运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都各有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范(Java SE 8版)》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域。如图:
参考
- 深入理解Java虚拟机
- Java虚拟机规范(Java SE 8版)
特点
- 程序计数器是一个以线程私有的一块较小的内存空间,用于记录所属线程所执行的字节码的行号指示器;字节码解释器工作时,通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳准、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。
- 在多线程中,就会存在线程上下文切换(CPU 时间片[1])执行,为了线程切换后能恢复正确的执行位置,所以需要从程序计数器中获取该线程需要执行的字节码的偏移地址(简单来说,可以先理解为执行的代码行号,但实际并不是所看到的代码行号,后续学习了字节码指令即明白了)。程序计数器是具备线程隔离性,每个线程工作时都有属于自己的独立程序计数器。
- 如果线程执行 Java 方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果执行 Navtive 方法,程序计数器值则为空(Undefined)。因为 Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。由于该方法是通过 C/C++ 而不是 Java 进行实现。那么自然无法产生相应的字节码,并且 C/C++ 执行时的内存分配是由自己语言决定的,而不是由 JVM 决定的。
Java 方法调用 - 由于是线程私有的,生命周期随着线程,线程启动而产生,线程结束而消亡。
- Java 虚拟机规范里面, 唯一 一个没有规定任何 OutOfMemoryError 情况的区域,由于保存的是线程需要执行的字节码的偏移地址,当执行下一条指令的时候,改变的只是程序计数器中保存的地址,并不需要申请新的内存来保存新的指令地址,因此,不会产生内存溢出。
答疑
可能有人对字节码的偏移地址有所困惑,因为这个属于字节码指令的知识范畴,这里就简单举例让大家先了解一下:
public int test() {
int x = 0;
int y = 1;
return x + y;
}
这段代码转化成字节码指令又是这样子的呢?可以使用 javap -v 命令执行该类,生成出来的字节码指令如下:
public int test();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: iconst_0
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: ireturn
LineNumberTable:
line 7: 0
line 8: 2
line 9: 4
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 this Lcom/alibaba/uc/TestClass;
2 6 1 x I
4 4 2 y I
以上只是这个方法的字节码指令,但是,我们重点所看的程序计数器所记录的值是:如 7: ireturn
操作指令中的 7 即为偏移地址。
偏移地址: 操作指令
0: iconst_0
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: ireturn
- CPU 时间片
CPU 时间片即CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,则 CPU 将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换。而不会造成 CPU 资源浪费。在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但在微观上:由于只有一个 CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
特点
- Java 虚拟机栈(Java Virtual Machine Stacks)是线程私有的,生命周期随着线程,线程启动而产生,线程结束而消亡。
- Java 虚拟机栈描述的是 Java 方法执行的内存模型,用于存储栈帧。线程启动时会创建虚拟机栈,每个方法在执行时会在虚拟机栈中创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法返回地址、附加信息等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈(压栈)到出栈(弹栈)的过程。
- Java 虚拟机栈使用的内存不需要保证是连续的。
- Java 虚拟机规范即允许 Java 虚拟机栈被实现成固定大小(
-Xss
),也允许通过计算结果动态来扩容和收缩大小。如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候就已经确定。
Java 虚拟机栈会出现的异常
- 如果线程请求分配的栈容量超过了 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出 StackOverflowError 异常。
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将抛出一个 OutOfMemoryError 异常。
Java 虚拟机栈执行过程
栈帧(Stack Frame)
- 栈帧存在于 Java 虚拟机栈中,是 Java 虚拟机栈中的单位元素,每个线程中调用同一个方法或者不同的方法,都会创建不同的栈帧(可以简单理解为,一个线程调用一个方法创建一个栈帧),所以,调用的方法链越多,创建的栈帧越多(代表作:递归)。在 Running 的线程,只有当前栈帧有效(Java 虚拟机栈中栈顶的栈帧),与当前栈帧相关联的方法称为当前方法。每调用一个新的方法,被调用方法对应的栈帧就会被放到栈顶(入栈),也就是成为新的当前栈帧。当一个方法执行完成退出的时候,此方法对应的栈帧也相应销毁(出栈)。
栈帧结构如图:
栈帧结构
局部变量表(Local Variable Table)
- 每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译成 Class 文件时,在 Class 文件格式属性表中 Code 属性的 max_locals(局部变量表所需的存储空间,单位是 Slot) 数据项中确定了需要分配的局部变量表的最大容量。
- 局部变量表的容量以变量槽(Variable Slot)为最小单位,不过 Java 虚拟机规范中并没有明确规定每个 Slot 所占据的内存空间大小,只是有导向性地说明每个 Slot 都应该存放的8种类型: byte、short、int、float、char、boolean、reference(对象引用就是存到这个栈帧中的局部变量表里的,这里的引用指的是局部变量的对象引用,而不是成员变量的引用。成员变量的对象引用是存储在 Java 堆(Heap)中)、returnAddress(虚拟机数据类型,Sun JDK 1.4.2版本之前使用
jsr/ret
指令用于进行异常处理,后续版本已废弃这种实现方式,目前使用异常处理器表代替)类型的数据,这8种类型的数据,都可以使用32位或者更小的空间去存储。Java 虚拟机规范允许 Slot 的长度可以随着处理器、操作系统或者虚拟机的不同而发生变化。对于64位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的 Slot 空间。即 long 和 double 两种类型。做法是将 long 和 double 类型速写分割为32位读写的做法。不过由于局部变量表建立在线程的堆栈上,是线程的私有数据,无论读写两个连续的 Slot 是否是原子操作,都不会引起数据安全问题。 - Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的 Slot 数量。如果是32位数据类型的数据,索引 n 就表示使用第 n 个 Slot,如果是64位数据类型的变量,则说明要使用第 n 和第 n+1 两个 Slot。
- 在方法执行过程中,Java 虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程。如果是实例方法(非
static
方法),那么局部变量表中的第0位索引的 Slot 默认是用来传递方法所属对象实例的引用,在方法中可以通过关键字 this
来访问这个隐含的参数。其余参数按照参数表的顺序来排列,占用从1开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。 - 局部变量表中的 Slot 是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码程序计数器的值已经超过了某个变量的作用域,那么这个变量相应的 Slot 就可以交给其他变量去使用,节省栈空间,但也有可能会影响到系统的垃圾收集行为。
- 局部变量无初始值(实例变量和类变量都会被赋予初始值),类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予开发者定义的值。因此即使在初始化阶段开发者没有为类变量赋值也没有关系,类变量仍然具有一个确定的默认值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。
使用一段代码说明一下局部变量表:
// java 代码
public int test() {
int x = 0;
int y = 1;
return x + y;
}
// javac 编译后的字节码,使用 javap -v 查看
public int test();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: iconst_0
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: ireturn
LineNumberTable:
line 7: 0
line 8: 2
line 9: 4
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 this Lcom/alibaba/uc/TestClass;
2 6 1 x I
4 4 2 y I
对应上面的解释说明,通过 LocalVariableTable 也可以看出来:
Code 属性:
stack(int x(1个栈深度)+ int y(1个栈深度))=2, locals(this(1 Slot)+ int x(1 Slot)+ int y(1 Slot))=3, args_size(非 static 方法,this 隐含参数)=1
验证 Slot 复用,运行以下代码时,在 VM 参数中添加 -verbose:gc
:
public void test() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0; // 当这段代码注释掉时,System.gc() 执行后,也并不会回收这64MB内存。当这段代码执行时,内存被回收了
System.gc();
}
局部变量表中的 Slot 是否还存在关于 placeholder 数组对象的引用。当 int a = 0;
不执行时,代码虽然已经离开了 placeholder 的作用域,但是后续并没有任何对局部变量表的读写操作,placeholder 原本所占用的 Slot 还没有被其他变量所复用,所以 placeholder 作为 GC Roots(所有 Java 线程当前活跃的栈帧里指向 Java 堆里的对象的引用) 仍然是可达对象。当 int a = 0;
执行时,placeholder 的 Slot 被变量 a 复用,所以 GC 触发时,placeholder 变成了不可达对象,即可被 GC 回收。
操作数栈(Operand Stack)
- 操作数栈是一个后入先出(Last In First Out)栈,方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程。
- 同局部变量表一样,操作数栈的最大深度也是Java 程序编译成 Class 文件时被写入到 Class 文件格式属性表的 Code 属性的 max_stacks 数据项中。
- 操作数栈的每一个元素可以是任意的 Java 数据类型,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2,在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值(指的是进入操作数栈的 “同一批操作” 的数据类型的栈容量的和)。
- 当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,通过一些字节码指令从局部变量表或者对象实例字段中复制常量或者变量值到操作数栈中,也提供一些指令向操作数栈中写入和提取值,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。例如,整数加法的字节码指令
iadd
(使用 iadd
指令时,相加的两个元素也必须是 int 型) 在运行的时候将操作数栈中最接近栈顶的两个 int 数值元素出栈相加,然后将相加结果入栈。
以下代码会以什么形式进入操作数栈?
// java 代码
public void test() {
byte a = 1;
short b = 1;
int c = 1;
long d = 1L;
float e = 1F;
double f = 1D;
char g = 'a';
boolean h = true;
}
// 字节码指令
0: iconst_1 // 把 a 压入操作数栈栈顶
1: istore_1 // 将栈顶的 a 存入局部变量表索引为1的 Slot
2: iconst_1 // 把 b 压入操作数栈栈顶
3: istore_2 // 将栈顶的 b 存入局部变量表索引为2的 Slot
4: iconst_1 // 把 c 压入操作数栈栈顶
5: istore_3 // 将栈顶的 c 存入局部变量表索引为3的 Slot
6: lconst_1 // 把 d 压入操作数栈栈顶
7: lstore 4 // 将栈顶的 d 存入局部变量表索引为4的 Slot,由于 long 是64位,所以占2个 Slot
9: fconst_1 // 把 e 压入操作数栈栈顶
10: fstore 6 // 将栈顶的 e 存入局部变量表索引为6的 Slot
12: dconst_1 // 把 f 压入操作数栈栈顶
13: dstore 7 // 将栈顶的 f 存入局部变量表索引为4的 Slot,由于 double 是64位,所以占2个 Slot
15: bipush 97 // 把 g 压入操作数栈栈顶
17: istore 9 // 将栈顶的 g 存入局部变量表索引为9的 Slot
19: iconst_1 // 把 h 压入操作数栈栈顶
20: istore 10 // 将栈顶的 h 存入局部变量表索引为10的 Slot
从上面字节码指令可以看出来,除了 long、double、float 类型使用的字节码指令不是 iconst
和 istore
,其他类型都是使用这两个字节码指令操作,说明 byte、short、char、boolean 进入操作数栈时,都会被转化成 int 型。
- 在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机实现会做一些优化,令两个栈帧出现一部分重叠。让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递。
栈帧共享 - Java 虚拟机的解释执行引擎称为 “基于栈的执行引擎”,其中所指的 “栈” 就是操作数栈。
动态连接(Dynamic Linking)
- 每个栈帧都包含一个指向运行时常量池(JVM 运行时数据区域)中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
- 在 Class 文件格式的常量池(存储字面量和符号引用)中存有大量的符号引用(1.类的全限定名,2.字段名和属性,3.方法名和属性),字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载过程的解析阶段的时候转化为直接引用(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。
看看以下代码的 Class 文件格式的常量池:
// java 代码
public Test test() {
return new Test();
}
// 字节码指令
Constant pool:
#1 = Methodref #4.#19 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#20 // com/alibaba/uc/Test.i:I
#3 = Class #21 // com/alibaba/uc/Test
#4 = Class #22 // java/lang/Object
#5 = Utf8 i
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/alibaba/uc/Test;
#14 = Utf8 test
#15 = Utf8 ()I
#16 = Utf8 <clinit>
#17 = Utf8 SourceFile
#18 = Utf8 Test.java
#19 = NameAndType #7:#8 // "<init>":()V
#20 = NameAndType #5:#6 // i:I
#21 = Utf8 com/alibaba/uc/Test
#22 = Utf8 java/lang/Object
public int test();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: getstatic #2 // Field i:I
3: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Lcom/alibaba/uc/Test;
从上面字节码指令看出 0: getstatic #2 // Field i:I
这行字节码指令指向 Constant pool 中的 #2,而 #2 中指向了 #3 和 #20 为符号引用,在类加载过程的解析阶段会被转化为直接引用(指向方法区的指针)。
方法返回地址
- 当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:
ireturn
),这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。 - 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用
athrow
字节码指令产生的异常,只要在本方法的异常处理器表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。 - 无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的程序计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
- 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。
简述:
虚拟机会使用针对每种返回类型的操作来返回,返回值将从操作数栈出栈并且入栈到调用方法的方法栈帧中,当前栈帧出栈,被调用方法的栈帧变成当前栈帧,程序计数器将重置为调用这个方法的指令的下一条指令。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。
特点
- 本地方法栈(Native Method Stacks)与 Java 虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。
- Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。
Java 方法调用 - 本地方法栈是一个后入先出(Last In First Out)栈。
- 由于是线程私有的,生命周期随着线程,线程启动而产生,线程结束而消亡。
- 本地方法栈会抛出 StackOverflowError 和 OutOfMemoryError 异常。
特点
- Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块,也被称为 “GC堆”,是被所有线程共享的一块内存区域,在虚拟机启动时被创建。
- 唯一目的就是储存对象实例和数组(JDK7 已把字符串常量池和类静态变量移动到 Java 堆),几乎所有的对象实例都会存储在堆中分配。随着 JIT 编译器发展,逃逸分析、栈上分配、标量替换等优化技术导致并不是所有对象都会在堆上分配。
- Java 堆是垃圾收集器管理的主要区域。堆内存分为新生代( Young ) 和老年代( Old) ,新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。
堆默认内存划分 - 从内存分配的角度看,线程共享的 Java 堆中可能划分出多个线程私有的线程本地分配缓存区(Thread Local Allocation Buffer,TLAB)。
- 根据Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过
-Xmx
和 -Xms
控制)。
Java 堆会出现的异常
- 如果 Java 堆可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,那 Java 虚拟机将抛出一个 OutOfMemoryError 异常。
运行时数据区
运行时数据区
JIT 编译器
即时编译器(Just-in-time Compilation,JIT)
- Java 程序最初是通过解释器来解释执行的,当虚拟器发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译为机器码,并进行各种层次的优化,完成这个任务的编译器成为即使编译器(JIT)。
- 在 HotSpot 实现中有多种选择:C1、C2 和 C1 + C2,分别对应 client、server 和分层编译。
1、C1 编译速度快,优化方式比较保守;
2、C2 编译速度慢,优化方式比较激进;
3、C1 + C2 在开始阶段采用 C1 编译,当代码运行到一定热度之后采用 C2 重新编译;
在 JDK8 之前,分层编译默认是关闭的,可以添加 -server -XX:+TieredCompilation
参数进行开启。
JIT 工作原理图
什么是热点代码
- 被多次调用的方法:方法调用的多了,代码执行次数也多,成为热点代码很正常。
- 被多次执行的循环体:假如一个方法被调用的次数少,只有一次或两次,但方法内有个循环,一旦涉及到循环,部分代码执行的次数肯定多,这些多次执行的循环体内代码也被认为“热点代码”。
如何检测热点代码
- 基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。
缺点:不够精确,容易受到线程阻塞或外界因素的影响
优点:实现简单、高效,很容易获取方法调用关系 - 基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。
缺点:实现麻烦,不能直接获取方法的调用关系
优点:统计结果精确
HotSpot 虚拟器为每个方法准备了两类计数器:方法调用计数器和回边计数器,两个计数器都有一定的阈值,超过阈值就会触发JIT 编译。
-XX:CompileThreshold
可以设置阈值大小,Client 编译器模式下,阈值默认的值1500,而 Server 编译器模式下,阈值默认的值则是10000。
方法调用计数器
回边计数器
逃逸分析
- 逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸。
- 可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
对象的三种逃逸状态
- GlobalEscape(全局逃逸) 一个对象的引用逃出了方法或者线程。例如,一个对象的引用是复制给了一个类变量,或者存储在在一个已经逃逸的对象当中,或者这个对象的引用作为方法的返回值返回给了调用方法。
- ArgEscape(参数逃逸) 在方法调用过程中传递对象的引用给调用方法,这种状态可以通过分析被调方法的二进制代码确定。
- NoEscape(没有逃逸) 一个可以进行标量替换的对象,可以不将这种对象分配在堆上。
private Object o;
/**
* 给全局变量赋值,发生逃逸(GlobalEscape)
*/
public void globalVariablePointerEscape() {
o = new Object();
}
/**
* 方法返回值,发生逃逸(GlobalEscape)
*/
public Object methodPointerEscape() {
return new Object();
}
/**
* 实例引用传递,发生逃逸(ArgEscape)
*/
public void instancePassPointerEscape() {
Object o = methodPointerEscape();
}
/**
* 没有发生逃逸(NoEscape)
*/
public void noEscape() {
Object o = new Object();
}
配置逃逸分析
- 开启逃逸分析,对象没有分配在堆上,没有进行GC,而是把对象分配在栈上。 (
-XX:+DoEscapeAnalysis
开启逃逸分析(JDK8 默认开启,其它版本未测试) ) - 关闭逃逸分析,对象全部分配在堆上,当堆中对象存满后,进行多次GC,导致执行时间大大延长。堆上分配比栈上分配慢上百倍。(
-XX:-DoEscapeAnalysis
关闭逃逸分析) - 可以通过
-XX:+PrintEscapeAnalysis
查看逃逸分析的筛选结果。
标量替换
- 标量和聚合量
标量即不可被进一步分解的量,而 Java 的基本数据类型就是标量(如:int,long 等基本数据类型以及 reference 类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在 Java 中对象就是可以被进一步分解的聚合量。 - 替换过程
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或 CPU 寄存器上分配空间。
栈上分配
栈上分配的技术基础是逃逸分析和标量替换。使用逃逸分析确认方法内局部变量对象(未发生逃逸,线程私有的对象,指的是不可能被其他线程访问的对象)不会被外部访问,通过标量替换将该对象分解在栈上分配内存,不用在堆中分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。方法执行完后自动销毁,而不需要垃圾回收的介入,减轻 GC 压力,从而提高系统性能。
使用场景:对于大量的零散小对象,栈上分配提供了一种很好的对象分配策略,栈上分配的速度快,并且可以有效地避免垃圾回收带来的负面的影响,但由于和堆空间相比,栈空间比较小,因此对于大对象无法也不适合在栈上进行分配。
测试栈上分配:
public static void alloc() {
byte[] b = new byte[2];
b[0] = 1;
}
public static void main(String[] args) {
long timeMillis = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
// 开启使用栈上分配执行时间 6 ms左右
// 关闭使用栈上分配执行时间 900 ms左右
System.out.println(System.currentTimeMillis() - timeMillis);
}
- 开启使用栈上分配(JDK8 默认开启,其它版本未测试),
-XX:+DoEscapeAnalysis
表示启用逃逸分析,栈上分配依赖于 JVM 逃逸分析结果。 - 禁止使用栈上分配,
-XX:-DoEscapeAnalysis
表示禁用逃逸分析。
注意:如果使用 idea 等工具测试,需使用 Run 执行
同步消除
线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。(锁和锁块内的对象不会逃逸出线程就可以把这个同步块取消)
测试同步消除:
public static void alloc() {
byte[] b = new byte[2];
synchronized (b) {
b[0] = 1;
}
}
public static void main(String[] args) {
long timeMillis = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
// 开启使用同步消除执行时间 10 ms左右
// 关闭使用同步消除执行时间 3870 ms左右
System.out.println(System.currentTimeMillis() - timeMillis);
}
- 开启同步消除 (
-XX:+EliminateLocks
(JDK8 默认开启,其它版本未测试) ) - 关闭同步消除(
-XX:-EliminateLocks
)
注意:如果使用 idea 等工具测试,需使用 Run 执行
TLAB
- TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区。这是线程私有的,在新生代 Eden 区分配内存区域,默认是开启的,也可以通过
-XX:+UseTLAB
开启。TLAB 的内存非常小,默认设定为占用新生代的1%,可以通过 -XX:TLABWasteTargetPercent
设置 TLAB 占用 Eden Space 空间大小。 - 由于对象一般会分配在堆上,而堆是全局共享的。同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步,而在竞争激烈的场合内存分配的效率又会进一步下降。JVM 使用 TLAB 来避免多线程冲突,每个线程使用自己的 TLAB,这样就保证了不使用同步,提高了对象分配的效率。
- 由于 TLAB 空间一般不会很大,因此大对象无法在 TLAB 上进行分配,总是会直接分配在堆上。TLAB 空间由于比较小,因此很容易装满。比如,一个100K的空间,已经使用了80KB,当需要再分配一个30KB的对象时,肯定就无能为力了。这时虚拟机会有两种选择,第一,废弃当前 TLAB,这样就会浪费20KB空间;第二,将这30KB的对象直接分配在堆上,保留当前的 TLAB,这样可以希望将来有小于20KB的对象分配请求可以直接使用这块空间。实际上虚拟机内部会维护一个叫作 refill_waste 的值,当请求对象大于 refill_waste 时,会选择在堆中分配,若小于该值,则会废弃当前 TLAB,新建 TLAB 来分配对象。这个阈值可以使用
-XX:TLABRefillWasteFraction
来调整,它表示 TLAB 中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的 TLAB 空间作为 refill_waste。默认情况下,TLAB 和 refill_waste 都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整 TLAB 的大小,可以使用 -XX:-ResizeTLAB
禁用,并使用 -XX:TLABSize
手工指定一个 TLAB 的大小。-XX:+PrintTLAB
可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。
扩展
虚拟机对象分配流程:首先如果开启栈上分配,JVM 会先进行栈上分配,如果没有开启栈上分配或则不符合条件的则会进行 TLAB 分配,如果 TLAB 分配不成功,再尝试在 Eden 区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。
虚拟机对象分配流程
特点
- 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
- JDK7 之前(永久代)用于存储已被虚拟机加载的类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。
- Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是 String.intern() 方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
Java 中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte、Short、Integer、Long、Character、Boolean,另外Float 和 Double类型的包装类则没有实现。另外Byte、Short、Integer、Long、Character这5种整型的包装类也只是在对应值在-128到127之间时才可使用对象池。
运行时常量池
- 实现区域
永久代:存储包括类信息、常量、字符串常量、类静态变量、即时编译器编译后的代码等数据。可以通过 -XX:PermSize
和 -XX:MaxPermSize
来进行调节。当内存不足时,会导致 OutOfMemoryError 异常。JDK8 彻底将永久代移除出 HotSpot JVM,将其原有的数据迁移至 Java Heap 或 Native Heap(Metaspace),取代它的是另一个内存区域被称为元空间(Metaspace)。
元空间(Metaspace):元空间是方法区的在 HotSpot JVM 中的实现,方法区主要用于存储类信息、常量池、方法数据、方法代码、符号引用等。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。,理论上取决于32位/64位系统内存大小,可以通过 -XX:MetaspaceSize
和 -XX:MaxMetaspaceSize
配置内存大小。
随JDK版本变迁的方法区
JDK6
- Klass 元数据信息
- 每个类的运行时常量池(字段、方法、类、接口等符号引用)、编译后的代码
- 静态字段(无论是否有final)在 instanceKlass 末尾(位于 PermGen 内)
- oop(Ordinary Object Pointer(普通对象指针)) 其实就是 Class 对象实例
- 全局字符串常量池 StringTable,本质上就是个 Hashtable
- 符号引用(类型指针是 SymbolKlass)
JDK7
- Klass 元数据信息
- 每个类的运行时常量池(字段、方法、类、接口等符号引用)、编译后的代码
- 静态字段从 instanceKlass 末尾移动到了 java.lang.Class 对象(oop)的末尾(位于 Java Heap 内)
- oop 与全局字符串常量池移到 Java Heap 上
- 符号引用被移动到 Native Heap 中
JDK8
- 移除永久代
- Klass 元数据信息
- 每个类的运行时常量池、编译后的代码移到了另一块与堆不相连的本地内存 -- 元空间(Metaspace)
关于 Open JDK 移除永久代的相关信息:
类信息
方法区 | 详细信息 |
类型信息 | 1. 类型的全限定名 2. 超类的全限定名 3. 直接超接口的全限定名 4. 类型标志(该类是类类型还是接口类型) 5. 类的访问描述符(public、private、default、abstract、final、static) |
类型的常量池 | 存放该类型所用到的常量的有序集合,包括直接常量(如字符串、整数、浮点数的常量)和对其他类型、字段、方法的符号引用。常量池中每一个保存的常量都有一个索引,就像数组中的字段一样。因为常量池中保存中所有类型使用到的类型、字段、方法的符号引用,所以它也是动态连接(栈中对应的方法指向这个引用)的主要对象(在动态链接中起到核心作用)。 |
字段信息 | 1. 字段修饰符(public、protect、private、default) 2. 字段的类型 3. 字段名称 |
方法信息 | 1. 方法名 2.方法的返回类型(包括void)3. 方法参数的类型、数目以及顺序 4. 方法修饰符(public、private、protected、static、final、synchronized、native、abstract) 5. 针对非本地方法,还有些附加方法信息需要存储在方法区中(局部变量表大小和操作数栈大小、方法体字节码、异常表) |
类变量(静态变量) | 指该类所有对象共享的变量,即使没有创建该对象实例,也可以访问的类变量。它们与类进行绑定 |
指向类加载器的引用 | JVM 必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么 JVM 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。JVM 在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,JVM 需要保证这两个类型的类加载器是相同的。这对 JVM 区分名字空间的方式是至关重要的。 |
指向 Class 实例的引用 | JVM 为每个加载的类和接口都创建一个 |
方法表 | 为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,JVM 的实现者还可以添加一些其他的数据结构,如方法表。JVM 对每个加载的非虚拟类的类型信息中都添加了一个方法表,方法表是一组对类实例方法的直接引用(包括从父类继承的方法)。JVM 可以通过方法表快速激活实例方法。(这里的方法表与 C++ 中的虚拟函数表一样。正像 Java 宣称没有指针了,其实 Java 里全是指针。更安全只是加了更完备的检查机制,但这都是以牺牲效率为代价的,Java 的设计者始终是把安全放在效率之上的,所有 Java 才更适合于网络开发) |