HashMap源码分析 | 青训营笔记

74 阅读21分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天


前言

java集合的关于HashMap源码剖析的知识

HashMap的源码包括:1、散列表实现、2、扰动函数、3、初始化容量、4、负载因子、5、扩容元素拆分、6、链表树化、7、红黑树、8、插入、9、查找、10、删除、11、遍历、12、分段锁


一、HashMap是什么

示例:pandas 是基于NumPy 的一种工具,该工具是为了解决数据分析任务而创建的。 哈希表是一种以键 - 值(key-value)存储数据的结构,我们只要输入待查找的值即 key,就可以找到其对应的值即 Value。哈希的思路很简单,把值放在数组里,用一个哈希函数把 key 换算成一个确定的位置,然后把 value 放在数组的这个位置。HashMap是基于hash表的Map接口实现,允许null key、null value 在这里插入图片描述 在这里插入图片描述

二、散列表实现

具体键值对在哈希表中的位置(数组 index)取决于下面的位运算:

i = (n - 1) & hash

demo范例

import com.alibaba.fastjson.JSON;

import java.util.ArrayList;

/**
 * 源码分析
 * @author asus
 */
public class HashDemo {
        public static void main(String[] args) {
            ArrayList<String> list = new ArrayList<>();
            list.add("jlkk");
            list.add("baye");
            list.add("eye");
            list.add("wan");
            list.add("依然");
            list.add("plop");
            list.add("ydafn");
            list.add("dan");
            //存放的数组
            String[] tab = new String[9];
            for (String key : list) {
                //计算索引位置
                int idx = key.hashCode() & (tab.length - 1);
                System.out.println(String.format("key值= %s Idx = %d" , key , idx));
                if(tab[idx] == null){
                    tab[idx] = key ;
                    continue;
                }
                tab[idx] = tab[idx] + "->" + key ;
            }
            //输出
            System.out.println(JSON.toJSONString(tab));
        }


}

如果说初始化的时候不是2的倍数 而是9 则在结果中会产生

输出

key值= jlkk Idx = 0
key值= baye Idx = 8
key值= eye Idx = 0
key值= wan Idx = 0
key值= 依然 Idx = 8
key值= plop Idx = 8
key值= ydafn Idx = 8
key值= dan Idx = 0
["jlkk->eye->wan->dan",null,null,null,null,null,null,null,"baye->依然->plop->ydafn"]

改正之后

import com.alibaba.fastjson.JSON;

import java.util.ArrayList;

/**
 * 源码分析
 * @author asus
 */
public class HashDemo {
        public static void main(String[] args) {
            ArrayList<String> list = new ArrayList<>();
            list.add("jlkk");
            list.add("baye");
            list.add("eye");
            list.add("wan");
            list.add("依然");
            list.add("plop");
            list.add("dan");
            //存放的数组
            String[] tab = new String[8];
            for (String key : list) {
                //计算索引位置
                int idx = key.hashCode() & (tab.length - 1);
                System.out.println(String.format("key值= %s Idx = %d" , key , idx));
                if(tab[idx] == null){
                    tab[idx] = key ;
                    continue;
                }
                tab[idx] = tab[idx] + "->" + key ;
            }
            //输出
            System.out.println(JSON.toJSONString(tab));
        }


}

输出

 
key值= jlkk Idx = 2
key值= baye Idx = 3
key值= eye Idx = 1
key值= wan Idx = 4
key值= 依然 Idx = 1
key值= plop Idx = 5
key值= dan Idx = 1
[null,"eye->依然->dan","jlkk","baye","wan","plop",null,null]

 

代码主要内容:

  1. 初始化一组字符串集合,这里初始化了8个。
  2. 定义一个数组用于存放字符串,注意这里的长度是8,也就是2的倍数。这样的数组长度才会出现一个 0111 除高位以外都是1的特征,也是为了散列。
  3. 接下来就是循环存放数据,计算出每个字符串在数组中的位置。key.hashCode() & (tab.length - 1)。
  4. 在字符串存放到数组的过程,如果遇到相同的元素,进行连接操作模拟链表的过程。
  5. 最后输出存放结果。

这是hashmap最基础的原理

  • 类比上面那张图就是上面代码实现的全过程,将每一个字符串元素通过Hash计算索引位置,存放到数组中。
  • 黄色的索引ID是没有元素存放、绿色的索引ID存放了一个元素、红色的索引ID存放了两个元素。

