JVM学习笔记

一、JVM基础

1、什么是虚拟机

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
目前常用的虚拟机是HotSpot虚拟机;


2、JVM与操作系统

Java是一门抽象程度特别高的语言,提供了自动内存管理等一系列的特性。这些特性直接在操作系统上实现是不大可能的,所以就需要JVM进行一番转换;
在这里插入图片描述


3、JVM、JRE、JDK的关系

在这里插入图片描述
JVM: JVM是Java程序能够运行的核心,但如果没有.class文件,JVM无法做任何事情,JVM相当于模拟了一个仅适用于Java的操作系统;

JRE: 仅仅是JVM,是无法完成一次编译,处处运行的。它需要一个基本的类库,比如怎么操作文件、怎么连接网络等。而Java体系很慷慨,会一次性将JVM运行所需的类库都传递给它。JVM标准加上实现的一大堆基础类库,就组成了Java的运行时环境,也就是我们通常说的JRE;

JDK: Java开发工具包,提供了javac、java、jar等。它是Java开发的核心


4、Java程序执行的过程

JIT: JIT 是 just in time 的缩写, 也就是即时编译器,是Java运行时环境的一个组件,通过在运行时将字节码编译为本机机器代码来提高 Java应用程序的性能;
在这里插入图片描述

当我们使用 Java 命令运行 .class 文件的时候,实际上就相当于启动了一个 JVM 进程。然后 JVM 会翻译这些字节码,它有两种执行方式。常见的就是解释执行,将 opcode + 操作数翻译成机器代码;另外一种执行方式就是 JIT,也就是我们常说的即时编译,它会在一定条件下将字节码编译成机器码之后再执行。

解释执行:运行时,逐行解释源代码,并且每次执行相同的代码都需要重新解释,导致额外的开销;但是因为不需要存储编译后的代码,所以占用内存少;
JIT:当JIT编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用;但是他需要一定的启动时间来编译代码,这可能导致程序的启动速度较慢。

机器码的运行效率肯定是高于Java解释器的。所以在实际情况中,为了运行速度以及效率,我们通常采用两者相结合的方式进行Java代码的编译执行。



二、Java虚拟机的内存管理

1、JVM整体架构

JVM分为五大模块:类装载器子系统、运行时数据区,执行引擎、本地方法接口和垃圾收集模块;
在这里插入图片描述

JVM内存运行时数据区中,JVM内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分;
线程共有部分:方法区、堆;
线程私有部分:栈、本地方法栈、程序计数器
在这里插入图片描述

对于方法区JDK7与8有所不同;

在这里插入图片描述

在jdk7以及jdk7之前,方法区被称为永久代(PermGen);

方法区Java8之后的变化:

  • 移除类永久代,替换为元空间;
  • 永久代中的类元信息转移到了本地内存,而不是虚拟机(虽然位置变了,但是元空间依旧是JVM内存的一部分);
  • 永久代中的字符串常量和类静态变量转移到了Java Heap;
  • 永久代参数->元空间参数;

Java8为什么要将永久代替换成元空间(Metaspace)?

  • 字符串存在永久代中,容易出现性能问题和内存溢出;
  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出;
  • 永久代会为GC带来不必要的复杂度,并且回收效率偏低;
  • Oracle可能会将HotSpot与JRockit合二为一,JRockit没有所谓的永久代;

2、JVM内存

2.1、PC程序计数器

什么是程序计数器

程序计数器:也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。


PC寄存器的特点

a、区别于计算机硬件的PC寄存器,两者略有不同。计算机用pc寄存器来存放“伪指令”或地址,而相对于虚拟机,pc寄存器它表现为一块内存,虚拟机的pc寄存器的功能也是存放伪指令,更确切的说是存放将要执行指令的地址;
b、当虚拟机正在执行的方法是一个本地方法的时候,jvm的pc寄存器存储的值是undefined;
c、程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个;
d、此内存区域是是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域;

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。

因此,为了线程切换后恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。



2.2、虚拟机栈

什么是虚拟机栈
Java虚拟机栈也是线程私有的,即生命周期和线程相同。Java虚拟机栈和线程同时创建,用于存储栈帧。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
在这里插入图片描述

什么是栈帧

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法的返回地址等信息。每个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出战的过程;

-Xss 为jvm启动的每个线程分配的内存大小;

局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)。

操作数栈
操作数栈也称操作栈,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。

动态链接
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接。
动态链接作用:将符号引用转换成直接引用;

方法返回地址
方法返回地址存放调用该方法的PC寄存器的值;



2.3、本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务;

特点:
a、本地方法栈加载native方法,native类方法存在的意义当然是填补Java代码不方便实现的缺陷而提出的;
b、虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的native方法服务;
c、是线程私有的,它的生命周期与线程相同,每个线程都有一个;



2.4、堆

2.4.1 简介

对于Java应用程序来说,Java堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

