JVM 01:基本概念与内存结构

84 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第7天,点击查看活动详情

JVM

JVM 是什么?

  • Java 虚拟机,英文全称是 Java Virtual MachineJVM 是可运行 Java 字节码文件的虚拟计算机。
  • Oracle 负责制定 JVM 规范,并会随着 JDK 的发布提供一个官方的 JVM 实现,但实际上不少商业公司也会提供商业级的 JVM 实现。常见的 JVM 实现是 HotSpot

JVMJREJDK 三者之间的关系?

  • JVMJava 虚拟机,JREJava 运行时环境,JDKJava 开发工具包
  • JDK = JRE + Java 开发工具(比如编译工具 javac.exe 和打包工具 jar.exe 等)
  • JRE = JVM + Java 常用类库
  • JVM = Java 虚拟机
  ┌─    ┌──────────────────────────────────┐
  │     │     Compiler, debugger, etc.     │
  │     └──────────────────────────────────┘
 JDK ┌─ ┌──────────────────────────────────┐
  │  │  │                                  │
  │ JRE │      JVM + Runtime Library       │
  │  │  │                                  │
  └─ └─ └──────────────────────────────────┘
        ┌───────┐┌───────┐┌───────┐┌───────┐
        │Windows││ Linux ││ macOS ││others │
        └───────┘└───────┘└───────┘└───────┘

JVM 内存结构

202204082110 JVM 01:基本概念与内存结构 00.png

程序计数器

程序计数器(寄存器)Program Counter Register

  • 作用:用于记录下一条 JVM 指令的执行地址
  • 特点:
    • 每个线程都有属于自己的程序计数器,它是线程私有的,生命周期与线程相同,随着线程创建而创建,随着线程销毁而销毁
    • 占用较小的内存空间,不存在内存溢出 OutOfMemoryError 的问题

虚拟机栈

虚拟机栈 JVM Stacks

  • 每个线程运行时分配的内存空间,称为虚拟机栈
  • 栈由多个栈帧 stack frame 组成,对应每次方法调用时所占用的内存,用于保存参数、局部变量、返回地址等内容。
  • 每个线程只能由一个活动栈帧,对应当前正在执行的那个方法

团子注:有点像 JavaScript 中的执行上下文栈与执行上下文。

垃圾回收是否设计栈内存?

  • 垃圾回收不会涉及栈内存

栈内存是否越大越好?

  • -Xss size 是虚拟机参数,用于设置线程栈大小 stack size,单位是字节,下面用不同形式设置了线程栈大小为 1024KB
    • -Xss1m
    • -Xss1024k
    • -Xss1048576
  • 栈内存的分配并非越大越好。线程栈大小越大,则支持更多层数的递归调用,但是由于物理内存的限制,会减少总线程数量。一般保持系统默认即可。

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

  • 如果一个变量是线程私有的,则称之为线程安全的;如果一个变量是线程共享的,则称之为线程不安全的。
  • 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的。
  • 如果局部变量引用了对象,并逃离了方法的作用范围,需要考虑线程安全问题。
public class ThreadSafeDemo {
    // sb 线程安全
    public static void m1() {
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }

    // sb 是方法参数,不是线程安全的
    public static void m2(StringBuilder sb) {
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }

    // sb 作为返回值,不是线程安全的
    public static StringBuilder m3() {
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        return sb;
    }
}

栈内存溢出

  • 栈内存溢出对应的异常是 java.lang.StackOverflowError
  • 有两种可能会导致占内存溢出:
    • 栈帧过多导致栈内存溢出,比如无限递归,或者调用第三方库对两个相互引用的对象进行 JSON 序列化
    • 栈帧过大导致栈内存溢出

线程运行诊断

  1. 问题:CPU 占用过多
  • 使用 top 命令定位进程编号,比如说是 32655
  • 使用 ps H -eo pid,tid,%cpu | grep 32655,第二列 tid 可以定位到线程编号
  • jstack 32655 可以将进程中的所有 Java 线程列出来,需要把上一步查到的 10 进制线程编号换算成 16 进制的线程编号,进而可以定位到是哪个类的哪一行。
  1. 问题:程序运行很长时间没有结果
  • 使用 jstack pid 可以查看进程中的所有线程
  • 查看最后是否有 Java-level deadlock,也就是死锁现象

原生方法栈

原生方法栈Java 虚拟机在调用原生方法时提供的内存空间。

团子注:也有人称其为本地方法栈,但个人认为把 native 翻译成“原生”更好一点。

原生方法不是由 Java 编写的,而是使用 C 或者 C++ 编写的方法,用于和操作系统底层 API 打交道。比如 Objectclone() 方法,方法声明为 protected native Object clone() throws CloneNotSupportedExceptoin,它没有方法实现,使用 native 关键字标识为一个原生方法。

