GC(Garbage Collection 垃圾回收)使得Java程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”。垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存。

哪些内存需要回收

上文JVM内存分配介绍了 JVM 运行时内存分配的各个部分,其中程序计数器、虚拟机栈、本地方法区都是和线程同生共死的,其内存的分配和回收都随着栈帧的进入和退出有序进行,不需要过多考虑回收问题。而 Java 堆方法区因为其存放对象和类都是要在运行时才能确认大小,而创建的动作频繁且随机,因此这两部分区域是 GC 关注的主要区域。

什么时候回收

Java 堆里的对象实例

Java 堆里存放着 JVM 中几乎所有的对象实例,判断 Stack 中哪些内存可以被回收,首先就得看哪些对象实例已经“死去”,即不可能再被使用。我们先来看两个简单的实现方式:引用计数算法发和可达性分析算法

引用计数算法(Reference Counting Collector)

这是在 Python 里被使用的 GC 算法,也是 GC 早期中的早期策略,其实现就是给每一个对象添加一个应用计数器,没有一个地方引用它就加1,当应用失效时就减1,当计数器为0时则说明这个对象不再被使用。这种实现方式简单高效,却有一个比较大的问题导致主流 Java 虚拟机都没有采纳这种方式,就是它无法解决循环引用的问题。例子可见:java垃圾回收之循环引用

可达性分析算法(Reachability Analysis)

这种算法是真正被主流商用语言采纳的 GC 算法,其思路是通过通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索走过的路径称为引用链(Reference Chain),成为一个树状结构,当所有根节点(GC root)到某个对象都不可达时,则说明这个对象是不可用的。

有以下几种对象可以被作为 GC Roots:

- Java Stack 中引用的对象。

- 方法区中类静态属性引用的对象。

- 方法区中常量引用的对象。

- 本地方法中 Native 方法引用的对象。

值得注意的是,这些不可达对象并不是会立即被清理掉,而是会被进行一次标记,如果该对象覆写了 finalize() 方法且还没有被调用过,则会被放到一个叫 F-Queue 的队列中,这个 finalize 方法原本是用于手动释放非 Java 内存的,但是 JVM 并不一定会执行它;如果在 finalize 方法中对象与引用链上的任一对象发生引用,这个对象就会“起死回生”,在第二次 GC 标记 F-Queue 中对象的时候被移除回收集合。finalize 方法最多只会被执行一次,不确定性大,所以不推荐使用。

关于引用

可达性分析算法中对引用的划分仅限于“引用”和“未引用”,而事实上在程序运行中还有一类“暂时没被引用”的对象,这部分对象我们希望在内存充裕的情况下保留,在内存紧张的时候再将其抛弃。因此在 JDK1.2 后,Java 将引用的概念扩充至强引用(Strong Reference)、软引用(Weak Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference),分别用来描述有用(类似 Object obj = new Object())、有用但非必须(OOM 前回收)、非必须引用(下一次 GC 回收),以及不影响对象生命周期的引用(用于系统回收通知)。

如何找到根节点

由于遍历 GC Roots 节点要求“一致性”,所以 GC 发生的时候需要暂停 Java 执行线程(Sun 称之为”Stop the world”),为了缩短 Stop the world 的时间,遍历根节点时间需要尽可能短,所以 HotSpot 使用了 OopMap 的数据结构来达到这个目的,当程序运行到安全点(Safepoint)的时候,线程的一些状态可以被确定,比如记录OopMap的状态,从而确定GC Root的信息,使JVM可以安全的进行 GC 操作。

safepoint指的特定位置主要有:

- 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)

- 方法返回前

- 调用方法的call之后

- 抛出异常的位置

安全点存在的意义是为了保证程序不用运行很长时间就可以满足一次 GC 的条件,而如果一些进程休眠或阻塞,则可能长时间无法进入 Safepoint,于是就有了安全区( Safe region),安全区域是指在一段区域内,对象引用关系等不会发生变化,在此区域内任意位置开始 GC 都是安全的;线程运行时,首先标记自己进入了安全区,然后在这段区域内,如果线程发生了阻塞、休眠等操作,JVM 发起 GC 时将忽略这些处于安全区的线程。当线程再次被唤醒时,首先他会检查是否完成了 GC Roots枚举(或这个GC过程),然后选择是否继续执行,否则将继续等待 GC 的完成。

方法区里的类

方法区(Method Area)对应 Java7 以前的持久代(Permanent generation),已经在 Java8中被移除,取而代之的是使用本地内存的 MetaSpace,其 GC 频率也大幅降低,只有当 MetaSpace 内存达到“MaxMetaspaceSize”值的时候才会触发一次该区域的 GC。根据 JVM 规范,满足以下三个条件的类将被定义为“可以被搜集”:

