当前位置: 首页 > news >正文

JVM学习总结

JVM学习总结

文章目录

  • JVM学习总结
    • 1. 学习路线图
    • 2. java代码是如何运行的
    • 3. JVM的类加载机制
      • 3.1. 加载(Loading) 阶段
      • 3.2. 验证(Verification) 阶段
      • 3.3. 准备(Preparation) 阶段
      • 3.4. 解析(Resolution) 阶段
      • 3.5.小结
      • 3.6. 初始化(lnitialization)阶段
      • 3.7. 总结
    • 4. 类加载器
      • 4.1.类加载器的概述
      • 4.2.类加载器的分类
        • 启动类加载器
        • 扩展类加载器
        • 应用程序类加载器
        • 自定义类加载器
      • 4.3. 类加载器原理-双亲委派
        • 4.3. 1. 类加载时使用了双亲委派模式
        • 4.3. 2. 而双亲委派机制是如何运作的呢
        • 4.3. 3. 而为什么要这么麻烦的从下到上,再从上到下呢?
        • 4.3. 4. 阿里面试题:Tomcat容器类加载器设计
        • 4.3.5. 线程上下文类加载器(Context Classloader)
    • 5. JVM的内存结构
      • 5.1. JVM内存结构划分
      • 5.2. 程序计数器
      • 5.3. 虚拟机栈
        • 5.3.1. 栈内存面试案例剖析</font>
      • 5.4. 本地方法栈
      • 5.5. 堆
        • 5.5.1. 概念:
        • 5.5.2. 内存分配关系
        • 5.5.3. 堆内存大小配置
        • 5.5.4. 堆内存分代模型-新生代和老年代
        • 5.5.5. 图解对象分配机制
        • 5.5.6. 对象分配流程案例实战
        • 5.5.7. 大对象频繁创建导致OOM
      • 5.6. 方法区
        • 5.6.1. HotSpot中方法区的演进
        • 5.6.2. 方法区内部结构
        • 5.6.3. 运行时常量池
        • 5.6.4. 方法区的演进细节
        • 5.6.5. StringTable
    • 6. JVM从加载到内存全过程图
    • 7. 案例实战剖析
      • 7.1. 如何抗住双11一天几十亿的订单量?JVM该如何设置内存?
      • 7.2. 每日百万的支付系统应该如何设置JVM内存?
        • 一个每日百万交易的支付系统压力在哪里?
        • 支付系统内存占用预估
        • 支付系统JVM内存如何设置?
      • 7.3. 双11大促,瞬时访问量增加10倍
    • 8. 如何判断对象可以回收
      • 8.1. 可达性分析算法
      • 8.2. 强引用、软引用、弱引用、虚引用
        • 强引用
        • 软引用
        • 弱引用
        • 虚引用
      • 8.3.【生存还是死亡?】对象的finalization机制
    • 9. JVM垃圾回收算法【新生代】
      • 9.1.标记清除算法
      • 9.2. 内存碎片
      • 9.3. 标记复制算法
      • 9.4.复制算法的缺点
    • 10. 对象进老年代案例剖析
      • 10.1. ①幸存者区装不下
      • 10.2. ②对象太大
      • 10.3. ③年龄到15岁
        • 10.3.1. MaxTenuringThreshold=1的情况
        • 10.3.2. MaxTenuringThreshold=15的情况
      • 10.4. 动态对象年龄判断机制
      • 10.5. 空间分配担保
        • 老年代空间够用
        • 老年代空间不够
        • 小结
    • 11. 老年代垃圾回收算法-标记整理算法
    • 12.【实战】日处理上亿数据的系统内存分析和优化
      • 12.1. 系统背景
      • 12.2. 生产环境
      • 12.3. 过程分析
      • 12.4. JVM优化
    • 13. 垃圾收集器
      • 13.1. Serial收集器
      • 13.2. ParNew 收集器
      • 13.3. 老年代CMC收集器
        • 1) 初始标记(CMS initialmark)
        • 2) 并发标记(CMSconcurrentmark)
        • 3)重新标记(CMS remark)
        • 4) 并发清除(CMSconcurrentsweep)
        • 5) 小结
        • 6) CMS的缺点分析
          • 1.并发导致CPU资源紧张
          • 2.Con-current Mode Failure问题频
          • 3.内存碎片问题
      • 13.4. Garbage First收集器
        • 13.4.1. G1收集器介绍
        • 15.2. G1垃圾回收流程
          • 15.2.1.G1 Yong Collection
          • 15.2.2.G1 Yong Collection + Concunrrent Mark
          • 15.2.3.G1 Mixed Collection
          • 15.2.4.Full GC
      • 13.5. 总结

1. 学习路线图

在这里插入图片描述

2. java代码是如何运行的

  • 我们平时写的Java代码,到底是如何运行起来的?
    我们都知道,我们平时创建的一个一个类,在本地磁盘中的文件名后缀就是 .java,比如User.java、Product.java,这也叫做源代码文件。
  • 这些源代码文件必须经历我们的javac工具进行编译后生成 .class 的字节码文件才能被运行。

在这里插入图片描述

那接着我们就要继续思考了:那这些.class 字节码文件又是如何运行起来的?

(这里我们可以借助于DOS窗口执行 java 命令进行启动)

在这里插入图片描述

此时一旦采用 java 命令,实际上就是启动了一个JVM进程,由 JVM来负责加载这些字节码文件到内存进行执行。

在这里插入图片描述

而将class字节码文件加载到虚拟机的内存,这个过程称为类加载,其中涉及到【类加载机制】和【类加载器】的概念。

在这里插入图片描述

当字节码文件被类加载器加载进入到JVM内存中后,会通过JVM的执行引擎来执行我们内存中对应的类,比如类中的main方法,就会先被执行,而main方法中如果还涉及到其他的对象引用,类加载又会开始加载对应的字节码文件到内存,再由 JVM进行调用执行。(如下图)

在这里插入图片描述

当我们通过Eclipse或IDEA工具开发完一个完整的项目后,一般都会将项目整体打成一个jar 包或者war包,然后部署到对应线上服务器进行运行; 其实就是将我们写的所有Java代码编译成对应的字节码文件后,加上一些项目的资源文件一起打包,部署进服务器比如Tomcat,当我们通过java -jar之类的命令就可以运行和执行我们写好的代码。

在这里插入图片描述

ok,通过以上的分析,我们先整体对java代码的运行流程做了一个初步的介绍,接下来再深入分析类加载过程又是如何执行的,一步一步深入学习。


3. JVM的类加载机制

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括: 加载(Loading)验证(Verification)准备(Preparation)解析(Resolution)初始化(lnitialization)使用(Using)卸载(Unloading) 7个阶段。其中验证、准备、解析3个部分统称为链接(Linking),这7个阶段的发生顺序如图:

在这里插入图片描述

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不定: 它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。


3.1. 加载(Loading) 阶段

加载 Loading 阶段就是 JVM 第一次去加载和读取对应位置上的文件,上述的整个类加载过程其实都是类加载器(后续讲)在完成。
相当于正式建立了IO通道,在这条通道上,我们需要做上述的一系列事情,比如验证、准备、解析等。

  • 个人理解,这个加载的过程,类似建立一个管道

在这里插入图片描述


思考:JVM在什么情况下会加载一个类呢?
通过以上的类加载流程,我们可以得知第一个环节就是加载一个类,因此当我们在IDEA中或直接运行某一个类的时候(比如First.java),其实是启动了JVM进程,然后JVM会通过类加载器将这个类的字节码(First.class)加载到内存,然后调用main方法开始执行。如果main方法中的代码是:

public class First {public static void main(String[] args) {//创建Second这个类的实例Second second = new Second();}
}

JVM这个时候会先检査内存中是否有该类的对象,如果没有会触发类加载器加载磁盘中的Second.class字节码到内存中,如下图:

在这里插入图片描述

如何验证类是否已经加载?可以通过如下方式演示:

  1. 在E盘创建demo03这个文件夹
  2. 将LoadTest.java文件拷贝到该文件夹下面
  3. 通过cmd编译
  4. 运行并査看: java-XX:+TraceClassLoading -cp.demo03.LoadTest该命令是监视类加载的过程

在这里插入图片描述

package demo03;import java.util.Scanner;/*** @author J_shuai* @date 2025-08-25 21:28*/
public class LoadTest {public static void main(String[] args) throws ClassNotFoundException {Scanner sc = new Scanner(System.in);System.out.println("start...");sc.nextLine();Class.forName("demo03.One");sc.nextLine();Class.forName("demo03.Two");sc.nextLine();Class.forName("demo03.Three");sc.nextLine();System.out.println("end...");}
}class One {
}class Two {
}class Three {
}
  • 首先在该代码的目录下面同cmd命令打开控制台:输入

    • javac demo03\LoadTest
      
    • 得到字节码文件(如下图)

在这里插入图片描述

  • 执行 java-XX:+TraceClassLoading -cp.demo03.LoadTest 这个命令
    • 我们可以看到他在加载我们JDK的一些jar包,也就是是说jdk提供的一些核心内库
    • 我们的java程序运行的话,是需要JDK中的一些核心内库去支撑和解析的

在这里插入图片描述

  • 我们往下就可以看到start,这表示我们的程序在运行了

在这里插入图片描述

  • 当我在控制台输入的时候,会触发类加载到内存

在这里插入图片描述


小结:
当我们需要运行某个类或代码中需要使用到另外某一个类的时候,JVM会先检查内存中是否已经加载过对应的字节码文件,如果没有则通过类加载器进行关联加载,并且按照整个生命周期包括 : 加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)进行执行。


3.2. 验证(Verification) 阶段

验证是链接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《lava虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
主要包括四种验证:文件格式验证(魔数CAFEBABE),元数据验证,字节码验证,符号引用验证。

简单说就是我们的【.class】文件是否符合JVM规范,是否有被篡改,否则 JVM是没法执行该字节码文件的。

每个符合规范的 Java文件二进制开头应该是对应的魔数CAFEBABE

在这里插入图片描述

每个类在被加载到 JVM内存前都会还行验证这个过程:

  • 首先我们在第一次要使用类的时候JVM与本地的类的class文件进行搭建一个连接(个人称之为管道)之后在加载到类的class文件后,需要在这个管道进行验证,符合规范才能进入下一个阶段

在这里插入图片描述

IDEA中安装插件:<查看对应的字节码文件二进制>

在这里插入图片描述


3.3. 准备(Preparation) 阶段

重点理解

准备阶段是正式为类中定义的变量 (即静态变量,被static修饰的变量) 分配内存并设置类变量初始值的阶段。

在这里插入图片描述

解释

  1. 比方说我在LoadTest类中定义一个静态变 static int age=10,当我们给这个 age 分配内存的时候,因为这个age是静态变量,他是依赖于这个LoadTest类的,所以我们需要先给这个LoadTest类的字节码文件在JVM中分配内存,而给这个LoadTest类字节码文件分配内存时,前提是需要将这个LoadTest类字节码文件加载到 JVM 中去,(此时的阶段是准备阶段,这个字节码已经加载到内存,这里只是强调一下,我们要时刻记住类加载的几个不变的阶段),加载到 JVM 中,放到 JVM 那个空间呢?JVM的内存空间是划分五个区域的,而LoadTest类的字节码文件是放到 JVM的方法区中,而方法区的具体实现叫做元空间,而LoadTest类字节码文件就是一个模版,里面存储的就是一些数据结构。
    1. 在准备阶段之前首先通过类加载阶段将LoadTest类的字节码文件加载到内存,此时JVM 内存中就有了LoadTest类的字节码文件了
    2. 而这个字节码文件存放的信息:这个类有哪些字段,有啥方法,变量,常量……
    3. JVM 会根据存放在方法区的 LoadTest类的这个模版(前面提到过),在堆内存里面创建一个对象,这个对象就叫字节码文件对象
    4. 而在字节码文件对象中,需要给LoadTest类的类变量进行初始化值 ,初始化static int age=0,注意此时不是10。
    5. 准备阶段只会给静态变量赋值初始值

注意事项:

  1. static 变量在JDK7之前存储于instanceKlass 末尾,从JDK7开始,存储于 java_mirror 末尾

​ (换句话理解就是: 1.7之前存储于方法区,1.7之后存储于堆内存中)

在这里插入图片描述

  • 所以说我们的类变量,都是存放在字节码文件对象里面的,更准确点:类变量(静态变量)是存放在 堆内存的字节码文件对象里面的
  1. 以后我们在代码里面 new 其它对象的时候,都是根据当前类的字节码对象去造出新的一些具体对象
  2. static 变量分配空间和赋值是两个步骤,分配空间 (内存分配) 在准备阶段完成,赋值在初始化阶段完成的

​ 关于准备阶段,还有两个容易产生混淆的概念需要着重强调,首先是这时候进行内存分配的仅包括 类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java堆中。其次是这里所说的初始值“通常情况"下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何 Java 方法,而把 value 赋值为123的putstatic指令是程序被编译后,存放于类构造器clinit()方法(类似初始化方法)之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。

  1. 如果static变量是final 修饰的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成

​ 上面提到在“通常情况“下初始值是零值,那言外之意是相对的会有某些"特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定 的初始值,假设上面类变量value的定义修改为:

public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置 将value赋值为123。

  1. 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
    测试代码:
public class LoadTest {static int a;public static int b = 10;static final int c = 20;static final String d = "hello";private static final User user = new User();
}

通过反编译进行验证:

  1. 准备阶段:就是给静态变量进行内存的分配以及给与初始化的默认值

​ 比如:a,b 都是类变量,那么都会在初始化阶段才会进行赋值

  1. 但是其他的被final修饰的类变量以及字符串常量,这里的c和d是会在准备阶段就进行赋值的,也就是初始化阶段已经有值了不会再赋值了
  2. 这里有一个特殊情况: 就是如果被static和final修饰的是一个引用类型的变量,它依然会在初始化阶段才会赋值
    这个情况就是我们这里的user变量

3.4. 解析(Resolution) 阶段

仅做了解即可

