一文夯实垃圾收集的理论基础
如何判断一个引用是否存活
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
优点:可即刻回收垃圾,当对象计数为 0 时,会立刻回收;
弊端:循环引用时,两个对象的计数都为 1,导致两个对象都无法被释放。JVM 不用这种算法
可达性分析算法
通过 GC Root 对象为起点,从这些节点向下搜索,搜索所走过的路径叫引用链,当一个对象到 GC Root 没有任何的引用链相连时,说明这个对象是不可用的。
JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象
扫描堆中的对象,看能否沿着 GC Root 对象为起点的引用链找到该对象,如果找不到,则表示可以回收
GC Root 的对象有哪些?
虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法栈用到的参数、局部变量或者临时变量等。
方法区中类静态属性引用的对象或者说 Java 类中的引用类型的静态变量。
方法区中常量引用的对象或者运行时常量池中的引用类型变量。
本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
JVM 内部的内存数据结构的一些引用、同步的监控对象(被修饰同步锁)。
方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:
该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
加载该类的 ClassLoader 已经被回收。
该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。
finalize()
finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。(Java 9 中已弃用)
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。
引用类型
四个引用的特点:
强引用:gc 时不会回收
软引用:只有在内存不够用时,gc 才会回收
弱引用:只要 gc 就会回收
虚引用:是否回收都找不到引用的对象,仅用于管理直接内存
强引用
平时常见的
只要一个对象有强引用,垃圾回收器就不会进行回收。即便内存不够了,抛出 OutOfMemoryError 异常也不会回收。因此强引用是造成 java 内存泄漏的主要原因之一。 对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相 应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。
软引用
特点:软引用通过 java.lang.SoftReference 类实现。只有在内存不够用时,gc 才会回收
软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即 JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java 虚拟机就会把这个软引用加入到与之关联的引用队列中。后续,我们可以调用 ReferenceQueue 的 poll()方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个 null;否则该方法返回队列中前面的一个 Reference 对象。
当内存足够的时候,垃圾回收器不会进行回收。当内存不够时,就会回收只存在软引用的对象释放内存。
常用于本地缓存处理。
执行代码,可以看到以下输出:
弱引用
特点:弱引用通过 WeakReference 类实现。只要 gc 就会回收
弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
只要发生 gc,就会回收只存在弱引用的对象。
常用于 Threadlocal。
执行代码,可以看到以下输出:
虚引用
特点:虚引用也叫幻象引用,通过 PhantomReference 类来实现。是否回收都找不到引用的对象,仅用于管理直接内存
无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。 程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取一些程序行动。
无论是否 gc,其实都获取不到通过 PhantomReference 创建的对象。
其仅用于管理直接内存,起到通知的作用。
这里补充一下背景。因为垃圾回收器只能管理 JVM 内部的内存,无法直接管理系统内存的。对于一些存放在系统内存中的数据,JVM 会创建一个引用(类似于指针)指向这部分内存。
当这个引用在回收的时候,就需要通过虚引用来管理指向的系统内存。这里还需要依赖一个队列来实现。当触发 gc 对一个虚引用对象回收时,会将虚引用放入创建时指定的 ReferenceQueue 中。之后单独对这个队列进行轮询,并做额外处理。
输出:
终结器引用
所有的类都继承自 Object 类,Object 类有一个 finalize 方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的 finalize 方法。调用以后,该对象就可以被垃圾回收了如上图,B 对象不再引用 A4 对象。这时终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的 finalize 方法。调用以后,该对象就可以被垃圾回收了
引用队列
软引用和弱引用可以配合引用队列
在弱引用和虚引用所引用的对象被回收以后,会将这些引用放入引用队列中,方便一起回收这些软/弱引用对象
虚引用和终结器引用必须配合引用队列
虚引用和终结器引用在使用时会关联一个引用队列
四种引用的应用场景
强引用:普通用法
软引用:缓存,软引用可以用于缓存非必须的数据
弱引用:防止一些关于 map 的内存泄漏。Threadlocal 中防内存泄漏;线程池,当一个线程不再使用时,垃圾回收器会回收其所占用的内存空间,以便释放资源。
虚引用:用来管理直接内存
垃圾回收简介
Minor GC、Major GC、Full GC
JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)
部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
老年代收集(Major GC/Old GC):只是老年代的垃圾收集
目前,只有 CMS GC 会有单独收集老年代的行为
很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
目前只有 G1 GC 会有这种行为
整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾
对象在堆中的生命周期
在 JVM 内存模型的堆中,堆被划分为新生代和老年代
新生代又被进一步划分为 Eden 区 Survivor 区 From SurvivorTo Survivor
当创建一个对象时,对象会被优先分配到新生代的 Eden 区
此时 JVM 会给对象定义一个对象年轻计数器 -XX:MaxTenuringThreshold
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
如果分配的对象超过了 -XX:PetenureSizeThreshold 直接被分配到老年代
内存分配策略
对象优先在 Eden 分配: 大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC
大对象直接进入老年代: 当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代,最典型的大对象有长字符串和大数组。可以设置 JVM 参数 -XX:PretenureSizeThreshold ,大于此值的对象直接在老年代分配。
长期存活的对象进入老年代: 通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。对象在 Survivor 区每经过一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。
动态对象年龄判定: 并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄阈值。
空间分配担保: 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;(也就是说,会把原先新生代的对象挪到老年代中) ;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC 。
下面解释一下空间分配担保时的 “冒险”是冒了什么风险?
新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。但前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁
Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
用 System.gc(): 用于建议 JVM 进行垃圾回收。这个方法只是一个建议,并不能保证垃圾回收会如期进行。具体行为取决于垃圾回收器的实现和 JVM 的设置。不建议使用这种方式,而是让虚拟机管理内存。注意:在 HotSpot JVM 中,默认情况下 System.gc()是一定会触发 Full GC 的,但 JVM 参数-XX:+DisableExplicitGC 可以用于禁止这个行为,使得所有 System.gc()调用被忽略。这意味着即便是在 HotSpot 中,也不能绝对保证 System.gc()一定会执行垃圾回收。
老年代空间不足: 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组、注意编码规范避免内存泄露。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
空间分配担保失败: 当程序创建一个大对象时,Eden 区域放不下大对象,使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
JDK 1.7 及以前的永久代空间不足: 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError 。(JDK 8 以后元空间不足)
Concurrent Mode Failure:执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
Java 对象内存分配过程
对象的分配过程:
编译器通过逃逸分析优化手段,确定对象是否在栈上分配还是堆上分配。
如果在堆上分配,则确定是否大对象,如果是则直接进入老年代空间分配, 不然则走 3。
对比 tlab, 如果 tlab_top + size <= tlab_end, 则在 tlab 上直接分配,并且增加 tlab_top 值,如果 tlab 不足以空间放当前对象,则重新申请一个 tlab 尝试放入当前对象,如果还是不行则往下走 4。
分配在 Eden 空间,当 eden 空间不足时发生 YGC, 幸存者区是否年龄晋升、动态年龄、老年代剩余空间不足发生 Full GC 。
当 YGC 之后仍然不足当前对象放入,则直接分配老年代。
TLAB 作用原理:Java 在内存新生代 Eden 区域开辟了一小块线程私有区域,这块区域为 TLAB,默认占 Eden 区域大小的 1%, 作用于小对象,因为小对象用完即丢,不存在线程共享,快速消亡 GC,JVM 优先将小对象分配在 TLAB 是线程私有的,所以没有锁的开销,效率高,每次只需要线程在自己的缓冲区分配即可,不需要进行锁同步堆 。
对象除了基本类型的不一定是在堆内存分配,在 JVM 拥有逃逸分析,能够分析出一个新的对象所拥有的范围,从而决定是否要将这个对象分配到堆上,是 JVM 的默认行为;Java 逃逸分析是一种优化技术,可以通过分析 Java 对象的作用域和生命周期,确定对象的内存分配位置和生命周期,从而减少不必要的内存分配和垃圾回收。可以在栈上分配,可以在栈帧上创建和销毁,分离对象或标量替换,同步消除。
垃圾回收算法
标记清除算法
定义: 标记清除算法顾名思义,将存活的对象进行标记,然后清理掉未被标记的对象,给堆内存腾出相应的空间
这里的腾出内存空间并不是将内存空间的字节清 0,而是记录下这段内存的起始结束地址,下次分配内存的时候,会直接覆盖这段内存
优点:
实现简单,与其他算法组合也简单
与保守式 GC 算法兼容,因为他们都不需要移动对象。
缺点:
碎片化。简单来说就是随着分配和回收的进行会产生很多小的空闲对象散落在堆中,彼此也不连续。碎片化带来的问题是无法分配大的空闲空间,尽管总的空闲空间是够用的。碎片化带来的另一个问题是局部性原理失效,因为具有引用关系的数据分配到的空闲空间并不连续。
分配速度慢。因为空闲链表是单链表结构,分配时需要遍历链表,时间复杂度是 O(n)。
与写时复制不兼容。因为标记阶段会修改堆内对象,导致大量拷贝。
标记整理
GC 标记压缩算法分为标记阶段和压缩阶段。它是将 GC 标记清除算法的清除阶段换成了压缩,而且这里的压缩不是将活动对象从一个空间复制到另一个空间,而是将活动对象整体前移,挤占非活动对象的空间。
优点:
堆的利用率高
分配速度快
不会产生碎片化
GC 标记压缩算法缺点是吞吐量低。因为在压缩阶段我们需要遍历堆 3 次,耗费时间与堆大小成正比,堆越大,耗费时间越久。
复制算法
GC 复制算法的思路是将堆一分为二,暂时叫它们 A 堆和 B 堆。申请内存时,统一在 A 堆分配,当 A 堆用完了,将 A 堆中的活动对象全部复制到 B 堆,然后 A 堆的对象就可以全部回收了。这时不需要将 B 堆的对象又搬回 A 堆,只需要将 A 和 B 互换以下就行了,这样原来的 A 堆变成 B 堆,原来的 B 堆变成了 A 堆。经过这一轮复制,活动对象搬了新家,垃圾也被回收了。
GC 复制算法就是在两个堆之间来回倒腾。JVM 中的新生区就是使用的这种方式,总结为:在幸存区中,谁空谁是 to
由于对象地址发生了变化,GC 复制算法在复制过程中还需要重写指针。从复制的角度来看,活动对象是从 A 堆复制到 B 堆。因此我们也将 A 堆称为 From 空间,将 B 堆称为 To 空间。经过复制,原本散落在 From 空间中的活动对象被集中放到了 To 空间开头的连续空间内,这一过程也叫做压缩。
现在的虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象
优点:
吞吐量优秀。这得益于 GC 复制算法只会搜索复制活动对象,能在较短时间内完成 GC,而且时间与堆的大小无关,只与活动对象数成正比。相比于需要搜索整个堆的 GC 标记清除算法,GC 复制算法吞吐量更高,而且堆越大,差距越明显。
分配速度快。因为不需要搜索空闲链表,在 O(1)的时间复杂度就能完成分配。
不会发生碎片化。因为每次复制都会执行压缩。
与缓存兼容。因为复制过程中使用了深度优先遍历,具有引用关系的对象会被复制到相邻的位置,局部性原理可以很好发挥作用。
缺点:
堆的使用效率低。这是一个最显眼的问题,因为要留一半的空间用来复制,所以堆的利用率总小于 50%。
不兼容保守式 GC。因为 GC 复制算法需要移动对象。
复制时存在递归调用,需要消耗栈空间,并可能导致栈溢出。
分代回收
根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
新生代使用: 复制算法
老年代使用: 标记 - 清除 或者 标记 - 整理 算法
开始新创建的对象都被放在了新生代的伊甸园中
当多创建几个对象的后发现伊甸园装不下了。
当伊甸园中的内存不足时,就会进行一次垃圾回收,这时的回收叫做轻 GC Minor GC。一次小的垃圾回收,根据可达性算法找到不能被回收的,把这些不能被回收的对象复制到幸存区 To 中(用的复制算法),然后把幸存的对象的寿命+1,然后回收掉伊甸园里面的全部对象。
再把 From 区和 To 区的指向互换,那么这是 to 区就又空出来了
再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 stop the world, 暂停其他用户线程,只让垃圾回收线程工作),
这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,From 也会被垃圾回收检查,再将活跃对象复制到幸存区 TO 中。回收以后会交换两个幸存区,并让幸存区中的对象寿命加 1。
这里 1 是从伊甸园区新进幸存区的对象,2 是原本就存活在幸存区寿命+1 后为 2
这时就有对象在两次 GC 中存活下来那么他的存活次数就会是 2,如果幸存区中的对象的寿命超过某个阈值(最大为 15,4bit),那么就会晋升到老年代中去
因为老年代的垃圾回收频率比较低,这个对象在新生代里面反复 GC 都没有回收掉说明长时间在用,那么就没有必要在新生代中反复 GC
如果新生代老年代中的内存都满了,就会先触发 Minor GC,再触发 Full GC,扫描新生代和老年代中所有不再使用的对象并回收。如果两次 GC 后还是放不下, 就会报 OOM 异常
在报堆内存溢出之前,还会去尝试 minorGC 一次如果 minorGC 了释放出来的空间还是放不下,那么就会触发一次 fullGC(类似于大扫除,老年代和新生代都会被 GC),如果还是没办法放下那么就会报 java.lang.OutOfMemoryError: Java heap space
总结: 在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,使用复制算法比较合适,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高,适合使用标记-清理或者标记-整理算法进行垃圾回收。
并发标记算法(三色标记法)
CMS 和 G1 在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。白色是未标记;灰色自身被标记,引用的对象未标记;黑色自身与引用对象都已标记。
GC 开始前所有对象都是白色,GC 一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色。然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色。当 GC 结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾。
文章转载自:Seven
评论