JDK8中HashMap的工作原理剖析

2024-05-15 02:58

本文主要是介绍JDK8中HashMap的工作原理剖析,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在Java语言里,HashMap无疑是使用频率非常高的一个类,了解它的内部实现将有助于更好的使用它。

在jdk8中的HashMap是由三种数据结构组成:数组 + ( 链表 or 红黑树 )

图示如下:

image

而在jdk8之前还只是数组+链表两种数据结构,在这里简单提下数组和链表的区别:

数组

优点:物理地址连续+按下标随机访问效率高O(1)

缺点:插入,删除效率低,

链表

优点:存储地址不连续,可灵活的扩展自己的长度,插入,删除效率高

缺点:访问效率低O(n)

而哈希表(Hash类数据结构)正是结合了两者的优点,而衍生出来的的一种高效的数据存储结构,本质上是采用空间换时间的方式,提高了读写的效率。

HashMap的继承结构如下:

public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable

这里我们能发现HashMap中K,V都是泛型的,所以可以支持任何类型作为key或者value,但实际开发中用的最多的都是以String类型的字符串作为key。

在这里泛型Key的hashCode和equals方法非常重要,因为它们会影响HashMap存储的数据分布和读写是否正确

上面说过HashMap可以看成是一个大的数组,然后每个数组元素的类型是Node类型,源码里定义如下:

transient Node<K,V>[] table;

注意Node类还有两个子类:TreeNode和Entry

TreeNode<K,V> extends Entry<K,V> extends Node<K,V>

上图中的链表就是Node类,而红黑树正是TreeNode类

接着我们来看下HashMap的一些成员变量:

//默认table数组buckets的数目,必须是2的平方,默认值是16static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认table最大的长度约10亿多(1073741824)最大的buckets数目static final int MAXIMUM_CAPACITY = 1 << 30;//默认负载因子static final float DEFAULT_LOAD_FACTOR = 0.75f;//当单个链表的个数超过8个节点就转化为红黑树存储static final int TREEIFY_THRESHOLD = 8;//如果原来是红黑树,后来被删除一些节点后,只剩小于等于6个,会被重新转成链表存储static final int UNTREEIFY_THRESHOLD = 6;//当数组的长度(注意不是map的size而是table.length)大于64的时候,//会对单个桶里大于8的链表进行树化static final int MIN_TREEIFY_CAPACITY = 64;//用来实现遍历map的set,依次遍历table中所有桶中的node或者treeNodetransient Set<Map.Entry<K,V>> entrySet;//当前存储的实际数据量=map.size而不是table.lengthtransient int size;//修改次数,用来判断是否该map同时被多个线程操作,//多线程环境下会抛出异常ConcurrentModificationExceptiontransient int modCount;//当前数组中的阈值 table.length * loadFactor,如果超//过这个阈值,就要进行扩容 (resize)int threshold;//负载因子final float loadFactor;

成员变量主要两部分组成,一部分是处理化时候的常量,一部分是变量会在运行时改变,这里还需要注意的是HashMap本身不是线程安全的,所以尽量避免在多线程环境下使用,如果非要使用,就用线程安全的Map,如下:

` (1)Map m = Collections.synchronizedMap(new HashMap(...)) (2)ConcurrentHashMap map=new ConcurrentHashMap();

此外,HashMap还有几个构造方法:

`   //1`   public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; }//2public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);}//3public HashMap(Map<? extends K, ? extends V> m) {this.loadFactor = DEFAULT_LOAD_FACTOR;putMapEntries(m, false);}//4public 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);}

(1)第一个是默认的构造方法,我们看到只对影响扩容负载因子做了初始化,默认是0.75

(2)第二个和第四个其实是一个方法逻辑,可以传入指定的table数组的大小,负载因子用默认的。

(3)第三个可以传入一个Map集合直接赋值给该map,里面用了putMapEntries方法,这个方法可以理解为迭代传入的Map然后将数据赋值给new的Map

(4)第四个是同时指定初始化table数组的大小和负载因子,中间有一些逻辑判断,这里需要提下tableSizeFor这个方法:

源码如下:

/*** Returns a power of two size for the given target capacity.*/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;}

这个方法,保证指定的设置的table数组的长度必须是2的n次方,比如你初始化传入的是5,但是实际运行后你会发现table数组的长度是8,这是因为2的n次方,对于数组的扩容和重新赋值有比较大的好处,所以如果你传入的不是2的n次方,那么经过这个方法出来的值一般都是大于你传入的参数最接近的2的n次方。

下面我们看下HashMap是如何存数据的?

源码中的put方法如下:

public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}

这里我们看到put方法里面调用了hash方法,来得到该key的hashCode,那么我们来看下其内部是如何实现的:

static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}

这里能看到hash方法并不是直接取的hashCode值,而是用hashCode()的高16位异或低16位实现的,这么做可以保证高低bit位都能参与到Hash的计算中,一句话就是为了减少hash冲突的几率。

然后在putVal方法中,实现数据的插入操作,注意数组的下标的计算方式是:

i = (table.length - 1) & hash

等价于使用转化后hash值对数组长度取模

h % table.length

但是位运算比模运算效率更高

在putVal插入数据的方法中,第一次会调用扩容方法,此外插入时还会判断该节点是链表还是红黑树,他们分别对应不同的赋值方法,并且如果单个bucket的节点数量大于8,还会将链表转化为红黑树,在插入完成后还会继续判断下一次是否需要扩容。

这里重点介绍下扩容方法:

final Node<K,V>[] resize() {Node<K,V>[] oldTab = table;//上一次table的长度赋值int oldCap = (oldTab == null) ? 0 : oldTab.length;//阈值等于当前成员变量的值int oldThr = threshold;int newCap, newThr = 0;if (oldCap > 0) {if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}//否则就将cap和thr进行*2扩容  else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; }else if (oldThr > 0) //如果旧table没有初始化,就把阈值作为table的长度newCap = oldThr;else { //第一次没有传入任何构造函数时,table.length=16newCap = DEFAULT_INITIAL_CAPACITY;//阈值=16*0.75=12newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}if (newThr == 0) {//如果新的阈值等于0,那么会根据新的table.length和负载因子,重新计算//并判断是否超过最大值,如果超过就取最大值float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);}//将新计算的阈值,重新赋值给成员变量threshold = newThr;@SuppressWarnings({"rawtypes","unchecked"})//用新的table的大小,构造一个数组Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;//赋值给成员变量if (oldTab != null) {//将旧table中的数据给重新计算到新table数组中for (int j = 0; j < oldCap; ++j) {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位置,要么不变要么是table[j + oldCap]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;elseloTail.next = e;loTail = e;}else {if (hiTail == null)hiHead = e;elsehiTail.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;}}}}}//返回扩容后的新table数组return newTab;}

注意,扩容后的table数组的长度,一定是2的n次方。

最后我们来看下get方法:

public V get(Object key) {Node<K,V> e;return (e = getNode(hash(key), key)) == null ? null : e.value;}

可以看到get方法也同样调用了hash方法获取hashCode,接着调用了getNode方法:

final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;//判断table不等于null,table.length必须大于0//然后同样通过位运算得到数组访问的下标接着从数组中取的第一个元素if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {//如果hash值相等,key相等,并且key不等于null,检索的key等于第一个元素的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);do {//如果不是,就遍历整个列表,直到找到符合条件的节点if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}//如果最终还没有找到就返回nullreturn null;}

HashMap读取的效率:

(1)如果在第一个节点命中,那就是O(1)

(2)如果在红黑树中查询,那就是O(logn)

(3)如果是在链表中查询,那就是O(n)

在这里,我们就会发现红黑树的结构的引入,其实是为了提升检索效率。

注意上面查询过程中还有一个小细节,就是判断key是否null,因为在HashMap中其key和value 都可以允许为null值,有时候你get一个null值出来,可能会有两种情况,那么value就是null, 要么是因为你的key传入的是null,而刚好这个null这个key,对应的value也是null。

演示的代码如下:

HashMap<String, Integer> map=new HashMap<String, Integer>();map.put(null, null);//k和v都允许为nullmap.put("5", null);//判断是否存在null的keySystem.out.println(map.containsKey(null));System.out.println(map.get(null));//nullSystem.out.println(map.get("5"));//null

这里可以通过containsKey方法判断是否有null的key

总结:

本文对JDK8中的HashMap的工作原理做了一个剖析,并介绍了一些核心的方法和注意事项,通过了解它的内部运行机制,以帮助我们更加合理的在实际开发中使用。

参考文章:

https://www.jianshu.com/p/aa017a3ddc40

https://www.geeksforgeeks.org/internal-working-of-hashmap-java/

https://www.cdn.geeksforgeeks.org/java-util-hashmap-in-java/

https://www.javacodegeeks.com/2017/11/java-hashmap-detail-explanation.html

http://blog.csdn.net/zxt0601/article/details/77413921

http://lingmeng.github.io/archives/

这篇关于JDK8中HashMap的工作原理剖析的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/990608

相关文章

找完工作该补充的东西

首先: 锻炼身体,包括乒乓球,羽毛球,都必须练习,学习,锻炼身体等是一个很重要的与人交际沟通的方式; 打牌,娱乐:会玩是一个人很重要的交际沟通的法宝; 摄影:这个是一个兴趣爱好,也是提高自己的审美,生活品质,当然也是与人沟通的重要途径; 做饭:这个的话就是对自己,对朋友非常有益的一件事情;

工作流Activiti初体验—流程撤回【二】

已经玩工作流了,打算还是研究一下撤回的功能。但是流程图里面并不带撤回的组件,所以需要自己动态改造一下,还是延续上一个流程继续试验撤回功能。《工作流Activiti初体验【一】》 完整流程图 我们研究一下分发任务撤回到发起任务,其他环节的撤回类似 撤回的原理大概如下: 将分发任务后面的方向清空,把发起任务拼接到原来的判断网关,然后结束分发任务,这样流程就到发起任务了 此时的流程如上图,

工作流Activiti初体验【一】

在这里记录一下我的Activiti历程:(以下示例不涉及真实业务,所有逻辑均建立在学习的基础上) bpmn图 发起任务我设置了一个权限组user1,只要是这个权限的用户都可以发起任务 分发任务我设置了一个用户组,用户组中每个用户都可以处理这步流程,只要有一个人处理这步任务,分发的流程就算结束了 分发任务这一环节还有个判断,允许任务下发和不允许任务下发 任务分发完成则来到子流程,每个被分

数据库原理与安全复习笔记(未完待续)

1 概念 产生与发展:人工管理阶段 → \to → 文件系统阶段 → \to → 数据库系统阶段。 数据库系统特点:数据的管理者(DBMS);数据结构化;数据共享性高,冗余度低,易于扩充;数据独立性高。DBMS 对数据的控制功能:数据的安全性保护;数据的完整性检查;并发控制;数据库恢复。 数据库技术研究领域:数据库管理系统软件的研发;数据库设计;数据库理论。数据模型要素 数据结构:描述数据库

计算机组成原理——RECORD

第一章 概论 1.固件  将部分操作系统固化——即把软件永恒存于只读存储器中。 2.多级层次结构的计算机系统 3.冯*诺依曼计算机的特点 4.现代计算机的组成:CPU、I/O设备、主存储器(MM) 5.细化的计算机组成框图 6.指令操作的三个阶段:取指、分析、执行 第二章 计算机的发展 1.第一台由电子管组成的电子数字积分和计算机(ENIAC) 第三章 系统总线

GaussDB关键技术原理:高性能(二)

GaussDB关键技术原理:高性能(一)从数据库性能优化系统概述对GaussDB的高性能技术进行了解读,本篇将从查询处理综述方面继续分享GaussDB的高性能技术的精彩内容。 2 查询处理综述 内容概要:本章节介绍查询端到端处理的执行流程,首先让读者对查询在数据库内部如何执行有一个初步的认识,充分理解查询处理各阶段主要瓶颈点以及对应的解决方案,本章以GaussDB为例讲解查询执行的几个主要阶段

【计算机组成原理】部分题目汇总

计算机组成原理 部分题目汇总 一. 简答题 RISC和CICS 简要说明,比较异同 RISC(精简指令集)注重简单快速的指令执行,使用少量通用寄存器,固定长度指令,优化硬件性能,依赖软件(如编译器)来提升效率。 CISC(复杂指令集)包含多样复杂的指令,能一条指令完成多步操作,采用变长指令,减少指令数但可能增加执行时间,倾向于硬件直接支持复杂功能减轻软件负担。 两者均追求高性能,但RISC

MySQL数据库锁的实现原理

MySQL数据库的锁实现原理主要涉及到如何确保在多用户并发访问数据库时,保证数据的完整性和一致性。以下是MySQL数据库锁实现原理的详细解释: 锁的基本概念和目的 锁的概念:在数据库中,锁是用于管理对公共资源的并发控制的机制。当多个用户或事务试图同时访问或修改同一数据时,数据库系统通过加锁来确保数据的一致性和完整性。 锁的目的:解决多用户环境下保证数据库完整性和一致性的问题。在并发的情况下,会

线性回归(Linear Regression)原理详解及Python代码示例

一、线性回归原理详解         线性回归是一种基本的统计方法,用于预测因变量(目标变量)与一个或多个自变量(特征变量)之间的线性关系。线性回归模型通过拟合一条直线(在多变量情况下是一条超平面)来最小化预测值与真实值之间的误差。 1. 线性回归模型         对于单变量线性回归,模型的表达式为:         其中: y是目标变量。x是特征变量。β0是截距项(偏置)。β1

标准分幅下的图幅号转换成经纬度坐标【原理+源代码】

最近要批量的把标准分幅下的图幅号转换成经纬度坐标,所以这两天写了个程序来搞定这件事情。 先举个例子说明一下这个程序的作用。 例如:计算出图幅号I50G021040的经纬度范围,即最大经度、最小经度、最大纬度、最小纬度。 运用我编写的这个程序,可以直接算出来,这个图幅号的经纬度范围,最大经度为115.3125°,最小经度为115.25°,最大纬度为31.167°,最小纬度为31.125°。