HashMap作为我们最常用的数据类型,当然有必要了解一下他内部是实现细节。相比于 JDK7 在JDK8 中引入了红黑树以及hash计算等方面的优化,使得 JDK8 中的HashMap效率要高于以往的所有版本,本文会详细介绍相关的优化,但是主要还是写 JDK8 的源码。

一、整体结构

1. 类定义

  1. public class HashMap<K,V> extends AbstractMap<K,V>
  2. implements Map<K,V>, Cloneable, Serializable {}

可以看到HashMap是完全基于Map接口实现的,其中AbstractMapMap接口的骨架实现,提供了Map接口的最小实现。

HashMap看名字也能猜到,他是基于哈希表实现的(数组+链表+红黑树):

2. 构造函数和成员变量

  1. public HashMap(int initialCapacity)
  2. public HashMap()
  3. public HashMap(Map<? extends K, ? extends V> m)
  4. public HashMap(int initialCapacity, float loadFactor) {
  5. if (initialCapacity < 0)
  6. throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
  7. if (initialCapacity > MAXIMUM_CAPACITY)
  8. initialCapacity = MAXIMUM_CAPACITY;
  9. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  10. throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
  11. this.loadFactor = loadFactor;
  12. this.threshold = tableSizeFor(initialCapacity);
  13. }

HashMap一共有四个构造函数,其主要作用就是初始化loadFactorthreshold两个参数:

  • threshold:扩容的阈值,当放入的键值对大于这个阈值的时候,就会发生扩容;
  • loadFactor:负载系数,用于控制阈值的大小,即threshold = table.length * loadFactor;默认情况下负载系数等于0.75,当它值越大时:哈希桶空余的位置越少,空间利用率越高,同时哈希冲突也就越严重,效率也就越低;相反它值越小时:空间利用率越低,效率越高;而0.75是对于空间和效率的一个平衡,通常情况下不建议修改;

但是对于上面构造函数当中this.threshold = tableSizeFor(initialCapacity);,这里的阈值并没有乘以负载系数,是因为在构造函数当中哈希桶table[]还没有初始化,在往里put数据的时候才会初始化,而tableSizeFor是为了得到大于等于initialCapacity的最小的2的幂;

  1. transient Node<K,V>[] table; // 哈希桶
  2. transient Set<Map.Entry<K,V>> entrySet; // 映射关系Set视图
  3. transient int size; // 键值对的数量
  4. transient int modCount; // 结构修改次数,用于实现fail-fast机制

哈希桶的结构如下:

  1. static class Node<K,V> implements Map.Entry<K,V> {
  2. final int hash; // 用于寻址,避免重复计算
  3. final K key;
  4. V value;
  5. Node<K,V> next;
  6. ...
  7. public final int hashCode() {
  8. return Objects.hashCode(key) ^ Objects.hashCode(value);
  9. }
  10. }

其中Node<K,V> next还有一个TreeNode子类用于实现红黑树,需要注意的是这里的hashCode()所计算的hash值只用于在遍历的时候获取hash值,并非寻址所用hash;

二、Hash表

既然是Hash表,那么最重要的肯定是寻址了,在HashMap中采用的是除留余数法,即table[hash % length],但是在现代CPU中求余是最慢的操作,所以人们想到一种巧妙的方法来优化它,即length为2的指数幂时,hash % length = hash & (length-1),所以在构造函数中需要使用tableSizeFor(int cap)来调整初始容量;

  1. /**
  2. * Returns a power of two size for the given target capacity.
  3. */
  4. static final int tableSizeFor(int cap) {
  5. int n = cap - 1;
  6. n |= n >>> 1;
  7. n |= n >>> 2;
  8. n |= n >>> 4;
  9. n |= n >>> 8;
  10. n |= n >>> 16;
  11. return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
  12. }

首先这里要明确:

  • 2的幂的二进制是,1后面全是0
  • 有效位都是1的二进制加1,就可以得到2的幂

以33为例,如图:

因为int是4个字节32位,所以最多只需要将高位的16位与低位的16位做或运算就可以得到2的幂,而int n = cap - 1;是为了避免cap本身就是2的幂的情况;这个算是真是厉害,看了很久才看明白,实在汗颜。

**计算 hash **

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

这里重新计算hash是因为在hash & (length-1)计算下标的时候,实际只有hash的低位参与的运算容易产生hash冲突,所以用异或是高位的16位也参与运算,以减小hash冲突,要理解这里首先要明白,

  • & 操作之后只会保留下都是1的有效位
  • length-1(2的n次方-1)实际上就是n和1
  • & 操作之后hash所保留下来的也只有低位的n个有效位,所以实际只有hash的低位参与了运算

具体如图所示:

三、重要方法讲解

对于Map而言最重要的当然是GetPut等操作了,所以下面将介绍与之相关的操作;

1. put方法

  1. public V put(K key, V value) {
  2. return putVal(hash(key), key, value, false, true);
  3. }
  4. /**
  5. * Implements Map.put and related methods * * @param hash hash for key
  6. * @param key the key
  7. * @param value the value to put
  8. * @param onlyIfAbsent if true, don't change existing value
  9. * @param evict if false, the table is in creation mode.
  10. * @return previous value, or null if none
  11. */
  12. final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
  13. Node<K,V>[] tab; Node<K,V> p; int n, i;
  14. // 如果没有初始化哈希桶,就使用resize初始化
  15. if ((tab = table) == null || (n = tab.length) == 0)
  16. n = (tab = resize()).length;
  17. // 如果hash对应的哈希槽是空的,就直接放入
  18. if ((p = tab[i = (n - 1) & hash]) == null)
  19. tab[i] = newNode(hash, key, value, null);
  20. else {
  21. Node<K,V> e; K k;
  22. // 如果已经存在key,就替换旧值
  23. if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
  24. e = p;
  25. // 如果已经是树节点,就用putTreeVal遍历树赋值
  26. else if (p instanceof TreeNode)
  27. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  28. else {
  29. // 遍历链表
  30. for (int binCount = 0; ; ++binCount) {
  31. // 遍历到最后一个节点也没有找到,就新增一个节点
  32. if ((e = p.next) == null) {
  33. p.next = newNode(hash, key, value, null);
  34. // 如果链表长度大于8,则转换为红黑树
  35. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
  36. treeifyBin(tab, hash);
  37. break;
  38. }
  39. // 找到key对应的节点则跳出遍历
  40. if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
  41. break;
  42. p = e;
  43. }
  44. }
  45. // e是最后指向的节点,如果不为空,说明已经存在key,则替换旧的value
  46. if (e != null) { // existing mapping for key
  47. V oldValue = e.value;
  48. if (!onlyIfAbsent || oldValue == null)
  49. e.value = value;
  50. afterNodeAccess(e);
  51. return oldValue;
  52. }
  53. }
  54. // 新增节点时结构改变modCount加1
  55. ++modCount;
  56. if (++size > threshold)
  57. resize();
  58. afterNodeInsertion(evict);
  59. return null;
  60. }

具体过程如图所示:

2. resize方法

  1. final Node<K,V>[] resize() {
  2. Node<K,V>[] oldTab = table;
  3. int oldCap = (oldTab == null) ? 0 : oldTab.length;
  4. int oldThr = threshold;
  5. int newCap, newThr = 0;
  6. if (oldCap > 0) {
  7. // 如果hash桶已经完成初始化,并且已达最大容量,则直接返回
  8. if (oldCap >= MAXIMUM_CAPACITY) {
  9. threshold = Integer.MAX_VALUE;
  10. return oldTab;
  11. }
  12. // 如果扩大2倍没有超过最大容量,则扩大两倍
  13. else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
  14. newThr = oldThr << 1; // double threshold
  15. }
  16. // 如果threshold已经初始化,则初始化容量为threshold
  17. else if (oldThr > 0) // initial capacity was placed in threshold
  18. newCap = oldThr;
  19. // 如果threshold和哈希桶都没有初始化,则使用默认值
  20. else { // zero initial threshold signifies using defaults
  21. newCap = DEFAULT_INITIAL_CAPACITY;
  22. newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  23. }
  24. // 重新计算threshold
  25. if (newThr == 0) {
  26. float ft = (float)newCap * loadFactor;
  27. newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
  28. }
  29. threshold = newThr;
  30. @SuppressWarnings({"rawtypes","unchecked"})
  31. Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  32. table = newTab;
  33. if (oldTab != null) {
  34. for (int j = 0; j < oldCap; ++j) {
  35. Node<K,V> e;
  36. if ((e = oldTab[j]) != null) {
  37. oldTab[j] = null;
  38. // 如果只有一个节点,则直接重新放置节点
  39. if (e.next == null)
  40. newTab[e.hash & (newCap - 1)] = e;
  41. // 如果是树节点,则将红黑树拆分后,重新放置
  42. else if (e instanceof TreeNode)
  43. ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
  44. // 将链表拆分为原位置和高位置两条链表
  45. else { // preserve order
  46. Node<K,V> loHead = null, loTail = null;
  47. Node<K,V> hiHead = null, hiTail = null;
  48. Node<K,V> next;
  49. do {
  50. next = e.next;
  51. // 节点重新放置后在原位置
  52. if ((e.hash & oldCap) == 0) {
  53. if (loTail == null)
  54. loHead = e;
  55. else
  56. loTail.next = e;
  57. loTail = e;
  58. }
  59. // 节点重新放置后位置+oldCap
  60. else {
  61. if (hiTail == null)
  62. hiHead = e;
  63. else
  64. hiTail.next = e;
  65. hiTail = e;
  66. }
  67. } while ((e = next) != null);
  68. // 放置低位置链表
  69. if (loTail != null) {
  70. loTail.next = null;
  71. newTab[j] = loHead;
  72. }
  73. // 放置高位置链表
  74. if (hiTail != null) {
  75. hiTail.next = null;
  76. newTab[j + oldCap] = hiHead;
  77. }
  78. }
  79. }
  80. }
  81. }
  82. return newTab
  83. }

上面的扩容过程需要注意的是,因为哈希桶长度总是2的幂,所以在扩大两倍之后原来的节点只可能在原位置或者原位置+oldCap,具体判断是通过(e.hash & oldCap) == 0实现的;

  • 之前将了 & 操作只保留了都是1的有效位
  • oldCap 是2的n次方,实际也就是在n+1的位置为1,其余地方为0
  • 因为扩容是扩大2倍,实际上也就是在hash上取了 n+1位,那么就只需要判断多取的第n+1位是否为0

如图所示:

3. get方法

  1. public V get(Object key) {
  2. Node<K,V> e;
  3. return (e = getNode(hash(key), key)) == null ? null : e.value;
  4. }
  5. final Node<K,V> getNode(int hash, Object key) {
  6. Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
  7. if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
  8. if (first.hash == hash && // always check first node
  9. ((k = first.key) == key || (key != null && key.equals(k))))
  10. return first;
  11. if ((e = first.next) != null) {
  12. if (first instanceof TreeNode)
  13. return ((TreeNode<K,V>)first).getTreeNode(hash, key);
  14. do {
  15. if (e.hash == hash &&
  16. ((k = e.key) == key || (key != null && key.equals(k))))
  17. return e;
  18. } while ((e = e.next) != null);
  19. }
  20. }
  21. return null;
  22. }

相较于其他方法get方法就要简单很多了,只是用hash取到对应的hash槽,在依次遍历即可。

4. clone方法

  1. public Object clone() {
  2. HashMap<K,V> result;
  3. try {
  4. result = (HashMap<K,V>)super.clone();
  5. } catch (CloneNotSupportedException e) {
  6. // this shouldn't happen, since we are Cloneable
  7. throw new InternalError(e);
  8. }
  9. result.reinitialize();
  10. result.putMapEntries(this, false);
  11. return result;
  12. }

对于clone方法这里有一个需要注意的地方,result.putMapEntries(this, false),这里在put节点的时候是用的this,所以这只是浅复制,会影响原map,所以在使用的时候需要注意一下;

至于其他方法还有很多,但大致思路都是一致的,大家可以在看一下源码。

四、HashMap不同版本对比

1. hash均匀的时候使用get

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 4 ms 3 ms 4 ms 2 ms
100,000 7 ms 6 ms 8 ms 4 ms
1,000,000 99 ms 15 ms 14 ms 13 ms

2. hash不均匀的时候使用get

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 197 ms 154 ms 132 ms 15 ms
100,000 30346 ms 18967 ms 19131 ms 177 ms
1,000,000 3716886 ms 2518356 ms 2902987 ms 1226 ms
10,000,000 OOM OOM OOM 5775 ms

3. hash均匀的时候使用put

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 17 ms 12 ms 13 ms 10 ms
100,000 45 ms 31 ms 34 ms 46 ms
1,000,000 384 ms 72 ms 66 ms 82 ms
10,000,000 4731 ms 944 ms 1024 ms 99 ms

4. hash不均匀的时候使用put

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 211 ms 153 ms 162 ms 10 ms
100,000 29759 ms 17981 ms 17653 ms 93 ms
1,000,000 3527633 ms 2509506 ms 2902987 ms 333 ms
10,000,000 OOM OOM OOM 3970 ms

从以上对比可以看到 JDK8 的 HashMap 无论 hash 是否均匀效率都要好得多,这里面hash算法的改良功不可没,并且因为红黑树的引入使得它在hash不均匀甚至在所有key的hash都相同的情况,任然表现良好;

另外这里我数据我是摘至 Performance Improvement for HashMap in Java 8,里面还有更详细的图表,大家有兴趣可以看一下;

总结

  1. 扩容需要重排所有节点特别损耗性能,所以估算map大小并给定一个合理的负载系数,就显得尤为重要了。
  2. HashMap 是线程不安全的。
  3. 虽然 JDK8 中引入了红黑树,将极端hash的情况影响降到了最小,但是从上面的对比还是可以看到,一个好的hash对性能的影响仍然十分重大,所以写一个好的hashCode()也非常重要。

参考

https://tech.meituan.com/java_hashmap.html

https://blog.csdn.net/fan2012huan/article/details/51097331

https://www.nagarro.com/en/blog/post/24/performance-improvement-for-hashmap-in-java-8

JDK源码分析(5)之 HashMap 相关的更多相关文章

  1. 【JDK】JDK源码分析-HashMap(1)

    概述 HashMap 是 Java 开发中最常用的容器类之一,也是面试的常客.它其实就是前文「数据结构与算法笔记(二)」中「散列表」的实现,处理散列冲突用的是“链表法”,并且在 JDK 1.8 做了优 ...

  2. 【JDK】JDK源码分析-HashMap(2)

    前文「JDK源码分析-HashMap(1)」分析了 HashMap 的内部结构和主要方法的实现原理.但是,面试中通常还会问到很多其他的问题,本文简要分析下常见的一些问题. 这里再贴一下 HashMap ...

  3. JDK 源码分析(4)—— HashMap/LinkedHashMap/Hashtable

    JDK 源码分析(4)-- HashMap/LinkedHashMap/Hashtable HashMap HashMap采用的是哈希算法+链表冲突解决,table的大小永远为2次幂,因为在初始化的时 ...

  4. JDK源码分析(三)—— LinkedList

    参考文档 JDK源码分析(4)之 LinkedList 相关

  5. JDK源码分析(一)—— String

    dir 参考文档 JDK源码分析(1)之 String 相关

  6. 【JDK】JDK源码分析-LinkedHashMap

    概述 前文「JDK源码分析-HashMap(1)」分析了 HashMap 主要方法的实现原理(其他问题以后分析),本文分析下 LinkedHashMap. 先看一下 LinkedHashMap 的类继 ...

  7. 【JDK】JDK源码分析-TreeMap(2)

    前文「JDK源码分析-TreeMap(1)」分析了 TreeMap 的一些方法,本文分析其中的增删方法.这也是红黑树插入和删除节点的操作,由于相对复杂,因此单独进行分析. 插入操作 该操作其实就是红黑 ...

  8. 【JDK】JDK源码分析-Vector

    概述 上文「JDK源码分析-ArrayList」主要分析了 ArrayList 的实现原理.本文分析 List 接口的另一个实现类:Vector. Vector 的内部实现与 ArrayList 类似 ...

  9. 【JDK】JDK源码分析-List, Iterator, ListIterator

    List 是最常用的容器之一.之前提到过,分析源码时,优先分析接口的源码,因此这里先从 List 接口分析.List 方法列表如下: 由于上文「JDK源码分析-Collection」已对 Collec ...

  10. 【JDK】JDK源码分析-AbstractQueuedSynchronizer(2)

    概述 前文「JDK源码分析-AbstractQueuedSynchronizer(1)」初步分析了 AQS,其中提到了 Node 节点的「独占模式」和「共享模式」,其实 AQS 也主要是围绕对这两种模 ...

随机推荐

  1. 关于 Mybatis 设置懒加载无效的问题

    看了 mybatis 的教程,讲到关于mybatis 的懒加载的设置: 只需要在 mybatis 的配置文件中设置两个属性就可以了: <settings> <!-- 打开延迟加载的开 ...

  2. VS2017简单使用

    1. 2.删除下面的文件 3.点击属性 4.改为否 不使用预编译头 万能头文件自己导入网上有教程

  3. javascript是什么,可以做什么?

    是一门脚本语言:不需要编译,直接运行 是一门解释型语言:遇到一行代码就解释一行代码 是一门动态类型的语言 是一门基于对象的语言 是一门弱类型的语言:声明变量的时候不用特别声明类型都使用var 不是一门 ...

  4. SpringMVC+Mybatis+MySQL8遇到的问题

    搭建SpringMVC+Mybatis+MySQL8过程中遇到的坑. 1.数据库驱动要使用新版本,我的和mysql保持一致. 查看mysql版本:MySQL\bin>mysql -V 配置对应版 ...

  5. eclipse中生成的html存在中文乱码问题的解决方法

    最近在做测试报告生成时遇到了个中文乱码的问题,虽然在html创建过程中设置了编码格式htmlReporter.config().setEncoding("UTF-8");但是生成的 ...

  6. Python基础之面向对象2(封装)

    一.封装定义: 二.作用 三.私有成员: 1.基本概念及作用 2.__slots__手段私有成员: 3.@property属性手段私有成员: 四.基础示例代码 1.用方法封装变量 "&quo ...

  7. 折线图hellocharts的使用说明

    以前用过一次XCL-chart,但是感觉只适合固定图表,不去滑动的那种,因为你一滑动太卡了你懂得(毕竟作者好久没更新优化了),拙言大神我开玩笑的 ,毕竟我加你的群大半年了 - - 第二研究了一下ach ...

  8. HTML5调用手机摄像机、相册功能 <input>方法

    最近用MUI框架做webapp项目,在有PLUS环境的基础上能直接调用手机底层的API来使用拍照或从相册选择上传功能! 在查资料的时候,想起了另一种用input调用摄像和相册功能的方法,之前没有深入了 ...

  9. 关于HTTP协议,这一篇就够了

    HTTP简介 HTTP协议是Hyper Text Transfer Protocol(超文本传输协议)的缩写,是用于从万维网(WWW:World Wide Web )服务器传输超文本到本地浏览器的传送 ...

  10. Dubbo+zookeeper构建高可用分布式集群(二)-集群部署

    在Dubbo+zookeeper构建高可用分布式集群(一)-单机部署中我们讲了如何单机部署.但没有将如何配置微服务.下面分别介绍单机与集群微服务如何配置注册中心. Zookeeper单机配置:方式一. ...