主要将常量池中的符号引用替换为直接引用的过程。
Java代码在进行 Javac 编译的时候,在虚拟机加载 Class 文件的时候进行动态连接。在Class文件中不会保存各个方法、字段最终 在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号 引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

  • 符号引用(SymbolicReferences): 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《ava虚拟机规范》的Class文件格式中。
  • 直接引用(Direct References): 直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

3.5.小结

这三个阶段中,大家最应该关心的核心是:准备阶段
这个阶段是给加载进来的类进行空间的分配,以及static静态变量的空间分配,并且给与初始化值。

在这里插入图片描述


3.6. 初始化(lnitialization)阶段

  • 通过准备阶段类变量已经赋过一次系统要求的初始零值,而初始化阶段就是在给类变量进行赋值操作。
  • 初始化阶段会执行类构造器方法c1init() ,该方法不同于类的构造器(是虚拟机视角下的init()),而类的构造器是给对象进行初始化的;
  • 该方法不需要定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
  • 通过代码观察:
    我们second类中的静态变量b是在c1init()方法中才被真正初始化的–>对应了静态变量的赋值操作

在这里插入图片描述

如果我们在second类中再添加一个静态代码块,去修改b的值为11,可以验证静态代码块的操作也是在c1init中执行的:

在这里插入图片描述

注意1:

类初始化顺序:静态变量的声明和静态代码块的执行顺序是按照它们在源代码中出现的顺序进行的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

/*** 静态代码块是在初始化阶段才会被执行,因此当初始化阶段去修改i变量的时候,i变量已经存在了,语法是通过的。但是这里请注意:如果你想在静态代码块里直接去打印这个类变量,是不允许的*/public class InitDemo {//静态代码块是在初始化阶段才会被执行,因此static {i = 20;// (这是 Java 的特殊规则,允许向前赋值),因为编译器能确认“将来会有一个 i,所以这行代码最终是给这个 i 赋值”,因此合法。//System.out.println(i);// 这句编译器会提示“非法向前引用”,因为此时i还是未声明的变量,你是无法打印的,编译器需要确保变量在被读取前已经完成声明和初始化,所以会提示 "非法向前引用" 错误。}public static int i = 10;//这个变量i在准备阶段就会被Jvm分配内存并且赋上初始化的默认值,也就是0
}

在这里插入图片描述

注意2:

父类初始化先执行<clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的 <clinit>()方法的类型肯定是java.lang.Object

注意3:

线程同步 : Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

public class ThreadInitTest {public static void main(String[] args) {Runnable script = new Runnable() {public void run() {System.out.println(Thread.currentThread() + "start");DeadLoopClass dlc = new DeadLoopClass();System.out.println(Thread.currentThread() + " run over");}};Thread thread1 = new Thread(script);Thread thread2 = new Thread(script);thread1.start();thread2.start();}
}class DeadLoopClass {static {if (true) {System.out.println(Thread.currentThread() + "init DeadLoopClass");boolean flag=true;while (flag) {Thread.sleep(5000);flag=false;}catch(InterruptedException e){throw new RuntimeException(e);}}}
}

其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒后则不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会被初始化一次。

总结一句话一个类如果已经被一个线程加载到内存中,也就代表加载过程中的这些阶段(加载-链接)都已经执行完毕了,那么后续如果有其他线程想要访问对应的字节码对象直接访问即可,不需要再次去加载这个类的字节码文件了

对应了我们JavaSE中的语法问题:一个类中的静态修饰的内容会随着类的加载而加载,只会被加载一次《这个说话仅仅限于同一个类加载器》

3.7. 总结

在这里插入图片描述

  • 类加载阶段:建立通道,把class文件加载到JVM内存中
  • 验证阶段:就是校验字节码文件是否符合JVM的规范
  • 准备阶段:就是分配内存空间,给类变量设置初始化值
  • 解析阶段:将符号引用(占位符)转化为直接引用(真实内存地址值)
  • 初始化阶段:给类变量进行赋值的操作

4. 类加载器

4.1.类加载器的概述

作用:类加载器用于实现类的加载动作,即:用来把class文件加载到JVM内存中

解释:对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载 的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

package demo03;/*** 同一个加载器加载同一个类的测试**/
public class ClassLoaderTest {/*** 结论:* 1.如果是同一个类加载器去加载同一个类,那么在内存中只会有一份字节码对象,因此三种方式获取到的字节码对象都是同一份* 2.如果是不同的类加载器去加载同一个类,那么在内存中会有多份字节码对象*/public static void main(String[] args) throws ClassNotFoundException {//回顾:在java中获取某个类的字节码对象//方式一:通过类名.class获取字节码对象--默认使用的是JVM提供的应用程序类加载器Class<ClassLoaderTest> clazz1 = ClassLoaderTest.class;//方式二:通过Class.forName的方式来获取字节码对象--默认使用的是JVM提供的应用程序类加载器Class<?> clazz2 = Class.forName("demo03.ClassLoaderTest");//方式三:通过对象.getClass()来获取字节码对象--默认使用的是JVM提供的应用程序类加载器Class<? extends ClassLoaderTest> clazz3 = new ClassLoaderTest().getClass();//思考:比较的方式是在比较内存中的地址值System.out.println(clazz1 == clazz2);//trueSystem.out.println(clazz2 == clazz3);//true}
}

在这里插入图片描述

下面是自定义类加载器的代码演示

在这里插入图片描述

/*** 自定义的类加载器* 核心逻辑是:自己先查找(当前这个类所在的目录下),如果自己找不到再交给上级类加载器进行查找*/
public class MyClassLoader extends ClassLoader{@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {//接受到的name就是传递进来的需要加载的类的全类名,比如cn.itsource.classloader.ClassLoaderTest,截取拼接后成为 ClassLoaderTest.classString fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";//获取字节流InputStream is = getClass().getResourceAsStream(fileName);//会找与MyClassLoader同一包结构下面的类 ,当我传的是First的类路径时,是找不到的if (is == null) {//如果没有找到该文件,交给上级加载器进行加载--->双亲委派机制return super.loadClass(name);}try {byte[] b = new byte[is.available()];is.read(b);return defineClass(name, b, 0, b.length);} catch (IOException e) {throw new RuntimeException(e);}}
}

下面是测试代码

/*** 不同类加载加载同一个类的测试* */
public class ClassLoaderTest {public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {//方式一:通过Class.forName的方式来获取到字节码对象;Class<ClassLoaderTest> clazz1 = ClassLoaderTest.class;//方式二:通过类名.class获取到字节码对象Class<ClassLoaderTest> clazz2 = (Class<ClassLoaderTest>) Class.forName("cn.itsource.classloader.ClassLoaderTest");//方式三:通过对象.getClass来获取到字节码对象;ClassLoaderTest loaderTest = new ClassLoaderTest();Class<ClassLoaderTest> clazz3 = (Class<ClassLoaderTest>) loaderTest.getClass();System.out.println(clazz1 == clazz2);System.out.println(clazz2 == clazz3);//自定义类加载器加载cn.itsource.classloader.ClassLoaderTest这个类MyClassLoader myClassLoader = new MyClassLoader();Class<?> clazz = myClassLoader.loadClass("cn.itsource.classloader.ClassLoaderTest");System.out.println(clazz1 == clazz);}
}

测试结果如下

在这里插入图片描述

4.2.类加载器的分类

Java的类加载器分为以下几种:

  • 注意前两种类加载器只会加载: 加载哪的类的字节码文件,不会加载其他的
名称加载哪的类说明
Bootstrap ClassLoader 【启动类加载器】JAVA_HOME/jre/lib无法直接访问
Extension ClassLoader 【扩展类加载器】JAVA_HOME/jre/lib/ext上级为 Bootstrap,显示为 null
Application ClassLoader【应用程序类加载器】classpath目录下面的,也就是我们日常写代码的目录上级为 Extension
自定义类加载器自定义上级为 Application
启动类加载器

Bootstrap ClassLoader主要是负责加载机器上安装的Java目录下的核心类文件,也就是JDK安装目录下jre目录下的lib目录,里面存放了一些Java运行所需要的核心的类库。当JVM启动的时候,会首先依托启动类加载器加载我们lib目录下的核心类库。

注意:如果我们定义了一个和jre/lib中相同的类(包名,类名,方法名都相同),我们的类是不会被加载的

扩展类加载器

Extension ClassLoader主要是加载 jre目录下的lib目录下的ext中的文件,这里面的类用来支撑我们系统的运行。

应用程序类加载器

Application ClassLoader该类加载器主要是加载“classpath”环境变量所指定的路径中的类,可以理解为加载我们自己写的Java代码 ,以及我的导入的三方Jar包中的代码。

注意:如果我们定义了一个和 三方Jar包中相同的类,会优先使用我们自己定义的。

自定义类加载器

除了以上三种以外,也可以自定义类加载器,根据具体的需求来加载对应的类。


4.3. 类加载器原理-双亲委派

所谓的双亲委派,就是指调用类加载器的loadClass方法时,查找类的规则

几种类加载器的加载的优先级关系如下:

在这里插入图片描述

基于这个亲子层级机构,就有一个双亲委派机制:先找“父亲”去加载,当父类加载不行的话再由儿子来加载,这样的话可以避免多层级的类加载器重复加载某些类。

注意: 这里的双亲,翻译为上级似乎更为合适,因为它们并没有继承关系

4.3. 1. 类加载时使用了双亲委派模式

基于这个亲子层级机构,就有一个双亲委派机制,加载规则,优先级按照从上往下加载,如果上一级加载了下一级就不会再加载,如果上一级没有加载,下一级就会进行加载,以此类推,到最后一级都没有加载成功,才报ClassNotFountException,这麽做的目的:

  • 不让我们轻易覆盖系统提供功能 :如果我们定义了一个和jre/lib中相同的类(包名,类名,方法名都相同),我们的类是不会被加载的,我没有办法去修改JDK自带的库
  • 也要让我们扩展我们功能:如果我们定义了一个类和三方Jar包中的类一模一样,那么会优先使用我们定义的类
4.3. 2. 而双亲委派机制是如何运作的呢

我们以应用程序类加载器举例,它在需要加载一个类的时候,不会直接去尝试加载,而是委托上级的扩展类加载器去加载,而扩展类加载器也是委托启动类加载器去加载.

启动类加载器在自己的搜索范围内没有找到这么一个类,表示自己无法加载,就再让扩展类加载器去加载,同样的,扩展类加载器在自己的。

搜索范围内找一遍,如果还是没有找到,就委托应用程序类加载器去加载.如果最终还是没找到,那就直接抛出异常了

4.3. 3. 而为什么要这么麻烦的从下到上,再从上到下呢?

这是为了安全着想,保证按照优先级加载.如果用户自己编写一个名为java.lang.Object的类,放到自己的Classpath中,没有这种优先级保证,应用程序类加载器就把这个当做Object加载到了内存中,从而会引发一片混乱.而凭借这种双亲委派机制,先一路向上委托,启动类加载器去找的时候,就把正确的Object加载到了内存中,后面再加载自行编写的Object的时候,是不会加载运行的.

结论:JDK自带的类是没法覆盖的,而引入的三方的JAR是可以自己定义相同的类来覆盖的。

下面是ClassLoader的源码

在这里插入图片描述

结合上一个案例我们可以Debug进行查看观察

在这里插入图片描述

虽然Ext类加载器的parent是null,但是当我们代码真正在执行的时候依然会去调用Bootstrap ClassLoader,因为Bootstrap ClassLoader并不是由Java代码实现的了,而是C++代码,所以这里的Ext类加载器的parent是null。

类加载器中的核心方法 loadClass 源码:

protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {// 1. 检查该类是否已经加载Class<?> c = findLoadedClass(name);if (c == null) {long t0 = System.nanoTime();try {                if (parent != null) {// 2. 有上级的话,委派上级 loadClassc = parent.loadClass(name, false);} else {// 3. 如果没有上级了(ExtClassLoader),则委派 BootstrapClassLoaderc = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {}if (c == null) {long t1 = System.nanoTime();// 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载c = findClass(name);// 5. 记录耗时sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;}
}

使用之前的自定义类加载器加载First这个类,并Debug去观察程序的完整执行流程。

Class<?> clazz = classLoader.loadClass("cn.itsource.load.First");

类加载器执行流程如图:

