【性能优化】真有那么慢?Java 和 Kotlin 的反射

0 评论

视频先行

哔哩哔哩

YouTube

下面是视频内容的脚本文案原稿分享。
210647777b3c9

开场

大家好!我是扔物线朱凯。

Java 和 Kotlin 的反射我们都知道啊,高级特性,它能帮我们突破禁忌,去做一些正常情况下做不到的事,很强。

另外它也有劣势,比如性能和兼容性。尤其是性能,对于 Android 这种性能受限的场景,我们对所有「性能差」的方案都是非常谨慎的。有个典型的例子就是 Dagger:Google 很早就有一个基于注解的依赖注入库 Guice,但是 Square 却又创建了一个用途和用法都差不多的库——Dagger。区别主要在于底层实现,Guice 是通过运行时用反射读取注解来实现的,而 Dagger 基本上没有用反射,而是用的 Annotation Processor。你看,废了一大番工夫,做了个一样的东西,只是为了避免反射所带来的性能损耗,足以说明反射的可怕。另外网上也有很多文章,告诉我们反射的性能非常差,一定要谨慎使用。

那么这儿其实就有个问题:它的性能到底有多差?摸到它的边界,我们才知道什么时候用它、用到什么程度,对吧?这就是今天咱要聊的话题。

反射是啥

反射之所以慢,跟它的原理是直接相关的。 

大家想过为什么这个功能叫「反射」吗?它原本的英文叫 reflection。这个词有「反射」的意思,但是它用在编程里的时候的本意其实不是反射,而是「自省」,指的是人对于自己的行为啊、发生过的事啊进行深刻的反思,并且期望通过这些反思来让自己以后可以做得更好或者避免同样的问题之类的,这种行为在英文里也叫 reflection。而这个,才是编程里的 reflection——也就是反射——的本意。在 Java 里面,reflection 的意思就是「程序进行自我探查,看到自己深处的、没有直接通过 API 暴露出来的性质」,比如列出自己内部的所有方法,或者根据方法名的字符串来获取指定的方法;另外在实际的功能上,Java 还做了延伸,Java 的反射还允许程序对自己进行性质和功能的动态修改。reflection 这个词的本意只有自省,没有自我修改的意思——人怎么自我修改啊,长出第三条腿?是吧。但是 Java 的反射是支持的。 

所以「反射」这个词,它实质上是「自省」或者说「自我探查」的意思——如果从这个翻译来解释,它的名字就比较直观了吧?

反射有什么用

反射具体的用途有很多,大致可以归为两类:一类是用来访问那些本来访问不到的 API,一类是动态的功能拓展。 

有些 API 由于加了 private 的限制,不能被公开访问,但是我们又需要在某些情况去访问它们——比如在测试流程里我们可能需要通过一些私有 API 来做测试场景的初始化——但并不希望真的把这些 API 给写成 public 的,那么就可以用反射来在程序运行过程中强行进行这些访问。

反射还有个经典的应用场景是动态代理,也就是在运行时根据实时需求来动态地创建新的类型,以及这些类型的实例化对象。比如 Retrofit 的网络访问就是通过动态代理来实现的。

反射为什么慢

反射的功能和定位大概就是这么回事。然后,我们更深入一步:反射为什么慢?——很简单,因为它是动态的。

Java 的 JIT(Just-In-Time),也就是即时编译的优化,反射绝大多数情况下是用不了的。因为 JIT 需要从字节码里获取足够多的上下文信息才能做优化,而反射都是动态的,没什么上下文,就很难优化。 

另外,由于反射是通过「自我探查」的方式来动态获取信息,这种动态获取所包含的信息查找过程——例如通过方法名来查找方法——也需要耗时。相比起来,正常的代码是我们要访问的时候,虚拟机已经知道我们要访问的对象在什么位置了,运行时就不用再查找了。

