本文分析HashMap的实现原理。

数据结构(散列表)

HashMap是一个散列表(也叫哈希表),用来存储键值对(key-value)映射。散列表是一种数组和链表的结合体,结构图如下:

简单来说散列表就是一个数组(上图纵向),数组的每个元素是一个链表(上图横向),类似二维数组。链表的每个节点就是我们存储的key-value数据(源码中将key和value封装成Entry对象作为链表的节点)。

哈希算法

对于散列表,不管是存值还是取值,都需要通过Key来定位散列表中的一个具体的位置(即某个链表的某个节点),计算这个位置的方法就是哈希算法。

大概过程是这样的:

  1. 用Key的hash值对数组长度做取余操作得到一个整数,这个整数作为数组中的索引得到这个索引位置的链表。
  2. 得到链表之后,就可以存值和取值了。
    如果是存值,直接把数据插入到链表的头部或者尾部即可(或者已存在就替换);
    如果是取值,就遍历链表,通过key的equals方法找到具体的节点。

例如一个key-value对要存到上图的散列表里,假设key的哈希值是17,由图可知(纵向)数组长度是16,那么17对16取余结果是1,数组中索引1位置的链表是 1->337->353 ,所以这个key-value对存储到这个链表里面(插到头还是尾可能不同Java版本不一样)。如果是取值,就遍历这个链表,由于这个链表每个节点的key的哈希值都一样,所以根据equals方法来确定具体是哪个节点。

通过上面的哈希算法,可以有如下结论:

  • 不同的key具体相同的哈希值叫做哈希冲突HashMap解决哈希冲突的方法是链表法,将具有相同哈希值的key放在同一个链表中,然后利用key类的equals方法来确定具体是哪个节点。
  • Key的唯一性是通过哈希值和equals方法共同决定的,所以想要用一个类作为HashMap的键,必须重写这个类的hashCode和equals方法。同理,HashSet是基于HashMap实现的,它没有重复元素的特点是利用HashMap没有重复键实现的。所以,Set集合里面的元素类,也必须同时实现hashCode方法和equals方法。
  • HashMap存储的数据是无序的。

为什么HashMap大小是2的整数次幂的时候效率最高

哈希算法主要分两步操作:1.通过哈希值定位一个链表; 2.遍历链表,通过equals方法找到具体节点。为了使哈希算法效率最高,应该尽量让数据在哈希表中均匀分布,因为那样可以避免出现过长的链表,也就降低了遍历链表的代价。
如何保证均匀分布?前面的哈希算法说到,通过取余操作将Key的哈希值转换成数组下标,这样可以认为是均匀的。但是,源码中并没有直接用%操作符取余,而是使用了更高效的与运算,源码如下:

/**
 * Returns index for hash code h.
 */
static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

这样就多了一些限制,因为只有当length是2的整数次幂的时候,h & (length-1) = h % length才成立。当然,如果length不是2的整数次幂,h & (length-1)的结果也一定比length小,将Key转换成数组下标也没什么问题,但是,这样会导致元素分布不均匀严重影响散列表的访问效率。看下面的一个示例代码:

解释一下图中的代码,随机生成一组Key,然后利用与运算,把key全部转换成一个数组容量的索引,这样就得到一组索引值,这组索引中不相同的值越多,说明分布越均匀,输出结果的result就是 “这组索引中不相同的值的数量”。
从运行结果来看,容量是64的时候相比于其他几个容量大小,分布是最均匀的。容量是65的时候,每次结果都是2,原因很简单,当容量是65的时候,下标=h&64,64的二进制是1000000,很明显,与它进行与运算的结果只有两种情况,0和64,也就是说,如果HashMap大小被指定成65,对于任意Key,只会存储到散列表数组的第0个或第64个链表中,浪费了63个空间,同时也导致0和64两个链表过长,取值的时候遍历链表的代价很高。容量66和67的结果是4同理。如果容量是64,那么下标=h&63,63的二进制是111111,每一位都是1,好处就是对于任意Key,与63做与运算的结果可能是1-63的任意数,很多Key的话自然就能分布均匀。
通过这个示例代码的分析就可以找到一个规律了,容量length=2^n 是分布最均匀,因为length-1的二进制每一位都是1;相反的length=2^n+1是分布最不均匀的,因为length-1的二进制中的1数量最少。

结论:HashMap大小是2的整数次幂的时候效率最高,因为这个时候元素在散列表中的分布最均匀。

从上面的分析来看,使用与运算虽然效率高了,但是增加了使用限制,如果用%取余的做法,那么对于任何大小的容量都能做到均匀分布,可以把图中代码int a = keySet[j] & (c - 1); 改成 int a = keySet[j] % c;试一下。

HashMap的容量

通过上面的分析,容量是2的整数次幂的时候效率最高,那么很容易想到,如果随着数据量的增长,HashMap需要扩容的时候是2倍扩容,区别于ArrayList的1.5倍扩容。
那么什么时候扩容呢?首先说明一下,我们所说的HashMap的容量是指散列表中数组的大小,这个大小不能决定HashMap能存多少数据,因为只要链表足够长,存多少数据都没问题。但是,数据量很大的时候,如果数组太小,就会导致链表很长,get元素的效率就会降低,所以我们应该在适当的时候扩容。源码默认的做法是,当数据量达到容量的75%的时候扩容,这个值称为负载因子,75%应该是大量实验后统计得到的最优值,没有特殊情况不要通过构造方法指定为其他值。
扩容是有代价了,会导致所有已存的数据重新计算位置,所以,和ArrayList一样,当知道大概的数据量的时候,可以指定HashMap的大小尽量避免扩容,指定大小要注意75%这个负载因子,比如数据量是63个的话,HashMap的大小应该是128而不是64。

对于容量的计算,源码已经封装好了一个方法

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

此方法在HashMap的构造方法中被调用,所以指定容量的时候无需自己计算,比如数据量是63,直接new HashMap<>(63)即可。

HashMap的遍历

前面提到一点,散列表中的链表的节点是Entry对象,通过Entry对象可以得到Key和Value。HashMap的遍历方法有很多,大概可以分为3种,分别是通过map.entrySet()、map.keySet()、map.values()三种方式遍历。比较效率的话,map.values()方式无法得到key,这里不考虑。比较map.entrySet()和map.keySet()的话,结合散列表的结构特点,很明显map.entrySet()直接遍历Entry集合(所有链表节点)取出Key和Value即可(一次循环),map.keySet()遍历的是Key,得到Key之后在通过Key去遍历相应的链表找到具体的节点(多个循环),所以前者效率高。

扩展:LinkedHashMap和LruCatch

对于LinkedHashMap的理解,我觉得一张图就够了:

在散列表的基础上加上了双向循环链表(图中黄色箭头和绿色箭头),所以可以拆分成一个散列表和一个双向链表,双向链表如下:

上面两张图片来自:https://www.cnblogs.com/xiaoxi/p/6170590.html

然后使用散列表操作数据,使用双向循环链表维护顺序,就实现了LinkedHashMap。

LinkedHashMap有一个属性可以设置两种排序方式:

private final boolean accessOrder;

false表示插入顺序,true表示最近最少使用次序,后者就是LruCatch的实现原理。

LinkedHashMap和LruCatch的具体实现细节这里就不分析了。

Java-HashMap原理解析的更多相关文章

  1. java集合框架之java HashMap代码解析

     java集合框架之java HashMap代码解析 文章Java集合框架综述后,具体集合类的代码,首先以既熟悉又陌生的HashMap开始. 源自http://www.codeceo.com/arti ...

  2. 【转载】Java类加载原理解析

    Java类加载原理解析 原文出处:http://www.blogjava.net/zhuxing/archive/2008/08/08/220841.html 1       基本信息 摘要: 每个j ...

  3. Java:HashMap原理与设计缘由

    前言 Java中使用最多的数据结构基本就是ArrayList和HashMap,HashMap的原理也常常出现在各种面试题中,本文就HashMap的设计与设计缘由作出一一讲解,并解答面试常见的一些问题. ...

  4. 深入理解Java类加载器(一):Java类加载原理解析

    摘要: 每个开发人员对java.lang.ClassNotFoundExcetpion这个异常肯定都不陌生,这个异常背后涉及到的是Java技术体系中的类加载机制.本文简述了JVM三种预定义类加载器,即 ...

  5. Java 7 和 Java 8 中的 HashMap原理解析

    HashMap 可能是面试的时候必问的题目了,面试官为什么都偏爱拿这个问应聘者?因为 HashMap 它的设计结构和原理比较有意思,它既可以考初学者对 Java 集合的了解又可以深度的发现应聘者的数据 ...

  6. Java集合详解(三):HashMap原理解析

    概述 本文是基于jdk8_271版本进行分析的. HashMap是Map集合中使用最多的.底层是基于数组+链表实现的,jdk8开始底层是基于数组+链表/红黑树实现的.HashMap也会动态扩容,与Ar ...

  7. Java类加载原理解析

    详见:http://blog.yemou.net/article/query/info/tytfjhfascvhzxcyt229 2       Java虚拟机类加载器结构简述 2.1    JVM三 ...

  8. 深入理解Java类加载器(1):Java类加载原理解析

    1 基本信息 每个开发人员对Java.lang.ClassNotFoundExcetpion这个异常肯定都不陌生,这背后就涉及到了java技术体系中的类加载.Java的类加载机制是技术体系中比较核心的 ...

  9. Java HashMap原理

    HashMap存储结构 HashMap中数据的存储是由数组与链表一起实现的 数组寻址非常容易,其时间复杂度为O(1),但是当要插入或删除数据时,时间复杂度就会变为O(n).链表插入和删除操作的内存复杂 ...

  10. 【转】Java类加载原理解析

    原链接 1 基本信息 每个java开发人员对java.lang.ClassNotFoundExcetpion这个异常肯定都不陌生,这背后就涉及到了java技术体系中的类加载. Java的类加载机制是j ...

随机推荐

  1. Splay的用法

    splay区间增减查询 #include<cstdio> #include<algorithm> using namespace std; ; const int INF = ...

  2. python基础学习笔记—— 多继承

    本节主要内容: 1.python多继承 2.python经典类的MRO 3.python新式类的MRO.C3算法 4.super是什么鬼? 一.python多继承 在前⾯的学习过程中. 我们已经知道了 ...

  3. 树状数组 - BZOJ 1103 [POI2007]大都市

    bzoj 1103 [POI2007]大都市 描述 在经济全球化浪潮的影响下,习惯于漫步在清晨的乡间小路的邮递员 Blue Mary也开始骑着摩托车传递邮件了.不过,她经常回忆起以前在乡间漫步的情景. ...

  4. PHP 函数 ignore_user_abort()

    ignore_user_abort 设置与客户机断开是否会终止脚本的执行.   本函数返回 user-abort 设置的之前的值(一个布尔值). int ignore_user_abort ([ st ...

  5. 微信小程序开发 -- 获取当前页面路径

    Page.prototype就是this: 你在任何一个Page里面都可以使用route字段和setData()函数: 示例代码: /** * 生命周期函数--监听页面加载 */ onLoad: fu ...

  6. javascript是脚本语言?javascript万物皆对象?

    呵呵哒!带你见识下js面对对象的魅力 是的是的,退后,朕要开始装逼了- 这是什么鸟东西?是的是的,装逼开始,2016年度最佳JS编译器,ES6标准出来后,小伙伴们对新特性摩拳擦掌,奈何浏览器支持把我们 ...

  7. [java开发篇][dom4j模块] 遍历xml文件

    http://blog.csdn.net/chenleixing/article/details/44353491 在android studio 导入dom4j库(build-gradle(Moud ...

  8. [Android Studio篇][1] AS开发中遇到问题汇总

    1 在android新建文件,提示权限不够,增加权限 修改工程下 main/AndroidMainfest.xml增加 <uses-permission android:name="a ...

  9. python 计算日期间隔

    from datetime import date a = date(2011,11,24) b = date(2011,11,17) print(a-b)

  10. [整理]Linux下的源码安装步骤及其功能解释

    源码的安装一般由3个步骤组成:配置(./configure).编译(make).安装(make install). 这时最常用的命令就是这三个--./configure && make ...