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. Gson转Map

    使用google的Gson包.把json字符串转成Map<String,Object>以及List<Object>对象,记得下载Gson包, 我使用的是gson-2.1.jar ...

  2. Android自己定义组件之日历控件-精美日历实现(内容、样式可扩展)

    需求 我们知道.Android系统本身有自带的日历控件,网络上也有非常多开源的日历控件资源.可是这些日历控件往往样式较单一.API较多.不易于在实际项目中扩展并实现出符合详细样式风格的,内容可定制的效 ...

  3. MPSOC之6——开发流程linux编译

    0.顶层Makefile增加交叉编译器 顶层makefile: ARCH ?= $(SUBARCH) CROSS_COMPILE ?= $(CONFIG_CROSS_COMPILE:"%&q ...

  4. Android(Java) 字符串的常用操作,获取指定字符出现的次数,根据指定字符截取字符串

    /*这是第100000份数据,要截取出100000*/ String s="这是第100000份数据"; String s1 = s.substring(s.indexOf(&qu ...

  5. jmeter+ant+jenkins的自动化接口测试

    一.Jenkins安装配置 1.安装配置JDK1.7+环境变量: 2.下载jenkins.war,放入D:\jenkins目录下,目录位置随意: Jenkins启动方法: cmd进入Jenkins目录 ...

  6. [置顶] xamarin android自定义标题栏(自定义属性、回调事件)

    自定义控件的基本要求 这篇文章就当是自定义控件入门,看了几篇android关于自定义控件的文章,了解了一下,android自定义控件主要有3种方式: 自绘控件:继承View类,所展示的内容在OnDra ...

  7. Mysql5.7.20 On Windows安装指导

    安装环境 Windows版本:Windows10 64bit MySQL版本: MySQL5.7.20 配置过程 1.下载MySQL Community Server (下载链接) 根据自己操作系统需 ...

  8. Hello TensorFlow 三 (Golang)

    在一台ubuntu 16.04.2虚拟机上为golang安装TensorFlow. 官方参考:https://www.tensorflow.org/install/install_go 首先安装go ...

  9. unity -- Time类(持续更新中)

    2018年了,新年总是会制定很多具体目标和计划,不管能否坚持去完成,初衷和决心总是要有的.本年第一篇博客终于开始下笔了,先立一些今年和公司业务无关的的flag: 1.希望每月或两月能看一套蛮牛游戏上的 ...

  10. CSS3背景渐变。。。

    CSS3 Gradient 分为 linear-gradient(线性渐变)和 radial-gradient(径向渐变).而我们今天主要是针对线性渐变来剖析其具体的用法.为了更好的应用 CSS3 G ...