您的位置:首页 > 职场人生

黑马程序员——多线程9:其他线程技术-下

2015-09-02 22:34 633 查看
------Java培训、Android培训、iOS培训、.Net培训、期待与您交流! -------

5 读写锁

5.1 简介

我们曾经在《多线程6:线程间通信》中,介绍过JDK1.5版本中线程互斥与通信的新技术——Lock和Condition接口,两者分别用于代替synchronized代码块,以及锁对象上的wait、notify、notifyAll方法。那么这两个类所在的java.util.concurrent.locks包中,除了提供用于一般线程同步功能的ReentrantLock锁以外,还提供了用于保护数据读取/写入线程间互斥的读写锁接口ReedWriteLock,及其实现类ReentrantReadWriteLock。
读写锁分为读锁和写锁,分别可以通过ReentranReadWriteLock的readLock和writeLock方法获取。实际上读锁和写锁分别是ReentrantReadWriteLock的两个内部类,全称分别是ReentrantReadWriteLock.ReadLock和ReentrantLockReadWriteLock.WriteLock。

5.2 读写锁特点

读写锁最大的特点是:多个读锁之间不互斥,读锁与写锁互斥,写锁与写锁互斥。乍看起来,两者的关系非常混乱,不过这都是Java虚拟机控制的,我们只需要上好相应的锁即可。之所以要分别定义读锁和写锁,以及将两者之前的关系定义得如此复杂,是因为在实际开发中经常需要有多个读取线程和写入线程同时对一个共享数据进行操作。那么在只有读取线程读取数据的情况是不会发生线程安全问题的,因为无论有多少个线程并发读取数据,数据本身是不会产生任何变化的,因此读锁之间不必互斥。然而,一旦有写入线程加入进来,事情就会变得很麻烦。假如有一个读取线程正在读取共享数据,还没读取完毕就有一个写入线程横插进来,将数据修改,那么读取线程就无法读取到原来的数据。再比如,写入线程A正在修改数据时,写入线程B又将数据修改为另一个值,由于数据最终的只能存储其中一个,因此这种情况也是不允许的。为了避免上述线程安全问题的发生,读写锁之间就被定义为了前述特点。

5.3 基本应用演示

下面我们就来演示读写锁的基本使用方法。演示代码的基本思路是定义一个定义一个共享数据封装到一个队列类Queue中,该类提供了用于读取和写入数据的方法——put和get。创建6个线程,其中三个负责获取数据,另外三个复写写入数据,观察在并发写入与读取数据过程中是否发生线程安全问题。为对比演示读写锁使用前后代码的不同执行效果,先将操作读写锁的代码注释掉。代码如下。
代码1:

import java.util.Random;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockTest {

//实现对内部数据进行读写的队列
private class Queue {
private Object data = null;

private boolean flag = true;
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

//数据获取
public void get(){
//lock.readLock().lock();

try {
System.out.println(Thread.currentThread().getName()+ "be ready to read data!");
Thread.sleep((long)(Math.random()*10000));
System.out.println(Thread.currentThread().getName()+ "have read data : "+data);
} catch (InterruptedException e) {
e.printStackTrace();
}finally{
//lock.readLock().unlock();
}
}

//数据写入
public void put(Object data){
//lock.writeLock().lock();

try{
System.out.println(Thread.currentThread().getName()+ "be ready to write data!");
Thread.sleep((long)(Math.random()*10000));

this.data = data;

System.out.println(Thread.currentThread().getName() + "have write data: " + data);
}catch(InterruptedException e){
e.printStackTrace();
}finally{
//lock.writeLock().unlock();
}
}
}

public static void main(String[]args) {
//创建队列
final Queue queue = new ReadWriteLockTest().new Queue();
//开启6个线程,其中三个负责写入数据,另外三个负责读取数据
for(int i=0; i<3; i++){
new Thread(new Runnable(){

@Override
public void run() {
while(true){
queue.put(newRandom().nextInt(10000));
}
}

}).start();

new Thread(new Runnable(){

@Override
public void run() {
while(true){
queue.get();
}
}

}).start();
}
}
}
执行结果为:

Thread-5 be ready to read data!
Thread-4 have write data: 8409
Thread-4 be ready to write data!
Thread-2 have write data: 4149
Thread-2 be ready to write data!
Thread-0 have write data: 6988
以上是部分执行结果,显然在发生读写线程安全问题。比如,线程5准备读取数据而还未读取时,线程4就写入了数据;线程4准备写入数据时,线程2却抢先将数据修改等等。以上问题当然是不能允许发生的。
实际上,以上线程安全问题利用普通的ReentrantLock锁对象也是可以解决的,但是这种方式的读写效率不高,因为无论是读取操作还是写入操作,只要有线程操作共享数据,其他线程都将无法进行读取或写入,然而读取线程之间却不需要互斥,因此读写锁也就很好的避免了这个问题。
接下来,我们将代码1中被注释的代码也加入到正常的代码执行当中,观察执行结果,不再发生读写线程安全问题:读取数据时,不能修改数据;修改数据时,不能读取数据;而读取线程之间并不互斥。
代码说明:
读写锁是不能直接手动创建的,只能创建ReentranReadWriteLock对象,通过调用readLock和writeLock方法,分别获得读锁与写锁。至于读写锁上锁与解锁的方式,与ReentranLock是相同的这里不再赘述。

5.4 读写锁实际应用实例

以上知识对读写锁最基本最简单应用一个介绍,那么在ReentranReadWriteLock类的API文档中,给出了一个利用读写锁实现数据缓存功能的代码,体现了读写锁在实际开发中的应用。那么在结合这段代码说明读写锁功能之前,我们先来说说数据缓存的应用背景和基本原理。
在利用Hibernate技术操作数据库时,假设我们想要通过用户的ID号,获取到对应的用户对象(User),这通常可以通过两种方法实现——load和get。调用get方法时,如果数据库中没有指定ID的User对象,则返回null,否则直接返回该User对象;而调用load方法所返回的并非是User对象本身,而是User类的一个代理类对象。以下伪代码可以表示这个代理类的运作原理。
代码2:

User$Proxy extends User {
//id即为调用load方法时传递的id值
private Integer id = id;
private User realUser;

//构造方法中为realUser进行初始化
User$Proxy() {
//若realUser为空则到数据库中查询
if(realUser == null) {
realUser = session.get(id);
//若数据库中确实2不存在指定用户对象,则抛出异常
if(realUser == null)
throw new Exception();
}
}
}
以上User类的代理类User$Proxy就相当于是一个缓存类,如果某个用户对象从来未被访问过,那么当通过load方法进行第一次访问时,则需要查询数据库获该User对象,并缓存到User代理中,那么下次再次访问此User对象时,就不必再查找数据库了,而是直接从User代理中获取即可。

