存储结构

JDK1.8前是数组+链表,JDK1.8之后是数组+链表+红黑树。本文分析基于JDK1.8源代码。

HashMap的基础结构是Node ,它存着hash、键值对,Node类型的指针next。

主干是桶数组,链表bin用于解决hash冲突,当链表的Node超过阈值(8),执行树化操作,将该链表改造成红黑树。

图片来源:Java核心技术36讲

初始化

HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity (容量)和loadFactor(负载因子)这两个参数,

会使用默认值 ,initialCapacity默认为16,loadFactory默认为0.75。

基于lazy-load原则,主干数组table的内存空间分配不在初始化中,而是在put中。

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);
}

put

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果数组为空,resize为数组分配存储空间
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//插入位置未被占用,直接创建节点。p是链表头节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//插入位置已存在数据,则选择覆盖或插入
else {
Node<K,V> e; K k;
//与头结点p相等
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相等的节点
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;
}
} if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败,抛出异常
//插入后,如果元素个数超过size门限,则扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

null处理

HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,

只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。

确定数组下标

//这是一个神奇的函数,用了异或,移位等运算来保证最终获取的存储位置尽量分布均匀
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要做异或运算?

主要目的是使hash结果平均化,因为有些数据计算出的哈希值差异主要在高位,而HashMap的哈希寻址是忽略容量以上高位的(取模),这样就可以避免哈希碰撞。

插入

//未树化,沿着链表查找是否有跟要插入的key相等的节点
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;
}

resize

resize有两个职责:

  • 初始化存储数组table
  • 容量不足时扩容

几个重要字段

//实际存储的key-value键值对的个数
transient int size;
//size门限
int threshold;
//负载因子,代表了table的填充度有多少,默认是0.75
final float loadFactor;
//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;
final Node<K,V>[] resize() {
//旧table数组的镜像
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//容量
int oldThr = threshold;//size门限
int newCap, newThr = 0;
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
}
//新数组的容量调整为旧数组的size门限
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
//size门限值 = 负载因子 * 容量
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;
//一般情况下,容量*2,负载因子不变,则size门限值*2
//将旧的数据移到新的数组
if (oldTab != null) {
...略
}
return newTab;
}

扩容可以归纳为:

  • 一般情况下,门限值 = 负载因子 * 容量
  • 门限值以倍数调整 newThr = oldThr << 1
  • 扩容后,要把旧数组的元素重新放入新数组

容量初始化

由于频繁扩容影响效率,所以初始化HashMap时要选择好初始容量,要大于预估元素数量/负载因子,且为2的幂数。

负载因子

负载因子小于1,目的是减少哈希碰撞,默认值0.75一般不需要修改。

树化

fnal void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//树化改造逻辑
}
}

当binCount(链表中节点个数)大于TREEIFY_THRESHOLD时,执行树化逻辑。

如果容量小于MIN_TREEIFY_CAPACITY,只会进行简单的resize。如果容量大于MIN_TREEIFY_CAPACITY ,则会进行树化改造。

get

通过key值返回对应value,如果key为null,直接去table[0]处检索。

key(hashcode)-->hash-->取模,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。

    final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
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);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

为什么equals和hashCode要同时重写?

equals()未重写:equals()继承自Object,未重写时其作用与==相同,只判断比较对象存储的值是否相等,当比较对象是引用时,若引用地址相同则返回true,否则,即使两个对象存储的内容是一样的(逻辑上是相等的),依然返回false。

重写后:通过自定义,使某些值逻辑上相等也会返回true,只有引用地址不同且存储内容不同时,才返回false。

hashcode()重写前:Object 对象的 hashCode() 方法会根据不同的对象生成不同的哈希值,默认情况下为了确保这个哈希值的唯一性,是通过将该对象的内部地址转换成一个整数来实现的。

重写后:hashcode 就不再是默认的对象内部地址了,而是自己定义的一个值,保证逻辑上相等。

