JVM 笔记: 内存结构

103 阅读14分钟

内存结构

image-20230507225411346

1. 程序计数器

1.1 作用

  • 记住下一条JVM指令的执行地址

    image-20230507231547606

    源代码编译成JVM代码,解释器获取到JVM代码发送给CPU执行。而程序计数器则是==保存下一条执行JVM命令的地址==

  • 特点

    • ==线程私有==。在多线程情况下,记录每个线程自己的执行进度,互不干扰。
    • 不会存在内存溢出。

2. 虚拟机栈

2.1 定义

  • ==线程私有==

  • 虚拟机栈就是线程的运行空间,栈的长度就代表着运行需要的内存大小。

  • 每个栈由多个栈帧组成,每个栈帧都对应着一个方法。栈帧代表着对应方法运行时所占用的内存大小。

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

  • 每个方法被调用,都会同步创建一个栈帧进行压栈。这个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕,都对应这一个栈帧的入栈和出战。

  • 局部变量表中包含了编译器期间可知的Java虚拟机的基本数据类型、对象引用(reference)和returnAddress。通常我们说的栈就是指虚拟机栈,更多情况下指的是虚拟机栈中局部变量表的部分。

    虚拟机栈

代码举例:

public class VmStack {
    public static void main(String[] args) {
        new VmStack().function01();
    }

    public void function01() {
        this.function02();
        System.out.println("function01 is using");
    }

    public void function02() {
        this.function03();
        System.out.println("function02 is using");
    }

    public void function03() {
        System.out.println("function03 is using");
    }
}

如图所示

  1. 我们可以看到这个方法有一个虚拟机栈和四个栈帧

    image-20230507234716540
  2. 当mian方法启动的时候,main方法被调用,所以压栈

    方法内的局部变量表所需要的内存空间在编译期间就已经完成分配了,所以当方法内局部变量表需要的空间已经完全确定,也就是说栈帧中保存局部变量内存空间的局部变量表大小已经完全确定,在方法运行期间不会修改。

    局部变量表保存了对应方法需要哪几种类型的、每种有多少个的局部变量。

    简单点,就是这个方法有多少个局部变量已经写死,不能修改。但是局部变量可能需要的空间会修改,比如你不断的向一个集合中添加数据直到挤满。

    image-20230507234851132
  3. 这时候main方法调用了function01方法,所以function01被压栈

  4. 以此类推,方法直接的调用到最后全部方法压栈

    image-20230507235254706

    此时执行VmStock这个方法的虚拟机栈所需内存大小就是这四个栈帧。

    此时的活动栈帧就是function03,也就是栈顶。==每个栈只有一个栈顶,也就只有一个活动栈帧==。

  5. 当function03执行完毕之后,就会弹栈,此时的虚拟机栈大小就由四变三了

    ==每一个方法被调用直至执行完毕,都对应这一个栈帧在虚拟机栈中入栈到出栈的过程。==

    image-20230507235724181
  6. 伴随着逐渐的弹出,栈帧逐渐减少,直至清空

2.2 问题解析

  1. 垃圾回收是否涉及栈内存?

    • Java的垃圾回收主要负责回收堆内存中的无用对象,栈内存中的对象则有JVM自行管理,不受垃圾回收影响。如果在递归方法中不断地回调没有设置出口,就会出现栈溢出异常StackOverflowError
  2. 占内存分配越大越好吗?

    • 物理内存的大小是固定的,每个栈内存越大,线程数越少。
  3. 方法内的局部变量是否线程安全?

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

      public class Main {
          public void method01(){
              String str = "此字符串没有逃逸";
          }
      } 
      
    • 如果是局部变量引用了对应或者局部变量逃离方法的作用范围,则需要考虑线程安全

      public class Main {
          private List<Integer> list = new ArrayList<>();
      
          public void addToList(int n) {
              list.add(n);
          }
      
          public List<Integer> getList() {
              return list;
          }
      
          public static void main(String[] args) {
              Main obj = new Main();
              obj.addToList(1);
              obj.addToList(2);
              List<Integer> list = obj.getList();
              obj = null; // 对象的引用被设为 null,但对象本身仍然存在
              new Thread(() -> {
                  // 在另一个线程中访问 list
                  for (int i : list) {
                      System.out.println(i);
                  }
              }).start();
          }
      }
      
    • 什么是方法逃离作用范围

      当方法中的局部变量引用了一个对象,如果该对象在方法结束后仍然被其他线程所引用,就会出现线程安全问题。

2.3 栈内存溢出

  • 什么是栈内存溢出?

    • 当线程请求的栈深度大于虚拟机允许的深度,就会抛出StackOverflowError异常。

      可以通过递归、死循环等方式可以实现,就是不断的向虚拟机栈中压栈指导栈空间不足。

    • 如果Java虚拟机栈可以动态扩容,当栈扩容时无法获取足够的内存就会抛出OutOfMemoryError异常

      在HotSpot虚拟机中栈容量是无法动态扩容的,所以不会因为动态扩容导致OutOfMemoryError异常,只要线程申请栈空间成功则不会出现OOM。但是如果申请失败了就会出现OOM异常。

