HashMap原理与源码分析

742 阅读14分钟

HashMap原理与源码分析

HashMap结构简述

HashMap底层数据结构采用拉链法,数组加链表的散列表,JDK8后引入红黑树。

讲一讲新增元素的过程,首先基于Key的hashCode通过扰动函数来计算hash值,然后进行路由寻址,找到对应的桶位置。

如果没有发生碰撞则直接放到桶内;反之新增到桶内的链表末尾。

当hash碰撞过多时,就会造成链表过长,从而增加数据操作的时间,故当数据量较大的时候链表会转化为树形结构(需要同时满足当前链表长度超过8个且整个哈希结构中元素超过64个两个条件)。

树形结构在存储和查询大量数据时比其他数据结构好很多,也正因此很多关系型数据库采用的也是树形结构来存储数据。

而其中红黑树便是Java HashMap的选择,该树是平衡的二叉查找数,查询效率相较于普通树更高。

当数组大小较小时,便使得hash碰撞过多从而导致元素堆积,一样会使得数据的增删改查工作耗时变长,此时数组便会扩容(所有k-v对数量超出扩容阈值threshold进行扩容)从而降低哈希冲突。

HashMap继承体系

image-20220208221654975

HashMap中的Node节点与Node数组(散列表)

Node就是链表节点(或者是红黑树节点)。HashMap中声明了一个静态类 Node,该节点则作为链表的头节点。

static class Node<K,V> implements Map.Entry<K,V> {
		// hash值 经过扰动后得到hashcode
    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;
    }
}

HashMap中的每一个key-value就是保存在这个Node中。

HashMap中维护了一个Node数组作为散列表:

transient Node<K,V>[] table;

transient:不会被序列化。

HashMap的put过程

对于put一个<K,V>来说,会先获取K的Hash值(即Node中的hash字段),然后该hash值经过扰动函数(后面源码中会讲),使hash值更散列,然后组装Node对象,通过路由算法找出Node应存放在数组(tab)的位置

💥路由寻址公式:(tab.length - 1) & node.hash 一定能得出一个小于tab.length的值(tab设置为2的n次方长度,初始大小是16)。 & 操作就是映射到桶位置的方法,当然学习哈希的时候用%方法,可能效率没有&高。

⚠️注意:此处就是为什么数组长度必须是2的n次幂。因为如果不是2的n次幂,则该长度减一后对应的二进制一定有至少一位为0,那么在计算桶位置(一般都是采用&运算来做)时就一定会造成该位置的bit一直是0,从而造成数组上的一些位置永远用不到,所以必须要用 1111 1111 1111 这种的全1二进制来与hash值做& 操作。

因为&效率高所以用&,因为用了&所以为了避免空桶需要长度为2的n次幂。

当然这是在该数组位置(桶)为空时才直接这样放置,如果发生碰撞了呢?那就添加到该桶位链表的末尾或者替换链表中的元素当然如果此时这个链表长度过长(超过8时)且整个哈希Map的Node数超过64个时,那么该链表转化为红黑树结构。

HashMap的扩容

当超出设定的扩容阈值threshold时,就会执行扩容操作;该值由当前数组长度和负载因子共同决定。

源码解析——几个常量

/**
 * 默认数组(tab)的初始长度
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * tab最大长度2的30次方 注意这是tab的最大长度而不是哈希结构中的最大元素数量
 */
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;

/**
 * 只有当整个哈希表所有元素超过64 之后满足TREEIFY_THRESHOLD的链表才能树化
 */
static final int MIN_TREEIFY_CAPACITY = 64;

源码解析——几个属性

/**
 * 当前元素个数
 */
transient int size;

/**
 * 当前哈希表结构变化次数
 */
transient int modCount;

/**
 * 扩容阈值:当HashMap中的所有元素超过阈值时会触发扩容
 */
int threshold;

/**
 * 负载因子,一般采用默认的0.75,用于计算threshold
 */
final float loadFactor;

threshold = capacity * loadFactor 数组长度 * 负载因子

源码解析——构造方法

HashMap有四个初始化方法:

/**
 * 初始化数组大小和负载因子
 */
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;
  	// 因为数组长度要求是2的次方 所以这里要对初始长度做一个处理,并赋值给threshold
    this.threshold = tableSizeFor(initialCapacity);
}

/**
 * 初始化数组长度
 */
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/**
 * 最常用的构造函数 负载因子直接使用默认值
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/**
 * 添加map元素
 */
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

源码解析——tableSizeFor

tableSizeFor的处理逻辑:

/**
 * Returns a power of two size for the given target capacity.
 * cap = 10
 * n = 9 二进制 1001
 * >>> 表示无符号右移。 9 >>> 1 的 值是 0100
 */
static final int tableSizeFor(int cap) {
  int n = cap - 1;  // 1001
  n |= n >>> 1;			// 1001 | 0100 = 1101
  n |= n >>> 2;			// 1101 | 0011 = 1111
  n |= n >>> 4;			// 1110 | 0000 = 1111
  n |= n >>> 8;			// 1111
  n |= n >>> 16;		// 1111 = 15
  return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; // n+1= 16
}

为什么减去1?为了避免16 32这种的数字传入后返回32 64 这种两倍的长度。

其实这段代码的目的就是想让下面这种不规则的二进制转变为前面全0、后面全1只要加1就能变成2的次方的二进制数字:

0011 0101 1101 => 0011 1111 1111。

个人认为也可以使用字符操作来处理,找到第一个高位1,后面的所有字符都变成1,可能这样比较暴力性能不好。

接下来好好分析分析这个算法的过程:

这个n肯定是大于等于0的一个数(由前面的筛选条件可知):

当是0时,无论怎么或操作都是0,最后返回一个1;

当大于0时,其二进制中一定有一个bit位的值为1。

这时找到最高位的1,比如001x xxxx xxxx,此时无符号向右移动一位变成0001 xxxx xxxx,这两个值一做或操作,原来的那个1突然就变成了连续的两个了:0011 xxxx xxxx。

这时候再把这两个1继续放到下面的两个低位即变成0000 11xx xxxx,这样子或操作后就能得到4个连续的1,即0011 11xx xxxx;

再移动四位并做或操作就能得到8位的1;继续移动或操作,最终都能把x变成1;

而由于容量最大就是32bit的正数所以最后至多移动16位再或操作就能得到32个连续的1,即由1111 1111 1111 1111 xxxx xxxx xxxx xxxx 变成1111 1111 1111 1111 1111 1111 1111 1111 。不过这时已经大于MAXIUM_CAPACITY,会返回该值。

所以这整个过程就是不断补充1的个数,有一种,我想发连续的很多个表情包😄,我先打出一个😄,然后复制一下,然后粘贴变成😄😄,然后继续复制变成😄😄😄😄,然后变成😄😄😄😄😄😄😄😄,就是这种感觉。

源码解析——HashMap的put方法

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

HashMap的put过程中已经简述了put的宏观过程,下面是从源码的角度来分析整个过程。

hash扰动函数

hash(key) 计算<K,V>中key对应的hash值。

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

主要看 (h = key.hashCode()) ^ (h >>> 16)。分解一下这个过程就是:

h = key.hashCode();
return h ^ (h >>> 16);

key.hashCode是一个int值,是一个有32bit的二进制值:xxxx xxxx xxxx xxxx yyyy yyyy yyyy yyyy

无符号右移16位后变成 0000 0000 0000 0000 xxxx xxxx xxxx xxxx。

此时做异或运算(0^0 = 0, 1^1 = 0, 0^1 = 1 ), 即原高十六位在高16位处与0000 0000 0000 0000做异或,在低16位处与原低16位的y……做异或,可是为啥要这样做呢?

首先要明确一点这个hash(key)的返回值是干嘛的?是送去做路由寻址的(tab.length - 1) & hash(key)

从这个角度来继续思考。因为初始化这个table(默认new HashMap())时,这个table的长度是16,减1后只有16bit位,此时hash的高16位压根就没用。那么这会造成什么结果呢?

明明两个不同hashCode的key,却由于hashCode的低16位相同,从而发生了碰撞,比如:

hashCode1: xxxx xxxx xxxx xxxx 0001 0001 1110 0010

hashCode2: yyyy yyyy yyyy yyyy 0001 0001 1110 0010

他们的路由结果是相同的,而如果让高十六位也来参与路由寻址的过程,那么虽然不能保证就不发生碰撞了,但一定能大大减少发生碰撞的概率从而降低数据积。

所以这个小方法真的很巧妙!

