性能调优 10. JVM内存模型深度剖析与优化

1. JDK体系结构


‌‌‌  JDK: 包含JAVA运行语言,JRE运行环境,JVM虚拟机。

在这里插入图片描述


2. Java语言的跨平台特性


在这里插入图片描述

‌‌‌  不同平台JDK不同即JVM不同,所以同一JAVA代码在不同平台能生成平台对应的机器码运行,这就是跨平台。


3. JVM整体结构及内存模型


在这里插入图片描述


JVM虚拟机组成部分

在这里插入图片描述

虚拟机核心是运行时数据区

‌‌‌  1. 类装载子系统(C++):加载Class文件,丢到运行时数据区(JVM内存区)。

‌‌‌  2. 字节码执行引擎(C++),执行方法区的加载类信息的代码,修改线程运行方法内容指令时,程序计数器的存储指令的地址。

‌‌‌  3. 虚拟机每个线程,都会分配一块工作内存。一个工作内存包含,虚拟机栈,程序计数器,本地方法栈。

‌‌‌  4. 虚拟机栈和本地方法栈也成为JAVA的栈区。 栈使用都是本地内存,也就是内存条剩余物理内存


线程栈(虚拟机栈)


在这里插入图片描述

‌‌‌  1. 每个线程分配唯一的栈,私有的

‌‌‌  2. 线程运行每个方法都会分配唯一的栈帧,存放方法运行过程中的数据结构,栈帧是存放在线程栈中
‌‌‌  栈里头栈帧是后入先出,一个方法运行完栈帧就出栈,释放栈帧的数据。
‌‌‌  栈帧存放:局部变量表,动态链接,操作数栈,方法出口等。

‌‌‌  3. 栈和栈帧的大小可以设置,有默认大小。

‌‌‌  查看设置栈大小

‌‌‌  查看虚拟机栈的默认大小


‌‌‌  java -XX:+PrintFlagsFinal -version | findstr ThreadStackSize

‌‌‌  这边默认虚拟机栈VMThreadStackSize为0B。

在这里插入图片描述

‌‌‌  通过JVM启动参数来设置栈的大小。例如,使用-Xss参数可以指定每个线程的栈大小,单位可以是K、M等。例如,-Xss2M表示将线程的栈大小设置为2MB。
  
  查看设置栈帧大小


‌‌‌  java -XX:+PrintFlagsFinal -version | findstr MaxJavaStackTraceDepth

‌‌‌  这边默认栈帧大小1024B,栈帧大小是由编译器或解释器自动计算和设置的,一般不需要手动设置。

在这里插入图片描述

‌‌‌  4. 栈帧里头数据满了或者栈里头栈帧满了,会报发生栈溢出错误(StackOverflowError)。


栈帧的组成部分

局部变量表

‌‌‌  局部变量表,存储的就是某个方法运行期间,会使用或创建的变量(local variable),以数组方式存储一个存储单位称之为slot(槽)。一个方法的局部变量表长度(slot的个数,也就是数组的长度)是在编译器就已经决定了的,我们能在class文件里找到这个值

在这里插入图片描述

‌‌‌  局部变量表大概如下所示:

‌‌‌  0位置:每个方法的局部变量表,第一个位置必须是this,调用该方法的对象。

‌‌‌  方法参数列表:方法参数列表排在this后面。

‌‌‌  方法内部创建的变量:方法内部创建变量排在方法参数列表后面。譬如我们在方法里声明了一个int值,那么在局部变量表后面就会新增一个slot,存储这个int变量。但是需要注意的是,如果你只是new Object,却并没有定义变量,那么是不会增加slot的。

‌‌‌  需要注意的是,int、boolean、char、Object这种都只占一个slot,如果遇到long或者double类型的,则占用两个slot来存储。


操作数栈

‌‌‌  操作数栈,是一种栈结构。运行方法指令时,临时存放操作数据地方。类似于局部变量表,其操作数栈最大深度也是在编译期间就已经决定了,也能在class文件中找到该值

‌‌‌  JAVAP -V 命令解析一个Class文件来理解操作数栈

‌‌‌  源代码内容

在这里插入图片描述

‌‌‌  JAVAP -V 解析代码编译后Class文件的助计指令

在这里插入图片描述

‌‌‌  分析

