大家好,我是eleven,这篇文章是学习KuangStudyJVM课程后整理的笔记,在狂神的笔记的基础上丰富了一些内容,也将一些比较模糊的概念更通俗的解释了一下。
JVM
jvm结构图
首先java文件经过编译后形成.class文件,然后class文件装载到类加载器classloader,类加载器将类加载进jvm中。
jvm的运行时数据区域runtime data area 包含:方法区 method area、java堆 heap、java栈 stack、本地方法栈native method stack、程序计数器。
除运行时数据区域runtime data area以外还包括执行引擎、本地方法接口、本地方法库。

垃圾回收
jvm中说的垃圾回收主要指的是方法区(method area)和java堆(heap)中的垃圾。方法区是一个特殊的堆。

jvm调优
因为jvm中产生垃圾的地方基本就是方法区和java堆,所以针对jvm的调优也是针对方法区和java堆,其中99%的调优都是针对java堆,方法区是一个特殊的堆。

类加载器
类的加载过程

也可以通过代码看到,多个实例的class是一个。

类加载器
- 启动类加载器BootStrapClassLoader:也叫基础类加载器,用C++编写,是JVM自带的类加载器,负责加载Java的核心类。该类加载器没有办法直接获取到。
- 扩展类加载器ExtClassLoader:负责加载/jre/lib/ext目录下的jar包。
- 应用程序类加载器AppClassLoader:负责加载java-classpath或-D java.class.path所指定目录下的类和jar包。是我们最常用的加载器。
- 自定义类加载器:通过自定义类加载器,我们可以改变类加载的顺序,可以打破双亲委派机制。
双亲委派机制
当一个类加载器收到类加载请求时,它不会立即尝试加载该类,而是将请求委派给父类加载器 处理,直到最顶层的启动类加载器。只有当父类加载器无法加载该类时,子加载器才会尝试自己加载。
工作流程如下:
- 请求向上委派:类加载器接收到加载请求后,先将请求传递给父类加载器,依次递归直到启动类加载器。
- 尝试向下加载:从启动类加载器开始,依次尝试加载类。如果无法加载(例如类不在其搜索路径中),则将控制权交还给子类加载器,直到类被成功加载或抛出异常。
沙箱安全机制
Java 沙箱安全机制通过多个阶段协作实现代码隔离与权限控制,目的就是确保代码的安全。
沙箱工作流程
一、类加载阶段的安全检查
- 双亲委派机制
当 JVM 需要加载类时:
- 应用程序类加载器 收到请求,委派给扩展类加载器。
- 扩展类加载器 再委派给启动类加载器。
- 启动类加载器 尝试加载核心类(如
java.lang.Object
),若失败则由扩展类加载器尝试,依此类推。 - 最终加载者确定后,将类字节码传递给字节码验证器进行验证。
安全意义 :防止恶意代码伪装成核心类(如自定义java.lang.String
),确保系统类的唯一性。
- 字节码验证
加载的字节码需通过以下验证:
- 格式检查 :确保字节码符合 JVM 规范(如
class
文件结构、常量池有效性)。 - 类型安全检查:验证方法调用、字段访问是否符合类型系统(如整数不能赋值给字符串)。
- 操作数栈验证:确保指令不会导致栈溢出或非法操作(如空指针解引用)。
- 访问权限验证:检查方法和字段访问权限(如是否调用私有方法)。
安全意义:阻止通过篡改字节码执行非法操作的攻击(如缓冲区溢出)。
二、运行时权限控制流程
当代码尝试执行敏感操作(如文件读写、网络连接)时:
- 调用敏感 API :例如,调用
FileInputStream("/secret.txt")
。 - 触发安全检查 :JVM 拦截敏感操作,调用
SecurityManager.checkPermission()
。 - 权限上下文构建
- 当前线程的 AccessControlContext:包含调用栈中所有类的权限集合。
- ProtectionDomain:每个类关联的权限域,由类加载器和代码源(如 URL、证书)决定。
- 权限检查递归
- 从调用栈顶开始,逐层检查每个类的权限。
- 若任一环节缺少所需权限,抛出
SecurityException
。
权限策略配置与动态授权
1. 安全策略文件配置
通过java.policy
文件配置权限,示例:
java
grant codeBase "file:/home/user/app/-" {
permission java.io.FilePermission "/tmp/*", "read,write";
permission java.net.SocketPermission "example.com:80", "connect";
};
- codeBase:指定代码来源(如本地文件、网络 URL)。
- permission:授予的具体权限(如文件读写、网络连接)。
2. 动态授权(特权代码块)
使用AccessController.doPrivileged()
执行特权操作:
java
FilePermission permission = new FilePermission("/tmp/data.txt", "read");
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
// 此代码块拥有额外权限
try (FileInputStream fis = new FileInputStream("/tmp/data.txt")) {
// 执行需要特权的操作
} catch (IOException e) {
e.printStackTrace();
}
return null;
});
创建对象内存分析
创建对象的代码样例:
java
public class Cat {
public static String color= "黄色";
String name;
int age;
public static void main(String[] args) {
Cat xiaomiao = new Cat();
xiaomiao.name = "小猫";
xiaomiao.age = 2;
}
}
在 JVM 中,Cat
类的各部分会被分配到不同的内存区域。以下是详细分析

