Java HashMap原理解析

本文分析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的理解,我觉得一张图就够了:

LinkedHashMap结构图

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

双向循环链表图

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

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

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

private final boolean accessOrder;

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

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


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • HashMap 是 Java 面试必考的知识点,面试官从这个小知识点就可以了解我们对 Java 基础的掌握程度。网...
    野狗子嗷嗷嗷阅读 6,649评论 9 107
  • 前言 今天来介绍下HashMap,之前的List,讲了ArrayList、LinkedList,就前两者而言,反映...
    嘟爷MD阅读 2,866评论 2 56
  • 曾经天真的以为会是他手心里的宝、以为是那个可以随时安心停靠的港湾、以为会是一辈子的依靠……那么多的以为终究只是一厢...
    独孤晚晴阅读 283评论 0 1
  • 月亮没那么圆了 月饼没那么甜了 人儿都走散了 故乡的天不见了 故乡的人别离了 别了只有再见了 再见又该很久了
    L志华阅读 131评论 0 0
  • 前几天朋友倩向我抱怨一位追求者的行动让她烦恼不已。原来那男生从她高中开始喜欢她,现在大学又在同一个城市,于是便一直...
    snowinglemon阅读 528评论 0 4