JVM的入门知识

原创
2020/08/11 22:17
阅读数 3W

前言:巴拉巴拉,今天给大家分享一点java三剑客(jre,jvm,jdk)中的jvm,纯理论教科书篇。 非原创,里面摘取了多个博客里面的内容

JDK、 JRE、JVM 的关系是什么?

我们学习JVM的之前,简单科普一下他们三者有啥关系

JVM 

JAVA 虚拟机(Java Virtual Machine)。 它只识别 .class 类型文件,它能够将 class 文件中的字节码指令进行识别并调用操作系统向上的 API 完成动作

JRE 

Java 运行时环境(Java Runtime Environment)。它主要包含两个部分:JVM 的标准实现和 Java 的一些基本类库。相对于 JVM 来说,JRE多出了一部分 Java 类库

JDK

Java 开发工具包(Java Development Kit)。JDK 是整个 Java 开发的核心,它集成了 JRE 和一些好用的小工具

常用工具

jar.exe       jar文件管理工具,打包压缩解压jar文件

java.exe     java运行工具,运行.class字节码文件或 .jar文件

javac.exe   java编译工具,用来编译.java源代码文件

javap.exe   java反编译工具,根据java字节码文件反汇编成java源代码文件

jvisualvm.exe  jvm监控,分析工具

这三者的关系:JDK > JRE > JVM

 

由于Oracle jdk 从jdk 8u211以后商业用途需要收费,提供一下免费JDK

阿里dragonwell8   https://github.com/alibaba/dragonwell8/releases

亚马逊Corretto      https://docs.aws.amazon.com/corretto/latest/corretto-8-ug/downloads-list.html

adoptopenjdk        https://adoptopenjdk.net

openjdk                 http://openjdk.java.net/install

 

2 JVM的核心

JVM(Java Virtual Machine)是用来运行Java字节码的虚拟机,包括字节码指令集、程序寄存器、栈、堆、方法区和垃圾回收器。

JVM运行在操作系统之上,不与硬件设备直接交互。

Java源文件在通过编译器之后被编译成相应的.Class文件(字节码文件),.Class文件又被JVM中的解释器编译成机器码在不同的操作系统(Windows、Linux、Mac)上运行。

每种操作系统的解释器都是不同的,但基于解释器实现的虚拟机是相同的,这也是Java能够跨平台的原因。

在一个Java进程开始运行后,虚拟机就开始实例化了,有多个进程启动就会实例化多个虚拟机实例。进程退出或者关闭,则虚拟机实例消亡,在多个虚拟机实例之间不能共享数据。

Java程序的具体运行过程如下。

(1)Java源文件被编译器编译成字节码文件。

(2)JVM将字节码文件编译成相应操作系统的机器码。

(3)机器码调用相应操作系统的本地方法库执行相应的方法。

Java虚拟机包括一个类加载器子系统(Class Loader SubSystem)、运行时数据区(Runtime Data Area)、执行引擎和本地接口库(Native InterfaceLibrary)。

本地接口库通过调用本地方法库(Native Method Library)与操作系统交互。

JVM核心图

◎ 类加载器子系统用于将编译好的.Class文件加载到JVM中;

◎ 运行时数据区用于存储在JVM运行过程中产生的数据,包括程序计数器、方法区、本地方法区、虚拟机栈和虚拟机堆;

◎ 执行引擎包括即时编译器和垃圾回收器,即时编译器用于将Java字节码编译成具体的机器码,垃圾回收器用于回收在运行过程中不再使用的对象;

◎ 本地接口库用于调用操作系统的本地方法库完成具体的指令操作。

 

3 JVM的内存区域

JVM的内存区域分为线程私有区域(程序计数器、栈、本地方法区)、线程共享区域(堆、方法区)和直接内存。

3.1 线程私有区域

生命周期与线程相同,随线程的启动而创建,随线程的结束而销毁。在JVM内,每个线程都与操作系统的本地线程直接映射,因此这部分内存区域的存在与否和本地线程的启动和销毁对应。

3.1.1 程序计数器

程序计数器是一块很小的内存空间,用于存储当前运行的线程所执行的字节码的行号指示器。每个运行中的线程都有一个独立的程序计数器,在方法正在执行时,该方法的程序计数器记录的是实时虚拟机字节码指令的地址;如果该方法执行的是Native方法,则程序计数器的值为空(Undefined)。程序计数器属于“线程私有”的内存区域,它是唯一没有Out Of Memory(内存溢出)的区域。

