HashMap 实现原理及源码分析

HashMap是JDK中很是重要的容器,采用 数组 + 链表 的方式实现,理想状况下能支持 O(1) 时间复杂度的增删改查操做。本文将由浅入深地讲解哈希表的实现原理,并对HashMap的部分源码进行分析。
node

1. 从数组提及

数组应该是咱们最早学习的数据结构,它是内存中一块连续的存储单元,所以计算机能够根据数组起始地址、元素长度和下标,计算出咱们要访问的元素的地址,时间复杂度为 O(1) 。
程序员

如下代码定义了一个简单的 Student 类,假如咱们要存储 20 个 Student 对象,咱们但愿可以在 O(1) 时间复杂度内,根据 studentID 找到相应的对象。
面试

public class Student {
    public int studentID;
    public String name;
    public Student(int studentID, String name) {
        this.studentID = studentID;
        this.name = name;
    }
} 复制代码

若是咱们要存储的 20 个 Student 对象的 studentID 恰好就是从 0 到 19,咱们天然能够新建一个长度为 20 的 Student 数组 students,而后将对象的 studentID 做为数组下标,放到对应的 slot 里面,以下图所示。这样的话,若是咱们想找 studentID 为 15 的对象,咱们就能够直接访问 students[15]。
spring

Student[] students = new Student[20];        
Student stu0 = new Student(0, "stu0");
Student stu19 = new Student(19, "stu19"); 

students[stu0.studentID] = stu0;
students[stu19.studentID] = stu19; 复制代码

为了表述方便,咱们用 key 表示查找关键字,在这里指的 studentID,用 value 表示查找内容,这里指的 Student 对象,用 slot 表示数组的每个元素,slot 由数组下标 index 来惟一标识(slot 的意思是槽,数组的元素就像是一个槽同样,等着被 Student 对象填满)。下图展现了 Student 对象在数组中的存储状态。 
数组

在这里插入图片描述

可是实际状况极可能是这 20 个 Student 对象的 studentID 在某个范围内随机分布,好比 0~2000。若是咱们这个时候还把 studentID 做为数组下标的话,就须要建立一个长度为 2001 的数组,但咱们只使用其中的 20 个用来存放 Student 对象,这样就形成了大量的空间浪费。 
bash

2. 哈希函数和哈希碰撞

那如何既能利用数组的常数查找特性,又能避免空间浪费呢?咱们能够很天然地想到,创建一个将 studentID 映射到 0~19 的函数,好比 h(studentID) = studentID % 20。这个函数就叫作哈希函数(或者散列函数),以此为例,咱们能够将 studentID 分别为 21,140,1163 的 Student 对象存储到数组上,以下图。
微信

Student stu21 = new Student(21, "stu21");
Student stu140 = new Student(140, "stu140");
Student stu1163 = new Student(1163, "stu1163");

students[stu21.studentID % 20] = stu21;
students[stu140.studentID % 20] = stu140;
students[stu1163.studentID % 20] = stu1163; 复制代码

在这里插入图片描述

哈希函数的实现方式有不少,一个好的哈希函数应该尽量地让映射后的结果均匀地分布在数组上。
数据结构

接下来咱们再考虑另外一个问题,若是咱们有两个 Student 对象,他们的 studentID 分别为 21 和 41,那么这两个对象都要存储到数组上 index 为 1 的 slot,这种状况就叫作哈希碰撞。
app

解决哈希碰撞的方式有两种,拉链法和开放寻址法。前者就是 HashMap 中用到的方法,在每一个 slot 后面悬挂一条链表,每进来一个结点就把它放到链表的尾部。后者是采用不一样的方式从新计算一次 key 对应的 index,直到找到一个不包含 Student 对象的 slot。
函数

以上就是哈希表的基本原理,studentID 就相似于下文所说的哈希值的概念,咱们经过 key 的哈希值来判断这个结点要放到哪一个 slot 中。

下面咱们经过 JDK 12.0.2 版本的源码来看看 HashMap 是如何工做的。

3. HashMap 中的常量

HashMap 中的一些比较重要的常量以下。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8; 复制代码

根据英文名称咱们能够大体了解该常量的做用,DEFAULT_INITIAL_CAPACITY 是默认初始容量,1 << 4 表示 1 左移四位,也就是 16。你们已经明白,HashMap 是以 数组+链表 的方式实现的,这里容量指的就是实例化 HashMap 对象内部数组的长度。若是咱们调用哈希表的构造函数时,未指定初始容量,数组的长度就由这个默认初始容量肯定。 

DEFAULT_LOAD_FACTOR 默认装载因子,装载因子的含义是平均每一个 slot 上悬挂了多少个结点,能够由下式计算获得

装载因子 = 结点数量 / 数组长度

一样的,若是调用哈希表的构造函数时,未指定装载因子,就使用这个装载因子。

TREEIFY_THRESHOLD 是树形化阈值,当某个 slot 悬挂的结点数量大于树形化阈值的时候,就把链表转化为一棵红黑树。为何树形化阈值取 8 呢?按照官方的说法,若是 key 的哈希值是随机的,在装载因子为 0.75 时,每一个 slot 中节点出现的频率服从参数为 0.5 的泊松分布,因此链表的长度(size)为 k 的几率为

在这里插入图片描述

代入 k = 8 能够获得 0.00000006,也就是说链表长度为 8 的几率小于千万分之一,因此选取 8 做为树形化阈值。

4. 静态内部类 Node

node 就是结点的意思,本文中所说的 “结点”,指的就是一个 Node 对象。Node<K, V> 实现了 Entry<K, V> 接口。

