63 当心字符串连接的性能问题
不要使用字符串连接操作符合并多个字符串
字符串连接操作符(+) 是将几个字符串组合成一个字符串的简便方法。
为了连接n个字符而重复地使用字符串连接操作符,需要O(n^2)的时间。因为字符串是不可变的,当两个字符串被连接在一起时,它们的内容都要被拷贝。
例如,下面代码将每个账单项目重复连接到一行来构造账单语句的字符串表示:
public String statement() { String result = ""; for (int i = 0; i < numItems(); i++) result += lineForItem(i); // String concatenation return result; }
如果项的数量很大,则该方法的执行时间就难以估算。要获得更好的性能,可以使用StringBuilder代替String
public String statement() { StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH); for (int i = 0; i < numItems(); i++) b.append(lineForItem(i)); return b.toString(); }
第二个方法预先分配了一个足够大的StringBuilder来保存整个结果,从而消除了自动增⻓的需要。即使使用默认大小的StringBuilder,它仍然比第一个方法快很多。
64 通过接口引用对象
如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。惟一真正需要引用对象的类的时候是使用构造函数创建它的时候。考虑LinkedHashSet的情况,它是Set接口的一个实现:
Set<Son> sonSet = new LinkedHashSet<>();
而不是这样:
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();
如果你养成了使用接口作为类型的习惯,那么你的程序将更加灵活。如果你决定要切换实现,只需在构造函数中更改类名(或使用不同的静态工厂)。例如,第一个声明可以改为:
Set<Son> sonSet = new HashSet<>();
所有的代码都会继续工作。周围的代码不知道旧的实现类型,所以它不会在意更改。
有一点值得注意:如果原实现提供了接口的通用约定不需要的一些特殊功能,并且代码依赖于该功能,那么新实现提供相同的功能就非常重要。例如,如果围绕第一个声明的代码依赖于LinkedHashSet的排序策略,那么在声明中将HashSet替换为LinkedHashSet将是不正确的,因为HashSet不保证迭代顺序。
为什么要更改实现类型呢?
因为第二个实现比原来的实现提供了更好的性能,或者因为它提供了原来的实现所缺乏的理想功能。例如,假设一个字段包含一个HashMap实例。将其更改为EnumMap将为迭代提供更好的性能和与键的自然顺序。
将HashMap更改为LinkedHashMap将提供可预测的迭代顺序,性能与HashMap相当,而不需要对键类型作出任何特殊要求。
如果没有合适的接口存在,那么用类引用对象也可以。如String和BigInteger。
值类很少在编写时考虑到多个实现。它们通常是final的,很少有相应的接口。使用这样的值类作为参数、变量、字段或返回类型非常合适。
没有合适接口类型的第二种情况是属于框架的对象,框架的基本类型是类而不是接口。如果一个对象属于这样一个基于类的框架,那么最好使用相关的基类来引用它,这通常是抽象的,而不是使用它的实现类。在java.io类中许多诸OutputStream之类的就属于这种情况。
没有合适接口类型的最后一种情况是,实现接口但同时提供接口中不存在的额外方法的类,例如,PriorityQueue有一个在Queue接口上不存在的comparator方法。
如果没有合适的接口,就使用类层次结构中提供必要功能的最小具体类来引用对象~~
65 接口优于反射
核心反射机制java.lang.reflect提供对任意类的编程访问。给定一个Class对象,你可以获得Constructor、Method和Field实例,分别代表了该Class实例所表示的类的构造器、方法和字段。
通过调用Constructor、Method和Field实例上的方法,可以构造底层类的实例、调用底层类的方法,并访问底层类中的字段。
然而,这种能力是有代价的:
1. 失去了编译时类型检查的优势
如果一个程序试图反射性地调用一个不存在的或不可访问的方法,它将在运行时失败
2. 执行反射访问所需的代码既笨拙又冗⻓
3. 性能降低
反射方法调用比普通方法调用慢得多
如果是非常有限的形式使用反射,则可以获得反射的许多好处,同时花费的代价很少。如果代码必须用到在编译时无法获取到的类,却在编译时存在一个适当的接口或超类来引用该类,就可以用反射方式创建实例,并通过它们的接口或超类正常地访问它们。
例如,这是一个创建Set<String>实例的程序,类由第一个命令行参数指定。程序将剩余的命令行参数插入到集合中并打印出来。注意,打印这些参数的顺序取决于第一个参数中指定的类。如果你指定java.util.HashSet,它们显然是随机排列的;如果指定了java.util.TreeSet,则是按字⺟顺序打印的
public static void main(String[] args) { // Translate the class name into a Class object Class<? extends Set<String>> cl = null; try { cl = (Class<? extends Set<String>>) // Unchecked cast! Class.forName(args[0]); } catch (ClassNotFoundException e) { fatalError("Class not found."); } // Get the constructor Constructor<? extends Set<String>> cons = null; try { cons = cl.getDeclaredConstructor(); } catch (NoSuchMethodException e) { fatalError("No parameterless constructor"); } // Instantiate the set Set<String> s = null; try { s = cons.newInstance(); } catch (IllegalAccessException e) { fatalError("Constructor not accessible"); } catch (InstantiationException e) { fatalError("Class not instantiable."); } catch (InvocationTargetException e) { fatalError("Constructor threw " + e.getCause()); } catch (ClassCastException e) { fatalError("Class doesn't implement Set"); } // Exercise the set s.addAll(Arrays.asList(args).subList(1, args.length)); System.out.println(s); } private static void fatalError(String msg) { System.err.println(msg); System.exit(1); }
这个例子反映了反射的两个缺点:
1. 该示例可以在运行时生成6个不同的异常,如果不使用反射的方式实例化,所有这些异常都将是编译时错误
2. 根据类的名称生成类的实例需要25行冗⻓的代码,而构造函数调用只需要一行
通过捕获ReflectiveOperationException(Java 7中引入的各种反射异常的超类),可以减少代码的⻓度
如果要变写一个包,它运行时必须依赖其他某个包的多个版本,这种做法可能非常有用。具体做法就是,在支持包所需要最老版本下对它进行编译,然后以反射的方式访问任何更加新的类或方法。
66 谨慎使用本地方法
Java本地接口(JNI)允许Java程序调用本地方法,它们提供了“访问特定于平台的机制”,比如注册表。本地方法还可以通过本地语言,编写应用程序中注重性能的部分,以提高性能。
使用本地方法访问特定于平台的机制是合法的,但是很少有必要:随着Java平台的成熟,它提供了对许多以前只能在宿主平台中上找到的特性。
使用本地方法来提高性能的做法也不值得提倡。原因是JVM变得越来越快了。对于大多数任务,现在可以在Java中获得类似本地方法的性能。
在Java 1.1中添加了java.math,里面的BigInteger是在一个用C编写的快速多精度运算库的基础上实现的。在当时,为了获得足够的性能这样做是必要的。在Java 3中,BigInteger则完全用Java重写了,并且进行了性能调优,新的版本比原来的版本更快。
使用本地方法有严重的缺点:
1. 本地语言是不安全的。所以使用本地方法的应用程序可能会受到内存毁坏的影响
2. 本地语言比Java更依赖于平台,因此使用本地方法的程序的可移植性较差
3. 使用本地方法的代码更难调试
4. 本地方法可能会降低性能,
因为垃圾收集器无法自动跟踪本地内存使用情况
5. 进出本地代码时会产生额外的开销
6. 本地方法需要「粘合代码」,很难阅读,并且编写起来很乏味
67 谨慎地进行优化
有三条关于优化的格言是每个人都应该知道的:
比起其他任何单一的原因(包括盲目的愚蠢),很多计算上的过失都被归昝于效率(不一定能实现)。
—William A. Wulf
不要去计较效率上的一些小小的得失,在97%的情况下,不成熟的优化才是一切问题的根源。
—Donald E. Knuth
在优化方面,我们应该遵守两条规则:
规则1:不要进行优化。
规则2(仅针对专家):还是不要进行优化,也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。
—M. A. Jackson
它们告诉我们关于优化的一个深刻的事实:很容易弊大于利,尤其是不成熟的优化。
我们应该努力编写好的程序,而不是快速的程序,如果一个好的程序不够快,它的架构将允许它被优化。好的程序体现了信息隐藏的原则:只要有可能,它们就会把设计决策集中在单个模块中,因此可以在不影响系统其余部分的情况下更改单个决策。
这并不意味着在程序完成之前可以忽略性能问题,而是必须在设计过程中考虑性能。
尽量避免限制性能的设计决策:设计中最难以更改的组件是那些指定组件之间以及与外部世界的交互的组件。最主要的是API、交互层协议和永久数据格式。
要考虑API设计决策的性能结果:
使public类成为可变的,可能需要大量不必要的保护性拷贝
在适合复合模式的public类里使用继承,会把该类永远绑定到它的超类,这会人为地限制子类的性能
在API中使用实现类而不是接口,会将你束缚在一个具体的实现上,即使将来可能会编写更快的实现也无法使用
以java.awt.Component中的getSize方法为例,这个注重性能的方法将返回Dimension实例,Dimension实例是可变的,这就使得该方法的任何实现在每次调用时分配一个新的Dimension实例。
存在几种API设计的替代方案:
Dimension应该是不可变的
用两个方法替换getSize,他们分别返回Dimension对象的单个基本组件
为了获得良好的性能而改变API是一个非常糟糕的想法,因为导致你改变API的性能问题,可能在平台或其他底层软件的未来版本中消失,但是改变的API和随之而来的问题将永远伴随着你。
在每次尝试优化前后都要测试性能。性能分析工具可以帮助你决定将优化工作的重点放在哪里。这些工具可以提供运行时信息;另一类工具是jmh,它是一个微基准测试框架,提供了对Java代码性能详情的预测性。
68 遵守被广泛认可的命名约定
Java平台有一组完善的命名约定,其中许多约定包含在了《The Java Language Specification》中,这些命名大致分为两类:字面的和语法的。
1. 包名和模块名应该是分层的,组件之间用句点分隔
每个部分都包括小写字⺟,很少使用数字。包名就是公司或组织的域名颠倒,例如:edu.nju、com.google、org.eff。
包名的其余部分应该由描述包的一个或多个组件组成,通常不超过8个字符。鼓励使用有意义的缩写,例如util(utilities)。缩写词也行,例如awt。
2. 类、接口、枚举、注释名称,应该由一个或多个单词组成,每个单词的首字⺟大写
例如List或FutureTask。对于首字母缩写,到底应该全部大写还是只有首字母大写,没有统一的说法。但还是强烈推荐只有首字母大写,比如HttpUrl就比HTTPURL清晰。
3. 方法和字段名的第一个字⺟应该小写
例如remove或ensureCapacity。如果首字母缩写组成的单词是一个方法或字段名的第一个单词,它应该是小写的。
4. 常量字段的名称应该由一个或多个大写单词组成,由下划线分隔
例如VALUES或NEGATIVE_INFINITY。
5. 局部变量名允许使用缩写,也允许使用单个字符和短字符序列,它们的含义取决于它们出现的上下文
例如i、denom、houseNum
6. 类型参数名通常由单个字⺟组成
最常⻅的是以下五种类型之一:T表示任意类型,E表示集合的元素类型,K和V表示Map的键和值类型,X表示异常。函数的返回类型通常为R。任意类型的序列可以是T、U、V或T1、T2、T3。
上面所有的讨论可以总结如下表:
语法命名约定比排版约定更灵活,也更有争议。
1. 可实例化的类,包括枚举类型,通常使用一个或多个名词短语来命名
例如Thread、PriorityQueue或ChessPiece
2. 不可实例化的类通常使用复数名词来命名
例如collector或Collections
3. 接口的名称类似于类
例如集合或比较器,或者以able或ible结尾的形容词,例如Runnable、Iterable或Accessible
4. 注解类型有很多的用途,所以名词、动词、介词和形容词都很常⻅
例如BindingAnnotation、Inject、ImplementedBy或Singleton
5. 执行某些操作的方法通常用动词或动词短语(包括对象)命名
例如append或drawImage
6. 返回布尔值的方法的名称通常以单词is或has(通常很少用)开头,后面跟一个名词、一个名词短语,或者任何用作形容词的单词或短语
例如isDigit、isProbablePrime、isEmpty、isEnabled或hasSiblings
7. 返回被调用对象的非布尔函数或属性的方法通常使用名词短语、以get开头的名词或动词短语来命名
例如size、hashCode或getTime
8. 转换对象类型的实例方法通常称为toType
例如toString或toArray
9. 返回与接收对象类型不同的视图的方法通常称为asType
例如asList
10. 返回与调用它们的对象具有相同值的基本类型的方法通常称为类型值
例如intValue
11. 静态工厂的常⻅名称
from、of、valueOf、instance、getInstance、newInstance、getType和newType
字段名的语法约定没有类、接口和方法名的语法约定建立得好,也不那么重要,因为设计良好的API包含很少的公开字段。类型为boolean的字段的名称通常类似于boolean访问器方法,省略了初始值「is」,例如initialized、composite。其他类型的字段通常用名词或名词短语来命名,如height、digits和bodyStyle。