- 1、java虚拟机的内存分区是如何的划分?
- 2、划分的分区的作用是什么?解决什么问题?
- 3、java对象的创建的过程?
- 4、OOM内存溢出异常是如何产生的?
java运行时数据区
java虚拟机会将管理的内存划分为若干个不同的数据区域(有程序计数器、虚拟机栈、本地方法栈、方法区、堆),如下图
- 程序计数器、虚拟机栈、本地方法栈是线程隔离的,即每个线程都拥有自己的虚拟机栈、本地方法栈、程序计数器
- 方法区、堆这两块内存是所有线程间共享的
2、程序计数器
2.1)什么是程序计数器
程序计数器是一个记录着当前线程所执行的字节码的行号指示器。
JAVA代码编译后的字节码在未经过JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。
从上面的描述中,可能会产生程序计数器是否是多余的疑问。因为沿着指令的顺序执行下去,即使是分支跳转这样的流程,跳转到指定的指令处按顺序继续执行是完全能够保证程序的执行顺序的。假设程序永远只有一个线程,这个疑问没有任何问题,也就是说并不需要程序计数器。但实际上程序是通过多个线程协同合作执行的。
首先我们要搞清楚JVM的多线程实现方式。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。
2.2)程序计数器的特点
- 1.线程隔离性,每个线程工作时都有属于自己的独立计数器。
- 2.执行java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址(参考上一小节的描述)。
- 3.执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。如下图
- 4.程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。
- 5.程序计数器,是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的区域。
3、Java虚拟机栈
3.1、什么是虚拟机栈
- 虚拟机栈是用于描述java方法执行的内存模型。
- 方法调用时,创建栈帧,并压入虚拟机栈;方法执行完毕,栈帧出栈并被销毁,如下图所示:
3.2、栈帧
- 每个java方法在执行时,会创建一个“栈帧(stack frame)”,栈帧的结构分为“局部变量表、操作数栈、动态链接、方法出口”几个部分(具体的作用会在字节码执行引擎章节中讲到,这里只需要了解栈帧是一个方法执行时所需要数据的结构)。
- 我们常说的“堆内存、栈内存”中的“栈内存”指的便是虚拟机栈,确切地说,指的是虚拟机栈的栈帧中的局部变量表,因为这里存放了一个方法的所有局部变量。
3.2.1、局部变量表
- 用于存储方法参数和方法内部定义的局部变量,在Java程序编译成Class文件的时候,就在Code属性中的max_locals数据项中确定了该方法所要分配的局部变量表的最大容量。
- 局部变量区被组织为以一个slot(变量槽)为单位、从0开始计数的数组。虚拟机规范中并没有明确规定slot的大小,只是说明每个slot都应该能存放一个boolean、byte、char、short、int、float、reference、或returnAddress类型的数据。
- 类型为short、byte和char的值在存入数组前要被转换成int值,而long和 double在数组中占据连续的两项,在访问局部变量中的long或double时,只需取出连续两项的第一项的索引值即可,如某个long值在局部变量区中占据的索引是3、4项,取值时,指令只需取索引为3的long值即可。
举例说明
public static int runClassMethod(int i,long l,float f,double d,Object o,byte b {
return 0;
}
public int runInstanceMethod(char c,double d,short s,boolean b) {
return 0;
}
首先,可以看到虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的。并且runInstanceMethod(实例方法)的局部变量区第一项是个reference(引用),它指定的就是对象本身的引用,也就是我们常用的this,但是在RunClassMethod方法中,没这个引用,那是因为runClassMethod是个静态方法。
关于reference类型,代表的是一个对象实例的引用,这个引用应当可以做到两点:
1.从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引。
2.从此引用中直接或间接的查找到对象所属类型在方法区中存储的类型信息。
- slot的复用:为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。当离开了某些变量的作用域之后,这些变量对应的 Slot 就可以交给其他变量使用。这种机制有时候会影响垃圾回收行为。
举例说明
代码一
public static void main(String[] args){
{
byte[] placeholder = new byte[64*1024*1024];
}
System.gc();
}
运行结果:
[GC 602K->378K(15872K), 0.0603803 secs]
[Full GC 378K->378K(15872K), 0.0323107 secs]
[Full GC 66093K->65914K(81476K), 0.0074124 secs]
代码二
public static void main(String[] args){
{
byte[] placeholder = new byte[64*1024*1024];
}
int a = 0;
System.gc();
}
运行结果
[GC 602K->378K(15872K), 0.0018270 secs]
[Full GC 378K->378K(15872K), 0.0057871 secs]
[Full GC 66093K->378K(81476K), 0.0054067 secs]
分析:通过结果可以知道,代码一和代码二内的 placeholder 变量在 System.gc() 执行后理应被回收了,可是结果却是只有代码二被回收了,这是为什么呢?
这是因为代码一中 placeholder 虽然离开了作用域,但之后没有任何局部变量对其进行读写,也就是说其占用的 Slot 没有被复用,也就是说 placeholder 占用的内存仍然有引用指向它,因而它没有被回收。而代码二中的变量a由于复用了 placeholder 的 Slot ,导致 placeholder 引用被删除,因此占用的内存空间被回收。
《Practical Java》一书中把”不使用的对象应手动赋值为 null “作为一条推荐的编码规则,这并不是一个完全没有意义的操作。但是不应该对 赋 null 值有过多的依赖,主要有两点原因:
- 从编码的角度来讲,用恰当的变量作用域来控制变量的回收才是最优雅的解决方法。
- 从执行角度将,使用赋值 null 的操作优化内存回收是建立在对字节码执行引擎概念模型基础上的,但是概念模型与实际执行模型可能完全不同。在使用解释器执行时,通常离概念模型还比较接近,但是一旦经过JIT 编译为本地代码才是虚拟机执行代码的主要方式,赋 null 值在JIT编译优化之后会被完全消除,这时候赋 null 值是完全没有意义的。(其实,上面代码一在 JIT 编译为本地代码之后,gc() 之后内存也会被自动回收)
3.2.2、操作数栈
- 与局部变量表一样,均以字长为单位的数组。不过局部变量表用的是索引,操作数栈是弹栈/压栈来访问。操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区。
- 存储的数据与局部变量表一致含int、long、float、double、reference、returnType,操作数栈中byte、short、char压栈前(bipush)会被转为int。
- 数据运算的地方,大多数指令都在操作数栈弹栈运算,然后结果压栈。
- java虚拟机栈是方法调用和执行的空间,每个方法会封装成一个栈帧压入*。其中里面的操作数栈用于进行运算,当前线程只有当前执行的方法才会在操作数栈中调用指令(可见java虚拟机栈的指令主要取于操作数栈)。
- int类型在-1~5、-128~127、-32768~32767、-2147483648~2147483647范围分别对应的指令是iconst、bipush、sipush、ldc(这个就直接存在常量池了)
举例说明:
public class Hello {
public static void main(String[] args) {
Hello hello = new Hello();
int testVal = hello.test();
}
public int test() {
//int类型
int a = 5 + 10; //验证直接相加在编译阶段已合并完结果
int b = a + 3; //探究变量与常量的相加过程
b = b + 6; //验证int不在-1~5,在-128~127范围的指令是bipush
b = b + 128; //验证int不在-128~127,在-32768~32767范围的指令是sipush
b = b + 32768; //验证int不在-32768~32767,在-2147483648~2147483647范围的指令是ldc(ldc:从常量池取并压栈,所以这个范围的int是存在常量池)
//short //验证byte、short、char在操作数栈压栈前均会转为int
short a_s = 5 + 10;
short b_s = (short)(a_s + 3);
//float类型 //以下验证float、double、String均是从常量池中取出(均使用了ldc、ldc_w、ldc2_w其中一个)
float a_f = 5.00F + 10.00F;
float b_f = a_f + 3.00F;
//double类型
double a_d = 5.00D + 10.00D;
double b_d = a_d + 3.00D;
//String类型
String a_str = "a" + "b";
String b_str = a_str + "c";
return b;
}
}
javac Hello.java编译,然后javap -verbose Hello.class反编译分析test()方法如下:
Code:
stack=4, locals=13, args_size=1
0: bipush 15 //1 15压入操作数的栈顶(编译过程中5+10合并成15,并且由于15在-128-127范围,即用bipush) 压栈
2: istore_1 //2 从栈顶弹出并压入局部变量表访问索引为1的Slot 弹栈入局部变量表
3: iload_1 //3 将局部变量表中访问索引为1的Slot重新压入栈顶 局部变量表入栈
4: iconst_3 //4 数值3压入操作数的栈顶(范围-1~5,即用指令iconst) 压栈
5: iadd //5 将栈顶的前两个弹出并进行加法运算后将结果重新压入栈顶 前两弹栈相加
6: istore_2 //6 从栈顶弹出并压入局部变量表访问索引为2的Slot 弹栈入局部变量表
7: iload_2 //7 将局部变量表中访问索引为2的Slot重新压入栈顶 局部变量表入栈
8: bipush 6 //8 6压入操作数的栈顶(在-128-127范围,用bipush指令)
10: iadd
11: istore_2
12: iload_2
13: sipush 128//9 128压入操作数的栈顶(在-32768~32767范围,用sipush指令)
16: iadd
17: istore_2
18: iload_2
19: ldc #5 // int 32768 //10 128压入操作数的栈顶(在-2147483648~2147483647范围,用ldc指令)
21: iadd
22: istore_2
23: bipush 15 //11 验证了short、byte、char压栈前都会转为int
25: istore_3
26: iload_3
27: iconst_3
28: iadd
29: i2s
30: istore 4
32: ldc #6 // float 15.0f //12 以下验证float、double、String均是从常量池中取出(均使用了ldc、ldc_w、ldc2_w其中一个)
34: fstore 5
36: fload 5
38: ldc #7 // float 3.0f
40: fadd
41: fstore 6
43: ldc2_w #8 // double 15.0d
46: dstore 7
48: dload 7
50: ldc2_w #10 // double 3.0d
53: dadd
54: dstore 9
56: ldc #12 // String ab
58: astore 11
60: new #13 // class java/lang/StringBuilder
63: dup
64: invokespecial #14 // Method java/lang/StringBuilder."<init>":()V
67: aload 11
69: invokevirtual #15 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
72: ldc #16 // String c
74: invokevirtual #15 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
77: invokevirtual #17 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
80: astore 12
82: iload_2
83: ireturn //13 返回结果
本地方法栈
与java虚拟机栈类似,只是描述的是native方法
java堆
java虚拟机管理的最大的一块内存,被所有线程共享,几乎所有的对象实例都在这里分配内存。
方法区
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
直接内存
NIO支持引入,通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,避免了在java堆和native堆中来回复制数据,可以显著提供性能。
可以通过-XX:MaxDirectMomorySize 指定直接内存大小,如果不指定,则默认与-Xmx指定值一样。
对象
对象的创建
1、遇到new指令,首先将去检查常量池中类的符号引用,并检测类是否已被加载、解析和初始化过。
2、分配内存(对象的内存分配在类加载完后便可以完全确定)。分配内存有两种方案:一种是对分配内存空间的动作进行同步处理-实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分不同的空间中进行,即每个线程分配一小块内存,称为TLAB,只有在TLAB用完并分配新的TLAB时,才需要同步锁定。通过-XX:+/-UseTLAB参数设定
3、将将要分配的内存都初始化为0值
4、设置对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息
5、执行init方法进行初始化