HashMap
HashMap的数据结构
JDK1.7的数据结构是数组+链表,JDK1.8之后数据结构就是数组+链表+红黑树,默认大小是16,加载因子是0.75
Java1.8之后,HashMap中的每一个节点都是用Node表示,Node是一个内部类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
其中,桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询效率。
- 数据元素通过映射关系,也就是hash,映射到桶数组对应索引的位置
- 如果发生冲突,从冲突的位置会生成一个链表,插入冲突的元素
- 链表长度>8并且数组长度>=64,链表转为红黑树
- 红黑树节点个数小于<=6,红黑树转为链表
HashMap的put流程
- 首先进行哈希值的扰动,获取一个新的哈希值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 判断 tab 是否位空或者长度为 0,如果是则进行扩容操作
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);
- 判断 tab[i] 是否为树节点,否则向链表中插入数据,是则向树中插入节点
- 如果链表中插入节点的时候,链表长度大于等于 8,则需要把链表转换为红黑树
treeifyBin(tab, hash);
- 最后所有元素处理完成后,判断是否超过阈值
threshold
,超过则扩容
HashMap如何查找元素?
- 使用扰动函数,获取新的哈希值
- 计算数组下标,获取节点
- 当前节点和 key 匹配,直接返回
- 否则,当前节点是否为树节点,查找红黑树
- 否则,遍历链表查找
HashMap的哈希/扰动函数是如何设计的
HashMap 的哈希函数是先拿到 key 的 hashcode,是一个 32 位的 int 类型的数值,然后让 hashcode 的高 16 位和低 16 位进行异或操作。(>>>无符号右移,^异或)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
设计目的:降低哈希碰撞的概率
为什么哈希/扰动函数能降低hash碰撞?
因为 key.hashCode() 函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。int 值范围为 -2147483648~2147483647,加起来大概 40 亿的映射空间。
只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。
假如 HashMap 数组的初始大小才 16,就需要用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。
static int indexFor(int h, int length) {
return h & (length-1);
}
为什么HashMap的容量是2的倍数呢?
- 方便哈希取余:
将元素放在 table 数组上面,是用 取模(hash 值 % 数组大小)定位位置,而 HashMap 是用 hash 值 &(数组大小 - 1),却能和前面达到一样的效果,这就得益于 HashMap 的大小是 2 的倍数,2
的倍数意味着该数的二进制位只有一位为 1,而该数 - 1 就可以得到二进制位上 1 变成 0,后面的 0 变成 1,再通过 & 运算,就可以得到和 % 一样的效果,并且位运算比 % 的效率高得多
HashMap 的容量是 2 的 n 次幂时,(n-1) 的 2 进制也就是 1111111***111 这样形式的,这样与添加元素的 hash 值进行位运算时,能够充分的散列,使得添加的元素均匀分布在 HashMap 的每个位置上,减少
hash 碰撞。
- 在扩容时,利用扩容后的大小也是 2 的倍数,将已经产生 hash 碰撞的元素完美的转移到新的 table 中去
if (++size > threshold)
resize();
如果初始化HashMap,传入一个17的值,HashMap会如何处理?
简单来说,就是初始化时,传的不是 2 的倍数时,HashMap 会向上寻找离得最近的2的N次方,所以传入 17,但 HashMap 的实际容量是 32。
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//负载因子
this.loadFactor = loadFactor;
//阀值 threshold ,通过⽅法tableSizeFor 进⾏计算,是根据初始化传的参数来计算的
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor用来计算正确的threshold
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
HashMap扩容机制
HashMap 是基于数组 + 链表和红黑树实现的,但用于存放 key 值的桶数组的长度是固定的,由初始化参数确定。
那么,随着数据的插入数量增加以及负载因子的作用下,就需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是 jdk1.8 中的优化操作,可以不需要再重新计算每一个元素的哈希值。
所以在扩容时,只需要看原来的 hash 值新增的那一位是 0 还是 1 就行了,是 0 的话索引没变,是 1 的化变成原索引+oldCap
JDK1.8对HashMap主要做了哪些优化?
-
数据结构:数组 + 链表改成了数组 + 链表或红黑树
原因
:发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n)
降为O(logn)
-
链表插入方式:链表的插入方式从头插法改成了尾插法
简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。
原因
:因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。 -
扩容 rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。
原因:
提高扩容的效率,更快地扩容。 -
扩容时机:在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容;
-
散列函数:1.7 做了四次移位和四次异或,jdk1.8 只做一次。
原因
:做 4 次的话,边际效用也不大,改为一次,提升效率。
HashMap是线程安全的嘛?多线程下会有什么问题?
-
多线程下扩容死循环。
原因
:JDK1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8 使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。 -
多线程的 put 可能导致元素的丢失。
-
put 和 get 并发时,可能导致 get 为 null
如何解决HashMap线程不安全的问题?
-
HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个 table 数组,粒度比较大;
-
Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
-
ConcurrentHashMap 在 jdk1.7 中使用分段锁,在 jdk1.8 中使用 CAS+synchronized。
HashMap 内部节点是有序的吗?
HashMap 是无序的,根据 hash 值随机插入。如果想使用有序的 Map,可以使用 LinkedHashMap 或者 TreeMap。
那为什么转回链表节点是用的6而不是复用8?
如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。
为什么负载因子是0.75而不是其他?
如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。
链表节点是用的6而不是复用8?
如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。
为什么负载因子是0.75而不是其他?
如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。