Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。
每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样
只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
垃圾收集器所关注的正是这部分内存该如何管理,本文后续讨论中的“内存”分配与回收也仅仅特指这一部分内存。

如何判断对象已死?

计数算法

一句话,计数算法很不错,但是Java不用

可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

finalize(),死前最后的波纹

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

  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,
  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象:
    1. 没有覆盖finalize()方法
    2. 或者finalize()方法已经被虚拟机调用过了一次

那么虚拟机将这两种情况都视为“没有必要执行”,这时候这个对象就是必死无疑。

演示代码:

//finalize()方法
class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAlive(){
        System.out.println("耶,我还活着!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        FinalizeEscapeGC.SAVE_HOOK = this;
        System.out.println("逃过一劫!");
    }
}

public class JavaGcTest {
    public static void main(String[] args) throws InterruptedException, Exception {

        FinalizeEscapeGC.SAVE_HOOK = new FinalizeEscapeGC();

        //第一次拯救自己
        FinalizeEscapeGC.SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if(FinalizeEscapeGC.SAVE_HOOK != null){
            FinalizeEscapeGC.SAVE_HOOK.isAlive();
        }
        else{
            System.out.println("日,我还是死了!");
        }

        //第二次拯救自己
        FinalizeEscapeGC.SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if(FinalizeEscapeGC.SAVE_HOOK != null){
            FinalizeEscapeGC.SAVE_HOOK.isAlive();
        }
        else{
            System.out.println("啊,我还是死了!");
        }

    }
}

运行结果:

逃过一劫!
耶,我还活着!
啊,我还是死了!

验证了如果对象第一次要被gc杀死的时候,如果他有重写finalize()方法,而且重写之后让他能产生与其他对象的引用,那么此时的finalize()就是他的免死金牌,但是第二次gc再来他还是会死就是了。

回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

分代收集理论

人们在设计垃圾收集器(GC)的时候,提出了一个原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
因此JVM设计者往往吧Java堆划分为新生代(Young Generation)和老年代(Old Gerneration)两个主要的区域:

这么设计的初衷,《深入理解JVM》是这么解释的:

一句话总结就是:大体上将Java堆分为存储容易“杀死”的对象不容易“杀死”的对象的两块区域,对前者我们可以高效的“杀死”,而后者因为不容易“杀死”,所以就少浪费时间,低频地“杀”。

什么叫不容易“杀死”?就是说一个对象在gc多次开“杀戒”的时候都因为这个那个原因没被清理掉,所以gc采取的策略就是算了,能不杀就不浪费时间杀。

这时候就要考虑一个问题:一个对象A他虽然可能在新生代区,但是却有可能被老生代的对象B所引用,那如果要杀对象A,GC就要先去对B进行可达性分析,看看他是不是“孤立”的,这一切就使得对象A成了一个事实上的不容易“杀死”的对象。关于这个情况,《深入理解JVM》是这么解释的:

也就是jvm在新生代上维护一个记忆集,对这种有“免死金牌”的新生代对象背后的老生代对象标记起来,每次要“杀”他们的时候就可以直接去(而不用大范围扫描)把他们背后的老生代对象找出来,虽然有了一些开销,但整体上是划算的。

标记-清除算法


如它的名字一样,算法分为“标记”和“清除”两个阶段:

  • 首先标记出所有需要回收的对象
  • 在标记完成后,统一回收掉所有被标记的对象,

也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

标记-复制算法


将内存按容量分为大小相等的两块,每次只使用其中一块(也就是只在其中一块)分配内存,比如当前在使用的内存称为A,保留的空闲内存称为B,那么当A用完了,我们就把存活对象(不会被GC的)对象复制到B,然后把不要的回收了,让A称为新的空闲内存,循环往复。

为什么要这么做?

  1. 虽然有内存空间复制的开销,但如果多数都是可回收的内存,那么只需复制占少数的存活对象就行了
  2. 分配内存的时候比较方便,因为存活对象最后都会规整的储存在内存中,此时只要移动堆顶指针,就可以按顺序分配即可。

缺点是什么?

内存空间一下子没了一半,事实上太浪费了

标记-整理算法


非常类似于标记-清除算法,可以理解为是进行了标记-清除算法之后,又进行了“紧凑”,使得内存规整。

缺点是什么?

这种对象移动的操作是需要阻塞程序运行的(Stop the World),这就更加让使用者不得不小心翼翼地权衡其弊端了

那为什么这么做?

因为事实上,内存访问是非常频繁的,一个规整的内存会更收操作系统的“欢迎”,而如果不进行“紧凑”,虽然GC效率提高了,但之后的内存访问吞吐量就会变低,因此权衡利弊之下就这么做了。

  • 最新的ZGC和Shenandoah收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行

垃圾收集器


图中展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。

Serial 收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。

对这个特点,《深入理解JVM》有段有趣的描述:

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


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

ParNew 收集器

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

  • 新生代采用标记-复制算法(Stop the world)
  • 老年代采用标记-整理算法。(Stop the world)


它是许多运行在 服务端模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器……Parallel Scavenge的诸多特性从表面上看和ParNew非常相似,那它有什么特别之处呢?

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)