使用hashcode的目的:相比equlas,它是一种粗粒度的比较,且速度较快。用于初步筛选,当hashcode不同时,其存储内容一定不同,就不需要用equals比较了。

hashcode与equals的基本约定:

  • equals相等,则hascode一定相等
  • 两者必须同时重写

两者同时重写,并不准确,应该说重写了equlas就一定要重写hascode,否则会出问题。参考https://www.cnblogs.com/skywang12345/p/3324958.html

这是为了保证,当equals返回true时,hashcode一定相同。当hashcode相同时,equals不一定返回true。

如果不同时重写

下面的例子重写了equals,但没重写hashcode。

import java.util.HashMap;
public class Test {
private static class Person{
int id;
String name; public Person(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()){
return false;
}
Person person = (Person) o;
//两个对象是否等值,通过id来确定
return this.id == person.id;
}
}
public static void main(String []args){
HashMap<Person,String> map = new HashMap<Person, String>();
Person p1 = new Person(1,"张三");
Person p2 = new Person(1,"张三");
map.put(p1,"一班");
//get取出,从逻辑上讲应该能输出“一班”
System.out.println("结果:"+map.get(p2));
}
}

上述代码返回null。对于重写的equals,p1 和 p2 是相等的,但因为没有重写hashcode,导致get时出现问题。再看一下get的代码,需要hashcode相同且key逻辑上相同。本例中虽然key p1 和 p2的equals返回true,但由于hashcode未重写,导致get失败。

if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))

为何HashMap的数组长度一定是2的次幂?

获取数组下标要对h取模

n = (tab = resize()).length;
tab[i = (n - 1) & hash]

h & (length-1)等价于 h % lenght,但是位运算操作比取模运算代价小。

如令 x = 1<<4,即 x 为 2 的 4 次方



y 与 x-1 做与运算,其作用是将y的前4位清零,结果与 y 对 x 取模相同





判断是否存在相同key的节点

//判断是否已经存在
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))

上文已提到,hash是hashcode(key)后经过一些列移位操作的结果,如果两个Entry的hash相同且key的equals()返回true(逻辑上相等),则用新的覆盖旧的。

线程安全

hashmap是非线程安全的,jdk1.7版本的hashmap在多线程并发扩容时,有可能会形成循环链表,再次插入链表会陷入死循环。同时,jdk1.8版本中,会因为其他原因陷入死循环,因为hashmap本来就不是卖你想多线程的,如有需要还是使用ConcurrentHashMap

参考

https://zhuanlan.zhihu.com/p/21673805

https://www.cnblogs.com/chengxiao/p/6059914.html

《Java核心技术36讲》 杨晓峰

