JVM GC是面试常见问题。工作中GC未必会带了什么困扰(因为设计带有GC的编程语言就是想程序员不要担心GC的问题),但是每次遇到这些问题再依靠搜索引擎也不是什么好事,所以便打算一次性将关于JVM GC的常见问题都汇总到这里。
JVM运行时的内存布局
在完整理解GC的各种算法前,必先理解JVM的内存布局。
这里不妨使用一个最简单的“HelloWorld”作为例子来方便理解:
// Main.java
public class Main {
static final String NAME = "Bruce";
void hello() {
System.out.println("Hello, " + NAME);
}
public static void main(String... args) {
Main main = new Main();
main.hello();
}
}
// 编译后就是 Main.class
类加载器 ClassLoader
Java编译之后会变成字节码,并非操作系统能运行的本地(Native)码。字节码按类为单位形成各个*.class
文件。所有类文件不是马上全部加载到JVM运行时的内存中的,而是按需加载的。
当程序执行到某个点,需要加载一个尚未加载过的类时(例如程序的main
函数所属的类Main
),便需要将那个*.class
文件加载至JVM运行时的内存中,而执行这个过程的东西就是ClassLoader
(另起文章再展开):
+----------------------+ Main.class ----> | AppClassLoader | +----------------------+ ↓ +----------------------------------------+ | JVM Runtime | +----------------------------------------+ | | +----------------------------------------+
方法区 Method Area
被加载的类的所有方法与常量都会被放置在方法区中,因为类一旦加载了、除非程序有明确的请求要卸载这个类,那么这个类的所有信息都会被一直存放在方法区中,以便后续重复使用,所以这个方法区是整个JVM运行时共享的:
+----------------------+ Main.class ----> | AppClassLoader | +----------------------+ ↓ +----------------------------------------+ | JVM Runtime | +----------------------------------------+ | +------------------------------------+ | | | Method Area + Constant Pool | | | +------------------------------------+ | | | Main.Main() | "Bruce" | | | | Main.hello() | "Hello, " | | | | Main.main() | | | | +------------------------------------+ | +----------------------------------------+
方法区实际上就是类映射到JVM运行时的内存,所以肯定是要占一定的内存空间。可以预见,如果你的Java程序要用到的第三方Jar包越多,方法区所占的空间就越大。
变动
- 在Java8之前,方法区所在的内存空间叫永久代(Perment Generation),可以通过JVM启动参数
-XX:PermSize
指定永久代的初始大小(默认64m)、用-XX:MaxPermSize
指定永久代最大的大小(默认无限制); - 当加载到JVM的类的大小超过
-XX:MaxPermSize
指定的大小,便会产生java.lang.OutOfMemoryError: PermGen space
错误,JVM便会马上停机; - 在Java8之后(含8),方法区所在的内存空间叫元空间(Meta Space)、它实际上是一块本地堆(Native Heap),而常量池则被移到了JVM堆(JVM Runtime Heap)中;
- 原则上任何一版的JVM实现中,JVM本地堆所占的内存都不会算在JVM运行时内,所以原则上就是操作系统还有多少内存,Java8之后都可以用那些内存来加载新的类文件/jar包,这便相当于杜绝了
java.lang.OutOfMemoryError: PermGen
错误; - 但元空间无限增长也不会是所有人都可以接受的,所以Java8之后便可以使用
-XX:MaxMetaspaceSize
来限制每个JVM能用的元空间有多大; -XX:MetaspaceSize
则可以指定元空间的初始大小。
执行引擎 Execution Engine
加载完类之后,JVM已经准备好可以运行各种字节码、进行自动GC了。但实际上真正执行这些任务的,是JVM的执行引擎。它既是JVM的字节码的解析器(Interpreter)、也是即时字节码/本地码编译器(JIT Compiler)、还是垃圾回收器(Garbage Collector)。
+----------------------+ Main.class ----> | AppClassLoader | +----------------------+ ↓ +----------------------------------------+ | JVM Runtime | +----------------------------------------+ | +------------------------------------+ | | | Method Area + Constant Pool | | | +------------------------------------+ | | | Main.Main() | "Bruce" | | | | Main.hello() | "Hello, " | | | | Main.main() | | | | +------------------------------------+ | +----------------------------------------+ ↓ ↑ +----------------------+ | Execution Engine | +----------------------+ |Interpreter| JIT | GC | +----------------------+
线程内存布局
执行引擎准备好,便可以进入主线程。每个线程除了要执行自己线程内的字节码,若遇到尚未执行的类的静态初始化代码,也是要执行的,也就是说,类的静态代码其实是在线程中执行的:
+----------------------+ Main.class ----> | AppClassLoader | +----------------------+ ↓ +----------------------------------------+ | JVM Runtime | +----------------------------------------+ | +------------------------------------+ | | | Method Area + Constant Pool | | | +------------------------------------+ | | | Main.Main() | Main.NAME="Bruce" | | | | Main.hello() | "Hello, " | | | | Main.main() | | | | +------------------------------------+ | | +--------------------------+ | | | Thread: main | | | +--------------------------+ | | | PC Register -> Main.NAME | | | |--------------------------| | | | JVM Stack (Main.static) | | | |--------------------------| | | | Native Stack | | | +--------------------------+ | +----------------------------------------+ ↓ ↑ +----------------------+ | Execution Engine | +----------------------+ |Interpreter| JIT | GC | +----------------------+
程序计数器 Program Counter Register
Program Counter Register 显然就是一个用来寄存当前线程执行到哪个字节码的指针。
栈
JVM栈与本地栈都是方法执行时候的状态记录,按方法调用顺序先进后出。栈帧(Frame)就是各次方法调用的状态的记录。
JVM栈 JVM Stack
+---------------+ | JVM Stack | +---------------+ | Empty Frame | |---------------| | Empty Frame | |---------------| | Empty Frame | |---------------| | Empty Frame | |---------------| | Main.static | # 类的初始化过程被当作一个匿名的方法被推入栈了 +---------------+
JVM栈帧 JVM Stack Frame
而每个JVM栈帧还有自己的内部结构,这些结构都是用来记录每个方法的执行状态的:
+------------------------+ | JVM Stack Frame | +------------------------+ | Local Variable Array | # 局部变量数组 |------------------------| | Operand Stack | # 操作数堆栈 |------------------------| | Ref to Constant Pool | # 对常量池的引用 +------------------------+
栈帧的大小一般都是固定的,因为数组实际上只是一个指针、操作数栈和常量池引用照样也可以预设足够大。但JVM规范没有强制它的大小,因为它很有可能与那个JVM的特色相关的。
而JVM栈的大小就是可以通过启动参数-Xss
来调整的。64位操作系统默认都是1024K大小。其实我们可以通过写一个最简单的递归函数来猜栈帧实际上有多大,例如如果在默认配置下递归1024次就抛出java.lang.StackOverflowError
的话,那每个栈帧就是1KB大小。不少地方都提到JVM的默认栈大小都太大,因为每个线程都要有一套自己的栈,所以如果程序没有递归很深的函数的话,可以适当将栈的大小调到比较小的值(最小不能小过228K),以便程序可以拥有更多的线程。
本地栈 Native Stack
而本地栈及其帧的大小是不能调整的,它们由操作系统决定。
堆 Heap
堆又分为本地(Native)堆和JVM堆,在讨论JVM的时候,说到堆默认就是指JVM堆。因为本地堆的申请和释放都不由GC负责。
当前面的例子执行到new Main()
那一刻,程序就需要向堆申请一个能够放下main对象的空间了:
+----------------------+ Main.class ----> | AppClassLoader | +----------------------+ ↓ +-----------------------------------------------------+ | JVM Runtime | +-----------------------------------------------------+ | +------------------------------------+ | | | Method Area + Constant Pool | | | +------------------------------------+ | | | Main.Main() | Main.NAME="Bruce" | | | | Main.hello() | "Hello, " | | | | Main.main() | | | | +------------------------------------+ | | +--------------------------------------+ +--------+ | | | Thread: main | | Heap | | | +--------------------------------------+ +--------+ | | | PC Register -> new Main() |→| main | | | |--------------------------------------| | | | | | JVM Stack (Main.Main(),Main.main()) | | | | | |--------------------------------------| | | | | | Native Stack | | | | | +--------------------------------------+ +--------+ | +-----------------------------------------------------+ ↓ ↑ +----------------------+ | Execution Engine | +----------------------+ |Interpreter| JIT | GC | +----------------------+
堆是整个JVM都共用的,就像方法区那样。但方法区是不需要进行GC的,堆才需要。线程的栈也不需要GC,因为出栈入栈、线程完成销毁的过程就可以回收内存空间。
所以,JVM需要配置堆的大小,对于64位操作系统的64位JVM:
-Xms
,堆的初始大小,默认-Xms256m
;-Xmx
,堆的最大大小,默认-Xmx4g
;- 如果在一个操作系统实例上只有那一个JVM应用,那么可以考虑设置
-Xms
和-Xmx
为一样的大小,因为JVM的堆内存占用只会增加不会减少,即便空间被GC了也只会在JVM内部循环,不会将空间归还给操作系统;而且这样配置便省却了JVM不断尝试申请更多堆空间时的来回消耗,使得程序更加少GC;当然,这个建议是在已经监控过程序最大所需的内存大小的情况下才这样做,如果两个值配置为一样的时候JVM堆不够用,那么便会出现java.lang.OutOfMemoryError: Java heap space
错误,这样JVM也是会马上停机的; - 由于JVM默认开启了
-XX:+UseCompressedOops
指针压缩,在最大堆大小小于等于32G的时候,JVM会启用4字节/32位指针,因为JVM为了优化性能,采取了8字节对齐的方式来索引内存地址,所以2^32*8B=32GB;当-Xmx
指定大小超过32G,64位JVM会自动进入8字节/64位指针模式,这个时候理论上操作系统允许JVM能申请多大的内存就是多大,理论上不能超过2^64*8B=128EB,但这个值往往不可能达到;而且在使用64位指针模式时,Java程序的运行时开销会变得更大了、普遍来说就是多20%的堆需求,所以在-Xmx32g
至-Xmx38g
这个范围,可用堆的绝对大小是更大了没错,但是实际上JVM能放的对象的数量反而少了。
经典的GC设计都使用了分代设计,因为它比较方面区分临时对象和驻留对象。新生代顾名思义就是用来存放“年龄”不长的数据/对象,老生代相对来说就是存放年龄较长的数据/对象。
新生代 Young Generation
用来指定新生代的初始大小的启动参数是-XX:NewSize
(默认1310M),而新生代最大的大小则用-XX:MaxNewSize
指定(默认没限制)。
新生代本身还有内部结构,分为伊甸园、生还者0和1。
伊甸园 Eden
刚new
/申请出来的对象/数组首先都会放在伊甸园。所以只要程序不断运行,伊甸园就会满;当伊甸园满了,就会触发MinorGC/YoungGC/小GC
。对于那些还在存活的对象/数据,便会从伊甸园复制至生还者0区。
关于对象存活的判断
主要有两个判断方式:
- 引用计数(Ref-Count)法:每个变量名都是一个强引用,一个对象被赋值至一个变量名,引用计数就
+1
;那个变量名若被设置为null
,或者变量引用以失效(例如退栈),对象的引用计数便-1
;引用计数为0
的时候,就可以视它为可回收,因为已经没有外部强引用了; - 可达性(Reachability)分析:如果对象间互相引用对方、或者间接引用对方,引用计数法便会无效,因为引用计数无法减为0;但如果将程序运行时的所有对象的引用关系想象成一个有向图,那么那些有循环应用的对象们会单独处于整个有向图的孤岛环、即无法从
GC Roots
遍历至那些孤岛环,这就可以判断那些对象即便引用计数不为0,也是可以回收的。
生还者 Suvivor 0/1
生还者0与1区都是用来存放伊甸园存活下来的对象的,而它们两个的角色可以在每次MinorGC
互换,即:
- 如果生还者0区是空的话,伊甸园与生还者1区的存活对象都会被复制至生还者1区;
- 如果生还者1区是空的话,伊甸园与生还者0区的存活对象都会被复制至生还者0区。
所以生还者0与1区的大小肯定是一样大小的。但是没有启动参数直接指定伊甸园与生还者的具体大小,只有-XX:SurvivorRatio
指定伊甸园与生还者的大小比例,即-XX:SurvivorRatio = Eden / S0 = Eden / S1
,它默认是6
。
对于JVM刚初始化的时候,便有-XX:NewSize = Eden + S0 + S1
、即S0 = S1 = -XX:NewSize / (2 + -XX:SurvivorRatio)
、Eden = S0 * -XX:SurvivorRatio
。随着JVM堆运行时不断自己增长,伊甸园与生还者的大小也会随着增长。
老/终生代 Old Generation/Tenured
如果生还者在多次MinorGC
都还有对象存活在新生代(不管在哪个区)、或者任意一个生还者区依然是满的,那么就会在当前这次MinorGC
中将那些还在新生代且年龄超过阈值的对象复制至老生代。每进行一次MinorGC
、对象的年龄就会大一岁。
迁移至老生代的年龄阈值不是固定的,JVM可以根据情况自动调整,但也可以人工配置启动参数-XX:InitialTenuringThreshold
指定初始年龄阈值,它默认为7
,即经过8
次MinorGC
后还存活的对象便会被迁移至老生代,7
是因为刚出生的对象年龄被记为0
;还可以指定-XX:MaxTenuringThreshold
指定年龄阈值的最大值,默认15
。
而对于那些占空间很大的对象/数组,如果它大到伊甸园也放不下,在刚刚分配空间给它的那一刻,也会将它安排至老生代,因为老生代一般都会比新生代大。那到底有多大呢?老生代与新生代的比值用启动参数-XX:NewRatio
指定,默认是2
,也就是说默认老生代的大小是新生代的2倍,即Tenured = 2 * YoungGen = 2 * (Eden + S0 + S1)
。
因为对象拷贝的方向是不可逆的,只可以从新生代拷贝至老生代,所以当老生代也被填满的时候,就会触发MajorGC/FullGC/大GC
。
本地接口 JNI 与本地方法库
当前面的例子运行至System.out.println
时,要打印字符串至控制台,实际上是要调用操作系统的输出字符串的功能的,例如这个功能的实现是通过libc.so
的printf
函数实现的,所以便需要一个本地接口JNI与操作系统的本地方法库libc.so
进行交互:
+----------------------+ Main.class ----> | AppClassLoader | +----------------------+ ↓ +-----------------------------------------------------------------------+ | JVM Runtime | +-----------------------------------------------------------------------+ | +------------------------------------+ | | | Method Area + Constant Pool | | | +------------------------------------+ | | | Main.Main() | Main.NAME="Bruce" | | | | Main.hello() | "Hello, " | | | | Main.main() | | | | +------------------------------------+ | | +--------------------------------------------------------+ +--------+ | | | Thread: main | | Heap | | | +--------------------------------------------------------+ +--------+ | | | PC Register -> sysout.println() |→| main | | | |--------------------------------------------------------| | | | | | JVM Stack (sysout.println(),main.hello(),Main.main()) | | | | | |--------------------------------------------------------| | | | | | Native Stack (JNI_printf()) | | | | | +--------------------------------------------------------+ +--------+ | +-----------------------------------------------------------------------+ ↓ ↑ ↓ ↑ +----------------------+ +-----------------------+ +-------------------+ | Execution Engine | | Java Native Interface | | OS Native Library | +----------------------+ +-----------------------+ +-------------------+ |Interpreter| JIT | GC | | JNI_printf() |←→| libc.so | +----------------------+ +-----------------------+ +-------------------+
小结:内存布局及其启动参数
-Xmx -XX:MaxPermSize/-XX:MaxMetaspaceSize |<----------------------------->|<------------------------------>| | -Xms | -XX:PermSize/-XX:MataspaceSize | | |<------------------------>| |<--------------------------->| | | | -XX:NewSize | | | | | |<-------------->| | | | | | -XX:MaxNewSize | | | | | |<----------------->| | | | | |..| Eden | S0 | S1 | Tenured |.| Method Area + Constant Pool |..| \ / \ / \ / \ / \ / \ / \ / \ / \ / \ / Old \ / Young Generation Generation Permanent Generation / Meta space -XX:SurvivorRatio = Eden / S0 -XX:NewRatio = Tenured / (Eden + S0 + S1) Tenured = -XX:NewRatio * (Eden + S0 + S1) = -XX:NewRatio * -XX:NewSize = -Xms - -XX:NewSize S0 = S1 = -XX:NewSize / (2 + -XX:SurvivorRatio) Eden = -XX:NewSize - 2 * S0 = -XX:NewSize * [1 - 1 / (2 + -XX:SurvivorRatio)] = -XX:NewSize * (1 + -XX:SurvivorRatio) / (2 + -XX:SurvivorRatio)
各种GC算法
不同版本的Java默认的GC算法都是不一样的:
- 1.3、1.4、5:单线程串行回收 Serial GC,
-XX:+UseSerialGC
;后续版本的Java如果监测到环境只有单核的话,也有可能自动默认启用这个回收器; - 6、7、8:多线程并行回收 Parallel GC,
-XX:+UseParallelGC
;它也被称为吞吐量(Throughput)收集器,因为它表现为吞吐量优先;8以前的老版本的JVM如果想只对新生代使用并行回收,则可以使用-XX:+UseParNew
参数;8(含)后来的版本的JVM如果想禁止老生代使用并行回收则需要-XX:-UseParallelOldGC
; - 9、10、11、12、13、14:回收优先 Garbage First GC,
-XX:+UseG1GC
;它会保证达成目标的短回收时间,使得运行时不会由于GC暂停太长时间,适用于快速响应、高吞吐量、规模从小至大的机器都适用。
除了默认的,还有一些常用且重要的GC算法:
- 并发标记清除 CMS:
-XX:+UseConcMarkSweepGC
;适用于并发快速响应的场景; - 雪兰朵 Shenandoah:
-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC
;主要目标是让GC时间在99.9%的情况下都在10ms以内,且暂停时常与堆的大小无关;它的内存布局与G1GC是相同的,但就是在GC移动对象的过程中,程序对对象的引用依然可用,这便使得它在外部看来就像一个无需停顿的GC算法; - 无停顿回收 ZGC:
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
;这个算法是JVM最新引入的,目标就是完成一个无需程序停顿也可以完成GC的算法,当然低延迟也是它的目标;由于程序将不会停顿、所以感觉上就像没执行过GC那样零感知,所以才被称为零GC算法,即不是没有GC、而是人类完全感觉不到有GC。
主要的GC类型图示:
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC
是一个不会进行GC的GC,基本只有特殊测试用途,Java11引入的,它不算重要,但是足够有趣。
单线程串行回收:标记-复制
串行GC的算法实际上是一个两阶段的【标记-复制】算法:
首先,将受管的内存(例如新生代)分为大小相同的两份,一份用来分配新来的内存申请,另一分用来保留(带点的位置表示被占用):
+----------------------+----------------------+ |... | | | ... | reserved | | . | | +----------------------+----------------------+
在进行GC的时候,第一阶段就是先将未保留那边的对象不存活的标记出来(“x”代表标记,标识要回收它):
+----------------------+----------------------+ |... | | | .x. | reserved | | . | | +----------------------+----------------------+
然后将存活的那些对象复制至另一边,这就意味着左边的所有空间都被回收了:
+----------------------+----------------------+ | |...... | | reserved | | | | | +----------------------+----------------------+
优点:每次复制完之后,被占用的内存都是连续的,没有内存碎片,简单高效、易于理解。
缺点:受管内存的利用率只有50%,而且由于是串行的、在执行完GC之前,程序的所有代码都是无法运行的,也就是俗称的“Stop-The-World”——全世界停下来GC,简称STW
。
多线程并行回收:多线程标记-复制
这个也是两阶段的【标记-复制】算法,只不过变为多线程。
但多线程也是有些问题的,假设以-XX:ParallelGCThreads=3
设置了3个线程来GC(默认值为CPU有多少个核心就有多少个线程,核超多的机器不妨减少线程):
+----------------------+----------------------+ |... | | gc thread 0 | ... | reserved | gc thread 1 | . | | gc thread 2 +----------------------+----------------------+
标记:
+----------------------+----------------------+ |... | | gc thread 0 | .x. | reserved | gc thread 1 | . | | gc thread 2 +----------------------+----------------------+
复制:
+----------------------+----------------------+ | |... | gc thread 0 | reserved |.. | gc thread 1 | |. | gc thread 2 +----------------------+----------------------+
原理上收集完之后还是有内存碎片的。实际上它会默认开启内存压缩:
+----------------------+----------------------+ | |...... | gc thread 0 | reserved | | gc thread 1 | | | gc thread 2 +----------------------+----------------------+
优点:每次复制完之后,被占用的内存都是连续的,没有内存碎片,简单高效、易于理解,理论上回收线程越多GC的吞吐量越大。
缺点:受管内存的利用率依然只有50%,而且即便是多线程执行GC,但并不是异步的GC,也会出现STW
。
调优
-XX:MaxGCPauseMillis
可以建议回收时间不要超过多少毫秒。但设置了这个参数之后,可能会由于时间太短无法在每次GC的时候都清理得足够干净,从而降低了GC的吞吐效率。默认这个参数是没有配置的,即没有限制最大的停顿时间是多少。
-XX:GCTimeRatio
用来指定GC的时间占JVM进程运行时间多少比例。实际的比例公式是1 / (1 + -XX:GCTimeRatio)
,默认这个参数值为99
,即默认的GC能占用进程1/(1+99)=1%
的运行时间。
这两个参数配合着使用就可以控制并行回收时节奏与每次停顿时常。
但又由于【标记-复制】算法本身是线性扩张的,即便又多线程的辅助,当JVM的堆慢慢增长到很大的时候(例如没有配置-Xmx
),回收的停顿时间也是会线性增长。所以说使用这个回收器的时候需要仔细监控/考虑使用多大的堆。
如果堆无可避免地要变大,那么是不是要考虑使用一些堆增长/缩小控制的参数。默认情况下,JVM的堆增长比例为20%(参数-XX:YoungGenerationSizeIncrement=20 -XX:TenuredGenerationSizeIncrement=20
)、缩小比例为5%(参数-XX: AdaptiveSizeDecrementScaleFactor=4
,计算方式为5% = 20% / 4 = -XX:YoungGenerationSizeIncrement / -XX:AdaptiveSizeDecrementScaleFactor
)。
适当调高缩小比例有助于主动缩小堆大小,这样理论上回收时间就会缩减;但太高的缩小比例会使得JVM不断在重复分配内存,这也会影响程序的性能。
Java9还带来了-XX:-ShrinkHeapInSteps/-XX:+UseAggressiveHeapShrink
参数。前面的缩小比例是在小GC的时候使用的,而新添加的这个参数,允许JVM在每次大GC缩小JVM进程占用操作系统的内存。以前JVM即便GC效果是正确正常的,在操作系统外面看也是不会主动释放任何内存的,这个参数就使得JVM这个毛病被根治了。
Concurrent Mark Sweep:并发标记清除
CMS就是并发标记清除算法,它的基础理论就是二阶段【标记-清除】。
假设我们的堆占用如下:
+--------+ |... | | ... | | .| +--------+
在进行GC的时候,第一阶段就是先将不再存活的对象标记出来(“x”代表标记,标识要回收它):
+--------+ |... | | .x. | | .| +--------+
最后清除阶段,就是只将无效的对象空间给清除/释放掉:
+--------+ |... | | . . | | .| +--------+
可以看到,这个算法天生就有内存碎片的问题。如果需要连续的空间超过12,那么上面的堆便无法继续工作了,要么扩堆、要么OOM
。
优点:不用复制/移动对象,只需要标记,停顿时间肯定会比【标记-复制】算法少(GC响应时间小优先而非GC吞吐量优先),且天生可以并发执行标记和释放过程。
缺点:内存碎片,标记过程依然会STW
、即便比以前的算法要短。
实际上JVM在CMS无法释放足够空间时候,会将GC算法退级至原有的压缩收集器,压缩收集器只会在申请连续空间无法放下、但是可用空间又够的情况下出现,也就是FullGC
,执行压缩的过程理所当然需要STW
;如果计算发现压缩收集器也无法达到申请空间的需求,就会直接抛出OOM
并且停机。
但又由于内存碎片的关系,使用CMS之后打印GC日志就会发现FullGC
出现的频率高了。
另外需要注意的就是CMS只会作用于老生代,因为新生代本身就有伊甸园拷贝至生还者区这个过程,实际上使用CMS只会增加麻烦。而老生代的对象本身年龄就已经比较大,存活下去的可能性极高,没必要通过【标记-复制】这样的过程移来移去,所以像CMS这样的【标记-清除】算法反而很适合老生代,即便以内存碎片为代价。
真正的CMS实现其实是分四阶段的,就是【初始标记-并发标记-再标记-并发清除】:
+--------+ |... | | ... | | .| +--------+
初始标记就是从GC Roots
开始做一次深度为1的可达性测试,由于GC Roots
的一阶可达对象没多少,所以这个过程即便串行执行也很快,没必要并发执行,但由于它是串行的,需要STW
很短一段时间:
+--------+ |... | | .x. | | .| +--------+
并发标记就是利用多线程一次性标记找到那些无效对象,程序业务代码可以并发继续运行:
+--------+ |.x. | gc thread 0 | .x. . | gc thread 1 | x| gc thread 2 +--------+
再标记就是为了防止再并发标记阶段程序业务代码又产生了一些垃圾,但即便有垃圾、量也是很少的,所以这个过程依然要STW
很短一段时间:
+--------+ |.x. | gc thread 0 | .x. x | gc thread 1 | x| gc thread 2 +--------+
最后就是并发清除阶段,直接将前面标记出来的垃圾对象的空间并发地释放出来
+--------+ |. . | gc thread 0 | . . | gc thread 1 | | gc thread 2 +--------+
调优
-XX:ConcGCThreads
可以控制有多少线程来并发执行GC,默认是CPU的核心数量,核心超多的话不妨减少这个值。
-XX:CMSInitiatingOccupancyFraction=75
(默认-1)与-XX:+UseCMSInitiatingOccupancyOnly=true
(默认为false),用来控制老生代占用率达到多大的时候开始CMS回收。默认不配置这些参数代表每次MinorGC都会触发CMS进入标记阶段。配置这个参数后就可以在老生代占用率达到70%的时候才开始CMS的标记阶段。
-XX:+ScavengeBeforeFullGC=true
(默认为false),让FullGC之前先执行一次MinorGC。如果不配置占用比例的话貌似没什么用,因为原本就是先小GC再大GC的。
-XX:+CMSScavengeBeforeRemark=true
(默认为false),让CMS二次标记(Remark)之前先执行一次MinorGC。如果程序有很多老生代的对象引用一些临时对象的情况,这个配置有可能可以让CMS在回收的时候释放更多内存。
版本8之前的CMS还有一个增量模式 (-XX:+CMSIncrementalMode
),但是8就取消了这个模式,所以这个模式的调优就懒得查了。
回收优先:Garbage First
G1GC是JVM GC算法的新纪元的代表,因为使用它的时候实际上JVM的堆布局已经完全改变了,不再是新生代、老生代,老生代还要再分伊甸和生还者这样的结构。
首先它将整个堆分为大小相同的多个区域(Region):
+----+----+----+----+ | | | | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | | +----+----+----+----+
每个区域都有自己的年代属性,即所有区域都可以是伊甸园(E)、生还者(S)、老生代(O)和笨拙者(Humongous)。每个区域都可以放置多个对象,只要放得下就可以了,而同一个区域内的对象的年代属性都被视为是一样的,即便它们的年龄可能不一样。笨拙者是新的概念,专门用来存放连续的、而且占空间大的对象(例如占大于等于1/2个区域大小),只有在FullGC阶段才会扫描这个年代的区域。
+----+----+----+----+ | E8 | S1 | | | +----+----+----+----+ | | | S6 | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | H1 | +----+----+----+----+
新分配的的对象先从多个区域中找到一个伊甸园,看看它的剩余空间够不够放着新对象,够的话就直接放下,不够的话就占用一个新区域来作为一个伊甸园。
新对象申请空间,发现第一个伊甸园的剩余空间还够,就放进去:
+----+----+----+----+ | E9 | S1 | | | +----+----+----+----+ | | | S6 | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | H1 | +----+----+----+----+
再申请一个新对象,发现仅有的一个伊甸园满了,先执行MinorGC
:
+----+----+----+----+ | E9→|?S1 | | | # 伊甸园里面的对象足够放进邻近的生还者吗? +----+----+----+----+ | | | S6 | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | H1 | +----+----+----+----+
+----+----+----+----+ | E9 | S1 | | | # 不够放,伊甸园的对象整体疏散(Evacuate)至另一个区域 +-↓--+----+----+----+ | | | S6 | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | H1 | +----+----+----+----+
+----+----+----+----+ | | S1 | | | # 发现原本那两个生还者其实可以同时合并为一个老生代区域 +----+-↓--+----+----+ | S9 | |←S6 | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | H1 | +----+----+----+----+
+----+----+----+----+ | | | | | # 这样新来的对象就有足够便利的位置了 +----+----+----+----+ | S9 | O7 | | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | H1 | +----+----+----+----+
再申请空间:
+----+----+----+----+ | E1 | | | | +----+----+----+----+ | S9 | O7 | | | +----+----+----+----+ | | | | | +----+----+----+----+ | | | | H1 | +----+----+----+----+
可以看到,G1GC的回收过程是以整个区域为单位来进行回收的,而且每次申请空间的时候都有可能触发MinorGC
重整区域,也就是说都是优先执行回收再申请空间,所以名字上就是Garbage First。其实回收过程与原本的分代年龄晋升、标记-复制过程十分相似,但是单位从对象改为区域。
整个堆只需要有一个空闲区域,就足够这个算法运行下去,不像普通的标记-复制算法那样需要预留50%的堆空间。
更有趣的是,G1GC老年代回收理论上应该也叫FullGC
的,但实际上按照这个回收规则,它是可以在扫描老年代的区域的时候一并将新生代的那些区域也回收了,所以被称为Mixed GC
。它被分为五个阶段:
- 初始标记阶段:又叫疏散阶段,实际上就是一个
YoungGC
,由于G1的小GC是STW
的,所以这个阶段就是STW
,目的就是先将那些小GC就可以处理掉的区域先尽量合并在一起; - 根区域扫描阶段:将小GC存活下来的生存着区域都扫描一遍,看看那些生还者对象有引用老生代区域的对象,这些生还者对象可以作为扫描的根部;这个过程明显可以并发执行,不需要停顿业务代码;
- 并发标记阶段:这个过程与CMS的并发标记过程很像,只不过标记过程的开始不是所谓的
GC Roots
一阶子节点,而是前面那个节点扫描出来的生存者;这个阶段也是并发执行的,不需要停顿业务代码; - 再标记阶段:与CMS类似,并发标记过后,需要
STW
来再标记一次,这个过程耗时很短; - 清理阶段:发现那些整个区域的对象都被标记为无效的时候,那个区域也被视为无效,直接将整个区域的空间收回;那些还混有部分存活对象的区域,在释放垃圾对象之后就执行一次压缩,由于压缩需要移动/复制对象,所以需要
STW
;足够幸运的话清理阶段也是可以不STW
的。
优点:堆的利用率高,执行高效、易于理解,而且可以以指定的停顿时间来规划回收的范围——要求很短的回收时间便在小GC的时候牵扯更少的区域,时间允许很长的话便尽量回收更多区域,还避免了内存碎片(区域内的角度),而且还避免了停顿时间的线性增长,因为对于空闲的区域是不会做任何事情的。
缺点:依然无法避免STW
;对象在不同区域间疏散的时候是不可用的,这个是STW
不可避免的核心。
调优
G1GC除了出色的设计与执行效率,还具有广泛的适应性。
低延迟配置
先将-Xms
与-Xmx
设置为一样,或者-XX:+AggressiveHeap
,免得JVM重复来回开辟堆空间。
-XX:+AlwaysPreTouch
,总是预先将所有空闲区域的内容设置为0。
-XX:+UseNUMA
启用NUMA,以便保证线程所属的CPU核心与内存有亲缘性。跨NUMA的操作会比同一个的慢一点。
降低-XX:G1NewSizePercent
比例,默认为5%,这个比例是指所有新生代(伊甸元与生还者)占所有区域的比例。由于小GC和混合GC第一阶段会遍历所有新生代区域,所以如果降低新生代的比例,就可以降低小GC和混合GC第一阶段STW
的耗时长度。
降低-XX:G1MaxNewSizePercent
比例,默认为60%,这个是上值的最大值。
直接指定区域大小-XX:G1HeapRegionSize
,默认为2MB。越大的区域大小,内涵的对象个数便会越多,对象与多、与其它区域交叉引用的可能性便越高,扫描与标记的效率就越高、停顿时间越短;与此同时,对象越多就意味着小GC/疏散阶段要复制的对象便越多,这些阶段停顿时间便越长。
处理Mixed GC停顿太长
指定-XX:InitiatingHeapOccupancyPercent
比例,默认为45%,即堆占用率大于等于45%的时候就会开始执行Mixed GC。
降低-XX:G1MixedGCLiveThresholdPercent
比例,默认为65%。这个配置用来指定哪些老生代的区域会被执行Mixed GC,即高于等于这个比例的区域就会被执行。如果区域内的对象越多,那么Mixed GC的耗时就会越长,降低这个比例会使得那些不是占比很大的区域都会尽早进行Mixed GC,所以每次Mixed GC的对象数量都相对较少。
增大-XX:G1MixedGCCountTarget
,默认是8。降低-XX:G1MixedGCLiveThresholdPercent
比例就意味着Mixed GC更短的停顿,也意味着需要多执行几次Mixed GC来确保达到老生代区域的存活对象的占用率小于-XX:G1MixedGCLiveThresholdPercent
。
例如默认配置就是堆占用率大于等于45%的时候,通过执行8次Mixed GC、达到所有老生代区域的存活对象占用率小于65%。
增大-XX:G1HeapWastePercent
比例,默认为10%。如果堆可用的空间小于这个比例,是不会执行Mixed GC的,而执行完Mixed GC之后,预计可用的堆空间会比之前多一些,所以这个配置就相当于你愿意浪费/预留多少比例的堆空间来触发Mixed GC。这个值越大,Mixed GC的触发频率越高。
降低-XX:G1OldCSetRegionThresholdPercent
比例,默认为10%。这个比例指明了每次Mixed GC所处理的老生代区域应该至少占堆的多大比例,所以这个值越大,Mixed GC耗时越长。
高吞吐量配置
-Xms
与-Xmx
设置为一样,或者-XX:+AggressiveHeap
,不赘述。
-XX:+AlwaysPreTouch
,不赘述。
-XX:+UseLargePages
,启用大内存页(需要操作系统支持)。
$ grep Hugepagesize /proc/meminfo
> Hugepagesize: 2048 kB
# echo 512 > /proc/sys/vm/nr_hugepages
> 512*2MB=1GB Hugepage space
增大-XX:MaxGCPauseMillis
,默认200毫秒。这个值可以启发性地诱导新生代各区域的大小,而新生代的大小又直接决定了小GC的频率。如果这个允许的停顿越大,小GC的频率就越低,吞吐量越高,反之就是小GC越频繁、吞吐量越低。
如果上面的诱导式参数无法达到预期,那么可以直接增大-XX:G1NewSizePercent
比例,同理还需增大-XX:G1MaxNewSizePercen
比例,不赘述。
降低-XX:G1RSetUpdatingPauseTimePercent
比例,默认为10%。这个更新RSet所导致的停顿时间实际上就是并行标记过程,如果并行标记更新RSet能占停顿时间的比例越高,就越多标记工作被挪到并行过程进行,CPU占比就越高、吞吐量越低;这个比例越低,就越多标记工作被挪到串行过程,CPU不需要来回切换所以占比低、吞吐量就越高。不过后续的版本没了这个参数了,仅当参考。
小结
G1GC在同样垃圾量、同样内存布局的情况下,小GC/混合GC越频繁、每次STW
的时间就越短,相反,小GC/混合GC的STW
时间越长,吞吐量就越高,频率就越低。G1GC的各种配置都是围绕这两个方向来进行的。
雪兰朵:Shenandoah
可以视它为G1的改进版。GC过程基本上是一致的。
它引入了一种叫做“代为转发指针”(Brooks Forwarding Pointer)的技术,使得对象在疏散过程和Mixed GC的清理压缩阶段依然可用,这就相当于STW
可以更短了,被疏散的对象在迁移的过程中也是可以正常工作的。
但是实际上使用它的时候还是会有一些停顿、以便它GC的过程更顺利。那些停顿将会被注入到恰当的业务代码线程当中,而不是使得整个JVM暂停。
调优
-Xms
与-Xmx
设置为一样,或者-XX:+AggressiveHeap
,不赘述。
-XX:+AlwaysPreTouch
,不赘述。
-XX:+UseNUMA
启用NUMA,虽然这个算法声称还不支持NUMA,但开启也是有好处的。
-XX:+UseLargePages
,启用大内存页。
-XX:+UseTransparentHugePages
,启用透明巨大内存页(需要操作系统支持)。
# echo 1 > /sys/kernel/mm/transparent_hugepage/enabled
# echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
-XX:-UseBiasedLocking
禁用偏向锁,现代CPU直接使用轻量级自旋锁的效率比JVM自行判断线程的偏向锁要高一些。(貌似JDK15默认就会禁用偏向锁,已经通过提案了。)所以对于要求低延迟的场景,现在应该尽量禁止偏向锁。
官方说法就是以更高的CPU占用率换取更低的延迟。
无停顿回收:ZGC
ZGC的目标是:即便面对TB级别的堆大小也要维持小于10毫秒的暂停时间。
ZGC的原理貌似又回归了GC算法的基本步:不再按代划分内存,直接在对象指针上做文章;堆依然采用分区域管理方法,释放空间效率更高。
ZGC只能在64位的操作系统上的64位JVM上启用,因为新的对象指针采用了“带色指针”(Colored Pointer)技术:
Finalizable Marked1 ↓ ↓ 63 46 45 43 41 0 00 0000 0000 0000 0000 _ 0 0 0 0 _ 00 0000 0000 0000 0000 0000 0000 0000 +----------------------+ ↑ ↑ +-------------------------------------+ | 18bits, reseverd | | | | 42bits object addr space,2^42=4TB | +----------------------+ | | +-------------------------------------+ Remapped Marked0
- 高18位暂时没用途;
- 第45位用来标记对象是否可以终极化,这一位只允许finalizer使用;
- 第44位用来标记对象的地址是否已经被重新映射;
- 第43位是标记阶段1;
- 第42位是标记阶段0;
- 上面4位被称为对象指针的“颜色”,且它们同一时刻只能有1个位是1、其它都是0;
- 低42位是对象空间,4TB是寻址范围。
传统GC在做对象标记时,为了防止其它线程将对象修改,就要靠STW
将整个JVM都暂停。而ZGC的带色指针在移动对象到别处时,会加入读屏障(就是与volatile
一样的机制),当对象被成功移动到别处后且更新指针完毕,才会取消这个读屏障,这便使得其它线程看到对象的指针总是有效、正确的,称这个为STW
可能不再正确,或许称Stop-The-Thread
更恰当。
它将整个GC周期划分为了三个阶段,而每个阶段的暂停时间都将会满足小于等于10毫秒这个设计目标、且不论JVM的堆内存有多大,且每个阶段都有自己不同的任务:
- 暂停标记开始阶段 Pause Mark Start:从
GC Roots
开始找到一阶可达对象1、2、4,红色代表那个对象的带色指针Marked0
被设置为1了;- 并发标记 Concurrent Mark:从各一阶可达对象出发遍历整个对象图,找到那些垃圾对象3、6、7;
- 并发标记 Concurrent Mark:从各一阶可达对象出发遍历整个对象图,找到那些垃圾对象3、6、7;
- 暂停标记结束阶段 Pause Mark End;
- 并发预备重定位 Concurrent Prepare for Reclocate:关键就是建立两个含有垃圾对象的区域内的存活对象的转发表;
- 并发预备重定位 Concurrent Prepare for Reclocate:关键就是建立两个含有垃圾对象的区域内的存活对象的转发表;
- 暂停重定位开始阶段 Pause Relocate Start:不需移动的对象1、2,和可以在相邻新区域找到复制空间的对象4,它们的带色指针都被标记为红色、即
Remapped
被设置为1了,同时转发表记下对象4的旧地址至新地址的映射;- 并发重定位 Concurrent Relocate:尽可能将旧区域内的存活对象迁移至新区域中,并记下新旧地址映射;
- GC周期结束:将转发表中的地址设置会原本的对象指针低42位中。
- 并发重定位 Concurrent Relocate:尽可能将旧区域内的存活对象迁移至新区域中,并记下新旧地址映射;
一个ZGC周期好像结束得很仓促,但事实上ZGC为了增强GC的回收能力,是将两个GC周期首尾相接、重叠地进行的,这也是为什么需要有两个标志位Marked0
和Marked1
,宏观上这是用一个线程模拟了两个GC线程的并行,下一周期的Mark Start和Mark End才是上一周期的Finalizer的执行时机:
而Finalizable
那一位实际上就是上图例子中3、6、7这三个垃圾对象被找出来后,它们的指针的这个颜色位就会被标记为1,Finalizer终极化线程在它们被释放前看到它们的指针是可被终极化的,所以就对它们执行终极化程序。
可以看到,ZGC就像游击队那样,边走边战的,没有什么大停顿,所以便没有了什么大小GC之分,可以认为每个阶段都是小GC,只不过任务不一样了而已。
ZGC与G1GC同样只需要有一个空闲的区域就可以继续运行下去(不过精细地说,还需一部分存放转发表的空间)。而不同的是,G1GC为了压缩空间会选择在已有的区域中就地压缩,而从上面的例子可以看到,ZGC喜欢直接将对象迁移到新的空闲区域,宏观上同样达到了压缩空间的效果,但是没有了专门为了压缩空间而STW
的要求,转而利用转发表、读屏障等机制达到同样的效果。
优点:堆利用率高,低延迟、高吞吐量、可水平扩展,易于(没什么可)调教,适用范围广。
缺点:原理上不是一时半刻就可以完全理解,相当复杂的算法,这里看起来容易理解只是因为我只看得懂这些,那些多重映射优化、颜色冲突解决机制我是一时半刻没看懂,但感觉上忽略掉那些来理解这个算法也是完全可以的。
调优
尽可以能调高-Xmx
,对于ZGC,堆越大越方便操作。
如果你的机器CPU很多,适当调小-XX:ConcGCThreads
,因为它默认为CPU数量,但是ZGC的线程越多,并发标记阶段切换线程所浪费的CPU时间就越多。具体要配置多少要自己监控统计程序的运行状况。
-XX:+UseNUMA
启用NUMA,这个算法官方声称支持NUMA的。
-XX:+UseLargePages
,启用大内存页。
-XX:+UseTransparentHugePages
,启用透明巨大内存页。
大内存页对堆极大的ZGC就是有天然优势。
真的十分易调教,关键参数只有两个。
ZGC显然十分适合于那些高性能CPU、内存集中型的运算的场景,例如Spark。
有些服务器可能4TB的堆空间也不够用,可以考虑自己修改zGlobals_linux_x86.hpp
里面的ZPlatformAddressOffsetBits = 42
常量,然后重新编译一份JDK出来,将它修改为43之后对应堆空间就可以达到8TB、修改为44之后就可以达到16TB。貌似官方也意识到这个问题,后续的JDK是不是可以直接在启动参数指定这个值?不过对于我们这些小朋友来说,4TB足够了,我连40GB内存的服务器都没有,谈这个是不是很无聊?