类加载

类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识。
ClassLoader只负责加载class文件,ExecutionEngine决定class文件是否可以运行。

Java程序对类的使用分为主动使用被动使用两种

主动使用,分为七种情况

  • 创建类的实例
  • 访问某各类或接口的静态变量,或者对静态变量赋值
  • 调用类的静态方法
  • 反射 比如Class.forName(com.dsh.jvm.xxx)
  • 初始化一个类的子类
  • java虚拟机启动时被标明为启动类的类
  • JDK 7 开始提供的动态语言支持:
    java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用java类的方式都被看作是对类的被动使用都不会导致类的初始化

类加载过程

加载(loading) —> 链接(linking) [验证(verification) 准备(preparation) 解析(resolution)] —> 初始化(initialization)

JVM - 图1

加载

  1. 通过类的全限定名获取定义此类的二进制字节流;
  2. 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构;
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问接口

链接

  1. 验证:目的在于保证加载类的Class文件中信息符合当前虚拟机的要求,主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证。
  2. 准备:为类变量分配内存并设置该类变量的默认初始值,即零值。这里不包含被final修饰的static,因为final在编译阶段就被初始化了,准备阶段会被显式初始化。这里也不会为实例变量分配初始化,类会分配在方法区中,而实例变量会随对象一起分配到Java堆中(实例变量在对象实例化时进行初始化)。
    ```java private static int a = 1;

// a 在准备阶段被赋值为0 // 在初始化阶段才被赋值为1

  1. 3. 解析:将常量池内的符号引用转换为直接引用的过程。(用于加载所依赖的类)<br />符号引用是一组用来描述引用目标的符号<br />直接引用是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  2. **初始化**
  3. - 初始化阶段就是执行类构造器方法`<clinit>()`的过程。此方法是javac编译器自动收集类中的所有**静态变量的赋值动作**和**静态代码块**中的语句合并而来。若不存在静态变量和静态代码块,则不会执行该方法
  4. ```java
  5. public class test01 {
  6. private static int num1 = 1;
  7. static {
  8. num1 = 2;
  9. num2 = 20;
  10. // System.out.println(num2)
  11. // 报错:非法的前向引用
  12. }
  13. private static int num2 = 10;
  14. // linking (prepare): num1=0, num2=0
  15. // initial: num1=2, num2=10
  16. public static void main(String[] args) {
  17. System.out.println(num1); // 2
  18. System.out.println(num2); // 10
  19. }
  20. }
  • <clinit>() 方法不同于 <init>()方法。 <init>()方法是类的构造器方法。
  • 子类的<clinit>() 方法执行前,会保证父类的<clinit>() 方法执行完毕
  • 一个类的<clinit>() 方法只会调用一次,类加载完成后会存放在方法区中。虚拟机必须保证一个类的<clinit>() 方法在多线程下被同步加锁。

类加载器分类

引导类加载器 —> 扩展类加载器 —> 系统类加载器 (三者间是包含关系)

  • 启动类加载器(引导类加载器 Bootstrap ClassLoader)
    使用C/C++语言实现,嵌套在JVM内部
    用来加载Java的核心类库,提供JVM自身需要的类
    不继承于java.lang.ClassLoader,没有父加载器
    负责加载扩展类和应用类加载器,并作为他们的父类加载器
    Bootstrap ClassLoader至加载包名为java,javax,sun等开头的类

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

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

双亲委派机制

JVM - 图2

内存结构

  • 内存是非常重要的系统资源,是硬盘和cpu的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异(对于Hotspot主要指方法区)

JVM - 图3

程序计数器

JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。

  • 作用:PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

JVM - 图4

它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError (OOM) 情况的区域;

  • 使用PC寄存器存储字节码指令地址有什么用?(为什么使用PC寄存器记录当前线程的执行地址?)
    因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
    JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

  • PC寄存器为什么会设定为线程私有?
    我们都知道所谓的多线程在一个特定的时间段内指回执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然会导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
    由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

虚拟机栈

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
栈是运行时的单位,而堆是存储的单位