了解数据缓存的一个简单原理以后,我们再来看一看ReentrantReadWriteLock类的API文档中对读写锁应用的演示代码。
代码3:

class CachedData {
Object data;
volatile boolean cacheValid;
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

void processCachedData() {
rwl.readLock().lock();
//读取数据以前,先判断标记是否为真
//标记为真,表示数据已经缓存,否则,数据为空
if (!cacheValid) {
//Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
//Recheck state because another thread might have acquired
//   write lock and changed state before we did.
//再次判断数据是否为空
//若确为空,则创建或者获取数据,并将标记置为真
if(!cacheValid) {
data= ...
cacheValid = true;
}
//Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
rwl.writeLock().unlock();// Unlock write, still hold read
}

use(data);
rwl.readLock().unlock();
}
}
在执行processCachedData方法以前,首先锁定了读锁。这是因为默认所有线程都是来读取数据的。但如果是首次执行该方法,那么数据就没有被缓存过,因此标记为假。此时说明数据为空,需要写入数据,因此第一个if代码块中,首先将读锁解锁,然后锁定写锁,防止多个线程同时写入数据的问题。继续向下执行,再次判断数据是否为空,此时才真正写入数据,并将标记置为真。这里之所以需要再次判断标记是因为,当某个线程正在写入数据时,可能有多个线程由于写锁锁定而处于等待状态(因为起初只锁定了读锁,因此所有线程都被允许执行到第一次读锁解锁处),如果没有第二次if判断,那么当第一个线程写入数据完毕,将写锁解锁后,其他线程就会依次向下执行而不断写入新的数据,从而出现了线程安全问题。数据写入完毕以后,再次将读锁锁定,将写锁解锁。最终数据使用完毕以后(use(data);),读锁也将被解锁。

以上代码演示了利用读写锁设计了一个数据缓存类,主要的设计目的就是当数据为空时可以保证只有一个线程写入数据,而除此以外的所有线程只能读取数据。
这里我们还要提醒大家,以上所谓的数据缓存类并非是缓存系统,数据缓存类是针对某一个数据的读取和写入操作,而缓存系统可以用于大量数据的读写操作。那么有一类面试题就是令面试者当场设计一个缓存系统。下面我们就来设计一个简单的缓存系统。

5.5 缓存系统

缓存系统与数据缓存类的思路有些相似,还是以查询数据库为例,缓存系统中有一个用于存放多个数据的容器(可以是数组,或者是集合等等),那么每次外部调用缓存系统获取某个数据时,首先到数据容器中查找是否包含该数据,如果有则无需查找数据库,而直接返回数据;否则就到数据库中查找。因此这样的一个缓存系统,最主要的设计目的就是为提高查询性能——因为不一定每次查询都要依赖于数据库。
当然缓存系统同样要考虑多线程访问的安全问题——当多个线程同时通过缓存系统访问某个数据时,若数据为空,则必须保证只能有一个线程去到数据库中查询并获取数据(可简单理解为写入数据)。代码如下。
代码4:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CacheDemo {

private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

//数据容器
private Map<String, Object> cache = new HashMap<String,Object>();

public Object getData(String key) {
Object value = null;
try {
lock.readLock().lock();
value = cache.get(key);//首先从容器中获取指定数据
if (value == null) {
lock.readLock().unlock();
try {
lock.writeLock().lock();
//若容器中不存在指定数据,则到数据库中查询
if (value == null) {
value = new Object();//实际代码应该是到数据库中查询
}
} finally {
lock.readLock().lock();
lock.writeLock().unlock();
}
}
} finally {
lock.readLock().unlock();
}

return value;
}
}
代码4的基本思路与代码3是相同的,当有获取某个数据的外部请求时,首先判断本地是否缓存有该数据,若本地未缓存,则到数据库中获取,再写入到本地中。在同一时间只能有一个线程进行写入操作,并且为了避免多个线程的反复写入数据,使用双if判断的设计。

6 四种同步器恐惧

本节共介绍四个4个线程同步器工具,它们分别是Semaphore、CountDownLatch、CyclicBarrier、Exchannger等,均包含于java.util.concurrent包中,可以利用它们的特殊功能协助实现常见的专用同步功能。

6.1 Semaphore

1) 简介

我们首先来看Semaphore类的API文档。
描述:
一个计数信号量。从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个acquire(),然后再获取该许可。每个release()添加一个许可,从而可能释放一个正在阻塞的获取这。 Semaphore通常用于限制可以访问某些资源(物理或逻辑的)线程数目。
构造方法:
public Semaphore(int permits):创建具有给定的许可数和非公平的公平设置的Semaphore。参数permits就是指定的许可数。
public Semaphore(int permits, boolean fair):创建具有给定的许可数和给定的公平设置的Semaphore。当变量fair的值为true时,那么许可的分发按照先进先得的原则;否则,如果为false,那么谁先被分配了执行权则由谁获得许可。

方法:
public void acquire() throwsInterruptedException:从此信号量获取一个许可,在提供一个许可前一直将线程阻塞,否则线程被中断。获取一个许可(如果提供了一个)并立即返回,将可用的许可数减1。
public void release():释放一个许可,将其返回给信号量。
从以上描述可知Semaphore同步器的主要功能就是控制活动线程的数量。就好比,Semaphore中存有指定数量的许可证,只有获取到许可证的线程才能处于活动状态,如果许可证分发完毕,若还有线程请求许可证就要被阻塞,直到其他线程释放一个许可证。其中,acquire方法用于请求获取一个许可证,release方法用于释放一个许可证。当然,Semaphore本身作为一个能够被并发访问的共享资源,也是线程安全的。
可以举一个厕所隔间的例子。比如一个厕所中有5个隔间,就好比是5个许可。某个时间同时有6个人来上厕所,那么只能是前5个人先使用隔间,第6个人就得等待,直到前5个人中有一个人使用完毕,释放许可,第6个人才能进去。这个例子非常生动形象的说明了Semaphore的功能。

2) 应用演示

