HashMap源码实现分析
HashMap源码实现分析
一、前言
HashMap 顾名思义,就是用hash表的原理实现的Map接口容器对象,那什么又是hash表呢。
我们对数组都很熟悉,数组是一个占用连续内存的数据结构,学过C的朋友对这一点影响肯定更为深刻。既然是一段连续的内存,数组的特点就显而易见了,一旦你知道要查第几个数据,时间复杂度就是O(1),但是对于插入操作就很困难;还有一种数据结构你也一定很熟悉,那就是链表,链表由一组指向(单向或者双向)的节点连接的数据结构,它的特点是内存不连续,查找困难,但是插入删除都很容易。
那有没有一种查找容易,插入删除查找都容易的数据结构呢, 没错,它就是hash表。
本篇,我们就来讨论:
- HashMap的数据结构实现方式
- HashMap是怎么做到为get、put操作提供稳定的时间复杂度的
- HashMap什么时候从单节点转成链表又是什么时候从链表转成红黑树
- HashMap初始化时为什么要给自定义的初始容量。
- HashMap如何保证容量始终是2的幂
- HashMap为何要保证容量始终是2的幂
- HashMap的hash值如何计算
- HashMap为什么是线程不安全的
要了解HashMap 最好的方式就是看源码,本篇内容基于Jdk1.8HashMap源码。
二、HashMap的基本要素
磨刀不误砍柴功,想了解HashMap的原理,必然绕不过HashMap源码中的以下几个变量:
- DEFAULT_INITIAL_CAPACITY: 初始容量 1<<4也就是16
- MAXIMUM_CAPACITY:最大容量 1<<30。
- DEFAULT_LOAD_FACTOR:负载因子,默认是0.75。什么意思呢,比如说你定义了一个初始容量为16的HashMap,当你不断向里面添加元素后,最多到初始容量的0.75,HashMap就会触发扩容操作。
- threshold:下一个触发扩容操作的阈值,threshold = CAPACITY * LOAD_FACTOR。
- TREEIFY_THRESHOLD:链表转红黑树阈值,默认为8,超过8就会执行链表转红黑树方法,但是注意转红黑树方法中会判断当前size是否大于64,只有大于64才转红黑树,否则执行resize()操作。
- UNTREEIFY_THRESHOLD: 红黑树转链表阈值,默认为6,顾名思义,红黑树节点小于6就会转成链表。
- Node<K, V> implements Map.Entry<K, V> HashMap存放数据的基本单位,里面存有hash值、key、value、next。
- Node<K, V>[] table:存放Node节点的数组,HashMap最底层数组,数组元素可以为单节点Node、多节点链表、多节点红黑树。
以上内容,有个印象就好,不必每个都记得。但这些概念对理解HashMap至关重要。
三、正文
3.1 HashMap 数据结构
HashMap的数据结构很简单,它是一个Node类型的数组,每个元素可以为单节点、多节点链表、多节点红黑树。关键的问题是,这么简单的结构怎么实现的put、get都很快? 什么时候从单节点转成链表又是什么时候从链表转成红黑树?
3.1.1 HashMap如何实现put、get操作时间复杂度为O(1)~O(n)?
我们知道,查找一个数组的元素,当我们不知道index的时候,复杂度是很高的,但是当我们知道index的时候,这个复杂度就是O(1)级别的。HashMap使用的就是这个原理。
对于get操作,首先根据key计算出hash值,而这个hash值执行操作(n - 1) & hash后就是它所在的index,在最好的情况下,该index恰好只有一个节点且hash值和key的hash值相同,那么时间复杂度就是O(1),当该节点为链表或者红黑树时,时间复杂度会上升,但是由于HashMap的优化(链表长度、红黑树长度相对于HashMap容量不会过长,过长会触发resize操作),所以最坏的情况也就是O(n),可能还会小于这个值。
对于put操作,我们知道,数组插入元素的成本是高昂的,HashMap巧妙的使用链表和红黑树代替了数组插入元素需要移动后续元素的消耗。这样在最好的情况下,插入一个元素,该index位置恰好没有元素的话,时间复杂度就是O(1),当该位置有元素且为链表或者红黑树的情况下,时间复杂度会上升,但是最坏的情况下也就是O(n)。
3.1.2 HashMap什么时候从单节点转成链表又是什么时候从链表转成红黑树?
单节点转链表很简单,当根据新加入的值计算出来的index处有元素时,若元素为单节点,则从节点转为链表。
链表转红黑树有两个条件:
链表长度大于TREEIFY_THRESHOLD,默认阈值是8
HashMap长度大于64
当同时满足这两个条件,那么就会触发链表转红黑树的操作。
3.2 HashMap初始化时为什么要给自定义的初始容量?
为啥前辈们都要求定义一个HashMap的时候一定要使用构造函数HashMap(int initialCapacity)指定初始容量呢?
在阿里的《Java开发手册》中是这样说明的:
- 【推荐】集合初始化时,指定集合初始值大小。
说明:HashMap 使用 HashMap(int initialCapacity) 初始化,
正例:initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loader
factor)默认为 0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。
反例:HashMap 需要放置 1024 个元素,由于没有设置容量初始大小,随着元素不断增加,容
量 7 次被迫扩大,resize 需要重建 hash 表,严重影响性能。
这个问题在HashMap源码中是显而易见的,每次put函数中都会检查当前size是否大于threshold,如果大于就会进行扩容,新容量是原来容量的二倍。那么问题就来了,当你要存大量数据到HashMap中而又不指定初始容量的话,扩容会被一次接一次的触发,非常消耗性能。
而初始容量和负载因子给多少好呢,日常开发中如无必要不建议动负载因子,而是根据要使用的HashMap大小确定初始容量,这也不是说为了避免扩容初始容量给的越大越好, 越大申请的内存就越大,如果你没有这么多数据去存,又会造成hash值过于离散。
3.3 HashMap如何保证容量始终是2的幂
HashMap使用方法tableSizeFor()来保证无论你给值是什么,返回的一定是2的幂:
static final int tableSizeFor(int cap)
{
int n = cap - 1; // 作用:保证当cap为2的幂时,返回原值而不是二倍,如8 返回8 而不是16
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;
}
首先我们来看操作:
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16
假设 n=01000000, n |= n >>> 1后 n=01100000,n |= n >>> 2后n=01111000,n |= n >>> 4;后n=01111111,我们可以发现,上述5步操作可以将一个32位数第一位为1的后面所有位全变为1。这样再执行n + 1操作后,该数就必为2的幂次方了。如01111111+1 = 10000000。
那又为什么要保证一定是2的幂次方呢?不是不行吗?
3.3.1 HashMap为何要保证容量始终是2的幂
说到这个问题不得不说执行put()方法时,是如何根据hash值在table中定位。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
{
......
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
......
可以看到,它使用了一个 (n - 1) & hash的操作,n为当前hashmap的容量,而容量一定为2的幂次方,n-1的二进制低位都为1,举例:16=0000000000010000,15=0000000000001111,这样的处理好处在于,当执行(n - 1) & hash的操作时,元素的位置仅取决于低位而与高位无关(这种无关性随着HashMap容量的增大而减小),这个逻辑优点是,无论你的hash值有多大,我都锁定了你的取值范围小于当前容量,这样做避免了hash值过于离散的情况,而当HashMap扩容时又可以同时增大hash值的取值范围,缺点是增加了hash碰撞的可能性,为了解决这个问题HashMap修改了hash值的计算方法来增加低位的hash复杂度。
3.3.2 HashMap计算hash值
不废话,直接上源码:
static final int hash(Object key)
{
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash方法用 key的hash值异或上key的hash值的高16位,为什么要这样做呢?
首先,h>>>16 的值高16位都为0,这样h^(h>>>16)时,高16位的值不会有任何变化,但是低16位的值混杂了key的高16位的值,从而增加了hash值的复杂度,进一步减少了hash值一样的概率。
3.4 HashMap为什么是线程不安全的
在Jdk1.7中,造成HashMap线程不安全的原因之一是transfer函数,该函数使用头查法在多线程的情况下很容易出现闭环链表从而导致死循环,同时还有数据丢失的问题,Jdk1.8中没有transfer函数而是在resize函数中完成了HashMap扩容或者初始化操作,resize采用尾插法很好的解决了闭环链表的问题,但是依旧避免不了数据覆盖的问题。
在HashMap的put操作中:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
{
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
......
在执行完 if ((tab = table) == null || (n = tab.length) == 0)判断且为true的情况下,会直接进行赋值,但是在多线程的环境下,当两个线程同时完成判断,线程1刚赋值完,线程2再进行赋值,就造成了数据覆盖的问题。
这只是最简单的现象,我们要想线程安全,首先要有多线程安全的处理逻辑,很明显HashMap是没有这样的逻辑的,那么很多为单线程设计的逻辑就很大可能出问题,所以HashMap为什么是线程不安全的?它本身设计就不支持多线程下的操作,所以不该有此问。
如果想要线程安全的使用基于hash表的map,可以使用ConcurrentHashMap,该实现get操作是无锁的,put操作也是分段锁,性能很好。
所以说术业有专攻,每个容器的实现都有它对应的优缺点。我们需要学会的是分析面对的每一种情况,合理的使用不同的容器去解决问题。
HashMap基本的原理和对应实现就说到这里了,更深入的话题如:红黑树插入节点、平衡红黑树、遍历红黑树,可以直接看红黑树对应的原理和实现。
需要源码注释的请戳这里源码解析
公众号:良许Linux
有收获?希望老铁们来个三连击,给更多的人看到这篇文章
HashMap源码实现分析的更多相关文章
- HashMap 源码详细分析(JDK1.8)
一.概述 本篇文章我们来聊聊大家日常开发中常用的一个集合类 - HashMap.HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现.HashMap 允许 null 键和 null 值, ...
- [Java] HashMap 源码简要分析
特性 * 允许null作为key/value. * 不保证按照插入的顺序输出.使用hash构造的映射一般来讲是无序的. * 非线程安全. * 内部原理与Hashtable类似. 源码简要分析 pu ...
- [java源码解析]对HashMap源码的分析(二)
上文我们讲了HashMap那骚骚的逻辑结构,这一篇我们来吹吹它的实现思想,也就是算法层面.有兴趣看下或者回顾上一篇HashMap逻辑层面的,可以看下HashMap源码解析(一).使用了哈希表得“拉链法 ...
- hashmap源码面试分析
HashMap源码问题解析 问: 说一说对hash的理解 答: hash是对任意长度的输入输出为相同长度的输出 问: hash算法的问题 答: hash冲突问题 问: hash冲突是否可以避免 答: ...
- Java集合之HashMap源码实现分析
1.简介 通过上面的一篇随笔我们知道了HashSet的底层是采用Map实现的,那么Map是什么?它的底层又是如何实现的呢?这下我们来分析下源码,看看具体的结构与实现.Map 集合类用于存储元素对(称作 ...
- [java源码解析]对HashMap源码的分析(一)
最近有空的时候研究了下HashMap的源码,平时我用HashMap主要拿来当业务数据整理后的容器,一直觉得它比较灵活和好用, 这样 的便利性跟它的组成结构有很大的关系. 直接开门见山,先简要说明一下H ...
- HashMap源码分析(一)
前言:相信不管在生产过程中还是面试过程中,HashMap出现的几率都非常的大,因此有必要对其源码进行分析,但要注意的是jdk1.8对HashMap进行了大量的优化,因此笔者会根据不同版本对HashMa ...
- LinkedHashMap 源码详细分析(JDK1.8)
1. 概述 LinkedHashMap 继承自 HashMap,在 HashMap 基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题.除此之外,Linke ...
- HashMap源码解读(JDK1.7)
哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出 ...
随机推荐
- Python分支结构与流程控制
Python分支结构与流程控制 分支结构 if 语句 分支结构在任何编程语言中都是至关重要的一环,它最主要的目的是让计算机拥有像人一样的思想,能在不同的情况下做出不同的应对方案,所以if语句不管是在什 ...
- lsomap降维
# -*- coding: utf-8 -*- """ lsomap """ import numpy as np import matpl ...
- Myeclipse启动WebLogic 总是报账号密码无效<Authentication denied: Boot identity not valid
在MyEclipse下配置了Weblogic 11后,每次启动从报错: Critical> 看了下描述,是用户名及密码什么的问题,我想起来,配置Weblogic 的域的时候将密码改成了12345 ...
- Zuul请求超时
最近在弄springcloud的时候发现在发送短信的时候zuul总是报错,错误信息如下 com.netflix.zuul.exception.ZuulException: at org.springf ...
- 一个ACE 架构的 Socket Client
.h /************************************************************** * Filename: TcpClient.h * Copyrig ...
- CSS3+JS实现静态圆形进度条
一.实现原理 首先,我们来一个圆(黑色).接着,再来两个半圆,将黑色的圆遮住.(为了演示,左右两侧颜色不一样)这时候,我们顺时针旋转右侧蓝色的半圆,下面的黑色圆就会暴露出来,比如我们旋转45度(12. ...
- rem和px
做过一段时间的H5页面,但是对于rem与px的换算还是比较模糊,总是引用一段脚本,也没有深究过为什么,就稀里糊涂的用了,遇到一些细微的地方,总是不知道是什么原因导致的,我总是只要能完成效果就行,全然不 ...
- python基础内容扩展复习
目录 一.关于编辑器 二.解释型和编译型 三.数据类型 1 一切皆对象 2 深浅拷贝 3 可变类型和不可变类型 四.闭包函数 一.关于编辑器 python开发:pycharm(收费),vscode(免 ...
- day16 本日作业+周末作业
目录 1.编写计数器功能,要求调用一次在原有的基础上加1 2.周末作业 1.编写计数器功能,要求调用一次在原有的基础上加1 def func(): x=0 def counter(): nonloca ...
- flask 源码专题(三):请求上下文和应用上下文入栈与出栈
1.请求上下文和应用上下文入栈 # 将ctx入栈,但是内部也将应用上下文入栈 ctx.push() def push(self): # 获取到的 top == ctx top = _request_c ...