Hello,大家好,我是此林。
今天我们来聊一聊 JVM 垃圾回收机制。
目录
老年代- CMS(Concurrent Mark Sweep)垃圾回收器
在C/C++这类没有自动垃圾回收机制的语言中,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏。
内存泄漏指的是不再使用的对象在系统中未被回收,内存泄漏的积累可能会导致内存溢出。
手动回收的方式相对来说回收比较及时,删除代码执行之后对象就被回收了,可以快速释放内存。缺点是对程序员要求比较高,很容易出现创建完对象之后,程序员忘记释放对象。
Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器。
垃圾回收器如果发现某个对象不再使用,就可以回收该对象。
我们先看下 JVM 内存结构。
堆内存是 JVM 中最大的一块内存区域,用于存储对象实例。
垃圾回收主要回收堆内存上的对象。
一、垃圾回收思想
为了判断对象是否可以被回收,有两种垃圾回收思想,分别是引用计数法和可达性分析。
1. 引用计数法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。
当对象的计数器为0时,说明对象没有被引用,可以被垃圾回收。
如上图,栈内存里 car 变量引用了堆内存的Car对象,Car对象计数器加1
person 变量指向了堆内存的 Person 对象,Person对象计数器加1
由于Car对象的成员变量又引用了Person,所以Person 对象计数器为2。
缺点:
当出现循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题。
如图:尽管栈内存没有变量指向A对象或B对象,A对象和B对象的成员变量互相引用,导致了A对象和B对象的计数器都不为0,所以无法被垃圾回收。
A a = new A();
B b = new B();
a.b = b;
b.a = a;
2. 可达性分析法
Java使用的是可达性分析算法来判断对象是否可以被回收,没有使用引用计数法。
下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个对象到GC Root对象是可达的,对象就不可被回收。
二、垃圾回收算法
通过垃圾回收思想,比如通过可达性分析算法排查出哪些对象需要被垃圾回收后,开始使用垃圾回收算法进行清理。
1. 复制算法
复制算法的核心思想是:
1. 准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
2. 在垃圾回收阶段,发现对象A存活,就将其复制到To空间。然后将From空间直接清空。
3. 将两块空间的From和To名字互换。接下来将两块空间的名称互换,下次依然在From空间上创建对象。
优点:
-
吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可
-
不会发生产生内存碎片化。
缺点:
内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
2. 标记-清除算法
核心思想分为两个阶段:
1. 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2. 清除阶段,从内存中删除没有被标记也就是非存活对象。
第一个阶段,从GC Root对象开始扫描,将对象A、B、C在引用链上的对象标记出来:
第二个阶段,将没有标记的对象清理掉,所以对象D就被清理掉了。
优点:实现简单,只需要在第一阶段给每个对象维护一个标志,第二阶段删除对象即可。
缺点:
1. 碎片化问题。由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
比如,GC前内存分配可能如下,蓝色的是对象,白色的是可用内存。
GC后,可用的内存不连续,导致了内存碎片的产生。
2.分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如果链表很长,遍历也会花费较长的时间。
3. 标记-整理算法
标记整理算法是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想分为两个阶段:
1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
优点:
-
内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
-
不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点:
整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。
4. 分代垃圾回收算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用。
整个堆内存被划分为年轻代和老年代。
新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法。(如下图)
核心流程:
1、分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
2、随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC。Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。
3、接下来,S0会变成To区,S1变成From区。和复制算法一样。
4、每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
5、如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
6、当老年代中空间不足,无法放入新的对象时,先尝试minor gc如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。
三、垃圾回收器
由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用。
具体的关系图如下:
可以看到,比如 Serial 垃圾回收器和 Serial Old 组合,Serial 垃圾回收器也可以和 CMS 组合,只不过 JDK9 废弃了这种组合。老年代垃圾回收器 CMS 在特殊情况下会退化成 Serial Old 垃圾回收器,这个后文会讲到。
年轻代-Serial垃圾回收器
Serial是一种单线程串行回收年轻代的垃圾回收器。(复制算法)
老年代-SerialOld垃圾回收器
SerialOld是Serial垃圾回收器的老年代版本,采用单线程串行回收(标记-整理算法)。
年轻代-ParNew垃圾回收器
ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收(复制算法)
老年代- CMS(Concurrent Mark Sweep)垃圾回收器
CMS 是老年代的多线程垃圾回收器。(标记清除算法)
CMS执行步骤:
1. 初始标记,用极短的时间标记出GC Roots能直接关联到的对象。I(会暂停用户线程,导致Stop The World,简称STW)。
2. 并发标记,标记所有的存活的对象,用户线程不需要暂停。
3.重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。
4.并发清理,清理死亡的对象,用户线程不需要暂停。
并发标记和并发清理阶段,会使用3个线程并行处理。重新标记阶段会使用10个线程处理。
三色标记
在并发标记阶段,由于标记期间与应用程序并行,对象间的引用关系可能发生变化,因此采用三色标记的方式对对象进行标记,标记过程分为三种颜色:白色、灰色、黑色。
-
黑色:表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
-
灰色:表示对象已经被垃圾收集器访问过, 但该对象上至少存在一个引用还没有被扫描过。
-
白色:表示对象尚未被垃圾收集器访问过。 在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
标记过程:
-
1)初始时,所有对象都在 【白色集合】中。
-
2)将GC Roots直接引用到的对象转移【灰色集合】中。
-
3)从灰色集合中获取对象:
-
将本对象引用到的其他对象全部转移【灰色集合】中。
-
将本对象转移【黑色集合】中。
-
重复步骤3,直至【灰色集合】为空时结束,结束后仍在【白色集合】的对象即为GC Roots不可达,可以进行回收。
三色标记存在多标和漏标问题。
下面是漏标步骤解析图:
1)最开始时,所有对象都在 【白色集合】中。
2)初始标记阶段,将GC Roots直接引用到的对象转移【灰色集合】中。
3) 继续扫描B、C对象,B、C扫描完后,A就变成了黑色。B变成灰色,C变成黑色。
4)此时,由于用户线程还未暂停,如果此时将B指向D的引用去掉,C指向了D。
5)再扫描B对象,发现没引用其他对象了,B变成了黑色。
此时问题产生了,C已经是黑色,不会再对其依赖对象进行扫描,但事实上C还有一个依赖对象D没有被扫描。如果进行垃圾回收,D会被回收掉,这就是漏标问题。
漏标解决方案
增量更新:将新增的引用维护到一个集合中,将引用的源头变为灰色,等待重新标记阶段再重新进行一次扫描。 如:当C的引用指向了D,则将C放到一个新增引用的集合中,在重新标记阶段会将C作为根节开始继续向下扫描。
三色标记还存在多标问题。
下面是多标步骤解析图:
1)在这个步骤时,如果A指向B的引用去除了。
2)此时B理应被回收,但是因为GC不会对黑色对象做重复扫描,所以B还是黑色,在进行垃圾清理时不会被回收,只能等下次GC时再进行重新标记扫描。这种情况不会导致系统出BUG,只有漏标会导致系统出BUG。
G1垃圾回收器
G1垃圾回收器和传统垃圾回收器不同之处,在于传统垃圾回收器把JVM内存分成了新生代和老年代两个区域,而G1垃圾回收器会把内存分成一个一个region区,每个region区域可能是Eden区,Survivor区,Old区,大对象区(H区) 、未分配区。即新生代和老年代并不是连续的。
Region区的总个数和大小都是可变的。
数量方面,region 默认个数为2048个;大小方面,默认是1MB,还可以手动设置为2的幂次方(1<= V <= 32)。要调整region的值,我们通过调整Xmx和Xms来间接region的值,Xmx和Xms我们通常设置为大小一样。
region = max( (Xmx + Xms) / 2*2048, 1MB)。
新生代分区:分为Eden区和Survivor区,存储新对象。占堆空间的比例动态变化,5%-60%之间。
老年代分区:Old区,存储生命周期比较长的对象。
大对象分区:H区,主要存储比较大的对象,当对象大小超过region区的一般就会放入H区。H区实际上占用的是老年代的空间
通过 -XX:NewRatio=n 来设置新生代和老年代的占比,默认值n=2,此时新生代占整个堆空间的比例就是 1/(n+1),也就是默认情况下新生代和老年代的比例为1:2。
G1 的三种垃圾回收方式
新生代回收(young GC) :只回收新生代的区域,代价低,频率高。每次young gc 后对象的年龄加1,到达年龄15就会晋升到老年代。速度毫秒级。
触发时机:当年轻代空间分配不足的时候。
混合回收(Mix GC):回收全部新生代和部分老年代,频率一般。
触发时机:前提是新生代已经满了,young gc 后发现满足堆内存占用达到45%,或者老年代使用率超过阈值的条件。
完全回收(Full GC):全部堆空间,代价高,频率低,离系统崩溃不远了。速度秒级。
触发时机:混合回收后对象仍然无法分配,再次触发full gc,这次主要回收软引用对象,再不行就OOM 挂了。
下面我们直接读取下GC日志:
// -Xmx128M -Xms128M -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
public class Demo {
public static void main(String[] args) {
byte[] data = new byte[1024 * 256];
for (int i = 0; i < 100; i++) {
data = new byte[1024 * 256];
}
}
}
JVM 参数设置为最大堆内存和初始堆内存都为128MB,
-XX:+UseG1GC:启用 G1 垃圾收集器。
-XX:+PrintGCDetails:打印详细的 GC 日志信息。
-XX:+PrintGCTimeStamps:在 GC 日志中打印时间戳。
0.233: [GC pause (G1 Evacuation Pause) (young), 0.0026428 secs]
[Parallel Time: 1.5 ms, GC Workers: 13]
[GC Worker Start (ms): Min: 233.7, Avg: 233.8, Max: 234.0, Diff: 0.3]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.9, Diff: 0.9, Sum: 2.4]
[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.1, Max: 1.0, Diff: 1.0, Sum: 1.0]
[Object Copy (ms): Min: 0.0, Avg: 0.8, Max: 1.0, Diff: 1.0, Sum: 10.7]
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.9]
[Termination Attempts: Min: 1, Avg: 3.1, Max: 6, Diff: 5, Sum: 40]
[GC Worker Other (ms): Min: 0.0, Avg: 0.1, Max: 0.4, Diff: 0.4, Sum: 1.1]
[GC Worker Total (ms): Min: 1.0, Avg: 1.2, Max: 1.4, Diff: 0.4, Sum: 16.2]
[GC Worker End (ms): Min: 235.0, Avg: 235.0, Max: 235.1, Diff: 0.1]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.3 ms]
[Other: 0.8 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.2 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.2 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 24576.0K(24576.0K)->0.0B(36864.0K) Survivors: 0.0B->2048.0K Heap: 24576.0K(128.0M)->1300.0K(128.0M)]
[Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
garbage-first heap total 131072K, used 13588K [0x00000000f8000000, 0x00000000f8100400, 0x0000000100000000)
region size 1024K, 15 young (15360K), 2 survivors (2048K)
Metaspace used 3470K, capacity 4564K, committed 4864K, reserved 1056768K
class space used 370K, capacity 388K, committed 512K, reserved 1048576K
发现在程序运行的0.233秒时发生了一次年轻代的 young GC,暂停时间为0.0026428秒。
[Parallel Time: 1.5 ms, GC Workers: 13]
表示并行阶段的总时间为1.5ms,参与GC的工作线程数为13。
[GC Worker Start (ms): Min: 233.7, Avg: 233.8, Max: 234.0, Diff: 0.3]
这里展示的是GC线程最早、平均、最晚的开始工作的时间(ms)
[Object Copy (ms): Min: 0.0, Avg: 0.8, Max: 1.0, Diff: 1.0, Sum: 10.7]
这个是Eden对象复制到Survivor区的时间。
[Eden: 24576.0K(24576.0K)->0.0B(36864.0K) Survivors: 0.0B->2048.0K Heap: 24576.0K(128.0M)->1300.0K(128.0M)]
1. 表示 Eden 区从对象占用从 24576.0K 变为 0.0B,Eden扩容后大小为 36864.0K。
2. Survivors区从0.0B 变为 2048.0K。
3. Heap 堆内存对象占用从24576K变成1300K,整体大小不变128MB,这是命令行参数设置过的
Heap
garbage-first heap total 131072K, used 13588K [0x00000000f8000000, 0x00000000f8100400, 0x0000000100000000)
region size 1024K, 15 young (15360K), 2 survivors (2048K)
代表 131072K 表示堆内存的总大小。used 13588K 表示已使用的堆内存大小。
region size 1024K 表示每个区域的大小。15 young (15360K) 表示年轻代有 15 个区域,总大小为 15360K。2 survivors (2048K) 表示 Survivor。
新生代 (Young GC) 回收过程
1. 我们已知堆内存被分成了2048个region,Eden区分布于这些region中,也就是Eden区不是连续的。新创建的对象会分配在Eden区。
2. 随着新对象的不断创建,Eden区所占的region数量不断动态攀升,到达一定阈值(也就是Eden区满了),就会触发新生代垃圾回收。
3. 这个时候会把存活的对象移动到Survivor区,然后回收所有Eden区。
4. 继续分配对象,Eden区满了,触发Young GC,回收Eden区和S区,把存活对象移动到新选定的S区。
5. 经过几次循环,对象年龄大于15的、或S区放不下了会被移动到老年代(Old区)。(大对象直接进入大对象region,但它本身占用的是老年代区域)。
详细过程:
1. 用可达性分析算法,从GC Root出发标记存活对象。(GC Root有线程栈帧中局部变量、堆中静态变量、方法区常量引用、加锁对象等)。
2. 每找到一个存活对象,就复制存活对象到Survivor区,再回收所有Eden区。
3. 动态调整新生代应该分配的region的数量。如果需要更大就增加;如果Young GC能力弱,回收时间长,就减少数量。
4. 判断是否需要开启并发标记。当堆空间占用超过45%,就开启并发标记。如果开启了,那么马上要进入混合回收(Mixed GC)了。
Young GC案例:
一个QPS为2000的订单服务系统,每个订单数据25KB,数据量有50MB左右。
如果使用传统垃圾回收器,
机器使用4核8G物理机,大约两三分钟触发一次Young GC,每次几百毫秒。
如果QPS变成10万,机器升级为64G内存,此时一次Young GC变成几秒钟。
如果使用G1垃圾回收器,
我们可以设置最大停顿时间,比如200ms,G1每次就会从region中选择价值最高的进行回收,-XX:MaxGCPauseMillis=200。这样虽然增加了Young GC的次数,但是减少了每次垃圾回收的延迟,不影响业务,提升了用户体验。
混合回收(Mixed GC)过程
触发条件:Young GC后,已分配的内存超过内存总量的45%会触发Mixed GC。参数是 -XX:InitiatingHeapOccupancyPercent。
1. 初始标记:通过可达性分析算法,标记出GC Root直接引用的对象。时间很短,会STW。(Young GC已经帮忙做了)。
2. 并发标记:
并发标记的起点:新的S区对象(Young GC已经把Eden区存活对象复制到了S区)、老年代GC Root直接引用的对象、解决跨区引用的老年代RSet。标记所有堆内存中的存活对象。时间较长,不会STW。并发标记完后,发现可以回收的region占总空间比例大于5%,才会接着执行。
3. 重新标记:由于并发标记对象间引用可能变化,存在漏标、错标等情况,要重新标记。时间短,会STW。
4. 存活对象计数阶段。统计出每个region存活对象的数量。为什么要统计呢?因为下一步回收时只会从老年代回收一部分区域,因此要先统计每个存活对象数量,才能判断如何选择才能满足用户设定的停顿时间并保证收益最大。
5. 选择回收价值高的region,把存活对象复制到S区,然后回收掉老区域。
停顿预测模型与垃圾区域的选择原理:
在G1里,用户可以通过MaxGCPauseMillis设定整个GC过程的期望停顿时间,默认是200ms。G1会努力在该时间内完成一次垃圾回收。
停顿预测模型主要是针对老年代的,因为堆内存里老年代空间占比一般最大。如果最大停顿时间设置得很小,Young GC时新生代都不一定能全部回收,这样会导致大量GC次数,使系统异常。
为了满足用户设定的停顿时间,停顿预测模型会基于历史每次GC耗时情况,计算平均GC时间,当然会对最近GC的耗时数据给予更高的权重。
如何判断region回收的价值?
通过查看GC日志可知,真正决定垃圾回收时间的是复制转移对象消耗的时间。
一个region里存活对象越少,回收价值越高。因为存活对象少,意味着复制转移对象时间少,转移对象完后,格式化清理region区几乎不消耗时间,这样清理一个region区总时间就少。
垃圾回收最大停顿时间设置过小的结果?
停顿时间过小 ----》 Young GC处理不完Eden区 ---- 》Eden区不会进行扩容 -----》放不下的短周期对象进入老年代 ----》等到堆内存占用高达45% -----》触发混合回收
Full GC过程
触发时机:
Young GC和Mixed GC后都无法分配对象,或者元空间满了会触发Full GC。
在Young GC 和Mixed GC里,都是把存活对象复制到一个region里,也就是S区。Full GC就不能用复制算法了,因为发生Full GC时,已经没有region可以分配对象了,Full GC只能采用标记-整理算法。
1. 用可达性分析算法从GC Root标记存活对象。
2. 更新存活对象的引用地址,引用关系
3. 移动复制整理对象。
关注我吧,老朋友此林,带你看不一样的世界!