
有这样一个梗,说在食堂里吃饭,吃完把餐盘端走清理的,是 C++ 程序员,吃完直接就走的,是 Java 程序员。
确实,在 Java 的世界里,似乎我们不用对垃圾回收那么的专注,很多初学者不懂 GC,也依然能写出一个能用甚至还不错的程序或系统。但其实这并不代表 Java 的 GC 就不重要。相反,它是那么的重要和复杂,以至于出了问题,那些初学者除了打开 GC 日志,看着一堆 0101 的天文,啥也做不了。
今天我们就从头到尾完整地聊一聊 Java 的垃圾回收。
导读我们通过一张图的方式,从总体上对 JVM 的结构特别是内存结构有一个比较清晰的认识。
虽然在 JDK1.8+ 的版本中,JVM 内存管理结构有了一定的优化调整:主要是方法区(持久代)取消变成了直接使用元数据区的方式,但是整体上 JVM 的结构并没有大改,特别是我们最为关心的堆内存管理方式并没有在 JDK1.8+ 的版本中有什么变化。
在上面的图中,我们也大致对整个垃圾回收系统进行了标注,这里主要涉及回收策略、回收算法、垃圾回收器这几个部分。
形象一点表述,就是 JVM 需要知道哪些内存可以被回收,要有一套识别机制,在知道哪些内存可以回收以后具体采用什么样的回收方式,这就需要涉及一些回收算法,而具体的垃圾回收器就是根据不同内存区域的使用特点,采用相应地回收策略和算法的具体实现了。
下面我们就从这几个方面给大家介绍,JVM的垃圾回收相关的知识点。
什么是垃圾回收?垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
哪些内存需要回收?我们知道,根据 《Java虚拟机规范》,Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。
而程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要过多考虑如何回收的问题。
而 Java 堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。
如何判断对象已成垃圾?既然是垃圾收集,我们得先判断哪些对象是垃圾,然后再看看何时清理,如何清理。
常见的垃圾回收策略分为两种:
优点:实现简单,效率高。
缺点:
所谓对象之间的相互引用问题,如下面代码所示:
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。
但是它们因为互相引用对方,导致它们的引用计数器都不为 0,通过引用计数算法,也就永远无法通知 GC 收集器回收它们。
可达性分析算法(Reachability Analysis)的基本思路:
有一个比喻十分恰当:可达性分析算法就好比是在清洗葡萄串,我们可以从一根枝提起一大串葡萄,他们就像一串引用链,而没有和引用链相连的对象就像是散落在池子里的葡萄,可以回收。
在图中虽然 Object 6 与 Object 7 之间互相有关联,但是它们到 GC Roots 是不可达的,所以将会被判定为可回收对象。
通过可达性算法,成功解决了引用计数所无法解决的问题「循环依赖」,只要你无法与 GC Roots 建立直接或间接的连接,系统就会判定你为可回收对象。那这样就引申出了另一个问题,哪些属于 GC Roots 。
在 Java 语言中里面,可作为 GC Roots 的对象包括以下几种:
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与「引用」有关。
JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种,引用强度逐渐减弱。
强引用正常情况下我们平时基本上我们只用到强引用类型,例如 Object obj = new Object();。
无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。且当内存空间不足抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠回收具有强引用的对象,来解决内存不足的问题。
软引用软引用是种相对强引用弱化一些的引用,用来描述一些还有用,但非必须的对象。
软引用是通过 SoftReference 类实现的。
Object obj = new Object(); SoftReference softObj = new SoftReference(obj); obj = null;
被软引用关联着的对象,在即将 OOM 之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
弱引用是通过 WeakReference 类实现的。
Object obj = new Object(); WeakReference
弱引用与软引用的区别:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
虚引用ThreadLocal 中的 key 就用到了弱引用。
虚引用,也称幻象引用。是通过 PhantomReference 类实现的。它是最弱的一种引用关系,定义完成之后,无法通过虚引用来取得一个对象实例。
无法通过虚引用访问对象的任何属性或者函数。那就要问了要它有什么用?
虚引用仅仅只是提供了一种确保对象被 finalize 以后来做某些事情的机制。
比如说这个对象被回收之后发一个系统通知啊啥的。虚引用是必须配合 ReferenceQueue 使用的,当垃圾回收时,如果存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。
引用小结重点来了,下面我们开始介绍几个重要的垃圾回收算法。
垃圾收集算法标记-清除算法是最为基础的一种收集算法,总的来说分为两步:
回收前状态:
回收后状态:
这种收集算法的优点是简单直接,不会影响 JVM 进程的正常运行。
但是会带来两个明显的问题:
复制算法(Copying)是在标记清除算法上演化而来,解决标记-清除算法的内存碎片问题。这种算法的思路是将可用的内存空间按容量划分为大小相等的两块,每次只使用其中一块。
算法描述:
回收前状态:
回收后状态:
优点:
缺点:
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
目前此种算法主要用于新生代回收。
因为新生代的中 98% 的对象都是很快就需要被回收的对象,所以并不需要 1:1 的比例来划分内存空间,在新生代中 JVM 是按照 8:1:1 的比例(文顶图中有标注)来将整个新生代内存划分为一块较大的 Eden 区和两块较小的 Survivor 区(S0、S1)。
每次使用 Eden 区和其中一个 Survivor 区,当发生回收时将 Eden 区和 Survivor 区中还存活的对象一次性复制到另一块 Survivor 区上,最后清理掉 Eden 区和刚才使用过的 Survivor 区。
理想情况下,每次新生代中的可用空间是整个新生代容量的 90%(80%+10%),只会有 10% 的内存会被浪费。
实际情况中,如果另外一个 10% 的 Survivor 区无法装下所有还存活的对象时,就会将这些对象直接放入老年代空间中 (这块在后面的分代回收算法会说到,这里先了解下)。
标记-整理算法如果在对象存活率较高的情况下,仍然采用复制算法的话,因为要进行较多的复制操作,效率就会变得很低,而且如果不想浪费 50% 的内存空间的话,就还需要额外的空间进行分配担保,以应对存活对象超额的情况。
显然老年代不能采用复制算法。
根据老年代的特点,标记-清除-压缩(简称标记-整理)算法应运而生。
算法描述:
回收前状态:
回收后状态:
标记整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但从上图可以看到,它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。
标记整理算法的问题:
分代收集算法Stop The World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
分代收集算法(Generational Collection)严格来说并不是一种思想或理论,而是融合上述 3 种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。
JVM 根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
问题来了,那内存区域到底被分为哪几块,每一块又有什么特别适合什么算法呢?
分代收集理论 为什么要分代?我们首先必须知道,将 JVM 堆中区域分成新生代和年老代并不是 Java 虚拟机规范所规定的。规范中只是阐述了堆这么个区域,将堆中区域进行分代是不同垃圾收集器的行为,而不是JVM的规范,当然大多数垃圾收集器确实对堆进行了分代回收的策略。
那为什么要这么做呢?
这是基于这样一个事实:不同的对象的生命周期是不一样的。
在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是它们依旧存在。
因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。
新生代中的对象存活时间短,只需要在新生代区域中频繁进行 GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。
内存分代划分Java 堆主要分为 2 个区域:新生代与老年代,其中新生代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 两个区。
内存分代示意图如下:
Eden 区IBM 公司的专业研究表明,有将近 98% 的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 YGC。
YGC 对应于新生代,第一次 YGC 只回收 Eden 区域,回收后大多数的对象会被回收,活着的对象通过复制算法进入 Survivor 0(后续用S0和S1代替)。
再次 YGC 后,Eden + S0 中活着的对象进入 S1
再次 YGC,Eden + S1 中活着的对象进入到 S0
依次循环。看到这里我相信你已经明白了为什么要设置两个 Survivor 区域了。
YGC(Young GC)/ MinorGC: 针对新生代进行的垃圾回收,新生代空间不足会触发
Major GC:清理老年代
Full GC:清理整个堆空间,包括年轻代和永久代甚至是方法区
Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。
Eden 区的旁边是两个存活区(Survivor Spaces),称为 from 空间和 to 空间。需要着重强调的的是,任意时刻总有一个存活区是空的(empty)。
空的那个存活区用于在下一次年轻代 GC 时存放收集的对象。年轻代中所有的存活对象(包括 Eden 区和非空的那个 from 存活区)都会被复制到 to 存活区。GC 过程完成后, to 区有对象,而 from 区里没有对象。两者的角色进行正好切换,from 变成 to,to 变成 from 。
为啥需要 Survivor 区?不就是新生代到老年代么,直接 Eden 到 Old 不好了吗,为啥要这么复杂。
想想如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。
所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历 15 次 Minor GC 还能在新生代中存活的对象,才会被提升到老年代。
对象提升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
如果存活区空间不够存放年轻代中的存活对象,提升也可能更早地进行。
老年代(Old Gen)老年代的 GC 实现要复杂得多。老年代内存空间通常会更大,里面的对象是垃圾的概率也更小。
老年代 GC 发生的频率比年轻代小很多。同时,因为预期老年代中的对象大部分是存活的,所以不再使用标记和复制(Mark and Copy)算法。而是采用移动对象的方式来实现最小化内存碎片。
老年代空间的清理算法通常是建立在不同的基础上的。原则上,会执行以下这些步骤:
通过上面的描述可知,老年代 GC 必须明确地进行整理,以避免内存碎片过多。
堆内存常见的分配策略目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
下面我们来进行实际测试一下。
public class GCTest {
public static void main(String[] args) {
byte[] allocation1, allocation2;
allocation1 = new byte[30900*1024];
//allocation2 = new byte[900000*1024];
}
}
idea 添加参数:
-XX:+PrintGCDetails
运行结果:
Heap PSYoungGen total 75776K, used 37408K [0x000000076bb00000, 0x0000000770f80000, 0x00000007c0000000) eden space 65024K, 57% used [0x000000076bb00000,0x000000076df88318,0x000000076fa80000) from space 10752K, 0% used [0x0000000770500000,0x0000000770500000,0x0000000770f80000) to space 10752K, 0% used [0x000000076fa80000,0x000000076fa80000,0x0000000770500000) ParOldGen total 173568K, used 0K [0x00000006c3000000, 0x00000006cd980000, 0x000000076bb00000) object space 173568K, 0% used [0x00000006c3000000,0x00000006c3000000,0x00000006cd980000) Metaspace used 3319K, capacity 4496K, committed 4864K, reserved 1056768K class space used 362K, capacity 388K, committed 512K, reserved 1048576K
根据上面的结果,我们可以看出 eden 区内存被分配 57%。假如我们再为 allocation2 分配内存会出现什么情况呢?
Heap PSYoungGen total 75776K, used 37403K [0x000000076bb00000, 0x0000000770f80000, 0x00000007c0000000) eden space 65024K, 57% used [0x000000076bb00000,0x000000076df86f60,0x000000076fa80000) from space 10752K, 0% used [0x0000000770500000,0x0000000770500000,0x0000000770f80000) to space 10752K, 0% used [0x000000076fa80000,0x000000076fa80000,0x0000000770500000) ParOldGen total 1073664K, used 900000K [0x00000006c3000000, 0x0000000704880000, 0x000000076bb00000) object space 1073664K, 83% used [0x00000006c3000000,0x00000006f9ee8010,0x0000000704880000) Metaspace used 3323K, capacity 4496K, committed 4864K, reserved 1056768K class space used 362K, capacity 388K, committed 512K, reserved 1048576K
简单解释一下为什么会出现这种情况:因为给 allocation2 分配内存的时候 eden 区内存几乎已经被分配完了,我们刚刚讲了当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。GC 期间虚拟机又发现 allocation1 无法存入 Survior 空间,所以只好通过分配担保机制把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。
执行 Minor GC后,后面分配的对象如果能够存在 eden 区的话,还是会在 eden 区分配内存。
大对象直接进入老年代大对象就是需要大量连续内存空间的对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
直接在老年代分配内存,主要为了避免在新生代区频繁的 GC 时发生大量的内存复制。
长期存活的对象将进入老年代既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别那些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。
对象在 Survivor 中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。
对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
总结本文我们主要介绍了垃圾回收的基本原理,垃圾回收器相关的,我们在后面的文章继续分析。
我整理了以下的问题,可以在文中找到答案:
如果你还想看更多优质原创文章,欢迎关注我的公众号「ShawnBlog」。