深入理解java虚拟机:虚拟机架构和 虚拟机运行时数据区

300 阅读22分钟

每次面试都在看别人总结的经验,被动式的记忆一些知识点,注定是不牢固的,也浪费了大量的时间,也没有真正学习到真知识。

什么是JVM

JVM是Java Virtual Machine(Java 虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

Java语言的一个非常重要的特点就是平台无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

Java虚拟机架构

首先在学习JVM之前,我们要了解它的架构

1.1 Class 文件 (字节码文件)

Java 之所以号称“一次编写,处处运行”,就是得益于虚拟机和 Class 文件 (注:CLass 文件、字节码文件和类文件是一个意思) 的组合机制。程序员并不需要自己去适配不同的操作系统,大家都知道我们平时编写的 java 代码在编译成 Class 文件后才能执行,而 Class 文件可以在任何操作系统上的 JVM 上执行,这样就做到了“平台无关性”。下面是一个最简单的 HelloWorld 程序及其对应的 Class 文件。

HelloWorld 程序及其编译后的 Class 文件

得益于 Class 文件,JVM 还可以做到“语言无关性”,也就是说不只有 Java 程序可以运行于 JVM 之上,很多其他语言例如最近在安卓开发者中大火的 Kotlin 语言,还有 Scala、Groovy 等语言也都是基于 JVM 平台的,这些语言的代码都可以编译成 Class 文件,然后在 JVM 上运行。

  • JVM提供的平台无关性和语言无关性

1.2 类加载器子系统 (ClassLoader Subsystem)

要执行 Class 文件就需要先将其加载进内存,这一工作正是由类加载器 (ClassLoader) 完成的,系统为我们提供了三种类加载器,分别是启动类加载器 (Bootstrap ClassLoader)、扩展类加载器 (Extension ClassLoader) 和应用程序类加载器 (Application ClassLoader),如果有必要,我们也可以加入自定义的类加载器。类加载过程如下:

  • 类加载过程

类加载过程具体可以看之前写的一篇文章: 类加载-类的生命周期

1.3 Java 虚拟机运行时数据区 (JVM Runtime Data Area)

执行引擎 (Execution Engine)

字节码被加载进运行时数据区后,执行引擎会进行读取并执行,执行引擎主要包含以下模块:

  • 解释器 (Interpreter):相信大家很久以前就听过“计算机只认识0和1”这句话,时至今日,计算机依然只认识0和1,所以任何编程语言的代码最终都要转化成机器码 (二进制代码)才能执行,Java 也不例外,而解释器的工作正是将编译得到的字节码再转化成机器码,然后才能执行。正因为如此,Java 才被称为解释型语言,也正是因为边解释边执行的特点,Java 程序在执行时才会慢于 C++ 之类的编译型语言。
  • 即时编译器 (JIT Compiler,just-in-time compiler):即时编译器百度百科),为了弥补解释执行带来的速度劣势,JVM 引入了即时编译器,它的作用就是把热点代码,比如重复调用的方法和循环代码等,编译成机器码并存放在 code cache 中,这样之后再用到这些代码就不用重新解释执行了,可以提高程序运行效率。
  • 垃圾收集器 (Garbage Collector):Java 程序员可以不用手动释放内存,全是垃圾收集器的功劳,这也是 JVM 中尤其重要的内容,后续会有多篇文章对其进行介绍。

本地库接口 (JNI,Java Native Interface)

如果你经常看 JDK 源码的话,一定会注意到 native 这个关键词,被它修饰的方法是没有方法体的,是因为它调用了计算机本地的方法库 (通常是 C 或 C++ 代码)。JDK 源码中有很多类的方法,特别是一些需要操作计算机硬件的方法,都调用了本地方法库,毕竟与硬件打交道还是用 C 和 C++ 更方便,比如下面这些方法:

// 例一:这是 Thread 类中的 currentThread 方法,用于获取当前正在执行的线程
public static native Thread currentThread();

本地方法库 (Native Method Library)

本地库接口所调用的对象正是位于这个库中,一般是位于计算机本地的 C 或 C++ 语言代码。

Java 虚拟机运行时数据区

程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码命令,分支、循环、异常处理、线程恢复等基础功能都需要依靠这个计数器来完成。

由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域“线程私有”的内存。

