一、HashMap的底层原理?
1. 面试题分析
HashMap是
Java
程序员使用频率最高的用于映射 (键值对
)
处理的数据类型。随着
JDK
(Java Developmet Kit)版本的更新,
JDK1.8
对 HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。本文结合JDK1.7 和JDK1.8
的区别,深入探讨
HashMap
的结构实现和功能原理。
2. 介绍
Java为数据结构中的映射定义了一个接口
java.util.Map
,此接口主要有四个常用的实现类,分别是HashMap
、
Hashtable
、
LinkedHashMap
和
TreeMap
,类继承关系如下图所示:
1. HashMap:
它根据键的hashCode
值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap
最多只允许一条记录的键为null
,允许多条记录的值为
null
。
HashMap
非线程安全,即任一时刻可以有多个线程同时写HashMap
,可能会导致数据的不一致。如果需要满足线程安全,可以用
Collections
的
synchronizedMap
方法使
HashMap
具有线程安全的能力,或者使用
ConcurrentHashMap
。
2. Hashtable:
Hashtable
是遗留类,很多映射的常用功能与HashMap
类似,不同的是它承自Dictionary
类,并且是线程安全的,任一时间只有一个线程能写Hashtable
,并发性不如
ConcurrentHashMap
,因为
ConcurrentHashMap
引入了分段锁。
Hashtable
不建议在新代码中使用,不需要线程安全的场合可以用HashMap
替换,需要线程安全的场合可以用ConcurrentHashMap
替换。
3. LinkedHashMap:
LinkedHashMap
是
HashMap
的一个子类,保存了记录的插入顺序,在用Iterator
遍历
LinkedHashMap
时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
4. TreeMap:
TreeMap
实现
SortedMap
接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator
遍历
TreeMap
时,得到的记录是排过序的。如果使用排序的映射,建议使用
TreeMap
。在使用
TreeMap
时,
key
必须实现
Comparable
接口或者在构造
TreeMap
传入自定义的Comparator
,否则会在运行时抛出
java.lang.ClassCastException
类型的异常。
3. 存储结构-字段(示意图+源码)
1.
从结构实现来讲,
HashMap
是数组
+
链表
+
红黑树(
JDK1.8
增加了红黑树部分)实现的,如下如所示
a. 从源码可知,
HashMap
类中有一个非常重要的字段,就是 Node[] table
,即哈希桶数组,明显它是一个Node
的数组。我们来看Node[JDK1.8]
是何物。
static class Node<K, V> implements Map.Entry<K, V> {
final int hash; // 用来定位数组索引位置 final K key; V value; Node<K,V> next; //链 表的下一个node
Node(int hash, K key, V value, Node<K, V> next) { ...}
public final K getKey() { ...}
public final V getValue() { ...}
public final String toString() { ...}
public final int hashCode() { ...}
public final V setValue(V newValue) { ...}
public final boolean equals(Object o) { ...}
}
Node是
HashMap
的一个内部类,实现了
Map.Entry
接口,本质是就是一个映射
(
键值对)
。上图中的每个黑色圆点就是一个
Node
对象。
b. HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java
中
HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash
后,
得到数组下标,把数据放在对应下标元素 的链表上。 例如程序执行下面代码:
map.put("name", "qixi");
系统将调用"name"
这个
key
的
hashCode()方法得到其hashCode
值(该方 法适用于每个Java
对象),然后再通过
Hash
算法的后两步运算(高位运 算和取模运算,下文有介绍)来定位该键值对的存储位置,有时两个 key
会定位到相同的位置,表示发生了
Hash
碰撞。
当然Hash
算法计算结果越分散均匀,Hash
碰撞的概率就越小,
map
的存取效率就会越高。
如果哈希桶数组很大,即使较差的
Hash算法也会比较分散,如果哈希桶 数组数组很小,即使好的Hash
算法也会出现较多碰撞,所以就需要在空 间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组 的大小,并在此基础上设计好的hash
算法减少
Hash
碰撞。
那么通过什么方式来控制map使得
Hash
碰撞的概率又小,哈希桶数组(
Node[] table
) 占用空间又少呢?答案就是好的Hash
算法和扩容机制。
map.put("name", "qixi");在理解
Hash
和扩容流程之前,我们得先了解下HashMap
的几个字段。 从
HashMap
的默认构造函数源码可知,构造函数就是对下面几个字段进 行初始化,源码如下:
int threshold; // 所能容纳的key-value对极限
final float loadFactor; // 负载因子
int modCount;
int size;
首先,Node[] table
的初始化长度
length(
默认值是16)
,
Load factor
为负载因子
(
默认值是
0.75)
,
threshold
是
HashMap
所能容纳的最大数据量的Node(
键值对
)
个数。
threshold =
length * Load factor
。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold就是在此Load factor和
length(
数组长度
)
对应下允许的最大元素数目,超过这个数目就重新resize(
扩容
)
,扩容后的
HashMap
容量是之前容量的两倍。
默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子
Load factor
的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor
的值,这个值可以大于1
。
size
这个字段其实很好理解,就是
HashMap中实际存在的键值对数量。
注意和table的长度length
、容纳最大键值对数量
threshold
的区别。
而modCount字段主要用来记录
HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put
新键值对,但是某个key
对应的
value
值被覆盖不属于结构变化。
在HashMap
中,哈希桶数组
table
的长度
length
大小必须为
2
的
n
次方
(
一定是合数
)
,这是一种非常规的设计,常规的设计是把桶的大小设计为素数。
相对来说素数导致冲突的概率要小于合数Hashtable初始化桶大小为
11
,就是桶大小设计为素数的应用(
Hashtable
扩容后不能保证还是素数)。
HashMap
采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap
定位哈希桶索引位置时,也加入了高位参与运算的过程。
这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。
于是,在JDK1.8
版本
中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8
) 时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap
的性能,其中会用到红黑树的插入、删除、查找等算法。
4. 功能实现-方法
HashMap
的内部功能实现很多,本文主要从根据key
获取哈希桶数组索引位置、
put
方法的详细执行、扩容过程三个具有代表性的点深入展开讲解。
1.
确定哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap
里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用
hash
算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。
HashMap 定位数组索引位置,直接决定了hash方法的离散性能。
先看看源码的实现(方法一
+
方法二):
// 方法一:
static final int hash(Object key) {
// jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一 步 取hashCode值
// h ^ (h >>> 16) 为第二步 高 位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 方法二:
static int indexFor(int h, int length) {
// jdk1.7的源码,jdk1.8没 有这个方法,但是实现原理一样的
return h & (length - 1); //第 三步 取模运算
}
这里的Hash
算法本质上就是三步:取
key
的
hashCode
值、高位运算、取模运算。
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash
码值总是相同的。
我们首先想到的就是把hash
值对数组长度取模运算,这样一来,元素的
分布相对来说是比较均匀的。
但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table
数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length-1)
来得到该对象的保存位,而
HashMap
底层数组的长度总是2
的
n
次方,这是
HashMap
在速度上的优化。
当length总是
2
的
n
次方时,
h& (length-
1)
运算等价于对
length
取模,也就是
h%length
,但是&
比
%
具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()
的高
16
位异或低
16
位实现的:(h = k.hashCode()) ^ (h >>> 16)
,主要是从速度、功效、质量来考虑的,这么做可以在数组
table
的
length
比较小的时候,也能保证考虑到高低Bit
都参与到
Hash
的计算中,同时不会有太大的开销。
下面举例说明下,n
为
table
的长度。
4. Change变形延伸
hashmap的扩容
练习题
4
道练习
(2
必做
, 2
个选做
)
二、HashMap的内部数据结构?
1.面试题分析
根据题目要求我们可以知道:
该题是一个数据结构问题,可以从HashMap的底层数据结构进行分析;
建议从多个不同jdk版本进行分析;
分析需要全面并且有深度
容易被忽略的坑
分析片面
没有深入
2.HashMap数据结构介绍
JDK1.8版本的,内部使用数组
+
链表红黑树
数据结构图:
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
并且数组长度大于
64
,大于的话链表转换为红黑树;
7. 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。
HashMap怎么设定初始容量大小?
一般如果 new HashMap()
不传值,默认大小是
16
,负载因子是
0.75
, 如果自己传入初始大小
k
,初始化大小为 大于k的
2
的整数次方,例如如果传
10
,大小为
16
。
(补充说明:
实现代码如下)
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;
}
123456789
补充说明:下图是详细过程,算法就是让初始二进制右移1
,
2
,
4
,
8
,
16
位,分别与自己位或,把高位第一个为1
的数通过不断右移,把高位为
1
的后面全变为
1
,最后再进行
+1
操作,
111111 + 1 = 1000000 = 26
(符合大于50
并且是
2
的整数次幂 )
HashMap的哈希函数设计
hash函数是先拿到
key
的
hashcode
,是一个
32
位的
int
值,然后让
hashcode
的高
16
位和低
16
位进行异或操作。
static final int hash(Object key){
int h;
// 1. 允许key为null,hash = 0;
// 2. ^ 异或,后面介绍这个算法;
return (key == null) ? 0 : (h = key.hashCode()) ^ ( h >>> 16);
}
这么设计有二点原因:
1. 一定要尽可能降低
hash
碰撞,越分散越好;
2. 算法一定要尽可能高效,因为这是高频操作
,
因此采用位运算;
为什么采用hashcode
的高
16
位和低
16
位异或能降低
hash
碰撞?
hash
函数能不能直接用
key
的hashcode?
因为
key.hashCode()
函数调用的是
key
键值类型自带的哈希函数,返回
int
型散列值。
int
值范围为-2147483648~2147483647
,前后加起来大概
40
亿的映射空间。只要哈希函数映射得比较均匀松 散,一般应用是很难出现碰撞的。但问题是一个40
亿长度的数组,内存是放不下的。你想,如果 HashMap数组的初始大小才
16
,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。源码中模运算就是把散列值和数组长度-1
做一个
"
与
"
操作,位运算比取余
%
运算要快。
static int indexFor(int h, int length) {
return h & (length - 1);
}
12345
这也正好解释了为什么HashMap
的数组长度要取
2
的整数幂。因为这样(数组长度
-1
)正好相当于一个“低位掩码
”
。
“
与
”
操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16
为例,
16-1=15
。
2
进制表示是
00000000 00000000 00001111
。和某散列值做
“
与
”
操作如下,结果就是截取了最低的四位值。
10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101 //高位全部归零,只保留末四位
// 1234
但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更 要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复。
此时“
扰动函数
”
的价值就体现出来了,说到这里大家应该猜出来了。
看下面这个图:
右移16
位,正好是
32bit
的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。最后我们来看一下
Peter Lawley
的一篇专栏文章《
An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了
352
个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。
结果显示,当HashMap
数组长度为
512
的时候(
29
),也就是用掩码取低
9
位的时候,在没有扰动函数的情况下,发生了103
次碰撞,接近
30%
。而在使用了扰动函数之后只有
92
次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。
另外Java1.8
相比
1.7
做了调整,
1.7
做了四次移位和四次异或,但明显
Java 8
觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。
下面是1.7
的
hash
代码:
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
1234
1.8对hash函数做了优化,1.8还有别的优化?
1. 数组
+
链表改成了数组
+
链表或红黑树;
2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,
1.7
将新元素放到数组中,原始节点作为新节点的后继节点,1.8
遍历链表,将元素放置到链表的最后;
3. 扩容的时候
1.7
需要对原数组中的元素进行重新
hash
定位在新数组的位置,
1.8
采用更简单的判断逻辑,位置不变或索引+
旧容量大小;
4. 在插入时,
1.7
先判断是否需要扩容,再插入,
1.8
先进行插入,插入完成再判断是否需要扩容;
优化目的:
1. 防止发生hash
冲突,链表长度过长,将时间复杂度由
O(n)
降为
O(logn)
;
2. 因为1.7
头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;A线程在插入节点B,
B
线程也在插入,遇到容量不够开始扩容,重新
hash
,放置元素,采用头插法,后遍历到的B
节点放入了头部,这样形成了环,如下图所示:
1.7的扩容调用
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];
//A线程如果执行到这一行挂起,B线程开始进行扩容
newTable[i] = e;
e = next;
}
}
}
12345678910111213141516
3. 扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?
这是由于扩容是扩大为原数组大小的2
倍,用于计算数组位置的掩码仅仅只是高位多了一个
1
,怎么理解呢?
扩容前长度为16
,用于计算
(n-1) & hash
的二进制
n-1
为
0000 1111
,扩容为
32
后的二进制就高位多了1
,为
0001 1111
。
因为是&
运算,
1
和任何数
&
都是它本身,那就分二种情况,如下图:原数据
hashcode
高位第
4
位为0
和高位为
1
的情况;
第四位高位为0
,重新
hash
数值不变,第四位为
1
,重新
hash
数值比原来大
16
(旧数组的容量)
那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();
afterNodeInsertion(evict);
return null;
}
123456789101112131415161718192021222324252627282930313233343536373839404142
怎么解决这个线程不安全的问题?
Java中有
HashTable
、
Collections.synchronizedMap
、以及
ConcurrentHashMap
可以实现线程安全的Map。
HashTable是直接在操作方法上加
synchronized
关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用
Collections
集合工具的内部类,通过传入
Map
封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;
ConcurrentHashMap
使用分段锁,降低了锁粒度,让并发度大大提高。
三.扩展内容
ConcurrentHashMap的分段锁的实现原理吗?
链表转红黑树是链表长度达到阈值,这个阈值是多少?为什么?
HashMap内部节点是有序的吗?
讲讲LinkedHashMap
怎么实现有序的?
讲讲TreeMap
怎么实现有序的?
通过CAS
和
synchronized
结合实现锁粒度的降低,讲讲
CAS
的实现以及
synchronized
的实现原理吗?