【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收

JVM

  • 一、类加载机制
    • 1、JVM什么时候会加载一个类?
    • 2、验证、准备、解析、初始化过程
      • 2.1 概念
      • 2.2 什么时候初始化一个类?
    • 3、类加载器和双亲委派机制
      • 3.1 类加载器
      • 3.2 双亲委派机制
      • 3.3 Tomcat类加载机制
        • 3.3.1 破坏双亲委派
  • 二、内存区域
    • 1、内存区域划分
      • 1.1 存放类的方法区
      • 1.2 执行代码指令用的程序计数器
      • 1.3 虚拟机机栈
      • 1.4 Java堆内存
      • 1.5 其它内存区域
    • 2、核心内存区域全部流程
  • 三、垃圾回收
    • 1、JVM分代模型:年轻代、老年代、永久代
    • 2、对象在内存中的分配
      • 2.1 分配方式
      • 2.2 一系列问题
      • 2.3 案例:生产系统的老年代频繁Full GC
        • 2.3.1 触发Minor GC时候会有多少对象进入老年代?
        • 2.3.2 系统运行多久,老年代大概就会被填满?
        • 2.3.3 系统运行多久,老年代会触发1次Full GC?
        • 2.3.4 如何进行JVM优化?
    • 3、触发垃圾回收时机
      • 3.1 触发老年代Full GC的时机
    • 4、对象的引用类型
    • 5、finalize()方法的作用
    • 6、垃圾回收算法
      • 6.1 标记-清除算法
      • 6.2 标记-整理算法
      • 6.3 标记-复制算法(新生代)
      • 6.4 复制算法的优化:Eden区和Survivor区
    • 7、垃圾回收器
      • 7.1 Stop the World
        • 7.1.1 机制
        • 7.1.2 造成的系统停顿
        • 7.1.3 不同的垃圾回收器的不同影响
      • 7.2 ParNew工作机制
        • 7.2.1 单线程垃圾回收好,还是多线程垃圾回收好?
      • 7.3 CMS工作机制
        • 7.3.1 工作阶段
        • 7.3.2 性能分析
        • 7.3.3 CMS带来的问题
      • 7.4 G1工作机制
        • 7.4.1 Par New + CMS痛点
        • 7.4.2 G1垃圾回收器原理
        • 7.4.3 G1分代回收原理深度解析
        • 7.4.4 G1垃圾回收的过程
        • 7.4.5 G1垃圾回收参数的设置
        • 7.4.6 回收失败的Full GC
  • 四、综合案例
    • 1、线程系统突然卡死无法访问(JVM GC)
      • 1.1 新生代GC多久一次对系统影响不大?
      • 1.2 什么时候新生代GC对系统影响很大?
      • 1.3 频繁老年代GC问题
      • 1.4 JVM性能优化是优化什么?
  • 五、问题答疑

一、类加载机制

JVM整体的运行原理:首先从".java"代码文件编译成".class"字节码文件,然后类加载器把".class"字节码文件中的类给加载到JVM中,接着JVM执行我们写好的那么类中的代码。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第1张图片

1、JVM什么时候会加载一个类?

一个类从加载到使用,一般会经过下面这个过程:

加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载

当代码中使用类的时候,就会加载一个类。

比如包含main()方法的主类在JVM进程启动之后被加载到内存(加载字节码文件),然后开始执行main()方法中的代码。

2、验证、准备、解析、初始化过程

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第2张图片

2.1 概念

  • 验证阶段:根据Java虚拟机规范,校验加载进来的".class"文件中的内容是否符合指定的规范。

  • 准备阶段:给类分配一定的内存空间,以及它里面的类变量(即static修饰的变量)分配内存空间,设置默认的初始值。(而实例变量在创建类的实例对象时才会初始化)

  • 解析阶段:将符号引用替换为直接引用

  • 初始化阶段(核心阶段):正式执行类初始化的代码,完成类变量的真正赋值操作。static静态代码块,也是在这个阶段完成的。

    (这个阶段主要是准备好类级别的数据,比如静态代码块,静态成员赋值,

    初始化跟对象无关,用new关键字才会构造出一个对象出来)

例子:

public class ReplicaManager {
	public static int flushInterval = Configuration.getInt("replica.flush.interval");
}

- 准备阶段:首先给ReplicaManager类分配一定的内存空间,然后给类变量flushInterval分配内存空间,设置0初始值
- 初始化阶段:`Configuration.getInt("replica.flush.interval") `完成一个配置项的读取,然后赋值给类变量`flushInterval`

2.2 什么时候初始化一个类?

  • 比如"new ReplicaManager()"实例化对象,就会触发类的加载到初始化过程,把这个类准备好,然后再实例化一个对象出来。
  • 包含"main()"方法的主类,必须是立马初始化的
  • 初始化一个类的时候,如果父类还没初始化,那么必须先初始化它的父类

类初始化时机:

  1. 当创建某个类的新实例时(如通过new或者反射、克隆、反序列化等)
  2. 当调用某个类的静态方法时
  3. 当使用某个类或者接口的静态字段时
  4. 调用Java API的某些反射方法时,比如类Class中的方法,或者java.lang.reflect中的类的方法时
  5. 当初始化某个子类时
  6. 当虚拟机启动某个被标明为启动类的类

3、类加载器和双亲委派机制

3.1 类加载器

  • 启动类加载器Bootstrap ClassLoader

    负责加载机器上安装的Java目录下的核心类("lib"目录)

  • 扩展类加载器Extension ClassLoader

    负责加载"lib\ext"目录中的类

  • 应用程序类加载器Application ClassLoader

    负责加载"ClassPath"环境变量所指定的路径中的类,可以理解为自己写好的Java代码

  • 自定义类加载器

    根据自己的需求加载一些类

    (如何实现一个自定义类加载器?自己写一个类,继承ClassLoader类,重写类加载的方法)

3.2 双亲委派机制

启动类加载器位于最上层、扩展类加载器在第二层、应用程序类加载器在第三层、最后一层是自定义类加载器

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第3张图片

如果一个应用程序类加载器需要加载一个类,首先委派给自己的父类加载器去加载,最后传导到顶层的类加载器去加载,如果父类加载器在自己负责加载的范围内,没找到这个类,那么就下推加载权力给自己的子类加载器。

好处:

  • 每个层级的类加载器各司其职,不会重复加载一个类
  • 保护一些核心类的安全

3.3 Tomcat类加载机制

Tomcat本身就是用Java写的,它自己就是一个JVM,我们写好的那些系统程序,通过编译后的.class文件放入一个war包,然后在tomcat中运行。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第4张图片
  • Tomcat自定义了Common、Catalina、Shared等类加载器,是用来加载Tomcat自己的一些核心基础类库的
  • Tomcat为每个部署在里面的Web应用都有一个对应的WebApp类加载器,负责加载我们部署的这个Web应用的类
  • Jsp类加载器,则是给每个JSP都准备了一个Jsp类加载器

每个WebApp负责加载自己对应的那个Web应用的class文件,即我们写好的系统打包好的war包中的所有class文件,不会传到给上层类加载器去加载。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第5张图片

Shared底层细分了不同的web类加载器用于隔离不同的web项目,打破了双亲委派机制,由自定义类加载器先加载类。

3.3.1 破坏双亲委派

原因:隔离、灵活、性能

  1. 不同的项目依赖Spring不同的包,那么就会导致依赖冲突问题,如果用不同的加载器,就能起到隔离的作用

  2. 当需要增加或者减少单独的某个web项目的部署,用多个类加载器可以灵活的实现

  3. 用多个类加载器性能要比用一个类加载器性能要高

二、内存区域

JVM在运行我们写好的代码时,必须使用多块内存空间,不同的内存空间用来放不同的数据,然后配合我们写的代码流程,才能让我们的系统运行起来。

1、内存区域划分

  • 线程共享的区域
    • 方法区
    • 直接内存(非运行时数据区的一部分)
  • 线程私有的区域
    • 程序计数器
    • 虚拟机栈
    • 本地方法栈

