一、初始虚拟机
1.1 为什么学习虚拟机
跟许多人一样,我一开始接触 Java 虚拟机只是因为面试需要用到,所以硬着头皮看看。所以很多人对于为什么要学虚拟机这个问题,他们的答案都是:因为面试。但我经过了几年的学习和实战,我发现其实学习虚拟机并不仅仅在于面试,而在于更深入地理解 Java 这门语言,以及为未来排查线上问题打下基础。
1.2 Java 语言的前世今生
Java 语言是一门存在了 20 多年的语言,虽然存在了这么长时间,但 Java 至今都是最大的工业级语言,许多大型互联网公司均采用 Java 来实现其业务系统。大到国际电商巨头阿里巴巴,小到无名小公司,我们均可看到 Java 的身影。
在刚刚接触 Java 的时候,我经常对于 Java 中的一些基本概念弄不清楚。例如:JDK 7 与 Java SE 7 有什么区别?JDK 与 JRE 有什么区别 ?Java SE 与 Java EE 有什么区别 ?等等。
上面这些问题其实都是 Java 中最最基础的知识,如果没有搞懂这些基础的知识,就不用谈更加深入的学习了。所以在开始学习 JVM 相关知识之前,我们这一节就来把那些我们经常混淆的概念弄清楚。
1.3 什么是 JVM
我们都知道在 Windows 系统上一个软件包装包是 exe 后缀的,而这个软件包在苹果的 Mac OSX 系统上是无法安装的。类似地,Mac OSX 系统上软件安装包则是 dmg 后缀,同样无法在 Windows 系统上安装。
为什么不同系统上的软件无法安装,这是因为操作系统底层的实现是不一样的。对于Windows 系统来说,exe 后缀的软件代码最终编译成 Windows 系统能识别的机器码。而 Mac OSX 系统来说,dmg 后缀的软件代码最终编译成 Mac OSX 系统能识别的代码。

系统软件无法通用是一个常见的问题。但使用过 Java 的同学都知道,Java 代码可以在服务端(Linux 系统)运行,也可以在 Windows 系统运行,但我们并没有生成多份不同的代码。所以 Java 语言是如何做到的呢?
与其他语言不同,Java 语言并不直接将代码编译成与系统有关的机器码,而是编译成一种特定的语言规范,这种语言规范我们称之为字节码。无论 Java 程序要在 Windows 系统,还是 Mac OSX 系统,抑或是 Linux 系统,它首先都得编译成字节码文件,之后才能运行。
但即使编译成字节码文件了,各个系统还是无法明白字节码文件的内容,这时候就需要 Java 虚拟机的帮助了。Java 虚拟机会解析字节码文件的内容,并将其翻译为各操作系统能理解的机器码。

简单地说,对于同样一份 Java 源码文件,我们编译成字节码之后,无论是 Linux 系统还是Windows 系统都不认识。这时候 Java 虚拟机就是一个翻译官,在 Linux 系统上翻译成 Linux 机器码给 Linux 系统听,在 Windows 系统上翻译成 Windows 机器码给 Windows 系统听。这样一来,Java 就实现了「Write Once,Run Anywhere」 的伟大愿景了。
在 Java 虚拟机还没出现之前,为了支持软件在不同系统上运行,我们必须在多 个平台写多代码,分别对应特定的系统。但 Java 虚拟机出现之后,你只需要按照特定规范编译书写,编译器编译成字节码文件后,**虚拟机会帮你将字节码生成对应的 Windows Code 和 Mac Code。**本质上最终还是会生成 Windows Code 和 Mac Code 两份机器代码,但对于开发人员来说,却只需要写一次代码了。Java 虚拟机帮开发人员承担了重复的工作,让开发效率更高了。

所以虽然名字是 Java 虚拟机,但 Java 虚拟机与 Java 语言没有直接关系,它只按照 Java 虚拟机规范去读取 Class 文件,并按照规定去解析、执行字节码指令,仅此而已。

1.3.1 JVM 编译器
在 JVM 中有三个非常重要的编译器,它们分别是:前端编译器、JIT 编译器、AOT 编译器。
**前端编译器:**最常见的就是我们的 javac 编译器,其将 Java 源代码编译为 Java 字节码文件。
**JIT 即时编译器:**最常见的是 HotSpot 虚拟机中的 Client Compiler 和 Server Compiler, 其将 Java 字节码编译为本地机器代码。
**AOT 编译器:**能将源代码直接编译为本地机器码。
这三种编译器的编译速度和编译质量如下:
编译速度上:解释执行 > AOT 编译器 > JIT 编译器。
编译质量上:JIT 编译器 > AOT 编译器 >解释执行。
在 JVM 中,通过这几种不同方式的配合,使得 JVM 的编译质量和运行速度达到最优的状 态。

