HotSpot 垃圾回收器
全网最硬核 JVM TLAB 分析
HotSpot虚拟机垃圾收集优化指南
垃圾回收的第一步,就是找出活跃的对象。根据 GC Roots 遍历所有的可达对象,这个过程,就叫作标记。标记完成后,把其他不活跃的对象判定为垃圾,然后删除。所以垃圾回收只与活跃的对象有关,和堆的大小无关。
JVM GC可以分为:
- Minor GC:发生在年轻代的 GC。
- Major GC:发生在老年代的 GC。
- Full GC:全堆垃圾回收。比如 Metaspace 区引起年轻代和老年代的回收。
年轻代垃圾回收器
新生代的对象存活时间短, 使用复制算法进行垃圾回收. 因为年轻代发生 GC 后, 只会有非常少的对象存活,复制这部分对象是非常高效的.
复制算法会造成一定的空间浪费,所以年轻代中间也会分很多区域。有一个比较好的思路可以完成这个整理过程,就是提供一个对等的内存空间,将存活的对象复制过去,然后清除原内存空间。扩缩容或者碎片整理问题时,复制算法都是非常有效的
年轻代分为:一个伊甸园空间(Eden ), 两个幸存者空间(Survivor ), 当伊甸园区空间满时会触发Minor GC. Eden、from、to 的默认比例是 8:1:1, 可通过参数-XX:SurvivorRatio
来更改
- Serial 垃圾收集器: 单线程处理 GC ,使用复制算法, 回收的过程中暂停一切用户线程
- ParNew 垃圾收集器: 多线程处理 GC ,回收的过程中暂停一切用户线程
-
Parallel Scavenge 垃圾收集器: 另一个多线程版本的垃圾回收器, 追求 CPU 吞吐量, 适合弱交互强计算
老年代垃圾回收器
老年代的对象存活率一般是比较高的, 空间又比较大使用复制算法不划算, 所以一般使用“标记-清除”、“标记-整理”算法,采取就地收集的方式。
- Serial Old 垃圾收集器: Serial收集器的老年代版本,单线程, 使用标记-整理算法。
- Parallel Old 垃圾收集器: Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
- CMS 垃圾收集器: CMS(Concurrent Mark Sweep)收集器是以获取最短 GC 停顿时间为目标的收集器,它在垃圾收集时使得用户线程和 GC 线程能够并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。从后续更高版本的JDK版本提示来看, CMS 垃圾回收器逐步被G1 等垃圾回收器取代掉。
- G1、ZGC 垃圾收集器: 启动参数添加-XX:+UseG1GC, -XX:+UseZGC
TLAB(Thread Local Allocation Buffer)
对象分配流程JVM 默认给每个线程开辟一个 buffer 区域,用来加速对象分配, 这个 buffer 就放在 Eden 区中。
对于单线程应用,每次分配内存,会记录上次分配对象内存地址末尾的指针,之后分配对象会从这个指针开始检索分配。这个机制叫做 bump-the-pointer(撞针)。对于多线程应用来说,内存分配需要考虑线程安全。最直接的想法就是通过全局锁,但是这个性能会很差。
为了优化这个性能,我们考虑可以每个线程分配一个线程本地私有的内存池,然后采用 bump-the-pointer 机制进行内存分配。这个线程本地私有的内存池,就是 TLAB。只有 TLAB 满了,再去申请内存的时候,需要扩充 TLAB 或者使用新的 TLAB,这时候才需要锁。这样大大减少了锁使用。
查看GC配置信息
通过-XX:+PrintCommandLineFlags
参数,可以查看当前 Java 版本默认使用的垃圾回收器-XX:InitialHeapSize=126596288 -XX:MaxHeapSize=2025540608 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
JDK1.8默认使用UseParallelGC, 由ParallelScavenge(年轻代) + ParallelOld(老年代)组成, 可通过自带的工具jmc查看相关信息, 启用飞行记录器在JVM启动时需要添加参数-XX:+UnlockCommercialFeatures -XX:+FlightRecorder
CMS垃圾回收器
年轻代使用复制算法,而对老年代使用标记-清除算法。CMS 的设计目标,是避免在老年代 GC 时出现长时间的卡顿. CMS 使用的是 Sweep 而不是 Compact,所以它的主要问题是碎片化。随着 JVM 的长时间运行,碎片化会越来越严重,只有通过 Full GC 才能完成整理. 为什么 CMS 能够获得更小的停顿时间呢?主要是因为它把最耗时的一些操作,做成了和应用线程并行。
把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色(三色标记):
- 白色:尚未被GC访问过的对象,如果全部标记已完成依旧为白色的,称为不可达对象,既垃圾对象。
- 黑色:本对象已经被GC访问过,且本对象的子引用对象也已经被访问过了。
- 灰色:本对象已访问过,但是本对象的子引用对象还没有被访问过,全部访问完会变成黑色,属于中间态。
步骤:
初始标记 -> 并发标记 -> 并发预清理 -> 可中止的并发预清理 -> 重新标记 -> 并发清理 -> 并发重置
也可以简化理解为四个阶段: 初始标记->并发标记->重新标记->并发清理
. 有两个阶段会发生stop-the-world(初始标记和重新标记阶段),其他阶段都是并发执行的。
在并发标记阶段, 应用线程和GC线程是并发执行的, 因此可能产生新的对象或对象关系发生变化, 如新生代的对象晋升到老年代,直接在老年代分配对象,老年代对象的引用关系发生变更等等. 对于这些对象,需要重新标记以防止被遗漏。 为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card标识为Dirty ,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。
在并发清理阶段, 由于 CMS 并发清理阶段用户线程还在运行中,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次 GC 中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为浮动垃圾。
G1垃圾回收器
G1收集器通过多种技术实现了高性能和暂停时间目标, G1将对象从堆的一个或多个区域复制到堆上的单个区域,并且在此过程中,压缩和释放了内存。撤离是在多处理器上并行执行的,以减少暂停时间并增加吞吐量。因此,对于每个垃圾回收,G1都会不断减少碎片。这超出了先前两种方法的能力。CMS(并发标记扫描)垃圾收集不会进行压缩。并行压缩仅执行整个堆压缩,这会导致相当长的暂停时间。
G1 的回收过程主要分为 3 类:
- G1“年轻代”的垃圾回收,同样叫 Minor GC,这个过程和我们前面描述的类似,发生时机就是 Eden 区满的时候。
- 老年代的垃圾收集,严格上来说其实不算是收集,它是一个“并发标记”的过程,顺便清理了一点点对象。
- 真正的清理,发生在“混合模式”,它不止清理年轻代,还会将老年代的一部分区域进行清理。
简单来说, 就是原本内存连续的堆切分成了均等大小的名字叫作小堆区(Region), Region的大小1M 到 32M 字节之间的一个 2 的幂值数。小堆区可以是 Eden 区,也可以是 Survivor 区,还可以是 Old 区。所以 G1 的年轻代和老年代的概念都是逻辑上的. 垃圾最多的小堆区,会被优先收集。这就是 G1 名字的由来
步骤
初始标记->Root 区扫描(Root Region Scan)->并发标记->重新标记->清理阶段
G1的过程和 CMS 垃圾回收器的回收过程非常类似,初始标记和重新标记阶段也是STW
- 并发标记: 这个阶段从 GC Roots 开始对 heap 中的对象标记,标记线程与应用程序线程并行执行,并且收集各个 Region 的存活对象信息。
- 清理阶段: 如果发现 Region 里全是垃圾,在这个阶段会立马被清除掉。不全是垃圾的 Region,并不会被立马处理,它会在 Mixed GC 阶段,进行收集。
CSet
全称是 Collection Set,即收集集合,保存一次 GC 中将执行垃圾回收的区间(Region)。GC 是在 CSet 中的所有存活数据(Live Data)都会被转移。
SATB 算法
全称是 Snapshot At The Beginning,它作用是保证在并发标记阶段的正确性。
这个快照是逻辑上的,主要是有几个指针,将 Region 分成个多个区段。如图所示,并发标记期间分配的对象,都会在 next TAMS 和 top 之间。
RSet
RSet 是一个空间换时间的数据结构。RSet 的功能与卡表(Card Table)类似,它的全称是 Remembered Set,用于记录和维护 Region 之间的对象引用关系。RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象),有点倒排索引的味道。
混合回收(Mixed GC)
能并发清理老年代中的整个整个的小堆区是一种最优情形。混合收集过程,不只清理年轻代,还会将一部分老年代区域也加入到 CSet 中。
ZGC垃圾回收器
如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个 Heap 的回收,那么 G1 要做的工作量就一点也不会比其他垃圾回收器少,而且因为本身算法复杂了,还可能比其他回收器要差。
所以垃圾回收器本身的优化和升级,从来都没有停止过。最新的 ZGC 垃圾回收器,
就有 3 个令人振奋的 Flag:
- 停顿时间不会超过 10ms;
- 停顿时间不会随着堆的增大而增大(不管多大的堆都能保持在 10ms 以下);
- 可支持几百 M,甚至几 T 的堆大小(最大支持 4T)。
内存回收算法
- 复制算法(Copy):复制算法是所有算法里面效率最高的,缺点是会造成一定的空间浪费。
- 标记-清除(Mark-Sweep):效率一般,缺点是会造成内存碎片问题。
- 标记-整理(Mark-Compact):效率比前两者要差,但没有空间浪费,也消除了内存碎片问题。
所以,没有最优的算法,只有最合适的算法。
卡片标记(card marking)
该技术解决老年代到新生代的跨代引用问题。具体是,使用卡表(Card Table)和写屏障(Write Barrier)来进行标记并 加快 对GC Roots的扫描, 老年代是被分成众多的卡页(card page)的(一般数量是 2 的次幂)。卡表(Card Table)就是用于标记卡页状态的一个集合,每个卡表项对应一个卡页。
在进行 Minor GC 时, 只需要扫描由Dirty标记的区域(老年代引用了新生代对象的区域)即可, 大大加快了扫描的速度, 使GC停顿的时间减少
对象如何进入老年代
-
提升(Promotion)
每当发生一次 Minor GC,存活下来的对象年龄都会加 1。直到达到一定的阈值,该对象提升到老年代。这些对象如果变的不可达,直到老年代发生 GC 的时候,才会被清理掉。这个阈值,可以通过参数 ‐XX:+MaxTenuringThreshold
进行配置,最大值是 15
-
分配担保
看一下年轻代的图,每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%。但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保。这个时候,对象也会直接在老年代上分配。
-
大对象直接在老年代分配
超出某个大小的对象将直接在老年代分配。这个值是通过参数 -XX:PretenureSizeThreshold
进行配置的。默认为 0,意思是全部首选 Eden 区进行分配。
-
动态对象年龄判定
有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。比如,如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代。
这些动态判定一般不受外部控制,我们知道有这么回事就可以了。通过下图可以看一下一个对象的分配逻辑。
参考
通过 JFR 与日志深入探索 JVM - TLAB 原理详解
关于栈上分配和TLAB的理解
CMS垃圾收集器
CMS与三色标记算法
一文看透垃圾回收,深入剖析,浅入深出
JVM调优:CardTable简介
一篇文章彻底搞懂CMS与G1
Java之CMS GC的7个阶段
网友评论