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

Posted TheWhc

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM ---- 大白话图文之JVM类加载机制内存区域垃圾回收相关的知识,希望对你有一定的参考价值。

JVM

一、类加载机制

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

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

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

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

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

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

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 双亲委派机制

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

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

好处:

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

3.3 Tomcat类加载机制

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

  • Tomcat自定义了Common、Catalina、Shared等类加载器,是用来加载Tomcat自己的一些核心基础类库的
  • Tomcat为每个部署在里面的Web应用都有一个对应的WebApp类加载器,负责加载我们部署的这个Web应用的类
  • Jsp类加载器,则是给每个JSP都准备了一个Jsp类加载器

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

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

3.3.1 破坏双亲委派

原因:隔离、灵活、性能

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

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

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

二、内存区域

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

1、内存区域划分

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

如图是JDK1.8之前:

如图是JDK1.8

1.1 存放类的方法区

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

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

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

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

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

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

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

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

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;
   

整个过程如图所示:

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

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;
	

1.5 其它内存区域

  • 本地方法栈

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

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

  • 堆外内存

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

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

  • 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区怎么办?如图所示

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

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

    如果新生代有大量对象存活,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内存里来进行计算处理。如图所示

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

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

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

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

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

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

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

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

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

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

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

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

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

此时老年代可用空间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了,几乎快满了,如图所示

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

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

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

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

2.3.4 如何进行JVM优化?

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

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

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

通过分析和优化,可以把生产系统的老年代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区去。

好处: 只有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系统的所有工作线程,让我们写的代码不再运行。

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

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

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

7.1.2 造成的系统停顿

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

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

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

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

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

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

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

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

7.2 ParNew工作机制

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

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

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是被浪费了,根本没用上。

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

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

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

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

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

      但是其实现在一般很少有用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状态,如下图:

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

    GC Roots直接引用的对象:

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

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

    如下图:

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

  2. 并发标记

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

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

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

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

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

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

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

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

  3. 重新标记(STW)

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

  4. 并发清理

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

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

    红圈的地方:就是在并发清除阶段,系统程序可能先把某些对象分配在新生代,然后可能触发一次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

以上是关于JVM ---- 大白话图文之JVM类加载机制内存区域垃圾回收的主要内容,如果未能解决你的问题,请参考以下文章

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

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

JAVA-大白话探索JVM-运行时内存

2期JVM必知必会

JVM中的类加载机制

jvm之java类加载机制和类加载器(ClassLoader)的详解