❤️‍🔥万字深度解析JVM内存模型,看完起飞🚀🚀

发布于:2024-04-30 ⋅ 阅读:(25) ⋅ 点赞:(0)

JVM组成

image-20230831142243692

各个组件的说明

子系统名称 描述 线程共享
类加载子系统 负责将.class文件加载到内存区域中。
堆内存(Heap) Java虚拟机中最大的一片空间,存储new出来的对象,以及通过其它方式构建出来的对象。也是GC主要回收的一片区域。 共享
方法区(Method Area) 存储一些类的定义信息,类的元信息,类的方法定义,类中的常量。(元数据) 共享
虚拟机栈(VM Stack) 存储线程运行过程中的栈信息,程序的调用与执行过程,就好比异常时打印出来的栈信息就是其中之一。 线程私有
程序计数器 保存程序运行到哪里了。 线程私有
本地方法栈 与虚拟机栈类似,但是这个是针对于本地方法native方法的栈信息。而虚拟机栈是针对于Java方法的 线程私有

执行引擎

负责翻译字节码指令到操作系统认知的指令,调用操作系统的指令


1.程序计数器

用于记录我们的程序执行到哪个位置的一个组件。

我们的Java程序其实就是字节码,执行需要字节码指令,我们知道Java是基于线程的,并且可以多个线程同时执行,因为CPU核心有限,想要执行多线程任务的话,就需要协调好各个线程的执行顺序,就是通过时间片来进行协调管理,那么就会存在线程的停止和启动,如果没有记录程序运行位置的话,程序停止后在运行就找不到该从哪开始执行,而程序计数器就恰恰解决了这个问题。

image-20230901160848261

如果调用的是navite方法,那么程序计数器为空。

因为native方法直接是调用的是c程序,处于两个不同的内存空间。因此获取不到相关信息。

特性

  • 程序计数器是记录着当前线程所即将执行的字节码指令行号
  • 每一个线程都拥有自己的计数器
  • 执行Java方法时,程序计数器是有值的
  • 执行native本地方法时,计数器值为空
  • 程序计数器占用内存非常少,不会出现OutOfMemoryError

2.虚拟机栈

栈介绍:它是一种数据结构,特性如下

  • 连续紧密存储
  • 先进后出
  • 压栈与出栈
  • 集装箱的摆放方式就是栈结构,入栈就是堆叠集装箱,出栈就是挪走集装箱
  • image-20230901161848494

虚拟机栈的生命周期是和线程一致的

线程运行时则虚拟机栈存在,线程销毁时,虚拟机栈也销毁。

栈大小、空间

  • Java1.5后默认每个栈大小为1mb,在此之前为256kb
  • Java在启动参数中配置-Xss数值[k|m|g] 可以配置栈大小,例:-Xss10m
    • 不建议手动设置大小,1mb可以满足使用,如果设置的太大会导致OS内存压力增大,影响高并发环境下的性能
  • 栈分配的内存决定了栈的深度,超出栈的深度则会抛出栈溢出的异常
    • StackOverflowError 堆栈溢出
    • OutOfMemoryError 内存溢出

栈帧的组成

一个栈帧就对应一个方法调用,也就是一个方法,除了方法具体的流程外,栈帧还包括其它内容:

  • 局部变量表
    • 方法内部的局部变量信息
  • 操作数栈
    • 保存中间计算的临时结果
  • 动态链接
    • 将符号引用转换为直接引用
  • 返回地址
    • 存放调用方法的程序计数器值
image-20230904094248530

局部变量表

局部变量表存储以下两块的内容:

  • 存储方法参数
  • 存储方法内的局部变量

何为局部变量?

public class Test{
   // 成员变量
   private int name;
   public void sayHi(){
      // 局部变量,因为作用域仅仅是方法内
      String text = "h";
   }
}

局部变量的特性

  • 线程私有,不允许跨线程访问,随方法调用创建,方法退出销毁
  • 编译期间局部变量表长度(变量个数)已确定,局部变量元数据会存在在字节码文件中。
  • 局部变量表是栈帧中最主要的存储空间,大小影响栈的深度。

