HashMap的认识
前言
HashMap作为Java集合中一个老生常谈的内容,有着重要的地位。我们从源码入手,来剖析一下HashMap的结构和原理
HashMap的数据结构
- 简单来讲,HashMap是数组+链表的组合,再JDK1.8后,成为了数组+链表红黑树的组合
HashMap的构造方法
- 无参数的HashMap构造方法会默认构造一个初始容量为16,负载因子为0.75的HashMap
- 有参数的构造方法,源码在下面
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);
}
- 先来看其中几个参数:
- initialCapacity:顾名思义,这是HashMap数组容量的初始值,默认为16,且都为2的整数次幂(这是为什么,下面会提到),假如你将该值设置为17,实际初始化的数字容量为32,tableSizeFor(initialCapacity)就是来计算这个数
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; }
- loadFactor:负载因子,默认为0.75
- threshold:threshold = initialCapacity * loadFactor,当HashMap的size到达这个数值时,需要进行resize()进行扩容
这里有一个疑问this.threshold = tableSizeFor(initialCapacity); 为什么不是this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
这是因为table并不是再构造方式中初始化,而是再put()方法中初始化,这样的话,每次put()之后,会重新计算这个threshold选择是否扩容
HashMap中的hash函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 从源码中可以看到,这里的hash值是又key的hashCode和hashCode右移16位进行异或操作得到,这样做的好处是什么呢?通过查阅资料,好处是,这样得到的hash值随机性更高,降低了hash冲突的可能性
- 但是这个数字同样位数很高,如何把他转化为数组的下标呢?
tab[i = (n - 1) & hash] //截取一段put()方法中的源码,n为数组长度-1
这里n为2的整数次幂的好处就体现出来了,n-1的结果是在低位全为1,仅为位运算很方便,这样就可以避免取模这样的复杂运算
HashMap中插入元素
由于在JDK1.8中加入了红黑树结构,所以插入元素要考虑以下几点:数组是否为空,是否存在hash冲突,应该使用链表还是红黑树,是否需要扩容,咱们慢慢道来
- 元素计算hash后,插入时计算得到数组下标,如不存在hash冲突,就构造一个Node节点
- 如存在hash冲突,判断该节点是否为树形节点,如是树形节点,构造树形节点插入红黑树
- 如不是树形节点,创建Node加入链表,判断链表的长度与TREEIFY_THRESHOLD(默认为8)的大小,大于时转化为红黑树结构,小于UNTREEIFY_THRESHOLD(默认为6)时,转回链表达到性能均衡
- 还要比较当前Hashmap的size与threshold大小,判断是否需要扩容,这里很关键
- 这里不得不提到JDK1.8的优化,在1.8之前扩容时,需要把数组中元素重新hash定位在新数组中的位置,在1.8之后,一切都不一样了。
- 这里数组容量为2的整数次幂的好处又体现出来了,由于扩容都是在原本的容量上扩大一倍,其实也就是在最高位多加了一个1而已,只需要判断这一位时0还是1,如果是0,则元素位置不变,如果是1,则下标加上原本数组的大小即可
线程安全
- HashMap不是线程安全的
- 关于线程安全问题在JDK1.8也进行了一定优化,1.7插入元素发生hash冲突时采用头插法,1.8改用尾插法
- 在1.7中头插法在多线程环境下可能会产生环
- 在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题,比如当某一结点为空时,A线程和B线程都向该节点放数据,A先放,在判断为空后挂起,这时B线程也添加了节点,A线程恢复后就会覆盖B线程的数据
- 解决方案:
- HashTable:通过sychronized关键字上锁,但是粒度较大
- ConcurrentHashMap使用分段锁,降低了锁粒度,提高并发度
- ConcurrentHashMap使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点
- 添加节点时通过CAS的方式添加
- 会检查内部是否在进行扩容,若是,则帮助其扩容
- 对每一个节点的操作是用sychronized实现的
其他Map结构
- HashMap拥有很高的查询效率,但是由于hash值的随机性,内部是无序的
- 有序Map的实现包括LinkedHashMap和TreeMap
LinkedHashMap
类似于HashMap,但是存在一个双向链表来维护keySet,但是迭代遍历时,取得“键值对”的顺序时插入的顺序,或者是最近最少使用的次序
TreeMap
基于红黑树的实现,在查看键或者键值对时,会依据Comparator的规则对key进行排序
总结
JDK1.8的优化
上面提到了很多关于JDK1.8的优化,我们来总结一下
- 当链表长度过长时,改用黑红树结构来提高查询性能
- 扩容机制的不同,减少了扩容对原本元素的操作,还有一点不同是1.7在插入时先判断是否需要扩容,1.8先进行插入,完成后再判断是否需要扩容
- 1.7插入元素发生hash冲突时采用头插法,1.8改用尾插法,提高线程安全性
位操作与位运算
HashMap中大量使用位运算,极大提高了性能
- 从hashCode得到hash
- 从hash得到数组下标
- 初始化数组容量
数组容量为2的整数次幂的好处
- 在由hash值得到数组下标的计算中,可以使用位运算避免复杂的取模运算
- 在jdk1.8的优化中,扩容时对数组中已存在元素的重排序变得更简单
结语
只有真正的去看源码,才知道jdk内部每一块代码都是这么优美且高效
参考资料:
- 《Java编程思想》第四版
- 一个HashMap跟面试官扯了半个小时
- 一文读懂HashMap