基础结构

JDK 1.8 之前是由“数组+链表”组成,JDK 1.8,底层是由“数组+链表+红黑树”组成

结构优化目的:优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。

/*
表在第一次使用时初始化(懒汉模式),并根据需要调整大小。当分配时,长度总是2的幂我们还允许一些操作的长度为0,以允许当前不需要的引导机制;这是hashmap的主体结构
*/
transient Node<K,V>[] table;transient int size;/*HashMap的基本数据结构,链表,数组中存储的是链表的地址*/
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);}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;}}

扩展:xxxxxxxxxxxxxxxxxxx

总体图表如下

为什么要改成“数组+链表+红黑树”?
主要是为了解决哈希冲突严重时(链表过长时),插入和查找结点缓慢的问题,使用链表的查找性能是 O(n),而使用红黑树是 O(logn)。

哈希

哈希表
是一种实现关联数组抽象数据类型的数据结构,这种结构可以将关键码映射到给定值。简单来说哈希表(key-value)之间存在一个映射关系,是键值对的关系,一个键对应一个值。

哈希冲突
当两个不同的数经过哈希函数计算后得到了同一个结果,即他们会被映射到哈希表的同一个位置时,即称为发生了哈希冲突。简单来说就是哈希函数算出来的地址被别的元素占用了。最典型的哈希冲突就是hashmap中,键(key)经过hash函数得到的结果作为地址去存放当前的键值对(k-v),但是却发现该地址已经有人先来了。这个冲突就是hash冲突了。

解决hash冲突的办法
开发定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
再哈希法
链地址法
建立一个公共溢出区
java中hashmap采用的是链地址法

插入流程分析

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {//tab等价于table,通过tab来完成对hashmap主体的操作,n代表hashmap主体数组的长度,i代表插入的位置,p代表数组存储的节点即链表的头结点         Node<K,V>[] tab; Node<K,V> p; int n, i;//把主体结构table赋值给tab,并表在第一次使用时初始化if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;//计算插入位置(i = (n - 1) & hash),判断该位置的节点是否为空,为空直接插入   if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else { //插入位置上的不为空//e代表要插入的节点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 {//对该链表上元素循环判断,有重复的就以新换旧,否则就插入到链表尾部for (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;}}//以新换旧的步骤,其实就是把新的节点的value替换掉旧节点的valueif (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;}

流程总结
put数据的过程中底层会创建一个大小为16位的node数组,根据插入键值对的key值计算哈希值,然后根据哈希值推算出待插入位置的数组,需要注意hashmap是懒加载的创建模式,只要第一次put时才会为hashmap分配空间,避免不必要的空间浪费。

  • 如果数组位置是空就直接插入
  • 如果插入位置有一个或链表形式的数据,那么首先对比哈希值,注意,如果是链表的话则需要遍历整个链表来判断是否重复
    • 如果哈希值都不相同那么插入成功,尾插法,如果当前链表结点数大于8会对链表进行树华
    • 如果哈希值相同,那么调用equals来对比两键值的key
      • 返回false则插入成功,尾插,如果当前链表结点数大于8会对链表进行树华
      • 返回true,那么在key不变的情况下,用新的value替换旧的value

细节1:为什么链表转红黑树的阈值是8?
我们平时在进行方案设计时,必须考虑的两个很重要的因素是:时间和空间。对于 HashMap 也是同样的道理,简单来说,阈值为8是在时间和空间上权衡的结果,红黑树节点大小约为链表节点的2倍,在节点太少时,红黑树的查找性能优势并不明显,付出2倍空间的代价并不值得。理想情况下,使用随机的哈希码,节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的公式计算,链表中节点个数为8时的概率为 0.00000006,这个概率足够低了,并且到8个节点时,红黑树的性能优势也会开始展现出来,因此8是一个较合理的数字。

细节2:为什么转回链表节点是用的6而不是复用8?
如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。

细节3,如何计算节点插入位置?

i = (n - 1) & hash

细节四:链地址法的体现
根据这种算法(i = (n - 1) & hash),可以保证hash值相同的节点都会集中到一条链表上,我们只需要遍历这条链表来判断是否重复即可,而不需要全局遍历,保证了插入的性能,而且插入过程中优先判断hash值是否相同,只要hash值相同时才会比较equals,jdk8中引入了红黑树结果,使得插入速度更快

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

细节:&&做为条件与,比如a && b,如果a为false就直接返回了,&是运算与,a&b,无论a是true还是false都会继续往下运算完b,|是运算或,也就是如果a|| b,如果a是true就直接返回true,a|b同理,a,b都会被运算,所以这里的就是首先判断插入节点的hash值和当前接待hash值是否一致,不一致就不再继续往下判断下去了,继续往下执行;

细节五:在什么时候用链表?什么时候用红黑树?
对于插入,默认情况下是使用链表节点。当同一个索引位置的节点在新增后达到9个(阈值8):如果此时数组长度大于等于 64,则会触发链表节点转红黑树节点(treeifyBin);而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容,因为此时的数据量还比较小。

初始化过程

/*
作为扩容阈值:当 HashMap 的个数达到该值,触发扩容,默认0.75,扩容阈值 = 容量 * 负载因子
用于初始化:因为HashMap 的容量必须是2的N次方,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方
*/
final float loadFactor;/*hashmap默认大小,默认大小为2的4次方16,后续扩容的话大小也必须是2的四次方*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4static final float DEFAULT_LOAD_FACTOR = 0.75f;/*扩容域值,默认值为table大小的0.75倍*/
int threshold;/*hashmap主要的构造方法*/
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);}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;}

分析tableSizeFor方法

  • |=(或等于):这个符号比较少见,但是“+=”应该都见过,看到这你应该明白了。例如:a |= b ,可以转成:a = a | b

    >>>(无符号右移):例如 a >>> b 指的是将 a 向右移动 b 指定的位数,右移后左边空出的位用零来填充,移出右边的位被丢弃。

    假设 n 的值为 0010 0001(33),则该计算如下图:

    最终所得到的值就是0011 1111(63),返回时加一恰好为64(2的8次方);验证

    在继续debug下一步

    threshold变成了48,table数组容量变为了64;中间又经过了什么呢?

扩容流程

/*
初始化或加倍表大小。如果为空,则根据域阈值中持有的初始容量目标进行分配。否则,因为我们使用的是2的幂次展开,所以每个bin中的元素要么保持在相同的索引上,要么在新表中以2的幂次偏移量移动。
*/
final Node<K,V>[] resize() { //oldTab代表原数组Node<K,V>[] oldTab = table;//oldCap代表原数组长度int oldCap = (oldTab == null) ? 0 : oldTab.length;//oldThr代表原threshold(扩容域)int oldThr = threshold;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)//扩容为旧临界值的2次方newThr = oldThr << 1; }else if (oldThr > 0) // initial capacity was placed in thresholdnewCap = oldThr;else {               //这里是hashmap首次扩容(第一次put时)且构造器无传参,全部赋默认值newCap = 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"})Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;if (oldTab != null) {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 orderNode<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;}}}}}return newTab;}

其实不难发现,最后table数组的容量就是初初始的thredshold值,即为64;整体流程分析如下

重点:红黑树和链表都是通过 e.hash & oldCap == 0 来定位在新表的索引位置,这是为什么?
首先我们应该知道,hashmap计算结点插入的算法是

i = (n-1) & hash

案例:

扩容前 table 的容量为16,a 节点和 b 节点在扩容前处于同一索引位置。

扩容后,table 长度为32,新表的 n - 1 只比老表的 n - 1 在高位多了一个1(图中标红)

因为 2 个节点在老表是同一个索引位置,因此计算新表的索引位置时,只取决于新表在高位多出来的这一位(图中标红),而这一位的值刚好等于 oldCap(32)

因为只取决于这一位,所以只会存在两种情况:

  • (e.hash & oldCap) == 0 ,则新表索引位置为“原索引位置” ,直接把该链表原封不动插入到原来的位置;
  • (e.hash & oldCap) != 0,则新表索引位置受到了影响,也就是遵循上述位置插入算法进行计算,需要将其插入到为“原索引 + oldCap 的位置”。

细节1: HashMap 的默认初始容量是 16,为什么是16而不是其他的?

16是2的N次方,并且是一个较合理的大小。如果用8或32,我觉得也是可以的。实际上,我们在新建 HashMap 时,最好是根据自己使用情况设置初始容量,这才是最合理的方案

细节2:负载因子默认初始值为什么是0.75而不是其他的?
这个也是在时间和空间上权衡的结果。如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。

细节3:threshold的作用是什么

  • 作为扩容域值,超过时hashmap会进行相应的扩容
  • 在我们新建 HashMap 对象时, threshold 还会被用来存初始化时的容量。HashMap 直到我们第一次插入节点时,才会对 table 进行初始化,避免不必要的空间浪费。

JDK 1.8 的主要优化

1)底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。

2)计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。

// JDK 1.7.0
public HashMap(int initialCapacity, float loadFactor) {// 省略// Find a power of 2 >= initialCapacityint capacity = 1;while (capacity < initialCapacity)capacity <<= 1;// ... 省略
}
// JDK 1.8.0_191
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;
}

优化了 hash 值的计算方式,老的通过一顿瞎JB操作,新的只是简单的让高16位参与了运算。

// JDK 1.7.0
static int hash(int h) {h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8.0_191
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环。

扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。

除了 HashMap,还用过哪些 Map,在使用时怎么选择?

HashMap 是线程安全的吗?

高并发是存在线程安全问题,案例

    public static void main(String[] args) {Map<String,String> list = new HashMap<>();for (int i = 0; i < 30; i++) {new Thread(()->{list.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,8));System.out.println(list);},"t"+i).start();}}

我们根据报错信息分析

执行打印是最终会调用到AbstractHashMap的toString

    public String toString() {Iterator<Entry<K,V>> i = entrySet().iterator();if (! i.hasNext())return "{}";StringBuilder sb = new StringBuilder();sb.append('{');for (;;) {Entry<K,V> e = i.next();K key = e.getKey();V value = e.getValue();sb.append(key   == this ? "(this Map)" : key);sb.append('=');sb.append(value == this ? "(this Map)" : value);if (! i.hasNext())return sb.append('}').toString();sb.append(',').append(' ');}}

内部调用到HashMap中的next()方法,源码如下

 abstract class HashIterator {Node<K,V> next;        // next entry to returnNode<K,V> current;     // current entryint expectedModCount;  // for fast-failint index;             // current slotHashIterator() {expectedModCount = modCount;Node<K,V>[] t = table;current = next = null;index = 0;if (t != null && size > 0) { // advance to first entrydo {} while (index < t.length && (next = t[index++]) == null);}}public final boolean hasNext() {return next != null;}final Node<K,V> nextNode() {Node<K,V>[] t;Node<K,V> e = next;if (modCount != expectedModCount)throw new ConcurrentModificationException();if (e == null)throw new NoSuchElementException();if ((next = (current = e).next) == null && (t = table) != null) {do {} while (index < t.length && (next = t[index++]) == null);}return e;}

正常情况下,每次打印时执行一遍next方法,也就意味着每次都会初始化 HashIterator,内部有操作

 expectedModCount = modCount;

而我们上述案例是开启了30个线程,在500毫秒内同时对一个map进行插入并打印map,

也就是第10个线程插入map(modCount变为10)但在打印前的一瞬间,第13个线程刚执行完插入(modCount=11),modCount是全部线程所共享的,expectedModCount是线程独享的,利用这种机制,可以及时抛出异常防止多线程产生的混乱,如果没有这种机制,那么就上述案例中的打印来说,会打印出被多线程搞混乱的错误结果;

总计:外部类hashmap是这30个线程共享的资源,hashmap内部类HashIterator每个线程都会在打印hashmap时获得一份,这样相当于拿共享变量和线程独享变量进行比较来判断是否状态不一致

Java并发HashMap报错ConcurrentModificationException解决方案

HashMap由浅入深(jdk8)相关推荐

  1. 不止JDK7的HashMap,JDK8的ConcurrentHashMap也会造成CPU 100%

    点击上方"方志朋",选择"设为星标" 回复"666"获取新整理的面试资料 作者:朱小厮 公众号:朱小厮的博客(ID:hiddenkafka) ...

  2. 不止 JDK7 的 HashMap ,JDK8 的 ConcurrentHashMap 也会造成 CPU 100%?原因与解决~

    现象 大家可能都听过JDK7中的HashMap在多线程环境下可能造成CPU 100%的现象,这个由于在扩容的时候put时产生了死链,由此会在get时造成了CPU 100%.这个问题在JDK8中的Has ...

  3. HashMap面试深入详解jdk1.8

    HashMap是Java后端工程师面试的必问题,因为其中的知识点太多,很适合用来考察面试者的Java基础.今天基于jdk1.8来研究一下HashMap的底层实现. HashMap的内部数据结构 JDK ...

  4. 【Java自顶向下】HashMap面试题(2021最新版)

    文章目录 1.HashMap的底层数据结构? 2.为啥需要链表,链表又是怎么样子的呢? 3.新的Entry节点在插入链表的时候,是怎么插入的么? 4.Java7中的HashMap和Java8中的Has ...

  5. 巧用HashMap一行代码统计单词出现次数

    文章目录 简介 爱在JDK8之前 JDK8中使用compute JDK8中使用merge 简介 JDK是在一直在迭代更新的,很多我们熟悉的类也悄悄的添加了一些新的方法特性.比如我们最常用的HashMa ...

  6. HashMap源码学习

    HashMap实现了Map接口,继承自AbstractMap,并且是LinkedHashMap的父类. JDK8中的HashMap 在jdk8中,HashMap的底层的存储结构是一个Node对象的数组 ...

  7. Java HashMap 遍历方式性能探讨

    转载自 Java HashMap 遍历方式性能探讨 关于HashMap的实现这里就不展开了,具体可以参考JDK7与JDK8中HashMap的实现 JDK8之前,可以使用keySet或者entrySet ...

  8. java遍历hashmapk v_Java HashMap 遍历方式探讨

    JDK8之前,可以使用keySet或者entrySet来遍历HashMap,JDK8中引入了map.foreach来进行遍历. keySet其实是遍历了2次,一次是转为Iterator对象,另一次是从 ...

  9. HashMap的底层原理你真的知道?

    HashMap的底层实现原理是面试中出现频率非常高的一道面试题,本文将对HashMap的底层实现原理做一个简要的概况和总结,便于复习. 一.对于Map集合存储结构的理解 首先介绍以HashMap为典型 ...

最新文章

  1. 编程珠玑第12章习题
  2. Git 经常使用命令总结
  3. sigsuspend的使用
  4. 关于人行acs对账不及时_以在线教育公司为例,如何做一款财务对账产品?
  5. js动态增加行 删除行
  6. linux rm 命令删除文件恢复_linux文件处理命令之rm常用方法介绍
  7. Spark基础学习笔记13:Scala函数
  8. 【09】Jenkins:Pipeline 补充
  9. python统计字典里面value出现的次数_python 统计list中各个元素出现的次数的几种方法...
  10. git如何安装aur_git系列:git 简介
  11. C#方法的六种参数,值参数、引用参数、输出参数、参数数组、命名参数、可选参数...
  12. 如何用iframe代码显示调用网页的指定部分
  13. rust原声音乐_Joan Baez – Diamonds Rust
  14. android开源音乐播放器简单demo,Android开源在线音乐播放器——波尼音乐
  15. beanshell断言_jmeter之beanshell断言---数据处理
  16. 算法设计与分析:最短路径问题(哈密顿回路+最短路)小学期实践
  17. HTML网页设计制作大作业 html+css+js萌宠之家 网页设计与实现
  18. 人脸服务器如何与门禁系统对接,人脸识别门禁系统功能介绍
  19. java wait until_java调用ktr文件trans.waitUntilFinished()超时
  20. WIN 10 挂载分区之diskpart工具

热门文章

  1. Oracle ROLLUP和CUBE 用法
  2. [Node.js] ES6新语法
  3. 15个最新的HTML5及CSS3特效代码生成器
  4. Tips--Solidworks 2016绘制工程图时显示gtol.sym文件缺失的解决方法
  5. 漫画解析Linux内核
  6. 物联网专题--基于APP Inventor的BLE蓝牙4.0数据通信
  7. OpenCV3学习(4.2)——图像常用滤波方法(方框、均值、高斯、中值、双边)
  8. 第2关:HDFS-JAVA接口之读取文件
  9. 采用分治法求一个整数序列中的最大值和最小值
  10. 卓越领导者的智慧(精华版)