栈的特点
栈是一种快速有效的分配存储方式,访问速度仅次于PC寄存器(程序计数器)
JVM直接对java栈的操作只有两个:每个方法执行,伴随着进栈(入栈,压栈);执行结束后的出栈工作
对于栈来说不存在垃圾回收问题,但会存在OOM(内存溢出)

Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过虚拟机栈允许的最大容量,虚拟机将会抛出一个 StackOverFlowError异常
  • 如果Java虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError异常

  • 每个线程都有自己的栈,栈中的数据都是以栈帧 (Stack Frame) 的格式存在。在这个线程上正在执行的每个方法都对应各自的一个栈帧(方法和栈帧是一一对应的关系)。栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

  • JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的原则。
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Frame)。
  • Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常(不处理)。不管使用哪种方式,都会导致栈帧被弹出。

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或执行运行时常量池的方法引用)
  • 方法返回地址(Return Adress)(或方法正常退出或者异常退出的定义)
  • 一些附加信息

JVM - 图5

  • 局部变量表

定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
由于局部变量表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题。

对于Slot的理解
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
局部变量表,最基本的存储单元是Slot(变量槽)
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

  • byte、short、char、float在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true;
  • long和double则占据两个slot

  • 操作数栈

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过入栈push和出栈pop操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

public class Test02 {
    public void add(){
        byte i = 15;
        int j = 8;
        int k = i + j;
    }
}

以上代码的字节码文件:

 0 bipush 15 # 15 入栈(进入操作数栈)
 2 istore_1 # 索引为1处存储元素15(索引为0处存储了this)(存入局部变量表),出栈
 3 bipush 8 # 8 入栈
 5 istore_2 # 局部变量表索引为2处存入8,出栈
 6 iload_1 # 索引1的元素入栈
 7 iload_2 # 索引2的元素入栈,此时操作数栈的深度达到本次最大值:2
 8 iadd # 进行相加操作
 9 istore_3 # 局部变量表索引为3的位置存入运算结果,出栈
10 return # 返回
  • 动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令。

在Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Refenrence)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

JVM - 图6

  • 返回值地址

存放调用该方法的PC寄存器的值。
一个方法的结束,有两种方式:
正常执行完成
出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都需要返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值也压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。

堆内存

一个进程对应一个JVM实例,一个运行时数据区,又包含多个线程,这些线程共享了方法区和堆,每个线程包含了程序计数器、本地方法栈和虚拟机栈。

  1. 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
  2. Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间(堆内存的大小是可以调节的)
  3. 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
  4. 所有的线程共享java堆,在这里还可以划分线程私有的缓冲区(TLAB: Thread Local Allocation Buffer)。(面试问题:堆空间一定是所有线程共享的么?不是,TLAB线程在堆中独有的)
  5. 《Java虚拟机规范》中对java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。
    • 从实际使用的角度看,“几乎”所有的对象的实例都在这里分配内存 (‘几乎’是因为可能存储在栈上) 
  6. 数组或对象永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
  7. 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
  8. 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域
  • JDK7以前
    新生区+养老区+永久区
  • JDK8以后
    新生区+养老区+元空间

JVM - 图7

Java堆区用于存储Java对象实例,堆的大小在JVM启动时就已经设定好了,可以通过 “-Xmx”和 “-Xms”来进行设置

  • -Xms 用于表示堆的起始内存,等价于 -XX:InitialHeapSize
    -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
  • -Xmx 用于设置堆的最大内存,等价于 -XX:MaxHeapSize

一旦堆区中的内存大小超过 -Xmx所指定的最大内存时,将会抛出OOM异常

通常会将-Xms和-Xmx两个参数配置相同的值,其目的就是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

存储在JVM中的java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另外一类对象时生命周期非常长,在某些情况下还能与JVM的生命周期保持一致

Java堆区进一步细分可以分为年轻代(YoungGen)和老年代(OldGen)
其中年轻代可以分为Eden空间、Survivor0空间和Survivor1空间(有时也叫from区,to区)
JVM - 图8
配置新生代与老年代在堆结构的占比

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3

