线程安全的容器
同步容器
同步容器包括 Vector和Hashtable,这些同步容器的封装器是由Collections.synchronizeDXXXX等工程安魂构建的。
并发容器的实现原理显得简单而粗暴,将他们的状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程能访问容器的状态。比如这样
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
这种简单粗暴的方法,虽然可以实现方法中的线程安全,但是在实际业务中,我们很少会单纯的执行删除或者更新。一般都将多步操作作为一个原子性的操作。
比如下面这个操作
public static Object getLast (Vector list) {
int i = list.size() - 1;
return list.get(i);
}
public static Object deleteLast (Vector list) {
int i = list.size() - 1;
return list.remove(i);
}
此时如果 一个线程调用了getLast,而另外一个线程进行deleteLast,这个时候再执行了getLast后半部分,会抛出一个数组越界的异常。
这个时候我们假如想使业务不发生错误那就只能通过自己实现加锁来解决问题。
public static Object getLast (Vector list) {
synchronized (list) {
int i = list.size() - 1;
return list.get(i);
}
}
public static Object deleteLast (Vector list) {
synchronized (list) {
int i = list.size() - 1;
return list.remove(i);
}
}
迭代器的问题
目前对容器类进行迭代的标准方式都是使用迭代器(Iterator),然而如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。
在设计迭代器的时候没有考虑到并发修改的问题,并且他们表现出的行为是“及时失败”的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。
如果想避免在迭代过程中出现ConcurrentModificationException异常,就必须在迭代过程中持有容器的锁。而如果容器的规模过大,或者在每个容器上执行操作的时间很长。那样可能存在饥饿或者死锁等风险。长时间对容器加锁也会降低程序的可伸缩性。当然另外一种办法是:克隆容器。对克隆的容器进行迭代,由于副本被封闭在线程内,因此其他线程不会再迭代期间对其进行修改。但是在克隆容器时存在显著的性能开销。这个取舍取决于容器大小、每个元素上执行的工作.
如果防止迭代器抛出ConcurrentModificationException异常,就必须对所有共享容器进行迭代的地方进行加锁。
并发容器
上面同步容器存在两个问题,一个是简单粗暴的给所有方法添加锁,会导致整个容器的访问都变成串行化。极大的降低了多线程的性能。第二个是虽然同步容器说明是线程安全的,但是在实际业务中往往需要一些复合操作,所以真正使用起来还需要开发者自己实现复合操作的加锁。
所以针对同步容器的缺点,并发容器做了针对性的优化
- 根据使用场景进行锁的优化,尽量避免使用锁,提高容器的并发访问
- 定义了一些常用的线程安全的复合操作
- 并发容器在迭代的时候,可以不需要加锁,但是每次循环看到的可能不是最新的数据。
Map
JAVA针对map提供了两种并发容器
- ConcurrentHashMap
- ConcurrentSkipListMap
其中ConcurrentSkipListMap是一种基于跳表(Skip List)线程安全的有序的哈希表结构。他们的类图关系是:
ConcurrentMap
ConcurrentMap是并发容器-Map的公共接口
主要是添加了一些满足实际业务场景的复合操作。比如下面的内容
V putIfAbsent(K key, V value);
ConcurrentHashMap
同步容器类在执行每个操作期间都持有一个锁。而ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种颗粒度更细的加锁机制来实现更大程度上的读取线程和并发的访问Map、执行读取操作的线程和执行写入操作的线程可以并发地访问map
ConcurrentHashMap默认使用16个分段保存数据
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
在初始化的代码中可以看到最终使用了DEFAULT_CAPACITY
数据
/**
* The default initial table capacity. Must be a power of 2
* (i.e., at least 1) and at most MAXIMUM_CAPACITY.
*/
private static final int DEFAULT_CAPACITY = 16;
put
ConcurrentHashMap使用了分段锁来提高并发性能,同时JAVA7,JAVA8使用了CSA + synchronized 来保证并发性。同时1.8中数据存放地点被修改为Node的数组中,可以看看put方法就能大概明白了
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 根据key获得hashcode
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 如果为空则表示可以写入数据,然后使用CAS尝试写数据
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// hashcode == MOVED == -1 表需要扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
// 如果都不满足则使用synchronized 加锁写入
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
// 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
在类中static final int TREEIFY_THRESHOLD = 8
可以看到,当链表长度超过8后会被修改为红黑树,
ConcurrentSkipListMap
底层是通过跳表来实现的。跳表是一个链表,但是通过使用“跳跃式”查找的方式使得插入。
跳表
使用“空间换时间”的算法,令链表的每个结点不仅记录next结点位置,还可以按照level层级分别记录后继第level个结点。在查找时,首先按照层级查找,比如:当前跳表最高层级为3,即每个结点中不仅记录了next结点(层级1),还记录了next的next(层级2)、next的next的next(层级3)结点。现在查找一个结点,则从头结点开始先按高层级开始查:head->head的next的next的next->。。。直到找到结点或者当前结点q的值大于所查结点,则此时当前查找层级的q的前一节点p开始,在p~q之间进行下一层级(隔1个结点)的查找…直到最终迫近、找到结点。此法使用的就是“先大步查找确定范围,再逐渐缩小迫近”的思想进行的查找。
关于跳表这个我之前也不怎么了解,也是最近学习的时候才了解的。后续我想在整理算法篇的时候再好好整理下。
List
JAVA 为List同样提供了两种并发容器
- CopyOnWriteArrayList
CopyOnWriteArrayList
CopyOnWriteArrayList是一个基于ArrayList的。某些情况下它提供了更好的并发性能,并且在迭代器期间不需要对容器进行加锁或者复制操作。此容器在新增数据的时候CopyOnWriteArrayList是先copy出一个副本,再往新的容器里添加这个新的数据,最后把新的容器的引用地址赋值给了之前那个旧的的容器地址
add
里面实现的拷贝和重新赋值
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 此时进行数据拷贝
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 在这里进行重新赋值
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
这样可以保证当其他线程在循环列表数据的时候,不会因为其他线程擅自修改容器内容而导致循环发生异常。当然容器数据过大时候进行数据的复制的时候无疑会带来比较大的资源消耗。
Set
JAVA 为Set同样提供了两种并发容器
- CopyOnWriteArraySet
- ConcurrentSkipListSet
CopyOnWriteArraySet
CopyOnWriteArraySet内部维护了一个CopyOnWriteArrayList对象,容器底层的数据维护也是使用CopyOnWriteArrayList,区别是set保证了不能插入重复元素
public class CopyOnWriteArraySet<E> extends AbstractSet<E>
implements java.io.Serializable {
private static final long serialVersionUID = 5457747651344034263L;
private final CopyOnWriteArrayList<E> al;
add
public boolean add(E e) {
return al.addIfAbsent(e);
}
CopyOnWriteArraySet中的add使用CopyOnWriteArrayList的addIfAbsent防止插入重复元素
ConcurrentSkipListSet
ConcurrentSkipListSet内部维护一个ConcurrentNavigableMap的Map初始化的时候使用的是ConcurrentSkipListMap
/**
* Constructs a new, empty set that orders its elements according to
* their {@linkplain Comparable natural ordering}.
*/
public ConcurrentSkipListSet() {
m = new ConcurrentSkipListMap<E,Object>();
}
/**
* Constructs a new, empty set that orders its elements according to
* the specified comparator.
*
* @param comparator the comparator that will be used to order this set.
* If {@code null}, the {@linkplain Comparable natural
* ordering} of the elements will be used.
*/
public ConcurrentSkipListSet(Comparator<? super E> comparator) {
m = new ConcurrentSkipListMap<E,Object>(comparator);
}
add
public boolean add(E e) {
return m.putIfAbsent(e, Boolean.TRUE) == null;
}
ConcurrentSkipListSet中的add使用ConcurrentSkipListMap的addIfAbsent防止插入重复元素
总结
目前关于线程安全的容器主要包括
- 同步容器
- 并发容器
同步容器
主要包括Vector和Hashtable
- 同步容器算是初代容器,简单粗暴所有方法使用synchronized加锁,可以实现线程安全,但是并发性能低下。
- 同步容器并没有实现一些复合操作线程安全,而一些复合操作又是我们实际业务常用的逻辑。
- 有一些方法无需使用synchronized加锁,而被进行了加锁,反倒导致性能下降。
并发容器
主要包括
- ConcurrentHashMap
- ConcurrentSkipListMap
- CopyOnWriteArrayList
- CopyOnWriteArraySet
- ConcurrentSkipListSet
- 以及后续添加的一系列队列(queue)和双端队列(deque)
和同步容器相比
-
ConcurrentHashMap将数据进行分段(默认16个分段)。首先通过key计算出hashcode得出所属分段,然后对当前分段加锁。这样就避免并发修改的时候对所有数据进行加锁。
-
ConcurrentSkipListMap内部是通过跳表来实现的。跳表是一个链表,但是通过使用“跳跃式”查找的方式使得插入。
-
CopyOnWriteArrayList在读取的时候不再加锁,而在新增数据的时候,将数据拷贝一份对拷贝进行新增操作,事后将数据引用指向新的数组。
-
CopyOnWriteArraySet和ConcurrentSkipListSet 分别使用CopyOnWriteArrayList和ConcurrentSkipListMap实现了set相关方法。