《Effective java》从2018年10月开始看,中间断断续续,到2019年4月23日才看完。该笔记对每一节的精华做总结(其实书中每节最后一段都有总结),我尽量做到一言以蔽之。这本书是适用于有java开发经验的人。本人不才,如有错误或理解不透彻的地方,欢迎大家指正。
《Effective java》已经出了第三版(包含jdk789),包含90条规则。但本人手中只有第二版,第二版适用于jdk1.6,包含78条规则
在学习整理时,发现有同学也有写过笔记,整理的比本人的更书面化,读者可以与本读书笔记相互对照着学习:https://www.cnblogs.com/wangyingli/category/886652.html
创建和销毁对象
1.考虑用静态工厂方法代替构造器
静态工厂方法和构造器各有所长,应优先考虑静态工厂方法。
静态工厂方法优点:
- 可以有自己的名称。
BigInteger.probablePrime
- 不必每次调用时候创建一个新对象。
Boolean.valueOf(true)
- 可以返回原返回类型的任何子类型的对象。
RegalarEumSet
与JumboEnumSet
- 在创建参数化类型实例的时候,使代码更简洁。
Map<String, List<String>> map = Map.newInstance()
静态工厂方法的缺点:
- 如果类不含共有的或受保护的构造器,就不能被子类化
- 与其他静态方法没有实质的区别
静态工厂方法惯用的名称:valueOf, of, getInstance, newInstance, getType, newType
2.遇到多个构造器参数时要考虑用构建器
解决多参数冗余问题,多于4个参数时考虑使用
演化过程:
- 重叠构造器模式:参数过多,很难编写;可读性差,不美观
- javaBeans模式:
new Bean().setName(xx).setAge(xx)
;构建过程被拆分使实例处在不一致状态,不安全;缺少参数有效性检查。 - 构建器模式(Builder模式):
Car car = new Car.Builder("bwm").wheel(4).weight(20).build()
;Builder是Car内部静态类,build方法return new Car(this)
创建Car实例。该模式易于理解,可选参,灵活,有参数检验并且安全。缺点就是要先创建build实例,有性能开销
本节后面还提到Class.newInstance
可以充当build方法的一部分。这两者相比较,Class.newInstance
调用无参构造器,而且需要处理运行异常;而build可以弥补这些不足。
3.用私有构造器或者枚举类型强化Singeton属性
在jdk1.5之前,实现单例模式有两种方式:
- 构造器保持private,导出
public static final T instance = new T()
- 构造器保持private,声明
private static final T instance = new T()
,并通过静态工厂方法导出
在jdk1.5之后,实现单例模式最佳方式是单元素的枚举模式
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() {...}
}
4.通过私有构造器强化不可实例化的能力
私有构造器应用在3节中的单例模式中,表示类不能在外部被实例化。通常应用场景是工具类Arrays、Collections。
为了防止类内部误调用私有构造器,在私有构造器内部抛出异常AssertionError
。
私有构造器的副作用:所有构造器必须显示或隐式地调用超类构造器,所以私有构造器会使一个类不能被子类化
5.避免创造不必要的对象
本节强调,当你应该重用现有对象时,不要创建新对象。
本节举出几种例子:
- 不要冗余创建,比如
String s = new String("test")
- 不可变对象应该放在初始化中,如static代码块,不要每次调用方法时重建
- 优先使用基本类型,避免装箱
- 创建对象的代价不大时,不要使用对象池;对象池会把代码弄斧在,而且增加内存空间,jvm优化性能可能很容易就超过轻量级对象池的性能
- 保护性拷贝的规则大于此规则
6.清除过期的对象引用
java不像C/C++语言一样,需要手工管理内存,释放内存空间。但java的内存回收机制的回收动作,是建立在对象没有引用的前期下,所以如果希望对象被回收,需要清除对象的引用。
本节举反例:实现一个stack类,pop方法只将元素弹出,指针前移,原数组位置没有置为null,对象引用仍在,存在“内存泄漏”
内存泄漏:对象引用被无意识地保留起来,导致内存无法回收这些对象;即使少量的对象引用被保留,也会有许多被间接引用的对象排除在gc之外
解决方式:清除过期引用,将对象引用值为null,该方式是无隐性问题,顶多是会发生NPE,而不是悄悄错误的运行下去。
清除对象引用时一个例外,而不是一种规范。我们应该在最紧凑的作用域范围内定义每一个变量,这样超出作用域对象会自动被回收,而不是每次都要手工清除对象引用。
一般而言,存在内存泄漏的场景有:1、只要类自己管理内存(数组),就应该警惕内存泄漏;2、缓存,对象放入缓存中很容易被遗忘,建议使用WeakHashMap、LinkedHashMap,或定期清理缓存;3、监听器和回调,如只注册回调,而没有显示的取消注册。,最好也使用弱引用,例如WeakHashMap
7.避免使用终结方法
终结方法finalizer通常是不可预测的,也是很危险的,一般情况下是不必要的。
带来的问题:
- 不保证何时、以及是否会执行;优先级很低。对象变得不可达,到终结方法被执行的时间是不确定的,而且JVM会延迟执行终结方法。
- 异常发生在finalizer方法中,会被忽略,且终结会终止
- 类中增加终结方法会有严重的性能损失
代替方法:显示提供一个终止方法,如close、cancel。典型例子如inputStream、OutputStream、Timer、Connection等。同时类中方法需要每次被调用前检查该对象是否已经终止。显示终止方法通常与try-finally结合,在finally代码块中被显示调用。
finalizer的作用:
- 为显示终止方法兜底,做一些资源释放或日志记录的工作,充当安全网。但要考虑这么做是否值得付出代价。
FileInputStream
和FileOutputStream
应用了这种方式 - 为本地对等体释放非关键的本地资源
注意:子类继承父类,如果需要重写finalizer方法,必须在try-finally中显示调用父类终结方法super.finalizer,因为终结方法链并不会自动执行。为了避免子类忘记这样做,父类可以使用终结方法守卫者,确保自身的终结方法被执行。
public class Foo {
//终结方法守卫者
private final Object finalizerGuardian = new Object() {
protected void finalize() throws Throwable { //TODO 终结外围类Foo}
}
}
对所有对象都通用的方法
本章讨论如何覆盖Object中的非final方法(equals、hashCode、toString、clone、finalize)。在覆盖这些方法时,都有责任遵守这些方法的通用约定,如果做不到这一点,其他依赖这些约定的类就无法结合该类一起正常工作。
8.覆盖equals时请遵守通用约定
如果能不需要覆盖equals就不要覆盖,避免因为覆盖导致错误。一般不覆盖的情况有:
- 类的每个实例本质上都是唯一的。如Thread
- 不关心类是否提供了“逻辑相等”的测试功能。如Random类,设计者不认为客户需要或期望检查两个Random实例是否产生相同的随机数这样的功能。
- 超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的。例如AbstractSet、AbstractList、AbstractMap。
- 类是私有的或者是包级私有的,可以确定equals方法永远不会被调用
如果类具有自己特有的“逻辑相等”概念,就要覆盖equals方法。这通常属于“值类”的情形。值类是一种表示值得类,如Integer、Date。
覆盖equals的原则:自反性、对称性、传递性、一致性、非空性(null-false)
接下来本节对上述原则进行详细讲解。在传递性原则中,举例Point类和子类ColorPoint在传递性和对称性总是不能同时满足的问题,得到这样一个结论:无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定。简单来说,就是继承一个可以创建实例的类,如果在超类基础上增加了别的属性,那么就不能保证equals的原则。示例就是java.sql.Timestamp
,这个类继承与Date
,而且不能和Date
混用。解决这个问题的权宜之计就是:16条,复合优先于继承。
在一致性原则中,描述不要使equals方法依赖于不可靠资源。比如java.net.URL
类,将主机名转换为IP地址需要访问网络,而网络是不稳定的,所以其equals违反了一致性原则。
如果必须要覆盖equals方法,实现高质量的equals的诀窍:
- 使用==操作符检查“参数是否为这个对象的引用”
- 使用instanceof操作符检查“参数是否为正确的类型”
- 把参数转换为正确的类型
- 对于该类中每个“关键”域,检查参数中的域是否与该对象中对应的域匹配。对于非float、double的基本类型使用==比较;引用类型使用equals比较;float使用Float.compare比较;double使用Double.compare比较。因为float和double有一些特殊的常量。判断null,避免NPE。注意比较顺序,先比较最可能不一致的域,提高性能。
- 编写完成后,检查是否具有对称性、传递性、一致性
- 覆盖equals时总要覆盖hashcode
- 不要让equals方法过于智能
- 不要将equals的参数类型Object替换成其他类型
9.覆盖equals时总要覆盖hashCode
违反该约定,会使类无法与基于散列的集合一起正常工作,包括hashmap、hashset等
Object约定的规范:
- equals比较用到的关键域没有改变,hashcode返回始终应该是同一个整数
- equals比较相等,则hashcode一定相等
- equals比较不相等,则hashcode不一定不相等,如不相等,则散列性能更好。
重写hashcode的简单方法:
- 初始一个值为
int result = 17
- 对equals涉及的关键域,计算hashcode为c,计算方法如下:
- boolean域:
(f ? 1 : 0)
- byte、char、short、int域,计算
(int)f
- long域,计算
(int)(f ^ (f >>> 32))
- float域,计算
Float.floatToIntBits(f)
- double域,计算
Double.doubleToLongBits(f)
,之后再由long转成int - 引用域,递归调用hashcode
- 数组域,使用
Arrays.hashCode()
- boolean域:
- 将上面计算的int值c,计算公式:
result = 31 * result + c
- 最后返回result
解读上述计算过程
- 冗余域排除在外,只计算equals包含的关键域
- result为17是任选的,非0即可
31 * result
的作用使得hashcode依赖域的顺序;之所以选择31,是因为31是一个奇素数,如果是偶数的话,乘法溢出会导致信息丢失,而且偶数相当于移位运算。31还个特性是,可以使用移位和减法代替乘法。31 * i == (i << 5) - i
对于一个不可变类,如果频繁计算hashcode,可以考虑将hashcode计算完一次后缓存在对象内部。
10.始终要覆盖toString
覆盖toString虽然没有覆盖equals和hashcode那么重要,但覆盖toString可以使类使用起来更加舒服,易于阅读,诊断消息。因为调用print(object)会自动调用调用object的toString,如果不覆盖,返回的是类名@hashcode无符号16进制
表示法。
正常情况下,toString应该包含所有关注的信息,但对象如果过大,可以返回一个摘要信息。
对于toString返回的格式,应该在注释中描述清楚,是否一直使用这种格式(需要谨慎考虑),提供一个格式示例。无论是否指定格式,都要为toString返回值包含的信息,提供一种编程式访问途径。
11.谨慎地覆盖clone
Cloneable接口是一个mixin接口,该接口没有方法,不值得效仿,去实现Cloneable接口并编写一个良好的clone很难操作,所以尽量不要去使用它
clone方法在Object中,如果类实现了Cloneable接口,调用clone方法就返回拷贝对象,是浅拷贝;没有实现Cloneable接口,调用clone方法就会抛CloneNotSupportedException
异常。
clone方法的通用约定很弱,下面是约定内容
- x.clone() != x
- x.clone().getClass() == x.getClass()
- x.clone().equals(x) == true
由于实现clone方法时,可能需要修正域,这种情况是不适用于类中有final修饰的引用对象域的
这里给出实现Cloneable接口,提供良好clone方法的注意点
- 提供public的clone方法
- 在clone方法内部,首先调用super.clone
- 修正超类的clone对象
替代方法:使用拷贝构造器public Yum(Yum yum)
或拷贝工厂pulbic static Yum newInstance(Yum yum)
代替clone。所有的通用集合都提供的拷贝构造器。
12.考虑实现Comparable接口
Comparable.compareTo
方法提供了等同性比较和自然排序的能力。一旦实现了该接口,就可以和许多泛型算法和依赖该接口的集合进行协作,如对存储在集合中的Comparable对象,进行搜索、计算、自动维护很简单。
compareTo要求值相等返回0,大于返回正值,小于返回负值。通用约定与equals方法相似,都要求:自反性、传递性、对称性,当然也无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留compareTo约定
compareTo还有一个强烈建议的约定:x.compareTo(y) == 0
等于x.equals(y)
由于Comparable是泛型,一般来说不必要做类型检查和类型转换。
如果类没有实现Comparable接口,可以实现Comparator类,通过Comparator.compare(x, y)
进行比较排序。
类和接口
对设计类和接口提供一些指导原则
13.使类和成员的可访问性最小化
要区别一个模块设计的好坏,重要的因素在于,这个模块对外部而言,是否隐藏了内部数据和其他细节。良好的模块会隐藏内部细节,通过API把实现隔离开来。这个概念称为信息隐藏。信息隐藏的好处是可以实现模块解耦、提供可重用性、降低系统风险、易于优化。
jdk提供了访问控制协助信息隐藏。有public、protected、private去决定类、接口、成员的可访问性。
对于顶级非嵌套类和接口,只有两种访问级别:要么使用public,永远支持它保持兼容性;要么使用default,作为包级私有,以后可以对它修改、替换、删除。如果一个包级私有的顶层类只在某个类内部使用,可以考虑使他作为私有嵌套类。然而降低public类可访问性比较低defalut类访问性更重要的多。
对于成员(域、方法、嵌套类、嵌套接口),有四种访问级别
- private:类级私有,类内部访问;不需要永远支持
- default:包级私有,该类所在包的其他类都可以访问;不需要永远支持
- protected:该类所在包的其他类都可以访问+子类可以访问;需要永远支持
- public:任何地方都可以访问;需要永远支持
要实现的原则是,尽可能地使每个类或者成员不被外界访问,使用最小的访问级别;
还有一些规则:
- 子类覆盖超类的方法,其访问级别不能低于超类
- 接口方法必须是public修饰的
- 实例域不能是public,即使是final修饰的,这样对象就无法控制实例域的变化,同时也是非线程安全的。该规则同样适用于静态域。
- 长度非零的数组总是可变的,所以不可以任何形式暴露数组,包括方法返回数组;修改整个问题可以需要将数组变为私有的,并增加一个不可变List或返回数组的一个备份
14.在公有类中使用访问方法而非公有域
该规则表示,坚持面向对象设计思想,如果类是public,永远都不要暴露可变域,应该通过访问方法去操作这些域
如果类是包级私有(default)或者私有嵌套类,直接暴露它的数据域并没有本质错误
15.使可变性最小化
可变性最小化可以达到不可变类,不可变类是所有内部域是final类型,在创建时赋值,并在对象生命周期内不变,如String。除非有必要,否则应该保持类是最小化状态。
定义不可变类方法
- 不提供任何修改对象状态的方法
- 保证类不会被扩展:类为final或者私有构造器+公有静态工厂
- 所有域都是final的
- 所有域都是私有的
- 可变域引用时不可访问的
不可变类优点:比较简单,只有初始化这一种状态;本质上是线程安全的,可以自由地被共享;不需要提供保护性拷贝方式;
不可变类缺点:每一个不同的值都需要一个单独的对象。解决这个问题可以提供一个配套类。如String类有StringBuilder。
如果类不能做成不可变的,仍然应该尽可能限制它的可变性,除非有令人信服的理由使域变成非final的,否则每个域都应该是final的。
16.复合优先于继承
类继承是代码复用的手段,但并非最佳;包内或为继承而设计的类很安全,但其他类继承会打破封装性,非常脆弱,并不能保证在任何情况都可用。举例:继承HashSet,实现曾增加历史元素计数功能,由于依赖HashSet实现细节,而这些细节不是承诺,所以不能保证java平台始终不变;又或者没有重写,而是新增方法,但如果之后父类发行版提供了与你一模一样方法,也会有问题,所以本节强调复合优先于继承。
复合:使用父类的实例作为新类的私有域,新类也称为包装类,这种方式也称为Decorator模式
转发:新类的每个方法都可以调用到原类的实例方法
何时选择:当AB类存在is-a的关系,即A也是B,A才扩展B,否则应使用复合,复合比继承更健壮。java库中有违反这条原则的地方,如Stack不是向量Vector,所以Stack不应该扩展Vector。Properties也不应该扩展Hashtable,复合模式才是恰当的。
17.要么为继承而设计,并提供文档说明,要么就禁止继承
继承的危害在16节已经说明了,重写可覆盖方法会影响子类的行为,而且也不健壮。所以要么就为了继承而设计一个类,并提供文档说明,要么就禁止继承
如何编写一个为继承而设计的类呢?
- 继承类必须说明可覆盖方法的自用性,即必须指明当前方法调用了哪些可覆盖方法,以什么顺序调用的,重写可覆盖方法所带来的影响。优秀的API文档会描述方法做了什么,而不是描述如何做的。
- 继承类也应该提供protected修饰的hook方法给子类重写,hook方法提供了进入内部工作流程中;如AbstractList.clear,调用了可重写的removeRange方法,客户可以重写removeRange改变clear的性能。
- 测试为了继承而设计的类,唯一的方法就是编写子类,一般3个子类就足以测试一个继承类
- 为继承而设计的类,其文档、实现细节都应该在后续版不可变的,所以设计要谨慎
- 为继承而设计的类,其构造器绝不能调用可被覆盖的方法;父类构造器会在子类构造前执行,而子类覆盖的方法会提前执行,如果此方法以来子类构造器初始化工作,则会有不可预期的行为;同样的,Cloneable.clone、Serializable.readObject方法都类似于构造器,所以也不能调用可覆盖方法
如果不是为了继承而设计的类,就禁止继承,防止子类化的方式有:
- 类声明final
- 构造器变成私有的或包级私有的,并增加静态工厂代替构造器
由于习惯了对普通类进行子类化,以增加新的功能设施,禁止继承可能会给程序员带来不变,如果你认为必须允许从这样的类继承,一种合理的办法是:确保这个类永远不会调用它的任何可覆盖方法,并在文档中说明这一点。换句话说,完全消除这个类中可覆盖方法的自用特性,这样做后,就可以安全地进行子类化了。
18.接口优于抽象类
接口和抽象类都是java中定义类型、扩展功能的机制。
两者主要区别在于接口中全部是抽象方法,抽象类中可以包含抽象方法,也可以包含非抽象方法。
而接口优于抽象类的原因是,类可以实现多个接口,但只能继承一个抽象类,抽象类的使用受限。
假使对一个类进行扩展功能,可在implements后新增接口实现,如Comparable引进到java中后,更新了许多现有类。同时接口也是mixin(混合类型)的理想选择,mixin的意思是混入其中,即允许接口功能混入类的主要功能中,如Cloneable。上述两种如果使用抽象类,则要构建类型层次,很臃肿不便。
抽象类可以作为接口的默认实现,文章中描述为“骨架实现类”,java中如AbstractCollection、AbstractSet等,为接口提供实现上的帮助。骨架实现类的编写要求确定基本方法,其他方法依赖基本方法实现,基本方法作为为抽象方法,由子类实现。骨架方法为继承所设计,应该编写说明文档。骨架实现类在没有子类继承时也可以单独使用。
设计接口要谨慎,一旦接口确定公开,被广泛实现,再想修改是不可能的,所以除此设计必须保证接口正确性,最好的做法是尽可能多去实现接口做测试。而抽象类则没有这个问题,抽象类演变比接口容易的多,如果演变容易性比灵活性重要,应使用抽象类定义类型,但要接口抽象类带来的局限性。
19.接口只用于定义类型
该条主要表达接口主要作用是充当类实例的类型,是实现类动作的描述,任何其他目的使用接口都是不恰当的,尤其是常量接口使不良使用。
常量接口的问题在于对实现类没价值;会把实现类需要的常量这种细节暴露出来;后续不需要这些常量仍要保持实现这个接口;
定义常量的合适方案有:只将必要的常量放入类或接口中;使用枚举类型;不可实例化的工具类。推荐工具类,因为可以利用静态导入,避免使用类名修饰常量名。
20.类层次优先于标签类
类层次指的是父类-子类层次关系,如(父类)形状类有(子类)圆形类和方形类,父类有计算面积的抽象方法,子类各有各的成员变量和计算面积实现方法。
标签类是将我们常用的类层次关系,混合在一个类中,这个类叫做标签类,一般有编程思想的人也不会考虑到标签类。按照上面父子类关系来说,标签类就是形状类体中,包含了圆形和方形的相关成员变量,在计算面积方法中,又使用switch实现具体的计算。
标签类的缺点在于,一个类中包含不相关的域,代码冗长,容易出错,很少有适用的时候。
21.用函数对象表示策略
以书中本节最后一段话总结。
函数指针的主要用途就是实现策略模式,实现该模式要声明一个接口表示该策略,并且每个具体的策略就是声明一个实现该接口的类,通常使用匿名类来实例化这个具体策略类,并将该实例以参数的形式,传入其他方法中。
当一个具体策略需要重复使用时,该具体策略类建议作为调用类的私有静态成员类(该定义见22节),并通过公有静态final成员暴露出去。
java中Comparator比较器就是策略模式的最佳示例
22.优先考虑静态成员类
嵌套类指定义在一个类内部的类,目的是为外围类提供服务。分为静态成员类,非静态成员类,匿名类,局部类,后3种都被称为内部类。
静态成员类可以看作普通类,只是碰巧在一个类内部声明而已,作用范围与外围类的静态成员一样,可以独立存在,可以访问外围类的所有成员。一般用于公有辅助类
非静态成员类作用范围与外围类的成员变量一样,需要依赖外围类实例,不能单独存在。如Map中的keySet,entrySet,Values。
每个非静态成员类实例都会包含一个指向外围类实例的引用,占用空间同时使外围类实例不能被回收;所以当成员类不需要访问外围实例,应考虑静态成员类
匿名类不具有名称,在声明的同时也被实例化,无法重复使用类;无法实现多个接口或继承类;没法有静态成员。一般用于动态创建函数如Comparator,或过程对象如Runable、Thread,或工厂方法内部。代码行数要少,避免影响可读性。
局部类一般声明在方法中,使用场景较少。与成员类一样可以有类名,可重复使用,但和匿名类一样不能有静态成员
泛型
在没有泛型之前,从集合中读取对象都需要显示转化,运行期可能报错;有了泛型,编译器会自动帮助转化,并在编译时提醒。本章介绍如何更优雅的使用泛型
23.不要在新代码中使用原生类型
以List<E>为例,E称为类型参数。拥有一个或多个类型参数的类或接口称为泛型。List称为该泛型的原生类型
使用原生类型会失去泛型在安全性和表达性上的优势,即可能导致运行时异常和显示转化的问题,所以不要再新代码中使用。原生类型的存在原因是为了兼容1.5之前的代码;
List和List<Object>的区别在于前者舍弃了泛型检查,后者明确使用Object;例如List<String>可以赋值给List,但不能赋值给List<Object>,而且赋值给List后,该变量可以add其他类型的实例,并且在编译期间只有警告没有异常。
原生类型有安全的替代方式,就是可以使用List<?>
,这被称为无限制通配符类型,它不关心类型参数是什么。List和List<?>的区别在于,前者可以add任何类型元素,后者只能add相同类型元素。
原生类型可以在两种场景下使用:1.使用List.class表示类,而不是List<String>.class。2.使用xxx instanceof List
,而不是xxx instanceof List<String>
24.消除非受检警告
要尽可能消除泛型相关的非受检警告,这样可以确保代码是类型安全的,不会有ClassCastException。
如果碰到无法消除警告,同时确认代码是类型安全的,可以使用@SuppressWarnings("unchecked")
注解来忽略警告。该注解要尽可能在小范围中使用,避免忽略其他重要警告。使用该注解忽略警告后,要注释原因和为什么是安全的
25.列表优先于数组
结论中说,数组是协变的且可以具体化的,泛型是不可变的且可以被擦除的。协变
指的是父子类成为数组类型仍然保持父子关系,而不可变
指父子类成为泛型就没有父子关系。具体化
指的是运行时才检查元素类型约束,擦除
指运行时会擦除类型信息,运行时包含的信息比编译时包含的更少,所以在编译时检查类型约束。因此,数组在运行时提供了类型安全,但编译时没有,泛型反之。
由于上述两种原因,数组和泛型不能很好的混合使用。例如不能创建一个泛型数组,下面这些是非法的:new List<String>[], new E[],因为它们不是类型安全
的。但有特例,无限通配符类型的数组是合法的:new List<?>[]
由于数组和泛型不能结合使用,这意味着在可变参数方法中使用泛型会出现令人费解的警告,这是由于可变参数方法原理是会创建一个数组存放可变参数,如果数组元素是泛型,那么将会有警告。
综上,当我们在泛型和数组之中做选择,或混合使用发生警告时,优先使用泛型列表,这样可以保证消除警告后代码是类型安全的。
26.优先考虑泛型
本节强调如果代码中存在类型转换,应考虑使用泛型替换;对于老代码的类型转换,应该都泛型化。
本节示例了在泛型化过程中,可能碰到创建泛型数组的问题,在必须使用数组的同时其警告又无法解决的情况下,可以自己保证代码不会涉及到类型安全,使用@SuppressWarnings
忽略掉警告。示例简述:x = new E[5] --> x = (E[]) new Object[5],确保x不会暴露到外部,再使用注解忽略警告。
本节提及到了DelayQueue<E extends Delayed>
,要求类型参数E是Delayed的子类,这被称为有限制的类型参数。
27.优先考虑泛型方法
泛型不止适用于类,也适用于方法,尤其是静态工具方法,如Collections中的所有算法方法都泛型化了。
泛型方法的格式如下:public <E> List<E> asList(E[] a)
表示为:修饰符 类型参数 返回值类型 方法名(参数类型 参数)
泛型方法的作用在于可以使方法变成类型安全的。
泛型方法的显著特性是无需指定类型参数的值,可以直接根据方法的参数类型,推导出泛型的类型参数,这个过程称为类型推导。使用场景有泛型静态工厂方法Map<String, List<String>> args = Func.newHashMap()
,后面的工厂方法无需指定类型参数。
本节还提到了递归类型限制
,最普遍的用法是Comparable接口。格式如下:public <T extends Comparable<T>> T max(List<T> list)
,每个类型参数都实现了Comparable<T>
接口,可以对自身进行比较
ps:这节示例比较晦涩,不易看懂,本节还有一个恒等函数的例子,未列在此笔记中
28.利用有限制通配符来提升API的灵活性
举例一个应用场景引出两种有限制通配符:
Stack<E>
类中有pushAll(Iterable<E> src)
方法和popAll(Collection<E> dst)
方法。
pushAll
考虑下面代码:
Stack<Number> stack = new Stack<Number>();
Iterable<Integer> integers = ...
stack.pushAll(integers)
虽然Integer是Number的子类,但由于泛型的不可变性,Iterable<Integer>
并不是Iterable<Number>
的子类,所以这么使用会有错误信息。解决方法是使用有限制的通配符类型pushAll(Iterable<? extends E> src)
,表示src是类型参数E子类的Iterable实例。
popAll
考虑下面代码:
Stack<Number> stack = new Stack<Number>();
Collection<Object> objects = ...
stack.popAll(objects)
存在类似的问题,Number
是Object
的子类,但Collection<Number>
并不是Collection<Object>
的子类。解决方法是使用有限制的通配符类型popAll(Collection<? super E> dst)
,表示dst是类型参数E父类的Collection实例。
上述示例可以总结一个助记得规则:PECS,表示producer-extends,consumer-super。上面示例push的参数表示生产者,所以使用extends;而pop参数表示消费者,所以使用super。(按照这个规则,本节对本章中,前面几节的示例进行修改,使API更加灵活易用。由于笔记中前几节并没有记录示例,所以此处只简单描述一下旧->新的变化)
<E> E reduce(List<E> list, Function<E> f, E initVal)
->
<E> E reduce(List<? extends E> list, Function<E> f, E initVal)
<E> Set<E> union(Set<E> s1, Set<E> s2)
->
<E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2)
,如果s1和s2是E的不同子类,编译器无法推导出返回类型,就需要显示指定出来,如Set<Number> n = Union.<Number>union(integers, doubles)
<T extends Comparable<T>> T max(List<T> list)
->
<T extends Comparable<? super T>> T max(List<? extends T> list)
,这个是最复杂的方法声明,参数list表示生产者,这个不必多说;而前面的递归类型限制使用super,最初T被指定扩展Comparable<T>
,同时T的comparable也消费T的实例,因此作为消费者用super替换。ps:这里不好理解,以后记住Comparator和Comparable都是消费者,都是用super。
类型参数和通配符都可以实现泛型,同一个方法怎么做选择呢?
<E> void swap(List<E> list, int i, int j)
和void swap(List<?> list, int i, int j)
,如果类型参数在方法声明出现一次,或方法是对外公开的api,则使用通配符声明泛型,因为容易编写且api对客户更友好和易使用;而类型参数泛型使用在内部或者作为通配符方法内部的辅助方法使用较好
29.优先考虑类型安全的异构容器
异构容器:泛型常用于Map、Set集合,以及单元素容器ThreadLocal、AtomicReference;通常我们是将泛型应用在类上,如果我们希望容器的元素类型可以是灵活多样的,例如Map的key可以是不同类型,要实现的话,可以对参数进行泛型化,而不是对容器泛型化,这种存储不同类型的容器就是异构容器。
例如代码如下,可根据不同的type参数,存储和返回对应类型的实例(Class类在1.5被泛型化了,例如String.class
属于Class<String>
)。
public class Favorites {
//此处无限通配符类型是嵌套在Map中的,所以每一个键都可以是不同的参数类型,也就是异构的
private Map<Class<?>, Object> fav = new HashMap<Class<?>, Object>();
public <T> void put(Class<T> type, T ins) {
if (type == null)
throw new NPE(...);
fav.put(type, ins);
}
public <T> T get(Class<T> type) {
//此处cast是Class的方法,由于map返回的是Object对象,所以需要用cast动态转换
return type.cast(fav.get(type));
}
}
Favorites类有两个局限性:1.恶意的客户端可以使用原生类型(Class)破坏类型安全,如put(integerclass, stringins),解决办法可以在put方法中对ins使用cast动态转化。2.不能用于不可具体化的类型,如List<String>
,因为不可具体化类型在擦除之后类型都是List.class,而List<String>.class
是语法错误,此问题在本节提到没有满意的解决办法。(提到使用Class.asSubclass,这里比较晦涩,暂时不记录在此笔记中)
枚举与注解
枚举与注解是1.5提供的;枚举可以认为是类,注解可以认为是接口
30.用enum代替int常量
枚举类的作用是提供一组固定的常量,替代静态常量枚举模式。使用场景有菜单选项、代码、星期等
静态常量的枚举模式存在不足:无命名空间、无法遍历、硬编码、不能在编译时被约束、无法关联方法等,这些都表示静态常量枚举模式十分脆弱,不安全。枚举类可解决上述问题,而枚举类相比静态常量缺点在于会有时间和空间成本。
枚举声明与类相似,声明时将原本class->enum即可;枚举可以近似认为是私有构造方法、final的类,无法实例化和继承。内部枚举元素近似认为是暴露公有静态final域。
枚举和类一样,可以有构造方法、成员方法和变量,也可以实现接口,但不能继承类,原因是枚举类本身是继承自Enum。
枚举默认带有values()方法,返回常量数组;toString()方法返回常量名称;valueOf方法将名称转换为元素;这些方法都可以重写
枚举类中可以有抽象方法,每个枚举常量声明时要实现该抽象方法;枚举类也可声明内部枚举类;在考虑灵活、安全方面,可将其他枚举类作为参数传入当前枚举类构造函数中,构成策略枚举(策略模式),调用其他枚举类的方法为自身所用。抽象方法和策略枚举要活用起来,避免使用switch这种。
枚举类的本质是int值
package enumcase;
public enum Operation {
PLUS("+"){
@Override
public double apply(double x, double y) {
return x + y;
}
},
MINUS("-"){
@Override
public double apply(double x, double y) {
return x - y;
}
},
TIMES("*"){
@Override
public double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/"){
@Override
public double apply(double x, double y) {
return x / y;
}
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol }
public String toString() { return symbol; }
public abstract double apply(double x, double y);
public static void main(String[] args) {
for (Operation op : Operation.values())
System.out.println(op.apply(10, 2));
}
}
31.用实例域代替序数
每个枚举还有ordinal()方法,它返回枚举常量在类中的位置。
本节强调,如果有需要枚举常量位置的需求,不要使用ordinal方法,而在枚举类中声明一个实例域index,将位置(1,2,3…)通过构造函数传给实例域保存。如:RED(1), GREEN(2), YELLOW(3)
32.用EnumSet代替位域
二进制中,如果每一位代表一个功能或属性,可以通过OR或AND位运算,将多个二进制常量合并在一个集合中,称为位域。一般用于传递多种属性集合的时候使用
public static final int STYLE_BOLD = 1 << 0;
public static final int STYLE_ITALIC = 1 << 1;
text.applyStyles(STYLE_BOLD | STYLE_BOLD); //位域
位域缺点在于,与静态常量枚举类型一样的问题,同时又难以遍历和翻译
EnumSet可以代替位域,而性能和位域是一样的。EnumSet实现于Set接口,底层同样使用二进制+位运算的方式实现,EnumSet将复杂的位运算逻辑封装,又具备枚举类型的优势,保证了易用性、类型安全性。使用例子如下:
public enum Style { BOLD, ITALIC, UNDERLINE}
public void applyStyles(Set<Style styles>) {...}
...
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC))
EnumSet缺陷:jdk内置工具类中,无法创建不可变的EnumSet类型;可以用Collections.unmodifiableSet
进行封装,但简洁性和性能会受到影响。
33.用EnumMap代替序数索引
简单来说,如果需要枚举和枚举、枚举和实例之间表示映射关系,不要使用序数索引数组来表示,应该用EnumMap。
数组不能和泛型兼容;数组下标没法代表任何意义;后期增加、修改元素风险和工作量大;
EnumMap实现Map接口,内部使用数组,但对使用着隐藏了复杂的实现细节。EnumMap构造器有一个Class对象的参数,限制key的类型。
34.用接口模拟可伸缩的枚举
本节讨论如何模拟枚举类可伸缩性,可伸缩性包括继承、面向接口。
首先枚举类是final不可继承。所以如果有公用方法,可以编写接口(包含公用的抽象方法),之后编写枚举类实现该接口,在有接口声明的地方,可以使用实现该接口的枚举类,以这种方式由使用者自己编写枚举模拟可伸缩的特性。
如果公用共享方法比较多,则可以将其封装在辅助类或静态辅助方法中,避免代码复制工作。ps:考虑复合优先于继承,也应该这么实现
35.注解优先于命令模式
包含本节之后的3节,讲讲注解的使用。本节以JUnit单元测试为前提了解注解。
注解的声明方式与接口类似,注解在声明后,可以通过@XX的方式,声明在类、方法、实例上,用来表示额外的元信息,可以被反射机制利用,而且注解不会改变被修饰代码的含义。
下面代码描述了JUnit使用Test注解的示例,JUnit工具类会通过反射获取标记了Test注解的方法,并执行这些方法,在通过value值获取特定的异常信息。
@Retention(RetentionPolicy.RUNTIME) //注解在运行期间保留
@Target(ElementType.METHOD) //注解只能修饰在方法上
public @interface Test{
Class<? extends Exception> value() //注解value属性
}
注解的功能之一是代替"命名模式":
命名模式指在没有注解前,一般使用命名模式对方法或实例增加额外信息的一种命名方式;如JUnit框架要求测试方法以test作为开头。命名模式的问题:1、有文字拼写错误编译器不会提醒,导致失败;2、无法限制只用于某特定方法或类上;3、命名名称与方法运行逻辑无法关联起来,如只在方法发生异常时记录。
在使用IDE或静态分析工具提供的任何注解可能并没有标准化,如果变化工具可能注解就无用了
36.坚持使用Override注解
Override注解修饰方法,表示方法是重写的。
此条规则的目的在于,当我们预期重写某方法,而由于编写错误导致没有重写,可以让编译器帮忙警告。
对于抽象方法的重写,不必标注该注解(标注也没坏处),因为编译器会提醒抽象方法没有实现。
37.用标记接口定义类型
标记接口是没有方法的接口,实现该接口代表具备额外的某些属性。如Serializable接口。
标记接口优于注解有两点:1、标记接口定义的类型是由实现类实现的,可以让第三方方法只接收该标记接口的实例;2、可以扩展其他接口(如Set接口扩展Collection)
本人并没有对本节内容理解得清楚,根据总结描述,当我们需求是标记类或接口,同时相关方法的参数接收该标记的实例,而且以后不会改变,则使用标记接口;其他情况下使用注解,尤其在使用反射时。
方法
38.检查参数的有效性
本节主要观点如下。
设计方法时,应该使它尽可能的通用,即方法对于他能接受的所有参数值都能够完成合理的工作,对参数限制越少越好。然而通常情况下,有些限制是固有的。
1、方法在执行之前就应该检查参数有效性,这样可以快速失败。失败抛出异常通常可以为IllegalArgumentException, IndexOutOfBoundsException, NullPointerException
等;构造函数也是方法,也要检查参数有效性
2、非公有方法通常使用断言assert
来检查参数,断言失败会抛出AssertionError。
3、参数有效性检查包含在方法计算过程或逻辑中,可以不用在方法执行之前检查。如排序方法会在排序过程中会对排序元素做检查
4、参数限制应写在方法注释中,异常也应写在@throws
中
39.必要时进行保护性拷贝
这节给我们的启示是,假如客户端会尽可能的破坏类的约束条件,类的设计者就必须考虑类的安全性。除非类和客户端之间有着相互的信任,或者破坏类的约束条件不会影响到除了客户端之外的其他对象(包装类模式)
如果类所包含的方法或构造器调用需要移交对象的控制权,这个类就无法抵御恶意的客户端。如果客户端提供的对象通过参数进入类的内部数据结构中,有必要考虑一下,该对象是否可能是可变的。如果是,就要考虑类是否能容忍对象进入数据结构之后发生变化。如果不能容忍,就必须对该对象进行保护型拷贝,保护型拷贝示例如下:
public final class Period{
//start和end为不可变,但Date内部实例是可变的
private final Date start;
private final Date end;
public Period(Date start, Date end) {
//不要直接用start和end给内部实例赋值
this.start = new Date(start.getTime());
this.end = new Date(end.getTime())
checkTime(this.start, this.end);
}
//不要直接返回start和end内部实例
public getStart(){return new Date(start.getTime())}
public getEnd(){return new Date(end.getTime())}
//ps:该类正确设计方法应该是存储final long型时间,而不使用Date
}
保护型拷贝,简单来说,就是类内部成员实例,无论类外对象通过类构造函数给内部实例赋值或get方法输出给类外对象的情况下,类外部对象和内部成员实例始终不是同一个引用,类外部对象的修改对内部实例无影响。而实现保护型拷贝,就是在赋值或输出时,通过clone或重新new对象的方式实现。但是赋值时进行clone,类外对象可能重写clone方法,返回不可信任的子类化参数,所以赋值时clone方法进行保护型拷贝不要使用。
保护性拷贝要在检查参数有效性之前,并且参数有效性检查针对拷贝之后的对象,这样可以避免线程不安全
数组总是可变的,内部数组返回客户端之前,应该考虑保护型拷贝或返回该数组的不可变视图
40.谨慎设计方法签名
本节将一些简单的、不能单独成一节的规则总结在一起
方法签名指确定一个唯一方法的要素:方法名、参数个数、参数类型
- 谨慎选择方法名称:遵循易于理解的、风格统一的命名规范
- 不要过分追求提供便利的方法:当一个方法被经常使用到,才考虑为它提供快捷方式。方法太多会使类难以学习,使用,文档化,测试和维护。
- 避免过长的参数列表:最好是4个参数或者更少;过多参数会使API使用不便,使用者也记不住。缩短参数列表前面有提到过:1、多个方法嵌套;2、创建辅助Bean作为参数;3、建造器模式
- 参数类型优先使用接口而非类,布尔类型优先使用枚举
41.慎用重载
这里扩展一下知识点:重载与重写是如何实现的(重载是静态的,编译期确定的;重写是动态的,运行确定的)
慎用重载的原因在于,某些重载的实现是由于参数类型为父类、子类,这会导致想要调用的方法与预期不相符。而且重载也容易让使用者对给定一组参数实例,但使用哪个重载方法感到困惑。如classify(List<?> c)
和classify(Collection<?> c)
,remove(int i)
和remove(E obj)
安全又保守使用重载的策略是:永远不要定义两个具有相同类型参数个数的重载方法;如果方法是可变参数,那就不要重载它。
变通的解决办法是通过修改方法名,将重载变成两个无关的方法;如果坚持使用重载,要么是保证多个重载方法内部行为逻辑一致,要么就一定要确认“给定一组实际参数调用哪个重载方法是确定的”,即实现重载的相同参数个数的方法,其参数类型之间是没有任何关系的.
jdk源码也有违背此规则的:String#valueOf(char[]),#valueOf(Object)
42.慎用可变参数
可变参数方法可接受0-n个指定类型的参数,可变参数是灵活的,但如果滥用会产生混乱
可变参数实现原理是,在调用方法前创建一个数组,数组长度为参数长度,之后将数组作为参数传递到方法中
static int sum(int... args) { //声明可变参数方法,args为数组
//TODO 相加操作
}
可变参数的问题:会在重载时对调用哪个方法产生混乱;由于每次调用可变参数方法都要新建数组,影响效率
如何安全优雅使用可变参数:假设确定某个方法95%会调用3个或者更少的参数,课声明该方法的5个重载,每个重载带有0-3个参数,参数超过3个就使用一个可变参数方法。最大限度降低混乱和性能开销。
43.返回零长度的数组或集合,而不是null
如果返回值为数组或集合的方法有可能返回null,那么使用者始终都要处理null。对于方法设计者和使用者都增加了逻辑复杂度。
规范做法:声明一个静态空数组或空集合常量或返回空数组空集合的静态方法,当需要时作为返回值。当然要保证该零长度元素不可变
44.为所有导出的API元素编写文档注释
javadoc工具会根据文档注释生成API文档,java中的注释就是html
注释编写注意如下几个方面。
- 方法第一句话应概括描述方法做了什么(注释不要描述怎么实现的)
- 描述方法的前提条件、后置条件、副作用
- 增加@throws、@param、@return标签(throws一般包括if表示什么情况下发生什么异常),这三个标签一般不以句号结尾
- 描述类是否是线程安全的
- 描述类是否是可序列化的
- 描述代码片段使用{@code xxxx}标签,转义则使用{@literal xxx}标签
- 注释中的this,一般指调用方法的对象
- 包级私有文档注释放在package-info.java中
- javadoc可以继承父类或接口的方法注释
通用程序设计
本章讨论java编码过程,应该遵守的具体实现细节
45.将局部变量的作用域最小化
将局部变量作用域最小化的目的是增强代码的可维护性、可读性,避免出错的可能性
局部变量作用域最小化的有力方式是:在第一次使用的地方声明。过早声明以为着过晚结束,如果在作用域之前或之后被意外使用,后果是灾难性的
在声明时,应该进行有意义的初始化,否则应该推迟声明直到可以初始化。(try-catch例外)
循环是变量作用域最小化的最佳示例。而for循环控制的作用域更小,书写更简短,所以for循环是要优于while循环
这里扩展:for循环中,可以在初始化位置用一个变量n来保存一个变量极限值,避免每次迭代执行冗余的计算开销。for(int i = 0, n = len(), i < n, i++) {...}
46.for-each循环优于传统的for循环
先说结论,for-each循环 > for循环 > while循环,后者for循环控制的作用域更小,书写更简短,所以for循环是要优于while循环。
但for循环使用迭代器(iterator.next())和索引变量(arr[i])会引起混乱和错误的可能(如指针越界,错误使用hasNext等);for-each完全隐藏了迭代器和索引变量,更简洁的同时避免了出错误的可能,而且它还稍有性能优势。
必须使用for循环的情况:需要对原数组、集合进行操作,如remove,get(i),替换
47.了解和使用类库
本节从自己实现一个随机器讲起,说明自己造轮子耗时、不完美等问题,而jdk标准库中已经有专家设计了正确的、通用的类,我们应该了解jdk的标准类库,并使用它们,这些类库是经过长时间使用,并且不断完善的,相比我们自己实现更节省时间更安全。
一个程序员应该了解lang、util、io、concurrent包下的类。总而言之,不要重复造轮子。
48.如果需要精确的答案,避免使用float和double
float和double是浮点数,就是0.1234 * 10^5
这个形式,它们通常应用在表示范围更大但不要精度的场景;不适合货币、金融计算等要求精度的场景。
错误示例:1.00 - 9 * .10
结果是0.099999999998
而不是0.1
需要精确计算,如果数值范围不超过9位十进制数字使用int;不超过18位使用long;超过18位使用BigDecimal。int和long性能更好,BigDecimal范围更大,功能更多
49.基本类型优先于装箱类型
每个基本类型都有与之对应的引用类型(装箱类型)。如int对应Integer,long对应Long。
基本类型与装箱类型的区别:基本类型只有值,装箱类型还有其他属性和方法;基本类型只有功能值,装箱类型有额外的null值;基本类型通常更节省空间和时间。
说白了装箱类型和引用类型的特性是一样的。所以在使用装箱类型时,`==``比较的不是值而是内存位置;会有NPE;混合使用会有拆箱装箱的性能问题等。所以可以选择的话,尽量使用更加简单高效的基本类型。装箱类型被用在泛型的参数类型、反射中。
50.如果其他类型更合适,尽量避免使用字符串
本节主要思想是:在使用数值时使用int或long等类型,使用逻辑运算判断使用bool,不要一味的使用字符串表示其他类型,如果使用不当,字符串更加笨拙、不灵活、速度慢且容易出错。
避免使用字符串代替基本类型、枚举类型、聚集类型
51.当心字符串连接的性能
字符串连接符加号+
使用方便快捷,适合使用与小场景,对于大规模使用场景中,每个加号都会重新创建对象,导致效率低下。代替方案:使用StringBuilder.append替代String
52.通过接口引用对象
本节主要思想是:优先使用接口作为对象声明的类型,而不使用具体的实现类作为声明类型。使用接口可以使代码更加灵活、耦合性更低,当你决定更换实现时,只需修改声明部分的具体实现类即可。
// 正确
List<String> obj = new Vector<String>()
// 错误
Vector<String> obj = new Vector<String>()
有几种情况使用类更适合:1、如果没有接口,也可以使用类来引用对象,大部分值类都是没有接口的:Integer、BigInteger、String。2、一个表示框架的基类(通常是抽象类),如TimerTask。3、实现类扩展了接口额外的方法,如LinkedHashMap。
53.接口优先于反射机制
本节主要强调的观点:反射机制是一项功能强大的机制,对于特定的复杂系统的编程任务,是非常必要的,但考虑他的缺点,如果必须要使用反射,应使用反射来实例化对象,而访问对象时使用编译时已知的某个接口或超类。
反射机制,是包reflect提供的,可以通过程序访问类的元信息,动态调用类的构造器、方法、域等。优点是动态加载类、动态调用方法、降低耦合性
使用反射的代价:1、丧失了编译时类型检查的好处。2、使用反射所需的代码笨拙、冗长、可读性差。3、性能损失,jvm无法优化
反射的应用场景:通用框架,如mybatis、spring、RPC框架、对象监视器、代码分析工具
54.谨慎地使用本地方法
jni允许java程序调用本地方法,本地方法的用途有: 1、访问特定于平台的机制如注册表、文件锁;2、访问遗留代码库、遗留数据;3、使用本地方法提高性能。1、2两种使用方式是合法的,但是3提高性能不值得提倡,因为jvm在更新迭代中其性能越来越好,不断取代了这些本地方法。
本地方法缺陷:1、本地语言不安全(数组越界、内存溢出、指针错误);2、难以移植;3、难以调试
总而言之,使用本地方法的用途如果是1、2条,则尽可能少用;用途是第3条,尽量不用。
55.谨慎地进行优化
本文体现一个深刻的道理:优化的弊大于利,特别是不成熟的优化;要努力设计合理的架构、编写好的程序,这样性能也随之而来。
在设计阶段,1、努力避免那些限制性能的设计决策(组件间交互、协议、数据格式);2、要考虑API设计决策的性能后果(返回值类型)
谨慎的设计了程序之后,对性能不满意而必须要进行优化,默念两边优化规则:“不要优化”,“还是不要优化”。
事实上因为不同jvm实现、不同jdk版本、不同操作系统、不同硬件,做性能优化各不相同,有的可能提升不明显,甚至更差,所以优化要谨慎。
在优化之前,使用性能剖析工具找出优化重点,分析问题根源,检查所选择的算法,再多的优化也无法弥补算法的选择不当,每次改变后都要测量性能,直到满意为止。
56.遵守普遍接受的命名惯例
字面上:
- 包:com.zhangchx.util,com.google.inject
- 类与接口:Timer、HttpUrl
- 方法与域:remove、ensureCapacity
- 常量:VALUES、MAX_CAPACITY
- 参数类型:T表示任意类型,E表示集合元素类型,KV表示键值类型,X表示异常
语法上:
- 包:无语法命名惯例
- 类与接口:名词或名词短语,接口额外可以使用-able、-ible结尾的形容词命名
- 方法与域:动词或动词短语,boolean型以is开头;Bean类使用get、set开头的方法操作属性;转换对象类型的方法以to开头,如toType、toString;返回视图以as开头,如asType;静态工厂多用valueOf、of、newInstance、getType;返回基本类型使用
类型+Value
,如intValue
使用并遵守命名惯例,统一代码规范,帮助我们理解他人编写的代码。
异常
本章讲解使用异常应该遵循的指导原则
57.只针对异常的情况才使用异常
本节举反例,使用捕获数组越界异常来代替数组遍历操作,表明一个观点:异常应该只用于异常的情况下,永远不应该用于正常的控制流。
这条规则也启发我们在设计API时,不应该强迫客户端为了正常的控制流而是用异常。如Iterator的next方法,提供了相应hasNext状态测试方法
,避免用户使用next时去捕获异常。有些情况也是用可识别返回值
来代替状态测试方法
58.对可恢复的情况使用受检异常,对编程错误使用运行时异常
抛出异常可分为:受检异常、运行时异常、错误
使用原则为:如果调用者可以恢复,应该使用受检异常,强迫调用者处理该异常;如果调用者不可恢复、继续执行下去有害无益应该抛出未受检异常(运行时异常、错误);如果不确定则使用未受检异常。
运行时异常表明编程错误,如数组越界。错误异常是JVM保留用于表示资源不足、约束失败等JVM级别的错误,用户不要实现Error的子类。
我们可以构造一个非Exception、RuntimeException、Error的子类,并把该类作为异常抛出,是合理的,他等同于受检异常,但不要这么做,因为这样做没有任何益处,只会困扰用户。
受检异常最好指明失败原因,帮助调用者了解恢复到正常状态。如提示欠费,用户就知道查询余额并充钱。
59.避免不必要地使用受检的异常
过度使用受检异常会给使用者增添不少编码负担,因为它强迫使用者去处理这些异常,所以尽可能避免抛出异常。
可以考虑将受检异常变成未受检异常;但本节中举例本人并没有理解明白。
60.优先使用标准的异常
本节提倡代码复用,包括异常。所以多了解并使用jdk已提供的异常类,更易学习使用,可读性更好,性能更好(时间开销少)
常用的异常如IllegalArgumentException
, IllegalStateException
, NullPointException
, IndexOutOfBoundsException
, ConcurrentModificationException
, UnsupportedOperationException
记住,使用哪个标准异常并不总是那么精确,比如对于IllegalArgumentException
, IllegalStateException
,使用哪个更好些并没有严格规则
61.抛出与抽象相对应的异常
如果底层抛出的异常(如NPE,IOOBE)被高层抛出,并且该异常与高层的执行的任务没有明显联系,会是人感到困惑,所以在抛出异常时,我们要考虑底层异常和当前业务是否有关。避免这个问题的方法是捕获底层异常,同时按照高层抽象业务进行异常转译重新抛出
try {
// lower level to do
} catch(LowerException e) {
//第一个参数表示异常转译,第二个参数可以实现异常链,会携带底层异常一起被抛出
throw new HigherException("higher business exception", e);
}
62.每个方法抛出的异常都要有文档
异常文档怎么写?在方法的javadoc使用@throws标记,记录抛出每个异常的条件,如果抛出多个异常,要分别描述,不要使用这些异常的父类。
可能抛出的未受检异常也建议在文档中写出来,让使用者知道方法执行的前提条件。
如果类中大部分方法都抛出同一个异常,该异常可以描述在类的文档中
63.在细节消息中包含能捕获失败的信息
当程序由于未被捕获的异常而失败的时候,系统会自动打印该异常的堆栈轨迹。
堆栈轨迹中,类名其后的细节信息(detail message)对排查问题非常重要,我们存放异常的关键信息,同时也避免细节信息过于冗长。
以IndexOutOfBoundsException
为例,细节信息需要包含上界、下界、当前异常下标。为了将产生的细节消息更规范,减少使用者产生细节消息的负担,将细节信息代码放在异常类更合适
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;
}
64.努力使失败保持原子性
我们期望当某个方法发生异常时,其调用对象的状态仍保持在被调用之前的状态,具有这种属性的方法称为具有失败原子性
实现失败原子性的方法有:1、设计对象是不可变的,方法就无法对对象的状态进行修改;2、在对象状态被修改之前,进行参数有效性检查,先抛出适当的异常;3、设计回滚方法,编写一段恢复代码;4、在对象进行一份临时性拷贝,在临时对象上进行操作,操作成功后替代原对象(如Collections.sort)
如果异常发生后,状态无法恢复,API文档中应该指明对象会处于什么状态。
错误通常不可恢复,也不需要努力保证失败原子性
65.不要忽略异常
不要通过try...catch...
捕获异常并包含一个空catch块,这会使异常达不到应有的目的,这如同把火警信号器关闭一样。应该在catch处理异常或进行异常转译后抛出,最差的情况,也至少应该包含一条说明,解释为什么可以忽略这个异常。
并发
并发程序设计比单线程程序设计更加困难,因为更容易出错,也很难复现。而使用并发能够从多核处理器中获得更好的性能,本章讨论编写并发程序的一些规范。
66.同步访问共享的可变数据
当多个线程共享可变数据的时候,每个读、写线程都必须执行同步,否则将会导致活性失败和安全性失败。最佳做法是尽少共享可变数据,将可变数据限制在单线程中
活性失败:程序无法继续执行;如由于可见性原因程序陷入死循环
安全性失败:计算出错误结果;如volatile修饰的count,在多线程中执行count++
同步是线程互斥的,可以保证一个线程修改可变对象时,阻止其他线程查看到对象内部不一致状态,按数据库隔离级别来说,就是不会出现脏读。
java语言中,读、写一个变量是原子性的,除了类型是long或double。但因为缓存的问题,java并不能保证一个线程写入的值对另一个线程可见,虽然读、写一个变量是原子性的,但却不是线程安全的。如果需要解决这个问题,可以使用synchronized,它达到了通信效果和互斥效果。如果只允许线程之间交互通信,而不需要互斥,可以使用volatile,保证可见性。记住volatile不能保证线程安全,如volatile修饰的count,在多线程中执行count++,++不是原子性的。
67.避免过度同步
同步会使线程等待、限制vm优化代码能力、保证一致内存视图(可见性)的延迟,所以过度同步可能会导致性能降低、死锁,甚至不确定的行为。
永远不要将用户实现代码引入到同步方法或代码块中。换句话说,一个同步区域,不要调用可被重写的方法,或者用户以函数对象形式提供的方法。外来的方法无法被控制,可能会导致异常、死锁、数据损坏。例如:同步区域中循环list集合,在循环里调用外来方法,而外来方法对集合list做增加或删除操作,会引起ConcurrentModificationException。
public void addObserver(Object obj, E element) {...}
public void synchronized add(E element) {
this.addObserver(this, element); //调用可被重写的方法,属于外来方法
}
public void synchronized notify(E element) {
element.add(this, "msg"); //调用element的add方法,属于外来方法
}
解决这个问题,可以将外来方法移除同步代码块;通过1、快照被保护的对象;2、缩小同步代码范围,使外来方法不再同步范围中;
创建自己的线程安全类,确保它将使用在多线程中,如果不确定,就不要同步你的类,而是建立文档,说明它是非线程安全的。但有一点特殊,如果用户使用这个类,不能在外部实现这个类线程安全,则还是要设计者实现同步,比如:成员方法修改静态域的这种类。
68.executor和task优先于线程
使用Executor框架提供的线程池代替直接使用线程、Timer。任务(task)指的是Runnable、Callable;结合Executor框架去使用任务,而不是使用Thread类。
Executor有execute(执行任务)、submit(执行任务有返回值)、invokeAny/All(批量执行并等待)、awaitTermination(优雅停止)等方法
测试或小负载系统建议使用single、cached线程池;大负载系统建议使用fixed线程池
69.并发工具优先于wait和notify
正确的使用wait、notify比较困难,就使用汇编语言编程一样;而concurrent包提供更高级的操作,没理由在新代码中使用wait、notify。
如果维护旧代码,切忌wait在while循环中调用,大部分情况优先使用notifyAll,而不使用notify。(从优化角度讲,如果等待所有线程都在等待同一个条件,而每次只有一个线程从这个条件唤醒,应该选用notify)
//wait标准使用模式
synchronized(obj) {
while(<condition does not hold>)
obj.wait(); //释放锁,唤醒时再次获取
... //继续操作
}
concurrent提供的更高级的操作在本节介绍有:
- ConcurrentHashMap.putIfAbsent实现String.intern
- BlockingQueue可用于生产消费模式
- CountDownLatch实现并发计时器,并提到间歇式定时应使用
System.nanoTime
PS:这里扩展String.intern方法的实现
70.线程安全性的文档化
一个类为了可被多个线程安全地使用,必须在文档中清除地说明它所支持的线程安全性级别:
- 不可变(immutable):类实例不变的,不需要外部同步。如String、Long、BigInteger
- 无条件的线程安全(unconditionally thread-safe):类实例可变,但有足够的内部同步,可以被并发使用,无需任何外部同步。如ConcurrentHashMap、Random
- 有条件的线程安全(conditionally thread-safe):与无条件线程安全基本相同,但需要外部同步。如Collections.synchronized包装返回的集合的迭代器,需要外部同步
- 非线程安全(not thread-safe):类实例可变,客户必须自己利用外部同步实现线程安全。如HashMap、ArrayList
- 线程对立(thread-hostile):这个类即时所有方法都被外部同步包围,也不能安全地被多线程使用。线程对立的根源在于没有同步地修改静态数据。好在这种类非常少。如System.runFinalizersOnExit
文档描述除了描述线程安全级别,还应包括哪个方法需要外部同步,获取哪一把锁(一般是this)。
避免使用一个公有可访问的锁对象,因为会造成拒绝访问攻击,应该是使用私有锁对象(特别是为了继承而设计的类)。
71.慎用延迟初始化
虽然延迟初始化是一种优化,而且还能够解决初始化循环的问题,但对于所有优化一样,建议除非绝对必要,否则不要这么做,它虽然降低了初始化的开销,却增加了访问初始化域的开销。值不值得使用延迟初始化,唯一的办法就是测量类在用或不用的性能差别。
以下皆为线程安全的初始化方式:
实例域使用双重检查模式;静态域使用内部私有静态类;可接受重复初始化的实例域考虑单重检查模式
//正常初始化
private final Task ins = new Task()
//一般延迟初始化
private Task ins;
synchronized Task getIns() {
if (ins == null)
ins = new Task();
return ins
}
//考虑性能,对静态域使用延迟初始化
private static class TaskHolder {
static final Task ins = new Task();
}
static Task getIns() { return TaskHolder.ins; }
//考虑性能,对实例域使用延迟初始化,双重检查
private volatile Task ins; //声明为volatile
Task getIns() {
Task t = ins; //t这个变量确保ins只在初始化时被读取一次,提升性能
if (t == null) {
synchronized(this) {
t = ins;
if (t == null) {
t = ins = new Task();
}
}
}
return t;
}
72.不要依赖于线程调度器
不要依赖于操作系统、java提供的一些线程调度功能来编写程序,因为可能无法移植和不符合预期。
本节强调以下几点:
- 运行线程平均数量不明显多于CPU数量
- 线程不要做无意义的工作。如空死循环
- 线程执行的任务不要过于复杂,尽量轻小
- 不要使用Thread.yield。yield唯一的用途是测试期间模拟并发性,但Thread.sleep(1)做的更好
- 不要使用线程优先级。优先级使java不具有移植性
73.避免使用线程组
没有用过线程组,但存在java.lang.ThreadGroup
类
线程组并没有提供太多有用的功能,而且现有提供的功能也都有缺陷,当它们不存在即可。如果敢需要一个类处理线程的逻辑组,应该是用线程池executor
序列化
序列化是将对象编码成一个字节流,相反的过程称为反序列化。对象被序列化后,可以在jvm之间进行传输,持久化到磁盘。
序列化为RPC提供了标准的线路级对象表示法
74.谨慎地实现Serializable接口
实现Serializable很简单,但付出的代价是:
- 最大的代价是一旦一个实现Serializable接口的类被发布,就大大降低钢鞭这个类实现的灵活性,也就是要永远支持这种序列化形式。而且如果接受了默认的序列化形式,这个类的私有实例域将变成导出API的一部分,不符合最低限度地访问域这条原则。
- 第二个代价是增加了出现bug和安全漏洞的可能性:序列化机制是语言之外的对象创建机制,反序列化过程必须保证对象状态都有约束关系,但这种约束关系很容易遭到破坏,以及非法访问。
- 第三个代价是随着发行新版本,测试负担增加:每次发布需要测试“新版本的实例能否在旧版本反序列化”,还要确保产生的对象是否是原始对象的复制品。
实现Serializable的场景:如果一个类加入到某个框架中,而该框架依赖于序列化来实现对象传输或者持久化。比如Date、BigInteger应该实现Serializable,大多数集合类也应该如此。
为继承而设计的类、或者接口尽可能少的实现Serializable,否则继承或实现的子类就需要考虑上述代价。当然,如果类或接口主要目的是参加到某框架中,实现Serializable也是有意义的,如Number、Throwable、Component、HttpServlet。
如果类没有实现Serializable接口,则子类也无法实现。解决该问题,需要超类提供一个无参构造器即可,但要考虑无参构造器能够约束好内部状态。这里有一个最佳实践:在超类中增加一个init初始化方法和state实例,同时超类的业务方法中都调用一个CheckXXX,检查state是否是初始化过,没有则快速失败;有了这样的机制做保证,无论是超类还是子类,都不得不调用init方法做初始化。
内部类不应该实现Serializable;静态成员类可以实现Serializable
当反序列化时,如果实例域值为默认值(0、null、false)是违背了约束条件的话,就应该给这个类增加readObjectNoData
方法。
75.考虑使用自定义的序列化形式
如果你决定将一个类实现Serializable,只有当默认序列化形式能够合理描述对象逻辑状态时才使用,否则建议设计一个自定义的序列化形式,来确定哪些实例域需要导出,因为序列化形式是一个承诺,选择错误的序列化形式对一个类的复杂性和性能都会有永久的负面影响。
如果一个类的物理表示法
和逻辑数据内容
不同时,使用默认序列化形式(就是物理表示法导出)会有以下缺点
- 这个类的导出API永远师傅在该类的内部表示法上。比如内部实例由数组变为链表,序列化也要维护数组这种形式
- 消耗过多空间。有些实例域可以通过基础实例域计算出来,根本不需要导出所有实例域
- 消耗过多时间。如果实例域导出链表等需要遍历的数据结构,则很耗时。
- 容易引起栈溢出。
此节介绍了StringList和散列表使用默认序列化形式所引起的问题,并通过自定义序列化形式进行修复。
使用自定义序列化就是在类中增加readObject
和writeObject
方法,方法内部建议首先调用s.defaultWriteObject
和s.defaultReadObject
保证前后版本的兼容性。同时对不需要导出的实例域增加transient
,但记住这样修饰的话,反序列化该值为默认值。
如果某类的是线程安全的且读取对象状态的方法都是同步的,readObject
和writeObject
也要是同步的。
提供自定义的UID,来告知序列化是否前后兼容。
对于序列化相关的方法和实例域建议增加上@serial
文档注释。
76.保护性地编写readObject方法
考虑readObject反序列化时,读取的字节流可能是伪造的情况,必须编写出健壮的readObject方法。
readObject近似认为是以字节流作为参数的公有构造器,既然是构造器必须要考虑:1、参数的有效性,保证约束条件;2、对参数进行保护性拷贝,保证类的可变引用实例域不会被外部更改。
readObject不要调用可被覆盖的方法,被覆盖的方法可能在子类状态被初始化之前运行,程序可能会失败。
本节使用39节中Periad的示例,表现了readObject没有考虑参数有效性、保护性拷贝所引起的约束破坏问题。这里给出readObject编写的最佳实践:
- 类中实例域为私有,必须保护性拷贝域里的每个对象
- 在保护性拷贝之后,检查约束条件,失败则抛出异常
InvalidObjectException
- 如果对象在反序列化之后必须验证,应该使用
ObjectInputValidation
接口 - 不要调用可被覆盖的方法
不要使用writeUnshared
、readUnshared
方法。
77.对于实例控制,枚举类型优先于readResolve
本节举例单例模式,引出每次反序列化之后的对象是该类的一个新实例,也就是不再具有单例属性。解决这个问题可以使用readResolve
方法,它允许该方法返回的实例可以替换掉readObject
创建的实例,被替换的掉实例将没有引用,被GC掉。
使用readResolve
方法对实例进行控制,需要对引用类型的实例域都加上transient
,否则还是可以被攻击。(本节的攻击方式并没有看懂)
自1.5开始,readResolve
不再是实例控制的最佳方法了,最佳方法可以将序列化类编写成枚举。但如果枚举表示不了,则还是要考虑安全的readResolve
。
readResolve
在final类中应该使私有的;在非final就应该认真考虑是否继承,从而确定可访问性。
78.考虑用序列化代理代替序列化实例
序列化代理模式:
- 设计一个私有的、静态嵌套类为序列化代理类,该类提供一个带参构造器,参数类型是外围类,构造器从外围类复制数据,不需要进行一致性检查和保护性拷贝。外围类和序列化代理类都必须实现Serializable。
- 外围类增加
writeReplace
方法,返回代理类;增加readObject
方法防止被攻击 - 代理增加
readResolve
返回外围类实例
正如保护性拷贝一样,序列化代理可以阻止伪字节流的攻击及内部域的盗用攻击。与使用保护性拷贝不同,使用序列化代理允许Period的域为final的,这可以保证Period类真正不可变。序列化代理模式更容易实现,它不必考虑哪些域会被序列化攻击,也不必显示的执行有效性检查。
序列化代理的局限性:不能与可以被客户端扩展的类兼容,也不能与对象图中包含循环的类兼容,比保护性拷贝性能低。