1.简单了解一下HashMap
HashMap 就是以 Key-Value 键值对的方式进行数据存储的一种数据结构,它在 JDK 1.7 和 JDK 1.8 中底层数据结构是有些不一样的。简单来说,JDK 1.7 中 HashMap 的底层数据结构是数组 + 链表,使用 Entry 类存储 Key 和 Value;JDK 1.8 中 HashMap 的底层数据结构是数组 + 链表/红黑树,使用 Node 类存储 Key 和 Value。这里的 Entry 和 Node 并没有什么不同,我们来看看 Node 类的源码:
//jdk1.8中HashMap底层就是以这个数组存储所有的键值对
transient Node<K,V>[] table;
//节点类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; //指向下一个节点的指针
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
每个节点都会保存当前节点的 hash、key 和 value、以及下个节点地址。
2.HashMap中的几个重点
- 哈希算法
我们向哈希表中添加元素时,会首先通过hash()方法计算当前key的hash值,找到元素插入的位置:
//底层put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
-------------------------------------------------------------------------------------
//计算hashCode方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
如果不这样做,而是直接做 & 运算那么高十六位所代表的部分特征就可能被丢失,将高十六位无符号右移之后与低十六位做异或运算使得高十六位的特征与低十六位的特征进行了混合得到的新的数值中就高位与低位的信息都被保留了 ,而在这里采用异或运算而不采用 & ,| 运算的原因是 异或运算能更好的保留各部分的特征,如果采用 **&**运算计算出来的值会向1靠拢,采用 | 运算计算出来的值会向0 靠拢。
- 哈希冲突
首先,数组的长度是有限的,在有限的数组上使用哈希,那么哈希冲突是不可避免的,很有可能两个元素计算得出的 index 是相同的,那么如何解决哈希冲突呢?拉链法。也就是把 hash 后值相同的元素放在同一条链表上。
当然这里还有一个问题,那就是当 Hash 冲突严重时,在数组上形成的链表会变的越来越长,由于链表不支持索引,要想在链表中找一个元素就需要遍历一遍链表,那显然效率是比较低的。为此,JDK 1.8 引入了红黑树。当链表的长度大于 8 的时候就会转换为红黑树,不过,在转换之前,会先去查看 table 数组的长度是否大于 64,如果数组的长度小于 64,那么 HashMap 会优先选择对数组进行扩容 resize,而不是把链表转换成红黑树。 - 扩容机制
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容resize(),而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
底层源码中几个重要常量:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认数组的初始容量为16
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认负载因子*数组初始容量就是扩容的阈值
static final int TREEIFY_THRESHOLD = 8; //树化阈值,当链表长度大于8,会转化为红黑树
static final int UNTREEIFY_THRESHOLD = 6; //当树的高度小于等于6时,红黑树又会退化为链表
static final int MIN_TREEIFY_CAPACITY = 64; //数组的长度小于64时,数组会优先扩容
在哈希表中,如果数组的长度达到初始容量*负载因子,在插入元素就需要扩容操作,也就是resize 方法。什么时候会进行 resize 呢?与两个因素有关:
- 1)Capacity:HashMap 当前最大容量/长度,可以通过参数传递,默认值为16
- 2)LoadFactor:负载因子,默认值0.75f
扩容 resize 分为两步:
- 1)扩容:创建一个新的 Entry/Node 空数组,长度是原数组的 2 倍
- 2)ReHash:遍历原 Entry/Node 数组,把所有的 Entry/Node 节点重新 Hash 到新数组
由于扩容后数组的长度改变,元素就需要通过哈希算法重新计算哈希值,找到元素插入的位置。
3.HashMap 线程不安全的表现有哪些?
JDK 1.7 中 HashMap 的链表添加元素采用的时头插法,在多线程的情况下,就是会出现环形链表,是线程不安全的,虽然 JDK 1.8 采用尾插法避免了环形链表的问题,但是它仍然是线程不安全的,我们来看看 JDK 1.8 中 HashMap 的 put 方法:
假设线程 1 和线程 2 同时进行 put 操作,恰好这两条不同的数据的 hash 值是一样的,并且该位置数据为null,这样,线程 1 和线程 2 都会进入这段代码进行插入元素。假设线程 1 进入后还没有开始进行元素插入就被挂起,而线程 2 正常执行,并且正常插入数据,随后线程 1 得到 CPU 调度进行元素插入,这样,线程 2 插入的数据就被覆盖了。
总结一下 HashMap 在 JDK 1.7 和 JDK 1.8 中为什么不安全:
- JDK 1.7:由于采用头插法改变了链表上元素的的顺序,并发环境下扩容可能导致循环链表的问题
- JDK 1.8:由于 put 操作并没有上锁,并发环境下可能发生某个线程插入的数据被覆盖的问题
4. 如何保证 HashMap 线程安全?
简要概述一下,主要有以下三种方法:
- 1)使用 java.util.Collections 类的 synchronizedMap 方法包装一下 HashMap,得到线程安全的 HashMap,其原理就是对所有的修改操作都加上 synchronized。方法如下:
//源码
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
-----------------------------------------------------------
//示例
public class TestHash {
public static void main(String[] args) {
HashMap<Integer,String> map =new HashMap<>();
map.put(1,"西安");
map.put(2,"上海");
map.put(3,"杭州");
synchronizedMap(map);
}
}
- 2)使用线程安全的 ConcurrentHashMap 类代替,该类在 JDK 1.7 和 JDK 1.8 的底层原理有所不同,JDK 1.7 采用数组 + 链表存储数据,使用分段锁 Segment 保证线程安全;JDK 1.8 采用数组 + 链表/红黑树存储数据,使用 CAS + synchronized 保证线程安全。
- 3)使用线程安全的 HashTable 类代替,该类在对数据操作的时候都会上锁,也就是加上synchronized(HashTable属于jdk中的古老类,虽然是线程安全的,但是效率比较低下,实际开发中几乎不使用)