同步容器类包括Vector和HashTable,二者都是早期JDK的一部分,此外还包括在JDK1.2当中添加的一些功能相似的类,这些同步的封装类是由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次都只有一个线程能够访问容器的状态。相较于并行容器,同步容器的实现原理其实很简单,就是对普通容器做了一层封装,并实现容器的每一个方法,在方法上实现同步。比如通过Collections类的工厂方法将一个普通的List封装成一个同步容器:
public static List synchronizedList(List list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
在并发编程当中,虽然同步容器类是线程安全的,但是在某些情况下可能需要额外的客户端加锁来保护复合操作。如下面一段代码:
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是线程安全的,但是获取Vector大小与获取/删除之间没有锁保护,当获得Vector大笑之后,如另外一个线程删除了Vector中的最末尾位置的元素,则每个函数的最后一句代码执行将报错。因此,对于复合操作,需要在符合操作上用锁来保证操作的原子性:
public static Object getLast(Vector list) {
synchronized (list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
public static void deleteLast(Vector list) {
synchronized (list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
}
在之前的文章《Java集合ArrayList中modCount详解及subList函数要点》中,曾经提到过ConcurrentModificationException
异常,在对集合进行迭代操作的过程中,如果修改了原集合,将导致异常的发生。同样,如果在迭代期间modCount被其他线程修改,那么同样将发生ConcurrentModificationException
异常。由于使用同步类容器需要保证在对容器进行复合操作及其他一些操作要进行客户端加锁,导致了实现线程安全的同步操作的保障将分散代码的各个地方,这将增加代码实现的难度以及维护的难度。正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略以及简化维护工作。因此,更能实现该目的的并行容器,也就成了更好的选择。
同步容器类存在两个问题,一个问题就是上面提到的复合操作需要客户端加锁,以保证操作的正确性。另外一个就是同步容器将所有对容器状态的访问都串行化,以实现他们的线程安全性,但这种方法的代价是严重降低并发性,当多个线程竞争访问容器的锁时,吞吐量将严重降低。因此,通过并发容器代替同步容器,可以极大地提高伸缩性并降低风险。并发容器注重以下特性:
1. 根据具体场景进行设计,尽量避免使用锁,提高容器的并发访问性。
2. 并发容器定义了一些线程安全的复合操作。
3. 并发容器在迭代时,可以不封闭在synchronized中。但是未必每次看到的都是”最新的、当前的”数据。如果说将迭代操作包装在synchronized中,可以达到”串行”的并发安全性,那么并发容器的迭代达到了”脏读”。
可以通过下图简单了解concurrent中关于容器类的接口和类:
ConcurrentMap
该接口定义Map的原子操作:putIfAbsent、remove、replace
BlockingQueue
阻塞队列,不允许null值;
取元素时,如果队列为空则等待;存元素时,如果没有空间则等待;
阻塞队列的方法有四种形式–当操作不能立即得到满足,但可能在未来某一时刻被满足的时候,有四种不同的方式来处理:
+ 抛出异常
+ 返回特殊的值(null或false,取决与具体的操作)
+ 无期限地阻塞当前线程,直到该操作成功
+ 仅在指定的最大时长内阻塞,过后还不成功就放弃
通过上面的图可以知道,concurrent包中的并发容器主要可以四类,分别是:
+ CopyOnWrite容器:CopyOnWriteArrayList、CopyOnWriteArraySet
+ CocurrentMap的实现类:ConcurrentHashMap、ConcurrentSkipListMap
+ 阻塞队列的实现类(共七种)
+ 其他:ConcurrentLinkedQueue、ConcurrentLikedDeque、ConcurrentSkipListSet
其实现原理是,在创建CopyOnWrite容器实例时,是通过安全方式发布了一个事实不可变对象,由前一篇文章中我们知道,安全发布的事实不可变对象是线程安全的,那么在访问该对象时就不再需要进一步的同步。但是在每次修改时,都会创建并重新发布一个新的容器副本就行修改,从而实现可变性。需要注意的时,每当修改容器是都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。所以,建议仅当迭代操作远远多余修改操作时,才应该使用“写入时复制”容器。
CopyOnWriteArrayList用于替代同步List,其在迭代期间不需要对容器进行加锁或复制。
java.util.ArrayList的线程安全版本:所有的修改操作都是通过对底层数组的最新copy来实现。
与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap并部署在每个方法上都用同一个锁进行同步并使得只能有一个线程访问容器,而是使用一种粒度更细的锁机制来实现更大程度的共享,这种机制成为分段锁。在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map。
,所谓分段锁,简单来说就是将数据进行分段,每一段锁用于锁容器中的一部分数据,那么当多线程访问容器里的不容数据段的数据时,线程间就不会存在锁竞争,从而可以有效地提高并发访问效率。有些方法需要跨段,比如size(),就需要按照顺序锁定所有的段,完成操作后,再按顺序释放锁。有关分段锁的应用,可以参看ConcurrentHashMap分段锁技术。
ConcurrentSkipListMap在JDK并发工具类使用范围不是很广,它是针对某一特殊需求而设计的——支持排序,同时支持搜索目标返回最接近匹配项的导航方法。ConcurrentSkipListMap使用SkipList(跳表)实现排序,而TreeMap使用红黑树。
阻塞队列是一个支持阻塞插入和阻塞移除的队列:当队列满时,队列会阻塞插入元素的线程,直到队列不满;当队列为空时,队列会阻塞获取元素的线程,直到队列不空。阻塞队列常用于生产者和消费者模式,生产者向队列中添加元素,消费者则从队列中取出元素。线程池当中使用阻塞队列来实现任务的排队,在这里简单介绍一下阻塞队列的几个具体实现类。
使用数组实现的有界阻塞队列,按照FIFO的原则对元素排序;内部使用重入锁可实现公平访问。内部使用一个重入锁来控制并发修改操作,即同一时刻,只能进行放或取中的一个操作。初始化时,必须指定容量大小。
使用链表实现的有界阻塞队列,按照FIFO的原则对元素排序;默认和最大长度均为Integer.MAX_VALUE,所以在使用的时候,要注意指定最大容量,否则可能会导致元素数量过多,内存溢出。内部使用两个重入锁来控制并发操作,即同一时刻,允许同时进行放和取。
支持优先级的无界阻塞队列,默认情况下元素按照自然顺序升序排列,可以自定义类实现compareTo()方法来指定元素的排序规则,或在初始化PriorityBlockingQueue时指定构造参数Comparator来对元素进行排序,但不能保证同优先级元素的顺序;
支持延时获取元素的无界阻塞队列,队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素,只有在延迟期满后,才能从队列中获取元素。
DelayQueue可以应用在缓存系统的设计(用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素,表示缓存有效期到了)、定时任务调度等场景(ScheduledThreadPoolExecutor中的ScheduledFutureTask类就是实现的Delayed接口)
不存储元素的阻塞队列,每一个put操作必须等待一个take操作,否则不能继续添加元素,支持公平访问队列,非常适合传递性场景,即把生产者线程处理的数据直接传递给消费者线程,队列本身不存储任何元素。SyncronousQueue的吞吐量高于ArrayBlockingQueue和LinkedBlockingDeque。
使用链表实现的无界阻塞TransferQueue,当有消费者正在等待接受元素时,队列可以通过transfer()方法把生产者传入的元素立即传给消费者。
使用链表实现的双向阻塞队列,可以在队列的两端进行插入和移除元素。
因此,在使用容器进行开发时,我们有三种选择,第一种是使用普通的容器,二是使用同步容器,三是使用并发容器。在容器的选择上,还是需要我们根据具体的业务需要,选择合适的容器来实现业务功能。
|