static i++ 底层原理:从字节码到并发安全的完整拆解
最近在整理 Java 面试题的时候有一个问题反复出现在我眼前static i 的底层到底发生了什么看起来就是一行再普通不过的 Java 代码静态变量自增一下而已。但真要把这个问题讲透你得把类加载、字节码指令、Java 内存模型、并发安全这几块全部串起来。很多工作两三年的同学能答出“static 变量属于类、多线程下不安全”但再往下追问一句“为什么不安全哪个环节出了问题”就卡住了。这篇文章我就用一段最简单的代码从字节码开始一层层往下拆。拆到最后你会发现一个 i 背后藏着的是 JVM 一整套关于内存、并发和性能的设计思路。适合准备面试的人也适合那些在线上看到计数器诡异变小、想知道到底发生了什么的同学。把这一个点吃透很多并发的八股文都不用死记硬背了。1. static先从类变量的一生看起1.1 说好的“属于类”到底存在哪里先看一行代码static int count 0;。很多新手觉得 static 变量就是“存在类里”但这个“类里”到底指 JVM 的哪个区域能说清楚的人不多。在 JVM 的规范视角里static 变量属于类变量逻辑上存放在方法区。在 JDK 8 之后方法区的落地实现改成了元空间Metaspace存的是类的元数据、方法信息、字段描述这些。但这里有个非常容易被忽略的细节HotSpot 的实现里static 字段的那些值其实挂在堆里的java.lang.Class对象上。别觉得绕。你写一个类JVM 加载它之后会在堆里创建一个 java.lang.Class 的实例static 变量的具体值就是作为这个 Class 对象的字段数据存在的。你在代码里通过ClassName.count访问它JVM 做的事情本质上是找到这个 Class 对象然后从中取出对应字段的值。这个理解有用吗太有用了。因为它决定了 static 变量不会被某个具体的实例对象回收它的生命周期跟 Class 对象绑定由启动类加载器加载的类基本永远不会被卸载。这也是为什么很多人用 static 集合缓存数据最后把内存撑爆的原因——你以为只是存个临时数据它实际上是被整个 JVM 级别的引用拽着根本释放不了。1.2 准备阶段赋零值初始化阶段才赋真值static 变量赋值的过程绝不是一行代码一秒钟那么简单。类加载过程中有个链接阶段链接阶段里又分验证、准备、解析。其中准备阶段会为静态变量分配内存并设置默认值。这个默认值非常关键int 是 0boolean 是 false引用类型是 null。也就是说你写static int count 100;在准备阶段 count 的值先被设为 0然后等到初始化阶段才会执行clinit方法把 count 真正赋值为 100。我当初面试就栽在这上面。面试官问一个 static int 从声明到使用过程中会被赋值几次我回答说一次。实际上规范的答案是有两次一次在准备阶段被赋予零值一次在初始化阶段被赋予你写的初始值。只不过很多时候初始值本身就是 0所以感觉像是只有一次。如果你写的是static int count 100;字节码里就能清楚看到 putstatic 写入 100 的动作。还有一类特殊情况要留意static final int MAX 100;这种编译期常量它不走上面的流程。javac 在编译期就会把 100 直接内联到使用它的代码里常量池里直接存着字面量运行时根本不需要你动态初始化。这也是为什么你改了一个 static final 常量重新编译依赖方却不生效的原因——内联早就在编译期完成了。1.3 static 变量天然是全局共享的视图static 变量最大的特点就是共享。不管你这个类 new 了多少对象它们访问的都是同一份内存里的同一个变量。实例变量是每个对象各存一份static 变量是整个 JVM 里就那一份当然还得分 ClassLoader 隔离这个我后面说。共享带来便利也带来麻烦。从内存模型的角度看static 变量存储在共享的主内存区域但线程执行代码时会把变量拷贝到自己的工作内存对应到 CPU 的寄存器或缓存里再操作。这个“拷贝到工作内存”的动作是静态变量在多线程环境下出各种诡异问题的根源。后面要讲的并发问题全都从“共享”这两个字出发。2. i一行代码背后的字节码三重奏2.1 你以为是 inc其实是 get、add、put 三步很多人印象里 i 在底层是一条自增指令。不好意思那是针对局部变量的。对于 static 变量j 对应的字节码要复杂得多。我们写一个最简单的类然后把它反编译看看。public class StaticInc { static int count 0; public static void main(String[] args) { count; } }编译之后用javap -c StaticInc.class查看 main 方法的字节码你会看到这样的输出public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field count:I 3: iconst_1 4: iadd 5: putstatic #2 // Field count:I 8: return这段字节码透露的信息量很大。count这个看似原子性的操作实际上被拆成了四步getstatic从类的静态字段中取出 count 当前的值压入操作数栈。iconst_1把常量 1 压入操作数栈。iadd把栈顶两个值弹出相加再把结果压回栈。putstatic把栈顶的值写回静态字段 count。所以你平时在 Java 代码里写的一行 i放到指令层面是“读一次、加一次、写一次”的组合。指令和指令之间完全可能被线程切换打断这行代码不是原子的这就是后面所有并发问题的总根源。2.2 为什么局部变量的 i 能一条指令搞定你可能要问了我在方法里写一个局部变量int i 0; i;反编译看到的为什么是iinc指令Code: 0: iconst_0 1: istore_1 2: iinc 1, 1 5: return区别就在变量的存储位置。局部变量存放在局部变量表里每个线程执行方法时都有自己的局部变量表副本天然是线程私有的所以 JVM 可以直接用一条iinc指令在局部变量表里完成“取、加、存”的操作一步到位。而 static 变量是类的字段不在当前栈帧的局部变量表里。它可能在堆上的某个位置要经过getstatic和putstatic一次次地读入读出。这种设计不是 JVM 偷懒而是指令集架构的必然选择iinc操作的索引就是局部变量表的槽位编号天然没有操作类字段的能力。理解了这个区别你再看“static i 为什么慢、为什么不安全”逻辑就顺多了。2.3 手推一次交错执行你就明白丢的计数去哪了既然 i 是读-加-写三步那多线程下就可能发生这样一种交错。假设 count 当前值是 10线程 A 执行了getstatic读到了 10还没来得及写回线程 B 也执行了getstatic同样读到 10。之后 A 做加法得 11 并写回B 也做加法得 11 并写回。结果是两个线程各执行了一次自增count 却只从 10 变成了 11。丢失的那一次更新就这么无声无息地消失了。这种问题叫丢失更新是读-改-写模式下最典型的竞态条件。我强烈建议你手动拿笔在两个线程之间画一画执行顺序把每个线程的 getstatic、iadd、putstatic 都列出来组合几次你就会发现最终结果永远小于“正确次数”而且你很难精确预言到底丢了多少次。3. 多线程并发static i 为什么怎么跑都不对3.1 跑个实验三个线程各加一万次理论说了一堆不如自己跑一遍。下面这段代码在面试里也经常被拿来当开场题。public class StaticIncDemo { static int count 0; public static void main(String[] args) throws InterruptedException { Thread[] threads new Thread[3]; for (int t 0; t threads.length; t) { threads[t] new Thread(() - { for (int i 0; i 10000; i) { count; } }); threads[t].start(); } for (Thread thread : threads) { thread.join(); } System.out.println(count count); } }逻辑很简单三个线程每个执行一万次 count最终结果按预期应该是 30000。实际上呢我本地跑了几次结果在 15000 到 28000 之间浮动很少能正好等于 30000。你把循环次数放大到一百万次结果偏差依旧存在而且会明显小于 300 万。这就是并发环境下的 i 的真实表现逻辑跑完了结果不对而且每次运行结果还不一样完全没法复现。这种问题放到线上就是那种“日志看着一切正常但统计数字就是诡异地偏小”的灵异事件。3.2 可见性读到的可能是缓存里的过期数据先搞清楚第一个问题为什么线程会读到“旧值”现代 CPU 有三级缓存架构每个核心有自己的 L1、L2 缓存。JVM 在 Java 内存模型里抽象出了主内存和工作内存的概念主内存是所有线程共享的工作内存是每个线程私有的可以粗略理解成 CPU 寄存器加缓存。线程执行 count 时不会直接去主内存操作而是先把 count 从主内存拷贝到自己的工作内存操作完成后再写回主内存。问题在于这个“写回”的时机是不确定的。线程 A 写回了主内存线程 B 的工作内存里可能还保留着旧值下次它做 getstatic 时优先读到自己工作内存里的备份自然就看不到 A 的修改。这个东西在概念上叫可见性问题。它导致一个线程的修改可能长时间不被另一个线程感知。对于 static 变量这种多线程共享的数据可见性问题尤其致命。3.3 原子性读-改-写三步并非不可分割可见性解释了一部分但还有一个更核心的问题原子性。即使我们假设所有线程都能看见最新值i 的三条指令中间仍然存在时间窗口。这个窗口有多小以纳秒为单位。但并发编程里的问题从来不取决于单个窗口的大小而取决于并发竞争的程度。你有十个线程高速循环地做 i窗口再小也架不住碰撞次数多。每一次碰撞就意味着一次丢失更新。所以结论很干脆static i 在多线程环境下既不保证原子性也不保证可见性结果就是不可预测。想要让它变得可靠必须引入并发控制机制。4. 修好 static i三种方案的硬核对比4.1 volatile能管可见性但救不了原子性很多同学第一反应是加 volatile。我们确实经常听到 volatile 能保证多线程下的变量可见性但请注意一个限定词它能保证可见性和有序性但不能保证原子性。给 count 加上 volatile 之后count 的字节码依然是 getstatic、iconst_1、iadd、putstatic 四步只是 putstatic 这一步在底层会额外插入内存屏障强制把修改写回主内存并让其他核心的缓存行失效。也就是说线程 B 下次去读 count 时确实能拿到最新值了。可问题在于读-加-写这三个步骤之间依然可能被打断。线程 A 读到 10还没写回线程 B 也读到 10两边都拿 10 加 1最终写回的结果还是 11。volatile 在这里完全无能为力因为它只管“读到的是不是最新”管不了“两个线程同时基于同一个旧值做加法”。所以 volatile 适合做标志位不适合做计数器。比如用 volatile boolean 控制线程的启停是它的经典正确用法拿它修饰一个变量然后做 i就是典型的用错场景。你可以跑一下加 volatile 的执行结果通常比不加的情况好一些但依然到不了 30000。4.2 AtomicIntegerCAS 在硬件层面强行保证原子性真正合适的方案是用原子类。以AtomicInteger为例static AtomicInteger count new AtomicInteger(0); // 线程内 count.incrementAndGet();运行结果稳定输出 30000一次不多一次不少。它靠的是什么CAS全称 Compare And Swap比较并交换。incrementAndGet 的逻辑简化一下是这样的伪代码public final int incrementAndGet() { int current get(); int next current 1; while (!compareAndSet(current, next)) { current get(); next current 1; } return next; }每次先读取当前值计算加一后的值然后调用 compareAndSet。这个方法会执行一条 CPU 级别的原子指令把内存里的值和预期的 current 比较如果相等就替换成 next返回 true如果不相等说明在这期间有其他线程改过这个值那就重新读取、重新计算、重试。这就是乐观锁的思路不阻塞别的线程靠冲突检测和重试来保证正确性。在高并发下CAS 的重试次数会增多但整体吞吐量依然远高于加锁。从 JDK 8 开始如果你的目标是“统计总数”我更推荐用 LongAdder它在 CAS 基础上做了分段累加争用激烈时表现比 AtomicLong 更稳。4.3 synchronized加锁老人简单但锁竞争是成本有同学会说我用 synchronized 把自增操作包起来不就行了能行但要看场景。synchronized static void inc() { count; }synchronized 是悲观锁思路进入方法时加锁执行完释放锁。底层对应到字节码就是 monitorenter 和 monitorexit锁的对象是当前类的 Class 实例。它既保证原子性也保证可见性逻辑绝对正确。但代价是你把整个线程都串行化了。原本三个线程可以并行跑现在每个线程执行自增都得等锁锁如果没有竞争到线程还会被阻塞挂起。在 JDK 6 之后 HotSpot 对 synchronized 做了大量优化有了偏向锁、轻量级锁、重量级锁的升级过程大部分场景下性能已经不是灾难。但如果对同一个 static 变量做高频自增锁竞争依然会拖累吞吐量。三个方案放到一起比较差别很清晰方案原子性可见性性能表现适用场景volatile static int不保证保证无锁开销吞吐量高但结果错误状态标志、开关控制AtomicInteger/LongAdder保证保证CAS 无锁高争用时代价可控计数器、累加统计synchronized static 方法保证保证锁竞争严重时吞吐量下降复杂的复合操作、临界区逻辑我平时做选择的方法是如果只是计数无脑选原子类如果是多个变量一起变更并且要保证一致性老老实实加锁。5. 面试追问与避坑清单5.1 面试官顺藤摸瓜的五个进阶问题这道题在面试里几乎必然会被追问我整理了几个高频延伸。static 变量到底存在 JVM 哪个区域规范上属于方法区JDK 8 以后落地为元空间但在 HotSpot 实现里static 字段的值实际挂在堆里的 Class 对象反射数据上。答到这一层说明你不是死背概念。new 一个对象static 变量会被重新初始化吗不会。static 变量的初始化发生在类加载的初始化阶段由clinit方法执行一个类生命周期内只执行一次。new 对象触发的是实例初始化init方法跟静态变量没关系。如果类还没被加载new 会顺带触发类加载这是另一回事。不同类加载器加载同一个类static 变量是共享的吗不共享。每个类加载器有自己的命名空间同一个二进制名字的类会被加载成两个不同的 Class 对象static 变量也就是两份。典型例子是 Tomcat 的 Web 应用隔离一个应用的 static 变量改得再欢也不会影响另一个应用。为什么双重检查锁的单例要用 volatile这里涉及一个现象叫指令重排。new Singleton() 底层是三步分配内存、调用构造函数、把引用地址赋给变量。这三步可能被 JIT 重排为分配内存、赋引用、调用构造函数。如果另一个线程在读这个变量时恰好看到引用非空但构造函数还没执行完毕就会拿到一个半初始化的对象。volatile 禁止了这种重排保证赋值操作发生在构造完成之后。i 和 i 在 static 变量上有什么差别在静态变量场景下两者的字节码结构几乎一样都是 getstatic、iconst_1、iadd、putstatic区别只在于返回值返回的是旧值还是新值。理解了这点面试里再扯前缀后缀的细节就不慌了。5.2 实战避坑别在生产环境里用 static i我在一线排查过不少跟静态变量有关的线上问题总结几个很实际的坑。第一个坑用 static 集合做缓存越积越多最终导致内存溢出。你看着只是个 static List但在框架里可能被各种引用链拽着永远无法回收。遇到这种代码优先换成带过期策略的缓存别自己裸用集合。第二个坑测试环境跑并发自增没出事就以为代码没问题。并发问题跟线程数、CPU 核数、循环次数强相关。本地双核测试没问题不代表线上十六核压力下不出事。判断标准永远是代码本身有没有竞态条件而不是“我跑了几次没出错”。第三个坑用 static 变量存储和业务相关的共享状态却不加任何并发控制。比如用一个 static Date 记录最后处理时间两个线程同时更新结果回到旧时间整个业务逻辑直接乱套。凡是这种共享可变状态必须用原子类保护或者干脆换设计。第四个坑在静态初始化块里做重活或抛异常。static {}里如果抛出异常会被包装成 ExceptionInInitializerError类直接进入不可用状态后续所有对该类的访问都会抛 NoClassDefFoundError而且这个错误很难察觉是静态块引起的。尽量保持静态块轻量只做简单赋值。第五个坑依赖 static 变量在不同部署环境下的隔离性。微服务部署多实例时每个 JVM 进程的 static 变量是独立的不能用它做跨节点的计数或状态同步。这个不算 JVM 层面的坑但在分布式场景下非常常见。5.3 一条最快的排查路径真遇到了 static 变量值不合预期的情况我建议这样干活先用javap -c看一眼字节码确定操作是不是多步的如果是再加日志确认实际读写点然后用 jstack 抓线程栈看哪些线程在同时操作这个字段最后定位到竞态窗口选原子类或加锁解决。本地复现不了的时候可以用 Arthas 在线观察静态字段的变化或者用 jmap 导出堆后用 MAT 分析 Class 对象上挂着的字段值。这些工具表面上看是“黑科技”本质就是把 JVM 内部让你看不见的部分暴露出来。我自己带人的时候一直强调八股文背得再多不如把 static i 这一个点彻底推演一遍。它像一把钥匙能打开类加载、字节码、内存模型、并发控制、性能优化这一整条知识链。你在纸上把两个线程的字节码交错执行画一遍很多并发问题就不用死记硬背了。下次再看到代码里有人用 static i 做计数你脑子里应该浮现的不只是“这可能有问题”而是能说出问题到底出在 getstatic 和 putstatic 之间的哪一步。这就是底层知识带给你的判断力。