深入浅出 Java 虚拟机(一)JVM 基础与其内存管理

本文章为《深入浅出 Java 虚拟机》系列课程学习笔记,侵删。学习地址为 深入浅出 Java 虚拟机

1 如何理解 JVM ?

JVM,又称 Java Virtual Machine,Java 虚拟机。它能识别 .class 后缀的文件,并且能够解析它的指令,最终调用操作系统上的函数,完成我们想要的操作。

事实上,我们可以把 JVM 等同于操作系统,把 Java 字节码等同于汇编语言。我们也可以把 JVM 看作是一个翻译器,持续不断的翻译执行 Java 字节码,然后调用真正的操作系统函数。

JVM 解决了字节码加载、自动垃圾回收、并发等一系列问题。JVM 其实是一个规范,定义了 .class 文件的结构、加载机制、数据存储、运行时栈等诸多内容,最常用的 JVM 实现就是 Hotspot。
在这里插入图片描述

2 Java 的跨平台性

JVM 只需要保证能够正确执行 .class 文件,就可以运行在诸如 Linux、Windows、MacOS 等平台上。

而 Java 跨平台的意义在于一次编译,处处运行,能够做到这一点 JVM 功不可没。比如我们在 Maven 仓库下载同一版本的 jar 包就可以到处运行,不需要在每个平台上再编译一次。

3 C++ 与 Java 编译的不同

C++ 程序是编译成操作系统能够识别的 .exe 文件,而 Java 程序是编译成 JVM 能够识别的 .class 文件,然后由 JVM 负责调用系统函数执行程序。

4 JVM、JRE、JDK的关系

JVM 是 Java 程序能够运行的核心,虽然它功能强大,但它也需要生产原料 .class 文件。如果只有 JVM,是无法完成一次编译,处处运行的。它需要一个基本的类库,比如怎么操作文件、怎么连接网络等。Java 会一次性将 JVM 运行所需的类库都传递给它。JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境,也就是我们常说的 JRE。

如果是只需要执行一些 Java 程序,只需要一个 JRE 就足够了。至于 JDK,不仅包括 JRE,还提供了一些非常好用的小工具,比如 javac、java、jar 等,它是 Java 开发的核心。

在这里插入图片描述

5 Java 代码到底是如何运行起来的

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

这里,我们调用了 System.out 等模块,也就是 JRE 里提供的类库。在我们使用 JDK 的工具 javac 进行编译后,会产生 HelloWorld 的字节码。Java 字节码是沟通 JVM 与 Java 程序的桥梁,下面我们使用 javap 来看一下字节码到底长什么样子。

0 getstatic #2 <java/lang/System.out>
3 ldc #3 <Hello World>
5 invokevirtual #4 <java/io/PrintStream.println>
8 return

Java 虚拟机采用基于栈的架构,其指令由操作码和操作数组成。这些字节码指令,就叫作 opcode。opcode 有一个字节的长度(0~255),意味着指令集的操作码个数不能超过 256 条。而紧跟在 opcode 后面的是被操作数。

JVM 就是靠解析这些 opcode 和操作数来完成程序的执行的。当我们使用 Java 命令运行 .class 文件的时候,实际上就相当于启动了一个 JVM 进程。

然后 JVM 会翻译这些字节码,它有两种执行方式。常见的就是解释执行,将 opcode + 操作数翻译成机器代码;另外一种执行方式就是 JIT,也就是我们常说的即时编译,它会在一定条件下将字节码编译成机器码之后再执行。

而 JVM 的程序运行,都是在栈上完成的,这和其他普通程序的执行是类似的,同样分为堆和栈。比如我们现在运行到了 main 方法,就会给它分配一个栈帧。当退出方法体时,会弹出相应的栈帧。你会发现,大多数字节码指令,就是不断的对栈帧进行操作。

在这里插入图片描述
一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到元数据区,执行引擎将会通过混合模式(Java 虚拟机采用基于栈的架构,有比较丰富的 opcode。这些字节码可以解释执行,也可以编译成机器码,运行在底层硬件上,可以说 JVM 是一种混合执行的策略)执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM 作为 .class 文件的黑盒存在,输入字节码,调用操作系统函数。

