JVM 运行时数据区:每个字节码在哪里执行?
一句话结论(30s)
JVM 内存按”线程共享 vs 线程私有”划分为共享的堆/元空间与私有的虚拟机栈/本地方法栈/PC 五大区域,因为共享区放对象和类元数据、私有区放执行现场,两者的生命周期与并发特性完全不同。关键设计是 PC 是 JVM 规范里唯一不会 OOM 的区域、Metaspace 用本地内存取代永久代解决了动态生成类的 PermGen OOM 痛点。权衡在于不同 OOM(堆、Metaspace、栈、直接内存)必须精确对应各自区域,才能对症排查。
核心原理(2min)
每个线程执行时,PC 寄存器指向当前字节码指令地址,方法调用在虚拟机栈压入栈帧(局部变量表存 this 和参数、操作数栈做指令中转、动态链接指向运行时常量池符号引用);对象分配在堆上,新生代 Eden→Survivor 复制晋升到老年代,类元数据存在元空间。关键机制是逃逸分析让未逃逸对象栈上分配、TLAB 让线程在 Eden 私有小块无锁分配,二者大幅降低堆分配竞争;Java 8 起 Metaspace 存于本地内存,默认只受物理内存限制。
底层深入(5-10min)
全景图
JVM 运行时数据区:
┌──────────────────────────────────────────────────┐
│ 线程共享区 │
│ ┌──────────┐ ┌─────────────────────────────┐ │
│ │ 堆(Heap) │ │ 元空间(Metaspace) │ │
│ │ - 对象 │ │ - 类元数据 │ │
│ │ - 数组 │ │ - 方法信息 │ │
│ │ - 字符串 │ │ - 运行时常量池 │ │
│ │ │ │ (使用本地内存,非堆) │ │
│ └──────────┘ └─────────────────────────────┘ │
├──────────────────────────────────────────────────┤
│ 线程私有区 (每个线程一份) │
│ ┌─────────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ 虚拟机栈 │ │ 本地方法栈 │ │ 程序计数器 │ │
│ │ (Java方法) │ │ (Native) │ │ (PC) │ │
│ │ - 栈帧 │ │ │ │ - 当前字节码 │ │
│ │ - 局部变量表│ │ │ │ 行号 │ │
│ │ - 操作数栈 │ │ │ │ │ │
│ │ - 动态链接 │ │ │ │ │ │
│ │ - 返回地址 │ │ │ │ │ │
│ └─────────────┘ └──────────┘ └─────────────┘ │
└──────────────────────────────────────────────────┘
直接内存 (Direct Memory)
┌──────────────────────────┐
│ - NIO ByteBuffer 堆外内存 │
│ - JVM 堆外,OS 直接管理 │
└──────────────────────────┘
程序计数器(PC)——唯一的”指针”
每个线程有一个 PC 寄存器,指向当前正在执行的字节码指令地址。线程切换时,PC 保存当前执行位置,切换回来从 PC 指向的位置继续执行。
HotSpot 里每个栈帧都有一个 _pc 字段保存程序计数器。解释执行时,它对应的是字节码指针(bcp,byte code pointer)——frame.hpp 中直接把它注释为 “program counter”:
class frame {
private:
// Instance variables:
union {
intptr_t* _sp; // stack pointer (from Thread::last_Java_sp)
int _offset_sp; // used by frames in stack chunks
};
address _pc; // program counter (the next instruction after the call)
mutable CodeBlob* _cb; // CodeBlob that "owns" pc
// ...
对于解释器帧,_pc 落到更细的字节码索引/指针上:
// byte code index
jint interpreter_frame_bci() const;
// byte code pointer
address interpreter_frame_bcp() const;
void interpreter_frame_set_bcp(address bcp);
_pc 是每个线程栈帧的成员,线程切换时它随栈帧一起保存,切换回来从 _pc 指向的下一条指令继续执行,这正是”PC 保存现场”的底层实现。
如果当前方法是 Native 方法,PC 的值为 undefined——因为 Native 代码的执行由 C/C++ 引擎管理,不在 Java 字节码范围内。
PC 是唯一一个 JVM 规范中不会 OOM 的区域。
为什么偏偏是 PC 不会 OOM?不妨想一想:它每个线程只存一个「当前指令地址」指针,大小恒定、不随对象数量或递归深度增长,自然不存在「耗尽」一说——这正是它被称为「唯一不会 OOM 的区域」的根源。
虚拟机栈——方法的”舞台”
每个方法被调用时,JVM 创建一个栈帧(Stack Frame)压入虚拟机栈。栈帧包含:
在 HotSpot 里,一个解释器栈帧在 frame.hpp 中被拆成若干区块:interpreter_frame_locals 就是局部变量表,interpreter_frame_expression_stack 就是操作数栈,二者加上 bcp 共同构成一个帧:
// Locals
// The _at version returns a pointer because the address is used for GC.
intptr_t* interpreter_frame_local_at(int index) const;
void interpreter_frame_set_locals(intptr_t* locs);
// byte code pointer
address interpreter_frame_bcp() const;
// expression stack (may go up or down, direction == 1 or -1)
intptr_t* interpreter_frame_expression_stack() const;
// top of expression stack
intptr_t* interpreter_frame_tos_at(jint offset) const;
intptr_t* interpreter_frame_tos_address() const;
jint interpreter_frame_expression_stack_size() const;
局部变量表本质是一个槽位数组,操作数栈是随字节码推进动态伸缩的栈顶指针区。二者都以 intptr_t* 地址定位——这正是”slot 存值、栈顶中转”的 C++ 落地。
局部变量表(Local Variables)
是一个槽位数组。实例方法的第 0 号槽固定存 this 引用。基本类型存值,引用类型存堆上对象的指针。
public void foo(int a, long b, Object c) {
// slot 0: this
// slot 1: a (int)
// slot 2-3: b (long,占两个 slot)
// slot 4: c (Object 引用)
}
操作数栈(Operand Stack)
字节码指令的执行空间。iload_1 从局部变量表取 int 压入操作数栈顶,iadd 从操作数栈弹出两个 int 相加再压回结果。操作数栈是字节码指令的”寄存器”——不直接操作局部变量,而是通过操作数栈中转。
为什么字节码不直接操作局部变量,非要经过操作数栈中转?因为 JVM 是栈式虚拟机——所有指令约定成对地从栈顶取数、再压回结果,指令集因此变得小而统一,也天然具备跨平台移植性。局部变量表只是「存储」,操作数栈才是「计算现场」。
动态链接
指向运行时常量池中该方法的符号引用。支持方法重写——运行时指向的是实际调用对象类的方法,而非声明类型的。
堆——对象的主战场
绝大多数对象(包括数组)分配在堆上。堆又分为:
堆 = 新生代(Young Gen) + 老年代(Old Gen)
新生代 = Eden (80%) + Survivor0 (10%) + Survivor1 (10%)
新对象分配在 Eden,经过一次 Minor GC 存活后进入 Survivor,在 S0/S1 之间反复复制直到达到晋升阈值(默认 15 次 GC)进入老年代。
为什么要分代?这是基于「弱分代假说」——绝大多数对象朝生夕死。既然大部分对象活不过第一次 GC,那就把它们集中放在新生代,用廉价的复制算法高频回收;少数长命对象晋升到老年代,低频整堆回收。分代的本质是用「按对象年龄差异化回收」换取整体吞吐。
在 HotSpot 里,“堆”并不是一个具体类,而是一个抽象基类 CollectedHeap,Serial/G1/ZGC 等都是它的具体实现。mem_allocate 是堆上对象分配的原始入口,allocate_new_tlab 则专门用于划出线程私有小块:
// A "CollectedHeap" is an implementation of a java heap for HotSpot. This
// is an abstract class: there may be many different kinds of heaps.
//
// CollectedHeap
// SerialHeap
// G1CollectedHeap
// ParallelScavengeHeap
// ShenandoahHeap
// ZCollectedHeap
//
class CollectedHeap : public CHeapObj<mtGC> {
// Create a new tlab. All TLAB allocations must go through this.
virtual HeapWord* allocate_new_tlab(size_t min_size,
size_t requested_size,
size_t* actual_size) = 0;
// Raw memory allocation facilities
// mem_allocate() should never be
// called to allocate TLABs, only individual objects.
virtual HeapWord* mem_allocate(size_t size) = 0;
堆的 Eden/Survivor/老年代分区由各收集器在 CollectedHeap 之下各自实现,因此上层分配逻辑是统一的、底层布局是可插拔的——这正是 HotSpot 能同时支持多种 GC 的结构性原因。
但逃逸分析可以让未逃逸对象分配在栈上(标量替换),TALB 让每个线程在 Eden 有私有小块,对象分配的竞争大幅降低。
元空间(Metaspace)——取代永久代
Java 8 用 Metaspace 替代永久代(PermGen),最大的变化:类元数据不再存在堆的永久代中,而是使用本地内存(Native Memory)。
在 HotSpot 源码里,Metaspace 是 memory/metaspace.hpp 中的一个纯静态工具类,把元数据分成”类(ClassType)“和”非类(NonClassType)“两类,各自独立分配。类元数据还被进一步压缩进一块连续的 class space 地址区间:
class Metaspace : public AllStatic {
public:
enum MetadataType {
ClassType,
NonClassType,
MetadataTypeCount
};
enum MetaspaceType {
ZeroMetaspaceType = 0,
StandardMetaspaceType = ZeroMetaspaceType,
BootMetaspaceType = StandardMetaspaceType + 1,
ClassMirrorHolderMetaspaceType = BootMetaspaceType + 1,
MetaspaceTypeCount
};
// ...
static MetaWord* allocate(ClassLoaderData* loader_data, size_t word_size,
MetaspaceObj::Type type, TRAPS);
// Returns true if pointer points into class space, false if it doesn't or if
// there is no class space.
static inline bool is_in_class_space(const void* ptr) {
return ptr < _class_space_end && ptr >= _class_space_start;
}
ClassType 与 NonClassType 的分野对应了”类元数据 + 运行时常量池”两块内容,is_in_class_space 则说明类元数据被隔离在独立地址区间中管理——这就是”方法区/元空间”在源码层面的结构。
这解决了一个历史性痛点:动态生成大量类(CGLIB 代理、JSP 编译、Lambda 表达式)时,永久代容量(-XX:MaxPermSize)一旦配置小了就 PermGen OOM。Metaspace 默认只受物理内存限制(可用 -XX:MaxMetaspaceSize 限制),不再有固定的上限焦虑。
为什么要用 Metaspace 取代永久代,而不是继续扩大永久代?因为永久代受固定堆上限约束,而动态生成类的场景(CGLIB、反射、Lambda)让类的数量不可预估——把类元数据搬到本地内存后,上限从「配置写死」变成「物理内存自适应」,也顺便消除了 PermGen OOM 这类难排查的问题。
各种 OOM 对应哪个区域?
| 错误信息 | 对应区域 | 根因 |
|---|---|---|
java.lang.OutOfMemoryError: Java heap space | 堆 | 对象太多,或内存泄漏 |
java.lang.OutOfMemoryError: Metaspace | 元空间 | 动态加载了太多类 |
java.lang.StackOverflowError | 虚拟机栈 | 递归过深 |
java.lang.OutOfMemoryError: Direct buffer memory | 直接内存 | NIO 堆外内存未释放 |
这些错误字符串并非运行时临时拼接,而是在 JVM 启动时预分配好的。universe.cpp 用枚举给每种 OOM 编号,再一次性生成带固定消息的异常对象,避免在内存耗尽时还要再分配内存来报错:
enum OutOfMemoryInstance { _oom_java_heap,
_oom_c_heap,
_oom_metaspace,
_oom_class_metaspace,
_oom_array_size,
_oom_gc_overhead_limit,
_oom_realloc_objects,
_oom_count };
// Setup preallocated OutOfMemoryError errors
void Universe::create_preallocated_out_of_memory_errors(TRAPS) {
InstanceKlass* ik = vmClasses::OutOfMemoryError_klass();
// ...
Handle msg = java_lang_String::create_from_str("Java heap space", CHECK);
java_lang_Throwable::set_message(oom_array->obj_at(_oom_java_heap), msg());
msg = java_lang_String::create_from_str("Metaspace", CHECK);
java_lang_Throwable::set_message(oom_array->obj_at(_oom_metaspace), msg());
msg = java_lang_String::create_from_str("Compressed class space", CHECK);
java_lang_Throwable::set_message(oom_array->obj_at(_oom_class_metaspace), msg());
}
_oom_java_heap、_oom_metaspace、_oom_class_metaspace 分别对应堆、元空间、压缩类空间三个区域。能报出哪种 OOM,取决于哪块内存先被耗尽——这就是”对症排查”的源码依据。
为什么 OOM 错误消息要提前预分配,而不是用到时再 new?因为 OOM 发生时内存已经见底,再临时分配字符串可能再次失败、连错误都报不出来。预分配 + 枚举编号,保证任何时刻都能「手里有现成的错误对象」可抛。
章末提问
-
JVM 内存区域为什么分「线程共享」和「线程私有」? — 结论:两者的生命周期和并发特性不同。因为共享区放对象和类元数据,多个线程都要访问、需要 GC 统一管理;私有区放每个线程的执行现场(栈帧、PC),随线程创建销毁,天然隔离、无需同步。
-
PC 寄存器为什么是唯一不会 OOM 的区域? — 结论:它只存一个固定大小的指令指针。因为每个线程只有一个「当前指令地址」,大小恒定、不随对象数量或递归深度增长,没有可耗尽的资源。
-
栈溢出(StackOverflowError)和堆的 OutOfMemoryError 本质区别是什么? — 结论:栈是容量受限、按帧压入,超了就溢出;堆是动态分配、对象太多才耗尽。因为栈帧大小和深度由方法调用链决定,堆容量由对象总量决定,两种失败模式根本不同。
-
为什么要用 Metaspace 取代永久代? — 结论:为了摆脱固定上限的 PermGen OOM。因为动态生成类(CGLIB/反射/Lambda)让类的数量不可预估,本地内存自适应比写死的
MaxPermSize更抗这类问题。 -
把
-Xss调小会带来什么? — 结论:每个线程栈更小、可创建的线程数更多,但递归和深调用更容易 StackOverflow。因为栈大小和线程数量此消彼长,而调用深度是硬约束。