翻译
随着 Java 8 被引入 Andorid, 我们更要时刻牢记标准库的 API 和语言特性都伴随着额外的性能消耗。虽然我们的设备不断升级,有了更大的内存,更高的运行速度,但是代码对性能的影响仍是至关重要的。360AnDev 的这次分享将会探讨一些 Java 特性的隐式消耗。我们主要关注与第三方库开发者和应用开发者相关的性能优化方法,并介绍相关的测试工具。
Introduction
文章前面并不会介绍优化相关内容,解决办法会在最后部分。以及本文会有大量命令行工具的使用方法,相关资源连接也会文章末尾附上。
Dex file
我们从一个多选题开始,这段代码里有几个方法?0, 1 or 2?
1 | class Example { |
翻译好累 不翻了…
0 -> 源文件里确实是 0 个
1 -> 编译成字节码,查看 class 文件,有自动生成的无参构造函数
2 -> 在 Andorid 中,字节码还会被转换成 dex 文件,使用 SDK 里的工具可以查看详细信息
javac1
2
3
4
5
6
7
8
9$ echo "class Example {
}" > Example.java
$ javac Example.java
$ javap Example.class
class Example {
Example();
}
dex1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16$ dx --dex --output=example.dex Example.class
$ dexdump -f example.dex
$ dexdump -f example.dex
Processing 'example.dex'...
Opened 'example.dex', DEX version '035'
DEX file header:
magic : 'dex\n035\0'
checksum : 53c72da5
...
field_ids_off : 0 (0x000000)
method_ids_size : 2 <--------------------------注意这里
method_ids_off : 156 (0x00009c)
...
也就是下面这两个方法1
2Example <init>()
java.lang.Object <init>()
Java 内部类
1 |
|
ItemsView 编译后生成两个单独的类文件,由此可见其在 Java 中并没有真正的嵌套。
使用 javap
指令查看,ItemsView.class
中没有任何关于其内部类的信息。另一个值得注意的是虽然内部类 ItemsAdapter
在源文件中被声明为 private
,但在其类文件中却不是私有的,而是默认的 package 访问权限。1
2
3
4
5
6
7
8
9// Compiled from "ItemsView.java"
public class ItemsView {
public ItemsView();
}
// Compiled from "ItemsView.java"
class ItemsView$ItemsAdapter {
ItemsView$ItemsAdapter();
}
看起来内部类只是以一种更高效的方式生成了两个处于同一包名下彼此相邻的类文件。
Ps: 我编出来和他说的不一样啊…1
2
3
4
5
6
7
8
9
10
11
12
13
14
15java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)
$ javac ItemsView.java -source 1.7 -target 1.7
// Compiled from "ItemsView.java"
public class ItemsView {
public ItemsView();
}
// Compiled from "ItemsView.java"
class ItemsView$ItemsAdapter {
final ItemsView this$0;
}
继续上面的,在我们的假设中嵌套类只是生成两个类,但下面两段代码又引出新的问题。
Compile Ok1
2
3
4
5
6
7
8
9
10
11
public class ItemsView {
private static String displayText(String item) {
return ""; // TODO
}
private class ItemsAdapter {
void bindItem(TextView tv, String item) {
tv.setText(ItemsView.displayText(item));
}
}
}
Compile Error1
2
3
4
5
6
7
8
9
10
11
12// ItemsView.java
public class ItemsView {
private static String displayText(String item) {
return ""; // TODO
}
}
// ItemsView$ItemsAdapter.java
class ItemsView$ItemsAdapter {
void bindItem(TextView tv, String item) {
tv.setText(ItemsView.displayText(item));
}
}
反编译看下编译成功的结果,又出来一个我们没写的方法。也就是说只要在刚才编译失败的那个分离版本中也写上这么一个方法,编译就没问题了。表面上看起来没什么,但这个方法是实实在在的加到你的方法数上的。1
2
3
4
5
6
7$ javap -p ItemsView
class ItemsView {
ItemsView();
private static java.lang.String displayText(…);
static java.lang.String access$000(…);
}
More Dex
这东西不光在 javac
里有影响,你把它转成 dex 也是这样的。
1 | $ dx --dex --output=example.dex *.class |
那么 Jack compiler 呢?还是一样,只不过不叫 access
改叫 -wrap0
了1
2
3
4
5
6
7
8
9
10
11
12
13
14$ java -jar android-sdk/build-tools/24.0.1/jack.jar \
-cp android-sdk/platforms/android-24/android.jar \
--output-dex . \
ItemsView.java
$ dex-method-list classes.dex
ItemsView -wrap0(String) → String
ItemsView <init>()
ItemsView displayText(String) → String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()
我们还有 ProGuard 不是,能不能搞定这个方法呢?1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ echo "-dontobfuscate
-keep class ItemsView$ItemsAdapter { void bindItem(...); }
" > rules.txt
$ java -jar proguard-base-5.2.1.jar \
-include rules.txt \
-injars . \
-outjars example-proguard.jar \
-libraryjars android-sdk/platforms/android-24/android.jar
$ dex-method-list example-proguard.jar
ItemsView access$000(String) → String
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
构造函数因为没使用被移除了,但是实际使用时还是会用到他们,所以我们这里手动保留一下。1
2
3
4
5
6
7
8$ dex-method-list example-proguard.jar
ItemsView <init>()
ItemsView access$000(String) → String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()
最终结果,access
方法仍然存在,但是 displayText
消失了。发生了什么?解压 Proguard 生成的 jar 文件,使用 javap 查看 class 文件:
1 | $ unzip example-proguard.jar |
是因为没有其他位置使用这个方法, Proguard 对 displayText 进行了优化。可以见得 Proguard 在这里确实有那么一点用处,不过对一个简单的 demo 起效并不足以证明。你可能不以为然,觉得内部类的影响没什么大不了的,那我们下面接着说匿名内部类。
匿名内部类
1 | class MyActivity extends Activity { |
这东西我们常用,匿名内部类其实就是嵌套类,只不过他没有名字。如果你访问了一个外部类的 private 方法,他就会对应生成 access 方法,访问字段也是这样。
1 | class MyActivity extends Activity { |
你猜上面这段代码会生成多少个 access 方法呢?1
2
3
4
5
6
7
8
9
10
11
12
13
14$ javac -bootclasspath android-sdk/platforms/android-24/android.jar \
MyActivity.java
$ javap MyActivity
class MyActivity extends android.app.Activity {
MyActivity();
protected void onCreate(android.os.Bundle);
static int access$002(MyActivity, int); // count = 0 write
static int access$004(MyActivity); // ++count preinc
static int access$006(MyActivity); // --count predec
static int access$008(MyActivity); // count++ postinc
static int access$010(MyActivity); // count-- postdec
static int access$000(MyActivity); // count read
}
可想而知,如果一个 Activity 或是 Fragment 里有几个这样的 listener,那方法数还不得爆炸。
现实世界
我们来看看你手机里的应用吧,先把你安装的 APK 都拷贝出来1
2
3
4
5
6
7
8$ adb shell mkdir /mnt/sdcard/apks
$ adb shell cmd package list packages -3 -f \
| cut -c 9- \
| sed 's|=| /mnt/sdcard/apks/|' \
| xargs -t -L1 adb shell cp
$ adb pull /mnt/sdkcard/apks
然后用 dex-method-list
和 grep
统计下方法数1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16#!/bin/bash accessors.sh
set -e
METHODS=$(dex-method-list $1 | \grep 'access\$')
ACCESSORS=$(echo "$METHODS" | wc -l | xargs)
METHOD_AND_READ=$(echo "$METHODS" | egrep 'access\$\d+00\(' | wc -l | xargs)
WRITE=$(echo "$METHODS" | egrep 'access\$\d+02\(' | wc -l | xargs)
PREINC=$(echo "$METHODS" | egrep 'access\$\d+04\(' | wc -l | xargs)
PREDEC=$(echo "$METHODS" | egrep 'access\$\d+06\(' | wc -l | xargs)
POSTINC=$(echo "$METHODS" | egrep 'access\$\d+08\(' | wc -l | xargs)
POSTDEC=$(echo "$METHODS" | egrep 'access\$\d+10\(' | wc -l | xargs)
OTHER=$(($ACCESSORS - $METHOD_AND_READ - $WRITE - $PREINC - $PREDEC - $POSTINC - $POSTDEC))
NAME=$(basename $1)
echo -e "$NAME\t$ACCESSORS\t$READ\t$WRITE\t$PREINC\t$PREDEC\t$POSTINC\t$POSTDEC\t$OTHER"
最后使用这个脚本遍历 pull 出来的 APK1
2
3
4
5
6
$ column -t -s $'\t' \
<(echo -e "NAME\tTOTAL\tMETHOD/READ\tWRITE\tPREINC\tPREDEC\tPOSTINC\tPOSTDEC\tOTHER" \
&& find apks -type f | \
xargs -L1 ./accessors.sh | \
sort -k2,2nr)
相信结果一定令人印象深刻。
解决办法也相当简单,不使用 private 或者使用 package 访问权限。使用 IntelliGate 可以对代码进行检测,并将问题代码高亮,有效的提示你注意这个问题。
Synthetic 方法
Synthetic 方法是自动生成的。而且上面 accessor 并不是唯一的例子,泛型也是其中之一。1
2
3
4
5
6
7
8
9
10
11// Callbacks.java
interface Callback<T> {
void call(T value);
}
class StringCallback implements Callback<String> {
public void call(String value) {
System.out.println(value);
}
}
假设我们有一个方法接收一个泛型值,编译后可以发现,这个方法被转换成了两个。一个是我们使用的,另一个的参数却是 Object,这就是类型擦除。我们不得不为它生成方法。
1 | $ javap -c StringCallback |
查看生成的方法,只是做了类型转换,然后调用实际方法。对于返回值的泛型也是这样
1 | $ javac -bootclasspath android-sdk/platforms/android-24/android.jar \ |
许多人还没注意到 Java 的一个特性1
2
3
4
5
6
7
8
9
10
11class ViewProvider implements Provider<View> {
public View get(Context context) {
return new View(context);
}
}
class TextViewProvider extends ViewProvider {
public TextView get(Context context) {
return new TextView(context);
}
}
你可以将这个返回值限定为一个更具体的类型。这被称为 covariant(协变)返回类型。返回值并不是强制的要像例子里那样实现一个 interface。这个 base class 可以是 interface 也可以是其他任何东西。你可以将 base class 中任何一个方法的返回值类型变成一个更为特定的类。
常用于你在其他类中使用这个方法时,想调用 get
方法获取某个具体的实现类型,而不是更宽广的基类,比如这个例子中的 View
。将返回值类型限定为 TextView
是被允许的。
Covariant return type
代价是什么?1
2
3
4
5
6
7
8$ javap TextViewProvider
class TextViewProvider extends ViewProvider {
TextViewProvider();
public android.widget.TextView get(android.content.Context);
public android.view.View get(android.content.Context);
public java.lang.Object get(android.content.Context);
}
又出现一个方法。在当前的例子中,它既是一个泛型也是个 covariant 返回值类型。我们把原有的一个方法变成了仨,然而并没有做啥有意义的事。下面的 python 脚本可以用来检测:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73#!/usr/bin/python
import os
import subprocess
import sys
list = subprocess.check_output(["dex-method-list", sys.argv[1]])
class_info_by_name = {}
for item in list.split('\n'):
first_space = item.find(' ')
open_paren = item.find('(')
close_paren = item.find(')')
last_space = item.rfind(' ')
class_name = item[0:first_space]
method_name = item[first_space + 1:open_paren]
params = [param for param in item[open_paren + 1:close_paren].split(', ') if len(param) > 0]
return_type = item[last_space + 1:]
if last_space < close_paren:
return_type = 'void'
# print class_name, method_name, params, return_type
if class_name not in class_info_by_name:
class_info_by_name[class_name] = {}
class_info = class_info_by_name[class_name]
if method_name not in class_info:
class_info[method_name] = []
method_info_by_name = class_info[method_name]
method_info_by_name.append({
'params': params,
'return': return_type
})
count = 0
for class_name, class_info in class_info_by_name.items():
for method_name, method_info_by_name in class_info.items():
for method_info in method_info_by_name:
for other_method_info in method_info_by_name:
if method_info == other_method_info:
continue # Do not compare against self.
params = method_info['params']
other_params = other_method_info['params']
if len(params) != len(other_params):
continue # Do not compare different numbered parameter lists.
match = True
erased = False
for idx, param in enumerate(params):
other_param = other_params[idx]
if param != 'Object' and not param[0].islower() and other_param == 'Object':
erased = True
elif param != other_param:
match = False
return_type = method_info['return']
other_return_type = other_method_info['return']
if return_type != 'Object' and other_return_type == 'Object':
erased = True
elif return_type != other_return_type:
match = False
if match and erased:
count += 1
# print "FOUND! %s %s %s %s" % (class_name, method_name, params, return_type)
# print " %s %s %s %s" % (class_name, method_name, other_params, other_return_type)
print os.path.basename(sys.argv[1]) + '\t' + str(count)
想知道这个 case 在应用中有多么普遍,我们会像上面一样遍历所有 apk,耗时会有点长。1
2
3
4
5$ column -t -s $'\t' \
<(echo -e "NAME\tERASED" \
&& find apks -type f | \
xargs -L1 ./erased.py | \
sort -k2,2nr)
大约有小几千。这种情况我们能做的不多,使用 ProGuard 可以剔除那些没被使用的方法,但是如果你在泛型场景中调用过的方法,将不会被移除。
最后的一个关于方法的例子里,我想讲点未来会出现在 Android 开发中的 Java 8 feature。
1 | class Greeter { |
我们已经使用 retro-lamina 有段日子了。Jack compiler 也实现了类似的功能,为了向后兼容。那么这里的性能消耗和新的语言特性之间有什么关联吗?
接下来就看段代码来说。
1 | class Greeter { |
在 lambda 世界里,只是减少了冗余的代码。实际上还是会创建一个 Runnable
,并且隐含了很多内容。你不必再指定其类型,实现的方法名和参数名。
最后,方法引用。因为可以推断出此处不需要接收参数,也不用返回值,我们只要执行这个方法就好,所以可以自动将其转换成 Runnable。
1 | class Greeter { |
How much do these cost?
那么他们各自的性能消耗优势怎样?
1 | Retrolambda toolchain |
Jack’s ,这里没有使用 ProGuard ,也不开启 Jack’s 的 minification ,因为对结果没什么影响。当前这个匿名内部类的例子方法数是 2。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15Example$1 <init>(Greeter)
Example$1 run()
$ javap -c 'Example$1'
final class Example$1 implements java.lang.Runnable {
Example$1(Greeter);
Code: <removed>
public void run();
Code:
0: aload_0
1: getfield #1 // Field val$greeter:LGreeter;
4: invokevirtual #3 // Method Greeter.sayHi:()V
7: return
}
Retrolambda 的旧版本开销比较大,一小段代码就会多生成六七个方法,不过新版本中下降到 4 个。
相比之下,多出来两个方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15Example lambda$main$0(Greeter)
Example$$Lambda$1 <init>(Greeter)
Example$$Lambda$1 lambdaFactory$(Greeter) → Runnable
Example$$Lambda$1 run()
$ javap -c 'Example$$Lambda$1'
final class Example$$Lambda$1 implements java.lang.Runnable {
public void run();
Code:
0: aload_0
1: getfield #15 // Field arg$1:LGreeter;
4: invokestatic #21 // Method Example.lambda$main$0:
7: return
}
最上面的那个方法就是和 jack 不同的地方。当你用 lambda 定义一段代码时,要有个位置存放这段代码。它没有被编码在定义 lambda 的那段代码的方法内,那会很奇怪,因为他不属于那个方法。必须有一个位置保存这段代码,以便在你请求这段代码时调用。
它实际上将 和 runnable 实现的那个很像。run 方法和构造函数仍在,只不过 run 函数没有直接调用 Greeter,而是返回原始类调用那段 lambda 方法。
1 | Example lambda$main$0(Greeter) |
retrolambda 也没有直接使用生成类的构造函数来实例化,而是用一个静态工厂方法创建它。Jack 基本和他一样,只不过没有这个静态方法。
1 | Example -Example_lambda$1(Greeter) |
最上面就是需要额外生成的那个方法。
访问了一个私有方法的话 retrolambda 和 jack 就不得不生成额外方法,实际上这个方法是由 java 产生的,也就是第四个方法。
Jack 现存一个 bug,会为每一个单独的方法引用生成一个 accessor 方法,希望能够尽快修复。
Lambdas in the wild
统计 apk 中的情况1
2
3
4
5
6
7
8
9
10
11
12
!/bin/bash lambdas.sh
set -e
ALL=$(dex-method-list $1)
RL=$(echo "$ALL" | \grep ' lambda\$' | wc -l | xargs)
JACK=$(echo "$ALL" | \grep '_lambda\$' | wc -l | xargs)
NAME=$(basename $1)
echo -e "$NAME\t$RL\t$JACK"1
2
3
4
5
6
7
8
9
10
11
12
13 column -t -s $'\t' \
<(echo -e "NAME\tRETROLAMBDA\tJACK" \
&& find apks -type f | \
xargs -L1 ./lambdas.sh | \
sort -k2,2nr)
NAME RETROLAMBDA JACK
com.squareup.cash 826 0
com.robinhood.android 680 0
com.imdb.mobile 306 0
com.stackexchange.marvin 174 0
com.eventbrite.attendee 53 0
com.untappdllc.app 53 0
显然,使用 lambda 的人还比较少,使用 jack 编译 lambda 的人为 0,再或者他们使用了 ProGuard,lambda 的类名何方法被消除了。
我是因为 65K 方法数的限制才研究的这里,不过实际上这些方法对运行时也会造成影响。
Collections
接下来介绍的内容更偏向于运行时, collections 。1
2
3
4
5
6
7HashMap<K, V> ArrayMap<K, V>
HashSet<K, V> ArraySet<V>
HashMap<Integer, V> SparseArray<V>
HashMap<Integer, Boolean> SparseBooleanArray
HashMap<Integer, Integer> SparseIntArray
HashMap<Integer, Long> SparseLongArray
HashMap<Long, V> LongSparseArray<V>
这是一组对应的集合,相对于 HashMap , Android 提供的实现有着显著优势。
以前常听人说自动装箱,将原始类型转换成对象。Java 对于常用的数字有缓存,但是剩下的就需要每次使用时都重新创建一个对象,这会带来很大的性能消耗。
除了上面这点,还有数据寻址和数据的内存布局。
如果你看 HashMap 的源码,他用一个 array 来保存 Node。当你进行存取操作时,都要进入这个 array 执行一个 hash 操作。并且这个 Node array,Node 包含了 key,value,hash,next Node 的引用。想要获取 value 还要 进入 Node,再之后获取 value 的引用,跳转到 value,他们都在内存的不同区域。跳来跳去是不是很烦。
还有 hash 碰撞,要做的事更多了。Sparse array 就是用来替代 HashMap 的。在讲之前再说一个优势,内存布局。
下面是两个类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 java -jar jol-cli-0.5-full.jar internals java.util.HashMap
Running 64-bit HotSpot VM.
Objects are 8 bytes aligned.
java.util.HashMap object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00
4 4 (object header) 00 00 00 00
8 4 (object header) 9f 37 00 f8
12 4 Set AbstractMap.keySet null
16 4 Collection AbstractMap.values null
20 4 int HashMap.size 0
24 4 int HashMap.modCount 0
28 4 int HashMap.threshold 0
32 4 float HashMap.loadFactor 0.75
36 4 Node[] HashMap.table null
40 4 Set HashMap.entrySet null
44 4 (loss due to the next object alignment)
Instance size: 48 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
一个 HashMap 对象自己要用 48 byte,还不赖。
1 |
|
32 bytes. 数组里的每个元素都这么大,你想想乘上数组 size 要有多大。再然后,HashMap 这东西里边还有个叫负载因子的货,它确保 HashMap 不会被填爆,控制 array 增长,这就导致后边永远是空的。默认负载因子控制 array 只使用 75% 。
现在可以看下 Sparse array 的寻址和内存使用情况了。 Sparse array 同时保存两个数组,一个存 key,一个存 value。与 HashMap 不同,查找 key 时,进行的是二分搜索,直接返回 value 对应的 index,就可以拿到 value 了。数组是连续的,跳转少了很多,没有链表,不必要的东西都没了。但是二分搜索带来的是不稳定的时间复杂度,所以保持这个 map 在一个很小的 size 很重要,几百个差不多,上千的话还是 HashMap 更好。
1 | javac SparseArray.java |
JVM 64 bit 和 Android 64 bit 应该差不多,简单看下就好,领会精神。
对象大小其实无关紧要,重要的是内部元素。 key 和 value。SparseArray 虽然没有负载因子,但是这些数组是隐式的树,所以实际上也有空置的空间。这里只能将它模糊的描述为和负载因子差不多内存消耗。实际情况取决于你存储的数据形式。列出一个假想的内存消耗计算公式:
1 | SparseArray<V> |
一个实例,50 个元素,二分搜索超快1
2
3
4
5SparseArray<V>
32 + (4 * 50 + 4 * 50) / 0.75 = 656
HashMap<Integer, V>
48 + 32 * 50 + 4 * (50 / 0.75) + 8 = 1922
Conclusion
- 打开 private member inspection 选项
- RetroLambda 要用最新版本
- 试试 Jack (Ps. Jack已经挂了)
- 开启 ProGuard
- 不要使用 ProGuard 中的
* *
规则,100% 错误的用法
Res
- The Jack Toolchain
- Proguard
- Retrolambda
- Eliminating Code Overhead
- Dex Ed
- Memories of Android
- Jack generates extra method for method references
- Retrolambda generates extra method for lambdas / method references
- dex-method-list tool for showing methods in class/jar/aar/dex/apk
- Java Object Layout (“jol”) tool for showing memory cost of types
- accessors.sh, erased.py, lambdas.sh helper scripts
Ref
https://news.realm.io/news/360andev-jake-wharton-java-hidden-costs-android/