此内存区域的唯一目的就是存放对象实例,Java世界里几乎所有的对象实例都在这里分配内存。几乎是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

补充:
逃逸分析:是一种编译器优化技术,主要用于分析指针动态范围。在Java程序中,当一个变量(或对象)在方法中被分配后,其指针有可能被返回或全局引用,从而被其他方法或线程所引用,这种现象被称为指针或引用的逃逸;

2.4.2 堆的特点

1、Java虚拟机所管理的内存中最大的一块;
2、堆是jvm所有线程共享的;
堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer(TLAB)
3、在虚拟机启动的时候创建;
4、唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存;
5、Java堆是垃圾收集器管理的主要区域;
6、很多时候Java堆也称为GC堆,从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为:Eden空间、From Survivor空间、To Survivor空间;
7、Java堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制);
8、方法结束后,堆中对象不会马上移出仅仅在垃圾回收的时候才移除;
9、如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常;

2.4.3 设置堆空间大小

-Xmx10m:设置Java应用最大可用内存为10M;
-Xms2m:设置最小内存为2M;

Java7 Hotspot虚拟机中将堆内存分为3部分:

  • 年轻代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Generation)

Java8以后
由于方法区的内存不再分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了,在Java11正式发布以后,官方文档中没有提到“永久代”,而只有年轻代和老年代;

年轻代
年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成一个Eden Space和两个Suvivor Space(from和to)。

年老代
年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁;

配置新生代和老年代堆结构占比

默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3;
修改占比 - XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5;
Eden空间和另外两个Survivor空间占比分别为8:1:1;
可以通过操作选项 - XX:SurvivorRatio调整这个空间比例。比如:-XX:SurvivorRatio=8;
几乎所有的Java对象都在Eden区创建,但80%的对象生命周期都很短,创建出来就会被销毁;
在这里插入图片描述

2.4.4 对象分配过程

JVM设计者不仅需要考虑到内存如何分配,在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,因此还需要考虑GC执行完内存回收后是否在空间中产生内存碎片

分配过程
1、new的对象先放在伊甸园区,该区域有大小限制;
2、当伊甸园区域填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中不再被其他对象引用的对象进行销毁,再加载新的对象放到伊甸园区;
3、然后将伊甸园区中的剩余对象移动到幸存者0区;
4、如果再次触发垃圾回收,此时上次幸存下来的放在幸存者0区的,如果没有回收,就会放到幸存者1区;
5、如果再次经历垃圾回收,此时后重新返回幸存者0区,接着再去幸存者1区;
6、如果累计次数达到默认的15次,就会进入老年区;可以通过设置参数,调整阈值 - XX:MaxTenuringThreshold=16;
7、老年区内存不足,就会触发Major GC进行老年区的内存清理;
8、如果老年区执行了Major GC之后仍然没有办法进行对象的保存,就会报OOM异常;

2.4.5 堆GC

Java中的堆是GC收集垃圾的主要区域。GC分为两种:一种是部分收集器(Partial GC),另一类是整堆收集器(Full GC)。
部分收集器:不是完整收集java堆的收集器,它又分为:

  • 新生代收集(Minor GC/Young GC):只负责新生代的垃圾收集;
  • 老年代收集(Major GC/Old GC):只负责老年代的垃圾收集(CMS GC但对回收老年代);
  • 混合收集(Mixed GC):收集整个新生代及老年代的垃圾收集(G1 GC会混合回收);
    整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集器;

年轻代GC(Minor GC)触发机制:

  • 年轻代空间不足,就会触发Minor GC,这里年轻代指的是Eden区满,Survivor满不会触发GC;
  • Minor GC会引发STW(stop the world),暂停其他用户的线程,等垃圾回收结束,用户的线程才恢复;

老年代GC(Major GC)触发机制:

  • 老年代空间不足时,会尝试触发Minor GC,如果空间还是不足,则触发Major GC;
  • 如果Major GC,内存仍然不足,则报错OOM;
  • Major GC的速度比Minor GC慢10倍以上;

Full GC触发机制:

  • 调用System.gc(),系统会执行Full GC,不是立即执行;
  • 老年代空间不足;
  • 方法区空间不足;
  • 通过Minor GC进入老年代平均大小大于老年代可用内存;

2.5 元空间

在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代来进行垃圾回收。但从JDK1.8开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。HotSpot取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间而已。

2.5.1 永久代与元空间的区别:
  • 存储位置不同:永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存。
  • 存储内容不同:在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。现在类的元信息存储在元空间中,但是静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。
2.5.2 为什么要废弃永久代,引入元空间?

相比于之前的永久代划分,Oracle为什么要做这样的改进呢?
1、在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池等的大小和方法数量等,-XX:MaxPermSize指定太小很容易造成永久代内存溢出;
2、移除永久代是为融合HotSpot VM与JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代;
3、永久代会为GC带来不必要的复杂度,并且回收效率偏低;

