HashMap
HashMap
变量
成员变量Filed
// HashMap最基本的成员标量table数组,对象是Node。其初始化是指向同一个地址(享元模式)
transient Node<K,V>[] table;
// Map.Entry是Map接口的内部接口(嵌套接口)
transient Set<Map.Entry<K,V>> entrySet;
//Note that AbstractMap fields are used for keySet() and values().
transient int size;
// 凡是我们做的增删改都会引发modCount值的变化,跟版本控制功能类似,适用于Fail-Fast机制。
// modCount实现了Fail-Fast机制可以抛出ConcurrentModificationException异常。
transient int modCount;
// 阈值非常重要的一个概念,影响put,resize等操作。 threshold = initialCapacity*loadFactor
int threshold;
final float loadFactor;
静态变量
关于map
// 序列化id
private static final long serialVersionUID = 362498820763181265L;
// 默认初始化map容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认装载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
关于list<--->tree
// list转化为tree阈值为8,设置为8,是系统根据泊松分布的数据分布图来设定的。
static final int TREEIFY_THRESHOLD = 8;
// tree退化为list阈值为6,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树形化容量阈值:即只有Map容量大于64才允许treeify,否则若桶内元素太多时,则直接扩容,而不是树形化
static final int MIN_TREEIFY_CAPACITY = 64;
注释:resize()有两种情况:1.当前map的容量大于阈值 2. 容量小于64 但是一个bin达到了8
内部类
Node
table使用的就是Node数组,每个Node节点包括 hash, key, value,next。这个类其实是一个链表,而他的本质就是对Map.Entry的实现。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ...
}
补充:TreeNode和Node的关系。
视图内部类
包括KeySet
,Values
和 EntrySet
三种视图。其关系入下图所示:
以KeySet为例:
final class KeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<K> iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
public final Spliterator<K> spliterator() {
return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super K> action){//...}
迭代器内部类
包括抽象类父类HashIterator
。和继承抽象类的子类KeyIterator
,ValueIterator
,EntryIterator
。
其中有一点HashIterator
居然没有实现Iterator
接口。而剩下的三个子类则实现了Iterator接口。
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index;
HashIterator() {
//...
}
public final boolean hasNext(){
//...
}
final Node<K,V> nextNode() {
//...
}
public final void remove() {
//...
}
}
final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
分割迭代器内部类
包括:HashMapSpliterator,KeySpliterator,ValueSpliterator,EntrySpliterator。类似于迭代器。
方法
成员方法
初始化🙂
初始化可以分两种方式,虽然很简单,但这两种方式对于后面put,resize()都有极其重要的影响。后面的很多判断条件都是根据这里的情况来的。因此要分清楚~
初始化方法一
// 对于 HashMap map = new HashMap<>(); 这种方式
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 第二种实现了Map接口addAll的初始化本质上分两步,第一步初始化,第二步放entry
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
很显然这种方式,啥都没设置。其实系统也啥也没给你,只给你确定了装载因子为0.75。其他的重要变量(主要是在resize()中会用到的):
table = null; // table.length更无从谈起
threshold = 0; // 阈值也是默认值 为0
modCount = 0; //本就应该是0
size = 0; // 也是一个很重要的量 判断当前容量中实际使用的空间大小
entrySet = null; // keySet() and values()同理
初始化方法二
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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);
}
这两个初始化方法可以算一个, 都是必须填写初始化容量大小的,如果不填装载因子就是默认为0.75。既然你填写了初始化容量大小,就可能与DEFAULT_INITIAL_CAPACITY
不一样。
如果是用方法二创建了一个容量大小为31的HashMap,实际上 你是只是设置了你的初始阈值为32,而这个时候你的table依然为null。
情况一:你第一次put,这个时候你的table==null 你需要一个newCap
情况二:你put的时候连续命中同一个Bin,也就是说你有8个对象的HashCode相同,而这个时候你的size又小于treeify的最小值64.你resize,需要一个newCap
情况三: 在你添加了一个后,你的size大于threshold了,这个时候你需要resize()。即你需要newCap。
get
put🙂
-
继承Map的put接口:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
-
HashMap真正的实现方法是putVal:
-
假设我们创建了一个空的HashMap,而目前是第一次put即:
Map<String,String> map = new HashMap<>(); map.put("123","123");
-
这个时候的
table
肯定是null,因此需要调整resize()
而默认就是容量就是16。 -
因此是新建的map,table里面的数组中每个Node都是空的,因此在put的时候需要创建一个新的node(
newNode
), -
否则,table中该下标的头节点是存在的。创建两个变量Node e 和key k,使用k进行存在性判断,使用e保存put进来的结果。
- 判断头结点的hash值是和要put的节点的hash值相同。
- 如果p等于put进来的key,则使用e保存p
- 如果p不等于key,则说明该节点不等于头结点,则继续判断是否存在List或者tree当中。因此先判断head是不是
TreeNode
。如果是TreeNode则放入黑红树当中进行判断或者添加(putTreeVal
) - 如果不是TreeNode则在链表中判断(一个for循环遍历即可)
- 如果bin是一个链表,并且节点不是该bin下面的任何一个节点,则创建一个节点添加在后面的链表中。注意这个时候的链表是包含新添加进来的节点的!!!通过更新
binCount
并判断是否大于等于8,进而执行treeifyBin
。然后完成添加。而这个时候刚好就把e赋值为了null。 - 其中在
treeifyBin
先进行判断,是否达到了化成树的最小容量(8*8 = 64),如果达到,则变成树,没有则resize()
。 - 最后再次判断size有没有超过阈值,如果超过则resize()这个resize则是因为size的resize而不是上面因此单个链表超过8可能导致的resize。
- 最最后,调用回调函数
afterNodeInsertion
。
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) // 初始化为table==null的时候,第一次put才真正的创建空间为16的容量map n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) // 如果数组table在下标为i的地方为空,则创建一个新的节点(相当于链表头) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 判断头结点的hash值是和要put的节点的hash值相同,将key赋值给临时遍历 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 { // 判断key是否存在于Node这个链表中,如果不存在则添加进去,添加的时候看是否达到treeify的阈值,如果达到还要看整体容量是否达到,如果没有达到则resize否则treeify。 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; } } // 如果不存在则上面最后e会指向null,如果存在e则会指向对应的key,这里相当于修改key的value if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; // 调用回调函数afterNodeAccess,里面其实是一个空的函数体。 afterNodeAccess(e); return oldValue; } } // 最后再次判断size有没有超过阈值,如果超过则resize()这个resize则是因为size的resize而不是上面因此单个链表超过8可能导致的resize。 ++modCount; if (++size > threshold) resize(); // hashMap自带的几个回调函数 afterNodeInsertion(evict); return null; }
-
resize()🙂
什么情况会resize()?
- oldCap>0的情况,什么情况是oldCap大于0的,就是已经存在table的时候又需要resize()的情况:
size > threshold
,那如何设置阈值=>resize()的时候或者实例化的时候。这个时候的oldCap是大于0的- 想要treeifyBin但
able.length<MIN_TREEIFY_CAPACITY
的时候。这个时候的oldCap也是大于0的。
table.length==0 || table==null
且需要put的时候。这个时候根据初始化的方式不同,分为两种情况resize()。- 默认容量大小和装载因子的。
- 自定义容量大小的,装载因子可能是默认可能不是默认的情况的。
// 情况1.1 和情况1.2 :直接按照cap为2的n次方。thr正常翻倍即可。
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
}
// 情况2.1:
// else if 可以理解为(else => oldCap = 0) && oldThr>0 这表明 这个是刚初始化没有table没有oldCap情况下的 自己设置阈值的初始化方式!!!根据前面的tableSizeFor设置阈值为2^n,这里直接将他拿来作为新容量,同时计算在该容量下自定义的阈值(因为装载因子可能是自定义的)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 情况2.2:
// else 可以理解为 oldCap = 0 && oldThr = 0 这表明使用了只设置了装载因子为0.75的初始化条件。
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 这个是上面else if(oldThr>0)的继续内容。不知道为啥没有写在一起。
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
如何resize()?
-
创建并赋值两个临时变量newCap和newThr。(赋值算法见上 什么情况会resize())
-
根据newCap创建数组 newTab 并让成员变量table 指向newTab。同时让newThr赋值给静态成员变量
threshold
。 -
如果oldTab不为空,则将oldTsab的内容深拷贝到newTab中。
- 创建临时变量
e = oldTab[j],oldTab[j] = null
后一句是为了帮助GC? - rehash=>
newTab[e.hash & (newCap - 1)] = e;
- 如果
oldTab[index].next == null
则只需要把这个head rehash了。 - 如果他的next不为null,再判断这个节点是TreeNode还是Node。
- 如果是TreeNode,则
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
- 如果是Node,则判断该Node在hash table扩容前后位置index是否改变。【见补充1】
- 如果与运算为0使用
loHead
和loTail
保存,然后直接放到低位table[j]
下面。 - 如果与运算为1则使用
hiHead
和hiTail
链表保存,然后存放在高位。
- 如果与运算为0使用
- 如果是TreeNode,则
- 创建临时变量
// 已经拿到了newThr 和newCap的值以后
// 静态变量设置为新阈值
threshold = newThr;
// 根据newCap创建数组 newTab 并让成员变量table 指向newTab
@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; // 用于保存put后不移位的链表
Node<K,V> hiHead = null, hiTail = null; // 用于保存put后移位的链表
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;
}
进一步阅读补充2:HashMap在并发情况下可能出现的问题。
静态方法(类方法)
hash🙂
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
确认hashmap的索引位置,h表示key的hashCode值(类型是int,一共4Byte 4*8 = 32位),而hash值则使用hashCode()的高16位异或低16位实现的。主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
而在存入table的时候则使用hash值对 len求余:h&(len-1)
(hash%length==hash&(length-1)的前提是length是2的n次方)。举个例子,当n为默认值16的时候,该hashcode值的最小四位为1010,但是通过异或变成了0101
tableSizeFor
该方法是用于计算当前容量下对应的map的容量值(map的容量是2的次方)
// 找到距离他最近的2的阶乘的那个值比如17 返回32, 16 返回16, 15 返回16。
static final int tableSizeFor(int cap) {
// -1的二进制反码表示为 11111111 11111111 11111111 11111111
// -1 >>> 这么多位 表示 前面有多少个0然后后面全是1。
// 也就是说 n 表示 cap当前对应的二进制数 -1
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1); // numberOfLeadingZeros判断二进制条件下这个数值前面有多少个0,比如15前面就有28个0。
// System.out.println("n:"+n); // 16 - 1 = 15 -> 前面有 28个0 -> n=(1 >> (32 - 28))-1 =(1 >> 4 )-1= 15
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; // 15 + 1 = 16
}
比较方法
put:表示添加或者修改。如果key存在,则修改value,如果key不存在,则直接添加这一对ky。
e
补充
newCap下的ReHash问题
背景:如果一个Node在扩充前的table中位于table[j],那么再扩容两倍后,他应该在table的那个下标中?
假设:oldCap = 16 newCap = 32; j = 7(即hashCode应该为 7 23 39 55 71 ... )
native方法:
* 验证两个 7 和 23 在oldCap是同一个位置
j = (oldCap - 1) & hashCode = 0000 1111 & 0000 0111 = 0000 0111 = 7
j = (oldCap - 1) & hashCode = 0000 1111 & 0001 0111 = 0000 0111 = 7
* 求解newCap下的位置(试验对象7 23 39 55):
j = (newCap - 1) & hashCode = 0001 1111 & 0000 0111 = 0000 0111 = 7
j = (newCap - 1) & hashCode = 0001 1111 & 0001 0111 = 0001 0111 = 23
j = (newCap - 1) & hashCode = 0001 1111 & 0010 0111 = 0000 0111 = 7
j = (newCap - 1) & hashCode = 0001 1111 & 0011 0111 = 0011 0111 = 23
很明显可以看出来当oldCap -> newCap的时候,一定会有一半的(从概率的角度)Node位于原来所在的head一半的去了新的Head,源代码中使用loHead和hiHead来表示。而这里就是7和23。其下标很明显得:
loHead -> index = j;
hiHead -> index = j + oldCap;
* 代码中求解newCap下的位置的方法:
我们很明显会发现,只要是同一个下标j的bin中,那么oldCap范围内(低位)的二进制数是一样的,比如上述例子都是0111。区别在于高位(前1位)是1还是0的问题。这个规律一发现,就会算法就会容易理解很多,这个判断语句应该这么写:
if(oldCap & hashCode == 0)
// 0001 0000 & 0001 0111 = 1 -> 23 高位
// 0001 0000 & 0000 0111 = 0 -> 7 低位
// 这样就实现了只比较高位部分是否为1,而后续其实不用管的算法了!
HashMap在并发情况下可能出现的问题
JDK8已经很好的解决了JDK7HashMap代码中resize()死循环的问题。但是依然不是线程安全的,以put过程为例,其中有这么一句:
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
tab[i] = newNode(hash, key, value, null);
如果使用多线程,这个地方就可能出现数据覆盖的问题。
背景2: 假设有两个线程对一个大小size=10的map添加元素。
而在++size的时候,这也是三个步骤,分别是取值,+1和赋值的操作。假设取值后被打断,然后线程2也size+1,完成后,你size取值为10,然后+1 结果还是11。这样两个线程都+1但是最终的结果size却是11。这样会由于数据覆盖又导致了线程不安全。
Node和TreeNode的关系
我们知道Node是HashMap中对于Map接口中的Entry子接口的(内部)实现类。而TreeNode则是继承了超类LinkedHashMap.Entry
的子类。
static class Node<K,V> implements Map.Entry<K,V> {
// ...
}
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
// ...
}
那么HashMap.Node
,HashMap.TreeNode
,LinkedHashMap.Entry
之间是什么关系呢?通过点入LinkedHashMap可以发现:Node又是HashMap的超类:
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
所以HashMap.Node是LinkedHashMap.Entry的超类,而LinkedHashMap.Entry又是TreeNode的超类。
阅读源码我们会发现,HashMap.Node是单向链表的节点,而LinkedHashMap.Entry是双向链表的节点。TreeNode是包含前驱节点的,红黑树节点。
JDK7中的hashMap
- 添加元素
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold) // 和jdk8一样,他是先添加,再扩容的。
resize(2 * table.length);
}
// 明显就把新添加的元素放在head的位置。 head.next = table[bucketIndex]
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
// resize主要内容包括创建newCap的数组,迁移并赋值到新的table,新的thr
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
// 关键函数,迁移函数。原始函数就是直接从类成员变量中得到。
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null; // 帮助GC?
do {
Entry<K,V> next = e.next; // e 表当前链表指向的位置,next是下一个位置
int i = indexFor(e.hash, newCapacity); // 求当前Entry的index
e.next = newTable[i]; // 当前Entry的next = newTable[i] (头插法)
newTable[i] = e; // 然后newTable[i] = e
e = next;
} while (e != null);
}
}
}
该代码中线程不安全举例:
假设现在有线程一和线程二,都同时希望对一个bucket进行扩容,那么扩容步骤如下所示:
假设我们线程一创建了遍历e和next并指向了对应Node,这个时候线程二执行了:
线程二如上所所示已经完成了resize(),但是线程一resize()只完成了一半,于是线程一继续resize():
初始状态:
第一步:先执行 newTable[i] = e; 然后让e = next 并让他进行头插法,插入线程一中的头部(把7插入了头部,同时7的下一个指向了3)。
第二步:让3插入头部,然后让3指向7。这个时候 循环链表 成了!~
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix