1、HashMap概述
在JAVA中,集合框架对数据结构(算法)的封装最为完美。而HashMap基本上融合了所有集合类的特点,可以说是集百家之长,并且面试必问HashMap,那么面对这样一个优秀的集合类,作为以技术为核心竞争力的程序猿,你一定非常想一窥它的真容(源码)吧,恰巧我也是,所以就让我们一起掀开那神秘的面纱吧。
HashMap的底层数据结构是:数组+链表+红黑树, 它的主干是一个Node(key-value)数组,然后在每一个桶(Node节点)下面会有一个由Node组成的单向链表,在链表 长度大于8 的时候,先判断数组长度是否大于64,如果数组小于64,那么先进行扩容操作,等到 链表长度大于8且数组长度大于64 的时候,链表会发生树变,变成一颗红黑树(由TreeNode构成),TreeNode是一个有前指针和后指针的 双向节点。现在就来看一下它的结构图吧:
在实际应用当中,通常对HashMap的操作如下:
- 实例化:HashMap<String, String> hashMap = new HashMap<>();
- 存储数据:hashMap.put(“key”,“value”);
- 获取数据:hashMap.get(“key”);
- 删除数据:hashMap.remove(“key”);
public class HashMapTest {
public static void main(String[] args) {
//实例化
HashMap<String, String> hashMap = new HashMap<>();
//存储数据
hashMap.put("key", "value");
//根据key获取数据
String value = hashMap.get("key");
//删除数据
hashMap.remove("key");
}
}
根据这四个步骤,现在来看看HashMap的源码是怎么实现的。
2、实例化
JAVA中通常通过new一个对象进行实例化,这个实例化过程是通过其构造函数实现的。HashMap的构造函数共有四个。在看构造函数之前,先来看一下HashMap的一些主要参数,这有助于后面理解其内部底层实现。
2.1、主要参数
2.1.1、静态成员变量
下面这些静态变量是HashMap扩容及树变的的主要依据。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
//Node数组初始化长度为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//数组的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//Node数组进行扩容的负载因子为0.75
//例如第一次扩容时:当存储数量(即map.size())大于16*0.75=12时就会发生一次扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表是否树变成红黑树的一个条件:Node数组长度大于64时
static final int MIN_TREEIFY_CAPACITY = 64;
//链表变成红黑树的阈值:
//当链表长度大于8且Node数组长度大于64时,链表变成红黑树,否则先进行扩容
static final int TREEIFY_THRESHOLD = 8;
//红黑树变成链表的阈值:当链表长度小于6,红黑树变成链表
static final int UNTREEIFY_THRESHOLD = 6;
}
2.1.2、动态成员变量
下面看一下静态成员变量吧
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
//Node数组
transient HashMap.Node<K,V>[] table;
//存放key、value数值的
transient Set<Map.Entry<K,V>> entrySet;
//这个size是entrySet的大小,代表的是实际存储的key-value对象的数量
transient int size;
//记录操作HashMap的次数,新增、修改、删除
transient int modCount;
//扩容时的阈值 = 数组大小 * 加载因子
// threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR
int threshold;
//可以自定义的加载因子,如果未定义则用默认值0.75f
final float loadFactor;
}
2.1.3、内部类
这一个主要看一下存储数据的两个个内部类,分别是Node类、TreeNode类,Map.Entry类是一个key-value的数据结构。
//Node类,实现了 Map.Entry接口,由Node节点形成的是单向链表
static class Node<K,V> implements Map.Entry<K,V> {
//key的哈希值
final int hash;
//key键
final K key;
//value值
V value;
//指向下一个Node节点
Node<K,V> next;
}
//TreeNode类,继承了 LinkedHashMap.Entry类,由TreeNode节点形成的是双向链表
//在HashMap中,红黑树是由TreeNode形成的
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
//红黑树的父节点
TreeNode<K,V> parent; // red-black tree links
//红黑树的左节点
TreeNode<K,V> left;
//红黑树的右节点
TreeNode<K,V> right;
//红黑树的前节点
TreeNode<K,V> prev;
//是否是红节点
boolean red;
}
2.2、构造函数
HashMap的构造函数总共有四个,new HashMap<>()都可以由这个四个函数进行实例化的。
- 1、 HashMap的无参构造函数,也是HashMap默认的构造函数。在这个构造函数里面只给了动态加载因子loadFactor 赋默认值0.75f, 即默认加载因子的值。其它的成员变量未做任何初始化。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- 2、 自定义初始化容量和加载因子的构造函数
public HashMap(int initialCapacity, float loadFactor) {
//初始化容量大小不能为0,否则抛异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//初始化容量大小大于最大值时,赋值为最大值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 加载因子小于等于0或者为Nan时,抛异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//赋值加载因子
this.loadFactor = loadFactor;
//根据tableSizeFor()函数调整此时扩容阈值大小
/调整后,Node数组大小始终为2的n次方
this.threshold = tableSizeFor(initialCapacity);
}
//这个函数的作用将数组大小变成2的n次方,
如果你传进来数组大小为7,那么会将7通过以下运算变成8
//有兴趣的同学可以根据下面的步骤验算一下
//至于将数组大小设置为2的n次方则是为了减少Hash碰撞
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;
}
- 3、 自定义初始化容量构造函数,其实就是调用上述第2个自定义初始化容量和加载因子的构造函数,只不过加载因子使用的是默认加载因子为0.75f。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- 4、 传入一个Map参数,然后根据当前的Map大小,结合默认的加载因子0.75f,推出扩容阈值threshold ,然后再判断是否需要进行Node数组的扩容(resize()),最后再讲Map中元素放置新的Map对象中。构造函数如下所示:
//以Map作为参数的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
//赋值默认加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
//调用putMapEntries()函数,将数据放入新的Map对象
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//获取当前Map的大小
int s = m.size();
//如果当前Map有值,则进行下列的数据转移操作
if (s > 0) {
//如果Node数组为空,则初始化Node数组的大小和加载因子等
//但是并没有Node数组进行初始化
if (table == null) { // pre-size
//根据加载因子计算数组大小
float ft = ((float)s / loadFactor) + 1.0F;
//判断数组是否大于最大值
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//判断是否需要调整数组的大小
if (t > threshold)
threshold = tableSizeFor(t);
}
//根据扩容阈值判断是否需要扩容
//resize()函数才会对Node数组进行初始化
else if (s > threshold)
resize();
//循环遍历Map,然后调用
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
总结: 通过过对上述构造函数的认识,可以知道除了参数是Map的构造函数外,其余的构造函数并没有对HashMap的Node数组、EntrySet、Node节点等进行初始化操作。因为这个步骤都是放在了put()方法里实现的,这就是HashMap节省内存开销的一个设计,等到用hashMap对象操作数据的时候才去对Node数组、EntrySet、Node节点等进行初始化。
3、数据的存储过程
先来看一下put()方法的源码分析吧,这一节按照以下函数的过程进行进行分析
- put():程序的入口
- putVal():put()函数真正的实现
- resize():Node数组初始化和Node数组扩容操作都在这个函数里面
- treeifyBin():由链表转换为红黑树的函数实现
3.1、put()方法和putVal()方法底层实现
put方法里面调用hash()方法,将key的hash值计算出来,然后将key的hash值和key、value传到putVal()方法里面,下面来看一下put()方法和hash()方法的源码吧。
//put()方法只计算了key的hash值,然后调用putVal()方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//hash()方法 :将key的hash码与key的hash码的高16进行异或操作,
//目的是尽可能的将数据均匀的打乱,尽可能的实现key的均匀分布
static final int hash(Object key) {
//申明一个变量h,用于接收key的hash码
int h;
//先对h进行赋值:h = key.hashCode()
//然后再对h进行无符号右移16位,得到hash码的高16位值
//最后将key的hash码和key的hash码的高16进行异或操作
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
下面来看一下putVal()方法底层实现:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//申明一个Node数组
Node<K,V>[] tab;
//申明一个Node节点
Node<K,V> p;
//申明两个局部变量,其中n表示Node数组的长度,
// i表示当前Node节点在数组中的位置
int n, i;
//如果table为空或者table的长度为0,则调用resize()方法进行初始化。
//这个table表示的就是Node数组
if ((tab = table) == null || (n = tab.length) == 0)
//调用resize()方法对Node数组进行初始化,同时对tab变量和n变量进行赋值
n = (tab = resize()).length;
//表达式:i = (n - 1) & hash :
//表示将数组的长度(n)-1再与key的hash值进行与计算得到数组的下标
//然后将其赋值给变量i
//表达式 p = tab[i = (n - 1) & hash]) 表示在数组中获取到下标i的第一个Node节点赋值给对象p;
//如果此时的Node节点为空的话,则直接在此处新建一个Node节点。
if ((p = tab[i = (n - 1) & hash]) == null)
//在数组的下标i处新建一个Node节点。
tab[i] = newNode(hash, key, value, null);
else {
//申明一个Node节点 e
//这个节点e是用来标记是否存在和传进来的key相同的节点
//如果存在则将key相同的节点赋值给e
Node<K,V> e;
//申明一个键对象k
K k;
//p表示数组当前下标位置下(key对应的下标)的链表的第一个Node节点
//表达式 p.hash == hash表示当前节点的hash值和传进来的key的hash值相等
//表达式 (k = p.key) == key 表示当前节点的key值和传进来的key值内存地址相等
//表达式 key != null && key.equals(k) 表示当前节点的key值和传进来的key值相等
//整个表达式的意思就是,当前节点p的hash值和传进来的key值的hash值相等且key值也相等
//那么就把p节点赋值给对象节点e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//把p节点赋值给对象节点e
e = p;
//如果节点p属于红黑树节点,那么直接在红黑树中新增一个TreeNode节点,
//然后进行红黑树平衡调整
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//遍历Node数组下标i下的链表,节点p是首个Node节点
//binCount 代表链表当前的位置
for (int binCount = 0; ; ++binCount) {
//如果节点p的后一个节点为空,
//则新建一个节点存储key-value,并赋值给p的后一个节点
if ((e = p.next) == null) {
//新建一个节点存储key-value,并赋值给p的后一个节点
p.next = newNode(hash, key, value, null);
//如果链表长度>8, 那么则调用treeifyBin()进行树变
//但是在进行树变前需要判断,当前Node数组的长度是否大于64
//如果小于64则进行扩容,如果大于64则将链表变成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//调用treeifyBin()函数
treeifyBin(tab, hash);
//跳出循环
break;
}
//节点e的hash值和传进来的key值的hash值相等且key值也相等
//则跳出循环
//至于更改当前key的value值在后面进行替换
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//跳出循环
break;
p = e;
}
}
//如果节点e不为空,则表示有相同key值的节点
//那么用当前的value替换原来的value
if (e != null) { // existing mapping for key
//获取就的value值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//用传进来的value替换旧的value值
e.value = value;
afterNodeAccess(e);
//返回旧的值
return oldValue;
}
}
//操作次数加1
++modCount;
//如果当前map存的key-value对象的数量大于阈值,则进行扩容
//HashMap的扩容操作尽然在最后进行!!!
if (++size > threshold)
//resize函数进行扩容
resize();
afterNodeInsertion(evict);
return null;
}
putVal()方法的流程如下图所示:
面试常考的几个问题
- HashMap是怎么确定存储对象在Node数组中的位置?
- 首先通过表达式 :(h = key.hashCode()) ^ (h >>> 16) 得到key的hash码,就是将key的hash码与其自身hash码的高16位进行异或来最终确定key的hash码。这样做的母的是为了使得存储对象可以更均匀的分布在Node数组中,减少树变。
- 然后就是将key的hash值与(Node数组长度-1)进行&操作就可以得到存储对象在数组中的下标位置。表达式: (n - 1) & hash。例子:n=16时,n-1=15的二进制为:1111;1111&hash肯定为16以内的整数。
- 如果遇到key值相同的则替换key的value值,否则插入新的节点。
- HashMap什么时候会进行树变?
- 当Node数组中某一个位置的链表长度大于8时,会进行判断,如果数组长度大于64则进行树变,否则先进行数组扩容,直至数组长度大于64时,则进行树变。
3.2、resize()方法底层实现
resize()方法的功能如下所示:
- 如果当前Node数组未初始化,那么初始化Node数组, 并初始化扩容阈值等参数
- 如果Node数组已初始化存有数据,那么进行扩容,即把Node数组大小调整为上一次的2倍,同时调整扩容阈值大小
- 扩容完成之后,剩下的就是将旧的Node数组数据移动到新的Node数组中来
- 如果移动的节点是Node节点,则重新计算hash(rehash操作),再根据hash值进行移动
- 如果移动的节点是TreeNode节点,则调用split()方法移动
resize()方法源码分析如下:
final Node<K,V>[] resize() {
//将Node数组赋值给oldTab对象
Node<K,V>[] oldTab = table;
//计算旧的Node数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//扩容阈值大小赋值给oldThr
int oldThr = threshold;
//申明newCap变量:表示一个新的Node数组的长度
//申明newThr变量:表示新的扩容阈值
int newCap, newThr = 0;
//如果旧的Node数组大小大于0,那么就代表Node数组已进行初始化,则表示扩容
if (oldCap > 0) {
//旧的Node数组大小大于最大值
if (oldCap >= MAXIMUM_CAPACITY) {
//赋值最大值给扩容阈值
threshold = Integer.MAX_VALUE;
//返回旧的Node数组
return oldTab;
}
//如果旧的Node数组大小扩大2两倍小于最大值,且旧的Node数组大小大于初始化值16
//则扩容阈值也扩大两倍,
//旧的Node数组大小扩大2两倍后也赋值给新的Node数组大小变量newCap
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//则扩容阈值也扩大两倍,
newThr = oldThr << 1; // double threshold
}
//如果就的扩容阈值大于0,则将旧的扩容阈值赋值给新的 Node数组变量newCap
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//将初始化值16赋值给新的Node数组大小变量newCap
//将 16 * 0.75 = 12 赋值给新的扩容阈值变量newThr
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的扩容阈值为0,则将新的Node数组大小*加载因子的值赋值给新的扩容阈值变量newThr
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//将新的扩容阈值变量newThr赋值给全局扩容变量threshold
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//新建一个Node数组对象,大小为newCap
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将新的Node数组对象赋值给全局数组对象table。
table = newTab;
//至此,关于Node数组的大小的操作结束(扩容或者初始化)
//如果Node数组中有数据,则下面就是进行数据迁移了
if (oldTab != null) {
//遍历整个旧的Node数组
for (int j = 0; j < oldCap; ++j) {
//新建一个Node对象
Node<K,V> e;
//如果Node数组当前下标位置有值,则赋值给Node对象e
if ((e = oldTab[j]) != null) {
将Node数组当前下标位置赋值为null
oldTab[j] = null;
//如果当前下标位置的链表只有当前一个节点,则重新计算节点e 的hash值,rehash操作
//然后根据e.hash & (newCap - 1)的值确定新下标位置,然后直接赋值
if (e.next == null)
//直接进行赋值
newTab[e.hash & (newCap - 1)] = e;
//如果节点e属于红黑树节点
//则调用split()方法,再根据rehash等操作放到新的Node数组下的新位置
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//如果上面两种情况都不是,即当前位置下面的链表有多个Node节点
//则循环遍历链表,移动位置
else { // preserve order
//如果旧Node数组下的Node位置在新的Node数组中的位置没有发生改变
//则使用下面的节点进行赋值
//如在旧的Node数组中下标的位置是1,在新的Node数组中下标的位置也是1
Node<K,V> loHead = null, loTail = null;
//如果旧Node数组下的Node位置在新的Node数组中的位置发生改变
//则使用下面的节点进行赋值
//如在旧的Node数组中下标的位置是1,则在新的Node数组中下标的位置就是:旧数组长度+1
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//当前节点e的下一个节点赋值给对象next节点
next = e.next;
//表达式e.hash & oldCap:计算元素原来在旧的Node数组中的位置
//如果表达式等于0,那么相当于在新数组中的位置和旧数组的位置一样,
//以旧数组长度16为例,扩容为32
//16的二进制是 10000,那么在16以内的二进制是:00000~01111之间,与10000进行与操作都为0
//意味着在数组前16的位置的Node节点没有发生位置改变。
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函数的整个流程如下所示:
关于resize函数有几个经常被问到的面试题。
- resize()方法中的rehash(哈希重定向)是怎么实现的?
- 它是根据表达式(e.hash & oldCap) == 0来进行rehash的。这个表达式的意思如下:
以旧Node数组长度为16,扩容后的新Node数组长度为32为例.
那么16的二进制为 10000, 假设e.hash的值为1,那么 (e.hash & oldCap)则为:
10000 & 00001 则为0,那么在新数组中的位置不会发生改变,也为1。因为确定数组下标位置的表 达式为 (e.hash & n-1),1 & 31即为 000001 &011111 也为1,事实证明如果
(e.hash & oldCap)==0成立,那么旧的节点数组在新的Node数组中的位置不会发生变化。 - 那如果不等的情况呢?
假设e.hash的值为17,那么17的二进制为10001,(e.hash & oldCap)则为:
10001 &10000 则为1, 不为0,这个表示它在新数组的位置为 16(旧数组长度)+1 = 17;
那么再根据表达式(e.hash & n-1)计算一下位置即17&31 : 010001 & 011111 =0100001 = 17
- 它是根据表达式(e.hash & oldCap) == 0来进行rehash的。这个表达式的意思如下:
你理解rehash的过程了吗?如果还不了解,请看下图:
3.3、treeifyBin()方法底层实现
在调用putVal()方法的时候,treeifyBin()方法的功能如下所示:
- 如果Node数组为空或者Node数组长度小于64那么调用resize()方法,如果数组为空则进行初始化,如果不为空,则进行数组扩容操作
- 如果Node数组不为空,则遍历当前链表,调用replacementTreeNode()方法,将当前链表的所有Node节点替换成
TreeNode节点, - 全部替换完成后,调用treeify()方法进行红黑树平衡调整
源码解析如下:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果Node数组为空或者Node数组长度小于64那么调用resize()方法
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果Node数组不为空,则遍历当前链表
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//将当前链表的所有Node节点替换成TreeNode节点
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);
//将当前链表的所有Node节点替换成TreeNode节点
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
treeifyBin()方法比较容易理解,比较难的就是怎么样进行红黑树平衡调整。这里因为篇幅问题就不再详细赘述,感兴趣的同学可以去看一下红黑树平衡调整的实现。不论是红黑树左旋和右旋,红黑树平衡调整始终要遵循红黑树的五大原则,五大原则如下:
- 节点必须是红色或者是黑色
- 根节点是黑色的
- 所有的叶子节点是黑色的。
- 每个红色节点的两个子节点是黑色的,也就是不能存在父子两个节点全是红色
- 从任意每个节点到其每个叶子节点的所有简单路径上黑色节点的数量是相同的。
至此hashMap的put方法全部解析完成。
4、获取数据的过程
HashMap获取数据的过程是通过调用map.get(“key”);方法实现的,现在就让我们看一下具体的底层实现吧。
get()方法其实只是调用了getNode()方法根据key的hash值和key查找Node节点,get()方法的源码如下:
public V get(Object key) {
Node<K,V> e;
//调用getNode()方法根据key的hash值和key查找Node节点,
//如果节点不为空,则返回Node的value值,否则返回null
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode()方法查找节点的过程就是put()方法的逆过程,
- 首先根据表达式 (n - 1) & hash确定key在Node数组的下标位置
- 如果当前下标位置的第一个Node节点的hash值和key值相等,则直接返回
- 否则继续看下一个节点,如果是树节点的话,则到红黑中去查找树节点返回
- 否则遍历当前链表,直到找到相同的hash值和key值的节点。
getNode() 方法源码如下:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//key在Node数组的下标位置
if ((tab = table) != null && (n = tab.length) > 0 &&
//获取当前下标位置的第一个Node节点
(first = tab[(n - 1) & hash]) != null) {
//如果hash值和key值相等,则直接返回
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);
//否则遍历当前链表,直到找到相同的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;
}
总结: getNode()方法相对来说容易理解,其实就是put方法的逆过程。
5、删除数据的过程
HashMap获取数据的过程是通过调用map.remove(“key”);方法实现的,现在就让我们看一下具体的底层实现吧。
remove()方法其实只是调用了removeNode()方法根据key的hash值和key查找Node节点,然后进行删除的,remove()方法的源码如下:
public V remove(Object key) {
Node<K,V> e;
//调用了removeNode()方法根据key的hash值和key查找Node节点进行删除
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
removeNode()方法的执行过程如下:
- 首先根据表达式 (n - 1) & hash确定key在Node数组的下标位置
- 如果当前下标位置的第一个Node节点的hash值和key值相等,则获取该节点,如果该节点是树节点,则调用removeTreeNode()方法进行删除,然后再进行红黑树平衡调整。否则继续向下查找
- 如果下一个节点不为空,那么再判断是否为树节点,
- 如果为树节点,则获取该树节点,然后调用removeTreeNode()方法进行删除,然后再进行红黑树平衡调整
- 否则遍历整个链表直到找到一个Node节点的hash值和key值相等的节点,如果没有找到则返回null
removeNode()方法的源码如下:
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;
//首先根据表达式 (n - 1) & hash确定key在Node数组的下标位置
if ((tab = table) != null && (n = tab.length) > 0 &&
//获取当前位置的第一个Node节点
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//第一个节点的hash值和key值相等,则将该节点赋值给node对象
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//如果第一个节点不相等且下一个节点不为null
else if ((e = p.next) != null) {
//如果下一个节点为红黑树节点
if (p instanceof TreeNode)
//调用getTreeNode()方法获取树节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//如果不是红黑树,那么就是链表了,则遍历链表查询
//hash值和key值相等的节点
else {
do {
//如果节点的hash值和key值相等,则获取该节点
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
//循环遍历链表
} while ((e = e.next) != null);
}
}
//如果node不为空,则表示找到了与之匹配的节点对象节点,node节点就是需要背删除的节点
//否则直接返回null
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果是树节点
if (node instanceof TreeNode)
//调用removeTreeNode()方法删除树节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//如果是node节点是头结点
else if (node == p)
//则将数组下标直接直接指向首节点的下一个节点,首节点直接被删除
tab[index] = node.next;
//如果是中间节点,则p节点的下一个节点直接指向node节点的下一个节点即可
else
p.next = node.next;
//修改次数加1
++modCount;
//map大小减1
--size;
afterNodeRemoval(node);
//返回删除的节点
return node;
}
}
return null;
}
至此,删除数据的过程结束。
6、常见面试题
- HashMap的底层数据结构是什么?数据存储的底层实现原理是什么?
底层原理就是put()方法和get()方法的具体实现过程。 - HashMap的是怎么样进行数据定位的?
根据表达式 ( key.hash & n-1 )进行定位的,该表达式在文中有详细说明 - 聊聊HashMap的resize()函数?
主要是问rehash是怎么实现。就是文中表达式( key.hash & n )==0 - 为什么Node数组长度是2的n次幂?
为了减少hash碰撞,使数据更均匀的分布, 因为2的n次方 -1之后的二进制最高位以下都是1,如16-1=15的二进制为
0 1111,这个值与任何值进行&操作的时候结果都在15以内,且很少会发生Hash碰撞。看下面两个例子:
例如长度为9时候,3&(9-1)=0 和2&(9-1)=0 ,都在0上,碰撞了;
例如长度为8时候,3&(8-1)=3 和2&(8-1)=2 ,不同位置上,不碰撞; - hashMap是线程安全的吗?多线程下会发生什么?
不是线程安全的,多线程下应该使用ConcurrentHashMap。 - hashMap是怎么解决hash冲突的?
即hashcode相同怎么办?采用链地址法,即数组+链表的形式。相同的hashcode放在链表中,如果Key相同则替换value值,否则新新增一个链表节点。 - 为什么要将链表转化为红黑树?
减少查找的时间复杂度。当长度大于8的时候,红黑树的平均时间复杂度要由于链表。
红黑树平均时间复杂度为 log8 = 3, 链表的平均时间复杂度为 8/2 = 4 ;
欢迎各位关注我的JAVAERS公众号,陪你一起学习,一起成长,一起分享JAVA路上的诗和远方。在公众号里面都是JAVA这个世界的朋友,公众号每天会有技术类文章,面经干货,也有进阶架构的电子书籍,如Spring实战、SpringBoot实战、高性能MySQL、深入理解JVM、RabbitMQ实战、Redis设计与实现等等一些高质量书籍,关注公众号即可领取哦。 欢迎大家加入JAVA后端讨论群。
如果大家对人工智能感兴趣,可以关注下面公众号,会持续更新c++、python、tensorflow、机器学习、深度学习、计算机视觉等系列文章
转载:https://blog.csdn.net/qq_36526036/article/details/105323539