字节码文件内定义的局部变量表元数据

使用Jclasslib插件查看

sayHi()实例方法的源代码

public void sayHi(int printTotal) {
   for (int i = 0; i < printTotal; i++) {
      int a = 0;
      String text = "你好";
      System.out.println(text + a);
   }
}

sayHi()实例方法对应的本地变量表

起始PC代表该变量是在第几行字节码创建的

main()静态方法的源代码

public static void main(String[] args) {
   new LocalVarSample().sayHi(3);
}

main()方法对应的局部变量表

image-20230904102418060

对比这两个方法(静态方法,实例方法),可以发现以下不同,也是最主要的区别

  • 静态方法的局部变量表没有this,因为是static方法,而sayHi()方法对应的0号槽位(0号序号)就是this变量

剩下的就是相同的地方

  • 局部变量表的顺序由变量的书写顺序决定,如果是构造方法或者实例方法,则0号槽位(序号为0)存放this,静态方法则是第一个书写的变量。

  • 局部变量表的长度固定(多少槽位),在字节码创建时就固定下来,称之为静态局部变量表

  • 一个局部变量至少占用一个Slot槽位,对应LocalVariableTableIndex列,也就是jclasslib的序号列。

  • StartPC代表字节码行号(并非代码行号),Length代表从StartPC开始的后几行可以使用这个变量,

高级特性

  • 上边提到局部变量表中,每个变量至少占用一个Slot,那什么情况下会占用多个Slot呢?答案如下:

    • 占用槽位多少是根据数据类型大小来决定的,32位以内的类型(int\float\char\引用类型..)占用1个槽位,大于32位的数据类型(long/double)就需要使用到2个槽位

    • 如下图所示,money字段(序号2)为double类型,id字段(序号4)为long类型,他俩的序号可以发现是从2-3,4-5,一个变量占用了两个槽位,因为他们超过了32位。

    • image-20230904104438089
    • double money = 32.1;
      long id = 1111111111111111111L;
      
  • Slot复用,Slot在字节码中被称为是静态的本地变量表,而JVM运行时会把静态变量表动态的加载到内存中去,因此槽位可能会产生变化,例如Slot复用的概念。示例代码如下:

    • public void sayHi(){
         int a = 0;
         int b = 1;
         if(a < b){
            int sum = a+b;
      		a = sum;
         }
         int d = 4;
         System.out.println(a+d);
      }
      
    • 在上边的示例代码中,我们创建了4个变量,absumd

    • 字节码文件的静态变量表如下:

      • Slot槽位序号 变量名
        0 this
        1 a
        2 b
        3 sum
        4 d
    • 那程序真正执行过程中,因为if内的局部变量sum作用域仅仅只有if内部,当程序执行到if末尾时,就会把3号槽位的sum给清除掉,因为已经不可能会被访问到了,在将d加载到3号槽位,实现复用。好处如下:

      • 可以节省栈帧的空间,空间大了栈的深度就会更深。
      • 提高性能:当槽位被复用时,可以避免创建新的槽位,从而减少内存的分配和回收,提高性能。
      • 优化垃圾收集:未复用的局部变量槽位会误导垃圾收集器,阻止内存回收。通过槽位复用,垃圾收集器能更准确地回收内存。

操作数栈

字节码指令执行过程中的临时结果数据存放的地方就叫做操作数

假如我们有一个方法,对应的字节码指令如下,因为方法还未运行,所以操作数栈为空:

image-20230904142715858image-20230904143130954

当执行第一条指令bipush 10时,10将被压入栈顶。此时栈如下

执行第二条指令istore_1 时,栈顶元素将被放入局部变量表,此时局部变量表和栈的情况如下:

10将被弹出栈,并被放入局部变量表,操作数栈清空

image-20230904143112535image-20230904143130954

执行第三条指令bipush 18时,18将被压入栈顶。此时局部变量表和栈的情况如下:

image-20230904143438460

执行第四条指令istore_2时,栈又会被清空,并将18放入局部变量表。

执行第五条指令iload_1和第六条指令iload_2后,局部变量表的1018将被压入栈顶:

