JVM 内存结构与垃圾回收
架构模型
指令集架构包括:基于寄存器的指令集架构、基于栈的指令集架构(JVM 所采用)
基于寄存器架构
- 传统的 PC 所用
- 指令集架构完全依赖硬件,可移植性差
- 性能优秀,执行高效
- 花费更少指令完成一项操作
- 一地址指令、二地址指令为主
基于栈架构
- 设计实现简单,适用于资源受限的系统
- 使用零地址指令
- 执行过程依赖于操作栈,不依赖硬件,可移植性好
- 指令集小,编译器易实现
- 实现同样的功能需要更多的指令,性能下降
加载与运行概览
class 文件的加载
此部分与JVM 类的加载配合学习
Loading
在这个阶段,JVM 会查找并加载类的二进制数据。类的二进制数据可以来自于本地文件系统、网络、或者其他的来源。在加载阶段,JVM 将会执行以下操作:
- 通过类的全限定名来定位和打开类文件
- 读取类文件的字节流数据,并将其转换成 JVM 内部的数据结构
- 为该类创建一个 java.lang.Class 对象,该对象包含了类的静态数据、方法以及其他的相关信息
- 将加载的类信息存放在方法区
Linking
链接阶段将已加载的类与其他类和资源关联起来,并进行一些校验和准备工作。包含以下三个步骤:
- 验证(Verification):对加载的类进行验证(文件格式验证、元数据验证、字节码验证、符号引用验证),确保其符合 JVM 规范,并且不会危害 JVM 的安全
- 准备(Preparation):为
static
变量分配内存,并设置默认值,为final static
常量显式初始化。注意常量在编译时就已经被分配内存 - 解析(Resolution):将符号引用(例如类、字段、方法的符号引用)解析为直接引用(引用目标的直接内存地址、相对偏移量或间接定位的句柄),也就是将其与内存中的具体位置关联起来
Initialization
执行类的 clinit()
方法(不是自己写的构造方法,而是由 javac 编译器自动根据类中 static 变量的赋值语句和 static 代码块的语句合并生成),该方法为类的静态变量赋值。按以下规则进行:
- 如果类的直接父类尚未初始化,则先初始化其父类
- 如果类中存在 static 代码块,则依次执行这些语句块
- 如果类中存在静态变量的初始化语句,则依次执行这些初始化语句
由虚拟机保证多线程下的同步加锁
1 | public class Demo1 { |
Class Loader
引导类加载器 Bootstrap ClassLoader:负责加载 Java 运行时环境的核心类库( jre/lib/rt.jar
、jre/lib/resources.jar
、sun.boot.class.path
),例如 java.lang
和 java.util
等,由 C/C++ 编写。
扩展类加载器 Extension ClassLoader、系统类加载器 System ClassLoader:继承于 ClassLoader
类,系统类加载器负责加载 Java 应用程序的类路径上的类和资源(classpath
、java.class.path
),扩展类加载器负责加载 Java 扩展目录( jre/lib/ext
、java.ext.dirs
)中的类和资源。
1 | public class Demo2 { |
JVM 中两个 class 对象是否为同一个类存在的必要条件:
- 全类名一致
- 加载这个类的
ClassLoader
相同
如果一个类是由用户类加载器加载,那么 JVM 会将这个类加载器的引用作为类型信息的部分保存在方法区,解析一个类型到另一个类型的引用时,需要保证这两个类型的类加载器相同。
对类的主动使用才会导致类初始化,被动使用不会导致类初始化。
主动使用:
遇到
new
、getstatic
、putstatic
或invokestatic
这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化使用
java.lang.reflect
包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
虚拟机启动时,用户需要执行一个要执行的主类(包含
main()
方法的类),虚拟机会先初始化这个主类当使用 jdk 1.7 的动态语言支持时,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32public class Solution {
public static void main(String[] args) {
// 0、包含 main() 方法的类,虚拟机会先初始化
// 输出 "Solution init"
// 1、使用 new 关键字实例化对象
Test test = new Test(); // 输出 "Test init"
// 2、读取或设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外)
int a = Test.a;
// 3、调用一个类的静态方法
Test.methodA(); // 输出 "Test init"
// 4、进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化;
Class clazz = Class.forName("com.pl.Test"); // 输出 "Test init"
// 5、初始化一个类时,如果其父类还未进行过初始化,则需要先初始化其父类
SubTest subTest = new SubTest(); // 依次输出 "Test init" "SubTest init"
}
static {
System.out.println("Solution init");
}
}
class Test {
static {
System.out.println("Test init");
}
public static int a = 1;
public static void methodA() {}
}
class SubTest extends Test {
static {
System.out.println("SubTest init");
}
}
被动使用:
除了以上主动使用的几种情况外,其他全是被动使用,不会触发类的初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class Solution {
public static void main(String[] args) {
// 1、子类引用父类的静态字段,不会导致子类初始化
int a = SubTest.a; // 只输出 "Test init",子类没有初始化
// 2、通过数组定义来引用类,不会触发此类的初始化
Test[] arr = new Test[10]; // 没有初始化
// 3、常量在编译阶段会存入调用类的常量池,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类初始化
int a = Test.A; // 没有初始化
}
}
class Test {
static {
System.out.println("Test init");
}
public static int a = 1;
public static final int A = 1;
}
class SubTest extends Test {
static {
System.out.println("SubTest init");
}
}
Parents Delegate
JVM 对 class 文件按需加载。加载某个类的 class 文件时,采用双亲委派机制:
- 一个类加载器收到类加载请求,会递归交给父加载器,直至到达顶层的启动类加载器
- 引导类加载器可以完成类加载,则成功返回,否则递归交给子加载器尝试加载
1 | package java.lang; |
优势
- 能够避免类的重复加载
- 沙箱安全机制:保护程序安全,防止核心 API 被随意篡改
Runtime Data Area
一个进程(即 JVM 实例)对应一个运行时数据区,其中包括堆、堆外内存、PC 寄存器、JVM 栈、Native 方法栈。
PC 寄存器、JVM 栈、Native 方法栈这三块区域与线程一一对应,随着线程的开始和结束而创建和销毁;
堆、堆外内存(永久代或元空间、代码缓存)线程间共享,随着虚拟机的启动和退出而创建和销毁。
Runtime
实例与 Java 应用程序一一对应。
Java 线程和操作系统的本地线程直接映射。
Program Counter Register
JVM 的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟,记录下一条执行的字节码指令地址,如果是 Native 方法,则记录的是 Undefined。
- 线程私有
- 不存在内存溢出
JVM Stacks
栈与线程同时创建,生命周期与线程相同。在每个方法被调用的时候都会创建一个对应的栈帧 frame 压入栈,这个 frame 用于存储局部变量、动态链接,方法出口(方法返回值、捕获异常)。
每个 frame 包括局部变量表,操作数栈,动态链接(当前方法所属类的运行时常量池的引用),返回地址及一些附加信息。
- 一个线程中活动的栈桢只有一个,称作当前栈帧(current frame),对应正在执行的方法
- 不存在垃圾回收
- 通过
-Xss{Size}
设置栈大小,如 -Xss256k。默认 1 m。
Local Variable Table
局部变量表是一个线程私有的数组,存储方法参数、方法体内使用到的局部变量及它们的作用域,包括八种基本数据类型、对象引用、returnAddress 类型。
基本的存储单位为 slot,占据 4 字节,long
和 double
占用两个 slot,boolean
、short
、char
转为 int
存储,占一个 slot。槽位可复用,某个局部变量过了其作用域,之后的新的局部变量可能会复用其槽位。
当前帧由构造方法或实例方法创建,则该对象引用 this
会存放在 0 号 slot 处。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象不会被回收。
1 | class LocalVariableTest { |
以上代码对应的 LocalVariable 如下:
<init>
Nr. | Start PC | Length | Index | Name | Descriptor |
---|---|---|---|---|---|
0 | 0 | 5 | 0 | cp_info #10 this |
cp_info #11 Lcom/pl/LocalVariableTest; |
test
Nr. | Start PC | Length | Index | Name | Descriptor |
---|---|---|---|---|---|
0 | 7 | 4 | 3 | cp_info #14 b |
cp_info #15 I |
1 | 0 | 14 | 0 | cp_info #10 this |
cp_info #11 Lcom/pl/LocalVariableTest; |
2 | 0 | 14 | 1 | cp_info #16 name |
cp_info #17 Ljava/lang/String; |
3 | 5 | 9 | 2 | cp_info #18 a |
cp_info #15 I |
4 | 13 | 1 | 3 | cp_info #19 c |
cp_info #15 I |
Operand Stack
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量(如调用的方法的返回值)的临时的存储空间。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,为 max stack 的值。32bit 的数据占用一个栈单位深度,64bit 的数据占用两个栈单位深度。
栈顶缓存:栈顶元素缓存在物理 CPU 的寄存器中,以调高效率。
Dynamic Linking
每个栈帧保存了指向运行时常量池中该栈帧所属方法的引用,将符号引用转为直接引用。
JVM 提供了以下几条方法调用指令:
- invokestatic:调用静态方法
- invokespecial:调用私有方法、构造方法、父类方法
- invokevirtual:调用所有的虚方法和
final
方法(final
方法是非虚方法) - invokeinterface:调用接口方法
- invokedynamic
1 | public class Demo6 { |
上述代码的 Son
类的 show()
字节码如下:
1 | 0 invokestatic #3 <com/pl/Son.showStatic : ()V> |
每个类的方法区有一个虚方法表(virtual method table),记录虚方法的实际入口(当前类或当前类的某个父类),它在类加载的 Linking 阶段的 Resolution 被创建并初始化。
Return Address
方法的结束有两种方式:
- 正常执行完成。此种情况下调用者的 PC 寄存器的值作为返回地址,即调用该方法指令的下一条指令地址
- 出现未处理的异常。返回地址通过异常表获取,不保存在栈帧中
返回指令包括 ireturn
(返回值为 boolean、byte、char、short、int)、lreturn
、freturn
、dreturn
、areaturn
(返回值是引用)、return
(返回值是 void)。
Native Method Stack
本地方法栈和 JVM 栈类似,线程私有,当某个线程调用 native 方法时,它就不受虚拟机限制,可以通过本地方法接口来访问虚拟机内部的运行时数据区。
并不是所有的 JVM 都支持本地方法。HotSpot 虚拟机中,JVM 栈和本地方法栈合二为一。
Heap
一个 JVM 实例只存在一个堆内存,堆区在 JVM 启动时即被创建,空间大小也被确定。
堆中也存在线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
几乎所有的对象实例和数组都位于堆(除了栈上分配、标量替换等)
Java 8 之前,堆空间逻辑上分为:
- Young Generation Space:新生区 / 新生代 / 年轻代
- Eden Space
- Survivor Space
- S0
- S1
- Old Generation Space:养老区 / 老年区 / 老年代
- Permanent Space:永久区 / 永久代,逻辑上看作堆的一部分,实际上为方法区的具体实现
Java 8 及之后,堆空间逻辑上分为:
- Young Generation Space:新生区 / 新生代 / 年轻代
- Eden Space
- Survivor Space
- S0
- S1
- Old Generation Space:养老区 / 老年区 / 老年代
- Meta Space:元空间,逻辑上看作堆的一部分,实际上为方法区的具体实现
空间大小
堆空间大小 = Eden Space + Survivor 0 + Survivor 1 + Old Gen,不包括永久区或元空间。
默认情况下,初始堆大小 = 物理机内存大小 / 64,最大堆大小 = 物理机内存大小 / 4。
可通过 -Xms{size}
和 -Xmx{size}
设置初始堆大小和最大堆大小,通常会将二者配置相同的值,其目的是为了能够在 GC 清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
通过 -XX:+PrintGCDetails
查看堆的参数。
1 | public class Demo7 { |
分布情况
为了优化 GC 性能,将堆分代,新生代占比较小,但却需要经常创建、销毁对象。
默认情况下,-XX:NewRatio=2
,表示老年代 :新生代 = 2,即老年代所占堆空间是新生代的两倍;-XX:SurvivorRatio=8
,表示新生代中, Eden 区、S0 区和 S1 区的空间比例是 8:1:1。
几乎所有的 Java 对象都是在 Eden 区创建,绝大多数对象的销毁位于新生代。
对象分配过程如下:
- new 的对象先放 Eden 区
- Eden 区满时,放入对象前,JVM 的垃圾回收器将对 Eden 区和 S 区进行垃圾回收(Minor GC),将 Eden 区中的垃圾(不再被其他对象所引用的对象)进行销毁后,将新的对象放到 Eden 区(注意:Eden 区会触发 Minor GC,而 S 区不会)
- 将此时 Eden 区中的对象移动到 to 区(此时为 S0 区),并将对象身上的 age++
- 若干次向 Eden 区添加对象后,Eden 区又满了,再次触发垃圾回收,重复如上步骤 2,注意此时的 to 区为另一个 S 区
- Survivor 区中的对象 age = 15 时,转入老年区。
分配策略
- 优先分配到 Eden
- 大对象直接分配到老年代
- 长期存活的对象分配到老年代
- 动态对象年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄
graph LR begin(新对象申请) if1{Eden放得下?} if1y[放入Eden区] if1n[YGC] if2{再次判断Eden放得下?} if3{Old放得下?} if3n[FGC] if4{Old放得下?} if4y(放入Old区) if4n(OOM) subgraph YGC sif{Survivor放得下?} sify[放入S0/S1区] sifn(晋升老年代, 放入Old/) sif2{对象存活超过阈值?} end begin --> if1 --是--> if1y if1 --否--> if1n --> if2 --是--> if1y if2 --否--> if3 --是-->if4y if3 --否--> if3n --> if4 --是-->if4y if4 --否-->if4n sif --是--> sify -->sif2 --是-->sifn sif2 --否--> sify sif--否-->sifn
GC 的类型
Partial GC
Minor GC / Young GC:只在新生代(Eden、S0/S1)的垃圾收集,Eden 区满时触发
Major GC / Old GC:只是老年代的垃圾收集
目前只有 CMS GC 会单独收集老年代的垃圾。很多时候 Major GC 与 Full GC 混用,需要具体分辨
Mixed GC:收集整个新生代和部分老年代的垃圾
目前只有 G1 GC 会有这种行为
Full GC:收集整个堆和方法区的垃圾,速度慢,应尽量避免
1 | public class Demo8 { |
TLAB
从内存模型而不是垃圾回收的角度从 Eden 区为每个线程分配一个私有缓存区域,TLAB 默认仅占整个 Eden 的 1% 。
JVM 将 TLAB 作为内存分配的首选,若分配失败,JVM 就尝试通过加锁机制确保数据操作的原子性,从而在剩下的 Eden 空间中分配内存。
常用的 VM 参数
-XX:+PrintFlagsInitial
:查看所有的参数的默认初始值
-XX:+PrintFlagsFinal
:查看所有的参数的最终值(可能会存在修改,不再是初始值)
jps
查看运行的进程及其 pid
jinfo -flag {参数名} {pid}
查看具体参数
-Xms
:初始堆空间内存(默认为物理内存的 1/64)
-Xmx
:最大雄空间内存(默认为物理内存的 1/4)
-Xmn
:设置新生代的大小(初始值及最大值)
-XX:NewRatio
:配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio
:设置 Eden 与 S 区空间的比例
-XX:MaxTenuringThreshold
:设置新生代垃圾的最大年龄
-XX:+PrintGCDetails
:输出详细的 GC 处理日志
-XX:+PrintGC
:输出简要的 GC 处理日志
-XX:HandlePromotionFailure
:是否设置空间分配担保
JDK7 及之后,不再使用 HandlePromotionFailure,只要老年代的连续空间大于新生代对象总大小或历次晋升的平均大小就进行 Minor GC,否则进行 Full GC
栈上分配
经过逃逸分析(Escape Analysis),一个对象没有逃逸出方法(即对象在方法中被定义后,只在方法内部使用)的话,就可能优化成栈上分配。
因此,尽量使用局部变量,而不是在方法外定义。
1 | // 发生了对象逃逸 |
同步省略
如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
1 | public void fu() { |
标量替换
在 JIT 阶段,经过逃逸分析,发现对象不会被外界访问的话,会把对象拆成若干个成员变量代替,这就是标量替换。可看作一种栈上分配的特例。
标量(Scalar):无法再分解成更小数据的数据,Java 中的原始数据类型就是标量
聚合量(Aggregate):可再分解成更小数据的数据,Java 中的对象就是聚合量
1 | class Point { |
Method Area
方法区:逻辑上用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
JVM 启动时创建,JVM 关闭时销毁。方法的大小决定了系统能保存的类数量,如果定义了太多类导致方法区溢出,会抛 OOM 错误。
JDK 7 及之前的具体实现是永久代(Permanent Generation),JDK 8 及之后具体实现是:元空间(Meta Space)。
永久代大小固定,通过 -XX:PermSize
来设置永久代初始分配空间。默认值是 20.75 M,-XX:MaxPermSize
来设定永久代最大可分配空间,32 位机器默认是 64 M,64 位机器模式是 82 M。
元空间不在 JVM 设置的内存中,而是使用本地内存,元空间大小动态扩容。默认值依赖于平台,windows下,-XX:MetaspaceSize
是 21 M,-XX:MaxMetaspaceSize
的值是 -1 ,即没有限制。其默认的 21 M 是初始的高水位线,一旦触及就会触发 Full GC 并卸载没用的类(即这些类对应的类加载器不再存活)然后根据 GC 释放了多少元空间,来动态调整高水位线。
类型信息
对每个加载的类型(class
、interface
、enum
、annotation
),JVM 必须在方法区中存储以下类型信息:
- 完整有效名称(全名=包名.类名)
- 直接父类的完整有效名(
interface
和java.lang.Object
没有父类) - 修饰符(
public
、abstract
、final
) - 直接接口的一个有序列表
Filed 信息
所有域的相关信息:
- 名称
- 类型
- 修饰符(
public
、private
、protected
、static
、final
、volatile
、transient
) - 声明顺序
Method 信息
所有方法的相关信息:
- 名称
- 返回类型
- 参数数量和类型(按顺序)
- 修饰符(
public
、private
、protected
、static
、final
、synchronized
、native
、abstract
) - 字节码、操作数栈、局部变量表及大小(除了
abstract
方法和native
方法) - 异常表(除了
abstract
方法和native
方法)
Runtime Constant Pool
区别于常量池(Constant Pool Table,一张表,class 文件的一部分,存放编译生成的各种字面量和符号引用,这部分内容在类加载后存放到方法区的运行时常量池中,JVM 指令根据这张表找到要执行的类名、方法名、参数类型、字面量等类型),运行时常量池中不再是常量池中的符号地址,而是真实地址,且具备动态性。
测试代码及其字节码
1 | public class Demo10 extends Object implements Comparable<String>, Serializable { |
对应的字节码文件如下
1 | Classfile /D:/ideaProjects/JVM/demo1/target/classes/com/pl/Demo10.class |
演进
只有 HotSpot 虚拟机有永久代。为永久代设置空间大小是难以确定的,对永久代进行调优很困难。
HotSpot 中方法区实现方式的变化:
版本 | 变化 |
---|---|
JDK 1.6 及之前 | 有永久代,静态变量存放在永久代 |
JDK 1.7 | 有永久代,字符串常量池、静态变量存放在堆中 |
JDK 1.8 及之后 | 无永久代,类型信息、字段、方法、常量保存在元空间,字符串常量池、静态变量存放在堆中 |
将 StringTable 移动到堆中的原因:永久代回收效率很低,在 Full GC 才会触发,导致 StringTable 回收效率不高,而开发中有大量的字符串被创建,回收效率低会导致永久代内存不足。
方法区的垃圾回收
规范不强制要求在方法区中必须实现垃圾回收。
主要回收:常量池中废弃的常量和不再使用的类型
常量允许回收的条件:
- 没被任何地方引用,就可以被回收。
类型允许回收的条件:
- 该类所有的实例都已经被回收,即堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Direct Memory
直接内存不属于规范定义的区域,不属于运行时数据区的一部分,是在 Java 堆外,直接向系统申请的内存区间,不受 JVM 内存回收管理,也可能导致 OOM。
源于 NIO 包提供的功能之内存映射文件,通过 DirectByteBuffer
操作 Native 内存(调用 unsafe.allocateMemory(size);
),读写性能高,适用于对大文件的读写操作。
通过 -XX:MaxDirectMemorySize={size}
设置大小,默认和 -Xmx
一致。
对象创建与布局
创建
创建对象的方法:
new
- Class 的
newInstance()
(只能调用空参的构造器,权限必须是public
) Constructor
的newInstance(xxx)
(可以调用空参、带参的构造器,权限没有要求)clone()
- 反序列化
- 第三方库
创建对象的步骤:
- 加载类元信息,检查是否存在元空间的常量池中定位类的符号引用并检查是否已经被加载(loading、linking、initialization)。如果没有,在双亲委派机制下进行加载;如果有,进行类加载并生成对应的类对象
- 为对象分配内存。如果内存规整(垃圾回收器为 Serial、ParNew 等),采用指针碰撞法(Bump The Pointer),即使用一个指针作为已用内存和空闲内存的边界;如果内存不规整(垃圾回收器为 CMS 等),采用空闲列表(Free List)记录可用的内存块
- 处理并发安全问题。为每个线程预分配一块 TLAB、采用 CAS
- 初始化分配到的空间。零值初始化、设置对象头信息
- 执行 init 初始化
内存布局
对象头
包括运行时元数据(Mark Word)和类型指针,运行时元数据包括哈希值、GC 分代年龄、锁状态标志、持有的锁、偏向线程 ID、偏向时间戳,类型指针指向类元数据 InstanceKlass,具体如下:
32 位 Jvm 中每个普通非数组 Java 对象的 Object Header 为 64 位(数组对象额外多了 32 位的长度标记)
包括 32 位的 Mark Word 和 32 位的 Klass Point
32 位 JVM 普通对象 | |||||
---|---|---|---|---|---|
Mark Word(32位) | 表示的锁状态 | ||||
hashcode(25位) | age(4位) | biased_lock: 0 | lock: 01 | Normal | |
thread(23位) | epoch(2位) | age(4位) | biased_lock: 1 | lock: 01 | Biased |
ptr_to_lock_record(30位) | lock: 00 | Lightweight Locked | |||
ptr_to_heavyweight_monitor(30位) | lock: 10 | Heavyweight Locked | |||
lock: 11 | Marked for GC |
实例数据
先放父类定义的变量,再放子类的变量。相同宽度的字段被分配到一起。默认开启 CompactFields,子类的窄变量可能插入到父类变量的空隙。
对齐填充
占位
对象访问
句柄访问
堆中维护一个句柄池,每一条句柄数据保存一个到对象实例数据(位于堆的实例池)的指针和一个到对象类型数据(位于方法区)的指针,栈帧的局部变量表中对应的引用数据指向堆中句柄池中对应的句柄数据。
如果实例对象发生移动,句柄池中的句柄数据改变,栈中的引用数据稳定。专门开辟空间,效率低,
直接指针
堆中存放对象实例数据,实例数据中有到对象类型数据的指针,栈帧的局部变量表中对应的引用数据指向堆中的实例对象。
HotSpot 虚拟机采用的方式。
Execution Engine
执行引擎的任务就是将字节码指令解释/编译为对应平台的本地机器指令。
- 解释器逐行解释字节码
- JIT Compiler 观察到某些代码频繁执行,判断其为热点代码(Hotspot)
- JIT Compiler 将热点代码动态地编译为机器码
- 执行机器码,以提高执行速度
-Xint
:纯解释器模式。-Xcomp
:纯即时编译器模式。-Xmixed
:混合模式(默认)。
解释器
字节码解释器:执行时通过纯软件代码模拟字节码的执行,效率非常低下。
模板解释器:将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
JIT 编译器
热点代码通过 JIT 编译器编译为本地机器指令,称为栈上替换(OSR,On Stack Replacement)
HotSpot 的热点代码探测方式:基于计数器的热点探测。为每一个方法都建立 2 个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter),方法调用计数器用于统计方法的调用次数,回边计数器则用于统计循环体执行的执行次数。
graph TD A(准备方法调用)--> B{存在编译后的<br/>代码缓存?} B -->|是| C(使用编译后的代码缓存执行) B -->|否| D[方法调用计数器+1] D --> DD{调用计数器与回边计数器<br/>之和超过阈值?} GC(GC时) -->E{超过半衰周期?} E -->|是| F[方法调用计数器减半] DD --> |否| H DD -->|是| G[向即时编译器提交OSR请求] G --> H(解释执行) F --> I(热度衰减)
当一个方法/一段循环体代码被调用时,会先检查该方法/代码是否被 JIT 编译过,如果存在,则优先使用编译后的代码缓存来执行,否则将此方法的调用计数器/回边计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值(-XX:CompileThreshold={num}
设置,Server 版本默认为 10000,Client 版本默认为 1500),如果超过阈值,那么会向即时编译器提交一个该方法代码/循环体代码的 OSR 请求。
超过半衰周期(Counter Half Life Time,-XX:CounterHalfLifeTime={time}
设置),如果方法的调用次数仍未达到阈值,那这个方法调用计数器就会减半,这个过程称为方法调用计数器的热度衰减(Counter Decay,-XX:-UserCounterDecay
设置是否关闭),在 GC 时顺便执行。
回边计数器没有热度衰减的过程。
AOT 编译器
JDK 9 引入了 AOT(Ahead Of Time)编译器,程序运行前将字节码转换为机器码,生成 .so 文件,加快预热,破环跨平台性、链接的动态性。
StringTable
StrinPool 是一个固定长度(-XX:StringTableSize={size}
,JDK 7 及之后默认为 60013)的 HashTable
(因此字符串常量池中不会存储相同内容的字符串)。
StringTable 在 JDK 7 及之后由永久代移动到了堆中。
直接用双引号声明的 String
对象或调用 intern()
的对象会放在字符串常量池中。
常量与常量的拼接操作会经编译器优化存入常量池,变量与常量、变量与变量的拼接会(非 final
)调用 StringBuilder
的 append()
和 toString()
放入堆中常量池之外的区域。
1 | String s1 = "hello"; // 常量池 |
String
类中的 intern()
在JDK 6 与 JDK 6 之后的实际操作有所不同
eg:
1 | public static void main(String[] args) { |
String str = new String("1")
涉及到两个对象:常量池中的 “1” 和通过 new
字节码调用 String("1")
构造器创建的新对象;
String str = new String("1") + new String("2")
涉及到六个对象:通过 new
字节码调用 StringBuilder()
构造器创建的新对象、常量池中的 “1”、常量池中的 “2”、通过 new
关键字调用 String("1")
字节码创建的新对象、通过 new
字节码调用 String("2")
构造器创建的新对象和 StringBuilder.toString()
中通过 new
字节码调用的 String(char value[], int offset, int count)
构造器创建的新对象。
调用 intern() | JDK 6 | JDK 7 起 |
---|---|---|
池中已存在该字符串 | 返回池中的对象的地址 | 返回池中的对象的地址 |
池中不存在该字符串 | 将对象复制一份放入池中,并返回池中的对象的地址 | 将对象的引用地址复制放入池中,并返回池中的引用地址 |
程序中需要存储大量字符串时,使用 intern()
可以用时间换大量空间
GC 相关概念
System.gc()
System.gc()
调用 Runtime.getRuntime().gc()
提醒进行 GC,但不保证对垃圾回收器的调用。
注意以下代码的 localGC3()
和 localGC4()
1 | public class Demo14 { |
SafePoint 和 SafeRegion
在特定的位置才能 STW 进行 GC,这些位置成为安全点。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。当线程运行到该区域代码时,标识已进入 Safe Region,若该段时间内发生了 GC,JVM 会忽略这个线程;当线程离开该区域时,若 JVM 未完成 GC,则会等待完成后才继续运行。
引用
引用分四类,引用强度依次递减,软引用、弱引用、虚引用都继承自抽象类 Reference
,为:
强引用(Strong Reference):常用的引用赋值,不会被回收,是内存泄漏的主要原因之一
软引用(Soft Reference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如缓存
弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次 GC 之前,GC 时,无论内存空间是否足够,都会回收掉被弱引用关联的对象
虚引用(Phantom Reference):一个对象若只有虚引用,那么它和没有引用几乎是一样的,随时可被回收。虚引用引用的对象被回收时,会将虚引用加入队列,为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33/**
* -Xms10m -Xmx10m
*/
public static void main(String[] args) {
Object obj = new Object();
Object objStrongReference = new Object();
SoftReference<Object> objectSoftReference = new SoftReference<>(obj);
// 取消强引用,只留下软引用
obj = null;
System.gc();
System.out.println(objectSoftReference.get() == null); // false
obj = new Object();
WeakReference<Object> objectWeakReference = new WeakReference<>(obj);
// 取消强引用,只留下弱引用
obj = null;
System.gc();
System.out.println(objectWeakReference.get() == null); // true
obj = new Object();
PhantomReference<Object> objectPhantomReference = new PhantomReference<>(obj, new ReferenceQueue<Object>());
System.out.println(objectPhantomReference.get() == null); // true
try {
byte[] bytes = new byte[1024 * 1024 * 7];
// OOM 前进行的 GC 时清理掉了软引用
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(objectSoftReference.get() == null); // true
System.out.println(objStrongReference == null); // false
}
}
Remebered Set
堆某一部分进行垃圾回收时,由于待回收的对象可能被其他区域引用,全局扫描会降低效率,因此采用 remembered set 记录其被引用的情况。
Garbage Collection
堆是垃圾回收器的工作重点,频繁收集年轻代,较少收集老年代,基本不动永久代。
垃圾标记阶段
判断对象存活有两种方法:引用计数算法和可达性分析算法
引用计数算法
对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。当该对象被引用时,引用计数器加 1,当引用失效时,引用计数器就减 1,引用计数器的值为 0,即表示该对象可被回收。
优点:
- 实现简单,垃圾对象便于辨识
- 判定效率高,回收没有延迟
缺点:
- 需要单独的字段存储计数器,增加了存储空间的开销
- 对计数器的加减法操作,增加了时间开销
- 无法处理循环引用
eg:
graph LR p((p指针)) subgraph A rca[RC = 2] aa[A对象] end subgraph B rcb[RC = 1] ab[B对象] end subgraph C rcc[RC = 1] ac[C对象] end p --> aa --> ab --> ac --> aa
断开 p 后:
graph LR p((p指针)) subgraph A rca[RC = 1] aa[A对象] end subgraph B rcb[RC = 1] ab[B对象] end subgraph C rcc[RC = 1] ac[C对象] end p aa --> ab --> ac --> aa
循环引用导致每个对象的 RC 都不为 0 无法回收。
可达性分析算法/追踪性垃圾收集
以根对象集合(GC Roots)作为起始点,从上至下(沿着引用链)搜索被根对象集合所连接的目标对象是否可达,只有能被根对象集合直接或间接连接的对象才是存活对象,是 Java 选择的算法。
对某区域(堆或单独对新生代等)进行 GC 时,区域外的指针指向区域内的对象,那么它就是一个 GC Root。
使用可达性分析算法时,分析工作必须在一个能保障一致性的快照中进行,这也是导致 GC 进行时必须 Stop The World 的一个重要原因。
finalize()
准备回收某个对象时,会先调用该对象的 finalize()
,该方法常用于进行资源释放、清理的工作,如关闭文件、套接字和数据库连接等。
对象的三种状态:
- 可触及的:从 GC Root 沿引用链可达
- 可复活的:从 GC Root 沿引用链不可达,但
finalize()
未被调用 - 不可触及的:从 GC Root 沿引用链不可达,且
finalize()
已被调用并且未复活
判断一个对象是否可回收:
- 如果 GC Roots 到该对象没有引用链,则进行第一次标记
- ① 如果该对象没有重写
finalize()
,或者重写了finalize()
但已经被虚拟机调用过,则该对象被判定为不可触及的
② 如果该对象重写了finalize()
,且还未执行,那么该对象会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其finalize()
方法执行 - 如果该对象在
finalize()
中与引用链上的任何一个对象建立了联系,那么在第二次标记时,该对象会被移出“即将回收”集合。之后,若对象会再次出现 GC Roots 到该对象没有引用链的情况,则执行步骤 2-①,即对象会直接变成不可触及的状态(复活币只能用一次)
1 | public class Demo13 { |
垃圾清除阶段
几乎所有的 GC 都采用分代收集的思想。
标记-清除(Mark-Sweep)算法
停止整个程序(Stop the World)
从根节点开始遍历标记所有可达的对象,在对象的 header 中记录为可达对象,然后垃圾回收器对堆进行从头到尾的线性遍历,将需要清除的对象地址保存在空闲地址表中。
在周志明老师的《深入理解 Java 虚拟机》中:
章节 2.3.2 的表 2-1 只说明 Mark Word 的标志位 11 为“GC标记”
章节 3.3.2:“首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象”
在 Hotspot 虚拟机代码关于对象头的部分的第 90 行:
1
2
3
4
5
6
7 // - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
// [ptr | 00] locked ptr points to real header on stack
// [header | 0 | 01] unlocked regular object header
// [ptr | 10] monitor inflated lock (header is wapped out)
// [ptr | 11] marked used by markSweep to mark an object
// not valid at any other time并未找到具体的执行细节,究竟是在什么时候进行标记以及是把不需要回收的标为 11,还是把需要留下的标为 11?求指点 QAQ
优点:思路简单
缺点:效率低、需要 STW 暂停程序、产生内存碎片、需要维护空闲列表
复制算法
内存分为两块,每次只使用一块,垃圾回收时将存活对象复制到另一块内存,然后清空当前内存块。
优点:效率高、解决内存碎片问题
缺点:需要双倍空间、维护引用关系
适用于垃圾对象多,存活对象少的场景(年轻代的 Survivor 区)
标记-压缩(Mark-Compact)算法
标记阶段和标记清除算法一致,标记阶段完后将所有存活对象压缩到内存的一端,按顺序存放,清理边界外的空间。
优点:采用指针碰撞法,即只保留一个指针作为已用内存和空闲内存的边界、解决内存碎片问题
缺点:效率上低于复制算法、需要 STW 暂停程序、移动被其他对象引用的对象,需要调整引用的地址
增量收集算法与分区算法
增量收集算法
让垃圾收集线程和应用程序线程交替执行,每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作,但会造成系统吞吐量的下降。
分区算法
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
一般来说,相同条件下,堆空间越大,一次 GC 时所需要的时间就越长,产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从
而减少一次 GC 所产生的停顿。
Garbage Collector
垃圾回收器可有多种分类方式:
- 按工作线程数:串行、并行
- 按工作模式:并发式、独占式
- 按碎片处理方式:压缩式、非压缩式
- 按工作的内存区域:年轻代、老年代
红色虚线表示这对组合在 JDK 9 及之后被取消,绿色虚线表示 JDK 14 及之后被取消,JDK 14 中取消了 CMS
使用 -XX:+PrintCommandLineFlags
查看命令参数,其中包括使用的垃圾回收器
1 | /* |
评估一个垃圾回收器有以下三个方面:
- 吞吐量:运行用户代码的时间 /(运行用户代码的时间 + 垃圾收集时间)
- 暂停时间
- 内存占用
名称 | 类型 | 工作区域 | 使用算法 | 特点 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单 CPU 环境下的 Client 模式 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多 CPU 环境下的 Server 模式与 CMS 配合 |
Parallel | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 后台运算而不需要太多交互 |
Serial Old | 串行 | 老年代 | 标记-压缩算法 | 响应速度优先 | 单 CPU 环境下的 Client 模式 |
Parallel Old | 并行 | 老年代 | 标记-压缩算法 | 吞吐量优先 | 后台运算而不需要太多交互 |
CMS | 并发 | 老年代 | 标记-清除算法 | 响应速度优先 | 互联网或 B/S 业务 |
G1 | 并行、并发 | 新生代、老年代 | 复制算法、标记-压缩算法 | 响应速度优先 | 面向服务端应用 |
力求在最大吞吐量优先的情况下,降低停顿时间。
最小化地使用内存和并行开销,选 Serial GC
最大化应用的吞吐量,选 Parallel GC
最小化 GC 的中断或停顿时间,选 CMS GC
Serial 垃圾回收器
采用复制算法、串行回收、STW 机制,工作在新生代。
HotSpot 的 Client 模式中:默认新生代垃圾回收器。
-XX:+UserSerialGC
指定年轻代用 Serial 和老年代用 Serial Old。
Serial Old 垃圾回收器
采用标记-压缩算法、串行回收、STW 机制,工作在老年代。
HotSpot 的 Client 模式中:默认老年代垃圾回收器。
HotSpot 的 Server 模式中:1、作为 CMS 的后备方案;2、与 Parallel Scavenge 配合使用
ParNew 垃圾回收器
采用复制算法、并行回收、STW 机制,工作在新生代。
很多 JVM 的 Server 模式中的默认新生代垃圾回收器。
-XX:+UseParNewGC
指定年轻代用 ParNew,-XX:+ParallelGCThreads
限制线程数量
Parallel Scavenge 垃圾回收器
采用复制算法、并行回收、STW 机制,工作在新生代。
目标是达到可控制的吞吐量。
Java 8 中默认的垃圾回收器。
Parallel Old 垃圾回收器
采用标记-压缩算法、并行回收、STW 机制,工作在老年代。
Java 8 中默认的垃圾回收器。
-XX:+UseParallelGC
指定年轻代用 Parallel Scavenge、老年代用 Parallel Old,-XX:+ParallelGCThreads
限制线程数量, -XX:GCTimeRatio=99
设置垃圾收集时间 = 1 / (1 + N),-XX:+UseAdaptiveSizePolicy
自动调整
CMS 垃圾回收器
采用标记-清除算法、并发回收、STW 机制,工作在老年代。
工作流程
- 初始标记(Initial-Mark)阶段:STW 暂停线程,只标记出 GC Roots 能直接关联到的对象,由于直接关联对象比较少,所以此阶段速度非常快
- 并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但不需要暂停用户线程
- 重新标记(Remark)阶段:修正并发标记期间,因与用户程序并发运行而导致标记产生变动的部分对象的标记记录,此阶段停顿时间比初始标记阶段略长,远比并发标记阶段的时间短
- 并发清除(Concurrent-Sweep)阶段:清理掉判断已死亡的对象,由于不需要移动存活对象,并发执行
由于与用户线程并发执行,回收时要确保用户线程有足够的内存,若满足不了用户线程,则会发生 Concurrent Mode Failure ,此时虚拟机启动备用方案,启用 Serial Old 收集器进行老年代的垃圾收集。
参数
-XX:+UseConcMarkSweepGC
使用 ParlNew + CMS + Serial Old。
-XX:CMSInitiatingOccupancyFraction=92
堆内存使用率的阈值。若内存增长缓慢,则可设置稍大的阈值,降低 CMS 的触发频率,反之,若内存增长很快,则应降低阈值,以避免频繁触发 Serial Old。因此通过该选项便可以有效降低 Full GC 的次数。
-XX:+UseCMSCompactAtFullCollection
执行完 Full GC 后进行内存压缩整理,-XX:CMSFullGCsBeforeCompaction=0
执行 n 次 Full GC 后进行内存压缩整理。
-XX:ParallelCMSThreads
设置 CMS 线程数量。
Garbage First 垃圾回收器
采用分区算法、空间整合、并行回收、并发工作、STW 机制,工作在新生代、老年代。
JDK 9 及之后的默认垃圾回收器。适用于大内存应用。
逻辑上区分年轻代和老年代,物理上不要求各个区空间连续,也不坚持固定大小和固定数量,而是将堆空间划分为若干个 region。除了 region 之外,还增加了新的内存区域 humongous 用于存储超过 1.5 个 region 大小的短期存在的大对象。
每个 region 对应一个 remembered set,通过写屏障机制维护其他 region 指向该 region 的记录,在垃圾回收时,将 remembered set 也加入 GC Roots 的枚举范围,既不用全局扫描,又不会遗漏。
空间整合:region 间复制算法,整体上可看作标记-压缩算法。
可预测的停顿时间模型:根据各个 region 回收所释放的空间大小及回收所需的时间,维护一个优先列表,每次根据允许的时间优先回收价值最大的 region。
参数
-XX:+UseG1GC
指定使用 G1。
-XX:G1HeapRegionSize
设置 region 的大小,值是 2 的幂次,默认为堆的 1/2000。
-XX:MaxGCPauseMillis=200ms
设置期望的最大 GC 停顿时间,不保证达到。
-XX:ParallelGCThread
设置 STW 时 GC 线程数,最多为 8。
-XX:ConcGCThreads
设置并发标记的线程数,设置为 ParallelGCThread 的 1/4 左右。
-XX:InitiatingHeapOccupancyPercent=45
设置触发并发 GC 周期的堆占用率阈值。超过此值,就触发 GC。
工作流程
- 当年轻代的 Eden 区用尽时开始对年轻代回收,独占式、并行、多线程
- 当堆内存使用达到阈值(默认45%)时,开始老年代并发标记过程
- 标记完成马上开始混合回收(年轻代 GC + 老年代 GC)。从部分老年代的 region 移动存活对象到空闲区间,同时,和年轻代一起被回收
graph LR Y[年轻代 GC] O[年轻代 GC <br/> + 并发标记] M[混合回收] F[Full GC] Y --> O --> M --> Y M -.-> F -.-> Y
年轻代 GC
扫描根
更新 Remebered Set。处理 dirty card queue 中的 card,使得 RSet 准确反映老年代对所在内存分段中对象的引用。
脏卡表队列(dirty card queue):对引用赋值语句
objectInOld.field = objectInEden
,JVM 会通过在该语句前后的特殊操作使得在脏卡表队列入队一个保存了对象引用信息的 card。由于 RSet 的处理需要线程同步,开销大,故此处不直接处理 RSet,而是等年轻代回收处理 RSet。根据 RSet 将部分对象认定为存活的对象
复制算法。若 age 达到阈值或 Survivor 空间不够,晋升至老年代,否则 age + 1
处理软、弱、虚等引用。最终 Eden 为空,等待下次分配。
并发标记
- 标记根节点直接可达的对象,并触发一次年轻代 GC
- 根区域扫描,扫描并标记 Survivor 区直接可达的老年代区域对象。这一过程必须在 Young GC 前完成,因为 Young GC会移动 Survivorl 区,存在部分 S 区的对象移动到老年代
- 并发标记(Concurrent Marking):在整个堆中进行并发标记,此过程可能被 Young GC 中断。若发现区域中所有对象都是垃圾,则立刻回收该区域。同时,并发标记过程中,计算各区域中存活对象的比例
- 再次标记(Remark):由于应用程序持续进行,需要独占式修正上一次的标记。G1 采用比 CMS 更快的初始快照算法:snapshot-at-the-beginning(SATB)
- 独占清理(cleanup):计算各区域的存活对象和回收比例,并进行排序,识别可以混合回收的区域,虽然名字叫清理,但实际上只是一个统计计算过程,不会涉及垃圾清理
- 并发清理阶段:若发现区域中所有对象都是垃圾,则立刻回收该区域。
混合回收
选取所有的 Young region + 收益高的若干个(根据 -XX:G1MixedGCCountTartget=8
即八分之一的老年代)Old region,若内存分段的垃圾占比不超过 -XX:G1MixedGCLiveThresholdPercent=65
即 65% 则不会被回收,而且混合回收不一定要进行 8 次,当可回收的垃圾占堆内存的比例低于 -XX:G1HeapWastePercent=10
即 10% 时,则不再进行混合回收。
Full GC
在 Young gc/Mixed gc 尝试回收后,都不能释放足够内存时,便会触发 FullGC,使用单线程的内存回收算法,性能差、停顿时间长。
一般解决方法:加大堆空间、调低触发并发 GC 周期的堆占用率阈值、加大最大 GC 停顿时间