深入理解Java容器——HashMap的更多相关文章

  1. 理解java容器底层原理--手动实现HashMap

    HashMap结构 HashMap的底层是数组+链表,百度百科找了张图: 先写个链表节点的类 package com.xzlf.collection2; public class Node { int ...

  2. 理解java容器底层原理--手动实现HashSet

    HashSet的底层其实就是HashMap,换句话说HashSet就是简化版的HashMap. 直接上代码: package com.xzlf.collection2; import java.uti ...

  3. java容器HashMap原理

    1.为什么需要HashMap 前面我们说了ArrayList和LinkedList,它们对容器内的对象都能实现增.删.改.查.遍历等操作, 并且对应不同的情况,我们可以选择不同的List,用以提高效率 ...

  4. 理解java容器:iterator与collection,容器的起源

    关于容器 iterator与collection:容器的起源 iterator的简要介绍 iterable<T> iterator<T> 关于remove方法 Collecti ...

  5. 理解java容器底层原理--手动实现LinkedList

    Node java 中的 LIinkedList 的数据结构是链表,而链表中每一个元素是节点. 我们先定义一下节点: package com.xzlf.collection; public class ...

  6. 理解java容器底层原理--手动实现ArrayList

    为了照顾初学者,我分几分版本发出来 版本一:基础版本 实现对象创建.元素添加.重新toString() 方法 package com.xzlf.collection; /** * 自定义一个Array ...

  7. 深入理解Java中的HashMap的实现原理

    HashMap继承自抽象类AbstractMap,抽象类AbstractMap实现了Map接口.关系图例如以下所看到的: Java中的Map<key, value>接口同意我们将一个对象作 ...

  8. Java 容器 & 泛型:五、HashMap 和 TreeMap的自白

    Writer:BYSocket(泥沙砖瓦浆木匠) 微博:BYSocket 豆瓣:BYSocket Java 容器的文章这次应该是最后一篇了:Java 容器 系列. 今天泥瓦匠聊下 Maps. 一.Ma ...

  9. java容器的理解(collection)

    容器类(Conllection)对于一个开发者来说是最强大的工具之一,可以大幅提高编程能力.容器是一个将多个元素组合到一个单元的对象,是代表一组对象的对象,容器中的对象成为它的元素. 容器适用于处理各 ...

随机推荐

  1. 记一次 .NET 某三甲医院HIS系统 内存暴涨分析

    一:背景 1. 讲故事 前几天有位朋友加wx说他的程序遭遇了内存暴涨,求助如何分析? 和这位朋友聊下来,这个dump也是取自一个HIS系统,如朋友所说我这真的是和医院杠上了,这样也好,给自己攒点资源, ...

  2. MySQL next-key lock 加锁范围是什么?

    前言 某天,突然被问到 MySQL 的 next-key lock,我瞬间的反应就是: 这都是啥啥啥??? 这一个截图我啥也看不出来呀? 仔细一看,好像似曾相识,这不是<MySQL 45 讲&g ...

  3. (最新)VS2015安装以及卸载过程——踩坑实录

    前言 Visual Studio (简称VS)是微软公司旗下最重要的软件集成开发工具产品.是目前最流行的 Windows 平台应用程序开发环境,也是无数人学习编程的入门软件之一.Visual Stud ...

  4. Keil MDK5 安装教程(附安装包百度云)

    关注微信公众号"龙行单片机",后台回复"安装包"获取最新安装包百度云链接. 1.MDK5.11a 安装 双击 mdk511a.exe,进行安装.这里我们将其安装 ...

  5. NVIDIA GPU自动调度神经网络

    NVIDIA GPU自动调度神经网络 对特定设备和工作负载进行自动调整对于获得最佳性能至关重要.这是有关如何使用自动调度器为NVIDIA GPU调整整个神经网络. 为了自动调整神经网络,将网络划分为小 ...

  6. L3级自动驾驶

    L3级自动驾驶 2020年开年 3月9日,工信部在其官网公示了<汽车驾驶自动化分级>推荐性国家标准报批稿,并拟于2021年1月1日开始实施. 按照中国自身标准制定的自动驾驶分级标准,在千呼 ...

  7. MindArmour差分隐私

    MindArmour差分隐私 总体设计 MindArmour的Differential-Privacy模块,实现了差分隐私训练的能力.模型的训练主要由构建训练数据集.计算损失.计算梯度以及更新模型参数 ...

  8. 英伟达TRTTorch

    英伟达TRTTorch PyTorch JIT的提前(AOT)编译Ahead of Time (AOT) compiling for PyTorch JIT TRTorch是PyTorch / Tor ...

  9. halcon——缺陷检测常用方法总结(频域空间域结合)

    摘要 缺陷检测是视觉需求中难度最大一类需求,主要是其稳定性和精度的保证.首先常见缺陷:凹凸.污点瑕疵.划痕.裂缝.探伤等. 缺陷检测算法不同于尺寸.二维码.OCR等算法.后者应用场景比较单一,基本都是 ...

  10. Collection&Map

    1.Collection 添加元素 boolean add(E e) 删除元素 boolean remove(E e) 元素个数 int size() 清空 void clear() 判空 boole ...