‌‌‌  结合JVM指令表,分析其中compute方法。

‌‌‌  0. iconst_1 将int类型常量1入操作数栈。

‌‌‌  1. istore_1 将操作栈顶int类型值存入局部变量1。局部变量1相当于局部变量表数组下标1的元素。这边是局部变量a,将栈顶的常量1出栈存入a,这时a=1,局部变量都已在局部变量表中分配了一个槽

‌‌‌  2. iconst_2 将int类型常量2入栈。

‌‌‌  3. istore_2 将操作栈顶int类型值出栈存入局部变量2,这边就是赋值给b。

‌‌‌  4. iload_1 从局部变量1中装载int类型值,将a的值复制入操作栈。

‌‌‌  5. iload_2 从局部变量2中装载int类型值,将b的值复制入操作栈。

‌‌‌  6. iadd 从操作数栈顶弹出两个int类型的数值,将它们相加再入栈,这边就是a+b。结合cpu来说,该指令转成cpu指令,cpu从寄存器或内存中获取两个整数值,将它们相加,并将结果存储到指定的目标位置(寄存器或内存)。

‌‌‌  7. bipush 10 将整数10压入操作栈。

‌‌‌  9. imul相乘 从操作数栈顶弹出两个int类型的数值,将它们相乘再入栈。这边就是3*10。这边地址原本地址是8变成9可以理解为前面常量10入栈,也占用一个内存地址。

‌‌‌  10. istore_3 将操作栈顶int类型值存入局部变量3,这边将30赋值给局部变量c。

‌‌‌  11. iload_3 从局部变量3中装载int类型值,将c的值入栈。

‌‌‌  12. ireturn 从方法中返回int类型的数据,将操作栈顶的int类型值出栈。

‌‌‌  13. 根据方法出口存储的地址,返回到main方法继续执行。、main方法局部变量表第一个位置存储了Math对象在堆的地址。


动态链接

‌‌‌  是在程序运行期间完成的将符号引用替换为直接引用,然后将直接引用放到,每个方法运行时候创建栈帧的动态链接中,比如在在运行阶段调用到方法时候,如下面代码。

‌‌‌  创建Math的实例math,然后调用math.compute()。需要知道compute()方法符号相关代码内容的地址,就会将math.compute()这个符号引用转成直接引用。

‌‌‌  package com.tuling.jvm;

‌‌‌  public class Math {
‌‌‌  public static final int initData = 666;
‌‌‌  public static User user = new User();

	public int compute() { //一个方法对应一块栈帧内存区域
	int a = 1;
	‌‌‌ int b = 2;
	‌‌‌ int c = (a + b) * 10;
	‌‌‌ return c;
	‌‌‌ }
	
	‌‌‌public static void main(String[] args) {
	‌‌‌Math math = new Math();
	‌‌‌math.compute();
	‌‌‌}
‌‌‌  }


‌‌‌  静态链接:为了提升效率,在类加载解析过程。将符号引用替换为直接引用,该阶段会把一些静态方法的符号引用,比如main()这个符号,替换为指向数据所存内存的指针或句柄等(了解下直接指针访问或者句柄访问数据),即直接引用(这些方法符号的代码在方法区地址,类加载后类相关信息会放在方法区)。这是所谓的静态链接过程(类加载期间完成),静态链接会在类加载期间存储在类对应的Class文件常量池中。
 
 

方法出口

‌‌‌  看下面示例图,执行完代码,要返回Main方法哪行代码继续执行,这些信息数据就存储在方法出口

在这里插入图片描述
在这里插入图片描述

栈和栈帧关系示例

‌‌‌  启动代码,添加JVM启动参数-Xss128k(设置虚拟机栈的大小参数)。


‌‌‌  package com.liu.jvm;

‌‌‌  /**
 * @author jsLiu
 * @version 1.0.0
 * @description 查看一个栈的栈帧放满后,栈溢出
 * @date 2023/08/27
 */// JVM设置-Xss128k(默认1M)
‌‌‌  public class StackOverflowTest {

    static int count = 0;

    static void redo() {
        count++;
        redo();
    }

    public static void main(String[] args) {
        try {
            redo();
        } catch (Throwable t) {
            t.printStackTrace();
            System.out.println(count);
        }
    }
‌‌‌  }

