vlambda博客
学习文章列表

JVM专题(四)--垃圾回收算法, 垃圾回收器

前言

Java程序在运行过程中会产生大量的对象,但是内存大小是有限的,如果光用而不释放,那内存迟早被耗尽。如C、C++程序,需要程序猿手动释放内存,Java则不需要,是由垃圾回收器去自动回收。垃圾回收器回收内存至少需要做两件事情:标记垃圾、回收垃圾。于是诞生了很多算法, 基于这些算法也有不同的垃圾收集器, 我们将在本文中详细讲解这些垃圾回收算法和垃圾回收器.

垃圾判断算法

判断JVM中的对象, 哪些是存活的, 哪些是可以被回收的算法

引用计数算法

最简单的垃圾判断算法, 为对象添加一个属性, 标记对象被引用的次数, 被引用的话, 计数器+1, 引用失效时, 计数-1, 当计数器等于0时, 表示没有被其他对象引用, 可以进行回收. 但是当出现循环引用的话, 对象会无法被回收. 如下所示:

可达性分析

通过一些列被称为”GC Roots”的对象为根节点集, 通过这些根节点通过引用向下搜索, 如果某个对象不能被搜索到, 则表明这个对象没有被引用, 可以进行回收, 反之表明这个对象是一个存活对象, 不可被回收.
JVM中使用的垃圾判断算法就是可达性分析, 先对能搜索到的对象打个标记, 垃圾回收时回收没有标记的对象
JVM专题(四)--垃圾回收算法, 垃圾回收器

哪些对象可以作为”GC Root”?

  • 所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值.

  • 方法区中类静态属性引用的对象.

  • 方法区中的常量引用的对象

  • 本地方法栈中JNI(native方法)引用的对象

垃圾回收算法

标记-清除算法

GC会分为两个阶段, 标记和清除, 对可回收的对象进行标记, 待标记完成时, 会对进行标记的对象进行回收, 标记-清除会产生空间碎片, 导致大对象分配内存时, 没有连续空间而导致再次触发GC, 标记-清除的效果如图所示:
JVM专题(四)--垃圾回收算法, 垃圾回收器

标记-整理算法

也会分为两个阶段, 第一步先标记可回收的对象, 然后将存活对象向一端移动, 最后清除边界以外的对象, 标记整理算法会解决标记清除算法的空间碎片问题, 存在对象的移动过程, 效率会比标记清除低, 标记-整理的效果如图所示:
JVM专题(四)--垃圾回收算法, 垃圾回收器

复制算法

复制算法会将内存区域分为两块, 每次只使用一块, 当使用的那块内存满了时, 会将存活对象移动到另一块内存区域, 然后直接清空当前使用内存区域. 因为是对半块内存的回收, 所以复制算法效率很高, 也不会存在空间碎片问题, 缺点就是需要两倍的内存空间, 复制算法效果如图所示:
JVM专题(四)--垃圾回收算法, 垃圾回收器

分代收集算法

根据对象的存活周期, 可以对堆区新生代和老年代使用不同的垃圾回收算法, 由于新生代对象经过垃圾回收后, 会有大量的对象被回收, 存活对象较少, 所以一般采用复制算法, 而老年代的对象存活率高, 没有额外的过多内存分配, 所以一般采用标记-清除或者标记-整理算法

三色标记与读写屏障

三色标记

前面我们看到, 不论是哪种垃圾回收算法, 都少不了标记, 垃圾回收器的工作基本上分为两步1.标记存活对象, 2.回收未被标记的对象, 那么JVM是如何标记一个存活对象呢?
根据可达性分析, 我们要找出存活对象, 需要从GC Roots开始遍历, 可达的就是存活对象, 在遍历的时候, 对象会被标记为以下三种颜色:

  • 白色:尚未访问过。

  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。

  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。

如图所示:
JVM专题(四)--垃圾回收算法, 垃圾回收器
A对象已访问过, 并且引用的B对象也访问过了, A会被标记为黑色, B对象已访问, 可能还有其他引用对象未访问, 此时为灰色, 如果其他引用都访问过, B会被标记为黑色, C对象尚未访问过, 被标记为白色.白色是垃圾回收时, 要被回收的对象

读写屏障

读写屏障, 就是在对象的读写操作之前做一些处理, 可以参考Spring AOP的理念:

读屏障
 
   
   
 
  1. 即在读前做一些处理

  2. 读屏障()

  3. 读操作

写屏障
 
   
   
 
  1. 即在写前后做一些处理

  2. 写前屏障()

  3. 写操作

  4. 写后屏障()

三色标记会出现以下三种特殊情况:

多标

JVM专题(四)--垃圾回收算法, 垃圾回收器

如图所示, 用户线程标记了A对象对B的引用, GC线程执行垃圾回收的时候, A断开了对B的引用, 这时候B对象及B对象所引用的对象存在多标的情况, 在此次垃圾回收的时候不会被回收, 可能会在下次垃圾回收的时候进行回收

少标

JVM专题(四)--垃圾回收算法, 垃圾回收器

