HashMap:
数组+链表结构。 HashMap是一个用于存储Key-Value键值对的集合,初始化长度16 每次拓展长度必须是2的幂 (为了服务于key映射到index的Hash算法index = HashCode(Key) & (Length - 1))。每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干,每个初始值都是null。当进行put操作时,为了使得数据均匀分布会对key进行hash操作再和 HashMap长度进行与运算(Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。)进行与运算来确定Entry的插入位置(index)。对于key进行hash运算得到相同的index时则通过链表来解决 HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。
使用Get方法根据Key来查找Value的时候,首先会把输入的Key做一次Hash映射,得到对应的index:index = Hash(“apple”)由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。
高并发下的hashMap再进行扩容时可能会形成环形链表。解析
影响发生Resize的因素有两个:
1.Capacity HashMap的当前长度。上一期曾经说过,HashMap的长度是2的幂。2.LoadFactor HashMap负载因子,默认值为0.75f 衡量HashMap是否进行Resize的条件如下:HashMap.Size >= Capacity * LoadFactor
步骤:
1.扩容
创建一个新的Entry空数组,长度是原数组的2倍。
2.ReHash
遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。
让我们回顾一下Hash公式:
index = HashCode(Key) & (Length - 1)
当原数组长度为8时,Hash运算是和111B做与运算;新数组长度为16,Hash运算是和1111B做与运算。Hash结果显然不同。
resize方法:
void resize(intnewCapacity)
{
Entry[] oldTable = table;
intoldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable =new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
transfer方法:
void transfer(Entry[] newTable)
{
Entry[] src = table;
intnewCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for(intj = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if(e != null) {
src[j] =null;
do{//链表数据循环
Entry<K,V> next = e.next;
inti = indexFor(e.hash, newCapacity);
e.next = newTable[i];//把entity e 采用头插法放入新HashTable,并且把e.next指向新的table。紧接着把这个节点往下移一个(整个链表关系不变)。则当前节点就在数组上
newTable[i] = e;//往下移一个
e = next;//链表下一个数据
}while (e != null);
}
}
}
单线程下:
并发下的Rehash:
假设我们有两个线程。我用红色和浅蓝色标注了一下。
do{
Entry<K,V> next = e.next;// <--假设线程一执行到这里就被调度挂起了
inti = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}while (e != null);
而我们的线程二执行完成了。于是我们有下面的这个样子。
线程一被调度回来执行。先是执行 newTalbe[i] = e;然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)
环形链接出现 e.next = newTable[i] 导致 key(3).next 指向了 key(7) 注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
ConcurrentHashMap:
了解之前先了解Segment:Segment本身就相当于一个HashMap对象。同HashMap一样,Segment包含一个HashEntry数组,数组中的每一个HashEntry既是一个键值对,也是一个链表的头节点。
单一的Segment结构如下:
像这样的Segment对象,在ConcurrentHashMap集合中有多少个呢?有2的N次方个,共同保存在一个名为segments的数组当中。因此整个ConcurrentHashMap的结构如下:
可以说,ConcurrentHashMap是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。这样的二级结构,和数据库的水平拆分有些相似
采用了锁分段技术每一个Segment就好比一个自治区读写操作高度自治,相互之间互不影响
Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞。由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
Get方法:
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.再次通过hash值,定位到Segment当中数组的具体位置。
Put方法:
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值,定位到对应的Segment对象
3.获取可重入锁
4.再次通过hash值,定位到Segment当中数组的具体位置。
5.插入或覆盖HashEntry对象。
6.释放锁。
Size方法的目的是统计ConcurrentHashMap的总元素数量, 自然需要把各个Segment内部的元素数量汇总起来。但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?
ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:
1.遍历所有的Segment。
2.把Segment的元素数量累加起来。
3.把Segment的修改次数累加起来。
4.判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。
5.如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。
6.再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
7.释放锁,统计结束。
官方源代码如下:
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
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();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
为什么这样设计呢?这种思想和乐观锁悲观锁的思想如出一辙。
为了尽量不锁住所有Segment,首先乐观地假设Size过程中不会有修改。当尝试一定次数,才无奈转为悲观锁,锁住所有Segment保证强一致性。
几点说明:
1. 这里介绍的ConcurrentHashMap原理和代码,都是基于Java1.7的。在Java8中会有些许差别。
2.ConcurrentHashMap在对Key求Hash值的时候,为了实现Segment均匀分布,进行了两次Hash。有兴趣的朋友可以研究一下源代码。
参考链接:https://www.jianshu.com/p/13c650a25ed3 参考公众号:程序员小灰