基于jdk1.8
1、浅析
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
//默认数组容量16。左移4位,也即2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//数组
transient Node<K,V>[] table;
//HashMap 中实际存在的键值对数量
transient int size;
//记录 HashMap 内部结构发生变化的次数
transient int modCount;
...
...
}
HashMap 底层数据结构是数组 + 链表 + 红黑树。
1、数组的主要作用是方便快速查找,时间复杂度是 O(1),默认大小是 16,数组的下标索引是通过 key 的 hashcode 计算出来的,数组元素叫做 Node,当多个 key 的 hashcode 一致,但 key 值不相同时,单个 Node 就会转化为链表。
2、链表的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化为红黑树。
3、红黑树的查询复杂度是 O(log(n)),简单来说,最坏的查询次数相当于红黑树的最大深度。
2、HashMap中数组大小的设计
在 HashMap 中,数组的长度大小必须是 2 的 n 次方,这是一种非常规的设计。常规来说是把数组的大小设计为素数。相对来说,素数导致冲突的概率要小于非素数。HashTable 初始化桶的大小为 11,就是把桶大小设计为素数的应用。HashMap 采用这种非常规的设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap 定位数组索引位置时,也加入了高位参与运算的过程。(使用位运算)
3、红黑树的引入
这里存在一个问题,即使负载因子和 Hash 算法设计的再合理,也免不了会出现链表过长的情况,一旦出现链表过长,则会严重影响 HashMap 的性能。于是,在 JDK 1.8 版本中,对数据结构作了进一步优化,当链表长度大于等于 8 并且数组长度大于等于 64 时,链表就转换为红黑树(二分搜索树),利用红黑树快速增删改差的特点提高 HashMap 的性能,从 O(n) 到 O(log n)。如果数组长度小于 64,则只会扩容不会树化。
为什么是 8 呢?这个答案在源码中注释又说到。大概意思就是,在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多时,才会转化成红黑树,但红黑树的占用空间是链表的 2 倍,考虑到转化时间和空间消耗,所以我们需要定义出转化的边界值。
(1)HashMap 开头源码中给出的8的设计注释
//由泊松分布概率函数下:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
当链表长度是 8 的时候,出现的概率是 0.000000006,不到千万分之一,所以说,正常情况下,链表的长度不可能到达 8,而一旦到达了 8,肯定是 hash 算法出了问题。
4、HashMap put设计
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
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; //1、获取数组长度
// 2、计算数组索引((n - 1) & hash)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
...
...
}
如上是put源码:
(1)数组索引的计算设计
1、我们首先想到的取模法,也即通过 hash值对数组长度取模得到索引,而且最好取模一个素数,这样索引的分布相对来说也是比较均匀的,但是模运算的消耗还是比较大的,而采用 hash & (n - 1) ,当 n 为 2 的次方时,(n - 1) & hash 等价于取模运算即(n - 1) & hash = hash % n,但是 & 比 % 具有更高的效率。
(2)hash方法
static final int hash(Object key) {
int h;
//首先计算出key的hashCode值 h
h = key.hashCode();
//h >>> 16 代表无符号右移16位
return (key == null) ? 0 : h^ (h >>> 16); //h异或h右移16位的值
}
取 hash 的时候通过 hashCode() 的高十六位和低十六位异或得到,这样做在数组长度比较小的时候也能保证高地位 bit 都能参与到 hash 的计算中,同时不会有太大开销。
(3)put 方法的设计
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.tab 为空,则创建一个tab数组
if ((tab = table) == null || (n = tab.length) == 0)
// n 为数组长度
n = (tab = resize()).length;
//2.计算下标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//3.如果存在节点 key,直接覆盖 value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//4.如果是红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//5.链表插入
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;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//6.如果超过最大容量,则扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
1、首先判断数组,当数组为空或者数组未申请空间时进行扩容处理
2、根据元素的key计算数组索引
3、往指定索引位置放元素,放之前先判断这个索引位置是否有元素?
- 有元素:判断key是否存在?
(1) key 已存在:直接覆盖
(2) key 不存在:table[i] 是否为红黑树?
- 红黑树:直接插入红黑树
- 非红黑树:遍历链表
若不存在元素:则插入末尾并判断链表大小是否大于8且数组容量是否大于64。不满足时只扩容不树化。
若存在元素:则覆盖
- 无元素:直接插入
4、是否需要扩容
5、结束
(4)扩容
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)
// 左移1位,代表 *2
newThr = oldThr << 1; // double threshold
}
}
...
}
扩容的时候容量翻倍,也就是 x2,这也同时保证了长度依旧是 2 的次方,所以前面基于 (n - 1) & hash 取索引的优化得以保留。
既然数组长度改变了,那么肯定的重新计算索引位置呀?
jdk1.8这里又有一个优化点,当 n 为 2 次方时,x2 只不过是在高位补个 1,然后在进行与运算时,hash 的低位保持不变,高位是 1 得 1,是 0 得 0,也就是说 hash 高位为 1,索引就变成了原索引再加上旧桶值;高位为 0,索引就和原索引一致。这也就避免重新 (n - 1) & hash 操作获取索引,只需要看 hash 的高位是 1 还是 0 即可。
总结:所以,可以把 HashMap 的优化都归功于桶长度为 2 的次方。
小结
-
扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
-
负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
-
HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
面试题
1、为啥负载因子是0.75?初始化临界值是12?
HashMap中的threshold(阈值)是HashMap所能容纳键值对的最大值。计算公式为length*LoadFactory。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数也越大。
参数负载因子:默认0.75,当负载因子较大时,给hash表扩容的可能性就会减少,所以数组相对占用空间就会较少,但是每条entry链上(单链表)的元素会相对较多,查询的时间也会增长(时间上较多)。反之相反。所以负载因子是一个时间和空间上折中的说法。自己设计时看自己追求的是时间上还是空间上合理选择即可。一般使用默认0.75即可。这个是通过背后多重计算检验得到的可靠值。
2、HashMap key可为null吗?value呢?
key可为空,value也可为空.
重复的key对应的value值会被后者覆盖
Map<String, String> map = new HashMap<>();
map.put(null, "aaa");
map.put(null, null);
map.put("Tom", null);
Set<String> keys = map.keySet();
for (String key : keys) {
System.out.println("key:"+key+" value:"+map.get(key));
}
--------------
log:
key:null value:null
key:Tom value:null
3、平时在使用HashMap时一般使用什么类型的元素作为Key?
面试者通常会回答,使用String或者Integer这样的类。这个时候可以继续追问为什么使用String、Integer呢?这些类有什么特点?如果面试者有很好的思考,可以回答出这些类是Immutable的,并且这些类已经很规范的覆写了hashCode()以及equals()方法。作为不可变类天生是线程安全的,而且可以很好的优化比如可以缓存hash值,避免重复计算等等,那么基本上这道题算是过关了。
4、如果让你实现一个自定义的class作为HashMap的key该如何实现?
这个问题其实隐藏着几个知识点,覆写hashCode以及equals方法应该遵循的原则,在jdk文档以及《effective java》中都有明确的描述。当然这也在考察应聘者是如何自己实现一个Immutable类。如果面试者这个问题也能回答得很好,基本上可以获得一点面试官的好感了。
5、为什么要在数组长度大于64之后,链表才会进化为红黑树
在数组比较小时如果出现红黑树结构,反而会降低效率,而红黑树需要进行左旋右旋,变色,这些操作来保持平衡,同时数组长度小于64时,搜索时间相对要快些,总之是为了加快搜索速度,提高性能。
6、一般用什么作为HashMap的key?
一般用Integer、String这种不可变类当HashMap当key。因为String是不可变的,当创建字符串时,它的hashcode被缓存下来,不需要再次计算,相对于其他对象更快。
因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类很规范的重写了hashCode()以及equals()方法。
7、HashMap为什么线程不安全?
多线程下扩容死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题
多线程的put可能导致元素的丢失。多线程同时执行put操作,如果计算出来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在JDK1.7和JDK1.8中都存在
put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题,此问题在JDK1.7和JDK1.8中都存在。
HashTable是线程安全的,相对hashmap其方法上都进行了加锁处理。
8、计算hash值时为什么要让低16bit和高16bit进行异或处理
如果我们不对hashCode进行按位异或,直接将hash和length-1进行按位与运算就有可能出现以下的情况
如果下一次生成的hashCode值高位起伏很大,而低位几乎没有变化时,高位无法参与运算可以看到,两次计算出的hash相等,产生了hash冲突所以无符号右移16位的目的是使高混乱度地区与地混乱度地区做一个中和,提高低位的随机性,减少哈希冲突