3. 本地方法栈

3.1 定义

  • ==线程私有==
  • 本地方法栈和虚拟机栈的作用类似,区别在于虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈为虚拟机执行本地方法服务。

3.2 什么是本地方法

  • 不是有Java编写的代码,一般来说就是由C和C++编写的底层方法。
  • 比如Object类中的clone()、notify()、notifyAll()、wait()这些方法都是本地方法。在这些方法前都会有navite关键字修饰

4. 堆

4.1 定义

  • ==线程公有==
  • 有垃圾回收机制
  • Java堆是垃圾回收器管理的区域
  • 此内存区域的唯一目的就是存放对象实例。

4.2 堆内存分配

  • 从内存分配的角度上看,堆中可以划分出多个线程私有的分配缓冲区(TLAB),以提升对象分配时的效率。
  • 如果Java堆中没有内存完成实例的分配,并且堆也无法再扩展时,Java虚拟机就会抛出OutOfMemoryError异常。
  • 一般来说Java规范对堆的约束时,物理上存储可以不连续,但是逻辑上需要连续。如果在面对像数组这类的大对象的时候,为了方便保存也会要求保存该对象时候物理存储上连续。

5. 方法区

5.1 定义

  • ==线程公有==
  • 存储和类相关的信息,用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译后的代码缓存等数据。
  • 方法区在虚拟机启动时被创建

5.2 永久代和元空间

  • 方法区在不同的时期有着不同的表现,接下来我们根据jdk1.7和jdk1.8分开讨论
    • jdk1.7之前,当时的设计团队选择将Java堆一样设计模式实现方法区,此时就叫做永久代。永久代受JVM管理,固定大小不会自动扩展,GC受JVM调配有的时候清除缓慢。它的大小是固定的,不会自动扩展,因此在某些情况下可能会导致内存溢出的问题。
    • jdk1.8之后,设计团队使用了元空间的方式来实现方法区。元空间不再使用固定大小内存,而是使用本地内存,因此它可以动态分配和释放内存。元空间中存储了类的元数据,和永久代相比,元空间具备更好的内存管理和回收机制。在jdk1.8之后的字符串常量池被放在了Java堆的新生代中,而静态变量则放在了老年代当中。

5.3 常量池和运行时常量池