还有更多的原因,比如反射的调用还需要程序对访问权限做动态检查,以及需要我们在代码里增加异常处理,等等等等,这些都是会耗时的。我们不需要对它们做完整了解,只要知道反射的「动态自我探查」这个本质,的确导致了它是一定比普通代码要慢的,这是个脱不掉的本质弱点。由于动态,所以灵活;但同样由于动态,所以性能差。

有多慢?

不过,到底有多慢呢? 我做了一个测试:在我的三星 S20 上面,执行一个直接返回数值 1 的 Java 方法:

我分别用直接调用和反射调用的方式来执行它:

执行一千万次,正常调用的耗时是 24 ms,而反射的耗时是 4716 ms,也就是 4 秒多。

可以看出来什么?

很明显,反射的性能比正常调用是差很多的。对吧?所以相比起普通的代码,我们在使用反射的时候的确会耗时更久,从而更容易导致界面的卡顿,甚至是 ANR(程序无响应)。大致估一下,一百多倍的差距,很大了。

我不止在这部真机上,还在两个虚拟机上做了测试,结果是差不多的:

这个「一百多倍」的比值,其实并没有经过太精细的考究,比如这两行代码实际上是做了两次的反射操作的,一次方法获取,一次方法调用:

如果只把第二步的方法调用放进循环里,得出的耗时就会减少:

另外,我测试的是方法调用的性能,但反射还有其他的功能,那些我都没测。到底要怎么制定测量规则才更精准,其实取决于最终我要考查的是什么。而我现在只是想做大致的性能对比,那么姑且就这么测,然后取个大致的中间值:一百倍。至少在数量级上,这么估算问题不大吧? 

然后,我们还需要具体看一下,这个看起来「相当差」的反射,它具体的性能是多少。

我把 4716 ms 除以 1 千万之后,得到的结果是每次耗时大约 0.0004716 毫秒。这是个什么概念?按照 Android 设备通常的 60-120 Hz 的刷新率来说,一帧的耗时在 8.3 到 16.7 毫秒左右,我们在一帧内做的所有事情加起来的耗时不能超过这个数值,才能确保不掉帧。那么 0.0004716 毫秒一次的操作,我们要做多少次会达到 8.3 毫秒呢?至少要 17000 多次:

这不是个精确的值,但就像我刚才说的,在数量级的层面,这个值是完全可以参考的,对吧?那么,什么事会需要我们在一帧里面做 17000 次反射?除了循环之外,基本不会发生这种事。当然了我们不可能把一帧的所有时间都让给反射,我们有很多别的事要做,比如动画的计算。但是我就算除以 10,只给反射留 10% 的时间,那也有 1700 次了吧?有什么需要我反射 1700 下?依然是除了循环,基本不可能。

所以结论是什么?反射虽然慢,但是你正常地用,有关系吗?完全没关系。

除非像刚才说的:如果你在循环里——或者任何高频调用的场景里使用反射——那还是要小心的,一不留神也许就耗时过久,把界面搞掉帧了。至于具体什么时候会用循环或者高频调用,那我就没法告诉你了,只有咱自己知道自己的代码的逻辑,是吧?这个得自己判断。

所以这份测试结果,实际上给了我们两个信息:

● 一,反射的确比正常调用要更慢,而且慢很多。

● 二,虽然慢很多,但它还是很快。所以正常情况下依然可以放心用而不至于造成可见的影响;只是对于高频调用的场景,就真的需要注意一下,用得太猛还是有可能把界面搞卡顿的。

整体来说,不算可怕。

旧版本 Android 的表现

但是,既然并不可怕,那为什么江湖上有那么多「反射的性能很差」的传言呢?难道又是一个以讹传讹的假知识?

如果你在搜索引擎把时间做一下限制,你会发现从十几年前开始,网上就已经有了对于反射在 Android 设备上比较慢的讨论了:

十几年前,还没有进入对 Android 进行极致性能优化的时代,所以如果那时候就有人在讨论反射性能差,那么过度担忧或者危言耸听的可能性就很小了。那为什么大家都说反射性能很差,但我实际的测试结果却还行呢?

