面试必备:HashMap,这篇文章就够了

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

引言

在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核心源码解析

核心静态变量

 

Java

代码解读

复制代码

// 默认初始容量: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类

这边整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

 需要全套面试笔记的【点击此处即可】免费获取

Java

代码解读

复制代码

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

核心静态方法

 

Java

代码解读

复制代码

// 计算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; }

成员变量

 

Java

代码解读

复制代码

// 哈希表,Node类型的数组,用来存储数据 transient Node<K,V>[] table; // map中所包含键值对的个数 transient int size; // 数组扩容阈值 // 当map中所包含的键值对大小size大于当前值时,就会扩容 // 一般情况下,threshold = 数组容量 * 负载因子(在第一次通过有参构造方法创建map时,会赋值为:tableSizeFor(n)的值;但是后面会恢复为:数组容量 * 负载因子) int threshold;

构造方法

 

Java

代码解读

复制代码

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

 

Java

代码解读

复制代码

// 如果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

 

Java

代码解读

复制代码

// 添加键值对 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()

 

Java

代码解读

复制代码

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

 

Java

代码解读

复制代码

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

 

Java

代码解读

复制代码

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马俊

演示代码如下:

 

Java

代码解读

复制代码

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值时,是先调用 key 的hashCode()方法,获取到对应的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为数组的大小,也就是16) 

101下标.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()方法的实现原理

 

Java

代码解读

复制代码

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值?

 

Java

代码解读

复制代码

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)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值