JVM(三)_执行引擎

不定期补充、修正、更新;欢迎大家讨论和指正

本文主要根据尚硅谷的视频学习,建议移步观看,其他参考资料会在使用时贴出链接
尚硅谷宋红康JVM全套教程(详解java虚拟机)
由于JVM的知识是互相穿插的,比如学习字节码会接触到运行时数据区的知识,学习堆区又会接触到GC的知识,所以建议先看视频对JVM有个完整的概念,本文只是作为学习笔记来复习,不适合入门学习。

JVM官方文档
The Java® Virtual Machine SpecificationJava SE 8 Edition

JVM(一)_类加载系统和字节码
JVM(二)_运行时数据区
JVM(三)_执行引擎
JVM(四)_性能监控与调优

前言

虚拟机(Virtual Machine)指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。在实体计算机中能够完成的工作在虚拟机中都能够实现。在计算机中创建虚拟机时,需要将实体机的部分硬盘和内存容量作为虚拟机的硬盘和内存容量。每个虚拟机都有独立的CMOS、硬盘和操作系统,可以像使用实体机一样对虚拟机进行操作。

广义上来看,我们可以将具屏蔽底层细节,专注本层功能的都视为虚拟机,比如计算机组成原理的多级层次结构的计算机结构,我们可以把M4(高级语言机器)视为具有高级语言编译功能的机器,但M4并不是实际的机器,只是人们感到存在的一台高级语言功能的机器。同理,Word、Excel也可以视为处理文字、表格等的虚拟机。
在这里插入图片描述
狭义上,虚拟机可以分为系统虚拟机、程序虚拟机。

系统虚拟机是指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统,是对物理计算机的仿真,比如VMware、Visual Box等,这样就可以在Windows上运行Linux等系统,虽然看上去我们操作另一个系统,实际上是在操作软件。

程序虚拟机是专门为了某个计算机应用而设计的,比如要学的JVM。

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
在这里插入图片描述
在这里插入图片描述

如果问世界上最好的语言是什么,各语言的程序员肯定吵得不可开交,但是最好的虚拟机,毫无疑问是JVM。
JVM从Java7开始就不只是服务Java语言,其他语言只要符合JSR-292规范,由编译器编译成JVM规范的字节码文件,就都能在JVM上运行。
因此Java平台多语言混合编程正成为主流,各个领域使用不同的语言,比如一个项目中,并行处理用Clojure编写,展示层用JRuby/Ralis,中间层用Java,各语言的交互不成问题,最终都在JVM上执行。

在这里插入图片描述

主要虚拟机

(我也不太了解,都是照抄视频中的,以后有机会学习再详细讲讲)

  1. Sun Classic VM
    1996年在java1.0由sun公司发布,是世界上第一款商用的java虚拟机。
    该虚拟机内部只提供了解释器,性能差(这也是造成Java运行效率比C/C++差固有印象的原因,现在的虚拟机一般是解释器和即时编译器(JIT)搭配执行),如果需要JIT,就需要外挂,但是解释器和即时编译器不能同时工作。该虚拟机在JDK1.4时候时被淘汰。

  2. Exact VM
    为了解决上一个虚拟机解释器和JIT不能同时工作的问题,JDK1.2时,sun提供了此虚拟机
    该虚拟机主要提供了准确式内存管理功能(Exact Memory Management :虚拟机知道内存中某个位置的数据是什么类型)、热点探测、编译器与解释器混合工作模式,是现代高性能虚拟机的雏形。
    但只在Solaris平台短暂使用,因为很快就被后来的HotSpot虚拟机取代。

  3. Hotspot VM
    最初由一家小公司Longview Technologizes设计,1997年被sun收购,2009年,sun公司被甲骨文oracle收购,JDK1.3时候,HotSpot成为默认虚拟机。是目前三大主流商用虚拟机之一,占绝对的主导地位,也是在此学习的虚拟机。
    HotSpot的名字就是他的热点代码探测技术,通过计数器找到最具编译价值代码,触发即时编译或栈上替换通过编译器与解释器协同工作,在优化响应时间和最佳执行性能中取得平衡。

  4. JRockit
    三大商用虚拟机之一,由BEA公司开发,专注服务器端应用,因为不太关注程序启动速度,所以JRockit内部不包括解析器实现,全部代码靠即时编译器编译后执行,因此也是目前世界上最快的JVM(因为都是编译器工作),在2008年BEA被Oracle收购(世界五百强不是吹的,Java就不说了,旗下的Oracle,Mysql数据库都是它家的)。

  5. IBM J9
    三大商用虚拟机之一,IBM Technology for java Virtual Machine 简称IT4J,内部代号J9,市场定位与HotSpot接近,服务器端、桌面应用,嵌入式等多用途,广泛应用于IBM的各种Java产品。号称速度最快,因为在自家平台测试,和IOS一样,与自家产品高度契合,当然效率也高。2017年开源,命名位OpenJ9,交给Eclipse基金会管理。

  6. Graal Vm
    2018年4月,Oracle Labs新公开了一项黑科技:Graal VM,从它的口号“Run Programs Faster Anywhere”就能感觉到一颗蓬勃的野心,这句话显然是与1995年Java刚诞生时的“Write Once,Run Anywhere”在遥相呼应。
    Graal VM被官方称为“Universal VM”和“Polyglot VM”,这是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,这里“任何语言”包括了Java、Scala、Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。
    Graal Vm野心极大,有一统所有虚拟机的目标,如果HotSpot被取代,最有可能的就是这款虚拟机,让我们拭目以待。

除了以上虚拟机,还有KVM、CDC、Azul VM、Liquid VM、Apache Harmony、Microsoft VM、TaobaoVM、Dalvik VM等

HotSpot

提起HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。
但不一定所有人都知道的是,这个目前看起来“血统纯正”的虚拟机在最初并非由Sun公司开发,而是由一家名为“Longview Technologies”的小公司设计的;
甚至这个虚拟机最初并非是为Java语言而开发的,它来源于Strongtalk VM
而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机,
Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果,在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。

HotSpot VM既继承了Sun之前两款商用虚拟机的优点(如前面提到的准确式内存管理),也有许多自己新的技术优势,
如它名称中的HotSpot指的就是它的热点代码探测技术(其实两个VM基本上是同时期的独立产品,HotSpot还稍早一些,HotSpot一开始就是准确式GC,
而Exact VM之中也有与HotSpot几乎一样的热点探测。
为了Exact VM和HotSpot VM哪个成为Sun主要支持的VM产品,在Sun公司内部还有过争论,HotSpot打败Exact并不能算技术上的胜利),
HotSpot VM的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。
如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。
通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,
即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。