3.1.2 虚拟机栈

虚拟机栈是描述Java方法的执行过程的内存模型,它在当前栈帧(Stack Frame)中存储了局部变量表、操作数栈、动态链接、方法出口等信息。同时,栈帧用来存储部分运行时数据及其数据结构,处理动态链接(Dynamic Linking)方法的返回值和异常分派(Dispatch Exception)。栈帧用来记录方法的执行过程,在方法被执行时虚拟机会为其创建一个与之对应的栈帧,方法的执行和返回对应栈帧在虚拟机栈中的入栈和出栈。无论方法是正常运行完成还是异常完成(抛出了在方法内未被捕获的异常),都视为方法运行结束。


上图展示了线程运行图。

线程1在CPU1上运行,线程2在CPU2上运行,在CPU资源不够时其他线程将处于等待状态,等待获取CPU时间片。

而在线程内部,每个方法的执行和返回都对应一个栈帧的入栈和出栈,每个运行中的线程当前只有一个栈帧处于活动状态。

jvm参数:

-Xss128k:每个线程栈的大小,合理的减少可以使剩余的系统内存支持更多的线程。

3.1.3 本地方法区

本地方法区和虚拟机栈的作用类似,区别是虚拟机栈为执行Java方法服务,本地方法栈为Native方法服务。

 

3.2线程共享区域

随虚拟机的启动而创建,随虚拟机的关闭而销毁。

3.2.1 堆

也叫作运行时数据区,在JVM运行过程中创建的对象和产生的数据都被存储在堆中,堆是被线程共享的内存区域,也是垃圾收集器进行垃圾回收的最主要的内存区域。由于现代JVM采用分代收集算法,因此Java堆从GC(Garbage Collection,垃圾回收)的角度还可以细分为:新生代、老年代和永久代。

jvm参数:

-Xms4G : JVM启动时整个堆(包括年轻代,年老代)的初始化大小 (一般将和最大保持一致,可以避免堆内存频繁震荡,导致系统性能下降,jvm会尽可能维持在最小空间运行,这样很有可能发生频繁GC)。

-Xmx4G : JVM启动时整个堆的最大值。

-Xmn2G:年轻代的空间大小,剩下的是年老代的空间。

3.2.2 方法区

方法区也被称为永久代,用于存储常量、静态变量、类信息、即时编译器编译后的机器码、运行时常量池等数据

JVM把GC分代收集扩展到了方法区,这样JVM的垃圾收集器就可以像管理Java堆一样管理这部分内存。

永久代的内存回收主要针对常量池的回收和类的卸载,可回收的对象很少。

3.3 直接内存

也叫堆外内存,就是把内存对象分配在Java虚拟机的堆以外的内存 ,它并不是JVM运行时数据区的一部分,直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。

JDK的NIO模块提供的基于Channel与Buffer的I/O操作方式就是基于堆外内存实现的,NIO模块通过调用Native函数库直接在操作系统上分配堆外内存,然后使用java.nio.DirectByteBuffer对象作为这块内存的引用 对内存进行操作。

这样可以加快复制速度,因为堆内数据刷新到远程时,会先复制到直接内存,然后再发送,可以减少堆内存和直接内存的来回复制影响性能,因此堆外内存在高并发应用场景下被广泛使用( Ehcache ,Netty、Flink、HBase、Hadoop都有用到堆外内存)。

 

4 JVM的运行时内存(堆)

JVM的运行时内存也叫作JVM堆,从GC的角度可以将JVM堆分为新生代、老年代和永久代。其中新生代默认占1/3堆空间,老年代默认占2/3堆空间,永久代占非常少的堆空间。新生代又分为Eden区、ServivorFrom区和ServivorTo区,Eden区默认占8/10新生代空间,ServivorFrom区和ServivorTo区默认分别占1/10新生代空间。

4.1 新生代

JVM新创建的对象(除了大对象外)会被存放在新生代,默认占1/3堆内存空间。由于JVM会频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。

新生代又分为Eden区、ServivorTo区和ServivorFrom区

◎Eden区:Java新创建的对象首先会被存放在Eden区,如果新创建的对象属于大对象,则直接将其分配到老年代。大对象的定义和具体的JVM版本、堆大小和垃圾回收策略有关,一般为2KB~128KB,可通过XX:PretenureSizeThreshold设置其大小。在Eden区的内存空间不足时会触发GC。

