Java平台类库包含了大量的并发基础构建模块,我们简单介绍其中的一部分。
1、同步容器类
同步容器类包括Vector、Hashtable以及它们的子类,还有通过Collections.synchronizedXxx的一些工厂方法创建的同步封装器类。
对于这类同步容器类,它们是线程安全的,但是它们实现线程安全的方式是对其方法使用synchronized关键字进行修饰,当多个线程共同使用这些方法就相当于是串行使用的,效率必然很糟糕。
a、同步容器类的问题
另外我们虽然说同步容器类是线程安全的,但是有时候需要额外的加锁机制来保护复合操作,包括迭代(遍历容器元素)、跳转(找到当前元素的下一个元素)、条件运算(比如检查Map是否存在K)。当其他线程并发修改容器的时候,这些复合操作就会出现问题。
比如我们现在使用一个线程对容器类进行迭代,仅仅从这个线程角度来看,当然不会出现问题。但是如果与此同时另一个线程对这个容器类进行修改,那么就会出现问题了。我们使用代码来说明一切:
import java.util.Iterator;
import java.util.Vector;
public class Main {
public static void main(String[] args) {
Vector<Integer> vector = new Vector<Integer>();
for(int i = 0 ; i < 10; i++){
vector.add(i);
}
new IterateThread(vector).start();
new DeleteThread(vector).start();
}
}
class IterateThread extends Thread{
private Vector<Integer> vector;
public IterateThread(Vector<Integer> vector){
this.vector = vector;
}
@Override
public void run(){
Iterator<Integer> iterator = vector.iterator();
while(iterator.hasNext()){
System.out.print(iterator.next()+" ");
}
}
}
class DeleteThread extends Thread{
private Vector<Integer> vector;
public DeleteThread(Vector<Integer> vector){
this.vector = vector;
}
@Override
public void run(){
vector.remove(vector.size()-1);
}
}
我们创建了两个线程,一个负责遍历容器,一个负责删除容器最后一个元素。固然单独从一个线程的角度来看都是安全的,但是如果在一个线程遍历过程中,另一个线程删除了元素,就会造成意想不到的结果:
可以看到迭代器抛出了并发修改异常。这种情况下就需要我们对容器额外加锁:
import java.util.Iterator;
import java.util.Vector;
public class Main {
public static void main(String[] args) {
Vector<Integer> vector = new Vector<Integer>();
for(int i = 0 ; i < 10; i++){
vector.add(i);
}
new IterateThread(vector).start();
new DeleteThread(vector).start();
}
}
class IterateThread extends Thread{
private Vector<Integer> vector;
public IterateThread(Vector<Integer> vector){
this.vector = vector;
}
@Override
public void run(){
//对容器加锁
synchronized(vector){
Iterator<Integer> iterator = vector.iterator();
while(iterator.hasNext()){
System.out.print(iterator.next()+" ");
}
}
}
}
class DeleteThread extends Thread{
private Vector<Integer> vector;
public DeleteThread(Vector<Integer> vector){
this.vector = vector;
}
@Override
public void run(){
//对容器加锁
synchronized(vector){
vector.remove(vector.size()-1);
}
}
}
这样我们就不会出错了。当然由于每个线程的完成时间时间具有一定的不确定性因素,输出结果可能是删除最后一个元素的结果,也可能是没删除的结果,就看哪个线程先获得锁并且执行完毕。这是我的运行结果:
因此对于这样的复合操作,哪怕是同步容器,也需要我们额外加锁。
b、迭代器与ConcurrentModificationException
Iterator也就是我们说的迭代器,就像上面我们写的代码那样,如果有其他线程并发修改容器,即使是使用迭代器也无法避免只有在迭代期间对容器加锁才能保证线程安全问题。因为在设计同步容器类的迭代器的时候没有考虑并发修改问题,而是使用了快速失败机制(fail-fast),当迭代过程中发现容器被修改就不再冒险继续迭代,而是直接抛出一个ConcurrentModificationException异常。
这种快速失败机制的迭代不是一种处理机制,而更像一个警告器,告诉coder这个地方出现了并发异常。
迭代的地方如果涉及到其他线程把容器修改了固然要加锁,但是有的时候迭代过程我们没有看到。比如对于以下代码:
System.out.println(vector);
而Vector类的toString()方法如下:
public synchronized String toString() {
return super.toString();
}
Vector的基类是AbstractList,而AbstractList的基类是AbstractCollection,这个类的toString()方法如下:
public String toString() {
Iterator<E> it = iterator();
if (! it.hasNext())
return "[]";
StringBuilder sb = new StringBuilder();
sb.append('[');
for (;;) {
E e = it.next();
sb.append(e == this ? "(this Collection)" : e);
if (! it.hasNext())
return sb.append(']').toString();
sb.append(',').append(' ');
}
}
可以看到toString()方法实际上是使用迭代器迭代的过程,因此在使用System.out.println(vector)输出同步容器类对象的时候也要加锁,因为它隐藏了迭代器的使用。
2、并发容器
前面我们提到了,虽然同步容器是线程安全的,但是由于线程对容器状态的访问是串行化的,因此严重降低了并发现和吞吐量。
相反,并发容器是为多个线程并发访问设计的。
比如ConcurrentHashMap类,ConcurrentHashMap采用了分段锁的设计,只有在同一个分段内才存在竞态关系,不同的分段锁之间没有锁竞争。相比于对整个Map加锁的设计,分段锁大大的提高了高并发环境下的处理能力。
比如CopyOnWriteArrayList,对应主要操作为遍历情况下的对并发有要求的ArrayList。在多个线程同时读CopyOnWriteArrayList的时候,是没有问题的。在其中一个线程写的时候,先copy出一个容器(可以简称副本),再往新的容器里添加这个新的数据,最后把新的容器的引用地址赋值给了之前那个旧的的容器地址,但是在添加这个数据的期间,其他线程如果要去读取数据,仍然是读取到旧的容器里的数据。
Java 5.0增加了两种新的容器类型:Queue和BlockingQueue。
Queue有几种实现,包括ConcurrentLinkedQueue,这是传统的线程安全队列。
以及PriorityQueue,这是一个非并发的优先队列。
BlockingQueue扩展了Queue,使得队列的插入和获取变得可阻塞。在插入的时候队列满会一直阻塞,直到可以插入;队列为空的时候进行获取会一直阻塞,直到可以获取到元素(记不记得我们之前写过的一篇多线程设计模式,生产者-消费者模式,不记得点击此处)。
之后Java 6.0还引入了ConcurrentSkipListMap和ConcurrentSkipListSet,分别作为SortedMap和SortedSet的并发代替容器类。
a、ConcurrentHashMap
前面说了ConcurrentHashMap使用了一种粒度更细的名叫分段锁的加锁机制来实现更大程度的共享,同时提高了并发性和伸缩性。
ConcurrentHashMap提供的迭代器不会抛出ConcurrentModificationException,因此不需要在其迭代过程中加锁。它的迭代具有弱一致性,而不是快速失败。弱一致性的迭代器可以容忍并发的修改,并且可以在迭代器被构造后将修改操作反应给容器。
因此这个容器的size()和isEmpty()这一类的方法实际上只是一个估计值。当然在多线程环境下这些需求也很小,我们更多的是看重例如get、put、containsKey、remove等等这些操作的性能。
在高并发下只有应用程序对Map要进行独占访问时,才应放弃使用ConcurrentHashMap。
b、CopyOnWriteArrayList/CopyOnWriteArraySet
所谓CopyOnWrite就是写入时复制,其安全性在于正确发布一个不可变的对象,那么在访问这个对象的时候就不需要再进行同步。修改的时候创建并且重新发布一个新的容器副本,从而实现可变。
写入时复制的容器不会抛出ConcurrentModificationException。但是仅仅当迭代操作远远多于修改操作的时候才应该使用写入时复制容器。
3、阻塞队列
对于非阻塞队列,不能在队列为空时使用take()方法,也不能在队列满的时候使用put()方法。但是对于则队列队列空的时候使用take则会阻塞,直到可以take;而队列满的时候put会阻塞,直到可以put。同时阻塞队列还提供了定时的offer方法和poll方法。对于offer方法,如果数据项不能添加到队列中,就会返回一个失败状态。这样就能创建更多的灵活策略来处理负荷过载的情况。因此我们可以使用有界队列来防止程序负荷过载。
阻塞队列有LinkedBlockingQueue和ArrayBlockingQueue,分别于并发的LinkedList和ArrayList相对应。
另外PriorityBlockingQueue是一个按照优先级排序的队列,当我们需要按照某种优先级顺序而不是FIFO来处理元素的时候这个队列将会非常有用。
最后一个阻塞队列时SynchronousQueue,它甚至都不算是一个真正的队列,因为它不会为队列中元素维护存储空间,而且size()方法永远返回0。一个线程的put()必须对应一个线程的take(),否则会一直阻塞。
我们使用synchronousQueue来做个生产者-消费者模式的测试:
import java.util.concurrent.SynchronousQueue;
public class Main {
public static void main(String[] args) {
SynchronousQueue<Integer> queue = new SynchronousQueue<Integer>();
new TakeThread(queue,"消费者").start();
new PutThread(queue,"生产者").start();
}
}
class TakeThread extends Thread {
public TakeThread(SynchronousQueue<Integer> queue, String name){
super(name);
this.queue = queue;
}
private SynchronousQueue<Integer> queue;
@Override
public void run(){
int i = 0;
while(i<30){
try {
System.out.println(Thread.currentThread().getName()+"取出:"+queue.take());
Thread.sleep(100);
i++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class PutThread extends Thread {
private SynchronousQueue<Integer> queue;
public PutThread(SynchronousQueue<Integer> queue, String name){
super(name);
this.queue = queue;
}
@Override
public void run(){
int i = 0;
while (i < 30) {
try {
System.out.println(Thread.currentThread().getName()+"放入:"+i);
queue.put(i++);
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
我们启动一个线程,负责取出30次元素,另一个线程负责放入30次元素,阻塞队列是SynchronousQueue。我们故意让生产者的速度比消费者慢两倍。结果运行如下:
后面的运行结果不放了,放不下了,总之就是虽然有速度差异,但是还是交替进行的。因为对于SynchronousQueue这个阻塞队列,一个线程的put必须对于另一个线程take,反之亦然。
阻塞队列的设计保证了阻塞队列中存储的对象在同一时刻只能由一个线程拥有,比如放入的时候由生产者拥有,取出的时候由消费者拥有。通过阻塞队列安全地转移对象的所有权,保证了阻塞队列存储对象的线程安全性。
此外,Java6增加了Deque和BlockingDeque。Deque是一种双端队列,BlockingDeque就是其对应的阻塞双端队列。实现包括ArrayDeque和LinkedBlockingDeque。
因为这个双端队列,出现了另一种叫做工作密取的模式。设想一下这种场景,每个消费者都有各自的双端队列,而不是共享一个工作队列。每个消费者完成了自己双端队列中的工作时,就可以从其他消费者双端队列末尾那里秘密获取工作。这种模式可伸缩性显然更好,各自访问各自的双端队列也减小了竞争,即使访问其他消费者的双端队列也是从末尾访问,而不是从头部访问,这样的并发性和可伸缩性显然更好。
我们来对这种工作密取这种工作模式进行实现:
import java.util.ArrayList;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) {
ArrayList<LinkedBlockingDeque<Integer>> allDequePool = new ArrayList<LinkedBlockingDeque<Integer>>(4);
allDequePool.add(new LinkedBlockingDeque<Integer>(3));
allDequePool.add(new LinkedBlockingDeque<Integer>(3));
allDequePool.add(new LinkedBlockingDeque<Integer>(3));
allDequePool.add(new LinkedBlockingDeque<Integer>(3));
new PutThread("放入者0",allDequePool).start();
new TakeThread("取出者0",allDequePool,0).start();
new TakeThread("取出者1",allDequePool,1).start();
new TakeThread("取出者2",allDequePool,2).start();
new TakeThread("取出者3",allDequePool,3).start();
}
}
class PutThread extends Thread{
private final ArrayList<LinkedBlockingDeque<Integer>> allDequePool ;
public PutThread(String name, ArrayList<LinkedBlockingDeque<Integer>> allDequePool){
super(name);
this.allDequePool = allDequePool;
}
@Override
public void run(){
int i = 0;
while(true){
put(i,i++%allDequePool.size());
}
}
//往第i个容器放入request
private void put(Integer request, int i){
System.out.println(Thread.currentThread()+"正在放入:"+request);
//模拟放入时间
try {
Thread.sleep(500);
allDequePool.get(i).put(request);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class TakeThread extends Thread {
//第i个是自己的容器 其他的是别个线程的容器
private final ArrayList<LinkedBlockingDeque<Integer>> ALL_DEQUE_POOL;
private final int SELF_DEQUE_NUM;
public TakeThread(String name,ArrayList<LinkedBlockingDeque<Integer>> allDequePool, int i){
super(name);
this.ALL_DEQUE_POOL = allDequePool;
this.SELF_DEQUE_NUM = i;
}
@Override
public void run(){
//每个消费者线程为其创建一个双端队列 因此每个线程访问自己的双端队列不会存在线程安全问题
while(true){
//自己的双端队列不为空时 访问自己的双端队列头部元素
if(ALL_DEQUE_POOL.get(SELF_DEQUE_NUM).size()!=0){
try {
deal(ALL_DEQUE_POOL.get(SELF_DEQUE_NUM).takeFirst());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//否则访问其他双端队列池的的双端队列末尾元素
//如果发现其他双端队列都为空 也不进行长时间阻塞
else{
Integer request = null;
try {
request = takeTaskFromTail();
} catch (InterruptedException e) {
e.printStackTrace();
}
if(request!=null){
deal(request);
}
}
}
}
private Integer takeTaskFromTail() throws InterruptedException{
for(int i = 0; i < ALL_DEQUE_POOL.size(); i++){
//这个size不一定是准确的 因此还是需要设置阻塞时间
if(i != SELF_DEQUE_NUM && ALL_DEQUE_POOL.get(i).size()!=0){
return ALL_DEQUE_POOL.get(i).pollLast(500, TimeUnit.MILLISECONDS);
}
}
return null;
}
private void deal(Integer request){
System.out.println(Thread.currentThread()+"正在处理:"+request);
try {
//假装处理花费1000ms
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果如下:
可以看到运行结果没什么问题。
每一个消费者和生产者都对应着一个双向队列池。每个单独的消费者对应着双列队列池中的一个双向队列,只要自己的双向队列还有任务,就一直从头部取自己双向队列中的任务,否则从其他线程的双向队列尾部获取任务。这样可以使得自己双向队列有任务的时候不存在竞争情况,而从其他线程的双向队列取任务的时候也是从双向队列尾部获取任务,竞争条件实际上也没那么强。
由于LinkedBlockingQueue本身就是并发容器,线程安全的,所以不用使用synchronized对其一些操作加锁。但是对于takeTaskFromTail这个地方的size()方法返回的只是一个尺寸的估计值,因此还要考虑队列为空,但是size()返回不为0的情况。
这个代码只是工作密取的简单实现,我们的生产者只是简单地对阻塞队列挨个进行放入操作,没有考虑符合过载地情况。消费者也只是在自己的双端队列没有元素的时候简单地从有元素的其他线程的双端队列进行取,而没有考虑取哪一个最优,因此会存在一些线程的任务分配不均的情况。在此我就不实现了,欢迎读者积极思考,有兴趣可以在评论下方留言表示自己的想法。