2.5.3 Metaspace相关参数
  • -XX:metaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值;
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小,==其最大可利用空间是整个系统内存的可用空间;

2.6 方法区

2.6.1 方法区的理解:

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

元空间、永久代是方法区具体的落地实现。方法区看作是一块独立于Java堆的内存空间,它主要是用来存储所加载的类信息的

在这里插入图片描述

方法区的特点

  • 方法区与堆一样是各个线程共享的内存区域;
  • 方法区在JVM启动的时候就会被创建并且它实际的物理内存空间和Java堆一样都可以不连续;
  • 方法区的大小跟堆空间一样可以选择固定大小或者动态变化;
  • 方法区的对象决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出OOM异常;
  • 关闭JVM就会释放这个区域的内存;
2.6.2 方法区结构

方法区的内部结构
在这里插入图片描述
类加载器将class文件加载到内存之后,将类的信息存储到方法区中。

方法区中存储的内容:

  • 类型信息(域信息、方法信息);
  • 运行时常量池;
    在这里插入图片描述
2.6.3 方法区设置

类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名);
  • 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类);
  • 这个类型的修饰符(public,abstract,final的某个子集);
  • 这个类型直接接口的一个有序列表;

域信息
域信息,即为类的属性,成员变量
JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序;
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集);

方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称方法的返回类型(或void);
  • 方法参数的数量和类型(按顺序);
  • 方法的修饰符public、private、protected、static、final、synchronized、native、abstract的一个子集;
  • 方法的字节码bytecodes、操作数栈、局部变量表及大小(abstract和native方法除外);
  • 异常表(abstract和native方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引;



2.7 运行时常量池

常量池vs运行时常量池

字节码文件中,内部包含了常量池;
方法区中,内部包含了运行时常量池;
常量池:存放编译期间生成的各种字面量与符号引用;
运行时常量池:常量池表在运行时的表现形式;

编译后的字节码文件中包含了类型的信息、域信息、方法信息等。通过ClassLooader将字节码文件的常量池中的信息加载内存中,存储在方法区的运行时常量池中;

理解为字节码中的常量池Constant pool只是文件信息,它想要执行就必须加载到内存中。而Java程序是靠JVM,更具体的来说是JVM的执行引擎来解释执行的。执行引擎在运行时常量池中取数据,被加载的字节码常量池中的信息是放到了方法区的运行时常量池中。

它们不是一个概念,存放的位置是不同的。一个在字节码文件中,一个在方法区中。

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant pool table),包括各种字面量和对类型、域和方法的符号引用。

常量池,可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

字面量:
描述:指由字母、数字等构成的字符串和数值常量,字面量只可以右值出现;
比如:int a = 1 这里a为左值,1为右值,1就是字面量。

符号引用:
描述:符号引用是编译原理中的概念,是相对于直接引用来说,主要包括了以下三大类;

  • 类和接口的全限定名;
  • 字段的名称和描述符;
  • 方法的名称和描述符;
    比如:int a = 1 a就是字段名称,就是一种符号引用;

1、运行时常量池是一个统称 也包括字符串常量池,但是字符串常量池放的只是字符串,而运行时常量池中,还包括类信息,属性信息,方法信息,以及其他基础类型的的常量池比如int,long等;
2、jdk1.7之前,运行时常量池(包含着字符串常量池)都在方法区,具体的hotspot虚拟机实现为永久代;
3、jdk1.7阶段,字符串常量池从方法区移到堆中,运行池常量池剩下的部分依旧在方法区(剩下类信息、属性信息、方法信息等),同样是hotspot中的永久代;
4、jdk1.8, 方法区的实现从永久代变成了元空间,因此 字符串常量池依然在堆中,运行时常量池在方法区,hotspot中的元空间(metaspace);

String::intern()方法是不是有点多余了,字符串反正会被放入常量池中?
String::intern()方法并非多余。它的主要目的是确保在常量池中只有一个该字符串的实例,并返回该字符串的引用。当需要共享字符串实例以节省内存时,这个方法非常有用。

例如,当你有两个字符串变量,它们包含相同的字符序列时,如果你调用intern()方法,它们将指向常量池中同一个字符串实例,而不是各自创建一个新的实例。

运行时常量池
只有在运行时被加载到内存后,这些符号才有对应的内存地址,那么这些常量池一旦被装入内存就变成运行时常量池,对应的符号引用会转变为被加载到内存区域的代码的直接引用,也就是我们说的动态链接

常量池表Constant pool table:
在这里插入图片描述
在方法区中对常量池表的符号引用:
在这里插入图片描述



3.OutOfMemoryError异常;

3.1 Java堆异常

堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生OutOfMemoryError的异常。