- 该类的所有实例都已经被回收。

- 加载该类的 ClassLoader 已经被回收。

- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

当然,被标记“可以回收”的类还要视 JVM 的参数(-Xnoclassgc)决定是否被回收。

如何回收

算法

在 Hotspot JVM 里,Java 堆采用分代收集(Generational Collection),在了解分代收集之前先来看几种简单的算法:

  • 标记-清除算法(Mark-Sweep):分为“标记”和“清除”两个阶段,将需要清除的内存标记后随即清除,因为绝大部分的对象都只存在很短的时间就需要被收回,这种算法显得很低效;而且会产生大量不连续的存储空间。
  • 复制算法(Copying):按 8:1 的比例划分出一块 Eden 空间和两块 Survivor 空间(S0 和 S1),回收时将 Eden 和 S0 里的内存直接 Copy 到 S1 中,如果 S1 中没有足够的空间,这些对象将通过分配担保(Handle Promotion)机制进入老年代。这种算法的缺点是对对象存活率较高的老年代并不适用。
  • 标记-整理算法(Mark-Compact):与标记清除类似,但标记后并不是直接对可回收对象进行清理,而是先让所有存活对象移向一端,然后直接清理掉端边界以外的内存。

在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。

如图所示,默认情况下新生代和老年占用空间比例为1:2,新生代中Eden:S0:S1=8:1:1,无论什么时候,总是有一块 Survivor 区域是空闲着的,原因后面会讲到。

图片来自:https://www.slideshare.net/rgrebski/on-heap-cache-vs-offheap-cache-53098109

Java Heap GC——分代收集

Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:Minor GC、Full GC ( 或称为 Major GC )。

Minor GC

Minor GC 是发生在新生代中的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。

新对象在 Eden + S0 中诞生,一旦 Eden 区将满,JVM 会因为申请不到内存触发一次 Minor GC,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳,则使用复制算法将这些仍然存活的对象复制到 S1 区域,然后清理 Edent 和 S0,并将 S0 和 S1 互换,以便执行下一次 GC。年轻代的中的对象每经历一次 Minor GC,对象的年龄会 +1,默认情况下对象年龄达到15(可以通过参数 -XX:MaxTenuringThreshold 来设定),则会被分配到老年代。
但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代。

Full GC

Full GC 是发生在老年代的垃圾收集动作,常常伴随至少一次的 Minor GC,速度一般比 Minor GC 慢 10 倍以上。

较大型的对象会直接进入老年代,另外在年轻代中生存的时间很长的可达对象也会进入Old代,老年代的对象是不容易死掉的,也就意味着 Full GC 发生的次数不会有 Minor GC 那么频繁,并且做一次 Full GC 要比进行一次 Minor GC 的时间更长。

另外,标记-清除算法收集垃圾的时候会产生许多的内存碎片 ( 即不连续的内存空间 ),此后需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次 GC 的收集动作。

JVM 参数选项

jvm 可配置的参数选项可以参考 Oracle 官方网站给出的相关信息:http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html

下面只列举其中的几个常用和容易掌握的配置选项:

参数 含义
-Xms 初始堆大小。如:-Xms256m
-Xmx 最大堆大小。如:-Xmx512m
-Xmn 新生代大小。通常为 Xmx 的 1/3 或 1/4。新生代 = Eden + 2 个 Survivor 空间。实际可用空间为 = Eden + 1 个 Survivor,即 90%
-Xss JDK1.5+ 每个线程堆栈大小为 1M,一般来说如果栈不是很深的话, 1M 是绝对够用了的。
-XX:NewRatio 新生代与老年代的比例,如 –XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3
-XX:SurvivorRatio 新生代中 Eden 与 Survivor 的比值。默认值为 8。即 Eden 占新生代空间的 8/10,另外两个 Survivor 各占 1/10
-XX:PermSize 永久代(方法区)的初始大小
-XX:MaxPermSize 永久代(方法区)的最大值
-XX:+PrintGCDetails 打印 GC 信息
-XX:+HeapDumpOnOutOfMemoryError 让虚拟机在发生内存溢出时 Dump 出当前的内存堆转储快照,以便分析用

垃圾收集器

垃圾收集器是收集算法的具体实现。不同厂商开发的虚拟机有不同的实现方法, HotSpot 包含的七种收集器如图所示:(连线表示可搭配使用)

图片来自:xuan’s blog

Serial 收集器

实现了用于新生代 GC 的复制算法,古老的单线程收集器,必须停掉所有其他工作线程才能进行,优点是相比其他收集器的单线程简单高效,比较适用于运行于 Client 模式下,需要收集内存比较小,停顿也不会很大的虚拟机。

ParNew 收集器

