线程安全集合类可以分为三大类:
-
遗留的线程安全集合如 Hashtable , Vector
-
使用 Collections 装饰的线程安全集合,如:
Collections.synchronizedCollection
Collections.synchronizedList
Collections.synchronizedMap
Collections.synchronizedSet
Collections.synchronizedNavigableMap
Collections.synchronizedNavigableSet
Collections.synchronizedSortedMap
Collections.synchronizedSortedSet
java.util.concurrent.* -
重点介绍 java.util.concurrent.下的线程安全集合类,可以发现它们有规律,里面包含三类关键词:Blocking、CopyOnWrite、Concurrent。
Blocking 大部分实现基于锁,并提供用来阻塞的方法。
CopyOnWrite 之类容器修改开销相对较重。
Concurrent 类型的容器。
- 内部很多操作使用 cas 优化,一般可以提供较高吞吐量
- 弱一致性
-遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
-求大小弱一致性,size 操作未必是 100%
-准确读取弱一致性
1. ConcurrentHashMap
1. 1 练习:单词计数
1. 2 问题与解决
虽然用了ConcurrentHM,但get个put两个是不能保证原子性的。
所以用里面包装在一个方法里的,computeIfAbsent()
(里面把synchronized加在一个链表头上)。
上面改成Integer value = map.computeIfAbsent(word, (key)->1);
但此时只能给1,不能累加。这时可以用LongAdder。
2. HashMap原理
HashMap不完全的实现有哪些并发问题。
拉链法:jdk7头插,8尾插。 链表长度到原来数组的3/4阈值时,进行扩容。 容量翻倍,重新算下标。
2. 1 JDK 7 HashMap 并发死链
P279
死链,究其原因,是因为在多线程环境下使用了非线程安全的 map 集合。
JDK 8 虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)。
2. 2 JDK 8 ConcurrentHashMap
2. 2. 1 重要属性和内部类
2. 2. 2 重要方法
2. 2. 3 构造器分析
2. 2. 4 get 流程
- 根据 hash 值计算位置。
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找它。如果是链表,遍历查找。
2. 2. 5 put 流程
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要执行树化方法,在treeifyBin
中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
2. 2. 6 size 计算流程
size 计算实际发生在 put,remove 改变集合元素的操作之中
- 没有竞争发生,向 baseCount 累加计数
- 有竞争发生,新建 counterCells,向其中的一个 cell 累加计数
counterCells 初始有两个 cell
如果计数竞争比较激烈,会创建新的 cell 来累加计数
Java 8 数组(Node) +(链表 Node | 红黑树 TreeNode )以下数组简称(table),链表简称(bin)
-
初始化,使用 cas 来保证并发安全,懒惰初始化 table
-
树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程会用 synchronized 锁住链表头
-
put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素添加至 bin 的尾部
-
get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 它会让 get 操作在新table 进行搜索
-
扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中
-
size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加即可
2. 3 JDK 7 ConcurrentHashMap
比较:
Java7 中 ConcurrentHashMap
使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment
都是一个类似 HashMap
数组的结构,它可以扩容,冲突会转化为链表。但是 Segment
的个数一但初始化就不能改变。
Java8 中的 ConcurrentHashMap
使用的 Synchronized
锁加 CAS 的机制。结构从 Java7 中的【 Segment
数组 + HashEntry
数组 + 链表】 进化成了 【Node 数组 + 链表 / 红黑树】,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化
3. LinkedBlockingQueue
3. 1 基本的入队出队
入队:
出队:
3. 2 加锁分析
# LinkedBlockingQueue的高明之处在于用了两把锁和 dummy 节点
- 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
- 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
消费者与消费者线程仍然串行
生产者与生产者线程仍然串行
线程安全分析:
- 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是head 节点的线程安全。两把锁保证了入队和出队没有竞争。
- 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争。
- 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞。
3. 3 性能比较
主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较:
- Linked 支持有界,Array 强制有界。
- Linked 实现是单向链表,Array 实现是数组。
- Linked 是懒惰的,而 Array 需要提前初始化 Node 数组。
- Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的。
- Linked 两把锁,Array 一把锁。
4. ConcurrentLinkedQueue 原理
ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是
- 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行。
- dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争。
- 只是这【锁】使用了 cas 来实现,所以这是一个非阻塞队列。
拓展:
ConcurrentLinkedQueue 应用还是非常广泛的。
比如之前的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用。
5. CopyOnWriteArrayList
实现线程安全的List
。
和ReentrantReadWriteLock
读写锁的设计很类似,只有读读不互斥。
但CopyOnWriteArrayList
中的读取操作是完全无需加锁的。写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。
底层实现采用了 写入时拷贝 的思想,增删改操作会将底层数组拷贝一份副本,更改操作在副本数组上执行,修改完之后再将修改后的数组赋值回去,这样就不影响其它线程的并发读和写,读写分离。