概述:为何要GC?
在C/C++中,程序员需要手动使用malloc和free来分配和释放内存。这是一项繁琐且极易出错的任务,如果忘记释放内存,就会导致内存泄漏;如果错误地释放了仍在使用的内存,就会导致程序崩溃。
Java引入了自动内存管理,即垃圾收集(Garbage Collection, GC)机制。它的目标是自动识别并回收不再被任何引用的对象所占用的内存,从而将程序员从复杂的内存管理中解放出来,专注于业务逻辑。
垃圾:在GC的上下文中,垃圾就是指那些存在于堆内存中,但没有任何存活对象引用它的对象实例。这些对象已经“死亡”,它们占用的空间需要被回收以复用。
2.2 判断对象生死:垃圾定义的法则
垃圾收集器在对堆进行回收前,第一件事就是要确定哪些对象还“存活”着,哪些已经“死去”(即不可能再被任何途径使用)。判断对象生死有两种经典的算法。
引用计数法(Reference Counting)
- 原理:在对象中添加一个引用计数器。每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。任何时刻计数器为零的对象就是不可能再被使用的。
- 优点:原理简单,判定效率高。
- 致命缺陷:无法处理循环引用的问题。如下代码所示,对象A和B相互引用,除此之外再无任何引用。它们的引用计数器都不为零,但实际上它们已经无法被访问,应被回收。
public class ReferenceCountingGC { public Object instance = null; public static void main(String[] args) { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; // objA引用了objB objB.instance = objA; // objB引用了objA -> 循环引用 objA = null; objB = null; // 假设在这里发生GC,objA和objB能否被回收?引用计数法下:不能。 System.gc(); } }
由于这个无法解决的硬伤,主流的Java虚拟机都没有选用引用计数法来管理内存。
可达性分析算法(Reachability Analysis)
这是当前主流Java虚拟机采用的算法。它的基本思路是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为 “引用链”(Reference Chain)。如果某个对象到GC Roots间没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达),则证明此对象是不可能再被使用的。
- 哪些对象可以作为GC Roots?
- 在虚拟机栈(栈帧中的局部变量表)中引用的对象。(例如:当前正在运行的方法中的参数、局部变量、临时变量)。
- 在方法区中类静态属性引用的对象。(例如:Java类的引用类型静态变量)。
- 在方法区中常量引用的对象。(例如:字符串常量池里的引用)。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用(如基本数据类型对应的Class对象,一些常驻的异常对象等)。
- 所有被同步锁(synchronized关键字)持有的对象。
对象引用的强度:从强到弱
在JDK 1.2之后,Java将引用概念拓宽,分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference) 和虚引用(Phantom Reference),引用强度依次逐渐减弱。
- 强引用:类似Object obj = new Object()这种普遍的引用。只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用:用来描述一些还有用但非必需的对象。在系统即将发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。使用SoftReference类实现。
- 弱引用:用来描述非必需对象,但它的强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。使用WeakReference类实现。
- 虚引用:最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用的唯一目的,是为了能在这个对象被收集器回收时收到一个系统通知。使用PhantomReference类实现。
// 软引用和弱引用示例 public class ReferenceTypeDemo { public static void main(String[] args) { // 强引用 Object strongRef = new Object(); // 软引用 SoftReference<Object> softRef = new SoftReference<>(new Object()); System.out.println("Before GC (Soft): " + softRef.get()); System.gc(); System.out.println("After GC (Soft, if memory not low): " + softRef.get()); // 可能还存在 // 弱引用 WeakReference<Object> weakRef = new WeakReference<>(new Object()); System.out.println("Before GC (Weak): " + weakRef.get()); System.gc(); // 显式触发GC,弱引用对象大概率被回收 System.out.println("After GC (Weak): " + weakRef.get()); // 大概率输出 null } }
2.3 垃圾收集算法:清洁工的方法论
从如何回收的角度,衍生出了几种不同的算法。
标记-清除算法(Mark-Sweep)
最基础的收集算法,分为“标记”和“清除”两个阶段。
- 标记:首先标记出所有需要回收的对象(使用可达性分析)。
- 清除:统一回收所有被标记的对象。
- 优点:是最基础的算法,后续很多算法都是以其为思路改进的。
- 缺点:
- 执行效率不稳定:如果堆中包含大量需要回收的对象,标记和清除两个过程的效率都会随之降低。
- 内存碎片化问题:标记、清除之后会产生大量不连续的内存碎片。空间碎片太多可能会导致以后在分配大对象时无法找到足够的连续内存,从而不得不提前触发另一次垃圾收集动作。
复制算法(Copying)
为了解决效率问题,“复制”算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 优点:
- 高效:每次都是针对整个半区进行内存回收,分配内存时也无需考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
- 无碎片:复制过去的过程中自然就完成了整理,不会产生内存碎片。
- 缺点:
- 空间浪费:将可用内存缩小为了原来的一半,空间浪费太多。
- 当对象存活率较高时,复制的开销会很大。
应用场景:现代商用Java虚拟机都优先采用了这种收集算法去回收新生代。IBM的研究表明,新生代中的对象有98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间。HotSpot虚拟机将新生代内存分为一个较大的Eden空间和两个较小的Survivor空间(通常称为From和To),每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot默认的Eden和Survivor大小比例是8:1:1,即每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。
标记-整理算法(Mark-Compact)
复制算法在对象存活率较高时要进行较多的复制操作,效率会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
“标记-整理”算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
- 优点:
- 无内存碎片。
- 消除了复制算法浪费一半空间的代价。
- 缺点:
- 效率问题:移动存活对象并更新所有引用这些对象的地方,需要全程暂停用户应用程序(Stop The World),并且移动操作在对象多、存活率高时,开销更为可观。
分代收集理论(Generational Collection)
当前商业虚拟机的垃圾收集器,大多都遵循了 “分代收集” 的理论。它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕死的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
根据这两个假说,收集器将Java堆划分出不同的区域(新生代和老年代),然后根据各个区域的特点,“因地制宜”地采用不同的垃圾收集算法。
- 新生代(Young Generation):区域小,对象存活率低。
- 回收频繁。
- 采用复制算法,只需付出少量存活对象的复制成本就可以完成收集,效率高。
- 老年代(Tenured/Old Generation):区域大,对象存活率高。
- 回收频率较低。
- 采用标记-清除或标记-整理算法。
2.4 经典垃圾收集器:清洁工战队
垃圾收集算法是方法论,而垃圾收集器是具体的实现。HotSpot虚拟机提供了多种不同的收集器,下图展示了JDK 7/8时期HotSpot虚拟机的垃圾收集器及其组合关系:
- Serial收集器:最古老、最基础的收集器。它是一个单线程工作的收集器,在进行垃圾收集时,必须暂停所有其他工作线程("Stop The World")。它是Client模式下虚拟机的默认新生代收集器,简单而高效。
- ParNew收集器:实质上是Serial收集器的多线程并行版本,除了使用多条线程进行垃圾收集外,其余行为与Serial完全一样。它是许多运行在Server模式下的HotSpot虚拟机中首选的新生代收集器,因为除了Serial外,目前只有它能与CMS收集器配合工作。
- Parallel Scavenge收集器:也是一个并行的多线程新生代收集器,使用复制算法。它的关注点与其他收集器不同:CMS等收集器的目标是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge的目标是达到一个可控制的吞吐量(Throughput)。
- 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
- 高吞吐量可以最高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
- JDK 8的默认收集器组合:Parallel Scavenge(新生代) + Parallel Old(老年代)。
- Serial Old收集器:是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。主要用于Client模式下的老年代,或在Server模式下作为CMS收集器失败后的后备预案。
- Parallel Old收集器:是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法。在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
- CMS收集器(Concurrent Mark Sweep):一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法,其运作过程相对于前面几种收集器来说更复杂,分为四个步骤:
- 初始标记(Initial Mark):仅仅标记一下GC Roots能直接关联到的对象,速度很快。需要“Stop The World”。
- 并发标记(Concurrent Mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时较长,但不需要停顿用户线程。
- 重新标记(Remark):为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。需要“Stop The World”。
- 并发清除(Concurrent Sweep):清理掉标记阶段判断的已经死亡的对象,不需要停顿用户线程。
- 优点:并发收集、低停顿。
- 缺点:
- 对处理器资源非常敏感(会和服务线程抢CPU)。
- 无法处理“浮动垃圾”,在并发清理阶段用户线程产生的垃圾,只能留到下一次GC再清理。
- 基于标记-清除算法,会产生内存碎片。
- G1收集器(Garbage-First):面向服务端应用的垃圾收集器,是JDK 9及之后的默认垃圾收集器。它的使命是未来可以替换掉CMS收集器。
- 革命性变化:G1将堆划分为多个大小相等的独立区域(Region),它同时兼顾新生代和老年代。G1跟踪各个Region里面的垃圾堆积的“价值”大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,优先回收价值最大的Region,这就是“Garbage-First”名字的由来。
- 运作步骤:虽然也遵循分代收集,但步骤与CMS类似:初始标记 -> 并发标记 -> 最终标记 -> 筛选回收。其中,最终标记和筛选回收阶段需要停顿用户线程。
- 优势:
- 并行与并发:能充分利用多核环境优势。
- 分代收集:依然区分分代概念。
- 空间整合:从整体看是基于标记-整理算法,从局部(两个Region之间)看是基于复制算法。这意味着G1运作期间不会产生内存空间碎片。
- 可预测的停顿:能建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
- ZGC与Shenandoah:JDK 11及之后引入的超低延迟收集器(实验或生产可用),目标是在任意堆内存大小下(如TB级)都能把垃圾收集的停顿时间控制在10毫秒以内。它们都采用了着色指针(Colored Pointers) 和读屏障(Read Barrier) 等革命性技术,实现并发标记和并发整理,几乎在整个GC过程中都不需要Stop The World。
2.5 实战:GC日志解读与参数配置
开启与解读GC日志
GC日志是理解GC行为、进行性能调优的最重要工具。使用以下JVM参数开启详细的GC日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log
一段典型的Parallel Scavenge收集器的GC日志:
2024-05-20T10:23:45.732+0800: [GC (Allocation Failure) [PSYoungGen: 65536K->10720K(76288K)] 65536K->15024K(251392K), 0.0085989 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
- 2024-05-20T10:23:45.732+0800:GC发生的时间戳。
- GC / Full GC:区分是Minor GC还是Full GC。
- Allocation Failure:触发GC的原因(分配失败)。
- PSYoungGen:收集器名称(这里是Parallel Scavenge)。
- 65536K->10720K(76288K):GC前该区域已使用容量 -> GC后该区域已使用容量 (该区域总容量)。
- 65536K->15024K(251392K):GC前Java堆已使用容量 -> GC后Java堆已使用容量 (Java堆总容量)。
- 0.0085989 secs:GC耗时。
- [Times: user=0.02 sys=0.00, real=0.01 secs]:用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束经过的墙钟时间(Wall Clock Time)。
关键参数速查表
参数 |
描述 |
示例 |
-Xms |
设置初始堆大小 |
-Xms2g |
-Xmx |
设置最大堆大小 |
-Xmx2g (通常设为与-Xms相同以避免内存震荡) |
-Xmn |
设置新生代大小(Eden + 2*Survivor) |
-Xmn512m |
-XX:SurvivorRatio |
设置新生代中Eden区与一个Survivor区的比例 |
-XX:SurvivorRatio=8 (Eden:Survivor=8:1) |
-XX:NewRatio |
设置老年代与新生代的比例 |
-XX:NewRatio=2 (老年代:新生代=2:1) |
-XX:+UseConcMarkSweepGC |
指定使用CMS收集器(老年代) |
|
-XX:+UseG1GC |
指定使用G1收集器 |
|
-XX:MaxGCPauseMillis |
设置期望的最大GC停顿时间目标(G1等收集器适用) |
-XX:MaxGCPauseMillis=200 |
-XX:MaxMetaspaceSize |
设置元空间最大值 |
-XX:MaxMetaspaceSize=256m |
在下一部分,我们将进入JVM监控与性能工具实战,学习如何使用这些工具来捕获GC日志、分析JVM状态,并将本章的理论知识应用于实际问题的定位中。