新产生的对象最初分配在新生代,新生代满后会进行一次Minor GC,如果Minor GC后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行Full GC,之后如果空间还不足以存放新对象则抛出OutOfMemoryError异常。
常见原因:

  • 内存中加载的数据过多,如一次从数据库中取出过多数据;
  • 集合中对象引用过多且使用后没有清空;
  • 代码中存在死循环或循环产生过多重复对象;
  • 堆内存分配不合理;

3.2 虚拟机栈和本地方法栈溢出

关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:
1、如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
2、如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常;

创建线程导致内存溢出异常:
由于在windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上,无限制地创建线程会对操作系统带来很大压力,可能会由于创建线程数量过多而导致操作系统假死;

3.3 运行时常量池和方法区溢出

运行时常量池内存溢出

可以使用String::intern(),这是一个本地方法,它的作用是如果字符串常量池中已经包含了一个等于此String对象的字符串,则返回常量池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用;

字符串创建有两种方式:
1、String s1 = “hello”;
2、String s2 = new String(“hello”);

两者的区别:
第一种方式,首先JVM会去字符串常量池中寻找有没有“hello“这个字符串,如果有,则将常量池中存放”hello"的地址直接赋值给s1;如果没有,则在常量池中开辟一块空间存放“hello",然后将常量池中“hello"空间地址直接赋值给s1;
这种方法开辟的空间为0个或1个

第二种方式,首先JVM会去字符串常量池里寻找有没有“hello"字符串,如果有,就会在堆中重新开辟一个空间,用来存放常量池中“hello“的空间地址,然后再让s2指向这个堆中开辟的空间的地址;若没有,它会先在常量池中开辟一块空间用来存放“hello“,然后在堆中开辟一块空间,用来存放常量池中”hello“的空间地址。这种方法,无论常量池中有没有“hello“,都会在堆中开辟一块空间;
这种方法开辟的空间为1个或2个

方法区内存溢出

方法区的其他部分的内容,方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。测试:可以产生大量的类去填满方法区,直到溢出为止。比如使用CGLib的方式;

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。




三、JVM加载机制详解

1.类装载子系统

1.1 类装载子系统介绍

1、类加载子系统负责从文件系统或是网络中加载.class文件,class文件在文件开头有特定的文件标识;
2、把加载后的class类信息存放于方法区,除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是class文件中常量池部分的内存映射);
3、ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定;
4、如果调用构造器实例化对象,则该对象存放在堆区;
在这里插入图片描述

1.2 类加载器ClassLoader角色

1、class file存在于本地硬盘上,可以理解为设计师画在纸上的模版,而最终这个模版在执行的时候是要加载到JVM当中来,根据这个文件实例化出n个一模一样的实例;
2、class file加载到JVM中,被称为DNA元数据模版;
3、在.class文件–>JVM–>最终成为元数据模版,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员角色;
在这里插入图片描述

1.3 类加载的执行过程

类使用的7个阶段
类从被加载到虚拟机内存中开始,到卸载出内存,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中验证、准备、解析 3个部分统称为连接;
在这里插入图片描述
图中,加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定)。接下来讲解加载、验证、准备、解析、初始化五个步骤,这五个步骤组成了一个完成的类加载过程。使用没什么好说的,卸载属于GC的工作。

1.3.1 加载

加载是类加载的第一个阶段。有两种时机会触发类加哎:

预加载

虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面的内容是程序运行时非常常用的,像java.lang.、java.util.、java.io.*等等,因此随着虚拟机一起加载。要证明这一点可以写一个空的main函数,设置虚拟机参数为“- XX:+TraceClassLoading”来获取类加载信息,运行一下:
在这里插入图片描述

运行时加载

虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的全限定名来加载这个类。

加载阶段做了三件事情:

  • 获取.class文件的二进制流;
  • 将类信息、静态变量、字节码、常量这些.class文件中的内容放入方法区中;
  • 在内存中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。一般这个Class是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的;

虚拟机规范对这三点的要求并不具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如第一条,根本没有指明二进制字节流要从哪里来,怎么来,因此单单就这一条,就能变出许多花样来;

  • 从zip包中获取,这就是以后jar、ear、war格式的基础;
  • 从网络中获取,典型应用就是Applet;
  • 运行时计算生成,典型应用就是动态代理技术;
  • 由其他文件生成,典型应用就是JSP,即由JSP生成对应的.class文件;
  • 从数据库中读取,这种场景比较少见;

总而言之,在类加载整个过程中,这部分是对于开发者来说可控性最强的一个阶段;


1.3.2 连接

连接包含三个步骤:分别是验证、准备、解析三个过程;

验证

连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全;

Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生.class文件。在字节码语言层面上,Java代码至少从语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

验证阶段将会做以下几个工作:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备

准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配。关于这一点,有两个地方注意一下:

  • 这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中;
  • 这个阶段赋初始值的变量指的是那些不被final修饰的static变量,比如“public static int value = 123",value在准备阶段过后是0而不是123,给value赋值为123的动作将在初始化阶段才进行;比如:“public static final int value=123",就不一样了,在准备阶段,虚拟机就会给value赋值为123。

