分享

Java HashMap 源码解析

 昵称不能为空dz 2018-04-22

干货文章第一时间送达!

继上一篇文章Java集合框架综述后,今天正式开始分析具体集合类的代码,首先以既熟悉又陌生的HashMap开始。

本文源码分析基于Oracle JDK 1.7.0_71,请知悉。

签名(signature)

可以看到继承了

标记接口Cloneable,用于表明对象会重写方法,HashMap实现的是浅拷贝(shallow copy)。

标记接口Serializable,用于表明对象可以被序列化

比较有意思的是,同时继承了抽象类与接口,因为抽象类的签名为

Stack Overfloooow上解释到:

在语法层面继承接口是多余的,这么做仅仅是为了让阅读代码的人明确知道是属于体系的,起到了文档的作用

相当于个辅助类,的一些操作这里面已经提供了默认实现,后面具体的子类如果没有特殊行为,可直接使用提供的实现。

Cloneable接口

这个接口设计的非常不好,最致命的一点是它里面竟然没有方法,也就是说我们自己写的类完全可以实现这个接口的同时不重写方法。

关于的不足,大家可以去看看《Effective Java》一书的作者给出的理由,在所给链接的文章里,Josh Bloch也会讲如何实现深拷贝比较好,我这里就不在赘述了。

Map接口

在eclipse中的outline面板可以看到接口里面包含以下成员方法与内部类:

Map_field_method

可以看到,这里的成员方法不外乎是“增删改查”,这也反映了我们编写程序时,一定是以“数据”为导向的。

在上篇文章讲了虽然并不是,但是它提供了三种“集合视角”(collection views),与下面三个方法一一对应:

,提供key的集合视角

,提供value的集合视角

,提供key-value序对的集合视角,这里用内部类表示序对

AbstractMap抽象类

对中的方法提供了一个基本实现,减少了实现接口的工作量。

举例来说:

如果要实现个不可变(unmodifiable)的map,那么只需继承,然后实现其方法,这个方法返回的set不支持add与remove,同时这个set的迭代器(iterator)不支持remove操作即可。

相反,如果要实现个可变(modifiable)的map,首先继承,然后重写(override)的put方法,同时实现所返回set的迭代器的remove方法即可。

设计理念(design concept)

哈希表(hash table)

是一种基于哈希表(hash table)实现的map,哈希表(也叫关联数组)一种通用的数据结构,大多数的现代语言都原生支持,其概念也比较简单:,如下图所示

hash table demo

很容易想到,一些不同的key经过同一hash函数后可能产生相同的索引,也就是产生了冲突,这是在所难免的。

所以利用哈希表这种数据结构实现具体类时,需要:

设计个好的hash函数,使冲突尽可能的减少

其次是需要解决发生冲突后如何处理。

后面会重点介绍是如何解决这两个问题的。

HashMap的一些特点

线程非安全,并且允许key与value都为null值,与之相反,为线程安全,key与value都不允许null值。

不保证其内部元素的顺序,而且随着时间的推移,同一元素的位置也可能改变(resize的情况)

put、get操作的时间复杂度为O(1)。

遍历其集合视角的时间复杂度与其容量(capacity,槽的个数)和现有元素的大小(entry的个数)成正比,所以如果遍历的性能要求很高,不要把capactiy设置的过高或把平衡因子(load factor,当entry数大于capacity*loadFactor时,会进行resize,reside会导致key进行rehash)设置的过低。

由于HashMap是线程非安全的,这也就是意味着如果多个线程同时对一hashmap的集合试图做迭代时有结构的上改变(添加、删除entry,只改变entry的value的值不算结构改变),那么会报ConcurrentModificationException,专业术语叫,尽早报错对于多线程程序来说是很有必要的。

通过这种方式可以得到一个线程安全的map。

源码剖析

首先从构造函数开始讲,遵循集合框架的约束,提供了一个参数为空的构造函数与有一个参数且参数类型为Map的构造函数。除此之外,还提供了两个构造函数,用于设置的容量(capacity)与平衡因子(loadFactor)。

从代码上可以看到,容量与平衡因子都有个默认值,并且容量有个最大值

可以看到,默认的平衡因子为0.75,这是权衡了时间复杂度与空间复杂度之后的最好取值(JDK说是最好的),过高的因子会降低存储空间但是查找(lookup,包括HashMap中的put与get方法)的时间就会增加。

这里比较奇怪的是问题:容量必须为2的指数倍(默认为16),这是为什么呢?解答这个问题,需要了解HashMap中哈希函数的设计原理。

哈希函数的设计原理

看到这么多位操作,是不是觉得晕头转向了呢,还是搞清楚原理就行了,毕竟位操作速度是很快的,不能因为不好理解就不用了。

网上说这个问题的也比较多,我这里根据自己的理解,尽量做到通俗易懂。

在哈希表容量(也就是buckets或slots大小)为length的情况下,为了使每个key都能在冲突最小的情况下映射到(注意是左闭右开区间)的索引(index)内,一般有两种做法:

让length为素数,然后用的方法得到索引

让length为2的指数倍,然后用的方法得到索引

HashTable用的是方法1,用的是方法2。

