【从0到1学习JVM · 09】整个JVM只有它绝不报OOM,程序计数器到底是怎么工作的
发布时间:2026/9/30 6:04:32 作者:尧图编辑部 阅读量:1,286

前言学 JVM 内存区域很多人最先略过的就是程序计数器。它体积最小功能看起来最单调甚至整个运行时数据区里只有它绝不报 OOM。但要是没有它多线程并发和字节码解释执行一秒都跑不下去。这篇文章把它的底层机制彻底讲透。文章目录前言一、CPU有PC寄存器JVM为什么也要造一个二、拆开字节码看一眼计数器到底记了什么三、多线程切走再切回凭什么一行代码都不会跑重四、为什么整个运行时数据区只有它绝不报OOM写在最后一、CPU有PC寄存器JVM为什么也要造一个计算机体系结构里有一个常识物理 CPU 内部有一组寄存器其中最重要的一个叫做 PC 寄存器Program Counter Register用来存放下一条要执行的机器指令在物理内存中的地址。CPU 执行完当前指令就把 PC 寄存器的值加一或者根据跳转指令跳转到新地址。Java 是运行在虚拟机上的。我们写的 Java 代码编译出来不是机器码而是平台无关的字节码.class文件。操作系统的物理 CPU 根本读不懂iload_1、iadd这些字节码指令真正负责翻译执行这些指令的是 JVM 执行引擎。运行在物理操作系统进程之上JVM 虚拟机层软件抽象逐条取指JVM 执行引擎解释器Interpreter JIT 编译器JVM 程序计数器PC Register记录当前线程正在执行的字节码指令偏移量方法字节码流Method Bytecode0x00, 0x1B, 0x60, 0x3C...物理硬件层读取执行物理 CPU执行原生机器指令物理硬件 PC 寄存器记录物理内存中的机器码地址物理内存RAMx86 / ARM 机器码指令流物理 CPU 需要一个指针知道下一条指令在哪负责逐行执行字节码的 JVM 自然也需要一个指针。这个指针就是 JVM 里的程序计数器Program Counter Register。它是物理 CPU 寄存器在软件层面的仿真与抽象。它记录的不是物理内存的物理地址而是当前方法字节码流中的行号或指令偏移量Offset。执行引擎里的解释器就像一个永动机它的日常工作非常单纯看看程序计数器里的偏移量是多少去方法字节码流里把对应位置的操作码Opcode和操作数取出来翻译成当前机器的汇编指令交给 CPU 执行更新程序计数器的指针指向下一条字节码指令。二、拆开字节码看一眼计数器到底记了什么光讲概念很抽象我们直接写几行代码反编译看一眼。packagecom.crayontech.jvm;publicclassCounterDemo{publicintcalculate(inta,intb){intsumab;if(sum10){returnsum*2;}returnsum;}}使用javac CounterDemo.java编译后用javap -c CounterDemo.class查看calculate方法的字节码public int calculate(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: istore_3 4: iload_3 5: bipush 10 7: if_icmple 14 10: iload_3 11: iconst_2 12: imul 13: ireturn 14: iload_3 15: ireturn每一行指令最左边的数字0、1、2、3、4、5、7、10、14……就是字节码的指令偏移量Bytecode Offset。程序计数器里面存的就是这个数字。执行引擎解释器calculate() 方法字节码指令流当前线程私有空间读取指向更新值若10则更新为14否则更新为10程序计数器当前值 70: iload_11: iload_22: iadd3: istore_34: iload_35: bipush 107: if_icmple 1410: iload_314: iload_31. 读取 PC 计数器 - 获得偏移量 72. 获取指令 7: if_icmple 143. 执行判断并根据结果刷新 PC 寄存器你会发现一个很有意思的现象为什么第 5 行之后直接跳到了第 7 行第 6 行去哪了原因在于第 5 行的指令是bipush 10。bipush操作码本身占 1 个字节偏移量 5但它后面跟着一个操作数10也占 1 个字节偏移量 6。所以下一条指令if_icmple就只能排在偏移量 7 的位置。程序计数器就是一个精准的游标顺序执行时它沿着偏移量向前推进遇到if_icmple 14时如果条件成立计数器的值直接被改写成 14跳过中间的代码循环、异常捕获跳转Exception Table、return恢复调用点底层的控制流转移全靠直接修改程序计数器的值来实现。这里有一个特殊的边界情况。如果当前方法调用的是一个 Native 本地方法比如Thread.sleep()、System.currentTimeMillis()或通过 JNI 调用的 C/C 动态链接库程序计数器里记录的是什么答案是未定义Undefined。因为 Native 代码编译后是直接跑在操作系统上的机器指令这些指令直接受硬件 CPU 的物理寄存器调度不再受 JVM 字节码解释引擎管辖JVM 的程序计数器自然无需、也无法记录它的偏移量。三、多线程切走再切回凭什么一行代码都不会跑重如果整台 JVM 只有一个全局程序计数器会发生什么现代操作系统在多核或单核 CPU 上对多线程普遍采用抢占式时间片轮转机制。每个线程分配几十毫秒的 CPU 运行时间时间片一到就会被剥夺 CPU 资源挂起等待下一次调度。假设有两个线程 A 和 B同时调用同一个计算方法线程 B有独立 PC 寄存器线程 A有独立 PC 寄存器线程 B有独立 PC 寄存器线程 A有独立 PC 寄存器线程 A 获得时间片开始执行操作系统时钟中断线程 A 时间片耗尽CPU 调度线程 B 运行线程 B 时间片耗尽挂起CPU 重新唤醒线程 ACPU 物理核心执行 0: iload_1 (PC0)1执行 1: iload_2 (PC1)2执行 2: iadd (PC2)3挂起线程 A保存 PC3 到线程 A 私有上下文4线程 B 恢复自己的私有 PC05执行 0: iload_1 (PC0)6执行 1: iload_2 (PC1)7挂起线程 B保存 PC2 到线程 B 私有上下文8恢复线程 A 私有 PC39从 3: istore_3 继续执行丝毫不受线程 B 干扰10CPU 物理核心如果程序计数器是全局共享的线程 A 执行到偏移量 2 时被打断线程 B 插进来把计数器改成 0。等线程 A 再次醒来读到的计数器变成了线程 B 留下的位置整个执行流全乱套了。因此《Java 虚拟机规范》明确规定程序计数器必须是线程私有的。每个线程在创建的那一刻JVM 都会为它独立分配属于自己的程序计数器。它的生命周期和所属线程严格绑定线程创建计数器诞生线程休眠或挂起计数器作为线程上下文状态的一部分被妥善保存线程重新被调度JVM 从保存的私有计数器里读出偏移量继续往下取指线程运行结束消亡计数器随之销毁释放。各管各的游标线程之间互不干扰。多线程频繁切换依然能精准续跑秘密全在每个线程随身携带的这个小指针上。四、为什么整个运行时数据区只有它绝不报OOM在 JVM 经典五大运行时数据区堆、方法区、虚拟机栈、本地方法栈、程序计数器里有一个非常著名的特权程序计数器是《Java 虚拟机规范》中唯一一个没有规定任何 OutOfMemoryError内存溢出情况的区域。我们可以横向对比一下其它四个区域为什么会 OOM唯一绝不 OOM 的安全区程序计数器PC Register只保存一个指令地址数字固定占用 1 个字长4 字节或 8 字节不分配动态对象不动态扩容- 规范未定义任何 OOM可能发生 OOM / 栈溢出的区域Java 堆Heap创建过多对象未释放GC 回收不了- OutOfMemoryError: Java heap space方法区 / 元空间Metaspace动态生成加载过多类CGLIB/动态代理- OutOfMemoryError: Metaspace虚拟机栈 / 本地方法栈方法递归太深栈帧超限 - StackOverflowError线程创建过多申请不到新内存 - OutOfMemoryError堆内存放对象随着业务运行对象数量不断暴涨GC 清理不过来就会堆内存溢出。方法区存放类元数据、常量池和静态变量如果框架疯狂用反射或字节码增强生成几万个动态类元空间耗尽也会 OOM。虚拟机栈随着方法调用一层层压入栈帧递归太深会爆出StackOverflowError如果无限制新建线程导致内存不够分配新的线程栈也会抛出OutOfMemoryError: unable to create new native thread。唯独程序计数器不同。它的内部结构极其单纯。一个线程在任何一个瞬间正在执行的方法只有当前这一个当前方法的一行字节码只有这一个固定的偏移量。换句话说程序计数器里面永远只存一个数字。在 32 位操作系统下一个内存地址或数字是 4 个字节在 64 位操作系统下一个数字是 8 个字节即一个 Word 机器字长。------------------------------------------------- | PC Register 存储内容 (固定 1 个字长4/8 字节) | | 当前字节码指令偏移量 0x00000007 | -------------------------------------------------无论你的 Java 业务代码写了几万行无论你的系统并发量有几千几万一个线程在某一个时刻能跑的代码永远只有一条。这个用来记录位置的计数器空间大小在编译和运行时是定死的它不需要申请额外内存不需要动态扩容更不会在里面创建对象。空间大小恒定完全没有动态增长的概念在物理机制上就彻底杜绝了内存耗尽的可能。所以《Java 虚拟机规范》在编写内存管理章节时对它网开一面根本没有为其定义任何内存溢出异常。写在最后程序计数器是 JVM 内存里最小的组件小到很多人复习八股文时只愿意留给它一句话的印象。但越是微小的底层组件往往承担着越纯粹的系统职责。没有这个只有几个字节的小游标JVM 的解释器就失去了眼睛多线程时间片轮转就会迷路异常处理与跳转逻辑也将无从谈起。搞懂了它你就掌握了线程私有区域里最轻量、也最稳定的一块拼图。接下来我们将深入到线程私有空间里最复杂、也最容易踩坑的另外两大区域Java 虚拟机栈与本地方法栈拆解栈帧的内部构造与方法调用的真实代价。如果你觉得这篇内容对你理解 JVM 内存有帮助记得点个关注追更本系列的后续深度拆解