HashMap是一个基于hash算法key-value键值对数据结构的集合,能够在时间复杂度O(1)的情况下根据key获取到对应的value。
底层采用数组+链表+红黑树的数据结构存储。
HashMap源码
/* ---------------- Fields -------------- */
// HashMap的hash表数组,HashMap存储的数据的地方
transient Node<K,V>[] table;
// HashMap调用entrySet()方法的时候回初始化创建EntrySet对象,赋值给该引用,该属性主要是为了减少调用entrySet()方法创建EntrySet的次数
transient Set<Map.Entry<K,V>> entrySet;
// HashMap中的元素个数,也就是key-value键值对的个数
transient int size;
// 记录改变HashMap的时候如调用set,put,remove等方法会新修改
transient int modCount;
// 扩容阈值=容量*扩容因子;当size>threshold即开始扩容,有该变量的目的是为了减少计算次数
int threshold;
// hash因子,扩容因子,当HashMap容量达到了
final float loadFactor;
创建HashMap
当调用new HashMap的时候,会初始化计算扩容阈值threshold 的值,并同时为hash因子赋初始值,如果没有指定,默认为0.75f。
HashMap的构造函数
HashMap提供了四个构造函数:
无参构造函数,所有都是默认值。
HashMap<String,Object> map1 = new HashMap<>();
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
只指定了hash因子为DEFAULT_LOAD_FACTOR=0.75f,其它都是默认值;这意味着HashMap的初始化容量为16,会在16*0.75=12的时候扩容。
指定了初始化容量和hash因子的构造函数,会计算最近的大于等于参数2的n次方值作为初始化容量。
// 只指定初始化容量
HashMap<String,Object> map2 = new HashMap<>(2);
// 指定初始化容量和hash因子
HashMap<String,Object> map3 = new HashMap<>(4,0.75f);
// 只指定了初始化容量
public HashMap(int initialCapacity) {
// hash因子还是为DEFAULT_LOAD_FACTOR=0.75f。
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定了初始化容量和hash因子
public HashMap(int initialCapacity, float loadFactor) {
// 初始化容量不能小于0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 初始化容量不得大于MAXIMUM_CAPACITY = 1 << 30;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// hash因子参数校验
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 赋值hash因子
this.loadFactor = loadFactor;
// 计算大于等于initialCapacity的第一个2的n次方值
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor
在创建HashMap的时候的时候计算容量容量阈值threshold的时候调用一个很关键的方法,也就是tableSizeFor,该方法的作用了计算大于等于参数的第一个2的n次方值;
如:
tableSizeFor(5) = 8; 大于5的第一个2的n次方值为2^3=8;
tableSizeFor(16) = 16; 如果参数本就为2的n次方,则不变
这个方法的源码很精彩,值得研究一下。
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;
}
int n = cap - 1;// 容量-1,最后返回的时候会+1,目的就是为了还原cap=2^n情形。
思路:
这种算法主要是通过位移加2^n二进制特性,(2^n)-1二进制正好是n个1。
如:8 = 2^3 = 1000 => 8-1 = 7 = 111 (正好是3个1)
因此如果我们要求出大于当前值的第一个2^n只需要当前值最高位右边所有二进制变为1,最后再加1就能达到效果。
如:9 = 1001
int n = cap - 1; => 8
n |= n >>> 1; => 1001 | 0100 => 1100
n |= n >>> 2; => 1100 | 0010 => 1110
n |= n >>> 4; => 1110 | 0001 => 1111
n |= n >>> 8; => 1111 | 0000 => 1111
n |= n >>> 16; =>1111| 0000 => 1111
最后就得到了结果1111=15
最后返回结果n+1等于16,正好是大于等于9的第一个2^n值。
这种方式的本质就是利用二进制最高位1不停的右移,进行或运算使得小于最高位的二进制全部变成1。
上面分析,可以看出在位移4的时候就已经得到了结果为什么要一直位移到16位?
这是因为一个int占用4个字节,32位,去掉最高位符号位,还有31位,而这里位移次数为:1+2+4+8+16=31正好满足int范围内的所有值。
将已有的Map转为HashMap
HashMap<String,Object> map4 = new HashMap<>(map1);
public HashMap(Map<? extends K, ? extends V> m) {
// 设置默认hash因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
// 初始化并将Map放入当前HashMap中
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
if (s > 0) {
// 初始化
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);
}
// 如果放入的Map元素大于当前HashMap的扩容阈值,则扩容
else if (s > threshold)
resize();
// put进当前HashMap
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);
}
}
}
put方法
put方法承担了HashMap的添加和更新节点功能
流程
public V put(K key, V value) {
// 调用hash函数,计算key的hash值
return putVal(hash(key), key, value, false, true);
}
hash(key)
hash方法,通过key的hash值高16位与第16位异或运算,得出新的hash值用来计算在hash表的索引
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash扰动
为什么要对hash值再一次计算,并且要用异或运算符(^)计算而不用与(&)、或(|)运算符?
这种做法叫做hash扰动。这样做的目的是为了让hash分配更加均匀,这是因为计算key的hash槽索引是通过hash&(n-1)得出的(n为hash表长度),如果hash表长度较短的时候,则能够参与运算的位就比较少。
如:hash表长度为16,则16-1=00000000000000000000000000001111;那么此时做hash运算,永远只能计算到低4位,因为高位都是0,做&运算结果都是0。那么此时元素的hashCode如果不做扰动,那么元素的hashCode永远都只有低4位能够参与运算,这样就丢失了hashCode的高位特征。
为了完全利用hash值和hash表长度特性,降低hash碰撞几率,因此才进行扰动。
举个例子:当前两个对象的hash值为:
a=1010111101010010 1010101010101010
b=1111001101010010 1010101010101010
它们的低16位值相同,但是它们的高16位不相同,那么如果不做扰动,则在HashMap的数组长度低于2^16次方的时候,它们的计算出来的hash值是相同的;如果它们做了扰动这时候它们的低16位就不相同了,所以他们的hash值也就可能不相同,减少了碰撞记录。
hash扰动为什么要用^
为什么扰动函数运算要用异或运算(^)?
异或运算能更好的保留各部分的特征,如果采用&运算计算出来的值会向0靠拢,采用|运算计算出来的值会向1靠拢。 hash算法的特征就是要尽量保证值散列得足够均匀,如果向某一个值靠拢了,那么发生hash碰撞的几率就增大了。
网上都是说&向1靠拢,|向0靠拢。我觉得应该是反了,因为&运算只有两个值完全相同的情况并且为1结果才是1,其他情况都是0,很明显为0的概率大于了为1的概率;|运算两个值其中一个为1那么结果就为1,只有两个值都为0的情况才会为0,因此为1的概率大于为0的概率;而使用^运算只有两种状态,相同为0,不同为1。
举个例子:当前对象hash值为
a=1010111101010010 1010101010101010
如果使用|运算,那么结果靠近低16位的结果值
如果使用&运算,那么结果靠近它们不相同趋近0的值。
也就是说当两个对象的低16位相同的时候它们扰动结果也会像某个值靠拢,这就丢失了一部分扰动的特性。
为什么容量大小一定是2^n
这也是为了能够在运算hash槽的时候最大的提高性能使用&运算,不使用%运算,使用&运算的性能是%的近两倍。那么为什么容量为2^n就能够更好的使用&运算hash槽了呢?
计算hash槽代码:
i = (n - 1) & hash // n为hash表长度,hash为根据key执行了hash方法后的hash值
原因在于(2^n)-1它的二进制位都是1,这样在做与运算的时候能够完全保留hash值,这样就和上面的扰动函数配合上了。
举例:
n=16,(n-1)&12=(1111)&(1100)=12;
n=17,(n-1)&12=(10001)&(1100)=16;
由结果可知,最后都实现了n%hash的结果,返回都在0-(n-1)范围内,不会索引越界。假如容量为17它能参与运算都只有最高位和最低位,这时候任何值,计算结果无非两种状态,16或者1,就浪费了14个hash槽,极大的增加了hash碰撞,这对于HashMap来说是灾难级的。
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没有初始化,则调用resize初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果hash槽没有被占用,则直接创建一个新节点,放入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// hash槽被占用了
Node<K,V> e; K k;
// 如果hash槽的key和put的key一样,则赋值给变量e,在最后执行更新操作。这里体现了对象的hash方法和equals方法的重要性
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果当前hash槽的节点已经是红黑树了,则直接将节点put进红黑树,如果节点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);
// 如果插入后节点数量>=(TREEIFY_THRESHOLD(8)-1)则转换成红黑树。其实出现这种几率已经非常低了,源码注释中有说明出现红黑树的几率为0.00000006
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果链表中已经存在了当前key,则跳出循环,执行更新操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果key已经存在hash表中,则执行更新操作,并直接返回
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 执行的添加操作,modCount++,防止并发修改
++modCount;
// put之后判断hash表容量是否大于了扩容值,大于了则调用resize扩容
if (++size > threshold)
resize();
// put之后的回调
afterNodeInsertion(evict);
return null;
}
resize()
resize()方法是HashMap扩容的方法,采取在原基础容量上,两倍扩容创建一个新的数组,并将原来的hash表数组中的所有元素迁移到新hash表数组中去。
final Node<K,V>[] resize() {
// 旧table引用
Node<K,V>[] oldTab = table;
// 旧容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧扩容阈值
int oldThr = threshold;
// newCap-新容量,newThr-新的扩容阈值
int newCap, newThr = 0;
// 计算新容量和新扩容阈值 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
// oldCap>0说明了hash表已经装了元素,则需要计算新的容量大小和扩容阈值
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// newCap(新容量) = oldCap << 1 (旧容量*2)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 已经初始化了,但是并没有存放元素,即调用了HashMap(int initialCapacity, float loadFactor)这构造函数,则扩容大小直接就扩容到计算出来的扩容阈值,这个通常是在初始化的时候调用,即putVal方法的 if ((tab = table) == null || (n = tab.length) == 0)条件满足的情况下
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 什么都没有初始化,即扩容阈值没有计算,容量也没有初始化,则全部赋默认值,容量为默认容量=16,扩容阈值=16*0.75
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);
}
// 计算新容量和新扩容阈值 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
threshold = newThr;
// 创建新的hash表数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 将扩容后的hash表数组赋值给table引用
table = newTab;
// 执行迁移
if (oldTab != null) {
// 遍历旧的hash表数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 将旧的hash表数组元素置为null,帮助垃圾回收
oldTab[j] = null;
// 如果是单节点,没有发生过hash碰撞,则直接按照新容量计算新hash槽索引即可。经过扩容后,元素位置要么在原来位置,要么在oldCap+oldIdx,也就是原来容量加原来位置的索引。
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果节点已经红黑树节点了,则进行切分,如果切分后树节点数量<=UNTREEIFY_THRESHOLD = 6;则重新转为链表
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 节点为链表,如果节点的hash值与上就容量为0,则继续存放于当前hash槽链表中,否则节点放入原hash槽索引+旧容量hash槽索引处
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;
}
HashMap总结
HashMap底层采用数组+链表+红黑树作为数据结构存储,有四个构造方法。
HashMap在计算hash槽索引的时候,会将key的hash值进行一次扰动,使得hash值分布更加均匀,原理是将key的hash值进行低16位与高16位做异或运算。
HashMap容量只能是2的n次方,扩容的时候有个重要属性就行hash因子,当hash表容量达到了容量*hash因子的时候,就需要扩容,扩容以2倍扩容,在扩容过程中会创建一个新的hash表数组,然后将原来hash表数组中的元组迁移到新的hash表数组中,迁移过程中旧的元素hash槽索引只有两种结果,一个是不变,一个是hash槽索引变为原来索引+旧的容量,在迁移过程中如果红黑树节点小于等于6,则会退化成链表。
扩展
为什么要用红黑树,而不用二叉搜索树,平衡二叉树搜索树等?
为什么使用红黑树?
选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题。
为什么不一直使用红黑树?
红黑树是比链表更加复杂的一种数据结构,它会为了保持平衡,会经过多次旋转调整指针等操作(增加旋转两次保持平衡,删除三次旋转保持平衡)这些操作的代价锁消耗的资源比链表直接添加尾结点大得多,因此没有一直采用红黑树作为HashMap的碰撞处理方案。但是当碰撞元素很多的时候,会导致链表深度过深,处理链表包括遍历,添加,删除等,开销更大,因此链表+红黑树是一个综合平衡的做法。
红黑树特点:
1、每个节点非红即黑。
2、根节点总是黑色的。
3、如果节点是红色的,则它的子节点必须是黑色的(反之不一定)。
4、每个叶子节点都是黑色的空节点(NIL节点)。
5、从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)。
红黑树优点:
如果插入一个node引起了树的不平衡,AVL和RB-Tree都是最多只需要2次旋转操作,即两者都是O(1);但是在删除node引起树的不平衡时,最坏情况下,AVL需要维护从被删node到root这条路径上所有node的平衡性,因此需要旋转的量级O(logN),而RB-Tree最多只需3次旋转,只需要O(1)的复杂度。
其次,AVL的结构相较RB-Tree来说更为平衡,在插入和删除node更容易引起Tree的unbalance,因此在大量数据需要插入或者删除时,AVL需要rebalance的频率会更高。因此,RB-Tree在需要大量插入和删除node的场景下,效率更高。自然,由于AVL高度平衡,因此AVL的search效率更高。
红黑树的查询性能略微逊色于AVL树,因为他比avl树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的avl树最多一次比较,但是,红黑树在插入和删除上完爆avl树,avl树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于avl树为了维持平衡的开销要小得多
map的实现只是折衷了两者在search、insert以及delete下的效率。总体来说,RB-tree的统计性能是高于AVL的。
注:红黑树不是绝对平衡的,它的平衡高度差不会大于2。
HashMap多线程问题
通过分析上面的HashMap存删和扩容机制,知道在HashMap添加一个元素的时候它会做很多操作如resize(),扩容,复制元素等,因此当HashMap在多线程情况下运行,主要会造成插入元素丢失问题,当线程A插入元素,线程B同时插入元素,这时候它们HashMap的值已经达到了threshold(边界),则会同时调用resize进行扩容,将table的引用指向newTable,这个newTable肯定是线程A或者线程B的创建的,它们肯定会导致另外一个创建的newTable失效,导致插入数据丢失。
在1.8以前还存在死链问题,在1.8以前HashMap底层数据存储的数据结果是数组+链表,为了提高插入效率,会在插入元素插入在链表头部,当多线程操作的时候会导致链表形成环导致死循环。