如图是JDK1.8之前:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第6张图片

如图是JDK1.8

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第7张图片

1.1 存放类的方法区

方法区在JDK1.8以前的版本,代表JVM中的一块区域。

主要是放从".class"文件里加载进来的类,还有一些类似常量池的东西也放在这个区域里。

JDK1.8以后,这块区域改成了"Metaspace",即元数据空间的意思,主要还是存放我们自己写的各种类相关的信息。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第8张图片

1.2 执行代码指令用的程序计数器

我们编写的代码首会存在于".java"后缀的文件中,但是计算机是看不懂我们写的代码的,所以就得通过编译器,把".java"后缀的源文件编译成".class"后缀的字节码文件,

这份文件存放的就是我们写出来的代码编译好的字节码。

字节码指令对应了一条一条的机器指令,计算机只有读到这种机器码指令,才知道具体应该要干什么。比如字节码指令可能会让计算机从内存读取某个数据,或者把某个数据写入到内存里。

在执行字节码指令的时候,JVM需要一个特殊的内存区域,就是"程序计数器",用来记录当前执行的字节码指令的位置即记录目前执行到了哪一条字节码指令

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第9张图片

1.3 虚拟机机栈

Java代码在执行的时候,一定是线程来执行某个方法中的代码。在方法里,我们经常会一定一些方法内的局部变量。

因此,JVM必须有一块区域是用来保存每个方法内局部变量等数据的,这个区域就是Java虚拟机栈

每个线程都有自己的Java虚拟机栈,如果线程执行了一个方法,就会对这个方法调用创建对应的一个栈帧

栈帧有这个方法的:局部变量表、操作数栈、动态链接、方法出口等信息。

例子:

public class ReplicaManager {

   public void loadReplicasFromDisk() {
      Boolean hasFinishedLoad = false;
      if(isLocalDataCorrupt()) {}
   }

   private Boolean isLocalDataCorrupt() {
      Boolean isCorrupt = false;
      return isCorrupt;
   }
}

整个过程如图所示:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第10张图片

结合前面的知识,如图所示:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第11张图片

1.4 Java堆内存

存放我们在代码中创建的各种对象,实例变量也是在堆内存的。

案例:

public class Kafka {
   public static void main(String[] args) {
      ReplicaManager replicaManager = new ReplicaManager();
      replicaManager.loadReplicasFromDisk();
   }
}

public class ReplicaManager {

	private long replicaCount;

	public void loadReplicasFromDisk() {
		Boolean hasFinishedLoad = false;
		if(isLocalDataCorrupt()) {}
	}

	private Boolean isLocalDataCorrupt() {
		Boolean isCorrupt = false;
		return isCorrupt;
	}
}

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第12张图片

1.5 其它内存区域

  • 本地方法栈

    JDK很多底层API,比如IO相关的,NIO相关的,网络Socket相关的。

    内部源码很多走的是native方法去调用本地操作系统里面的一些方法,调用这些方法时,就会有线程对应的本地方法栈,存放各种native方法的局部变量表之类的信息。

  • 堆外内存

    这块区域不属于JVM,通过NIO的allocateDirect这种API,可以在Java堆外分配内存空间,然后通过Java虚拟机栈的DirectByteBuffer来引用来操作堆外内存空间

2、核心内存区域全部流程

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第13张图片

  • JVM进程启动时,首先加载Kafka类到内存里(因为包含了main()方法),然后有一个main()线程,开始执行main()方法

  • main线程执行main()方法时,会在main线程关联的Java虚拟机栈里,压入一个main()方法的栈帧

  • 接着发现需要创建一个ReplicaManager对象实例分配在Java堆内存里,并且在main()方法的栈帧的局部变量表引入一个replicaManager变量,让它引用RplicaManager对象在Java堆内存的地址

  • 接着main线程开始执行ReplicaManager对象中的方法,会依次把自己执行到的方法对应的栈帧压入自己的Java虚拟机栈,执行完方法之后再把方法对应的栈帧从Java虚拟机栈里出栈

三、垃圾回收

Java堆内存里创建的对象,都是占用内存资源的,而且内存资源有限。

JVM本身是有垃圾回收机制的,它是一个后台自动运行的线程

1、JVM分代模型:年轻代、老年代、永久代

JVM将Java堆内存划分为了两个区域,一个是年轻代,一个是老年代。

年轻代:创建和使用完之后立马就要回收的对象

老年代:创建之后需要一直长期存在的对象

项目中托管给Spring管理的对象,带@Configuration的配置对象,都是长期存在老年代。自己定义的那些pojo对象,如果不被定义为类对象就是朝生夕灭,所以分配在年轻代里面。

什么是永久代?

JVM里的永久代其实就是我们之前说的方法区,可以认为永久代就是放一些类信息。

方法区会不会进行垃圾回收?

在满足以下情况的条件下,方法区的类会被回收:

  • 首先该类的所有实例对象都已经从Java堆内存里被回收(没有任何实例)
  • 其次加载这个类的ClassLoader已经被回收(没有调用class的静态变量或静态方法)
  • 最后,对该类的Class对象没有任何引用(没有调用class的静态变量或静态方法,没有利用反射访问class)

2、对象在内存中的分配

2.1 分配方式

  • 大部分正常对象都优先在新生代分配内存

    如果新生代空间(Eden区和Survivor1区)几乎要被对象占满时,就会触发一次Minor GC, 有时候也称Young GC,把存活的对象转移到Survivor2中

  • 长期存活的对象躲过多次垃圾回收

    如果一个实例对象在新生代,成功的在15次垃圾回收之后,还是没被回收掉,那么它会被转移到老年代。

  • 动态对象年龄判断

    对象年龄不用等待15次GC过后才进去老年代。

    规则:如果当前存放对象的Survivor区域里,一批对象的总大小大于了这块Survivor区域的内存大小50%,那么此时大于等于这批对象年龄的对象,就可以直接进入老年代。

    比如Survivor1区域中有两个对象,两个对象年龄一样都是两岁,加起来对象超过50MB,假设分配给Survivor1区域的内存为100MB,那么此时已经超过了一半了,所以Survivor区大于等于2岁的对象都要全部进入老年代里去。

    年龄1 + 年龄2 + … ,年龄从小到大进行累加,当加入某个年龄段后,累加和超过了Survivor区域的50%,此时就会把那个年龄段以上的对象都放入老年代

  • 大对象直接进入老年代

    当创建一个大小 大于一个指定大小 的对象时,比如一个超大的数组,就会直接分配在老年代。避免在垃圾回收的时候,频繁的复制。

2.2 一系列问题

  1. Minor GC后的对象太多无法放入Survivor区怎么办?如图所示

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第14张图片

    答:这个时候就必须得吧这些对象直接转移到老年代去

  2. 老年代空间分配担保机制

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第15张图片

    如果新生代有大量对象存活,Minor GC后,Survivor区域仍放不下,老年代空间也无法放入的时候,那如何办呢?

    • 首先,在执行任何一次Minor GC之前,JVM会先检查老年代可用的内存空间,如老年代的内存大小是大于新生代所有对象的,此时可以放心大胆的对新生代发起一次Minor GC,即使Minor GC之后所有对象都存活了,Survivor区放不下,也可以转移到老年代中去

    • 假如执行Minor GC之前,发现老年代可用内存大小已经小于新生代的全部对象大小了,那么这个时候有没有可能在Minor GC之后新生代的对象全部存活下来,然后全部转移到老年代去呢?

      理论上,是有这种可能的。所以假如Minor GC之前,发生老年代的可用内存已经小于了新生代的全部对象大小了,就会看一个

      -XX:HandlePromotionFailure的参数是否设置了,如果有这个参数,那么就会继续尝试进行下一步判断。

      • 步骤一:就是看看老年代的内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小,如果大于,则此时老年代空间是够的。

      • 步骤二:如果步骤一失败了,或者是-XX:HandlePromotionFailure参数没设置,此时就会直接触发一次Full GC,就是对老年代进行垃圾回收

      如果两个步骤都判断成功了,那么可以尝试一下Minor GC。

    • Minor GC有几种可能

      • 第一种:Minor GC过后,剩余的存活对象的大小,是小于Survivor区的大小,那么此时存活对象进入Survivor区域即可
      • 第二种:Minor GC过后,剩余的存活对象的大小,是大于Survivor区的大小,但是是小于老年代可用内存大小的,此时就直接进入老年代即可
      • 第三种:Minor GC过后,剩余的存活对象的大小,大于了Survivor区的大小,也大于了老年代可用内存的大小。此时老年代都放不下这些存活对象了,就会发生Handle Promotion Failure,这个时候触发一次Full GC。Full GC就是对老年代进行垃圾回收,同时也一般会对新生代进行垃圾回收。如果Fll GC过后,老年代还是没有足够的空间存放Minor GC过后的剩余对象,那么此时就会发生OOM内存溢出

2.3 案例:生产系统的老年代频繁Full GC

系统:不停的从MySQL数据库以及其它数据源里提取大量的数据,加载到自己的JVM内存里来进行计算处理。如图所示

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第16张图片

假设每台机器每分钟执行100次数据提取和计算的任务,每次提取大概1万条数据到内存计算,平均每次计算需要耗费10S左右的时间。每天机器配置是4核8G,JVM内存给了4G,其中新生代和老年代分别是1.5G的内存空间。如图所示:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第17张图片

Eden:Survivor From : Survivor To = 8 : 1 : 1

即Eden = 1.2GB,Survivor From = 100MB, Survivor To = 100MB

假设每条数据占据1KB左右大小,那么每次执行一次计算任务,Eden区分配10MB左右的对象,一分钟大概对应100次计算任务,所以基本一分钟过后,Eden区就全是对象,基本就全满了。

10000条 * 1KB * 100次 = 1000MB ≈ 1GB

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第18张图片

2.3.1 触发Minor GC时候会有多少对象进入老年代?

假设新生代Eden区1分钟过后都塞满了对象,接着继续执行计算任务的时候,必然会导致需要进行Minor GC回收一部分的垃圾对象。

  • 首先第一步:先看看老年代的可用内存空间是否大于新生代全部对象,如图所示

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第19张图片

    此时老年代可用内存空间是1.5GB,新生代对象总共有1.2GB,即使一次Minor GC过后,全部对象都存活,老年代也能放下,那么此时就会直接执行Minor GC了

  • 此时Eden区有多少对象还是存活的,无法被垃圾回收的呢?

    每个计算任务1万条数据需要计算10秒钟,假设此时80个计算任务都执行结束了,但是还有20个计算任务共计20 * 1万 * 1KB = 200MB数据还在计算,此时200MB对象是存活的,不能被垃圾回收,然后有1GB对象是可以垃圾回收的,如图所示

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第20张图片

    此时一次Minor GC就会回收掉1GB的对象,然后200MB对象能放入Survivor区吗?

    不能。因为任何一块Survivor区实际尚就100MB空间,此时就会通过 空间担保机制 ,让这200MB对象直接进入老年代,占用里面200MB内存空间,然后Eden区就清空了,如图所示。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第21张图片

2.3.2 系统运行多久,老年代大概就会被填满?

每分钟都是一个轮回,每分钟都会把Eden区填满,然后触发一次Minor GC,然后大概都会有200MB左右的数据进入老年代。

假设2分钟过去了,此时老年代已经有400MB,只有1.1GB内存空间可用,此时如果第3分钟运行完毕,又要进行Minor GC,会做什么检查呢?如图所示

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第22张图片

此时老年代可用空间1.1GB,新生代对象有1.2GB,那么此时假设一次Minor GC过后新生代对象全部存活,老年代是放不下的,那么就得看一个参数是否打开了。

如果-XX:-HandlePromotinoFailure参数被打开了,一般都会打开,此时会进入第二步检查,看看老年代可用空间是否大于历次Minor GC过后进入老年代的对象的平均大小。

前面已经计算过了,大概每分钟会执行一次Minor GC,每次大概200MB对象会进入老年代。

此时发现老年代空的1.1GB空间,是大于每次Minor GC后平均200MB对象进入老年代的大小的。

所以基本可以推测,本次Minor GC后大概率还是有200MB对象进入老年代,1.1G可用空间是足够的。

所以此时就会放心执行一次Minor GC,然后又是200MB对象进入老年代。

  • 大概运行了7分钟过后,7次Minor GC执行过后,大概1.4GB对象进入老年代,老年代剩余空间就不到100MB了,几乎快满了,如图所示

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第23张图片

2.3.3 系统运行多久,老年代会触发1次Full GC?

大概在第8分钟运行结束的时候,新生代又满了,执行Minor GC之前进行检查,此时发生老年代只有100MB内存空间,比之前每次Minor GC后进入老年代的200MB对象要小,此时就会直接触发一次Full GC。

Full GC会把老年代的垃圾对象都给回收了,假设此时老年代被占据的1.4GB空间里,都是可回收对象,此时一次性就把这些对象全部回收了,如图:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第24张图片

然后接着就会执行Minor GC,此时Eden情况,200MB对象再次进入老年代,之前Full GC就是为这些新生代本次Minor GC要进入老年代的对象准备的,如下图:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第25张图片

2.3.4 如何进行JVM优化?

因为这个系统是数据计算系统,每次Minor GC的时候,必然会有一批数据没计算完毕,但是按照现有的内存模型,最大的问题其实就是每次Survivor区域放不下存活对象。

所以可以增加新生代的内存比例,3GB左右的堆内存,其中2GB分配给新生代,1GB留给老年代。

这样Survivor区大概就是200MB,每次刚好能放得下Minor GC过后存活的对象了,如下图所示:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第26张图片

通过分析和优化,可以把生产系统的老年代Full GC的频率从几分钟一次降低到了几个小时一次,大幅度提升了系统的性能,避免了频繁Full GC对系统运行的影响。

注意一点:有个动态年龄判定升入老年代的规则:年龄1 + 年龄2 + … ,年龄从小到大进行累加,当加入某个年龄段后,累加和超过了Survivor区域的50%,此时就会把那个年龄段以上的对象都放入老年代。所以这里的优化仅仅是一个示例说明,意思是增加Survivor区的大小,让Minor GC后的对象进入Survivor区中,避免进入老年代。

实际上为了避免动态对象年龄判定规则把Survivor区中的对象直接升入老年代,在这里如果新生代内存有限,可以调整-XX:SurvivorRatio=8这个参数,默认是说Eden区比例是80%,也可以降低Eden区的比例,给两块Survivor区更多的内存空间,然后让每次Minor GC后的对象进入Survivor区中,还可以避免动态年龄判定规则直接把它们升入老年代。

3、触发垃圾回收时机

JVM使用了一种 可达性分析算法来判定对象是可以被回收的,哪些对象是不可以被回收的。通过GC Roots引用链来判断哪些对象可不可以被回收。

哪些可以作为GC Roots?

  • 局部变量就是可以作为GC Roots的(方法的局部变量引用的对象)
  • 静态变量也可以看做是一种GC Roots(类的静态变量引用的对象)

3.1 触发老年代Full GC的时机

  • 第一是老年代可用内存小于新生代全部对象的大小,如果没开启空间担保参数,会直接触发Full GC,所以一般空间担保参数都会打
    开;

  • 第二是老年代可用内存小于历次新生代Minor GC后进入老年代的平均对象大小,此时会提前Full GC

  • 第三是新生代某次Minor GC后的存活对象大于Survivor,要升入老年代的对象有几百MB,但是老年代可用空间不足了。

  • 第四是如果老年代可用内存大于历次新生代GC后进入老年代的对象平均大小,但是老年代已经使用的内存空间超过了“-XX:CMSInitiatingOccupancyFaction”参数指定的比例,也会自动触发Full GC

    CMSInitiatingOccupancyFaction: 用来设置老年代占用多少比例的时候触发CMS垃圾回收,JDK1.6默认是92%,剩余的8%空间给CMS并发清除阶段中,系统程序把一些新对象放入老年代中。
    