如图所示, 在GC线程执行过程中, 用户线程创建了新的对象, 因为新创建的对象都是黑色, 如果存在新创建的对象使用后就失去引用了, 那么这些对象就会出现少标的情况, 在此次垃圾回收的时候, 不会进行回收, 可能会在下次GC时进行回收

漏标

JVM专题(四)--垃圾回收算法, 垃圾回收器
如图所示, 在GC执行过程中, A节点开始没有对D的引用, 所以扫描完B之后A被标记为黑色, 然后扫描B的过程中, B断开了对D的引用, 同时A建立了对B的引用,因为A已经被扫描过标记为黑色, 不会被重复扫描, D就是一个被漏标对象, 此次垃圾回收会被回收掉, 导致程序出现异常.

如何解决漏标的问题?:

从之前案例中可以分析出, 漏标出现的情况有两个必要条件

  1. 灰色对象断开了对白色对象的引用; 即灰色对象的成员变量发生变化

  2. 黑色对象重新对白色对象建立引用; 即黑色对象重新引用成员变量

1.读屏障+重新标记

在建立A对D的引用时, 把类似的对象全部标记为灰色或者白色缓存到一个集合中, 然后STW, 等待标记完成后, 重新标记集合中的对象
重新标记环节, 一定要STW, 不然可能会没完没了的标记下去

2.写屏障+增量更新

这种方式是在解决条件2, 即通过写屏障记录下更新, 具体操作是在A对象建立对D对象的引用时, 通过写屏障, 把D对象添加到待扫描的集合中等待扫描

3.写屏障+原始快照

这种方式是在解决条件1, 使扫描时依然能够扫描到D, 具体操作是, 在B断开D的引用时, 把B对D的引用关系保存起来, 在扫描的时候, 扫描旧的对象图,这个旧的对象图就是原始快照

4.实际应用

CMS:写屏障+增量更新
G1:写屏障+原始快照

串行, 并行, 并发

串行:一个GC线程运行
并行:多个GC线程运行
并发:多个GC线程和多个用户线程同时进行(即使是并发收集器, 也会在某些阶段出现STW)

STW

Stop The World, 即GC线程和用户线程是不能同时运行的阶段, 不管事并行还是并发, 总会有阶段出现STW

垃圾回收器

Serial收集器

串行垃圾收集器, 用户线程与GC线程交替进行, 垃圾回收的过程中会STW, 垃圾回收完成后才会恢复所有用户线程, 专注于收集年轻代, 底层是复制算法.
相关参数: -XX:+UseSerialGC 新生代使用Serial收集器
执行过程如图所示:
JVM专题(四)--垃圾回收算法, 垃圾回收器

ParNew收集器

并行垃圾收集器, Serial收集器的多线程版本, 唯一能与CMS搭配使用的新生代垃圾收集器
相关参数:
-XX:+UseConcMarkSweepGC:指定使用CMS后,会默认使用ParNew作为新生代收集器
-XX:+UseParNewGC:强制指定使用ParNew
-XX:ParallelGCThreads:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同
执行过程如图所示:
JVM专题(四)--垃圾回收算法, 垃圾回收器

Parallel收集器

关注吞吐量的垃圾回收器
吞吐量=用户线程执行时间/(用户线程时间+垃圾回收器执行时间)
相关参数:
-XX:MaxGCPauseMilli:是一个大于0的毫秒数, 垃圾回收器会尽量保证垃圾回收时间小于这个值. 但大家也不要想着把这个值设置的很小, 就能使得垃圾收集的速度就更快, GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的: 比如收集300M肯定要比收集500M要快吧, 但是也提升了垃圾回收的次数, 比如本来回收500M是每10s收集一次, 每次停顿50ms, 而300M则每7s收集一次, 每次停顿30ms, 虽然停顿时间降下来了, 但是对应吞吐量也提升了
-XX:GCTimeRatio:是一个(0-100)的整数, 也就是垃圾收集时间占总时间的比率, 计算公式是1/(1+设置值), 比如设置为19, 那么垃圾收集时间占用比率就是1/(1+19)=5%, 该值默认是99, 也就是默认垃圾收集占用总时间的1%
-XX:+UseAdaptiveSizePolicy:一个开关参数, 当开关打开后, 就不用手动指定新生代大小(-Xmn), Eden区与Survivor区的比例(-XX:SurvivorRatio), 晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等细节参数了, java虚拟机会根据当前系统的运行情况, 手机性能监控信息, 来动态的调整这些参数以保证最合适的停顿时间以及最大的吞吐量, 这种调节方式的名称成为GC自适应的调节策略(GC Ergonomics)
执行过程如图所示:
JVM专题(四)--垃圾回收算法, 垃圾回收器

Serial Old收集器

Serial收集器的老年代版本, 基于标记-整理算法实现
有两个作用:
1.与Serial、Parallel收集器配合使用
2.作为CMS收集器的备用方案

Parallel Old收集器

Parallel收集器的老年代版本, 基于标记-整理算法实现

CMS收集器

