HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。随着JDK(Java Developmet Kit)版本的更新,JDK1.8对HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。

​ Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,类继承关系如下图所示:

下面针对各个实现类的特点做一些说明:

(1) HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

(2) Hashtable:Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。

(3) LinkedHashMap:LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。

(4) TreeMap:TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。

对于上述四种Map类型的类,要求映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。

HashMap的基本原理

​ 如果小伙伴们对HashMap的基本原理还不熟悉,建议大家参考 漫画:什么是HashMap?一文。这里对基本原理进行简单的梳理:

​ HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中(这个数组被称为Hash桶数组),这个数组就是HashMap的基础。

​ 那么我们如何将对象按照key的值放入到数组中去呢?这里我们就需要借助Hash函数,Hash函数可以将任何一个对象转换为一个int类型的值,但是这还不能将元素插入到数组中,因为数组的容量有限,而int类型的值非常大,我们不可能将int类型的Hash值与数组的元素一一对应,那么解决这种问题最简单的方法就是取模(%),将Hash值%数组容量就会得到一个不大于数组容量的一个数值,就可以将元素插入到数组中。

​ 但是,因为HashMap中数组的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况(Hash冲突就是两个不同Entry计算出来的数组索引值相同)。那么解决Hash冲突最简单的方法采用拉链法,也就是将冲突的Entry以链表的形式存在。

​ 在JDK7之前,HashMap解决Hash冲突时都是采用拉链法解决的;而在JDK8开始,HashMap采用链表+红黑树相结合的方式解决的,具体实现原理在后序源码讲解中体现。

​ 当HashMap中的数组中保存的Entry太多后,Hash冲突的可能性不断增大,这样会大大降低HashMap的执行效率,所以HashMap引入了扩容机制。这里就需要了解加载因子(负载因子)的概念,当元素个数大>数组容量*加载因子时就会触发扩容,扩容时HashMap中的数组增加至原来两倍,将原Map中的数组重新散列到新的数组中去,至此完成了扩容操作。

HashMap源码分析

HashMap中的常量值

	/**
* 默认的数组容量为16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /**
* 数组的最大容量。在后面我们会将每一个数组元素看成一个桶,因为数组元素在后面可能连接的是一个链表或者是一颗树。
*/
static final int MAXIMUM_CAPACITY = 1 << 30; /**
* 默认的加载因子(负载因子)为0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f; /**
* 当链表长度到达TREEIFY_THRESHOLD时将链表转换为红黑树,也就是说链表最长为7。
*/
static final int TREEIFY_THRESHOLD = 8; /**
* . 桶的链表还原阈值:即 红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
*/
static final int UNTREEIFY_THRESHOLD = 6;

​ 上面部分常量值有可能不能完全理解,我们这里只做一些了解即可。

HashMap中几个关键字段

int threshold;             // 所能容纳的key-value对极限 (阈值)
final float loadFactor; // 负载因子
int modCount; //用于迭代的快速失败机制
int size; //HashMap中实际存在的键值对数量
  • threshold和loadFactor:Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
  • size:这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。
  • modCount:这个字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化

构造函数分析

 /**
* @param initialCapacity HashMap初始化桶的个数(数组的容量)
* @param loadFactor 加载因子小于0抛出异常
*/
public HashMap(int initialCapacity, float loadFactor) {
//如果initialCapacity小于0抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
//如果initialCapacity大于最大容量,则将初始化容量设为最大值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//当加载因子小于等于0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//如果用户传入的initialCapacity值不是2的幂,那么返回一个不小于它的2的幂
this.threshold = tableSizeFor(initialCapacity);
}

​ 构造函数中传入了initialCapacity,loadFactor。

​ initialCapacity表示HashMap中桶的个数。

​ loadFactor是HashMap的加载因子(负载因子),负载因子决定HashMap的扩容时机,如果HashMap中存储的Entry数量大于等于 当前数组容量*加载因子时就会触发数组的扩容操作。举例来说:假如当前数组容量为16,加载因子为0.75,那么此时数组中最大的元素个数为16*0.75=12。

tableSizeFor方法的作用

​ 在构造函数中调用了一个名为tableSizeFor方法,它将用户传入的初始容量转换为一个不小该值的2的幂值。例如:传入initialCapacity=11,他返回的是initialCapacity=16。这样做的目的到底是为什么呢?

​ 前面说到了元素定位时会使用取模运算,实际上这种算法效率非常低,为了实现更加高效的算法,HashMap采用位运算的方式:

index = HashCode(Key) & (Length - 1)

下面我们以值为“book”的Key来演示整个过程:

  1. 计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
  2. 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
  3. 把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。

采用位运算得到的结果与取模运算的效果完全相同,但是这样做的前提就是Length必须是2的幂。这也就是为什么HashMap会定义tableSizeFor方法返回容量的2次幂值。

静态内部类

// Node实现了Map.Entry<K,V>接口,也就是说Node实际上就是我们前面提到的Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//保存当前Entry key的hash值,由于后面会多次使用该Hash值,避免重复计算
final K key;
V 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;
} public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; } public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
} public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
} public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}

​ 看完构造函数和类的常量值以及Entry的结构后,我们就以HashMap中put方法入手,看看HashMap中的底层原理。

put方法源码

	public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
} /**
* 计算key的hash值,该hash算法调用了Obejct的hashcode
* 返回的是key.hashCode()&(key.hashCode()>>>16),其中>>>代表无符号右移
**/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
} final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//将Map内部的table数组赋给局部变量tab,如果table为空或者大小为0,则使用resize进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; /**
* n-1&hash的效果就是 hash%n (因为HashMap中封装的数组的长度都是2的幂(默认16))
* 如果数组对应位置没有元素(没有发生Hash冲突),则新建一个Node元素,放入该数组位置
*/
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); /**
* 发生Hash冲突后的处理
*/
else {
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 {
//如果此时解决Hash冲突的数据结构为链表,则遍历到链表尾部
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//向链表中添加新元素
p.next = newNode(hash, key, value, null);
//如果新元素未加入之前,链表长度大于等于7了则需要将链表转换为红黑树了,换句话说加入新元素后链表长度大于等于8了,就转成红黑树。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);//将链表转换为红黑树
//跳出循环
break;
}
//判断key是否相等
//这里的条件判断显示出HashMap允许一个key==null的键值对存储
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果找到了一个相同的key,则根据onlyIfAbsent判断是否需要替换旧的value。
//onlyIfAbsent为true时代表不替换原先元素。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
} //被修改的次数,fast-fail机制
++modCount; //如果HashMap中存储的节点数量是否到达了扩容的阈值
if (++size > threshold)
//进行扩容
resize();
afterNodeInsertion(evict);
return null;
}

resize方法

