zoukankan      html  css  js  c++  java
  • 深入理解java虚拟机(十四)正确利用 JVM 的方法内联

    在IntelliJ IDEA里面Ctrl+Alt+M用来拆分方法。选中一段代码,敲下这个组合,非常简单。Eclipse也用类似的快捷键,使用 Alt+Shift+M。我讨厌长的方法,提起这个下面这个方法我就觉得太长了:

    [java] view plain copy
     
    1. public void processOnEndOfDay(Contract c) {  
    2.         if (DateUtils.addDays(c.getCreated(), 7).before(new Date())) {  
    3.             priorityHandling(c, OUTDATED_FEE);  
    4.             notifyOutdated(c);  
    5.             log.info("Outdated: {}", c);  
    6.         } else {  
    7.             if (sendNotifications) {  
    8.                 notifyPending(c);  
    9.             }  
    10.             log.debug("Pending {}", c);  
    11.         }  
    12.     }  

    首先,它有个条件判断可读性很差。先不管它怎么实现的,它做什么的才最关键。我们先把它拆分出来:

    [java] view plain copy
     
    1. public void processOnEndOfDay(Contract c) {  
    2.         if (isOutDate(c)) {  
    3.             priorityHandling(c, OUTDATED_FEE);  
    4.             notifyOutdated(c);  
    5.             log.info("Outdated: {}", c);  
    6.         } else {  
    7.             if (sendNotifications) {  
    8.                 notifyPending(c);  
    9.             }  
    10.             log.debug("Pending {}", c);  
    11.         }  
    12.     }  
    13.   
    14.     private boolean isOutDate(Contract c) {  
    15.         return DateUtils.addDays(c.getCreated(), 7).before(new Date());  
    16.     }  


    很明显,这个方法不应该放到这里:

    [java] view plain copy
     
    1. public void processOnEndOfDay(Contract c) {  
    2.         if (c.isOutDate()) {  
    3.             priorityHandling(c, OUTDATED_FEE);  
    4.             notifyOutdated(c);  
    5.             log.info("Outdated: {}", c);  
    6.         } else {  
    7.             if (sendNotifications) {  
    8.                 notifyPending(c);  
    9.             }  
    10.             log.debug("Pending {}", c);  
    11.         }  
    12.     }  


    注意到什么不同吗?我的IDE把isOutdated方法改成Contract的实例方法了,这才像样嘛。不过我还是不爽。这个方法做的事太杂了。一个分支在处理业务相关的逻辑priorityHandling,以及发送系统通知和记录日志。另一个分支在则根据判断条件做系统通知,同时记录日志。我们先把处理过期合同拆分成一个独立的方法.

    [java] view plain copy
     
    1. public void processOnEndOfDay(Contract c) {  
    2.         if (c.isOutDate()) {  
    3.             handleOutdated(c);  
    4.         } else {  
    5.             if (sendNotifications) {  
    6.                 notifyPending(c);  
    7.             }  
    8.             log.debug("Pending {}", c);  
    9.         }  
    10.     }  
    11.   
    12.     private void handleOutdated(Contract c) {  
    13.         priorityHandling(c, OUTDATED_FEE);  
    14.         notifyOutdated(c);  
    15.         log.info("Outdated: {}", c);  
    16.     }  


    有人会觉得这样已经够好了,不过我觉得两个分支并不对称令人扎眼。handleOutdated方法层级更高些,而else分支更偏细节。软件应该清晰易读,因此不要把不同层级间的代码混到一起。这样我会更满意:

    [java] view plain copy
     
    1. public void processOnEndOfDay(Contract c) {  
    2.         if (c.isOutDate()) {  
    3.             handleOutdated(c);  
    4.         } else {  
    5.             stillPending(c);  
    6.         }  
    7.     }  
    8.   
    9.     private void stillPending(Contract c) {  
    10.         if (sendNotifications) {  
    11.             notifyPending(c);  
    12.         }  
    13.         log.debug("Pending {}", c);  
    14.     }  
    15.   
    16.     private void handleOutdated(Contract c) {  
    17.         priorityHandling(c, OUTDATED_FEE);  
    18.         notifyOutdated(c);  
    19.         log.info("Outdated: {}", c);  
    20.     }  


    这个例子看起来有点装,不过其实我想证明的是另一个事情。虽然现在不太常见了,不过还是有些开发人员不敢拆分方法,担心这样的话影响运行效率。他们不知道JVM其实是个非常棒的软件(它其实甩Java语言好几条街),它内建有许多非常令人惊讶的运行时优化。首先短方法更利于JVM推断。流程更明显,作用域更短,副作用也更明显。如果是长方法JVM可能直接就跪了。第二个原因则更重要:

    方法内联

    如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身。比如说下面这个:

    [java] view plain copy
     
    1. private int add4(int x1, int x2, int x3, int x4) {  
    2.         return add2(x1, x2) + add2(x3, x4);  
    3.     }  
    4.   
    5.     private int add2(int x1, int x2) {  
    6.         return x1 + x2;  
    7.     }  


    可以肯定的是运行一段时间后JVM会把add2方法去掉,并把你的代码翻译成:

    [java] view plain copy
     
    1. private int add4(int x1, int x2, int x3, int x4) {  
    2.         return x1 + x2 + x3 + x4;  
    3.     }  


    注意这说的是JVM,而不是编译器。javac在生成字节码的时候是比较保守的,这些工作都扔给JVM来做。事实证明这样的设计决策是非常明智的:

    JVM更清楚运行的目标环境 ,CPU,内存,体系结构,它可以更积极的进行优化。 JVM可以发现你代码运行时的特征,比如,哪个方法被频繁的执行,哪个虚方法只有一个实现,等等。 旧编译器编译的.class在新版本的JVM上可以获取更快的运行速度。更新JVM和重新编译源代码,你肯定更倾向于后者。

    我们对这些假设做下测试。我写了一个小程序,它有着分治原则的最糟实现的称号。add128方法需要128个参数并且调用了两次add64方法——前后两半各一次。add64也类似,不过它是调用了两次add32。你猜的没错,最后会由add2方法来结束这一切,它是干苦力活的。有些数字我给省略了,免得亮瞎了你的眼睛:

    [java] view plain copy
     
    1. public class ConcreteAdder {  
    2.    
    3.   public int add128(int x1, int x2, int x3, int x4, ... more ..., int x127, int x128) {  
    4.     return add64(x1, x2, x3, x4, ... more ..., x63, x64) +  
    5.         add64(x65, x66, x67, x68, ... more ..., x127, x128);  
    6.   }  
    7.    
    8.   private int add64(int x1, int x2, int x3, int x4, ... more ..., int x63, int x64) {  
    9.     return add32(x1, x2, x3, x4, ... more ..., x31, x32) +  
    10.         add32(x33, x34, x35, x36, ... more ..., x63, x64);  
    11.   }  
    12.    
    13.   private int add32(int x1, int x2, int x3, int x4, ... more ..., int x31, int x32) {  
    14.     return add16(x1, x2, x3, x4, ... more ..., x15, x16) +  
    15.         add16(x17, x18, x19, x20, ... more ..., x31, x32);  
    16.   }  
    17.    
    18.   private int add16(int x1, int x2, int x3, int x4, ... more ..., int x15, int x16) {  
    19.     return add8(x1, x2, x3, x4, x5, x6, x7, x8) + add8(x9, x10, x11, x12, x13, x14, x15, x16);  
    20.   }  
    21.    
    22.   private int add8(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8) {  
    23.     return add4(x1, x2, x3, x4) + add4(x5, x6, x7, x8);  
    24.   }  
    25.    
    26.   private int add4(int x1, int x2, int x3, int x4) {  
    27.     return add2(x1, x2) + add2(x3, x4);  
    28.   }  
    29.    
    30.   private int add2(int x1, int x2) {  
    31.     return x1 + x2;  
    32.   }  
    33.   
    34. }   


    不难发现,调用add128方法最后一共产生了127个方法调用。太多了。作为参考,下面这有个简单直接的实现版本:

    [java] view plain copy
     
    1. public class InlineAdder {  
    2.    
    3.     public int add128n(int x1, int x2, int x3, int x4, ... more ..., int x127, int x128) {  
    4.         return x1 + x2 + x3 + x4 + ... more ... + x127 + x128;  
    5.     }   
    6. }  


    最后再来一个使用了抽象类和继承的实现版本。127个虚方法调用开销是非常大的。这些方法需要动态分发,因此要求更高,所以无法进行内联。

    [java] view plain copy
     
    1. public abstract class Adder {  
    2.    
    3.   public abstract int add128(int x1, int x2, int x3, int x4, ... more ..., int x127, int x128);  
    4.    
    5.   public abstract int add64(int x1, int x2, int x3, int x4, ... more ..., int x63, int x64);  
    6.    
    7.   public abstract int add32(int x1, int x2, int x3, int x4, ... more ..., int x31, int x32);  
    8.    
    9.   public abstract int add16(int x1, int x2, int x3, int x4, ... more ..., int x15, int x16);  
    10.    
    11.   public abstract int add8(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8);  
    12.    
    13.   public abstract int add4(int x1, int x2, int x3, int x4);  
    14.    
    15.   public abstract int add2(int x1, int x2);  
    16. }   


    还有一个实现:

    [java] view plain copy
     
    1. public class VirtualAdder extends Adder {  
    2.    
    3.   @Override  
    4.   public int add128(int x1, int x2, int x3, int x4, ... more ..., int x128) {  
    5.     return add64(x1, x2, x3, x4, ... more ..., x63, x64) +  
    6.         add64(x65, x66, x67, x68, ... more ..., x127, x128);  
    7.   }  
    8.    
    9.   @Override  
    10.   public int add64(int x1, int x2, int x3, int x4, ... more ..., int x63, int x64) {  
    11.     return add32(x1, x2, x3, x4, ... more ..., x31, x32) +  
    12.         add32(x33, x34, x35, x36, ... more ..., x63, x64);  
    13.   }  
    14.    
    15.   @Override  
    16.   public int add32(int x1, int x2, int x3, int x4, ... more ..., int x32) {  
    17.     return add16(x1, x2, x3, x4, ... more ..., x15, x16) +  
    18.         add16(x17, x18, x19, x20, ... more ..., x31, x32);  
    19.   }  
    20.    
    21.   @Override  
    22.   public int add16(int x1, int x2, int x3, int x4, ... more ..., int x16) {  
    23.     return add8(x1, x2, x3, x4, x5, x6, x7, x8) + add8(x9, x10, x11, x12, x13, x14, x15, x16);  
    24.   }  
    25.    
    26.   @Override  
    27.   public int add8(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8) {  
    28.     return add4(x1, x2, x3, x4) + add4(x5, x6, x7, x8);  
    29.   }  
    30.    
    31.   @Override  
    32.   public int add4(int x1, int x2, int x3, int x4) {  
    33.     return add2(x1, x2) + add2(x3, x4);  
    34.   }  
    35.    
    36.   @Override  
    37.   public int add2(int x1, int x2) {  
    38.     return x1 + x2;  
    39.   }  
    40. }   


    受到我的另一篇关于@Cacheable 负载的文章的一些热心读者的鼓舞,我写了个简单的基准测试来比较这两个过度分拆的ConcreteAdder和VirtualAdder的负载。结果出人意外,还有点让人摸不着头脑。我在两台机器上做了测试(红色和蓝色的),同样的程序不同的是第二台机器CPU核数更多而且是64位的:

    具体的环境信息:

    看起来慢的机器上JVM更倾向于进行方法内联。不仅是简单的私有方法调用的版本,虚方法的版本也一样。为什么会这样?因为JVM发现Adder只有一个子类,也就是说每个抽象方法都只有一个版本。如果你在运行时加载了另一个子类(或者更多),你会看到性能会直线下降,因为无能再进行内联了。先不管这个了,从测试中来看,

    这些方法的调用并不是开销很低,是根本就没有开销!

    方法调用(还有为了可读性而加的文档)只存在于你的源代码和编译后的字节码里,运行时它们完全被清除掉了(内联了)。

    我对第二个结果也不太理解。看起来性能高的机器B运行单个方法调用的时候要快点,另两个就要慢些。也许它倾向于延迟进行内联?结果是有些不同,不过差距也不是那么的大。就像 优化栈跟踪信息生成 那样——如果你为了优化代码性能,手动进行内联,把方法越搞越庞大,越弄越复杂,那你就真的错了。

    ps:64bit 机器之所以运行慢有可能是因为 JVM 内联的要求的方法长度较长。

  • 相关阅读:
    电子公文传输系统个人贡献
    第十三章学习笔记
    第十二章学习笔记
    第14章学习笔记
    团队作业(五):冲刺总结
    冲刺总结(day7)
    thread同步测试
    冲刺总结(day1)
    元宇宙、VR(Unity3D、Unreal4)、AR、WPF&H5外包团队—北京动点飞扬软件新办公室照片
    kanzi外包团队:长年承接kanzi项目应用开发 Kanzi 3.6 LTS已经更新至3.6.3版!
  • 原文地址:https://www.cnblogs.com/linghu-java/p/8590352.html
Copyright © 2011-2022 走看看