JVM 学习笔记(一)

JVM 是 Java 后端面试的一个重点内容,也是我校招和社招面试时候的一个老大难。这一次准备认真彻底地学习和总结一下 Java 虚拟机相关的知识点,并且通过输出的方式让自己真正学会它们。


JVM 的一些基础概念

在学习 JVM 更加深入的内容之前,可以先对 JVM 的有一些基本的了解,如下:

  • JVM 是 Java 程序运行的环境,Java 程序会编译成二进制字节码,然后在虚拟机上运行

  • JVM 是保证 Java 所谓的「一次编写、到处运行」的关键,因为 JVM 作为更高的一个抽象层级,屏蔽掉了底层操作系统的区别,让 Java 程序员在编程的时候,面对的是统一的 JVM 规范。

    • 而不同平台的 JVM 根据底层操作系统的不同,有各自的实现,但都符合 JVM 规范。因此,同样一个 Java 程序编译出来的相同的字节码,在各个平台都可以运行。

  • JVM 提供了很多的好处:

    • 自动的内存管理与垃圾回收

    • 相比 C / C++ 允许数组越界访问而言,JVM 提供了数组下标越界的检查,会抛出 ArrayIndexOutOfBoundsException 异常

    • 通过虚方法调用实现多态(简单来说,就是 JVM 在编译时,通过一个 invokevirtual 的 JVM 指令,来 “看似和声明类型的方法进行绑定,实际上等到运行时再决定”)。具体更详细的细节之后再记录。

  • JVM、JRE、JDK 的关系:

    • JVM 时 Java 程序的运行环境

    • JRE 就是 JVM 加上基础类库

    • JDK 就是 JRE 的基础上再加上编译工具

  • JVM 其实是一套规范,有各个不同的厂商提供具体的实现,比如著名的有 HopSpot 虚拟机、OpenJDK 等

JVM 知识板块

JVM 的学习内容,可以通过这一张图来概括:

粗略地来看,会学习到关于 Java 类加载的细节、JVM 的内存结构、Java 代码执行时候的编译和垃圾回收的机制等。

JVM 内存结构

JVM 内存结构是学习的起点,学习 Java 虚拟机,首先了解 JVM 内存当中的各个部分,它们都和程序的执行有关。

JVM 内存结构之程序计数器

想要理解 JVM 中程序计数器的作用,就需要先理解 Java 程序执行的细节,下面这张图可以清晰地展示:

  • Java 源代码会被编译成二进制字节码,也就是一个个的 JVM 指令

  • JVM 指令会被解释器读取,转换成机器码,才能让 CPU 去执行

关键在于二进制字节码的内容,每一个 JVM 指令都对应有一个执行地址(在 .class 文件当中的字节位置),程序计数器就是用来跟踪记录 JVM 当前正在执行、或即将执行的字节码指令的位置。每个 JVM 指令被执行之后,解释器会到程序计数器里去读取下一条指令的地址。

值得一提的是,程序计数器在硬件上是通过寄存器来实现的,这是 CPU 里面读写速度最快的区域。

程序计数器有很多重要的特点:

  • 每个线程都有自己的程序计数器,即线程私有。因为时间片会在不同线程之间分配,因此每个线程的执行位置不一样,因此都需要记录自己各自的执行位置。

  • 不存在内存溢出:JVM 规范规定了程序计数器不会内存溢出(可能就这么规定的吧)

JVM 内存结构之虚拟机栈

每个线程在运行的时候都需要内存空间,因为在代码运行执行一个个的方法的时候,我们需要记录方法的参数、局部变量、返回地址等信息。JVM 中有一个后入先出的栈的结构来存储这些内容,称为虚拟机栈。每一个方法运行时需要的空间都对应一个栈帧,方法执行的时候,栈帧入栈,方法执行完成之后栈帧弹出。每个线程中,正在执行的方法,就对应虚拟机栈中的那个活动栈帧。

我们能够从 IDEA 的 Debug 模式中看到虚拟机栈的样子:

  • Frames 就是虚拟机栈,而里面就存放着一个一个地栈帧,对应方法调用。

  • 最下面的方法是最先被调用的,最上面的方法是活动栈帧方法。现象栈的结构就好。

关于虚拟机栈,有一些值得讨论辨析的问题:

  • 首先是,栈内存不需要被垃圾回收。因为方法执行结束之后,栈帧空间就会被弹出,因此不需要垃圾回收来管理这一块内存。就像我们从栈弹出一个元素之后,就腾出了这部分空间一样。

  • 其次,栈内存并不是分配的越多越好。我们可以通过 -Xss 来设定 stack size 的大小,比如通过 IDEA 中的运行配置里的 VM Options 选项,但是线程的栈内存越大,能支持的总线程数就会减少,因为物理机的总内存数是有限的。

  • 再有就是结合「栈帧中会存储方法运行时的变量」这个特点,结合线程安全问题来进行一个分析:

    • 核心还是要记住,是不是线程安全的,关键看变量是不是线程私有的,还是每个线程都能访问的。

    • 因此,对于方法中的局部变量来说,它们是存储在每个线程自己的虚拟机栈中的,因此一般情况下是线程安全的。作为对比的情况,如果方法操作的是 static 变量,它不在虚拟机栈中,可以被每一个线程所访问,因此就不是线程安全的,需要进行一些必要的同步措施。

    • 但是还有一些例外

      • 如果方法参数传入的是对象类型的变量,那么不同线程执行方法时候,可能操作的是同一个变量

      • 如果方法返回的是对象类型的变量,那么在外部,可能多个线程会操作这同一个变量。虽然是局部变量,但是它已经逃离了方法的作用范围了。

JVM 的虚拟机栈和我们经常遇到的 StackOverflow 错误有关,如果方法中递归调用层级太深,或者如果某一个栈帧过大(通常不太可能,Linux 环境下默认 JVM 栈的大小是 1M),都会出现这个错误,也就是栈内存溢出。

和这个相关的,还有一些程序运行诊断的案例技巧:

  • 如果我们想要定位 CPU 占用过多的线程,可以使用如下方法:

    • 使用 top 命令查看哪个进程占用过高

    • 使用 ps H -eo pid,tid,%cpu | grep <pid> 查看 Java 进程中哪个线程占用过高

    • 使用 jstack <pid> 来打印出这个进程的所有线程信息,从中通过十六进制的线程 id 来找到有问题的线程,以及问题代码的行数

  • 如果程序运行了很长时间都没有结果,可能出现了死锁的情况

    • jstack 的输出结果里,可以帮我们诊断出 deadlock 的出现位置

JVM 内存结构之本地方法栈

Java 源码中有一类方法是标注了 native 关键字,它们代表的是本地方法,即不是使用 Java 代码实现的方法(C、C++)。比如 Object 类中的 clone(), hashCode(), notify(), notifyAll()。JVM 是通过本地方法接口来调用这些方法的。那么对于这些方法的运行,也需要存储它们需要的内存空间,这个部分就是本地方法栈。