二、运行时数据区
什么是运行时数据区?
运行时数据区是 JVM 在程序执行时分配和管理内存的区域,分为线程私有区域 (每个线程独立拥有,线程销毁时回收)和线程共享区域(所有线程共用,只有 JVM 退出时才回收)。
Java 程序在运行时,会为 JVM 单独划出一块内存区域,而这块内存区域又可以再次划分出一块运行时数据区,运行时数据区域大致可以分为五个部分:

2.1 线程私有区域
2.1.1 虚拟机栈(JVM Stacks)
- 作用 :存储线程执行 Java 方法时的栈帧(Stack Frame),每个方法调用对应一个栈帧的入栈,方法执行完毕对应栈帧出栈。
- 栈帧结构 (每个栈帧包含):
- 局部变量表:存储方法的参数、局部变量(基本类型、对象引用);
- 操作数栈:方法执行时的临时数据栈(如计算
a+b时,先把a、b压入栈,再执行加法指令); - 动态链接:指向方法区中该方法的类元信息(用于运行时解析接口、方法调用);
- 方法返回地址:记录方法执行完后回到调用方的指令地址。
- 特点 :
- 线程私有:线程执行的方法栈帧都在自己的虚拟机栈中,线程安全;
- 内存大小:默认 1M~10M(可通过
-Xss参数调整,如-Xss256k); - 异常情况:
StackOverflowError:线程调用栈过深(如递归无终止条件),栈帧数量超过虚拟机栈深度;OutOfMemoryError:虚拟机栈可动态扩展(部分 JVM 实现),若扩展时无法申请到足够内存。
2.1.2 程序计数器(Program Counter Register)
- 作用:记录当前线程执行的字节码指令地址(行号),用于线程切换后恢复执行位置(比如线程 A 被挂起,线程 B 执行完后,A 能通过计数器找到上次执行到哪条指令)。
- 特点 :
- 线程私有:每个线程都有独立的计数器,互不干扰;
- 无 OOM:内存占用极小(仅存储指令地址),是 JVM 中唯一不会抛出
OutOfMemoryError的区域; - 特殊情况:若线程执行的是
native方法(本地方法,如调用 C++ 代码),计数器值为undefined(因为 native 方法不编译为字节码)。
2.1.3 本地方法栈(Native Method Stack)
- 作用 :与 Java 虚拟机栈功能类似,但专门用于执行
native方法(本地方法,如System.currentTimeMillis()、调用 C/C++ 库)。 - 特点 :
- 线程私有;
- 实现依赖虚拟机:HotSpot JVM 直接将本地方法栈与 Java 虚拟机栈合并(共用同一块内存),其他 JVM 可能单独实现;
- 异常情况:同样会抛出
StackOverflowError(调用栈过深)和OutOfMemoryError(内存不足)
2.2线程共享区域
2.2.1 堆(Heap)------ JVM 内存最大区域
- 作用:存储所有 Java 对象实例(包括数组)和数组,是垃圾回收器(GC)的核心工作区域("GC 堆" 的由来)。
- 特点 :
- 线程共享:所有线程都能访问堆中的对象,因此对象的线程安全需要通过
synchronized、volatile等关键字保证; - 内存大小:默认几 MB~ 几十 GB(可通过
-Xms(初始堆大小)、-Xmx(最大堆大小)调整,如-Xms512m -Xmx1g); - 异常情况:
OutOfMemoryError: Java heap space(堆内存不足,如创建大量对象未被回收);
- 线程共享:所有线程都能访问堆中的对象,因此对象的线程安全需要通过
区域划分(为了 GC 高效回收,逻辑上分为 3 部分):

