HashMap 的实现原理

  hashMap用了一个名字为table的数组;还有若干个名字为entry的链表。看hashMap是如何应用这些数据结构的。用插 入<key,value>举例:hashMap首先会通过key得到其hashCode,具体的hash函数就不说了(因为没多大意义);然 后把key的hashCode%table.length,就是拿hashCode模table数组大小,得到的余数就是key所在table数组中的下 标(实际不是key的下标,是entry类);但这样做有个问题,可能不同key却有一样的hasdCode,所以求余后其必然会得到相同的下标,那如何 存储了?有两个办法,一种是利用开放地址法,就是说后来相同的hashCode去找先来hashCode所在下标的相邻下标。说的有点绕口,举个例子,比 如<1,2>已经存在table数组的31的位置上了,再来一个<101,102>,其通过哈希后说:我也应该在31的位置上, 但是table说,你后来,你再在31附近找个空位安置下吧。当然,具体怎么找,有规则的。另外一种方式就是链地址法,还是拿以上的例子 说,<101,102>来到时,发现31的位置已经被占了,这时table说:<1,2>,你带 下<101,102>;其实就是要<1,2>把<101,102>的引用存储了。但是<1,2>说:我 怎么存储<101,102>的引用了,我没位置呀。所以table说:我给你们每个壳(entry类)吧,把你们都封装了;于是就有了entry类。

  那hashMap是使用那种方式了。先分析下开放地址和链地址法的优缺点。开放地址法一般需要2倍实际数据大小的空间,因为要留下一定的空闲地址去存储相 同hashCode的<key,value>;并且查找相邻空闲地址也是一项比较费时间的任务;链地址法,就不需要2倍的空间(table数 组),但是需要存储额外的信息,比如next信息;总体来看,链地址法好点(关键是节省了查找相邻地址的时间),所以,hashMap用的是链地址法。

  还有问题,hashMap为什么用数组存储index(hashCode%table.length)了,而不用链表了?因为数组有固定大小限制,而链表 没有,而且map是没有限制大小的?这主要考虑了查找效率的问题。从前面的分析可以看到,因为key的hashCode%table.length直接做 为entry的下标,所以其查询key的速度很快,只要O(1)的时间;如果是链表,要一个一个的排查对比,需要O(N)的时间;这之间的效率,相差太远 了。所以,hashMap用了数组。

  最后一个问题,那数组的固定大小如何解决了?hashMap在每次插入数据前,会检查table数组的实际容量,如果实际容量>=初始容量,则把 table的初始容量扩为原来的2倍,这时,就需要一个一个复制原来的数据项了,这是比较费时的!所以,初始容量很重要。

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

从图中我们可以看到一个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);
}

  当length=2^n时,hashcode & (length-1) == hashcode % length

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

  看下图,左边两组是数组长度为16(2的4次方),减一之后就是二进制的1111,右边两组是数组长度为15,减一之后就是二进制的1110。两组的hashcode均为9(1001)和8(1000),但是很明显,当它们和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比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的问题。

4、key的hashcode与equals方法改写

  在第一部分hashmap的数据结构中,annegu就写了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有了一定的理解。

时间: 2024-10-29 04:05:08

HashMap 的实现原理的相关文章

HashMap的工作原理