4、对象的引用类型

  • 强引用

    一个变量引用一个对象,强引用的类型,垃圾回收的时候不会去回收这个对象

  • 软引用

    正常情况下垃圾回收不会回收软引用对象,如果进行了垃圾回收之后,发现内存空间还是不够存放新对象,内存快要溢出时,那么就会把软引用对象给回收

  • 弱引用

    发生垃圾回收,就会把对象回收

  • 虚引用

5、finalize()方法的作用

假设没有GC Roots引用的对象,是一定立马被回收吗?

不是的,有一个finalize()方法可以拯救这个对象。

public class ReplicaManager {

   public static ReplicaManager instance;

   @Override
   protected void finalize() throws Throwable {
      ReplicaManager.instance = this;
   }
}

重新让某个GC Roots变量引用了自己,那么就不用被垃圾回收了。

6、垃圾回收算法

6.1 标记-清除算法

该算法分为“标记”和”清除“两个阶段

首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法。这种垃圾收集算法会带来两个明显的问题。

  1. 效率问题
  2. 空间问题(标记清除后会产生大量不连续的碎片)

6.2 标记-整理算法

该算法分为"标记"和“整理”两个阶段

让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

  • 优点:
    • 不会内存碎片
  • 缺点:
    • 需要移动大量对象,处理效率比较低

6.3 标记-复制算法(新生代)

针对新生代的垃圾回收算法,叫做标记-复制算法

将新生代的内存分为两块,每次只使用其中一块,当这一块内存使用完后,就将还存活的对象复制到另外一块,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

缺点:

每次只使用其中一块,内存使用效率低

6.4 复制算法的优化:Eden区和Survivor区

真正的复制算法会做出如下优化:把新生代内存区域划分为三块:

  • 1个Eden区
  • 2个Survivor区

Eden : Survivor From : Survivor To = 8 :1 :1

  • 刚开始的时候对象都是分配在Eden区内,如果Eden区快满了,此时就会触发垃圾回收

  • 此时就会把Eden区中存活对象都一次性转移到一块空着的Survivor区

  • 接着Eden区就会被清空,然后再次分配新对象到Eden区里

  • Eden区和一块Survivor区都是有对象的,其中Survivor区里放的是上一次Minor GC后存活的对象。如果下次再次Eden区和其中一块Survivor区满,那么再次触发Minor GC,就会把Eden区和放着上一次Minor GC后存活对象的Survivor区内的存活对象,转移到另一块Survivor区去。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第27张图片

好处: 只有10%内存空间是闲置的,90%的内存都被使用上了

7、垃圾回收器

在新生代和老年代进行垃圾回收的时候,都要用到垃圾回收器进行回收的,不同的区域用不同的垃圾回收器。

  • Serial和Serial Old垃圾回收器:分别用来回收新生代和老年代的垃圾对象

    工作原理:单线程运行,垃圾回收的时候会停止我们写的系统的其它工作线程,让我们系统直接卡死不动,然后让它们垃圾回收,这个现实一般写后台Java系统几乎不用

  • ParNew和CMS垃圾回收器:ParNew现在一般都是用在新生代的垃圾回收器,CMS是用在老年代的垃圾回收器,它们都是多线程并发的机制,性能更好,现在一般是线上生产系统的标配组合。

  • G1垃圾收集器:统一收集新生代和老年代,采用了更加优秀的算法和设计机制。

7.1 Stop the World

7.1.1 机制

JVM最大的痛点:在垃圾回收的这个过程中,因为在垃圾回收的时候,要尽可能要让垃圾回收器专心的工作,所以不能随便让我们写的Java系统继续创建对象,此时JVM会在后台直接进入"Stop the World"状态。

它会直接停止我们写的Java系统的所有工作线程,让我们写的代码不再运行。

然后让垃圾回收线程可以专心的进行垃圾回收的工作,如图所示:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第28张图片

这样的话,就可以让我们的系统暂停运行,然后不再创建新的对象,同时让垃圾回收线程尽快完成垃圾回收的工作,就是标记和转移
Eden以及Survivor2的存活对象到Survivor1中去,然后尽快一次性回收掉Eden和Survivor2中的垃圾对象,如下图。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第29张图片

接着一旦垃圾回收完毕,就可以继续恢复我们写的Java系统的工作线程的运行了,然后我们的那些代码就可以继续运行,继续在Eden中创建新的对象,如下图:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第30张图片

7.1.2 造成的系统停顿

假设我们的Minor GC要运行100ms,那么可能会导致我们系统直接停顿100ms不能处理任何请求,在这100ms期间用户发起的所有请求都会出现短暂的卡顿,因为系统的工作线程不再运行,不能处理请i去。

如果因为内存分配不合理,导致对象频繁进入老年代,平均每几分钟的一次Full GC,而Full GC是最慢的,可能耗费几秒甚至几十秒。一旦频繁的Full GC,就会造成系统每隔几分钟就卡死几十秒,用户体验极差。

所以说,无论是新生代GC还是老年代GC,都尽量不要让频率过高,也避免持续时间过长,避免影响系统正常运行,这也是使用JVM过程中一个最需要优化的地方,也是最大的一个痛点。

7.1.3 不同的垃圾回收器的不同影响

比如对新生代的回收,Serial垃圾回收器就是用一个线程进行垃圾回收,然后此时暂停系统工作线程,所以一般我们在服务器程序中很
少用这种方式。

但是我们平时常用的新生代垃圾回收器是ParNew,它针对服务器一般都是多核CPU做了优化,他是支持多线程个垃圾回收的,可以大幅度提升回收的性能,缩短回收的时间。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第31张图片

不同的垃圾回收器他会有不同的机制和原理,使用多线程或者单线程,都是有区别的。

CMS垃圾回收器也是基于多线程的,而且可以使用一套独特的机制尽可能的在垃圾回收的过程中减少“Stop the World”的时间,避免长时间卡死我们的系统。

7.2 ParNew工作机制

最常用的新生代垃圾回收器:ParNew

ParNew可以充分利用服务器的多核CPU的优势。是一款多线程垃圾回收机制,主要负责回收新生代。

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第32张图片

ParNew垃圾回收器默认情况下的线程数量

因为现在一般我们部署系统的服务器都是多核CPU的,所以为了在垃圾回收的时候充分利用多核CPU的资源,一旦我们指定了使用ParNew垃圾回收器之后,他默认给自己设置的垃圾回收线程的数量就是跟CPU的核数是一样的。
比如我们线上机器假设用的是4核CPU,或者8核CPU,或者16核CPU,那么此时ParNew的垃圾回收线程数就会分别
是4个线程、8个线程、16个线程。一般不用我们手动去调节,因为跟CPU核数一样的线程数量,是可以充分进行并行处理的。如果你一定要自己调节ParNew的垃圾回收线程数量,也是可以的,使用“-XX:ParallelGCThreads”参数即可,
通过他可以设置线程的数量。建议一般不要随意动这个参数。

