@[toc]
1. 引入
Java中Collection是所有单列集合的最顶层接口,它的子接口包括List、Set、Queue,接口中定义了所有单列集合实现时应遵循的规范。Map接口是所有双列集合的顶层接口,它的继承树如上所示,它具有如下的特点:
- Map集合中的元素,key和value的数据类型可以相同也可以不同
- Map集合中的元素,key不允许重复,value可以重复
- Map集合中的元素,key和value是一一对应的
关于Collection接口和Map接口,及其它们各自实现类的使用请参阅以下两篇博文,这里不再赘述。
从文章的标题出发,我们下面着重关于与Map接口及其它的实现类底层的实现原理,并从源码的角度详细的进行剖析。
2. Map接口
从上面的叙述可知,Map中存放的是key-value类型的数据,key和value是一一对应的,即通过指定的key总能找到对应value。其中key用Set来存放,从而保证key的不重复,即同一个Map对象所对应的的类须重写hashCode()
和equals()
。通常使用不可变类型的值作为key,如常用的Integer和String。Map整体存放数据的原理如下所示:
其中Entry是定义在Map接口内部的一个接口,不同Map的实现类都需要实现Entry接口。当使用Map.put()
往集合中存放key-value的数据时,实际上是存放到了一个一个的Entry中。因此,当需要遍历Map时,可以先获取到Entryset,然后使用Entry.getkey()
和Entry.getValue()
来进行键和值的获取。Entry接口定义如下:
interface Entry<K,V> {
// 获取键
K getKey();
// 获取值
V getValue();
// 设置值
V setValue(V value);
boolean equals(Object o);
int hashCode();
// 一系列的定制的Comparator,供排序时使用
public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> c1.getKey().compareTo(c2.getKey());
}
public static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K,V>> comparingByValue() {
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> c1.getValue().compareTo(c2.getValue());
}
public static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> cmp) {
Objects.requireNonNull(cmp);
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
}
public static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> cmp) {
Objects.requireNonNull(cmp);
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
}
}
3. 背景知识
在基本了解了Map接口底层的实现基础上,接下来分别来看一下Map接口的实现类,如HashMap、LinkedHashMap和TreeMap底层的实现原理,不过在阅读它们的源码之前,我们需要回顾一些背景知识,从而来方便后续源码的理解。
3.1 数据存储
计算机中常用于数据存储的数据结构有数组和链表,不管是各种类型的树还是更为复杂的图,它们底层的实现都是依赖于数组或是链表。例如,图有邻接矩阵和邻接表两种实现方式,它们各自的实现依赖就是数组和链表。数组使用的是一段物理上连续的空间来进行数据的存放,因此可以根据指定的索引进行数据的存取,适合于频繁进行查询的场景。但是物理上连续空间的要求,使得数据的插入和删除需要移动数组元素,因此并不适用于频繁进行数据插入和删除的操作。
链表并不要求使用物理上连续的空间,而是通过节点之间的引用来进行数据间的联系。因此,当需要进行数据的插入和删除时,只需要处理节点间的引用即可,适用于频繁进行插入和删除的场景。但是,链表并不能像数组一样通过索引来直接进行数据访问,而是每次访问都需要遍历一遍链表,时间复杂度相比数组较高。
基于数组和链表实现的诸如二叉树和图等其他的数据结构,由于各自结构上的特点,分别适合于不同的应用场景,这些不是这里关注的重点。之所以要讲到数组和链表,原因是Map的实现中需要用到它们。
3.2 哈希算法
哈希算法将任意长度的二进制值映射为较短的固定长度的二进制值,这个小的二进制值称为哈希值。哈希值是一段数据唯一且极其紧凑的数值表示形式。如果散列一段明文而且哪怕只更改该段落的一个字母,随后的哈希都将产生不同的值。要找到散列为同一个值的两个不同的输入,在计算上是不可能的,所以数据的哈希值可以检验数据的完整性,一般用于快速查找和加密算法。
简单来说,任意长度的数据经过哈希算法都可以得到一段固定长度的输出,这里的输出称为哈希值。而且对于一段具体的数据来说,如果哈希算法不变,那么它对应的哈希值就是唯一的,当然相同哈希值可能会对应不同的数据。
常用的哈希算法有:
算法 | 输入长度(位) | 输入长度(字节) |
---|---|---|
MD5 | 128 | 16 |
SHA-1 | 160 | 20 |
RipeMD-160 | 160 | 20 |
SHA-256 | 256 | 32 |
SHA-512 | 512 | 64 |
3.3 哈希表
哈希表(散列表)就是哈希算法借助数组的一种具体实现,不管是查询操作,还是数据的插入和删除操作,哈希表都可以在O(1)
时间复杂度内完成。哈希表的映射功能的实现还依赖于具体的哈希函数,它的形式如下所示:
%0A#card=math&code=%5Ctext%7B%E5%AD%98%E6%94%BE%E4%BD%8D%E7%BD%AE%7D%20%3D%20f%28%E6%95%B0%E6%8D%AE%29%0A)
哈希函数的实现方式多种多样,原则上只要能满足哈希算法的功能要求都可以作为哈希函数,例如常用的取模等。一个优秀的哈希函数应该是计算上简单的,散列地址上分配是均匀的。前面讲到,对于一段具体的数据来说,只要哈希函数是固定的,那么它对应的哈希值就是唯一的。但是相同哈希值可能会对应不同的数据,这就是哈希冲突。即时再好的哈希函数,仍然无法彻底避免哈希冲突的出现。故而需要相应的方法来处理它。常用于解决哈希冲突的方法有:
- 开放定址法:当关键字key的哈希地址出现冲突时,以为基础,产生另一个哈希地址,如果仍然冲突,再以为基础产生另一个哈希地址,…,直到找出一个不冲突的哈希地址,将相应元素存入其中 %20%2Bd_i)%20%25%20m%0A#card=math&code=H_i%20%3D%20%28H%28Key%29%20%2Bd_i%29%20%25%20m%0A)
其中H表示哈希函数,m为表长,为增长序列
再哈希法:同时构造不同的哈希函数,当某个哈希函数发生哈希冲突时,再使用另一个哈希函数计算,直到不再发生冲突为止
链地址法:将所有哈希地址相同的元素构成一个单链表,并将单链表的头指针存在哈希表中,适用于经常进行插入和删除的场景
4. HashMap实现原理
HashMap是Map接口最为常用的实现类,它的底层实现就是哈希表。针对于不同的JDK版本来说,又具有不同的实现方式:
- JDK7及之前:数组 + 链表
- JDK8及之后:数组 + 链表 + 红黑树
由于不同版本JDK中HashMap的实现有些差别,因此下面分别就JDK7和JDK8中的源码实现为例,对HahsMap的底层源码实现做一个分析。对于源码的解读来说,分别从属性字段、构造函数和常用方法三个方向入手。
4.1 JDK7
JDK 7中HashMap的定义如下所示:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable{}
它不仅继承了Map接口,同时还继承了Cloneable和Serializable两个接口,便于对象的克隆及序列化和反序列化操作。
4.1.1 字段
首先来看一下HashMap的字段定义:
static final int DEFAULT_INITIAL_CAPACITY = 16; // 默认初始容量大小为16
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大可设置容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认加载因子为0.75,用于扩容
transient Entry<K,V>[] table; // transient表示该字段不进行序列化,注意这里定义的为table,和JDK8中是不同的
transient int size;
int threshold; // 扩容临界值
final float loadFactor;
transient int modCount;
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
transient boolean useAltHashing;
transient final int hashSeed = sun.misc.Hashing.randomHashSeed(this);
其中:
- DEFAULT_INITIAL_CAPACITY:定义了HashMap的默认容量为16,即初始时哈希表中数组的长度
- MAXIMUM_CAPACITY:HashMap的最大容量
- DEFAULT_LOAD_FACTOR:默认的加载因子为0.75,它和HashMap的容量决定了何时进行扩容
- threshold:扩容的临界值,计算公式为
4.1.2 构造函数
然后看一下HashMap的构造函数:
// 1. 无参构造,使用默认的容量大小和加载因子
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
// 2. 只指定初始容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 3. 指定初始容量和加载因子
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量不允许为负数值,否则抛参数非法异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 当初始容量超过了最大容量,则只能初始化为规定的最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 加载因子不允许为负数值和null,否则抛参数非法异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// 注意:初始容量最终只能是2的次幂,便于后续的扩容
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
// 初始化加载因子
this.loadFactor = loadFactor;
// 初始化扩容阈值
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 新建一个Entry类型的table,用于存放数据,大小就是前面得到的capacity
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
// 最后的初始化操作
init();
}
// 4. 使用已有的Map对象进行初始化
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
putAllForCreate(m);
}
HashMap中提供了四种构造函数的实现方式,例如,最为常用的是直接调用无参构造来进行对象的实例化,此时初始容量和加载因子使用的就是字段中定义的默认值:
Map<String, String> map = new HashMap<>();
当然也可以调用HashMap(int initialCapacity)
来创建对象,此时初始容量就是在函数中传入的值,最终内部调用全参的构造函数,详细的分析可见上面的源码注释。
至于最后一种构造函数,参数列表中传递的是一个已有的Map对象,首先计算得到初始容量和加载因子,再调用全参的构造函数进行字段的初始化,最后调用putAllForCreate()
来进行对象的初始化。它的源码实现如下:
private void putAllForCreate(Map<? extends K, ? extends V> m) {
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
putForCreate(e.getKey(), e.getValue());
}
遍历map中的每一个Entry,获取到对应的key和value后,又调用了putForCreate()
,如下所示:
private void putForCreate(K key, V value) {
// 如果此时key为null,则对应的哈希值为0,放到数组首部;否则计算对应的哈希值
int hash = null == key ? 0 : hash(key);
// 获取在数组中的存放位置
int i = indexFor(hash, table.length);
// 遍历此时table中的元素
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 如果两个map相等,直接返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
e.value = value;
return;
}
}
// 否则初始化Entry对象
createEntry(hash, key, value, i);
}
最终调用的是createEntry()
来初始化每一个Entry对象。
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
4.1.3 常用方法
由上面的分析可知,HashMap的底层实现使用的是数组 + 链表形式的哈希表,而且比较重要有初始容量、最大容量和加载因子等参数。HashMap整体的数据存放如下所示:
那么它们在提供的方法中是如何被使用的呢?
4.1.3.1 put
首先来看一下最常用的put()
,方法的实现流程大致如下:
- 计算要添加的Entry对象中key的哈希值,经过一系列后续的计算,得到它在table中的索引位置
- 如果该位置没有其他Entry存放,则直接添加到该位置
如果当前索引处已有其他的Entry存在,那么依次遍历该位置的Entry链中的每个Entry,进行比较:
- 如果彼此哈希值不同,那么成功添加
- 如果彼此哈希值相同,那么继续使用
equals()
比较,如果为true,则使用当前的value进行覆盖;如果遍历结束都是false,则成功添加,添加的Entry指向原有的Entry元素
这里新添加的Entry作为该位置Entry链的head存在。方法的源码如下所示:
public V put(K key, V value) {
// 如果此时key为null, 那么调用putForNullKey方法将其放到table中
if (key == null)
return putForNullKey(value);
// 如果key不为null,首先计算对应的哈希值
int hash = hash(key);
// 通过计算的哈希值找到table中对应的存放位置
int i = indexFor(hash, table.length);
// 得到了table对应的位置后,遍历该位置的链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 依次比较当前元素和链表中元素是否相等
// 如果找到存在value相等的元素,则将当前元素的value覆盖掉原有的value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
// 并返回旧的value
return oldValue;
}
}
// 如果当前位置没有其他元素存放,则调用addEntry将其直接存放到该位置
modCount++;
addEntry(hash, key, value, i);
return null;
}
其中putForNullKey()
的源码为:
private V putForNullKey(V value) {
// 遍历table寻找key=null的位置,如果有,则使用传入的value替换掉旧的value
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
// 返回旧的value
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
它的实现也是判当前table索引为0的位置有没有元素存放,如果有,则使用传入的value覆盖掉已有的value,否则调用addEntry()
直接存到0位置。addEntry()
的源码实现为:
void addEntry(int hash, K key, V value, int bucketIndex) {
// 首先判断是否需要扩容
// 如果需要满足了扩容的条件
if ((size >= threshold) && (null != table[bucketIndex])) {
// 先扩容
resize(2 * table.length);
// 计算扩容后对应的哈希值
hash = (null != key) ? hash(key) : 0;
// 获取到应存放的索引位置
bucketIndex = indexFor(hash, table.length);
}
// 调用createEntry实现最终的保存
createEntry(hash, key, value, bucketIndex);
}
它最终调用的是createEntry()
将元素存放到对应位置的Entry中,实现如下:
void createEntry(int hash, K key, V value, int bucketIndex) {
// 先取bucketIndex位置的元素
Entry<K,V> e = table[bucketIndex];
// 将传入的元素放到bucketIndex位置
table[bucketIndex] = new Entry<>(hash, key, value, e);
// 元素个数加1
size++;
}
此外,哈希值的计算使用的是hash()
,它的实现如下所示:
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
// 如果此时的key为String类型,直接使用stringHash32获取到哈希值
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
// 否则先设定一个哈希种子
h = hashSeed;
}
// 先使用hashCode方法获取到哈希值后,再和种子做异或操作
h ^= k.hashCode();
// 然后再和本身移位后的结果异或
h ^= (h >>> 20) ^ (h >>> 12);
// 返回最终使用的哈希值
return h ^ (h >>> 7) ^ (h >>> 4);
}
4.1.3.2 get
get()
就是使用指定的key来从Map中获取到相应的value,它的源码实现如下:
public V get(Object key) {
// 如果输入的key为null,ze调用getForNullKey返回table索引为0位置的value
if (key == null)
return getForNullKey();
// 否则,调用getEntry获取到相应的value
Entry<K,V> entry = getEntry(key);
// 如果获取到的entry为null,则返回null;否则返回相应的value
return null == entry ? null : entry.getValue();
}
其中getForNullKey()
的实现如下:
private V getForNullKey() {
// 遍历table找到第一个存放key为null的值
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
// 如果之前已经存放过key为null的Entry,则返回对应的value
if (e.key == null)
return e.value;
}
// 如果table中没有针对key=null的值存在,直接返回null
return null;
}
这就是HashMap和Hashtable的一个不同之处:
- Hashtable不允许使用null作为key和value
- HashMap允许使用null作为key和value
4.1.3.3 resize
resize()
对应的功能就是扩容,那为什么需要扩容?以及HashMap中是如何实现扩容的呢?由上面的分析知道,一个优秀的哈希函数不仅计算要简单,更重要的是散列地址要均匀。哈希表中存放元素时,大部分的元素都可以直接存放到数组中,只有一小部分需构成链,这样在数据的有关操作时,只需要针对于数组即可,此时的效率是很高的。
HashMap在初始化时通过capacity指定了table的初始容量,当存放的元素不太多时,哈希冲突的概率很小,但是当存放的元素越来越多时,哈希冲突就会频繁发生。为了存放所有的元素,table的每个位置可能都会维持一条长长的链表,而对链表执行查询操作,效率是很低的。既然在初始容量下,当前的哈希函数已经无法保持它的优异性,那么就对table进行扩容。扩容后原来存放的元素要重新计算在新table中的位置,然后再放到相应的位置上,这里性能开销也是很大。但如果频繁的执行查询操作,相比于对链表不停地查询,这样的开销还是可以忍受的。
那么HashMap是怎么执行扩容操作的呢? 当HashMap中元素的个数超过了数组大小和加载因子的乘积时,就会对table进行扩容。例如HashMap初始默认的数组大小为16,加载因子为0.75,那么当table中元素个数大于12() 时就会扩容,此时扩容操作会将table的大小扩展为当前的2倍,如,然后重新计算元素在新table中的位置,最后将它们重新放到新table中。
具体表现在使用addEntry()
最终添加元素时,如果满足了给定的条件,就需要进行扩容。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容
resize(2 * table.length);
// 如果key不为null要重新计算哈希值,否则哈希值设为0
hash = (null != key) ? hash(key) : 0;
// 根据哈希值和新table的大小重新计算在table中的位置索引
bucketIndex = indexFor(hash, table.length);
}
// 最后按照新的位置存放元素
createEntry(hash, key, value, bucketIndex);
}
而resize()
具体的源码实现如下:
// 参数列表中指定新的容量
void resize(int newCapacity) {
// 首先保存旧table
Entry[] oldTable = table;
// 获取旧table的容量
int oldCapacity = oldTable.length;
// 如果旧table的容量已经到达了HashMap的最大容量,即2^30
if (oldCapacity == MAXIMUM_CAPACITY) {
// 修改阈值为Integer的最大值(2^31-1),这样以后就不会扩容了
threshold = Integer.MAX_VALUE;
return;
}
// 如果table的容量还没有到达最大容量值
// 首先使用新容量构建一个Entry数组,即新的table
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
// 判断是否需要重新进行哈希值计算
boolean rehash = oldAltHashing ^ useAltHashing;
// 将旧table中的元素重新放到新table对应的位置上
transfer(newTable, rehash);
// 替换到原有的table
table = newTable;
// 根据新table的大小重新计算threshold
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
有关数组重复添加操作的方法实现transfer()
源码如下所示:
void transfer(Entry[] newTable, boolean rehash) {
// 获取新table的大小
int newCapacity = newTable.length;
// 遍历旧table中的每一个Entry
for (Entry<K,V> e : table) {
// 如果当前的Entry不为null
while(null != e) {
// 获取链上的Entry
Entry<K,V> next = e.next;
// 如果此时需要重新计算hash,则获取新的哈希值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 重新计算每个元素在数组中的位置
int i = indexFor(e.hash, newCapacity);
// 标记
e.next = newTable[i];
// 将元素放在数组上
newTable[i] = e;
// 访问下一个Entry链上的元素
e = next;
}
}
}
其中e.next = newTable[i]
中newTable[i]的引用赋给了e.next,也就是使用了单链表的头插法,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话)。
4.2 JDK8
4.2.1 字段
HashMap在JDK8中是如何实现的呢?它和JDK7中的实现有何不同呢?下面我们依然通过字段、构造函数和常用方法来看一下区别之处。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 指定了一个固定的序列化ID
private static final long serialVersionUID = 362498820763181265L;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // a 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8; // 链表转为红黑树的阈值
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树重新转换为链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<K,V>[] table; // ! 这里的table是Node类型,而不是JDK中的Entry类型
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
}
除了和JDK7中定义的相同字段外,还有一些新的字段加入,如:
serialVersionUID:序列化ID,作为唯一识别标志,用于序列化和反序列化
TREEIFY_THRESHOLD:table的Bucket中链表的长度大于该值时,将链表转换为红黑树,默认为8
UNTREEIFY_THRESHOLD:Bucket中红黑树存储的元素个数小于该值时,转换为链表,默认为6
MIN_TREEIFY_CAPACITY:Bucket中元素数被转换为红黑树的最小容量,默认为64
table:此时的table的类型为Node,它是HashMap中定义的一个静态的内部类,Node的定义如下:
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
4.2.2 构造函数
接着看一下构造函数中如何操作上述定义的字段,同样提供了四个构造函数,基本上和JDK7中是一致的。
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);
// 这里并没有直接按照默认初始容量初始化table
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
但不同之处在于,这里并没有在全参构造函数中直接初始化一个大小为capacity的table,只是设置了loadFactor和threshold。另外HashMap(Map<? extends K, ? extends V> m)
中的实现也有差别,这些等看完常用方法中的改变后再回头来理解。
4.2.3 常用方法
4.2.3.1 put
方法执行流程如下:
下面依然看一下put()
、get()
和resize()
这三个方面的具体实现,首先是put()
的实现,源码如下所示:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
这里在方法体内部并没有具体的实现逻辑,而是调用了putVal()
,它的源码实现如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 常量定义
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果当前的table为null,调用resize()初始化table的大小,创建一个哈希表
// 由于最一开始初始化时并没有初始化table,所以第一次调用put方法时table必定为null
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果当前Bucket没有哈希冲突,则直接把key和value构造为Node类型后插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 如果有哈希冲突
else {
Node<K,V> e; K k;
// 比较头节点的扰动hash值及key值
// 如果相同则说明存入的节点key已存在,而且就是头节点
// 先获取该节点,是否覆盖其值进一步处理
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果是采用红黑树的方式处理冲突,则通过红黑树的putTreeVal方法去插入这个键值对
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 否则说明此时采用的仍是链表结构
// 遍历当前的链表,判断链表中是否存在和要插入的数据中key相同的节点
for (int binCount = 0; ; ++binCount) {
// 如果遍历到尾部仍未发现,说明要插入的数据可作为一个新节点
if ((e = p.next) == null) {
// 构造为Node类型作为当前链的最后一个数据插入
// 这里采用的是尾插法
p.next = newNode(hash, key, value, null);
// 如果此时链的长度大于TREEIFY_THRESHOLD这个临界值,则把链变为红黑树
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;
// 方法传入的onlyIfAbsent参数为false,或者旧值为null则直接替换掉旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 以上操作以及全部完成,并且已经成功插入或更改一个节点的值
// 修改modCount的值,记录修改次数
++modCount;
// 更新size
// 判断插入新节点后是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
可以看到,由于JDK8中HashMap采用了数组+ 链表+ 红黑树结构,在插入数据时,不仅要考虑是否需要扩容,还需要考虑当前的具体情况,是采用链表的方法插入还是使用红黑树的方法插入,以及是否需要将链表转换为红黑树。总体来说,put()
的执行逻辑为:
- 首先计算哈希值找到应将其放入那个Bucket中
- 如果此时没有发生了哈希冲突,则直接插入即可
如果此时发生了哈希冲突,那么需要根据具体的情况进行处理:
- 如果此时Bucket已经采用了红黑树,那么就使用红黑树的方法插入
- 否则继续使用链表的方法插入,而且当插入后链表的长度到达设置的临界值时,还需要将链表转换为红黑树
- 插入的过程中,如果存在重复的key,还需要将其value替换为新的值
- 数据插入结束后,如果size超过了阈值,还需要进行扩容
理解了put()
的实现后,再去看最后一个构造函数的实现就很轻松了。
4.2.3.2 get
get()
的实现相比来说较为简单,源码如下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
它也并没有直接实现,而是调用了getNode()
方法,如下所示:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果哈希表不为空,以及Bucket不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 首先看第一个节点是否是要找的相等节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 如果是,直接返回该节点
return first;
// 如果还有后续的节点,继续遍历
if ((e = first.next) != null) {
// 如果当前的桶是采用红黑树处理冲突,则调用红黑树的get方法去获取节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 不是红黑树的话,那采用链表的方法,循环判断链中是否存在该key
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 如果所有情况下都找不到,那么返回null
return null;
}
get()
的执行逻辑同样需要区分此时是否已经采用了红黑树,如果是,则采用红黑树的方法进行处理,否则采用链表的方法处理。总体的执行逻辑如下:
- 首先计算哈希值找到应将其放入那个Bucket中
- 如果此时Bucket中的key就是传入的key,那么直接返回该节点
否则继续查看后续节点
- 如果已经采用了红黑树,使用红黑树的方法查找key
- 否则采用链表的方法,循环遍历整个链表查找key
4.2.3.3 resize
JDK8中扩容操作虽然仍然是将其变为当前的2倍,但实现的方法更为的巧妙。因为每次扩容都是翻倍,与原来计算%20%5C%26%20%5C%20hash#card=math&code=%28n-1%29%20%5C%26%20%5C%20hash)的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到原位置+旧容量这个位置。
假设原来的容量为32,那么应该拿hash跟31(0x11111)做与操作;在扩容为64之后,应该拿hash跟63(0x111111)做与操作。新容量跟原来相比只是多了一个bit位,假设原来的位置在23,
- 那么当新增的那个bit位的计算结果为0时,那么该节点还是在23
- 反之,当计算结果为1时,则该节点会被分配到23+31的Bucket中
这样扩容后,每个Bucket中的节点数最多只能和之前的一样,而更大概率是比之前的少,这样就减少了哈希冲突的发生。resize()
的源码实现如下:
final Node<K,V>[] resize() {
// 首先获取旧table
Node<K,V>[] oldTab = table;
// 获取旧table的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 获取旧的阈值
int oldThr = threshold;
// 初始化新的容量和阈值为0
int newCap, newThr = 0;
// 如果旧table不为空
if (oldCap > 0) {
// 如果此时旧table已经达到了最大容量,则无法继续进行扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 否则将其扩充为原来的2倍
newThr = oldThr << 1;
}
// 将旧的阈值作为新table的初始容量
else if (oldThr > 0)
newCap = oldThr;
else {
// 否则使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 更新当前阈值字段的值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 使用扩容后的容量创建新的哈希表
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 遍历旧哈希表的每个Bucket,重新计算Bucket里元素的新位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果桶上只有一个键值对,则直接插入
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果是通过红黑树来处理冲突的,则调用相关方法把树分离开
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 如果采用链式处理冲突
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 最后返回扩容后的新table
return newTab;
}
5. 细节问题
经过前面对于HashMap的源码解读后,我们对于很多相关的问题应该是有所体会了。因此,这里趁热打铁来看一下所涉及的其他细节问题。
5.1 HashMap中存放自定义对象为什么要重写hashCode和equals方法?
HashMap在使用put
方法存放新的键值对数据时,不经要比较哈希值,而且还要使用equals方法进行比较,这是因为hashCode可以帮助快速的找到数据在哈希表中的位置,但是位置相同的数据并不一定相等,还需要使用equals方法进行最终判断两个Entry对象是否相等。
那为什么使用equals进行判断,而不使用==进行判断呢?这里因为,==用于比较两个对象是否相等时,实际上比较的是对象的引用,知道两个对象的引用指向的是同一地址,那么就判断相等。而equals方法判断两个对象相等的标识是:两个对象的类型一致。并且内容一致。而这显然更符合HashMap中的需求。
5.2 哈希表采用LinkedList实现是否可以?
答案当然是可以的!只要是可以根据给定的索引进行数据的查找的数据结构,理论上都可以作为哈希表实现的结构基础。HashMap之所以采用的是数组实现,是因为此时数组的实现方式已经够用,效率相比更复杂的实现更高。因此,HashMap采用了数组实现的哈希表。
5.3 扩容时为什么要求是2的幂次形式?
至于为什么是2的幂次,在JDK8的resize部分已有初步说明,更详细的内容可以看出HashMap实现原理及源码分析相关部分的阐述,这里就不再重写了。
5.4 为什么当链表的长度超过8时才转换为红黑树?什么时候又退化为链表?
当哈希表中Bucket中的链表长度超过8时,就需要将其转换为红黑树,从而保证查询的高效性。之所以不是只使用红黑树,是因为当Bucket中数据较少时,使用红黑树带来的查询的效率提升无法抵消红黑树左旋、右旋、变色和操作带来的额外开销。因此,当数据个数小于8时,仍采用传统的链表。
而当不断的扩容等操作导致树中的元素个数等于6时,红黑树再次转换为链表。中间的差值7是为了两者之间频繁的转换,而带来的性能开销。如果数据数为7时为链表,就继续使用链表;如果数据数为7时为红黑树,就继续使用红黑树。只有当达到了转换的阈值时才进行转换操作。
那么能否使用更简单的二叉树呢?答案是不可以的。因为二叉树极端情况下相当于单链表,那么使用它的意义就没有了。
5.5 通常使用何种类型数据作为key?
HashMap使用哈希值来找到数据在哈希表中的存放位置,因此,哈希值变化越小越好。因此,通常使用String或Integer类型的数据作为key,这样它对应的哈希值在每次使用时不需要重新计算,可以实现快速的存取。
5.6 HashMap为什么是线程不安全的?
线程不安全问题出在不同的线程对同一个HaahMap进行put操作的过程中,往深了说主要出现在HashMap的扩容过程中。如果不同的线程对HashMap只是执行get操作,并不会涉及改变HashMap内部结构和存储数据的情况,那么即时再多的线程访问,也不活出现线程不安全问题。
然而,put操作可能会触发扩容。扩容执行过程中不仅需要将当前HashMap扩充为当前容量的2倍,而且还需要根据新的容量来改变索引的计算规则。根据新的计算规则,将之前已有的Entry类型或是Node类型的数据重新放入扩容后的HashMap中。但是,如果现在多个线程都触发了HashMap的扩容机制,那么由于HashMap底层对于哈希冲突采用的是链地址法,所有重新存放的过程中就需要进行链表的构建。Jdk1.7及之前HashMap采用的都是头插法,如果不同线程之间的操作不同,那么对于同一个索引地址的链表就可能会出现循环链表的情况,这就是出现了所谓的线程不安全问题。另外,也可能会出现数据丢失问题。
Jdk8在重新放置元素的过程中,对于相同哈希值的元素放置过程中,可能会出现后面放的元素将前面放的元素覆盖的情况,此时也认为是线程不安全。
总结:
在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。
详细的分析过程可见:
6. 参考
Java 集合系列10之 HashMap详细介绍(源码解析)和使用示例
Java 8系列之重新认识HashMap
JDK8 HashMap 源码解析
HashMap面试指南
HashMap实现原理及源码分析
Java中HashMap的实现原理
一文解读JDK8中HashMap的源码