各个数据类型的零值如下表:

数据类型零值
int0
long0L
short(short)0
char“\u0000”
byte(byte)0
booleanfalse
float0.0f
double0.0d
referencenull

面试题:下面有两段代码,code1将会输出0,而code2将会无法编译通过。
code1:

public class Code1 {
    static int a;
    public static void main(String[] args) {
        System.out.println(a);
    }
}

code2:

public class Code2 {
    public static void main(String[] args) {
        int a;
        System.out.println(a);
    }
}

注意:
这是因为局部变量不像类变量那样存在准备阶段。类变量有两次初始值的过程,一次在准备阶段,赋予初始值(也可以是指定值);另外一次在初始化阶段,赋予程序员定义的值。
因此,即时程序员没有为类变量赋值也没有关系,它仍然有一个默认的初始值,但局部变量就不一样了,如果没有给它赋初始值,是不能使用的。

解析Resolution

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。来了解一下符号引用和直接引用有什么区别:
1、符号引用
符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。
这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:

  • 类和接口的全限定名;
  • 字段的名称和描述符;
  • 方法的名称和描述符;

总而言之,符号引用是对于类、变量、方法的描述。符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。

2、直接引用
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位目标的句柄。直接引用和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机示例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在在内存中了。

解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要。那这个阶段都做了哪些工作呢?大致可以分为:

  • 类或接口的解析;
  • 类方法解析;
  • 接口方法解析;
  • 字段解析;

1.3.3 初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式布局参与外,其余动作都完全是由Java虚拟机来主导控制。知道初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

初始化阶段就是执行类构造器()方法的过程。类构造器方法并不是程序员在Java代码中直接编写的方法,它是javac编译器的自动生成物,类构造器方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如:

public class StaticTest {
    static {
        a = 2; //给变量赋值可以正常编译通过
        System.out.println(a); //这句编译器会提示"非法向前引用"
    }
    static int a = 1;
}

先父后子,先静态后普通


1.4 <cinit>与<init>

<cinit>方法和<init>方法有什么区别?
主要是为了弄明白类的初始化和对象的初始化之间的差别。
static字段和static代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在在方法区,在同一个类加载器下,这些信息有一份就够了,所以上面的static代码块只会执行一次,它对应的是方法。



2.类加载器

2.1 类加载器的作用

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。

注意:JVM主要在程序第一次主动使用类的时候,才会去加载该类,也就是说,JVM并不是在一开始就把一个程序中所有的类都加载到内存中,而是在用的时候才把它加载进来,而且只加载一次。

2.2 类加载器分类

