- 浏览: 332977 次
- 性别:
- 来自: 天津
文章分类
最新评论
-
xing0029:
这样配置的话 事物不成功啊 还有什么地方需要注意的么 可以加我 ...
JTA集成JOTM或Atomikos配置分布式事务(Tomcat应用服务器) -
mengqingyu:
liuxiaolu 写道jotm的我的没有成功,楼主能否帮助一 ...
JTA集成JOTM或Atomikos配置分布式事务(Tomcat应用服务器) -
liuxiaolu:
jotm的我的没有成功,楼主能否帮助一下
JTA集成JOTM或Atomikos配置分布式事务(Tomcat应用服务器) -
aptech406328627:
求解救,没弄好QQ:1053942353
Spring邮件发送(可带附件,模板,群发,异步发送等功能) -
ghpaas:
web可视化自定义表单推荐使用GForms开发平台(http: ...
在线表单设计器设计原理
第一章 前言
第二章 创建和销毁对象
1、 考虑用静态工厂方法代替构造器
创建对象方法:一是最常用的公有构造器,二是静态工厂方法。下面是一个Boolean的简单示例:
public static Boolean valueOf(boolean b) {
return (b ? Boolean.TRUE : Boolean.FALSE);
}
l 静态工厂方法与构造器不同的第一大优势在于,它们有名称。
作用不同的公有构造器只能通过参数来区别(因为一个类只有一个带有指定签名的构造器,所以多个构造器只能使用不同的参数列表来区分),如果使用静态的工厂方法,则方法名会很清楚地表达方法的作用。
l 静态工厂方法与构造器不同的第二大优势在于,不必在每次调用它们的时候都创建一个新对象。
不可变类完全可以使用预先构建好的实例,而不必每次使用时都创建一个对象。另外,将构建好的实例缓存起来重复使用,从而避免创建不必要的重复对象。Boolean.valueOf(boolean)方法就使用了这项技术——它从来不创建对象。
l 静态工厂方法与构造器不同的第三大优势在于,它们可以返回原返回类型的任何子类型的对象。
这样我们在选择返回对象的类时就有了更大的灵活性。这种灵活性的一种应用是,API可以返回对象,同时又不会使对象的类变成公有的,比如我们完全可以先定义一个产品接口类,然后采用私有的内部类去实现这个接口,静态工厂方法返回这个类的实例,这样就隐藏了具体的实现。另外,使用静态工厂方时,要求客户端通过接口来引用被返回的对象,而不是通过它的实现类来引用被返回的对象,这是一种良好的编程习惯。
公有静态工厂方法所返回的对象的类不仅可以是private,而且通过静态工厂方法的参数,还可以随着每次的返回不同的类的实例,只要是已声明返回类型的子类型。这样的好处是,可以在以后的版本中删除这个类重新实现也不会影响到已使用的客户。
静态工厂方法返回的对象所属的类,在编写包含该静态工厂方法的类时可能不必存在。这种灵活的静态工厂方法构成了服务提供者框架的基础,例如JDBC API。服务提供者框架是指这样一个系统:多个服务提供者实现一个服务,系统为服务提供者的客户端提供多个实现,并把他们从多个实现中解耦出来。
服务提供者框架有三个重要组件:服务接口(Service Interface),这是提供者实现的;提供者注册API(Provider Registration API),这是系统用来注册实现,让客户端访问它们的;服务访问API(Service Access API),是客户端用来获取服务的实例的方法接口。服务访问API一般允许但是不要求客户端指定某种选择提供者的条件。如果没有这样的规定,API就会返默认实现的一个实例。服务访问API是“灵活的静态工厂”,它构成了服务提供者框架的基础。
服务提供者框架的第四个组件是可选的:服务提供者接口(Service Provider Interface)(即工厂方法模式中的工厂接口),这些提供者负责创建其服务实现的实例。如果没有服务提供者接口,实现就按照类名称注册,并通过反射方式进行实例化。对于JDBC来说,Connection就是它的服务接口,DriverManager.registerDriver是提供者注册API,DriverManager.getConnection是服务访问API,Driver就是服务提供者接口。
下面看看这四个组件的应用:
// 服务接口,就是我们的业务接口。(相当于Connection接口,由Sun提供)
public interface Service {
// ...
}
// 服务提供都接口,即业务工厂接口。(相当于Driver接口,由第三方厂家实现)
public interface Provider {
Service newService();
}
// 服务提供者注册与服务提供者接口(好比DriverManager)
public class Services {
private Services() {}
// 服务名与服务映射,即注册容器
private static final Map providers = new ConcurrentHashMap();
public static final String DEFAULT_PROVIDER_NAME = "def";
// 服务提供者注册API,即注册工厂实现,相当于DriverManager.registerDriver
public static void registerDefaultProvider(Provider p) {
registerProvider(DEFAULT_PROVIDER_NAME, p);
}
public static void registerProvider(String name, Provider p) {
providers.put(name, p);
}
// 服务访问API,向外界提供业务实现,相当于DriverManager.getConnection
public static Service newInstance() {
return newInstance(DEFAULT_PROVIDER_NAME);
}
public static Service newInstance(String name) {
Provider p = (Provider) providers.get(name);
if (p == null) {
throw new IllegalArgumentException(
"NO provider registered with name:" + name);
}
return p.newService();
}
}
静态工厂方法的第四大优势在于,在创建参数化类型实例的时候,它们使代码变得更加简洁。比如要创建一个参数化的HashMap,我们需要如下做:
Map<String,List<String>> m= new HashMap<String, List<String>>();
这么长的类型参数实在是不太好,而且随着类型参数变得越来越长,也越来越复杂。但如果有了静态工厂方法,编译器就可以替你推导出类型,new时不需要提供参数类型。例如,假设HashMap提供了这个静态工厂:
public static <k,v> HashMap<k,v> newInstance(){
return new HashMap<k,v>();
}
那么你就可以使用以下简洁的代码来代替上面这段繁琐的声明:
Map<String,List<String>> m= HashMap.newInstance();
但可惜的是,到现在发行的版本1.6止还未加入,不过我们可以把这些方法放在自己的工具类中。
静态工厂方法的一些惯用名称:
valueOf——不太严格地讲,该方返回的实例与它的参数具有相同的值。这样的静态工厂方法实际上是类型转换方法。
of——valueOf的一种更为简洁的替换,在EnumSet中使用并流行起来。
getInstance——返回的实例是通过方法的参数来描述的,但是不能够说与参数具有同样的值。对于Singleton来说,该方法没有参数,并返回唯一值。
newInstance——像getInstance一样,但newInstance能够确保返回每个实例都与把有其他实例不同。
getType——像getInstance一样,但是在工厂方法处于不同的类中的时候使用。Type表示工厂方法所返回的对象类型。
newType——像newInstance一样,但是在工厂方法处于不同的类中的时候使用。Type表示工厂方法所返回的对象类型。
2、 遇到多个构造器参数时要考虑构造器
如果实例化时需要多个参数时,且这些参数中只有少数几个是必须的,而很多是可选的,这时我们一般考虑使用构造器的方式,而不是使用静态工厂方法。
对于此情况,我们可以使用重叠构造器模式——你提供一个只有必要参数的构造器,第二构造器有一个可选参数,第三个有两个可选参数,依此类推,最后一个构造器包含所有可选参数。
public class NutritionFacts {
private final int servingSize; // 必选参数
private final int servings; // 必选参数
private final int calories; // 可选参数
private final int fat; // 可选参数
private final int sodium; // 可选参数
private final int carbohydrate; // 可选参数
// 第一个构造器带上所有必选参数
public NutritionFacts(int servingSize, int servings) {
// 调用另一个构造器
this(servingSize, servings, 0);// 第三个参数为默认值
}
// 第二个构造器在第一个构造器的基础上加上一个可先参数
public NutritionFacts(int servingSize, int servings,
int calories) {
// 第四个参数为默认值
this(servingSize, servings, calories, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}
当你想要创建实例时,就利用参数列表最短的构造器。虽然重叠构造器模式可行,但是当有许多参数的时候,客户端代码会行难编写,并且难以阅读,随着参数的增加,它很快就失去控制。
遇到许多构造器参数时,还有第二种代替办法,即JavaBean模式,在这种模式下先调用一个无参数构造器来创建对象,然后调用setter方法来设置每个必要的参数,以及每个相关的可先参数:
public class NutritionFacts {
private int servingSize = -1; //必选,没有默认值
private int servings = -1; //必选,没有默认值
private int calories = 0; //可选,有默认值
private int fat = 0; //可选,有默认值
private int sodium = 0; //可选,有默认值
private int carbohydrate = 0; //可选,有默认值
public NutritionFacts() {}
// set方法
public void setServingSize(int val) { servingSize = val; }
public void setServings(int val) { servings = val; }
public void setCalories(int val) { calories = val; }
public void setFat(int val) { fat = val; }
public void setSodium(int val) { sodium = val; }
public void setCarbohydrate(int val) { carbohydrate = val; }
}
这种模式弥补了重叠构造器模式的不足,他创建实例很容易,代码阅读也很容易。但遗憾的是,JavaBean模式自身有着很严重的缺点。因为构造过程中被分到了几个调用中,在构造过程中JavaBean可能处于不一致的状态,并且类也无法仅仅通过检验构造器参数的有效性来保证一致。另外,JavaBeans模式阻止了把一个类做成不可变的可能,这就需要应用中确保线程安全。
幸运的是,还有第三替代方法,既能保证重叠构造器模式那样的安全性,也能保证像JavaBeans模式那么好的可读性,这就是Builder模式的一种形式——不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造(或者静态工厂),得到一个builder对象,然后客户端在builder对象上调用类似于setter方法,来设置每个样的可选参数,这个builder是它构建的类的静态成员类,下面是示例:
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
// 必输参数
private final int servingSize;
private final int servings;
// 可选参数 - 初始化成默认值
private int calories = 0;
private int fat = 0;
private int carbohydrate = 0;
private int sodium = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val)
{ calories = val; return this; }
public Builder fat(int val)
{ fat = val; return this; }
public Builder carbohydrate(int val)
{ carbohydrate = val; return this; }
public Builder sodium(int val)
{ sodium = val; return this; }
// 构造产品
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
// 构造器需要一个builder对象
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
public static void main(String[] args) {
NutritionFacts cocaCola = new NutritionFacts.Builder(240,.
calories(100).sodium(35).carbohydrate(27).build();
}
}
如果类的构造器或者静态工厂中具有多个参数,设计这种类时,Builder模式就是种不错的选择,特别是当大多数参数都是可选的时候。与使用传统的重叠构造器模式相比,使用Builder模式的客户端代码将更易于阅读和编写,构建器也比JavaBeans更安全。
3、 用私有构造器或者枚举类型强化Singleton属性
Singleton指仅仅被实例化一次的类。在Java1.5发行版本之前,实例Singleton有两种方法,这两种方法都要把构造器设置成私有的,并导出公有的静态成员,以便允许客户端能够访问该类的唯一实例。在第一种方法中,公有静态成员是个final域:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { }
}
私有构造器仅被调用一次,用来实例化仅有的静态final域INSTANCE。由于没有公有的或受保护的构造器,所以保证了实例的全局唯一性:一旦Elvis类被实例化,只会存在一个Elvis实例。客户端任何行为都不会改变这一点,但要提醒一点:享有特权的客户端可以借助于AccessibleObject.setAccessible方法,通过反射机制(第53条)调用私有构造器,如果要抵御这种攻击,可以修改构造器,让他在要求创建第二个实例的时候抛出异常。
第二种方法中公有的成员是个静态工厂方法:
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public static Elvis getInstance() { return INSTANCE; }
}
对于静态方法getInstance的所有调用,都会返回同一个对象引用,所以永远不会创建其他实例(上述提醒依然适用)。
第一种可以在以前的VM上效率要高一点,但在现在的JVM实现几乎都能够将静态工厂方法的调用内联化了,所以不存在性能的差异了,再说第二种方式的主要好处在于:组成类的成员的声明很清楚地表明了这个类是一个Singleteon。
另外,如果一个Singleton类实现了Serializable是不够的,为了维护并保证Singleton,必须(原因请见76,简单的说就是防止私有域导出到序列化流中而受到攻击)声明所有实例域都是瞬时(transient)的,并提供一个readResolve方法,否则,每次序列化时,都会创建一个新的实例,正确的作法:
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
private Object readResolve() {
// Return the one true Elvis and let the garbage collector
// take care of the Elvis impersonator.
return INSTANCE;
}
}
从Java1.5版本起,实例Singleton还有第三种方法。只需写一个包含单个元素的枚举类型:
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
// This code would normally appear outside the class!
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
elvis.leaveTheBuilding();
}
}
这种方法在功能上与公有域方法相近,但是它更加简洁,无偿地提供了化机制,绝对防止多次实例化,即使是在相对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为了实现Singleton的最佳方法。
上面前面两种是懒汉式单例,还有其他的设计方法,请参考XXX
4、 通过私有构造器强化不可实例化的能力
有时候,你可能需编写只包含静态方法和静态域的类。这些类的名声很不好,因为有些人在面向对象的语言中滥用这样的类来编写过程化的程序。尽管如此,它们也确实有它们特有的用处。比如java.util.Arrays、java.util.Collections,这样的工具类不希望被实例化(这与单例是不一样的),实例对它没有任何意义,它们里的成员都是静态的,所有构造器也定义成了private,但这样是否就能确保不能实例化了呢?如果采用反射就不一定了。那怎样才能做完全不能实例化呢?有的人可能企图通过将类做成抽象类来强制该类不可被实例化,这是行不通的,该类可以被子类化,并且该子类也可以被实例化。这样做甚至会误导用户,以为这种类是专门为了继承设计的。正确的作法:
public class UtilityClass {
// Suppress default constructor for noninstantiability
private UtilityClass() {
throw new AssertionError();//抛异常,防止内部实例化与外部通过反射来实例化
}
}
5、 避免创建不必要的对象
一般来说,最好能重用对象而不是在每次需要的时候就创建一个相同功能的新对象。如果对象是不可变的,它就始终可以被重用。
反例: String s = new String("stringette");
该语句每次被执行的时候都创建一个新的String实例,但是这些创建对象的动作全都是不必要的,传递给String构造器的参数("stringette")本身就是一个String实例,功能方面等同于构造器创建的对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出很多不必要的String实例。
改进:String s = "stringette";
改进后,只用一个String实例,而不是每次执行的时候都创建一个新的实例,而且,它可以保证,对于所有在同一虚拟机中运行的代码,只要它们包含相同的字符串字面常量,该对象就会被重用。
关于字符串对象创建细节请看:《String,到底创建了多少个对象?》
对于同时提供了静态工厂方法和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf(String)几乎总是比构造器Boolean(String)好,构造器在每次被调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。
除了重用不可变的对象之外,也可以重用那此已知不会被修改的可变对象,即当一个对象创建后,以后不会去改变其内部状态,此时也不会去创建新的对象,而是直接利用以前创建的对象。比如某方法里定义了一个大的对象,而这个对象一但创建完后,就可以共以后方法调用使用时,最好将它定义为成员域,而不是局部变量。
例如,Map接口的keySet方法就是每次返回的是keySet实例,当创建好KeySet视图对象后,它会将它存储到keySet成员域中以便下次使用:
public Set<K> keySet() {
Set<K> ks = keySet;
return (ks != null ? ks : (keySet = new KeySet()));
}
它返回的Map对象的Set视图,其中包含该Map中所有的键。粗看起来,好像每次调用keySet都应该创建一个新的Set实例,但是,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例,虽然被返回的Set实例一般是可改变的,但是所有返回的对象在功能上是等同的。
要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱:
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}
这段程序算出的答案是正确的,但是比实际情况要更慢一些,只因为打错了一个字符。变量sum被声明成Long而不是long,这就意味着程序构造了大约2^31个多的Long实例。
不要错误地认为“创建对象的代价非常昂贵,我们应该要尽可能地避免创建对象,而不是不创建对象”,相反,由于小对象的构造器只做很少量的工作,所以,小对象的创建和回收动作是非常廉价的,特别是在现代的JVM实现上更是如此。通过创建附加的对象,提升程序的清晰性、简洁性和功能性,这通常也是件好事。
反之,通过维护自己的对象池来创建对象并不是一种好的做法,除非池中的对象是非常重量级的。真正正确使用对象池的典型对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。
另外,在1.5版本里,对基本类型的整形包装类型使用时,要使用形如 Byte.valueOf来创建包装类型,因为-128~127的数会缓存起来,所以我们要从缓冲池中取,Short、Integer、Long也是这样。
6、 消除过期的对象引用
Java中会有内存泄漏,听起来似乎是很不正常的,因为Java提供了垃圾回收器针对内存进行自动回收,但是Java还是会出现内存泄漏的。什么是Java中的内存泄漏:在Java语言中,内存泄漏就是存在一些被分配的对象,这些对象有两个特点:这些对象可达,即在对象内存的有向图中存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象了。如果对象满足这两个条件,该对象就可以判定为Java中的内存泄漏,这些对象不会被GC回收,然而它却占用内存,这就是Java语言中的内存泄漏。Java中的内存泄漏和C++中的内存泄漏还存在一定的区别,在C++里面,内存泄漏的范围更大一些,有些对象被分配了内存空间,但是却不可达,由于C++中没有GC,这些内存将会永远收不回来,在Java中这些不可达对象则是被GC负责回收的,因此程序员不需要考虑这一部分的内存泄漏。二者的图如下:
INCLUDEPICTURE "mhtml:file://G:\\新建文件夹\\12_Java内存模型%20-%20风中的索莉逖亚%20-%20CSDN博客.mht!http://docs.google.com/File?id=dc9d7w83_68ckf7x437_b" \* MERGEFORMATINET
下面看内存泄漏的例子:
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];//size~elements.length间的元素为过期元素
}
/**
* Ensure space for at least one more element, roughly
* doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size){
Object[] oldElements = elements;
elements = new Object[2 * elements.length +1];
System.arraycopy(oldElements, 0, elements, 0, size);
}
}
}
从栈中弹出来的对象不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,它们也不会被回收,这是因为,栈内部维护着对象这些对象的过期引用,过期引用是指永远也不会被解除的引用,在本例中,凡是在elements数组的“活动部分”之外的任何引用都是过期的,活动部分是指定elements中下标小于size的那些元素。
这类问题的修复方法很简单:一旦对象引用已经过期,只需清空这些引用即可。对于上面例子,只要一个单元被弹出栈,指向它的引用就过期了。pop方法的修改如下:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result =elements[--size];
elements[size] = null;//消除过期引用,只要外界不再引用即可回收
return result;
}
清空对象引用应该是一种例外,而不是一种规范行为:我们不必对每个对象引用一旦程序不再用到它就把它清空,这样做即没必要,也不是我们所期望的,因为这样做会把程序代码弄得很乱。消除过期引用最好的方法是让引用结束其任命周期,如果你在小的作用域内定义的每一个变量,退出作用域就会自动结束。
内存泄漏的另一个常见来源是缓存。一旦你把对象引用放到缓存中,它就很容易被遗忘掉,
从而使得它不再有用之后很长一段时间内仍然留在缓存中。对于这个问题,有几种可能的解决方案。如果你正好要实现这样的缓存:只要在缓存之外存在对某个项的键的引用,该项就有意义,那么就可以用WeakHashMap(弱键映射,允许垃圾回收器回收无外界引用指向象Map中键)代表缓存;当缓存中的项过期之后,它们就会自动被删除。记住只有当所要的缓存项的任命周期是由该键的外部引用而不是由值决定时,WeakHashMap才有用外。
另外,随着时间的推移,早些存入的项会变得越来越没有价值,在这种情况下,缓存应该时不时地清除掉没用的项。我们可以使用的是LinkedHashMap来实现,可以在给缓存添加新条目的时候顺便进行清理,如果要实现这种功能,我们需继承LinkedHashMap并重写它的removeEldestEntry方法(默认返回false,即不会删除最旧项),put 和 putAll 将调用此方法,下面是自己写的测试项:
public class CacheLinkedHashMap extends LinkedHashMap {
//允许最大放入的个数,超过则可能删除最旧项
private static final int MAX_ENTRIES = 5;
@Override
// 是否删除最旧项(最先放入)实现
protected boolean removeEldestEntry(Map.Entry eldest) {
Integer num = (Integer) eldest.getValue();// 最早项的值
//如果老的项小于3且已达到最大允许容量则会删除最老的项
if (num.intValue() < 3 && size() > MAX_ENTRIES) {
System.out.println("超容 - " + this);
return true;
}
return false;
}
public static void main(String[] args) {
CacheLinkedHashMap lh = new CacheLinkedHashMap();
for (int i = 1; i <= 5; i++) {
lh.put("K_" + Integer.valueOf(i), Integer.valueOf(i));
}
System.out.println(lh);
// 放入时会删除最早放入的 k_1 项
lh.put("K_" + Integer.valueOf(11), Integer.valueOf(0));
System.out.println(lh);
}
}
输出:
{K_1=1, K_2=2, K_3=3, K_4=4, K_5=5}
超容 - {K_1=1, K_2=2, K_3=3, K_4=4, K_5=5, K_11=0}
{K_2=2, K_3=3, K_4=4, K_5=5, K_11=0}
说到这里,我们再来看看LinkedHashMap的另一特性 —— 可以按照我们访问的顺序来重新排序(即访问的项放到链表最后),平时我们构造的LinkedHashMap 是按照存放的顺序来排的,如果要按照我们访问(调用get或者是修改时,即put已存在的键时相当于修改,如果放入的不是存在的项则还是放在链的最后)的顺序来重排集合,则需使用LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)来构造,并将accessOrder设置为true:
public class OrderLinkedHashMap {
public static void main(String[] args) {
//按存入顺序连接
LinkedHashMap lh = new LinkedHashMap();
init(lh);
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh.get(0);//不将访问过的项放到链表最后
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh = new LinkedHashMap(10, 0.75f, true);//按访问顺序
init(lh);
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh.get(0);//会将访问过的项放到链表最后
print(lh);//{1=1, 2=2, 3=3, 4=4, 0=0}
lh.put(1, 11);//会将访问过的项放到链表最后
print(lh);//2=2, 3=3, 4=4, 0=0, 1=11,
}
static void init(Map map) {
for (int i = 0; i < 5; i++) {
map.put(i, i);
}
}
static void print(Map map) {
Iterator it = map.entrySet().iterator();
while (it.hasNext()) {
Entry entry = (Entry) it.next();
System.out.print(entry.getKey() + "=" + entry.getValue() + ", ");
}
System.out.println();
}
}
内存泄漏的第三个常见来源是监听器和其他回调。如果你实现了一个API,客户端在这个API中注册回调(即将回调实例存储到某个容器中),却没有显示地取消注册,那么除非你采取某些动作,否则它们就会积聚。确保立即被垃圾回收的最佳方法是只保存它的弱引用,例如,只将它们保存成WeakHashMap中的键。
内存泄漏剖析工具:Heap Profiler
7、 避免使用终结方法
终结方法(finalize)通常是不可预测的,也是限危险的,一般情况下是不必要的,它不是C++中的析构函数。为什么呢?在C++中所有的对象运用delete()一定会被销毁,而JAVA里的对象并非总会被垃圾回收器回收,所以调用的时机是不确定的。C++的析构器也可以被用来回收其他非内存资源,而在Java中,一般用try-finally块来完成类似的工作。
终结方法的缺点是不能保证会被及时地执行。
Java语言规范不仅不保证终结方法会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这是完全有可能的。
不要被System.gc和System.runFinalization这两个方法所诱惑,它们确实增加了终结方法被执行的机会,但是它们并不保证终结方法一定会被执行。唯一声称保证终结方法被执行的方法是System.runFinalizersOnExit,以及它臭名昭著的孪生兄弟Runtime.runFinalizersOnExit,这两个方法都有致命的缺陷(它可能对正在使用的对象调用终结方法,而其他线程正在操作这些对象,从而导致不正确的行为或死锁),已经被废弃了。注,runFinalizersOnExit(true)只是在JVM退出时才开始调用那些还没有调用的对象上的finalize方法(默认情况下JVM退出时不会调用这些方法),而不像前面的gc与runFinalization在调用稍后执行finalize方法(也可能不执行,因为垃圾收集器并未开始工作)。
如果未被捕获的异常在终结过程中被抛出来,那么该异常将被忽略,并且该对象的终结过程也会终止,并且不会打印异常栈轨迹信息,但该对象仍可以被垃圾收集器收集。
还有一点,使用终结方法有一个非常严重的性能损失。换句话说,用终结方法创建和销毁对象慢了很多。
如果类对象中封闭的资源确实需要终止,我们首先需提供一个显示的终止方法(如InputStream、OutputStream、Connection、Timer上的close方法),并通常与try-finally结构结合起来使用,以确保及时终止(另外要注意的是,该实例应该记录下自己是否已经被关闭了,如果用户已显示地关闭了,则在终结方法中不得再关闭),而不是将它们的释放工作放在finalize终结方法中执行。
当然终结方法不是一无事处的,它有两种合法用途。第一种用途是,当对象的所有者忘记调用前面段落中建议的显示终止方法时,终结方法可以充当“安全网”,我们可以在finalize方法中再进行一次释放资源的工作。这样做并不能保证终结方法会被及时调用或甚至不会被调用,但是在客户端无法通过调用显示的终止方法(或者是根本未调用或忘记调用)来正常结束操作的情况下,这样迟一点释放关键资源总比永远不释放要好。但是如果终结方法发现资源还未被终止,则应该在日志中记录一条警告(最好再调用一次释放资源的方法),因为这表示客户端代码中的一个Bug,应该得到修复,如果你正考虑编写这样的安全网终结方法,就要认真考虑清楚,这种的保护是否值得你付出这份额外的代价。
显示终止方法模式的类(如InputStream、OutputStream、Connection、Timer)都具有终结方法,当它们的终止方法未能被调用的情况下,可以再次在终结方法中显示调用它们的关闭方法,这样终结方法充当了安全网;第二种就是可以回收那些并不重要的本地资源(即本地方法所分配的资源)。
“终结方法链(父类的终结方法)”并不会被自动被执行。如果类(不是Object)有终结方法,并且子类覆盖了终结方法,子类的终结方法就必须手工调用超类的终结方法。你应该在一个try块中终结子类,并在相应的finally块中调用超类的终结方法。这样做可以保证:即使子类的终结过程抛出异常,超类的终结方法也会得到执行,如下面示例:
protected void finalize() throws Throwable{
try{
…// 子类回收动作
}finally{
super.finalize();// 调用父类的终结方法
}
}
如果子类实现者覆盖了超类的终结方法,但是忘了手式调用超类的终结方法,那么超类的终结方法永远也不会被调用到。要防范这样的粗心大意,我们可以为每个将被终结的对象创建一个附加的对象。不是把终结方法放在要求终结处理的类中,而是把终结方法放在一个匿名的类中,该匿名类的唯一作用就是终结它的外围实例。该匿名类的单个实例被称为终结方法守卫者,外围类的每个实例都会创建这样一个守卫者。外围实例在它的私有实例哉中保存着一个对其终结方法守卫者的唯一引用,因些终结方法守卫都与外围实例可以同时启动终结过程。当守卫都被终结的时候,它执行外围实例所期望的终结行为,就好像它的终结方法是外围对象上的一个方法一样:
class Foo{
//终结守卫者
private final Object finalizerGuardian = new Object(){
protected void finalize() throws Throwable{
System.out.println("Foo gc");
}
};
}
class Sub extends Foo{
// 即使没有在子类的终结方法中调用父类的终结方法,父类也会终结
protected void finalize() throws Throwable {
System.out.println("Sub gc");
}
}
注意,公有类Foo并没有终结方法(除了它从Object中继承了一个无关紧要的的之外),所以子类的终结方法是否调用super.finalize并不重要。
总之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用终结方法。当然如果使用了终结方法,就要记得调用super.finalize。如果用终结方法作为安全网,要记得记录终结方法的非法调用。最后,如果需要把终结方法与公有的非final类关联起来,请考虑使用终结方法守卫者,以确保即使子类的终结方法未能调用super.finalize,该终结方法也会被执行。
补充:
虽然终结一个对象时,它不会去自动调用父类的终结方法,除非手工调用super.finalize,但是父类里的成员域一定会被调用终结方法,这就是为什么终结守卫者安全的原因。另外,回收的顺序是不确定的,不会像C++中的那样,先调用子类析构函数,再调用父类析构函数。还有一点要注意的是,即使对象的finalize 已经运行了,不能保证该对象被销毁。因为对象可以重生。
对于任何给定对象,Java 虚拟机最多只调用一次 finalize 方法。
垃圾收集器的工作过程大致是这样的:一旦垃圾收集器准备好释放无用对象占用的存储空间,它首先调用那些对象的finalize()方法,然后才真正回收对象的内存。
与 Java 不同,C++ 支持局部对象(存储在栈中)和全局对象(存储在堆中),C++ 能对栈中的对象自动析构,但对于堆中的对象就要手动分配内存与释放。在 Java 中,所有对象都驻留在堆内存,而内存的回收则由垃圾收集器统一回收。
finalize可以用来保护非内存资源被释放,即使我们定义了其它的方法来释放非内存资源,但是其它人未必会调用该方法来释放。在finalize里面可以检查一下,如果没有释放就释放好了,晚释放总比不释放好,这样好比“双保险”。通常我们可以在finalize方法中释放容易被忽略的资源,并且这些都是非常重要的资源。
第三章 对所有对象都通用的方法
8、 覆盖equals时请遵守通用约定
如果类具有自己特定的“逻辑相等”概念(不同于对象等同概念),而且超类还没有覆盖equals以实现期望的行为,这时我们就需要覆盖equals方法,这通常属于“值类”的情形,例如Integer或者是Data,程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。
在覆盖equals方法时,你必须要遵守它的通用约定。下面是约定的内容,来自Object的规范:
l 自反性:对于任何非null的引用值x,x.equals(x)必须返回true。如果自已不等于自己的话,将其放入集合中后,该集合的contains方法将告诉你,集合中不包括你刚添加的实例。
l 对称性:对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。这就要求不同类的实例如果在逻辑值相同的情况下,要求这两个实例所对应的类的equals方法比较逻辑要相同,不然的话,对称性将不再满足。
l 传递性:对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
l 一致性:对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false。
l 非空性:对于任何非null的引用值x,x.equals(null)必须返回false。
实现高质量的equals方法:
1、 使用==操作符检查“参数是否为这个对象的引用”。如果是,则返回true,这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
2、 使用instanceof操作符检查“参数是否为正确的类型”。如果不是,则返回false。一般说来所谓“正确的类型”是指定equals所在的那个类。有些情况下,是指该类所实现的某个接口。如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口如Set、List、Map和Map.Entry具有这样的特性。注,这步会过滤掉null,因为 null instanceof XX 一定会返回false。另外,要注意的是,如果你只与自己本身类型的类相比,则可以使用if(getClass() == obj.getClass())来限制为同一个类比较而不希望是父类或其子类(思想来源于《Practice Java》)。
3、 把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。
4、 对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true,否则返回false。
对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用Float.compare方法;对于double域,则使用Double.compare。对于数组域,则要把以上这些指导原则应用到每个元素上,如果数组域中的每个元素都需要比较的话,可以使用1.5版本中发行的Arrays.equals方法。
对于float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量,详细信息请参考Float.equals的文档,看看Float.equals源码与文档描述:
public boolean equals(Object obj) {
return (obj instanceof Float)
&& (floatToIntBits(((Float) obj).value) == floatToIntBits(value));
}
在比较是否相等时使用是floatToIntBits(float)方法,即将浮点的二进制位看作是整型位后比较的。注意,在大多数情况下,对于 Float 类的两个实例 f1 和 f2,让 f1.equals(f2) 的值为 true 的条件是当且仅当 f1.floatValue() == f2.floatValue() 的值也为 true。但是也有下列两种例外:
l 如果 f1 和 f2 都表示 Float.NaN(规定Float.NaN = 0x7fc00000),那么即使 Float.NaN = = Float.NaN 的值为 false,equals 方法也将返回 true(因为他们所对应的整型位是相同的)。
l 如果 f1 表示 +0.0f,而 f2 表示 -0.0f,或相反的情况,则 equal 测试返回的值是 false(因为他们所对应的整型位是不同的),即使 0.0f = = -0.0f 的值为 true 也是如此。
另外,来看看Float.compare的源码:
public static int compare(float f1, float f2) {
if (f1 < f2)
return -1; // Neither val is NaN, thisVal is smaller
if (f1 > f2)
return 1; // Neither val is NaN, thisVal is larger
int thisBits = Float.floatToIntBits(f1);
int anotherBits = Float.floatToIntBits(f2);
return (thisBits == anotherBits ? 0 : // Values are equal
(thisBits < anotherBits ? -1 : // (-0.0, 0.0) or (!NaN, NaN)
1)); // (0.0, -0.0) or (NaN, !NaN)
}
compare是从数字上比较两个 Float 对象。在应用到基本 float 值时,有两种方法来比较执行此方法产生的值与执行Java 语言的数字比较运算符(<、<=、== 和 >= >)产生的那些值之间的区别:
l 该方法认为 Float.NaN 将等于其自身,且大于其他所有 float 值(包括 Float.POSITIVE_INFINITY)。
l 该方法认为 0.0f 将大于 -0.0f。
请记住,如果是通过Java 语言的数字比较运算符(<、<=、== 和 >= >)而不是compare方法来比较时,只要其中有一个操作为Float.NaN,那么比较结果就是false。
对象引用域的值为null是有可能的,所以,为了避免可能导致的空指针异常,则使用下面的作法: filed = = null ? o.field = = null : filed.equals(o.filed)
如果field和o.field通常是相等的对象引用,那么下面的做法就会更快一些:(field == o.field || (field != null && field.equals(o.field)))
5、 当你编写完成了equals方法之后,应该问自己:它是否是对称的、传递的、一致的?当然,equals方法也必须满足其他两个我(自反性和非空性),但是这两种我通常会自动满足。
另一点要注意的是如果该类有除Object以外的父类,则要考虑是否调用父类的equals方法,如super.equals(obj)(思想来源于《Practice Java),因为可能有些逻辑状态在父类中也需要比较。
下面的例子是根据上面的诀窍构建equals方法:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
// …
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNumber == lineNumber
&& pn.prefix == prefix
&& pn.areaCode == areaCode;
}
}
最后一点要注意的是,在重写equals方法时,参数类型应该为Object,而不应该是某个要比较的具体类,因为这样在调用equals方法时可能为调用成Object里的equals方法,比如外界将比较的对象赋值给一个Object类型的变量时就会有这个问题。
9、 覆盖equals时总是要覆盖hashCode
在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。如果不这样做的话,就会违反Object.hashCode的能用约定,从而导致该类无法结合所有基于散列的集合一起正常动作,这些集合包括HashMap、HashSet、Hashtable等。
下面是约定的内容:
l 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致(我想可能是因为对象的状态信息被修改过)。
l 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生相同的整数结果。
l 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(has table)的性能。
hash集合查找某个键是否存在时,采用取了优化方式,它们先比较的是两者的hashcode,如果不同,则直接返回false(因为放入合希集合的过程中元素的hashcode就已计算出并存Entry里的hash域中了,所以先比较这哈希值很快),否则再比较内容,源码部分如下:if (e.hash == hash && (x == y || x.equals(y))) 。
一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”,这正是上面约定中第三条含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布所有可能的散列值上。要完全达到这种理想的情形是非常困难的。但我们如果按照如下的规则来写hashCode函数,则可能比较理想:
1、 把某个非零的常数值,比如说17(值17是任选的,困此即即使2.a步骤中计算出的散列值为0初始域也会影响到散列值,这样会大大的避免了冲突的可能性,所以这个一般是一个非零的常数值),保存在一个名为result的int的类型变量中。
2、 对于对象中每个键域f(指equals方法中涉及的每个域),完成以下步骤:
a. 为该域计算int类型的散列码c:
I、 如果该域是boolean类型,则计算(f ? 1 : 0)。
II、 如果该域是byte、char、short或者int类型,则计算(int)f。
III、 如果该域是long类型,则计算(int)(f ^ (f >>> 32))。
IV、 如果该域是float类型,则计算Float.floatToIntBits(f),即将内存中的浮点数二进制位看作是整型的二进制,并将返回整型结果。
V、 如果该域是dobule类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.III。
VI、 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals方式来比较这个域,则同样为这个域递归地调用hashCode。如果这个域的值为null,则返回0。
VII、 如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组的每个元素都需要求,则可以使用1.5版本发行的Arrays.hashCode方法。
b. 按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中:
result = 31 * result + c;
步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只要组成该字符串的字符是一样的,而不管它们的排列的顺序,则会导致只要有相同字符内容的字符串就会相等的问题,而String的equals方法是与字符排序顺序有关的。另外,之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会全丢失,因为与2相乘等价于移位运算。31还有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i = i ^32 - i = (i << 5) – i,现代的VM可以自动完成这种优化。
3、 返回result。
4、 写完了hashCode方法后,问问自己“相等的实例是否都具有相等的散列码”。
在散列码计算的过程中,可以把冗余域排除在外,换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。但必须排除equals比较计算中没有用到的所有域,否则很有可能违反hashCode约定的第二条。
不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。
下面看看根据上面hashCode规则的实例:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNumber == lineNumber
&& pn.prefix == prefix
&& pn.areaCode == areaCode;
}
@Override public int hashCode() {
int result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
return result;
}
}
当然,如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象的内部,而不是每次请求的时候都重新计算散列码:
private volatile int hashCode; // (See Item 71)
@Override public int hashCode() {
int result = hashCode;
if (result == 0) {
result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
hashCode = result;
}
return result;
}
10、 始终要覆盖toString
toString方法应该返回对象中包含的所有值得关注的信息。建议所有的子类都覆盖这个方法。
在实现toString的时候,必须要做出一个很重要的决定:是否在文档中指定返回值的格式。如果格式化,则易阅读,但你得要一直保持这种格式,因而缺乏灵活性。
11、 谨慎地覆盖clone
Cloneable是一个标识性接口,没有任何方法,那么它到底有什么作用?它决定了Object中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException异常。
Object.clone()能够按对象大小创建足够的内存空间,从旧对象到新对象,复制所有的比特位。 这被称为逐位复制。但是,Object.clone()在执行操作前,会先检查此类是否可克隆,即检查 它是否实现了Cloneable接口。如果没有实现此接口,Object.clone()会抛出CloneNotSuppo rtedException异常,说明它不能被克隆。
如果某个类中每个成员域是一个基本类型的值(但不包括基本类型数组),或者是指向一个不可变对象的引用,那么我们直接调用Object中的clone方法就是我们要返回的拷贝对象了,而不需要对这个对象再做进一步的处理:
public final class PhoneNumber implements Cloneable {
private final short areaCode;
private final short prefix;
private final short lineNumber;
// …
// 注,这里返回的是PhoneNumber而不是Object,1.5版本后支持参数有协变:覆盖方法的返回烦劳可以是被覆盖方法的返回类型的子类。这样不用在客户端强转了。
@Override public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch(CloneNotSupportedException e) {
throw new AssertionError(); // Can't happen
}
}
}
如果对象含有引用类型且指向了可变对象,使用上述这种简单的clone实现可能会导致灾难性后果,考虑像上面那样克隆如下类(类来自于第6条):
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// …
}
假设你希望把这个类做成可克隆的(Cloneable),如果它的clone方法仅仅是返回super.clone(),这样得到的Stack实例,在其size域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。为了使用Stack类中的clone方法正常地工作,它必须拷贝栈的内部信息,最容易的做法是,在elements数组路递归地调用clone:
@Override public Stack clone() {
try {
Stack result = (Stack) super.clone();
// 1.5版本后不必将返回的Object类型结果强转成为Object[]类型,自1.5起,在数组上调用clone返回的数组,其编译时类型与被克隆数组的类型相同,1.5前返回的是Object对象。
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
注意,上面如果elements域是final的,上述方案就不能正常工作了,因为clone方法是被禁止给elements域赋新值的。这是个根本的问题:clone架构与引用可变对象的final域的正常用法是不相兼容的,可以说是相违背的,除非在原始对象和克隆对象之间可以安全地共享此可变对象(比如使用final修饰的StringBuffer就不可安全共享,如果是不可变对象如String则可安全共享,就可以不必克隆)。为了使类成为可克隆的,可能有必要从某些域中去掉final修饰符。
另外result.elements = elements.clone();所克隆的也只是elements中所有对象地址罢了,克隆出的数组里的元素还是与原数组指向同一个对象,如果要真真深层次克隆,则还是要对数组循环来一个个调用对象上的clone方法才行,下面就来看看这个问题的相应例子。
例如,假设你正在为自己设计的一个散列表编写clone方法,它的内部数据包含一个散列桶数组,每个散列桶都指向“键 — 值”对单向链表的第一个节点,如果桶是空的,则为null,该类如下:
public class Hashtable implements Cloneable {
private transient Entry buckets[];
private static class Entry {
final Object key;
Object value;
Entry next;
protected Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
}
…
}
假设你仅仅递归地克隆这个散列桶数组,就像我们能Stack类所做的那样:
@Override public HahsTable clone() {
try{
HashTable result = (HashTable)super.clone();
result.buckets = buckets.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
虽然被克隆对象有它自己的散列桶数组,但是,这个数组引用的链表对象与原始对象是一样的,从而很容易地引起克隆对象和原始对象中不确定的行为。为了修正这个问题,必须单独地拷贝并组成每个桶的链表,下面是一种常见做法:
public class Hashtable implements Cloneable {
private transient Entry buckets[];
private static class Entry {
final Object key;
Object value;
Entry next;
protected Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
Entry deepCopy(){// 递归地深层复制每个链表节点对象
return new Entry(key, value, next == null ? null : next.deepCopy());
}
}
@Override public HahsTable clone() {
try{
HashTable result = (HashTable)super.clone();
result.buckets = new Entry[buckets.length];
// 采用循环的方式对每个桶引用的单链表进行深层拷贝
for(int i = 0; i < buckets.length; i++){
if(buckets[i] != null){
result.buckets[i] = buckets[i].deepCopy();
}
}
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
…
}
Entry类中的深度拷贝方法递归地调用它自身,以便拷贝整个链表。虽然这种方法很灵活,因为针对列表中的每个元素,它都要消耗一段空间。如果链表比较长,这很容易导致栈溢出,为了避免发生这种情况,你可以在deepCopy中用迭代代替递归:
Entry deepCopy(){
Entry result = new Entry(key, value, next);
for(Entry p = result; p.next != null; p = p.next){
p.next = new Entry(p.next.key, p.next.value, p.next.next);
}
return result;
}
简而言之,所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone(如果不是公有的且没有覆盖Object中的clone方法,则外界不能克隆该类的实例)。此公有方法首先调用super.clone,然后修正任何需要修正的域。
数组具有clone方法,但我们不能使用反射来调用该方法,但可以拿到数组对象后直接调用。
直接通过调用数组对象的clone方法克隆出的对象是否是深度克隆,则要看这个数组是否是基本类型的数组,如果是则属于深度克隆,否则不是(但是数组对象本身还是被复制了一份的,而不是指向数组同一存储空间了,只是数组里的引用还是指向原来数组指向的对象,如果此时需对元素再次深度克隆,则需要对数组里的每个元素进行单独克隆处理)。
使用Object中的默认clone对某个类进行克隆时,任何类型的数组属性成员都只是浅复制,即克隆出来的数组与原来类中的数组指向同一存储空间,其他引用也是这样,只有基本类型才深复制。
12、 考虑实现Comparable接口
如果一个类实现了Comparabler接口,就表明它的实例具有内在的自然排序规则了。事实上,Java平台类库中的所有值类都实现了Comparable接口。如果你正在编写一个值类,它具有非常的内在排序关系,比如按字母顺序、按数值顺序或按年代,那你就应该考虑实现这个接口:
public interface Comparable<T>{
int compareTo(T t);
}
依赖于比较关系的类包括有序集全类TreeSet和TreeMap,以及工具类Collections和Arrays,它们内部包含有搜索和排序算法。
如果一个域并没有实现Comparable接口,或者你需要使用一个非标准的排序关系,就可以使用一个显式的Comparable来代替:
public interface Comparator<T> {
int compare(T o1, T o2);
}
比较整型基本类型的域,可以使用关系操作符 == 、< 和 >。但浮点域要使用Double.compare或者Float.comprae,而不是用关系操作符。
如果一个类有多个关键域,那么从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果,则整个比较操作结束,并返回该结果。如果最关键的域是相等的,则进一步比较次最关键的域,以此类推。如果所有的域都是相等,则对象就是相等的,并返回零,下面是第9条的PhoneNumber类的compareTo方法:
public int compareTo(PhoneNumber pn) {
// Compare area codes
if (areaCode < pn.areaCode)
return -1;
if (areaCode > pn.areaCode)
return 1;
// Area codes are equal, compare prefixes
if (prefix < pn.prefix)
return -1;
if (prefix > pn.prefix)
return 1;
// Area codes and prefixes are equal, compare line numbers
if (lineNumber < pn.lineNumber)
return -1;
if (lineNumber > pn.lineNumber)
return 1;
return 0; // All fields are equal
}
虽然这个方法可行,但可以改进一下,因为compareTo方法的规定并没有指定返回值的大小,而只是指定了返回值的符号:
public int compareTo(PhoneNumber pn) {
// Compare area codes
int areaCodeDiff = areaCode - pn.areaCode;
if (areaCodeDiff != 0)
return areaCodeDiff;
// Area codes are equal, compare prefixes
int prefixDiff = prefix - pn.prefix;
if (prefixDiff != 0)
return prefixDiff;
// Area codes and prefixes are equal, compare line numbers
return lineNumber - pn.lineNumber;
}
虽然比前面快一点,但用起来要非常小心。除非这些域不会为负数,或都更一般的情况:最小和最大的可能域值之差小于或等于Integer.MAX_VALUE,否则就不要使用这种方法。比如i是一个很大的正整数,而j是一个很大的负整数,那么i-j将会溢出。这不是理论,它已经在实际的系统中导致了失败,所以要格外小心,因为这样的compareTo方法对于大多数的输入值都能正常工作。
第四章 类和接口
13、 使类和成员的可访问性最小化
要区别设计良好的模块与设计不好的模块,最后重要的因素在于,这个模块对于外部的其他模块而言,是否隐藏其内部了数据和其他实现细节。设计良好的模块会隐藏所有的实现细节,把它的API与它的实现清晰地隔离开来。然后,模块之间只通过它们的API进行通信,一个模块不要知道其他模块的内部工作情况。这个概念被称为信息隐藏或封装,也是软件设计的基本原则之一。
封装有效地解除组成系统的各模块之间的耦合关系,使得这些模块可以独立地开发、测试与维护,还提高了软件的可重用性。
Java里的封装是通过访问控制符来加以限制的。
第一规则很简单:尽可能地使每个类或者成员不被外界访问,即使用尽可能最小的访问级别。
对于顶层类(非嵌套的)类和接口,只有两种可能的访问级别:包级私有的和公有的。如果使用pulbic修饰符声明了顶层类或者接口,那它就是公有的;否则,它将是包级私有的。如果顶层类或者接口能够被做成包级私有的,它就应该被做成包级私有,这样类或者是接口就成了这个包的实现的一部分,而不是该包导出的API的一部分,在以后的版本中,可以对它进行修改、替换、或者删除,而无需担心会影响到现有的客户端程序。
如果一个包级私有的顶层类(或者接口)只是在某一个类的内部用到,就应该考虑使它成为唯一使用它的那个类的私有嵌套类。这样可以将它的可访问范围从包中的所有类缩小到了使用它的那个类。然而,降低不必要公有类的可访问性,比降低包级私有的顶层类的更重要得多:因为公有类是包的API的一部分,而包级私有的顶层类只是这个包的实现的一部分,包级是我们可以掌控的一部分。
对于成员(域、方法、嵌套类和嵌套接口)有四种可能的访问级别,下面按照可访问性的递增顺序罗列出来:
1、 私有的(private):只有在声明该成员的顶层类内部才可以访问这个成员。
2、 包级私有的:声明该成员的包内部的任何的任何类都可以访问这个成员。这也是“缺省”访问级别,如果没有为成员指定访问修饰符,就采用这个访问级别。
3、 受保护的(protected):声明该成员的类的子类可以访问这个成员(但有一些限制[JLS,6.6.2]),并且声明该成员的包内部的任何类也可以访问这个成员。
4、 公有的(public):在任何地方都可以访问该成员。
只有当同一个包内的另一个类真正需要访问一个成员的时候,你才应该删除private修饰符,使该成员变成包私有的。
私有成员和包级私有成员都是一个类的实现中的一部分,一般不会影响它的导出的API。然而,如果这个类实现了Serializable接口(见第74和75),这些域就有可能会被“泄漏”到导出的API中。
对于公有类的成员,当访问级别从包级私有变成保护级别时,会大大增强可访问性。受保护的成员是类的导出的API的一部分,必须永远得到支持。导出的类的受保护成员也代表了该类对于某个实现细节的公开承诺(见第17条)。受保护的成员应该尽量少用。
如果方法覆盖了超类中的一个方法,子类中的访问级别就不允许低于超类中的访问级别。这样就可以确保任何可使用超类的实例的地方也都可以使用子类的实例。
如果一个类实现了一个接口,那么接口中所有的类方法在这个类中也都必须被声明为公有的。之所以如此,是因为接口中的所有方法都隐含着公有访问级别。
为了测试而将一个公有类的私有成员变成包级别私有的,这还可以接受,但是如果要将访问级别提高到超过包访问级别,这就无法接受了。换包话说,不能为了测试,而将类、接口、或成员变成包的导出的API的一部分。幸运的是,也没有必要这么做,因为可以让测试作为被测试的包的一部分来运行,从而能够访问它的包级私有的元素。
实例域决不能是公有的(见第14条)。如果域是非final的,或者即是final但指向的却是可变对象,那么一旦使这个域成为公有的,就放弃了对存储在这个域中的值进行限制的能力;这意味着,你也放弃了强制这个域不可变的能力。因此,包含公有可变域的类并不是线程安全的。即使域是final的,并且引用不可变的对象,当把这个域变成公有的时候,也就放弃了“切换换到一种新的内部了数据表示法”(比如将这个字段删除掉,或者使用多个字段来表示等)的灵活性。
public final修饰的域要么包含基本类型的值,要么包含指向不可变对象的引用(见第15条)(同样的建议也适用于静态域)。如果final域包含可变对象的引用,它更具有非final域的所有缺点,虽然引用本身不能被修改,但是它所引用的对象却可以被修改。
长度非零的数组总是可变的,所以,类具有公有的静态final数组域,或者返回这种域的访问方法(这与方法返回的是局部数组是不一样的,因为这个是共享的,而返回的局部数组是单个线程共享的),这几乎总是错误的。如果类具有这样的域或者访问方法,客户端将能够修改数组中的内容,这是安全漏洞的一个常见根源:
public static final Thing[] VALUES={…};
修正这个问题有两种方法,可以使公有数组变成私有的,并增加一个公有的不可变列表:
private static final Thing[] PRIVATE_VALUES={…};
public static final LIST VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
另一种方法是,可以使用数组变成私有的,并添加一个公有方法,它返回私有的数组的一个备份:
private static final Thing[]PRIVATE_VALUES ={…};
public static final Thing[] values(){
return PRIVATE_VALUES.clone();
}
但依我个人看,上面的克隆还是不起根本作用(如果数组元素是基本类型的,就没有问题):虽然各个元素引用已被复制,但是它们所引用的对象却是同一份,如果指向的对象是可变的,则还是可以通过引用去修改它们所指向对象的问题,所以上面的修改也有潜在的问题。
从上面分析来看public static final最好修改的是基本类型的变量,或者是不可变的类。
总而言之,你应该始终尽量可能地降低可访问性。除了公有静态final域的特殊情形之外,公有类都不应该包含公有域。并且要确保公有静态final域所引用的对象都是不可变的。
14、 在公有类中使用访问方法而非公有域
如果类可以在它所在的包的外部进行访问,就将域设为私有的并提供域的访问与设置公有方法,以保留将来改变该类的内部表示法的灵活性。如果公有类暴露了它的数据域,要想在将来改变其内部表示法是不可能的,因为公有类的客户端已经遍布各处了。另外,如果不采取这种方式,则当域被访问的时候,无法彩任何辅助的行动。
如果类是包级私有的(虽然将域暴露了,但也只仅限于包含该类的包中,还在你可控的范围之内),或者是私有的嵌套类,直接暴露它的数据域并没有本质的错误——假设这些数据域确实描述了该类所提供的抽象。比如LinkedList中的Entry静态内部类,就暴露了所有的成员给外类了,这样用起来更简洁方便:
public class LinkedList{
//...
private static class Entry {
Object element;
Entry next;
Entry previous;
//...
}
}
让公有类直接暴露域虽然从来都不是种好办法,但是如果域是不可变的,这种做法危害就比较小一些(只是不能改变其指向,但指向的内容是否安全就不好说了)。如果不改变类的API,就无法改变这种类的表示法,当域被读取的时候,你也无法采取辅助的行动。
总之,公有类永远不应该暴露可变的域。虽然还是有问题,但是让公有类暴露不可变域其危害比较小。但是,有时候会需要使包级私有的或者私有的嵌套灰来暴露域,无论这个类是可变还是不可变的。
15、 使可变性最小化
不可变类是其实例不能被修改的类(不只是类前加上了final就可以了)。每个实例中包含的所有信息都必须在创建该实例时候就提供,并在对象的整个生命周期内固定不变。
Java平台类库上有很多不可变的类,其中有String、基本类型的包装类、BigInteger和BigDecimal。
存在不可变内的许多理由:不可变类比可变类更加易于设计、实现和使用。它们不容易出错,且更加安全。
为使类成为不可变,要遵循以下5条规则:
1、 不要提供任何会修改对象状态(属性)的方法。
2、 保证类不会被扩展。一般做法是使这个类成为final的,另外作法就是让类所有构造器都变成私有的或者是包级私有的。
3、 使用有的域都是final的(一般是针对非静态变量)。通过这种加上final修饰的强制方式,这可以清楚地表明你的意图:确保该域在使用前得到正确的初始化。而且,如果一个指向新创建的实例的引用在缺乏同步机制(一般不可变对象的访问是不需要同步的,因为状态一旦确定,就不会再更改)的情况下,从一个线程切换另一个线程就必需确保实例的正确状态(比如刚创建完这个实例,但还没来得及将工作内存中的数据存回到主内存中时就会有问题,但如果是加上了final修饰符后,则不会出现使用前final域还会初始化完成的情况,这样一定能保证构造器调用完后final域也会随之初始化完成。虽然以前很早的虚拟机上会出现构造器执行完成后final域未初始化完成的问题,但现已JMM已修复),正如果内存模型中所述那样[JLS 17.5]。
4、 使用所有的域都成为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。虽然从技术上讲,允许不可变的类具有公有的final域,只要这些域包含基本类型的值或都指向不可变对象的引用,但是不建议这样做,因为这样会使得在以后的版本中无法以再改变内部的表示法。
5、 确保对于任何可变域的互斥访问。如果类具有指向可变对象域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法中返回该对象的引用(即进出都不行)。在构造器、访问方法、readObject方法(见76条)中请使用保护性拷贝技术。
下面是一个不可变复数(具有实部和虚部)类的例子:
//复数
public final class Complex {
private final double re;//实部
private final double im;//虚部
// 私有的,让它无法扩展
private Complex(double re, double im) {
this.re = re;
this.im = im;
}
//静态工厂方法
public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}
public static Complex valueOfPolar(double r, double theta) {
return new Complex(r * Math.cos(theta), r * Math.sin(theta));
}
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
// 可以直接返回基本类型的值
public double realPart() {
return re;
}
public double imaginaryPart() {
return im;
}
//每次加减乘除都返回一个新的对象
public Complex add(Complex c) {
return new Complex(re + c.re, im + c.im);
}
public Complex subtract(Complex c) {
return new Complex(re - c.re, im - c.im);
}
public Complex multiply(Complex c) {
return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
}
public Complex divide(Complex c) {
double tmp = c.re * c.re + c.im * c.im;
return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re
* c.im)
/ tmp);
}
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Complex))
return false;
Complex c = (Complex) o;
// 浮点数的比较要使用Double.compare,而不能直接使用==比较
return Double.compare(re, c.re) == 0 && Double.compare(im, c.im) == 0;
}
public int hashCode() {
int result = 17 + hashDouble(re);
result = 31 * result + hashDouble(im);
return result;
}
private int hashDouble(double val) {
long longBits = Double.doubleToLongBits(re);
return (int) (longBits ^ (longBits >>> 32));
}
public String toString() {
return "(" + re + " + " + im + "i)";
}
}
不可变对象只有一种状态,即被创建时的状态。不可变对象本质上是线程安全的,它们不要求同步,可以被自由的共享。不可变类应该充分利用这种优势,鼓励客户端尽可能地重用现有的实例,要做到这一点,一个很简便的办法就是,对于频繁用到的值,为它们提供公有的静态final常量,例如上面的常量:
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
这种方法可以被进一步的扩展,不可变以的类可以提供一些静态工厂,它们把频繁请求主的实例缓存起来,在请求合适的对象时候,就不必要创建新的实例。所有的基本类型的包装类和BigInteger都有这样的静态工厂。使得实例可以共享,从而降低内存与垃圾回收成本。在设计类时,选择用静态工厂代替公有的构造器可以让你以后有缓存的灵活性,而不必影响客户端。
“不可变对象可以被自由地共享”,我们永远也不需要,也不应该为不可变对的类提供clone方法或者拷贝构造器。这一点在Java平台早期的版本中做得并不好,所以String类仍然具有拷贝构造器,但是应该尽量少用它。
不仅可以共享不可变对象,甚至也可以共享它们的内部信息。如BigInteger的negate方法产生一个新的BigInteger,其中数组是一样的,符号则是相反的,它并不需要拷贝数组;新建的BigInteger也指向原始实例中的同一个内部数组。
不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象,创建这种对象的代价可能很高,特别是对于大型对象的情形。
如果你选择让自己的不可变实现Serializable接口,并具它包含一个或者多个指向可变对象的域,就必须提供一个显示的readObject或者readResolve方法,或者使用ObjectOutputStream.writeUnshared和ObjectInputStream.readUnshared方法,否则攻击者或能从不可变的类创建可变的实例。这个话题详细请参见76条。
除非有很好的理由要让类成为可变的类,否则就应该是不可变的。不可变的类优点有很多,唯一缺点是在特定的情况下存在潜在的性能问题。你应该总是使用一些小的值对象(如Complex),成为不可变的。但你也应该认真考虑把一些较大的值对象做成不可变的(String、BigInteger),只有当你确认有性能问题时,才应该为不可变的类提供一个公有的可变配套类(如String的配套类StringBuffer、StringBuilder;还有BigInteger的配套类为BitSet)。
对于有些类而言,基不可变性是不切实际的。如果为不能被做成是不可变的,仍然应该尽可能地限制它的可变性。除非有使人信服的理由要使域变成是非final的,否则要使每个域都是final的。
构造器应该创建完全初始化的对象,并建立起所有的约束关系。不要在构造器或者静态工厂之外再提供公有的初始化方法,除非有使人信服的理由。同样也不应该提供“重新初始化”方法(比如TimerTask类,它是可变的,一旦完成或被取消,就不可能再对它重新调度),与所增加的复杂性相比,通常并没有带来太多的性能。
关于 不可变对象可变的修复问题 请参考:http://www.ibm.com/developerworks/cn/java/j-jtp02244/
总之,新的内存模型中,以前不可变对象与final域的可变问题得到了修复,可以放心的使用了(为了确保String的安全初始化,1.5中String的value[]、offset、count三个字段前都已加上了final修饰符,这样新的Java内存模型会确认final字段的初始化安全)。
16、 组合优先于继承
继承是实现代码重用的有力手段,但它并非是最好的手段。
在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处在同一个程序员的控制之下,即你还可以修改一下超类的东西。对普通的具体类进行跨越包边界的继承,则是非常危险的,因为你一旦发布包之后,你就得要遵循你的接口,而不能轻易的去修改它而影响客户端已有的应用。
与组合复用不同的是,继承打破了封装性。换句话说,子类依赖于其超类中特定的功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。
下面程序要使用HashSet功能,直接采用了继承:
public class InstrumentedHashSet<E> extends HashSet<E> {
/*
* 用来记录添加了多少个元素,与HashSet的size是不一样的
* HashSet的size会受到删除的影响,而这个只记录添加的元素
* 个数
*/
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override
//因为要记录我已放入的元素,所以重写了父类的方法
public boolean add(E e) {
addCount++;
return super.add(e);//最终还是调用HashSet 的add方法将元素存入
}
/*
* super.addAll是以调用HashSet 的add方法来实现的,而add方法又被子类
* 重写了,所以该类的add方法也会被再次调用,即实质上调用addAll
* 方法的时候,它也会去调用一下add方法,这在我们不调试的情况下
* 是很难发现的,这就是继承所带来的后果:因为我们依赖了父类的实现的细节。
*/
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
这个类看起来非常合理,但是它并不能正常工作,上面的子例返回的是6,而不是我们期望的3,为什么?在HashSet的内部,addAll方法是基于它的add方法来实现的,即addAll是循环调用add来实现的(即使HashSet的文档并没有说明这样的细节也是合理的,因为该类就不是为了用来继承的),而这add方法又被我们设计的这个类InstrumentedHashSet重写了,所以会再次调用我们的add方法。
我们只去掉被重写的addAll方法中的“addCount += c.size();”,就可以“修正”这个子类,但我们不能否认这样的实事:HashSet的addAll方法是在它的add方法上实现的,这种“自用性(self-use)”是实现的细节,它不是承诺,所以Sun不能保证在Java平台的所有版本实现中都保持不变,即不能保证随着发行版本的不同而不发生变化(比如说将来HashSetr addAll不是以调用add方法来实现),这样的实现细节是不必承诺了,他们能承诺的也只是公开的接口而已,因此,这样得到的InstrumentedHashSet类将是非常脆弱的。
这里稍微好一点的做法是,在InstrumentedHashSet中重写addAll方法来遍历指定的集合,为每个元素调用一次add方法,这样做可以保证得到正确的结果 ,不管HashSet的addAll方法是否是以调用add方法来实现的,因为HashSet的addAll实现将不会再被调用:
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
Iterator<? extends E> e = c.iterator();
while (e.hasNext()) {
//直接调用自己重写过的add方法,重写addAll后,不再依赖于HashSet的addAll
if (add(e.next()))
modified = true;
}
return modified;
}
以上实现只是把HashSet的addAll方法Copy过来而已。然而,这项技术并没有解决所有的问题,它相当于重新自己实现了超类的方法,这些超类的方法可能是自用的(self-use),也可能不是,如果是则这里的重新实现还有点意义,如果不是,则重新实现则完全是没有必要的,并且可能很容易出错。另外有一点,也许你说管他父类的addAll方法是否是自用的,我重新实现就是了,但是,你想过没有,如果父类中的addAll修改了某个私有的域的状态,那你该如何做?我们是无法在重新实现时修改父类中的那个私有域的状态的,因为我们根本无法访问到,所以,总之这种方案是不可取的,因为有太多的不确定因素。
导致子类脆弱的一个相关的原因就是,它们的超类在后续的版本中可能添加新的方法。假设我们的程序的有这样一个要求:所有被插入到某个集合中的元素都必须满足某个条件(比如上面在放入之前addCount会记录一下)。这样做是可以确保一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的版本中,超类中没有增加能插入元素的新方法,这种做法是安全的。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被覆盖的新的方法,而将“非法的”元素添加到子类的实例中。这不是纯粹的理念问题,在把Hashtable和Vector加入到集合框架中时,就修正了这几个类性质的安全漏洞。
上面的问题都是来源于覆盖动作。如果在继承一个类的时候,仅仅是添加新的方法,而没有覆盖现有的父类中的方法,你可能会认为是安全的,但是不然,比如你现在已继承了某个类,并扩展了父类,并添加了父类中没有方法,但是有可能将来父类也会添加同样签名的方法,但重写的条件不满足时,此时你设计的子类将不能编译,或者即使满足重写条件,这样又会有问题(如父类私有域状态的维护工作)。这样的问题不是不存在的,因为当你在编写子类方法时候,你肯定是不会知道将来父类会增加这样的名称的方法。
幸运的是,有一种办法可以避免前面提到的两个问题(覆盖可能影响父类私有域的状态不正确,部分覆盖又可能影响“自用性(self-use)”),不用继承现有类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称做为“组合”,因为现有类变成了新的类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为“转发”,新类中的方法被称为转发方法,这样得到的类将会非常稳固,它不依赖于现有类的实现细节,即使现有的类添加了新的方法,也不会影响新的类。下面我们使用“组合”来修正前面两个问题,注意这个实现分为两部分:类本身和可重用转发类,转发类没有其他方法,全是转发方法:
//功能不变,接口也不变,这叫转发
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;//组合
public ForwardingSet(Set<E> s) { this.s = s; }
//下面全是转发方法
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection<?> c)
{ return s.containsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
//下面全是重写Object中的方法
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
super(s);
}
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedSet<String> s =
new InstrumentedSet<String>(new HashSet<String>());
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
Set接口的存在使得InstrumentedSet类的设计成Set集合类成为可能,因为Set接口保存了HashSet类的功能特性,除了获得健壮性之外,这种设计也带来了格外灵活性。InstrumentedSet类实现了Set接口,并且拥有单个构造器,它的参数也是Set类型,从本质上讲,这个类把一个Set转变成了另一个Set,同时增加了计数器功能。
因为每一个InstrumentedSet实例都把另一个Set实例包装起来了,所以InstrumentedSet类被称做为包装类,这也正是Decorator装饰模式。InstrumentedSet类对一个集合进行了装饰,为它增加了计数特性。有时,组合和转发的结合也被错误地称为“委托”,从技术的角度而言,这不是委托,除非包装对象(InstrumentedSet)把自身传递给被包装的对象(HashSet)。
组合类几乎没有缺点,但需要注意的一点是,包装类不适合用在回调框架中;在回调框架中,对象把自身的引用传递给其他的对象,所以它传递一个指向自身的引用(this),回调时避开了外面包装对象,这被称为SELF问题。
有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发方法倒有点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包替你提供。
只有当子类真正是超类的子类型时,才适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在“is-a”关系的时候,类B才应该扩展类A。如果你打算让类B扩展类A,就应该问问自己了:每个B确实也是A吗?如果不能确定,通常情况下,B应该包含A的一个私有实例,并且暴露一个较小的、比较简单的API:A本质上不是B的一部分,只是它的实现细节而已。
在Java平台类库中,有许多明显违反这条原则的地方,例如,栈并不是向量,所以Stack不能继承Vecotr;属性列表也不是散列,所以Properties不能继承Hashtable,在这种情况下,复合模式才是恰当的。
如果在适合于使用组合的地方使用了继承,则会不必有地暴露实现细节(如暴露不必要的接口导致外界调用这些接口来非法改变实例的状态)。这样得到的API会把你限制在原始的实现上,永远限定了类的性能,更为严重的是,由于暴露了内部的细节或不必要的接口,客户端就有可能直接访问这些内部细节,从而破坏实现的内部状态。
继承机制会把超API中的所有缺陷传播到子类中,而复合则允许设计新的API来隐藏这些缺陷。
简而言之,继承的功能非常强大,但是也存在诸多的问题,因为它违背了封装的原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即使如此,如果子类和超类外在不同的包中,并且超类并不是为了继承而设计的,那么继承将会脆弱性,为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装为不仅比子类更加健壮,而且功能也更加强大。
17、 要么为继承而设计并提供文档说明,要么就禁止继承
第16条提醒我们,继承自一个不是为了继承而设计、并且没有文档说明的“外来”类是危险的。
如果一个类是专为了继承而设计的,要求有哪些呢?
首先,该类的文档必须精确地描述覆盖每个方法所带来的影响。换句话说,该类必须有文档说明它可覆盖的方法的自用性(self-use)。对于每个公有的或受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果以是如何影响后续的处理过程的(如在哪些情况下它会调用可覆盖的方法,必须在文档中说明)。
按照惯例,如果方法调用到了可覆盖的方法,在它的文档注释的末尾该包含关于这些调用的描述信息。这段描述信息要以这样的句子开头:“This implementation.(该实现...)”。这样的句子不是在表明该方法可能会随着版本的变迁而改变,而是表明了该方法的内部工作情况。下面是摘自java.util.AbstractCollection的规范:
public boolean remove(Object o):
从此 collection 中移除指定元素的单个实例(如果存在)(可选操作)。更正式地说,如果该 collection 包含一个或多个满足 (o==null ? e==null : o.equals(e)) 的元素 e,则移除 e。如果该 collection 包含指定的元素(或等价元素,如果该 collection 随调用的结果发生变化),则返回 true。
此实现在该 collection 上进行迭代,查找指定的元素。如果找到该元素,那么它会使用迭代器的 remove 方法从该 collection 中移除该元素。注意,如果此 collection 的 iterator 方法所返回的迭代器无法实现 remove 方法,并且此 collection 包含指定的对象,那么此实现会抛出 UnsupportedOperationException。
该文档清楚地说明了,覆盖iterator方法将会影响remove方法的行为。而且,它确切地描述了iterator方法返回的Iterator的行为将会臬影响remove方法的行为。与此相反,在第16条的情形中,程序员在子类化HashSet时,并没有说明覆盖add方法是否会影响addAll方法的行为。
关于程序文档有句格言:好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。而HashSet的add方法违背了这句格言,这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全的继承,你必须描述清楚那些有可能未定义的实现细节(引导子类如何实现)。
类可以通过某种形式提供适当的钩子(hook),以便能够进行入到它的内部工作流程,这种形式可以是精心选择的受保护(protected)方法。
为了允许继承,类还必须遵守其他一些约束。构造器决不能调用可被覆盖的方法。因为超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前就先被调用,如果该覆盖版本的方法依赖于子类构造器所执行的任何初始化工作,该方法将不会如预期般地执行。
如果你决定在一个为了继承而设计的类中实现Cloneable或者Serializable接口,就应该意识到,因为clone和readObject方法在行为上非常类似于构造器,所以类似的限制规则也是适用的:无论是clone还是readObject,都不可以调用可覆盖的方法。对于readObject方法,覆盖版本的方法将在子类的状态被反序列化前先被运行;而对于clone方法,覆盖版本的方法则在子类的clone方法调用来修正 被克隆对象的状态之前先被运行(即父类的clone方法调用了覆盖的方法,而子类的clone却还没调用,这样就可能没有初始化这个覆盖方法所依赖的数据)。
对于普通的具体类应该怎么办?他们即不是final的,也不是为了子类化而设计和编写文档的,所以这种状况很危险。这个问题的最佳解决方案是,对于那些并不能安全进行子类化又没有编写文档的类,要禁止子类化。禁止子类化有两种方法,一种就是定义成final的,另一种就是私有构造器,交提供静态工厂方法,可参考第15条。
这条建议可能会引来争议,因为许多程序员已经习惯了对普通的具体类进行子类化,以便增加新的功能。如果这个普通类实现了某个接口,比如Set、List或者Map,就完全可以禁止该普通类可子类化,因为即使禁止了,我们可以采第16条里的组合与转发即包装模式来提供一种更好的办法;如果具休的类并没有实现标准的接口,那么禁止继承可能会给有些程序员带来不便,如果认为必须允许从这样的类继承,一种合理的办法是确保这个类永远不会自己调用自已的任何可覆盖的方法,并在文档中说明这一点,换句话说,完全消除这个类中可覆盖方法的自用特性,这样做后就可以创建“能够安全地进行子类化”的类。
当然,你可以使用一种机械的做法来消除可覆盖方法的自用特性,而不改变它的功能行为。就是直接将被调用的可覆盖方法的实现代码Copy到另外可覆盖方法中,让它们不相互依赖调用,那么此时子类就可以继承并覆盖这些方法了。
18、 接口优于抽象类
如果这些方法尚不存在,你所需要做的就是只是增加必要的方法,然后在类的声明中增加一个implements子句,实现抽象方法即可。但一般来说,你无法直接修改当前类来扩展新的抽象类,此时你只能将抽象类从继承树的最底向上移到最顶层,
实现新的接口,现有类可以很容易被更新:从代码重构的角度上讲,将一个单独的Java具体类重构成一个实现某个Java接口是很容易的,只需要声明一个Java接口,并将重要的方法添加到接口声明中,然后在具体定义语句后面加上一个合适的implements关键字即可, 例如,当Comparable接口被引入到Java平台中时,会更新许多现有的类,以实现Comparable接口;而为一个具体类添加一个抽象类作为抽象类型却不是那么容易,因为这个具体类可能已经有了一个超类。这样一来,这样新定义的抽象类只好继续向上移动,变成这个超类的超类,这样,最后这个新定义的抽象类必定处于整个类型等级结构的最上端,从而使等级结构中的所有成员都会受到影响,无论这个抽象类对于这些后代类是否合适。
接口是定义混合类型的理想选择:混合类型就是类除了实现或继承了它的“基本类型(也可叫主要类型)”之外,还可以实现这个混合类型,以表明它提供了某些可供选择的行为。例如,Comparable就是一个混合接口,它允表明实现了Comparable混合接口的实例可以与其他的可相互比较的对象进行排序。抽象类是不能被用于定义混合类型的,同样也是因为它们不能被添加到现有的类中:类不可能有一个以上的父类,类层次结构中也没有适当的地方来插入这种混合类型。
接口允许我们构造非层次结构的类型框架:类型层次对于组织某些事物是非常合适的,但是其他有些事物并不能被整齐地组织成一个严格的层次结构。例如,假设我们有一个接口代表一个Singer(歌唱家),另一个接口代表一个SongWriter(作曲家):
public interface Singer{
AudioClip sing(Song s);
}
public interface SongWriter{
Song compose(boolean hit);
}
在现实生活中,有些歌唱家本身也是作家。因为我们这里使用了接口而不是抽象类来定义这些类型,所以对于单个类而言,它同时实现Singer和SongWriter是完全允许的。实际上,我们可以定义第三个接口,它同时扩展了Singer和SongWriter,并添加了一些适合于这种组合的新方法:
public interface SingerSongwriter extends Singer,SongWriter{
AudioClip strum();
void actSensitive();
}
你也许并不总需要这种灵活性,但是一旦你这样做了,接口可就成了救世主,能帮助你解决大问题,因为这种非层关系的类我们可以自由的组合。这里另外一种做法是编写一个臃肿的类层次,对于每一种要被支持的属性组合,都包含一个单独的类。如果在整个类型系统中有n个属性,那么就必须支持2^n种可能的组合,这种现象被称为“组合爆炸”,类层次的臃肿会导致类也臃肿,这些类包含许多方法,并且这些方法只是在参数类型上有所不同而已,因为类层次中没有任何类型体现了公共的行为特征。所以这里如果使用抽象类来定义的话显示是不合适的,
虽然接口不允许包含方法的实现,但是,使用接口来定义类型并不妨碍你为程序提供实现上的帮助。通过对你导出的每个重要接口都提供一个抽象类,将公用的方法实现,把接口和抽象类的优点结合起来。接口的作用仍然是定义类型,但是抽象类接管了所有与接口实现相关的工作。
按照惯例,我们一般将提供的抽象类命名为AbstractInterface这种形式,这里的Interface是指所实现的接口的名字。例如,集合框架为每个重要的集合接口都提供了一个骨架实现,如AbstractCollection、AbstractSet、AbstractList和AbstractMap。再来看看这些集合类的定义:
public class ArrayList extends AbstractList implements List{}
public class HashSet extends AbstractSet implements Set{}
public class HashMap extends AbstractMap implements Map{}
如果设计得当,抽象类可以使用程序员很容易就提供他们自己的接口实现。例如,下面是一个静态工厂方法,它包含一个完整的、功能全面的List实现:
public class IntArrays {
static List<Integer> intArrayAsList(final int[] a) {
if (a == null)
throw new NullPointerException();
return new AbstractList<Integer>() {
public Integer get(int i) {
return a[i]; // Autoboxing (Item 5)
}
@Override public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // Auto-unboxing
return oldVal; // Autoboxing
}
public int size() {
return a.length;
}
};
}
public static void main(String[] args) {
int[] a = new int[10];
for (int i = 0; i < a.length; i++)
a[i] = i;
List<Integer> list = intArrayAsList(a);
Collections.shuffle(list);
System.out.println(list);
}
}
编写抽象类相对比较简单,只是有点单调乏味。首先,必须认真研究接口,并确定哪些方法是最为基本的,其他的方法则可以根据它们来实现。这些基本的方法将成为抽象类中的抽象方法。然后,必须为接口中所有其他的方法提供具体的实现。例如,下面是Map.Entry接口的抽象类:
public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {
// 基本操作,定义为抽象的
public abstract K getKey();
public abstract V getValue();
// 可修改的Map中的实体Entry需要实现
public V setValue(V value) {
throw new UnsupportedOperationException();
}
// 实现共(通)用接口Map.Entry.equals
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (! (o instanceof Map.Entry))
return false;
Map.Entry<?,?> arg = (Map.Entry) o;
return equals(getKey(), arg.getKey()) &&
equals(getValue(), arg.getValue());
}
private static boolean equals(Object o1, Object o2) {
return o1 == null ? o2 == null : o1.equals(o2);
}
// 实现共(通)用接口Map.Entry.hashCode
@Override public int hashCode() {
return hashCode(getKey()) ^ hashCode(getValue());
}
private static int hashCode(Object obj) {
return obj == null ? 0 : obj.hashCode();
}
}
抽象类的唯一优点就是抽象类的演变比接口的演变要容易得多:如果在后续的发行版本中,你希望在抽象类中增加新的方法,你始终可以增加具体方法,如果向一个抽象类加入一个新的具体方法,那么所有的子类一下子就都得到了这个新的具体方法,而Java接口做不到这一点,如果向一个Java接口加入一个新方法的话,所有实现这个接口的类就不能通过编译了,因为它们都没有实现这个新声明的方法。这显然是Java接口的一个缺点。
由于Java抽象类具有提供缺省实现的优点,而Java接口具有其他所有的优点,所以联合使用就是一个很好的选择。如果一个具体类直接实现这个Java接口的话,它就必须自行实现所有的接口;相反,如果它继承自抽象类的话,它可以省去一些不必要的方法,因为它可以从抽象类中自动得到这些方法的缺省实现。如果需要向Java接口加入一个新的方法的话,那么只要同时向这个抽象类加入这个方法的一个具体实现就可以了,因为所有继承自这个抽象类的子类都会从这个抽象类得到这个具体方法。这其实就是一种缺省适配器模式。一般来说,要想在公开的接口增加方法,而不破坏实现这个接口的所有现有类,这是不可能的,除非像上面说的那样,一开始就让某个类实现接口的时候,也继承抽象类,但这是不完全可能的,所以不从抽象类继承的接口实现类仍然会无法编译。
因此,设计公有接口要非常谨慎,接口一旦被公开发行,并且已被广泛实现,再想改变这个接口几乎是不可能的。你必须在初次设计的时候就保证接口是正确的。如果接口包含即使微小的瑕疵,它将会一直影响接口用户。如果接口具有严重缺陷,它可以导致API彻底的失败。
简而言之,接口通常是定义允许多个实现的类型的最佳途径。这条规则有个例外,即当演变的容易性比灵活性和功能更为重要的时候,在这种情况下,应该使用抽象类来定义类型,但前提是必须理解并且可以接受抽象类的局限性。如果你导出一个重要接口,就应该坚决考虑同时提供一个抽象类。最后,应该尽可能谨慎地设计所有公有接口,一旦发行,将不可更改。
19、 接口只用于定义类型
当类实现接口时,接口就充当可以引用这个类的实例的类型。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的而定义接口是不恰当的。
有一种接口称为常量接口,它违反了上面的条件,这种接口不包含任何方法,它只包含静态的final域,每个域都导出一个常量。使用这些常量的类实现这个接口,以避免用类名来修饰常量名:
public interface PhysicalConstants {
static final double AVOGADROS_NUMBER = 6.02214199e23;
}
class Sub implements PhysicalConstants {
public static void main(String[] args) {
System.out.println(AVOGADROS_NUMBER);
}
}
常量接口模式是对接口的不良使用。类在内部使用某些常量,这纯粹是实现细节。实现常量接口,会导致把这样的实现细节泄露到该类的导出API中。类实现常量接口没有什么价值。如果在将来的发行版本中,这个类被修改了,它不再需要使用这些常量了,它依然必须实现这个接口,以确保二制兼容性。如果非final类实现了常量接口,它的所有子类的命名空间也会被接口中的常量所“污染”。Java平台类库中有几个常量接口,例如java.io.ObjectStreamConstants,被认为是反面例子,不值得效仿。
如果要导出常量,可以有几种合理的方案。如果这些常量与某个现有的类或者接口紧密相关,就应该把这些常量添加到这个类或者接口。例如,在Java平台类库中所有的数值包装类,如果Integer和Double,都导出了MIN_VALUE和MAX_VALUE常量。如果这些常量最好被看作枚举类型的成员,就应该使用枚举类型(见第30条)来导出这些常量。否则,应该使用不可实例化的工具类(见第4条)来导出这些常量,下面是前面的PhysicalConstants例子的工具类翻版:
public class PhysicalConstants {
private PhysicalConstants() { } // 私有构造器
public static final double AVOGADROS_NUMBER = 6.02214199e23;
}
如果大量利用工具类导出的常量,可以通过利用静态导入机制,避免用类名来修饰常量,不过,静态导入机制是在1.5中才引用的。
简而言之,接口应该只被用来定义类型,它们不应该被用来导出常量。
20、 类层次优于标签类
有时候,可能会遇到带有两种甚至更多风格(功能)的实例的类,并包含表示实例风格的标签域,例如:
class Figure {
enum Shape { RECTANGLE, CIRCLE };
// 标签域 - 是圆形还是长方形
final Shape shape;
// 这两个域仅用于长方形
double length;
double width;
// 这个域仅用于圆形
double radius;
// 圆形构造器
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
// 长方形构造器
Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
// 求面积
double area() {
//不同的形状有不同的算法
switch(shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError();
}
}
}
这种标签类有着许多缺点。它们中充斥着样板代码(同一时刻只用到一种功能),包括枚举声明、标签域以及条件语句。破坏了可读性,内存占用也增加,因为实例承担着属于其他风格的不相关的域。总之,标签类过于冗长、容易出错、并且效率低下。
使用子类化能更好地设计这个类。为了将标签转变成类层次,首先要为每个依赖于标签的方法都定义成一个抽象方法并放在抽象类中。在Figure类中,只有一个这样的方法:area。这个抽象类是类层次的根。如果还有其他的方法其行为不依赖于标签域,就应该把这样的方法放在这个抽象类中。同样地,如果所有的方法都用到了某些数据域,应该把它们放在这个抽象类中。不过,在Figure类中,不存在这种类型独立的方法或者数据域,以下是重构:
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
private final double radius;
Circle(double radius) { this.radius = radius; }
double area() { return Math.PI * (radius * radius); }
}
class Rectangle extends Figure {
private final double length;
private final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
double area() { return length * width; }
}
所有的域都是final的,编译器确保每个类的构造器都初始化它的数据域,对于根类中声明的每个抽象方法,都确保有一个实现,这样杜绝了由于遗漏swithc case而导致运行时失败的可能性。这个类层次纠正了前面提到过的标签类所有缺点。
类层次的另一好处在于,它们可以用来反映类之间本质上的层次关系,有助于后期的扩充。假设现有加一个正方形有,标签类就需要修改源码,而利用类层次结构只需新加一个正文型类,并继承自长方形,这样可以反映出它是一种特殊的长方性:
class Square extends Rectangle {
Square(double side) {
super(side, side);
}
}
简而言之,标签类很少有适用的时候,当你想使用的时,这个标签是否可以被取消,这个类是否可以用类层次来代替。当你遇到一个包含标标签域的现有类时,就要考虑将它重构到一个层次结构中去。
21、 用函数对象表示策略
Java没有提供函数指针,但是可以用对象引用实现同样的功能。
考虑下面的类:
public class StringLengthComparator {
public int comare(String s1, String s2) {
return s1.length() - s2.length();
}
}
它是一个字符长度比较器,只有这样的一个比较功能的方法,如果一个类仅仅导出某个特定功能的方法,它的实例实际上就等同于一个指向该方法的指针,这样的实例被称为函数对象。这是一种策略的应用,换句话说,StringLengthComparator实例是用于字符串比较操作的具体策略对象。
作为典型的具体策略类,StringLengthComparator类是无状态的:它没有域,所以,这个类的所有实例在功能上都是相互等价的。因此,它作一个Singleton是非常合适的,可以节省不必要的对象创建开销(见第3与第5条):
public class StringLengthComparator {
private StringLengthComparator(){}
public int comare(String s1, String s2) {
return s1.length() - s2.length();
}
}
为了把StringLengthComparator实例传递给方法,需要适当的参数类型,直接使用StringLengthComparator并不好,因为客户端将无法传递任何其他的比较策略,即不能随时动态的改变比较性为。此时,我们可以定义一个比较策略接口,这个接口在java.util包就已提供,我们不需要再提供,我们直接实现它:
import java.util.Comparator;
public class StringLengthComparator implements Comparator<String> {
private StringLengthComparator() {}
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
具体的策略类往往使用匿名类(见第22条)来声明,下面的语句根据长度对一个字符串数组进行排序:
Arrays.sort(strArr,new Comparator<String>(){
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}});
但是注意,以这种方式使用匿名类时,将会在每次执行调用的时候创建一个新的实例。如果它被重复执行,考虑将函数对象存储到一个私有的静态final域时重复使用它。
因为策略接口被用做所有具体策略实例的类型,所以我们并不需要为了导出具体策略,而把具体策略类做成公有的。我们可以把具体策略类定义成静态的内部类,让外部类直接导出公有的静态策略对象域(或定义成私有的后通过静态的工厂方法返回),其类型为策略接口。下面的例子使用静态成员类,而不是匿名类,这样允许我们的具体的策略类实现第二个接口Serializable:
public class Outer {
private static class StrLenCmp implements Comparator<String>, Serializable {
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
public static final Comparator<String> STRING_LEN_CMP = new StrLenCmp();
//...
}
这新我们很容易改变比较的策略算法,比如要导出一个不区分大小定的字符串比较器就很容易了,只要提供的接口不变。
简而言之,函数的主要用途就是实现策略模式。为了在Java中实现这种模式,要声明一个接口来表示该策略,并且为每个具体策略声明一个实现了该接口的策略类。当一个具体策略只被使用一次时,通常使用匿名类来声明和实例化这个具体匿名策略类。当一个具体策略是设计用来重复使用的时候,它的类通常要被实现为私有的静态成员类,并通过公有的静态final域被导出,其类型为该策略接口。
22、 四种嵌套类中优先考虑静态成员类
嵌套类是指被定义在一另一个类的内部的类。它的目的只为外围类提供服务,如果将来它可能会单独用于其他的某个环境中,它应该是顶层类。嵌套类有四种:静态成员类、非静态成员类、匿名类、局部类,除了第一种,其他三种被称为内部类,静态与非静态成员类又称为成员类。
静态成员类的一种常见用法是作为公有的辅助类,仅当与它的外部类一起使用时才有意义。例如,这样的一个枚举类,它描述了计算器支持的各种操作(见30条)。Operation枚举应该是Calculator类的公有静态成员类,然后,Calculator类的客户端就可以采用如Calculator.Operation.PLUS和Calculator.Operation.MINUS这样的名称来引用这些操作。
如果嵌套类的实例可以在它外围类的实例之外独立存在,这个嵌套类就必须是静态成员类:在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的。
非静态成员类一种常见用法是定义一个Adapter(适配器,将Map集合转换成Set或Collection接口),它允许外部类的实例被看作是另一个不相关的类的实例。例如,Map接口的实现往往使用非静态成员类来实现它们的集合视图,这些集合视图由Map的entrySet、keySet、values方法返回的,同样,如Set和List这种集合接口的实现往往也使用非静态成员类来实现它的迭代器,下面看看HashMap的组成部分:
public class HashMap extends AbstractMap implements Map, Cloneable, Serializable{
//静态实体组件类
static class Entry implements Map.Entry {
final Object key;
Object value;
final int hash;
Entry next;
//...
}
//HashMap的抽象迭代器,迭代出的是一个个Entry。专用来被key、value迭代器继承
private abstract class HashIterator implements Iterator {
//...
Entry nextEntry() {
//...
}
}
// 对Map中的value进行迭代
private class ValueIterator extends HashIterator {
public Object next() {
return nextEntry().value;
}
}
// 对Map中的key进行迭代
private class KeyIterator extends HashIterator {
public Object next() {
return nextEntry().getKey();
}
}
//Entry迭代器
private class EntryIterator extends HashIterator {
public Object next() {
return EntryIterator ();
}
}
// 下面的集合视图都是建立在上面迭代器之上的
// Key视图
private class KeySet extends AbstractSet {
public Iterator iterator() {
return KeyIterator ();
}
//...
}
//value视图
private class Values extends AbstractCollection {
public Iterator iterator() {
return ValueIterator ();
}
// ...
}
如果声明成员类不要求访问外围实例,就应加上static修饰符。如果省略了,则每个实例都将包含一个额外指向外围对象的引用,保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收(见第6条)时却仍然得到保留。
私有静态成员类的一种常见用法是用来代表外围类所代表的对象的组件。Map实现的内部都有一个Entry类,表示Map中的每个健-值对。虽然每个entry都与一个Map关联,但是entry上的方法(getKey、getValue和setValue)并不需要访问该Map,因此,使用非静态成员来表示entry是很浪费的:私有的静态成员类是最好的选择。如果漏掉了Entry声明成static,该Map仍可以工作,但是每个entry中将会包含一个指向Map的引用,这样就浪费空间和时间了。
非静态的内部类中不能定义static成员,但可以是定义final static成员。因为一个成员类实例必然与一个外部类实例关联,这个static定义完全可以移到其外围类中去。
当且仅当匿名类出现在非静态环境中时,它才有外围实例。但是如果它出现在静态的环境中,如静态的方法中时,就没不会指向外围类对象。而且匿名类不能包含静态的非final成员。
匿名类的适用性受到很多的限制,除了在它们声明的同时实例化外,不可以在其他地方实例化。你不能执行instanceof测试,或者做任何需要命名类的其他事情。你无法声明在一个匿名类上再实现一个或多个接口,或者是继承一个类。匿名类的客户端无法调用到它里面定义的父类中没有成员,而只能是访问到父类或接口中的成员,因为客户端引用父类类型。另外,由于匿名类出现在表达式中,它们必须保持简短——大约10行或更少——否则会影响程序的可读性。
匿名类的一种常见用法是动态地创建函数对象(见第21条),与策略接口或回调接口一起使用;匿名类的另一种常见用法是创建过程对象,比如Runnable、Thread或者TimerTask实例;第三种常见的用法是在静态工厂方法的内部(见第18条中的intArrayAsList方法)。
局部类是四种嵌套类中用得最少的类。在任何“可以声明局部变量”的地方,都可以声明局部类,并且也只能在当前作用域类使用。局部类与其他三种嵌套类中的每一种都有一些共同点:与成员类(静态与非静态成员类)一样,局部类有名字,可以被重复地使用;与匿名类一样,只有当局部类是在非静态环境中定义的时候,才有外围实例,与匿名类一样局部类中都不能定义静态的非final成员。与匿名类一样,它们也必须非常简短,以便不会影响到可读性。下面是静态与非静态环境中的局部类,它们是否拥有指向外围类的引用:
public class Outer {
static void f() {//静态方法中的内部类没有外围对象引用
class Inner {
static final int i = 1;
}
}
void g() {
class Inner {}
}
}
使用javap反编译后如下:
class Outer$1Inner extends java.lang.Object{
static final int i;
Outer$1Inner();
}
class Outer$2Inner extends java.lang.Object{
final Outer this$0; //非静态方法中的内部类有外围对象引用
Outer$2Inner(Outer);
}
静态方法中的局部类为静态的,没有指向外围类实例,非静态方法中的局部类有指向外围实例的引用(从上面反编译可以看出)。
另外,不管是在静态的还是非静态的环境中定义的局部或匿名类,都不是static的(所以为什么局部或匿名类不能定义静态的非final成员了),或者更严格的讲,没有静态的局部或匿名类,因为局部环境中是不可以使用static修饰的。
简而言之,四种不同的嵌套类,都有自己的用途。如果一个嵌套类需要在单个方法之外仍然是可见的,或者它太长了,不适合于放在方法内部,就该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,就要把成员类做成非静态的,否则做成静态的。假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例,并且已经有了一个预置的类型接口可以说明这个类的特征,就要把它做成匿名类,否则,就做成局部类吧。
第五章 泛型
23、 请不要在新代码中使用原生态类型
声明中具有一个或者多个类型参数的类或者接口,就是泛型类或者泛型接口。泛型类和接口统称为泛型。
每种泛型可以定义一种参数化的类型,格式为:先是类或者接口的名称,接着用尖括号(<>)把对应于泛型的类型参数的实际类型参数列表括起来。
每个泛型都定义一个原生态类型,即不带任何实际类型参数的泛型名称,也是没有泛型之前的类型。
泛型能将运行时期的错误提前到编译时期检测。
如果使用原生态类型,就失掉了泛型在安全性和表述性方面的所有优势。既然不应该使用原生态类型,为什么Java设计还要允许使用它们呢?这是为了提供兼容性,要兼容以前没有使用泛型的Java代码。
原生态类型List和参数化的类型List<Object>之间到底有什么区别呢?不严格地说,前者逃避了泛型检查,后者则明确告知编译器,它能够持有任意类型的对象。泛型有子类型化的规则:List<String>是原生态类型List的一个子类型,而不是参数化类型List<Object>的子类型(见25条)。因此,如果用不用像List这样的原生态类型,就会失掉类型安全性,但是如果使用像List<Object>这样的参数化类型,则不会。
在无限制通配类型Set<?>和原生态类型Set之间有什么区别呢?由于可以将任何元素放进使用原生态类型的集合中,因此很容易破坏该集合的类型约束条件;但不能将任何元素(除了null之外)放到Collection<?>中。
“不要在新代码中使用原生态类型”,这条规则有两个例外,这是因为“泛型信息在运行时就会被擦除”。在获取类信息中必须使用原生态类型(数组类型和基本类型也算原生态类型),规范不允许使用参数化类型。换句话说:List.class,String[].class和int.class都是合法,但是List<String>.class和List<?>.class都是不合法的。这条规则的第二个例外与instanceof操作符有关,由于泛型信息在运行时已被擦除,因此在参数化类型而不是无限制通配符类型(如List<?>)上使用instanceof操作符是非法的,用无限制通配符类型代替原生态类型,对instanceof操作的行为不产生任何影响。在这种情况下,尖括号<>和问号?就显得多余了。下面是利用泛型来使用instanceof操作符的首先方法:
if(o instanceof set){
Set<?> m = (Set<?>)o;
// ...
}
注意,一旦确定这个o是个Set,就必须将它转换成通配类型Set<?>,则不是转换成原生态类型Set,否则Set会引起编译时警告。
总之,使用原生态类型会在运行时导致异常,因此不要在新代码中使用。原生态类型只为了与引入泛型之前的遗留代码进行兼容和互用而提供的。另外Set<Object>是个参数化类型,表示可以包括任何对象类型的一个集合;Set<?>则是一个通配符类型,表示只能包含某种未知对象类型的一个集合;Set则是个原生态类型,它脱离了泛型系统。前两者是安全的,最后一种不安全。
术语介绍:
原生态类型:List
参数化的类型:List<String>
泛型:List<E>
有限制类型参数:List<E extends Number>
形式类型参数:E
无限制通配符类型:List<?>
有限制通配符类型:List<? extends Number>
递归类型限制:List <T extends Comparable<T>>
泛型方法: static<E> List<E> asList(E[] a)
24、 消除非受检警告
用泛型编程时,会遇到许多编译器警告:非受检强制转换警告、非受检方法调用警告、非受检普通数组创建警告,以及非受检转换警告。
要尽可能地消除每一个非受检警告。如果消除了所有警告,就可以确保代码是类型安全的。
如果无法消除警告,同时可以证明引起警告的代码是类型安全的,只有在这种情况下才可以用一个@SuppressWarnings("unchecked")注解来禁止这条警告。
SuppressWarnings注解可以用在任何粒度的级别中,从单独的局部变量到整个类都可以。应该始终在尽可能小的范围中使用SuppressWarnings注解。它通常是个变量声明,或者是非常简短的方法或者构造器。永远不要在整个类上使用SuppressWarnings,这么做可能会掩盖了重要的警告。
总而言之,非受检警告很重要,不要忽略它们。每一条警告都表示可能在运行时抛出ClassCastException异常。要尽最大的努力消除这些警告。如果无法消掉同时确实是类型安全的,就可以在尽可能小的范围中,用@SuppressWarnings("unchecked")注解来禁止这条警告。要用注释把禁止该警告的原因记录下来。
25、 列表优先于数组
数组与泛型相比,有两个重要的不同点:首先,数组是协变的,如Sub为Super的子类型,那么数组类型Sub[]就是Super[]的子类型。但泛型则是不可变的,对于任意两个不同的类型Type1和Type2,List<Type1>与List<Type2>没有任何父子关系。
下面的代码片段是合法的:
Object[] objectArray = new Long[1];
objectArray[0]= "";//运行时抛异常
但下面这段代码则在编译时就不合法:
List<Object> ol = new ArrayList<Long>();//编译时就不能通过
ol.add("");
利用数组,你会在运行时才可以发现错误,而利用列表,则可以在编译时发现错误。而我们最好是编译时发现错误,及早的处理它。
数组与泛型之间的第二大区别在于,数组是具体化的[JLS,4.7]。因此数组会在运行时才知道并检查它们的元素类型约束。相比,泛型则是通过擦除[JLS,4.6]来实现的。因此泛型只在编译时强化它们的类型信息,并在运行时丢弃它们的元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用。
由于上述这些根本的区另,因此数组和泛型不能很好混合使用。例如,创建泛型、参数化类型或者类型参数的数组是非法的,如:new List<E>[]、new List<String>[]、new E[]都是非法的。
为什么不允许创建泛型数组呢?看具体例子:
List<String>[] stringLists= new List<String>[1];//1
List<Integer> intList = Arrays.asList(42); //2
Object[] objects = stringLists; //3
objects[0] = intList; //4
String s = stringLists[0].get(0); //5
这里首先假设第一行可以,其他行本身编译是没有问题的,但运行到5行时肯定会抛出ClassCastException异常。为了防止出现这种情况,创建泛型数组第1行就不允许了。
从技术角度说,像List<Strign>、List<E>、E这样的类型应称作为不可具体化的类型[JLS,4.7]。直观地说,不可具体化的类型是指其运行时表示法包含的信息比它的编译时表示法包含的信息更少的类型。唯一可具体化的参数化类型是无限制的符类型,如List<?>和Map<?,?>(Map<?,?>[] maps = new Map<?,?>[1];),虽然不常用,但是创建无限制通配类型的数组是合法。
当你得到泛型数组创建错误时,最好的解决办法通常是优先使用集合类型List<E>,而不是E[]。这样可以会损失一些性能或者简洁性,但是挽回的是更高的类型安全性和互用性。
总之,数组和泛型有着非常不同的类型规则。数组是协变且可以具体化的,泛型是不可变的且可以被擦除的。因此,数组提供了运行时的类型安全,但是没有编译时的类型安全,反之,对于泛型也一样。一般来说,数组和泛型不能很好地混合使用。如果你发现自己将它们混合起来使用,并且得到了编译时错误或者警告,你的第一反应就应该是用列表来代替数组。
26、 优先考虑泛型
考虑第6条中的堆栈实现,将它定义成泛型类。第一种是将elements定义成类型参数数组:
public class Stack<E> {
private E[] elements;//定义成类型参数数组
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// 通过pust(E)我们只能将E类型的实例放入到elements中,这已充分确保类型
// 安全,所以这里可以强转。但是运行时数组的类型不是E[],它仍然是Objct[]类型的!
@SuppressWarnings("unchecked")
public Stack() {
//elements =new E[DEFAULT_INITIAL_CAPACITY];//不能创建类型参数数组
/*
* 编译器不可能证明你的程序是类型安全的,但是你可以证明。你自己必须确保未受
* 检的转换不会危及到程序的类型安全。因为elements保存在一个私有的域中,永远
* 不会返回到客户端。或者传给任何其他方法。这个数组中保存的唯一元素,是传给
* push方法的那些元素,它们的类型为E,因此未受检的转换不会有任何危害。一旦
* 你证明了未受检的转换是安全的,就要在尽可能小的范围中禁警告。然后你就可以
* 使用的它了,无需显示转换,也不需担心会出ClassCastException异常。
*/
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];//这里会有
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
elements[size] = null; // 解除过期引用
return result;
}
//...
}
第二种是将elements域的类型从E[]改为Object[]:
public class Stack<E> {
private Object[] elements;//直接定义成Object[]类型数组
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
//这里就不需要转型了
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
// Appropriate suppression of unchecked warning
public E pop() {
if (size == 0)
throw new EmptyStackException();
// 放入栈中的元素类型一定是E类型的,所以转换是没有问题的!
@SuppressWarnings("unchecked")//将@SuppressWarnings尽量应用到最小的范围上
E result = (E) elements[--size];//转型会移到这里,但会有警告
elements[size] = null; // 解除过期引用
return result;
}
// ...
}
总之,使用泛型比使用需要在客户端代码中进行转换的类型来得更加安全,也更加容易。在设计新类型时,要确保它们不需要这种转换就可以使用。这通常意味着要把类做成是泛型的,只要时间允许,就把现有的类型都泛型化。这对于这些类型的新用户来说会变得更加轻松,又不会破坏现有的客户端。
27、 优先考虑泛型方法
静态工具方法尤其适合泛型方法。Collections工具类中的所有算法方法都泛型化了。
public class Union {
// 泛型方法
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<E>(s1);
result.addAll(s2);
return result;
}
}
union方法的局限性在于,三个集合的类型(两个输入参数和一个返回值)必须全部相同。利用有限制的通配符类型,可以使这个方法变得更加灵活。
泛型方法的一个显著特性是,无需明确指定类型参数的值,不像调用泛型构造器的时候是必须指定的。编译器通过检查方法参数的类型来计算出类型参数的值。对于上面两个参数都是Set<String>类型,因此知道类型参数E必须为String,这个过程称作为类型推导。
可以利用泛型方法调用所提供的类型推导,使用创建参数化类型实例的过程变得更加轻松。下面的写法有点冗余:
Map<String, List<String>> anagrams = new HashMap<String, List<String>>();
为了消除这种冗余,可以编写一个泛型静态工厂方法,与想要的每个构造器相对应,如:
// 静态的泛型工厂方法
public static <K, V> HashMap<K, V> newHashMap() {
return new HashMap<K, V>();
}
通过这个泛型静态工厂方法,可以用下面这段简洁的代码来取代上面那个冗余的行:
// 使用泛型静态工厂方法来创建参数化的类的实例
Map<String, List<String>> anagrams = newHashMap();
在泛型上调用构造器时,如果语言本身支持类型推导与调用泛型方法时所做的相同,那就好了,将来也许可以,但现在还不行。
递归类型限制最普遍的用途与Comparable接口有关,例如在集合中找最大的元素泛型静态方法:
public static <T extends Comparable<T>> T max(List<T> list){
Iterator<T> i = list.iterator();
T result = i.next();
while(i.hasNext()){
T t = i.next();
if(t.compareTo(result)>0){
result = t;
}
}
return result;
}
总之,泛型方法就像泛型一样,使用起来比要求客户端转换输出参数并返回值的方法来得更加安全,也更加容易,就像类型一样,你应该确保新的方法可以不用转换就能使用,这通常意味着要将它们泛型化。
28、 利用有限制通配符来提升API的灵活性
现在在前面前面第26条中的Stack<E>中加上以下方法,以便将某个集合一次性放入到栈中:
public void pushAll(Iterable<E> src) {
for (E e : src)
push(e);
}
这样使用:
Stack<Number> numberStack = new Stack<Number>();//1
Iterable<Integer> integers = Arrays.asList(3, 1, 4, 1, 5, 9); //2
numberStack.pushAll(integers); //3
按照上面的写法,我们是无法将Integer类型的元素放入Number类型的栈中,这是不合理的,因为Integer为Number的子类。原因是第一行执行完后,pushAll方法中的类型参数E就会固定为Number类型,这就好比要将Iterable<Integer>赋值给Iterable<Number>一样,这显然是不可以的,因为Iterable<Integer>不是Iterable<Number>的子类型。这样就显得缺少灵活性了,幸好限制通配符类型可以帮我们解决:
// 限制通配符意味这这里的元素只少是E类型
public void pushAll(Iterable<? extends E> src) {
for (E e : src)
push(e);
}
经过上面的修改后src不只是可以接受E类型的Iterable,还可以接受E的子类型Iterable,而Integer恰好为Number的子类型,所以现在第3行可以正常编译与运行。
注:List<? extends E> src这种集合只能读不能写,即只能传进参数(如add方法),而不能返回E类型元素(如get方法),当然不带泛型类型参数与返回泛型类型的方法是可以随便调用的,比如size();
完成上面的pushAll方法的后,我们现在想编写一个popAll对应的方法,它从栈中弹出每个元素,并将这些元素到传进去参数集合中,下面如果这样设计:
public void popAll(Collection<E> dst) {
while (!isEmpty())
dst.add(pop());
}
应用代码如下:
Collection<Object> objects = new ArrayList<Object>();//1
numberStack.popAll(objects);//2
System.out.println(objects);
很不幸的是,第2行根本无法编译通过,按理来说我们完全可以将Number类型的元素放入到一个Object类型参数的集合中,可这里失败了,这不是不可以,是我们设计的错误。这里失败的原因与上面一样,是由于numberStack的类型为Number,所以popAll的类型参数固定为Number,因而不能将一个Collection<Object>赋值给Collection<Number>,因为Collection<Object>不是Collection<Number>的子类型。像上面一样,也有这样一样限制通配类型来解决这个问题:
public void popAll(Collection<? super E> dst) {
while (!isEmpty())
dst.add(pop());
}
现在dst不只是可以接受E类型的Collection了,而且还可以接受E的父类型Collection,这里的Object为Number的父类,所以这里可以正常运行。
注:List<? super E> dst这种集合只能写不能读,即只能写入元素(如调用add方法)而不能读取元素(如调用get),当然不带泛型类型参数与返回泛型类型的方法是可以随便调用的,比如size();
XXX<? extends T> x:使用<? extends T>定义的引用x,x可以用来接受类型参数为T及T的所有子类类型的实例,但通过这个引用调用x上的泛型方法时有一定的限制:如果方法带泛型参数,则不能调用,因为真真实例方法的类型参数类型完全有可能比你传进这个泛型方法的参数的类型要窄,一个子类型的引用是不能接受一个父类类型引用的,所以不能通过这种限制通配类型定义的引用x来调用一切带泛型参数的方法;但你可以调用那此具有返回类型为泛型类型的方法,因为不管真真实例的类型如果,它们都是T的子类,所以此时的返回类型只少是T类型;最后如果方法不带泛型类型参数,则是可以随便调用的。总之,这种限制通配类型一般用于从集合中读取操作。
XXX<? super T> x:使用<? super T>定义的引用x,x可以用来接受类型参数为T及T的所有父类类型的实例,但通过这个引用调用x上的泛型方法时有一定的限制:如果方法的返回类型为泛型类型,则接收这个返回类型的变量类型不能是T,而只能是以Object类型变量来接收,因为方法的实际返回的类型完全有可能比方法定义的返回类型T要宽,但我们又不知道究竟比T宽多少,你总不能将Object类型对象赋值给T类型的引用吧,所以通过这种限制通配类型定义的引用x来调用返回类型为泛型参数的方法时会失去类型限制;但你可以调用方法参数类型为泛型类型的方法,因为现在方法的实例类型至少为T或比T要宽,所以可以接收T及T子类类型参数;最后如果方法的返回类型不为泛型类型参数时,则也是可以随便调用的。总之,这种限制通配类型一般用于将元素写入集合。
不要用符类型作为返回类型,除了为用户提供额外的灵活性外,它还会强制用户在客户端代码中使用通配符类型。
将第27条的union方法修改一下,让它能同时接受Integer与Double集合,由于这两个集合是用来读的,所以使用<? extends E>限制通配符:
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2) ;
如果有这以下两个集合:
Set<Integer> integers = new HashSet<Integer>();
Set<Double> doubles = new HashSet<Double>();
调用union(integers, doubles)时类型推断为<? extends Number>,所以以下编译不能通过:
Set<Number> nubers =Union.union(integers, doubles);
只能是这样显示的指定返回类型,而不使用类型推导:
Set<Number> nubers =Union.<Number>union(integers, doubles);
或者使用类型推导,则只能以通配类型来接受,因为Set<Number>并不是Set<Float>的父类:
Set<? extends Number> nubers = union(integers, doubles);
接下来,我们将第27条的max方法,让它更灵活,做如下修改:因为list只用来读取或生产元素(第1、2、4行都是从list中读,即只通过list直接或间接地调用过返回类型为泛型类型的方法,而没有去调用过参数为泛型类型的方法),所以从List<T>修改成List<? extends T>,让list可以接受T及其它的子类。而Comparable<T>应该修改成Comparable<? super T>,因为Comparable只是来消费T的实例(第5行属于消费,因为T的实例调用带有泛型类型参数的compareTo方法),传递进的参数要求是最宽的,这样可以确保compareTo中的参数能接受T及其T的子类类型:
public static <T extends Comparable<? super T>> T max(List<? extends T> list) {
//只能使用通配类型来接受,因为iterator()方法返回的为Iterator<E> 类型,又Iterator<Object>并不是Iterator<String>的父类,所以这里也需要修改一下
Iterator<? extends T> i = list.iterator();//1
//但这里不需要使用通配类型来接收,因为next()返回的类型直接就是类型参数E,而不像上面返回的为Iterator<E>泛型类型
T result = i.next();//2
while (i.hasNext()) {//3
T t = i.next();//4
if (t.compareTo(result) > 0) {//5
result = t;
}
}
return result;
}
假如现在有以下两个接口:
interface I1 extends Comparable<I1> {}
interface I2 extends I1 {}
如果上面不这样修改的话,下面第二行将不适用:
max(new ArrayList<I1>());
max(new ArrayList<I2>());
现在我们具体的分析一下上面代码:如果Comparable<T>不修改成Comparable<? super T>,第一行还是可正常运行,但是第二行则不可以,因为此时的T为I2,而I2又没有实现Comparable接口,而方法声明<T extends Comparable<T>>部分则要求I2直接实现Comparable接口,但父类I1实现了Comparable接口,I2又已经继承了I1,我们不需要再实现该接口,所以这里变通的作法是让Comparable<T>可以接收T及T的父类类型,所以修改成Comparable<? super T>即可,并且这样修改也符合前面的描述。
所以,使用时始终应该是Comparable<? super T>优先于Comparable<T>,对于comparator也一样,使用时始终应该是Comparator<? super T>优先于Comparator<T>。
类型参数和通配符之间具有双重性,许多方法都可以利用其中一个或者另一个进行声明。例如下面是可能的两种静态方法声明,来交换列表中的两个元素,第一个使用无限的类型参数,第二个使用的是无限的通配符:
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);
一般来说,如果类型参数只在方法声明中出现一次(即只在方法参数声明列表中出现过,而方法体没有出现),就可以用通配符取代它:如果是无限制的类型参数<E>,就用无限制的通配符取代它<?>;如果是有限制的类型参数<E extends Number>,就用有限制的通配符取代它<? extends Number>。
第二种实质上会有问题,下面是简单的实现都不能通过编译:
public static void swap(List<?> list, int i, int j) {
list.set(i, list.get(j));
}
原因很简单了,不再累述,但可以修改它,编写一个私有的辅助方法来捕捉通配符类型:
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.get(j));
}
不过,还可以将List<? >修改成List<? super Object>也可以:
public static void swap(List<? super Object> list, int i, int j) {
list.set(i, list.get(j));
}
总之,在API中使用通配符类型使API变得灵活多。如果编写的是一个被广泛使用的类库,则一定要适当地利用通配类型。记住基本原则:producer-extends,consumer-super(PECS)。还要记住所有的Comparable和Comparator都是消费者,所以适合于<? super XXX>。
PECS:如果参数化类型表示一个T生产者,就使用<? extends T>;如果它表示一个T是消费者,就使用<? super T>。
29、 优先考虑类型安全的异构容器
通过对泛型的学习我们知道,泛型集合一旦实例化,类型参数就确定下来,只能存入特定类型的元素,比如:
Map<K, V> map = new HashMap<K, V>();
则只能将K、V及它们的子类放入Map中,就不能将其他类型元素存入。如果使用原生Map又会得到类型安全检查,也许你这样定义:
Map<Object,Object> map = new HashMap<Object,Object>();
map.put("Derive", new Derive());
map.put("Sub", new Sub());
这样是可以存入各种类型的对象,虽然逃过了警告,但取出时我们无法知道确切的类型,它们都是Object,那么有没有一种这样的Map,即可以存放各种类型的对象,但取出时还是可以知道其确切类型,这是可以的:
public class Favorites {
// 可以存储不同类型元素的类型安全容器,但每种类型只允许一个值,如果存放同一类型
// 多个值是不行的
private Map<Class<?>, Object> favorites = new HashMap<Class<?>, Object>();
public <T> void putFavorite(Class<T> type, T instance) {
if (type == null)
throw new NullPointerException("Type is null");
// favorites.put(type, instance);
/*
* 防止客户端传进原生的Class对象,虽然这会警告,但这就不能
* 确保后面instance实例为type类型了,所以在这种情况下强制
* 检查
*/
favorites.put(type, type.cast(instance));
}
public <T> T getFavorite(Class<T> type) {
//返回的还是存入时真真类型
return type.cast(favorites.get(type));
}
public static void main(String[] args) {
Favorites f = new Favorites();
f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorites.class);
String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);
System.out.printf("%s %x %s%n", favoriteString, favoriteInteger,
favoriteClass.getName());
}
}
总之,集合API说明了泛型的一般用法,限制你每个容器只能有固定数目的类型参数。你可以通过将类型参数放在键上而不是容器上来避开这一限制。对于这种类型安全的异构容器,可以用Class对象作为键。
第六章 枚举和注解
30、 用enum代替int常量
枚举类型是指由一组固定的常量组成合法值的类型,例如一年中的季节或一副牌中的花色。在没引入枚举时,一般是声明一组int常量,每个类型成员一个常量:
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
这种方法称作int枚举模式,存在很多不足,不具有类型安全与使用方便性。如果你将apple传到一个想要接收orange的方法中,编译器也不会出现警告,而且还可以使用==来比较apple与orange。
注意每个apple常量都以APPLE_作为前缀,每个orange常量都以ORANGE_作为前缀,这是因为可以防止名称冲突。
采用int枚举模式的程序是十分脆弱,因为int枚举是编译时常量,被编译到使用它们的客户端中。如果与枚举常量关联的int发生了变化,客户端就必须重新编译,如果不重新编译,程序还是可以运行,但不是最新的值了。
另外从使用方便性来看,没有便利的toString方法,打印出来的为数字,没有多大的用处。要遍历一组中所有的int枚举常量,也没有可靠的方法。
既然int枚举常量有这么多的缺点,那使用String枚举常如何?同样也不是我们期望的。虽然在可以打印字符串,但它会导致性能问题,因为它依赖于字符串的比较操作。另外与int枚举常量一样会编译到客户端代码中,编译时难以发现,但会在运行时出错。
幸运的是1.5版本开始,枚举可以避免int和String枚举模式的缺点,并提供许多额外的好处。下面是最简单的形式:
public enum Apple{FUJI,PIPPIN,GRANNY_SMITH}
public enum Orange{NAVEL,TEMPLE,BLOOD}
Java枚举类型背后的基本想法很简单:本质上是int值,它们是通过公有的静态final域为每个枚举常量导出实例的类。因为没有可以访问的构造器,枚举类型是真正的final。因为客户端即不能创建枚举类型的实例,也不能对它进行扩展,因此对它进行实例化,而只有声明过的枚举常量。换句话说,枚举类型是实例受控的。它们是单例的泛型化,本质上是单元素的枚举。
枚举提供了编译时类型安全。如果声明一个参数的类型为Apple,就可以保证,被传到该参数上的任何非null的对象引用一定属于三个有效的Apple值之一。试图传递类型错误的值时,会导致编译时错误,就像试图将某种枚举类型的表达式赋值给另一种枚举类型的变量,或者试图利用==操作符比较不同枚举类型的值一样,都会出错。
枚举提供了单独的命名空间,同一系统中可以有多个同名的枚举类型变量。你可以增加或者重新排序枚举类型常量,而无需重新编译它的客户端代码,因为导出常量的域在枚举类型和它的客户端之间提供了一个隔离层:常量值并没有被编译到客户端代码中,而是在int枚举模式之中。最终,可以通过调用toString方法,将枚举转换成可打印的字符串。
除了完善了int枚举模式不足外,枚举还允许添加任意的方法和域,并实例任意接口,它们提供了所有Object(见第3章)的高级实现,实现了Comparable和Serializable接口,并针对枚举型的可任意改变性设计了序列化方式。
如果一个枚举具有普遍适用性,它就应该成为一个顶层类,如果它只是被用在一个特定的顶层类中,它就应该成为该顶层类的一个成员类。
可以为枚举类型添加数据域与方法,下面是一个算术运行的枚举类:
public enum Operation {
PLUS("+") {
double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
double apply(double x, double y) {
return x / y;
}
};
private final String symbol;//操作符:+ - * /
Operation(String symbol) {//构造函数,存储操作符供toString打印使用
this.symbol = symbol;
}
@Override
//重写Enum中的打印name的性为
public String toString() {
return symbol;
}
//抽像方法,不同的常量具有不同的功能,需在每个常量类的主体里重写它
abstract double apply(double x, double y);
/*
* 初始化时,存储操作符与枚举常量的对应关系,用来实现 fromString 方法
* 这样我们就可以通过 操作符来获取到对应的枚举常量,有点像valueOf方法,
* 只不过它是通过枚举常量的名字name来获取常量的。这种通用的方法还可以
* 应用到其他枚举类中
*/
private static final Map<String, Operation> stringToEnum = new HashMap<String, Operation>();
static { // 从name到枚举常量转换到从某个域到枚举常量的转换
for (Operation op : values())
stringToEnum.put(op.toString(), op);
}
// 根据操作符来获取对应的枚举常量,如果没有返回null,模拟valueOf方法
public static Operation fromString(String symbol) {
return stringToEnum.get(symbol);
}
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n", x, op, y, Operation
.fromString(op.toString()).apply(x, y));
}
}
在opr包下会看见Operation.class、Operation$4.class、Operation$2.class、Operation$3.class 、Operation$1.class这样几个类,Operation$X.class都是继承自Operation类,而Operation又继承自Enum类,下面是反编译这些类的代码:
public abstract class opr.Operation extends java.lang.Enum{
public static final opr.Operation PLUS;
public static final opr.Operation MINUS;
public static final opr.Operation TIMES;
public static final opr.Operation DIVIDE;
private final java.lang.String symbol;
private static final java.util.Map stringToEnum;
private static final opr.Operation[] ENUM$VALUES;
static {};
private opr.Operation(java.lang.String, int, java.lang.String);
public java.lang.String toString();
abstract double apply(double, double);
public static opr.Operation fromString(java.lang.String);
public static void main(java.lang.String[]);
public static opr.Operation[] values();
public static opr.Operation valueOf(java.lang.String);
opr.Operation(java.lang.String, int, java.lang.String, opr.Operation);
}
class opr.Operation$1 extends opr.Operation{
opr.Operation$1(java.lang.String, int, java.lang.String);
double apply(double, double);
}
枚举构造器不可以访问枚举的静态域,除了编译时常量域之外,这一限制是有必要的,因为构造器运行的时候,这些静态域还没有被初始化。
枚举常量中的方法有一个美中不足的地方,它们使用在枚举常量中共享代码变得更加因难了。例如,考虑用一个枚举来实现星期中的工资数。算法是这样的,在五个工作日中,除正常的工作时间外,算加班;在双休日中,所有工作时数都算加班时间,下面是第一次简单的实现:
public enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
private static final int HOURS_PER_SHIFT = 8;//正常工作时数
/**
* 工资计算
* @param hoursWorked 工作时间(小时)
* @param payRate 每小时工资
* @return
*/
double pay(double hoursWorked, double payRate) {
//基本工资,注这里使用的是double,真实应用中请不要使用
double basePay = hoursWorked * payRate;
double overtimePay;//加班工资,为正常工资的1.5倍
switch (this) {
case SATURDAY:
case SUNDAY://双休日加班工资
overtimePay = hoursWorked * payRate / 2;
default: //正常工作日加班工资
overtimePay = hoursWorked <= HOURS_PER_SHIFT ? 0
: (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
break;
}
return basePay + overtimePay;//基本工资+加班工资
}
}
不可否认,这段代码很简单,但是从维护来看,非常危险。假设将一个元素添加到枚举中,如一个假期的特殊值,但忘了给switch语句添加相应的case,这时会计算出错。
为了针对不同的常量有不同的安全计算工资法,你必须重复每个常量的加班工资,或者将计算移到两个辅助方法中(一个用来计算工作日,一个用来计算双休日),并从每个常量调用相应的辅助方法。这任何一种方法都会产生很多的重复的样板代码,第二次如下实现:
public enum PayrollDay {
MONDAY() {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
TUESDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
WEDNESDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
THURSDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
FRIDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
SATURDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekendPay(hoursWorked, payRate);
}
},
SUNDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekendPay(hoursWorked, payRate);
}
};
private static final int HOURS_PER_SHIFT = 8;//正常工作时数
//抽象出加班工资计算
abstract double overtimePay(double hoursWorked, double payRate);
//计算工资
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;//公用
return basePay + overtimePay(hoursWorked, payRate);
}
//双休日加班工资算法
double weekendPay(double hoursWorked, double payRate) {
return hoursWorked * payRate / 2;
}
//正常工作日加班工资
double weekdayPay(double hoursWorked, double payRate) {
return hoursWorked <= HOURS_PER_SHIFT ? 0
: (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
}
}
上面设计中存在很多的样板代码,如正常工作日都是调用weekdayPay方法来完成的,而双休都是调用weekendPay来完成的,有没有一种可以减少这些重复样板代码呢?请看下面:
enum PayrollDay {
MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY), WEDNESDAY(
PayType.WEEKDAY), THURSDAY(PayType.WEEKDAY), FRIDAY(PayType.WEEKDAY), SATURDAY(
PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
private final PayType payType;//策略枚举类
PayrollDay(PayType payType) {
this.payType = payType;
}
double pay(double hoursWorked, double payRate) {
//计算委托给策略类
return payType.pay(hoursWorked, payRate);
}
// 嵌套的枚举策略类
private enum PayType {
WEEKDAY {//工作日枚举策略实例常量
double overtimePay(double hours, double payRate) {
return hours <= HOURS_PER_SHIFT ? 0 : (hours - HOURS_PER_SHIFT)
* payRate / 2;
}
},
WEEKEND {//双休日枚举策略实例常量
double overtimePay(double hours, double payRate) {
return hours * payRate / 2;
}
};
private static final int HOURS_PER_SHIFT = 8;
abstract double overtimePay(double hrs, double payRate);
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;
return basePay + overtimePay(hoursWorked, payRate);
}
}
}
虽然这种模式没有前面两种那么简单,便更加安全,也更加灵活。
从上面加班工资计算三种实现来看,如果多个枚举常量同时共享相同的行为时,则考虑策略枚举。
枚举适用于一组固定常量,当然枚举类型中的常量集并不一定要始终保持不变。
31、 不要使用ordinal,用实例域代替序数
永远不要根据枚举序数ordinal()导出与它关联的值,即不要依赖于枚举序数,否则重新排序这些枚举或添加新的常量,维护起来将是很困难的:
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET;
public int numberOfMusicians() {
return ordinal() + 1;
}
}
我们要将它保存在一个实例域中:
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5), SEXTET(6), SEPTET(7);
private final int numberOfMusicians;
Ensemble(int size) {
this.numberOfMusicians = size;
}
public int numberOfMusicians() {
return numberOfMusicians;
}
}
Enum规范中谈到ordinal时这么定道:“大多数程序员都不需要这个方法。它是设计成用于像EunmSet和EnumMap这种基于枚举的通用数据结构”,除非你在编写的是这种数据结构,否则最好完全避免使用ordinal方法。
32、 用EnumSet代替位域
如果一个枚举类型的元素主要用在集合(组合)中,一般就使用int枚举模式,做法是将1向左移位来实现,这样就会有很多的组合形式,下面是四种字体样式的应用,可以组合出 2^4 – 1 = 15种样式来:
class Text{
public static final int STYLE_BOLD = 1 << 0;//1 字体加粗
public static final int STYLE_ITALTC = 1 << 1;// 2 斜体
public static final int STYLE_UNDERLINE = 1 << 2;//4 下划线
public static final int STYLE_STRIKETHROUGH = 1 << 3;//8 删除线
//应用样式
public void applyStyles(int styles){
//...
}
public static void main(String[] args) {
//应用粗体与斜体组合样式
new Text().applyStyles(STYLE_BOLD|STYLE_ITALTC);
}
}
位域表示法允许利用位操作,有效地执行了像组合和交集这样的集合操作。但位域有着int枚举常量的所有缺点,甚至更多,如当位域以数字形式打印时,翻译位域比翻译简单的(单个的)枚举常要困难得多。那么有没有一种好的方案来代替上面的设计呢?使用EnumSet类来有效地表示从单个枚举类型中提取的多个值的多个集合。EnumSet内容都表示为位矢量,如果底层的枚举类型有64个或者更少的元素——大多如此——整个EnumSet就是用单个long来表示,因此它的性能比得上位域的性能。批处理,如removeAll和retainAll,都是利用位算法来实现的,就像手工替位域实现那样,但可以避免手工位操作时容易出现的错误以及复杂的代码。
下面是前一个实例改用枚举代替位域后的代码,它更加简短、清楚、安全:
public class Text {
public enum Style {
BOLD, ITALIC, UNDERLINE, STRIKETHROUGH
}
/*
* 这里使用的Set接口而不是EnumSet类,最好还是使用接口
* 类型而非实现类型,这样还可以传递一些其他的Set实现
*/
public void applyStyles(Set<Style> styles) {
}
// Sample use
public static void main(String[] args) {
Text text = new Text();
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
}
}
总之,正是因为枚举类型可用在集合EnumSet中,所以没有理由用位域来表示它。EnumSet类集位域的简洁和性能优势及第30条中所述的枚举类型的所有优点于一身,用EnumSet代替位域就是理所当然的了。
33、 用EnumMap代替序数索引
EnumMap:与枚举类型键一起使用的专用 Map 实现。枚举映射中所有键都必须来自单个枚举类型,该枚举类型在创建映射时显式或隐式地指定。枚举映射在内部表示为数组。此表示形式非常紧凑且高效。
先来看一个能植物分类的实例,分类的标准是按照某枚举类型来分的:
public class Herb {
// 植物各类:一年生、多年生、两年生
static public enum Type {
ANNUAL, PERENNIAL, BIENNIAL
}
private final String name;//植物名字
private final Type type;//植物各类
Herb(String name, Type type) {
this.name = name;
this.type = type;
}
@Override
public String toString() {
return name;
}
public static void main(String[] args) {
//现有这样一些植物集合
Herb[] garden = { new Herb("Basil", Type.ANNUAL),
new Herb("Carroway", Type.BIENNIAL),
new Herb("Dill", Type.ANNUAL),
new Herb("Lavendar", Type.PERENNIAL),
new Herb("Parsley", Type.BIENNIAL),
new Herb("Rosemary", Type.PERENNIAL) };
//数组的索引与枚举Type对应 //问题一:需要进行未受检的转换
Set<Herb>[] herbsByType = (Set<Herb>[]) new Set[Herb.Type.values().length];
//初始化
for (int i = 0; i < herbsByType.length; i++) {
herbsByType[i] = new HashSet<Herb>();
}
//开始分类操作
for (Herb h : garden) {
//根据序数取出对应的容器再放入
herbsByType[h.type.ordinal()].add(h);
}
//输出
for (int i = 0; i < herbsByType.length; i++) {
System.out//问题二:手工输出类别,还有可能引发数组越界
.printf("%s: %s%n", Herb.Type.values()[i], herbsByType[i]);
}
}
}
输出:
ANNUAL: [Basil, Dill]
PERENNIAL: [Rosemary, Lavendar]
BIENNIAL: [Carroway, Parsley]
使用EnumMap对上面进行改进:
Herb[] garden =...
// 使用EnumMap并按照植物种类(枚举类型)来分类
Map<Herb.Type, Set<Herb>> herbsByType = new EnumMap<Herb.Type, Set<Herb>>(
Herb.Type.class);
for (Herb.Type t : Herb.Type.values())//初始化
herbsByType.put(t, new HashSet<Herb>());
for (Herb h : garden)//进行分类
herbsByType.get(h.type).add(h);
System.out.println(herbsByType);//输出
EnumMap在内部使用数组实现,在性能上与数组相当。但是它对程序员隐藏了实现细节,集Map的丰富功能和类型安全与数组的快速于一身。
34、 用接口模拟可伸缩的枚举
枚举类型是不能被扩展的(继承),但使用接口可以解决这一问题,解决办法是让枚举类实现同一接口,在应用的地方以接口类型来传递参数,但这样会失去Enum类的某些特性:
// 枚举接口
public interface Operation {
double apply(double x, double y);
}
// 基础运算
public enum BasicOperation implements Operation {
PLUS("+") {
public double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
public double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
public double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
public double apply(double x, double y) {
return x / y;
}
};
private final String symbol;
BasicOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
//扩展运算
public enum ExtendedOperation implements Operation {
EXP("^") {
public double apply(double x, double y) {
return Math.pow(x, y);
}
},
REMAINDER("%") {
public double apply(double x, double y) {
return x % y;
}
};
private final String symbol;
ExtendedOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
测试:
private static <T extends Enum<T> & Operation> void test(Class<T> opSet,
double x, double y) {
for (Operation op : opSet.getEnumConstants())//失去Enum特性,使用反射
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(BasicOperation.class, x, y);
test(ExtendedOperation.class, x, y);
}
输出:
4.000000 + 2.000000 = 6.000000
4.000000 - 2.000000 = 2.000000
4.000000 * 2.000000 = 8.000000
4.000000 / 2.000000 = 2.000000
4.000000 ^ 2.000000 = 16.000000
4.000000 % 2.000000 = 0.000000
总之,虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型,对它进行模拟。这样,客户端就能够编写自己的枚举来实现接口,如果API是根据接口编写的,那么在可以使用基础枚举类的地方,也都可以敷衍这些枚举。
35、 注解优先于命名模式
命名模式,表示有些程序元素需要通过某种工具或者框架进行特殊处理。例如,Junit框架原本要求它的用户一定要使用test作为测试方法名称的开头。
下面是一个简单的测试框架,使用注解来实现:
//专用于普通测试注解,该注解只适用于静态的无参方法,
//如果使用地方不正确由注解工具自己把握
@Retention(RetentionPolicy.RUNTIME)//注解信息保留到运行时,这样工具可以使用
@Target(ElementType.METHOD)//只适用于方法
public @interface Test {}
//专用于异常测试的注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
//测试方法可能抛出多个异常
Class<? extends Exception>[] value();
}
下面应用上面定义的注解:
public class Sample {
@Test
public static void m1() {} // 测试应该通过
public static void m2() {}
@Test
public static void m3() { // 测试应该失败
throw new RuntimeException("Boom");
}
public static void m4() {}
@Test//不应该使用在这里,但应该由注解工具自己处理这种不当的使用
public void m5() {} // 错误使用: 非静态方法
public static void m6() {}
@Test
public static void m7() { // 测试应该失败
throw new RuntimeException("Crash");
}
public static void m8() {}
}
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // 测试应该要通过,因为抛出了算术异常
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // 测试应该不通过,因为抛出的异常为数组越界异常
int[] a = new int[0];
int i = a[1];
System.out.println(i);
}
@ExceptionTest(ArithmeticException.class)
public static void m3() {
} // 测试应该不通过,因为没有抛也异常
// 可能抛出多个异常,使用{}括起来,如果是单个可以省略
@ExceptionTest( { IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<String>();
//这里会抛出空指针错误,测试应该会通过
list.addAll(5, null);
}
}
T注解对应用类的语义没有直接的影响。注解永远不会改变被注解代码的语义,但是使用它可以通过工具进行特殊的处理,如下面注解工具实现类:
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;//需要测试的数量
int passed = 0;//测试通过的数量
//加载被注解的类,即被测试的类
Class<?> testClass = Class.forName(args[0]);
//遍历测试类的所有方法
for (Method m : testClass.getDeclaredMethods()) {
//Test注解实现工具
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);//没有参数,表示调用的是静态方法
passed++;//如果方法调用成功则表示测试通过
}//表示测试方法本身抛出了异常
catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
//打印测试方法抛出的异常信息
System.out.println(m + " failed: " + exc);
}//如果抛异常表示注解使用错误,不应使用在非静态或带参数的方式上
catch (Exception exc) {
//打印测试未通过的方法信息
System.out.println("使用@Test注解错误的方法 : " + m);
}
}
// ExceptionTest注解实现工具
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
//如果注解工具运行到这里,则测试方法未抛出异常,但属于测试未通过
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
//获取异常根源
Throwable exc = wrappedExc.getCause();
//取出注解的值
Class<? extends Exception>[] excTypes = m.getAnnotation(
ExceptionTest.class).value();
int oldPassed = passed;
//将根源异常与注解值对比
for (Class<? extends Exception> excType : excTypes) {
//如果测试方法抛出的异常与注解中预期的异常匹配则表示测试通过
if (excType.isInstance(exc)) {//使用动态的instance of
passed++;
break;
}
}
//打印测试没有通过的方法信息
if (passed == oldPassed)
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
}
//打印最终测试结果,通过了多少个,失败了多少个
System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
}
}
下面是两次运行输出结果(前面的为测试Sample类,后面是测试Sample1类):
public static void Item35.Sample.m3() failed: java.lang.RuntimeException: Boom
使用@Test注解错误的方法 : public void Item35.Sample.m5()
public static void Item35.Sample.m7() failed: java.lang.RuntimeException: Crash
Passed: 1, Failed: 3
Test public static void Item35.Sample2.m2() failed: java.lang.ArrayIndexOutOfBoundsException: 1
Test public static void Item35.Sample2.m3() failed: no exception
Passed: 2, Failed: 2
本条目中开发的测试框架只是一个试验,但它清楚地示范了注解优于命名模式,这只是揭开注解功能的冰山一角。如果在编写一个需要程序员给源文件添加信息的工具,就要定义一组适当的注解类型。另外,我们要考虑使用Java平台提供的预定义的标准注解类型(见第36条)。
36、 坚持使用Override注解
当你打算重写一个方法时,有可能写成重载,这时如果@Override注解就可以防止出现这个问题。
这个经典示例涉及equals方法,程序员可以编写如下代码:
public boolean equals (Foo that){…}
当你应当把他们编写成如下时:
public Boolean equals(Object that)
这也是合法的,但是类Foo从Object继承了equals实现,最终成了重载,而原本是重写的,这时我们可以使用@Override在重写的方法前,这样如果在没有重写的情况下,编译器则会提示我们。
注,@Override不能用在实现父接口中的方法前面,因为这叫实现不叫重写了,这与可以加在实现父抽象类中方法前是不一样的。
总之,如果在你想要的每个方法声明中使用Override注解来覆盖超类声明,编译器可以替你防止大量的错误,但有一个例外,在具体的类中,不必标注你确认覆盖了抽象方法声明的方法,虽然这么做也没有什么坏处。
37、 用标记接口定义类型
标记接口是没有包含方法声明的接口,而只是指明(或者“标明”)一个类实现了具有某种属性的接口。如,Serializable接口,通过实现这个接口,表明它的实例可以被写到ObjectOutputStream中(或者“被序列化”)。
标记注解,没有参数,只是“标注”被注解的元素,如果第 35 条的@Test就是一个标记注解。
标记注解并不能替代标记接口。标记接口有两点胜过标记注解。最重要的一点是,标记接口定义的类型是由被标记类的实例实现的;标记注解则没有定义这样的类型。标记接口在编译时就会被检测是否有错误,而标记注解则要到运行时期(但也并非如此,就Serializable标记接口而言,如果它的参数没有实现该接口,ObjectOutputStream.write(Object)方法将会失败,但令人不解的是,ObjectOutputStream API的创建者在声明Write方法时并没有利用Serializable接口,该方法的参数类型应该为Serializable而非Object,因此,试着在没有实现Serializable的对象上调用ObjectOutputStream.write,只会在运行时出错,所以也并不是像前面说的那样)。
HYPERLINK "mailto:如果你正在编写的是目标为@Target(ElementType.TYPE)" 如果你正在编写的是目标为@Target(ElementType.TYPE)的标记注解类型,就要考虑使用标记接口来实现呢。
总之,接口是用来定义类型的,而注解是用来辅助分析类元素信息的。
从某种意义上说,本条目与第19条中“如果不想定义类型就不要使用接口”的说法相反。本科目最接近的意思是说:“如果想要定义类型,一定要使用接口”。
第七章 方法
38、 检查参数的有效性
绝大多数方法和构造器对于传递给它们的参数值都会有某些限制。例如,索引值必须是非负的,对象引用不能为null等,这些都是常见的。你应该在文档中清楚地指明所有这些限制,并且在方法体的开头处检查参数,以强制施加这些限制。
应该在方法和构造器体前进行了参数的有效性检查,并且及时向外抛出适当的异常。如果方法没有检查它的参数,就有可能发生几种情形。该方法可能在处理过程中失败,并且产生令人费解的异常,更有可能,该方法可以正常返回,但是会悄悄地计算出错误的结果。
对于公有的方法,要用JavaDoc的@throws标签(tag)在文档中说明违反参数值限制时会抛出的异常(见第62条),这样的异常通常为IllegalArgumentException、IndexOutOfBoundsException或NullPionterException(见第60条),下面是一个例子:
/**
* ...
* @param m m为系数,必须是正数
* @return this mod m
* @throws 如果m小于等于0时抛出ArithmeticException
*/
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0) {
throw new ArithmeticException("Modulus <= 0:" + m);
}
...// do the computations
}
对于那此非公有方法,你可以控制这个方法在哪些情况下被调用,因此你可以,也应该确保只将有效的参数值传递进来。因此,非公有的方法通常应该使用断言来检查它们的参数,具体做法如下:
private static void sort(long a[], int offset, int length) {
assert a != null;
assert offset >= 0 && offset <= a.length;
assert length >= 0 && length <= a.length - offset;
// ... do the computation
}
断言就是被断言的条件将会为真,否则将会抛出AssertionError。不同于一般的有效性检查,如果它们没有起到作用,本质上也不会有成本开销,除非通过将 –ea(或者 -enableassertions)开关传递给Java解释器,来启用它们。
对于有些参数,方法本身没有用到,却被保存起来供以后使用,则检验这类参数的有效性尤其重要,因为将来在使用时抛出异常时要想找到这个参数的来源就非常困难了,所以这类参数更应该先检查,这个规则也适用于构造函数。
不是所有的参数都就应该在使用前检查他的有效性,因为有的检查是要代价的,或根本是不切实际的,而且有效性检查本身就可以在计算过程中完成。例如,Collections.sort(List)对集合排序的方法,集合中的每个元素都实现了Comparable接口,否则在计算时会抛出ClassCastException,这正是sort方法所应该做的事情,因此,提前检测是否实现了该比较接口是没有多大意义的,再说多遍历一次也是消耗性能的,但此时我们先不进行参数有效性检测,则无效的参数值会导致计算过程抛出异常,而这种异常与文档中标明这个方法抛出的异常不符,在这种情况下,我们应该转换异常,将计算过程中抛出的异常转换为正确的异常。
当然,不是要求所有的参数都要求检查的,有些参数在实际应用中不会产生非法值或在我们需范围内不会有其他值,则就不需要进行检测。
39、 必要时进行保护性拷贝
保护性拷贝的实例:
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
//开始时间一定不能大于结束时间
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start() {
return (Date)start.clone();
//return new Date(start.getTime());
}
public Date end() {
return (Date)end.clone();
//return new Date(end.getTime());
}
}
这里两个地方都需要进行保护性拷贝,第一个是进参的地方,这里是构造器;第二个地方是传出去的地方,这里为start与end方法,如果丢掉一个地方,都有可能打破上面约束条件“开始时间一定不大于结束时间”。注,在构造器中,保护性拷贝是在检查参数的有效性之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始对象,这是一定要这么做的,因为如果将检测放在了最前面,则当检测满足条件后另一线程改变了原始对象参数的值,此进检测实质上已无效,所以这里与第38条并不是矛盾的。
同时请注意,构造器中我们没有用Date的clone方法来进行保护性拷贝。因为Date是非final类,不能保证传进来的一点是Date类型对象:它有可能是专门出于恶意的目的而设计的不可信子类的实例,这样我们调用clone方法时实质上不是调用Date上面的clone方法,而是恶意子对象上的,这样在克隆时子类可以在每个克隆实例被创建的时候,把指向该实例的引用记录到一个私有的静态列表中,并且允许攻击者访问这个列表,这将使得攻击者可以自由地控制所有的实例。所以,对于传进的参数实例我不要使用clone方法进行保护拷贝,而是直接使用new的创建方式来拷贝。
然而,对于访问方法,与构造器不同,在进行保护拷贝时候是允许使用clone方法的。之所以可以,是因为我们知道,Period内部的Date对象的类是java.util.Date,而不可能是其他某个潜在的不可信子类。
参数的保护性拷贝并不仅仅针对不可变类。每当编写方法或者构造器时,如果它要允许客户提供的对象进入到内部数据结构中,则有必要考虑一下,客户提供的对象是否有可能是可变的。如果是,就要考虑你的类是否能容忍对象进入数据结构之后发生变化,如果不允许,就必须对该对象进行保护性拷贝,以防止相互影响。
在内部组件返回给客户端之前,对它们进行保护性拷贝也是同样的道理。不管类是否为不可变的,在把一个指向内部可变组件的引用返回给客户端之前,也应该考虑是否进行拷贝。
记住长度非零的数组总是可变的。因引,在把内部数组返回给客户端之前,应该总要进行保护性拷贝。另一种解决方案是,给客户端返回该数组的不可变视图,这两种方法在第13条中已演示过了。
保护性拷贝可能会带来相关的性能损失,如果类信任它的调用都不会修改内部的组件,可能因为类及其客户端都是同一个包的双方,那么不进行保护性拷贝也是可以的。在这种情况下,类的文档必须清楚地说明,调用者绝不能修改受影响的参数或者返回值。
总之,如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改这些组件,就可以在文档中指明不能修改这些受到影响的组件,以此来代替保护性拷贝。
40、 谨慎设计方法签名
1、 谨慎地选择方法的名称。遵循标准命名习惯,风格统一、大众认可的相一致的名称。设计时可以参考类库。
2、 不要过于追求提供便利的方法。每个方法都应该尽其所能。方法太多会使类难以学习、使用、文档化、测试和维护。对于接口,更是这样,方法太多会使用接口实现者和接口用户的工作变得复杂起来。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到时,考虑为它提供快捷的方式。
3、 避免过长的参数列表。目标是四个参数或者更少。相同类型的长参数序列格外有害,如果不小心弄错了参数顺序时,他们的程序仍然可以编译和运行。有三种方法可以缩短过长的参数列表。第一种是把方法分解成多个方法,每个方法只需要这些参数的一个子集,即将多功能分解成多个单一的小功能。第二种方法是创建辅助类,如果使用FromBean封装了页面上的所有参数然后传到Action。第三种是结合了前两种方法特征,从对象构造到方法调用都采用Builder模式(参见第2条),如果方法有多个参数,其中有些又是可选的,最好定义一个对象来表示所有参数,并允许客户端在这个对象上进行多次“setter”调用,每次调用都设置一个参数或设置一个较小的集合,一旦设置了所需要的参数,客户端就调用这个对象的“执行(execute)”方法,它对参数进行最终的有效性检查,并执行实际的计算。
对于参数传递类型,我们要优先使用接口而不是类(请见第52条),只要有适当的接口可用来定义参数,就优先使用这个接口,而不是这个接口实现。我们没有理由在编写方法时使用HashMap类来作为输入,相反,应当使用Map接口作为参数类型,这使你可以传进各种Map的实现,如果碰巧输入的数据是以其他形式存在,使用具体类类型作为参数时就会导致不必要的转换操作。
对于boolean参数,要优先使用两个元素的枚举类型,这样便于以后第种选择的加入,这样不必要再添加另一个方法。
41、 慎用重载
public class CollectionClassifier {
public static String classify(Set<?> s) {
return "Set";
}
public static String classify(List<?> lst) {
return "List";
}
public static String classify(Collection<?> c) {
return "Unknown Collection";
}
public static void main(String[] args) {
Collection<?>[] collections = { new HashSet<String>(),
new ArrayList<BigInteger>(), new HashMap<String, String>().values() };
for (Collection<?> c : collections)
System.out.println(classify(c));
}
}
上面程序三次打印“Unknown Collection”,而没有打印出“Set”与“List”,为什么呢?因为重载方法的选择是静态的,而对于被覆盖的方法的选择则是动态的。选择被覆盖的方法的正确版本是在运行时进行的,选择的依据是被调用方法所在对象的运行时类型;而选择调用那一种方法则是在编译时就已经确定了的。
上面修改如下:
public static String classify(Collection<?> c) {
return c instanceof Set ?"Set": c instanceof List ?"List"
:Unknown Collection";
}
由于重载容易引起方法调用的混乱,有时调用的根本不是我们想要的方法。因此,应该避免胡乱地使用重载机制,而使方法的名称不同。
到底怎样才算胡乱使用重载机制呢?这个问题有点争议。安全保守的策略是,永远不要导出两个具有相同参数数目的重载方法,如果方法使用可变参数,保守的策略是根本不要重载它,除第42条中所述情形之外。例如ObjectOutputStream对于每种基本类型,以及几中引用类型,它的wirte方法都有一种变形,这些变形方法并不是重载write方法,而是如writeInt(int)、writeLong(long)这样的命名模式。
对于构造器,你没有选择使用不同名称的机会;一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器(见第1条)。
Jdk1.5引入了自动拆装箱,使用方法的调用更加混乱了,如:
public class SetList {
public static void main(String[] args) {
Set<Integer> set = new TreeSet<Integer>();
List<Integer> list = new ArrayList<Integer>();
for (int i = -3; i < 3; i++) {
set.add(i);
list.add(i);
}
//[-3, -2, -1, 0, 1, 2] [-3, -2, -1, 0, 1, 2]
System.out.println(set + " " + list);
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove(i);
}
//[-3, -2, -1] [-2, 0, 2]
System.out.println(set + " " + list);
}
}
上面set.remove(i)调用remove(Object o)方法,E为元素的类型,将i从int自动装箱到Integer,这是我们所期望的。而list.remove(i)则是调用的remove(int i)方法,而根本没有调用重载的remove(Object o)方法,即调用时采用了参数类型最匹配优先原则,所以没有自动装箱。修改如下:
for (int i = 0; i < 3; i++) {
//显示的自动装箱
list.remove((Integer) i);//或者是 list.remove(Integer.valueOf(i))
}
总之,“能够重载方法”并不意味着就“应该重载方法”,一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。在某些情况下,特别是涉及构造器的时候,要遵循这条建议也许是不可能的。在这种情况下,至少应该避免这样的情形:同一组参数只需经过类型转换就可以被传递给不同的重载方法,在这种情况下,我们坚决抵制这数的重载。
42、 慎用可变参数
可变数组方法接受0个或者多个指定类型的参数。可变参数机制通过先创建一个数组,数组大小为在调用位置所传递的参数数量,然后将参数值传到数组中,最后将数组传递给方法。
传递给可变参数方法时,可变参数可以先使用一个数组包装起来再传递到方法中去也是可以的。
在设计可变参数时,如果至少要传递一个参数,则最好将这个参数单独做为第一个参数,而不是都做成可变参数后在方法里进行参数检测。下面程序是在某个数组里找最小的元素,不应该是这样:
static int min(int... args) {
if (args.length == 0)
throw new IllegalArgumentException("Too few arguments");
int min = args[0];
for (int i = 1; i < args.length; i++)
if (args[i] < min)
min = args[i];
return min;
}
上面这种方案如果前面不对参数进行有效性检测,又如果运行时没有传递参数,则运行时出错。最好应该是这样设计:
static int min(int firstArg, int... remainingArgs) {
int min = firstArg;
for (int arg : remainingArgs)
if (arg < min)
min = arg;
return min;
}
下面看看1.4与1.5中的Arrays.asList,1.4中的是这样public static List asList(Object[] a),到了1.5中改成了可变参数成这样public static <T> List<T> asList(T... a) ,现在我们使用这个方法时要格外小心,看看下面几个问题:
public static void main(String[] args) {
String strs[] = new String[] { "str1", "str2" };
int ints[] = new int[] { 1, 2 };
/*
* 1.4输出:[str1, str2]
* 1.5输出:[str1, str2]
*/
System.out.println(Arrays.asList(strs));
/*
* 1.4编译不能通过!!
* 1.5输出:[[I@757aef]
*/
System.out.println(Arrays.asList(ints));
}
由于1.5版本中,令人遗憾地决定将Arrays.asList改造成可变参数方法,现在上面这个程序在1.5中可以通过编译,但是运行时,输出的不是我们想要的结果而是[[I@757aef],这主要是由于基本类型不能用于泛型的原因所致,所以在将一个基本类型数组传给asList(T... a)方法时,将整个基本类型数组看作是可能参数集中的第一个参数了。
但从好的方面来看,本来asList方法就不是用来打印数组中的元素字面值的,它的作用是将数组转换成List而已,这在1.5中得到了修补,并增加了Arrays.toString的方法,它正是专门为了将任何类型的数组转变成字符串而设计的。如果用Arrays.toString来代替Arrays.asList,我们将会得到想要的结果:
//[1, 2]
System.out.println(Arrays.toString(ints));
有两个方法签名是要特别注意的:
ReturnType1 suspect1(Object…args){}
<T> ReturnType2 suspects(T…args){}
带有上述任何一种签名的方法都可以接受任何参数列表,改造之前(asList(Object[] a))进行的任何编译时的类型检查(如不能传递基本类型的数组)都将会丢失,Arrays.asList发生的情形正是说明了这一点。
可变参数会影响性能,方法的每次调用都会导致进行一次数组的分配和初始化,如果考虑性能而又要这种可能参数的灵活性时,并假设对某个方法调用时使用的都是3个或更少的参数,就声明该方法的5个重载方法,每个重载方法带0至3个普通参数,当参数的数目超3个时,就使用一个可变参数方法:
public void foo(){}
public void foo(int a1){}
public void foo(int a1, int a2){}
public void foo(int a1, int a2, int a3){}
public void foo(int a1, int a2, int a3, int…rest){}
这种我们可以在EnumSet的静态工厂方法看到这样的引子。
总之,在定义参数数目不定的方法时,可变参数方法是一种很方便的方式,但是它们不应该被过度滥用,如果使用不当,会产生混乱的结果。
43、 返回零长度的数组或者集合,而不是null
对于一个返回null而不是零长度数组或者集合方法,几乎每次用到该方法时都需要额外处理是否为null,这样做很容易出错,因为缩写客户端程序的程序员可能会忘记写这种专门的代码来处理null返回值,如:
private final List<Cheese> cheesesInStock = …;
public Cheese[] getCheeses(){
if(cheesesInStock.size() == 0)
return null;
…
}
客户端使用如下:
Cheese[] cheeses = shop.getCheeses();
if(cheeses != null && …);
有时候有人会认为:null返回值比零长度数组更好,因为它避免了分配数组所需要的开销,这种观点是站不住脚的,原因有二:一是,除非这个方法正是造成性能问题的真正源头。二是,完全可以使用一个零长度的数组共享。
下面处理当一个集合为空时,返回一个零长度数组的有效做法:
private final List<Cheese> cheesesInStock = …;
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];//将零长度的数组设为静态的,以便共享用
public Cheese[] getCheeses(){
//借助于List的toArray方法,将列表转换成数组,如果传进的数组长度为零,则会返这个零长度数组本身,并且这个零长度数组是共享的
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
<T> T[] toArray(T[] a):如果指定的数组能容纳 collection 并有剩余空间(即数组的元素比 collection 的元素多),此时并不创建新的数组,那么会将数组中紧跟在 collection 末尾的元素设置为 null。(这对确定 collection 的长度很有用,但只有 在调用方知道此 collection 没有包含任何 null 元素时才可行。);如果指定的数组的容量比集合容量要小,则会重新创建一个集合大小的新的数组,所以如果数组与集合都是空时将返回一定会返回零长度数组本身。并且返回数组一定是安全的。
注意,toArray(new Object[0]) 和 toArray() 在功能上是相同的,只不过返回的数组恰好是集合中的元素,不多也不少,但如果集合为空时,返回也是零长度数组,不过这是集合为我们重新创建的,我们没有办法让零长度数组在以后共享。
上面是返回零长度数组的做法,下面看一下返回空集合的做法:
Collection能转换成安全的数组,Collections能在需要返回空集合时都返回同一个不可变的空集合(不能向其中添加元素,也不能读取,只知道它的长度为0,并且contains永远返回false),如emptySet、emptyList、emptyMap:
public List<Cheese> getCheeseList(){
if(cheesesInStock.isEmpty())
return Collections.emptyList();//总是返回相同的空的list
else
return new ArrayList<Cheese>(cheesesInStock);
}
总之,返回类型为数组或者集合的方法没有理由返回null,而是返回一个零长度的数组或者集合。
44、 为所有导出的API元素编写文档注释
为了正确地编写API文档,必须在每个导出类、接口、构造器、方法和域声明之前增加一个文档注释(这是强制的)。如果类是可序列化的,也应该对它的序列化形式编写文档(见第75条)。为了编写出可维护的代码,还应该为那些没有被导出的类、接口、构造器、方法和域编写文档注释(非强制的)。
方法的文档注释应该简洁地描述出它和客户端之间的约定,除了专门为继承而设计的类中的方法(见第17条)之外,这个约定应该说明这个方法做了什么,而不是说明它是如何完成这功项工作的。文档注释应该列举这个方法的前置条件与后置条件,前提条件指调用该方法要得到预期的结果必须满足的条件,如参数的约束。而后置就是指调用方法完后要完成预期的功能。
跟在@param标签或者@return标签后面的文字应该是一个名词短语,描述了这个参数或者返回值所表示的值,跟在@throws标签之后的文字应该包含单词“if如果”,紧接着是一个名词短语,它描述了这个异常将在什么样的条件下会被抛出。有时候,也会用表达式来代替名词短语。并且按照惯例,这个标签后面的短语或者子名都不用句点来结束。
类是否线程安全的,也应该在文档中对它的线程安全级别进行说明,如第75条中所述。
在文档注释内部出现任何HTML标签都是允许的,但是HTML元字符必须要经过转义。
第八章 通用程序设计
45、 将局部变量的作用域最小化
将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。
要使用局部变量的作用域最小化,最有力的方法就是在第一次使用它的地方才声明,不要过早的声明。
局部变量的作用域从它被声明的点开始扩展,一直到外围块的结束外。如果变量是在“使用它的块”之外被声明有,当程序退出该块之后,该变量仍是可见的,如果它在目标使用区之前或之后意外使用,将可能引发意外错误。
几乎每个局部变量的声明都应该包含一个初始化表达式,如果你还没有足够信息来对象一个变量进行有意义的初始化,就应该推迟这个声明,直到可初始化为止。但这条规则有个例外的情况与try-catch语句有关。如果一个变量被一个方法初始化,而这个方法可能会抛出一个检测性异常,该变量就必须在try块内部被初始化。如果变量的值必须在try块的外部使用到,它就必须在try块之前被声明,但是在try块之前,它还不能“有意义地初始化”,请参照第53条中的异常实例。
循环中提供了特殊的机会来将变量的作用域最小化。如果在循环终止之后不再需要使用循环变量的内容,for循环就优先于while循环。例如,下面是一种遍历集合的首选做法:
for(Element e: c){
doSomething(e);
}
在1.5前,首先做法如下:
for(Iterator i = c.iterator();i.hasNext();){
doSomething((Element) i.next());
}
为了弄清为什么这个for循环比while循环更好,请看下面代码:
Iterator<Element> i = c.iterator();
while(i.hasNext()){
doSomething(i.next());
}
…
Iterator<Element> i2 = c2.iterator();
while(i.hasNext()){//Bug!
doSomething(i2.next());
}
CP过来的代码未修改完,结果导致for循环编译通不过。
最后一种“将局部变量的作用域最小化”的方法是使方法小而集中。
----------------------
补充,不能在while条件中声明变量,这与for循环不一样,也不能像下面这样在while体中声明一个变量:
while(true)
int i = 1;
只可以这样:
while(true){
int i = 1;
}
46、 for-each循环优先于传统的for循环
1.5前,遍历集合的首选做法如下:
for(Iterator i = c.iterator(); i.hasNext();){
doSomething((Element)i.next());
}
遍历数组的首选做法如下:
for(int i =0; i < a.length;i++){
doSomething(a[i]);
}
虽然这些做法比while循环理好,但并不完美,因为迭代器和索引变量在每个循环中出现三次,其中有两次让你出错。
1.5中完全隐藏迭代器或者索引变量,避免了混乱和出错的可能,下面这种模式同样适合于集合与数组:
for(Element e : elements){
doSomething(e);
}
集合内嵌迭代时问题:
Collection<Suit> suits = ...;
Collection<Rank> ranks = ...;
List<Card> deck = ...;
for (Iterator<Suit> i = suits.iterator(); i.hasNext();)
for (Iterator<Rank> j = ranks.iterator(); j.hasNext();)
deck.add(new Card(i.next(), j.next()));//i.next在内部循环中多次调用,会出现问题
将i.next()临时存储起来可以解决这个问题:
for (Iterator<Suit> i = suits.iterator(); i.hasNext();){
Suit suit = i.next();
for (Iterator<Rank> j = ranks.iterator(); j.hasNext();)
deck.add(new Card(suit, j.next()));
}
如果使用内嵌的for-each循环,这个问题很快会完全消失,代码是如此的简洁:
for (Suit suit : suits)
for (Rank rank : ranks)
deck.add(new Card(suit, rank));
for-each循环不仅让你遍历集合数组,还让你遍历任何实现Iteralble接口的对象。这个简单的接口接口由单个方法组成,与for-each循环同时被增加到Java平台中,这个接口如下:
public interface Iterable<E>{
Iterator<E> iterator();
}
总之,for-each循环在简洁性和预防Bug方面有着传统的for循环无法比拟的优势,并且没有性能损失。应该尽可能地使用for-each循环,遗憾的是,有些情况是不适用的,比如需要显示地得到索上或迭代器然后进行其他操作,或者是内部循环的条件与外部有直接关系的(比如内层循环的起始值依赖于外层循环的条件值)。
47、 了解和使用类库
假如你希望产生位于0和某个上界之间的随机整数,你可以会这么做:
privae static final Random rnd = new Random();
static int random(int n){
return Math.abs(rnd.nextInt())%n;
}
上面程序产生的0到n间的整数是不均的,使用类库中的Random类的nextInt(Int)可以解决,这些方法是经过专家们设计,并经过多次测试和使用过的方法,比我们自己实现可靠得多。
通过使用标准类库,可以充分利用这些编写标准类库的专家的知识,以及在你之前的其他人的使用经验。
使用标准类库中的第二个好处是,不必关心底层细节上,把时间应花在应用程序上。
使用标准类库中的第三个好处是,它们的性能往往会随着时间的推移而不断提高,无需你做任何努力。
本条目不可能总结类库中所有便利工具,但是有两种工具值得特别一提。一个是1.2发行版本中的集合框架,二是1.5版本中,在java.util.concurrent包中增加了一组并发实用工具,这个包既包含高级的并发工具来简化多线程的编程任务,还包含低级别的并发基本类型,允许专家们自己编写更高级的并发抽象,java.util.concurrent的高级部分,也应该是每个程序员基本工具箱中的一部分。
总之,不要重新发明轮子,已存在的我们就直接使用,只有不能满足我们需求时,才需自己开发,总的来说,多了解类库是有好处的,特别是为库中的工具包。
48、 如果需要精确的答案,请避免使用float和double
float和double类型主要是用来为科学计算和工程计算而设计的。它们执行二进制浮点运算,这是为了在广泛的数值满园上提供较为精确的快速近似计算而精心设计的,然而,它们并没有提供完全精确的结果,所以不应该被用于需要精确结果的场合。float和double类型尤其不适合用于货币的计算,因为要让一个float和double精确地表示0.1(或者10的任何其他负数次方值)是不可能的。
System.out.println(1.0-.9);// 0.09999999999999998
请使用BigDecimal、int或long(int与long以货币最小单位计算)进行货币计算。
使用BigDecimal时请还请使用BigDecimal(String),而不要使用BigDecimal(float或double),因为后者在传递的过程中会丢失精度:
new BigDecimal(0.1)// 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1")//0.1
使用BigDecimal有两个缺点:与使用基本运算类型相比,这样做很不方便,而且很慢。
如果性能非常关键,请使用int和long,如果数值范围没有超过9位十进制数字,就可以使用int;如果不超过18位数字,就可以使用long,如果数字可能超过18位数字,就必须使用BigDecimal。
49、 基本类型优先于包装基本类型
Comparator<Integer> naturalOrder = new Comparator<Integer>() {
public int compare(Integer first, Integer second) {
/* 因为
* first < second 运行时会自动拆箱
* first == second 运行时不会自动拆箱
*/
return first < second ? -1 : (first == second ? 0 : 1);
}
};
//比较两个值相等的Integer
int result = naturalOrder.compare(new Integer(42), new Integer(42));
System.out.println(result);//所以结果为 1
修正上面这个问题做法是添加两个局部变量,让他们比较前自动拆箱,比较时一定是基本类型:
Comparator<Integer> naturalOrder = new Comparator<Integer>() {
public int compare(Integer first, Integer second) {
int f = first; // 自动拆箱
int s = second; // 自动拆箱
return f < s ? -1 : (f == s ? 0 : 1); // 按基本类型比较
}
};
int result = naturalOrder.compare(new Integer(42), new Integer(42));
System.out.println(result);//0
接下来,考虑这个小程序:
public class Unbelievable {
static Integer i;
public static void main(String[] args) {
if (i == 42)// !! 抛空指针异常
System.out.println("Unbelievable");
}
}
当一个项操作中混合使用基本类型和包装基本类型时,装箱基本类型就会自动拆箱,上面就是这样,如果null对象引用被自动拆箱,就会得到NullPointerException异常。修正这个问题很简单,声明i是个int而不是Integer就可以了。
最后考虑这个程序:
public static void main(String[] args){
Long sum = 0L
for(long i = 0; i < Integer.MAX_VALUE; i++){
sum += i;
}
System.out.println(sum);
}
这个程序运行起来很慢,因为它不小心将一个局部变量(sum)声明为是装箱基本类型Long,而不是基本类型long,程序反复的进行装箱与拆箱。
包装用在以下时机:一是作为集合中的元素、健和值;二是作为泛型的参数类型;三是反射。
总之,当可以选择的时候,基本类型要优先于包装类型,基本类型更加简单,也更加快速。自动装箱减少了使用包装基本类型的繁琐,但是并没有减少它的风险。另外,当程序用==操作符比较两个包装类型时,即使是在1.5中,也不会自动拆箱后比较,所以不管是1.5前还是以后,==都是比较的地址。
50、 如果其他类型更适合,则尽量避免使用字符串
字符串不适合代替其他的值类型。数组经过文件、网络,或键盘输出设置进入到程序中之后,它通常是以字符形式存在,但我们应该尽量将他们转换为确切的类型。
如果可以使用更加合适的数据类型,或者可以编写更加适当的数据类型,就应该避免用字符串来表示对象。若使用不当,字符串会比其他类型更加笨拙、更不灵活、速度慢,也更容易出错。经常被错误地用字符串来代替的类型包括基本类型、枚举类型和聚集类型。
51、 当心字符串连接的性能
由于字符串是不可变的,连接操作会产生新的字符串对象。所以不适合运用在大规模的场景中。
考虑下面的方法,它通过反复连接每个项目行,构造出一个代表该账单的字符串:
// Inappropriate use of string concatenation - Performs horribly!
public String statement() {
String result = "";
for (int i = 0; i < numItems(); i++)
result += lineForItem(i); // String concatenation
return result;
}
如果项目数量巨大,这个方法执行的时间就难以估算。为了获得可以接受的性能,请使用StringBuilder替代String(1.5中增加了非同步的StringBuilder类,代替了现在已经过时的StringBuffer类),下面是重构:
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,使它大到足以容纳结果字符串,即使使用默认大小(16)的StringBuilder,它仍比第一种快。
原则很简单:不要使用字符串连接操作符“+”来合并多个字符串,除非性能无关紧要。相反,应该使用StringBuilder的append方法。另一种方法是,使用字符数组,或者每次只处理一个字符串,而不是将它们组合起来。
——end
以上是effective java的建议,但说的不够精准,这里需要补充一下。
在JDK1.5中:String s = "a" + "b" + "c"; 在编译时,编译器会自动引入java.lang.StringBuilder类,并使用StringBuilder.append方法来连接。虽然我们在源码中并没有使用StringBuilder类,但是编译器自动地使用了它,因为它更高效。在1.4时或之前使用的是StringBuffer连接的。
虽然在JDK1.5或以上版本中使用“+”连接字符串时为避免产生过多的字符串对象,编译器会自加使用StringBuilder类来优化,但是如果连接操作在循环里,编译器会为每次循环都创建一个StringBuilder对象,所以在循环里一般我们不要直接使用“+”连接字符串,而是自己在循环外显示的创建一个StringBuilder对象,用它来构造最终的结果。但是在使用StringBuilder类时也要注意,不要这样使用:StringBuilder.append(a + ":" + c); ,如果这样,那编译器就会掉入陷井,从而为你另外创建一个StringBuilder对象处理括号内的字符串连接操作。
上面第一个例子中,是一个涉及到循环的字符串连接,由于循环次数是不确定的,我们无法将整个连接过程用单个表达式描述,所以编译器不得不隐式地为每一个表达式创建一个 StringBuffer 的对象,这才是导致运行效率低下的原因。也正是在这种前提下,显式地使用一个 StringBuffer 来进行字符串连接才能提高运行效率。
所以,如果我们最终要得到的字符串是可以通过一个表达式就连接而成的话(如String s = "a" + "b" + "c";),那么无论是用“+”还是 StringBuffer 在编译后的运行效率是完全一样的。相比之下,使用“+”的可读性恐怕还要更好些,因为1.4或之前的代码中使用“+”的在现在1.5中编译时会使用StringBuilder,但那些已经显式使用了 StringBuffer 的代码就不得不靠手工维护了,所以使用“+”在有时(可以通过一个表达式就能搞定的话)可能会更好一些。
总之,在大多数情况下,我们应该尽可能将整个字符串的连接集中在一个表达式里描述,然后让编译器来替我们使用 StringBuffer/StringBuilder ,只有当字符串的连接不得不涉及到多条语句的时候,才有必要显式的使用 StringBuffer/StringBuilder。
下来看一下例子:
void f() {
String a = "a";
String b = "b";
String c = "c";
//这里只产生一个StringBuilder
String s1 = a + b + c;
//从字节码中可以看现,下面会产生两个StringBuilder
String s2 = a + b;//这里会产生一个StringBuilder
s2 = s2 + c;//这里还会产生一个StringBuilder
}
从上面可以看出,如果使用“+”接连拉,要尽量将连接操作在一个表达式中完成,而不要在多个表达式中进行连接,因为一个表达式会产生一个StringBuilder,多个连接表达式就会产生多个StringBuilder。
52、 通过接口引用对象(针对接口编程)
如果有适合的接口类型存在,那么对于参数、返回值、变量和域来说,就都应该使用接口类型进行声明。只有当你利用构造器创建某个对象的时候,才真正需要引用这个对象的类。
// 应该这样:
List<Foo> foo = new Vector<Foo>();
// !! 不应该这样:
Vector <Foo> foo = new Vector<Foo>();
如果你养成了用接口作为类型的习惯,你的程序将会更加灵活。当你决定更换实例时,所要做的就是只要改变构造器中类的名称(或者使用一个不同的静态工厂),例如,第一个声明可以改成:
List<Foo> foo = new ArrayList<Foo>();
周围的所有代码都可以继承工作,因为它们不知道原来的实现类型,所以不关注这种变化。
有一点值得注意:如果原来的实现提供了某特殊的功能,而这种功能并不是这个接口的通用约定所要求的,并且周围的代码又依赖于这种功能,那么关键的一点是,新的实现也要提供同样的功能。例如,如果代码依赖于Vector的同步功能,在声明中用ArrayList代替Vector就不正确了。如果依赖于实现的任何特殊属性,就要在声明变量的地方给这些需求建立相应的文档说明。
如果没有合适的接口存在,完全可以用类而不是接口来引用对象。例如,考虑值类,比如String和BigInteger。值类很少会用多个实现编写。它们通常是final的,并且很少有对应的接口。使用这种值类作为参数、变量、域或者返回类型是再合适不过了。
如果没有接口,但存在抽象类时,我也要优先考虑使用这个最基础的类来定义的类型。
不存在适当接口类型的最后一种情形是,类实现了接口,但是它提供了接口中不存在的额外方法——例如LinkedHashMap。如果程序依赖于这些额外的方法,这种类就应该只被用来引用它的实例,它很少应该被用作参数类型(第40条)。
53、 接口优先于反射机制(使用接口类型引用反射创建实例)
反射的代价:
1、 丧失了编译时类型检查的好处,包括异常检查。如反射的东西不存在时,在运行时将会失败。
2、 执行反射访问所需要的代码非常笨拙和冗长。
3、 性能损失。反射方法调用比普通方法调用慢了许多。
核心反射机制最初是为了基本组件的应用创建工具而设计的,普通应用程序在运行时不应该以反射方式访问对象。
如果只是以非常有限的形式使用反射机制,虽然也要付出小许代价,但是可以获得许多好处。对于有些程序,它们必须用到编译时无法获取的类,但是在编译时存在适当的接口或者是超类,通过它们可以引用这个类。如果是这种情况,就可以使用反射方式创建实例,然后通过它们的接口或者超类,以正常的方式访问这些实例。如果调用的构造器不带参数,我们根本不需要使用java.lang.reflect中的Constructor来反射出构造器对象,而是可以直接使用Class.newInstance方法就已经提供了所需要的功能。
类对于在运行时可能不存在的其他类、方法或者域的依赖性,用反射法进行管理,这种用法是合理的,但是很少使用。
总之,反射机制是一种功能强大的机制,对于特定的复杂系统编程任务,它是非常必要的,但它也是有一些缺点。如果你编写的程序必须要与编译时未知的类一起工作,如有可能,应该仅仅使用反射机制来实例化对象,而访问对象时则使用编译时已知的某个接口或者超类。
54、 谨慎使用本地方法
Java Native Interface(JNI)允许Java应用程序可以调用本地方法,所谓本地方法是指用本地程序设计的语言(如C或者C++)来编写的特殊的方法。它可以在本地语言中执行任意的计算任务后返回到Java语言。
本地方法主要有三种用途。它们提供了“访问特定于平台的机制”的能力,比如访问注册表和文件锁。它们还提供了访问遗留代码库的能力,从而可以访问遗留数据。最后,本地方法可以通过本地语言,编写应用程序中注重性能的部分,以提高系统的性能。
使用本地方法来访问特定于平台的机制与访问遗留代码是合法的。但使用本地方法来提高性能的做法不值得提倡,因为VM在逐渐的更优更快了,如1.1中BigInteger是在一个用C编写的快速多精度运行库的基础上实现的,但在1.3中,则完全用Java重写了,并进行了精心的性能调优,比原来的版本甚至更快一些。
使用本地方法有一些严重的缺点。因为本地语言不是安全的、不可移植、难调试,而且在进行本地代码时,需要相关的固定开销,所以如果本地代码只是做少量的工作,本地方法就可能降低性能。
总之,本地方法极少数情况下会需要使用本地方法来提高性能。如果你必须要使用本地方法访问底层的资源,或者遗留代码,也要尽可能少的使用本地代码。
55、 谨慎地进行优化
有三条与优化有关的格言是每个人都应该知道的:
1、 很多计算上的过失都被归咎于效率(没有必要达到的效率),而不是任何其他原因——甚至包括盲目地做傻事。
2、 不要去计较效率上的一些小小的得失,在97%的情况下,不成熟的优化才是一切问题的根源。
3、 在优化方面,我们应该两条规则:
规则1:不要进行优化。
规则2(仅针对专家):还是不要进行优化——也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。
所有这些格言都比Java程序设计语言的出现早了20年,它们讲述了一个关于优化的深刻真理:优化的弊小于利,特别是不成熟的优化。在优化过程中,产生软件可能既不快速,也不正确,而且还不容易修正。
不要因为性能而牺牲合理的结构。要努力编写好的程序而不是快的程序。好的程序体现了信息隐藏的原则:只要有可能,它们就会设计决策集中在单个模块里,因此,可以改变单个的决策而不会影响到系统的其他部分。
必须在设计过程中考虑到性能问题。遍布全局并且限制性能的结构缺陷几乎是不可能被改正的,除非重新编写系统。
要考虑API设计决策的性能后果。使公有的类型成为可变的,这可能会导致大量不必要的保护性拷贝。同样,在适合使用复合模式的公有类中使用继承,会把这个类与它的超类永远地束缚在一起,从而人为地限制了子类的性能。最后一个例子,在API中使用实现的类型而不是接口,会把你束缚在一个具体的实现上,即使将来出现更快的实现你也无法使用。
一旦谨慎地设计了程序并且产生了一个清晰、简明、结构良好的实现,那么就到了该考虑优化的时候了,假定此时你对程序的性能还是不满意。
总之,不要费力去编写快速的程序——应该努力编写好的程序,速度自然会随之而来。在设计系统的时候,特别是在设计API、线路层协议和永久数据库格式的时候(模块之间的交互与模块与外界的交互一旦定下来后是不可能更改的),一定要考虑性能的因素。当构建完系统之后,要测量它的性能。如果它足够快,你的任务就完了。如果不够快,则可以在性能剖析器的帮助下,找到问题的根源,然后设法优化系统中相关的部分。第一个步骤是检查所选择的算法:再多的低层优化也无法弥补算法的选择不当。
>>>《Practical Java》性能拾遗<<<
实践28:先要把焦点放在设计、数据结构和算法身上,不要因要求提高程序执行速度度,而放弃了良好、可靠的设计,转而是追求不可能达到或甚小的性能改良。
产生运行快的代码的一个规则是,只优化必要的部分。花费时间将代码优化,却未能给程序带来实质性的性能影响,就是在浪费时间。如果80%-90%的程序执行的时间花费在10%-20%的代码上面(80-20法则),那你最好找出这需要改善的10%-20%代码然后进行优化。
请记住,高效代码与良好的设计、明智地选择数据结构和明智地选择算法三者的密切程度,远大于与实现语言的关系。
实践29:不要依赖于编译期的优化技术。比如在编译时使用 javac -o,-o选项不一定能够为运行期产生优化代码。以前可能做过一些内联函数的优化,但后来这个优化选项被取消了(因为这个选项产生的代码并不会比你自己撰写的更好),不过那些能内联的函数的确在“运行期”由JIT进行了内联动作。在Java中这样的函数可以内联:如果函数体小而且可以由编译器静态决议,它就可以被视为内联的候选者。所谓“可被静态决议”的函数,就是“不能被覆写”的函数,不能覆写的函数是private、static或final函数。
程序员必须明白,“常见”的Java编译器几乎做不了什么优化工作,面对这种情况,我们只有三个选择:
1、 手工优化Java源码,以求获得更好的性能。
2、 使用某个第三方优化编译器,将源码编译为优化的bytecode。
3、 依靠诸如JIT或Hotspot这新的“运行期优化技术”。
实践30:理解运行期代码优化技术。JIT的目的在于将bytecode于运行期转换为本机二进制码,它是一种运行期代码优化技术,有大部分桌面系统和企业系统的JVMS伴随有JIT。JIT优化时需先运转起来,这也是需要消耗时间的,它是针对相对较少的运行时间而设计,因为它们是存在是为了加速代码,如果JIT做的工作越多,它运行的时间也就越长,如果这样你的程序运行时间就会更长。
实践31:使用StringBuffer或StringBuilder进行字符连接要优于String。非并发环境下优先使用非线程安全集合类。
实践32:将对象的创建成本降到最低。创建一个轻对象就比创建一个重型对象快得多。所谓轻型对象是指:既不具有长继承链,也不含有其他引用域。重型对象恰恰相反。创建一个对象要经过heap的分配、内存清零、初始化最深层父类的域、调用最深层父类构造函数、沿着继承树向下到本类初始化域与执行构造器一系列的动作,所以创建重型对象将可能会比较慢。
实践33:对象的创建成本是非常昂贵的,绝对不要创建一些不必要的对象:
public int[] addArrays1(int[] arr1, int[] arr2) {//优先前
//下面创建的两个变量过早,可能用不着
int[] result = new int[ArraySize];
IllegalArgumentException exc = new IllegalArgumentException(
"arrays are invalid");
if (arr1 != null && arr2 != null && arr1.length == ArraySize
&& arr2.length == ArraySize) {
for (int i = 0; i < ArraySize; i++)
result[i] = arr1[i] + arr2[i];
} else
throw exc;
return result;
}
public int[] addArrays2(int[] arr1, int[] arr2) {//优先后
if (arr1 != null && arr2 != null && arr1.length == ArraySize
&& arr2.length == ArraySize) {
//只有在必要时才创建对象
int[] result = new int[ArraySize];
for (int i = 0; i < ArraySize; i++)
result[i] = arr1[i] + arr2[i];
return result;
}
//只有在必要时才创建对象
throw new IllegalArgumentException("arrays are invalid");
}
实践34:将同步化降至最低。
public synchronized int top1(){
return intArr[0];
}
public int top2(){
synchronized(this){
return intArr[0];
}
}
我们可以通过生成的字节码可以看出top2比top1体积大且还慢。因为top2中它要进行和异常的处理。记住,top1虽然带有synchronized修饰符,这并不会生成额外代码(性能还是会打折扣的),但top2会。如果你在函数体中使用synchronized,就会产生操作码monitorenter和moniterexit的bytecode,以及为了处理异常而附加的代码,之所以这样是为了在出现异常后确保退出前释放锁,所以top1比top2性能略高一些。总之,不管是同步方法还是使用同步块,都会大大降低性能,但如果整个函数都需要被同步化,则为了产生体积较小的且执行速度较快的代码,请优先使用函数修饰符,而不是在函数内使用synchronized块。
如果我们不需要synchronized方法,但我们又无法修改被调用的类,我们可以继承这个类,来重写那个被同步了的方法,不过样会增加代码维护量,不建议这么做,只是说可以这样实现。
当然,同步方法是可能会导致长时间的占用锁而导致并发性降低,一般只有短的方法才使用,如果方法体很长,而且有些代码行是不需要同步访问的,这时我们使用同步块为了高并发性可能会好些。
实践35:尽可能使用stack(局部)变量。访问stack变量要快于静态的或实例域,因为VM所做的相应工作远少于访问static变量或instance变量所做的工作。考虑下面代码:
class StackVars {
private int instVar;
private static int staticVar;
// 访问局部stack变量
void stackAccess(int val) {
int j = 0;
for (int i = 0; i < val; i++)
j += 1;
}
//访问实例域变量
void instanceAccess(int val) {
for (int i = 0; i < val; i++)
instVar += 1;
}
// 访问静态域变量
void staticAccess(int val) {
for (int i = 0; i < val; i++)
staticVar += 1;
}
}
经过测试发现stackAccess快于其它两个方法,不过可以优先它们,经过下面的优化后性能与stackAccess不相上下:
// 访问实例域变量
void instanceAccess(int val) {
int j = instVar;
for (int i = 0; i < val; i++)
j += 1;
}
// 访问静态域变量
void staticAccess(int val) {
int j = staticVar;
for (int i = 0; i < val; i++)
j += 1;
}
实践36:使用static、final和private函数以促成inlining(内联),此类函数可以在编译期被静态决议,让这此函数成为inlining的候选者,因为以函数本体替换函数调用会使代码执行更快。通常inlined函数包括class中常见而小巧的取值函数和设值函数,这些函数往往只有一两行代码。这些函数不会被目前市面上的大部Java编译器“inline化”,不过它们将被目前市面上大部分的JITs于运行期“inline化”,并导致性能显著的提升。另外,inlined函数只有在“被多次调用”的情况下,才会获得令人满意的性能提升。
实践37:instance变量初始化一次就好。不要在构造器内重复对实例域赋类初始默认值,也不要在定义时给实例域重新赋类初始默认值,这都是画蛇添足的做法,反而会影响性能,因为在调用构造器时或之前都会再一次执行赋值操作,而赋的这些值都是在对象内存分配时VM就已经将实例域初始化为相应的缺省值了。下面是错误的做法:
class Foo {
private int count;
private boolean done = false;//多余
private Vector vec;
public Foo() {
count = 0;//多余
vec = new Vector(10);
}
}
具有优化能力的编译器理应消除这些多余的赋值操作,不幸的是许多编译器没有这种能力。
但要记住,local变量没有缺省值,因此你必须将它明确地初始化,否则编译不通过。
实践38:使用基本类型使代码更快更小。基本类型体积(所需存储空间)小,访问快;而包装类型体积大,访问慢。考虑以下函数,stack之中有一个基本类型的int变量与一个reference指向的Integer对象:XXXXXXXXXXXXXXXXXX
实践39:不使用使用迭代器来遍历ArrayList、Vector,而是直接使用数组索引在通过for循环遍历。
实践40:使用System.arraycopy()来复制数组(在1.6中可以使用Arrays.copyOf方法更简洁),不需要使用for循环来循环拷贝每个元素,System.arraycopy()是本地函数,它可以直接、高效地移动“原array”的内存内容到“目标array”,这个动作之快中以消减本机函数的调用代价,固然调用函数需要代价,但那怕是在通过for循环直接面方法体里,也不及调用一下本地方法。
实践41:优先使用数组,然后才考虑ArryaList和Vector。如果不需同步,也请先使用ArrayList。
实践42:尽可地复用已存在的对象。
实践43:延迟加载相关的域。对有些不必马上初始化,而是等到使用时再初始化,不必要一下将整个对象都初始化完整。
实践44:以手工方将代码优化。
1、 删除空白函数
2、 删除无用代码
3、 使用复合赋值操作:i = i + x 改为 i += x;
4、 如果果能,尽量将变量声明成final,以便于在编译时期就能执行计算。
5、 对于方法里重复出现的表达式(比如i+j出重出现)或重复调用的有结果的方法(如果ArrayList的size())时,一定要将它们先赋值给一个临时变量后再使用这个临时变量来代替使用。
6、 对于小循环(次数少),我们可以将它展开,不使用循环,从而产生更快的执行效果,因为展开后相于变相的inline。
7、 简化代数:
int a = 1 ,b =2, c = 3;
int x = f*a + f*b + f*c;
可以做如下优化:
int x = f*(a + b + c);
8、 使用临时变量代替循环体内的固定表达式或返回结果不变的方法。
void fuc(int a, int b, List list) {
for (int i = 0; i < list.size(); i++) {
list.add(Integer.valueOf(i + a + b));
}
}
上面可做如下优化:
void fuc(int a, int b, List list) {
int x = a + b;
int size = list.size();
for (int i = 0; i < size; i++) {
list.add(Integer.valueOf(i + x));
}
}
实践45:编译为本机代码。缺点是失去了跨平台性。
实践46:使用包装类型时,如果要构造的包装类型是-128到127间时,请使用valueOf静态工厂方法来构造,不要使用new,因为这样会使用缓存中的包装对象(包装类型都是不可变的,所以缓存具有意义)。
56、 遵守普遍接受的命名惯例
任何将在你的组织之外使用的包,其名称都应该以你的组织的Internet域名形状,并且顶级域名放在前面,例如com.sum。标准类库和一些可选的包,其名称以java和javax开头,这属于例外,用户创建的包名绝不能以java和javax开头。包名称的其余部分应该包括一个或者多个描述该包的组成部分,这些组成部分应该比较简短,通常不超过8个字符,使用单词缩写或字母缩写。
类和接口的名称,包括枚举和注解类型的名称,都应该使用一个或者多个单词,每个单词的首字母大写,例如Timer和TimerTask。应该尽量避免用缩写,除非是一些首字母写和一些通用的缩写,比如max和min。对于首字母缩写,强烈建义采用仅首字母大写的形式,如HttpUrl就比HTTPURL好。
方法和域的名称与类和接口的名称一样,都遵守相同的字面惯例,只不过方法或者域的名称的第一个字母应该小写,哪怕第一个单词全由缩写组成。
上述规则的唯一例外的是“常量域”,它的名称应该包含一个或者多个大写的单词,中间用下划线符号隔开,例如VALUES域NEGATIVE_INFINITY。注意,常量域是唯一推荐使用下划线的情形。
局部变量名称的字面命名惯例与成员名称类似,只不过它也允许缩写。
类型变量名称通常单个字母组成,这个字母通常是以下一种上类型之一:T表示任意类型,E表示集合元素类型,K和V表示映射的键和值类型,X表示异常。如果同时有多个类型参数,则可以是T、U、V或T1、T2、T3。
语法命名惯例比字面惯例更加灵活,也更有争议。对于包而言,没有语法命名惯例。类包括枚举类型,通常用一个名词或者名词短言命名,如Timer、BufferedWriter。接口的命名与类相似,例如Collection或Comparator,或者使用一个以“-able”或者“-ible”结尾的形容词,例如Runnable、Iterable或者Accessible。由于注解类型有很多用处,因此没有单独安排词类。名词、动词、介词、形容词都常用。
执行某个动作的方法通常用动词或者动词短语来命名,例如append或drawImage。对于返回boolean值的方法,其名称往往以单词“is”开头,很少使用has,后面跟名词或名词短语,或者任何具有形容词功能的单词或短语,如isDigit、isEmpty、isProbablePrime、isEnabled或者hasSiblings。
如果方法返回被调用对象的一个非boolean的函数或者属性,它通常用名词、名词短语,或者以动词“get”开资源丰富的动词短语来命名,例如size、hashCode或者getTime。但如果方法所在的类是个Bean,就要强制使用以“get”开头的形式。另外,如果这个类包含一个方法用于设置同样的属性,则强烈建议采用这种形式,在这种情况下,这两个方法应该被命名为getAttribute和setAttribute。
有些方法的名称需专门提出来说。转换对象类型的方法、返回不同类型的独立对象的方法,通常被称为toType,如toString和toArray。返回视图(视图的类型不同于接收对象的类型)的方法通常被称为asType,例如asList。返回一个与被调用对象同值的基本类型方法,通常被称为typeValue,如intValue。静态工厂的常用名称为valueOf、of、getInstance、newInstance、getType和newType(见第1条)。
域名称的语法惯例没有很好地建立起来,也没有类、接口和方法的惯例那么重要,因为域会很少暴露出来的。boolean类型的域与返回boolean类型的访问方法很类似,但是省去了开头的“is”,例如initialized和composite。其它类型的域通常用名词或名词短语来命名,比如height、digits或bodyStyle。局部变量的方法惯例类似于域的语法惯例。但是更弱一些。
第九章 异常
57、 只针对异常的情况才使用异常
也许你在将来会碰到下面这样的代码,它是基本异常模式的循环:
try{
int i = 0;
while(true)
range[i++].climb();
}catch(ArrayIndexOutOfBoundsException e){
}
这所以有些人会这么做,是因为他们企图利用Java的错误判断机制来提高性能,因为VM对每次数组访问都要检查越界情况,即使是使用for-each,他们认为正常的循环终止测试只是被编译器隐藏了,但在循环中仍然可见,这种考虑无疑是多余的。
实例上,在现代的JVM实例上,基本异常的模式比标准模式要慢得多。
异常应该只用于异常的情况下,它们永远不应该用于正常的控制流。
设计良好的API不应该强迫它的客户端为了正常的控制流程而使用异常。如Iterator接口有一个“状态相关”的next方法,和相应的状态测试方法hasNext,这使得利用传统的for循环对集合进行迭代的标准模式成为可能:
for(Iterator<Foo> i = coolection.iterator(); i.hasNext()){
Foo foo = i.next();
…
}
如果Iterator缺少hasNext方法,客户端将被迫改用下面的做法:
try{
Iterator<Foo> i = collection.iterator();
while(true){
Foo foo = i.next();
…
}
}catch(NoSuchElementException e){
}
另一种提供单独的状态测试方法的做法是,如果“状态相关的”方法被调用时,该对象处于不适当的状态之中时,它就会返回一个可识别的值,比如null。这种方法对于Iterator而言不合适,因为null是next就去的合法返回值。
对于“状态测试方法”和“可识别的返回值”这两种做法,有些告诫:如果对象在缺少外部同步的情况下被并发访问,或者可被外界改变状态,使用可被识别的返回值可能是很有必要的,因为在调用“状态测试”方法和调用对应的“状态相关”方法的时间间隔之中,对象的状态有可能会发生变化。但从性能角度考虑,使用可被识别的返回值要好些。如果所有其他方面都是等同的,那么“状态测试”方法则略优先可被识别的返回值。
总之,异常是为了在异常情况下使用而设计的,不要将它们用于普通的控制流,也不要缩写迫使它们这么做的API。
>>>《Practice Java》异常拾遗<<<
如果finally块中出现了异常没有捕获或者是捕获后重新抛出,则会覆盖掉try或catch里抛出的异常,最终抛出的异常是finally块中产生的异常,而不是try或catch块里的异常,最后会丢失最原始的异常。
如果在try、catch、finally块中都抛出了异常,只是只有一个异常可被传播到外界。记住,最后被抛出的异常是唯一被调用端接受到的异常,其他异常都被掩盖而后丢失掉了。如果调用端需要知道造成失几的初始原因,程序之中就绝不能掩盖任何异常。
请不要在try块中发出对return、break或continue的调用,万一无法避免,一定要确保finally的存在不会改变函数的返回值(比如说抛异常啊、return啊以及其他任何引起程序退出的调用)。因为那样会引起流程混乱或返回值不确定,如果有返回值最好在try与finally外返回。
不要将try/catch放在循环内,那样会减慢代码的执行速度。
如果构造器调用的代码需要抛出异常,就不要在构造器处理它,而是直接在构造器声明上throws出来,这样更简洁与安全。因为如果在构造器里处理异常或将产生异常的代码放在构造器之外调用,都将会需要调用额外的方法来判断构造的对象是否有效,这样可能忘记调用这些额外的检查而不安全。
58、 对可恢复的情况使用受检异常,对编程错误使用运用时异常
Java程序设计语言提供了三种异常:受检的异常(checked exception)、运行时异常(run-time exception)和错误(error)。关于什么时候适合使用哪种异常,虽然没有明确的规定,但还是有些一般性的原则的。
检测性异常通常是由外部条件不满足而引起的,只要条件满足,程序是可以正常运行的,即可在不修改程序的前提下就可正常运行;而运行时异常则是由于系统内部或编程时人为的疏忽而引起的,这种异常一定要修正错误代码后再能正确运行。受检异常对客户是有用的,而运行时异常则是让开发人员来调试的,对客户没有多大的用处。
在决定使用受检异常还是未受检异常时,主要的原则是:如果期望调用者能够适当地恢复,对于这种情况就应该使用受检的异常。抛出的受检异常都是对API用户的一种潜在的指示:与异常相关的条件是调用这个方法的一种可能的结果。
有两种未受检的异常:运行时异常和错误。在行为上两种是等同:它们都不需要捕获。如果抛出的是未受检异常或错误,往往就属于不可恢复的情形,继续执行下去有害无益。如果程序未捕获这样的异常或错误,将会导致线程停止,并出现适当的错误消息。
用运行时异常来表明编程错误。大多数的运行时异常都表示违返了API规约,API的客户同有遵守API规范。例如,数组访问的约定指明了数组的下标值必须在零和数组长度减1之间,ArrayIndexOutOfBoundsException表明了这个规定。
按照惯例,错误往往被JVM保留用于表示资源不足、约束失败,或者其他程序无法继续执行的条件。由于这已经是个几乎被普遍接受的惯例,因此最好不要再实现任何新的Error子类。因此,你实现的所有未受检异常都应该是RuntimeException的子类或间接是的。
总而言这,对于可恢复的情况,使用受检的异常;对于程序错误,则使用运行时异常。当然,这也不总是这么分明的。例如,考虑资源枯竭的情形,这可能是由于程序错误而引起的,比如分配了一块不合理的过大的数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大所造成的,这种情况可能就是可恢复的。API设计者需要判断这样的资源枯竭是否允许。如果你相信可允许恢复,就使用受检异常,否则使用运行时异常。如果不清楚,最好使用未受检异常(原因请见第59条)。
API的设计都往往会忘记,异常也是个完全意义上的对象,可以在它上面定义任意的方法,这些方法的主要用途是为捕获异常的代码而提供额外的信息,特别是关于引发这个异常条件的信息。如果没有这样的方法,API用户必须要懂得如何解析“该异常的字符串表示法”,以便获得这些额外的信息,这是极不好的作法。类很少以文档的形式指定它们的字符串表示的细节,因此,不同的实现,不同的版本,字符串表示可能会大相径庭,因此,“解析异常的字符串表示法”的代码可能是不可移植的,也是非常脆弱的。
因为受检异常往往指明了可恢复的条件,所以,这于这样的异常,提供一些辅助方法尤其重要,通过这些方法,调用都可以获得一些有助于恢复的信息。例如,假设因为没有足够的钱,他企图在一个收费电话上呼叫就会失败,于是抛出检查异常。这个异常应该提供一个访问方法,以便用户所缺的引用金额,从而可以将这个数组传递给电话用户。
59、 避免不必要地使用受检异常
受检异常与运行时异常不一样,它们强迫程序员处理异常的条件,大大增强了可靠性,但过分使用受检异常会使用API使用起来非常不方便。如果方法抛出一个或者多个受检异常,调用都就必须在一个或多个catch块中处理,或者将它们抛出并传播出去。无论是哪种,都会给程序员添加不可忽视的负担。当然,如果程序员能处理这一类异常,则不算做负担,但以下决对是负担:
}catch(TheCheckedException e){
throw new AssertionError();// 断言不会发生异常
}
或
}catch(TheCheckedException e){// 哦,失败了,不能恢复
e.printStackTrace();
System.exit(1);
}
上面两种方式都不是最好的处理方式,之所以程序员这样处理,是因为他们认为这根本就是一种不可恢复的异常(当然程序员这种认为要是正当的),如果使用API的程序员无法做得比这更好,那么未受检异常可能更为合适,所以异常设计者应将TheCheckedException设计成运行时异常会更好些。这种例子的反例就是CloneNotSupportedException,它是Object.clone抛出来的,而Object.clone应该只是在实现了Cloneable的对象上者可以被调用,这显然是API调用都未实现该接口所导致,除非程序实现该接口,否是不可恢复的,所以CloneNotSupportedException应该设计成运行时异常或许更加合理一些。
如果方法只抛出单个受检异常,也会导致该方法不得在try块中,在这种情况下,应该问自己,是否有别的途径来避免API调用者使用受检的异常。这里提供这样的参考,我们可以把抛出的单个异常的方法分成两个方法,其中一个方法返回一个boolean,表明是否该抛出异常。这种API重构,把下面的调用:
try{//调用时检查异常
obj.action(args);//调用检查异常方法
}catch(TheCheckedExcption e){
// 处理异常条件
...
}
重构为:
if(obj.actionPermitted(args)){//使用状态测试方法消除catch
obj.action(args);
}else{
// 处理异常条件
...
}
这种重构并不总是合适的,但在合适的地方,它会使用API用起来更加舒服。虽然没有前者漂亮,但更加灵活——如果程序员知道调用肯定会成功,或不介意由调用失败而导致的线程终止,则下面为理为简单的调用形式:
obj.action(args);
如果你怀疑这个简单的调用是否成功,那么这个API重构则可能就是恰当的。这种重构之后的API在本质上等同于第57条件的“状态测试方法”,并且,同样的告诫也要遵循(告诫参考57)。
60、 优先使用标准异常
java平台提供了一组基本未受检查的异常,它们满足了绝大多数API的异常抛出的需要。
重用现有的异常好处最主要的是,易学和易使用,与已经熟悉的习惯用法一致。第二可读性更好,不会出现不熟悉的异常。
最经常被重用的异常是IllegalArgumentException。当调用者传递的参数值不合适的时候,往往就会抛出这个异常。如需要一个正数而传递的是一个负数时。
另一个经常被重用的异常是IllegalStateException。如果因为对象的状态而使用调用非法,则会抛出。如某个对象被正确初始化之前就调用。
可以这么说,所有错误的方法调用都可以被归结为非法参数或者非法状态,但是,还有更具体的异常用于非法参数和非法状态。例如在参数不允许为null或传递数组索引越界时,就应该抛出NullPointerExcption异常与IndexOutOfBoundsException异常,而不是IllegalArgumentException。
另一个值得了解的能用异常是ConcurrentModificationException。如果一个对象被设计专用于单线程或者与外部同步机制配合使用,一旦发现它正在或已经被修改,就抛出该异常。
最后一个通用的异常是UnsupportedOperationException。如果对象不支持所请求的操作,就会抛出这个异常。
前面列举的最是最常用的异常,但在有些情况下,这些也是可重用的,如ArithmeticException和NumberFormatException异常可用在算术对象中。
一定要确保抛出的异常的条件与该异常的文档中的描述的条件是一致的,如果希望稍微增加更多的失败-捕获信息,可以把现有的异常进行子类化。
61、 在无法避免底层异常时,抛出与系统直接相关的异常
如果方法抛出的异常与所执行的任务没有明显的联系,这种情形将会使人不知所措,当底层的异常传播到高层时往往会出现这种情况。这了使人困惑之外,抛出的底层异常类会污染高层的API(高层要依赖于底层异常类)。为了避免这个问题,高层在捕获底层抛出的异常的同时,在捕获的地方将底层的异常转换后再重新抛出会更好:
// 异常转换
try {
// 调用底层方法
...
} catch(LowerLevelException e) {
//捕获底层抛出的异常后并转换成适合自己系统的异常后再重新抛出
throw new HigherLevelException(...);
}
下面是个来自AbstractSequentialList类中的底层异常转换的实例,该数是List的一个抽象类,它的直接子类为LinkedList,在这个例子中,按照List<E>接口中的get方法的规范(规范中说到:如果索引超出范围 (index < 0 || index >= size()),就会抛出IndexOutOfBoundsException异常),底层方法只要可能抛出异常,我们就需要转换这个异常,下面是AbstractSequentialList类库的做法:
/**
* Returns the element at the specified position in this list.
* @throws IndexOutOfBoundsException if the index is out of range
* ({@code index < 0 || index >= size()}).
*/
public E get(int index) {
ListIterator<E> i = listIterator(index);
try {
return i.next();//Iterator的next会抛出NoSuchElementException运行时异常
} catch(NoSuchElementException e) {
/*
* 但接口规范是要求抛出IndexOutOfBoundsException异常,所以需要转换。当然这种
* 转换也是合理的,因为该方法的功能特性就是按索引来取元素,在索引越界的情况
* 下抛出NoSuchElementException也是没有太大的问题的(当然劈开规范来说的),但
* 抛IndexOutOfBoundsException异常会更适合一些
*/
throw new IndexOutOfBoundsException("Index: " + index);
}
}
另一种异常转换的形式是异常链,如果底层的异常对于高层调试有很大帮助时,使用异常链就非常合适,这样在高层我们可以通过相应的方法来获取底层抛出的异常:
// 异常链
try {
... // 调用底层方法
} catch (LowerLevelException cause) {
// 构造异常链后重新抛出
throw new HigherLevelException(cause);
}
尽管异常转换与不加选择地将捕获到的底层异常传播到高层中去相比有所改进,但是它不能滥用。处理来自底层异常的首选做法是根本就让底层抛出异常,在调用底层方法前确保它会成功,从而来避免抛出异常,另外,我们有时也可以在调用底层方法前,在高层检查一下参数的有效性,从而也可以避免异常的发生,当然这种做法(不要抛出底层异常的做法)只是对底层抛出的是运行时异常时才可行。如果确实无法避免(如低层抛出的是受检异常或是运行时异常但根本无法阻止)低层异常时,次选方案是让高层绕开这些异常,并将异常使用日志记录器记录下来供事后调试。
总之,处理底层异常最好的方法首选是阻止底层异常的发生,如果不能阻止或者处理底层异常时,一般的做法是使用异常转换(包括异常链转换),除非底层方法碰巧可以保证抛出的异常对高层也合适才可以将底层异常直接从底层传播到高层。异常链对高层和低层异常都提供了最佳的功能:它允许抛出适当的高层异常的同时,又能捕获底层的原因进行失败分析。
62、 每个方法抛出的异常都要有文档描述
如果一个方法可能抛出多个异常类,则不要使用“快捷方式”声明它会抛出这此异常类的某个超类。永远不要声明一个方法“throws Exception”,或者更糟的是声明“throws Throwable”,这是极端的例子,因为它掩盖了该方法可能抛出的其他异常。
对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。每个方法的文档应该描述它的前提条件,这是很重要的,在文档中描述出未受检的异常是满中前提条件的最佳做法。
对于掊中的方法,在文档中描述出它可能抛出的未受检异常显得尤其重要。这份文档成了该接口的通用约定的一部分,它指定了该接口的多个实现必须遵循的公共行为。
未受检异常也要在@throws标签中进行描述。
应该注意的是,为每个方法可能抛出的所有未受检异常建立文档是一种很理想的好的想法,但在实践中并非总能做到这一点。比如在后面的版本中如果修改代码,有可能抛出另外一个未受检异常,这不算违反源代码或者二进制兼容性。
如果某类所有方法抛出同一个异常,那么这个异常的文档可以描述在类文档中。
总之,要为你编写的每个方法所能摆好出的每个异常建立文档,对于未受检和受检异常,以及对于抽象的和具体的方法也都一样。
63、 异常信息中要包含足够详细的异常细节消息
异常的细节消息对异常捕获者非常有用,对异常的诊断是非常有帮助的。
为了捕获失败,异常的细节消息应该包含所有“对该异常有作用”的参数和域值。例如IndexOutOfBoundsException异常的细节消息应该包含下界、上界以及没有落在界内的下标值,因为这三个值都有可能引起这个异常。
异常的细节消息不应该与“用户层次的错误消息”混为一谈,后都对于最终用户而言必须是可理解的。与用户层次的错误消息不同,异常的详细消息主要是让程序员用来分析失败原因的。因此,异常细节消息的内容比可理解性重要得多。
为了确保在异常的细节消息中包含足够的能捕获失败的信息,一种办法是在异常的构造器而不是字符串细节消息中引入这些信息。然后,有了这些信息,只要把它们放到消息描述中,就可以自动产生细节消息。例如,IndexOutOfBoundsException本应该这样设计的:
/**
* Construct an IndexOutOfBoundsException.
*
* @param lowerBound the lowest legal index value.
* @param upperBound the highest legal index value plus one.
* @param index the actual index value.
*/
public IndexOutOfBoundsException(int lowerBound, int upperBound,int index) {
// 构建详细的捕获消息
super("Lower bound: " + lowerBound +
", Upper bound: " + upperBound +
", Index: " + index);
// 存储失败的细节消息供程序访问
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
但遗憾的是,Java平台类库并没有使用这种做法,但是,这种做法仍然值得大力推荐。
正如第58条中所建议的,为异常的“失败捕获”信息提供一些访问方法是合适的(如在上述例子中的访问lowerBound、upperBound和index域的方法,注上面已省),提供这样的访问方法对于受检的异常,比对于未受检的异常更为重要,因为失败——捕获信息对于从失败中恢复是非常有用的。程序员访问未受检查异常的细节是很少见,然而,即使对于未受检的异常,提供这些访问方法也是明智的。
64、 努力使失败保持原子性
当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败是发生在执行某个操作的过程中间。对于受检异常而言,这尤其重要,因为调用者希望能从这种异常中进行恢复(即继续调用发生异常对象的方法)。一般而言,失败的方法调用应该使对象保持在它被调用之前的状态。具有这种属性的方法被称为具有失败原子性。
有几种途径可以实现这种效果。最简单的办法莫过于设计一个不可变的对象。如果对象是不可变的,失败原子性就是显然的。如果一个操作失败了,它可能会阻止创建新的对象,但是永远也不会使用已有的对象保持在不一致的状态中,因为当每个对象被创建之后它就处于一致的状态中,以后不会再发生变化。
对于在可变对象上执行操作的方法,获得失败原子性最常见的办法是,在执行操作之前检查参数的有效性,这可以使得在对象的状态被修改前,先抛出适当的异常。例如,考虑第6条中的Stack.pop方法:
public Object pop() {
if (size == 0)//先对状态进行有效性检测
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
如果取消对初始大小(size)的检查,当这个方法企图从一个空栈中弹出元素时,它仍然会抛出异常。然而,这会导致size域保持在不一致的状态(负数)之中,从而导致将来对该对象的任何方法调用都会失败。
一种类似的获得失败原子性的办法是,调整计算处理的过程的顺序,使得任何可能失败的计算部分都在对象状态被修改之前执行。如果对参数的检查只有在执行了部分计算之后(这些提前执行的计算与后面需检查的参数要无关者可以啊!)才能进行,这种办法实际上就是上一种办法的自然扩展。例如放入TreeMap中的元素都要具有比较能力,在放入元素时,put方法是先进行了比较方法compare()的调用(如果放的元素不具有比较能力时会抛出ClassCastException),然后才是修改对象内部状态如size的自增与红黑树的结构调整。
第三种获得失败原子性的办法远远没有那么常用,做法是编写一段恢复代码,由它来拦截操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。
最后一种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内部。如果数据保存在临时的数据结构中,计算过程会更加快的话,使用这种办法就更加可行了。例如,Collections.sort在执行排序之前,首先把它的输入列表转换到一个数组中,以便降低在排序的循环内访问元素所需要的开销,这是出于性能考虑,但是,它增加了一项优势:即使排序失败,它也能保证输入列表保持原样。
虽然一般情况下都希望实现失败原子性,但并非总是可以做到的。例如,如果两个线程企图在没有同步机制下,并发地修改了同一个对象,这个对象就有可能处于不一致的状态中。因此,在捕获了ConcurrentModificationException异常之后再假设对象仍然是可用的,这就不正确了,此时不需要努力保持失败原子性了。
即使在可以实现失败原子性场合,也并不是人们所希望的,因为某些操作,会增加开销或者复杂性,实现失败原子性应该往往是轻松自如的。
65、 不要忽略异常
请不要这样做:
// 忽略异常块,强烈反对
try {
...
} catch (SomeException e) {
}
空的catch块会使异常达不到应用的目的。忽略异常就如同火警信号器关掉了。至少,catch块也应该包含一条说明,解释为什么可以忽略这个异常。
有一种情况可以忽略异常,即关闭FileInputStream的时候,因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息了,因此不必终止正在进行的操作,即使在这种情况下,也得要把异常记录下来。
本条目中的建议同样适用于受检异常和未受检异常。
第十章 并发
66、 同步访问共享的可变数据
许多程序员把同步的概念仅仅理解为一个种互斥的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象的内部不一致的状态。正确地使用同步可以保证其他任何方法都不会看到对象处于不一致的状态中。这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态中(即原子性),它还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改结果(即可见性)。
我的理解,同步 = 原子性 + 可见性
synchronized就是同步的代名词,它具有原子性与可见性。而volatile只具有可见性,但不具有原子性。可见性其实说的就是在读之前与写之后都与主内同步,除了可见性外,volatile还严禁语义重排:“禁止reorder任意两个volatile字段或者volatile变量,并且同时严格限制(尽管没有禁止)reorder volatile字段(或变量)周围的非volatile字段(或变量)。”
Java语言规范保证或写是一个变量是原子的(即数据的读写是不可分割的。注,不可分割的操作并不意味“多线程安全”),除非这个变量的类型为long或double[JLS 17.4.7]。换句话说,读取一个非long或double类型的变量,可以保证返回值是某个线程完整保存在该变量中的值(即要么读取还没有修改的值,要么读取到某线程修改完后的值,但决不会读到另一线程对变量的一半或一部分修改后的值,如一个int型变量,某线修改该变量的前16位后,被另一线程读到,这是不可能的;而long或double类型的变量就完全有可能这样,读到的是另一线程写入的高32位,而低32位还是原来值),即使用多个线程在没有同步的情况下并发地修改这个变量也是如此。
你可能听说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值(严格的说是完整的值,即不会读取还未修改完成的值),但是它并不保证一个线程写入的值对于另一个线程将是可见的(即另一线程修改完后,其他线程有可能将永远读不到这个修改后的值)。为了在线程之间进行可靠的通信(需要靠可见性来保证),也为了互斥访问(需要原子性来保证),同步(需要可见性和原子性来保证)是必要的。这归因于Java语言规范中内存模型,它规定了一个线程所做的变化何时以及如何让其他线程可见[JLS 17]。
如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。考虑下面这个阻止一个线程妨碍另一个线程的任务。由于boolean域的读和写操作都是原子的,程序员在访问这个域的时候不再使用同步,这是错误的做法:
import java.util.concurrent.TimeUnit;
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested)
i++;
}
});
backgroundThread.start();
//睡一秒
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
你可能期待这个程序运行大约一秒钟之后,主线程将stopRequested设置为true,致使后台线程的循环终止。但是在我的机子上,这个程序永远不会终止:因为后台线和永远在循环中!
问题在于,由于没有同步,就不能保证后台线程何时“看到”主线程对stopRequested的值所做的修改。在没有同步的情况下,VM将个这个代码:
while (!stopRequested)
i++;
转变成这样:
if (!stopRequested)
while (true)
i++;
这是完全有可能的,也是可以接受的。这种优化称作提升(hoisting),正是HopSpot Server VM的工作。结果是个“活性失败”:这个程序无法结束。修改这个问题的一种方式是同步访问stopRequested域,修改如下:
public class StopThread {
private static boolean stopRequested;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested())
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
注意上面的写方法(requestStop)和读方法(stopRequested)都被同步了,只同步写方法或读方法是不够的!
StopThread程序中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果(即可见性),而不是为了互斥访问(即原子性)。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的替代方法,它更加简洁,性能也可能更好。这种替代就是将stopRequested声明为volatile,第二版本的StopThread中的锁就可以省略。虽然volatile修饰符不具有互斥访问的特性,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被其他线程写入的值,下面是使用volatile修正后的版本:
public class StopThread {
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested)
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
上面就说了,volatile只具有可见性,而不具有原子性,所以使用时要格外小心,请考虑下面的方法,假设它要产生序列号:
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
这个方法的目的是要确保每次调用都要返回不同的值,而且是递增的(只要不超过2^32次调用)。这个方法的状态只包含一个可原子访问的域:nextSerialNumber,不同步的情况下读到的这个域的所有可能的值都是合法(即不可能读到修改未完成的值),但是,这个方法仍然无法工作。
问题在于,增量操作(++)不是原子的。它在nextSerialNumber域中执行两项操作:首先它读取值,然后写回一个新值,相当于原来的值再加上1。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是“安全性失败”:这个程序会计算出错误的结果。
修正generateSerialNumber方法的一种方法是是在它的声明中加上synchronized修饰符。这样可能确保多个调用不会交叉存在。一旦这么做,就可以且应该从nextSerialNumber中删除volatile修饰符。为了让这个方法更可靠,要用long代替int。但最好还是遵循第47条中的建议,使用类AtomicLong,它是java.util.concurrent.atomic的一部分,它比同步版本的generateSerialNumber性能上可能要更好,因为atomic包使用了非锁定的线程安全技术来做到同步的,下面是使用AtomicLong修正后的版本:
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
避免本条目中所讨论到的问题的最佳办法是不共享可变的数据,要么共享不可变的数据(见第15条),要么压根不共享。
让一个线程在我短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作为事实上不可变的。将这种对象引用从一个线程传递到其他的线程被称作安全发布。安全发布对象引用有许多种方法:可以将它保存在静态域中,作为类初始化的一部分;可以将它保存在volatile域、final域或者通过正常锁定访问域中;或者可以将它放到并发集合中。下面是针对安全发布的例子“将 volatile 变量用于一次性安全发布”,来自XXXX:
模式 #2:一次性安全发布(one-time safe publication)
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原语值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
实现安全发布对象的一种技术就是将对象引用定义为 volatile 类型。清单 3 展示了一个示例,其中后台线程在启动阶段从数据库加载一些数据。其他代码在能够利用这些数据时,在使用之前将检查这些数据是否曾经发布过。
清单 3. 将 volatile 变量用于一次性安全发布
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;
public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}
public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}
如果 theFlooble 引用不是 volatile 类型,doWork() 中的代码在解除对 theFlooble 的引用时,将会得到一个不完全构造的 Flooble。
该模式的一个必要条件是:被发布的对象必须是线程安全的,或者是有效的不可变对象(有效不可变意味着对象的状态在发布之后永远不会被修改)。volatile 类型的引用可以确保对象的发布形式的可见性,但是如果对象的状态在发布后将发生更改,那么就需要额外的同步。
总之,当多个线程共享可变数据的时候,每个读或写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的“活性失败”和“安全性失败”。如果只是需要线程之间的交互通信,而不需要互斥,volatile修饰就是一种可以接受的同步形式。
>>>《Practical Java》线程拾遗<<<
如果synchronized函数抛也异常,则在异常离开这个函数前,锁会被自动释放。
不允许你将构造函数声明为synchronized,否则编译出错。原因是当两个线程并发调用同一个构造函数时,它们各自操控的是同一个class的两个不同实体对象的内存,所以没有必要。但是,如果构造器中要访问竞争共享资源的代码时,需要使用同步块来访问临界资源。
synchronized修饰的非静态函数时,锁对象为this;修饰静态函数时,锁对象为当前对象的Class对象。
需要同步的资源一定要声明成private的,不然外界直接可以访问这个临界资源了。
notifyAll和notify一样,不能指定以何种顺序通知线程。唤醒线程由JVM决定,除了保证所有等待中的线程都被唤醒之外,不做任何其他保证,线程未必以优先权顺序来接获通知。
使用wait和notifyAll线程通信机制替换轮询循环,避免不必要的性能损耗。
不要对locked object(上锁对象)的object reference重新赋值,否则会破坏同步。
不要调用stop或suspend。stop的本意是用来中止一个线程,中止线程的问题根源不在object locks,而在object的状态,当stop中止一个线程时,会释放线程持有的所有locks,但是你并不知道当时代码正在做些什么,所以会造成object处于无效状态;suspend本意是用来“暂时悬挂起一个线程”,但不安全,因为容易引起死锁,与sleep一样的是阻塞时不释放锁,但与sleep不同的是sleep是在等待一段时间后会自动唤醒,而suspend后一定需要另一线程通过调用该线程的resume方法来恢复,但此时如果调用resume方法的线程需要suspend所拥有的锁时,就会产生死锁,而sleep则安全多了,它在阻塞只在指定的时间之内,时间一到它就会恢复运行,不易引起死锁;destroy该方法最初用于破坏该线程,与suspend一样也不会释放锁,不过,该方法决不会被实现,即使要实现,它也极有可能与 suspend 一样产生死锁。
死锁实例:
class TestDeathLock {//死锁例子
static void deathLock(Object lock1, Object lock2) {
try {
synchronized (lock1) {
Thread.sleep(10);
synchronized (lock2) {
System.out.println(Thread.currentThread());
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
final Object lock1 = new Object();
final Object lock2 = new Object();
new Thread() {
public void run() {
deathLock(lock1, lock2);
}
}.start();
new Thread() {
public void run() {
// 注意,这里在交换了一下位置
deathLock(lock2, lock1);
}
}.start();
}
}
67、 避免过多同步
与第66相反。过多同步可能会导致性能降低、死锁,甚至不确定的行为。
为了安全性与正确性,在一个被同步的方法或者代码块中,永远不要放弃对象客户端的控制。换句话说,在一个被同步的区域内部,不要调用自己类中可被重写的方法,或者是由客户端以函数对象(如策略接口或回调接口)的形式提供的方法(见第21条)。从包含该同步区域的类的角度来看,这样的方法是外来的,这个类不知道这样的方法会做什么事,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据正确性。
下面是一个可被观察的集合,为了简单起见,在从集合中删除元素时(remove())没有提供通知方法,只提供了在调用添加add()时才通知所有观察者,这个可被观察的集合类ObservableSet是在第16条中可重用的ForwardingSet上实现的:
public class ObservableSet<E> extends ForwardingSet<E> {
public ObservableSet(Set<E> set) {
super(set);
}
private final List<SetObserver<E>> observers = new ArrayList<SetObserver<E>>();
public void addObserver(SetObserver<E> observer) {
synchronized (observers) {
observers.add(observer);
}
}
public boolean removeObserver(SetObserver<E> observer) {
synchronized (observers) {
return observers.remove(observer);
}
}
// This method is the culprit
private void notifyElementAdded(E element) {
synchronized (observers) {
for (SetObserver<E> observer : observers)
//这里就是在调用外来方法,由客户端提供实例,这里只是调用了回调接口而已
observer.added(this, element);
}
}
@Override
public boolean add(E element) {
boolean added = super.add(element);
if (added)
notifyElementAdded(element);
return added;
}
@Override
public boolean addAll(Collection<? extends E> c) {
boolean result = false;
for (E element : c)
result |= add(element); // calls notifyElementAdded
return result;
}
}
观察者接口:
// 集合观察者回调接口
public interface SetObserver<E> {
// 当一个元素添加到ObservableSet时调用
void added(ObservableSet<E> set, E element);
}
第一次使用下面测试类来进行测试:
public class Test1 {
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<Integer>(
new HashSet<Integer>());
set.addObserver(new SetObserver<Integer>() {// 观察者注册
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23)
s.removeObserver(this);//注销
}
});
for (int i = 0; i < 100; i++)
set.add(i);
}
}
上面运行时输出到23后,ObservableSet的notifyElementAdded方法的for循环抛出了ConcurrentModificationException异常,因为notifyElementAdded在使用Iterator遍历集合的过程中,另一个方法added删除元素23,改变了observers的结构,所以当它准备遍历第元素时24就抛出了异常,这正是因为违反了在使用代替遍历集合时,不能通过集合本身去修改其结构的约束所致。
第二次使用以下类来进行测试:
public class Test2 {
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<Integer>(
new HashSet<Integer>());
// Observer that uses a background thread needlessly
set.addObserver(new SetObserver<Integer>() {
public void added(final ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23) {
ExecutorService executor = Executors
.newSingleThreadExecutor();
final SetObserver<Integer> observer = this;
try {
executor.submit(new Runnable() {
public void run() {
s.removeObserver(observer);
}
}).get();//等待removeObserver方法调用完成
} catch (ExecutionException ex) {
throw new AssertionError(ex.getCause());
} catch (InterruptedException ex) {
throw new AssertionError(ex.getCause());
} finally {
executor.shutdown();
}
}
}
});
for (int i = 0; i < 100; i++)
set.add(i);
}
}
运行时发生死锁。后台线程调用s.removeObserver,它企图锁定observers,但它无法获得该锁,因为主线程已经先锁定了。在这期间,主线程又一直等待后台线程来完成对观察都的删除,这正是造成死锁的原因。
ObservableSet中的同步根本就没有起到作用,相反还造成了上面的死锁。通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。第一种解决办法是对于notifyElementAdded方法,给observers列表拍张“快照”,然后没有锁也可以安全地遍历这个列表了,经过这样的修改,前两个例子运行起来不会出异常或死锁了:
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot = null;
synchronized (observers) {
snapshot = new ArrayList<SetObserver<E>>(observers);
}
for (SetObserver<E> observer : snapshot)
observer.added(this, element);
}
第二种解决办法是使用1.5中的并发集合类,见第69条,这里使用CopyOnWriteArrayList来代替ArrayList,每次add与remove、set时都会重新拷贝整个底层数组,由于内部数组永远没有改动,即没有共享,所以不需要锁定:
private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<SetObserver<E>>();
public void addObserver(SetObserver<E> observer) {
observers.add(observer);
}
public boolean removeObserver(SetObserver<E> observer) {
return observers.remove(observer);
}
private void notifyElementAdded(E element) {
for (SetObserver<E> observer : observers)
observer.added(this, element);
}
在同步区外调用外来方法被称作为“开放调用”,除了可以避免死锁之外,开放调用还可以极大地增加并发性。外来方法的运行时间可能是任意长,如果在同步区域内调用外来方法,其他线程对受保护资源的访问就会阻塞。
通常,你应该在同步区域内做尽可能少的工作。如果你必须要执行某个很耗时的动作,则应该设法将它移到同步区外,但不能违背第66条的指导方针。
上面是讨论正确性,下面讨论一下性能。虽然自从Java平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过多同步。在这个多核时代,过多同步的实际成本并不是指获取锁所花费的CPU时间,而是指失去了并行的机会。另外潜在的开销在于,它会限制VM优化代码执行的能力。
要在一个类的内部进行同步,一个很好的理由是因为它将被大量地并发使用,而且通过执行内部细粒度的同步操作你可以获得很高的并发性。
如果一个可变的类要在并发环境中使用,应该使这个类变成线程安全的(见70)。如果经常用在并发环境中,通过内部同步,你可以获得明显比从外部锁整个对象更高的并发性(在外部同步锁的粒度粗,最细也只能到方法级别,而在内同步可以缩小同步的范围,只在需要的代码行进行同步,而不是整个方法。粗粒度锁时间长,而细粒度锁时间短,所以并发性高)。否则,如果很少在并发环境中,就不要在内部同步,让客户在必要的时候(需要并发的时候)从外部同步。在Java平台出现的早期,许多类都违背了这些指导方针,例如,StringBufer实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此,StringBuffer基本上都都StringBuilder代替,它在Java1.5版本中是个非同步的StringBuffer。
如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如拆分锁、分离锁和非阻塞并发控制。
如果方法修改了静态域,那么你也必须同步对这个域的访问,即使这个方法通常只用于单个线程。客户要在这种方法上执行外部同步是不可能的,因为不可能保证其他不相关的客户也会执行外部同步。第66条中的generateSerialNumber方法就是这样的一个例子。(注,这段一直没有理解)
总之,为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更为一般地讲,要尽量限制同步域内部的工作量。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。
68、 task(工作单元)和executor(执行机制)优先于线程(工作单元 + 执行机制)
本书第1版49条中阐述了简单的工作队列,下面是实例代码:
//工作队列
public abstract class WorkQueue {
private final List queue = new LinkedList();//队列
private boolean stopped = false;
protected WorkQueue() {
//启动工作队列后台处理线程
new WorkerThread().start();
}
// 入队
public final void enqueue(Object workItem) {
synchronized (queue) {
queue.add(workItem);
queue.notify();
}
}
//停止工作队列
public final void stop() {
synchronized (queue) {
stopped = true;
queue.notify();
}
}
//工作队列中元素的抽象处理方法
protected abstract void processItem(Object workItem) throws InterruptedException;
// 后台工作线程
private class WorkerThread extends Thread {
public void run() {
while (true) { // Main loop
Object workItem = null;
synchronized (queue) {
try {
while (queue.isEmpty() && !stopped)
queue.wait();
} catch (InterruptedException e) {
return;
}
if (stopped)
return;
workItem = queue.remove(0);
}
try {
//调用外来方法,一定要入在同步块的外面调用,原因见第67条
processItem(workItem); // No lock held
} catch (InterruptedException e) {
return;
}
}
}
}
}
//工作队列测试
class DisplayQueue extends WorkQueue {
//元素处理方法实现 每秒处理一个元素
protected void processItem(Object workItem) throws InterruptedException {
System.out.println(workItem);
Thread.sleep(1000);
}
public static void main(String[] args) throws InterruptedException {
WorkQueue queue = new DisplayQueue();
for (int i = 0; i < 10; i++)
queue.enqueue(new Integer(i));
// 等待所有元素处理完后再停止队列
Thread.sleep(11 * 1000);
queue.stop();//停止工作队列后台处理线程
}
}
这个类允许客户将后台线程异步处理的工作项目加入队列。当不再需要这个工作队列时,客户端可以调用一个方法,让后台线程在完成了已经在队列中的所有工作之后,优雅地终止自己。但这个类容易出现安全问题或准确性。幸运的是,你再也不需要编写这样的代码了。
在java.15中,增加了java.util.concurrent。这个包中包含了一个Executor Framework,这是一个很灵活的基于接口的任务执行工具。它创建了一个在各方面都本书第一版更好的工作队列,却只需要这一行代码:
ExecutorService executor = Executors.newSingleThreadExecutor();
下面是为执行提交一个Runnable的方法:
executor.execute(runnable);
下面是告诉executor如果优雅地终止(如果不这样,VM可能不会退出):
executor.shutdown();
你可以利用executor service完成更多的事情,如可以等待一特殊的任务(如第67条中的SetObserver),你也可以等待一个任务集合中的任何任务或者所有任务完成(利用invokeAny或者invokeAll方法),你也可以等待excecutor service优雅地完成终止(利用awaitTermination方法),你还可以在任务完成时逐个地获取这些任务的结果(利用ExecutorCompletionService),等等。
如果想让不止一个线程来处理来自这个队列的请求,只要调用一个同的静态工厂,就可创建不同的executor service,即线程池,池中的数量可以固定也可变化。
当然选择executor service是很的技巧的。如果是小程序,或者是轻载的服务器,使用Executors.newCachedThreadPool通常不错,因为它不需要配置,并且一般也能完成工作。但对于大负载的服务器来说,缓存的线程池就不是好了,因为在缓存的线程池中,被提交的任务没有排队,而是直接交给线程执行,如果服务器负载很重,会导致吞吐率下降,创建更多的线程。因此,在大负载的产品中,最好使用executors.newFixedThreadPool,它为你提供了一个包含固定线程数目的线程池。然而,如果你想更灵活,可以直接使用ThreadPoolExecutor类,这个类允许你控制线程池的几乎每个方面。
你不仅应该尽量不要编写自己的工作队列,而且应该尽量不直接使用线程,现在关键的抽象不再是Thread了,它以前可是即充当工作单元,又是执行机制。现在工作单元和执行机制是分开的。现在关键的抽象是工作单元,称作任务(task)。任务有两种:Runnable及其近亲Callable(与Runnable相似,但它会返回值)。执行任务的通用机制是executor service。如果你从任务的角度来看问题,并让一个executor service替你执行任务,在选择适当的执行策略方面就获得很的灵活性,从本质上讲,Excecutor Famework所做的工作是执行。
Executor Framework也有一个可替代java.util.Timer的东西,即ScheduledThreadPoolExecutor。虽然timer使用起来容易,但被调度的线程池executor更加灵活。timer只用一个线程来执行任务,这在面对长期运行的任务时,会影响到定时的准确性。如果timer唯一的线程抛出未被捕获的异常,timer就会终止。但被调度的线程池executor支持多个线程,并且能从抛出未受检异常的任务中恢复。
69、 并发工具优先于wait和notify
回顾第一版:永远不要在循环的外面调用wait-----
总是使用wait循环模式来调用wait方法,永远不要在循环的外面调用wait。循环被用来在等待的前后测试等待条件。下面是使用wait方法的标准模式:
synchronized(obj){
while(<等待条件>){
obj.wait();
}
… // 条件满足后开始处理
}
而不能是这样:
if(<等待条件>){
obj.wait();
}
… // 条件满足后开始处理
为什么这么做,因为当线程醒过来时,等待条件可能还是成立的。通过调用某个对象上的wait后,当线程会进入锁对象的等待池,在被唤醒后不会马上进入就绪状态,而是进入锁对象的锁池,只有再一次获取锁后,才能进入到就绪状态,也有可能就在它再一次获取锁前,等待条件被另一线程改变了,或者是在等待条件还根本还未破坏时另一线程意外或恶意的调用了notify或notifyAll。
notify唤醒一个正在等待的线程(如果这样的线程存在的话),而notifyAll唤醒所有正在等待的线程,通常,你总是应该使用notifyAll。这是合理而保守的建议,它总会产生正确的结果,因为它可以保证你将会唤醒所有需要被唤醒的线程。你可能也会唤醒基本他一些线程,但是这不会影响唾弃的正确性,这些线程醒来之后,会检查它们正在等待的条件,如果发现条件不满足,就会继续等待。
从优化的角度来看,如果处于等待状态的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么你就应该选择调用nofiy,而不是notifyAll。即使这些条件都是真的,还是有理由使用notifyAll而不是notity,就好像把wait调用放在一个循环中,以避免在公有可访问的对象上意外或恶意的通知。与此类似,使用notifyAll代替notify可能避免来自不相关线程的意外或恶意的等等,否则的话,这样的等待会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。在第68条中WorkQueue例子中没有使用notifyAll的原因是,因为辅助线程WorkerThread在一个私有的对象queue上等待,所以这里不存在意外或者恶意地等待的危险。
关于使用notifyAll优先于notify的有一个告诫:虽然使用notifyAll不会影响正确性,但会影响性能,特别在是等待线程很多的情况下,因为所有被唤醒(唤醒后只是进行锁池状态)后的线程有可能因为等待条件被破坏而再次进入阻塞状态(等待池)(中间经过了从等待池状态中唤醒—>进入锁池状态—>获取锁—>进行等待池—>释放锁几个动作,所以很消耗性能,另外,即使没有获取到锁的,也会因引起大量线程竞争锁而影响性能),这会导致大量的上下文切换。但如果唤醒后等待条件不再被破坏的情况下是没有问题。所以本人认为在线程数量小或只有一个线程的情况下,可以使用notifyAll,因为这样即带来了可靠性,但又不太引响性能;或者是在线程数量大的情况,而一旦唤醒后等待条件不再被其他线程“很快”破坏或者根本就不可能被破坏时,也还是可以使用notifyAll的,因为此时的唤醒工作不会白作。
end-----
上面的这些仍然有效,但这些建议现在远远没有之前那么重要了,因为几乎没有理由再使用wait和notify,1.5中有更高级的并发工具来实现这些。
java.util.concurrent中更高级的工具分成三类:Executor Framework、并发集合以及同步器, Executor Framework已在第68条中简单的提到过。
并发集合为的集合接口(如List、Queue和Map)提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步,困此,并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使程序速度变慢。
上面提到“并发集合中不可能排除并发活动”,是说客户无法原子地对并发集合进行方法调用(如先调用它的get方法判断一个元素是否存在,如果不存在,再通地put放入不存在的元素就会有并发问题,因为可以在get后,切换到另一线程,然后再切换回来调用put),并发集合中的方法只是单个方法是原子性的,如果调用多个方法(如get与put)要求是原子的则也需要额外的同步才行,不过这些集合已经将些多个方法的调用扩展成了另一个接口,这样我们也就不需要自己同步了。例如ConcurrentMap扩展了Map接口,并添加了几个方法,如putIfAbsent(K key, V value)。
ConcurrentHahsMap除了提供卓越的并发性之外,速度也非常快。除非不得已,否则应该优先使用ConcurrentHahsMap,而不是使用Collections.synchronizedMap或者Hashtable。并发Map比老式的同步Map性能高,所以应该优先使用并发集合,而不是使用外部同步的集合。
同步器是一些使线程能够等待另一个线程的对象,允许它们协调动作,最常用的同步器是CountDownLatch和Semaphore。较不常用的是CyclicBarrier和Exchanger。
总这,直接使用wait和notify就像用“并发汇编语言”进行编程一样,而java.util.concurrent则提供了更高级语言。没有理由在新的代码中使用wait或notify,即使有,也是极少。如果你在维护使用wait和notify的代码时,务必确保始终是利用标准模式从while循环内部调用wait。一般情况下,应该使用notifyAll,而不是notify。如果使用notify,请小心,确保程序的活性。
70、 线程安全性的文档化
如果你没有在一个类的文档里描述并发的情况,使用这个类的程序员将可能缺少同步和过多同步。
一个类为了可被多个线程安全可用,必须在文档中清楚地说明它所支持的线程安全性级别,下面是些常见的安全级别:
1、 不可变类——这个类的实例是不可变的,所以,不需要外部的同步,如String、Long和BigInteger。
2、 无条件的线程安全——这个类的实例是可变的,但是这个类有着足够的内部同步,所以,它的实例可以被并发使用,无需任何外部同步。如,Random和ConcurrentHashMap。
3、 有条件的线程安全——除了有些方法为进行安全的并发使用而需要外部同步外,这种线程安全级别与无条件的线程安全相同。如Collections.synchronized包装返回的集合,它们的迭代器(iterator)要求同步,否则在迭代期间被其他线程所修改。下面是源码:
public Iterator<E> iterator() {
return c.iterator(); // Must be manually synched by user!
}
4、 非线程安全——这个类的实例是可变的。为了并发地使用它们,客户必须利用自己选择的外部同步包围每个方法调用(或者调用序列)。如能用的集合实现ArrayList、HashMap。
5、 线程对立的——这个类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是因为没有考虑到并发性而产生后果。幸运的是,在Java平台类库中,这样的类很少,System.runFinalizersOnExit()是这样的,但已废除了。
上面这里只是粗略的分类,详细参见《Java Concurrency Practice》一书中的线程安全注解。
尽量别使用公有对象来作为锁对象,因为这样外界可能意外或者故意的霸占锁,造成拒绝服务,所以这该使用私有锁对象来代替同步方法(非静态的同步方法的锁就是this,但这个对象在外面可以访问到,所以避免使用):
private final Object lock = new Object();//注意最好声明成final的
public void foo(){
synchronized(lock){
…
}
}
这样外界就不能访问到这个锁对象,所以它们不可能妨碍对象的同步。但是,需要重早一下的是,私有锁对象模式只能用在无条件的线程安全类上。有条件的线程安全类不能使用这种模式,因数它们必须在文档中说明:在执行某些方法调用序列时,它们客户端程序必须获取哪把锁。
私有锁对象模式特别适合用于那些专门为继承而设计的类。如果这种类使用它的实例作为锁对象,子类可能很容易在无意中妨碍基类的操作,反之亦然。出于不同的目的而使用相同的锁,子类和基类可能会“相互绊住对方的脚”。
总这,每个类都需要说明线程安全说明,synchronized修饰符并不能说明这个类就是线程安全的。有条件的线程安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。如果你编写的是无条件的线程安全类,就应考虑私有锁对象代替同步方法。
71、 慎用延迟初始化
延迟初始化降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。
如果域只在类的实例部分被访问,并且初始化这个域的开锁很高,可能就值得进行延迟初始化。
大多情况下,正常的初始化要优先于延迟初始化,下面是下常初始化,注意,如果域是不可变的,一定要加上final或者在可变情况下加上volatile,因为这样才能保证实例初始化的完整性:
// Normal initialization of an instance field - Page 282
private final FieldType field1 = computeFieldValue();
下面是延迟初始化模式:
private FieldType field;
synchronized FieldType getField(){
if(field == null){
filed = computeFieldvalue();
}
return field;
}
上面这两种模式也可用在静态域上,只需在前域与方法前添加static,锁对象从this变为.class对象即可。
如果出于性能的考虑需要对静态域使用延迟初始化,可借助于一个Holder类来延迟加载:
prvate static class FieldHolder{
static final FieldType field = computeFieldValue();
}
static FieldType getField(){return FieldHolder.field;}//这里没有使用同步,性能高
现代的VM将在初始化类的时候,将会同步域的访问,也就是说如果类还没有初始化完成,是不能访问这些域的,一旦这个类被初始化,VM将修补代码,以便后续对该域的访问不会导致任何同步。
如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式。这种模式避免了在域被初始化之后访问这个域的开销(因为不必要的同步):
private volatile FieldType field;
FieldType getField() {
FieldType result = field;//第一次从主存中读field
if (result == null) { // First check (no locking) 从工作内中读result
synchronized (this) {
/*
* 这里个人认为result没起多大作用,直接对field进行判断即可,像这样:
* if (field == null)
* field = result = computeFieldValue();
* 因为在这里最多只执行两次,一次就是在初始化时,第二次是初始化完成后
* 第一次访问,但第二次有可能不会发生在这里,所以直接使用上面与下面没
* 有什么很大的区别,result优化效果最多起二次作用,而不上外面的多次
* 访问那么有用
*/
result = field;
if (result == null) // Second check (with locking)
field = result = computeFieldValue();
}
}
return result;//从工作内中读result
}
注意这里的局部变量result,这个变量的作用是确保field在已经被初始化的情况下从主存中只读取一次,而不使用这个局部变量时需要至少两次从主内存中读取,而从工作内存中读取比从主内存中直接读取要快。虽然这不是严格的要求,但是可以提升性能。在我的机器上,上述方法比没用局部变量的方法快了大约25%,对比一下不使用局部变量时双重检测模式:
private volatile FieldType field;
FieldType getField() {
if (field== null) { // 第一次从主存中读field
synchronized (this) {
if (field == null) // 第二次从主存中读field,但只有在初始化完成后第一
// 访问时才有可能执行到这里
field = computeFieldValue();
}
}
return field;//第三次从主存中读field
}
在1.5以前,双重检查模式的功能很不稳定,因为volatile修饰符的语义不够强,难以支持它。但1.5版本中的内存模型解决了这个问题。所以如今可以使用双重检查对实例域进行延迟初始化,但对静态域也可使用双重检查模式,但没有理由这么做,因为使用类的延迟加载方式是更好的选择了。
双重检查模式对于只能产生一个实例是很重要的,但有时候,你可能需要延迟初始化一个可以接受重复初始化的实例域。如果是这情况,可以省去第二次检查,这就是所谓的“单重检查模式”,下面就是这样的例子,注意fileld仍然是volatile:
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null)
field = result = computeFieldValue();
return result;
}
本条目中讨论的所有初始化方法都适用于基本类型的域,以及对象引用域。当双重检查模式或者单重检查模式应用到数值型的基本类型域时,就会用0来检查这个域(数值类型默认值),而不是null。
如果你不在意是否每个线程都新计算域的值,并且域的类型为基本类型,而不是long或者double类型,就可以选择从单重检查模式的域声明中删除volatile修饰符。这种变体叫 racy single-check idiom。它加快了某些架构上的域访问,代价是增加了额外的初始化。
总之,大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性能目标,可以使用相应的延迟初始化方法。对于实例域,使用双重检查模式;对于静态域,则使用类延迟加载。对于可以接受重复初始化的实例域,也可考虑使用单重检查模式。下面是上面完全实例代码:
class FieldType {}
// 各种初始化模式
public class Initialization {
// 正常初始化
private final FieldType field1 = computeFieldValue();
private static FieldType computeFieldValue() {
return new FieldType();
}
// 延迟初始化模式 - 同步访问
private FieldType field2;
synchronized FieldType getField2() {
if (field2 == null)
field2 = computeFieldValue();
return field2;
}
// 对静态域使用类延迟初始化
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
static FieldType getField3() {
return FieldHolder.field;
}
// 对实例域进行双重检测延迟初始化
private volatile FieldType field4;
FieldType getField4() {
FieldType result = field4;
if (result == null) { // First check (no locking)
synchronized (this) {
result = field4;
if (result == null) // Second check (with locking)
field4 = result = computeFieldValue();
}
}
return result;
}
// 单重检查 - 会引起重复初始化
private volatile FieldType field5;
private FieldType getField5() {
FieldType result = field5;
if (result == null)
field5 = result = computeFieldValue();
return result;
}
}
72、 不依赖于线程调度器
任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能是不可移植的。
要编写健壮的、响应良好的、可移植的多线程程序应用程序,最好的办法确保可运行线程的平均数量不明显多于处理器的数量。
保持可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义工作,如果线程没有在做有意义的工作,就不应该运行。
线程不应该一直处于忙等的状态,即反复地检查一个共享对象,以等待某些事情的发生。忙等会极大地增加处理器的负担,降低了同一机器上其他进行可以完成的工作量。如下面这个例子:
public void await() {//忙等,等到为零止
while (true) {
synchronized (this) {
if (count == 0)
return;
}
}
}
Thread.yield的唯一用途是在测试期间人为地增加程序的并发性,可以发现一些隐藏的Bug,这种方法曾经十分奏效,但从来不能保证一定可行。Java语言规范中,Thread.yield根本不做实质性的工作,只是将控制权返回给调用者。所应该使用Thread.sleep(1)代替Thread.yield来进行并发测试,但千万不要使用Thread.sleep(0),它会立即返回。
另外,不要人为地调整线程的优先级,线程优先级是Java平台上最不可移值的特征了。
73、 避免使用线程组
线程组初衷是作为一种安全隔离一些小程序的机制,但是它们从来没有真正履行这个承诺,它们的安全价值已经差到根本不能在Java安全模型的标准工作中提及的地步了。
除了安全性外,它们允许你同时把Thread的某些基本功能应用到一组线程上,但有时已被废弃,剩下的也很少使用了,因为它们有时并不准确。
总这,线程级并没有提供太多有用的功能,而且它们提供的许多的功能都有缺陷的。我们最好把线程组看作是一个不成功的试验,你可以忽略他们,就当不存在一样。如果你正在设计一个类需要处理线程的逻辑组,或许应该使用线程池executor。
第十一章 序列化
74、 谨慎地实现Serializable接口
实现Serializable接口而付出的最大代价是,一旦一个类被发布,就大降低了“改变这个类的实现”的灵活性。如采用默认的序列化方式时(仅实现Serializable),且没有在一个名为serialVersionUID的私有静态final的long域显示地指定该标识号,则如果类改变了,则会导致不兼容。
实现Serializable的第二个代价是,它增加了出现Bug和安全漏洞的可能性。反序列化过程不是调用原有的构造器,所以你很容易忘记要确保:反序列化过程必须也要保证所有“由真正的构造器建立起来约束关系”,并且不允许攻击者访问正在构造过程上的对象的内部信息,依靠默认的反序列化机制,很容易对象的约束关系遭到破坏,以及遭受非法访问(第76条)。
实现Serializable的第三个代价是,随着类发行新的版本,相关的测试负担也增加了。
实现Serializable接口并不是一个很轻松就可以做出的决定。根据经验,如Data和BigInteger这样的的值类应该实现Serializable,大多数的集合类也是应该如此。代表活动实体的类,如线程池,一般不应该实现Serializable。
为了继承而设计的类,应该尽可能少地去实现Serializable接口,用户的接口也应该尽可能少地继承Serializable接口。如果违反了这条规则,扩展这个类或者实现这个接口的程序员就会背上沉重的负担。然后在有些情况下是合适的,如,这个类或接口主要是为了参与到某个框架中,而该框架要求所有参与者都实现Serializable接口,则实现Serializable是有意义的。
对于为继承而设计的不可序列化的类,你应该考虑提供一个无参构造器,因为他的子类可能是可序列化的,一旦子类实现Serializable接口,则在反序列化时会调用调用父类的无参构造器。最好在所有约束关系都已经建立的情况下再创建对象。下面是一个父类不可序列化,而子类可序列化的建议做法:
//不可序列化
public abstract class AbstractFoo {
private int x, y; // 状态
// 枚举字段用于跟踪初始化于哪种状态:NEW-新建,INITIALIZING-正在初始化,INITIALIZED-初始化完成
private enum State {
NEW, INITIALIZING, INITIALIZED
};
/*
* 初始化到哪种状态了
* 注意,init是一个原子引用。在遇到特定的情况下,确保对象的完整性是很重要的。如果没有这样的防范,
* 万一有个线程要在某个实例上调用initialize,而另一个线程又要企图使用这个实例,第二个线程就有
* 可能看到这个实例处于不一致的状态。这种模式利用compareAndSet方法来操作枚举的大孩子引用。
*/
private final AtomicReference<State> init = new AtomicReference<State>(State.NEW);
//该域是第一版中的
// private boolean initialized = false;//第一版
public AbstractFoo(int x, int y) {
initialize(x, y);
}
//此构造和下面的方法让子类的readObject方法来初始化我们的状态
protected AbstractFoo() {//受保护的构造器
}
protected final void initialize(int x, int y) {
//compareAndSet(V expect, V update):如果当前值 == 预期值expect,
//则以原子方式将该值设置为给定的更新值update。如果还未初始化时,将初始状态设为:INITIALIZING
if (!init.compareAndSet(State.NEW, State.INITIALIZING))
//if (initialized)//第一版
throw new IllegalStateException("Already initialized");
this.x = x;
this.y = y;
// 构造完后设置成完成状态
init.set(State.INITIALIZED);
// initialized=true;//第一版
}
//这些方法提供了访问内部状态,因此可以
//通过子类的writeObject方法来手动序列化。
protected final int getX() {
checkInit();
return x;
}
protected final int getY() {
checkInit();
return y;
}
// 所有共有的与保护的实例方法都必须调用
private void checkInit() {
// if(!initialized)//第一版
if (init.get() != State.INITIALIZED)
throw new IllegalStateException("Uninitialized");
}
// 其余的略
}
//继承不可序列化父类,但已自己实现Serializable接口
public class Foo extends AbstractFoo implements Serializable {
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();
// 手工反序列化和初始化父类的状态
int x = s.readInt();
int y = s.readInt();
initialize(x, y);
}
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
// 手工序列化父类的状态
s.writeInt(getX());
s.writeInt(getY());
}
// Constructor does not use the fancy mechanism
public Foo(int x, int y) {
super(x, y);
}
private static final long serialVersionUID = 1856835860954L;
}
内部类不应该实现Serializable接口,它们使用编译器产生的合成域来保存指向外围实例的引用,以保存来自外围作用域的局部变量的值。“这些域如何对应到类定义中”并没有明确的规定,就好像匿名类和局部类的名称一样,它们都是由编译器临时产生的,我们不能引用它们。因此,内部类默认序列化形式是定义不清楚的。然而,静态成员类却可以实现Serializable接口。
总之,实现Serializable接口不是容易的事。实现Serializable接口是个严肃的承诺,必须认真对待。如果类是为了继承使用的,则一定要提供一个默认构造器,以防止子类序列化。
75、 考虑使用自定义的序列化形式
如果没有先认真考虑默认的序列化形式是否合适,则不要贸然接受。一般来讲只有当你自行设计的自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化形式。
理想的序列化应该只包含该对象所表示的逻辑数据。
如果一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。例如,对于下面仅仅表示人名的类,默认的序列化形式就是合理的:
public class Name implements Serializable {
/**
* Last name. Must be non-null.
* @serial
*/
private final String lastName;
/**
* First name. Must be non-null.
* @serial
*/
private final String firstName;
/**
* Middle name, or null if there is none.
* @serial
*/
private final String middleName;
... // Remainder omitted
}
上面的数据全是逻辑内部,又是物理内容,所以适合于默认序列化方式。
即使你确定默认的序列化形式是合适的,通常你还必须提供一个readObject方法以保证约束关系和安全性。对于Name这个类而言,readObject方法必须确保lastName和firstName是非null的,第76与78条详细地讨论这个问题。
注,虽然lastName、firstName和middleInitial域是私有的,但它们依然有相应的文档注释。这是因为,这些私有域定义了一个公有的API,即这个类的序列化形式,并且该公有的API必须建立文档。
下面与Name不同,它是一个极端例子,该类表示了一个字符串列表:
public final class StringList implements Serializable {
private int size = 0;
private Entry head = null;
private static class Entry implements Serializable {
String data;
Entry next;
Entry previous;
}
... // Remainder omitted
}
从逻辑上讲,它表示一个字符序列,但从物理上看,它把字符串序列表示成了双向链表。如果你采用默认的序列化,它会将链表中的所有项都序列化。
当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下缺点:
1、 它使这个类的导出API永远地束缚在该类的内部表示法上。上面例子中私有的StringList.Entry类变成了公有API的一部分。如果将来版本中内部表示法变化了,StringList仍将需接受链表形式的输入,并产生链表的输出。这个类永远也摆脱不掉维护链表项所需要的代码,即使不再使用链作为内部数据结构了,也仍需要这些代码。因为原来已序列化的二进对象在默认恢复过程中与当前版本类不兼容而导致反序列化失败,比如当前版本中少了某个域。
2、 它会消耗过多的空间与时间。上面的链表中的项的next、previouse是链表的实现实节,不用关心链接的物理信息,在恢复时是可以构造这些关系,不需要将它们一起序列化。
3、 它会引起栈溢出。默认的序列化过程要对对象图进行一次递归遍历,如果对象图层次很深的话很易容就会引起栈的溢出。
对于上面的StringList,我们只需要对size,与date逻辑数据进行序列化即可:
public final class StringList implements Serializable {
private transient int size = 0;
private transient Entry head = null;
// 不再需要序列化!
private static class Entry {
String data;
Entry next;
Entry previous;
}
// 将指定的字符串接连到字符串列表中
public final void add(String s) {
// 实现省略
}
/**
* 序列化当前 {@code StringList} 实例.
*
* @serialData 列表数目 (字符串列表所包含的字符串个数) 是导出的
* ({@code int}), 紧接着是所有的元素 (each
* a {@code String}), 并且按照适当的顺序.
*/
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();//默认序列化,将非静态与非transient序列化
s.writeInt(size);
// 将所有元素数据以适当的顺序写出.
for (Entry e = head; e != null; e = e.next)
s.writeObject(e.data);
}
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();
int numElements = s.readInt();
// 读出所有元素并恢复成链
for (int i = 0; i < numElements; i++)
add((String) s.readObject());
}
private static final long serialVersionUID = 93248094385L;
// 其他略
}
如果所有实例域都是瞬时的,从技术上来说可以不调用defaultWriteObject与defaultReadObject两个方法,但不推荐这样做,因为后面可能有新的非瞬时实例时,还能保持前后兼容。
默认序列化对上面StringList是适合,序列化与反序列化StringList实例都会产生原始对象的忠实拷贝,约束关系没有被破坏。但如果对象约束关系依赖于特定于实现的细节,情况不一样了,如HashMap,它会将Key哈希地址转换成哈希表中的对应桶号,如果使用默认的序列化方式,则恢复时出现严重Bug,因为恢复出的对象的地址已发生变化会导致元素新的哈希码发生变化,则真实的桶号也会发生变化,所以恢复出的原哈希表已不适用。所以为了防止约束关系的破坏,HashMap需要手动的序列化,API也正是这样做的。
transient指导原则:如果一些域不需要序列化,即冗余域,则标示为transient;如果域的值依赖于JVM的运行,则标示为transient;在决定将一个域做成transient之前,请一定要确认它是一个逻辑状态;如果你正在使用自定义的序列化形式,大多数或者所有的域都应该标示为transient。
默认序列化方式时,transient的域反序列化时会初始化为默认值:对象null,数值0,布尔false。如果这些值不是你期望的,则需要在readObject方法中重新手动初始化。
无论你是否使用默认的序列化形式,如果在读取对象任何状态的地方使用到了同步,则也必须在对象序列化上强制这种同步。如果你使用的是默认序列化方式,也得要这样做:
private synchronized void writeObject(ObjectOutputStream s)
throws IOException {
s.defaultWriteObject();
}
不管是哪种方式,都要为可序列化的类显示的提供序列版本唯一标示(serialVersionUID),一是可以避免不兼容,二是提升小小的性能。
如果是默认序列化,又没有指定serialVersionUID时,要想兼容,则新类中要手动指定为以前版本中的serialVersionUID值,要起查看以前版本中类的serialVersionUID,请使用serialver命令。
76、 保护性地编写readObject方法
假设你将第39条中的不可变Period日期范围类做成可序列化的,你可能认为使用默认序列化方式较合适,因为Period的物理表示法正好是逻辑数据内容。但这样会有很大的安全隐患。问题在于,readObject方法实际上相当于另一个公有的构造器,如同其他的构造器一样,它也需要注意同样的所有注意事项:一是构造器必须检查其参数的有效性,二是在必要的时候对参数进行保护性拷贝(见39),同样的,readObject方法也需要这样做。如果readObject方法没有做到这两点,对于攻击都来说就很容易破坏这个类的约束条件了。
>>>伪字节流的攻击法<<<
因为readObject方法是字节流作为参数的,因此我们可以伪造这样的对象流后传递给它进行反序列化。假设现在Period类采用默认的序列化方式,下面这个程序将反序列化产生一个Period实例,而它的结束时间比开始时间要早:
public class BogusPeriod {
// 字节流可能不是来自真真的 Period实例,而是原有基础修改过的
private static final byte[] serializedForm = new byte[] { (byte) 0xac,
(byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06, 0x50, 0x65, 0x72,
0x69, 0x6f, 0x64, 0x40, 0x7e, (byte) 0xf8, 0x2b, 0x4f, 0x46,
(byte) 0xc0, (byte) 0xf4, 0x02, 0x00, 0x02, 0x4c, 0x00, 0x03, 0x65,
0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f,
0x75, 0x74, 0x69, 0x6c, 0x2f, 0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c,
0x00, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00,
0x01, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61,
0x2e, 0x75, 0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68,
0x6a, (byte) 0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte) 0xdf, 0x6e,
0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03, 0x77, 0x08,
0x00, 0x00, 0x00, (byte) 0xd5, 0x17, 0x69, 0x22, 0x00, 0x78 };
public static void main(String[] args) throws Exception {
InputStream is = new ByteArrayInputStream(serializedForm);
ObjectInputStream ois = new ObjectInputStream(is);
System.out.println(ois.readObject());
}
}
被用来初始化serializedForm是这样产生的:首先对一个正常的Period实例进行序列化,然后对得到字节流进行手工编辑。你可以在《Java Object Serialization Specification》中查到有关序列化字节流格式的规范信息。如果你运行该程序,会输出:
Sat Jan 02 04:00:00 CST 1999 - Mon Jan 02 04:00:00 CST 1984
只要把Period声明成可序列化的,攻击者就可以伪造这样的流来进行攻击。为了修正这个问题,你可以提供一个readObject方法,该方法首先要调用defaultReadObject,然后检查被反序列化之后的对象的有效性,如果验证不通过,则抛异常,使反序列化失败:
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();//先调用默认恢复
// 再进行状态参数的有效性验证
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
>>>内部私有域盗用攻击法<<<
尽管上面那样修正避免了攻击者创建无效的Period实例,但是,这里仍然隐藏着一个更为微妙的问题。当我们正常序列化完后,然后附加上两个额外的引用,指向Period实例中的两个私有的Date域。攻击者从ObjectInputStream中读取Period实例,然后读取附加在后面的“恶意编制的对象引用”。这些对象引用使用攻击者能够访问到Period对象内部私有Date域所引用的对象。通过改变这些Date实例,来改变Period实例,下面演示了这种攻击:
public class MutablePeriod {
// 一个 period 实例
public final Period period;
// period's 的 start 域, 指向我们不能访问的私有域
public final Date start;
// period's 的 end 域, 指向我们不能访问的私有域
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
// 序列化正确的Period实例
out.writeObject(new Period(new Date(), new Date()));
/*
* 添加两个恶意的引用让它们指向上面Period序列化字节流中的私有域
* 详细做法请参考 "Java Object Serialization Specification,"
* Section 6.4.
*/
byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // Ref #5
bos.write(ref); // The start field
ref[4] = 4; // Ref # 4
bos.write(ref); // The end field
// 在反序列化的过程中,偷取私有域,故能访问到私有域
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
// 下面引用序列化对象中的私有域
start = (Date) in.readObject();//偷取私有的start域
end = (Date) in.readObject();//偷取私有的end域
} catch (Exception e) {
throw new AssertionError(e);
}
}
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
// Let's turn back the clock
pEnd.setYear(78);
System.out.println(p);
}
}
运行得到如下结果:
Mon May 24 13:57:08 CST 2010 - Wed May 24 13:57:08 CST 1978
上面虽然反序列化的过程中没有破坏约束条件,但反序列化完后通过恶意的引用私有内部状态出了问题。这个问题的根源在于Period的readObject方法并没有完成足够的保护性拷贝。因此,对于每个可序列化的不可变类,如果它包含了私有的可变组件,必须对这些组件进行保护性拷贝,下面重新对Period的readObject方法进一步的修正:
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();//先调用默认恢复
// 对可变组件进行保护性拷贝
start = new Date(start.getTime());
end = new Date(end.getTime());
// 进一步检测内部状态参数是否有效
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
注意,保护性拷贝是在有效性检查之前进行的,而且,我们没有使用Date的clone方法来执行保护拷贝,这两个细节对于保护Period不受攻击是必须的(原因请见39)。同时原Period类中的start与end域为final类型是行不通了的,因为如果这样将不能进行拷贝。这是很遗憾的,但这还算相对好的做法,不过我们可以加上volatile关键字加强并发的可见性。经过上面修改后我们再次执行MutablePeriod类,结果正常:
Mon May 24 14:19:26 CST 2010 - Mon May 24 14:19:26 CST 2010
在1.4中,为了阻止恶意的对象引用攻击,同时节省保护性拷贝的开销,在ObjectOutputStream中增加了wirteUnshared和readUnshared方法。但遗憾的是,这些方法都很容易受到复杂的攻击,即本质上与第77条中所述ElvisStealer攻击相似的攻击。所以不要使用这两个方法,虽然它们通常比保护性拷贝更快,但是它们还是不很安全。
readObject其实就相当于公共的构造器,所以对于readObject额外要注意的是,不要在readObject方法中调用可能被覆盖的方法,因为这与在构造函数中调用被覆盖方法是一样错误的。
默认的readObject 方法是否可以被接受,我们只需做一个简单的测试:增加一个公有的构造器,其参数对就于该对象每个非transient的域,并且无论参数的值是什么,都是不进行检查就可以保存到相应域中,如果对就这个做法不赞同,就必须提供一个显示的readObject方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝,另一种方法是使用序列化代理模式(见79条)
77、 对于实例控制,枚举类型优先于readResolve
对于实现了Serializable接口的单实例类,只要反序列化就一定会产生一个不同于现VM中实例的新对象,这是肯定的。
readResolve方法允许你用另一个实例去替代readObject方法创建的实例,如果可序列化类中有readResolve的方法,则在readObject调用之后再调用readResolve方法,然后,readResolve方法返回的对象引用将被返回,取代新建的对象,这样readObject新建的对象不再被引用,立即成为垃圾回收的对象。具体做法如下:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
该方法忽略了被反序列化的对象,只返回该类被初始化时创建的那个特殊的Elvis实例,因此,Elvis实例的序列化形式并不需要包含任何实际的数据(因为真真反序列化得到的实例被readResolve方法给替换成了当前VM中正在运行的原有的单实例,所以单例模式在序列化成字节码流后对反序列化根本没有用,所以不需要将任何域序列化)。单例中的所有的实例域都应该被声明为transient,事实上,如果依赖readResolve进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient的,否则,攻击者们可能使用readResolve方法被运行前,通过一个引用指向反序列化出来的对象,从而系统中会同时存在两个实例。基本原理就是非transient引用域会默认序列化,并且这个域的readResolve方法会在Singleton的readResolve方法前调用。当这个域反序列化时,就可以使用精心制作的该域的“反序列化替代对象”即“盗用者”来代替这个域默认的反序列化过程。以下是具体工作原理:先编写一个“盗用者”类,它既有readResolve方法,又有实例域,实例域指向被序列化的Singleton的实例,在序列化流中,用“盗用者”类的实例代替Singleton的非transient域。现在Singleton序列化流中包含“盗用者”类实例,而“盗用者”类实例则引用Singleton实例。当反序列化时这个“盗用者”类的readResolve会先于Singleton类的readResolve方法运行,因此,当“盗用者”的readResolve方法运行时,它的实例域是可以引用到被部分反序列化的Singleton实例,最后“盗用者”将引用到的Singleton实例赋值给“盗用者”的静态域,供外界引用,最终导致系统同时存在两个实例。具体做法请看下面几个类:
// 有问题的单例 - 有一个非transient域!
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
//!! 非transient域。注,这里安全做法是使用transient修饰
private String[] favoriteSongs = { "Hound Dog",
"Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}
//盗用者,用来代替Elvis类中的非transient域favoriteSongs的
//反序列化过程中的readResolve方法调用
public class ElvisStealer implements Serializable {
static Elvis impersonator;//保存反序列化得到的Singleton实例供外界使用
private Elvis payload;//通过编译字节充让它指向反序列化得到的Singleton实例
private Object readResolve() {
// 将部分反序列化的实例存储到静态域中供外界使用
impersonator = payload;
// 注这里一定要返回数组类型,因为ElvisStealer是用来替代favoriteSongs域的
return new String[] { "A Fool Such as I" };
}
private static final long serialVersionUID = 0;
}
public class ElvisImpersonator {
// 该字节流中潜伏了盗用者
private static final byte[] serializedForm = new byte[] { (byte) 0xac,
(byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45, 0x6c, 0x76,
0x69, 0x73, (byte) 0x84, (byte) 0xe6, (byte) 0x93, 0x33,
(byte) 0xc3, (byte) 0xf4, (byte) 0x8b, 0x32, 0x02, 0x00, 0x01,
0x4c, 0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65,
0x53, 0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61,
0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a,
0x65, 0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45,
0x6c, 0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,
0x4c, 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74,
0x00, 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70,
0x71, 0x00, 0x7e, 0x00, 0x02 };
public static void main(String[] args) throws Exception {
InputStream is = new ByteArrayInputStream(serializedForm);
ObjectInputStream ois = new ObjectInputStream(is);
Elvis elvis = (Elvis) ois.readObject();//返回Elvis.INSTANCE
//但这里返回的反序列化过程中创建的实例
Elvis impersonator = ElvisStealer.impersonator;
//下面会输出不同的结果,因为他们是不同的两个实例
elvis.printFavorites();
impersonator.printFavorites();
}
}
输出结果:
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]
上面只需将favoriteSongs域声明成transient即个解决上面的问题。但最好把这个类做成一个单元素的枚举类型(见第3条)进行修正。自从1.5后使用readResolve就不是个好做法了。下面使用单一的枚举类型来修正:
public enum Elvis {
INSTANCE;
private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
}
总之,应该尽可能地使用类型来实施实例控制的约束条件。如果做不到(如果受控实例在编译时还不知道时,就不适合使用枚举类型),同时又需要一个既可序列化又是实例受控的类,就必须提供一个readResolver方法,并确保该类的怕有实例域都为基本类型或者是transient的。
78、 考虑用序列化代理代替序列化实例
序列化代理模式可以避免直接对可序列化类进行序列化时的一系列问题,如出错和出现安全问题的可能性,因为它导致实例要利用语言之外的机制来创建,而不是用普通的构造器。
这个代理类很简单,首先它是一个静态的成员类,精确地表示外围类的实例逻辑状态。它应该有一个单独的构造器,其参数就是外围类。这个构造器只是从它的参数中复制数据,不需要进行任何一致性检查或者保护性拷贝。下面使用序列化代理类来替代76中的方案:
//可序列化的不可变类
public final class Period implements Serializable {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
public String toString() {
return start + " - " + end;
}
/*
* 外围类的序列化代理类
* 该的为静态私有的会很重要,这新避免了外界伪造这个类实例的序列化字节
* 流,因为外界根本就能不能构造该私有成员类的实例,这样就避免了第76条
* 中的第一个 通过伪造序列化字节流进行攻击的安全问题;但此时好像还是可
* 能通 过76条中的第二种攻击方法来进行攻击,这其实则不然,即使在外界修
* 改了 SerializationProxy里的start与end私有域,但readResolve方法是
* 调用的公有构造器,而公有构造器以是拷贝的方式来构造的,所以外面对私
* 有域的修改不会影响到外围对象,所以序列化代理类同时也避免了76条中的
* 第二 种攻击法;对于单例模式的序列化,也可以避开第77中的问题,因为序
* 列化的不是单例类本身,只是它的序列化代替对象,外界拿手的也只是代理
* 对象,不过此时readResolve返回要是最开始初始化的单例对象,还有就是
* 这个序列化代理类也不需要定义单例的所有域。
*/
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private static final long serialVersionUID = 234098243823485285L;
// 将序列化代理转变回外围类的实例
private Object readResolve() {
// 使用公有的构造器创建实例
return new Period(start, end);
}
}
// writeReplace 方法将使用序列化代理对象代替自己
private Object writeReplace() {
return new SerializationProxy(this);
}
// 不让外界伪造外围类序列化字节流然后直接通常外围类反序列化,
// 如果外界这样做则抛异常
private void readObject(ObjectInputStream stream)
throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
}
上面序列化代理类中的readResolve方法仅仅利用它的公有API创建外围类的一个实例,这正是该模式的魅力之所在。它极大地消除了序列化机制中语言本身之外的特征,因为反序列化实例是利用与任何其他实例相同的构造器、静态工厂和方法而创建的。这样你就不必单独确保被反序列化的实例一定要遵守类的约束条件。如果该类的静态工厂或构造器建立了这些约束条件,并且它的实例方法在维持着这些约束条件,你就可以确信序列化会维护这些约束条件。
序列化代理方法可以阻止伪字节流的攻击与以及内部的盗用攻击,与前面方案(见第76)不同的是,这种方案允许Period的域为final的,为了确保Period类真正不可变是必须的。
总之,每当你发现自己必须在一个不能被客户端扩展的类上编写readObject或者writeObject方法的时候,就应该考虑使用序列化代理模式,对具有约束条件的对象序列化是否不错的选择,但性能不如保护性拷贝(见76)。
转自:http://mywiz.cn/s/20130622180659226/1j6dG431Mkxj2n48s52zeUI5
第二章 创建和销毁对象
1、 考虑用静态工厂方法代替构造器
创建对象方法:一是最常用的公有构造器,二是静态工厂方法。下面是一个Boolean的简单示例:
public static Boolean valueOf(boolean b) {
return (b ? Boolean.TRUE : Boolean.FALSE);
}
l 静态工厂方法与构造器不同的第一大优势在于,它们有名称。
作用不同的公有构造器只能通过参数来区别(因为一个类只有一个带有指定签名的构造器,所以多个构造器只能使用不同的参数列表来区分),如果使用静态的工厂方法,则方法名会很清楚地表达方法的作用。
l 静态工厂方法与构造器不同的第二大优势在于,不必在每次调用它们的时候都创建一个新对象。
不可变类完全可以使用预先构建好的实例,而不必每次使用时都创建一个对象。另外,将构建好的实例缓存起来重复使用,从而避免创建不必要的重复对象。Boolean.valueOf(boolean)方法就使用了这项技术——它从来不创建对象。
l 静态工厂方法与构造器不同的第三大优势在于,它们可以返回原返回类型的任何子类型的对象。
这样我们在选择返回对象的类时就有了更大的灵活性。这种灵活性的一种应用是,API可以返回对象,同时又不会使对象的类变成公有的,比如我们完全可以先定义一个产品接口类,然后采用私有的内部类去实现这个接口,静态工厂方法返回这个类的实例,这样就隐藏了具体的实现。另外,使用静态工厂方时,要求客户端通过接口来引用被返回的对象,而不是通过它的实现类来引用被返回的对象,这是一种良好的编程习惯。
公有静态工厂方法所返回的对象的类不仅可以是private,而且通过静态工厂方法的参数,还可以随着每次的返回不同的类的实例,只要是已声明返回类型的子类型。这样的好处是,可以在以后的版本中删除这个类重新实现也不会影响到已使用的客户。
静态工厂方法返回的对象所属的类,在编写包含该静态工厂方法的类时可能不必存在。这种灵活的静态工厂方法构成了服务提供者框架的基础,例如JDBC API。服务提供者框架是指这样一个系统:多个服务提供者实现一个服务,系统为服务提供者的客户端提供多个实现,并把他们从多个实现中解耦出来。
服务提供者框架有三个重要组件:服务接口(Service Interface),这是提供者实现的;提供者注册API(Provider Registration API),这是系统用来注册实现,让客户端访问它们的;服务访问API(Service Access API),是客户端用来获取服务的实例的方法接口。服务访问API一般允许但是不要求客户端指定某种选择提供者的条件。如果没有这样的规定,API就会返默认实现的一个实例。服务访问API是“灵活的静态工厂”,它构成了服务提供者框架的基础。
服务提供者框架的第四个组件是可选的:服务提供者接口(Service Provider Interface)(即工厂方法模式中的工厂接口),这些提供者负责创建其服务实现的实例。如果没有服务提供者接口,实现就按照类名称注册,并通过反射方式进行实例化。对于JDBC来说,Connection就是它的服务接口,DriverManager.registerDriver是提供者注册API,DriverManager.getConnection是服务访问API,Driver就是服务提供者接口。
下面看看这四个组件的应用:
// 服务接口,就是我们的业务接口。(相当于Connection接口,由Sun提供)
public interface Service {
// ...
}
// 服务提供都接口,即业务工厂接口。(相当于Driver接口,由第三方厂家实现)
public interface Provider {
Service newService();
}
// 服务提供者注册与服务提供者接口(好比DriverManager)
public class Services {
private Services() {}
// 服务名与服务映射,即注册容器
private static final Map providers = new ConcurrentHashMap();
public static final String DEFAULT_PROVIDER_NAME = "def";
// 服务提供者注册API,即注册工厂实现,相当于DriverManager.registerDriver
public static void registerDefaultProvider(Provider p) {
registerProvider(DEFAULT_PROVIDER_NAME, p);
}
public static void registerProvider(String name, Provider p) {
providers.put(name, p);
}
// 服务访问API,向外界提供业务实现,相当于DriverManager.getConnection
public static Service newInstance() {
return newInstance(DEFAULT_PROVIDER_NAME);
}
public static Service newInstance(String name) {
Provider p = (Provider) providers.get(name);
if (p == null) {
throw new IllegalArgumentException(
"NO provider registered with name:" + name);
}
return p.newService();
}
}
静态工厂方法的第四大优势在于,在创建参数化类型实例的时候,它们使代码变得更加简洁。比如要创建一个参数化的HashMap,我们需要如下做:
Map<String,List<String>> m= new HashMap<String, List<String>>();
这么长的类型参数实在是不太好,而且随着类型参数变得越来越长,也越来越复杂。但如果有了静态工厂方法,编译器就可以替你推导出类型,new时不需要提供参数类型。例如,假设HashMap提供了这个静态工厂:
public static <k,v> HashMap<k,v> newInstance(){
return new HashMap<k,v>();
}
那么你就可以使用以下简洁的代码来代替上面这段繁琐的声明:
Map<String,List<String>> m= HashMap.newInstance();
但可惜的是,到现在发行的版本1.6止还未加入,不过我们可以把这些方法放在自己的工具类中。
静态工厂方法的一些惯用名称:
valueOf——不太严格地讲,该方返回的实例与它的参数具有相同的值。这样的静态工厂方法实际上是类型转换方法。
of——valueOf的一种更为简洁的替换,在EnumSet中使用并流行起来。
getInstance——返回的实例是通过方法的参数来描述的,但是不能够说与参数具有同样的值。对于Singleton来说,该方法没有参数,并返回唯一值。
newInstance——像getInstance一样,但newInstance能够确保返回每个实例都与把有其他实例不同。
getType——像getInstance一样,但是在工厂方法处于不同的类中的时候使用。Type表示工厂方法所返回的对象类型。
newType——像newInstance一样,但是在工厂方法处于不同的类中的时候使用。Type表示工厂方法所返回的对象类型。
2、 遇到多个构造器参数时要考虑构造器
如果实例化时需要多个参数时,且这些参数中只有少数几个是必须的,而很多是可选的,这时我们一般考虑使用构造器的方式,而不是使用静态工厂方法。
对于此情况,我们可以使用重叠构造器模式——你提供一个只有必要参数的构造器,第二构造器有一个可选参数,第三个有两个可选参数,依此类推,最后一个构造器包含所有可选参数。
public class NutritionFacts {
private final int servingSize; // 必选参数
private final int servings; // 必选参数
private final int calories; // 可选参数
private final int fat; // 可选参数
private final int sodium; // 可选参数
private final int carbohydrate; // 可选参数
// 第一个构造器带上所有必选参数
public NutritionFacts(int servingSize, int servings) {
// 调用另一个构造器
this(servingSize, servings, 0);// 第三个参数为默认值
}
// 第二个构造器在第一个构造器的基础上加上一个可先参数
public NutritionFacts(int servingSize, int servings,
int calories) {
// 第四个参数为默认值
this(servingSize, servings, calories, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}
当你想要创建实例时,就利用参数列表最短的构造器。虽然重叠构造器模式可行,但是当有许多参数的时候,客户端代码会行难编写,并且难以阅读,随着参数的增加,它很快就失去控制。
遇到许多构造器参数时,还有第二种代替办法,即JavaBean模式,在这种模式下先调用一个无参数构造器来创建对象,然后调用setter方法来设置每个必要的参数,以及每个相关的可先参数:
public class NutritionFacts {
private int servingSize = -1; //必选,没有默认值
private int servings = -1; //必选,没有默认值
private int calories = 0; //可选,有默认值
private int fat = 0; //可选,有默认值
private int sodium = 0; //可选,有默认值
private int carbohydrate = 0; //可选,有默认值
public NutritionFacts() {}
// set方法
public void setServingSize(int val) { servingSize = val; }
public void setServings(int val) { servings = val; }
public void setCalories(int val) { calories = val; }
public void setFat(int val) { fat = val; }
public void setSodium(int val) { sodium = val; }
public void setCarbohydrate(int val) { carbohydrate = val; }
}
这种模式弥补了重叠构造器模式的不足,他创建实例很容易,代码阅读也很容易。但遗憾的是,JavaBean模式自身有着很严重的缺点。因为构造过程中被分到了几个调用中,在构造过程中JavaBean可能处于不一致的状态,并且类也无法仅仅通过检验构造器参数的有效性来保证一致。另外,JavaBeans模式阻止了把一个类做成不可变的可能,这就需要应用中确保线程安全。
幸运的是,还有第三替代方法,既能保证重叠构造器模式那样的安全性,也能保证像JavaBeans模式那么好的可读性,这就是Builder模式的一种形式——不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造(或者静态工厂),得到一个builder对象,然后客户端在builder对象上调用类似于setter方法,来设置每个样的可选参数,这个builder是它构建的类的静态成员类,下面是示例:
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
// 必输参数
private final int servingSize;
private final int servings;
// 可选参数 - 初始化成默认值
private int calories = 0;
private int fat = 0;
private int carbohydrate = 0;
private int sodium = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val)
{ calories = val; return this; }
public Builder fat(int val)
{ fat = val; return this; }
public Builder carbohydrate(int val)
{ carbohydrate = val; return this; }
public Builder sodium(int val)
{ sodium = val; return this; }
// 构造产品
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
// 构造器需要一个builder对象
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
public static void main(String[] args) {
NutritionFacts cocaCola = new NutritionFacts.Builder(240,.
calories(100).sodium(35).carbohydrate(27).build();
}
}
如果类的构造器或者静态工厂中具有多个参数,设计这种类时,Builder模式就是种不错的选择,特别是当大多数参数都是可选的时候。与使用传统的重叠构造器模式相比,使用Builder模式的客户端代码将更易于阅读和编写,构建器也比JavaBeans更安全。
3、 用私有构造器或者枚举类型强化Singleton属性
Singleton指仅仅被实例化一次的类。在Java1.5发行版本之前,实例Singleton有两种方法,这两种方法都要把构造器设置成私有的,并导出公有的静态成员,以便允许客户端能够访问该类的唯一实例。在第一种方法中,公有静态成员是个final域:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { }
}
私有构造器仅被调用一次,用来实例化仅有的静态final域INSTANCE。由于没有公有的或受保护的构造器,所以保证了实例的全局唯一性:一旦Elvis类被实例化,只会存在一个Elvis实例。客户端任何行为都不会改变这一点,但要提醒一点:享有特权的客户端可以借助于AccessibleObject.setAccessible方法,通过反射机制(第53条)调用私有构造器,如果要抵御这种攻击,可以修改构造器,让他在要求创建第二个实例的时候抛出异常。
第二种方法中公有的成员是个静态工厂方法:
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public static Elvis getInstance() { return INSTANCE; }
}
对于静态方法getInstance的所有调用,都会返回同一个对象引用,所以永远不会创建其他实例(上述提醒依然适用)。
第一种可以在以前的VM上效率要高一点,但在现在的JVM实现几乎都能够将静态工厂方法的调用内联化了,所以不存在性能的差异了,再说第二种方式的主要好处在于:组成类的成员的声明很清楚地表明了这个类是一个Singleteon。
另外,如果一个Singleton类实现了Serializable是不够的,为了维护并保证Singleton,必须(原因请见76,简单的说就是防止私有域导出到序列化流中而受到攻击)声明所有实例域都是瞬时(transient)的,并提供一个readResolve方法,否则,每次序列化时,都会创建一个新的实例,正确的作法:
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
private Object readResolve() {
// Return the one true Elvis and let the garbage collector
// take care of the Elvis impersonator.
return INSTANCE;
}
}
从Java1.5版本起,实例Singleton还有第三种方法。只需写一个包含单个元素的枚举类型:
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
// This code would normally appear outside the class!
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
elvis.leaveTheBuilding();
}
}
这种方法在功能上与公有域方法相近,但是它更加简洁,无偿地提供了化机制,绝对防止多次实例化,即使是在相对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为了实现Singleton的最佳方法。
上面前面两种是懒汉式单例,还有其他的设计方法,请参考XXX
4、 通过私有构造器强化不可实例化的能力
有时候,你可能需编写只包含静态方法和静态域的类。这些类的名声很不好,因为有些人在面向对象的语言中滥用这样的类来编写过程化的程序。尽管如此,它们也确实有它们特有的用处。比如java.util.Arrays、java.util.Collections,这样的工具类不希望被实例化(这与单例是不一样的),实例对它没有任何意义,它们里的成员都是静态的,所有构造器也定义成了private,但这样是否就能确保不能实例化了呢?如果采用反射就不一定了。那怎样才能做完全不能实例化呢?有的人可能企图通过将类做成抽象类来强制该类不可被实例化,这是行不通的,该类可以被子类化,并且该子类也可以被实例化。这样做甚至会误导用户,以为这种类是专门为了继承设计的。正确的作法:
public class UtilityClass {
// Suppress default constructor for noninstantiability
private UtilityClass() {
throw new AssertionError();//抛异常,防止内部实例化与外部通过反射来实例化
}
}
5、 避免创建不必要的对象
一般来说,最好能重用对象而不是在每次需要的时候就创建一个相同功能的新对象。如果对象是不可变的,它就始终可以被重用。
反例: String s = new String("stringette");
该语句每次被执行的时候都创建一个新的String实例,但是这些创建对象的动作全都是不必要的,传递给String构造器的参数("stringette")本身就是一个String实例,功能方面等同于构造器创建的对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出很多不必要的String实例。
改进:String s = "stringette";
改进后,只用一个String实例,而不是每次执行的时候都创建一个新的实例,而且,它可以保证,对于所有在同一虚拟机中运行的代码,只要它们包含相同的字符串字面常量,该对象就会被重用。
关于字符串对象创建细节请看:《String,到底创建了多少个对象?》
对于同时提供了静态工厂方法和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf(String)几乎总是比构造器Boolean(String)好,构造器在每次被调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。
除了重用不可变的对象之外,也可以重用那此已知不会被修改的可变对象,即当一个对象创建后,以后不会去改变其内部状态,此时也不会去创建新的对象,而是直接利用以前创建的对象。比如某方法里定义了一个大的对象,而这个对象一但创建完后,就可以共以后方法调用使用时,最好将它定义为成员域,而不是局部变量。
例如,Map接口的keySet方法就是每次返回的是keySet实例,当创建好KeySet视图对象后,它会将它存储到keySet成员域中以便下次使用:
public Set<K> keySet() {
Set<K> ks = keySet;
return (ks != null ? ks : (keySet = new KeySet()));
}
它返回的Map对象的Set视图,其中包含该Map中所有的键。粗看起来,好像每次调用keySet都应该创建一个新的Set实例,但是,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例,虽然被返回的Set实例一般是可改变的,但是所有返回的对象在功能上是等同的。
要优先使用基本类型而不是装箱基本类型,要当心无意识的自动装箱:
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}
这段程序算出的答案是正确的,但是比实际情况要更慢一些,只因为打错了一个字符。变量sum被声明成Long而不是long,这就意味着程序构造了大约2^31个多的Long实例。
不要错误地认为“创建对象的代价非常昂贵,我们应该要尽可能地避免创建对象,而不是不创建对象”,相反,由于小对象的构造器只做很少量的工作,所以,小对象的创建和回收动作是非常廉价的,特别是在现代的JVM实现上更是如此。通过创建附加的对象,提升程序的清晰性、简洁性和功能性,这通常也是件好事。
反之,通过维护自己的对象池来创建对象并不是一种好的做法,除非池中的对象是非常重量级的。真正正确使用对象池的典型对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。
另外,在1.5版本里,对基本类型的整形包装类型使用时,要使用形如 Byte.valueOf来创建包装类型,因为-128~127的数会缓存起来,所以我们要从缓冲池中取,Short、Integer、Long也是这样。
6、 消除过期的对象引用
Java中会有内存泄漏,听起来似乎是很不正常的,因为Java提供了垃圾回收器针对内存进行自动回收,但是Java还是会出现内存泄漏的。什么是Java中的内存泄漏:在Java语言中,内存泄漏就是存在一些被分配的对象,这些对象有两个特点:这些对象可达,即在对象内存的有向图中存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象了。如果对象满足这两个条件,该对象就可以判定为Java中的内存泄漏,这些对象不会被GC回收,然而它却占用内存,这就是Java语言中的内存泄漏。Java中的内存泄漏和C++中的内存泄漏还存在一定的区别,在C++里面,内存泄漏的范围更大一些,有些对象被分配了内存空间,但是却不可达,由于C++中没有GC,这些内存将会永远收不回来,在Java中这些不可达对象则是被GC负责回收的,因此程序员不需要考虑这一部分的内存泄漏。二者的图如下:
INCLUDEPICTURE "mhtml:file://G:\\新建文件夹\\12_Java内存模型%20-%20风中的索莉逖亚%20-%20CSDN博客.mht!http://docs.google.com/File?id=dc9d7w83_68ckf7x437_b" \* MERGEFORMATINET
下面看内存泄漏的例子:
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];//size~elements.length间的元素为过期元素
}
/**
* Ensure space for at least one more element, roughly
* doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size){
Object[] oldElements = elements;
elements = new Object[2 * elements.length +1];
System.arraycopy(oldElements, 0, elements, 0, size);
}
}
}
从栈中弹出来的对象不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,它们也不会被回收,这是因为,栈内部维护着对象这些对象的过期引用,过期引用是指永远也不会被解除的引用,在本例中,凡是在elements数组的“活动部分”之外的任何引用都是过期的,活动部分是指定elements中下标小于size的那些元素。
这类问题的修复方法很简单:一旦对象引用已经过期,只需清空这些引用即可。对于上面例子,只要一个单元被弹出栈,指向它的引用就过期了。pop方法的修改如下:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result =elements[--size];
elements[size] = null;//消除过期引用,只要外界不再引用即可回收
return result;
}
清空对象引用应该是一种例外,而不是一种规范行为:我们不必对每个对象引用一旦程序不再用到它就把它清空,这样做即没必要,也不是我们所期望的,因为这样做会把程序代码弄得很乱。消除过期引用最好的方法是让引用结束其任命周期,如果你在小的作用域内定义的每一个变量,退出作用域就会自动结束。
内存泄漏的另一个常见来源是缓存。一旦你把对象引用放到缓存中,它就很容易被遗忘掉,
从而使得它不再有用之后很长一段时间内仍然留在缓存中。对于这个问题,有几种可能的解决方案。如果你正好要实现这样的缓存:只要在缓存之外存在对某个项的键的引用,该项就有意义,那么就可以用WeakHashMap(弱键映射,允许垃圾回收器回收无外界引用指向象Map中键)代表缓存;当缓存中的项过期之后,它们就会自动被删除。记住只有当所要的缓存项的任命周期是由该键的外部引用而不是由值决定时,WeakHashMap才有用外。
另外,随着时间的推移,早些存入的项会变得越来越没有价值,在这种情况下,缓存应该时不时地清除掉没用的项。我们可以使用的是LinkedHashMap来实现,可以在给缓存添加新条目的时候顺便进行清理,如果要实现这种功能,我们需继承LinkedHashMap并重写它的removeEldestEntry方法(默认返回false,即不会删除最旧项),put 和 putAll 将调用此方法,下面是自己写的测试项:
public class CacheLinkedHashMap extends LinkedHashMap {
//允许最大放入的个数,超过则可能删除最旧项
private static final int MAX_ENTRIES = 5;
@Override
// 是否删除最旧项(最先放入)实现
protected boolean removeEldestEntry(Map.Entry eldest) {
Integer num = (Integer) eldest.getValue();// 最早项的值
//如果老的项小于3且已达到最大允许容量则会删除最老的项
if (num.intValue() < 3 && size() > MAX_ENTRIES) {
System.out.println("超容 - " + this);
return true;
}
return false;
}
public static void main(String[] args) {
CacheLinkedHashMap lh = new CacheLinkedHashMap();
for (int i = 1; i <= 5; i++) {
lh.put("K_" + Integer.valueOf(i), Integer.valueOf(i));
}
System.out.println(lh);
// 放入时会删除最早放入的 k_1 项
lh.put("K_" + Integer.valueOf(11), Integer.valueOf(0));
System.out.println(lh);
}
}
输出:
{K_1=1, K_2=2, K_3=3, K_4=4, K_5=5}
超容 - {K_1=1, K_2=2, K_3=3, K_4=4, K_5=5, K_11=0}
{K_2=2, K_3=3, K_4=4, K_5=5, K_11=0}
说到这里,我们再来看看LinkedHashMap的另一特性 —— 可以按照我们访问的顺序来重新排序(即访问的项放到链表最后),平时我们构造的LinkedHashMap 是按照存放的顺序来排的,如果要按照我们访问(调用get或者是修改时,即put已存在的键时相当于修改,如果放入的不是存在的项则还是放在链的最后)的顺序来重排集合,则需使用LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)来构造,并将accessOrder设置为true:
public class OrderLinkedHashMap {
public static void main(String[] args) {
//按存入顺序连接
LinkedHashMap lh = new LinkedHashMap();
init(lh);
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh.get(0);//不将访问过的项放到链表最后
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh = new LinkedHashMap(10, 0.75f, true);//按访问顺序
init(lh);
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh.get(0);//会将访问过的项放到链表最后
print(lh);//{1=1, 2=2, 3=3, 4=4, 0=0}
lh.put(1, 11);//会将访问过的项放到链表最后
print(lh);//2=2, 3=3, 4=4, 0=0, 1=11,
}
static void init(Map map) {
for (int i = 0; i < 5; i++) {
map.put(i, i);
}
}
static void print(Map map) {
Iterator it = map.entrySet().iterator();
while (it.hasNext()) {
Entry entry = (Entry) it.next();
System.out.print(entry.getKey() + "=" + entry.getValue() + ", ");
}
System.out.println();
}
}
内存泄漏的第三个常见来源是监听器和其他回调。如果你实现了一个API,客户端在这个API中注册回调(即将回调实例存储到某个容器中),却没有显示地取消注册,那么除非你采取某些动作,否则它们就会积聚。确保立即被垃圾回收的最佳方法是只保存它的弱引用,例如,只将它们保存成WeakHashMap中的键。
内存泄漏剖析工具:Heap Profiler
7、 避免使用终结方法
终结方法(finalize)通常是不可预测的,也是限危险的,一般情况下是不必要的,它不是C++中的析构函数。为什么呢?在C++中所有的对象运用delete()一定会被销毁,而JAVA里的对象并非总会被垃圾回收器回收,所以调用的时机是不确定的。C++的析构器也可以被用来回收其他非内存资源,而在Java中,一般用try-finally块来完成类似的工作。
终结方法的缺点是不能保证会被及时地执行。
Java语言规范不仅不保证终结方法会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这是完全有可能的。
不要被System.gc和System.runFinalization这两个方法所诱惑,它们确实增加了终结方法被执行的机会,但是它们并不保证终结方法一定会被执行。唯一声称保证终结方法被执行的方法是System.runFinalizersOnExit,以及它臭名昭著的孪生兄弟Runtime.runFinalizersOnExit,这两个方法都有致命的缺陷(它可能对正在使用的对象调用终结方法,而其他线程正在操作这些对象,从而导致不正确的行为或死锁),已经被废弃了。注,runFinalizersOnExit(true)只是在JVM退出时才开始调用那些还没有调用的对象上的finalize方法(默认情况下JVM退出时不会调用这些方法),而不像前面的gc与runFinalization在调用稍后执行finalize方法(也可能不执行,因为垃圾收集器并未开始工作)。
如果未被捕获的异常在终结过程中被抛出来,那么该异常将被忽略,并且该对象的终结过程也会终止,并且不会打印异常栈轨迹信息,但该对象仍可以被垃圾收集器收集。
还有一点,使用终结方法有一个非常严重的性能损失。换句话说,用终结方法创建和销毁对象慢了很多。
如果类对象中封闭的资源确实需要终止,我们首先需提供一个显示的终止方法(如InputStream、OutputStream、Connection、Timer上的close方法),并通常与try-finally结构结合起来使用,以确保及时终止(另外要注意的是,该实例应该记录下自己是否已经被关闭了,如果用户已显示地关闭了,则在终结方法中不得再关闭),而不是将它们的释放工作放在finalize终结方法中执行。
当然终结方法不是一无事处的,它有两种合法用途。第一种用途是,当对象的所有者忘记调用前面段落中建议的显示终止方法时,终结方法可以充当“安全网”,我们可以在finalize方法中再进行一次释放资源的工作。这样做并不能保证终结方法会被及时调用或甚至不会被调用,但是在客户端无法通过调用显示的终止方法(或者是根本未调用或忘记调用)来正常结束操作的情况下,这样迟一点释放关键资源总比永远不释放要好。但是如果终结方法发现资源还未被终止,则应该在日志中记录一条警告(最好再调用一次释放资源的方法),因为这表示客户端代码中的一个Bug,应该得到修复,如果你正考虑编写这样的安全网终结方法,就要认真考虑清楚,这种的保护是否值得你付出这份额外的代价。
显示终止方法模式的类(如InputStream、OutputStream、Connection、Timer)都具有终结方法,当它们的终止方法未能被调用的情况下,可以再次在终结方法中显示调用它们的关闭方法,这样终结方法充当了安全网;第二种就是可以回收那些并不重要的本地资源(即本地方法所分配的资源)。
“终结方法链(父类的终结方法)”并不会被自动被执行。如果类(不是Object)有终结方法,并且子类覆盖了终结方法,子类的终结方法就必须手工调用超类的终结方法。你应该在一个try块中终结子类,并在相应的finally块中调用超类的终结方法。这样做可以保证:即使子类的终结过程抛出异常,超类的终结方法也会得到执行,如下面示例:
protected void finalize() throws Throwable{
try{
…// 子类回收动作
}finally{
super.finalize();// 调用父类的终结方法
}
}
如果子类实现者覆盖了超类的终结方法,但是忘了手式调用超类的终结方法,那么超类的终结方法永远也不会被调用到。要防范这样的粗心大意,我们可以为每个将被终结的对象创建一个附加的对象。不是把终结方法放在要求终结处理的类中,而是把终结方法放在一个匿名的类中,该匿名类的唯一作用就是终结它的外围实例。该匿名类的单个实例被称为终结方法守卫者,外围类的每个实例都会创建这样一个守卫者。外围实例在它的私有实例哉中保存着一个对其终结方法守卫者的唯一引用,因些终结方法守卫都与外围实例可以同时启动终结过程。当守卫都被终结的时候,它执行外围实例所期望的终结行为,就好像它的终结方法是外围对象上的一个方法一样:
class Foo{
//终结守卫者
private final Object finalizerGuardian = new Object(){
protected void finalize() throws Throwable{
System.out.println("Foo gc");
}
};
}
class Sub extends Foo{
// 即使没有在子类的终结方法中调用父类的终结方法,父类也会终结
protected void finalize() throws Throwable {
System.out.println("Sub gc");
}
}
注意,公有类Foo并没有终结方法(除了它从Object中继承了一个无关紧要的的之外),所以子类的终结方法是否调用super.finalize并不重要。
总之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用终结方法。当然如果使用了终结方法,就要记得调用super.finalize。如果用终结方法作为安全网,要记得记录终结方法的非法调用。最后,如果需要把终结方法与公有的非final类关联起来,请考虑使用终结方法守卫者,以确保即使子类的终结方法未能调用super.finalize,该终结方法也会被执行。
补充:
虽然终结一个对象时,它不会去自动调用父类的终结方法,除非手工调用super.finalize,但是父类里的成员域一定会被调用终结方法,这就是为什么终结守卫者安全的原因。另外,回收的顺序是不确定的,不会像C++中的那样,先调用子类析构函数,再调用父类析构函数。还有一点要注意的是,即使对象的finalize 已经运行了,不能保证该对象被销毁。因为对象可以重生。
对于任何给定对象,Java 虚拟机最多只调用一次 finalize 方法。
垃圾收集器的工作过程大致是这样的:一旦垃圾收集器准备好释放无用对象占用的存储空间,它首先调用那些对象的finalize()方法,然后才真正回收对象的内存。
与 Java 不同,C++ 支持局部对象(存储在栈中)和全局对象(存储在堆中),C++ 能对栈中的对象自动析构,但对于堆中的对象就要手动分配内存与释放。在 Java 中,所有对象都驻留在堆内存,而内存的回收则由垃圾收集器统一回收。
finalize可以用来保护非内存资源被释放,即使我们定义了其它的方法来释放非内存资源,但是其它人未必会调用该方法来释放。在finalize里面可以检查一下,如果没有释放就释放好了,晚释放总比不释放好,这样好比“双保险”。通常我们可以在finalize方法中释放容易被忽略的资源,并且这些都是非常重要的资源。
第三章 对所有对象都通用的方法
8、 覆盖equals时请遵守通用约定
如果类具有自己特定的“逻辑相等”概念(不同于对象等同概念),而且超类还没有覆盖equals以实现期望的行为,这时我们就需要覆盖equals方法,这通常属于“值类”的情形,例如Integer或者是Data,程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。
在覆盖equals方法时,你必须要遵守它的通用约定。下面是约定的内容,来自Object的规范:
l 自反性:对于任何非null的引用值x,x.equals(x)必须返回true。如果自已不等于自己的话,将其放入集合中后,该集合的contains方法将告诉你,集合中不包括你刚添加的实例。
l 对称性:对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。这就要求不同类的实例如果在逻辑值相同的情况下,要求这两个实例所对应的类的equals方法比较逻辑要相同,不然的话,对称性将不再满足。
l 传递性:对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
l 一致性:对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false。
l 非空性:对于任何非null的引用值x,x.equals(null)必须返回false。
实现高质量的equals方法:
1、 使用==操作符检查“参数是否为这个对象的引用”。如果是,则返回true,这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
2、 使用instanceof操作符检查“参数是否为正确的类型”。如果不是,则返回false。一般说来所谓“正确的类型”是指定equals所在的那个类。有些情况下,是指该类所实现的某个接口。如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口如Set、List、Map和Map.Entry具有这样的特性。注,这步会过滤掉null,因为 null instanceof XX 一定会返回false。另外,要注意的是,如果你只与自己本身类型的类相比,则可以使用if(getClass() == obj.getClass())来限制为同一个类比较而不希望是父类或其子类(思想来源于《Practice Java》)。
3、 把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。
4、 对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true,否则返回false。
对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用Float.compare方法;对于double域,则使用Double.compare。对于数组域,则要把以上这些指导原则应用到每个元素上,如果数组域中的每个元素都需要比较的话,可以使用1.5版本中发行的Arrays.equals方法。
对于float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量,详细信息请参考Float.equals的文档,看看Float.equals源码与文档描述:
public boolean equals(Object obj) {
return (obj instanceof Float)
&& (floatToIntBits(((Float) obj).value) == floatToIntBits(value));
}
在比较是否相等时使用是floatToIntBits(float)方法,即将浮点的二进制位看作是整型位后比较的。注意,在大多数情况下,对于 Float 类的两个实例 f1 和 f2,让 f1.equals(f2) 的值为 true 的条件是当且仅当 f1.floatValue() == f2.floatValue() 的值也为 true。但是也有下列两种例外:
l 如果 f1 和 f2 都表示 Float.NaN(规定Float.NaN = 0x7fc00000),那么即使 Float.NaN = = Float.NaN 的值为 false,equals 方法也将返回 true(因为他们所对应的整型位是相同的)。
l 如果 f1 表示 +0.0f,而 f2 表示 -0.0f,或相反的情况,则 equal 测试返回的值是 false(因为他们所对应的整型位是不同的),即使 0.0f = = -0.0f 的值为 true 也是如此。
另外,来看看Float.compare的源码:
public static int compare(float f1, float f2) {
if (f1 < f2)
return -1; // Neither val is NaN, thisVal is smaller
if (f1 > f2)
return 1; // Neither val is NaN, thisVal is larger
int thisBits = Float.floatToIntBits(f1);
int anotherBits = Float.floatToIntBits(f2);
return (thisBits == anotherBits ? 0 : // Values are equal
(thisBits < anotherBits ? -1 : // (-0.0, 0.0) or (!NaN, NaN)
1)); // (0.0, -0.0) or (NaN, !NaN)
}
compare是从数字上比较两个 Float 对象。在应用到基本 float 值时,有两种方法来比较执行此方法产生的值与执行Java 语言的数字比较运算符(<、<=、== 和 >= >)产生的那些值之间的区别:
l 该方法认为 Float.NaN 将等于其自身,且大于其他所有 float 值(包括 Float.POSITIVE_INFINITY)。
l 该方法认为 0.0f 将大于 -0.0f。
请记住,如果是通过Java 语言的数字比较运算符(<、<=、== 和 >= >)而不是compare方法来比较时,只要其中有一个操作为Float.NaN,那么比较结果就是false。
对象引用域的值为null是有可能的,所以,为了避免可能导致的空指针异常,则使用下面的作法: filed = = null ? o.field = = null : filed.equals(o.filed)
如果field和o.field通常是相等的对象引用,那么下面的做法就会更快一些:(field == o.field || (field != null && field.equals(o.field)))
5、 当你编写完成了equals方法之后,应该问自己:它是否是对称的、传递的、一致的?当然,equals方法也必须满足其他两个我(自反性和非空性),但是这两种我通常会自动满足。
另一点要注意的是如果该类有除Object以外的父类,则要考虑是否调用父类的equals方法,如super.equals(obj)(思想来源于《Practice Java),因为可能有些逻辑状态在父类中也需要比较。
下面的例子是根据上面的诀窍构建equals方法:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
// …
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNumber == lineNumber
&& pn.prefix == prefix
&& pn.areaCode == areaCode;
}
}
最后一点要注意的是,在重写equals方法时,参数类型应该为Object,而不应该是某个要比较的具体类,因为这样在调用equals方法时可能为调用成Object里的equals方法,比如外界将比较的对象赋值给一个Object类型的变量时就会有这个问题。
9、 覆盖equals时总是要覆盖hashCode
在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。如果不这样做的话,就会违反Object.hashCode的能用约定,从而导致该类无法结合所有基于散列的集合一起正常动作,这些集合包括HashMap、HashSet、Hashtable等。
下面是约定的内容:
l 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致(我想可能是因为对象的状态信息被修改过)。
l 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生相同的整数结果。
l 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(has table)的性能。
hash集合查找某个键是否存在时,采用取了优化方式,它们先比较的是两者的hashcode,如果不同,则直接返回false(因为放入合希集合的过程中元素的hashcode就已计算出并存Entry里的hash域中了,所以先比较这哈希值很快),否则再比较内容,源码部分如下:if (e.hash == hash && (x == y || x.equals(y))) 。
一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”,这正是上面约定中第三条含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布所有可能的散列值上。要完全达到这种理想的情形是非常困难的。但我们如果按照如下的规则来写hashCode函数,则可能比较理想:
1、 把某个非零的常数值,比如说17(值17是任选的,困此即即使2.a步骤中计算出的散列值为0初始域也会影响到散列值,这样会大大的避免了冲突的可能性,所以这个一般是一个非零的常数值),保存在一个名为result的int的类型变量中。
2、 对于对象中每个键域f(指equals方法中涉及的每个域),完成以下步骤:
a. 为该域计算int类型的散列码c:
I、 如果该域是boolean类型,则计算(f ? 1 : 0)。
II、 如果该域是byte、char、short或者int类型,则计算(int)f。
III、 如果该域是long类型,则计算(int)(f ^ (f >>> 32))。
IV、 如果该域是float类型,则计算Float.floatToIntBits(f),即将内存中的浮点数二进制位看作是整型的二进制,并将返回整型结果。
V、 如果该域是dobule类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.III。
VI、 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals方式来比较这个域,则同样为这个域递归地调用hashCode。如果这个域的值为null,则返回0。
VII、 如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组的每个元素都需要求,则可以使用1.5版本发行的Arrays.hashCode方法。
b. 按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中:
result = 31 * result + c;
步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只要组成该字符串的字符是一样的,而不管它们的排列的顺序,则会导致只要有相同字符内容的字符串就会相等的问题,而String的equals方法是与字符排序顺序有关的。另外,之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会全丢失,因为与2相乘等价于移位运算。31还有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i = i ^32 - i = (i << 5) – i,现代的VM可以自动完成这种优化。
3、 返回result。
4、 写完了hashCode方法后,问问自己“相等的实例是否都具有相等的散列码”。
在散列码计算的过程中,可以把冗余域排除在外,换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。但必须排除equals比较计算中没有用到的所有域,否则很有可能违反hashCode约定的第二条。
不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。
下面看看根据上面hashCode规则的实例:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNumber == lineNumber
&& pn.prefix == prefix
&& pn.areaCode == areaCode;
}
@Override public int hashCode() {
int result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
return result;
}
}
当然,如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象的内部,而不是每次请求的时候都重新计算散列码:
private volatile int hashCode; // (See Item 71)
@Override public int hashCode() {
int result = hashCode;
if (result == 0) {
result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
hashCode = result;
}
return result;
}
10、 始终要覆盖toString
toString方法应该返回对象中包含的所有值得关注的信息。建议所有的子类都覆盖这个方法。
在实现toString的时候,必须要做出一个很重要的决定:是否在文档中指定返回值的格式。如果格式化,则易阅读,但你得要一直保持这种格式,因而缺乏灵活性。
11、 谨慎地覆盖clone
Cloneable是一个标识性接口,没有任何方法,那么它到底有什么作用?它决定了Object中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException异常。
Object.clone()能够按对象大小创建足够的内存空间,从旧对象到新对象,复制所有的比特位。 这被称为逐位复制。但是,Object.clone()在执行操作前,会先检查此类是否可克隆,即检查 它是否实现了Cloneable接口。如果没有实现此接口,Object.clone()会抛出CloneNotSuppo rtedException异常,说明它不能被克隆。
如果某个类中每个成员域是一个基本类型的值(但不包括基本类型数组),或者是指向一个不可变对象的引用,那么我们直接调用Object中的clone方法就是我们要返回的拷贝对象了,而不需要对这个对象再做进一步的处理:
public final class PhoneNumber implements Cloneable {
private final short areaCode;
private final short prefix;
private final short lineNumber;
// …
// 注,这里返回的是PhoneNumber而不是Object,1.5版本后支持参数有协变:覆盖方法的返回烦劳可以是被覆盖方法的返回类型的子类。这样不用在客户端强转了。
@Override public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch(CloneNotSupportedException e) {
throw new AssertionError(); // Can't happen
}
}
}
如果对象含有引用类型且指向了可变对象,使用上述这种简单的clone实现可能会导致灾难性后果,考虑像上面那样克隆如下类(类来自于第6条):
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// …
}
假设你希望把这个类做成可克隆的(Cloneable),如果它的clone方法仅仅是返回super.clone(),这样得到的Stack实例,在其size域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。为了使用Stack类中的clone方法正常地工作,它必须拷贝栈的内部信息,最容易的做法是,在elements数组路递归地调用clone:
@Override public Stack clone() {
try {
Stack result = (Stack) super.clone();
// 1.5版本后不必将返回的Object类型结果强转成为Object[]类型,自1.5起,在数组上调用clone返回的数组,其编译时类型与被克隆数组的类型相同,1.5前返回的是Object对象。
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
注意,上面如果elements域是final的,上述方案就不能正常工作了,因为clone方法是被禁止给elements域赋新值的。这是个根本的问题:clone架构与引用可变对象的final域的正常用法是不相兼容的,可以说是相违背的,除非在原始对象和克隆对象之间可以安全地共享此可变对象(比如使用final修饰的StringBuffer就不可安全共享,如果是不可变对象如String则可安全共享,就可以不必克隆)。为了使类成为可克隆的,可能有必要从某些域中去掉final修饰符。
另外result.elements = elements.clone();所克隆的也只是elements中所有对象地址罢了,克隆出的数组里的元素还是与原数组指向同一个对象,如果要真真深层次克隆,则还是要对数组循环来一个个调用对象上的clone方法才行,下面就来看看这个问题的相应例子。
例如,假设你正在为自己设计的一个散列表编写clone方法,它的内部数据包含一个散列桶数组,每个散列桶都指向“键 — 值”对单向链表的第一个节点,如果桶是空的,则为null,该类如下:
public class Hashtable implements Cloneable {
private transient Entry buckets[];
private static class Entry {
final Object key;
Object value;
Entry next;
protected Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
}
…
}
假设你仅仅递归地克隆这个散列桶数组,就像我们能Stack类所做的那样:
@Override public HahsTable clone() {
try{
HashTable result = (HashTable)super.clone();
result.buckets = buckets.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
虽然被克隆对象有它自己的散列桶数组,但是,这个数组引用的链表对象与原始对象是一样的,从而很容易地引起克隆对象和原始对象中不确定的行为。为了修正这个问题,必须单独地拷贝并组成每个桶的链表,下面是一种常见做法:
public class Hashtable implements Cloneable {
private transient Entry buckets[];
private static class Entry {
final Object key;
Object value;
Entry next;
protected Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
Entry deepCopy(){// 递归地深层复制每个链表节点对象
return new Entry(key, value, next == null ? null : next.deepCopy());
}
}
@Override public HahsTable clone() {
try{
HashTable result = (HashTable)super.clone();
result.buckets = new Entry[buckets.length];
// 采用循环的方式对每个桶引用的单链表进行深层拷贝
for(int i = 0; i < buckets.length; i++){
if(buckets[i] != null){
result.buckets[i] = buckets[i].deepCopy();
}
}
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
…
}
Entry类中的深度拷贝方法递归地调用它自身,以便拷贝整个链表。虽然这种方法很灵活,因为针对列表中的每个元素,它都要消耗一段空间。如果链表比较长,这很容易导致栈溢出,为了避免发生这种情况,你可以在deepCopy中用迭代代替递归:
Entry deepCopy(){
Entry result = new Entry(key, value, next);
for(Entry p = result; p.next != null; p = p.next){
p.next = new Entry(p.next.key, p.next.value, p.next.next);
}
return result;
}
简而言之,所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone(如果不是公有的且没有覆盖Object中的clone方法,则外界不能克隆该类的实例)。此公有方法首先调用super.clone,然后修正任何需要修正的域。
数组具有clone方法,但我们不能使用反射来调用该方法,但可以拿到数组对象后直接调用。
直接通过调用数组对象的clone方法克隆出的对象是否是深度克隆,则要看这个数组是否是基本类型的数组,如果是则属于深度克隆,否则不是(但是数组对象本身还是被复制了一份的,而不是指向数组同一存储空间了,只是数组里的引用还是指向原来数组指向的对象,如果此时需对元素再次深度克隆,则需要对数组里的每个元素进行单独克隆处理)。
使用Object中的默认clone对某个类进行克隆时,任何类型的数组属性成员都只是浅复制,即克隆出来的数组与原来类中的数组指向同一存储空间,其他引用也是这样,只有基本类型才深复制。
12、 考虑实现Comparable接口
如果一个类实现了Comparabler接口,就表明它的实例具有内在的自然排序规则了。事实上,Java平台类库中的所有值类都实现了Comparable接口。如果你正在编写一个值类,它具有非常的内在排序关系,比如按字母顺序、按数值顺序或按年代,那你就应该考虑实现这个接口:
public interface Comparable<T>{
int compareTo(T t);
}
依赖于比较关系的类包括有序集全类TreeSet和TreeMap,以及工具类Collections和Arrays,它们内部包含有搜索和排序算法。
如果一个域并没有实现Comparable接口,或者你需要使用一个非标准的排序关系,就可以使用一个显式的Comparable来代替:
public interface Comparator<T> {
int compare(T o1, T o2);
}
比较整型基本类型的域,可以使用关系操作符 == 、< 和 >。但浮点域要使用Double.compare或者Float.comprae,而不是用关系操作符。
如果一个类有多个关键域,那么从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果,则整个比较操作结束,并返回该结果。如果最关键的域是相等的,则进一步比较次最关键的域,以此类推。如果所有的域都是相等,则对象就是相等的,并返回零,下面是第9条的PhoneNumber类的compareTo方法:
public int compareTo(PhoneNumber pn) {
// Compare area codes
if (areaCode < pn.areaCode)
return -1;
if (areaCode > pn.areaCode)
return 1;
// Area codes are equal, compare prefixes
if (prefix < pn.prefix)
return -1;
if (prefix > pn.prefix)
return 1;
// Area codes and prefixes are equal, compare line numbers
if (lineNumber < pn.lineNumber)
return -1;
if (lineNumber > pn.lineNumber)
return 1;
return 0; // All fields are equal
}
虽然这个方法可行,但可以改进一下,因为compareTo方法的规定并没有指定返回值的大小,而只是指定了返回值的符号:
public int compareTo(PhoneNumber pn) {
// Compare area codes
int areaCodeDiff = areaCode - pn.areaCode;
if (areaCodeDiff != 0)
return areaCodeDiff;
// Area codes are equal, compare prefixes
int prefixDiff = prefix - pn.prefix;
if (prefixDiff != 0)
return prefixDiff;
// Area codes and prefixes are equal, compare line numbers
return lineNumber - pn.lineNumber;
}
虽然比前面快一点,但用起来要非常小心。除非这些域不会为负数,或都更一般的情况:最小和最大的可能域值之差小于或等于Integer.MAX_VALUE,否则就不要使用这种方法。比如i是一个很大的正整数,而j是一个很大的负整数,那么i-j将会溢出。这不是理论,它已经在实际的系统中导致了失败,所以要格外小心,因为这样的compareTo方法对于大多数的输入值都能正常工作。
第四章 类和接口
13、 使类和成员的可访问性最小化
要区别设计良好的模块与设计不好的模块,最后重要的因素在于,这个模块对于外部的其他模块而言,是否隐藏其内部了数据和其他实现细节。设计良好的模块会隐藏所有的实现细节,把它的API与它的实现清晰地隔离开来。然后,模块之间只通过它们的API进行通信,一个模块不要知道其他模块的内部工作情况。这个概念被称为信息隐藏或封装,也是软件设计的基本原则之一。
封装有效地解除组成系统的各模块之间的耦合关系,使得这些模块可以独立地开发、测试与维护,还提高了软件的可重用性。
Java里的封装是通过访问控制符来加以限制的。
第一规则很简单:尽可能地使每个类或者成员不被外界访问,即使用尽可能最小的访问级别。
对于顶层类(非嵌套的)类和接口,只有两种可能的访问级别:包级私有的和公有的。如果使用pulbic修饰符声明了顶层类或者接口,那它就是公有的;否则,它将是包级私有的。如果顶层类或者接口能够被做成包级私有的,它就应该被做成包级私有,这样类或者是接口就成了这个包的实现的一部分,而不是该包导出的API的一部分,在以后的版本中,可以对它进行修改、替换、或者删除,而无需担心会影响到现有的客户端程序。
如果一个包级私有的顶层类(或者接口)只是在某一个类的内部用到,就应该考虑使它成为唯一使用它的那个类的私有嵌套类。这样可以将它的可访问范围从包中的所有类缩小到了使用它的那个类。然而,降低不必要公有类的可访问性,比降低包级私有的顶层类的更重要得多:因为公有类是包的API的一部分,而包级私有的顶层类只是这个包的实现的一部分,包级是我们可以掌控的一部分。
对于成员(域、方法、嵌套类和嵌套接口)有四种可能的访问级别,下面按照可访问性的递增顺序罗列出来:
1、 私有的(private):只有在声明该成员的顶层类内部才可以访问这个成员。
2、 包级私有的:声明该成员的包内部的任何的任何类都可以访问这个成员。这也是“缺省”访问级别,如果没有为成员指定访问修饰符,就采用这个访问级别。
3、 受保护的(protected):声明该成员的类的子类可以访问这个成员(但有一些限制[JLS,6.6.2]),并且声明该成员的包内部的任何类也可以访问这个成员。
4、 公有的(public):在任何地方都可以访问该成员。
只有当同一个包内的另一个类真正需要访问一个成员的时候,你才应该删除private修饰符,使该成员变成包私有的。
私有成员和包级私有成员都是一个类的实现中的一部分,一般不会影响它的导出的API。然而,如果这个类实现了Serializable接口(见第74和75),这些域就有可能会被“泄漏”到导出的API中。
对于公有类的成员,当访问级别从包级私有变成保护级别时,会大大增强可访问性。受保护的成员是类的导出的API的一部分,必须永远得到支持。导出的类的受保护成员也代表了该类对于某个实现细节的公开承诺(见第17条)。受保护的成员应该尽量少用。
如果方法覆盖了超类中的一个方法,子类中的访问级别就不允许低于超类中的访问级别。这样就可以确保任何可使用超类的实例的地方也都可以使用子类的实例。
如果一个类实现了一个接口,那么接口中所有的类方法在这个类中也都必须被声明为公有的。之所以如此,是因为接口中的所有方法都隐含着公有访问级别。
为了测试而将一个公有类的私有成员变成包级别私有的,这还可以接受,但是如果要将访问级别提高到超过包访问级别,这就无法接受了。换包话说,不能为了测试,而将类、接口、或成员变成包的导出的API的一部分。幸运的是,也没有必要这么做,因为可以让测试作为被测试的包的一部分来运行,从而能够访问它的包级私有的元素。
实例域决不能是公有的(见第14条)。如果域是非final的,或者即是final但指向的却是可变对象,那么一旦使这个域成为公有的,就放弃了对存储在这个域中的值进行限制的能力;这意味着,你也放弃了强制这个域不可变的能力。因此,包含公有可变域的类并不是线程安全的。即使域是final的,并且引用不可变的对象,当把这个域变成公有的时候,也就放弃了“切换换到一种新的内部了数据表示法”(比如将这个字段删除掉,或者使用多个字段来表示等)的灵活性。
public final修饰的域要么包含基本类型的值,要么包含指向不可变对象的引用(见第15条)(同样的建议也适用于静态域)。如果final域包含可变对象的引用,它更具有非final域的所有缺点,虽然引用本身不能被修改,但是它所引用的对象却可以被修改。
长度非零的数组总是可变的,所以,类具有公有的静态final数组域,或者返回这种域的访问方法(这与方法返回的是局部数组是不一样的,因为这个是共享的,而返回的局部数组是单个线程共享的),这几乎总是错误的。如果类具有这样的域或者访问方法,客户端将能够修改数组中的内容,这是安全漏洞的一个常见根源:
public static final Thing[] VALUES={…};
修正这个问题有两种方法,可以使公有数组变成私有的,并增加一个公有的不可变列表:
private static final Thing[] PRIVATE_VALUES={…};
public static final LIST VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
另一种方法是,可以使用数组变成私有的,并添加一个公有方法,它返回私有的数组的一个备份:
private static final Thing[]PRIVATE_VALUES ={…};
public static final Thing[] values(){
return PRIVATE_VALUES.clone();
}
但依我个人看,上面的克隆还是不起根本作用(如果数组元素是基本类型的,就没有问题):虽然各个元素引用已被复制,但是它们所引用的对象却是同一份,如果指向的对象是可变的,则还是可以通过引用去修改它们所指向对象的问题,所以上面的修改也有潜在的问题。
从上面分析来看public static final最好修改的是基本类型的变量,或者是不可变的类。
总而言之,你应该始终尽量可能地降低可访问性。除了公有静态final域的特殊情形之外,公有类都不应该包含公有域。并且要确保公有静态final域所引用的对象都是不可变的。
14、 在公有类中使用访问方法而非公有域
如果类可以在它所在的包的外部进行访问,就将域设为私有的并提供域的访问与设置公有方法,以保留将来改变该类的内部表示法的灵活性。如果公有类暴露了它的数据域,要想在将来改变其内部表示法是不可能的,因为公有类的客户端已经遍布各处了。另外,如果不采取这种方式,则当域被访问的时候,无法彩任何辅助的行动。
如果类是包级私有的(虽然将域暴露了,但也只仅限于包含该类的包中,还在你可控的范围之内),或者是私有的嵌套类,直接暴露它的数据域并没有本质的错误——假设这些数据域确实描述了该类所提供的抽象。比如LinkedList中的Entry静态内部类,就暴露了所有的成员给外类了,这样用起来更简洁方便:
public class LinkedList{
//...
private static class Entry {
Object element;
Entry next;
Entry previous;
//...
}
}
让公有类直接暴露域虽然从来都不是种好办法,但是如果域是不可变的,这种做法危害就比较小一些(只是不能改变其指向,但指向的内容是否安全就不好说了)。如果不改变类的API,就无法改变这种类的表示法,当域被读取的时候,你也无法采取辅助的行动。
总之,公有类永远不应该暴露可变的域。虽然还是有问题,但是让公有类暴露不可变域其危害比较小。但是,有时候会需要使包级私有的或者私有的嵌套灰来暴露域,无论这个类是可变还是不可变的。
15、 使可变性最小化
不可变类是其实例不能被修改的类(不只是类前加上了final就可以了)。每个实例中包含的所有信息都必须在创建该实例时候就提供,并在对象的整个生命周期内固定不变。
Java平台类库上有很多不可变的类,其中有String、基本类型的包装类、BigInteger和BigDecimal。
存在不可变内的许多理由:不可变类比可变类更加易于设计、实现和使用。它们不容易出错,且更加安全。
为使类成为不可变,要遵循以下5条规则:
1、 不要提供任何会修改对象状态(属性)的方法。
2、 保证类不会被扩展。一般做法是使这个类成为final的,另外作法就是让类所有构造器都变成私有的或者是包级私有的。
3、 使用有的域都是final的(一般是针对非静态变量)。通过这种加上final修饰的强制方式,这可以清楚地表明你的意图:确保该域在使用前得到正确的初始化。而且,如果一个指向新创建的实例的引用在缺乏同步机制(一般不可变对象的访问是不需要同步的,因为状态一旦确定,就不会再更改)的情况下,从一个线程切换另一个线程就必需确保实例的正确状态(比如刚创建完这个实例,但还没来得及将工作内存中的数据存回到主内存中时就会有问题,但如果是加上了final修饰符后,则不会出现使用前final域还会初始化完成的情况,这样一定能保证构造器调用完后final域也会随之初始化完成。虽然以前很早的虚拟机上会出现构造器执行完成后final域未初始化完成的问题,但现已JMM已修复),正如果内存模型中所述那样[JLS 17.5]。
4、 使用所有的域都成为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。虽然从技术上讲,允许不可变的类具有公有的final域,只要这些域包含基本类型的值或都指向不可变对象的引用,但是不建议这样做,因为这样会使得在以后的版本中无法以再改变内部的表示法。
5、 确保对于任何可变域的互斥访问。如果类具有指向可变对象域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法中返回该对象的引用(即进出都不行)。在构造器、访问方法、readObject方法(见76条)中请使用保护性拷贝技术。
下面是一个不可变复数(具有实部和虚部)类的例子:
//复数
public final class Complex {
private final double re;//实部
private final double im;//虚部
// 私有的,让它无法扩展
private Complex(double re, double im) {
this.re = re;
this.im = im;
}
//静态工厂方法
public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}
public static Complex valueOfPolar(double r, double theta) {
return new Complex(r * Math.cos(theta), r * Math.sin(theta));
}
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
// 可以直接返回基本类型的值
public double realPart() {
return re;
}
public double imaginaryPart() {
return im;
}
//每次加减乘除都返回一个新的对象
public Complex add(Complex c) {
return new Complex(re + c.re, im + c.im);
}
public Complex subtract(Complex c) {
return new Complex(re - c.re, im - c.im);
}
public Complex multiply(Complex c) {
return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
}
public Complex divide(Complex c) {
double tmp = c.re * c.re + c.im * c.im;
return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re
* c.im)
/ tmp);
}
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Complex))
return false;
Complex c = (Complex) o;
// 浮点数的比较要使用Double.compare,而不能直接使用==比较
return Double.compare(re, c.re) == 0 && Double.compare(im, c.im) == 0;
}
public int hashCode() {
int result = 17 + hashDouble(re);
result = 31 * result + hashDouble(im);
return result;
}
private int hashDouble(double val) {
long longBits = Double.doubleToLongBits(re);
return (int) (longBits ^ (longBits >>> 32));
}
public String toString() {
return "(" + re + " + " + im + "i)";
}
}
不可变对象只有一种状态,即被创建时的状态。不可变对象本质上是线程安全的,它们不要求同步,可以被自由的共享。不可变类应该充分利用这种优势,鼓励客户端尽可能地重用现有的实例,要做到这一点,一个很简便的办法就是,对于频繁用到的值,为它们提供公有的静态final常量,例如上面的常量:
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
这种方法可以被进一步的扩展,不可变以的类可以提供一些静态工厂,它们把频繁请求主的实例缓存起来,在请求合适的对象时候,就不必要创建新的实例。所有的基本类型的包装类和BigInteger都有这样的静态工厂。使得实例可以共享,从而降低内存与垃圾回收成本。在设计类时,选择用静态工厂代替公有的构造器可以让你以后有缓存的灵活性,而不必影响客户端。
“不可变对象可以被自由地共享”,我们永远也不需要,也不应该为不可变对的类提供clone方法或者拷贝构造器。这一点在Java平台早期的版本中做得并不好,所以String类仍然具有拷贝构造器,但是应该尽量少用它。
不仅可以共享不可变对象,甚至也可以共享它们的内部信息。如BigInteger的negate方法产生一个新的BigInteger,其中数组是一样的,符号则是相反的,它并不需要拷贝数组;新建的BigInteger也指向原始实例中的同一个内部数组。
不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象,创建这种对象的代价可能很高,特别是对于大型对象的情形。
如果你选择让自己的不可变实现Serializable接口,并具它包含一个或者多个指向可变对象的域,就必须提供一个显示的readObject或者readResolve方法,或者使用ObjectOutputStream.writeUnshared和ObjectInputStream.readUnshared方法,否则攻击者或能从不可变的类创建可变的实例。这个话题详细请参见76条。
除非有很好的理由要让类成为可变的类,否则就应该是不可变的。不可变的类优点有很多,唯一缺点是在特定的情况下存在潜在的性能问题。你应该总是使用一些小的值对象(如Complex),成为不可变的。但你也应该认真考虑把一些较大的值对象做成不可变的(String、BigInteger),只有当你确认有性能问题时,才应该为不可变的类提供一个公有的可变配套类(如String的配套类StringBuffer、StringBuilder;还有BigInteger的配套类为BitSet)。
对于有些类而言,基不可变性是不切实际的。如果为不能被做成是不可变的,仍然应该尽可能地限制它的可变性。除非有使人信服的理由要使域变成是非final的,否则要使每个域都是final的。
构造器应该创建完全初始化的对象,并建立起所有的约束关系。不要在构造器或者静态工厂之外再提供公有的初始化方法,除非有使人信服的理由。同样也不应该提供“重新初始化”方法(比如TimerTask类,它是可变的,一旦完成或被取消,就不可能再对它重新调度),与所增加的复杂性相比,通常并没有带来太多的性能。
关于 不可变对象可变的修复问题 请参考:http://www.ibm.com/developerworks/cn/java/j-jtp02244/
总之,新的内存模型中,以前不可变对象与final域的可变问题得到了修复,可以放心的使用了(为了确保String的安全初始化,1.5中String的value[]、offset、count三个字段前都已加上了final修饰符,这样新的Java内存模型会确认final字段的初始化安全)。
16、 组合优先于继承
继承是实现代码重用的有力手段,但它并非是最好的手段。
在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处在同一个程序员的控制之下,即你还可以修改一下超类的东西。对普通的具体类进行跨越包边界的继承,则是非常危险的,因为你一旦发布包之后,你就得要遵循你的接口,而不能轻易的去修改它而影响客户端已有的应用。
与组合复用不同的是,继承打破了封装性。换句话说,子类依赖于其超类中特定的功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。
下面程序要使用HashSet功能,直接采用了继承:
public class InstrumentedHashSet<E> extends HashSet<E> {
/*
* 用来记录添加了多少个元素,与HashSet的size是不一样的
* HashSet的size会受到删除的影响,而这个只记录添加的元素
* 个数
*/
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override
//因为要记录我已放入的元素,所以重写了父类的方法
public boolean add(E e) {
addCount++;
return super.add(e);//最终还是调用HashSet 的add方法将元素存入
}
/*
* super.addAll是以调用HashSet 的add方法来实现的,而add方法又被子类
* 重写了,所以该类的add方法也会被再次调用,即实质上调用addAll
* 方法的时候,它也会去调用一下add方法,这在我们不调试的情况下
* 是很难发现的,这就是继承所带来的后果:因为我们依赖了父类的实现的细节。
*/
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
这个类看起来非常合理,但是它并不能正常工作,上面的子例返回的是6,而不是我们期望的3,为什么?在HashSet的内部,addAll方法是基于它的add方法来实现的,即addAll是循环调用add来实现的(即使HashSet的文档并没有说明这样的细节也是合理的,因为该类就不是为了用来继承的),而这add方法又被我们设计的这个类InstrumentedHashSet重写了,所以会再次调用我们的add方法。
我们只去掉被重写的addAll方法中的“addCount += c.size();”,就可以“修正”这个子类,但我们不能否认这样的实事:HashSet的addAll方法是在它的add方法上实现的,这种“自用性(self-use)”是实现的细节,它不是承诺,所以Sun不能保证在Java平台的所有版本实现中都保持不变,即不能保证随着发行版本的不同而不发生变化(比如说将来HashSetr addAll不是以调用add方法来实现),这样的实现细节是不必承诺了,他们能承诺的也只是公开的接口而已,因此,这样得到的InstrumentedHashSet类将是非常脆弱的。
这里稍微好一点的做法是,在InstrumentedHashSet中重写addAll方法来遍历指定的集合,为每个元素调用一次add方法,这样做可以保证得到正确的结果 ,不管HashSet的addAll方法是否是以调用add方法来实现的,因为HashSet的addAll实现将不会再被调用:
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
Iterator<? extends E> e = c.iterator();
while (e.hasNext()) {
//直接调用自己重写过的add方法,重写addAll后,不再依赖于HashSet的addAll
if (add(e.next()))
modified = true;
}
return modified;
}
以上实现只是把HashSet的addAll方法Copy过来而已。然而,这项技术并没有解决所有的问题,它相当于重新自己实现了超类的方法,这些超类的方法可能是自用的(self-use),也可能不是,如果是则这里的重新实现还有点意义,如果不是,则重新实现则完全是没有必要的,并且可能很容易出错。另外有一点,也许你说管他父类的addAll方法是否是自用的,我重新实现就是了,但是,你想过没有,如果父类中的addAll修改了某个私有的域的状态,那你该如何做?我们是无法在重新实现时修改父类中的那个私有域的状态的,因为我们根本无法访问到,所以,总之这种方案是不可取的,因为有太多的不确定因素。
导致子类脆弱的一个相关的原因就是,它们的超类在后续的版本中可能添加新的方法。假设我们的程序的有这样一个要求:所有被插入到某个集合中的元素都必须满足某个条件(比如上面在放入之前addCount会记录一下)。这样做是可以确保一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的版本中,超类中没有增加能插入元素的新方法,这种做法是安全的。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被覆盖的新的方法,而将“非法的”元素添加到子类的实例中。这不是纯粹的理念问题,在把Hashtable和Vector加入到集合框架中时,就修正了这几个类性质的安全漏洞。
上面的问题都是来源于覆盖动作。如果在继承一个类的时候,仅仅是添加新的方法,而没有覆盖现有的父类中的方法,你可能会认为是安全的,但是不然,比如你现在已继承了某个类,并扩展了父类,并添加了父类中没有方法,但是有可能将来父类也会添加同样签名的方法,但重写的条件不满足时,此时你设计的子类将不能编译,或者即使满足重写条件,这样又会有问题(如父类私有域状态的维护工作)。这样的问题不是不存在的,因为当你在编写子类方法时候,你肯定是不会知道将来父类会增加这样的名称的方法。
幸运的是,有一种办法可以避免前面提到的两个问题(覆盖可能影响父类私有域的状态不正确,部分覆盖又可能影响“自用性(self-use)”),不用继承现有类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称做为“组合”,因为现有类变成了新的类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为“转发”,新类中的方法被称为转发方法,这样得到的类将会非常稳固,它不依赖于现有类的实现细节,即使现有的类添加了新的方法,也不会影响新的类。下面我们使用“组合”来修正前面两个问题,注意这个实现分为两部分:类本身和可重用转发类,转发类没有其他方法,全是转发方法:
//功能不变,接口也不变,这叫转发
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;//组合
public ForwardingSet(Set<E> s) { this.s = s; }
//下面全是转发方法
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection<?> c)
{ return s.containsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
//下面全是重写Object中的方法
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
super(s);
}
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedSet<String> s =
new InstrumentedSet<String>(new HashSet<String>());
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
Set接口的存在使得InstrumentedSet类的设计成Set集合类成为可能,因为Set接口保存了HashSet类的功能特性,除了获得健壮性之外,这种设计也带来了格外灵活性。InstrumentedSet类实现了Set接口,并且拥有单个构造器,它的参数也是Set类型,从本质上讲,这个类把一个Set转变成了另一个Set,同时增加了计数器功能。
因为每一个InstrumentedSet实例都把另一个Set实例包装起来了,所以InstrumentedSet类被称做为包装类,这也正是Decorator装饰模式。InstrumentedSet类对一个集合进行了装饰,为它增加了计数特性。有时,组合和转发的结合也被错误地称为“委托”,从技术的角度而言,这不是委托,除非包装对象(InstrumentedSet)把自身传递给被包装的对象(HashSet)。
组合类几乎没有缺点,但需要注意的一点是,包装类不适合用在回调框架中;在回调框架中,对象把自身的引用传递给其他的对象,所以它传递一个指向自身的引用(this),回调时避开了外面包装对象,这被称为SELF问题。
有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发方法倒有点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包替你提供。
只有当子类真正是超类的子类型时,才适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在“is-a”关系的时候,类B才应该扩展类A。如果你打算让类B扩展类A,就应该问问自己了:每个B确实也是A吗?如果不能确定,通常情况下,B应该包含A的一个私有实例,并且暴露一个较小的、比较简单的API:A本质上不是B的一部分,只是它的实现细节而已。
在Java平台类库中,有许多明显违反这条原则的地方,例如,栈并不是向量,所以Stack不能继承Vecotr;属性列表也不是散列,所以Properties不能继承Hashtable,在这种情况下,复合模式才是恰当的。
如果在适合于使用组合的地方使用了继承,则会不必有地暴露实现细节(如暴露不必要的接口导致外界调用这些接口来非法改变实例的状态)。这样得到的API会把你限制在原始的实现上,永远限定了类的性能,更为严重的是,由于暴露了内部的细节或不必要的接口,客户端就有可能直接访问这些内部细节,从而破坏实现的内部状态。
继承机制会把超API中的所有缺陷传播到子类中,而复合则允许设计新的API来隐藏这些缺陷。
简而言之,继承的功能非常强大,但是也存在诸多的问题,因为它违背了封装的原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即使如此,如果子类和超类外在不同的包中,并且超类并不是为了继承而设计的,那么继承将会脆弱性,为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装为不仅比子类更加健壮,而且功能也更加强大。
17、 要么为继承而设计并提供文档说明,要么就禁止继承
第16条提醒我们,继承自一个不是为了继承而设计、并且没有文档说明的“外来”类是危险的。
如果一个类是专为了继承而设计的,要求有哪些呢?
首先,该类的文档必须精确地描述覆盖每个方法所带来的影响。换句话说,该类必须有文档说明它可覆盖的方法的自用性(self-use)。对于每个公有的或受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果以是如何影响后续的处理过程的(如在哪些情况下它会调用可覆盖的方法,必须在文档中说明)。
按照惯例,如果方法调用到了可覆盖的方法,在它的文档注释的末尾该包含关于这些调用的描述信息。这段描述信息要以这样的句子开头:“This implementation.(该实现...)”。这样的句子不是在表明该方法可能会随着版本的变迁而改变,而是表明了该方法的内部工作情况。下面是摘自java.util.AbstractCollection的规范:
public boolean remove(Object o):
从此 collection 中移除指定元素的单个实例(如果存在)(可选操作)。更正式地说,如果该 collection 包含一个或多个满足 (o==null ? e==null : o.equals(e)) 的元素 e,则移除 e。如果该 collection 包含指定的元素(或等价元素,如果该 collection 随调用的结果发生变化),则返回 true。
此实现在该 collection 上进行迭代,查找指定的元素。如果找到该元素,那么它会使用迭代器的 remove 方法从该 collection 中移除该元素。注意,如果此 collection 的 iterator 方法所返回的迭代器无法实现 remove 方法,并且此 collection 包含指定的对象,那么此实现会抛出 UnsupportedOperationException。
该文档清楚地说明了,覆盖iterator方法将会影响remove方法的行为。而且,它确切地描述了iterator方法返回的Iterator的行为将会臬影响remove方法的行为。与此相反,在第16条的情形中,程序员在子类化HashSet时,并没有说明覆盖add方法是否会影响addAll方法的行为。
关于程序文档有句格言:好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。而HashSet的add方法违背了这句格言,这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全的继承,你必须描述清楚那些有可能未定义的实现细节(引导子类如何实现)。
类可以通过某种形式提供适当的钩子(hook),以便能够进行入到它的内部工作流程,这种形式可以是精心选择的受保护(protected)方法。
为了允许继承,类还必须遵守其他一些约束。构造器决不能调用可被覆盖的方法。因为超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前就先被调用,如果该覆盖版本的方法依赖于子类构造器所执行的任何初始化工作,该方法将不会如预期般地执行。
如果你决定在一个为了继承而设计的类中实现Cloneable或者Serializable接口,就应该意识到,因为clone和readObject方法在行为上非常类似于构造器,所以类似的限制规则也是适用的:无论是clone还是readObject,都不可以调用可覆盖的方法。对于readObject方法,覆盖版本的方法将在子类的状态被反序列化前先被运行;而对于clone方法,覆盖版本的方法则在子类的clone方法调用来修正 被克隆对象的状态之前先被运行(即父类的clone方法调用了覆盖的方法,而子类的clone却还没调用,这样就可能没有初始化这个覆盖方法所依赖的数据)。
对于普通的具体类应该怎么办?他们即不是final的,也不是为了子类化而设计和编写文档的,所以这种状况很危险。这个问题的最佳解决方案是,对于那些并不能安全进行子类化又没有编写文档的类,要禁止子类化。禁止子类化有两种方法,一种就是定义成final的,另一种就是私有构造器,交提供静态工厂方法,可参考第15条。
这条建议可能会引来争议,因为许多程序员已经习惯了对普通的具体类进行子类化,以便增加新的功能。如果这个普通类实现了某个接口,比如Set、List或者Map,就完全可以禁止该普通类可子类化,因为即使禁止了,我们可以采第16条里的组合与转发即包装模式来提供一种更好的办法;如果具休的类并没有实现标准的接口,那么禁止继承可能会给有些程序员带来不便,如果认为必须允许从这样的类继承,一种合理的办法是确保这个类永远不会自己调用自已的任何可覆盖的方法,并在文档中说明这一点,换句话说,完全消除这个类中可覆盖方法的自用特性,这样做后就可以创建“能够安全地进行子类化”的类。
当然,你可以使用一种机械的做法来消除可覆盖方法的自用特性,而不改变它的功能行为。就是直接将被调用的可覆盖方法的实现代码Copy到另外可覆盖方法中,让它们不相互依赖调用,那么此时子类就可以继承并覆盖这些方法了。
18、 接口优于抽象类
如果这些方法尚不存在,你所需要做的就是只是增加必要的方法,然后在类的声明中增加一个implements子句,实现抽象方法即可。但一般来说,你无法直接修改当前类来扩展新的抽象类,此时你只能将抽象类从继承树的最底向上移到最顶层,
实现新的接口,现有类可以很容易被更新:从代码重构的角度上讲,将一个单独的Java具体类重构成一个实现某个Java接口是很容易的,只需要声明一个Java接口,并将重要的方法添加到接口声明中,然后在具体定义语句后面加上一个合适的implements关键字即可, 例如,当Comparable接口被引入到Java平台中时,会更新许多现有的类,以实现Comparable接口;而为一个具体类添加一个抽象类作为抽象类型却不是那么容易,因为这个具体类可能已经有了一个超类。这样一来,这样新定义的抽象类只好继续向上移动,变成这个超类的超类,这样,最后这个新定义的抽象类必定处于整个类型等级结构的最上端,从而使等级结构中的所有成员都会受到影响,无论这个抽象类对于这些后代类是否合适。
接口是定义混合类型的理想选择:混合类型就是类除了实现或继承了它的“基本类型(也可叫主要类型)”之外,还可以实现这个混合类型,以表明它提供了某些可供选择的行为。例如,Comparable就是一个混合接口,它允表明实现了Comparable混合接口的实例可以与其他的可相互比较的对象进行排序。抽象类是不能被用于定义混合类型的,同样也是因为它们不能被添加到现有的类中:类不可能有一个以上的父类,类层次结构中也没有适当的地方来插入这种混合类型。
接口允许我们构造非层次结构的类型框架:类型层次对于组织某些事物是非常合适的,但是其他有些事物并不能被整齐地组织成一个严格的层次结构。例如,假设我们有一个接口代表一个Singer(歌唱家),另一个接口代表一个SongWriter(作曲家):
public interface Singer{
AudioClip sing(Song s);
}
public interface SongWriter{
Song compose(boolean hit);
}
在现实生活中,有些歌唱家本身也是作家。因为我们这里使用了接口而不是抽象类来定义这些类型,所以对于单个类而言,它同时实现Singer和SongWriter是完全允许的。实际上,我们可以定义第三个接口,它同时扩展了Singer和SongWriter,并添加了一些适合于这种组合的新方法:
public interface SingerSongwriter extends Singer,SongWriter{
AudioClip strum();
void actSensitive();
}
你也许并不总需要这种灵活性,但是一旦你这样做了,接口可就成了救世主,能帮助你解决大问题,因为这种非层关系的类我们可以自由的组合。这里另外一种做法是编写一个臃肿的类层次,对于每一种要被支持的属性组合,都包含一个单独的类。如果在整个类型系统中有n个属性,那么就必须支持2^n种可能的组合,这种现象被称为“组合爆炸”,类层次的臃肿会导致类也臃肿,这些类包含许多方法,并且这些方法只是在参数类型上有所不同而已,因为类层次中没有任何类型体现了公共的行为特征。所以这里如果使用抽象类来定义的话显示是不合适的,
虽然接口不允许包含方法的实现,但是,使用接口来定义类型并不妨碍你为程序提供实现上的帮助。通过对你导出的每个重要接口都提供一个抽象类,将公用的方法实现,把接口和抽象类的优点结合起来。接口的作用仍然是定义类型,但是抽象类接管了所有与接口实现相关的工作。
按照惯例,我们一般将提供的抽象类命名为AbstractInterface这种形式,这里的Interface是指所实现的接口的名字。例如,集合框架为每个重要的集合接口都提供了一个骨架实现,如AbstractCollection、AbstractSet、AbstractList和AbstractMap。再来看看这些集合类的定义:
public class ArrayList extends AbstractList implements List{}
public class HashSet extends AbstractSet implements Set{}
public class HashMap extends AbstractMap implements Map{}
如果设计得当,抽象类可以使用程序员很容易就提供他们自己的接口实现。例如,下面是一个静态工厂方法,它包含一个完整的、功能全面的List实现:
public class IntArrays {
static List<Integer> intArrayAsList(final int[] a) {
if (a == null)
throw new NullPointerException();
return new AbstractList<Integer>() {
public Integer get(int i) {
return a[i]; // Autoboxing (Item 5)
}
@Override public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // Auto-unboxing
return oldVal; // Autoboxing
}
public int size() {
return a.length;
}
};
}
public static void main(String[] args) {
int[] a = new int[10];
for (int i = 0; i < a.length; i++)
a[i] = i;
List<Integer> list = intArrayAsList(a);
Collections.shuffle(list);
System.out.println(list);
}
}
编写抽象类相对比较简单,只是有点单调乏味。首先,必须认真研究接口,并确定哪些方法是最为基本的,其他的方法则可以根据它们来实现。这些基本的方法将成为抽象类中的抽象方法。然后,必须为接口中所有其他的方法提供具体的实现。例如,下面是Map.Entry接口的抽象类:
public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {
// 基本操作,定义为抽象的
public abstract K getKey();
public abstract V getValue();
// 可修改的Map中的实体Entry需要实现
public V setValue(V value) {
throw new UnsupportedOperationException();
}
// 实现共(通)用接口Map.Entry.equals
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (! (o instanceof Map.Entry))
return false;
Map.Entry<?,?> arg = (Map.Entry) o;
return equals(getKey(), arg.getKey()) &&
equals(getValue(), arg.getValue());
}
private static boolean equals(Object o1, Object o2) {
return o1 == null ? o2 == null : o1.equals(o2);
}
// 实现共(通)用接口Map.Entry.hashCode
@Override public int hashCode() {
return hashCode(getKey()) ^ hashCode(getValue());
}
private static int hashCode(Object obj) {
return obj == null ? 0 : obj.hashCode();
}
}
抽象类的唯一优点就是抽象类的演变比接口的演变要容易得多:如果在后续的发行版本中,你希望在抽象类中增加新的方法,你始终可以增加具体方法,如果向一个抽象类加入一个新的具体方法,那么所有的子类一下子就都得到了这个新的具体方法,而Java接口做不到这一点,如果向一个Java接口加入一个新方法的话,所有实现这个接口的类就不能通过编译了,因为它们都没有实现这个新声明的方法。这显然是Java接口的一个缺点。
由于Java抽象类具有提供缺省实现的优点,而Java接口具有其他所有的优点,所以联合使用就是一个很好的选择。如果一个具体类直接实现这个Java接口的话,它就必须自行实现所有的接口;相反,如果它继承自抽象类的话,它可以省去一些不必要的方法,因为它可以从抽象类中自动得到这些方法的缺省实现。如果需要向Java接口加入一个新的方法的话,那么只要同时向这个抽象类加入这个方法的一个具体实现就可以了,因为所有继承自这个抽象类的子类都会从这个抽象类得到这个具体方法。这其实就是一种缺省适配器模式。一般来说,要想在公开的接口增加方法,而不破坏实现这个接口的所有现有类,这是不可能的,除非像上面说的那样,一开始就让某个类实现接口的时候,也继承抽象类,但这是不完全可能的,所以不从抽象类继承的接口实现类仍然会无法编译。
因此,设计公有接口要非常谨慎,接口一旦被公开发行,并且已被广泛实现,再想改变这个接口几乎是不可能的。你必须在初次设计的时候就保证接口是正确的。如果接口包含即使微小的瑕疵,它将会一直影响接口用户。如果接口具有严重缺陷,它可以导致API彻底的失败。
简而言之,接口通常是定义允许多个实现的类型的最佳途径。这条规则有个例外,即当演变的容易性比灵活性和功能更为重要的时候,在这种情况下,应该使用抽象类来定义类型,但前提是必须理解并且可以接受抽象类的局限性。如果你导出一个重要接口,就应该坚决考虑同时提供一个抽象类。最后,应该尽可能谨慎地设计所有公有接口,一旦发行,将不可更改。
19、 接口只用于定义类型
当类实现接口时,接口就充当可以引用这个类的实例的类型。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的而定义接口是不恰当的。
有一种接口称为常量接口,它违反了上面的条件,这种接口不包含任何方法,它只包含静态的final域,每个域都导出一个常量。使用这些常量的类实现这个接口,以避免用类名来修饰常量名:
public interface PhysicalConstants {
static final double AVOGADROS_NUMBER = 6.02214199e23;
}
class Sub implements PhysicalConstants {
public static void main(String[] args) {
System.out.println(AVOGADROS_NUMBER);
}
}
常量接口模式是对接口的不良使用。类在内部使用某些常量,这纯粹是实现细节。实现常量接口,会导致把这样的实现细节泄露到该类的导出API中。类实现常量接口没有什么价值。如果在将来的发行版本中,这个类被修改了,它不再需要使用这些常量了,它依然必须实现这个接口,以确保二制兼容性。如果非final类实现了常量接口,它的所有子类的命名空间也会被接口中的常量所“污染”。Java平台类库中有几个常量接口,例如java.io.ObjectStreamConstants,被认为是反面例子,不值得效仿。
如果要导出常量,可以有几种合理的方案。如果这些常量与某个现有的类或者接口紧密相关,就应该把这些常量添加到这个类或者接口。例如,在Java平台类库中所有的数值包装类,如果Integer和Double,都导出了MIN_VALUE和MAX_VALUE常量。如果这些常量最好被看作枚举类型的成员,就应该使用枚举类型(见第30条)来导出这些常量。否则,应该使用不可实例化的工具类(见第4条)来导出这些常量,下面是前面的PhysicalConstants例子的工具类翻版:
public class PhysicalConstants {
private PhysicalConstants() { } // 私有构造器
public static final double AVOGADROS_NUMBER = 6.02214199e23;
}
如果大量利用工具类导出的常量,可以通过利用静态导入机制,避免用类名来修饰常量,不过,静态导入机制是在1.5中才引用的。
简而言之,接口应该只被用来定义类型,它们不应该被用来导出常量。
20、 类层次优于标签类
有时候,可能会遇到带有两种甚至更多风格(功能)的实例的类,并包含表示实例风格的标签域,例如:
class Figure {
enum Shape { RECTANGLE, CIRCLE };
// 标签域 - 是圆形还是长方形
final Shape shape;
// 这两个域仅用于长方形
double length;
double width;
// 这个域仅用于圆形
double radius;
// 圆形构造器
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
// 长方形构造器
Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
// 求面积
double area() {
//不同的形状有不同的算法
switch(shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError();
}
}
}
这种标签类有着许多缺点。它们中充斥着样板代码(同一时刻只用到一种功能),包括枚举声明、标签域以及条件语句。破坏了可读性,内存占用也增加,因为实例承担着属于其他风格的不相关的域。总之,标签类过于冗长、容易出错、并且效率低下。
使用子类化能更好地设计这个类。为了将标签转变成类层次,首先要为每个依赖于标签的方法都定义成一个抽象方法并放在抽象类中。在Figure类中,只有一个这样的方法:area。这个抽象类是类层次的根。如果还有其他的方法其行为不依赖于标签域,就应该把这样的方法放在这个抽象类中。同样地,如果所有的方法都用到了某些数据域,应该把它们放在这个抽象类中。不过,在Figure类中,不存在这种类型独立的方法或者数据域,以下是重构:
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
private final double radius;
Circle(double radius) { this.radius = radius; }
double area() { return Math.PI * (radius * radius); }
}
class Rectangle extends Figure {
private final double length;
private final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
double area() { return length * width; }
}
所有的域都是final的,编译器确保每个类的构造器都初始化它的数据域,对于根类中声明的每个抽象方法,都确保有一个实现,这样杜绝了由于遗漏swithc case而导致运行时失败的可能性。这个类层次纠正了前面提到过的标签类所有缺点。
类层次的另一好处在于,它们可以用来反映类之间本质上的层次关系,有助于后期的扩充。假设现有加一个正方形有,标签类就需要修改源码,而利用类层次结构只需新加一个正文型类,并继承自长方形,这样可以反映出它是一种特殊的长方性:
class Square extends Rectangle {
Square(double side) {
super(side, side);
}
}
简而言之,标签类很少有适用的时候,当你想使用的时,这个标签是否可以被取消,这个类是否可以用类层次来代替。当你遇到一个包含标标签域的现有类时,就要考虑将它重构到一个层次结构中去。
21、 用函数对象表示策略
Java没有提供函数指针,但是可以用对象引用实现同样的功能。
考虑下面的类:
public class StringLengthComparator {
public int comare(String s1, String s2) {
return s1.length() - s2.length();
}
}
它是一个字符长度比较器,只有这样的一个比较功能的方法,如果一个类仅仅导出某个特定功能的方法,它的实例实际上就等同于一个指向该方法的指针,这样的实例被称为函数对象。这是一种策略的应用,换句话说,StringLengthComparator实例是用于字符串比较操作的具体策略对象。
作为典型的具体策略类,StringLengthComparator类是无状态的:它没有域,所以,这个类的所有实例在功能上都是相互等价的。因此,它作一个Singleton是非常合适的,可以节省不必要的对象创建开销(见第3与第5条):
public class StringLengthComparator {
private StringLengthComparator(){}
public int comare(String s1, String s2) {
return s1.length() - s2.length();
}
}
为了把StringLengthComparator实例传递给方法,需要适当的参数类型,直接使用StringLengthComparator并不好,因为客户端将无法传递任何其他的比较策略,即不能随时动态的改变比较性为。此时,我们可以定义一个比较策略接口,这个接口在java.util包就已提供,我们不需要再提供,我们直接实现它:
import java.util.Comparator;
public class StringLengthComparator implements Comparator<String> {
private StringLengthComparator() {}
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
具体的策略类往往使用匿名类(见第22条)来声明,下面的语句根据长度对一个字符串数组进行排序:
Arrays.sort(strArr,new Comparator<String>(){
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}});
但是注意,以这种方式使用匿名类时,将会在每次执行调用的时候创建一个新的实例。如果它被重复执行,考虑将函数对象存储到一个私有的静态final域时重复使用它。
因为策略接口被用做所有具体策略实例的类型,所以我们并不需要为了导出具体策略,而把具体策略类做成公有的。我们可以把具体策略类定义成静态的内部类,让外部类直接导出公有的静态策略对象域(或定义成私有的后通过静态的工厂方法返回),其类型为策略接口。下面的例子使用静态成员类,而不是匿名类,这样允许我们的具体的策略类实现第二个接口Serializable:
public class Outer {
private static class StrLenCmp implements Comparator<String>, Serializable {
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
public static final Comparator<String> STRING_LEN_CMP = new StrLenCmp();
//...
}
这新我们很容易改变比较的策略算法,比如要导出一个不区分大小定的字符串比较器就很容易了,只要提供的接口不变。
简而言之,函数的主要用途就是实现策略模式。为了在Java中实现这种模式,要声明一个接口来表示该策略,并且为每个具体策略声明一个实现了该接口的策略类。当一个具体策略只被使用一次时,通常使用匿名类来声明和实例化这个具体匿名策略类。当一个具体策略是设计用来重复使用的时候,它的类通常要被实现为私有的静态成员类,并通过公有的静态final域被导出,其类型为该策略接口。
22、 四种嵌套类中优先考虑静态成员类
嵌套类是指被定义在一另一个类的内部的类。它的目的只为外围类提供服务,如果将来它可能会单独用于其他的某个环境中,它应该是顶层类。嵌套类有四种:静态成员类、非静态成员类、匿名类、局部类,除了第一种,其他三种被称为内部类,静态与非静态成员类又称为成员类。
静态成员类的一种常见用法是作为公有的辅助类,仅当与它的外部类一起使用时才有意义。例如,这样的一个枚举类,它描述了计算器支持的各种操作(见30条)。Operation枚举应该是Calculator类的公有静态成员类,然后,Calculator类的客户端就可以采用如Calculator.Operation.PLUS和Calculator.Operation.MINUS这样的名称来引用这些操作。
如果嵌套类的实例可以在它外围类的实例之外独立存在,这个嵌套类就必须是静态成员类:在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的。
非静态成员类一种常见用法是定义一个Adapter(适配器,将Map集合转换成Set或Collection接口),它允许外部类的实例被看作是另一个不相关的类的实例。例如,Map接口的实现往往使用非静态成员类来实现它们的集合视图,这些集合视图由Map的entrySet、keySet、values方法返回的,同样,如Set和List这种集合接口的实现往往也使用非静态成员类来实现它的迭代器,下面看看HashMap的组成部分:
public class HashMap extends AbstractMap implements Map, Cloneable, Serializable{
//静态实体组件类
static class Entry implements Map.Entry {
final Object key;
Object value;
final int hash;
Entry next;
//...
}
//HashMap的抽象迭代器,迭代出的是一个个Entry。专用来被key、value迭代器继承
private abstract class HashIterator implements Iterator {
//...
Entry nextEntry() {
//...
}
}
// 对Map中的value进行迭代
private class ValueIterator extends HashIterator {
public Object next() {
return nextEntry().value;
}
}
// 对Map中的key进行迭代
private class KeyIterator extends HashIterator {
public Object next() {
return nextEntry().getKey();
}
}
//Entry迭代器
private class EntryIterator extends HashIterator {
public Object next() {
return EntryIterator ();
}
}
// 下面的集合视图都是建立在上面迭代器之上的
// Key视图
private class KeySet extends AbstractSet {
public Iterator iterator() {
return KeyIterator ();
}
//...
}
//value视图
private class Values extends AbstractCollection {
public Iterator iterator() {
return ValueIterator ();
}
// ...
}
如果声明成员类不要求访问外围实例,就应加上static修饰符。如果省略了,则每个实例都将包含一个额外指向外围对象的引用,保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收(见第6条)时却仍然得到保留。
私有静态成员类的一种常见用法是用来代表外围类所代表的对象的组件。Map实现的内部都有一个Entry类,表示Map中的每个健-值对。虽然每个entry都与一个Map关联,但是entry上的方法(getKey、getValue和setValue)并不需要访问该Map,因此,使用非静态成员来表示entry是很浪费的:私有的静态成员类是最好的选择。如果漏掉了Entry声明成static,该Map仍可以工作,但是每个entry中将会包含一个指向Map的引用,这样就浪费空间和时间了。
非静态的内部类中不能定义static成员,但可以是定义final static成员。因为一个成员类实例必然与一个外部类实例关联,这个static定义完全可以移到其外围类中去。
当且仅当匿名类出现在非静态环境中时,它才有外围实例。但是如果它出现在静态的环境中,如静态的方法中时,就没不会指向外围类对象。而且匿名类不能包含静态的非final成员。
匿名类的适用性受到很多的限制,除了在它们声明的同时实例化外,不可以在其他地方实例化。你不能执行instanceof测试,或者做任何需要命名类的其他事情。你无法声明在一个匿名类上再实现一个或多个接口,或者是继承一个类。匿名类的客户端无法调用到它里面定义的父类中没有成员,而只能是访问到父类或接口中的成员,因为客户端引用父类类型。另外,由于匿名类出现在表达式中,它们必须保持简短——大约10行或更少——否则会影响程序的可读性。
匿名类的一种常见用法是动态地创建函数对象(见第21条),与策略接口或回调接口一起使用;匿名类的另一种常见用法是创建过程对象,比如Runnable、Thread或者TimerTask实例;第三种常见的用法是在静态工厂方法的内部(见第18条中的intArrayAsList方法)。
局部类是四种嵌套类中用得最少的类。在任何“可以声明局部变量”的地方,都可以声明局部类,并且也只能在当前作用域类使用。局部类与其他三种嵌套类中的每一种都有一些共同点:与成员类(静态与非静态成员类)一样,局部类有名字,可以被重复地使用;与匿名类一样,只有当局部类是在非静态环境中定义的时候,才有外围实例,与匿名类一样局部类中都不能定义静态的非final成员。与匿名类一样,它们也必须非常简短,以便不会影响到可读性。下面是静态与非静态环境中的局部类,它们是否拥有指向外围类的引用:
public class Outer {
static void f() {//静态方法中的内部类没有外围对象引用
class Inner {
static final int i = 1;
}
}
void g() {
class Inner {}
}
}
使用javap反编译后如下:
class Outer$1Inner extends java.lang.Object{
static final int i;
Outer$1Inner();
}
class Outer$2Inner extends java.lang.Object{
final Outer this$0; //非静态方法中的内部类有外围对象引用
Outer$2Inner(Outer);
}
静态方法中的局部类为静态的,没有指向外围类实例,非静态方法中的局部类有指向外围实例的引用(从上面反编译可以看出)。
另外,不管是在静态的还是非静态的环境中定义的局部或匿名类,都不是static的(所以为什么局部或匿名类不能定义静态的非final成员了),或者更严格的讲,没有静态的局部或匿名类,因为局部环境中是不可以使用static修饰的。
简而言之,四种不同的嵌套类,都有自己的用途。如果一个嵌套类需要在单个方法之外仍然是可见的,或者它太长了,不适合于放在方法内部,就该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,就要把成员类做成非静态的,否则做成静态的。假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例,并且已经有了一个预置的类型接口可以说明这个类的特征,就要把它做成匿名类,否则,就做成局部类吧。
第五章 泛型
23、 请不要在新代码中使用原生态类型
声明中具有一个或者多个类型参数的类或者接口,就是泛型类或者泛型接口。泛型类和接口统称为泛型。
每种泛型可以定义一种参数化的类型,格式为:先是类或者接口的名称,接着用尖括号(<>)把对应于泛型的类型参数的实际类型参数列表括起来。
每个泛型都定义一个原生态类型,即不带任何实际类型参数的泛型名称,也是没有泛型之前的类型。
泛型能将运行时期的错误提前到编译时期检测。
如果使用原生态类型,就失掉了泛型在安全性和表述性方面的所有优势。既然不应该使用原生态类型,为什么Java设计还要允许使用它们呢?这是为了提供兼容性,要兼容以前没有使用泛型的Java代码。
原生态类型List和参数化的类型List<Object>之间到底有什么区别呢?不严格地说,前者逃避了泛型检查,后者则明确告知编译器,它能够持有任意类型的对象。泛型有子类型化的规则:List<String>是原生态类型List的一个子类型,而不是参数化类型List<Object>的子类型(见25条)。因此,如果用不用像List这样的原生态类型,就会失掉类型安全性,但是如果使用像List<Object>这样的参数化类型,则不会。
在无限制通配类型Set<?>和原生态类型Set之间有什么区别呢?由于可以将任何元素放进使用原生态类型的集合中,因此很容易破坏该集合的类型约束条件;但不能将任何元素(除了null之外)放到Collection<?>中。
“不要在新代码中使用原生态类型”,这条规则有两个例外,这是因为“泛型信息在运行时就会被擦除”。在获取类信息中必须使用原生态类型(数组类型和基本类型也算原生态类型),规范不允许使用参数化类型。换句话说:List.class,String[].class和int.class都是合法,但是List<String>.class和List<?>.class都是不合法的。这条规则的第二个例外与instanceof操作符有关,由于泛型信息在运行时已被擦除,因此在参数化类型而不是无限制通配符类型(如List<?>)上使用instanceof操作符是非法的,用无限制通配符类型代替原生态类型,对instanceof操作的行为不产生任何影响。在这种情况下,尖括号<>和问号?就显得多余了。下面是利用泛型来使用instanceof操作符的首先方法:
if(o instanceof set){
Set<?> m = (Set<?>)o;
// ...
}
注意,一旦确定这个o是个Set,就必须将它转换成通配类型Set<?>,则不是转换成原生态类型Set,否则Set会引起编译时警告。
总之,使用原生态类型会在运行时导致异常,因此不要在新代码中使用。原生态类型只为了与引入泛型之前的遗留代码进行兼容和互用而提供的。另外Set<Object>是个参数化类型,表示可以包括任何对象类型的一个集合;Set<?>则是一个通配符类型,表示只能包含某种未知对象类型的一个集合;Set则是个原生态类型,它脱离了泛型系统。前两者是安全的,最后一种不安全。
术语介绍:
原生态类型:List
参数化的类型:List<String>
泛型:List<E>
有限制类型参数:List<E extends Number>
形式类型参数:E
无限制通配符类型:List<?>
有限制通配符类型:List<? extends Number>
递归类型限制:List <T extends Comparable<T>>
泛型方法: static<E> List<E> asList(E[] a)
24、 消除非受检警告
用泛型编程时,会遇到许多编译器警告:非受检强制转换警告、非受检方法调用警告、非受检普通数组创建警告,以及非受检转换警告。
要尽可能地消除每一个非受检警告。如果消除了所有警告,就可以确保代码是类型安全的。
如果无法消除警告,同时可以证明引起警告的代码是类型安全的,只有在这种情况下才可以用一个@SuppressWarnings("unchecked")注解来禁止这条警告。
SuppressWarnings注解可以用在任何粒度的级别中,从单独的局部变量到整个类都可以。应该始终在尽可能小的范围中使用SuppressWarnings注解。它通常是个变量声明,或者是非常简短的方法或者构造器。永远不要在整个类上使用SuppressWarnings,这么做可能会掩盖了重要的警告。
总而言之,非受检警告很重要,不要忽略它们。每一条警告都表示可能在运行时抛出ClassCastException异常。要尽最大的努力消除这些警告。如果无法消掉同时确实是类型安全的,就可以在尽可能小的范围中,用@SuppressWarnings("unchecked")注解来禁止这条警告。要用注释把禁止该警告的原因记录下来。
25、 列表优先于数组
数组与泛型相比,有两个重要的不同点:首先,数组是协变的,如Sub为Super的子类型,那么数组类型Sub[]就是Super[]的子类型。但泛型则是不可变的,对于任意两个不同的类型Type1和Type2,List<Type1>与List<Type2>没有任何父子关系。
下面的代码片段是合法的:
Object[] objectArray = new Long[1];
objectArray[0]= "";//运行时抛异常
但下面这段代码则在编译时就不合法:
List<Object> ol = new ArrayList<Long>();//编译时就不能通过
ol.add("");
利用数组,你会在运行时才可以发现错误,而利用列表,则可以在编译时发现错误。而我们最好是编译时发现错误,及早的处理它。
数组与泛型之间的第二大区别在于,数组是具体化的[JLS,4.7]。因此数组会在运行时才知道并检查它们的元素类型约束。相比,泛型则是通过擦除[JLS,4.6]来实现的。因此泛型只在编译时强化它们的类型信息,并在运行时丢弃它们的元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用。
由于上述这些根本的区另,因此数组和泛型不能很好混合使用。例如,创建泛型、参数化类型或者类型参数的数组是非法的,如:new List<E>[]、new List<String>[]、new E[]都是非法的。
为什么不允许创建泛型数组呢?看具体例子:
List<String>[] stringLists= new List<String>[1];//1
List<Integer> intList = Arrays.asList(42); //2
Object[] objects = stringLists; //3
objects[0] = intList; //4
String s = stringLists[0].get(0); //5
这里首先假设第一行可以,其他行本身编译是没有问题的,但运行到5行时肯定会抛出ClassCastException异常。为了防止出现这种情况,创建泛型数组第1行就不允许了。
从技术角度说,像List<Strign>、List<E>、E这样的类型应称作为不可具体化的类型[JLS,4.7]。直观地说,不可具体化的类型是指其运行时表示法包含的信息比它的编译时表示法包含的信息更少的类型。唯一可具体化的参数化类型是无限制的符类型,如List<?>和Map<?,?>(Map<?,?>[] maps = new Map<?,?>[1];),虽然不常用,但是创建无限制通配类型的数组是合法。
当你得到泛型数组创建错误时,最好的解决办法通常是优先使用集合类型List<E>,而不是E[]。这样可以会损失一些性能或者简洁性,但是挽回的是更高的类型安全性和互用性。
总之,数组和泛型有着非常不同的类型规则。数组是协变且可以具体化的,泛型是不可变的且可以被擦除的。因此,数组提供了运行时的类型安全,但是没有编译时的类型安全,反之,对于泛型也一样。一般来说,数组和泛型不能很好地混合使用。如果你发现自己将它们混合起来使用,并且得到了编译时错误或者警告,你的第一反应就应该是用列表来代替数组。
26、 优先考虑泛型
考虑第6条中的堆栈实现,将它定义成泛型类。第一种是将elements定义成类型参数数组:
public class Stack<E> {
private E[] elements;//定义成类型参数数组
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// 通过pust(E)我们只能将E类型的实例放入到elements中,这已充分确保类型
// 安全,所以这里可以强转。但是运行时数组的类型不是E[],它仍然是Objct[]类型的!
@SuppressWarnings("unchecked")
public Stack() {
//elements =new E[DEFAULT_INITIAL_CAPACITY];//不能创建类型参数数组
/*
* 编译器不可能证明你的程序是类型安全的,但是你可以证明。你自己必须确保未受
* 检的转换不会危及到程序的类型安全。因为elements保存在一个私有的域中,永远
* 不会返回到客户端。或者传给任何其他方法。这个数组中保存的唯一元素,是传给
* push方法的那些元素,它们的类型为E,因此未受检的转换不会有任何危害。一旦
* 你证明了未受检的转换是安全的,就要在尽可能小的范围中禁警告。然后你就可以
* 使用的它了,无需显示转换,也不需担心会出ClassCastException异常。
*/
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];//这里会有
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
elements[size] = null; // 解除过期引用
return result;
}
//...
}
第二种是将elements域的类型从E[]改为Object[]:
public class Stack<E> {
private Object[] elements;//直接定义成Object[]类型数组
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
//这里就不需要转型了
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
// Appropriate suppression of unchecked warning
public E pop() {
if (size == 0)
throw new EmptyStackException();
// 放入栈中的元素类型一定是E类型的,所以转换是没有问题的!
@SuppressWarnings("unchecked")//将@SuppressWarnings尽量应用到最小的范围上
E result = (E) elements[--size];//转型会移到这里,但会有警告
elements[size] = null; // 解除过期引用
return result;
}
// ...
}
总之,使用泛型比使用需要在客户端代码中进行转换的类型来得更加安全,也更加容易。在设计新类型时,要确保它们不需要这种转换就可以使用。这通常意味着要把类做成是泛型的,只要时间允许,就把现有的类型都泛型化。这对于这些类型的新用户来说会变得更加轻松,又不会破坏现有的客户端。
27、 优先考虑泛型方法
静态工具方法尤其适合泛型方法。Collections工具类中的所有算法方法都泛型化了。
public class Union {
// 泛型方法
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<E>(s1);
result.addAll(s2);
return result;
}
}
union方法的局限性在于,三个集合的类型(两个输入参数和一个返回值)必须全部相同。利用有限制的通配符类型,可以使这个方法变得更加灵活。
泛型方法的一个显著特性是,无需明确指定类型参数的值,不像调用泛型构造器的时候是必须指定的。编译器通过检查方法参数的类型来计算出类型参数的值。对于上面两个参数都是Set<String>类型,因此知道类型参数E必须为String,这个过程称作为类型推导。
可以利用泛型方法调用所提供的类型推导,使用创建参数化类型实例的过程变得更加轻松。下面的写法有点冗余:
Map<String, List<String>> anagrams = new HashMap<String, List<String>>();
为了消除这种冗余,可以编写一个泛型静态工厂方法,与想要的每个构造器相对应,如:
// 静态的泛型工厂方法
public static <K, V> HashMap<K, V> newHashMap() {
return new HashMap<K, V>();
}
通过这个泛型静态工厂方法,可以用下面这段简洁的代码来取代上面那个冗余的行:
// 使用泛型静态工厂方法来创建参数化的类的实例
Map<String, List<String>> anagrams = newHashMap();
在泛型上调用构造器时,如果语言本身支持类型推导与调用泛型方法时所做的相同,那就好了,将来也许可以,但现在还不行。
递归类型限制最普遍的用途与Comparable接口有关,例如在集合中找最大的元素泛型静态方法:
public static <T extends Comparable<T>> T max(List<T> list){
Iterator<T> i = list.iterator();
T result = i.next();
while(i.hasNext()){
T t = i.next();
if(t.compareTo(result)>0){
result = t;
}
}
return result;
}
总之,泛型方法就像泛型一样,使用起来比要求客户端转换输出参数并返回值的方法来得更加安全,也更加容易,就像类型一样,你应该确保新的方法可以不用转换就能使用,这通常意味着要将它们泛型化。
28、 利用有限制通配符来提升API的灵活性
现在在前面前面第26条中的Stack<E>中加上以下方法,以便将某个集合一次性放入到栈中:
public void pushAll(Iterable<E> src) {
for (E e : src)
push(e);
}
这样使用:
Stack<Number> numberStack = new Stack<Number>();//1
Iterable<Integer> integers = Arrays.asList(3, 1, 4, 1, 5, 9); //2
numberStack.pushAll(integers); //3
按照上面的写法,我们是无法将Integer类型的元素放入Number类型的栈中,这是不合理的,因为Integer为Number的子类。原因是第一行执行完后,pushAll方法中的类型参数E就会固定为Number类型,这就好比要将Iterable<Integer>赋值给Iterable<Number>一样,这显然是不可以的,因为Iterable<Integer>不是Iterable<Number>的子类型。这样就显得缺少灵活性了,幸好限制通配符类型可以帮我们解决:
// 限制通配符意味这这里的元素只少是E类型
public void pushAll(Iterable<? extends E> src) {
for (E e : src)
push(e);
}
经过上面的修改后src不只是可以接受E类型的Iterable,还可以接受E的子类型Iterable,而Integer恰好为Number的子类型,所以现在第3行可以正常编译与运行。
注:List<? extends E> src这种集合只能读不能写,即只能传进参数(如add方法),而不能返回E类型元素(如get方法),当然不带泛型类型参数与返回泛型类型的方法是可以随便调用的,比如size();
完成上面的pushAll方法的后,我们现在想编写一个popAll对应的方法,它从栈中弹出每个元素,并将这些元素到传进去参数集合中,下面如果这样设计:
public void popAll(Collection<E> dst) {
while (!isEmpty())
dst.add(pop());
}
应用代码如下:
Collection<Object> objects = new ArrayList<Object>();//1
numberStack.popAll(objects);//2
System.out.println(objects);
很不幸的是,第2行根本无法编译通过,按理来说我们完全可以将Number类型的元素放入到一个Object类型参数的集合中,可这里失败了,这不是不可以,是我们设计的错误。这里失败的原因与上面一样,是由于numberStack的类型为Number,所以popAll的类型参数固定为Number,因而不能将一个Collection<Object>赋值给Collection<Number>,因为Collection<Object>不是Collection<Number>的子类型。像上面一样,也有这样一样限制通配类型来解决这个问题:
public void popAll(Collection<? super E> dst) {
while (!isEmpty())
dst.add(pop());
}
现在dst不只是可以接受E类型的Collection了,而且还可以接受E的父类型Collection,这里的Object为Number的父类,所以这里可以正常运行。
注:List<? super E> dst这种集合只能写不能读,即只能写入元素(如调用add方法)而不能读取元素(如调用get),当然不带泛型类型参数与返回泛型类型的方法是可以随便调用的,比如size();
XXX<? extends T> x:使用<? extends T>定义的引用x,x可以用来接受类型参数为T及T的所有子类类型的实例,但通过这个引用调用x上的泛型方法时有一定的限制:如果方法带泛型参数,则不能调用,因为真真实例方法的类型参数类型完全有可能比你传进这个泛型方法的参数的类型要窄,一个子类型的引用是不能接受一个父类类型引用的,所以不能通过这种限制通配类型定义的引用x来调用一切带泛型参数的方法;但你可以调用那此具有返回类型为泛型类型的方法,因为不管真真实例的类型如果,它们都是T的子类,所以此时的返回类型只少是T类型;最后如果方法不带泛型类型参数,则是可以随便调用的。总之,这种限制通配类型一般用于从集合中读取操作。
XXX<? super T> x:使用<? super T>定义的引用x,x可以用来接受类型参数为T及T的所有父类类型的实例,但通过这个引用调用x上的泛型方法时有一定的限制:如果方法的返回类型为泛型类型,则接收这个返回类型的变量类型不能是T,而只能是以Object类型变量来接收,因为方法的实际返回的类型完全有可能比方法定义的返回类型T要宽,但我们又不知道究竟比T宽多少,你总不能将Object类型对象赋值给T类型的引用吧,所以通过这种限制通配类型定义的引用x来调用返回类型为泛型参数的方法时会失去类型限制;但你可以调用方法参数类型为泛型类型的方法,因为现在方法的实例类型至少为T或比T要宽,所以可以接收T及T子类类型参数;最后如果方法的返回类型不为泛型类型参数时,则也是可以随便调用的。总之,这种限制通配类型一般用于将元素写入集合。
不要用符类型作为返回类型,除了为用户提供额外的灵活性外,它还会强制用户在客户端代码中使用通配符类型。
将第27条的union方法修改一下,让它能同时接受Integer与Double集合,由于这两个集合是用来读的,所以使用<? extends E>限制通配符:
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2) ;
如果有这以下两个集合:
Set<Integer> integers = new HashSet<Integer>();
Set<Double> doubles = new HashSet<Double>();
调用union(integers, doubles)时类型推断为<? extends Number>,所以以下编译不能通过:
Set<Number> nubers =Union.union(integers, doubles);
只能是这样显示的指定返回类型,而不使用类型推导:
Set<Number> nubers =Union.<Number>union(integers, doubles);
或者使用类型推导,则只能以通配类型来接受,因为Set<Number>并不是Set<Float>的父类:
Set<? extends Number> nubers = union(integers, doubles);
接下来,我们将第27条的max方法,让它更灵活,做如下修改:因为list只用来读取或生产元素(第1、2、4行都是从list中读,即只通过list直接或间接地调用过返回类型为泛型类型的方法,而没有去调用过参数为泛型类型的方法),所以从List<T>修改成List<? extends T>,让list可以接受T及其它的子类。而Comparable<T>应该修改成Comparable<? super T>,因为Comparable只是来消费T的实例(第5行属于消费,因为T的实例调用带有泛型类型参数的compareTo方法),传递进的参数要求是最宽的,这样可以确保compareTo中的参数能接受T及其T的子类类型:
public static <T extends Comparable<? super T>> T max(List<? extends T> list) {
//只能使用通配类型来接受,因为iterator()方法返回的为Iterator<E> 类型,又Iterator<Object>并不是Iterator<String>的父类,所以这里也需要修改一下
Iterator<? extends T> i = list.iterator();//1
//但这里不需要使用通配类型来接收,因为next()返回的类型直接就是类型参数E,而不像上面返回的为Iterator<E>泛型类型
T result = i.next();//2
while (i.hasNext()) {//3
T t = i.next();//4
if (t.compareTo(result) > 0) {//5
result = t;
}
}
return result;
}
假如现在有以下两个接口:
interface I1 extends Comparable<I1> {}
interface I2 extends I1 {}
如果上面不这样修改的话,下面第二行将不适用:
max(new ArrayList<I1>());
max(new ArrayList<I2>());
现在我们具体的分析一下上面代码:如果Comparable<T>不修改成Comparable<? super T>,第一行还是可正常运行,但是第二行则不可以,因为此时的T为I2,而I2又没有实现Comparable接口,而方法声明<T extends Comparable<T>>部分则要求I2直接实现Comparable接口,但父类I1实现了Comparable接口,I2又已经继承了I1,我们不需要再实现该接口,所以这里变通的作法是让Comparable<T>可以接收T及T的父类类型,所以修改成Comparable<? super T>即可,并且这样修改也符合前面的描述。
所以,使用时始终应该是Comparable<? super T>优先于Comparable<T>,对于comparator也一样,使用时始终应该是Comparator<? super T>优先于Comparator<T>。
类型参数和通配符之间具有双重性,许多方法都可以利用其中一个或者另一个进行声明。例如下面是可能的两种静态方法声明,来交换列表中的两个元素,第一个使用无限的类型参数,第二个使用的是无限的通配符:
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);
一般来说,如果类型参数只在方法声明中出现一次(即只在方法参数声明列表中出现过,而方法体没有出现),就可以用通配符取代它:如果是无限制的类型参数<E>,就用无限制的通配符取代它<?>;如果是有限制的类型参数<E extends Number>,就用有限制的通配符取代它<? extends Number>。
第二种实质上会有问题,下面是简单的实现都不能通过编译:
public static void swap(List<?> list, int i, int j) {
list.set(i, list.get(j));
}
原因很简单了,不再累述,但可以修改它,编写一个私有的辅助方法来捕捉通配符类型:
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.get(j));
}
不过,还可以将List<? >修改成List<? super Object>也可以:
public static void swap(List<? super Object> list, int i, int j) {
list.set(i, list.get(j));
}
总之,在API中使用通配符类型使API变得灵活多。如果编写的是一个被广泛使用的类库,则一定要适当地利用通配类型。记住基本原则:producer-extends,consumer-super(PECS)。还要记住所有的Comparable和Comparator都是消费者,所以适合于<? super XXX>。
PECS:如果参数化类型表示一个T生产者,就使用<? extends T>;如果它表示一个T是消费者,就使用<? super T>。
29、 优先考虑类型安全的异构容器
通过对泛型的学习我们知道,泛型集合一旦实例化,类型参数就确定下来,只能存入特定类型的元素,比如:
Map<K, V> map = new HashMap<K, V>();
则只能将K、V及它们的子类放入Map中,就不能将其他类型元素存入。如果使用原生Map又会得到类型安全检查,也许你这样定义:
Map<Object,Object> map = new HashMap<Object,Object>();
map.put("Derive", new Derive());
map.put("Sub", new Sub());
这样是可以存入各种类型的对象,虽然逃过了警告,但取出时我们无法知道确切的类型,它们都是Object,那么有没有一种这样的Map,即可以存放各种类型的对象,但取出时还是可以知道其确切类型,这是可以的:
public class Favorites {
// 可以存储不同类型元素的类型安全容器,但每种类型只允许一个值,如果存放同一类型
// 多个值是不行的
private Map<Class<?>, Object> favorites = new HashMap<Class<?>, Object>();
public <T> void putFavorite(Class<T> type, T instance) {
if (type == null)
throw new NullPointerException("Type is null");
// favorites.put(type, instance);
/*
* 防止客户端传进原生的Class对象,虽然这会警告,但这就不能
* 确保后面instance实例为type类型了,所以在这种情况下强制
* 检查
*/
favorites.put(type, type.cast(instance));
}
public <T> T getFavorite(Class<T> type) {
//返回的还是存入时真真类型
return type.cast(favorites.get(type));
}
public static void main(String[] args) {
Favorites f = new Favorites();
f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorites.class);
String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);
System.out.printf("%s %x %s%n", favoriteString, favoriteInteger,
favoriteClass.getName());
}
}
总之,集合API说明了泛型的一般用法,限制你每个容器只能有固定数目的类型参数。你可以通过将类型参数放在键上而不是容器上来避开这一限制。对于这种类型安全的异构容器,可以用Class对象作为键。
第六章 枚举和注解
30、 用enum代替int常量
枚举类型是指由一组固定的常量组成合法值的类型,例如一年中的季节或一副牌中的花色。在没引入枚举时,一般是声明一组int常量,每个类型成员一个常量:
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
这种方法称作int枚举模式,存在很多不足,不具有类型安全与使用方便性。如果你将apple传到一个想要接收orange的方法中,编译器也不会出现警告,而且还可以使用==来比较apple与orange。
注意每个apple常量都以APPLE_作为前缀,每个orange常量都以ORANGE_作为前缀,这是因为可以防止名称冲突。
采用int枚举模式的程序是十分脆弱,因为int枚举是编译时常量,被编译到使用它们的客户端中。如果与枚举常量关联的int发生了变化,客户端就必须重新编译,如果不重新编译,程序还是可以运行,但不是最新的值了。
另外从使用方便性来看,没有便利的toString方法,打印出来的为数字,没有多大的用处。要遍历一组中所有的int枚举常量,也没有可靠的方法。
既然int枚举常量有这么多的缺点,那使用String枚举常如何?同样也不是我们期望的。虽然在可以打印字符串,但它会导致性能问题,因为它依赖于字符串的比较操作。另外与int枚举常量一样会编译到客户端代码中,编译时难以发现,但会在运行时出错。
幸运的是1.5版本开始,枚举可以避免int和String枚举模式的缺点,并提供许多额外的好处。下面是最简单的形式:
public enum Apple{FUJI,PIPPIN,GRANNY_SMITH}
public enum Orange{NAVEL,TEMPLE,BLOOD}
Java枚举类型背后的基本想法很简单:本质上是int值,它们是通过公有的静态final域为每个枚举常量导出实例的类。因为没有可以访问的构造器,枚举类型是真正的final。因为客户端即不能创建枚举类型的实例,也不能对它进行扩展,因此对它进行实例化,而只有声明过的枚举常量。换句话说,枚举类型是实例受控的。它们是单例的泛型化,本质上是单元素的枚举。
枚举提供了编译时类型安全。如果声明一个参数的类型为Apple,就可以保证,被传到该参数上的任何非null的对象引用一定属于三个有效的Apple值之一。试图传递类型错误的值时,会导致编译时错误,就像试图将某种枚举类型的表达式赋值给另一种枚举类型的变量,或者试图利用==操作符比较不同枚举类型的值一样,都会出错。
枚举提供了单独的命名空间,同一系统中可以有多个同名的枚举类型变量。你可以增加或者重新排序枚举类型常量,而无需重新编译它的客户端代码,因为导出常量的域在枚举类型和它的客户端之间提供了一个隔离层:常量值并没有被编译到客户端代码中,而是在int枚举模式之中。最终,可以通过调用toString方法,将枚举转换成可打印的字符串。
除了完善了int枚举模式不足外,枚举还允许添加任意的方法和域,并实例任意接口,它们提供了所有Object(见第3章)的高级实现,实现了Comparable和Serializable接口,并针对枚举型的可任意改变性设计了序列化方式。
如果一个枚举具有普遍适用性,它就应该成为一个顶层类,如果它只是被用在一个特定的顶层类中,它就应该成为该顶层类的一个成员类。
可以为枚举类型添加数据域与方法,下面是一个算术运行的枚举类:
public enum Operation {
PLUS("+") {
double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
double apply(double x, double y) {
return x / y;
}
};
private final String symbol;//操作符:+ - * /
Operation(String symbol) {//构造函数,存储操作符供toString打印使用
this.symbol = symbol;
}
@Override
//重写Enum中的打印name的性为
public String toString() {
return symbol;
}
//抽像方法,不同的常量具有不同的功能,需在每个常量类的主体里重写它
abstract double apply(double x, double y);
/*
* 初始化时,存储操作符与枚举常量的对应关系,用来实现 fromString 方法
* 这样我们就可以通过 操作符来获取到对应的枚举常量,有点像valueOf方法,
* 只不过它是通过枚举常量的名字name来获取常量的。这种通用的方法还可以
* 应用到其他枚举类中
*/
private static final Map<String, Operation> stringToEnum = new HashMap<String, Operation>();
static { // 从name到枚举常量转换到从某个域到枚举常量的转换
for (Operation op : values())
stringToEnum.put(op.toString(), op);
}
// 根据操作符来获取对应的枚举常量,如果没有返回null,模拟valueOf方法
public static Operation fromString(String symbol) {
return stringToEnum.get(symbol);
}
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n", x, op, y, Operation
.fromString(op.toString()).apply(x, y));
}
}
在opr包下会看见Operation.class、Operation$4.class、Operation$2.class、Operation$3.class 、Operation$1.class这样几个类,Operation$X.class都是继承自Operation类,而Operation又继承自Enum类,下面是反编译这些类的代码:
public abstract class opr.Operation extends java.lang.Enum{
public static final opr.Operation PLUS;
public static final opr.Operation MINUS;
public static final opr.Operation TIMES;
public static final opr.Operation DIVIDE;
private final java.lang.String symbol;
private static final java.util.Map stringToEnum;
private static final opr.Operation[] ENUM$VALUES;
static {};
private opr.Operation(java.lang.String, int, java.lang.String);
public java.lang.String toString();
abstract double apply(double, double);
public static opr.Operation fromString(java.lang.String);
public static void main(java.lang.String[]);
public static opr.Operation[] values();
public static opr.Operation valueOf(java.lang.String);
opr.Operation(java.lang.String, int, java.lang.String, opr.Operation);
}
class opr.Operation$1 extends opr.Operation{
opr.Operation$1(java.lang.String, int, java.lang.String);
double apply(double, double);
}
枚举构造器不可以访问枚举的静态域,除了编译时常量域之外,这一限制是有必要的,因为构造器运行的时候,这些静态域还没有被初始化。
枚举常量中的方法有一个美中不足的地方,它们使用在枚举常量中共享代码变得更加因难了。例如,考虑用一个枚举来实现星期中的工资数。算法是这样的,在五个工作日中,除正常的工作时间外,算加班;在双休日中,所有工作时数都算加班时间,下面是第一次简单的实现:
public enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
private static final int HOURS_PER_SHIFT = 8;//正常工作时数
/**
* 工资计算
* @param hoursWorked 工作时间(小时)
* @param payRate 每小时工资
* @return
*/
double pay(double hoursWorked, double payRate) {
//基本工资,注这里使用的是double,真实应用中请不要使用
double basePay = hoursWorked * payRate;
double overtimePay;//加班工资,为正常工资的1.5倍
switch (this) {
case SATURDAY:
case SUNDAY://双休日加班工资
overtimePay = hoursWorked * payRate / 2;
default: //正常工作日加班工资
overtimePay = hoursWorked <= HOURS_PER_SHIFT ? 0
: (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
break;
}
return basePay + overtimePay;//基本工资+加班工资
}
}
不可否认,这段代码很简单,但是从维护来看,非常危险。假设将一个元素添加到枚举中,如一个假期的特殊值,但忘了给switch语句添加相应的case,这时会计算出错。
为了针对不同的常量有不同的安全计算工资法,你必须重复每个常量的加班工资,或者将计算移到两个辅助方法中(一个用来计算工作日,一个用来计算双休日),并从每个常量调用相应的辅助方法。这任何一种方法都会产生很多的重复的样板代码,第二次如下实现:
public enum PayrollDay {
MONDAY() {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
TUESDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
WEDNESDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
THURSDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
FRIDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
SATURDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekendPay(hoursWorked, payRate);
}
},
SUNDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekendPay(hoursWorked, payRate);
}
};
private static final int HOURS_PER_SHIFT = 8;//正常工作时数
//抽象出加班工资计算
abstract double overtimePay(double hoursWorked, double payRate);
//计算工资
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;//公用
return basePay + overtimePay(hoursWorked, payRate);
}
//双休日加班工资算法
double weekendPay(double hoursWorked, double payRate) {
return hoursWorked * payRate / 2;
}
//正常工作日加班工资
double weekdayPay(double hoursWorked, double payRate) {
return hoursWorked <= HOURS_PER_SHIFT ? 0
: (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
}
}
上面设计中存在很多的样板代码,如正常工作日都是调用weekdayPay方法来完成的,而双休都是调用weekendPay来完成的,有没有一种可以减少这些重复样板代码呢?请看下面:
enum PayrollDay {
MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY), WEDNESDAY(
PayType.WEEKDAY), THURSDAY(PayType.WEEKDAY), FRIDAY(PayType.WEEKDAY), SATURDAY(
PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
private final PayType payType;//策略枚举类
PayrollDay(PayType payType) {
this.payType = payType;
}
double pay(double hoursWorked, double payRate) {
//计算委托给策略类
return payType.pay(hoursWorked, payRate);
}
// 嵌套的枚举策略类
private enum PayType {
WEEKDAY {//工作日枚举策略实例常量
double overtimePay(double hours, double payRate) {
return hours <= HOURS_PER_SHIFT ? 0 : (hours - HOURS_PER_SHIFT)
* payRate / 2;
}
},
WEEKEND {//双休日枚举策略实例常量
double overtimePay(double hours, double payRate) {
return hours * payRate / 2;
}
};
private static final int HOURS_PER_SHIFT = 8;
abstract double overtimePay(double hrs, double payRate);
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;
return basePay + overtimePay(hoursWorked, payRate);
}
}
}
虽然这种模式没有前面两种那么简单,便更加安全,也更加灵活。
从上面加班工资计算三种实现来看,如果多个枚举常量同时共享相同的行为时,则考虑策略枚举。
枚举适用于一组固定常量,当然枚举类型中的常量集并不一定要始终保持不变。
31、 不要使用ordinal,用实例域代替序数
永远不要根据枚举序数ordinal()导出与它关联的值,即不要依赖于枚举序数,否则重新排序这些枚举或添加新的常量,维护起来将是很困难的:
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET;
public int numberOfMusicians() {
return ordinal() + 1;
}
}
我们要将它保存在一个实例域中:
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5), SEXTET(6), SEPTET(7);
private final int numberOfMusicians;
Ensemble(int size) {
this.numberOfMusicians = size;
}
public int numberOfMusicians() {
return numberOfMusicians;
}
}
Enum规范中谈到ordinal时这么定道:“大多数程序员都不需要这个方法。它是设计成用于像EunmSet和EnumMap这种基于枚举的通用数据结构”,除非你在编写的是这种数据结构,否则最好完全避免使用ordinal方法。
32、 用EnumSet代替位域
如果一个枚举类型的元素主要用在集合(组合)中,一般就使用int枚举模式,做法是将1向左移位来实现,这样就会有很多的组合形式,下面是四种字体样式的应用,可以组合出 2^4 – 1 = 15种样式来:
class Text{
public static final int STYLE_BOLD = 1 << 0;//1 字体加粗
public static final int STYLE_ITALTC = 1 << 1;// 2 斜体
public static final int STYLE_UNDERLINE = 1 << 2;//4 下划线
public static final int STYLE_STRIKETHROUGH = 1 << 3;//8 删除线
//应用样式
public void applyStyles(int styles){
//...
}
public static void main(String[] args) {
//应用粗体与斜体组合样式
new Text().applyStyles(STYLE_BOLD|STYLE_ITALTC);
}
}
位域表示法允许利用位操作,有效地执行了像组合和交集这样的集合操作。但位域有着int枚举常量的所有缺点,甚至更多,如当位域以数字形式打印时,翻译位域比翻译简单的(单个的)枚举常要困难得多。那么有没有一种好的方案来代替上面的设计呢?使用EnumSet类来有效地表示从单个枚举类型中提取的多个值的多个集合。EnumSet内容都表示为位矢量,如果底层的枚举类型有64个或者更少的元素——大多如此——整个EnumSet就是用单个long来表示,因此它的性能比得上位域的性能。批处理,如removeAll和retainAll,都是利用位算法来实现的,就像手工替位域实现那样,但可以避免手工位操作时容易出现的错误以及复杂的代码。
下面是前一个实例改用枚举代替位域后的代码,它更加简短、清楚、安全:
public class Text {
public enum Style {
BOLD, ITALIC, UNDERLINE, STRIKETHROUGH
}
/*
* 这里使用的Set接口而不是EnumSet类,最好还是使用接口
* 类型而非实现类型,这样还可以传递一些其他的Set实现
*/
public void applyStyles(Set<Style> styles) {
}
// Sample use
public static void main(String[] args) {
Text text = new Text();
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
}
}
总之,正是因为枚举类型可用在集合EnumSet中,所以没有理由用位域来表示它。EnumSet类集位域的简洁和性能优势及第30条中所述的枚举类型的所有优点于一身,用EnumSet代替位域就是理所当然的了。
33、 用EnumMap代替序数索引
EnumMap:与枚举类型键一起使用的专用 Map 实现。枚举映射中所有键都必须来自单个枚举类型,该枚举类型在创建映射时显式或隐式地指定。枚举映射在内部表示为数组。此表示形式非常紧凑且高效。
先来看一个能植物分类的实例,分类的标准是按照某枚举类型来分的:
public class Herb {
// 植物各类:一年生、多年生、两年生
static public enum Type {
ANNUAL, PERENNIAL, BIENNIAL
}
private final String name;//植物名字
private final Type type;//植物各类
Herb(String name, Type type) {
this.name = name;
this.type = type;
}
@Override
public String toString() {
return name;
}
public static void main(String[] args) {
//现有这样一些植物集合
Herb[] garden = { new Herb("Basil", Type.ANNUAL),
new Herb("Carroway", Type.BIENNIAL),
new Herb("Dill", Type.ANNUAL),
new Herb("Lavendar", Type.PERENNIAL),
new Herb("Parsley", Type.BIENNIAL),
new Herb("Rosemary", Type.PERENNIAL) };
//数组的索引与枚举Type对应 //问题一:需要进行未受检的转换
Set<Herb>[] herbsByType = (Set<Herb>[]) new Set[Herb.Type.values().length];
//初始化
for (int i = 0; i < herbsByType.length; i++) {
herbsByType[i] = new HashSet<Herb>();
}
//开始分类操作
for (Herb h : garden) {
//根据序数取出对应的容器再放入
herbsByType[h.type.ordinal()].add(h);
}
//输出
for (int i = 0; i < herbsByType.length; i++) {
System.out//问题二:手工输出类别,还有可能引发数组越界
.printf("%s: %s%n", Herb.Type.values()[i], herbsByType[i]);
}
}
}
输出:
ANNUAL: [Basil, Dill]
PERENNIAL: [Rosemary, Lavendar]
BIENNIAL: [Carroway, Parsley]
使用EnumMap对上面进行改进:
Herb[] garden =...
// 使用EnumMap并按照植物种类(枚举类型)来分类
Map<Herb.Type, Set<Herb>> herbsByType = new EnumMap<Herb.Type, Set<Herb>>(
Herb.Type.class);
for (Herb.Type t : Herb.Type.values())//初始化
herbsByType.put(t, new HashSet<Herb>());
for (Herb h : garden)//进行分类
herbsByType.get(h.type).add(h);
System.out.println(herbsByType);//输出
EnumMap在内部使用数组实现,在性能上与数组相当。但是它对程序员隐藏了实现细节,集Map的丰富功能和类型安全与数组的快速于一身。
34、 用接口模拟可伸缩的枚举
枚举类型是不能被扩展的(继承),但使用接口可以解决这一问题,解决办法是让枚举类实现同一接口,在应用的地方以接口类型来传递参数,但这样会失去Enum类的某些特性:
// 枚举接口
public interface Operation {
double apply(double x, double y);
}
// 基础运算
public enum BasicOperation implements Operation {
PLUS("+") {
public double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
public double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
public double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
public double apply(double x, double y) {
return x / y;
}
};
private final String symbol;
BasicOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
//扩展运算
public enum ExtendedOperation implements Operation {
EXP("^") {
public double apply(double x, double y) {
return Math.pow(x, y);
}
},
REMAINDER("%") {
public double apply(double x, double y) {
return x % y;
}
};
private final String symbol;
ExtendedOperation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
测试:
private static <T extends Enum<T> & Operation> void test(Class<T> opSet,
double x, double y) {
for (Operation op : opSet.getEnumConstants())//失去Enum特性,使用反射
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
test(BasicOperation.class, x, y);
test(ExtendedOperation.class, x, y);
}
输出:
4.000000 + 2.000000 = 6.000000
4.000000 - 2.000000 = 2.000000
4.000000 * 2.000000 = 8.000000
4.000000 / 2.000000 = 2.000000
4.000000 ^ 2.000000 = 16.000000
4.000000 % 2.000000 = 0.000000
总之,虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型,对它进行模拟。这样,客户端就能够编写自己的枚举来实现接口,如果API是根据接口编写的,那么在可以使用基础枚举类的地方,也都可以敷衍这些枚举。
35、 注解优先于命名模式
命名模式,表示有些程序元素需要通过某种工具或者框架进行特殊处理。例如,Junit框架原本要求它的用户一定要使用test作为测试方法名称的开头。
下面是一个简单的测试框架,使用注解来实现:
//专用于普通测试注解,该注解只适用于静态的无参方法,
//如果使用地方不正确由注解工具自己把握
@Retention(RetentionPolicy.RUNTIME)//注解信息保留到运行时,这样工具可以使用
@Target(ElementType.METHOD)//只适用于方法
public @interface Test {}
//专用于异常测试的注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
//测试方法可能抛出多个异常
Class<? extends Exception>[] value();
}
下面应用上面定义的注解:
public class Sample {
@Test
public static void m1() {} // 测试应该通过
public static void m2() {}
@Test
public static void m3() { // 测试应该失败
throw new RuntimeException("Boom");
}
public static void m4() {}
@Test//不应该使用在这里,但应该由注解工具自己处理这种不当的使用
public void m5() {} // 错误使用: 非静态方法
public static void m6() {}
@Test
public static void m7() { // 测试应该失败
throw new RuntimeException("Crash");
}
public static void m8() {}
}
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // 测试应该要通过,因为抛出了算术异常
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // 测试应该不通过,因为抛出的异常为数组越界异常
int[] a = new int[0];
int i = a[1];
System.out.println(i);
}
@ExceptionTest(ArithmeticException.class)
public static void m3() {
} // 测试应该不通过,因为没有抛也异常
// 可能抛出多个异常,使用{}括起来,如果是单个可以省略
@ExceptionTest( { IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<String>();
//这里会抛出空指针错误,测试应该会通过
list.addAll(5, null);
}
}
T注解对应用类的语义没有直接的影响。注解永远不会改变被注解代码的语义,但是使用它可以通过工具进行特殊的处理,如下面注解工具实现类:
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;//需要测试的数量
int passed = 0;//测试通过的数量
//加载被注解的类,即被测试的类
Class<?> testClass = Class.forName(args[0]);
//遍历测试类的所有方法
for (Method m : testClass.getDeclaredMethods()) {
//Test注解实现工具
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);//没有参数,表示调用的是静态方法
passed++;//如果方法调用成功则表示测试通过
}//表示测试方法本身抛出了异常
catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
//打印测试方法抛出的异常信息
System.out.println(m + " failed: " + exc);
}//如果抛异常表示注解使用错误,不应使用在非静态或带参数的方式上
catch (Exception exc) {
//打印测试未通过的方法信息
System.out.println("使用@Test注解错误的方法 : " + m);
}
}
// ExceptionTest注解实现工具
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
//如果注解工具运行到这里,则测试方法未抛出异常,但属于测试未通过
System.out.printf("Test %s failed: no exception%n", m);
} catch (Throwable wrappedExc) {
//获取异常根源
Throwable exc = wrappedExc.getCause();
//取出注解的值
Class<? extends Exception>[] excTypes = m.getAnnotation(
ExceptionTest.class).value();
int oldPassed = passed;
//将根源异常与注解值对比
for (Class<? extends Exception> excType : excTypes) {
//如果测试方法抛出的异常与注解中预期的异常匹配则表示测试通过
if (excType.isInstance(exc)) {//使用动态的instance of
passed++;
break;
}
}
//打印测试没有通过的方法信息
if (passed == oldPassed)
System.out.printf("Test %s failed: %s %n", m, exc);
}
}
}
//打印最终测试结果,通过了多少个,失败了多少个
System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
}
}
下面是两次运行输出结果(前面的为测试Sample类,后面是测试Sample1类):
public static void Item35.Sample.m3() failed: java.lang.RuntimeException: Boom
使用@Test注解错误的方法 : public void Item35.Sample.m5()
public static void Item35.Sample.m7() failed: java.lang.RuntimeException: Crash
Passed: 1, Failed: 3
Test public static void Item35.Sample2.m2() failed: java.lang.ArrayIndexOutOfBoundsException: 1
Test public static void Item35.Sample2.m3() failed: no exception
Passed: 2, Failed: 2
本条目中开发的测试框架只是一个试验,但它清楚地示范了注解优于命名模式,这只是揭开注解功能的冰山一角。如果在编写一个需要程序员给源文件添加信息的工具,就要定义一组适当的注解类型。另外,我们要考虑使用Java平台提供的预定义的标准注解类型(见第36条)。
36、 坚持使用Override注解
当你打算重写一个方法时,有可能写成重载,这时如果@Override注解就可以防止出现这个问题。
这个经典示例涉及equals方法,程序员可以编写如下代码:
public boolean equals (Foo that){…}
当你应当把他们编写成如下时:
public Boolean equals(Object that)
这也是合法的,但是类Foo从Object继承了equals实现,最终成了重载,而原本是重写的,这时我们可以使用@Override在重写的方法前,这样如果在没有重写的情况下,编译器则会提示我们。
注,@Override不能用在实现父接口中的方法前面,因为这叫实现不叫重写了,这与可以加在实现父抽象类中方法前是不一样的。
总之,如果在你想要的每个方法声明中使用Override注解来覆盖超类声明,编译器可以替你防止大量的错误,但有一个例外,在具体的类中,不必标注你确认覆盖了抽象方法声明的方法,虽然这么做也没有什么坏处。
37、 用标记接口定义类型
标记接口是没有包含方法声明的接口,而只是指明(或者“标明”)一个类实现了具有某种属性的接口。如,Serializable接口,通过实现这个接口,表明它的实例可以被写到ObjectOutputStream中(或者“被序列化”)。
标记注解,没有参数,只是“标注”被注解的元素,如果第 35 条的@Test就是一个标记注解。
标记注解并不能替代标记接口。标记接口有两点胜过标记注解。最重要的一点是,标记接口定义的类型是由被标记类的实例实现的;标记注解则没有定义这样的类型。标记接口在编译时就会被检测是否有错误,而标记注解则要到运行时期(但也并非如此,就Serializable标记接口而言,如果它的参数没有实现该接口,ObjectOutputStream.write(Object)方法将会失败,但令人不解的是,ObjectOutputStream API的创建者在声明Write方法时并没有利用Serializable接口,该方法的参数类型应该为Serializable而非Object,因此,试着在没有实现Serializable的对象上调用ObjectOutputStream.write,只会在运行时出错,所以也并不是像前面说的那样)。
HYPERLINK "mailto:如果你正在编写的是目标为@Target(ElementType.TYPE)" 如果你正在编写的是目标为@Target(ElementType.TYPE)的标记注解类型,就要考虑使用标记接口来实现呢。
总之,接口是用来定义类型的,而注解是用来辅助分析类元素信息的。
从某种意义上说,本条目与第19条中“如果不想定义类型就不要使用接口”的说法相反。本科目最接近的意思是说:“如果想要定义类型,一定要使用接口”。
第七章 方法
38、 检查参数的有效性
绝大多数方法和构造器对于传递给它们的参数值都会有某些限制。例如,索引值必须是非负的,对象引用不能为null等,这些都是常见的。你应该在文档中清楚地指明所有这些限制,并且在方法体的开头处检查参数,以强制施加这些限制。
应该在方法和构造器体前进行了参数的有效性检查,并且及时向外抛出适当的异常。如果方法没有检查它的参数,就有可能发生几种情形。该方法可能在处理过程中失败,并且产生令人费解的异常,更有可能,该方法可以正常返回,但是会悄悄地计算出错误的结果。
对于公有的方法,要用JavaDoc的@throws标签(tag)在文档中说明违反参数值限制时会抛出的异常(见第62条),这样的异常通常为IllegalArgumentException、IndexOutOfBoundsException或NullPionterException(见第60条),下面是一个例子:
/**
* ...
* @param m m为系数,必须是正数
* @return this mod m
* @throws 如果m小于等于0时抛出ArithmeticException
*/
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0) {
throw new ArithmeticException("Modulus <= 0:" + m);
}
...// do the computations
}
对于那此非公有方法,你可以控制这个方法在哪些情况下被调用,因此你可以,也应该确保只将有效的参数值传递进来。因此,非公有的方法通常应该使用断言来检查它们的参数,具体做法如下:
private static void sort(long a[], int offset, int length) {
assert a != null;
assert offset >= 0 && offset <= a.length;
assert length >= 0 && length <= a.length - offset;
// ... do the computation
}
断言就是被断言的条件将会为真,否则将会抛出AssertionError。不同于一般的有效性检查,如果它们没有起到作用,本质上也不会有成本开销,除非通过将 –ea(或者 -enableassertions)开关传递给Java解释器,来启用它们。
对于有些参数,方法本身没有用到,却被保存起来供以后使用,则检验这类参数的有效性尤其重要,因为将来在使用时抛出异常时要想找到这个参数的来源就非常困难了,所以这类参数更应该先检查,这个规则也适用于构造函数。
不是所有的参数都就应该在使用前检查他的有效性,因为有的检查是要代价的,或根本是不切实际的,而且有效性检查本身就可以在计算过程中完成。例如,Collections.sort(List)对集合排序的方法,集合中的每个元素都实现了Comparable接口,否则在计算时会抛出ClassCastException,这正是sort方法所应该做的事情,因此,提前检测是否实现了该比较接口是没有多大意义的,再说多遍历一次也是消耗性能的,但此时我们先不进行参数有效性检测,则无效的参数值会导致计算过程抛出异常,而这种异常与文档中标明这个方法抛出的异常不符,在这种情况下,我们应该转换异常,将计算过程中抛出的异常转换为正确的异常。
当然,不是要求所有的参数都要求检查的,有些参数在实际应用中不会产生非法值或在我们需范围内不会有其他值,则就不需要进行检测。
39、 必要时进行保护性拷贝
保护性拷贝的实例:
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
//开始时间一定不能大于结束时间
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start() {
return (Date)start.clone();
//return new Date(start.getTime());
}
public Date end() {
return (Date)end.clone();
//return new Date(end.getTime());
}
}
这里两个地方都需要进行保护性拷贝,第一个是进参的地方,这里是构造器;第二个地方是传出去的地方,这里为start与end方法,如果丢掉一个地方,都有可能打破上面约束条件“开始时间一定不大于结束时间”。注,在构造器中,保护性拷贝是在检查参数的有效性之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始对象,这是一定要这么做的,因为如果将检测放在了最前面,则当检测满足条件后另一线程改变了原始对象参数的值,此进检测实质上已无效,所以这里与第38条并不是矛盾的。
同时请注意,构造器中我们没有用Date的clone方法来进行保护性拷贝。因为Date是非final类,不能保证传进来的一点是Date类型对象:它有可能是专门出于恶意的目的而设计的不可信子类的实例,这样我们调用clone方法时实质上不是调用Date上面的clone方法,而是恶意子对象上的,这样在克隆时子类可以在每个克隆实例被创建的时候,把指向该实例的引用记录到一个私有的静态列表中,并且允许攻击者访问这个列表,这将使得攻击者可以自由地控制所有的实例。所以,对于传进的参数实例我不要使用clone方法进行保护拷贝,而是直接使用new的创建方式来拷贝。
然而,对于访问方法,与构造器不同,在进行保护拷贝时候是允许使用clone方法的。之所以可以,是因为我们知道,Period内部的Date对象的类是java.util.Date,而不可能是其他某个潜在的不可信子类。
参数的保护性拷贝并不仅仅针对不可变类。每当编写方法或者构造器时,如果它要允许客户提供的对象进入到内部数据结构中,则有必要考虑一下,客户提供的对象是否有可能是可变的。如果是,就要考虑你的类是否能容忍对象进入数据结构之后发生变化,如果不允许,就必须对该对象进行保护性拷贝,以防止相互影响。
在内部组件返回给客户端之前,对它们进行保护性拷贝也是同样的道理。不管类是否为不可变的,在把一个指向内部可变组件的引用返回给客户端之前,也应该考虑是否进行拷贝。
记住长度非零的数组总是可变的。因引,在把内部数组返回给客户端之前,应该总要进行保护性拷贝。另一种解决方案是,给客户端返回该数组的不可变视图,这两种方法在第13条中已演示过了。
保护性拷贝可能会带来相关的性能损失,如果类信任它的调用都不会修改内部的组件,可能因为类及其客户端都是同一个包的双方,那么不进行保护性拷贝也是可以的。在这种情况下,类的文档必须清楚地说明,调用者绝不能修改受影响的参数或者返回值。
总之,如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改这些组件,就可以在文档中指明不能修改这些受到影响的组件,以此来代替保护性拷贝。
40、 谨慎设计方法签名
1、 谨慎地选择方法的名称。遵循标准命名习惯,风格统一、大众认可的相一致的名称。设计时可以参考类库。
2、 不要过于追求提供便利的方法。每个方法都应该尽其所能。方法太多会使类难以学习、使用、文档化、测试和维护。对于接口,更是这样,方法太多会使用接口实现者和接口用户的工作变得复杂起来。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到时,考虑为它提供快捷的方式。
3、 避免过长的参数列表。目标是四个参数或者更少。相同类型的长参数序列格外有害,如果不小心弄错了参数顺序时,他们的程序仍然可以编译和运行。有三种方法可以缩短过长的参数列表。第一种是把方法分解成多个方法,每个方法只需要这些参数的一个子集,即将多功能分解成多个单一的小功能。第二种方法是创建辅助类,如果使用FromBean封装了页面上的所有参数然后传到Action。第三种是结合了前两种方法特征,从对象构造到方法调用都采用Builder模式(参见第2条),如果方法有多个参数,其中有些又是可选的,最好定义一个对象来表示所有参数,并允许客户端在这个对象上进行多次“setter”调用,每次调用都设置一个参数或设置一个较小的集合,一旦设置了所需要的参数,客户端就调用这个对象的“执行(execute)”方法,它对参数进行最终的有效性检查,并执行实际的计算。
对于参数传递类型,我们要优先使用接口而不是类(请见第52条),只要有适当的接口可用来定义参数,就优先使用这个接口,而不是这个接口实现。我们没有理由在编写方法时使用HashMap类来作为输入,相反,应当使用Map接口作为参数类型,这使你可以传进各种Map的实现,如果碰巧输入的数据是以其他形式存在,使用具体类类型作为参数时就会导致不必要的转换操作。
对于boolean参数,要优先使用两个元素的枚举类型,这样便于以后第种选择的加入,这样不必要再添加另一个方法。
41、 慎用重载
public class CollectionClassifier {
public static String classify(Set<?> s) {
return "Set";
}
public static String classify(List<?> lst) {
return "List";
}
public static String classify(Collection<?> c) {
return "Unknown Collection";
}
public static void main(String[] args) {
Collection<?>[] collections = { new HashSet<String>(),
new ArrayList<BigInteger>(), new HashMap<String, String>().values() };
for (Collection<?> c : collections)
System.out.println(classify(c));
}
}
上面程序三次打印“Unknown Collection”,而没有打印出“Set”与“List”,为什么呢?因为重载方法的选择是静态的,而对于被覆盖的方法的选择则是动态的。选择被覆盖的方法的正确版本是在运行时进行的,选择的依据是被调用方法所在对象的运行时类型;而选择调用那一种方法则是在编译时就已经确定了的。
上面修改如下:
public static String classify(Collection<?> c) {
return c instanceof Set ?"Set": c instanceof List ?"List"
:Unknown Collection";
}
由于重载容易引起方法调用的混乱,有时调用的根本不是我们想要的方法。因此,应该避免胡乱地使用重载机制,而使方法的名称不同。
到底怎样才算胡乱使用重载机制呢?这个问题有点争议。安全保守的策略是,永远不要导出两个具有相同参数数目的重载方法,如果方法使用可变参数,保守的策略是根本不要重载它,除第42条中所述情形之外。例如ObjectOutputStream对于每种基本类型,以及几中引用类型,它的wirte方法都有一种变形,这些变形方法并不是重载write方法,而是如writeInt(int)、writeLong(long)这样的命名模式。
对于构造器,你没有选择使用不同名称的机会;一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器(见第1条)。
Jdk1.5引入了自动拆装箱,使用方法的调用更加混乱了,如:
public class SetList {
public static void main(String[] args) {
Set<Integer> set = new TreeSet<Integer>();
List<Integer> list = new ArrayList<Integer>();
for (int i = -3; i < 3; i++) {
set.add(i);
list.add(i);
}
//[-3, -2, -1, 0, 1, 2] [-3, -2, -1, 0, 1, 2]
System.out.println(set + " " + list);
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove(i);
}
//[-3, -2, -1] [-2, 0, 2]
System.out.println(set + " " + list);
}
}
上面set.remove(i)调用remove(Object o)方法,E为元素的类型,将i从int自动装箱到Integer,这是我们所期望的。而list.remove(i)则是调用的remove(int i)方法,而根本没有调用重载的remove(Object o)方法,即调用时采用了参数类型最匹配优先原则,所以没有自动装箱。修改如下:
for (int i = 0; i < 3; i++) {
//显示的自动装箱
list.remove((Integer) i);//或者是 list.remove(Integer.valueOf(i))
}
总之,“能够重载方法”并不意味着就“应该重载方法”,一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。在某些情况下,特别是涉及构造器的时候,要遵循这条建议也许是不可能的。在这种情况下,至少应该避免这样的情形:同一组参数只需经过类型转换就可以被传递给不同的重载方法,在这种情况下,我们坚决抵制这数的重载。
42、 慎用可变参数
可变数组方法接受0个或者多个指定类型的参数。可变参数机制通过先创建一个数组,数组大小为在调用位置所传递的参数数量,然后将参数值传到数组中,最后将数组传递给方法。
传递给可变参数方法时,可变参数可以先使用一个数组包装起来再传递到方法中去也是可以的。
在设计可变参数时,如果至少要传递一个参数,则最好将这个参数单独做为第一个参数,而不是都做成可变参数后在方法里进行参数检测。下面程序是在某个数组里找最小的元素,不应该是这样:
static int min(int... args) {
if (args.length == 0)
throw new IllegalArgumentException("Too few arguments");
int min = args[0];
for (int i = 1; i < args.length; i++)
if (args[i] < min)
min = args[i];
return min;
}
上面这种方案如果前面不对参数进行有效性检测,又如果运行时没有传递参数,则运行时出错。最好应该是这样设计:
static int min(int firstArg, int... remainingArgs) {
int min = firstArg;
for (int arg : remainingArgs)
if (arg < min)
min = arg;
return min;
}
下面看看1.4与1.5中的Arrays.asList,1.4中的是这样public static List asList(Object[] a),到了1.5中改成了可变参数成这样public static <T> List<T> asList(T... a) ,现在我们使用这个方法时要格外小心,看看下面几个问题:
public static void main(String[] args) {
String strs[] = new String[] { "str1", "str2" };
int ints[] = new int[] { 1, 2 };
/*
* 1.4输出:[str1, str2]
* 1.5输出:[str1, str2]
*/
System.out.println(Arrays.asList(strs));
/*
* 1.4编译不能通过!!
* 1.5输出:[[I@757aef]
*/
System.out.println(Arrays.asList(ints));
}
由于1.5版本中,令人遗憾地决定将Arrays.asList改造成可变参数方法,现在上面这个程序在1.5中可以通过编译,但是运行时,输出的不是我们想要的结果而是[[I@757aef],这主要是由于基本类型不能用于泛型的原因所致,所以在将一个基本类型数组传给asList(T... a)方法时,将整个基本类型数组看作是可能参数集中的第一个参数了。
但从好的方面来看,本来asList方法就不是用来打印数组中的元素字面值的,它的作用是将数组转换成List而已,这在1.5中得到了修补,并增加了Arrays.toString的方法,它正是专门为了将任何类型的数组转变成字符串而设计的。如果用Arrays.toString来代替Arrays.asList,我们将会得到想要的结果:
//[1, 2]
System.out.println(Arrays.toString(ints));
有两个方法签名是要特别注意的:
ReturnType1 suspect1(Object…args){}
<T> ReturnType2 suspects(T…args){}
带有上述任何一种签名的方法都可以接受任何参数列表,改造之前(asList(Object[] a))进行的任何编译时的类型检查(如不能传递基本类型的数组)都将会丢失,Arrays.asList发生的情形正是说明了这一点。
可变参数会影响性能,方法的每次调用都会导致进行一次数组的分配和初始化,如果考虑性能而又要这种可能参数的灵活性时,并假设对某个方法调用时使用的都是3个或更少的参数,就声明该方法的5个重载方法,每个重载方法带0至3个普通参数,当参数的数目超3个时,就使用一个可变参数方法:
public void foo(){}
public void foo(int a1){}
public void foo(int a1, int a2){}
public void foo(int a1, int a2, int a3){}
public void foo(int a1, int a2, int a3, int…rest){}
这种我们可以在EnumSet的静态工厂方法看到这样的引子。
总之,在定义参数数目不定的方法时,可变参数方法是一种很方便的方式,但是它们不应该被过度滥用,如果使用不当,会产生混乱的结果。
43、 返回零长度的数组或者集合,而不是null
对于一个返回null而不是零长度数组或者集合方法,几乎每次用到该方法时都需要额外处理是否为null,这样做很容易出错,因为缩写客户端程序的程序员可能会忘记写这种专门的代码来处理null返回值,如:
private final List<Cheese> cheesesInStock = …;
public Cheese[] getCheeses(){
if(cheesesInStock.size() == 0)
return null;
…
}
客户端使用如下:
Cheese[] cheeses = shop.getCheeses();
if(cheeses != null && …);
有时候有人会认为:null返回值比零长度数组更好,因为它避免了分配数组所需要的开销,这种观点是站不住脚的,原因有二:一是,除非这个方法正是造成性能问题的真正源头。二是,完全可以使用一个零长度的数组共享。
下面处理当一个集合为空时,返回一个零长度数组的有效做法:
private final List<Cheese> cheesesInStock = …;
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];//将零长度的数组设为静态的,以便共享用
public Cheese[] getCheeses(){
//借助于List的toArray方法,将列表转换成数组,如果传进的数组长度为零,则会返这个零长度数组本身,并且这个零长度数组是共享的
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
<T> T[] toArray(T[] a):如果指定的数组能容纳 collection 并有剩余空间(即数组的元素比 collection 的元素多),此时并不创建新的数组,那么会将数组中紧跟在 collection 末尾的元素设置为 null。(这对确定 collection 的长度很有用,但只有 在调用方知道此 collection 没有包含任何 null 元素时才可行。);如果指定的数组的容量比集合容量要小,则会重新创建一个集合大小的新的数组,所以如果数组与集合都是空时将返回一定会返回零长度数组本身。并且返回数组一定是安全的。
注意,toArray(new Object[0]) 和 toArray() 在功能上是相同的,只不过返回的数组恰好是集合中的元素,不多也不少,但如果集合为空时,返回也是零长度数组,不过这是集合为我们重新创建的,我们没有办法让零长度数组在以后共享。
上面是返回零长度数组的做法,下面看一下返回空集合的做法:
Collection能转换成安全的数组,Collections能在需要返回空集合时都返回同一个不可变的空集合(不能向其中添加元素,也不能读取,只知道它的长度为0,并且contains永远返回false),如emptySet、emptyList、emptyMap:
public List<Cheese> getCheeseList(){
if(cheesesInStock.isEmpty())
return Collections.emptyList();//总是返回相同的空的list
else
return new ArrayList<Cheese>(cheesesInStock);
}
总之,返回类型为数组或者集合的方法没有理由返回null,而是返回一个零长度的数组或者集合。
44、 为所有导出的API元素编写文档注释
为了正确地编写API文档,必须在每个导出类、接口、构造器、方法和域声明之前增加一个文档注释(这是强制的)。如果类是可序列化的,也应该对它的序列化形式编写文档(见第75条)。为了编写出可维护的代码,还应该为那些没有被导出的类、接口、构造器、方法和域编写文档注释(非强制的)。
方法的文档注释应该简洁地描述出它和客户端之间的约定,除了专门为继承而设计的类中的方法(见第17条)之外,这个约定应该说明这个方法做了什么,而不是说明它是如何完成这功项工作的。文档注释应该列举这个方法的前置条件与后置条件,前提条件指调用该方法要得到预期的结果必须满足的条件,如参数的约束。而后置就是指调用方法完后要完成预期的功能。
跟在@param标签或者@return标签后面的文字应该是一个名词短语,描述了这个参数或者返回值所表示的值,跟在@throws标签之后的文字应该包含单词“if如果”,紧接着是一个名词短语,它描述了这个异常将在什么样的条件下会被抛出。有时候,也会用表达式来代替名词短语。并且按照惯例,这个标签后面的短语或者子名都不用句点来结束。
类是否线程安全的,也应该在文档中对它的线程安全级别进行说明,如第75条中所述。
在文档注释内部出现任何HTML标签都是允许的,但是HTML元字符必须要经过转义。
第八章 通用程序设计
45、 将局部变量的作用域最小化
将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。
要使用局部变量的作用域最小化,最有力的方法就是在第一次使用它的地方才声明,不要过早的声明。
局部变量的作用域从它被声明的点开始扩展,一直到外围块的结束外。如果变量是在“使用它的块”之外被声明有,当程序退出该块之后,该变量仍是可见的,如果它在目标使用区之前或之后意外使用,将可能引发意外错误。
几乎每个局部变量的声明都应该包含一个初始化表达式,如果你还没有足够信息来对象一个变量进行有意义的初始化,就应该推迟这个声明,直到可初始化为止。但这条规则有个例外的情况与try-catch语句有关。如果一个变量被一个方法初始化,而这个方法可能会抛出一个检测性异常,该变量就必须在try块内部被初始化。如果变量的值必须在try块的外部使用到,它就必须在try块之前被声明,但是在try块之前,它还不能“有意义地初始化”,请参照第53条中的异常实例。
循环中提供了特殊的机会来将变量的作用域最小化。如果在循环终止之后不再需要使用循环变量的内容,for循环就优先于while循环。例如,下面是一种遍历集合的首选做法:
for(Element e: c){
doSomething(e);
}
在1.5前,首先做法如下:
for(Iterator i = c.iterator();i.hasNext();){
doSomething((Element) i.next());
}
为了弄清为什么这个for循环比while循环更好,请看下面代码:
Iterator<Element> i = c.iterator();
while(i.hasNext()){
doSomething(i.next());
}
…
Iterator<Element> i2 = c2.iterator();
while(i.hasNext()){//Bug!
doSomething(i2.next());
}
CP过来的代码未修改完,结果导致for循环编译通不过。
最后一种“将局部变量的作用域最小化”的方法是使方法小而集中。
----------------------
补充,不能在while条件中声明变量,这与for循环不一样,也不能像下面这样在while体中声明一个变量:
while(true)
int i = 1;
只可以这样:
while(true){
int i = 1;
}
46、 for-each循环优先于传统的for循环
1.5前,遍历集合的首选做法如下:
for(Iterator i = c.iterator(); i.hasNext();){
doSomething((Element)i.next());
}
遍历数组的首选做法如下:
for(int i =0; i < a.length;i++){
doSomething(a[i]);
}
虽然这些做法比while循环理好,但并不完美,因为迭代器和索引变量在每个循环中出现三次,其中有两次让你出错。
1.5中完全隐藏迭代器或者索引变量,避免了混乱和出错的可能,下面这种模式同样适合于集合与数组:
for(Element e : elements){
doSomething(e);
}
集合内嵌迭代时问题:
Collection<Suit> suits = ...;
Collection<Rank> ranks = ...;
List<Card> deck = ...;
for (Iterator<Suit> i = suits.iterator(); i.hasNext();)
for (Iterator<Rank> j = ranks.iterator(); j.hasNext();)
deck.add(new Card(i.next(), j.next()));//i.next在内部循环中多次调用,会出现问题
将i.next()临时存储起来可以解决这个问题:
for (Iterator<Suit> i = suits.iterator(); i.hasNext();){
Suit suit = i.next();
for (Iterator<Rank> j = ranks.iterator(); j.hasNext();)
deck.add(new Card(suit, j.next()));
}
如果使用内嵌的for-each循环,这个问题很快会完全消失,代码是如此的简洁:
for (Suit suit : suits)
for (Rank rank : ranks)
deck.add(new Card(suit, rank));
for-each循环不仅让你遍历集合数组,还让你遍历任何实现Iteralble接口的对象。这个简单的接口接口由单个方法组成,与for-each循环同时被增加到Java平台中,这个接口如下:
public interface Iterable<E>{
Iterator<E> iterator();
}
总之,for-each循环在简洁性和预防Bug方面有着传统的for循环无法比拟的优势,并且没有性能损失。应该尽可能地使用for-each循环,遗憾的是,有些情况是不适用的,比如需要显示地得到索上或迭代器然后进行其他操作,或者是内部循环的条件与外部有直接关系的(比如内层循环的起始值依赖于外层循环的条件值)。
47、 了解和使用类库
假如你希望产生位于0和某个上界之间的随机整数,你可以会这么做:
privae static final Random rnd = new Random();
static int random(int n){
return Math.abs(rnd.nextInt())%n;
}
上面程序产生的0到n间的整数是不均的,使用类库中的Random类的nextInt(Int)可以解决,这些方法是经过专家们设计,并经过多次测试和使用过的方法,比我们自己实现可靠得多。
通过使用标准类库,可以充分利用这些编写标准类库的专家的知识,以及在你之前的其他人的使用经验。
使用标准类库中的第二个好处是,不必关心底层细节上,把时间应花在应用程序上。
使用标准类库中的第三个好处是,它们的性能往往会随着时间的推移而不断提高,无需你做任何努力。
本条目不可能总结类库中所有便利工具,但是有两种工具值得特别一提。一个是1.2发行版本中的集合框架,二是1.5版本中,在java.util.concurrent包中增加了一组并发实用工具,这个包既包含高级的并发工具来简化多线程的编程任务,还包含低级别的并发基本类型,允许专家们自己编写更高级的并发抽象,java.util.concurrent的高级部分,也应该是每个程序员基本工具箱中的一部分。
总之,不要重新发明轮子,已存在的我们就直接使用,只有不能满足我们需求时,才需自己开发,总的来说,多了解类库是有好处的,特别是为库中的工具包。
48、 如果需要精确的答案,请避免使用float和double
float和double类型主要是用来为科学计算和工程计算而设计的。它们执行二进制浮点运算,这是为了在广泛的数值满园上提供较为精确的快速近似计算而精心设计的,然而,它们并没有提供完全精确的结果,所以不应该被用于需要精确结果的场合。float和double类型尤其不适合用于货币的计算,因为要让一个float和double精确地表示0.1(或者10的任何其他负数次方值)是不可能的。
System.out.println(1.0-.9);// 0.09999999999999998
请使用BigDecimal、int或long(int与long以货币最小单位计算)进行货币计算。
使用BigDecimal时请还请使用BigDecimal(String),而不要使用BigDecimal(float或double),因为后者在传递的过程中会丢失精度:
new BigDecimal(0.1)// 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1")//0.1
使用BigDecimal有两个缺点:与使用基本运算类型相比,这样做很不方便,而且很慢。
如果性能非常关键,请使用int和long,如果数值范围没有超过9位十进制数字,就可以使用int;如果不超过18位数字,就可以使用long,如果数字可能超过18位数字,就必须使用BigDecimal。
49、 基本类型优先于包装基本类型
Comparator<Integer> naturalOrder = new Comparator<Integer>() {
public int compare(Integer first, Integer second) {
/* 因为
* first < second 运行时会自动拆箱
* first == second 运行时不会自动拆箱
*/
return first < second ? -1 : (first == second ? 0 : 1);
}
};
//比较两个值相等的Integer
int result = naturalOrder.compare(new Integer(42), new Integer(42));
System.out.println(result);//所以结果为 1
修正上面这个问题做法是添加两个局部变量,让他们比较前自动拆箱,比较时一定是基本类型:
Comparator<Integer> naturalOrder = new Comparator<Integer>() {
public int compare(Integer first, Integer second) {
int f = first; // 自动拆箱
int s = second; // 自动拆箱
return f < s ? -1 : (f == s ? 0 : 1); // 按基本类型比较
}
};
int result = naturalOrder.compare(new Integer(42), new Integer(42));
System.out.println(result);//0
接下来,考虑这个小程序:
public class Unbelievable {
static Integer i;
public static void main(String[] args) {
if (i == 42)// !! 抛空指针异常
System.out.println("Unbelievable");
}
}
当一个项操作中混合使用基本类型和包装基本类型时,装箱基本类型就会自动拆箱,上面就是这样,如果null对象引用被自动拆箱,就会得到NullPointerException异常。修正这个问题很简单,声明i是个int而不是Integer就可以了。
最后考虑这个程序:
public static void main(String[] args){
Long sum = 0L
for(long i = 0; i < Integer.MAX_VALUE; i++){
sum += i;
}
System.out.println(sum);
}
这个程序运行起来很慢,因为它不小心将一个局部变量(sum)声明为是装箱基本类型Long,而不是基本类型long,程序反复的进行装箱与拆箱。
包装用在以下时机:一是作为集合中的元素、健和值;二是作为泛型的参数类型;三是反射。
总之,当可以选择的时候,基本类型要优先于包装类型,基本类型更加简单,也更加快速。自动装箱减少了使用包装基本类型的繁琐,但是并没有减少它的风险。另外,当程序用==操作符比较两个包装类型时,即使是在1.5中,也不会自动拆箱后比较,所以不管是1.5前还是以后,==都是比较的地址。
50、 如果其他类型更适合,则尽量避免使用字符串
字符串不适合代替其他的值类型。数组经过文件、网络,或键盘输出设置进入到程序中之后,它通常是以字符形式存在,但我们应该尽量将他们转换为确切的类型。
如果可以使用更加合适的数据类型,或者可以编写更加适当的数据类型,就应该避免用字符串来表示对象。若使用不当,字符串会比其他类型更加笨拙、更不灵活、速度慢,也更容易出错。经常被错误地用字符串来代替的类型包括基本类型、枚举类型和聚集类型。
51、 当心字符串连接的性能
由于字符串是不可变的,连接操作会产生新的字符串对象。所以不适合运用在大规模的场景中。
考虑下面的方法,它通过反复连接每个项目行,构造出一个代表该账单的字符串:
// Inappropriate use of string concatenation - Performs horribly!
public String statement() {
String result = "";
for (int i = 0; i < numItems(); i++)
result += lineForItem(i); // String concatenation
return result;
}
如果项目数量巨大,这个方法执行的时间就难以估算。为了获得可以接受的性能,请使用StringBuilder替代String(1.5中增加了非同步的StringBuilder类,代替了现在已经过时的StringBuffer类),下面是重构:
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,使它大到足以容纳结果字符串,即使使用默认大小(16)的StringBuilder,它仍比第一种快。
原则很简单:不要使用字符串连接操作符“+”来合并多个字符串,除非性能无关紧要。相反,应该使用StringBuilder的append方法。另一种方法是,使用字符数组,或者每次只处理一个字符串,而不是将它们组合起来。
——end
以上是effective java的建议,但说的不够精准,这里需要补充一下。
在JDK1.5中:String s = "a" + "b" + "c"; 在编译时,编译器会自动引入java.lang.StringBuilder类,并使用StringBuilder.append方法来连接。虽然我们在源码中并没有使用StringBuilder类,但是编译器自动地使用了它,因为它更高效。在1.4时或之前使用的是StringBuffer连接的。
虽然在JDK1.5或以上版本中使用“+”连接字符串时为避免产生过多的字符串对象,编译器会自加使用StringBuilder类来优化,但是如果连接操作在循环里,编译器会为每次循环都创建一个StringBuilder对象,所以在循环里一般我们不要直接使用“+”连接字符串,而是自己在循环外显示的创建一个StringBuilder对象,用它来构造最终的结果。但是在使用StringBuilder类时也要注意,不要这样使用:StringBuilder.append(a + ":" + c); ,如果这样,那编译器就会掉入陷井,从而为你另外创建一个StringBuilder对象处理括号内的字符串连接操作。
上面第一个例子中,是一个涉及到循环的字符串连接,由于循环次数是不确定的,我们无法将整个连接过程用单个表达式描述,所以编译器不得不隐式地为每一个表达式创建一个 StringBuffer 的对象,这才是导致运行效率低下的原因。也正是在这种前提下,显式地使用一个 StringBuffer 来进行字符串连接才能提高运行效率。
所以,如果我们最终要得到的字符串是可以通过一个表达式就连接而成的话(如String s = "a" + "b" + "c";),那么无论是用“+”还是 StringBuffer 在编译后的运行效率是完全一样的。相比之下,使用“+”的可读性恐怕还要更好些,因为1.4或之前的代码中使用“+”的在现在1.5中编译时会使用StringBuilder,但那些已经显式使用了 StringBuffer 的代码就不得不靠手工维护了,所以使用“+”在有时(可以通过一个表达式就能搞定的话)可能会更好一些。
总之,在大多数情况下,我们应该尽可能将整个字符串的连接集中在一个表达式里描述,然后让编译器来替我们使用 StringBuffer/StringBuilder ,只有当字符串的连接不得不涉及到多条语句的时候,才有必要显式的使用 StringBuffer/StringBuilder。
下来看一下例子:
void f() {
String a = "a";
String b = "b";
String c = "c";
//这里只产生一个StringBuilder
String s1 = a + b + c;
//从字节码中可以看现,下面会产生两个StringBuilder
String s2 = a + b;//这里会产生一个StringBuilder
s2 = s2 + c;//这里还会产生一个StringBuilder
}
从上面可以看出,如果使用“+”接连拉,要尽量将连接操作在一个表达式中完成,而不要在多个表达式中进行连接,因为一个表达式会产生一个StringBuilder,多个连接表达式就会产生多个StringBuilder。
52、 通过接口引用对象(针对接口编程)
如果有适合的接口类型存在,那么对于参数、返回值、变量和域来说,就都应该使用接口类型进行声明。只有当你利用构造器创建某个对象的时候,才真正需要引用这个对象的类。
// 应该这样:
List<Foo> foo = new Vector<Foo>();
// !! 不应该这样:
Vector <Foo> foo = new Vector<Foo>();
如果你养成了用接口作为类型的习惯,你的程序将会更加灵活。当你决定更换实例时,所要做的就是只要改变构造器中类的名称(或者使用一个不同的静态工厂),例如,第一个声明可以改成:
List<Foo> foo = new ArrayList<Foo>();
周围的所有代码都可以继承工作,因为它们不知道原来的实现类型,所以不关注这种变化。
有一点值得注意:如果原来的实现提供了某特殊的功能,而这种功能并不是这个接口的通用约定所要求的,并且周围的代码又依赖于这种功能,那么关键的一点是,新的实现也要提供同样的功能。例如,如果代码依赖于Vector的同步功能,在声明中用ArrayList代替Vector就不正确了。如果依赖于实现的任何特殊属性,就要在声明变量的地方给这些需求建立相应的文档说明。
如果没有合适的接口存在,完全可以用类而不是接口来引用对象。例如,考虑值类,比如String和BigInteger。值类很少会用多个实现编写。它们通常是final的,并且很少有对应的接口。使用这种值类作为参数、变量、域或者返回类型是再合适不过了。
如果没有接口,但存在抽象类时,我也要优先考虑使用这个最基础的类来定义的类型。
不存在适当接口类型的最后一种情形是,类实现了接口,但是它提供了接口中不存在的额外方法——例如LinkedHashMap。如果程序依赖于这些额外的方法,这种类就应该只被用来引用它的实例,它很少应该被用作参数类型(第40条)。
53、 接口优先于反射机制(使用接口类型引用反射创建实例)
反射的代价:
1、 丧失了编译时类型检查的好处,包括异常检查。如反射的东西不存在时,在运行时将会失败。
2、 执行反射访问所需要的代码非常笨拙和冗长。
3、 性能损失。反射方法调用比普通方法调用慢了许多。
核心反射机制最初是为了基本组件的应用创建工具而设计的,普通应用程序在运行时不应该以反射方式访问对象。
如果只是以非常有限的形式使用反射机制,虽然也要付出小许代价,但是可以获得许多好处。对于有些程序,它们必须用到编译时无法获取的类,但是在编译时存在适当的接口或者是超类,通过它们可以引用这个类。如果是这种情况,就可以使用反射方式创建实例,然后通过它们的接口或者超类,以正常的方式访问这些实例。如果调用的构造器不带参数,我们根本不需要使用java.lang.reflect中的Constructor来反射出构造器对象,而是可以直接使用Class.newInstance方法就已经提供了所需要的功能。
类对于在运行时可能不存在的其他类、方法或者域的依赖性,用反射法进行管理,这种用法是合理的,但是很少使用。
总之,反射机制是一种功能强大的机制,对于特定的复杂系统编程任务,它是非常必要的,但它也是有一些缺点。如果你编写的程序必须要与编译时未知的类一起工作,如有可能,应该仅仅使用反射机制来实例化对象,而访问对象时则使用编译时已知的某个接口或者超类。
54、 谨慎使用本地方法
Java Native Interface(JNI)允许Java应用程序可以调用本地方法,所谓本地方法是指用本地程序设计的语言(如C或者C++)来编写的特殊的方法。它可以在本地语言中执行任意的计算任务后返回到Java语言。
本地方法主要有三种用途。它们提供了“访问特定于平台的机制”的能力,比如访问注册表和文件锁。它们还提供了访问遗留代码库的能力,从而可以访问遗留数据。最后,本地方法可以通过本地语言,编写应用程序中注重性能的部分,以提高系统的性能。
使用本地方法来访问特定于平台的机制与访问遗留代码是合法的。但使用本地方法来提高性能的做法不值得提倡,因为VM在逐渐的更优更快了,如1.1中BigInteger是在一个用C编写的快速多精度运行库的基础上实现的,但在1.3中,则完全用Java重写了,并进行了精心的性能调优,比原来的版本甚至更快一些。
使用本地方法有一些严重的缺点。因为本地语言不是安全的、不可移植、难调试,而且在进行本地代码时,需要相关的固定开销,所以如果本地代码只是做少量的工作,本地方法就可能降低性能。
总之,本地方法极少数情况下会需要使用本地方法来提高性能。如果你必须要使用本地方法访问底层的资源,或者遗留代码,也要尽可能少的使用本地代码。
55、 谨慎地进行优化
有三条与优化有关的格言是每个人都应该知道的:
1、 很多计算上的过失都被归咎于效率(没有必要达到的效率),而不是任何其他原因——甚至包括盲目地做傻事。
2、 不要去计较效率上的一些小小的得失,在97%的情况下,不成熟的优化才是一切问题的根源。
3、 在优化方面,我们应该两条规则:
规则1:不要进行优化。
规则2(仅针对专家):还是不要进行优化——也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。
所有这些格言都比Java程序设计语言的出现早了20年,它们讲述了一个关于优化的深刻真理:优化的弊小于利,特别是不成熟的优化。在优化过程中,产生软件可能既不快速,也不正确,而且还不容易修正。
不要因为性能而牺牲合理的结构。要努力编写好的程序而不是快的程序。好的程序体现了信息隐藏的原则:只要有可能,它们就会设计决策集中在单个模块里,因此,可以改变单个的决策而不会影响到系统的其他部分。
必须在设计过程中考虑到性能问题。遍布全局并且限制性能的结构缺陷几乎是不可能被改正的,除非重新编写系统。
要考虑API设计决策的性能后果。使公有的类型成为可变的,这可能会导致大量不必要的保护性拷贝。同样,在适合使用复合模式的公有类中使用继承,会把这个类与它的超类永远地束缚在一起,从而人为地限制了子类的性能。最后一个例子,在API中使用实现的类型而不是接口,会把你束缚在一个具体的实现上,即使将来出现更快的实现你也无法使用。
一旦谨慎地设计了程序并且产生了一个清晰、简明、结构良好的实现,那么就到了该考虑优化的时候了,假定此时你对程序的性能还是不满意。
总之,不要费力去编写快速的程序——应该努力编写好的程序,速度自然会随之而来。在设计系统的时候,特别是在设计API、线路层协议和永久数据库格式的时候(模块之间的交互与模块与外界的交互一旦定下来后是不可能更改的),一定要考虑性能的因素。当构建完系统之后,要测量它的性能。如果它足够快,你的任务就完了。如果不够快,则可以在性能剖析器的帮助下,找到问题的根源,然后设法优化系统中相关的部分。第一个步骤是检查所选择的算法:再多的低层优化也无法弥补算法的选择不当。
>>>《Practical Java》性能拾遗<<<
实践28:先要把焦点放在设计、数据结构和算法身上,不要因要求提高程序执行速度度,而放弃了良好、可靠的设计,转而是追求不可能达到或甚小的性能改良。
产生运行快的代码的一个规则是,只优化必要的部分。花费时间将代码优化,却未能给程序带来实质性的性能影响,就是在浪费时间。如果80%-90%的程序执行的时间花费在10%-20%的代码上面(80-20法则),那你最好找出这需要改善的10%-20%代码然后进行优化。
请记住,高效代码与良好的设计、明智地选择数据结构和明智地选择算法三者的密切程度,远大于与实现语言的关系。
实践29:不要依赖于编译期的优化技术。比如在编译时使用 javac -o,-o选项不一定能够为运行期产生优化代码。以前可能做过一些内联函数的优化,但后来这个优化选项被取消了(因为这个选项产生的代码并不会比你自己撰写的更好),不过那些能内联的函数的确在“运行期”由JIT进行了内联动作。在Java中这样的函数可以内联:如果函数体小而且可以由编译器静态决议,它就可以被视为内联的候选者。所谓“可被静态决议”的函数,就是“不能被覆写”的函数,不能覆写的函数是private、static或final函数。
程序员必须明白,“常见”的Java编译器几乎做不了什么优化工作,面对这种情况,我们只有三个选择:
1、 手工优化Java源码,以求获得更好的性能。
2、 使用某个第三方优化编译器,将源码编译为优化的bytecode。
3、 依靠诸如JIT或Hotspot这新的“运行期优化技术”。
实践30:理解运行期代码优化技术。JIT的目的在于将bytecode于运行期转换为本机二进制码,它是一种运行期代码优化技术,有大部分桌面系统和企业系统的JVMS伴随有JIT。JIT优化时需先运转起来,这也是需要消耗时间的,它是针对相对较少的运行时间而设计,因为它们是存在是为了加速代码,如果JIT做的工作越多,它运行的时间也就越长,如果这样你的程序运行时间就会更长。
实践31:使用StringBuffer或StringBuilder进行字符连接要优于String。非并发环境下优先使用非线程安全集合类。
实践32:将对象的创建成本降到最低。创建一个轻对象就比创建一个重型对象快得多。所谓轻型对象是指:既不具有长继承链,也不含有其他引用域。重型对象恰恰相反。创建一个对象要经过heap的分配、内存清零、初始化最深层父类的域、调用最深层父类构造函数、沿着继承树向下到本类初始化域与执行构造器一系列的动作,所以创建重型对象将可能会比较慢。
实践33:对象的创建成本是非常昂贵的,绝对不要创建一些不必要的对象:
public int[] addArrays1(int[] arr1, int[] arr2) {//优先前
//下面创建的两个变量过早,可能用不着
int[] result = new int[ArraySize];
IllegalArgumentException exc = new IllegalArgumentException(
"arrays are invalid");
if (arr1 != null && arr2 != null && arr1.length == ArraySize
&& arr2.length == ArraySize) {
for (int i = 0; i < ArraySize; i++)
result[i] = arr1[i] + arr2[i];
} else
throw exc;
return result;
}
public int[] addArrays2(int[] arr1, int[] arr2) {//优先后
if (arr1 != null && arr2 != null && arr1.length == ArraySize
&& arr2.length == ArraySize) {
//只有在必要时才创建对象
int[] result = new int[ArraySize];
for (int i = 0; i < ArraySize; i++)
result[i] = arr1[i] + arr2[i];
return result;
}
//只有在必要时才创建对象
throw new IllegalArgumentException("arrays are invalid");
}
实践34:将同步化降至最低。
public synchronized int top1(){
return intArr[0];
}
public int top2(){
synchronized(this){
return intArr[0];
}
}
我们可以通过生成的字节码可以看出top2比top1体积大且还慢。因为top2中它要进行和异常的处理。记住,top1虽然带有synchronized修饰符,这并不会生成额外代码(性能还是会打折扣的),但top2会。如果你在函数体中使用synchronized,就会产生操作码monitorenter和moniterexit的bytecode,以及为了处理异常而附加的代码,之所以这样是为了在出现异常后确保退出前释放锁,所以top1比top2性能略高一些。总之,不管是同步方法还是使用同步块,都会大大降低性能,但如果整个函数都需要被同步化,则为了产生体积较小的且执行速度较快的代码,请优先使用函数修饰符,而不是在函数内使用synchronized块。
如果我们不需要synchronized方法,但我们又无法修改被调用的类,我们可以继承这个类,来重写那个被同步了的方法,不过样会增加代码维护量,不建议这么做,只是说可以这样实现。
当然,同步方法是可能会导致长时间的占用锁而导致并发性降低,一般只有短的方法才使用,如果方法体很长,而且有些代码行是不需要同步访问的,这时我们使用同步块为了高并发性可能会好些。
实践35:尽可能使用stack(局部)变量。访问stack变量要快于静态的或实例域,因为VM所做的相应工作远少于访问static变量或instance变量所做的工作。考虑下面代码:
class StackVars {
private int instVar;
private static int staticVar;
// 访问局部stack变量
void stackAccess(int val) {
int j = 0;
for (int i = 0; i < val; i++)
j += 1;
}
//访问实例域变量
void instanceAccess(int val) {
for (int i = 0; i < val; i++)
instVar += 1;
}
// 访问静态域变量
void staticAccess(int val) {
for (int i = 0; i < val; i++)
staticVar += 1;
}
}
经过测试发现stackAccess快于其它两个方法,不过可以优先它们,经过下面的优化后性能与stackAccess不相上下:
// 访问实例域变量
void instanceAccess(int val) {
int j = instVar;
for (int i = 0; i < val; i++)
j += 1;
}
// 访问静态域变量
void staticAccess(int val) {
int j = staticVar;
for (int i = 0; i < val; i++)
j += 1;
}
实践36:使用static、final和private函数以促成inlining(内联),此类函数可以在编译期被静态决议,让这此函数成为inlining的候选者,因为以函数本体替换函数调用会使代码执行更快。通常inlined函数包括class中常见而小巧的取值函数和设值函数,这些函数往往只有一两行代码。这些函数不会被目前市面上的大部Java编译器“inline化”,不过它们将被目前市面上大部分的JITs于运行期“inline化”,并导致性能显著的提升。另外,inlined函数只有在“被多次调用”的情况下,才会获得令人满意的性能提升。
实践37:instance变量初始化一次就好。不要在构造器内重复对实例域赋类初始默认值,也不要在定义时给实例域重新赋类初始默认值,这都是画蛇添足的做法,反而会影响性能,因为在调用构造器时或之前都会再一次执行赋值操作,而赋的这些值都是在对象内存分配时VM就已经将实例域初始化为相应的缺省值了。下面是错误的做法:
class Foo {
private int count;
private boolean done = false;//多余
private Vector vec;
public Foo() {
count = 0;//多余
vec = new Vector(10);
}
}
具有优化能力的编译器理应消除这些多余的赋值操作,不幸的是许多编译器没有这种能力。
但要记住,local变量没有缺省值,因此你必须将它明确地初始化,否则编译不通过。
实践38:使用基本类型使代码更快更小。基本类型体积(所需存储空间)小,访问快;而包装类型体积大,访问慢。考虑以下函数,stack之中有一个基本类型的int变量与一个reference指向的Integer对象:XXXXXXXXXXXXXXXXXX
实践39:不使用使用迭代器来遍历ArrayList、Vector,而是直接使用数组索引在通过for循环遍历。
实践40:使用System.arraycopy()来复制数组(在1.6中可以使用Arrays.copyOf方法更简洁),不需要使用for循环来循环拷贝每个元素,System.arraycopy()是本地函数,它可以直接、高效地移动“原array”的内存内容到“目标array”,这个动作之快中以消减本机函数的调用代价,固然调用函数需要代价,但那怕是在通过for循环直接面方法体里,也不及调用一下本地方法。
实践41:优先使用数组,然后才考虑ArryaList和Vector。如果不需同步,也请先使用ArrayList。
实践42:尽可地复用已存在的对象。
实践43:延迟加载相关的域。对有些不必马上初始化,而是等到使用时再初始化,不必要一下将整个对象都初始化完整。
实践44:以手工方将代码优化。
1、 删除空白函数
2、 删除无用代码
3、 使用复合赋值操作:i = i + x 改为 i += x;
4、 如果果能,尽量将变量声明成final,以便于在编译时期就能执行计算。
5、 对于方法里重复出现的表达式(比如i+j出重出现)或重复调用的有结果的方法(如果ArrayList的size())时,一定要将它们先赋值给一个临时变量后再使用这个临时变量来代替使用。
6、 对于小循环(次数少),我们可以将它展开,不使用循环,从而产生更快的执行效果,因为展开后相于变相的inline。
7、 简化代数:
int a = 1 ,b =2, c = 3;
int x = f*a + f*b + f*c;
可以做如下优化:
int x = f*(a + b + c);
8、 使用临时变量代替循环体内的固定表达式或返回结果不变的方法。
void fuc(int a, int b, List list) {
for (int i = 0; i < list.size(); i++) {
list.add(Integer.valueOf(i + a + b));
}
}
上面可做如下优化:
void fuc(int a, int b, List list) {
int x = a + b;
int size = list.size();
for (int i = 0; i < size; i++) {
list.add(Integer.valueOf(i + x));
}
}
实践45:编译为本机代码。缺点是失去了跨平台性。
实践46:使用包装类型时,如果要构造的包装类型是-128到127间时,请使用valueOf静态工厂方法来构造,不要使用new,因为这样会使用缓存中的包装对象(包装类型都是不可变的,所以缓存具有意义)。
56、 遵守普遍接受的命名惯例
任何将在你的组织之外使用的包,其名称都应该以你的组织的Internet域名形状,并且顶级域名放在前面,例如com.sum。标准类库和一些可选的包,其名称以java和javax开头,这属于例外,用户创建的包名绝不能以java和javax开头。包名称的其余部分应该包括一个或者多个描述该包的组成部分,这些组成部分应该比较简短,通常不超过8个字符,使用单词缩写或字母缩写。
类和接口的名称,包括枚举和注解类型的名称,都应该使用一个或者多个单词,每个单词的首字母大写,例如Timer和TimerTask。应该尽量避免用缩写,除非是一些首字母写和一些通用的缩写,比如max和min。对于首字母缩写,强烈建义采用仅首字母大写的形式,如HttpUrl就比HTTPURL好。
方法和域的名称与类和接口的名称一样,都遵守相同的字面惯例,只不过方法或者域的名称的第一个字母应该小写,哪怕第一个单词全由缩写组成。
上述规则的唯一例外的是“常量域”,它的名称应该包含一个或者多个大写的单词,中间用下划线符号隔开,例如VALUES域NEGATIVE_INFINITY。注意,常量域是唯一推荐使用下划线的情形。
局部变量名称的字面命名惯例与成员名称类似,只不过它也允许缩写。
类型变量名称通常单个字母组成,这个字母通常是以下一种上类型之一:T表示任意类型,E表示集合元素类型,K和V表示映射的键和值类型,X表示异常。如果同时有多个类型参数,则可以是T、U、V或T1、T2、T3。
语法命名惯例比字面惯例更加灵活,也更有争议。对于包而言,没有语法命名惯例。类包括枚举类型,通常用一个名词或者名词短言命名,如Timer、BufferedWriter。接口的命名与类相似,例如Collection或Comparator,或者使用一个以“-able”或者“-ible”结尾的形容词,例如Runnable、Iterable或者Accessible。由于注解类型有很多用处,因此没有单独安排词类。名词、动词、介词、形容词都常用。
执行某个动作的方法通常用动词或者动词短语来命名,例如append或drawImage。对于返回boolean值的方法,其名称往往以单词“is”开头,很少使用has,后面跟名词或名词短语,或者任何具有形容词功能的单词或短语,如isDigit、isEmpty、isProbablePrime、isEnabled或者hasSiblings。
如果方法返回被调用对象的一个非boolean的函数或者属性,它通常用名词、名词短语,或者以动词“get”开资源丰富的动词短语来命名,例如size、hashCode或者getTime。但如果方法所在的类是个Bean,就要强制使用以“get”开头的形式。另外,如果这个类包含一个方法用于设置同样的属性,则强烈建议采用这种形式,在这种情况下,这两个方法应该被命名为getAttribute和setAttribute。
有些方法的名称需专门提出来说。转换对象类型的方法、返回不同类型的独立对象的方法,通常被称为toType,如toString和toArray。返回视图(视图的类型不同于接收对象的类型)的方法通常被称为asType,例如asList。返回一个与被调用对象同值的基本类型方法,通常被称为typeValue,如intValue。静态工厂的常用名称为valueOf、of、getInstance、newInstance、getType和newType(见第1条)。
域名称的语法惯例没有很好地建立起来,也没有类、接口和方法的惯例那么重要,因为域会很少暴露出来的。boolean类型的域与返回boolean类型的访问方法很类似,但是省去了开头的“is”,例如initialized和composite。其它类型的域通常用名词或名词短语来命名,比如height、digits或bodyStyle。局部变量的方法惯例类似于域的语法惯例。但是更弱一些。
第九章 异常
57、 只针对异常的情况才使用异常
也许你在将来会碰到下面这样的代码,它是基本异常模式的循环:
try{
int i = 0;
while(true)
range[i++].climb();
}catch(ArrayIndexOutOfBoundsException e){
}
这所以有些人会这么做,是因为他们企图利用Java的错误判断机制来提高性能,因为VM对每次数组访问都要检查越界情况,即使是使用for-each,他们认为正常的循环终止测试只是被编译器隐藏了,但在循环中仍然可见,这种考虑无疑是多余的。
实例上,在现代的JVM实例上,基本异常的模式比标准模式要慢得多。
异常应该只用于异常的情况下,它们永远不应该用于正常的控制流。
设计良好的API不应该强迫它的客户端为了正常的控制流程而使用异常。如Iterator接口有一个“状态相关”的next方法,和相应的状态测试方法hasNext,这使得利用传统的for循环对集合进行迭代的标准模式成为可能:
for(Iterator<Foo> i = coolection.iterator(); i.hasNext()){
Foo foo = i.next();
…
}
如果Iterator缺少hasNext方法,客户端将被迫改用下面的做法:
try{
Iterator<Foo> i = collection.iterator();
while(true){
Foo foo = i.next();
…
}
}catch(NoSuchElementException e){
}
另一种提供单独的状态测试方法的做法是,如果“状态相关的”方法被调用时,该对象处于不适当的状态之中时,它就会返回一个可识别的值,比如null。这种方法对于Iterator而言不合适,因为null是next就去的合法返回值。
对于“状态测试方法”和“可识别的返回值”这两种做法,有些告诫:如果对象在缺少外部同步的情况下被并发访问,或者可被外界改变状态,使用可被识别的返回值可能是很有必要的,因为在调用“状态测试”方法和调用对应的“状态相关”方法的时间间隔之中,对象的状态有可能会发生变化。但从性能角度考虑,使用可被识别的返回值要好些。如果所有其他方面都是等同的,那么“状态测试”方法则略优先可被识别的返回值。
总之,异常是为了在异常情况下使用而设计的,不要将它们用于普通的控制流,也不要缩写迫使它们这么做的API。
>>>《Practice Java》异常拾遗<<<
如果finally块中出现了异常没有捕获或者是捕获后重新抛出,则会覆盖掉try或catch里抛出的异常,最终抛出的异常是finally块中产生的异常,而不是try或catch块里的异常,最后会丢失最原始的异常。
如果在try、catch、finally块中都抛出了异常,只是只有一个异常可被传播到外界。记住,最后被抛出的异常是唯一被调用端接受到的异常,其他异常都被掩盖而后丢失掉了。如果调用端需要知道造成失几的初始原因,程序之中就绝不能掩盖任何异常。
请不要在try块中发出对return、break或continue的调用,万一无法避免,一定要确保finally的存在不会改变函数的返回值(比如说抛异常啊、return啊以及其他任何引起程序退出的调用)。因为那样会引起流程混乱或返回值不确定,如果有返回值最好在try与finally外返回。
不要将try/catch放在循环内,那样会减慢代码的执行速度。
如果构造器调用的代码需要抛出异常,就不要在构造器处理它,而是直接在构造器声明上throws出来,这样更简洁与安全。因为如果在构造器里处理异常或将产生异常的代码放在构造器之外调用,都将会需要调用额外的方法来判断构造的对象是否有效,这样可能忘记调用这些额外的检查而不安全。
58、 对可恢复的情况使用受检异常,对编程错误使用运用时异常
Java程序设计语言提供了三种异常:受检的异常(checked exception)、运行时异常(run-time exception)和错误(error)。关于什么时候适合使用哪种异常,虽然没有明确的规定,但还是有些一般性的原则的。
检测性异常通常是由外部条件不满足而引起的,只要条件满足,程序是可以正常运行的,即可在不修改程序的前提下就可正常运行;而运行时异常则是由于系统内部或编程时人为的疏忽而引起的,这种异常一定要修正错误代码后再能正确运行。受检异常对客户是有用的,而运行时异常则是让开发人员来调试的,对客户没有多大的用处。
在决定使用受检异常还是未受检异常时,主要的原则是:如果期望调用者能够适当地恢复,对于这种情况就应该使用受检的异常。抛出的受检异常都是对API用户的一种潜在的指示:与异常相关的条件是调用这个方法的一种可能的结果。
有两种未受检的异常:运行时异常和错误。在行为上两种是等同:它们都不需要捕获。如果抛出的是未受检异常或错误,往往就属于不可恢复的情形,继续执行下去有害无益。如果程序未捕获这样的异常或错误,将会导致线程停止,并出现适当的错误消息。
用运行时异常来表明编程错误。大多数的运行时异常都表示违返了API规约,API的客户同有遵守API规范。例如,数组访问的约定指明了数组的下标值必须在零和数组长度减1之间,ArrayIndexOutOfBoundsException表明了这个规定。
按照惯例,错误往往被JVM保留用于表示资源不足、约束失败,或者其他程序无法继续执行的条件。由于这已经是个几乎被普遍接受的惯例,因此最好不要再实现任何新的Error子类。因此,你实现的所有未受检异常都应该是RuntimeException的子类或间接是的。
总而言这,对于可恢复的情况,使用受检的异常;对于程序错误,则使用运行时异常。当然,这也不总是这么分明的。例如,考虑资源枯竭的情形,这可能是由于程序错误而引起的,比如分配了一块不合理的过大的数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大所造成的,这种情况可能就是可恢复的。API设计者需要判断这样的资源枯竭是否允许。如果你相信可允许恢复,就使用受检异常,否则使用运行时异常。如果不清楚,最好使用未受检异常(原因请见第59条)。
API的设计都往往会忘记,异常也是个完全意义上的对象,可以在它上面定义任意的方法,这些方法的主要用途是为捕获异常的代码而提供额外的信息,特别是关于引发这个异常条件的信息。如果没有这样的方法,API用户必须要懂得如何解析“该异常的字符串表示法”,以便获得这些额外的信息,这是极不好的作法。类很少以文档的形式指定它们的字符串表示的细节,因此,不同的实现,不同的版本,字符串表示可能会大相径庭,因此,“解析异常的字符串表示法”的代码可能是不可移植的,也是非常脆弱的。
因为受检异常往往指明了可恢复的条件,所以,这于这样的异常,提供一些辅助方法尤其重要,通过这些方法,调用都可以获得一些有助于恢复的信息。例如,假设因为没有足够的钱,他企图在一个收费电话上呼叫就会失败,于是抛出检查异常。这个异常应该提供一个访问方法,以便用户所缺的引用金额,从而可以将这个数组传递给电话用户。
59、 避免不必要地使用受检异常
受检异常与运行时异常不一样,它们强迫程序员处理异常的条件,大大增强了可靠性,但过分使用受检异常会使用API使用起来非常不方便。如果方法抛出一个或者多个受检异常,调用都就必须在一个或多个catch块中处理,或者将它们抛出并传播出去。无论是哪种,都会给程序员添加不可忽视的负担。当然,如果程序员能处理这一类异常,则不算做负担,但以下决对是负担:
}catch(TheCheckedException e){
throw new AssertionError();// 断言不会发生异常
}
或
}catch(TheCheckedException e){// 哦,失败了,不能恢复
e.printStackTrace();
System.exit(1);
}
上面两种方式都不是最好的处理方式,之所以程序员这样处理,是因为他们认为这根本就是一种不可恢复的异常(当然程序员这种认为要是正当的),如果使用API的程序员无法做得比这更好,那么未受检异常可能更为合适,所以异常设计者应将TheCheckedException设计成运行时异常会更好些。这种例子的反例就是CloneNotSupportedException,它是Object.clone抛出来的,而Object.clone应该只是在实现了Cloneable的对象上者可以被调用,这显然是API调用都未实现该接口所导致,除非程序实现该接口,否是不可恢复的,所以CloneNotSupportedException应该设计成运行时异常或许更加合理一些。
如果方法只抛出单个受检异常,也会导致该方法不得在try块中,在这种情况下,应该问自己,是否有别的途径来避免API调用者使用受检的异常。这里提供这样的参考,我们可以把抛出的单个异常的方法分成两个方法,其中一个方法返回一个boolean,表明是否该抛出异常。这种API重构,把下面的调用:
try{//调用时检查异常
obj.action(args);//调用检查异常方法
}catch(TheCheckedExcption e){
// 处理异常条件
...
}
重构为:
if(obj.actionPermitted(args)){//使用状态测试方法消除catch
obj.action(args);
}else{
// 处理异常条件
...
}
这种重构并不总是合适的,但在合适的地方,它会使用API用起来更加舒服。虽然没有前者漂亮,但更加灵活——如果程序员知道调用肯定会成功,或不介意由调用失败而导致的线程终止,则下面为理为简单的调用形式:
obj.action(args);
如果你怀疑这个简单的调用是否成功,那么这个API重构则可能就是恰当的。这种重构之后的API在本质上等同于第57条件的“状态测试方法”,并且,同样的告诫也要遵循(告诫参考57)。
60、 优先使用标准异常
java平台提供了一组基本未受检查的异常,它们满足了绝大多数API的异常抛出的需要。
重用现有的异常好处最主要的是,易学和易使用,与已经熟悉的习惯用法一致。第二可读性更好,不会出现不熟悉的异常。
最经常被重用的异常是IllegalArgumentException。当调用者传递的参数值不合适的时候,往往就会抛出这个异常。如需要一个正数而传递的是一个负数时。
另一个经常被重用的异常是IllegalStateException。如果因为对象的状态而使用调用非法,则会抛出。如某个对象被正确初始化之前就调用。
可以这么说,所有错误的方法调用都可以被归结为非法参数或者非法状态,但是,还有更具体的异常用于非法参数和非法状态。例如在参数不允许为null或传递数组索引越界时,就应该抛出NullPointerExcption异常与IndexOutOfBoundsException异常,而不是IllegalArgumentException。
另一个值得了解的能用异常是ConcurrentModificationException。如果一个对象被设计专用于单线程或者与外部同步机制配合使用,一旦发现它正在或已经被修改,就抛出该异常。
最后一个通用的异常是UnsupportedOperationException。如果对象不支持所请求的操作,就会抛出这个异常。
前面列举的最是最常用的异常,但在有些情况下,这些也是可重用的,如ArithmeticException和NumberFormatException异常可用在算术对象中。
一定要确保抛出的异常的条件与该异常的文档中的描述的条件是一致的,如果希望稍微增加更多的失败-捕获信息,可以把现有的异常进行子类化。
61、 在无法避免底层异常时,抛出与系统直接相关的异常
如果方法抛出的异常与所执行的任务没有明显的联系,这种情形将会使人不知所措,当底层的异常传播到高层时往往会出现这种情况。这了使人困惑之外,抛出的底层异常类会污染高层的API(高层要依赖于底层异常类)。为了避免这个问题,高层在捕获底层抛出的异常的同时,在捕获的地方将底层的异常转换后再重新抛出会更好:
// 异常转换
try {
// 调用底层方法
...
} catch(LowerLevelException e) {
//捕获底层抛出的异常后并转换成适合自己系统的异常后再重新抛出
throw new HigherLevelException(...);
}
下面是个来自AbstractSequentialList类中的底层异常转换的实例,该数是List的一个抽象类,它的直接子类为LinkedList,在这个例子中,按照List<E>接口中的get方法的规范(规范中说到:如果索引超出范围 (index < 0 || index >= size()),就会抛出IndexOutOfBoundsException异常),底层方法只要可能抛出异常,我们就需要转换这个异常,下面是AbstractSequentialList类库的做法:
/**
* Returns the element at the specified position in this list.
* @throws IndexOutOfBoundsException if the index is out of range
* ({@code index < 0 || index >= size()}).
*/
public E get(int index) {
ListIterator<E> i = listIterator(index);
try {
return i.next();//Iterator的next会抛出NoSuchElementException运行时异常
} catch(NoSuchElementException e) {
/*
* 但接口规范是要求抛出IndexOutOfBoundsException异常,所以需要转换。当然这种
* 转换也是合理的,因为该方法的功能特性就是按索引来取元素,在索引越界的情况
* 下抛出NoSuchElementException也是没有太大的问题的(当然劈开规范来说的),但
* 抛IndexOutOfBoundsException异常会更适合一些
*/
throw new IndexOutOfBoundsException("Index: " + index);
}
}
另一种异常转换的形式是异常链,如果底层的异常对于高层调试有很大帮助时,使用异常链就非常合适,这样在高层我们可以通过相应的方法来获取底层抛出的异常:
// 异常链
try {
... // 调用底层方法
} catch (LowerLevelException cause) {
// 构造异常链后重新抛出
throw new HigherLevelException(cause);
}
尽管异常转换与不加选择地将捕获到的底层异常传播到高层中去相比有所改进,但是它不能滥用。处理来自底层异常的首选做法是根本就让底层抛出异常,在调用底层方法前确保它会成功,从而来避免抛出异常,另外,我们有时也可以在调用底层方法前,在高层检查一下参数的有效性,从而也可以避免异常的发生,当然这种做法(不要抛出底层异常的做法)只是对底层抛出的是运行时异常时才可行。如果确实无法避免(如低层抛出的是受检异常或是运行时异常但根本无法阻止)低层异常时,次选方案是让高层绕开这些异常,并将异常使用日志记录器记录下来供事后调试。
总之,处理底层异常最好的方法首选是阻止底层异常的发生,如果不能阻止或者处理底层异常时,一般的做法是使用异常转换(包括异常链转换),除非底层方法碰巧可以保证抛出的异常对高层也合适才可以将底层异常直接从底层传播到高层。异常链对高层和低层异常都提供了最佳的功能:它允许抛出适当的高层异常的同时,又能捕获底层的原因进行失败分析。
62、 每个方法抛出的异常都要有文档描述
如果一个方法可能抛出多个异常类,则不要使用“快捷方式”声明它会抛出这此异常类的某个超类。永远不要声明一个方法“throws Exception”,或者更糟的是声明“throws Throwable”,这是极端的例子,因为它掩盖了该方法可能抛出的其他异常。
对于方法可能抛出的未受检异常,如果将这些异常信息很好地组织成列表文档,就可以有效地描述出这个方法被成功执行的前提条件。每个方法的文档应该描述它的前提条件,这是很重要的,在文档中描述出未受检的异常是满中前提条件的最佳做法。
对于掊中的方法,在文档中描述出它可能抛出的未受检异常显得尤其重要。这份文档成了该接口的通用约定的一部分,它指定了该接口的多个实现必须遵循的公共行为。
未受检异常也要在@throws标签中进行描述。
应该注意的是,为每个方法可能抛出的所有未受检异常建立文档是一种很理想的好的想法,但在实践中并非总能做到这一点。比如在后面的版本中如果修改代码,有可能抛出另外一个未受检异常,这不算违反源代码或者二进制兼容性。
如果某类所有方法抛出同一个异常,那么这个异常的文档可以描述在类文档中。
总之,要为你编写的每个方法所能摆好出的每个异常建立文档,对于未受检和受检异常,以及对于抽象的和具体的方法也都一样。
63、 异常信息中要包含足够详细的异常细节消息
异常的细节消息对异常捕获者非常有用,对异常的诊断是非常有帮助的。
为了捕获失败,异常的细节消息应该包含所有“对该异常有作用”的参数和域值。例如IndexOutOfBoundsException异常的细节消息应该包含下界、上界以及没有落在界内的下标值,因为这三个值都有可能引起这个异常。
异常的细节消息不应该与“用户层次的错误消息”混为一谈,后都对于最终用户而言必须是可理解的。与用户层次的错误消息不同,异常的详细消息主要是让程序员用来分析失败原因的。因此,异常细节消息的内容比可理解性重要得多。
为了确保在异常的细节消息中包含足够的能捕获失败的信息,一种办法是在异常的构造器而不是字符串细节消息中引入这些信息。然后,有了这些信息,只要把它们放到消息描述中,就可以自动产生细节消息。例如,IndexOutOfBoundsException本应该这样设计的:
/**
* Construct an IndexOutOfBoundsException.
*
* @param lowerBound the lowest legal index value.
* @param upperBound the highest legal index value plus one.
* @param index the actual index value.
*/
public IndexOutOfBoundsException(int lowerBound, int upperBound,int index) {
// 构建详细的捕获消息
super("Lower bound: " + lowerBound +
", Upper bound: " + upperBound +
", Index: " + index);
// 存储失败的细节消息供程序访问
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
但遗憾的是,Java平台类库并没有使用这种做法,但是,这种做法仍然值得大力推荐。
正如第58条中所建议的,为异常的“失败捕获”信息提供一些访问方法是合适的(如在上述例子中的访问lowerBound、upperBound和index域的方法,注上面已省),提供这样的访问方法对于受检的异常,比对于未受检的异常更为重要,因为失败——捕获信息对于从失败中恢复是非常有用的。程序员访问未受检查异常的细节是很少见,然而,即使对于未受检的异常,提供这些访问方法也是明智的。
64、 努力使失败保持原子性
当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败是发生在执行某个操作的过程中间。对于受检异常而言,这尤其重要,因为调用者希望能从这种异常中进行恢复(即继续调用发生异常对象的方法)。一般而言,失败的方法调用应该使对象保持在它被调用之前的状态。具有这种属性的方法被称为具有失败原子性。
有几种途径可以实现这种效果。最简单的办法莫过于设计一个不可变的对象。如果对象是不可变的,失败原子性就是显然的。如果一个操作失败了,它可能会阻止创建新的对象,但是永远也不会使用已有的对象保持在不一致的状态中,因为当每个对象被创建之后它就处于一致的状态中,以后不会再发生变化。
对于在可变对象上执行操作的方法,获得失败原子性最常见的办法是,在执行操作之前检查参数的有效性,这可以使得在对象的状态被修改前,先抛出适当的异常。例如,考虑第6条中的Stack.pop方法:
public Object pop() {
if (size == 0)//先对状态进行有效性检测
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
如果取消对初始大小(size)的检查,当这个方法企图从一个空栈中弹出元素时,它仍然会抛出异常。然而,这会导致size域保持在不一致的状态(负数)之中,从而导致将来对该对象的任何方法调用都会失败。
一种类似的获得失败原子性的办法是,调整计算处理的过程的顺序,使得任何可能失败的计算部分都在对象状态被修改之前执行。如果对参数的检查只有在执行了部分计算之后(这些提前执行的计算与后面需检查的参数要无关者可以啊!)才能进行,这种办法实际上就是上一种办法的自然扩展。例如放入TreeMap中的元素都要具有比较能力,在放入元素时,put方法是先进行了比较方法compare()的调用(如果放的元素不具有比较能力时会抛出ClassCastException),然后才是修改对象内部状态如size的自增与红黑树的结构调整。
第三种获得失败原子性的办法远远没有那么常用,做法是编写一段恢复代码,由它来拦截操作过程中发生的失败,以及使对象回滚到操作开始之前的状态上。
最后一种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内部。如果数据保存在临时的数据结构中,计算过程会更加快的话,使用这种办法就更加可行了。例如,Collections.sort在执行排序之前,首先把它的输入列表转换到一个数组中,以便降低在排序的循环内访问元素所需要的开销,这是出于性能考虑,但是,它增加了一项优势:即使排序失败,它也能保证输入列表保持原样。
虽然一般情况下都希望实现失败原子性,但并非总是可以做到的。例如,如果两个线程企图在没有同步机制下,并发地修改了同一个对象,这个对象就有可能处于不一致的状态中。因此,在捕获了ConcurrentModificationException异常之后再假设对象仍然是可用的,这就不正确了,此时不需要努力保持失败原子性了。
即使在可以实现失败原子性场合,也并不是人们所希望的,因为某些操作,会增加开销或者复杂性,实现失败原子性应该往往是轻松自如的。
65、 不要忽略异常
请不要这样做:
// 忽略异常块,强烈反对
try {
...
} catch (SomeException e) {
}
空的catch块会使异常达不到应用的目的。忽略异常就如同火警信号器关掉了。至少,catch块也应该包含一条说明,解释为什么可以忽略这个异常。
有一种情况可以忽略异常,即关闭FileInputStream的时候,因为你还没有改变文件的状态,因此不必执行任何恢复动作,并且已经从文件中读取到所需要的信息了,因此不必终止正在进行的操作,即使在这种情况下,也得要把异常记录下来。
本条目中的建议同样适用于受检异常和未受检异常。
第十章 并发
66、 同步访问共享的可变数据
许多程序员把同步的概念仅仅理解为一个种互斥的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象的内部不一致的状态。正确地使用同步可以保证其他任何方法都不会看到对象处于不一致的状态中。这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态中(即原子性),它还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改结果(即可见性)。
我的理解,同步 = 原子性 + 可见性
synchronized就是同步的代名词,它具有原子性与可见性。而volatile只具有可见性,但不具有原子性。可见性其实说的就是在读之前与写之后都与主内同步,除了可见性外,volatile还严禁语义重排:“禁止reorder任意两个volatile字段或者volatile变量,并且同时严格限制(尽管没有禁止)reorder volatile字段(或变量)周围的非volatile字段(或变量)。”
Java语言规范保证或写是一个变量是原子的(即数据的读写是不可分割的。注,不可分割的操作并不意味“多线程安全”),除非这个变量的类型为long或double[JLS 17.4.7]。换句话说,读取一个非long或double类型的变量,可以保证返回值是某个线程完整保存在该变量中的值(即要么读取还没有修改的值,要么读取到某线程修改完后的值,但决不会读到另一线程对变量的一半或一部分修改后的值,如一个int型变量,某线修改该变量的前16位后,被另一线程读到,这是不可能的;而long或double类型的变量就完全有可能这样,读到的是另一线程写入的高32位,而低32位还是原来值),即使用多个线程在没有同步的情况下并发地修改这个变量也是如此。
你可能听说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值(严格的说是完整的值,即不会读取还未修改完成的值),但是它并不保证一个线程写入的值对于另一个线程将是可见的(即另一线程修改完后,其他线程有可能将永远读不到这个修改后的值)。为了在线程之间进行可靠的通信(需要靠可见性来保证),也为了互斥访问(需要原子性来保证),同步(需要可见性和原子性来保证)是必要的。这归因于Java语言规范中内存模型,它规定了一个线程所做的变化何时以及如何让其他线程可见[JLS 17]。
如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。考虑下面这个阻止一个线程妨碍另一个线程的任务。由于boolean域的读和写操作都是原子的,程序员在访问这个域的时候不再使用同步,这是错误的做法:
import java.util.concurrent.TimeUnit;
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested)
i++;
}
});
backgroundThread.start();
//睡一秒
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
你可能期待这个程序运行大约一秒钟之后,主线程将stopRequested设置为true,致使后台线程的循环终止。但是在我的机子上,这个程序永远不会终止:因为后台线和永远在循环中!
问题在于,由于没有同步,就不能保证后台线程何时“看到”主线程对stopRequested的值所做的修改。在没有同步的情况下,VM将个这个代码:
while (!stopRequested)
i++;
转变成这样:
if (!stopRequested)
while (true)
i++;
这是完全有可能的,也是可以接受的。这种优化称作提升(hoisting),正是HopSpot Server VM的工作。结果是个“活性失败”:这个程序无法结束。修改这个问题的一种方式是同步访问stopRequested域,修改如下:
public class StopThread {
private static boolean stopRequested;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested())
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
注意上面的写方法(requestStop)和读方法(stopRequested)都被同步了,只同步写方法或读方法是不够的!
StopThread程序中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果(即可见性),而不是为了互斥访问(即原子性)。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的替代方法,它更加简洁,性能也可能更好。这种替代就是将stopRequested声明为volatile,第二版本的StopThread中的锁就可以省略。虽然volatile修饰符不具有互斥访问的特性,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被其他线程写入的值,下面是使用volatile修正后的版本:
public class StopThread {
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested)
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
上面就说了,volatile只具有可见性,而不具有原子性,所以使用时要格外小心,请考虑下面的方法,假设它要产生序列号:
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
这个方法的目的是要确保每次调用都要返回不同的值,而且是递增的(只要不超过2^32次调用)。这个方法的状态只包含一个可原子访问的域:nextSerialNumber,不同步的情况下读到的这个域的所有可能的值都是合法(即不可能读到修改未完成的值),但是,这个方法仍然无法工作。
问题在于,增量操作(++)不是原子的。它在nextSerialNumber域中执行两项操作:首先它读取值,然后写回一个新值,相当于原来的值再加上1。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是“安全性失败”:这个程序会计算出错误的结果。
修正generateSerialNumber方法的一种方法是是在它的声明中加上synchronized修饰符。这样可能确保多个调用不会交叉存在。一旦这么做,就可以且应该从nextSerialNumber中删除volatile修饰符。为了让这个方法更可靠,要用long代替int。但最好还是遵循第47条中的建议,使用类AtomicLong,它是java.util.concurrent.atomic的一部分,它比同步版本的generateSerialNumber性能上可能要更好,因为atomic包使用了非锁定的线程安全技术来做到同步的,下面是使用AtomicLong修正后的版本:
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
避免本条目中所讨论到的问题的最佳办法是不共享可变的数据,要么共享不可变的数据(见第15条),要么压根不共享。
让一个线程在我短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作为事实上不可变的。将这种对象引用从一个线程传递到其他的线程被称作安全发布。安全发布对象引用有许多种方法:可以将它保存在静态域中,作为类初始化的一部分;可以将它保存在volatile域、final域或者通过正常锁定访问域中;或者可以将它放到并发集合中。下面是针对安全发布的例子“将 volatile 变量用于一次性安全发布”,来自XXXX:
模式 #2:一次性安全发布(one-time safe publication)
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原语值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
实现安全发布对象的一种技术就是将对象引用定义为 volatile 类型。清单 3 展示了一个示例,其中后台线程在启动阶段从数据库加载一些数据。其他代码在能够利用这些数据时,在使用之前将检查这些数据是否曾经发布过。
清单 3. 将 volatile 变量用于一次性安全发布
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;
public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}
public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}
如果 theFlooble 引用不是 volatile 类型,doWork() 中的代码在解除对 theFlooble 的引用时,将会得到一个不完全构造的 Flooble。
该模式的一个必要条件是:被发布的对象必须是线程安全的,或者是有效的不可变对象(有效不可变意味着对象的状态在发布之后永远不会被修改)。volatile 类型的引用可以确保对象的发布形式的可见性,但是如果对象的状态在发布后将发生更改,那么就需要额外的同步。
总之,当多个线程共享可变数据的时候,每个读或写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的“活性失败”和“安全性失败”。如果只是需要线程之间的交互通信,而不需要互斥,volatile修饰就是一种可以接受的同步形式。
>>>《Practical Java》线程拾遗<<<
如果synchronized函数抛也异常,则在异常离开这个函数前,锁会被自动释放。
不允许你将构造函数声明为synchronized,否则编译出错。原因是当两个线程并发调用同一个构造函数时,它们各自操控的是同一个class的两个不同实体对象的内存,所以没有必要。但是,如果构造器中要访问竞争共享资源的代码时,需要使用同步块来访问临界资源。
synchronized修饰的非静态函数时,锁对象为this;修饰静态函数时,锁对象为当前对象的Class对象。
需要同步的资源一定要声明成private的,不然外界直接可以访问这个临界资源了。
notifyAll和notify一样,不能指定以何种顺序通知线程。唤醒线程由JVM决定,除了保证所有等待中的线程都被唤醒之外,不做任何其他保证,线程未必以优先权顺序来接获通知。
使用wait和notifyAll线程通信机制替换轮询循环,避免不必要的性能损耗。
不要对locked object(上锁对象)的object reference重新赋值,否则会破坏同步。
不要调用stop或suspend。stop的本意是用来中止一个线程,中止线程的问题根源不在object locks,而在object的状态,当stop中止一个线程时,会释放线程持有的所有locks,但是你并不知道当时代码正在做些什么,所以会造成object处于无效状态;suspend本意是用来“暂时悬挂起一个线程”,但不安全,因为容易引起死锁,与sleep一样的是阻塞时不释放锁,但与sleep不同的是sleep是在等待一段时间后会自动唤醒,而suspend后一定需要另一线程通过调用该线程的resume方法来恢复,但此时如果调用resume方法的线程需要suspend所拥有的锁时,就会产生死锁,而sleep则安全多了,它在阻塞只在指定的时间之内,时间一到它就会恢复运行,不易引起死锁;destroy该方法最初用于破坏该线程,与suspend一样也不会释放锁,不过,该方法决不会被实现,即使要实现,它也极有可能与 suspend 一样产生死锁。
死锁实例:
class TestDeathLock {//死锁例子
static void deathLock(Object lock1, Object lock2) {
try {
synchronized (lock1) {
Thread.sleep(10);
synchronized (lock2) {
System.out.println(Thread.currentThread());
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
final Object lock1 = new Object();
final Object lock2 = new Object();
new Thread() {
public void run() {
deathLock(lock1, lock2);
}
}.start();
new Thread() {
public void run() {
// 注意,这里在交换了一下位置
deathLock(lock2, lock1);
}
}.start();
}
}
67、 避免过多同步
与第66相反。过多同步可能会导致性能降低、死锁,甚至不确定的行为。
为了安全性与正确性,在一个被同步的方法或者代码块中,永远不要放弃对象客户端的控制。换句话说,在一个被同步的区域内部,不要调用自己类中可被重写的方法,或者是由客户端以函数对象(如策略接口或回调接口)的形式提供的方法(见第21条)。从包含该同步区域的类的角度来看,这样的方法是外来的,这个类不知道这样的方法会做什么事,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据正确性。
下面是一个可被观察的集合,为了简单起见,在从集合中删除元素时(remove())没有提供通知方法,只提供了在调用添加add()时才通知所有观察者,这个可被观察的集合类ObservableSet是在第16条中可重用的ForwardingSet上实现的:
public class ObservableSet<E> extends ForwardingSet<E> {
public ObservableSet(Set<E> set) {
super(set);
}
private final List<SetObserver<E>> observers = new ArrayList<SetObserver<E>>();
public void addObserver(SetObserver<E> observer) {
synchronized (observers) {
observers.add(observer);
}
}
public boolean removeObserver(SetObserver<E> observer) {
synchronized (observers) {
return observers.remove(observer);
}
}
// This method is the culprit
private void notifyElementAdded(E element) {
synchronized (observers) {
for (SetObserver<E> observer : observers)
//这里就是在调用外来方法,由客户端提供实例,这里只是调用了回调接口而已
observer.added(this, element);
}
}
@Override
public boolean add(E element) {
boolean added = super.add(element);
if (added)
notifyElementAdded(element);
return added;
}
@Override
public boolean addAll(Collection<? extends E> c) {
boolean result = false;
for (E element : c)
result |= add(element); // calls notifyElementAdded
return result;
}
}
观察者接口:
// 集合观察者回调接口
public interface SetObserver<E> {
// 当一个元素添加到ObservableSet时调用
void added(ObservableSet<E> set, E element);
}
第一次使用下面测试类来进行测试:
public class Test1 {
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<Integer>(
new HashSet<Integer>());
set.addObserver(new SetObserver<Integer>() {// 观察者注册
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23)
s.removeObserver(this);//注销
}
});
for (int i = 0; i < 100; i++)
set.add(i);
}
}
上面运行时输出到23后,ObservableSet的notifyElementAdded方法的for循环抛出了ConcurrentModificationException异常,因为notifyElementAdded在使用Iterator遍历集合的过程中,另一个方法added删除元素23,改变了observers的结构,所以当它准备遍历第元素时24就抛出了异常,这正是因为违反了在使用代替遍历集合时,不能通过集合本身去修改其结构的约束所致。
第二次使用以下类来进行测试:
public class Test2 {
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<Integer>(
new HashSet<Integer>());
// Observer that uses a background thread needlessly
set.addObserver(new SetObserver<Integer>() {
public void added(final ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23) {
ExecutorService executor = Executors
.newSingleThreadExecutor();
final SetObserver<Integer> observer = this;
try {
executor.submit(new Runnable() {
public void run() {
s.removeObserver(observer);
}
}).get();//等待removeObserver方法调用完成
} catch (ExecutionException ex) {
throw new AssertionError(ex.getCause());
} catch (InterruptedException ex) {
throw new AssertionError(ex.getCause());
} finally {
executor.shutdown();
}
}
}
});
for (int i = 0; i < 100; i++)
set.add(i);
}
}
运行时发生死锁。后台线程调用s.removeObserver,它企图锁定observers,但它无法获得该锁,因为主线程已经先锁定了。在这期间,主线程又一直等待后台线程来完成对观察都的删除,这正是造成死锁的原因。
ObservableSet中的同步根本就没有起到作用,相反还造成了上面的死锁。通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。第一种解决办法是对于notifyElementAdded方法,给observers列表拍张“快照”,然后没有锁也可以安全地遍历这个列表了,经过这样的修改,前两个例子运行起来不会出异常或死锁了:
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot = null;
synchronized (observers) {
snapshot = new ArrayList<SetObserver<E>>(observers);
}
for (SetObserver<E> observer : snapshot)
observer.added(this, element);
}
第二种解决办法是使用1.5中的并发集合类,见第69条,这里使用CopyOnWriteArrayList来代替ArrayList,每次add与remove、set时都会重新拷贝整个底层数组,由于内部数组永远没有改动,即没有共享,所以不需要锁定:
private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<SetObserver<E>>();
public void addObserver(SetObserver<E> observer) {
observers.add(observer);
}
public boolean removeObserver(SetObserver<E> observer) {
return observers.remove(observer);
}
private void notifyElementAdded(E element) {
for (SetObserver<E> observer : observers)
observer.added(this, element);
}
在同步区外调用外来方法被称作为“开放调用”,除了可以避免死锁之外,开放调用还可以极大地增加并发性。外来方法的运行时间可能是任意长,如果在同步区域内调用外来方法,其他线程对受保护资源的访问就会阻塞。
通常,你应该在同步区域内做尽可能少的工作。如果你必须要执行某个很耗时的动作,则应该设法将它移到同步区外,但不能违背第66条的指导方针。
上面是讨论正确性,下面讨论一下性能。虽然自从Java平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过多同步。在这个多核时代,过多同步的实际成本并不是指获取锁所花费的CPU时间,而是指失去了并行的机会。另外潜在的开销在于,它会限制VM优化代码执行的能力。
要在一个类的内部进行同步,一个很好的理由是因为它将被大量地并发使用,而且通过执行内部细粒度的同步操作你可以获得很高的并发性。
如果一个可变的类要在并发环境中使用,应该使这个类变成线程安全的(见70)。如果经常用在并发环境中,通过内部同步,你可以获得明显比从外部锁整个对象更高的并发性(在外部同步锁的粒度粗,最细也只能到方法级别,而在内同步可以缩小同步的范围,只在需要的代码行进行同步,而不是整个方法。粗粒度锁时间长,而细粒度锁时间短,所以并发性高)。否则,如果很少在并发环境中,就不要在内部同步,让客户在必要的时候(需要并发的时候)从外部同步。在Java平台出现的早期,许多类都违背了这些指导方针,例如,StringBufer实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此,StringBuffer基本上都都StringBuilder代替,它在Java1.5版本中是个非同步的StringBuffer。
如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如拆分锁、分离锁和非阻塞并发控制。
如果方法修改了静态域,那么你也必须同步对这个域的访问,即使这个方法通常只用于单个线程。客户要在这种方法上执行外部同步是不可能的,因为不可能保证其他不相关的客户也会执行外部同步。第66条中的generateSerialNumber方法就是这样的一个例子。(注,这段一直没有理解)
总之,为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更为一般地讲,要尽量限制同步域内部的工作量。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。
68、 task(工作单元)和executor(执行机制)优先于线程(工作单元 + 执行机制)
本书第1版49条中阐述了简单的工作队列,下面是实例代码:
//工作队列
public abstract class WorkQueue {
private final List queue = new LinkedList();//队列
private boolean stopped = false;
protected WorkQueue() {
//启动工作队列后台处理线程
new WorkerThread().start();
}
// 入队
public final void enqueue(Object workItem) {
synchronized (queue) {
queue.add(workItem);
queue.notify();
}
}
//停止工作队列
public final void stop() {
synchronized (queue) {
stopped = true;
queue.notify();
}
}
//工作队列中元素的抽象处理方法
protected abstract void processItem(Object workItem) throws InterruptedException;
// 后台工作线程
private class WorkerThread extends Thread {
public void run() {
while (true) { // Main loop
Object workItem = null;
synchronized (queue) {
try {
while (queue.isEmpty() && !stopped)
queue.wait();
} catch (InterruptedException e) {
return;
}
if (stopped)
return;
workItem = queue.remove(0);
}
try {
//调用外来方法,一定要入在同步块的外面调用,原因见第67条
processItem(workItem); // No lock held
} catch (InterruptedException e) {
return;
}
}
}
}
}
//工作队列测试
class DisplayQueue extends WorkQueue {
//元素处理方法实现 每秒处理一个元素
protected void processItem(Object workItem) throws InterruptedException {
System.out.println(workItem);
Thread.sleep(1000);
}
public static void main(String[] args) throws InterruptedException {
WorkQueue queue = new DisplayQueue();
for (int i = 0; i < 10; i++)
queue.enqueue(new Integer(i));
// 等待所有元素处理完后再停止队列
Thread.sleep(11 * 1000);
queue.stop();//停止工作队列后台处理线程
}
}
这个类允许客户将后台线程异步处理的工作项目加入队列。当不再需要这个工作队列时,客户端可以调用一个方法,让后台线程在完成了已经在队列中的所有工作之后,优雅地终止自己。但这个类容易出现安全问题或准确性。幸运的是,你再也不需要编写这样的代码了。
在java.15中,增加了java.util.concurrent。这个包中包含了一个Executor Framework,这是一个很灵活的基于接口的任务执行工具。它创建了一个在各方面都本书第一版更好的工作队列,却只需要这一行代码:
ExecutorService executor = Executors.newSingleThreadExecutor();
下面是为执行提交一个Runnable的方法:
executor.execute(runnable);
下面是告诉executor如果优雅地终止(如果不这样,VM可能不会退出):
executor.shutdown();
你可以利用executor service完成更多的事情,如可以等待一特殊的任务(如第67条中的SetObserver),你也可以等待一个任务集合中的任何任务或者所有任务完成(利用invokeAny或者invokeAll方法),你也可以等待excecutor service优雅地完成终止(利用awaitTermination方法),你还可以在任务完成时逐个地获取这些任务的结果(利用ExecutorCompletionService),等等。
如果想让不止一个线程来处理来自这个队列的请求,只要调用一个同的静态工厂,就可创建不同的executor service,即线程池,池中的数量可以固定也可变化。
当然选择executor service是很的技巧的。如果是小程序,或者是轻载的服务器,使用Executors.newCachedThreadPool通常不错,因为它不需要配置,并且一般也能完成工作。但对于大负载的服务器来说,缓存的线程池就不是好了,因为在缓存的线程池中,被提交的任务没有排队,而是直接交给线程执行,如果服务器负载很重,会导致吞吐率下降,创建更多的线程。因此,在大负载的产品中,最好使用executors.newFixedThreadPool,它为你提供了一个包含固定线程数目的线程池。然而,如果你想更灵活,可以直接使用ThreadPoolExecutor类,这个类允许你控制线程池的几乎每个方面。
你不仅应该尽量不要编写自己的工作队列,而且应该尽量不直接使用线程,现在关键的抽象不再是Thread了,它以前可是即充当工作单元,又是执行机制。现在工作单元和执行机制是分开的。现在关键的抽象是工作单元,称作任务(task)。任务有两种:Runnable及其近亲Callable(与Runnable相似,但它会返回值)。执行任务的通用机制是executor service。如果你从任务的角度来看问题,并让一个executor service替你执行任务,在选择适当的执行策略方面就获得很的灵活性,从本质上讲,Excecutor Famework所做的工作是执行。
Executor Framework也有一个可替代java.util.Timer的东西,即ScheduledThreadPoolExecutor。虽然timer使用起来容易,但被调度的线程池executor更加灵活。timer只用一个线程来执行任务,这在面对长期运行的任务时,会影响到定时的准确性。如果timer唯一的线程抛出未被捕获的异常,timer就会终止。但被调度的线程池executor支持多个线程,并且能从抛出未受检异常的任务中恢复。
69、 并发工具优先于wait和notify
回顾第一版:永远不要在循环的外面调用wait-----
总是使用wait循环模式来调用wait方法,永远不要在循环的外面调用wait。循环被用来在等待的前后测试等待条件。下面是使用wait方法的标准模式:
synchronized(obj){
while(<等待条件>){
obj.wait();
}
… // 条件满足后开始处理
}
而不能是这样:
if(<等待条件>){
obj.wait();
}
… // 条件满足后开始处理
为什么这么做,因为当线程醒过来时,等待条件可能还是成立的。通过调用某个对象上的wait后,当线程会进入锁对象的等待池,在被唤醒后不会马上进入就绪状态,而是进入锁对象的锁池,只有再一次获取锁后,才能进入到就绪状态,也有可能就在它再一次获取锁前,等待条件被另一线程改变了,或者是在等待条件还根本还未破坏时另一线程意外或恶意的调用了notify或notifyAll。
notify唤醒一个正在等待的线程(如果这样的线程存在的话),而notifyAll唤醒所有正在等待的线程,通常,你总是应该使用notifyAll。这是合理而保守的建议,它总会产生正确的结果,因为它可以保证你将会唤醒所有需要被唤醒的线程。你可能也会唤醒基本他一些线程,但是这不会影响唾弃的正确性,这些线程醒来之后,会检查它们正在等待的条件,如果发现条件不满足,就会继续等待。
从优化的角度来看,如果处于等待状态的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么你就应该选择调用nofiy,而不是notifyAll。即使这些条件都是真的,还是有理由使用notifyAll而不是notity,就好像把wait调用放在一个循环中,以避免在公有可访问的对象上意外或恶意的通知。与此类似,使用notifyAll代替notify可能避免来自不相关线程的意外或恶意的等等,否则的话,这样的等待会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。在第68条中WorkQueue例子中没有使用notifyAll的原因是,因为辅助线程WorkerThread在一个私有的对象queue上等待,所以这里不存在意外或者恶意地等待的危险。
关于使用notifyAll优先于notify的有一个告诫:虽然使用notifyAll不会影响正确性,但会影响性能,特别在是等待线程很多的情况下,因为所有被唤醒(唤醒后只是进行锁池状态)后的线程有可能因为等待条件被破坏而再次进入阻塞状态(等待池)(中间经过了从等待池状态中唤醒—>进入锁池状态—>获取锁—>进行等待池—>释放锁几个动作,所以很消耗性能,另外,即使没有获取到锁的,也会因引起大量线程竞争锁而影响性能),这会导致大量的上下文切换。但如果唤醒后等待条件不再被破坏的情况下是没有问题。所以本人认为在线程数量小或只有一个线程的情况下,可以使用notifyAll,因为这样即带来了可靠性,但又不太引响性能;或者是在线程数量大的情况,而一旦唤醒后等待条件不再被其他线程“很快”破坏或者根本就不可能被破坏时,也还是可以使用notifyAll的,因为此时的唤醒工作不会白作。
end-----
上面的这些仍然有效,但这些建议现在远远没有之前那么重要了,因为几乎没有理由再使用wait和notify,1.5中有更高级的并发工具来实现这些。
java.util.concurrent中更高级的工具分成三类:Executor Framework、并发集合以及同步器, Executor Framework已在第68条中简单的提到过。
并发集合为的集合接口(如List、Queue和Map)提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步,困此,并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使程序速度变慢。
上面提到“并发集合中不可能排除并发活动”,是说客户无法原子地对并发集合进行方法调用(如先调用它的get方法判断一个元素是否存在,如果不存在,再通地put放入不存在的元素就会有并发问题,因为可以在get后,切换到另一线程,然后再切换回来调用put),并发集合中的方法只是单个方法是原子性的,如果调用多个方法(如get与put)要求是原子的则也需要额外的同步才行,不过这些集合已经将些多个方法的调用扩展成了另一个接口,这样我们也就不需要自己同步了。例如ConcurrentMap扩展了Map接口,并添加了几个方法,如putIfAbsent(K key, V value)。
ConcurrentHahsMap除了提供卓越的并发性之外,速度也非常快。除非不得已,否则应该优先使用ConcurrentHahsMap,而不是使用Collections.synchronizedMap或者Hashtable。并发Map比老式的同步Map性能高,所以应该优先使用并发集合,而不是使用外部同步的集合。
同步器是一些使线程能够等待另一个线程的对象,允许它们协调动作,最常用的同步器是CountDownLatch和Semaphore。较不常用的是CyclicBarrier和Exchanger。
总这,直接使用wait和notify就像用“并发汇编语言”进行编程一样,而java.util.concurrent则提供了更高级语言。没有理由在新的代码中使用wait或notify,即使有,也是极少。如果你在维护使用wait和notify的代码时,务必确保始终是利用标准模式从while循环内部调用wait。一般情况下,应该使用notifyAll,而不是notify。如果使用notify,请小心,确保程序的活性。
70、 线程安全性的文档化
如果你没有在一个类的文档里描述并发的情况,使用这个类的程序员将可能缺少同步和过多同步。
一个类为了可被多个线程安全可用,必须在文档中清楚地说明它所支持的线程安全性级别,下面是些常见的安全级别:
1、 不可变类——这个类的实例是不可变的,所以,不需要外部的同步,如String、Long和BigInteger。
2、 无条件的线程安全——这个类的实例是可变的,但是这个类有着足够的内部同步,所以,它的实例可以被并发使用,无需任何外部同步。如,Random和ConcurrentHashMap。
3、 有条件的线程安全——除了有些方法为进行安全的并发使用而需要外部同步外,这种线程安全级别与无条件的线程安全相同。如Collections.synchronized包装返回的集合,它们的迭代器(iterator)要求同步,否则在迭代期间被其他线程所修改。下面是源码:
public Iterator<E> iterator() {
return c.iterator(); // Must be manually synched by user!
}
4、 非线程安全——这个类的实例是可变的。为了并发地使用它们,客户必须利用自己选择的外部同步包围每个方法调用(或者调用序列)。如能用的集合实现ArrayList、HashMap。
5、 线程对立的——这个类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是因为没有考虑到并发性而产生后果。幸运的是,在Java平台类库中,这样的类很少,System.runFinalizersOnExit()是这样的,但已废除了。
上面这里只是粗略的分类,详细参见《Java Concurrency Practice》一书中的线程安全注解。
尽量别使用公有对象来作为锁对象,因为这样外界可能意外或者故意的霸占锁,造成拒绝服务,所以这该使用私有锁对象来代替同步方法(非静态的同步方法的锁就是this,但这个对象在外面可以访问到,所以避免使用):
private final Object lock = new Object();//注意最好声明成final的
public void foo(){
synchronized(lock){
…
}
}
这样外界就不能访问到这个锁对象,所以它们不可能妨碍对象的同步。但是,需要重早一下的是,私有锁对象模式只能用在无条件的线程安全类上。有条件的线程安全类不能使用这种模式,因数它们必须在文档中说明:在执行某些方法调用序列时,它们客户端程序必须获取哪把锁。
私有锁对象模式特别适合用于那些专门为继承而设计的类。如果这种类使用它的实例作为锁对象,子类可能很容易在无意中妨碍基类的操作,反之亦然。出于不同的目的而使用相同的锁,子类和基类可能会“相互绊住对方的脚”。
总这,每个类都需要说明线程安全说明,synchronized修饰符并不能说明这个类就是线程安全的。有条件的线程安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。如果你编写的是无条件的线程安全类,就应考虑私有锁对象代替同步方法。
71、 慎用延迟初始化
延迟初始化降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。
如果域只在类的实例部分被访问,并且初始化这个域的开锁很高,可能就值得进行延迟初始化。
大多情况下,正常的初始化要优先于延迟初始化,下面是下常初始化,注意,如果域是不可变的,一定要加上final或者在可变情况下加上volatile,因为这样才能保证实例初始化的完整性:
// Normal initialization of an instance field - Page 282
private final FieldType field1 = computeFieldValue();
下面是延迟初始化模式:
private FieldType field;
synchronized FieldType getField(){
if(field == null){
filed = computeFieldvalue();
}
return field;
}
上面这两种模式也可用在静态域上,只需在前域与方法前添加static,锁对象从this变为.class对象即可。
如果出于性能的考虑需要对静态域使用延迟初始化,可借助于一个Holder类来延迟加载:
prvate static class FieldHolder{
static final FieldType field = computeFieldValue();
}
static FieldType getField(){return FieldHolder.field;}//这里没有使用同步,性能高
现代的VM将在初始化类的时候,将会同步域的访问,也就是说如果类还没有初始化完成,是不能访问这些域的,一旦这个类被初始化,VM将修补代码,以便后续对该域的访问不会导致任何同步。
如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式。这种模式避免了在域被初始化之后访问这个域的开销(因为不必要的同步):
private volatile FieldType field;
FieldType getField() {
FieldType result = field;//第一次从主存中读field
if (result == null) { // First check (no locking) 从工作内中读result
synchronized (this) {
/*
* 这里个人认为result没起多大作用,直接对field进行判断即可,像这样:
* if (field == null)
* field = result = computeFieldValue();
* 因为在这里最多只执行两次,一次就是在初始化时,第二次是初始化完成后
* 第一次访问,但第二次有可能不会发生在这里,所以直接使用上面与下面没
* 有什么很大的区别,result优化效果最多起二次作用,而不上外面的多次
* 访问那么有用
*/
result = field;
if (result == null) // Second check (with locking)
field = result = computeFieldValue();
}
}
return result;//从工作内中读result
}
注意这里的局部变量result,这个变量的作用是确保field在已经被初始化的情况下从主存中只读取一次,而不使用这个局部变量时需要至少两次从主内存中读取,而从工作内存中读取比从主内存中直接读取要快。虽然这不是严格的要求,但是可以提升性能。在我的机器上,上述方法比没用局部变量的方法快了大约25%,对比一下不使用局部变量时双重检测模式:
private volatile FieldType field;
FieldType getField() {
if (field== null) { // 第一次从主存中读field
synchronized (this) {
if (field == null) // 第二次从主存中读field,但只有在初始化完成后第一
// 访问时才有可能执行到这里
field = computeFieldValue();
}
}
return field;//第三次从主存中读field
}
在1.5以前,双重检查模式的功能很不稳定,因为volatile修饰符的语义不够强,难以支持它。但1.5版本中的内存模型解决了这个问题。所以如今可以使用双重检查对实例域进行延迟初始化,但对静态域也可使用双重检查模式,但没有理由这么做,因为使用类的延迟加载方式是更好的选择了。
双重检查模式对于只能产生一个实例是很重要的,但有时候,你可能需要延迟初始化一个可以接受重复初始化的实例域。如果是这情况,可以省去第二次检查,这就是所谓的“单重检查模式”,下面就是这样的例子,注意fileld仍然是volatile:
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null)
field = result = computeFieldValue();
return result;
}
本条目中讨论的所有初始化方法都适用于基本类型的域,以及对象引用域。当双重检查模式或者单重检查模式应用到数值型的基本类型域时,就会用0来检查这个域(数值类型默认值),而不是null。
如果你不在意是否每个线程都新计算域的值,并且域的类型为基本类型,而不是long或者double类型,就可以选择从单重检查模式的域声明中删除volatile修饰符。这种变体叫 racy single-check idiom。它加快了某些架构上的域访问,代价是增加了额外的初始化。
总之,大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性能目标,可以使用相应的延迟初始化方法。对于实例域,使用双重检查模式;对于静态域,则使用类延迟加载。对于可以接受重复初始化的实例域,也可考虑使用单重检查模式。下面是上面完全实例代码:
class FieldType {}
// 各种初始化模式
public class Initialization {
// 正常初始化
private final FieldType field1 = computeFieldValue();
private static FieldType computeFieldValue() {
return new FieldType();
}
// 延迟初始化模式 - 同步访问
private FieldType field2;
synchronized FieldType getField2() {
if (field2 == null)
field2 = computeFieldValue();
return field2;
}
// 对静态域使用类延迟初始化
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
static FieldType getField3() {
return FieldHolder.field;
}
// 对实例域进行双重检测延迟初始化
private volatile FieldType field4;
FieldType getField4() {
FieldType result = field4;
if (result == null) { // First check (no locking)
synchronized (this) {
result = field4;
if (result == null) // Second check (with locking)
field4 = result = computeFieldValue();
}
}
return result;
}
// 单重检查 - 会引起重复初始化
private volatile FieldType field5;
private FieldType getField5() {
FieldType result = field5;
if (result == null)
field5 = result = computeFieldValue();
return result;
}
}
72、 不依赖于线程调度器
任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能是不可移植的。
要编写健壮的、响应良好的、可移植的多线程程序应用程序,最好的办法确保可运行线程的平均数量不明显多于处理器的数量。
保持可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义工作,如果线程没有在做有意义的工作,就不应该运行。
线程不应该一直处于忙等的状态,即反复地检查一个共享对象,以等待某些事情的发生。忙等会极大地增加处理器的负担,降低了同一机器上其他进行可以完成的工作量。如下面这个例子:
public void await() {//忙等,等到为零止
while (true) {
synchronized (this) {
if (count == 0)
return;
}
}
}
Thread.yield的唯一用途是在测试期间人为地增加程序的并发性,可以发现一些隐藏的Bug,这种方法曾经十分奏效,但从来不能保证一定可行。Java语言规范中,Thread.yield根本不做实质性的工作,只是将控制权返回给调用者。所应该使用Thread.sleep(1)代替Thread.yield来进行并发测试,但千万不要使用Thread.sleep(0),它会立即返回。
另外,不要人为地调整线程的优先级,线程优先级是Java平台上最不可移值的特征了。
73、 避免使用线程组
线程组初衷是作为一种安全隔离一些小程序的机制,但是它们从来没有真正履行这个承诺,它们的安全价值已经差到根本不能在Java安全模型的标准工作中提及的地步了。
除了安全性外,它们允许你同时把Thread的某些基本功能应用到一组线程上,但有时已被废弃,剩下的也很少使用了,因为它们有时并不准确。
总这,线程级并没有提供太多有用的功能,而且它们提供的许多的功能都有缺陷的。我们最好把线程组看作是一个不成功的试验,你可以忽略他们,就当不存在一样。如果你正在设计一个类需要处理线程的逻辑组,或许应该使用线程池executor。
第十一章 序列化
74、 谨慎地实现Serializable接口
实现Serializable接口而付出的最大代价是,一旦一个类被发布,就大降低了“改变这个类的实现”的灵活性。如采用默认的序列化方式时(仅实现Serializable),且没有在一个名为serialVersionUID的私有静态final的long域显示地指定该标识号,则如果类改变了,则会导致不兼容。
实现Serializable的第二个代价是,它增加了出现Bug和安全漏洞的可能性。反序列化过程不是调用原有的构造器,所以你很容易忘记要确保:反序列化过程必须也要保证所有“由真正的构造器建立起来约束关系”,并且不允许攻击者访问正在构造过程上的对象的内部信息,依靠默认的反序列化机制,很容易对象的约束关系遭到破坏,以及遭受非法访问(第76条)。
实现Serializable的第三个代价是,随着类发行新的版本,相关的测试负担也增加了。
实现Serializable接口并不是一个很轻松就可以做出的决定。根据经验,如Data和BigInteger这样的的值类应该实现Serializable,大多数的集合类也是应该如此。代表活动实体的类,如线程池,一般不应该实现Serializable。
为了继承而设计的类,应该尽可能少地去实现Serializable接口,用户的接口也应该尽可能少地继承Serializable接口。如果违反了这条规则,扩展这个类或者实现这个接口的程序员就会背上沉重的负担。然后在有些情况下是合适的,如,这个类或接口主要是为了参与到某个框架中,而该框架要求所有参与者都实现Serializable接口,则实现Serializable是有意义的。
对于为继承而设计的不可序列化的类,你应该考虑提供一个无参构造器,因为他的子类可能是可序列化的,一旦子类实现Serializable接口,则在反序列化时会调用调用父类的无参构造器。最好在所有约束关系都已经建立的情况下再创建对象。下面是一个父类不可序列化,而子类可序列化的建议做法:
//不可序列化
public abstract class AbstractFoo {
private int x, y; // 状态
// 枚举字段用于跟踪初始化于哪种状态:NEW-新建,INITIALIZING-正在初始化,INITIALIZED-初始化完成
private enum State {
NEW, INITIALIZING, INITIALIZED
};
/*
* 初始化到哪种状态了
* 注意,init是一个原子引用。在遇到特定的情况下,确保对象的完整性是很重要的。如果没有这样的防范,
* 万一有个线程要在某个实例上调用initialize,而另一个线程又要企图使用这个实例,第二个线程就有
* 可能看到这个实例处于不一致的状态。这种模式利用compareAndSet方法来操作枚举的大孩子引用。
*/
private final AtomicReference<State> init = new AtomicReference<State>(State.NEW);
//该域是第一版中的
// private boolean initialized = false;//第一版
public AbstractFoo(int x, int y) {
initialize(x, y);
}
//此构造和下面的方法让子类的readObject方法来初始化我们的状态
protected AbstractFoo() {//受保护的构造器
}
protected final void initialize(int x, int y) {
//compareAndSet(V expect, V update):如果当前值 == 预期值expect,
//则以原子方式将该值设置为给定的更新值update。如果还未初始化时,将初始状态设为:INITIALIZING
if (!init.compareAndSet(State.NEW, State.INITIALIZING))
//if (initialized)//第一版
throw new IllegalStateException("Already initialized");
this.x = x;
this.y = y;
// 构造完后设置成完成状态
init.set(State.INITIALIZED);
// initialized=true;//第一版
}
//这些方法提供了访问内部状态,因此可以
//通过子类的writeObject方法来手动序列化。
protected final int getX() {
checkInit();
return x;
}
protected final int getY() {
checkInit();
return y;
}
// 所有共有的与保护的实例方法都必须调用
private void checkInit() {
// if(!initialized)//第一版
if (init.get() != State.INITIALIZED)
throw new IllegalStateException("Uninitialized");
}
// 其余的略
}
//继承不可序列化父类,但已自己实现Serializable接口
public class Foo extends AbstractFoo implements Serializable {
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();
// 手工反序列化和初始化父类的状态
int x = s.readInt();
int y = s.readInt();
initialize(x, y);
}
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
// 手工序列化父类的状态
s.writeInt(getX());
s.writeInt(getY());
}
// Constructor does not use the fancy mechanism
public Foo(int x, int y) {
super(x, y);
}
private static final long serialVersionUID = 1856835860954L;
}
内部类不应该实现Serializable接口,它们使用编译器产生的合成域来保存指向外围实例的引用,以保存来自外围作用域的局部变量的值。“这些域如何对应到类定义中”并没有明确的规定,就好像匿名类和局部类的名称一样,它们都是由编译器临时产生的,我们不能引用它们。因此,内部类默认序列化形式是定义不清楚的。然而,静态成员类却可以实现Serializable接口。
总之,实现Serializable接口不是容易的事。实现Serializable接口是个严肃的承诺,必须认真对待。如果类是为了继承使用的,则一定要提供一个默认构造器,以防止子类序列化。
75、 考虑使用自定义的序列化形式
如果没有先认真考虑默认的序列化形式是否合适,则不要贸然接受。一般来讲只有当你自行设计的自定义序列化形式与默认的序列化形式基本相同时,才能接受默认的序列化形式。
理想的序列化应该只包含该对象所表示的逻辑数据。
如果一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。例如,对于下面仅仅表示人名的类,默认的序列化形式就是合理的:
public class Name implements Serializable {
/**
* Last name. Must be non-null.
* @serial
*/
private final String lastName;
/**
* First name. Must be non-null.
* @serial
*/
private final String firstName;
/**
* Middle name, or null if there is none.
* @serial
*/
private final String middleName;
... // Remainder omitted
}
上面的数据全是逻辑内部,又是物理内容,所以适合于默认序列化方式。
即使你确定默认的序列化形式是合适的,通常你还必须提供一个readObject方法以保证约束关系和安全性。对于Name这个类而言,readObject方法必须确保lastName和firstName是非null的,第76与78条详细地讨论这个问题。
注,虽然lastName、firstName和middleInitial域是私有的,但它们依然有相应的文档注释。这是因为,这些私有域定义了一个公有的API,即这个类的序列化形式,并且该公有的API必须建立文档。
下面与Name不同,它是一个极端例子,该类表示了一个字符串列表:
public final class StringList implements Serializable {
private int size = 0;
private Entry head = null;
private static class Entry implements Serializable {
String data;
Entry next;
Entry previous;
}
... // Remainder omitted
}
从逻辑上讲,它表示一个字符序列,但从物理上看,它把字符串序列表示成了双向链表。如果你采用默认的序列化,它会将链表中的所有项都序列化。
当一个对象的物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下缺点:
1、 它使这个类的导出API永远地束缚在该类的内部表示法上。上面例子中私有的StringList.Entry类变成了公有API的一部分。如果将来版本中内部表示法变化了,StringList仍将需接受链表形式的输入,并产生链表的输出。这个类永远也摆脱不掉维护链表项所需要的代码,即使不再使用链作为内部数据结构了,也仍需要这些代码。因为原来已序列化的二进对象在默认恢复过程中与当前版本类不兼容而导致反序列化失败,比如当前版本中少了某个域。
2、 它会消耗过多的空间与时间。上面的链表中的项的next、previouse是链表的实现实节,不用关心链接的物理信息,在恢复时是可以构造这些关系,不需要将它们一起序列化。
3、 它会引起栈溢出。默认的序列化过程要对对象图进行一次递归遍历,如果对象图层次很深的话很易容就会引起栈的溢出。
对于上面的StringList,我们只需要对size,与date逻辑数据进行序列化即可:
public final class StringList implements Serializable {
private transient int size = 0;
private transient Entry head = null;
// 不再需要序列化!
private static class Entry {
String data;
Entry next;
Entry previous;
}
// 将指定的字符串接连到字符串列表中
public final void add(String s) {
// 实现省略
}
/**
* 序列化当前 {@code StringList} 实例.
*
* @serialData 列表数目 (字符串列表所包含的字符串个数) 是导出的
* ({@code int}), 紧接着是所有的元素 (each
* a {@code String}), 并且按照适当的顺序.
*/
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();//默认序列化,将非静态与非transient序列化
s.writeInt(size);
// 将所有元素数据以适当的顺序写出.
for (Entry e = head; e != null; e = e.next)
s.writeObject(e.data);
}
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();
int numElements = s.readInt();
// 读出所有元素并恢复成链
for (int i = 0; i < numElements; i++)
add((String) s.readObject());
}
private static final long serialVersionUID = 93248094385L;
// 其他略
}
如果所有实例域都是瞬时的,从技术上来说可以不调用defaultWriteObject与defaultReadObject两个方法,但不推荐这样做,因为后面可能有新的非瞬时实例时,还能保持前后兼容。
默认序列化对上面StringList是适合,序列化与反序列化StringList实例都会产生原始对象的忠实拷贝,约束关系没有被破坏。但如果对象约束关系依赖于特定于实现的细节,情况不一样了,如HashMap,它会将Key哈希地址转换成哈希表中的对应桶号,如果使用默认的序列化方式,则恢复时出现严重Bug,因为恢复出的对象的地址已发生变化会导致元素新的哈希码发生变化,则真实的桶号也会发生变化,所以恢复出的原哈希表已不适用。所以为了防止约束关系的破坏,HashMap需要手动的序列化,API也正是这样做的。
transient指导原则:如果一些域不需要序列化,即冗余域,则标示为transient;如果域的值依赖于JVM的运行,则标示为transient;在决定将一个域做成transient之前,请一定要确认它是一个逻辑状态;如果你正在使用自定义的序列化形式,大多数或者所有的域都应该标示为transient。
默认序列化方式时,transient的域反序列化时会初始化为默认值:对象null,数值0,布尔false。如果这些值不是你期望的,则需要在readObject方法中重新手动初始化。
无论你是否使用默认的序列化形式,如果在读取对象任何状态的地方使用到了同步,则也必须在对象序列化上强制这种同步。如果你使用的是默认序列化方式,也得要这样做:
private synchronized void writeObject(ObjectOutputStream s)
throws IOException {
s.defaultWriteObject();
}
不管是哪种方式,都要为可序列化的类显示的提供序列版本唯一标示(serialVersionUID),一是可以避免不兼容,二是提升小小的性能。
如果是默认序列化,又没有指定serialVersionUID时,要想兼容,则新类中要手动指定为以前版本中的serialVersionUID值,要起查看以前版本中类的serialVersionUID,请使用serialver命令。
76、 保护性地编写readObject方法
假设你将第39条中的不可变Period日期范围类做成可序列化的,你可能认为使用默认序列化方式较合适,因为Period的物理表示法正好是逻辑数据内容。但这样会有很大的安全隐患。问题在于,readObject方法实际上相当于另一个公有的构造器,如同其他的构造器一样,它也需要注意同样的所有注意事项:一是构造器必须检查其参数的有效性,二是在必要的时候对参数进行保护性拷贝(见39),同样的,readObject方法也需要这样做。如果readObject方法没有做到这两点,对于攻击都来说就很容易破坏这个类的约束条件了。
>>>伪字节流的攻击法<<<
因为readObject方法是字节流作为参数的,因此我们可以伪造这样的对象流后传递给它进行反序列化。假设现在Period类采用默认的序列化方式,下面这个程序将反序列化产生一个Period实例,而它的结束时间比开始时间要早:
public class BogusPeriod {
// 字节流可能不是来自真真的 Period实例,而是原有基础修改过的
private static final byte[] serializedForm = new byte[] { (byte) 0xac,
(byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06, 0x50, 0x65, 0x72,
0x69, 0x6f, 0x64, 0x40, 0x7e, (byte) 0xf8, 0x2b, 0x4f, 0x46,
(byte) 0xc0, (byte) 0xf4, 0x02, 0x00, 0x02, 0x4c, 0x00, 0x03, 0x65,
0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f,
0x75, 0x74, 0x69, 0x6c, 0x2f, 0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c,
0x00, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00,
0x01, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61,
0x2e, 0x75, 0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68,
0x6a, (byte) 0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte) 0xdf, 0x6e,
0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03, 0x77, 0x08,
0x00, 0x00, 0x00, (byte) 0xd5, 0x17, 0x69, 0x22, 0x00, 0x78 };
public static void main(String[] args) throws Exception {
InputStream is = new ByteArrayInputStream(serializedForm);
ObjectInputStream ois = new ObjectInputStream(is);
System.out.println(ois.readObject());
}
}
被用来初始化serializedForm是这样产生的:首先对一个正常的Period实例进行序列化,然后对得到字节流进行手工编辑。你可以在《Java Object Serialization Specification》中查到有关序列化字节流格式的规范信息。如果你运行该程序,会输出:
Sat Jan 02 04:00:00 CST 1999 - Mon Jan 02 04:00:00 CST 1984
只要把Period声明成可序列化的,攻击者就可以伪造这样的流来进行攻击。为了修正这个问题,你可以提供一个readObject方法,该方法首先要调用defaultReadObject,然后检查被反序列化之后的对象的有效性,如果验证不通过,则抛异常,使反序列化失败:
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();//先调用默认恢复
// 再进行状态参数的有效性验证
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
>>>内部私有域盗用攻击法<<<
尽管上面那样修正避免了攻击者创建无效的Period实例,但是,这里仍然隐藏着一个更为微妙的问题。当我们正常序列化完后,然后附加上两个额外的引用,指向Period实例中的两个私有的Date域。攻击者从ObjectInputStream中读取Period实例,然后读取附加在后面的“恶意编制的对象引用”。这些对象引用使用攻击者能够访问到Period对象内部私有Date域所引用的对象。通过改变这些Date实例,来改变Period实例,下面演示了这种攻击:
public class MutablePeriod {
// 一个 period 实例
public final Period period;
// period's 的 start 域, 指向我们不能访问的私有域
public final Date start;
// period's 的 end 域, 指向我们不能访问的私有域
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
// 序列化正确的Period实例
out.writeObject(new Period(new Date(), new Date()));
/*
* 添加两个恶意的引用让它们指向上面Period序列化字节流中的私有域
* 详细做法请参考 "Java Object Serialization Specification,"
* Section 6.4.
*/
byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // Ref #5
bos.write(ref); // The start field
ref[4] = 4; // Ref # 4
bos.write(ref); // The end field
// 在反序列化的过程中,偷取私有域,故能访问到私有域
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
// 下面引用序列化对象中的私有域
start = (Date) in.readObject();//偷取私有的start域
end = (Date) in.readObject();//偷取私有的end域
} catch (Exception e) {
throw new AssertionError(e);
}
}
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
// Let's turn back the clock
pEnd.setYear(78);
System.out.println(p);
}
}
运行得到如下结果:
Mon May 24 13:57:08 CST 2010 - Wed May 24 13:57:08 CST 1978
上面虽然反序列化的过程中没有破坏约束条件,但反序列化完后通过恶意的引用私有内部状态出了问题。这个问题的根源在于Period的readObject方法并没有完成足够的保护性拷贝。因此,对于每个可序列化的不可变类,如果它包含了私有的可变组件,必须对这些组件进行保护性拷贝,下面重新对Period的readObject方法进一步的修正:
private void readObject(ObjectInputStream s) throws IOException,
ClassNotFoundException {
s.defaultReadObject();//先调用默认恢复
// 对可变组件进行保护性拷贝
start = new Date(start.getTime());
end = new Date(end.getTime());
// 进一步检测内部状态参数是否有效
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
注意,保护性拷贝是在有效性检查之前进行的,而且,我们没有使用Date的clone方法来执行保护拷贝,这两个细节对于保护Period不受攻击是必须的(原因请见39)。同时原Period类中的start与end域为final类型是行不通了的,因为如果这样将不能进行拷贝。这是很遗憾的,但这还算相对好的做法,不过我们可以加上volatile关键字加强并发的可见性。经过上面修改后我们再次执行MutablePeriod类,结果正常:
Mon May 24 14:19:26 CST 2010 - Mon May 24 14:19:26 CST 2010
在1.4中,为了阻止恶意的对象引用攻击,同时节省保护性拷贝的开销,在ObjectOutputStream中增加了wirteUnshared和readUnshared方法。但遗憾的是,这些方法都很容易受到复杂的攻击,即本质上与第77条中所述ElvisStealer攻击相似的攻击。所以不要使用这两个方法,虽然它们通常比保护性拷贝更快,但是它们还是不很安全。
readObject其实就相当于公共的构造器,所以对于readObject额外要注意的是,不要在readObject方法中调用可能被覆盖的方法,因为这与在构造函数中调用被覆盖方法是一样错误的。
默认的readObject 方法是否可以被接受,我们只需做一个简单的测试:增加一个公有的构造器,其参数对就于该对象每个非transient的域,并且无论参数的值是什么,都是不进行检查就可以保存到相应域中,如果对就这个做法不赞同,就必须提供一个显示的readObject方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝,另一种方法是使用序列化代理模式(见79条)
77、 对于实例控制,枚举类型优先于readResolve
对于实现了Serializable接口的单实例类,只要反序列化就一定会产生一个不同于现VM中实例的新对象,这是肯定的。
readResolve方法允许你用另一个实例去替代readObject方法创建的实例,如果可序列化类中有readResolve的方法,则在readObject调用之后再调用readResolve方法,然后,readResolve方法返回的对象引用将被返回,取代新建的对象,这样readObject新建的对象不再被引用,立即成为垃圾回收的对象。具体做法如下:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
该方法忽略了被反序列化的对象,只返回该类被初始化时创建的那个特殊的Elvis实例,因此,Elvis实例的序列化形式并不需要包含任何实际的数据(因为真真反序列化得到的实例被readResolve方法给替换成了当前VM中正在运行的原有的单实例,所以单例模式在序列化成字节码流后对反序列化根本没有用,所以不需要将任何域序列化)。单例中的所有的实例域都应该被声明为transient,事实上,如果依赖readResolve进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient的,否则,攻击者们可能使用readResolve方法被运行前,通过一个引用指向反序列化出来的对象,从而系统中会同时存在两个实例。基本原理就是非transient引用域会默认序列化,并且这个域的readResolve方法会在Singleton的readResolve方法前调用。当这个域反序列化时,就可以使用精心制作的该域的“反序列化替代对象”即“盗用者”来代替这个域默认的反序列化过程。以下是具体工作原理:先编写一个“盗用者”类,它既有readResolve方法,又有实例域,实例域指向被序列化的Singleton的实例,在序列化流中,用“盗用者”类的实例代替Singleton的非transient域。现在Singleton序列化流中包含“盗用者”类实例,而“盗用者”类实例则引用Singleton实例。当反序列化时这个“盗用者”类的readResolve会先于Singleton类的readResolve方法运行,因此,当“盗用者”的readResolve方法运行时,它的实例域是可以引用到被部分反序列化的Singleton实例,最后“盗用者”将引用到的Singleton实例赋值给“盗用者”的静态域,供外界引用,最终导致系统同时存在两个实例。具体做法请看下面几个类:
// 有问题的单例 - 有一个非transient域!
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
//!! 非transient域。注,这里安全做法是使用transient修饰
private String[] favoriteSongs = { "Hound Dog",
"Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}
//盗用者,用来代替Elvis类中的非transient域favoriteSongs的
//反序列化过程中的readResolve方法调用
public class ElvisStealer implements Serializable {
static Elvis impersonator;//保存反序列化得到的Singleton实例供外界使用
private Elvis payload;//通过编译字节充让它指向反序列化得到的Singleton实例
private Object readResolve() {
// 将部分反序列化的实例存储到静态域中供外界使用
impersonator = payload;
// 注这里一定要返回数组类型,因为ElvisStealer是用来替代favoriteSongs域的
return new String[] { "A Fool Such as I" };
}
private static final long serialVersionUID = 0;
}
public class ElvisImpersonator {
// 该字节流中潜伏了盗用者
private static final byte[] serializedForm = new byte[] { (byte) 0xac,
(byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45, 0x6c, 0x76,
0x69, 0x73, (byte) 0x84, (byte) 0xe6, (byte) 0x93, 0x33,
(byte) 0xc3, (byte) 0xf4, (byte) 0x8b, 0x32, 0x02, 0x00, 0x01,
0x4c, 0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65,
0x53, 0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61,
0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a,
0x65, 0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45,
0x6c, 0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,
0x4c, 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74,
0x00, 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70,
0x71, 0x00, 0x7e, 0x00, 0x02 };
public static void main(String[] args) throws Exception {
InputStream is = new ByteArrayInputStream(serializedForm);
ObjectInputStream ois = new ObjectInputStream(is);
Elvis elvis = (Elvis) ois.readObject();//返回Elvis.INSTANCE
//但这里返回的反序列化过程中创建的实例
Elvis impersonator = ElvisStealer.impersonator;
//下面会输出不同的结果,因为他们是不同的两个实例
elvis.printFavorites();
impersonator.printFavorites();
}
}
输出结果:
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]
上面只需将favoriteSongs域声明成transient即个解决上面的问题。但最好把这个类做成一个单元素的枚举类型(见第3条)进行修正。自从1.5后使用readResolve就不是个好做法了。下面使用单一的枚举类型来修正:
public enum Elvis {
INSTANCE;
private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
}
总之,应该尽可能地使用类型来实施实例控制的约束条件。如果做不到(如果受控实例在编译时还不知道时,就不适合使用枚举类型),同时又需要一个既可序列化又是实例受控的类,就必须提供一个readResolver方法,并确保该类的怕有实例域都为基本类型或者是transient的。
78、 考虑用序列化代理代替序列化实例
序列化代理模式可以避免直接对可序列化类进行序列化时的一系列问题,如出错和出现安全问题的可能性,因为它导致实例要利用语言之外的机制来创建,而不是用普通的构造器。
这个代理类很简单,首先它是一个静态的成员类,精确地表示外围类的实例逻辑状态。它应该有一个单独的构造器,其参数就是外围类。这个构造器只是从它的参数中复制数据,不需要进行任何一致性检查或者保护性拷贝。下面使用序列化代理类来替代76中的方案:
//可序列化的不可变类
public final class Period implements Serializable {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
public String toString() {
return start + " - " + end;
}
/*
* 外围类的序列化代理类
* 该的为静态私有的会很重要,这新避免了外界伪造这个类实例的序列化字节
* 流,因为外界根本就能不能构造该私有成员类的实例,这样就避免了第76条
* 中的第一个 通过伪造序列化字节流进行攻击的安全问题;但此时好像还是可
* 能通 过76条中的第二种攻击方法来进行攻击,这其实则不然,即使在外界修
* 改了 SerializationProxy里的start与end私有域,但readResolve方法是
* 调用的公有构造器,而公有构造器以是拷贝的方式来构造的,所以外面对私
* 有域的修改不会影响到外围对象,所以序列化代理类同时也避免了76条中的
* 第二 种攻击法;对于单例模式的序列化,也可以避开第77中的问题,因为序
* 列化的不是单例类本身,只是它的序列化代替对象,外界拿手的也只是代理
* 对象,不过此时readResolve返回要是最开始初始化的单例对象,还有就是
* 这个序列化代理类也不需要定义单例的所有域。
*/
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private static final long serialVersionUID = 234098243823485285L;
// 将序列化代理转变回外围类的实例
private Object readResolve() {
// 使用公有的构造器创建实例
return new Period(start, end);
}
}
// writeReplace 方法将使用序列化代理对象代替自己
private Object writeReplace() {
return new SerializationProxy(this);
}
// 不让外界伪造外围类序列化字节流然后直接通常外围类反序列化,
// 如果外界这样做则抛异常
private void readObject(ObjectInputStream stream)
throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
}
上面序列化代理类中的readResolve方法仅仅利用它的公有API创建外围类的一个实例,这正是该模式的魅力之所在。它极大地消除了序列化机制中语言本身之外的特征,因为反序列化实例是利用与任何其他实例相同的构造器、静态工厂和方法而创建的。这样你就不必单独确保被反序列化的实例一定要遵守类的约束条件。如果该类的静态工厂或构造器建立了这些约束条件,并且它的实例方法在维持着这些约束条件,你就可以确信序列化会维护这些约束条件。
序列化代理方法可以阻止伪字节流的攻击与以及内部的盗用攻击,与前面方案(见第76)不同的是,这种方案允许Period的域为final的,为了确保Period类真正不可变是必须的。
总之,每当你发现自己必须在一个不能被客户端扩展的类上编写readObject或者writeObject方法的时候,就应该考虑使用序列化代理模式,对具有约束条件的对象序列化是否不错的选择,但性能不如保护性拷贝(见76)。
转自:http://mywiz.cn/s/20130622180659226/1j6dG431Mkxj2n48s52zeUI5
发表评论
-
Eclipse一些设置
2014-07-10 14:29 9441.设置控制台显示打印的全部信息 windows->Pr ... -
JVM原理分析及性能调优
2014-02-16 01:49 2621一.Javac 1.Javac编译器的作用 将符合Jav ... -
Java文件压缩(apache.tools.zip)
2014-02-10 17:18 1221由于jdk自带的压缩功能存在编码问题,所以使用apache工具 ... -
Excel数据导入(下拉列表和ID隐藏域)
2013-12-20 12:57 5152当我们导入excel数据到数据表中时,如果数据表中包含其他 ... -
Java五种单例模式详解
2013-03-29 14:07 1654//懒汉 class LazySingleton ... -
Java单例模式
2013-03-29 14:06 1//静态内部类 public class Singleton ... -
动态生成、编译、运行java文件
2013-03-09 02:38 6175动态生成,编译,运行java文件,jdk动态代理底层实现就是利 ... -
Java常用算法
2013-02-21 11:05 1189/** * 类功能描述:常用算法 */ public ... -
dom4j+反射,面向对象方式的xml格式转换
2012-09-03 13:42 3833工作中遇到了两种不同表单设计器保存xml之间的相互转换需求,在 ... -
java正则表达式学习笔记
2012-05-30 15:59 978.{3}X 匹配前3个字符和第4个字符为X的字符串 ... -
GROOVY实现表达式解析
2012-03-23 15:39 2853Groovy嵌入到JAVA里面执行有一种方式在 ... -
Java 打开文件夹(Windows文件夹)
2011-12-13 13:30 1125java.awt.Desktop.getDesktop().o ... -
文件上传下载
2010-07-08 15:17 212public class FileUploadUtil { ... -
Flex与Java语法区别
2010-04-28 15:07 16611.定义变量 Flex private var _na ... -
设计模式 笔记(附带代码)
2010-04-09 09:33 13251.策略模式 例如:动物有很多行为,把共同的行为用抽 ... -
xml转义字符
2010-01-13 10:51 1503< < ... -
(转载)listorderedmap
2009-12-15 15:51 2322要有序可以用List,要便于查找可以用Map,那既要有序又便于 ... -
(转载)深入浅出的多线程(CachedThreadPool OutOfMemoryError)
2009-09-09 09:39 1752线程池是Conncurrent ... -
MyEclipse常用设置
2009-06-28 10:47 18301.注册MyEclipse 菜单栏/MyEclipse/S ... -
Eclipse最实用快捷键
2009-06-28 10:44 1502以下是我工作中,使用最频繁的快捷键: Alt + / 代码引导 ...
相关推荐
《Effective Java》是Java编程领域的一本经典著作,由Joshua Bloch撰写,该书的第三版继续提供了关于如何编写高效、优雅、可维护的Java代码的指导。以下是基于给出的目录和部分内容提取的一些关键知识点: ### 第一...
标题《effective-java.pdf》表明本文档是关于Java编程语言的实践指南,而描述说明了这是一份根据网上资源翻译后的HTML版本,经过转换形成的PDF文件,其内容可供参考。标签“java”指明文档内容与Java编程语言紧密...
"Effective Java读书笔记" Effective Java是一本关于Java编程语言的经典书籍,本笔记主要总结了Java语言的发展历程、静态工厂方法的应用、构造器模式的使用等重要知识点。 一、Java语言的发展历程 Java语言的发展...
总的来说,《Effective Enterprise Java》中文版是一本值得每一个Java开发者阅读的书籍,它不仅提供了丰富的编程技巧,还强调了代码的可读性和可维护性,对于提升个人技能和团队开发效率都有极大的帮助。通过深入...
《Effective Java》是Java开发领域的经典著作,由Joshua Bloch撰写,中文版第二版更是深受广大Java开发者喜爱。这本书提供了许多实用的编程实践和经验教训,帮助开发者编写出更高效、可维护的Java代码。这里我们将...
Item 85: Prefer alternatives to Java serialization Item 86: Implement Serializable with great caution Item 87: Consider using a custom serialized form Item 88: Write readObject methods defensively ...
读书笔记:Effective Java中文版第二版示例代码
读书笔记:Effective Java中文版第二版示例、笔记
以上只是《Effective Java》第二版部分关键知识点的概述,实际上,书籍中还包含更多关于类设计、方法设计、多线程、集合框架等方面的深入讨论和建议,是每个Java开发者不可或缺的参考书。通过阅读并实践书中的建议,...
《Effective Java 第三版》是由Joshua Bloch所著的一本关于Java编程的书籍,旨在向Java开发者传授编写高效、健壮、可靠的Java代码的最佳实践。书中分为多个章节,每一章节都详细介绍了Java语言中的一个特定主题,并...
《Effective Java》是Java编程领域的一本经典著作,由Joshua Bloch撰写,现在已经更新到第三版。这本书深入探讨了如何编写高效、可维护且设计良好的Java代码,是每一个Java开发者提升技能的重要参考资料。以下是对该...
籌劃第二版期間,我改寫此書的雄心一再被恐懼所取代。成千上萬的程式員熱情擁抱Effective C++ 第一版,我不希望破壞吸引他們的任何東西。但是自從我寫了第一版之後,六年過去了,C++ 有了變化,C++ 程式庫有了變化...
[Addison-Wesley] Effective Java 第2版 (英文版) [Addison-Wesley] Effective Java 2nd Edition (E-Book) ☆ 出版信息:☆ [作者信息] Joshua Bloch [出版机构] Addison-Wesley [出版日期] 2008年05月28日 ...
在编程领域,特别是Java开发中,"Effective Java"是一本非常经典的书籍,由Joshua Bloch撰写,书中提出了一系列最佳实践和设计原则,以帮助开发者编写出更高效、更安全的代码。根据提供的标题和描述,我们将探讨三个...