一、JVM内存整体结构(运行时数据区):

前言:JVM的组成 | 东的博客 (dongtech.eu.org)

当我们通过前面的:类的 加载 –> 验证 –> 准备 –> 解析 –> 初始化,这几个阶段完成后,就会用到执行引擎对我们的类进行使用,同时执行引擎将会使用到我们运行时数据区

image-20230506094411247.png

运行时数据区的整体结构如下:

  • 总结一下:方法中使用的原生数据类型和对象引用地址在栈上存储;对象、对象成员与类定义、静态变量在堆上。

  • 堆内存又称为“共享堆”,堆中的所有对象,可以被所有线程访问,只要他们能拿到对象的引用地址。

  • 如果一个线程可以访问某个对象时,也就可以访问该对象的成员变量。

  • 如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量,但每个线程的局部变量副本是独立的。

二、堆

堆是JVM中用于存储对象实例和数组的主要内存区域。它是由所有线程共享的运行时内存区域。当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

  • 对象实例:所有通过new关键字创建的对象实例都在堆上分配内存。这包括Java类的实例和数组。

  • 垃圾回收域:堆是垃圾回收器管理的主要区域,不再被引用的对象将在此被回收。

  • 运行时常量池自Java 7起,字符串常量池被移至堆中

  • 年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到老年代区间。

  • 老年代主要保存生命周期长的对象,一般是一些老的对象

  • 元空间保存的类信息、静态变量、常量、编译后的代码

为了避免方法区出现OOM,所以在java8中将堆上的方法区【永久代】给移动到了本地内存上,重新开辟了一块空间,叫做元空间。那么现在就可以避免掉OOM的出现了。

元空间(MetaSpace)≈ 方法区

在 HotSpot JVM 中,永久代( ≈ 方法区)中用于存放类和方法的元数据以及常量池,比如Class 和 Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。

永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即OutOfMemoryError,为此不得不对虚拟机做调优。

那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?

官网给出了解释:http://openjdk.java.net/jeps/122

1)由于 PermGen 内存经常会溢出,引发OutOfMemoryError,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM。

2)移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。

准确来说,Perm 区中的字符串常量池被移到了堆内存中是在 Java7 之后,Java 8 时,PermGen 被元空间代替,其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区。比如 java/lang/Object 类元信息、静态属性 System.out、整型常量等。

元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

三、栈

虚拟机栈是线程私有的,每个线程创建时都会创建自己的虚拟机栈。这个区域主要存储了栈帧(Stack Frame),每个栈帧包含了方法的运行状态。

  • 局部变量表:存储方法的局部变量(包括基本数据类型、对象引用)。

  • 操作数栈:作为方法执行时的操作数和返回值的临时存放地。

  • 动态链接信息:每个栈帧都包含指向运行时常量池中该栈帧所属方法的引用,支持方法调用过程中的动态链接。

  • 方法返回信息:存储方法完成时的返回地址或者异常捕捉表。

Java Virtual machine Stacks (java 虚拟机栈)

  • 每个线程运行时所需要的内存,称为虚拟机栈,先进后出

  • 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存

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

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

  1. 垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放

2.栈内存分配越大越好吗?

  1. 未必,默认的栈内存通常为1024k

    栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半

3.方法内的局部变量是否线程安全?

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

  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

  • 比如以下代码:

4.栈内存溢出情况

  • 栈帧过多导致栈内存溢出,典型问题:递归调用

  • 栈帧过大导致栈内存溢出

四、方法区(元空间)

官方文档https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4

《Java虚拟机规范》中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。

1. 概述

  • 方法区(Method Area)是各个线程共享的内存区域

  • 主要存储类的元数据运行时常量池静态变量即时编译器编译后的代码

  • 虚拟机启动的时候创建,关闭虚拟机时释放

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,方法区域中的内存无法满足分配请求,则会抛出java.lang.OutofMemoryError:PermGen space或者java.lang.OutOfMemoryError:Metaspace

2. 方法区存储什么?

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

  • 类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名=包名.类名)

  2. 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)

  3. 这个类型的修饰符(public,abstract,final的某个子集)

  4. 这个类型直接接口的一个有序列表

  • 域(Field)信息

也就是我们常说的成员变量,域信息是比较官方的称呼

  1. JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

  2. 域的相关信息包括:域名称,域类型,域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

  • 方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  1. 方法名称

  2. 方法的返回类型(包括 void 返回类型),void 在 Java 中对应的为 void.class

  3. 方法参数的数量和类型(按顺序)

  4. 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)

  5. 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

  6. 异常表(abstract和native方法除外),异常表记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

3. 常量池

可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

查看字节码结构(类的基本信息、常量池、方法定义)javap -v xx.class

比如下面是一个Application类的main方法执行,源码如下:

 public class Application {
     public static void main(String[] args) {
         System.out.println("hello world");
     }
 }

