《Java 虚拟机》 基本概念与内存结构

《Java 虚拟机》 专栏索引 👉基本概念与内存结构 👉垃圾回收 👉类文件结构与字节码技术 👉类加载阶段 👉运行期优化 👉 happens-before 与锁优化

@[TOC](《Java 虚拟机》 基本概念与内存结构)

1. JVM 基本概念

定义 :Java Virtual Machine,Java 程序的运行环境(Java 二进制字节码的运行环境)。 优点:

  • 一次编写,到处运行
  • 自动管理内存,具有垃圾回收的功能
  • 数组下标越界检查
  • 多态

JVM、JRE、JDK、JavaSE 和 JavaEE 之间比较:

2. JVM 内存结构

整体架构

2.1 程序计数器

定义: 程序计数器(Program Counter Register)是一块较小的内存,可以看作是当前线程所执行的字节码的行号指示器。 作用:保存 JVM 中下一条要执行字节码的指令的地址,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。

特点:

  • 线程私有的,不会被其他线程共享
  • 唯一一个不会存在内存溢出的区域

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值为空(Undefined)。《深入理解 Java 虚拟机》

解释:每个 Java 线程都会直接映射到一个操作系统线程上执行,而 Native 方法是由原生平台直接执行,不需要理会 JVM 平台层面的程序计数器。

2.2 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks)属于线程私有区域。 定义

  • 描述的是 Java 方法执行的内存模型,每个方法对应一个栈帧。
  • 每个线程运行时所需要的内存,称为虚拟机栈,生命周期与线程相同。
  • 每个栈由多个栈帧(Frame)组成,每个栈帧对应着每个方法执行时所占用的内存空间,用于存储 局部变量表(存放各种基本类型以及引用类型的变量)、操作数栈 、动态链接和方法出口等信息。每个方法从调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 每个线程只能有一个活动栈帧,对应着当前线程正在执行的那个方法。
java 复制代码
/**
* 演示栈帧
*/
public class Demo1_1 {
    public static void main(String[] args) throws InterruptedException {
        method1();
    }
    private static void method1() {
        method2(1, 2);
    }
    private static int method2(int a, int b) {
        int c = a + b;
        return c;
    }
}

从上图中可以看到,每个栈帧在栈中的位置符合栈先进先出的特性。

问题辨析:

  • 垃圾回收是否涉及栈内存? :垃圾回收不涉及栈内存。栈内存就是一次次的方法调用所产生的栈帧内存,而栈帧内存在每一次方法调用结束后都会被弹出栈,也就是自动地被回收掉,所以根本不需要垃圾回收管理栈内存。
  • 栈内存的分配越大越好嘛? :不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
  • 方法内的局部变量是否线程安全? 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的。 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全。
java 复制代码
/**
 - 局部变量的线程安全问题
 */
public class Demo1_17 {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append(4);
        sb.append(5);
        sb.append(6);
        new Thread(()->{
            m2(sb);
        }).start();
    }
    // 线程安全
    public static void m1() {
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }
    // 线程不安全,参数是从外部传递进来,说明该对象也能作为其他方法的参数,进行操作
    public static void m2(StringBuilder sb) {
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }
    // 线程不安全,有返回值,其他地方可能会调用返回的对象
    public static StringBuilder m3() {
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        return sb;
    }
}

这个区域规定了两种异常情况:

  • 线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverFlowError 异常。也就是栈帧过多导致栈内存溢出,例如无限递归。
java 复制代码
/**
- 演示栈内存溢出
- -Xss 参数设置栈内存容量
- 设置: -Xss256k
- 默认为1024k
*/
public class Demo1_2 {
   private static int count;
   public static void main(String[] args) {
       try {
           method();
       } catch (Throwable e) {
           e.printStackTrace();
           System.out.println(count);
       }
   }
   public static void method() {
       count++;
       method();
   }
}

经过 3301 次递归调用后,栈内存溢出。

  • 虚拟机栈在进行动态扩展时,如果无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