无论是在永久代或者元空间的时期,常量池和运行时常量池都是方法区的一部分

  • 什么是常量池

    • 每个类的Class文件中包含了一项信息叫做常量池表。该表在编译阶段生成,包含了各种字面量常量(如字符串、数字等)、符号引用(如类和接口的全限定名、字段和方法的名称和描述符)以及一些特定的符号信息。
  • 常量池的作用

    • 用于存储常量数据和符号的引用,供编译器和解释器使用。

    • 节省存储空间和提高执行效率,通过引用指向对应的常量数据。在避免出现重复存储相同的常量,只需要在常量池中保存一份,其他所有都是通过引用指向这一份数据。既节省空间又方便维护。

      编译器和解释器是将高级语言代码转化为机器代码或解释执行的工具,它们在软件开发和执行过程中起到了关键的作用。

  • 什么是运行时常量池

    • 和常量池类似,存储类加载后运行的常量数据和符号的引用,供JVM快速访问这些常量的能力。
    • 当类被加载时,它的Class文件各种的常量池信息就会放到运行时常量池当中,并把里面的符号地址变为真实地址。
    • 提供快速访问常量的能力,JVM可以通过索引快速定位到具体的常量值。
    • 为动态链接、动态调用、方法调用等提供支持,存储了类、方法、字段的符号引用。
  • 为什么运行时常量池需要将常量池的符号地址变为真实地址?

    • 因为常量池中的符号地址是相对于类文件的偏移量,并不是真正的内存地址。当类被加载时,类文件中的常量池信息会被解析并存储到运行时常量池中。

      在类加载过程中,虚拟机会将类文件中的符号地址解析成真实的内存地址。这个过程称为:符号解析。通过符号解析,虚拟机就能够找到常量池中所引用的类、方法、字段等数据在内存中的实际位置,从而能够进行相关操作。

      所以说,常量池中的符号地址是相对的,需要经过符号解析转换为真实的内存地址才能进行实际的访问和操作。运行时常量池提供了这种解析和转换的机制,使程序能够正确的访问常量池中的数据。

  • 运行时常量池的特点

    • 运行时常量池和Class常量池不同,它具有动态性。不要求常量在编译期产生,可以在运行时将新的常量放入常量池当中。
    • 运行时常量池受到JVM的影响,如果无法申请空间也会出现OOM。
    • 每个类都有自己的运行时常量池,和其他类的运行时常量池相互独立。
  • 它们的关系

    • 关系:编译阶段生成的常量池引用在类加载的时候被加载到运行时常量池中。
      • 运行时常量池对常量池的符号引用解析(符号引用解析):在类加载过程中,解析符号引用是一个关键的步骤。符号引用包含了类、字段、方法的名称和描述符等信息,通过常量池中的符号引用可以定位到具体的类、字段或方法,并进行相应的解析和链接操作。
      • 运行时常量池对常量池的类和接口的加载:常量池中存储了类和接口的全限定名信息,类加载器根据这些信息定位并加载对应的类和接口。
      • 运行时常量池对常量池的字段和方法的解析:常量池中包含了字段和方法的名称、描述符等信息,通过这些信息可以在类加载过程中解析和校验字段和方法的访问权限、参数类型、返回类型等。
      • 运行时常量池对常量池的字面量常量的初始化:常量池中存储了各种字面量常量的值,如字符串、整数、浮点数等,这些常量的初始化过程涉及到对常量池的访问和读取。
  • 异同

    • 常量池:
      • 静态性质:编译时常量池是在编译阶段确定的,它存储了编译期间的字面量、符号引用等信息。这些信息在编译时是静态确定的,不可修改。
      • 独立:每个类都有自己的常量池。每个Class文件都包含一个常量池表(Constant Pool Table),用于存储该类的常量信息
      • 不可修改:编译时常量池的静态性质,编译时确定的常量值通常是不可修改的。
    • 运行时常量池:
      • 动态性质:运行时常量池是在类加载时被加载到内存中的,它与具体的运行环境相关。运行时常量池的目的是为了在运行时能够提供快速访问常量的机制,并支持动态性质,例如动态链接和动态调用。
      • 独立与共享:每个类在内存中都有一个运行时常量池,这样可以使得每个类的常量池相互独立,实现了类之间的隔离。同时,运行时常量池也可以被多个实例共享,减少了内存占用
      • 运行时修改:运行时常量池中的常量值是可以被修改的,但是这种修改是有限制的。这种修改只能在特定的情况下进行,例如通过反射技术动态修改常量值。
  • 总结

    • 常量池是在编译阶段生成的一张表,用于存储字面量常量和符号引用。它的作用是节省存储空间、提高执行效率,并为编译器和解释器提供常量数据和符号引用的快速访问机制。运行时常量池则是在类加载到内存时,为JVM提供快速访问常量的机制。
    • 常量池和运行时常量池都是引用,只不过一个代表了编译期间的引用,一个代表了运行期间的引用。
    • Java虚拟机对Class文件的每一部分都有要求,但是对于运行时常量池没有任何要求,任何供应商实现的虚拟机都可以按照自己的要求实现对应的运行时常量池。
    • 编译期间的常量池存储了我们在代码中定义的常量信息,而类加载时将这些常量加载到运行时常量池中,方便我们在运行时使用这些常量。
    • 常量池和运行时常量池就像是店里的摩托车和买到手的摩托车。店内的摩托车你改不了,到手之后可以根据原厂基础随意改装。

5.4 字符串常量池

在jdk1.6以前,字符串常量池是被放在方法区中,受方法区影响的。在jdk1.8后,字符串常量池被放在了堆中管理。接下来的例子都是使用jdk1.8的版本。

  • 直接赋值和new String的区别

    public abstract class Main {
        public static void main(String[] args) {
            String s1 = "a";
            String s2 = new String("a");
            System.out.println(str1 == str2);
        }
    }
    

    我们可以通过图来理解这段代码:

    字符串常量池

    可以看到s1直接赋值的话就指向了字符串常量池中的地址,而s2通过new String()的方式赋值,就会在堆中创建一个对象,通过对象间接引用对应的字符串。

  • 对象拼接和字符串拼接的区别

    public abstract class Main {
        public static void main(String[] args) {
            String s1 = "a";
            String s2 = "b";
            String s3 = "ab";
            String s4 = s1 + s2;
            String s5 = new String("ab");
            String s6 = "a" + new String("b");
    
            // "ab" == s1 + s2
            System.out.println(s3 == s4);
            // "ab" ==  new String("ab");
            System.out.println(s3 == s5);
            //  s1 + s2 == new String("ab")
            System.out.println(s4 == s5);
            // "ab" == "a" + new String("b")
            System.out.println(s3 == s6);
            // s1 + s2 == "a" + new String("b")
            System.out.println(s4 == s6);
            // new String("ab") == "a" + new String("b")
            System.out.println(s5 == s6);
        }
    }
    

    最后的结果是都为false。

    这里我就不画图了,总结一下规律:字符串直接拼接的就直接引用字符串常量池的地址,只要有一个对象就引用堆的对象地址。

  • 总结

    • 常量池中的字符串仅是符号,第一次用时才变成对象
    • 通过字符串常量池避免重复创建字符串对象
    • 字符串变量的拼接原理是StringBuilder
    • 字符串常量拼接的原理在编译器优化
    • 可以使用intern方法,主动将字符串常量池中还没有的字符串放入串池中。