六十六、同步访问共享的可变数据:
在Java中很多时候都是通过synchronized关键字来实现共享对象之间的同步的。事实上,对象同步并不仅限于当多个线程操作同一可变对象时,仍然能够保证该共享对象的状态始终保持一致。与此同时,他还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改效果。
Java的语言规范保证了读写一个变量是原子的,除非这个变量的类型为long或double。换句话说,读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量中的,即时多个线程在没有同步的情况下并发地修改这个变量也是如此。然而需要特别指出的是,这样的做法是非常危险的。即便这样做不会带来数据同步修改的问题,但是他会导致另外一个更为隐匿的错误发生。见如下代码:
public class StopThread {
private static boolean stopRequested = false;
public static void main(String[] args) throw InterruptedException {
Thread bgThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested)
i++;
}
});
bgThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
对于上面的代码片段,有些人会认为在主函数sleep一秒后,工作者线程的循环状态标志(stopRequested)就会被修改,从而致使工作者线程正常退出。然而事实却并非如此,因为Java的规范中并没有保证在非同步状态下,一个线程修改的变量,在另一个线程中就会立即可见。事实上,这也是Java针对内存模型进行优化的一个技巧。为了把事情描述清楚,我们可以将上面代码中run方法的代码模拟为优化后的代码,见如下修改后的run方法:
public void run() {
int i = 0;
if (!stopRequested) {
while (true)
i++;
}
}
这种优化被称为提升,正是HotSpot Server VM的工作。
要解决这个问题并不难,只需在读取和写入stopRequested的时候加入synchronized关键字即可,见如下代码:
public class StopThread {
private static boolean stopRequested = false;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
public static void main(String[] args) throw InterruptedException {
Thread bgThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested())
i++;
}
});
bgThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
在上面的修改代码中,读写该变量的函数均被加以同步。
事实上,Java中还提供了另外一种方式用于处理该类问题,即volatile关键字。该单词的直译为“易变的”,引申到这里就是告诉cpu该变量是容易被改变的变量,不能每次都从当前线程的内存模型中获取该变量的值,而是必须从主存中获取,这种做法所带来的唯一负面影响就是效率的折损,但是相比于synchronized关键字,其效率优势还是非常明显的。见如下代码:
public class StopThread {
private static volatile boolean stopRequested = false;
public static void main(String[] args) throw InterruptedException {
Thread bgThread = new Thread(new Runnable() {
public void run() {
int i = 0;
while (!stopRequested)
i++;
}
});
bgThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
和第一个代码片段相比,这里只是在stopRequested域变量声明之前加上volatile关键字,从而保证该变量为易变变量。然而需要说明的是,该关键字并不能完全取代synchronized同步方式,见如下代码:
public class Test {
private static volatile int nextID = 0;
public static int generateNextID() {
return nextID++;
}
}
generateNextID方法的用意为每次都给调用者生成不同的ID值,遗憾的是,最终结果并不是我们期望的那样,当多个线程调用该方法时,极有可能出现重复的ID值。这是因为++运算符并不是原子操作,而是由两个指令构成,首先是读取该值,加一之后再重新赋值。由此可见,这两个指令之间的时间窗口极有可能造成数据的不一致。如果要修复该问题,我们可以使用JDK(1.5 later)中java.util.concurrent.atomic包提供的AtomicLong类,使用该类性能要明显好于synchronized的同步方式,见如下修复后的代码:
public class Test {
private static final AtomicLong nextID = new AtomicLong();
public static long generateNextID() {
return nextID.getAndIncrement();
}
}
六十七、避免过度同步:
过度同步所导致的最明显问题就是性能下降,特别是在如今的多核时代,再有就是可能引发的死锁和一系列不确定性的问题。当同步函数或同步代码块内调用了外来方法,如可被子类覆盖的方法,或外部类的接口方法等。由于这些方法的行为存在一定的未知性,如果在同步块内调用了类似的方法,将极有可能给当前的同步带来未知的破坏性。见如下代码:
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.remover(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);
return result;
}
}
下面的代码片段是回调接口和测试调用:
public interface SetObserver<E> {
void added(ObservableSet<E> set,E element);
}
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);
}
});
for (int i = 0; i < 100; i++)
set.add(i);
}
对于这个测试用例,他完全没有问题,可以保证得到正确的输出,即打印出0-99的数字。
现在我们换一个观察者接口的实现方式,见如下代码片段:
set.addObserver(new SetObserver<Integer>() {
public void added(ObservableSet<Integer> s,Integer e) {
System.out.println(e);
if (e == 23)
s.removeObserver(this);
}
});
对于以上代码,当执行s.removeObserver(this)的时候,将会抛出ConcurrentModificationException异常,因为在notifyElementAdded方法中正在遍历该集合。对于该段代码,我只能说我们是幸运的,错误被及时抛出并迅速定位,这是因为我们的调用是在同一个线程内完成的,而Java中synchronized关键字构成的锁是可重入的,或者说是可递归的,即在同一个线程内可多次调用且不会被阻塞。如果恰恰相反,我们的冲突调用来自于多个线程,那么将会形成死锁。在多线程的应用程序中,死锁是一种比较难以重现和定位的错误。为了解决上述问题,我们需要做的一是将调用外部代码的部分移出同步代码块,再有就是针对该遍历,我们需要提前copy出来一份,并基于该对象进行遍历,从而避免了上面的并发访问冲突,如:
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);
}
减少不必要的代码同步还可以大大提高程序的并发执行效率,一个非常明显的例子就是StringBuffer,该类在JDK的早期版本中即以出现,是数据操作同步类,即时我们是以单线程方式调用该类的方法,也不得不承受块同步带来的额外开销。Java在1.5中提供了非同步版本的StringBuilder类,这样在单线程应用中可以消除因同步而带来的额外开销,对于多线程程序,可以继续选择StringBuffer,或者在自己认为需要同步的代码部分加同步块。
六十八、executor和task优先于线程:
在Java 1.5 中提供了java.util.concurrent包,在这个包中包含了Executor Framework框架,这是一个很灵活的基于接口的任务执行工具。该框架提供了非常方便的调用方式和强大的功能,如:
ExecutorService executor = Executors.newSingleThreadExecutor(); //创建一个单线程执行器对象。
executor.execute(runnable); //提交一个待执行的任务。
executor.shutdown(); //使执行器优雅的终止。
事实上,Executors对象还提供了更多的工厂方法,如适用于小型服务器的Executors.newCachedThreadPool()工厂方法,该方法创建的执行器实现类对于小型服务器来说还是比较有优势的,因为在其内部实现中并没有提供任务队列,而是直接将任务提交给当前可用的线程,如果此时没有可用的线程了,则创建一个新线程来执行该任务。因此在任务数量较多的大型服务器上,由于该机制创建了大量的工作者线程,这将会导致系统的整体运行效率下降。对于该种情况,Executors提供了另外一个工厂方法Executors.newFixedThreadPool(),该方法创建的执行器实现类的内部提供了任务队列,用于任务缓冲。
相比于java.util.Timer,该框架也提供了一个更为高效的执行器实现类,通过工厂方法Executors.ScheduledThreadPool()可以创建该类。它提供了更多的内部执行线程,这样在执行耗时任务是,其定时精度要优于Timer类。
六十九、并发工具优先于wait和notify:
java.util.concurrent中更高级的工具分成三类:Executor Framework、并发集合(Concurrent Collection)以及同步器(Synchronizer)。相比于java.util中提供的集合类,java.util.concurrent中提供的并发集合就有更好的并发性,其性能通常数倍于普通集合,如ConcurrentHashMap等。换句话说,除非有极其特殊的原因存在,否则在并发的情况下,一定要优先选择ConcurrentHashMap,而不是Collections.syschronizedmap或者Hashtable。
java.util.concurrent包中还提供了阻塞队列,该队列极大的简化了生产者线程和消费者线程模型的编码工作。
对于同步器,concurrent包中给出了四种主要的同步器对象:CountDownLatch、Semaphore、CyclicBarrier和Exchanger。这里前两种比较常用。在该条目中我们只是简单介绍一个CountDownLatch的优势,该类允许一个或者多个线程等待一个或者多个线程来做某些事情。CountDownLatch的唯一构造函数带有一个int类型的参数 ,这个int参数是指允许所有在等待的线程被处理之前,必须在锁存器上调用countDown方法的次数。
现在我们给出一个简单应用场景,然后再给出用CountDownLatch实现该场景的实际代码。场景描述如下:
假设想要构建一个简单的框架,用来给一个动作的并发执行定时。这个框架中包含单个方法,这个方法带有一个执行该动作的executor,一个并发级别(表示要并发执行该动作的次数),以及表示该动作的runnable。所有的工作线程自身都准备好,要在timer线程启动时钟之前运行该动作。当最后一个工作线程准备好运行该动作时,timer线程就开始执行,同时允许工作线程执行该动作。一旦最后一个工作线程执行完该动作,timer线程就立即停止计时。直接在wait和notify之上实现这个逻辑至少来说会很混乱,而在CountDownLatch之上实现则相当简单。见如下示例代码:
public static long time(Executor executor,int concurrency,final Runnable action) {
final CountDownLatch ready = new CountDownLatch(concurrency);
final CountDownLatch start = new CountDownLatch(1);
final CountDownLatch done = new CountDownLatch(concurrency);
for (int i = 0; i < concurrency; i++) {
executor.execute(new Runnable() {
public void run() {
ready.countDown();
try {
start.await();
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
}
});
//等待工作者线程准备可以执行,即所有的工作线程均调用ready.countDown()方法。
ready.await();
//这里使用nanoTime,是因为其精确度高于System.currentTimeMills()。
long startNanos = System.nanoTime();
//该语句执行后,工作者线程中的start.await()均将被唤醒。
start.countDown();
//下面的等待,只有在所有的工作者线程均调用done.countDown()之后才会被唤醒。
done.await();
return System.nanoTime() - startNanos;
}
}
七十一、慎用延迟初始化:
延迟初始化作为一种性能优化的技巧,它要求类的域成员在第一次访问时才执行必要的初始化动作,而不是在类构造的时候完成该域字段的初始化。和大多数优化一样,对于延迟初始化,最好的建议"除非绝对必要,否则就不要这么做"。延迟初始化如同一把双刃剑,它确实降低了实例对象创建的开销,却增加了访问被延迟初始化的域的开销,这一点在多线程访问该域时表现的更为明显。见如下代码:
public class TestClass {
private final FieldType field;
synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}
}
从上面的代码可以看出,在每次访问该域字段时,均需要承担同步的开销。如果在真实的应用中,在多线程环境下,我们确实需要为一个实例化开销很大的对象实行延迟初始化,又该如何做呢?该条目提供了3中技巧:
1. 对于静态域字段,可以考虑使用延迟初始化Holder class模式:
public class TestClass {
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
static FieldType getField() {
return FieldHolder.field;
}
}
当getField()方法第一次被调用时,它第一次读取FieldHolder.field,导致FieldHolder类得到初始化。这种模式的魅力在于,getField方法没有被同步,并且只执行一个域访问,因此延迟初始化实际上并没有增加任何访问成本。现在的VM将在初始化该类的时候,同步域的访问。一旦这个类被初始化,VM将修补代码,以便后续对该域的访问不会导致任何测试或者同步。
2. 对于实例域字段,可使用双重检查模式:
public class TestClass {
private volatile FieldType f;
FieldType getField() {
FieldType result = f;
if (result == null) {
synchronized(this) {
result = f;
if (result == null)
f = result = computeFieldValue();
}
}
return result;
}
}
注意在上面的代码中,首先将域字段f声明为volatile变量,其语义在之前的条目中已经给出解释,这里将不再赘述。再者就是在进入同步块之前,先针对该字段进行验证,如果不是null,即已经初始化,就直接返回该域字段,从而避免了不必要的同步开销。然而需要明确的是,在同步块内部的判断极其重要,因为在第一次判断之后和进入同步代码块之前存在一个时间窗口,而这一窗口则很有可能造成不同步的错误发生,因此第二次验证才是决定性的。
在该示例代码中,使用局部变量result代替volatile的域字段,可以避免在后面的访问中每次都从主存中获取数据,从而提高函数的运行性能。事实上,这只是一种代码优化的技巧而已。
针对该技巧,最后需要补充的是,在很多并发程序中,对某一状态的测试,也可以使用该技巧。
3. 对于可以接受重复初始化实例域字段,可使用单重检查模式:
public class TestClass {
private volatile FieldType f;
FieldType getField() {
FieldType result = f;
if (result == null)
f = result = computeFieldValue();
return result;
}
}
七十五、考虑使用自定义的序列化形式:
设计一个类的序列化形式和设计该类的API同样重要,因此在没有认真考虑好默认的序列化形式是否合适之前,不要贸然使用默认的序列化行为。在作出决定之前,你需要从灵活性、性能和正确性多个角度对这种编码形式进行考察。一般来讲,只有当你自行设计的自定义序列化形式与默认的形式基本相同时,才能接受默认的序列化形式。比如,当一个对象的物理表示法等同于它的逻辑内容,可能就适合于使用默认的序列化形式。见如下代码示例:
public class Name implements Serializable {
private final String lastName;
private final String firstName;
private final String middleName;
... ...
}
从逻辑角度而言,该类的三个域字段精确的反应出它的逻辑内容。然而有的时候,即便默认的序列化形式是合适的,通常还必须提供一个readObject方法以保证约束关系和安全性,如上例代码中,firstName和lastName不能为null等。
下面我们再看一个极端的例子:
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;
}
}
对于上面的示例代码,如果采用默认形式的序列化,将会导致双向链表中的每一个节点的数据以及前后关系都会被序列化。因此这种物理表示法与它的逻辑数据内容有实质性的区别时,使用默认序列化形式会有以下几个缺点:
1. 它使这个类的导出API永远的束缚在该类的内部表示法上,即使今后找到更好的的实现方式,也无法摆脱原有的实现方式。
2. 它会消耗过多的空间。事实上对于上面的示例代码,我们只需要序列化数据部分,可以完全忽略链表节点之间的关系。
3. 它会消耗过多的时间。
4. 它会引起栈溢出。
根据以上四点,我们修订了StringList类的序列化实现方式,见如下代码:
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;
}
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
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 numElemnet = s.readInt();
for (int i = 0; i < numElements; i++)
add((String)s.readObject());
}
public final void add(String s) { ... }
... ...
}
在修订代码中,所有的域字段都是transient,但writeObject和readObject方法的首要任务仍然是先调用defaultWriteObject和defaultReadObject方法,即便这对于缺省序列化形式并不是必须的。因为在今后的修改中,很有可能会为该类添加非transient域字段,一旦忘记同步修改writeObject或readObject方法,将会导致序列化和反序列化的数据处理方式不一致。
对于默认序列化还需要进一步说明的是,当一个或多个域字段被标记为transient时,如果要进行反序列化,这些域字段都将被初始化为其类型默认值,如对象引用域被置为null,数值基本域的默认值为0,boolean域的默认值为false。如果这些值不能被任何transient域所接受,你就必须提供一个readObject方法。它首先调用defaultReadObject,然后再把这些transient域恢复为可接受的值。
最后需要说明的是,无论你是否使用默认的序列化形式,如果在读取整个对象状态的任何其他方法上强制任何同步,则也必须在对象序列化上强制这种同步,见如下代码:
private synchronized void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
}
七十六、保护性的编写readObject方法:
在条目39中介绍了一个不可变的日期范围类,它包含可变的私有Date域。该类通过在其构造器和访问方法中保护性的拷贝Date对象,极力的维护其约束条件和不可变性。见如下代码:
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();
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
public String toString() {
return start + " - " + end;
}
... ...
}
这个对象的物理表示法和其逻辑表示法完全匹配,所以我们可以使用默认的序列化形式。因此在声明该类的地方增加" implements Serializable "。然而,如果你真是这样做了,那么这个类将不再保证他的关键约束了。
问题在于,如果反序列化的数据源来自于该类实例的正常序列化,那么将不会引发任何问题。如果恰恰相反,反序列化的数据源来自于一组伪造的数据流,事实上,反序列化的机制就是从一组有规则的数据流中实例化指定对象,那么我们将不得不面对Period实例对象的内部约束被破坏的危险,见如下代码:
public class BogusPeriod {
private static final byte[] serializedForm = new byte[] {
... ... //这里是一组伪造的字节流数据
};
public static void main(String[] args) [
Period p = (Period)deserialize(serializedForm);
System.out.println(p);
}
private static Object deserialize(byte[] sf) {
try {
InputStream is = new ByteArrayInputStream(sf);
ObjectInputStream ois = new ObjectInputStream(is);
return ois.readObject();
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
}
如果执行上面的代码就会发现Period的约束被打破了,end的日期早于start。为了修正这个问题,可以为Period提供一个readObject方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象的有效性。如果检查失败,则抛出InvalidObjectException异常,使反序列化过程不能成功地完成。
private void readObject(ObjectInputStream s)
throws IOException,ClassNotFoundException {
s.defaultReadObject();
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
如果执行上面的代码就会发现Period的约束被打破了,end的日期早于start。为了修正这个问题,可以为Period提供一个readObject方法,该方法首先调用defaultReadObject,然后检查被反序列化之后的对象的有效性。如果检查失败,则抛出InvalidObjectException异常,使反序列化过程不能成功地完成。
private void readObject(ObjectInputStream s)
throws IOException,ClassNotFoundException {
s.defaultReadObject();
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
除了上面的攻击方式之外,还存在着另外一种更为隐匿的攻击方式,它也是通过伪造序列化数据流的方式来骗取反序列化方法的信任。它在伪造数据时,将私有域字段的引用在外部保存起来,这样当对象实例反序列化成功后,由于外部仍然可以操作其内部数据,因此危险仍然存在。如何避免该风险呢?见如下修订后的readObject方法:
private void readObject(ObjectInputStream s)
throws IOException,ClassNotFoundException {
s.defaultReadObject();
//执行保护性copy
start = new Date(start.getTime());
end = new Date(end.getTime());
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " after " + end);
}
注意,保护性copy一定要在有效性检查之前进行。
这里给出一个基本的规则,可以用来帮助确定默认的readObject方法是否可以被接受。规则是增加一个公有的构造器,其参数对应于该对象中每个非transient域,并且无论参数的值是什么,都是不进行检查就可以保存到相应的域中的。对于这样的做法如果仍然可以接受,那么默认的readObject就是合理,否则就需要提供一个显式的readObject方法。
对于非final的可序列化类,在readObject方法和构造器之间还有其他类似的地方,readObject方法不可以调用可被覆盖的方法,无论是直接调用还是间接调都不可以。如果违反了该规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。程序很可能会失败。