◎ServivorTo区:保留上一次GC时的幸存者。

◎ServivorFrom区: 上一次GC的幸存者,作为这一次GC的被扫描者。

新生代的GC过程叫作MinorGC,采用复制算法实现,具体过程如下。

(1)Eden区内存空间不足会触发GC

(2)扫描Eden区和ServivorFrom区进行GC回收

(3)将存活的对象复制到ServivorTo区(如果某对象的年龄达到老年代的标准(对象晋升老年代的标准由XX:MaxTenuringThreshold设置,默认为15),则将其复制到老年代。如果ServivorTo区的内存空间不够,则也直接将其复制到老年代;如果对象属于大对象(大小为2KB~128KB的对象属于大对象,例如通过XX:PretenureSizeThreshold=2097152设置大对象为2MB,1024×1024×2),则也直接将其复制到老年代)

(4)将现有ServivorTo区的存活的对象年龄加1

(5)清空Eden区和ServivorFrom区中的对象

(6)将ServivorTo区和ServivorFrom区互换(原来的ServivorTo区成为下一次GC时的ServivorFrom区)

4.2 老年代

老年代主要存放有长生命周期的对象和大对象。老年代的GC过程叫作MajorGC。在老年代,对象比较稳定,MajorGC不会被频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,在MinorGC过后仍然出现老年代空间不足或无法找到足够大的连续空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。MajorGC采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。因为要先扫描老年代的所有对象再回收,所以MajorGC的耗时较长。MajorGC的标记清除算法容易产生内存碎片。在老年代没有内存空间可分配时,会抛出Out Of Memory异常。 报错误的原因是因为执行垃圾收集的时间比例太大, 有效的运算量太小。默认情况下, 如果GC花费的时间超过 98%, 并且GC回收的内存少于 2%, JVM就会抛出这个错误。

4.3 永久代

永久代指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在类加载时被放入永久代。永久代和老年代、新生代不同,GC不会在程序运行期间对永久代的内存进行清理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载的Class文件过多时会抛出Out OfMemory异常,比如Tomcat引用Jar文件过多导致JVM内存不足而无法启动。需要注意的是,在Java 8中永久代已经被元数据区(也叫作元空间)取代。元数据区的作用和永久代类似,二者最大的区别在于:元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。因此,元空间的大小不受JVM内存的限制,只和操作系统的内存有关。在Java 8中,JVM将类的元数据放入本地内存(Native Memory)中,将常量池和类的静态变量放入Java堆中,这样JVM能够加载多少元数据信息就不再由JVM的最大可用内存(MaxPermSize)空间决定,而由操作系统的实际可用内存空间决定。

 

5.垃圾回收与算法

5.1 如何确定是垃圾?

Java采用引用计数法和可达性分析来确定对象是否应该被回收,其中,引用计数法容易产生循环引用的问题,可达性分析通过根搜索算法(GC RootsTracing)来实现。根搜索算法以一系列GC Roots的点作为起点向下搜索,在一个对象到任何GCRoots都没有引用链相连时,说明其已经死亡。根搜索算法主要针对栈中的引用、方法区中的静态引用和JNI中的引用展开分析,如图1-6所示。

5.1.1 引用计数法

在Java中如果要操作对象,就必须先获取该对象的引用,因此可以通过引用计数法来判断一个对象是否可以被回收。在为对象添加一个引用时,引用计数加1;在为对象删除一个引用时,引进计数减1;如果一个对象的引用计数为0,则表示此刻该对象没有被引用,可以被回收。引用计数法容易产生循环引用问题。循环引用指两个对象相互引用,导致它们的引用一直存在,而不能被回收。

Object1与Object2互为引用,如果采用引用计数法,则Object1和Object2由于互为引用,其引用计数一直为1,因而无法被回收。

 

5.1.2 可达性分析

为了解决引用计数法的循环引用问题,Java还采用了可达性分析来判断对象是否可以被回收。具体做法是首先定义一些GC Roots对象,然后以这些GCRoots对象作为起点向下搜索,如果在GC roots和一个对象之间没有可达路径,则称该对象是不可达的。不可达对象要经过至少两次标记才能判定其是否可以被回收,如果在两次标记后该对象仍然是不可达的,则将被垃圾收集器回收。

 

