99.Java垃圾回收

345 阅读22分钟

一、什么是GC?

垃圾回收(GC)是由Java虚拟机(JVM)垃圾回收器提供的一种对内存回收的一种机制,它一般会在内存空间或占用过高时对那些没有被引用的对象不定时回收。GC时需要我们思考三件事情?

  • 哪些内存需要回收?
  • 什么时候进行回收?
  • 如何回收?

Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快的分配内存。

image.png

大部分情况,对象都会优先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入S0或者S1,并且对象的年龄还会加1(Eden区 -》 Survivor区后对象的初始年龄变为1),当对象的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄可以通过参数-XX:MaxTenuringThreshold来设置默认值。

二、对象已死?

堆中几乎放着所有的对象实例,对垃圾回收前的第一步就是要判断哪些对象已经“死亡”(即不可能再被任何途径引用的对象),哪些对象还活着。那么如何判断对象已经死亡呢?

2.1 引用计数法

为对象添加一个引用计数器,每当一个地方引用它时i,计数器值就加1;当引用失效时计数器减1;任何时刻计数器为0的对象就是不能再被使用的。

客观说引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是目前主流虚拟机并没有选择这个算法来进行内存管理,其中最主要的原因是它很难解决对象间相互循环引用的问题。

比如下面的代码:

image.png

对象objA和objB相互引用着对方,因为他们之间的互相引用,导致他们的引用计数器都不为0,于是引用技术算法无法判定他们的死亡,不能通知GC回收器回收他们。

2.2 可达行分析算法

这个算法的基本思路是通过一系列称为"GC Roots"的对象作为起始点,从这些节点向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

image.png

可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(帧栈中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)中引用的对象

2.3 再谈引用

无论是通过引用计数算法判断对象引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。

JDK1.2以前Java中的引用定义很传统:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。

JDK1.2以后Java对引用概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phanom Reference)4种(引用强度依次减弱)

1. 强引用(Strong Reference)

强引用指程序代码中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器绝不会回收它。当内存不足时,JVM宁愿抛出OutOfMemoryError错误,也不会随意回收具有强引用的对象解决内存不足问题

2. 软引用(Soft Reference)

软引用是用来描述一些还有用但并非必需的对象。在系统将要发生内存溢出之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,就会进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常

3. 弱引用(Weak Reference)

弱引用是用来描述非必须的对象,但是其强度比软引用更弱一些,被软引用关联的对象只能生存到下次垃圾收集发生之前。当开始GC时,无论当前内存空间是否足够都会回收掉只被弱引用关联的对象

4. 虚引用(Phantom Reference)

虚引用是最弱的一中引用关系。一个对象是否有虚引用的存在,完全不会对其生命周期构成影响。也无法通过虚引用取得一个对象实例。

需要注意的是,在程序设计中一般很少使用弱引用和虚引用,使用软引用情况较多,这是因为软引用可以加速JVM堆垃圾内存的回收速度,可以维护系统运行安全,防止内存溢出等问题产生。

2.4 不可达对象并非“非死不可”

即使在可达性分析算法中不可达的对象,也并非是“非死不可”。此时它们处于“缓刑”阶段,要真正宣告一个对象的死亡,至少要经历两次标记过程:

  • 如果对象在进行可达性分析算法后发现并没有与GC Roots相连接的引用链,它将会第一次标记并再进行一次筛选。 第二次筛选的条件是此对象是否有必要执行finallize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行回收”
  • 如果这个对象被判定为有必要执行finalize()方法,那么该对象将会放置在一个F-Queue的队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则真的就会被回收。

Object类中finalize方法一直被认为是一个糟糕的设计,成为了Java语言中的负担,影响了Java语言的安全和GC性能。JDK9及后续版本各个类中的finalize方法会被逐渐弃用移除。

2.5 如何判断一个常量是否废弃常量

