Skip to content
Go back

JVM运行时数据区——每个字节码在哪里执行?

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;
  }

ClassTypeNonClassType 的分野对应了”类元数据 + 运行时常量池”两块内容,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 发生时内存已经见底,再临时分配字符串可能再次失败、连错误都报不出来。预分配 + 枚举编号,保证任何时刻都能「手里有现成的错误对象」可抛。

章末提问

  1. JVM 内存区域为什么分「线程共享」和「线程私有」? — 结论:两者的生命周期和并发特性不同。因为共享区放对象和类元数据,多个线程都要访问、需要 GC 统一管理;私有区放每个线程的执行现场(栈帧、PC),随线程创建销毁,天然隔离、无需同步。

  2. PC 寄存器为什么是唯一不会 OOM 的区域? — 结论:它只存一个固定大小的指令指针。因为每个线程只有一个「当前指令地址」,大小恒定、不随对象数量或递归深度增长,没有可耗尽的资源。

  3. 栈溢出(StackOverflowError)和堆的 OutOfMemoryError 本质区别是什么? — 结论:栈是容量受限、按帧压入,超了就溢出;堆是动态分配、对象太多才耗尽。因为栈帧大小和深度由方法调用链决定,堆容量由对象总量决定,两种失败模式根本不同。

  4. 为什么要用 Metaspace 取代永久代? — 结论:为了摆脱固定上限的 PermGen OOM。因为动态生成类(CGLIB/反射/Lambda)让类的数量不可预估,本地内存自适应比写死的 MaxPermSize 更抗这类问题。

  5. -Xss 调小会带来什么? — 结论:每个线程栈更小、可创建的线程数更多,但递归和深调用更容易 StackOverflow。因为栈大小和线程数量此消彼长,而调用深度是硬约束。


Share this post on:

Previous Post
OOM类型全景排查——从heap到direct memory的四种溢出路径
Next Post
JVM三色标记与漏标问题