1、jvm支持两种类型的加载器,分别是引导类加载器和自定义加载器;
2、引导类加载器是由c/c++实现的,自定义加载器是由Java实现的;
3、jvm规范定义自定义加载器是指派生于抽象类ClassLoader的类加载器;
4、按照这样的加载器的类型划分,在程序中我们最常见的类加载器是:引导类加载器BootStrapClassLoader、自定义类加载器(Extension Class Loader、System Class Loader、User_defined ClassLoader)
在这里插入图片描述
启动类加载器
1、这个类加载器使用c/c++实现,嵌套在jvm内部;
2、它用来加载Java的核心类库(JAVA_HOME/jre/lib/rt.jar、resource.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类;
3、并不继承自java.lang.ClassLoader,没有父加载器;

扩展类加载器
1、java语言编写,由sum.misc.Launcher$ExtClassLoader实现;
2、从java.ext.dirs系统属性所指定的目录下加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载;派生于ClassLoader;
3、父类加载器为启动类加载器;

系统类加载器
1、java语言编写,由sun.misc.Lanucher$AppClassLoader实现;
2、该类加载是程序中默认的加载器,一般来说,Java应用的类都是由它来完成加载的,它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库,派生于ClassLoader;
3、父类加载器为扩展类加载器;
4、通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器;

用户自定义类加载器
在日常的Java开发中,类加载几乎是由三种加载器配合执行的,在必要时我们还可以自定义类加载器,来定制类的加载方式。



3.双亲委派模型

3.1 什么是双亲委派

双亲委派模型工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。

在这里插入图片描述

3.2 为什么需要双亲委派模型?

为什么需要双亲委派模型呢?假设没有双亲委派模型,试想一个场景:

黑客自定义一个java.lang.String类,该String类具有系统的String类一样的功能,只是在某个函数稍修改。比如equals函数,这个函数经常使用,如果在这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到JVM中。此时,如果没有双亲委派模型,那么JVM就可能误以为黑客自定义的java.lang.String类是系统的String类,导致“病毒代码”被执行。

而有了双亲委派模型,黑客自定义的java.lang.String类永远不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。

或许你会想,我在自定义的类加载器里面强制加载自定义的java.lang.String类,不去通过调用父类加载器不就好了吗》确实,这样是可行。但是,在JVM中,判断一个对象是否是某个类型时,如果该对象的实际类型与待比较的类型加载器不同,那么会返回false。

举个简单的例子:

ClassLoader1、ClassLoader2都加载java.lang.String类,对应Class1、Class2对象。那么Class1对象不属于ClassLoader2对象加载的java.lang.String类型。


3.3 如何实现双亲委派模型

双亲委派模型的原理很简单,实现也简单。每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加载。其实ClassLoader类默认的loadClass方法已经帮我们写好了,我们无需去写。




四、垃圾回收机制及算法

1、垃圾回收概述

垃圾回收概述

说起垃圾收集(Garbage Collection,简称GC),有不少人把这项技术当作Java语言的伴生产物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始用内存动态分配和垃圾收集技术的语言。垃圾收集需要完成的三件事:

哪些内存需要回收?

什么时候回收?

如何回收?

java垃圾回收的优缺点:

优点:
1、不需要考虑内存管理;
2、可以有效的防止内存泄漏,有效的利用可使用的内存;
3、由于垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”;

缺点:
Java开发人员不了解自动内存管理,内存管理就像一个黑匣子,过度依赖就会降低我们解决内存溢出/内存泄漏等问题的能力。



2、垃圾回收-对象是否已死

2.1 判断对象是否存活-引用计数算法

引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当此对对象被某个地方引用时,计数值+1,引用失效时-1,所以当计数值为0时表示对象已经不能被使用。引用计数算法大多数情况下是个比较不错的算法,简单直接,也有一些著名的应用案例但是对于Java虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相互循环引用的问题。

优点:
实现简单,执行效率高,很好的和程序交织。

缺点:
无法检测出循环引用

譬如有A和B两个对象,他们都互相引用,除此之外都没有任何对外的引用,那么理论上A和B都可以被作为垃圾回收掉,但实际如果采用引用计数算法,则A、B的引用计数都是1,并不满足被回收的条件,如果A和B之间的引用一直存在,那么就永远无法被回收了

但是在Java程序这两个对象仍然会被回收,因为java中并没有使用引用计数算法。

2.2 判断对象是否存活-可达性分析算法

2.2.1 可达性分析算法

在主流的商用程序语言如:java、C#等的主流实现中,都是通过可达性分析来判断对象是否存活的。此算法的基本思路就是通过一些列的“GC Roots“的对象作为起始点,从起始点开始向下搜集到对象的路径。搜索所经过的路径称为引用链,当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。
在这里插入图片描述
在Java语言中,可作为GC Roots的对象包括下面几种:

  • 栈帧中的局部变量表中的reference引用所引用的对象;
  • 方法区中static静态引用的对象;
  • 方法区中final常量引用的对象;
  • 本地方法栈中JNI(Native方法)引用的对象;
  • java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如:NullPointException、OutOfMemoryError)等,还有系统类加载器;
  • 所有被同步锁(synchronized关键字)持有的对象;
  • 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等;
    -在这里插入图片描述
    详情请见:https://blog.csdn.net/chengqiuming/article/details/118973746
2.2.2 JVM之判断对象是否存活

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

第一次标记:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

没有必要:
假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

有必要:
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己–只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
在这里插入图片描述
注意:
Finalizer线程去执行它们的finalize()方法,这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finaliize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。



3.垃圾收集算法

3.1 分代收集理论

思想也很简单,就是根据对象的生命周期将内存划分,然后进行分区管理。当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
1、弱分代假说:绝大多数对象都是朝生夕灭的;
2、强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡;

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存锤。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域—因而才有了“Minor GC"、“Major GC“、”Full GC“这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法–因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。

部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集:指目标只是新生代的垃圾收集;
  • 老年代收集:指目标只是老年代的垃圾收集,目前只有CMS收集器会有单独收集老年代的行为;
  • 混合收集:指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为;

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.2 标记-清除算法

什么是标记-清除算法?

最早出现也是最基础的垃圾收集算法是“标记-清除”算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

标记过程就是对象是否属于垃圾的判定过程,这在前一节讲述垃圾对象标记判定算法时其实已经介绍过了。之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
在这里插入图片描述
标记-清除算法有两个不足之处:

第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;

第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。


3.3 标记-复制算法

什么是标记-复制算法

标记-复制算法常被简称为复制算法。

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,在1969年Fenichel提出了一种称为“半区复制”的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可;
在这里插入图片描述
但是这种算法也有缺点:

  • 需要提前预留一半的内存区域用来存放存活的对象(经过垃圾收集后还存活的对象),这样导致可用的对象区域减小一半,总体的GC更加频繁了;
  • 如果出现存活对象数量比较多的时候,需要复制较多的对象,成本上升,效率降低;
  • 如果99%的对象都是存活的(老年代),那么老年代是无法使用这种算法的;

