Java虚拟机二:垃圾回收机制

时间:2022-12-28 10:22:34

上一篇说了Java运行时的内存区域及对象的创建,本文将说垃圾回收器及内存分配,上一篇文章链接 Java虚拟机一:Java运行时内存区域及对象的创建

[声明]Java虚拟机设计的知识点主要来源于周志明先生的深入理解Java虚拟机

本文将根据三个方面来描述垃圾回收机制:

一、如何确定哪些是要回收的对象
二、垃圾回收算法思想
三、垃圾收集器

一、如何确定哪些是要回收的对象

1.1。引用计数算法 :

引用计数算法就是给对象添加一个引用计数器,每当有一个地方引用该对象的时候就会+1,相反当失去一个引用的时候就-1,当引用数为0的时候也就说明这个对象不在被使用就可以被回收。这种算法实现简单,效率也很高,但是唯一的缺点就是在两个对象相互引用的时候那么他们的引用就不会为0,那么GC也就无法回收他们,

例子:

public class GcTest {
public Object instance = null;

public static void testGC() {

GcTest objA = new GcTest();//第一步,实例1

GcTest objB = new GcTest();//第二步,实例2

objA.instance = objB;//第三步

objB.instance = objA;//第四步

objA = null;//第五步

objB = null;//第六步

}
}

上面的例子中内存是不会被回收的,这个例子也被广泛的流传,但是又是否真的理解了为什么不能被回收呢?下面我们分析一下为什么:
1。第一步,GcTest 触发了new操作,在栈中创建objA的引用,在堆中创建了GcTest的实例,那么这个时候GcTest的实例1引用数 = 1,被objA引用;

2。第二步,GcTest 触发了new操作,在栈中创建objA的引用,在堆中创建了GcTest的实例,那么这个时候GcTest的实例2引用数 = 1,被objB引用;

3。第三步,GcTest实例2的引用 = 2,因为他的实例又被实例1引用了

4。第四部,GcTEst实例1的引用 = 2,因为他的实例又被实例2引用了

5。第五步,这个时候实例1的引用变为了 = 1,因为置null后,这时候实例1只是失去了栈中objA的引用

6。第六步,这个时候实例2的引用变为了 = 1,因为置null后,这时候实例2只是失去了栈中objB的引用

那么我们也就清楚了,这个时候虽然我们的实例1和实例2都不再使用,但是因为它们两个的循环互相引用,那么GC是没办法对它们回收的。这也就是引用计数算法最大的缺点

1.2。可达性分析算法 :

可达性分析算法是以一系列可以成为GC Roots的对象的节点向下搜索,搜索所走的路径称之为引用链,当一个对象没有任何一条引用链和GC Roots相连的时候,那么就认为这个对象是可以被回收的。

可以被称之为GC Roots的对象有:

虚拟机栈(栈帧中的本地变量表)中引用的对象;

方法区中类静态属性引用的对象;

方法区中常量引用的对象;

本地方法栈中JNI(即一般说的Native方法)引用的对象;

总结就是,方法运行时,方法中引用的对象;类的静态变量引用的对象;类中常量引用的对象;Native方法中引用的对象。

Java虚拟机二:垃圾回收机制

那么我们在返回去看上面给的例子中,最后是实例1和实例2互相引用,但是它们都不能和GC Roots相连,这个时候GC还是可以回收他们的,现在目前基本上所有的虚拟机都是使用的可达性分析算法。

1.3、Java中的引用类型

Java在1.2以后将引用分为了4种类型,即强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom
Reference)。
强引用一般就是我们平常写代码触发new的动作产生的对象。例如 Object obj = new Object(); obj即为强应用,只要强引用还引用着对象,GC就不会回收被引用的对象。

软引用(SoftReference) : 一般就是我们目前还有点用,但是也不是必须的对象,软引用所关联的对象在系统要发生内存溢出的时候,将会对这类对象进行二次回收,如果还是回收失败才会抛出oom;

弱引用(WeakReference) : 和软引用的意义类似,但是比软引用更弱,被软引用关联的对象,只能生存到下一次GC发生之前,当GC触发时无论内存是否足够都会回收掉该类对象。

虚引用(PhantomReference):任何时候都可以被GC回收,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否存在该对象的虚引用,来了解这个对象是否将要被回收。可以用来作为GC回收Object的标志。

