HashMap源码及相关问题
HashMap简介
HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。
它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。
HashMap的存储结构
在JDK1.7的时候,HashMap的存储结构是数组加链表。
然而在JDK1.8中,对H爱上Map的存储结构进行了优化,使其的结构实现变成了数组+链表+红黑树,如下图:
JDK1.8的HashMap源码
HashMap的几个属性
//初始化桶大小,因为底层是数组,所以这是数组默认的大小。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组桶最大值。
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子(0.75)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//用于判断是否需要将链表转换为红黑树的阈值。
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
//真正存放数据的数组。
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
//HashMap中实际存在的键值对数量
transient int size;
//HashMap中所能存放的最多键值对/阈值
int threshold;
//负载因子,可在初始化时显式指定。
final float loadFactor;
首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
也就是说threshold就是负载因子和桶数组长度对应条件下允许的最大元素数目,如果HashMap中存储的键值对超过这个阈值,就要调用resize()扩容。
注:
- 默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
- 哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
HashMap的构造方法
空参构造
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
这里只把默认的负载因子/满载率赋值给loadFactor。
一个参数的构造方法
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
这个构造方法会传入一个初始化的容积,然后会调用HashMap的双参数构造方法,传入这个容积和默认的负载因子/满载率。
双参数的构造方法
public HashMap(int initialCapacity, float loadFactor) {
//初始容量小于0,抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
//如果设置的初始容量大于数组长度的最大值,则将初始化容量变为最大值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//负载因子/满载率小于0则抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
//把传入的负载因子赋值给全局的负载因子
this.loadFactor = loadFactor;
//这里对传入进来的初始化容量进行了一个tableSizeFor操作,以保证容量为2^n,以便于后期移位的操作
this.threshold = tableSizeFor(initialCapacity);
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
带Map类的构造方法
public HashMap(Map<? extends K, ? extends V> m) {
//给负载因子赋默认值
this.loadFactor = DEFAULT_LOAD_FACTOR;
//把传入的map集合当作参数调用这个方法
putMapEntries(m, false);
}
我们看出这个构造方法中主要调用了putMapEntries(m, false)方法,所以具体的向当前的HashMap中添加一个集合的数据的过程是在putMapEntries(m, false)方法中:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
//如果没有初始化table数组
if (table == null) {
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
//扩容
resize();
//遍历参数集合数据,调用putVal方法将其添加到当前HashMap
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
HashMap中的table桶数组
我们看见HashMap中有一个数组为transient Node<K,V>[] table,这个就是HashMap真正存数据的数组,table数组存储Node,而Node的本质就是一个映射(键值对),我们看一下Node的实现:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
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) { /*......*/ }
public final boolean equals(Object o) { /*......*/ }
}
Node是HashMap的一个内部类,它的成员变量也很好理解:
- hash为这个Node的哈希值,也就是其存放在table数组中的下标。
- key就是我们写入的键值。
- value就是我们写入的key对应的值。
- next存放的是当前Node的next,因为我们知道HashMap的存储结构是链表加红黑树,所以这个next就是实现链表结构的。
HashMap计算哈希值/索引的方法
在说HashMap的两个重要的put/get方法之前,我们一定要说以下如何通过hash算法来计算传入的键值对在table数组中存储的位置。前面说过,HashMap的存储结构是数组+链表+红黑树,这个结构的目的就是避免过多的哈希冲突导致查询数据变慢,所以我们对于每个传入的键值对进行哈希算法,目的就是让他们在HashMap桶数组中分布的更加均匀,便于查找。
JDK1.7的hash算法
static final int hash(int h) {
h ^= k.hashCode(); //第一步:取hashCode值
h ^= (h >>> 20) ^ (h >>> 12); //高位参与运算
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
}
在JDK1.7中,先将key传换成哈希值,然后再进行4次位运算+5次异或运算进行了9次的扰动处理,使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)。
调用完hash方法后,调用indexFor方法来计算该对象应该保存在table数组的哪个索引处,我们看一下indexFor方法中的这一句h & (length-1)。因为table数组的长度总是2的n次方,所以h & (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
JDK1.8的hash算法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在JDK1.8中,先将key转换成哈希值,然后进行1次位运算+1次异或运算进行了2次扰动处理尽量避免hash冲突。没有了indexFor方法,而是在hash中优化了高位运算的算法。通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的。下面是图解:
HashMap的put方法
对于HashMap的操作,最重要的就是它的put和get方法,我们先看一下put的源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
在put方法中,我们先对key进行哈希运算,然后作为参数传到putval方法中,putVal方法的后两个参数一个是是否不覆盖旧的值,一个是给HashMap的子类LinkedHashMap用的。
也就是说添加数据的操作都在putVal方法中:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//步骤1:如果table数组为空,则通过resize扩容操作创建出table数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//步骤2:计算出索引值index,如果table数组中该索引值无数据,即没有链接一个节点,则通过newNode方法给他连上一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//步骤3:判断是否第一个节点就是节点key,如果是,则直接覆盖value值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//步骤4:如果该链是红黑树的形式存在,则调用putTreeVal方法,进行红黑树中添加节点。
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//步骤5:如果该链为链表
else {
for (int binCount = 0; ; ++binCount) {
//如果一直没找到Key,则创建一个结点存到链表最后面
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表长度达到树化的阈值,则调用treeifyBin方法进行树化处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果遍历时找到了Key,则break跳出循环,在下面会进行值的覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//步骤6:存在该Key,覆盖原值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//步骤7:判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
由上面的分析可知,putVal方法答题总共分为7步:
- 第一步:判断是否存在table数组,没有则通过resize方法创建。
- 第二步:通过哈希值计算出在table中的索引,如果该索引处为空,则直接使用newNode方法创建节点连接到这里。
- 第三步:如果该索引处的第一个节点Key就是我们要存的,则直接新值替换旧值
- 第四步:如果该索引的链是红黑树的形式,则调用putTreeVal方法,进行红黑树中添加节点的操作。
- 第五步:如果该链是链表,则会进行遍历。
- 如果一直没有找到Key,则newNode方法创建节点放到链的末尾。然后判断是否要树化。
- 如果找到了则break。
- 第六步:如果在上面的for中找到了Key,则实现替换。
- 第七步:比较size和阈值,判断是否需要扩容,是否需要调用resize方法。
在putVal方法中,主要涉及了两个方法,treeifyBin和resize。一个是进行树化操作,一个是进行扩容操作。
HashMap的树化treeifyBin方法
当table上的某一个链达到了树化的条件,就会把这个链表转换成红黑树,转换就是通过treeifyBin方法来进行的,下面我们来看一下treeifyBin方法的源码:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果table数组的长度小于64,则不进行转换,进行扩容即可
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//转换
//经哈希函数计算得到新增键值对链接在数组的哪个单元格下
else if ((e = tab[index = (n - 1) & hash]) != null) {
//定义首、尾节点
TreeNode<K,V> hd = null, tl = null;
do {
//调用replacementTreeNode方法,把节点由node类型转换成treeNode类型
TreeNode<K,V> p = replacementTreeNode(e, null);
//如果尾节点为空,则hd成为首节点
if (tl == null)
hd = p;
//否则就把单向链表转换为双向链表(树的节点形式)
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
//调用treeify方法将准备好的双向链表转换成红黑树
hd.treeify(tab);
}
}
//真正的树化操作是hd.treeify(tab)方法,将双向链表转换为红黑树
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null; // 定义树的根节点
for (TreeNode<K,V> x = this, next; x != null; x = next) { // 遍历链表,x指向当前节点、next指向下一个节点
next = (TreeNode<K,V>)x.next; // 下一个节点
x.left = x.right = null; // 设置当前节点的左右节点为空
if (root == null) { // 如果还没有根节点
x.parent = null; // 当前节点的父节点设为空
x.red = false; // 当前节点的红色属性设为false(把当前节点设为黑色)
root = x; // 根节点指向到当前节点
}
else { // 如果已经存在根节点了
K k = x.key; // 取得当前链表节点的key
int h = x.hash; // 取得当前链表节点的hash值
Class<?> kc = null; // 定义key所属的Class
for (TreeNode<K,V> p = root;;) { // 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出
// GOTO1
int dir, ph; // dir 标识方向(左右)、ph标识当前树节点的hash值
K pk = p.key; // 当前树节点的key
if ((ph = p.hash) > h) // 如果当前树节点hash值 大于 当前链表节点的hash值
dir = -1; // 标识当前链表节点会放到当前树节点的左侧
else if (ph < h)
dir = 1; // 右侧
/*
* 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
* 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class的实例,那么通过comparable的方式再比较两者。
* 如果还是相等,最后再通过tieBreakOrder比较一次
*/
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p; // 保存当前树节点
//判断节点在左还是在右
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp; // 当前链表节点 作为 当前树节点的子节点
if (dir <= 0)
xp.left = x; // 作为左孩子
else
xp.right = x; // 作为右孩子
root = balanceInsertion(root, x); // 重新平衡
break;
}
}
}
}
//查找确定根节点
moveRootToFront(tab, root);
}
HashMap的扩容resize()
这个resize()方法有两种使用情况:
- 初始化哈希表
- 当前数组容量过小,需扩容
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。
我们看一下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;
}
//如果没达到/超过最大值,则直接扩容两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
//针对初始化哈希表操作,对阈值和初始化长度赋默认值或者指定值
else if (oldThr > 0)
newCap = oldThr;
else {
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 = newThr;
//创建新数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//进行老数组向新数组的拷贝操作
if (oldTab != null) {
//这里是table数组的遍历,不是对链表的遍历
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 {
//定义了一些低位/高位链表头尾
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//将原来一个链表转换成一个高位链表和一个低位链表
do {
next = e.next;
//注意这里计算在新数组索引时,没有令oldCap-1,这是因为这样可以计算出高低位
//为0去低位
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);
//将转换的链表赋值给新的table数组
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
- 下面我们附一张图片来概括put方法
HashMap的get()方法
HashMap操作中,我们可以根据键key,向HashMap获取对应的值:map.get(key)。
下面我们就看一下HashMap的get方法的源码:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
由此可见,get方法中,通过key去调用getNode方法寻找value,如果没有找到node就返回null,所以主要的查找方法是getNode方法:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//计算存放在数组table中的位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果数组中直接存在Key相等的情况,直接返回
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果不存在则去红黑树中或者链表中寻找
if ((e = first.next) != null) {
//红黑树
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
上述方法大体分为如下几步:
- 先根据hash值算出在数组中存放的位置
- 看是否数组的索引处存的链表或者树的头就是我们要查的Key,如果是直接返回
- 遍历红黑树
- 遍历链表
以上就是get方法的源码解析。
JDK1.8 HashMap的源码总结
下面,用3个图总结整个源码内容:
- 数据结构 & 主要参数
示意图
- 添加 & 查询数据流程
示意图
- 扩容机制
HashMap常见问题
JDK1.8相比于JDK1.7有什么变化?
- JDK1.7中对table数组中的链表采用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7采用头插法时,在并发扩容时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树,使用尾插法,能够避免出现逆序且链表死循环的问题。HashMap的死循环问题
- 扩容后数据存储位置的计算方式不一样:JDK1.7全部按照原来的方法来进行计算,即HashCode()->扰动处理->与运算;而JDK1.8则是用哈希值&旧的容量(而不是旧的容量-1)计算出高低位即新的存储位置。
- JDK1.7是先扩容在插入,JDK1.8是先插入在扩容:在JDK1.7的时候是先扩容后插入的,这样就会导致无论这一次插入是不是发生hash冲突都需要进行扩容,如果这次插入的并没有发生Hash冲突的话,那么就会造成一次无效扩容;但是在1.8的时候是先插入再扩容的,优点其实是因为为了减少这一次无效的扩容,原因就是如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容,但是在1.7的时候就会急造成扩容。
- hash值的计算方式:JDK1.7中进行了hashcode()+ 9次扰动处理(4次位运算+5次异或运算),然而JDK1.8中进行了hashcode()+ 2次扰动处理(1次位运算+1次异或运算)。
其他问题
如何避免哈希冲突?
- Hash算法:通过hashCode()方法和扰动处理。
- 扩容机制:当哈希表容量大于阈值(容量 * 负载因子)时,会扩容,以避免同一个索引处数据太多即哈希冲突。
如何解决哈希冲突?
- 数据结构:JDK1.7为数组 + 链表。JDK1.8为数组 + 链表 + 红黑树。
- 良好的数据存储结构:JDK1.7中,采用链地址法 + 头插法;JDK1.8中,采用链地址法 + 尾插法 + 红黑树
为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键
- 因为String和Integer等包装类中重写了equals和hashCode方法,不容易出现hash值得计算错误,有效减少了发生Hash冲突的几率。
- String和Integer为final类型,具有不可变性,即保证Key的不可更改性,保证了Hash值得不可更改性和计算准确性。