下面我们通过一个演示代码,说明Semaphore同步器的使用方法。代码如下。
代码5:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class SemaphoreTest {

public static void main(String[] args) {
int MaxAvailablePermits = 3;//许可数

ExecutorService threadPool = Executors.newCachedThreadPool();
final Semaphore semaphore = new Semaphore(MaxAvailablePermits);
//创建10个线程,来争夺3个许可
for(int i=0; i<10; i++){
Runnable command = new Runnable(){

@Override
public void run() {
try{
//申请获取执行许可
//若没有执行许可,则等待
semaphore.acquire();
}catch(InterruptedException e){
e.printStackTrace();
}

Thread thread = Thread.currentThread();
System.out.println("线程"+thread.getName()+" 进入,当前已有 "
+(MaxAvailablePermits-semaphore.availablePermits())+
"个线程并发");

try{
Thread.sleep((long)(Math.random()*10000));
} catch(InterruptedException e){
e.printStackTrace();
}

System.out.println("线程"+thread.getName()+" 即将离开");

//释放执行许可
semaphore.release();

System.out.println("线程"+thread.getName()+" 已离开,当前还有 "
+(MaxAvailablePermits-semaphore.availablePermits())+
"个线程并发");
}

};

threadPool.execute(command);
}

threadPool.shutdown();
}
}
执行结果为:
线程 pool-1-thread-1 进入,当前已有2 个线程并发
线程 pool-1-thread-3 进入,当前已有3 个线程并发
线程 pool-1-thread-2 进入,当前已有2 个线程并发
线程 pool-1-thread-2 即将离开
线程 pool-1-thread-4 进入,当前已有3 个线程并发
线程 pool-1-thread-2 已离开,当前还有3 个线程并发
线程 pool-1-thread-4 即将离开
线程 pool-1-thread-5 进入,当前已有3 个线程并发
线程 pool-1-thread-4 已离开,当前还有3 个线程并发
线程 pool-1-thread-3 即将离开
线程 pool-1-thread-6 进入,当前已有3 个线程并发
线程 pool-1-thread-3 已离开,当前还有3 个线程并发
线程 pool-1-thread-1 即将离开
线程 pool-1-thread-7 进入,当前已有3 个线程并发
线程 pool-1-thread-1 已离开,当前还有3 个线程并发
线程 pool-1-thread-7 即将离开
线程 pool-1-thread-8 进入,当前已有3 个线程并发
线程 pool-1-thread-7 已离开,当前还有3 个线程并发
线程 pool-1-thread-5 即将离开
线程 pool-1-thread-5 已离开,当前还有2 个线程并发
线程 pool-1-thread-9 进入,当前已有3 个线程并发
线程 pool-1-thread-9 即将离开
线程 pool-1-thread-10 进入,当前已有3 个线程并发
线程 pool-1-thread-9 已离开,当前还有3 个线程并发
线程 pool-1-thread-6 即将离开
线程 pool-1-thread-6 已离开,当前还有2 个线程并发
线程 pool-1-thread-8 即将离开
线程 pool-1-thread-8 已离开,当前还有1 个线程并发
线程 pool-1-thread-10 即将离开
线程 pool-1-thread-10 已离开,当前还有0 个线程并发
从结果来看,在同一时间段获取许可而具活动性的线程只有3个
代码说明:
(1) 通过构造方法指定Semaphore的许可书数。创建10个线程争夺这三个许可。每个线程在执行过程中,显示目前获取到许可的线程数。availablePermits方法返回,目前可获得的许可数,那么许可总数与该方法返回值的差值即为目前已获取到许可的线程数。其实Semaphore也相当于是互斥,只不过同步锁是有针对性的互斥,而Semaphore只是控制同一时间的活动线程数。
(2) Semaphore的还有一个重载构造方法,除了可以指定许可数量,还可以控制,许可获取的顺序,大家可以在创建Semaphore对象的时候,再传递一个布尔变量,将其值设置为true,再次执行以上代码,结果将会有所不同。
(3) Semaphore与同步锁最大的不同之处在于:哪个线程锁定了同步锁,就只能由哪个线程解锁;而不管哪个线程获得了Semaphore许可,都可以由其他任何线程调用release方法释放一个许可。

6.2 CyclicBarrier

1) 简介

我们还是先来阅读该类的API文档。
描述:
一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点。在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时CyclicBarrier很有用。因为该barrier在释放等待线程后可以重用,所以称它为循环的barrier。
构造方法:
public CyclicBarrier(int parties):创建一个新的CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,但它不会在启动barrier时指定预定的操作。其中参数parties用于指定参与线程的数量。
方法:
public int await() throwsInterruptedException, BrokenBarrierException:在所有参与者都已经再次barrier上调用await方法之前,将一直等待。
public int getNumberWaiting():返回当前在屏障处等待的参与者数目。此方法主要用于调试和断言。
CyclicBarrier的主要功能就是控制参与线程的执行节奏。在创建CyclicBarrier对象时,我们首先要指定一个参与线程的数量,就是构造方法的参数parties。当有某个线程调用了CyclicBarrier的await方法则会进入冻结状态,并且所有调用CyclicBarrier对象await方法而进入冻结状态的线程只有等到,等待线程的数量等于parties时,才能被同时唤醒。
可以举一个旅游的例子。比如,一个旅游团约定某月某日在某地某时某刻集合。那么先到的旅客就只能等待,只有所有人到齐以后才能出发(所有人到齐,相当于所有人等待)。到了某个景点后,约定在某时某刻在某地集合,那么先结束游玩的旅客就又要等待,只有旅客到齐以后才能继续前往下一个景点。

2) 应用演示

演示代码如下。
代码6:

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CyclicBarrierTest {

public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
final CyclicBarrier cyclicBarrier = new CyclicBarrier(3);//指定有参数线程数为3
//开启三个线程
for(int i=0; i<3; i++){
Runnable command = new Runnable(){

@Override
public void run() {
Thread thread = Thread.currentThread();

System.out.println("线程"+thread.getName()+" 即将出发");

try {
//令每个线程sleep随机的时间
//以此模拟不同线程执行任务的需要不同的时间
//那么先完成任务的线程就要先等待
Thread.sleep((long)(Math.random()*10000));

System.out.println("线程 "+thread.getName() +
" 到达集合点1, 当前已有" +
(cyclicBarrier.getNumberWaiting()+1)+
" 个到达,"+(cyclicBarrier.getNumberWaiting()== 2 ?
"都到齐了,继续走": "正在等待..."));
cyclicBarrier.await();

Thread.sleep((long)(Math.random()*10000));

System.out.println("线程 "+thread.getName() +
" 到达集合点2, 当前已有" +
(cyclicBarrier.getNumberWaiting()+1) +
" 个到达,"+(cyclicBarrier.getNumberWaiting()== 2 ?
"都到齐了,继续走": "正在等待..."));
cyclicBarrier.await();

Thread.sleep((long)(Math.random()*10000));

System.out.println("线程 "+thread.getName() +
" 到达集合点3, 当前已有" +
(cyclicBarrier.getNumberWaiting()+1) +
" 个到达,"+(cyclicBarrier.getNumberWaiting()== 2 ?
"都到齐了,继续走": "正在等待..."));
cyclicBarrier.await();

} catch(InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}

};

threadPool.execute(command);
}

threadPool.shutdown();
}
}
执行结果为:

