HashMap是Map家族中使用频度最高的一个,下文主要结合源码来讲解HashMap的工作原理。

1. 数据结构

HashMap的数据结构主要由数组+链表+红黑树(JDK1.8后新增)组成,如下图所示:

左侧数组是哈希表,数组的每个元素都是一个单链表的头节点,当不同的key映射到数组的同一位置,就将其放入单链表中来解决key的hash值的冲突。

当链表的长度>8时,JDK1.8做了数据结构的优化,会将链表转化为红黑树,利用红黑树快速增删改查的特点提升HashMap的性能,查询效率链表O(N),红黑树是O(lgN)。

哈希表中当key的哈希值冲突时,可采用 开放地址法 和 链地址法 来解决。Java中的HashMap使用了链地址法:在每个数组元素后都有一个链表,对key通过Hash算法定位到数组下标,将键值对数据放在对应下标元素的链表上。

先了解下HaspMap的几个字段:

/* ---------------- Fields -------------- */

/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table; /**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet; /**
* The number of key-value mappings contained in this map.
*/
transient int size; /**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient int modCount; /**
* The next size value at which to resize (capacity * load factor).
*/
int threshold; /**
* The load factor for the hash table.
*/
final float loadFactor;
  • size:HashMap中实际存在的 Node(key-value对)数量。
  • modCount:记录HashMap内部结构发生变化的次数,主要用于迭代器的Fail-Fast(迭代快速失败)。当 put 新的 key-value 键值对时,如果新增了Node节点,属于结构变化,而某个key对应的value被覆盖则不属于结构变化。
  • threshold:threshold = capacity * loadFactor,允许数组容纳的最多元素数量,如果超过这个数目就重新resize(扩容),扩容后HashMap的容量是之前的两倍。负载因子越大,所能容纳的键值对个数越多。
  • loadFactor:负载因子,默认是0.75。是对空间和时间效率的一个平衡选择,建议不要修改。
  • Node[] table:是 HashMap 的哈希桶数组,是一个 HashMap 类中的非常重要的字段。

HashMap默认的初始容量是 16,负载因子是 loadFactor=0.75,也就是说:使用HashMap默认构造函数新建了一个HashMap对象,数组最多容纳元素个数 threshold = 16 * 0.75 = 12。当增加数据时,size 和 modCount 会随着增加,数据实际容量超过12时,HashMap就会进行扩容。

Node的源码如下:

static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 用来定位数组索引位置
final K key;
V value;
Node<K,V> next; // 链表的下一个node Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
} public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; } public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
} public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
} public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}

Node 是 HashMap 的一个内部类,实现了 Map.Entry 接口,存储着键值对。上图中的每一个黑色节点就是一个 Node 对象。

2. Hash算法

在查找、增加、删除 key-value 键值对时,都需要先在HashMap中定位哈希桶数组的索引位置。有时两个key的下标会一样,此时就发生了Hash碰撞,当Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就越高。

定位数组索引位置的源码实现如下:

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
} // jdk1.7的源码
static int indexFor(int h, int length) {
return h & (length-1);
} //jdk1.8没有 indexFor() 方法,但实现原理一样的,定位数组索引下标一般按如下方式:tab[(n - 1) & hash]
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
...
}
}

Hash算法本质上分三步:

  • 取key的hashCode值:h = key.hashCode()
  • 高位运算:h ^ (h >>> 16)
  • 取模运算:table[(table.length - 1) & hash]

hash值通过hashCode()的高16位异或低16位来计算,可以在tabl.length比较小时,能将高低bit都参与到Hash计算中。

在HashMap中,哈希桶数组table的长度length大小必须为2的n次方,这样设计,主要是为了在取模和扩容时做优化。如果将hash值直接对数组长度进行取模运算,这样元素分布也比较均匀,但是模运算的消耗是比较大的。当length总是2的n次方时,(table.length - 1) & hash = hash % length,如此来计算元素在table数组的索引处,& 比 % 具有更好的效率。

举例如下:

3. put方法

HashMap的put方法源码如下:

public V put(K key, V value) {
// 对key求hash值
return putVal(hash(key), key, value, false, true);
} /**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table为空,则resize()进行扩容新建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算key在table中的index索引下标,如果Node为null,则table[index]中新建Node节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// table[index]的首个节点key存在,则覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断table[index]是否为红黑树,如果是,则直接在树中插入key-value
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// table[index]为链表,遍历链表。
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 若链表长度 > 8,则将链表转化为红黑树,在红黑树中进行插入
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已经存在,则直接覆盖value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 插入Node成功后,判断实际存在的key-value对是否大于最大容量threshold,如果超过,则进行扩容resize()
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

4. 扩容

扩容(resize)就是重新计算容量。向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,就需要扩大数组的长度,以便能装入更多的元素。方法是使用一个新的数组代替已有的容量小的数组。

resize() 扩容时,会新建一个更大的Entry数组,将原来Entry数组中的元素通过transfer()方法转移到新数组上。通过遍历数组+链表的方式来遍历旧Entry数组中的每个元素,通过上文提到的 indexFor()方法确定在新Entry数组中的下标位置,然后使用链表头插法插入到新Entry数组中。扩容会带来一系列的运算,新建数组,对原有元素重新hash,这是很耗费资源的。

JDK1.7 resize的源码如下:

void resize(int newCapacity) {   // newCapacity为新的数组长度
// 获取扩容前旧的Entry数组和数组长度
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 扩容前的数组长度已经达到最大值了(2^30)
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE; // 修改最大容量阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
} Entry[] newTable = new Entry[newCapacity]; // 初始化一个新的Entry数组
transfer(newTable); // 将数据转移到新的Entry数组里
table = newTable; // HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor); // 修改阈值
} void transfer(Entry[] newTable) {
Entry[] src = table; // src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
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]; // 使用单链表的头插方式,将旧Entry数组中元素添加到新Entry数组中
newTable[i] = e;
e = next; // 访问下一个Entry链上的元素
} while (e != null);
}
}
}

JDK1.8 resize的源码如下:

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;
}
// 容量没有超过最大值,就扩充为原来的2倍
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);
}
// 计算新的resize容量上限
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) {
// 把每个bucket都移动到新的bucket中
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 { // 链表优化重hash
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;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

HashMap长度扩展为原来的2倍,这样使得元素的位置要不在原位置,要不在移动2次幂的位置。

旧table数组的长度为n,元素原来的位置为(n - 1) & hash,扩容后数组长度为原来的2倍,则元素的新位置为 (n * 2 - 1) & hash。举个例子,原来table数组长度 n=16,图a 表示key1和key2确定索引的位置,图 b表示扩容后 key1和key2确定索引的位置,hash1和hash2分别为key1和key2通过Hash算法求得的hash值。如下图所示:

key1的原位置为00101=5,扩容后的位置仍为00101=5;而key2原位置为00101=5,扩容后的位置为10101=5+16(原位置+oldCap)

这样设计的好处在于:既省去了重新计算hash值的时间;同时,新增1bit是0或1是随机的,因此resize扩容的过程,将之前冲突的同一链表上的节点均匀的分散到新的bucket上

5. 线程安全问题

HashMap是非线程安全的,在多线程场景下,应该避免使用,而是使用线程安全的ConcurrentHashMap。在多线程场景中使用HashMap可能出现死循环,从而导致CPU负载过高达到100%,最终程序宕掉。

当put新元素到HashMap中时,如果总元素个数超过 threshold ,HashMap则会resize扩容,从而hash表中的所有元素会rehash,重新分配到新的hash表中。如果多个线程并发进行 rehash的话,可能会导致环形链表的出现,当另一线程调用HashMap.get(),访问到了环形链表时,就出现了死循环,最终导致程序不可用。如何产生环形链表的细节,这篇文章写的很简介明了:https://coolshell.cn/articles/9606.html

6. 参考

https://tech.meituan.com/java-hashmap.html

https://coolshell.cn/articles/9606.html

HashMap源码阅读的更多相关文章

  1. HashMap源码阅读笔记

    HashMap源码阅读笔记 本文在此博客的内容上进行了部分修改,旨在加深笔者对HashMap的理解,暂不讨论红黑树相关逻辑 概述   HashMap作为经常使用到的类,大多时候都是只知道大概原理,比如 ...

  2. HashMap源码阅读与解析

    目录结构 导入语 HashMap构造方法 put()方法解析 addEntry()方法解析 get()方法解析 remove()解析 HashMap如何进行遍历 一.导入语 HashMap是我们最常见 ...

  3. 【JAVA】HashMap源码阅读

    目录 1.关键的几个static参数 2.内部类定义Node节点 3.成员变量 4.静态方法 5.HashMap的四个构造方法 6.put方法 7.扩容resize方法 8.get方法 9.remov ...

  4. JAVA8 HashMap 源码阅读

    序 阅读java源码可能是每一个java程序员的必修课,只有知其所以然,才能更好的使用java,写出更优美的程序,阅读java源码也为我们后面阅读java框架的源码打下了基础.阅读源代码其实就像再看一 ...

  5. HashMap源码阅读笔记(基于jdk1.8)

    1.HashMap概述: HashMap是基于Map接口的一个非同步实现,此实现提供key-value形式的数据映射,支持null值. HashMap的常量和重要变量如下: DEFAULT_INITI ...

  6. HashMap 源码阅读

    前言 之前读过一些类的源码,近来发现都忘了,再读一遍整理记录一下.这次读的是 JDK 11 的代码,贴上来的源码会去掉大部分的注释, 也会加上一些自己的理解. Map 接口 这里提一下 Map 接口与 ...

  7. HashSet HashMap 源码阅读笔记

    hashcode() 与 equals() 应一起重写,在HashMap 会先调用hash(key.hashcode()) 找到对应的entry数组位置 (一般初始是16,2^x,rehash后会翻倍 ...

  8. HashMap源码阅读(小白的java进阶)

    OverView 构造方法 //构造方法 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < ...

  9. 【JDK1.8】JDK1.8集合源码阅读——HashMap

    一.前言 笔者之前看过一篇关于jdk1.8的HashMap源码分析,作者对里面的解读很到位,将代码里关键的地方都说了一遍,值得推荐.笔者也会顺着他的顺序来阅读一遍,除了基础的方法外,添加了其他补充内容 ...

随机推荐

  1. 黑马day16 jquery&amp;内容过滤选择器&amp;可见度选择器

    内容过滤选择器的过滤规则主要体如今它所包括的子元素和文本内容上 .:contains(text) 使用方法: $("div:contains('John')")    返回值  集 ...

  2. java文本编辑器v2.0 图形用户界面

    package 文本编辑器; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Dimension; impor ...

  3. 深入理解JSP

    JSP(Java server page)是Java EE规范最基本成员,他是Java Web开发的重点知识,尽管我们一直在用.但其原理知之甚少.今天重点研究一些JSP核心内容以及其工作原理. JSP ...

  4. node.js之路由,中间件,ge请求和post请求的参数

    一.路由 1.什么是路由 服务器需要根据不同的URL或请求来执行不一样的操作,我们可以通过路由来实现这个步骤 2.实现路由的方法 2.1.get请求访问网址时,做什么事 1 app.get(" ...

  5. 理解Vue中的Render渲染函数

    理解Vue中的Render渲染函数 VUE一般使用template来创建HTML,然后在有的时候,我们需要使用javascript来创建html,这时候我们需要使用render函数.比如如下我想要实现 ...

  6. 苹果审核返回崩溃日志 iOS .crash文件处理 symbolicatecrash.  困扰我多年的牛皮癣根治了 看到这篇文章拿过来用下

    AppStore审核被拒,返回crashLog.txt文件,可是打开后都是十六进制的地址,我们可以使用Xcode自带的 symbolicatecrash 解析得到我们需要的详细崩溃信息crashLog ...

  7. SSH框架完全整合

    大三学期渐末,事情也挺多的,上周就开始着手整合SSH框架,到现在才真正的完成,过程中碰到了许多小问题(小问题大折腾,哭脸.jpg).本着善始善终的原则,最终把它给完成了. 本篇文章就在: win7 6 ...

  8. Akka Cluster简介与基本环境搭建

      akka集群是高容错.去中心化.不存在单点故障以及不存在单点瓶颈的集群.它使用gossip协议通信以及具备故障自动检测功能. Gossip收敛   集群中每一个节点被其他节点监督(默认的最大数量为 ...

  9. Xamarin截取/删除emoji表情bug解决方案

    大家都知道,一个英文=1字节,一个汉字2字节,而一个emoji表情=4个字节,在有这三种混用的时候,比如app聊天界面,那么删除和截取便成了很头痛的事情. 问题描述 截取导致乱码,如下图: 解决方案 ...

  10. SpringMVC 中配置 Swagger 插件.

    一.简介 Swagger的目标是为REST API定义一个与语言无关的标准接口,允许用户发现和理解计算机服务的功能,而无需访问源代码.当通过Swagger正确定义时,用户可以用最少量的实现逻辑理解远程 ...