0%

并发容器

并发容器

Java并发容器JUC是三个单词的缩写。是JDK下面的一个包名。即Java.util.concurrency

CopyOnWriteArrayList

CopyOnWriteArrayList 写操作时复制,当有新元素添加到集合中时,从原有的数组中拷贝一份出来,然后在新的数组上作写操作,将原来的数组指向新的数组。整个数组的add操作都是在锁的保护下进行的,防止并发时复制多份副本。读操作是在原数组中进行,不需要加锁。

源码分析

下面首先展示了CopyOnWriteArrayList中比较重要的成员:

1
2
3
4
5
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

CopyOnWriteArrayList使用了ReentrantLock来支持并发操作,array就是实际存放数据的数组对象。ReentrantLock是一种支持重入的独占锁,任意时刻只允许一个线程获得锁。下面首先展示了add方法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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();//释放锁
}
}

现在来分析一下读操作,下面是get方法的细节:

1
2
3
4
5
6
7
8
9

public E get(int index) {
return get(getArray(), index);
}

private E get(Object[] a, int index) {
return (E) a[index];
}

可以发现是非常简单的,而且读是允许多个线程进入的

下面来分析一下CopyOnWriteArrayList的迭代器。下面是两个重要的变量:

1
2
3
4
5
6

/** Snapshot of the array */
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
private int cursor;

遍历的时候首先会获得当前数组对象的一个拷贝,称为快照,然后遍历的操作会在该快照上进行。获取了迭代器之后再对CopyOnWriteArrayList进行写操作会,迭代器不会感知到这种变化。每一个线程都将获得当前时刻的一个快照,所以不需要加锁就可以安全的实现遍历。

设计思想

  1. 读写分离
  2. 最终一致性
  3. 使用时另外开辟空间,防止并发冲突

缺点

  1. 写操作时复制消耗内存,如果元素比较多时候,容易导致young gc 和full gc。
  2. 不能用于实时读的场景。由于复制和add操作等需要时间,故读取时可能读到旧值。能做到最终一致性,但无法满足实时性的要求,更适合读多写少的场景。如果无法知道数组有多大,或者add,set操作有多少,慎用此类,在大量的复制副本的过程中很容易出错。

ConcurrentHashMap

Map 这样的 Key Value 在软件开发中是非常经典的结构,常用于在内存中存放数据。

HashMap

众所周知 HashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。

Base 1.7

img

Base 1.8

其实一个很明显的地方就是:当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。因此 1.8 中重点优化了这个查询效率,判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。

img

线程不安全

HashMap 扩容的时候会调用 resize() 方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。

疫苗:Java HashMap的死循环 | 酷 壳 - CoolShell

ConcurrentHashMap

Base 1.7

img

如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。

它的核心成员变量:

1
2
3
4
5
6
7
/**
* Segment 数组,存放数据时首先需要定位到具体的 Segment 中。
*/
final Segment<K,V>[] segments;

transient Set<K> keySet;
transient Set<Map.Entry<K,V>> entrySet;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static final class Segment<K,V> extends ReentrantLock implements Serializable {

private static final long serialVersionUID = 2249069246763182397L;

// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;

transient int count;

transient int modCount;

transient int threshold;

final float loadFactor;

}

原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。

Base 1.8

img

1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但查询遍历链表效率太低。1.8抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

ConcurrentSkipListMap

底层实现采用SkipList跳表
曾经有人用ConcurrentHashMap与ConcurrentSkipListMap做性能测试,在4个线程1.6W的数据条件下,前者的数据存取速度是后者的4倍左右。但是后者有几个前者不能比拟的优点:

  • Key是有序
  • 支持更高的并发,存储时间与线程数无关

安全共享对象策略:

  • 线程限制:一个被线程限制的对象,由线程独占,并且只能被占有它的线程修改
  • 共享只读:一个共享只读的U帝乡,在没有额外同步的情况下,可以被多个线程并发访问,但是任何线程都不能修改它
  • 线程安全对象:一个线程安全的对象或者容器,在内部通过同步机制来保障线程安全,多以其他线程无需额外的同步就可以通过公共接口随意访问他
  • 被守护对象:被守护对象只能通过获取特定的锁来访问。