我们先从一个小故事开始讲起。几个星期前,我在Java核心库的邮件列表中发起
一个修改的提议,希望能重写一些目前是final类型的方法。这个提案引发了好几个讨论的话题——其中一个是方法是不是final类型的,它的性能差距到底有多大。
关于取消final是否会导到性能变差我其实有一些自己想法,但我决定先抛开这些主观看法,想找找看有没有这个课题相关的一些基准测试的数据。很不幸的是我没找到。并不是说真的不存在或者没有人研究过这种情况,只能说我没有看到有公开的同行审查过的代码。看来,得自己写点测试了。
基准测试方法论
我决定使用
JMH这个靠谱的框架来将这些基准测试进行打包。如果你不相信会有框架能帮助你得到精确的基准测试的数据,你应该看下Aleksey Shipilev的
这个演讲,他是这个框架的作者,或者看下 Nitsan Wakart的这篇很赞的
博文,里面详细解释了具体的原因。
对我而言,我想知道的是什么因素会影响到方法调用的性能。我决定使用不同的方式来进行方法调用,并测量下它们各自的开销。我这有一组基准测试,每次只调整其中的一个因素,这样我们可以搞清楚不同的因素或者不同因素的组合到底会对方法调用的开销产生怎样的影响。
内联
最明显但同时又最不明显的因素当然就是到底有没有产生方法调用。编译器是有可能将整个方法调用的开销全都都优化掉的。一般来说,有两种减少调用开销的方法。一个是直接将方法本身内联,另外一种是使用内联缓存。别担心——这只是些很简单的概念而已,只不过里面用到的一些术语可能需要介绍下。我们先假设有一个叫Foo的类,它里面定义了一个bar方法。
class Foo {
void bar() { ... }
}
调用这个bar方法的话可以这么写:
Foo foo = new Foo();
foo.bar();
这里重要的在于bar方法实际产生调用的位置——foo.bar(),这个被称作调用点(callsite)。当我们说一个方法被“内联”了,它的意思是方法体被拿出来塞到了这个调用点这里,整个替换掉了这次方法调用。对于那些包含很多小的方法的程序来说(我敢说,这是个正确分解任务的程序),方法内联能显著地提升程序的运行速度。这是因为程序不会花太多的时候进行方法调用而不是在干实际的工作!我们可以通过@CompilerControl注解来控制是否要内联一个方法。后面我们会讲到什么是内联缓存。
类层次的深度及方法的重写
如果我们选择移除方法的final关键字,这意味我们可以对它进行重写了。这是另一个我们需要考虑的因素。因些我在一个类的不同的层级上调用它的方法,同时在不同的层级上对它们进行重写,这样我才能弄清楚类的深度和重写到底会产生多大的开销。
多态
在前面我提到调用点的时候,我故意漏掉了一个很重要的细节。由于可以在子类中重写非final方法,我们的调用点可能最终会调用到不同的方法。那么可能我传的是Foo对象或者是它的子类——Baz——它也实现了bar()方法。那编译器怎么知道该调用哪个方法呢?Java中的方法默认都是虚方法(可重写的),也就是说每次方法调用都得在一张表中查找合适的方法,这个表称为虚方法表。这个过程是相当慢的,因此好的编译器都会尝试去减少这个查找的开销。我们之前提到的一个方法是内联,如果你的编译器能够确定在一个指定的调用点只会调用到某个方法的话那就太棒了。这样的调用点被称作单态调用点。
不幸的是要证明一个调用点是单态的所花费的时间大多都是不切实际的。JIT编译器倾向于采用另一种方法,它会统计各个调用点实际调用的类型,如果前N个调用都是单态的,那它就会猜测这个调用点可能一直都是单态的。这个投机式的优化通常来说都是正确的,但由于它并不全是对的,因此编译器需要方法调用前插入一个守卫,以便检查这个方法的类型。
我们想要优化的可不止单态调用点一个而已。有很多调用点专业点的话叫做双态——它可能会调用到两个方法。你可以使用你的守卫代码来判断应该调用哪个方法,然后跳转过去。这比完整的方法调用可要廉价多了。这种情况也可以使用内联缓存来进行优化。内联缓存并不是实际将方法体内联到调用点,而是使用了一个专门的跳转表,它就像是一个完整的虚方法表查询的缓存。HotSpot的JIT编译器支持双态内联缓存,它将那些有三个以上可能的实现的调用点称为”兆态“(megamorphic)。
现在我们区分出了三种需要进行基准测试和分析的调用方式:单态,双态,及兆态。
测试结果
我们将测试结果进行分组收集,这样能更容易看清问题的本质。我把原始数据列了出来,同时还附带了一点分析。具体的数字或者开销意义其实并不是特别大。但有趣的是不同的方法调用间的比率以及相应的标准误差都非常的低。不同调用的区别非常明显——最快和最慢的实现差了6.26倍。现实中这种差距可能更大,因为这里我们测量的是一个空方法的开销。
这些基准测试的源代码在
GitHub上有。我没有把结果都放到起以免产生混淆。多态的基准测试是运行PolymorphicBenchmark的结果,而其它的是运行JavaFinalBenchmark的结果。
简单调用点
基准测试 |
模式 | 采样数 | 均值 | 标准误差 | 单位 |
c.i.j.JavaFinalBenchmark.finalInvoke | avgt | 25 | 2.606 | 0.007 | ns/op |
c.i.j.JavaFinalBenchmark.virtualInvoke | avgt | 25 | 2.598 | 0.008 | ns/op |
c.i.j.JavaFinalBenchmark.alwaysOverriddenMethod | avgt | 25 | 2.609 | 0.006 | ns/op |
我们的第一组数据比较的是虚方法,final方法,以及一个在很深的类层次中进行重写的方法间的调用开销。注意,我们这里强制让编译器不进行内联。我们可以看到,它们之间的差别非常小,标准误差也很低。因此我们可以得出这样的结论,简单的加一个final关键字其实不会对方法调用的性能有太大的提升。同样的,重写方法也不会产生太大的区别。
简单调用点内联
基准测试 |
模式 | 采样数 | 均值 | 标准误差 | 单位 |
c.i.j.JavaFinalBenchmark.inlinableFinalInvoke | avgt | 25 | 0.782 | 0.003 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableVirtualInvoke | avgt | 25 | 0.780 | 0.002 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableAlwaysOverriddenMethod | avgt | 25 | 1.393 | 0.060 | ns/op |
现在我们还是使用这三个用例进行测试,但去掉了内联的限制。这次final和虚方法调用的结果仍然很接近。它们比非内联版本快了大概4倍,我认为这当然是进行了内联的原因。重写的这个方法介于两者之间。我怀疑这是由于这个方法可能存在多个子类的实现,导致编译器插入了一个类型守卫(type guard)。这个机制在上面的多态一节中已经有很详细的描述。
类层级的影响
基准测试 |
模式 | 采样数 | 均值 | 标准误差 | 单位 |
c.i.j.JavaFinalBenchmark.parentMethod1 | avgt | 25 | 2.600 | 0.008 | ns/op |
c.i.j.JavaFinalBenchmark.parentMethod2 | avgt | 25 | 2.596 | 0.007 | ns/op |
c.i.j.JavaFinalBenchmark.parentMethod3 | avgt | 25 | 2.598 | 0.006 | ns/op |
c.i.j.JavaFinalBenchmark.parentMethod4 | avgt | 25 | 2.601 | 0.006 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentMethod1 | avgt | 25 | 1.373 | 0.006 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentMethod2 | avgt | 25 | 1.368 | 0.004 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentMethod3 | avgt | 25 | 1.371 | 0.004 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentMethod4 | avgt | 25 | 1.371 | 0.005 | ns/op |
哇,这里的方法可真多。每个编号的方法(1-4)都对应着方法调用所在类的层级的深度。因此parentMethod4方法意味着我们调用的这个方法声明在类的第4级的父类中。如果你看一下结果数据你会发现1到4之间其实没太大区别。因此我们可以认为,类层级的深度并没有什么影响。内联的方法性能和前面的inlinableAlwaysOverriddenMethod结果差不多,但比inlinableVirtualInvoke要差些。我认为这是使用了类型守卫的原因。JIT编译器可以统计方法找出需要内联的那个,但它不能确保一直都会是调用的这个方法。
类层级对final方法的影响
基准测试 |
模式 | 采样数 | 均值 | 标准误差 | 单位 |
c.i.j.JavaFinalBenchmark.parentFinalMethod1 | avgt | 25 | 2.598 | 0.007 | ns/op |
c.i.j.JavaFinalBenchmark.parentFinalMethod2 | avgt | 25 | 2.596 | 0.007 | ns/op |
c.i.j.JavaFinalBenchmark.parentFinalMethod3 | avgt | 25 | 2.640 | 0.135 | ns/op |
c.i.j.JavaFinalBenchmark.parentFinalMethod4 | avgt | 25 | 2.601 | 0.009 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod1 | avgt | 25 | 1.373 | 0.004 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod2 | avgt | 25 | 1.375 | 0.016 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod3 | avgt | 25 | 1.369 | 0.005 | ns/op |
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod4 | avgt | 25 | 1.371 | 0.003 | ns/op |
这和上面的结果差不多——final关键字看起来并没有太大的影响。我原本认为这里可能inlinableParentFinalMethod4会被内联成不使用类型守卫的版本,不过从结果来看并不是这样。
多态
Monomorphic: 2.816 +- 0.056 ns/op
Bimorphic: 3.258 +- 0.195 ns/op
Megamorphic: 4.896 +- 0.017 ns/op
Inlinable Monomorphic: 1.555 +- 0.007 ns/op
Inlinable Bimorphic: 1.555 +- 0.004 ns/op
Inlinable Megamorphic: 4.278 +- 0.013 ns/op
最后终于到了多态分发的了。单态调用的开销和通常的虚方法调用是一样的。随着我们需要查找的虚方法表越来越大,它们也变得越来越慢了,就像双态和兆态那两个例子中那样。一旦我们开启了内联,类型分析开始介入了,单态和双态的调用点的开销降低成了”内联守卫(inlined with guard)“的开销。跟类层级用例中的很类似,只是稍微慢了些。兆态的这个仍然十分缓慢。记住,我们并没有告诉hotspot说不要去进行内联,只是它没有为比双态更复杂的调用点来实现多态的内联缓存而已。
我们从中学到了什么?
我想值得注意的是,很多人对于不同类型的方法调用需要不同的时间并没有一个清晰的性能模型,同时虽然很多人也知道它们所花费的时间不同,但却没能正确地理解它。我曾经也是这样,也曾做过话多错误的假设。因此我希望这次的分析能对你们有所帮助。下面是我的一些总结。
- 方法调用的最快实现和最慢实现的差别还是很大的。
- 在实践中增加或者减少final关键字其实对性能的影响并不大。
- 类层次结构的深度对方法调用的性能并没有什么实际的影响。
- 单态调用比双态调用要快。
- 双态调用比兆态调用要快。
- 在我们前面看到的基于统计分析可能是单态调用但是并不确定的用例中(注:会进行内联优化,但是在调用前会插入一个类型守卫),里面所使用到的类型守卫的确是会影响到性能。
类型守卫的开销对我个人而言有很大的启发。我很少看到有人提及它,通常大家都认为它无关而忽略掉了。
说明
当然这并不是这个话题的一个结论性的论述。
- 本文关注的只是和方法调用性能相关的类型相关的因素。有一个因素我没有提及到的是方法体的大小和调用栈的深度对内联的影响。如果你的方法太大的话,它是不会被内联的,你仍然会为方法调用的开销买单。这也是为什么方法要写得小而易看懂的一个原因。
- 我没有分析通过接口调用方法对这几种情况的影响。如果你对这个感兴趣的话,在Mechanical Sympathy的博客上有关于接口调用性能的一个分析。
- 还有一个完全忽略了的因素是方法内联对其它编译器优化的影响。当编译只对某个方法进行优化的时候,它当然希望能收集尽可能多的信息,这样它才能更有效地进行优化。内联优化的限制可能会对其它优化产生很大的影响。
- 从汇编的层面来进行分析才能对这个问题有更深入的了解。
也许在以后的博客中会讨论一下这些话题。
原创文章转载请注明出处:
http://it.deepinmind.com
英文原文链接
分享到:
相关推荐
在IT行业中,跨语言交互是常见的需求,...这些方法各有优缺点,开发者应根据项目需求、性能和兼容性等因素来选择最合适的方式。在实际项目中,通过实践和调试这些工具,可以更好地理解和掌握Java与Python的交互技巧。
此外,性能和稳定性可能受到跨语言调用的影响,因此在生产环境中评估这些因素是至关重要的。 总结,通过IKVM.NET,C#可以方便地调用Java类和使用Java的jar包,实现跨语言的互操作。这对于整合现有Java库或利用特定...
4. **实例化Java对象**:在C#中,你需要通过IKVM.NET提供的API来创建Java对象并调用其方法。这可能涉及加载Java类库,然后使用`JavaType`和`JavaObject`来实例化和调用Java接口。 5. **错误处理和异常转换**:由于...
- 在接口中定义需要暴露给Java调用的方法,如`add`、`say`和`isCOM1`。 - 配置项目设置,使程序集对COM可见,并生成COM Interop注册。 - 为程序集签名,生成`.snk`文件,以确保在目标机器上注册。 - 编译项目,...
在实际项目中,你可能还需要考虑其他因素,如安全性(确保数据传输的安全)、性能优化(如使用连接池)以及文件上传进度的显示等。通过这个"java上传文件接口调用源码案例",我们可以学习到如何在Java环境中实现文件...
值得注意的是,上述代码中的文件名列表(如Project1.cfg、Unit1.dcu等)与Java调用存储过程无关,它们看起来像是Delphi或FreePascal项目的文件,这些文件通常用于描述项目配置、单元信息、表单布局等,而不是与Java...
- **性能**:跨语言调用通常比同语言调用有更高的开销,因此要考虑性能影响。 - **错误处理**:由于是跨语言通信,错误处理要特别注意,确保能正确捕获和处理可能出现的问题。 6. **最佳实践** - **设计清晰的...
Java JNI(Java Native ...注意,调用DLL可能会涉及线程安全、错误处理以及资源管理等问题,所以在实际应用中需要仔细考虑这些因素。此外,确保DLL与Java环境的兼容性,包括版本匹配和系统架构的适配,也是非常重要的。
Java调用DLL函数是跨平台编程中的一种常见需求,特别是在Java与C/C++代码交互时。JNA(Java Native Access)是Java平台上的一个库,它允许Java代码直接调用本机库(如DLL文件)的函数,而无需编写JNI(Java Native ...
总结来说,Java调用C动态SO文件的接口,主要是通过JNI机制实现的,涉及Java端的本地方法声明、C/C++代码实现、动态库加载和调用等步骤。这个过程需要对Java、C/C++以及JNI规范有深入理解,以确保跨语言交互的正确性...
然而,给定的描述中提到了使用Java调用C/C++生成的动态链接库(DLL)的过程,这与描述部分的内容有所偏差。接下来,我们将基于给定的部分内容详细探讨如何在Java中调用由C/C++编写的DLL。 #### 一、生成C的头文件 ...
在IT行业中,转换文档格式是一项常见的任务,尤其是从Microsoft Word的doc格式转换为PDF。Java作为一门广泛应用的编程语言,提供了多种...在实际项目中,应根据需求、性能和稳定性等因素综合考虑选择最合适的转换方法。
8. **性能和安全性**:由于涉及到网络通信,性能和安全性是需要考虑的重要因素。优化HTTP请求,使用安全的HTTPS协议,以及正确管理认证和授权,都是必要的步骤。 9. **测试与调试**:为了确保Java客户端能正确调用...
5. 在Java代码中加载并调用本地方法。 二、使用第三方库 更常见的做法是使用第三方库,如RXTX、JSerialComm等,这些库已经封装了与不同操作系统交互的细节。 1. RXTX:这是一个开源项目,提供了Java串口通信的API...
在实际应用中,可能还需要考虑性能、线程安全、异常处理等因素。由于C#和Java运行在不同的虚拟机上,调用开销可能会比纯.NET或Java代码高,因此优化跨语言调用的性能也是一项重要任务。 总的来说,"C#调用Java类库...
总的来说,Java调用Web Service涉及到多个环节,从理解协议和标准,到选择合适的工具和框架,再到实际的编码和测试,每个步骤都需要开发者具备扎实的理论基础和实践经验。通过以上知识点的学习和实践,你将能够熟练...
在IT行业中,跨语言通信是常见的需求,例如PHP与Java之间的交互。PHP是一种广泛用于Web开发的脚本语言,而Java则以其强大...在实际项目中,我们还需要考虑性能、安全、可维护性等因素,确保系统设计的合理性和高效性。
本篇主要介绍如何在Java环境中调用规则引擎,具体包括三种方法:Java类直接调用规则包、通过规则服务调用和通过SOAP方式调用。 1. **Java类调用规则包** 在Java项目中,首先需要配置类路径,将规则引擎的相关库...
Java调用APNs(Apple Push Notification service)推送是iOS应用开发者在进行远程通知服务时常见的需求。...在实际开发中,还需要考虑到性能、稳定性和用户体验等因素,以提供高效、可靠的推送服务。