做后端开发这些年,i++应该是敲得最多的一行代码,多到我们基本不会去想它到底被编译成了什么。真出问题的时候才会回头盯着它看:某次活动页的面积分统计,压测跑完发现总计数比预期少了几十;代码评审里有人问i = i++为什么结果没变;面试时被追问“i++是不是原子操作”,答完“不是”就没了下文。这几个场景其实指向同一个东西——i++在 JVM 里从来不是一条指令,它是一段发生在栈帧内部的读写序列:值从局部变量表搬进操作数栈,加一,再搬回去。把这条链路走通,你就能解释清楚为什么volatile修饰了计数器依然会丢更新、为什么iinc指令只认 int、为什么循环体里的自增在 JIT 之后和字节码长得完全不一样。这篇内容面向已经写过 Java、但没系统翻过字节码的开发者,也面向想搞懂“一行代码在栈帧里经历了什么”的同行,我会用javap把每条指令摊开,配合可复现的并发案例,把从i++到 JVM 栈帧的这条路走一遍。
1. 先把 i++ 这行代码拆开看
1.1 源码里长得像,字节码里差很远
很多人对i++的第一层误解,是把它当成一个“动作”。在源码层面它确实只是一个符号,但在编译产物层面,i++会被拆成若干条字节码指令,具体拆成几条、拆成什么,取决于这个i是局部变量、成员变量、静态变量还是数组元素,也取决于自增表达式的返回值有没有被使用。这四种情况的字节码形态完全不同,性能特征和并发安全性也不同,混在一起谈“i++不安全”其实是不准确的,准确的说法是:当自增对象不在当前栈帧的局部变量表里,或者自增结果被多个线程共享时,才需要额外考虑同步。
先看最干净的一种情况,自增表达式的结果不被使用,也就是一句独立的i++;。Javac 在这里做了一次很实在的优化:既然只关心局部变量最终的值,那就没必要把它搬到操作数栈上再搬回来,直接对局部变量表里的槽位做原地加一即可。所以独立的i++;只会生成一条iinc指令,注意它不产生任何栈操作,也不消耗操作数栈深度。这个细节很关键,因为它意味着单线程下无返回值的局部变量自增,开销小到几乎可以忽略,你在热点循环里写i++和写i += 1,生成的字节码是一样的。
再看++i和i++作为表达式使用的情况。两者的区别只有指令顺序:i++是先把旧值 load 到操作数栈,再对局部变量加一,最后返回栈顶那个旧值;++i是先加一,再 load 新值返回。这个顺序差异导致它们在表达式语境下语义不同,但在“最终变量值”这个维度上完全一致。我见过有人为了“性能”把i++改成++i,这在前置自增不涉及临时对象的前提下是没有意义的,因为两者生成的指令条数一样,只是排列顺序调换了一下,JIT 之后基本无法区分。
真正拉开差距的是自增对象的位置。成员变量自增需要经过aload_0拿到this、getfield读字段、加一、putfield写回,这是一次实实在在的堆内存读写;静态变量自增走getstatic/putstatic;数组元素自增要先算出下标、iaload取值、加一、iastore写回,还算一次数组边界检查。这几种形态下,读和写之间存在明显的时间窗口,这也是并发丢更新的物理基础。理解这一点之后再去看AtomicInteger和synchronized,你就不是在背结论,而是在给这个窗口加锁。
1.2 用 javap 把字节码摊在桌面上
要看清这些差异,最趁手的工具就是 JDK 自带的javap。我一般的做法是写一个把所有形态都覆盖到的小类,编译之后加-c -l -p三个参数反汇编,-c输出指令,-l输出行号和局部变量表,-p连私有成员一起显示。这套组合基本能覆盖日常九成以上的字节码阅读需求,不需要装任何插件。
public class IncDemo { private int field; private static int sField; public int postIncLocal(int i) { return i++; } public int preIncLocal(int i) { return ++i; } public void plainIncLocal() { int i = 0; i++; } public void incField() { field++; } public void incStatic() { sField++; } public void incArray(int[] a, int idx) { a[idx]++; } public void incBy127() { int i = 0; i += 127; } public void incBy200() { int i = 0; i += 200; } }编译命令很简单,javac IncDemo.java之后跑javap -c -l -p IncDemo.class。这里有一个容易被忽略的点:反汇编看的是 class 文件,也就是编译期产物,它不代表运行时真正执行的机器指令。JIT 编译之后这些字节码会被翻译成本地指令,iinc在 x86 上大概率变成一条add直接作用于栈上的变量槽。但这不影响我们用字节码理解语义,因为 JIT 必须保持字节码定义的语义不变,包括指令顺序带来的可见性差异。
顺带说一个实操技巧:如果你不想每次都开终端敲命令,IDEA 里装一个字节码查看插件,点开 class 文件就能对照源码看指令,行号映射也会标出来,调试i++相关问题的时候特别省事。但要注意插件版本和 JDK 版本对不上时,可能出现指令显示不全的情况,遇到怪现象还是回到javap最稳。
2. 栈帧:i++ 真正干活的地方
2.1 局部变量表与槽位分配
栈帧是 JVM 里方法执行的载体,每个方法调用都会在当前线程的虚拟机栈上压入一个新帧,方法返回时弹出。一个栈帧里最核心的两块空间是局部变量表和操作数栈,另外还有动态链接、方法返回地址和一些附加信息。i++的全部动作都发生在前两块空间之间,所以想搞懂它,就得先把这两块空间的规则说清楚。
局部变量表是一个以槽位(Slot)为单位的数组,索引从 0 开始。实例方法的第 0 个槽位固定留给this,静态方法没有这个约定,参数从 0 开始排。基本类型里int、float、reference、returnAddress各占一个槽位,long和double各占两个连续槽位。这个规则带来一个很实际的影响:long类型的自增不可能用iinc完成,因为iinc的操作对象是单个 int 槽位,long得走lload、lconst_1、ladd、lstore四步,这四步之间的竞态窗口比 int 还要宽。
槽位的数量在编译期就确定了,写在 class 文件的max_locals属性里。方法里定义了多少局部变量、参数有几个、有没有long/double占双槽,编译器都算得清清楚楚。这也是为什么局部变量表的大小不会在运行时变化——它是静态分配的。栈帧的总大小在方法进入时一次性分配完成,不会因为方法体里某个分支多用了几个变量就临时扩容。这个设计让栈帧的创建和销毁非常廉价,基本上就是移动一下栈指针。
这里还有一个常被拿来当面试题的点:局部变量表是 GC Roots 的一部分。方法执行期间,局部变量表里的引用类型槽位指向的对象不会被回收。我踩过一次坑,方法里声明了一个大对象引用,中间逻辑已经用完了,但变量作用域还没结束,结果那次 GC 就是回收不掉它。后来把变量作用域收窄或者手动置 null 才解决。这类问题和i++表面上没关系,但底层是同一块空间,理解局部变量表的生命周期,对排查内存问题很有帮助。
2.2 操作数栈的压栈与出栈
操作数栈是一个后进先出的栈结构,最大深度同样在编译期确定,写在max_stack属性里。绝大多数字节码指令都是围绕操作数栈工作的:iload把局部变量表里的值压到栈顶,istore把栈顶的值弹回局部变量表,iadd弹出栈顶两个 int 相加再把结果压回去。理解了这套“压栈—运算—弹栈”的模型,再看i++的字节码就是顺理成章的事。
以return i++;为例,指令序列是iload_1、iinc 1, 1、ireturn。第一步把参数i的值复制一份压到操作数栈顶,此时栈顶是旧值;第二步直接对局部变量表第 1 号槽位做加一,注意这一步完全不碰操作数栈,栈顶还是那个旧值;第三步ireturn把栈顶的旧值返回给调用方。所以调用方拿到的是自增前的值,而方法内部的i已经是自增后的值,只不过方法返回之后这个局部变量就随栈帧一起销毁了,调用方看不到。
++i的顺序正好相反:iinc 1, 1先把槽位加一,iload_1再把新值压栈,ireturn返回新值。两条路径的指令条数完全相同,唯一区别是iinc和iload的先后。这个差异在单线程里只影响返回值,在多线程里则会和后面的可见性问题纠缠在一起,形成更隐蔽的 bug。
再补充一个容易忽略的细节:iinc指令的操作数里,增量部分是一个带符号的 8 位整数,取值范围是 -128 到 127。这意味着i += 127可以用一条iinc搞定,但i += 200就超出了范围,Javac 会退化成iload、sipush 200、iadd、istore四条指令。我第一次发现这件事是在看一段热点代码的反汇编时,明明写的是自增,却看到了四条指令,一度以为编译器出了 bug。后来翻指令集手册才明白是增量范围限制。这个知识点的实用价值在于:如果你在极端性能敏感的循环里做的是大步长累加,指令条数会变多,虽然 JIT 之后影响很小,但理解这个退化路径能帮你解释一些反汇编里“多出来”的指令。
2.3 方法调用时栈帧的创建与销毁
栈帧的生命周期和i++的关系,体现在“值到底活在哪一层”这个问题上。假设你这样写:int a = obj.postInc(5);,方法调用发生时,JVM 会为postInc压入一个新栈帧,参数5复制到这个新帧的局部变量表第 1 号槽位(第 0 号是this),然后在新帧内部完成自增和返回。返回时栈帧弹出,局部变量表里的那个i也就跟着没了,只有返回值通过操作数栈传回调用方的栈帧。跨栈帧传递的只有值,不传递变量本身,这是 Java 值传递语义的物理体现。
这也解释了一个经典现象:把int传进方法里自增,调用方的变量不会变。很多人把这个当成“Java 是值传递”的论据,其实更底层的说法是——自增发生在新栈帧的局部变量表里,和调用方栈帧的槽位是两个完全独立的存储位置。如果传的是引用类型,复制过去的是引用值,两个栈帧的槽位指向同一个堆对象,这时候在方法里改对象的字段,调用方就能看到变化,但重新赋值引用本身依然不影响调用方。
方法返回地址这块和i++关系不大,但值得提一句:正常的返回地址指向调用方的下一条指令,异常返回则要靠异常表来定位处理位置。栈帧里还保存着指向当前方法所属类的运行时常量池引用,也就是动态链接,它让字节码里的符号引用能在运行时解析成直接引用。getfield、putfield、invokevirtual这些指令能工作,全靠这一层。
栈帧的销毁是隐式的,方法返回即弹出,不需要显式释放。但栈的容量是有限的,默认大小由-Xss控制,常见配置是 512KB 到 1MB。递归太深会抛StackOverflowError,这个异常和OutOfMemoryError是两回事,前者是单个线程的栈空间耗尽,后者是整个堆或者元空间不足。我在排查一次线上问题时见过有人把StackOverflowError当成内存泄漏去查堆 dump,方向完全错了。判断方法很简单:异常名里带 Stack 的就是栈溢出,调大-Xss或者改递归为迭代即可;带 OutOfMemory 的才需要去看堆和 GC 日志。
3. iinc 那条指令为什么救不了并发
3.1 三步指令之间的竞态窗口
现在回到最开始的问题:为什么i++在多线程下会丢更新。前面已经看到,独立的i++;语句只生成一条iinc,看起来是一条指令,但 JVM 规范只保证单条字节码指令的原子性,不保证多条指令组成的复合操作原子性。而在字段自增的场景里,指令序列是aload_0、dup、getfield、iconst_1、iadd、putfield六条,读和写被彻底分开了,中间隔着取值和加法两步。
假设两个线程同时对一个字段做自增,初始值是 0。线程 A 执行getfield读到 0,线程 B 也执行getfield读到 0;A 算出 1 并putfield写入,B 也算出 1 并putfield写入。两次自增,最终结果却是 1。丢掉的这一次不是因为“指令被打断”,而是因为两次读都发生在写之前,读到了同一个旧值。这个窗口在单核上靠时间片轮转也能出现,在多核上更是必然会撞上,只是概率和线程调度、缓存一致性协议有关。
即便是只有一条iinc的局部变量自增,也不能简单说它在并发下安全,因为局部变量本来就是线程私有的,压根不存在共享,讨论它的原子性没有意义。真正需要讨论原子性的,永远是共享状态:字段、静态字段、数组元素、堆上的对象属性。把“局部变量自增”和“共享变量自增”分开看,是理解这块的分水岭。很多资料笼统地说i++非原子,容易让人误以为连循环变量for (int i = 0; i < n; i++)都有线程安全问题,这就跑偏了。
更进一步,即使给字段自增加上synchronized,也要注意锁的对象是不是同一个。我见过一段代码,在方法上加了个实例方法的synchronized,结果每个线程都 new 了一个新对象去调用,锁形同虚设,计数照样丢。判断锁是否有效,只看一件事:多个线程竞争的是不是同一个锁对象,其他的都是形式。
3.2 volatile 挡不住 i++ 的原因
volatile是这块最容易被误用的关键字。它的语义有两个:保证可见性,保证有序性(禁止特定重排序),但不保证复合操作的原子性。字段自增的六条指令里,volatile只能保证每次getfield读到的是最新值、putfield写完立刻对其他线程可见,但“读—加—写”这个序列中间依然可以被插入别的线程的操作。
用具体的执行序列说明会更清楚。字段count用volatile修饰,初始 0。线程 A 读到 0,线程 B 读到 0,这里的“读到 0”在volatile语义下都是合法的最新值,因为确实还没有人写过。然后 A 写入 1,B 写入 1,最终结果 1。volatile从头到尾没有违反任何承诺,它只是不负责把这三步包成一个整体。要保证整体性,只有两条路:一是加锁,把读改写框在一个临界区里;二是用 CAS 这类硬件提供的原子指令,AtomicInteger的incrementAndGet走的就是这条路。
CAS 的机制值得展开一点。它有三个操作数:内存位置、期望旧值、新值。执行时先比较内存里的当前值和期望值是否相等,相等才写入新值,否则失败重试。AtomicInteger的自增就是循环执行“读取当前值、计算加一后的值、CAS 写回”,失败就重新读,直到成功。这里的开销在于高并发下 CAS 失败率上升,循环重试会空转 CPU,所以 JDK 8 加了LongAdder,把热点分散到多个 cell 上,最后再求和,牺牲了一点实时精确性换取吞吐。如果你的场景是高并发纯计数、读的时候可以容忍短暂不一致,LongAdder通常比AtomicInteger快得多。
提示:
volatile加在计数器字段上不但解决不了丢更新,还会因为禁止重排序和缓存同步带来额外开销。正确做法是换原子类或者缩小锁粒度,不要指望volatile兜底。
3.3 复现实操与压测观察
空谈不如跑一遍。下面这段代码可以直接拿来复现,思路是用固定数量的线程各做若干次自增,跑完对比期望值和实际值。我把线程数和次数做成可调的,方便你在自己机器上观察差异。
public class LostUpdate { private static int plainCount = 0; private static volatile int volatileCount = 0; private static final AtomicInteger atomicCount = new AtomicInteger(0); public static void main(String[] args) throws Exception { int threads = 8; int loops = 100_000; run(threads, loops, () -> plainCount++); run(threads, loops, () -> volatileCount++); run(threads, loops, () -> atomicCount.incrementAndGet()); System.out.println("plain = " + plainCount + " expect " + threads * loops); System.out.println("volatile = " + volatileCount + " expect " + threads * loops); System.out.println("atomic = " + atomicCount.get() + " expect " + threads * loops); } static void run(int threads, int loops, Runnable task) throws Exception { CountDownLatch start = new CountDownLatch(1); Thread[] ts = new Thread[threads]; for (int i = 0; i < threads; i++) { ts[i] = new Thread(() -> { try { start.await(); } catch (InterruptedException e) { return; } for (int j = 0; j < loops; j++) task.run(); }); ts[i].start(); } start.countDown(); for (Thread t : ts) t.join(); } }实测下来,plain和volatile两行的结果几乎每次都会小于期望值,而且波动很大,说明丢更新的数量受调度影响;atomic那行稳定等于期望值。这里有个细节要提醒:不要把三次统计放在同一轮里跑,因为第一次跑完之后 JIT 已经预热,后面的循环执行效率会高很多,竞态窗口反而变窄,丢更新数量可能反而变少,容易让人误判。想观察得更明显,就把每次统计单独起一个 JVM 进程,或者至少把循环次数调大。
还有一个更隐蔽的坑:如果loops太小、线程数太少,你可能一次都复现不出来,然后误以为代码没问题。我一般的经验是线程数不少于 CPU 核数、单线程循环不低于十万次,基本每次都能看到差异。如果还是复现不了,检查一下是不是被 JIT 把整个循环优化掉了——循环体里如果没有任何副作用能被观察到,编译器是有可能做激进优化的,加个System.out或者把结果写进静态字段能阻止这种情况。
4. 一次完整的对照实操:从源码到运行
4.1 四类自增对象的字节码对照
把前面零散提到的形态集中列一张表,是我自己在做技术分享时最常用的一张图。这张表把局部变量、实例字段、静态字段、数组元素四类自增对象的字节码形态放在一起对比,能一眼看出差异在哪。
| 自增对象 | 典型字节码序列 | 是否触碰操作数栈 | 并发风险 |
|---|---|---|---|
| 局部变量(语句形式) | iinc 1, 1 | 否 | 无(线程私有) |
| 局部变量(表达式返回旧值) | iload_1/iinc 1, 1/ireturn | 是 | 无(线程私有) |
| 局部变量(表达式返回新值) | iinc 1, 1/iload_1/ireturn | 是 | 无(线程私有) |
| 实例字段 | aload_0/dup/getfield/iconst_1/iadd/putfield | 是 | 高 |
| 静态字段 | getstatic/iconst_1/iadd/putstatic | 是 | 高 |
| 数组元素 | aload_1/iload_2/iaload/iconst_1/iadd/iastore | 是 | 高 |
long局部变量 | lload_1/lconst_1/ladd/lstore_1 | 是 | 无(线程私有) |
byte局部变量 | iload_1/iconst_1/iadd/i2b/istore_1 | 是 | 无(线程私有) |
最后两行是我加进去的,因为很多人不知道iinc只对 int 生效。long类型没有对应的自增指令,只能走加载、常量、加法、存回四步;byte和short虽然在局部变量表里占一个槽位,但 JVM 的计算模型是 int,所以自增完之后必须用i2b或者i2s截断回原来的类型,不截断的话Byte.MAX_VALUE自增就会变成 128,赋值回 byte 变量时才截断,中间值是 int。这两条规则在看反汇编时会频繁遇到,提前知道能省不少困惑。
4.2 逐行对照解读一次真实方法
挑一个稍微复杂点的例子走一遍完整流程,把前面所有概念串起来。假设有这样一个方法,参数是两个 int,方法体里先做前置自增赋值,再做后置自增赋值,最后求和返回。
public int mix(int a, int b) { int x = ++a; int y = b++; return x + y; }反汇编出来的指令大致是这样:iinc 1, 1先把参数a加一,iload_1把新的a压栈,istore_3弹出存到局部变量表第 3 号槽位(也就是x);接着iload_2把b的旧值压栈,iinc 2, 1给b加一,istore 4把栈顶的旧值存到y;最后iload_3、iload 4、iadd、ireturn完成求和返回。
对照这个序列,可以清楚看到槽位的分配逻辑:第 0 号是this,第 1 号是a,第 2 号是b,第 3 号是x,第 4 号是y。x和y用的都是单槽位,如果把它们改成long,那x会占 3 和 4,y会占 5 和 6,后面的索引全部顺延。这个细节在调试“局部变量表槽位和源码变量对不上”的问题时特别有用——用javap -l输出的 LineNumberTable 和 LocalVariableTable,可以精确还原每个槽位对应哪个变量、作用域从哪一行到哪一行。
还要注意istore 4用的是带索引的宽指令形式,而istore_3用的是短指令形式。JVM 为索引 0 到 3 准备了专用的短指令istore_0到istore_3,超过 3 就要用带操作数的通用形式,指令长度从 1 字节变成 2 字节。这个设计纯粹是为了压缩 class 文件体积,对执行效率的影响可以忽略,但在读反汇编时会看到指令名不一样,别以为是两种不同的操作。
4.3 从字节码到 JIT 的最后一公里
讲到这里必须补一句:字节码不是最终执行的东西。方法第一次被调用时走解释执行,调用次数超过阈值之后会被 JIT 编译成本地机器码,这时候iinc大概率已经被优化成一条寄存器或者栈内存的加法指令,getfield/putfield也可能被内联、被消除冗余读取。这也是为什么很多并发 bug 在压测初期复现不了、跑久了才冒出来——解释执行和编译执行的时间窗口不一样。
如果想看 JIT 之后的样子,可以用-XX:+PrintAssembly配合 hsdis 插件输出汇编,但这套环境搭起来比较麻烦,日常排查我更推荐用 JMH 做基准测试,看吞吐和延迟的变化趋势就够了。有一个经验值得记下来:在单线程热点循环里,i++的 JIT 优化效果非常明显,几乎和空操作一样廉价;但在多线程共享字段的场景下,JIT 不敢做激进优化,因为它必须遵守内存模型的约束,这时候指令的真实开销才会暴露出来。所以不要拿单线程的压测数据去推断并发场景的性能,这是两个世界。
另外提一下逃逸分析和标量替换。如果一个对象只在方法内部使用、没有逃逸出去,JIT 有可能把它拆散成若干局部变量直接放在栈上,连堆分配都省了。这和我们讨论的栈帧是同一个思路的另一面:能放在栈上的东西就不要放堆上,栈的分配和回收几乎是零成本的。我见过有人为了减少 GC 压力,把一堆临时对象改成了局部基本类型变量,原理就在这里。
5. 常见问题与排查清单
5.1 高频误判速查
这块我整理了一张表,都是实际排查中反复出现的误判,按出现频率排序。遇到自增相关的怪现象,先对着表过一遍,能省掉大量无效排查。
| 现象 | 常见误判 | 真实原因 |
|---|---|---|
| 计数偏小 | 以为是指令被打断 | 读改写三步之间存在窗口,两次读读到同一个旧值 |
加了volatile还是丢 | 以为volatile能保证原子性 | volatile只保证可见性和有序性 |
加了synchronized还是丢 | 以为锁没生效 | 多个线程锁的不是同一个对象 |
i = i++结果没变 | 以为是编译器 bug | 后置自增返回旧值,赋值把新值覆盖了 |
| 单线程压测没问题 | 以为并发也安全 | 单线程不存在竞态,压测必须多线程 |
byte自增溢出 | 以为会抛异常 | 中间结果是 int,赋值回 byte 时才截断 |
| 反汇编里指令条数对不上 | 以为是反编译工具出错 | iinc增量只支持 -128 到 127,超范围会退化 |
i = i++这条特别值得展开。很多人第一次看到结果是原值时都以为是编译器优化掉了自增,其实没有。执行顺序是:先把i的旧值压栈,再对局部变量加一,然后赋值操作把栈顶的旧值写回i,最后一次写入覆盖了自增的结果。整个过程完全符合规范,没有任何优化参与。这类代码在实际项目里当然不该出现,但它是一个极好的理解工具——只要你能画出自增过程中操作数栈和局部变量表每一时刻的状态,这类问题就再也不会看错。
5.2 排查手法与工具组合
真遇到计数不对的问题,我的排查顺序是这样的。第一步先确认自增对象是不是共享状态,如果是局部变量,直接排除并发问题,往逻辑错误方向找。第二步确认共享状态的保护手段是什么,没有保护就加保护,有保护就检查保护是否有效。第三步用最小复现用例验证,不要在完整的业务代码里改,抽出一个几十行的独立类跑一遍,能复现就说明找到了方向。
工具上,javap -c -l -p是最基础也最可靠的一环,任何时候都能用。想看运行时的线程状态和栈帧,jstack打出来的线程栈里能看到每个线程正在执行的方法,配合top -Hp找到占 CPU 高的线程 ID 再转十六进制去比对,能定位到具体是哪一行在自旋。堆里的对象状态用jmap或者堆 dump 分析工具看,不过自增丢更新这类问题一般不需要看堆,看线程栈和代码逻辑就够了。
一个我自己踩过的坑值得分享:有一次排查计数偏小,我先怀疑是并发问题,加了一堆锁,结果还是偏小。折腾了半天才发现是业务逻辑里有个分支提前return了,压根没走到自增那行。从那以后我养成了一个习惯——在怀疑并发之前,先确认这行代码是不是真的被执行了。加一行日志或者打个断点,成本远低于上一堆锁再一个个拆。
注意:加日志排查并发问题时,日志本身会改变执行时序,可能让原本能复现的 bug 消失。用
Thread.sleep或者计数器统计来复现,比用System.out可靠,因为后者的同步开销会意外地“修好”你的竞态。
5.3 指令层面的几个冷知识
最后补充几个我在读指令集文档时记下来的细节,不算高频,但遇到时能救命。iinc的索引和增量都是无符号或有符号的单字节,这意味着局部变量表索引超过 255 时,iinc就没法直接寻址了,Javac 会退化成iload/iadd/istore的组合。这种情况只在方法里定义了大量局部变量、恰好自增的那个又在很靠后的槽位时才会出现,平时几乎碰不到,但在自动生成代码的场景里是有可能的。
iinc不改变操作数栈深度,这一点前面提过,它带来的直接后果是:纯语句形式的局部变量自增,对操作数栈零占用。所以你在一个方法里写一百行i++;,max_stack也不会因此增加。反过来说,如果看到某个方法的max_stack特别大,那一半以上是因为有大量的方法调用参数需要压栈,而不是因为自增。
还有一点关于dup指令。实例字段自增的序列里有一个dup,它的作用是把栈顶的引用复制一份,让getfield消费一份之后还能留一份给后面的putfield用。如果没有这个dup,getfield弹掉引用之后就没法写回了。理解这个dup的存在,你就明白为什么实例字段自增是六条指令,而静态字段自增只要四条——静态字段不需要对象引用,直接getstatic/putstatic配对就行了。
6. 收个尾:几个我自己的使用习惯
写到这里,从i++到栈帧这条线基本走完了。最后分享几个我在实际开发和代码评审里固定下来的习惯,都是从踩坑里长出来的,不是什么规范要求,你觉得合适就拿去用。
第一个习惯是看到共享计数器就条件反射地找原子类。不管是统计调用次数、累计金额、还是记录失败数量,只要这个变量会被多个线程写,我第一反应就是AtomicLong或者LongAdder,而不是先写个int再想想要不要加锁。这个习惯帮我避掉了不少后期才暴露的问题,代价是多一点内存和一次对象创建,完全不亏。
第二个习惯是对volatile保持警惕。每次看到有人给计数器加volatile,我都会多问一句“这个操作是不是复合操作”。如果是,那就说明这个volatile是白加的。这个关键字在状态标志位、一次性初始化这类场景下非常好用,但一旦涉及读改写,就必须换方案。
第三个习惯是遇到解释不了的行为,先javap再看源码。源码是我们想表达的意思,字节码是编译器实际生成的东西,两者之间隔着泛型擦除、语法糖、自动装箱、循环优化等一堆处理。自增这种看起来最简单的语法,都有语句形式和表达式形式的差别,其他语法糖就更不用说了。
最后一个,关于性能。i++单看开销极小,真正贵的是它背后的内存访问和同步。所以在做性能优化时,把精力放在减少共享状态、缩小临界区、选对并发容器上,收益远比纠结用i++还是++i大得多。我在实际项目里做过的几次优化,效果最明显的都是把一个大锁拆成几个细粒度锁,或者把共享计数换成LongAdder,从来没靠改自增写法拿到过什么提升。这个判断标准,希望能帮你把注意力放在真正重要的地方。