线程 pool-1-thread-1 即将出发
线程 pool-1-thread-2 即将出发
线程 pool-1-thread-3 即将出发
线程 pool-1-thread-1 到达集合点1, 当前已有 1 个到达,正在等待...
线程 pool-1-thread-2 到达集合点1, 当前已有 2 个到达,正在等待...
线程 pool-1-thread-3 到达集合点1, 当前已有 3 个到达,都到齐了,继续走
线程 pool-1-thread-2 到达集合点2, 当前已有 1 个到达,正在等待...
线程 pool-1-thread-3 到达集合点2, 当前已有 2 个到达,正在等待...
线程 pool-1-thread-1 到达集合点2, 当前已有 3 个到达,都到齐了,继续走
线程 pool-1-thread-1 到达集合点3, 当前已有 1 个到达,正在等待...
线程 pool-1-thread-3 到达集合点3, 当前已有 2 个到达,正在等待...
线程 pool-1-thread-2 到达集合点3, 当前已有 3 个到达,都到齐了,继续走
代码说明:
执行结果表明,只有所有线程都处于等待状态以后,才能统一被唤醒,并继续向下执行,这样就能保证每个线程能够同时开始,并且不允许任何线程在先“到达”屏障点后,擅自继续执行。其中getNumberWaiting方法返回正在等待线程的个数,注意该方法内部的计数器从0开始计数,比如已经有一个线程等待时返回0,以此类推。

6.3 CountDownLatch

1) 简介

以下为CountDownLatch类的API文档。
描述:
一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。用给定的计数初始化CountDownLatch。由于调用了countDown()方法,所以在当前计数到达零以前,await()方法会一直受到阻塞。之后,会释放所有等待的线程,await的所有后续调用都将立即返回。这种现象只出现一次——计数无法被重置。如果需要重置计数,请考虑使用CyclicBarrier。CountDownLatch的一个有用特性是,它不要求调用countDown方法的线程等到计数到达零时才继续,而在所有线程都能通过之前,它只是阻止任何线程继续通过一个await。
构造方法:
public CountDownLatch(int count):构造一个用给定计数器初始化的CountDownLatch》
方法:
public void await() throwsInterruptedException:使当前线程在锁存器倒计时至零以前一直等待,除非线程被中断。如果当前计数为零,则此方法立即返回。如果当前计数大于零,则出于线程调度目的,将禁用当前线程,且在发生以下两种情况之一前,该线程将一直出于休眠状态:
1) 由于调用countDown()方法,计数达到零;
2) 或者,其他某个线程中断当前线程。
public void countDown():递减锁存器的计数,如果计数到达零,则释放所有等待的线程。如果当前计数大于零,则将计数减少。如果新的计数为零,出于线程调度的目的,将重新启用所有的等待线程。如果当前线程计数为零,则不发生任何操作。
对于CountDownLatch的用法,简言之就是,首先为CountDownLatch指定一个计数值。若干线程出于线程调用的目的调用CountDownLatch对象的await方法进入冻结状态。而这一冻结状态只有在,其他线程调用若干次countDown方法后,令计数值达到零时才能解除。
那么通过这个同步器工具可以实现,某一个线程等待其他若干线程,或者,若干线程等待某一个线程等功能。

2) 应用演示

演示代码如下。
代码7:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CountDownLatchTest {

public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
//创建两个CountDownLatch对象,分别用于控制主线程和若干子线程
final CountDownLatch mainOrder = new CountDownLatch(1);
final CountDownLatch subOrder = new CountDownLatch(3);
//开启3个子线程
for(int i=0; i<3; i++){
Runnable command = new Runnable(){

@Override
public void run() {
try {
Thread thread =Thread.currentThread();

System.out.println("线程 "+thread.getName()+
"准备接受主线程命令...");
//3个子线程将在此全部冻结
//直到mainOrder的计数为0
mainOrder.await();

System.out.println("线程 "+thread.getName()+
"已接受主线程命令,并准备回应主线程的命令");

Thread.sleep((long)(Math.random()*10000));
//三个子线程分别调用countDown方法
//令其计数为0,唤醒主线程
subOrder.countDown();
System.out.println("线程 "+thread.getName()+
"已向主线程发送命令");

} catch(InterruptedException e) {
e.printStackTrace();
}
}
};

threadPool.execute(command);
}

//主线程
try{
Thread thread = Thread.currentThread();

Thread.sleep((long)(Math.random()*10000));
System.out.println("线程 "+thread.getName()+"准备向子线程发送程命令...");
//唤醒所有3个正在等待的子线程
mainOrder.countDown();

System.out.println("线程 "+thread.getName()+
"已向子线程发送命令,并准备接受子线程的回复...");
//主线程在此等待
//直到subOrder计数为0
subOrder.await();

System.out.println("线程 "+thread.getName()+"已接收子线程回复的命令");
} catch(InterruptedException e){
e.printStackTrace();
}

threadPool.shutdown();
}
}
执行结果为:
线程 pool-1-thread-1准备接受主线程命令...

线程 pool-1-thread-2准备接受主线程命令...
线程 pool-1-thread-3准备接受主线程命令...
线程 main准备向子线程发送程命令...
线程 main已向子线程发送命令,并准备接受子线程的回复...
线程 pool-1-thread-2已接受主线程命令,并准备回应主线程的命令
线程 pool-1-thread-1已接受主线程命令,并准备回应主线程的命令
线程 pool-1-thread-3已接受主线程命令,并准备回应主线程的命令
线程 pool-1-thread-3已向主线程发送命令
线程 pool-1-thread-2已向主线程发送命令
线程 pool-1-thread-1已向主线程发送命令
线程 main已接收子线程回复的命令
代码说明:
(1) 以上代码中,共创建了两个CountDownLatch对象,分别被主线程和子线程操控。mainOrder的给定计数为1,因此主线程调用一次countDown即可令其计数达到0;而子线程有3个,因此subOrder的给定计数为3,每个子线程调用一次countDown,令subOrder的计数达到0.
(2) 代码7执行过程为如下:首先开启三个子线程,然后先后调用mainOrder的await全部进入冻结状态。主线程运行后,调用mainOrder的countDown,由此mainOrder的计数达到0,将所有子线程唤醒。此后主线程调用subOrder的await方法,进入冻结状态,而三个子线程分别调用countDown,将subOrder的计数减为0,最终唤醒主线程,至此4个线程全部执行完毕。
(3) 以上代码可以类比一场赛跑。当所有运动员都各就各位等待裁判发令,那么这些运动员就好比是,由裁判这个CountDownLatch对象冻结的多个线程一样,裁判发射令枪就好比是将计数减1,此时所有等待的子线程全部唤醒,比赛开始。那么这个时候裁判进入冻结,因为他在等待所有运动员达到终点,才能结束比赛。当所有运动员逐个通过终点时,就好比是每个线程调用一次countDown方法,当最后一个选手通过终点时,计数就达到了0,裁判被唤醒记录每个选手的用时,至此比赛结束。

