Java 垃圾回收

211 阅读11分钟

一、内存回收算法

Java的内存运行时区域的各个部分,其中PC、VM Stack、Native Method Stack这3个区域都是线程私有的,因此其中的对象随线程而生,随线程而死。栈帧随着方法的进入和退出而有条不紊的进行进栈和出栈操作。每一个栈帧的内存大小在类结构确定之后就是已知的(忽略JIT优化),所以这几个区域就不需要过多的考虑内存回收。而堆和方法区就不一样了,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,因此只有在程序运行期间我们才知道会创建那些对象,这部分创建和回收的内存都是动态的,需要垃圾回收器时刻关注。但是我们如何判断对象是否还有用呢?换句话说,如何判断Java对象是否已死?一般而言,有两种方法:引用计数和可达性分析

1. 引用计数

算法描述

给对象添加一个引用计数器,每当在一个地方引用该对象一次,该对象引用次数+1,当引用失效时,引用次数-1,当引用次数为0时,表明该对象可以被回收。

2. 可达性分析(GC Roots)

算法描述

通过一系列被称为GC Roots的对象作为起始点,从这些对象向下搜索,搜索走过的路径被称为引用链,如果一个对象无法通过任何一条引用链到达GC Roots,那么则认为该对象可以被回收。

通过算法描述可知,GC Roots的选择是算法的关键。

只有全局性的引用才可以作为GC Roots(例如常量或类静态属性),主要有以下几类:

  1. 虚拟机栈(栈帧中的本地向量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈(Native Method Stack)中JNI引用的对象

引用计数与可达性分析的对比

算法 应用 优点 缺点
引用计数 MS COM技术、Python、游戏引擎 简单、效率高 无法解决循环引用的问题
可达性分析 JVM 高效、解决循环引用问题

备注:Java中的四种引用

二、方法区(Method Area)的回收

方法区中用于存储已被虚拟机加载的类信息、常量、静态变量、及时变异器变异后的代码等数据,一般而言,认为方法区保存的都是永久代,因此回收效率较低。

方法区的垃圾回收主要回收两部分内容:废弃常量和无用的类。

1. 回收废弃常量

如果常量池中的某一字符串没有被任何一个String对象引用,那么将该常量移除常量池。

2. 回收无用的类

3. 如何判断类是否已经无用

  • [x] 该类所有的实例已经被回收;
  • [x] 加载该类的ClassLoader已经被回收
  • [x] 该类对应的java.lang.Class对象没有在任何地方被应用,无法在任何地方通过反射的方式来访问该类。

只有同时满足上述三个条件,该类才可以被回收。

三、垃圾收集算法

1. 标记-清除算法(Mark-Sweep)

首先标记处所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。

2. 复制算法(Copying)

将内存等分为两块,每次只是用其中的一块。如果其中的一块内存用完了,就将其中还存活的对象复制到另一块内存中,然后将已使用的那块内存空间进行一次清理。很明显,这样的复制算法有一个很严重的缺陷:将可用的内存大小限制为原来的一半。

IBM研究表明,新生代中98%的对象都是朝生夕死的,因此不需要按照1:1的比例来划分内存区域,在Hotspot中,采用了如下的内存划分方案:

Java内存模型

通常情况下,需要进行GC时,将Eden和From区的对象复制到To,然后对Eden和From区进行GC。如果To区太小不足以存放某些Eden和From的对象,则该对象直接进入Old Generation。

3. 标记-整理算法(Mark-Compact)

算法 优点 缺点 目标对象
Mark-Sweep 简单 效率低下、容易产生大量的内存碎片
Copying 简单、高效 运行内存缩小 新生代
Mark-Compact 简单、不会产生内存碎片 需要内存整理 老年代

4. 分代收集算法(Generational Collection)

四、HotSpot中垃圾回收算法的实现

我们知道,执行GC时要停下当前所有的线程操作(STOP THE WORLD),但是在实际的生产环境中,这个时间必须非常短。在HotSpot中,采用可达性分析的算法来实现GC,但是可以作为GC ROOT的引用非常多,因此不可能将所有可能的对象都作为GC ROOT。针对这个问题,提出了安全点(Safe Point)OopMap的概念。

当类加载完成后,HotSpot就将对象内存布局之中什么偏移量上数值是一个什么样的类型的数据这些信息存放到 OopMap 中;

因此如果为每一条指令都生成对应的OopMap,那么将需要大量的额外空间,这样对导致 GC 成本很高,所以 HotSpot 只在 “特定位置”(特定指令) 记录这些信息,这些位置被称为安全点(Safepoint)

如果一条指令具有“让程序长时间执行”的特征,那么这条指令就可以作为安全点。例如循环、方法调用、异常跳转等。

在安全点上停止线程有两种方式:“抢断式”和“主动式”。抢断式指:发生GC时,主动中断所有的线程,所有某个线程处于不安全点,则恢复线程,让它跑到安全点上。主动式(通用方案)指:发生GC时,不主动去中断各个线程,仅仅设置一个GC的标识,各个线程轮询(轮序的地方必须为安全点)该标识,发现中断为真时,则将自己挂起。

但是,如果一个线程执行了很久,但是一直没有执行到安全点,则该线程一直无法挂起,因此引入了安全区域(Safe Region)。安全区域是指:在该代码块中,引用关系不会发生变化,因此在该区域的任何地方开始GC都是安全的。Safe Region时Safe Point的扩展。程序进入安全区域之后,首先标识自己进入到了Safe Region,GC可以随时进行,当要离开Safe Region时,首先查询GC ROOT枚举是否完成,如果完成则退出Safe Region并标识自己已离开Safe Region,否则持续等待知道GC ROOT枚举完成。

五、HotSpot中的垃圾收集器

1. Serial

单线程收集器,此处的单线程具有两个含义:①只有一个线程执行GC; ②必须STOP THE WORLD,即只有一个GC线程在执行。

在桌面应用中,通常分配给JVM管理的内存最多也就几十兆100兆左右,停顿时间完全可以控制在几十毫秒最多一百多毫秒,只要不是频繁发生还是可以接受的,而且在单CPU环境下,Serial没有线程切换带来的额外消耗,因此它适用于Client模式的JVM中新生代的垃圾回收。

Serial收集器使用Copy算法。

2. ParNew

Serial的多线程版本。Server模式下虚拟机中首选的新生代收集器,使用Copy算法。默认开启的线程数与CPU的数量相同。

仅有ParNew收集器可以与CMS收集器搭配使用,CMS是Java1.5时期提出的并发垃圾收集器,主要用来回收老年代。

3. Parallel Scavenge

Parallel Scavenge:新生代收集器,使用Copy算法,并行多线程收集器。乍看与ParNew无区别,但是ParNew关注的是GC停顿时间,而Parallel Scavenge关注的是吞吐量。

?吞吐量= 运行用户代码时间 /(运行用户代码时间+垃圾收集时间)?

4. Serial Old

Serial Old用来收集老年代,使用"Mark-Compact"算法。主要意义在于给Client模式下的虚拟机使用。用在Server模式下时,主要有两个作用:①在JDK1.5 之前,与Parallel Scavenge配合使用收集老年代; ②作为CMS失败后的预备方案。

5. Parallel Old(Since JDK1.6)

Parallel Old用来收集老年代,使用“Mark-Compact”算法。一般与Parallel Scavenge配合使用,来提供高吞吐量。

6. CMS(Concurrent Mark Sweep)

CMS致力于提供最短回收停顿时间,使用“Mark-Sweep”算法,运行于Server模式,用来回收老年代。 整个过程分为四个步骤:

  • [x] 初始标记
  • [x] 并发标记
  • [x] 重新标记
  • [x] 并发清除

初始标记和重新标记需要“STOP THE WORLD”。初始标记仅标记与GC ROOT直接引用的对象,速度很快;并发标记时执行GC Roots Tracing,标记阶段是为了修正并发标记期间因用户程序继续运行而导致标记产生的错误。

虽然CMS已经几乎能做到GC与用户程序并发执行,但任由3个明显的缺点:

  1. CMS无法处理浮动垃圾(Floating Garbage);浮动垃圾是指在CMS进行GC时,用户程序同步执行而产生的新的垃圾,这部分垃圾无法被此次GC回收,所以称之为浮动垃圾。也正是因为用户程序的同步运行,所以不能在老年代几乎完全被填满之后再进行回收,因为还需要给用户程序留有足够的内存以便正常执行程序。如果在CMS运行期间预留的内存无法满足程序需要,就会出现“Concurrent Mode Failure”错误,此时将启动CMS的后备方案Serial Old来重新对老年代进行一次Full GC,这样就会花费更多的时间,因此设置一个合适的阈值来启动CMS是必须的。在Java 1.5的默认设置中,当老年代使用了68%时,开始执行CMS,而在Java 1.7中则为92%。
  2. CMS对CPU资源十分敏感;换句话说,CMS的执行会抢占用户程序资源,从而导致用户程序响应缓慢。
  3. 因为CMS使用“Mark-Sweep”算法,因此会产生大量的内存碎片。因此CMS提供了-XX:+UseCMSCompactAtFullCollection参数来解决大对象引起的Full GC。

7. G1(Garbage-First)

G1是一款面向服务端应用、面向整个GC堆的垃圾回收器。

使用G1时,Java的内存模型不再像之前按照对象年龄来分区,而是将所有的堆内存划分为对等的独立区域Region,但是每一个对象仍然有新生代和老年代的划分,新生代和老年底啊可以处于同一个Region。G1会跟踪每个region的回收价值,并维护一个优先队列,进行GC时,优先回收价值最大的Region,以保证在有限时间内获取最大的回收效率。但是Region之间不是相互独立的,Region之间的对象可能存在相互引用,G1使用Remembered Set记录避免对整个堆控件的搜索。Remembered Set用来保存对应Region的向外引用列表,所谓向外引用指其他Region对本Region内的对象的引用。当进行GC时,将Remembered Set添加到GC ROOT中即可。

G1的执行过程可以描述如下:

  1. 初始标记(Initial Marking)
  2. 并发标记 (Concurrent Marking)
  3. 最终标记 (Final Marking)
  4. 筛选回收 (Live Data Counint and Evacuation)

初始阶段只标记与GC ROOT直接引用的对象,需要STOP THE WORLD。并发标记与用户程序同步运行,从GC Root出发进行可达性分析,标记出所有的存活的对象,与此同时,在用户程序执行期间,又会导致某些对象不可达(很明显不会出现之前不可达的对象再次可达的情况)、或产生新的对象或对象引用,将这些变化记录在Remembered Set Logs中。在最终标记阶段,根据Remembered Set Logs更新Remembered Set,该阶段需要STOP THE WORLD。在筛选回收阶段则根据各个Region的回收价值来进行回收。

因此,从全局而言,G1采用了Copy算法;从布局而言,两个Region之间则采用了Mark-Compact算法,因此不会产生内存碎片。

GC总结