HashMap知识总结
HashMap学习
-
HashMap是基于哈希表实现的Map接口,且允许null键和null值
-
HashMap的实现不是同步的!!因此它不是线程安全的
-
HashMap的映射不是有序的!
JDK1.8以前,HashMap是由数组 + 链表 组成的!数组是HashMap的主体,链表则是主要为了解决哈希冲突!!!而存在的(“拉链法解决冲突”)
哈希冲突:
两个对象调用的hashCode方法计算的哈希码值一致,导致计算的数组索引值相同
JDK1.8以后在解决哈希值冲突时,有了较大的变化!!!,当链表长度大于阔值(红黑树的边界值,默认为8),并且当前数组的长度大于64时,此时索引位置上的所有数据改为使用红黑树存储
注意:将链表转换成红黑树前会判断,即使阔值大于8,但是数组长度小于64,此时并不会将链表变为红黑树,而是选择进行数组扩容!!!
因为数组比较小的时候,应该尽量避免红黑树结构,这种情况下,反而会降低效率!因为红黑树会进行左旋转、右旋转、变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快一些。综上,只有在阔值大于8,数组长度小于64时,链表才转为红黑树。
一、存储过程
- jdk1.8之前,HashMap由 数组 + 链表 数据结构组成
- jdk1.8之后,HashMap由 数组 + 链表 + 红黑树 数据结构组成
Map<Integer, String> map = new HashMap<>();
1.当创建HashMap集合对象的时候,在JDK8以前,构造方法中创建一个长度是16的Entry[] table 用来存放键值对。在jdk8以后,不是在HashMap的构造方法底层创建数组了,是在第一次调用put方法时创建数组,Node[] table用来存储键值对数据的。
map.put(1 ," 一号");
2.假设向哈希表中存储1-1号数据,根据1调用Integer类中重写之后的hashCode()方法计算出值,然后结合数组长度采用某种算法计算出向Node数组中存储数据的空间的索引,
- 若计算出的索引空间没有数据,则直接将1-一号存储到数组中
map.put(2 ," 二号");
3.向哈希表中存储数据2-二号,假设2计算出的hashCode方法结合数组长度计算出的索引值和1计算出的hashCode方法结合数组长度计算出的索引值一样的话,那么此时数组空间不是null,此时底层会比较1和2的hash值是否一致!
- 若不一致,则在此空间划出一个节点来存储键值对(拉链法)
map.put(2 ," 三号");
4.假设向哈希表中存储数据2-三号,那么根据2调用hashCode方法结合数组长度计算出索引肯定和之前的2一样,那么此时比较后一个2和之前已经存储的2的hash值是否相等
- 若hash值相等,此时发生哈希冲突(碰撞)
- 于是,底层会调用2所属类Integer中的equals方法比较两个内容是否相等:
- 相等:则将后添加的数据的value覆盖之前的value
- 不相等:那么继续向下和其他的数据的key进行比较,若都不相等,则划出一个节点存储数据
- 于是,底层会调用2所属类Integer中的equals方法比较两个内容是否相等:
面试题:哈希表底层采用何种算法计算hash值?还有哪些算法可以计算出hash值?
1.底层采用的key的hashCode方法的值结合数组长度进行无符号右移(>>>)、按位异或(^)、按位与(&)计算出索引
2.还有平方取中法、取余数、伪随机数法
二、扩容
在不断的添加数据的过程,会涉及到扩容问题,当超出临界值(且要存放的位置非空)时,扩容。
默认的扩容方式为:扩容到原来容量的2倍,并将原有的数据复制过来
HashMap使用单链表时,时间复杂度为O(n),使用红黑树的时间复杂度为O(logn)
- size表示HashMap中K-V的实时数量,注意这个不等于数组的长度
- threshold(临界值)= capacity(容量)* loadFactory(加载因子)
- size若超过这个临界值就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍
2.1 扩容机制
当HashMap中的元素个数超过数组大小(数组长度 * loadFactory)时,就会进行数组扩容,loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的值。
即默认情况下,数组大小为16,那么当HashMap中的元素个数超过16 * 0.75 = 12(即这个值就是阔值或者边界值threshold值!!!),就把数组的大小扩展为 2 * 16 = 32,即扩大一倍
当HashMap中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型,当然,若映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表!!!
没进行依次扩容,会税额这一次重新hash分配,并且会遍历hash表中所有的元素,非常耗时,我们应该尽量避免resize
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算(n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到”原位置+旧容量“这个位置
三、HashMap集合类的成员
3.1 成员变量
序列化版本号
private static final long serialVersionUID = 362498820763181265L;
集合的初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为什么必须是2的n次幂?若输入值不是2会怎么样?
当向HashMap中添加一个元素的时候,需要根据key的hash值,去确定其在数组中的具体位置。HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就是把数据存到哪个链表的算法
-
hash%length,由于计算机直接求余效率上不如位移运算。所以源码使用了优化:
使用hash&(lenght-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂
若不是2的n次幂:eg10,则会找16,即最小的2的n次幂
默认的负载因子,默认值是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
集合的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
当链表的值超过8则会转红黑树(1.8新增)
static final int TREEIFY_THRESHOLD = 8;
为什么Map桶中节点个数超过8才转为红黑树?
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含最够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码时发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin
即!相当于空间和时间的权衡!!!
因为8符合泊松分布,超过8时,概念非常小了,所以选择了8
当链表的值小于6便会从红黑树转回链表
static final int UNTREEIFY_THRESHOLD = 6;
当Map里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容、树形化选择的冲突,这个值不能小于4 * TREEIFY_THRESHOLD(8)
static final int MIN_TREEIFY_CAPACITY = 64;
table用来初始化(必须是二的n次幂)
transient Node<K,V>[] table;
table在JDK1.8中我们了解到HashMap是由数组加链表加红黑树来组成的结构,
其中table就是HashMap中的数组,JDK8之前数组类型是Entry<K,V>类型
从JDK8之后是Node<K,V>类型,只是换了个名字,都实现了一样的接口:Map.Entry<K,V>
负责存储键值对数据
用来存放缓存
transient Set<Map.Entry<K,V>> entrySet;
HashMap中存放元素的个数
transient int size;
size 是 HashMap中 K-V 的实时数量,不是数组table的长度
记录HashMap的修改次数
transient int modCount;
用来调整大小下一个容量的值计算方式为(容量 * 负载因子)
// 临界值 当实际大小(容量 * 负载因子)超过临界值时,会进行扩容
int threshold;
哈希表的加载因子
final float loadFactor;
loadFactor加载因子,是用来衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率
计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity是桶的数量,也就是table的长度length
loadFactory太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散
loadFactory默认值 0.75f 是官方给出的一个比较好的临界值
当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到rehash、复制数据等操作,非常消耗性能。
!!!!!!所以开发中尽量减少扩容的次数,可以通过创建时指定初始容量来尽量避免‘
public HashMap(int initialCapacity, float loadFactor) 构造一个带指定初始容量和加载因子的空 HashMap
为什么加载因子设置为0.75,初始化临界值为12?
LoadFactory越趋近于1,那么数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,LoadFactory越小,也就是趋近于0,数组中存放的数据也就越少,也就越稀疏。
3.2 构造方法
构造一个空的HashMap,默认初始容量为16,默认负载因子为0.75
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
构造一个具有指定的初始容量和默认负载因子(0.75)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
......
float ft = ((float)s / loadFactor) + 1.0F,这一行代码为什么要加1.0F?
s/loadFactory的结果是最小数,加1.0F与(int)ft相当于是对小数做一个向上取整以尽可能保证更大容量,更大的容量能够减少resize的调用次数。所以 + 1.0F是为了更大的容量
3.3 成员方法
增加方法(put):实现如下;
- 先通过hash值计算出key映射到哪个桶
- 如果桶上没有碰撞冲突,则直接插入
- 若出现碰撞冲突
- 若该桶使用红黑树处理冲突,则调用红黑树的方法插入数据
- 否则采用传统的链式方法插入。若链的长度达到临界值,则把链表变为红黑树
- 若桶中存在重复的键,则为该键替换新值value
- 若size大于阔值threshold,则进行扩容
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
说明:
-
HashMap只提供了put用于添加元素,putVal方法只是给put方法调用的一个方法,并没有提供给用户使用。重点看putVal方法
-
putVal方法中的key执行了以下hash()方法
static final int hash(Object key) { int h; /** 1.如果key等于null 可以看到当key等于null的时候也是有哈希值的,返回的是0 2.如果key不等于null 首先计算出key的hashCode复制给h,然后与h无符号右移16位后的 二进制进行按位异或得到最后的hash值 */ return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
HashMap是支持key为空的,而HashTable是直接用key来获取HashCode,
所以key为空会抛异常
四、ConcurrentHashMap
ConcurrentHashMap是J.U.C(java.util.concurrent包)的重要成员,它是HashMap的一个线程安全的、支持高效并发的版本。在默认理想状态下,ConcurrentHashMap可以支持16个线程执行并发写操作及任意数量线程的读操作。
ConcurrentHashMap是通过以下三方面保证高效的并发机制的
- 通过锁分段技术保证并发环境下的写操作
- 通过HashEntry的不变性、Volaiile变量的内存可见性和加锁重读机制保证高效、安全的读操作
- 通过不加锁和加锁两种方案控制跨段操作的安全性
HashMap是一个数组链表,当一个 key - Value对 被加入时,首先会通过Hash算法定位出这个键值对要被放入的桶,然后把他插到相应桶中。若这个桶中已经有元素了,那么发生了碰撞,这样会在这个桶中形成一个链表。一般来说当有数据要插入HashMap时,都会检查容量有没有超过设定的thredhold,如果超过,需要增大HashMap的尺寸,但是这样一来,就需要对整个HashMap里的节点进行重哈希操作。
HashMap在并发环境下使用中最为典型的一个问题:HashMap进行扩容重哈希时,导致Entry链形成环。一旦Entry链中有环,势必会导致在同一个桶中进行插入、查询、删除等操作时陷入死循环。
ConcurrentHashMap类中包含两个静态内部类:HashEntry和Segment
- HashEntry:封装具体的K/V对,是一个典型的四元组
- Segment:用来充当锁的角色
总的来说,一个ConcurrentHashMap实例中包含由若干个Segment实例组成的数组,而一个Segment实例又包含由若干个桶,每个桶中都包含一条由若干个 HashEntry 对象链接起来的链表。
特别地,ConcurrentHashMap 在默认并发级别下会创建16个Segment对象的数组,如果键能均匀散列,每个 Segment 大约守护整个散列表中桶总数的 1/16。
ConcurrentHashMap允许多个修改(写)操作并发进行,其关键在于 使用了
锁分段技术!!!,它使用了不同的锁来控制对哈希表的不同部分进行的修改,而ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分!
在HashEntry类中,key、hash、next域都是被声明为final的,value域被volatile所修饰,因此HashEntry对象几乎是不可变的,因此 ConcurrentHashMap读操作不需要加锁
由于value域被volatile修饰,所以其可以确保被读线程读到最新的值,这是ConcurrentHashmap读操作并不需要加锁的另一个重要原因
通过使用段(Segment)将ConcurrentHashMap划分为不同的部分,ConcurrentHashMap就可以使用不同的锁来控制对哈希表的不同部分的修改,从而允许多个修改操作并发进行, 这正是ConcurrentHashMap锁分段技术的核心内涵。
ConcurrentHashMap不同于HashMap,它既不允许key值为null,也不允许value值为null
JDK1.8中抛弃了Segment分段锁,而是采用了CAS + synchronized来实现