哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,本文会对java集合框架中HashMap的实现原理进行讲解,并对JDK1.8的HashMap源码进行分析。
面试官的灵魂拷问
-
谈一下HashMap的特性?
-
HashMap中hash函数是怎么实现的?
-
为什么HashMap数组的长度必须是2的N次幂?
-
HashMap的扩容机制是什么?
-
为什么红黑树和链表的转换阈值是6和8不是7呢?
-
为什么jdk1.8中新增的是红黑树而不是AVL?
什么是HashMap?
HashMap是基于哈希表的Map接口的非同步实现,是一种用于key-value的映射关系的集合,并且key和value都允许为null,且不保证顺序性。在1.7的时候底层采用数组+链表实现,1.8底层采用数组+链表+红黑树实现(以下基于1.8)。
数据结构
基本常量
//默认初始化大小 1<<4=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//加载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当链表长度大于等于该值时转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
//当红黑树的大小小于等于该值时转换回链表
static final int UNTREEIFY_THRESHOLD = 6;
//转换为红黑树时当前整个数组的大小必须大于等于这个值
//才进行转换,可以去看看转换代码treeifyBin
static final int MIN_TREEIFY_CAPACITY = 64;
构造函数
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);
}
//可传入初始容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
// all other fields defaulted 0.75f
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
上面三个构造函数中第二个函数调用调用的是第一个构造函数,第三个是默认的无参数构造。下面主要讲第一个。
在构造函数HashMap(int initialCapacity, float 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;
}
以上tableSizeFor()
这个函数的意思就是返回一个>=给定数的2^N。>>>
表示不带符号向右移动二进制数。|=
表示按二进制位或
操作,并赋值等价于a=a|b
。
以下
/**
*
* 以正数8为例
* 源码:
* 00000000 00000000 00000000 00001000
* >>>1
* 00000000 00000000 00000000 00000100
* |=1
* 00000000 00000000 00000000 00001100
* >>>2
* 00000000 00000000 00000000 00000011
* |=2
* 00000000 00000000 00000000 00001111
* >>>4
* 00000000 00000000 00000000 00000000 到这里可以看出来,后面的计算是无效的
* |=4
* 00000000 00000000 00000000 00001111
* -------------------------------------------------
* 以正数7为例
* 源码:
* 00000000 00000000 00000000 00000111
* >>>1
* 00000000 00000000 00000000 00000011
* |=1
* 00000000 00000000 00000000 00000111
* >>>2
* 00000000 00000000 00000000 00000001
* |=2
* 00000000 00000000 00000000 00000111
*/
上面整个计算过程的意思就是从高位到低位第一个1后面的数字全部置1。
为什么要把第一个1后面的数字全部置1?因为2在计算机中具有特殊的地位。如果一个数等于2^N
,这个数的二进制表示肯定只有一个1(不包括符号位)。
代码一开始为什么要减1?可以看到上面正数8
进行5次移位或等
的结果是15
,与预期初始化长度为8
大了一个级别。这里减掉一个1,在进行移位运算的时候将自己本身也包含进去。
前面移位运算的结果总是2^N-1
,所以在函数返回的时候+1
,刚好等于2^N
hash函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 :
(h = key.hashCode()) ^ (h >>> 16);
}
首先进行了>>>16位,也就是将高16位移动到低16位, 然后^
按位异或(同为0,不同为1),其运算结果高地位都参与进去了,增加了随机因子。
插入原理
因为put函数调用的直接就是putVal()方法,所以这里直接看putVal(),代码挺长;
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;
//判断数组是否存在和数组长度
if ((tab = table) == null || (n = tab.length) == 0)
//创建新的数组
n = (tab = resize()).length;
//判断该地址是否存在元素节点,并将p指向地址
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))))
//如果元素的hash相同且equals相等,那么判定为同一个元素
e = p;
//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)
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;
}
执行流程图
扩容机制
resize()源码
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;
//这里0.75*数组长度
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);
}
//设置下一次扩容阈值
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
//高低链,高链需要迁移,低链不用
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;
}
扩容机制
扩容时高低链问题
先看代码是如何通过hash定位数组下标的·
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
首先知道数组的长度永远是2^N次方,转换为2进制,只有1个1,(2^N)-1,二进制从2^N这一位的后一位开始为后面全是1,采用这种方式分布也是可以让不同的hash值定位到相同的下标上面。(注:同一个链表里面的所有节点hash不一定相同,相同hash的肯定定位到同一个链表)
再看看如何区分高链和低链的
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;
}
上面代码就是hash & oldCap
时=0时就是低链,!=就是高链,将高链偏移一个原始数组的位置即可。
(注:这里(2^N)-1
和(2^N-1)
在二进制的时候除了第一位一样,其余位全部不一样,所以偏移一个原始数组位置)
为什么转换阈值不是7?
直接看什么时候转换的
//插入时
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
//转换或者移除时,很多地方大差不差
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
转换红黑树条件>=7,转换回来条件<=6,插入的时候是已经存在了7个,当前插入的算一个,相当于间隔了一个的。如果是7的话,那么链表和红黑树之间的切换范围值就太小了。如果我的链表长度不停地在7和8之间切换,那岂不是得来回变换形态?所以选择6是一种折中的考虑。
为什么jdk1.8选用红黑树?
AVL树性质
-
本身首先是一棵二叉搜索树。
-
带有平衡条件:每个结点的左右子树的高度之差的绝对值(平衡因子)最多为1。也就是说,AVL树,本质上是带了平衡功能的二叉查找树(二叉排序树,二叉搜索树)
红黑树性质
-
每个节点,不是红色就是黑色的;
-
根节点是黑色的;
-
如果一个节点是红色的,则它的两个子节点是黑色的(没有连续的红色结点,可以有连续的黑色节点,这里意味着最长路径最差情况是最短路径的1倍)
对每个节点,从该节点到其所有后代叶节点的简单路径上,均包含相同数目的黑色节点。(每条路径的黑色结点个数相等)
每个叶子节点都是黑色的(这里的叶子节点是指的NIL节点(空节点)) 优缺点:因为不要求严格平衡,在查询效率上比AVL少许,但是在插入效率上比AVL效率高,这里增删改差的效率比较平衡;
整体来说:AVL树侧重于搜索,红黑树因为不要求严格彭亨,在插入和搜索两个功能上性能比较均匀,所以选用红黑树。
其他常见问题
Q:什么是哈希碰撞?
A:HashMap中2个对象产生相同的hashcode时就是哈希碰撞
Q:HashMap是如何解决哈希碰撞?
A:产生哈希碰撞的对象,以链表的形式存储在数组的同一个位置上
Q:HashMap的数组中同一链表上对象的hashcode都一样的吗?
A:不是,而且大多数情况下都不一样