HashMap的底层实现相信是java开发者们面试都会碰到的问题。的确,它方便易用,在java开发中使用频率非常高。它的实现原理包含很多知识点,所以当面试官问到你HashMap的时候可就要当心了,因为如果基本功不够扎实的话,很容易被带到沟里哦,本文主要剖析HashMap的实现原理。首先搞清楚什么是HashMap,实际上从它的名字上就能明白大概。HashMap这个词可以分成两词,Hash和Map。其中Hash指哈希算法,Map则是映射。两个词组合在一起表示的通过哈希算法得到一个散列映射的集合,也就是一个Key对应一个Value的集合,先奉上HashMap的数据结构。
1.HashMap的实现
以下暂时说jdk1.8的HashMap实现,我们都知道在数据结构中,物理存储的数据结构只有两种,即数组和链表,其余的如树,队列,栈等数据结构都是在它们的基础上逻辑抽象而来。它们各有优势,数组存储区域连续,查找快,增删慢,而链表存储区域离散,查找慢,增删快,因此各有各的应用场景。并没有一种既满足查找快也满足增删快的基本数据结构。那能不能把数组和链表组合在一起构成一种新的数据结构呢?HashMap就此诞生了,它的底层就是通过数组加链表组合的方式实现了查找快和增删快的统一。那么怎么样的组合才能使两者达到平衡呢?下面我们来一探究竟,下图是HashMap实现的结构图。
从图中可以看出组成HashMap最基本的单元是Entry
1 | static class Node<K,V> implements Map.Entry<K,V> { |
在HashMap类中还定义了几个静态常量,这几个常量是一些很重要的属性,源码如下
1 | public class HashMap<K,V> extends AbstractMap<K,V> |
HashMap有两个有参构造器可以用来设置initialCapacity和loadFactor的值,即HashMap的初始容量和负载因子的值,如果不传参则都使用默认值。
2.HashMap的几个重要方法
(1)put方法
废话不多说,直接上源码,下面所有方法我都加上了注释,以方便阅读理解
1 | public V put(K key, V value) { |
源码中put方法调用了putval方法,OK,接下来我们来看看putval操作的实现吧
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, |
(2) treeify方法构建红黑树
此方法中比较重要的方法就是treefyBin方法了,此方法以当前节点为头节点,构建一个双向链表(双向链表:链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点
1 | final void treeifyBin(Node<K,V>[] tab, int hash) { |
然后在这个方法中构建红黑树,jdk1.8对HashMap的优化核心也在于此方法,可以重点看看
1 | final void treeify(Node<K,V>[] tab) { |
(3)resize方法对HashMap扩容
接下来看看扩容方法
1 | final Node<K,V>[] resize() { |
以上就是HashMpa中几个重要的方法,get方法相对来说比较简单就不做介绍了,当一个容量稍大的HashMap对象放在jvm中时,它数据的逻辑结构如下图
其中白底的虚线框是数组,也可以说是哈希桶,当不同的数据经过哈希计算后被放到同一个哈希桶时(哈希碰撞)这些数据会以链表的方式组合在一起,也就是图中绿底框。当有相当多的数据出现哈希碰撞时,会导致这个链表很长,在使用get方法时,需要遍历链表,我们知道链表优势是增删快,查找慢,这样get方法效率会很低。因此在jdk1.8中对这里做了优化,当链表长度不小于8时就将链表转化成红黑树,长度小于8时再转化为链表。既然红黑树能有效的提高查询速度(黑红树查找相当于二分查找),那为什么不一开始就把链表转化为红黑树,而是要在链表长度大于8时才做这样的处理呢?这里个人理解,红黑树固然能提高查询速度,但也带来了维护红黑树的成本。我们知道红黑树是一颗自平衡的二叉树,因此在插入数据时,有可能导致红黑树不平衡,这时需要翻转红黑树使它达到平衡状态。而8这个数字就是链表和数字在插入效率和查询效率上的折中处理,就如同HashMap的负载因子是0.75,而不是0.6或是0.8,是一个道理。
3 . HashMap的扩容
通过阅读源码,我们知道HashMap中数组的默认初始长度是16。但当数组中有数据的个数超过总数的0.75时,HashMap就会自动扩容。一旦扩容,就意味着,旧的HashMap中的Entry全部要迁移到新的HashMap中,也就是rehash。想一下如果是你做这件事,你会怎么做呢?我思考过这个问题,首先要把所有的Entry放到一起,然后遍历所有的Entry,出现哈希碰撞时,就把碰撞的数据组成链表。为什么要遍历所有的Entry,因为table[]的长度变成了原来的两倍,所以所有的Entry都需要通过哈希算法重新定位到新的哈希桶。这样做确实很直接,简单粗暴。但如果旧的HashMap中数据量非常大,这样做不但需要一个超大的数组暂存所有的Entry,而且HashMap的扩容将是一个巨大且低效率的 “工程” 。HashMap源码的编写者比我要睿智的多,我们来看看HashMap源码中扩容是怎么做的。其实这个问题的核心点在于哈希算法的设计。也就是Entry的key通过什么样的哈希算法定位到table中的位置。下面是jdk中的哈希算法
1 | static final int hash(Object key) { |
以及putval方法
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, |
上面两个方法,第一个是hash算法,这个方法的作用是根据key计算哈希值,使结果尽量的离散,这样当数据很多时,数据才会尽可能均匀的分布在哈希桶中。而第二个方法中数组的长度与哈希值的与计算得到下标。然后就是最后一个巧妙的设计了,table[]扩容为原来的两倍。我们先来看以下两种情况,这里HashMap的长度是默认值16
1.key计算后的哈希值小于16
2.key计算后的哈希值大于16
图中的与运算就是源码中Entry确定table下标的代码,也就是上面putval代码的第七行 p = tab[i = (n - 1) & hash] 这行代码的计算。从图中计算的结果可以看出,与运算保留了低位的值,去掉了高位的值。因此在hash值小于16的情况下,旧的Entry在重新定位到扩容后的新HashMap的table下标时,得到的下标值是一样的。而在hash值大于16的情况下,旧的Entry在重新定位到扩容后的新HashMap的table下标时,得到的下标值是不一样的。这说明在第一次HashMap扩容时,hash值大于16的Entry会迁移到新的位置,而小于16的Entry不需要迁移。这就保证了旧table已经散列良好的数据位置重新调换的频率。而每次扩容两倍更是保证了,每次变化的位数只有一位,如下图
这样就可以使之前已经散列好的数组变动最小,这也是为什么扩容2倍的原因。到这里可以给HashMap的原理做个总结:HashMap由数组加链表组成的,数组是HashMap的哈希桶,链表则是为解决哈希碰撞而存在的,如果定位到的数组位置不含链表(即哈希桶中只有一个Entry),那么对于查找,添加等操作很快,仅需一次寻址即可(数组根据下标寻址,速度很快);如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,也需遍历链表,然后通过key对象的equals方法逐一比较查找。所以,性能考虑,HashMap中的链表出现越少,性能就会越好。(其实也就是key的哈希值越离散,Entry就会尽可能的均匀分布,出现链表的概率也就越低)
4 . 重写equals和hashCode方法
这个问题相信很多初学java的朋友都碰到过。就是在使用自定义对象作为HashMap的key时,如果重写了equals,也一定要重写hashcode方法。首先来看个小例子,如果在类中重写了equals方法,但是不重写hashCode方法会出现什么样的问题
1 | public class Car { |
我定义了一个汽车类,重写了equals方法,通过判断两辆车的车牌号相等来判断是否为同一辆车,预期结果应该输出 张三的车 ,可实际结果却不是这样的,实际输出如下
1 | 结果---null |
通过上面HashMap的原理,我们大概能明白其中的道理,是因为我们没有重写hashCode方法的原因,因为两个对象的哈希值不一样,导致定位到了HashMap的不同哈希桶中,因此没有get到值。但为什么hashCode会不相等呢。这需要搞清楚hashCode方法的原理。equals方法和hashCode方法都属于java基类Object类的方法,其中equals方法是用于比较两个值是否相等的。hashCode主要作用是为了配合基于散列的集合一起正常运行,这样的散列集合包括HashSet、HashMap以及HashTable,可以这么理解hashCode方法就是为了集合而生的。或许很多数人都会想到调用equals方法来逐个进行比较,这个方法确实可行。但是如果集合中已经存在成千上万条数据或者更多,如果采用equals方法遍历比较,效率必然是一个问题。此时hashCode方法的作用就体现出来了,当需要通过集合查找某条数据时,先调用这个对象的hashCode方法,得到对应的hashcode值。这样就把值的范围定位到一小片区域(即哈希桶)中,只要遍历这里面的数据就能找到这条数据了。说了这么多,那么hashCode是怎么计算而来的呢?有些朋友误以为默认情况下,hashCode返回的就是对象的存储地址,这种看法是不全面的,确实有些JVM在实现时是直接返回对象的存储地址,但是大多时候并不是这样,只能说与存储地址有一定关联。下面是HotSpot JVM中生成hash散列值的实现:
1 | static inline intptr_t get_next_hash(Thread * Self, oop obj) { |
该实现位于hotspot/src/share/vm/runtime/synchronizer.cpp文件下。到这里我们就能明白为什么只重写equals方法不行了,既然hashCode和对象内存地址有关系,那么上面那个例子中,在往HahsMap中set和get时,明显是两个不同的对象,因此肯定会得到不同的哈希值。当我们重写了hashCode方法后,就能得到预期的结果了。
1 | public class Car { |
实际输出结果
1 | 结果---张三的车 |
5 . HashMap的并发问题
HashMap是不能在并发场景下使用的,因为在HashMap的源码中,它的所有方法都没有同步处理。实际上只要是在堆中的对象,如果在多线程情况下使用而不做同步处理的话,都有可能导致数据不一致的问题。HashMap独特的数据结构还会带来另一个问题,这个问题在jdk1.8中得到了解决,但在jdk1.7中这个bug是很严重的存在,关于这个问题的解析,网上的博客也有很多,这里我也再回顾一下这个问题,权当复习,下面不做声明的话,默认都是说的jdk1.7的HashMap。
1. jdk1.7中HashMap的并发问题
大家都知道HashMap正常情况下哈希桶中的数据是一个单向链表,而并发情况下哈希桶中的链表有可能会形成一个环形链表。而一旦环形链表出现将会出现一个严重的问题,就是HashMap的get方法。我们知道在使用get时,首先会通过哈希算法定位到哈希桶,然后再通过equals方法遍历链表,获取key对象的值。问题就出现在这里,因为需要遍历链表,如果链表变成环形链表,这个遍历就会变成死循环。这个导致当前线程持续占用cpu资源,得不到释放,甚至会把cpu资源耗尽,是一个极其危险的情况。当另外一个线程get 到这个死循环的key的时候,这个线程也陷入死循环。最后结果是越来越多的线程死循环,最后导致服务器宕机。那么HashMap的环形链表是如何出现的呢?究其原因是因为它的扩容机制,即rehash。当table中的哈希桶超过HashMap默认的0.75时,会自动扩容,这个扩容在单线程下没有任何问题。而在多线程下问题就暴露出来了。那么来看看多线程场景下,环形链表是怎么出现的。下面是jdk1.7中的resize方法,这里省略了部分代码,直接看核心代码
1 | void resize(int newCapacity) |
在resize方法中调用了transfer方法,transfer方法源码如下
1 | void transfer(Entry[] newTable) |
上面代码中,外层的for循环遍历的是HashMap的table[],即所有哈希桶,而里层的do…while循环遍历的是哈希桶中的链表。现在假设有两个线程在执行put方法时,都出现了HashMap的扩容。两个线程怎么同时出现需要扩容的情况呢,很简单。看addEntry方法源码
1 | void addEntry(int hash, K key, V value, int bucketIndex) |
上面两方法的源码中,我分别标注了①和②两个断点。这两个点就是环形链表的形成的原因,为了方便分析,再假设服务器cpu为单核。在②处代码是关于HashMap是否需要扩容的判断,由于此方法没有加synchronized修饰。于是当线程1运行到②处时,线程判断当前的HashMap需要扩容,就在此时线程1被挂起(线程是操作系统调度的最小单元,是否被挂起,什么时候被挂起,什么时候继续运行,都是由操作系统的线程调度说了算)。这时线程2也运行到了②处,这时由于线程1被挂起了,HashMap实际上还没有进行扩容操作。因此线程2也判断当前的HashMap需要扩容。于是两个线程都会执行resize方法。如果此时线程2一鼓作气,将扩容操作全执行完倒也好了,大不了线程1再执行一遍扩容操作,两个线程也会相安无事。可事与愿违,两个线程是交替运行的。在代码①处再出现线程被挂起的情况,就会出现环形链表。下面拿几个数据,举例说明一下,首先来看一下正常情况下的rehash过程。我假设HashMap的table初始长度为2,现在由于要rehash扩容到4。HashMap中有一个长度为3的链表。同样的为了简化分析,这里哈希算法,仅仅只是用key值除以table的长度取余来定位哈希桶的位置,哈希桶0有一个key为2的Entry,哈希桶1有3个key为3,5,7的Entry组成的链表,数据结构如下
正常的rehash过程
1.table[]的第二次循环,遍历哈希桶1,链表的第一次循环,此时e指向Entry<3,b>,next指向Entry<5,c>;5,c>3,b>
2.table[]的第二次循环,遍历哈希桶1,链表的第二次循环,此时e指向Entry<5,c>,next指向Entry<7,d>;7,d>5,c>
3.table[]的第二次循环,遍历哈希桶1,链表的第三次循环,此时e指向Entry<7,d>,next指向null;为了便于理解,下图简单的描述了正常的rehash过程7,d>
并发的rehash过程
假设线程2已经完成了对hashMap的扩容,那么线程1和线程2的状态分别如下(再强调一下出现环链的前置条件是两个线程同时读到了原HashMap的值,都准备扩容)
这个时刻线程2完成rehash过程,有图不难看出,新的HashMap已经是由两条链表有值了,然后由于操作系统的调度,线程2挂起,线程1继续执行rehash过程。这时线程1拿到的新的HashMap已经是线程1rehash完成之后的HashMap,里面是有值的。我们来看线程1进行rehash的第一步
在链表的第一次循环时,取下原链表的Entry<3,b>值,经过hash算法得到插入新HashMap的第三个下标,由于会采用头节点插入法,因此会得到Entry<3,b> 指向 Entry<7,d>,而此链表中原本就存在Entry<3,b>,且链表结构为Entry<7,d>指向Entry<3,b>。所以就出现了 Entry<3,b> 指向 Entry<7,d> 指向 Entry<3,b>。环形链表就这样出现了,上图实际上应该是这样子的3,b>7,d>3,b>3,b>7,d>3,b>7,d>3,b>3,b>
我们知道正常rehash流程线程拿到是是一个新的全空的HashMap,而在并发情况下,线程1是在线程2进行了全部或者部分rehash基础上,对新的HashMap进行rehash,而且扩容时操作同一个链表导致了环形链表的出现。环形链表一旦出现将导致get方法出现问题,因为get方法时会遍历链表去找到对应的key。假设某个key经过hash需要去遍历3链表,而恰巧3链表中没有这个key,那么get方法就一直在遍历这个链表break不掉了。在jdk1.8中修复了这个问题,使用了两个局部链表进行扩容不会出现哈希环。但jdk1.8中HashMap仍然不可在并发场景下使用,因为HashMap的put方法在并发下会出现丢数据的问题。
总结
对于HashMap的并发问题,有几种解决办法。1. 使用HashTable,这个类是线程安全的。但HashTable源码好像只是在方法上加上了synchronize方法,而synchronize 属于重量级锁,对效率损耗是比较大的。2. 使用jdk1.8中的ConcurrentHashMap,这个类实现了更高级的线程安全,且使用分段锁,在并发下也可有很高的吞吐量,推荐使用。对于HashMap的原理解析就到这吧,如有纰漏,望指正。
打赏一个呗