putVal函数

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
  	// 当前散列表
    Node<K,V>[] tab; 
  	// put进去的节点
  	Node<K,V> p; 
  	// 数组长度和数组下标
  	int n, i;
  	// 	延迟初始化table(table为null时先用resize初始化table),将HashMap的table赋值给tab
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
  
  	// i = (n-1) & hash 路由寻址算法。如果是空桶直接生成Node并塞入,反之执行else后面的操作。
  	// 桶元素赋值给p
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
      	// 当前映射的桶元素的hash值与当前key的hash一样并且当key相同(内存相同或者值相同)
      	// 用e记录当前桶,后面用于直接覆盖桶内容
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
      	// 	如果是树的节点说明已经树化,在树上(此时应该把p看成一棵树)添加节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 说明现在是链表结构 需要考虑是否树化还是怎样
      	else {
          	// 遍历链表  添加到链表末尾或替换其中的相同value节点
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                  	// 链表达到树化条件 尝试去树化(在treeifyBin中会检查整个tab的元素个数是否超出64)
                    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;
          	// 覆盖旧的value
          	// 当使用 putIfAbsent进行put的时候 传入的onlyIfAbsent为true,此时不覆盖旧的value值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            // 直接返回旧值
            return oldValue;
        }
    }
  
  	// 结构修改次数+1。
    ++modCount;
  
  	// <K,V>数量加一,并与阈值比较,如果大于则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
  	// 新增操作返回null 这样就能与是否是修改旧值区分开了
    return null;
}

源码解析——HashMap的resize方法

扩容方法。用于table的初始化和HashMap的元素数超出扩容阈值时的扩容操作。

final Node<K,V>[] resize() {
  	// 记录扩容前的table
    Node<K,V>[] oldTab = table;
  	// 旧的table的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 旧的扩容阈值 当threshold为null时发生类型转换oldThr的值为0
  	int oldThr = threshold;
  	// 新的table长度 新的扩容阈值
    int newCap, newThr = 0;
  
  	// 旧的table大小大于0,表示已经初始化过table
    if (oldCap > 0) {
      	// 数组长度超出最大值,把阈值改成Integer最大值,以后都不再扩容了
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
      	// oldCap左移一位数值**翻倍**赋值给newCap 当满足最大值限制条件且扩容之前的数组大小>=16时 阈值翻倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
  	// oldCap等于0 table未初始化【但阈值不一定是否初始化】
  	// oldThr大于0时 表示阈值已初始化。
    else if (oldThr > 0) 
      	// 新的数组大小为旧的阈值(new HashMap时通过tableSizeFor计算得到的阈值)
        newCap = oldThr;
  	// oldThr为0 表示阈值未初始化。
  	else {               
      	// 数组大小使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
      	// 阈值使用默认数组大小*默认负载因子
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
  	// 如果新的阈值为0 通过负载因子和新的数组长度来计算新的阈值
    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;
  	// table已初始化
    if (oldTab != null) {
      	// 遍历桶位
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
          	// 不为null时说明不是空桶
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
              	
              	// 如果当前桶内只有一个节点,那么直接rehash一下,将该节点放到新的桶位中
                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 { 
                  	// 将原来桶中的链表拆成两个链表
                  	// low 低位链表
                    Node<K,V> loHead = null, loTail = null;
                  	// high 高位链表
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                      	// 当hash的高位(oldCap对应的最高位)是1时,放到高位链表中
                        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;
}

计算新数组大小和新扩容阈值大小的流程图

image-20220209133915759

解释扩容时 桶内为链表时的处理逻辑

假设扩容前数组长度为16,即桶位从0~15。扩容后长度变成32,桶位从0~31。

现在我们随便拿出一个桶,比如原来的下标为14的桶,假设它上面就有一个长度为5的链表。那么根据路由寻址的公式可知,该链表上的所有节点的hash值的后四位均为1110,否则不可能映射到桶位14上。

现在扩容后需要重新rehash,做hash & 11111操作了,原来是做hash & 1111,所以关键点就在于hash值的新高位(指从左往右的第5位)是否为1。

x1110 & 11111

x若为1,则该节点迁移到11110位置,即桶位30;反之保持原桶位不变。

所以扩容之后,每个桶位上的链表均遵循这样一个规律:该链表上的节点要么还在原桶位的链表上,要么就需要迁移到原桶位+原数组大小位置的新桶位上,而是否迁移则有新高位是否为1决定,若为1则迁往新桶位,反之不动。

故可将原链表拆解为两个新链表,分别称为高位链表和低位链表,高位链表放到高桶位中,低位链表放到低、原桶位中。

🐷如果用一句话来描述扩容的整个过程就是:先根据扩容逻辑计算新的数组大小和扩容阈值大小,然后根据计算结果来生成新数组和新数组桶内节点或链表或树形结构的修改。

为什么阈值要小于桶位(数组长度)呢?

牺牲桶位来让链表尽可能的短。

image-20220209144308988

源码解析——HashMap的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;
}

根据hash路由寻址到桶上,然后遍历链表/红黑树,找到对应的key即可。

源码展示——HashMap的remove方法

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

removeNode中的条件跳转逻辑跟putVal很像,不多展开了。