HashMap超详细讲解

276 阅读29分钟

学了一些新的东西,总得留下点什么。

引言

在Java编程中,HashMap 是一种非常重要的数据结构。无论是日常开发还是面试环节,HashMap 都是我们无法绕开的一个话题。那么,HashMap 到底是什么?它是如何工作的?又有哪些常见的面试题呢?今天我们就来聊聊这些问题。

一、什么是 HashMap?

HashMap 是一种基于哈希表的数据结构,它用来存储键值对(Key-Value pairs)。你可以把 HashMap 想象成一本《中华字典》,字典的每一页都有一个页码(键),我们通过这个字典页码就能快速找到我们想要的解释(值)。

  • 键(Key):用来唯一标识一个值
  • 值(Value):实际存储的数据

这种通过键查找值的方式,使得 HashMap 的查找、插入和删除操作非常高效,一般来说,时间复杂度都是 O(1)。

二、HashMap 是如何工作的?

Java 8的HashMap底层实现:数组 + 单向链表 + 红黑树

理解 HashMap 的关键在于两个词:哈希(Hash)和表(Table)。当我们往 HashMap 里存一个键值对时,HashMap 会先计算这个键的哈希值,通过这个哈希值可以获得这个键值对在表中存放的位置,然后再把这个值存到对应的表格位置里。

  1. 哈希函数:哈希函数是 HashMap 的核心部分。它通过计算键的哈希值来决定键值对应该放在哈希表的哪个位置。理想的哈希函数能够将键均匀分布到哈希表的各个位置,减少冲突。
  2. 处理冲突:即使有了哈希函数,有时候不同的键还是会被映射到同一个位置,这就叫哈希冲突。Java 中的 HashMap 采用链地址法来解决冲突——简单来说,就是在每个位置存一个链表,如果多个键值对被映射到同一个位置,就把它们放到同一个链表里。如果链表过长(默认超过 8 且数组长度大于等于 64),HashMap 会把链表转为红黑树,这样能更快地查找到元素。
  3. 负载因子和扩容:HashMap 还有一个“负载因子”的概念,它是一个衡量 HashMap 装满程度的指标。默认情况下,当 HashMap 中的元素数量超过容量的 75% 时(负载因子为 0.75),HashMap 就会扩容,把原有的数据重新哈希到一个新的更大的表中。

三、HashMap核心源码解析

核心静态变量

// 默认初始容量:16(必须为2的幂次方)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 把链表转为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;

// 将红黑树转化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;

// 将链表转化为红黑树时,数组的大小必须大于等于这个值。否则如果 TREEIFY_THRESHOLD 大于8,将扩容,而不是转为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;

Node类

static class Node<K,V> implements Map.Entry<K,V> {
    // 节点的hash值
    final int hash;
    
    // 节点的key
    final K key;
    
    // 节点的值
    V value;
    
    // hash冲突时,指向下一节点(Java 8是尾插)
    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;
    }
}

核心静态方法

// 计算key的hash值
static final int hash(Object key) {
    int h;
    
    // 为什么要异或上 无符号右移16位的值?
    // 因为hash值是int类型的,总共32位长,让高16位的值参与计算,是为了更好的减少hash碰撞(下面会举例说明)
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 根据给定的容量,返回一个大于等于该值的2的幂次
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;
}

成员变量

// 哈希表,Node类型的数组,用来存储数据
transient Node<K,V>[] table;

// map中所包含键值对的个数
transient int size;

// 数组扩容阈值
// 当map中所包含的键值对大小size大于当前值时,就会扩容
// 一般情况下,threshold = 数组容量 * 负载因子(在第一次通过有参构造方法创建map时,会赋值为:tableSizeFor(n)的值;但是后面会恢复为:数组容量 * 负载因子)
int threshold;