7.2.1 单线程垃圾回收好,还是多线程垃圾回收好?
  • 启动系统的时候是可以区分服务器模式和客户端模式的,如果你启动系统的时候加入“-server”就是服务器模式,如
    果加入“-cilent”就是客户端模式。

    区别就是,如果你的系统部署在比如4核8G的Linux服务器上,那么就应该用服务器模式,如果你的系统是运行在比如Windows上的客户端程序,那么就应该是客户端模式。

    • 服务端:服务器模式通常运行我们的网站系统、电商系统、业务系统、APP后台系统之类的大型系统,一般都是多核CPU。如果你部署在服务器上,但是你用了单线程垃圾回收,那么就有一些CPU是被浪费了,根本没用上。

      如图是单核工作的情况下:

      【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第33张图片

    • 客户端:如果你的Java程序是一个客户端程序,比如类似百度云网盘的Windows客户端,或者是印象笔记的Windows客户端,运行在Windows个人操作系统上呢?

      这种操作系统很多都是单核CPU,此时你如果要是还是用ParNew来进行垃圾回收,就会导致一个CPU运行多个线程,
      反而加重了性能开销,可能效率还不如单线程好。

      因为单CPU运行多线程会导致频繁的线上上下文切换,有效率开销,如下图。

      【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第34张图片

      所以如果是类似于那种运行在Windows上的客户端程序,建议采用Serial垃圾回收器,单CPU单线程垃圾回收即可,
      反而效率更高,如下图。

      【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第35张图片

      但是其实现在一般很少有用Java写客户端程序的,几乎很少见,Java现在主要是用来构建复杂的大规模后端业务系统
      的,所以常见的还是用“-server”指定为服务器模式,然后配合ParNew多线程垃圾回收器。

7.3 CMS工作机制

老年代我们选择的垃圾回收器是CMS,它采用的是标记-清除算法

现在假设因为老年代内存空间小于了历次Minor GC后升入老年代对象的平均大小,
判断Minor GC有风险,可能就会提前触发Full GC回收老年代的垃圾对象。

或者是一次Minor GC后的对象太多了,都要升入老年代,发现空间不足,触发了一次老年代的Full GC。

总之就是要进行Full GC了,会发生所谓的标记-清理算法,其实就是先通过追踪GC Roots的方法,看看各个对象是否被GC Roots给引用了,如果是的话,那就是存活对象,否则就是垃圾对象。
先将垃圾对象都标记出来,然后一次性把垃圾对象都回收掉。

这种方法其实最大的问题,就是会造成很多内存碎片

如果停止一切工作线程,然后慢慢的去执行“标记-清理”算法,会导致系统卡死时间过长,很多响应无法处理。所以CMS垃圾回收器采取的是垃圾回收线程和系统工作线程尽量同时执行的模式来处理的

7.3.1 工作阶段

CMS在执行一次垃圾回收的过程一共分为4个阶段:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清理
  1. 初始标记

    CMS要进行垃圾回收时,会先执行初始标记阶段,这个阶段会让系统的工作线程全部停止,进入Stop the World状态,如下图:

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第36张图片

    所谓的初始标记,就是标记出来所有GC Roots直接引用的对象。

    GC Roots直接引用的对象:

    • 方法的局部变量
    • 类的静态变量

    (类的实例变量不是GC Roots,和对象共存亡,所以是不能作为GC Roots的)

    如下图:

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第37张图片

    第一个阶段,虽然要造成"STW"暂停一切工作线程,但是其实影响不大,因为速度很快,仅仅标记GC Roots直接引用的那些对象

  2. 并发标记

    这个阶段会让系统线程随意创建各种新对象,继续运行。在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用变成垃圾对象。

    在这个过程中,垃圾回收线程,会尽可能的
    对已有的对象进行GC Roots追踪

    所谓进行GC Roots追踪,意思就是对类似“ReplicaFetcher”之类的全部老年代里的对象,他会去看他被谁引用了?
    比如这里是被“ReplicaManager”对象的实例变量引用了,接着会看,“ReplicaManager”对象被谁引用了?会发现被“Kafka”类
    的静态变量引用了。那么此时可以认定“ReplicaFetcher”对象是被GC Roots间接引用的,所以此时就不需要回收他。如下图所示。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第38张图片

    但是这个过程中,在进行并发标记的时候,系统程序会不停的工作,他可能会各种创建出来新的对象,部分对象可能成为垃圾,如下图
    所示。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第39张图片

    第二个阶段,就是对老年代所有对象进行GC Roots追踪,其实是最耗时的

    它需要追踪所有对象是否从根源上被GC Roots引用了,但是这个最耗时的阶段,是跟系统程序并发运行的,所以其实这个阶段不会对系统运行造成影响的。

    因为第二阶段里,你一边标记存活对象和垃圾对象,一边系统在不停运行创建新对象,让老对象变成垃圾。所以第二阶段结束之后,绝对会有很多存活对象和垃圾对象,是之前第二阶段没标记出来的,如下图。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第40张图片

    此时进入第三阶段,要继续让系统程序停下来,再次进入“Stop the World”阶段。

  3. 重新标记(STW)

    重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾的情况,如下图。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第41张图片

  4. 并发清理

    重新恢复系统程序的运行。这个阶段就是让系统程序随意运行,然后他来清理掉之前标记为垃圾的对象即可。这个阶段其实是很耗时的,因为需要进行对象的清理,但是他也是跟系统程序并发运行的,所以其实也不影响系统程序的执行,如下图。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第42张图片

7.3.2 性能分析

最耗时的两个阶段:并发标记、并发清除。

并发标记:对老年代全部对相关进行GC Roots追踪,标记出来到底哪些可以回收。

并发清除:对各种垃圾对象从内存里清理掉。

但是第二阶段和第四阶段,都是和系统程序并发执行的,所以基本这两个最耗时的阶段对性能影响不大。

只有 第一个阶段(初始标记)和第三个阶段(重新标记)是需要“Stop the World”的,但是这两个阶段都是简单的标记而已,速度非常的快,所以基本上对系统运行响应也不大。

7.3.3 CMS带来的问题
  1. 并发回收垃圾导致CPU资源紧张

    • 并发标记的时候,需要对GC Roots进行深度追踪,这个过程会追踪大量的对象(因为老年代存活对象是比较多的),所以耗时较高。
    • 并发清理的时候,又需要把垃圾对象从各种随机的内存位置清理掉,也是比较耗时的
    • 所以在这两个阶段,CMS的垃圾回收线程是比较耗费CPU资源的。

    CMS默认启动的垃圾回收线程数数量 是 (CPU核数 + 3)/4

    假设是2核CPU,计算得到 (2 + 3) / 4 = 1,占用了宝贵的一个CPU。

  2. Concurrent Mode Failure

    在并发清理阶段,CMS只不过是回收之前标记好的垃圾对象,但是这个阶段系统一直在运行,可能会随着系统运行让一些对象进入老年代,同时还变成了垃圾对象,这种垃圾对象是“浮动垃圾”

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第43张图片

    红圈的地方:就是在并发清除阶段,系统程序可能先把某些对象分配在新生代,然后可能触发一次Minor GC,一些对象进入了老年代,然后短时间内又没人去引用这些对象,这种对象,就是老年代的"浮动垃圾"

    虽然成为了垃圾,但是CMS只能回收之前标记出来的垃圾对象,不会回收它们,需要等到下一次GC的时候才会回收它们。

    所以为了保证CMS垃圾回收期间,还有一定的内存空间让一些对象可以进入老年代,一般会预留一些空间。

    CMS垃圾回收的触发时机:

    其中有一个就是当老年代内存占用达到一定比例时了,就自动执行GC。

    -XX:CMSInitiatingOccupancyFaction:这个参数可以用来设置老年代占用多少比例的时候触发CMS垃圾回收,JDK1.6默认的值是92%。即老年代占用了92%空间了,就自动进行CMS垃圾回收,预留8%的空间给并发回收期间,系统程序把一些新对象放入老年代中。

    如果CMS垃圾回收期间,系统程序要放入老年代的对象大于了可用内存空间,此时会如何?

    这个时候,会发生Concurrent Mode Failure,即并发垃圾回收失败了,一边回收,一边把对象放入老年代,内存都不够了。此时就会自动用Serial Old垃圾回收器替代CMS,直接强行把系统程序"Stop the World",重新进行长时间的GC Roots追踪,标记出来全部垃圾对象,不允许新的对象产生。然后一次性把垃圾对象都回收掉,完事了再恢复系统线程。

    所以在生产实践中,这个自动触发CMS垃圾回收的比例需要合理优化一下,避免“Concurrent Mode Failure”问题

  3. 内存碎片问题

    是老年代的CMS采用“标记-清理”算法,每次都是标记出来垃圾对象,然后一次性回收掉,这样
    会导致大量的内存碎片产生。

    如果内存碎片太多,会导致后续对象进入老年代找不到可用的连续内存空间了,然后触发Full GC

    所以CMS不是完全就仅仅用“标记-清理”算法的,因为太多的内存碎片实际上会导致更加频繁的Full GC

    • CMS有一个参数是“-XX:+UseCMSCompactAtFullCollection”,默认就打开了。意思是在Full GC之后要再次进行“Stop the World”,停止工作线程,然后进行碎片整理,就是把存活对象挪到一起,空出来大片连续内存空间,避免内存碎片。

    • 还有一个参数是“-XX:CMSFullGCsBeforeCompaction”,这个意思是执行多少次Full GC之后再执行一次内存碎片整理的工作,默认是0,意思就是每次Full GC之后都会进行一次内存整理

7.4 G1工作机制

7.4.1 Par New + CMS痛点

Stop the World:最大的痛点

无论是新生代还是老年代,或多或少都会产生"Stop the World"现象,对系统的运行是有一定影响的。所以其实之后对垃圾回收器的优化,都是朝着减少Stop the World的目标去做的。

7.4.2 G1垃圾回收器原理

G1垃圾回收器可以同时回收新生代和老年代对象,不需要两个垃圾回收器配合使用。

1. 最大的一个特点:就是把Java堆内存拆分为多个大小相等的Region

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第44张图片

G1也有新生代和老年代的概念,只不过是逻辑上的概念

image-20210619011350091

2. 最大的另一个特点:就是可以让我们设置一个垃圾回收的预期停顿时间

说比如我们可以指定:希望G1在垃圾回收的时候,可以保证,在1小时内由G1垃圾回收导致的“Stop the World”时间,
也就是系统停顿的时间,不能超过1分钟

之前我们的很多JVM优化的思路,其实我们对内存合理分配,优化一些参数,就是为了尽可能减少Minor GCFull GC,尽量减少GC带来的系统停顿,避免影响系统处理请求。但是现在我们直接可以给G1指定,在一个时间内,垃圾回收导致的系统停顿时间不能超过多久,G1全权给你负责,保证达到这个目
标。相当于我们就可以直接控制垃圾回收对系统性能的影响了。

问题:

  • G1如何做到对垃圾回收导致的系统停顿可控?

    其实G1如果要做到这一点,他就必须要追踪每个Region里的回收价值

    如下图所示,G1通过追踪发现,1个Region中的垃圾对象有10MB,回收他们需要耗费1秒钟,另外一个Region中的垃圾对象有
    20MB,回收他们需要耗费200毫秒。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第45张图片

    然后在垃圾回收的时候,G1会发现在最近一个时间段内,比如1小时内,垃圾回收已经导致了几百毫秒的系统停顿了,现在又要执行一次垃圾回收,那么必须是回收上图中那个只需要200ms就能回收掉20MB垃圾的Region啊!

    于是G1触发一次垃圾回收,虽然可能导致系统停顿了200ms,但是一下子回收了更多的垃圾,就是20MB的垃圾,如下图。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第46张图片

    简单来说:G1可以做到让你来设定垃圾回收对系统的影响,他自己通过把内存拆分为大量小Region,以及追踪每个Region中可以回收的对象大小和预估时间,最后在垃圾回收的时候,尽量把垃圾回收对系统造成的影响控制在你指定的时间范围内,同时在有限的时间内尽量回收尽可能多的垃圾对象

  • Region可能属于新生代也可能属于老年代

    在G1中,每一个Region时可能属于新生代,但是也可能属于老年代的。

    刚开始Region可能谁都不属于,然后接着就分配给了新生代,然后放了很多属于新生代的对象,接着就触发了垃圾回收这个Region。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第47张图片

    然后下一次同一个Region可能又被分配了老年代了,用来放老年代的长生存周期的对象,如下图所示。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第48张图片

    所以其实在G1对应的内存模型中,Region随时会属于新生代也会属于老年代,所以没有所谓新生代给多少内存,老年代给多少内存这
    一说了。

    实际上新生代和老年代各自的内存区域是不停的变动的,由G1自动控制。

7.4.3 G1分代回收原理深度解析
  • 如何设定G1对应的内存大小?

    (有多少个Region呢?每个Region大小是多大?)

    • 默认情况是自动计算和设置的

      给整个堆内存设置一个大小,比如说用“-Xms”和“-Xmx”来设置堆内存的大小。
      
      然后JVM启动的时候一旦发现你使用的是G1垃圾回收器,可以使用
      
      `“-XX:+UseG1GC”`来指定使用G1垃圾回收器,此时会自动用堆大小除以2048。
      
      因为JVM最多可以有2048个Region,然后Region的大小必须是2的倍数,比如说1MB、2MB、4MB之类的。 
      
      比如说堆大小是4G,那么就是4096MB,此时除以2048个Region,每个Region的大小就是2MB。大概就是这样子来决定Region的数量和大小的,一般保持默认的计算方式就可以。
      
    • 手动方式指定,则是“-XX:G1HeapRegionSize”

      【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第49张图片

      刚开始的时候,默认新生代对堆内存的占比是5%,也就是占据200MB左右的内存,对应大概是100个Region,这个是可以通过“-XX:G1NewSizePercent”来设置新生代初始占比的,其实维持这个默认值即可。
      
      因为在系统运行中,JVM其实会不停的给新生代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”。
      
      而且一旦Region进行了垃圾回收,此时新生代的Region数量还会减少,这些其实都是动态的。
      

      【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第50张图片

  • 新生代还有Eden和Survivor概念吗?

    其实在G1中虽然把内存划分为了很多的 Region,但是其实还是有新生代、老年代的区分,而且新生代里还是有Eden和Survivor的划分的。

    比如新生代之前说刚开始初始的时候,有100个Region,那么可能80个Region就是Eden,两个Survivor各自占10个Region,如下图。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第51张图片

    所以其实还是有Eden和Survivor的概念的,他们会各自占据不同的Region。

    只不过随着对象不停的在新生代里分配,属于新生代的Region会不断增加,Eden和Survivor对应的Region也会不断增加。

  • G1的新生代垃圾回收

    既然G1的新生代也有Eden和Survivor的区分,那么触发垃圾回收的机制都是类似的。

    随着不停的在新生代的Eden对应的Region中放对象,JVM就会不停的给新生代加入更多的Region,直到新生代占据堆大小的最大比例60%

    一旦新生代达到了设定的占据堆内存的最大大小60%,比如都有1200个Region(2048 * 60%)了,里面的Eden可能占据了1000个Region,每个
    Survivor是100个Region,而且Eden区还占满了对象,此时如下图所示。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第52张图片

    这个时候还是会触发新生代的GC,G1就会用之前说过的复制算法来进行垃圾回收,进入一个“Stop the World”状态。

    然后把Eden对应的Region中的存活对象放入S1对应的Region中,接着回收掉Eden对应的Region中的垃圾对象,如下图。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第53张图片

    但是这个过程跟之前是有区别的,因为G1是可以设定目标GC停顿时间的,也就是G1执行GC的时候最多可以让系统停顿多长时间,可以通过

    “-XX:MaxGCPauseMills”参数来设定,默认值是200ms。

    那么G1就会通过之前说的,对每个Region追踪回收他需要多少时间,可以回收多少对象选择回收一部分的Region,保证GC停顿时间控制在指定范围内**,**尽可能多的回收掉一些对象。

  • 对象什么时候进入老年代?

    跟之前一样,还是这么几个条件:

    • 对象在新生代躲过了很多次的垃圾回收,达到了一定的年龄了,

      “-XX:MaxTenuringThreshold”参数可以设置这个年龄,他就会进入老年代

    • 动态年龄判定规则,如果一旦发现某次新生代GC过后,存活对象超过了Survivor的50%

      比如年龄为1岁,2岁,3岁,4岁的对象的大小总和超过了Survivor的50%,此时4岁以上的对象全部会进入老年代,这就是动态年龄判定规则
      

      【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第54张图片

    • 大对象Region

      G1提供了专门的Region来存放大对象,而不是让大对象进入老年代的Region中。

      在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2MB,只要一个大对象超过了1MB,就会被放入大对象专门的Region中。而且一个大对象如果太大,可能会横跨多个Region来存放。如下图。

      【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第55张图片

      不是说60%的给新生代,40%的给老年代吗,那还有哪些Region给大对象?
      
      在G1里,新生代和老年代的Region是不停的变化的。 比如新生代现在占据了1200个Region,但是一次垃圾回收之后,就让里面1000个Region都空了,此时那1000个Region就可以不属于新生代了,里面很多Region可以用来存放大对象。
      
      大对象既然不属于新生代和老年代,那么什么时候会触发垃圾回收呢?
      其实新生代、老年代在回收的时候,会顺带带着大对象Region一起回收,所以这就是在G1内存模型下对大对象的分配和回收的策略。
      

