《Effective Java》是Java开发领域无可争议的经典之作,连Java之父James Gosling都说: “如果说我需要一本Java编程的书,那就是它了”。它为Java程序员提供了90个富有价值的编程准则,适合对Java开发有一定经验想要继续深入的程序员。
本系列文章便是这本著作的精华浓缩,通过阅读,读者可以在5天时间内快速掌握书中要点。为了方便读者理解,笔者用通俗易懂的语言对全书做了重新阐述,避免了如翻译版本中生硬难懂的直译,同时对原作讲得不够详细的地方做了进一步解释和引证。
本文是系列的第五部分,包含对第十到十二章的22个准则的解读,约2.6万字。
Chapter 10. Exceptions
Item 69: Use exceptions only for exceptional conditions
下面的代码用来遍历一个数组。不过写法很糟糕,需要仔细读才能弄懂用意:
// Horrible abuse of exceptions. Don't ever do this!
try {
int i = 0;
while(true)
range[i++].climb();
}
catch (ArrayIndexOutOfBoundsException e) {
}
正常的写法应该是:
for (Mountain m : range)
m.climb();
这样的写的原因可能是代码编写者误认为标准for-each循环做结束判断性能不佳,应该使用异常机制来提升性能。这种思路有三点误区:
实际上,使用异常做循环终止,性能远低于标准用法。而且这样做还可能导致对真正数组越界报错的掩盖。所以,异常只适用于确实有异常的情况,它们不应该用于一般的控制流程。
API的设计也应该遵循同样的思路。例如,迭代器中应同时提供hasNext()和next()方法,我们称next()为“状态依赖”的,因为它需要通过一个“状态测试”的方法hasNext()才能判断调用是否合法。我们通过hasNext()判断循环是否应该终止:
for (Iterator<Foo> i = collection.iterator(); i.hasNext(); ) {
Foo foo = i.next();
...
}
而不应该使用异常来终止循环:
// Do not use this hideous code for iteration over a collection!
try {
Iterator<Foo> i = collection.iterator();
while(true) {
Foo foo = i.next();
...
}
}
catch (NoSuchElementException e) {
}
除了提供“状态测试”,另一种设计思路是让“状态依赖”的方法返回一个Optional对象,或者在不能执行计算时返回null。
Item 70: Use checked exceptions for recoverable conditions and runtime exceptions for programming errors
Java 提供了三种可抛出项:受检异常(checked exception)、运行时异常(runtime exception)和错误(error)。
使用受检异常的情况是为了期望调用者能够从中恢复。其他两种可抛出项都是非受检的。
使用运行时异常来表示编程错误。 例如数组越界ArrayIndexOutOfBoundsException。如果对于选择受检异常还是运行时异常有疑问,那么推荐还是使用运行时异常。
错误保留给 JVM 使用,用于表示:资源不足、不可恢复故障或其他导致无法继续执行的条件。不要自己定义新的错误类型。
Item 71: Avoid unnecessary use of checked exceptions
使用受检异常应满足:正确使用 API 也不能防止异常情况,并且使用 API 在遇到异常时可以采取一些有用的操作;否则应使用非受检异常。
} catch (TheCheckedException e) {
throw new AssertionError(); // Can't happen!
}
} catch (TheCheckedException e) {
e.printStackTrace(); // Oh well, we lose.
System.exit(1);
}
以上两种处理受检异常的方式都很糟糕。
受检异常会给程序员带来额外负担,消除受检异常的最简单方法是返回所需结果类型的 Optional 对象,当存在受检异常时返回空的Optional对象。这种方法缺点是无法提供附加信息说明为何不能继续执行计算。
另一种消除受检异常的方法是拆分方法逻辑。例如下面的方法原本需要捕获受检异常:
// Invocation with checked exception
try {
obj.action(args);
}
catch (TheCheckedException e) {
... // Handle exceptional condition
}
可以将其逻辑拆分:若参数合法,则走第一部分无受检异常的逻辑;否则走第二部分处理异常条件的逻辑。
// Invocation with state-testing method and unchecked exception
if (obj.actionPermitted(args)) {
obj.action(args);
}
else {
... // Handle exceptional condition
}
如果我们确定调用一定成功,或者不介意调用失败导致线程中止,甚至可以简化逻辑为下面语句:
obj.action(args);
Item 72: favor the use of standard exceptions
Java 库提供了一组标准异常,涵盖了日常的大多数异常抛出需求。
复用异常的好处是使代码易于阅读和维护。
此表总结了最常见的可复用异常:
Exception | Occasion for Use |
---|---|
IllegalArgumentException | 非null参数值不合适 |
IllegalStateException | 对象状态不适用于方法调用 |
NullPointerException | 禁止参数为null时仍传入 null |
IndexOutOfBoundsException | 索引参数值超出范围 |
ConcurrentModificationException | 在禁止并发修改对象的地方检测到并发修改 |
UnsupportedOperationException | 对象不支持该方法调用 |
不要直接复用 Exception、RuntimeException、Throwable 或 Error,应当将这些类当做抽象类。实际使用的异常类应该是这些类的继承类。
异常复用必须基于文档化的语义,而不仅仅是基于名称。另外,如果你想添加更多的细节,可以子类化标准异常。
Item 73: Throw exceptions appropriate to the abstraction
为了保证抽象的层次性,高层应该捕获低层异常,并确保抛出的异常可以用高层抽象解释。 这个习惯用法称为异常转换:
// Exception Translation
try {
... // Use lower-level abstraction to do our bidding
} catch (LowerLevelException e) {
throw new HigherLevelException(...);
}
下面是来自 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();
}
catch (NoSuchElementException e) {
throw new IndexOutOfBoundsException("Index: " + index);
}
}
如果低层异常有助于调试高层异常的问题,那么需要一种称为链式异常的特殊异常转换形式。低层异常作为原因传递给高层异常,高层异常提供一个访问器方法(Throwable 的 getCause 方法)来访问低层异常:
// Exception Chaining
try {
... // Use lower-level abstraction to do our bidding
}
catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
这个链式异常的实现代码如下:
// Exception with chaining-aware constructor
class HigherLevelException extends Exception {
HigherLevelException(Throwable cause) {
super(cause);
}
}
虽然异常转换可以有助于屏蔽低层异常,但不应被滥用。更好的办法是确保低层方法避免异常,例如在将高层方法的参数传递到低层之前检查它们的有效性。
另一种让屏蔽低层异常的方法是:让高层静默处理这些异常。例如可以使用一些适当的日志工具(如 java.util.logging)来记录异常。
Item 74: Document all exceptions thrown by each method
仔细记录每个方法抛出的所有异常是非常重要的。
始终单独声明受检异常,并使用 Javadoc 的 @throw 标记精确记录每次抛出异常的条件。
使用 Javadoc 的 @throw 标记记录方法会抛出的每个异常,但是不要对非受检异常使用 throws 关键字。
如果一个类中的许多方法都因为相同的原因抛出异常,你可以在类的文档注释中记录异常, 而不是为每个方法单独记录异常。例如,在类的文档注释中可以这样描述NullPointerException:“如果在任何参数中传递了 null 对象引用,该类中的所有方法都会抛出 NullPointerException”。
Item 75: Include failure capture information in detail messages
要捕获失败,异常的详细消息应该包含导致异常的所有参数和字段的值。例如,IndexOutOfBoundsException 的详细消息应该包含下界、上界和未能位于下界之间的索引值。
详细消息中不应包含密码、加密密钥等敏感信息。
确保异常在其详细信息中包含足够的故障捕获信息的一种方法是,在其构造函数中配置,而不是以传入字符串方式引入这些信息:
/**
* Constructs 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) {
// Generate a detail message that captures the failure
super(String.format("Lower bound: %d, Upper bound: %d, Index: %d",lowerBound, upperBound, index));
// Save failure information for programmatic access
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
Item 76: Strive for failure atomicity
失败的方法调用应该使对象能恢复到调用之前的状态。 具有此属性的方法称为具备故障原子性。
保证故障原子性的方法有如下几种:
设计不可变对象。
对于操作可变对象的方法,在执行操作之前检查参数的有效性:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
对计算进行排序,以便可能发生故障的部分都先于修改对象的部分发生。
以对象的临时副本执行操作,并在操作完成后用临时副本替换对象的内容。
编写恢复代码,拦截在操作过程中发生的故障,并使对象回滚到操作开始之前的状态。这种方法主要用于持久的(基于磁盘的)数据结构。
故障原子性并不总是可以实现的。例如,多线程修改同一个容器类对象,导致抛出ConcurrentModificationException,此时是不可恢复的。
Item 77: Don’t ignore exceptions
// Empty catch block ignores exception - Highly suspect!
try {
...
}
catch (SomeException e) {
}
以上的空 catch 块违背了异常的目的, 异常的存在是为了强制你处理异常情况。
在某些情况下忽略异常是合适的,例如在关闭 FileInputStream 时。你没有更改文件的状态,因此不需要执行任何恢复操作,也没有理由中止正在进行的操作。也可选择记录异常。如果你选择忽略异常,catch 块应该包含一条注释,解释为什么这样做是合适的,并且应该将变量命名为 ignore:
Future<Integer> f = exec.submit(planarMap::chromaticNumber);
int numColors = 4; // Default; guaranteed sufficient for any map
try {
numColors = f.get(1L, TimeUnit.SECONDS);
}
catch (TimeoutException | ExecutionException ignored) {
// Use default: minimal coloring is desirable, not required
}
Chapter 11. Concurrency
Item 78: Synchronize access to shared mutable data
同步不仅能防止线程修改的对象处于不一致状态,而且保证每个线程修改的结果为其他线程可见。
线程之间能可靠通信以及实施互斥,同步是所必需的。
即使是原子读写,没有设置同步也会造成糟糕的后果。下面代码展示从一个线程中使另一个线程停止,不要使用 Thread.stop,而是通过设置标志变量:
// Broken! - How long would you expect this program to run?
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
在某些机器上,线程永远不会停止。这是因为在没有同步的情况下,虚拟机可能会将下面代码
while (!stopRequested)
i++;
优化为如下代码:
if (!stopRequested)
while (true)
i++;
解决上面问题的办法是对stopRequested变量做同步读写,程序会立即结束:
// Properly synchronized cooperative thread termination
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(() -> {
int i = 0;
while (!stopRequested())
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
仅同步写方法是不够的。除非读和写操作都同步,否则不能保证同步生效。
一种更简单、更高效的做法是使用volatile。虽然 volatile 不保证互斥,但是它保证任何读取字段的线程都会看到最近修改的值:
// Cooperative thread termination with a volatile field
public class StopThread {
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
注意volatile不保证变量读写的原子性,因此下面的代码不能保证每次生成的序列号是严格递增的:
// Broken - requires synchronization!
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
解决办法是使用原子变量:
// Lock-free synchronization with java.util.concurrent.atomic
private static final AtomicLong nextSerialNum = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}
为避免出现本条目中出现的问题,最好办法是不共享可变数据。应当将可变数据限制在一个线程中。
Item 79: Avoid excessive synchronization
为避免活性失败和安全故障,永远不要在同步方法或块中将控制权交给用户。
为了展示这个问题,下面的代码实现了观察者模式,当元素被添加到集合中时,允许用户订阅通知:
// Broken - invokes alien method from synchronized block!
public class ObservableSet<E> extends ForwardingSet<E> {
public ObservableSet(Set<E> set) { super(set); }
private final List<SetObserver<E>> observers= new ArrayList<>();
public void addObserver(SetObserver<E> observer) {
synchronized(observers) {
observers.add(observer);
}
}
public boolean removeObserver(SetObserver<E> observer) {
synchronized(observers) {
return observers.remove(observer);
}
}
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;
}
}
观察者通过调用 addObserver 方法订阅通知,调用 removeObserver 方法取消订阅:
@FunctionalInterface
public interface SetObserver<E> {
// Invoked when an element is added to the observable set
void added(ObservableSet<E> set, E element);
}
粗略地检查一下,ObservableSet 似乎工作得很好。例如,打印从 0 到 99 的数字:
public static void main(String[] args) {
ObservableSet<Integer> set =new ObservableSet<>(new HashSet<>());
set.addObserver((s, e) -> System.out.println(e));
for (int i = 0; i < 100; i++)
set.add(i);
}
现在让我们尝试一些更有想象力的事情。假设我们将 addObserver 调用替换为一个传递观察者的调用,该观察者打印添加到集合中的整数值,如果该值为 23,则该调用将删除自身:
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23)
s.removeObserver(this);
}
});
你可能期望这个程序会打印数字0到23,然后终止。但是实际上它会抛出ConcurrentModificationException,虽然我们对observers加了并发,也无法阻止对它的并发修改。
现在让我们尝试一些奇怪的事情:编写一个观察者来取消订阅,但是它没有直接调用 removeObserver,而是使用executor启动另一个线程来执行:
// Observer that uses a background thread needlessly
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23) {
ExecutorService exec = Executors.newSingleThreadExecutor();
try {
exec.submit(() -> s.removeObserver(this)).get();
} catch (ExecutionException | InterruptedException ex) {
throw new AssertionError(ex);
} finally {
exec.shutdown();
}
}
}
});
结果是它不会抛出异常,而是形成死锁,原因是主线程调用addObserver后一直持有observers锁并等待子线程执行完毕,可是子线程调用removeObserver也需要获取observers锁,形成循环依赖。
一种解决办法是把遍历集合的代码移到同步块以外:
// Alien method moved outside of synchronized block - open calls
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot = null;
synchronized(observers) {
snapshot = new ArrayList<>(observers);
}
for (SetObserver<E> observer :snapshot)
observer.added(this, element);
}
另一种更好的办法是使用CopyOnWriteArrayList,适合用在很少修改和经常遍历的场合:
// Thread-safe observable set with CopyOnWriteArrayList
private final List<SetObserver<E>> observers =new CopyOnWriteArrayList<>();
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);
}
应该在同步区域内做尽可能少的工作,将耗时的代码移出同步块。
对一些Java类库的选择:
Item 80: Prefer executors, tasks, and streams to threads
Executor框架使用非常方便:
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(runnable);
exec.shutdown();
应该使用Executor,而非直接使用线程。后者既是工作单元,又是执行机制;前者做了对工作单元和执行机制做了很好的分离,可以根据实际情况灵活选择执行机制。
Item 81: Prefer concurrency utilities to wait and notify
直接使用wait-notify就像使用“并发汇编语言”编程一样原始,你应该使用更高级别的并发实用工具。
并发集合接口配备了依赖于状态的修改操作,这些操作将多个基本操作组合成单个原子操作。例如下面例子演示了Map的putIfAbsent(key, value)方法的使用,用于模拟实现String.intern的行为:
// Concurrent canonicalizing map atop ConcurrentMap - not optimal
private static final ConcurrentMap<String, String> map =new ConcurrentHashMap<>();
public static String intern(String s) {
String previousValue = map.putIfAbsent(s, s);
return previousValue == null ? s : previousValue;
}
事实上可以进一步优化。ConcurrentHashMap 针对 get 等检索操作进行了优化。因此,只有在 get 表明有必要时,才值得调用 putIfAbsent:
// Concurrent canonicalizing map atop ConcurrentMap - faster!
public static String intern(String s) {
String result = map.get(s);
if (result == null) {
result = map.putIfAbsent(s, s);
if (result == null)
result = s;
}
return result;
}
使用并发集合而非同步集合。例如,使用 ConcurrentHashMap 而不是 Collections.synchronizedMap。
下面例子展示了如何构建一个简单的框架来为一个操作的并发执行计时。在 wait 和 notify 的基础上直接实现这种逻辑会有点麻烦,但是在 CountDownLatch 的基础上实现起来却非常简单:
// Simple framework for timing concurrent execution
public static long time(Executor executor, int concurrency,Runnable action) throws InterruptedException {
CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
for (int i = 0; i < concurrency; i++) {
executor.execute(() -> {
ready.countDown(); // Tell timer we're ready
try {
start.await(); // Wait till peers are ready
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown(); // Tell timer we're done
}
});
}
ready.await(); // Wait for all workers to be ready
long startNanos = System.nanoTime();
start.countDown(); // And they're off!
done.await(); // Wait for all workers to finish
return System.nanoTime() - startNanos;
}
对于间隔计时,始终使用 System.nanoTime 而不是 System.currentTimeMillis。 System.nanoTime 不仅更准确和精确,而且不受系统实时时钟调整的影响。
有时候维护老代码还需要使用wait-notify。下面是wait-notify的基本用法:
// The standard idiom for using the wait method
synchronized (obj) {
while (<condition does not hold>)
obj.wait(); // (Releases lock, and reacquires on wakeup)
... // Perform action appropriate to condition
}
始终使用循环来调用 wait 方法。 notifyAll 方法通常应该优先于 notify。如果使用 notify,则必须非常小心以确保其活性。
Item 82: Document thread safety
每个类都应该详细描述或使用线程安全注解记录其线程安全属性。
不要依赖synchronized修饰符来记录线程安全,它只是实现细节,而不是API的一部分,不能可靠表明方法是线程安全的。
类必须仔细地记录它支持的线程安全级别,级别可分为:
在记录一个有条件的线程安全类时需要小心,要指出哪些调用方法需要外部同步。如Collections.synchronizedMap在遍历集合时,需要在map上做同步:
Map<K, V> m = Collections.synchronizedMap(new HashMap<>());
Set<K> s = m.keySet(); // Needn't be in synchronized block
...
synchronized(m) { // Synchronizing on m, not s!
for (K key : s)
key.f();
}
为了防止用户恶意长期持有公开锁,可以使用私有锁,并放在对外提供的方法内部:
// Private lock object idiom - thwarts denial-of-service attack
private final Object lock = new Object();
public void foo() {
synchronized(lock) {
...
}
}
Lock 字段应该始终声明为 final,防止无意中对其修改。
Item 83: Use lazy initialization judiciously
在大多数情况下,常规初始化优于延迟初始化。延迟初始化只有在必要时才这么做。
延迟初始化适合的场景:如果一个字段只在类的一小部分实例上访问,并且初始化该字段的代价很高,那么可以考虑延迟初始化。
在多线程竞争的情况下,使用延迟初始化容易导致错误。
下面是一个常规初始化的例子:
// Normal initialization of an instance field
private final FieldType field = computeFieldValue();
改成延迟初始化的版本,需使用synchronized同步:
// Lazy initialization of instance field - synchronized accessor
private FieldType field;
private synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}
如果需要在静态字段上使用延迟初始化提升性能,请使用延迟初始化持有类模式:
// Lazy initialization holder class idiom for static fields
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() { return FieldHolder.field; }
如果需要在实例字段上使用延迟初始化提升性能,请使用双重检查模式:
// Double-check idiom for lazy initialization of instance fields
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null) { // First check (no locking)
synchronized(this) {
if (field == null) // Second check (with locking)
field = result = computeFieldValue();
}
}
return result;
}
如果可以容忍重复初始化,可以改为单检查模式:
// Single-check idiom - can cause repeated initialization!
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null)
field = result = computeFieldValue();
return result;
}
Item 84: Don’t depend on the thread scheduler
任何依赖线程调度器来保证正确性或性能的程序都无法保证可移植性。
线程不应该在循环中检查共享对象状态变化。除了使程序容易受到线程调度器变化无常的影响之外,循环检查状态变化还大幅增加了处理器的负载,并影响其他线程获取处理器进行工作。例如下面实现的CountDownLatch版本性能很糟糕:
// Awful CountDownLatch implementation - busy-waits incessantly!
public class SlowCountDownLatch {
private int count;
public SlowCountDownLatch(int count) {
if (count < 0)
throw new IllegalArgumentException(count + " < 0");
this.count = count;
}
public void await() {
while (true) {
synchronized(this) {
if (count == 0)
return;
}
}
}
public synchronized void countDown() {
if (count != 0)
count--;
}
}
当有1000个线程竞争时,上面例子的执行时间是Java中CountDownLatch的10倍。
通过调用Thread.yield来优化上面的程序,可以勉强让程序运行起来,但它是不可移植的。更好的做法是重构代码,减少并发线程的数量。
类似地,不要依赖调整线程优先级。线程优先级是 Java 中最不可移植的特性之一。
Chapter 12. Serialization
Item 85: Prefer alternatives to Java serialization
当序列化特性被引入Java时,它还从未在生产语言中出现过。当时的设计者评估认为收益大于风险。
后来的历史证明并非如此。序列化导致了严重的安全漏洞,而且由于它的可攻击范围太大,难以防范,问题还在不断增多。
下面例子演示了一个“反序列化炸弹”,反序列化需要执行很长时间:
// Deserialization bomb - deserializing this stream takes forever
static byte[] bomb() {
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for (int i = 0; i < 100; i++) {
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo"); // Make t1 unequal to t2
s1.add(t1); s1.add(t2);
s2.add(t1); s2.add(t2);
s1 = t1;
s2 = t2;
}
return serialize(root); // Method omitted for brevity
}
避免序列化漏洞的最好方法是永远不要反序列化任何东西。没有理由在你编写的任何新系统中使用Java序列化。
用于取代Java序列化,领先的跨平台结构化数据是JSON和Protobuf。
如果需要在老系统中使用序列化,那么永远不要反序列化不可信的数据。
Java 9中添加的对象反序列化筛选机制,允许接收或拒绝某些类。优先选择白名单而不是黑名单, 因为黑名单只保护你免受已知的威胁。
Item 86: Implement Serializable with great caution
实现Serializable接口会带来以下代价:
实现 Serializable 接口并不是一个轻松的决定。
为继承而设计的类很少情况适合实现 Serializable 接口,接口也很少适合继承Serializable。
内部类不应该实现 Serializable。
Item 87: Consider using a custom serialized form
在没有考虑默认序列化形式是否合适之前,不要接受它。
如果对象的物理表示与其逻辑内容相同,则默认的序列化形式可能是合适的。如下面的类表示一个人的名字:
// Good candidate for default serialized form
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方法来确保不变性和安全性。
下面的例子不适合使用默认序列化,因为它会镜像出链表中的所有项,以及这些项之间的双向链接:
// Awful candidate for default serialized form
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
}
当对象的物理表示与其逻辑数据内容有很大差异时,使用默认的序列化形式有四个缺点:
StringList的合理序列化形式是列表中的字符串数量和字符串本身。这构成了由StringList表示的逻辑数据,去掉了其物理表示的细节。下面是修改后的StringList版本,其中transient修饰符表示要从类的默认序列化中省略该实例字段:
// StringList with a reasonable custom serialized form
public final class StringList implements Serializable {
private transient int size = 0;
private transient Entry head = null;
// No longer Serializable!
private static class Entry {
String data;
Entry next;
Entry previous;
}
// Appends the specified string to the list
public final void add(String s) { ... }
/**
* Serialize this {@code StringList} instance.
**
@serialData The size of the list (the number of strings
* it contains) is emitted ({@code int}), followed by all of
* its elements (each a {@code String}), in the proper
* sequence.
*/
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
s.writeInt(size);
// Write out all elements in the proper order.
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();
// Read in all elements and insert them in list
for (int i = 0; i < numElements; i++)
add((String) s.readObject());
}
... // Remainder omitted
}
必须对对象序列化强制执行任何同步操作,就如同对读取对象整个状态的任何其他方法那样强制执行:
// writeObject for synchronized class with default serialized form
private synchronized void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
}
无论选择哪种序列化形式,都要在编写的每个可序列化类中声明显式的序列化版本UID:
private static final long serialVersionUID = randomLongValue;
不要更改序列化版本UID,除非你想破坏与现有序列化所有实例的兼容性。
Item 88: Write readObject methods defensively
第50条中编写了一个包含Date字段的不变日期范围类,它通过防御性地复制Date对象来保证其不变性:
// Immutable class that uses defensive copying
public final class Period {
private final Date start;
private final Date end;
/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start
* @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is null
*/
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; }
... // Remainder omitted
}
如果我们让这个类支持Java默认的序列化,那么会产生漏洞:可以从字节流反序列出一个有问题的对象,其结束时间小于开始时间,绕过原构造方法做的限制:
public class BogusPeriod {
// Byte stream couldn't have come from a real Period instance!
private static final byte[] serializedForm = {
(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) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// Returns the object with the specified serialized form
static Object deserialize(byte[] sf) {
try {
return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
} catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
}
解决办法是在readObject方法中检查反序列化对象的有效性:
// readObject method with validity checking - insufficient!
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Check that our invariants are satisfied
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start +" after "+ end);
}
然而还有另外的问题。攻击者可以通过反序列化访问Period对象中原有的私有Date字段。通过修改这些Date实例,攻击者可以修改Period实例:
public class MutablePeriod {
// A period instance
public final Period period;
// period's start field, to which we shouldn't have access
public final Date start;
// period's end field, to which we shouldn't have access
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
// Serialize a valid Period instance
out.writeObject(new Period(new Date(), new Date()));
/*
* Append rogue "previous object refs" for internal
* Date fields in Period. For details, see "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
// Deserialize Period and "stolen" Date references
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
start = (Date) in.readObject();
end = (Date) in.readObject();
} catch (IOException | ClassNotFoundException e) {
throw new AssertionError(e);
}
}
}
在作者的机器上,可以产生如下输出:
Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969
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);
// Bring back the 60s!
pEnd.setYear(69);
System.out.println(p);
}
Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969
对象被反序列化时,对任何私有字段进行防御性地复制至关重要。例如:
// readObject method with defensive copying and validity checking
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Defensively copy our mutable components
start = new Date(start.getTime());
end = new Date(end.getTime());
// Check that our invariants are satisfied
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start +" after "+ end);
}
修改后会产生如下输出:
Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017
Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017
下面是编写 readObject 方法的指导原则:
Item 89: For instance control, prefer enum types to readResolve
第3条中编写了如下的单例模式代码:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
如果该类实现序列化接口,那么它就不再是单例的。因为readObject方法会返回一个新的实例,与类初始化时创建的实例不同。
不过如果在类中定义了readResolve方法,那么反序列化新创建的对象将调用这个方法,并以这个方法返回的对象引用替代新创建的对象。故可以通过以下代码实现单例:
// readResolve for instance control - you can do better!
private Object readResolve() {
// Return the one true Elvis and let the garbage collector
// take care of the Elvis impersonator.
return INSTANCE;
}
如果你依赖readResolve进行实例控制,那么所有具有对象引用类型的实例字段都必须声明为 transient。否则,有的攻击者有可能在运行反序列化对象的readResolve方法之前”窃取“对该对象的引用,并之后发起攻击。
Item 90: Consider serialization proxies instead of serialized instances
使用序列化代理可以降低使用普通序列化面临的风险。
以下代码实现一个序列化代理。
首先,编写一个私有静态内部类,它的字段与外围类一样,拥有一个以外围类对象为参数的构造方法:
// Serialization proxy for Period class
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; // Any number will do (Item 87)
}
为外围类编写writeReplace方法,它在序列化之前将外围类的实例转换为其序列化代理:
// writeReplace method for the serialization proxy pattern
private Object writeReplace() {
return new SerializationProxy(this);
}
这样,序列化系统将永远不会生成外围类的序列化实例,但是攻击者可能会创建一个实例,试图违反类的不变性。为了保证这样的攻击会失败,只需将这个readObject方法添加到外围类中:
// readObject method for the serialization proxy pattern
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
最后,在SerializationProxy类上提供一个readResolve方法,该方法返回外围类的逻辑等效实例。此方法的存在导致序列化系统在反序列化时将序列化代理转换回外围类的实例:
// readResolve method for Period.SerializationProxy
private Object readResolve() {
return new Period(start, end); // Uses public constructor
}
不过,使用序列化代理的开销通常比用保护性拷贝要高。