5.2 常用的垃圾回收算法

Java中常用的垃圾回收算法有标记清除(Mark-Sweep)、复制(Copying)、标记整理(Mark-Compact)和分代收集(GenerationalCollecting)这4种垃圾回收算法。

5.2.1 标记清除算法、

标记清除算法是基础的垃圾回收算法,其过程分为标记和清除两个阶段。在标记阶段标记所有需要回收的对象,在清除阶段清除可回收的对象并释放其所占用的内存空间

由于标记清除算法在清理对象所占用的内存空间后并没有重新整理可用的内存空间,因此如果内存中可被回收的小对象居多,则会引起内存碎片化的问题,继而引起大对象无法获得连续可用空间的问题。

 

 

5.2.2 复制算法

复制算法是为了解决标记清除算法内存碎片化的问题而设计的。复制算法首先将内存划分为两块大小相等的内存区域,即区域1和区域2,新生成的对象都被存放在区域1中,在区域1内的对象存储满后会对区域1进行一次标记,并将标记后仍然存活的对象全部复制到区域2中,这时区域1将不存在任何存活的对象,直接清理整个区域1的内存即可。

复制算法的内存清理效率高且易于实现,但由于同一时刻只有一个内存区域可用,即可用的内存空间被压缩到原来的一半,因此存在大量的内存浪费。同时,在系统中有大量长时间存活的对象时,这些对象将在内存区域1和内存区域2之间来回复制而影响系统的运行效率。因此,该算法只在对象为“朝生夕死”状态时运行效率较高。

5.2.3 标记整理算法

标记整理算法结合了标记清除算法和复制算法的优点,其标记阶段和标记清除算法的标记阶段相同,在标记完成后将存活的对象移到内存的另一端,然后清除该端的对象并释放内存。

5.2.4 分代收集算法

无论是标记清除算法、复制算法还是标记整理算法,都无法对所有类型(长生命周期、短生命周期、大对象、小对象)的对象都进行垃圾回收。因此,针对不同的对象类型,JVM采用了不同的垃圾回收算法,该算法被称为分代收集算法。分代收集算法根据对象的不同类型将内存划分为不同的区域,JVM将堆划分为新生代和老年代。新生代主要存放新生成的对象,其特点是对象数量多但是生命周期短,在每次进行垃圾回收时都有大量的对象被回收;老年代主要存放大对象和生命周期长的对象,因此可回收的对象相对较少。因此,JVM根据不同的区域对象的特点选择了不同的算法。目前,大部分JVM在新生代都采用了复制算法,因为在新生代中每次进行垃圾回收时都有大量的对象被回收,需要复制的对象(存活的对象)较少,不存在大量的对象在内存中被来回复制的问题,因此采用复制算法能安全、高效地回收新生代大量的短生命周期的对象并释放内存。JVM将新生代进一步划分为一块较大的Eden区和两块较小的Servivor区,Servivor区又分为ServivorFrom区和ServivorTo区。JVM在运行过程中主要使用Eden区和ServivorFrom区,进行垃圾回收时会将在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区,然后清理Eden区和ServivorFrom区的内存空间。

 

老年代主要存放生命周期较长的对象和大对象,因而每次只有少量非存活的对象被回收,因而在老年代采用标记清除算法。在JVM中还有一个区域,即方法区的永久代,永久代用来存储Class类、常量、方法描述等。在永久代主要回收废弃的常量和无用的类。JVM内存中的对象主要被分配到新生代的Eden区和ServivorFrom区,在少数情况下会被直接分配到老年代。在新生代的Eden区和ServivorFrom区的内存空间不足时会触发一次GC,该过程被称为MinorGC。在MinorGC后,在Eden区和ServivorFrom区中存活的对象会被复制到ServivorTo区,然后Eden区和ServivorFrom区被清理。如果此时在ServivorTo区无法找到连续的内存空间存储某个对象,则将这个对象直接存储到老年代。若Servivor区的对象经过一次GC后仍然存活,则其年龄加1。在默认情况下,对象在年龄达到15时,将被移到老年代。

5.2.5 分区收集算法