这是一节让你深入理解hash_map的介绍,如果你只是想囫囵吞枣,不想理解其原理,你倒是可以略过这一节,但我还是建议你看看,多了解一些没有坏处. hash_map基于hash table(哈希表).哈希表最大的优点,就是把数据的存储和查找消耗的时间大大降低,几乎可以看成是常数时间:而代价仅仅是消耗比较多的内存.然而在当前可利用内存越来越多的情况下,用空间换时间的做法是值得的.另外,编码比较容易也是它的特点之一. 其基本原理是:使用一个下标范围比较大的数组来存储元素.可以设计一个函数(哈希函数,也

HashMap的实现原理 HashMap底层实现,hashCode如何对应bucket?

韩梦飞沙  韩亚飞  [email protected]  yue31313  han_meng_fei_sha 数组和链表组合成的链表散列结构,通过hash算法,尽量将数组中的数据分布均匀,如果hashcode相同再比较equals方法,如果equals方法返回false,那么就将数据以链表的形式存储在数组的对应位置,并将之前在该位置的数据往链表的后面移动,并记录一个next属性,来指示后移的那个数据.注意数组中保存的是entry,其中保存的是键值. ======= HashMap的数据结构是

Java中HashMap底层实现原理(JDK1.8)源码分析

这几天学习了HashMap的底层实现,但是发现好几个版本的,代码不一,而且看了Android包的HashMap和JDK中的HashMap的也不是一样,原来他们没有指定JDK版本,很多文章都是旧版本JDK1.6.JDK1.7的.现在我来分析一哈最新的JDK1.8的HashMap及性能优化. 在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里.但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效

从头认识java-15.7 Map(4)-介绍HashMap的工作原理-hash碰撞(经常作为面试题)

这一章节我们来讨论一下hash碰撞. 1.什么是hash碰撞? 就是两个对象的key的hashcode是一样的,这个时候怎么get他的value呢? 答案是通过equals遍历table那个位置上面的Entry链表. 2.例子 正常的例子: package com.ray.ch14; import java.util.HashMap; public class Test { public static void main(String[] args) { HashMap<Person, Dog>

从头认识java-15.7 Map(2)-介绍HashMap的工作原理-put方法

这一章节我们来介绍HashMap的工作原理. 1.HashMap的工作原理图 下图引用自:http://www.admin10000.com/document/3322.html 2.HashMap初始化的时候我们可以这样理解:一个数组,每一个位置存储的是一个链表,链表里面的每一个元素才是我们记录的元素 3.下面我们来看put的源码: public V put(K key, V value) { if (key == null) return putForNullKey(value); int

深入Java集合学习系列:HashMap的实现原理

参考文献 引用文献:深入Java集合学习系列:HashMap的实现原理,大部分参考这篇博客,只对其中进行稍微修改 自己曾经写过的:Hashmap实现原理 1. HashMap概述: HashMap是基于哈希表的Map接口的非同步实现(Hashtable跟HashMap很像,唯一的区别是Hashtalbe中的方法是线程安全的,也就是同步的).此实现提供所有可选的映射操作,并允许使用null值和null键.此类不保证映射的顺序,特别是它不保证该顺序恒久不变. 2. HashMap的数据结构: 在ja

java 关于 hashmap 的实现原理的测试

网上关于HashMap的工作原理的文章多了去了,所以我也不打算再重复别人的文章.我就是有点好奇,我怎么样能更好的理解他的原理,或者说使用他的特性呢?最好的开发就是测试~ 虽说不详讲hashmap的工作原理,但是起码的常识还是要提一下的. 一句话:hashmap最直观的表现是一维数组或者说一维字典,但是每个每个值又可以指向另一个数组或都字典! 一张图: 其实说实话,给我个人的感觉是,说链表只是显得高大上些罢了,当然这片面的理解,不过也是令人误解的地方. 我曾经就以为,链表真是个高大上的东西,那我怎

深入理解Java中的HashMap的实现原理

HashMap继承自抽象类AbstractMap,抽象类AbstractMap实现了Map接口.关系图例如以下所看到的: Java中的Map<key, value>接口同意我们将一个对象作为key.也就是能够用一个对象作为key去查找还有一个对象. 在我们探讨HashMap的实现原理之前,我们先自己实现了一个SimpleMap类,该类继承自AbstractMap类. 详细实现例如以下: import java.util.*; public class SimpleMap<K,V>

HashMap底层实现原理/HashMap与HashTable区别/HashMap与HashSet区别

Hash算法 Hash,一般翻译做"散列",也有直接音译为"哈希"的,就是把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值.这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值.简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数. HASH主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128

转载: HashMap的工作原理

摘要 HashMap在java项目中占有举足轻重的地位,所以了解HashMap的工作原理很有必要. 1.前言 在探讨HashMap源码之前,先说一下HashCode,为什么呢?因为HashMap有一个特性是Key是唯一值,如何确定key的唯 一性呢,这就用到了hash算法.在HashMap(jdk1.7)的put方法实现中首先利用了hash()生成key的hashCode,然后比较 key的hashCode是否已经存在集合,如果不存在,就插入到集合,如果已存在,则返回null. 1.1 hash