39-编译优化-循环展开与公共子表达式消除
引言
前两篇我们看了方法内联和逃逸分析——前者打开跨方法优化视野,后者消除不必要的对象分配。本篇聚焦JIT在表达式与控制流层面的经典优化:循环展开、公共子表达式消除、常量传播、死代码消除、空检查消除、范围检查消除,以及专门针对字符串拼接的OptimizeStringConcat优化。
这些优化大多源自传统编译原理,但HotSpot将它们与profiling结合,形成了"基于运行时反馈的自适应优化"——这是C2区别于静态编译器的独到之处。这些优化单个看都不复杂,但它们协同工作产生的效果令人惊叹:一段看似朴素的高层Java代码,最终编译出的机器码可能比手写C还精炼。理解这些优化的原理,能帮你写出"JIT友好"的代码,也能解释很多"反直觉"的性能现象。
循环展开(Loop Unrolling)
循环是性能关键路径最常见的结构。循环本身有开销:每次迭代要更新计数器、比较边界、跳转。循环展开通过把多次迭代合并为一次,减少循环控制开销,并为后续优化(如指令级并行)腾出空间。
循环展开的原理
// 原始循环for(inti=0;i<n;i++){sum+=a[i];}// 展开两次inti=0;intlimit=n-1;// 处理奇数情况for(;i<limit;i+=2){sum+=a[i];sum+=a[i+1];}for(;i<n;i++){// 处理剩余sum+=a[i];}展开后,循环次数减半,每次迭代做两份工作。控制开销减半,且两条a[i]/a[i+1]的加载可以并行(无数据依赖)。
HotSpot的循环展开策略
HotSpot的循环展开不是在源码层面展开,而是在机器码生成阶段。C2会分析循环的回边计数,对热点循环做:
- 完全展开:若循环次数在编译时已知且较小(如
for (int i = 0; i < 4; i++)),直接展开成顺序代码 - 部分展开:对次数未知的热点循环,展开2/4/8次迭代,剩余通过"前导/尾"循环处理
相关参数:
-XX:LoopMaxUnroll=16# 默认16,最大展开次数(每次迭代的工作量上限)循环展开的收益
- 减少循环控制开销:分支预测失败的代价降低
- 暴露指令级并行:展开后多个独立的加载/计算可被CPU流水线并行执行
- 配合其他优化:展开后常量传播、CSE等能跨迭代生效
循环展开的代价
- CodeCache膨胀:展开让代码体积增大
- 寄存器压力:展开后需要更多寄存器存放中间值,寄存器不足时会溢出到栈,反而变慢
C2会根据循环体大小和寄存器情况决定展开因子,不是无脑展开。
公共子表达式消除(CSE)
**公共子表达式消除(Common Subexpression Elimination,CSE)**是编译原理的经典优化:如果同一个表达式在多处出现且操作数不变,只计算一次,复用结果。
// 原始代码inta=x*y+z;intb=x*y-w;// CSE后intt=x*y;inta=t+z;intb=t-w;x * y只计算一次,结果存入临时变量复用。HotSpot在Sea-of-Nodes IR上做CSE——相同操作数的计算节点会被合并,天然消除冗余。Sea-of-Nodes的精髓在于:值相同的节点在图里就是同一个节点,CSE在这里几乎是"免费"的副作用。
CSE与内联的协同
CSE的真正威力在跨方法时显现,而这依赖内联:
staticintsquare(intx){returnx*x;}staticintcompute(intx){inta=square(x)+1;intb=square(x)-1;returna+b;}不内联square时,两次调用各自独立计算x*x。内联后:
staticintcompute(intx){inta=x*x+1;intb=x*x-1;// CSE识别出x*x是公共子表达式returna+b;}// 优化为staticintcompute(intx){intt=x*x;inta=t+1;intb=t-1;returna+b;// 进一步:a + b = (t+1) + (t-1) = 2t}// 最终staticintcompute(intx){return2*x*x;// 经过代数化简}这种"内联+CSE+代数化简"的组合拳,是JIT把多层抽象的Java代码优化到极致的核心机制。
常量传播与折叠
**常量传播(Constant Propagation)**把已知的常量值沿数据流向前传播,替代变量。**常量折叠(Constant Folding)**则在编译时直接计算常量表达式的结果。
intx=10;inty=x*2;// 常量传播:x=10 → y = 10 * 2// 常量折叠:y = 20二者结合能让很多"看似运行时计算"的表达式在编译期完成。
与profiling结合的"乐观常量"
HotSpot的独到之处在于:它能基于profiling数据,把**“绝大多数时候是常量”**的变量当作常量处理。例如:
staticintconfigValue=readConfig();// 运行时才知道staticintcompute(intx){returnx+configValue;}如果profiling显示configValue在被调用时总是100,C2可能生成"假设configValue=100"的快速路径:
// 优化后(伪代码)if(configValue==100){returnx+100;// 编译期常量}else{returnx+configValue;// 慢速路径}这种"乐观常量传播"是speculative optimization的体现,一旦假设失败触发uncommon trap回退到解释执行,并废弃这份编译产物。
死代码消除(DCE)
**死代码消除(Dead Code Elimination,DCE)**移除对结果无影响的代码。最常见的来源是前面优化的副产物——CSE、常量折叠后会产生大量中间变量,其中很多不再被使用,DCE负责清理。
staticintcompute(intx){inta=x*0;// 常量折叠 → a = 0intb=a+x;// 常量传播 → b = 0 + x = xintc=b*1;// 代数化简 → c = b = xreturnc;// 最终只剩 return x}经过一系列优化,a和b的赋值都变成死代码,被DCE移除。最终生成的机器码可能只有一条mov指令。在Sea-of-Nodes IR上,DCE体现为"从结果节点反向遍历,不可达的节点直接丢弃"——干净利落。
死代码消除与异常处理
DCE还负责处理"不可达的异常路径"。如果profiling显示某段代码从未抛出异常,C2会把它从主路径上移除,放到uncommon trap里:
staticintdivide(inta,intb){try{returna/b;// 可能抛 ArithmeticException}catch(ArithmeticExceptione){return0;}}若profiling显示b从未为0,C2生成"假设不抛异常"的快速路径,try-catch退化为uncommon trap。异常处理的代码完全不在主路径上,性能等同于无try-catch。这是"Java的异常几乎零开销"的底层原因。
空检查消除(Null Check Elimination)
Java的隐式空检查无处不在——任何字段访问、方法调用前JVM都要检查引用是否为null,否则抛NullPointerException。这些检查如果每次都做,性能损耗累积可观。
乐观空检查消除
C2基于profiling做乐观消除。如果某个引用在历史调用中从未为null,C2生成"假设非null"的代码,把空检查移到uncommon trap:
staticintgetLen(Strings){returns.length();// 隐式空检查}优化后的机器码大致是:
if (s == null) → uncommon trap(抛NPE) return s.length; // 直接访问,无空检查注意空检查没有完全消失,而是被"折叠"到一个预测分支里。绝大多数情况走快速路径,null出现时才触发trap,代价是逆优化+重新解释执行。
链式空检查
staticintprocess(Personp){returnp.getAddress().getCity().length();}这里有三处隐式空检查(p、getAddress()返回值、getCity()返回值)。C2会合并这些检查,只在入口做一次"任一为null则trap"的检查,减少分支数量。配合内联后,整条调用链往往被摊平成一个连续的字段偏移访问。
范围检查消除(Range Check Elimination)
数组访问a[i]在Java语义里必须检查0 <= i < a.length,否则抛ArrayIndexOutOfBoundsException。循环中的数组访问尤其频繁,每次都做范围检查代价不小。
循环范围检查消除
staticintsum(int[]a){ints=0;for(inti=0;i<a.length;i++){s+=a[i];// 每次都要检查 0 <= i < a.length}returns;}C2分析循环结构后发现:i从0开始,每次+1,循环条件是i < a.length。因此i在循环体内必然满足0 <= i < a.length,范围检查可以消除。
优化后的逻辑
if (a == null) → uncommon trap(NPE) // 入口已知 a.length >= 0 int i = 0; while (i < a.length) { s += a[i]; // 无范围检查 i++; }范围检查被移到循环外,循环体内完全没有检查开销。这是Java数组操作性能能与C数组接近的关键。
不能消除的情况
并非所有范围检查都能消除。比如:
staticintget(int[]a,inti){returna[i];// i来自外部,无法静态确定范围}这种情况下C2只能保留范围检查。但如果调用点的i是循环变量且满足条件,通过内联后可能再次获得消除机会——又一次体现内联的重要性。HotSpot还有一种loop predication机制:在循环入口插一个一次性的范围断言,若断言成立则整个循环免检查,否则走慢速路径。
字符串拼接优化:OptimizeStringConcat
Java里字符串拼接极其常见,+语法糖会被javac编译成StringBuilder.append链。C2针对这种模式做了专门优化,由-XX:+OptimizeStringConcat控制(默认开启)。
优化的本质
Stringname="user-"+id+"-"+score;// javac编译后等价于Stringname=newStringBuilder().append("user-").append(id).append("-").append(score).toString();每次append都要做容量检查、数组拷贝、字符编码转换,开销不低。OptimizeStringConcat会在JIT编译时识别这种"纯拼接链"模式,做几件事:
- 一次性计算总长度:扫描所有
append参数,预先算出最终char[]/byte[]长度,避免多次扩容 - 合并拷贝:把多次
append的数组写入合并为一次连续拷贝 - 消除中间对象:直接构造最终
String,跳过StringBuilder的部分中间状态
一个直观对比
// 适用 JDK 17publicclassStringConcatDemo{staticStringbuild(intid,intscore){return"user-"+id+"-"+score;}publicstaticvoidmain(String[]args){for(inti=0;i<200_000;i++)build(i,i);longstart=System.nanoTime();for(inti=0;i<5_000_000;i++)build(i,i);System.out.printf("%d ms%n",(System.nanoTime()-start)/1_000_000);}}javaStringConcatDemo# 默认开启 OptimizeStringConcatjava-XX:-OptimizeStringConcatStringConcatDemo# 关闭后对比关闭后通常慢 20%~40%,因为每次拼接都走完整的StringBuilder扩容路径。
JDK 9的indy拼接
JDK 9起,javac不再直接生成StringBuilder链,而是用invokedynamic(StringConcatFactory)把拼接策略选择权交给运行时。HotSpot的OptimizeStringConcat仍然作用于底层识别到的拼接模式,配合invokedynamic的链接期优化,整体效果比JDK 8更好。生产环境永远保持默认开启,关闭它通常只会让性能变差,仅在排查"优化是否引入bug"时用作对照开关。
优化的协同:一个完整示例
下面这个示例展示多种优化如何叠加生效。
// 适用 JDK 11/17publicclassOptimizationDemo{staticfinalintSIZE=1024;staticintcompute(int[]a,int[]b){intsum=0;for(inti=0;i<SIZE;i++){intx=a[i]*b[i];// 乘法inty=a[i]*b[i];// 相同表达式 → CSEsum+=(x+y)/2;// 代数化简:等价于 sum += x}returnsum;}staticintcomputeOptimal(int[]a,int[]b){intsum=0;for(inti=0;i<SIZE;i++){sum+=a[i]*b[i];// 程序员手动优化}returnsum;}publicstaticvoidmain(String[]args){int[]a=newint[SIZE];int[]b=newint[SIZE];for(inti=0;i<SIZE;i++){a[i]=i;b[i]=i+1;}// 预热for(inti=0;i<50_000;i++){compute(a,b);computeOptimal(a,b);}longstart=System.nanoTime();longr1=0;for(inti=0;i<10_000;i++)r1+=compute(a,b);longt1=System.nanoTime()-start;start=System.nanoTime();longr2=0;for(inti=0;i<10_000;i++)r2+=computeOptimal(a,b);longt2=System.nanoTime()-start;System.out.printf("compute: %d, %d ms%n",r1,t1/1_000_000);System.out.printf("computeOptimal: %d, %d ms%n",r2,t2/1_000_000);}}预期结果:两个方法性能几乎相同。因为compute经过CSE、代数化简、循环展开、范围检查消除后,生成的机器码与computeOptimal高度相似。这就是JIT的魔力——程序员不必过度手动优化,JIT会帮你做。
优化链路回放
对compute的优化大致经过:
- 常量传播:
SIZE是final,编译期常量,循环边界已知为1024 - CSE:
a[i] * b[i]识别为公共子表达式,只算一次 - 代数化简:
(x + x) / 2 = x,简化为sum += x - 循环展开:循环体小,展开若干次
- 范围检查消除:
i在[0, SIZE)内,消除数组访问检查 - 空检查消除:
a/b非null(基于profiling),消除空检查 - 死代码消除:中间变量
y被移除
最终生成的机器码与程序员手写的computeOptimal几乎一致,甚至因循环展开还更快一些。
实践要点
不要过度手动优化:很多"程序员以为的优化"JIT已经做了。比如把
a[i]*b[i]的结果存到临时变量复用——JIT的CSE做得更好。先写清晰的代码,profile发现瓶颈再优化。循环边界用常量利于优化:
final int SIZE = 1024比int size = 1024更利于JIT做循环展开和范围检查消除。即使非final,只要profiling显示值稳定,JIT也能做乐观优化,但final更确定。避免在循环内做不必要的工作:虽然CSE能消除重复计算,但前提是表达式"可见"。如果重复计算分散在不同方法且未被内联,JIT看不到。把热点路径的循环体写成"扁平"的结构,利于优化。
数组访问模式要"规矩":顺序访问
a[i]最利于范围检查消除和CPU缓存预取。跳跃访问a[idx[i]]会让优化变难,性能也差。别为了"避免空检查"手动加
if (x != null):JIT的空检查消除比手写更高效(合并到uncommon trap)。手动加判断反而增加分支,可能阻碍优化。try-catch在热点循环里要谨慎:JDK 8的C2对含try-catch的方法内联支持有限,可能阻断优化链。把try-catch提到循环外,或确保循环体本身不抛异常(让trap路径冷)。
-XX:+OptimizeStringConcat等开关谨慎使用:HotSpot有大量针对特定场景的优化开关,默认开启。关闭它们通常只会让性能变差,只在定位"优化是否引入bug"时用作对照。类似的还有-XX:+UseLoopPredicate(循环断言)、-XX:+RangeCheckElimination等,生产环境保持默认。用JMH测,不要用
System.nanoTime拍脑袋:上面的示例代码用nanoTime测只是演示,真实性能测试必须用JMH——它能正确处理预热、fork、黑洞消费,避免JIT把"结果未使用"的代码DCE掉。
小结
- 循环展开减少循环控制开销、暴露指令级并行,C2根据循环体大小决定展开因子,受
LoopMaxUnroll约束 - **公共子表达式消除(CSE)**在Sea-of-Nodes IR上合并相同计算,与内联协同后威力倍增
- 常量传播与折叠把编译期已知的值固化,HotSpot甚至基于profiling做"乐观常量传播"
- 死代码消除清理其他优化的副产物,让最终机器码极简,也负责把冷异常路径移出主路径
- 空检查消除和范围检查消除通过把检查移到
uncommon trap或循环外,让Java的高层语义零开销 - **
OptimizeStringConcat**专门优化字符串拼接链,一次性计算长度并合并拷贝,JDK 9后配合invokedynamic效果更佳 - 这些优化协同工作,最终把多层抽象的Java代码优化到接近手写C的水平——程序员应优先写清晰代码,让JIT做它擅长的事
下一篇我们将跳出运行时JIT,展望GraalVM与AOT编译,看Java如何在"启动速度"与"峰值性能"之外开辟第三条路。
更多内容:JVM调优实战