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

JVM组成

各个组件的说明

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

执行引擎

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


1.程序计数器

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

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

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

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

特性

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

2.虚拟机栈

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

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

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

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

栈大小、空间

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

栈帧的组成

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

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

局部变量表

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

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

何为局部变量?

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

局部变量的特性

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

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

使用Jclasslib插件查看

sayHi()实例方法的源代码

java 复制代码
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()静态方法的源代码

java 复制代码
public static void main(String[] args) {
   new LocalVarSample().sayHi(3);
}

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

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

  • 静态方法的局部变量表没有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位。

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

    java 复制代码
    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号槽位,实现复用。好处如下:

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

操作数栈

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

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

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

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

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

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

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

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

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

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

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

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

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


动态链接

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

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

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

为什么要这么干呢?

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

动态链接示意图


返回地址

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

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

爆栈

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

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

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

结果

java 复制代码
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判断需要垃圾回收时才进行垃圾回收。

堆结构

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

堆大小

参数相关

  • -Xms数量[k|m|g] 设置堆空间(年轻代+老年代)的初始化内存大小,例如-Xms2g
  • -Xmx数量[k|m|g] 设置堆空间(年轻代+老年代)的最大内存大小,例如-Xmx2g
  • -XX:+PrintGCDetails 设置该参数可以查看GC相关的数据,在程序退出时打印
tex 复制代码
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区,因此需要有足够的空间存放这些对象

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

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

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

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

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

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

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

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

问题一

为什么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错误

复制交换算法


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;,那它会存放在元空间内。

相关推荐
bobz9654 分钟前
kubevirt 替换为 hostnetwork 的优势
后端
大象席地抽烟4 分钟前
Nginx Ingress 证书
后端
心之语歌5 分钟前
Java 设计 MCP SSE 配置
java·后端
华仔啊21 分钟前
推荐一款比Cursor更懂中国程序员的AI编程工具
前端·后端
海风极客28 分钟前
Ping命令这种事情用Go也能优雅实现
后端·go·github
天天摸鱼的java工程师1 小时前
“你能从字节码层面解释JVM内存模型吗?”——面试官的死亡提问
java·后端·面试
这里有鱼汤1 小时前
分享一个年化96%的小市值策略
后端
LaoZhangAI1 小时前
沉浸式翻译API深度解析:500万用户的翻译神器如何配置[2025完整指南]
前端·后端
brzhang2 小时前
别再梭哈 Curosr 了!这 AI 神器直接把需求、架构、任务一条龙全干了!
前端·后端·架构
安妮的心动录2 小时前
安妮的2025 Q2 Review
后端·程序员