什么时候触发新生代 + 老年代的混合垃圾回收?

G1中的一个参数:-XX:InitiatingHeapOccupancyPercent,默认值是45%。即老年代占据了堆内存的45%的Region的时候,此时会触发一个新生代 + 老年代一起回收的混合回收阶段。

7.4.4 G1垃圾回收的过程

垃圾回收过程:

  • 首先触发一个"初始标记"的操作,这个过程是需要进入"Stop the World",仅仅只是标记一下GC Roots直记引用的对象,这个过程速度是很快的

    如下图,先停止系统程序的运行,然后对各个线程栈内存中的局部变量代表的GC Roots,以及方法区中的类静态变量代表的GC Roots,进行扫描,标记出来它们直接引用的那些对象。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第56张图片

  • 接着会进入"并发标记"的阶段,这个阶段允许系统程序的运行,同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象,如下图:

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第57张图片

    这个并发标记阶段还是很耗时的,因为要追踪全部的存活对象。但是这个阶段是可以跟系统程序并发运行的,所以对系统程序的影响不太大。而且JVM会对并发标记阶段对对象做出的一些修改记录起来,比如说哪个对象被新建了,哪个对象失去了引用。

  • 接着是下一个阶段,最终标记阶段,这个阶段会进入"Stop the World",系统程序是禁止运行的,但是会根据并发标记阶段记录的那些对象修改,最终标记一下有哪些存活对象,有哪些是垃圾对象。如下图所示:

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第58张图片

  • 最后一个阶段,就是混合回收阶段,这个阶段会计算老年代每个Region中的存活对象数量,存活对象的占比,还有执行垃圾回收的预期性能和效率。接着会停止系统程序,然后全力以赴尽快进行垃圾回收,此时会选择部分Region进行回收,因为必须让垃圾回收的停顿时间控制在我们指定的范围内。

    比如说老年代此时有1000个Region都满了,但是因为根据预定目标,本次垃圾回收可能只能停顿200毫秒,那么通过之前的计算得
    知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,把GC导致的停顿时间控制在我们指定的范围内,如
    下图。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第59张图片

    老年代对堆内存占比达到45%的时候,触发的是'混合回收'

    也就是说,此时垃圾回收不仅仅是回收老年代,还会回收新生代,还会回收大对象。

7.4.5 G1垃圾回收参数的设置
  • -XX:G1MixedGCCountTarget,默认值是8次。

    混合回收的时候,会停止所有程序执行,所以说G1是允许执行多次混合回收。
    比如先停止工作,执行一次混合回收掉一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收掉一些Region。

    有一个参数可以控制, -XX:G1MixedGCCountTarget参数, 即在一次混合回收的过程中,最后一个阶段执行几次混合回收,默认值是8次。

    为什么要反复回收多次?

    因为停止系统一会儿,回收掉一些Region,再让系统运行一会儿,然后再次停止系统一会儿,再次回收掉一些Region,这样尽可能让得系统不要停顿时间太长,可以在多次回收的间隙,也运行一下。

  • -XX:G1HeapWastePercent,默认值是5%

    在混合回收的时候,对Region回收都是基于复制算法进行的,都是要把回收的Region里的存活对象放到其它Region,然后这个Region中的垃圾对象全部清理掉。

    【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第60张图片

    这样的话再回收的过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收结束。

    而且从这里也可以看出来,G1整体是基于复制算法进行Region垃圾回收的,不会出现内存碎片的问题,不需要像CMS那样标记-清除之后,再进行内存碎片的整理。

  • -XX:G1MixedGCLiveThresholdPercent,默认值是85%

    回收Region的时候,必然是存活对象低于85%的Region才可以回收,否则把85%对象拷贝到别的Region,成本是很高的。

7.4.6 回收失败的Full GC

如果在进行Mixed回收的时候,无论新生代还是老年代都是基于复制算法进行回收,都要把各个Region的存活对象拷贝到别的Region里去。

此时万一拷贝的过程中发现没有空闲Region可以承载承载自己的存活对象了,就会触发一次失败。

一旦失败,立马就会切换为停止系统程序,然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region,这个过程是极慢的。

四、综合案例

1、线程系统突然卡死无法访问(JVM GC)

平时我们基于Java写出来的系统在部署的时候,基于JVM运行的时候,最核心的内存区域就是堆内存,在这里会放各种我们系统中创建出来的对象。

而且堆内存通常会划分为新生代和老年代两个内存区域,对象一般来说都是优先分配在新生代的,如下图所示:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第61张图片

随着系统不停的运行,一定会导致越来越多的对象放入新生代中,然后新生代快要塞满时,就放不下更多的对象了,毕竟内存是有限的,这个时候必须清理一下新生代的垃圾回收,即那些没有被GC Roots引用的对象。

GC Roots就是的类的静态变量、方法的局部变量。平时我们最经常创建对象的地方,就是在方法里,但是一旦一个方法运行结束之后,方法的局部变量就没了,此时之前在方法创建出来的对象就是垃圾了,没人引用。

如何对新生代的垃圾对象进行回收呢?

通过复制算法进行回收,一旦要对新生代进行垃圾回收了,此时一定会停止系统程序的运行,不让系统程序执行任何代码逻辑,这个过程就是Stop the World

所谓的复制算法,就是对`所有的GC Roots进行追踪`,去标记出来所有被GC Roots直接或者间接引用的对象,它们就是存活对象。

此时只能允许后台的垃圾回收器的多个垃圾回收线程去工作,执行垃圾回收,如下图:

【JVM】 ---- 大白话图文之JVM类加载机制、内存区域、垃圾回收_第62张图片

每次一旦新生代塞满之后,在进行垃圾回收的时候,这个期间都必须停止系统程序的运行。

这个就是JVM运行的系统最害怕的问题: 系统卡顿问题

1.1 新生代GC多久一次对系统影响不大?

其实通常来说新生代GC对系统的性能影响是不大的,它的运行逻辑非常简单,就是一旦Eden和一块Survivor满了,无法放新对象就会触发一次GC。

所以对新生代GC进行调优:只要给系统分配足够的内存即可,核心点还是在于堆内存的分配、新生代内存的分配。

内存足够的话,通常来说系统可能在低峰时期几个小时才有一次新生代GC,高峰期最多也就几分钟一次新生代GC。

而且新生代采用复制算法效率极高,因为新生代存活的对象很少,只要迅速标记出这少量存活对象,移动到Survivor区,然后回收掉其它全部垃圾对象即可,速度很快。

很多时候,一次新生代GC可能就耗费几毫秒,几十毫秒,几乎用户都是无感知的。

1.2 什么时候新生代GC对系统影响很大?

当系统部署在大内存机器上的时候,比如机器是32核64G的机器,此时分配给系统有几十个G,新生代的Eden区可能30~40G的内存。