CMS是我们要重点关注的垃圾收集器, 我们先看下他的执行流程:
JVM专题(四)--垃圾回收算法, 垃圾回收器
CMS收集器聚焦低延迟, 适用于老年代, 基于标记-清除算法实现

从图中我们可以分析下CMS的执行流程:

1.初始标记

会STW,只标记GC Roots直接关联的对象

2.并发标记

不会STW, GC线程用用户线程并发执行.
会沿着GC Roots关联的对象链, 扫描整个对象图, 可想而知整个过程时间会比较长, 但是由于整个过程是不会STW的, 所以不会感觉到卡顿, 只有性能指标会显示CPU飙升

3.重新标记

会STW, 上文说过, 三色标记会出现漏标的问题, CMS收集器就会采用写屏障+增量更新的方式, 将并发标记阶段建立的对象保存起来, 在这个阶段, 就会遍历保存的集合, 防止出现漏标的问题

4.并发清除

不会STW, 用户线程与GC线程并发执行, 清除未标记的对象
默认启动的回收线程数 = (处理器核心数 + 3) / 4

相关参数:

 
   
   
 
  1. -XX:+UseConcMarkSweepGC:手动开启CMS垃圾收集器

  2. -XX:+CMSIncrementalMode:设置为增量模式

  3. -XX:CMSFullGCsBeforeCompaction:进行多少次垃圾回收后, 执行一次内存压缩

  4. -XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收

  5. -XX:UseCMSInitiatingOccupancyOnly:表示在达到阈值时, 才进行垃圾回收

  6. -XX:CMSInitiatingOccupancyFraction:由于CMS垃圾收集器是并发收集器, 即使在运行阶段, 用户线程仍然在运行, 仍然会产生对象, 所以不能等内存满了才进行垃圾回收, 这个阈值默认是92%

  7. -XX:+UseCMSCompactAtFullCollection:表示CMS收集器在执行完一次垃圾回收后, 是否需要进行内存碎片整理

CMS垃圾收集器并不是完美的(不然怎么会产生后面G1,ZGC…呢), 会有哪些缺点呢?

  1. 垃圾回收过程中, 会与用户线程抢夺CPU资源(当然这是所有并发回收器存在的问题);

  2. 无法处理浮动垃圾, (少标产生的对象, 即标记结束后产生的对象)

  3. 因为采用标记-清除算法, 存在内存碎片

G1收集器

G1收集器可以说与之前的所有收集器都不同, 也可以说是所有垃圾收集器的综合体, 他的实现原理是将堆内存分成了一个一个的Region, 这些Region在使用时才会被赋予角色Eden, From, To, Old, humongous. 每个Region只能被赋予一个角色, 一个Region不能既是Eden又是From.
如图所示:

每个region的大小可通过参数-XX:G1HeapRegionSize设置,取值范围是2-32M.
如果一个对象的大小超过一个Region的一半, 则会被认定为是大对象, 大对象会用N个连续的Region进行存储

G1的名字代表的是Garbage First, 回收某个region的价值大小 = 回收获得的空间大小 + 回收所需时间, G1收集器会维护一个优先级列表, 每个region会按照价值大小, 有序排列在这个列表中, 回收的时候, 会优先回收回收价值较大的region, 这也是G1名字的由来.

G1的执行流程如图所示:

从图中我们可以分析下G1的执行流程:

1.初始标记

会STW, 做了两件事
1.修改TAMS的值, TAMS是一个指向当前Region中并发标记过的位置指针, TAMS值以上的新建对象默认标记为存活对象
2.标记GC Roots直接关联的对象
所以这一步会STW,但是会很快

2.并发标记

不会STW, 会根据GC roots引用链, 遍历整个对象图, 标记存活对象
这个阶段耗时会比较长, 同样因为是与用户线程并发执行的, 所以不会感觉到卡顿, 只是性能上会感觉到CPU飙升

3.最终标记

之前说解决漏标时有种解决方案, 就是扫描原始快照, 即对灰色对象断开白色对象的引用时, 记录引用关系, 并且保存到集合中, 这里就是扫描原始快照旧的引用, 防止出现漏标

4.筛选回收

更新Region的统计数据, 对各个Region回收价值进行计算并排序, 根据用户设置的期望回收的暂停时间, 生成回收集
然后开始执行清除操作, 将旧的Region中的存活对象移动到新的Region中, 这个过程会STW

相关参数

 
   
   
 
  1. -XX:G1HeapRegionSize:设置Region的大小

  2. -XX:MaxGCPauseMillis:设置垃圾回收是允许的最大暂停时间(默认200ms)

  3. -XX:+UseG1GC:开启G1

  4. -XX:ConcGCThreads:设置并发标记

  5. -XX:ParallelGCThreadsSTW期间, 并发执行的GC线程数量

G1垃圾回收器也有一定的缺点
1.同样的做为一款并发执行的垃圾回收器, 在回收过程中, 也会与用户线程竞争CPU资源
2.会占用10%~20%的内存来存储G1收集器运行所需要的数据