找到类对应的class文件存放目录,执行命令:javap -v Application.class 查看字节码结构

 D:\code\jvm-demo\target\classes\com\heima\jvm>javap -v Application.class
 Classfile /D:/code/jvm-demo/target/classes/com/heima/jvm/Application.class
   Last modified 2023-05-07; size 564 bytes    //最后修改的时间
   MD5 checksum c1b64ed6491b9a16c2baab5061c64f88   //签名
   Compiled from "Application.java"   //从哪个源码编译
 public class com.heima.jvm.Application   //包名,类名
   minor version: 0
   major version: 52     //jdk版本
   flags: ACC_PUBLIC, ACC_SUPER  //修饰符
 Constant pool:   //常量池
    #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
    #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
    #3 = String             #23            // hello world
    #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
    #5 = Class              #26            // com/heima/jvm/Application
    #6 = Class              #27            // java/lang/Object
   ...
   #18 = Utf8               SourceFile
   #19 = Utf8               Application.java
   #20 = NameAndType        #7:#8          // "<init>":()V
   #21 = Class              #28            // java/lang/System
   #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
   #23 = Utf8               hello world
   #24 = Class              #31            // java/io/PrintStream
   #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
   #26 = Utf8               com/heima/jvm/Application
   #27 = Utf8               java/lang/Object
   #28 = Utf8               java/lang/System
   #29 = Utf8               out
   #30 = Utf8               Ljava/io/PrintStream;
   #31 = Utf8               java/io/PrintStream
   #32 = Utf8               println
   #33 = Utf8               (Ljava/lang/String;)V
 {
   public com.heima.jvm.Application();  //构造方法
     descriptor: ()V
     flags: ACC_PUBLIC
     Code:
       stack=1, locals=1, args_size=1
          0: aload_0
          1: invokespecial #1                  // Method java/lang/Object."<init>":()V
          4: return
       LineNumberTable:
         line 3: 0
       LocalVariableTable:
         Start  Length  Slot  Name   Signature
             0       5     0  this   Lcom/heima/jvm/Application;
 ​
   public static void main(java.lang.String[]);  //main方法
     descriptor: ([Ljava/lang/String;)V
     flags: ACC_PUBLIC, ACC_STATIC
     Code:
       stack=2, locals=1, args_size=1
          0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
          3: ldc           #3                  // String hello world
          5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          8: return
       LineNumberTable:
         line 7: 0
         line 8: 8
       LocalVariableTable:
         Start  Length  Slot  Name   Signature
             0       9     0  args   [Ljava/lang/String;
 }
 SourceFile: "Application.java"

下图,左侧是main方法的指令信息,右侧constant pool 是常量池

main方法按照指令执行的时候,需要到常量池中查表翻译找到具体的类和方法地址去执行

4. 运行时常量池

常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

5. 字符串常量池

在Java虚拟机(JVM)中,字符串常量池的位置随着Java版本的变化而变化。

  1. 在Java 7之前:字符串常量池位于永久代(PermGen,Permanent Generation)。永久代是JVM堆的一部分,用于存储类的元数据以及字符串常量池。

  2. 从Java 7开始:字符串常量池被移动到了堆(Heap)内存。在Java 7中,字符串常量池的移动是JVM性能优化的一部分,这样做的主要目的是避免永久代空间不足的问题,并更灵活地管理字符串常量。

  3. 在Java 8及以后版本:永久代被完全移除,取而代之的是元空间(Metaspace)。元空间并不位于虚拟机内存堆中,而是使用本地内存。类的元数据被移至元空间,但字符串常量池仍然保留在堆内存中。

五、直接内存

  • 不受 JVM 内存回收管理,而是在Java堆外分配的内存。它通常位于操作系统的本地内存中。

  • 常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理

  • 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存‘

  • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。

举例:

需求,在本地电脑中的一个较大的文件(超过100m)从一个磁盘挪到另外一个磁盘

代码如下:

 /**
  * 演示 ByteBuffer 作用
  */
 public class Demo1_9 {
     static final String FROM = "E:\\编程资料\\第三方教学视频\\youtube\\Getting Started with Spring Boot-sbPSjI4tt10.mp4";
     static final String TO = "E:\\a.mp4";
     static final int _1Mb = 1024 * 1024;
 ​
     public static void main(String[] args) {
         io(); // io 用时:1535.586957 1766.963399 1359.240226
         directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
     }
 ​
     private static void directBuffer() {
         long start = System.nanoTime();
         try (FileChannel from = new FileInputStream(FROM).getChannel();
              FileChannel to = new FileOutputStream(TO).getChannel();
         ) {
             ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
             while (true) {
                 int len = from.read(bb);
                 if (len == -1) {
                     break;
                 }
                 bb.flip();
                 to.write(bb);
                 bb.clear();
             }
         } catch (IOException e) {
             e.printStackTrace();
         }
         long end = System.nanoTime();
         System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
     }
 ​
     private static void io() {
         long start = System.nanoTime();
         try (FileInputStream from = new FileInputStream(FROM);
              FileOutputStream to = new FileOutputStream(TO);
         ) {
             byte[] buf = new byte[_1Mb];
             while (true) {
                 int len = from.read(buf);
                 if (len == -1) {
                     break;
                 }
                 to.write(buf, 0, len);
             }
         } catch (IOException e) {
             e.printStackTrace();
         }
         long end = System.nanoTime();
         System.out.println("io 用时:" + (end - start) / 1000_000.0);
     }
 }

可以发现,使用传统的IO的时间要比NIO操作的时间长了很多了,也就说NIO的读性能更好。

这个是跟我们的JVM的直接内存是有一定关系,如下图,是传统阻塞IO的数据传输流程

下图是NIO传输数据的流程,在这个里面主要使用到了一个直接内存,不需要在堆中开辟空间进行数据的拷贝,jvm可以直接操作直接内存,从而使数据读写传输更快。

六、运行时数据区总结