目录
[(二)
与
一、什么是?
(一)本质和定位

Java虚拟机(JVM)是运行Java字节码的虚拟计算机,实现”一次编译,到处运行” 。
核心功能:
- 内存管理:自动分配与回收堆内存
- 解释执行:将字节码转换为机器指令
- 跨平台性:屏蔽操作系统差异
(二)JVM vs JDK vs JRE

- JVM是Java虚拟机,是Java程序运行的环境。
- 它负责将字节码解释或编译成机器码,并执行程序。JVM提供了内存管理、垃圾回收、安全性等功能,使得Java程序具备跨平台性。
- JRE是Java运行时环境,是Java程序运行所需的最小环境。
- 它包含了JVM和一组Java类库,用于支持Java程序的执行。JRE不包含开发工具,只提供Java程序运行所需的运行环境。
- JDK是Java开发工具包,是开发Java程序所需的工具集合。
- 它包含了JVM、编译器、调试器等开发工具,以及一系列的类库。JDK提供了开发、编译、调试和运行Java程序所需的全部工具和环境。
JVM让程序可以被编译执行,JRE让编译好的Java程序可以运行,JDK让Java程序可以被开发。
二、结构
(一)栈

1. 栈帧精解
栈帧是虚拟机栈的核心单元,每个方法调用对应一个栈帧,包含局部变量表、操作数栈、动态链接三大核心结构。
每个栈帧还拥有独立的程序计数器,用于记录该栈帧下一条要执行的字节码指令地址,以便在发生上下文切换后可以按照原进度继续执行。
(1)局部变量表
方法参数和方法体内定义的局部变量,数据类型包括基本类型、对象引用和返回地址。编译期确定大小,以变量槽(Slot) 为最小单位。
如果是非静态方法的话第0位slot存储的是this引用,然后存储参数,最后再存局部变量。静态方法的话就直接从存储参数开始即可。
如果一个变量的作用域结束后,后续的新变量可直接覆盖复用该变量使用的slot。
需要注意的一点时,当局部变量作用域结束后,只有该变量所占用的slot被复用后,该变量的强引用依旧有效,不会被识别为无用变量,因此就算手动执行GC也无法释放该变量引用。 也就是说,如果想要回收非作用域的变量时,需要声明新变量复用slot或者声明该变量为null。 可能有同学还没听懂,让我们引入一个案例: public static void main(String[] args) { { byte[] placeholder = new byte[64 * 1024 * 1024]; } // 此时placeholder的引用仍旧有效,不会被释放 System.gc(); }一键获取完整项目代码java运行 public static void main(String[] args) { { byte[] placeholder = new byte[64 * 1024 * 1024]; // 可以手动释放引用 //placeholder = null; } // 也可以复用slot表示placeholder的引用已无效让GC释放 int a = 0; System.gc();}一键获取完整项目代码java运行 还需要注意复用slot仅发生在同一方法作用域内,即同一个栈帧内。 这个比较好理解,因为每个栈帧的局部变量表独立。
(2)操作数栈
每个栈帧都有一个独立的操作数栈,用于数据暂存,遵守先进后出原则。
让我们梳理一下关系:每个JVM都有一个虚拟机栈,每个虚拟机栈存放栈帧,每个栈帧又有一个操作数栈,而操作数栈存放的是数据。 虚拟机栈和操作数栈都是遵守先进后出原则的容器,因此二者都可能发生栈溢出问题。
关于操作数栈的执行流程,我们引入一个案例来进行讲解:
int c = a + b;这一行代码反编译成字节码指令的话一共分为4步:
- iload_1: 把第1位的slot的变量a取出,压进操作数栈
- iload_2: 把第2位的slot的变量b取出,压进操作数栈
- iadd: 这一步里面又分成4步:
- 将栈顶b弹出
- 将栈顶a弹出
- 让a和b在CPU的算术逻辑单元里计算
- 将结果压进栈内
- istore_3: 将栈顶的结果弹出,放入第3位的slot,也就是赋值给c
不难发现,操作数栈是用于字节码指令进行交互的核心容器,因此被称作为JVM执行引擎的“心脏”。
2. 栈溢出问题
(1)StackOverflowError
正如我们上面所说,导致StackOverflowError存在两种情况:
- 虚拟机栈空间溢出:未设置递归结束条件或过度递归导致过量的栈帧塞满了虚拟机栈
- 操作数栈空间溢出:一行语句中使用过多的变量参与计算导致需要存储的数据过量塞满了操作数栈
其中操作数栈空间溢出的概率很小,应该说几乎不可能发生。
而解决方案两者也不尽相同:
- 对操作进行拆分:虚拟机栈将递归拆解成循环来保证单栈帧;操作数栈则需要将一行语句使用多个局部变量进行拆解
- 扩大栈空间
(2)OutOfMemory
这个异常主要是因为局部变量表的空间溢出问题,也就是我们使用了超大数组或者过多局部变量导致的。
解决方案也很简单:
- 用new关键字声明超大数组,将其存储在堆内存空间当中
- 扩大栈帧空间
- 减少局部变量个数,尽量合并作用域
需要注意的是,栈溢出所抛出的StackOverflowError是无法被try-catch代码块所捕获的,因为catch块执行是需要一个新的栈帧的,而此时虚拟机栈已经无法容纳新的栈帧,因此该异常无法被捕获。 所以我们尽量在开发阶段就避免该问题,不然在生产环境下可能会引发严重事故……
(二)本地方法栈
本地方法栈是JVM为执行本地方法专设的内存区域,与Java虚拟机栈协同工作。
本地方法指的是用C/C++写的函数,用于操作操作系统资源。
1. JNI调用原理
typedef struct { // Java方法名 const char* name; // 方法签名 const char* signature; // C/C++函数指针 void* fnPtr;} JNINativeMethod;先通过这个结构体建立起Java方法与本地方法的映射关系。
然后会根据signature方法名查找JNI函数表对应的指针,然后再调用指针所指向的函数。
之后函数返回结果,再转换成Java对象返回即可。
这一个流程就像一条调用链一样,因此本地方法栈管理的对象并不是栈帧,而是JNI函数调用链,其GC不受JVM管理。
但相同的是本地方法栈同样又栈溢出风险,其诱因也大体相同:
- 递归调用链过长
- 局部变量过大或者过多
解决方法也相同,这里不再做赘述。
(三)堆内存

堆是线程共享的最大内存区域,由于存储了所有对象实例和数组(new关键字创建的对象),因此是GC的主战场。
堆划分为新生代和老年代,逻辑连续但物理可不连续。
| 区域 | 占比 | 对象特征 |
|---|---|---|
| 新生代 | 整堆约1/3(默认) | 新创建对象、存活时间短 |
| - Eden区 | 新生代80% | 对象诞生区域 |
| - Survivor区 | 新生代20%(From+To各10%) | Minor GC后存活对象暂存区 |
| 老年代 | 整堆约2/3(默认) | 长期存活对象、大对象直接进入 |
这里先简单介绍一下堆的存储机制,详细请见下文GC分代回收机制相关文段。
新对象最先存储在Eden区,Eden区满了后会进行一次针对新生代的GC,幸存者会被复制到Survivor区。
然后当对象存活一定时间后会晋升移动到老年代区,若老年代空间不足则会进行一次针对整个堆和方法区的GC。
1. 对象生命周期全链路
(1)TLAB分配
当并发场景下,多线程共享堆内存并对Eden区进行操作,这必然是线程不安全的,但如果加锁的话会大幅度降低性能,因为对堆的操作是极其频繁的。
为解决该问题,JVM为每个线程分配独立的TLAB空间(默认占Eden 1%)。
每当线程要new一个新对象时,会先将对象实例存储在自己的TLAB空间中,倘若空间不足则会申请新的TLAB空间进行分配,如果无法分配新的TLAB空间,此时才会对Eden区进行加锁然后将对象实例存储在Eden区当中。
如果TLAB空间私有,那如何确保该对象多线程共享? TLAB空间也是Eden区的一部分,私有表示只有持有线程可对其写入,但读权限依旧是所有线程共享。
(2)逃逸分析与标量替换
三种逃逸状态:
| 状态 | 判定条件 | 优化策略 |
|---|---|---|
| 全局逃逸 | 对象被外部类/线程引用(如静态变量) | 禁止栈上分配 |
| 参数级逃逸 | 对象作为方法参数传递 | 禁止栈上分配 |
| 无逃逸 | 对象仅在方法内部使用 | 栈上分配或标量替换 |
关于逃逸分析的详细内容在之前的JUC篇有讲到,这里主要讲解栈上分配的优化。 带你轻松学习JUC-CSDN博客https://blog.csdn.net/2401_88959292/article/details/149500338?spm=1001.2014.3001.5501
当对象被逃逸分析判定为未逃逸时,JVM会触发标量替换:
将对象拆解为独立的基本类型变量,拆解后的变量直接压进栈帧的操作数栈中,而非局部变量表。然后擦除对象头指针类型信息,原对象不再以整体形式存在。
对于栈上分配策略,HotSpot虚拟机是没有实现的,因为局部变量表仅能存储标量,无法存储引用对象,而且对象包含对象头,所占用空间通常更大,容易无法存入碎片空间导致栈溢出。
因此HotSpot仅采用了标量替换策略。
引入案例来更直观的理解一下:
// 原始代码Point p = new Point(1, 2);return p.x + p.y;
// 标量替换后 → 直接使用基本变量int x = 1, y = 2;return x + y;2. 堆溢出问题
堆溢出的诱因一般为以下三点:
- 内存泄漏
- 大对象分配
- 堆空间不足
什么是内存泄漏? 指程序在运行过程中,由于逻辑错误导致不再使用的对象无法被GC正常回收,造成内存空间被无效占用的现象。 其核心特征是: 对象生命周期失控:对象在完成业务功能后本应被销毁,但因被错误的强引用关系持续持有,导致其无法被GC回收。 资源持续累积:每次执行特定操作都会泄漏新对象,内存占用随运行时间单调递增。 后果不可逆:泄漏的内存空间永久性不可复用,最终耗尽堆内存触发OOM。
常见原因: 静态集合:使用静态数据结构存储对象,且未清理。 事件监听:未取消对事件源的监听,导致对象持续被引用。 线程:未停止的线程可能持有对象引用,无法被回收。
关于如何诊断,可以使用以下几个手段:
- 生成堆转储文件:添加JVM参数-XX:+HeapDumpOnOutOfMemoryError -XX
=/dump.hprof,将抛出OOM时保存堆内存快照 - 线程分析:jstack <pid>检查线程是否阻塞在对象创建代码
- GC日志:-Xloggc输出GC日志观察GC频率与回收效率
解决方案:
- 移除长周期或静态集合无用引用
- 增大堆内存空间
- 分页加载数据
(四)方法区

方法区是JVM规范定义的线程共享内存区域,逻辑上属于堆的一部分,但物理内存独立,存储已被虚拟机加载的类型信息(类、接口、枚举、注解)、运行时常量池、静态变量、即时编译器编译后的代码缓存等。
- JDK7及之前:称为永久代,位于JVM堆内存中
- JDK8+:改为元空间,使用本地内存
之所以变化,是因为元空间相比永久代更难OOM,因为使用的是本地内存,可控性强;而且元空间的大小是不固定的,更好适应动态类的加载;永久代的GC触发频率与老年代一致,易堆积大量无效引用。
1. 常量池优化历程
(1)运行时常量池 & 类文件常量池
- **类文件常量池:**存在于编译后的字节码文件中,存储字面量(文本字符串、
final常量等)和符号引用(类/接口全限定名、字段/方法描述符)。 - **运行时常量池:**类加载时,类文件常量池的内容存入方法区的运行时常量池。它存储符号引用解析后的直接引用,每个类独立拥有一个运行时常量池
(2)符号引用转直接引用
符号引用其实指的是用文本形式描述引用地址,比如“java/lang/Object”,这种形式在编译阶段无法确定实际地址,因此在类加载阶段就会将符号引用解析成直接引用放入运行时常量池,而直接引用就是指向地址的指针或者内存地址偏移量。
2. StringTable机制
这是JVM为优化字符串内存管理设计的全局哈希表,存储唯一字符串对象的引用,避免重复创建相同内容的字符串。
用字面量创建的字符串对象,会优先复用StringTable中的引用,若不存在则将其引用存入StringTable。
(1)常见双等号判断问题
注意以下场景JDK均为8及以后,即7+。
String s1 = "abc"; // 常量池引用String s2 = new String("abc"); // 堆中新对象System.out.println(s1 == s2); // false一个引用在常量池一个在堆内存中,内存地址不一样结果肯定是false。
String s1 = new String("a") + new String("b"); // 堆中对象"ab"(常量池无"ab")s1.intern(); // 将堆中"ab"的引用存入常量池String s2 = "ab"; // 指向常量池引用(即s1的堆地址)System.out.println(s1 == s2); // true这里需要注意JDK7前后intern()的区别:
- JDK7之前,intern()是会深拷贝一个新的字符串,将其引用存入常量池
- JDK7+则是浅拷贝原字符串到常量池
因此该场景下如果是JDK7之前的话结果就会是false。
String s1 = "a" + "b"; // 编译期优化为 "ab"String s2 = "ab"; // 常量池引用System.out.println(s1 == s2); // true编译期会将字面量拼接自动优化为拼接完成后的。
String s1 = "a";String s2 = "b";String s3 = s1 + s2; // 等同于 new StringBuilder().append(s1).append(s2).toString()String s4 = "ab";System.out.println(s3 == s4); // false而变量拼接,由于编译期无法确定变量具体值,因此无法像字面量拼接那样直接优化,会使用StringBuilder直接动态拼接并存入堆内存中。
final String s1 = "a";final String s2 = "b";String s3 = s1 + s2; // 编译期优化为 "ab"String s4 = "ab";System.out.println(s3 == s4); // true但如果变量被final修饰的话,则会转化为常量,因此可以被直接优化。
String s1 = "ab"; // 常量池生成"ab"String s2 = new String("ab");String s3 = s2.intern(); // 返回常量池已有引用System.out.println(s1 == s3); // trueSystem.out.println(s2 == s3); // false这个很简单,不做说明了。
String s1 = new String("a") + new String("b"); // 堆中对象"ab"(常量池无"ab")String s2 = "ab"; // 常量池新建"ab"System.out.println(s1 == s2); // false一个堆内存一个常量池,结果肯定为false。
String s1 = "abc"; // 常量池对象String s2 = s1.substring(0,3); // 堆中新对象System.out.println(s1 == s2); // false这里也跟JDK版本有关:
- JDK7之前,substring是会直接引用原字符串的char[]
- JDK7+则是深拷贝一份到堆内存中
因此如果在JDK7之前的话结果则为true。
String s1 = new String("abc"); // 堆对象s3.intern(); // 入池String s2 = "abc"; // 常量池引用System.out.println(s1 == s2); // false
// 对比:未显式使用字面量char[] c = {'a','b','c'};String s3 = new String(c); // 堆对象s3.intern(); // 入池String s4 = "abc"; // 指向s3的堆地址System.out.println(s3 == s4); // true注意如果new的构造方法里用的是字面量,则会先将该字面量字符串引用存入常量池,但赋值的对象的引用仍在堆内存当中,因此此时就算入池也无法成功,因为常量池里已有该字符串的引用了,所以结果返回false。
(2)String去重
A. intern()去重
在每次创建新字符串对象时都在后面调用intern()入池。
这样的话每次创建重复字符串时就会复用常量池中的引用。
B. G1去重
在Minor GC阶段中会扫描存活的String对象,并对年代已达一定数值的字符串对象哈希去重,其共享的是char[],对象头是不会改变的。
需要配置-XX:+UseG1GC -XX:+UseStringDeduplication开启去重机制,去重年代阈值则需要通过-XX
| 特性 | G1去重 | intern() |
|---|---|---|
| 作用范围 | 全自动,处理所有存活字符串 | 手动,需代码调用 |
| 内存节省 | 共享char[],对象头仍存在 | 共享整个String对象(省24字节头) |
| 性能开销 | 低(后台异步执行) | 高(哈希表查询,慎用大规模场景) |
3. 方法区溢出问题
永久代因为存储在堆内存中且有固定空间大小,因此容易因为StringTable入池过度导致溢出。
变为元空间后因为存储在本地内存无内存上限,所以几乎很少会因为常量池溢出。
但是仍旧可能因为框架等动态生成的类过多导致溢出。
解决策略也很简单:
- 增加初始内存空间
- 若有自定义内加载器要记得及时卸载无用类
- 减少动态类的生成
- 谨慎使用intern()
(五)直接内存

直接内存位于 JVM 堆外,由操作系统本地内存直接分配,不受 JVM 堆大小限制。
1. Cleaner机制
Cleaner机制主要是用来解决JVM无法释放直接内存的问题的。
当分配直接内存时,JVM 在堆内创建DirectByteBuffer对象,同时在构造函数中注册Cleaner。
当DirectByteBuffer对象不再被引用后,会在下一次GC被回收,其关联的Cleaner则会放入ReferenceHandler中,调用后台线程进行清理,而后台线程通过Cleaner.clean()调用Unsafe类对直接内存进行释放。
但是自动回收是有可能失效的:
- 当DirectByteBuffer被强引用时且晋升成为老年代时,仅Full GC可释放其引用
- 禁用System.gc() ,因为该命令会触发Full GC,所以有时为了安全考虑会通过VM参数配置将其禁用,但是部分框架是通过该命令释放直接内存的,禁用会导致内存泄漏
所以一般推荐手动释放直接内存,调用Cleaner.clean()即可。
2. 零拷贝

由于直接内存位于内核态,而JVM位于用户态,想要交互的话必须上下文切换,内核态会先将数据写入到直接内存的缓冲区中,然后才从缓冲区中写入JVM管理的内存中。
而JVM则通过建立内存映射提升了效率,也就是在直接内存和堆内存之间建立了一个共享空间,可以让双方同时访问的区域。
由此减少了一次拷贝次数,和RocketMQ所用的零拷贝是一样的。
三、GC
在了解垃圾回收之前,让我们先了解一下JVM是如何判定一个对象是需要回收的:
- 引用计数法: 给每个对象分配一个引用计数器,每当该对象被引用一次,计数器就+1,当计数器归0时代表该对象没有被引用,即判定为垃圾。 缺点:无法解决循环依赖问题,比如A引用B,B引用A,这两个对象如果不再被其他对象引用时,则永远无法被回收。
- 可达性算法:
从一组称为GC Roots的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到GC Roots没有任何引用链相连,那么这个对象就被认为是不可达的,可以被回收。
(一)五种引用类型
| 引用类型 | GC回收条件 | 获取对象 | 引用队列 | 典型场景 |
|---|---|---|---|---|
| 强引用 | 永不回收(除非置 null) | 直接访问 | 不支持 | 通用对象创建 |
| 软引用 | 内存不足时回收 | get() 获取 | 可选 | 内存敏感缓存(图片、网页) |
| 弱引用 | 发现即回收(无论内存状态) | get() 获取 | 可选 | 临时缓存 |
| 虚引用 | 回收时入队(无法干预回收) | 始终为 null | 必须绑定 | 对象回收跟踪(堆外内存管理) |
| 终结器引用 | 两次GC(finalize() 延迟) | 不可获取 | JVM内部使用 | 不推荐使用 |
这里提一嘴,之所以不推荐用终结器引用,是因为执行GC的时机是不定的,而且其还得经历两轮GC才可被回收,因此自然终结器引用的回收时间是完全无法确定的,容易造成内存泄漏导致OOM。
而上文刚提到的Cleaner机制用的就是虚引用,也能通过该机制理解虚引用的作用。
(二)垃圾回收算法
1. 标记 - 清除算法
- 执行过程
- 标记阶段:从GC Roots出发遍历引用链,标记所有存活对象。
- 清除阶段:遍历堆内存,回收未被标记的对象空间。
- 核心问题
- 内存碎片化:回收后产生不连续空间,导致大对象分配失败。
- 效率瓶颈:需两次堆遍历,且全程STW(Stop The World)。
STW是什么? Stop The World,听上去很高大上,但实际效果也确实是这个意思。 STW会暂停除GC线程外所有的线程,全部阻塞直至GC完成。
为何要STW? 因为GC操作的是对象的引用,如果该引用正在被某一线程使用,容易造成前后引用不一致的现象或者抛出空指针异常。
2. 复制算法
执行过程 将堆划分为大小相等的两区(From/To),存活对象从From区复制到To区,清空From区并交换角色。
- 关键优化
- 年轻代应用:HotSpot将年轻代分为Eden区和两个Survivor区。
- 对象晋升:默认年龄阈值15次,超龄对象进入老年代。
- 缺点
- 空间浪费:50%内存闲置,不适合存活率高的场景(如老年代)。
3. 标记 - 整理算法
- 执行过程
- 标记阶段:同标记-清除算法。
- 整理阶段:将存活对象向内存一端移动,清除边界外空间。
- 适用对象存活率高的场景,解决碎片化问题且无需双倍空间。
| 算法 | 速度 | 空间开销 | 碎片问题 | 适用场景 |
|---|---|---|---|---|
| 标记-清除 | 中等 | 低 | 严重 | 老年代(CMS) |
| 复制算法 | 最快 | 高(需50%备份空间) | 无 | 新生代(存活率低) |
| 标记-整理 | 最慢 | 低 | 无 | 老年代(Parallel Old) |
(三)分代回收机制
具体流程如下:
- 一个新对象被创造出来,首先将其放入新生代的Eden区
- 当Eden区所占内存空间达到一定阈值时发生Minor GC,会对新生代进行一次GC,存活的对象年龄+1,并且移至幸存区
- 幸存区有两个,采用复制算法,因为新生代对象生命周期通常较短,发生Minor GC的概率大,为了提升性能而牺牲空间,因此通常STW较短
- 若达到以下三个条件之一,则晋升老年代:
- 年龄 ≥ 15 或配置的指定阈值的对象
- Eden区和幸存区内存空间都不足,进行一次Minor GC后幸存的对象
- 占用空间很大的大对象
- 当老年代内存也不足时,会先进行一次Moinor GC,如果内存还不足的话再进行一次Full GC,也就是针对整个堆内存和方法区的GC,使用标记-清除或标记-整理算法,STW较长
- 如果Full GC后内存还不足则会抛出OOM异常终止该线程运行,不影响其他线程正常运行
为什么堆内存都溢出了其他线程还能正常运行? 因为一般OOM的罪魁祸首都是抛出异常的那个线程,JVM会在抛出OOM之后终止该线程运行并立即清理跟该线程相关的所有引用,会释放大量堆内存空间。
| GC 类型 | 作用范围 | 特点 |
|---|---|---|
| Minor GC | 新生代 | 高频、耗时短 |
| Major GC | 老年代 | 低频、耗时长 |
| Full GC | 整个堆 + 方法区 | STW 最长,避免频繁触发 |
(四)垃圾回收器
1. 新生代
(1)Serial收集器
单线程串行收集,全程STW,采用复制算法。
是Client模式下的默认新生代收集器,适用于单核CPU或小内存场景。
简单高效,无线程交互竞争开销,但相对的执行效率低下,STW停顿时间长。
什么是Client模式? Client模式是JVM的两种主要运行模式之一,另一种是Server模式。 特性Client模式Server模式启动速度快(轻量级编译) 慢(深度优化)长期性能较低 高(C2编译器深度优化)默认堆内存-Xms1M, -Xmx64M-Xms128M, -Xmx1024MGC策略串行GC(单线程)并行/并发GC(多线程)适用场景桌面应用、短任务服务器、大数据计算64位系统支持不支持默认 Client模式是JVM为快速启动和低内存场景设计的轻量级运行方案,但随着64位系统和云计算的普及,Server模式已成为主流。
(2)ParNew收集器
Serial收集器的多线程并行版本,同样采用复制算法,需STW,是唯一能与收集器搭配的新生代回收器。
适合数据量不低的场景,需与CMS配合实现低停顿。
(3)Parallel Scavenge收集器
吞吐量优先的多线程并行收集器,一样是复制算法。
可以动态调整新生代分区比例。
适合数据量大的对延迟不敏感场景。
2. 老生代
(1)Serial Old收集器
Serial的老年代版本,单线程串行,采用标记-整理算法。
是Client模式下的默认老生代收集器。
一般作为CMS失败的后备方案。
(2)Parallel Old收集器
多线程并行,关注吞吐量,采用的也是标记-整理算法。
一般与Parallel Scavenge组合,适用于高吞吐需求的服务端。
(3)CMS收集器
追求低延迟,采用标记-清除算法。
与ParNew搭配,适用于响应敏感系统。
回收一共有五个流程:
- 初始标记(有STW):
- **任务:**标记所有GC Roots直接关联的对象,仅标记关联对象,不递归关联其引用链上的对象
- **特点:**单线程执行,耗时极短
- 并发标记(无STW):
- **任务:**从初始标记对象触发,递归关联其引用链上的所有对象,也就是整个老年代的对象。
- **特点:**并发执行,耗时最长。通过三色标记法管理对象状态: 1. 黑色:对象及其引用已经被处理 2. 灰色:对象已被处理,但引用链还未被处理完全 3. 白色:未扫描对象
- 可中断预清理(无STW):
- **任务:**等待一次Moinor GC以减少重新标记时的扫描量
- 终止条件: 1. 执行超过5s 2. Eden区内存占用达50% 3. 发生了Minor GC
- 重新标记(有STW):
- **任务:**重新扫描并发标记的对象,修正并发期间的引用变更
- 并发清除(无STW):
- **任务:**释放标记对象引用
三大缺陷:
- CPU敏感:并发线程数公式
(CPU+3)/4,可能抢占应用资源 - 浮动垃圾:需预留空间(默认老年代68%触发回收)
- 内存碎片:标记-清除算法导致,需Full GC整理
3. 跨代
(1)G1收集器
将堆划分为多个等大Region,物理不分代但逻辑保留分代。类似Kafka中一个Topic被划分为多个Partition,但不一样的是每个Region可以是Eden、Survivor、Old或Humongous角色。
Humongous区专门存储超过Region容量50%的大对象,若单个Region无法容纳,则分配连续多个Region存储。
每个Region维护了一个反向指针集合,叫做记忆集RSet,用于记录其他Region对本Region的引用,这样就无需递归遍历。
同时将Region划分为512字节的卡片,多个卡片的集合叫做卡表。通过字节数组记录脏卡(并发期间引用被修改),这样可快速进行重新标记。
G1的具体流程如下:
- **初始标记(有STW):**会趁着在Minor GC期间标记GC Roots直接关联对象
- **并发标记(无STW):**全局对象图遍历,使用SATB解决漏标
什么是SATB? SATB(原始快照) 当用户线程删除灰色→白色对象的引用时,记录旧引用,最终标记阶段以旧引用为根扫描,保证白色对象不被误删。 而CMS采用的策略是增量更新:当用户线程新增黑色→白色对象的引用时,记录新引用,重新标记阶段以黑色对象为根重新扫描,避免漏标。 因此CMS每次更新标记都得全盘扫描,STW停顿时间随堆内存增大而增大;而G1只需要扫描已经变更的引用即可,停顿时间是可控的。
- **最终标记(有STW):**扫描SATB队列里的引用,进行变更
- **筛选回收(有STW):**按Region回收价值排序,选择收益高的Region复制清理
- **复制收尾(有STW):**复制存活对象到空闲Region,清空原Region
| 对比维度 | CMS | G1 |
|---|---|---|
| 标记目标 | 仅老年代 | 全堆 |
| 漏标解决 | 增量更新 | SATB |
| 内存模型 | 物理分代 + 卡表 | Region分区 + RSet |
| 停顿控制 | 不可控 | 可预测 |
| 碎片处理 | 无整理 | 复制算法整理 |
| 最佳适用堆大小 | ≤8GB | ≥6GB |
可以看到G1更适用于空间占用需求量较大、可控停顿要求的场景。
且相比CMS,G1不会产生内存碎片和浮动垃圾。
什么是浮动垃圾? 浮动垃圾指的是在并发标记阶段中用户线程产生的额外的可回收对象。 CMS中其并发标记与用户线程并行运行,增量更新仅解决漏标问题,无法处理引用断开后残留的无效对象,加之标记-清除算法不移动对象,导致浮动垃圾必然存在。 而G1通过SATB快照锁定标记开始时所有存活对象,写屏障拦截引用断开操作,复制算法隔离新旧对象,三者结合确保单次回收无残留。
(2)ZGC收集器
ZGC抛弃了原有的分代垃圾回收算法,支持毫秒级可控停顿以及16TB超大堆内存,同时也吞吐量也十分可观。
其引入染色指针替代传统的对象头标记,一共有以下几种颜色:
| 颜色状态 | 值 | 含义 |
|---|---|---|
| Marked0 | 1000 | 当前周期标记为存活(周期1使用) |
| Marked1 | 1001 | 当前周期标记为存活(周期2使用) |
| Remapped | 1010 | 对象已完成地址转移(正常使用状态) |
| Finalizable | 1011 | 对象需调用finalize方法(特殊状态) |
优势:
- 省去对象头访问,GC 操作速度提升
- 对象转移后旧Region可立即释放,无需等待全局引用更新
除此之外还有读屏障自愈机制,当从堆内存中读取引用时,会先检查其染色指针颜色,如果是坏颜色就会通过转发表定位引用新地址并进行更新。
什么是坏颜色? 指的是指针状态与当前GC周期不匹配的颜色状态。 比如说当前GC周期使用Marked0标记对象,则除了Marked0以外的颜色全是坏颜色。 例如如果指针颜色是Remapped,则代表该对象引用已经改变但是还未更新;是Marked1则代表该对象的染色指针已经过期,使用的还是上个周期的颜色。
其工作流程如下:
- **初始标记(有STW):**扫描GC Roots直接引用对象。
- 并发标记(无STW):
- 遍历对象图标记存活对象,通过染色指针记录状态
- SATB 优化
- **重新标记(有STW):**修正并发标记期间的引用变更,超时则退回并发标记
- **并发转移准备(无STW):**统计需回收的Region,组成重分配集
- **初始转移(有STW):**转移GC Roots直接引用的对象。
- 并发转移(无STW):
- 核心阶段:将重分配集的存活对象复制到新 Region
- 自愈机制:用户线程访问旧对象时,读屏障自愈
- 立即释放:对象转移后,原Region可立刻重用
- **并发重映射(无STW):**修正堆中指向旧地址的引用(合并至下次标记阶段,减少遍历开销)。
因此ZGC的优点是超低可控停顿、无内存碎片,适用于高并发场景。
但是相对的ZGC的空间开销大,且无分代设计易产生浮动垃圾。
因此在JDK21+,引入了分代设计的ZGC,取消了多重映射,简化内存模型,解决了易产生浮动垃圾的问题。
(五)GC调优
在讲解GC调优之前,需要强调一下调优优先性:如果程序性能出现瓶颈,大概率是因为你写的代码有问题,而不是GC配置的问题,GC调优仅作最后手段。
1. 垃圾收集器选型
| 回收器 | 启用参数 | 适用场景 | 关键配置 |
|---|---|---|---|
| G1 | -XX:+UseG1GC | 堆≥4GB,平衡吞吐与延迟 | MaxGCPauseMillis=100, G1ReservePercent=15 |
| ZGC | -XX:+UseZGC | 堆≥32GB,停顿要求≤10ms | ConcGCThreads=4, -Xmx16g |
| CMS | -XX:+UseConcMarkSweepGC | JDK8老系统迁移 | CMSInitiatingOccupancyFraction=70 |
以上是常用的垃圾回收器,JDK11+优先选择G1,如果对堆内存大小或停顿有要求的则使用ZGC,至于CMS基本只在JDK较老的一些应用程序里会使用,已在JDK14将其彻底移除。
2. 分代优化
对于分代来说,优化的点应该是尽量减少各种GC次数,甚至做到无GC。
(1)新生代优化
新生代一般是频繁的Minor GC。
解决方法一般是增大Eden区占比、减少对象晋升老年代阈值,避免大对象写入。
(2)老年代优化
而老年代则是影响更为严重的Full GC。
解决方法则是增大对象晋升老年代阈值、扩容堆内存以及增加预留空间预防复制转移失败导致OOM。
可以看到对于新生代需要减少对象晋升老年代阈值,而老年代则需要增大对象晋升老年代阈值,因此可以看到晋升阈值不能过大也不能过小,默认的15可谓是平衡了两个年代,但实际开发可以稍作调整,切忌过度调整。
3. 减少GC次数的技巧
- 对象复用:即之前JUC中的享元模式,共用共享对象,减少内存占用。
- 零分配设计:热点路径避免new操作,防止频繁分配内存空间。
- 数据结构优化
- 集合预分配大小避免扩容(扩容需要连续的内存空间,如果后续内存空间被占用,则只能晋升老年代)。
- 避免finalize():使用PhantomReference替代。
四、字节码指令
字节码由单字节操作码和可选的操作数组成,基于栈式结构执行。
(一)基础字节码指令
- 加载与存储指令
- 加载:
iload(加载int)、aload(加载引用)、iconst(加载常量)等。 - 存储:
istore(存储int)、astore(存储引用)等。
- 算术与类型转换指令
- 算术:
iadd(整数加)、fmul(浮点乘)、irem(取模)等。 - 类型转换:
i2f(int转float)、d2i(double转int)等。
- 控制转移指令
- 条件跳转:
ifeq(等于0跳转)、if_icmpge(整数比较跳转)。 - 多分支跳转:
tableswitch(密集case)、lookupswitch(稀疏case)。 - 无条件跳转:
goto。
- 对象操作指令
- 创建:
new(实例化对象)、newarray(新建数组)。 - 访问:
getfield(读取实例字段)、putstatic(修改静态字段)。
(二)<clinit>与<init>
初始化和对象构造由两个特殊方法实现:<clinit>用于类初始化,<init>用于实例构造,均为懒加载。
1. <clinit>
该指令仅在访问类的静态资源时调用,整个生命周期仅执行一次,由JVM内部进行加锁保证线程安全。
该指令会将静态资源按照代码顺序从上自下合并成一个新的构造方法并调用。
引入一个案例来体会一下:
class StaticDemo { static int COUNTER = initCounter(); static { System.out.println("静态块执行"); } static int initCounter() { return 5; }}对应字节码如下:
0: invokestatic #2 // 调用initCounter()
3: putstatic #3 // 赋值给COUNTER
6: getstatic #4 // 获取System.out
9: ldc #5 // 加载字符串”静态块执行”
11: invokevirtual #6 // 调用PrintStream.println()
14: return
可以看到字节码顺序也是按照代码声明顺序执行的。
需要注意<clinit>无法被代码直接调用,是隐式执行的。
2.<init>
该指令在每次new创建对象实例的时候调用,每次实例一次就执行一次,且支持重载。
该指令也遵循以下顺序执行,但是仅有被赋值的成员变量可被加载:
- 父类构造
- 实例字段初始化
- 实例初始化块
- 构造方法
同样引入一个案例:
class ObjectDemo { private int id = initId(); private int name; { System.out.println("初始化块"); }
public ObjectDemo() { System.out.println("构造方法"); } private int initId() { return 1; }}对应字节码如下:
0: aload_0 // 加载this到操作数栈 6: invokespecial #2 // 调用initId() 9: putfield #3 // 结果存入id字段 12: getstatic #4 // 获取System.out 15: ldc #5 // 加载字符串”初始化块” 17: invokevirtual #6 // 调用PrintStream.println() 20: getstatic #4 // 获取System.out 23: ldc #7 // 加载字符串”构造方法” 25: invokevirtual #6 // 调用PrintStream.println() 28: return
| 特性 | ||
|---|---|---|
| 方法名 | 固定 | 固定 |
| 执行时机 | 类首次加载时 | 每次new创建对象时 |
| 执行次数 | 整个JVM生命周期1次 | 每个对象实例1次 |
| 线程安全 | JVM自动加锁保证 | 无特殊保证 |
| 内容来源 | 静态字段赋值 + 静态块 | 实例字段赋值 + 实例块 + 构造方法代码 |
| 调用链 | 父类→子类 | 父类构造→子类构造 |
| 显式调用 | 禁止 | 通过new触发 |
下面引入两个危险代码案例:
- 循环依赖 class A { static B b = new B();} class B { static A a = new A();}一键获取完整项目代码java运行 类A的
调用类B的 ,类B的 又调用类A的 ,会抛出ClassCircularityError类循环依赖错误的异常。 - 无限递归 class RecursionDemo { { System.out.println(“普通代码块:创建实例”); new RecursionDemo(); } public RecursionDemo() { System.out.println(“构造方法:完成实例化”); } }一键获取完整项目代码java运行 如果在
执行的代码中再次创造对象实例执行 的话就会陷入无限递归,最终无限栈帧加载导致虚拟机栈溢出。
(三)方法调用字节码指令
| 指令 | 调用类型 | 绑定方式 |
|---|---|---|
| invokestatic | 静态方法 | 静态绑定 |
| invokespecial | 构造/私有/父类方法 | 静态绑定 |
| invokevirtual | 虚方法 | 动态绑定(虚方法表) |
| invokeinterface | 接口方法 | 动态绑定(接口表) |
| invokedynamic | 动态语言方法 | 运行时动态绑定 |
(四)多态原理
多态的实现依赖于invokevirtual指令,每个类加载的时候JVM都会给他们逐个构建一个需方发表,里面记录指向每个方法的指针,当子类重写方法时会覆盖对应的父类方法指针。
当执行invokevirtual时,会根据实际创造的实例对象的对象头中记录的类型指针,拿到其持有的虚方法表,定位对应的方法指针并执行方法。
(五)异常原理
异常的实现依赖于异常表,异常表有如下几个字段:
from/to:监控的字节码范围(try块)target:异常处理入口(catch或finally)type:捕获的异常类型(any表示所有异常,对应finally)
当抛出异常时,JVM会检查异常表,看看当前代码行是否有被监控,然后再匹配对应的type,若匹配则会根据target进行跳转,执行对应的代码。
如果异常表没有匹配的话,则会进行栈回溯,也就是弹出当前栈帧,检查异常表,若不匹配则再弹,直到匹配成功或者虚拟机栈为空为止。
finally块的实现则是依靠赋值,JVM会将finally块的字节码分别赋值到try块结尾、catch块结尾以及异常未捕获的代码块的结尾。
因此要注意,如果finally块中有return代码时,会覆盖try块或catch块的return代码: try{ return 1;}finally{ return 2;}一键获取完整项目代码java运行 该代码最后会返回2。 再来看一种情况: int num = 0;try{ return num;}finally{ num = 1;}一键获取完整项目代码java运行 这段代码最后返回的其实是0。 其实return代码分为了两步,第一步是将num值暂存,第二步是取出并返回。 而finally块的字节码的位置是在第一步之后第二步之前,也就是说即使finally块中重赋值了num,也不影响try块中暂存的num。 换成引用对象的话稍有不同,虽然暂存的引用地址不会改变,但是可以改变对象内部的属性,因为都是对同一个引用地址的对象进行操作。
同时要注意,finally块中若存在return代码时,可能会吞掉try块中抛出的未捕获的异常,因为finally块的字节码也在异常未捕获时代码的结尾复制了一份,所以异常并不会抛出,而是直接return了。
(六)synchronized原理
这一块需要搭配之前JUC篇讲解的synchronized内存模型食用:带你轻松学习JUC-CSDN博客
这里就直接上字节码指令:
0: aload_1 // 加载obj
1: dup // 复制栈顶元素,即obj,一份用于存储(防止引用被修改),一份用于加锁
2: astore_2 // 存储锁对象到局部变量表
3: monitorenter // 获取obj的Monitor
4: … // 同步代码块执行
15: aload_2 // 将之前存储的obj压入栈顶
16: monitorexit // 正常释放obj的Monitor
17: goto 25 // 正常结束,跳转至25
20: astore_3 // 若抛出异常的话,则先将异常对象存到局部变量表
21: aload_2 // 将之前存储的obj压入栈顶
22: monitorexit // 异常时释放锁
23: aload_3 // 压入异常对象
24: athrow // 抛出
25: return
五、类加载
让我们先了解一下类的整个生命周期:
- 加载
- 通过全限定名获取类的二进制字节流。
- 将字节流转换为方法区的运行时数据结构。
- 在堆中生成.Class对象,作为访问方法区数据的入口。
- 连接
- 验证:确保类文件头的魔数是0x CAFEBABE。
- 准备:为类静态变量分配内存并赋默认值。
- 例外:若变量被static final修饰,则直接赋代码中指定的值(如public static final int value=3在准备阶段即赋值为3)。
- 解析:将常量池内的符号引用替换为直接引用。
- 初始化
- 执行<clinit>。
- 触发条件(主动引用):
- new实例化对象、访问/设置类的静态字段(非常量)、调用静态方法。
- 反射调用类。
- 初始化子类时触发父类初始化。
- 主类在JVM启动时初始化。
- 使用:类实例化成对象后进入运行阶段。
- 卸载:
- 条件:类的Class对象无引用,且加载该类的自定义类加载器被回收。
- 规则:JVM内置类加载器加载的类永不卸载;自定义类加载器加载的类可卸载。
既然提到了类,就顺便把对象的生命周期也一块说了吧: 类加载检查:检查是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。分配内存:对象所需的内存大小在类加载完成后便可确定,在堆中分配对应的内存空间给该对象。初始化零值:将分配到的内存空间都初始化为零值。根据对象当前状态设置对象头信息执行 init 方法
(一)类加载器
根据加载范围和职责,Java的类加载器分为四类:
- 启动类加载器
- 职责:加载JVM核心类库,路径为
JAVA_HOME/lib下的jar包 - 特点:无父类加载器,是所有类加载器的“根”
- 扩展类加载器
- 职责:加载JVM扩展类库,路径为
JAVA_HOME/lib/ext下的jar包 - 特点:父类加载器是启动类加载器
- 应用程序类加载器
- 职责:加载应用程序的类路径下的类
- 特点:父类加载器是扩展类加载器,也是默认的系统类加载器
- 自定义类加载器
- 职责:加载自定义路径下的类
- 特点:父类加载器默认是应用程序类加载器
(二)双亲委派模型

双亲委派模型规定:当一个类加载器需要加载某个类时,必须先委托给父类加载器加载,只有当父类加载器无法加载时,才由自己尝试加载。
假设应用程序类加载器要加载com.example.MyClass,流程如下:
- 步骤1:应用程序类加载器委托给扩展类加载器
- 步骤2:扩展类加载器委托给启动类加载器
- 步骤3:启动类加载器检查
JAVA_HOME/lib下是否有com.example.MyClass,无则返回给扩展类加载器 - 步骤4:扩展类加载器检查
JAVA_HOME/lib/ext下是否有该类,无则返回给应用程序类加载器 - 步骤5:应用程序类加载器检查
classpath下是否有该类,有则加载,否则抛出ClassNotFoundException异常
那为什么需要这么一个规范呢?其核心原因有两点:
- 避免类重复加载:同一个类只会被一个类加载器加载,防止重复创建
Class对象 - 防止核心类被篡改:确保启动类加载器加载的核心类无法被其他类加载器加载,做到资源隔离,提高安全性
(三)SPI机制 + 线程上下文类加载器
但是在有些时候,我们需要破坏双亲委派模型,因为特殊情况下该规范反而无法实现部分需求。
1. SPI机制
SPI是服务发现机制,允许框架定义接口,第三方通过实现接口并配置文件,让框架自动加载实现类。
这么乍一说可能有些同学还不明白是什么东西,其实这个机制在开发中很常见,比如说JDBC和LogBack都是依靠SPI实现的初始化。

以JDBC为例:
- 定义接口:JDK定义
java.sql.Driver接口 - 实现接口:MySQL提供
com.mysql.jdbc.Driver实现类 - 配置文件:在
mysql-connector-java.jar的META-INF/services目录下,创建java.sql.Driver文件,内容为实现类的全类名 - 加载实现类:框架通过
ServiceLoader类加载配置文件中的实现类
一套操作下来行云流水,但是问题就处在加载实现类这一步,Driver接口是在核心类库里的,只能启动类加载器来调用,但是MySQL提供的Driver实现类属于第三方jar包,是由应用程序类加载器来加载的。但是根据双亲委派模型,启动类加载器无法加载归属于应用程序类加载器的类,所以在这里我们需要打破双亲委派模型。
2. 线程上下文类加载器
线程上下文类加载器是一种打破双亲委派模型的机制,它允许父类加载器加载子类加载器的类,是线程的一个属性,默认值为应用程序类加载器。
通过手动声明Thread.currentThread().getContextClassLoader()获取线程上下文类加载器,然后用这个类加载器进行加载对应的类即可。
打破双亲委派模型听上去很高端,但实际也就是主动指定类加载器进行加载而已。
JDBC也是依靠这个机制解决该问题,其内部有一个核心类DriverManager,这个是被启动类加载器所加载,但这个类中的getConnection()方法内部实现是调用了线程上下文类加载器来加载Driver类。
一般情况下这些中间件都有自己的核心类来帮助我们打破双亲委派机制,不需要我们操作,但这也仅限一般情况。 例如我之前开发过程中遇到的一个问题:Sharding-JDBC 定时任务 SQL 无响应的解决方案-CSDN博客 简单概括就是我在SpringTask使用到了Sharding-JDBC进行分库查询操作,但是关键的SQL语句却无法正常运行。 现如今可以通过刚才学到的知识作出解释:SpringTask的所调度的线程是ScheduledThreadPool的,而线程池里创建出的线程默认的类加载器无法覆盖到所需要的配置文件目录,所以需要手动声明线程上下文类加载器打破双亲委派模型。
六、运行期优化
(一)语法糖
语法糖是编译器提供的便捷语法,在编译阶段会被还原为基础结构,对运行时无功能影响,但提升代码可读性和开发效率。
1. 默认构造
当类没有显式定义任何构造函数时,编译器会自动生成一个无参构造函数(默认构造)。
作用:保证类能被无参实例化。
// 源文件:未定义构造函数的类class User { String name;}
// 编译后:编译器自动添加无参构造class User { String name; // 自动生成的无参构造 public User() {}}若类显式定义了构造函数,编译器不会再生成默认构造,此时若尝试new User(),会编译报错。
2. 自动拆装箱
基本类型与包装类型之间的自动转换。
作用:避免手动装箱或拆箱,简化代码。
// 源文件Integer x = 1;int y = x;System.out.println(x + y);
// 编译后Integer x = Integer.valueOf(1);int y = Integer.intValue(x);System.out.println(Integer.intValue(x) + y);包装类型可为null,拆箱时需避免引发空指针异常。
3. 泛型擦除
Java的泛型是编译时特性,运行时会擦除泛型类型参数。
作用:兼容JDK 1.5之前的非泛型代码。
- 无边界泛型(如
List<T>):T擦除为Object。 - 有边界泛型(如
List<T extends Number>):T擦除为Number。
// 源文件:泛型类List<String> list = new ArrayList<>();list.add("Java");String s = list.get(0); // 自动强转
// 编译后:泛型擦除为原生类型List list = new ArrayList();list.add("Java");String s = (String) list.get(0);4. 可变参数
用…表示方法的可变参数,允许传入任意数量的参数。
作用:替代数组参数,简化调用。
// 源文件:可变参数方法void print(String... args) {}// 调用:传入任意数量参数print("Java", "Python", "Go");
// 编译后:可变参数转为数组void print(String[] args) {}// 调用转为:print(new String[]{"Java", "Python", "Go"});可变参数必须作为方法的最后一个参数。
还需要避免传入null,因为数组无法存储null值,会引发空指针异常。
5. For-each
作用:替代传统for循环,简化代码。
// 1. 遍历集合(List)List<String> list = Arrays.asList("Java", "Python");for (String s : list) { System.out.println(s);}
// 编译后:转为迭代器Iterator<String> it = list.iterator();while (it.hasNext()) { String s = it.next(); System.out.println(s);}
// 2. 遍历数组(int[])int[] arr = {1, 2, 3};for (int i : arr) { System.out.println(i);}
// 编译后:转为索引循环for (int j=0; j<arr.length; j++) { int i = arr[j]; System.out.println(i);}遍历集合时,不能修改集合结构,否则会破坏迭代器状态。
6. -String
Java 7及以上支持在switch语句中使用字符串
作用:避免用if-else判断字符串,提升代码可读性。
// 源文件:switch-stringString language = "Java";switch (language) { case "Java": System.out.println("Java"); break; case "Python": System.out.println("Python"); break;}
// 编译后:转为hash值的switchint hash = language.hashCode();switch (hash) { case 2301506: // "Java"的hashCode() if (language.equals("Java")) { System.out.println("Java"); break; } case 114179315: // "Python"的hashCode() if (language.equals("Python")) { System.out.println("Python"); break; } default: break;}switch中的字符串不能为null。
之所以还需要equals是为了兜底哈希冲突。
7. Switch-enum
Java 5及以上支持在switch语句中使用枚举类型。
作用:替代if-else判断枚举,且类型安全**。**
// 定义枚举enum Color { RED, GREEN, BLUE }
// 源文件:switch-enumColor color = Color.RED;switch (color) { case RED: System.out.println("红色"); break; case GREEN: System.out.println("绿色"); break;}
// 编译后:转为ordinal()的switchint ordinal = color.ordinal(); // RED的ordinal()是0,GREEN是1switch (ordinal) { case 0: System.out.println("红色"); break; case 1: System.out.println("绿色"); break;}枚举的ordinal值由定义顺序决定,修改顺序会影响switch逻辑。
8. 枚举
用enum关键字定义的特殊类,用于表示固定的常量集合。
替代public static final常量,提供更严谨的类型检查和丰富的方法。
// 源文件:枚举类enum Color { RED("#FF0000"), GREEN("#00FF00"), BLUE("#0000FF"); // 枚举常量 private String hex; // 字段 // 构造函数(只能是private,编译器自动添加) Color(String hex) { this.hex = hex; } // 方法 public String getHex() { return hex; }}
// 使用枚举Color red = Color.RED;System.out.println(red.getHex()); // 输出:#FF0000System.out.println(Color.valueOf("GREEN")); // 输出:GREEN9. try-with-resources
作用:替代try-catch-finally手动关闭资源,避免遗漏close()导致的资源泄漏。
// 源文件:try-with-resources(自动关闭文件流)try (InputStream in = new FileInputStream("test.txt")) { byte[] bytes = new byte[1024]; in.read(bytes); // 读取文件} catch (IOException e) { e.printStackTrace();}
// 编译后:转为try-catch-finally(手动关闭资源)InputStream in = null;try { in = new FileInputStream("test.txt"); // 读取逻辑} catch (IOException e) { e.printStackTrace();} finally { if (in != null) { try { in.close(); // 自动添加的close() } catch (IOException e) { e.printStackTrace(); } }}资源必须实现AutoCloseable接口。
10. 重写桥段
当子类重写父类的泛型方法时,由于泛型擦除导致方法签名不匹配,编译器会自动生成桥接方法,确保动态绑定正确。
// 父类class Parent<T> { public void method(T t) { // 泛型方法,擦除后为method(Object t) System.out.println("Parent: " + t); }}
// 子类class Child extends Parent<String> { @Override public void method(String t) { // 重写父类的method(T),擦除后为method(String t) System.out.println("Child: " + t); }}可以发现参数类型对不上,因此需要桥接方法:
Method[] methods = Child.class.getMethods();内部用修饰器模式将子类的method方法以Object为返回值的方法包裹起来。
11. 匿名内部类
作用:避免为简单逻辑创建单独的类文件,简化代码。
// 源文件:匿名内部类public class Outer { public void startThread() { final int count = 5; // 外部变量必须是final修饰 // 匿名内部类:实现Runnable接口 Runnable runnable = new Runnable() { @Override public void run() { for (int i=0; i<count; i++) { // 访问外部变量count System.out.println("Thread: " + i); } } }; new Thread(runnable).start(); // 启动线程 }}匿名内部类中使用的外部变量必须有final修饰,为了避免在匿名内部类使用的时候外部类对其进行了更改。
(二)JIT编译优化
JIT会将Java字节码中的“热点代码”(频繁执行的代码)动态编译为机器码,替代解释执行,大幅提升程序运行性能。是Java实现“一次编译,到处运行”且保持高性能的关键机制。
那JIT如何识别热点代码的呢?
- 方法调用计数器:统计方法被调用的次数,超过阈值时,触发JIT编译。
- 循环回边计数器:统计循环体的执行次数,超过阈值时,触发栈上替换**,**即循环执行到一半时,将循环体从解释执行切换为编译后的机器码,避免等待循环结束。
优化具体用到的技术如下:
1. 方法内联
将被调用方法的代码直接嵌入到调用方方法中,消除方法调用的开销。
// 原代码:调用getter方法class User { private String name; // 小方法 public String getName() { return name; }}
User user = new User();String name = user.getName();
// JIT内联后:直接访问字段(无方法调用)String name = user.name;内联的前提必须是热点代码,且方法体小。
2.逃逸分析
这点在上文的堆内存处有提到过,这里不再做赘述。
3. 锁优化
这个其实就是synchronized的锁升级、锁粗化以及锁消除机制,在JUC篇已经讲解过,这里也不再做赘述。
4. 循环优化
(1)循环展开
将循环体复制多份,减少循环的迭代次数,消除循环控制开销。
// 原代码:循环4次 for (int i=0; i<4; i++) { sum += arr[i]; }
// JIT优化后:循环展开(复制4次循环体) sum += arr[0]; sum += arr[1]; sum += arr[2]; sum += arr[3];这样就省去了i++以及条件判断的开销。
(2)循环不变量提升
将循环内不变的表达式移到循环外,避免重复计算。
// 原代码:循环内的不变表达式(b*c) int a = 0; int b = 10; int c = 20; for (int i=0; i<1000; i++) { // b*c的值不变,每次循环都计算 a += b * c; }
// JIT优化后:将b*c移到循环外 int a = 0; int b = 10; int c = 20; int bc = b * c; for (int i=0; i<1000; i++) { // 直接使用bc的值 a += bc; }(3)循环向量优化
利用CPU的向量指令,将循环中的连续操作并行化,一次处理多个元素。
// 原代码:遍历数组累加 int[] arr = new int[1000]; int sum = 0; for (int i=0; i<arr.length; i++) { sum += arr[i]; }
// JIT优化后:一次处理4个元素 int sum = 0; int i=0; // 处理前996个元素(4的倍数) for (; i<=arr.length-4; i+=4) { // 使用AVX指令,一次读取arr[i]~arr[i+3],累加为一个向量值 sum += _mm256_add_epi32(arr[i], arr[i+1], arr[i+2], arr[i+3]); } // 处理剩余元素(0~3个) for (; i<arr.length; i++) { sum += arr[i]; }向量优化可将循环性能提升2-4倍。
5. 分层编译
JVM结合两种编译器实现性能平衡:
| 编译器类型 | 特点 | 适用场景 |
|---|---|---|
| C1(Client) | 编译速度快,优化简单(方法内联、常量传播) | 短期运行任务,关注启动速度 |
| C2(Server) | 编译速度慢,全局深度优化(逃逸分析、锁消除) | 长期运行服务,追求峰值性能 |
Client模式和Server模式在上文也提到过,不清楚的可以往上翻翻。
JVM的编译是随着时间而变化的:
- Tier 0:解释执行
- Tier 1:C1编译
- Tier 2:C1编译+ profiling
- Tier 3:C1编译+ 更深入的profiling
- Tier 4:C2编译
什么是profiling? Profiling是动态分析程序运行时行为的关键技术,通过收集程序执行过程中的资源使用数据(如CPU时间、内存分配、I/O操作等),识别性能瓶颈,为优化代码提供依据。
程序启动时,C1快速编译热点代码,保证启动速度;运行一段时间后,C2根据profiling信息生成更优的机器码,使性能逐步达到峰值。
所以Java项目启动慢的核心原因就在此,需要逐步地提高性能,峰值是接近C++。
关于JMM的知识可见我另一篇博客中的六 ~ 十一部分: 带你轻松学习JUC-CSDN博客https://blog.csdn.net/2401_88959292/article/details/149500338?spm=1001.2014.3001.5501 本文的多处知识也在其中做了详细讲解,请务必结合两者一并学习~
码文不易,留个赞再走吧
原文链接: 带你轻松学习JVM 作者: Yilena
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时