二、垃圾回收算法思想 :

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

标记清除算法的思想就是先标记可以被清除的对象,然后统一回收被标记要清除的对象,标记过程中会对被标记的对象进行筛选,筛选就是判断是否有必要执行执行对象的finalize()方法,当对象没有重写finalize()方法或者finalize已经被虚拟机调用过的话,就被判定为不需要执行。
如果对象需要执行finalize方法的话那么这个对象将会加入到一个叫F-Queue的队列当中,然后会在一个优先级较低的Finalizer的线程中去执行finalize方法,但是虚拟机并不保证这个对象finalize的方法会完全执行完,因为可能在这个方法中你干了很耗时的操作的时候会导致整个F-Queue队列的其他对象都在等待或者导致系统崩溃。如果对象在finalize方法中将自己赋值给某个类的变量或者对象的成员变量,那么这个对象将逃脱这次的GC。对象的finalize方法只会被虚拟机调用一次,第二次再触发GC的时候不会在去调用finalize方法。
标记清除算法最大的缺点是在垃圾回收之后会产生大量的内存碎片,而如果内存碎片多了,当我们再创建一个占用内存比较大的对象时就没有足够的内存来分配,那么这个时候虚拟机就还要再次触发GC来清理内存后来给新的对象分配内存。

Java虚拟机二:垃圾回收机制

2.2 复制算法 ( Copying ):

复制算法会将内存空间平均分为大小相等的两块,每次只使用其中的一快,当触发GC操作的时候,会将存活的对象复制到另一个区域当中,然后将整块区域情况,这种算法最大的缺点是将原有内存分成了两块,每次只能使用区中一块,也就是损失了50%的内存空间,代价有点大。
Java虚拟机二:垃圾回收机制

HoSpot虚拟机将内存分为新生代和老年代,其中新生代中就采用了复制算法,但是并没有按照等分来分配内存区域,而是将新生代内存分为了一块较大的Eden空间和两块较小的From Survivor和To Survivor空间,每次使用Eden和其中一块Survivor,当触发GC的时候,将还存活的对象赋值到另一块Survivor中,然后清理掉Eden和之前使用的Survivor内存空间,HotSpot虚拟机默认的新生代容量中Eden和两块Survivor的比例是8:1:1,这种分配只浪费了总内存的10%,比单纯的节省了40%的空间。
如果当复制到Survivor时内存不够存放需要复制的对象时,那么就会将对象直接进入到老年代。

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

标记整理算法一般用在老年代中,因为在老年代如果也采用复制算法的话,第一会浪费一部分内存,第二是当存活对象较多的时候会进行大量的复制,这样会影响效率。所以提出了标记整理算法,标记过程还和标记清除一样,然后在清理的时候是先将存活的对象全部像一边移动,然后再清理掉边界以外的内存。
Java虚拟机二:垃圾回收机制

2.4 分代收集算法 :

目前大部分商业虚拟机都使用了分代收集算法,它的思想是根据对象的存活周期将内存分为了新生代和老年代,在每个年代中采用合适的收集算法,这样更能提升效率。例如新生代中的对象大多都是很快就会死去,只有少量的存活,那么就采用复制算法,这样可以付出少量复制的成本就可以完成收集,而且可以解决内存碎片的问题。而老年代一般对象的存活率较高,不能浪费内存空间,所有一般采用标记清除或者标记整理算法。

三、垃圾回收器 : 垃圾回收器就是对垃圾收集算法的具体实现。

3.1 部分概念 :

并发和并行 :这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下。**

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

Minor GC和Full GC :

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

吞吐量 : 就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%

“Stop The World”: 对象是否需要回收的分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因