‌‌‌  结论

‌‌‌  -Xss设置越小count值越小,说明能执行方法次数越少。也就是一个线程栈设置越小能分配的栈帧就越少,但是对JVM整体来说能开启的线程数(一个线程对应一个虚拟机栈)会更多


程序计数器(PC寄存器)

‌‌‌  1. 每个线程拥有一个程序计数器,私有的

‌‌‌  2. 程序计数器只存一条数据,消耗内存小且固定。这个数据可以理解为执行下一条字节码指令代码的地址,执行完一条指令,字节码执行引擎就会修改成下个指令地址。

‌‌‌  3. 如遇到多线程冲突抢占cpu,那么当前线程就会挂起,程序计数器计下要执行的指令地址,后面线程才能继续执行。

‌‌‌  4. 程序计数器基本不会内存溢出,溢出也是别的区域先导致内存溢出。


本地方法栈


‌‌‌  1. 每个线程拥有一个本地方法栈,私有的

‌‌‌  2. 是Java虚拟机(JVM)在执行本地方法时使用的一块内存区域。本地方法是使用其他编程语言(如C、C++)编写并通过JNI(Java Native Interface)调用的代码。

‌‌‌  3. Windows下Java能通过本地方法调用到dll库对应的方法实现。


方法区


‌‌‌  包含常量,静态变量(如果是对象则存储是对象的地址),类元信息(C语言组织的结构体)。

‌‌‌  1.7以及之前叫永久代。1.8开始叫元空间,用的是本地内存,也就是内存条剩余的物理内存。

‌‌‌  查看和设置方法区

‌‌‌  1.7以及之前(了解)

‌‌‌  -XX:PermSize设置永久代初始大小。-XX:MaxPermSize设置永久代最大可分配空间。

‌‌‌  1.8开始及以后

‌‌‌  1. 查看默认大小和最大大小

‌‌‌  windows下查看


‌‌‌  java -XX:+PrintFlagsFinal -version 2>&1 | findstr "MetaspaceSize"

‌‌‌  2. 设置大小

‌‌‌  关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N

‌‌‌  -XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小,有的JVM虚拟机会设置最大大小。

‌‌‌  -XX:MetaspaceSize: 指定元空间触发Full GC的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发Full GC进行类型卸载。 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。

‌‌‌  由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M

‌‌‌  容量分配大小自动扩容机制

‌‌‌  假设触发Full GC回收后,剩余空间很小,那么就会调大初始值,不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。如果回收后剩余空间很大,那么就会调小初始值。所以建议设置初始值,默认21M很容易触发Full GC,比如启动war包和jar包很大,启动很慢可以看下有没有设置初始值和最大值



垃圾收集算法

‌‌‌  堆中的垃圾由垃圾回收器进行回收(后面章节会将),堆是垃圾收集器的主要工作区域(个人认为像方法区垃圾回收,用垃圾回收器回收性价比低,可用可不用。侧重垃圾回收器在堆中的回收就行)。

‌‌‌  垃圾回收器根据垃圾收集算法实现了垃圾回收功能。

‌‌‌  主要垃圾回收法算有:分代收集算法(或者说分代收集理论),复制算法,标记整理算法,标记清除算法。

‌‌‌  真正垃圾收集算法,只有复制算法,标记整理算法,标记清除算法(这些算法后面章节会说,这边只是知道有这个东西,方便理解文章)。这三种都是基于分代收集算法(分代收集理论)。复制算法和标记整理算法区别于,是否在同一页面空间


‌‌‌分代收集理论(分代收集算法)

‌‌‌  当前虚拟机的垃圾收起的垃圾收集都有采用到分代收集算法,根据对象的存活周期,把内存分成多个区域。一般很常见划分就是将,JAVA堆分为新生代和老年代,不同区域使用不同的垃圾收集算法回收对象。

‌‌‌  比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。

‌‌‌  像我们常常说堆的结构划分。

‌‌‌  老年代/年老代占3分之2,年轻代/新生代占3分之1。

‌‌‌  年轻代/新生代分Eden区,S0区,S1区。比例8:1:1。

在这里插入图片描述