注意事项:
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代“朝生夕灭:的特点做了更量化的诠释–新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,即每次新生代中可用内存空间为新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。


3.4 标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”算法,其中标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
在这里插入图片描述
是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。



4.垃圾收集器

4.1 垃圾收集器概述

1、垃圾回收器与垃圾回收算法

垃圾回收算法分为两类:第一类算法判断对象生死算法,如引用计数法、可达性分析算法;第二类收集死亡对象方法有四种,如标记-清除算法、标记-复制算法、标记整理算法。一般的实现采用分代回收算法,根据不同代的特点应用不同的算法。垃圾回收算法是内存回收的方法论。垃圾收集器是算法的落地实现。和回收算法一样,目前还没有出现完美的收集器,而是要根据具体的应用场景选择最合适的收集器,进行分代收集。

2、垃圾收集器分类

在这里插入图片描述
串行垃圾回收
串行垃圾回收是为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程,不适合交互性强的服务器环境。
在这里插入图片描述

并行垃圾回收
多个垃圾收集器线程并行工作,同样会暂停用户线程,适用于科学计算、大数据后台处理等多交互场景。
在这里插入图片描述

并发垃圾回收(CMS)
用户线程和垃圾回收线程同时执行,不一定是并行的,可能是交替执行,可能一边垃圾回收,一边运行应用线程,不需要停顿用户线程,互联网应用程序中经常使用,适用对响应时间有要求的场景。
在这里插入图片描述

G1垃圾回收
G1垃圾回收器将堆内存分割成不同的区域然后并发地对其进行垃圾回收。

3、七种垃圾收集器及其组合关系

根据分代思想,我们有七种主流的垃圾回收器
在这里插入图片描述
新生代垃圾收集器:Serial、ParNew、Parallel Scavenge
老年代垃圾收集器:Serial Old、Parallel Old、CMS
整理收集器:G1

垃圾收集器的组合关系
在这里插入图片描述
JDK8中默认使用组合是:Parallel Scavenge GC、ParallelOld GC;

JDK9默认是用G1为垃圾收集器;

JDK14弃用了:Parallel Scavenge GC、Parallel OldGC

JDK14移除了CMS GC

4、GC性能指标

吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%;
暂停时间:执行垃圾回收时,程序的工作线程被暂停的时间;
内存占用:Java堆所占内存的大小;
收集频率:垃圾收集的频次;


4.2 Serial收集器

单线程收集器,“单线程”的意义不仅仅说明它只会使用一个CPU或一个收集线程去完成垃圾收集工作;
更重要的是它在垃圾收集的时候,必须暂停其他工作线程,直到垃圾收集完毕;

“Stop The World“这个词也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。
在这里插入图片描述
Serial收集器也并不是只有缺点;Serial收集器由于简单并且高效,对于单CPU环境来说,由于Serial收集器没有线程间的交互,专心做垃圾收集自然可以做获得最高的垃圾收集效率;
使用方式:-XX:+UseSerialGC

4.3 ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

ParNew收集器的工作过程:
在这里插入图片描述
ParNew收集器在单CPU服务器上的垃圾收集效率绝对不会比Serial收集器高;
但是在多CPU服务器上,效果会明显比Serial好;
使用方式:- XX:UseParNewGC
设置线程数:XX:ParllGCThreads


4.4 Parallel Scavenge收集器

1、什么是Parallel Scavenge
又称为吞吐量优先收集器,和ParNew收集器类似,是一个新生代收集器。使用复制算法的并行多线程收集器。Paralle Scavenge是Java1.8默认的收集器,特点是并行的多线程回收,以吞吐量优先。

2、特点

  • Parallel Scavenge收集器的目标是达到一个可控制的吞吐量;
  • 自适应调节策略,自动指定年轻代、Eden、Survivor区的比例;

3、使用场景
适合后台计算,交互不多的任务,如批量处理,订单处理,科学计算等。

