1. HashMap的实现
1.1 Hash的实现
1.2. 底层实现
1.2.1. JDK1.8之前
拉链法
创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
1.2.2. JDK1.8之后
链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
2. Jdk 1.7和1.8 hashMap 区别
2.1 扩容时方式不同
在移动链表时,1.7是头插法,1.8尾插法,导致1.7链表元素顺序会颠倒,1.8不会
确定元素新的索引时,1.8只需要看多出来的一位index是0还是1,1.7需要重新计算
h&(length - 1)
2.1.1 头插法和尾插法
头插
新增在链表上元素的位置为链表头部,也就是数组桶位上的那个位置
可能是考虑到热点数据的原因,即最近插入的元素也很可能最近会被使用到
尾插
为了避免出现逆序导致的成环的问题
2.1.2 头插法导致的后果(1.7并发为何不安全)
在transfer()
方法中,因为新的Table
顺序和旧的不同,所以在多线程同时扩容情况下,会导致第二个扩容的线程next混乱,本来是A -> B
,但t1线程已经B -> A
了,所以就成环了。
2.1.3 Java1.8如何解决成环的问题的
1.8扔掉了transfer()
方法,用resize()
扩容:
使用do while
循环一次将一个链表上的所有元素加到链表上,然后再放到新的Table
上对应的索引位置。
2.2 底层结构不同
JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)
2.3 索引计算方式不同
1.8的索引 只用了一次移位,一次位运算就确定了索引,计算过程优化。
二者的hash
扰动函数也不同,1.7有4次移位和5次位运算,1.8只有一次移位和一次位运算
3. HashMap参数理解
3.1 常见参数
初始容量和加载因子会影响HashMap
的性能:
- 初始容量设置过小会导致大量扩容(最好预估一下)
- 加载因子设置不当,导致频繁扩容操作
3.1.1 capacity
常说的capacity指的是DEFAULT_INITIAL_CAPACITY
(初始容量),值是1<<4
,即16;
capacity()
是个方法,返回数组的长度。
final int capacity() {
return (table != null) ? table.length :
(threshold > 0) ? threshold :
DEFAULT_INITIAL_CAPACITY;
}
3.1.2 loadFactor(加载因子)
final float loadFactor;
在hashMap构造函数中,赋值为DEFAULT_LOAD_FACTOR(0.75f)
加载因子可设为>1,即永不会扩容,(牺牲性能节省内存)
3.1.3 size
Map中现在有的键值对数量,每put
一个entry,++size
The number of key-value mappings contained in this map.
3.1.4 threshold
数组扩容阈值。
即:HashMap数组总容量 * 加载因子(16 * 0.75 = 12)。当前size
大于或等于该值时会执行扩容resize()。扩容的容量为当前 HashMap 总容量的两倍。比如,当前 HashMap 的总容量为 16 ,那么扩容之后为 32
3.2 关于树
树形化阈值TREEIFY_THRESHOLD。当链表的节点个数大于等于这个值时,会将链表转化为红黑树。
解除树形化阈值UNTREEIFY_THRESHOLD 。当链表的节点个数小于等于这个值时,会将红黑树转换成普通的链表
MIN_TREEIFY_CAPACITY 树形化阈值的第二条件。当数组的长度小于这个值时,就算树形化阈达标,链表也不会转化为红黑树,而是优先扩容数组resize()
4. hashCode
4.1 hashCode()
获取哈希码,object
的hashCode()
方法是个本地方法,是由C实现的。
理论上hashCode是一个int值,这个int值范围在-2147483648和2147483648之间,如果直接拿这个hashCode作为HashMap中数组的下标来访问的话,正常情况下是不会出现hash碰撞的。
但是这样的话会导致这个HashMap的数组长度比较长,长度大概为40亿,内存肯定是放不下的,所以这个时候需要把这个hashCode对数组长度取余,用得到的余数来访问数组下标。
4.2 计算索引的过程
- 调用hashCode()得到一串数字超长的数字h
- 将h右移16位,与h按位异或(^),得到hash(扰动函数)
- 将(n-1)与hash按位与(&)
此处n-1指 (1111)_2(即15),因为数组默认长度16(n) - 得到下标
你知道hash的实现吗?为什么要这样实现?
在Java 1.8的实现中,是通过把计算得到的hashCode()的右移16位,异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),这里得到的
hash
与(n-1)
按位与之后就是索引。主要是从速度、功效、质量来考虑的,
- 这么做保证高低bit都参与到hash的计算中,保留了一部分高位的信息(加大了低位随机性,减少了冲突)(如果不移位,高位信息就用不上)
- 同时不会有太大的开销。
高低位异或,避免高位不同,低位相同的两个hashCode
值 产生碰撞。
Java1.7中 hash的实现
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
//这里为什么用&位运算??(下文)
}
一个数对2^n取模 == 一个数和(2^n – 1)做按位与运算 。
X % 2^n == X & (2^n – 1)
//2^n表示2的n次方
假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 -1 = 7 ,即0111。
假设x=18,也就是 0001 0010,一起看下结果:
对 2的n次方 取模: 18 % 8 = 2
对 2的n次方-1 按位与: 0001 0010 & 0111 = 0000 0010 = 2
4.3 HashCode在equals方法中的作用
为什么重写 equals
时必须重写 hashCode
方法?
hashCode()
的默认行为是对堆上的对象产生独特值。如果没有重写hashCode()
,则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)hashCode 方法的常规协定声明 相等对象必须具有相等的哈希码 。
equals()既然已能对比的功能了,为什么还要hashCode()呢?因为重写的equals()里一般比较的比较全面比较复杂,这样效率就比较低,而利用hashCode()进行对比,则只要生成一个hash值进行比较就可以了,效率很高。
hashcode
只是用来缩小查找成本
hashCode()既然效率这么高为什么还要equals()呢?因为hashCode()并不是完全可靠,有时候不同的对象他们生成的hashcode也会一样(生成hash值得公式可能存在的问题),所以hashCode()只能说是大部分时候可靠,并不是绝对可靠,
5. RESIZE(扩容)
5.1 为什么扩容
hashMap是懒加载的,第一次用之前,Table都是空的,第一次用需要扩容
-
当容量(
size
属性 * )达到阈值threshold减少hash冲突-
size
属性就是指所有的
数量,不管在不在同一个链表内都算;下文是putValue()
源码,这个++size
是在所有if else
之外的,所以不管新节点放到哪size都会增加。if (++size > threshold) resize();
-
5.2 扩容到多少?
自动扩容的容量为当前 HashMap 总容量的两倍,
-
如果是指定了容量,
tableSizeFor
函数会根据传入的参数,寻找距离最近的2的N次方;传75,容量就是128.https://www.jianshu.com/p/f86191afd918 (tableSizeFor函数源码解析)
5.3 为什么扩容是2倍?
容量是2的次幂,结合计算索引的位运算,可以比较大程度分散元素,散列效果更好
-
计算索引会更高效(尤其是扩容时,用容量n和hash按位与很方便高效,(实际上是查看最高位是0/1))
计算索引本质是取模运算,当容量是2的次幂时,取模可以转化为快速的位运算。
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;
这个算法实际就是取模,hash%length。 但是,大家都知道这种运算不如位移运算快
Jdk1.8中,用的是
hash&(length-1)
,如果容量是2的n次方,在计算索引时,(length - 1)的二进制形式上每一位都是1,这样与hash进行按位与会更大程度的分散元素,例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。 而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了
5.3 什么时候扩容
A:两个时候
- HashMap实行了懒加载, 新建HashMap时不会对table进行赋值, 而是到第一次插入时, 进行resize时构建table;当新建时, 如果没有指定HashMap.table的初始长度, 就用默认值16, 否则就是指定的值; 然后不管是第一次构建还是后续扩容, threshold = capacity * loadFactor(比如往HashMap里面放了一对值,threshold就会更新)
- 当HashMap.size 大于 threshold时, 会进行resize;threshold的值:
5.4 loadFactor为什么是0.75
Node[] table,即哈希桶数组,哈希桶数组table的长度length大小必须为2的n次方
0.75 * 2^n 得到的都是整数。
5.5 HashCode的计算在扩容上的好处
把bucket扩充为2倍,之后重新计算index,把节点再放到新的bucket中
hashCode是很长的一串数字,(换成二进制,此元素的位置就是后四位组成的 (数组的长度为16,即4位))
扩容 = 把作为索引的数字范围往前一个
eg.
1111 1111 1111 1111 0000 1111 0001 1111 (原索引是后面四个,索引是15)
扩容后:
1111 1111 1111 1111 0000 1111 0001 1111 (新的索引多了一位)(多出来这个,或1或0 随机,完全看hash)
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。
if ((e.hash & oldCap) == 0) {// oldCap == 1000 按位与就是看第一位是不是1
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的(hashCode里被作为索引的数往前走了一个,走的这个可能是0,也可能是1),因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
6. HashMap使用
6.1 遍历
用Iterator有两种方式,分别把迭代器放到entry和keyset上,第一种更推荐,因为不需要再get(key)
Iterator> entryIterator = map.entrySet().iterator();
while (entryIterator.hasNext()) {
Map.Entry next = entryIterator.next();
System.out.println("key=" + next.getKey() + " value=" + next.getValue());
}
Iterator iterator = map.keySet().iterator();
while (iterator.hasNext()){
String key = iterator.next();
System.out.println("key=" + key + " value=" + map.get(key));
}
7. 常见问题
7.1 树形化阈值为何是8?
- 如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
- 还有一点重要的就是由于treeNodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值
7.2 key的选取
7.2.1 为什么Integer String等包装类适合作为key
可减少哈希碰撞
- 因为 String、Integer 等包装类是 final 类型的,具有不可变性,不可变性保证了计算 hashCode() 后键值的唯一性和缓存特性,不会出现放入和获取时哈希码不同的情况
- 包装类已经重写了 equals() 和 hashCode() 方法,而这些方法是在存取时都会用到的。
读取哈希值高效,此外官方实现的 equals() 和 hashCode() 都是严格遵守相关规范的,不会出现错误。
7.2.2 Null可以做key吗
可以,null的索引被设置为0,也就是Table[0]位置
在JDK7中,调用了putForNullKey()
方法,处理空值
JDK8中,则修改了hash函数,在hash函数中直接把key==null
的元素hash值设为0,
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//如果key为Null,直接把hash设为0
再通过计算索引的步骤
//(JDK11,函数:putVal())
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//索引:i = (n - 1) & hash
得到索引为0;
7.2.3 如何设计一个class作为key
- 重写
equals
方法,equals
方法与hashCode()
的关系保证正确(hashCode相同不一定equals,equals一定hashCode相同) - 让这个类不可变
- 添加final修饰符,保证类不被继承
- 保证所有成员变量必须私有,并且加上final修饰
- 不提供改变成员变量的方法,包括setter
- 通过构造器初始化所有成员,进行深拷贝(deep copy) ,传入的构造器参数也复制一份,而不是直接用引用
- 在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝
7.3 为什么使用数组+链表的结构
- 数组根据索引取值效率高,用来根据key确定桶的位置
- 链表用来解决hash冲突,索引值一样时,就在链表上增加一个节点。
7.3.1 为什么不用linkedList或者ArrayList代替数组
- 因为有索引,数组取值比LinkedList快;
- 数组是基本的数据结构,操作更方便(扩容机制可以自定义,ArrayList扩容是变成1.5倍容量)
7.4 hashmap中get元素的过程?
对key的hashCode()做hash运算,计算index; 如果在bucket里的第一个节点里直接命中,则直接返回; 如果有冲突,则通过key.equals(k)去查找对应的Entry;
- 若为树,则在树中通过key.equals(k)查找,O(logn);
- 若为链表,则在链表中通过key.equals(k)查找,O(n)。
7.5 hashmap中put元素的过程?
调用putValue
:
- 查看当前
Table
是否为空,为空就resize
; - 查看对应索引处是否为空,是就直接
newNode()
放到Table[i]
中
7.5 说说String中hashcode的实现?
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
是以31为权,每一位为字符的ASCII值进行运算,用int的自然溢出来等效取模。
假设String是ABC,
((A + 0*31) * 31 + 'B' )*31 + 'C'
7.5.1 为什么每次乘31?
- 31是个比较大的质数,适合用于hash
- 31 == 2^5 - 1,很适合改成位运算,
i* 31 == (i << 5) - i
7.6 为什么不直接用红黑树
- 空间资源考虑:红黑树节点大小比链表节点大,占空间更多
- 时间资源:虽然查询快,但是需要左右旋转,调整颜色等保持红黑树的性质,在节点比较少的情况下,节省下来的查询时间开销并不值。
7.7 HashMap在并发时有什么问题?1.8还有吗?如何解决
多线程扩容后,取元素时的死循环(1.8解决)
-
多线程put可能导致元素丢失(未解决)
元素为什么会丢失?
- 元素put时是一种“先查后改“的机制,即先计算得到索引,然后再往桶里面放;查询可以同步进行(假设有两个索引相同的元素需要put,查询出前一个节点都是A,那么插入时,后插入的C可能就会覆盖先插入的B)先插入的B就丢失了。
-
put
函数中有一句++size
表示HashMap当前的Entry数量,但该操作不是原子的,源码设计时就没有考虑其并发安全性。在多线程执行这句++size时,结果很可能出错,这导致元素个数是错的
put非null元素
get
出来却是null(未解决)
可以使用ConcurrentHashmap,Hashtable等线程安全等集合类。
7.9 map中的对象能否修改
Divenier总结:
要看作为key的元素的类如何实现的,如果修改的部分导致其hashcode
变化,则修改后不能get()
到;
如修改部分对hashcode
无影响,则可以修改。
因为 key 更新后 hashCode 也更新了,(这里是因为重写了 hashcode 的原因)而 HashMap 里面的对象是我们原来哈希值的对象,在 get 时由于哈希值已经变了,原来的对象不会被索引到了,所以结果为 null
因此当把对象放到 HashMap 后就不要尝试对 key 进行修改操作,谨防出现哈希值变化或者 equals 比较不等的情况导致无法索引。
7.10 解决Hash冲突有几个办法?HashMap用的是什么办法?
开放定址法、链地址法(拉链法)、再Hash法
HashMap用的是拉链法。
ThreadLocalMap
是开放定址法
之所以采用不同的方式主要是因为:在 ThreadLocalMap中的散列值分散得十分均匀,很少会出现冲突。并且 ThreadLocalMap经常需要清除无用的对象,使用纯数组更加方便。
8. 并发安全
8.1 HashMap和HashTable
- 都实现了map接口,HashMap继承自
AbstractMap
,实现此接口比较简单, HashTable继承自Dictionary
类(已废弃),都是键值对存储方式 - HashMap可以有Null键(位置是0),HashTable则不可以(直接返回 NullPointerException)
- HashMap线程不安全(非
synchronized
) Table安全(几乎所有的 public方法都加了synchronized
,所以线程安全)
在 Collections 类中存在一个静态方法:synchronizedMap(),该方法创建了一个线程安全的 Map 对象,并把它作为一个封装的对象来返回。
部分参考资料:
https://tech.meituan.com/2016/06/24/java-hashmap.html
https://zhuanlan.zhihu.com/p/76735726
https://zhuanlan.zhihu.com/p/111501405