线程运行诊断: CPU 占用过高:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程:

  • top 命令,查看是哪个进程占用 CPU 过高。
  • ps H -eo pid, tid(线程id), %cpu | grep 刚才通过top查到的进程号 通过ps命令进一步查看是哪个线程占用 CPU 过高。
  • jstack 进程 id 通过查看进程中的线程的 tid,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。

2.2.1 局部变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量 ,这些数据类型包括八种基本数据类型、对象引用和 returnAddress 地址(指向了一条字节码指令的地址)
  • 是线程的私有数据,不存在数据安全问题
  • 局部变量表所需的内存空间在编译期间完成分配 ,并保存在方法的 Code 属性的 maximum local varibales 数据项中,在方法运行期间是不会改变局部变量表的大小的
  • 局部变量表中最基本的单位是 Slot(变量槽),32 位以内的类型只占用一个 Slot(包括 returnAddress 类型),64 位的类型(long 和 double) 占用两个 Slot

变量的分类:

  • 成员变量 :在使用前都经历过默认初始化赋值:(1)类变量 : 连接的准备阶段:给类变量默认赋值 --->初始化阶段:给类变量显式赋值即静态代码块赋值(2)实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
  • 局部变量:在使用前必须要进行显式赋值!否则,编译不通过

注意:

  • 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表,在方法执行时,虚拟机使用局部变量表完成方法的传递
  • 局部变量表中的变量也是重要的垃圾回收器根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

2.2.2 操作数栈

  • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈
  • 操作数栈并非采用访问索引的方式来进行数据访问,而是只能通过标准的入栈和出栈来完成一次数据访问

2.2.3 动态链接

2.3 本地方法栈

定义:本地方法栈和虚拟机栈非常类似,最大的区别就是虚拟机栈是为虚拟机执行 Java 方法(即字节码)服务,而本地方法栈是为虚拟机用到的 Native 方法服务。《深入理解 Java 虚拟机》

一些带有 native 关键字的方法就是需要 Java 去调用本地的 C 或者 C++ 方法,因为 Java 有时候无法直接和操作系统底层交互,所以需要用到本地方法。

两种异常情况:

  • StackOverflowError
  • OutOfMemoryError

异常测试参考虚拟机栈。

2.4 Java 堆

定义 :Java 堆是 Java 虚拟机所管理的内存中最大的一块区域,在虚拟机启动时创建,几乎所有的对象实例都在 Java 堆分配内存,是所有线程共享的一块区域。此外,Java 堆也是垃圾收集器管理的主要区域,通常也被称为 "GC 堆(Garbage Collected Heap)"

特点

  • 存放对象实例,由所有线程共享,堆内存中的对象都需要考虑线程安全问题。
  • 有垃圾回收机制。

Java 堆内存溢出 :java.lang.OutofMemoryError :java heap space. Java 堆内存溢出

java 复制代码
/**
- 演示堆内存溢出
- -Xms 参数表示堆的最小值    -Xmx 参数表示堆的最大值;设置成一样可以避免自动扩展
- VM Args:-Xms20m -Xmx20m
*/
public class Demo1_5 {
	public static void main(String[] args) {
    int i = 0;
    try {
        List<String> list = new ArrayList<>();
        String a = "hello";
        while (true) {
            list.add(a);
            a = a + a;
            i++;
        }
    } catch (Throwable e) {
        e.printStackTrace();
        System.out.println(i);
    }
  }
}

堆内存诊断

  1. jps 工具
    • 在终端输入 jps 命令
    • 查看当前系统中有哪些 java 进程
  2. jmap 工具
    • 查看堆内存占用情况 jmap -heap 进程 id
  3. jconsole 工具
    • 在终端输入 jconsole 命令
    • 图形界面的,多功能的监测工具,可以连续监测
  4. jvisualvm 工具
    • 在终端输入命令 jvisualvm

2.5 方法区

定义: 方法区(Method Area)与 Java 堆一样,被 Java 虚拟机中所有线程共享。它类似于存储区域,用来存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

内存溢出: 1、jdk 1.8 以前会导致永久代溢出

