为什么使用CocurrentHashMap
- ConcurrentHashMap解决了在多线程情况下数据存储被覆盖的问题,同时也提高了存储效率。
- 相比于HashMap是线程不安全的,在多线程情况下HashMap的put操作变成死循环。
- HashTable中使用synchronized来解决多线程情况下的问题,但是随之也导致了效率非常低。
CocurrentHashMap的结构
对于ConcurrentHashMap的结构,jdk1.8多了很多优化,这里我们对1.7和1.8进行一个对比
1.7:
- Segments数组 + HashEntry数组 + 链表
- 采用分段锁机制
- 底层是数组+链表
- Segments的意思就是将一个大的table分成多个Segments,每一个Segments就相当于是一个HashMap
1.8:
- 取消了Segment
- 使用了CAS+synchronized+Node来保证并发安全
CocurrentHashMap初始化
/**
* 多线程之间,以volatile的方式读取sizeCtl属性,来判断ConcurrentHashMap当前所处的状态。通过cas设置sizeCtl属性,告知其他线程ConcurrentHashMap的状态变更。
* 不同状态,sizeCtl所代表的含义也有所不同。
* 未初始化:
* sizeCtl=0:表示没有指定初始容量。
* sizeCtl>0:表示初始容量。
* 初始化中:
* sizeCtl=-1,标记作用,告知其他线程,正在初始化
* 正常状态:
* sizeCtl=0.75n ,扩容阈值
* 扩容中:
* sizeCtl < 0 : 表示有其他线程正在执行扩容
* sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2 :表示此时只有一个线程在执行扩容
*/
private transient volatile int sizeCtl;
/**
* 扩容索引,表示已经分配给扩容线程的table数组索引位置。主要用来协调多个线程,并发安全地获取迁移任务(hash桶)。
* 1 在扩容之前,transferIndex 在数组的最右边 。此时有一个线程发现已经到达扩容阈值,准备开始扩容。
* 2 扩容线程,在迁移数据之前,首先要将transferIndex左移(以cas的方式修改 transferIndex=transferIndex-stride(要迁移hash桶的个数)),获取迁移任务。
* 每个扩容线程都会通过for循环+CAS的 方式设置transferIndex,因此可以确保多线程扩容的并发安全。
*/
private transient volatile int transferIndex;
// ConcurrentHashMap的创建有五种方式
// 第一种方式
/**
* 创建一个空对象,默认初始容量是16
* Creates a new, empty map with the default initial table size (16).
*/
public ConcurrentHashMap() {
}
// 第二种
/**
* 创建一个指定初始容量的对象
* Creates a new, empty map with an initial table size
* accommodating the specified number of elements without the need
* to dynamically resize.
*/
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;
}
/**
* 返回一个最接近指定容量的2的次幂的数
*/
private static final int tableSizeFor(int c) {
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
// 第三种方式
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
// 设置默认容量
this.sizeCtl = DEFAULT_CAPACITY;
// 接受一个map,创建一个新的map
putAll(m);
}
/**
* 将传入map中所有的元素循环copy到新的ConcurrentHashMap中
* @param m mappings to be stored in this map
*/
public void putAll(Map<? extends K, ? extends V> m) {
// 尝试在插入前扩容
tryPresize(m.size());
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
putVal(e.getKey(), e.getValue(), false);
}
// 第四种方式
/**
* 根据指定的初始容量和加载因子创建一个新的map
*/
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
// 第五种方式
/**
* 根据指定的初始容量和加载因子和并发级别创建一个新的map
* 这里想一个问题, concurrencyLevel 是干什么用的,发挥了什么作用,我们后面在分析
* @param concurrencyLevel the estimated number of concurrently
* updating threads. The implementation may use this value as
* a sizing hint.
*/
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);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
trySize-预扩容
/**
* Tries to presize table to accommodate the given number of elements.
*
* @param size number of elements (doesn't need to be perfectly accurate)
*/
private final void tryPresize(int size) {
// 如果指定的容量大于等于最大容量的一半则直接设置容量为最大值
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
// 这里sizeCtl默认初始容量16
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
// 当数组为空时
if (tab == null || (n = tab.length) == 0) {
// 设置容量,这里sc默认是16,如果传入的对象大小小于16都会设置为16
n = (sc > c) ? sc : c;
// SIZECTL = -1 表示加锁
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
// 这里再次判断之后创建一个指定长度的数组并赋个table
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
// 计算下次扩容阈值 n - n / 4 = n * 0.75
sc = n - (n >>> 2);
}
} finally {
// 将下次扩容阈值赋值给 sizeCtl
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
// 这里说明数组已经初始化过了
// 如果扩容大小小于等于扩容阈值或者数组长度已经是最大值了,直接结束
break;
else if (tab == table) {
// 这里说明数组还未发生变化
int rs = resizeStamp(n);
if (sc < 0) {
// 表示正在扩容,
Node<K,V>[] nt;
//条件1: true -> 当前线程获取到的扩容唯一标识戳非本次扩容批次,执行方法体
// false -> 当前线程获取到的扩容唯一标识戳是本次扩容批次,进入下一个判断
//条件2: jdk1.8有bug 这里应该是 sc == (rs << RESIZE_STAMP_SHIFT) + 1
// true -> 表示所有线程都执行完毕了 线程数量是 = 1+n,执行方法体
// false -> 表示还在扩容中,进入下一个判断
//条件3: jdk1.8有bug 这里应该是 sc == (rs << RESIZE_STAMP_SHIFT) + MAX_RESIZERS
// true -> 表示当前参与扩容的线程数量达到最大限度了,不能再参与了,执行方法体
// false -> 表示当前参与扩容的线程数量还未达到最大限度,当前线程可以参与,进入下一个判断
//条件4: true -> 表示扩容已完毕,执行方法体
// false -> 表示扩容还在进行,进入下一个判断
//条件5: true -> 表示全局范围内的任务已经分配完了,执行方法体
// false -> 表示还有任务可分配,结束判断
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
//当前线程协助扩容
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 当前线程是第一个执行扩容的线程
transfer(tab, null);
}
}
}
put流程
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 这一行代码可以看出在ConcurrentHashMap中 key和value都不能为空,这也是跟HashMap的一个区别
if (key == null || value == null) throw new NullPointerException();
// 对key值进行二次 hash
int hash = spread(key.hashCode());
// binCount 用于链表节点计数,用于后续判断是否转成红黑树
int binCount = 0;
// 循环遍历数组,这里使用的是乐观锁的方式
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// table初始化,初始化完成后进入下一轮循环,后面就不会走到这一步了
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 若果要插入的位置为空,则使用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)
// 如果要插入的位置不为空,并且节点hash是 MOVE,说明数组正在扩容,调用 helpTransfer 协助扩容并把插入元素插入到新的table中
tab = helpTransfer(tab, f);
else {
// 走到这里说明发生了hash冲突,这里只对要插入的位置中的节点加锁
V oldVal = null;
synchronized (f) {
// 再次判断要插入的位置元素与之前取出来的元素是否一样,防止被修改
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// fh >=0 说明是链表
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
// 从第一个元素开始遍历链表
K ek;
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;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
// 如果是红黑树,就以红黑树的方式插入元素
Node<K,V> p;
// 思考这里如果是红黑树为什么 binCount 设置为2
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 走到这一步说明插入新元素成功,
if (binCount != 0) {
// 如果 binCount 大于等于红黑树转换阈值8,并且数组长度达到64,进行转换
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 第一个参数表示元素变化值,1表示新增一个元素,-1表示移除一个元素,
addCount(1L, binCount);
return null;
}
下面我们来对put方法做一个总结
- 在进行插入操作时,首先会进入乐观锁,如果还没初始化就先进行初始化操作
- 然后判断要插入的节点是否为空,如果为空则直接通过CAS插入
- 如果不为空,先判断数组是否处于扩容状态,如果是正在扩容,帮助扩容
- 如果不是扩容状态,找到发生hash冲突的那个节点,循环遍历该节点,然后判断相等则更新,不等则插入
spread-二次hash
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
initTable-数组初始化
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 循环检查是否已经初始化
while ((tab = table) == null || tab.length == 0) {
// sizeCtl < 0,就是-1表示正在初始化,直接线程阻塞
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 进入该判断说明 sizeCtl >= 0,存在两种情况,一个是未初始化,二是正在扩容sizeCtl为阈值
// 这里进入初始化代码,现将 SIZECTL 设置为初始化状态
try {
// 再次判断数组是否已经初始化
if ((tab = table) == null || tab.length == 0) {
// 设置初始化容量,默认16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// sc表示扩容阈值
sc = n - (n >>> 2);
}
} finally {
// 初始化完成后将阈值赋给 sizeCtl
sizeCtl = sc;
}
break;
}
}
return tab;
}
下面我们来分析以下初始化的步骤:
- 判断哈希表是否初始化
- 如果未初始化,判断sizeCtl的值如果sizeCtl
- 如果sizeCtl >= 0,则有两种可能,一是哈希表未初始化;二是有其他线程在已经初始化完成,此时sizeCtl记录的是扩容阈值
- 使用CAS将 SIZECTL 值修改为-1。如果修改成功,开始初始化操作
- 如果修改失败,进入下一轮循环判断是否初始化
- 修改-1成功后再次判断是否初始化,这里为什么要采用双重校验呢?因为在并发情况下,如果其他线程已经完成了初始化,sizeCtl也是 >=0,也会进入这个判断
- 如果还未初始化,则开始初始化
- 如果顺利初始化完成,sizeCtl记录扩容阈值。
helpTransfer-协助扩容
/**
* Helps transfer if a resize is in progress.
*/
https://blog.csdn.net/weixin_41392674/article/details/126250297
https://blog.csdn.net/u010285974/article/details/106301101/
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
// 前面我们已经知道Node是链表节点,TreeNode是红黑树节点,那么这里的 ForwardingNode 代表说明呢?
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
/**
* A node inserted at head of bins during transfer operations.
*/
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
outer: for (Node<K,V>[] tab = nextTable;;) {
Node<K,V> e; int n;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;;) {
int eh; K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
}
treeifyBin-红黑树转换
// 从put流程中的这段代码我们可以看出当节点链表长度大于等于 8 是会调用红黑树扩容方法 treeifyBin
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
/**
* Replaces all linked nodes in bin at given index unless table is
* too small, in which case resizes instead.
*/
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// 这里第一步会再判断数组的长度是否大于等于 64 ,如果小于64还是会进行扩容,扩容流程前面我们已经分析过了
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 走到这里说明数组长度 >= 64 并且链表长度 >= 8
synchronized (b) {
// 加锁
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
// 遍历链表,创建红黑树
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将红黑树结构元素设置到数组中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
addCount-计数扩容
/**
* Adds to count, and if table is too small and not already
* resizing, initiates transfer. If already resizing, helps
* perform transfer if work is available. Rechecks occupancy
* after a transfer to see if another resize is already needed
* because resizings are lagging additions.
*
* @param x the count to add
* @param check if <0, don't check resize, if <= 1 only check if uncontended
*/
// 该方法主要有两个作用,1:计数;2:判断是否扩容
// x : 1表示新增元素,-1表示移除元素
// check:正常是数组某个hash槽中链表或红黑树元素个数
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// 第一次执行时 as = counterCells == null
/* U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x) ,baseCount表示元素个数
* 1. 在没有竞争的情况下写入成功,第一个if判断返回false
* 2. 在多线程情况下,如果写入失败,进入第一个if判断
*/
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
// 1. 如果as == null,说明多线程竞争失败,counterCells 初始化
// 2. 如果as != null,并且当前线程对应as数组存储对象不为空,直接使用as存储值 +x
// 3. 如果as != null,当前线程对应as数组中没有数据,调用 fullAddCount 插入
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
// 表示不需要检查扩容,直接返回
if (check <= 1)
return;
// 返回当前map中数组个数
s = sumCount();
}
if (check >= 0) {
// 检查扩容
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
get流程
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 如果数组非空,元素并且存在该key,开始查询
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
// 如果正好在数组内,找到一样的key,hash则直接返回
return e.val;
}
else if (eh < 0)
// hash<0 这里有两种情况
// static final int MOVED = -1; // hash for forwarding nodes
// static final int TREEBIN = -2; // hash for roots of trees
// 当 eh = -1 时,说明数组正在扩容,e 是ForwardingNode
// 当 eh = -2 时,说明数组中节点是红黑树结构, e 是TreeBin
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
// 这里说明是链表结构直接循环查找返回
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
分析完get流程,我们发现get方法中没有加锁的操作,这是为什么呢?
ConcurrentHashMap 读操作为什么不用加锁
1.7:
ConcurrentHashMap 内部采用了一种分段锁的机制,即将整个哈希表分成多个 Segment,每个 Segment 都是一个独立的哈希表,每个 Segment 内部的操作都是线程安全的。当多个线程同时访问 ConcurrentHashMap 时,每个线程会被分配到不同的 Segment,从而避免了锁的竞争。这样就可以在不影响读操作的情况下,同时支持多个线程的并发写操作。
1.8:
ConcurrentHashMap的get操作并没有像put操作一样有CAS和synchronized锁。get操作不需要加锁,因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,所以在多线程的环境下,即便value的值被修改了,在线程之间也是可见的。
JDK1.8中为什么使用synchronized替换可重入锁ReentrantLock
// 在JDK1.7中,就是通过锁定Segment对象实现线程安全的
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
// 从这段代码可以看出 Segment 继承了ReentrantLock 类,说明 Segment 是一个可重入锁
下面我们来分析一下为什么要用 synchronized 代替 ReentrantLock
- Segemnt 其实锁住的就是一个 HashEntry 数组,而 synchronized 锁的是发生hash冲突的头节点或者红黑树的节点提供了性能
- 从jdk1.6开始,对synchronized进行了很多优化,对其增加了状态转换,锁会从无锁->偏向锁->轻量级锁->重量级锁一步步升级,只要线程可以通过自旋拿到锁就不会升级为重量级锁,从而线程不会被挂起减少了线程的切换消耗,ReentrantLock并没有自旋的功能
ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?
ConcurrentHashMap的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。
HashMap迭代器是强一致性
扩容期间在未迁移到的hash桶插入数据会发生什么
只要插入的位置扩容线程还未迁移到,就可以直接插入;
如果当前要插入的位置正在迁移,会帮助其他扩容线程一起扩容直到扩容结束,然后再加synchronized锁执行插入操作。
正在迁移的hash桶遇到get操作会发生什么
因为ln和hn是在原本链表的基础上复制出来的,复制引用。
原链表并不会受到影响,可以正常的访问。
如果数据迁移结束,会在对应的哈希槽上放一个fwd,那么之后的get请求,就会将请求转发到扩容后的数组中。
正在迁移的hash桶遇到put/remove操作会发生什么
协助扩容
ConcurrentHashMap 是如何发现当前槽点正在扩容的
ConcurrentHashMap 新增了一个节点类型,叫做转移节点 ForwardingNode,当我们发现当前槽点是转移节点时(转移节点的 hash 值是 -1),即表示 Map 正在进行扩容。
// remove 方法
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
else if ((fh = f.hash) == MOVED)
// MOVED = -1
tab = helpTransfer(tab, f);
else {
......
}
}
// 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;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
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)
tab = helpTransfer(tab, f);
else {
......
}
}
为什么超过冲突超过8才将链表转为红黑树而不直接用红黑树
因为红黑树需要进行左旋,右旋操作, 而单链表不需要
以下都是单链表与红黑树结构对比。
如果元素小于8个,查询成本高,新增成本低
如果元素大于8个,查询成本低,新增成本高
因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。 还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
时间和空间的权衡
时间:因为Map中桶的元素初始化是链表保存的,其查找性能是O(n),而树结构能将查找性能提升到O(log(n))。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
空间:因为红黑树需要进行左旋,右旋操作, 而单链表不需要,TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
描述一下 CAS 算法在 ConcurrentHashMap 中的应用
CAS 其实是一种乐观锁,一般有三个值,分别为:赋值对象,原值,新值,在执行的时候,会先判断内存中的值是否和原值相等,相等的话把新值赋值给对象,否则赋值失败,整个过程都是原子性操作,没有线程安全问题。
putVal() 方法中,有使用到 CAS ,是结合无限 for 循环一起使用的,步骤如下:
- 计算出数组索引下标,拿出下标对应的原值;
- CAS 覆盖当前下标的值,赋值时,如果发现内存值和 1 拿出来的原值相等,执行赋值,退出循环,否则不赋值,转到 3;
- 进行下一次 for 循环,重复执行 1,2,直到成功为止。
可以看到这样做的好处,第一是不会盲目的覆盖原值,第二是一定可以赋值成功。
ConcurrentHashMap 和 Hashtable 的区别
1.底层数据结构:
JDK1.7的ConcurrentHashMap底层采用:Segments数组+HashEntry数组+链表
JDK1.8的ConcurrentHashMap底层采用:Node数据+链表+红黑树
Hashtable底层数据结构采用:数组+链表
2.实现线程安全的方式:
在JDK1.7中ConcurrentHashMap采用分段锁实现线程安全。
在JDK1.8中ConcurrentHashMap采用synchronized和CAS来实现线程安全。
Hashtable采用synchronized来实现线程安全。在方法上加synchronized同步锁。
ConcurrentHashMap 和 HashMap 的区别
HashMap是非线程安全的,这意味着不应该在多线程中对这些Map进行修改操作,否则会产生数据不 一致的问题,甚至还会因为并发插入元素而导致链表成环,这样在查找时就会发生死循环,影响到整个应用程序。
Collections工具类可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map,而包装类是基于synchronized关键字来保证线程安全的(Hashtable也是基于synchronized关键字),底层使用的是互斥锁,性能与吞吐量比较低。
ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。
它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。