Serial 收集器的多线程版,其并行的特性使得它在 CPU 数量大于 2 的时候会拥有比单线程更好 GC 性能,同时 ParNew 也是除 Serial 以外唯一能和 CMS 收集器(HotSpot 虚拟机中第一款真正意义的并发收集器)配合工作的收集器,因此 ParNew 是许多运行在 Server 模式下虚拟机中首选的新生代收集器。

名词解释:

- 并发(Parallel):多条 GC 线程并行工作,但此时用户线程仍处于等待状态。

- 并发(Concurrent):用户线程和 GC 线程同时执行(但不一定是并行的,可能会交替执行),而 GC 程序运行与另一个 CPU 上。

Parallel Scavenge 收集器

又一个实现复制算法的新生代多线程收集器, 也被称为“吞吐量优先”收集器。吞吐量是反应的是程序对 CPU 的使用效率,单位时间内 GC 时间越长,吞吐量约低。一般来说 GC 造成的停顿时间越短 GC 频率就会越高,从而使吞吐量下降,但带来的好处是停顿无感,这种策略适用于客户端这种需要和用户直接交互的程序;而高吞吐量的配置是服务器端后台运算所需要的。Parallel Scavenge 收集器提供 -XX:MaxGCPauseMillis 来设置停顿时间,-XX:GCTimeRatio 直接设置吞吐量百分比(运行代码时间/(运行代码时间+单位时间内GC时间)),除此之外还有一个 -XX:+UseAdaptiveSizePolicy 来提供 GC 自适应调整(GC Ergonomics)以提供最合适的停顿时间或者最大吞吐量,可以用这种方式搭配前两个参数使用。

Serial Old 收集器

用于老年代的单线程收集器,实现了“标记-整理”算法,是 Serial 的老年版,主要用于 Client 端的虚拟机。

Parallel Old 收集器

用于老年代的多线程收集器,同样实现“标记-整理”算法,是 Parallel Scavenge 的老年版,和 Parallel Scavenge 搭配用于对吞吐量和 CPU 资源敏感的场景非常适用。

CMS 收集器

CMS(Concurrent Mark Sweep)是一种以获取最短 GC 停顿时间为目标的收集器,在重视网站响应时间的 Java Web 中使用广泛,实现的是“标记-清除”算法,分为以下几个步骤:

  • 初始标记(CMS initial mark):标记 GC Roots 能直接关联到的对象,速度很快,需要 STW。
  • 并发标记(CMS concurrent mark):进行 Root Tracing 过程,期间用户进程仍在运行。
  • 重新标记(CMS remark):修正并发标记期间变动的标记对象,需要 STW,时间略长于初始标记但远短于并发标记。
  • 并发清除(CMS concurrent sweep):并发执行,不需要 STW。

CMS 的优点是并发处理、低停顿,缺点是 CPU 数量少的时候性价比不高,且因为无法处理并发清除期间产生的垃圾导致必须要在老年代真正满之前进行 GC。除此之外,CMS 仍然是一款优秀的收集器。

G1 收集器*

G1 是未来要替换掉 CMS 成为 JVM 主流收集器的方案,已经被 Java8 设定为默认收集器,其实现思路是将整个 Java 堆划分为多个大小相等的独立区域(Region),将新生代老年代打乱后分配到不同的 Region,各个 Region 单独 GC 从而避免整个 Heap 的扫描,再对 Region GC 价值排序,维护一个优先列表以达到 GC 最高效率。

图片来自:G1GC – Java 9 Garbage Collector explained in 5 minutes

G1 收集器运作大概分为以下几个步骤:

  • 初始标记(Initial Marking,STW):和 CMS 的初始标记一样,不过除了标记 GC Roots 直接关联的对象之外还会修改 next TAMS(Next Top at Mark Start)的值以求下一个阶段用户新建对象能够被放在正确的 Region。
  • 并发标记(Concurrent Marking):和 CMS 相同,从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息。
  • 最终标记(Final Marking,STW):标记那些在并发标记阶段发生变化的对象,将被回收。
  • 筛选回收(Live Data Counting and Evacuation):清除空Region(没有存活对象的),加入到free list。

在 G1 收集器中有一个至关重要的数据结构用于处理 Region 之间对象引用的问题,叫做 Remembered Set,简称 RSet,是一种典型的空间换时间的做法,每个 Region 有自己 RSet,记录了谁引用了我的对象,GC 发生时会先检索 RSet,确保不对全栈扫描也不会有遗漏。

G1 收集器运行示意图 - 图片来自网络

小结

内存回收与垃圾搜集很多时候是影响系统性能、并发能力的主要原因,深入了解 JVM GC 机制有助于性能调优和理解如何编写高性能代码。