比如类似Kafka、Elasticsearch之类的大数据相关的系统,都是部署在大内存的机器上的,此时如果你的系统负载非常的高,对于大数据系统是很有可能的,比如每秒几万的请求到Kafka、Elasticsearch上去。

可能导致Kafka、Elasticsearch区的几十G内存频繁塞满要触发垃圾回收,假设1分钟塞满1次。然后每次垃圾回收要停顿Kafka、Elasticsearch的运行,然后执行垃圾回收大概需要几秒钟,可能每过一分钟,系统就要卡顿几秒钟,有的请求甚至一旦卡死几秒钟就会超时报错,此时可能会导致你的系统频繁出错。

如何解决大内存机器的新生代的GC过慢的问题?

  • 用G1垃圾回收器

    针对G1,可以设置一个期望的每次GC的停顿时间,基于它的Region内存划分原理,就可以在运行一段时间之后,针对2G内存的Region进行垃圾回收,此时就仅仅停顿20ms,然后回收掉2G的内存空间,腾出来部分内存后,接着还可以继续让系统运行。

    G1天生就适合这种大内存机器的JVM运行,可以完美解决大内存垃圾回收时间过长的问题。

1.3 频繁老年代GC问题

新生代GC一般问题不会太大,真正问题最大的地方在于频繁触发老年代的GC。

对象进入老年代的几个条件:

  • 年龄太大了

    这种对象一般很少,都是系统中确实需要长期存在的核心组件,一般不需要被回收掉,所以在新生代熬过默认15次垃圾回收之后就会进入老年代

  • 动态年龄判断规则

    如果一次新生代GC后,发现Survivor区域中的几个年龄的对象加起来超过了Survivor区域的50%,比如年龄1 + 年龄2 + 年龄3的对象大小总和,超过了Survivor区域的50%,此时就会把年龄3以上的对象都放入老年代。

  • 新生代GC后存活对象太多无法进入Survivor中

对于上述进入老年代条件,第二个和第三个是很关键的,通常如果新生代中的Survivor区域内存过小,就会导致上述第二个和第三个条件频繁发生,然后导致大量对象快速进入老年底啊,进而频繁触发老年代的GC。

老年代GC通常来说都很耗费时间,无论是CMS垃圾回收器还是G1垃圾回收器,比如说CMS要经过初始标记、并发标记、重新标记、并发清除、碎片整理几个环节,过程非常的复杂,G1同样也如此。

通常来说,老年代GC至少要比新生代慢10倍以上,比如新生代GC每次耗费200ms,其实对用户影响不大,但是老年代每次GC耗费2s,那么可能就会导致老年代GC的时候用户发现页面上卡顿2s,影响就很大了。

所以一旦因为JVM内存分配不合理,导致频繁进行老年代GC,每次GC系统都要停顿几秒钟,此时用户会发现页面上或者APP上经常性的出现点击按钮之后卡顿几秒钟。

1.4 JVM性能优化是优化什么?

通过上面的描述,系统真正最大的问题,就是因为内存分配,参数设置不合理,导致对象频繁的进入老年代,然后频繁触发老年代GC,导致系统频繁的每隔几分钟就要卡顿几秒钟。

五、问题答疑

1、什么时候会尝试触发Minor GC?

  • Eden区和存对象的其中一个Survivor区满的时候触发MinorGC

2、触发Minor GC之前如何检查老年代大小,涉及哪几个步骤和条件?

  • 检查新生代所有对象所占空间是否大于老年代可用空间, 如果小于,进行MinorGC。

  • 如果大于,查看"-XX:-HandlePromotionFailure"是否设置。

  • 如果没设置,进行FullGC。

    如果设置,判断老年代空间是否大于之前MinorGC后进入老年代对象的平均大小。 如果大于,进行MinorGC。 如果小于,进行FullGC。

3、什么时候触发Minor GC之前就会提前触发一次Full GC?

  • 新生代对象大小大于老年代空间,且没有设置"-XX:-HandlePromotionFailure"
  • 设置了"-XX:-HandlePromotionFailure",老年代可用空间小于之前每次MinorGC后进入老年代的平均大小

4、Full GC的算法是什么?

  • 标记整理算法,老年代对象存活时间较长,复制算法不太适合,标记-清理算法会产生内存碎片。标记整理可以规避。

5、Minor GC过后可能对应哪几种情况?

  • Minor GC前先判断:存活的对象所占的内存空间 < Survivor区域,那么存活对象进入进入Survivor区域

  • Minor GC前先判断:Survivor区域内存空间的大小 < 存活的对象所占的内存空间 < 老年代的可用空间大小。
    那么存活的对象,直接进入老年代

  • Minor GC前先判断: (存活的对象所占的内存空间 > Survivor区域内存空间的大小) && (存活的对象所占的
    内存空间 > 老年代的可用空间大小)。那么会触发Full GC,老年代腾出空间后,再进行Minor GC。如果腾出空间后还
    不能存放存活的对象,那么会导致OOM即堆内存空间不足、堆内存溢出。

6、哪些情况下Minor GC后的对象会进入老年代?

  • Survivor区 < 存活对象占用的空间

  • 经过XX:MaxTenuringThreshold次M

7、老年代的Full GC要比新生代的Minor GC慢很多倍,一般在10倍以上

  • 新生代(复制算法的优化)执行速度其实很快,因为直接从GC Roots出发就追踪哪些对象是活的就行了,新生代存活对象是很少的,这个速度是极快的,
    不需要追踪多少对象。

    然后直接把存活对象放入Survivor中,就一次性直接回收Eden和之前使用的Survivor了。

  • CMS的Full GC(标记-清除算法)

    • 在并发标记阶段,他需要去追踪所有存活对象,老年代存活对象很多,这个过程就会很慢;
    • 其次并发清理阶段,他不是一次性回收一大片内存,而是找到零零散散在各个地方的垃圾对象,速度也很慢;
    • 最后完事儿了,还得执行一次内存碎片整理,把大量的存活对象给挪在一起,空出来连续内存空间,这个过程还得“Stop the
      World”,那就更慢了。
    • 万一并发清理期间,剩余内存空间不足以存放要进入老年代的对象了,引发了“Concurrent Mode Failure”问题,那更是麻烦,还得
      立马用“Serial Old”垃圾回收器,“Stop the World”之后慢慢重新来一遍回收的过程,这更是耗时了。

8、Full GC、Major GC、Minor GC、Young GC

  • Full GC和Major GC其实是一个概念,指的是老年代的GC,只不过一般会带着一次Minor GC,也就是Young GC

9、针对web服务,POJO类一般都是在新生代,而通过@service @controller @component 等注解创建的对象一般都是在老年代。

  • 针对一些纯java代码的后台跑的服务,基本都是新生代,除了一些通过静态变量或者常量引用的类,或者通过单例创建的类(本质也是通过静态变量引用)。

10、老年代会默认预留8%的空间给并发回收期间,进入老年代的对象使用,若进入老年代的对象大于8%的空间,是否会触发Full GC

  • 这种情况会触发concurrent mode failure,用serial old进行垃圾回收,直接stop the world

11、对象进入触发 -->哪些情况下有对象进入老年代?

  • 大对象直接分配到老年代
  • Minor GC的年龄到了15(有参数设置)
  • 存活对象大于survivor from 区
  • 动态年龄挤出对象,规则触发

12、G1在什么场景下适用呢?

  • G1压缩内存空间会比较有优势,适合会产生大量碎片的应用;
  • G1能够可预期的GC停顿时间,对高并发应用更有优势
  • 其他垃圾收集器对大内存回收耗时较长,G1对内存分成多块区域,能够根据预期停顿时间选择性的对垃圾多的区域进行回收,适用多核、jvm内存占用大的应用
  • parNew+cms回收器比较适用内存小,对象能够在新生代中存活周期短的应用

你可能感兴趣的:(JVM)