java 复制代码
* 演示永久代内存溢出  java.lang.OutMemoryError: PerGen space
* -XX:MaxPermSize = 8m

2、jdk 1.8 以后会导致元空间溢出

java 复制代码
/**
 * 演示元空间内存溢出
 * -XX:MaxMetaspaceSize=8m
 */
public class Demo1_8 extends ClassLoader{  // 可以加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_8 test= new Demo1_8();
            for (int i = 0; i < 10000; i++, j++) {
                // ClassWriter 作用是生成类的二进制字节码
                ClassWriter cw = new ClassWriter(0);
                // 版本号, public, 类名, 包名, 父类, 接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回 byte[]
                byte[] code = cw.toByteArray();
                // 执行了类的加载
                test.defineClass("Class" + i, code, 0, code.length);
            }
        } finally {
            System.out.println(j);
        }
    }
}

实际发生的场景:

  • spring
  • mybatis

运行时常量池

在介绍运行时常量池之前,先来了解一下常量池。

常量池(Constant Pool Table)

二进制字节码组成

  • 类基本信息
  • 常量池信息
  • 类方法定义
  • 虚拟机指令

在终端命令行中输入二进制字节码查看的命令:

Bash 复制代码
javap -v *.class 
java 复制代码
// 二进制字节码(类基本信息、常量池、类方法定义、包含了虚拟机指令)
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

类的基本信息

Bash 复制代码
Classfile /D:/java学习/深入理解java虚拟机/out/production/深入理解java虚拟机/com/xxx/t1/HelloWorld.class
  Last modified 2022-3-29; size 555 bytes
  MD5 checksum 66459c9d86936e88b74e59657176cfcc
  Compiled from "HelloWorld.java"
public class com.czh.t1.HelloWorld
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER

常量池信息:

Bash 复制代码
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/xxx/t1/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/xxx/t1/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.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/xxx/t1/HelloWorld
  #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

运行时常量池

  • 常量池是方法区的一部分,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量和符号引用等信息
  • 运行时常量池也是方法区的一部分,常量池是 *.class 文件中的,当该类被加载后,它的常量池信息就会被放入方法区的运行时常量池中。

注意运行时常量池 相对于 Class 文件常量池的一个重要特征是具备动态性 ,Java 语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,例如可以使用 String 类的 intern() 方法

String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象 ;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。《深入理解 Java 虚拟机》

异常情况:

  • 当常量池无法再申请内存时会抛出 OutOfMemoryError 异常。

串池 StringTable 特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder(1.8),属于编译器优化
  • 可以使用 intern() 方法,主动将串池中还没有的字符串对象放入串池:
    • 1.8 将这个字符串对象尝试放入串池,如果有则不会放入,放入失败;如果没有则会放入串池,放入成功,会把串池中的对象返回。
    • 1.6 将这个字符串对象尝试放入串池,如果有则不会放入,放入失败;如果没有把此对象复制一份,放入串池,放入成功,会把串池中的对象返回。
    • 注意:在 1.8 中,如果调用 intern() 方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象。而在 1.6 中,无论调用 intern() 方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象。
    • 此外,无论放入是否成功,都会返回串池中的字符串对象
java 复制代码
public class Demo1_23 {
    // ["a", "b"]
    public static void main(String[] args) {
        String x = "ab";
        String s = new String("a") + new String("b");  // new String("ab")
        // 堆 new String("a") new String("b") new String("ab")
        String s2 = s.intern();   //将这个字符串对象尝试放入串池,如果有则不会放入,如果没有则放入串池,会把串池中的对象返回
        System.out.println(s2 == "ab");  // true
        System.out.println(s2 == x); // true
        System.out.println(s == x); // false
    }
}

StringTable 位置

  • jdk 1.6 在永久代中
  • jdk 1.8 在堆内存中

StringTable 垃圾回收:StringTable 在内存紧张时,会发生垃圾回收。

StringTable 调优

  • 调整 -XX:StringTableSize = 桶个数。由于 StringTable 是由 HashTable 实现的,所以可以适当增加 HashTable 桶的个数,来减少字符串放入串池所需要的时间。
  • 考虑是否需要将字符串对象入池。可以通过intern方法减少重复入池。

2.6 直接内存

Direct Memory 直接内存

  • 属于操作系统,在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式
  • 分配回收成本较高,但读写性高
  • 不受 JVM 内存回收管理

文件读写流程: 使用了 DirectBuffer 使用 Native 函数库可以直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,在一些场合中能够显著提高性能,避免了在 Java 堆和 Native 堆中来回复制数据。

直接内存溢出演示

java 复制代码
/**
 * 演示直接内存溢出
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class Demo1_10 {
    static int _100Mb = 1024 * 1024 * 100;

    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
                list.add(byteBuffer);
                i++;
            }
        } finally {
            System.out.println(i);
        }
        // 方法区是jvm规范,jdk6 中对方法区的实现称为永久代,jdk8 对方法区的实现称为元空间
    }
}

直接内存释放原理 直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过 unsafe.freeMemory 来手动释放。

java 复制代码
/**
 * 演示自动分配和回收直接内存
 */
public class Demo1_26 {
    static int _1GB = 1024 * 1024 * 1024;
    public static void main(String[] args) throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
        System.out.println("分配完毕......");
        System.in.read();
        System.out.println("开始释放......");
        byteBuffer = null;
        System.gc();
        System.in.read();
    }
}

allocateDirect 方法实现:

java 复制代码
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

DirectByteBuffer 类:

java 复制代码
DirectByteBuffer(int cap) {   
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        base = unsafe.allocateMemory(size); //主动分配直接内存
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象
    att = null;
}

这里调用了一个 Cleaner 的 create 方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是 DirectByteBuffer)被回收以后,就会调用 Cleaner 的 clean 方法,来清除直接内存中占用的内存。

java 复制代码
public void clean() {
       if (remove(this)) {
           try {
               this.thunk.run(); //调用run方法
           } catch (final Throwable var2) {
               AccessController.doPrivileged(new PrivilegedAction<Void>() {
                   public Void run() {
                       if (System.err != null) {
                           (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                       }

                       System.exit(1);
                       return null;
                   }
               });
}

对应对象的 run 方法:

java 复制代码
public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address); //释放直接内存中占用的内存
    address = 0;
    Bits.unreserveMemory(size, capacity);
}
java 复制代码
/**
 * 演示主动分配和主动回收直接内存
 */
public class Demo1_27 {
    static int _1GB = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        Unsafe unsafe = getUnsafe();
        // 分配内存,base表示分配的内存的地址
        long base = unsafe.allocateMemory(_1GB);
        unsafe.setMemory(base, _1GB, (byte) 0);
        System.in.read();
        // 释放内存
        unsafe.freeMemory(base);
        System.in.read();
    }
    public static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e){
            throw new RuntimeException();
        }
    }
}
  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法。
  • BuyteBuffer 的实现类内部,使用了 Cleaner(虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存。
相关推荐
uzong13 分钟前
后端系统设计文档模板
后端
幽络源小助理17 分钟前
SpringBoot+Vue车票管理系统源码下载 – 幽络源免费项目实战代码
vue.js·spring boot·后端
uzong1 小时前
软件架构指南 Software Architecture Guide
后端
又是忙碌的一天1 小时前
SpringBoot 创建及登录、拦截器
java·spring boot·后端
勇哥java实战分享2 小时前
短信平台 Pro 版本 ,比开源版本更强大
后端
学历真的很重要2 小时前
LangChain V1.0 Context Engineering(上下文工程)详细指南
人工智能·后端·学习·语言模型·面试·职场和发展·langchain
计算机毕设VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue二手家电管理系统(源码+数据库+文档)
vue.js·spring boot·后端·课程设计
上进小菜猪2 小时前
基于 YOLOv8 的智能杂草检测识别实战 [目标检测完整源码]
后端
韩师傅3 小时前
前端开发消亡史:AI也无法掩盖没有设计创造力的真相
前端·人工智能·后端
栈与堆4 小时前
LeetCode-1-两数之和
java·数据结构·后端·python·算法·leetcode·rust