HashMap
之前的文章(HashMap实现原理和源码分析),讲了HashMap,核心就一句话:数组 + 链表/红黑树。
即三大数据结构。理解了为什么需要三大数据结构,也就理解了map。
今天要讲的是,在map上加了并发功能。具体的类就是:ConcurrentHashMap。
ConcurrentHashMap
讲HashMap的时候,核心是数据结构,那么讲ConcurrentHashMap,核心仍然是数据结构。
那数据结构是啥?没变,和HashMap完全一样,还是三大件。
唯一的变化,就是加了并发功能,所以才叫并发HashMap。
那并发HashMap是如何解决并发问题的呢?
put方法:如何解决并发问题?
核心底层技术
直接开门见山,先说结论:用到了什么核心技术?cas + 同步关键字。
为什么要用这两个核心技术?各自分别又是用在什么地方?看下文。
核心步骤
1、 初始化数组和第一个节点
这个时候,都是基于cas。
2、 链表上的非第一个节点
这个时候,是基于同步关键字。
看源码:java.util.concurrent.ConcurrentHashMap#putVal
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(); //初始化数组:底层其实是基于cas
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null))) //如果是第一个节点:也是基于cas
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { //如果不是第一个节点:基于同步关键字 //锁的是头节点对象
if (tabAt(tab, i) == f) {
if (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;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
源码里的三个关键地方有注释,分别说明了使用了什么技术。
其实就是,
1、 没数据的时候,基于cas
比如,数组初始化,就是没有数据,就基于cas。
然后,索引位置的第一个节点,没有数据,就基于cas,写第一个数据。
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); //基于sun.misc.Unsafe#compareAndSwapObject解决写的线程安全问题
}
2、 有数据的时候,基于同步关键字
但是,如果链表的头节点已经有数据,接下来,就是基于同步关键字,而且锁住的对象就是头节点。
get方法
源码
/**
* Returns the value to which the specified key is mapped,
* or {@code null} if this map contains no mapping for the key.
*
* <p>More formally, if this map contains a mapping from a key
* {@code k} to a value {@code v} such that {@code key.equals(k)},
* then this method returns {@code v}; otherwise it returns
* {@code null}. (There can be at most one such mapping.)
*
* @throws NullPointerException if the specified key is null
*/
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) {
if ((eh = e.hash) == h) { //首先判断头结点是否是要找的节点
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val; //如果是,直接返回
}
else if (eh < 0) //红黑树
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;
}
核心步骤就是三种情况
1、数组
2、链表
3、红黑树
ConcurrentHashMap get方法 如何保证线程安全?
1、基于sun.misc.Unsafe#getObjectVolatile,确保可以读到最新数据
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
2、非阻塞,因为读方法没有锁
非阻塞操作: get
操作是只读操作,因此在多个线程同时访问的情况下,不会发生阻塞。这允许多个线程同时访问不同的桶,而不会产生锁竞争。
ConcurrentHashMap get方法:如果访问同一个桶,是不是也是阻塞?
不,ConcurrentHashMap
的 get
方法通常不会在访问同一个桶(Bucket)时阻塞。get
方法是只读操作,因此在访问同一个桶内的数据时,不会发生阻塞或竞争锁。多个线程可以并发地读取同一个桶内的数据,而不会相互阻塞。
在 ConcurrentHashMap
的设计中,分段锁和 CAS 操作等机制被用来允许并发地进行读取操作,以提高性能。只有写操作(插入、删除等)才会涉及到内部同步机制,以确保数据的一致性和线程安全性。对于读操作,即使多个线程同时访问同一个桶,它们也不会因为读取而发生阻塞。
因此,ConcurrentHashMap
的 get
方法在访问同一个桶时不会阻塞,并且可以高效地支持并发的读操作。这是 ConcurrentHashMap
设计的一大特点,使得在多线程环境下对数据的读取操作可以具备良好的性能。
else if (eh < 0) :这个条件应该是红黑树,那为什么红黑树eh<0?
抱歉,我之前的回答可能存在一些误导。您是正确的,eh
的值小于零实际上是用来标识根节点为红黑树而不是链表。ConcurrentHashMap
中确实使用负数的 eh
值来标识红黑树根节点,而不是普通的链表头节点。
根据 JDK 8 中 ConcurrentHashMap
的源码,eh
是用来标记特殊情况的,其中包括:
eh == -1
:表示该桶正在进行初始化操作。eh == -2
:表示该桶正在进行扩容操作。eh < 0
且不等于-1
或-2
:表示该桶中存储的数据结构是红黑树,而不是链表。
对于红黑树来说,哈希桶的根节点是一个红黑树的根节点,而且通过 eh
的负数值可以进行快速判断。这是因为红黑树根节点的哈希码的最高位是 1,所以在判断时会小于零。
再次对之前的回答中可能存在的混淆表示歉意。感谢您的纠正,希望这次的解释能够更准确地解释 eh
值的含义。
小结
写
是基于cas和同步关键字来解决线程安全问题。
读
是基于sun.misc.Unsafe#getObjectVolatile
,确保可以读到最新数据。另外,非阻塞,因为没有锁。
sun.misc.Unsafe#getObjectVolatile和compareAndSwapObject的区别
sun.misc.Unsafe#getObjectVolatile
和 sun.misc.Unsafe#compareAndSwapObject
都是 sun.misc.Unsafe
类提供的底层内存操作方法,用于在多线程环境下进行原子操作。它们有不同的作用和用途:
-
getObjectVolatile
:getObjectVolatile
方法用于获取指定对象的字段的值,并且保证了在获取值时具有 volatile 语义。- 这意味着,即使在多线程环境中,也可以确保读取到最新的值,防止指令重排序对读操作的影响。
-
compareAndSwapObject
:compareAndSwapObject
方法用于原子地比较对象的字段的当前值与给定的期望值,如果相等,则将字段的值更新为新的值。- 这是一个原子的操作,用于实现类似于乐观锁的机制。如果当前值与期望值相等,就会执行更新操作;否则,不执行更新。
- 这个方法可以用于实现一些高级的同步和并发算法,例如无锁数据结构或自旋锁。
总的来说,getObjectVolatile
用于保证获取字段值的 volatile 语义,而 compareAndSwapObject
用于实现原子的比较和交换操作,以实现并发控制。这两个方法都属于底层的内存操作,应该在了解其作用和原理的基础上谨慎使用。由于它们是非标准的 API,不同的 Java 版本或 JVM 可能会有不同的实现和行为,因此在实际开发中不推荐直接使用这些方法。
说白了,就是一个用于写,一个用于读。
写是为了确保同一时间只有一个线程能写成功。
读是为了能够读到最新数据。
sun.misc.Unsafe#getObjectVolatile 会阻塞吗
sun.misc.Unsafe#getObjectVolatile
方法本身并不会阻塞。它是一个底层内存操作方法,用于获取指定对象的字段的值,并且保证了在获取值时具有 volatile 语义。Volatile 语义确保在多线程环境中,读取操作可以看到最新的值,但它不会引起线程的阻塞。
Volatile 语义主要涉及到内存可见性,即一个线程对于一个 volatile 字段的写操作,会立即刷新到主内存中,而其他线程对该字段的读操作可以立即从主内存中获取最新的值。这是通过特定的 CPU 指令或内存屏障来实现的。
因此,getObjectVolatile
方法执行的是一个快速的读取操作,不会引起线程的阻塞。这使得多个线程可以同时读取同一个字段的值,而不会发生互斥或阻塞。但需要注意的是,这个方法是 sun.misc.Unsafe
的底层方法,使用时需要谨慎考虑其在不同 Java 版本和不同 JVM 上的行为可能存在的差异和风险。
不会阻塞,因为不是锁。
本质是类似volatile关键字的作用。
compareAndSwapObject会阻塞吗
sun.misc.Unsafe#compareAndSwapObject
方法不会阻塞线程。这是一个底层的原子操作方法,用于实现比较并交换操作(CAS,Compare and Swap)。CAS 是一种乐观锁的机制,它在特定条件下原子地比较某个值与期望值,如果相等,则将值替换为新值。
CAS 操作是非阻塞的,因为它不会导致线程进入阻塞状态。CAS 操作在执行时会尝试修改内存中的值,但如果操作失败(即实际值与期望值不匹配),则不会阻塞线程,而是返回失败的结果。
在 Java 中,CAS 操作常用于实现无锁的并发控制机制,例如用于实现自旋锁、无锁队列、并发容器等。这种操作可以在一定程度上减少锁竞争和线程的上下文切换,从而提高并发性能。
需要注意的是,sun.misc.Unsafe
是 Java 的内部 API,它提供了一些底层的内存操作方法,但它也可能会引发内存安全问题。在实际开发中,推荐使用标准的高级并发控制工具,如 java.util.concurrent
包中提供的工具,以避免直接使用底层的内存操作方法。
也不会阻塞,因为也不是锁。
注意,cas不是锁,乐观锁不是锁。比如基于数据库版本号字段实现的乐观锁,都不是锁。
乐观锁,是确保同一时间只有一个线程写成功。其他线程写失败,而不会阻塞
。
和CopyOnWriteArrayList比较
1、写
基于单独复制一份数据,并且加锁确保线程安全。
2、读
无锁。也没有volatile确保读最新数据,因为读数据和写数据都是单独的一份数据,根本不需要volatile确保读最新数据。所以,读方法根本无需解决线程安全问题。
总结
集合包
集合包,要么是非线程安全,要么是基于悲观锁(都是通过加同步关键字)来解决线程安全问题。
并发包
并发包,做了优化,
1、写
一般有两种方案,其实就是乐观锁和悲观锁,或者是二者结合。
一般情况下,优先使用乐观锁(基于cas)解决并发写的问题,实在不行才使用悲观锁(基于同步关键字或者显示锁)。
2、读
一般使用volatile(sun.misc.Unsafe#getObjectVolatile或者volatile关键字),确保可以读到最新数据。
小结
集合包,写和读,都是使用悲观锁。
并发包,写尽量使用乐观锁,实在不行才使用悲观锁。但是读没有锁,只需要使用volatile确保能读到最新数据即可。
参考
《Java高并发和集合框架》