本章主要讲解一些HashMap与ConcurrentHashMap的原理与对比。
HashMap线程不安全
put源码如下: 这个modCount 是类变量,也就是在jvm内存结构中存放在方法区的,方法区是共享的,那么也就是modCount这个变量不是线程安全。而且源码中++modCount不是原子操作。这个其实有三个步骤先增加,后保存,最后读取。这三个操作不是加锁同步的。因此HashMap的put()是线程不安全的。
jdk1.8中有多个线程同时使用put来添加元素,而且恰好两个put的数据hash值一样,并且该位置数据为null。此时有一个线程的数据就会被覆盖,丢失。
HashMap 是线程不安全的,在多线程使用场景中如果需要使用 Map,应该尽量避免使用线程不安全的 HashMap。同时,虽然 Collections.synchronizedMap(new HashMap()) 是线程安全的,但是效率低下,因为内部用了很多的 synchronized,多个线程不能同时操作。推荐使用线程安全同时性能比较好的 ConcurrentHashMap。
ConcurrentHashMap
Java7过时了就直接讲java8的ConcurrentHashMap
在讲ConcurrentHashMap的时候,我们先了解一些HashMap
HashMap
图中的节点有三种类型。
-
第一种是最简单的,空着的位置代表当前还没有元素来填充。 -
第二种就是和 HashMap 非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续如果计算出相同的 Hash 值,就用链表的形式往后进行延伸。 -
第三种结构就是红黑树结构,这是 Java 7 的HashMap 中所没有的结构,在此之前我们可能也很少接触这样的数据结构。
当第二种情况的链表长度大于某一个阈值(默认为 8),且同时满足一定的容量要求的时候,HashMap 便会把这个链表从链表的形式转化为红黑树的形式,目的是进一步提高它的查找性能。所以,Java 8 的一个重要变化就是引入了红黑树的设计,由于红黑树并不是一种常见的数据结构,所以我们在此简要介绍一下红黑树的特点。
红黑树
红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,红黑树的本质是对二叉查找树 BST 的一种平衡策略,我们可以理解为是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生。
由于自平衡的特点,即左右子树高度几乎一致,所以其查找性能近似于二分查找,时间复杂度是 O(log(n)) 级别;反观链表,它的时间复杂度就不一样了,如果发生了最坏的情况,可能需要遍历整个链表才能找到目标元素,时间复杂度为 O(n),远远大于红黑树的 O(log(n)),尤其是在节点越来越多的情况下,O(log(n)) 体现出的优势会更加明显。
红黑树的一些其他特点:
-
每个节点要么是红色,要么是黑色,但根节点永远是黑色的。 -
红色节点不能连续,也就是说,红色节点的子和父都不能是红色的。 -
从任一节点到其每个叶子节点的路径都包含相同数量的黑色节点。
正是由于这些规则和要求的限制,红黑树保证了较高的查找效率,所以现在就可以理解为什么 Java 8 的 ConcurrentHashMap 要引入红黑树了。好处就是避免在极端的情况下冲突链表变得很长,在查询的时候,效率会非常慢。而红黑树具有自平衡的特点,所以,即便是极端情况下,也可以保证查询效率在 O(log(n))。
ConcurrentHashMap
JDK1.8的实现已经摒弃了Segment(Java7的设计,已过时)的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本. 讲解源码前需要了解的几个参数
-
table 所有的数据都存在在表中,table的容量是可以扩容 -
TreeBin 用于包装红黑树结构的节点类型 -
ForwardingNode 扩容时存放的节点类型,并发扩容的实现关键之一 -
Node 普通节点类型 -
nextTable 扩容时存放数据的变量,扩容完后才能置为NULL -
sizeCtl 以volatile修饰的sizeCtl用于数组初始化与扩容控制,它有以下几个值:
当前未初始化:
= 0 //未指定初始容量
> 0 //由指定的初始容量计算而来,再找最近的2的幂次方。
//比如传入6,计算公式为6+6/2+1=10,最近的2的幂次方为16,所以sizeCtl就为16。
初始化中:
= -1 //table正在初始化 = -N //N是int类型,分为两部分,高15位是指定容量标识,低16位表示 //并行扩容线程数+1,具体在resizeStamp函数介绍。 初始化完成: =table.length * 0.75 //扩容阈值调为table容量大小的0.75倍
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;
// ... }
每个 Node 里面是 key-value 的形式,并且把 value 用 volatile 修饰,以便保证可见性,同时内部还有一个指向下一个节点的 next 指针,方便产生链表结构。
put 方法源码分析
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) {
throw new NullPointerException();
}
//计算 hash 值
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(); } // 找该 hash 值对应的数组下标 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; } } //hash值等于 MOVED 代表在扩容 else if ((fh = f.hash) == MOVED) { tab = helpTransfer(tab, f); } //槽点上是有值的情况 else { V oldVal = null; //用 synchronized 锁住当前槽点,保证并发安全 synchronized (f) { if (tabAt(tab, i) == f) { //如果是链表的形式 if (fh >= 0) { binCount = 1; //遍历链表 for (Node<K, V> e = f; ; ++binCount) { K ek; //如果发现该 key 已存在,就判断是否需要进行覆盖,然后返回 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; //到了链表的尾部也没有发现该 key,说明之前不存在,就把新值添加到链表的最后 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; //调用 putTreeVal 方法往红黑树里增加数据 if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) { p.val = value; } } } } } if (binCount != 0) { //检查是否满足条件并把链表转换为红黑树的形式,默认的 TREEIFY_THRESHOLD 阈值是 8 if (binCount >= TREEIFY_THRESHOLD) { treeifyBin(tab, i); } //putVal 的返回是添加前的旧值,所以返回 oldVal if (oldVal != null) { return oldVal; } break; } } } addCount(1L, binCount); return null; }
通过以上的源码分析,我们对于 putVal 方法有了详细的认识,可以看出,方法中会逐步根据当前槽点是未初始化、空、扩容、链表、红黑树等不同情况做出不同的处理。
添加是发现槽点没有值就使用CAS来存放槽点新值。如果槽点有值,就使用synchronized来锁住槽点
get 方法源码分析
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算 hash 值
int h = spread(key.hashCode());
//如果整个数组是空的,或者当前槽点的数据是空的,说明 key 对应的 value 不存在,直接返回 null
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; } //如果头结点 hash 值小于 0,说明是红黑树或者正在扩容,就用对应的 find 方法来查找 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; }
-
计算 Hash 值,并由此值找到对应的槽点; -
如果数组是空的或者该位置为 null,那么直接返回 null 就可以了; -
如果该位置处的节点刚好就是我们需要的,直接返回该节点的值; -
如果该位置节点是红黑树或者正在扩容,就用 find 方法继续查找; -
否则那就是链表,就进行遍历链表查找。
为啥hashmap中槽点(桶)超过8个转为红黑树
在源码中已经有了为啥槽点到8会转为红黑树 链表查询的复杂度是O(n),红黑树是O(log(n))。n越变大时,链表的查询效率越慢。但是TreeNode的存储是普通的Node的两倍空间。所以需要一个阈值来转换。因为只有key键的hash值相同是,数据才会存到链表中。如源码注释,链表长度的为8的可能性是千万分之一
HashTable与ConcurrentHashMap
众所周知,HashTable是线程安全的,那为啥还要用ConcurrentHashMap
出现的版本
HashTable是1.0出来的,ConcurrentHashMap是在1.5出来的。
实现线程方式不同
Hashtable 是通过synchronized 锁住方法来实现的,几乎每个方法都被 synchronized 关键字所修饰了,这也就保证了线程安全。
Collections.SynchronizedMap(new HashMap()) 的原理和 Hashtable 类似。也是利用 synchronized 实现的。
ConcurrentHashMap是通过CAS + synchronized + Node 节点的方式。也就是所为的分段锁。
性能不同
正因为实现线程同步的方式不同,导致性能不同。当线程数量增加的时候,Hashtable 的性能会急剧下降,因为每一次修改都需要锁住整个对象,而其他线程在此期间是不能操作的。ConcurrentHashMap 中,就算上锁也仅仅会对一部分上锁而不是全部都上锁,所以多线程中的吞吐量通常都会大于单线程的情况,也就是说,在并发效率上,ConcurrentHashMap 比 Hashtable 提高了很多。
迭代时修改的不同
HashTable在迭代时不能修改,会抛出ConcurrentModificationException。ConcurrentHashMap可以修改
请大家关注我的公众号,一起讨论后端技术
本文使用 mdnice 排版