HashMap的工作方式
- HashMap在Map.Entry静态内部类实现中存储key-value对
- HashMap使用哈希算法,在put() 和get() 方法中,使用了hashCode() 和equals() 方法
通过传递key-value对调用put() 方法时 ,HashMap使用key hashCode() 和哈希算法找到存储key-value对的索引 .Entry存储在LinkedList中,如果存在Entry, 会使用equals() 方法来检查传递的key是否存在.如果存在,就会覆盖value. 如果不存在,就会创建一个新的Entry来保存
通过传递key调用get() 方法时,再次使用key hashCode() 方法来找到数组中的索引,然后使用equals() 方法找出正确的Entry并返回Entry的值
HashMap的实现原理
Java中的数据结构映射定义了接口java.util.Map, 接口有以下四个常用的实现类:
HashMap:
- HashMap根据键的hashCode值存储数据,通常可以根据键的hashCode值直接定位到键对应的值,从而具有很快的访问速度
- HashMap中遍历的顺序是不确定的
- HashMap中最多只允许一条数据的键为null, 可以允许多条数据的值为null
- HashMap是线程不安全的.在同一时刻允许多个线程同时写入HashMap, 这样就会导致数据的不一致
- HashMap如果想要满足线程安全,可以使用Collections的synchronizedMap() 方法使得HashMap具有线程安全性,或者使用ConcurrentHashMap
Hashtable:
- Hashtable和HashMap类似,只是Hashtable继承自Dictionary类
- Hashtable中的键和值都不能为null
- Hashtable是线程安全的.在同一时刻只允许一个线程写入Hashtable. 但是Hashtable的并发性不如引入了分段锁的ConcurrentHashMap
- Hashtable是通过为方法添加synchronized锁实现线程安全的
- ConcurrentHashMap是由Segment数据结构和HashEntry数据结构组成的
- Segment是一种可重入锁ReentrantLock, 在ConcurrentHashMap中作为锁
- HashEntry用于存储键值对数据
- 一个ConcurrentHashMap包含一个Segment数组 .Segment的数据结构和HashMap类似,是一种数组和链表的结构
- 一个Segment中包含一个HashEntry数组.每个HashEntry是一个链表结构的元素
- 一个Segment守护一个HashEntry数组中的元素
- HashEntry中的数据进行修改时,必须首先获得HashEntry对应的Segment锁
- 分段锁:
- 分段锁的含义就是用到哪一部分就锁定哪一部分
- 分段锁就是将整个Map划分成N个Segment, 在进行put() 和get() 操作时,根据键的hashCode值寻找到应该使用哪个Segment. 这个Segment做到了类似HashTable的线程安全
- ConcurrentHashMap中的键和值都不能为null
- 不建议使用HashTable, 在不需要线程安全的场景中,可以使用HashMap. 在需要线程安全的场景中,可以使用ConcurrentHashMap
LinkedHashMap:
- LinkedHashMap是HashMap的一个子类
- LinkedHashMap中保存了数据的插入顺序.使用Iterator遍历LinkedHashMap时,首先得到的数据一定是首先插入的
- LinkedHashMap中可以使用带参的构造函数来按照访问次序进行排序
TreeMap:
- TreeMap实现了SortedMap接口
- TreeMap可以将保存的数据按照键来进行排序.默认是按照键值进行升序排序,也可以指定排序的比较器.使用Iterator遍历TreeMap时,得到的数据时排序后的数据
- 如果需要使用排序的映射,建议使用TreeMap. 使用TreeMap时,键必须实现Comparable接口或者是在构造TreeMap时传入自定义的Comparator, 否则会在运行时抛出java.lang.ClassCastException异常
- Map类都是要求映射中的键key是不可变对象:
不可变对象就是这个对象创建后,对象的hashCode值不会改变
如果对象的hashCode值发生改变,就很可能无法定位到映射的位置
HashMap的数据结构
- HashMap使用数组+链表+红黑树的数据结构存储数据的
- HashMap的内部数据结构是一个桶数组
每一个桶中存放着一个单链表的头节点
每一个节点中存储着一个键值对Entry
- HashMap采用拉链法解决存在的Hash冲突问题
拉链法: 也就是链地址法,是数组和链表的结合.在每个数组元素上都有一个链表结构,当数据进行Hash之后,得到数组的下标,然后将数据存放到对应下标元素的链表上
- Node: Node是HashMap的一个内部类,实现了Map.Entry接口,本质上就是一个键值对映射
HashMap构造函数
- HashMap中有三个构造函数 : 通常情况下,使用默认的无参构造函数.在能够预估到数据的容量时推荐使用指定容量大小的构造函数
public HashMap();
public HashMap(int initialCapacity);
public HashMap(int initialCapacity, float loadFactor);
- 构造函数中只是设置了几个参数的值,没有对数组和链表进行初始化,在第一次put操作时才调用resize() 方法初始化数组tab. 这样可以很好的节省空间
- HashMap函数构造过程:
- 首先,在数组Node[] table中
- length: 初始化长度,默认为16
- loadFactor: 负载因子,默认为0.75
- 默认负载因子0.75是对空间和时间效率的平衡性的选择,不建议修改,只有在时间和空间比较特殊的情况下才需要修改:
- 内存较多但是对时间效率要求很高: 降低负载因子loadFactor的值
- 内存紧张但是对时间效率要求不高: 增加负载因子loadFactor的值,这个值可以大于1
- threshold: HashMap中能够容纳的最大数据量的键值对Node个数.
- threshold=length∗loadFactor : 定义好数组长度之后,负载因子越大,能够容纳的键值对个数越多
- threshold是对应的数组长度length和负载因子loadFactor允许的最大元素数量,超过这个数量HashMap就会重新扩容resize. 扩容后的HashMap容量是当前容量的两倍
HashMap重要方法
hash(K)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- Java的HashMap中,没有直接使用hashcode() 作为HashMap中的hash值
- HashMap中将hashcode() 的值无符号右移16位得到一个新值,然后将hashcode() 的值和这个新值进行异或运算得到最终的hash值保存在HashMap中. 这样可以避免哈希碰撞
比如容量大小n为16时 ,n-1为15(0x1111), 散列值真正生效的只是低4位,此时新增的键的hashcode() 的值如果是2,18,34这样以16的倍数为差的等差数列时,就会产生大量的哈希碰撞
使用这样的方法,将高16位和低16位进行异或,因为大部分hashcode() 的值分布已经很均匀了,即使发生碰撞也用O(logn)O(logn)O(logn)时间复杂度的红黑树进行了优化.这样通过使用异或的方法,不仅减少了系统开销,也不会因为tab长度较小时高位没有参与下标的运算引发哈希碰撞
put(K, V)
- 使用put(K, V) 操作时 ,HashMap计算键值K的哈希值,然后将这个键值对Entry放入到HashMap中对应的桶bucket上
- 然后寻找以当前桶为头结点的一个单链表,顺序遍历单链表找到某个节点的Entry中的key等于给定的参数K
- 如果找到则将旧的参数值V替换为参数指定的V. 否则直接在链表的尾部插入一个新的Entry节点
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
- HashMap中重写equals() 方法必须也要重写hashcode() 方法:
根据hash值,定位到数组某个位置后,向位置中后面的链表添加元素时,判断元素是否一样中,首先判断hash值是否相等,然后再判断equals()
如果只对equals() 进行重写,不对hashcode() 进行重写时,依然会按照不同的两个对象处理,所以重写equals() 方法时必须也要重写hashcode() 方法
- HashMap中既要判断hash值,也要使用equals() 方法判断:
HashMap中链表结构进行遍历判断时,重写的equals() 方法判断对象是否相等的业务逻辑比较复杂,这样下来的循环遍历判断影响性能
HashMap中将hash值的判断放在前面,只要hash值不同,整个条件就是false, 不需要进行equals() 方法判断对象是否相等,提升了HashMap的性能
HashMap中是根据hashcode() 的值定位到数组的位置的,同一个数组位置中后面的链表中元素的hashcode() 的值都相同.比较hashcode() 的值没有意义,因为必定相等 .HashMap中没有直接使用hashcode() 的值,用的是对hashcode() 的值进行移位和异或运算后的hash值,这里比较的是元素的hash值
resize()
- 初始化HashMap时,按照阈值threshold分配内存
- 如果HashMap中的数据记录超过HashMap的阈值就会进行扩容
- 扩容时,数组会采用将数组容量大小的值左移一位的算法将将数组扩容至两倍
- 扩容时,根据数据的hash值与数组长度进行逻辑与运算,根据运算结果是否为0来决定数据是不动还是将数组索引位置变更为当前索引位置和原数组长度之和
- 扩容时不会重新计算hash值 ,key的hash值会保存在数组位置的后面的node节点元素中
treeifyBin()
- 数组中单个链表长度超过8, 数组的长度超过64时才会进行链表结构到红黑树结构的转换,否则只是进行扩容操作
- HashMap中,使用红黑树结构占用空间大,尽可能不使用红黑树结构
get(K)
- HashMap通过计算键的哈希值,寻找到对应的桶bucket, 然后顺序遍历桶bucket存放的单链表,通过比对Entry的键找到对应的哈希值
- 如果对应位置后面是红黑树结构就在红黑树结构中查找,如果是链表结构就遍历链表,查询需要找的对象 红黑树遍历的时间复杂度 : O(logn)O(logn)O(logn) 链表遍历的时间复杂度 : O(n)O(n)O(n)
Hash冲突
- Hash冲突:
因为Hash是一种压缩映射,这样每一个Entry节点无法对应到一个只属于自身的桶bucket
必然会存在多个Entry共用一个桶bucket, 拉成一个链条的情况.这种情况就是Hash冲突
- Hash冲突存在的问题:
在Hash冲突的极端情况下,某一个桶bucket后面挂着的链表会特别长,导致遍历的效率很低
- Hash冲突无法完全避免,为了提高HashMap的性能,需要尽量缓解Hash冲突来缩短每个桶的外挂链表的长度
当HashMap中存储的Entry较多时,需要对HashMap扩容来增加桶bucket的数量 这样对后续要存储的Entry来讲,就会大大缓解Hash冲突
HashMap总结
HashMap中MAXIMUM_CAPACITY设置为1<<30
- MAXIUM_CAPACITY:
int类型,表示HashMap的最大容量
使用 << 移位运算的结果不能超过int类型表示的最大值
使用1左移 << 运算时最大只能左移30位,否则就会溢出
- Java中的int类型占4个字节,每个字节占用8位,所以int类型占用32位
- Java中的int类型是有符号的,使用第1位作为符号位,此时还有31位,这时使用1左移只能左移30位
HashMap中容量设置为2的整数幂次方
- 通过限制一个数组长度length为2的整数幂次方的数,这样使得 (length - 1) & h 和 h % length 的结果是一致的
- HashMap中将容量设置为2的整数幂次方主要就是为了在取模和扩容时做优化,同时减少冲突
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
- tab[(n - 1) & hash] : 根据hash值快速定位到数组的位置
数组tab
数组长度n
需要查找的key对应的值hash
- 因为数组长度n设置为2的整数幂次方,这样初始情况下n-1转换为2进制时各个位上都是1
- 此时使用 & 与对应的值hash进行运算时的结果就和hash值一样,也就快速定位到了数组中的位置
- 使得数组中的数据更加分散,减少碰撞
- 如果数组长度不是设置为2的整数幂次方:
数组长度在初始情况下使用n-1转换为2进制时,存在0位,导致很多位置无法放置元素,造成空间浪费
数组的有效使用位置大量减少,增加了碰撞几率,减慢了查询速度
HashMap中的负载因子设置为0.75
- 泊松分布: Poisson分布.描述某段时间内,事件具体的发生概率
P(X=k)=k!λke−λ,k=0,1,…(λ是均值,k为发生次数)
- TreeNode占用的空间是常规节点的两倍,所以只有当箱子bin(数组中的一个桶)中元素的数量超过TREEIFY_THRESHOLD时才会需要使用TreeNode
- HashMap中的hash值分布比较均匀时,很少使用到TreeNode
- 在随机hashcode情况下 ,bin中节点出现的频率遵循泊松Poisson分布,此时负载因子为 0.75, 均值 λ{\lambda}λ 为 0.5
- 如果调整负载因子的值,均值 λ{\lambda}λ 会出现较大偏差
- HashMap扩容到32或者64时,一个箱子bin中存储8个数据量的概率为0.00000006. 所以当一个箱子中节点数目大于等于8个时,可以将HashMap中桶中的数据从链表结构转换为树结构存储,效果是最好的
HashMap中的元素尽量使用迭代器Iterator遍历
- 在迭代器Iterator中使用的fail-fast策略,在遍历发生线程并发时,可以立即抛出异常
- fail-fast策略:
HashMap是非线程安全的
使用迭代器Iterator过程中,如果其余的线程同时也在修改HashMap, 就会立即抛出
ConcurrentModificationException异常
- fail-fast策略实现:
fail-fast策略通过modCount实现
modCount记录修改次数
在迭代器初始化过程中将modCount的值赋值迭代器的expectedModCount
在迭代器迭代过程中,判断modCount和expectModCount是否相等.如果不相等就说明有其余线程对HashMap进行了修改
Map<Integer, Integer> hashMap = new HashMap<Integer, Integer>();
Iterator<Map.Entry<Integer, Integer>> entries = hashMap.entrySet().iterator();
while (entries.hasNext()) {
Map.Entry<Integer, Integer> entry = entries.next();
System.out.println("KEY = " + entry.getKey() + ", VALUE = " + entry.getValue());
}
HashMap的使用特点
- HashMap中的扩容是一个特别损耗性能的操作,所以在初始化HashMap时,应该估算HashMap的大小,确定一个大致的数值,避免在使用HashMap时频繁初始化
- HashMap是线程不安全的,在并发的环境中建议使用ConcurrentHashMap
- Java 8中引入红黑树极大程度地优化了HashMap的性能.主要体现在哈希算法不均匀时,也就是拉链法中链表很长时,可以将链表转换为红黑树结构,此时算法复杂度由O(n)下降为O(logn)O(logn)
作者:攻城狮Chova
链接:
https://juejin.cn/post/7084947689011413028