4、参数

  • 使用方式:- XX:+useParallelGC
  • 分别是控制:最大垃圾收集器停顿时间-XX:MaxGCPauseMillis
  • 吞吐量大小- XX:GCTimeRatio
  • 设置年轻代线程数XX:ParllGCThreads
  • 与Paralle Scavenge收集器有关的还有一个参数:-XX:+uuseAdaptiveSizePolicy(有了这个参数之后,就不要手动指定年轻代、Eden、Survivor区的比例,晋升老年代的对象年龄等,因为虚拟机会根据系统运行情况进行自适应调节

parallel scavenge 与parnew 区别:
两者都是复制算法,都是并行处理,但是不同的是,paralel scavenge 可以设置最大gc停顿时间(-XX:MaxGCPauseMills)以及gc时间占比(-XX:GCTimeRatio),


4.5 Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

特点:

  • 针对老年代
  • 采用“标记-整理”算法
  • 单线程收集

执行流程:
在这里插入图片描述
应用场景:主要用于Client模式
1、在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
2、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用;

参数设置
使用方式:-XX:+UseSerialGC

注意事项
需要说明一下,Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集球,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的,所以在官方的许多资料中都是最直接以Seriial Old代替PS MarkSweep进行讲解。


4.6 Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。

1、Parallel Old收集器的工作过程:
在这里插入图片描述
2、应用场景
JDK1.6及之后用来代替老年代的Serial Old收集器;
特别是在Server模式,多CPU的情况下;
这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的“给力”应用组合;

3、设置参数
“- XX:UseParallelOldGC“:指定使用Parallel Old收集器;

4.7 CMS收集器

4.7.1 CMS垃圾回收器

CMS是以获取最短垃圾收集停顿时间为目标的收集器,CMS收集器的关注点尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合与用户交互的程序,目前很大一部分的Java应用几种在互联网的B/S系统服务器上,这类应用尤其注重服务器的响应速度,系统停顿时间最短,给用户带来良好的体验,CMS收集器使用的算法是标记-清除算法实现的;

4.7.2 CMS垃圾收集过程

整个过程分为4个步骤:
1、初始标记
2、并发标记
3、重新标记
4、并发清除
其中初始标记重新标记都需要StopTheWorld
在这里插入图片描述
CMS整个过程比之前的收集器要复杂,整个过程分为4个阶段即初始标记、并发标记、重新标记、并发清除;

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

由于最消耗时间的并发标记与并发清除阶段都不需要暂停工作,因为整个回收阶段是低停顿(低延迟)的;

4.7.3 并发可达性分析

当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都是基于一个能保障一致性的快照中才能够进行分析。

垃圾回收器的工作流程大体如下:
1、标记出哪些对象存活,哪些是垃圾(可回收);
2、进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用;

三色标记

三色标记作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成一下三种颜色:
要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象;
在这里插入图片描述
我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:

  • 白色:尚未访问过;
  • 黑色:本对象已访问过,而且本对象引用到其他的对象也全部访问过了;
  • 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完,全部访问后,会转换为黑色。

在这里插入图片描述
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
1、初始时,所有对象都在【白色集合】中;
2、将GC Roots直接引用到的对象挪到【灰色集合】中;
3、从灰色集合中获取对象:

  • 将本对象引用到的其他对象全部挪到【灰色集合】中;
  • 将本对象挪到【黑色集合】里面;
    4、重复步骤3,直到【灰色集合】为空时结束;
    5、结束后,仍在【白色集合】的对象即为GC Roots不可达,可以进行回收;

注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

当Stop The World(以下简称STW)时,对象间的引用是不会发生变化的,可以轻松完成标记。而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。



4.8 G1收集器

4.8.1 G1垃圾收集器简介

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

4.8.2 G1收集器特点

1、G1把内存划分为多个独立的区域Region;
2、G1仍然保留分代思想,保留了新生代和老年代,但他们不再是物理隔离,而是一部分Region的集合;
3、G1能够充分利用多CPU、多核环境硬件优势,尽量缩短STW;
4、G1整体采用标记-整理算法,局部采用是复制算法,不会产生内存碎片;
5、G1的停顿可预测,能够明确指定一个时间段内,消耗在垃圾收集上的时间不超过设置时间;
6、G1根据各个Region里面垃圾的价值大小,会维护一个优先列表,每次根据允许的时间来回收价值最大的区域,,从而保证在有限时间内高效的收集垃圾;

4.8.3 Region区域

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间、或者老年代空间。
在这里插入图片描述
将整个堆空间细分为若干个小的区域;
1、使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB;
2、虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续;
3、G1垃圾收集器还增加了一个种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region就放到H。一般被视为老年代;

4.8.4 G1 GC过程

G1提供了两种GC模式,Young GC和Mixed GC,两种均是完全Stop The World的。

  • Young GC:选定所有年轻代里代Region。通过控制年轻代的region个数,即年轻代内存大小,来控制Young GC的时间开销;
  • Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

在G1 GC垃圾回收的过程一共有四个阶段:
初始标记:和CMS一样只标记GC Roots直接关联的对象;
并发标记:进行GC Roots Traceing(追踪)过程;
最终标记:修改并发标记期间,因程序运行导致发生变化的那一部分对象;
筛选回收:根据时间来进行价值最大化收集;

下面是G1收集的示意图:
在这里插入图片描述
CMS收集器和G1收集器的区别
区别一:使用的范围不一样
CMS收集器是老年代收集器,可以配合新生代的serial和ParNew收集器一起使用

区别二:STW(stop the world)的时间
CMS收集器:以最小的停顿时间为目标的收集器
G1收集器:可预测垃圾回收的停顿时间(建立可预测的停顿时间模型)

区别三:垃圾碎片
CMSS收集器:是使用标记-清除算法进行的垃圾回收,容易产生碎片
G1收集器:是使用的标记-整理算法,进行了空间整合,降低了内存空间碎片

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值