目录
一、put()方法的作用和执行流程
HashMap 只提供了 put 用于添加元素,putval也是使用的默认修饰符,因此只能被本类或者该包下的类访问到,所以putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。
对 putVal 方法添加元素的分析如下:
- 如果定位到的数组位置没有元素,就直接插入。
- 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。
二、put()和putVal()源码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods.
* 实现了map的put和相关方法
* @param hash key的hash值(key的hash高16位+高16位与低16位的异或运算)
* @param key 键
* @param value 值
* @param onlyIfAbsent onlyIfAbsent为true的时候不要修改已经存在的值,如果onlyIfAbsent为false,当插入的元素已经在HashMap中已经拥有了与其key值和hash值相同的元素,仍然需要把新插入的value值覆盖到旧value上。如果nlyIfAbsent为true,则不需要修改
* @param evict evict如果为false表示构造函数调用
* @return 返回旧的value值(在数组桶或链表或红黑树中找到存在与插入元素key值和hash值相等的元素,就返回这个旧元素的value值),如果没有发现相同key和hash的元素则返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab用来临时存放数组table引用 p用来临时存放数组table桶中的bin
// n存放HashMap容量大小 i存放当前put进HashMap的元素在数组中的位置下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
// e记录当前节点 k记录key值
Node<K,V> e; K k;
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录。直接将插入的新元素覆盖旧元素
e = p;
// hash值不相等,即key不相等并且该节点为红黑树结点,将元素插入红黑树
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 为链表结点
else {
// 在链表最末插入结点(尾插法)
for (int binCount = 0; ; ++binCount) {
// 到达链表的尾部
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
// 这个treeifyBin()方法会根据 HashMap 数组情况来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少执行效率。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 树化操作
treeifyBin(tab, hash);
// 跳出循环 此时e=null,表示没有在链表中找到与插入元素key和hash值相同的节点
break;
}
// 判断链表中结点的key值和Hash值与插入的元素的key值和Hash值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 若相等,则不用将其插入了,直接跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 当e!=null时,表示在数组桶或链表或红黑树中存在key值、hash值与插入元素相等的结点。此时就直接用原有的节点就可以了,不用插入新的元素了。此时e就代表原本就存在于HashMap中的元素
if (e != null) {
// 记录e的value,也就是旧value值
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null,则需要用新的value值对旧value值进行覆盖
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 替换旧值时会调用的方法(默认实现为空)
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改,记录HashMap被修改的次数,主要用于多线程并发时候
++modCount;
// 实际大小大于阈值则扩容 ++size只有在插入新元素才会执行,如果发现HashMap中已经存在了相同key和hash的元素,就不会插入新的元素,在上面就已经执行return了,也就不会改变size大小
if (++size > threshold)
resize();
// 插入成功时会调用的方法(默认实现为空)
afterNodeInsertion(evict);
// 没有找到原有相同key和hash的元素,则直接返回Null
return null;
}
2.1 实现为空的方法
这里顺带说一下下面三个方法
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
在putVal()方法中替换旧值和插入成功的时候都调用了上面其中的两个方法,这三个方法是HashMap类中的方法,但是我们查看源码后会发现这三个方法都是空的方法。其实这三个方法是为继承HashMap的LinkedHashMap类服务的。
- LinkedHashMap 是 HashMap 的一个子类,它保留插入的顺序,如果需要输出的顺序和输入时的相同,那么就选用 LinkedHashMap。
比如LinkedHashMap中被覆盖的afterNodeInsertion方法,用来回调移除最早放入Map的对象。这三个方法会在以后的LinkedHashMap章节里面讲到。
2.2 treeifyBin()方法
将所有的节点转换成树形节点,并且将链接的链表线索化,即为每个二叉树的节点添加前驱和后继节点,形成线索,构造出双链表结构。再调用treeify()方法构造红黑树结构关系。
/**
* 将数组指定位置的索引里的链表转为红黑树。通过两步完成:
* 1、为每个节点添加前驱和后继节点,形成双向链表结构,并且将每个节点都转换成红黑树节点TreeNode
* 2、调用treeify()方法构造红黑树结构关系
*
* @param tab:数组
* @param hash:要将这个hash值所在的索引上的链表转换为红黑树
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
// n:当前数组长度,index:hash经过计算得到的索引,e:index索引位置的元素
int n, index; Node<K,V> e;
// 当前数组为空或者当前数组长度小于数组转为红黑树的阈值64时,需要扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 计算得到索引index,并且取出来index索引对应的节点e,并且 e 不是null,
else if ((e = tab[index = (n - 1) & hash]) != null) {
// hd 存头节点,e表示当前遍历到的链表节点,p表示当前遍历到的已经转换成树节点的节点,tl表示当前遍历到的已经转换成树节点的上一个节点
TreeNode<K,V> hd = null, tl = null;
// 从e节点开始遍历链表
do {
// 将链表节点e转红黑树节点p
TreeNode<K,V> p = replacementTreeNode(e, null);
// 如果是第一次遍历,将头节点赋值给hd
if (tl == null)
hd = p;
// 如果不是第一次遍历,则处理当前节点的prev属性和上一个节点的next属性
else {
// 当前节点的prev属性设为上一个节点
p.prev = tl;
// 上一个节点的next属性设置为当前节点
tl.next = p;
}
// 将p节点赋值给tl
tl = p;
} while ((e = e.next) != null); //后移,找下一个节点,再继续遍历
// 将table该索引位置赋值为hd头节点,如果该节点不为空,则以头节点(hd)为根节点, 构建红黑树
if ((tab[index] = hd) != null)
// treeify()是内部类TreeNode的方法,这个在TreeNode那篇文章里有详细的讲解
hd.treeify(tab);
}
}
// For treeifyBin 将指定的链表节点转为树节点
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
三、对比JDK1.7的put()方法源码
3.1 JDK1.7的put()方法执行流程
对于JDK1.7的 put 方法的分析如下:
- 如果定位到的数组位置没有元素 就直接插入。
- 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和链表上的 key 比较,如果存在key 相同的节点就直接覆盖,没有相同的节点就采用头插法将元素插入链表。与JDK1.8链表插入元素的不同点就在于1.8是尾插法,1.7是头插法
流程图:
3.2 JDK1.7的put()方法源码
首先贴一下JDK1.7中HashMap成员属性与1.8相比不同的两个,在put()源码中会出现
//HashMap内部的存储结构是一个数组,此处数组为空,即没有初始化之前的状态
static final Entry<?,?>[] EMPTY_TABLE = {};
//空的存储实体 table是真正存储元素的数组
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
put()方法源码:
/**
* 将“key-value”添加到HashMap中
* @return 如果插入的key在HashMap中已存在,将新插入的value替换旧value,并且返回旧value
* 如何插入的key在HashMap中不存在,则返回Null
*/
public V put(K key, V value) {
// 1. 若 哈希表未初始化(即 table为空)
// 则使用构造函数进行初始化 数组table
if (table == EMPTY_TABLE) {
// 分配数组空间
// 入参为threshold,此时threshold为initialCapacity initialCapacity可以是构造方法中传入的大小,如果构造方法没有指定HashMap容量大小,则使用默认值1<<4(=16)
inflateTable(threshold);
}
// 2. 判断key是否为空值null
// 2.1 若key == null,则将该键-值 存放到数组table 中的第1个位置,即table [0]
// (本质:key = Null时,hash值 = 0,故存放到table[0]中)
// 该位置永远只有1个value,新传进来的value会覆盖旧的value
if (key == null)
return putForNullKey(value);
// 2.2 若 key ≠ null,则计算存放数组 table 中的位置(下标、索引)
// a. 根据键值key计算hash值
int hash = hash(key);
// b. 根据hash值 最终获得 key对应存放的数组Table中位置
int i = indexFor(hash, table.length);
// 3. 判断该key对应的值是否已存在(通过遍历 以该数组元素为头结点的链表 逐个判断)
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
// 3.1 若该key已存在(即 key-value已存在 ),则用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
// 调用value的回调函数,该函数为空实现
e.recordAccess(this);
return oldValue;
}
}
// 结构性修改,记录HashMap被修改的次数。保证并发访问时,若HashMap内部结构发生变化,快速响应失败
modCount++;
// 3.2 若 该key不存在,则将“key-value”添加到table中
addEntry(hash, key, value, i);
return null;
}
1. 初始化哈希表
真正初始化哈希表(初始化存储数组table)是在第1次添加键值对时,即第1次调用put()时,而不是在构造函数中。
inflateTable()方法用于初始化HashMap,即初始化数组(table)、扩容阈值(threshold)。
inflateTable的源码如下:
/**
* 初始化hash表
* @param toSize 指定HashMap容量大小
*/
private void inflateTable(int toSize) {
// 1. capacity必须是2的次幂,将传入的容量大小toSize转化为:大于传入容量大小toSize的最小的2的次幂
// 即如果传入的是容量大小是19,那么转化后,初始化容量大小为32(即2的5次幂)
int capacity = roundUpToPowerOf2(toSize);
// 2. 重新计算阈值 threshold = 容量 * 加载因子
// 取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 3. 使用计算后的初始容量(已经是2的次幂) 为table分配空间,即初始化数组table(作为数组长度)
// 即 哈希表的容量大小 = 数组大小(长度)
table = new Entry[capacity];
// 选择合适的Hash因子(即Hash种子),好的Hash种子能提高计算Hash时结果的散列性
initHashSeedAsNeeded(capacity);
}
inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。其实现如下:
/**
* 找到大于传入容量大小的最小的2的次幂
*/
private static int roundUpToPowerOf2(int number) {
//若 容量超过了最大值,初始化容量设置为最大值 ;否则,设置为:大于传入容量大小的最小的2的次幂
return number >= MAXIMUM_CAPACITY ?
MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值。
2.当 key ==null时,将该 key-value 的存储位置规定为数组table 中的第1个位置,即table [0]
/**
* 将key为null的value值放入table[0]上
*/
private V putForNullKey(V value) {
// 遍历以table[0]为首的链表,寻找是否存在key==null 对应的键值对
// 1. 若有:则用新value 替换 旧value;同时返回旧的value值
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
}
从此处可以看出:
- HashMap的键key 可为null(区别于 HashTable的key 不可为null)
- HashMap的键key 可为null且只能为1个,但值value可为null且为多个
3.当key≠null的时候,计算key的Hash并根据Hash值计算对应在table中的下标
hash()方法:
/**
* 源码分析1:hash(key)
* 该函数在JDK 1.7 和 1.8 中的实现不同,但原理一样 = 扰动函数 = 使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)
* JDK 1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算
* JDK 1.8 简化了扰动函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算
*/
// JDK 1.7实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
通过hash函数得到散列值后,再通过indexFor进一步处理来获取实际的存储位置,其实现如下:
/**
* 函数源码分析2:indexFor(hash, table.length)
* JDK 1.8中实际上无该函数,但原理相同,即具备类似作用的函数
*/
static int indexFor(int h, int length) {
// 将对哈希码扰动处理后的结果 与运算(&) (数组长度-1),最终得到存储在数组table的位置(即数组下标、索引)
return h & (length-1);
}
4.当key≠null时,得到存储的下标位置后,我们就可以将元素放入HashMap中
先判断链表中是否已经存在与要插入元素的key相同的元素,如果有,直接用要插入的新value覆盖旧value。如果没有,则调用addEntry()方法将元素插入:
/**
* 添加链表元素
* 作用:添加键值对(Entry )到 HashMap中
* @param bucketIndex 元素要插入到数组table的索引位置(下标)
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
// 1. 插入前,先判断容量是否足够
// 1.1 若不足够,则进行扩容(2倍)、重新计算Hash值、重新计算存储数组下标
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); // a. 扩容2倍
hash = (null != key) ? hash(key) : 0; // b. 重新计算该Key对应的hash值
bucketIndex = indexFor(hash, table.length); // c. 重新计算该Key对应的hash值的存储数组下标位置
}
// 1.2 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
createEntry(hash, key, value, bucketIndex);
}
/**
* 创建元素,并将新元素添加到HashMap中
* 作用: 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
// 1. 把table中该位置原来的Entry保存
Entry<K,V> e = table[bucketIndex];
// 2. 使用头插法讲元素插入到链表中,新元素成为链表头节点,新元素的next节点为原链表头节点。这保证了新插入的元素总是在链表的头
table[bucketIndex] = new Entry<>(hash, key, value, e);
// 3. 哈希表的键值对数量计数增加
size++;
}
通过以上代码能够得知,当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。扩容会在后面的章节讲解。