HashMap是我们经常用的数据结构,采用了key-value的方式来存储数据。在JDK 1.8的版本中也是对其做了优化修改,现在我们来通过在JDK 1.8的环境下的源代码分析一下HashMap的工作原理。及相比于1.7,在1.8中的优化。

文章出自:HashMap的源码分析

HashMap源码分析

概述

HashMap是我们经常用的数据结构,采用了key-value的方式来存储数据。在使用设计的过程中牵涉了许多的数据结构及知识点,数组+链表,扩容,头插尾插,hash函数等。JDK 1.8的版本中也是对其做了优化修改,现在我们来通过在JDK 1.8的环境下的源代码分析一下HashMap的工作原理,及相比于1.7,在1.8中的优化。本文基于JDK 1.8中源代码,比较与JDK 1.7中代码的时候会额外说明。同时,本文的描述从构造函数及节点等基本数据类型开始,然后再通过我们常用的put()remove()等常用函数来逐步介绍了解,碰到的一步步状况作者在设计时都做了什么,为什么是这样设计的,而不是直接将一堆结论罗列出来讲解。

Hashmap结构

首先,我们来看下初始化HashMap的时候的构造函数new HashMap()

    /*** The load factor used when none specified in constructor.*/// 1static final float DEFAULT_LOAD_FACTOR = 0.75f;public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted}

Hashmap的初始化中,在注释1处设置了Hashmap的默认负载因子DEFAULT_LOAD_FACTOR为 0.75f。接下来,我们再来看下Hashmap中的一个基本结构-Hashmap节点。

Hashmap节点

HashMap中有一个静态内部类Node,为Hashmap的节点。我们来看下Node中的结构。

    static class Node<K,V> implements Map.Entry<K,V> {final int 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);}//......}

从该静态类中可以看到,节点有4个属性值。第一个为key值的hash值hash,由key值经过hash函数得来的。第二个为相对应的key值。第三个为Value值。最后一个值为下一个节点Next值,所以可以看出Node其实是一个链表的结构。

了解完hashmap的基础结构之后,我们先来了解一下hashmap的哈希算法及扩容机制,之后再通过hashmap的常用方法来深入介绍。

Hashmap的哈希及索引算法

Hashmap通过散列表函数hash(key)计算key的哈希值。通过hash值来计算Node节点所在数组的索引。来看下是如何计算hash值及如何计算索引的。

    static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}// 1// n = tab.length// i = (n - 1) & hash

计算hash值过程很简单,只有一行代码。取key的hashCode,将hashCode的低16位和高16位进行异或运算。而key的hashCode()方法是由自己重写的。String类型的hashCode在Java中有默认重写方法。所以从这里我们可以知道,作为Hashmap的key值则必须重写hashCode()方法。

同时,hashCode为int类型,4个字节有32位,取低16位与高16位进行异或运算是为了尽可能的让hashCode值的高位和低位参与运算。因为注释1中将数组长度减1和hash值做与运算来确定Node节点所在数组的索引。高低16位进行异或这种做法可以在数组长度n值较小时也能让尽可能多的位值参与运算。同时取位运算而不通过mod模运算来计算余数是为了更高效。

除此之外,将hash值与n-1取与运算,是因为数组的长度都是2的幂,所以n-1都为1。类似于01111...这种形式,1的个数为2的幂数。所以设计hashmap数组的时候故意将数组长度设置为2的幂是为了散列值计算出来的索引更均匀的分布在数组中,减少hash碰撞。

HashMap常用方法

介绍完Hashmap的初始化后,我们来看下Hashmap的常用函数的用法。

put(K key, V value)

我们直接来看下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;if ((tab = table) == null || (n = tab.length) == 0)// 1.1n = (tab = resize()).length;// 1.2if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;// 1.3if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;// 1.4else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);else {// 1.5for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}// 1.6if (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)// 1.7resize();afterNodeInsertion(evict);return null;}

这个函数信息量比较大,做的事情比较多。调用putVal()之前,便先计算key的hash值作为参数传入。然后再来看下注释1.1。默认table是为一个空数组,那么函数会首次调用put()函数的时候会直接调用resize()函数,这个函数称为hashmap的扩容函数。扩容函数我们之后再讨论,执行完扩容之后也即默认初始化数组。

之后再注释1.2处,i = (n-1) & hash为上文所述,通过将hash值与数组长度减1做与运算来获取索引index,目的是为了高效地使hash均匀分布在数组中。如果所在数组节点处没有元素,那么直接新建一个node节点放在该数组index处。
否则在注释1.3处,如果该数组index已经有了节点p,那么判断该节点的key的hash值和key值是不是相等,如果是同一个key,那么就找到了要修改的节点e。将p赋值给e,联系注释1.6处代码,可知将旧的value值用新的value值代替。

如果在数组上Index的节点的key值和我们插入的key值不一致,那么看注释1.4处,判断节点链表是否为红黑树,如果是红黑树,那么直接在红黑树中插入。

否则进入到注释1.5处,头节点的key值不一致,那么就向链表中下一个节点同理查询,如果下一个节点的key值和插入的key值相等,那么就相当于1.3处的逻辑,找到了要修改的节点e,用新的value替换旧的value。如果顺着链表一直没有找到相同key值的节点,那么就生成key-value的新节点,插入到链表尾部。

我们可以通过图来更直接的理解hashmap的hash值计算及插入操作。

图中,分别有节点A,B,C,D的信息。其中第二行蓝色32位数据为节点的hashCode值,第一行为hashCode右移16位值。第一行红色为执行异或运算之后得到的hash值。绿色为数组长度n-1的值,下面的红色值为hash & (n-1)与运算得到要插入数组的index值。

插入更新完成之后,注释1.7处,通过数组中已有node节点数和threshold值比较,如果超过了该值,那么进行扩容操作。接下来我们就该看看hashmap到底是如何进行扩容的。

Hashmap的扩容机制

由上述put()方法,我们接触到扩容。当hashmap容量不足以储存新数据时,便要采用扩容来扩大Hashmap容量。重点我们来看下上述提到的resize()函数。

    final Node<K,V>[] resize() {Node<K,V>[] oldTab = 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;}// 2.1else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold}else if (oldThr > 0) // initial capacity was placed in thresholdnewCap = oldThr;else {               // zero initial threshold signifies using defaults// 2.2newCap = DEFAULT_INITIAL_CAPACITY;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"})// 2.3Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;if (oldTab != null) {// 2.4for (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 orderNode<K,V> loHead = null, loTail = null;Node<K,V> hiHead = null, hiTail = null;Node<K,V> next;do {next = e.next;// 2.5if ((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;}}}}}return newTab;}

初始化数组的时候使用扩容会进入注释2.2处,数组容量采用默认容量DEFAULT_INITIAL_CAPACITY16,threshold为默认负载因子乘以容量。threshold的作用为判断hashmap是否需要扩容,当hashmap中的Node节点数size大于threshold时,那么hashmap就需要扩容了,这一点在put中的源码有体现,我们可以之后来验证它。

而当hashmap节点size到一定容量时也会触发扩容,此时就会进入注释2.1处。数组长度通过位运算进行乘2扩容,同时由于负载因子未变,threshold = capacity * loadfactor也会扩大一倍。所以也通过位运算左移一位完成乘2操作。

完成了capacitythreshold的更新之后,注释2.3处便新建一个新容量的数组,在注释2.4处进行节点元素的迁移。从将旧数组中的元素迁移到新数组中。迁移过程可参考下图。

迁移时,如果和节点A一样,没有子节点,那么就直接重新计算新数组下的索引插入。如果和B节点一样,有子节点,那么会区分节点B是不是为红黑树,如果没有那么进入到注释2.5处。这里就有点意思了,这里采用e.hash & oldCap,e的hash值和旧数组长度做与运算。如果为0则将元素插入新数组的j处。这里有2个问题。首先,为什么将元素插入j处?其次,为什么定位索引是使用和旧数组长度做位运算,而不是和新数组长度减1做位运算定位index索引呢?

针对这两个问题,我们一一来解释。首先为什么将元素插入j处,可以从新数组扩容图中各节点的hashCode值与新数组长度做位运算时比较看出。因为该节点在旧数组时在j处,所以该节点的hash值和新数组长度减1做位运算时仅仅不同的是比原来多一位运算。所以只要看那一位是0还是1,0的话那就保持和旧数组中索引一样为j,如果那一位是1的话就将j+旧数组长度作为它的新索引(因为新数组长度为旧数组长度2倍),将节点分散减少hash碰撞。

其次问题2,为什么用旧数组长度做位运算呢?因为在旧数组时已经做过了位运算知道了旧索引j,所以只要将hash值和第n+1位做与运算,也即和旧数组长度做位运算。确定是0还是1,就能在旧索引j的基础上直接定位出新索引。大大提高效率。同时,在新数组图中,我们也可以发现插入的时候采用尾插方式,新的节点都是插入链表尾部。

HashMap的线程不安全

可能大家都有听说过Hashmap非线程安全会产生死锁,但是死锁是如何产生的呢?我们可以通过分析JDK 1.7中的代码来一窥究竟。

   void resize(int newCapacity) {Entry[] oldTable = table;int oldCapacity = oldTable.length;if (oldCapacity == MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return;}Entry[] newTable = new Entry[newCapacity];transfer(newTable, initHashSeedAsNeeded(newCapacity));table = newTable;threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);}/*** Transfers all entries from current table to newTable.*/void transfer(Entry[] newTable, boolean rehash) {int newCapacity = newTable.length;for (Entry<K,V> e : table) {while(null != e) {Entry<K,V> next = e.next;if (rehash) {e.hash = null == e.key ? 0 : hash(e.key);}int i = indexFor(e.hash, newCapacity);e.next = newTable[i];newTable[i] = e;e = next;}}}

这是JDK 1.7中的Hashmap扩容操作。如果有两个线程同时扩容,进入transfer()函数中,那么此时两个线程中分别创建了新数组newTable。如果我们在transfer()函数中加入断点,在Entry<K,V> next = e.next;处加入断点,使线程1在此处暂停,然后继续执行线程2,让线程2完成扩容操作。然后我们再继续线程1的执行。此时e = key(3), next = key(7)。如果所示,我们处在第一个循环Loop1。

由于线程2已扩容,如图可以得知int i = 3,并且线程1中的newTable[3]指向key(3)。然后e = next;,e变为key(7)进入第二次循环Loop2。

第二次循环中,e = key(7), next = key(3),i依旧为3。并且key(7).next = key(3),key(3)指向key(7),线程1中的newTable[3]由指向key(3)变为指向key(7),然后e = key(3)。进入第三次循环。

第三次循环中,e = key(3), next = null,i依然是3。然后e.next = newTable[i]key(3).next = key(7)key(7)指向key(3)了。此时形成了环,如图中Loop3处数组所示。并且e = null,跳出循环。此时如果在线程1中进行map.get()操作,那么就进入死循环,形成非线程安全。

JDK 1.7中由于头插法会导致死循环,而在JDK 1.8处将头插法改为尾插法,多线程操作不再会有死循环,但是会产生数据覆盖错误。所以Hashmap依旧是线程不安全。

知识点总结

  • Hashmap中的Hash操作会将key值的hashCode的高16位和低16位进行异或运算来得到hash值,目的有2个。第一,使用位运算效率高。第二,当hashmap的size较小时也可以尽可能多的让hashCode加入运算,增加hash值的分散,减小哈希碰撞。

  • Hashmap的扩容操作,会将数组长度翻倍(乘2),并且将节点重新移入新数组中。而新数组不再重新计算索引,而是只看第n+1位为0或1,来判断新索引和旧索引一样还是在旧索引基础上加上原数组长度。

  • Hashmap为非线程安全,在JDK 1.7中会形成死锁,在JDK 1.8中户形成数据覆盖,如果线程安全可采用ConcurrentHashMap,其采用了分段锁,保证了线程安全。

  • Hashmap在JDK 1.8中,加入新节点后,若链表长度大于8,那么链表就会转换成红黑树。查询所需要的时间复杂度由O(n)变为O(logn)。所以JDK 1.8中,Hashmap的结构为 数组+链表+红黑树。

  • size : 表示Hashmap中存放key-value的数量。(Node节点总和)

  • capacity : Hashmap中数组的长度。table.length

  • loadFactor : 负载因子。根据size与装载因子 * capacity 判断是否扩容。

  • threshold : 表示Hashmap的size大于threshold时会进行扩容操作。 threadhold = capacity * loadFactor.

引用

  • Java 8系列之重新认识HashMap
  • 一个HashMap跟面试官扯了半个小时
  • 漫画:什么是HashMap?
  • 漫画:高并发下的HashMap

HashMap的源码分析(不作标题党)相关推荐

  1. vfp赋值超过7位出错_JDK1.7下的HashMap的源码分析

    源码分析jdk1.7下的HashMap 我们都知道1.7版本的hashmap的底层是数组加链表构成的,那么今天我们就来自己分析一波源码~ 篇幅有点长,废话不多说,直接开始分析~ 「属性声明」 //初始 ...

  2. 集合框架知识系列05 HashMap的源码分析和使用示例

    一.HashMap简介 HashMap是基于"拉链法"实现的散列表.一般用于单线程程序中,JDK 1.8对HashMap进行了比较大的优化,底层实现由之前的"数组+链表& ...

  3. java地图源码_Java集合源码分析(四)HashMap

    一.HashMap简介 1.1.HashMap概述 HashMap是基于哈希表的Map接口实现的,它存储的是内容是键值对映射.此类不保证映射的顺序,假定哈希函数将元素适当的分布在各桶之间,可为基本操作 ...

  4. HashMap、ConcurrentHashMap(1.7、1.8)源码分析 + 红黑树

    个人博客欢迎访问 总结不易,如果对你有帮助,请点赞关注支持一下 微信搜索程序dunk,关注公众号,获取博客源码 序号 内容 1 Java基础面试题 2 JVM面试题 3 Java并发编程面试 4 计算 ...

  5. HashMap与ConcurrentHashMap万字源码分析

    HashMap与ConcurrentHashMap`源码解析 JDK版本:1.7 & 1.8 ​ 开发中常见的数据结构有三种: 1.数组结构:存储区间连续.内存占用严重.空间复杂度大 优点:因 ...

  6. HashMap实现原理及源码分析为何选用红黑树

    目录 一.什么是哈希表 二.HashMap实现原理 三.为何HashMap的数组长度一定是2的次幂? 四.重写equals方法需同时重写hashCode方法 五.总结 为什么HashMap使用红黑树而 ...

  7. HashMap实现原理及源码分析

    目录 一.什么是哈希表 二.HashMap实现原理 三.为何HashMap的数组长度一定是2的次幂? 四.重写equals方法需同时重写hashCode方法 五.总结 一.什么是哈希表 在讨论哈希表之 ...

  8. java基础之HashMap源码分析

    目录 1. HashMap原理分析 1.1. HashMap继承体系 1.2.Node数据结构分析 1.3.底层储存结构 1.3.1.put方法分析 1.4.hash碰撞 1.4.1.key值的唯一性 ...

  9. HashMap 源码分析

    每逢面试都会被问到 HashMap 源码,那么 HashMap 为什么这么受面试官喜欢呢?它到底是好在了哪里?我们在平时工作中,也会经常使用它,今天,给大家带来一篇 HashMap 的源码分析,也是我 ...

最新文章

  1. datatable无法设置横向滚动条(设置无效)
  2. TensorRT(4)-Profiling and 16-bit Inference
  3. WEB入门之二十 插件
  4. SQL5 将查询后的列重新命名(数据库的几种去重方法)
  5. Tricks(七)——list of lists 行和、列和的计算
  6. go语言 Accept error: accept tcp [::]:5551: too many open files;
  7. VisualVM初次使用BTrace功能方法步骤
  8. 动辄几个亿的东半球最强饭局:大佬们都吃了啥?
  9. OpenCV-图像处理(12、形态学操作应用-提取水平与垂直线)
  10. SQLite 数据库存取图片(QT方式)
  11. Python实现图片标记分类文件夹自动归类文件的脚本
  12. 海康威视监控摄像头连接电脑教程
  13. linux嵌入式reboot不生效,Embeded linux之reboot
  14. const的意义及作用
  15. 服务器装win7无限重启吗,win7下机子无限重启解决办法
  16. 别再自己抠图了,3行Python代码实现图像照片抠图和换底色的方法
  17. 计算机蓝屏了 怎么维修,电脑蓝屏怎么解决
  18. opensparc中的crossbar
  19. 邀请函 · Android 9 Pie 技术分享会直播
  20. 适用vue的html前台页面打印插件

热门文章

  1. Eclipse 两种安装方式
  2. github下载代码运行问题
  3. 在多平台windows、安卓、苹果ios 中使用 airpods/airpods pro。以及设备切换
  4. FOW的JAVA基础知识复习
  5. 制作kali linux u盘,用 Kali Linux 创建U盘随身系统
  6. BIM族库下载——大型机具及施工用车族库
  7. excel2019单元格插入图片位置大小随单元格变化
  8. python的random函数掷色子和为7的概率_案例(7):模拟掷骰子
  9. 热敏打印机在小票上打印条码和开钱箱打印小票
  10. 3DMax/Blender模型联动桥接插件:BMAX 1.1/1.14 For 2013-2021/2.8x-2.9x