  • 为啥自定义类加载器找不到:在之前代码里面说了:会找与MyClassLoader同一包结构下面的类 ,当我传的是First的类路径时,他与MyClassLoader不在同一个目录下,所以是找不到的,也就加载不到

在这里插入图片描述

4.3. 4. 阿里面试题:Tomcat容器类加载器设计

首先我们来看下Tomcat类加载器的设计结构:

在这里插入图片描述

那么应用程序类加载器下的都是Tomcat自定义的类加载器,Tomcat为什么要自定义这么多类加载器又分别有什么用呢?我们通过以下图来进行说明:

在这里插入图片描述

首先Tomcat会通过Common类加载器来加载本地lib包下的核心文件,比如 servlet-api.jar、jsp-api.jar、el-api.jar等,这些类可以供Tomcat以及所有的WebApp进行访问和使用

可以通过查看 Tomcat目录下的conf/catalina.properties配置文件查看

common.loader=“catalina.base/lib","{catalina.base}/lib","catalina.base/lib","{catalina.base}/lib/.jar","catalina.home/lib","{catalina.home}/lib","catalina.home/lib","{catalina.home}/lib/.jar”

其次Catalina类加载器加载Tomcat应用程序所独有的一些类文件,这些文件对所有WebApp不可见,比如实现自己的会话存储方案。其路径由server.loader指定,默认为空,可以手动更改指定。

可以通过查看 conf/catalina.properties配置文件查看 server.loader= ,再次,Shared类加载器负责加载Web应用共享类,这些类tomcat服务器不会依赖。 ,可以通过查看 conf/catalina.properties配置文件查看, shared.loader=

而我们的WebApp类加载器主要是加载我们每个应用程序自己编写的代码,主要路径为: /WEB-INF/classes/目录下的Class资源文件 以及 /WEB-INF/lib目录下的jar包,该类加载器加载的资源仅对当前应用程序可见,其他应用程序无法访问。并且WebApp类加载器可以使用到上级Shared类加载器和Common类加载器加载到的类。

最后JSP类加载器是为每一个JSP文件单独设计的一个类加载器,这也能解释为什么JSP文件被修改后不用重启服务器就能实现新的代码功能,这也是现在的热部署方案原因。当某一个JSP文件被修改后,对应的类加载器会被销毁重新创建新的一个JSP类加载器进行加载。

问题思考

当我们的服务器中有多个应用程序的时候,并且都使用到了Spring来进行组织和管理,那么我们就可以把Spring放到Shared类加载器路径下进行加载,让所有应用程序进行共享,我们自己写的代码由于是WebApp加载器加载的所以访问上级Shared加载器加载的类是没问题的。但是Spring中的类要对应用程序中的类进行管理,如何访问呢?根据我们上文所说的双亲委派机制,显然是无法做到让上级类加载器去请求下级类加载器进行类加载的动作的。因此这里我们需要引出破坏性双亲委派机制。(如下图)

在这里插入图片描述

按主流的双亲委派机制,显然无法做到让父加载器加载的类去访问子类加载器加载的类,但使用线程上下文加载器可以让父类加载器请求子类加载器去完成类加载的动作。

4.3.5. 线程上下文类加载器(Context Classloader)
  • 线程上下文类加载器是从JDK 1.2开始引入的,类Thread中的getContextClassLoader()与setContextClassLoader(ClassLoade)分别用来获取和设置上下文类加载器
  • 如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。
  • 如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(在Tomcat里就是WebAppClassLoader)。

因此spring根本不会去管自己被放在哪里,它统统使用线程上下文加载器来加载类,而线程上下文加载器默认设置为了WebAppClassLoader,也就是说哪个WebApp应用调用了spring,spring就去取该应用自己的WebAppClassLoader来加载bean。

在这里插入图片描述

ContextLoaderListener监听器的作用就是启动Web容器时,自动装配ApplicationContext的配置信息,可以跟进源码查看

在这里插入图片描述

小结

有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打破了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、 JDBC、JCE、JAXB和JBI等。

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。

这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由引导类加载器来加载的;SPI的实现类是由系统类加载器来加载的。引导类加载器是无法找到 SPI 的实现类的,因为依照双亲委派模型,BootstrapClassloader无法委派AppClassLoader来加载类。

比如:在java中定义了接口java.sql.Driver,并没有具体的实现,具体的实现都是由不同厂商来提供的

在这里插入图片描述


5. JVM的内存结构

5.1. JVM内存结构划分

从Java代码经历编译生成对应字节码文件,再经由类加载器加载,经历验证、准备、解析、初始化阶段,整个过程我们称之为类加载阶段,也是我们JVM第一部分重要的开端;接下来我们正式进入主战场JVM内存区域,来看看JVM在不同内存区域之间的巧妙设计。

大家思考:为什么Java需要设计这么多内存区域?
在这里插入图片描述

  1. 程序计数器线程私有,用于记录将要执行的JVM指令地址,保证程序正确的执行,例如:当线程获取到CPU时间片,代码正常执行到一半,这时CPU切到另一个线程中去执行,当CPU再次切回来的时候如何能保证代码能正确执行而不会错乱?就需要靠程序计数器去完成。
  2. 虚拟机栈线程私有,每个线程运行时所需要的内存,称为虚拟机栈;每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。每个方法被调用都会形成栈帧压栈到虚拟机栈中,当方法执行结束栈帧进行出栈,栈帧中的变量被回收。
  3. 本地方法栈线程私有,保存native方法进入区域的地址,他是用来调用第三方库所需要的一片栈空间,这里的第三方库指的是其他语言比如:c开发的库。
  4. 通过 new 关键字创建对象,以及数组都会使用堆内存,堆是垃圾回器主要回收的区域
  5. 方法区存储类的信息以及运行时常量池,在JDK8以后被元空间代替

5.2. 程序计数器

在这里插入图片描述

首先我们来看第一个内存区域:程序计数器

Program Counter Register 程序计数器(PC寄存器)

  • 他的作用是记住下一条jvm指令(就是java代码编译后的字节码命令)的执行地址
  • 他的特点是:
    • 线程私有,每一个线程对应一个程序计数器
    • 不会有内存溢出(内存溢出:当我们的内存不够了,我们去申请一些额外的内存空间,申请不到,就会出现内存溢出)

首先我们来看一段非常简单的代码:

public class Demo1 {public static void main(String[] args) {int num1 = 1;System.out.println(num1);int num2 = 2;System.out.println(num2);}
}

这个代码大家都能看懂,但是JVM能看懂吗?答案是:NO!

JVM是不识别我们写的代码的,我们的java代码会被编译为.class字节码文件,而字节码文件中的代码才是JVM能识别和执行的,这些代码我们也叫【字节码指令】,它对应了一条一条的机器指令,JVM通过将这些指令再解释翻译为机器指令,来操作我们的计算器进行执行。

上述的代码对应的字节码指令如下:

 0 iconst_11 istore_12 getstatic #2 <java/lang/System.out>5 iload_16 invokevirtual #3 <java/io/PrintStream.println>9 iconst_2
10 istore_2
11 getstatic #2 <java/lang/System.out>
14 iload_2
15 invokevirtual #3 <java/io/PrintStream.println>
18 return

在这里插入图片描述

注意:这些字节码指令就是由 字节码执行引擎 来执行的!

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

当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。
在这里插入图片描述

由于java多线程的执行,我们的代码可能会开启多个线程并发执行不同的代码,所以对应着会有多个线程来并发的执行不同的代码指令。

而每个线程底层是根据CPU分配给它的时间片的方式、依次轮流来执行的, 可能A线程执行⼀段时间后就切换为B线程来执行了,B线程执行时间结束后,再切换回A线程执行了, 此时线程A肯定要知道自己上⼀次执行到字节码指令的哪个位置了,才能在上次的位置继续执行下去。

所以:程序计数器就扮演了一个这样的角色,记录每个线程执行字节码指令位置;并且程序计数器每个线程都是私有的,专门为各自线程记录每次执行字节码指令的位置,方便下次线程切换回来时还能找的到上次执行的位置继续执行。
在这里插入图片描述

5.3. 虚拟机栈

在这里插入图片描述

Java Virtual Machine Stacks (Java 虚拟机栈),他是每个线程运行时所需要的内存,称为虚拟机栈

他有如下特点

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存 ,
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
  • 每个线程都有自己的java虚拟机栈

1.每个线程运行时所需要的内存,称为虚拟机栈---->每个线程都有自己的java虚拟机栈

Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素

Java代码的执行一定是由线程来执行某个方法中的代码,哪怕就是我们的main()方法也是有一个主线程来执行的,在main线程执行main()方法的代码指令的时候,就会通过main线程对应的程序计数器来记录自己执行的指令位置。

main()方法本质上是一个方法,在main()中也可以调用其他的方法,而每个方法中也有自己的局部变量数据,因此JVM提供了一块内存区域用来保存每个方法内的局部变量等数据,这个区域就是Java虚拟机栈

2.每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存

每个栈帧主要存放:局部变量表,操作数栈,动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

当我们在线程中调用了一个方法,就会对该方法创建一个对应的栈帧,比如我们如下的代码:

public class Demo1 {public static void main(String[] args) {int num1 = 1;System.out.println(num1);int num2 = 2;System.out.println(num2);}}

这时在虚拟机栈内存中就会先创建对应main方法的栈帧,同时记录保存对应的局部变量:

在这里插入图片描述

public class Demo1 {public static void main(String[] args) {int num1 = 1;System.out.println(num1);int num2 = 2;System.out.println(num2);method1();}public static void method1(){int num3 = 20;System.out.println("哈哈哈哈");}
}

在这里插入图片描述

并且当method1方法执行完毕后会弹出该栈队列,最后弹出main()方法栈帧,代表整个main方法代码执行完毕。这也对应了栈的特点:先进后出

3. 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在 调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与 这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作

流程图小结

在这里插入图片描述

5.3.1. 栈内存面试案例剖析
  1. 垃圾回收是否涉及栈内存?

    栈帧每次执行结束自动弹栈,自动被回收,所以不会涉及到垃圾的产生,也就不会对栈内存进行垃圾回收

  2. 栈内存分配越大越好吗?

    并不是,假设分配的物理内存是100MB,每个线程虚拟机栈大小是1MB,那么可以分配100个线程,但是如果提升了线程栈大小,那可以分配的对应线程数就变少了。

    我们先来看官网给出的每个虚拟机栈默认的大小分配:

    在这里插入图片描述

    Linux系统上默认就是1MB,当然我们可以通过-Xss进行大小的更改

  3. 方法内的局部变量是否线程安全?

    • 如果方法内局部变量没有逃离方法的作用范围内访问,它是线程安全的

    • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

参考一下示例代码

  	//方法内局部变量:线程安全public static void method1(){StringBuilder sb = new StringBuilder();sb.append(1);sb.append(2);sb.append(3);System.out.println(sb);}//方法内局部变量引用对象:线程不安全public static void method2(StringBuilder sb){sb.append(1);sb.append(2);sb.append(3);System.out.println(sb);}//方法内局部变量引用对象提供暴露:线程不安全public static StringBuilder method3(){StringBuilder sb = new StringBuilder();sb.append(1);sb.append(2);sb.append(3);return sb;}
  1. 栈内存溢出

什么原因会导致栈内存溢出(Stack Overflow)

  • 栈帧过多,没有足够的栈内存空间来存储更多的栈帧,导致内存溢出, 将抛出StackOverflowError异常。 常见的情况就是递归调用,不断产生新的栈帧,前面的栈帧不释放

在这里插入图片描述

我们可以通过以下代码来测试和实验:

/*** @Description:   VM Args: -Xss128k 设置虚拟机栈空间的大小对于不同版本的Java虚拟机和不同的操作系统, 栈容量最小值可能会有所限制, 这主要取决于操作系统内存分页大小。 譬如上述方法中的参数-Xss128k可以正常用于32位Windows系统下的JDK 6, 但是如果用于64位Windows系统下的JDK 11, 则会提示栈容量最小不能低于180K, 而在Linux下这个值则可能是228K, 如果低于这个最小限制, HotSpot虚拟器启动时会提示:The Java thread stack size specified is too small. Specify at least 228k*/
public class JavaVMStackSOF {private int stackLength = 1;public void stackLeak() {stackLength++;stackLeak();}public static void main(String[] args) throws Throwable {JavaVMStackSOF oom = new JavaVMStackSOF();try {oom.stackLeak();} catch (Throwable e) {System.out.println("stack length:" + oom.stackLength);throw e;}}
}

打印结果如下

在这里插入图片描述

  • 栈帧过大导致内存溢出, 将抛出StackOverflowError异常。
    我们这次可以尝试将每一个栈帧的局部变量定义多一点,就会多占用一点空间,这样每个栈帧的大小就会变大,我们还是设定每个线程栈空间为128K,看看以下代码运行后,多少次就会撑满内存:

    /*** @Description: VM Args: -Xss128k*/
    public class JavaVMStackSOF2 {private static int stackLength = 0;public static void test() {long unused1, unused2, unused3, unused4, unused5,unused6, unused7, unused8, unused9, unused10,unused11, unused12, unused13, unused14, unused15,unused16, unused17, unused18, unused19, unused20,unused21, unused22, unused23, unused24, unused25,unused26, unused27, unused28, unused29, unused30,unused31, unused32, unused33, unused34, unused35,unused36, unused37, unused38, unused39, unused40,unused41, unused42, unused43, unused44, unused45,unused46, unused47, unused48, unused49, unused50,unused51, unused52, unused53, unused54, unused55,unused56, unused57, unused58, unused59, unused60,unused61, unused62, unused63, unused64, unused65,unused66, unused67, unused68, unused69, unused70,unused71, unused72, unused73, unused74, unused75,unused76, unused77, unused78, unused79, unused80,unused81, unused82, unused83, unused84, unused85,unused86, unused87, unused88, unused89, unused90,unused91, unused92, unused93, unused94, unused95,unused96, unused97, unused98, unused99, unused100;stackLength++;test();}public static void main(String[] args) throws Throwable {try {test();}catch (Error e){System.out.println("stack length:" + stackLength);throw e;}}
    }

    我们发现仅51次就撑爆了!打印结果:
    在这里插入图片描述

最后总结出一个结论:无论是由于栈帧太大还是虚拟机栈容量太小, 当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。


5.4. 本地方法栈

在这里插入图片描述

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

在这里插入图片描述

说明:

  1. 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
  2. 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

为什么要使用本地方法?

Java使用起来很方便,然而Java代码有一定的局限性,有时候不能和系统底层交互,或是追求程序的效率时。这时候就需要更加底层的语言和更快的运行效率。

  • 方便与Java之外的环境交互,如与操作系统或某些硬件交换信息,本地方法为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
  • 虚拟机本来就是由C++写的,一些操作系统特性JVM没有封装提供出来,那我们就可以自行使用C语言来实现它,并通过本地方法来调用。
  • 最求更快的运行效率

这幅图展示了JAVA虚拟机内部线程运行的全景图。一个线程可能在整个生命周期中都执行Java方法,操作它的Java栈;或者它可能毫无障碍地在Java栈和本地方法栈之间跳转。

在这里插入图片描述

该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。假设这是一个C语言栈,其间有两个C函数,第一个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法),最终这个Java方法又调用了一个Java方法(它成为图中的当前方法)。

在这里插入图片描述


5.5. 堆

在这里插入图片描述

5.5.1. 概念:

Heap 堆:

  • 通过new 关键字创建对象都会使用堆内存,堆是垃圾回收期主要回收的区域。
  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。它是 JVM 管理的最大一块内存空间。

特点:

  • 它是线程共享的,堆中对象都需要考虑线程安全的问题,《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程共享Java堆,在堆中还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
  • 有垃圾回收机制

说明:

当栈帧被执行的时候,里面有对象的创建,那么栈帧里面仅仅是保存对象名以及对应的地址值,真正的对象存储是分配在了堆内存:(全流程图)

在这里插入图片描述

5.5.2. 内存分配关系

《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)

要注意的是:“几乎”所有的对象实例都在这里分配内存–是从实际使用角度看的。因为还有一些对象是在栈上分配的。

数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

比如下面一段很简单的代码:

public class Demo2 {public static void main(String[] args) {Hello h1 = new Hello();Hello h2 = new Hello();int[] arr = new int[3];}
}
class Hello{}

这段代码在堆中的存储结果如下

在这里插入图片描述

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除,也就是触发了GC的时候,才会进行回收。如果堆中对象马上被回收,那么用户线程就会受到影响。堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。


5.5.3. 堆内存大小配置

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