‌‌‌  这些划分只是一些垃圾收集器,使用了分代收集算法,会按照这个比例去划分堆内存结构,比如Serial 垃圾收集器和CMS垃圾收集器。但是有的垃圾收集器划分,默认不一定这个比例,比如Parallel 垃圾收集器。或者有的垃圾收集器根本不在用年轻代和老年代概念划分堆,比如ZGC垃圾收集器不在使用年轻代和老年代概念,直接用分页划分堆。
  
  注意

‌‌‌  1. 在JVM中垃圾收集器负责垃圾回收,但不是所有垃圾收集器都按这种老年代,年轻代这种概念或者比例去划分堆,比如ZGC取消了老年代和年轻代的,采用分页去划分堆内存。G1垃圾收集器,将堆划分成很多独立区域,虽然保留年轻代老年代概念,但是两者比例默认就不是1:2。


可达性分析算法

‌‌‌  垃圾收集器,要回收垃圾对象前,就要有一个算法标记哪些对象是非垃圾,然后回收垃圾对象。目前主流虚拟机使用的是可达性分析算法,像引用计数法就是鸡肋基本不会用。

‌‌‌  可达性分析算法将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象


‌‌‌GC Roots根节点

‌‌‌  如线程栈的本地变量、静态变量、本地方法栈的变量等。这些变量都能找到对应的对象,把这些变量做为根节点。

在这里插入图片描述


‌‌‌ 分代收集算法下的Minor GC/Young GC

‌‌‌  分代收集算法下的堆,绝大情况创建对象放在Eden区,Eden区满就会Minor GC/Young GC。该操作大体就是根据可达性分析算法,从GC Roots根节点开始,找所有关联这些根节点的对象(比如,引用这些对象的,这些对象的成员变量等),以及关联这些根节点的对象的关联对象,一层层下去查找,总之有关联的都找出来。凡是能找到的对象,就是非垃圾对象

将非垃圾对象,复制到S0区,S0区放不下就移动到老年代。留在Eden就是垃圾对象,GC时候就清掉。

‌‌‌  一个对象如果GC后还存在,分代年龄就加1。

‌‌‌  分代年龄存在对象头里。第一次Eden满清空后,如果第二次Eden又放满,则GC还是会按照上面判断垃圾对象方式,将非垃圾对象移动到S1区,S1区放不下就移动到老年代。清理Eden和S0区垃圾对象,非垃圾对象分代年龄又加1。

‌‌‌  第三次同理,这时非垃圾对象就移动到S0,周而复始切换使用S0和S1,非垃圾对象分代年龄加1。当对象分代年龄达到15(不同垃圾回收器值可能不一样,不管怎样最大不超过15)就会移动到老年代。一般像静态变量,缓存对象等可能就会移动到老年代。

‌‌‌  老年代满后就会触发Full GC/Major GC(具体用什么垃圾回收算法和算法怎么实现,看后面垃圾回收器章节,不同垃圾回收器使用的不一样),查找非垃圾对象方法基本都用到可达性分析算法回收是整个堆(侧重堆),还有方法区。如果触发Full GC,回收垃圾对象后,存活的对象还是不能完全放入老年代,老年代满后就触发OOM


‌‌‌STW

‌‌‌  在‌‌‌Minor GC或者Full GC过程都可能会触发STW,即停止所有用户线程(JVM那些后台线程不会停止,因为不是用户发起的)。

‌‌‌  这种就会对用户体验造成影响,如卡顿等。调优一般侧重尽量减少触发Full GC,或者减少Full GC执行时间, 因为回收垃圾对象时间比较长,STW时间就比较长。


对象动态年龄判断机制

‌‌‌  对象动态年龄判断是在分代收集算法下有将对堆划分成年轻代,年轻代里头分Eden区和S区,和老年代的垃圾收集器来说。

‌‌‌  对象动态年龄判断机制一般是在Minor GC之后触发。当前存放对象的Survivor区域里(其中一块S区域),一批对象的总大小,大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定比例),那么此时大于等于这批对象年龄最大值的存活对象,就可以直接进入老年代了

‌‌‌  例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和刚好超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代(再具体说,如一个S区里头存在1个年龄为1和年龄为2的对象,所有总大小累加刚好超过了S区50%,则GC后就把年轻代里头存活的所有年龄2以及2以上对象放入老年代)。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。


‌‌‌查看设置堆大小

‌‌‌  -Xms:设置堆的初始可用大小,默认物理内存的1/64 。

‌‌‌  -Xmx:设置堆的最大可用大小,默认物理内存的1/4。

‌‌‌  -Xmn:设置新生代/年轻代的大小。

‌‌‌  -XX:NewRatio:默认2表示,新生代/年轻代占老年代/年老代,的1/2。占整个堆内存的1/3。(不要跟-Xmn一起使用,会覆盖-Xmn设置,一些垃圾收集器才支持)。

‌‌‌  -XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的,新生代/年轻代的内存(一些垃圾收集器才支持)。


JVM内存参数大小调优示例


大型电商系统后端现在一般都是拆分为多个子系统部署的,比如,商品系统,库存系统,订单系统,促销系统,会员系统等等。这里以比较核心的订单系统为例 ,估算了下,下单高峰期每台服务,每秒会产生60M的对象。

在这里插入图片描述

假设当前使用的JDK8,在JDK8默认是Parallel垃圾收集器。

‌‌‌  为了便于理解,堆的划分比例,老年代/年老代占3分之2,年轻代/新生代占3分之1。年轻代/新生代分Eden区,S0区,S1区。比例8:1:1(实际比例可能不一样)。

‌‌‌  JVM默认有个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy 固定比例(但不管怎样实际比例还是有点误差)。

‌‌‌  参数 -XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,用法
-XX:SurvivorRatio=8

‌‌‌  对于8G内存,一般是分配4G内存给JVM,正常的JVM参数配置如下:


‌‌‌  -Xms3072M -Xmx3072M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M  -XX:SurvivorRatio=8 -XX:-UseAdaptiveSizePolicy

‌‌‌  根据参考设置可知,堆初始大小和最大大小都是3072M,老年代2000多M,Eden区大小为800多M,S0和S1就都是100多M。

‌‌‌  下单这些产生的对象都是方法的局部变量,做为GCRoot,方法结束后没有引用才会被回收。

‌‌‌  下单高峰期时候,14秒就会产生800多M对象,在第14秒时候Eden区会被塞满。这时触发Minor GC,此时前13秒的对象可以被回收掉。根据当前垃圾收集器收集垃圾过程可知Minor GC会触发STW,会停止所有用户线程,此时第14秒用户线程下订单的方法还没执行完,因为STW暂停执行,则进入Eden区的对象没有被回收。 Minor GC之后存活的第14秒的这些对象(60M)进入S区(S0或者S1),这时候这批对象超过S区S0或者S1)50%。Minor GC触发后会进行对象动态年龄判断,在S区存活的60M对象满足要求,进入老年代。下单高峰期下,差不多五六分钟老年代满就会触发一次Full GC。但是这些对象其实都是垃圾对象了,在下单方法执行完就变成了垃圾对象。

‌‌‌  这种高峰期下,频繁Full GC就要进行优化

在这里插入图片描述

优化方法

‌‌‌  1. 把S区放大,修改比例。减少对象动态年龄判断的触发,但是Minor GC触发会更频繁。 下次Minor GC时对真正垃圾对象进行回收,空出空间。可以用前面说过参数-XX:SurvivorRatio设置S区的比例。

‌‌‌  2. 年轻代分配更大空间,同时Eden区和S区也会一起增大,即减少了Minor GC触发,也减少对象动态年龄判断触发。可以通过前面说的参数-Xmn设置。

‌‌‌  根据上面优化方式,选择设置加大年轻代大小,添加参数-Xmn2048M,修改JVM参数如下

‌‌‌  java -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M 
‌‌‌  -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar


结论

‌‌‌  尽可能让对象都在新生代里分配和回收,别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,在减少触发老年代垃圾回收情况下,避免新生代频繁的进行垃圾回收。


面试题


‌‌‌1. 为啥JVM回收垃圾对象,要设计STW?

‌‌‌  如果取消STW,用户线程继续执行,比如执行完一个方法,局部对象就释放了。如果之前的GC操作根据该局部变量做为Root GC根节点,找出其关联对象,确定它们都是非垃圾对象时候,用户线程执行完方法释放了局部变量对象,这时候局部变量和其相关联对象可能就都是垃圾对象。结果就不确定,重新计算又浪费时间。

  • 49
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值