Heap:使用 new 关键字创建的对象会使用堆内存。

特点:

  • 它是线程共享的,堆中的对象都要考虑线程安全的问题。
  • 有垃圾回收机制。

堆内存溢出

  • 堆内存溢出对应的异常是 java.lang.OutOfMemoryError: Java heap space
  • 设置堆内存空间大小的 JVM 参数是 -Xmx,比如设置堆空间大小为 8mb 可以这样子 -Xmx8m
  • 把堆内存设置的比较小,可以尽早地暴露出由堆内存溢出引起的问题。

堆内存诊断

  1. jps 工具:查看当前系统中有哪些 Java 进程

  2. jmap 工具:查看堆内存使用情况

  • jmap -heap <pid>
  1. jconsole 工具:图形界面、多功能监测工具,可以连续监测

  2. jvisualvm 工具:可视化工具,其中堆转储(堆 Dump)可以截取堆内存快照,然后查找占用内存最大的 20 个对象,最后排查原始代码。

方法区

方法区

  • 方法区类似传统语言中编译后的代码的存储区,用于存储每个类的结构。
  • JVM 启动时会创建方法区,逻辑上讲方法区属于堆内存的一部分,但是不同 JVM 有不同的实现。
  • HotSpot JVMJDK 1.8 之前,方法区使用永久代实现,占用堆内存;JDK 1.8 及以后,方法区使用元空间实现,占用本地内存,也就是操作系统的内存,默认没有设置上限。
  • 如果方法区出现内存溢出,JVM 会抛出 OutOfMemoryError

202204082110 JVM 01:基本概念与内存结构 01.png

202204082110 JVM 01:基本概念与内存结构 02.png

内存溢出

  • 永久代内存溢出:对应的异常是 java.lang.OutOfMemoryError: PermGen space,比如可以通过 JVM 选项 -XX:MaxPermSize=8m 来设置永久代大小为 8mb
  • 元空间内存溢出:对应的异常是 java.lang.OutOfMemoryError:Metaspace,比如可以通过 JVM 选项 -XX:MaxMetaspaceSize=8m 来设置元空间大小为 8mb

下面的代码用来演示元空间内存溢出,如果想演示永久代内存溢出,只需要把 Opcodes.V1_8 改成 Opcodes.V1_6 即可。

/**
 * 演示元空间内存溢出
 * java.lang.OutOfMemoryError: Metaspace
 * -XX:MaxPermSize=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 < 20000; 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);
        }
    }
}

在使用一些第三方库的时候,比如 SpringMyBatis,它们会动态生成类的字节码,完成动态类加载。这些框架都用到了 ClassWriter,它继承自 ClassVisitor,属于 asm 项目。

运行时常量池

把下面的类编译成二进制字节码文件(.class 文件),其中包含如下信息:

  • 类的基本信息
  • 常量池
  • 类方法定义,其中包含 JVM 指令
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

我们很难直接阅读 .class 文件,可以借助 JDK 提供的工具反编译字节码:

  • javap -v HelloWorld.class
  • -v 表示显示详细信息
PS C:\Users\patrick\Desktop> javac HelloWorld.java
PS C:\Users\patrick\Desktop> javap -v HelloWorld.class
Classfile /C:/Users/patrick/Desktop/HelloWorld.class
  Last modified 2022-4-9; size 425 bytes
  MD5 checksum 92cb0edfd970bc2b3a2ec2be5a24945f
  Compiled from "HelloWorld.java"
public class HelloWorld
  minor version: 0
  major version: 61
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = String             #14            // hello world
  #14 = Utf8               hello world
  #15 = Methodref          #16.#17        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #16 = Class              #18            // java/io/PrintStream
  #17 = NameAndType        #19:#20        // println:(Ljava/lang/String;)V
  #18 = Utf8               java/io/PrintStream
  #19 = Utf8               println
  #20 = Utf8               (Ljava/lang/String;)V
  #21 = Class              #22            // HelloWorld
  #22 = Utf8               HelloWorld
  #23 = Utf8               Code
  #24 = Utf8               LineNumberTable
  #25 = Utf8               main
  #26 = Utf8               ([Ljava/lang/String;)V
  #27 = Utf8               SourceFile
  #28 = Utf8               HelloWorld.java
{
  public HelloWorld();
    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 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #13                 // String hello world
         5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 3: 0
        line 4: 8
}
SourceFile: "HelloWorld.java"

常量池:常量池就是一张表,虚拟机指令根据该表查找要执行的类名、方法名、参数类型、字面量等信息。

运行时常量池:常量池位于 .class 文件中,当该类被加载,它的常量池信息就会被放入运行时常量池,并把里面的符号地址(#数字)变成真实内存地址。

参考资料