目录
2.Q:为什么Hashcode 要 (hashcode >>> 16) ^ hashcode?
一、概述
- HashMap,基于Hash算法实现的一种map。
- HashMap 即是一种数据结构,也是一种算法。
- JDK7 和 JDK8 的区别
- 介绍一下HashMap里的一些名词:
-
负载因子:实际元素个数 / 申请的元素空间,比如70个元素,申请了100个元素的空间,70 / 100 = 0.7。
-
桶(bucket):Hash函数计算的结果是一个存储单元,每一个存储单元就是一个bucket。
-
同义词:经过Hash函数后结果相同的多个key。
二、分析
1.数据结构
- 数组
- 【why】存储方便,数组占据一块连续的内存空间,查找速度快
- 链表
- 【why】
- 基于Hash算法,有Hash冲突,链表就是为了解决hash冲突
- 链表不需要连续空间,而且增加和删除的效率高
- 【缺点】链表的查找效率低,存在最坏的情况,遍历整个链表,如果hash冲突非常严重,则会导致查找性能非常的差。
- 【why】
- 红黑树(JDK1.8)
- 【why】确保最坏的链表查询情况时,提升查询效率
2.Hash 算法
1.什么是Hash?
Hash算法,是一种从数据中获取数据摘要的算法,即将输入的数据映射到一个短字符中。
理想状态下,通过Hash函数生成的HashCode、数据摘要或者数据指纹,是唯一且不可逆的。
散列表,它是基于高速存取的角度设计的,也是一种典型的“空间换时间”的做法。顾名思义,该数据结构能够理解为一个线性表,可是当中的元素不是紧密排列的,而是可能存在空隙。散列表根据关键码值直接进行访问的数据结构,键值通过映射函数(即Hash函数)来定位到散列表中的一个位置,来提高查找速度,其中存储数据的数组叫散列表。
2.Hash冲突
Hash算法是唯一的,相同的输入和输出时一致的,但是不同的输入对应相同的输出,即不同的key对应相同的bucket,散列函数只能尽可能的减少碰撞冲突,没有办法完全消除冲突。
3.Hash函数
经常构造散列函数的方法:
1). 直接寻址法:取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key) = a•key + b,当中a和b为常数(这样的散列函数叫做自身函数)
2). 数字分析法:分析一组数据,比方一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体同样,这种话,出现冲突的几率就会非常大,可是我们发现年月日的后几位表示月份和详细日期的数字区别非常大,假设用后面的数字来构成散列地址,则冲突的几率会明显减少。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
3). 平方取中法:取keyword平方后的中间几位作为散列地址。
4). 折叠法:将keyword切割成位数同样的几部分,最后一部分位数能够不同,然后取这几部分的叠加和(去除进位)作为散列地址。
5). 随机数法:选择一随机函数,取keyword的随机值作为散列地址,通经常使用于keyword长度不同的场合。
6). 除留余数法:取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p, p<=m。不仅能够对keyword直接取模,也可在折叠、平方取中等运算之后取模。对p的选择非常重要,一般取素数或m,若p选的不好,容易产生同义词。
3.HashMap 的put过程,扩容过程
JDK1.7:头插法;(存在死循环)
JDK1.8:尾查法;
4.HashMap 的死循环问题(JDK1.7)
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
【产生原因】多线程扩容,头插法;
【单线程】
【多线程】
【JDK1.8】使用的尾插法, 链表的最后节点的next 是 null,不会产生死循环问题
5.HashMap 的线程安全问题
多线程-线程在java语言中是不可受控制的(不一定按照代码的执行,业务代码的执行)
【原因】JVM 内存模型的问题,线程共享、线程私有的;
【线程安全】HashMap 是非线程安全的;ConcurrentHashMap是线程安全的;
【ConcurrentHashMap】
JDK1.7 分段锁;
JDK1.8;
三、源码分析(JDK.18)
1.常量
// 默认的初始化容量,buckets数组的桶位,2^4=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 最大的容量,buckets数组的桶位,2^30=1 073 741 824
static final int MAXIMUM_CAPACITY = 1 << 30;
// 由链表转换成树的阈值(一个桶中bin(箱子)的存储方式由链表转换成树的阈值。即当桶中bin的数量超过TREEIFY_THRESHOLD时使用树来代替链表。)
static final int TREEIFY_THRESHOLD = 8;
// 由树转换成链表的阈值(当执行resize操作时,当桶中bin的数量少于UNTREEIFY_THRESHOLD时使用链表来代替树。)
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;
// 实际元素个数
transient int size;
// 散列表的负载因子
final float loadFactor;
2.HashMap使用的hash方法
最简单的方法:除留余数法,先对key进行hash得到它的hash code,然后再用该hash code对buckets数组的元素数量取余,得到的结果就是bucket的下标,HashTable使用的除留余数法,没有强制约束buckets的长度。而HashMap中的buckets数组的长度永远是2的幂。
1)Java在Object对象中提供了hashCode()函数,该函数返回了一个int值,所以任何你想要放入HashMap的自定义的抽象数据类型,都必须实现该函数和equals()函数,而HashMap对key的hashCode做了处理。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2)寻找下标方法
p = tab[i = (n - 1) & hash]
留余法:与 目标数的反数做与运算。
3.HashMap解决冲突
散列函数不可能是完美的,key完全分布均匀是不可能的,所以碰撞冲突总是难以避免。HashMap使用Separate Chaining策略来解决冲突。核心思想是:每个bucket又相当于一个独立的数据结构,当发生冲突时,将数据放入bucket中,这样查询一个key 所消耗的时间为查找bucket的时间+bucket内部查找的时间。
HashMap的buckets数组其实就是一个链表数组,在发生冲突时只需要把Entry放到链表的尾部,如果未发生冲突(位于该下标的bucket为null),那么就把该Entry做为链表的头部。
HashMap还使用了Lazy策略,buckets数组只会在第一次调用put()函数时进行初始化,这是一种防止内存浪费的做法,像ArrayList也是Lazy的,它在第一次调用add()时才会初始化内部的数组。
Java8新增了 TREEIFY_THRESHOLD、UNTREEIFY_THRESHOLD阀值优化碰撞冲突。碰撞冲突可能会频繁发生,链表也会变得越来越长,这个效率是非常差的。Java 8对其实现了优化,链表的节点数量在到达阈值时会转化为红黑树,这样查找所需的时间就只有O(log n)了。
如果在插入Entry时发现一条链表超过阈值,就会执行以下的操作,对该链表进行树化;相对的,如果在删除Entry(或进行扩容)时发现红黑树的节点太少(根据阈值UNTREEIFY_THRESHOLD),也会把红黑树退化成链表。
// 替换指定hash所处位置的链表中的所有节点为TreeNode,如果buckets数组太小,就进行扩容。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// MIN_TREEIFY_CAPACITY = 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 {
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)
// TreeNode 中树化方法
hd.treeify(tab);
}
}
// TreeNode.treeify方法
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
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);
}
4.动态扩容
散列表以数组的形式组织bucket,问题在于数组是静态分配的,为了保证查找的性能,需要在Entry数量大于一个临界值时进行扩容,否则就算散列函数的效果再好,也难免产生碰撞。
所谓扩容,其实就是用一个容量更大(在原容量上乘以二)的数组来替换掉当前的数组,这个过程需要把旧数组中的数据重新hash到新数组,所以扩容也能在一定程度上减缓碰撞。
HashMap通过负载因子(Load Factor)乘以buckets数组的长度来计算出临界值,算法:threshold = load_factor * capacity。比如,HashMap的默认初始容量为16(capacity = 16),默认负载因子为0.75(load_factor = 0.75),那么临界值就为threshold = 0.75 * 16 = 12,只要Entry的数量大于12,就会触发扩容操作。
还可以通过下列的构造函数来自定义负载因子,负载因子越小查找的性能就会越高,但同时额外占用的内存就会越多,如果没有特殊需要不建议修改默认值。
// 有参构造函数
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);
}
// 返回指定容量的最近的2次幂
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;
}
数组索引的计算方法:index = (table.length - 1) & hash,这其实是一种优化手段,由于数组的大小永远是一个2次幂,在扩容之后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。&运算只会关注n – 1(n = 数组长度)的有效位,当扩容之后,n的有效位相比之前会多增加一位(n会变成之前的二倍,所以确保数组长度永远是2次幂很重要),然后只需要判断hash在新增的有效位的位置是0还是1就可以算出新的索引位置,如果是0,那么索引没有发生变化,如果是1,索引就为原索引加上扩容前的容量。这样在每次扩容时都不用重新计算hash,省去了不少时间,而且新增有效位是0还是1是带有随机性的,之前两个碰撞的Entry又有可能在扩容时再次均匀地散布开。
扩容时机:
- 当前已有元素的个数必须大于等于阈值
- 当前存放数据发生hash碰撞
从源码的put函数中可以看出,先判断是否容量为0,然后判断是否有hash冲突,方法结束时,判断当前元素个数是否大于阀值,如果大于阀值,进行扩容,那么条件1满足,就会发生扩容。
// 扩容操作
final Node<K,V>[] resize() {
// 当前buckets数组
Node<K,V>[] oldTab = table;
// 获取原数组容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 原数组阀值
int oldThr = threshold;
// 新数组容量,新数组阀值
int newCap, newThr = 0;
if (oldCap > 0) {
// 原数组容量超过最大值不会进行扩容,并且把阈值设置成Interger.MAX_VALUE
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,扩容为原来的2倍
// 向左移1位等价于乘2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 原数组容量为0,阀值不为0,这种情况发生在用户调用了有参构造函数
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// oldCap与oldThr都为0,这种情况发生在用户调用了无参构造函数
// 采用默认值进行初始化
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果newThr还没有被赋值,那么就根据newCap计算出阈值
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;
// 如果oldTab != null,代表这是扩容操作
// 需要将扩容前的数组数据迁移到新数组
if (oldTab != null) {
// 遍历oldTab的每一个bucket,然后移动到newTab
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 索引j的bucket只有一个Entry(未发生过碰撞)
// 直接移动到newTab
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果是一个树节点(代表已经转换成红黑树了)
// 那么就将这个节点拆分为lower和upper两棵树
// 首先会对这个节点进行遍历
// 只要当前节点的hash & oldCap == 0就链接到lower树
// 注意这里是与oldCap进行与运算,而不是oldCap - 1(n - 1)
// oldCap就是扩容后新增有效位的掩码
// 比如oldCap=16,二进制10000,n-1 = 1111,扩容后的n-1 = 11111
// 只要hash & oldCap == 0,就代表hash的新增有效位为0
// 否则就链接到upper树(新增有效位为1)
// lower会被放入newTab[原索引j],upper树会被放到newTab[原索引j + oldCap]
// 如果lower或者upper树的节点少于阈值,会被退化成链表
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 下面操作的逻辑与分裂树节点基本一致
// 只不过split()操作的是TreeNode
// 而且会将两条TreeNode链表组织成红黑树
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
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;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
5.添加元素
put函数源码:
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 {
// 发生了Hash冲突
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)
// 节点为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;
}
}
// 节点已存在,替换value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//提供给LinkedHashMap重写的函数,在HashMap中没有意义
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 超过阀值,进行扩容(从这可以看出,超过阀值一定会进行扩容)
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
6.内部类
1)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) {
V oldValue = value;
value = newValue;
return oldValue;
}
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;
}
}
2.TreeNode类
四、QA
1.Q:为什么数组的查找效率比链表快?
跟CPU的缓存有关,CPU缓存比内存快几十倍,在使用时,CPU缓存会读取内存的一片连续空间,所以可以读取到数组的全部元素或部分元素,在缓存中的操作比内存快,而链表的空间是不连续的,只能去内存中读取,此时的效率就哺乳数组快了。
2.Q:为什么Hashcode 要 (hashcode >>> 16) ^ hashcode?
A:hash算法要使hash值足够分散,而如果只是计算hashcode和数组的余数,16 - 1 (01111),只会用到4位,也就是说hashcode中实际用到的只有最后几位,所以需要进行异或运算,这样就可以使用到全部的32位。(用异或运算,结果概率为50%、50%)
3.Q:什么是红黑树?
二叉树-》二叉排序树-》平衡树