哈希表+ JDK 1.8 HashMap源码学习记录
一、什么是哈希表
哈希表(英文名字为Hash table
,也可以翻译为散列表),是根据关键码的值而直接进行访问的数据结构。其实数组就是一张哈希表。哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,下标是索引,存的内容是元素。
那么哈希表能解决什么问题呢?
一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。枚举的话时间复杂度是O(n),但如果使用哈希表只需要**O(1)**就可以做到。
我们只需要初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到了hash function ,也就是哈希函数。
1.1 哈希函数
如果我们想要查找学校所有学生中是否包含学生姓名,可以把学生的姓名直接映射为哈希表上的索引,就可以通过查询索引下标快速知道这位同学是否在这所学校里了。
哈希函数如下图所示,通过hashCode
把名字转化为数值,一般hashcode
是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
如果hashCode
得到的数值大于 哈希表的大小了,也就是大于tableSize
了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模(%)的操作,二进制中可以用位运算来执行取模操作,文章的后面会说到,取模完我们就保证了学生姓名一定可以映射到哈希表上了。
1.1.1 哈希碰撞
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
如果在索引的位置发生了冲突,那把发生冲突的元素都存储在链表中。 这样我们就可以通过索引找到小李和小王了。拉链法是要选择适当的 哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
使用线性探测法,一定要保证tableSize
大于dataSize
。 我们需要依靠哈希表中的空位来解决碰撞问题。例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize
一定要大于dataSize
,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
总结
总结一下,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。如果遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
二、HashMap类
HashMap
是基于哈希表的Map
接口实现,是以key-value
储形式存在,即主要用来存放键值对。同时HashMap
的实现是不同步的,这意味着它不是线程安全的,即任一时刻可以有多个线程同时写HashMap
,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections
的synchronizedMap
方法使HashMap
具有线程安全的能力,或者使用ConcurrentHashMap
。HashMap
最多只允许一条记录的key
为null
,允许多条记录的value
为null
此外,HashMap
中的映射不是有序的。
它根据键的**hashCode
值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度**,但遍历顺序却是不确定的。
2.1 继承关系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
- Cloneable 空接口,表示可以克隆
- Serializable 序列化
- AbstractMap 提供Map实现接口
2.2 底层数据结构
1.8以前:在JDK1.8之前的HashMap
由数组+链表 组成, 数组是HashMap
的主体,链表主要是为了解决哈希冲突(两个对象调用HashCode()方法计算的哈希码值一样导致数组索引相同)。当创建HashMap结合对象时,是在构造方法中创建一个长度为16的Entry[] table
数组来存储键值对数据的。对于每一个Entry
对象,当在数组中时,它是数组中的一项,当哈希冲突时,用链地址法解冲突时它是链表的一个节点。
注意:1.8
以前,采用的是头插法将数据插入链表。
1.8
以后:在解决冲突时有了较大的变化,当链表长度大于阈值TREEIFY_THRESHOLD
(红黑树的边界值,默认为8)并且当前数组长度大于64时,此时这个索引位置上的所有数据改为使用红黑树存储。此时数据结构变为数组+链表+红黑树
注意:1.8
中,采用的是尾插法将数据插入链表。
在Java 1.8
中如果桶数组的同一个位置上的链表数量超过一个定值,则整个链表有一定概率会转为一棵红黑树。
2.2.1 HashMap中的table桶数组
我们看见HashMap中有一个数组为**transient Node<K,V>[] table
**,这个就是HashMap真正存数据的数组,table数组存储Node,而Node的本质就是一个映射(键值对),我们看一下Node的实现:
static class Node<K,V> implements Map.Entry<K,V> {
//Node是Entry的实现类
final int hash; //hash为这个Node的哈希值,也就是其存放在table数组中的下标。
final K key; //key就是我们写入的键值。
V value; //value就是我们写入的key对应的值。
Node<K,V> next; //next存放的是当前Node的next,因为我们知道HashMap的存储结构是链表加红黑树,所以这个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) {
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;
//key和value都相等,两个Map.Entry才相等
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
当链表长度大于阈值(红黑树的边界值,默认为8)时,采用链式结构查找性能是O(n),而树结构能将查找性能提升到O(log(n))。
//HashMap中红黑树的数据结构
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);
}
/**
* Returns root of tree containing this node.
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
...太长了就不贴了
}
三、HashMap的工作过程
3.1 需要了解的知识
二进制中,如果**模数为2的正整数次幂
,可以用位运算来执行取模操作**,比如为了取数 n%8,可以转而求数 n&7,而对于 n%5 则没有对应的操作了,因为6不是2的正整数次幂。
X % 2^n = X & (2^n - 1)
一个数对2^n
取模==
一个数和(2^n - 1)
做按位与运算 。
x/2^n
:是指二进制的x低位向 右移n位,高位0补齐(正数)。
x%2^n
:x/2^n
中被移掉的部分
假设n为3,则2^3 = 8,表示成2进制是1000。2^3 -1 = 7 ,即0111。此时X & (2^3 - 1)
就相当于取X的2进制的最后三位数。
从2进制角度来看,X / 8相当于 X >> 3,即把X右移3位,此时得到了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数。
3.1.1 用位运算代替取模运算的好处:
- 位运算直接对内存数据进行操作,不需要转成十进制,因此位运算(&)效率要比代替取模运算(%)高很多。
- 很好的解决负数的问题,
hashcode
的结果是int
类型,而int
的取值范围是-2^31 ~ 2^31 - 1
,即[-2147483648, 2147483647
];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免对负数取模的麻烦。首先,不管hashcode
的值是正数还是负数。n-1
这个值一定是个正数(DEFAULT_INITIAL_CAPACITY
默认为16
)。那么,他的二进制的第一位一定是0
(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。
3.2 new HashMap()
的过程
在JDK 1.8
中,在调用new HashMap()
的时候并没有分配数组堆内存,只是做了一些参数校验,初始化了一些常量
//Node[] table 数组的长度默认值,为2^4,也就是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//Node[] table 数组的长度的最大值,为2^30,必须为2的整数幂,不可修改
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的加载因子为0.75,为时间和空间复杂度的平衡值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//用于判断是否需要将链表转换为红黑树的阈值
//**链表长度**的阈值,默认为8
static final int TREEIFY_THRESHOLD = 8;
//扩容时**取消树形化**的阀值,
static final int UNTREEIFY_THRESHOLD = 6;
//**可以被树形化**的最小数组容量
static final int MIN_TREEIFY_CAPACITY = 64;
//存放数据的底层Node数组
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
//map中实际存放键值对的数量
transient int size;
//map结构被修改的次数
transient int modCount;
//HashMap 所能容纳的 **最大的 键值对的个数** ,在容量capacity(数组长度)和loadFactor(加载因子)确定的情况下的阀值,
//超过此阀值就会调用resize()方法进行**扩容**,扩容为原来的2倍
int threshold;
//负载因子,可在初始化时显式指定。
final float loadFactor;
//第一个是初始容量,第二个是负载因子
public HashMap(int initialCapacity, float loadFactor) {
//初始容量小于0,抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果设置的初始容量大于Node[] table 数组的长度的最大值,设置initialCapacity为最大值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//把传入的负载因子赋值给成员的负载因子
this.loadFactor = loadFactor;
//tableSizeFor()方法会把我们传入的初始容量转换为大于等于的**2的正整数次幂**,比如15转换为16,17转换为32,使HashMap 所能容纳的 **最大的 键值对的个数始终为2的n次幂**
this.threshold = tableSizeFor(initialCapacity);
}
HashMap
的 数组Node[] table
初始化的长度capacity
是16
,哈希桶数组的 length 大小必须是2的正整数次幂,这种设计主要是为了在取模和扩容时做优化,同时为了减少冲突。loadFactor
为负载因子,默认值是0.75
,0.75
是对时间和空间效率的一个平衡选择,建议不要修改,除非在时间或者空间上比较特殊的情况下。例如:如果 内存空间很多 而又对 时间效率要求很高 ,可以降低负载因子loadFactor
的值,相反,如果内存空间较少而又对时间效率要求不高,可以增加负载因子loadFactor
的值,这个值可以大于1threshold
是HashMap
所能容纳的 最大的 键值对的个数 ,注意和Node[] table
数组的容量capacity
做区分,他们之间的关系:threshold = capacity * loadFactor
,也就是说capacity
数组一定的情况下,**负载因子越大,所能容纳的键值对个数越多,**超出threshold
这个数目就重新resize
(扩容),扩容后的HashMap
的容量是之前的2倍。
注意:threshold = capacity * loadFactor
(*DEFAULT_LOAD_FACTOR* * *DEFAULT_INITIAL_CAPACITY*)
,是 HashMap
所能容纳的 最大的 键值对的个数 。在第一次put
创建数组时会把threshold
赋值给数组容量,在这里把threshold
替换成capacity * loadFactor
。
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;
}
3.2.1 tableSizeFor`原理:
我们假设n
(cap -1
)对应的二进制为000001xxxxxx
,其中x代表的二进制位是0是1我们不关心,
n |= n >>> 1;
执行后n
的值为:
此时n
的二进制最高两位
已经变成了1(1和0或1异或都是1),再接着执行第二行代码:
n
的二进制最高四位已经变成了1,等执行完n |= n >>> 16;
之后,n
的二进制 最低位全都变成了1,也就是让n = 2^x - 1
其中x和n的值有关,如果n
没有超过MAXIMUM_CAPACITY
,最后tableSizeFor
会返回n+1(假设n
(cap -1
)对应的二进制为000001xxxxxx
),也就是2^x(n+1 = 2^x
,2的正整数次幂)。因此tableSizeFor
的作用通过位运算就是保证返回一个比参数大的最小的2的正整数次幂,赋值给threshold
。
3.2.2 其他的构造方法
一个参数的构造方法
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
这里实际是用默认的加载因子和传入的容量去调用两个参数的构造方法
空参构造
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
只把默认的加载因子赋给loadFactor
其余的在第一次put时调用resize()方法完成
带Map类的构造器
public HashMap(Map<? extends K, ? extends V> m) {
//给加载因子赋默认值
this.loadFactor = DEFAULT_LOAD_FACTOR;
//把传入的map集合里的数据加入到当前的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) { // 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 {
// Because of linked-list bucket constraints, we cannot
// expand all at once, but can reduce total resize
// effort by repeated doubling now vs later
while (s > threshold && table.length < MAXIMUM_CAPACITY)
//扩容
resize();
}
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
//吧每一个put进当前HashMap
putVal(hash(key), key, value, false, evict);
}
}
}
3.3 HashMap计算哈希值/索引的方法——hash算法(也称为高位运算算法)
我们对于每个传入的键值对进行哈希算法,目的就是让他们在HashMap桶数组中分布的更加均匀,便于查找。在HashMap
这个特殊的数据结构中,**hash
函数承担着寻址定址**的作用。其性能对整个HashMap
的性能影响巨大,那什么才是一个好的hash
函数呢?
- 计算出来的哈希值足够散列,能够有效减少哈希碰撞
- 本身能够快速计算得出,因为
HashMap
每次调用get
和put
的时候都会调用hash
方法
JDK1.8:该方法主要是将Object转换成一个整型。
static final int hash(Object key) {
int h;
//先将key转换成哈希值,然后进行1次位运算+1次异或运算进行了2次扰动处理尽量避免hash冲突。
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
小结
一个问题:为何不直接用hashcode,
而使用(h = key.hashCode()) ^ (h >>> 16)
?
这个位运算其实是将key.hashCode()
计算出来的hash
值的高16位与 低16位继续异或,为什么要这么做呢?
我们知道hash
函数是为了确定key
在桶数组中的位置,在JDK
中为了更好的性能HashMap
使用位运算代替了**取模运算(%),**通常会这样写:
index =(table.length - 1) & key.hash();
//回忆前文中的内容,table.length是一个**2的正整数次幂**,类似于000100000,这样的值减一就成了000011111,
//通过这样的**位运算来快速寻址定址**。
可以看到hash()
计算出来的哈希值都要与table.length - 1
做&
运算,那就意味着计算出来的hash
值只有低位参与index的运算, 更容易发生hash冲突。比如:11111 1000
和0001 1000
在对0000 1111
进行**&运算后的值是相等的**。因此需要高位运算来解冲突,在hash()
中让高16位与低16位做异或,让低位保留部分高位信息,防止不同hashCode
的高位不同但低位相同导致的hash
冲突,来减少哈希碰撞。
简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
3.3 put确定插入数据的位置
public V put(K key, V value) {
// 调用我们刚刚分析过的hash方法
return putVal(hash(key), key, value, false, true);
}
在put方法中,我们先对key进行哈希运算,然后作为参数传到putval方法中,
putVal方法的后两个参数一个是是否不覆盖旧的值,一个是给HashMap的子类LinkedHashMap用的。
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为空,即第一次put时,
if ((tab = table) == null || (n = tab.length) == 0)
//调用resize()方法扩容并创建并初始化出table数组
n = (tab = resize()).length;
// 根据数组长度和哈希值相与来寻址,原理刚刚分析过
//步骤2:计算出索引值index,如果index位置没有元素(没有哈希碰撞)
if ((p = tab[i = (n - 1) & hash]) == null)
// 没有哈希碰撞,直接在该索引插入数据
tab[i] = newNode(hash, key, value, null);
//否则,意味着出现了哈希冲突(碰撞)
else {
Node<K,V> e; K k;
//步骤3:如果key存在相同,hash也相同(就是一个节点),则直接覆盖value值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//步骤4:如果p的数据结构已变成红黑树,用putTreeVal红黑树处理
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 步骤5:哈希碰撞的链表处理
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//如果一直没找到Key,链表的下一个节点为null,创建新的节点加到表尾
p.next = newNode(hash, key, value, null);
// 链表过长,调用treeifyBin方法转换为树结构
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果遍历时找到了key,并且hash与链表中节点hash,break跳出循环,在下面会进行值的覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 否则,指针后移,继续后循环
p = e;
}
}
//步骤6:存在该key并且hash相等的话,覆盖原值
if (e != null) { // existing mapping for key
//对应着上文中节点已存在,跳出循环的分支,直接替换
//如果出现重复值,e指向的是要修改的node
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;//替换新的value
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//修改次数
//步骤7:判断如果超过阈值,还需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在JDK 1.8
中put
这个方法的思路分为以下几步:
- 调用
hash
方法计算哈希值,并根据这个hash值计算出数组下标index
- 如果发现当前的table数组为
null
,则调用resize()
方法进行初始化 - 如果没有发生哈希碰撞,则直接放到索引对应的元素中
- 如果发生哈希碰撞,且节点已经存在(key存在相同,hash也相同),就替换掉相应的
value
- 如果发生哈希碰撞,且元素中存放的是树状结构,则挂载到树上
- 如果碰撞后为链表,如果该链是链表,则会进行遍历。
如果链表长度超过TREEIFY_THRESHOLD
(默认是8),则将链表转换为树结构
如果一直没有找到Key,则newNode方法创建节点放到链的末尾。然后判断是否要树化。
如果找到了则break。 - 如果在上面的for中找到了Key,则实现替换。
- 数据
put
完成后,如果HashMap
中存放键值对的总数超过threshold
就要resize
这里有一个小细节,HashMap
允许put
key为null
的键值对,但是这样的键值对都放到了桶数组的第0个桶中。
3.3.1 取模运算
取模运算是用于获取到存在数组中的索引位置
// n是table数组的长度,hash是哈希算法的返回值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
这是put方法里一段代码,索引位置为(n - 1) & hash
由于table
数组的容量capacity(n)
是2的正整数次幂,初始值是16,之后每次扩充为原来的2倍。所以(n - 1) & hash == hash % n
(因为(2^n - 1) & X = X % 2^n),这里用位运算代替了取模运算
定位的基本原理:调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap的容量进行取模运算
3.4 HashMap的树化treeifyBin方法
当table上的某一个链达到了树化的条件,就通过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);
}
}
当数组的长度较小,应该尽量避开红黑树。因为红黑树需要进行左旋,右旋,变色操作来保持平衡, 所以当数组长度小于64,使用数组加链表比使用红黑树查询速度要更快、效率要更高。
//真正的树化操作是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);
}
3.5 resize()
resize
方法有两种使用情况:初始化哈希表与当前数组容量过小,需扩容。resize
是整个HashMap
中最复杂的一个模块,如果在put
数据之后HashMap
中存储的元素大小超过了threshold
(capacity * loadFactor
),就需要扩容以减小哈希碰撞,扩容意味着桶数组大小变化,前文中分析过,HashMap
寻址是通过index =(table.length - 1) & key.hash();
来计算的。
现在table.length
发生了变化,势必会导致部分key
的位置也发生了变化,HashMap
是如何设计的呢?也就是说为什么要和老的数组长度&?
这里就涉及到table数组长度为2的正整数幂的第二个优势了:数组长度都是2
的幂次方,都是00000100000
这种的,所以(e.hash & oldCap)
的值是否为0,完全取决于oldCap
里面的唯一的一个"1",要么是0(低位),要么是1(高位),要么在原来的下标不变,要么是oldindex+oldCap
(原来的下标位置+原来的数组长度)。当数组发生扩容(长度翻倍),则桶数组中的元素大概只有一半需要切换地址到新的桶中,这个概率可以看做是均等的。
这个设计非常的巧妙,即省去了重新计算hash值的时间,而且同时,由于新增的 1 bit 是0还是1可以认为是随机的,因此 resize 的过程,均匀的把之前的冲突的节点分散到新的table
了,这就是JDK1.8新增的优化点。
通过图中分析可以看到如果在即将扩容的那个位上key.hash()
的值的二进制值为0,则扩容后在桶中的地址不变,key.hash()
的值的二进制值为1的话,扩容后的最高位也变为了1,新的地址也可以快速计算出来newIndex = oldCap + oldIndex;
。不使用newcap的原因是newcap太大了,会导致本该是高位的节点去了低位。
下面是Java 1.8
中的实现:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//oldCap记录原table的length
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//oldThr记录原阈值
int oldThr = threshold;
//新的长度,新的阈值
int newCap, newThr = 0;
//如果原来table不为null,说明是扩容操作
if (oldCap > 0) {
//如果oldCap > 0则对应的是扩容而不是初始化
//oldCap超过最大数组长度,无法扩容,把threshold设置为z^31-1,返回旧的数组
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//没有超过最大值,就扩大为原先的2倍
//数组长度capacity和阈值threshold都扩大为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//oldCap为0,但是oldThr不为0,则代表的是table还未进行过初始化(之前没put,没resize),
else if (oldThr > 0) // initial capacity was placed in threshold
//新的数组长度直接设置为旧的阈值(应用中就是调用一个参数的构造方法)
newCap = oldThr;
//旧的长度,阈值为0(应用中就是调用空参构造)
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的阀值还没有定,确定新的阀值
if (newThr == 0) {
// 如果到这里newThr还未计算,比如初始化时,则根据容量计算出新的阈值
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;//将新数组赋值给table
//如果是扩容操作,把原数组上的数据要迁移到新数组
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 { // preserve order
// 如果当前元素是链表,定义了一些低位/高位链表头尾
Node<K,V> loHead = null, loTail = null;
// hiHead和hiTail代表元素在新的桶中和旧的桶中的位置不一致
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);
//**原节点加入不需要移动的链表,将原节点赋值给新的桶**
if (loTail != null) {
loTail.next = null;
// loHead和loTail代表元素在新的桶中和旧的桶中的位置一致
newTab[j] = loHead;
}
//新节点加入需要移动的链表,**转换的链表赋值给新的桶**
if (hiTail != null) {
hiTail.next = null;
// 新的桶中的位置 = 旧的桶中的位置 + oldCap, 详细分析见前文
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容对于红黑树的处理:
1.8的扩容中,如果遇到红黑树,先把红黑树变成链表,和链表的处理相同,当小于树形化阀值UNTREEIFY_THRESHOLD
(6)时变为链表,否则变为红黑树。
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
//将红黑树拆分成两个链表,这里的逻辑和链表扩容差不多
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//不需要移动的链表
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
//需要移动的链表
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
//原节点处理
if (loHead != null) {
//小于树形化阀值时,链表处理
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
//否则,再次变为红黑树
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
//新节点处理
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
3.5 get()方法源码分析
我们可以根据键key,向HashMap获取对应的值:map.get(key)。
- 调用
HashMap.get()
的时候会首先计算参数key
的hash值,继而在数组中找到key
对应的位置,然后遍历该位置上的链表找相应的值。
public V get(Object key) {
Node<K,V> e;
//计算出key的哈希值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
通过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;
if ((tab = table) != null && (n = tab.length) > 0 &&
//(n - 1) & hash计算存放在数组table中的位置
(first = tab[(n - 1) & hash]) != null) {
//数组中节点直接就是要找的节点,key相等hash也相等,直接返回
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);
//首节点是链表,按链表的查找方式找
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,如果是直接返回
- 遍历红黑树
- 遍历链表
总结
-
HashMap
内部的**table
**数组长度为什么一直都是2的整数次幂答:这样做有两个好处,第一,可以通过
(table.length - 1) & key.hash()
这样的位运算快速寻址,第二,在HashMap
扩容的时候可以保证 同一个桶中的元素均匀的散列到新的桶 中,具体一点就是同一个桶中的元素在扩容后一半留在原先的桶中,一半放到了新的桶中。 -
HashMap
默认的**table
**数组是多大答:默认是
16
,即使指定的大小不是2的整数次幂,HashMap
也会找到一个最近的2的整数次幂来初始化桶数组。 -
HashMap
什么时候开辟**table
**数组占用内存答:在第一次
put
的时候调用resize
方法 -
HashMap
何时扩容?答:当
HashMap
中的元素熟练超过阈值时,阈值threshold
计算方式是capacity * loadFactor
,在HashMap
中loadFactor
是0.75
-
桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?
答:当同一个桶中的元素数量大于等于
8
的时候元素中的链表转换为红黑树,反之,当桶中的元素数量小于等于6
的时候又会转为链表,这样做的原因是避免红黑树和链表之间频繁转换,引起性能损耗 -
Java 8
中为什么要引进红黑树,是为了解决什么场景的问题?答:引入红黑树是为了避免
hash
性能急剧下降,引起HashMap
的读写性能急剧下降的场景,正常情况下,一般是不会用到红黑树的,在一些极端场景下,假如客户端实现了一个性能拙劣的hashCode
方法,可以保证HashMap
的读写复杂度不会低于O(lgN)public int hashCode() { return 3; }
-
HashMap
如何处理key
为null
的键值对?答:放置在桶数组中下标为0的桶中
-
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
值得不可更改性和计算准确性。 -
为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化