作者:郑老师,华清远见嵌入式学院讲师。
本文主要介绍Android下Java编程与传统Java编程的一些区别,提出了在Android下Java编程为了性能和功耗应该遵循的一些原则。这一章很多观点都来自于官方Android开发者指南,但是开发者指南里重点告诉我们的是应该怎么做,作为一个应用开发者这其实已经足够,如果要深层次的理解这些原则,还是要关注一些Android底层的东西。
1、Android下Java编程性能优化介绍
本文使用华清远见FS2416平台。FS2416使用Socket网络设备驱动和字符设备驱动两种方式向Linux内核提供MCP2515的驱动,上篇文章介绍了使用Socket方式设计的基于MCP2515的Linux CAN总线驱动程序,这篇文章主要介绍编写一个MCP2515的字符设备驱动。
使用Android平台的设备一般为移动设备,其运算能力、存储空间、电池容量都比较有限。所以对于Android应用程序来说,为保证顺畅的运行,其必须是高效节能的。其中,电池续航能力是迫使你必须优化程序的关键,因为Android设备一般耗电量都比较快,即使你的应用程序运行已经很快,但是耗电量巨大的话,用户迟早会发现这一点而抛弃你的程序应用。要做到应用程序的优化,有以下两个基本的原则:
不要做不必要做的事情。
尽可能的节省内存的使用。
我们在这一章中介绍的所有优化方法都是基于这两个原则的。为什么这两条原则这么重要呢?因为Android应用程序的成败关键在于是否有好的用户体验,如果你的程序不顺畅或者响应时间很慢,那么这个应用程序必然不算成功。如果遵守这两点原则,那么应用程序在设备里就会相对顺畅。但是这是相对的,影响应用程序性能的因素还有很多,甚至包括设备上的其他应用程序。所以最好是所有应用程序开发者都遵守这两条原则,避免运行时应用程序的“撞车。”
这就是为什么上面两条原则这么重要。Android的成功在于开发程序提供给用户的体验,然而用户体验的好坏又决定于你的代码是否能及时的响应而不至于慢的让人崩溃。因为我们所有的程序都会在同一个设备上面运行,所以我们把它们作为一个整体来考虑。本文就像你考驾照需要学习的交通规则一样:如果所有人遵守,事情就会很流畅;但当你不遵守时,你就会撞车。
虽然这两点原则和我们在后面要给大家介绍的优化方法会对应用程序的优化起到一定作用,但是这无法成为你应用程序成败的关键。选择高效的适合的算法和数据结构是程序的基础,是要在性能优化之前考虑的事情。我们要说的只是优化。
在正式进行优化方法之前,要说明的是,无论是对于虚拟机还是Java编译器,这些方法都是正确的。我们把这些方法归为两类,分别为提升性能的优化方法和编程中注意避免的事项。我们可以看作是TO DO 和 NOT TO DO 。
2、提升性能的优化方法
2.1 使用本地方法
先举个例子,当你处理字符串的时候,尽量能多的使用诸如String.indexOf()、String.lastIndexOf()这样对象自身带有的方法。因为这些方法使用C/C++来实现的,要比在一个Java循环中做同样的事情快10-100倍。但是使用本地方法并不是因为本地方法一定比Java高效,至少,Java和native之间过渡的关联是有消耗的。而JIT并不能越过这个界限进行优化。当你分配本地资源时(本地堆上的内存,文件说明符等),往往很难实时的回收这些资源。同时你也需要在各个结构中编译你的代码,而非依赖JIT。甚至可能需要针对相同的架构来编译出不同版本:针对ARM处理器的GI编译的本地代码,并不能充分利用Nexus One上的ARM,而针对Nexus One上ARM编译的本地代码不能在G1的ARM上运行。 当存在有你想部署到Android上的本地代码库时,本地代码显得尤为有用,而非为了Java应用程序的提速。
2.2 使用虚方法优于使用接口
如果你想要创建一个HashMap对象,你可以声明它为HashMap类型或者向上转型为Map类型,使用哪一种更好呢?
Map myMap1 = new HashMap();
HashMap myMap2 = new HashMap();
可能大家根据Java的编程经验会觉得使用Map更好,因为它允许实现Map接口上的任何方法。但是这种观点适用于常规的编程,而非嵌入式系统。因为相对于具体的引用虚拟方法进行调用,通过引用接口的调用会花费两倍以上的时间。
2.3 使用静态代替虚拟
如果你的方法不需要去访问外部对象,那么将你的方法声明为静态方法。静态方法的调用会比非静态方法加速15%-20%,因为它不需要设置一个虚拟方法导向表。这是一个很好的性能提升途径。同时,通过这种做法,你也可以知道调用该方法不会改变此对象的状态。
2.4 缓冲对象属性调用
首先阅读以下这两行代码
for (int i = 0; i < this.mCount; i++)
dumpItem(this.mItems[i]);
这样的代码是性能很低的。因为在for循环中,每次都要访问对象属性,这要比访问本地变量慢太多,可以这样去优化:
int count = this.mCount;
Item[] items = this.mItems;
for (int i = 0; i < count; i++)
dumpItems(items[i]);
先将对象属性赋予本地变量,在for循环中调用本地变量。
所以这里总结出一个原则即使在调用对象属性时进行缓冲。举个例子说就是不要在一个for语句中第二次调用一个类的方法。例如,下面的代码就会多次地执行getCount()方法,这会造成程序运行速度极慢。
for (int i = 0; i < this.getCount(); i++)
dumpItems(this.getItem(i));
应该把它隐藏于一个int变量中,这就是属性的缓冲。
当你不止一次需要调用某个对象实例时,先将这个实例本地化,把实例中的某些需要用到的属性和值赋给本地变量。例如:
protected void drawHorizontalScrollBar(Canvas canvas, int width, int height)
{
if (isHorizontalScrollBarEnabled())
{
int size = mScrollBar.getSize(false);
if (size <= 0)
{
size = mScrollBarSize;
}
mScrollBar.setBounds(0, height- size, width, height);
mScrollBar.setParams(computeHorizontalScrollRange(),computeHorizontalScrollOffset(),computeHorizontalScrollExtent(), false);
mScrollBar.draw(canvas);
}
}
这段程序中四次调用了mScrollBar 的属性。所以应该先把mScrollBar 缓冲到一个堆栈变量之中。这时再运行程序就变成了四次访问堆栈,效率会提升很多。另外,对于方法的调用同样也可以像本地变量一样具有此的特点。
2.5 声明final常量
首先,来考虑一下在下面在某个类内部的变量申明:
static int intVal = 42;
static String strVal = "Hello, world!";
变量的生成过程是:当一个类第一次使用时,编译器会调用一个类初始化方法—— ,这个方法将值42赋予变量 intVal ,并得到类字符串常量strVal的引用。当这些值在后面被引用时,他们通过字段查找进行访问。
现在让我们用“final”关键字来优化代码:
static final int intVal = 42;
static final String strVal = "Hello, world!";
这个类第一次运行生成变量时, 不会调用 方法,因为这些常量直接写入了类文件静态属性初始化中。这个初始化直接由虚拟机来处理。代码访问intVal 将会使用Integer类型的42,访问strVal 将使用相对节省的“字符串常量”来替代一个属性调用。这仅仅是针对基本数据类型和String类型常量的优化,而非任意的引用类型。但尽可能的将常量声明为static final类型是一种好的做法。实际上将一个类或者方法声明为“final”并不会带来任何的执行上的好处,但是它能够进行一定的最优化处理。例如,如果编译器知道一个get方法不能被子类重载,那么它就把该函数设置成inline。
2.6 考虑用包访问权限替代私有访问权限
我们先来看下面的类定义:
public class Foo
{
private int mValue;
public void run()
{
Inner in = new Inner();
mValue = 27;
in.stuff();
}
private void doStuff(int value)
{
System.out.println("Value is " + value);
}
private class Inner
{
void stuff()
{
Foo.this.doStuff(Foo.this.mValue);
}
}
}
这里我们要注意的是我们定义了私有内部类(Foo$Inner),它直接访问外部类中的私有方法和私有变量。这是合法的调用,程序运行结果也会打印出预期的Value is 27。
问题是Foo$Inner在理论上(后台运行上)应该是一个完全独立的类,它违规的调用了Foo的私有成员,虚拟机会认为这是非法的。为了弥补这个缺陷,编译器产生了一对合成的方法:
/*package*/ static int Foo.access$100(Foo foo)
{
return foo.mValue;
}
/*package*/ static void Foo.access$200(Foo foo, int value)
{
foo.doStuff(value);
}
内部类需要从访问外部类的“mValue”和调用“doStuff”时,内部类就会调用这些静态的方法。但这时你并不是直接访问该类成员,而是通过公共的方法来访问的。我们之前说过,直接访问比间接访问要快,这例子说明,某些语言约定导致了不可见的性能问题。可以通过让拥有包空间的内部类直接声明需要访问的属性和方法来避免这个问题。
如果你在高性能的Hotspot中使用这些代码,可以通过声明被内部类访问的字段和成员为包访问权限,而非私有。不幸的是这意味着这些字段会被其他处于同一个包中的类访问,因此在公共API中不宜采用。
2.7使用改进的for循环语法
改进的for循环也就是for-each循环,能够用于实现了Iterable接口的集合类及数组中。在集合类中,迭代器会促使接口访问 hasNext()和next()方法,在ArrayList中,无论有没有JIT,计数循环迭代要快3倍。但其他集合类中,改进的for循环语法和迭代器具有相同的效率。
public class Foo
{
int mSplat;
static Foo mArray[] = new Foo[27];
public static void zero()
{
int sum = 0;
for (int i = 0; i < mArray.length; i++)
{
sum += mArray[i].mSplat;
}
}
public static void one()
{
int sum = 0;
Foo[] localArray = mArray;
int len = localArray.length;
for (int i = 0; i < len; i++)
{
sum += localArray[i].mSplat;
}
}
public static void two()
{
int sum = 0;
for (Foo a: mArray)
{
sum += a.mSplat;
}
}
}
zero()是方法中运行最慢的,因为对于这个遍历中的历次迭代,JIT不能优化获取数组长度的开销。
One()稍快一些,因为将所有东西都放进局部变量中,避免了查找代价。
Two()是在无JIT的设备上运行最快的,它采用了改进for循环语句。但是对于有JIT的设备则和one()不分上下。所以是否选用改进的for循环语句,还是需要谨慎去考虑。
3、编程中注意避免的事项
3.1 避免创建不必要的对象
创建对象是有代价的,虽然通过多线程给临时对象分配一个地址池能降低分配开销,但分配内存往往需要比不分配内存付出更高的代价。
因此如果在用户界面内分配对象的话,你需要一个强制性的内存回收机制,但是这会给用户体验带来停顿间隙,虽然时间会很短。因此,应该尽可能避免创建不要必要的对象。下面是几个示例:
当从原始的输入数据中提取字符串时,试着从原始字符串返回一个子字符串,而不是创建一份拷贝。你将会创建一个新的字符串对象,但是它和你的原始数据共享数据空间。
如果你有一个返回字符串地方法,你应该知道无论如何返回的结果是StringBuffer,改变你的函数的定义和执行,让函数直接返回而不是通过创建一个临时的对象。 一个更激进的做法就是把一个多维数组分割成几个平行的一维数组:
一个int类型的数组要比一个Integer类型的数组要好。同样的,两个int类型的数组要比一个二维的(int,int)对象数组的效率要高的多 。对于其他原始数据类型,这个原则同样适用。
如果需要实现存放数组(Foo,Bar)对象的容器,记住两个平行数组Foo[], Bar[]会优于一个(Foo,Bar)对象的数组。不过这个例子也有一个例外,就是当你设计其他代码的接口API时。在这种情况下,速度上的一点损失就不用考虑了。但是在你的代码里面,你应该尽可能的编写高效代码。
通常来说,尽可能避免创建短时临时对象,少的对象意味着低频率的垃圾回收,这会提高你程序的用户体验质量。
3.2避免内部的Getters/Setters
在类似C++的原生语言中,通常会使用Getters(i=getCount())函数去替代直接访问属性字段(i=mCount )。这样做的目的是使编译器去内联这些访问。当你想约束这些访问时,只需要添加一些访问约束代码。
但是在Android编程中,这个想法就不实用了。因为我们在上面说过,虚方法的调用代价比直接读取字段要大的多。我们可以按照面向对象语言的通常做法那样在外部调用时使用Getters和Setters,但是在内部调用时候,我们还是直接调用字段。因为没有JIT时,直接访问字段比使用Getter方法大约快三倍;而有JIT时会快7倍。所以避免内部的Getters/Setters是提高性能的一项方法。
3.3 避免使用枚举类型
在编程中很多人会感到枚举类型很好用。但是使用它的代价却很大,在空间和速度方面都会有影响,例如:
public class Foo
{
public enum Shrubbery { GROUND, CRAWLING, HANGING }
}
上述枚举类型创建的Shrubbery会转变为一个约占用900字节的Foo$Shrubbery.class文件。当第一次使用时,类进行初始化,要占用上面调用方法去描述枚举类型的每一项。每一个对象都会被分配自己的静态空间,被存储于一个叫做“$VALUE”的静态数组中。这一系列的代码和数据仅仅是为了描述三个整数值。这是一个太大的代价了。
当我们调用这个枚举类型的时候:
Shrubbery shrub = Shrubbery.GROUND;
当调用这个枚举类型的时候,会引起静态属性的调用,如果GROUND是一个静态的final变量,编译器会把它当作一个常数嵌套在代码里面。
在这里要说的是枚举类型也有优势,那就是通过枚举类型可以得到更好的API和一些编译时间上的检查。因此,一种比较平衡的做法就是:在你的公用API中使用枚举类型变量,当处理问题时就尽量的避免这样做。
在一些环境下面,通过ordinal()方法获取一个列举变量的整数值是很有用的,例如,把下面代码:
for (int n = 0; n < list.size(); n++)
{
if (list.items[n].e == MyEnum.VAL_X)
// do stuff 1
else if (list.items[n].e == MyEnum.VAL_Y)
// do stuff 2
}
优化以下代码,有可能在一些条件下获得更快的运行速度:
int valX = MyEnum.VAL_X.ordinal();
int valY = MyEnum.VAL_Y.ordinal();
int count = list.size();
MyItem items = list.items();
for (int n = 0; n < count; n++)
{
int valItem = items[n].e.ordinal();
if (valItem == valX)
// do stuff 1
else if (valItem == valY)
// do stuff 2
}
3.4 避免浮点类型的使用
浮点类型,是奔腾的CPU发布之后的一个突出的特点,比起单独使用整数,浮点数和整数的结合使用会使程序运行更快。速度术语中,在现代硬件上,float和double之间并没有不同。更广泛的讲,double大约2倍大。在没有存储空间问题的桌面机器中,double的优先级高于float。但是,在Android设备中,这一点可不适用。因为在设备硬件的条件下,使用浮点数会比整型慢两倍。另外,即使是整数,一些芯片也只有乘法而没有除法。在这些情况下,整数的除法和取模操作都是通过软件实现。基于这些问题的存在,在Android编程中,尽量避免使用浮点型数据。
4、标准操作的时间比较
我们所说的性能提高是有据可循的。读者可以从下表中看到一些基本操作的大概时间。这些时间没有考虑CPU和时钟频率,所以并不是绝对的时间,会在不同系统中有所差别。从这些时间中读取的最有意义的信息是不同操作的时间长度对比,例如,创建一个成员变量的时间比创建一个本地变量要多4倍。
表12-1 操作时间对比表
编写嵌入式程序,要时刻保持清晰的头脑,我们必须很明白程序的每一步在做什么,才能保证硬件条件有限的情况下,程序可以高效的运行。依照上述原则和方法,可以有效的优化你的程序。当然优化程序的方法和因素还有很多,最重要的是要有优化程序和考虑应用程序性能的思想。
小结
本文针对Android设备平台的特点,阐述了Android下Java编程性能优化的必要性;阐述了优化原则;从要做的事情和不要做的事情两个角度给出了优化的具体方法。相信读者通过此文学习已经建立起了程序优化的意识,掌握了基本的技巧。