搞定HashMap
1.Map是个啥?
HashMap隶属于Java中集合这一块,我们知道集合这块有list,set和map,这里的HashMap就是Map的实现类,那么在Map这个大家族中还有哪些重要角色呢?
上图展示了Map的家族,都是狠角色啊,我们对这些其实都要了解并掌握,这里简单的介绍下这几个狠角色:
TreeMap从名字上就能看出来是与树有关,它是基于树的实现,而HashMap,HashTable和ConcurrentHashMap都是基于hash表的实现,另外这里的HashTable和HashMap在代码实现上,基本上是一样的,还记得之前在讲解ArrayList的时候提到过和Vector的区别嘛?这里他们是很相似的,一般都不怎么用HashTable,会用ConcurrentHashMap来代替,这个也需要好好研究,它比HashTable性能更好,它的锁粒度更小。
简单来说,Map就是一个映射关系的数据集合,就是我们常见的k-v的形式,一个key对应一个value,大致有这样的图示
2.HashMap是个啥?
建议了解了哈希表之后再学习HashMap,这样很多难懂的也就不那么难理解了,参考哈希表部分
对于HashMap来说,底层也是基于数组实现,只不过这个数组可能和你印象中的数组有些许不同,我们平常整个数组出来,里面会放一些数据,比如基础数据类型或者引用数据类型,数组中的每个元素我们没啥特殊的叫法,在jdk1.7及之前叫做Entry,而在jdk1.8之后人家又改名叫做Node
3.HashMap初始化大小是多少
先来看HashMap的基础用法:
HashMap map = new HashMap();
就这样,我们创建好了一个HashMap,接下来我们看看new之后发生了什么,看看这个无参构造函数吧
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
解释下新面孔:
- loadFactor :负载因子,之前聊哈希表的时候说过这个概念
- DEFAULT_LOAD_FACTOR :默认负载因子,看源码知道是0.75
很简单,当你新建一个HashMap的时候,人家就是简单的去初始化一个负载因子,不过我们这里想知道的是底层数组默认是多少嘞,显然我们没有得到我们的答案,我们继续看源码。
在此之前,想一下之前ArrayList的初始化大小,是不是在add的时候才创建默认数组,这里会不会也一样,那我们看看HashMap的添加元素的方法,这里是put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
这里大眼一看,有两个方法;
- putVal 重点哦
- hash
这里需要再明确下,这是我们往HashMap中添加第一个元素的时候,也就是第一次调用这个put方法,可以猜想,现在数据已经过来了,底层是不是要做存储操作,那肯定要弄个数组出来啊,好,离我们想要的结果越来越近了。
先看这个hash方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
记得之前聊哈希表的时候说过,哈希表的数据存储有个很明显的特点,就是根据你的key使用哈希算法计算得出一个下标值
而这里的hash就是根据key得到一个hash值,并没有得到下标值哦,重点要看这个putVal方法,可以看看源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
.......
首先Node<K,V>[] table就是HashMap底层的那个数组,其次这个resize()方法主要作用就是初始化数组大小以及将来数组的扩容,看看resize()方法的源码中,有这么一句
newCap = DEFAULT_INITIAL_CAPACITY;
有这么一个赋值操作,DEFAULT_INITIAL_CAPACITY字面意思理解就是初始化容量啊,是多少呢?
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
这里是个左移位运算,就是16,现在已经确定默认容量是16了,那具体在哪创建默认的Node数组呢?继续往下看源码,有这么一句
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
ok,到这里我们发现,第一次使用HashMap添加数据的时候底层会创建一个长度为16的默认Node数组。
4.hashmap是如何减少hash冲突的?
HashMap 中对 key 做 hash 处理时,做了什么特殊操作?为什么这么做?首先我们知道 HashMap 在做 put 操作的时候,会先对 key 做 hash 操作,直接定位到源码位置
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到再对 key 做 hash 操作时,执行了 (h = key.hashCode()) ^ (h >>> 16)
原 hashCode 值: 10110101 01001100 10010101 11011111
右移 16 位后的值: 00000000 00000000 10110101 01001100
异或后的值: 10110101 01001100 00100000 10010011
这个操作是把 key 的 hashCode 值与 hashCode 值右移 16 位做异或(不同为 1,相同为 0),这样就是把哈希值的高位和低位一起混合计算,这样就能使生成的 hash 值更离散,减少hash碰撞,注意hash(Object key)只是算出hash值
这里需要我解释下,通过前面的介绍,我们知道数组的容量范围是 [0,2^30],这个数还是比较大的,平时使用的数组容量还是比较小的,比如默认的大小 16,假设三个不同的 key 生成 1`的 hashCode 值如下所示:
19305951 00000001 00100110 10010101 11011111
128357855 00000111 10100110 10010101 11011111
38367 00000000 00000000 10010101 11011111
他们三个有个共同点是低 16 位完全一样,但高 16 位不同,当计算他们在数组中所在的下标时,如果直接通过 (n-1)&hash,这里 n 是 16,n-1=15,15 的二进制表示为
00000000 00000000 00000000 00001111
用 19305951、128357855、38367 都与 15 进行 & 运算,结果如下
通过计算后发现他们的结果一样,也就是说他们会被放到同一个下标下的链表或红黑树中,显然不符合我们的预期
所以对 hash 与其右移 16 位后的值进行异或操作,然后与 15 做与运算,看 hash 冲突情况
可见经过右移 16位后再进行异或操作,然后计算其对应的数组下标后,就被分到了不同的桶中,解决了哈希碰撞问题,思想就是把高位和低位混合进行计算,提高分散性
5.出现哈希冲突怎么解决
对于HashMap而言,存放的是键值对,所以做数据添加操作的时候会根据你传入的key值做hash运算,从而得到一个下标值,也就是以这个下标值来确定你的这个value值应该存放在底层Node数组的哪个位置。
那么这里一定会出现的问题就是,不同的key会被计算得出同一个位置,那么这样就冲突啦,位置已经被占了,那么怎么办嘞?
首先就是冲突了,我们要想办法看看后来的数据应该放在哪里,就是给它找个新位置,这是常规方法,除此之外,我们是不是也可以聚焦到hash算法这块,就是尽量减少冲突,让得到的下标值能够均匀分布。但是在添加数据的时候尽管为实现下标值的均匀分布做了很多努力,但是势必还是会存在冲突的情况,那么该怎么解决冲突呢?
在jdk1.7中采用数组+链表
在jdk1.8中采用数组+链表+红黑树
6.新的节点在插入到链表的时候,是怎么插入的?
Java8之前是头插法,啥意思嘞,就是放在之前Node的前面,为啥要这样,这是之前开发者觉得后面插入的数据会先用到,因为要使用这些Node是要遍历这个链表,在前面的遍历的会更快
7.为什么使用尾插法?
但是在Java8及之后都使用尾插法了,这里主要是一个链表成环的问题,啥意思嘞,想一下,使用头插法是不是会改变链表的顺序,你后来的就应该在后面嘛,如果扩容的话,由于原本链表顺序有所改变,扩容之后重新hash,可能导致的情况就是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
这样的话在多线程操作下就会出现链表死循环,而使用尾插法,在相同的前提下就不会出现这样的问题,因为扩容前后链表顺序是不变的,他们之间的引用关系也是不变的
对应源码:HashMap的resize()方法中的for循环和do while循环
也就是说:如果在并发环境下不扩容的话,采用头插法和尾插法都可以
8.HashMap扩容机制?
下面我们继续说HashMap的扩容, HashMap 的最大容量为 2^30,经过上面的分析,我们知道第一次使用HashMap是创建一个默认长度为16的底层Node数组,如果满了怎么办,那就需要进行扩容了,也就是之前谈及的resize方法,这个方法主要就是初始化和增加表的大小,关于扩容要知道这两个概念:
- Capacity:HashMap当前长度。
- LoadFactor:负载因子,默认值0.75f。
这里怎么扩容的呢?首先是达到一个条件之后会发生扩容,什么条件呢?就是这个负载因子,比如HashMap的容量是100,负载因子是0.75,乘以100就是75,所以当你增加第76个的时候就需要扩容了,那扩容又是怎么样步骤呢?
首先是创建一个新的数组,容量是原来的二倍,之所以要为原来的二倍(也就是2 的整数次幂)目的是减少更多的hash冲突,然后会经过重新hash,把原来的数据放到新的数组上,至于为啥要重新hash,那必须啊,你容量变了,相应的hash算法规则也就变了,得到的结果自然不一样了
9.关于链表转红黑树
在Java8之前是没有红黑树的实现的,在jdk1.8中加入了红黑树,就是当链表长度为8时会将链表转换为红黑树,为6时又会转换成链表,这样是为了提高了性能,因为在链表的长度没有超过6时,链表由于使用尾插法,在新增和删除的时候快,查询效率也高,当长度为8时,链表的插入和删除操作比红黑树还是要快,因为红黑树要不断的维护树的平衡和节点的自旋操作,但是查询效率没有红黑树高(如:一个元素在链表的最末端,用链表的话要一一个个的找,用红黑树的话效率很高),为了综合性能,故在长度超过8之后,就用红黑树,不用链表
因为经过计算,在 hash 函数设计合理的情况下,发生 hash 碰撞 8 次的几率为百万分之 6,概率说话。因为 8 够用了,至于为什么转回来是 6,因为如果 hash 碰撞次数在 8 附近徘徊,会一直发生链表和红黑树的转化,为了预防这种情况的发生,所以为6
10.HashMap增加新元素的主要步骤
下面我们分析一下HashMap增加新元素的时候都会做哪些步骤:
1、判断数组是否为空,为空进行初始化;
2、不为空,计算 k 的 hash 值,....,然后通过(n - 1) & hash计算应当存放在数组中的下标 index;
3、看 table[index] 是否存在数据,没有数据就构造一个 Node 节点存放在 table[index] 中;
4、存在数据,说明发生了 hash 冲突(存在二个节点 key 的 hash 值一样), 继续判断 key 是否相等,相等,用新的 value 替换原数据(onlyIfAbsent 为 false);
5、如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;
6、如果不是树型节点,创建普通 Node 加入链表中;判断链表长度是否大于 8, 大于的话链表转换为红黑树;
7、插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍
所以啊,这里面的重点就是判断放入HashMap中的元素要不要替换当前节点的元素,那怎么判断呢?总结起来只要满足以下两点即可替换:
1、hash值相等。2、==或equals的结果为true。
11.谈谈jdk1.8 对HashMap三点主要的优化
1、数组+链表改成了—数组+链表或红黑树;
2、链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为节点的后继节点,1.8 遍历链表,将元素放置到链表的最后;
3、在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容
12. HashMap 是线程安全的吗
不是,在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题,以 1.8 为例,当 A 线程判断 index 位置为空后正好挂起,B 线程开始往 index 位置的写入节点数据,这时 A 线程恢复现场,执行赋值操作,就把 A 线程的数据给覆盖了;还有++size 这个地方也会造成多线程同时扩容等问题
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //多线程执行到这里
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) // 这里很重要
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // 多个线程走到这,可能重复 resize()
resize();
afterNodeInsertion(evict);
return null;
13. 如何解决HashMap线程不安全的问题?
Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap 可以实现线程安全的 Map
HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 对象,内部定义了一个对象锁,方法内通过对象锁实现;ConcurrentHashMap 使用分段锁,降低了锁粒度,让并发度大大提高
14.ConcurrentHashMap 的分段锁原理?
ConcurrentHashMap 成员变量使用 volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用 CAS 操作和 synchronized 结合实现赋值操作,多线程操作只会锁住当前操作索引的节点
如下图,线程 A 锁住 A 节点所在链表,线程 B 锁住 B 节点所在链表,操作互不干涉。
15.HashMap 内部节点是有序的吗
是无序的,根据 hash 值随机插入
16.有没有有序的 Map?
LinkedHashMap 和 TreeMap
17. LinkedHashMap 怎么实现有序的?
如上图,我们依次插入A、B、C、D、E五个Entry,而每次插入时,我们都按照插入顺序维持一个双向链表。我们从head指针开始,顺着after指针走(也就是图中的红色箭头),就可以还原我们的插入顺序
LinkedHashMap继承HashMap, LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 LinkedHashMap.Entry<K,V> head 和LinkedHashMap.Entry<K,V> tail 用于标识前置节点和后置节点。LinkedHashMap底层是数组 + 单项链表 + 双向链表。也就是说LinkedHashMap = HashMap+双向链表,数组 + 单向链表就是HashMap的结构,记录数据用;双向链表是用来给节点排序的,默认情况下LinkedHashMap是按照插入元素的顺序给元素排序的(accessOrder = false),当然我们也可以按照访问元素的方式 给链表排序(accessOrder = true)
案例:
public static void main(String[] args) {
Map<String, String> map = new LinkedHashMap<>();
map.put("1", "小孔明");
map.put("2", "的");
map.put("3", "博客");
for (Map.Entry<String, String> item : map.entrySet()) {
System.out.println(item.getKey() + ":" + item.getValue());
}
}
18.TreeMap 怎么实现有序的?
TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,或者自定义一个实现了 Comparator 接口的比较器,传给 TreeMap 用户 key 的比较
@Data
@AllArgsConstructor
@NoArgsConstructor
class Student {
private String name;
private int age;
}
class StuNameComparator implements Comparator<Student> {
@Override
public int compare(Student s1, Student s2) {
int num = s1.getName().compareTo(s2.getName());
if(num == 0){
return new Integer(s1.getAge()).compareTo(new Integer(s2.getAge()));
}
return num;
}
}
public class MyTest {
public static void main(String[] args) {
TreeMap<Student,String> map = new TreeMap<Student,String>(new StuNameComparator());
map.put(new Student("lisi",21),"beijing");
map.put(new Student("lisi",21),"tianji");
map.put(new Student("lisi",22),"shanghai");
map.put(new Student("lisi",23),"wuhan");
map.put(new Student("lisi",24),"nanjing");
Set<Map.Entry<Student, String>> entries = map.entrySet();
entries.forEach(item-> System.out.println(item.getKey().getAge()));
}
}
附:hashmap整体存储结构说明
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。