一、Java 虚拟机
Java 虚拟机(Java Virtual Machine, JVM)是运行 Java 字节码的虚拟机。它是Java平台的核心组件之一,使得Java程序具有 一次编写,到处运行(Write Once, Run Anywhere) 的特性。
JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言 一次编写,到处运行 的关键所在。
二、类加载机制与字节码
1. 字节码
计算机无法直接执行Java代码,必须通过Java虚拟机(JVM)来运行。首先,Java源代码需要经过编译器编译成Java字节码(.class文件)。然后,JVM加载并执行这些字节码。字节码是JVM的中间语言,它使得Java程序能够在不同的硬件和操作系统平台上运行,只要该平台上有相应的JVM实现。
Java代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的JVM虚拟机去读取执行,从而实现一次编写,到处运行的目的。
许多基于JVM的编程语言,如Groovy、Scala、Koltin等编程语言,也被JVM支持。
2. 类加载机制
2.1 类加载过程
类加载过程是Java虚拟机(JVM)将类的字节码加载到内存中,并将其转换为可供JVM运行的类对象的过程。以下是类加载过程的详细步骤:
(1)类加载过程三个阶段
类加载过程主要分为三个阶段:加载、连接和初始化,其中连接阶段可以分为 验证、准备和解析三个子阶段。
-
加载(Loading):通过类的全限定名来获取这个类的二进制字节流;将字节流转化为方法区的运行时数据结构;在内存中生成一个代表这个类的 java.lang.Class 对象,作为这个类在方法区的访问入口。
-
连接(Linking) :连接阶段又分为三个子阶段:验证、准备和解析。
- 验证(Verification):验证的作用是保证加载的字节码是合法的。先后验证字节码文件格式(字节码版本号、文件结构等)、字节码指令(检查指令的类型转换是否合法、操作数栈的深度是否合理等)是否符合JVM规范;验证类的元数据信息(类名、字段名、方法名)是否符合Java语言规范;验证类的符号引用是否可以被解析为直接引用(检查类的父类、接口、字段和方法是否存在等)。
- 准备(Preparation):准备的作用是 正式为类的静态变量分配内存,并设置默认初始值和代码中声明的初始值。
- 解析(Resolution):解析的作用是 将常量池内的符号引用替换为直接引用的过程。将类或接口的符号引用转换为直接引用。
-
初始化(Initialization):执行类构造器
<clinit>()
方法,初始化类的静态变量和静态代码块,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
类加载过程是Java程序运行的基础,确保了类的字节码能够被正确加载、验证、准备和初始化,为程序的执行提供了必要的环境和资源。
(2)类加载过程的触发时机
类加载过程通常在以下情况下触发:
- 创建类的实例时(例如使用
new
关键字)。 - 访问类的静态变量或静态方法时。
- 反射调用时(例如使用
Class.forName()
方法)。 - 初始化子类时,会先触发父类的初始化。
- Java虚拟机启动时,会加载核心类库和应用程序的主类.
2.2 类加载器
类加载器是负责加载类字节码到JVM中的组件,主要有以下几种:
- 启动类加载器(Bootstrap ClassLoader):由C++编写,负责加载JVM核心类库(如
rt.jar
)和扩展类库(如ext
目录下的jar文件)。 - 扩展类加载器(Extension ClassLoader):由Java编写,负责加载JVM扩展目录中的类库。
- 应用类加载器(Application ClassLoader):也称为系统类加载器,由Java编写,负责加载应用程序类路径(如
CLASSPATH
环境变量指定的路径)中的类库。 - 用户自定义类加载器:可以通过继承
ClassLoader
类来实现自定义的类加载器,以满足特定的加载需求。
2.3 双亲委派模型(Parent Delegation Model)
双亲委派模型(Parent Delegation Model)是Java虚拟机(JVM)中类加载器加载类时的一种机制。它规定了类加载器之间如何协作,确保类的唯一性和安全性。
(1)双亲委派模型工作原理
- 当一个类加载器尝试加载某个类时,它首先会将请求委托给它的父类加载器去加载。父类加载器会继续委托给它的父类加载器,这个过程会递归向上进行,直到到达顶层的 应用类加载器(Application ClassLoader) 扩展类加载器(Extension ClassLoader)、启动类加载器(Bootstrap ClassLoader)。
- 如果父类加载器无法完成加载请求(即该类不在父类加载器的搜索范围内),子类加载器才会尝试自己去加载该类。
- 一旦某个类加载器成功加载了某个类,那么这个类就由该类加载器负责,其他类加载器不再参与这个类的加载过程。
(2)双亲委派模型的实现
双亲委派模型的实现主要依赖于 java.lang.ClassLoader 的 loadClass(String name, boolean resolve) 方法。以下是该方法的简化实现逻辑:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {// 首先检查是否已经加载过该类Class<?> c = findLoadedClass(name);if (c == null) {try {// 委托给父类加载器加载if (parent != null) {c = parent.loadClass(name, false);} else {// 如果没有父类加载器,则由启动类加载器加载c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {// 父类加载器无法加载时,子类加载器尝试加载c = findClass(name);}}if (resolve) {// 解析类resolveClass(c);}return c;}
}
三、JVM内存模型与垃圾回收
1. JVM 内存模型
Java虚拟机(JVM)内存模型定义了Java程序在运行时如何管理和操作内存资源。它详细描述了Java程序在执行过程中,数据如何在不同的内存区域之间流动和存储。以下是JVM内存模型的主要组成部分:
-
程序计数器(Program Counter Register):存储当前线程执行的字节码的行号指示器。它是一个小的内存区域,用于指示当前线程正在执行的字节码指令的位置。
程序计数器是线程私有的,每个线程都有自己的程序计数器,是线程切换后能够恢复执行的关键。当线程切换时,程序计数器会保存当前线程的执行位置,以便线程恢复执行时能够从正确的位置继续执行. -
虚拟机栈(Virtual Machine Stack):存储局部变量、方法调用的上下文信息和部分结果。Java栈由栈帧(Stack Frame)组成,每个栈帧对应一个方法调用。栈帧结构包括 存储方法的参数和局部变量的局部变量表、用于存储字节码指令的操作数和中间结果的操作数栈、存储对当前方法所属类的运行时常量池的引用,以及对被调用方法的符号引用的动态链接信息、存储方法返回后的执行位置的方法返回地址。
Java栈是线程私有的,每个线程都有自己的Java栈。栈帧的创建和销毁与方法的调用和返回同步进行。 -
本地方法栈(Native Method Stack):为JVM使用到的Native方法(本地方法)提供服务。本地方法栈与Java栈类似,但用于执行非Java代码(如C/C++代码)。
本地方法栈是线程私有的,其实现和具体平台有关,其结构和功能与Java栈类似,但主要用于支持本地方法的执行。 -
堆(Heap):存储对象实例和数组。堆是JVM中最大的一块内存区域,是垃圾回收器管理的主要区域。
堆是所有线程共享的内存区域。堆中对象的内存分配和回收是动态进行的,由垃圾回收器负责。
JVM中堆空间可以分成 新生代( Young Generation)、老年代( Old Generation )、永久代(Permanent Generation) 三个大区。新生代用于存储新创建的对象,老年代用于存储经过多次垃圾回收仍然存活的对象。新生代可以划分为三个区,Eden区,From Survivor区、To Survivor区两个幸存区。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。- 新生代中一般保存新出现的对象,所以每次垃圾收集时都发现大批对象死去,只有少量对象存活,便采用了复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
- 老年代中一般保存存活了很久的对象,他们存活率高、没有额外空间对它进行分配担保,就必须采用标记-清理或者标记-整理算法。
- 永久代就是JVM的方法区。在这里都是放着一些被虚拟机加载的类信息,静态变量,常量等数据。这个区中的东西比老年代和新生代更不容易回收。从 JDK 1.8 开始,永久代已经被移除了,取而代之的是元空间(Meta Space) ,它和服务器的内存相关联。
-
方法区(Method Area):存储类的结构信息,如类名、字段信息、方法信息、常量池等。
方法区是所有线程共享的内存区域。方法区的大小是有限的,如果超出限制,会抛出OutOfMemoryError
异常。
HotSpot 虚拟机对虚拟机规范中方法区的实现方式包括永久代以及元空间,永久代是 JDK 1.8 之前的方法区实现,元空间是JDK 1.8 及以后方法区的实现。
JVM内存模型通过这些内存区域的协同工作,为Java程序的运行提供了必要的内存支持和管理机制,确保程序能够高效、安全地执行.
2. 垃圾回收
2.0 STW(Stop The World)机制
STW(Stop The World)机制,是指 JVM 在执行垃圾回收(GC)时,会暂停所有正在执行的应用程序线程,没有任何响应,这个停顿称为STW。
STW让垃圾回收器能够在一个稳定的环境中执行内存回收工作,以确保内存数据的一致性和完整性。但同时也会影响应用程序性能。
2.1 判断对象被垃圾回收的方法
(1)引用计数法(Reference Counting)
给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。因此 Java 虚拟机不使用引用计数算法。
(2)局部可达算法(Reachability Analysis)
维护一个 GC Roots Set 集合,存放着所有的 GC Roots ,通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。
Java 虚拟机使用局部可达算法来判断对象是否可被回收。
在 Java 中 GC Roots 一般包含:虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区中类静态属性引用的对象、方法区中的常量引用的对象。
如上图,通过 GC Roots 作为起始点进行搜索,Object1、Object3、Object5、Object6 、Object7 对象是能够到达到的对象,局部可达算法将这些对象判断为存活对象;而Object2、Object4 对象是不能到达到的对象,局部可达算法认为这些对象可被回收。
2.2 垃圾回收算法
垃圾回收(Gabage Collection,GC )功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。
-
**标记-清除算法(Tracing Collector) **:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
-
**标记-整理算法(Compacting Collector) **:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
-
**复制算法(Copying Collector) **:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
-
**分代算法(Adaptive Collector) **:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。
2.3 垃圾回收机制
- Minor GC:是新生代GC,指的是发生在新生代的垃圾收集动作。由于Java对象大都是朝生夕死的,所以Minor GC非常频繁,一般回收速度也比较快。(一般采用复制算法回收垃圾)
- Major GC:是老年代GC,主要针对老年代进行回收,但不一定只回收老年代,通常执行Major GC会连着Minor GC一起执行。Major GC的速度要比Minor GC慢的多。(可采用标记清楚法和标记整理法)
- Full GC:是清理整个堆空间,包括年轻代和老年代
Minor GC 的触发条件:
当Eden区空间不足时,JVM会触发一次Minor Gc,将Eden区和一个Survivor区中的存活对象移动到另一个Survivorx或老年代(Old Generation)
Major GC 的触发条件:
当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快,可能会触发Major GC。
Full GC 的触发条件:
- 调用System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。 - 未指定老年代和新生代大小,堆伸缩时会产生 Full GC,所以一定要配置-Xmx、-Xms
- 老年代空间不足
2.4 垃圾收集器
HotSpot 虚拟机中包含 9 个垃圾收集器,包括 Serial 收集器、ParNew 收集器、Parallel Scavenge 收集器、Serial Old 收集器、Parallel Old 收集器、MS 收集器、G1 收集器、ZGC 收集器、Shenandoah GC 收集器。
垃圾收集器的几个概念:
- 单线程与多线程:
- 单线程指的是垃圾收集器使用一个线程进行收集;
- 多线程指的是垃圾收集器使用多个线程进行收集。
- 串行(Serial)与并行(Parallel):
- 串行指的是垃圾收集器与用户程序交替执行,因此在执行垃圾收集的时候需要停顿用户程序;
- 并行指的是垃圾收集器和用户程序同时执行,因此在执行垃圾收集的时候不必停顿用户程序。
(1)Serial 收集器
从JDK 1.3之前开始使用。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作;以串行的方式执行,垃圾收集器与用户程序交替执行。
Serial 收集器采用复制算法。
Serial 回收的范围是新生代。
它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
Serial 收集器是 HotSpot 中 Client 模式下默认的新生代垃圾收集器
(2)ParNew 收集器
从JDK 1.4开始引入,是Serial收集器的多线程版本。
它是多线程的收集器,同时使用一个线程进行垃圾收集工作;以串行的方式执行,垃圾收集器与用户程序交替执行。
ParNew 收集器采用复制算法。
ParNew 回收的范围是新生代。
ParNew 收集器是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。
(3)Parallel Scavenge 收集器
从JDK 1.5开始引入,是Serial收集器的多线程版本。
它是多线程的收集器,同时使用一个线程进行垃圾收集工作;以串行的方式执行,垃圾收集器与用户程序交替执行。
Parallel Scavenge 收集器采用复制算法。
Parallel Scavenge 回收的范围是新生代。
其它收集器关注点是用户线程的停顿时间(提高用户体验),而Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量(高效率的利用 CPU),它被称为吞吐量优先收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略(GC Ergonomics),把内存管理优化交给虚拟机去完成也是一个不错的选择。
(4)Serial Old 收集器
从JDK 1.3之前开始使用,是Serial收集器的老年代版本。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作;以串行的方式执行,垃圾收集器与用户程序交替执行。
Parallel Scavenge 收集器采用标记-整理算法。
Serial Old 回收的范围是老年代。
(5)Parallel Old 收集器
从JDK 1.6开始引入,是Parallel Scavenge收集器的老年代版本。
它是多线程的收集器,同时使用一个线程进行垃圾收集工作;以串行的方式执行,垃圾收集器与用户程序交替执行。
Parallel Old 收集器采用标记-整理算法。
Parallel Old 回收的范围是老年代。
其它收集器关注点是用户线程的停顿时间(提高用户体验),而 Parallel Old 收集器的目标是达到一个可控制的吞吐量(高效率的利用 CPU),它被称为吞吐量优先收集器。
(6)CMS (Concurrent Mark Sweep)收集器
从JDK 1.5开始引入。在JDK 9中被标记为废弃,并在JDK 14中被删除
它是多线程的收集器,同时使用一个线程进行垃圾收集工作;以并行的方式执行,垃圾收集器与用户程序交替执行。
CMS 收集器采用标记-清除算法。
CMS 回收的范围是老年代。
以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
CMS 收集器工作过程分为四个步骤:
- 初始标记: 记录下与 GC Roots 直接关联的对象,速度很快,需要暂停其他线程。
- 并发标记: 用一个闭包结构记录可达对象,由于用户程序在继续运行,对象的引用关系可能会发生变化,所以这个闭包结构不能保证包含当前所有的可达对象,故 GC 线程无法保证可达性分析的实时性。耗时较长,不需要暂停其他线程。
- 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要暂停其他线程。
- 并发清除: 对未标记的区域做清扫,不需要暂停其他线程。
(7)G1 (Garbage First)收集器
从JDK 1.7u4版本开始引入。
它是多线程的收集器,同时使用一个线程进行垃圾收集工作;以并行的方式执行,垃圾收集器与用户程序交替执行。
G1 收集器采用标记-整理算法。
G1回收的范围是**整个Java堆(包括新生代,老年代) **。
G1垃圾收集器通过将堆内存划分为多个大小相等的独立区域(Region),打破了传统的新生代和老年代的物理隔离。
每个Region可以独立进行垃圾回收,这种设计提供了极大的灵活性,并使得可预测的停顿时间模型成为可能。G1通过记录每个Region的垃圾回收时间和回收后获得的空间(这些数据基于历史回收经验),并维护一个优先级列表,每次根据允许的收集时间,优先回收最有价值的Region。每个Region都有一个Remembered Set,用于记录该Region中对象引用的对象所在的Region。通过Remembered Set,可以在进行可达性分析时避免全堆扫描.
G1的垃圾回收过程包括以下几个阶段:
- 初始标记:记录下与 GC Roots 直接关联的对象,速度很快,需要暂停其他线程。
- 并发标记:用一个闭包结构记录可达对象,由于用户程序在继续运行,对象的引用关系可能会发生变化,所以这个闭包结构不能保证包含当前所有的可达对象,故 GC 线程无法保证可达性分析的实时性。耗时较长,不需要暂停其他线程。
- 最终标记:为了修正并发标记期间由于用户程序继续运行导致的标记变化,虚拟机会将这段时间对象的变化记录在线程的Remembered Set Logs中。最终标记阶段需要将Remembered Set Logs的数据合并到Remembered Set中。虽然这个阶段需要暂停用户线程,但可以并行执行,以提高效率。
- 筛选回收:首先对各个Region中的回收价值和成本进行排序,根据用户设定的GC停顿时间目标来制定回收计划。这个阶段可以与用户程序并发执行,但由于只回收部分Region,因此停顿时间是可控的。暂停用户线程可以显著提高垃圾收集的效率,因为可以集中处理回收任务,减少并发操作的复杂性。
(8)ZGC(The Z Garbage Collector) 收集器
ZGC 收集器从JDK 11开始引入
它是多线程的收集器,同时使用一个线程进行垃圾收集工作;以并行的方式执行,垃圾收集器与用户程序交替执行。
ZGC 收集器采用标记-整理算法。
ZGC 回收的范围是整个Java堆(包括新生代,老年代) 。
它是一款低延迟垃圾收集器,并且支持大堆内存,能够处理从几兆字节到几TB的堆,最大支持16TB。
(9)Shenandoah GC 收集器
Shenandoah GC 收集器从JDK 12开始引入
它是多线程的收集器,同时使用一个线程进行垃圾收集工作;以并行的方式执行,垃圾收集器与用户程序交替执行。
Shenandoah GC 回收的范围是J整个ava堆(包括新生代,老年代) 。
Shenandoah GC 收集器采用标记-整理算法。
它是一款低延迟垃圾收集器,并且适应性强,能够在不同的堆大小和应用场景下保持稳定的性能。