构造方法

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 最大容量为2^30
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    
    // 这里的赋值并不是用:tableSizeFor(initialCapacity) * loadFactor的结果
    // 而是直接把tableSizeFor(initialCapacity)的值给threshold
    // 这是因为在扩容的resize()方法中,有一行代码“newCap = oldThr;”,会把threshold赋值为数组的容量。而数组容量又必须是2的幂次,所以这里直接这么赋值
    // “newCap = oldThr;”这行代码,只有在通过有参构造方法创建map时,并且在第一次进入resize()方法时才会执行到!!!
    // 这个时候你可能会有个疑问,那这样扩容阈值不就跟数组容量大小一样了吗?
    // 放心,在第一次扩容时,执行完“newCap = oldThr;”之后,就会对threshold重新赋值“threshold = newThr;”
    this.threshold = tableSizeFor(initialCapacity);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap() {
    // 其它所有都赋默认值
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

核心方法

获取值:get

// 如果map中存在该key,则返回该key对应的值,否则返回null
// 思考一个问题:这里为什么不直接判断hash对应的桶是否为空,而是要调用getNode()方法来判断是否有值?
// 因为判断一个key是否存在,除了要hash对应的桶有值外,还要比较key的值是否相等(有可能出现hash冲突)
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

// 根据传入的hash值、key,返回对应的Node
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 && 
            ((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;
}

添加键值对:put

// 添加键值对
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    // 如果数组为空或者长度为0,则初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
        
    // 当前桶处于null状态,则直接创建一个node
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    
    // 当前桶上已经有node存在了,这个存在的node为:p
    // 接下来就是顺着指针依次查找,如果能找到key相等的,就更新;否则就在尾部新建出待添加的节点(尾插)
    else {
    
        // e:为查找到的节点,也就是e的key与待添加节点的key是相等的(key已经存在了)
        // 在底下的if ()...else if ()... else ()分支里,如果找到与待添加节点的key相等的,会把节点赋值给e(注意:这里只是赋值,并不会直接更新旧值,是否更新的逻辑在代码66行处)
        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 {
        
            // 这个binCount为统计单向链表上节点个数的参数
            // 注意:这里从0开始计算,并没有把p算进去,所以底下在判断链表节点个数是否超过转化为红黑树的阈值时,是用“TREEIFY_THRESHOLD - 1”来判断的
            for (int binCount = 0; ; ++binCount) {
            
                // 把p的next赋值给e
                // 如果e为null,说明在链表上找不到key与待插入节点的key相等的
                if ((e = p.next) == null) {
                
                    // 在p节点的后面插入一个新的节点(尾插)
                    p.next = newNode(hash, key, value, null);
                    
                    // 判断节点个数是否大于等于转化为红黑树的阈值;如果是则执行转化为红黑树的逻辑;否则退出循环,直接到代码79行处
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                
                // 检查当前的e节点,满足条件则退出循环,到代码66行处
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                    
                // p继续往下查找
                p = e;
            }
        }
        
        // 待插入节点的key已经存在了
        if (e != null) {
            V oldValue = e.value;
            
            // onlyIfAbsent:false:更新旧值,true:不更新旧值;可以看put()方法调用的地方,默认为false
            // 或者当旧值为null时,也会更新
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            
            // 返回旧值(注意:这里直接返回了,并不会修改modCount的值,size也不会增加)
            return oldValue;
        }
    }
    ++modCount;
    
    // 如果键值对大小超过扩容阈值,则触发扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

扩容方法:resize()

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    
    // 旧的扩容阈值
    // 当且仅当通过无参构造方法创建map时,且第一次进入该方法,threshold为:0
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    // 旧的数组容量大于0,说明不是第一次扩容
    if (oldCap > 0) {
        
        // 如果旧容量已经大于限制的最大容量时,直接把threshold赋Integer的最大值返回(无法再触发扩容)
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        
        // 赋值新的数组容量为旧的2倍
        // 当这个条件不满足时,newThr = 0
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
                 
            // 当新的容量小于MAXIMUM_CAPACITY 且 旧的容量大于等于DEFAULT_INITIAL_CAPACITY时,才会将容量阈值翻倍
            newThr = oldThr << 1;
    }
    
    // 这个分支,只有在通过有参构造方法创建map时,并且在第一次进入resize()方法时才会执行到
    // 这时oldThr的值为tableSizeFor(n)的值,也就是确保了newCap的值为2的幂次
    else if (oldThr > 0)
        newCap = oldThr;
        
    // 通过无参构造方法创建map,且第一次进入resize()方式时,使用默认值
    else {
        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"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    
    // 新的table已经扩容好了,准备开始从旧数组迁移数据到新的数组
    if (oldTab != null) {
    
        // 从旧数组的第一个桶开始迁移
        for (int j = 0; j < oldCap; ++j) {
        
            // e为待迁移节点
            Node<K,V> e;
            
            // 如果当前桶上有节点存在,将该节点赋值给e
            if ((e = oldTab[j]) != null) {
                
                // 将旧数组这个位置上的桶赋值为null
                oldTab[j] = null;
                
                // e的下一节点为空,说明只有一个节点,直接在新的数组上存放
                if (e.next == null)
                
                    // 扩容时为什么可以直接这么赋值?不用考虑这个桶上原本有没有值呢?
                    // 这跟扩容机制有关,在扩容的时候,旧桶上的所有节点,会按规律分别放在新数组中的两个桶中:一个下标与旧桶所在的下标一样;一个下标为旧桶所在的下标 + oldCap
                    newTab[e.hash & (newCap - 1)] = e;
                    
                // 走红黑树的分支
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    
                // 单向链表中的节点,按照一定规则,分成两个链表,分别挂到新数组中下标为:j、j + oldCap的桶中
                // 新的两个链表中节点的顺序,会维持旧链表中的相对顺序
                else {
                
                    // 下标为j的桶的头结点
                    Node<K,V> loHead = null, loTail = null;
                    
                    // 下标为j + oldCap的桶的头结点
                    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;
}

将链表转化为红黑树:treeifyBin

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    
    // 如果链表的长度大于等于TREEIFY_THRESHOLD,但是数组的长度小于MIN_TREEIFY_CAPACITY,则不会将链表转化为红黑树,而是触发扩容操作
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
        
    // 将链表上的节点转化为红黑树节点,生成一条以hd为头的双向链表
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        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);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

删除key: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;
}

四、案例演示

我们通过演示一个简单的常用案例,来深入学习HashMap的原理。

案例:使用HashMap存储用户信息

假设我们现在有一些用户的信息需要存储,分别为:

用户ID用户名
101张伟
102李娜
103王磊
104刘洋
1陈芳
17赵强
33黄婷
49周杰
65吴丽
81孙浩
97杨静
113马俊

演示代码如下:

public static void main(String[] args) {
    // 默认初始容量大小:16
    // 默认负载因子:0.75
    Map<Integer, String> userInfoMap = new HashMap<>();
    userInfoMap.put(101, "张伟");
    userInfoMap.put(102, "李娜");
    userInfoMap.put(103, "王磊");
    userInfoMap.put(104, "刘洋");
    userInfoMap.put(1, "陈芳");
    userInfoMap.put(17, "赵强");
    userInfoMap.put(33, "黄婷");
    userInfoMap.put(49, "周杰");
    userInfoMap.put(65, "吴丽");
    userInfoMap.put(81, "孙浩");
    userInfoMap.put(97, "杨静");
    userInfoMap.put(113, "马俊");
    System.out.println(userInfoMap);
}

1.创建一个新的 HashMap

代码第4行Map<Integer, String> userInfoMap = new HashMap<>();,通过无参构造方法创建了一个新的HashMap。

2.将(101,"张伟")放入map中

代码第5行userInfoMap.put(101, "张伟");,调用 put 方法,将“张伟”的相关信息存入map。

(1)计算101hash
查看HashMap的hash()方法: 截屏2024-09-03 15.46.05.png 由代码可见,HashMap 在计算hash值时,是先调用 keyhashCode()方法,获取到对应的hashCode,再将该hashCode无符号右移16位后,与hashCode参与异或计算。

查看Integer类型的hashCode方法: 截屏2024-09-03 15.47.25.png 可以看到,Integer.hashCode()方法返回的就是对象本身的int值。 101计算.png 所以,计算101的hash值结果为:0000 0000 0000 0000 0000 0000 0110 0101

(2)初始化哈希表
因为创建完userInfoMap后,这个对象还没有使用过,哈希表结构还是null的,所以要先执行扩容方法: 截屏2024-09-03 15.41.25.png 执行完resize()方法后,此时的哈希表状态为: 初始.png

(3)计算101所在桶的下标
上面的步骤算出了101hash值,接下来就是计算当前hash值,应该对应到数组的哪个桶上。
计算hash对应的桶的下标代码为:(n - 1) & hashn为数组的大小,也就是16101下标.png

计算结果为:0000 0000 0000 0000 0000 0000 0000 0101,也就是101应该在数组下标为5的桶上

(4)将(101,"张伟")放到数组中下标为5的桶上 此时哈希表状态为: 101插入.png

3.按照第2步,依次将(102,"李娜")、(103,"王磊")、(104,"刘洋")、(1,"陈芳")放入map中

此时哈希表状态为: 3.png

4.将(17,"赵强")放入map中

通过计算,可以得到17hash值为0000 0000 0000 0000 0000 0000 0001 0001
计算下标: 17下标.png 也就是(17,"赵强")应该存在下标为1的桶上,而这个时候下标为1的桶上已经存在节点了,但是这个时候链表上的节点只有1个,所以(17,"赵强")应该存在(1,"陈芳")的下一个节点
此时哈希表状态为:

17.png 我们也可以通过查看断点验证这一个现象: 截屏2024-09-03 16.39.07.png

5.依次将剩下的6个人的信息放入map中

此时哈希表状态为: 插入完成.png 再次通过断点验证下当前的哈希表结构: 截屏2024-09-03 16.58.02.png

案例总结

上面演示了HashMap如何计算hash值,如何计算桶的下标,展示了常规的插入,以及如何解决hash碰撞等常见操作。

数组扩容、链表转化为红黑树、红黑树退化为链表这些操作,读者有兴趣可以自己通过实验去验证下。在下面的章节我也会通过以面试题的形式来回答这些问题。

五、HashMap 常见面试题

1、哈希表的大小为什么必须是2的幂次?

这个问题需要从 HashMap 是如何计算hash值对应桶的方法来看。

假设有一个大小为10的数组,现在有几个数据要放到数组中,分别为10/15/31/42,这个时候你会怎么做呢?
最简单的方式就是:用待存放的数据模上数组的大小,比如10 % 10 = 0,所以应该把10放到数组中下标为0的位置上。同理15 % 10 = 5,应该把15放到数组下标为5的位置上。

现在回头来看下 HashMap 是怎么做的:(n - 1) & hash,也就是用数组的大小减1之后再与上hash值。假设n的大小为16,减1之后为15,二进制形式为:1111。现在把10放到这个数组中,10的二进制形式为:1010,下标就为:1111 & 1010,结果是10,所以应该把10放到数组下标为10的位置上。用模的形式计算一下:10 % 16 = 10,结果是一样的。

为什么 HashMap 要选择这种方式来实现呢?因为位运算比%运算符速度快多了,要求性能更好。所以就要求哈希表的大小必须是2的幂次,为了方便下标计算。

2、HashMap的MAXIMUM_CAPACITY为什么是 1 << 30

这个问题也就是HashMap的最大容量为什么是1073741824(1 << 30实际的值)?

截屏2024-09-04 14.56.46.png 首先,可以看到这个变量是int类型的,而int类型是32位的。Java中int是有符号数,所以最高位为符号位,如果符号位为1,就表示这个数是负数。 左移.png
可以看到,如果再左移1位的话,这个值就变成负数-2147483648了。并且数组的容量必须是2的幂次,也就是说int的32位中,只能有一个位置是1的。在数组扩容的时候,新容量为旧容量的2倍,代码为:newCap = oldCap << 1

综上,因为数组容量是int类型的,并且这个值必须是2的幂次,所以HashMap的最大容量为1 << 30

3、默认负载因子为什么是0.75?

先说说负载因子的作用:假设现在有一个哈希表,数组长度为16,负载因子为0.75,那么当哈希表内包含的键值对大于12(16 * 0.75 = 12)时,就会发生扩容操作。

注意:12是指包含的键值对个数,而不是数组实际上有12个位置被使用了,有可能因为hash冲突,实际上被使用到的桶并没有那么多。

知道了负载因子的作用后,那为什么默认值是0.75呢?
如果负载因子为1.0的话,会使哈希表中的桶填充的更多,增加内存利用率,但是增加了哈希冲突的可能性,从而降低查询和插入的性能。
如果负载因子为0.5的话,那么会有更多的空闲桶,但是会减少哈希冲突,提高查询性能。

总的来说,默认负载因子0.75在大多数情况下能够提供较好的性能和内存使用效率,是一种通用的、合理的默认选择。

4、为什么链表改为红黑树的阈值为8?

先说说红黑树节点与常规节点的区别:

  • 内存:红黑树节点的大小一般是常规节点大小的2倍。
  • 查找效率:链表复杂度O(n),红黑树复杂度O(log n)。

出于性能和空间效率上的考量,所以只有当某个桶上的节点足够多的时候,才会将链表转化为红黑树,这个值就是要讨论的阈值。另外,由于删除节点或者扩容操作,当节点数量减少到UNTREEIFY_THRESHOLD的时候,红黑树会转化为链表。

从 HashMap 的注释中可以看到,“在使用分布良好的用户哈希码时,很少使用树容器。理想情况下,在随机哈希码下,容器中节点的频率遵循泊松分布”:

  • 0: 0.60653066
  • 1: 0.30326533
  • 2: 0.07581633
  • 3: 0.01263606
  • 4: 0.00157952
  • 5: 0.00015795
  • 6: 0.00001316
  • 7: 0.00000094
  • 8: 0.00000006
  • 更多:小于千万分之一

所以,当链表中的长度达到8个及以上时,概率已经足够小了,这个时候将常规节点转化为红黑树节点,既能保证查询效率好,又能提高空间利用率。

5、计算hash时,为什么要异或上hash值的高16位?

通过一个例子来看一下:
假设现在要将(101,"张三")、(196709,"李四")放到一个大小为16的哈希表中。分别看下有右移16位与没有右移16位的情况: 右移计算.png 可以看到,有右移情况的下标为:

  • 101:0000 0000 0000 0000 0000 0000 0000 0101,也就是下标为5
  • 196709:0000 0000 0000 0000 0000 0000 0000 0110,也就是下标为6

没有右移情况的下标为:

  • 101:0000 0000 0000 0000 0000 0000 0000 0101,下标为5
  • 196709:0000 0000 0000 0000 0000 0000 0000 0101,下标为5

两种计算方式,得到的下标不一样。我们在实际使用场景中,大部分情况下的哈希表容量都是比较小的,当数组长度很短时,只有低16位的hashCode可以参与下标运算。而int类型是4个字节的,在计算hash时,让低16位与高16位一起参与运算,能够在一定程度上减少hash碰撞。

6、tableSizeFor()方法的实现原理

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;
}

这个方法的作用就是返回一个大于或者等于给定目标容量的2的幂次方。
同样,通过一个例子来说明。假设在构造方法中,我们传入的参数为65538,这个时候计算过程就是: tableSizeFor.png 代码第7行执行完以后,n的值就为0000 0000 0000 0001 1111 1111 1111 1111,也就是十进制的131071。在结果返回之前,判断n是否小于0,如果小于0,则返回1;否则就判断n是否超过最大值,如果没有超过最大值,就返回n + 1的值,也就是131071 + 1131072(二进制为0000 0000 0000 0010 0000 0000 0000 0000)。

这个方法的实现原理就是:目标容量cap二进制中最高位的1,通过移位,再与原本的值相或,重复执行几次,将最高的1扩散到低位中,通过位运算快速获得2的幂次。

最开始的时候为什么要减1呢?这是为了避免一开始传入的参数就刚好是2的幂次,比如目标容量cap传入的是16,一开始不减1的话,就会返回32。所以在一开始减1,在返回结果的时候再把1加上就能避免这个问题。

7、为什么getNode方法在判断key是否相等时,总要先比较一下hash值?

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) {
        // 这里总是会先比较hash是否相等
        if (first.hash == hash &&
            ((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值会落到同一个桶上(可以看看上面的案例演示)。在查找的时候,如果hash值不同,就没有必要再判断当前节点的key是否相等了,直接继续往下查询就好了。先判断hash值而不是直接比较key,是因为hashint类型的,在比较时速度更快,性能更好,不需要调用equals方法去判断。

其实这一个判断的代码,在 HashMap 中查找节点的地方都有用到,并不是只在getNode这个方法里有。

8、数组是怎么扩容的?

数组扩容可以分两个大步骤来说:

  1. 新数组的创建
  2. 旧数组数据迁移到新数组

新数组创建

先说下数组扩容触发的时刻:

  1. 当元素个数超过扩容阈值的时候
  2. 当使用哈希表时,哈希表还未初始化过
  3. 当产生哈希冲突,单个桶上链表长度大于8,且数组容量小于64的时候(treeifyBin方法中)
第1种情况

数组之前已经初始化过了,这个时候元素个数达到了扩容阈值。

  • 当旧数组容量大于等于哈希表限制的数组最大容量时,不再扩容,但是会把扩容阈值设置为Integer的最大值,直接返回,让哈希表下次不会再达到扩容阈值。
  • 当数组还能继续扩容时,新数组的长度为原来旧数组的2倍;当新数组长度小于MAXIMUM_CAPACITY且旧的数组长度大于等于DEFAULT_INITIAL_CAPACITY时,会将新的扩容阈值翻倍;否则,新的扩容阈值为新的容量 * 负载因子(如果新的容量大于等于MAXIMUM_CAPACITY,则扩容阈值为Integer的最大值)。
第2种情况

当前数组还未初始化时,这个时候会初始化数组。

  • 通过有参构造方法创建的map,此时扩容阈值是有值的,代码为this.threshold = tableSizeFor(initialCapacity)。新数组的长度就为该值,扩容阈值会被重新赋值为新的容量 * 负载因子
  • 通过无参构造方法创建map时,新的数组容量为DEFAULT_INITIAL_CAPACITY,扩容阈值为(int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY)
第3种情况

这种情况也是属于第1种情况的,只是触发点不一样。扩容流程跟第1种情况是一样的。

到这,我们明白了新数组是怎么创建的,接下来看看,旧数据怎么迁移。

数据迁移

数据迁移的流程是:

  1. 从数组下标0开始,遍历旧数组的每一个桶
  2. 遍历到一个桶的时候,会从第一个节点开始,从头到尾遍历往下的每一个节点

通过一个例子来看一下。用上面的案例演示那个例子: 插入完成.png 此时哈希表大小为16,当前已经有12个键值对了,继续添加下一个节点的话,就会触发扩容(12 + 1 = 13 > 12(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY))。

现在,把(109,"张三")加入 map 中,此时状态为: 109.png 可以看到,此时(109,"张三")被放在了下标为13的桶上,数组并还未扩容。通过断点验证下: 截屏2024-09-05 16.19.14.png 当节点插入后,在方法返回前,才会判断是否应该扩容(可以看看上面关于put的代码)。扩容后状态为: 扩容.png 截屏2024-09-05 16.24.21.png 这个扩容的下标计算挺有意思的,读者有没有发现,除了1号桶、17号桶之外,其它桶的位置、数据都不变。

这也跟下标的计算方式有关。旧数组的容量为16,计算下标时n - 1的二进制为:0000 0000 0000 0000 0000 0000 0000 1111,而新数组的容量为32,计算下标时n - 1的二进制为:0000 0000 0000 0000 0000 0000 0001 1111,差别只是第4位上的那个1

所以扩容以后,新的下标要么跟旧数组的一样,要么就是旧数组的下标 + 旧数组的长度

以上,就是关于数组扩容的大致流程,这里只是简单举了一个不包含红黑树的例子。其实包含红黑树的话,道理也是一样的,也是通过将红黑树节点分成两个链表,只不过这个链表的节点类型是树节点。然后判断链表的节点是否小于等于6(UNTREEIFY_THRESHOLD),如果小于6,则将红黑树结果转化为普通的链表结构。

9、说说put方法的流程

  1. 判断哈希表是否初始化过,如果没有初始化过(null或者长度为0),则先初始化,调用扩容方法
  2. 计算hash值对应的下标位置
    • 如果当前桶上没有节点,则直接创建一个节点出来
    • 当前桶上有节点
      • 判断当前key是否是第一个节点
      • 如果是红黑树节点,走红黑树的插入分支
      • 普通节点,遍历当前链表,如果key不存在,新增节点,并且判断是否需要转成红黑树;否则继续往下查找。
      • 如果key之前存在,则判断是否更新旧值
  3. 判断是否需要扩容,如果需要,则扩容
  4. 结束

10、链表什么时候会转化为红黑树?

当往map中不断插入键值对时,此时如果某个桶上的链表长度超过了8(TREEIFY_THRESHOLD),就会调用转化为红黑树的方法treeifyBin

但是调用了该方法后,不一定会将链表马上转化为红黑树,只有当数组长度大于等于MIN_TREEIFY_CAPACITY(64)时,才会转化为红黑树。否则只是会触发扩容。

通过代码验证下:

public static void main(String[] args) {
    // 默认初始容量大小:16
    // 默认负载因子:0.75
    Map<Integer, String> userInfoMap = new HashMap<>();
    userInfoMap.put(101, "张伟");
    userInfoMap.put(102, "李娜");
    userInfoMap.put(1, "陈芳");
    userInfoMap.put(65, "赵强");
    userInfoMap.put(129, "黄婷");
    userInfoMap.put(193, "周杰");
    userInfoMap.put(257, "吴丽");
    userInfoMap.put(321, "孙浩");
    userInfoMap.put(385, "杨静");
    userInfoMap.put(449, "马俊");
    userInfoMap.put(513, "张三");
    System.out.println(userInfoMap);
}

当执行完代码第14行时,也就是插入(449, "马俊")后,状态为: tree1.png 截屏2024-09-05 17.31.34.png 此时下标为1的桶上,链表长度已经为8了,这个时候继续插入(513, "张三"),状态为: tree3.png 截屏2024-09-05 17.40.45.png 可以看到,链表确实没有变成红黑树,仅仅只是扩容了而已。而且在这个例子中,我特意把key设置成比较特殊的,让这些冲突的节点还是保持在同一个桶上。这让我验证了另外一个现象:HashMap 中的链表的长度有可能大于8。

现在,仅仅修改一行代码,把无参构造方法换成有参的,指定初始容量大小为64,其余都不变:

Map<Integer, String> userInfoMap = new HashMap<>(64, 0.75F);

执行完代码后,状态为: tree4.png

综上,只有当哈希表长度大于等于64时,且单个桶的链表长度大于8时,才会把链表改为红黑树。

11、红黑树什么时候会变回链表?

红黑树变回链表的时机:

  • 删除节点的时候
  • 哈希表扩容的时候

第一种情况:在删除键值对的时候,如果节点类型为树节点,且满足一定条件的时候(头结点为空或者头结点的左右节点有一个为空等),就会把红黑树转为链表。

第二种情况,在数组扩容那里有提到过,当扩容后,如果桶上原本是红黑树的话,会分成两个节点为树的链表,然后判断链表长度是否小于等于6。如果小于等于6的话,就会转为链表。

六、总结

这一次算是很完整的看完了 HashMap 的源码,并且也通过代码调试,亲自验证了相关的逻辑。针对核心代码,每一行都去试着理解。读完以后,收获很大。

本文版权归 [小杰森] 所有,未经授权禁止转载、修改及商业使用。