以上我们实现了一个简单的HashMap,或者说还算不上HashMap,只能算做一个散列数据存放的雏形。但这样的一个数据结构放在实际使用中,会有哪些问题呢?

  1. 这里所有的元素存放都需要获取一个索引位置,而如果元素的位置不够散列碰撞严重,那么就失去了散列表存放的意义,没有达到预期的性能。
  2. 在获取索引ID的计算公式中,需要数组长度是2的倍数,那么怎么进行初始化这个数组大小。
  3. 数组越小碰撞的越大,数组越大碰撞的越小,时间与空间如何取舍。
  4. 目前存放7个元素,已经有两个位置都存放了2个字符串,那么链表越来越长怎么优化。
  5. 随着元素的不断添加,数组长度不足扩容时,怎么把原有的元素,拆分到新的位置上去。 以上这些问题可以归纳为;扰动函数、初始化容量、负载因子、扩容方法以及链表和红黑树转换的使用等。

三、扰动函数

扰动函数是为了优化散列效果

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

理论上来说字符串的hashCode是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。 我们默认初始化的Map大小是16个长度 DEFAULT_INITIAL_CAPACITY = 1 << 4,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列列子。 那么,hashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。 在这里插入图片描述 一句话说 : 使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。 从上面的分析可以看出,扰动函数使用了哈希值的高半区和低半区做异或,混合原始哈希码的高位和低位,以此来加大低位区的随机性。

三、初始化容量和负载因子

3.1 初始化容量

从我们模仿HashMap的例子中以及HashMap默认的初始化大小里,都可以知道,散列数组需要一个2的倍数的长度,因为只有2的倍数在减1的时候,才会出现01111这样的值。 但是如果说在初始化HashMap的时候,如果传一个17个的值new HashMap<>(17);,它会怎么处理呢? 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;
        this.threshold = tableSizeFor(initialCapacity);
    }

阀值threshold,通过方法tableSizeFor进行计算,是根据初始化来计算的。

  • 这个方法也就是要寻找比初始值大的,最小的那个2进制数值。比如传了17,我应该找到的是32。
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;
    }

  • MAXIMUM_CAPACITY = 1 << 30,这个是临界范围,也就是最大的Map集合。
  • 乍一看可能有点晕😵怎么都在向右移位1、2、4、8、16,这主要是为了把二进制的各个位置都填上1,当二进制的各个位置都是1以后,就是一个标准的2的倍数减1了,最后把结果加1再返回即可。
  • 在这里插入图片描述

3.2 负载因子

 static final float DEFAULT_LOAD_FACTOR = 0.75f;

负载因子决定了数据量多少了以后进行扩容。这里要提到上面做的HashMap例子,我们准备了7个元素,但是最后还有3个位置空余,2个位置存放了2个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了Map数组的性能。 所以,要选择一个合理的大小下进行扩容,默认值0.75就是说当阀值容量占了3/4时赶紧扩容,减少Hash碰撞。 同时0.75是一个默认构造值,在创建HashMap也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。

四、扩容元素拆分

因为数组长度不足了。那扩容最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程中,原jdk1.7中会需要重新计算哈希值,但是到jdk1.8中已经进行优化,不在需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。 测试数据

import jdk.internal.org.objectweb.asm.tree.InnerClassNode;
import org.junit.Test;

import javax.naming.InterruptedNamingException;
import java.util.ArrayList;
import java.util.List;

public class HashMapTest {
    @Test
    public void test_hashmap(){
        List<String> list = new ArrayList<>();
        list.add("jlkk");
        list.add("lopi");
        list.add("jmdw"); list.add("e4we");
        list.add("io98"); list.add("nmhg"); list.add("vfg6");
        list.add("gfrt"); list.add("alpo"); list.add("vfbh"); list.add("bnhj");
        list.add("zuio"); list.add("iu8e"); list.add("yhjk"); list.add("plop"); list.add("dd0p");
        for (String key : list) {
            int hash = key.hashCode() ^ (key.hashCode() >>> 16);
            System.out.println("字符串:" + key + "\tIdx(16):" + ((16-1) & hash) + "\tBit值:" +
                    Integer.toBinaryString(hash)+ "-" + Integer.toBinaryString(hash & 16) + "\t\tIdx(32):");

            System.out.println(Integer.toBinaryString(key.hashCode())+" " + Integer.toBinaryString(hash)
            + " " + Integer.toBinaryString((32 -1 ) & hash));
        }
        }
  
}

结果

字符串:jlkk	Idx(16):3	Bit值:1100011101001000010011-10000		Idx(32):
1100011101001000100010 1100011101001000010011 10011
字符串:lopi	Idx(16):14	Bit值:1100101100011010001110-0		Idx(32):
1100101100011010111100 1100101100011010001110 1110
字符串:jmdw	Idx(16):7	Bit值:1100011101010100100111-0		Idx(32):
1100011101010100010110 1100011101010100100111 111
字符串:e4we	Idx(16):3	Bit值:1011101011101101010011-10000		Idx(32):
1011101011101101111101 1011101011101101010011 10011
字符串:io98	Idx(16):4	Bit值:1100010110001011110100-10000		Idx(32):
1100010110001011000101 1100010110001011110100 10100
字符串:nmhg	Idx(16):13	Bit值:1100111010011011001101-0		Idx(32):
1100111010011011111110 1100111010011011001101 1101
字符串:vfg6	Idx(16):8	Bit值:1101110010111101101000-0		Idx(32):
1101110010111101011111 1101110010111101101000 1000
字符串:gfrt	Idx(16):1	Bit值:1100000101111101010001-10000		Idx(32):
1100000101111101100001 1100000101111101010001 10001
字符串:alpo	Idx(16):7	Bit值:1011011011101101000111-0		Idx(32):
1011011011101101101010 1011011011101101000111 111
字符串:vfbh	Idx(16):1	Bit值:1101110010111011000001-0		Idx(32):
1101110010111011110110 1101110010111011000001 1
字符串:bnhj	Idx(16):0	Bit值:1011100011011001100000-0		Idx(32):
1011100011011001001110 1011100011011001100000 0
字符串:zuio	Idx(16):8	Bit值:1110010011100110011000-10000		Idx(32):
1110010011100110100001 1110010011100110011000 11000
字符串:iu8e	Idx(16):8	Bit值:1100010111100101101000-0		Idx(32):
1100010111100101011001 1100010111100101101000 1000
字符串:yhjk	Idx(16):8	Bit值:1110001001010010101000-0		Idx(32):
1110001001010010010000 1110001001010010101000 1000
字符串:plop	Idx(16):9	Bit值:1101001000110011101001-0		Idx(32):
1101001000110011011101 1101001000110011101001 1001
字符串:dd0p	Idx(16):14	Bit值:1011101111001011101110-0		Idx(32):
1011101111001011000000 1011101111001011101110 1110


Process finished with exit code 0

  • 这里我们随机使用一些字符串计算他们分别在16位长度和32位长度数组下的索引分配情况,看哪些数据被重新路由到了新的地址。

  • 同时,这里还可以观察🕵出一个非常重要的信息,原哈希值与扩容新增出来的长度16,进行&运算,如果值等于0,则下标位置不变。如果不为0,那么新的位置则是原来位置上加16。{这个地方需要好好理解下,并看实验数据}

  • 这样一来,就不需要在重新计算每一个数组中元素的哈希值了。

前言

既上一节内容 本节内容是,HashMap还有基本的数据功能;存储、删除、获取、遍历,在这些功能中经常会听到链表、红黑树、之间转换等功能。

回顾上一节


一、 回顾总结

简单来说就是通过你的Key值取得哈希再计算下标,之后把相应的数据存放到里面。 但是会遇到问题

比如;1. 如果出现哈希值计算的下标碰撞了怎么办? 2. 如果碰撞了是扩容数组还是把值存成链表结构,让一个节点有多个值存放呢?3. 如果存放的数据的链表过长,就失去了散列表的性能了,怎么办呢? 4. 如果想解决链表过长,什么时候使用树结构呢,使用哪种树呢?

二、HashMap数据插入流程

HashMap中一个数据插入的整体流程,包括了;计算下标、何时扩容、何时链表转红黑树等,具体如下;

  1. 首先进行哈希值的扰动,获取一个新的哈希值。(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  2. 判断tab是否位空或者长度为0,如果是则进行扩容操作。
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
  1. 根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可否则需要覆盖。tab[i = (n - 1) & hash])
  2. 判断tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点。
  3. 如果链表中插入节点的时候,链表长度大于等于8,则需要把链表转换为红黑树。treeifyBin(tab, hash);
  4. 最后所有元素处理完成后,判断是否超过阈值;threshold,超过则扩容。
  5. treeifyBin,是一个链表转树的方法,但不是所有的链表长度为8后都会转成树,还需要判断存放key值的数组桶长度是否小于64 MIN_TREEIFY_CAPACITY。如果小于则需要扩容,扩容后链表上的数据会被拆分散列的相应的桶节点上,也就把链表长度缩短了。

JDK1.8 HashMap的put方法源码如下:

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

    /**
     * Implements Map.put and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    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,table 被延迟到插入新数据时再进行初始化
        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;
            // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
                // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
            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;
                    }
                    // 条件为 true,表示当前链表包含要插入的键值对,终止遍历
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 判断要插入的键值对是否存在 HashMap 中
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 键值对数量超过阈值时,则进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

2.1 扩容机制

HashMap是基于数组+链表和红黑树实现的,但用于存放key值得的数组桶的长度是固定的,由初始化决定。 那么,随着数据的插入数量增加以及负载因子的作用下,就需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是jdk1.8中的优化操作,可以不需要再重新计算每一个元素的哈希值,这在上一章节中已经讲到,可以阅读系列专题文章。

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        // Cap 是 capacity 的缩写,容量。如果容量不为空,则说明已经初始化。
        if (oldCap > 0) {
        // 如果容量达到最大1 << 30则不再扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 按旧容量和阀值的2倍计算新容量和阀值
            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
          // 初始化时,将 threshold 的值赋值给 newCap, 
          // HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
        // 调用无参构造方法时,数组桶数组容量为默认容量 1 << 4; aka 16 
        // 阀值;是默认容量与负载因子的乘积,0.75
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // newThr为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"})
        // 初始化数组桶,用于存放key
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
        // 如果旧数组桶,oldCap有值,则遍历将键值映射到新数组桶中
            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)
                    // 这里split,是红黑树拆分操作。在重新映射时操作的。
                        ((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;
    }

整理上一部分的resize()源码

  1. 扩容时计算出新的 newCap 、 newThr ,这是两个单词的缩写,一个是 Capacity 另一个是阀 Threshold
  2. newCap 用于创新的数组桶 new Node[newCap];
  3. 随着扩容后,原来那些因为哈希碰撞,存放成链表和红黑树的元素,都需要进行拆分存放到新的位置中。

2.2 链表树化

HashMap这种散列表的数据结构,最大的性能在于可以O(1)时间复杂度定位到元素,但因为哈希碰撞不得已在一个下标里存放多组数据,那么jdk1.8之前的设计只是采用链表的方式进行存放,如果需要从链表中定位到数据时间复杂度就是O(n),链表越长性能越差。因为在jdk1.8中把过长的链表也就是8个,优化为自平衡的红黑树结构,以此让定位元素的时间复杂度优化近似于O(logn),这样来提升元素查找的效率。但也不是完全抛弃链表,因为在元素相对不多的情况下,链表的插入速度更快,所以综合考虑下设定阈值为8才进行红黑树转换操作。

 final void treeifyBin(Node<K,V>[] tab, int hash) {
 // 这块就是我们上面提到的,不一定树化还可能只是扩容。主要桶数组容量是否小于64 MIN_TREEIFY_CAPACITY
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 又是单词缩写;hd = head (头部),tl = tile (结尾)
            TreeNode<K,V> hd = null, tl = null;
            do {
            // 将普通节点转换为树节点,但此时还不是红黑树,也就是说还不一定平衡
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
            // 转红黑树操作,这里需要循环比较,染色、旋转。 
                hd.treeify(tab);
        }
    }

这一部分链表树化的操作并不复杂,复杂点在于下一层的红黑树转换上

  1. 链表树化的条件有两点;链表长度大于等于 8 、桶容量大于 64 ,否则只是扩容,不会树化。
  2. 链表树化的过程中是先由链表转换为树节点,此时的树可能不是一颗平衡树。同时在树转换过程中会记录链表的顺序, tl.next = p ,这主要方便后续树转链表和拆分更方便。
  3. 链表转换成树完成后,在进行红黑树的转换。先简单介绍下,红黑树的转换需要染色和旋转,以及比对大小。在比较元素的大小中,有一个比较有意思的方法,tieBreakOrder 加时赛,这主要是因为 HashMap 没有像 TreeMap 那样本身就有 Comparator 的实现。

2.3 红黑树转链

在链表转红黑树中我们重点介绍了一句,在转换树的过程中,记录了原有链表的顺序。 那么,这就简单了,红黑树转链表时候,直接把TreeNode转换为Node即可,源码如下;

 final Node<K,V> untreeify(HashMap<K,V> map) {
 // 遍历TreeNode
            Node<K,V> hd = null, tl = null;
            for (Node<K,V> q = this; q != null; q = q.next) {
            // TreeNode替换Node
                Node<K,V> p = map.replacementNode(q, null);
                if (tl == null)
                    hd = p;
                else
                    tl.next = p;
                tl = p;
            }
            return hd;
        }

替换方法

// For conversion from TreeNodes to plain nodes
    Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
        return new Node<>(p.hash, p.key, p.value, next);
    }

因为记录了链表关系,所以替换过程很容易。所以好的数据结构可以让操作变得更加容易。

三、查找

在这里插入图片描述

 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 &&
        // 计算下标,哈希值与数组长度-1
            (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) {
            // TreeNode 节点直接调用红黑树的查找方法,时间复杂度O(logn)
                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;
    }

总结

  1. 扰动函数的使用,获取新的哈希值,这在上一章节已经讲过
  2. 下标的计算,同样也介绍过 tab[(n 1) & hash])
  3. 确定了桶数组下标位置,接下来就是对红黑树和链表进行查找和遍历操作了

四、删除

 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;
        // 定位桶数组中的下标位置,index = (n - 1) & hash
        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;
            // 如果键的值与链表第一个节点相等,则将 node 指向该节点
            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;
    }
  • 删除的操作也比较简单,这里面都没有太多的复杂的逻辑。
  • 另外红黑树的操作因为被包装了,只看使用上也是很容易。

五、遍历

HashMap中的遍历也是非常常用的API方法,包括:在这里插入图片描述 从方法上以及日常使用都知道,KeySet是遍历是无序的,但每次使用不同方式遍历包括keys.iterator(),它们遍历的结果是固定的。 那么从实现的角度来看,这些种遍历都是从散列表中的链表和红黑树获取集合值,那么他们有一个什么固定的规律吗? 测试的场景和前提;

  1. 这里我们要设定一个既有红黑树又有链表结构的数据场景
  2. 为了可以有这样的数据结构,我们最好把 HashMap 的初始长度设定为 64 ,避免在 链表超过 8 位后扩容 ,而是直接让其转换为红黑树。
  3. 找到 18 个元素,分别放在不同节点 这些数据通过程序计算得来
  4. 桶数组 02 节点: 24 、 46 、 68
  5. 桶数组 07 节点: 29
  6. 桶数组 12 节点: 150 、 172 、 194 、 271 、 293 、 370 、 392 、 491 、 590 测试
import org.junit.Test;

import java.util.HashMap;
import java.util.Map;

public class HashMapIterator {
    @Test
    public void test_iterator(){
        Map<String, String> map = new HashMap<String, String>(64);
        map.put("24", "Idx:2"); map.put("46", "Idx:2"); map.put("68", "Idx:2");
        map.put("29", "Idx:7"); map.put("150", "Idx:12"); map.put("172", "Idx:12");
        map.put("194", "Idx:12"); map.put("271", "Idx:12");
        System.out.println("排序01:");
        for (String key : map.keySet()) {
            System.out.print(key + " ");
        }
        map.put("293", "Idx:12"); map.put("370", "Idx:12"); map.put("392", "Idx:12");
        map.put("491", "Idx:12"); map.put("590", "Idx:12");
        System.out.println("\n\n排序02:");
        for (String key : map.keySet()) {
            System.out.print(key + " ");
        }
        map.remove("293"); map.remove("370"); map.remove("392");
        map.remove("491"); map.remove("590");
        System.out.println("\n\n排序03:");
        for (String key : map.keySet()) {
            System.out.print(key + " ");
        }
    }
}

结果

排序0124 46 68 29 150 172 194 271 

排序0224 46 68 29 271 150 172 194 293 370 392 491 590 

排序0324 46 68 29 172 271 150 194 

Process finished with exit code 0

测试过程如下:

  1. 添加元素,在 HashMap 还是只链表结构时,输出测试结果 01
  2. 添加元素,在 HashMap 转换为红黑树时候,输出测试结果 02
  3. 删除元素,在 HashMap 转换为链表结构时,输出测试结果 03

在这里插入图片描述 2. 02 情况下,因为链表转换为红黑树,树根会移动到数组头部。 moveRootToFront() 方法 在这里插入图片描述 在这里插入图片描述