会不会……跟时代有关?我去试试。

当我把同样的代码,在更早期版本的 Android 设备上一个个进行测试的时候,我发现在 API 19——也就是 Android 4.4——以及更早版本的设备上,性能出现了严重的下降。降到了什么程度呢,我需要让循环从一千万次减少到十万次,才能正常地测试,不然我每次等测试结果都要等老半天:

另外,我发现并不是只有反射,连正常的函数调用的性能也严重下降了。
实际的耗时算下来,单次反射调用的耗时从 0.0003-0.0004 毫秒增加到了 0.008-0.09 毫秒,大概是 20-300 倍的差距——浮动有点大,不过咱看个大概的数量级,就取个粗略的中间值:也姑且按照 100 倍吧。刚看的是反射和普通调用的性能差距,100 倍;现在看的是旧版和新版 Android 的反射的性能差距,也是 100 倍。那么如果依然只给反射分配 10% 的单帧时间,单次耗时按照 100 倍的话就是 0.04 毫秒,这样的话就算是按照旧版 Android 的 60 帧的刷新率——每帧可以有 16.7 毫秒,是 8.3 毫秒的两倍了——但每帧也只有 41 次调用的机会:

这就明显有点捉襟见肘了,是吧?

所以,「反射性能很差」这个说法是有依据的,对于旧版的 Android 来说,反射的性能真的是很差的。

为什么差距这么大?

为什么呢?为什么不同版本 Android 之间的差距这么大?

这是有多方面原因的。比如 Android 5.0 推出的 ART 虚拟机,性能是强过旧版的 Dalvik 的。这个很好证明:Android 是在 4.4 推出的测试版 ART,这是唯一一个允许设备在 Dalvik 和 ART 之间切换的版本。我把我 API 19 的测试虚拟机切换到 ART 之后,它的性能表现就提升了很多:

所以 ART 的引入,肯定对性能提升有帮助。

另外,新版 Android 对垃圾回收的处理机制也做了大幅优化,这个也会对性能表现有很好的影响。

完整的原因有很多,我也说不全,没正经研究过。但咱能看到的结果是,新版 Android 的性能比旧版是强很多倍的,是吧?

这样来看,似乎就说得通了:为什么网上很多说「反射性能差」的文章,但我们的实测结果虽然也是差,但远远没到不可用的程度?——因为时代变了,旧时代它真的差得可怕,而新时代里,其实还好。

总结

那么到这里,差不多可以总结一下了:

● 反射的性能差吗?差,比不用反射来说,差很多;

● 为什么?因为它的动态特性,让它注定会失去各种优化,所以必然会慢得多,这是不可改变的事实;

● 从绝对表现来说,它的性能差到不可用了吗?其实还行,只要不在大规模调用的场景下使用反射,一般是不会影响软件的性能表现的;

● 但,如果你开发的软件会在旧版本的 Android——尤其是 5.0 之前——的设备上运行,那你还是需要非常谨慎地使用反射。在旧版本设备上用反射,还是有点容易把软件搞卡顿的。

我只做了 Java 的反射的测试,但对于 Kotlin 来说道理是一样的,因为 Kotlin 的反射实质上也是基于 Java 的反射来做的。以上的结论,不仅适用于 Java,也适用于 Kotlin 的反射。

反射是 Java 和 Kotlin 上非常有用的一套机制,它能给我们带来很大的帮助。但是,它的性能陷阱也让我们经常对它有所忌惮,甚至不敢轻易使用它。正是因为这个原因,我做了这个视频,希望能帮助大家对于反射的性能有更清楚的理解和认识,能让你在开发里可以更清楚反射代码的性能表现,从而更好地判断能不能、要不要用反射。

行,就这么多!关注我,了解更多 Android 开发相关的知识和技能。我是扔物线,我不和你比高低,我只助你成长。我们下期见!

210647777b3c9

版权声明

本文首发于:https://rengwuxian.com/reflection/

微信公众号:扔物线

转载时请保留此声明