如上图,运行时数据区包括五个部分,红色区域多个线程共享,灰色区域每一个线程独占,
在java API ,一个java虚拟机就对应一个Runtime类,一个Runtime就对应一个运行时数据区。
程序计数器用来存储指向下一条指令的地址,也即将要执行的指令代码,由执行引擎读取下一条指令。不会出现OutOfMemoryError情况,也没有GC(垃圾回收)
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return
0,2,3就是指令地址(偏移地址),就是pc寄存器存储的结构,bipush、istore_1是操作指令PC寄存器线程私有的原因:为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法就是每个线程都分配一个PC寄存器
每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧,对应着一次次的Java方法调用,是线程私有的,对于栈不存在垃圾回收问题的,但是存在OOM;Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。当线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常;如果Java虚拟机栈可以动态扩展,当线程没有申请到足够的虚拟机栈,Java虚拟栈将会抛出一个OutOfMemoryError异常。
发生StackOverflowError异常
public static void main(String[] args) {
main(args);
}
可以使用参数-Xss选项来设置线程的最大栈空间,例如-Xss265k
栈的存储单位为栈帧,线程上正在执行的一个方法对应一个栈帧,栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
栈帧的内部结构
每个栈帧包含五部分,分别包括局部变量表、操作数栈、动态链表(指向运行时常量池的方法引用)、方法返回地址和一些附加信息。
(1)局部变量表(local variables)
(1)局部变量表定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各种基本数据类型、对象引用(reference),以及returnAddress类型。
(2)由于局部变量表是线程的私有数据,不存在线程的安全问题
(3)局部变量表所需容量大小在编译器确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
public class Test {
public static void main(String[] args) {
int a = 1;
}
}
从上图,反编译后会看到局部变量表的maximum local variables为2,分别是参数args和a
(1)局部变量表的变量只在当前方法调用中有效的,当方法调用结束后,随着方法栈帧的消耗,局部变量表也会随之销毁。
(2)局部变量表的最基本存储单位是Slot(变量槽),32位以内的类型只占用一个slot(包括returnAddress类型,引用类型),64位的类型(long和double)占用两个slot。
(3)每一slot都分配一个访问索引,占用两个slot的变量,只需要使用前一个索引即可
(4)构造方法或者实例方法(非static),该对象引用this将会存放在index为0的slot处
(5)局部变量表的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
对于实例方法,如下图,可以看到this对象存放在index为0的位置
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
public void test1(){
int a = 0;
{
int b = 0;
b = a + 1;
}
int c = a + 1;
}
从上图可以看出, b在{}之后就出了作用域,此时在声明一个变量c会重复利用b的slot。
(2)操作数栈
(1)操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈或者出栈,使用数组实现
(2)操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
(3)操作数栈的栈深度在编译时就确定了,保存在方法的Code中max_stack值
(4)32位·类型占一个栈深度,64位类型占一个栈深度
(5)不能采用索引访问,被调方法带有返回值,其返回值将会被压入当期栈帧的操作数栈中
(6)Java虚拟机的解释是基于栈的执行引擎,其中栈指的就是操作数栈
public void test1(){
byte i = 15;
int j = 8;
int k = i + j;
}
bipush 15 将15加载到操作数栈
istore_1 将15从操作数栈存储到局部变量表下标为1的位置
bipush 8 将8加载到操作数栈
istore_2 将15从操作数栈存储到局部变量表下标为2的位置
iload_1 将局部变量表索引为1的位置的数据加载到操作数栈
iload_2 将局部变量表索引为2的位置的数据加载到操作数栈
iadd 把栈中数据8 和15 出栈进行求和操作后,在放回操作数栈
istore_3 将15从操作数栈存储到局部变量表下标为3的位置
return 方法结束
方法的返回值会被放入操作数栈
public void testGetSum(){
int i = getSum();
int j = 10;
}
从上图可以看到,第一条字节码指令就是aload_0就是将方法的返回值保存在操作数栈中
(3)动态链接
(1)每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现的动态链接。
(2)在java源文件被编译到字节码文件中,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
补充:方法的调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
如果被调用的方法在编译期无法被确定下来,也就是说,只能能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
在编译期确定的具体的调用版本,在运行时不变的方法是非虚方法(静态方法、私有方法、final方法、实例构造器、父类方法),其他方法就是虚方法。
虚拟机调用方法的指令
1.invokestatic:调用静态方法
2.invokespecial:调用
方法、私有以及父类方法 3.invokevirtual:调用所有虚方法
4.invokeinterface:调用接口方法
前两条指令调用的方法是非虚方法,后面两条指令调用的是虚方法,但是final修饰的方法使用invokevirtual调用,是非虚方法。
调用非虚方法展示
public class Test {
//invokestatic
public static void methodA(){
System.out.println("methodA....");
}
//invokespecial
private void methodB(){
System.out.println("methodB....");
}
//invokevirtual
public final void methodC(){
System.out.println("methodC...");
}
public static void main(String[] args) {
Test test = new Test();
methodA();
test.methodB();
test.methodC();
}
}
调用虚方法展示
public class Test {
static class Father{
public void fatherMethod(){
System.out.println("fatherMethod");
}
}
interface IFather{
void ifatherMethod();
}
static class Son extends Father{
public void show(){
fatherMethod();
IFather iFather = null;
iFather.ifatherMethod();
}
}
}
involvedynamic,先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
在使用Lambda表达式会使用这个指令。
public class Test {
public static void main(String[] args) {
String str = "e21e2";
Function function = str1 -> str.length();
function.apply(str);
}
}
interface Function{
int apply(String str);
}
(4)方法返回地址
方法返回地址存储的是调用该方法的pc寄存器的值。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
(5)附加信息
允许虚拟机实现增加一些规范里没有描述的信息到栈帧中,这部分信息与具体的虚拟机实现有关。
本地方法栈与虚拟机栈类似,唯一的区别是虚拟机栈是执行java方法的,本地方法栈是执行本地方法(native),Hot Spot虚拟机将本地方法栈和虚拟机栈合二为一。
(1)一个JVM实例只存在一个堆内存,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被看做是连续的。
(2)所有线程共享堆,在堆里还可以划分线程私有的缓冲区(TLAB)
(3)“几乎”所有的对象实例都在这里分配内存
堆的内存细分
现代垃圾收集器大部分都基于分代收集理论设计
Java 7及以前堆内存逻辑上分为三部分:新生区(新生代、年轻代)+养老区(老年区、老年代)+ 永久区
Java 8及以后堆内存逻辑上分为三部分:新生区+养老区+元空间,新生代又分为Eden区(伊甸区)和Survivor区(存活区)
逻辑上划分为三部分,其实堆实际不包含元空间,如下代码,在idea配置虚拟机参数
-Xms10m -Xmx10m,新生代+年老代=10m,没有包含元空间的(注意:下面内存查看是由使用idea的插件VisualGC所得的结果)
public static void main(String[] args) {
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
设置堆空间的大小
“-Xms”(-XX:InitialHeapSize)用于表示堆区的起始内存
“-Xmx”(-XX:MaxHeapSize)用于表示堆区的最大内存,一旦堆内存的大小超过了“-Xmx”的最大内存,会出现OutOfMemoryError异常
默认情况下,初始内存大小:物理电脑内存大小 / 64,最大内存大小:物理电脑内存大小 / 4
开发中推荐-Xms和-Xmx设置成一样的
查看设置参数:方式一:jps / jstat -gc 进程id
方式二:-XX:+PrintGCDetails
年轻代和老年代
java堆可以分为年轻代和老年代,年轻代又分为Een、Survivr0(from区)和Survivor1(to区)。
配置新生代和老年代在堆结构的占比,默认-XX:NewRatio=2,新生代占堆的1/3,老年代占堆的2/3
Eden和两个Survivor区空间所占的比例是8:1:1,可以通过-XX:SurvivorRatio配置
使用“-Xmn”设置新生代最大内存大小,当-XX:NewRatio和-Xmn都存在,以-Xmn为主(-Xmn100m)
对象分配的过程(如图)
(1)new对象先放到伊甸园,伊甸园空间填满,对该区的垃圾对象销毁,将剩余对象移到s0
(2)再次发生垃圾回收,s0区没有垃圾回收,就会放到s1
(3)每进一次新存区,年龄就加一,默认15次,到达15次,到16次会放到老年代
(4)可以设置参数:-XX:MaxTenuringThreshold=
进行设置 对于幸存者s0,s1区的总结:复制之后有交换,谁空谁是to
对于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区或者元空间收集。
对象太大可能会发生直接晋升到老年代,如下图
Minor GC(YGC)、Major GC 和Full GC
GC按照回收区域分:部分收集(Partial GC)和整堆收集(Full GC)
部分收集:不是完整收集整个JAVAV堆的垃圾收集,其中又分为:
新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
老年代收集(Major GC / Old GC):只是老年代的垃圾收集
整堆收集(Full GC):整个java堆和方法区的垃圾收集
Eden代满会触发GC,Survivor满不会引发GC
堆空间参数设置
堆是分配对象存储的唯一选择吗?
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配堆上也渐渐变得不那么“绝对”了。
当一个对象在方法被定义后,对象只在方法内部使用,则认为没有发生逃逸;若对象被外部方法所引用,则发生了逃逸。没有发生逃逸就栈上分配(逃逸要关注new 对象的实体发生)
HotSpot默认开启逃逸分析
因此,在开发中尽量使用局部变量,就不要使用在方法外定义
逃逸分析:代码优化
(1)栈上分配。将不会发生逃逸的对象在栈上分配
(2)同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
(3)分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
栈、堆和方法区的交换关系,如下图
方法区的理解
(1)方法区是各个线程共享的内存区域
(2)方法区在JVM启动的时候被创建,并且它的实际物理内存空间和Java堆区一样都可以是不连续的。
(3)方法区会发生内存溢出(OOM)
(4)元空间不在虚拟机设置的内存中,而是使用本地内存
设置方法区大小与OOM
jdk8的元空间大小使用参数-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定
元空间的默认值依赖于平台,windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
package com.hzq.chapter1;
import com.sun.xml.internal.ws.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;
/**
* .-XX:MetaspaceSize=5m -XX:MaxMetaspaceSize=5m
*/
public class OOMTest extends ClassLoader {
public static void main(String[] args) {
int j = 0;
try {
OOMTest test = new OOMTest();
for (int i = 0; i < 10000; i++) {
//创建classWriter对象,用于生成类的二进制字节码
ClassWriter classWriter = new ClassWriter(0);
//指明版本号,修饰符,类名,包名,父类,接口
classWriter.visit(Opcodes.V1_8,Opcodes.ACC_PUBLIC,"Class" + i,null,"java/lang/Object",null);
byte[] code = classWriter.toByteArray();
//类的加载
test.defineClass("Class" + i,code,0,code.length);
j++;
}
}finally {
System.out.println(j);
}
}
}
方法区的内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存、域信息等
类型信息:对每个加载的类型(class、interface,enum、annotation),jvm要存储类型信息有该类型和直接父类的完整有效名称(interface和Object没有父类)、该类型的修饰符和该类型直接接口的一个有序列表
域信息:JVM须在方法区保存类型的所有域的相关信息以及域的声明顺序
方法信息:方法名称、返回值类型,修饰符等
public class MethodAreaTest {
public static void main(String[] args) {
Order order = null;
order.hello();
System.out.println(order.count);
}
}
class Order{
public static int count = 1;
public static final int number = 2;
public final int a = 3;
public static void hello(){
System.out.println("hello!");
}
}
在idea终端输入javap -v -p Order.class > test.txt ,反编译Order.class,可以得出,此字节码文件所含的类型信息,域信息都会被类加载器加载后存储到方法区,存储在方法区的信息也包括类加载信息,被final修的变量在编译时会被赋值,从反编译的字节码文件ConstantValue:int 3 可以看出
上面图片中的常量池(Constant pool)可以看做一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池是方法区的一部分,常量池表是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部类加载后存放到方法区的运行时常量池中。
运行时常量池具备动态性,运行时常量池不仅包括编译期就明确的数值字面量,也包括运行期解析后才能获得的方法或者字段引用,此时不再是常量池中的符号地址了,已经是真实地址了。
方法区的演进细节
永久代或者元空间是方法区的实现方式,以前的永久代使用的jvm虚拟机,之后的元空间使用的是本地内存。
永久代替换元空间的原因:
(1)为永久代设置空间大小是很难确定的
(2)对永久代进行调优是很困难的
字符串常量池放在堆里的原因:在开发中会有大量的字符串被创建,如果放入永久代,只有full gc才会垃圾回收,使得回收效率低,导致永久代内存不足,故放到堆里。
方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
总结