在JAVA中,集合框架对数据结构(算法)的封装最为完美。而HashMap基本上融合了所有集合类的特点,可以说是集百家之长,并且面试必问HashMap,那么面对这样一个优秀的集合类,作为以技术为核心竞争力的程序猿,你一定非常想一窥它的真容(源码)吧,恰巧我也是,所以就让我们一起掀开那神秘的面纱吧。
HashMap的底层数据结构是:数组+链表+红黑树, 它的主干是一个Node(key-value)数组,然后在每一个桶(Node节点)下面会有一个由Node组成的单向链表,在链表 长度大于8 的时候,先判断数组长度是否大于64,如果数组小于64,那么先进行扩容操作,等到 链表长度大于8且数组长度大于64 的时候,链表会发生树变,变成一颗红黑树(由TreeNode构成),TreeNode是一个有前指针和后指针的 双向节点。现在就来看一下它的结构图吧:
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的源码是怎么实现的。
JAVA中通常通过new一个对象进行实例化,这个实例化过程是通过其构造函数实现的。HashMap的构造函数共有四个。在看构造函数之前,先来看一下HashMap的一些主要参数,这有助于后面理解其内部底层实现。
下面这些静态变量是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;
}
下面看一下静态成员变量吧
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;
}
这一个主要看一下存储数据的两个个内部类,分别是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;
}
HashMap的构造函数总共有四个,new HashMap<>()都可以由这个四个函数进行实例化的。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
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;
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//以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节点等进行初始化。
先来看一下put()方法的源码分析吧,这一节按照以下函数的过程进行进行分析
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;
}
resize()方法的功能如下所示:
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函数有几个经常被问到的面试题。
在调用putVal()方法的时候,treeifyBin()方法的功能如下所示:
源码解析如下:
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方法全部解析完成。
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()方法的逆过程,
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方法的逆过程。
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()方法的执行过程如下:
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;
}
至此,删除数据的过程结束。
欢迎各位关注我的JAVAERS公众号,陪你一起学习,一起成长,一起分享JAVA路上的诗和远方。在公众号里面都是JAVA这个世界的朋友,公众号每天会有技术类文章,面经干货,也有进阶架构的电子书籍,如Spring实战、SpringBoot实战、高性能MySQL、深入理解JVM、RabbitMQ实战、Redis设计与实现等等一些高质量书籍,关注公众号即可领取哦。 欢迎大家加入JAVA后端讨论群。
如果大家对人工智能感兴趣,可以关注下面公众号,会持续更新c++、python、tensorflow、机器学习、深度学习、计算机视觉等系列文章