刷完HashMap源码,我们一起进大厂

Posted 博学谷狂野架构师

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了刷完HashMap源码,我们一起进大厂相关的知识,希望对你有一定的参考价值。

不可不知的哈希映射

引言
hashmap这个东西呢,太老生常谈了
开发中常用、面试中常问
总之,很重要。。。。。
接下来呢
咱们就一起来看下,里面到底有哪些解不开的东西

2.1 HashMap数据结构

目标:

HashMap 概念、数据结构回顾(JDK8和JDK7) & 为什么1.8使用红黑树?

概念:

HashMap 是一个利用散列表(哈希表)原理来存储元素的集合,是根据Key value而直接进行访问的数据结构

在 JDK1.7 中,HashMap 是由 数组+链表构成的。

在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成

回顾: 数组、链表(优势和劣势)

刷完HashMap源码,我们一起进大厂_数据结构

数组: 优势:数组是连续的内存,查询快(o1) 劣势:插入删除O(N) 链表: 优势:不是连续的内存,随便插入(前、中间、尾部) 插入O(1) 劣势:查询慢O(N)

思考?

为什么是JDK1.8 是数组+链表+红黑树???

HashMap变化历程

1.7的数据结构:链表变长,效率低 了!!!

刷完HashMap源码,我们一起进大厂_算法_02

1.8的数据结构:

数组+链表+红黑树

刷完HashMap源码,我们一起进大厂_算法_03

链表--树(链长度>8、数组长度大于64)

备注:现在重点讲map,不讲树的操作。树在算法课里有详细学习

总结:

JDK1.8使用红黑树,其实就是为了提高查询效率

因为,1.7的时候使用的数组+链表,如果链表太长,查询的时间复杂度直接上升到了O(N)

2.2 HashMap继承体系

目标:梳理map的继承关系

刷完HashMap源码,我们一起进大厂_后端_04

总结

HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口

那为什么HashMap还要在实现Map接口呢?

据 java 集合框架的创始人Josh Bloch描述,这样的写法是一个失误。

在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。

显然的,JDK的维护者,后来不认为这个小小的失误值得去修改,所以就这样存在下来

  • Cloneable 空接口,表示可以克隆
  • Serializable 序列化
  • AbstractMap 提供Map实现接口

2.3 HashMap源码深度剖析

1)目标:

通过阅读HashMap(since1.2)源码,我们可以知道以下几个问题在源码是如何解决的

(1)HashMap的底层数据结构是什么?

(2)HashMap中增删改查操作的底部实现原理是什么?

(3)HashMap是如何实现扩容的?

(4)HashMap是如何解决hash冲突的?

(5)HashMap为什么是非线程安全的?

2)测试代码如下

package com.mmap;

import org.openjdk.jol.info.ClassLayout;

import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;

public class MMapTest
public static void main(String[] args)
HashMap<Integer, String> m = new HashMap<Integer, String>();//尾插
//断点跟踪put
m.put(1, "001");
m.put(1, "002");
m.put(17, "003");//使用17可hash冲突(存储位置相同)

System.out.println(ClassLayout.parseInstance(m).toPrintable());
//断点跟踪get
System.out.println(m.get(1));//返回002(数组查找)
System.out.println(m.get(17));//返回003(链表查找)
//断点跟踪remove
m.remove(1);//移除
System.out.println(m);
m.remove(1, "002");//和上面的remove走的同一个代码

3)关于hashMap基本结构的验证

先来个小验证,几乎地球人都知道map是 数组 + 链表 结构,那我们先来验证一下

刷完HashMap源码,我们一起进大厂_数据结构_05

再来看debug结果:

刷完HashMap源码,我们一起进大厂_java_06

验证了基本结构,那为啥1和17就在一块了?到底谁和谁放在一个链上呢?内部到底怎么运作的?往下看 ↓

2.3.1 成员变量与内部类

目标:先了解一下它的基本结构

回顾:位运算(下面还会频繁用到)

1<<4

二进制相当于1右边补4个0:10000

十进制相当于1 x 2的4次方 , 也就是 16

二进制运算是因为它的计算效率高

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //16,默认数组容量:左位移4位,即16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量:即2的30次幂
static final float DEFAULT_LOAD_FACTOR = 0.75f;//负载因子:扩容使用,统计学计算出的最合理的
static final int TREEIFY_THRESHOLD = 8;//链表转红黑树阈值
static final int UNTREEIFY_THRESHOLD = 6;//当链表的值小<6, 红黑树转链表