/**
* 初始化Map中的Node数组,如果已经初始化则进行扩容操作
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//获取数组的原始大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取原始阈值
int oldThr = thresholds;
//用于记录新容量和新阈值
int newCap, newThr = 0;
//如果原始容量大于0,则代表当前Map已经初始化过了,则应该进行扩容操作
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//将新的容量设为原先的两倍(oldCap<<1)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//将阈值也设为原先的两倍
newThr = oldThr << 1; // double threshold
}
// 初始化数组走这里
// 如果构造函数定义了数组初始容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr; // 如果构造函数没有定义初始容量
else { // zero initial threshold signifies using defaults
//初始化数组容量(16)
newCap = DEFAULT_INITIAL_CAPACITY;
//初始化阈值(16*0.75 = 12)
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 构造函数中定义了初始容量 在这里计算阈值
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;
if (oldTab != null) {
//遍历旧数组,将就数组中的元素,重新散列到新数组中去
for (int j = 0; j < oldCap; ++j) {
//e表示数组上的节点
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;
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) {
//将尾节点的next指针置为空
loTail.next = null;
//将链接的不移动链表放到原索引位置
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
//将链接的移动链表放到新的索引位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

从61行开始代码进入了链表元素的迁移工作,loHeadloTail两个节点分别记录不需要移动的链表的头部和尾部,hiHeadhiTail分别记录需要移动的链表头部和尾部。

假设在扩容的时候某个数组下有这样一个链表 :

其中,假设天蓝色部分的不需要挪动,红色部分的需要挪动

第一步 : 建立loHead loTail hiHead hiTail四个节点

第二步 :

第三步 :

…第N步:

总结

下图是美团技术团队的put函数的流程总结

参考文章

漫画:什么是HashMap

HashMap源码分析——put和get(一)

美团HashMap技术博客

红黑联盟,Java类集框架之HashMap(JDK1.8)源码剖析,2015

CSDN博客频道, 教你初步了解红黑树,2010

深入解析HashMap源码的更多相关文章

  1. JDK源码解析---HashMap源码解析

    HashMap简介 HashMap是基于哈希表实现的,每一个元素是一个key-value对,其内部通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长. HashMap是非线程安全的,只是 ...

  2. 详解HashMap源码解析(上)

    jdk版本:1.8 数据结构: HashMap的底层主要基于数组+链表/红黑树实现,数组优点就是查询块,HashMap通过计算hash码获取到数组的下标来查询数据.同样也可以通过hash码得到数组下标 ...

  3. HashMap 源码解析

    HashMap简介: HashMap在日常的开发中应用的非常之广泛,它是基于Hash表,实现了Map接口,以键值对(key-value)形式进行数据存储,HashMap在数据结构上使用的是数组+链表. ...

  4. 【转】Java HashMap 源码解析(好文章)

    ­ .fluid-width-video-wrapper { width: 100%; position: relative; padding: 0; } .fluid-width-video-wra ...

  5. 源码解析之HashMap源码

    关于HashMap的源码分析,网上已经有很多写的非常好的文章了,虽然多是基于java1.8版本以下的.Java1.8版本的HashMap源码做了些改进,理解起来更复杂点,但也不脱离其桶+链表或树的重心 ...

  6. 【Java深入研究】9、HashMap源码解析(jdk 1.8)

    一.HashMap概述 HashMap是常用的Java集合之一,是基于哈希表的Map接口的实现.与HashTable主要区别为不支持同步和允许null作为key和value.由于HashMap不是线程 ...

  7. HashMap源码阅读与解析

    目录结构 导入语 HashMap构造方法 put()方法解析 addEntry()方法解析 get()方法解析 remove()解析 HashMap如何进行遍历 一.导入语 HashMap是我们最常见 ...

  8. HashMap源码解析 非原创

    Stack过时的类,使用Deque重新实现. HashCode和equals的关系 HashCode为hash码,用于散列数组中的存储时HashMap进行散列映射. equals方法适用于比较两个对象 ...

  9. [java源码解析]对HashMap源码的分析(二)

    上文我们讲了HashMap那骚骚的逻辑结构,这一篇我们来吹吹它的实现思想,也就是算法层面.有兴趣看下或者回顾上一篇HashMap逻辑层面的,可以看下HashMap源码解析(一).使用了哈希表得“拉链法 ...

  10. 一、基础篇--1.2Java集合-HashMap源码解析

    https://www.cnblogs.com/chengxiao/p/6059914.html  散列表 哈希表是根据关键码值而直接进行访问的数据结构.也就是说,它能通过把关键码值映射到表中的一个位 ...

随机推荐

  1. 关于集群节点timeline不一致的处理方式

    关于集群节点 timeline 不一致的处理方式 本文出处:https://www.modb.pro/db/400223 在 PostgreSQL/MogDB/openGauss 数据库日常维护过程中 ...

  2. mask2former出来的灰度图转切割轮廓后的二值图

    切割后的灰度图 转成二值图代码如下 点击查看代码 # This is a sample Python script. import cv2 import numpy as np # Press Shi ...

  3. jenkins 持续集成和交付——开篇(一)

    前言 因为以前就很想看下jenkins了,平时工作中也使用,主要是写脚本,但是jenkins 主要还是说运维部门来搞定的,因为公司安全部门认为程序员不应该去接触运维的东西,但是上次面试问了下,准备把这 ...

  4. 重启React Native老项目的奇幻之旅:填坑实录与解决方案分享

    这两天为了重启五年前基于 React Native(版本 0.59.9)开发的老项目,经过各种填坑查询等操作,最终把它成功地运行起来了. 在这篇文章中,我将详述那些遭遇的挑战以及对应的解决方案,以期为 ...

  5. 对于dubbo和zookeeper的浅见

    在服务器集群环境中,阿里推出的dubbo框架一直是让人仰望的存在,可如今想想,也没啥. dubbo其实就是一个调用工具,他的服务调度也就是知名的几个负载均衡算法,服务监控其实也就是有一个定时任务在定期 ...

  6. javascript:eval()的用法

    eval() 是 JavaScript 中的一个全局函数,它可以计算或执行参数.如果参数是表达式,则 eval() 计算表达式:如果参数是一个或多个 JavaScript 语句,则 eval() 执行 ...

  7. CentOS7.9 systemctl

    目录 命令格式 语法 加载配置文件 关机和开机 unit 文件存放位置 unit 格式说明 service unit file 文件构成部分 unit 段的常用选项 service 段的常用选项 in ...

  8. nethttp和gin 路由

    net/http 路由注册 func test1() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Requ ...

  9. Mac搭建appium环境及python运行代码示例

    Appium主要是通过调用安卓提供的接口来执行命令的,所以需要安装Java和安卓SDK. 1.安装Appium服务端 appium的服务端是基于node的,直接使用npm(node包管理器)安装即可, ...

  10. mysql8在Win10下安装教程

    一.准备工作 下载mysql8安装包,下载URL地址:https://mirrors.tuna.tsinghua.edu.cn/mysql/downloads/MySQL-8.0/ 二.管理员权限执行 ...