HashMap,ConcurrentHashMap,TreeMap
理解Java7和8里面HashMap+ConcurrentHashMap的扩容策略_AXibaaa的博客-CSDN博客
原文:https://blog.csdn.net/u010454030/article/details/82458413
前言
理解HashMap和ConcurrentHashMap的重点在于:
(1)理解HashMap的数据结构的设计和实现思路
(2)在(1)的基础上,理解ConcurrentHashMap的并发安全的设计和实现思路
前面的文章已经介绍过Map结构的底层实现,这里我们重点放在其扩容方法, 这里分别对JDK7和JDK8版本的HashMap+ConcurrentHashMap来分析:
JDK7的HashMap扩容
这个版本的HashMap数据结构还是数组+链表的方式,扩容方法如下:
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
上面的这段代码不并不难理解,对于扩容操作,底层实现都需要新生成一个数组,然后拷贝旧数组里面的每一个Node链表到新数组里面,这个方法在单线程下执行是没有任何问题的,但是在多线程下面却有很大问题,主要的问题在于基于头插法的数据迁移,会有几率造成链表倒置,从而引发链表闭链,导致程序死循环,并吃满CPU。据说已经有人给原来的SUN公司提过bug,但sun公司认为,这是开发者使用不当造成的,因为这个类本就不是线程安全的,你还偏在多线程下使用,这下好了吧,出了问题这能怪我咯?仔细想想,还有点道理。
JDK7的ConcurrentHashMap扩容
HashMap是线程不安全的,我们来看下线程安全的ConcurrentHashMap,在JDK7的时候,这种安全策略采用的是分段锁的机制,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。
下面看下其扩容的源码:
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 2 倍
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;
// 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素,那比较好办
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry<K,V> lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;
// 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的节点,
// 这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
注意这里面的代码,外部已经加锁,所以这里面是安全的,我们看下具体的实现方式:先对数组的长度增加一倍,然后遍历原来的旧的table数组,把每一个数组元素也就是Node链表迁移到新的数组里面,最后迁移完毕之后,把新数组的引用直接替换旧的。此外这里这有一个小的细节优化,在迁移链表时用了两个for循环,第一个for的目的是为了,判断是否有迁移位置一样的元素并且位置还是相邻,根据HashMap的设计策略,首先table的大小必须是2的n次方,我们知道扩容后的每个链表的元素的位置,要么不变,要么是原table索引位置+原table的容量大小,举个例子假如现在有三个元素(3,5,7)要放入map里面,table的的容量是2,简单的假设元素位置=元素的值 % 2,得到如下结构:
[0]=null
[1]=3->5->7
现在将table的大小扩容成4,分布如下:
[0]=null
[1]=5->7
[2]=null
[3]=3
因为扩容必须是2的n次方,所以HashMap在put和get元素的时候直接取key的hashCode然后经过再次均衡后直接采用&位运算就能达到取模效果,这个不再细说,上面这个例子的目的是为了说明扩容后的数据分布策略,要么保留在原位置,要么会被均衡在旧的table位置,这里是1加上旧的table容量这是是2,所以是3。基于这个特点,第一个for循环,作的优化如下,假设我们现在用0表示原位置,1表示迁移到index+oldCap的位置,来代表元素:
[0]=null
[1]=0->1->1->0->0->0->0
第一个for循环的会记录lastRun,比如要迁移[1]的数据,经过这个循环之后,lastRun的位置会记录第三个0的位置,因为后面的数据都是0,代表他们要迁移到新的数组中同一个位置中,所以就可以把这个中间节点,直接插入到新的数组位置而后面附带的一串元素其实都不需要动。
接着第二个循环里面在此从第一个0的位置开始遍历到lastRun也就是第三个元素的位置就可以了,只循环处理前面的数据即可,这个循环里面根据位置0和1做不同的链表追加,后面的数据已经被优化的迁移走了,但最坏情况下可能后面一个也没优化,比如下面的结构:
[0]=null
[1]=1->1->0->0->0->0->1->0
这种情况,第一个for循环没多大作用,需要通过第二个for循环从头开始遍历到尾部,按0和1分发迁移,这里面使用的是还是头插法的方式迁移,新迁移的数据是追加在链表的头部,但这里是线程安全的所以不会出现循环链表,导致死循环问题。迁移完成之后直接将最新的元素加入,最后将新的table替换旧的table即可。
JDK8的HashMap扩容
在JDK8里面,HashMap的底层数据结构已经变为数组+链表+红黑树的结构了,因为在hash冲突严重的情况下,链表的查询效率是O(n),所以JDK8做了优化对于单个链表的个数大于8的链表,会直接转为红黑树结构算是以空间换时间,这样以来查询的效率就变为O(logN),图示如下:
我们看下其扩容代码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//重点关注区域
// preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在JDK8中,单纯的HashMap数据结构增加了红黑树是一个大的优化,此外根据上面的迁移扩容策略,我们发现JDK8里面HashMap没有采用头插法转移链表数据,而是保留了元素的顺序位置,新的代码里面采用:
//按原始链表顺序,过滤出来扩容后位置不变的元素(低位=0),放在一起
Node<K,V> loHead = null, loTail = null;
//按原始链表顺序,过滤出来扩容后位置改变到(index+oldCap)的元素(高位=0),放在一起
Node<K,V> hiHead = null, hiTail = null;
把要迁移的元素分类之后,最后在分别放到新数组对应的位置上:
//位置不变
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//位置迁移(index+oldCap)
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
JDK7里面是先判断table的存储元素的数量是否超过当前的threshold=table.length*loadFactor(默认0.75),如果超过就先扩容,在JDK8里面是先插入数据,插入之后在判断下一次++size的大小是否会超过当前的阈值,如果超过就扩容。
JDK8的ConcurrentHashMap扩容
在JDK8中彻底抛弃了JDK7的分段锁的机制,新的版本主要使用了Unsafe类的CAS自旋赋值+synchronized同步+LockSupport阻塞等手段实现的高效并发,代码可读性稍差。
ConcurrentHashMap的JDK8与JDK7版本的并发实现相比,最大的区别在于JDK8的锁粒度更细,理想情况下talbe数组元素的大小就是其支持并发的最大个数,在JDK7里面最大并发个数就是Segment的个数,默认值是16,可以通过构造函数改变一经创建不可更改,这个值就是并发的粒度,每一个segment下面管理一个table数组,加锁的时候其实锁住的是整个segment,这样设计的好处在于数组的扩容是不会影响其他的segment的,简化了并发设计,不足之处在于并发的粒度稍粗,所以在JDK8里面,去掉了分段锁,将锁的级别控制在了更细粒度的table元素级别,也就是说只需要锁住这个链表的head节点,并不会影响其他的table元素的读写,好处在于并发的粒度更细,影响更小,从而并发效率更好,但不足之处在于并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点,好在Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类,在一个线程发起扩容的时候,就会改变sizeCtl这个值,其含义如下:
sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。
-1 代表table正在初始化
-N 表示有N-1个线程正在进行扩容操作
其余情况:
1、如果table未初始化,表示table需要初始化的大小。
2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍
扩容时候会判断这个值,如果超过阈值就要扩容,首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f,初始化一个forwardNode实例fwd,如果f == null,则在table中的i位置放入fwd,否则采用头插法的方式把当前旧table数组的指定任务范围的数据给迁移到新的数组中,然后 给旧table原位置赋值fwd。直到遍历过所有的节点以后就完成了复制工作,把table指向nextTable,并更新sizeCtl为新数组大小的0.75倍 ,扩容完成。在此期间如果其他线程的有读写操作都会判断head节点是否为forwardNode节点,如果是就帮助扩容。
扩容源码如下:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
在扩容时读写操作如何进行
(1)对于get读操作,如果当前节点有数据,还没迁移完成,此时不影响读,能够正常进行。
如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时get线程会帮助扩容。
(2)对于put/remove写操作,如果当前链表已经迁移完成,那么头节点会被设置成fwd节点,此时写线程会帮助扩容,如果扩容没有完成,当前链表的头节点会被锁住,所以写线程会被阻塞,直到扩容完成。
对于size和迭代器是弱一致性
volatile修饰的数组引用是强可见的,但是其元素却不一定,所以,这导致size的根据sumCount的方法并不准确。
同理Iteritor的迭代器也一样,并不能准确反映最新的实际情况
总结
本文主要了介绍了HashMap+ConcurrentHashMap的扩容策略,扩容的原理是新生成大于原来1倍大小的数组,然后拷贝旧数组数据到新的数组里面,在多线程情况下,这里面如果注意线程安全问题,在解决安全问题的同时,我们也要关注其效率,这才是并发容器类的最出色的地方。
JDK容器学习之TreeMap (一) : 底层数据结构 - 小灰灰Blog的个人空间 - OSCHINA - 中文开源技术交流社区
TreeMap
在日常的工作中,相比较与
HashMap
而言,TreeMap
的使用会少很多,即使在某些场景,需要使用到排序的Map时,也更多的是选择LinkedHashMap
,那么这个TreeMap
到底是个怎样的容器,又适用于什么样的应用场景呢?
1. 数据结构分析
分析数据结构,最好的方式无疑是google+baidu+源码了
1. 继承体系
看到源码第一眼,就会发现与HashMap不同的是 TreeMap 实现的是 NavigableMap
, 而不是直接实现 Map
public class TreeMap<K,V>
extends AbstractMap<K,V>
implements NavigableMap<K,V>, Cloneable, java.io.Serializable {
// ....
}
有必要仔细看下这个 NavigableMap
,到底有些什么特殊之处
继承体系: Map -> SortMap -> NavigbleMap
其中 SortMap
新增了下面几个接口,目前也不知道具体有啥用,先翻译下源码注释
// 既然叫做SortMap, 要排序的话,当然需要一个比较器了
Comparator<? super K> comparator();
SortedMap<K,V> subMap(K fromKey, K toKey);
// 源码注释: 返回比Map中key比参数toKey小的所有kv对
SortedMap<K,V> headMap(K toKey);
// 源码注释:返回比Map中key比参数fromKey大或相等的所有kv对
SortedMap<K,V> tailMap(K fromKey);
K firstKey();
K lastKey();
接着就是 NavigableMap
定义的接口
// 返回Map中比传入参数key小的kv对中,key最大的一个kv对
Map.Entry<K,V> lowerEntry(K key);
K lowerKey(K key);
// 返回Map中比传入参数key小或相等的kv对中,key最大的一个kv对
Map.Entry<K,V> floorEntry(K key);
K floorKey(K key);
// 返回Map中比传入参数key大或相等的kv对中,key最小的一个kv对
Map.Entry<K,V> ceilingEntry(K key);
K ceilingKey(K key);
// 返回Map中比传入参数key大的kv对中,key最小的一个kv对
Map.Entry<K,V> higherEntry(K key);
K higherKey(K key);
Map.Entry<K,V> firstEntry();
Map.Entry<K,V> lastEntry();
Map.Entry<K,V> pollFirstEntry();
NavigableMap<K,V> descendingMap();
NavigableSet navigableKeySet();
NavigableSet descendingKeySet();
基本上这两个接口就是提供了一些基于排序的获取kv对的方式
2. 数据结构
看下内部的成员变量,发现可能涉及到数据结构的就只有下面的这个root了
private transient Entry<K,V> root;
结合 TreeMap
的命名来看,底层的结构多半就真的是Tree
了,有树的根节点,一般来讲遍历都是没啥问题的
接下来看下 Entry的实现
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
/**
* Make a new cell with given key, value, and parent, and with
* {@code null} child links, and BLACK color.
*/
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
/**
* Returns the key.
*
* @return the key
*/
public K getKey() {
return key;
}
/**
* Returns the value associated with the key.
*
* @return the value associated with the key
*/
public V getValue() {
return value;
}
/**
* Replaces the value currently associated with the key with the given
* value.
*
* @return the value associated with the key before this method was
* called
*/
public V setValue(V value) {
V oldValue = this.value;
this.value = value;
return oldValue;
}
public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
return valEquals(key,e.getKey()) && valEquals(value,e.getValue());
}
public int hashCode() {
int keyHash = (key==null ? 0 : key.hashCode());
int valueHash = (value==null ? 0 : value.hashCode());
return keyHash ^ valueHash;
}
public String toString() {
return key + "=" + value;
}
}
从Entry的内部成员变量可以看出,这是一个二叉树,且极有可能就是一颗红黑树(因为有个black
)
2. 添加一个kv对
通过新增一个kv对的调用链,来分析下这棵树,到底是不是红黑树
将put方法捞出来, 然后补上注释
public V put(K key, V value) {
Entry<K,V> t = root;
if (t == null) {
// 奇怪的一行逻辑,感觉并没有什么用
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
if (cpr != null) {
// 下面这个循环可以得出树的左节点小于根小于右节点
// 然后找到新增的节点,作为叶子节点在树中的位置
// 注意这个相等时,直接更新了value值(这里表示插入一条已存在的记录)
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
else {
// 比较器不存在的逻辑,这时要求key继承 Comparable 接口
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
// 创建一个Entry节点
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
// 红黑树的重排
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
从添加逻辑,可以得出结论:
- 树结构为二叉排序树(且不能出现相等的情况)
- 重排的方法可以保证该树为红黑树
所以新增一个kv对的逻辑就比较简单了
遍历树,将kv对作为叶子节点存在对应的位置
小结
红黑树相关可以作为独立的一个知识点,这里不详细展开,基本上通过上面的分析,可以得出下面几个点
- TreeMap 底层结构为红黑树
- 红黑树的Node排序是根据Key进行比较
- 每次新增删除节点,都可能导致红黑树的重排
- 红黑树中不支持两个or已上的Node节点对应
红黑值
相等
相关博文
HashMap之Hash碰撞冲突解决方案及未来改进_qedgbmwyz的博客-CSDN博客
说明:参考网上的两篇文章做了简单的总结,以备后查(http://blogread.cn/it/article/7191?f=wb ,http://it.deepinmind.com/%E6%80%A7%E8%83%BD/2014/04/24/hashmap-performance-in-java-8.html)
1.HashMap位置决定与存储
通过前面的源码分析可知,HashMap 采用一种所谓的“Hash 算法”来决定每个元素的存储位置。当程序执行put(String,Obect)方法 时,系统将调用String的 hashCode() 方法得到其 hashCode 值——每个 Java 对象都有 hashCode() 方法,都可通过该方法获得它的 hashCode 值。得到这个对象的 hashCode 值之后,系统会根据该 hashCode 值来决定该元素的存储位置。源码如下:
- public V put(K key, V value) {
- if (key == null)
- return putForNullKey(value);
- int hash = hash(key.hashCode());
- int i = indexFor(hash, table.length);
- for (Entry<K,V> e = table[i]; e != null; e = e.next) {
- Object k;
- if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
- V oldValue = e.value;
- e.value = value;
- e.recordAccess(this);
- return oldValue;
- }
- }
- modCount++;
- addEntry(hash, key, value, i);
- return null;
- }
- static int hash(int h) {
- // This function ensures that hashCodes that differ only by
- // constant multiples at each bit position have a bounded
- // number of collisions (approximately 8 at default load factor).
- h ^= (h >>> 20) ^ (h >>> 12);
- return h ^ (h >>> 7) ^ (h >>> 4);
- }
- /**
- * Returns index for hash code h.
- */
- static int indexFor(int h, int length) {
- return h & (length-1);
- }
- static class Entry<K,V> implements Map.Entry<K,V> {
- final K key;
- V value;
- Entry<K,V> next;
- final int hash;
- /**
- * Creates new entry.
- */
- Entry(int h, K k, V v, Entry<K,V> n) {
- value = v;
- next = n;
- key = k;
- hash = h;
- }
- public final K getKey() {
- return key;
- }
- public final V getValue() {
- return value;
- }
- public final V setValue(V newValue) {
- V oldValue = value;
- value = newValue;
- return oldValue;
- }
- public final boolean equals(Object o) {
- if (!(o instanceof Map.Entry))
- return false;
- Map.Entry e = (Map.Entry)o;
- Object k1 = getKey();
- Object k2 = e.getKey();
- if (k1 == k2 || (k1 != null && k1.equals(k2))) {
- Object v1 = getValue();
- Object v2 = e.getValue();
- if (v1 == v2 || (v1 != null && v1.equals(v2)))
- return true;
- }
- return false;
- }
- public final int hashCode() {
- return (key==null ? 0 : key.hashCode()) ^
- (value==null ? 0 : value.hashCode());
- }
- public final String toString() {
- return getKey() + "=" + getValue();
- }
- /**
- * This method is invoked whenever the value in an entry is
- * overwritten by an invocation of put(k,v) for a key k that‘s already
- * in the HashMap.
- */
- void recordAccess(HashMap<K,V> m) {
- }
- /**
- * This method is invoked whenever the entry is
- * removed from the table.
- */
- void recordRemoval(HashMap<K,V> m) {
- }
- }
我们知道Entry含有的属性是Value,Key,还有一只指向下一个指针Next。当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。这也说明了前面的结论:我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可
2.Hash碰撞产生及解决
Hashmap里面的bucket出现了单链表的形式,散列表要解决的一个问题就是散列值的冲突问题,通常是两种方法:链表法和开放地址法。链表法就是将相同hash值的对象组织成一个链表放在hash值对应的槽位;开放地址法是通过一个探测算法,当某个槽位已经被占据的情况下继续查找下一个可以使用的槽位。java.util.HashMap采用的链表法的方式,链表是单向链表。形成单链表的核心代码如下:
- void addEntry(int hash, K key, V value, int bucketIndex) {
- ry<K,V> e = table[bucketIndex];
- table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
- if (size++ >= threshold)
- resize(2 * table.length);
- }
上面方法的代码很简单,但其中包含了一个设计:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。 HashMap里面没有出现hash冲突时,没有形成单链表时,hashmap查找元素很快,get()方法能够直接定位到元素,但是出现单链表后,单个bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。
通过上面可知如果多个hashCode()的值落到同一个桶内的时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到O(n)。也就是说我们是通过链表的方式来解决这个Hash碰撞问题的。
3.Hash碰撞性能分析
Java 7:随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。不过Java 8的表现要好许多!它是一个log的曲线,因此它的性能要好上好几个数量级。尽管有严重的哈希碰撞,已是最坏的情况了,但这个同样的基准测试在JDK8中的时间复杂度是O(logn)。单独来看JDK 8的曲线的话会更清楚,这是一个对数线性分布:
4.Java8碰撞优化提升
为什么会有这么大的性能提升,尽管这里用的是大O符号(大O描述的是渐近上界)?其实这个优化在JEP-180中已经提到了。如果某个桶中的记录过大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。它是如何工作的?前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。这对HashMap的key来说并不是必须的,不过如果实现了当然最好。如果没有实现这个接口,在出现严重的哈希碰撞的时候,你就并别指望能获得性能提升了。这个性能提升有什么用处?比方说恶意的程序,如果它知道我们用的是哈希算法,它可能会发送大量的请求,导致产生严重的哈希碰撞。然后不停的访问这些key就能显著的影响服务器的性能,这样就形成了一次拒绝服务攻击(DoS)。JDK 8中从O(n)到O(logn)的飞跃,可以有效地防止类似的攻击,同时也让HashMap性能的可预测性稍微增强了一些。
HashMap?ConcurrentHashMap?相信看完这篇没人能难住你!_Java团长的博客-CSDN博客
前言
Map 这样的 Key Value
在软件开发中是非常经典的结构,常用于在内存中存放数据。
本篇主要想讨论 ConcurrentHashMap 这样一个并发容器,在正式开始之前我觉得有必要谈谈 HashMap,没有它就不会有后面的 ConcurrentHashMap。
HashMap
众所周知 HashMap 底层是基于 数组 + 链表
组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。
Base 1.7
1.7 中的数据结构图:
先来看看 1.7 中的实现。
这是 HashMap 中比较核心的几个成员变量;看看分别是什么意思?
-
初始化桶大小,因为底层是数组,所以这是数组默认的大小。
-
桶最大值。
-
默认的负载因子(0.75)
-
table
真正存放数据的数组。 -
Map
存放数量的大小。 -
桶大小,可在初始化时显式指定。
-
负载因子,可在初始化时显式指定。
重点解释下负载因子:
由于给定的 HashMap 的容量大小是固定的,比如默认初始化:
1 public HashMap() { 2 this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); 3 } 4 5 public HashMap(int initialCapacity, float loadFactor) { 6 if (initialCapacity < 0) 7 throw new IllegalArgumentException("Illegal initial capacity: " + 8 initialCapacity); 9 if (initialCapacity > MAXIMUM_CAPACITY)10 initialCapacity = MAXIMUM_CAPACITY;11 if (loadFactor <= 0 || Float.isNaN(loadFactor))12 throw new IllegalArgumentException("Illegal load factor: " +13 loadFactor);1415 this.loadFactor = loadFactor;16 threshold = initialCapacity;17 init();18 }
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12
就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
因此通常建议能提前预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。
根据代码可以看到其实真正存放数据的是
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
这个数组,那么它又是如何定义的呢?
Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:
-
key 就是写入时的键。
-
value 自然就是值。
-
开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
-
hash 存放的是当前 key 的 hashcode。
知晓了基本结构,那来看看其中重要的写入、获取函数:
put 方法
1 public V put(K key, V value) { 2 if (table == EMPTY_TABLE) { 3 inflateTable(threshold); 4 } 5 if (key == null) 6 return putForNullKey(value); 7 int hash = hash(key); 8 int i = indexFor(hash, table.length); 9 for (Entry<K,V> e = table[i]; e != null; e = e.next) {10 Object k;11 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {12 V oldValue = e.value;13 e.value = value;14 e.recordAccess(this);15 return oldValue;16 }17 }1819 modCount++;20 addEntry(hash, key, value, i);21 return null;22 }
-
判断当前数组是否需要初始化。
-
如果 key 为空,则 put 一个空值进去。
-
根据 key 计算出 hashcode。
-
根据计算出的 hashcode 定位出所在桶。
-
如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
-
如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。
1 void addEntry(int hash, K key, V value, int bucketIndex) { 2 if ((size >= threshold) && (null != table[bucketIndex])) { 3 resize(2 * table.length); 4 hash = (null != key) ? hash(key) : 0; 5 bucketIndex = indexFor(hash, table.length); 6 } 7 8 createEntry(hash, key, value, bucketIndex); 9 }1011 void createEntry(int hash, K key, V value, int bucketIndex) {12 Entry<K,V> e = table[bucketIndex];13 table[bucketIndex] = new Entry<>(hash, key, value, e);14 size++;15 }
当调用 addEntry 写入 Entry 时需要判断是否需要扩容。
如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位。
而在 createEntry
中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表。
get 方法
再来看看 get 函数:
1 public V get(Object key) { 2 if (key == null) 3 return getForNullKey(); 4 Entry<K,V> entry = getEntry(key); 5 6 return null == entry ? null : entry.getValue(); 7 } 8 9 final Entry<K,V> getEntry(Object key) {10 if (size == 0) {11 return null;12 }1314 int hash = (key == null) ? 0 : hash(key);15 for (Entry<K,V> e = table[indexFor(hash, table.length)];16 e != null;17 e = e.next) {18 Object k;19 if (e.hash == hash &&20 ((k = e.key) == key || (key != null && key.equals(k))))21 return e;22 }23 return null;24 }
-
首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
-
判断该位置是否为链表。
-
不是链表就根据
key、key 的 hashcode
是否相等来返回值。 -
为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
-
啥都没取到就直接返回 null 。
Base 1.8
不知道 1.7 的实现大家看出需要优化的点没有?
其实一个很明显的地方就是:
当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为
O(N)
。
因此 1.8 中重点优化了这个查询效率。
1.8 HashMap 结构图:
先来看看几个核心的成员变量:
1 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 2 3 /** 4 * The maximum capacity, used if a higher value is implicitly specified 5 * by either of the constructors with arguments. 6 * MUST be a power of two <= 1<<30. 7 */ 8 static final int MAXIMUM_CAPACITY = 1 << 30; 910 /**11 * The load factor used when none specified in constructor.12 */13 static final float DEFAULT_LOAD_FACTOR = 0.75f;1415 static final int TREEIFY_THRESHOLD = 8;1617 transient Node<K,V>[] table;1819 /**20 * Holds cached entrySet(). Note that AbstractMap fields are used21 * for keySet() and values().22 */23 transient Set<Map.Entry<K,V>> entrySet;2425 /**26 * The number of key-value mappings contained in this map.27 */28 transient int size;
和 1.7 大体上都差不多,还是有几个重要的区别:
-
TREEIFY_THRESHOLD
用于判断是否需要将链表转换为红黑树的阈值。 -
HashEntry 修改为 Node。
Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next
等数据。
再来看看核心方法。
put 方法
看似要比 1.7 的复杂,我们一步步拆解:
-
判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
-
根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
-
如果当前桶有值( Hash 冲突),那么就要比较当前桶中的
key、key 的 hashcode
与写入的 key 是否相等,相等就赋值给e
,在第 8 步的时候会统一进行赋值及返回。 -
如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
-
如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
-
接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
-
如果在遍历过程中找到 key 相同时直接退出遍历。
-
如果
e != null
就相当于存在相同的 key,那就需要将值覆盖。 -
最后判断是否需要进行扩容。
get 方法
1 public V get(Object key) { 2 Node<K,V> e; 3 return (e = getNode(hash(key), key)) == null ? null : e.value; 4 } 5 6 final Node<K,V> getNode(int hash, Object key) { 7 Node<K,V>[] tab; Node<K,V> first, e; int n; K k; 8 if ((tab = table) != null && (n = tab.length) > 0 && 9 (first = tab[(n - 1) & hash]) != null) {10 if (first.hash == hash && // always check first node11 ((k = first.key) == key || (key != null && key.equals(k))))12 return first;13 if ((e = first.next) != null) {14 if (first instanceof TreeNode)15 return ((TreeNode<K,V>)first).getTreeNode(hash, key);16 do {17 if (e.hash == hash &&18 ((k = e.key) == key || (key != null && key.equals(k))))19 return e;20 } while ((e = e.next) != null);21 }22 }23 return null;24 }
get 方法看起来就要简单许多了。
-
首先将 key hash 之后取得所定位的桶。
-
如果桶为空则直接返回 null 。
-
否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
-
如果第一个不匹配,则判断它的下一个是红黑树还是链表。
-
红黑树就按照树的查找方式返回值。
-
不然就按照链表的方式遍历匹配返回值。
从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)
。
但是 HashMap 原有的问题也都存在,比如在并发场景下使用时容易出现死循环。
1final HashMap<String, String> map = new HashMap<String, String>();2for (int i = 0; i < 1000; i++) {3 new Thread(new Runnable() {4 @Override5 public void run() {6 map.put(UUID.randomUUID().toString(), "");7 }8 }).start();9}
但是为什么呢?简单分析下。
看过上文的还记得在 HashMap 扩容的时候会调用 resize()
方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。
如下图:
遍历方式
还有一个值得注意的是 HashMap 的遍历方式,通常有以下几种:
1Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator(); 2 while (entryIterator.hasNext()) { 3 Map.Entry<String, Integer> next = entryIterator.next(); 4 System.out.println("key=" + next.getKey() + " value=" + next.getValue()); 5 } 6 7Iterator iterator = map.keySet().iterator(); 8 while (iterator.hasNext()){ 9 String key = iterator.next();10 System.out.println("key=" + key + " value=" + map.get(key));1112 }
强烈建议
使用第一种 EntrySet 进行遍历。
第一种可以把 key value 同时取出,第二种还得需要通过 key 取一次 value,效率较低。
简单总结下 HashMap:无论是 1.7 还是 1.8 其实都能看出 JDK 没有对它做任何的同步操作,所以并发会出问题,甚至出现死循环导致系统不可用。
因此 JDK 推出了专项专用的 ConcurrentHashMap ,该类位于 java.util.concurrent
包下,专门用于解决并发问题。
坚持看到这里的朋友算是已经把 ConcurrentHashMap 的基础已经打牢了,下面正式开始分析。
ConcurrentHashMap
ConcurrentHashMap 同样也分为 1.7 、1.8 版,两者在实现上略有不同。
Base 1.7
先来看看 1.7 的实现,下面是他的结构图:
如图所示,是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。
它的核心成员变量:
1 /**2 * Segment 数组,存放数据时首先需要定位到具体的 Segment 中。3 */4 final Segment<K,V>[] segments;56 transient Set keySet;7 transient Set<Map.Entry<K,V>> entrySet;
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:
1 static final class Segment<K,V> extends ReentrantLock implements Serializable { 2 3 private static final long serialVersionUID = 2249069246763182397L; 4 5 // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶 6 transient volatile HashEntry<K,V>[] table; 7 8 transient int count; 910 transient int modCount;1112 transient int threshold;1314 final float loadFactor;1516 }
看看其中 HashEntry 的组成:
和 HashMap 非常类似,唯一的区别就是其中的核心数据如 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性。
原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
下面也来看看核心的 put get
方法。
put 方法
1 public V put(K key, V value) { 2 Segment<K,V> s; 3 if (value == null) 4 throw new NullPointerException(); 5 int hash = hash(key); 6 int j = (hash >>> segmentShift) & segmentMask; 7 if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck 8 (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment 9 s = ensureSegment(j);10 return s.put(key, hash, value, false);11 }
首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。
1 final V put(K key, int hash, V value, boolean onlyIfAbsent) { 2 HashEntry<K,V> node = tryLock() ? null : 3 scanAndLockForPut(key, hash, value); 4 V oldValue; 5 try { 6 HashEntry<K,V>[] tab = table; 7 int index = (tab.length - 1) & hash; 8 HashEntry<K,V> first = entryAt(tab, index); 9 for (HashEntry<K,V> e = first;;) {10 if (e != null) {11 K k;12 if ((k = e.key) == key ||13 (e.hash == hash && key.equals(k))) {14 oldValue = e.value;15 if (!onlyIfAbsent) {16 e.value = value;17 ++modCount;18 }19 break;20 }21 e = e.next;22 }23 else {24 if (node != null)25 node.setNext(first);26 else27 node = new HashEntry<K,V>(hash, key, value, first);28 int c = count + 1;29 if (c > threshold && tab.length < MAXIMUM_CAPACITY)30 rehash(node);31 else32 setEntryAt(tab, index, node);33 ++modCount;34 count = c;35 oldValue = null;36 break;37 }38 }39 } finally {40 unlock();41 }42 return oldValue;43 }
虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut()
自旋获取锁。
-
尝试自旋获取锁。
-
如果重试的次数达到了
MAX_SCAN_RETRIES
则改为阻塞锁获取,保证能获取成功。
再结合图看看 put 的流程。
-
将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
-
遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
-
不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
-
最后会解除在 1 中所获取当前 Segment 的锁。
get 方法
1 public V get(Object key) { 2 Segment<K,V> s; // manually integrate access methods to reduce overhead 3 HashEntry<K,V>[] tab; 4 int h = hash(key); 5 long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; 6 if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && 7 (tab = s.table) != null) { 8 for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile 9 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);10 e != null; e = e.next) {11 K k;12 if ((k = e.key) == key || (e.hash == h && key.equals(k)))13 return e.value;14 }15 }16 return null;17 }
get 逻辑比较简单:
只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。
由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
Base 1.8
1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题。
那就是查询遍历链表效率太低。
因此 1.8 做了一些数据结构上的调整。
首先来看下底层的组成结构:
看起来是不是和 1.8 HashMap 结构类似?
其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized
来保证并发安全性。
也将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的。
其中的 val next
都用了 volatile 修饰,保证了可见性。
put 方法
重点来看看 put 函数:
-
根据 key 计算出 hashcode 。
-
判断是否需要进行初始化。
-
f
即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。 -
如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 -
如果都不满足,则利用 synchronized 锁写入数据。
-
如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。
get 方法
-
根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
-
如果是红黑树那就按照树的方式获取值。
-
就不满足那就按照链表的方式遍历获取值。
1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(
O(logn)
),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。
总结
看完了整个 HashMap 和 ConcurrentHashMap 在 1.7 和 1.8 中不同的实现方式相信大家对他们的理解应该会更加到位。
其实这块也是面试的重点内容,通常的套路是:
-
谈谈你理解的 HashMap,讲讲其中的 get put 过程。
-
1.8 做了什么优化?
-
是线程安全的嘛?
-
不安全会导致哪些问题?
-
如何解决?有没有线程安全的并发容器?
-
ConcurrentHashMap 是如何实现的? 1.7、1.8 实现有何不同?为什么这么做?
这一串问题相信大家仔细看完都能怼回面试官。
除了面试会问到之外平时的应用其实也蛮多,像之前谈到的 Guava 中 Cache 的实现就是利用 ConcurrentHashMap 的思想。
同时也能学习 JDK 作者大牛们的优化思路以及并发解决方案。