在2006年的JavaOne大会上,Sun公司宣布最终会把Java开源,并在随后的一年,陆续将JDK的各个部分(其中当然也包括了HotSpot VM)在GPL协议下公开了源码,
并在此基础上建立了OpenJDK。这样,HotSpot VM便成为了Sun JDK和OpenJDK两个实现极度接近的JDK项目的共同虚拟机。

在2008年和2009年,Oracle公司分别收购了BEA公司和Sun公司,这样Oracle就同时拥有了两款优秀的Java虚拟机:JRockit VM和HotSpot VM。
Oracle公司宣布在不久的将来(大约应在发布JDK 8的时候)会完成这两款虚拟机的整合工作,使之优势互补。
整合的方式大致上是在HotSpot的基础上,移植JRockit的优秀特性,譬如使用JRockit的垃圾回收器与MissionControl服务,
使用HotSpot的JIT编译器与混合的运行时系统。–摘自《深入理解Java虚拟机:JVM高级特性与最佳实践》

整体结构

以下为HotSpot VM的大致结构,源代码编译成字节码文件(Class files,因此前面应还有一个编译过程),字节码文件通过类加载系统加载到JVM中,JVM所管理的内存为运行时数据区,执行引擎从运行时数据区获取数据,再通过执行引擎对这些数据进行处理,最终运行在操作系统上。
在这里插入图片描述
根据以上结构,对JVM分为四部分学习:

  1. 字节码和类加载系统
  2. 运行时数据区和本地方法接口
  3. 执行引擎
  4. 性能监控与调优

以下是更为详细的结构图

  • 类加载子系统(Class Loader SubSystem):由加载(Loading)->链接(Linking)->初始化(Initialization)三部分完成
  • 运行时数据区(Runtime Data Areas):包含方法区(Method Area)、堆区(Heap Area)、栈区(Stack Area)、PC寄存器(PC Registers)、本地方法栈(Native Method Stack)
  • 执行引擎(Execution Engine):解释器(Interpreter)、及时编译器(JIT Compiler)、分析器(Profiler)、垃圾回收器(Garbage Collection)

在这里插入图片描述

执行引擎

执行引擎主要由三部分组成

  • 解释器(Interpreter)
  • 即时编译器(Just-In-Time Compiler)
  • 垃圾收集器(Garbage Collection)

在这里插入图片描述

即时编译器

对于即时编译器(JIT编译器,Just In Time Compiler)和解释器,深入了解至少需要编译原理的知识,所以这里只简单学习
由于将源码为字节码文件的过程也称作编译过程,为了区别该过程和执行引擎的编译过程,可以将前者称为前端编译器或静态提前编译器(AOT编译器,Ahead Of Time Compiler),后者称为后端编译器或即时编译器

相信大家都听说过编译型语言(比如C/C++)和解释型语言(Java、Python),大概区别就是编译型语言将源代码编译成汇编或机器指令运行;解释型语言一边解释一遍执行,所以效率比编译型差。在第一款商用虚拟机Classic VM中,JVM只支持解释器,使用JIT编译器需要外挂,而且两者不能同时工作,这也是Java运行效率不如C/C++固有印象的原因之一。

而目前JVM能够解释器和JIT编译器同时工作(所以现在说Java是解释型语言不太严谨,而是两者都有),那既然编译器效率高,为什么不全部采用编译器呢,因为编译器先需要完全把源码编译好才能执行,而解释器可以直接执行,视频举的例子就很好:

去某个地方,可以步行和坐公交车,坐公交车固然速度快,但是不能保证直接坐到公交车,可能在站台等待的时间步行就到了,因此合理的方案应该是由有公交车就上,没有就走路。解释器就相当于走路,源代码来了可以直接解析,响应快;编译器就相当于坐公交,等完全编译了才能执行,但是一旦执行效率就比解释器高。

因此JIT编译器一般负责编译热点代码,比如for循环中重复的代码。HotSpot VM的名称也是源于热点探测技术

那如何识别哪些是热点代码呢,首先需要明确一个阈值,确定一个方法被调用多少次,或循环体执行多少次后,JIT编译器才会将这些代码视为热点代码,这需要用到热点探测技术

HotSpot采用的热点探测技术是基于计数器的热点探测:HotSpot会为每个方法都建立两个不同类型的计数器:

  • 方法调用计数器(Invocation Counter):统计方法调用次数
  • 回边计数器(Edge Back Counter):统计循环体执行的循环次数

在HotSpot中内嵌了两个JIT编译器,分别为Client Compiler和Server Compiler,一般简称为C1和C2编译器

  • C1:使用-client选项,C1编译器会对字节码进行简单可靠的优化,耗时短,达到更快的编译速度
  • C2:使用-server选项,C2编译器会进行耗时较长的优化,以及激进优化,优化的代码执行效率更高

解释器

默认情况下HotSpot采用的是JIT编译器和解释器并存的架构,开发者可以通过以下虚拟机选项显式地声明使用哪种模式工作。

  • -Xint
    完全采用解释器模式执行程序
  • Xcomp
    完全采用即时编译器模式执行程序(如果即时编译出现问题,解释器会介入执行)
  • Xmixed
    采用解释器+即时编译器的混合模式共同执行程序。

在命令行中查看Java的版本,后面就显示了目前采用何种工作模式
在这里插入图片描述
加上上述参数后工作模式也跟着切换
在这里插入图片描述
在这里插入图片描述
上面提到JIT编译器会方法调用计数器来探测热点代码,用来统计方法被调用的次数,在超过一定阈值后就会触发JIT编译器(Client模式默认1500次,Server模式默认10000次,该阈值可以通过-XX:CompileThreshold来设置),将该方法视为热点代码进行编译。

当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本

  • 如果存在,则优先使用编译后的本地代码来执行
  • 如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。
  • 如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
  • 如果未超过阈值,则使用解释器对字节码文件解释执行

我们可以通过下面代码来测试各工作模式的效率
-Xmixed 混合模式,即默认模式
在这里插入图片描述
在这里插入图片描述
-XInt 解释器模式下
在这里插入图片描述
-Xcomp 编译器模式下,可以看到纯编译器模式比混合模式还要慢些,原因大家都知道了
在这里插入图片描述
以上代码的循环次数是十万次,当次数提升至两百万次时,纯编译器效率就比混合模式高了(-Xcomp:9275ms -Xmixed:9497ms)。
很少有代码能循环这么多次,所以开发中默认模式就很好了。
在这里插入图片描述

