
先说个我印象很深的场景。有一次我临时给团队新人补JVM基础问到“运行时数据区都有哪些”对方很流畅地背出了堆、方法区、虚拟机栈、本地方法栈、程序计数器。我再追问一句“那程序计数器为什么不会OOM”对方卡住了。说实话这种状态特别常见——名字都听过但要落到原理、落到排查现场很多人就含混了。堆和方法区天天跟GC、内存溢出打交道大家不陌生而程序计数器和虚拟机栈这两个“线程私有”的区域平时不出大幺蛾子除了栈溢出容易被一带而过。但恰恰是这两个区域决定了你写的每个方法是怎么被执行的、线程切换为什么不会乱套、递归为什么爆栈。这篇我就把JVM的这两个关键区域拆开揉碎讲一遍它们是什么、为什么存在、出了问题怎么看。目标读者是刚开始啃JVM的新人也适合写过一段时间代码、想补底层逻辑的开发者。不讲虚的直接上干货。1. 先铺一张JVM运行时数据区的地图谁共享、谁私有1.1 JVM到底在做什么JVM全称是Java Virtual Machine也就是Java虚拟机。它的核心使命是执行.class文件里的字节码。Java源代码先通过javac编译成字节码文件JVM负责把字节码加载进来经过校验、解析、翻译最终变成当前操作系统能识别的机器指令去执行。所以“一次编译到处运行”这句口号能成立靠的不是Java语言本身而是每一类操作系统上都有对应的JVM实现。你可以把JVM理解成一个翻译官加物业管家。翻译官负责把字节码“翻译”成当前平台能懂的动作物业管家负责打理程序运行时的内存——哪些内存该分配、哪些对象没用了该回收。我们常说的GC垃圾回收就是物业管家的核心工作之一。这个角色注定了JVM手里必须握着一张清晰的内存布局图也就是运行时数据区。这张图不搞清楚后面聊程序计数器、虚拟机栈都是空中楼阁。1.2 运行时数据区的完整分工JVM规范把运行时数据区分成几个逻辑区域各自职责不同生命周期也不同。我用一张表把最核心的版图列出来区域线程归属主要职责典型异常堆线程共享存放对象实例、数组OutOfMemoryError方法区Java 8后由元空间实现线程共享类型信息、常量、静态变量、方法元数据OutOfMemoryError虚拟机栈线程私有方法调用的栈帧存储局部变量、中间计算结果StackOverflowError、OutOfMemoryError本地方法栈线程私有执行native方法时的调用栈StackOverflowError、OutOfMemoryError程序计数器线程私有记录当前线程正在执行的字节码地址规范上不会抛出任何异常注意看这个划分逻辑堆和方法区是“大家都用得上的公共数据”所以线程共享而程序计数器、虚拟机栈、本地方法栈是“一个线程跑到哪儿了”的执行状态所以线程私有。为什么要这样分因为线程是操作系统调度的基本单位每个线程都有自己独立的执行轨迹。你在一个方法里调用另一个方法这个调用链只属于当前线程自己别的线程跑的是它自己的调用链绝不能混在一起。混了程序就乱套了。1.3 线程私有的“三件套”是怎么配合的一个线程在JVM里跑起来内存层面至少需要三样东西程序计数器记录当前执行到字节码的哪个位置相当于书签。虚拟机栈支撑Java方法的调用每个方法一个栈帧。本地方法栈支撑native方法的调用。这三样合起来就是一个线程独立执行的最小内存配置。PC负责“我在哪一行”栈负责“我调到了哪一层”。执行引擎从PC拿到指令地址取出字节码指令翻译执行执行过程中遇到方法调用就往虚拟机栈里压入一个新栈帧方法结束了弹出栈帧PC回到调用者的下一条指令位置。这套配合是整个JVM执行模型的地基。接下来我们把两张最重要的图放大看清楚。2. 程序计数器每个线程手里那本“读到哪一行”的笔记本2.1 保存的是字节码指令地址不是Java行号JVM规范里对程序计数器的描述很准确它是“当前线程所执行的字节码的行号指示器”。很多人一听“行号”下意识以为是Java源码的行号其实不是。它记录的是当前正在执行的字节码指令的地址你可以理解为字节码数组里的一个偏移量。为什么需要这个东西因为JVM执行引擎本质上是一个不断循环的机器从PC取指令执行PC指向下一条再取指令执行…… 循环往复。PC就是这个循环的“进度指针”。Java代码编译成字节码之后每条字节码指令在方法体里都有一个位置执行引擎靠PC锁住当前位置才能一条接一条地执行下去。调试的时候之所以能显示“当前停在第几行Java代码”是因为class文件里有字节码行号和Java源码行号的映射表。调试器拿着PC值去查映射表翻译成人能看懂的源码行号。这是辅助信息PC本身并不直接存源码行号。2.2 线程切换时它是“恢复现场”的关键这是程序计数器存在的一个核心原因线程上下文切换。CPU的核数是有限的但线程可能很多。为了让所有线程都有机会跑操作系统的调度器会让线程们分时复用CPU核心。线程A跑了一阵子被换下来线程B上去跑过一会儿线程B下来线程A再上去继续跑。问题来了线程A重新上CPU的时候怎么知道自己上次跑到哪儿了如果从头开始整个程序逻辑全乱如果从错误的位置继续结果更不可控。所以每个线程都必须有一个自己的“书签”记录上次执行到的字节码位置。线程被挂起时PC保存当前位置线程被恢复时直接从PC指向的位置继续。可以想象一台服务器同时处理成千上万个请求每个请求对应一个线程这些线程频繁切换。程序计数器就是它们各自的进度记录少了它线程切回来就是“失忆”状态整个并发体系直接垮掉。2.3 为什么整个JVM只有它不会OOMJava开发里最常见的崩溃就是OutOfMemoryError但程序计数器是一个例外JVM规范明确说它不会抛出任何内存异常。原因不难理解程序计数器只需要保存一个很小的地址值这个容量需求在编译和类加载阶段就是确定且极小的。它不像堆那样要动态分配大量对象也不像虚拟机栈那样需要动态扩展栈帧。堆和方法区都可能因为数据太多而耗尽内存栈会因为压栈过深、线程过多而触顶但PC这个区域本身没有“动态扩容”的需求占用的空间基本恒定。所以规范层面直接把它设计成了不会OOM的区域。实际开发中也确实见不到“程序计数器内存溢出”这种东西。就算遇到内存问题要么是堆满了要么是创建线程时系统分配不出栈内存锅都不在PC身上。2.4 执行native方法时会怎样程序计数器有一个容易被忽略的细节当线程正在执行的是一个native方法用C/C等非Java语言实现的方法时PC的值是undefined。为什么因为native方法走的不是字节码指令而是操作系统层面的原生指令。执行引擎不需要通过PC去取字节码自然也就不需要为native方法保存字节码地址。等native方法执行完返回到Java方法时PC会重新指向调用点之后的下一条字节码指令继续正常工作。有人可能问那线程执行native方法被切走了回来怎么续上这个由底层执行引擎和操作系统调度自身去处理不在JVM规范需要管PC的范围内。JVM的PC只对“当前线程执行的Java字节码”负责。3. 虚拟机栈线程的方法调用“现场”也是出错重灾区3.1 栈帧是方法调用的“工位”虚拟机栈是每个线程私有的内存区域生命周期和线程一致线程结束栈就释放。这里面存的东西叫栈帧Stack Frame每次方法调用都会创建一个栈帧压入栈中方法返回时栈帧弹出。栈顶的栈帧是当前正在执行的方法也就是“当前帧”。想象一个调用链main调用serviceservice调用daodao再调用另一个工具方法。JVM执行到最内层时虚拟机栈里从底到顶依次压着这四个方法对应的栈帧。每个栈帧保存了“这个方法跑到现在所需的现场信息”。一个栈帧内部主要包含这几块内容局部变量表操作数栈动态链接方法返回地址3.2 局部变量表slot、this与变量复用局部变量表用于存放方法参数和方法内部定义的局部变量。它的容量在编译期就确定好了方法执行期间不会改变。JVM以Slot变量槽为最小单位分配空间容量大小就是Slot的数量编译完的class文件里已经写好了需要多少Slot。Slot的分配规则很明确32位及以下的数据类型int、short、byte、boolean、float、引用类型占1个Slot。64位的数据类型long、double占2个Slot。这里有个值得注意的细节实例方法非静态方法的局部变量表第0位slot一定用来存放this引用。也就是说你写的每个实例方法编译器都偷偷塞了一个this进去。静态方法没有this所以第0位直接是第一个参数。局部变量表的Slot是可以复用的。一个变量的作用域结束了它占用的Slot就能被后续的局部变量重新使用。这引出一个老生常谈的话题大对象用完了要不要置null早年GC不聪明的时候置null确实能帮GC提前回收。现代JVM的JIT编译器已经能精确做活跃性分析变量离开作用域后GC基本能准确判断它已死置null的作用越来越小。但如果你写的是长期运行的服务某个超大数组或超大对象用完后再也不会访问保留“显式置null”这个习惯仍然没有坏处属于花小成本买安心。另外要分清楚基本类型的值直接存在局部变量表的Slot里引用类型的局部变量Slot里存的是对象在堆上的地址对象本身在堆里。这也是“局部变量线程安全”这话不能乱说的原因——变量引用本身是线程私有的但它指向的对象可能是共享的。3.3 操作数栈所有计算的临时工作台操作数栈是栈帧里的另一个核心区域它的工作方式就是“后进先出”。几乎所有字节码算数运算都先把操作数压进操作数栈运算完再把结果弹出来。用一个最简单的例子看就明白了。假设有这么个Java方法public int add(int a, int b) { int c a b; return c; }编译后用javap -c看字节码核心指令大致是这样的iload_1 // 把局部变量表中Slot 1的a压入操作数栈 iload_2 // 把局部变量表中Slot 2的b压入操作数栈 iadd // 弹出两个int相加把结果压回栈顶 istore_3 // 弹出栈顶值存入局部变量表Slot 3也就是c iload_3 // 把c重新压入操作数栈 ireturn // 返回栈顶的int值这说明什么呢JVM做加法不是在寄存器里直接算而是把数据从局部变量表搬到操作数栈运算完再搬回局部变量表。操作数栈就是一个“临时计算台”所有中间结果都在这里流转。操作数栈的最大深度也在编译期确定class文件里有对应的属性记录。所以虚拟机能提前知道每个方法执行时需要的栈空间大小。顺便可以解释一个经典面试题i和i有什么区别拿字节码看就很清楚。// i iload_1 // 先把i的旧值压栈 iinc 1, 1 // 局部变量Slot 1的i自增1 istore_1 // 把栈顶旧值存回Slot 1// i iinc 1, 1 // 先把Slot 1的i自增1 iload_1 // 再把新值压栈 istore_1 // 存回Slot 1区别在于自增指令和压栈指令的先后顺序这直接决定了表达式返回值是旧值还是新值。理解了操作数栈的工作机制这类问题根本不用死记。3.4 动态链接和返回地址调用链怎么闭合栈帧里有一个指向运行时常量池的引用作用是为当前方法提供动态链接支持。字节码里所有方法调用指令最初引用的都是一个符号引用类的全限定名、方法名、方法描述符。JVM在类加载的解析阶段可能把一部分符号引用转换成直接引用指向实际内存地址但还有一部分比如虚方法调用需要到运行期才能确定到底调用哪个类的方法这就是“动态链接”。举个例子父类引用指向子类对象调用一个被子类重写的方法。编译期根本不知道对象的真实类型是哪个子类只能运行时根据对象的实际类型去解析目标方法。动态链接就是干这个的。方法返回地址也好理解。方法正常返回时当前栈帧出栈执行引擎把控制权交还给调用者同时把PC恢复成调用者“下一条还没执行”的指令地址。如果方法执行过程中抛出异常且异常没有被方法内部捕获那就会走异常表Exception Table的查找逻辑。查不到对应的异常处理器方法就会异常退出栈帧被弹出异常继续抛给上一层。3.5 -Xss与两类内存异常不是所有栈问题都一样先明确一点虚拟机栈涉及两种异常很多人混为一谈StackOverflowError线程请求的栈深度超过了虚拟机允许的最大深度。最常见的触发场景就是递归没有出口一层层压栈最终把栈压穿。OutOfMemoryError栈容量允许动态扩展但扩展时无法申请到足够内存或者创建新线程时系统无法给新线程的栈分配内存。这两种异常的出现场景、排查方向完全不同。前者先查代码逻辑里的递归和深层调用后者要先算内存账、看线程数。控制栈大小有一个直接参数-Xss。比如-Xss512k表示把每个线程的栈大小设置为512KB。目前主流HotSpot虚拟机在64位平台上的默认值通常是1MB左右想确认当前JDK的实际值可以执行java -XX:PrintFlagsFinal -version | grep ThreadStackSize栈大小设置不是越大越好因为线程栈是线程私有的。假设你有500个线程每个线程栈1MB光是线程栈就要吃掉将近500MB的内存。如果把-Xss调大到2MB同样的线程数栈内存就翻倍。反过来如果线程数很多、可用内存有限把-Xss调小能开更多线程但代价是单个线程的栈深度下降递归能力强的方法更容易栈溢出。3.6 用一个小实验看栈深度的极限写个简单的递归程序就能直观感受栈深度跟-Xss的关系public class StackDepth { private static int depth 0; public static void main(String[] args) { try { recurse(); } catch (StackOverflowError e) { System.out.println(栈溢出最大深度 depth); } } private static void recurse() { depth; recurse(); } }默认配置下运行输出的大致深度会落在几千到上万这个区间具体数字取决于每个栈帧占用的空间、JVM版本、操作系统不同环境差异不小。然后用-Xss256k和-Xss2m分别再跑一次深度变化非常明显。这个小实验值得自己在本地跑一遍。它能把“栈帧压栈”这个抽象概念变成具体感受栈不是无限高的每调用一次方法都要消耗一块栈空间方法参数越多、局部变量越多、操作数栈越深单个栈帧占的空间越大同样的栈大小能容纳的调用层级就越少。4. 排查现场把栈信息变成破案线索4.1 看到StackOverflowError先别慌线上遇到StackOverflowError第一件事不是改-Xss而是看异常堆栈。如果堆栈里出现大量重复的栈帧那基本可以断定是递归没有终止条件或者递归的终止条件永远不满足。常见原因包括递归方法缺少退出分支。对象关系形成环比如A调用B、B调用A。使用框架的动态代理、拦截器时方法调用被不断包装间接形成深度调用链。更隐蔽的一种场景是递归本身有终止条件但数据量太大导致递归深度超过栈容量。这时候单纯改代码也不一定解决问题更稳妥的方案是把递归写成循环或者引入显式的任务栈。我自己写递归算法有个习惯先写终止条件和深度上限再写递归体。比如限制“深度超过某阈值直接抛业务异常”把底层的内存崩溃转换成可控的业务失败至少不会半夜被StackOverflowError报警吵醒。4.2 读线程dump就像看现场监控排查线程卡顿、死锁、响应变慢用jstack抓线程dump是最直截了当的方式。jstack pid会把所有线程当前的方法调用栈打出来。每个线程一段包含线程名、线程状态、栈帧列表。线程状态尤其值得关注RUNNABLE正在执行或者等待CPU。BLOCKED正在等待监视器锁典型的卡在synchronized同步块上。WAITING / TIMED_WAITING在等待其他线程唤醒比如Object.wait、Thread.join、LockSupport.park。实际排查时先找状态异常的线程再顺着栈帧看它卡在哪一行。比如多个线程都BLOCKED在同一个锁对象上说明锁竞争激烈或某个持锁线程长时间不释放。如果jstack输出里明确提示“Found one Java-level deadlock”那通常已经形成了循环等待锁的僵局。下面是一个简化后的线程栈片段示意注意看锁信息http-nio-8080-exec-8 ... java.lang.Thread.State: BLOCKED (on object monitor) at com.example.service.OrderService.deductStock(OrderService.java:88) - waiting to lock 0x00000000821f2a60 (a com.example.service.InventoryLock)看懂这段就明白了这个线程停在下单方法里正在等一个库存锁对象。顺着锁对象的地址全局搜索就能把持锁线程找出来再判断是不是死锁还是长耗时导致的等待。这套方法非常实用比反复看日志高效得多。4.3 线程数上不去时先算栈内存账还有一种常见线上问题内存看起来没满但创建线程失败报OutOfMemoryError: unable to create new native thread。这时候要意识到每个线程除了JVM内部的数据结构还要在操作系统层面分配一块栈内存。线程栈的总开销 线程数 x 每个线程栈大小。如果进程可用的虚拟内存、或者容器限制的内存已被吃紧新线程就分配不到栈空间了。处理这类问题算账比乱调参数重要。假设一个进程的目标是1000个线程每个栈默认1MB光栈就要预留1GB如果实际可用内存只有几百MB1000个线程根本不现实。要么降低线程数要么把-Xss调小。但调小-Xss是有代价的单个线程能支撑的栈帧层级变少递归能力下降。所以做取舍前先评估业务里的调用深度而不是无脑调小。这也是很多高并发服务不采用“一请求一线程”模型的原因。线程栈的固定开销在那里摆着连接数一高内存就扛不住了。事件循环、协程这类模型之所以流行本质上就是绕开了“每个连接一个线程栈”的内存浪费。4.4 逃逸分析带来的“额外福利”聊虚拟机栈绕不开一个现代JVM优化逃逸分析。HotSpot默认开启逃逸分析它会判断一个对象是否会“逃逸”出当前方法。如果一个新建对象没有被方法外部引用它可能不会进入堆而是被JIT编译器做“标量替换”直接在栈上分配甚至拆成多个局部变量。这样对象就不需要GC参与回收栈帧弹出时内存自然失效极大减轻GC压力。但要注意这是JIT编译器的优化行为不是JVM规范强制要求的。而且“栈上分配”属于优化细节实际代码不要依赖它。你手写代码时没有办法强制某个对象一定在栈上分配强行去做反而可能妨碍JIT优化。正确的姿势是把优化交给JVM业务代码专注把对象的作用域控制好。作用域越小逃逸分析越容易判断“不逃逸”优化机会越大。所以写代码时缩小局部变量的作用域不光是代码风格问题对JIT优化也有实际帮助。4.5 几个容易被带偏的说法最后澄清几个我在各种讨论里经常看到的错误说法“程序计数器会OOM”不成立。JVM规范明确该区域不存在内存溢出。“把-Xss调大就不会栈溢出”治标不治本。除非是正常的深度调用需求否则根因往往是递归和循环调用。“局部变量都在栈上所以多线程下局部变量一定安全”只说对了一半。局部变量引用本身是线程私有的但如果多个线程持有同一个对象引用并去修改那个对象竞争照样发生。“线程栈设置成一样就行”线程栈太大浪费内存太小容易触发溢出建议结合具体线程数量、业务调用深度去评估而不是抄一个固定值到处用。我自己在实际排查里最常用的两个命令仍然是jmap看堆、jstack看栈。堆告诉你“内存去哪了”栈告诉你“线程卡在哪”。程序计数器和虚拟机栈在平时确实像透明人一样不显眼但一旦出问题它们就是最直接的破案线索。最后分享一个小习惯每次写递归相关的代码我会顺手在方法开头加一个深度检查超过设定阈值直接抛业务异常。这样就算线上出现异常数据也不至于把虚拟机的栈打爆而是变成一条清晰的错误日志。这类问题处理多了你会明白很多底层崩溃看起来吓人其实只要提前设好屏障完全可以在变成致命故障之前被拦截掉。