HashMap是由数组+链表组成的
JDK1.8之后是数组+链表+红黑树
HashMap的实现原理
HashMap是一个用于存储Key-Value键值对的集合, 每一个键值对也叫做Entry. 这些个键值对(Entry)分散存储在一个数组当中, 这个数组就是HashMap的主干.
HashMap数组每一个元素的初始值都是Null.
对于HashMap, 我们最常使用的是两个方法:Get和Put.
Put方法的原理
调用Put方法的时候发生了什么?
比如调用hashMap.put(“apple”,0), 插入一个Key为"apple"的元素. 这时我们需要利用一个哈希函数来确定Entry的插入位置 index=Hash(“apple”)
最后计算出的index是2, 那么将这个键值对插入到下标为2的数组当中.
但是, 因为HashMap的长度是有限的, 当插入的Entry越来越多时, 再完美的Hash函数也会产生index冲突(哈希冲突).
这时候就利用链表来解决.
HashMap数组的每一个元素不止是一个Entry对象, 也是一个链表的头结点.
每一个Entry对象通过Next指针指向它的下一个Entry节点. 当新的Entry映射冲突的数组位置时, 只需要插入对应的链表即可.
需要注意的是, 新来的Entry节点插入链表时, 使用的是"头插法".
Get方法的原理
使用Get方法根据Key来查找Value的时候, 发生了什么呢?
首先会把输入Key做一次Hash映射, 得到对应的 index=Hash(“apple”)
由于可能会存在哈希冲突, 同一个位置有可能匹配到多个Entry, 这时候就需要顺着对应链表的头结点, 一个一个向下来查找. 假设我们要查找的Key是"apple":
第一步, 查看头结点Entry3, Entry3的Key是banana, 显然不是要找的结果.
第二步, 查看的Next节点Entry1, Entry1的Key是apple, 完成.
之所以使用"头插法", 是因为HashMap的发明者认为, 后插入的Entry被查找的可能性更大.
HashMap默认初始长度是16, 并且每次启动扩展或是手动初始化时,长度必须是2的幂.
初始长度16是为了服务于Key映射到index的Hash算法.
之前说过, 从Key映射到HashMap数组的对应位置, 会用到一个Hash函数: index=Hash(“Key”)
如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算.
为了实现高效的Hash算法, HashMap的发明者采用了位运算的方式
(Length是HashMap的长度)
index=HashCode(Key)&(Length-1)
下面我们以值"book"的Key来演示整个过程:
- 计算book的hashcode, 结果为十进制的3029737,二进制的10 1110 0011 1010 1110 1001
- 假定HashMap长度是默认的16, 计算Length-1的结果为十进制的15, 二进制的1111.
- 把以上两个结果做与运算,10 1110 0011 1010 1110 1001 & 1111=1001,十进制是9, 所以index=9.
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位.
这样做的在效果上不仅等同于取模, 而且还大大提高了性能. 长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值. 只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的.
高并发下的HashMap
Rehas是HashMap在扩容时候的一个步骤.
HashMap的容量是有限的, 当经过多次元素插入, 使得HashMap达到一定饱和度时, Key映射位置发生冲突的几率会逐渐提高.
这时候,HashMap需要扩展它的长度, 也就是Resize
影响Resize的因素有两个:
Capacity: HashMap的当前长度(2的幂)
LoadFactor: HashMap扩容因子, 默认值为0.75f
衡量HashMap是否进行Resize的条件如下: HashMap.Size>=Capacity * LoadFactor
Resize需要经过两个步骤:
-
扩容
创建一个新的Entry空数组, 长度是原数组的两倍. -
ReHash
遍历原Entry数组, 把所有的Entry重新Hash到新数组. 重新Hash的原因是因为长度扩大后, 哈希的规则也随之改变了.
Hash公式:index=HashCode(Key)&(Length-1)
当原数组长度为8时, Hash运算和111B做与运算; 新数组长度为16, Hash运算是和1111B做与运算. Hash结果显然不同.
Rehash的java源码:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
但是, HashMap并非线程安全的
ReHash在并发的情况下可能会形成链表环!
线程安全的Hash容器
线程安全的集合类: Vocter、HashTable、Collections.synchronizedMap.
HashTable、Collections.synchronizedMap,但是这两者有共同的性能: 性能.
无论读操作还是写操作, 它们都会给整个集合加锁, 导致同一时间的其他操作为之阻塞.
在并发环境下, 如何能够兼顾线程安全和运行效率呢?这时候ConcurrentHashMap就诞生了.
相比较HashMap,ConcurrentHashMap多了一个Segment.
Segment本身就相当于一个HashMap对象.
同HashMap一样, Segment包含一个HashEntry数组, 数组中的每一个HashEntry既是一个键值对, 也是一个链表的头节点.
单一Segment结构:
像这样的Segment对象, 在ConcurrentHashMap集合中有2的N存放个, 共同保存一个名为segments的数组当中.
ConcurrentHashMap结构:
可以说,ConcurrentHashMap是一个二级哈希表. 在一个总的哈希表下面,有若干个子哈希表.
这样的二级结构,和数据库的水平拆分有些相似.
ConcurrentHashMap优势就是采用了[锁分段技术], 每一个Segment就好比一个自治区,读写操作高度自治,Segment之间互不影响.
ConcurrentHashMap并发
情况1: 不同Segment的并发写入
不同Segment的写入是可以并发执行的.
情况2: 同一Segment的一写一读:
同一Segment的写和读是可以并发执行的.
情况3: 同一Segment的并发写入
Segment的写入是上锁的, 因此对同一Segment的并发写入会被阻塞.
由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁. 在保证线程安全的同时降低了锁的粒度,让并发操作效率更高.
ConcurrentHashMap读写过程
- Get方法
- 为输入的Key做Hash运算,得到hash值.
- 通过hash值,定位到对应的Segment对象
- 再次通过hash值,定位到Segment当中数组的具体位置.
- put方法
- 为输入的Key做Hash运算,得到hash值.
- 通过hash值,定位到对应的Segment对象.
- 获取可重入锁
- 再次通过hash值,定位到Segment当中数组的具体位置.
- 插入或覆盖HashEntry对象.
- 释放锁.
ConcurrentHashMap在读写时都需要二次定位. 首先定位到Segment, 之后定位到Segment内的具体数组下标.
既然每一个Segment都各自加锁, 那么在调用Size方法的时候, 怎么解决一致性问题.
Size方法的目的是统计ConcurrentHashMap的总元素数量, 自然需要把各个Segment内部的元素数量汇总起来.
但是,如果在统计Segment元素数量的过程中,已统计过的Segment瞬间插入新的元素,这时候该怎么办呢?
ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:
- 遍历所有的Segment.
- 把Segment的元素数量累加起来.
- 把Segment的修改次数累加起来.
- 判断所有Segment的总修改次数是否大于上一次的总修改次数. 如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是. 说明没有修改,统计结束.
- 如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计.
- 再次判断所有Segment的总修改次数是否大于上一次的总修改次数. 由于已经加锁,次数一定和上次相等.
- 释放锁,统计结束.
官方源代码如下:
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保证强一致性.
本篇ConcurrentHashMap原理和代码,都是基于Java1.7的。在Java8中会有些许差别.
ConcurrentHashMap在对Key求Hash值的时候,为了实现Segment均匀分布,进行了两次Hash.
关于容量的一些问题
为什么hashmap只支持可扩容增, 而不能动态减容积
hashmap为什么要缩容:1.程序员需要主动缩容比如程序初始化的时候刷新了所有用户的缓存,然后这个map就必须缩小到一个非常小的容量,用来做缓存。这种情况下,还是new一个HashMap,然后foreach添加到新的hashmap中比较好。反正也是O(n)的复杂度。2.系统内存紧张,需要释放内存很遗憾,JVM中目前没有让用户代码感知到内存紧张并主动释放内存的机制。当然软引用应该也算一个,但是如果用软引用来实现动态缩容的话,效果就是:我FullGC结束了,你才执行释放内存的操作。第一太晚了,第二耗时也比较严重。3.hashmap中的数据量少的时候,自动缩容这个有两个实现方式。第一,在remove操作中做缩容操作。那就非常怪异了,remove操作居然有可能是O(n)的复杂度,很多时候这个是不能接受的。。第二,合适的时候通知单独的线程来做缩容操作。众所周知,hashmap不是线程安全的。。。所以你看,不管怎么样,这个缩容都是鸡肋的操作,所以不如没有它。当然,再拔高一点:Java在大部分情况下都是用空间换时间的,缩容这种不符合Java的哲学。(来自知乎的回答)
redis有自动缩容的特性