在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1,开发人员可以通过选项 -XX:SurvivorRatio 调整空间比例,如-XX:SurvivorRatio=8

  • 对象分配过程
  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去养老区呢?可以设置次数。默认是15次。·可以设置参数:
    -XX:MaxTenuringThreshold= 进行设置。
  7. 在养老区,相对悠闲。当老年区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。
  8. 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常。

JVM - 图9

  • 为什么要把Java堆分代?不分代就不能正常工作了么

经研究,不同对象的生命周期不同。70%-99%的对象都是临时对象。

  • 新生代:有Eden、Survivor构成(s0,s1 又称为from to),to总为空
  • 老年代:存放新生代中经历多次依然存活的对象

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

TLAB(Thread Local Allocation Buffer)

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

什么是TLAB

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 所有OpenJDK衍生出来的JVM都提供了TLAB的设计

JVM - 图10

  • 堆空间的参数设置

-XX:PrintFlagsInitial: 查看所有参数的默认初始值
-XX:PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)

  • 具体查看某个参数的指令:
    • jps:查看当前运行中的进程
    • jinfo -flag SurvivorRatio 进程id: 查看新生代中Eden和S0/S1空间的比例

-Xms: 初始堆空间内存(默认为物理内存的1/64)
-Xmx: 最大堆空间内存(默认为物理内存的1/4)
-Xmn: 设置新生代大小(初始值及最大值)
-XX:NewRatio: 配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄(默认15)
-XX:+PrintGCDetails:输出详细的GC处理日志

  • 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc

-XX:HandlePromotionFailure:是否设置空间分配担保

  • 使用逃逸分析,编译器可以对代码做如下优化:
  1. 栈上分配:将堆分配转化为栈分配。如果一个对象在子线程中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  2. 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  3. 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

方法区(元空间)

方法区可以看作是一块独立于Java堆的内存空间

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
  • 方法区在JVM启动时就会被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者 java.lang,OutOfMemoryError:Metaspace,比如:加载大量的第三方jar包;Tomcat部署的工程过多;大量动态生成反射类;
  • 关闭JVM就会释放这个区域的内存

JVM - 图11
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

类型信息
对每个加载的类型( 类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
①这个类型的完整有效名称(全名=包名.类名)
②这个类型直接父类的完整有效名(对于interface或是java. lang.Object,都没有父类)
③这个类型的修饰符(public, abstract, final的某个子集)
④这个类型直接接口的一个有序列表

域(Field)信息(成员变量)

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

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

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

non-final的类变量

  • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
  • 类变量被类的所有实例所共享,即使没有类实例你也可以访问它。

运行时常量池

  • 常量池:

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型域和方法的符号引用。常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量等信息。

  • 方法区的演进
  1. Hotspot中 方法区的变化:
    jdk1.6及之前:有永久代(permanent generation) ,静态变量存放在永久代上
    jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
    jdk1.8及之后: 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

JVM - 图12
这项改动是很有必要的,原因有:

  • 1)为永久代设置空间大小是很难确定的。 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。 "Exception in thread' dubbo client x.x connector’java.lang.OutOfMemoryError: PermGenspace" 而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制
  • 2)对永久代进行调优是很困难的。

StringTable 为什么要调整
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full GC 是老年代的空间不足、永久代不足时才会触发。这就导致了StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

JVM - 图13

直接内存

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
  • 直接内存是Java堆外的、直接向系统申请的内存区间(Metaspace)

  • 来源于NIO (new IO / Non-Blocking IO),通过存在堆中的DirectByteBuffer操作Native内存

  • 通常,访问直接内存的速度会优于Java堆,即读写性能提高
    • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
    • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

JVM - 图14

对象创建的步骤

  1. 判断对象对应的类是否加载、链接、初始化
  2. 为对象分配内存
    1. 如果内存规整一指针碰撞
    2. 如果内存不规整:
      1. 虚拟机需要维护一个列表
      2. 空闲列表分配
  3. 处理并发安全问题
    1. 采用CAS配上失败重试保证更新的原子性
    2. 每个线程预先分配一块TLAB
  4. 初始化分配到的空间——所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
  5. 设置对象的对象头
  6. 执行init方法进行初始化

