[TOC]
存储结构
内部包含了一个 Entry 类型的数组 table,1.8之后改成Node。
1 | //transient Entry[] table; |
Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当
成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结
果相同的 Entry。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
put方法
1 | //put方法,会先调用一个hash()方法,得到当前key的一个hash值, |
Hash方法
1 | static final int hash(Object key) { |
这里,会先判断key是否为空,若为空则返回0。这也说明了hashMap是支持key传 null 的。若非空,则先计算key的hashCode值,赋值给h,然后把h右移16位,并与原来的h进行异或处理。为什么要这样做,这样做有什么好处呢?
可以看到,其实相当于,我们把高16位值和当前h的低16位进行了混合,这样可以尽量保留高16位的特征,从而降低哈希碰撞的概率。
思考一下,为什么这样做,就可以降低哈希碰撞的概率呢?先别着急,我们需要结合 i = (n - 1) & hash 这一段运算来理解。
i = (n - 1) & hash
1 | i = (n - 1) & hash |
令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:
令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:
1 | y : 10110010 |
这个性质和 y 对 x 取模效果是一样的:
1 | y : 10110010 |
get方法
- 首先将key hash之后取得所定位的桶
- 如果桶为空,则直接返回null
- 否则判断桶的第一个位置(有可能是链表、红黑树)的key是否为查询的key,是就直接返回value
- 如果第一个不匹配,则判断它的下一个是红黑树还是链表
- 红黑树就按照树的查找方式返回值
- 不然就按照链表的方式遍历匹配返回值
为什么HashMap链表会形成死循环
准确的讲应该是 JDK1.7 的 HashMap 链表会有死循环的可能,因为JDK1.7是采用的头插法,在多线程环境下有可能会使链表形成环状,从而导致死循环。JDK1.8做了改进,用的是尾插法,不会产生死循环。
JDK7与JDK8中HashMap的不同点
JDK8中使用了红黑树
JDK7中链表的插入使用的头插法(扩容转移元素的时候也是使用的头插法,头插法速度更快,无需遍历链表,但是在多线程扩容的情况下使用头插法会出现循环链表的问题,导致CPU飙升),JDK8中链表使用的尾插法(JDK8中反正要去计算链表当前结点的个数,反正要遍历的链表的,所以直接使用尾插法
那为啥用16不用别的呢?
因为在使用是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。
只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。这是为了实现均匀分布。
为什么是0.75?
HashMap负载因子为什么是0.75?
HashMap有一个初始容量大小,默认是16
static final int DEAFULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为了减少冲突概率,当HashMap的数组长度达到一个临界值就会触发扩容,把所有元素rehash再放回容器中,这是一个非常耗时的操作。
而这个临界值由负载因子和当前的容量大小来决定:
DEFAULT_INITIAL_CAPACITYDEFAULT_LOAD_FACTOR
即默认情况下数组长度是160.75=12时,触发扩容操作。
所以使用hash容器时尽量预估自己的数据量来设置初始值。
那么,为什么负载因子要默认为0.75,在HashMap注释中有这么一段:
1 | Ideally, under random hashCodes, the frequency of |
在理想情况下,使用随机哈希吗,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素的个数和概率的对照表。
从上表可以看出当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为负载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
hash容器指定初始容量尽量为2的幂次方。
HashMap负载因子为0.75是空间和时间成本的一种折中。
什么时候变成红黑树
一个是链表长度到8,一个是数组长度到64.
HashMap在多线程环境下存在线程安全问题,那你一般都是怎么处理这种情况的?
1.Hashtable
2.ConcurrentHashMap
不过出于线程并发度的原因,我都会舍弃前两者使用最后的ConcurrentHashMap,他的性能和效率明显高于前两者。
Hashtable效率低
他在对数据操作的时候都会上锁,所以效率比较低下。
扩容
resize 方法:扩容数组,分为两个部分,一个是扩容数组,一个是重新规划长度。
重新规划长度和阈值,如果长度发生了变化,部分数据节点也要重新排列。
1 | 1 HashMap实行了懒加载, 新建HashMap时不会对table进行赋值, 而是到第一次插入时, 进行resize时构建table; |
1 | final Node<K,V>[] resize() { |
重新规划长度
① 判断原来的table是否不为空,是的话判断: 如果原容量大于等于最大容量,那么将阈值设为 Integer 的最大值,并且 return 终止扩容,由于 size 不可能超过该值因此之后不会再发生扩容。如果 size 超出扩容阈值,把 table 容量增加为之前的2倍。否则 把 table 容量增加为之前的2倍。
② 判断oldThr>0, 如果是hashmap传入指定的initialCapacity,这个初始值会给到oldThr.
③ 否则的话 传入默认的容量和负载因子
重新排列数据节点
① 如果节点为 null 值则不进行处理。② 否则如果节点没有next节点,那么重新计算其散列值然后存入新的 table 数组中。③ 如果节点为 TreeNode 节点,那么调用 split 方法进行处理,该方法用于对红黑树调整,如果太小会退化回链表。④ 如果节点是链表节点,需要将链表拆分为 超出旧容量的链表和未超出容量的链表。对于hash & oldCap == 0
的部分不需要做处理,反之需要放到新的下标位置上,新下标 = 旧下标 + 旧容量。
假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:
1 | capacity : 00010000 |
1 |
|
对于一个 Key
它的哈希值如果在第 5 位上为 0,那么取模得到的结果和之前一样;
如果为 1,那么得到的结果为原来的结果 +16。
线程不安全:Java 7 扩容时 resize 方法调用的 transfer 方法中使用头插法迁移元素,多线程会导致 Entry 链表形成环形数据结构,Entry 节点的 next 永远不为空,引起死循环。Java 8 在 resize 方法中完成扩容,并且改用了尾插法,不会产生死循环的问题,但是在多线程的情况下还是可能会导致数据覆盖的问题,因此依旧线程不安全。
为啥Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null?
ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m可能已经不同了
和HashTable的对比
- 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的,因为 HashTable 内部的方法基本都经过
synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!); - 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
- 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
- 初始容量大小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的
tableSizeFor()
方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 - 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
fail-fast
fail-fast的字面意思是“快速失败”。在迭代器遍历元素的过程中,需要比较操作前后 modCount 是否改变,如果改变了说明集合结构被改变,需要抛出ConcurrentModificationException,防止继续遍历。
fail-safe
当我们对集合结构上做出改变的时候,fail-fast机制就会抛出异常。但是,对于采用fail-safe机制来说,就不会抛出异常(大家估计看到safe两个字就知道了)。
这是因为,当集合的结构被改变的时候,fail-safe机制会在复制原集合的一份数据出来,然后在复制的那份数据遍历。
因此,虽然fail-safe不会抛出异常,但存在以下缺点:
1.复制时需要额外的空间和时间上的开销。
2.不能保证遍历的是最新内容
死循环
resize方法
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;//判断是否需要对原node重新hash定位table的index
transfer(newTable, rehash); //扩容核心方法
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
JDK7的transfer
1 | void transfer(Entry[] newTable, boolean rehash) { |
扩容时 resize
调用 transfer
使用头插法迁移元素,每个线程都会生成newTable (newTable 是局部变量),但原先 table 中的 Entry 链表是共享的.假设两个线程,线程1挂起,线程二执行迁移完成,此时线程1继续执行,本来是用e遍历table,用next保存下一个结点,但这样顺序就颠倒了。
JDK8 在 resize
方法中完成扩容,并改用尾插法,不会产生死循环,但并发下仍可能丢失数据。可用 ConcurrentHashMap 或 Collections.synchronizedMap
包装同步集合