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

一、整体结构

1. 类定义

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

可以看到HashMap是完全基于Map接口实现的,其中AbstractMapMap接口的骨架实现,提供了Map接口的最小实现。
HashMap看名字也能猜到,他是基于哈希表实现的(数组+链表+红黑树):

2. 构造函数和成员变量

public HashMap(int initialCapacity)
public HashMap()
public HashMap(Map<? extends K, ? extends V> m)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);
}

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

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

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

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

哈希桶的结构如下:

static class Node<K,V> implements Map.Entry<K,V> {final int hash;       // 用于寻址,避免重复计算final K key;V value;Node<K,V> next;...public final int hashCode() {return Objects.hashCode(key) ^ Objects.hashCode(value);}
}

其中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)来调整初始容量;

/*** 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;
}

首先这里要明确:

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

以33为例,如图:

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

计算 hash

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

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

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

具体如图所示:

三、重要方法讲解

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

1. put方法

public V put(K key, V value) {return putVal(hash(key), key, value, false, true);
}/*** Implements Map.put and related methods * * @param hash hash for key* @param key the key* @param value the value to put* @param onlyIfAbsent if true, don't change existing value* @param evict if false, the table is in creation mode.* @return previous value, or null if none*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;// 如果没有初始化哈希桶,就使用resize初始化if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 如果hash对应的哈希槽是空的,就直接放入if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);else {Node<K,V> e; K k;// 如果已经存在key,就替换旧值if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))e = p;// 如果已经是树节点,就用putTreeVal遍历树赋值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);// 如果链表长度大于8,则转换为红黑树if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}// 找到key对应的节点则跳出遍历if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}// e是最后指向的节点,如果不为空,说明已经存在key,则替换旧的valueif (e != null) { // existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}// 新增节点时结构改变modCount加1++modCount;if (++size > threshold)resize();afterNodeInsertion(evict);return null;
}

具体过程如图所示:

2. 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) {// 如果hash桶已经完成初始化,并且已达最大容量,则直接返回if (oldCap >= MAXIMUM_CAPACITY) {threshold = Integer.MAX_VALUE;return oldTab;}// 如果扩大2倍没有超过最大容量,则扩大两倍else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold}// 如果threshold已经初始化,则初始化容量为thresholdelse if (oldThr > 0)      // initial capacity was placed in thresholdnewCap = oldThr;// 如果threshold和哈希桶都没有初始化,则使用默认值else {                    // zero initial threshold signifies using defaultsnewCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}// 重新计算thresholdif (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;}// 节点重新放置后位置+oldCapelse {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的幂,所以在扩大两倍之后原来的节点只可能在原位置或者原位置+oldCap,具体判断是通过(e.hash & oldCap) == 0实现的;

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

如图所示:

3. get方法

public V get(Object key) {Node<K,V> e;return (e = getNode(hash(key), key)) == null ? null : e.value;
}final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {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);}}return null;
}

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

4. clone方法

public Object clone() {HashMap<K,V> result;try {result = (HashMap<K,V>)super.clone();} catch (CloneNotSupportedException e) {// this shouldn't happen, since we are Cloneablethrow new InternalError(e);}result.reinitialize();result.putMapEntries(this, false);return result;
}

对于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

转载于:https://www.cnblogs.com/sanzao/p/10245212.html

JDK源码分析(5)之 HashMap 相关相关推荐

  1. JDK源码分析--HashMap深入理解

    一.实现原理 以JDK1.7源码为例进行分析 (一)Hashing的概念 将字符串转换成固定长度(一般是更短的长度)的数值或索引值的方法,也称为散列法或哈希法.常用于数据库中建索引,或是用于各种加解密 ...

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

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

  3. 【JDK】JDK源码分析-CountDownLatch

    概述 CountDownLatch 是并发包中的一个工具类,它的典型应用场景为:一个线程等待几个线程执行,待这几个线程结束后,该线程再继续执行. 简单起见,可以把它理解为一个倒数的计数器:初始值为线程 ...

  4. JDK源码分析(2)LinkedList

    JDK版本 LinkedList简介 LinkedList 是一个继承于AbstractSequentialList的双向链表.它也可以被当作堆栈.队列或双端队列进行操作. LinkedList 实现 ...

  5. StringBuffer类【JDK源码分析】

    StringBuffer类[JDK源码分析] 前言 推荐 说明 StringBuffer类 基本信息 属性 构造方法 部分方法 length capacity append insert revers ...

  6. LIRE原理与源码分析(二)——相关接口

    1. LIRE原理与源码分析(二)-- 代码结构 2. LIRE原理与源码分析(二)-- 相关接口 上一篇文章介绍了LIRE的基本内容和源码的代码结构.本文针对LIRE中主要的三个接口(LireFea ...

  7. JDK源码分析(三)——HashMap 下(基于JDK8)

    目录 概述 内部字段及构造方法 哈希值与索引计算 存储元素 扩容 删除元素 查找元素 总结 概述   在上文我们基于JDK7分析了HashMap的实现源码,介绍了HashMap的加载因子loadFac ...

  8. JDK源码分析 NIO实现

    总列表:http://hg.openjdk.java.net/ 小版本:http://hg.openjdk.java.net/jdk8u jdk:http://hg.openjdk.java.net/ ...

  9. jdk源码分析书籍 pdf_如何阅读源码?

    点击上方"IT牧场",选择"设为星标" 技术干货每日送达! 阅读源码是每个优秀开发工程师的必经之路,那么这篇文章就来讲解下为什么要阅读源码以及如何阅读源码. 首 ...

最新文章

  1. HP-UX 六大虚拟化技术之“分区”
  2. 面试让你手写SQL的时候,你慌了没?
  3. 创建一个坚固的备份系统
  4. python 物理引擎 摩擦力_参赛作品2-phenom的2D物理引擎
  5. master节点部署pod_小伙!Kubernetes 部署如此简单,你看完全明白了
  6. Asp.net在线备份、压缩和修复Access数据库
  7. hibernate4.3 无法获取数据库最新值
  8. TensorFlow2.0学习使用笔记
  9. php redis 里面的hscan 第四个参数count很不靠谱
  10. fgo服务器维护补偿什么时候才有,FGO11月02日临时维护公告 补偿奖励一览
  11. c语言大作业宿舍管理系统,数据库大作业——学生宿舍管理系统
  12. 语音识别之Fbank特征提取工具的比较(kaldi、python_speech_features、torchaudio)
  13. C语言入门(一)——程序的基本概念
  14. vsual studio 如何关闭禁止mscorsvw.exe (转)
  15. 3、HeidiSQL数据库管理工具下载与安装
  16. cout后面输出时加endl和不加endl的区别
  17. 还不知道什么是分布式存储?赶紧来学习一下FusionStorage吧。
  18. 基因家族进化分析之DNA序列批量获取
  19. Android学习笔记_28_手势识别
  20. matlab如何使用源代码,rosenbrock函数的matlab源程序代码是怎么样的?

热门文章

  1. 利用ssh反向代理以及autossh实现从外网连接内网服务器
  2. NOIP2008 普及组T4 立体图 解题报告-S.B.S.(施工未完成)
  3. ASP.NET 对类进行XML序列化和反序列化
  4. VMware共享文件夹遇到的问题
  5. GridView 移除模板列
  6. postman提取返回值
  7. 高德地图如何将比例尺放大到10米?
  8. 第十二章 Shell脚本编写及常见面试题(三)
  9. Jenkins_获取源码编译并启动服务(二)
  10. zabbix通过JMX监控Tomcat及一些报错