java垃圾回收机制


本文参考书籍**《深入理解JAVA虚拟机》**,对java中垃圾回收机制进行整理和分析。
我们从三个问题着手来探究java中垃圾收集。

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

1.判断对象是否回收

判断对象是否可以被回收的两个经典算法分别是引用计数算法(Reference Counting)和可达性分析算法。

1.1 引用计数算法

思想:给对象中添加一个引用计数器,每当有 一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0 的对象就是不可能再被使用的。
优点:实现简单,判定效率高,在大部分情况下它都是一个不错的算法。
缺点:很难解决对象之间相互循环引用的问题,例如,对象objA和objB都有字段 instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引 用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引 用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。因此主流的Java虚拟机里面并没有选用引用计数算法来管理内存。

1.2 可达性分析算法

思想:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所 走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连 (用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,也就是此对象可以回收。
在这里插入图片描述

优点:是主流程序语言(Java,c#等)所采用的算法。
Java中可作为GC Roots的对象包括下面几种
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)引用的对象。

2.垃圾回收算法

什么时候回收以及如何回收?

2.1 标记-清除算法(Mark-Sweep)

算法分 为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有 被标记的对象。
在这里插入图片描述

缺点
(1)效率问题,标记和清除两个过程的效率都不高;
(2)空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程 序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾 收集动作。

2.2 复制收集算法(Copying Collection)

它将可用内存按容 量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是 对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指 针,按顺序分配内存即可,实现简单,运行高效。
在这里插入图片描述

缺点
(1)在对象存活率较高时要进行较多的复制操作,效率将会变低;
(2)算法的代价是将内存缩小为了原来的一半,浪费了50%的空间,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中 所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

这里提到的内存的分配担保可以简单的理解为我们去银行借款,如果我们信誉很好,在98%的情况下都能按时 偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保 证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。

2.3 标记-整理算法(Mark-Compact)

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

2.4 分代收集算法(Generational Collection)

由于不同的对象的生命周期是不一样的,因此分代收集算法是根据对象存活周期的不同将内存划分为几块,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在这里插入图片描述

2.4.1 新生代(Young Generation)

  • 所有新生成的对象首先都是放在新生代的。新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。
  • 新生代内存一般按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。大多数情况下,对象在新生代Eden区中分配。回收时采用复制算法,先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空,如此往复。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,Minor GC是指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 当survivor1区不足以存放eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),Full GC是发生在老年代的垃圾收集动作,会将新生代、老年代都进行回收,因此在一次Full GC的过程中经常会伴随着至少一次的Minor GC。
  • 新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,因此选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

产生Full GC可能的原因有:
老年代被写满
永久代被写满
System.gc的调用
上一次GC后heap的各域分配策略的动态变化

2.4.2 老年代(Old Generation)

  • 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清除”或者“标记—整理”算法来进行回收。

2.4.3 永久代(Permanent Generation)

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,用于存放静态文件,如Java类、方法等。由于java堆是GC的主要区域,因此永久代对GC没有显著影响,但这并不表示永久代不需要GC,只是不是GC的主要对象。永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

  • 废弃常量
    回收废弃常量与回收 Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了 常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说,就是没有任何 String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内 存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。
  • 无用的类
    类需要同时满足下面3个条件才能算是“无用的类”:
    该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
    加载该类的ClassLoader已经被回收。
    该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

方法区和永久代的关系
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

JDK 1.8之后使用元空间(存放在内存里)替代永久代,为什么要这样做?

(1) 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
(2)元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

JDK1.7 及之后版本的 JVM 已经将字符串常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放字符串常量池。但是运行时常量池的其他内容还在方法区,也就是在元空间里面。
在这里插入图片描述

2.5 垃圾回收算法小结

算法总结
标记-清除先标记再清除,效率和内存碎片问题
复制整理分区复制回收,适应于对象存活率低的场景(新生代)
标记-整理先标记再清除再整理,适用于对象存活率高的场景(老年代)
分代算法根据对象存活周期分区,老年代使用标记-清除或者标记-整理算法,新生代使用复制算法

3.垃圾回收器

垃圾收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。
新生代使用的收集器:Serial、PraNew、Parallel Scavenge
老年代使用的收集器:Serial Old、Parallel Old、CMS
在这里插入图片描述

3.1 垃圾回收器说明

垃圾收集器算法说明
Serial复制算法新生代单线程收集器,标记和清理都是单线程,优点是简单高效。
Serial Old标记-整理算法老年代单线程收集器,Serial收集器的老年代版本。
ParNew停止-复制算法新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
Parallel Scavenge停止-复制算法并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。
Parallel Old停止-复制算法Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先
CMS(Concurrent Mark Sweep)标记-清理算法高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择。(下面具体介绍)
G1(Garbage First)标记-整理算法G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。(下面具体介绍)

3.2 CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清除算法实现的,是一种老年代收集器,通常与ParNew一起使用。

先介绍几个概念:

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

Stop-the-world意味着 JVM由于要执行GC而停止了应用程序的执行,并且这种情形会在任何一种GC算法中发生。当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态直到GC任务完成。事实上,GC优化很多时候就是指减少Stop-the-world发生的时间,从而使系统具有 高吞吐 、低停顿 的特点。

3.2.1 CMS的垃圾收集过程

  • 初始标记:需要“Stop the World”,初始标记仅仅只是标记一下GC Root能直接关联到的对象,速度很快。
  • 并发标记:是主要标记过程,这个标记过程是和用户线程并发执行的。
  • 重新标记:需要“Stop the World”,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(停顿时间比初始标记长,但比并发标记短得多)。
  • 并发清除:和用户线程并发执行的,基于标记结果来清理对象。

如果在重新标记之前刚好发生了一次MinorGC,会不会导致重新标记阶段Stop the World时间太长?
答:不会的,在并发标记阶段其实还包括了一次并发的预清理阶段,虚拟机会主动等待年轻代发生垃圾回收,这样可以将重新标记对象引用关系的步骤放在并发标记阶段,有效降低重新标记阶段Stop The World的时间。

3.2.2 CMS的优缺点

优点:CMS以降低垃圾回收的停顿时间为目的,很显然其具有并发收集,停顿时间低的优点。
缺点
(1)对CPU资源非常敏感,因为并发标记和并发清理阶段和用户线程一起运行,当CPU数变小时,性能容易出现问题。
(2)收集过程中会产生浮动垃圾,所以不可以在老年代内存不够用了才进行垃圾回收,必须提前进行垃圾收集。通过参数-XX:CMSInitiatingOccupancyFraction的值来控制内存使用百分比。如果该值设置的太高,那么在CMS运行期间预留的内存可能无法满足程序所需,会出现Concurrent Mode Failure失败,之后会临时使用Serial Old收集器做为老年代收集器,会产生更长时间的停顿。
(3)标记-清除方式会产生内存碎片,可以使用参数-XX:UseCMSCompactAtFullCollection来控制是否开启内存整理(无法并发,默认是开启的)。参数-XX:CMSFullGCsBeforeCompaction用于设置执行多少次不压缩的Full GC后进行一次带压缩的内存碎片整理(默认值是0)。

浮动垃圾
由于在应用运行的同时进行垃圾回收,所以有些垃圾可能在垃圾回收进行完成时产生,这样就造成了“Floating Garbage”,这些垃圾需要在下次垃圾回收周期时才能回收掉。所以,并发收集器一般需要20%的预留空间用于这些浮动垃圾。

3.3 G1收集器

G1收集器是Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1是一款面向服务端应用的垃圾收集器。

3.3.1 G1的垃圾收集过程

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行计算,根据用户期望的GC停顿时间来制定回收计划。

3.3.2 G1收集器特点

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者 CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的 GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集:G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一 次GC。

使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

  • 可预测的停顿:能够建立可以预测的停顿时间模型,预测停顿时间。

4.面试相关

4.1 为什么需要Survivor空间?

我们看看如果没有 Survivor 空间的话,垃圾收集将会怎样进行:一遍新生代 gc 过后,不管三七二十一,活着的对象全部进入老年代,即便它在接下来的几次 gc 过程中极有可能被回收掉。这样的话老年代很快被填满, Full GC 的频率大大增加。我们知道,老年代一般都会被规划成比新生代大很多,对它进行垃圾收集会消耗比较长的时间;如果收集的频率又很快的话,那就更糟糕了。基于这种考虑,虚拟机引进了“幸存区”的概念:如果对象在某次新生代 gc 之后任然存活,让它暂时进入幸存区;以后每熬过一次 gc ,让对象的年龄+1,直到其年龄达到某个设定的值(比如15岁), JVM 认为它很有可能是个“老不死的”对象,再呆在幸存区没有必要(而且老是在两个幸存区之间反复地复制也需要消耗资源),才会把它转移到老年代。
Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

4.2 为什么有两个Survivor区?

为什么 Survivor 分区不能是 1 个?
如果 Survivor 分区是 1 个的话,假设我们把两个区域分为 1:1,那么任何时候都有一半的内存空间是闲置的,显然空间利用率太低不是最佳的方案。
但如果设置内存空间的比例是 8:2 ,只是看起来似乎“很好”,假设新生代的内存为 100 MB( Survivor 大小为 20 MB ),现在有 70 MB 对象进行垃圾回收之后,剩余活跃的对象为 15 MB 进入 Survivor 区,这个时候新生代可用的内存空间只剩了 5 MB,这样很快又要进行垃圾回收操作,显然这种垃圾回收器最大的问题就在于,需要频繁进行垃圾回收。

为什么 Survivor 分区是 2 个?
刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。
整个过程中,永远有一个survivor space是空的,另一个非空的survivor space无碎片。

Survivor为什么不分更多块呢?比方说分成三个、四个、五个?
如果Survivor区再细分下去,每一块的空间就会比较小,很容易导致Survivor区满。根据上面的分析可以得知,当新生代的 Survivor 分区为 2 个的时候,不论是空间利用率还是程序运行的效率都是最优的。

4.3 JVM如何判定一个对象是否应该被回收?

参见上文中判断对象是否回收的算法。

4.4 JVM垃圾回收算法有哪些?

参见上文中垃圾回收算法。

4.5 Java常用版本

通过java -XX:+PrintCommandLineFlags -version 查看所用的垃圾回收器。
在这里插入图片描述

jdk1.8默认的新生代垃圾收集器:Parallel Scavenge,老年代:Parallel Old
jdk1.9默认垃圾收集器:G1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值