如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Navite方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java 虚拟机栈 (JVM Stacks)

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用 (reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置) 和 returnAddress 类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽 (Slot) 来表示,其中64位长度的 long 和 double 类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间 (譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。

Java 虚拟机栈的内部结构如下图所示:

  • Java 虚拟机栈

  • 局部变量表

局部变量表是存放方法参数和局部变量的区域。 局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。

  • 操作数栈

操作数栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。下面使用 i++ 和 ++i 的区别来帮助理解操作数栈:

i++ 和 ++i 的区别:

i++:从局部变量表取出 i 并压入操作栈,然后对局部变量表中的 i 自增 1,将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,如此线程从操作栈读到的是自增之前的值。 ++i:先对局部变量表的 i 自增 1,然后取出并压入操作栈,再将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,线程从操作栈读到的是自增之后的值。 之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。

动态连接

每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。

方法出口

方法执行时有两种退出情况:

1.正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;

2.异常退出。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

1.返回值压入上层调用栈帧。

2.异常信息抛给能够处理的栈帧。

3.程序计数器指向方法调用后的下一条指令。

本地方法栈 (Native Method Stacks)

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地 (Native) 方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机 (譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失 败时分别抛出 StackOverflowError 和OutOfMemoryError 异常。

Java 堆 (Heap)

对于Java应用程序来说,Java 堆 (Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。Java 堆是垃圾收集器管理的内存区域,因此也常被称为“GC 堆”。 根据《Java虚拟机规范》的规定,Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大 对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

Java 堆的唯一作用就是存放对象实例,这也是垃圾收集器最关注的内存区域,因为大多数对象实例的存活时间都很短,比如在方法内部创建的实例在方法执行完之后就没有存在价值了,所以这个区域的垃圾回收性价比最高。关于垃圾回收的详细内容,见后续文章。

方法区 (Method Area)

方法区 (Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与 Java 堆区分开来。

说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK 8以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。

本质上这两者并不是等价的,因为仅仅是当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了 Java 应用更容易遇到 内存溢出的问题(永久代有-XX:M axPermSize 的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题 ),而且有极少数方法 (例如 String :: intern() ) 会因永久代的原因而导致不同虚拟机下有不同的表现。当 Oracle 收购 BEA 获得了 JRockit 的所有权后,准备把 JRockit 中的优秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到 HotSpot 未来的发展,在 JDK 6 的 时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存 (Native Memory) 来实现方法区的计划了,到了JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Metaspace)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

这部分引用内容对方法区的介绍十分全面,切记不要将方法区和永久代混为一谈,从JDK 8 以后已经没有永久代的概念了。

运行时常量池 (Runtime Constant Pool)

运行时常量池 (Runtime Constant Pool) 是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表 (Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java虚拟机对Class文件每一部分(自然也包含常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的便是String类的intern()方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存 时会抛出OutOfMemoryError异常。

常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。

HotSpot虚拟机对象探秘

实战:OutOfMemoryError异常

垃圾收集器与内存分配策略

概述

对象已死吗?

垃圾收集算法

HotSpot的算法实现

介绍了对象存活判定算法和垃圾收集算法,而在HotSpot虚拟机上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。

  • 枚举根节点

    从可达性分析中从GC Roots节点找引用为例,可作为GC Roots的节点主要是全局性的引用与执行上下文中,如果要逐个检查引用,必然消耗时间。 另外可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里的“一致性”的意思是指整个分析期间整个系统执行系统看起来就行被冻结在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果的准确性就无法得到保证。这点是导致GC进行时必须暂停所有Java执行线程的其中一个重要原因。 由于目前主流的Java虚拟机都是准确式GC,做一档执行系统停顿下来之后,并不需要一个不漏的检查执行上下文和全局的引用位置,虚拟机应当有办法得知哪些地方存放的是对象的引用。在HotSpot的实现中,是使用一组OopMap的数据结构来达到这个目的的,在类加载完成时,HostSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。 这样GC在扫描时就可以直接得知这些信息。

  • 安全点

    在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots的枚举,但可能导致引用关系变化的指令非常多,如果为每一条指令都生成OopMap,那将会需要大量的额外空间,这样GC的空间成本会变的很高。 实际上,HotSpot也的确没有为每条指令生成OopMap,只是在特定的位置记录了这些信息,这些位置被称为安全点(SafePoint)。

    SafePoint的选定既不能太少,以致让GC等待时间太久,也不能设置的太频繁以至于增大运行时负荷。所以安全点的设置是以让程序“是否具有让程序长时间执行的特征”为标准选定的。“长时间执行”最明显的特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生SafePoint。

    对于SafePoint,另一个问题是如何在GC发生时让所有线程都跑到安全点在停顿下来。这里有两种方案:抢先式中断和主动式中断。抢先式中断不需要线程代码主动配合,当GC发生时,首先把所有线程中断,如果发现线程中断的地方不在安全点上,就恢复线程,让他跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程来响应GC。 而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点是重合的另外再加上创建对象需要分配的内存的地方。

  • 安全区域

    使用安全点似乎已经完美解决了如何进入GC的问题,但实际情况却并不一定,安全点机制保证了程序执行时,在不太长的时间内就会进入到可进入的GC的安全点。但是程序如果不执行呢?所谓的程序不执行就是没有分配cpu时间,典型的例子就是线程处于sleep状态或者blocked状态,这时候线程无法响应jvm中断请求,走到安全的地方中断挂起,jvm显然不太可能等待线程重新分配cpu时间,对于这种情况,我们使用安全区域来解决。 安全区域是指在一段代码片段之中,你用关系不会发生变化。在这个区域的任何地方开始GC都是安全的,我们可以把安全区域看做是扩展了的安全点。 当线程执行到安全区域中的代码时,首先标识自己已经进入了安全区,那样当在这段时间里,JVM要发起GC时,就不用管标识自己为安全区域状态的线程了。当线程要离开安全区域时,他要检查系统是否完成了根节点枚举,如果完成了,那线程就继续执行,否则他就必须等待,直到收到可以安全离开安全区域的信号为止。

垃圾收集器

内存分配与回收策略

虚拟机性能监控与故障处理工具

调优案例分析与实战

参考: