02 运行时数据区——堆、栈、方法区的内存布局
摘要:
JVM 规范定义了六块运行时数据区(Run-Time Data Areas),它们共同支撑 Java 程序的执行:程序计数器(当前指令地址)、虚拟机栈(方法调用帧)、本地方法栈(JNI 方法帧)、堆(对象实例的大本营)、方法区(类元数据仓库)以及运行时常量池(方法区的子集)。本文逐一剖析每块区域的存储内容、生命周期、溢出行为(StackOverflowError、OutOfMemoryError)及其在 JDK 演进中的变化——尤其是 JDK 8 将**永久代(PermGen)替换为元空间(Metaspace)**这一关键架构变革,以及直接内存(DirectBuffer)与堆外内存的关系。理解这张内存地图,是分析 OOM 根因、调整 JVM 参数和进行性能调优的基础。
第 1 章 为什么要划分内存区域
1.1 没有内存分区会怎样
设想一下,如果 JVM 把所有东西都放在同一块连续的内存里——方法的字节码、对象实例、局部变量、类的元数据、本地方法状态……会发生什么?
GC 的效率会极低:GC 需要扫描整块内存区分”存活”和”垃圾”,而不同类型的数据有完全不同的生命周期特征——局部变量随方法返回立刻消亡,对象实例可能存活很长时间,类元数据几乎伴随整个 JVM 生命周期。混在一起,任何 GC 算法都无法高效处理。
多线程隔离无从实现:每个线程有自己的执行上下文(当前执行到哪一行、局部变量是什么值),这些数据天然是线程私有的。如果混在全局内存里,要么需要复杂的锁来隔离,要么完全无法保证安全性。
内存回收策略无法针对性优化:字节码是只读的,对象是读写的,类元数据偶尔加载/卸载——对读写特征如此不同的数据使用同一套管理策略,必然在某些维度上浪费。
划分内存区域,本质上是一种关注点分离(Separation of Concerns):让不同性质的数据在各自适合的管理机制下运转,GC 只需要关注堆,线程切换只需要保存/恢复程序计数器和栈的状态,类加载/卸载只影响方法区。
1.2 线程私有 vs 线程共享
JVM 的六块内存区域按线程维度分为两类:
线程私有区域(每个线程拥有独立的一份,随线程创建而存在,随线程销毁而消失):
- 程序计数器(PC Register)
- 虚拟机栈(JVM Stack)
- 本地方法栈(Native Method Stack)
线程共享区域(所有线程共享,JVM 启动时创建,JVM 退出时销毁):
- 堆(Heap)
- 方法区(Method Area)→ HotSpot 实现:JDK 8 前为永久代,JDK 8+ 为元空间
这个分类直接决定了线程安全的范围:局部变量在虚拟机栈(线程私有),天然线程安全;对象实例在堆(线程共享),需要并发控制。
第 2 章 程序计数器——最小的区域,最重要的状态
2.1 程序计数器是什么
程序计数器(Program Counter Register,PC Register)是 JVM 规范中定义的最小内存区域,每个线程拥有一个独立的程序计数器。它记录当前线程正在执行的字节码指令的地址(更准确地说,是字节码指令在方法 Code 属性中的偏移量)。
执行引擎每取一条字节码指令、执行完后,就通过程序计数器找到下一条指令的位置。它是 JVM 实现线程切换后能够恢复到正确执行位置的关键:操作系统调度线程时会中断当前线程,下次恢复执行时,靠的就是程序计数器中保存的”断点位置”。
2.2 为什么是线程私有的
如果多个线程共享同一个程序计数器,一旦线程 A 和线程 B 同时运行,PC 就会相互覆盖,导致线程 A 切换回来后跳到线程 B 的执行位置——这是灾难性的错误。
因此 JVM 规范规定:程序计数器必须是线程私有的,每个线程在任意时刻都有一个独立的 PC,互不干扰。
2.3 执行本地方法时 PC 未定义
当线程执行的是 Java 方法时,PC 记录字节码指令地址。但当线程执行的是本地(native)方法时(通过 JNI 调用的 C/C++ 代码),PC 的值是 undefined(未定义)。
原因:本地方法的执行不经过 JVM 的字节码解释器,而是直接在操作系统层面执行本地代码,JVM 无法控制其执行流程,因此也无法维护一个有意义的”字节码指令地址”。
2.4 唯一不会 OOM 的区域
JVM 规范明确说明:程序计数器是唯一不会导致 OutOfMemoryError 的运行时数据区域。这很容易理解——PC 只需要存储一个整数(指令地址偏移量),其大小是固定的、极小的,不会随程序运行而无限增长。
第 3 章 虚拟机栈——方法调用的完整上下文
3.1 虚拟机栈的基本模型
虚拟机栈(Java Virtual Machine Stack) 描述的是 Java 方法执行的线程内存模型:每个方法被调用时,JVM 为其创建一个栈帧(Stack Frame)并压入当前线程的虚拟机栈;方法返回时,栈帧从栈顶弹出。
这个模型非常直观——调用链的深度与栈的深度完全对应:
void a() {
b(); // 调用 b 时,b 的栈帧压栈
}
void b() {
c(); // 调用 c 时,c 的栈帧压栈
}
void c() {
// c 返回时,c 的栈帧出栈
}栈(从底到顶):
┌───────────────┐ ← 栈顶(当前执行方法)
│ c() 的栈帧 │
├───────────────┤
│ b() 的栈帧 │
├───────────────┤
│ a() 的栈帧 │
├───────────────┤
│ main() 的栈帧 │
└───────────────┘ ← 栈底
3.2 栈帧的内部结构
每个栈帧包含四个部分:
局部变量表(Local Variable Table):存储方法参数和方法内部定义的局部变量。它是一个以字(word,JVM 中通常 4 字节或 8 字节)为单位的数组。int、float、reference、returnAddress 类型各占 1 个 Slot,long 和 double 各占 2 个 Slot。
实例方法(非 static 方法)的 Slot 0 始终是 this——这就是为什么在实例方法里可以直接使用 this,它其实是一个藏在局部变量表第 0 位置的隐式参数。
// 对于这个方法:
public int add(int a, int b) {
int result = a + b;
return result;
}
// 局部变量表(实例方法,Slot 0 是 this):
// Slot 0: this(类型 reference)
// Slot 1: a (类型 int)
// Slot 2: b (类型 int)
// Slot 3: result(类型 int)操作数栈(Operand Stack):字节码指令的”计算草稿纸”。JVM 是一个基于栈的虚拟机,字节码指令通过操作数栈传递操作数和返回结果。例如,a + b 的计算过程:iload_1(将 Slot 1 的 a 压栈)→ iload_2(将 Slot 2 的 b 压栈)→ iadd(弹出两个整数,相加,结果压栈)→ istore_3(弹出栈顶,存入 Slot 3)。
操作数栈的最大深度在编译期由 javac 静态计算,存储在 Code 属性的 max_stack 字段中。
动态链接(Dynamic Linking):每个栈帧都持有一个指向当前方法所在类的运行时常量池的引用。方法在调用其他方法时,需要通过符号引用找到目标方法——而这个符号引用正是通过动态链接到运行时常量池中解析为直接引用的。
方法返回地址(Return Address):方法结束(正常返回或抛出异常)时,JVM 需要知道返回到调用者的哪个位置继续执行。正常返回时,返回地址是调用者中调用指令的下一条指令;异常退出时,通过异常表(Exception Table)查找对应的异常处理器。
3.3 StackOverflowError 的根因
虚拟机栈的大小是有限的(由 -Xss 参数控制,默认通常 512KB~1MB)。当方法调用深度超过栈的容量时,JVM 抛出 StackOverflowError。
最典型的场景是无限递归:
// 经典的无限递归:每次调用都压一个新栈帧,直到栈空间耗尽
public void infiniteRecursion() {
infiniteRecursion(); // 没有递归终止条件
}
// 抛出:java.lang.StackOverflowError深度递归的正常代码(如遍历深度很大的树)也可能触发此错误。解决方案:
- 将递归改为迭代(手动维护栈)
- 增大
-Xss(如-Xss2m,但会增加每个线程的内存占用,系统总线程数会减少)
生产避坑
-Xss影响的是每个线程的栈大小,不是总大小。如果系统有 500 个线程,-Xss2m意味着仅栈就消耗约 1GB。线程数多的应用,盲目增大-Xss可能导致系统内存耗尽。首先应该消除不必要的深递归,而不是靠增大栈空间掩盖问题。
第 4 章 堆——所有对象的大本营
4.1 堆是 JVM 内存管理的核心
Java 堆(Heap) 是 JVM 内存中最大的一块,也是 GC 管理的核心区域。JVM 规范的规定非常简洁:所有对象实例(new 出来的对象)以及数组,都在堆上分配。
堆在 JVM 启动时创建,通过 -Xms(初始大小)和 -Xmx(最大大小)控制。当堆中没有足够空间为新对象分配内存,且 GC 也无法回收足够空间时,抛出 java.lang.OutOfMemoryError: Java heap space。
4.2 堆的分代布局
现代 JVM 的堆并非一个简单的连续内存块,而是按分代假说划分为不同区域,不同回收器的划分方式略有差异:
经典分代布局(Serial、Parallel、CMS、G1 均适用):
堆(-Xms / -Xmx 控制总大小)
│
├── 新生代 Young Generation(-Xmn 或 -XX:NewRatio 控制)
│ ├── Eden 区:新对象优先在此分配(约 80%)
│ ├── Survivor 0(From,约 10%)
│ └── Survivor 1(To,约 10%)
│ Eden:S0:S1 = 8:1:1(由 -XX:SurvivorRatio=8 控制)
│
└── 老年代 Old Generation(剩余部分,约 2/3 堆空间)
└── 存放晋升的"老"对象、大对象(直接分配)
为什么要有两个 Survivor 区(From 和 To)?
这是复制算法的要求。Minor GC 时,Eden 和 From Survivor 中存活的对象被复制到 To Survivor(To 是空的),复制后 Eden 和 From 全部清空。下次 GC 时,From 和 To 的角色对调。这样始终有一块 Survivor 是空的(作为复制目标),避免了内存碎片,但也意味着始终有约 10% 的新生代空间被”浪费”作为复制缓冲区。
大对象直接进入老年代:超过 -XX:PretenureSizeThreshold(Parallel GC 默认 0,即不限制;G1 通过 Humongous Region 处理)的大对象直接分配在老年代,避免在 Eden 和 Survivor 之间来回复制(大对象复制代价高)。
年龄晋升机制:对象在 Survivor 区中每经历一次 Minor GC 存活,年龄(Age)加 1。年龄达到 -XX:MaxTenuringThreshold(默认 15)时,对象晋升(Promotion)到老年代。
4.3 堆内存的动态扩缩
JVM 堆的实际大小在 -Xms 到 -Xmx 之间动态调整:
- 当内存紧张时,JVM 向操作系统申请更多内存(扩容),最大不超过
-Xmx - 当大量对象被 GC 回收后,JVM 会将多余的内存归还操作系统(缩容),最小不低于
-Xms
设计哲学
生产环境通常建议将
-Xms和-Xmx设置为相同值(如-Xms4g -Xmx4g),避免堆的动态扩缩带来的性能抖动——每次扩缩都涉及操作系统的内存申请/释放,会触发一次 Full GC。固定堆大小后,JVM 启动时就申请好所有内存,运行时不再变动。
4.4 TLAB——线程私有的分配缓冲区
在堆上分配对象,最简单的实现是指针碰撞(Bump-the-Pointer):维护一个指针指向堆中空闲区域的起始位置,每次分配就将指针向后移动对象大小的距离。
但多线程并发分配时,这个指针的移动必须是线程安全的——如果 1000 个线程同时分配对象,每次都要对这个指针进行 CAS 操作,竞争会非常激烈。
解决方案是 TLAB(Thread-Local Allocation Buffer,线程本地分配缓冲):
JVM 为每个线程在 Eden 区预先划分一小块私有区域(通常几十 KB 到几百 KB),线程在自己的 TLAB 中分配对象时,不需要任何锁或 CAS——因为只有这个线程会写这块区域。只有当 TLAB 用完、需要申请新 TLAB 时,才需要同步(但频率远低于每次对象分配)。
Eden 区:
┌──────────────────────────────────────────────────────┐
│ TLAB T1 │ TLAB T2 │ TLAB T3 │ ... │ 公共 Eden 空间 │
│(线程1用)│(线程2用)│(线程3用)│ │(TLAB 分配失败用)│
└──────────────────────────────────────────────────────┘
通过 -XX:+UseTLAB(JDK 8+ 默认开启)启用 TLAB,-XX:TLABSize 设置每个 TLAB 的大小。
第 5 章 方法区与元空间——类的元数据仓库
5.1 方法区存储什么
方法区(Method Area) 是 JVM 规范定义的一个逻辑概念——存储已被加载的类型信息,具体包括:
- 类的全限定名(
com.example.HelloWorld) - 父类信息(每个类都有对
java.lang.Object的引用链) - 接口列表(该类实现了哪些接口)
- 字段描述符(字段名、类型、访问标志)
- 方法描述符(方法签名、返回类型、字节码、异常表)
- 运行时常量池(符号引用、字面量常量)
- JIT 编译后的代码缓存(某些实现中存在此区域)
一句话:方法区是类的”身份证”存放地。每加载一个类,其元数据就会进入方法区,并在整个 JVM 生命周期内(或类被卸载之前)持续存在。
5.2 永久代(PermGen):JDK 8 之前的实现
在 HotSpot JDK 8 之前,方法区被实现为永久代(Permanent Generation,PermGen),它是堆内的一块固定区域,由 JVM 的 GC 统一管理。
相关 JVM 参数:
-XX:PermSize=64m:永久代初始大小-XX:MaxPermSize=256m:永久代最大大小
永久代的问题:
- 大小难以预估:应用加载的类越多,PermGen 需要越大;动态生成类(如 Spring AOP、CGLib 动态代理、JSP 编译)会持续增加 PermGen 占用
- 频繁 OOM:
java.lang.OutOfMemoryError: PermGen space是当年 Java 企业应用最常见的错误之一,尤其在频繁重部署的 Tomcat 应用中(每次重部署旧类不能被卸载,PermGen 越来越满) - GC 效率差:永久代的 GC 触发条件苛刻,且回收效率低(大多数类元数据不能被回收)
5.3 元空间(Metaspace):JDK 8 的重大重构
JDK 8 彻底移除了永久代,引入**元空间(Metaspace)**作为方法区的新实现,元空间有一个根本性的变化:它不再是堆的一部分,而是存储在本地内存(Native Memory)中,由操作系统直接管理。
JDK 7(永久代在堆内): JDK 8+(元空间在本地内存):
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ 堆(Heap) │ │ 堆(Heap) │ │ 本地内存 │
│ ┌───────────────┐ │ │ │ │ ┌──────────────┐ │
│ │ 永久代 │ │ │ 无 PermGen │ │ │ 元空间 │ │
│ │ (PermGen) │ │ │ │ │ │ (Metaspace) │ │
│ └───────────────┘ │ └────────────────────┘ │ └──────────────┘ │
└────────────────────┘ └────────────────────┘
元空间的优势:
-
几乎无界限:默认情况下,元空间的上限是系统可用内存(操作系统会按需分配),不再受 JVM 参数的人为限制。
java.lang.OutOfMemoryError: PermGen space这个错误从此消失。 -
更高效的内存分配:元空间使用本地内存分配器(
malloc-like),远比在堆中管理一个固定区域更灵活。 -
GC 解耦:元空间的回收(类卸载)不再与堆 GC 强耦合,减少了 Full GC 的触发因素。
元空间的控制参数(JDK 8+):
-XX:MetaspaceSize=64m:元空间初始大小(同时也是触发第一次 GC 尝试类卸载的阈值)-XX:MaxMetaspaceSize=256m:元空间上限(不设置则无上限,存在”悄悄耗尽系统内存”的风险)-XX:MinMetaspaceFreeRatio=40:GC 后元空间最小空闲比例,低于此则扩容-XX:MaxMetaspaceFreeRatio=70:GC 后元空间最大空闲比例,高于此则缩容
生产避坑
不设置
-XX:MaxMetaspaceSize时,如果程序存在类加载泄漏(如框架 Bug 导致类不断被生成但无法卸载),元空间会不断增长直到耗尽物理内存,导致系统 OOM 或 JVM 崩溃,且没有明显的 JVM 级别错误提示。生产环境务必设置-XX:MaxMetaspaceSize上限,配合监控告警。
5.4 运行时常量池
运行时常量池(Runtime Constant Pool) 是方法区的一部分,它是 Class 文件中常量池(Constant Pool Table)的运行时形式。
Class 文件的常量池存储的是静态的符号引用(字符串形式的类名、方法名),在类加载后,JVM 会将这些符号引用解析为直接引用(内存地址/偏移量),解析后的结果存入运行时常量池。
字符串常量池(String Pool) 是一个特殊的运行时常量池,用于存储字符串字面量的唯一副本,实现字符串驻留(Interning):
String a = "hello"; // 字面量,存入字符串常量池
String b = "hello"; // 从常量池取,与 a 是同一对象
String c = new String("hello"); // 在堆上新建,不是常量池中的对象
System.out.println(a == b); // true:同一常量池引用
System.out.println(a == c); // false:不同对象
System.out.println(a == c.intern()); // true:intern() 返回常量池中的引用字符串常量池的位置演进:
- JDK 6:在永久代中
- JDK 7:迁移到堆中(这是 JDK 7 的重要变化,减少了 PermGen 的压力,字符串常量可以被 GC 回收)
- JDK 8+:继续在堆中(元空间只包含类元数据,字符串常量池依然在堆上)
第 6 章 本地方法栈——JNI 的专属区域
本地方法栈(Native Method Stack) 与虚拟机栈的作用类似,区别在于:虚拟机栈为 Java 方法的执行服务,本地方法栈为本地(Native)方法(用 C/C++ 实现的方法,通过 JNI 调用)的执行服务。
在 HotSpot VM 中,虚拟机栈和本地方法栈合二为一,没有单独实现一个本地方法栈。但规范上它们是两个独立的区域。
本地方法栈也可以抛出 StackOverflowError(本地方法调用链过深)和 OutOfMemoryError(本地方法栈无法扩展)。
第 7 章 直接内存——堆外的隐形内存
7.1 直接内存不是 JVM 规范定义的区域
直接内存(Direct Memory) 不属于 JVM 规范定义的运行时数据区,而是 JVM 进程通过 sun.misc.Unsafe 或 NIO 的 ByteBuffer.allocateDirect() 在 JVM 堆之外、操作系统内存中直接分配的内存。
7.2 为什么 NIO 需要直接内存
传统的 Java IO(BIO)通过 JVM 堆上的 byte[] 缓冲区进行数据传输:
网络/磁盘 → 内核缓冲区 → JVM 堆内 byte[] → Java 程序处理
↑
需要一次额外的数据拷贝
这里存在一次不必要的数据复制:数据从内核缓冲区复制到 JVM 堆上的 Java 数组,才能被 Java 程序读取。而 GC 可能随时移动堆上的对象(压缩整理),所以 JVM 必须先将数据复制到一个 GC 无法移动的堆外内存,再供 JNI 读取,这又是一次复制。
NIO 的直接内存(DirectByteBuffer)通过在 JVM 堆外分配缓冲区,让内核可以直接将数据放入这个缓冲区(因为堆外内存地址不会被 GC 移动),省去了额外的复制:
网络/磁盘 → 直接内存(DirectByteBuffer)→ Java 程序处理(通过 ByteBuffer API)
↑
内核可以直接 DMA,无需额外复制
这是 Netty 大量使用 DirectByteBuffer 的核心原因——减少内存拷贝次数,降低 IO 延迟。
7.3 直接内存的 OOM
直接内存不受 -Xmx 控制,它受 -XX:MaxDirectMemorySize(默认与 -Xmx 相同)限制。当直接内存超过此限制或系统物理内存不足时,抛出:
java.lang.OutOfMemoryError: Direct buffer memory
直接内存 OOM 的特征:
- 堆内存使用率正常(Heap 不满)
- 系统物理内存使用率高
sun.misc.Cleaner或java.lang.ref.PhantomReference相关的引用链中可以找到DirectByteBuffer
第 8 章 各区域的 OOM 类型与排查方向
| 内存区域 | OOM 类型 | 常见原因 | 排查工具 |
|---|---|---|---|
| 堆 | Java heap space | 对象泄漏(静态集合、缓存无限增长)、堆太小 | jmap -heap、MAT、堆 Dump 分析 |
| 元空间 | Metaspace(JDK 8+) | 类加载泄漏(动态生成类不断增加) | jcmd VM.metaspace、JFR ClassLoading 事件 |
| 虚拟机栈 | StackOverflowError | 无限递归、深递归 | 异常栈跟踪 |
| 虚拟机栈 | OOM: unable to create new native thread | 线程数过多(每个线程都占用栈内存) | jstack、OS ps -ef |
| 直接内存 | Direct buffer memory | NIO 缓冲区未及时释放 | jcmd VM.native_memory、NMT |
| 代码缓存 | OOM: Code Cache is full | JIT 编译缓存满(-XX:ReservedCodeCacheSize 太小) | JFR CompilerEvent |
第 9 章 关键 JVM 参数速查
| 参数 | 含义 | 默认值 | 建议 |
|---|---|---|---|
-Xms | 堆初始大小 | 物理内存 1/64 | 与 -Xmx 设置相同,避免动态扩缩 |
-Xmx | 堆最大大小 | 物理内存 1/4 | 一般设置为容器内存的 50%~75% |
-Xss | 每个线程的栈大小 | 512KB~1MB(平台相关) | 深递归场景可调至 2m~4m,注意总线程数 |
-Xmn | 新生代大小 | 约堆的 1/3 | G1 时不需要手动设置 |
-XX:MetaspaceSize | 元空间初始触发 GC 大小 | 21MB(JDK 11) | 容器化应用建议显式设置 |
-XX:MaxMetaspaceSize | 元空间上限 | 无上限 | 生产必须设置,推荐 256m~512m |
-XX:MaxDirectMemorySize | 直接内存上限 | 与 -Xmx 相同 | NIO 密集型应用需单独评估 |
第 10 章 总结
运行时数据区是理解 JVM 所有行为的基础地图:
程序计数器:线程私有,记录当前字节码位置,是线程切换恢复的关键,唯一不会 OOM 的区域。
虚拟机栈:线程私有,每次方法调用创建栈帧(局部变量表 + 操作数栈 + 动态链接 + 返回地址),栈深度超限抛 StackOverflowError,this 引用藏在实例方法局部变量表的 Slot 0。
堆:线程共享,所有对象的大本营,分代布局(Eden → Survivor → 老年代),TLAB 减少多线程分配竞争,OOM 是 Java heap space。
方法区/元空间:线程共享,存储类元数据和运行时常量池。JDK 8 的永久代→元空间重构是近年 JVM 最重要的架构变化之一,消除了 PermGen OOM,但需要设置 -XX:MaxMetaspaceSize 上限。
直接内存:JVM 规范外的堆外内存,NIO/Netty 用来减少内存拷贝,需要独立监控和限制。
下一篇 03 对象的创建、内存布局与访问定位 将在”堆”这块区域内深入一层,逐字节解析一个 Java 对象在内存中的精确布局——Mark Word 的位结构、Klass Pointer、实例数据与对齐填充——以及 CompressedOops 如何在 32GB 堆限制内将对象引用从 8 字节压缩到 4 字节。
参考文献
- Tim Lindholm et al., “The Java Virtual Machine Specification, Java SE 21 Edition”, Chapter 2: The Structure of the Java Virtual Machine
- 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 2 章:Java 内存区域与内存溢出异常
- OpenJDK Wiki, “TLAB Allocation”, wiki.openjdk.org
- JEP 122: Remove the Permanent Generation (JDK 8), openjdk.org
- Java SE 8 Release Notes: “Removal of PermGen”, oracle.com
- Aleksey Shipilev, “JVM Anatomy Quark #6: New Object Stages”, shipilev.net
思考题
- JVM 的线程栈帧(Stack Frame)包含局部变量表、操作数栈、动态链接和方法返回地址。如果一个方法有 100 个
int类型的局部变量,该栈帧的局部变量表大小是固定的还是可以动态增长?当线程栈空间不足时,JVM 抛出的是StackOverflowError还是OutOfMemoryError?两者的触发条件有什么区别?- 方法区(Metaspace,JDK 8+)存储类元数据、常量池和 JIT 编译后的代码。Metaspace 使用的是本地内存(native memory)而非 Java 堆。如果不设置
-XX:MaxMetaspaceSize,Metaspace 会无限增长吗?在什么场景下 Metaspace 的 OOM 最容易发生(提示:考虑动态代理和类加载器泄漏)?- 堆是 GC 管理的主要区域。但并非所有对象都分配在堆上——JIT 编译器的逃逸分析可以将未逃逸的对象分配在栈上(标量替换)。在什么条件下逃逸分析会判定对象’未逃逸’?如果一个对象被传递给
synchronized(obj)块,它还能栈上分配吗?