  • “-Xms"用于表示堆区的起始内存,等价于-XX:InitialHeapSize。
  • “-Xmx"用于表示堆区的最大内存,等价于-XX:MaxHeapSize。

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

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

  • 初始内存大小:电脑物理内存大小/64。
  • 最大内存大小:电脑物理内存大小/4。

可以通过如下代码进行查看:

/*** -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小*  -X:是jvm运行参数*  ms:memory start* -Xmx:用来设置堆空间(年轻代+老年代)的最大内存大小*/
public class Demo2 {public static void main(String[] args) {// 返回Java虚拟机中的堆内存总量---当前的可用容量long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;// 返回Java虚拟机试图使用的最大堆内存long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;System.out.println("-Xms:" + initialMemory + "M");System.out.println("-Xmx:" + maxMemory + "M");}
}

运行结果

-Xms:245M

-Xmx:3625M


5.5.4. 堆内存分代模型-新生代和老年代

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

  1. 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速,生命周期短的,及时回收即可。
  2. 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。

比如Class字节码对象,只有满足三个条件的情况下,才会被GC也就是卸载:

  1. 该类所有的实例都已经被GC
  2. 加载该类的ClassLoader已经被GC
  3. 该类的java.lang.Class 对象没有在任何地方被引用
//main方法:走完了,main方法栈空间弹出栈,栈空间被JVM回收,那么Hello类实例:h1,h2,对应的堆内存就会被回收public class Demo2 {public static void main(String[] args) {Hello h1 = new Hello();Hello h2 = new Hello();int[] arr = new int[3];}
}
class Hello{}
//hello类没有被销毁,private static Hello h 的内存很难被回收,而h1,h2很容易被回收public class Demo2 {public static void main(String[] args) {Hello h1 = new Hello();Hello h2 = new Hello();int[] arr = new int[3];}
}
class Hello{//这个h是一个类变量,在类加载的:准备和解析阶段,就已经分配空间和值了private static Hello h = new Hello();
}

在这里插入图片描述


Java堆区进一步细分的话,可以划分为:年轻代/新生代(YoungGen)和老年代(oldGen),其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。

在这里插入图片描述

上面这参数开发中一般不会调:

  • Eden区,From区,to区 ; 比例为: 8:1:1
  • 新生代 ,老年代 , 比例为: 1 : 2

配置新生代和老年代在堆内存结构的比例

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

可以自定义:-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5。

当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整 老年代的大小,来进行调优。

在HotSpot中,Eden空间和另外两个survivor空间默认所占的比例是8:1:1,当然开发人员可以通过选项“-XX:SurvivorRatio”调整这个空间比例,比如:-XX:SurvivorRatio=8。

几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象的销毁都在新生代进行了。(有些大的对象在Eden区无法存储时候,将直接进入老年代)。

IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。

可以使用选项"-Xmn"设置新生代最大内存大小。

这个参数一般使用默认值就可以了。


5.5.5. 图解对象分配机制

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

因此整个堆内存的对象分配核心规则如下:

  1. 所有new的对象先放伊甸园区,包括Class对象
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对新生代区进行垃圾回收(MinorGC/YongGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁,然后将伊甸园中的剩余对象移动到幸存者0区(或者1区,不确定),再加载新的对象放到伊甸园区。
  3. 如果再次触发垃圾回收,此时检查上次幸存下来的放到幸存者0区的对象,有没有垃圾对象(对整个新生代进行检查有没有垃圾),如果没有被回收,将会和本次伊甸园区中幸存下来的对象一起放到幸存者1区。
  4. 后续依次类推,循环往复。

​ 啥时候能去养老区呢?可以设置次数,默认是15次(就是说在新生代区被上述操作的时候被检查了15次,都没有被gc回收,那么在第16次的时候就会被转到老年代区中)。可以通过设置参数:-XX:MaxTenuringThreshold= N 进行最大年龄的设置。

注意:

  • 在Eden区满了的时候,才会触发MinorGC,而幸存者区满了后,不会触发MinorGC操作。
  • 如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代,具体分析请往下看。

在这里插入图片描述

5.5.6. 对象分配流程案例实战

我们来一起看下如下的代码,对他们在内存中的分配做一个剖析:

public class Test {private static User user = new User();public static void main(String[] args) throws InterruptedException {user.login();for (int i = 0; i < 100; i++) {doSomething();Thread.sleep(200);}}public static void doSomething(){Student stu = new Student();stu.study();}
}
class User{public void login(){System.out.println("登录");};
}
class Student{public void study(){System.out.println("I'm studying");};
}

Test类中静态成员变量 user 是长期存活的,并且分配在新生代中。

main方法中通过for循环调用了100次 doSomething() 方法,方法中会创建Student()对象。

  • 我们先将时间定格在执行完第一次后,内存中的分配情况是:

在这里插入图片描述

  • 以上仅仅是执行第一次doSomething() 方法后的情况,如果执行10次后的情况会发生什么样的变化呢?

  • 首先大家要明确,我们的doSomething() 方法执行完后对应的栈帧肯定会弹栈,那么对应栈帧的局部变量也相应被释放回收,我们堆内存中的实例对象就会变成无引用的垃圾对象了:

在这里插入图片描述

  • 当最后一次 doSomething() 方法执行完后 对应栈帧弹栈,那么堆内存中新生代里面的Student实例对象就存在了有10个对象没有地址引用,后续如果再继续产生一些垃圾对象,当新生代中的内容空间已无法分配空间的时候,就会进行“ Minor GC ”,将对应新生代的垃圾对象进行回收:

在这里插入图片描述

  • 回收后的内存就仅剩User这个对象了,并且User对象会进入幸存者区。
  • 另外我们的字节码对象,也是会被放入幸存者区(只是没画出来而已)。

在这里插入图片描述

  • 当然如果我们的程序在经历了15次“ Minor GC”后还没有被回收的对象就会被放入我们的老年代进行管理;比如我们的User实例对象,因为一直被Test类静态变量引用,所以它不会被回收。
  • 另外我们的字节码对象,也是会被放入我们的老年代进行管理(只是没画出来而已)。

在这里插入图片描述

  • 当然如果老年代里面的空间也存满了后,也会触发垃圾回收叫 FullGC(Stop the world),把老年代中没用的垃圾对象进行清理。

在这里插入图片描述


5.5.7. 大对象频繁创建导致OOM

当我们不断创建大对象的时:

/*** 大对象长期持有直接进老年代案例* -Xms300m -Xmx380m :新生代 100M Eden80M S S1:10M*/
public class HeapInstanceTest {//数组申请分配的内存空间是2Mbyte[] buffer = new byte[new Random().nextInt(1024 * 1024 * 2)];public static void main(String args) throws InterruptedException {ArrayList<HeapInstanceTest> list = new ArrayList<>();while (true) {list.add(new HeapInstanceTest());Thread.sleep(200);}}
}

设置参数 : -Xms200m -Xmx200m
然后cmd输入: jvisualvm,打开VisualVM图形化界面

在这里插入图片描述

最终,在老年代和新生代都满了,就出现OOM。

Exception in thread "main" java.lang.0utOfMemoryError: Java heap spaceat cn.itsource.memory.HeapInstanceTest.<init>(HeapInstanceTest.java:13)at cn.itsource.memory.HeapInstanceTest.main(HeapInstanceTest.java:17)

常用的调优工具:

  • JDK命令行
  • Eclipse: Memory Analyzer Tool
  • Jconsole
  • VisualVM(实时监控 推荐)
  • Jprofiler(推荐)
  • Java Flight Recorder(实时监控)
  • GCViewer
  • GCEasy

5.6. 方法区

在这里插入图片描述

方法区主要存放的是『class字节码文件』,而堆中主要存放的是『实例化的对象』

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。

  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space 或者java.lang.OutOfMemoryError:Metaspace

    • 加载大量的第三方的 jar包
    • Tomcat 部署的工程过多 (30~50个)
    • 大量动态的生成反射类。
  • 关闭JVM就会释放这个区域的内存。


5.6.1. HotSpot中方法区的演进

在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。

本质上,方法区和永久代并不等价。仅是对hotspot而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit / IBM J9 中不存在永久代的概念。

现在来看,当年使用永久代,不是好的idea。导致Java程序更容易oom(超过-XX:MaxPermsize上限)

在这里插入图片描述

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

永久代、元空间二者并不只是名字变了,内部结构也调整了。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。


5.6.2. 方法区内部结构

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

在这里插入图片描述

上述图片针对jdk1.8之前,1.8之后静态变量放到了JVM中了


5.6.3. 运行时常量池

方法区,内部包含了运行时常量池

在这里插入图片描述

要弄清楚方法区的运行时常量池,需要理解清楚classFile中的常量池

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

在这里插入图片描述

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

  • 运行时常量池
    • 运行时常量池(Runtime Constant Pool)是方法取得一部分
    • 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
    • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
    • JVM为每个已加载的类型(类或接口)都维护一个常量池。**池中的数据项像数组项一样,通过**索引访问。
    • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
    • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outofMemoryError异常。

5.6.4. 方法区的演进细节

首先明确:只有Hotspot才有永久代。BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。

Hotspot中方法区的变化:

版本内容
JDK1.6及以前有永久代,静态变量存储在永久代上
JDK1.7有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中
JDK1.8无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。

JDK6的时候:

在这里插入图片描述

JDK7的时候:

在这里插入图片描述

JDK8的时候,元空间大小只受物理内存影响

在这里插入图片描述


5.6.5. StringTable

StringTable 叫做字符串常量池,用于存放字符串常量,这样当我们使用相同的字符串对象时,就可以直接从StringTable中获取而不用重新创建对象。

StringTable 为什么要调整位置

jdk7中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。

这就导致 StringTable 回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。


6. JVM从加载到内存全过程图

在这里插入图片描述


7. 案例实战剖析

注意:JVM参数到底该如何设置,一定是根据不同的业务系统具体的一些场景来调整的,不是说有一个通用的配置和模板,照着设就没问题了,这个思路是肯定不对的,一定要结合案例和业务场景来分析。

7.1. 如何抗住双11一天几十亿的订单量?JVM该如何设置内存?

我们先来看一个数据,2020天猫双11全球狂欢季实时物流订单总量定格在23.21亿。这是什么概念!一天成交23.21亿个订单!更夸张的是天猫订单在2020年创建峰值达58.3万笔/秒!

在这里插入图片描述

54.4万笔/秒订单背后的秘密
双11前两个月,阿里巴巴完成了将数以十万计的物理服务器从线下数据中心迁移到云上。这是一个浩大的工程,但前端的消费者毫无感知。
阿里云近三年投入巨大资源研发出来的神龙服务器,是54.4万笔/秒订单的峰值能够平稳度过的保障。54.4万笔/秒订单是什么概念?阿里云智能基础产品事业部研究员张献涛表示,其他公司可能还在为1000笔/秒的订单做斗争。
不可忽视的算力
双11当天,阿里巴巴处理了970PB的数据。一个可以对比的数字是,央视拍了几十年的节目,存下来的数据是80PB。
支撑双11大规模算力的是流计算系统飞天大数据平台。在系统和商家调度上,流计算系统发挥了重要作用
不一样的双11
在双11的媒体沟通会上,阿里巴巴集团CTO张建锋表示,阿里云在技术上完成了四个方面核心突破

第一、在核心虚拟机系统上,自研神龙架构,用自研的服务器来做虚拟化。神龙服务器在压力很大的情况下,输出也是非常线性的。
第二、自研了云原生的数据库
第三、计算与存储做了分离,数据都是从远端存取的,存储可以很方便的扩容。
第四、做了RDMA网络,能够做到在远端存储,能够比本地读写磁盘更快。

双11期间,近200万个容器支撑着电商的核心系统,在商家侧阿里巴巴的技术团队为商家快速的扩容了5.4万核,峰值每秒帮助商家处理87万笔订单,向商家提供了410亿次的调用。
这些都是双十一背后的技术力量。


7.2. 每日百万的支付系统应该如何设置JVM内存?

现在的系统基本很多都离不开支付,那么支付系统的开发也几乎是我们必须掌握的一个技能,这里我们以一个电商系统的开发为背景,针对一个日交易量在百万的支付系统作为一个实战案例来进行分析。

进入正题分析:
首先大家正常上网购物的流程是: 添加商品到购物车 → 下单 → 结算支付而 → 显示支付结果

对于我们的系统开发来讲整个过程应该如何进行呢?我们先来看一张之前开发我做的一张微信支付的全流程图:

在这里插入图片描述

是不是感觉有点复杂?而且这里出现了三个系统的交互,我们将上图再精简一下,那么其实整个过程就是如下这样的:

在这里插入图片描述

这样一提取是不是感觉非常清晰了?
订单系统你可以理解为就是我们的电商后台系统,而我们的支付系统也可以看做是电商系统中的一部分,不过这部分却非常重要,通常我们提取出来单独作为一个独立的系统进行开发和维护。

支付系统是连接消费者、商家(或平台)和金融机构的桥梁,管理支付数据,调用第三方支付平台接口,记录支付信息(对应订单号,支付金额等),金额对账等功能。

我们把整个流程从用户到订单系统到支付系统以及三方支付系统的流程先理下,大家先有个整体的支付逻辑:

在这里插入图片描述

  1. 用户提交订单支付到我们的订单系统
  2. 订单系统将该支付请求提交给支付系统
  3. 支付系统生成一个支付订单,此时订单状态是“待支付“状态,返回用户跳转到付款页面,选择支付方式
  4. 用户选择微信or支付宝确定付款方式
  5. 支付系统把实际支付请求提交到第三方支付渠道:处理请求、资金转移6.返回处理结果,支付系统修改订单为“已完成”

以上仅仅是我们简化的一个简单支付流程,实际一个完整支付系统还包含很多东西(比如账户管理、对账管理、清算管理、结算管理等),我们重点关注核心的支付流程即可。

一个每日百万交易的支付系统压力在哪里?

JVM内存中每天会有百万个支付订单对象需要创建(每个订单对象包含用户信息、商品信息、支付渠道信息、支付时间、价格等各类信息的汇总),因此我们聚焦在JVM的管理中来看,每一天我们的JVM内存中就会有上百万个支付订单对象的创建和销毁,那么我们需要思考以下几个核心问题:

  1. 我们的JM内存空间需要多大才能支撑起这么多订单对象的创建?堆内存空间是关键,又该分配多少?
  2. 每台机器需要多大的内存空间,以及需要部署多少台机器?

要去分析:系统的高峰期在哪儿?比如中午和晚上,疯狂购物,一般就是持续几个小时。
可以理解为在这几个小时之间一共产生了100万个订单,按照4个小时来计算,每秒差不多在70个订单左右。
这里按照每秒有100笔订单产生来进行计算和处理。

一个支付订单的处理需要多久以及占用多大空间
接下来我们必须要清楚的知道一个订单大概要处理的时间,当用户点击提交订单的时候,这时会携带订单相关参数到电商后台系统,由电商系统创建订单,并做移除购物车商品的操作,以及保存订单到数据库的操作等,接着才会向支付系统发送当前订单,整个过程从发起请求到创建订单到支付系统中,我们粗略计算为1秒差不多了。

那一个订单所占据的对象大小是多少呢? 一般一个订单对象中核心的实例变量也就20多个差不多了,根据基本数据类型所对应的字节大小来计算,一般一个订单对象也就差不多在500字节的大小。那每秒100笔订单到来,也就是差不多一秒能产生 100*500=50000大概也就50KB而已,其实非常的小。

那么结合以上两点分析我们可以知道,系统每1秒会来100个支付订单,而每个支付订单的创建需要1秒,那也就是1秒过后,就会在内存中产生50KB的垃圾对象,因为1秒过后这100个对象就没人引用了,成为新生代中的垃圾对象

在这里插入图片描述

下一秒过后又会持续产生100个订单对象,那么接着又继续产生50KB的垃圾对象,如此一来新生代里就会持续的产生堆积垃圾对象,直到装满为止触发MinorGc进行回收。

支付系统内存占用预估

按照上述所分析,1秒产生50KB垃圾对象,那么100秒就有差不多5MB垃圾对象了,可能大家觉得有点不足为惧,但是我们以上仅仅只是分析了一个支付订单对象的占用大小,实际运行中每秒还会产生其他大量的对象(系统本身的+我们携带关联的各种对象),所以我们真正要估算内存占用的话,还得将之前的计算结果放大10~20倍!

那这样估算的话,我们每秒钟创建的对象大概就在500KB~1MB之间。按最大1MB来计算好了,1秒产生1MB垃圾对象,那100秒就能产生出来100MB垃圾对象,按新生代内存为1个G来计算,Eden区分配800MB,那也就是800秒就得触发一次Minor Gc了,如果频繁的触发Minor Gc肯定不是一个好事!会影响我们线上的性能稳定。

支付系统JVM内存如何设置?

那我们在真正系统上线的时候应该如何进行部署以及分配JM内存呢?这里假如我们经济有限仅仅分配一台2核4G的机器来部署,4G的内存能真正分配到JM上的也就最多2G,而这2G还不能全都给堆内存,还有方法区、栈内存等区域,堆内存最多也就能分配到个1G左右,而且堆内存还分新生代和老年代,这样算下来我们的新生代最多也就几百Mb大小了,根据我们之前的分析,1秒就能消耗1MB左右的内存,几百秒就能撑满导致垃圾回收,影响我们系统的性能稳定性。(一旦触发垃圾回收就会导致STW,系统线程停止,这块我们后续会讲解)

那么如何解决和优化呢?

  1. 可以考虑提升成本,使用4核8G的机器来进行部署, 那么我们的JVM至少可以分配到4G以上内存,新生代也至1.少能分配到2G内存,那么可以将Minor Gc的触发时间由几百秒提升到半小时~1小时触发,降低GC的频率
  2. 扩展服务器数量,我们可以部署3~5台机器来进行横向扩展,当然机器数量越多,每天机器处理的请求就更少,这样对JM内存的压力就更小。

当然实际需要根据各位自己的业务量以及系统性能进行合理配置,针对每个系统上线前都要做一次JM内存的模拟估算(如何通过工具来查看实际JVM内存的变化过程后续我们再讲解)并且是多次测试得出一个合理的数据再通过预估的用户请求量来进行模拟估算,提前设置有一个合理的值,减少Gc的频繁触发,保障系统的稳定运行。

7.3. 双11大促,瞬时访问量增加10倍

除了日常的平均支付交易量需要预估设置以外,还需要思考的就是大促的时候如何保障服务器的稳定
之前我们计算过每一秒产生的对象是在1MB,那遇到大促的时候,每一秒的内存占用有可能就能达到10MB甚至几十MB(得往大一点预估不要考虑刚刚好),并且这个时候还有个问题就是,以前差不多1秒能处理完我们100个订单,但是现在1000个订单1秒是肯定处理不完的,刚才也分析过,CPU、线程、内存都吃紧,系统性能也会跟着不稳定,那1000个订单至少需要几秒甚至几十秒才可能处理完毕。

那么当我们的新生代快满的时候,这个时候还在往里进对象就会出现问题,因为上一波的对象可能还未处理完这时有来一波对象,而新生代中也被垃圾对象给填满了,那么就会触发Minor Gc,假设我们的新生代和老年代内存分配分别为1G,现在的情况如下:

在这里插入图片描述

新的请求过来分配空间不足触发MinorGc,回收部分对象,而我们的少部分对象由于系统处理较慢还在引用,而每秒还在产生大量对象不断进来,假如我们预估每秒创建新对象100MB,1个G的新生代中Eden区占800MB,不到8S就会触发一次MinorGc,那么这么大量频繁的触发MinorGc,加上少数对象处理较慢就会导致部分对象由于多次经历Minor Gc后依然存活,最终进入老年代:

在这里插入图片描述

而当那部分对象处理完毕后失去引用就成为垃圾对象了,但是已经存在于我们的老年代了

在这里插入图片描述

那么按照这个频率老年代被占满的速度也很快!而一旦老年代被占满就会触发FullGC,这个比Minor Gc更恐怖,导致系统暂停的时长更久!你试想下,在大促秒杀抢单的过程中你的系统卡死,正在执行垃圾回收,而用户这边一直无法进入付款页面是什么感受? 等系统恢复,再进行付款这时也过秒杀时段或商品已售空,严重影响用户体验。

至于新生代和老年代的垃圾回收规则以及如何优化我们放在后续讲解。

因此大家在公司进行项目开发上线的时候一定要结合JVM内存进行思考和预估,特别是用户量大的项目,不合理的预估业务系统压力,等真正压力来临的时候,系统随时面临崩盘。这也是为什么很多大厂面试都要考核JVM这块的原因,考核你是否真正做到对你自己的系统足够了解,对线上的内存预估是否准确。


8. 如何判断对象可以回收

当触发垃圾回收的时候,我们的JVM到底按照一个什么样的规则来回收垃圾对象。到底哪些对象可以被回收,哪些对象不能被回收。

8.1. 可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至古老的Lisp) 的内存管理子系统,都是通过可达性分析 (Reachability Analysis) 算法来判定对象是否存活的。 这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain) ,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GCRoots到这个对象不可达时,则证明此对象是不可能再被使用的。

