接上一篇《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
上面列举了 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 数据结构
![6b2c044de106b3f0b7127a1a495b96e1.png](https://i-blog.csdnimg.cn/blog_migrate/a936da9d71830efe56a8eb91d9ba777d.jpeg)
jdk1.7 的 ConcurrentHashMap 内部数据结构是一个 Segment 数组,每一个 Segment 又持有一个 HashEntry 数组。
Segment 实际上是一个锁,继承自 ReentrantLock。
Segment 下面的 HashEntry 数组则相当于是一个独立的普通 HashMap,发生 hash 碰撞的时候以链表的形式串在一起。
所以,它的设计思路很简单:
- 为了提升性能,没有像 HashTable 那样对整个表加锁,而是通过多个 Segment 把一个表分成多个段,每段各自加锁;
- 这样如果多个线程操作不同的 Segment,则可以并发进行,只有操作同一个 Segment 时才会竞争锁。
6.1.2.1.2 构造方法
构造方法有多个,都调用了下面这个:
public
这里有三个参数:
- 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
get 方法流程:
- 利用 key 的 hash 算出来这个元素在哪个 Segment 里面;
- 利用 key 的 hash 算出来这个元素在 Segment 里面 HashEntry 数组的哪个位置;
- 去这个位置下面找这个元素,如果这个位置存在 hash 碰撞,则遍历链表。
可以看到,get 操作是不加锁的,仅通过 volatile 变量保证元素变更的可见性。
这里需要注意 volatile 的一个链路:
/**
首先,Segment 数组是 final 的,这个引用不会变更。Segment 数组元素实际也被实现成了不可变的。
/**
Segment 里面的 HashEntry 数组是 volatile 的,所以 HashEntry 数组对象的引用变更对其它线程是可见的。
但,需要注意的是 volatile 修饰数组对象,只能保证数组对象本身的可见性,并不能保证数组元素的引用变更的可见性。
所以 get 方法里访问 HashEntry 数组元素没有直接使用下标访问,而是使用了 Unsafe 类的 getObjectVolatile 方法。
6.1.2.1.4 put
public
还是先根据 hash 找到 Segment,然后调用 Segment 的 put 方法,当然如果有必要会先初始化:
private
因为在 ConcurrentHashMap 初始化的时候,只初始化了 Segment[0],这里初始化其它 Segment 时沿用 Segment[0] 的容量和负载因子。初始化 Segment 由于是写操作,所以这里用了 cas 自旋。
Segment 初始化完成之后,就执行 Segment 的 put 操作:
final
这里只需要关注开始的 tryLock() 和最后的 unlock() 即可。因为 Segment 本身继承自 ReentrantLock,所以它自己就是一个锁,直接调用自己的加锁方法。
加锁之后,其它操作就跟 HashMap 的 put 操作没什么两样了。
如果元素数量超过阈值(容量 * 负载因子 )就会 rehash,当然如果容量已经扩容到最大,就没办法再 rehash,只能把链表挂得越来越长了。
不过为了提升性能,这里在加锁的时候还做了一些小动作,竞争锁失败的线程走到这里进行重试:
private
这里有两个小动作:
- 前几次竞争锁都是通过不阻塞的 tryLock 方法进行的,当重试大于一定次数(多核 64 次,单核 1 次)时才会使用 lock 方法进行阻塞式加锁;
- 在重试获得锁的过程中,会同时把 HashEntry 先 new 出来,这样等拿到锁就可以直接用了,节约时间。
6.1.2.1.5 remove
final
remove 方法与 put 方法类似,都是加锁操作,加锁过程也是一样,先重试非阻塞式加锁,不行再阻塞加锁。
6.1.2.1.6 rehash
private
rehash 时将原来 HashEntry 数组容量乘 2,然后遍历旧的 HashEntry 数组里面的元素,复制到新的 HashEntry 数组。
rehash 不用加锁,因为它只会在 put 的时候被触发,而 put 方法是加锁的。
这里也有一个小动作:在遍历链表的时候,会在链表上找出一个元素 lastRun,在这个元素后面,所有的元素 rehash 之后仍然跟这个元素落在 HashEntry 数组的同一个 index 上面。这样就只需要把 lastRun 元素复制到新的 HashEntry 数组就可以了,后面的元素直接复用不需要复制,因为反正在同一个 index 上面,链表指针也不会变。这样可以提高一些效率。
6.1.2.1.7 isEmpty
public
modCount 是 Segment 被更改的次数。
算两次,如果在两次计算中,每个 Segment 都没有元素,并且没有线程对 ConcurrentHashMap 做过增删操作,那么可以认为 isEmpty 。
不加锁,通过对比两次计算结果得出结论。
6.1.2.1.8 size
public
通过遍历累加每个 Segment 的元素数量,连续统计两次 size 和 modCount。如果两次 modCount 相等,即在两次统计期间,没有线程对 ConcurrentHashMap 做增删操作,那么就认为统计的结果是对的。
如果两次 modCount 不等,说明有线程在更新 ConcurrentHashMap,那么再继续重新统计。
需要注意的是,如果重试一定次数后仍然没有统计出来,也就是不断有线程更新 ConcurrentHashMap 干扰 size 的统计,那么就要加锁,并且锁的是全表。
因此,size 方法的开销可能会比较大,在并发写较多的场景下谨慎使用。
6.1.2.1.9 contains 和 containsValue
public
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 数据结构
![6ffce92e99da6671c419aa8aefc88be5.png](https://i-blog.csdnimg.cn/blog_migrate/d4141893a2c150aa0cf833a45e528728.jpeg)
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
可以并没有在构造方法里面初始化 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
这个构造方法看上去完全是为了兼容低版本的 jdk。因为 concurrencyLevel 参数除了会对初始容量造成一点很小的影响,没其它作用。而 loadFactor 也是除了对初始容量造成一些影响以外再无其它作用。
从第一次 put 元素时会调用的初始化方法可以看到 loadFactor 其实是写死为 0.75 的:
private
主要在这两行代码:
sc
6.1.2.2.3 put
主要逻辑在下面这个方法,这里贴出来的源码省略了一些细节:
final
上面的注释里面已经标注了 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
所谓扩容,就是新建一个 Node[],把原来的 Node[] 上面所有的节点转移过去,转移完成后用新的 Node[] 替换原来的 Node[]。
具体的扩容操作,在 transfer 方法:
private
并发扩容逻辑非常复杂,上面已经去掉的很多实现细节,另外还有一个 helpTransfer 方法会进入 transfer 方法,不再细说。
另外单独看一下并发扩容的开始与结束的逻辑:
private
并发扩容开始时,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
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
size 方法相对 jdk1.7 做了优化,不可能再锁整个数组了,而且全程不加锁。
size 的统计分为两部分:
- baseCount:记录 put 或 remove 元素时 cas 增减 count 成功的;
- CounterCell[]:记录 put 或 remove 元素时,cas 增减 count 失败的。
以上这两部分累加即可得到 size,当然并发场景下获取 size 仍然意义不大。
6.1.2.2.8 containsValue
public
粗略看一下,相比 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
出现这种现象的原因是,DelayQueued 里面维护的一个 PriorityQueue 竟然不是依据 getDelay 方法来排序,而是依赖于 compareTo 方法。这就导致,如果我们 compareTo 方法瞎实现,那么 DelayQueue 就会出现问题——最先过期的没有最先出队。
从 DelayQueue 的功能——最先过期的元素先出队——来看,PriorityQueue 里面放在堆顶的元素一定是最先过期的,也就是 getDelay 返回值最小的。所以,暴露一个 compareTo 接口出来让用户自己实现就显得很多余(因为这个逻辑是固定的),反而增加了用户的工作量以及出错的风险。很奇怪 Doug Lea 大师为什么会这么写。
6.5.7 SynchronousQueue
特点:
- 同步队列:队列 capacity = 0,所以队列里面没有空间存放哪怕一个元素,存放元素时必须直接交给获取元素的线程,否则阻塞;获取元素时必须直接从存放元素的线程获取,否则阻塞;
- 有界,且 capacity = 0;
- cas + 自旋 + park/unpark。
示例:
package
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
首发于 CSDN 博客:
Java并发系列(8)--并发容器_JinchaoLv的博客-CSDN博客blog.csdn.net![960f81a66e2ce94f7a2329ed66ed15e7.png](https://i-blog.csdnimg.cn/blog_migrate/88afa5b16fae833bbf1a326dc87c7408.jpeg)