JVM 内存模型
1.1 概览导图
1.2 JVM 内存划分及模型
暂时无法在文档外展示此内容
public class App {
private int plus() {
int a = 1;
int b = 1;
int c = a + b;
return c;
}
public static void main(String[] args) {
App obj = new App();
System.out.println(obj.plus());
}
}
1.3 JVM 内存区域大小控制参数
暂时无法在文档外展示此内容
生产环境下,-Xmx 与 -Xms 、-XX:MetaspaceSize 与 -XX:MaxMetaspaceSize 都应该尽量保持一致。
原因都是为了避免启动初期扩容带来GC停顿,并且JVM一般不会主动释放已占用的内存,预期等待JVM自动扩容,不如启动直接指定最大内存。
对象创建与回收
2.1 概览导图
2.2 对象创建过程
暂时无法在文档外展示此内容
2.2.1 类加载
类对象的加载、连接(验证->准备->解析)和初始化(<clinit>方法)等过程。
2.2.2 分配内存
类加载完毕后,类的新对象所需要的内存大小已经确定。这时候可以为新对象在堆中分配空间。分配空间的算法在不同的垃圾收集器中实现不一样。常见有以下两种解决方案:
指针碰撞:内存空间是规整,使用和未使用的空间由指针相隔,则从指针位置开始尝试申请一块空闲空间。Serial、ParNew 采用此法;
空闲列表:内存空间是零碎的,JVM 需要维护一个空闲内存列表,分配时从列表里选取一块内存用于分配对象,并更新空闲列表。CMS 采用此法;
并发情况下分配内存,存在线程安全问题,常见解决方案:
CAS:JVM 使用 CAS 和失败重试来保证操作原子性,从而对分配内存的过程进行同步处理,以实现并发安全;
TLAB(Thread Local Allocation Buffer):即线程本地分配缓存区。每个线程预先分配一小块内存空间,然后每个线程的对象分配在各自的 TLAB 空间进行,互不干扰。通过虚拟机参数 -XX:UseTLAB 可以开启该功能。
2.2.2.1 对象在新生代分配
大多数情况下,对象都在新生代中分配。当新生代中的 Eden 区以及其中一个 Survivor 区没有足够空间的时候,会触发一次 Minor GC,并将剩余存活对象移动到另一个空的 Survivor 区。
Eden与Survivor区默认8:1:1。可以通过参数 -XX:SurvivorRatio=n 改变这个比例,该参数设置的是Eden区与每一个Survivor区的比值,例如当n=8可以反推出占新生代的比值,Eden为8, 两个Survivor为1, Eden占新生代的4/5, 每个Survivor占1/10,两个占1/5。
JVM还有个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变 化可以设置参数-XX:-UseAdaptiveSizePolicy。
2.2.2.2 对象在栈上分配
Code -> AllocateOnStackTest
逃逸分析 (-XX:+DoEscapeAnalysis,JDK7后默认开启)
JVM 通过对象逃逸分析确定对象是否会被外部访问,如果不会逃逸则该对象将在栈上分配。栈上分配的内存空间会随着出栈销毁,避免对象分配在堆中,从而减轻回收压力。
注意默认情况下,数组对象长度超过64时不会通过逃逸分析优化,会自动在堆上分配。这个大小可以通过启动参数-XX:EliminateAllocationArraySizeLimit=n来进行控制,n是数组的大小。
标量分析 (-XX:+EliminateAllocations, JDK7后默认开启)
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该 对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就 不会因为没有一大块连续空间导致对象内存不够分配。
2.2.2.3 大对象直接进入老年代
Code -> BigObjectAllocateTest
大对象需要大量连续内存空间(比如:字符串、数组等)。一般情况下大对象会在新生代分配。另外存在两种情况会直接分配到老年代:
在 Serial 和 ParNew 这两个收集器下,可以通过参数 -XX:PretenureSizeThreshold=n 设置大对象的大小(n 是字节数),此时大对象会直接分配到老年代;
在 G1 收集器下,超过 Region 大小的一半的对象,会直接分配到老年代。
> 因为大对象占用较大空间,在新生代里复制十几次才被晋升的话,效率太低。
2.2.2.4 新生代对象通过年龄代数进入老年代
Code -> MaxTenuringThresholdTest
如果一个对象在新生代多次回收依然存活,则会被晋升到老年代。通过参数-XX:MaxTenuringThreshold 设置年龄阈值。(一般默认值是15,CMS是6)。
通过 -XX:+PrintFlagsFinal 可以打印启动后参数值
2.2.2.5 新生代对象通过对象动态年龄判断机制进入老年代
不好复现
在一次 minor GC之后 JVM 将当前保存对象的 Survivor 区对象从年龄小到大排序,并累加,如果当前对象占用内存总和超过了 Survivor 区的50%,则剩下的较老的对象会直接晋升老年代。通过参数 -XX:TargetSurvivorRatio 可以改变该比例,默认值是50%。
2.2.2.6 老年代空间担保机制
简单来说,就是 Minor GC 前先看看老年代是否有足够剩余空间,如果没有则先触发 Full GC。具体流程看图:
2.2.3 初始化
Code -> ObjectInitialZeroValueTest
分配内存结束后,对象会被初始化为零值;如果使用 TLAB,则提前至 TLAB 分配时进行。这个阶段确保了对象新建后不用对其字段赋值,便可以使用其字段默认零值的原因。
2.2.4 设置对象头
Code -> MarkWordTest
对象空间划分为对象头和实例数据两部分,其中对象头又包含以下几个部分:
Mark Word 标记字段
Mark Word 在32位 JVM 占32bit,64位系统占64bit。Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:
Kclass 类型指针
类的元数据指针,而元数据是保存在方法区(或元空间)。在64位 JVM 中,开启指针压缩占用32bit,不开启的话占用64bit;在32位 JVM 中,占用32bit。
数组长度(只有数组对象才有)
只有数组才会有该字段,占用32bit。
填充字节
JVM 要求对象空间长度是 8 字节的倍数。如果不满足倍数关系,需要填充
2.2.4.1 指针压缩
从 JDK1.6 update 14开始,JVM 在64位系统开始支持指针压缩,主要包含两个参数:
-XX:+UseCompressedOops 开启压缩所有指针(默认开启,禁用可用-XX:-UseCompressedOops);
-XX:+UseCompressedClassPointers 开启压缩对象头里的类型指针Klass Pointer(默认开启,禁用可用-XX:-UseCompressedClassPointers)。
指针压缩细节问题:
64位 JVM 中使用指针压缩,可以大大减少内存占用,降低内存压力;
32位 JVM 支持最大的内存是4G(2^32);
64位 JVM 中,如果堆内存小于4G,不需要开启指针压缩,JVM 会直接去掉高32位地址;而当堆空间超过32G的时候,指针压缩会失效,强制改为使用64位地址空间。这和指针压缩的实现原理有关。简单来说,指针压缩是从byte的角度寻址,而不是从bit的角度,因为堆里的对象都是8字节对齐的,堆内使用字节角度来寻址更快更优,当然在寄存器层面依然是按位寻址。
2.2.5 执行<init>方法
<init>方法是由 JVM 生成的方法,会执行一系列的初始化,按顺序包括:
父类变量初始化
父类语句块
父类构造函数
子类变量初始化
子类语句块
子类构造函数
这里提一下类加载过程中的<clinit>方法,其内部的初始化步骤按顺序包括:
父类静态变量初始化
父类静态语句块
子类静态变量初始化
子类静态语句块
读者需要注意区分 <clinit> 和 <init>,前者是在类加载阶段执行,而后者是在对象初始化之后执行。也就是说 <clinit> 一定先于 <init> 执行。
Code -> 执行顺序 --> ObjectInitializeOrderTest
-> 了解 --> ExceptionSingleton
2.3 对象回收
2.3.1 对象存活判断算法
常见的垃圾回收器都是通过标记那些存活对象,而没有得到标记的对象将成为垃圾对象被回收。常见的判断对象存活算法有二:
引用计数法:简单高效,但是很难解决循环依赖问题。
可达性分析算法:大多数垃圾收集器都采用此法。可达性算法的GC Roots根节点一般是线程栈本地变量、静态变量、本地方法栈变量等等。
2.3.2 常见引用类型
强引用:最常见的引用方式
Person person = new Person();
软引用:使用 SoftReference 包裹的对象,正常情况下不会回收,但如果 GC 后依然无法释放空间存放新对象的时候,会把软引用对象回收掉。
SoftReference<Person> persion = new SoftReference<>(new Person());
弱引用:使用 WeakReference 包裹的对象,只要发生 GC 会直接被回收掉。
WeakReference<Person> persion = new WeakReference<>(new Person());
虚引用:最弱的一种引用关系。
ReferenceQueue queue = new ReferenceQueue();
PhantomReference<byte[]> reference = new PhantomReference<byte[]>(new byte[1], queue);
2.3.3 回收方法区
无用类判断条件:
该类的所有实例都已经被回收;
加载该类的 ClassLoader 已经被回收;
该类的 Class 对象已经没有任何引用。
垃圾收集
3.1 概览导图
3.2 垃圾收集算法
标记清除算法
需要标记的对象非常多,效率一般不高
内存容易导致空间碎片化问题
标记整理算法
在标记清除算法基础上,增加内存整理功能,避免碎片化的问题
同样存在标记量大而效率不高的问题
标记复制算法
一般需要预留 Survivor 区域,导致空间利用率不高
回收效率非常高
分代收集算法
常见的经典垃圾收集器大多都采用此法
3.3 常见垃圾收集器
3.3.1 经典垃圾收集器
新生代收集器
Serial New 收集器(-XX:+UseSerialGC)
新生代单线程垃圾收集器。
Parallel 收集器(-XX:+UseParallelGC):
也称为 Parallel Scavenge收集器,可以理解为 Serial New 收集器的多线程版本。该垃圾收集器可与 Serial Old 以及 Parallel Old 两款收集器搭配使用。
ParNew 收集器(-XX:+UseParNewGC)
该垃圾收集器专门开发来与 CMS 收集器搭配使用,当然也可以与 Serial Old 垃圾收集器使用。
老年代收集器
Serial Old 收集器(-XX:+UseSerialOldGC)
Parallel Old 收集器(-XX:+UseParallelOldGC)
老年代并行垃圾收集器,仅与 Parallel 收集器搭配使用。
CMS 收集器(-XX:+UseConcMarkSweepGC)
跨代收集器
G1 收集器(-XX:+UseG1GC)
3.3.2 低延迟垃圾收集器
ZGC 收集器:JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。
Shenandoah 收集器:由 Red Hat 的一个团队负责开发,与 G1 类似,基于 Region 设计的垃圾收集器,但不需要 Remember Set 或者 Card Table 来记录跨 Region 引用,停顿时间和堆的大小没有任何关系。停顿时间与 ZGC 接近。
3.3.4 各垃圾收集器之间的关系
JDK8 参数检查:
了解 CMS
4.1 简介
CMS (Concurrent Mark Sweep)是老年代垃圾收集器,内部使用标记清除算法,使用参数-XX:+UseConcMarkSweepGC启用,默认与 ParNew 新生代收集器搭配使用。
CMS 的回收模式一般分为两种background 和 foreground 两种模式。background模式下,一般划分为以下几个阶段:
初始标记:快速标记 GC Roots 直接引用的对象,该阶段为 Stop The World 过程;
并发标记:从 GC Roots 直接引用对象出发遍历其他存活对象,该过程与用户线程并发运行,因此可能会有已被标记的对象状态发生变化。该过程持续时间会比较长,但不会 STW;
重新标记:修正并发标记阶段状态发生变动的对象,该阶段停顿时间会比初始标记稍长,但远比并发标记要短暂。另外,CMS 主要采用了三色标记的增量更新算法实现重新标记;
并发清理:清理未标记的对象,该阶段与用户线程并发运行。同时根据三色标记原理,该阶段新增对象都会被标记为黑色而不必清理。
另外一种模式是 foreground ,该模式下 CMS 将会退化为与 Serial Old 相同的收集算法,也就是采用单线程串行 GC 模式,STW 时间超长,有时会长达十几秒。这种模式下才能准确称为 Full GC。
4.2 图解回收阶段
4.2.1 初始化:
4.2.2 年轻代回收
4.2.3 老年代收集——初始标记、并发标记、重新标记
4.2.4 老年代收集——并发清除前
4.2 主要缺点:
对 CPU 资源敏感,会与应用程序争抢 CPU 资源;
并发标记和并发清理阶段会产生浮动垃圾,需要等待下一次 GC 过程才能回收;
标记清除算法会产生大量空间碎片(但可通过 -XX:+UseCMSCompactAtFullCollection 参数让 JVM 清理后进行内存整理);
在并发标记和并发清理阶段,GC 线程与用户线程并发执行,此时如果再次触发 Full GC,会引起 “concurrent mode failure”,此时会进入 STW 状态,并退化为使用 Serial Old 垃圾收集器来回收。
4.3 常见调优思路
https://tech.meituan.com/2020/11/12/java-9-cms-gc.html
了解 G1
5.1 简介
Region 划分堆空间(从1M到32M,满足2^n次)
目标停顿时间(-XX:MaxGCPauseMillis=200)
混合收集模式
G1 垃圾收集模式分为三种:
Young GC:当 G1 评估当前年轻代的回收时间接近 -XX:MaxGCPauseMillis 指定值的时候,会触发 Young GC;
Mixed GC:当 老年代占有率达到 -XX:InitiatingHeapOccupancyPercent 指定值的时候,会触发并发周期。而并发清理阶段阶段会判断是否需要进入空间回收周期。在空间回收周期内,会根据期望的GC停顿时间以及区块的回收价值等来决定是否进行以及进行多少次 Mixed GC;
Full GC:STW,单线程收集过程。
5.2 图解回收阶段
5.2.1 堆空间分配
5.2.2 年轻代收集过程
5.2.3 初始标记
5.2.4 并发标记
5.2.5 重新标记
5.2.6 复制清理
5.3 主要缺点
停顿时间过长。不适合用户体验高的应用场景;
内存利用率不高。RSet等辅助数据结构会占用1~20%不等的空间大小;
难以支撑超过100G的大堆。
5.4 常见调优思路
https://www.oracle.com/technical-resources/articles/java/g1gc.html
了解 ZGC
6.1 简介
6.1.1 回收阶段
6.1.2 堆空间划分
6.1.3 染色指针
+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
| | | |
| | | * 41-0 Object Offset (42-bits, 4TB address space)
| | |
| | * 45-42 Metadata Bits (4-bits) 0001 = Marked0
| | 0010 = Marked1
| | 0100 = Remapped
| | 1000 = Finalizable
| |
| * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)
6.1.3 读屏障
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用
6.2 图解回收阶段
6.2.1 初始标记
一开始全局处于remapped阶段,活跃状态的对象会被变更为M0
6.2.2 并发标记
标记结束后,活跃对象都处于M0状态,而不活跃对象处于remapped状态
6.2.3 转移阶段
转移活跃对象,转移后的对象会被变更为remapped状态;对象转移后会释放region空间。转移结束后,被转移的对象处于 remapped状态,而没有被转移的对象处于M0状态。
指针自愈:并发转移过程中,如果应用线程读取了被转移的对象,可以通过读屏障将引用从M0状态修改为remapped状态。
6.2.4 修正指针阶段 (同时也是下一个阶段的标记阶段)
下一个阶段开始,使用M1状态代替M0,开始新的初始标记。
6.3 实战效果
使用ZGC
机器配置:测试 4 Core 8G
JVM参数:-Xmx5500m -Xms3g -XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:ConcGCThreads=2 -XX:ParallelGCThreads=4 -XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5 -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive
GC日志分析:gceasy.io
使用G1
机器配置:生产 8 Core 16G
JVM参数: -Xmx10g -Xms8g -XX:+UseG1GC -XX:MaxGCPauseMillis=500 -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=128M -XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=30 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime
GC日志分析:gceasy.io
调优经验分享