Android移动性能实战
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

第1章 磁盘:最容易被忽略的性能洼地

1.1 原理

在没有SSD硬盘之前,大家都会觉得我们的HDD硬盘很好用,什么5400转、7200转,广告都是棒棒的。直到有一天,SSD出现了,发现启动Windows的时候,居然可以秒开,这才幡然醒悟。因此,对于外行来说,磁盘I/O性能总是最容易被忽略的,精力会更集中在CPU上。但是对于内行人来说,大家都懂得,性能无非是CPU密集型和I/O密集型。磁盘I/O就是其中之一。那么到了移动时代,我们的存储芯片性能究竟怎样呢?在讨论这个问题之前,我们来看一个测试数据。

如图1-1所示,我们的顺序读/写的性能进步得非常快,很多新的机型,顺序读/写比起以前的性能,那是大幅度提升,跟SSD的差距已经缩小了很多。但是这里有个坏消息,随机读/写的性能依旧很差,见MOTO X、S7、iPhone 6S Plus。到这里,必须给大家介绍第一个概念:随机读/写。

图1-1

随机读/写

随机写无处不在,举两个简单例子吧。第一个例子最简单,数据库的journal文件会导致随机写。当写操作在数据库的db文件和journal文件中来回发生时,则会引发随机写。如表1-1所示,将一条数据简单地插入到test.db,监控pwrite64的接口,可以看到表中有底纹的地方都是随机写。第二个例子,如果向设置了AUTOINCREMENT(自动创建主键字段的值)的数据库表中插入多条数据,那么每插入一条数据,都需要操作两张数据库表,这就意味着存在随机写。

表1-1

从上面的例子可知,随机读/写是相对顺序读/写而言的,在读取或者写入的时候随机地产生offset。但为什么随机读/写会如此之慢呢?

1. 随机读会失去预读(read-ahead)的优化效果。

2. 随机写相对于顺序写除了产生大量的失效页面之外,更重要的是增加了触发“写入放大”效应的概率。

那么“写入放大”又是什么呢?下面我们来介绍第二个概念:“写入放大”效应。

“写入放大”效应

