前提
最近由于系统业务量比较大, 从生产的 GC 日志 (结合 Pinpoint) 来看, 需要对部分系统进行 GC 调优. 但是鉴于以往不是专门做这一块, 但是一直都有零散的积累, 这里做一个相对全面的总结. 本文只针对 HotSpot VM 也就是 Oracle Hotspot VM 或者 OpenJDK Hotspot VM, 版本为 Java8, 其他 VM 不一定适用.
什么是 GC(Garbage Collection)
Garbage Collection 可以翻译为 "垃圾收集" -- 一般主观上会认为做法是: 找到垃圾, 然后把垃圾扔掉. 在 VM 中, GC 的实现过程恰恰相反, GC 的目的是为了追踪所有正在使用的对象, 并且将剩余的对象标记为垃圾, 随后标记为垃圾的对象会被清除, 回收这些垃圾对象占据的内存, 从而实现内存的自动管理.
分代假说(Generational Hypothesis)
名称 | 具体内容 |
---|---|
弱分代假说(Weak Generational Hypothesis) | 大多数对象在年轻时死亡 |
强分代假说(Strong Generational Hypothesis) | 越老的对象越不容易死亡 |
弱分代假说已经在各种不同类型的编程范式或者编程语言中得到证实, 而强分代假说目前提供的证据并不充足, 观点还存在争论.
分代垃圾回收器的主要设计目的是减少回收过程的停顿时间, 同时提升空间吞吐量. 如果采用复制算法对年轻代对象进行回收, 那么期望的停顿时间很大程度取决于次级回收 (Minor Collection) 之后存活的对象总量, 而这一数值又取决于年轻代的整体空间.
如果年轻代的整体空间太小, 虽然一次回收的过程比较快, 但是由于两次回收之间的间隔太短, 年轻代对象有可能没有足够的时间 "到达死亡", 因而导致回收的内存不多, 有可能引发下面的情况:
年轻代的对象回收过于频繁并且存活下来需要复制的对象数量变多, 增大垃圾回收器停顿线程和扫描其栈上数据的开销.
将较大比例的年轻代对象提升到老年代会导致老年代被快速填充, 会影响整个堆的垃圾回收速率.
许多证据表明, 对新生代对象的修改会比老年代对象的修改更加频繁, 如果过早将年轻代对象晋升到老年代, 那么大量的更新操作 (mutation) 会给赋值器的写屏障带来比较大的压力.
对象的晋升会使得程序的工作集合变得稀疏.
分代垃圾回收器的设计师对上面几个方面进行平衡的一门艺术:
要尽量加快次级回收的速度.
要尽量减少次级回收的成本.
要减少回收成本更高的主回收(Major Collection).
要适当减少赋值器的内存管理开销.
基于弱分代假说, JVM 中把堆内存分为年轻代 (Young Generation) 和老年代(Old Generation), 而老年代有些时候也称为 Tenured.
JVM 对不同分代提供了不同的垃圾回收算法. 实际上, 不同分代之间的对象有可能相互引用, 这些被引用的对象在分代垃圾回收的时候也会被视为 GC Roots(见下一节分析). 弱分代假说有可能在特定场景中对某些应用是不适用的; 而 GC 算法针对年轻代或者老年代的对象进行了优化, 对于具备 "中等" 预期寿命的对象, JVM 的垃圾回收表现是相对劣势的.
对象判活算法
JVM 中是通过可达性算法 (Reachability Analysis) 来判定对象是否存活的. 这个算法的基本思路就是: 通过一些列的称为 GC Roots(GC 根集合)的活跃引用为起始点, 从这些集合节点开始向下搜索, 搜索所走过的路径称为引用链(Reference Chain), 当一个对象到 GC Roots 没有任何引用链相连时, 说明该对象是不可达的.
GC Roots 具体是指什么? 这一点可以从 HotSpot VM 的 Parallel Scavenge 源码实现总结出来, 参考 jdk9 分支的 psTasks.hpp 和 psTasks.cpp:
- // psTasks.hpp
- class ScavengeRootsTask : public GCTask {
- public:
- enum RootType {
- universe = 1,
- jni_handles = 2,
- threads = 3,
- object_synchronizer = 4,
- flat_profiler = 5,
- system_dictionary = 6,
- class_loader_data = 7,
- management = 8,
- jvmti = 9,
- code_cache = 10
- };
- private:
- RootType _root_type;
- public:
- ScavengeRootsTask(RootType value) : _root_type(value) {}
- char* name() { return (char *)"scavenge-roots-task"; }
- virtual void do_it(GCTaskManager* manager, uint which);
- };
- // psTasks.cpp
- void ScavengeRootsTask::do_it(GCTaskManager* manager, uint which) {
- assert(ParallelScavengeHeap::heap()->is_gc_active(), "called outside gc");
- PSPromotionManager* pm = PSPromotionManager::gc_thread_promotion_manager(which);
- PSScavengeRootsClosure roots_closure(pm);
- PSPromoteRootsClosure roots_to_old_closure(pm);
- switch (_root_type) {
- case universe:
- Universe::oops_do(&roots_closure);
- break;
- case jni_handles:
- JNIHandles::oops_do(&roots_closure);
- break;
- case threads:
- {
- ResourceMark rm;
- Threads::oops_do(&roots_closure, NULL);
- }
- break;
- case object_synchronizer:
- ObjectSynchronizer::oops_do(&roots_closure);
- break;
- case flat_profiler:
- FlatProfiler::oops_do(&roots_closure);
- break;
- case system_dictionary:
- SystemDictionary::oops_do(&roots_closure);
- break;
- case class_loader_data:
- {
- PSScavengeKlassClosure klass_closure(pm);
- ClassLoaderDataGraph::oops_do(&roots_closure, &klass_closure, false);
- }
- break;
- case management:
- Management::oops_do(&roots_closure);
- break;
- case jvmti:
- JvmtiExport::oops_do(&roots_closure);
- break;
- case code_cache:
- {
- MarkingCodeBlobClosure each_scavengable_code_blob(&roots_to_old_closure, CodeBlobToOopClosure::FixRelocations);
- CodeCache::scavenge_root_nmethods_do(&each_scavengable_code_blob);
- AOTLoader::oops_do(&roots_closure);
- }
- break;
- default:
- fatal("Unknown root type");
- }
- // Do the real work
- pm->drain_stacks(false);
- }
由于 HotSpot VM 的源码里面注释比较少, 所以只能参考一些资料和源码方法的具体实现猜测 GC Roots 的具体组成:
Universe::oops_do:VM 的一些静态数据结构里指向 GC 堆里的对象的活跃引用等等.
JNIHandles::oops_do
: 所有的 JNI handle, 包括所有的 global handle 和 local handle.
Threads::oops_do: 所有线程的虚拟机栈, 具体应该是所有 Java 线程当前活跃的栈帧里指向 GC 堆里的对象的引用, 或者换句话说, 当前所有正在被调用的方法的引用类型的参数 / 局部变量 / 临时值.
ObjectSynchronizer::oops_do
: 所有被对象同步器关联的对象, 看源码应该是 ObjectMonitor 中处于 Block 状态的对象, 从 Java 代码层面应该是通过 synchronized 关键字加锁或者等待加锁的对象.
FlatProfiler::oops_do
: 所有线程的中的 ThreadProfiler.
SystemDictionary::oops_do
:System Dictionary, 也就是系统字典, 是记录了指向 Klass,KEY 是一个 Entry, 由 KalssName 和 Classloader 组成, 实际上, YGC 不会处理 System Dictionary, 但是会扫描 System Dictionary, 某些 GC 可能触发类卸载功能, 可以这样理解: System Dictionary 包含了所有的类加载器.
ClassLoaderDataGraph::oops_do
: 所有已加载的类或者已加载的系统类.
Management::oops_do
:MBean 所持有的对象.
JvmtiExport::oops_do
:JVMTI 导出的对象, 断点或者对象分配事件收集器相关的对象.
CodeCache::scavenge_root_nmethods_do
: 代码缓存(Code Cache).
AOTLoader::oops_do:AOT 加载器相关, 包括了 AOT 相关代码缓存.
还有其他有可能的引用:
StringTable::oops_do: 所有驻留的字符串(StringTable 中的).
JVM 中的内存池
JVM 把内存池划分为多个区域, 下面分别介绍每个区域的组成和基本功能, 方便下面介绍 GC 算法的时候去理解垃圾收集如何在不同的内存池空间中发挥其职责.
年轻代(Young Generation): 包括 Eden 和 Survivor Spaces, 而 Survivor Spaces 又等分为 Survivor 0 和 Survivor 1, 有时候也称为 from 和 to 两个区.
老年代(Old Generation): 一般称为 Tenured.
元空间: 称为 Metaspace, 在 Java8 中 VM 已经移除了永久代 Permanent Generation.
Eden
伊甸园是地上的乐园, 根据《圣经. 旧约. 创世纪》记载, 神. 耶和华照自己的形像造了人类的祖先男人亚当, 再用亚当的一个肋骨创造了女人夏娃, 并安置第一对男女住在伊甸园中.
Eden, 也就是伊甸园, 是一块普通的在创建对象的时候进行对象分配的内存区域. 而 Eden 进一步划分为驻留在 Eden 空间中的一个或者多个 Thread Local Allocation Buffer(线程本地分配缓冲区, 简称 TLAB),TLAB 是线程独占的. JVM 允许线程在创建大多数对象的时候直接在相应的 TLAB 中进行分配, 这样可以避免多线程之间进行同步带来的性能开销.
当无法在 TLAB 中进行对象分配的时候 (一般是缓冲区没有足够的空间), 那么对象分配操作将会在 Eden 中共享的空间(Common Area) 中进行. 如果整个 Eden 都没有足够的空间, 则会触发 YGC(Young Generation Garbage Collection), 以释放更多的 Eden 中的空间. 触发 YGC 后依然没有足够的内存, 那么对象就会在老年代中分配(一般这种情况称为分配担保(Handle Promotion), 是有前置条件的).
当垃圾回收器收集 Eden 的时候, 会遍历所有相对于 GC Roots 可达的对象, 并且标记它们是活对象, 这一阶段称为标记阶段.
这里还有一点需要注意的是: 堆中的对象有可能跨代链接, 也就是有可能年轻代中的对象被老年代中的对象持有 (注: 老年代中的对象被年轻代中的对象持有这种情况在 YGC 中不需要考虑), 这个时候如果不遍历老年代的对象, 那么就无法通过可达性算法分析这种被被老年代中的对象持有的年轻代对象是否可达. JVM 中采用了 Card Marking(卡片标记) 的方式解决了这个问题, 这里不对卡片标记的细节实现进行展开.
标记阶段完成后, Eden 中所有存活的对象会被复制到幸存者空间(Survivor Spaces) 的其中一块空间. 复制阶段完成后, 整个 Eden 被认为是空的, 可以重新用于分配更多其他的对象. 这里采用的 GC 算法称为标记 - 复制(Mark and Copy) 算法: 标记存活的对象, 然后复制它们到幸存者空间(Survivor Spaces) 的其中一块空间, 注意这里是复制, 不是移动.
关于 Eden 就介绍这么多, 其中 TLAB 和 Card Marking 是 JVM 中的相对底层实现, 大概知道即可.
Survivor Spaces
Survivor Spaces 也就是幸存者空间, 幸存者空间最常用的名称是 from 和 to. 最重要的一点是: 幸存者空间中的两个区域总有一个区域是空的.
下一次 YGC 触发之后, 空闲的那一块幸存者空间才会入驻对象. 年轻代的所有存活的对象(包括 Eden 和非空的 from 幸存者区域中的存活对象), 都会被复制到 to 幸存者区域, 这个过程完成之后, to 幸存者区域会存放着活跃的对象, 而 from 幸存者区域会被清空. 接下来, from 幸存者区域和 to 幸存者区域的角色会交换, 也就是下一轮 YGC 触发之后存活的对象会复制到 from 幸存者区域, 而 to 幸存者区域会被清空, 如此循环往复.
上面提到的存活对象的复制过程在两个幸存者空间之间多次往复之后, 某些存活的对象 "年龄足够大"(经过多次复制还存活下来), 则这些 "年纪大的" 对象就会晋升到老年代中, 这些对象会从幸存者空间移动到老年代空间中, 然后它们就驻留在老年代中, 直到自身变为不可达.
如果对象在 Eden 中出生并且经过了第一次 YGC 之后依然存活, 并且能够被 Survivor Spaces 容纳的话, 对象将会被复制到 Survivor Spaces 并且对象年龄被设定为 1. 对象在 Survivor Spaces 中每经历一次 YGC 之后还能存活下来, 则对象年龄就会增加 1, 当它的年龄增加到晋升老年代的年龄阈值, 那么它就会晋升到老年代也就是被移动到老年代中. 晋升老年代的年龄阈值的 JVM 参数是 - XX:MaxTenuringThreshold=n:
VM 参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
-XX:MaxTenuringThreshold=n | Survivor Spaces 存活对象晋升老年代的年龄阈值 | 1<= n <= 15 | 15 |
值得注意的是: JVM 中设置 - XX:MaxTenuringThreshold 的默认值为最大可选值, 也就是 15.
JVM 还具备动态对象年龄判断的功能, JVM 并不是永远地要求存活对象的年龄必须达到 MaxTenuringThreshold 才能晋升到老年代, 如果在 Survivor Spaces 中相同年龄的所有对象的大小总和大于 Survivor Spaces 的一半, 那么年龄大于或者等于该年龄的对象可以直接晋升到老年代, 不需要等待对象年龄到达 MaxTenuringThreshold, 例如:
类型 | 占比 | 年龄 | 动作 (
) |
---|---|---|---|
ObjectType-1 | 60% | 5 | 下一次 YGC 如果存活直接晋升到老年代 |
ObjectType-2 | 1% | 6 | 下一次 YGC 如果存活直接晋升到老年代 |
ObjectType-3 | 10% | 4 | 下一次 YGC 如果存活对象年龄增加 1 |
可以简单总结一下对象进入老年代的几种情况:
多次 YGC 对象存活下来并且年龄到达设定的
-XX:MaxTenuringThreshold=n
导致对象晋升.
因为动态对象年龄判断导致对象晋升.
大对象直接进入老年代, 这里大对象通常指需要大量连续内存的 Java 对象, 最常见的就是大型的数组对象或者长度很大的字符串, 因为年轻代完全有可能装不下这类大对象.
年轻代空间不足的时候, 老年代会进行空间分配担保, 这种情况下对象也是直接在老年代分配.
Tenured
老年代 (Old Generation) 更多时候被称为 Tenured, 它的内存空间的实现一般会更加复杂. 老年代空间一般要比年轻大大得多, 它里面承载的对象一般不会是 "内存垃圾", 侧面也说明老年代中的对象的回收率一般比较低.
老年代发生 GC 的频率一般情况下会比年轻代低, 并且老年代中的大多数对象都被期望为存活的对象(也就是对象经历 GC 之后存活率比较高), 因此标记和复制算法并不适用于老年代. 老年代的 GC 算法一般是移动对象以最小化内存碎片. 老年代的 GC 算法一般规则如下:
通过 GC Roots 遍历和标记所有可达的对象.
删除所有相对于 GC Roots 不可达的对象.
通过把存活的对象连续地复制到老年代内存空间的开头 (也就是起始地址的一端) 以压缩老年代内存空间的内容, 这个过程主要包括显式的内存压缩从而避免过多的内存碎片.
Metaspace
在 Java8 之前 JVM 内存池中还定义了一块空间叫永久代(Permanent Generation), 这块内存空间主要用于存放元数据例如 Class 信息等等, 它还存放其他数据内容, 例如驻留的字符串(字符串常量池). 实际上永久代曾经给 Java 开发者带来了很多麻烦, 因为大多数情况下很难预测永久代需要设定多大的空间, 因为开发者也很难预测元数据或者字符串常量池的具体大小, 一旦分配的元数据等内容出现了失败就会遇到 java.lang.OutOfMemoryError: Permgen space 异常. 排除内存溢出导致的 java.lang.OutOfMemoryError 异常, 如果是正常情况下导致的异常, 唯一的解决手段就是通过 VM 参数 - XX:MaxPermSize=XXXXm 增大永久代的内存, 不过这样也是治标不治本.
因为元数据等内容是难以预测的, Java8 中已经移除了永久代, 新增了一块内存区域 Metaspace(元空间), 很多其他杂项 (例如字符串常量池) 都移动了 Java 堆中. Class 定义信息等元数据目前是直接加载到元空间中. 元空间是一片分配在机器本地内存 (native memory) 的内存区, 它和承载 Java 对象的堆内存是隔离的. 默认情况下, 元空间的大小仅仅受限于机器本地内存可以分配给 Java 程序的极限值, 这样基本可以避免因为添加新的类导致 java.lang.OutOfMemoryError: Permgen space 异常发生的场景.
VM 参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
XX:MetaspaceSize=Xm | Metaspace 扩容时触发 FullGC 的初始化阈值 | - | - |
XX:MaxMetaspaceSize=Ym | Metaspace 的内存上限 | - | 接近于无穷大 |
常用内存池相关的 VM 参数
-Xmx 和 -Xms
VM 参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
-Xmx | 设置最大堆内存大小 | 有下限控制,视 VM 版本 | - |
-Xms | 设置最小堆内存大小 | 有下限控制,视 VM 版本 | - |
-Xmn,-XX:NewRatio 和 -XX:SurvivorRatio
VM 参数 | 功能 | 可选值 | 默认值 |
---|---|---|---|
-Xmn | 设置年轻代内存大小 | - | - |
-XX:NewRatio= | 设置老年代和年轻代的内存大小比值,设置为 4 表示年轻代占堆内存的 1/5 | - | 4 |
-XX:SurvivorRatio= | 设置 Eden 和幸存者区域的内存大小比值,设置为 8 表示 from:to:Eden=1:1:8 | - | 8 |
GC 类型
参考 R 大 (RednaxelaFX) 的知乎回答, 其实在 HotSpot VM 的 GC 分类只有两大种:
Partial GC: 也就是部分 GC, 不收集整个 GC 堆.
Young GC: 只收集 young gen 的 GC.
Old GC: 只收集 old gen 的 GC, 目前只有 CMS 的 concurrent collection 是这个模式.
Mixed GC: 收集整个 young gen 以及部分 old gen 的 GC, 目前只有 G1 有这个模式.
Full GC: 收集整个堆, 包括 young gen,old gen,perm gen(如果存在的话)等所有部分的模式.
因为 HotSpot VM 发展多年, 外界对 GC 的名词解读已经混乱, 所以才出现了 Minor GC,Major GC 和 Full GC.
Minor GC
Minor GC, 也就是 Minor Garbage Collection, 直译为次级垃圾回收, 它的定义相对清晰: 发生在年轻代的垃圾回收就叫做 Minor GC.Minor Garbage Collection 处理过程中会发生:
当 JVM 无法为新的对象分配内存空间的时候, 始终会触发 Minor GC, 常见的情况如 Eden 的内存已经满了, 并且对象分配的发生率越高, Minor GC 发生的频率越高.
Minor GC 期间, 老年代中的对象会被忽略. 老年代中的对象引用的年轻代的对象会被认为是 GC Roots 的一部分, 在标记阶段会简单忽略年轻代对象中引用的老年代对象.
Minor GC 会导致 Stop The World, 表现为暂停应用线程. 大多数情况下, Eden 中的大多数对象都可以视为垃圾并且这些垃圾不会被复制到幸存者空间, 这个时候 Minor GC 的停顿时间会十分短暂, 甚至可以忽略不计. 相反, 如果 Eden 中有大量存活对象需要复制到幸存者空间, 那么 Minor GC 的停顿时间会显著增加.
Major GC 和 Full GC
Major GC(Major Garbage Collection, 可以直译为主垃圾收集)和 Full GC 目前是两个没有正式定义的术语, 具体来说就是: JVM 规范中或者垃圾收集研究论文中都没有明确定义 Major GC 或者 Full GC. 不过按照民间或者约定俗成, 两者区别如下:
Major GC: 对老年代进行垃圾收集.
Full GC: 对整个堆进行垃圾收集 -- 包括年轻代和老年代.
实际上, GC 过程是十分复杂的, 而且很多 Major GC 都是由 Minor GC 触发的, 所以要严格分割 Major GC 或者 Minor GC 几乎是不可能的. 另一方面, 现在垃圾收集算法像 G1 收集算法提供部分垃圾回收功能, 侧面说明并不能单纯按照收集什么区域来划分 GC 的类型.
上面的一些理论或者资料指明: 与其讨论或者担心 GC 到底是 Major GC 或者是 Minor GC, 不如花更多精力去关注 GC 过程是否会导致应用的线程停顿或者 GC 过程是否能够和应用线程并发执行.
常用的 GC 算法
下面分析一下目前 Hotspot VM 中比较常见的 GC 算法, 因为 G1 算法相对复杂, 这里暂时没有能力分析.
GC 算法的目的
GC 算法的目的主要有两个:
找出所有存活的对象, 对它们进行标记.
移除所有无用的对象.
寻找存活的对象主要是基于 GC Roots 的可达性算法, 关于标记阶段有几点注意事项:
标记阶段所有应用线程将会停顿(也就是 Stop The World), 应用线程暂时停顿保存其信息在还原点中(Safepoint).
标记阶段的持续时间并不取决于堆中的对象总数或者是堆的大小, 而是取决于存活对象的总数, 因此增加堆的大小并不会显著影响标记阶段的持续时间.
标记阶段完成后的下一个阶段就是移除所有无用的对象, 按照处理方式分为三种常见的算法:
Sweep -- 清理, 也就是 Mark and Sweep, 标记 - 清理.
Compact -- 压缩, 也就是 Mark-Sweep-Compact, 标记 - 清理 - 压缩.
Copy -- 复制, 也就是 Mark and Copy, 标记 - 复制.
Mark-Sweep 算法
Mark-Sweep 算法, 也就是标记 - 清理算法, 是一种间接回收算法(Indirect Collection), 它并非直接检测垃圾对象本身, 而是先确定所有存活的对象, 然后反过来判断其他对象是垃圾对象. 主要包括标记和清理两个阶段, 它是最简单和最基础的收集算法, 主要包括两个阶段:
第一阶段为追踪 (trace) 阶段: 收集器从 GC Roots 开始遍历所有可达对象, 并且对这些存活的对象进行标记(mark).
第二阶段为清理 (sweep) 阶段: 收集器把所有未标记的对象进行清理和回收.
Mark-Sweep-Compact 算法
内存碎片化是非移动式收集算法无法解决的一个问题之一: 尽管堆中有可用空间, 但是内存管理器却无法找到一块连续内存块来满足较大对象的分配需求, 或者花费较长时间才能找到合适的空闲内存空间.
Mark-Sweep-Compact 算法, 也就是标记 - 清理 - 压缩算法, 也是一种间接回收算法(Indirect Collection), 它主要包括三个阶段:
标记阶段: 收集器从 GC Roots 开始遍历所有可达对象, 并且对这些存活的对象进行标记.
清理阶段: 收集器把所有未标记的对象进行清理和回收.
压缩阶段: 收集器把所有存活的对象移动到堆内存的起始端, 然后清理掉端边界之外的内存空间.
对堆内存进行压缩整理可以有效地降低内存外部碎片化 (External Fragmentation) 问题, 这个是标记 - 清理 - 压缩算法的一个优势.
Mark-Copy 算法
Mark-Copy 算法, 也就是标记 - 复制算法, 和标记 - 清理 - 压缩算法十分相似, 重要的区别在于: 标记 - 复制算法在标记和清理完成之后, 所有存活的对象会被复制到一个不同的内存区域 -- 幸存者空间. 主要包括三个阶段:
标记阶段: 收集器从 GC Roots 开始遍历所有可达对象, 并且对这些存活的对象进行标记.
清理阶段: 收集器把所有未标记的对象进行清理和回收 --- 实际上这一步可能是不存在的, 因为存活对象指针被复制之后, 原来指针所在的位置已经可以重新分配新的对象, 可以不进行清理.
复制阶段: 把所有存活的对象复制到 Survivor Spaces 中的某一块空间中.
标记 - 复制算法可以避免内存碎片化的问题, 但是它的代价比较大, 因为用的是半区复制回收, 区域可用内存为原来的一半.
小结
JVM 和 GC 是 Java 开发者必须掌握的内容, 包含的知识其实还是挺多的, 本文也只是简单介绍了一些基本概念:
分代假说.
Minor GC,Major GC 和 Full GC.
内存池组成.
常用的 GC 算法.
后面会分析一下 GC 收集器搭配和 GC 日志查看, JVM 提供的工具等等.
参考资料:
《深入理解 Java 虚拟机 - 2nd》
《The Garbage Collection Handbook》
知乎 - RednaxelaFX 部分回答
Java Garbage Collection handbook
OpenJDK HotSpot VM 部分源码
- GitHub Page:
- Coding Page:
来源: https://www.cnblogs.com/throwable/p/10993090.html