前言
本文是对《深入理解Java虚拟机》的第三章垃圾收集器与内存分配策略的一个学习总结,本文的思维导图如下。
对象存活判定算法
判断对象是否存储的算法有两种,分别是
引用计数法
和可达性分析算法
。
引用计数法
引用计数法
指的是在对象中添加一个计数器
,当对象被引用一次,计数器加一,释放引用,计数器减一,任何时刻只要计数器为零的对象就是不可能再被使用的对象。虽然引用计数法简单高效,但是没办法解决对象之间相互循环引用
的问题。
public class ReferenceCountingGC{
public Object instance = null;
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}
上面代码中,对象objA和对象objB之间存在互相引用,当将对象objA和对象objB引用都置为空,然后执行垃圾回收,发现两个对象都没有回收掉,这就是对象循环引用问题。
可达性分析算法
可达性分析算法指
的是对象如果到GC Roots(根对象)
之间不存在任务引用链的话,那么该对象是不可能再被使用的。
如上图object5、object6、object7虽然互有关联,但是它们到GC Roots
是不可达的,所以它们会被判定为可回收的对象。
可以作为GC Roots的对象
虚拟机栈(栈帧中局部变量表)
中引用的对象。- 在方法区中
类静态属性
引用的对象。 - 在方法区中
常量
的对象。 - 在本地方法栈中
JNI
引用的对象。
Java中的引用
无论是引用计数法还是可达性分析法,判断对象是否存活都和对象引用有关,在JDK1.2之后,Java的引用被分为
强引用
、软引用
、弱引用
、虚引用
四种,引用的强度依次减弱。
强引用
:指的是类似"Object o = new Object()“
这种引用关系,无论什么情况下,只要强引用还存在,垃圾收集器就不会回收被引用的对象。软引用
:指的是有用但不是必须的对象。只要被软引用关联的对象,在系统将要发送内存溢出之前
,会被列进垃圾收集范围之中进行第二次回收。弱引用
:指的也是有用但不是必须的对象,强度比软引用要弱,当垃圾收集器开始工作,不管内存是否溢出,都会回收被弱引用关联的对象
。虚引用
:虚引用其实就是为了能够在这个垃圾收集器回收时收到一个系统通知
。
垃圾收集算法
标记-清除算法
标记-清除算法分为
标记
和清除
两个阶段,使用可达性分析算法标记出需要回收的对象,然后统一回收掉所有被标记的对象。如下图所示。
缺点
执行效率不稳定
:如果Java堆中存在大量待回收对象,就必须进行大量标记和清除的动作,使得执行效率不稳定。空间碎片问题
:经过标记,清除后会出现大量不连续的内存空间,导致分配大对象的时候没有足够的空间而不得不提前触发垃圾收集动作。
标记-复制算法
标记-复制算法的标记阶段还是使用的可达性分析算法,标记处回收的对象,但是复制过程指的是,将内存分成两块,每次只使用其中一块,当其中一块的内存满了,就会将存活的对象全部复制到另一块内存上,然后把已使用过的内存空间一次清除掉。如下图所示。
缺点
由上图可以发现标记-复制算法的一个明显缺点就是要
牺牲一半的内存空间
。如果不想牺牲一半的内存空间就要使用以下的改良版的复制算法
,但是需要额外的分配担保
。
改良版复制算法
对于改良版复制算法来说,将Java堆内存分成
新生代
和老年代
两大块,然后新生代中又分成了一个Eden区
和两个Survivor区
,比例为8:1:1
。每次只使用Eden区和其中一块Survivor区,每次垃圾收集,将Eden区和Survivor区中存活的对象复制到另一块Survivor区中,然后直接清理掉Eden区和已经使用过的那块Survivor区。这是时候只会浪费10%的内存空间。如下图所示。
如果一个Survivor区无法容纳存活的对象,这个时候就需要借助其他内存空间(一般就是
老年代
),进行分配担保
。
标记-整理算法
标记-整理算法跟标记-清除算法不同之处在于,第二个阶段,对于标记-整理算法来说,标记完回收的对象之后,不是直接清理掉所有垃圾对象,而是将存活的对象向内存空间一侧移动,然后清除掉边界以外的内存。如下图所示。
缺点
其实标记-清除和标记-整理算法的区别就在于
是否移动对象
,如果对于老年代来说,会有大量存活的对象,这个时候就需要移动大量的对象,在这个过程中需要暂停用户应用程序
才能进行(STOP THE WORLD
),所以标记-整理算法会让垃圾收集停顿时间更长
。
分代算法
分代收集算法是指,对于新生代和老年代分别采用不同的垃圾收集算法来处理。
新生代
:新生代中的对象绝大多数都是朝生夕死
的,所以可以采用复制算法
,只需要复制少量的对象。老年代
:老年代中的对象是经过多次垃圾收集存活下来的,所以可以采用标记-清除
或者标记-整理
算法来回收。
垃圾收集器
上面展示了七种作用于不同分代的收集器,在春招的过程中,曾经被面试官问到我现在使用的JDK版本默认垃圾收集器是什么?怎么看的?
首先可以通过命令java -XX:+PrintCommandLineFlags -version
查看当前JDK使用的垃圾收集器,如下JDK1.8使用的Parallel Scavenge(新生代)+Parallel Old(老年代)
。
新生代垃圾收集器
由上图可以看到新生代垃圾收集器按并行还是串行来分,主要有串行的
Serial收集器
,还有并行的ParNew收集器
和Parallel Scavenge收集器
。相比于ParNew收集器,Parallel Scavenge收集器更注重吞吐量
。
Serial收集器
Serial收集器
一个单线程
的垃圾收集器,单线程意味着,正在回收垃圾的时候,必须停止其他工作线程,Stop The World
。运行示意图如下。
ParNew收集器
ParNew收集器实质上就是Serial收集器的
多线程并行版本
。除了支持多线程并行收集之外,其他特性与Serial收集器基本相同。在JDK1.7之前作为新生代首选的垃圾收集器,是因为只有ParNew可与CMS收集器配合工作
。
并行与并发概念
- 并行:同一时间可以有多个垃圾收集线程协同工作。
- 并发:同一时间垃圾收集线程与用户线程都在运行。
Parallel Scavenge收集器
Parallel Scanvenge收集器也是并行收集器,但是与ParNew收集器不同的是,关注点是
达到一个可控的吞吐量
,吞吐量指的就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
Parallel Scanvenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills参数
以及直接设置吞吐量大小的-XX:GCTimeRatio参数
。
老年代垃圾收集器
老年代垃圾收集器主要包括串行的
Serial Old收集器
,并行的Parallel Old收集器
和CMS收集器
。
Serial Old收集器
Serial Old收集器就是Serial收集器的老年代版本,采用的是标记-整理回收算法,状态图如下。
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
CMS收集器
CMS收集器
是一种以获取最短回收停顿时间
为目标的垃圾收集器,是基于标记-清除算法实现的,具体实现过程如下图所示。
如图所示CMS收集器分成四个步骤:
初始标记
:初始标记仅仅标记一下GC Roots
可以关联到的对象。这个过程比较快。并发标记
:并发标记指的是从GC Roots
关联的对象开始遍历整个对象图的过程,这个过程比较耗时但是不需要停顿用户线程,是并发执行的。重新标记
:重新标记为了修正在并发标记期间,因用户线程运作而导致标记产生变动的那一部分对象的标记。并发清除
:清理掉被标记已经死亡的对象,这个过程比较耗时,但是不需要停顿用户线程。
缺点
对处理器资源非常敏感
,因为在并发阶段,不会导致用户线程停顿,但是会占用用户线程的处理器资源,导致应用程序变慢。无法处理”浮动垃圾“
,在并发标记和并发清除阶段,用户线程还会产生新的垃圾,这些”浮动垃圾“只能等下一次的垃圾收集,在本次收集中无法处理。标记-清除算法的空间碎片问题
。
G1垃圾收集器
自JDK1.9之后,G1取代了Parallel Scavenge加Parallel Old收集器的组合,成为服务端模式下默认的垃圾收集器。G1垃圾收集器能够建立起一个”停顿时间模型“,指的是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不会超过N毫秒的目标。
G1垃圾收集器是如何实现”停顿时间模型“这个目标的呢?
G1垃圾收集器会把Java堆分成多个大小相等的独立区域(Region),每一个Region都可以扮演新生代的Eden区、Survivor区或者老年代空间,然后G1收集器可以对不同的Region采取不同的回收策略。更具体的思路是会让G1收集器去跟踪每一个Regin里面垃圾堆积的”价值“大小,价值指的是回收所获得的大小与回收所需要时间的经验值,然后会在后台维护一个优先级列表,每次根据用户设置的允许的收集停顿时间,优先处理回收价值最高的那些Region,这也是为啥叫"Garbage First"。
内存分配策略
对象优先在Eden区分配
大多数情况下,对象在新生代的Eden区中分配。
大对象直接进入老年代
虚拟机可以通过设置参数
-XX:PretenureSizeThreshold
,指定大于该设置值的对象直接进入老年代分配,避免在Eden区和两个Survivor区之间来回复制,产生大量的内存复制操作。
长期存活的对象将进入老年代
在学习Java对象内存布局的时候,在对象头中会有一个
Age(分代年龄)
,指的是对象每经过一次Minor GC
,如果存活下来,Age加一。然后虚拟机通过设置参数-XX:MaxTenuringThreshold
,指定对象的分代年龄超过这个阈值就直接进入老年代。
动态对象年龄判定
虚拟机中,对象的年龄要达到参数
-XX:MaxTenuringThreshold
所设置的值才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一般,年龄大于或者等于该年龄的对象都会直接进入老年代。
分配担保
在讲解标记-复制垃圾收集算法提到过分配担保,在进行一次Minor GC之前,虚拟机要确保老年代有足够的连续空间存放新生代存活对象的大小总和,如果条件成立,这次Minor GC是安全的。