尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

【JVM原理详解】39-编译优化-循环展开与公共子表达式消除

【JVM原理详解】39-编译优化-循环展开与公共子表达式消除 39-编译优化-循环展开与公共子表达式消除引言前两篇我们看了方法内联和逃逸分析——前者打开跨方法优化视野后者消除不必要的对象分配。本篇聚焦JIT在表达式与控制流层面的经典优化循环展开、公共子表达式消除、常量传播、死代码消除、空检查消除、范围检查消除以及专门针对字符串拼接的OptimizeStringConcat优化。这些优化大多源自传统编译原理但HotSpot将它们与profiling结合形成了基于运行时反馈的自适应优化——这是C2区别于静态编译器的独到之处。这些优化单个看都不复杂但它们协同工作产生的效果令人惊叹一段看似朴素的高层Java代码最终编译出的机器码可能比手写C还精炼。理解这些优化的原理能帮你写出JIT友好的代码也能解释很多反直觉的性能现象。循环展开Loop Unrolling循环是性能关键路径最常见的结构。循环本身有开销每次迭代要更新计数器、比较边界、跳转。循环展开通过把多次迭代合并为一次减少循环控制开销并为后续优化如指令级并行腾出空间。循环展开的原理// 原始循环for(inti0;in;i){suma[i];}// 展开两次inti0;intlimitn-1;// 处理奇数情况for(;ilimit;i2){suma[i];suma[i1];}for(;in;i){// 处理剩余suma[i];}展开后循环次数减半每次迭代做两份工作。控制开销减半且两条a[i]/a[i1]的加载可以并行无数据依赖。HotSpot的循环展开策略HotSpot的循环展开不是在源码层面展开而是在机器码生成阶段。C2会分析循环的回边计数对热点循环做完全展开若循环次数在编译时已知且较小如for (int i 0; i 4; i)直接展开成顺序代码部分展开对次数未知的热点循环展开2/4/8次迭代剩余通过前导/尾循环处理相关参数-XX:LoopMaxUnroll16# 默认16最大展开次数每次迭代的工作量上限循环展开的收益减少循环控制开销分支预测失败的代价降低暴露指令级并行展开后多个独立的加载/计算可被CPU流水线并行执行配合其他优化展开后常量传播、CSE等能跨迭代生效循环展开的代价CodeCache膨胀展开让代码体积增大寄存器压力展开后需要更多寄存器存放中间值寄存器不足时会溢出到栈反而变慢C2会根据循环体大小和寄存器情况决定展开因子不是无脑展开。公共子表达式消除CSE**公共子表达式消除Common Subexpression EliminationCSE**是编译原理的经典优化如果同一个表达式在多处出现且操作数不变只计算一次复用结果。// 原始代码intax*yz;intbx*y-w;// CSE后inttx*y;intatz;intbt-w;x * y只计算一次结果存入临时变量复用。HotSpot在Sea-of-Nodes IR上做CSE——相同操作数的计算节点会被合并天然消除冗余。Sea-of-Nodes的精髓在于值相同的节点在图里就是同一个节点CSE在这里几乎是免费的副作用。CSE与内联的协同CSE的真正威力在跨方法时显现而这依赖内联staticintsquare(intx){returnx*x;}staticintcompute(intx){intasquare(x)1;intbsquare(x)-1;returnab;}不内联square时两次调用各自独立计算x*x。内联后staticintcompute(intx){intax*x1;intbx*x-1;// CSE识别出x*x是公共子表达式returnab;}// 优化为staticintcompute(intx){inttx*x;intat1;intbt-1;returnab;// 进一步a b (t1) (t-1) 2t}// 最终staticintcompute(intx){return2*x*x;// 经过代数化简}这种内联CSE代数化简的组合拳是JIT把多层抽象的Java代码优化到极致的核心机制。常量传播与折叠**常量传播Constant Propagation**把已知的常量值沿数据流向前传播替代变量。**常量折叠Constant Folding**则在编译时直接计算常量表达式的结果。intx10;intyx*2;// 常量传播x10 → y 10 * 2// 常量折叠y 20二者结合能让很多看似运行时计算的表达式在编译期完成。与profiling结合的乐观常量HotSpot的独到之处在于它能基于profiling数据把**“绝大多数时候是常量”**的变量当作常量处理。例如staticintconfigValuereadConfig();// 运行时才知道staticintcompute(intx){returnxconfigValue;}如果profiling显示configValue在被调用时总是100C2可能生成假设configValue100的快速路径// 优化后伪代码if(configValue100){returnx100;// 编译期常量}else{returnxconfigValue;// 慢速路径}这种乐观常量传播是speculative optimization的体现一旦假设失败触发uncommon trap回退到解释执行并废弃这份编译产物。死代码消除DCE**死代码消除Dead Code EliminationDCE**移除对结果无影响的代码。最常见的来源是前面优化的副产物——CSE、常量折叠后会产生大量中间变量其中很多不再被使用DCE负责清理。staticintcompute(intx){intax*0;// 常量折叠 → a 0intbax;// 常量传播 → b 0 x xintcb*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从未为0C2生成假设不抛异常的快速路径try-catch退化为uncommon trap。异常处理的代码完全不在主路径上性能等同于无try-catch。这是Java的异常几乎零开销的底层原因。空检查消除Null Check EliminationJava的隐式空检查无处不在——任何字段访问、方法调用前JVM都要检查引用是否为null否则抛NullPointerException。这些检查如果每次都做性能损耗累积可观。乐观空检查消除C2基于profiling做乐观消除。如果某个引用在历史调用中从未为nullC2生成假设非null的代码把空检查移到uncommon trapstaticintgetLen(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){ints0;for(inti0;ia.length;i){sa[i];// 每次都要检查 0 i a.length}returns;}C2分析循环结构后发现i从0开始每次1循环条件是i a.length。因此i在循环体内必然满足0 i a.length范围检查可以消除。优化后的逻辑if (a null) → uncommon trapNPE // 入口已知 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机制在循环入口插一个一次性的范围断言若断言成立则整个循环免检查否则走慢速路径。字符串拼接优化OptimizeStringConcatJava里字符串拼接极其常见语法糖会被javac编译成StringBuilder.append链。C2针对这种模式做了专门优化由-XX:OptimizeStringConcat控制默认开启。优化的本质Stringnameuser-id-score;// javac编译后等价于StringnamenewStringBuilder().append(user-).append(id).append(-).append(score).toString();每次append都要做容量检查、数组拷贝、字符编码转换开销不低。OptimizeStringConcat会在JIT编译时识别这种纯拼接链模式做几件事一次性计算总长度扫描所有append参数预先算出最终char[]/byte[]长度避免多次扩容合并拷贝把多次append的数组写入合并为一次连续拷贝消除中间对象直接构造最终String跳过StringBuilder的部分中间状态一个直观对比// 适用 JDK 17publicclassStringConcatDemo{staticStringbuild(intid,intscore){returnuser-id-score;}publicstaticvoidmain(String[]args){for(inti0;i200_000;i)build(i,i);longstartSystem.nanoTime();for(inti0;i5_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链而是用invokedynamicStringConcatFactory把拼接策略选择权交给运行时。HotSpot的OptimizeStringConcat仍然作用于底层识别到的拼接模式配合invokedynamic的链接期优化整体效果比JDK 8更好。生产环境永远保持默认开启关闭它通常只会让性能变差仅在排查优化是否引入bug时用作对照开关。优化的协同一个完整示例下面这个示例展示多种优化如何叠加生效。// 适用 JDK 11/17publicclassOptimizationDemo{staticfinalintSIZE1024;staticintcompute(int[]a,int[]b){intsum0;for(inti0;iSIZE;i){intxa[i]*b[i];// 乘法intya[i]*b[i];// 相同表达式 → CSEsum(xy)/2;// 代数化简等价于 sum x}returnsum;}staticintcomputeOptimal(int[]a,int[]b){intsum0;for(inti0;iSIZE;i){suma[i]*b[i];// 程序员手动优化}returnsum;}publicstaticvoidmain(String[]args){int[]anewint[SIZE];int[]bnewint[SIZE];for(inti0;iSIZE;i){a[i]i;b[i]i1;}// 预热for(inti0;i50_000;i){compute(a,b);computeOptimal(a,b);}longstartSystem.nanoTime();longr10;for(inti0;i10_000;i)r1compute(a,b);longt1System.nanoTime()-start;startSystem.nanoTime();longr20;for(inti0;i10_000;i)r2computeOptimal(a,b);longt2System.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编译期常量循环边界已知为1024CSEa[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调优实战
返回列表