原理
存储key-value对,加快查找速度,是非线程安全的哈希散列,其底层原理是数组+链表+红黑树。
红黑树是JDK1.8新加进来的,为了解决哈希冲突太严重导致查找时间复杂度变为O(n)的问题,当链表长度大于8时,链表会自动转换为红黑树,当链表长度小于8时,红黑树会自动退化为链表。注:红黑树是一棵高度平衡的二叉搜索树,时刻维持树高为logn,可以保证查找性能为O(logn),在插入或删除操作时通过“旋转”维持树高。
hash冲突的解决方案
开放地址法
- 线性探测:从冲突位置依次向后寻找空闲位置,会有聚集的问题,聚集是指table比较满的时候,需要频繁探测插入位置
- 二次探测:每次探测的步长是探测次数的平方,线性探测每次探测的步长是1,可以探测比较远的单元,解决聚集的问题,但是会发生二次聚集,二次聚集不严重,但二次探测不经常使用
- 再哈希法:再使用一个哈希函数计算探测步长,不同的关键字对应不同的步长,避免聚集
链地址法
每个table元素都是一个链表,发生冲突新增链表节点即可
公共溢出区
HashMap的put操作
(建议结合源码来看,思路会更清晰,参考https://blog.csdn.net/woshimaxiao1/article/details/83661464)
- 若table的size为0,先初始化table,默认初始化大小为16;
- 如果key为null,则直接保存到table[0]的位置;
- 否则,计算哈希值,在哈希值的计算过程中加入了位运算的操作,尽量让更多位参与到运算过程当中,得到更加均匀的散列结果;
- 根据哈希值计算索引i,计算方式为h&(length-1),HashMap中table的长度必须为2的n次幂,其中一个原因是h&(length-1)==h%length,位运算可以大大提高计算速度;
- 判断table[i]是否为null,如果为空,直接插入即可;
- 如果table[i]不为空,判断key是否存在,如果存在直接替换value的值(先判断当前链表长度是不是超过8了,超过要执行树的查询,否则执行链表的查询);
- 如果key不存在,则遍历红黑树或链表并插入;
- 插入完成后如果++size>threshold,则执行扩容操作,其中threshold==LoadFactory*capacity。
HashMap扩容
HashMap扩容时要将容量扩充为当前容量的2倍,扩容后每个元素对应的索引会发生变化,因此需要重新计算索引,移动元素到相应位置。
JDK1.7的底层数据结构是数组+链表,在扩容时采用头插法移动元素,会导致元素顺序发生改变,多线程同时扩容时可能会死循环,因此是线程不安全的。JDK1.8改成了尾插法扩容,解决了死循环的问题(但是我看其它的文章指出,尾插法并不能完全解决线程安全问题,这一点我也不太确定)
HashMap长度为2的次幂的原因
- 索引的计算方式为h&(length-1),h&(length-1)==h%length,位运算比取余计算高效很多。
- 扩容时将旧的table移动到新的table时更方便计算索引,索引的计算方式为h&(length-1),扩容后length-1最后一位0会变为1,计算新的索引时,索引要么不变(与length-1最后一位1对应的那1位为0),要么变为原位置移动2次幂后的位置(与length-1最后一位1对应的那1位为0)。
- length-1低位全为1,因此获得的数据索引会更加均匀,碰撞的概率变小,查询速度会更快。
HashMap&HashTable&ConcurrentHashMap
HashMap:key和value可以为空;继承自AbstractMap类,实现了Map接口;非线程安全
HashTable:key和value不能为空;继承自Dictionary类,实现了Map接口;阻塞的,通过锁住所有数据实现线程安全;总是能拿到更新后的数据,效率比较低,不建议使用。
ConcurrentHashMap:实现了Map接口;非阻塞的,更新的时候只锁住部分表,读取的时候完全并发,线程安全;合理调度的情况下效率比较高,但是读取操作不能保证反应最近的更新。
- JDK1.7通过分段锁的方式实现,默认分段数量为16,可以达到比较好的并发性能
- JDK1.8之后通过CAS+Synchronized+ReenTrantLock实现