分区算法将整个堆空间划分为连续的大小不同的小区域,对每个小区域都单独进行内存使用和垃圾回收,这样做的好处是可以根据每个小区域内存的大小灵活使用和释放内存。分区收集算法可以根据系统可接受的停顿时间,每次都快速回收若干个小区域的内存,以缩短垃圾回收时系统停顿的时间,最后以多次并行累加的方式逐步完成整个内存区域的垃圾回收。如果垃圾回收机制一次回收整个堆内存,则需要更长的系统停顿时间,长时间的系统停顿将影响系统运行的稳定性。

 

5.3 java中垃圾收集器

Java堆内存分为新生代和老年代:新生代主要存储短生命周期的对象,适合使用复制算法进行垃圾回收;老年代主要存储长生命周期的对象,适合使用标记整理算法进行垃圾回收。因此,JVM针对新生代和老年代分别提供了多种不同的垃圾收集器,针对新生代提供的垃圾收集器有Serial、ParNew、Parallel Scavenge,针对老年代提供的垃圾收集器有Serial Old、Parallel Old、CMS,还有针对不同区域的G1,ZGC分区收集算法。

 

1. Serial (新生代单线程复制算法)

针对新生代的垃圾回收器,它是单线程执行的,是一款串行的垃圾回收器,采用的是复制算法。它的单线程并不仅仅指它在进行垃圾回收时是单线程或者单处理器执行,更深的含义是它在垃圾回收时,需要暂停其他所有的线程,造成 STW。 当 JVM 处于客户端模式下时,Serial 是默认的垃圾回收器,它的优点是简单高效。在内存资源受限的环境下,Serial 垃圾回收器相比其他垃圾回收器,它所占用的内存更小。对于单处理器的场景,Serial 处理器由于是单线程的,它省去了线程之间的资源竞争,因此会更加高效。 当使用参数 「-XX:+UseSerialGC」时,在开启使用Serial垃圾回收器同时,老年代的垃圾回收器为Serial Old。

 

2.Serial Old (老年代单线程标记整理算法)

和 Serial 一样,Serial Old 也是单线程执行的,是一款串行的垃圾回收器,不同的是 Serail Old 回收的是老年代区域,采用的算法是标记-压缩(整理)算法。在进行垃圾回收时,同样也会造成 STW 的现象。

 

3.ParNew(新生代多线程复制算法)

针对新生代区域的垃圾回收器,它是 Serial 垃圾收集器的多线程版本,即它是一款并行的垃圾回收器,支持多个垃圾回收线程同时并行回收垃圾,使用的也是复制算法。ParNew 的大部分参数配置和 Serial 收集器一样,但额外多了部分参数,如:可以通过参数 「-XX:ParallelGCThreads」 来指定并行的垃圾回收的线程个数,默认情况下,垃圾回收线程的个数与处理器的个数相等。在单处理器的系统中,ParNew 的性能并不一定比 Serial 好,因为线程的切换需要额外耗费 CPU 资源。 可以使用参数 「-XX:+UseParNewGC」 来开启使用 ParNew 进行垃圾回收。 ParNew 可以和 Serial Old 或者 CMS 搭配使用,然而从 JDK9 开始,官方已经移除了 ParNew 和 Serial Old 的组合使用方式,同时 JDK9 中将 CMS 标记为 Deprecated 状态,在 JDK14 中彻底移除 CMS,这就导致了 ParNew 将处于一个十分尴尬的地位,在高版本中既不能和 Serial Old 搭配使用,也将在未来无法和 CMS 搭配使用,这就导致了 ParNew 这款垃圾回收器必然消失在历史的舞台。

 

4. Parallel Scavenge (新生代多线程复制算法)

针对新生代的并行的垃圾回收器,它和 ParNew 虽然都是并行、针对新生代,但是它们的区别很大,Parallel Scavenge 是一款「吞吐量优先」的垃圾回收器。适用于那些期望尽可能的利用 CPU 资源、尽快完成程序的运算任务以及不太注重用户交互行为的场景。 Parallel Scavenge 提供了两个参数来精准地控制吞吐量,分别是 「MaxGCPauseMillis」 和 「GCTimeRatio」。