在这里插入图片描述

垃圾收集

垃圾收集机制(Garbage Collection,GC)是一种对内存中无用的数据进行收集的机制(网上大多都是叫垃圾回收,个人觉得不太好,回收有废物利用的含义,而GC好像并没有把这些垃圾重新利用的效果,内存回收才合理些,况且collection是收集,recovery才是回收,本文将一直采用垃圾收集,原谅我比较杠)。如果不进行垃圾收集,内存会在不断地分配内存空间中被消耗完;除了清除没用的数据,GC也可以整理内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象;随着业务越来越庞大复杂,现在没有GC机制应用程序很难正常运行,所以GC是十分重要的机制。

GC作为Java的招牌之一,极大地提高了开发效率,但该机制不是Java的伴生物,早在1960年,Lisp语言作为第一门开始使用内存动态分配和垃圾收集技术诞生了,如今垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战。
同时C#、Python、Ruby等语言都使用了自动垃圾收集的思想,也是未来发展趋势,可以说这种自动化的内存分配和垃圾收集方式已经成为了现代开发语言必备的标准,与之相对应的是C/C++,这两者并没有采用垃圾收集机制,内存的释放需要开发者自己手动释放。

既然C/C++没有GC机制也能很好的完成各种业务,为什么Java语言要采用GC机制呢?
C语言用malloc()开辟需要的内存,调用free()释放内存,虽然开发者的权限和灵活度很高,但倘若有一处内存区间由于开发者编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃,这就要求开发者的水平和对语言的能力较高(学过C的应该都被指针弄得头疼,各种悬垂指针,野指针)。而有了GC机制,对开发者是一种福音,大大提高了开发效率,降低了门槛,专注于业务开发(语言适用领域使然,C/C++面对的大多是底层,Java互联网比较多)。

相关算法

那什么算是垃圾呢?简单来说垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被收集的垃圾。

An object is considered garbage when it can no longer be reached from any pointer in the running program

这样的定义肯定不够,所以JVM提供了相关算法来确定堆和方法区中哪些是垃圾以及收集垃圾的算法,即标记阶段和清除阶段:

  • 标记阶段:在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾收集时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。分为
    1. 引用计数算法
    2. 可达性分析算法
  • 清除阶段:将被标记为垃圾的对象清除掉。分为
    1. 标记-清除算法
    2. 复制算法
    3. 标记-压缩算法
    4. 增量收集算法

引用计数算法

如果让我们来设计垃圾识别算法,相信第一想到的就是个计数器来标识该对象由被多少引用指向,如果计数器的值为0就说明该对象是垃圾,该算法就是引用计数算法(Reference Counting)。

引用计数算法对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行收集。

引用计数算法原理和实现都比较简单,垃圾对象容易识别,判定效率高,收集没有延迟性。
但该算法有一个致命的缺陷,即无法处理循环引用的情况,导致在Java的垃圾收集器中没有使用这类算法。

如下图的循环链表,即使外部的指向断开了,但内部的引用形成一个循环,引用计数器都还是1,无法被回收,这就是循环引用,从而造成内存泄漏
在这里插入图片描述
可以根据下面代码验证Java并不是使用引用计数算法

/**
 * -XX:+PrintGCDetails
 * 证明:java使用的不是引用计数算法
 */
public class RefCountGC {
    //这个成员属性唯一的作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB

    Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();

        obj1.reference = obj2;
        obj2.reference = obj1;

        obj1 = null;
        obj2 = null;
        //显式的执行垃圾收集行为
        //这里发生GC,obj1和obj2能否被收集?
        System.gc();

    }
}

我们先通过obj1和obj2引用两个对象,然后让堆区的这两个引用相互引用,再撤去obj1和obj2,这样堆中这两个对象没有外部引用,只有内部相互引用,在我们看来这两对象是垃圾
在这里插入图片描述
在不调用System.gc()的情况下(虚拟机选项记得加上-XX:+PrintGCDetails,System.gc()是主动触发GC的方法)
新生区被使用40%,因为伊甸园空间够,所以也没有触发GC
在这里插入图片描述
在主动使用GC的情况下,上面就已经输出了GC的一些细节,既然能成功GC,就反证知道Java不是使用引用计数算法
在这里插入图片描述
引用计数算法会产生内存泄漏,所以Java并没有采用

PS:内存泄漏和内存溢出是两种不同的情况,要注意区分开来。

  • 内存泄漏(Memory Leak)
    在狭义上一个对象不被其他任何对象引用,但GC无法收集该对象时,就是内存泄漏,就是上面循环引用的情况;
    广义上,代码上的不小心或者忽视导致一个对象的生命周期变得很长甚至导致OOM,也属于内存泄漏,比如常见的文件IO忘记关闭流,使用数据库连接后忘记关闭等。(值得注意的是当谈及Java内存泄漏的例子,因为Java并没有采用引用计数算法,所以不建议以此为例子)
    尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OOM异常,导致程序崩溃。
  • 内存溢出(Out Of Memory)
    内存溢出就是内存空间不足以分配给对象。随着垃圾收集器和硬件的提升,除非应用程序占用内存的增长速率比垃圾收集的速率快很多,OOM是不太容易发生的,实在不行也可以进行Full GC来回收大量内存。
    一般产生OOM的原因有两个:堆内存设置不够;代码中有大量大对象,但这些对象仍在使用,无法进行垃圾收集。
  • 内存泄漏和内存溢出的概念,打个比方就是有一房子,现在各式各样的家具占了一些空间,垃圾又占了一些空间。这时新买了家具,但是空间不够,这时可能把垃圾清理出去就够了,亦或者就算垃圾清理出去新买的家具还是放不下(因为其他家具都是有用的,就像对象还在使用),甚至房子的大小本来就放不下,比如买了艘巨轮,这是OOM;如果房子里有一些垃圾,你没有发现或者打扫不掉,这是内存溢出,虽然短时间不会影响什么,但随着这种时间的进行,房子的空间慢慢被蚕食,最后发现新买的家具无法放入,就导致OOM了。

虽然引用计数算法这个致命的缺陷导致Java不采用,但Python和其他一些语言在需要提高吞吐量的场合仍采用该算法,Python解决循环引用的方法是:手动解除,在合适的时候解除对象间的循环引用;使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。

