深入浅出的理解HsahMap的实现原理及常见面试题

126 阅读29分钟

文章目录


Map继承树


1. 引入

Java中Collection是所有单列集合的最顶层接口,它的子接口包括List、Set、Queue,接口中定义了所有单列集合实现时应遵循的规范。Map接口是所有双列集合的顶层接口,它的继承树如上所示,它具有如下的特点:

  • Map集合中的元素,key和value的数据类型可以相同也可以不同
  • Map集合中的元素,key不允许重复,value可以重复
  • Map集合中的元素,key和value是一一对应的

关于Collection接口和Map接口,及其它们各自实现类的使用请参阅以下两篇博文,这里不再赘述。

浅析Java中的Collection接口

浅析Java中的Map接口及实现类

从文章的标题出发,我们下面着重关于与Map接口及其它的实现类底层的实现原理,并从源码的角度详细的进行剖析。



2. Map接口

从上面的叙述可知,Map中存放的是key-value类型的数据,key和value是一一对应的,即通过指定的key总能找到对应value。其中key用Set来存放,从而保证key的不重复,即同一个Map对象所对应的的类须重写hashCode()equals()通常使用不可变类型的值作为key,如常用的Integer和String。Map整体存放数据的原理如下所示:

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接口的实现类,如HashMapLinkedHashMapTreeMap底层的实现原理,不过在阅读它们的源码之前,我们需要回顾一些背景知识,从而来方便后续源码的理解。


3.1 数据存储

计算机中常用于数据存储的数据结构有数组链表,不管是各种类型的树还是更为复杂的图,它们底层的实现都是依赖于数组或是链表。例如,图有邻接矩阵和邻接表两种实现方式,它们各自的实现依赖就是数组和链表。数组使用的是一段物理上连续的空间来进行数据的存放,因此可以根据指定的索引进行数据的存取,适合于频繁进行查询的场景。但是物理上连续空间的要求,使得数据的插入和删除需要移动数组元素,因此并不适用于频繁进行数据插入和删除的操作。

链表并不要求使用物理上连续的空间,而是通过节点之间的引用来进行数据间的联系。因此,当需要进行数据的插入和删除时,只需要处理节点间的引用即可,适用于频繁进行插入和删除的场景。但是,链表并不能像数组一样通过索引来直接进行数据访问,而是每次访问都需要遍历一遍链表,时间复杂度相比数组较高。

数组和链表

基于数组和链表实现的诸如二叉树和图等其他的数据结构,由于各自结构上的特点,分别适合于不同的应用场景,这些不是这里关注的重点。之所以要讲到数组和链表,原因是Map的实现中需要用到它们。


3.2 哈希算法

哈希算法将任意长度的二进制值映射为较短的固定长度的二进制值,这个小的二进制值称为哈希值。哈希值是一段数据唯一且极其紧凑的数值表示形式。如果散列一段明文而且哪怕只更改该段落的一个字母,随后的哈希都将产生不同的值。要找到散列为同一个值的两个不同的输入,在计算上是不可能的,所以数据的哈希值可以检验数据的完整性,一般用于快速查找和加密算法。

哈希算法

简单来说,任意长度的数据经过哈希算法都可以得到一段固定长度的输出,这里的输出称为哈希值。而且对于一段具体的数据来说,如果哈希算法不变,那么它对应的哈希值就是唯一的,当然相同哈希值可能会对应不同的数据。

常用的哈希算法有:

算法输入长度(位)输入长度(字节)
MD512816
SHA-116020
RipeMD-16016020
SHA-25625632
SHA-51251264

3.3 哈希表

哈希表(散列表)就是哈希算法借助数组的一种具体实现,不管是查询操作,还是数据的插入和删除操作,哈希表都可以在O(1)时间复杂度内完成。哈希表的映射功能的实现还依赖于具体的哈希函数 f f f,它的形式如下所示:
存放位置 = f ( 数 据 ) \text{存放位置} = f(数据) 存放位置=f(数据)
哈希函数的实现方式多种多样,原则上只要能满足哈希算法的功能要求都可以作为哈希函数,例如常用的取模等。一个优秀的哈希函数应该是计算上简单的,散列地址上分配是均匀的。前面讲到,对于一段具体的数据来说,只要哈希函数是固定的,那么它对应的哈希值就是唯一的。但是相同哈希值可能会对应不同的数据,这就是哈希冲突。即时再好的哈希函数,仍然无法彻底避免哈希冲突的出现。故而需要相应的方法来处理它。常用于解决哈希冲突的方法有:

  • 开放定址法:当关键字key的哈希地址 p = H ( k e y ) p=H(key) p=H(key)出现冲突时,以 p p p为基础,产生另一个哈希地址 p 1 p_1 p1​,如果 p 1 p_1 p1​仍然冲突,再以 p 1 p_1 p1​为基础产生另一个哈希地址 p 2 p_2 p2​,…,直到找出一个不冲突的哈希地址 p i p_i pi​,将相应元素存入其中
    H i = ( H ( K e y ) + d i ) H_i = (H(Key) +d_i) % m Hi​=(H(Key)+di​)
    其中H表示哈希函数,m为表长, d i d_i di​为增长序列
  • 再哈希法:同时构造不同的哈希函数,当某个哈希函数发生哈希冲突时,再使用另一个哈希函数计算,直到不再发生冲突为止
  • 链地址法:将所有哈希地址相同的元素构成一个单链表,并将单链表的头指针存在哈希表中,适用于经常进行插入和删除的场景


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:扩容的临界值,计算公式为 t h r e s h o l d = c a p a c i t y × l o a d F a c t o r threshold = capacity \times loadFactor threshold=capacity×loadFactor

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整体的数据存放如下所示:

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( 16 × 0.75 = 12 16 \times 0.75 = 12 16×0.75=12) 时就会扩容,此时扩容操作会将table的大小扩展为当前的2倍,如 2 × 16 = 32 2 \times 16 = 32 2×16=32,然后重新计算元素在新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倍,但实现的方法更为的巧妙。因为每次扩容都是翻倍,与原来计算 ( n − 1 ) &   h a s h (n-1) \& \ hash (n−1)& hash的结果相比,只是多了一个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中,在多线程环境下,会发生数据覆盖的情况。

详细的分析过程可见:

三太子敖丙 - 面试官:HashMap 为什么线程不安全?



6. 参考

Java 集合系列10之 HashMap详细介绍(源码解析)和使用示例

Java 8系列之重新认识HashMap

JDK8 HashMap 源码解析

HashMap面试指南

HashMap实现原理及源码分析

Java中HashMap的实现原理

一文解读JDK8中HashMap的源码