JVM运行时数据区域(Run-Time Data Areas)的解析

# JVM运行时数据区域(Run-Time Data Areas)的解析


欢迎来到我的博客:TWind的博客

我的CSDN::Thanwind-CSDN博客

我的掘金:Thanwinde 的个人主页

本文参考于:深入理解Java虚拟机:JVM高级特性与最佳实践

本文的JVM均指HotSpot 虚拟机


0.前言

首先,JMM(Java 内存模型)和JVM运行时数据区域是两个东西(JVM内存模型),前者是一种规范,类似于接口,譬如 可见性(visibility) 、有序性(ordering )和 原子性(atomicity)

后者则是实打实的对JVM内存的严格划分区域

尽管作为一个八股中经久不衰的考查点,但很少有文章对其进行深度的剖析

虽然我的理解可能也不是很深,但我会尽己所能写出我觉得不该遗漏的地方


1.JDK 1.7

JVM的运行时数据区域在1.7->1.8阶段进行了一次巨大变化,后面的版本直到现在并未有很大的变化,我们就用1.7 和1.8来展示,首先先是1.7:

这里用了一个大致的图像来展现了JDK1.7版本下的JVM内存的大致分布

大致可以分为:

  • 虚拟机栈
  • 本地方法栈
  • 程序计数器(PC)
  • 堆内存
  • 永久代
  • 直接内存

但实际上,内存的划分比这些更为复杂,这里只是简单的概括


虚拟机栈

显而易见的,虚拟机栈是用来存储虚拟机执行的方法栈帧的

每一条线程都拥有自己的一个栈用于储存自己的栈帧

栈帧在其中以栈的形式存储,其严格的分为:

  • 局部变量表:存储了这个方法的基本类型变量以及引用类型变量的引用在编译完成后便不会改变

  • 动态链接:包含一个指向当前方法所属类型的运行时常量池(后面会介绍),栈帧会通过这个去找到对应的对象的符号引用和实际引用

    !WARNING

    注意!HotSpot在堆中存储的是对象实例数据+指向对象类型数据的指针!对象类型数据是存储在方法区之中的!

  • 操作数栈:用来存储做运算的数,譬如要计算a+b,就会往里面压入a,压入b,然后执行iadd(字节码)就会弹出两个数相加并把答案压回

  • 返回地址:链接到其执行处,你可以简单的理解为:int a = test(b),这里的test所指的地址也就是返回地址,也就是一个栈帧连接到另一个栈帧上

虚拟机栈有溢出风险:栈帧过多 ,栈内存过小都会引起栈溢出

我在查资料时看到有一种说法:递归过多会引起局部变量表膨胀导致栈溢出:实际上这是错误的,局部变量表在编译完成后就不会变了,膨胀的说法无从说起

涉及到class文件的信息会在后面再行详细讲解


本地方法栈

本地方法为JVM提供了一个用来操作底层的接口,相对应的,其栈内存的管理,使用完全取决于底层来管理

可以参考https://www.artima.com/insidejvm/ed2/jvm9.html

里面提到了:

When a thread invokes a native method, it enters a new world in which the structures and security restrictions of the Java virtual machine no longer hamper its freedom.

也就是说,虚拟机并不会妨碍其运行,就像是用一个"拓展"方法一样


程序计数器(PC)

请不要将其与OS中的PC寄存器弄混!

但你也可以将程序计数器理解为OS中的PC寄存器的虚拟机版:JVM理所当然的需要支持多个线程

那么就需要保存每一个线程上次执行到哪里,便于在每个线程之间来回切换

那么就有一小片内存用来保存其位置,这就是程序计数器

其并不会内存泄漏:就保存个位置想必也泄露不了

程序计数器并不属于栈,栈郑等等,你要硬说的话其属于线程:线程中的_last_Java_pc变量就是其上次执行到的位置,可参考

https://cr.openjdk.org/\~aph/8064357-rev-1/src/share/vm/runtime/javaFrameAnchor.hpp-.html

每个线程的程序计数器相互独立

堆内存

堆内存是JVM中最重要的存储区之一:他存储了几乎所有Java中的对象的实例数据,字符串常量池,静态变量等等

所以,堆是被所有线程所共享的

就拿栈帧来举例:

对于基本类型的数据,一般会直接存储在局部变量表中

但是对于引用类型的数据,就会引到堆内存中:其存储了实例的数据以及指向实例类型数据的指针,程序会通过实例类型数据来正确的加载使用这个类

对象数据会存储在堆内存中,对象的类型数据会存储在永久代之中