image-20230904143623664

执行第七条指令iadd时,操作数栈的栈顶元素与第二位元素将相加。

执行第八条指令istore_3时,栈顶存放的相加后的元素28将先被弹出栈顶,再被存入本地变量表。

执行第九条指令iload_3时,28将被压入栈顶。

image-20230904143830046

执行最后一条指令ireturn时将返回栈顶元素28

此时方法结束,局部变量表将被清空,操作数栈将被清空。方法返回结果。


动态链接

什么是动态链接?将字节码中的符号引用转换成内存的直接引用。

字节码文件中存储的引用信息都是cp_info #27这种东西,可以理解为就是个字符串信息,也叫字面量,实际上就是一个引用的信息,假如字节码new一个对象,在字节码中存储的是new #14这种字符串,那在JVM运行时,想真正的找到这个对象的信息,就要去JVM方法区里去找,而这个#14就好比是对象信息存储的位置,有了#14就能找到LocalVarSample这个对象。

在内存中执行的做个转换过程,就叫动态链接。

image-20230904145327595

为什么要这么干呢?

  • 因为栈帧的空间是有限的,不能直接存储对象的信息,如果只存储对象的指针,就能大大的节省栈帧的空间。并且方便JVM管理。

动态链接示意图


返回地址

它主要用来指示方法执行完毕后程序的执行流应该跳转到的位置。简单来说,就是告诉程序:我这个方法执行完了,你应该去哪里继续执行。

举个例子,假设你正在执行一个名为A的方法,然后在A中又调用了一个名为B的方法。那么在调用B的时候,虚拟机会在栈帧中保存一个返回地址,这个地址指向的是方法A中调用方法B那条指令的下一条指令。当方法B执行完毕后,程序就会跳转到这个返回地址,也就是回到方法A中继续执行。

爆栈

虚拟机栈,是个栈结构,既然是一个存储的结构那么必然是有大小限制,不可能无限制的一直入栈,这样内存也扛不住,那么爆栈的意思其实也很好理解,就是方法运行的层级过深,就好比递归,没有停止条件,就会导致一直入栈,一直入栈,直到把栈给顶爆,就抛出,StackOverflowError 异常。

public static void main(String[] args) {
   // 调用爆栈(递归)方法
   System.out.print(method1());
}

public int method1(){
   // 无限制的递归
   return method1();
}

结果

Exception in thread "main" java.lang.StackOverflowError
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
	at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
   ... 省略n行

3.本地方法栈

本地方法栈是专供于本地方法(navite修饰)使用的,当JVM通过JNI调用本地方法时,本地方法栈就是主要记录JVM调用本地方法时的一些状态信息。


4.堆(Heap)

堆是JVM中最核心的内存区域,存放运行时实例化的对象实例。堆在JVM创建的时候就被创建,空间也被分配。堆是线程共享的一片大区域。

  • 堆内存在物理上是分散的,在逻辑上是连续的,也就是说堆内存的数据可以分散在不同的内存颗粒上,但是JVM在使用时会通过指针将数据连续起来,在我们看来JVM堆就是连续的一片空间。

  • 堆中包含线程私有的缓冲区(TLAB),可以有效的提高JVM的并发效率

TLAB(Thread-Local Allocation Buffer)是线程本地分配缓冲区的缩写。在Java中,为了提高内存分配的效率,JVM通常会为每个线程在堆中分配一个私有的缓冲区,这就是TLAB。

TLAB的主要作用是减少线程之间的竞争。在多线程环境下,如果每个线程都直接在堆上分配内存,那么这些线程可能会竞争同一块内存区域,从而导致性能下降。通过使用TLAB,每个线程都可以在自己的缓冲区中分配内存,从而避免了这种竞争。

TLAB只用于分配小对象。当一个线程需要分配一个大对象时,它会直接在堆上进行分配。当TLAB用完时,线程会申请一个新的TLAB。

总的来说,TLAB的引入是为了提高内存分配的效率,特别是在多线程环境下。