[HotSpot虚拟机中的收集器: 如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
Java虚拟机二:垃圾回收机制

3.2 Serial回收器 :

Serial回收器是一个单线程的收集器,单线程的意思不全是因为它只会使用一个CPU或一条线程去工作,最主要是它在工作的时候,会暂停其他所有的工作线程,Clent模式下的默认收集器,因为没有线程交互的开销,更能专心的做垃圾收集工作,比其他收集器的单线程收集器效果高效。

Java虚拟机二:垃圾回收机制

3.3 ParNew回收器 :

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其他的基本都和Serial收集器一样,包括控制参数、收集算法、Stop The World、对象分配规则、回收策略等。
它是许多运行在Server模式下虚拟机的首先收集器,其中最重要的原因是因为在JDK1.5的时候,HotSpot推出了一款认为有划时代意义的垃圾收集器-CMS(Concurrent Mark Sweep),这个收集器做到了部分动作可以和用户线程同时工作,CMS作为老年代的收集器,但是无法与JDK1.4中存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1.5中当使用CMS作为老年代收集器的时候,新生代中只能从ParNew或者Serial中选一个。

Java虚拟机二:垃圾回收机制

3.4 Parallel Scavenge收集器 :

Parallel Scavenge是一个新生代的收集器,它也是使用了复制算法,而且是并行的多线程收集器,与ParNew类似。
Parllel Scavenge与其他收集器的关注点不同,CMS等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而Parllel Scavenge的关注点是吞吐量,停顿时间短适合与用户交互的程序,可以有良好的响应速度,而高吞吐量则可以高效率的利用CPU的时间,适合在后台运算而不需要和太多交互的任务。
Parllel Scavenge比ParNew最大的区别是多了GC自适应调节功能,可以通过设置-XX:+UseAdaptiveSizePolicy的值来开关GC自适应调节策略,如果这个开关打开后不需再设置新生代大小、Eden与Survivor的比例、晋升老年代对象的年龄等参数了,虚拟机会根据系统的运行情况动态的调整这些参数以达到最大的吞吐量。

3.5 Serial Old收集器 :

Serial Old收集器是Serial的老年代的版本,它同样是一个单线程的收集器,使用了标记整理算法,这个收集器主要也是给Clent模式下的虚拟机使用。如果在Server模式下 主要是为了在JDK1.5后与Paraller Scavenge收集器搭配使用,还有就是作为CMS收集器的后背预案,当发生Concurrent Mode Failure时使用。
Java虚拟机二:垃圾回收机制

3.6 Parallel Old收集器 :

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法。这个收集器是在JDK1.6后提供的,主要用于配合Parallel Scavenge收集器使用。因为在1.6之前如果新生代选择了Parallel Scavenge收集器的话,那么因为Parallel Scavenge无法与CMS配合使用的原因所以只能选择Serial Old来作为老年代的收集器,而Serial Old又是一个单线程的收集器,所以在多CPU的时候有点力不从心。

Java虚拟机二:垃圾回收机制

3.7 CMS收集器 :

CMS(Concurrent Mark Sweep)收集器产生的最终目的是为了在垃圾清理的时候可以停顿最短的时间。它是基于标记清除算法实现的,运作过程相对于前面几种更负责,分为4个步骤:
初始标记(CMS initial mark):初始标记只是标记一下GC Roots能直接关联的对象,暂停用户线程

并发标记(CMS concurrent mark):并发标记就是GC RootsTracing的过程,可以和用户线程同时执行。

重新标记(CMS remark):重新标记是为了修正并发标记期间因为用户线程继续运行而导致标记变化的对象,暂停用户线程。

并发清除:清理经过重新标记后的对象内存,可以和用户线程同时执行。

由于耗时最长的并发标记和并发清除过程都可以和用户线程一起执行,所有从整体来说CMS收集器是可以和用户线程一起并发执行的。

Java虚拟机二:垃圾回收机制

CMS收集器的3个缺点:
1。它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致程序变慢,总吞吐量降低,CMS默认的启动回收的线程数是CPU数量+3/4,也就是当CPU在4个以上的时候,并发回收垃圾时垃圾回收线程不少于25%的CPU资源,但是会CPU数量的增加而下降。但是如果当CPU数量不足4个时,CMS对用户程序的影响就会非常大,如果本来CPU的负载就比较大,还要分出一般的运算能力去执行收集器线程,这样可能导致用户的程序执行速度直接降低50%。

2。由于CMS在并发清除的阶段是和用户线程同时执行的,既然程序还在执行,那么就会产生新的垃圾,这时出现的垃圾CMS无法在当次收集中清除他们,这时候产生的垃圾就是浮动垃圾。CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次的Full GC的产生。
因为在垃圾清理阶段用户线程还在执行,那么就需要预留一部分内存空间来给用户线程使用,所以CMS收集器不能像其他收集器那样等到老年代快满了的时候再去收集,因为要保证一部分内存来给用户线程使用。在JDK1.5中的默认值是当老年代内存达到68%时就会被激活,在JDK1.6中这个值升到了92%。如果CMS运行期间预留的空间不能满足程序的运行时,就会出现一次Concurrent Mode Failure失败,这时虚拟机就会启动预备方案:临期启动Serial Old收集器来重新进行老年代的垃圾收集,这样停顿的时间就会变得很长。

3。还有就是CMS是基于标记清除算法实现的,那么清理后也就会产生内存碎片,碎片过多的时候,可能会出现老年代有很大的内存,但是没有足够内存分给某一个对象,这样的话就会再次触发一次Full GC。为了解决这个问题CMS提供了一个参数-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器要进行FullGC时开启内存碎片的整理过程,但是整理的过程是不能并发的,虽然解决了碎片问题,但是停顿的时候会变成。

3.8 G1收集器 : G1收集器是当今收集器技术最前沿的成功之一与其他。与其他收集器相比主要特点如下:

1、并行与并发:G1能充分的利用多CPU、多核的环境使用多个CPU来缩短停顿的时间,也就是说同样拥有和用户线程同时执行的功能。

2、分代收集:虽然G1可以不需要其他收集器的配合就能独立管理整个Java堆,但是还是采用了不同的方式去处理新建对象和存活了一短时间的对象,这样效果更佳

3、空间整理:与CMS的标记清理算法不同,G1从整体来看是基于标记整理算法实现的,从局部两个Region上来看是基于复制算法,但是不管哪种算法都不会产生内存碎片的问题。

4、可预测的停顿时间:这是G1比CMS的另一优势,降低停顿时间是CMS和G1的共同关注点,但是G1出了追求低停顿外,还可以预测停顿的时间,让使用者明确指定一个长度为毫秒的时间,消耗在垃圾收集的时间不超过这个时间。

G1收集器不再是完全的将堆划分新生代和老年代,取而代之的是将堆划分为多个大小的相等的独立区域(Region),虽然还保留了新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

G1可以预测停顿时间是因为它可以有计划的避免对整个Java堆进行全区域的垃圾收集,G1跟踪各个Region里面的垃圾价值情况,也就是回收后会获得的空间大小和回收所需要多少时间的经验,在后台维护一个优先列表,每次根据允许的时间去判断回收哪个区域后获得的价值更大,这样使用Region和优先级的方式回收,可以保证G1在有限的时间内获得最高的收集价值。

因为一个对象被分配到一个Region中,但是并非只能本Region中的其他对象才能引用,而是可以被整个Java堆中的任意对象所产生引用关系,那么为了避免进行全局的扫描,G1收集器在每个Region中都维护了一个Remembered Set(用来记录跨Region引用的数据结构,在分代中就是记录夸新生代和老年代)。如果虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,然后检查Reference引用的对象是否处于不同的Region之中(在分代中就是检查老年代和新生代的夸代引用),如果是就会通过CardTable(可以理解为是Remembered Set的一种实现)把相关引用的信息记录到被引用对象所属的Region的Rememered Set之中。当进行内存回收时,在GC跟节点的范围加入对Remembered Set中的对象分析,这样就不用为了查找引用而进行全堆的搜索了

如果不计算Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

初始标记(Initial Marking) : 初始标记阶段只是为了标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top Mark Start)的值,让下一个阶段用户程序并发运行时,能在正确的Region中创建对象。这个阶段需要停顿线程,但是耗时很短

并发标记(Concurrent Marking): 并发标记阶段是从GC Roots开始对堆中的对象进行可达性分析,找出存活的对象,这个阶段耗时较长,但是可以和用户线程并发执行。

最终标记(Final Marking) : 最终标记阶段是为了修正并发标记期间程序继续运行而导致标记产生变化的一部分对象的记录,虚拟机将这段时间对对象的变化记录在线程REmembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这个阶段需要停顿线程,可是可以并行执行。

筛选回收(Live Data Counting and Evacuation) : 筛选回收阶段首先要对各个Region的回收价值和成本进行排序,然后根据用户所期望的GC停顿时间来制定回收计划,从Sun透露的消息,这个阶段可以做到和用户线程并发执行,但是因为只是回收一部分Region,时间是用户可控的,而且停顿用户线程将大幅度提高手机的效率。

Java虚拟机二:垃圾回收机制