MaxGCPauseMillis 表示的是每次进行 GC 时,系统的最大停顿时间,如果配置了该参数,那么 JVM 在每次进行垃圾回收时,它会尽可能的将停顿时间控制在 MaxGCPauseMillis 之内。该参数并不是配置的越小越好,如果配置得很小,那么 JVM 可能会为了达到停顿时间控制在 MaxGCPauseMillis 之内的目的,选择以减小新生代区域的大小为代价,毕竟每次回收 300M 的空间所花的时间肯定比 500M 的短。「而 JVM 将新生代的内存区域调小后,带来的后果就是垃圾回收进行得更加频繁了,最后会导致系统的吞吐量下降」。通常情况下,我们无法精准地把控每次垃圾回收需要停顿的时间,所以该参数需要慎用,一不小心,配置的不合理,可能适得其反。

GCTimeRatio 表示的是每次 GC 的时间占用的比率是多少(具体计算方是:GCTimeRatio = 用户线程运行时间/ GC 线程运行时间),例如:如果 GCTimeRatio 参数的值配置的 19,那么 GC 运行的时间占总时间的 5%(1/(1+19))。JVM 通过这个参数来达到控制系统吞吐量的目的。

另外 JVM 还提供了一个参数,叫做「UseAdpativeSizePolicy」,它表示的是让 JVM「根据系统的运行情况来动态调整」新生代(Eden、S0、S1)、老年代的大小,我们只需要设置好最基本的内存参数以及 MaxGCPauseMillis(最大停顿时间)或者 GCTimeRatio(目标吞吐量)即可,不需要设置-XX:Xmn(新生代的内存大小)、-XX:SurvivorRatio (Surivivior区域的比例)等参数了,JVM 会根据系统运行时监控到相关信息,来动态进行调整。Parallel Scavenge 支持动态调整策略,这也算是它和 ParNew 收集器的另一大不同之处了。

 

5.Parallel Old (老年代多线程标记整理算法)

收集器的老年代版本,也是支持多线程的并行执行,它底层是基于标记-压缩(整理)算法来实现的。在 JDK6 中才开始提供,在 Parallel Old 出现之前,Parallel Scavenge 收集器只能配合着 Serial Old 使用,无法与 CMS 垃圾回收器配合使用,这是因为 Parallel Scavenge 与 CMS、Serial、ParNew 这些收集器的底层框架不一样,无法兼容导致的。而 Serial Old 又是单线程的垃圾收集器,在多处理器的场景下,性能不高,白白浪费了 Parallel Scavenge 并行的优点,好车配劣马,所以在 Parallel Old 出现之前,Parallel Scavenge 一直处于比较鸡肋的地位。目前,Parallel Scavenge 和 Parallel Old 的组合,其垃圾回收效果不错,是 JDK8 中默认的垃圾回收组合方式。

 

6.CMS(老年代多线程标记清除算法)

CMS 的全称是 Concurrent-Mark-Sweep 的缩写,翻译过来就是并发标记清除,它是一款「以低停顿时间为目标」的垃圾回收器,特点是低延时。

CMS 的工作原理大致分为四个步骤:初始标记、并发标记、重新标记、并发清除。

使用参数:「-XX:+UseConcMarkSweepGC」 即可开启使用 CMS 垃圾回收器。

「初始标记」指的是仅仅只标记出和 GC Roots 直接关联的对象,这个过程需要暂停所有的用户线程,因此会产生 STW。由于这一步仅仅标记和 GC Roots 直接关联的对象,因此这一步耗费的时间会很短,造成的停顿时间会很短。

「并发标记」。这一步是从和 GC Roots 直接关联的对象出发,开始遍历整个对象图引用链,这个过程是 GC 线程和用户线程并发执行的,因此不会造成 STW。这一步因为需要遍历所有对象的引用链,所以耗费时间较长,由于不会造成 STW,即使耗时较长,也没有关系。

「重新标记」。在并发标记阶段,用户线程仍然在运行,因此会改变对象之间的引用关系,那么在重新标记阶段,就是对并发标记的结果进行修正。把那些怀疑是垃圾,而实际不是垃圾的对象重新标记为存活对象。这一步需要暂停所有的用户线程,因此会造成 STW 的现象,这一步的耗时会比初始标记阶段长一些,但是远小于并发标记阶段的耗时。

「并发清除」。这一阶段是垃圾回收线程和用户线程一起并发执行,垃圾回收线程进行垃圾对象的清除,这一步耗时较长,但不会造成 STW。 整体上来看,CMS 垃圾回收器只有在初始标记阶段和重新标记阶段会造成用户线程的停顿,但是这两步都耗时较短,因此整体上,CMS 进行垃圾回收时,是低延时的。

 

7.G1(新生代和老年代多线程分区标记整理算法)

G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region来表示Eden、S0区,S1区,Old区等。 独立使用这些区域的内存资源并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,在垃圾回收过程中根据系统允许的最长垃圾收集时间,优先回收垃圾最多的区域。

在JDK1.7正式启用,是JDK9以后默认的垃圾回收器,被Oracle官方称为“全功能的垃圾收集器”

优点:

1. 并行与并发

  • 并行性:G1在回收期间,可以有多个GC线程同时工作(不再是一个GC线程),有效利用多核计算能力,此时用户线程处于STW

  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

2. 分区收集,支持新老代

同时兼顾年轻代和老年代。将堆空间分为若干个小区域(Region),这些区域中包含了逻辑上的年轻代和老年代。

3.可预测的停顿时间模型

回收时间可预测性,每次根据允许的时间优先回收价值最大的Region,尽可能提高收集效率

 

配置参数

-XX:+UseG1GC:手动指定使用G1收集器执行内存回收任务。

-XX:G1HeapRegionSize:设置每个Region的大小, 大小区间只能是1M、2M、4M、8M、16M和32M如果G1HeapRegionSize为默认值,则在堆初始化时计算Region的实践大小

-XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认值是200ms

-XX:ParallelGCThread:设置STW工作线程数的值,最多设置为8

-XX:ConcGCThreads:设置并发标记的线程数。

-XX:InitiatingHeapoccupancyPercent:设置触发并发GC周期的Java堆占用率阙值。超过此值,就触发GC。默认值是45。

文章:https://mp.weixin.qq.com/s/7CWbARimO5rFBHq4NtAt5Q

8.最前沿的低延时垃圾回收技术——ZGC,Shenandoah

ZGC 全称为 Z Garbage Collector,一款在保证吞吐量的情况下,追求低延时的垃圾回收器。

ZGC 是目前垃圾回收器中最前沿的技术,可惜的是目前 ZGC 还没有被正式使用,一直处于实验状态(Experiment)。从 JDK11 开始,被加入到了 OpenJDK 中,到目前 2020 年 4 月份发布的最新 Oracle JDK14 中,ZGC 依旧处于实验状态。

可以通过添加 JVM 参数:-XX:+UnlockExperimentalVMOptions 进行解锁实验状态。

Shenandoah 的目标是将垃圾回收的停顿时间控制在 10ms 以内,这意味着 Shenandoah 不仅需要在并发标记阶段实现并发,还需要在标记清除阶段实现并发。

Shenandoah 垃圾回收器是 RedHat 公司发明的,非 Oracle 公司官方实现,不是 Oracle 的亲儿子,因此在一定程度上遭到了“排挤”,只在开源的 OpenJDK12 中开始出现,而在商业版的 Oracle JDK12 中则没有。

ZGC文章:https://mp.weixin.qq.com/s/FkG0iweym0q8gGDx2b8iMg

Shenandoah文章: https://mp.weixin.qq.com/s/J9lOoihkfUKvJpt-7GSxXw

 

9.总结

随着JDK的不断更新,垃圾回收器的效率也越来越高,每一次JDK大版本的更新,必然会对垃圾回收器更新,截止到目前,JDK14可以使用的最新的垃圾回收器ZGC,在 JDK9 中,取消了 ParNew 与 Serial Old、Serial 与 CMS 的搭配组合,并且 CMS 被标记 Deprecated,在 JDK14 中被彻底移除。

评估GC性能的重要指标:

  • 吞吐量:运行用户代码的时间占总运行时间的比例

  • 暂停时间[STW]:执行GC线程时,用户线程被暂停的时间

  • 内存占用:Java堆区所占的内存大小

吞吐量

吞吐量就是CPU运行用户代码的时间与CPU总消耗时间的比值,即:

  • 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)

  • 吞吐量高是降低了内存回收的执行频率

比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞量就是99%

 

暂停时间

暂停时间是指一个时间段内应用程序线程暂停,让GC线程执行的状态,例如:

  • GC期间100毫秒的暂停时间,意味着在这100毫秒期间内没有应用程序线程是活动的

  • 暂停时间短,但是频繁的执行内存回收

这两个指标本质上是互斥的,我们只能在最大吞吐量优先的情况下,降低停顿时间。

 