总结下就是: 每一个对象,都分析下有谁在引用他,然后一层一层往上去判断,看是否有一个GC Roots

在这里插入图片描述

那么有哪些是被作为是GC Roots对象的?
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、 临时变量等。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • 类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • Java虚拟机内部的引用,如基本数据类型对应的class对象,一些常驻的异常对象(比如NulPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

在我们刚才的代码中:

public class Test {private static User user = new User();public static void main(String[] args) throws InterruptedException {user.login();for (int i = 0; i < 100; i++) {doSomething();Thread.sleep(200);}}public static void doSomething(){Student stu = new Student();stu.study();}
}

在这里插入图片描述

局部变量stu就是一个GC Roots,这种也是最常见的一种情况。假如代码正在调用执行doSomething()方法,这时局部变量stu持有实例对象,新生代空间存满,发生垃圾回收,就会去分析这个stu对象的可达性,这时发现stu对象被局部变量GC Roots持有无法回收。

类静态变量也是一种常见的GC Roots,上述代码中的user对象就是被user静态变量持有,那么当发生垃圾回收的时候也不会被回收。

一句话总结:只要你的对象被方法的局部变量、类的静态变量给引用了,就不会回收他们。


8.2. 强引用、软引用、弱引用、虚引用

Java有不同的引用类型,分别是:强引用、软引用、弱引用、虚引用,不同的引用类型跟我们的垃圾回收也有着不同的规则。

在这里插入图片描述

强引用

我们直接通过new关键字创建出来的对象都叫强引用对象,比如:

Object obj=new 0bject();

强引用的特点:

  1. 强引用可以直接访问目标对象。
  2. 强引用所指向的对象在任何时候都不会被系统回收。JVM宁愿抛出O0M异常,也不会回收强引用所指向的对象。
  3. 强引用可能导致内存泄漏。

软引用

软引用是除了强引用外,最强的引用类型。可以通过java.lang:ref.SoftReference使用软引用。一个持有软引用的对象,不会被JVM很快回收,JVM会根据当前堆的使用情况来判断何时回收 (只有当JM认为内存不足时,才会试图去回收软引用的对象,JVM 会确保在抛出 0utOfMemoryError 之前,清理软引用指向的对象)。因此,软引用可以用于实现对内存敏感的高速缓存。

代码示例:

User user =new User();//user就是一个强引用
SoftReference<User>softReference= new SoftReference<>(user);
user = nu1l://销毁强引用
System.gc();//手动垃圾回收
System.out.printin(softReference.get());//打印软引用中user对象地址值:demo2.User@b4c966a

注意:触发软引用回收的点在于当内存空间已经装不下的时候以及内存空间很紧张的时候执行回收。

在这里插入图片描述

代码示例:

package demo03;import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;/*** 需要先配置参数-Xms2M -Xmx3M,将JVM的初始内存设为2M,最大可用内存为3M* 1.当内存充足的情况下,软引用是不会被回收的,哪怕你主动调用垃圾回收System.gc();* 2.当内存不足的情况下,软引用就会被回收* @author J_shuai* @date 2025-08-30 21:58*/
public class Test {private static List<Object> list = new ArrayList<>();public static void main(String[] args) {testSoftReference();//testWeakReference();}private static void testSoftReference() {for (int i = 0; i < 10; i++) {//创建1MB的数组,并将其包装成SoftReference对象,并存入集合中byte[] buff = new byte[1024 * 1024];//1MB// 创建软引用对象,并将其包装成SoftReference对象,并存入集合中SoftReference<byte[]> sr = new SoftReference<>(buff);// 将SoftReference对象存入集合中list.add(sr);}System.gc();//主动通知垃圾回收for (int i = 0; i < list.size(); i++) {Object obj = ((SoftReference) list.get(i)).get();System.out.println(obj);}}
}

在这里插入图片描述


弱引用

弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。在JDK1.2之后,用java.lang.ref.WeakReference 来表示弱引用。

我们以同样的方式来测试弱引用:

public class Test {private static List<Object> list = new ArrayList<>();public static void main(String[] args) {testWeakReference();}private static void testWeakReference(){for (int i = 0; i < 10; i++) {//创建1MB的数组,并将其包装成WeakReference对象,并存入集合中byte[] buff = new byte[1024 * 1024];//1MB// 创建软引用对象,并将其包装成SoftReference对象,并存入集合中WeakReference<byte[]> sr = new WeakReference<>(buff);// 将sr对象存入集合中list.add(sr);}System.gc();//主动通知垃圾回收for (int i = 0; i < list.size(); i++) {Object obj = ((WeakReference) list.get(i)).get();System.out.println(obj);}}
}

虚引用

虚引用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,在JK1.2之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个get( 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue引用队列一起使用。虚引用在创建时必须传入一个引用队列作为参数,当垃圾收集器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。

public class PhantomReference<T> extends Reference<T>{public T get(){return null;}public PhantomReference(T referent, ReferenceQueue<? super T> g){super(referent,q);}
}

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。
由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。


8.3.【生存还是死亡?】对象的finalization机制

现在理解了GC Roots和引用类型的概念后,也就知道了哪些对象可以被回收,哪些对象不能回收。

有GC Roots引用的对象不能回收,没有Gc Roots引用的对象可以回收,如果有GC Roots引用,但是如果是软引用或者弱引用的,也有可能被回收掉。

问: 假设没有GC Roots引用的对象,是一定立马被回收吗?
答: 其实不是的,这里有一个 finalize()方法可以拯救他自己

示例代码:

package demo03;/*** 1.对象可以在被GC时自我拯救。* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次* 一个类可以重写Finalize()方法,在方法里面去去自救或者做一些事情,这个方法会在GC的时候被JVM自动调用** @author J_shuai* @date 2025-08-30 22:27*/
public class TestFinalize {public static TestFinalize testFinalize;@Overrideprotected void finalize() throws Throwable {System.out.println("当前类的finalize方法执行");// 当当前对象在被JVM回收的时候,重新将当前对象赋值给静态变量,从而拯救自己testFinalize = this;}public static void main(String[] args) {//对象第一次成功拯救自己testFinalize = new TestFinalize();testFinalize = null;System.gc();System.out.println("第一次 gc......");try {//因为Finalizer方法优先级很低,暂停0.5秒,以等待它Thread.sleep(500);} catch (InterruptedException e) {e.printStackTrace();// 打印异常信息}if (testFinalize != null) {System.out.println("对象被拯救");} else {System.out.println("对象被回收");}//下面这段代码与上面的完全相同,但是这次自救却失败了testFinalize = null;System.gc();System.out.println("第二次 gc......");try {Thread.sleep(500);} catch (InterruptedException e) {e.printStackTrace();}if (testFinalize == null) {System.out.println("对象已死");} else {System.out.println("对象依然存活");}}
}

打印结果

第一次 gc......
当前类的finalize方法执行
对象被拯救
第二次 gc......
对象已死

注意: finalize方法只会被调用一次!

永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:

  • 在 finalize()时可能会导致对象复活
  • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则 finalize()方法将没有执行机会。
  • 一个糟糕的fnalize()会严重影响GC的性能

从功能上来说, finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以 finalize()方法在本质上不同于c++中的析构函数。

其次它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。有些教材中描述它适合做“关闭外部资源”之类的清理性工作,这完全是对finalize()方法用途的一种自我安慰。finalize()能做的所有工作,使用try-finallv或者其他方式都可以做得更好、更及时,所以建议大家完全可以忘掉Java语言里面的这个方法。


9. JVM垃圾回收算法【新生代】

通过之前的学习,我们知道了JVM会通过可达性算法来筛选出哪些对象是可回收的,哪些对象是不可回收的,GCRoots对象是哪些,java的引用类型有哪些以及finlize()方法的作用。同时我们也知道了当一个对象在创建的时候是存放在堆内存中的新生代里的,那么当新生代内存满了后就会触发Minor Gc;但是问题是我们如何针对新生代内存进行管理,以及如何进行回收这也是一个值得分析和探讨的问题。

9.1.标记清除算法

我们先来回顾下之前讲堆内存的结构分配

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

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

Java堆区进一步细分的话,可以划分为: 新生代(YoungGen)和老年代(oldGen),其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。

在这里插入图片描述

这里大家需要去思考,为什么JM会分成年轻代和老年代,以及年轻代里面又为什么要再划分出三个区域,这样做的好处是什么?

我们先来看新生代(年轻代)的第一种:标记-清除算法所带来的的优劣

这时比如我们的代码如下:

public class Demo2 {public static void main(String[] args) {regisUser();}public static void regisUser() {User user=new User();}}

那么对应内存中的分配就如下:

在这里插入图片描述

那么如果我们假设我们的程序不停止,依然在运行,这时不停的调用registUser()方法生产大量的User对象,当对应栈帧已经退出,没有指向对应的对象,那么就会在堆内存中产生大量的垃圾对象:
在这里插入图片描述

当新生代第一块区域内容已满,装不下的时候,就会触发MinorGc回收垃圾。

标记-清除算法:
就是根据之前的可达性分析算法+四种引用类型对象判断,来标记哪些是可以被回收的对象(垃圾对象),哪些是存活的对象,然后对垃圾对象进行清理回收。

在这里插入图片描述

这时,如果我们仅仅是采用标记-清除算法,标记哪些对象是可回收的,哪些对象是不可回收的,然后针对可回收的内容进行回收,那么会导致一个不好的后果,就是产生大量的内存碎片。

在这里插入图片描述


9.2. 内存碎片

内存碎片一般是由于空闲的连续性空间比要申请的空间小,导致这些小内存不能被利用

在这里插入图片描述

产生内存碎片的方法很简单,举个例:
假设有一块一共有100个单位的连续空闲内存空间,范围是0-99。如果你从中申请一块内存,如10个单位,那么申请出来的内存块就为0-9区间。这时候你继续申请一块内存,比如说5个单位大,第二块得到的内存块就应该为10~14区间。
如果你把第一块内存块释放,然后再申请一块大于10个单位的内存块,比如说20个单位。因为刚被释放的内存块不能满足新的请求,所以只能从15开始分配出20个单位的内存块。现在整个内存空间的状态是0-9空闲,10-14被占用,15-35被占用,36-99空闲。其中0-9就是一个内存碎片了。如果10-14一直被占用,而以后申请的空间都大于10个单位,那么0~9就永远用不上了,造成内存浪费。如果你每次申请内存的大小,都比前一次释放的内存大小要小,那么申请就总能成功。

在这里插入图片描述

如果内存碎片过多,就会造成大量的内存浪费,随着回收的次数越多,这样的碎片可能更多更杂乱,因此这样通过标记清除算法进行内存回收的做法是不可取的


9.3. 标记复制算法

1969年Feniche!提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面然后再把已使用过的内存空间一次清理掉。
简单点来说,就是把新生代的内存分为两块,如下图所示:

在这里插入图片描述

因此JVM也采用了复制算法,当真正发生垃圾回收的时候,JVM会将第一块空间中哪些对象是可回收的,不能回收的进行标记【标记清除算法】,然后将不可回收的对象统统复制到下面那块区域中,并且复制的时候可以紧凑的排列在一起,最大化利用内存空间:

在这里插入图片描述

那么我们可以直接一次性回收掉上面空间的所有垃圾对象,同时有新的对象产生的时候,直接放在下面这块区域进行存储即可。 那么这时上面空间就会腾出,下面空间就越会越来越多:

在这里插入图片描述

当下面区域装满的时候,同样按照刚才的逻辑复制存活对象到上面区域,一次性回收下面区域内存。两块区域内存就可以一直重复循环使用。


9.4.复制算法的缺点

那么复制算法确实可以解决内存碎片的问题,也使得我们的回收工作更加效率,不过其缺点也是显而易见的。这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点 。

如果我们给新生代内存分配一个G的大小,那么两块区域平均分配,各自占512MB内存,从始至终就只有一半的内存可用,这样的算法对内存的使用效率就太低了!
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释–新生代中的对象有98%熬不过第一轮收集。 因此并不需要按照1:1的比例来划分新生代的内存空间。
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略, 现在称为“Appel式回收”。 Hotspot虛拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。

Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块survivol空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和survivor的大小比例是8:1,也即每次新生代中可用内存空间为整个新生代容量的90% ( Eden的80%加上一个Survivor的10%),只有一个survivor空间,即10%的新生代是会被“浪费”的。

在这里插入图片描述


当然,98%的对象可被回收仅仅是“普通场景”下测得的数据, 任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳-次 Minor GC之后存活的对象时,就需要依赖其他内存区域 (实际上大多就是老年代) 进行分配担保(HandlePromotion)。

内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那我们下一次也能按时按量地偿还贷款,银行就认为没有什么风险了。 内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。


10. 对象进老年代案例剖析

10.1. ①幸存者区装不下

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。Hotspot虚拟机提供了-XX:+PrintGcDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析。

接下来在如下的代码片段testAllocation()方法中 ,我们尝试分配2个2MB大小和一个3MB大小的对象,在运行时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:Survivor-Ratio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1。

package demo03;import java.util.Scanner;/*** VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGcDetails -XX:SurvivorRatio=8 -XX:+UseSerialGc* -Xms20M -Xmx20M:堆内存20M;-Xmn10M:新生代10M (End8M,S0、S1:1M);-XX:+PrintGcDetails:日志打印;* Xx:+UseSerial6c:这里我们先手动指定垃圾收集器为客户端模式下的serial+Serial 0ld的收集器组合进行内存* 回收由于不同的收集器的收集机制不同,为了呈现出内存分配的担保效果,我们这里需要手动指定为Serial+serial old模式。** @author J_shuai* @date 2025-08-31 09:36*/
public class Test1 { private static final int _1MB = 1024 * 1024;// 1M// 堆内存20M,新生代10M,老年代10M;public static void testAllocation() {Scanner sc =new Scanner(System.in);byte[] allocation1,allocation2, allocation3;System.out.println("start...");sc.nextLine();//1allocation1 =new byte[2*_1MB];//对内存中开辟一块2M的空间,此时新生代剩余空间为8Msc.nextLine();//2allocation2 =new byte[2*_1MB];sc.nextLine();//3allocation3=new byte[3*_1MB];//出现一次Minor GCsc.nextLine();//4System.out.println("end...");}public static void main(String[] args) {testAllocation();}
}

在这里插入图片描述

开始

在这里插入图片描述

输入1

在这里插入图片描述

输入2

在这里插入图片描述

当准备输入3的时候

在这里插入图片描述


输入3后,触发Minor GC

在这里插入图片描述

在这里插入图片描述

执行testAllocation()中分配allocation4对象的语句时会发生一次Minor Gc,这次回收的结果是新生代7654KB变为1023KB:堆内存总量由7654KB变为5219KB。

产生这次垃圾收集的原因是为allocation4分配内存时,发现Eden已经被占用了7MB多,剩余空间已不足以分配alocation3所需的3MB内存,因此发生Minor Gc。垃圾收集期间虚拟机又发现已有的两个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。

在这里插入图片描述


10.2. ②对象太大

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。上述例子中的bytel]数组就是典型的大对象,大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇至群 “朝生夕灭” 的“短命大对象”,我们写程序的时候应注意避免。

在Java虚拟机中要避免大对象的原因是,在分配空间时, 它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。 试想一下,一个大对象屡次躲过GC,还要不停在两个survivor区域里复制来复制去才能进入老年代,这本身就是一个很耗时间和效率的事儿。

Hotspot虚拟机提供了 -XX: PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

示例代码如下:

package demo03;import java.util.Scanner;/**** VM参数:-verbose:gc -XX:+PrintGcDetails -Xms20M -Xmx20M -Xmn18M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:PretenureSizeThreshold=3M* *-XX:PretenureSizeThreshold=3M 设置当对象超过指定阈值则直接进老年代** @author J_shuai* @date 2025-08-31 09:36*/
public class Test2 {private static final int _1MB = 1024 * 1024;// 1M// 堆内存20M,新生代10M,老年代10M;public static void testAllocation() {Scanner sc =new Scanner(System.in);byte[] allocation1;allocation1= new byte[4 * _1MB];}public static void main(String[] args) {testAllocation();}
}

在这里插入图片描述

通过打印结果可以看到,新生代eden区使用的22%,也就是1802KB大小,显然不是对应的allocation对象;而我们的 tenured generation total 10240K,used 4096K,the space 10240K,40%used 使用的4MB,刚好和allocation对象大小匹配;从而验证了一点,当我们的大对象超过3MB的时候,会直接进入老年代。

注意-XX;PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel scavenge收集器并不支持这个参数。如果必须使用此参数进行调优,可考虑ParNew加CMS的收集器组合。


10.3. ③年龄到15岁

Hotspot虚拟机在内存回收时必须能决策哪些存活对象应当放在新生代, 哪些存活对象放在老年代中。 为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。 对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被survivor容纳的话,该对象会被移动到survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-Xx:MaxTenuringThreshold设置值,当年龄等于对应值,遇到GC则直接晋升老年代。

10.3.1. MaxTenuringThreshold=1的情况

当我们以 -XX: MaxTenuringThreshold=1 进行参数设置时,来执行以下代码:

package demo03;import java.util.Scanner;/*** VM参数:-verbose:gc -XX:+PrintGcDetails -Xms48M -Xmx48M -Xmn20M -XX:SurvivorRatio=8 -XX:+UseSerialGc -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution-XX:MaxTenuringThreshold=1 :当新生代对象的年龄达到1岁即可进入老年代-XX:+PrintTenuringDistribution:JVM 在每次新生代GC时,打印出幸存区中对象的年龄分布。** @author J_shuai* @date 2025-08-31 09:36*/
public class Test3 {private static final int _1MB = 1024 * 1024;// 1M// 堆内存20M,新生代10M,老年代10M;public static void testAllocation() {Scanner sc =new Scanner(System.in);byte[] allocation1,allocation2, allocation3;System.out.println("start...");sc.nextLine();//1allocation1 =new byte[_1MB/4];// 256KB 什么时候进入老年代决定于XX:MaxTenuringThreshold设置的值sc.nextLine();//2allocation2 =new byte[4*_1MB];sc.nextLine();//3allocation3=new byte[10*_1MB];//出现一次Minor GCsc.nextLine();//4allocation3=null;allocation3=new byte[10*_1MB];// 出现一次Full GCsc.nextLine();//5System.out.println("end...");}public static void main(String[] args) {testAllocation();}
}

最开始

在这里插入图片描述

输入1

在这里插入图片描述

在这里插入图片描述

输入2

在这里插入图片描述

在这里插入图片描述

输入3,触发GC

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

输入4,触发一次Full GC

在这里插入图片描述

在这里插入图片描述

当输入4的时候,首先将 allocation3=null;那么allocation3 开辟的10M空间,就变成了垃圾,之后再执行 allocation3=new byte[10*_1MB];,需要重新开辟10M空间,但由于之前的10M垃圾空间并没有被回收掉,而新的10M空间又无法开辟出来,那么就触发了出现一次GC,但是注意我们之前第一次GC的时候 ,存放在幸存者区的数据年龄变成1了。

在这里插入图片描述

输入4,触发GC完成之后

在这里插入图片描述


10.3.2. MaxTenuringThreshold=15的情况

代码没有变化,只是JVM参数发生了改变,我们可以直接看运行后的日志结果:

package demo03;import java.util.Scanner;/*** VM参数:-verbose:gc -XX:+PrintGcDetails -Xms48M -Xmx48M -Xmn20M -XX:SurvivorRatio=8 -XX:+UseSerialGc -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution-XX:MaxTenuringThreshold=15 :当新生代对象的年龄达到15岁即可进入老年代-XX:+PrintTenuringDistribution:JVM 在每次新生代GC时,打印出幸存区中对象的年龄分布。** @author J_shuai* @date 2025-08-31 09:36*/
public class Test3 {private static final int _1MB = 1024 * 1024;// 1M// 堆内存20M,新生代10M,老年代10M;public static void testAllocation() {Scanner sc =new Scanner(System.in);byte[] allocation1,allocation2, allocation3;System.out.println("start...");sc.nextLine();//1allocation1 =new byte[_1MB/4];// 256KB 什么时候进入老年代决定于XX:MaxTenuringThreshold设置的值sc.nextLine();//2allocation2 =new byte[4*_1MB];sc.nextLine();//3allocation3=new byte[10*_1MB];//出现一次Minor GCsc.nextLine();//4allocation3=null;allocation3=new byte[10*_1MB];// 出现一次Full GCsc.nextLine();//5System.out.println("end...");}public static void main(String[] args) {testAllocation();}
}

在这里插入图片描述

执行2之后

在这里插入图片描述

执行3,之后触发GC

在这里插入图片描述

在这里插入图片描述


执行4,再次触发GC

在这里插入图片描述

在这里插入图片描述

注意:我们神奇的发现:在第二次GC后,新生代的占用空间变成了0! 这是尼玛啥情况!我们明明已经设置了阈值为15
这里跟这个对象年龄有另外一个规则可以让对象进入老年代,不用等待15此GC过后才可以。


10.4. 动态对象年龄判断机制

为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX: MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold中要求的年龄。

10.5. 空间分配担保

之前我们讲过,如果Eden区中的对象无法存入Survivor区则会通过空间分配担保,让对象直接进入老年代But!大家是否想过一个问题:如果老年代里空间也不够这些对象呢?又该咋整!别急,我们一步一图继续讲解。

老年代空间够用

首先:在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。

试想一个极端情况就是MinorGC后所有对象存活下来,那所有的对象都会进入老年代,如果老年代判断剩余空间是大于所有对象的那么就可以放心担保进入老年代

在这里插入图片描述


老年代空间不够

但是: 假如执行Minor GC之前,发现老年代的可用内存空间已经小于新生代的全部对象大小了,那么这个时候就有可能新生代Minor GC 后对象全部存活,然后需要转移到老年代,但是老年代空间又不够的情况。(理论上是有这种可能的) 因此 JVM 在 Minor GC 之前,当判断到老年代的可用内存已经小于新生代的全部对象大小,会看一个参数 : “-xx: HandlePromotionFailure”(新生代晋升老年代)是否设置了。如果有该参数的设置,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。当判断到历次平均大小是小于老年代可用内存空间的,将尝试进行-次Minor GC,尽管这次Minor GC是有风,险的;如果小于,或者 -XX: HandlePromotionFailure 没有设置,那这时就要改为进行一次Full GC (为了回收老年代里面的垃圾对象,腾出更多的空间)。

举个栗子,之前每次Minor GC之后,平均都有10MB左右的对象会进入老年代,那么此时老年代可用内存大于10MB,这就说明,很可能这次Minor GC 过后也是差不多10MB左右的对象会进入老年代,此时老年代空间是够的。

取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次Minor GC 存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好老老实实地重新发起一次Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将 -XX :HandlePromotionFailure 开关打开,避免Full GC过于频繁。
我们通过完整的一张流程图来帮助大家更好的梳理清楚整个JVM的空间担保原则:

在这里插入图片描述

如果FUllGC 过后 ,老年代的空间还是小于要存进来的新生代的空间

  • 如下图新生代存活了4M,当新对象6M,需要申请空间,触发MinorGC ,发现平均值为3M,小于此时的老年代空间4M,那么就还可以存进老年代,但是需要将这4M存活对象在空间存放到老年代,但是老年代此时只剩下3M

在这里插入图片描述

那么此时就会触发FullGC

在这里插入图片描述

我们通过完整的一张流程图来帮助大家更好的梳理清楚整个JVM的空间担保原则:

在这里插入图片描述


小结

通过以上的分析我们其实也知道了,老年代触发垃圾回收的时机,一般就是两个:

  1. Minor GC 之前发现要进入老年代的对象太多,装不下,触发Fu’llGC 再带着进行 Minor GC
  2. Minor GC 过后,剩余对象太多老年代存放不下,触发Full GC

11. 老年代垃圾回收算法-标记整理算法

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

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

在这里插入图片描述

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新 所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用 程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机 设计者形象地描述为“stop The World”。

老年代的垃圾回收算法速度至少比新声代的垃圾回收算法的速度慢10倍!如果频繁出现老年代的Full GC,会导致系统性能被严重影响,出现频繁卡顿的情况!

所以后面用各种案例给大家展现出来的就是在各种业务系统的生产故障下,如何去一步一步分析为什么会频繁触发Full GC ,然后怎么通过调整JVM 的参数来进行优化!

所谓 JVM 优化就是尽可能的让对象都在新生代分配和回收,尽量避免频繁的老年代Full GC,同时给系统充足的内存大小,避免新生代也频繁的垃圾回收,更好的保证系统的运行效率。


12.【实战】日处理上亿数据的系统内存分析和优化

12.1. 系统背景

该系统主要是做大数据相关计算分析的,日处理数据量在上亿的规模。这里我们重点针对 JVM 内存的管理来进行模型分析,数据的来源获取主要是MYSQL数据库以及其他数据源里提取大量的数据,通过加载到JVM内存的过程我们来一起分析出现的问题以及如何优化解决(如下图所示):

在这里插入图片描述

12.2. 生产环境

这是一套分布式运行系统,生产环境部署了多台服务器(每台4核8G配置),每台机器大概每分钟负责执行100次数据提取和计算,每次提取大概1万条左右的数据到内存计算,平均每次计算需要耗费10秒左右时间。 JVM内存总共分配了4G,堆内存占3G,其中新生代和老年代分别是1.5G的内存空间

在这里插入图片描述

12.3. 过程分析

这里每条数据较大,平均包含20个字段,可以认为每条数据大概在1KB左右。那么1万条数据对应就是10MB大小。那么运行多久就会导致新生代塞满呢?
新生代总共分配1.5G,那么Eden区分配就是1.2G,S1和S2区分别是150MB;如下图:

在这里插入图片描述


手动计算下:

  • 1次往Eden区里填充10MB对象,1分钟读取100次,也就是差不多1个G
  • 1分钟左右 Eden 区就差不多填满
  • 触发Minor GC

我们通过之前的学习知道,JVM在执行Minor Gc之前是会进行一步检查动作的:老年代可用内存空间是否大于新生代全部对象? 如果是第一次运行到这儿,那么我们的老年代是空的,也就是有1.5G的空间,完全是够用的。


在这里插入图片描述


这里触发Minor GC 进行回收,但是问题在于如何回收呢?

  • 每次任务计算的耗时是10S
  • 1分钟能执行大概80次任务,剩余20个任务正在计算中

也就是1分钟触发Minor GC,同时还有20个任务正在计算,对应200MB对象正在引用;而我们的幸存者区域最大也就是150MB无法存放下200MB,那么根据我们讲过的空间担保机制,这200MB对象会直接进入到老年代!如下图:

在这里插入图片描述

分析:

  • 第一分钟,触发Minor GC,老年代占用200MB,剩余1.3G
  • 第二分钟,触发Minor GC,老年代占用400MB,剩余1.1G
  • 第三分钟,触发Minor GC,老年代可用空间1.1G小于Eden区所有对象1.2G

在这里插入图片描述


先看参数: -XX:-HandlePromotionFailure是否设置,当然一般都会设置,此时会判断老年代连续空间是否大于历史平均晋升老年代对象的大小,那历史晋升对象大小都在200MB,很明显大于,那么 JVM 会直接进行冒险操作,触发Minor GC 的执行,而本次冒险是成功的!新生代依然继续晋升200MB对象到老年代。

那么当系统运行到第7分钟的时候,这时进入到老年代的对象有1.4G了,剩余空间仅剩100MB!如下图:

在这里插入图片描述

系统运行到这儿,发现老年代剩余空间已经比历史平均晋升对象大小都要小了,这时会直接触发Ful GC! 假设老年代空间都可以被回收,那么这时老年代对象就完全清除,接着会继续进行MinorGC,200MB对象继续进入老年代又开始重复循环执行了。


结论:

那么按照以上的运行分析,我们可以得出一个结论就是: 系统平均运行7、8分钟左右就会触发–次Full GC的执行! 而每次一旦Full GC 执行,就会严重影响到系统的运行效率,加上该系统的Full GC频率较高,给用户带来的使用感受是非常糟糕的!


12.4. JVM优化

像真实开发中大家也有很大几率会遇到类似这样的情况,我们应该减少 Full GC 的次数以及降低它出现的频率,甚至不触发Ful GC,那么如何进行优化呢?这也是考验一个Java程序员的价值体现。

针对类似的计算系统,每次 Minor GC 的时候,必然会有一部分数据没处理完毕,但是按照现有的内存模型,我们的幸存者区域只有150MB是无法满足200MB对象的存放,因此有必要调整我们的内存比例。

解决方案:
3GB的堆内存大小,我们直接分配2G给新生代,1G给老年代,这样Survivor区的大小就有200MB了每次刚好能存放下MinorGC过后存活的对象了。如下图所示:

在这里插入图片描述


只要每次 Minor GC时200MB存活对象可以存放进Survivor区,那么等下一次Minor GC时这部分对象对应的计算任务也已经结束,也可以直接进行回收。

那么接下来我们还是在继续模拟跑一次,当Eden区内存已经装满,此时S0区也有200MB对象,这时触发Minor GC的执行,200MB正在执行的任务对象(存活对象)直接转移到S1区,回收清空掉Eden区和S0区,如下图:

在这里插入图片描述

那么通过以上的分析也不免看出,基本上很少会有对象进入到老年代,我们也成功的将几分钟一次的Full GC降低到几个小时一次甚至没有,大幅度提升了系统的性能,避免了Full GC对系统运行的影响!

当然这里其实还有一个细节点: 就是对象幼态年龄判断规则!如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 -XX:MaxTenuringThreshold 中要求的年龄。 这里需要结合自己公司的实际系统分析到底有多少对象是根据动态年龄规则进入到了老年代,如果要避免因为这项规则进入老年代,从而触发Full GC也可以尝试调整Eden区Survivor区的比例,调整survivor区的大小。


13. 垃圾收集器

如果说收集算法是内存回收的方法论

那垃圾收集器就是内存回收的实践者

13.1. Serial收集器

Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。 这个收集器是一个单线程工作的收集器, 但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程“Stop TheWorld”,直到它收集结束。

“Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉, 这对很多应用来说都是不能接受的。 大家不妨试想一下,要是你的电脑每运行一个小时就会暂停响应五分钟,你会有什么样的心情?

Serial/serial old收集器的运行过程:

Serial: 新生代垃圾收集器、Serial Old: 老年代垃圾收集器

在这里插入图片描述

从 JDK1.3开始,一直到现在最新的 JDK 21, HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark sweep(CMS)GarbageFirst(G1) 收集器,最终至现在垃圾收集器的最前沿成果 Shenandoah ZGC 等,我们看到了一个个越来越构思精巧,越来越优秀,也越来越复杂的垃圾收集器不断涌现,用户线程的停顿时间在持续缩短,但是仍然没有办法彻底消除,探索更优秀垃圾收集器的工作仍在继续。

通过下列命令查看jdk使用何种垃圾收集器

-XX:+PrintCommandLineFlags

在这里插入图片描述

对于单核处理器或处理器核心数较少的环境来说, Serial收集器由于没有线程交互的开销, 专心做垃圾收集自然可以获得最高的单线程收集效率。 在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代 ( 仅仅是指新生代使用的内存, 桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

开启Serial收集器的命令: -XX:+UseSerialGC


13.2. ParNew 收集器

我们运行在服务器上的 Java 系统,其实可以充分利用服务器的多核CPU资源的优势,比如通常服务器配置为4核CPU,如果我们使用 (Serial) 收集器单线程进行回收,是没有充分利用我们的CPU处理器资源的。如下图:

在这里插入图片描述


我们的ParNew垃圾回收器主打的就是多线程垃圾回收机制,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数 (例如: -XX:SurvivorRatio、-Xx:PretenureSizeThreshold、 -XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

ParNew收集器的运行图如下:

在这里插入图片描述

ParNew收集器一旦在合适的时机执行Minor GC的时候,就会把系统程序的工作线程全部停掉,禁止程序继续运行创建新的对象,然后通过分配多个线程(理论上4核CPU可以支持4个垃圾回收线程并行执行)进行垃圾回收工作提升性能,如下图:

在这里插入图片描述

开启ParNew收集器的命令: -XX:+UseParNewGC (开启的是新生代的)

​ 默认情况下,垃圾回收的线程数量跟我们的CPU的核数是一致的,比如我们线上机器是8核CPU,那么我们垃圾回收器对应的线程数量也可以达到8个。当然我们也可以手动修改线程数量:
“-XX:ParallelGcThreads” 参数即可。(建议一般不要修改,除非涉及到一些内存优化,后续我们再结合案例讲解)

补充说明:

ParNew 收集器除子多线程实现垃圾收集之外,其他没有什么太多创新之处,但是它确实是Server模式下的新生代首选虚拟机收集器。其中一个重要的原因就是除了Serial收集器外,只有它能与CMS配合使用。

新生代使用ParNew,老年代使用CMS收集器,这是目前大部分线上生产系统的标配组合。

Parallel收集器 (Parallel Scavenge(新生代)+Parallel Old(老年代)) 主要适合在后台运算而不需要太多交互的分析任务,最高效率利用CPU资源,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整参数(新生代大小、Eden和Survior比例、晋升老年代对象大小等) 以提供最合适的停顿时间或者最大的吞吐量。


13.3. 老年代CMC收集器

一般老年代我们选择的垃圾回收器就是CMS ( Concurrent MarkSweep ) 收集器,这是一种以获取最短回收停顿时间为目标的收集器。 我们大部分互联网网站或者基于浏览器的B/S系统的服务端 ,这类应用通常都会较为关注服务的响应速度, 希望系统停顿时间尽可能短,以给用户带来良好的交互体验。 CMS收集器就非常符合这类应用的需求。

从名字 ( 包含“Mark sweep” ) 上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  1. 初始标记 (CMS initial mark)
  2. 并发标记 (CMS concurrent mark)
  3. 重新标记 (CMS remark)
  4. 并发清除 (CMS concurrent sweep)

1) 初始标记(CMS initialmark)

首先根据之前讲过的“可达性分析算法“来判断有哪些对象是被GC Roots给引用的,如果是的话就是存活对象,否则就是垃圾对象。然后将垃圾对象都标记出来,如下图:

在这里插入图片描述

注意:初始标记的过程会让系统停止工作,进入“Stop The World”状态,不过这个过程很快,仅仅标记 GC Roots直接引用的那些对象。(回顾下GC Roots对象有:类的静态变量,方法的局部变量)假设我现在系统中有这样一段代码:

public class Test{private static Company company=new Company();
}public class Company{private Employee employee =new Employee();
}

那么在内存中对应的初始标记阶段只会标记出来GC Roots直接引用的对象(就是在GC Roots引用连中的第一个对象),也就是Company()对象,而employee对象仅仅是类的实例变量,不会被进行标记。内存图如下:

在这里插入图片描述

注意: Employee对象仅仅是类的实例变量引用的对象,不是GCRoot直接引用的对象,因此初始标记并不会进行标记,暂时归类为垃圾对象。


2) 并发标记(CMSconcurrentmark)

并发标记阶段恢复系统正常运行,可以随意创建对象,同时并发标记线程也开始工作,这里由于一边进行并发标记,一边进行对象的创建,必然会持续增加新的对象产生,同时也有可能一些对象失去引用变成垃圾对象。

那么并发标记主要是标记哪些对象呢?比如Emplovee对象,垃圾回收线程会判断该对象被谁引用,这里是被company对象引用,再次判断company对象被谁引用,由于初始标记的时候已经知道是被GCRoots直接引用,从而判断到Employee对象是间接被GCRoots对象引用,从而标记为存活对象。

在这里插入图片描述

总之,针对所有老年代中存在的对象以及不断新增的对象都会进行标记,而我们的系统线程也在一直工作不断产生对象,所以该阶段也是最耗时的。虽然是耗时的,但是垃圾回收与系统是并行进行的,所以并不会对系统的运行造成影响。


3)重新标记(CMS remark)

由于我们的第二个阶段是并发标记,那么肯定会造成有部分对象已经失去引用变成垃圾对象没有来得及更正,以及新创建的对象还未来得及标记,如下图:

在这里插入图片描述

因此第三阶段: 重新标记 会暂停我们的系统线程,开始重新整理,如下图:

在这里插入图片描述

不过该阶段会很快,要是针对第二阶段中被系统程序运行变动过的少数对象进行标记,所以速度很快。
接着重新恢复系统线程工作,开始进入第四阶段: 并发清理。


4) 并发清除(CMSconcurrentsweep)

最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

在这里插入图片描述


5) 小结

通过以上CMS工作的整个过程,我们总结下:

  • 最耗时的阶段是: 并发标记与并发清除 ----> 不过该阶段是与用户线程并发执行并不影响系统
  • 初始标记和重新标记阶段: 需要Stopthe World,暂停系统工作---->但是该两个阶段速度很快几乎影响不大

通过一张完整的流程图来表示我们CMS的工作逻辑:

在这里插入图片描述


6) CMS的缺点分析

CMS是一款优秀的收集器, 它最主要的优点在名字上已经体现出来: 并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause collector)。CMS收集器是Hotspot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:

