如何优化 Android Marshmallow 中的 Java 代码





5.00/5 (1投票)
本文是关于 Marshmallow 版本;有一个关于 Lollipop 的类似文章。
Intel® Developer Zone 提供跨平台应用开发工具和操作指南、平台和技术信息、代码示例以及同行专业知识,以帮助开发人员进行创新并取得成功。加入我们的社区,了解 Android、物联网、Intel® RealSense™ 技术 和 Windows,下载工具、访问开发套件、与志同道合的开发人员分享想法,并参加黑客马拉松、竞赛、路演和本地活动。
引言
随着 Android* 生态系统的不断发展,提供性能和良好用户体验的组件也在不断发展。Android Marshmallow 版本集成了新功能和新功能,以及对 Android Runtime* (ART) 的多项增强,从而提高了应用程序性能、降低了内存开销并加快了多任务处理速度。本文是关于 Marshmallow 版本;有一篇 关于 Lollipop 的类似文章。
对于每个版本,应用程序开发人员都必须了解 Android 虚拟机发生了哪些变化。换句话说,过去用于获得最佳性能的方法可能不再有效,而新技术可能会产生更好的结果。通常关于 Android 版本更改的公开信息很少,因此开发人员必须通过反复试验来找出这些差异。
本文从程序员的角度描述了 Marshmallow 的 Android Runtime 代码生成,并提供了开发人员可以用来为最终用户提供最佳用户体验的技术。提供的技巧和窍门将帮助您实现更好的代码生成和性能。本文还描述了为什么某些优化会被启用或独立于开发人员的 Java* 代码。
用于更好 ART 性能的 Java 编程技巧
Android 生态系统非常复杂。这种复杂性的一部分是编译器,它将 Java 代码转换为 Intel® x86 代码以用于 Intel 设备。Android Marshmallow 包括一个名为“优化编译器”的新编译器,它比 Lollipop 的传统“快速编译器”具有更好的代码性能来优化 Java 程序。目前在 Marshmallow 中,几乎所有程序都使用优化编译器进行编译。Android 系统框架方法使用快速编译器进行编译,以便为 Android 开发人员提供更好的调试方式。
库选择和精度损失注意事项
浮点计算提供了多种相似操作的变体。在 Java 中,Math 和 StrictMath 为浮点运算提供了不同的精度级别。虽然 StrictMath 提供了更具可重复性的计算,但在大多数情况下 Math 库就足够了。以下方法计算余弦
public float myFunc (float x) {
float a = (float) StrictMath.cos(x);
return a;
}
然而,如果精度损失是可以接受的,开发人员可以使用 Math.cos(x) 而不是 StrictMath.cos(x)。Math 类已针对使用 Intel® 架构的 Android Bionic 库进行了优化。Intel 对 ART 的实现调用 Bionic 库中的数学库函数,这些函数比等效的 StrictMath 方法快 3.5 倍。
在某些情况下需要 StrictMath 类,不应将其替换为 Math 类。然而,Math 在大多数情况下都可以接受。这实际上是一个精度损失的问题,也取决于算法和实现。
对递归算法的支持
与 Lollipop 相比,Marshmallow 中的递归调用效率更高。当代码以递归方式编写给 Lollipop 时,self 方法参数始终从 Dalvik* Executable Format (dex) 缓存加载。在 Marshmallow 中,递归函数使用初始参数列表中的相同 self 方法参数,而不是从 dex 缓存重新加载它。当然,递归深度越大,Lollipop 和 Marshmallow 之间的性能差异就越大。然而,当存在算法的迭代版本时,它在 Marshmallow 版本中仍然表现更好。
使用 array.length 来消除边界检查
Marshmallow 中的优化编译器能够消除某些数组边界检查。请参阅 这篇文章 了解关于边界检查消除的讨论。
空循环是
for (int i = 0; i < max; i++) { }
变量 i 被称为归纳变量(IV)。如果 IV 用于访问数组,并且循环遍历每个元素,那么如果 max 被显式定义为数组的长度,则可以移除数组边界检查。请参阅 这篇文章 了解更多关于归纳变量的信息。
示例
考虑以下示例,其中代码使用变量 size 作为 IV 的最大值
int sum = 0;
for (int i = 0; i < size; i++) {
sum += array[i];
}
在此程序中,数组索引 i 与 size 进行比较。我们假设 size 要么在方法外部定义,要么作为参数传递,要么在方法内的其他地方定义。在任何这些情况下,编译器可能无法推断出 size 是数组的长度。由于这种不确定性,编译器必须生成一个运行时边界检查,作为每次数组访问的一部分。
将代码重写如下,运行时边界检查将被编译器消除。
int sum = 0;
for (int i = 0; i < array.length; i++) {
sum += array[i];
}
包含两个数组的循环:高级边界检查消除(BCE)
上一节展示了 BCE 优化的简单案例以及如何激活它。但是,在某些算法中,单个循环处理多个单独的数组,这些数组都具有相同的长度。在这种情况下,编译器必须对两个访问都执行 null 和边界检查。
下一节将仔细研究 BCE 以及在使用多个数组时如何启用它。通常需要重写代码才能使编译器优化循环。
本节的示例考虑了同一循环中的多个数组访问
for (int i = 0; i < age.length ; i++) {
totalAge += age[i];
totalSalary += salary[i];
}
此代码存在问题。程序没有检查 salary 的长度,有数组索引越界异常的风险。程序应该在进入循环之前检查长度是否相同,例如
for (int i = 0; i < age.length && i < salary.length; i++) {
totalAge += age[i];
totalSalary += salary[i];
}
现在代码是正确的,但仍然存在一个问题,因为 BCE 在这种情况下不起作用。
在上面的循环中,程序员正在访问两个一维数组:age 和 salary。即使归纳变量 i 被设置为检查两个数组的长度,编译器也无法消除多条件情况下的边界检查。
在所示循环中,两个数组不使用相同的内存。因此,对两个数组字段进行的逻辑操作彼此独立。相反,将操作分成两个独立的循环,如下所示
for (int i = 0; i < age.length; i++) {
totalAge += age[i];
}
for (int i = 0; < salary.length; i++) {
totalSalary += salary[i];
}
分离循环后,优化编译器将从两个循环中消除数组边界检查。对于类似的简单循环,Java 代码的速度可以提高三到四倍。
BCE 在这里有效,但现在函数包含两个循环,可能导致代码膨胀。取决于目标体系结构和循环的大小,或执行次数,这可能会影响最终生成的代码大小。
多线程编程技术
在多线程程序中,开发人员在访问数据结构时必须小心。
假设程序在下面的循环之前生成了四个相同的线程。然后,每个线程访问一个名为 thread_array_sum 的整数数组,每个线程通过一个名为 myThreadIdx 的变量访问一个单元格,该变量是标识每个线程的唯一整数。
for (int i = 0; i < number_tasks; i++) {
thread_array_sum[myThreadIdx] += doWork(i);
}
某些设备架构,如 Intel® Atom™ x5-Z8000 处理器系列,没有所有处理器核心共享的 LastLevelCache (LLC)。虽然具有独立 LLC 的响应时间可能更好(因为缓存被“保留”给一个或两个处理器核心),但它们之间的保持一致性可能导致数据块在 LLC 之间“跳跃”。这种跳跃可能导致性能下降和处理器核心扩展问题。请参阅 这篇文章 了解更多信息。
由于缓存布局,多个线程写入同一数组存在性能下降的风险,这可能是由于高水平的缓存颠簸。程序员应该使用局部变量来存储中间结果,然后稍后才更新数组。然后循环将变为
int tmp = 0;
for (int i = 0; i < number_tasks; i++) {
tmp += doWork(i);
}
thread_array_sum[myThreadIdx] += tmp;
在这种情况下,数组元素 thread_array_sum[myThreadIdx] 与内部循环无关,并且 doWork() 的累积值可以在循环外部存储在数组元素中。这大大减少了潜在的缓存颠簸。缓存颠簸在指令 thread_array_sum[myThreadIdx] += tmp 时仍然可能发生,但可能性要小得多。
除非存储的值必须在每个循环迭代结束时对其他线程可见,否则不建议在循环中存储共享数据结构。这些情况通常至少需要使用 volatile 字段和/或变量,但这超出了本文的范围。
低存储设备的最优代码性能提示
Android 设备具有各种内存和存储配置。Java 程序应编写为易于在任何内存大小的设备上进行优化。低存储设备可能会针对空间进行优化,这是 ART 的编译选项。在 Marshmallow 中,为了节省设备存储空间,大小超过 256 字节的方法不会被编译,因此包含大型热方法的 Java 程序将在解释器中执行并且性能不佳。为了在 Marshmallow 中获得最佳性能,将经常使用的代码实现为小方法,以完全启用编译器优化。
以小方法编写的 Java 程序更有可能被 ART 编译,无论设备存储限制如何,这使得大型 Android 应用程序的性能提高高达三倍。
摘要
每个 Android 版本都附带了新的元素和不同的技术。就像 KitKat 和 Lollipop 一样,Marshmallow 在 Android 生态系统中对编译器技术进行了重大更改。
与 Lollipop 一样,ART 使用即时编译器 (Ahead-of-Time),它通常在安装时将用户应用程序转换为本机代码。然而,Marshmallow 使用了一个名为优化编译器的新编译器,而不是使用 Lollipop 的快速编译器。虽然在某些情况下优化编译器会依赖于快速编译器,但优化编译器是 Android Java 二进制代码生成的新核心。
每个编译器都有自己的特点和优化,因此每个编译器可能会根据 Java 程序的编写方式生成不同的二进制代码。本文介绍了 Marshmallow 版本的一些主要区别,以及开发人员在使用它时应该注意的事项。
从数学库的使用到边界检查的消除,优化编译器中包含许多新功能。有些很难通过示例展示,因为大部分优化都是“在后台”完成的。我们所知道的是,Android 编译器的成熟度正在显现,因为其技术正在变得越来越先进,并正在慢慢赶上其他优化编译器。随着编译器的发展,开发人员可以确信他们编写的代码将得到很好的优化,并提供更好的最终用户体验,这是每个人都能欣赏到的。
致谢(按字母顺序)
Johnnie L Birch, Jr., Dong Yuan Chen, Chris Elford, Haitao Feng, Paul Hohensee, Aleksey Ignatenko, Serguei Katkov, Razvan Lupusoru, Mark Mendell, Desikan Saravanan, and Kumar Shiv
关于作者
Rahul Kandu 是 Intel 软件和解决方案部门 (SSG)、系统技术与优化部门 (STO)、客户端软件优化部门 (CSO) 的一名软件工程师。他专注于 Android 性能,并寻找优化机会以帮助 Intel 在 Android 生态系统中获得更好的性能。
Jean Christophe Beyler 是 Intel 软件和解决方案部门 (SSG)、系统技术与优化部门 (STO)、客户端软件优化部门 (CSO) 的一名软件工程师。他专注于 Android 编译器和生态系统,但也深入研究其他与性能相关的编译器技术。