HashMap是我们在日常写代码时最常用到的一个数据结构,它为我们提供key-value形式的数据存储。同时,它的查询,插入效率都非常高。

在之前的排序算法总结里面里,我大致学习了HashMap的实现原理,并制作了一个简化版本的HashMap。 今天,趁着项目的间歇期,我又仔细阅读了Java中的HashMap的实现。

HashMap的初始化:

Java代码 
  1. public HashMap(int initialCapacity, float loadFactor)
  2. public HashMap(int initialCapacity)
  3. public HashMap()
  4. public HashMap(Map<? extends K, ? extends V> m)

最近看到几篇精彩的文章:

存取之美 —— HashMap原理、源码、实践

Hash碰撞与拒绝服务攻击

这些文章让我收获良多, 但是有些地方说的不够详细, 在此写下本人对上述文章的总结和理解, 希望可以给需要的朋友带来一些帮助.

1. 概述

HashMap在底层采用数组+链表的形式存储键值对.

在HashMap中定义了一个内部类Entry<K, V>, 该内部类是对key-value的抽象. Entry类包含4个成员: key, value, hash, next. key和value的意义很清晰, hash表示key的hash值, next是指向下一个Entry对象的引用.

HashMap内部维护了一个Entry<K, V>[] table, 数组table中的Entry元素是一个Entry链表的头结点(理解这一点很重要).

2. put/get方法

向HashMap中添加键值对时, 程序会根据key的hashCode值计算出hash值, 然后对hash值取模, 模数是table.length. 假如取模的结果为index, 则取出table[index]. table[index]可能为null, 也可能是一个Entry对象. 如果为null, 则直接存储. 否则计算key.equals(table[index].key), 如果为false, 就取出table[index].next, 继续调用key的equals方法, 直到equals方法返回true, 或者比较完链表中所有Entry对象.

Java代码 
  1. public V put(K key, V value) {
  2. if (key == null)
  3. return putForNullKey(value);
  4. // 对hashCode值进行二次hash得到最终的hash值
  5. int hash = hash(key.hashCode());
  6. // 根据hash值定位数组中的索引位置
  7. int i = indexFor(hash, table.length);
  8. // 遍历table[i]位置处的链表
  9. for (Entry<K, V> e = table[i]; e != null; e = e.next) {
  10. Object k;
  11. // 如果hash值相同且equals返回true, 则替换原来的value值
  12. if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
  13. V oldValue = e.value;
  14. e.value = value;
  15. e.recordAccess(this);
  16. return oldValue;
  17. }
  18. }
  19. modCount++;
  20. // 如果之前函数没有return, 将该键值对插入table[i]链表中
  21. addEntry(hash, key, value, i);
  22. return null;
  23. }

理解了put方法, 那么get方法就会很容易理解:

Java代码 
  1. public V get(Object key) {
  2. if (key == null)
  3. return getForNullKey();
  4. int hash = hash(key.hashCode());
  5. // 首先根据hash值计算index, 然后取出index处的链表的头结点. 遍历链表.
  6. for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
  7. Object k;
  8. if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
  9. return e.value;
  10. }
  11. return null;
  12. }

3. HashMap的容量和索引位置确定

前面没有叙述HashMap的容量问题, 是因为容量是与索引位置计算紧密相关的.

理解HashMap的容量就需要关注成员变量size, loadFactor, threshold.

size表示HashMap中实际包含的键值对个数.

loadFactor表示负载因子, loadFactor的值越大, 则对table数组的利用率越大, 相当于节省内存空间. 但是loadFactor的值增大, 同时也会导致hash冲突的概率增加, 从而使得程序效率降低. loadFactor的取值应该兼顾内存空间和效率, 默认值为0.75.

threshold表示极限容量, 计算公式为threshold = (int)(capacity * loadFactor);  当size达到threshold时, 就需要对table数组扩容.

HashMap的容量大小就是table.length. 由于java中取模是一个效率低下的操作, 所以出于性能的考虑, HashMap的容量被设计为2的N次方. 如此hash%table.length就可以转换为hash&(table.length-1). 与运算的效率比取模运算高效很多.

Java代码 
  1. public HashMap(int initialCapacity, float loadFactor) {
  2. if (initialCapacity < 0)
  3. throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
  4. if (initialCapacity > MAXIMUM_CAPACITY)
  5. initialCapacity = MAXIMUM_CAPACITY;
  6. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  7. throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
  8. // 计算大于initialCapacity的最小的2的N次方数
  9. int capacity = 1;
  10. while (capacity < initialCapacity)
  11. capacity <<= 1;
  12. this.loadFactor = loadFactor;
  13. // 求出极限容量
  14. threshold = (int) (capacity * loadFactor);
  15. // table的容量被设计为2的N次方
  16. table = new Entry[capacity];
  17. init();
  18. }

如果使用无参的构造函数创建HashMap, 则容量默认为16, 负载因子默认为0.75.

indexFor函数用于确定索引位置:

Java代码 
  1. static int indexFor(int h, int length) {
  2. // 当length为2的N次方时相当于h%table.length, 但效率要高效很多
  3. return h & (length - 1);
  4. }

4. rehash

前面提到过, 当size达到threshold时, 就需要对table数组扩容. 调用put函数向HashMap中插入一个键值对时会调用到addEntry(hash, key, value, i)方法:

Java代码 
  1. void addEntry(int hash, K key, V value, int bucketIndex) {
  2. // 取出索引处的Entry对象
  3. Entry<K, V> e = table[bucketIndex];
  4. // 更新索引处链表的头结点, 并使新的头结点的next属性指向原来的头结点
  5. table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
  6. // 当size大于threshold时扩容数组, 容量增加至原来的2倍. 保证table的容量始终是2的N次方
  7. if (size++ >= threshold)
  8. resize(2 * table.length);
  9. }

resize用于扩容数组. 数组的length增加大了, 那么HashMap中已有的键值对就必须重新进行hash, 这就是rehash. 如果不进行rehash, 就会使得put和get时table数组的length不同, 从而导致get方法无法取出原先put存入的键值对.

Java代码 
  1. void resize(int newCapacity) {
  2. Entry[] oldTable = table;
  3. int oldCapacity = oldTable.length;
  4. if (oldCapacity == MAXIMUM_CAPACITY) {
  5. threshold = Integer.MAX_VALUE;
  6. return;
  7. }
  8. Entry[] newTable = new Entry[newCapacity];
  9. transfer(newTable);
  10. table = newTable;
  11. threshold = (int) (newCapacity * loadFactor);
  12. }
  13. void transfer(Entry[] newTable) {
  14. Entry[] src = table;
  15. int newCapacity = newTable.length;
  16. // 对已有的键值对进行rehash
  17. for (int j = 0; j < src.length; j++) {
  18. // 得到j处的链表的头结点
  19. Entry<K, V> e = src[j];
  20. // 遍历链表
  21. if (e != null) {
  22. src[j] = null;
  23. do {
  24. // 进行rehash
  25. Entry<K, V> next = e.next;
  26. int i = indexFor(e.hash, newCapacity);
  27. e.next = newTable[i];
  28. newTable[i] = e;
  29. e = next;
  30. } while (e != null);
  31. }
  32. }
  33. }

从源码可以看出, rehash对性能的影响是非常大的, 因此我们应该尽量避免rehash的发生. 这就要求我们预估需要存入HashMap的键值对的数量, 根据数量在创建HashMap对象时指定合适的容量和负载因子.

5. hash碰撞和HashMap的退化

hash碰撞在HashMap中的表现为: 不同的key, 计算出相同的index. 如果对所有的key调用indexFor方法的返回值都是相同的, 那么HashMap就退化为链表, 这对性能的影响也是非常大的. 几个月前的闹得沸沸扬扬的hash碰撞攻击就是基于这样的原理.

常用的web框架都会将请求中的参数保存在HashMap(或HashTable)中, 如果客户端根据Web应用框架采用的Hash函数来通过某种Hash攻击的方式获得大量的碰撞, 那么HashMap就会退化为链表, 服务器有可能处理一次请求要花上十几分钟甚至几个小时的时间...

6. 线程安全

HashMap是线程不安全的, 如果遍历HashMap的过程中修改了HashMap, 那么就会抛出java.util.ConcurrentModificationException异常:

Java代码 
  1. final Entry<K, V> nextEntry() {
  2. if (modCount != expectedModCount)
  3. throw new ConcurrentModificationException();
  4. Entry<K, V> e = next;
  5. if (e == null)
  6. throw new NoSuchElementException();
  7. if ((next = e.next) == null) {
  8. Entry[] t = table;
  9. while (index < t.length && (next = t[index++]) == null)
  10. ;
  11. }
  12. current = e;
  13. return e;
  14. }

modCount是HashMap的成员变量, 用于表示HashMap的状态. expectedModCount是遍历初始时modCount的值. 如果在遍历过程中改变了modCount的值就会导致modCount和expectedModCount不相等, 从而抛出异常. put, clear, remove等方法都会导致modCount的值改变.

java jdk 中HashMap的源码解读的更多相关文章

  1. Java中HashMap的源码分析

    先来回顾一下Map类中常用实现类的区别: HashMap:底层实现是哈希表+链表,在JDK8中,当链表长度大于8时转换为红黑树,线程不安全,效率高,允许key或value为null HashTable ...

  2. 浅析JDK中ServiceLoader的源码

    前提 紧接着上一篇<通过源码浅析JDK中的资源加载>,ServiceLoader是SPI(Service Provider Interface)中的服务类加载的核心类,也就是,这篇文章先介 ...

  3. java.io.BufferedWriter API 以及源码解读

    下面是java se 7 API 对于java.io.BufferedWriter 继承关系的描述. BufferedWriter可以将文本写入字符流.它会将字符缓存,目的是提高写入字符的效率. bu ...

  4. java.io.writer API 以及 源码解读

    声明 我看的是java7的API文档. 如下图所示,java.io.writer 继承了java.lang.Object,实现的接口有Closeable, Flushable, Appendable, ...

  5. go中sync.Cond源码解读

    sync.Cond 前言 什么是sync.Cond 看下源码 Wait Signal Broadcast 总结 sync.Cond 前言 本次的代码是基于go version go1.13.15 da ...

  6. go中sync.Mutex源码解读

    互斥锁 前言 什么是sync.Mutex 分析下源码 Lock 位运算 Unlock 总结 参考 互斥锁 前言 本次的代码是基于go version go1.13.15 darwin/amd64 什么 ...

  7. go中semaphore(信号量)源码解读

    运行时信号量机制 semaphore 前言 作用是什么 几个主要的方法 如何实现 sudog 缓存 acquireSudog releaseSudog semaphore poll_runtime_S ...

  8. go中sync.Once源码解读

    sync.Once 前言 sync.Once的作用 实现原理 总结 sync.Once 前言 本次的代码是基于go version go1.13.15 darwin/amd64 sync.Once的作 ...

  9. JDK容器类Map源码解读

    java.util.Map接口是JDK1.2开始提供的一个基于键值对的散列表接口,其设计的初衷是为了替换JDK1.0中的java.util.Dictionary抽象类.Dictionary是JDK最初 ...

随机推荐

  1. HTML每日学习笔记(0)

    2019.7.14 1.属性为 HTML 元素提供附加信息,总是在 HTML 元素的开始标签中规定. 例子:<h1 align="center"> 对齐方式 <b ...

  2. ProjectTool写白包工具,秒级别写H5游戏壳包,可视化操作,极易使用,支持Swift、Objecive-C双语言

    这是自动写白包工具,秒级别写H5游戏壳包,可视化操作,极易使用,支持Swift.Objecive-C双语言 扣扣交流群:811715780 [ 官网下载 ] 这是一个白包目录示例 ProjectToo ...

  3. shell脚本基础-起始句的含义

    大部分的shell脚本第一行,要么是 #!/bin/bash 要么是 #!/bin/sh 其实第二种是第一种的升级版,增加了协议posix(#!/bin/sh = #!/bin/bash + posi ...

  4. shell编程学习之使用jq对json数据进行提取

    shell编程学习之使用jq对json提取 jq命令允许直接在命令行下对JSON进行操作,包括分片.过滤.转换等 ,jq是用C编写,没有运行时依赖,所以几乎可以运行在任何系统上.预编译的二进制文件可以 ...

  5. 常见排序算法总结与分析之交换排序与插入排序-C#实现

    前言 每每遇到关于排序算法的问题总是不能很好的解决,对一些概念,思想以及具体实现的认识也是模棱两可.归根结底,还是掌握不够熟练.以前只是看别人写,看了就忘.现在打算自己写,写些自己的东西,做个总结.本 ...

  6. SpringBoot源码分析(二)启动原理

    Springboot的jar启动方式,是通过IOC容器启动 带动了Web容器的启动 而Springboot的war启动方式,是通过Web容器(如Tomcat)的启动 带动了IOC容器相关的启动 一.不 ...

  7. 【分布式锁】06-Zookeeper实现分布式锁:可重入锁源码分析

    前言 前面已经讲解了Redis的客户端Redission是怎么实现分布式锁的,大多都深入到源码级别. 在分布式系统中,常见的分布式锁实现方案还有Zookeeper,接下来会深入研究Zookeeper是 ...

  8. Android 文本TextView底部对齐实现

    前言 想要实现主体文字突出显示,前面是数值加粗,后面是单位符号,且底部对齐数值显示的效果:但是不同TextView字体大小排版后总是有些差别,无法底部对齐.百度一番后有重写TextView的,还有其它 ...

  9. git 分支的创建与合并

    首先我们需要先创建一个新的dev分支,然后切换到dev分支: $ git checkout -b dev //命令语句 Switched to a new branch 'dev' //成功执行输出语 ...

  10. KDD 2019放榜!录取率仅14%,强调可重现性

    [导读]KDD 2019录取结果终于放榜了,今年Research和ADS两个 track共评审论文1900篇,其中Research track的录取率只有14%.今年也是KDD第一次采用双盲评审政策, ...