1.并发导致CPU资源紧张
  • CMS默认启动的回收线程数是 (处理器核心数量+3) /4

比如我们常见的机器是2核4G,那么分配给CMS的回收线程数=(2+3)/4=1个,直接占据了一半的CPU资源

因此建议大家在实际开发中,可以配置服务器至少为4核或者以上。

2.Con-current Mode Failure问题频
  1. 【浮动垃圾】由于CMS收集器无法处理“浮动垃圾”(Floating Garbage)有可能出现“Con-current ModeFailure”失败进而导致另一次完全“Stop The World”的Full GC的产生。

在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”

  1. 【预留空间】由干在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。

  2. 【新对象分配失败】 要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败” (Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案: 冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。

在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-Xx:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK6时,CMS收集器的启动阈值就已经默认提升至92%。

所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置,

3.内存碎片问题

CMS是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。(如下红圈所示)

在这里插入图片描述


13.4. Garbage First收集器

13.4.1. G1收集器介绍

Garbage first(简称G1) 收集器是垃圾收集器技术发展历史上的里程碑式的成果, 它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

就是把 Java 堆内存拆分为多个大小相等的 Region ,并且也有新生代和老年代的概念,只不过是逻辑概念。每一个Region都可以根据需要,扮演新生代的 Eden空间Survivor空间,或者老年代空间

JDK9默认就是采用G1收集器:

首先要有一个思想上的改变, 在G1收集器出现之前的所有其他收集器,包括CMS在内, 垃圾收集的目标范围要么是整个新生代(MinorGc),要么就是整个老年代(MajorGc),再要么就是整个Java堆 (FulGC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集 (Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多, 回收收益最大,这就是G1收集器的 Mixed GC 模式。

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

​ Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionsize设定,取值范围为1MB~32MB ,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待,如下图所示:

在这里插入图片描述


亮点:G1收集器能建立可预测的停顿时间模型

比如我们希望G1在垃圾回收的时候可以保证1小时内系统停顿(STW)的时间不超过1分钟。

那这个模型就很厉害了!之前我们通过各种JVM参数设置,内存分配就是为了尽可能的减少Minor GCFull GC 带来的停顿,从而影响系统的请求。 而现在通过G1收集器能直接给定一个指定的时间,交给G1全权负责,达成目标!

那么G1是如何做到对垃圾回收导致的系统停顿可控的?
那么具体的思路是:G1会对每一个Region里回收价值进行追踪,动态的判断如何回收

啥叫回收价值呢? 也就是G1必须搞清楚每一个Region里面到底有多少的垃圾对象需要回收,以及回收这些对象需要消耗多少时间。

比如下图,1个Region中有10MB,需要回收时间为1S,另一个Region中有20MB垃圾对象,需要消耗200ms,那么G1肯定会优先选择时间更少还能回收更多垃圾的200msRegion。

在这里插入图片描述


G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间 ( 使用参数 -XX:MaxGcrauseMilis 指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“GarbageFirst”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。这也是G1的核心设计思路。

相关JVM参数:
-XX: +UserG1GC : 在JDK8中可以通过手动指定使用G1收集器进行回收
-XX : G1HeapRegionSize=size 指定每一个Region的大小
-XX: MaxGCPauseMillis=time 指定收集的停顿时间,默认是200ms

G1中的Region是如何分配的? 每个Region大小是多少?
默认情况下是自己分配和设置,我们依然可以通过参数 : “-Xms”和“-Xmx” 来设置堆内存的大小,默认情况下是分配2048个Region,比如我们设置堆内存大小为2G,那么分配到2048个Region中,每一个Region的大小就是1MB。而且Region的大小必须是2的倍数,比如1MB,2MB,4MB之类的。

当然我们也可以通过“-XX:G1HeapRegionSize”来手动指定每一个Region的大小。

这里还有一些默认配置需要大家清楚:

  • 新生代默认开始分配占比是5%,可以通过“-XX:G1NewSizePercent“ 来设置新生代初始占比

  • 新生代运行过程中最多可以分配到60%的堆内存,可以通过 “-XX: G1MaxNewSizePercent”来设置

  • 新生代中默认也是按照8:1:1对Eden和survivor去进行分配


15.2. G1垃圾回收流程

在这里插入图片描述

G1的垃圾回收流程主要是从新生代回收开始,新生代回收与并发标记再到混合回收,接下来我们就先来说第一个新生代回收。

15.2.1.G1 Yong Collection

当我们的程序启动刚开始的时候会默认分配新生代5%的空间,这里我们假设分配了8个Region给Eden,1个Region给Survior(只是为了画图方便,实际可能Eden对应了有好几十甚至上百个Region),那么对应的初始内存分配如下:

在这里插入图片描述

那么当我们的Eden区域装满,还是会触发新生代的GC,那么新生代的GC还是会通过复制算法来进行垃圾回收,同时系统进入“Stop the World” 状态,然后把Eden区中的对应的Region里存活的对象拷贝到S1对应的Region中,接着回收掉Eden对应的Region中的垃圾对象。

注意并不是Eden区一满就会立马触发新生代GC,G1会计算现在Eden区回收大概要多久的时间,如果远远小于默认的200ms值,那么就继续增加新生代的Region,继续存放新的对象,知道下一次Eden区满在计算回收时间,如果时间接近200ms或设置的值,则触发MionrGC。

在这里插入图片描述


那么新生代对象什么时候进入老年代呢?跟之前一样,还是这么几个条件:

  1. 对象在新生代躲过了多次的垃圾回收,达到了一定的年龄,就会进入老年代。可以通过参数“-XX:MaxTenuringThreshold”进行年龄的设置。

  2. 动态年龄规则判断,如果一旦发现某个新生代GC过后,同年龄的存活对象超过了survior的50%,比如此时有1岁的,2岁的,3岁的,5岁的,发现3岁的对象大小总和已经超过了Survior的50%。那么3岁及以上的对象都会全部进入老年代。

所以经过一段时间新生代的使用和垃圾回收后,总有一些对象会进入老年代,如下图:

在这里插入图片描述

此时大家可能会疑惑? 之前不是说我们有大对象根据JVM的空间担保原则也会直接进入老年代吗?

实际根据G1的分配原则,G1会提供专门的Region来存放大对象,而不是让大对象直接进入老年代的Region中,G1中如何判断大对象是根据Region的大小来的,如果一个对象的大小已经超过Region大小的50%了,那么就会被放入大对象专门的Region中,这种Region我们叫humongous,如下图:

在这里插入图片描述


那肯定会有人问了,这个humongous区域的大对象什么时候被回收呢? 它既不属于新生代与不属于老年代,什么时候触发垃圾回收进行回收?

其实很简单,在新生代和老年代回收的时候,就会顺带着对大对象一并回收了,所以这就是G1内存模型下对大对象的分配和回收的策略。

注意:

在G1进行新生代垃圾回收的同时还会做一件事情就是“初始标记”:: 仅仅只是标记一下GC Roots能直接关联到的对象,为下一阶段并发标记做准备(跟之前的CMS垃圾回收过程类似)


15.2.2.G1 Yong Collection + Concunrrent Mark

当G1新生代垃圾回收结束后,紧接着开始进入并发标记阶段:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。

而且JVM会对并发标记阶段对对象做出的一些修改记录起来,比如哪个对象被新建了,哪个对象失去引用了。

在这里插入图片描述


15.2.3.G1 Mixed Collection

G1有一个参数: “-XX:InitiatingHeapOccupancyPercent” 默认值是45%

也就是说,当老年代的大小占据了堆内存的45%的Region时,此时就会触发一个新生代和老年代的混合回收阶段,对ES0 H进行全面回收。

该阶段一旦触发会导致系统进入STW,同时进行最后一个标记:

  • 最终标记阶段: 会根据并发标记阶段记录的对象修改,最终标记哪些对象是存活,哪些对象是垃圾

此时老年代也是根据标记-复制算法来进行回收的,会将标记存活的对象拷贝到新的Region中作为老年代区域

在这里插入图片描述

注意我们上面说过一个参数: -XX:MaxGCPauseMilis=time 指定收集的停顿时间,默认是200ms。

由于混合回收是一个比较耗时的操作,那么根据G1的特点可以指定收集停顿时间,为了保证停顿时间这个目标,JVM会从新生代、老年代、以及大对象H区挑选一部分Region进行拷贝回收,如果回收不完,后续再进行回收,一部分一部分回收直到回收完毕。但是一次回收停顿的时长保证再200ms。

这里有一个参数: “-XX:G1MixedGccountTarget”,可以设置在一次混合回收的过程中,最后一个阶段执行几次混合回收,默认值是8次!这样设置的目的也是能让每次回收停顿的时长记得到保证同时又能间隙的让系统接着运行。

同时还有一个参数: “-XX:G1HeapWastePercent”,默认值是5%,意思是当混合回收的时候,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收。

15.2.4.Full GC

当在进行混合回收的过程中,由于无论是新生代还是老年代都是基于复制算法进行的,都需要将各个Region中的存活对象拷贝到别的Region中,此时如果一旦出现拷贝的过程中发现没有空闲的Region可以进行存储了,就会触发一次失败!那么这个时候系统会立马切换为我们的Seiral收集器进行单线程的标记、清理和压缩整理,该过程就变得非常的慢了!


13.5. 总结

这里我们可以小结下各个收集器的FullGC:

  • SerialGC

    • 新生代内存不足发生的垃圾收集 -minor gc
    • 老年代内存不足发生的垃圾收集 -full gc
  • ParallelGC \ ParNew

    • 新生代内存不足发生的垃圾收集 -minor gc
    • 老年代内存不足发生的垃圾收集 -full gc
  • CMS

    • 新生代内存不足发生的垃圾收集 -minor gc
    • 老年代内存92%开始回收,触发Concurrent Mode Failure时触发Full GC
  • G1

    • 新生代内存不足发生的垃圾收集 -minor gc
    • 老年代内存不足,无多余Region可供拷贝,触发Full GC

http://www.dtcms.com/a/359986.html

相关文章:

  • docker中的命令(四)
  • 大话 IOT 技术(3) -- MQTT篇
  • 机器视觉学习-day19-图像亮度变换
  • 【模型训练篇】VeRL分布式基础 - 框架Ray
  • 分布式相关
  • 正则表达式 Python re 库完整教程
  • 如何用熵正则化控制注意力分数的分布
  • 让你的App与众不同打造独特品牌展示平台
  • Scikit-learn Python机器学习 - 类别特征提取- OneHotEncoder
  • 编写Linux下usb设备驱动方法:disconnect函数中要完成的任务
  • 【数学建模学习笔记】异常值处理
  • RAG(检索增强生成)技术的核心原理与实现细节
  • 【Unity开发】Unity核心学习(三)
  • macos自动安装emsdk4.0.13脚本
  • 在Ubuntu系统上安装和配置JMeter和Ant进行性能测试
  • 基于SpringBoot + Vue 的宠物领养管理系统
  • 【Spring Cloud微服务】7.拆解分布式事务与CAP理论:从理论到实践,打造数据一致性堡垒
  • ANR InputDispatching TimeOut超时判断 - android-15.0.0_r23
  • 拆分TypeScript项目的学习收获:处理编译缓存和包缓存,引用本地项目,使用相对路径
  • 配置 Kubernetes Master 节点不可调度的标准方法
  • 【51单片机】【protues仿真】基于51单片机音乐喷泉系统
  • 记录测试环境hertzbeat压测cpu高,oom问题排查。jvm,mat,visulavm
  • opencv 梯度提取
  • [Android] UI进阶笔记:从 Toolbar 到可折叠标题栏的完整实战
  • 掩码语言模型(Masked Language Model, MLM)
  • android-studio 安装
  • 基于计算机视觉的海底图像增强系统:技术详述与实现
  • 如何正确校正电脑时间?
  • 【开源】AI模型接口管理与分发系统开源项目推荐
  • Redis八股小记