对象创建操作的执行流程与内存交互
- 类加载阶段 :
- JVM 加载
Cat
类,将类元数据(字段、方法)和静态变量color
存入方法区。 - 字符串常量
"黄色"
存入运行时常量池。
- JVM 加载
- 方法执行阶段 :
- 线程执行
main()
方法,在栈中创建main方法栈帧。 - 执行new Cat()
- 在堆中分配内存,初始化对象(
name=null
、age=0
)。 - 调用构造函数(默认初始化)。
- 将对象引用存入栈帧的局部变量表(
xiaomiao
)。
- 在堆中分配内存,初始化对象(
- 执行赋值语句:
- 通过引用修改堆中对象的字段:
name = "小猫"
、age = 2
。
- 通过引用修改堆中对象的字段:
- 线程执行
JVM关键
native
在java中使用native修饰的方法,都是去调用底层C语言的库。
native方法执行流程:
- 进入本地方法栈
- 调用本地方法接口,通过JNI:Java native interface
JNI的作用:扩展Java的使用,调用不同语言的代码。(最初是为了融合C、C++语言)
因为Java诞生的时候,C和C++非常火,想要立足,就有必要调用C、C++的程序。所以JVM专门开辟了一块标记区域本地方法栈native method stack,专门用来登记native方法,在最终执行的时候通过JNI调用本地方法库中的接口。
我们来看一个native方法:
一个创建并启动线程的代码。
java
public class NativeStu {
public static void main(String[] args) {
new Thread(() -> {}).start();
}
}
继续看一下start()启动线程方法的源码:
java
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
private native void start0();
可以看到最终启动线程的start0()方法就是native的。
方法区
method area:线程共有的,所有定义的信息都存在该区域,比如Class类模板信息,字段,方法,接口等元数据。方便记忆:static、final、Class类模板、常量池都保存在方法区。
程序计数器
程序计数器:Program Counter Register,线程私有。 每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也可以理解为存储即将要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。
栈
线程私有,每个线程在创建时都会分配一个独立的 JVM 栈,数据结构特点:先进后出
JVM 栈中存储的核心内容是栈帧(Stack Frame),每个方法从调用到执行完成的过程,对应着一个栈帧在 JVM 栈中入栈到出栈的过程。所以可以理解一个方法对应一个栈帧
栈的作用
负责程序的运行,生命周期和线程同步。能够保证程序执行的顺序。
线程结束,对应的栈内存也就释放了,main方法也是一个线程,当main方法执行结束,相当于整个程序就结束了。所以对于栈来说,不存在垃圾回收。
栈存储的东西
- 局部变量表(Local Variable Table)
-
存储方法的参数 和方法内部的局部变量,包括:
-
基本数据类型(
boolean
、byte
、short
、char
、int
、long
、float
、double
); -
对象引用(
reference
类型,指向堆中对象的地址或句柄);
- 操作数栈(Operand Stack)
-
作为方法执行的临时数据缓冲区,用于存放计算过程中的中间结果,或作为方法调用时的参数传递区。
-
操作数栈的深度在编译期确定(写入类的方法表),遵循 "先入后出" 原则;
例如:执行
a + b
时,会先将a
、b
依次压入操作数栈,再弹出计算,结果压回栈中。
- 动态链接(Dynamic Linking)
- 指向当前方法在运行时常量池中对应的常量引用,保存常量的地址。
- 作用是将符号引用转换为直接引用,简单说就是保留当前方法调用其他方法的地址,能准确找到对应的类的方法(实际是找到地址)进行调用。
- 返回地址(Return Address)
-
记录方法执行完毕后,需要返回的调用者位置(即调用该方法的下一条指令地址)。
-
正常退出:方法执行完最后一条指令后,从返回地址恢复调用者的执行;
-
异常退出:通过异常处理表(在方法的元数据中)确定返回位置,此时栈帧中可能不保存返回地址。
栈帧关键信息代码对应说明:
java
public class StackFrameDemo {
// 全局变量(不在栈帧中,在方法区)
private static int classVar = 10;
//main方法栈帧入栈
public static void main(String[] args) {
// 局部变量(在main方法栈帧的局部变量表中)
int a = 5;
int b = 3;
// 调用add方法,会创建新的栈帧并入栈,这个调用的过程的地址会存入动态链接中。
int result = add(a, b);
System.out.println("结果: " + result);
}
//add方法栈帧入栈
// 被调用的方法
private static int add(int x, int y) {
// 局部变量(在add方法栈帧的局部变量表中)
int temp = 2;
// 计算过程使用操作数栈
int sum = x + y * temp;
return sum;
}
}
- 局部变量表 :
main
方法的局部变量表存储:args
(参数)、a
、b
、result
add
方法的局部变量表存储:x
(参数)、y
(参数)、temp
、sum
- 局部变量的数量和类型在编译期已确定,运行时不会改变
- 操作数栈 :
- 执行
y * temp
时,会先将y
和temp
的值压入操作数栈,执行乘法后将结果弹出 - 执行
x + (y * temp)
时,再将x
和乘法结果压入栈,执行加法后弹出结果赋值给sum
- 执行
- 动态链接 :
main
方法中调用add()
时,通过动态链接将符号引用转换为add
方法的直接地址- 确保程序能正确找到并执行
add
方法
- 方法返回地址 :
add
方法执行完毕后,通过返回地址回到main
方法中调用add
的下一行代码(即打印结果的语句)- 返回地址存储在
add
方法栈帧中,对应main
方法的程序计数器值
栈的运行原理
简单结构图

解释:整个程序相当于一个大的栈,每个方法都相当于一个栈帧(小的方法栈),如果存在方法之间循环调用,那么就相当于创建了无限个方法栈帧压入了栈,当栈满了后就报栈溢出了。
代码模拟栈溢出

详细结构图

堆
三种JVM
- Sun公司的HotSpot。(java -version查看)
- BEA的JRockit
- IBM的J9VM
但我们使用的是Sun公司的HotSpot,学习的也是HotSpot的JVM。
堆
java heap,一个JVM只有一个堆内存,堆内存可以通过配置进行调节。
类加载器读取类文件后,一般把哪些东西放在堆中:(方法区属于特殊的堆)
vbnet
Class类模板、方法、常量、变量、类实例
ps:但是这仅限于早期的JDK版本,现在的有所变动,放在后面说。
堆内存划分
- 伊甸园区(新生区):Young/New/Eden Space
- 养老区(老年区):Old Space
- 永久区:Perm Space

新生区(伊甸园区)
新生区又叫伊甸园区,主要分为三部分内容:伊甸园区Eden Space,幸运0区或者幸运from区:S0,幸运1区或者幸运to区:S1。
老年区(养老区)
老年区又叫养老区,Old Space,当新生区触发一次轻GC后,发现新生区还是无法容纳新生对象时,新生对象就会进去老年区。如果发现老年区也无法容纳新生对象时,就会触发重GC,清理老年区的空间。
如果一个对象在新生区经过了默认15次轻GC后都没有被回收,那么这个对象也会进入到老年区。
永久区
永久区(永久代)是HotSpot关于JVM堆内存划分所形成的概念,用于实现JVM的方法区。
JVM方法区是一种规范,不同厂家的虚拟机可以基于规范做出不同的实现。
所以永久区存放的内容是和JVM方法区存放的内容是一样的:
- 类元数据(类名、继承关系、字段、方法信息等)
- 常量池(字面量、符号引用等)
- 静态变量(
static
修饰的变量) - 即时编译器(JIT)编译后的代码。
永久区不存在垃圾,关闭JVM虚拟就会释放这部分的空间。
那什么情况下,永久区会崩呢?
- 当启动时加载了超级多的第三方jar包和类
- Tomcat部署了太多的应用
- 大量动态生成的反射类,不断地被加载知道内存满了,就会出现OOM。
元空间
元空间,Meta space,跟永久代是一个东西,是不同版本JDK关于JVM方法区的实现。方法区就像是一个接口,永久代与元空间分别是两个不同的实现类。
永久代是HotSpot 虚拟机在 JDK1.7 及之前 对JVM方法区的实现,是一块连续的堆内存区域 (属于堆的一部分),专门用于存储方法区的内容。有固定大小限制(默认较小),可通过 JVM 参数-XX:PermSize
和-XX:MaxPermSize
调整。
由于永久代的局限性,JDK8 及之后的 HotSpot 彻底移除了永久代,改用元空间(Metaspace) 实现方法区。
- 元空间 使用本地内存(而非堆内存),大小默认无上限(受系统内存限制)。
- 解决了永久代 OOM 问题,且类的元数据存储更灵活。
jdk1.8之前:

jdk1.8以及之后:元空间逻辑上存在于堆内存中,但是物理上不存在于堆内存中(元空间使用的是本地内存),下面会用代码演示一下。

常量池
常量池是JVM方法区(元空间/永久代)中的一块区域,用于存储Class类元数据加载到方法区中的所有常量数据。
其中字符串常量池 是运行时常量池的一个子区域,字符串常量池用于存储字符串常量,除字符串常量以外的常量数据都存储在运行时常量池中。
- 在jdk1.7之前,运行时常量池+字符串常量池存放在方法区中,HotSpot虚拟机对方法区的实现称为永久代。

- 在jdk1.7中,字符串常量池从方法区移到堆中,运行时常量池保留在方法区中。

- jdk1.8之后,HotSpot移除永久代,使用元空间代替;此时字符串常量池保留在堆中,运行时常量池保留在方法区(元空间)中,只是实现不一样了,元空间内存变成了直接使用本地内存。

java
public class ConstantPoolExample {
// 静态常量(存于运行时常量池)
public static final int NUM = 100;
// 字符串常量(hello存于字符串常量池)
public static final String STR = "hello";
// 实例变量(存于堆中对象)
private int age;
private String name;
// 构造方法,存于方法区
public ConstantPoolExample(int age, String name) {
this.age = age;
this.name = name;
}
// 静态方法
public static void main(String[] args) {
// 局部变量(存于栈帧)
int a = 10;
String s1 = "world";
String s2 = new String("java");
// 调用方法(符号引用)
ConstantPoolExample obj = new ConstantPoolExample(25, "Alice");
obj.printInfo();
}
// 实例方法,存于方法区
public void printInfo() {
System.out.println("Name: " + name + ", Age: " + age);
}
}
代码演示
模拟OOM

默认内存分配情况

默认情况下:分配的最大内存是电脑内存的1/4,分配的初始化内存是电脑内存的1/64
永久区/元空间物理不存在于堆
现在调整类的jvm堆内存的参数 ,给堆内存分配1024M的空间
ruby
-Xms1024m -Xmx1024m -XX:+PrintGCDetails
-XX:+PrintGCDetails :打印GC详细信息。

注意:不要觉得java获取到的内存个分配的内存对不上,因为java计算不那么精确。
通过计算器计算我们可以看到新生区内存和老年区内存加起来就是总内存了,所以说元空间是物理上不存在于堆内存上的区域。

String a = new String("abc")是怎么存于JVM内存上的
- 字符串常量池相关操作
- 检查字符串常量 :JVM 首先会去字符串常量池(在 JDK 7 及之后位于堆中)查看是否存在字符串
"abc"
。如果不存在,就会在字符串常量池中创建一个内容为"abc"
的字符串对象。 - 字符串共享 :如果已经存在内容为
"abc"
的字符串对象,那么就直接复用该对象,不会重复创建 。
- 堆内存操作
- 创建新对象 :不管字符串常量池中是否已经存在
"abc"
,new String("abc")
这一操作都会在堆内存中创建一个新的String
对象,该对象包含了对字符串数据的引用。也就是说,这个新创建的String
对象里,会有一个指针指向字符串常量池中的"abc"
(在一些实现中,也可能是将字符串内容复制一份到堆中的对象里,但从逻辑上来说是指向或关联到常量池中的字符串 )。
- 虚拟机栈操作
- 变量存储 :在执行
String a = new String("abc");
时,会在当前方法的栈帧(位于虚拟机栈中)的局部变量表中创建一个名为a
的变量。这个变量a
存储的是在堆中创建的String
对象的引用(内存地址 )。
JProfiler工具分析OOM
-
配置JVM参数,生成dump快照
java-Xms1m -Xmx1m -XX:+HeapDumpOnOutOfMemoryError
arduinoXX:HeapDumpPath //可配置dump快照保存的路径,不配置默认在项目根路径下
配置堆内存初始化大小为1M,最大1M,开启当发生OOM时生成快照。
-
当发生OOM时会生成dump文件

- 使用JProfiler文件打开快照文件
通过堆遍历器--》当前对象集中的类、最大对象可以分析定位到哪些对象造成了OOM

通过堆遍历器--》线程转储可以定位到发生OOM的代码

只需要关注main中的业务线程,system的线程不需要关注,我们可以看到当前我们就一个main线程,点击main线程后,下方可以定位到在JVMStu类的main方法中的第18行发生的OOM。
GC垃圾回收算法
垃圾回收的区域
垃圾回收主要存在于堆内存中,方法区属于一个特殊的堆。

GC引用计数法

每个对象都有一个引用计数器,记录被引用的次数,当计数器为0的时候就代表该对象没有再被引用了,就可以被清理回收掉。
GC之复制算法

复制算法主要操作的是新生区,幸存区to区(S1)区永远是空的。

当一个对象在新生区经历了默认15次轻GC后,都没有被回收,那么该对象就会进入到老年区。
复制算法优缺点:
- 好处:没有内存的碎片。
- 坏处:浪费了内存空间(因为多了一半空间,幸存区to区永远是空)。假设对象100%存活(极端情况),就会将对象永远全部进行复制,很耗费性能,不适合使用复制算法。
适用场景:
对象存活度较低的时候,这样在新生区发生轻GC的时候就能把对象回收掉。
GC之标记清除算法

会对所有对象进行两次扫描,第一次扫描会对没有引用的对象进行标记,第二次扫描对已标记的对象进行清除。
优缺点:
- 优点:不需要浪费额外的空间。
- 缺点:两次扫描,严重浪费时间,会产生内存碎片(已清除的对象的内存位置是空的)。
GC之标记清除压缩算法
是基于标记清除算法的进化。

会存在三次扫描,在标记清楚算法两次扫描的前提下,第三次扫描向一端移动存活的对象。
标记清除压缩算法改进
可以进行多次标记清除,再进行一次压缩。
GC算法总结
执行效率:复制算法>标记清除算法>标记压缩算法(时间复杂度) 内存整齐度:复制算法=标记压缩算法>标记清除算法 内存利用率:标记压缩算法=标记清除算法>复制算法(浪费了一部分空间,因为S1区永远是空的)
注意:没有最好的算法,只有最合适的算法
所以我们可以基于现有的算法分别应用到新生区和老年区,这就是:GC分代收集算法
年轻代:对象存活率低,使用GC复制算法。
老年代:对象存活率高,使用标记清除(内存碎片不是太多)+标记清除压缩算法。
JMM内存模型
JMM内存模型主要跟线程的内存有关系,主要涉及到线程间数据的共享和私有,共享数据的一致性,涉及到synchronized和volatile。暂时不整理这块了。