堆内存中还存有譬如字符串常量池,引用类型的静态变量(还没有完全迁移过来,目前基本类型还是储存在方法区,引用类型则在堆区,到了1.8后会把所有静态变量都迁移过来,后面会有实验验证,参考https://stackoverflow.com/questions/8387989/where-are-static-methods-and-static-variables-stored-in-java,以及https://openjdk.org/jeps/122,里面提到了JDK8完全迁移了静态变量区到了堆内存中)

但是:

目前来说,在高版本的JDK中,JIT,逃逸分析,栈上分配使得不是所有的对象都会存储在堆内存之中了,需要注意!

JVM大名鼎鼎的GC回收,在1.7版本的主要对象就是堆内存:永久代由于其设计的原因,难以被回收,所以,堆内存该如何去高效率的回收利用就成了一项十分关键的技术

对于1.7来说,主要是采用分代回收技术:将其分为新生代与老年代,分别采用不同的回收策略:但这并不是我们今天的主角,你只需要知道,堆内存相当于JVM的"硬盘",需要经常清理就对了

字符串常量池

用来存储字符串常量的地方,原本位于永久代,JDK1.7因为大字符串容易引起永久代溢出便被移到了堆内存之中

这个池子本质上是 JVM 运行时常量池(Runtime Constant Pool)里的一个哈希表 StringTable,你可以理解为:引用在运行时常量池中,本体在堆中

具体来说,所有"xxx"双引号之中的字符串会被加入其中,"a" + "b"这种在编译就能确定的字符串也会加入其中

对于手动拼接的:String a = new String("a") + new String("b"),可以调用intern方法将其加入,本质是将其对象整个移入字符串常量池,而不是复制一份

这样能很好的避免保存很多重复的字符串,比如

java 复制代码
    public static void main(String[] args)  {
        String a = "a";
        String b = "b";
        String c = a + b;
        c.intern();
        System.out.println(c == "ab");
    }

这里会返回true,原因在于c的引用被"移"到了字符串常量池中,String是引用类型

实际上,Integer,Boolean等类型也存在类似的复用:Integer会存储-128-127的对象复用,Boolean会复用true和false,这称为享元机制

永久代

!WARNING

永久代在JDK1.8,即JDK8被移除,取而代之的是元空间

经常有些文章大言不惭的说:永久代就是方法区

实际上,在Java虚拟机规范上,是这么描述方法区的:

!NOTE

The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the "text" segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.

Java虚拟机具有一个方法区域,该方法区域在所有Java虚拟机线程中共享。该方法区域类似于存储区域的常规语言代码,或类似于操作系统过程中的"文本"段。它存储了每个类结构,例如运行时常数池,字段和方法数据以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法。

所以,方法区就像一个interface,元空间,永久代就是它的实现

如同翻译所说:方法区存储了每个类结构,例如运行时常数池,字段和方法数据以及方法和构造函数的代码

也就是说,每个类的"骨干"就被存在了方法区之中

对于永久代,也就有了这种思想:类不容易被卸载,所以永久代就像蜂巢一样,把每一个类塞进去

这样当然会很方便去管理,但是,对于去卸载类,扩展类会极其困难

随着发展,人们发现对于方法区的GC是非常重要的,对于无用类的回收也非常重要

而且永久代拥有大小限制,不像元空间那样理论上限就是机器的内存大小

于是,在JDK1.8永久代就被移除替换成了可以拓展,便于GC的元空间了

其中的静态变量也被全部移到了堆内存之中(之前只是引用类型的静态变量被移到了堆内存之中)

举个例子,static int a = 10;属于基本类型,会直接存在永久代之中

但是,static student a = new student();student是你的一个自定义类,这里就属于引用类型:永久代之中存储的就是一个指向堆中的引用

运行时常量池

栈帧的动态链接链到的就是这里

运行时常量池就像一个字典:作为符号引用和实际地址的桥梁:

比如说,x = y x,y都是符号,指什么?

这里就要去运行时常量池查找,将其解析为实际上的在堆中的引用(解析的过程并不像这样简单,这里不涉及)

然后会把引用返回,栈帧会将其加入动态链接来替换掉原来的符号引用,便于后序加速访问

上文提到的字符串常量区中的StringTable 就存储在其中,StringTable所指向的实际字符串存储在堆中

类元数据

除开运行时常量池外,永久代还存储着最为重要的信息,类元数据:被虚拟机加载的各种类的类型信息,譬如的结构信息,包括类的名称、父类、方法、字段、接口、注解等以及JIT编译后的代码等

所有的类加载后都会将其信息存储在在其中,更形象的说明是:堆中的类的内容,而且永久代中是类的骨架

这一点可以从java对对象的访问看出:先从栈帧的局部变量表中的引用类型可以看出:reference中有一个指向堆的地址,这个地址存储有对象的实例信息和指向永久代的对象的类型信息,两者相辅相成

直接内存

用于NIO的一块内存,这块内存比较特殊,既能直接用native方法操作,又能通过java堆中的DirectByteBuffer直接访问来操作,这样就避免了来回在Java堆和本机内存中来回的复制数据

最常见的用处,就是用于支持NIO的缓冲区:Native内存能直接放入,Java堆能直接读取,极大的提高了IO性能

直接内存不受JVM限制,大小由本地机器的内存限制

2.class文件分析

这是一个简简单单的Java代码:

java 复制代码
public class test1 {
    public static void main(String[] args)  {
        String a = "a";
        String b = "b";
        User user = new User();
    }
    static class User{
        String name;
        String password;
    }
}

编译后,执行javap -v target/classes/你的类名.class

就能得到一份比较容易看懂的字节码:

java 复制代码
Classfile /E:/JavaSourceCode/jvm-test/target/classes/test1.class
  Last modified 2025-5-8; size 530 bytes    //这里是文件的基本信息,大小,上次修改
  MD5 checksum 284474226b95abd8838ad253b0d7beb0 //这个文件的M哈希码
  Compiled from "test1.java"    //源文件信息
public class test1
  minor version: 0      //次版本号
  major version: 52     //主版本号,52是JAVA 8
  flags: ACC_PUBLIC, ACC_SUPER  //类类型:这里是标识是public,ACC_SUPER无用,已废除
Constant pool:      //这个类的常量池,运行后会被搬到自己的运行时常量池(Runtime Constant Pool)
   #1 = Methodref          #7.#28         // java/lang/Object."<init>":()V
   #2 = String             #21            // a
       //拿最简单的String类型举例
       //这里#2,你可以理解为是这个量的"位置",后面的#21对应着其实际或与之关联量的位置
       
   #3 = String             #23            // b
   #4 = Class              #29            // test1$User
   #5 = Methodref          #4.#28         // test1$User."<init>":()V
   #6 = Class              #30            // test1
   #7 = Class              #31            // java/lang/Object
   #8 = Utf8               User
   #9 = Utf8               InnerClasses
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               LocalVariableTable
  #15 = Utf8               this
  #16 = Utf8               Ltest1;
  #17 = Utf8               main
  #18 = Utf8               ([Ljava/lang/String;)V
  #19 = Utf8               args
  #20 = Utf8               [Ljava/lang/String;
  #21 = Utf8               a
       //这里直接对应着上面的#2,直接是对应着字符串常量池里面的"a":运行时记载/链接时会自动将其放到字符串常量池中
  #22 = Utf8               Ljava/lang/String;
  #23 = Utf8               b
  #24 = Utf8               user
  #25 = Utf8               Ltest1$User;
  #26 = Utf8               SourceFile
  #27 = Utf8               test1.java
  #28 = NameAndType        #10:#11        // "<init>":()V
  #29 = Utf8               test1$User
  #30 = Utf8               test1
  #31 = Utf8               java/lang/Object
{
        //这里是类构造器部分,存储着要去构造类的一些必须的信息
        //你也可以理解为:类的构造函数,所以它的结构和普通方法很像
  public test1();
    descriptor: ()V //方法签名为void,无参
    flags: ACC_PUBLIC   //public范围
    Code://代码区,从上往下执行
      stack=1, locals=1, args_size=1    //这里是类的一些信息:操作数栈大小 :1,本地变量表大小:1,只用了一个slot(this),后面解释
         0: aload_0 //压入this
         1: invokespecial #1  //调用超类构造器加载(对应着常量池第一个)                // Method java/lang/Object."<init>":()V
         4: return      //返回
      LineNumberTable:
        line 9: 0   //对应着Java源代码的第九行,没有偏移量:即是这个类的开始地址
      LocalVariableTable:   //本地变量表,仅仅是用来便于你看的,不会存储
        Start  Length  Slot  Name   Signature
            0       5     0  this   Ltest1;
            //仅仅只有一个变量,就是this

  public static void main(java.lang.String[]);  //同上,main的构造类
    descriptor: ([Ljava/lang/String;)V  //返回void,具有一个参数
    flags: ACC_PUBLIC, ACC_STATIC   //具有public和static
    Code:   //代码区,从上往下执行
      stack=2, locals=4, args_size=1
         0: ldc           #2                  // String a
         2: astore_1        //从常量池中读a,然后存到slot 1中
         3: ldc           #3                  // String b
         5: astore_2
         6: new           #4                  // class test1$User
         9: dup         //这里是加载User类,通过超类加载器加载
        10: invokespecial #5                  // Method test1$User."<init>":()V
        13: astore_3    //存储到槽3
        14: return
      LineNumberTable:  //对应着Java代码的每一行
        line 11: 0
        line 12: 3
        line 13: 6
        line 14: 14
      LocalVariableTable:   //同上,待会在下面解释slot
        Start  Length  Slot  Name   Signature
            0      15     0  args   [Ljava/lang/String;
            3      12     1     a   Ljava/lang/String;
            6       9     2     b   Ljava/lang/String;
           14       1     3  user   Ltest1$User;
}
SourceFile: "test1.java"
InnerClasses:
     static #8= #4 of #6; //User=class test1$User of class test1

可以看到,字节码相对于汇编,比较好阅读

其中我们看眼大致的看到,对于一个class,会将其大致分成:常量池+方法(构造函数+其他方法)

其中常量池中,#部分完全可以理解成"地址",下面的code对应的地址都会回归到常量池

所以常量池就像电话簿一样,提供了每一个要用到的地址和信息

类加载后,常量池会被加入到运行时常量池中

这样就链起来了:

一个方法被调用->创建栈帧->执行code->加载对象->根据栈帧中的动态链接来找到自己类的运行时常量池->从运行时常量池中获取到对象在堆中的地址->读取对象进行操作->根据返回地址返回到对应的栈帧

slot意为"槽",是对于对于本地变量表的,操作数栈一个基本单位,具体来说,本地变量表里面的所有对象都是用"slot"来作为单位来存储的,比如int,string都占一个slot,long,double则占两个slot,对于其他引用类型,比如你自定义的类,只占一个slot(参照上面表中的user,只占1个slot)

slot更像是用来作为一个"索引"来访问,每个对象都对应着一个自己的slot(占用多个slot的对象会以第一个作为自己的索引),可以通过这个索引直接在局部变量表中找到对应的对象,比如astore_1,就是存储到编号为1的slot之中

class文件的大致描述就这样

3.JDK1.8变化

JDK1.8相对于JDK最大的区别就是,取消了永久代,用元空间代替,把静态变量完全移到了堆区:现在方法区只会存储其引用了,就算是基本类型也是如此

这里如何证明呢?我会新开一篇文章来介绍这个实验


元空间

经过上面的介绍,你大概知道了永久代的缺点了:固定,难回收,难拓展

于是,在JDK1.8,直接删掉了永久代,改用了可以扩展了,便于回收的元空间

元空间可以随意拓展,理论上限制其的只有本地机器对内存的限制:比如win32位限制一个进程最多拥有4GB内存

在存储的内容上,并没有太多的变化,完全剔除了静态变量,主要存储类元数据

这样让GC回收元空间成为可能,虽然想要卸载类还是很困难,但无论如何有了方法

除此之外,JDK1.8在运行时数据区域并未其他区别


4.总结

纸上得来终觉浅,绝知此事要躬行

一定要自己看一些底层的书自己扣扣字眼

并不要完全相信博客之类的内容,尤其是比较偏冷门偏难的内容,最好配合AI自己设计实验去验证!

就比如针对1.8基本类型的静态变量存储在哪,正确答案是堆------但很多文章说是在元空间的运行时常量区中,实际上常量区中存储的只是引用

如果有没说详细或者说错的地方,欢迎指正与讨论!后续我会把我的实验写成文章发布!

相关推荐
eternal__day1 分钟前
Spring Boot 实现验证码生成与校验:从零开始构建安全登录系统
java·spring boot·后端·安全·java-ee·学习方法
陈大爷(有低保)1 小时前
swagger3融入springboot
java
weixin_376934633 小时前
JDK Version Manager (JVMS)
java·开发语言
月月大王3 小时前
easyexcel导出动态写入标题和数据
java·服务器·前端
大G哥5 小时前
Kotlin Lambda语法错误修复
android·java·开发语言·kotlin
行走__Wz5 小时前
计算机学习路线与编程语言选择(信息差)
java·开发语言·javascript·学习·编程语言选择·计算机学习路线
Micro麦可乐6 小时前
最新Spring Security实战教程(十四)OAuth2.0精讲 - 四种授权模式与资源服务器搭建
java·服务器·spring boot·spring·spring security·oauth2·oauth2授权
进击的小白菜6 小时前
如何高效实现「LeetCode25. K 个一组翻转链表」?Java 详细解决方案
java·数据结构·leetcode·链表
悟能不能悟7 小时前
java实现一个操作日志模块功能,怎么设计
java·开发语言
caihuayuan57 小时前
[数据库之十四] 数据库索引之位图索引
java·大数据·spring boot·后端·课程设计