JVM - 堆 (Heap)
# 1. 堆的核心概念 (Heap)
Java 堆 (Heap) 是 Java 虚拟机(JVM)管理的最大一块内存区域,也是理解 JVM 内存管理和垃圾回收(GC)机制的核心。
关键特性:
JVM 进程唯一且线程共享:
- 一个 JVM 实例(即一个 Java 进程)只拥有一个堆内存区域。
- 堆是所有线程共享的内存区域。这意味着多个线程可以同时访问堆中的对象,因此在并发访问时需要考虑线程安全问题。
创建时机:Java 堆在 JVM 启动时即被创建,其初始空间大小也随之确定。
内存大小可调:堆的大小可以通过 JVM 参数进行配置和调整(例如
-Xms
和-Xmx
)。逻辑连续,物理可不连续:根据《Java虚拟机规范》,堆所占用的内存空间可以在物理上不连续,但在逻辑上应当被视为一块连续的内存区域。
对象存储核心:规范中明确指出:“所有的对象实例以及数组都应当在运行时分配在堆上 (The heap is the run-time data area from which memory for all class instances and arrays is allocated)”。
- 注意:这是一个普遍原则,但随着 JIT 编译优化技术(如逃逸分析、栈上分配)的发展,“几乎”所有的对象实例都在堆上分配更为准确。极少数情况下,未逃逸的对象可能被优化分配在栈上。
- 栈与堆的关系:栈帧中(特别是局部变量表)保存的是对象的引用 (reference),这个引用指向堆中对象或数组的实际存储位置。对象实体本身不会存储在栈上。
生命周期管理:与栈中数据(方法结束即销毁)不同,堆中对象的生命周期不由方法调用直接决定。对象不再被任何存活的引用指向时,并不会立即被移除,而是等待垃圾收集器 (Garbage Collector, GC) 在合适的时机进行回收。
- GC 的执行通常伴随着“Stop-The-World (STW)”暂停,为了减少对用户线程的影响,对象的回收是延时的、批量的。
GC 的重点区域:堆是 JVM 进行垃圾回收操作的最主要区域。GC 的主要工作就是识别堆中不再存活的对象并回收它们占用的内存。
代码示例:对象和数组在堆上分配
// 文件名: SimpleHeap.java
public class SimpleHeap {
private int id; // 实例变量(成员变量),随对象存储在堆中
// 构造函数
public SimpleHeap(int id) {
this.id = id;
}
// 实例方法
public void show() {
System.out.println("My ID is " + id);
}
public static void main(String[] args) {
// sl 和 s2 是局部变量(引用),存储在 main 方法的栈帧中
// new SimpleHeap(1) 和 new SimpleHeap(2) 创建的对象实例存储在堆中
SimpleHeap sl = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
// arr 是局部变量(引用),存储在 main 方法的栈帧中
// new int[10] 创建的 int 数组对象存储在堆中
int[] arr = new int[10];
// arr1 是局部变量(引用),存储在 main 方法的栈帧中
// new Object[10] 创建的 Object 数组对象存储在堆中
Object[] arr1 = new Object[10];
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
内存布局示意图:
(图中清晰地展示了栈中存放引用,堆中存放对象实例和数组实例)
# 堆内存的内部结构细分 (Heap Generations)
为了优化垃圾回收的效率(特别是针对对象生命周期不同的特点),HotSpot JVM 将堆内存从逻辑上划分为几个不同的区域(或称为“代”,Generation):
Java 7 及之前版本:
- 新生代 (Young Generation / New):存放新创建的对象和生命周期较短的对象。
- Eden 区 (Eden Space):新对象主要在这里分配。
- Survivor 区 (Survivor Space):分为两个等大的区域,通常称为 Survivor 0 (S0 或 From) 和 Survivor 1 (S1 或 To)。用于存放经过一次或多次 Minor GC 后仍然存活的对象。
- 老年代 (Tenured Generation / Old):存放生命周期较长的对象,通常是从新生代晋升过来的。
- 永久代 (Permanent Generation / Perm):逻辑上属于堆的一部分(但在 HotSpot 实现中可能使用不同内存区域),用于存储类的元数据、常量池、静态变量等。大小固定,受
-XX:PermSize
和-XX:MaxPermSize
控制。
Java 8 及之后版本:
- 新生代 (Young Generation / New):结构和作用同上 (Eden + S0 + S1)。
- 老年代 (Tenured Generation / Old):结构和作用同上。
- 元空间 (Metaspace):取代了永久代。用于存储类的元数据。与永久代的主要区别是,元空间使用的是本地内存 (Native Memory),而不是 JVM 堆内存。其大小默认只受可用本地内存限制(可以通过
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
进行控制)。
常用简称/别名:
- 新生区 = 新生代 = 年轻代
- 养老区 = 老年区 = 老年代 (注意:有时非正式场合下,老年代/永久区/永久代可能被混用,但在 JDK 8 后应明确区分老年代和元空间)
堆结构演变图示:
(包含新生代、老年代、永久代)
(永久代
PERMANENT
被元空间 METASPACE
替代)
# 2. 使用 JVisualVM 可视化查看堆内存
JDK 自带了一个强大的可视化监控工具 jvisualvm.exe
(位于 JDK 安装目录的 bin
目录下),可以用来实时监控 Java 应用程序的 CPU、内存(包括堆和元空间)、线程等状态。
步骤:
启动 JVisualVM:双击运行
jvisualvm.exe
。安装 Visual GC 插件(如果尚未安装):
- 打开 JVisualVM 后,选择菜单
工具 (Tools)
->插件 (Plugins)
。 - 在
可用插件 (Available Plugins)
标签页中找到Visual GC
,选中并点击安装 (Install)
。 - 按照提示完成安装并重启 JVisualVM。
- 打开 JVisualVM 后,选择菜单
运行目标 Java 程序:例如,运行以下简单程序,让它保持运行状态:
// 文件名: HeapDemo.java public class HeapDemo { public static void main(String[] args) { System.out.println("start..."); try { // 让程序暂停足够长的时间,以便观察 Thread.sleep(1000000); // 暂停约 16 分钟 } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("end..."); } }
1
2
3
4
5
6
7
8
9
10
11
12
13连接到目标程序:在 JVisualVM 的左侧“应用程序 (Applications)”窗格中,找到并双击正在运行的
HeapDemo
进程。查看 Visual GC:在右侧打开的
HeapDemo
标签页中,选择Visual GC
子标签页。(上图展示了 Visual GC 界面,可以清晰地看到 Metaspace、Old Gen、Eden、S0、S1 各区域的大小和使用情况)
# 3. 设置堆内存大小与 OOM (OutOfMemoryError)
合理配置堆内存大小对于 Java 应用的性能和稳定性至关重要。
JVM 参数:
-Xms<size>
:设置 Java 堆的初始内存大小。等价于-XX:InitialHeapSize=<size>
。- JVM 启动时就会分配指定大小的内存。
-Xmx<size>
:设置 Java 堆的最大内存大小。等价于-XX:MaxHeapSize=<size>
。- 堆内存允许扩展到的上限。
- 单位:可以使用
k/K
(KB),m/M
(MB),g/G
(GB)。例如-Xms512m
,-Xmx2g
。
OOM 异常:
如果程序运行过程中,需要分配的内存超过了 -Xmx
指定的最大限制,并且经过垃圾回收后仍然无法获得足够的空间,JVM 就会抛出 java.lang.OutOfMemoryError: Java heap space
异常。
设置建议: 通常建议将 -Xms
和 -Xmx
设置为相同的值。这样做的好处是:
- 避免运行时堆的动态扩展和收缩开销:如果初始值和最大值不同,JVM 可能会在负载变化时频繁地调整堆大小,这会带来额外的性能开销。
- 启动时即获得稳定性能:应用启动时就拥有了其可能需要的最大堆空间,避免了运行中因堆扩展引发的暂停。
默认堆大小:
如果没有显式设置 -Xms
和 -Xmx
,JVM 会根据物理内存自动计算默认值:
- 默认初始堆大小 (
-Xms
):通常是物理内存的 1/64。 - 默认最大堆大小 (
-Xmx
):通常是物理内存的 1/4(但也有上限,例如不会超过 32GB 等,具体取决于 JVM 版本和配置)。
# 实验:设置和查看堆内存
代码:
// 文件名: HeapSpaceInitial.java
public class HeapSpaceInitial {
public static void main(String[] args) {
// 获取 Java 虚拟机中的初始堆内存总量 (约等于 -Xms)
// totalMemory() 返回的是当前已分配给 JVM 的内存,可能小于 -Xms,但通常接近初始值
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 获取 Java 虚拟机试图使用的最大堆内存量 (等于 -Xmx)
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms (approx): " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");
// 尝试根据默认比例反推物理内存 (仅为估算)
System.out.println("Estimated System Memory (based on initial): " + initialMemory * 64.0 / 1024 + "G");
System.out.println("Estimated System Memory (based on max): " + maxMemory * 4.0 / 1024 + "G");
try {
// 保持进程存活以便观察
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
运行情况 1:不设置参数 (使用默认值)
输出示例 (取决于具体机器配置):
-Xms (approx): 243M
-Xmx : 3607M
Estimated System Memory (based on initial): 15.1875G
Estimated System Memory (based on max): 14.08984375G
2
3
4
(可以看到初始内存远小于最大内存,且根据默认比例估算的物理内存接近实际值,但有误差,因为操作系统也会占用内存)
运行情况 2:设置参数 -Xms600m -Xmx600m
在 IDE 的 VM Options 中设置:
输出结果:
-Xms (approx): 575M
-Xmx : 575M
2
为什么 totalMemory()
显示的是 575M 而不是 600M?
Runtime.getRuntime().totalMemory()
返回的是 JVM 当前已经从操作系统申请到并管理的内存总量,这个值可能略小于通过 -Xms
设置的初始堆大小。更精确地查看堆各区域大小,可以使用命令行工具或 -XX:+PrintGCDetails
参数。
# 查看堆内存分配详情的方式
方式一:使用
jps
和jstat
命令行工具
运行 Java 程序。
打开命令行/终端,输入
jps
查找 Java 进程 ID (pid)。输入
jstat -gc <pid>
查看 GC 和堆内存统计信息。列含义解释:
S0C
/S1C
: Survivor 0/1 区的容量 (KB)。S0U
/S1U
: Survivor 0/1 区已使用的容量 (KB)。EC
: Eden 区的容量 (KB)。EU
: Eden 区已使用的容量 (KB)。OC
: 老年代的容量 (KB)。OU
: 老年代已使用的容量 (KB)。MC
/MU
: Metaspace 容量/使用量 (KB)。CCSC
/CCSU
: Compressed Class Space 容量/使用量 (KB)。YGC
/YGCT
: Young GC 次数/耗时 (秒)。FGC
/FGCT
: Full GC 次数/耗时 (秒)。GCT
: 总 GC 耗时 (秒)。
验证堆总大小: 根据上图
jstat
输出计算(新生代 + 老年代):S0C + S1C + EC + OC
=25600 + 25600 + 153600 + 409600
=614400 KB
614400 / 1024
=600 MB
。这与我们设置的-Xms600m
和-Xmx600m
匹配。为什么
totalMemory()
返回 575M?totalMemory()
通常反映的是 新生代当前可用容量 + 老年代当前容量。因为 S0 和 S1 在任何时候只有一个处于使用状态(另一个是空的,用于下次 GC 复制),所以totalMemory
可能近似于(EC + 一个S区) + OC
或已分配的新生代大小 + 已分配的老年代大小
。 计算EC + S0C + OC
=153600 + 25600 + 409600
=588800 KB
588800 / 1024
=575 MB
。这与Runtime.totalMemory()
的输出吻合。
方式二:使用
-XX:+PrintGCDetails
JVM 参数
在 -Xms
和 -Xmx
参数后添加 -XX:+PrintGCDetails
。程序运行结束时(或发生 GC 时),会在控制台打印详细的 GC 日志和堆内存信息。
(输出中会明确显示 Young Gen (Eden, From, To) 和 Old Gen (或 ParOldGen) 各区域的大小和使用情况)
# 示例:触发 OutOfMemoryError
通过不断创建对象填满堆空间来模拟 OOM。
// 文件名: OOMTest.java
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class OOMTest {
public static void main(String[] args) {
List<Picture> list = new ArrayList<>();
while (true) {
try {
// 短暂休眠,减缓创建速度,便于观察
Thread.sleep(10); // 休眠 10 毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
// 持续创建大小随机的 Picture 对象并加入列表
// new Random().nextInt(1024 * 1024) 会创建 0 到 1MB 大小的 byte 数组
list.add(new Picture(new Random().nextInt(1024 * 1024)));
}
}
}
// 用于占用内存的简单类
class Picture {
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
设置 VM Options: -Xms20m -Xmx20m
(设置一个较小的堆,更容易触发 OOM)
运行并使用 JVisualVM 监控:
运行
OOMTest
。打开 JVisualVM,连接到
OOMTest
进程。切换到
Visual GC
标签页。观察堆内存变化。(可以看到 Eden 区、Old Gen 被逐渐填满,GC 活动频繁)
程序最终会因无法分配新对象而抛出 OOM 异常。控制台输出:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at OOMTest.<init>(OOMTest.java:20) at OOMTest.main(OOMTest.java:12)
1
2
3使用 JVisualVM 的 Heap Dump 功能分析 OOM:
- 在 JVisualVM 的 "监视 (Monitor)" 标签页,当 OOM 发生前后,可以点击 "堆 Dump (Heap Dump)" 按钮生成堆快照。
- 打开堆快照文件,可以分析是哪些对象占用了大量内存,定位内存泄漏。
(可以看到
byte[]
(来自Picture
类) 占用了绝大部分堆空间)
# 4. 新生代 (Young Generation) 与 老年代 (Old Generation)
将堆内存划分为新生代和老年代是基于对大量 Java 应用对象生命周期统计分析得出的分代收集理论 (Generational Collection Theory):
- 弱分代假说 (Weak Generational Hypothesis):绝大多数对象都是“朝生夕灭”的,生命周期很短。
- 强分代假说 (Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说 (Intergenerational Reference Hypothesis):跨代引用(例如老年代对象引用新生代对象)相对于同代引用来说仅占极少数。(这条假说指导了 GC 如何高效扫描,例如只需扫描少量老年代对象即可找到指向新生代对象的引用)。
基于这些假说,将堆分为不同区域,并对不同区域采用不同的垃圾回收策略,可以显著提高 GC 效率:
- 新生代 (Young Generation):
- 特点:对象创建频繁,生命周期短,死亡率高。
- GC 策略:采用复制 (Copying) 算法进行垃圾回收(称为 Minor GC 或 Young GC)。回收频率高,速度快,但需要额外的空间(Survivor 区)。
- 内部结构:
- Eden 区:新对象的主要出生地。
- Survivor 0 (From) 区 和 Survivor 1 (To) 区:大小相等,用于存放经过 Eden 区 Minor GC 后仍然存活的对象。每次 Minor GC 后,存活对象会被复制到空的那个 Survivor 区(To 区),然后 From 区和 To 区角色互换。
- 老年代 (Old Generation):
- 特点:存放生命周期长的对象(从新生代晋升而来)或者大对象。对象存活率高。
- GC 策略:通常采用标记-清除 (Mark-Sweep) 或 标记-整理 (Mark-Compact) 算法进行垃圾回收(称为 Major GC 或 Old GC)。回收频率低,但单次回收耗时较长。
堆内各区域默认比例 (HotSpot JVM):
- 新生代 : 老年代 = 1 : 2 (
-XX:NewRatio=2
)。即新生代占整个堆大小的 1/3。可以通过修改NewRatio
调整,例如-XX:NewRatio=4
表示 新生代:老年代 = 1:4,新生代占 1/5。- 调优考虑:如果应用中生命周期长的对象较多,可以适当增大老年代比例(调大
NewRatio
)。
- 调优考虑:如果应用中生命周期长的对象较多,可以适当增大老年代比例(调大
- Eden : Survivor0 : Survivor1 = 8 : 1 : 1 (
-XX:SurvivorRatio=8
)。即 Eden 区占新生代的 8/10,每个 Survivor 区占 1/10。可以通过修改SurvivorRatio
调整。- 注意:Survivor 比例是 Eden 区与一个 Survivor 区的比例。
几乎所有对象都在 Eden 区创建: 这是普遍规律,但有例外(如大对象直接进老年代)。
大部分对象在新生代消亡: IBM 研究表明,典型应用中 80% 甚至更多的新生代对象在第一次 Minor GC 时就会被回收。
配置新生代大小:
可以使用 -Xmn<size>
直接设置新生代的最大内存大小(不推荐,建议通过 -Xms
, -Xmx
和 NewRatio
间接控制)。
查看各区域比例的示例代码:
// 文件名: EdenSurvivorTest.java
/**
* JVM 参数演示:
* -Xms600m -Xmx600m (设置总堆大小)
* -XX:NewRatio=2 (默认值,新生代:老年代 = 1:2)
* -XX:SurvivorRatio=8 (默认值,Eden:S0:S1 = 8:1:1)
* -XX:-UseAdaptiveSizePolicy (关闭自适应大小策略,确保比例生效,通常不建议关闭)
* -Xmn: (设置新生代大小,一般不设置,让 JVM 根据 NewRatio 计算)
*/
public class EdenSurvivorTest {
public static void main(String[] args) {
System.out.println("只是为了让进程保持运行,方便使用工具查看内存...");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
运行此程序,并使用 jstat -gc <pid>
或 Visual GC 查看,可以验证设置的比例是否生效(需要注意自适应策略 -XX:+UseAdaptiveSizePolicy
默认开启,可能会动态调整比例,测试时可使用 -XX:-UseAdaptiveSizePolicy
关闭它来观察固定比例)。
(例如,总堆 600M,NewRatio=2,则新生代 200M,老年代 400M。新生代 200M,SurvivorRatio=8,则 Eden 160M,S0=20M,S1=20M。)
# 5. 图解对象分配与晋升过程
理解对象如何在新生代和老年代之间流转是掌握 GC 的关键。
基本流程:
- Eden 区分配:新创建的对象首先尝试在 Eden 区分配内存。
- Eden 区满触发 Minor GC:当 Eden 区空间不足以分配新对象时,触发一次 Minor GC(也叫 Young GC)。
- Minor GC 清理:GC 会扫描 Eden 区 和 From Survivor 区 (S0),标记存活对象。
- 复制到 To 区:将 Eden 区和 From 区中的存活对象复制到 To Survivor 区 (S1)。
- 年龄增加:所有复制到 To 区的存活对象,其年龄计数器加 1。
- 清空 Eden 和 From 区:复制完成后,Eden 区和 From 区被清空。
- 角色互换:From 区 (S0) 和 To 区 (S1) 的角色互换。原来的 To 区现在变成了 From 区,原来的 From 区变成了空的 To 区,等待下一次 Minor GC。
- 重复过程:后续新对象继续在 Eden 区分配,Eden 满再触发 Minor GC,存活对象从 Eden 和新的 From 区复制到新的 To 区,年龄再加 1。
- 晋升老年代 (Promotion):当 Survivor 区(无论是哪个)中的对象年龄达到一定阈值(由
-XX:MaxTenuringThreshold
参数设定,默认通常是 15)时,在下次 Minor GC 时,该对象不再被复制到另一个 Survivor 区,而是被晋升 (Promote) 到老年代 (Old Generation)。 - 老年代 GC (Major GC / Full GC):当老年代空间也不足时,会触发 Major GC(只回收老年代,CMS 等收集器支持)或 Full GC(回收整个堆,包括新生代和老年代,有时还包括方法区/元空间)。Major/Full GC 通常比 Minor GC 慢得多,STW 时间更长。
- OOM:如果在执行 Full GC 后,老年代仍然无法容纳所需的对象(例如,一个超大对象或晋升所需空间),则抛出
OutOfMemoryError
。
图解过程:
- 初始状态:对象在 Eden 创建。
- 第一次 Minor GC:Eden 满,GC 后存活对象(绿色)进入 S0(From 区),年龄为 1。红色对象被回收。
- 第二次 Minor GC:Eden 再次满,GC 后,Eden 和 S0(From 区)中的存活对象进入 S1(To 区),年龄加 1。S0 被清空。S0 和 S1 角色互换。
- 对象晋升:经过多次 Minor GC,Survivor 区对象的年龄不断增加。当年龄达到阈值(如 15),在下次 GC 时晋升到老年代。
思考:Survivor 区满了怎么办?
- Survivor 区满不会直接触发 Minor GC。Minor GC 的触发条件是 Eden 区满。
- 如果在 Minor GC 期间,需要将存活对象复制到 To Survivor 区,但 To 区空间不足以容纳所有存活对象,会发生以下情况(取决于具体 GC 策略):
- 直接晋升 (Premature Promotion):部分对象(通常是年龄较大或占用空间较大的)会被直接晋升到老年代,即使它们的年龄还没达到
MaxTenuringThreshold
。 - 动态年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象会直接进入老年代,无需等到阈值年龄。
- 直接晋升 (Premature Promotion):部分对象(通常是年龄较大或占用空间较大的)会被直接晋升到老年代,即使它们的年龄还没达到
GC 频率总结: 垃圾回收的特点是:频繁在新生代收集 (Minor GC),较少在老年代收集 (Major GC / Full GC),几乎不在元空间/永久代收集 (Full GC 时可能涉及)。
# 5.1 对象分配的特殊情况总结
- 优先 Eden:绝大多数对象优先在 Eden 分配。
- 大对象直接进老年代:如果一个对象所需内存大小超过了 Eden 区的容量(或者超过了 JVM 设置的某个阈值
-XX:PretenureSizeThreshold
,如果启用),它会直接在老年代分配。目的是避免大对象在新生代(特别是 Survivor 区)之间频繁复制,降低 GC 开销。应尽量避免创建生命周期短的超大对象。 - 长期存活对象晋升老年代:达到
-XX:MaxTenuringThreshold
年龄阈值的对象。 - 动态年龄判断晋升:Survivor 区同年龄对象总大小超过一半时,该年龄及以上对象提前晋升。
- 空间分配担保失败:Minor GC 前检查老年代剩余空间。如果空间不足以容纳新生代所有对象(或历次晋升平均大小),且不允许担保失败(JDK 7 后实际已不允许配置为失败),则会直接触发一次 Full GC 来腾出老年代空间,然后再进行 Minor GC(如果还需要)。
- Survivor 空间不足:Minor GC 时,存活对象无法完全放入 To Survivor 区,多余的对象会直接晋升到老年代。
# 5.2 代码演示对象分配与 GC 过程
使用 Visual GC 动态观察对象分配和 GC 情况。
代码: (创建大小随机的对象,模拟真实场景)
// 文件名: HeapInstanceTest.java
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* 代码演示对象在堆中的分配过程及 GC 触发
*/
public class HeapInstanceTest {
// 创建一个 byte 数组,大小在 0 到 200KB 之间随机
byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
public static void main(String[] args) {
List<HeapInstanceTest> list = new ArrayList<>();
while (true) {
// 不断创建 HeapInstanceTest 对象并加入列表
list.add(new HeapInstanceTest());
try {
// 短暂休眠,控制创建速度
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
break; // 发生中断则退出循环
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
设置 VM Options: -Xms20m -Xmx20m
(同样设置小堆便于观察)
运行并观察 Visual GC:
启动程序,用 JVisualVM 连接并打开 Visual GC。
观察现象:
- Eden 区:内存使用量周期性地上升然后瞬间下降(发生 Minor GC)。
- Survivor 区 (S0, S1):内存使用量在两个区域之间交替变化,并且通常占用不高(因为很多对象在 Minor GC 时死掉了)。
- Old Gen (老年代):内存使用量逐渐上升(有对象晋升进来)。
- GC 次数和时间:图表下方会显示 Minor GC 和 Full GC 的次数以及累计耗时。
- 最终 OOM:当 Old Gen 也被填满,并且 Full GC 也无法回收足够空间时,程序抛出
OutOfMemoryError
。
# 5.3 常用的堆分析/调优工具
- JDK 命令行工具:
jps
,jstat
,jmap
(生成 heap dump),jhat
(分析 heap dump,较少用),jstack
(看线程栈)。 - Eclipse MAT (Memory Analyzer Tool):强大的离线 heap dump 分析工具。
- JConsole:JDK 自带的 GUI 监控工具,提供基本的内存、线程、类加载监控。
- VisualVM:JDK 自带,功能比 JConsole 更强大,支持插件扩展(如 Visual GC),可以进行 CPU Profiling, Memory Profiling, 生成和分析 Heap Dump、Thread Dump。(实时监控推荐)
- JProfiler:商业的、功能非常全面的 Java 性能分析工具(IDEA 有对应插件)。(深度分析推荐)
- Arthas:阿里巴巴开源的 Java 诊断利器,可以在线分析运行中的 Java 应用,无需重启。
- Java Flight Recorder (JFR) & JDK Mission Control (JMC):JDK 内置的低开销事件记录和分析工具,适用于生产环境监控。
- GCViewer / GCEasy.io:用于可视化分析 GC 日志的工具。
# 5.4 对象分配过程总结
- 新对象优先在 Eden 分配。
- Eden 满触发 Minor GC,存活对象移至 Survivor To 区,年龄+1。
- Survivor 区采用复制算法和角色互换机制,谁空谁是 To。
- 对象年龄达阈值(
MaxTenuringThreshold
)或满足动态年龄判断或 Survivor 放不下时,晋升至老年代。 - 大对象可能直接在老年代分配。
- 老年代满触发 Major GC 或 Full GC。
- Full GC 后空间仍不足则 OOM。
- 新生代使用复制算法是为了高效回收大量死亡对象并减少内存碎片。
# 6. GC 类型详解与触发条件
JVM 进行垃圾回收时,根据回收的范围和目标,可以分为不同的类型。理解这些类型有助于分析 GC 日志和进行性能调优。
主要分类:
部分收集 (Partial GC):指不是对整个 Java 堆进行的收集。
- 新生代收集 (Minor GC / Young GC):
- 目标:只针对新生代(Eden + 两个 Survivor 区)进行的垃圾收集。
- 触发时机:通常是 Eden 区满时触发。
- 特点:发生非常频繁,回收速度相对较快,对应用停顿(STW)时间较短。
- 注意:Minor GC 可能会引发 STW,暂停所有用户线程。
- 老年代收集 (Major GC / Old GC):
- 目标:只针对老年代进行的垃圾收集。
- 触发时机:通常是老年代空间不足时。
- 实现:目前只有 CMS (Concurrent Mark Sweep) 收集器以及 G1 的部分模式存在严格意义上独立的 Major GC。其他收集器(如 Serial Old, Parallel Old)的老年代收集通常是伴随 Full GC 发生的。
- 特点:发生频率较低,回收速度较慢,STW 时间通常比 Minor GC 长很多(CMS 目标是降低 STW,但仍有暂停)。
- 易混淆:注意!很多资料或口语中会将 Major GC 和 Full GC 混用,有时 Major GC 就指代 Full GC。需要根据上下文判断,或者明确是指仅针对老年代的回收。
- 混合收集 (Mixed GC):
- 目标:收集整个新生代以及部分老年代区域。注意不是全部老年代。
- 实现:这是 G1 (Garbage-First) 收集器独有的回收模式。G1 将堆划分为多个 Region,Mixed GC 会选择一部分收益最高的 Old Region 进行回收。
- 新生代收集 (Minor GC / Young GC):
整堆收集 (Full GC):
- 目标:对整个 Java 堆(包括新生代和老年代)以及方法区/元空间进行的全面垃圾收集。
- 触发时机:是代价最高的一种 GC,应极力避免频繁发生。常见触发条件包括:
- 显式调用
System.gc()
:程序代码中调用System.gc()
或Runtime.getRuntime().gc()
。这只是建议 JVM 进行 Full GC,JVM 不一定会立即执行,且可以通过-XX:+DisableExplicitGC
禁用。 - 老年代空间不足:在 Minor GC 后,对象要晋升到老年代,但老年代剩余空间不足以容纳这些对象。
- 方法区/元空间不足:当方法区(元空间)需要分配更多空间(如加载新类、JIT 编译代码缓存)但空间不足时,通常会触发 Full GC。
- 空间分配担保失败:Minor GC 前检查,若老年代连续空间不足以容纳新生代所有对象(或历次晋升平均大小),则触发 Full GC。
- Concurrent Mode Failure (CMS 收集器特有):在使用 CMS 进行并发垃圾回收的过程中,如果业务线程产生的垃圾速度超过了 GC 回收的速度,导致老年代空间在并发回收完成前就被填满,会触发一次后备的、带 STW 的 Full GC (使用 Serial Old 算法)。
- 显式调用
- 特点:最耗时的 GC 类型,其 STW 时间最长,对应用程序的响应性影响最大。
性能影响: Major GC 和 Full GC 的 STW 时间通常是 Minor GC 的 10 倍以上。因此,JVM 调优的一个重要目标就是尽量减少 Major GC 和 Full GC 的频率和持续时间。
# GC 日志示例分析
使用前面的 GCTest
代码模拟内存不断增长并触发 GC。
// 文件名: GCTest.java
import java.util.ArrayList;
import java.util.List;
/**
* 测试 MinorGC, MajorGC, FullGC
* VM Options: -Xms10m -Xmx10m -XX:+PrintGCDetails
*/
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "young kbt"; // 初始字符串
while (true) {
// 不断将字符串自身拼接,长度指数级增长
list.add(a); // 将引用加入列表,防止字符串被回收
a = a + a; // 创建新的、更长的字符串对象
i++;
}
} catch (Throwable e) { // 使用 Throwable 捕获 OOM
System.out.println("******** i = " + i + " ********");
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
设置 VM Options: -Xms10m -Xmx10m -XX:+PrintGCDetails
运行输出 (示例,具体数值和次数会变化):
[GC (Allocation Failure) [PSYoungGen: 2038K->500K(2560K)] 2038K->797K(9728K), 0.3532002 secs] [Times: user=0.01 sys=0.00, real=0.36 secs]
// ^-- Minor GC: (Allocation Failure 表明是因分配新对象空间不足触发)
// PSYoungGen: 新生代区域(Parallel Scavenge收集器),回收前 2038K -> 回收后 500K (总容量 2560K)
// 2038K->797K(9728K): 整个堆,回收前 2038K -> 回收后 797K (总容量 9728K ≈ 10m)
// 0.3532002 secs: GC 耗时 (real time)
[GC (Allocation Failure) [PSYoungGen: 2108K->480K(2560K)] 2405K->1565K(9728K), 0.0014069 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
// ^-- 又一次 Minor GC
[Full GC (Ergonomics) [PSYoungGen: 2288K->0K(2560K)] [ParOldGen: 6845K->5281K(7168K)] 9133K->5281K(9728K), [Metaspace: 3482K->3482K(1056768K)], 0.0058675 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
// ^-- Full GC: (Ergonomics 表明是 JVM 根据人机工程学/自适应策略触发,通常是老年代空间不足)
// PSYoungGen: 新生代回收情况
// ParOldGen: 老年代区域(Parallel Old收集器),回收前 6845K -> 回收后 5281K (总容量 7168K)
// 9133K->5281K(9728K): 整个堆回收情况
// [Metaspace: ...]: 元空间回收情况 (Full GC 可能回收元空间)
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 5281K->5281K(9728K), 0.0002857 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
// ^-- Full GC 后可能紧接着触发 Minor GC (因为 Full GC 清理了老年代,可能有对象能从新生代晋升了)
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 5281K->5263K(7168K)] 5281K->5263K(9728K), [Metaspace: 3482K->3482K(1056768K)], 0.0058564 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
// ^-- 又一次 Full GC (Allocation Failure 表明是因分配对象失败触发,即使 Full GC 后空间仍不足)
Heap // 打印最终堆信息
PSYoungGen total 2560K, used 60K ...
eden space 2048K, 2% used ...
from space 512K, 0% used ...
to space 512K, 0% used ...
ParOldGen total 7168K, used 5263K ...
object space 7168K, 73% used ...
Metaspace used 3514K, capacity 4498K ...
class space used 388K, capacity 390K ...
******** i = 26 ********
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at GCTest.main(GCTest.java:19)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
日志分析要点:
- 区分
GC
(Minor GC) 和Full GC
。 - 关注
[PSYoungGen: Before -> After (Total)]
和[ParOldGen: Before -> After (Total)]
来了解各代回收情况。 - 关注
TotalHeap: Before -> After (Total)
了解整体堆变化。 - 关注 GC 原因(如
Allocation Failure
,Ergonomics
,System.gc()
等)。 - 关注 GC 耗时 (
real=... secs
),特别是 Full GC 的耗时。 - 最终 OOM 通常发生在一次(通常是 Full)GC 之后。
# 7. 堆空间分代思想的意义
为什么要把 Java 堆划分成新生代和老年代?不分代不行吗?
理论上,不分代完全可以工作。早期的 JVM 实现以及某些特定的 GC 算法(如 ZGC、Shenandoah 的部分模式)可能不严格区分代。
分代的唯一核心理由是:优化垃圾回收 (GC) 的性能。
理论基础(再回顾): 基于对象的生命周期特性(大部分对象朝生夕灭,少数对象长期存活),分代收集可以将不同生命周期的对象隔离开,并对它们应用最适合的、最高效的垃圾回收策略:
- 新生代:对象死亡率极高。适合使用复制 (Copying) 算法。只需复制少量存活对象,效率很高。 Minor GC 频繁但快速。
- 老年代:对象存活率高,复制成本高。适合使用标记-清除 (Mark-Sweep) 或 标记-整理 (Mark-Compact) 算法。Major GC / Full GC 频率低,但需要扫描更多对象,耗时较长。
如果不分代:
- 所有对象(无论生命周期长短)都混杂在一起。
- 每次 GC 都需要扫描整个堆来查找存活对象。
- 对于新生代中大量快速死亡的对象,这种全局扫描是低效的。
- 如果使用复制算法,需要复制大量存活对象(老年代对象),成本高昂。
- 如果使用标记-清除/整理算法,虽然适合老年代,但对于新生代频繁的回收来说,其 STW 时间可能过长。
分代的好处:
- 聚焦回收:Minor GC 只需关注新生代,处理的数据量小,速度快,STW 短。大部分垃圾在 Minor GC 中就被回收了。
- 降低频率:只有经过多轮 Minor GC 仍然存活的对象才会进入老年代,大大降低了需要对老年代进行扫描和回收(Major/Full GC)的频率。
- 针对性算法:可以为不同代选择最合适的 GC 算法,扬长避短。
类比理解:
想象一下管理一个大型社区的垃圾清理工作:
- 不分代:每天派清洁工挨家挨户检查每件物品是否是垃圾,效率极低。
- 分代:
- 新生代 (类似临时垃圾桶):居民每天产生大量生活垃圾放入这里。清洁工每天来清理一次,发现大部分都是垃圾,只把少量有用的东西(可回收物)挑出来放到另一个桶(Survivor)。这个过程很快。
- 老年代 (类似长期储藏室):只有被挑出来很多次、确认长期有用的东西才会被放进储藏室。清洁工很久才来大扫除一次(可能一年一次),因为里面的东西大部分都是有用的,清理起来比较费劲。
通过这种“分代管理”,整体的垃圾清理效率最高,对居民(应用程序)的干扰最小。
# 8. 内存分配策略详解
JVM 在为对象分配内存时,遵循一系列策略,以优化性能和空间利用率。
优先分配到 Eden 区:
- 绝大多数新创建的对象会首先尝试在 Eden 区分配内存。
- Eden 区通常是新生代中最大的区域,设计用于容纳大量“朝生夕灭”的对象。
大对象直接分配到老年代:
- 目的:避免大对象在新生代的 Eden 区和两个 Survivor 区之间进行频繁且耗费资源的复制操作。
- 触发条件:当需要分配的对象大小超过 JVM 设定的一个阈值时,该对象会被直接分配到老年代。
- 控制参数:可以通过
-XX:PretenureSizeThreshold=<size_in_bytes>
参数设置这个阈值(单位:字节)。注意:此参数只对 Serial 和 ParNew 等收集器有效,Parallel Scavenge、CMS、G1 等有自己的策略,可能不遵守此参数。 默认值通常是 0,表示不启用,所有对象先尝试在 Eden 分配。 - 建议:程序应尽量避免创建生命周期短的超大对象,因为它们即使直接进入老年代,也可能很快死亡,但老年代的 GC 频率较低,导致内存不能及时回收。
长期存活的对象晋升到老年代:
- 机制:对象在新生代的 Survivor 区每经历一次 Minor GC 并且存活下来,其年龄 (Age) 计数器就会加 1。
- 晋升条件:当对象的年龄达到某个阈值时,它将在下一次 Minor GC 时被晋升到老年代。
- 控制参数:通过
-XX:MaxTenuringThreshold=<age>
参数设置晋升年龄阈值。默认值通常是 15(最大值)。
动态对象年龄判断 (Dynamic Age Calculation):
- 目的:为了更好地适应不同应用的内存使用模式,避免 Survivor 区被填满而导致对象过早或过多地晋升到老年代。
- 规则:如果在 Survivor 空间中,一批相同年龄的所有对象大小的总和大于 Survivor 空间容量的一半,那么年龄大于或等于该年龄的对象就会直接进入老年代,无需等到达到
MaxTenuringThreshold
。 - 影响:这意味着
MaxTenuringThreshold
只是一个最大限制,实际晋升年龄可能小于它。
空间分配担保 (Handle Promotion Failure):
- 背景:在进行 Minor GC 时,需要将 Eden 和 From Survivor 区的存活对象复制到 To Survivor 区。但最坏的情况是,所有对象都存活,那么 To 区可能放不下。如果 To 区放不下,这些对象就需要晋升到老年代。但老年代也可能空间不足。
- 机制 (JDK 6u24 之前):
- Minor GC 前,JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总大小。如果大于,Minor GC 是安全的。
- 如果小于,检查
-XX:HandlePromotionFailure
是否允许担保失败(默认为 true)。 - 如果允许,则进一步检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,则尝试进行 Minor GC(有风险)。如果小于,则直接进行 Full GC。
- 如果不允许担保失败,则直接进行 Full GC。
- 机制 (JDK 6u24 / JDK 7 及之后):
HandlePromotionFailure
参数失效,规则简化为:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则进行 Full GC。
示例:大对象直接进老年代
// 文件名: YoungOldAreaTest.java
/**
* 测试大对象直接分配到老年代
* VM Options: -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
* (新生代 20M: Eden=16M, S0=2M, S1=2M; 老年代 40M)
*/
public class YoungOldAreaTest {
public static void main(String[] args) {
// 创建一个 20MB 的 byte 数组
// 这个大小超过了 Eden 区 (16M) 的容量
byte[] buffer = new byte[1024 * 1024 * 20]; // 20MB
// 观察 GC 日志或使用 JVisualVM 查看 buffer 分配在哪个区域
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
设置 VM Options: -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
运行后查看 -XX:+PrintGCDetails
输出的 Heap 信息,会发现 ParOldGen
(老年代) 的 used 部分显著增加,接近 20MB,而 PSYoungGen
(新生代) 使用量很小,证明这个 20MB 的大对象被直接分配到了老年代。
# 9. TLAB (Thread Local Allocation Buffer) 为对象分配内存
# 9.1 堆空间一定是线程共享的吗?
从逻辑上讲,整个 Java 堆是所有线程共享的。但为了优化对象分配的效率和并发性能,JVM 在堆的新生代 Eden 区内部引入了 TLAB (Thread Local Allocation Buffer) 机制。
# 9.2 为什么需要 TLAB?
- 高频操作:对象的创建在 Java 中是非常频繁的操作。
- 并发冲突:堆是线程共享的。如果多个线程同时在 Eden 区分配内存,它们可能会争抢同一块内存空间。为了保证分配的原子性和线程安全,JVM 需要进行同步控制(例如,加锁)。
- 性能损耗:加锁操作会带来性能开销,尤其是在高并发场景下,频繁的锁竞争会严重影响对象分配的速度和整体吞吐量。
# 9.3 TLAB 是什么?
- 定义:TLAB 是 JVM 在 Eden 区为每个活动线程分配的一小块私有缓存区域 (Buffer)。
- 目的:让每个线程在自己的 TLAB 内部分配对象,避免直接在共享的 Eden 区进行分配时所需的同步开销。
- 分配流程:
- 当线程需要分配一个新对象时,它首先尝试在自己的 TLAB 中分配。
- 如果 TLAB 空间足够容纳该对象,分配直接在 TLAB 中完成,这是一个快速且无需加锁的操作。
- 如果 TLAB 空间不足,或者线程还没有 TLAB(首次分配或 TLAB 已用尽需要重新分配),线程会尝试向 JVM 申请一块新的 TLAB。申请新 TLAB 的过程可能需要加锁。
- 如果新 TLAB 申请成功,则在新 TLAB 中分配对象。
- 如果 TLAB 空间不足,并且对象本身比较大,或者申请新 TLAB 也失败(比如 Eden 区整体空间不足),JVM 才会 fallback 到在共享的 Eden 区直接分配内存,此时需要加锁来保证线程安全。
(图中展示了 Eden 区被划分为多个线程各自的 TLAB 和一块共享区域)
TLAB 的特点:
- 默认开启:在现代 OpenJDK 及其衍生 JVM (如 HotSpot) 中,TLAB 默认是开启的。可以通过
-XX:-UseTLAB
关闭(不推荐)。 - 大小:TLAB 的大小是动态调整的,JVM 会根据线程的分配速率等因素进行优化。默认情况下,一个 TLAB 大约只占 Eden 区的 1%。可以通过
-XX:TLABWasteTargetPercent=<percentage>
设置 TLAB 空间占用 Eden 的目标百分比。 - 并非所有对象都在 TLAB:只有小对象才会在 TLAB 中分配。非常大的对象(例如超过 TLAB 默认大小)或者 TLAB 空间不足时,仍然会在共享 Eden 区或直接在老年代分配。TLAB 只是首选的快速分配路径。
# 9.4 TLAB 分配失败时的策略 (refill_waste)
当一个线程的 TLAB 剩余空间不足以容纳要分配的新对象时(例如,TLAB 剩 20KB,要分配 30KB 对象),JVM 需要决定如何处理:
- 直接在堆(共享 Eden)分配:保留当前 TLAB 不变(里面还有 20KB 可用),将这个 30KB 的对象尝试在共享 Eden 区(需要加锁)分配。后续如果来了小于 20KB 的小对象,还能使用当前 TLAB 的剩余空间。
- 废弃当前 TLAB,申请新 TLAB:放弃当前 TLAB 剩余的 20KB 空间(造成浪费),然后向 JVM 申请一个新的、更大的 TLAB,再在新 TLAB 中分配这个 30KB 的对象。
JVM 内部使用一个名为 refill_waste
的阈值来做决策:
- 如果请求分配的对象大小 >
refill_waste
,倾向于认为这个对象比较大,或者 TLAB 剩余空间太小,不值得为了它浪费 TLAB 空间。此时会选择策略 1(在堆中分配)。 - 如果请求分配的对象大小 <=
refill_waste
,倾向于认为这个对象不大,或者 TLAB 剩余空间还比较可观。此时会选择策略 2(废弃旧 TLAB,申请新 TLAB 分配)。
refill_waste
的值通常是 TLAB 大小的 1/64 左右,并且 JVM 会在运行时根据情况动态调整这个值,以达到最优的内存分配效率和空间利用率平衡。
TLAB 分配流程图总结:
# 10. 常用堆空间相关 JVM 参数
核心参数:
-Xms<size>
:设置初始 Java 堆大小。-Xmx<size>
:设置最大 Java 堆大小。-Xmn<size>
:设置新生代的大小。(不推荐直接设置,建议通过NewRatio
控制)。-XX:NewRatio=<ratio>
:设置老年代与新生代的比例。默认2
,即 老年代:新生代 = 2:1。-XX:SurvivorRatio=<ratio>
:设置 Eden 区与一个 Survivor 区的比例。默认8
,即 Eden:S0 = 8:1。-XX:MaxTenuringThreshold=<age>
:设置对象晋升老年代的最大年龄阈值。默认15
。-XX:+PrintGCDetails
:打印详细的 GC 日志。-XX:+PrintGC
或-verbose:gc
:打印简要的 GC 日志。-XX:HandlePromotionFailure
:(JDK 7 后实际已无效,总为 true) 控制空间分配担保策略。-XX:+UseTLAB
:启用 TLAB(默认开启)。-XX:-UseTLAB
:禁用 TLAB。-XX:TLABWasteTargetPercent=<percentage>
:设置 TLAB 空间占用 Eden 的目标百分比(默认 1)。-XX:PretenureSizeThreshold=<size_in_bytes>
:设置大对象直接晋升老年代的大小阈值(字节)。0 表示不启用。只对部分收集器有效。
查看 JVM 参数值:
-XX:+PrintFlagsInitial
:打印所有 JVM 参数的默认值。-XX:+PrintFlagsFinal
:打印所有 JVM 参数的最终生效值(可能被其他参数或 JVM 自动调整)。jps
:查看运行中的 Java 进程 ID。jinfo -flag <ParameterName> <pid>
:查看指定进程中某个具体参数的值。例如jinfo -flag NewRatio 12345
。
# 11. 堆是分配对象的唯一选择吗?逃逸分析与栈上分配
传统上认为“所有对象实例都在堆上分配”。但在现代 JVM 中,随着 JIT 编译器优化技术的发展,这个说法变得不那么绝对了。其中最关键的技术就是逃逸分析 (Escape Analysis)。
什么是逃逸分析?
逃逸分析是 JIT 编译器在编译期间进行的一种数据流分析。它分析一个对象的动态作用域,判断该对象是否有可能**“逃逸”出**其被创建的方法或当前线程之外。
- 未逃逸 (No Escape):如果一个对象在方法内部被定义,并且其引用从未被传递到方法外部(例如,没有作为返回值返回、没有赋值给成员变量、没有传递给其他方法被外部引用持有),那么这个对象就是方法内私有的,是未逃逸的。
- 参数逃逸 (Argument Escape):对象在方法内创建,但其引用被传递给了其他方法作为参数。
- 全局逃逸 (Global Escape):对象在方法内创建,但其引用被赋值给了类的成员变量,或者作为方法的返回值返回,或者传递给了可能在其他线程中访问它的代码。
逃逸分析示例:
// 文件名: EscapeAnalysis.java
public class EscapeAnalysis {
public EscapeAnalysis obj; // 成员变量
// 1. 返回对象引用 -> 全局逃逸
public EscapeAnalysis getInstance() {
// 如果 obj 为 null,创建新对象并返回;否则返回 obj
// 无论哪种情况,返回的对象引用都逃逸出了 getInstance 方法
return obj == null ? new EscapeAnalysis() : obj;
}
// 2. 为成员变量赋值 -> 全局逃逸
public void setObj() {
// 创建新对象,并将其引用赋值给成员变量 obj
// obj 是成员变量,可能被其他方法或线程访问,因此新对象逃逸了
this.obj = new EscapeAnalysis();
}
// 思考:如果 obj 是 static 的? 仍然会发生全局逃逸,因为静态变量是线程共享的。
// 3. 对象仅在方法内部使用 -> 未逃逸
public void useEscapeAnalysis() {
// 对象 e 在方法内创建,只在方法内使用,方法结束时 e 的引用就失效了
// e 没有被返回,没有赋值给成员变量,没有传给其他方法
EscapeAnalysis e = new EscapeAnalysis();
// ... 只在内部使用 e ...
} // 方法结束,e 是未逃逸的
// 4. 引用外部方法返回的对象 -> 间接逃逸
public void useEscapeAnalysis2() {
// 调用 getInstance 获取一个对象引用 e
// getInstance 返回的对象本身是逃逸的
EscapeAnalysis e = getInstance();
// 对 e 的后续使用,实际上是使用了一个已经逃逸的对象
// ... 使用 e ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
逃逸分析的优化效果:
如果 JIT 编译器通过逃逸分析确定一个对象是未逃逸的,它可以对这个对象应用一系列优化,从而可能避免在堆上分配内存:
栈上分配 (Stack Allocation):
- 原理:对于完全未逃逸的小对象,JIT 编译器理论上可以将对象的内存直接分配在当前线程的虚拟机栈上,而不是堆上。
- 好处:对象内存随栈帧弹出而自动销毁,无需 GC 回收,极大减轻 GC 压力,提升性能。
- HotSpot JVM 的实现:需要特别注意的是,根据 Oracle HotSpot JVM 团队的官方说明和广泛的技术讨论,尽管理论可行,HotSpot JVM 目前并没有真正实现将整个对象直接在栈上分配。我们观察到的“栈上分配”效果,实际上是标量替换优化的结果。
同步省略 (Synchronization Elimination / Lock Elision):
原理:如果逃逸分析确定一个对象(特别是锁对象)只会被单个线程访问,不可能存在多线程竞争,那么 JIT 编译器就可以安全地移除对该对象的所有同步操作(
synchronized
块或方法)。好处:消除了不必要的锁开销(锁获取、释放、上下文切换等),提高并发性能。
示例:
public void lockOnLocalObject() { Object lock = new Object(); // lock 是局部变量,未逃逸 synchronized (lock) { // 对 lock 加锁 // ... do something ... } // 锁释放 } // JIT 优化后可能变成: public void lockOnLocalObjectOptimized() { Object lock = new Object(); // ... do something ... // 同步被移除 }
1
2
3
4
5
6
7
8
9
10
11(原始字节码包含 monitorenter/monitorexit)
标量替换 (Scalar Replacement):
标量 (Scalar):指无法再分解的原子数据类型,如 Java 的基本数据类型(
int
,float
,reference
等)。聚合量 (Aggregate):指可以分解为其他标量或聚合量的数据结构,如 Java 的对象。
原理:如果逃逸分析确定一个对象未逃逸,并且 JIT 编译器发现这个对象可以被拆散,将其成员变量(标量或聚合量)直接在栈上(作为原始类型的局部变量)分配和访问,而无需创建对象本身。
好处:
- 避免堆分配:不创建对象实例,减少了堆内存分配和 GC 压力。
- 为栈上分配提供基础:拆散后的标量成员可以直接存储在栈帧的局部变量表中(或 CPU 寄存器中),实现了“事实上的”栈上分配效果。
- 进一步优化:拆散后的标量更容易进行其他 JIT 优化。
示例:
// 原始代码 class Point { private int x, y; /*构造器...*/ } public void createPoint() { Point p = new Point(1, 2); // p 未逃逸 System.out.println(p.x + p.y); } // 标量替换优化后 (伪代码) public void createPointOptimized() { int p_x = 1; // 将 p.x 替换为局部变量 p_x int p_y = 2; // 将 p.y 替换为局部变量 p_y System.out.println(p_x + p_y); }
1
2
3
4
5
6
7
8
9
10
11
12
逃逸分析与优化的控制参数:
-XX:+DoEscapeAnalysis
:显式启用逃逸分析(JDK 7+ 默认开启)。-XX:-DoEscapeAnalysis
:显式禁用逃逸分析。-XX:+PrintEscapeAnalysis
:打印逃逸分析结果。-XX:+EliminateAllocations
:启用标量替换(默认开启)。-XX:-EliminateAllocations
:禁用标量替换。-XX:+EliminateLocks
:启用同步省略(默认开启)。-XX:-EliminateLocks
:禁用同步省略。
示例:测试栈上分配 (实际是标量替换) 效果
// 文件名: StackAllocation.java
/**
* 测试逃逸分析和标量替换带来的"栈上分配"效果
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) { // 创建一千万次 User 对象
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
try {
Thread.sleep(1000000); // 暂停以便观察内存
} catch (InterruptedException e1) { e1.printStackTrace(); }
}
// alloc 方法内部创建 User 对象,但 User 对象未逃逸
private static void alloc() {
User user = new User(); // User 对象的作用域仅在此方法内
// user.id = 1; // 可以访问字段
// user.name = "test";
}
static class User {
// int id;
// String name;
// User 类可以有字段,也可以为空。即使为空,创建对象也需要内存开销。
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
运行情况 1:禁用逃逸分析 (-XX:-DoEscapeAnalysis
)
VM Options: -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
输出:
... (多次 GC 日志) ...
花费的时间为: 664 ms
2
使用 JVisualVM 观察堆内存,会发现大量的 User
对象实例。
运行情况 2:启用逃逸分析和标量替换 (默认)
VM Options: -Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+EliminateAllocations
(后两个是默认值)
输出:
花费的时间为: 5 ms
几乎没有 GC 发生。使用 JVisualVM 观察堆内存,几乎看不到 User
对象实例。
结论:逃逸分析识别出 User
对象未逃逸,标量替换(即使 User
没有字段也能优化掉对象头开销)使得 JIT 避免了在堆上创建 User
对象,从而极大提升了性能并减少了 GC 压力。这就是我们通常所说的“栈上分配”效果的来源。
逃逸分析的局限性:
- 不是万能的:逃逸分析本身需要消耗计算资源,并不保证总能带来正收益(如果分析后发现对象都逃逸了,分析就白做了)。
- 分析范围有限:目前的逃逸分析是方法级别的,不是全局的,且相对保守。
- HotSpot 不做完整栈分配:再次强调,HotSpot JVM 主要通过标量替换来实现类似栈上分配的效果,而非直接在栈上分配整个对象。所以,“所有对象实例都在堆上创建”这个说法在 HotSpot 虚拟机上仍然是基本成立的,只不过未逃逸的对象可能被 JIT 优化掉,根本不创建了。
开发建议:
- 尽量缩小对象的作用域,优先使用局部变量。
- 无状态的对象(如工具类)可以考虑设计为单例或静态方法。
- 虽然有逃逸分析,但写代码时仍需考虑对象的生命周期和作用域,有助于 JIT 更好地优化。
# 12. 堆内存小结
- 核心地位:Java 堆是 JVM 内存管理的核心,用于存储对象实例和数组。
- 线程共享:堆是所有线程共享的区域。
- 分代设计:为了优化 GC,堆被划分为新生代(Eden, S0, S1)和老年代。
- 对象分配:新对象优先在 Eden 分配,经历 Minor GC 后存活对象在 Survivor 区之间复制并增加年龄,达到阈值后晋升到老年代。大对象可能直接进入老年代。
- GC 类型:Minor GC (Young GC) 频繁回收新生代;Major GC (Old GC) 回收老年代(不常用);Full GC 回收整个堆和方法区/元空间,应尽量避免。
- TLAB:为提高并发分配效率,在 Eden 区为每个线程分配的私有缓存区,优先在此分配小对象。
- 逃逸分析与优化:JIT 编译器通过逃逸分析,对未逃逸的对象可能进行栈上分配(通过标量替换实现)、同步省略等优化,减少堆分配和 GC 压力。
- 基本原则:在 HotSpot JVM 中,尽管有优化,但对象实例主要还是在堆上分配。理解堆的结构、分配策略和 GC 机制对于编写高性能、稳定的 Java 应用至关重要。