前言
HashMap一直是面试官特别喜欢问的。本文笔者也将重学一下HashMap并且源码的角度去分析1.7版本HashMap和1.8版本的HashMap
准备工作
先选择1.7版本的java,然后new一个HashMap,这里我使用IDEA查看的1.7版本的HashMap,不知道为什么Android Studio选择1.7版本依然显示的是1.8版本的HashMap,没有下载1.7版本的,笔者这里给出百度网盘的下载地址
链接: pan.baidu.com/s/1mxhqVClc… 密码: dwpt
前期知识铺垫
这里主要是对一些不熟悉数据结构的小伙伴做一个简单的介绍,如果对数据结构熟悉的小伙伴,可以直接跳过这一小节。
- 1.7版本的
HashMap用到的数据结构是数组+链表 - 1.8版本的
HashMap用到的数据结构是数组+链表+红黑树
对数据结构一点不懂的小伙伴可以去看一下 (小甲鱼)数据结构与算法补一下基础知识。
1.7版本HashMap原理简介
在看源码之前,还是先简要介绍一下1.7版本HashMap原理,这样后续看源码不至于一脸懵。在前期知识铺垫中介绍了 「1.7版本的HashMap用到的数据结构是数组+链表」
给定一个key,先经过Hash运算,判断其应该放在哪个数组。🌰 例如 map.put("name","江海洋"),经过Hash运算,得到name下标为0,那么将这个value:江海洋填入下边为0的数组
但是Hash运算出现相同的下标。🌰map.put("size","18cm"); 经过Hash运算发现size得到的下标也是0,那么就发生了所谓的Hash碰撞,这个时候。会将size,以链表的方式插入到name之前,形成一个链表。
但是put那么多key,下标总会有用完的时候。那么就需要扩容机制,主动增大数组的长度。
那么就有以下几个问题需要在源码中找到答案
- 数组长度应该多长?
- 什么时候扩容?
- 如何做到扩容?
- 如何通过key去查询到对应的值的?
带着这些问题。开始看源码吧
当我们new一个HashMap发生了什么
点击进入看一下其无参构造方法。可以看到无参的构造方法,调用了双参的构造方法。并且传入两个固定的值。这两个值很重要。
DEFAULT_INITIAL_CAPACITY数组的长度,默认给到16DEFAULT_LOAD_FACTOR负载系数,当数组占有率达到 16*0.75即12 则自动扩容
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 值为16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
//判断数组长度不能小于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//判断长度不能大于 1073741824
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//负载系数 不能小于0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//重新将 数组的长度 和 负载系数 赋值给变量
this.loadFactor = loadFactor;
threshold = initialCapacity;
//空方法。给子类(LinkedHashMap)使用
init();
}
可以看到。new了一个HashMap并没有创建数组,只是指定了数组的长度,和负载系数。
put发生了什么
继续看源码。看一下put发生了什么
public V put(K key, V value) {
//判断数组长度是否是一个空数组
if (table == EMPTY_TABLE) {
//创建一个数组 threshold 在上面创建的时候知道是16
//此方法详细信息查看 『如何创建数组』小节
inflateTable(threshold);
}
// 判断key 是否是null
if (key == null)
//详细可查看「如果key是null」小节
return putForNullKey(value);
//hash运算获取hash值
int hash = hash(key);
//通过hash值获取数组的下标
int i = indexFor(hash, table.length);
//查找下标为i的数组。获取他的链表结构
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//hash值相同 并且 key 相同 覆盖旧值,
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」小节
addEntry(hash, key, value, i);
return null;
}
如何创建数组
上面看到inflateTable(threshold)是创建一个数组。接下来我们分析是如何创建的
private void inflateTable(int toSize) {
// 将初始化得到的指定数组长度传入 即 16 返回一个capacity
// 这个capacity 一定是 16 的倍数 即 16 32 等
int capacity = roundUpToPowerOf2(toSize);
// 获取到阈值的大小。即 数组长度*负载系数 16*0.75 = 12
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 创建数组 即 长度为16的数组
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
可以看到,数组其实是在put元素的时候被创建出来的。
如果key是null
上诉发现如果key是null,会发生什么
private V putForNullKey(V value) {
//寻找下标为0的链表。按照链表的结构一个一个查找
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//如果找到了 key 是 null
if (e.key == null) {
//key == null 对应的value 赋值给局部变量oldValue
V oldValue = e.value;
// 将他的值重新赋值 设置成新设置进入的 value 覆盖原来的value
e.value = value;
//空方法 给子类(LinkedHashMap)使用
e.recordAccess(this);
//返回参数
return oldValue;
}
}
//修改的次数+1
modCount++;
//如果没找到就添加一个元素第一个数组
//详细信息查看 「addEntry」小节
addEntry(0, null, value, 0);
return null;
}
addEntry
在「如果key是null」和「put发生了什么」这两个小节中,都看到这addEntry的身影。
void addEntry(int hash, K key, V value, int bucketIndex) {
//数量超过了设置的阈值 即超过了 16*0.75 = 12
//并且数组中的也有值。不为null
// 达到了扩容的需求
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容 此方法详情可查看 「resize」小节
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
//通过hash 冲 0-15中选择一个数组下标
bucketIndex = indexFor(hash, table.length);
}
//如果没有达到扩容的要求,则创建新的Entry 此方法详细信息查看「createEntry」小节
createEntry(hash, key, value, bucketIndex);
}
resize
这个方法是实际扩容的代码。
void resize(int newCapacity) {
Entry[] oldTable = table;
//获取原来的数组长度 注意 此时第一次应该是16
int oldCapacity = oldTable.length;
//判断不能超过最大值
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建一个新的数组 长度为32了
Entry[] newTable = new Entry[newCapacity];
//将老数组中的数据迁移到新的数组中 详细信息查看 『transfer』小节
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//旧指针指向新数组
table = newTable;
//原来是12 现在 变成 32*0.75 = 24 新的阈值是24
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer
在resize小节中知道,这个方法是 将老数组中的数据迁移到新的数组中
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) {
//二次hash运算
e.hash = null == e.key ? 0 : hash(e.key);
}
//通过hash 冲 0-31中选择一个数组下标
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
createEntry
void createEntry(int hash, K key, V value, int bucketIndex) {
//找到原来的数据
Entry<K,V> e = table[bucketIndex];
//创建新的数据。
table[bucketIndex] = new Entry<>(hash, key, value, e);
//size+1 这里的size就是在「addEntry」小节中判断 是否超过阈值的size
size++;
}
size在createEntry的时候+1,在map.remove("key")的时候会-1 详细信息可以查看「removeEntryForKey」小节查看
🌰举例说明
先put("name","江海洋")然后在put("age","22")。假设其hash相同。并且都在第0位的数组中。那么现在他的数据结构就如下图所示一样。注意,这里使用的是头插法其next指向的是下一个元素的。使用头插法而不是用尾插发,原因是因为最新被插入的元素可能会先开发者去查找,使用头插法将最新的元素放在前面,那么如果找新的元素,查找就比较快,还记得在前期知识铺垫小节中说的,链表的查找时间复杂度是O(n).查找是比较慢的
removeEntryForKey
final Entry<K,V> removeEntryForKey(Object key) {
if (size == 0) {
return null;
}
//通过hash计算得到hash值
int hash = (key == null) ? 0 : hash(key);
//通过hash值得到数组下标
int i = indexFor(hash, table.length);
//找到对应数组拿到他链表
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
//遍历链表
while (e != null) {
//获取下一个数组的指针
Entry<K,V> next = e.next;
Object k;
//判断hash值 判断key 确保找到key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
//size - 1
size--;
//如果刚好链表中只有一个元素。next 就是null
if (prev == e)
//就是null
table[i] = next;
else
//将指针指向下一个元素
prev.next = next;
//空方法
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
🌰举例子
连续put name age cool,假设其hash运算得到结果相同。那么其链表如下所示。
map.put("name","江海洋");
map.put("age","22");
map.put("cool","很帅");
如果需要删除age,只需要将cool对应的next变成name即可,修改如下,就完成了remove的操作。
查询元素
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
//没有添加任何元素就直接返回 size 在 「createEntry」会自动+1
if (size == 0) {
return null;
}
//hash运算得到hash值
int hash = (key == null) ? 0 : hash(key);
//通过hash值得到数组下标。在遍历对应下标所在的链表
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
1.8版本HashMap
看一下1.8版本的HashMap
初始化
在1.7当中们分析发现其设置了两个很重要的参数
DEFAULT_INITIAL_CAPACITY数组的长度,默认给到16DEFAULT_LOAD_FACTOR负载系数,当数组占有率达到 16*0.75即12 则自动扩容
而在1.8中只设置了一个负载系数 默认依然是0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
put
1.8版的的put和1.7版本也不一样
public V put(K key, V value) {
//详细可查看「putVal」小节
return putVal(hash(key), key, value, false, true);
}
首先不同的是hash算法
putVal
1.8版本的put方法老长了。代码可读性真差!差评
第一次put元素的时候。默认的数组是null,阈值也是0.所以执行resize()方法,去创建一个长度为16的数组。并且将阈值等信息记录下来,得到数组以后,将key进行Hash运算得到hash值,在通过hash值得到处于数组哪一个下标中。即哪一个桶当中。确认了桶的位置。会创建一个新的Node对应的就是1.7代码中的Entry作为链表的第一个元素。
第二次put元素的时候,依然通过key得到桶的位置,然后对比桶对应的链表对一个元素的值,如果是相同的元素,就将值替换了,说明put的key是相同的,如果第一个元素不相同,则判断是否是树结构,如果不是树结构则遍历当前桶中的链表。匹配到了,说明put的key是相同的,将值替换成新值,如果都找不到,那么就说明是一个新的key,使用尾插法追加链表。
使用尾插法插入以后,判断链表的长度是否是超过7,如果大于7,则将链表转成树结构。当树的节点小于6,则树会退化成链表结构,当节点过多时,红黑树可以更高效的查找到节点。毕竟红黑树是一种二叉查找树
在put的最后,判断是否map数量是否超过阈值。超过则需要扩容
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//声明变量。源码中写在一行。这里为了好看 笔者分成3行
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//【第一次】先将table赋值给局部变量 判断其是否是null
//【第一次】再将tab 此时也就是table的长度复制给n 判断是否是0
if ((tab = table) == null || (n = tab.length) == 0){
//【第一次】执行resize() 将获得新的数组赋值给tab (resize 方法请查看 「resize」小节)
//【第一次】在将新获取的数据长度复制给n 第一次结果n = 16
n = (tab = resize()).length;
}
//【第一次】通过hash值获取到数组的下标。将下标复制给i
//【第一次】再将数组下标为i的数据赋值给p
//【第一次】在判断p 是否为null
//【第一次】😤这代码可读性真差!!!!!
if ((p = tab[i = (n - 1) & hash]) == null)
//【第一次】是null的话创建一个新的对象
tab[i] = newNode(hash, key, value, null);
else {
//【第二次进入】数组中的链表元素不是null
Node<K,V> e;
K k;
//【第二次进入】判断hash值和key是否相同 和1.7一样
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))){
//【第二次进入】复制给局部变量e上
e = p;
//是否满足树节点的属性。1.8数据结构中有红黑树
}else if (p instanceof TreeNode){
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
}else {
//启动循环,不是之前的key,需要在链表中加入新的元素
for (int binCount = 0; ; ++binCount) {
//当最后指向null 表示链表结束了 结束了还没找到就创建一个新的Node
if ((e = p.next) == null) {
//创建一个新的Node 使用的是尾插发。插入到最后
p.next = newNode(hash, key, value, null);
//判断是达到了转换成树的阈值 (8 - 1 ) = 7
if (binCount >= TREEIFY_THRESHOLD - 1){
//链表转成树
treeifyBin(tab, hash);
}
break;
}
//如果找到了就break 然后下面(e != null)的方法进行覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))){
break;
}
p = e;
}
}
//【第二次进入】如果刚好是在同一个数组中那么e不为null,如果不在同一个数组,则为null
// 即表示 put了之前有的key,执行覆盖操作
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null){
//将新的value设置到value字段上
e.value = value;
}
//空方法
afterNodeAccess(e);
//返回旧值
return oldValue;
}
}
++modCount;
//判断是否需要扩容代码 第一次 判断是否大于 12 (数组长度*负载系数)
if (++size > threshold){
//满足扩容条件 扩容
resize();
}
afterNodeInsertion(evict);
return null;
}
resize
此方法主要是为了扩容。还是比较长的。阅读此代码,先看一下【第一次】进入.在看一下【满足扩容条件】,然后在看一下如何【迁移数据】
在【第一次】的时候会先生成一个长度16的数组,之后如果【满足扩容条件】,即阈值大于 数组长度*负载系数则将旧数组中的数据迁移中新数组。这里相比1.7少了一个二次hash的过程。
final Node<K,V>[] resize() {
//【第一次】oldTab 指向 table 然而table 是null 所以 oldTab也是null
Node<K,V>[] oldTab = table;
//【第一次】put的时候table是null;oldTab也是null 那么oldCap = 0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//【第一次】put 阈值也是0
int oldThr = threshold;
int newCap, newThr = 0;
//【满足扩容条件】当之后在进入 已经不是0了 值为16
if (oldCap > 0) {
//【满足扩容条件】限定最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
//【满足扩容条件】新的数组长度 newCap = 老数组长度 oldCap * 2
}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY){
//【满足扩容条件】新的阈值变成老阈值的2倍
//【满足扩容条件】第一次的时候是16 第二次扩容则是32
newThr = oldThr << 1;
}
}else if (oldThr > 0){
newCap = oldThr;
}else {
//【第一次】进入,在这里进行复制 数组长度16 阈值 16*0.75 = 12
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赋值成第一次计算得到的 12
threshold = newThr;
//【第一次】创建一个长度为16的数组
//【满足扩容条件】会创建一个老数组长度*2的新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//【第一次】将数组指针给table
table = newTab;
//【第一次】上面知道第一次oldTab肯定是null 所以不会走到下面的代码
if (oldTab != null) {
//【迁移数据】遍历老的数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//【迁移数据】获取第j个数组 并且将数组赋值给e,在判断e是否为null
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//【迁移数据】如果next是null表示此数组只有一个元素
if (e.next == null){
//【迁移数据】创建一个新数组,将新数组直接指向e,表示直接把e的链表给到了新数组
newTab[e.hash & (newCap - 1)] = e;
} else if (e instanceof TreeNode){
//【迁移数据】如果是树结构。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
} else {
//【迁移数据】如果是链表,并且链表中又不止一个数据
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;
}
快速失败(fail—fast)
HashMap遍历使用的是一种快速失败机制,它是Java非安全集合中的一种普遍机制,这种机制可以让集合在遍历时,如果有线程对集合进行了修改、删除、增加操作,会触发并发修改异常。
它的实现机制是在遍历前保存一份modCount,在每次获取下一个要遍历的元素时会对比当前的 modCount 和保存的 modCount是否相等。
快速失败也可以看作是一种安全机制,这样在多线程操作不安全的集合时,由于快速失败的机制,会抛出异常ConcurrentModificationException
为什么说1.8版本效率高于1.7版本
从两个地方分析
- hash算法:在JDK1.8的实现中。优化了高位运算的算法
- 引入红黑树:在桶中有大量数据的时候,红黑树属于二叉查找树,其效率优于链表
为什么说HashMap是线程不安全的
- 在多线程中,有可能会形成
环形链表导致Infinite Loop.具体可以参考Java 8系列之重新认识HashMap中的示例 - 删除操作、修改操作,会有覆盖问题,导致数据不准确。
线程安全的HashMap
-
ConcurrentHashMap使用了分段锁的技术(segment + Lock)比较优化的策略. 详细可参考Map 综述(三):彻头彻尾理解 ConcurrentHashMap -
Hashtable通过加锁实现线程安全。Java 1.1提供的旧有类,从性能上和使用上都不如其他的替代类,因此已经不推荐使用 -
SynchronizeMap
他是Collections.synchronizeMap()方法返回的对象,他实现线程安全的方法和Hashtable一致,直接加锁
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
什么是hash表
散列表也叫做hash表,根据键(Key)而直接访问在内存储存位置的数据结构,上面看到的HashMap通过key得到hash值,在通过hash值得到数组的下标,这个数组就是一个hash表,而得到hash值的方法,也被称为散列函数
为啥负载系数默认是0.75
如果是1,只有等到全部填充完了才能触发扩容,这就带来了很大的问题,因为Hash冲突时避免不了的。当负载因子是1.0的时候,意味着会出现大量的Hash的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率
负载系数是0.5的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。但是空间利用率就会大大的降低,原本存储1M的数据,现在就意味着需要2M的空间。0.75 是时间和空间的权衡
为什么数组大小16的整数倍
先看一下我们如何获取通过key得到下标的,将key经过hash运算,然后hash值 & 数组长度-1
🌰 下面的hash值 & 15 看一下结果,其实看到最后4位即可,因为后面4位全是1,
0000 0000 1010 1001
0000 0000 0000 1111
>结果
0000 0000 0000 1001
其无论与何种hash值进行&计算,取值一定都是后面的4位,也就是 [0,15]区间
同理看一下32-1的2进制
0000 0000 0001 1111
在看一下 64-1的2进制
0000 0000 0011 1111
当这些值与数组长度-1进行&运算时候,都是在[0,数组的长度-1]这个区间,实现了均匀分布
为什么将1.7的头插法改成了1.8的尾插法
JDK1.7中扩容时,每个元素的rehash之后,都会插入到新数组对应索引的链表头,所以这就导致原链表顺序为A->B->C,扩容之后,rehash之后的链表可能为C->B->A,元素的顺序发生了变化。在并发场景下,扩容时可能会出现循环链表的情况。而JDK1.8从头插入改成尾插入元素的顺序不变,避免出现循环链表的情况。