Java虚拟机 JVM
JVM启动入口:JLI_Launch
函数
以OPENJDK8源码GitHub - openjdk/jdk at jdk8-b120为例,虚拟机的启动入口在jdk/src/share/bin/java.c
的JLI_Launch
函数,整个流程分为:
- 配置JVM装载环境
- 解析虚拟机参数
- 设置线程栈大小
- 执行
JavaMain
方法
JLI_Launch
函数:
1 | int |
1 | // 初始化操作 |
JVM的启动执行过程:
JNI调用本地方法
JNI(Java Native Interface),Java本地接口。它允许在JVM内运行的Java代码与其他编程语言编写的程序和库进行交互。
操作系统:Windows 11
JDK11
MYSYS2安装C++环境(安装 MinGW-W64 及配置环境变量)
以让C语言程序帮助实现Java程序的a+b运算功能为例:
创建一个本地方法:
1
2
3
4
5
6
7
8
9
10package com.hunter.demo;
public class Main {
public static void main(String[] args) {
System.out.println(sum(1, 2));
}
// 本地方法 native关键字
public static native int sum(int a, int b);
}生成位于jni目录下的C头文件
1
javac -h .\jni -classpath .\src\main\java\ -d .\jni .\src\main\java\com\hunter\demo\Main.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/* DO NOT EDIT THIS FILE - it is machine generated */
/* Header for class com_hunter_demo_Main */
extern "C" {
/*
* Class: com_hunter_demo_Main
* Method: sum
* Signature: (II)I
*/
JNIEXPORT jint JNICALL Java_com_hunter_demo_Main_sum
(JNIEnv *, jclass, jint, jint);
}CLion中新建一个C++项目。
放入刚刚生成的头文件
com_hunter_demo_Main.h
,并添加到CMake Project
:导入JDK文件夹中jni相关头文件,将C++项目中自动生成的
main.cpp
文件改名为与.h
头文件同名:com_hunter_demo_Main.cpp
1
2
3
4
5
6
7
8
9
10cmake_minimum_required(VERSION 3.28)
project(jniTest)
# 导入JDK文件夹中jni相关头文件
include_directories("C:\\Program Files\\jdk-11.0.0.1\\include")
include_directories("C:\\Program Files\\jdk-11.0.0.1\\include\\win32")
set(CMAKE_CXX_STANDARD 17)
add_executable(jniTest com_hunter_demo_Main.cpp
com_hunter_demo_Main.h)编写
com_hunter_demo_Main.cpp
文件1
2
3
4
5
6
// 方法头 从上述生成的.h头文件中复制,补充方法体
JNIEXPORT jint JNICALL Java_com_hunter_demo_Main_sum(JNIEnv * env, jclass clazz, jint a, jint b) {
return a + b;
}将cpp编译为动态链接库(MacOS下生成
.dylib
文件,Windows下生成.dll
文件,Linux下生成.so
文件)1
gcc .\com_hunter_demo_Main.cpp -I "C:\Program Files\jdk-11.0.0.1\include" -I "C:\Program Files\jdk-11.0.0.1\include\win32" -shared -o test.dll -lstdc++
在一开始的Java类中,通过绝对路径加载动态链接库,就能顺利执行main方法。
1
2
3
4
5
6
7
8
9
10
11public class Main {
static {
System.load("C:\\Users\\Hunter\\CLionProjects\\jniTest\\test.dll");
}
public static void main(String[] args) {
System.out.println(sum(1, 2));
}
public static native int sum(int i, int i1);
}
JVM内存管理
内存区域划分
JVM在运行时,内存区域如下划分:
程序计数器
程序计数器可以看作当前线程所执行字节码的行号指示器。
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。
- 在多线程情况下,每条线程都需要一个程序计数器来记录当前线程执行的位置,从而线程被切换回来时,能够恢复到正确的执行位置。
程序计数器是唯一一个不会出现内存溢出的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而结束,是线程私有的。
虚拟机栈
每个方法被执行的时候,JVM都会同步创建一个栈帧,栈帧中包括局部变量表、操作数栈、动态连接、方法出口。
局部变量表。
局部变量表就是方法中的局部变量。实际上局部变量表在class文件中就已经定义好了。
操作数栈。
动态链接。
当方法中需要调用其他方法时,从运行时常量池中找到指向对应方法的符号引用,再将符号引用转换为直接引用,进而调用对应的方法,这个过程就是动态链接。
方法出口。
抛出异常或正常返回。
本地方法栈
本地方法栈和虚拟机栈的作用相似,为执行本地方法服务。
堆
堆是整个Java应用程序共享的区域,也是整个虚拟机最大的一块内存空间。它用来存放和管理对象和数组。
方法区
方法区也是整个Java应用程序共享的区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。可以大致分为两个部分:
类信息表。
存放类的版本、字段、方法、接口
运行时常量池。
存放编译时生成的常量池表数据(各种字面量和符号引用)。
直接内存
直接内存不受JVM管控,本质上是JVM通过JNI的方式在本地内存上进行分配的内存。直接内存不受到Java堆大小的限制,但是依然受到本机最大内存的限制。
JDK 1.4加入的NIO,引入了基于通道和缓存区的IO方式,可以直接使用Native函数库分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中提高性能,避免了Java堆和Native堆之间来回复制数据。
垃圾回收机制
对象存活判定算法
引用计数法
每个对象都包含一个引用计数器,每当有一个地方引用此对象,引用计数+1,当引用失效,引用计数-1(离开了局部变量的作用域、引用被设定为null
)。当引用计数为0,表示此对象不可能再被使用。
但是,如果对象之间循环引用:
1 | ublic class Main { |
在testA和testB被赋值为null后,实际对应的对象不可能再被得到,但由于两个对象存在循环引用的情况,各自的引用计数器的值永远会是1。所以引用计数法不是最好的解决方案。
可达性分析算法
可达性分析算法采用了类似树结构的搜索机制。
每个对象的引用都有机会成为树的根节点(GC Roots):
- 虚拟机栈的栈帧中的局部变量表中引用的对象
- 本地方法栈中JNI引用的对象
- 方法区中类的静态成员变量引用的对象
- 方法区中常量池中引用的对象
- 被同步锁(synchronized关键字)持有的对象
- 虚拟机内部需要用到的对象
可达性分析算法从这些节点开始,根据引用关系向下搜索,搜索的路径称为引用链(Reference Chain)。如果某个对象到GC Roots间没有任何引用链相连,那么这个对象就不可能再被使用。这样的对象就可以被回收。
垃圾回收算法
分代收集机制
Java虚拟机将堆内存划分为:
新生代 Young Generation
Eden区、Survivor区 (From和To)
老年代 Old Generation
永久代 Permanent Generation
MetaSpace
在HotSpot虚拟机中,新生代被划分为3块,一块较大的Eden区和两块较小的Survivor空间,**默认比例是8:1:1
**。老年代的GC频率相对较低,永久代一般存放类信息等(方法区)。
所有新创建的对象,优先在Eden区分配。**在1次GC后,没有被回收的对象会进入到Survivor区中的From区,并且对象有了初始年龄
1
**,最后From和To发生一次交换,存放对象的From区变成To区。再进行1次GC,操作与上述相同。此时由于To区中已经存在对象,需要对其中的对象进行年龄判定。如果当前年龄增加到一定阈值(默认15,对象头用4位来保存年龄,最大就是15),会晋升到老年代,否则移动到From区,并且年龄+1。最后,交换From和To区。
大对象(需要大量连续内存空间的对象,如字符串、数组)直接进入老年代,减少新生代的垃圾回收频率和成本。
垃圾收集分为Partial GC和Full GC:
Minor GC/Young GC:
在Eden区容量已满时,对新生代进行垃圾回收。
Major GC/Old GC:
对老年代进行垃圾回收。
Mixed GC:
对整个新生代和部分老年代进行垃圾回收。目前只有G1收集器有这种行为。
Full GC:
回收整个Java堆和方法区。触发条件:
- 有新的晋升到老年代的对象,老年代剩余空间不足以存放这些对象。
- 新生代GC之后,存活的对象超过了老年代剩余空间。
- 手动调用
System.gc()
方法。
空间分配担保
确保在Minor GC之前,老年代本身还有容纳新生代所有对象的剩余空间。
标记-清除算法
标记出所有不需要回收的对象,再依次回收掉没被标记的对象。
优点:操作简单。
缺点:
- 如果内存中存在大量对象,就可能需要大量标记,标记和清除的效率都不高。
- 容易产生大量不连续的内存碎片。
标记-复制算法
该算法将内存分为大小相同的2块区域,每次只使用其中1块。当1块用完后,将还存活的对象复制到另一块,一次性清空当前区域。使得每次的内存回收都是一半。
优点:解决了内存空间碎片的问题。适用于新生代内存。
缺点:可用内存变少,不适合老年代。
标记-整理算法
标记过程和标记-清除算法一样,但后续是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。由于多出了整理这一步,效率不高,甚至由于需要修改对象在内存中的位置,程序必须要暂停,适合老年代这种回收频率不高的场景。
垃圾收集器
Serial 收集器
Serial(串行)收集器是历史最悠久的垃圾收集器。它的单线程不仅意味着只会使用一条垃圾收集线程去完成垃圾收集工作,而且在进行垃圾收集工作时,必须暂停其他所有的工作线程(Stop The World),直到收集结束。
新生代采用标记-复制算法,老年代采用标记-整理算法。
- 与其他收集器相比,它简单而高效。
- 对于运行在Client模式下的虚拟机(一些桌面级图形化界面应用程序)来说不错。
ParNew 收集器
ParNew其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为(控制参数、收集算法、回收策略)和Serial收集器完全一样。
它是许多运行在Server模式下的虚拟机新生代收集器的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器)配合工作。
Parallel Scavenge / Parallel Old 收集器
Parallel Scavenge收集器是使用标记-复制算法的新生代多线程收集器,关注点是吞吐量。
$吞吐量=\frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}=\frac{运行用户代码时间}{CPU总消耗时间}$
Parallel Scavenge收集器有一个参数-XX:+UseAdaptiveSizePolicy
,当使用这个参数之后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整相应参数以提供最合适的停顿时间或最大吞吐量。
Parralel Old收集器是Parallel Scavenge收集器的老年代版本,基于标记-整理算法实现。
JDK8采用的就是 Parallel Scavenge + Parallel Old 的垃圾回收方案。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿为目标的收集器。它采用了标记-清除算法,非常符合在注重用户体验的应用上使用。
CMS实现了让垃圾收集线程与用户线程基本上同时工作。
整个运行过程分为四个步骤:
- 初始标记(暂停所有其他线程,记录直接与GC Roots相连的对象,速度很快)
- 并发标记(同时开启GC和用户线程,用一个闭包结构去记录可达对象)
- 重新标记(暂停所有其他线程,修正并发标记期间因为用户程序继续运行而导致标记变动的记录)
- 并发清除 (开启用户线程,同时GC线程开始对未标记的区域做清扫)
优点:并发收集、低停顿。
缺点:对CPU资源敏感;无法处理浮动垃圾,触发Full GC的概率更高;使用标记-清除算法导致产生大量空间碎片,触发Full GC的概率更高。
浮动垃圾:在CMS的并发标记和并发清除阶段,用户线程在运行,自然会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
不过自从G1收集器问世之后,CMS收集器不再推荐使用了。
Garbage First (G1)收集器
G1是面向服务器的垃圾收集器,主要针对配有多颗处理器以及大内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。在JDK9时,取代了JDK8默认的 Parallel Scavenge(新生代) + Parallel Old (老年代) 的回收方案。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把Java堆划分为2048个大小相等的独立区域,每一个区域称之为Region。每个Region的大小可以通过参数-XX:G1HeapRegionSize
设定,取值范围为1MB~32MB,且应为2的N次幂。
每一个Region
都可以根据需要,自由决定扮演哪个角色(Eden、Survivor和老年代),收集器会根据对应的角色采用不同的回收策略。此外,G1收集器还存在一个Humongous区域,它专门用于存放大对象(一般认为大小超过了Region容量一半的对象为大对象)新生代、老年代在物理上,不再是一个连续的内存区域,而是到处分布的。
G1的整个运行过程分为4个步骤:
初始标记
暂停所有其他线程,记录直接与GC Roots相连的对象,并修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。
并发标记
同时开启GC和用户线程,用一个闭包结构去记录可达对象。
最终标记
暂停所有其他线程,用于处理并发标记阶段漏标的那部分对象。
筛选回收
暂停所有其他线程,更新Region的统计数据,对各个Region的回收价值(耗时+空间大小)进行排序,根据用户期望的停顿时间来指定回收计划。可以自由选择任意多个Region构成回收集,把决定回收的Region中的存活对象复制到空Region中,再清理掉整个旧Region。(G1从整体来看,是基于标记-整理算法实现的收集器;从局部来看,是基于标记-复制算法实现的)
元空间
JDK8之后,Hotspot虚拟机不再使用永久代,而是采用了元空间。类的元信息被存储在元空间中。元空间使用与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。
引用类型
强引用
1 | Object o = new Object(); |
如果方法中存在这样的强引用类型,需要回收其所指向的对象,要么方法结束运行,要么引用连接断开,否则被引用的对象无法被判定为可回收。内存空间不足时,JVM宁愿抛出OOM错误。
软引用
如果内存空间不足,就会回收这种对象。可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止OOM等问题。可以用来实现内存敏感的高速缓存。
1 | // 软引用写法 |
软引用还存在一个带引用队列的构造方法:
1 | ReferenceQueue<Object> queue = new ReferenceQueue<>(); |
执行上述代码,得到如下结果。可知,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
弱引用
与软引用的区别在于,只具有弱引用的对象具有更短的生命周期。不管内存空间是否足够,都会回收它的内存。
1 | public static void main(String[] args) { |
可以看到弱引用对象直接被回收了。
虚引用 phantom
虚引用相当于没有引用,在任何时候都可能被垃圾回收。
1 | public class PhantomReference<T> extends Reference<T> { |
虚引用只能使用带引用队列的构造方法,主要用于对象被回收时接收通知。
类加载机制
类加载过程
类加载过程分为加载、链接、初始化。链接又可分为校验、准备、解析。
加载
加载作为第一步,主要完成下面3件事:
- 通过全类名获取定义此类的二进制字节流
- 类加载器将字节流代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的Class对象,作为方法区数据的访问入口
链接
- 校验阶段相当于对加载的类进行一次规范校验。
- 准备阶段为类变量分配内存,并为一些字段设定系统规定的初始值。
- 解析阶段将常量池表内的符号引用替换为直接引用,放入运行时常量池。
初始化
从初始化开始,类中的Java代码部分才会开始执行。例如类中存在一个静态成员变量被赋值,或者存在一个静态代码块,就会自动生成一个<clinit>
方法进行赋值操作。
类加载器
一个类可以由不同的类加载器加载,并且,不同的类加载器加载的出来的类,即使来自同一个Class文件,也是不同的,只有两个类来自同一个Class文件并且是由同一个类加载器加载的,才能判断为是同一个。默认情况下,所有的类都是由JDK自带的类加载器进行加载。
JVM中内置了3个类加载器:
BootstrapClassLoader 启动类加载器
主要用来加载JDK内部的核心类库、-Xbootclasspath参数指定路径下的所有类。
ExtensionClassLoader 扩展类加载器 (java 9 被改名为平台类加载器(platform class loader))
主要负责加载
%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。AppClassLoader 应用程序类加载器
面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
双亲委派模型
双亲委派模型总结起来就是两句话:
自底向上查找判断类是否被加载,自顶向下尝试加载类。
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则尝试加载(每个父类加载器都会走一遍这个流程
- 类加载器在进行类加载的时候,它首先会把这个请求委派给父类加载器去完成。这样的话,所有的请求最终都会传送到顶层的启动类加载器中。
- 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子类才会尝试自己去加载(调用自己的findClass()方法)
- 如果子类也无法加载这个类,会抛出找不到类的异常。