《Effective Java》读书笔记
Posted 扬州慢_
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《Effective Java》读书笔记相关的知识,希望对你有一定的参考价值。
创建和销毁对象
静态工厂模式
- 构造器里未传参的成员不会被初始化。int类型是0,布尔类型是false,String类型是null,List<>也是null。
重叠构造器
- 进阶1:javabean模式,使用set方法来初始化成员,缺点是构造过程中javabean可能处于不一致状态(可以理解成该模式下成员的设置的分步进行的,可能某处使用到该类的某个成员时其还未被初始化),并且该模式阻止了把类变成不可能的可能,需要考虑线程安全。
- 进阶2: Builder模式:类里定义一个静态类builder(其实就是javabean),对builder初始化完成后使用build()返回该类,Buidler模式的状态不一致是builder,而不是类本身,并且类自身的成员也可设置成final。
修饰符
- 长度非零的数组总是可变的,即使是final类型:
public static final int[] VALUES=... //错误
//正确1:增加一个公有的不可变列表
private static final int[] VALUES=...
public static final List< intergeR > VALUES=
Collections.unmodifiableList(Arrays.adList(PRIVATE_VALUES));
//正确2:返回私有数组的拷贝
private static final int[] VALUES=...
public static final int[] values()
return VALUES.clone();
- 如果是公有类,直接暴露数据会有很大的隐患,因为当你将来想改变其内部表示法时已经不可能了,因为共有类的客户端代码已经遍布各处了。
public class Point //错误
public int x;
public int y;
public class Point //正确
private int x;
private int y;
public int getX() return x;
类和接口
使可变性最小化
- 线程安全最容易的做法:只提供访问方法,不提供设值方法,对对象的加减乘除都重新返回一个新的对象。对象不会变化,也就不要求同步。
- 可以把开销昂贵的计算结果缓存起来,例如String的hashcode方法,第一次计算后会将结果保存在成员hashCode里。
复合优先继承
- 子类脆弱:例如一个类继承HashSet,如果子类里重写了addAll和add方法来计数,就会导致错误,因为HashSet的addAll是基于add方法实现的。不能保证父类不随着版本而变化,因此extends 子类继承父类是非常脆弱的。
- 只有当子类真正是超类的子类型,即A和B,两者确实存在B is A的关系时,类B才应该扩展A,如果答案是否定的,通常情况下B应该包含A的一个私有实例,并且暴露一个较小的,简单的API:A本质上不是B的一部分,只是它的实现细节而已。
装饰者模式(Decorator模式)
结合上面说到的,HashSet是implement Set类的,在HashSet里重写了Set接口定义的add,addAll等方法。因此新的子类继承Hashset重写add、addAll就不可避免会将HashSet里的实现继承下来。
使用装饰者模式:ForwardingSet implements Set,该类有成员private final Set s s,构造器里就是传入一个Set ,该类不具体实现Set的任何方法,例如:
public boolean add(E e)
return s.add(e);
InstrumentedSet extends ForwardingSet,构造器super父类即可,在这个类里添加一些功能,例如:
@Override
public boolean add(E e)
count++;
return super.add(e);
这种模式下,InstrumentedSet 只是一个包装类,只是对其成员Set进行修饰,为它增加计数特性。包装类并不实现具体功能,构造器里传入的就是实现具体功能的Set,可以是HaseSet或者自己实现的Set。
另可参考阅读:
Android源码学习之装饰模式应用
继承后构造方法的调用
- 如果子类没有定义构造方法,则调用父类的无参数的构造方法。
- 如果子类定义了构造方法,不论是无参数还是带参数,在创建子类的对象的时候,首先执行父类无参数的构造方法,然后执行自己的构造方法。
- 如果子类调用父类带参数的构造方法,可以通过super(参数)调用所需要的父类的构造方法,切该语句做为子类构造方法中的第一条语句。
- 如果某个构造方法调用类中的其他的构造方法,则可以用this(参数),切该语句放在构造方法的第一条。
说白了:原则就是,先调用父亲的。(没有就默认调,有了就按有的调,反正只要有一个就可以了)
public class Son extends Father
public Son()
// super(); //没加默认调用父类无参构造方法
super("from son");
Log.e("zyz", "son-constructor");
public Son(String str)
// super(); //没加默认调用父类无参构造方法
Log.e("zyz", str + " son-constructor-with-params");
@Override
public void print()
Log.e("zyz", "son-print");
public class Son extends Father
public Son()
// super(); //没加默认调用父类无参构造方法
super("from son");
Log.e("zyz", "son-constructor");
public Son(String str)
// super(); //没加默认调用父类无参构造方法
Log.e("zyz", str + " son-constructor-with-params");
@Override
public void print()
Log.e("zyz", "son-print");
接口优于抽象类
抽象类可以写实例方法,通过派生继承,实现代码复用(子类可直接调用父类方法),但由于重用方法增加了耦合度,接口的方法一定需要重写,最大程度实现了解耦。
类层次优于标签类
标签类:
例如使用枚举或常量定义了圆和矩形,成员里有半径、长、宽。在公共方法 计算面积里,使用switch来判断是那种形状,再分别计算。类似的把多个实现乱七八糟地挤在单个类中,破坏可读性,又增加了内存占用,因为实例承担着属于其他类型的域。
应该使用类层次来优化:
定义一个抽象类,包含抽象方法:将共有的方法(计算面积),如果有公有的成员还可以将其放在抽象类中。之后不同的类圆和矩形继承公共抽象类,另外添加自己的参数,并重写自己的计算面积的方法。
优先考虑静态成员
如果成员类不要求访问外围实例,就要定义成静态内部类。非静态内部类始终要保持外围对象的引用,不仅消耗内存,还将导致外围实例无法被垃圾回收。
例如Map实现的内部都有Entry对象,每个Entry都与Map关联,但是entry的方法(getKey/getValue)等并不需要访问Map,因此私有的静态成员类是最佳的选择。
- 如果一个嵌套类需要在单个方法之外可见,或者它太长了不适合放在方法内部,就使用成员类。
- 如果成员类的每个实例都需要一个指向外围实例的应用,就使用非静态成员类。否则就使用静态成员类。
- 如果嵌套类属于一个方法的内部,且你只需要在一个地方创建实例,并且已经有了一个预置的类型可以说明这个类的特征,就使用匿名类。否则就使用局部类。
泛型
列表优先于数组
二者的不同点:
数组是协变的(covariant)
如果B是A的子类,那么B[]就是A[]的子类型。
//编译时不报错,运行时报错ArrayStoreException
Object[] test = new Long[1];
test[0] = "test";
而两个不同的类型A、B,List既不是List的子类也不是超类。
List<Object> test2 = new ArrayList<Long>(); //编译时报错
test2.add("123");
数组是具体化的(reified)
数组在运行时才知道并检查他们的元素类型约束。泛型则是通过擦除(erasure)来实现的。泛型只在编译时强化类型信息,在运行时擦除元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意互用。
利用有限制通配符提升API的灵活性
PECE producer-extends,consumer-siper
如果参数化类型表示生产者T,就使用
//src产生E实例供使用,是生产者
public void pushAll(Iterable<? extands E> src)
for (E e : src) push(e);
//dst消费E实例,是消费者
public void popAll(Collection<E> dst)
while(!isEmpty())
dst.add(pop());
不要用通配符类型作为返回参数
枚举和注解
用enum代替int常量
(android不推荐使用enum)
- 枚举本质上是int值
- 枚举允许添加任意的方法和域
public enum Test
APPLE("test1", 2),
pen("test2", 1);
private final String name;
private final int num;
Test(String name, int num)
this.name = name;
this.num = num;
public void print()
Log.e("zyz", APPLE.name + APPLE.num);
//遍历枚举
Test[] values = Test.values();
用实例域代替序数
- 所有枚举都有一个ordinal方法,返回每个枚举常量在类型中的数字位置。避免使用ordinal方法,除非是编写EnumSet和EnumMap这种基于枚举的通用数据结构。使用实例域(类似成员变量)来保存与枚举相关的值。
注解
- 注解类型声明
@Retention(RetentionPolicy.RUNTIME) //运行时保留
@Target(ElementType.METHOD) //只在方法声明中才是合适的
public @interface MyTest
坚持使用Override注解
覆盖equals时的参数是Object类型的,否则则变成了重载。但如果使用@Override注解后写错了编译器就会报错。
用标记接口定义类型
- 标记接口是没有包含方法声明的接口,只是指名了某个类实现了具有某种属性的接口(例如Serializable接口)
- 标记接口胜过标记注解的两点:
- 接口定义的类型是由被标记类的实例实现的,注解则没有定义这样的类型。这个类型允许你在编译时捕捉到错误,而不像注解需要在运行时才能捕捉到
- 接口可以被更加精确地锁定。假设一个标记只适用于特殊接口的实现,如果定义成标记接口就可以用它将唯一的接口扩展成它适用的接口。
- 注解胜过接口的两点:
- 注解可以不断演变。而接口通常不可能在实现后再给它添加方法。
- 注解是注解机制的一部分。注解可以作为支持注解作为编程元素之一的框架中具有一致性。
- 接口和注解使用场景:
- 如果标记是应用到任何程序元素而不是类或接口,就必须使用注解,因为只有类和接口可以用来实现或扩展接口。
- 如果标记只给类和接口,若要编写多个只接受有这种标记的方法则优先使用接口,这样可以在编译时进行类型检查。
- 如果要永远限制这个标记只用于特殊接口的元素,最好将标记定义成该接口的一个子接口。
- 如果2,3都是否定的,则应该使用注解。
方法
检查参数的有效性
- assert 对于有些参数,方法本身没有用到,却被保存起来供以后使用,可以使用断言检验这类参数的有效性。如果断言失败,则会抛AssertionError。
必要时进行保护性拷贝
- 如果类的成员是可变的,为了保护内部信息变化,对于构造器的每个可变can’shu参数进行保护性拷贝是必要的,使用被封对象作为实例的组件,而不使用原始的对象。但注意,保护性拷贝是在检查参数的有效性之前进行的,并且有效性检查是针对拷贝之后的对象而不是原始对象。
- 慎用clone。如果对于非final的成员,不能保证clone方法一定返回同样的类的对象,它有可能返回专门出于恶意目的而设计的不可信子类的实例,例如这样的子类可以在每个实例被创建时把指向该实例的引用记录到一个私有的静态列表中,并且允许攻击者访问这个列表,这将使得攻击者可以自由地控制所有的实例。为了阻止这种攻击,对于参数类型可以被不可信任方子类话的参数,请不要使用clone方法进行保护性拷贝。
另外需要修改访问方法,返回可变内部域的保护性拷贝:
public Data end() return new Data(end.getTime());
- 只要可能,都应该使用不可变的对象作为对象内部的组件,这样就不必再为保护型拷贝操心。
慎用重载
类型还是父类,虽然调用父类方法指向子类引用。
安全而保守的策略是:永远不要导出两个具有相同参数数目的重载方法。如果方法使用可变参数,保守的策略是根本不要重载它。
慎用可变参数
如果客户端调用这个方法时并没有传递参数进去,它就会在运行时而不是编译时失败。
//带两个参数,避免没有传参导致的问题 static init min(int firstArg, int... remainingArgs) int min = firstArg; for(int arg : remainingArgs) ...
- 在重视性能的情况下,使用可变参数要特别小型,可变参数方法的每次调用都会导致进行一次数组分配和初始化。可以使用多个重载方法,每个重载方法带有0至3个普通参数,当参数数目超过3个时,就使用可变参数方法。
返回零长度的数组或集合,而不是null
通用程序设计
for each循环优于传统的for循环
- 如果你在编写的类型是一组元素,实现Iterable可以允许用户利用for-each循环遍历你的类型。
- 三种常见的无法使用for-each的情况:
- 过滤——需要遍历集合并删除选定元素
- 转换——需要遍历集合并取代它的部分或全部元素值
- 平行迭代——需要并行地遍历多个集合,就需要显式地控制迭代器或者索引变量以便所有迭代器或索引变量都可以得到同步前移
了解和使用类库
伪随机数生成器
//错误 Math.abs(new Random().nextInt()); //正确 Random.nextInt(int)
了解和使用标准类库提供的便利工具,而不用浪费时间为那些与工作不太相关的问题提供特别的解决方案。标准类库太庞大了,以至于不可能去学习所有文档,但是每个程序员都应该熟悉java.lang,java.util,某种程度上还有java.io种的内容。有两种工具值得特别一提。
- Collections Framework 用来表示和操作集合
- java.util.concurrentbao’zhong包中增加了一组并发使用工具
总而言之,不要重新发明轮子,如果你要做的事情看起来是十分常见的,有可能类库中已经有某个类完成了这样的工作。
如果需要精确的答案,请避免使用float和double
float和double类型尤其不适合用于货币计算,因为要让一个float或double精确地表示0.1(或者10的ren’he’qi’ta任何其他负数次方值)是不可能的。
改进
使用BigDecimal代替double:
BigDecimal bigDecimal = new BigDecimal(0.1);
BigDecimal允许你完全控制舍入,每当一个操作设计舍入的时候,它允许你从8种舍入模式中选择其一。但是缺点是与基本运算类型比,不仅不方便,而且很慢。如果性能非常关键,并且又不介意自己记录是金子小数点,而且涉及的数值又不太大,就可以使用int或long(例如0.1改变单位计作10)。如果数值范围没超过9位十进制数字,就可以使用int。如果不超过18位数值,就可以使用long。如果数值超过18位数字,就必须使用BigDecimal。
基本类型优于装箱基本类型
当程序装箱了基本类型值时,会导致高开销和不必要的对象创建。
当心字符串连接的性能
连接操作符不适合运用在大规模的场景中,为连接n个字符串而重复地使用字符串连接操作符,需要n的平方级的时间。这是由于字符串不可变,当两个字符串被连接在一起时,它们的内容都要被拷贝。
使用StringBuilder:
StringBuilder test = new StringBuilder("test");
test.append("test2")
通过接口引用对象
如果有合适的接口类型存在,那么对于参数、返回值、变量和域来说,就都应该使用接口类型进行声明。只有当你利用构造器创建某个对象的时候,才真正需要引用这个对象的类。
List<String> list = new ArrayList<>();
这样会使程序更灵活,当你决定更换实现时,只需要改变构造器中类的名称:
List<String> list = new Vector<>();
所有的代码都可以继续工作,代码并不知道原来的实现类型,所以对于这种变化并不在意。
接口优先于反射机制
反射机制允许一个类使用另一个类,即使当前者被编译的时候后者还根本不存在,然而这种能力也是要付出代价的:
- 丧失了编译时类型检查的好处(包括异常检查)
- 执行反射访问所需要的代码非常笨拙和冗长
- 性能损失
异常
只针对异常的情况才使用异常
//Dont't do this
try
int i = 0;
while (true)
range[i++].climb();
catch (ArrayIndexOutOfBoundsException e)
不要优先使用基于异常的模式:
- 异常机制的设计初衷是用于不正常的情形,所以很少会有JVM实现试图对它们进行优化。
- 代码块放在try-catch块中反而阻止了现代JVM实现本来可能要执行的某些特定优化。
- 对数组进行比那里的标准模式并不会导致冗余的检查,有些现代的JVM实现会将它们优化掉。
- 基于异常的循环模式不仅模糊了代码的意图,还降低了性能,而且它还不能保证正常工作,如果出现不想关的bug,这个模式会悄悄地失效。
努力使失败保持原子性
一般而言,失败的方法调用应该使对象保持在被调用之前的状态。具有这种属性的方法被称为具有失败原子性(failure atomic)。有几种途径可以实现这种效果:
- 在执行操作前检查参数的有效性,这可以使在对象状态被修改前先抛出适当的异常。
- 调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修之前发生。
- 编写一段恢复代码,由它来拦截操作过程发生的失败,以及使对象回滚到操作开始之前的状态,这种办法主要用于永久性的数据结构。
- 在对象的一份临时拷贝上执行操作,操作完成之后再用临时拷贝中的结果代替对象的内容。
不要忽略异常
忽略一个异常非常容易,只需将方法调用通过try语句包围起来,并包含一个空的catch块。空的catch块会使异常达不到应有的目的,至少,catch块也应该包含一条说明,解释为什么可以忽略这个异常。
并发
正确地使用同步可以保证没有任何方法会看到对象处于不一致的状态中。它还可以保证刚进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改效果。换句话说,读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量中的,即使多个线程在没有同步的情况下并发地修改这个变量也是如此。
不要使用 Thread.stop方法。要阻止一个线程妨碍另一个线程,建议做法是让第一个线程轮训一个boolean域,这个域一开始为false,但是可以通过第二个线程设置为true,以表示第一个线程将终止自己。由于boolean域的读写操作都是原子的,程序员在访问这个域的时候不再使用同步。
实际上,如果读和写操作没有都被同步,同步就不会起作用。
如果变量修饰符是volatile,则读取变量时不需要锁,虽然volatile修饰符不执行互斥访问,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被写入的值。
使用volatile的时候务必要小心。
//错误
private static volatile int number = 0;
//需要使用synchronization
public static int getNumber()
return number++;
虽然number是原子的,但是增量操作符不是原子的,它首先读取值,然后写回一个新值。如果第二个线程在第一个线程读取旧值和返回新值期间读取这个域就会出错。
避免过度同步
在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法。这样的方法是外来的,这个类不知道方法会做什么事情,也无法控制它,从同步区域中调用它很可能会导致异常、死锁或者数据损坏。
通常,你应该在同步区域内做尽可能少的工作。如果你必须要执行某个很耗时的动作,应该设法把这个动作移到同步区域的外面。
executor 和 task 优先于线程
Java1.5增加了java.util.concurrent,这个包中包含了一个Executor Framework:
ExecutorService executorService = Executors.newSingleThreadExecutor();
//执行提交一个runnable方法
executorService.execute(runnable);
//告诉executor如何优雅地终止
executor.shutdonw();
你可以利用executor service完成更多的事情。例如,可以等待一个任务集合中的任何任务或所有任务完成(invokeAny或invokeAll),你可以等待executor service优雅地完成终止(awaitTermination),可以在任务完成时逐个地获取这些任务的结果(ExecutorCompletionService)等。
并发工具优于wait和notify
自从java1.5发型版本开始,java就提供了更高级的并发工具,他们可以完成以前必须在wait和notify上手写代码来完成的各项工作。其分成三类:
- Executor Framework
- 并发集合(Concurrent Collectionin)
- 同步器(Synchronizer)
并发集合为标准的集合接口(如List、Queue、Mpa)提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步,因此,并发集合中不可能排除并发活动,将它锁定没有什么作用,只会是程序的速度变慢。
同步器(Synchronizer)是一些使线程能够等待另一个线程的对象,允许他们协调动作。最常用的同步器是CountDownLatch和Semaphore。
倒计数锁存器(CountDown Latch)是一次性的障碍,允许一个或者多个线程等待一个或者多个其他线程来做某些事情。CountDownLatch是唯一构造器带有一个int类型的参数,这个int参数是指允许所有在等待的线程被处理之前,必须在锁存器上调用countDown方法的次数。
例如:一个方法带有一个执行该动作的executor,一个并发级别(表示要并发执行该动作的次数),以及表示该动作的runnable。所有的工作线程自身都准备好,要在time线程启动时钟之前运行该动作(为了实现准确的定时)。当最后一个工作线程准备好运行该动作时,timer线程就“发起头炮”,同事允许工作线程执行该动作,一旦最后一个工作线程执行完该动作,timer线程就立即停止计时。直接在wait和notify上实现这个逻辑至少来说会很混乱,而在CountDownLatch之上实现则相当简单:
public long getTime(Executor executor, int councurrency, final Runnable action) throws InterruptedException
final CountDownLatch ready = new CountDownLatch(councurrency);
final CountDownLatch start = new CountDownLatch(1);
final CountDownLatch done = new CountDownLatch(councurrency);
for (int i = 0; i < councurrency; i++)
executor.execute(new Runnable()
@Override
public void run()
ready.countDown();
try
start.await();
catch (InterruptedException e)
Thread.currentThread().interrupt();
finally
done.countDown();
);
ready.await();
long startNano = System.nanoTime();
start.countDown();
done.await();
return System.nanoTime() - startNano;
用ready来告诉timer线程他们已经准备好了。然后工作线程会在start上等待。当最后一个工作线程调用ready.countDown时,timer线程记录下起始时间,并调用start.countDown,允许所有的工作线程继续进行。然后timer线程在done上等待,直到最后一个工作线程运行完该动作,并调用donw.countDown。一旦调用这个,timer线程就会苏醒过来,并记录下结束时间。
wait方法的标准模式:
synchronized(obj)
while()
obj.wait(); //release lock, and reacquires on wakeup
始终应该使用wait循环模式来调用wait方法;永远不要在循环之外调用wait方法。循环会在等待之前和之后测试条件。
线程安全性的文档化
线程安全性的几种级别。(这份列表并没有涵盖所有的可能,而只是些常见的情形:
- 不可变的(immutable):这个类的实例是不变的。所以不需要外部的同步,例如String、Long、BigInteger。
- 无条件的线程安全(unconditionnally thread-safe):这个类的实例是可变的,但是这个类有着足够的内部同步,所以它的实例可以被并发使用,无需任何外部同步。 例如:Random和ConcurrentHashMa
- 有条件的线程安全(conditionally thread-safe):除了有些方法为进行安全的并发而使用需要外部同步
- 非线程安全(not thread-safe):这个类的实例是可变的。为了并发地使用它们,客户必须利用自己选择的外部同步包围每个方法调用(或者调用序列)。这样的例子包括通用的集合实现,例如ArrayList和HashMap。
- 线程对立的(thread-hostile):这个类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。Java平台类库中,线程对立的类或者方法非常少。System.runFinalizersOnExit方法是线程对立的,但已经被废除了。
//私有锁对象
private final Object lock = new Object();
public void foo()
synchronized(lock)
...
私有锁对象模式只能用在无条件的线程安全类上。有条件的线程安全类不能使用这种模式,因为它们必须在文档中说明:在执行某些方法调用序列时,它们的客户端程序必须获得哪把锁。
私有锁对象模式特别适用于那些专门为继承而设计的类。如果这种类使用它的实例作为锁对象,之类可能很容易在无意中妨碍基类的操作,反之亦然,出于不同的目的而使用相同的锁,子类和基类很可能会“互相绊住对方的脚”。
有条件的线程安全类必须在文档中指明哪些方法调用序列需要外部同步,以及在执行这些序列的时候需要获得哪把锁。如果你编写的是无条件的线程安全类,就应该考虑使用私有锁对象来代替同步的方法以防止客户端程序和子类的不同步干扰。
慎用延迟初始化
如果处于性能的考虑需要对静态域使用延迟初始化:
private static class FieldHolder
static final FieldType field = computeFieldValue();
static FieldHolder getField()
如果处于性能的考虑需要对实例域使用延迟初始化:
private volatile FieldType field;
FieldTpye getField()
FieldType result = field;
if(result == null) //First check(no locking)
synchronized (this)
result = field;
if(result == null) //Second check(with locking)
field = result = computeFieldValue();
return result;
如果需要延迟初始化一个可以接受重复初始化的实例域:
private volatile FieldType field;
private FieldType getField()
FieldType result = field;
if(result == null)
field = result = computeFiedlValue();
return result;
不要依赖于线程调度器
线程不应该一直处于忙-等状态,即反复地检查一个共享对象,以等待某些事情的发生。
不要让应用程序的正确性依赖于线程调度器,否则结果得到的应用程序将既不健壮,也不具有可移植性。不要依赖Thread.yield或者线程优先级。线程优先级可以用来提高一个已经能够正常工作的程序的服务质量,但永远不应该用来“修正”一个原本能不能工作的程序。
序列化
谨慎地实现Serializable接口
实现Serializable接口而付出的巨大代价是,一旦一个类被发布,就大大降低了“改变这个类的实现”的灵活性。
如果一个类实现了Serializable接口,它的字节流编码(序列化形式)就变成了它的导出的API的一部分,一旦这个类被广泛使用,往往必须永远支持这种序列化形式。
第二个代价是,它增加了出现bug和安全漏洞的可能性。你可能会忘记确保:反序列化过程必须也要保证所有“由真正的构造器建立起来的约束关系”,并且不允许攻击者访问正在构造过程中的对象的内部信息。
第三个代价是,随着类发行新的版本,相关的测试负担也增加了。可序列化的类被修订后,你必须既要确保“序列化-反序列化”过程成功,也要确保结果产生的对象真正是原始对象的复制品。
内部类不应该实现Serializable。
如果一个类为了继承而设计,要更加小心。对于这样的类而言,在“允许子类实现Serializable接口”或者“禁止子类实现serialzable”两者间的一个折衷方案是:提供一个可访问的无参构造器,这种方案允许(但不要求)子类实现Serializable接口。
以上是关于《Effective Java》读书笔记的主要内容,如果未能解决你的问题,请参考以下文章