目录
- 为什么要使用 ConcurrentHashMap
- ConcurrentHashMap
- HashTable
- HashMap 和 HashTable 有什么区别?
- Java 中的另一个线程安全的与 HashMap 极其类似的类是什么?同样是线程安全,它与 HashTable 在线程同步上有什么不同?
- HashMap & ConcurrentHashMap 的区别?
- 为什么 ConcurrentHashMap 比 HashTable 效率要高?
- 针对 ConcurrentHashMap 锁机制具体分析(JDK 1.7 VS JDK 1.8)?
- ConcurrentHashMap 在 JDK 1.8 中,为什么要使用内置锁 synchronized 来代替重入锁 ReentrantLock?
- ConcurrentHashMap 简单介绍?
- ConcurrentHashMap 的并发度是什么?
为什么要使用 ConcurrentHashMap
HashMap死循环分析 ( jdk 1.7 )
- 引发死循环,是在 HashMap 的扩容操作中,1.7中HashMap是由数组加链表构成的。
正常扩容
1、取当前 table 的 2 倍作为新 table 的大小 ;
2、根据算出的新 table 的大小 new 出一个新的 Entry 数组来,名为 newTable;
3、轮询原 table 的每一个位置,将每个位置上连接的 Entry,算出在新 table 上的位置,并以链表形式连接;
4、原 table 上的所有 Entry 全部轮询完毕之后,意味着原 table 上面的所有 Entry 已经移到了新的 table 上,HashMap 中的 table 指向 newTable。
- 扩容前
- 第一步
- 第二步
- 第三步
- HashMap在1.7使用的是头插法,后进入的元素放到头部,原来的链表变成了倒序。
并发扩容
- 链表在添加数据,属于头插,当扩容时,比如线程1对key=3和key=7转移到新的位置3,先插key=3,再插key=7,所以key=7是头,key=3挂在key=7下面,完成转移
这时候线程2又对key=3和key=7进行转移,把key=3插到头部,但是现在3号位置头部是key=7,所以key=3的下一个指向了key=7,如下图所示
- 当你去getkey=3或者key=7都不会有问题,但是当你去get一个不存在的key,比如key=11,当遍历这个链表的时候,因为找不到key就继续查找下一个next,这时候就会一直next,进入死循环,cpu会100%
ConcurrentHashMap
1. 在 1.7 下的实现
- ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。 Segment 是一种可重入锁(继承了ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构。一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素, 每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得与它对应的 Segment 锁。
public ConcurrentHashMap(int initialCapacity,float loadFactor,int concurrencyLevel){}
-
ConcurrentHashMap 初始化方法是通过 initialCapacity、loadFactor 和 concurrencyLevel(参数 concurrencyLevel 是用户估计的并发级别,就是说你觉得最多有多少线程共同修改这个 map,根据这个来确定 Segment 数组的大小 concurrencyLevel 默认是 DEFAULT_CONCURRENCY_LEVEL = 16;)等几个参数来初始化 segment 数组、段偏移量 segmentShift、段掩码 segmentMask 和每个 segment 里的 HashEntry 数组来实现的。
-
并发级别可以理解为程序运行时能够同时更新 ConccurentHashMap 且不产 生锁竞争的最大线程数,实际上就是 ConcurrentHashMap 中的分段锁个数,即 Segment[]的数组长度。ConcurrentHashMap 默认的并发度为 16,但用户也可以在构造函数中设置并发度。当用户设置并发度时,ConcurrentHashMap 会使用大于等于该值的最小 2 幂指数作为实际并发度(假如用户设置并发度为 17,实际并发度则为 32)。
-
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个 Segment 内的访问会扩散到不同的 Segment 中,CPU cache 命中率会下降,从而引起程序性能下降。
-
segments 数组的长度 size 是通过 concurrencyLevel 计算得出的。为了能通过按位与的散列算法来定位 segments 数组的索引,必须保证 segments 数组的长度是 2 的 N 次方(power-of-two size),所以必须计算出一个大于或等于 concurrencyLevel 的最小的 2 的 N 次方值来作为 segments 数组的长度。假如 concurrencyLevel 等于 14、15 或 16,size 都会等于 16,即容器里锁的个数也是 16。
-
ConcurrentHashMap 使用分段锁 Segment 来保护不同段的数据,那么在 插入和获取元素的时候,必须先通过散列算法定位到 Segment 。
-
ConcurrentHashMap 允许多个读操作并发进行,读操作并不需要加锁。 ConcurrentHashMap 实现技术是保证 HashEntry 几乎是不可变的以及 volatile 关键字。
static final class HashEntry<K,V>{
final int hash;
final K key;
volatile V value;
volatile HashEntry<K, V> next;
}
get 操作
- get 操作先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment(使用了散列值的高位部分),再通过散列算法定位到 table(使用了散列值的全部)。整个 get 过程,没有加锁,而是通过 volatile 保证 get 总是可以拿到最新值。
put 操作
- ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽,在插入第一个值的时候再进行初始化。
- 多个线程同时进入初始化同一个槽 segment[k],但只要有一个成功就可以了,使用cas操作控制。
- put 方法会通过 tryLock()方法尝试获得锁,获得了锁,node 为 null 进入 try 语句块,没有获得锁,调用 scanAndLockForPut 方法自旋等待获得锁。
- scanAndLockForPut 方法里在尝试获得锁的过程中会对对应 hashcode 的链表进行遍历,如果遍历完毕仍然找不到与 key 相同的 HashEntry 节点,则为后续的 put 操作提前创建一个 HashEntry。当 tryLock 一定次数后仍无法获得锁,则通过 lock 申请锁。
- 在获得锁之后,Segment 对链表进行遍历,如果某个 HashEntry 节点具有相同的 key,则更新该 HashEntry 的 value 值,否则新建一个 HashEntry 节点,采用头插法,将它设置为链表的新 head 节点并将原头节点设为新 head 的下一个节点。新建过程中如果节点总数(含新建的 HashEntry)超过 threshold,则调用 rehash()方法对 Segment 进行扩容,最后将新建 HashEntry 写入到数组中。
rehash 操作
- 扩容是新创建了数组,然后进行迁移数据,最后再将 newTable 设置给属性 table。
- 为了避免让所有的节点都进行复制操作:由于扩容是基于 2 的幂指来操作, 假设扩容前某 HashEntry 对应到 Segment 中数组的 index 为 i,数组的容量为 capacity,那么扩容后该 HashEntry 对应到新数组中的 index 只可能为 i 或者 i+capacity,因此很多 HashEntry 节点在扩容前后 index 可以保持不变。
- key=15,key=16;库容前table长度是4,则15和16分别在table的3号和0号位置;扩容后table长度是8,则15在table的7号位置(3+4),16在table的0号位置,没有变化。
- 扩容的目的是为了减少hash碰撞,让元素散列更均匀
remove 操作
- 与 put 方法类似,都是在操作前需要拿到锁,以保证操作的线程安全性。
ConcurrentHashMap 的弱一致性
- 对链表遍历判断是否存在 key 相同的节点以及获得该节点的 value。但 由于遍历过程中其他线程可能对链表结构做了调整,因此 get 和 containsKey 返 回的可能是过时的数据,这一点是 ConcurrentHashMap 在弱一致性上的体现。如 果要求强一致性,那么必须使用 Collections.synchronizedMap()方法。
size、containsValue
- 首先不加锁循环执行以下操作:循环所有的 Segment,获得对应的值以及所有 Segment 的 modcount 之和。当循环次数超过预定义的值时,需要对所有segment加锁,已经不再是分段锁,高并发情况下,效率低;
- 如果为了判断是否为空,可以使用isEmpty()。
2. 在 1.8 下的实现
- 改进一:取消 segments 字段,直接采用 transient volatile HashEntry<K,V>[] table 保存数据,采用 table 数组元素作为锁,从而实现了对缩小锁的粒度,进一步减少并发冲突的概率,并大量使用了采用了 CAS + synchronized 来保证并发安全性。
- 改进二:将原先 table 数组+单向链表的数据结构,变更为 table 数组+单 向链表+红黑树的结构。对于 hash 表来说,最核心的能力在于将 key hash 之后能均匀的分布在数组中。如果 hash 之后散列的很均匀,那么 table 数组中的每个队列长度主要为 0 或者 1。但实际情况并非总是如此理想,虽然 ConcurrentHashMap 类默认的加载因子为 0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式, 那么查询某个节点的时间复杂度为 O(n);因此,对于个数超过 8(默认值)的列表, jdk1.8 中采用了红黑树的结构,那么查询的时间复杂度可以降低到 O(logN),可以改进性能。
- 使用 Node(1.7 为 Entry) 作为链表的数据结点,仍然包含 key,value, hash 和 next 四个属性。 红黑树的情况使用的是 TreeNode(extends Node)。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
- 根据数组元素中,第一个结点数据类型是 Node 还是 TreeNode 可以判断该位置下是链表还是红黑树。
- 用于判断是否需要将链表转换为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
- 用于判断是否需要将红黑树转换为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
与 1.8 中 HashMap 不同点:
1、它并不是直接转换为红黑树,而是把这些结点放在 TreeBin 对象中,由 TreeBin 完成对红黑树的包装。
2、TreeNode 在 ConcurrentHashMap 扩展自 Node 类,而并非 HashMap 中的 扩展自 LinkedHashMap.Entry<K,V>类,也就是说 TreeNode 带有 next 指针。
TreeBin
- 负责 TreeNode 节点。它代替了 TreeNode 的根节点,也就是说在实际的 ConcurrentHashMap“数组”中,存放的是 TreeBin 对象,而不是 TreeNode 对象。 另外这个类还带有了读写锁机制。
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState;
// values for lockState
static final int WRITER = 1; // set while holding write lock
static final int WAITER = 2; // set when waiting for write lock
static final int READER = 4; // increment value for setting read lock
}
特殊的 ForwardingNode
- 一个特殊的 Node 结点,hash 值为 -1,其中存储 nextTable 的引用。有 table 发生扩容的时候,ForwardingNode 发挥作用,作为一个占位符放在 table 中表示当前结点为 null 或者已经被移动
sizeCtl
- 用来控制 table 的初始化和扩容操作。
- 负数代表正在进行初始化或扩容操作
- -1 代表正在初始化
- -N 表示有 N-1 个线程正在进行扩容操作
- 0 为默认值,代表当时的 table 还没有被初始化
- 正数表示如果数组没有初始化,记录的是数组初始容量,如果数组已经初始化,记录的是数组的扩容阈值
构造方法
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;
}
- 这里只设置了Map的大小,如果不指定大小,默认是16,如果指定,则是大于指定容量最小的 2 的幂指数,比如你指定16,则变为32。
- 真正的初始化在放在了是在向 ConcurrentHashMap 中插入元素的时候发生 的。如调用 put、computeIfAbsent、compute、merge 等方法的时候,调用时机 是检查 table==null。
get 操作
- 给定一个 key 来确定 value 的时候,必须满足两个条件 key 相同 hash 值相同,对于节点可能在链表或树上的情况,需要分别去查找。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
// 1.根据hash值确定节点位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 2.如果是Node数组中元素就直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 3.eh < 0表示节点在树上,调用树的find方法查找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 4.走到这,说明是链表,遍历链表找到对应的值返回
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
put 操作
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
// 1.死循环,何时插入成功,何时跳出
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 2.如果table为空,初始化table
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 3.Node数组,如果这个位置为空,使用cas操作放值
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
// 4.正在扩容,当前线程帮忙扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 5.锁Node数组中的元素
// 这个位置是Hash冲突组成链表的头节点或者红黑树的根节点
synchronized (f) {
if (tabAt(tab, i) == f) {
// 6.fh > 0说明这是一个链表的头节点,不是树的根节点
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 7.put和putIfAbsent方法的实现
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
// 8.如果遍历到最后一个节点,使用尾插法,插到链表尾部
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 9.按照树的方式插入值
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
// 10.达到临界值8,把链表转换成树结构
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 11.Map的元素数量+1,并检查是否需要扩容
addCount(1L, binCount);
return null;
}
- 总结来说,沿用 HashMap 的 put 方法的思想,根据 hash 值计算这个新插入的点在 table 中的位置 i,如果 i 位置是空的,直接放进去,否则进行判断,如果 i 位置是树节点,按照树的方式插入新的节点,否则把 i 插入到链表的末尾;
- 如果这个位置是空的,那么直接放入,而且不需要加锁操作;
- 如果这个位置存在结点,说明发生了 hash 碰撞,首先判断这个节点的类型。 如果是链表节点,则得到的结点就是 hash 值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到 hash 值与 key 值都与新加入节点是一致的情况,则只需要更新 value 值即可。否则依次向后遍历,直到链表尾插入这个结点。如果加入这个节点以后链表长度大于 8且数组长度大于等于64,就把这个链表转换成红黑树;如果加入这个节点以后链表长度大于 8,但数组长度小于64,则数组扩容。如果这个节点的类型已经是树节点的话,直接调用树节点的插入方法进行插入新的值。
初始化
- 构造方法中并没有真正初始化,真正的初始化在放在了是在向 ConcurrentHashMap 中插入元素的时候发生的。
// 默认大小事16
private static final int DEFAULT_CAPACITY = 16;
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
// 1.cas把sizeCtl设置为-1,表示正在初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// 2.n=16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 3.n右移2位,相当于除以4,n-n/4等于n的3/4,0.75*n
sc = n - (n >>> 2);
}
} finally {
// 4.设置数组的扩容阈值
sizeCtl = sc;
}
break;
}
}
return tab;
}
transfer
- 当 ConcurrentHashMap 容量不足的时候,需要对 table 进行扩容。这个方法 的基本思想跟 HashMap 是很像的,但是由于它是支持并发扩容的;
- 为何要并发扩容?因为在扩容的时候,总是会涉及到从一个“数组”到另一 个“数组”拷贝的操作,如果这个操作能够并发进行,就能利用并发处理去减少扩容带来的时间影响。
- 整个扩容操作分为两个部分:
第一部分是构建一个 nextTable,它的容量是原来的 2 倍;
第二个部分就是将原来 table 中的元素复制到 nextTable 中,这里允许多线程 进行操作。
整个扩容流程就是遍历和复制
- 为 null 或者已经处理过的节点,会被设置为 forwardNode 节点,当线程准备扩容时,发现节点是 forwardNode 节点,跳过这个节点,继续寻找未处理的节点,找到了,对节点上锁;
- 如果这个位置是 Node 节点(fh>=0),说明它是一个链表,就构造一个反序链表,把他们分别放在 nextTable 的 i 和 i+n 的位置上;
- 如果这个位置是 TreeBin 节点(fh<0),说明它是一个红黑树,也做一个反序处理,并且判断是否需要红黑树转链表,把处理的结果分别放在 nextTable 的 i 和 i+n 的位置上;
- 遍历过所有的节点以后就完成了复制工作,这时让 nextTable 作为新的 table, 并且更新 sizeCtl 为新容量的 0.75 倍 ,完成扩容;
- 并发扩容其实就是将数据迁移任务拆分成多个小迁移任务,在实现上使用了一个变量 stride 作为步长控制,每个线程每次负责迁移其中的一部分,比如被分配到数组0号位置的迁移,除了除了0号位置,还要处理0+stride号位置的迁移。
remove
- 移除方法的基本流程和 put 方法很类似,只不过操作由插入数据变为移除数 据而已,而且如果存在红黑树的情况下,会检查是否需要将红黑树转为链表。
treeifyBin
- 用于将过长的链表转换为 TreeBin 对象。但是他并不是直接转换,而是进行一次容量判断,如果容量没有达到转换的要求,直接进行扩容操作并返回;如果满足条件才将链表的结构转换为 TreeBin ,这与 HashMap 不同的是,它并没有把 TreeNode 直接放入红黑树,而是利用了 TreeBin 这个小容器来封装所有的 TreeNode。
size
- 在 JDK1.8 版本中,对于 size 的计算,在扩容和 addCount()方法就已经有处理了,可以注意一下 Put 函数,里面就有 addCount()函数,早就计算好的,然后你 size 的时候直接给你。JDK1.7 是在调用 size()方法才去计算,其实在并发集合中去计算 size 是没有多大的意义的,因为 size 是实时在变的。
- 在具体实现上,计算大小的核心方法都是 sumCount()
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;
}
- 可以看见,统计数量时使用了 baseCount 和 CounterCell 类型的变量 counterCells 。其实 baseCount 就是记录容器数量的,而 counterCells 则是记录 CAS 更新 baseCounter 值时,由于高并发而导致失败的值。这两个变量的变化在 addCount() 方法中有体现,大致的流程就是:
1、对 baseCount 做 CAS 自增操作。
2、如果并发导致 baseCount CAS 失败了,则使用 counterCells。
3、如果 counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操作,直到成功。
HashTable
HashTable 容器使用 synchronized 来保证线程安全,但在线程竞争激烈的情 况下 HashTable 的效率非常低下。因为当一个线程访问 HashTable 的同步方法, 其他线程也访问 HashTable 的同步方法时,会进入阻塞或轮询状态。如线程 1 使 用 put 进行元素添加,线程 2 不但不能使用 put 方法添加元素,也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。
HashMap 和 HashTable 有什么区别?
1、HashMap 是线程不安全的,HashTable 是线程安全的;
2、由于线程安全,所以 HashTable 的效率比不上 HashMap;
3、HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null, 而 HashTable 不允许;
4、HashMap 默认初始化数组的大小为 16,HashTable 为 11,前者扩容时, 扩大两倍,后者扩大两倍+1;
5、HashMap 需要重新计算 hash 值,而 HashTable 直接使用对象的 hashCode
Java 中的另一个线程安全的与 HashMap 极其类似的类是什么?同样是线程安全,它与 HashTable 在线程同步上有什么不同?
1、ConcurrentHashMap 类(是 Java 并发包 java.util.concurrent 中提供的一个线程安全且高效的 HashMap 实现)。
2、HashTable 是使用 synchronize 关键字加锁的原理(就是对对象加锁);
ConcurrentHashMap,在 JDK 1.7 中采用分段锁的方式;JDK 1.8 中 直接采用了 CAS(无锁算法)+ synchronized,也采用分段锁的方式并大大缩小了锁的粒度。
HashMap & ConcurrentHashMap 的区别?
1、除了加锁,原理上无太大区别。
2、HashMap 的键值对允许有 null,但是 ConCurrentHashMap 都不允许。 在数据结构上,红黑树相关的节点类,分别为ConCurrentHashMap-Node<K,V> 与 HashMap-LinkedHashMap.Entry<K,V>
为什么 ConcurrentHashMap 比 HashTable 效率要高?
1、HashTable 使用一把锁(锁住整个链表结构)处理并发问题,多个线程 竞争一把锁,容易阻塞;
2、ConcurrentHashMap JDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一 个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。JDK 1.8 中使用 CAS + synchronized + Node + 红黑树。锁粒度:Node(首结 点)(实现 Map.Entry<K,V>)。锁粒度降低了。
针对 ConcurrentHashMap 锁机制具体分析(JDK 1.7 VS JDK 1.8)?
1、JDK 1.7 中,采用分段锁的机制,实现并发的更新操作,底层采用数组+链表 的存储结构,包括两个核心静态内部类 Segment 和 HashEntry。
①、Segment 继承 ReentrantLock(重入锁) 用来充当锁的角色,每个 Segment 对象守护每个散列映射表的若干个桶;
②、HashEntry 用来封装映射表的键-值对;
③、每个桶是由若干个 HashEntry 对象链接起来的链表。
2、JDK 1.8 中,采用 Node + CAS + Synchronized 来保证并发安全。取消类 Segment,直接用 table 数组存储键值对;当 Node 对象组成的链表长度超过 TREEIFY_THRESHOLD 时,链表转换为红黑树,提升性能。底层变更为数组 + 链表 + 红黑树。
ConcurrentHashMap 在 JDK 1.8 中,为什么要使用内置锁 synchronized 来代替重入锁 ReentrantLock?
1、JDK 1.8 对 synchronized 做了大量性能上的优化,而且基于 JVM 的 synchronized 优化空间更大,更加自然。
2、在大量的数据操作下,对于 JVM 的内存压力,基于 API 的 ReentrantLock 会开销更多的内存。
ConcurrentHashMap 简单介绍?
1、重要的常量:
private transient volatile int sizeCtl;
- 负数代表正在进行初始化或扩容操作
- -1 代表正在初始化
- -N 表示有 N-1 个线程正在进行扩容操作
- 0 为默认值,代表当时的 table 还没有被初始化
- 正数表示如果数组没有初始化,记录的是数组初始容量,如果数组已经初始化,记录的是数组的扩容阈值
2、数据结构
- Node 是存储结构的基本单元,继承 Map 中的 Entry,用于存储数据;
- TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储 结构,用于红黑树中存储数据;
- TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。
3、存储对象时put() 方法
- 如果没有初始化,就调用 initTable() 方法来进行初始化;
- 如果没有 hash 冲突就直接 CAS 无锁插入;
- 如果需要扩容,就先进行扩容;
- 如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形 式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入;
- 如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一 次进入循环
- 如果添加成功就调用 addCount() 方法统计 size,并且检查是否需要扩容。
4、扩容方法 transfer()
- 默认容量为 16,扩容时,容量变为原来的两倍。
- helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。
5、获取对象时get()方法
- 计算 hash 值,定位到该 table 索引位置,如果是首结点符合就返回;
- 如果遇到扩容时,会调用标记正在扩容节点 ForwardingNode.find()方法, 查找该节点,匹配就返回;
- 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回 null。
ConcurrentHashMap 的并发度是什么?
- 1.7 中程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数。默认为 16,且可以在构造函数中设置。当用户设置并发度时, ConcurrentHashMap 会使用大于等于该值的最小 2 幂指数作为实际并发度(假如用户设置并发度为 17,实际并发度则为 32)。
- 1.8 中并发度则无太大的实际意义,主要用处就是当设置的初始容量小于并发度,将初始容量提升至并发度大小。