一、Hash表
1. 什么是Hash表
hash函数就是根据key计算出应该存储地址的位置,而哈希表是基于哈希函数建立的一种查找表
2. hash函数设计的考虑因素
- 计算散列地址所需要的时间(即hash函数本身不要太复杂)
- 关键字的长度
- 表长
- 关键字分布是否均匀,是否有规律可循
- 设计的hash函数在满足以上条件的情况下尽量减少冲突
3.哈希冲突的解决方案
不管hash函数设计的如何巧妙,总会有特殊的key导致hash冲突,特别是对动态查找表来说。hash函数解决冲突的方法有以下几个常用的方法
A.开放定制法(线性探索)
B.链地址法(HashMap)
C.公共溢出区法建立一个特殊存储空间,专门存放冲突的数据。此种方法适用于数据和冲突较少的情况。
D.再散列法(布隆过滤器)准备若干个hash函数,如果使用第一个hash函数发生了冲突,就使用第二个hash函数,第二个也冲突,使用第三个……
开放定址法
当一个关键字和另一个关键字发生冲突时,使用某种探测技术在Hash表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。基本公式为:hash(key) = (hash(key)+di)mod TableSize。其中di为增量序列,TableSize为表长。根据di的不同我们又可以分为线性探测,平方(二次)探测,双散列探测。
1)线性探测
以增量序列 1,2,……,(TableSize -1)循环试探下一个存储地址,即di = i。如果table[index+di]为空则进行插入,反之试探下一个增量。但是线性探测也有弊端,就是会造成元素聚集现象,降低查找效率。具体例子如下图:
特别对于开放定址法的删除操作,不能简单的进行物理删除,因为对于同义词来说,这个地址可能在其查找路径上,若物理删除的话,会中断查找路径,故只能设置删除标志。
//插入函数,利用线性探测法 bool Insert_Linear_Probing(int num){ //哈希表已经被装满,则不在填入 if(this->size == this->length){ return false; } int index = this->hash(num); if(this->data[index] == MAX){ this->data[index] = num; }else{ int i = 1; //寻找合适位置 while(this->data[(index+i)%this->length] != MAX){ i++; } index = (index+i)%this->length; this->data[index] = num; } if(this->delete_flag[index] == 1){//之前设置为删除 this->delete_flag[index] = 0; } this->size++; return true; }
链地址法
HashMap即是采用了链地址法,也就是数组+链表的方式,HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。
//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一个静态内部类。代码如下
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构 int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算 /** * Creates new entry. */ Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; }
所以,HashMap的整体结构如下
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
二.ConcurrentHashMap
ConcurrentHashMap是Java并发包中提供的一个线程安全且高效的HashMap实现,ConcurrentHashMap在并发编程的场景中使用频率非常之高,下面我们来分析下ConcurrentHashMap的实现原理,并对其实现原理进行分析 。
众所周知,哈希表是种非常高效,复杂度为O(1)的数据结构,在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理。
HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(多线程扩容时可能造成),导致get操作时,cpu空转, 所以,在并发环境中使用HashMap是非常危险的。
HashTable : HashTable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据比喻[11],这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。java1.7后的CHM中把每个数组叫Segment,每个segment下面存的是默认16段的Hashhenery,Hashhenery解决充突是在Hashhenery下面挂载链表,我们就画图说明下分段锁
ConcurrentHashMap
初始化时,计算出Segment
数组的大小ssize
和每个Segment
中HashEntry
数组的大小cap
,并初始化Segment
数组的第一个元素;其中ssize
大小为2的幂次方,默认为16,cap
大小也是2的幂次方,最小值为2,最终结果根据初始化容量initialCapacity
进行计算,计算过程如下
if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1;
因为Segment
继承了ReentrantLock,所有segment是线程安全的,但是在1.8中放弃了Segment
分段锁的设计,使用的是Node
+CAS
+Synchronized
来保证线程安全性,而且这样设计的好处是层级降低了,锁的粒度更小了,可以说是一种优化,比喻锁的是2,那么他锁的就只是发生冲突的2下面的链表,而不像1.7样,是锁整个HashEntry;而且1.8中对链表的长度进行了优化,在1.7的链表中链表查询的复杂度是O(n),但是在1.8中为了解决这问题引入了红黑树,在1.8中当我们链表长度大于8时并且数组长度大于64时,就会发生一个链表的转换,会把单向链表转换成红黑树。
put操作
在1.7 中当执行put
方法插入数据的时候,根据key的hash值,在Segment
数组中找到对应的位置如果当前位置没有值,则通过CAS进行赋值,接着执行Segment
的put
方法通过加锁机制插入数据;假如有线程AB同时执行相同Segment
的put
方法
线程A 执行tryLock方法成功获取锁,然后把HashEntry对象插入到相应位置 线程B 尝试获取锁失败,则执行scanAndLockForPut()方法,通过重复执行tryLock()方法尝试获取锁 在多处理器环境重复64次,单处理器环境重复1次,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B 当线程A执行完插入操作时,会通过unlock方法施放锁,接着唤醒线程B继续执行
但在1.8 中执行put
方法插入数据的时候,根据key的hash值在Node
数组中找到相应的位置如果当前位置的 Node
还没有初始化,则通过CAS插入数据
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //如果当前位置的`Node`还没有初始化,则通过CAS插入数据 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin }
如果当前位置的Node已经有值,则对该节点加synchronized
锁,然后从该节点开始遍历,直到插入新的节点或者更新新的节点
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; } } }
如果当前节点是TreeBin
类型,说明该节点下的链表已经进化成红黑树结构,则通过putTreeVal
方法向红黑树中插入新的节点
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; } }
如果binCount
不为0,说明put
操作对数据产生了影响,如果当前链表的节点个数达到了8个,则通过treeifyBin
方法将链表转化为红黑树
size操作
在1.7中统计每个segment
对象中的元素个数,然后进行累加,但是这种方式计算出来的结果不一定准确,因为在计算后面的segment
的元素个数时,前面计算过了的segment
可能有数据的新增或删除;他的计算方式是:先采用不加锁的方式,连续计算两次;如果两次结果相等,说明计算结果准确,如果两次结果不相等,说明计算过程中出现了并发新增或者删除操作于是给每个segment
加锁,然后再次计算
try { for (;;) { if (retries++ == RETRIES_BEFORE_LOCK) { for (int j = 0; j < segments.length; ++j) ensureSegment(j).lock(); // force creation } sum = 0L; size = 0; overflow = false; for (int j = 0; j < segments.length; ++j) { Segment<K,V> seg = segmentAt(segments, j); if (seg != null) { sum += seg.modCount; int c = seg.count; if (c < 0 || (size += c) < 0) overflow = true; } } if (sum == last) break; last = sum; } } finally { if (retries > RETRIES_BEFORE_LOCK) { for (int j = 0; j < segments.length; ++j) segmentAt(segments, j).unlock(); } }
但是在1.8中使用一个volatile
类型的变量baseCount
记录元素的个数,当新增或者删除节点的时候会调用,addCount()
更新baseCount
private final void addCount(long x, int check) { CounterCell[] as; long b, s; if ((as = counterCells) != null ||
// 有多线程竞争 !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { CounterCell a; long v; int m; boolean uncontended = true;
//还没有初始化 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; 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(); } } }
初始化时counterCells
为空,在并发量很高时,如果存在两个线程同时执行CAS
修改baseCount
值,则失败的线程会继续执行方法体中的逻辑,使用CounterCell
记录元素个数的变化,如果CounterCell
数组counterCells
为空,调用fullAddCount()
方法进行初始化,并插入对应的记录数,通过CAS
设置cellsBusy字段,只有设置成功的线程才能初始化CounterCell
数组
else if (cellsBusy == 0 && counterCells == as && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { boolean init = false; try { // Initialize table if (counterCells == as) {
//初始化数组长度 CounterCell[] rs = new CounterCell[2];
//表示元素个数 rs[h & 1] = new CounterCell(x); counterCells = rs; init = true; } } finally { cellsBusy = 0; } if (init) break; }
因为初始长度就只有两个,如果线程竞争很激烈的话,长度不够就要扩容,下面就是扩容的代码
else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { try { if (counterCells == as) {// Expand table unless stale CounterCell[] rs = new CounterCell[n << 1]; for (int i = 0; i < n; ++i) rs[i] = as[i]; counterCells = rs; } } finally { cellsBusy = 0; } collide = false; continue; // Retry with expanded table }
如果通过CAS
设置cellsBusy字段失败的话,则继续尝试通过CAS
修改baseCount
字段,如果修改baseCount
字段成功的话,就退出循环
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x)) break;
所以在1.8中的size
实现比1.7简单多,因为元素个数保存baseCount
中,部分元素的变化个数保存在CounterCell
数组中,通过累加baseCount
和CounterCell
数组中的数量,即可得到元素的总个数。实现如下:
public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } 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; }
三.transfer扩容
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);
//rc值等于扩容阈值,第一次一定不小于 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(); } }
static final int resizeStamp(int n) { return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); }
Integer.numberOfLeadingZeros 这个方法是返回无符号整数 n 最高位非 0 位前面的 0 的个数;
比如 10 的二进制是 0000 0000 0000 0000 0000 0000 0000 1010
那么这个方法返回的值就是 28
transfer
上面的解析完后下面我们走进正真的扩容代码;扩容是 ConcurrentHashMap 的精华之一,扩容操作的核心在于数据的转移,在单线程环境下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。但是这在多线程环境下,
扩容过程图解
ConcurrentHashMap 支持并发扩容,实现方式是,把 Node 数组进行拆分,让每个线程处理自己的区域,假设 table 数组总长度是 64,默认情况下,那么每个线程可以分到 16 个 bucket。然后每个线程处理的范围,按照倒序来做迁移通过 for 自循环处理每个槽位中的链表元素,默认 advace 为真,通过 CAS 设置 transferIndex属性值,并初始化 i 和 bound 值,i 指当前处理的槽位序号,bound 指需要处理的槽位边界,先处理槽位 31 的节点; (bound,i) =(16,31) 从 31 的位置往前推动。
sizeCtl 扩容退出机制
在扩容操作 transfer 的第 2414 行,代码如下
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
synchronized (f) {//对数组该节点位置加锁,开始处理数组该位置的迁移工作 if (tabAt(tab, i) == f) {//再做一次校验 Node<K,V> ln, hn;//ln 表示低位, hn 表示高位;接下来这段代码的作用 是把链表拆分成两部分,0 在低位,1 在高位 if (fh >= 0) {//下面部分代码原理点击这里 int runBit = fh & n; Node<K,V> lastRun = f; //遍历当前 bucket 的链表,目的是尽量重用 Node 链表尾部的一部分 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) {如果最后更新的 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);//将低位的链表放在 i 位置也就是不动 setTabAt(nextTab, i + n, hn);//将高位链表放在 i+n 位置
高低位原理分析
ConcurrentHashMap 在做链表迁移时,会用高低位来实现,这里有两个问题要分析一下
第 14 个槽位插入新节点之后,链表元素个数已经达到了 8,且数组长度为 16,优先通过扩容来缓解链表过长的问题,扩容这块的图解稍后再分析,先分析高低位扩容的原理假如当前线程正在处理槽位为 14 的节点,它是一个链表结构,在代码中,首先定义两个变量节点 ln 和 hn,实际就是 lowNode 和 HighNode,分别保存 hash 值的第 x 位为 0 和不等于0 的节点通过 fn&n 可以把这个链表中的元素分为两类,A 类是 hash 值的第 X 位为 0,B 类是 hash 值的第 x 位为不等于 0(至于为什么要这么区分,稍后分析),并且通过 lastRun 记录最后要处理的节点。最终要达到的目的是,A 类的链表保持位置不动,B 类的链表为 14+16(扩容增加的长度)=30我们把 14 槽位的链表单独伶出来,我们用蓝色表示 fn&n=0 的节点,假如链表的分类是这样
for (Node<K,V> p = f.next; p != null; p = p.next) { int b = p.hash & n; if (b != runBit) { runBit = b; lastRun = p; } }
通过上面这段代码遍历,会记录 runBit 以及 lastRun,按照上面这个结构,那么 runBit 应该是蓝色节点,lastRun 应该是第 6 个节点;接着,再通过这段代码进行遍历,生成 ln 链以及 hn 链
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); }
接着,通过 CAS 操作,把 hn 链放在 i+n 也就是 14+16 的位置,ln 链保持原来的位置不动。并且设置当前节点为 fwd,表示已经被当前线程迁移完了
setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd);
在上面设计中分高低位的原因要从ConcurrentHashMap 的根据下标获取对象的算法来看,在 putVal 方法中 1018 行 ,通过(n-1) & hash 来获得在 table 中的数组下标来获取节点数据,【&运算是二进制运算符,1& 1=1,其他都为 0】
(f = tabAt(tab, i = (n - 1) & hash)) == null
假设我们的 table 长度是 16, 二进制是【0001 0000】,减一以后的二进制是 【0000 1111】假如某个 key 的 hash 值=9,对应的二进制是【0000 1001】,那么按照(n-1) & hash 的算法0000 1111 & 0000 1001 =0000 1001 , 运算结果是 9当我们扩容以后,16 变成了 32,那么(n-1)的二进制是 【0001 1111】仍然以 hash 值=9 的二进制计算为例0001 1111 & 0000 1001 =0000 1001 ,运算结果仍然是 9;我们换一个数字,假如某个 key 的 hash 值是 20,对应的二进制是【0001 0100】,仍然按照(n-1) & hash算法,分别在 16 为长度和 32 位长度下的计算结果