《Java并发编程实战》学习笔记--同步容器类
我们平时使用的一些容器,例如ArrayList其实不是线程安全的。如果我们在多线程的环境之下在没有保证线程安全的情况之下使用它们,就有可能会发生意想不到的错误。那我们该如何解决这个问题呢?别着急,Java自早期开始,就为我们提供了同步容器类:
- Vector和Hashtable以及继承自Vector的Stack。
- Collections.synchronizedXxx等工厂方法创建的类。
那么它们是如何实现线程安全的呢?
很简单,这些同步容器类将它们所有的成员变量都设为私有的(进行状态封装),并且对每个公有方法都进行同步(在方法头部使用Synchronized进行声明)从而实现每一次只有一个线程能够访问该同步容器类的实例。
既然如此,那么在使用这些同步容器类的时候是不是就高枕无忧,万事大吉了呢?
让我们来看看下面这两个程序:
public static Object getLast(Vector list){
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
public static void deleteLast(Vector list){
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
上面两个方法看起来没有一点问题,它们都会执行“先检查再运行”的操作。每个方法都是先获得数组的大小,然后通过结果来获取或者删除最后一个元素。表面上看起来无论多少个线程同时调用它们,也不会破坏Vector。但从调用者的角度来看,情况就不同了:
交替调用getList和deleteList时将抛出ArrayIndexOutOfBoundsException如果线程A在包含10个元素的Vector上调用getLast,同时线程B在此Vector上调用deleteLast,这些操作的交替执行如上图所示。getLast将抛出ArrayIndexOutOffBoundsException异常。在调用size与调用getLast这两个操作之间,Vector变小了,因此在调用size时得到的索引值将不再有效。
虽然这种情况很好地遵循了Vector的规范:如果请求一个不存在的元素,那么将抛出一个异常。但这并不是Vector调用者所期望的(即使在并发修改的情况下也不希望看到),除非Vector一开始就是空的。
我们可以使用同步策略,即使用客户端加锁来保证操作的原子性:
public static Object getLast(Vector list){
synchronized(this){
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
public static void deleteLast(Vector list){
synchronized(this){
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
}
类似的还有下面这个例子:
在调用size和相应的get之间,Vector的长度可能发生变化,这种风险在对Vector中的元素进行迭代时仍然会出现。
for(int i = 0 ;i < vector.size(); i++){
doSome(vector.get(i));
}
这种迭代方法的正确性完全依赖于运气:我们无法保证在调用size与get直接按有没有其他线程对所操作的这个Vector进行了修改。但是这并不代表Vector就不是线程安全的。Vector仍然是线程安全的,而抛出的异常也与其规范保持一致。然而,像读取最后一个或者迭代等这样简单的操作中抛出异常并不是我们所期待的。
改进方法:
synchronized(vector){
for(int i = 0 ;i < vector.size(); i++){
doSome(vector.get(i));
}
}
迭代器与ConcurrentModificationException
Vector是一个“古老”的容器类。然而,许多“现代”的容器类也没有消除复合操作中的问题。无论在直接迭代还是使用for-each循环语法中,对容器类进行迭代的标准方式都是使用Iterator。然而,如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器进行加锁。许多同步容器类在被设计的时候并没有考虑到被并发修改的问题,它们所表现出的行为是****“及时失败”(fail - fast)****的。具体的可以参考我关于ArrayList源码的博客,里面有对用于及时失败机制中modCount的介绍。
我们并不希望出现并发修改的问题,同时也不希望在迭代的过程中对容器进行加锁 -- 因为持有两个锁可能会导致死锁的问题,并且持有锁的时间过长,那么在锁上的竞争就会非常激烈,从而将极大降低吞吐量以及CPU的利用率。
如果不希望在迭代的过程中加锁,那么一种替代的方法就是对容器进行克隆,并在副本上进行迭代。副本将被封闭在线程内部,因此其他线程不会在迭代期间对其进行修改。这样就避免抛出ConcurrentModificationException(但是在克隆容器的过程中仍需要对容器进行加锁)。但是在克隆容器的过程中存在着显著的性能开销。这种方式的好坏取决于多个因素:容器的大小、在每个元素上执行的工作、迭代操作相对于容器其他操作的调用频率以及在响应时间和吞吐量等方面的需求。
隐藏的迭代器
我们看看下面这个程序:
public class HiddenIterator{
@GuardedBy(this)
private final Set<Integer> set = new HashSet<Integer>();
public synchronized void add(Integer i ){ set.add(i); }
public synchronized void remove(Integer i ){ set.remove(i); }
public void addTenThings(){
Random r = new Random();
for(int i = 0 ; i < 10; i++)
add(r.nextInt());
System.out.println("DEBUG : added ten elements to" + set);
}
}
表面上看起来十分的安全,add和remove两个方法都加上了锁。但是其实这里面隐藏了对容器的迭代操作:编译器将字符串的连接操作转换为调用StringBuilder.append(Object),而这个方法又会调用容器的toString方法,标准容器的toString方法将迭代容器,并在每个元素上调用toString来生成容器的格式化表示。
addTenThings可能会抛出ConcurrentModificationException,因为在生成调试消息的过程中,toString将对容器进行迭代。当然真正的问题在于HiddenIterator不是线程安全的。在使用println中的set之前必须首先获取HiddenIterator的锁。如果HiddenIterator用synchronizedSet来包装HashSet,并且对同步代码进行封装,那么就不会发生这种错误。
正如封装对象的状态有利于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略
其实,容器的hashCode和equals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或者是键值时,就会出现这种情况。同样,containsAll、removeAll和retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都会导致ConcurrentModificationException。
标准容器的toString方法将迭代容器