static class Node<K,V> implements Map.Entry<K,V> 复制代码

Node<V, U> 中有 4 个成员变量。能够看出 Node 的主要功能是把 key 和与之对应的 value 封装到一个结点中,该结点的 next 字段指向下一个结点,从而实现单向链表。hash 由 key 的哈希值得来,下文会介绍。

final int hash;
final K key;
V value;
Node<K,V> next; 复制代码

5. HashMap 构造函数和扩容

如下是 HashMap 的成员变量,table 是 Node 数组,HashMap 就用它来存放结点。size 表示目前 HashMap 中存放的结点总数。threshold 是阈值,表示当前的数组容量所能容纳的结点数,它是装载因子和数组容量的乘积,当 size 大于 threshold 的时候,就须要进行扩容操做。loadFactor 即装载因子。 

transient Node<K,V>[] table;
transient int size;
int threshold;
final float loadFactor; 复制代码

构造函数的主要任务就是初始化其中的一些成员变量,由于咱们调用的是无参构造函数,因此只有装载因子被赋值了。注意这个时候并无初始化 table 数组。

public HashMap(int initialCapacity, float loadFactor) {
	// 省略了一些判断极端输入的代码
	this.loadFactor = loadFactor;
	this.threshold = tableSizeFor(initialCapacity);
}
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
} 复制代码

tableSizeFor() 这个方法返回大于等于 initialCapaticy 的最小的 2 的幂。好比输入 16 就返回 16,输入 17 就返回 32。如下是该方法的实现。

static final int tableSizeFor(int cap) {
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
} 复制代码

由于位运算的执行效率很高,因此在 HashMap 中有不少地方都有应用,最大化地提升了执行速度。-1 的十六进制表示是 0x1111,numberOfLeadingZeros 返回 cap - 1 前面 0 的个数,>>> 是无符号右移运算。以 cap= 16 为例,cap -1 = 0x000F,因而 n = 0x000F,返回 n + 1, 也就是 16,。 

扩容操做由 resize() 方法完成,由于代码要综合考虑各类状况,因此有不少 if-else 语句,可是这些并非咱们要理解的重点。咱们须要知道的是,通常状况下, resize() 主要完成的任务是构造一个新的数组,数组的长度为原数组长度的 2 倍,而后将原数组的节点复制到新数组,最后返回新数组。 

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            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) {
            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 { // preserve order
                        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;
                        }
                    }
                }
            }
        }
        return newTab;
    } 复制代码

复制过程由 for 循环来完成,其中 e instanceof TreeNode 是用来判断结点 e 是否是已经被树形化为红黑树结点。

由于数组容量始终是 2 的幂,因此原数组中某个 index 对应的 slot 悬挂的链表上的结点,只可能出如今新数组的两个 slot 中:index 和 index + oldCap。oldCap 表示原数组的长度。相应的,loHead 表示 index 对应的 slot 悬挂的链表头部,hiHead 表示 index + oldCap 对应的 slot 悬挂的链表尾部。 

在判断 e 应该放到哪条链表的尾部时,也采用了比较讨巧的办法,e.hash & oldCap 若是为 0 就放到 loTail,若是为 1 就放到 hiTail。

6. put() 和 get() 方法

如下面的代码为例,分析 put() 方法的执行过程。

Student stu21 = new Student(21, "stu21");
HashMap<Integer, Student> map = new HashMap<>();
map.put(stu21.studentID, stu21); 复制代码

stu21.studentID 是 int 类型,在执行 put() 方法以前,须要进行装箱,把它转换为 Integer 类型,这一过程由编译器自动完成。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
} 复制代码

put() 方法中调用了 putVal() 方法。以下所示,其中的 hash() 方法是一个静态方法。返回的是 key 的哈希值无符号右移 16 位,而后跟自身异或的结果。其目的是为了利用哈希值前 16 位的信息。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
} 复制代码

下面是 putVal() 方法的代码,HashMap 不容许内部有重复的 key 存在,因此当 put() 方法的参数 key 与已有节点的 key 重复时,默认会将原来的 value 覆盖。onlyIfAbsent 为 true 表示只有在原来的 value 为 null 的时候才进行覆盖,此处传入的是 false,因此新的 value 必定会把原有的 value 覆盖。

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;
    } 复制代码

虽然代码有点长,可是 put() 执行的操做也比较简单,根据 i = hash & (n - 1) 计算出应该存放的 slot 的下标,若是 table[i] 为 null,直接生成新的结点放进去。不然从结点开始往下走,若是哪一个节点的 key 和传入的 key 同样,就覆盖掉该结点的 value,直到到达链表尾部,生成一个新结点放到最后,这时若是这条链表的节点数大于 8,就开始执行树形化操做。 

这里补充一点,当咱们用某个类做为 key 的时候,咱们若是重写了 equals() 方法,应该把 hashCode() 方法也一块儿重写了。由于咱们须要保证两个 key 相等的时候,它们的哈希值必定要相等。不然咱们就有可能在 HashMap 里面存储两个相等的 key。

get() 方法相对来说就比较简单了,不作过多解释。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
} 复制代码

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    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) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
} 复制代码

总结:HashMap 以 “数组 + 链表” 的方式实现,其内部以 Node 对象的方式存储 key-value 键值对。数组的长度始终保持为 2 的幂,方便使用位运算提升执行速度。key 的哈希值随机的条件下,其增删改查操做的时间复杂度正比于它的负载因子(loadFactor)。当某条链表的结点数大于 8 的时候,该链表被转化为一棵红黑树。当结点总数大于 threshold 的时候,进行扩容操做,新数组的长度是原数组的两倍。

相关文章
相关标签/搜索