JVM - 图15

垃圾回收

JVM在进行GC时,并非每次都针对上面三个内存区域(新生代、老年代、方法区)一起回收的,大部分时候回收都是指新生代。
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC/Young GC):只是新生代(Eden, S0, S1)的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有CMS GC会有单独收集老年代的行为
      • 注意,很多时候Major GC 会和 Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
      • 目前,只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

Java引用类型

强引用
平时我们编程的时候例如:Object object=new Object();那object就是一个强引用了。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

image.png

软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。(典型例子是反射数据
image.png


弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。 (典型例子是 ThreadLocalMap 中的 Entry 对象
image.png

虚引用(PhantomReference)
“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。 虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。 (典型例子是 Cleaner 释放 DirectByteBuffer 关联的直接内存
image.png

  • Finalize

它是 Object 中的一个方法,如果子类重写它,垃圾回收时此方法会被调用,可以在其中进行资源释放和清理工作
将资源释放和清理放在 finalize 方法中非常不好,非常影响性能,严重时甚至会引起 OOM,从 Java9 开始就被标注为 @Deprecated,不建议被使用了

缺点:

  • 无法保证资源释放:FinalizerThread 是守护线程,代码很有可能没来得及执行完,线程就结束了
  • 无法判断是否发生错误:执行 finalize 方法时,会吞掉任意异常(Throwable)
  • 内存释放不及时:重写了 finalize 方法的对象在第一次被 gc 时,并不能及时释放它占用的内存,因为要等着 FinalizerThread 调用完 finalize,把它从 unfinalized 队列移除后,第二次 gc 时才能真正释放内存

垃圾回收算法

  • 标记清除算法

标记:根据可达性分析判断当前对象是否可以回收
清除:将可回收对象的内存空间判定为可以重新分配状态(记录垃圾对象内存的起始结束地址)
优点:清除速度快(只需要对相应内存空间进行标记)
缺点:1. 清除后不会对空闲空间进行整理操作,容易导致内存碎片化;2. 在进行GC时需要停止整个应用程序,用户体验差
image.png

  • 标记整理算法

优点:没有内存碎片
缺点:整理过程涉及对象的移动,效率较低
image.png

  • 复制算法

优点:不会产生内存碎片
缺点:只有一半的空间可以用来存储对象
JVM - 图22

JVM - 图23

垃圾回收器

  1. 串行
    • 单线程
    • 堆内存较小,适合个人电脑
  2. 吞吐量优先
    • 多线程
    • 堆内存较大,多核cpu
    • 一定时间内,STW消耗的总时间最少
  3. 响应时间优先
    • 多线程
    • 堆内存较大,多核cpu
    • 尽量减少单次STW所需要的时间

Serial

JVM - 图24

  • 串行垃圾回收器包含新生代中的Serial和老年代中采用的SerialOld
  • Serial采用复制算法,SerialOld采用标记-整理算法
  • JVM的client模式下默认使用串行垃圾回收器
  • 它在进行垃圾收集时,必须暂停其他所有的工作线程(用户线程)。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

Parallel

JVM - 图25

  • ParrallelGC:多线程并行垃圾回收器,目标是达到一个可控制的吞吐量
  • 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
  • -XX:+UseParallelGC:新生代Parrallel垃圾回收器(JDK8默认开启),采用复制算法
  • -XX:+UseParallelOldGC:老年代Parrallel垃圾回收器,采用标记-整理算法
  • 垃圾回收器会开启多个线程同时进行回收,开启线程数默认和cpu核数一致。所以在启动垃圾回收线程时会占用所有的cpu资源。可以通过-XX:ParallelGCThreads=n参数设置垃圾回收线程的数量。
  • -XX:UseAdaptiveSizePolicy:动态设置新生代大小、Eden与Survivor区的比例、晋升老年代对象年龄
  • -XX:GCTimeRatio:通过调整堆空间的大小,从而调整GC线程运行时间在程序总运行时间的占比(直接设置吞吐量的大小,一般值为19,即GC时间占5%,吞吐量为95%)
  • -XX:MacGCPauseMillis=ms:GC线程的执行时间
  • 吞吐量的提升会导致单次GC执行时间的升高

CMS

JVM - 图26
以获取最短回收停顿时间为目标的收集器。非常符合互联网站或者B/S系统的服务端上,重视服务的响应速度,希望系统停顿时间最短的应用

  • -XX:UseConcMarkSweepGC:开启并发标记-清除垃圾回收器(CMS)。GC线程在执行的时候,用户线程可以并发执行,减少STW时间。运行在老年代。
  • 与之配合的是运行在新生带的ParNewGC,通过-XX:UseParNewGC参数开启,基于复制算法。
  • CMS垃圾回收器有时会出现并发失败的问题,这时老年代的GC退化为SerialOldGC

运行流程:

  1. 初始标记:发生STW,阻塞用户线程,只标记GC Root,时间很短
  2. 并发标记:用户线程恢复运行,GC线程并发执行找出要回收的垃圾对象
  3. 重新标记:发生STW,找出在并发标记阶段发生变化的对象(浮动垃圾),判断是否进行回收
  4. 并发清理:用户线程恢复运行,GC进行垃圾清理
  • GC线程数量控制:
    • -XX:ParrallelGCThreads=n:并行线程,STW期间并行的GC线程数量,一般与cpu核数一致
    • -XX:ConcGCThreads=threads:并发线程,用户线程与GC线程并发运行时,GC线程的数量,一般为并行GC线程数量的1/4
  • 其他参数
    • -XX:CMSInitiatingOccupancyFraction 设置当老年代空间实用率达到百分比值时进行一次cms回收,默认为68,当老年代的空间使用率达到68%的时候,会执行CMS回收。因为存在浮动垃圾,所以需要为收集浮动垃圾提供部分空间。
    • -XX:+CMSScavengeBeforeRemark:在进行重新标记前先进行一次垃圾回收(先回收当前被标记的垃圾,避免在重新标记阶段再次进行过多的可达性分析)
  • CMS基于标记-清除算法,所以在运行时容易导致内存碎片化,最终导致并发失败;同时,浮动垃圾的存在也可能导致并发失败。在并发失败时CMS会退化为SerialOld,采用单线程串行垃圾回收机制,大大延长一次垃圾回收所需要的时间。

并发标记而言,必须解决漏标问题,也就是要记录标记过程中的变化。有两种解决方法:

  1. Incremental Update 增量更新法,CMS 垃圾回收器采用
    • 思路是拦截每次赋值动作,只要赋值发生,被赋值的对象就会被记录下来,在重新标记阶段再确认一遍
  2. Snapshot At The Beginning,SATB 原始快照法,G1 垃圾回收器采用
    • 思路也是拦截每次赋值动作,不过记录的对象不同,也需要在重新标记阶段对这些对象二次处理
    • 新加对象会被记录
    • 被删除引用关系的对象也被记录

G1

同时注重吞吐量和低延迟,默认的暂停目标是200ms。适用于超大堆内存的设备,会将堆划分为多个大小相等的Region。整体上是标记-整理算法,两个区域之间是复制算法。JDK9中默认使用。主要改变是Eden,Survivor和Tenured等内存区域不再是连续的了,而是变成了一个个大小一样的region

优点:

  1. 并行与并发:充分利用多CPU、多核环境下的硬件优势
  2. 分代收集:不需要其他收集器配合就能独立管理整个GC堆
  3. 空间整合:“标记—整理”算法实现的收集器,局部上基于“复制”算法不会产生内存空间碎片
  4. 可预测的停顿:能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒

G1垃圾回收阶段

  1. 新生带回收:使用复制算法回收新生代,存活对象复制到幸存区,其中较老对象晋升至老年代
  2. 并发标记:当老年代占用内存超过阈值后,触发并发标记。并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程
  3. 混合收集:混合收集时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域

image.png