……

transient Node<K,V>[] table;//HashMap中的数组,中间状态数据
transient Set<Map.Entry<K,V>> entrySet;//用来存放缓存,中间状态数据;

transient int size;//size为HashMap中K-V的实时数量(重点),注意!不是table的长度!
transient int modCount;//用来记录HashMap的修改次数,几个集合里都有它
int threshold;//扩容临界点;(capacity * loadFactor)(重点)
final float loadFactor;//负载因子 DEFAULT_LOAD_FACTOR = 0.75f赋值

具体的key,value放在哪里呢?答案是一个静态内部类(1.8前是Entry)

static class Node<K,V> implements Map.Entry<K,V> //数组和链表上的节点,1.8前叫Entry
final int hash;//扰动后的hash
final K key;//map的key
V value;//map的value
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;//链表下一个

2.3.2 HashMap构造器

1)目标:学习下面的三个构造器,它们都干了哪些事情?

public HashMap() 
this.loadFactor = DEFAULT_LOAD_FACTOR; // 负载因子DEFAULT_LOAD_FACTOR = 0.75f
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); // 注意,map里是没有capacity这个类变量的!

map的构造函数就做了几个赋值这么点事?这么简单?错!接着往下看

2)无参构造函数验证

第一步:添加以下debug变量

第二步:使用默认构造函数时,在put之前和之后分别debug以上变量信息对比看看

put之前:

刷完HashMap源码,我们一起进大厂_算法_07

之后

刷完HashMap源码,我们一起进大厂_面试_08

毫无违和感,那么,我们接着往下!

3)自定义初始化参数验证

接下来我们胡搞一下,让容量=15,因子=0.5,猜一猜会发生什么?

刷完HashMap源码,我们一起进大厂_java_09

调试到put之后,再来看:

刷完HashMap源码,我们一起进大厂_面试_10

源码剖析:

在有参数构造时,最终tableSizeFor

//capacity函数,初始化了table,就是table的length,否则取的是threshold
final int capacity()
return (table != null) ? table.length :
(threshold > 0) ? threshold :
DEFAULT_INITIAL_CAPACITY;


//带参数的初始化,其实threshold调用的是以下函数:
//这是什么神操作???
//其实是将n转成2进制,右移再和自己取或,相当于把里面所有的0变成了1
//最终目的:找到>=n的,1开头后面全是0的数。如果n=111 , 那就是 1000 ; 如果n=100,那就是它自己
//而这个数,恰好就是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; //到这一步n已经各个位都是1了。
//范围校验,小于0返回1,大于最大值返回最大值,绝大多数正常情况下,返回n+1,也就是10000……
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

调试案例:

package com.mmap;

public class TableSizeTest

public static void main(String[] args)
System.out.println(tableSizeFor(9)); //9的二进制更能看出以下变化


static final int tableSizeFor(int cap)
System.out.println(Integer.toBinaryString(cap)); //1001
int n = cap - 1;
System.out.println(Integer.toBinaryString(n)); //1000
n |= n >>> 1; //无符号右移,前面补0
System.out.println(Integer.toBinaryString(n)); //右移再或,1100
n |= n >>> 2;
System.out.println(Integer.toBinaryString(n)); //再移动2位, 1111
n |= n >>> 4;
System.out.println(Integer.toBinaryString(n)); //就这么长,再迁移也是1111
n |= n >>> 8;
System.out.println(Integer.toBinaryString(n));
n |= n >>> 16;
System.out.println(Integer.toBinaryString(n)); //Integer的最大长度32位,16折半后迁移全覆盖
System.out.println(Integer.toBinaryString(n+1));
return n + 1; //+1后变为 10000 ,也就是16 , 2的4次方

4)总结:

map的构造函数没有你想象的那么简单!

  • 无参构造时,容量=16,因子=0.75。第一次插入数据时,才会初始化table、阈值等信息
  • 有参构造时,不会容忍你胡来,会取大于但是最接近你容量的2的指数倍(想一下为什么?提示:和扩容规则有关)
  • 无论哪种构造方式,扩容阈值最终都是 =(容量*因子)

2.3.3 HashMap插入方法

目标:图解+代码+断点分析put源码