当有对象需要分配时,一个对象永远优先被分配在年轻代的 Eden 区,等到 Eden 区域内存不够时,Java 虚拟机会启动垃圾回收。此时 Eden 区中没有被引用的对象的内存就会被回收,而一些存活时间较长的对象则会进入到老年代。在 JVM 中有一个名为-XX:MaxTenuringThreshold 的参数专门用来设置晋升到老年代所需要经历的 GC 次数,即在年轻代的对象经过了指定次数的 GC 后,将在下次 GC 时进入老年代。
2.2.2 方法区(Method Area)------ 又称 "非堆"
- 作用 :存储类的元数据信息(类名、父类、接口、字段、方法、常量池)、静态变量(
static修饰)、即时编译器(JIT)编译后的代码等。 - 特点 :
- 线程共享:所有线程共用方法区,类加载后类元信息被所有线程共享;
- 内存大小:默认较小(可通过
-XX:MetaspaceSize、-XX:MaxMetaspaceSize(JDK 8+)或-XX:PermSize、-XX:MaxPermSize(JDK 7 及以前)调整); - 异常情况:
- JDK 7 及以前(永久代实现):
OutOfMemoryError: PermGen space(永久代内存不足,如频繁动态生成类); - JDK 8 及以后(元空间实现):
OutOfMemoryError: Metaspace(元空间内存不足);
- JDK 7 及以前(永久代实现):
- 关键变化(JDK 8+):
- 用 "元空间(Metaspace)" 替代 "永久代(PermGen)";
- 元空间不再占用堆内存,而是直接使用本地内存(操作系统内存),默认无上限(可通过
MaxMetaspaceSize限制),解决了永久代 OOM 的频繁问题;
- 常量池:方法区的核心部分,存储字符串常量(如
"abc")、数字常量、符号引用(如类名、方法名的字符串)等,JDK 7 后字符串常量池移到堆中。
2.2.3 补充:直接内存(Direct Memory)------ 非规范定义区域
- 作用 :不属于《Java 虚拟机规范》定义的运行时数据区,但 JVM 会频繁使用(如 NIO 的
ByteBuffer.allocateDirect()分配的内存),用于优化 Java 程序与操作系统的 IO 操作(避免堆内存与本地内存之间的数据拷贝)。 - 特点 :
- 内存来源:直接使用操作系统的本地内存,不受堆大小(
-Xmx)限制,但受物理内存总大小限制; - 异常情况:
OutOfMemoryError: Direct buffer memory(直接内存不足,如分配大量直接缓冲区未释放); - 注意:直接内存的回收需要依赖
Unsafe类或 GC 的 "虚引用" 机制,若未正确释放可能导致内存泄漏。
- 内存来源:直接使用操作系统的本地内存,不受堆大小(
三、类加载器
加载一个 Class 类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的:
- BootStrap ClassLoader:rt.jar,要负责加载核心的类库(java.lang.*等),构造 ExtClassLoader 和 APPClassLoader
- Extention ClassLoader:主要负责加载 jre/lib/ext 目录下的一些扩展的 jar
- App ClassLoader:主要负责加载应用程序的主函数类
- Custom ClassLoader:自定义的类加载器

3.1 双亲委派机制
双亲委派是一个孩子向父亲方向,然后父亲向孩子方向的双亲委派过程
总结:自下(从 App 开始)而上进行检查,自上而下进行加载。
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时, 即无法完成该加载,子加载器才会尝试自己去加载该类。
双亲委派机制:
- 当 AppClassLoader 加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器 ExtClassLoader 去完成。
- 当 ExtClassLoader 加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给 BootStrapClassLoader 去完成。
- 如果 BootStrapClassLoader 加载失败(例如在 $JAVA_HOME/jre/lib 里未查找到该 class),会使用 ExtClassLoader 来尝试加载;
- 若 ExtClassLoader 也加载失败,则会使用 AppClassLoader 来加载,如果 AppClassLoader 也加载失败,则会报出异常 ClassNotFoundException。
3.2 为什么进行双亲委派?
双亲委派机制的好处主要有以下几点:
- **避免类的重复加载:**在 JVM 中,每个类都由一个唯一的全限定名和一个对应的类加载器确定,类加载器根据全限定名和类路径来确定类的位置。因此,在一个 JVM 实例中,如果有两个类加载器分别加载了同一个类,JVM 会认为这两个类是不同的,从而导致类型转换异常等问题。通过双亲委派机制,父类加载器在加载类之前会先委托给自己的父类加载器去加载,从而保证一个类在 JVM 中只会有一份,并且由其父类加载器所加载。
- **安全性考虑:**Java 核心类库(如 java.lang 包下的类)都是由启动类加载器加载的,其他的类都是由其它类加载器加载的。这样,我们就可以保证 Java 核心类库的安全性,因为不同的应用程序无法改变这些类的实现。另外,也可以在类加载过程中做一些安全性检查。
- **模块化开发:**在实际应用中,我们经常需要在一个程序中使用多个第三方库,这些库可能会存在同名类。如果使用了双亲委派机制,就可以保证不同的类加载器只会加载自己的类,从而避免了类名冲突的问题。

四、垃圾回收机制
Java 虚拟机的内存,就不得不谈到 Java 虚拟机的垃圾回收机制。因为内存总是有限的, 我们需要一个机制来不断地回收废弃的内存,从而实现内存的循环利用,这样程序才能正常地运转下去。
比起 Java 虚拟机的内存结构有《Java 虚拟机规范》规定,垃圾回收机制并没有具体的规范约束。所以很多时候不同的虚拟机有不同的实现方式,下面所说的垃圾回收都是以 HotSpot 虚拟机为例。
4.1 判断谁是垃圾?
联想日常生活中,如果一个东西经常没被使用,那么这个对象可以说就是垃圾。在 Java 中也是如此,如果一个对象不可能再被引用,那么这个对象就是垃圾,应该被回收。
根据这个思想,我们很容易想到使用引用计数的方法来判断垃圾。在一个对象被引用时加一, 被去除引用时减一,这样我们就可以通过判断引用计数是否为零来判断一个对象是否为垃圾。这种方法我们一般称之为「引用计数法」。
这种方法虽然简单,但是其存在一个致命的问题,那就是循环引用。
A 引用了 B,B 引用了 C,C 引用了 A,它们各自的引用计数都为 1。但是它们三个对象却从未被其他对象引用,只有它们自身互相引用。从垃圾的判断思想来看,它们三个确实是不被其他对象引用的,但是此时它们的引用计数却不为零。这就是引用计数法存在的循环引用问题。

而现今的 Java 虚拟机判断垃圾对象使用的是:GC Root Tracing(可达性分析)算法。其大概的过程是这样:从 GC Root 出发,所有可达的对象都是存活的对象,而所有不可达的对象都是垃圾。

简单地说,**GC Root 就是经过精心挑选的一组活跃引用,这些引用是肯定存活的。**那么通过这些引用延伸到的对象,自然也是存活的。
4.2 如何回收垃圾
垃圾回收算法简单地说有三种算法:标记清除算法、标记复制算法、标记压缩(标记整理) 算法。
4.2.1 标记--清除算法
从名字可以看到其分为两个阶段:标记阶段和清除阶段。一种可行的实现方式是,在标记阶段,标记所有由 GC Root 触发的可达对象。此时,所有未被标记的对象就是垃圾对象。之后在清除阶段,清除所有未被标记的对象。
标记清除算法最大的问题就是空间碎片问题。如果空间碎片过多,则会导致内存空间的不连续。虽说大对象也可以分配在不连续的空间中,但是效率要低于连续的内存空间。

4.2.2 标记--复制算法
复制算法的核心思想是将原有的内存空间分为两块,每次只使用一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中。之后清除正在使用的内存块中的所有对象,之后交换两个内存块的角色,完成垃圾回收。
该算法的缺点是要将内存空间折半,极大地浪费了内存空间。

4.2.3 标记--压缩算法
标记压缩算法可以说是标记清除算法的优化版,其同样需要经历两个阶段,分别是:标记结算、压缩阶段。在标记阶段,从 GC Root 引用集合触发去标记所有对象。在压缩阶段,其则是将所有存活的对象压缩在内存的一边,之后清理边界外的所有空间。
这样,既避免了内存碎片,也不存在堆空间浪费的说法了。但是,每次进行垃圾回收的时候, 都要暂停所有的用户线程,特别是对老年代的对象回收,则需要更长的回收时间,这对用户体验是非常不好的。

标记清除算法虽然会产生内存碎片,但是不需要移动太多对象,**比较适合在存活对象比较多的情况。**而复制算法虽然需要将内存空间折半,并且需要移动存活对象,但是其清理后不会有空间碎片,**比较适合存活对象比较少的情况。**而标记压缩算法,则是标记清除算法的优化版,减少了空间碎片。
4.3 分代思想
所谓分代算法,就是**根据 JVM 内存的不同内存区域,采用不同的垃圾回收算法。**例如对于存活对象少的新生代区域,比较适合采用复制算法。这样只需要复制少量对象,便可完成垃圾回收,并且还不会有内存碎片。而对于老年代这种存活对象多的区域,比较适合采用标记压缩算法或标记清除算法,这样不需要移动太多的内存对象。
4.4 分区思想
分代思想按照对象的生命周期长短将其分为了两个部分(新生代、老年代),但 JVM 中其实还有一个分区思想,即将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收,这种算法的好处是可以控制一次回收多少个区间,可以较好地控制 GC 时间。