1、hashmap 的数据结构

要知道 hashmap 是什么,首先要搞清楚它的数据结构,在 java 编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap 也不例外。Hashmap 实际上是一个数组和链表的结合体(在数据结构中,一般称之为 “链表散列 “),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。



注:关于hashcode和equals方法的解释,可以看

Java面试知识点(一)hashmap、hashtable和hashset

第5个知识点

从图中我们可以看到一个 hashmap 就是一个数组结构,当新建一个 hashmap 的时候,就会初始化一个数组。我们来看看 java 代码:

	/**
* The table, resized as necessary. Length MUST Always be a power of two.
* FIXME 这里需要注意这句话,至于原因后面会讲到
*/
transient Entry[] table; static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
final int hash;
Entry<K,V> next;
..........
}

上面的 Entry 就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。

当我们往 hashmap 中 put 元素的时候,先根据 key 的 hash 值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从 hashmap 中 get 元素时,首先计算 key 的 hashcode,找到数组中对应位置的某一元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么 hashmap 的 get 效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服,哈哈~

2、hash 算法

我们可以看到在 hashmap 中要找到某个元素,需要根据 key 的 hash 值来求得对应数组中的位置。如何计算这个位置就是 hash 算法。前面说过 hashmap 的数据结构是数组和链表的结合,所以我们当然希望这个 hashmap 里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。

所以我们首先想到的就是把 hashcode 对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模” 运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java 中时这样做的,

	static int indexFor(int h, int length) {
return h & (length-1);
}

首先算得 key 得 hashcode 值,然后跟数组的长度 - 1 做一次 “与” 运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是 2 的 4 次方,那么 hashcode 就会和 2 的 4 次方 - 1 做 “与” 运算。很多人都有这个疑问,为什么 hashmap 的数组初始化大小都是 2 的次方大小时,hashmap 的效率最高,我以 2 的 4 次方举例,来解释一下为什么数组大小为 2 的幂时 hashmap 访问的性能最高。

看下图,左边两组是数组长度为 16(2 的 4 次方),右边两组是数组长度为 15。两组的 hashcode 均为 8 和 9,但是很明显,当它们和 1110 “与” 的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8 和 9 会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到 8 或者 9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为 15 的时候,hashcode 的值会与 14(1110)进行 “与”,那么最后一位永远是 0,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!



所以说,当数组长度为 2 的 n 次幂的时候,不同的 key 算得得 index 相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

说到这里,我们再回头看一下 hashmap 中默认的数组大小是多少,查看源代码可以得知是 16,为什么是 16,而不是 15,也不是 20 呢,看到上面 annegu 的解释之后我们就清楚了吧,显然是因为 16 是 2 的整数次幂的原因,16-1的二进制是 1 1 1 1 ,能够尽量报错hash值的结果,在小数据量的情况下 16 比 15 和 20 更能减少 key 之间的碰撞,而加快查询的效率。

所以,在存储大容量数据的时候,最好预先指定 hashmap 的 size 为 2 的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的 2 次幂来初始化的,代码如下 (HashMap 的构造方法中):

// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;

3、hashmap 的 resize

当 hashmap 中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对 hashmap 的数组进行扩容,数组扩容这个操作也会出现在 ArrayList 中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的 “均摊” 原理,就释然了,而在 hashmap 数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是 resize。

那么 hashmap 什么时候进行扩容呢?当 hashmap 中的元素个数超过数组大小 * loadFactor 时,就会进行数组扩容,loadFactor 的默认值为 0.75,也就是说,默认情况下,数组大小为 16,那么当 hashmap 中元素个数超过 16*0.75=12 的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 hashmap 中元素的个数,那么预设元素的个数能够有效的提高 hashmap 的性能。比如说,我们有 1000 个元素 new HashMap (1000), 但是理论上来讲 new HashMap (1024) 更合适,不过上面 annegu 已经说过,即使是 1000,hashmap 也自动会将其设置为 1024。 但是 new HashMap (1024) 还不是更合适的,因为 0.75*1000 < 1000, 也就是说为了让 0.75 * size > 1000, 我们必须这样 new HashMap (2048) 才最合适,既考虑了 & 的问题,也避免了 resize 的问题。

  • 如果需要扩容,调用扩容的方法 resize ()

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()方法把原数组中的值放到新数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //设置hashmap扩容后为新的数组引用
    table = newTable;
    //设置hashmap扩容新的阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
  }
  • transfer () 在实际扩容时候把原来数组中的元素放入新的数组中

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);
        }
        //通过key值的hash值和新数组的大小算出在当前数组中的存放位置
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
      }
    }
  }

4、key 的 hashcode 与 equals 方法改写

在第一部分 hashmap 的数据结构中,就写了 get 方法的过程:首先计算 key 的 hashcode,找到数组中对应位置的某一元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的元素。所以,hashcode 与 equals 方法对于找到对应元素是两个关键方法。

Hashmap 的 key 可以是任何类型的对象,例如 User 这种对象,为了保证两个具有相同属性的 user 的 hashcode 相同,我们就需要改写 hashcode 方法,比方把 hashcode 值的计算与 User 对象的 id 关联起来,那么只要 user 对象拥有相同 id,那么他们的 hashcode 也能保持一致了,这样就可以找到在 hashmap 数组中的位置了。如果这个位置上有多个元素,还需要用 key 的 equals 方法在对应位置的链表中找到需要的元素,所以只改写了 hashcode 方法是不够的,equals 方法也是需要改写滴~当然啦,按正常思维逻辑,equals 方法一般都会根据实际的业务内容来定义,例如根据 user 对象的 id 来判断两个 user 是否相等。

在改写 equals 方法的时候,需要满足以下三点:

(1) 自反性:就是说 a.equals (a) 必须为 true。

(2) 对称性:就是说 a.equals (b)=true 的话,b.equals (a) 也必须为 true。

(3) 传递性:就是说 a.equals (b)=true,并且 b.equals (\c)=true 的话,a.equals (\c) 也必须为 true。

通过改写 key 对象的 equals 和 hashcode 方法,我们可以将任意的业务对象作为 map 的 key (前提是你确实有这样的需要)。

总结:

本文主要描述了 HashMap 的结构,和 hashmap 中 hash 函数的实现,以及该实现的特性,同时描述了 hashmap 中 resize 带来性能消耗的根本原因,以及将普通的域模型对象作为 key 的基本要求。尤其是 hash 函数的实现,可以说是整个 HashMap 的精髓所在,只有真正理解了这个 hash 函数,才可以说对 HashMap 有了一定的理解。

Java面试知识点(六)hashmap深度理解的更多相关文章

  1. Java 面试知识点解析(六)——数据库篇

    前言: 在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Java 知识点进行复习和学习一番,大 ...

  2. Java面试知识点汇总

    Java面试知识点汇总 置顶 2019年05月07日 15:36:18 温柔的谢世杰 阅读数 21623 文章标签: 面经java 更多 分类专栏: java 面试 Java面试知识汇总   版权声明 ...

  3. Java 面试知识点解析(二)——高并发编程篇

    前言: 在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Java 知识点进行复习和学习一番,大 ...

  4. Java 面试知识点解析(四)——版本特性篇

    前言: 在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Java 知识点进行复习和学习一番,大 ...

  5. Java 面试知识点解析(五)——网络协议篇

    前言: 在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Java 知识点进行复习和学习一番,大 ...

  6. Java 面试知识点解析(七)——Web篇

    前言: 在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Java 知识点进行复习和学习一番,大 ...

  7. Java 面试知识点解析(三)——JVM篇

    前言: 在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Java 知识点进行复习和学习一番,大 ...

  8. Java面试知识点之线程篇(二)

    前言:接上篇,这里继续对java线程相关知识点进行总结. 1.notify和notifyall的区别 notify()方法能够唤醒一个正在等待该对象的monitor的线程,当有多个线程都在等待该对象的 ...

  9. Java面试知识点之线程篇(三)

    前言:这里继续对java线程相关知识点进行总结,不能间断. 1.yield()方法 yield()的作用是让步.它能让当前线程由“运行状态”进入到“就绪状态”,从而让其它具有相同优先级的等待线程获取执 ...

  10. Java面试知识点之线程篇(一)

    前言:在Java面试中,一定会遇到线程相关问题,因此笔者在这里总结Java中有关线程方面知识点,多数从网上得来(文中会贴出主要参考链接),有些也是笔者在面试中所遇到的问题,如有错误,请不吝指正.主要参 ...

随机推荐

  1. STM32【HAL库】使用外部SRAM程序

    #include <board.h> #ifdef BSP_USING_SRAM #include <drv_common.h> #include <rtthread.h ...

  2. 习题8 #第8章 Verilog有限状态机设计-1 #Verilog #Quartus #modelsim

    1. 设计一个"111"串行数据检测器.要求是:当检测到连续3个或3个以上的"1"时输出为1,其他输入情况下输出为0. (1)思路分析:参照本章前文的范例,如第 ...

  3. vue使用websoket(非封装)

    上代码: create:function(){ this.initWebpack() } methods:{ initWebpack(){//初始化websocket           let us ...

  4. 什么是NASM

    什么是NASM NASM是一个汇编器的名称,全称是Netwide Assembler,支持x86与x64架构的CPU(注意不支持ARM架构). 我们知道,在x86和x64架构上有多个操作系统,比较流行 ...

  5. WPF 纯xaml实现控件运动动画

    1.Image控件加载后上下运动 2.Image控件可以放在Grid等布局控件中 3.指定加载时触发动画: EventTrigger RoutedEvent="Image.Loaded&qu ...

  6. python交教程4:文件操作

    文件操作流程 人类操作一个word流程: 1.找到文件.双击打开 2. 读或修改 3. 保存&关闭 ⽤python操作⽂件也差不多: 只读模式  创建模式  追加模式  遍历文件  图片视频- ...

  7. 数据库—SQL语言学习

    文章目录 SQL 数据类型 重要的关键字 定义数据库 数据库的文件 table创建与删除 表的定义 表的alter 表的删除 视图 定义视图 删除视图 更新视图 插入视图 视图总结 索引 SQL单表查 ...

  8. Istio(十二):Istio问题排查

    目录 一.模块概览 二.Envoy基础 2.1 Envoy 基础 三.Envoy示例 四.调试备忘录 4.1 配置 4.2 运行时 4.3 检查 Envoy 日志 4.4 配置 istiod 日志 一 ...

  9. 给大家分享一套非常棒的python机器学习课程

    给大家分享一套非常棒的python机器学习课程--<AI小天才:让小学生轻松掌握机器学习>,2024年5月完结新课,提供配套的代码+笔记+软件包下载!学完本课程,可以轻松掌握机器学习的全面 ...

  10. C#简易商城收银系统v1.0(2-1)

    C#简易商城收银系统v1.0(2-1) 当初: 面向编程对象的好处及应用简单工厂模式(继承,多态) 现在: 制作一个简易的收银窗体应用程序 可以参考之前的 计算器 随笔 创建窗体程序 客户端代码 us ...