引用类型与堆的关系

  • 引用类型的本质就是一个指针,指向堆内存的队列实例地址。
  • 堆是垃圾回收(GC)的重点区域,方法结束后并不会立即进行垃圾回收,而是等JVM判断需要垃圾回收时才进行垃圾回收。

image-20230904171606718

堆结构

  • 新生代,主要存放刚创建的对象,这些对象是不稳定的,可能会被频繁GC回收。
  • 老年代,存放相对稳定的对象(GC多次未被回收),不会进行频繁的GC行为。
  • 元空间,内存中永久保存区域,用于存放类的描述信息,几乎不会GC。
image-20230905135503001

堆大小

参数相关

  • -Xms数量[k|m|g] 设置堆空间(年轻代+老年代)的初始化内存大小,例如-Xms2g
  • -Xmx数量[k|m|g] 设置堆空间(年轻代+老年代)的最大内存大小,例如-Xmx2g
  • -XX:+PrintGCDetails 设置该参数可以查看GC相关的数据,在程序退出时打印
Heap
 PSYoungGen      total 1378816K, used 161309K [0x0000000716580000, 0x000000076e180000, 0x00000007c0000000)
  eden space 1364480K, 11% used [0x0000000716580000,0x0000000720307790,0x0000000769a00000)
  from space 14336K, 0% used [0x000000076d380000,0x000000076d380000,0x000000076e180000)
  to   space 33792K, 0% used [0x0000000769f80000,0x0000000769f80000,0x000000076c080000)
 ParOldGen       total 459264K, used 73890K [0x00000005c3000000, 0x00000005df080000, 0x0000000716580000)
  object space 459264K, 16% used [0x00000005c3000000,0x00000005c7828ba0,0x00000005df080000)
 Metaspace       used 92836K, capacity 98120K, committed 98752K, reserved 1134592K
  class space    used 12143K, capacity 13082K, committed 13184K, reserved 1048576K

设置建议:建议整个堆大小设置为FullGC后存活对象的3-4倍

默认值

  • 堆默认大小为操作系统内存的64分之1
  • 堆最大内存为操作系统的4分之1
  • 新生代与老年代比例
    • 新生代占用总堆的3分之1
    • 老年代占用总堆的3分之2

新生代

新生代分为三个小区域:Eden(伊甸园)、Survivor-0(From)、Survivor-1(To)

  • 内存分配比例为:8:1:1
  • 假如新生代有1G内存,那分配结果就是:800M100M100M
  • 绝大多数刚创建的对象都会放在Eden区,因此需要有足够的空间存放这些对象

刚初始化的新生代,此时还没有数据时是长这样的

image-20230905145226969

如果新创建一个对象:User user = new User() 此时User()对象会被放入Eden区。

image-20230905145534420

如果Eden区满了(默认)的时候,无法分配新对象的时候,就会触发一次Minor GC,也叫Young GC对新生代进行垃圾回收。

  • 根据可达性算法扫描过所有对象后,会将对象分为两拨:无引用对象,持有引用对象
  • 持有引用对象将会被通过复制拷贝算法到S0区,剩下的所有对象将被垃圾回收掉。
  • 并将对象的头属性age + 1
image-20230905145534420

Eden区再次满了之后,再次触发Minor GC,再次通过可达性算法分析对象是否持有引用,再次将对象进行拷贝交互和清除。

假如我们的User对象依然持有引用,User对象将被复制并拷贝到S1age + 1,再将无引用对象进行垃圾回收

image-20230905150216165

如果Eden区再次满了,又会触发Minor GC,此时我们的User对象依然持有引用,那么它将被移动到S0age + 1

image-20230905150409121

如果一直Minor GC,直到Userage大于15时,User对象会被JVM认定为是稳定的对象,会被放入Old Gen老年代里。

image-20230905151019995

问题一

为什么User会从S1移动到S0呢?

  • JVM为了方便关联内存数据,解决内存碎片问题,所以会将S0区和S1区的数据进行互换,每次Minor GCEden区的对象会被放入空的Survivor区,并将该区的所有对象移动到另一个Survivor
  • 出对象的Survivor区被称为From区,移动到的Survivor区被称为To

