java取余_Java并发系列(8)——并发容器

接上一篇《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

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

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
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值