三个问题:
- 哪些内存需要回收?
- 什么时候回收?
- 怎么回收内存?
3.2 哪些内存需要回收?
一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件的分支所需要的内存也可能不一样。只有处于运行期间,我们才能知道程序需要创建哪些对象。
3.2.1 引用计数算法
在对象中创建一个引用计数器,每个有一个地方引用它的时候,计数器加一。当引用失效时,计数器减一。任何时刻计数器为零的对象就是不可能被使用的。
优点:高效
缺点:情况多种多样,比如相互循环问题
Obj a = new Obj();
Obj b = new Obj();
a.instance = b;
b.instance = a;
3.2.2 可达性分析算法
通过一系列的“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链连接,那么证明此对象时不可能被使用。
怎么判断是GC Roots?
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NPE,OOM)等,还有系统类加载器。
- 所用被同步锁持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
在分代收集和局部回收时,有可能这些区域被堆中的其它区域所引用。这时需要将这些关联区域的对象也一起加入到GC Roots中。
3.2.3 再谈引用
-
强引用
/** * 强引用 * 如果一个对象具有强引用,那就类似于必不可少的物品,不会被垃圾回收器回收。 * 当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。 * * 如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为null, * 这样一来的话,JVM在合适的时间就会回收该对象。 * @author ZhengChaoHui * @Date 2020/6/30 8:47 */ public class StrongRef { public static void main(String[] args) { StrongRef.m1(); } public static void m1(){ Object o = new Object(); Object[] objects = new Object[Integer.MAX_VALUE]; } } 执行报错: Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit at references.StrongRef.m1(StrongRef.java:19) at references.StrongRef.main(StrongRef.java:15)
-
软引用
/** * 软引用 * 软引用是用来描述一些有用但并不是必需的对象,在Java中用java.lang.ref.SoftReference类来表示。 * 对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。 * 因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。 * * 软引用可以和一个引用队列(ReferenceQueue)联合使用, * 如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。 * @author ZhengChaoHui * @Date 2020/6/29 19:42 */ @SuppressWarnings("all") public class SoftRef { public static void main(String[] args) { Obj obj = new Obj(); SoftReference<Obj> sr = new SoftReference<Obj>(obj); obj = null; System.out.println(sr.get()); } } class Obj{ int[] obj; public Obj(){ obj = new int[1000]; } }
-
弱引用
/** * 弱引用 * 弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。 * 在java中,用java.lang.ref.WeakReference类来表示。 * 弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。 * 在垃圾回收器线程扫描它所管辖的内存区域的过程中, * 一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。 * 不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。 * 软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在JVM进行垃圾回收时总会被回收。 * @author ZhengChaoHui * @Date 2020/6/29 19:46 */ public class WeakRef { public static void main(String[] args) { WeakReference<String> wr = new WeakReference<String>(new String("Hello")); //输出Hello System.out.println(wr.get()); //通知jvm回收(这句是无法确保此时JVM一定会进行垃圾回收的) System.gc(); //输出null System.out.println(wr.get()); } }
-
虚引用
/** * 虚引用 * 虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。 * 在java中用java.lang.ref.PhantomReference类表示。 * 如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。 * 虚引用主要用来跟踪对象被垃圾回收的活动。 * *虚引用必须和引用队列关联使用* * @author ZhengChaoHui * @Date 2020/6/30 8:56 */ public class PhantomRef { public static void main(String[] args) { ReferenceQueue<String> queue = new ReferenceQueue<>(); System.out.println(queue.poll()); PhantomReference<String> ptr = new PhantomReference<>(new String("hello"), queue); System.out.println(queue.poll()); System.out.println(ptr.get()); } } 输出:null null
参考文章:
3.2.4 finalize方法
要想宣告一个对象真正死亡,至少要经历两次标记过程:如果对象在经过可达性分析后发现没有与GC Roots相连接的引用链,那么就会被第一次标记,随后进行一次筛选,筛选条件是此对象是否有必要进行finalize方法。假如对象没有覆盖finalize方法,或者finalize方法已经被调用过,那么虚拟机都将其视为‘没有必要执行‘。
如果在finalize方法里面对象又重新引用了引用链上的任意对象,那么对象可以跳脱。但是再下一次收集的时候,就不会再判断是否执行finalize方法了,直接宣告死亡(如果没有引用到引用链)。
3.2.5 回收方法区
方法区的垃圾收集主要回收两部分:废弃的常量和不再使用的类型。
判断废弃的常量只需要判断是否有其它对方引用它。
判断一个类型不再使用:
- 该类的所有实例都已经被回收。
- 加载该类的加载器已经被回收。
- 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.3 垃圾收集算法(重点)
3.3.1 分代收集理论
弱分代假说
绝大多数对象都是朝生夕灭的。
强分代假说
熬过越多次垃圾收集过程的对象就越难消亡。
根据这两个假说奠定了多款垃圾收集器的一致设计原则:需要分区域、分代收集。
跨代引用假说
跨代引用相对于同代引用来说仅占极少数
部分收集 Partial GC
- Minor GC/ Young GC:目标只是新生代的垃圾收集
- Major GC/Old GC:目标只是老年代的垃圾收集(目前只有CMS收集器拥有)
- Mixed GC:目标是整个新生代和部分老年区的垃圾收集
整堆收集 Full GC
收集整个Java堆和方法区的垃圾收集
3.3.2 标记-清除算法(Mark-Sweep)

先标记需要清除的,然后删除。或者标记不需要清除的,删除没有标记的。
优点:简单,不需要移动对象。
缺点:产生大量内存碎片,导致内存分配复杂。而且再需要大量回收对象的时候,效率低下。
3.3.3 标记复制算法(Mark-Copy)

优点:在需要大量清除可回收对象的时候,只需要将少量的对象移动到另一个半区即可。
缺点:浪费一半的空间。
折中:Appel式回收,将新生代分为Eden和两个Survivor区(一个from,一个to),比例是8:1:1。
3.3.4 标记整理算法(Mark-Compact)

优点:便于内存分配。
缺点:如果存活对象过多,STW会很长,因为移动对象需要暂停用户进程。
折中:平时多数时间使用标记-清除算法,直到内存的碎片化程度已经大到影响对象分配时,再采用一个标记-整理算法收集一次。
3.4 HotSpot的算法细节实现
3.4.1 根节点枚举
迄今为止,所有的收集器在这一步骤都要STW。
OopMap里面存着引用的位置。
3.4.2 安全点
在“特定位置”记录OopMap信息,这些位置就是安全点。
安全点的选取一般以“是否具有让程序长时间运行”的标准选取的。一般在方法调用、循环跳转、异常跳转等地方设置。
抢先式中断:GC时,所有线程中断,然后让没跑到安全点的线程继续跑到安全点。(几乎不使用)
主动式中断:设置一个标志位,让线程轮询访问,一旦发现中断标记为真就在最近的安全点上中断挂起。
3.4.3 安全区域
针对休眠或者阻塞的线程不能主动中断,JVM引入安全区域来解决。
安全区域是指能够在某一段代码里面,引用关系不会发生变化。(其实就是安全点的扩展拉伸)
线程进入到安全区域的时候,会标记自己已经进入安全区域,等到出去的时候,它要检查根节点枚举是否完成。如果完成,继续运行,否则,等待。GC时可以不用管在安全区域的线程。
3.4.4 记忆集和卡表
记忆集:
一种用于记录从非收集区区域指向收集区域的指针集合的数据结构
卡表是记忆集的一种实现。
卡表标记为1,说明对应的卡页存在跨代指针。
3.4.5 写屏障
写屏障包括写前屏障和写后屏障,和AOP中的Around相似。
在写后屏障中更新卡表状态。
卡表容易出现“伪共享”,可以不采用写屏障,而是先检查卡表状态,只有没有标记过的卡表才去标记其为脏。
3.4.5 并发的可达性分析
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
增量更新:
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照:
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
3.5 经典垃圾收集器

3.5.1 Serial收集器
3.5.2 ParNew收集器
3.5.3 Parallel Scavenge收集器
吞 吐 量 = 运 行 用 户 代 码 时 间 运 行 用 户 代 码 时 间 + 运 行 垃 圾 收 集 时 间 吞吐量 = \frac{运行用户代码时间} {运行用户代码时间 + 运行垃圾收集时间} 吞吐量=运行用户代码时间+运行垃圾收集时间运行用户代码时间
3.5.4 Serial Old收集器
3.5.5 Parallel Old收集器
3.5.6 CMS收集器
- Inital Mark 初始标记: 主要是标记GC Root开始的下级(注:仅下一级)对象,这个过程会STW,但是跟GC Root直接关联的下级对象不会很多,因此这个过程其实很快。
- Concurrent Mark 并发标记:根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有STW。
- Remark 再标志:为啥还要再标记一次?因为第2步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。试想下,高铁上的垃圾清理员,从车厢一头开始吆喝“有需要扔垃圾的乘客,请把垃圾扔一下”,一边工作一边向前走,等走到车厢另一头时,刚才走过的位置上,可能又有乘客产生了新的空瓶垃圾。所以,要完全把这个车厢清理干净的话,她应该喊一下:所有乘客不要再扔垃圾了(STW),然后把新产生的垃圾收走。当然,因为刚才已经把收过一遍垃圾,所以这次收集新产生的垃圾,用不了多长时间(即:STW时间不会很长)
- Concurrent Sweep:并行清理,这里使用多线程以“Mark Sweep-标记清理”算法,把垃圾清掉,其它工作线程仍然能继续支行,不会造成卡顿。等等,刚才我们不是提到过“标记清理”法,会留下很多内存碎片吗?确实,但是也没办法,如果换成“Mark Compact标记-整理”法,把垃圾清理后,剩下的对象也顺便排整理,会导致这些对象的内存地址发生变化,别忘了,此时其它线程还在工作,如果引用的对象地址变了,就天下大乱了。另外,由于这一步是并行处理,并不阻塞其它线程,所以还有一个副使用,在清理的过程中,仍然可能会有新垃圾对象产生,只能等到下一轮GC,才会被清理掉。
3.5.7 Garbage First收集器
它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式
CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。
G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过 程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在 这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
- 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的
与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”
参考书籍/文章:
- 一文看懂 JVM 内存布局及 GC 原理
- 深入理解Java虚拟机