Java 垃圾回收与内存分配
# Java 垃圾回收与内存分配
对象探活
对象探活的目的在于找到那些需要清理的对象
对象探活常见方法有引用计数和可达性分析等。使用引用计数法(类似于 C++ 中的智能指针 shared_ptr
)实现对象探活相对容易,但无法解决对象之间相互循环引用的问题,例如
class A{
public A element;
...
}
A a, b;
a.element = b;
b.element = a;
可达性分析 & GC Roots
Java 中可达性分析与 GC Roots 是息息相关的。Java 是面向对象的语言,所有对象之间都使用引用进行关联(底层实现一般是 C/C++ 中的指针)。从软件启动开始,所有的对象都会有一个“父对象”,所有的对象都是由父对象创建的,可以认为 JVM 是始祖对象。Java 启动且未进入main 函数前初始化的对象可以认为其父对象是 JVM;进入 main 函数后创建的所有对象可以认为其父对象是 main 函数所在的对象。由此可见 Java 中所有对象之间的关系可以使用多叉树进行表示,这些树的根节点就是垃圾回收扫描的起点(不考虑 JVM),也就是 GC Roots
所有对象的父对象都可以追溯到 JVM,但垃圾回收时不将 JVM 当做起点
GC Roots 一般有以下 4 类:
- 虚拟机栈中引用的对象
- 方法区静态属性引用的对象
- 方法区常量引用的对象
- 本地方法栈中 JNI(Java Native Interface,原生方法)引用的对象
引用分类
Java 1.2 后引用被分为以下 4 类,“引用强度”由强到弱分别为:
- 强引用
强引用就是指在程序代码之中普遍存在的,类似
Object obj= new Object()
这类的引用,只要强引用还存在,垃圾收集器就不会回收掉被引用的对象
- 软引用
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收(可以使用 SoftReference 函数创建)
- 弱引用
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前(WeakReference)
- 虚引用
为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知(PhantomReference)
垃圾回收
垃圾回收会涉及到对象探活与内存整理。因为 GC 过程很难保证正在移动的对象没有被其他执行中的线程所引用与修改,所以这个过程一般需要整个 JVM 停止执行工作线程,Sun 将这件事称为 Stop The World
。虚拟机暂停执行工作线程会影响业务性能,所以要尽可能减少垃圾回收造成的系统停顿时间。使用句柄来访问对象时可以只停顿与将要 GC 的对象相关联的线程,其他线程照样执行,但使用句柄访问对象效率不高,这类方法将 GC 时间分摊到了工作进程执行过程
垃圾回收常见算法
分代收集算法
根据对象存活周期的不同将内存划分为几块。一般把 Java 堆分为新生代(刚创建没多久的对象)和老年代(已经存在很久的对象,一些比较大的对象也默认是老年代),这样就可以根据各个年代的特点采用最适当的收集算法。例如新生代对象在每次垃圾回收时只有少量对象存活,可以使用下面提及的复制算法;对于存活时间长的老年代对象可以使用下面提及的“标记-清理”或者“标记-整理”算法
标记-清除
具体过程和下面的两次标记过程类似,缺点是效率低且会出现内存碎片问题
两次标记
如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过时,虚拟机将对象视为“没有必要执行”,否则是“有必要执行”
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行” 是指虚拟机会触发这个方法,但并不承诺会等待它运行结束
finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联,那么对象将被移出 F-Queue,并被认为“活着”
对象的 finalize() 方法只会被调用一次,在 Java 中尽量不要使用 finalize 方法
标记-整理
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
复制
基础的复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,代价比较大
从统计上来讲,在进行一次垃圾回收前,有 98% 的新生代对象都是可回收的,所以没必要把内存分为大小相等的两份
大部分商用虚拟机使用下述方法回收新生代对象
HotSpot 虚拟机将新生代内存分为三个部分:80% 的 Eden 和两个 10% 的 Survivor,创建对象时优先在 Eden 中创建。每次垃圾回收时因为 98% 的对象都可以删除,所以大部分情况下 10% 的内存即可存储所有存活的对象。垃圾回收前虚拟机只会使用 Eden 和其中一个 Survivor,回收时将 Eden 和被使用的 Survivor 中的对象移到另一个未被使用的 Survivor 中
当一个 Survivor 保存不了余下的对象时,会触发分配担保(下面有介绍)
方法回收
类(方法,位于静态内存区)需要同时满足三个条件才算可回收类
- 该类所有实例均被回收
- 加载该类的
ClassLoader
已经被回收 - 该类对应的
java.lang.Class
对象没有在任何地方被引用,即无法在任何地方通过反射访问该类的方法
对方法是否进行回收,虚拟机提供了配置选项。在大量使用反射、动态代理、CGLib
等 ByteCode
的框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader
的场景都需要虚拟机具备类卸载的功能,以保证静态区不会溢出
HotSpot & OopMap 垃圾回收实现
保守 & 准确
JVM 的垃圾回收分为保守式 GC 和准确式 GC,这里的保守和准确指的是 JVM 在垃圾回收时是否确定当前字段为引用类型。下面做详细的介绍,参考
-
保守式 GC
保守式 GC, JVM 不记录变量类型信息。每次进行垃圾回收时扫描所有 GC Roots 区域(本地方法栈、JVM 栈、静态方法区等),如果发现一个疑似指针变量(如真实的指针、整数等),JVM 都会检查堆中是否存在对象,如果存在就将对象信息保存在内存分配表中以标识某一段内存已被对象占用,否则就从内存分配表中删除这段内存以供下次分配。如果恰巧一个整数类型和一个指针所指向的地址相同,无论这个对象是否还“存活”,保守式 GC 都会保留这块内存,因为 JVM 不能确定这个变量不是指针。因为同一个对象可能在不同的栈帧中被使用且 JVM 又无法区分变量类型,所以 JVM 不能修改栈中“地址”的值(万一一个整型值和指针的值刚好相同),故保守式 GC 不能移动对象,只能使用类似标记-清除的算法法回收内存。如果 JVM 使用句柄(另一种为直接内存访问)的方式访问对象,则保守式 GC 也可以移动对象,但本该回收的对象依旧存在的现象无法消除
-
半保守式 GC
JVM 中栈上的变量一般不包含类型信息,但堆中的对象可以包含类型信息(例如反射等,这个和 C++ 中的虚函数表概念类似),所以垃圾回收时堆对象中的指针类型是可以确定的,此时就不存在上面整型和指针类型不分的情况,堆上的指针可以实现准确式回收,且可以移动对象
-
准确式 GC
准确式 GC 指 JVM 进行内存回收时可以确定指针的类型与位置,这一般需要辅助的数据结构与存储空间,HotSpot 中这些数据存储在 OopMap 中
OopMap
OopMap 是 HotSpot 实现准确式垃圾回收的基础,HotSpot 中的 GC Roots 一般保存在 OopMap 中,这样 JVM 在 GC 时就不用扫描所有静态区和栈帧,栈帧在运行时动态变化,所以 OopMap 的内容也在不断的变化
在类文件载入和 JIT(Just-In-Time Compiler)编译过程中 OopMap 都有发生变化的可能
何时更新 OopMap ?
什么时候或者说代码有什么特点时应该更新 OopMap?总不能编译器每执行一条指令就判断一下是否需要更新 OopMap吧,这样效率太低了
安全点
GC 需要在更新 OopMap 后才能进行, GC 时的 OopMap 已经包含了所有 GC Roots,且对象之间的引用关系不会在 GC 过程中发生变化,所以 JVM 并不能随意在任何位置进行垃圾回收,JVM 进行垃圾回收的位置需要满足一定的条件,这些满足条件的位置被称为安全点。常见的安全点有:方法调用、循环跳转、异常跳转等,这些点都不会改变对象之间的引用关系
垃圾回收需要所有线程都运行到安全点,一般有两种方式
-
抢先式中断(少见)
JVM 停止所有线程,如果一个线程没有运行到安全点就恢复其运行,直至到达安全点
-
主动式中断(常见)
JVM 设置一个标志位,当线程到达安全点时检查这个标志并自动停止运行
安全区域
如果线程阻塞了,或者 sleep,线程将无法执行到安全点,则 JVM 无法进行 GC,此时就需要安全区域的概念。所谓的安全区域,是指引用关系不会发生变化的指令段。线程进入安全区域时会给出一个标识,以供 JVM 查询
常见垃圾回收器
从垃圾回收器出现至今并没有出现一款通用的、在任何场景下性能都非常出众的实现,所以大部分请求下需要在不同的场景下选择不同的垃圾回收器,具体场景具体分析
新生代收集器
Serial
单线程垃圾收集器,收集时需要停止所有工作线程
ParNew
Serial 的多线程版本,可以配合 CMS 使用
Parallel Scavenge
使用复制算法的多线程收集器。其他算法关注如何减少垃圾回收时间,当前算法控制垃圾回收时间和工作线程工作时间的比值。你可以设置一个小的比值,则新生代会占用更多的内存;你可以设置一个大的比值,新生代会占用更少的内存,当然回收时间会变长
老年代收集器
CMS(基于标记清除)
CMS(Concurrent Mark Sweep)是一种追求最短停顿为目标的收集器,特点是并发低停顿收集器,老年代推荐使用
CMS 可以在用户进程运行时进行垃圾回收,此时用户进程产生的垃圾被称为浮动垃圾,CMS 只能等待下次回收时回收这些垃圾
一般在老年代内存被占用超过一定比例时才会触发 CMS 垃圾回收,提高比例可以减少垃圾回收的次数
Serial old(MSC)
Serial 的老年代版本
Parallel Old
Parallel Scavenge 的老年代版本
混合代垃圾回收
G1
未来可能替代 CMS,G1 可以同时用于新生代与老年代
GC 日志
不同垃圾回收器的日志格式不同,但有一定的共性
33.125:[ GC[ DefNew: 3324K- > 152K( 3712K), 0. 0025925 secs] 3324K- > 152K( 11904K), 0. 0031680 secs]
100.667:[ Full GC[ Tenured: 0 K- > 210K( 10240K), 0. 0149142secs] 4603K- > 210K( 19456K),[ Perm: 2999K- > 2999K( 21248K)], 0. 0150007 secs][ Times: user= 0. 01 sys= 0. 00, real= 0. 02 secs]
以第一行为例:
- 33.125 表示 GC 发生事件,从 JVM 启动到当前的秒数
- DefNew 表示新生代垃圾回收,不同垃圾回收器使用的关键字不同
- 3324k->152k(3712k),表示 GC 前该区域已使用容量,和垃圾回收后该区域所使用容量,括号内为当前区域总容量
- 0.0025925 secs 表示本次新生代垃圾回收所用时间
- 3324k -> 152k(11904k),GC 前 JVM 使用的堆容量,和 GC 后 JVM 使用的堆容量,圆括号内为总容量
- 0.0031680,为本次垃圾回收总耗时
部分 GC 原则
- 对象优先在 Eden 分配
- 如果 Eden 空间不足,则发起一次 minor GC
- 老年代 GC (Major GC/Full GC),速度比 Minor GC 要慢 10 倍以上
- 大对象直接进入老年代
- 避免新生代大量内存复制,新生代一般使用复制算法进行垃圾回收
- 长期存活的对象将直接进入老年代,可以设置一个时间,当对象存活时间超过这个值就扔进老年代
- 动态对象年龄判断
- Survivor 空间中先相同年龄所有对象大小总合大于 Survivor 空间的一半时,年龄大于或等于该年龄的对象将进入老年代
- 空间分配担保
- 新生代使用复制算法,当一个 Survivor 无法保存所有新生代对象时,需要将部分对象保存到老年代内存中,所以在进行 minor GC 前一般需要查看老年代可用空间是否大于新生代所有对象所用总空间,如果是就可以确保 minor GC 可以成功;否则一般会触发 Full GC 腾出空间或者其他机制,保证 Minor GC 不会出现大问题
- 具体介绍可参考周志明《深入理解 Java 虚拟机》