1)先了解下流程图

刷完HashMap源码,我们一起进大厂_后端_11

2)关于key做hash值的计算

当我们调用put方法添加元素时,实际是调用了其内部的putVal方法,第一个参数需要对key求hash值

public V put(K key, V value) 
return putVal(hash(key), key, value, false, true);//调用Map的putVal方法

小提问:map里所谓的hash是直接用的key的hashCode方法吗?

static final int hash(Object key) 
int h;
//【知识点】hash扰动
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

图解:

刷完HashMap源码,我们一起进大厂_后端_12

结论:使用移位和异或做二次扰动,不是直接用的hashCode!

3)核心逻辑

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) //onlyIfAbsent:true不更改现有值;evict:false表示table为创建状态
//几个临时变量:
//tab=数组,p=插槽指针,n=tab的长度,i数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)//数组是否null或者==0,第1次put为空
//初始化数组(or扩容),所以table是在这里初始化的,不是new的时候!
//初始时,n=16
n = (tab = resize()).length; // resize,下面单独讲扩容

//【知识点】为何1 与 17 在一个槽上!秘密就藏在寻址这里
if ((p = tab[i = (n - 1) & hash]) == null)//寻址:(n - 1) & hash(重要!)
tab[i] = newNode(hash, key, value, null);//当前插槽没有值,空的!将新node直接扔进去
else //有值,说明插槽上发生了碰撞,需要追加成链表了!
//还是临时变量
//e=是否找到与当前key相同的节点,找到说明是更新,null说明是新key插入
//k=临时变量,查找过程中的key在这里暂存用
Node<K,V> e; K k;
if (p.hash == hash && //如果正好,插槽第一个节点(p),跟插入的key相同
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //就将它赋值给e,注意!这时候还没覆盖上去,只是标记到e,发现了相同key的节点!
else if (p instanceof TreeNode) //如果不是这个key,但是类型是一个红黑树节点
//这说明当前插槽的链很长,已经变成红黑树了,就调putTreeVal,扔到这颗树上去
//树的插入,这里不赘述,在数据结构课中有
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else //如果都不是以上情况,那就是链表了
for (int binCount = 0; ; ++binCount)
if ((e = p.next) == null) //顺着链表一直往后跳,直到遍历到尾巴节点
p.next = newNode(hash, key, value, null);//然后把key封装成新node追加到尾巴上
if (binCount >= TREEIFY_THRESHOLD - 1) //链表长度计数如果>8转红黑树
treeifyBin(tab, hash);//转成树
break;

if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;// 如果遍历过程中找到相同key,那就赋给e,break跳出for循环,执行后面的逻辑
p = e;


if (e != null) // 如果e非空,说明前面一顿猛如虎的操作后,找到了相同的key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;// 看看onlyIfAbsent,如果让覆盖那就覆盖,不让那就算了
afterNodeAccess(e);
return oldValue;// 返回覆盖前的value值,也就是put方法的返回值


++modCount;//用来记录HashMap的修改次数
if (++size > threshold)//key的数量是否大于阈值
resize();//如果size大于threshold,就需要进行扩容
afterNodeInsertion(evict);
return null;

4)重点(寻址计算):

接上文,关于hash值取得后,放入tab的哪个插槽,也就是所谓的寻址我们重点来讲

(n - 1) & hash

我们还是以开始的例子,1和17为例,他们的hash计算后正好是1和17本身,我们可以验证一下

Integer i = new Integer(1);
Integer j = new Integer(17);
System.out.println(i.hashCode() ^ i.hashCode()>>16); //1
System.out.println(j.hashCode() ^ j.hashCode()>>16); //17

开始位运算

默认n=16,n-1也就是15,二进制是 1111

那么 15 & 1
1 1 1 1
0 0 0 1
与运算后 = 1

再来看15 & 17,17是 10001
1 1 1 1
1 0 0 0 1
与运算后 = 1

所以,1和17肯定会落在table的1号插槽上!两者会成为链表,解释了我们前面的案例

原理:不管你算出的hash是多少,超出tab长度的高位被抹掉,低位是多少就是你所在的槽的位置,也就是table的下标

思考:为什么不用mod(模运算)进行寻址?mod也能保证不会超出数组边界,岂不是更简单直观?

package com.mmap;

public class CMod
public static void main(String[] args)
bit();
mod();


