线程安全集合类可以分为三大类:
-
遗留的线程安全集合如
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% 准确
-
读取弱一致性
-
-
8.8 ConcurrentHashMap
ConcurrentHashMap 就是线程安全的 map,使用了细粒度的锁,其中利用了锁分段的思想大大提高了并发的效率。1.8 版本舍弃了 segment,并且使用了大量的 synchronized,以及 CAS 无锁操作以保证 ConcurrentHashMap 的线程安全性。
1. HashMap
基本原理
HashMap
的内部是通过数组和链表(或红黑树)结合的方式实现的。具体来说:
-
数组:这是哈希表的主体,每个数组位置称为一个“桶”。每个桶用于存储具有相同哈希值的元素。
-
链表或红黑树:当多个元素哈希到同一个桶时,这些元素会形成一个链表或者在JDK 1.8及以后版本中可能转化为红黑树,以减少查找时间。
工作过程
当向HashMap
添加一个键值对时,HashMap
会根据键的哈希码确定其在数组中的存储位置(桶的位置)。具体步骤如下:
-
计算哈希码:首先通过调用键的
hashCode()
方法来获取其哈希码。 -
哈希函数:然后通过哈希函数处理这个哈希码,以计算出数组索引(桶的位置)。通常,这个函数是哈希码与数组大小减一的位与操作(
index = hashCode & (n-1)
,其中n
是数组的大小)。 -
解决哈希冲突:如果计算得到的数组位置已经被占用,则需要解决哈希冲突。这通常通过链表或红黑树实现。在JDK 1.8及以后,如果一个桶中的结点超过了某个阈值(默认为8),链表就会转换成红黑树,以改善性能。
-
put(K key, V value)
:添加键值对时,如果键已经存在于HashMap
中,则新值会覆盖旧值。如果键不存在,则根据键的哈希码将键值对存储到相应的桶中。 -
get(Object key)
:获取键对应的值时,会先计算键的哈希码,然后使用哈希函数找到对应的桶,最后通过链表或红黑树搜索具体的键值对。 -
加载因子:加载因子是一个度量标准,表示哈希表在其容量自动增加之前可以达到多满。默认的加载因子是0.75,这是时间和空间成本的一种折衷。
HashMap
是非线程安全的,jdk7中后加入的放在链表头部,在多线程扩容过程中会产生并发死链问题,究其原因,是因为在多线程环境下使用了非线程安全的 map 集合。
JDK 8虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序)但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)
2. JDK 8 ConcurrentHashMap
ConcurrentHashMap
通过使用一种更细粒度的锁机制(分段锁或桶锁)来提供线程安全,而不是对整个数据结构使用单一的锁。在JDK 8中,ConcurrentHashMap
的实现从之前的Segment(分段锁)方式被重构为使用Node数组加链表和红黑树,类似于HashMap
的结构。
-
链表转红黑树:和
HashMap
类似,当链表过长时(默认超过8个节点),链表会转化为红黑树,以保持高效的访问速度。 -
使用
synchronized
和CAS操作:ConcurrentHashMap
在JDK 8中使用synchronized
关键字来锁定单个桶中的第一个节点,这比锁定整个段更细粒度。对于简单的更新操作,如put和remove,它使用CAS操作来避免锁的使用,提高效率。
重要属性和内部类
-
Node 类实现了 Map.Entry 接口,主要存放 key-value 对,并且具有 next 域
-
TreeNode树节点,继承于承载数据的 Node 类。红黑树的操作是针对 TreeBin 类的,TreeBin 是对 TreeNode 的再一次封装。
-
TreeBin类用于封装红黑树的结构,封装了很多 TreeNode 节点。实际的 ConcurrentHashMap “数组”中,存放的都是 TreeBin 对象,而不是 TreeNode 对象。
-
ForwardingNode用于协助在哈希表结构变化期间(如扩容操作时)保持映射的操作继续进行,其 key、value、hash 全部为 null,并用 nextTable 包含了对新表的引用,所有对旧表的操作都会被重定向到新表,保持了扩容时的非阻塞性。
// 默认为 0
// 当初始化时, 为 -1
// 当扩容时, 为 -(1 + 扩容线程数)
// 当初始化或扩容完成后,为 下一次的扩容的阈值大小
private transient volatile int sizeCtl;
// 整个 ConcurrentHashMap 就是一个 Node[]
static class Node<K,V> implements Map.Entry<K,V> {}
// hash 表
transient volatile Node<K,V>[] table;
// 扩容时的 新 hash 表
private transient volatile Node<K,V>[] nextTable;
// 扩容时如果某个 bin 迁移完毕, 用 ForwardingNode 作为旧 table bin 的头结点
static final class ForwardingNode<K,V> extends Node<K,V> {}
// 用在 compute 以及 computeIfAbsent 时, 用来占位, 计算完成后替换为普通 Node
static final class ReservationNode<K,V> extends Node<K,V> {}
// 作为 treebin 的头节点, 存储 root 和 first
static final class TreeBin<K,V> extends Node<K,V> {}
// 作为 treebin 的节点, 存储 parent, left, right
static final class TreeNode<K,V> extends Node<K,V> {}
重要方法 - 利用 CAS 算法来保障线程安全的操作
// 获取 Node[] 中第 i 个 Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i)
// cas 修改 Node[] 中第 i 个 Node 的值, c 为旧值, v 为新值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v)
// 直接修改 Node[] 中第 i 个 Node 的值, v 为新值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v)
构造器分析
可以看到实现了懒惰初始化,在构造方法中仅仅计算了 table 的大小,以后在第一次使用时才会真正创建
// 参数用来给定map大小,加载因子以及并发度(预计同时操作数据的线程)
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);
// tableSizeFor 仍然是保证计算的大小是 2^n, 即 16,32,64 ...
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
ConcurrentHashMap 是一个哈希桶数组,如果不出现哈希冲突的时候,每个元素均匀的分布在哈希桶数组中。当出现哈希冲突的时候,采用拉链法的解决方案,将 hash 值相同的节点转换成链表的形式,另外,在 JDK 1.8 版本中,为了防止拉链过长,当链表的长度大于 8 的时候会将链表转换成红黑树。
put 方法
-
调用 put 方法时会调用 putVal 方法,确定好数组的索引 i 后,可以调用
tabAt()
方法获取该位置上的元素,如果当前 Node 为 null 的话,可以直接用 casTabAt 方法将新值插入。 -
如果当前节点不为 null,且该节点为特殊节点(forwardingNode),就说明当前 concurrentHashMap 正在进行扩容操作。通过判断该节点的 hash 值是不是等于 -1(MOVED),来确定当前这个 Node 是特殊节点
-
当
table[i]
不为 null 并且不是 forwardingNode 时,以及当前 Node 的 hash 值大于0fh >= 0)
时,说明当前节点为链表的头节点,那么向 ConcurrentHashMap 插入新值就是向这个链表插入新值。通过synchronized (f)
的方式进行加锁以实现线程安全。-
如果在链表中找到了与待插入的 key 相同的节点,就直接覆盖;
-
如果找到链表的末尾都还没找到的话,直接将待插入的键值对追加到链表的末尾。
-
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[] 当中。最后统计数量时累加 即可
JDK 7 ConcurrentHashMap
JDK 1.7 中,提供了一种粒度更细的分段锁「Lock Striping」。整个哈希表被分为多个段,每个段都独立锁定。读取操作不需要锁,写入操作仅锁定相关的段。
分段锁,就是将数据分段,对每一段数据分配一把锁,它维护了一个 segment 数组,每个 segment 对应一把锁
-
优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
-
缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组构成的。Segment 是一种可重入的锁ReentrantLock,HashEntry 则用于存储键值对数据。
一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。
ConcurrentHashMap 读写过程如下:
get 方法
-
并未加锁,用了 UNSAFE 方法保证了可见性,扩容过程中,get 先发生就从旧表取内容,get 后发生就从新表取内容
-
为输入的 Key 做 Hash 运算,通过 hash 值,定位到对应的 Segment 对象
-
再次通过 hash 值,定位到 Segment 当中数组的具体位置。
put 方法
-
为输入的 Key 做 Hash 运算,过 hash 值,定位到对应的 Segment 对象
-
获取可重入锁
-
再次通过 hash 值,定位到 Segment 当中数组的具体位置。
-
插入或覆盖 HashEntry 对象。
-
释放锁。
rehash 流程
-
发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全
size 计算流程
-
计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
-
如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回
8.9 LinkedBlockingQueue 和 ConcurrentLinkedQueue 原理
阻塞队列(BlockingQueue)代表了一个线程安全的队列,被广泛用于“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
LinkedBlockingQueue 是一个基于链表的线程安全的阻塞队列:
-
容量可选:
LinkedBlockingQueue
可以是一个可选固定容量的队列。在没有指定容量时,默认容量为Integer.MAX_VALUE
,这实际上使得队列的大小仅受内存限制。 -
线程安全:队列内部通过使用锁(Locks)和条件(Conditions)来保证多线程操作的线程安全性。具体来说,它使用了
takeLock
和putLock
两把ReentrantLock
锁:一把用于入队操作,另一把用于出队操作,这样可以提高队列操作的并发性,因为入队和出队操作不会相互阻塞 -
阻塞操作:
LinkedBlockingQueue
支持阻塞的插入和移除操作。当队列满时,插入操作会阻塞;当队列空时,移除操作会阻塞。 -
notEmpty
和notFull
: 这是两个 Condition变量,分别与 takeLock 和 putLock 相关联。当队列为空时,尝试从队列中取出元素的线程将会在 notEmpty 上等待。当新元素被放入队列时,这些等待的线程将会被唤醒。同样地,当队列已满时,尝试向队列中放入元素的线程将会在 notFull 上等待,等待队列有可用空间时被唤醒。
加锁分析
用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
-
消费者与消费者线程仍然串行
-
生产者与生产者线程仍然串行
线程安全分析
-
当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是 head 节点的线程安全。两把锁保证了入队和出队没有竞争
-
当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
-
当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
性能比较
主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较
-
Linked 支持有界,Array 强制有界
-
Linked 实现是链表,Array 实现是数组
-
Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
-
Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
-
Linked 使用两个锁(putLock 和 takeLock),Array 使用一个单独的 ReentrantLock 来控制对队列的访问
ConcurrentLinkedQueue
ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行,只是这【锁】使用了 CAS 来实现:
-
非阻塞算法:
ConcurrentLinkedQueue
使用了基于 Michael & Scott 算法的非阻塞并发算法,这意味着在多线程环境中执行操作时,线程不会进行传统的锁操作,而是依赖原子类和 CAS 操作。这样可以减少线程争用,提高效率。 -
关键方法:
-
offer(E e)
:添加一个元素到队列尾部,如果成功添加元素,则返回 true,并且始终返回true
因为队列是无界的。是一种先进先出(FIFO,First-In-First-Out)的队列。 -
poll()
:使用CAS操作移除并返回队列头部的元素。如果队列为空,则返回null
。 -
peek()
:查看队列头部的元素而不移除它。如果队列为空,则返回null
。
-
8.10 CopyOnWriteArrayList
CopyOnWriteArrayList 是线程安全的 ArrayList
,可以在多线程环境下使用。
-
CopyOnWriteArrayList 遵循写时复制的原则,每当对列表进行修改(例如添加、删除或更改元素)时,都会创建列表的一个新副本,当需要修改列表时,在这份新的副本上进行修改
-
而对旧列表的所有读取操作仍然可以继续,修改完成后,再将原数据结构的引用指向新的副本,这个新副本会替换旧的列表。这样,读操作总是在不变的数据结构上进行,从而可以不需要同步。
-
适用于读多写少的并发应用场景。
get 弱一致性
迭代器弱一致性
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iter = list.iterator();
new Thread(() -> {
list.remove(0); // 移除列表中内容
System.out.println(list);
}).start();
sleep1s();
while (iter.hasNext()) { // 迭代器仍然读取的是旧的列表中内容
System.out.println(iter.next());
}