HashMap 硬核 10 连问
HashTable vs HashMap vs TreeMap vs LinkedHashMap
Map 接口主要有四个常用的实现类,分别是 HashMap
、Hashtable
、LinkedHashMap
和TreeMap
,类继承关系如下图所示:
下面针对各个实现类的特点做一些说明:
- HashMap:
- 它根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。
- HashMap 最多只允许一条记录的键为null,允许多条记录的值为 null。
- HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。
- 如果需要满足线程安全,可以用
Collections
的synchronizedMap
方法使 HashMap 具有线程安全的能力,或者使用ConcurrentHashMap
。
- Hashtable:
- Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自
Dictionary
类 - 并且 Hashtable 是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如
ConcurrentHashMap
,因为ConcurrentHashMap
引入了分段锁。 - 不建议在新代码中使用 Hashtable,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用
ConcurrentHashMap
替换。
- Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自
- LinkedHashMap:
- LinkedHashMap 是 HashMap 的一个子类
- LinkedHashMap 保存了记录的插入顺序,在用
Iterator
遍历 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
- TreeMap:
- TreeMap 实现
SortedMap
接口; - TreeMap 能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,
- 当用
Iterator
遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap。 - 在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造TreeMap 传入自定义的 Comparator,否则会在运行时抛出
java.lang.ClassCastException
类型的异常。
- TreeMap 实现
HashMap 的底层原理
HashMap 基于 hashing
原理。
- JDK 8 后采用
数组+链表+红黑树
的数据结构。数组中的每个元素由 Node 内部类实现。 - 我们通过
put()
和get()
方法储存和获取对象。
存储对象时,将 K/V 键值传给 put() 方法:
- 计算关于 K 的 hash 值(与
K.hashCode
的高 16 位做异或运算) - HashMap 使用的是懒加载方式,散列表为空时,调用
resize()
初始化散列表 - 如果没有发生碰撞,直接添加元素到散列表中去
- 如果发生了碰撞(hashCode值相同),进行三种判断
- 若 key 地址相同或者 equals 后内容相同,则替换旧值
- 如果是红黑树结构,就调用树的插入方法
- 链表结构,循环遍历直到链表中某个节点为空,
尾插法
进行插入,插入之后判断链表个数是否到达变成红黑树的阈值为 8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。
- 如果桶满了大于阀值,则
resize
进行扩容。
为什么使用链表?
要知道为什么使用链表,首先要知道哈希冲突是如何来的。
大家都知道数组的长度是有限的,在有限的长度里面使用哈希函数计算数组下标时,很有可能插入的 k 值不同,但所产生的数组下标是相同的(也叫做哈希碰撞),这也就是哈希函数存在一定的概率性。此时我们就可以使用链表来对其进行链式的存放。当出现 hash 值一样的情形,就在数组上的对应位置形成一条链表。
这种解决哈希碰撞的方法也叫做链地址法。
hash 冲突你还知道哪些解决办法?
- 开放定址法
- 链地址法
- 再哈希法
- 公共溢出区域法
为什么 HashMap 在链表元素数量超过 8 时候改为红黑树?
我们知道 Java 8 后,当链表长度大于或等于阈值 TREEIFY_THRESHOLD
(默认为 8)的时候,如果同时还满足容量(数组的长度)大于或等于 MIN_TREEIFY_CAPACITY
(默认为 64)的要求,就会把链表转换为红黑树。
同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 以后,又会恢复为链表形态。
首先要知道为什么要转换为红黑树?
每次遍历一个链表,平均查找的时间复杂度是 O(n),n 是链表的长度。
红黑树有和链表不一样的查找性能,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在 O(log(n))。最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。
所以为了提升查找性能,需要把链表转化为红黑树的形式。
那为什么不一开始就用红黑树,反而要经历一个转换的过程呢?
JDK 的源码注释中已经对这个问题作了解释:
大意是:因为树节点(TreeNodes)所占的空间是普通节点的两倍,所以我们只有在桶中包含足够的节点时才使用树节点(请参阅TREEIFY_THRESHOLD
)(只有在同一个哈希桶中的节点数量大于等于TREEIFY_THRESHOLD
时,才会将该桶中原来的链式存储的节点转化为红黑树的树节点)。并且当桶中的节点数过少时 (由于移除或调整),树节点又会被转换回普通节点(当桶中的节点数量过少时,原来的红黑树树节点又会转化为链式存储的普通节点),以便节省空间。
从链表转化为红黑树的阈值为什么是 8?
通过查看源码可以发现,默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想。
在源码中也对选择 8 这个数字做了说明,原文如下:
大意是,如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。
在理想情况下,桶(bins)中的节点数概率(链表长度)符合泊松分布
,当桶中节点数(链表长度)为 8 的时候,概率仅为 0.00000006
。这是一个小于千万分之一
的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以一般情况下,并不会发生从链表向红黑树的转换。
但是,HashMap 决定某一个元素落到哪一个桶里,是和这个对象的 hashCode 有关的,JDK 并不能阻止我们用户实现自己的哈希算法,如果我们故意把哈希算法变得不均匀,例如:
综上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。
知道 hash 的实现吗?为什么要这样实现?
在 Java 1.8 的实现中,是通过对 key 对象的 hashCode 进行扰动,即使用 hashCode()
的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16)
。
扰动是为了让 hashCode 的随机性更高,第二步取模就不会让所以的 key 都聚集在一起,提高散列均匀度,同时不会有太大的开销。
谈一下hashMap什么时候需要进行扩容,以及扩容的实现
调用场景:
- 初始化数组 table;
- 当数组 table 的 size 达到阈值时即
++size > loadfactor * capacity
时,也是在putVal
函数中。
实现过程:
通过判断「旧数组的容量」是否「大于 0」 来判断数组是否初始化过。
-
否:进行初始化
- 判断是否调用无参构造器
- 是:使用默认的大小和阈值
- 否:使用构造函数中初始化的容量,当然这个容量是经过
tableSizefor
计算后的 2 的次幂数
- 判断是否调用无参构造器
-
是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中。
概括的讲:扩容需要重新分配一个新数组,新数组是老数组的 2 倍长,然后遍历整个老结构,把所有的元素挨个重新 hash 分配到新结构中去。
可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作。
为什么 HashMap 加载因子是 0.75?
从上文我们知道,HashMap 的底层其实也是哈希表(散列表),而解决冲突的方式是链地址法。
HashMap 的初始容量大小默认是 16,为了减少冲突发生的概率,当HashMap 的数组长度到达一个临界值的时候,就会触发扩容,把所有元素rehash 之后再放在扩容后的容器中,这是一个相当耗时的操作。
而这个临界值就是由加载因子和当前容器的容量大小来确定的:
临界值 =
DEFAULT_INITIAL_CAPACITY
*DEFAULT_LOAD_FACTOR
即默认情况下是 16x0.75=12
时,就会触发扩容操作。
那么为什么选择了 0.75 作为 HashMap 的加载因子呢?
通常,加载因子需要在时间和空间成本上寻求一种折衷。
加载因子是表示 Hash 表中元素的填满的程度。加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。
反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。
冲突的机会越大,则查找的成本越高。反之,查找的成本越小。
因此,必须在冲突的机会与空间利用率之间寻找一种平衡与折衷。
为什么HashMap的数组长度为2的整数次幂,又是怎样实现的?
修改数组长度有两种情况:
- 初始化时指定的长度
- 扩容时的长度增量
先看第一种情况。默认情况下,如未在 HashMap 构造器中指定长度,则初始长度为16。16 是一个较为合适的经验值,他是 2 的整数次幂,同时太小会频繁触发扩容、太大会浪费空间。如果指定一个非2 的整数次幂,会自动转化成大于该指定数的最小 2 的整数次幂。如指定 6 则转化为8,指定 11 则转化为 16。
第二种改变数组长度的情况是扩容。HashMap 每次扩容的大小都是原来的两倍,控制了数组大小一定是 2 的整数次幂。
为什么HashMap的数组长度为 2 的整数次幂?
先说结论:为了加快哈希计算以及减少哈希冲突。
为什么可以加快计算?
我们都知道为了找到 k 的位置在哈希表的哪个槽里面,需要计算 hash(k) % 数组长度
。但是 %
计算比 &
慢很多。当 length 为 2 的次幂时,num & (length - 1)
= num % length
等式成立。具体原理可以看下一文弄懂 HashMap 中的位运算。
为什么可以减少冲突?
以 length 为 8 为例(默认 HashMap 初始数组长度是16),那 8-1
转成二进制的话,就是 0111
。
那我们举一个随便的 hashCode 值 1010 1001
,与 0111
进行与运算看看结果如何:
1010 1001
&
0000 0111
=
0000 0001
这样得到的数,就会完整的得到原 hashcode 值的低位值,不会受到与运算
对数据的变化影响。
如过当 length = 15 时,转换为二进制为 1111
,length - 1 = 1110
。length - 1
的二进制数最后一位为 0,因此它与任何数进行与操作的结果,最后一位也必然是 0,也即结果只能是偶数,不可能是单数,这样的话单数桶的空间就浪费掉了。
同理:length = 12
,二进制为 1100
,length - 1
的二进制则为 1011
,那么它与任何数进行与操作的结果,右边第 2 位必然是 0,这样同样会浪费一些桶空间。
因此,length 取 2 的整数次幂,是为了使不同 hash 值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。
线程安全问题
我们都知道 HashMap 是线程不安全的,在多线程环境中不建议使用,但是其线程不安全主要体现在什么地方呢。
jdk1.7 扩容导致的死循环问题
在 jdk1.8 中对 HashMap 做了很多优化,这里先分析在 jdk1.7 中的问题,相信大家都知道在 jdk1.7 多线程环境下 HashMap 容易出现死循环,这里我们先用代码来模拟出现死循环的情况:
public class HashMapTest {
public static void main(String[] args) {
HashMapThread thread0 = new HashMapThread();
HashMapThread thread1 = new HashMapThread();
HashMapThread thread2 = new HashMapThread();
HashMapThread thread3 = new HashMapThread();
HashMapThread thread4 = new HashMapThread();
thread0.start();
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
class HashMapThread extends Thread {
private static AtomicInteger ai = new AtomicInteger();
private static Map<Integer, Integer> map = new HashMap<>();
@Override
public void run() {
while (ai.get() < 1000000) {
map.put(ai.get(), ai.get());
ai.incrementAndGet();
}
}
}
上述代码比较简单,就是开多个线程不断进行 put 操作,并且 HashMap 与 AtomicInteger 都是全局共享的。在多运行几次该代码后,出现如下死循环情形:
其中有几次还会出现数组越界的情况:
这里我们着重分析为什么会出现死循环的情况,通过 jps 和 jstack 命名查看死循环情况,结果如下:
从堆栈信息中可以看到出现死循环的位置,通过该信息可明确知道死循环发生在 HashMap 的扩容函数中,根源在 transfer 函数中,jdk1.7 中 HashMap 的 transfer 函数如下:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
总结下该函数的主要作用:
在对 table 进行扩容到 newTable 后,需要将原来数据转移到 newTable 中,注意 10-12 行代码,这里可以看出在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转,这里也是形成死循环的关键点。
具体原因:某个线程执行过程中,被挂起,其他线程已经完成数据迁移,等CPU资源释放后被挂起的线程重新执行之前的逻辑,数据已经被改变,造成死循环、数据丢失。
数据覆盖问题
两个线程执行 put() 操作时,可能导致数据覆盖。JDK1.7 版本和 JDK1.8 版本的都存在此问题,这里以JDK1.8 为例。
JDK1.8 中,由于多线程对 HashMap 进行 put 操作,调用了 HashMap#putVal()
。
具体原因:假设两个线程 A、B 都在进行 put 操作,并且 hash 函数计算出的插入下标是相同的。
- 当线程 A 执行完上图蓝框中的代码后,由于时间片耗尽导致被挂起。
- 而线程B 得到时间片后在该下标处插入了元素,完成了正常的插入;
- 然后线程 A 获得时间片,由于之前已经进行了hash 碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程 B 插入的数据被线程 A 覆盖了,从而线程不安全。
总结来说,HashMap线程不安全的体现:
- JDK1.7 HashMap 线程不安全体现在:死循环、数据丢失
- JDK1.8 HashMap 线程不安全体现在:数据覆盖。
如何规避 HashMap 的线程不安全?
- 使用
Collections.SynchronizedMap()
- 使用
ConcurrentHashMap