public static void bit()
int a ;
long start = System.currentTimeMillis();
//同样的计算次数,先位运算,后取余
for (int i = 1; i < Integer.MAX_VALUE; i++)
a = 1 & i;

long end = System.currentTimeMillis();
System.out.println("BIT耗时>>" + (end - start));



public static void mod()
int a ;
long start = System.currentTimeMillis();
for (int i = 1; i < Integer.MAX_VALUE; i++)
a = 1 % i;

long end = System.currentTimeMillis();
System.out.println("MOD耗时>>" + (end - start));



跑一下试试?

结论:

一切为了性能

2.3.4 HashMap扩容方法

目标:图解+代码(map扩容与数据迁移)

注意:扩容复杂、绕、难

备注:线程不安全的

图解: 假设我们 new HashMap(8)

迁移前:长度8 扩容临界点6(8*0.75)

刷完HashMap源码,我们一起进大厂_面试_13

迁移过程

刷完HashMap源码,我们一起进大厂_数据结构_14

刷完HashMap源码,我们一起进大厂_数据结构_15

核心源码resize方法

//注意!该方法兼容了初始化和扩容,所以比较难理清楚!
//需要对照上面的图例来同步讲解。
final Node<K,V>[] resize()
Node<K,V>[] oldTab = table; //旧的数组先拿出来
int oldCap = (oldTab == null) ? 0 : oldTab.length;//旧数组是null,那就是初始化咯
int oldThr = threshold;//扩容临界点(旧)
int newCap, newThr = 0;//临时变量,数组容量(新)、扩容临界点(新)
if (oldCap > 0)
// 扩容的时候调用
if (oldCap >= MAXIMUM_CAPACITY) //如果旧值达到上限
threshold = Integer.MAX_VALUE; //扩容阈值也调到最大,从此再无意义
return oldTab; //不扩了,直接返回旧的。上限了还扩什么扩

//如果没到上限就计算新容量,注意这时候还没发生实际的数组扩容,真正的扩容迁数据操作在下面
//将旧容量左移1位,也就是乘以2作为新容量,所以map是每次扩到之前的2倍
//链表是右移1位再加上旧长度,也就是扩为原来的1.5倍,注意区别
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 同时,阈值也乘以2,为下次扩容做准备

else if (oldThr > 0)
// HashMap(int initialCapacity, float loadFactor)初始化的时候调用
// 将cap和thres相等,约定
newCap = oldThr;
else
// HashMap() 初始化的时候调用,注意前面验证过了,是在第一次put的时候调的
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

if (newThr == 0) //如果新阈值为0,根据负载因子设置新阈值
float ft = (float)newCap * loadFactor; // put之后的变化就在这里
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; //以新容量为长度,创建新数组
if (oldTab != null) //如果旧数组不为空,说明有数据要迁移
for (int j = 0; j < oldCap; ++j) //遍历数组
Node<K,V> e; //临时变量,记录当前指向的node
if ((e = oldTab[j]) != null)
oldTab[j] = null;//gc处理
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;//只一个节点,赋值到新数组的索引下即可
else if (e instanceof TreeNode)// 如果变成了树,拆成俩拼到新table上去
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else //如果是链表,拆成两个(重点!!!)
Node<K,V> loHead = null, loTail = null;//低位链表(原位置i)
Node<K,V> hiHead = null, hiTail = null;//高位链表(i+n位置)
Node<K,V> next;
do
next = e.next;
if ((e.hash & oldCap) == 0) //oldCap=8,2进制为1000
// 如果为0,说明e的hash没超出旧cap长度去,在低位不动即可
if (loTail == null)
loHead = e;// 如果为空的,那就是第一个,同时当头节点
else
loTail.next = e; //否则的话,沿着尾巴一直往上追加
loTail = e;

以上是关于刷完HashMap源码,我们一起进大厂的主要内容,如果未能解决你的问题,请参考以下文章

50道SQL练习题(刷完直接进大厂)

分析了上百份大厂面经,我总结了这些最最最常问的面试题,刷完直接进大厂!

硬核!刷完这份《Java面试突击宝典》,90%的程序员进了大厂!

我们一起进大厂 -- Redis哨兵模式

刷完这套阿里面试真题,大厂 offer 拿来吧你

刷完500道BAT面试题,我能去面试大厂了吗?