回收废弃常量与Java堆中的对象非常相似。以常量池中字面量的回收为例,假如常量池中有一个字符串“abc”,但是当前系统中没有任何一个String对象是叫做“abc”的,换句话说就是没有任何String对象引用常量池中“abc”常量,也没有其它任何地方引用“abc”,发生内存回收时“abc”常量会被清理出常量池。

2.6 如何判断一个类是无用的类

相比于常量,判断一个类是不是无用的类条件苛刻了许多。类需要同时满足下面3个条件才能算是无用的类:

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

满足上述三个条件的无用类可以进行回收,这里说的仅仅是“可以”,而并非像对象一样不使用了就必然会回收。HotSpot虚拟机提供了-Xnoclasssc参数进行控制。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

三、垃圾收集算法

3.1 标记-清除算法

该算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统计回收所有被标记的对象。

image.png

标记清除算法有两个问题:

  • 效率问题:标记和清除过程效率都不高
  • 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片过多可能会导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次GC

3.2 复制算法

复制算法将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间清理掉。这样使得每次回收都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片的问题,只要移动指针,按顺序分配内存即可。

不过这种算法的缺点就是将内存缩小为原来的一半,未免太高了点。

复制算法也有两个问题:

  • 对象存活率较高时需要进行较多的复制操作,效率将会变低。
  • 如果不想浪费50%的空间,就需要额外空间进行分配担保以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不选用这种方法。

image.png

现在主流商业虚拟机都是采用这种算法来回收新生代,IBM公司研究表明,新生代中对象98%是“朝生夕死”,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间(大小比例为8:1),每次只使用Eden和其中一块Survivor。

  • 回收时,将Eden和Survivor中存活着的对象一次性复制到另外一块Survivor空间
  • 清理掉Eden空间和刚才用过的Survivor空间

HotSpot虚拟机默认Eden和Survivor大小比例为8:1,也就是新生代中可用内存空间为90%,只有10%的内存空间会被“浪费”。当然如果GC时发现存活的对象大小超过内存空间的10%时,当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。

3.3 整理算法

鉴于复制算法有上述两种缺点,所以老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了标记-整理算法。

标记整理算法过程与标记“标记-清除”算法一样,但后续步骤不是直接对2克回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

image.png

3.4 分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法就是根据对象存活周期的不同将内存划分为几块。一般将堆内存划分为新生代和老年代,这样就可以根据各个年代特点采用最适当的收集算法。

  • 新生代中,每次垃圾收集时都会有大批对象死去,只有少量存活,那就选用复制算法
  • 老年代中对象存活率高、没有额外空间对它进行分配担保,就必须选用“标记-清理”或者“标记-整理”算法进行回收。

3.5 如何枚举根节点

GC Roots的节点主要在全局性的引用(常量或静态属性)与执行上下文中,现在很多应用仅方法区就有数百兆,如果逐个检查这里的引用,必然会消耗很多时间。

可达性分析算法对执行时间的敏感性还体现在GC停顿上,因为这项分析工作必须在一个能够确保一致性的快照中进行----这里“一致性”的意思是指整个分析期间,整个系统看起来就像被冻结在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况。这点是导致GC进行时必须停顿所有Java执行线程(Stop the world)的其中一个重要原因。

3.6 垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

虽然我们是在对各个收集器进行比较,但并非为了挑选出一个最好的收集器,因为直到现在为止还没有最好的收集器出现,更加没有万能的收集器,所以要我们选择的只是对具体应用最合适的收集器。

3.6.1 Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器了。看名字就能知道,这个收集器是一个单线程的收集器,但它的单线程并不仅仅意味着它只会用一个CPU或一条收集线程去完成垃圾收集工作。更重要的是它在进行 垃圾收集时,必须暂停其他所有工作线程直到收集结束。

新生代采用标记-复制算法,老年代采用标记-整理算法

image.png

虚拟机的设计者们当然知道Stop The World带给用户的不良体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短。但是Serial收集器有没有优于其它垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择。

3.6.2 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数收集算法回收策略等)和Serial收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法

image.png

它是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。

ParNew收集器在单CPU环境中绝对不会比Serial收集器有更好的效果,甚至由于存在线程交互开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分百的超越Serial收集器。它默认开启的收集线程数与CPU数量相同,在CPU非常多的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

3.6.3 Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。看上去和ParNew都一样,那它有什么特别之处呢?

新生代采用标记-复制算法,老年代采用标记-整理算法

image.png

Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)。

JDK1.8默认使用的是Parallel Scavenge + Parallel Old,如果指定了-XX:+UseParallelGC参数,则默认指定了-XX:+UseParallelOldGC, 可以使用-XX:-UseParallelOldGC来禁用该功能。

停顿时间越短越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

3.6.4 Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。

老年代采用标记-整理算法

image.png

它有两大用途:

  • 一种用途是在JDK1.5及以前版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。
  • 另一种用途是作为CMS收集器的后备方案。

3.6.5 Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。在注重吞吐量以及CPU资源的场合,都可以优先考虑Parallel Scavenge收集器和Parallel Old收集器。

使用多线程和标记-整理算法

image.png

这个收集器是在JDK1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scavenge收集器,除了Serial Old收机器外别无选择。由于老年代Serial Old收集器在服务端应用性能上的拖累,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大且硬件比较高级的环境中,这种组合吞吐量还不一定有ParNew + CMS的组合给力。直到Parallel Old收集器出现后,吞吐量优先收集器终于有了比较名副其实的应用组合。

3.6.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集合在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

CMS(Concurrent Mark Sweep)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种“标记-清除”算法实现的,其运作过程相比于前面集中垃圾收集器来说更加复杂一些,整个过程分为四个步骤:

  • 初始标记:暂停所有其他线程,并记录下直接与Root相连的对象,速度很快
  • 并发标记:同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断更新引用域。所以GC线程无法保证可达性的实时性,所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除:开启用户线程,同时GC线程开始对未标记区域做清扫。

image.png

从它的名字可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它也有下面三个明显的缺点:

  • 对CPU资源敏感
  • 无法处理浮动垃圾
  • 使用“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

3.6.7 G1收集器

G1是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以提高概率满足GC停顿时间要求的同时,还具备高吞吐量的性能特征。

被视为JDK1.7中HotSpot虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集:虽然G1可以不需要其它收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
  • 空间整合:与CMS的标记-清理算法不同,G1从整体上来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。

G1收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也是其名字Garbage_First的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)

四、内存分配与回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化的解决两个问题:给对象分配内存以及回收分配给对象的内存。

4.1 对象优先在Eden分配

大多数情况下,对象在新生代中eden区分配。当eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

image.png

从图中可以看到。当Eden空间足够时,对象优先在Eden中分配。from space和to spacee内存并未使用。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor空间比例是8:1。从输出结果也可以看到新生代总可用空间=(65536+10752)K

image.png

在为allocation2分配内存时,JVM发现Eden空间不足,便会触发Minor GC对Eden区内存进行回收然后存储到from space内。Eden空间足够大的话便在Eden区为新对象分配空间

image.png

在为allocation2分配内存时,JVM发现Eden空间不足,便会触发Minor GC对Eden内存进行回收。但是在这过程中发现from space内存空间不足以存储回收对象时便会通过分配担保机制将对象存入老年代中。所以这里可以看到老年代内存被使用了一部分。

4.2 大对象直接进入老年代

大对象是指,需要大量连续内存空间的Java对象(比如字符串以及数组)。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来存放它们。虚拟机提供-XX:PretenureSizeThreshold参数,令大于这个设置值得对象直接在老年代分配。这样做目的是避免在Eden区以及Survivor区间发生大量的内存复制(新生代采用复制算法收集内存)。

image.png

4.3 长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1.对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

image.png

4.4 空间分配担保

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

《深入理解 Java 虚拟机》第三章对于空间分配担保的描述如下:

JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。

JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。