可达性分析算法

可达性分析算法(根搜索算法、追踪性垃圾收集)
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生,是Java、C#选择的垃圾标记算法,通常也叫作追踪性垃圾收集(Tracing Garbage Collection)

可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达,和DFS(Depth First Search,深度优先算法)思想类似
使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
如果目标对象没有任何引用链直接或间接相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象,如下图所示。
在这里插入图片描述
在JVM中,以下元素可以作为GC Roots

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

在这里插入图片描述

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前收集的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部收集(PartialGC)。

如果只针对Java堆中的某一块区域进行垃圾收集(比如:典型的只针对新生区),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性。

小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。

在可达性分析算法中枚举根节点(GC Roots)会触发了STW导致所有Java执行线程停顿(在JVM(四)_性能监控与调优中会使用工具对GC Root进行溯源分析)
STW(Stop the World,停止世界咋瓦鲁多)是指垃圾收集事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉。
在这里插入图片描述
STW的目的是让JVM的某一刻的状态固定下来,才能在一个确保一致性的快照进行分析工作,找出哪些是垃圾,如果分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。
所以STW是十分必要的机制,所有的垃圾收集器都有该机制,哪怕是后面的优秀的并发收集器G1 GC也不能完全避免STW。STW暂停的时间长,对用户的体验就不好,所以要降低其时间。

可以根据下面的代码感受STW造成的延迟效果

public class StopTheWorldDemo {
    public static class WorkThread extends Thread {
        List<byte[]> list = new ArrayList<byte[]>();

        public void run() {
            try {
                while (true) {
                    for(int i = 0;i < 1000;i++){
                        byte[] buffer = new byte[1024];
                        list.add(buffer);
                    }

                    if(list.size() > 10000){
                        list.clear();
                        System.gc();//会触发full gc,进而会出现STW事件
                     
                    }
                }
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
    }

    public static class PrintThread extends Thread {
        public final long startTime = System.currentTimeMillis();

        public void run() {
            try {
                while (true) {
                    // 每秒打印时间信息
                    long t = System.currentTimeMillis() - startTime;
                    System.out.println(t / 1000 + "." + t % 1000);
                    Thread.sleep(1000);
                }
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        WorkThread w = new WorkThread();
        PrintThread p = new PrintThread();
        w.start();
        p.start();
    }
}

对象的finalization机制
尽管一个对象标记可被收集,但并不代表该对象可以马上清除,这是因为Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。

对象终止机制在Object类中定义finalize() ,同时默认是空。finalize() 方法允许在子类中被重写,用于在对象被收集时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
在这里插入图片描述

当标记阶段发现此对象是垃圾后,在清除之前,总会先调用这个对象的finalize()方法,由于该机制的存在,JVM中的对象分为以下三种状态

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。

因此一个垃圾对象会处于“缓刑”状态,仍可以在某一时刻“复活”自己,就像电视常演的刑场上的犯人被砍头之际有人拿着免死金牌说刀下留人,所以判定一个对象是否可以被清除,至少需要经历两次标记阶段

  • 如果对象到GC Roots没有引用链,则进行第一次标记。
  • 进行筛选,判断此对象是否有必要执行finalize()方法
  • 如果对象没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,被判定为不可触及的。
  • 如果对象重写了finalize()方法,且还未执行过,那么会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
  • finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的-任何一个对象建立了联系,那么在第二次标记时,会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法只会被调用一次。

可以根据下面的代码进行演示

public class CanReliveObj {
    // 类变量,属于GC Roots的一部分
    public static CanReliveObj canReliveObj;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("调用当前类重写的finalize()方法");
        canReliveObj = this;
    }

    public static void main(String[] args) throws InterruptedException {
        canReliveObj = new CanReliveObj();
        canReliveObj = null;
        System.gc();
        System.out.println("-----------------第一次gc操作------------");
        // 因为Finalizer线程的优先级比较低,暂停2秒,以等待它
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }

        System.out.println("-----------------第二次gc操作------------");
        canReliveObj = null;
        System.gc();
        // 下面代码和上面代码是一样的,但是 canReliveObj却自救失败了
        Thread.sleep(2000);
        if (canReliveObj == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is still alive");
        }

    }
}

先把重写的finalize()注释,该对象作为垃圾直接被清除
在这里插入图片描述
重写finalize()后,对象canReliveObj重新和其他对象建立起引用,所以第一次GC没有被清除,但在第二次JVM识别其已经调过finalize(),不会再给复活的机会,直接清除
在这里插入图片描述

这里结合finalize()讲一下System.gc()
在显示调用System.gc()者Runtime.getRuntime().gc() ,会显式触发Full GC,但不确保垃圾收集一定会生效。一般情况下垃圾收集都应该由JVM来调用。大家可以自行试验下面的代码,GC是不一定会生效的,但调用System.runFinalization()方法则能够强制调用失去引用对象finalize()的方法。

public class SystemGCTest {
    public static void main(String[] args) {
        new SystemGCTest();
        System.gc();

//        System.runFinalization();//强制调用使用引用的对象的finalize()方法
    }
    //如果发生了GC,这个finalize()一定会被调用
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("SystemGCTest 重写了finalize()");
    }
}

作为开发者应该禁止调用finalize()方法,应交由垃圾收集机制来调用,因为

  • 在finalize()时可能会导致对象复活。
  • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
  • 一个糟糕的finalize()会严重影响GC的性能。

标记-清除算法

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾收集,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并并应用于Lisp语言。
标记-清除算法故名思意,会经过两个过程

