迭代和ConcurrentModificationException
在使用迭代器或for-each对同步容器(Vector和Hashtable)进行迭代时,倘若容器在迭代期间被修改,会抛出一个ConcurrentModificationException异常。避免这个异常的常用方法是给迭代加锁,例:
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
synchronized(list){
for(E e : list){
dosomething(e);
}
}
这意味着在迭代期间其他线程无法访问容器,需要等待至迭代结束,这降低了并发度;除此之外,如果在迭代期间执行的操作又依赖于其他的锁,这就产生了死锁的风险。
另一种办法是复制容器,但这同样会增加性能开销。
除了显式的迭代,一些容器执行的方法也会隐式地或间接地进行迭代,如toString、hashCode、equals、containsAll、removeAll、retainAll等。
并发容器
java中为一些常用容器实现了相应的并发容器,它们具有更好的并发性,且避免了在迭代期间对容器进行加锁或复制。如ConcurrentHashMap、CopyOnWriteArrayList、 ConcurrentLinkedQueue等。这里简单介绍ConcurrentHashMap,它通过更加细化的锁(分离锁,将容器分成多个部分,每部分由单独的锁来负责)来实现并发访问,读和写可以并发访问Map,多个线程可以并发修改Map。对于并发容器,向size、isEmpty这样的操作被弱化了,因为并发环境下容器处于不断变化之中,获取的可能是过期值;并发容器对最重要操作如get、put、containsKey、remove等进行了优化。它的唯一缺点就是无法独占访问加锁。ConcurrentMap接口还实现了常见复合操作,如缺少即加入、相等便移除、相等便替换等。
阻塞队列与生产者-消费者模式
阻塞队列:若队列已满,则插入操作阻塞直到有空间可用再执行;若队列为空,则获取元素的操作阻塞直到队列不为空。阻塞队列常用于对资源的管理。
生产者-消费者模式:基于阻塞队列,生产者把数据放入队列,消费者从队列中取数据,彼此独立,实现了生产者、消费者代码的解耦。
java类库中有BlockingQueue的实现,有FIFO队列如LinkedBlockingQueue和ArrayBlockingQueue;还有优先队列PriorityBlockingQueue,通过Comparator或元素实现的Comparable进行排序。
生产者-消费者模式实例:文件搜索和归档建立索引。代码:
public class ProducerConsumer {
//生产者:搜索文件
static class FileCrawler implements Runnable {
private final BlockingQueue<File> fileQueue;
private final FileFilter fileFilter;
private final File root;
public FileCrawler(BlockingQueue<File> fileQueue,
final FileFilter fileFilter,
File root) {
this.fileQueue = fileQueue;
this.root = root;
this.fileFilter = new FileFilter() {
public boolean accept(File f) {
return f.isDirectory() || fileFilter.accept(f);
}
};
}
private boolean alreadyIndexed(File f) {
return false;
}
public void run() {
try {
crawl(root);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void crawl(File root) throws InterruptedException {
File[] entries = root.listFiles(fileFilter);
if (entries != null) {
for (File entry : entries)
if (entry.isDirectory())
crawl(entry);
else if (!alreadyIndexed(entry))
fileQueue.put(entry);
}
}
}
//消费者:归档文件,建立索引
static class Indexer implements Runnable {
private final BlockingQueue<File> queue;
public Indexer(BlockingQueue<File> queue) {
this.queue = queue;
}
public void run() {
try {
while (true)
indexFile(queue.take());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public void indexFile(File file) {
// Index the file...
};
}
private static final int BOUND = 10;
private static final int N_CONSUMERS = Runtime.getRuntime().availableProcessors();
//启动线程:让生产者、消费者开始工作
public static void startIndexing(File[] roots) {
BlockingQueue<File> queue = new LinkedBlockingQueue<File>(BOUND);
FileFilter filter = new FileFilter() {
public boolean accept(File file) {
return true;
}
};
for (File root : roots)
new Thread(new FileCrawler(queue, filter, root)).start();
for (int i = 0; i < N_CONSUMERS; i++)
new Thread(new Indexer(queue)).start();
}
}
双端队列与窃取工作模式
java类库中还实现了双端队列Deque和BlockingDeque,与之对应的是窃取工作模式:每个消费者都拥有自己的一个双端队列,当一个消费者完成了自己队列的全部工作,就会获取其他消费者的双端队列末尾的任务。
Synchronizer
Synchronizer是一个根据自身状态调节线程的控制流。阻塞队列就是Synchronizer的一种。除此之外常见的Synchronizer还有闭锁、信号量、关卡等。java类库中有许多可供使用的Synchronizer类。
闭锁
闭锁机制要求等待某一事务或条件达成之前阻塞所有线程,事务或条件达成之后释放所有线程。例如在执行某一计算之前等待初始化资源。
例:
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
final CountDownLatch startGate = new CountDownLatch(1); //开始阀门
final CountDownLatch endGate = new CountDownLatch(nThreads); //结束阀门
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
startGate.await(); //await方法会阻塞线程,等待计数器达到0
try {
task.run();
} finally {
endGate.countDown(); //结束阀门用于等待至最后一个线程完成任务,以便计时
}
} catch (InterruptedException ignored) {
}
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end - start;
}
}
上面的例子用于计算线程并发执行的时间,开始阀门释放所有线程,直到最后一个线程执行结束结束阀门关闭。
信号量
计数信号量用于限制访问某一资源的所有活动的数量,或同时执行某一操作的数量,此外还可以将所有容器变为有限阻塞容器,常用于实现资源池。
限制容器边界例:
public class BoundedHashSet <T> {
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound) {
this.set = Collections.synchronizedSet(new HashSet<T>());
sem = new Semaphore(bound); //设置边界大小
}
public boolean add(T o) throws InterruptedException {
sem.acquire(); //获得许可,若没有可用许可则阻塞
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if (!wasAdded)
sem.release(); //释放许可
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if (wasRemoved)
sem.release();
return wasRemoved;
}
}
Semaphore管理一个有效的许可集,当活动执行的时候会后的一个许可,结束之后释放许可;若没有可用许可acquire会被阻塞。
关卡
与闭锁不同,关卡等待的是线而不是事务。只有当所有线程都到达关卡点的时候会释放所有线程,这之前到达关卡点的线程会被阻塞。这种机制常常在并行计算中被使用:一个任务被划分成多个子任务并行执行,但只有当所有子任务完成之后才能进入下一步。
并发编程的规则与总结
- 解决并发问题的根本在于协调对对可变状态的访问,可变状态越少,保证线程安全越容易
- 尽量将域声明为final的,除非它是可变的
- 不可变对象是线程安全的
- 在对象中封装同步
- 用锁来保护每一个可变变量
- 在运行复合操作期间持有锁
- 在设计过程中就要考虑线程安全,或在文档中说明它不是线程安全的
- 对同步策略进行文档化
参考文献:《Java并发编程实战》