接上一篇《Java并发系列(7)——AQS与显式锁实现原理》
文章目录
6 并发容器
众所周知,HashMap,ArrayList 等等这些容器不是线程安全的。
在多线程场景下,如果要使用这些容器,JDK 也提供了一些线程安全的容器类。
6.1 并发 HashMap
JDK 提供的并发安全的 HashMap 有两个:java.util.Hashtable 和 java.util.concurrent.ConcurrentHashMap。
顺便一提:并发场景下不要使用 HashMap,不仅仅是线程不安全可能访问错误数据的问题,还有可能导致死循环。JDK1.7 的实现在多个线程同时 resize 的时候可能会出现循环链表从而导致死循环;JDK1.8 的实现与 JDK1.7 不太一样,hash 冲突时链表头插改成了尾插。
6.1.1 HashTable
HashTable 实现线程安全的方式非常简单:
public synchronized V get(Object key) {
//...
}
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
protected void rehash() {
//...
}
private void addEntry(int hash, K key, V value, int index) {
//...
}
public synchronized V put(K key, V value) {
//...
}
public synchronized V remove(Object key) {
//...
}
public synchronized void putAll(Map<? extends K, ? extends V> t) {
//...
}
public synchronized void clear() {
//...
}
public synchronized Object clone() {
//...
}
public synchronized String toString() {
//...
}
上面列举了 HashTable 的一些方法实现,所有对外暴露的 public 方法都使用 synchronized 进行了同步。
相当于直接锁全表,哪怕多个线程都是读操作,哪怕多个线程操作了不同的 key,还是存在锁竞争,因此效率很低,基本不会用它。
不过也并不是一定不能用,在 jdk1.6 之前 synchronized 是重量级锁,性能很差,是没法用的。就算没有 ConcurrentHashMap,手写一个 Lock 也比 HashTable 强。
但在 jdk1.6 之后,jdk 对 synchronized 做了大量优化。在没有多个线程同时执行临界区的代码,也就是虽然是多线程场景但实际上没有竞争时,synchronized 是偏向锁或轻量锁状态,几乎没有额外的性能损失(线程不会阻塞,也不会自旋,但依然会刷新工作内存)。所以 HashTable 在低并发场景用一用问题也不大,只不过我们有性能更好的 ConcurrentHashMap,所以还是没必要用它,除非要求数据强一致性。
6.1.2 ConcurrentHashMap
ConcurrentHashMap 在 jdk1.7 和 jdk1.8 的实现方式不同。
6.1.2.1 jdk1.7 的实现
6.1.2.1.1 数据结构
jdk1.7 的 ConcurrentHashMap 内部数据结构是一个 Segment 数组,每一个 Segment 又持有一个 HashEntry 数组。
Segment 实际上是一个锁,继承自 ReentrantLock。
Segment 下面的 HashEntry 数组则相当于是一个独立的普通 HashMap,发生 hash 碰撞的时候以链表的形式串在一起。
所以,它的设计思路很简单:
- 为了提升性能,没有像 HashTable 那样对整个表加锁,而是通过多个 Segment 把一个表分成多个段,每段各自加锁;
- 这样如果多个线程操作不同的 Segment,则可以并发进行,只有操作同一个 Segment 时才会竞争锁。
6.1.2.1.2 构造方法
构造方法有多个,都调用了下面这个:
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
//ssize 实际上就是并发度,对传入的 concurrencyLevel 做 2 的 n 次幂处理
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
//每个 Segment 下面的 HashEntry[] 大小最小是 2,同样做 2 的 n 次幂处理
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
这里有三个参数:
- initialCapacity:初始化容量;
- loadFactor:负载因子;
- concurrencyLevel:并发度,即最大并发访问线程数,也就是 Segment 的数量。
需要注意的是,ConcurrentHashMap 会对初始化参数做一些处理,保证 Segment 的数量,以及 Segment 下面 HashEntry 的数量都是 2 的 n 次幂。
初始化动作如下:
- 设置实际并发度为大于等于 concurrencyLevel 的最小的 2 的 n 次幂(最小是 1),如传入 3,则实际并发度为 4,并发度确定之后是不可更改的;
- 传入的 initialCapacity 均分到各个 Segment,得到每个 Segment 下面 HashEntry 数组的初始容量(最小是 2),如实际并发度为 4,传入 initialCapacity 为 7,则 HashEntry 数组初始容量为 7/4 + 1 = 2;
- 负载因子与 HashMap 中一样,这里每个 Segment 独立计算 rehash 阈值(capacity * loadFactor),也就是每个 Segment 下面的 HashEntry 数组的容量不一定相同;
- 初始化 Segment 数组,及 Segment[0]。
为什么使用 2 的 n 次幂,也是有讲究的,主要是为了快速定位:
一般计算某个元素的 hash 会落在哪个 slot 是通过取余计算,即 index = hash % capacity,但是当 capacity == 2 ^ n 时,hash % capacity == hash & (capacity - 1),因此相对较慢的取余运算就可以转化为相对较快的位与运算。
每次扩容时,capacity 乘 2 也是有讲究的,主要是为了快速扩容:
(hash % (2 * capacity)) - (hash % capacity ) 也就是 newIndex 与 oldIndex 之差,只有两种结果,要么是 0,要么是 capacity,利用这个特性,扩容之后,没有必要对每个元素重新处理 hash 碰撞,分别搬到 oldIndex 和 oldIndex + capacity 的位置即可。
6.1.2.1.3 get
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
get 方法流程:
- 利用 key 的 hash 算出来这个元素在哪个 Segment 里面;
- 利用 key 的 hash 算出来这个元素在 Segment 里面 HashEntry 数组的哪个位置;
- 去这个位置下面找这个元素,如果这个位置存在 hash 碰撞,则遍历链表。
可以看到,get 操作是不加锁的,仅通过 volatile 变量保证元素变更的可见性。
这里需要注意 volatile 的一个链路:
/**
* The segments, each of which is a specialized hash table.
*/
final Segment<K,V>[] segments;
首先,Segment 数组是 final 的,这个引用不会变更。Segment 数组元素实际也被实现成了不可变的。
/**
* The per-segment table. Elements are accessed via
* entryAt/setEntryAt providing volatile semantics.
*/
transient volatile HashEntry<K,V>[] table;
Segment 里面的 HashEntry 数组是 volatile 的,所以 HashEntry 数组对象的引用变更对其它线程是可见的。
但,需要注意的是 volatile 修饰数组对象,只能保证数组对象本身的可见性,并不能保证数组元素的引用变更的可见性。
所以 get 方法里访问 HashEntry 数组元素没有直接使用下标访问,而是使用了 Unsafe 类的 getObjectVolatile 方法。
6.1.2.1.4 put
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
还是先根据 hash 找到 Segment,然后调用 Segment 的 put 方法,当然如果有必要会先初始化:
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
因为在 ConcurrentHashMap 初始化的时候,只初始化了 Segment[0],这里初始化其它 Segment 时沿用 Segment[0] 的容量和负载因子。初始化 Segment 由于是写操作,所以这里用了 cas 自旋。
Segment 初始化完成之后,就执行 Segment 的 put 操作:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
这里只需要关注开始的 tryLock() 和最后的 unlock() 即可。因为 Segment 本身继承自 ReentrantLock,所以它自己就是一个锁,直接调用自己的加锁方法。
加锁之后,其它操作就跟 HashMap 的 put 操作没什么两样了。
如果元素数量超过阈值(容量 * 负载因子 )就会 rehash,当然如果容量已经扩容到最大,就没办法再 rehash,只能把链表挂得越来越长了。
不过为了提升性能,这里在加锁的时候还做了一些小动作,竞争锁失败的线程走到这里进行重试:
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
这里有两个小动作:
- 前几次竞争锁都是通过不阻塞的 tryLock 方法进行的,当重试大于一定次数(多核 64 次,单核 1 次)时才会使用 lock 方法进行阻塞式加锁;
- 在重试获得锁的过程中,会同时把 HashEntry 先 new 出来,这样等拿到锁就可以直接用了,节约时间。
6.1.2.1.5 remove
final V remove(Object key, int hash, Object value) {
if (!tryLock())
scanAndLock(key, hash);
V oldValue = null;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> e = entryAt(tab, index);
HashEntry<K,V> pred = null;
while (e != null) {
K k;
HashEntry<K,V> next = e.next;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
if (value == null || value == v || value.equals(v)) {
if (pred == null)
setEntryAt(tab, index, next);
else
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
remove 方法与 put 方法类似,都是加锁操作,加锁过程也是一样,先重试非阻塞式加锁,不行再阻塞加锁。
6.1.2.1.6 rehash
private void rehash(HashEntry<K,V> node) {
/*
* Reclassify nodes in each list to new table. Because we
* are using power-of-two expansion, the elements from
* each bin must either stay at same index, or move with a
* power of two offset. We eliminate unnecessary node
* creation by catching cases where old nodes can be
* reused because their next fields won't change.
* Statistically, at the default threshold, only about
* one-sixth of them need cloning when a table
* doubles. The nodes they replace will be garbage
* collectable as soon as they are no longer referenced by
* any reader thread that may be in the midst of
* concurrently traversing table. Entry accesses use plain
* array indexing because they are followed by volatile
* table write.
*/
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
HashEntry<K,V> lastRun = e;
int lastIdx = idx;
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
newTable[lastIdx] = lastRun;
// Clone remaining nodes
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
rehash 时将原来 HashEntry 数组容量乘 2,然后遍历旧的 HashEntry 数组里面的元素,复制到新的 HashEntry 数组。
rehash 不用加锁,因为它只会在 put 的时候被触发,而 put 方法是加锁的。
这里也有一个小动作:在遍历链表的时候,会在链表上找出一个元素 lastRun,在这个元素后面,所有的元素 rehash 之后仍然跟这个元素落在 HashEntry 数组的同一个 index 上面。这样就只需要把 lastRun 元素复制到新的 HashEntry 数组就可以了,后面的元素直接复用不需要复制,因为反正在同一个 index 上面,链表指针也不会变。这样可以提高一些效率。
6.1.2.1.7 isEmpty
public boolean isEmpty() {
/*
* Sum per-segment modCounts to avoid mis-reporting when
* elements are concurrently added and removed in one segment
* while checking another, in which case the table was never
* actually empty at any point. (The sum ensures accuracy up
* through at least 1<<31 per-segment modifications before
* recheck.) Methods size() and containsValue() use similar
* constructions for stability checks.
*/
long sum = 0L;
final Segment<K,V>[] segments = this.segments;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
if (seg.count != 0)
return false;
sum += seg.modCount;
}
}
if (sum != 0L) { // recheck unless no modifications
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
if (seg.count != 0)
return false;
sum -= seg.modCount;
}
}
if (sum != 0L)
return false;
}
return true;
}
modCount 是 Segment 被更改的次数。
算两次,如果在两次计算中,每个 Segment 都没有元素,并且没有线程对 ConcurrentHashMap 做过增删操作,那么可以认为 isEmpty 。
不加锁,通过对比两次计算结果得出结论。
6.1.2.1.8 size
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
通过遍历累加每个 Segment 的元素数量,连续统计两次 size 和 modCount。如果两次 modCount 相等,即在两次统计期间,没有线程对 ConcurrentHashMap 做增删操作,那么就认为统计的结果是对的。
如果两次 modCount 不等,说明有线程在更新 ConcurrentHashMap,那么再继续重新统计。
需要注意的是,如果重试一定次数后仍然没有统计出来,也就是不断有线程更新 ConcurrentHashMap 干扰 size 的统计,那么就要加锁,并且锁的是全表。
因此,size 方法的开销可能会比较大,在并发写较多的场景下谨慎使用。
6.1.2.1.9 contains 和 containsValue
public boolean containsValue(Object value) {
// Same idea as size()
if (value == null)
throw new NullPointerException();
final Segment<K,V>[] segments = this.segments;
boolean found = false;
long last = 0;
int retries = -1;
try {
outer: for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
//重试一定次数后,锁全表
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
long hashSum = 0L;
int sum = 0;
for (int j = 0; j < segments.length; ++j) {
HashEntry<K,V>[] tab;
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null && (tab = seg.table) != null) {
for (int i = 0 ; i < tab.length; i++) {
HashEntry<K,V> e;
for (e = entryAt(tab, i); e != null; e = e.next) {
V v = e.value;
if (v != null && value.equals(v)) {
found = true;
break outer;
}
}
}
sum += seg.modCount;
}
}
if (retries > 0 && sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return found;
}
contains 方法调用了 containsValue 方法。
这个方法不但要遍历所有 Segment 的所有元素,同样在重试一定次数后还可能会锁全表遍历,谨慎使用。
6.1.2.1.10 总结
在 jdk1.7 中 ConcurrentHashMap 的实现:
- 保证并发安全的方式:加锁,使用的是显式锁 ReentrantLock;
- 涉及的主要数据类型:分段锁 Segment,节点 HashEntry;
- 数据结构:数组,hash 冲突时用链表;
- 锁优化:把 map 切分成多个 Segment(段),各 Segment 使用不同的锁,从而减少锁竞争;
- 负载因子和扩容的设定与 HashMap 相同,但各 Segment 独立扩容;
- 虽然 map 可扩容,但 Segment 不可扩容,ConcurrentHashMap 创建时设置了多大就多大;
- 不加锁的操作:get,isEmpty,containsKey;
- 锁单个 Segment 的操作:put,remove,rehash;
- (可能会)锁全部 Segment 的操作:size,contains,containsValue;
- 弱一致性:由于 get 不加锁,所以有可能 get 到过期数据;而 HashTable 是强一致;
- 不可靠的方法:isEmpty,size,contains 等,如,刚判断完 containsKey(“xxx”) 返回 true,还没有做任何接下来的操作,其它线程可能就把这个 key 删掉了;
- 可靠的方法:put,如,并发 put 两个 hash 冲突的数据,一定会形成链表;而 HashMap 的并发 put,则有可能 put 完只有一个数据。
6.1.2.2 jdk1.8 的实现
6.1.2.2.1 数据结构
jdk1.8 中 ConcurrentHashMap 内部就是一个 Node 数组。
hash 冲突时则使用链表或红黑树:
- put 时,如果链表长度达到 8(不含新 put 的节点),并且 capacity 达到 64,将链表转化为红黑树;
- remove 时,在 remove 第三层最左一个元素时会发生红黑树转链表,所以可能发生在 remove 剩余的第 4 ~7 个元素时;
- rehash 时,如果原来的红黑树拆分到 Node[] 的两个不同的 index 之后,节点数量 <= 6,则在迁移节点的时候转换为链表。
与 jdk1.7 所有的元素都封装在 HashEntry 中不同,在 jdk1.8 中共有四种数据类型:
- Node:普通节点,刚开始所有的节点都是 Node;
- TreeNode:将链表转换为红黑树之后,红黑树上的所有节点就变成了 TreeNode(Node 子类);
- TreeBin:红黑树的封装类,内部持有红黑树的 root 节点。也是 Node 子类;
- ForwardingNode:和 TreeBin 一样也是只会出现在数组元素中,它表示 ConcurrentHashMap 当前正在扩容,并且数组上 ForwardingNode 所表示的这个 slot 已经搬迁到新的 Node 数组了。
6.1.2.2.2 构造方法
构造方法主要有两个。
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
可以并没有在构造方法里面初始化 Node[],就初始化了一个 sizeCtl 变量,这是一个多功能变量:
- 在 Node[] 初始化之前,sizeCtl = 初始容量;
- 在 Node[] 初始化开始到完成之前,sizeCtl = -1,作为正在初始化的一个标识位;
- 在 Node[] 初始化完成之后,没有在扩容的时候,sizeCtl = 容量 * 0.75,也就是扩容阈值;
- 在 Node[] 扩容期间,sizeCtl 是一个(比 -1 还小的)负数,并且记录了参与并发扩容的线程数。
这里设置的初始容量比较奇怪,是比入参 initialCapacity * 1.5 大的最小的 2 的 n 次幂,如:
-
initialCapacity = 4,则 initialCapacity * 1.5 = 6,初始容量为 2^3 = 8;
-
initialCapacity = 10,则 initialCapacity * 1.5 = 15,初始容量为 2^4 = 16。
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
这个构造方法看上去完全是为了兼容低版本的 jdk。因为 concurrencyLevel 参数除了会对初始容量造成一点很小的影响,没其它作用。而 loadFactor 也是除了对初始容量造成一些影响以外再无其它作用。
从第一次 put 元素时会调用的初始化方法可以看到 loadFactor 其实是写死为 0.75 的:
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;
}
主要在这两行代码:
sc = n - (n >>> 2); // 相当于 n - 0.25n = 0.75n
sizeCtl = sc; //即下次触发扩容的阈值是 sc = 0.75n
6.1.2.2.3 put
主要逻辑在下面这个方法,这里贴出来的源码省略了一些细节:
final V putVal(K key, V value, boolean onlyIfAbsent) {
//1.key,value 都不允许为 null
if (key == null || value == null) throw new NullPointerException();
//2.计算 hash
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//3.第一次 put 的时候初始化 Node[]
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//4.Node[] 当前 slot 为 null,可直接 put,结束
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
}
//5.MOVED 表示当前 Node[] 正在扩容,此时暂停 put,协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//6.锁当前这一个 slot
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
//7.Node[] 上的元素 hash > 0,表示这个 slot 下面挂着一个链表,
//执行链表的插入操作
}
else if (f instanceof TreeBin) {
//8. Node[] 上的元素类型为 TreeBin,表示这个 slot 下面挂着一个红黑树,
//执行红黑树的插入操作
}
}
}
if (binCount != 0) {
//9.当链表元素 > 8 个(正在 put 第 9 个元素)时,链表转红黑树
//实际上当 capacity < 64 的时候会直接扩容而不是转红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//10.统计元素个数,size + 1,超出阈值会进行扩容
addCount(1L, binCount);
return null;
}
上面的注释里面已经标注了 10 个关键逻辑,需要详细说明的是:
- 第 4 步:如果当前 slot 是空的,直接 put,不用加锁了,当然自旋 + cas 还是需要的。这是相比 jdk1.7 的一个优化点,put 操作不一定会加锁。
- 第 6 步:如果要加锁,仅仅锁了当前一个 slot,用的是 synchronized 关键字。相比 jdk1.7 锁了一个 Segment,锁粒度变小了,这也是一个优化。
- 第 5 步:一个线程执行 put 操作,“碰巧”发现此时另一个线程正在对 ConcurrentHashMap 扩容,于是暂停 put,利用当前线程的资源协助其它线程进行扩容,所以扩容操作实际可能是并发进行的。jdk1.7 也是并发扩容的,但 jdk1.7 的并发扩容仅限于不同的 Segment,而 jdk1.8 的并发扩容对整个 ConcurrentHashMap 都是并发进行的。触发协助扩容的时机在后面扩容部分再讲。
6.1.2.2.4 并发扩容
扩容会在 put 元素时,在 addCount 方法里面触发:
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
//1.cas 操作,根据增加(或删除)的元素数量更新 baseCount 变量
//省略部分代码...
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//2.元素数量大于阈值,扩容
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
//省略部分代码...
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
//3.sc < 0,其它线程正在扩容,当前线程加入一起扩容
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//4.当前线程扩容
transfer(tab, null);
//5.当前线程扩容完成,统计当前元素数量,继续 while 循环判断是否又要继续扩容
s = sumCount();
}
}
}
所谓扩容,就是新建一个 Node[],把原来的 Node[] 上面所有的节点转移过去,转移完成后用新的 Node[] 替换原来的 Node[]。
具体的扩容操作,在 transfer 方法:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//1.结合运行环境 cpu 数量,决定并发扩容步长,
//即每个线程每次会从 Node[] 领取多少个 slot 作为自己迁移节点的任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
//2.初始化另一个 Node[],容量为当前两倍,作为迁移 Node 用
//省略具体逻辑...
}
int nextn = nextTab.length;
//注意这个 ForwardingNode
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
//3.根据前面计算的步长,从原 Node[] 选取下标从 bound 到 i 的 slot,逐一迁移,
//迁移的时候是倒着进行的,所以下标 bound 会比 i 小,
//省略具体逻辑...
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//4.扩容完成,原 Node[] 都已经转移到新的 Node[],退出
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//...
}
//5.1.当前 slot 为空,不用转移节点,直接设为 ForwardingNode,
//表示当前 slot 转移完成。
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//5.2.注意 ForwardingNode 的 hash 写死的就是 MOVED,
//所以这里意味着当前 slot 已经被转移完成设为 ForwardingNode 了。
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//5.3.具体的节点迁移逻辑,锁当前 slot
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
//5.3.1.链表迁移逻辑...
}
else if (f instanceof TreeBin) {
//5.3.2.红黑树迁移逻辑...
//...
//6.这里有一个红黑树转链表的逻辑,阈值是 6
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//5.3.3.迁移完成同样把当前 slot 设为 ForwardingNode
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
并发扩容逻辑非常复杂,上面已经去掉的很多实现细节,另外还有一个 helpTransfer 方法会进入 transfer 方法,不再细说。
另外单独看一下并发扩容的开始与结束的逻辑:
private final void addCount(long x, int check) {
//...
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//2.协助其它线程发起扩容,sizeCtl ++
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//1.自己线程发起的扩容,
//sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//...
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
//...
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//3.当前线程节点迁移结束,且所有的节点迁移任务都已经分配,sizeCtl--
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//sizeCtl 又回到了发起扩容时设置的初始值,说明当前是最后一个线程
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
//...
}
}
并发扩容开始时,sizeCtl 会被设置为一个初始的负值,(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2,每增加一个线程参与并发扩容就 +1。
并发扩容结束时,每有一个线程完成扩容任务,不再参与并发扩容,sizeCtl 就减 1,哪个线程减 1 之后 sizeCtl 又回到了初始的负值,它就是最后一个完成扩容任务的线程,它要负责把 finishing 变量改为 true,其它线程不用管直接 return。
并发扩容的过程举例(以容量 64,cpu*4 为例,算出来步长应该是 16)来说就是:
- 线程 1:put 的时候发现当前元素数量超过阈值,需要扩容,于是选取了数组下标 63~48 的 slot,逐一转移到新的数组,每转移完成一个 slot 就把原数组上的 slot 设为 ForwardingNode;
- 线程 2:put 的元素 hash 取余之后假设是 62,数组下标 62 的元素发现是一个 ForwardingNode,可知其它线程正在扩容,于是协助扩容,下标 63~48 的迁移任务已被线程 1 领取了,于是选取下标 47~32 的任务;
- 线程 3:put 的元素 hash 取余之后假设是 33,线程 2 还没有完成下标 33 的 slot 的转移,slot 还没有被设为 ForwardingNode,所以线程 3 并不知道现在正在扩容,继续向原数组下标 33 的 slot 插入元素,当然这里会加锁;
- 线程 1:由于没有更多的线程加入协助扩容,这时线程 1 完成了 63~48 的迁移,于是选取下标 31~16 的 slot 进行转移;
- 线程 2:完成了下标 47~32 的 slot 迁移任务,选取下标 15~0 的 slot 进行转移;
- 线程 1:完成了 31~16 的 slot 迁移任务,没有更多的 slot 需要迁移,并且发现其它线程还没有完成任务,直接 return;
- 线程 2:完成了 15~0 的 slot 迁移任务,没有更多的 slot 需要迁移,并且发现自己是最后一个完成任务的,于是扩容结束,要负责把原来的数组扔掉,以新的数组取代。
链表和红黑树的迁移这里也有个小技巧:
- 基于一个事实:设 hash % capacity = index,那么两倍扩容后,hash % (2 * capacity)的值,要么是 index,要么是 index + capacity;
- 所以,只要把同一个 slot 上的链表或红黑树,按照扩容后 index 是否与原来相等拆分成两份,直接放到扩容后下标 index 和 Index + capacity 的位置即可。
6.1.2.2.5 get
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//1.hash < 0,要么是 TreeBin,要么是 ForwardingNode
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//2.链表
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
get 方法比较简单,不需要加锁,一共有三种情况:
- hash > 0:链表查找;
- hash < 0,TreeBin:红黑树查找;
- hash < 0,ForwardingNode,正在扩容并且当前 slot 已经迁移到了新的数组,查找新的数组。
6.1.2.2.6 remove
remove 方法与 put 方法几乎一样,主要有两个区别:
- remove 完之后调用 addCount 方法,入参是两个 -1,表示元素数量增加 -1,并且不需要检查扩容;
- remove 红黑树上的元素可能触发红黑树转链表,不过与扩容不同,这里红黑树转链表的阈值并不是 6,而是红黑树第三层最左一个元素,如果它被删了,就要转链表。
6.1.2.2.7 size
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
size 方法相对 jdk1.7 做了优化,不可能再锁整个数组了,而且全程不加锁。
size 的统计分为两部分:
- baseCount:记录 put 或 remove 元素时 cas 增减 count 成功的;
- CounterCell[]:记录 put 或 remove 元素时,cas 增减 count 失败的。
以上这两部分累加即可得到 size,当然并发场景下获取 size 仍然意义不大。
6.1.2.2.8 containsValue
public boolean containsValue(Object value) {
if (value == null)
throw new NullPointerException();
Node<K,V>[] t;
if ((t = table) != null) {
Traverser<K,V> it = new Traverser<K,V>(t, t.length, 0, t.length);
for (Node<K,V> p; (p = it.advance()) != null; ) {
V v;
if ((v = p.val) == value || (v != null && value.equals(v)))
return true;
}
}
return false;
}
粗略看一下,相比 jdk1.7 也优化过了,不再加锁了,但还是意义不大。
6.1.2.2.9 putIfAbsent,compute
并发场景下,因为非原子性的问题,isEmpty,size,contains 等操作意义都不大。
比较有用的可能是下面这几个方法,它们是对整个读写过程加锁的:
- putIfAbsent;
- compute;
- computeIfAbsent;
- computeIfPresent。
6.1.2.2.10 总结
数据结构:
- 数组、链表、红黑树;
- 链表转红黑树:put 时链表长度超过 8;
- 红黑树转链表:remove 时发现第三层最左元素已被删除;rehash 时红黑树节点数小于 6;
怎样保证并发安全:
- cas:值替换用 cas,如数组设值,size 计数;
- synchronized:找到要操作的元素落在数组的哪个 slot 后,对这个 slot 的操作都是 synchronized;
锁粒度:
- 更改操作(put,remove,compute 等)锁数组上的一个 slot;
- 查询操作(get,size,contains 等)不加锁;
一些细节:
- capacity 永远是 2 的 n 次幂;
- loadfactor 永远是 0.75,指定了也没用;
- 链表与红黑树的切换:
- put 时,节点数大于 8,且 capacity < 64,扩容;
- put 时,节点数大于 8,且 capacity >= 64,链表转红黑树;
- remove 时,红黑树第三层最左元素被删除时,红黑数转链表;
- 扩容时,红黑树节点数 <= 6,红黑树转链表。
6.1.2.3 jdk1.7 与 jdk1.8 实现对比
jdk1.8 中 ConcurrentHashMap 实现的一些变化:
- 去掉了 Segment,进一步减小锁粒度;
- ReentrantLock 改为 cas + synchronized;
- 数组链表结构改为数组链表红黑树结构;
- 一些方法实现优化:扩容,size,contains 等。
6.2 写时复制容器
特点:
- 更新:每一次更新(add,remove 等)都会将旧表数据复制到新表;
- 读取:从旧表读取。
数据结构:数组;
加锁方式:ReentrantLock;
锁粒度:
- 更新操作,锁整个表;
- 查询操作,不加锁;
实现类:
- CopyOnWriteArrayList
- CopyOnWriteArraySet(内部实现直接使用 CopyOnWriteArrayList);
适用场景:读多写少。
6.3 并发有序容器
特点:有序;
数据结构:跳表;
加锁方式:无锁,cas + 自旋;
实现类:
- ConcurrentSkipListMap;
- ConcurrentSkipListSet(内部是一个 ConcurrentSkipListMap);
适用场景:有排序要求。
6.4 并发非阻塞队列
特点:
- 无界(容量无限,意味着 add/offer 永远成功,理论上无限,实际受硬件资源限制,比如可能会 OutOfMemory);
- 不阻塞(存取都是直接返回,没有阻塞方法);
数据结构:链表;
加锁方式:无锁,cas + 自旋;
实现类:
- ConcurrentLinkedQueue;
- ConcurrentLinkedDeque。
6.5 并发阻塞队列
阻塞:
- 增加阻塞方法 put:如果队列已满,阻塞等待其它线程取走元素;
- 增加阻塞方法 take:如果队列已空,阻塞等待其它线程放入元素。
6.5.1 ArrayBlockingQueue
特点:
- 有界;
- 数组结构;
- ReentrantLock,put 和 take 是同一个 Lock;
- await 和 signal 实现等待和通知。
6.5.2 LinkedBlockingQueue
特点:
- 有界;
- 链表结构;
- ReentrantLock,put 和 take 是两个不同的 Lock(吞吐量比 ArrayBlockingQueue 高);
- await 和 signal 实现等待和通知。
6.5.3 LinkedBlockingDeque
特点:
- 双向队列;
- 有界;
- 双向链表结构
- ReentrantLock,put 和 take 是同一个 Lock;
- await 和 signal 实现等待和通知。
6.5.4 LinkedTransferQueue
特点:
- 增加 transfer 方法:使用 transfer 方法放入元素时,必须要有线程在等待取出元素,否则阻塞;
- 无界;
- 链表结构;
- 无锁,cas + 自旋(虽说是无锁,但 cas + 自旋 + park/unpark 基本相当于 Lock);
- park 和 unpark 实现等待和通知。
6.5.5 PriorityBlockingQueue
特点:
- 优先级:并非先进先出,每次都是剩下的元素中最小的先出队;
- 无界;
- 小顶堆结构;
- ReentrantLock 加锁;
- await 和 signal 实现等待和通知。
6.5.6 DelayQueue
特点:
- 延迟出队:DelayQueue 中的元素必须实现 Delayed 接口,必须过期才出队(但未必先过期的先出队,这一点很奇怪,后面讲);
- 无界;
- 小顶堆结构,DelayQueue 内部就是一个 PriorityQueue,延迟队列算是 PriorityQueue 的一个典型应用;
- ReentrantLock 加锁;
- await 和 signal 实现等待和通知。
为什么说它并非一定是先过期的先出队呢?
示例,下面个 demo 会让最后过期的先出队,以至于先过期的所有元素被堵在队列里出不来:
package per.lvjc.concurrent.collection;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
public class DelayQueueTest {
// DelayQueue 中的元素必须实现 Delayed 接口
private static class Ele implements Delayed {
public Ele(long value) {
this.value = value;
this.deadline = System.currentTimeMillis() + value;
}
private long value;
private long deadline;
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(deadline - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
//Delayed 接口继承了 Comparable 接口,这个接口方法是从 Comparable 那里继承来的
@Override
public int compareTo(Delayed o) {
// 正确的实现应该是 Long.compare(this.getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS))
// 这里故意反过来比较
return Long.compare(o.getDelay(TimeUnit.MILLISECONDS), this.getDelay(TimeUnit.MILLISECONDS));
}
}
public static void main(String[] args) throws InterruptedException {
DelayQueue<Ele> delayQueue = new DelayQueue<>();
//放入第一个元素, 10 秒后过期
delayQueue.offer(new Ele(10000));
//放入第二个元素, 0.5 秒后过期
delayQueue.offer(new Ele(500));
//放入第三个元素, 0.02 后过期
delayQueue.offer(new Ele(20));
//取出一个元素,并不会取出 20 的那个,而是会阻塞 10 秒,然后取出 10000 的那个
System.out.println(delayQueue.take().value);
}
}
出现这种现象的原因是,DelayQueued 里面维护的一个 PriorityQueue 竟然不是依据 getDelay 方法来排序,而是依赖于 compareTo 方法。这就导致,如果我们 compareTo 方法瞎实现,那么 DelayQueue 就会出现问题——最先过期的没有最先出队。
从 DelayQueue 的功能——最先过期的元素先出队——来看,PriorityQueue 里面放在堆顶的元素一定是最先过期的,也就是 getDelay 返回值最小的。所以,暴露一个 compareTo 接口出来让用户自己实现就显得很多余(因为这个逻辑是固定的),反而增加了用户的工作量以及出错的风险。很奇怪 Doug Lea 大师为什么会这么写。
6.5.7 SynchronousQueue
特点:
- 同步队列:队列 capacity = 0,所以队列里面没有空间存放哪怕一个元素,存放元素时必须直接交给获取元素的线程,否则阻塞;获取元素时必须直接从存放元素的线程获取,否则阻塞;
- 有界,且 capacity = 0;
- cas + 自旋 + park/unpark。
示例:
package per.lvjc.concurrent.collection;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
public class SynchronousQueueTest {
public static void main(String[] args) throws InterruptedException {
SynchronousQueue<String> queue = new SynchronousQueue<>();
//SynchronousQueue<String> queue = new SynchronousQueue<>(true);
for (int i = 0; i < 5; i++) {
// new 5 个线程去 take,会阻塞直到有线程 put
new Thread(() -> {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + " begin...");
try {
System.out.println(threadName + " take: " + queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadName + " end...");
}, "thread-" + i).start();
TimeUnit.MILLISECONDS.sleep(200);
}
// new 一个线程 put 5 个元素,take 的线程会被依次唤醒
new Thread(() -> {
System.out.println("put thread begin");
for (int i = 0; i < 5; i++) {
try {
queue.put("element" + i);
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("put thread end");
}).start();
}
}
SynchronousQueue 有 fair 和 unfair 两种模式:
- fair:先等待的线程先被唤醒(队列模式);
- unfair:先等待的线程后被唤醒(栈模式,默认);
上面的代码,unfair 模式输出:
thread-0 begin...
thread-1 begin...
thread-2 begin...
thread-3 begin...
thread-4 begin...
put thread begin
thread-4 take: element0
thread-4 end...
thread-3 take: element1
thread-3 end...
thread-2 take: element2
thread-2 end...
thread-1 take: element3
thread-1 end...
thread-0 take: element4
thread-0 end...
put thread end
fair 模式输出:
thread-0 begin...
thread-1 begin...
thread-2 begin...
thread-3 begin...
thread-4 begin...
put thread begin
thread-0 take: element0
thread-0 end...
thread-1 take: element1
thread-1 end...
thread-2 take: element2
thread-2 end...
thread-3 take: element3
thread-3 end...
thread-4 take: element4
thread-4 end...
put thread end