过程如下:Java 文件 -> 编译器 > 字节码 -> JVM -> 机器码。

6 JVM 的内存布局

在这里插入图片描述
Java 8 及之后的版本,彻底移除了持久代,而使用 Metaspace 来进行替代。从上图中我们可以得知:

  1. JVM 堆中的数据是共享的,是占用内存最大的一块区域。
  2. 可以执行字节码的模块叫作执行引擎。
  3. 执行引擎在线程切换时怎么恢复?依靠的就是程序计数器。
  4. JVM 的内存划分与多线程是息息相关的。像我们程序中运行时用到的栈,以及本地方法栈,它们的维度都是线程。
  5. 本地内存包含元数据区和一些直接内存。

虚拟机栈

Java 虚拟机栈是基于线程的。哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。

栈里的每条数据,就是栈帧。在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。每个栈帧,都包含四个区域:

  1. 局部变量表
  2. 操作数栈
  3. 动态连接
  4. 返回地址
    在这里插入图片描述

这里有一个两层的栈。第一层是栈帧,对应着方法;第二层是方法的执行,对应着操作数。所有的字节码指令,其实都会抽象成对栈的入栈出栈操作。执行引擎只需要傻瓜式的按顺序执行,就可以保证它的正确性。

本地方法栈

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法,你甚至可以认为虚拟机栈和本地方法栈是同一个区域。

程序计数器

程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。这里面存的,就是当前线程执行的进度。
在这里插入图片描述

程序计数器也是因为线程而产生的,与虚拟机栈配合完成计算操作。程序计数器还存储了当前正在运行的流程,包括正在执行的指令、跳转、分支、循环、异常处理等。

0 getstatic #2 <java/lang/System.out>
3 ldc #3 <Hello World>
5 invokevirtual #4 <java/io/PrintStream.println>
8 return

我们在上面的内容中分析了 HelloWorld 的字节码,可以看到在每个 opcode 前面,都有一个序号,可以认为它们是程序计数器的内容。

在这里插入图片描述
堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。堆是所有线程共享的。

随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC。

那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。

Java 的对象可以分为基本数据类型和普通对象。对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。

而对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。

注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。

最后,我们可以来看一下这个 returnAdress,这种类型只存在于字节码层面,所以我们平常打交道的比较少。对于 JVM 来说,程序就是存储在方法区的字节码指令,而 returnAddress 类型的值就是指向特定指令内存地址的指针。

元空间

我们之前生成的 .class 文件,是放在 JVM 的哪个区域的?想要问答这个问题,就不得不提下 Java 的历史。在 Java 8 之前,这些类的信息是放在一个叫 Perm 区的内存里面的。更早版本,甚至 String.intern 相关的运行时常量池也放在这里。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。

Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的,这是背景。关于它们的对比,可以看下这张图。

在这里插入图片描述
然后,元空间的好处也是它的坏处。使用非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize 来控制大小。

方法区,作为一个概念,依然存在。它的物理存储的容器,就是 Metaspace。这个区域存储的内容,包括:类的信息、常量池、方法数据、方法代码。

常见问题

1 我们常说的字符串常量,存放在哪呢?

由于字符串常量池,在 Java 7 之后,放到了堆中,我们创建的字符串,将会在堆上分配。

JVM 中存在多个常量池。字符串常量池,已经移动到堆上(jdk8 之前是 perm 区),也就是执行 intern 方法后存的地方。至于类文件常量池,constant_pool,是每个类每个接口所拥有的,这部分数据在方法区,也就是元数据区。而运行时常量池是在类加载后的一个内存区域,它们都在元空间。

2 堆、非堆、本地内存,有什么关系?

在这里插入图片描述
JVM 在运行时,会从操作系统申请大块的堆内内存,进行数据的存储。但是,堆外内存也就是申请后操作系统剩余的内存,也会有部分受到 JVM 的控制。比较典型的就是一些 native 关键词修饰的方法,以及对内存的申请和处理。

发布了127 篇原创文章 · 获赞 237 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/Geffin/article/details/104434946