当数据第一次写入时,由于所有的颗粒都为已擦除状态,所以数据能够以页为最小单位直接写入进去。当有新的数据写入需要替换旧的数据时,主控制器将把新的数据写入到另外的空白闪存空间上(已擦除状态),然后更新逻辑LBA地址来指向到新的物理FTL地址。此时,旧的地址内容就变成了无效的数据,但主控制器并没执行擦除操作而是会标记对应的“页”为无效。当磁盘需要在上述无效区域进行再次写入的话,为了得到空闲空间,闪存必须先复制该“块”中所有的有效“页”到新的“块”里,并擦除旧“块”后,才能写入。(进一步学习,可参见:http://bbs.pceva.com.cn/forum.php?mod=viewthread&action=printable&tid=8277。)

比如,现在写入一个4KB的数据,最坏的情况就是,一个块里已经没有干净空间了,但是恰好有一个“页”的无效数据可以擦除,所以主控就把所有的数据读出来,擦除块,再加上这个4KB新数据写回去。回顾整个过程,其实只想写4KB的数据,结果造成了整个块(512KB)的写入操作。同时带来了原本只需要简单地写4KB的操作变成了“闪存读取(512KB)->缓存改(4KB)->闪存擦除(512KB)->闪存写入(512KB)”,这造成了延迟大大增加,速度慢是自然的。这就是所谓的“写入放大”(Write Amplification)问题,如图1-2所示。

图1-2

下面我们通过构造场景来验证写入放大效应的存在。

场景1:正常向SD卡写入1MB文件,统计文件写入的耗时。

场景2:先用6KB的小文件将SD卡写满,然后将写入的文件删除。这样就可以保证SD卡没有干净的数据块。这时再向SD卡写入1MB的文件,统计文件写入的耗时。

图1-3是分别在三星9100、三星9006以及三星9300上进行的测试数据,从测试数据看,在SD卡没有干净数据块的情况下,文件的写入耗时是正常写入耗时的1.9~6.5倍,因此测试结果可以很好地说明“写入放大”效应的存在。

图1-3

那么写入放大效应最容易是在什么时候出现呢?外因:手机长期使用,磁盘空间不足。内因:应用触发大量随机写。这时,磁盘I/O的耗时会产生剧烈的波动,App能做的只有一件事,即减少磁盘I/O的操作量,特别是主线程的操作量。那么如何发现、定位、解决这些磁盘I/O的性能问题呢?当然就要利用我们的工具了。

1.2 工具集

工具集如表1-2所示,后文分别进行介绍。

表1-2

· STRICTMODE

STRICTMODE应该是入门级必备工具了,可以发现并定位磁盘I/O问题中影响最大的主线程I/O。由如图1-4所示的代码可见,启用方法非常简单。

图1-4

原理也非常简单,主要是文件操作(BlockGuardOs.java)、数据库操作(SQLiteConnection. java)和SharePreferences操作(SharedPreferencesImpl.java)的接口中插入检查的代码。我们截取了一段Android源码中文件操作的监控实现代码,如图1-5所示,最后实际调用StrictMode中的onWriteToDisk方法,通过创建BlockGuardPolicyException来打印I/O调用的堆栈,帮助定位问题。

图1-5

详细代码: http://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/libcore/io/BlockGuardOs.java#91

· Perfbox:I/OMonitor

原理:I/OMonitor的功能可以归结为通过Hook Java层系统I/O的方法,收集区分进程和场景的I/O信息。

1. Hook java方法

I/O Monitor Hook java方法借鉴了开源项目xposed,网上介绍xposed的文章很多,这里就用如图1-6所示的流程图来简要说明获取此次I/O操作信息的方法。

图1-6

2. 区分进程和场景的I/O信息收集

区分进程和场景的I/O信息收集有以下4个步骤。

(1)app_process替换

app_process是Android中Java程序的入口,通过替换app_process就可以控制入口,在任何一个应用中运行我们的代码。替换后的app_process工作流程如图1-7所示。

图1-7

(2)将libfork.so添加到环境变量LD_PRELOAD中

在UNIX中,LD_PRELOAD是一个可以影响程序的运行时链接的环境变量,让你可以定义在程序运行前优先加载的动态链接库。而这个功能就可以用来有选择性地载入不同动态链接库中的相同函数。而在zygote进程启动前设置LD_PRELOAD环境变量,这样zygote的所有子进程都会继承这个环境变量。libfork.so实现了一个fork函数,当app_process通过fork函数来启动zygote进程时,会优先使用libfork.so中实现的fork函数,fork函数的流程如图1-8所示。

图1-8

(3)将XPlatform.jar添加到环境变量CLASSPATH中

将XPlatform.jar加入到CLASSPATH中,是为了可以让像common.jar这种插件型jar使用XPlatform.jar中的类。手机QQ中也存在类似事情,开发的同事把整个工程编译成了两个dex文件,在手机QQ启动后,把第二个dex文件放入CLASSPATH中(与XPlatform实现方法不同,但效果相同),这样主dex可以直接import并使用第二个dex中的类。如果不加入CLASSPATH,需要借助DexClassLoader类来使用另一个jar包中的类,这样使用起来很麻烦,并且会有很大的限制。

在系统启动过程中,app_process进程实际上是zygote进程的前身,所以XPlatform.jar是在zygote进程中运行的。

在XPlatform中主要Hook了两个java方法,来监控system_server进程和应用进程的启动,并在这些进程中做一些初始化的操作。这里面用了一个fork的特性,父进程使用fork创建子进程,子进程会继承父进程的所有变量,由于zygote使用fork创建子进程,所以在zygote进程中进行Hook,在它创建的任何一个应用进程和system_server进程也是生效的。

XPlatform工作流程图如图1-9所示。

图1-9

这样就实现了在应用进程启动时,控制在指定进程中运行I/O Monitor的功能。

(4)区分场景的I/O信息收集

为了实现分场景的I/O信息收集,我们给I/O Monitor添加了一个开关,对应的就是Python控制脚本,这样便可以实现指定场景的I/O信息收集,使测试结果做到更精准,如图1-10所示。

图1-10

这样我们就实现了区分进程和场景的I/O信息收集。

在介绍了我们的工具原理之后,来看一下采集的I/O日志信息,包括文件路径、进程、线程、读/写文件的次数、大小和耗时以及调用的堆栈,如图1-11所示。

图1-11

图1-9中的数据说明:某个文件的一次<open, close>对应CSV文件中的一行,每次调用系统的API(read或者write方法),读/写次数(readcount, writecount)就加1。读/写耗时(readtime, writetime)是计算open到close的时间。

· SQLite性能分析/监控工具SQL I/O Monitor

我们知道,数据库操作最终操作的是磁盘上的DB文件,DB文件和普通的文件本质上并无差异,而I/O系统的性能一直是计算机的瓶颈,所以优化数据库最终落脚点往往在如何减少磁盘I/O上。

无论是优化表结构、使用索引、增加缓存、调整page size等,最终的目的都是减少磁盘I/O,而这些都是我们常规的优化数据库的手段。习惯从分析业务特性、尝试优化策略到验证测试结果的正向思维,那么我们为何不能逆向一次?既然数据库优化的目的都是减少磁盘I/O,那我们能不能直接从磁盘I/O数据出发,看会不会有意想不到的收获。

1. 采集数据库I/O数据

要想实现我们的想法,第一步当然要采集数据库操作过程中对应的磁盘I/O数据。由于之前通过Java Hook技术,获取到了Java层的I/O操作数据,虽然SQLite的I/O操作在libsqlite.so进行,属于Native层,但我们会很自然地想到通过Native Hook采集SQLite的I/O数据。

Native Hook主要有以下实现方式。

(1)修改环境变量LD_PRELOAD。

(2)修改sys_call_table。

(3)修改寄存器。

(4)修改GOT表。

(5)Inline Hook。

下面主要介绍(1)、(4)、(5)三种实现方式。

(1)修改环境变量LD_PRELOAD

这种方式实现最简单,重写系统函数open、read、write和close,将so库放进环境变量LD_PRELOAD中,这样程序在调用系统函数时,会先去环境变量里面找,这样就会调用重写的系统函数。可以参考看雪论坛的文章“Android使用LD_PRELOAD进行Hook”(http://bbs.pediy.com/showthread.php? t=185693)。

但是这种Hook针对整个系统生效,即系统所有I/O操作都被Hook,造成Hook的数据量巨大,系统动不动就卡死。

(4)修改GOT表

引用外部函数的时候,在编译时会将外部函数的地址以Stub的形式存放在.GOT表中,加载时linker再进行重定位,即将真实的外部函数写到此stub中。Hook的思路就是替换.GOT表中的外部函数地址。而libsqlite.so中的I/O操作是调用libc.so中的系统函数进行,所以修改GOT表的Hook方案是可行的。

然而现实总不是一帆风顺的,当我们的方案实现后,发现只能记录到libsqlite.so中的open和close函数调用,而由于sqlite的内部机制而导致的read/write调用我们无法记录到。

(5)Inline Hook

在前两种方案无果后,只能尝试Inline Hook。Inline Hook可以Hook so库的内部函数,我们首先想到的是Hook libsqlite.so内部I/O接口posixOpen、seekandread、seekandwrite以及robust_close。但是在成功的路上总是充满波折,sqlite内部竟然将大部分的关键函数定义为static函数,如posixOpen。在C语言中,static函数是不导出符号的,而Inline Hook就是要在符号表中找到对应的函数位置。这样一来,通过Hook sqlite内部函数的路子又行不通了。

    static int posixOpen(const char *zFile, int flags, int mode){
      return open(zFile, flags, mode);
    }

既然这样不行,那我们只能更暴力地Hook libc.so中的open、read、write和close方法。因为不管sqlite里面怎么改,最终还是会调用系统函数,唯一不好的是这样录到了该进程所有的IO数据。这种方法在自己编译的libsqlite.so里面证实是可行的。

正当我满怀欣喜地去调用手机自带的libsqlite.so库时,读/写数据再一次没有被记录到,我当时的内心几乎是崩溃的。为什么我自己编译的libsqlite.so库可以,用手机上的就不行呢?没办法,只能再去看如图1-12所示的源码,最后在seekAndRead里面发现,sqlite定义了很多宏开关,可以决定调用系统函数pread、pread64以及read来进行读文件。莫非我自己编的so和手机里面的so的编译方式不一样?

图1-12

笔者又Hook了pread和pread64,这一次终于记录到了完整的I/O数据,原来手机里面的libsqlite.so调用系统的pread64和pwrite64函数来进行I/O操作,同时通过Inline Hook获取到了数据库读/写磁盘时page的类型,sqlite的page类型有表叶子页、表内部页、索引叶子页、索引内部页以及溢出页,采集的数据库日志信息如图1-13所示。

图1-13

费尽了千辛万苦,终于拿到了数据库读/写磁盘的信息,但是这些信息有什么用呢?我们能想到可以有以下用途。

· 通过I/O数据的量直观地验证数据库优化效果。

· 通过偏移量找出随机读/写进行优化。

但是我们又面临另外一个问题,因为获取的磁盘信息是基于DB文件的,而应用层操作数据库是基于表的,同时又缺乏堆栈,很难定位问题。基于此,我们又想到了另外一个解决方法,就是Hook应用代码的数据库操作,通过堆栈把两者对应起来,这样就可以把应用代码联系起来,更方便分析问题。

2. Hook应用层SQL操作

Hook应用代码其实就是Hook SQLiteDatabase里面的数据库增删改查操作,应用代码SQL语句如图1-14所示,Java层Hook基于Xposed的方案实现。最终可以通过堆栈和磁盘信息对应起来,如图1-15所示。

图1-14

图1-15

获取到了这么多数据,我们在后面数据库相关的案例中可以看一下如何应用。

1.3 案例A:手机QQ启动有10次重复读写/proc/cpuinfo

问题类型:冗余读/写

解决策略:缓存

案例分析:通过I/O信息可以发现/proc/cpuinfo被读了10次,且前9次的堆栈完全一样,说明前9次是同一个方法调用的,所以可以在获取cpuinfo的方法中,将读取的信息保存起来,下次再调用时,就不用再去文件中读取了,如图1-16所示。

图1-16

解决方案:

从代码中可以看出,开发的同事用静态数据成员将第一次读取的信息保存起来,后面就不需要读这些信息了,优化后,该文件的读操作由10次降为2次,如图1-17所示

图1-17

我们知道每次打开、关闭或者读/写文件,操作系统都需要从用户态到内核态的切换,这种状态的切换本身是很消耗性能的,所以为了提高文件的读/写效率,就需要尽量减少用户态和内核态的切换。使用缓存可以避免重复读/写,对于需要多次访问的数据,在第一次取出数据时,将数据放到缓存中,下次再访问这些数据时,就可以从缓存中取出来。

1.4 案例B:对于系统API,只知其一造成重复写入

问题类型:冗余读/写

解决策略:延迟写入

案例分析:Android系统中使用SharedPreferences文件来保存数据非常方便,在需要保存数据的地方调用commit就可以,但是很多开发同学可能并不知道每调用一次commit(),就会对应一次文件的打开和关闭,从而造成因commit()方法的随意调用而导致文件的重复打开和关闭,Android源码如图1-18所示。

图1-18

手机QQ就出现过这样的案例,从I/O Monitor获取的数据可以看出,safe_mode_com. qzone.xml文件被写入了两次,如图1-19所示。

图1-19

通过堆栈找到源代码,可以看出在同一个方法中连续使用commit()方法,从而造成safe_mode_com.qzone.xml被打开了两次,如图1-20所示。

图1-20

解决方案:只需要保留最好的一个commit方法即可。跟上面的道理差不多,也可以使用缓存来保存多次写入的数据,延迟写入,从而减少写入次数。

1.5 案例C:手机QQ启动场景下主线程写文件

问题类型:主线程读/写

解决策略:移到子线程

案例分析:从I/O信息中,可以看出该文件是在主线程进行写操作的。我们需要避免在主线程进行I/O操作,尤其是写操作。因为写入放大效应有时会让平时十多毫秒的操作放大几十倍,因此需要把该I/O操作放到如下的子线程中操作。

解决方案:将主线程的I/O操作移到非主线程,问题得到解决,如图1-21所示。

图1-21

众所周知,Android的UI操作在主线程进行操作,主线程耗时越少,因此UI界面的生成可以更快,所以尽量减少在主线程的操作,上文StrictMode中主线程I/O的规则也从另外一个方面印证了这点。然而事情并非那么简单,大家要有更深层次的思考。如果I/O本身跟要展示的关键内容非常相关,那么改子线程即改善了交互类性能中的流畅度,俗称不“卡”了。但是默认的子线程的线程优先级并不高,I/O操作会变得更慢,而I/O的内容又是界面的核心内容,那么就彻底变成了“慢”的问题,例如后面第2部分中响应时延相关章节提到的白屏、黑屏。所以将I/O放到子线程是第一步,更重要的是如后面的案例一样,怎么真正地减少I/O,甚至避免I/O。

1.6 案例D:Object Output Stream 4000多次的写操作

问题类型:I/O效率低

解决策略:合理使用ByteArrayOutputStream

案例分析:手机QQ“附近的人”的功能中,大小为16KB的文件在序列化磁盘时(如图1-22所示),因为使用了ObjectOutputStream(),导致写次数达到了4000+次(如图1-23所示)。有人会有疑问,ObjectOutputStream()到底是怎么工作的?这需要从源码里来寻找答案。

图1-22

图1-23

由图1-24的源码可以看出,ObjectOutputStream在序列化磁盘时,会把内存中的每个对象保存到磁盘,在保存对象的时候,每个数据成员会带来一次I/O操作,也就是为什么16KB的文件会有4000次I/O的缘故。

图1-24

解决方案:在ObjectOutputStream上面再封装一个输出流ByteArrayOutputStream,先将对象序列化后的信息写到缓存区中,然后再一次性地写到磁盘上,如图1-25所示。

图1-25

实验室:寻找序列化最佳的实践

问题:Android QQ在序列化读/写磁盘时,存在直接使用ObjectInputStream和ObjectOutputStream来读/写磁盘而导致磁盘I/O次数过多的情况,对于一个几十KB的文件,写次数达1000多次,频繁地写入势必严重影响App性能。

解决方案:可以通过使用缓冲区,有效减少磁盘I/O的次数,推荐如表1-3所示的方式来序列化磁盘。

表1-3

对相同的内容,通过不同的方式序列化到磁盘,磁盘的I/O次数和耗时对比如表1-4所示,由表可以看出,使用推荐的方式写耗时减少46%,读耗时减少36%,对于I/O任务频繁的App来说,这个效果会更明显。

表1-4

1.7 案例E:手机QQ“健康中心”使用的Buffer太小

问题类型:I/O效率低

解决策略:合理地设置Buffer的大小

案例分析:在“健康中心”通过计算文件的md5值来验证文件安全性的业务时,从I/O信息可以得到,OfflineSecurity()方法读取了100多个文件,如图1-26所示。拿第一个文件来分析,大概17KB的文件被读了18次,可以得出该方法在读取文件时使用了1KB的Buffer,从如图1-27所示的代码中看也确实如此。

图1-26

图1-27

解决方案:从如图1-28所示的代码中看,开发的同事最终使用了4KB的Buffer来提高读/写效率。

图1-28

在读/写时使用缓冲区可以减少读写次数,从而减少了切换内核态的次数,提高读/写效率,根据实际经验,这里推荐使用的Buffer大小为8KB,这和Java默认的Buffer大小一致,Buffer大小至少应为4KB。当然,Buffer也不是越大越好,Buffer如果太大,会导致申请Buffer的时间变长,反而整体效率不高。从上文看出,I/O Monitor可以获取到读/写的大小和次数,其中读/写次数就是调用系统API的次数,所以读/写大小除以读/写次数可以得到Buffer的大小,如果Buffer太小就会存在问题,这样一目了然。这里其实还有一种更智能地确定Buffer大小的方法。这个方法由两个影响因子决定,一是Buffer size不能大于文件大小;二是Buffer size根据文件保存所挂载的目录的block size来确认Buffer大小,而数据库的pagesize,就是这样确定的,具体可见Android源码中SQLiteGlobal.java的getDefaultPageSize()。

1.8 案例F:手机QQ解压文件使用的Buffer太小

问题类型:I/O效率低

解决策略:使用BufferedOutputStream

案例分析:在手机里面,发现一处I/O效率不高的Bug,10MB的文件要写磁盘22000次,如图1-29所示,计算下来每次写磁盘只有496个字节,这里是不是有和上一案例同属于Buffer设置太小的问题呢?

图1-29

我们找到对应的代码,如图1-30所示,看到开发人员这里竟然用的是20KB的Buffer,为什么最终写磁盘时Bufter只有496个字节呢?要想知道答案,还得通过我们的“实验室”去看看系统的源码。

图1-30

实验室:寻找压缩文件的最佳实践

从Android的源码看到,Android压缩文件提供了两个API,分别是ZipFile和ZipOutpurStream,要想弄清楚这两个API的区别,我们还是从ZIP的文件结构说起。

Zip文件结构

ZIP文件结构如图1-31所示, File Entry表示一个文件实体,一个压缩文件中有多个文件实体。文件实体由一个头部和文件数据组组成,Central Directory由多个File header组成,每个File header都保存了一个文件实体的偏移。

图1-31

(1)Local File Header(本地文件夹)

本地文件头偏移的具体描述如表1-5所示。文件的最后到End of central directory结束。

表1-5

(2)Data descriptor(数据描述符)

当头部标志第3位(掩码0×08)置位时,表示CRC-32校验位和压缩后大小在File Entry结构的尾部增加一个Data descriptor来记录。数据描述符偏移的具体描述如表1-6所示。

表1-6

(3)Central Directory是什么

中央目录文件夹(Central Directory File Header)偏移的具体描述如表1-7所示。

表1-7

(4)End of Central Directory record(年底中央目录记录)所有的File Header结束后面是该数据结构,其偏移的描述如表1-18所示。

表1-18

问题1:Central Directory的作用

通过Central Directory可以快速获取ZIP包含的文件列表,而不用逐个扫描文件,虽然Central Directory的内容和文件原来的头文件有冗余,但是当ZIP文件被追加到其他文件时,就只能通过Central Directory获取ZIP信息,而不能通过扫描文件的方式,因为Central Directory可能声明一些文件被删除或者已经更新。Central Directory中Entry的顺序可以和文件的实际顺序不一样。

问题2:ZIP如何更新文件?

举例说明:一个ZIP包含A、B和C三个文件,现在准备删除文件B,并且对C进行了更新,可以将新的文件C添加到原来ZIP的后面,同时添加一个新的Central Directory,仅仅包含文件A和新文件C,这样就实现了删除文件B和更新文件C。

在ZIP设计之初,通过软盘来移动文件很常见,但是读/写磁盘是很消耗性能的,对于一个很大的ZIP文件,只想更新几个小文件,如果采用这种方式效率非常低。

ZIP文件解压

Android提供两种解压ZIP文件的方法:ZipInputStream和ZipFile。

(1)ZipInputStream

ZipInputStream通过流式来顺序访问ZIP,当读到某个文件结尾时(Entry)返回-1,通过getNextEntry来判断是否要继续往下读, ZipInputStream read方法的流程图如图1-32所示。

图1-32

问题3:为什么要判断是否是压缩文件?

因为文件在添加到ZIP时,可以通过设置Entry.setMethod(ZipEntry.STORED)以非压缩的形式添加到文件中,所以在解压时,对于这种情况,可以直接读文件返回,不需要解压。

这里重点介绍一下InflaterInputStream.read()方法,其流程图如1-33所示。

图1-33

从图1-33的流程图可以看出,Java层将待解压的数据通过我们定义的Buffer传入Native层。每次传入的数据大小是固定值,为512字节,在InflaterInputStream.java中定义如下:

static final int BUF_SIZE = 512;

对于压缩文件来说,最终会调用zlib中的inflate.c来解压文件,inflate.c通过状态机来对文件进行解压,将解压后的数据再通过Buffer返回。对inflate解压算法感兴趣的读者可以看源码,传送门http://androidxref.com/4.4.4_r1/xref/external/zlib/src/inflate.c,返回count字节并不等于Buffer的大小,取决于inflate解压返回的数据。

(2)ZipFile

ZipFile通过RandomAccessFile随机访问ZIP文件,通过Central Directory得到ZIP中所有的Entry, Entry中包含文件的开始位置和size,前期读Central Directory可能会耗费一些时间,但是后面就可以利用RandomAccessFile的特性,每次读入更多的数据来提高解压效率。

ZipFile中定义了两个类,分别是RAFStream和ZipInflaterInputStream,这两个类分别继承自RandomAccessFile和InflateInputStream,通过getInputStream()返回,ZipFile的解压流程和ZipInputStream类似。

ZipFile和ZipInputStream真正不同的地方在于InflaterInputStream.fill(), fill源码如图1-34所示。

图1-34

InflaterInputStream.read()的流程图如1-35所示,读者就能明白两者的区别之处。

从流程图可以看出,ZipFile的读文件是在Native层进行的,每次读文件的大小是由Java层传入的,定义如下。

Math.max(1024, (int) Math.min(entry.getSize(), 65535L));

即ZipFile每次处理的数据大小在1KB和64KB之间,如果文件大小介于两者之间,则可以一次将文件处理完。而对于ZipInputStream来说,每次能处理的数据只能是512个字节,所以ZipFile的解压效率更高。

图1-35

3. ZipFile vs ZipInputStream效率对比

解压文件可以分为如下三步:

(1)从磁盘读出ZIP文件;

(2)调用inflate解压出数据;

(3)存储解压后的数据。

因此两者的效率对比可以细化到这三个步骤来进行对比。

(1)读磁盘

ZipFile在Native层读文件,并且每次读的数据在1~64KB之间, ZipInputStream只有采用更大的Buffer才可能达到ZipFile的性能。

(2)infalte解压效率

从上文可知,inflate每次解压的数据是不定的,一方面和inflate的解压算法有关,另一方面取决于Native层infalte.c每次处理的数据,从以上分析可知,ZipInputStream每次只传递512字节数据到Native层,而ZipFile每次传递的数据可以在1KB~64KB,所以ZipFile的解压效率更高。从java_util_zip_Inflater.cpp源码看,这是Android做的特别优化。

demo验证(关键代码)

ZipInputStream关键代码,如图1-36所示。

图1-36

ZipFile关键代码,如图1-37所示。

图1-37

我们用两个不同压缩率的文件对demo进行测试,文件说明如表1-9所示。

表1-9

测试数据,如表1-10所示。

表1-10

结论:①ZipFile的read调用的次数减少39%~93%,可以看出ZipFile的解压效率更高。

②ZipFile解压文件耗时,相比ZipInputStream有22%~73%的减少。

(3)存储解压后的数据

从上文可以知道,inflate解压后返回的数据可能会小于Buffer的长度,如果每次在读返回后就直接写文件,此时Buffer可能并没有充满,造成Buffer的利用效率不高,此处可以考虑将解压出的数据输出到BufferedOutputStream,等Buffer满后再写入文件,这样做的弊端是,因为要凑满Buffer,会导致read的调用次数增加,下面就对ZipFile和ZipInputstream做一个对比。

demo(关键代码)

ZipInputStream的关键代码如图1-38所示。

图1-38

ZipFile的关键代码如图1-39所示。

图1-39

同样对上面的两个压缩文件进行解压,测试数据如表1-11所示。

表1-11

结论:①ZipFile与ZipInputStream相比,耗时仍减少15%~22%。

②与不使用Buffer相比,ZipInputStream的耗时减少14%~62%, ZipFile解压低压缩率文件耗时有6%的减少,但是对于高压缩率,耗时将有9%的增加(虽然减少了写磁盘的次数,但是为了凑满Buffer,增加了read的调用次数,导致整体耗时增加)。

问题4:那么问题来了,既然ZipFile效率这么好,那么ZipInputStream还有存在的价值吗?

千万别被数据迷惑了双眼,上面的测试仅仅覆盖了一种场景,即文件已经在磁盘中存在,且须全部解压出ZIP中的文件,如果你的场景符合以上两点,使用ZipFile无疑是正确的。同时,也可以利用ZipFile的随机访问能力,实现解压ZIP中间的某几个文件。

但是在以下场景,ZipFile则会略显无力,这时ZipInputStream的价值就体现出来了。

①当文件不在磁盘上,比如从网络接收的数据,想边接收边解压,因ZipInputStream是顺序按流的方式读取文件,这种场景实现起来毫无压力。

②如果顺序解压ZIP前面的一小部分文件, ZipFile也不是最佳选择,因为ZipFile读Central Directory会带来额外的耗时。

③如果ZIP中的Central Directory遭到损坏,只能通过ZipInputStream来按顺序解压。

(4)结论

· 如果ZIP文件已保存在磁盘中,且解压ZIP中的所有文件,建议用ZipFile,效率较ZipInputStream提升15%~27%。

· 仅解压ZIP中间的某些文件,建议用ZipFile。

· 如果ZIP没有在磁盘上或者顺序解压一小部分文件,又或ZIP文件目录遭到损坏,建议用ZipInputStream。

从以上分析和验证可以看出,同一种解压方法使用的方式不同,效率也会相差甚远,最后再回顾一下ZipInputStream和ZipFile最高效的用法(有底纹的代码为关键部分),如图1-40所示。

图1-40

1.9 案例G:刚创建好表,就做大量的查询操作

问题类型:冗余读/写

解决策略:利用INSERT OR REPLACE

问题:

通过获取手机QQ首次启动的I/O数据,看到大量对848688603.db的读操作,且每次读的大小是16字节,如图1-41所示。找到对应的SQL语句,其对应的是大量的SELECT* FROM ExtensionInfo WHERE uin=?和SELECT * FROM Friends WHERE uin=?语句,select语句耗时超过6秒,如图1-42所示。

图1-41

图1-42

分析:

每次读的字节数只有16字节,且偏移都一样,说明Friends和ExtensionInfo里面并没有内容。原来在Friends和ExtensionInfo创建完之后,在插入好友信息前,需要先去表里查询一下是否存在该记录。此时表是空的,所以才有大量的16字节的读取,如图1-43所示。

图1-43

解决方案:

(1)首次安装的表为空时,不要去做无谓的查询操作。

(2)对于覆盖安装,在表已经存在的情况下,可以使用INSERT OR REPLACE语句来完成插入。

1.10 案例H:重复打开数据库

问题类型:重复打开数据库

解决策略:缓存数据库连接

问题:

在使用数据库测试工具在统计手机QQ启动过程中各个DB打开次数时,发现多个业务打开数据库的次数不止一次,而最多的竟然打开数据库424次(如图1-44所示),简直骇人听闻。

图1-44

分析:

多次打开数据库有什么影响?

先看一下SQLiteDatabase的源码,getWriteableDatabase()方法的注释说明:一旦打开数据库,该连接就会被缓存,以供下次使用,只有当真正不需要时,调用close关闭即可,如图1-45所示。

图1-45

为什么要这样呢?

因为打开数据库比较耗时,如app_plugin_download.db的两次耗时分别为80ms和120ms。每次打开数据库,同时会有一些I/O操作。getWriteableDatabase的注释也明确说明该方法比较耗时,不能在主线程进行。

解决方案:

数据库在打开后,先不要关闭,在应用程序退出时再关闭。

1.11 案例I:AUTOINCREMENT可没有你想的那么简单

问题类型:冗余读/写

解决策略:减少使用AUTOINCREMENT

背景:

最近在分析手空(Android)的数据库读写时,发现有一条插入语句耗时平均在60ms+, SQL语句为:INSERT INTO events(timestamp, content, status, send_count) VALUES (1445916309639, test, 1 ,100),

可以看到这条插入语句非常简单,仅仅是插入3个整形和一个简单的字符串。而一般的插入操作最多也就十几ms,所以这个问题值得我们好好研究一下。

索引惹的祸

首先我们拿到创建这个表的SQL语句,见如下的代码,以及对应的events表结构,如图1-46所示,这个表结构除了创建status为索引外,似乎并无特殊之处。

create table if not exists events(event_id INTEGER PRIMARY KEY AUTOINCREMENT
NOT NULL, content TEXT, status INTEGER, send_count INTEGER, timestamp LONG)
CREATE INDEX if not exists status_idx ON events(status)

图1-46

问题1:难道是索引导致插入这么耗时吗?

作为常识:索引是为了提高查询的速度,但在数据库插入操作时,因为要维护索引,会使插入效率有所降低。但是真的会降低这么多吗?还是要通过数据来说话。

这个表是MTA用来存储上报记录的,我们找到了负责该表的同事,给了一个没有索引的SDK,再次编包验证。事实证明,索引对插入速度的影响是很有限的,一条语句简单的插入操作竟然要55ms,如表1-12所示。

表1-12

这下就奇怪了,除了索引,这个表也没有特别的地方了。因此我们决定采用排除法,把该表的特性一点点去掉,看到底是谁在搞鬼。

AUTOINCREMENT漏网之鱼

接下来我们把AUTOINCREMENT关键字去掉,测试同样的表结构,测试结果让我们大吃一惊。有AUTOINCREMENT的INSERT耗时是不含该关键字耗时的3倍,如表1-13所示。

表1-13

为了保证数据严谨,又分别测试了使用事务对1条语句和50条语句进行插入操作的耗时,结果表明对于批量插入,两者的差距有所减少,但是仍差2倍之多,如表1-14所示。

表1-14

到这里,用过SQLite的读者,可能会对这个结果觉得难以置信,因为AUTOINCREMENT关键字在SQLite里面很常用,大家用的时候似乎也没有担心效率问题。接下来要弄明白仅多一个AUTOINCREMENT为什么会有这么大的差别。

问题2:AUTOINCREMENT是什么?

AUTOINCREMENT其实就是“自增长”,这个关键字只会出现在INTEGER PRIMARY KEY后面,而INTEGER PRIMARY KEY就是“主键”,下面先来了解一下主键。

SQLite表的每行都有一个行号,行号用64位带有符号的整型数据表示。SQLite支持使用默认的列名ROWID、_ROWID_和OID来访问行号。同时,如果表里某一列指定为INTEGER PRIMARY KEY类型,那么这一列和ROWID是等价的。也就是说,如果你指定某一列为主键,访问该列其实就是访问行号。

问题3:行号是如何生成的?

对于刚创建的表来说,行号默认是从1开始的,如果在插入数据时明确指定行号,则会将数据插入对应的行,如果没有指定行号,则SQLite会选择比当前已用行号大1的行来进行插入。如果当前已用行号已达到最大值,数据库引擎会尝试寻找当前表里面没有使用的行号,如果没有找到可用的行号,就会出现SQLITE_FULL错误。

小结:如果你没有删除过数据,并且没有指定最大值的行号,行号选择算法可以保证行号是递增且唯一的。但是如果你有删除数据或者使用了最大行号,之前删除的行号可能被复用,并不能保证插入数据的行号是严格递增的。

问题4:主键加了AUTOINCREMENT,会有什么变化?

上面提到,AUTOINCREMENT只能用来修饰主键,主键在被“自增长”修饰之后,会略微有些区别。

(1)数据库引擎选择的行号会比所有之前用过的行号都大,即使数据被删除,行号也不会被复用,可以保证行号严格单调递增。

(2)如果行号的最大值被用过,那么在插入新数据时,会报SQLITE_FULL错误。

小结:AUTOINCREMENT的作用是保证主键是严格单调递增的。

AUTOINCREMENT实现原理

SQLite创建一个叫sqlite_sequence的内部表来记录该表使用的最大的行号。如果指定使用AUTOINCREMENT来创建表,则sqlite_sequence也随之创建。UPDATE、INSERT和DELETE语句可能会修改sqlite_sequence的内容。因为维护sqlite_sequence表带来的额外开销将会导致INSERT的效率降低。

使用数据库测试工具,可以获取到两种情况下磁盘的读/写数据,如图1-47所示。从中可以看出,AUTOINCREMENT会使写磁盘次数由2次增加到11次。这也能很好地说明,由于要维护sqlite_sequence而增加额外的I/O开销。

图1-47

AUTOINCREMENT的坑

在主键加上AUTOINCREMENT后,可以保证主键是严格递增的,但是并不能保证每次都加1,因为在插入失败后,失败的行号不会被复用,这就造成主键会有间隔。以手机QQ为例子,有80%的数据库表使用了AUTOINCREMENT关键字。coco尝试去掉创建表时的AUTOINCREMENT,对比相同场景相同时间序列的事务耗时,可以看到优化后,事务耗时比之前有所减少,如图1-48所示。

图1-48

同时对比手机QQ的启动耗时,发现LoginA和首次启动的耗时都略微有一些下降,同时CPU和内存也有一些降低,优化前后手机QQ时延性能对比,如表1-15所示。

表1-15

总结:

AUTOINCREMENT可以保证主键的严格递增,但使用AUTOINCREMENT会增加INSERT耗时1倍以上,所以使用AUTOINCREMENT时不可以任性,用在该用的地方效果才佳。比如,客户端需要拿该主键和服务器校对数据,需要保证主键唯一性。

最后以SQLite官网的一句话作为结尾:

这个AUTOINCREMENT关键词会增加CPU,内存,磁盘空间和磁盘I/O的负担,所以尽量不要用,除非必需。其实通常情况下都不是必需的。

1.12 案例J:Bitmap解码,Google没有告诉你的方面

问题类型:I/O效率低

解决策略:使用decodeStream代替decodeFile

随着Android SDK的升级,Google修改了Bitmap解码API的实现,从而埋下了一个性能的坑。先把这个坑说出来,后面再详细介绍发现和解决过程。

· 解码Bitmap不要使用decodeFile,因为在Android 4.4以上系统效率不高。

· 解码Bitmap使用decodeStream,同时传入的文件流为BufferedInputStream。

· decodeResource同样存在性能问题,请用decodeResourceStream。

背景:

最近用I/O监控工具检测Android QQ的性能时,提了41个读/写磁盘Buffer太小的Bug单(我们认为读/写磁盘如果Buffer小于8KB,会导致I/O效率不高),如图1-49所示。

图1-49

图1-49中红框出部分的含义:aio_static_50.png大小1702字节,需要读取磁盘215次。查看堆栈,发现都是已经存在很久的代码,令人不解的是,之前的版本也一直都有I/O性能检测,为什么现在才提单呢?

分析:

(1)验证数据准确性

看到这个数据,首先想到SDK是否因为最近修改引入了Bug,不过通过demo验证,数据是可信的。

(2)为什么之前没有提单

我们已经用I/O工具检测手机多个版本,之前为什么没有提单?对比之下,发现之前用的手机一直都是三星9300(Android 4.3系统),这次换成Nexus 5(Android 4.4系统),难道这和系统版本有关系吗?

我们用一个demo在两台手机上验证,如表1-16所示的结果令人出乎意料。

表1-16

在两个手机上decode同一张图片,读磁盘次数相差巨大,这时我们可以确定这个问题是和系统版本有关的。

追根溯源——decodeFile()你用过吗?

为了能够一探究竟,最好的方法是对比两个版本API的实现有何不同。

通过源码看到,BitmapFactory.java提供多个decode Bitmap的API,有decodeFile()、decodeResource()、decodeByteArray()、decodeFileDescriptor()、decodeStream()、decodeResourceStream()。而大家最常用的是decodeFile(),前面提的Bug单也都是用的这个API。我们来对比一下这个API在Android 4.3和Android 4.4的实现差异。

图1-50是Android 4.3 decodeFile()的实现流程图,看到最终读磁盘用的是BufferedInputStream,并且Buffer大小为DECODE_BUFFER_SIZE = 16×1024,这也就是为什么在4.3系统decode大小为12KB的图片,只需要读一次磁盘就可以的原因。

图1-50

我们再看一下如图1-51所示的Android 4.4的实现流程图。

图1-51

从图1-51看到,4.4系统去掉了isMarkSupported的判断,而是直接调用nativeDecodeStream,这就导致Native在decode图片时,每次都要实际去读磁盘,故导致读次数增加很多。

解决方案:

通过上面的分析,我们知道决定写磁盘次数的是:传给nativeDecodeStream的文件流是否使用了Buffer,而在4.4系统上,如果使用decodeFile,生成的文件流只能是FileInputStream,这是无法修改的。但是如果我们直接调用decodeStream,是可以传递带Buffer的文件流进来的,所以解决方法是:不要使用decodeFile,而改用decodeStream,并且传入BufferedInputStream类型的文件流,如1-52所示。

图1-52

对修改方案进行demo验证的数据如表1-17所示,在三星9300(Android 4.3)上, decodeFile和decodeStream的耗时几乎一致,而在MI2S(Android 5.0)上,decodeStream的速度是decodeFile的3倍之多。

表1-17

结论

(1)解码Bitmap要使用decodeStream,不要使用decodeFile,同时传给decodeStream的文件流是BufferedInputStream。

(2)decodeResource同样存在这个问题,建议使用decodeResourceStream。

专项标准:磁盘,如表1-18所示。

1.13 专项标准:磁盘

表1-18