需要注意的是,即使Parellel Scavenge收集器可以通过参数-XX:MaxGCPauseMillis人为设置停顿时间长度,但是不以为着设置越小吞吐量越大,因为他的底层是缩小新生代空间为代价的,新生代空间越小,会使得需要回收空间的次数变多,也就是收集的频率变高,经常要出现stop the world,实质上会导致吞吐量下降。

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


注意:这个收集器是jdk8默认的版本,可通过命令查看:

java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=510248320 -XX:MaxHeapSize=8163973120 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_202"
Java(TM) SE Runtime Environment (build 1.8.0_202-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)

其中的-XX:+UseParallelGC就指明了收集器。

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些。

流程

整个过程分为四个步骤,包括:

  1. 初始标记(CMS initial mark)
    :需要Stop the world,仅仅只是记录下直接与GC Roots 相连的对象,速度很快 ;
  2. 并发标记(CMS concurrent mark)
    :从GC Roots直接关联对象中开始遍历整个对象图,过程耗时,但是不需要stop the world,可以与GC线程并发运行;
  3. 重新标记(CMS remark)
    :修正并发标记时期之间,用户线程继续运行而导致标记发生变化的记录(可以结合Serial收集器的例子,妈妈打扫卫生的时候,本来你说不要的东西,你又突然说要了,那GC就会把一开始的标记给去掉),停顿时间的长度介于初始标记与并发标记之间。
  4. 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

优点

  • 并发收集
  • 低停顿

缺点

  • 对处理器资源敏感,CMS默认的启动GC线程数是(处理器核心数量+3)/4,我们定性的做个计算,假设处理器核心数量为x,处理器运算资源占用率计算按 线程数/处理器核心数量 来看:
    ,所以定性的看,x越大,占用率越小,和我们的直观感受是匹配的;
  • CMS收集器无法处理“浮动垃圾”(Floating Garbage)。关于这一点,书中有一段可以说是非常深刻的描述:
  • CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况,JVM将收集整个Java堆和方法区的垃圾收集,时间开销就很大了。

G1(Garbage First) 收集器

可以看出来G1被赋予很高的期望,为什么这么说,因为从G1开始,GC的设计导向不再是单纯追求一次性把Java堆清理干净,以追求更少的Stop the world,而是追求能够应付应用的内存分配速率(Allocation Rate),也就是GC的速度能跟上对象分配的速度。

特性

  1. 内存空间划分思想上进行了转变:垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
    分代不再是最重要的,而是回收的价值:
    价值即回收所获得的空间大小以及回收所需时间,也就是要看划不划得来
  1. 使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。这是回答:“将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?”的答案。
  1. 通过原始快照(SATB)算法来实现并发标记。
  2. G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。
  3. 可预测停顿时间,甚至可让用户自己定义。

流程

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

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。****类似CMS的重新标记。
  • 最终标记(Final Marking)(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收(Live Data Counting and Evacuation):
    • 对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划
    • 可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。
    • 这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

*注意:这里除了并发标记,其余阶段都是要Stop the world的,体现了并非纯粹追求低停顿(但是用户可自定义),而追求尽可能高的吞吐量的设计思想。

优点

  1. 可指定最大停顿时间
  2. 分Region的内存布局
  3. 按收益动态确定回收集
  4. G1从整体来看是基于“标记-整理”算法(进行“紧凑”)实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。

Shenandoah 收集器

流程

  1. 初始标记 (Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
  2. 并发标记 (Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
  3. 最终标记 (Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
  4. 并发清理 (Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
  5. 并发回收 (Concurrent Evacuation):Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。并发回收阶段运行的时间长短取决于回收集的大小。
  1. 初始引用更新 (Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。初始引用更新时间很短,会产生一个非常短暂的停顿。
  1. 并发引用更新 (Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
  2. 最终引用更新 (Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
  3. 并发清理 (Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

  • 蓝色区域代表用户线程可以用来分配对象的内存Region
  • 黄色区域代表初始标记后会出现被选入回忆集对象的Region
  • 绿色区域代表存活的对象

因此根据这幅图:

  • 在初始标记(Init Mark)、并发标记(Concurrent Mark)与最后标记(Final Mark)之后,将决定最终被选入回忆集对象的Region

  • 在进行并发清除(Concurrent evacuation)之后,黄色区域里非回忆集对象将被复制到一个未被使用的Region中

  • 初始化引用更新(Init Update Reference)和并发引用更新(concurrent update reference)做了这么一件事:并发回收阶段复制对象结束后,堆中所有指向**旧对象的引用修正到复制后的新地址

    ,也就是说此时原本黄色区域内的绿色都已经到了橙色处,此时可以把原本的都标黄,他们也可以进回忆集了**

  • 最终引用更新还需修改GC Roots
  • 最后经过并发清理,将回忆集中的Region(即黄色区域)清理即可。

ZGC收集器

特性:

  1. ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小
  1. ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer,其他类似的技术中可能将它称为Tag Pointer或者Version Pointer),看起来有点像InnoDB用于MVCC的隐藏字段的回滚指针。它直接把标记信息记在引用对象的指针上,这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。

流程

  1. 并发标记 (Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
  2. 并发预备重分配 (Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。
  3. 并发重分配 (Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
  1. 并发重映射 (Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的。

参考

  1. 《深入理解Java虚拟机》
  2. JavaGuide
03-12 23:50