文章目录
简介
HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。
HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。
HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
HashMap 的实例有两个参数影响其性能:“初始容量” 和 “加载因子”。容量是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数(*2+1)。
通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
构造函数
HashMap共有4个构造函数,如下:
// 默认构造函数。
HashMap()
// 指定“容量大小”的构造函数
HashMap(int capacity)
// 指定“容量大小”和“加载因子”的构造函数
HashMap(int capacity, float loadFactor)
// 包含“子Map”的构造函数
HashMap(Map<? extends K, ? extends V> map)
HashMap的API
void clear()
Object clone()
boolean containsKey(Object key)
boolean containsValue(Object value)
Set<Entry<K, V>> entrySet()
V get(Object key)
boolean isEmpty()
Set<K> keySet()
V put(K key, V value)
void putAll(Map<? extends K, ? extends V> map)
V remove(Object key)
int size()
Collection<V> values()
数据结构
HashMap的继承关系:
java.lang.Object
↳ java.util.AbstractMap<K, V>
↳ java.util.HashMap<K, V>
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable { }
1、HashMap继承于AbstractMap类,实现了Map接口。Map是"key-value键值对"接口,AbstractMap实现了"键值对"的通用函数接口。
2、HashMap是通过"拉链法"实现的哈希表。它包括几个重要的成员变量:table,size, threshold,loadFactor,modCount。
2.1、table是一个Entry[]数组类型,而Entry实际上就是一个单向链表。哈希表的"key-value键值对"都是存储在Entry数组中的。
2.2、size是HashMap的大小,它是HashMap保存的键值对的数量。
2.3、threshold是HashMap的阈值,用于判断是否需要调整HashMap的容量。threshold的值=“容量*加载因子”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。
2.4、loadFactor就是加载因子。
2.5、modCount是用来实现fail-fast机制的。
3、HashMap 数据结构为数组+链表,其中:链表的节点存储的是一个 Entry 对象,每个Entry 对象存储四个属性(hash,key,value,next)
三句话说清它的数据结构:整体是一个数组;数组每个位置是一个链表;链表每个节点是Entry,存储着hash,key,value,next。
工作原理
首先,初始化 HashMap,提供了有参构造和无参构造,无参构造中,容器默认的数组大小 initialCapacity 为 16,加载因子loadFactor 为0.75。容器的阈值为 initialCapacity * loadFactor,默认情况下阈值为 16 * 0.75 = 12。
PUT 方法
第一步:通过 HashMap 自己提供的hash 算法算出当前 key 的hash 值
第二步:通过计算出的hash 值去调用 indexFor 方法计算当前对象应该存储在数组的几号位置
第三步:判断size 是否已经达到了当前阈值,如果没有,继续;如果已经达到阈值,则先进行数组扩容,将数组长度扩容为原来的2倍。请注意:size 是当前容器中已有 Entry 的数量,不是数组长度。
第四步:将当前对应的 hash,key,value封装成一个 Entry,去数组中查找当前位置有没有元素,如果没有,放在这个位置上;如果此位置上已经存在链表,那么遍历链表,如果链表上某个节点的 key 与当前key 进行 equals 比较后结果为 true,则把原来节点上的value 返回,将当前新的 value替换掉原来的value,如果遍历完链表,没有找到key 与当前 key equals为 true的,就把刚才封装的新的 Entry中next 指向当前链表的始节点,也就是说当前节点现在在链表的第一个位置,简单来说即,先来的往后退。
JDK1.8开始:
第一步:判断数组是否为空,为空进行初始化;
第二步:不为空,调用hashCode()计算 k 的 hash 值,通过indexFor()方法利用(n - 1) & hash计算应当存放在数组中的下标 index;
第三步:查看 table[index] 是否存在数据,没有数据就构造一个Node节点存放在 table[index] 中;
第四步:存在数据,说明发生了hash冲突(存在二个节点key的hash值一样), 继续判断key是否相等,相等,用新的value替换原数据;
第五步:如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;(如果当前节点是树型节点证明当前已经是红黑树了)
第六步:如果不是树型节点,创建普通Node加入链表中;判断链表长度是否大于 8并且数组长度大于64,大于的话链表转换为红黑树;
第七步:插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。
hashCode方法
hash函数是先拿到 key 的hashcode,是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作。
这个也叫扰动函数,这么设计有二点原因:一定要尽可能降低hash碰撞,越分散越好;算法一定要尽可能高效,因为这是高频操作, 因此采用位运算。
hash函数能不能直接用key的hashcode?
不能。因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。int值范围为**-2147483648~2147483647**,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下(hashmap中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比取余%运算要快。这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问)
为什么采用hashcode的高16位和低16位异或能降低hash碰撞?
混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。
扩容机制
HashMap 使用 “懒扩容” ,只会在PUT的时候才进行判断,然后进行扩容。
将数组长度扩容为原来的2 倍
将原来数组中的元素进行重新放到新数组中
需要注意的是,每次扩容之后,都要重新计算原来的 Entry 在新数组中的位置。
为什么数组扩容了,Entry 在数组中的位置发生变化了呢?
indexFor方法的源码如下:
static int indexFor(int h, int length) { // h 为key 的 hash值;length 是数组长度
return h & (length-1);
}
由源码得知,元素所在位置是和数组长度是有关系的,既然扩容后数组长度发生了变化,那么元素位置肯定是要发生变化了。
HashMap 计算元素位置采用的是&运算,为什么 HashMap使用这种方式计算在数组中位置呢?
按照我们的潜意识,取模就可以了。hashMap 用与运算主要是提升计算性能。这又带来一个新问题,为什么与运算要用 length -1 呢,回看 hashmap初始化的时候,数组长度 length必须是2的整次幂(如果手动传参数组长度为奇数n,hashMap会自动转换长度为距离n最近的2的整次幂数),只有这样, h & (length-1) 的值才会和 h % length 计算的结果是一样的。这就是它的原因所在。另外,当length是2的整次幂的时候,length-1的结果都是低位全部是1,为后面的扩容做了很好的准备。
JDK1.8对hashMap的优化
数组+链表改成了数组+链表或红黑树;
链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容。
优化原因:
(红黑树)防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
(尾插法)因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环:A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环。
HashMap和HashTable 的异同
- 二者的存储结构和解决冲突(拉链法)的方法都是相同的。
- HashTable在不指定容量的情况下的默认容量为11,而HashMap为16
- Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂
- HashTable 中 key和 value都不允许为 null,而HashMap中key和value都允许为 null(key只能有一个为null,而value则可以有多个为 null)。但是如果在 Hashtable中有类似 put( null, null)的操作,编译同样可以通过,因为 key和 value都是Object类型,但运行时会抛出 NullPointerException异常。
- Hashtable扩容时,将容量变为原来的2倍+1,而HashMap扩容时,将容量变为原来的2倍
- Hashtable计算hash值,直接用key的hashCode(),而HashMap重新计算了key的hash值
- Hashtable在计算hash值对应的位置索引时,用 %运算,而 HashMap在求位置索引时,则用 &运算
优化 HashMap
初始化 HashMap 的时候,我们可以自定义数组容量及加载因子的大小。所以,优化 HashMap 从这两个属性入手,但是,如果你不能准确的判别你的业务所需的大小,请使用默认值,否则,一旦手动配置的不合适,效果将适得其反。
threshold = (int)( capacity * loadFactor );阈值 = 容量 X 负载因子;
loadFactor过大时,map内的数组使用率高了,内部极有可能形成很长Entry链,影响查找速度;
loadFactor过小时,map内的数组使用率较低,不过内部不会生成Entry链,或者生成的Entry链很短,由此提高了查找速度,不过会占用更多的内存。
所以可以根据实际硬件环境和程序的运行状态来调节loadFactor;
线程安全问题
HashMap不是线程安全的,1.7会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题:当A线程判断index位置为空后正好挂起,B线程开始往index位置的写入节点数据,这时A线程恢复现场,执行赋值操作,就把A线程的数据给覆盖了。
Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。
- HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大,
- Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;
- ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。
ConcurrentHashMap的分段锁的实现原理
-
JDK1.7的ConcurrentHashMap实现:
Java7中的ConcurrentHashMap最外层是多个segment,每个segment的底层数据结构与HashMap类似,仍然是数组和链表组成的拉链法;
每个segment设置了独立的ReentrantLock锁,每个segment之间互不影响,提高了并发效率;
ConcurrentHashMap默认有16个Segments,所以最多可以同时支持16个线程并发写(操作分别分布在不同的Segment上)。这个默认值可以在初始化的时候设置为其他值,但是一旦初始化以后,是不可以扩容的。
-
JDK1.8的ConcurrentHashMap实现:
取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,并发控制使用Synchronized和CAS来操作
将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构
有序的Map
HashMap内部节点是无序的,根据hash值随机插入
有序的Map: LinkedHashMap 和 TreeMap
- LinkedHashMap怎么实现有序的?
LinkedHashMap内部维护了一个双向链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。 - -TreeMap怎么实现有序的?
TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序,内部是通过红黑树来实现。所以要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用于key的比较。
看了多个博客汇总而成,以下为参考链接
https://blog.csdn.net/zhengwangzw/article/details/104889549?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522162540137916780255233294%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=162540137916780255233294&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_positive~default-2-104889549.first_rank_v2_pc_rank_v29&utm_term=hashmap&spm=1018.2226.3001.4187
https://blog.csdn.net/weixin_35586546/article/details/81153793?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522162540137916780255233294%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=162540137916780255233294&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_positive~default-1-81153793.first_rank_v2_pc_rank_v29&utm_term=hashmap&spm=1018.2226.3001.4187