在 Java 中我们使用最多的 List 就是 ArrayList 和 LinkedList,它们在单线程中可以说是”玩的不亦乐乎”,而在多线程并发操作时就不行了。Java 为我们提供了几种方式以适应多线程下的 List 操作场景。本文将介绍几个多线程中适用的 List,文章最后也会介绍线程安全的 Map、Set 以及 Queue 和 Deque 的几个实现类。
Collections.synchronizedList(List list)
因为 ArrayList 本身不是线程安全的,通过 Collections.synchronizedList(List
可以将其包装成一个线程安全的 List。这个方法根据传入的 List 返回一个支持同步(线程安全)的 List。接下来就可以利用这个返回的 List 进行串行访问了。
但是,需要注意的是,当遍历返回列表时,必须手动对其进行同步,方法如下:
List list = Collections.synchronizedList(new ArrayList());
...
synchronized (list) {
Iterator i = list.iterator(); // 必须在同步代码块里
while (i.hasNext())
foo(i.next());
}
如果不遵循该建议可能导致不确定的行为。如果传入的 List 是可序列化的,则返回的 List 也是可序列化的。
Vector
Vector 和 List 大同小异,底层都是用数组实现,只是在它的大部分方法上添加了 synchronized
关键字,用来保证线程安全;另外,ArrayList 在扩容时是在原来的基础上扩展 0.5 倍,而 Vector 是扩展 1 倍;此外 Vector 除了 iterator() 和 listIterator() (两个都支持 fast-fail 机制)比 ArrayList 多一个不支持 fast-fail 机制的迭代器:elements(),只有 hasMoreElements() 和 nextElement() 方法。
和 ArrayList 和 Vector 一样,同样的类似关系的类还有 HashMap 和 HashTable,StringBuilder 和 StringBuffer,后者是前者线程安全版本的实现,只是加了个 Synchronized 关键字。
Vector
和 Collections.synchronizedList(List list)
有什么区别?
Collections.synchronizedList(List list)
源码:
public static List synchronizedList(List list) {
return (list instanceof RandomAccess ? new SynchronizedRandomAccessList<>(list) : new SynchronizedList<>(list));
}
static List synchronizedList(List list, Object mutex) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list, mutex) :
new SynchronizedList<>(list, mutex));
}
static class SynchronizedList extends SynchronizedCollection implements List {
final List list;
SynchronizedList(List list) {
super(list);
this.list = list;
}
SynchronizedList(List list, Object mutex) {
super(list, mutex);
this.list = list;
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
}
通过源码分析得知,Collections.synchronizedList()
返回的是 SynchronizedList
或 SynchronizedRandomAccessList(SynchronizedList 的子类)
,而 SynchronizedList
只是持有原来的 List 对象,并实现了 List 接口的方法,然后在方法内部通过一个 mutex 对象锁的代码块保证线程安全。
另外,从上面的代码还可以看出,SynchronizedList
的同步,使用的是synchronized 代码块对 mutex
对象加锁,这个 mutex 对象还能够通过构造函数传进来,也就是说我们可以指定锁定的对象。而 Vector 则使用了synchronized 方法,同步方法的作用范围是整个方法,所以没办法对同步进行细粒度的控制。而且同步方法加锁的是 this 对象,没办法控制锁定的对象。这也是 vector 和 SynchronizedList 的一个区别。
CopyOnWriteArrayList
它是 ArrayList 的线程安全的变体,其中所有写操作(add,set等)都通过对底层数组进行全新复制来实现,允许存储 null 元素。
顾名思义,Copy-On-Write 就是 CopyOnWriteArrayList 的实现机制。即当对象进行写操作时,使用了Lock锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组;若进行的读操作,则直接返回结果,操作过程中不需要进行同步。
这种实现方式的核心思想是减少锁竞争,从而提高在高并发时的读取性能,但是它却在一定程度上牺牲了写的性能。
看上去这样做的成本就很高,但是在遍历操作的场景远远超过写操作(add,set 等)的情况下,它可能是最好的方案,并且在无法或不想同步遍历而又需要防止并发线程之间的干扰时很有用。
那么它是如何做到避免并发线程之间遍历时的干扰呢?
CopyOnWriteArrayList 的迭代器方法在创建迭代器时会创建一个当前数组状态的『快照』。这个数组在迭代器的生命周期内永不更改,因此不可能发生干扰,并且保证迭代器不会引发 ConcurrentModificationException。从迭代器被创建的那一刻起,该迭代器将不会因外界对列表的添加,删除或更改而改变。同时也不支持对迭代器本身进行元素更改操作(删除,设置和添加),这些方法都会抛出 UnsupportedOperationException。
内存一致性影响:与其他并发集合一样,能够保证在时间上先在一个线程中向 CopyOnWriteArrayList 中的写入操作,先行发生于后续在另一个线程中对 CopyOnWriteArrayList 的读取或删除操作。
要弄明白这一点,就要先了解 Java 的内存模型,然后就可以根据源码证明上面这句话。
CopyOnWriteArrayList 的写入与读取源码:
private transient volatile Object[] array;
final Object[] getArray() {
return array;
}
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
...
return true;
} finally {
lock.unlock();
}
}
public E get(int index) {
return get(getArray(), index);
}
public E remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
...
} finally {
lock.unlock();
}
}
通过阅读源码,可以发现无论是写操作,还量读操作或者是删除操作,都要先调用 getArray() 方法,而 getArray() 返回的就是 array 成员变量。但这个变量因为被 valatile 修饰,而 valatile 有一个特性就是:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的『后面』是指时间上的先后顺序(这就是8条先行发生原则里的 volatile 变量规则)。所以在多线程并发执行时,虚拟机会遵循这个先行发生原则,保证写操作先执行。
CopyOnWrite VS Vector
在 get() 操作上,Vector 使用了同步关键字,所有的 get() 操作都必须先取得对象锁才能进行。在高并发的情况下,大量的锁竞争会拖累系统性能。反观CopyOnWriteArrayList 的get() 实现,并没有任何的锁操作。
在 add() 操作上,CopyOnWriteArrayList 的写操作性能不如 Vector,原因也在于Copy-On-Write,写入时不止加锁,还使用了Arrays.copyOf()进行了数组复制,性能开销较大,遇到大对象也会导致内存占用较大。
在读多写少的高并发环境中,使用 CopyOnWriteArrayList 可以提高系统的性能,但是,在写多读少的场合,CopyOnWriteArrayList 的性能可能不如 Vector。
其实只要明白了线程安全的 List 的实现机制后,Set 和 Map 也就很容易明白了,它们的机制是相似的。
Collections 也提供了 synchronizedSet 和 synchronizedMap 方法,它们的原理也和 synchronizedList 一样。
java.util 包下也提供了线程安全的 Map —— HashTable。HashTable 与 HashMap 的关系同 Vector 与 ArrayList 的关系一样,都是对读写方法加锁。但是 HashTable 的 Key 和 Value 都不允许为 null。
java.util.concurrent 包下也提供了像 CopyOnWriteArrayList 类似的 CopyOnWriteSet。而且实际上 CopyOnWriteSet 内部就是通过 CopyOnWriteArrayList 实现的,它的 add 方法内部调用了 CopyOnWriteArrayList 的 addIfAbsent() 方法,这个方法的作用是如果要添加的元素在集合中不存在才会加入集合中,否则集合不会发生改变,并返回 false。
支持线程安全的 Set 还有 ConcurrentSkipListSet。
java.util.concurrent 包下还提供了线程安全的 Map —— ConcurrentHashMap。它与 HashTable 的主要区别是二者加锁粒度的不同,HashTable 的加锁方法是给每个方法加上 synchronized 关键字,这样锁住的是整个 Table 对象。而 ConcurrentHashMap 是更细粒度的加锁,在JDK1.8之前,ConcurrentHashMap加的是分段锁,也就是Segment锁,每个Segment 含有整个 table 的一部分,这样不同分段之间的并发操作就互不影响。
分段锁的原理如下:
Segment 的结构与 HashMap 类似,每个片段对应一个table数组和链表结构!一个Segment里面包含一个HashEntry数组,每个HashEntry是一个链表结构,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁!
虽然分段锁可以提高并发性,但理论上最大并发度与Segment个数相等。JDK1.8对此做了进一步的改进,它取消了Segment字段,直接在table元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率。对于put操作,如果Key对应的数组元素为null,则通过CAS操作(Compare and Swap)将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用 synchronized 关键字申请锁,然后进行操作。如果该 put 操作使得当前链表长度超过一定阈值,则将该链表转换为红黑树,从而提高寻址效率。
对于读操作,由于数组被 volatile 关键字修饰,因此不用担心数组的可见性问题。同时每个元素是一个 Node 实例(Java 7中每个元素是一个HashEntry),它的Key值和hash值都由final修饰,不可变更,无须关心它们被修改后的可见性问题。而其Value及对下一个元素的引用由volatile修饰,可见性也有保障。
支持线程安全的 Map 还有 ConcurrentSkipListMap。
并发 Queue。在并发队列上,JDK提供了两套实现,一个是以 ConcurrentLinkedQueue 为代表的高性能队列,一个是以 BlockingQueue 接口为代表的阻塞队列。ConcurrentLinkedQueue 是一个适用于高并发场景下的队列。它通过无锁的方式(CAS),实现了高并发状态下的高性能。通常,ConcurrentLinkedQueue 的性能要好于 BlockingQueue 。
与 ConcurrentLinkedQueue 的使用场景不同,BlockingQueue 的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享。
BlockingQueue 提供一种读写阻塞等待的机制,即如果消费者速度较快,则 BlockingQueue 则可能被清空,此时消费线程再试图从 BlockingQueue 读取数据时就会被阻塞。反之,如果生产线程较快,则 BlockingQueue 可能会被装满,此时,生产线程再试图向 BlockingQueue 队列装入数据时,便会被阻塞等待。
并发 Deque(Double-Ended Queue,双端队列)。Deque 允许在队列的头部或尾部进行出队和入队操作。LinkedList、ArrayDeque、LinkedBlocingDeque 都实现了双端队列Deque接口。其中LinkedList使用链表实现了双端队列,ArrayDeque使用数组实现双端队列。通常情况下,由于ArrayDeque基于数组实现,拥有高效的随机访问性能,因此ArrayDeque具有更好的遍历性能。但是当队列的大小发生变化较大时,ArrayDeque需要重新分配内存,并进行数组复制,在这种环境下,基于链表的 LinkedList 没有内存调整和数组复制的负担,性能表现会比较好。但无论是LinkedList或是ArrayDeque,它们都不是线程安全的。
LinkedBlockingDeque 是一个线程安全的双端队列实现。它的内部使用链表结构,每一个节点都维护了一个前驱节点和一个后驱节点。LinkedBlockingDeque 没有进行读写锁的分离,因此同一时间只能有一个线程对其进行操作。Java 还提供了一个 ConcurrentLinkedDeque,可在多线程并发时进行读写操作。