想知道自己的GC算法,可以使用  java -XX:+PrintCommandLineFlags -version 查看

我的14默认的是G1

 

6 JVM的类加载机制

6.1 JVM的类加载阶段

JVM的类加载分为5个阶段:加载、验证、准备、解析、初始化。在类初始化完成后就可以使用该类的信息,在一个类不再被需要时可以从JVM中卸载。

 

1.加载

指JVM读取Class文件,并且根据Class文件描述创建java.lang.Class对象的过程。类加载过程主要包含将Class文件读取到运行时区域的方法区内,在堆中创建java.lang.Class对象,并封装类在方法区的数据结构的过程,在读取Class文件时既可以通过文件的形式读取,也可以通过jar包、war包读取,还可以通过代理自动生成Class或其他方式读取。

2.验证

主要用于确保Class文件符合当前虚拟机的要求,保障虚拟机自身的安全,只有通过验证的Class文件才能被JVM加载。

3.准备

主要工作是在方法区中为类的变量分配内存空间并设置不同数据类型的静态变量的默认值。

4.解析

JVM会将常量池中的符号引用替换为直接引用。

5.初始化

初始化阶段,执行类构造器<clinit>()方法的过程

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。

JVM规定,只有在父类的<client>方法都执行成功后,子类中的<client>方法才可以被执行。

在一个类中既没有静态变量赋值操作也没有静态语句块时,编译器不会为该类生成<client>方法。

 

哪些情况会进行类的初始化?主动引用

1.创建类的实例

2.访问类的静态变量

3.访问类的静态方法

4.反射(Class.forName)

5.子类初始化会先对父类初始化

6.虚拟机启动时,定义了main()方法的会先初始化

 

哪些情况不会进行类初始化?被动引用

1. 子类调用父类的静态变量,子类不会被初始化。只有父类被初始化。对于静态字段,只有直接定义这个字段的类才会被初始化.

2. 通过数组定义来引用类,不会触发类的初始化

3. 访问类的常量,不会初始化类

 

对于类的初始化我们搞点小demo瞧瞧,上东西~~

6.2 类加载器

JVM提供了3种类加载器,分别是启动类加载器、扩展类加载器和应用程序类加载器。

(1)启动类加载器:负责加载JAVA_HOME/lib目录中的类库,或通过-Xbootclasspath参数指定路径中被虚拟机认可的类库。

(2)扩展类加载器:负责加载JAVA_HOME/lib/ext目录中的类库,或通过java.ext.dirs系统变量加载指定路径中的类库。

(3)应用程序类加载器:负责加载用户路径(classpath)上的类库。

除了上述3种类加载器,我们也可以通过继承java.lang.ClassLoader实现自定义的类加载器。

6.3 双亲委派机制

JVM通过双亲委派机制对类进行加载。双亲委派机制指一个类在收到类加载请求后不会尝试自己加载这个类,而是把该类加载请求向上委派给其父类去完成,其父类在接收到该类加载请求后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中。若父类加载器在接收到类加载请求后发现自己也无法加载该类(通常原因是该类的Class文件在父类的类加载路径中不存在),则父类会将该信息反馈给子类并向下委派子类加载器加载该类,直到该类被成功加载,若找不到该类,则JVM会抛出ClassNotFoud异常。双亲委派类加载机制的类加载流程如下。

 

(1)将自定义加载器挂载到应用程序类加载器。

(2)应用程序类加载器将类加载请求委托给扩展类加载器。

(3)扩展类加载器将类加载请求委托给启动类加载器。

(4)启动类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,则交由扩展类加载器加载。

(5)扩展类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,则交由应用程序类加载器加载。

(6)应用程序类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,则交由自定义加载器加载。

(7)在自定义加载器下查找并加载用户指定目录下的Class文件,如果在自定义加载路径下未找到目标Class文件,则抛出ClassNotFoud异常。双亲委派机制的核心是保障类的唯一性和安全性。例如在加载rt.jar包中的java.lang.Object类时,无论是哪个类加载器加载这个类,最终都将类加载请求委托给启动类加载器加载,这样就保证了类加载的唯一性。如果在JVM中存在包名和类名相同的两个类,则该类将无法被加载,JVM也无法完成类加载流程。

 

展开阅读全文
加载中
点击加入讨论🔥(1) 发布并加入讨论🔥
打赏
1 评论
69 收藏
5
分享
返回顶部
顶部