因为本篇主题讲的是HashMap,所以关于方法1为什么要用素数,我这里不想过多介绍,大家可以看这里(http:///5aKbBh)。

重点说说方法2的情况,方法2其实也比较好理解:

因为length为2的指数倍,所以所对应的二进制位都为1,然后在与做与运算,即可得到内的索引

但是这里有个问题,如果的大于的值,而且的二进制位的低位变化不大,那么冲突就会很多,举个例子:

Java中对象的哈希值都32位整数,而HashMap默认大小为16,那么有两个对象那么的哈希值分别为:与,它们的后几位都是一样,那么与16异或后得到结果应该也是一样的,也就是产生了冲突。

造成冲突的原因关键在于16限制了只能用低位来计算,高位直接舍弃了,所以我们需要额外的哈希函数而不只是简单的对象的方法了。

具体来说,就是HashMap中函数干的事了

首先有个随机的hashSeed,来降低冲突发生的几率

然后如果是字符串,用了来获取索引值

最后,通过一系列无符号右移操作,来把高位与低位进行异或操作,来降低冲突发生的几率

右移的偏移量20,12,7,4是怎么来的呢?因为Java中对象的哈希值都是32位的,所以这几个数应该就是把高位与低位做异或运算,至于这几个数是如何选取的,就不清楚了,网上搜了半天也没统一且让人信服的说法,大家可以参考下面几个链接:

http:///questions/7922019/openjdks-rehashing-mechanism/7922219#7922219

http:///questions/9335169/understanding-strange-java-hash-function/9336103#9336103

http:///questions/14453163/can-anybody-explain-how-java-design-hashmaps-hash-function/14479945#14479945

HashMap.Entry

HashMap中存放的是HashMap.Entry对象,它继承自Map.Entry,其比较重要的是构造函数

可以看到,Entry实现了单向链表的功能,用成员变量来级连起来。

介绍完Entry对象,下面要说一个比较重要的成员变量

你也许会疑问,Entry不是单向链表嘛,怎么这里又需要个数组类型的table呢?

我翻了下之前的算法书,其实这是解决冲突的一个方式:链地址法(开散列法),效果如下:

链地址法处理冲突得到的散列表

就是相同索引值的Entry,会以单向链表的形式存在

链地址法的可视化

网上找到个很好的网站,用来可视化各种常见的算法,很棒。瞬间觉得国外大学比国内的强不知多少倍。

下面的链接可以模仿哈希表采用链地址法解决冲突,大家可以自己去玩玩

https://www.cs./~galles/visualization/OpenHash.html

get操作

get操作相比put操作简单,所以先介绍get操作

put操作(含update操作)

因为put操作有可能需要对HashMap进行resize,所以实现略复杂些

remove操作

到现在为止,HashMap的增删改查都介绍完了。

一般而言,认为HashMap的这四种操作时间复杂度为O(1),因为它hash函数性质较好,保证了冲突发生的几率较小。

fast-fail的HashIterator

集合类用Iterator类来遍历其包含的元素,接口Enumeration已经不推荐使用。相比Enumeration,Iterator有下面两个优势:

Iterator允许调用者在遍历集合类时删除集合类中包含的元素(相比Enumeration增加了remove方法)

比Enumeration的命名更简短

HashMap中提供的三种集合视角,底层都是用HashIterator实现的。

序列化

介绍到这里,基本上算是把HashMap中一些核心的点讲完了,但还有个比较严重的问题:保存Entry的table数组为transient的,也就是说在进行序列化时,并不会包含该成员,这是为什么呢?

为了解答这个问题,我们需要明确下面事实:

Object.hashCode方法对于一个类的两个实例返回的是不同的哈希值

我们可以试想下面的场景:

我们在机器A上算出对象A的哈希值与索引,然后把它插入到HashMap中,然后把该HashMap序列化后,在机器B上重新算对象的哈希值与索引,这与机器A上算出的是不一样的,所以我们在机器B上get对象A时,会得到错误的结果。

所以说,当序列化一个HashMap对象时,保存Entry的table是不需要序列化进来的,因为它在另一台机器上是错误的。

因为这个原因,HashMap重写了与 方法

简单来说,在序列化时,针对Entry的key与value分别单独序列化,当反序列化时,再单独处理即可。

总结

在总结完HashMap后,发现这里面一些核心的东西,像哈希表的冲突解决,都是算法课上学到,不过由于“年代久远”,已经忘得差不多了,我觉得忘

一方面是由于时间久不用

另一方面是由于本身没理解好

平时多去思考,这样在遇到一些性能问题时也好排查。

还有一点就是我们在分析某些具体类或方法时,不要花太多时间一些细枝末节的边界条件上,这样很得不偿失,倒不是说这么边界条件不重要,程序的bug往往就是边界条件没考虑周全导致的。

只是说我们可以在理解了这个类或方法的总体思路后,再来分析这些边界条件。

如果一开始就分析,那真是丈二和尚——摸不着头脑了,随着对它工作原理的加深,才有可能理解这些边界条件的场景。

今天到此为止,下次打算分析TreeMap。Stay Tuned!。我已经写完了,两篇文章对比看,效果更好。

原文://liujiacai.net/blog/2015/09/03/java-hashmap/

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多