问题二

为什么垃圾回收要分代(新生代、老年代)处理

  • 为了执行效率的考量,因为大多数对象的存活时间可能极低,可能方法执行完对象就没有引用了,如果触发全局GCFull GC),则会牵连无关紧要的对象,影响整体效率,增加程序响应时间。

新生代垃圾回收的特殊情况

其实特殊情况简单概括就是,新创建的对象没有足够的内存进行分配了该怎么办?

Eden区内存不足无法分配,对象被移动到S0区

  • 执行Minor GC,并将对象移动到S0区,但是S0区满了,对象放不进去。
  • 尝试直接将对象放入Old Gen老年代里。
  • 如果老年代满了放不进去,则会触发Full GC后再次尝试存放
    • 如果还放不进去,则抛出OOM错误
image-20230905153020104

复制交换算法


5.方法区

存放类的信息,方法信息等字节码中的静态信息(元数据)。

  • 方法区是线程共享的区域,是物理上分散,逻辑上连续的一片区域

保存的内容

  1. 类型信息:这包括类的名称、父类、接口、访问修饰符等信息。
  2. 字段信息:类中的字段(包括静态字段和非静态字段)的名称、类型、访问修饰符等信息。
  3. 方法信息:类中的方法(包括静态方法和非静态方法)的名称、返回类型、参数类型、访问修饰符等信息。
  4. 常量池:包括字面量(如文本字符串)和符号引用(如类和接口的全名、字段的名称和描述符、方法的名称和描述符)。
  5. 静态变量:类的静态变量。
  6. 即时编译后的代码:如果启用了即时编译(JIT),方法区还会保存编译后的本地机器代码。

永久代与元空间

永久代的数据是存放在JVM内存之内的,和JVM共享内存空间,而元空间的数据是直接存放在操作系统内存上的,与JVM内存互不影响,不占用JVM内存。

  • 永久代占用JVM内存,容易导致内存溢出
  • 元空间与物理机内存保持一致,最大可用内存为物理机剩余可用内存。

元空间大小

默认为(12mb~21mb),根据操作系统的不同在此区间内动态调整。如果慢了则会触发Full GC,并提高元空间大小可能突破21mb

可以通过-XX:MetaSpaceSize=xxx[M|G]设置元空间最小值、使用-XX:MaxMetaSpaceSize=xxx[M|G]设置最大值

  • 建议设置较大的元空间大小,减少因元空间内存不足导致的Full GC
  • 如果元空间内存超过了MaxMetaSpaceSize则会抛出OutOfMemoryError:MetaSpace错误

不同版本JVM方法区的变化

方法区是个概念,JVM规范要求必须要有这个东西,至于怎么实现就是JVM厂商做的事情。例如HotSpot虚拟机

  • 1.7前,方法区叫做永久代Permanent Generation
  • 1.7开始逐渐抛弃永久代
  • 1.8彻底抛弃永久代并实现了元空间Meta Space

好比公共场所的消防设施,消防队要求必须有,但是怎么去安装是公共场所的事情。

  • 某棋牌室放了16个灭火器 + 3个消防栓作为消防设施
  • 某酒店采用了热感喷头 + 36个消防栓作为消防设施

运行时常量区

存放来自字节码中常量池的数据,或者动态产生的新常量。

运行时常量区位与元空间内,属于内部的一片空间。


方法区历史变化

静态变量是存储在哪的?public static User user = new User();

不同版本JVM是不一样的,如下图所示

JDK版本 变化
<=1.6 此时有永久代的概念,静态变量存放在永久代内
1.7 有永久代,但是逐渐移除永久代,字符串常量池,静态变量从永久代移除,改为存放在堆中
>= 1.8 已移除永久代,类型信息、字段、方法、常量保存在元空间内,字符串常量池,静态变量仍在堆中

静态变量是使用new关键字创建的,如new User(),那一定是存储在堆内存中的。

如果静态变量是基本类型如static int i = 1;,那它会存放在元空间内。


网站公告

今日签到

点亮在社区的每一天
去签到