前几篇分析了 ArrayList
, LinkedList
,Vector
,Stack
List 集合的源码,Java 容器除了包含 List 集合外还包含着 Set 和 Map 两个重要的集合类型。而 HashMap
则是最具备表明性的,也是咱们最常使用到的 Map 集合。咱们这篇文章就来试着分析下 HashMap
的源码,因为 HashMap
底层涉及到太多方面,一篇文章老是不能面面俱到,因此咱们能够带着面试官常问的几个问题去看源码:html
本文也将从以上几个方面来展开叙述:java
因为掘金后台审核可能会因为某些缘由形成文章发布延迟或者遗漏,若是感受我写的源码分析文章还不错,能够关注我,之后我每次更新文章就能够收到推送了。另外博主也是在努力进步中,全部文章若是有问题请尽管留言给我。我会及时改正。你们一块儿进步。node
为了方便下边的叙述这里须要先对几个常见的关于 HashMap
的知识点进行下概述:面试
HashMap
存储数据是根据键值对存储数据的,而且存储多个数据时,数据的键不能相同,若是相同该键以前对应的值将被覆盖。注意若是想要保证 HashMap
可以正确的存储数据,请确保做为键的类,已经正确覆写了 equals()
方法。算法
HashMap
存储数据的位置与添加数据的键的 hashCode()
返回值有关。因此在将元素使用 HashMap 存储的时候请确保你已经按照要求重写了 hashCode()
方法。这里说有关系表明最终的存储位置不必定就是 hashCode
的返回值。segmentfault
HashMap
最多只容许一条存储数据的键为 null,可容许多条数据的值为 null。数组
HashMap
存储数据的顺序是不肯定的,而且可能会由于扩容致使元素存储位置改变。所以遍历顺序是不肯定的。安全
HashMap
是线程不安全的,若是须要再多线程的状况下使用能够用 Collections.synchronizedMap(Map map)
方法使 HashMap
具备线程安全的能力,或者使用 ConcurrentHashMap
。bash
要想分析 HashMap 源码,就必须在 JDK1.8 和 JDK1.7之间划分一条线,由于在 JDK 1.8 后对于 HashMap 作了底层实现的改动。数据结构
经过上篇文章搞懂 Java equals 和 hashCode 方法 咱们以及对 hash 表有所了解,咱们了解到及时 hashCode() 方法已经写得很完美了,终究仍是有可能致使 「hash碰撞」的,HashMap
做为使用 hash 值来决定元素存储位置的集合也是须要处理 hash 冲突的。在1.7以前JDK采用「拉链法」来存储数据,即数组和链表结合的方式:
「拉链法」用专业点的名词来讲叫作链地址法。简单来讲,就是数组加链表的结合。在每一个数组元素上存储的都是一个链表。
咱们以前说到不一样的 key 可能通过 hash 运算可能会获得相同的地址,可是一个数组单位上只能存放一个元素,采用链地址法之后,若是遇到相同的 hash 值的 key 的时候,咱们能够将它放到做为数组元素的链表上。待咱们去取元素的时候经过 hash 运算的结果找到这个链表,再在链表中找到与 key 相同的节点,就能找到 key 相应的值了。
JDK1.7中新添加进来的元素老是放在数组相应的角标位置,而原来处于该角标的位置的节点做为 next 节点放到新节点的后边。稍后经过源码分析咱们也能看到这一点。
对于 JDK1.8 以后的HashMap
底层在解决哈希冲突的时候,就不仅仅是使用数组加上单链表的组合了,由于当处理若是 hash 值冲突较多的状况下,链表的长度就会愈来愈长,此时经过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),所以在 JDK1.8 以后,在链表新增节点致使链表长度超过 TREEIFY_THRESHOLD = 8
的时候,就会在添加元素的同时将原来的单链表转化为红黑树。
对数据结构很在行的读者应该,知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn)
级别,因此利用红黑树的特色就能够更高效的对 HashMap
中的元素进行操做。
JDK1.8 对于HashMap 底层存储结构优化在于:当链表新增节点致使链表长度超过8的时候,就会将原有的链表转为红黑树来存储数据。
关于 HashMap 源码中分析的文章通常都会说起几个重要的概念:
哈希桶(buckets):在 HashMap 的注释里使用哈希桶来形象的表示数组中每一个地址位置。注意这里并非数组自己,数组是装哈希桶的,他能够被称为哈希表。
初始容量(initial capacity) : 这个很容易理解,就是哈希表中哈希桶初始的数量。若是咱们没有经过构造方法修改这个容量值默认为DEFAULT_INITIAL_CAPACITY = 1<<4
即16。值得注意的是为了保证 HashMap 添加和查找的高效性,HashMap
的容量老是 2^n 的形式。
加载因子(load factor):加载因子是哈希表(散列表)在其容量自动增长以前被容许得到的最大数量的度量。当哈希表中的条目数量超过负载因子和当前容量的乘积时,散列表就会被从新映射(即重建内部数据结构),从新建立的散列表容量大约是以前散列表哈系统桶数量的两倍。默认加载因子(0.75)在时间和空间成本之间提供了良好的折衷。加载因子过大会致使很容易链表过长,加载因子很小又容易致使频繁的扩容。因此不要轻易试着去改变这个默认值。
扩容阈值(threshold):其实在说加载因子的时候已经提到了扩容阈值了,扩容阈值 = 哈希表容量 * 加载因子。哈希表的键值对总数 = 全部哈希桶中全部链表节点数的加和,扩容阈值比较的是是键值对的个数而不是哈希表的数组中有多少个位置被占了。
树化阀值(TREEIFY_THRESHOLD) :这个参数概念是在 JDK1.8后加入的,它的含义表明一个哈希桶中的节点个数大于该值(默认为8)的时候将会被转为红黑树行存储结构。
非树化阀值(UNTREEIFY_THRESHOLD): 与树化阈值相对应,表示当一个已经转化为数形存储结构的哈希桶中节点数量小于该值(默认为 6)的时候将再次改成单链表的格式存储。致使这种操做的缘由可能有删除节点或者扩容。
最小树化容量(MIN_TREEIFY_CAPACITY): 通过上边的介绍咱们只知道,当链表的节点数超过8的时候就会转化为树化存储,其实对于转化还有一个要求就是哈希表的数量超过最小树化容量的要求(默认要求是 64),且为了不进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD);在达到该有求以前优先选择扩容。扩容由于由于容量的变化可能会使单链表的长度改变。
与这个几个概念对应的在 HashMap 中几个常亮量,因为上边的介绍比较详细了,下边仅列出几个变量的声明:
/*默认初始容量*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/*最大存储容量*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/*默认加载因子*/
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;
复制代码
对应的还有几个全局变量:
// 扩容阈值 = 容量 x 加载因子
int threshold;
//存储哈希桶的数组,哈希桶中装的是一个单链表或一颗红黑树,长度必定是 2^n
transient Node<K,V>[] table;
// HashMap中存储的键值对的数量注意这里是键值对的个数而不是数组的长度
transient int size;
//全部键值对的Set集合 区分于 table 能够调用 entrySet()获得该集合
transient Set<Map.Entry<K,V>> entrySet;
//操做数记录 为了多线程操做时 Fast-fail 机制
transient int modCount;
复制代码
HashMap 在 JDK 1.7 中只有 Entry
一种存储单元,而在 JDK1.8 中因为有了红黑树的存在,就多了一种存储单元,而 Entry
也随之应景的改成名为 Node。咱们先来看下单链表节点的表示方法 :
/**
* 内部类 Node 实现基类的内部接口 Map.Entry<K,V>
*
*/
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; }
//节点的 hashCode 值经过 key 的哈希值和 value 的哈希值异或获得,没发如今源码中中有用到。
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
//更新相同 key 对应的 Value 值
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//equals 方法,键值同时相同才节点才相同
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;
}
}
复制代码
对于JDK1.8 新增的红黑树节点,这里不作展开叙述,有兴趣的朋友能够查看 HashMap 在 JDK 1.8 后新增的红黑树结构这篇文章来了解一下 JDK1.8对于红黑树的操做。
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
·········
}
复制代码
HashMap
构造方法一共有三个:
threshold
加载因子。其中加载因子不能够小于0,并无规定不能够大于 1,可是不能等于无穷.你们可能疑惑
Float.isNaN()
其实 NaN 就是 not a number 的缩写,咱们知道在运算 1/0 的时候回抛出异常,可是若是咱们的除数指定为浮点数 1/0.0f 的时候就不会抛出异常了。计算器运算出的结果能够当作一个极值也就是无穷大,无穷大不是个数因此 1/0.0f 返回结果是 Infinity 无穷,使用 Float.isNaN()判断将会返回 true。
public HashMap(int initialCapacity, float loadFactor) {
// 指按期望初始容量小于0将会抛出非法参数异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 指望初始容量不能够大于最大值 2^30 实际上咱们也不会用到这么大的容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 加载因子必须大于0 不能为无穷大
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;//初始化全局加载因子变量
this.threshold = tableSizeFor(initialCapacity);//根据初始容量计算计算扩容阈值
}
复制代码
咦?不是说好扩容阈值 = 哈希表容量 * 加载因子么?为何还要用到下边这个方法呢?咱们以前说了参数 initialCapacity
只是指望容量,不知道你们发现没咱们这个构造函数并无初始化 Node<K,V>[] table
,事实上真正指定哈希表容量老是在第一次添加元素的时候,这点和 ArrayList 的机制有所不一样。等咱们说到扩容机制的时候咱们就能够看到相关代码了。
//根据指望容量返回一个 >= cap 的扩容阈值,而且这个阈值必定是 2^n
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;
//通过上述面的 或和位移 运算, n 最终各位都是1
//最终结果 +1 也就保证了返回的确定是 2^n
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
复制代码
这个就比较简单了,将指定的指望初容量和默认加载因子传递给两个参数构造方法。这里就不在赘述。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
复制代码
这也是咱们最经常使用的一个构造函数,该方法初始化了加载因子为默认值,并无调动其余的构造方法,跟咱们以前说的同样,哈希表的大小以及其余参数都会在第一调用扩容函数的初始化为默认值。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
复制代码
该方法解释起来就比较麻烦了,由于他在初始化的时候就涉及了添加元素,扩容这两大重要的方法。这里先把它挂起来,紧接着咱们讲完了扩容机制再回来看就行了。
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
复制代码
在分析 HashMap
添加元素的方法以前,咱们须要先来了解下,如何肯定元素在 HashMap
中的位置的。咱们知道 HashMap
底层是哈希表,哈希表依靠 hash 值去肯定元素存储位置。HashMap
在 JDK 1.7 和 JDK1.8中采用的 hash 方法并非彻底相同。咱们如今看下
这里提出一个概念扰动函数,咱们知道Map 文中存放键值对的位置有键的 hash 值决定,可是键的 hashCode 函数返回值不必定知足,哈希表长度的要求,因此在存储元素以前须要对 key 的 hash 值进行一步扰动处理。下面咱们JDK1.7 中的扰动函数:
//4次位运算 + 5次异或运算
//这种算法能够防止低位不变,高位变化时,形成的 hash 冲突
static final int hash(Object k) {
int h = 0;
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
复制代码
JDK1.8中再次优化了这个哈希函数,把 key 的 hashCode 方法返回值右移16位,即丢弃低16位,高16位全为0 ,而后在于 hashCode 返回值作异或运算,即高 16 位与低 16 位进行异或运算,这么作能够在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到 hash 的计算中,同时不会有太大的开销,扰动处理次数也从 4次位运算 + 5次异或运算 下降到 1次位运算 + 1次异或运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
进过上述的扰动函数只是获得了合适的 hash 值,可是尚未肯定在 Node[] 数组中的角标,在 JDK1.7中存在一个函数,JDK1.8中虽然没有可是只是把这步运算放到了 put 函数中。咱们就看下这个函数实现:
static int indexFor(int h, int length) {
return h & (length-1); // 取模运算
}
复制代码
为了让 hash 值可以对应到现有数组中的位置,咱们上篇文章讲到一个方法为 取模运算,即 hash % length
,获得结果做为角标位置。可是 HashMap 就厉害了,连这一步取模运算的都优化了。咱们须要知道一个计算机对于2进制的运算是要快于10进制的,取模算是10进制的运算了,而位与运算就要更高效一些了。
咱们知道 HashMap
底层数组的长度老是 2^n ,转为二进制老是 1000 即1后边多个0的状况。此时一个数与 2^n 取模,等价于 一个数与 2^n - 1作位与运算。而 JDK 中就使用h & (length-1)
运算替代了对 length取模。咱们根据图片来看一个具体的例子:
图片来自:https://tech.meituan.com/java-hashmap.html 侵删。
经过上边的分析咱们能够到以下结论:
敲黑板了,重点来了。对于理解 HashMap 源码一方面要了解存储的数据结构,另外一方面也要了解具体是如何添加元素的。下面咱们就来看下 put(K key, V value)
函数。
// 能够看到具体的添加行为在 putVal 方法中进行
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
复制代码
对于 putVal 前三个参数很好理解,第4个参数 onlyIfAbsent 表示只有当对应 key 的位置为空的时候替换元素,通常传 false,在 JDK1.8中新增方法 public V putIfAbsent(K key, V value)
传 true,第 5 个参数 evict 若是是 false。那么表示是在初始化时调用的:
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 = null 则须要扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;// n 表示扩容后数组的长度
// i = (n - 1) & hash 即上边讲得元素存储在 map 中的数组角标计算
// 若是对应数组没有元素没发生 hash 碰撞 则直接赋值给数组中 index 位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {// 发生 hash 碰撞了
Node<K,V> e; K k;
//若是对应位置有已经有元素了 且 key 是相同的则覆盖元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)//若是添加当前节点已经为红黑树,则须要转为红黑树中的节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {// hash 值计算出的数组索引相同,但 key 并不一样的时候, // 循环整个单链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {//遍历到尾部
// 建立新的节点,拼接到链表尾部
p.next = newNode(hash, key, value, null); // 若是添加后 bitCount 大于等于树化阈值后进行哈希桶树化操做
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//若是遍历过程当中找到链表中有个节点的 key 与 当前要插入元素的 key 相同,此时 e 所指的节点为须要替换 Value 的节点,并结束循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//移动指针
p = e;
}
}
//若是循环完后 e!=null 表明须要替换e所指节点 Value
if (e != null) { // existing mapping for key
V oldValue = e.value//保存原来的 Value 做为返回值
// onlyIfAbsent 通常为 false 因此替换原来的 Value
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//这个方法在 HashMap 中是空实现,在 LinkedHashMap 中有关系
afterNodeAccess(e);
return oldValue;
}
}
//操做数增长
++modCount;
//若是 size 大于扩容阈值则表示须要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
因为添加元素中设计逻辑有点复杂,这里引用一张图来讲明,理解
图片来来自:https://tech.meituan.com/java-hashmap.html
添加元素过程:
Node[] table
表为 null ,则表示是第一次添加元素,讲构造函数也提到了,及时构造函数指定了指望初始容量,在第一次添加元素的时候也为空。这时候须要进行首次扩容过程。i = (n - 1) & hash
得到。在上边说明 HashMap 的 putVal 方法时候,屡次提到了扩容函数,扩容函数也是咱们理解 HashMap 源码的重中之重。因此再次敲黑板~
final Node<K,V>[] resize() {
// oldTab 指向旧的 table 表
Node<K,V>[] oldTab = table;
// oldCap 表明扩容前 table 表的数组长度,oldTab 第一次添加元素的时候为 null
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的扩容阈值
int oldThr = threshold;
// 初始化新的阈值和容量
int newCap, newThr = 0;
// 若是 oldCap > 0 则会将新容量扩大到原来的2倍,扩容阈值也将扩大到原来阈值的两倍
if (oldCap > 0) {
// 若是旧的容量已经达到最大容量 2^30 那么就不在继续扩容直接返回,将扩容阈值设置到 Integer.MAX_VALUE,并不表明不能装新元素,只是数组长度将不会变化
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
}
//oldThr 不为空,表明咱们使用带参数的构造方法指定了加载因子并计算了
//初始初始阈值 会将扩容阈值 赋值给初始容量这里再也不是指望容量,
//可是 >= 指定的指望容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// 空参数构造会走这里初始化容量,和扩容阈值 分别是 16 和 12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//若是新的扩容阈值是0,对应的是当前 table 为空,可是有阈值的状况
if (newThr == 0) {
//计算新的扩容阈值
float ft = (float)newCap * loadFactor;
// 若是新的容量不大于 2^30 且 ft 不大于 2^30 的时候赋值给 newThr
//不然 使用 Integer.MAX_VALUE
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) {
//遍历老数组中每一个位置的链表或者红黑树从新计算节点位置,插入新数组
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 { // preserve order
//由于扩容是容量翻倍,
//原链表上的每一个节点 如今可能存放在原来的下标,即low位,
//或者扩容后的下标,即high位
//低位链表的头结点、尾节点
Node<K,V> loHead = null, loTail = null;
//高位链表的头节点、尾节点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;//用来存放原链表中的节点
do {
next = e.next;
// 利用哈希值 & 旧的容量,能够获得哈希值去模后,
//是大于等于 oldCap 仍是小于 oldCap,
//等于 0 表明小于 oldCap,应该存放在低位,
//不然存放在高位(稍后有图片说明)
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);
//将低位链表存放在原index处,
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//将高位链表存放在新index处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
return newTab;
}
复制代码
相信你们看到扩容的整个函数后对扩容机制应该有所了解了,总体分为两部分:1. 寻找扩容后数组的大小以及新的扩容阈值,2. 将原有哈希表拷贝到新的哈希表中。
第一部分没的说,可是第二部分我看的有点懵逼了,可是踩在巨人的肩膀上老是比较容易的,美团的大佬们早就写过一些有关 HashMap 的源码分析文章,给了我很大的帮助。在文章的最后我会放出参考连接。下面说下个人理解:
JDK 1.8 不像 JDK1.7中会从新计算每一个节点在新哈希表中的位置,而是经过 (e.hash & oldCap) == 0
是否等于0 就能够得出原来链表中的节点在新哈希表的位置。为何能够这样高效的得出新位置呢?
由于扩容是容量翻倍,因此原链表上的每一个节点,可能存放新哈希表中在原来的下标位置, 或者扩容后的原位置偏移量为 oldCap 的位置上,下边举个例子 图片和叙述来自 https://tech.meituan.com/java-hashmap.html:
图(a)表示扩容前的key1和key2两种key肯定索引位置的示例,图(b)表示扩容后key1和key2两种key肯定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在从新计算hash以后,由于n变为2倍,那么n-1的mask范围在高位多1bit(红色),所以新的index就会发生这样的变化:
因此在 JDK1.8 中扩容后,只须要看看原来的hash值新增的那个bit是1仍是0就行了,是0的话索引没变,是1的话索引变成“原索引+oldCap
另外还须要注意的一点是 HashMap 在 1.7的时候扩容后,链表的节点顺序会倒置,1.8则不会出现这种状况。
上边将构造函数的时候埋了个坑即便用:
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
复制代码
构造函数构建 HashMap 的时候,在这个方法里,除了赋值了默认的加载因子,并无调用其余构造方法,而是经过批量添加元素的方法 putMapEntries
来构造了 HashMap。该方法为私有方法,真正批量添加的方法为putAll
public void putAll(Map<? extends K, ? extends V> m) {
putMapEntries(m, true);
}
复制代码
//一样第二参数表明是否初次建立 table
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
//若是哈希表为空则初始化参数扩容阈值
if (table == null) { // pre-size
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)//构造方法没有计算 threshold 默认为0 因此会走扩容函数
resize();
//将参数中的 map 键值对一次添加到 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);
}
}
}
复制代码
JDK1.8 中还新增了一个添加方法,该方法调用 putVal 且第4个参数传了 true,表明只有哈希表中对应的key 的位置上元素为空的时候添加成功,不然返回原来 key 对应的 Value 值。
@Override
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
复制代码
分析了完了 put 函数后,接下来让咱们看下 get 函数,固然有 put 函数计算键值对在哈希表中位置的索引方法分析的铺垫后,get 方法就显得很容容易了。
public V get(Object key) {
Node<K,V> e;
//经过 getNode寻找 key 对应的 Value 若是没找到,或者找到的结果为 null 就会返回null 不然会返回对应的 Value
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//现根据 key 的 hash 值去找到对应的链表或者红黑树
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 若是第一个节点就是那么直接返回
if (first.hash == hash && // always check first node
((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);
//遍历单链表找到对应的 key 和 Value
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
复制代码
@Override
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
}
复制代码
HashMap
没有 set
方法,若是想要修改对应 key 映射的 Value ,只须要再次调用 put
方法就能够了。咱们来看下如何移除 HashMap
中对应的节点的方法:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
复制代码
@Override
public boolean remove(Object key, Object value) {
//这里传入了value 同时matchValue为true
return removeNode(hash(key), key, value, true, true) != null;
}
复制代码
这里有两个参数须要咱们提起注意:
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//判断哈希表是否为空,长度是否大于0 对应的位置上是否有元素
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// node 用来存放要移除的节点, e 表示下个节点 k ,v 每一个节点的键值
Node<K,V> node = null, e; K k; V v;
//若是第一个节点就是咱们要找的直接赋值给 node
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 遍历红黑树找到对应的节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//遍历对应的链表找到对应的节点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 若是找到了节点
// !matchValue 是否不删除节点
// (v = node.value) == value ||
(value != null && value.equals(v))) 节点值是否相同,
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//删除节点
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
复制代码
咱们都只咱们知道 Map 和 Set 有多重迭代方式,对于 Map 遍历方式这里不展开说了,由于咱们要分析迭代器的源码因此这里就给出一个使用迭代器遍历的方法:
public void test(){
Map<String, Integer> map = new HashMap<>();
...
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
//经过迭代器:先得到 key-value 对(Entry)的Iterator,再循环遍历
Iterator iter1 = entrySet.iterator();
while (iter1.hasNext()) {
// 遍历时,需先获取entry,再分别获取key、value
Map.Entry entry = (Map.Entry) iter1.next();
System.out.print((String) entry.getKey());
System.out.println((Integer) entry.getValue());
}
}
复制代码
经过上述遍历过程咱们可使用 map.entrySet()
获取以前咱们最初说起的 entrySet
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
复制代码
// 咱们来看下 EntrySet 是一个 set 存储的元素是 Map 的键值对
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
// size 放回 Map 中键值对个数
public final int size() { return size; }
//清除键值对
public final void clear() { HashMap.this.clear(); }
// 获取迭代器
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
//经过 getNode 方法获取对一个及对应 key 对应的节点 这里必须传入
// Map.Entry 键值对类型的对象 不然直接返回 false
public final boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Node<K,V> candidate = getNode(hash(key), key);
return candidate != null && candidate.equals(e);
}
// 滴啊用以前讲得 removeNode 方法 删除节点
public final boolean remove(Object o) {
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Object value = e.getValue();
return removeNode(hash(key), key, value, true, true) != null;
}
return false;
}
...
}
复制代码
//EntryIterator 继承自 HashIterator
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
// 这里多是由于你们使用适配器的习惯添加了这个 next 方法
public final Map.Entry<K,V> next() { return nextNode(); }
}
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
//初始化操做数 Fast-fail
expectedModCount = modCount;
// 将 Map 中的哈希表赋值给 t
Node<K,V>[] t = table;
current = next = null;
index = 0;
//从table 第一个不为空的 index 开始获取 entry
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
//若是当前链表节点遍历完了,则取哈希桶下一个不为null的链表头
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
//这里仍是调用 removeNode 函数不在赘述
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}
复制代码
除了 EntryIterator
之外还有 KeyIterator
和 ValueIterator
也都继承了HashIterator
也表明了 HashMap 的三种不一样的迭代器遍历方式。
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
复制代码
能够看出不管哪一种迭代器都是经过,遍历 table 表来获取下个节点,来遍历的,遍历过程能够理解为一种深度优先遍历,即优先遍历链表节点(或者红黑树),而后在遍历其余数组位置。
面试的时候面试官老是问完 HashMap 后会问 HashTable 其实 HashTable 也算是比较古老的类了。翻看 HashTable 的源码能够发现有以下区别:
HashMap
是线程不安全的,HashTable是线程安全的。
HashMap
容许 key 和 Vale 是 null,可是只容许一个 key 为 null,且这个元素存放在哈希表 0 角标位置。 HashTable
不容许key、value 是 null
HashMap
内部使用hash(Object key)
扰动函数对 key 的 hashCode
进行扰动后做为 hash
值。HashTable
是直接使用 key 的 hashCode()
返回值做为 hash 值。
HashMap
默认容量为 2^4 且容量必定是 2^n ; HashTable
默认容量是11,不必定是 2^n
HashTable
取哈希桶下标是直接用模运算,扩容时新容量是原来的2倍+1。HashMap
在扩容的时候是原来的两倍,且哈希桶的下标使用 &运算代替了取模。
写 HashMap 源码分析的过程,能够说比 ArrayList
或者LinkedList
源码简直不是一个级别的。我的能力有限,因此在学习的过程当中,参考了不少前辈们的分析,也学到了不少东西。这颇有用,通过这一波分析我以为我对面试中的的 HashMap 面试题回答要比之前强不少。对于 HashMap的相关面试题集合番@HashMap一文通(1.7版) 这篇文章末尾较全面的总结。另外 HashMap 的多线程会致使循环链表的状况,你们能够参考 Java 8系列之从新认识HashMap 写的很是好。你们能够原博客去查看。