6.4 Exchanger

1) 简介

API文档如下。
描述:
可以在对元素进行配对和交换的线程的同步点。每个线程将条目上的某个方法呈现给exchange方法,与伙伴线程进行匹配,并且在返回时接收器伙伴的对象。
构造方法:
public Exchanger():创建一个新的Exchanger。
方法:
public V exchanger(V x) throwsInterruptedException:等待另一个线程达到此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。
如果另一个线程已经在交换点等待,则出于线程调度目的,继续执行此线程,并接收当前线程传入的对象。当前线程立即返回,接收其他线程传递的交换的对象。
如果还没有其他线程在交换点等待,则出于调度目的,禁用当前线程,且在发生以下两种情况之一前,该线程将一直处于休眠状态:
1) 其他某个线程进入交换点;
2) 或者,其他某个线程中断当前线程。
简而言之,当本线程先调用exchange方法时,就进入冻结状态,直到其他线程也调用该方法时,迅速交换双方的数据对象;如果已有某个线程调用exchange方法而处于等待状态,则本线程调用该方法后将迅速与该等待线程交换数据对象。因此Exchanger同步器,主要用于线程间数据的交换。
注意到,Exchanger类可以定义泛型,其类型参数就是两线程通过Exchanger对象要交换的数据对象类型。

2) 应用演示

演示代码如下。
代码8:

import java.util.concurrent.Exchanger;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExchangerTest {

public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
final Exchanger exchanger = new Exchanger();

threadPool.execute(new Runnable(){

@Override
public void run() {
//本线程将要交换出去的数据
String data ="methamphetamine";
Thread thread =Thread.currentThread();

System.out.println(thread.getName()+" 正在准备交换数据: "+data);

String exchangedData = null;
try {
//随机等待时间,模拟一个线程等待另一线程的效果
Thread.sleep((long)(Math.random()*10000));
//交换数据,由于没有定义泛型,因此需要类型转换
exchangedData =(String)exchanger.exchange(data);
} catch (InterruptedExceptione) {
e.printStackTrace();
}

System.out.println(thread.getName()+
"交换得到的数据为 : "+exchangedData);
}

});

threadPool.execute(new Runnable(){

@Override
public void run() {
String data ="marijuana";
Thread thread =Thread.currentThread();

System.out.println(thread.getName()+" 正在准备交换数据: "+data);

String exchangedData = null;
try {
Thread.sleep((long)(Math.random()*10000));
exchangedData =(String)exchanger.exchange(data);
} catch (InterruptedExceptione) {
e.printStackTrace();
}

System.out.println(thread.getName()+
"交换得到的数据为 : "+exchangedData);
}

});

threadPool.shutdown();
}
}
执行结果为:
pool-1-thread-1 正在准备交换数据: methamphetamine
pool-1-thread-2 正在准备交换数据: marijuana
pool-1-thread-1 交换得到的数据为: marijuana
pool-1-thread-2 交换得到的数据为: methamphetamine
代码说明:
两线程由于sleep时间的不同,因此总有线程会先调用exchange方法而进入等待状态,直到另一个线程从sleep中唤醒,才调用exchange方法,两者才能够迅速完成数据的交换。

7 可阻塞队列

我们曾经在介绍利用两个Condition对象,实现两路路线程等待唤醒的功能时,提到过同样可以利用两个Condition对象实现具备可阻塞特性的队列。由于java.util.concurrent包中提供了现成的可阻塞队列工具类,因此不必手动定义。虽然在前面的博客中介绍过可阻塞队列的特点,这里为了说明方便再简单介绍其原理。

7.1 可阻塞队列原理

队列,英文称为Queue,顾名思义,就像人们排队买票一样,先到的人先买票,后到的人后买票,因此队列这种数据容器的基本特点就是先存储的数据,要先被取出(FIFO)。对于固定大小的队列(可以理解为数组),当数据被存满以后,就要再返回来从第一个数据位开始存储,这就需要一个专门用于存储数据使用的指针变量,比如我们可以称之为putptr。同样,当取到最后一个位置上的数据后,再次取出数据时,应该再返回来从头角标位取数据,这也需要一个指针,可以称之为takeptr。那么如何判断队列中还有可取出的数据呢?此时还要再定义一个变量count,每存储一个数据就自增一次,每取出一个数据就自减一次,因此总是表示队列中还未被取出数据的个数。
那么上述对是队列的基本原理进行了简要介绍,而在有些情况下,我们希望每个数据都能够取出,而不被新存储的数据覆盖掉,或者不希望因队列中没有可用数据,而取出“null”。因此当可取出数据的数量count等于队列长度,或等于0时,应作出一些措施阻止继续存储,或者继续取出数据的操作。那么可选择措施包括:阻塞(等待)、报错、或者返回一些特殊值,表示存储或者取出失败。
那么java.util.concurrent包中已经提供了实现阻塞队列功能的工具类——ArrayBlockingQueue,下面我们就来简单介绍该类的特点,并演示其基本的用法。

7.2 ArrayBlockingQueue

7.2.1 简介

我们先来看一看ArrayBlockingQueue接口BlockingQueue的API文档。
描述:
支持两个附加操作的Queue,这两个操作是:获取元素时等待队列变为非空,以及存储元素时等待空间变得可用。BlockingQueue方法以四种形式出现,对于不能立即满足但是可能在将来某一时刻可以满足的操作,这四种形式的处理方式不同:第一种是抛出一个异常,第二种是返回一个特殊值(null或false,具体取决于操作),第三种是在操作可以成功前,无限期地阻塞当前线程,第四种是在放弃前只在给定的最大时间限制内阻塞。下表中总结这些方法:

操作
抛出异常
特殊值
阻塞
超时
插入
add(e)
offer(e)
put(e)
offer(e, time, unit)
移除
remove()
poll()
take()
poll(time, unit)
检查
element()
peek()
不可用
不可用
从上表中可以看出,阻塞队列对于元素的操作,按照操作失败的措施分为3类:要么抛出异常,要么给出特殊值,要么就会令线程等待。对于这三类操作方式的选择应按照实际序曲进行选择。
注意,BlockingQueue不接受null元素。试图add、put或offer一个null元素时,某些实现会抛出NullPointerException。null被用作指示poll操作失败的警戒值。
由于ArrayBlockingQueue实现了BlockingQueue,因此继承了以上所有对元素的操作方式。
构造方法:(ArrayBlockingQueue)
public ArrayBlockingQueue(int capacity):创建一个带有给定的(固定)容量和默认访问策略的ArrayBlockingQueue。参数capacity用于指定队列的长度。
方法:
由于这里主要介绍ArrayBlockingQueue的阻塞特性,因此仅介绍阻塞相关的方法。
public void put(E e) throwsInterruptedException:将指定的元素插入此队列的尾部,如果该队列已满,则可等待可用的空间。当队列存满元素时(可取出元素数量等于队列长度),继续尝试添加元素,将会使得当前线程的阻塞,也就是进入等待状态,直到空出可用位置。由于ArrayBlockingQueue可以定义泛型,因此参数e的类型就是类型参数类型E。
public E take() throws InterruptedException:获取并移除此队列的头部,在元素变得可用之前一直等待(如果有必要)。如果可取出元素数量为0时,继续尝试取出元素,将会使得当前线程阻塞,也就是进入等待状态,直到可去除元素数量大于0。返回值类型同样是泛型类型参数类型。

7.2.2 应用演示

1) 基本使用方法

我们通过下面的代码演示ArrayBlockingQueue的基本使用方法。演示的内容为,6个线程同时操作一个阻塞队列,其中4个线程负责取数据,2个线程负责存数据。如果存储速度较快,则存储线程可能出现阻塞现象;反之,如果取出速度较快,取出线程可能会出现阻塞现象。代码如下。
代码9:

import java.util.Random;
importjava.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ArrayBlockingQueueTest {

public static void main(String[] args) {
BlockingQueue<Integer> queue = newArrayBlockingQueue<Integer>(3);
Random rand = new Random();

//4个取出线程,2个存储线程
for(int i=0; i<2; i++) {
new Thread(new Runnable() {

@Override
public void run() {
while (true) {
try {
Thread thread =Thread.currentThread();

Thread.sleep((long)(Math.random() * 10000));
System.out.println(thread.getName()+" 准备存储元素");

Integer data =rand.nextInt(100);
queue.put(data);
System.out.println(thread.getName()+
" 已存储数据:"+data+",队列中目前有:"+
queue.size()+"个数据");
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}

}).start();
new Thread(new Runnable() {

@Override
public void run() {
while (true) {
try {
Thread thread =Thread.currentThread();

Thread.sleep((long)(Math.random() * 10000));
System.out.println(thread.getName()+" 准备存储元素");

Integer data =rand.nextInt(100);
queue.put(data);
System.out.println(thread.getName()+
" 已存储数据:"+data+",队列中目前有:"+
queue.size()+"个数据");
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}

}).start();
new Thread(new Runnable() {

@Override
public void run() {
while (true) {
try {
Thread thread =Thread.currentThread();

Thread.sleep((long)(Math.random() * 10000));
System.out.println(thread.getName()+" 准备存储元素");

Integer data =queue.take();
System.out.println(thread.getName()+
" 已取出数据:"+data+",队列中目前有:"+
queue.size()+"个数据");
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}

}).start();
}
}
}
执行结果为:

Thread-0 准备存储数据
Thread-0 已存储数据:49,队列中目前有:1个数据
Thread-2 准备取出数据
Thread-2 已取出数据:49,队列中目前有:0个数据
Thread-2 准备取出数据
Thread-4 准备存储数据
Thread-4 已存储数据:48,队列中目前有:1个数据
Thread-2 已取出数据:48,队列中目前有:0个数据

Thread-0 准备存储数据
Thread-0 已存储数据:1,队列中目前有:3个数据
Thread-1 准备存储数据
Thread-0 准备存储数据
Thread-2 准备取出数据
Thread-2 已取出数据:59,队列中目前有:2个数据

Thread-1 准备存储数据
Thread-1 已存储数据:71,队列中目前有:3个数据
Thread-1 准备存储数据
Thread-3 准备存储数据

代码说明:
(1) 从以上执行结果明显可以看出,当队列存满时,存储线程存储的操作将会阻塞;而当队列为空时,取出线程的取出操作也将阻塞。
(2) 以上代码中put方法与take方法虽然可以实现互斥,但是6个线程的run方法中的方法体并不是同步的,因此显示的队列剩余元素个数可能不是正确的。但这并不妨碍对阻塞队列阻塞效果的演示。
(3) 可以通过设置不同的睡眠时间,使得阻塞效果更为明显。比如,可以将取出线程的睡眠时间固定为10ms,这样一来过快的取出操作,将导致取出线程总是处于阻塞状态,而队列中的元素将总是保持在0个或者1个。也可以将取出线程的睡眠时间固定为10000ms,那么过慢的取出操作,将导致存储线程总是处于阻塞状态,而队列中的元素总是保持在3个。

2) 利用阻塞队列实现线程间的等待唤醒机制

由于阻塞队列可以利用put和take方法实现线程的等待效果,这就好比是Condition的功能。比如,可以创建两个长度为1的阻塞队列,q1和q2,其中q2事先存放了一个元素。然后分别开启两个线程,t1和t2,t1与q1绑定,t2与q2绑定。首先两线程均尝试向各自的队列中存储元素,但是由于q2中已存有元素,因此t2阻塞,t1存储一个元素后得以继续向下执行。在t1结束执行以前,从q2中取出数据,此时t2的阻塞解除(相当于唤醒t2),此后当t1尝试循环执行run方法时,由于q1中已存有元素而阻塞。此时只有t2能够正常执行,由于之前put方法被阻塞,而在恢复活动后,必然先执行put方法向q2中存储一个元素,然后在结束执行以前,取出q1的元素,唤醒t1,而t2尝试循环执行时再次被阻塞,以此类推,循环往复。演示代码如下。
代码10:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public classThreadCommunicationByBlockingQueue {

private class Resource {
private int j = 100;

private BlockingQueue q1 = new ArrayBlockingQueue(1);
private BlockingQueue q2 = new ArrayBlockingQueue(1);

//构造代码块,用于事先向q2中存储一个元素
{
try {
q2.put(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public void increment() {
try {
q1.put(1);
} catch (InterruptedException e) {
e.printStackTrace();
}

for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()
+ ":"+ (j++)+" --> "+j);
}

try {
q2.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void decrement() {
try {
q2.put(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()
+ ":" + (j--)+" -->"+j);
}
try {
q1.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[]args) {
Resource resource = new ThreadCommunicationByBlockingQueue().newResource();

new Thread(new Runnable() {

@Override
public void run() {
while (true) {
resource.increment();
}
}

}).start();
new Thread(new Runnable() {

@Override
public void run() {
while (true) {
resource.decrement();
}
}

}).start();
}
}
执行结果为:
Thread-0:100 --> 101
Thread-0:101 --> 102
Thread-0:102 --> 103
Thread-0:103 --> 104
Thread-0:104 --> 105
Thread-0:105 --> 106
Thread-0:106 --> 107
Thread-0:107 --> 108
Thread-0:108 --> 109
Thread-0:109 --> 110
Thread-1:110 --> 109
Thread-1:109 --> 108
Thread-1:108 --> 107
Thread-1:107 --> 106
Thread-1:106 --> 105
Thread-1:105 --> 104
Thread-1:104 --> 103
Thread-1:103 --> 102
Thread-1:102 --> 101
Thread-1:101 --> 100
Thread-0:100 --> 101
Thread-0:101 --> 102
Thread-0:102 --> 103
Thread-0:103 --> 104
Thread-0:104 --> 105
Thread-0:105 --> 106
Thread-0:106 --> 107
Thread-0:107 --> 108
Thread-0:108 --> 109
Thread-0:109 --> 110

从执行结果来看,利用阻塞队列put、take方法的阻塞效果,同样可以实现线程间的等待唤醒机制。
代码说明:
(1) 代码10中,内部类Resource类利用构造代码块事先向阻塞队列q2中存储了一个元素,该操作会在创建Resource对象时同时完成。这里注意不要使用静态代码块,因为静态代码块在类加载期间就会执行,但是成员变量q2并非是静态的,只有在创建Resource对象时才能被创建并初始化。并且内部类Resource本身也不是静态的,因此该类的类加载操作只能在创建ThreadCommunicationByBlockingQueue外部类时完成,这同样与静态代码块在类加载期间执行相矛盾。
(2) 两个ArrayBlockingQueue的相互配合既实现了同步锁的作用,同时也扮演了Condition的角色,因此千万不要在阻塞队列的基础上又在increment和decrement方法内添加同步代码块,否则会出现死锁现象。原因是无论是Object类的wait方法还是Lock对象的await方法,都可以使得被冻结的线程暂时释放锁对象,这样其他活动线程得以获取锁而执行同步代码。虽然依靠put和take方法同样可以起到阻塞线程的效果,但是却无法使得当前线程释放锁,那么其他线程也就无法进一步执行起来,出现了双方互相等待的现象。
(3) 大家可以思考一个问题:能否通过一个BlockingQueue对象实现与上述代码同等的功能——既可以实现同步,还可以实现等待唤醒机制?答案是否定的。单个BlockingQueue只能实现同步功能却无法实现线程间通信。可以想象,当只有一个BlockingQueue的时候,两个线程同时尝试存储一个元素,那么谁获得执行权谁就能够执行put方法,这就相当于谁有执行权谁就锁定了锁对象,而另一个线程就被阻塞而等待。活动线程在执行完相应代码以前,将队列中的数据取出,此时两线程又可以对“锁”对象公平竞争了,同样谁被分配了执行权,谁就可以锁定“锁”对象。显然这样的一个过程仅仅保证了共享资源的线程安全,但线程之间是无法通信的。

8 同步集合

8.1 同步集合应用背景

我们曾经在《集合框架》系列博客中详细介绍过各种集合类的底层数据结构、原理,以及使用方法。我们说,这些集合类在单线程操作条件下时没有任何问题的,但一旦处于多线程并发访问的情况,就会出现线程安全问题——因为此时集合类对象就是多线程访问的共享资源,而操作共享资源的方法如果没有实现同步,出现线程安全问题是必然的。那么在这样的需求背景下,自身实现同步功能的集合类也就应运而生了。

8.2 普通线程出现线程安全的原因

1) Iterator的问题

我们曾经在《集合框架2:List集合》中提到过,利用Iterator迭代器对集合进行遍历的同时,是不能调用集合本身的删除方法删除集合中元素的,这样会造成安全问题,甚至抛出异常。下面我们就结合例程和ArrayList的源代码对这个问题的发生进行说明。例程代码如下。
代码11:

//先定义一个User类
public class User implements Cloneable {
private String name;
private int age;

public User(String name, int age) {
super();
this.name = name;
this.age = age;
}

public void setAge(int age){
this.age = age;
}
public String getName(){
return name;
}

@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + age;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}

@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
User other = (User) obj;
if (age != other.age)
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}

public String toString(){
return "{name:'" + name + "',age:" + age +"}";
}
}


//测试类
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.ArrayList;

public class ConcurrentCollectionTest {

public static void main(String[] args) {
//Collection<User> users = new CopyOnWriteArrayList<User>();
Collection<User> users = new ArrayList<User>();

users.add(new User("David",21));
users.add(new User("Peter",34));
users.add(new User("Cook",29));

for(Iterator<User> it = users.iterator(); it.hasNext();){System.out.println(1);
User user = it.next();
if("David".equals(user.getName())){
users.remove(user);
}
else{
System.out.println(user);
}
}
}
}
执行结果为:

Exception in thread "main"java.util.ConcurrentModificationException
atjava.util.ArrayList$Itr.checkForComodification(Unknown Source)
atjava.util.ArrayList$Itr.next(Unknown Source)
atconcurrentcollection.ConcurrentCollectionTest.main(ConcurrentCollectionTest.java:18)
通过Iterator对集合中的元素进行迭代的同时,尝试调用集合本身的remove方法就出现了并发修改异常——ConcurrentModificationException。如果将被删除元素指定为“Cook”,也会出现同样的问题,但如果删除“Peter”则没有任何异常发生。
我们先来说明产生ConcurrentModificationException的原因。在ArrayList的源代码中定义有一个名为modCount的成员变量,声明如下。
protected transient int modCount = 0;
这一变量其实是从父类AbstractList继承而来,其作用是用于记录对集合中元素进行增删操作的次数。比如,向元素中添加5个元素,又删除了3个元素,那么modCount值就是8。比如我们来看add方法的源代码。
代码12:

public boolean add(E e) {
ensureCapacityInternal(size + 1);  // Increments modCount!!
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
if(elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}

ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;

//overflow-conscious code
if(minCapacity - elementData.length > 0)
grow(minCapacity);
}
add方法执行期间,会首先调用ensureCapacityInternal方法,而该方法的末尾又会调用ensureExplicitCapacity方法,其中就对modCount变量进行了自增操作。

我们再来看看remove方法的源代码。
代码13:

//通过元素本身删除元素的remove方法
public boolean remove(Object o) {
if(o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
}else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
if(numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work
}
同样在remove方法在调用fastRemove方法时也对modCount进行了自增操作。

我们接着来看问题发生的源头——Iterator的源代码。
代码14:

private class Itr implementsIterator<E> {
int cursor;       // index of next element toreturn
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;

public boolean hasNext() {
return cursor != size;
}

@SuppressWarnings("unchecked")
publicE next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}

public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();

try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

@Override
@SuppressWarnings("unchecked")
publicvoid forEachRemaining(Consumer<? super E> consumer) {
Objects.requireNonNull(consumer);
final int size = ArrayList.this.size;
int i = cursor;
if (i >= size) {
return;
}
final Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) {
throw new ConcurrentModificationException();
}
while (i != size && modCount == expectedModCount) {
consumer.accept((E)elementData[i++]);
}
// update once at end of iteration to reduce heap write traffic
cursor = i;
lastRet = i - 1;
checkForComodification();
}

final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
Iterator内部定义有记录modCount值的成员变量expectedModCount,那么在初始化Iterator对象时就会将外部类ArrayList的modCount值赋给expectedModCount,那么代码11中expectedModCount初始化值就是3(添加了3个元素)。而从checkForComodification方法的声明可知,该方法将检查modCount与expectedModCount是否相等,如果不相等就会抛出ConcurrentModificationException。

那么在代码11中出现这一问题的原因是:调用Iterator对象的remove方法,其实就是调用ArrayList的remove方法,修改modCount值,然后在remove方法内的最后,将modCount赋值给expectedModCount。这样一来即使循环调用next或者remove方法时,经checkForComodification方法检查也不会发生异常。但是代码11中的删除操作是利用ArrayList的remove删除指定元素,虽然会修改modCount值,但是却不会把modCount赋值给expectedModCount,此时modCount与expectedModCount之间就产生了差异,就代码11来说,删除一个元素后,modCount是4,而expectedModCount依旧是3,那么再次调用checkForComodification时就会抛出异常了。因此利用Iterator遍历集合时,千万不要调用ArrayList的方法对元素进行操作。
那么上述问题不仅可能在单线程情况下会发生,在多线程并发访问同一个集合对象时更有可能发生。因此在多线程环境下应使用线程安全的集合类。

2) cursor与size

那么为什么当我们删除“Peter”时却不会抛出任何异常呢?Iterator内定义有一个名为cursor的成员变量,其初始化值为0,指向集合内的第一个元素。hasNext方法会判断cursor与size(集合中的元素数量)是否相等。那么第一次遍历集合时,cursor自增为1,第二次遍历时,调用next方法返回“Peter”后自增为2,随后“Peter”被删除,同时size自减为2;第三次遍历时,cursor与size相等,而返回false,直接结束循环。而抛出ConcurrentModificationException异常总是在删除一个元素后再调用next时发生,但是删除“Peter”后由于直接结束了循环,因此也就没有了调用next方法的机会了,也就没有抛出异常,但其实此时的modCount与expectedModCount值也是不一样的。
那么删除“Cook”后抛出的异常就是以上两个问题的结合体。遍历完“Peter”后,cursor值为2,size值为3,modCount值为3,expectedModCount值为3。继续向下遍历,调用next返回“Cook”,此时cursor值为3,删除“Cook”后,size值为2,modCount值为4。继续循环,调用hasNext时,由于cursor和size分别为3和2,因此返回真,得以继续执行循环体,但此时集合中已没有了能够迭代的元素了。而此时调用next时,调用checkForComodification方法,就会由于modCount与expectedModCount不等而抛出异常。
大家可以想象,如果不是单线程操作,而是多线程并发操作,比如线程A只是正常的遍历集合(没有增删操作),并打印元素,在遍历到最后一个元素的时候,恰好有一个线程B删除了最后一个元素,那么此时集合的cursor就会是n+1,而size就是n,那么线程A的迭代循环就进入了死循环,这是非常可怕的。

8.3 线程同步集合

为了避免上述线程安全问题的发生,在JDK1.5版本java.util.concurrent包中提供了5种线程安全的集合类,它们分别是ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、CopyOnWriteArrayList和CopyOnWriteArraySet。这5种线程安全集合的正是5种传统常用集合类的线程安全版本:ConncurrentHashMap对应HashMap、ConcurrentSkipListMap对应TreeMap、ConcurrentSkipListSet对应TreeSet、CopyOnWriteaArrayList对应ArrayList,而CopyOnWriteArraySet对应的就是HashSet。
由于我们在《集合框架》系列博客中对集合类的使用进行过详细说明,因此这一部分中不再对这些类的应用进行重复介绍,大家只需在实际开发中根据需要,在多线程并发访问的情况下,将普通集合类替换为线程安全集合即可。
比如我们将代码11中的ArrayList修改为CopyOnWriteArrayList后,无论删除“David”、“Peter”还是“Cook”都不会抛出任何异常。

8.4 通过Collections类将普通集合转换为同步集合

实际上,在JDK1.5版本中推出同步集合以前,人们也发现了普通集合的线程安全问题,因此在java.util包中的集合工具类Collections就提供了若干将普通集合类转换为同步集合类的静态方法,比如synchronizedCollection(Collection<T> c)、synchronizedList(List<T>list)、synchronized(Map<K, V> m)等等。我们曾经在《集合框架5:集合工具类》中提到过上述方法的实现原理,这里不妨在重复一下
以synchronizedMap方法为例,该方法的基本思路就是接收一个普通的非线程安全Map对象,然后创建一个叫做SynchronizedMap的对象并返回。SynchronizedMap是声明在Collections类内部实现了Map接口的内部类,此即线程安全的Map集合。SynchronizedMap的构造方法中接收一个普通Map对象,而其实现同步功能的方式也很简单:与普通Map集合声明的方法相同,而方法内部定义有synchronized同步代码块,在代码块内调用外部传递进来的Map对象的对应方法,这样既能可以提供基本的Map集合的功能,还具备同步特性。那么这里的SynchronizedMap就相当于是一个代理类——代理类能够实现原始类的所有基本功能,并在此基础上提供其他特性。
不过,既然在JDK1.5版本中提供了专门用于解决线程安全问题的集合类,也就没有了使用Collections工具类静态方法的必要了。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: