HashMap源码探究
HashMap源码探究
写在前面:
HashMap是一个重要的内容,无论是日常使用还是面试,都会用到;
个人水平不足以完成这个源码探究,但是找到了很多大佬的博客,深有感触,所以决定洗稿开始,加上自己的感受,如果有错误纯属正常操作,请指出。
参考来源
HashMap要点
HashMap的底层实现:Jdk1.7中使用数组+链表;Jdk1.8使用数组+链表+红黑树
红黑树化条件:(两个缺一不可)
- 数组的长度大于64(如果不大于64,使用扩容策略)
- 链表的长度大于8
红黑树节点在6以下会恢复为链表
HashMap的初始化方法仅仅是设置扩容临界值、负载因子、初始容量,真正的初始化Node在
resize
方法中resize方法负责两个工作:
初始化
node
数组table
扩容
HashMap是线程不安全的,如果在多线程中,请使用
ConcurrentHashMap
负载因子默认值0.75:
- 负载因子大:提高内存利用率,但是碰撞几率变大
- 负载因子小:提高查询速度,浪费内存空间
扩容一定是2的幂次,成倍的进行扩容
JDK1.7是表头插入法,每次扩容改变存储顺序,易造成列表环
JDK1.8是尾部插入法,扩容不改变存储顺序
HashMap底层实现
基本field
1 | /** |
由此我们可以看出,JDk1.8由链表转换红黑树的条件有两个且必须同时满足:
- 数组的长度至少为64
- 链表的长度至少大于8
由红黑树转换回链表的条件:
- 红黑树的节点数小于6
内部类——Node
在JDK1.7中称为Entry,在JDK1.8中叫Node
HashMap本质就是一个Node数组,而Node又有next,可以看做链表
1 | // 是一个静态类,方便HashMap调用(不需要借助引用) |
构造方法
前置变量:
1 | // transient 表名这个变量不需要被序列化; |
共有四个重载的构造方法:
- 两个参数:初始容量和负载因子
1 | public HashMap(int initialCapacity, float loadFactor) { |
- 一个参数
1 | public HashMap(int initialCapacity) { |
- 无参构造
1 | public HashMap() { |
- 将一个Map转为一个HashMap(不讨论此构造)
1 | public HashMap(Map<? extends K, ? extends V> m) { |
核心方法
put方法:
1 | static final int TREEIFY_THRESHOLD = 8; |
treeifyBin方法:(树化)
1 | final void treeifyBin(Node<K,V>[] tab, int hash) { |
HashMap原理
为什么JDK1.8要进行树化?
原因1:并发操作(不要并发使用hashmap)易死循环
JDK1.7的情况下,并发扩容时容易形成链表环,此情况在1.8时就好太多太多了。因为在1.8中当链表长度大于阈值(默认长度为8)时,链表会被改成树形(红黑树)结构。
JDK1.7采用表头插入法。扩容是改变链表原本的顺序,并发易导致链表形成环。
JDK1.8采用尾部插入法。扩容时保持原有顺序,不会出现链表环问题。
原因2:安全问题
因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能。
而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端CPU大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。
用哈希碰撞发起拒绝服务攻击(DOS,Denial-Of-Service attack),常见的场景是攻击者可以事先构造大量相同哈希值的数据,然后以JSON数据的形式发送给服务器,服务器端在将其构建成为Java对象过程中,通常以Hashtable或HashMap等形式存储,哈希碰撞将导致哈希表发生严重退化,算法复杂度可能上升一个数据级,进而耗费大量CPU资源。
为什么链表转换红黑树的临界值为8?
链表长度符合泊松分布
,各个长度的命中概率依次递减,当长度为 8 的时候,是最理想的值。也是出于时间与空间的一种平衡。
如果太大:链表的查询速度为
O(n)
会大大降低查询速度如果太小:
TreeNode
占用的资源远大于Node
负载因子的作用
“负载极限”是一个0~1
的数值,“负载极限”决定了hash表的最大填满程度。
当hash表中的负载因子达到指定的“负载极限”时,hash表会自动成倍地增加容量(桶的数量),并将原有的对象重新分配,放入新的桶内,这称为rehashing
。
“负载极限”的默认值(0.75)是时间和空间成本上的一种折中:
较高
的“负载极限”可以降低hash表所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的操作(HashMap的get()与put()方法都要用到查询)较低
的“负载极限”会提高查询数据的性能,但会增加hash表所占用的内存开销
线程不安全
为什么HashMap线程不安全:
HashMap使用时,内部无可避免会进行resize
与rehash
。
rehash
极有可能形成链表环,导致死循环resize
期间,由于会新建一个新的空数组,并且用旧的项填充到这个新的数组中去。所以,在这个填充的过程中,如果有线程获取值,很可能会取到 null 值,而不是我们所希望的、原来添加的值。
JDK1.7、1.8计算索引的区别
JDK1.7计算索引使用indexFor
方法:
1 | static int indexFor(int h, int length) { |
hash方法是这样的:
1 | final int hash(Object k) { |
JDK1.8具体键值对在哈希表中的位置(数组index)取决于下面的位运算(去除了indexFor
方法):
1 | i = (n - 1) & hash |
仔细观察哈希值的源头,会发现它并不是key本身的hashCode,而是来自于HashMap内部的另一个hash方法。
1 | //(这里的hash值方法来源于) |
为什么这里需要将高位数据移位到低位进行异或运算呢?
这是因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免哈希碰撞。
为什么要重写equals
方法?
equals
默认是继承自Object的方法,它本身是比较内存地址的;假如有一个HashMap,内部有一个由于哈希碰撞拉出的链表,如果我们不重写equals
方法,不比较内容的话,就只能找到链表为止,找不到我们要的node
了
而重写hashcode
方法,是为了让相同内容的对象返回相同的哈希值
HashMap、HashTable、TreeMap区别
- 可以存储NULL?
- HashMap可以存储key为null,value为null的值,但是只能有一个key为null的元素
- HashTable、TreeMap都不能为null
为什么hashTable的key不能为null,但是Hashmap却可以?
因为HashTable对于null的key是直接抛异常的,但是HashMap内部的hash方法进行判断,如果是null,返回0
如果深究的话,就是因为安全失败机制(fail safe),这种机制会使你此次读到的数据不一定是最新的数据。
如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)
来对key是否存在进行判断ConcurrentHashMap
同理。
- 初始容量:
- hashmap为16
- HashTable为11(负载因子都是0.75)
- 扩容机制:
- Hashmap翻倍
- HashTable翻倍+1
- 是否有序?
- HashMap、HashTable无序
- TreeMap能够对保存的记录根据键进行排序
- 时间复杂度
- HashTable、HashMap具有
O(1)
级别时间复杂度 - TreeMap因为有序,所以为
O(logn)
- HashTable、HashMap具有
- 线程安全问题:
- HashMap线程不安全
- HashTable线程安全
- 底层实现:
- HashMap:数组+链表+红黑树
- TreeMap:数组+红黑树
- HashTable:数组+链表
- 选择:
- 首选HashMap
- 需要排序则选TreeMap