基于JDK8进行分析
HashMap是我们日常开发中使用频率最高的容器之一了,它具有如下几个特点:
- 存取是无序的
- 键和值都允许为null
- 底层的数据结构是:数组 + 链表 + 红黑树
- 当链表长度大于8并且数组长度大于64,才会将链表转换为红黑树,其目的是为了增加查询效率
- 是线程不安全的
尽量使用不可变对象来作为key,否则对象如果变化,重新计算的hash值可能和之前不一样,从而出现错误
组成结构简单如图看下:
接下来我们就通过源码看下,HashMap是如何完成数据存储和扩容的。
1.HashMap的主要属性
/**
* 为什么继承了AbstractMap后还实现了Map接口呢?
* 根据集合框架的作者Josh Bloch 所述,这样的写法是一个失误,在Java集合框架中,有很多集合
* 都采用了这种写法,比如ArrayList, LinkedList ,起初作者这样写,在某些地方是有价值的,
* 后来意识到这是一种错误写法,但是JDK的开发者认为这个小小的失误不值得他们去改,所以就一直
* 沿用到了现在。
*/
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
/**
* 默认的初始容量:16(1 * 2^4), 必须是2的n次幂.
* 为什么必须是2的n次幂呢?
* >> 容量一定要是2的n次幂,是为了提高“计算元素放哪个桶”的效率,也是为了提高扩容效率(避免了扩容后再重复处理哈希碰撞问题)<<
*
* 后面看计算索引位置以及扩容的时候再说
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 数组的最大容量
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的负载因子:默认值是 0.75
* 它是用来判断是否需要扩容的
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 当链表的长度超过阈值8,则转换成红黑树
* 为什么达到8才转换成红黑树呢?
* 在HashMap的文档注释中有提到,树节点占用的空间是普通节点的2倍,所以当每个桶(数组的节点)包含
* 足够的节点(链表)时,才会转换成树节点,这个阈值就是8,注释中还提到,根据泊松分布统计
* 链表达到8个的概率是0.00000006,这几乎是不可能事件。
*
* 由于树节点占用的空间是普通节点的2倍,并且在转换成树节点时还要左旋和右旋,如果节点数量少,
* 一顿操作下来,比普通链表花费的开销还要大。
*
* 说白了,就是在时间和空间中不断权衡
*
* 还有资料表明:红黑树的平均查找长度是log(n),链表的平均查找长度是n/2, 如果长度是8,那么红黑树
* 的平均查找长度是3,链表是4,这才有转换成树的必要,如果长度是6,那么红黑树的平均查找长度是2.6,
* 链表是3,虽然速度也是快的,但是转换成树结构和生成树的时间并不会短。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当红黑树的长度小于6,则将红黑树转换成链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 当链表的长度超过8,"并且" "数组的长度"大于64,才会转换成红黑树,如果没有达到64,此时
* 先进行扩容。(注意:是数组的length, 不是元素个数size)
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 元素个数(数组上的 + 链表上的 + 红黑树上的), 不是数组长度!!!
*/
transient int size;
/**
* 记录HashMap的修改次数,和 ConcurrentModificationException 异常有关
*/
transient int modCount;
/**
* 临界值:当实际大小(容量 * 负载因子)超过临界值时,就会扩容。
* 扩容后的容量是原来的2倍,即也是2的N次幂
*/
int threshold;
/**
*
* 负载因子,用来衡量HashMap满的程度。默认是0.75 (DEFAULT_LOAD_FACTOR)
* loadFactor = size/capacity(table.length)
*
* 为什么是0.75 呢 ?
* 如果过小,比如0.3,那么 threshold = 16 * 0.3 = 4,当达到4就扩容,容易造成数组空间浪费,而且会导致频繁扩容,这将会带来性能损耗
* 如果过大,比如0.9,那么 threshold = 16 * 0.9 = 14, 当达到14扩容,我们知道,数组是很难均匀填满的,这样当数据量多的时候就容易产生hash碰撞,导致链表过长
*/
final float loadFactor;
}
HashMap的容量必须是2的N次幂,为什么这样设计呢?
容量一定要是2的n次幂,是为了提高“计算元素放哪个桶”的效率,也是为了提高扩容效率(避免了扩容后再重复处理哈希碰撞问题)
2.初始容量
2.1如果初始容量不是2的N次幂会怎样?
在属性的注释中有说明,数组的容量必须是2的N次幂,那如果不是2的N次幂会怎样呢?
结论:它就取大于当前指定容量的最小的2的N次幂
6 --> 8 (2^3)
9 --> 16 (2^4)
16 --> 16 (2^4)
我们通过有参构造器看下,它是如何做到的:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//核心方法:将初始容量指定为2的N次幂
//这里的threshold临界值是16,并不是 初始容量capacity * 0.75 = 12 ? 写错了吗?
//其实并没有,当第一次put的时候,它会将threshold修改为上述规则
this.threshold = tableSizeFor(initialCapacity);
}
从构造器中可以看到,容量最大能取的值就是2^30次方,假如我指定的初始容量是 10, 那么我们看下他是如何计算出16的:
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;
}
核心就是无符号右移 + 按位或运算
按位与:相同的二进制数位上,都是1的时候结果为1,否则为0
按位或:相同的二进制数位上,都是0的时候结果为0,否则为1(有1就是1)
我指定的初始容量是10,那么就带进来,看下他是如何一步一步工作的:
int n = cap - 1; // n = 10 - 1 = 9
n |= n >>> 1; ①
1.无符号右移1位:
n=9 00000000 00000000 00000000 00001001 (int: 4字节,32位)
右移1位 00000000 00000000 00000000 00000100 结果是:4
-------------------------------------------------------
按位或 00000000 00000000 00000000 00001101 结果是: 13(按位或:有1就是1)
第一次无符号右移1位后按位或得到结果是13,"把高位的相邻1位也会置为1",因为高位向右移动了1位
2.无符号右移2位:
n |= n >>> 2; ②
n=13 00000000 00000000 00000000 00001101
右移2位 00000000 00000000 00000000 00000011 结果是:3
-------------------------------------------------------
按位或 00000000 00000000 00000000 00001111 结果是: 15
3.无符号右移4位:
n |= n >>> 4; ③
n=15 00000000 00000000 00000000 00001111
右移4位 00000000 00000000 00000000 00000000 结果是:0
-------------------------------------------------------
按位或 00000000 00000000 00000000 00001111 结果是: 依然是15
4.无符号右移8位:
n |= n >>> 8; ④
n=15 00000000 00000000 00000000 00001111
右移8位 00000000 00000000 00000000 00000000 结果是:0
-------------------------------------------------------
按位或 00000000 00000000 00000000 00001111 结果是: 依然是15
5.无符号右移16位:
n |= n >>> 16; ④
n=16 00000000 00000000 00000000 00001111
右移16位 00000000 00000000 00000000 00000000 结果是:0
-------------------------------------------------------
按位或 00000000 00000000 00000000 00001111 结果是: 依然是15
6.返回 // 15 + 1 = 16
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
从上述的无符号右移和按位或运算,可以得出一个现象,假如高位中有连续的4个1,那么经过右移2位,在按位或运算后,他会变成6个1,那如果是右移4位,在做按位或运算,就会变成8个1。以此类推...
我们知道,int类型的容量最大可取的值是32bit的正数,而且最后一步中的 n |= n >>> 16 最多可以产生32个1,但此时这已经是负数了,因此在执行 tableSizeFor(initialCapacity) 方法之前,对
initialCapacity
进行了判断,如果 initialCapacity > MAXIMUM_CAPACITY(2^30), 则initialCapacity
取 MAXIMUM_CAPACITY,如果等于 MAXIMUM_CAPACITY(2^30),那么int n = cap - 1
就等于2^30 -1
, 移位操作最多可以得到30个1,此时是小于MAXIMUM_CAPACITY的,30个1加上1之后,得到 2^30,也就是 MAXIMUM_CAPACITY。(类比:4个1是15,加上一个1后是16,也就变成了2的4次方)
在进行移位和按位或操作前要把容量减1呢?
int n = cap - 1;
这是为了避免指定的容量本身就是2的N次幂,经过移位和按位或操作后变成2倍的2的N次幂,假如,初始容量为16,如果不减1,那么经过移位和按位或操作后,就会变成32,如果减1后,得到的结果就是16.
2.2 初始容量指定多少合适?
这一点在阿里巴巴的java规约手册中有提到
其实这个很好理解,假如我要存放的元素有7个,那么数组的容量是8,但是当存储6个时候它就会扩容了,所以为了避免扩容,我们应该存在的元素是 7 / 0.75 + 1 = 10,这样数组的长度是16,虽然浪费了一点空间,但问题不大。
3.构造器分析
- 无参构造器
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
仅指定默认的加载因子,不创建存储元素的table数组,当put的时候再去创建数组。
2.指定初始容量,使用默认的加载因子(加载因子推荐使用默认值)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
- 参数是Map
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
//关注这个方法
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
//table默认值刚开始是null
if (table == null) { // pre-size
/**
* 为什么要+1呢?
* 加1的目的是尽量减少扩容的次数。
* 假如新添加到Map有6个元素,那么如果不+1,那么 t = 8,threshold计算出来也是8
* 然后开始put元素,当到达阈值6(put的时候重新计算threshold)的时候,他就会扩容
* 那如果 +1,t=9, threshold计算出来是16,这样put的时候就不用扩容了
*/
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();
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);
}
}
}
4.重要方法分析
4.1 put()方法
整体步骤大致是:
- 先通过hash值计算出key映射到哪个桶(桶就是数组的空间)
- 如果桶上没有碰撞,则直接插入
- 如果桶上有碰撞,则通过equals方法判断key是否相同,如果相同,则覆盖旧值value, 如果不相同,则处理冲突
3.1): 如果该桶是红黑树,则调用红黑树的方法插入数据
3.2): 如果该桶是链表,则通过尾插法插入到链表尾部,如果长度超过8,则转换成红黑树 - 如果size 大于 阈值threshold,则扩容
源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//核心方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//计算桶的索引位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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 {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4.1.1 计算key的哈希值
static final int hash(Object key) {
int h;
/**
* 如果key = null,那么它的hash值为0,这样经过按位与运算后得到的桶的索引仍然是0,所以
* key=null的元素会存在数组的index=0的位置。
*
* 如果 key != null, 那么先计算出key的hash值赋给h,然后将h无符号右移16位,在与h按位异或
* 得到最终的hash值
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们简单看下:
假设key的hashcode计算出来是h
01111111 11111111 11110000 11101010 h
00000000 00000000 01111111 11111111 h >>> 16
-------------------------------------------------
01111111 11111111 10001111 00010101 ^(按位异或:相同为0,不同为1)
00000000 00000000 00000000 00001111 15(table.length - 1)
---------------------------------------------------
00000000 00000000 00000000 00000101 5 这样就计算出了key在桶的位置
那从效果上看,它和 hashcode % table.length 是一样的。
按位异或:相同的二进制数位上,相同为0,不同为1
不过,为什么要对 h 进行无符号右移后再与h取按位异或运算呢?
如果数组长度很小,比如capacity = 16, 16 - 1 = 15, 换算成二进制就是 1111, 这样的值直接与hashcode值进行按位与操作,实际上只使用了hash值的后4位,如果hash值高位变化很大,低位变化很小或没有变化,这样很容易造成hash冲突,所以这里通过无符号右移和按位异或操作来将高低位都利用起来,从而减少hash碰撞。
我们来演示一下:
①第一次存储key并计算出的hash值:
01111111 11111111 11110000 11101010 h
00000000 00000000 00000000 00001111 15(table.length - 1)
-------------------------------------------------------------
00000000 00000000 00000000 00001010 10(按位与操作的结果)
②第二次存储key并计算出的hash值:
高 低
01000111 10100111 11110000 11101010 h (高位变化大,低位变化小)
00000000 00000000 00000000 00001111 15(table.length - 1)
-------------------------------------------------------------
00000000 00000000 00000000 00001010 10(按位与操作的结果)
不难发现,高位变化很大,低位变化很小(或者没有变化) 很容易发生hash冲突。
4.1.2 计算key所在桶的位置
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//......
//n: 数组长度
//hash: key的hash值
if ((p = tab[i = (n - 1) & hash]) == null)
//......
}
前面我们有提过,HashMap数组的长度必须是2的N次幂,其中它就和计算桶的位置以及扩容有关,那么我们就先来看下他和桶的位置的关系。
简单来讲,一个元素放到哪个桶中,是通过 “hash % capacity” 取模运算得到的余数来确定的,但是HashMap用另一种更高效方式来替代取模运算,就是位运算:(capacity - 1) & hash
如果想要 hash % capacity == (capacity - 1) & hash, 那前提条件就是capacity
必须是2的N次幂才可以。(至于效率自己可以测试看下), 我们先看下通过位运算计算索引
假设 capacity = 8
, hash = 3
(8 - 1) & 3 = 3
00000111 ---> 7
00000011 ---> 3
-----------
按位与: 00000011 --> 3
假设 capacity = 8
, hash = 2
(8 - 1) & 2 = 2
00000111 ---> 7
00000010 ---> 2
-----------
按位与: 00000010 --> 2
取模运算:
3 % 8 = 3
2 % 8 = 2
假设 capacity
不是2的N次幂, capacity = 9
, hash = 3
00001000 ---> 8
00000011 ---> 3
-----------
按位与: 00000000 --> 0
假设 capacity
不是2的N次幂, capacity = 9
, hash = 2
00001000 ---> 8
00000010 ---> 2
-----------
按位与: 00000000 --> 0
不难发现,如果不是2的N次幂,非常容易发生哈希碰撞。
所以,通过 (capacity - 1) & hash 按位与的方式计算索引时,要比取模快,但前提是容量必须是2的N次幂。
4.1.3 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;
/**
* 步骤一:
* ①将table赋给tab,判断是否为空,如果为空,则扩容(第一次的话就是初始化table数组)
* ②如果table不为空,说明已经初始化了,然后将table.lenght赋给n
*/
if ((tab = table) == null || (n = tab.length) == 0)
//扩容(每次扩容为原来的2倍)
n = (tab = resize()).length;
/**
* 步骤二:
* ①通过hash值按位与(数组长度-1)计算当前key在桶的索引,并取出值赋给 p
* a.如果 p == null, 说明数组的当前索引位置没有存放元素,然后直接存储即可
* b.如果 p != null, 说明数组的当前索引位置已经存放了元素,继续走else判断
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//定义两个临时存储的变量
Node<K,V> e; K k;
/**
* 步骤三:
* ①判断p的hash值是否和当前key的hash值相等,如果相等,则判断key的值是否相等
* ②如果都是true,说明key重复了,则将p赋给e,执行步骤六
* ③如果是false,则执行步骤四
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
/**
* 步骤四:
* ①判断p节点是否是树节点,如果是,则插入到红黑树中,其中p是红黑树的根节点
* ②如果不是,则执行步骤五
*/
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
/**
* 步骤五:
* ②进入for循环
* ①将p节点的next取出来赋给e,判断是否为空,就是看下当前节点p是否有下一个节点(链表)
* a.如果不为空,那么,将p的next节点指向新插入的元素,也就是链表
* b.判断链表的节点数量是否大于8,如果大于8则转成红黑树
* ②如果e不为空,则判断链表中的元素e的hash值是否和当前key的hash值相等,以及key是否相同
* a.如果整体结果是true,则跳出for循环,说明key重复了,则执行步骤六
* b.如果整体结果是false,则将e再次赋给p,继续寻找链表中的下一个节点,进行判断
*
* ③整个for循环,最多能执行8次,超过8次后,将转成红黑树并跳出循环
*/
else {
for (int binCount = 0; ; ++binCount) {
//完成了对e的赋值操作
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
/**
* 步骤六:
* 如果e不为空,也就意味着key重复了,则用后插入key的value覆盖已经存在key的value
*/
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
我们简单通过栗子演示下(先跳过红黑树):
HashMap<String, Integer> map = new HashMap<>();
map.put("刘备", 50); //假设:hash=2
map.put("孙权", 18); //假设:hash=5
map.put("曹操", 40); //假设:hash=7
map.put("孔明", 30); //假设:hash=2
map.put("张飞", 32); //假设:hash=2
map.put("孔明", 31);
接下来我们看下转成红黑树的逻辑:
4.1.3.1 链表转成红黑树
当链表长度大于8(其中不包含数组中的节点,如果算上数组上的元素共计9个) 会将链表转成红黑树。
/**
* tab: 是HashMap中存储元素的数组
* hash: 是链表中最后一个元素的hash值,也就是整个链表中所有元素的hash值
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
/**
* 当链表长度大于8,一定会转成红黑树吗?
* 从源码中可以看到,即使链表长度大于8,但是如果数组长度小于64,此时也不会转成红黑树,而是扩容。
* 所以:只有当链表长度大于8并且数组长度大于等于64才会转成红黑树。
*/
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
/**
* 这个hash传进来的时候是链表中最后一个元素的hash值,但是这个整个链表中包括数组中的元素
* 他们的hash值都是一样的,不然也不会产生hash碰撞形成链表了。
*
* tab[index = (n - 1) & hash]): 很熟悉了,根据hash取出数组的元素(可以理解为链表的第一个元素)
*/
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
/**
*
* 整个do-while就是将原来的单向链表,转成双向链表
*/
do {
//将普通的Node节点,转成TreeNode节点
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
/**
* 这里将双向链表转成红黑树
*/
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
NOTE: 只有当链表长度大于8并且数组长度大于等于64才会转成红黑树,否则将对数组扩容。
转成红黑树前,先把单向链表转成双向链表
然后调用TreeNode头结点,将双向链表转成红黑树
4.2 resize()扩容方法
4.2.1 什么时候扩容?
当HashMap中的元素个数(
size
)超过数组长度(table.length
) * 负载因子(loadFactor
)时,就会扩容。loadFactor
默认是0.75,是一个折中的值。默认情况下,数组长度是16,那么当HashMap中元素个数size超过12(16 * 0.75 = 12,这个是12是阈值边界threshold
)时,HashMap的数组就会扩容 为原来的2倍(16 * 2 = 32),然后再重新计算每个元素在数组中的位置.
当链表的长度大于8并且数组长度小于64,此时HashMap会先通过扩容来解决问题,如果数组长度达到了64,那么此时会将链表转成红黑树;当移除红黑树上的元素后,如果树中节点的数量小于6,那么此时会将红黑树转成链表。
4.2.2 扩容
HashMap在扩容时,由于每次扩容都是原来的2倍(也就是2的N次方),与原来计算的 (n-1) & hash 结果相比,只是多了一个bit位,所以节点要么在原位置,要么就被分配到”原位置+旧容量“这个位置。
00000000 0000000 00000000 00010000 ---> 16
00000000 0000000 00000000 00100000 ---> 32
00000000 0000000 00000000 01000000 ---> 64
00000000 0000000 00000000 10000000 ---> 128
扩容后多了一位
数组长度是16的情况: n = 16
(n - 1) & hash
0000 0000 0000 0000 0000 0000 0000 1111 n - 1 = 15
hash1(key1) 0111 1111 1111 1111 0000 1111 0000 0101
-------------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 index = 5
0000 0000 0000 0000 0000 0000 0000 1111 n - 1 = 15
hash2(key2) 0111 1111 1111 1111 0000 1111 0001 0101
-------------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 index = 5
*****************************************************************************
扩容后:n = 32
(n - 1) & hash
0000 0000 0000 0000 0000 0000 0001 1111 n - 1 = 31
hash1(key1) 0111 1111 1111 1111 0000 1111 0000 0101
-------------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 index = 5
0000 0000 0000 0000 0000 0000 0001 1111 n - 1 = 31
hash2(key2) 0111 1111 1111 1111 0000 1111 0001 0101
-------------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 0101 index = 5 + 16 (原位置 + 旧容量)
计算出新的索引高位如果是0,则存储到原位置,如果高位是1,则存储到原来索引+旧的数组长度的位置
因此HashMap在扩容的时候,根本不需要在重新计算hash,只需要看原来的hash值新增的那个bit位是0还是1就可以了,是0的索引位置不变,依然是原位置,如果是1的话,索引的位置为”原位置 + 旧数组容量“
这是由于这种巧妙的rehash方式,既省去了重新计算hash值的时间,而且由于新增的一个bit位到底是0还是1这是随机的,这样在resize()的过程中rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前冲突的节点分散到新的桶中了。
接下来我们看下源码:
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; // 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);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//新数组的阈值是 32 * 0.75 = 24 (我假设初始了16个长度已经不够用了,现在开始扩容)
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//新的数组,长度是32
table = newTab;
//老的数组,接下来将老的数组中的数据放到新的数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//新老的数组中的第一个节点的数据赋给 e, 并判断是否为空,就是看数组的第一个位置有没有数据
if ((e = oldTab[j]) != null) {
//如果有数据,则将老的数组的第一个位置数据置为null,便于GC
oldTab[j] = null;
//判断是否有链表,如果没有,则将老的数组中的第一个元素放到新的数组中
//它会放到新数组的第0个位置或者第16(0 + oldCap)的位置
if (e.next == null)
/**
* e.hash & (newCap - 1) : 要么放到原位置,要么放到【原位置 + oldCap】的位置
*/
newTab[e.hash & (newCap - 1)] = e;
//判断是否为树节点
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
/**
* 处理链表
*/
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//整个do-while循环结束,也就是链表移动结束了,然后跳出do-while循环后在进行真正的移位,移动到新的数组中
do {
//第一次进来:将桶中第一个元素的下一个元素赋给next,循环条件
next = e.next;
/**
* 当前元素的hash值&旧数组容量的值 如果是0,那么表示当前元素
* 会放到新数组的原位置(在旧数组的哪个位置,就放到新数组的哪个位置)
*
* 如果不是0,那就会放到新数组(旧数组的原位置 + 旧数组容量)的位置
*
* 整个if-else的逻辑是:将整个链表分组
* 1)将计算出放到新数组原位置的,先放到loHead--loTail 链表中
* 2)将计算出放到新数组[原位置+oldCap)位置的,先放到 hiHead--hiTail 链表中
* 参考下面的链表拆分图
*
*/
if ((e.hash & oldCap) == 0) { //原位置
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //原位置 + oldCapacity
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//处理放到原位置的链表
if (loTail != null) {
loTail.next = null;
//将放到原位置的链表数据整体放到新数组的newTab[j]的位置
newTab[j] = loHead;
}
//处理放到【原位置+oldCap】位置的链表
if (hiTail != null) {
hiTail.next = null;
//将放到【原位置 + oldCap】位置的链表数据整体放到新数组的newTab[j + oldCap]的位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
do-while的逻辑如图所示:
do-while的逻辑就是将原来的链表进行分组,扩容后放到原位置的为一组链表,放到【原位置+oldCap】位置的为一组链表。
将链表分组好后,然后将他们各自放到新的数组的桶中,整体如图所示:
这里说一下,如果是树节点在扩容时它该如何处理?
它是这样做的,首先遍历树的所有节点,将其转成双向链表,然后按照【hash & oldCap】是否为0,分成2组,为0的一组放到新数组的【原位置】,不为0的一组放到新数组的【原位置+oldCap】位置。
前面我们在分析put的时候又看到,如果链表长度大于8并且数组长度大于64,此时会将链表转成红黑树,但是,在真正转成红黑树之前他会先将单向链表转成双向链表。
4.3 remove()移除方法
理解了put()方法后,再来看remove()方法就相对简单了,具体做法是:先根据key的hash值找到元素的位置,如果是链表则遍历链表找到元素后删除;如果是红黑树则遍历树找到元素后删除,删除后如果树节点树太小,则将红黑树转成链表。
public V remove(Object key) {
Node<K,V> e;
//hash(key):计算当前key的hash值
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
核心逻辑:
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;
/**
* 如果数组为空,或者 tab[index = (n - 1) & hash] 没有元素,则直接返回退出
*
* 注意:p = tab[index = (n - 1) & hash] 已经完成了对p的赋值
*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
/**
* 如果if=true,说明数组中的元素(p)就是将要删除的元素,将p赋给node
* 如果if=false, 说明p不是要删除的元素,然后看eles if
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
/**
* 看下p.next 是否有点,如果有,可能是树节点,还有可能是链表
* 1)如果是树节点,则根据hash和key获取节点数据
* 2) 如果不是树节点,则遍历链表,找到链表节点数据
*/
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;
}
/**
* 如果if=true,则 p是e(node)的前一个
* 如果if=false,则 p就是e
*/
p = e;
} while ((e = e.next) != null);
}
}
//以上操作,已经获得了node节点(当然前提是这个key存在,因为有可能这个key的hash一样,但是key并不存在)
//如果node == null, 说明当前要删除的key不存在,直接退出
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);
/**
* 如果 node == p, 说明数组中的节点就是要移除的节点,直接将数组节点的下一个
* 放到数组中,至于下一个是否为空,并不关心
*/
else if (node == p)
tab[index] = node.next;
/**
* 来到这里,说明要移除的元素是链表
* 将直接将p.next 指向node的next, 跳过node即可
*/
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
4.4 get()方法
理解了put()方法后,再来看get()方法就相对简单了,整个过程如下:
- 根据key计算hash值,然后通过hash值获取映射到的桶
- 如果桶上的key就是要查找的key,则直接返回这个key对应的value
- 如果桶上的key不是要查找的key,则查看后续的节点:
a. 如果后续的节点是红黑树节点,则遍历红黑树获取当前key对应的value
b. 如果后续的节点是链表,则遍历链表获取当前key对应的value
public V get(Object key) {
Node<K,V> e;
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;
/**
* 如果数组为空,或者 tab[index = (n - 1) & hash] 没有元素,则直接返回退出
*
* 注意:p = tab[index = (n - 1) & hash] 已经完成了对p的赋值
*/
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果桶中的元素就是要查找的key,则直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//桶中的节点有下一个(next)元素,说明不是链表就是红黑树
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;
}
5.多线程下的数据覆盖(丢失)
我们知道,JDK7中put链表元素时采用的"头插法",容易出现死循环,至于原因,我就不看了,可以参考文档
而JDK8中put元素时采用的尾插法,避免了死循环的问题,但是仍然会出现数据覆盖的问题。我们看下代码:
假设线程t1判断桶中元素为null,然后成功进入,但是当t1在设置元素之前,CPU切到线程t2,t2也判断当前桶位是null,于是也成功进入,然后CPU时间片切回t1,t1成功设置数据,然后CPU又切到t2,然后t2也put元素,然后就把t1的数据给覆盖了。
6.总结
- 数组的长度是2的N次幂,如果指定的容量不是2的N次幂,底层通过位运算取大于指定容量最小的2的N次幂。(比如 7 ---> 8, 10 ---> 16)
- 数组容量计算:initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loader factor)默认为 0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。
- 当链表长度大于8并且数组长度大于64,才会将链表转成红黑树
- 当红黑树的元素小于6,则将红黑树转成链表
- 由于数组容量是2的N次幂,通过位运算将大大提高“元素放哪个桶”的效率(位运算的效率高于取模),同时也避免了扩容后重新计算hash;扩容后元素要么放到新数组的【原位置】,要么放到新数组的【原位置 + oldCap】位置。
好了,关于HashMap的知识就整理到这里吧。
限于作者水平,文中难免有错误之处,欢迎指正,勿喷,感谢感谢