  • 标记:收集器从GC Roots开始遍历,标记所有被引用的对象(注意标记的是可达的对象,而不是垃圾,而且垃圾也不可达)。一般是在对象的Header中记录为可达对象。
  • 清除:收集器对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其收集

如下图所示
在这里插入图片描述
标记-清除算法需要进行两次遍历,即一次标记阶段,一次清除阶段,虽然时间复杂度是O(N),但是想想之前一个简单的类都加载上上千个类,所以该算法效率不算高,进而在GC时停止用户线程较久,造成体验效果不好;并且如上图所示,该算法清理出的内存是不连续的,产生碎片,还需要维护一个空闲列表。

注意:何为清除?
这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放(也就是覆盖原有的地址)。硬盘也是相同的机制,所以Steam的游戏一下就卸载了,其实并没有,通过技术手段这些数据是可以恢复的,陈冠希老师就是栽在这了。

复制算法

为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器(CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。

其核心思想是将内存空间分为两块,每次只使用其中一块,在垃圾收集时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾收集。Survivor区采用的就是该算法。

在这里插入图片描述
复制算法很好的弥补了标记-清除算法的两个弊端:实现简单,运行高效;复制到另一块内存区域可以保证空间的连续性,不会出现“碎片”问题。

但缺点也是很明显的,需要两倍的空间
对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小

因此复制算法适合,对象生命周期短,一次GC存活的对象少(这样复制快,需要的两倍空间也可以接收)的区域,这就和新生区的Survivor区十分契合,(算法发明的时间比JVM的早,Survivor区应该就是依据算法而设计的)对常规应用的垃圾回收,一次通常可以回收70% - 99% 的内存空间,回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生区。
在这里插入图片描述

标记-压缩(整理)算法

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生区经常发生,但是在老年区,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年区垃圾收集的特性,需要使用其他的算法。

标记一清除算法的确可以应用在老年区中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。

1970年前后,G.L.Steele、C.J.Chene和D.s.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。

标记-压缩算法第一阶段也是标记,区别在于第二阶段将所有的存活对象压缩到内存的一端,按顺序排放,随后清理边界外的空间。

在这里插入图片描述
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。

二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

指针碰撞(Bump the Pointer)
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump tHe Pointer)。

以上的算法各有各的优缺点,所以没有最好的算法,只有具体情况具体分析选用合适的算法。
JVM根据对象的生命周期区别将堆分为了新生区和老年区,因此GC也要结合新生区和老年区的特点。

  • 新生区
    新生区特点:区域相对新生区较小,对象生命周期短、存活率低,回收频繁。
    这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于新生区的回收。而复制算法内存利用率不高的问题,通过HotSpot中的两个survivor的设计得到缓解。
  • 老年区
    老年区特点:区域较大,对象生命周期长、存活率高,收集不及年轻代频繁。
    这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
    Mark阶段的开销与存活对象的数量成正比。
    Sweep阶段的开销与所管理区域的大小成正相关。
    Compact阶段的开销与存活对象的数据成正比。

以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的收集效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年区内存的整理。

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾收集器都区分新生代和老年代

增量收集算法

  • List item

垃圾收集器

垃圾收集器(Garbage Collector)就是具体执行垃圾收集的工具了,垃圾收集和垃圾收集器的缩写都是GC,资料一般不会特别说明,得靠我们自己结合上下文明确是指的哪个。比如堆中的新生区的Young GC,就是垃圾收集,是一种策略;而CMS GC才是具体的垃圾收集器。

这里回忆一下在上篇堆中讲的各个GC策略

  • Young GC/MinorGC:针对新生区的GC策略,在Eden区空间不足时触发,注意Survivor区不会触发YGC,但是收集时也会收集Survivor区,具体垃圾收集器比如Serial、ParNew GC。
  • Old GC/Major GC:针对老年区的GC策略,在Old区空间不足时触发,具体垃圾收集器比如CMS。
  • Mixed GC:针对整个新生区和部分老年区的垃圾收集,具体垃圾收集器有G1。
  • Full GC:进行整堆收集,对堆区所有区域和方法区的垃圾收集。

垃圾收集器的分类

垃圾收集的线程数分可以分为串行垃圾收集器和并行垃圾收集器,如下图(蓝色为用户线程、橙色为垃圾收集线程,串行并行就不用多讲了)
在这里插入图片描述
串行收集器适用于单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行收集器的性能表现可以超过并行收集器和并发收集器(毕竟切换线程还要消耗资源)。
串行收集默认被应用在客户端的Client模式下的JVM中,其他场合还是并行的优越。可以在CMD使用java -version查看虚拟机是在什么模式下(现在的家用电脑一般都是Server)
在这里插入图片描述

按照工作模式分,可以分为并发式垃圾收集器和独占式垃圾收集器。
并发式垃圾收集器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
独占式垃圾收集器STW一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
在这里插入图片描述
碎片处理方式分,可分为压缩式垃圾收集器和非压缩式垃圾收集器。
压缩式垃圾收集器会在收集完成后,对存活对象进行压缩整理,消除收集后的碎片;非压缩式的垃圾收集器不进行这步操作。

工作的区域区分,又可分为新生区垃圾收集器和老年区垃圾收集器。

收集器的性能指标
垃圾收集器的性能指标主要注重以下几点

  • 吞吐量:运行用户代码的时间占总运行时间的比例(若总运行时间 = 程序的运行时间a + 内存回收的时间b,吞吐量则为a/(a+b)),越大越好。吞吐量优先的GC比如Parallel Scavenge GC
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例,(同上,b/(a+b)),越小越好。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间,越小越好。低延迟优先的CG比如CMS
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java堆区所占的内存大小。

无论如何,吞吐量、暂停时间、内存占用这三个指标都不能同时满足,好的GC最多满足两项。随着硬件发展,内存占用是可以牺牲的,同时内存的扩大反而对延迟带来负面效果。所以我们着重关注吞吐量和暂停时间。

垃圾收集器发展史

  • 1999年随JDK1.3.1一起来的是串行方式的Serial GC,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本
  • 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK1.4.2一起发布, Parallel GC在JDK6之后成为HotSpot默认GC。
  • 2012年,在JDK1.7u4版本中,G1可用。
  • 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
  • 2018年3月,JDK10中G1垃圾收集器的并行完整垃圾收集,实现并行性来改善最坏情况下的延迟。
  • 2018年9月,JDK11发布。引入Epsilon 垃圾收集器,又被称为 "No-Op(无操作)“ 收集器。同时,引入ZGC:可伸缩的低延迟垃圾收集器(Experimental)
  • 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC(红帽公司的):低停顿时间的GC(Experimental)。
  • 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
  • 2020年3月,JDK14发布。删除CMS垃圾收集器。扩展ZGC在macOS和Windows上的应用

经典垃圾收集器
这里主要学习的是这几款经典的垃圾收集器:Serial(GC)、Serial Old、ParNew、Parallel Scavenge(清除)、Parallel Old、CMS、G1。如果根据之前垃圾收集器不同分类标准来分,可以把这七款按照垃圾收集线程分为

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS
  • G1比较特别,可以有并行和并发,而且采用的是region分区思想,所以下图G1看起来就特别。
    在这里插入图片描述

按照工作区域分为

  • 新生区收集器:Serial、ParNew、Parallel Scavenge
  • 老年区收集器:Serial Old、Parallel Old、CMS
  • 整堆收集器:G1
    在这里插入图片描述
    从上图可以看到除了G1可以在新生区和老年区工作,其他GC只能在一个区域工作,所以组合使用(分代思想),下图为这些GC的组合关系(有连线表明可以组合工作)
  • Serial Old和作为CMS出现"Concurrent Mode Failure"失败的后备预案,所以这两者也连接在一起
  • (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP173),并在JDK9中完全取消了这些组合的支持(JEP214)
  • (绿色虚线)JDK14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)
  • (青色虚框)JDK14中:删除CMS垃圾回收器(JEP363)

在这里插入图片描述

Serial和Serial old

Serial收集器是最基本、历史最悠久的垃圾收集器,在JDK1.3之前收集新生区只能使用该收集器(毕竟早期硬件的条件,大部分是单核CPU),为了收集老年区,还提供了Serial Old收集器,Serial和Serial Old都是采用串行和STW机制进行收集,区别在于Serial使用复制算法收集,而Serial Old采用标记-压缩算法收集。

由于 Serial单线程串行的特点,它在工作时必须停止其他所有线程,如下图

在这里插入图片描述

从图上我们可以看到一个没见过的术语Safe Point。
在程序执行中,并非任意都可以停顿下来进行GC,只有特定位置才能进行GC,这些位置就是安全点(Safe Point).

Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
JVM提供两种机制保证所有线程都能跑到最近的安全点停顿下来:

  • 抢先式中断
    中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。(目前JVM已经不采用这种方式)
  • 主动式中断
    设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)

Safe Point 机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safe Point。但是如果线程处于Sleep状态或Blocked 状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safe Point。

  • 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Relgion,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
  • 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;

随着硬件水平的提升,现在很少用该收集器了,但仍有适用的场合,就常见的就是单核的环境下。
Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率,所以该场合下比其他收集器的单线程相比简单而高效,因此运行在Client模式下的虚拟机是个不错的选择。
在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生,使用串行回收器也是可以接受的,所以CMS GC被移除了,Serial还存在。

在Server模式下,Serial Old仍在发光发热,主要用于与作用于新生区Parallel Scavenge和作为老年区CMS GC的后备垃圾收集方案(Serial Old的组合范围很广)

在虚拟机中可以添加-XX:+UseSerialGC选项使用该GC(该选项同时开启Serial和Serial Old GC,并没有单独开启Serial Old GC的选项)

ParNew

随着多核CPU的普遍使用,并行收集器很自然而然的就研发出来了。ParNew(Par:Parallel并行,New:处理新生区)收集器可以认为是Serial收集器的多线程版本,除了是并行的方式执行收集外,也一样采用复制算法、STW机制。在早期ParNew是很多JVM在Server模式下新生区的GC(和Serial Old搭配使用,对于老年区,回收次数少,使用串行节省资源,所以没有开发“ParOld”)

在这里插入图片描述
目前ParNew处境也很窘迫,我们可以看看前面的组合图,在JDK 9中ParNew+Serial Old的组合完全弃用,而剩下的CMS GC在JDK 14也宣布要移除
在这里插入图片描述
在JDK 9中还使用该收集器会给出警告
在这里插入图片描述
在虚拟机中可以加上-XX:+UseParNewGC使用该收集器,老年区的收集器可以另外指定。由于并行的特性,还可以通过
-XX:ParallelGCThreads选项来限制线程数量,默认开启和CPU线程相同的线程数。

Parallel Scavenge、Parallel Old

JVM在后续推出了Parallel Scavenge(后面用PS代称),该收集器也和ParNew一样采用了复制算法、并行回收和STW机制。
推出PS并不是多此一举,PS是能达到可控制吞吐量的收集器,也被称作吞吐量优先的GC。和ParNew另一个区别是PS有自适应调节策略。

高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

PS在JDK1.6时提供了用于执行老年区垃圾收集的Parallel Old收集器,用来代替老年区的Serial Old收集器。
Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和STW机制。
在这里插入图片描述
在程序吞吐量优先的应用场景中,Parallel 收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。在JDK 8中,默认是此垃圾收集器。可以添加-XX:+PrintCommandLineFlags选项,查看开启了哪些选项(没有手动添加的就是默认选项)

在这里插入图片描述
Parallel Scavenge和Parallel Old收集器现在还在使用,需要了解的参数略多些

  • -XX:+UseParallelGC
    手动指定Parallel Scavenge执行年轻区的GC。
  • -XX:+UseParallelOldGC
    手动指定Parallel Old执行老年区的GC。

上面两个参数,默认开启一个,另一个也会开启(相互激活)。JDK 8默认开启

  • -XX:ParallelGCThreads
    设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
    默认情况下,当CPU核心数小于8,线程数等于CPU核心数;大于8则根据下面式子调整。
    在这里插入图片描述

  • -XX:MaxGCPauseMillis
    设置垃圾收集器最大停顿时间(即STW的时间),单位是毫秒。
    为了尽可能地把停顿时间控制所设置的时间以内,收集器在工作时会调整Java堆大小或者其他一些参数。
    对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。该参数使用需谨慎。

  • -XX:GCTimeRatio
    设置垃圾收集时间占总时间的比例(1/(N+1)),用于衡量吞吐量的大小。取值范围(0, 100),默认值99,也就是垃圾回收时间不超过1%。
    与-XX:MaxGCPauseMillis参数有一定矛盾性,暂停时间越长,Radio参数就容易超过设定的比例。

  • -XX:+UseAdaptivesizePolicy
    设置Parallel Scavenge收集器具有自适应调节策略
    在这种模式下,年轻区的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
    在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。

CMS

CMS GC(Concurrent-Mark-Sweep,并发-标记-清除)是在JDK1.5时期Hotspot推出了一款在强交互应用中的垃圾收集器。它是Hotspot第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作,具有划时代的意义。

CMS GC侧重于低延迟,尽可能缩短GC时用户线程停顿的时间,适用于与用户交互的场合,提升体验。常见的使用场合比如互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

根据的CMS的名称就可对该GC略知一二,CMS采用的是标记-清除算法来进行老年区GC,大致工作过程如下图。
在这里插入图片描述
CMS的工作流程显然比前几个GC要复杂的多,主要分为四个阶段:初始标记、并发标记、重新标记、并发清理

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为STW机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GCRoots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,该阶段的执行速度非常快。
  • 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

耗时最长的并发标记和并发清理阶段都是并发执行的,所以整体上是低延迟的。
CMS收集器采用的是标记-清除算法,这就意味着GC完后会不可避免地产生内存碎片,因此在为对象分配内存空间时只能选择空闲列表而不能使用指针碰撞技术来分配内存。由于CMS的并发性,所以也就不能采用标记-压缩算法( 想想刚要移动一个存活对象,结果资源轮到另一个线程了,这个线程刚好释放那个对象,该对象就成了垃圾。所以标记-压缩算法适用于有STW的时候使用)

另外,由于在GC阶段用户线程没有中断,堆的占用率很有可能比CMS的GC提升速度还快,这就使得CMS不能像其他GC那样等到老年区快满时才进行GC,而是要求堆的使用率到达一定阈值就开始GC(后面有参数设置),以确保其他线程在CMS工作过程中仍有足够的空间支持运行;若CMS工作时预留的空间仍无法满足程序需要,就会出现“Concurrent Mode Failure” 失败,这时CMS将启动后备预案:也就是前面提过的Serial Old收集器来进行老年区的收集,这样停顿的时间就长很多了。

在G1出现之前,CMS使用非常广泛,但由于设计不同,CMS不能和在JDK 1.4已经存在的新生区收集器Parallel Scavenge GC配合工作,只能和ParNew或者Serial收集器配合。在JDK 9中,就弃用了CMS+Serial的组合;在JDK 14中,CMS从JVM中移除。
在这里插入图片描述

CMS参数设置

  • -XX:+UseConcMarkSweepGC
    手动指定使用CMS收集器
    开启该参数后-XX:+UseParNewGC的参数也会跟着开启,即开启ParNew + CMS + Serial Old的组合
    在这里插入图片描述

  • -XX:CMSInitiatingOccupanyFraction
    设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
    JDK 5前默认值为68,JDK 6版本前为92。一般我们根据堆使用的速率来设置,如果增长缓慢可以设置稍大的值,从而降低CMS的GC频率,改善程序性能;若增长较块就需降低阈值,进行及时的GC,避免启用后备预案Serial Old GC,毕竟Full GC效率更加低。

  • -XX:+UseCMSCompactAtFullCollection
    用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

  • -XX:CMSFullGCsBeforeCompaction
    和上个参数搭配使用,设置在执行多少次Full GC后对内存空间进行压缩整理。

  • -XX:ParallelcMSThreads
    设置CMS的线程数量。
    CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年轻区并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾收集阶段可能会非常糟糕。

G1

G1(Garbage-First) GC是Java 7 update4之后引入的一个新的垃圾收集器,是当今收集器技术发展的最前沿成果之一,G1是一个业务越来越复杂,硬件水平比如内存容量和CPU核数提升的背景下的产物

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

G1的目的是在延迟(暂停时间)可控的情况下尽可能获取高的吞吐量,再加上Region分区的机制,G1担起了“全功能收集器”的重担和期望,也是JDK 9后默认的收集器。

G1相比于其他GC只能作用于新生区或老年区一个区域,G1两个区域都可以工作
在这里插入图片描述
这和Region分区机制密切相关,G1不要求新生区和老年区在内存上是连续的,也不要求这些区域的大小和数量是固定的。
G1将堆划分为以下一2048个独立的Region区域,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB。所有的Region大小相同,且在JVM生命周期内不会被改变。

在这里插入图片描述
一个Region有可能属于Eden、Survivor、Old或Humongous(巨大无比)区域。Humongous是G1新增加的内存区域(后面用H块简称),用于存储大对象,如果超过1.5个Region就放到H块。
另外设计H块的原因是因为在之前,对于一个大对象,新生区放不下默认会直接放到老年区,但如果该对象是一个短期存在的大对象,会对GC造成负面影响。虽然H块和老年区区分出来了,但G1的大多数行为仍将H块作为老年区来对待。

如果一个H区放不下大对象,G1会寻找连续的H区来存储,这也是H区和其他区块的区别。为了能找到连续的H区,有时候不得不启动Full GC。

在这里插入图片描述

G1收集的对象是一个个Region块,所以能又收集新生区也能收集老年区。
在Region区域基础上,G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,从而有计划地避免进行全区域的GC。

  • GC收集过程

G1参数设置

  • -XX:+UseG1GC
    手动指定使用G1垃圾收集器执行内存回收任务
  • -XX:G1HeapRegionSize
    设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
  • -XX:MaxGCPauseMillis
    设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms(人的平均反应速度)
  • -XX:+ParallelGCThread
    设置STW工作线程数的值。最多设置为8(上面说过Parallel回收器的线程计算公式,当CPU_Count > 8时,ParallelGCThreads 也会大于8)
  • -XX:ConcGCThreads
    设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
  • -XX:InitiatingHeapOccupancyPercent
    设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。

G1的设计也简化了JVM的性能调优,实际生产中开发者一般只用设置堆的最大内存和设置最大停顿时间就行(前提当然要开启G1)。

G1和CMS

在JDK 8 CMS还生龙活虎,但在JDK 9就标记要废弃(deprecated),这肯定和G1的出现或多或少有些关系,所以这里和CMS进行比较,看看G1为什么能取代CMS(毕竟Serial GC都还没弃用)

首先当然是工作区域,CMS只能作用于老年区,G1可以工作在新生区和老年区。同时G1采用了可预测的停顿时间模型,

其次是空间整合上

  • CMS采用标记-清除算法,会造成内存碎片。在若干次GC后才会进行碎片整理
  • G1收集的单位是一个Region块,Region间是复制算法,但整体上看可看作是标记-压缩算法。当堆特别大时,G1的优势更加明显。

目前来说,G1对CMS还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。
从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。

因此G1适用于面向服务端应用,针对具有大内存、多处理器的机器,(在普通大小的堆里表现并不惊喜)最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。

在下面的情况时,使用G1可能比CMS好:

  • 超过50%的Java堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC停顿时间过长(长于0.5至1秒)

HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

总结

以上七种GC是HotSpot十分经典的垃圾收集器(可以预见ZGC也会成为经典的一款,不过目前学习的资料太少)。每款GC都有各自的特点和适用场景,GC的选择是性能调优重要的一项环节。

垃圾收集器线程工作方式作用区域使用算法特点适用场景
Serial串行新生区复制算法响应速度优先适用于单CPU环境下的Client模式
Serial Old串行老年区标记-压缩算法响应速度优先适用于单CPU环境下的Client模式,作为CMS的后备处理方案
ParNew并行新生区复制算法响应速度优先多CPU环境Server模式下与CMS配合使用
Parallel Scavenge并行新生区复制算法吞吐量优先适用于后台运算而不需要太多交互的场景
Parallel Old并行老年区标记-压缩算法吞吐量优先适用于后台运算而不需要太多交互的场景
CMS并发老年区标记-清除算法响应速度优先适用于互联网或B/S业务
G1并行和并发新生区和老年区标记-压缩、复制算法响应速度优先面向服务端应用

截至JDK 14,各GC的相互组合关系

在这里插入图片描述

引用类型

谈到引用,相信很多人第一时间想到的就是Object obj = new Object();除了这种引用其他类型的引用就很难说出来了,我们开发绝大多数(99.99%)用的也是这种引用。

在JDK 1.2后,Java对引用的概念进行了扩充,将引用强度由强到弱分为(不包括终结器引用)

  • 强引用(Strong Reference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)
  • 终结器引用(Final Reference)

可以通过 Reference类的继承树看到这些引用

在这里插入图片描述

虽然我们除了强引用基本用不上其他类型的引用,但为了更好的了解垃圾收集,学习这些引用的区别的作用是很有必要的。这些引用最主要的区别在于在垃圾收集后的行为。

强引用(Strong Reference)
程序代码中最普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系,也是我们最常使用、默认的引用类型。
无论任何情况下,只要强引用关系还存在,即使内存溢出,垃圾收集器就也不会回收掉被引用的对象,所以强引用会导致内存泄漏,而其他的引用类型不会。

软引用(Soft Reference)
软引用是用来描述一些还有用,但非必需的对象。
只被软引用关联着的对象,在系统将要发生OOM前,会把这些对象列进收集范围之中进行第二次收集,如果第二次收集还没有足够的内存,才会抛出OOM。也就是说,当内存足够时,GC是不会收集软引用所引用的对象;当内存不足,GC就会收集软引用所引用的对象,如果内存还不足才会OOM(当软引用的对象被收集时,可以放到指定的引用队列,通过引用队列来追踪对象的收集情况)。

软引用引用一个对象,可以先创建一个强引用来引用一个对象,再通过强引用间接引用该对象,如下。
软引用可以通过get()方法获取其引用的对象,可以看到这date强引用引用的对象和date1软引用的对象都是同一个
在这里插入图片描述
在这里插入图片描述
在内存充足的情况下,软引用所引用的对象是不会被收集的

在这里插入图片描述
在这里插入图片描述

而内存不足时,软引用所引用的对象会被收集
为了演示OOM的情况,需要设置堆空间
在这里插入图片描述
同时更好的看区别,这里用一个强引用来参照

在这里插入图片描述
可以看到即使OOM,强引用的对象仍没有被收集,而软引用的对象在OOM前就被清除,但空间仍不足所以抛出OOM
在这里插入图片描述

基于软引用的特性,软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

弱引用(Weak Reference)
弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

不过由于垃圾收集器的线程通常优先级很低,因此并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。

弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被收集时,就会加入指定的引用队列,通过这个队列可以跟踪对象的收集情况。

弱引用和软引用的用法大致一样,这里直接看结果

在这里插入图片描述
GC时直接收集
在这里插入图片描述
弱引用也适用于缓存,JDK还根据弱引用的特性提供WeakHashMap类,即弱引用的哈希表。
在这里插入图片描述
WeakHashMap类除了正常实现Map的功能,最主要的特点就是其Key是弱引用的
在这里插入图片描述

基于弱引用的特性,WeakHashMap的Key随时可能被回收,即使没有删除、更改弱哈希表,WeakHashMap的大小、get()方法都有可能不一样。这种飘忽不定,一言不合就删除的特性作为缓存就很合适,在缓存场景下,由于内存是有限的,不能缓存所有对象,因此就需要一定的删除机制,淘汰掉一些对象。

虚引用(Phantom Reference)
虚引用也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。
一个对象是否有虚引用的存在,完全不会决定对象的生命周期;如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾收集器收集。
虚引用不能单独使用,也无法通过虚引用来获取被引用的对象,即当试图通过虚引用的get()方法取得对象时,总是null。

虚引用的作用主要是跟踪对象的收集状态,也可以将一些资源释放操作放置在虚引用中执行和记录,因此虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾收集准备收集一个对象时,如果发现它还有虚引用,就会在收集对象后,将这个虚引用加入引用队列,以通知应用程序对象的收集情况。

虚引用不能获取其引用的对象
在这里插入图片描述在这里插入图片描述
可以根据下面代码看看虚引用追踪,代码还是比较多的,理解不了可以去原视频下学习
尚硅谷宋红康JVM全套教程(详解java虚拟机)P167虚引用-对象的回收跟踪

public class PhantomReferenceTest {

    public static PhantomReferenceTest obj;//当前类对象的声明
    static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;//引用队列

    public static class CheckRefQueue extends Thread {
        @Override
        public void run() {
            while (true) {
                if (phantomQueue != null) {
                    PhantomReference<PhantomReferenceTest> objt = null;
                    try {
                        objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    if (objt != null) {
                        System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了");
                    }
                }
            }
        }
    }

    @Override
    protected void finalize() throws Throwable { //finalize()方法只能被调用一次!
        super.finalize();
        System.out.println("调用当前类的finalize()方法");
        obj = this;
    }

    public static void main(String[] args) {
        Thread t = new CheckRefQueue();
        t.setDaemon(true);//设置为守护线程:当程序中没有非守护线程时,守护线程也就执行结束。
        t.start();

        phantomQueue = new ReferenceQueue<PhantomReferenceTest>();
        obj = new PhantomReferenceTest();
        PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<PhantomReferenceTest>(obj, phantomQueue);

        try {

            obj = null;
            System.out.println("第 1 次 gc___________________________");
            System.gc();
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj 是 null");
            } else {
                System.out.println("obj 可用");
            }
            System.out.println("第 2 次 gc___________________________");
            obj = null;
            System.gc(); //一旦将obj对象回收,就会将此虚引用存放到引用队列中。
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj 是 null");
            } else {
                System.out.println("obj 可用");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这里插入图片描述

终结器引用(Final Reference)
它用于实现对象的finalize() 方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。

在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象调用它的finalize()方法,第二次GC时才回收被引用的对象

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值