《Effective Java》读书笔记
第1条:考虑用静态工厂方法代替构造器
这不同于设计模式中的工厂方法,我们可以理解它为“在一个类中用一个静态方法来返回这个类的实例”。
- 静态工厂方法与构造器相比第一大优势在于:它们有名称
- 静态工厂方法与构造器相比第二大优势在于:不必在每次调用它们的时候都调用一个新对象
- 静态工厂方法与构造器相比第三大优势在于:它们可以返回原返回类型的任何子类型对象
- 静态工厂方法与构造器相比第四大优势在于:在创建参数化类型实例的时候,它们使代码变得更加简洁
- 静态工厂方法的主要缺点在于:类如果不包含公有的或者受保护的构造器,就不能被子类化
- 静态工厂方法的的第二个缺点:它们与其他的静态方法实际没有任何区别
第2条:遇到多个构造器参数时要考虑用构建器
- 重叠构造器,难以编写,难以阅读
- JavaBeans模式,实例构建过程不是一气呵成,构造过程中JavaBean可能出现不一致装态
- Build模式,推荐
第3条:用私有构造器或者枚举类型强化Singleton属性
实现singleton有以下几种方法
- 第一种
public class Instance {
public static final Instance obj = new Instance();
private Instance() {}
}
- 第二种
public class Instance {
private static final Instance obj = new Instance();
private Instance() {
}
public static Instance getInstance() {
return obj;
}
}
要使用上面两种方法实现的singleton类是可序列化的,仅仅implements Serializable是不够的,当反序列化时生成的对象并不是singleton。要保证单例还必须在单例类中实现readResolve的方法:
public class Instance {
private static final Instance obj = new Instance();
private Instance() {
}
public static Instance getInstance() {
return obj;
}
private Object readResolve(){
return obj;
}
}
- 第三种,能保证反序列化后仍然是singleton,虽然这种方法还没有被推广,却是实现singleton的最佳方法
public enum Instance {
INSTANCE
}
第4条:通过私有构造器强化不可实例的能力
有的工具类例如Arrays等,对它们进行实例化并没有意义,所以应该在它们的构造方法上应该使用private修饰。但是有个缺点,不能被子类化
所有的构造器都必须显示或者隐式地调用超类,在这种情形下,子类就没有可访问的超类构造器可调用
第5条:避免创建不必要的对象
当你应该重用现有对象的时候,请不要创建新的对象
第6条:消除过期的对象引用
之所以要消除过期的对象引用其目的就在于尽量避免内存泄露的问题
第7条:避免使用终结方法
略
第8条:覆盖equals时请遵守通用约定
equals方法来自于Object类,默认比较引用是否相等。若想覆盖equals请遵循通用约定:自反性、对称性、传递性、一致性。
String中equals方法的实现实际上就是给我们重写equals的一些诀窍:
- 使用==操作检查“对象是否为这个对象的引用”,这不是必须的,只是作为一种性能优化,例如Integer类中并无此项判断。
- 使用instanceof操作符检查“参数是否为正确的类型”。
- 把参数转换成正确的类型。
- 对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域想匹配。
第9条:覆盖equals时总要覆盖hashCode
略
第10条:始终要覆盖toString
原因在于在有的场景下会打印一条日志,日志的内容就是POJO类的属性字段值,这个时候toString的意义很明显的就体现出来了。有的打印信息太长就有点不切实际了,可以打印摘要信息。
第11条:谨慎地覆盖clone
略
第12条:考虑实现Comparable接口
略
第13条:使类和成员的可访问性最小化
如何正确地使用访问权限?首先在定义一个成员变量时类型前会有一个修饰符public 、protected、private(或者没有)。在未学习到“面向对象”时,初学时老师为了讲解方便直接将变量定义为了public,慢慢接触到了面向对象的三大特性:继承、封装、多态,我们也学会了将成员变量的访问权限定义为private。
此条实际上就是讲解面向对象的三大特性之一——封装。
private——只有在生命该成员的类才能访问,其他类都不能访问。
default(默认访问修饰符)——又称为“包级私有”,也就是说只有在同一个包下的类才能访问,就算是它的子类但不是在同一包下也不能访问。
protected——有两种情况可以访问:1、和default一样同一个包下的类能够访问。2、它的子类也能访问。
public——任何类都能访问。
为什么书中提到提到要将可访问性最小化呢?实际上原因在于可维护,一旦你将可访问性置为protected或者public,意味着很大范围的类都能对它就行访问、修改、引用等等,如果你修改了这个变量意味着你要同时修改很多其他的类。但如果可访问性很小private的话,意味着你修改了这个变量,其他类并不知道啊,只对你自己有关系改好你自身就可以了不用担心还有其他哪个地方有用到。
对于成员变量通常使用private类,如果完全不提供访问的渠道或者途径似乎也是“死”的,这是setter/getter方法,有人在初学时可能会发出疑问,既然提供了能访问修改这个成员变量的方法,那何不直接置为public呢?原因在于方法是可以提供检查的,它能检查传入的值是否符合规范。
另外还有一条规则限制了降低方法的可访问性的能力,那就是如果方法覆盖了超类中的一个方法,子类中的访问级别就不允许低于超类中的访问级别。就是说如果父类的方法是public,那么子类就不能是private;如果父类的方法是protected,那么子类就只能是protected或者public。
第14条:在公有类中使用访问方法而非公有域
这一条实际上我们在上一条已经说到对于类的成员变量,我们通常对它的访问控制置为private,并为它提供getter/setter方法。
第15条:使可变性最小化
注意第13条指的是“可访问性”主要讲访问修饰符,而这一条指的是“可变性”讲的是final关键字。
把它和static关键字配合使用使得程序中不会出现硬编码,而是以常量的形式出现,这样也便于修改和阅读,但是final的功效远远不止于此。
第16条:复合优先于继承
继承是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处于同一个程序员的控制下。对于专门为了继承而设计的并且具有很好的文档说明的类来说,使用继承也是非常安全的。然而,对于普通的具体类进行跨超包边界的继承则是非常危险的。本条目并不适用于接口继承(一个类实现一个接口,或者一个接口扩展另一个接口)。
- 与方法调用不同的是,继承打破了封装性。子类信赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有变化,子类有可能会被破坏。
- 超类在以后的发行版本中增加了新方法。假设一个程序的安全性信赖于这样的事实:所有被插入到某个集合的元素都满足某个先决条件。下面的做法就可以确保这一点:对集合进行子类化,并覆盖所有能够添加元素的方法以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,那么这种方法可以正常工作。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被子类覆盖的新方法,而将不合法的元素添加到子类的实例中。
- 如果在扩展一个类的时候仅仅是增加新的方法而不覆盖现有的方法,这也许看来相对安全一些,但是设想一下,如果超类在后续的发行版本中获得了一个新方法,并且和子类中的某一方法只是返回类型不同,那么这样的子类将针法通过编译。如果给子类提供的方法带有与新的超类方法完全相同的方法(签名和返回类型都相同),这又变成了子类覆盖超类的方法问题。此外,子类的方法是否则够遵守新的超类的方法的约定也是个值得怀疑的问题,因为当编写子类方法的时候,这个约定根本还没有面世。
第17条:要么为继承而设计,并提供文档说明,要么就禁止继承
不要过度设计。
面向对象编程,从一开始被洗脑难免在上手写代码时都会首先思考有没有公共方法啊,能不能把两个类抽象成一个父类再继承啊等,慎重使用继承,当要使用继承时一定要在文档注释中写明重写这个方法会给其他方法带来什么影响。书中给出建议如果类并不是为了继承而生,那么这个类应该用final修饰禁止子类化。
第18条:接口优于抽象类
接口和抽象类的异同这是Java基础中的基础。我们不妨来回顾下Java和抽象类的区别:
接口不能被实例化不能有构造器,抽象类也不能被实例化但可以有构造器;
接口中不能有实现方法(JDK8在接口中可以有实现方法,称“默认方法”),抽象类中可以有实现方法,也可以没有;
接口方法的默认修饰符就是public,不可以用其他修饰符,抽象类可以有public、protected、default。
回到“接口优于抽象类”的问题上来,原因就是Java只支持单继承,但可以实现多个接口。有抽象类的地方基本上都可以看到其中的方法很多是模板方法。
第19条:接口只用于定义类型
这个条目中建议接口不要只用于定义常量使之成为常量接口,如果一个类只有常量应该使用枚举类型或者不可实例化的工具类。JDK中的反例就是java.io.ObjectStreamConstant。
第20条:类层次优于标签类
标签类是指在类中定义了一个变量,使用该变量的值来控制该做什么动作。
书中举例:定义一个Figure类,使用Shapre变量,可以传入“长方形”或者“圆形”,根据传入的类型不同调用共同的方法。这个就是一个标签类,如果新增一个“三角形”的话,就得修改这个标签类的代码。
更好的方法就是利用继承,合理利用继承能更好的体现面向对象的多态性。
第21条:用函数对象表示策略
略
第22条:优先考虑静态成员类
略
第23条:请不要新代码中使用原生态类型
简而言之,使用泛型相对“安全”,从一开始就能限定数据类型,防止之后不小心插入了错误的类型,而对于原生态类型则不会检查插入的类型,有可能在以后插入了其他类型而只有在运行时才抛出异常,所以鼓励使用泛型。
第24条:消除非受检警告
我们应该在代码中尽量消除警告,如果无法消除警告,同时可以证明引起警告的代码是类型安全的,可以使用@SuppreWarnings(“unchecked”)注解,并在注释中加以解释。
第25条:列表优先于数组
首先数组是协变的,这里的“变”指的是数据类型,而不是说数组的长度,数组的长度当然从一开始就确定不可改变,但对于以下代码确实合法的:
public class Main {
public static void main(String[] args) throws Exception{
Object[] objects = new Long[1];
objects[0] = "hello world";
System.out.println(objects[0]);
}
}
第26条:优先考虑泛型
引用书中的话“一般来说,将集合声明参数化,以及使用JDK所提供的泛型和泛型方法,这些都不太困难。编写自己的泛型会比较困难一些,但是值得花些时间去学习如何编写”。举一个简单例子来帮助我们如何正确编写泛型:
public class Test<E> {
private E[] elements;
public Test() {
//elements = new E[16]; //编译时出错,不能创建不可具体化的类型的数组
elements = (E[]) new Object[16];
}
}
public class Test<E> {
private Object[] elements;
public Test() {
//elements = new E[16]; //编译时出错,不能创建不可具体化的类型的数组
elements = new Object[16];
}
public E test() {
return (E) elements[0];
}
}
当在编写不可避免要使用数组时,可参考以上两种数组和泛型的实现方式。
第27条:优先考虑泛型方法
泛型方法即在定义方法的返回值前加上<E>,例如Collections.sort方法,至于优点不再多说,一句话能用泛型尽量用泛型。
public static <T> void sort(List<T> list, Comparator<? super T> c)
这个方法的第二个参数实际上是下条要提到的有限制通配符。
第28条:利用有限制通配符来提升API的灵活性
之前我们提到了<?>形式的无限制通配符,这里则是有限制通配符。上一条目中已经出现了有限制通配符,它一共有这么2种:
- <? extends E>:表示可接受E类型的子类型;
- <? super E>:表示可接受E类型的父类型。
第29条:优先考虑类型安全的异构容器
书中所举的例子我认为非常有参考价值,仔细品味。
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();
}
favorites.put(type, instance);
}
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
}
public class Main {
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);
Integer favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);
System.out.printf("%s %x %s", favoriteString, favoriteInteger, favoriteClass.getName());
}
}
Favorite类使用起来有点Map的感觉,putFavorite方法就类似Map.put,或者说用Map不就能实现吗?例如:
public class Main {
public static void main(String[] args) {
Map<Class<?>, Object> map = new HashMap<Class<?>, Object >();
map.put(String.class, "Java");
map.put(Integer.class, 122);
System.out.println(map.get(String.class));
System.out.println(map.get(Integer.class));
}
}
能运行和上面结果一致,但问题就在于以下代码:
public class Main {
public static void main(String[] args) {
Map<Class<?>, Object> map = new HashMap<Class<?>, Object >();
map.put(String.class, "Java");
map.put(Integer.class, 122);
Object str = map.get(String.class); //Integer str = (Integer) map.get(String.class);
Object in = map.get(Integer.class);
}
}
根据键取出来的值是Object,也就是说这是很危险的一件事情,如果代码写成上面注释那样的话在编译时是无法判断的,只有在运行时才会抛出异常。记住,能在编译时检查就在编译时检查而不要等到真正运行起来才做检查,这也就是上面Favorite所带来的好处,它是类型安全的,同时它也是异构的,这个例子值得细细品味。
第30条~第37条
略
第38条:检查参数的有效性
对于这一条,最常见的莫过于检查参数是否为null。
有时出现调用方未检查传入的参数是否为空,同时被调用方也没有检查参数是否为空,结果这就导致两边都没检查以至于出现null的值程序出错,通常情况下会规定调用方或者被调用方来检查参数的合法性,或者干脆规定都必须检查。null值的检查相当有必要,很多情况下没有检查值是否为空,结果导致抛出NullPointerException异常。
第39条:必要时进行保护性拷贝
略
第40条:谨慎设计方法签名
方法签名不仅仅是指方法命名,还包括方法所包含的参数。方法命名要遵循一定的规则和规律,可参考JDK的命名;方法所包含的参数最好不应超过4个,如果超过4个则应考虑建造者模式构造实例。
第41条:慎用重载
- 依赖静态类型来定位方法执行版本的分派动作称为静态分派
- 在运行时根据实际类型确定方法执行版本的分派过程称为动态分配
重写是在编译期已经确定了定位的执行版本,所以容易造成错误的结果。比较好的解决办法学习ObjectOutputStream类中做法:writeBoolean(boolean),writeInt(int),writeLong(long)。如果两个重载方法的参数类型很相似,那一定得考虑这样做是否容易造成“程序的误解”。
第42条:慎用可变参数
- 有可能在没有传入参数的时候程序没有做任何保护而导致程序错误
- 会带来一定的性能问题,因为可变参数的每次调用都会导致进行一次数组分配和初始化。
总之,“在定义参数数目不定的方法时,可变参数是一种很方便的方式,但是它们不应该被过度滥用。如果使用不当,会产生混乱的结果”
第43条:返回零长度的数组或者集合,而不是null
使用和避免null:null是模棱两可的,会引起令人困惑的错误,有些时候它让人很不舒服。
例如对于一个Map,调用其get(key)方法,此时若返回null,可能表示这个key所对应的值本身就是null,或者表示这个Map中没有这个key值。这是两种截然不同的语义。
书中仅是说明对于零长度的数组或者集合不应该返回null,实际上对于所有的情况,都不要轻易返回null,特别是在语义不清的情况,更别说返回null时有的客户端程序并没有处理null的这种情况。
如果一定要用到null,更好的办法是单独维护它。
第44条:为所有导出的API元素编写文档注释
起码的参数、返回值、用途的注释一定要写。
第45条:将局部变量的作用域最小化
简单一句话,用到的时候在定义,不要提前定义,当然在某种特殊情况下例外。
第46条:for-each循环优先于传统的for循环
第47条:了解和使用类库
在进行工程项目类的开发时,不应重复造轮子,而且用类库使得项目更稳定。书中建议每个程序员都应该熟悉java.lang、java.util。
第48条:如果需要精确的答案,请避免使用float和double
float和double表示浮点类型数据,对于要精确到小数点的数值运算,通常下意识的会选择float或者double类型,但实际上这两种类型对于精确的计算是存在一定隐患的。
对于精确的数值计算,首推BigDecimal,或者可以使用int、long型将单位缩小不再有小数点。书中举了详细例子来说明。
第49条:基本类型优先于装箱基本类型
第50条:如果其他类型更合适,则尽量避免使用字符串
第51条:当心字符串连接的性能
我们都知道String字符串是不可变的,每次对一个字符串变量的赋值实际上都在内存中开辟了新的空间。如果要经常对字符串做修改应该使用StringBuilder(线程不安全)或者StringgBuffer(线程安全),其中StringBuilder由于不考虑线程安全,它的速度更快。
第52条:通过接口引用对象
考虑程序代码的灵活性应该优先使用接口而不是类来引用对象,例如:
List<String> list = new ArrayList<String>();
这样带来的好处就是可以更换list的具体实现只需一行代码,之前有谈到将接口作为参数的类型,这两者配合使用就能最大限度实现程序的灵活性。
但如果是类实现了接口,但是它提供了接口中不存在的额外方法,且程序依赖这些额外方法,这个时候用接口来代替类引用对象就不合适了。
第53条:接口优先于反射机制
其实在有关接口的建议中所推崇的都是面向接口编程,此条也不例外。
首先是反射的使用一定要慎重,它能在运行时访问对象,但它也有以下负面影响:
* 丧失了编译时类型检查
* 执行反射访问所需要的代码非常笨拙和冗长(这需要一定的编码能力)
* 性能损失
在使用反射时利用接口指的是,在编译时无法获取相关的类,但在编译时有合适的接口就可以引用这个类,当在运行时以反射方式创建实例后,就可以通过接口以正常的方式访问这些实例。这可以联想服务提供者模式。
第54条:谨慎地使用本地方法
第55条:谨慎地进行优化
我在实际编码过程中,常常听到别人说,这么实现性能可能会好一点,少了个什么什么性能会好一点,甚至是少了个局部变量也会提到这么性能要好一点,能提高一点是一点。
书中引用了三句话提出了截然不同的观点:
很多计算上的过失都被归咎于效率(没有必要达到的效率),而不是任何其他的原因——甚至包括盲目地做傻事。
——William A.Wulf
不要去计较效率上的一些小小的得失,在97%的情况下,不成熟的优化才是一切问题的根源。
——Donald E.Knuth
在优化方面,我们应该遵守两条规则:
规则1:不要进行优化
规则2:(仅针对专家):还是不要进行优化——也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。
——M.A.Jackson
上面几句话看似让你不要做优化,这当然不可能。实际上是在编码中如果你没有考虑清楚就冒然想当然的去做优化,常常可能是得不偿失,就像我开头提到的那样,甚至为了优化性能而去减少一个局部变量。
正确的做法应该是,写出结构优美、设计良好的代码,不是写出快的程序。
性能的问题应该有数据做支撑,也就是有性能测试软件对程序测试来评判出性能问题出现在哪个地方,从而做针对性的修改。
第56条:遵守普遍接受的命名惯例
阿里巴巴针对Java已经出了一份《阿里巴巴Java开发手册》,这本手册就是很好的参考。就说书里没有的一点,不要使用拼音!
第57条:只针对异常的情况才使用异常
条建议在书中所给出的例子我相信实际上也没有几个会写出来,异常就是超出意料之外的错误,也就是说正常的控制流逻辑是不会走到异常的,所以不要再正常的控制流中出现异常来对程序做正常逻辑处理。
第58条:对可恢复的情况使用受检异常,对程序错误使用运行时异常
什么时候使用受检查的异常(throws Exception),什么时候使用不受检查的异常(throws RuntimeException),本书中给出原则是:如果期望调用者能够适当地恢复,对于这种情况就应该使用受检的异常。对于程序错误,则使用运行时异常。例如在数据库的事务上通常就会对异常做处理,防止出现数据不一致等情况的发生。
第59条:避免不必要地使用受检的异常
关于这条建议书中实际上是建议如何更好的“设计”异常处理。
对于异常本身初衷是使程序具有更高的可靠性,特别是受检查的异常。但滥用受检查的异常可能就会使得程序变得负责,给程序员带来负担。
两种情况同时成立的情况下就可以使用受检查的异常:1、正确地使用API并不能阻止这种异常条件的产生;2、如果一旦产生异常,使用API的程序员可以立即采取有用的动作。以上两种情况成立时,就可以使用受检查的异常,否则可能就是徒增烦恼。
另外在一个方法抛出受检查异常时,也需要仔细考量,因为对于调用者来讲就必须处理做相应处理,或捕获或继续向上抛出。如果是一个方法只抛出一个异常那么实际上可以将抛出异常的方法重构为boolean返回值来代替。
第60条:优先使用标准的异常
对异常的时候都知道可以自定义异常,但往往在尚不标准的开发工程中都是不管三七二十一统统捕获或者抛出Exception异常。实际上更好的是使用内置的标准的异常而不是这么笼统。例如参数值不合法就抛出llegalArgumentException等。
借用书中的话:专家级程序员与缺乏经验的程序员一个最主要的区别在于,专家追求并且通常也能够实现高度的代码重用。
第61条:抛出与抽象相对应的异常
看题目会觉得不知所云,这实际上讲的是“异常转译”,所谓异常转译简单来说指的就是将一个异常转换成另外一个异常,例如AbstractSequentialList#get方法。
首先查看对于列表List类 get方法的规范要求:
//List
/**
* Returns the element at the specified position in this list.
*
* @param index index of the element to return
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException if the index is out of range
* (<tt>index < 0 || index >= size()</tt>)
*/
E get(int index);
//AbstractSequentialList#get
/**
* Returns the element at the specified position in this list.
*
* <p>This implementation first gets a list iterator pointing to the
* indexed element (with <tt>listIterator(index)</tt>). Then, it gets
* the element using <tt>ListIterator.next</tt> and returns it.
*
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E get(int index) {
try {
return listIterator(index).next();
} catch (NoSuchElementException exc) {
throw new IndexOutOfBoundsException("Index: "+index); //异常转译,底层抛出NoSuchElementException,转译为IndexOutOfBoundsException。
}
}
可以看到AbstractSequentialList#get方法的异常抛出就做了异常转译,这么做的原因有几点:符合List对get方法的规范;便于理解。
另外还有一点就是“异常链”:底层的异常被传到高层的异常,高层的异常提供访问方法来获得底层的异常。这样做的好处在于高层能查看低层异常的原因,其坏处就是逐层上抛会消耗大量资源。
总之,就是当低层抛出异常时,此时要考虑是否做异常转译,使得上层方法的调用者易于理解。
第62条:每个方法抛出的异常都要有文档
这里书中主要提到要为抛出的异常建立Java的文档注释即Javadoc的@throws标签。
对于一些未受检的异常同样也应该在文档注释中说明,例如上面提到的List#get。
第63条:在细节信息中包含能捕获失败的信息
在生产环境中当然不能这么写而需要打印到日志中,除了堆栈信息外还需要一个可跟踪的信息,这通常是一个用户的ID,总之就是需要可定位、可分析。
第64条:努力使失败保持原子性
失败的方法调用应该使对象保持在被调用之前的状态,具有这种属性的方法被称为具有失败原子性。
失败过后,我们不希望这个对象不可用。在数据库事务操作中抛出异常时通常都会在异常做回滚或者恢复处理,要实现对象在抛出异常过后照样能处在一种定义良好的可用状态之中,有以下两个办法:
1) 设计一个不可变的对象。不可变的对象被创建之后它就处于一致的状态之中,以后也不会发生变化。
2) 在执行操作之前检查参数的有效性。例如对栈进行出栈操作时提前检查栈中的是否还有元素。
3) 在失败过后编写一段恢复代码,使对象回滚到操作开始前的状态。
在对象的一份临时拷贝上执行操作,操作完成过后再用临时拷贝中的结果代替对象的内容,如果操作失败也并不影响原来的对象。
第65条:不要忽略异常
try {
doSomething();
} catch (Exception e) {
}
吃掉了异常,出问题时问题就暴露不出来
第66条~第73条
略
第74条:谨慎地实现Serializable接口
略
第75条:考虑使用自定义的序列化形式
略
第76条:保护性地编写readObject方法
略
第77条:对于实例控制,枚举类型优先于readResolve
略
第78条:考虑用序列化代理代替序列化实例
略