HashMap源码解读——一步步学hashMap

一直以来,hashMap源码就是备受追捧的话题,面试官的宠儿。
虽然我不太知道具体有什么作用,但是就跟初中高中的史地政生一样,用处不用考虑,应试就得了。
正好这几日工作不忙,所以我也算下定了决定要把hashMap的源码啃个明白。写这篇文章也是为了给自己一个记录的方式,毕竟好记性不如烂笔头,而纸质虽然更有仪式感可是携带又不方便。同事也算是给同样想看hashmap源码的朋友一个参考或者例子。(以下都是以jdk8作为参考)

从如何将源码变为可读说起

首先我用的是eclipse编译器。然后众所周知的如果直接打开依赖中的HashMap类,是无法直接看到源码的,会报source not found


直接打开源码

然后解决办法也简单,在此页面点击change attached source


如图点击

选择jdk目录下的src.zip

下一步如图选择external file,选择jdk目录下的src.zip,然后ok,再重新打开想看的文件就可以看到正常格式的代码了。
打开hashMap文件

HashMap的继承/实现关系

其实单独说HashMap,在java中也就是一个类,哪怕内容再多,也就是一个2392行的文件,但是其背后的继承,实现还是蛮多的,忘大了说Map家族,兄弟姐妹更多,什么HashTable,TreeMap,表亲堂亲亲兄弟一大堆。虽然我这里是主要读HashMap,可是一些基础的亲戚关系还是应该了解的。所以就从最顶级Map说起。
首先,从整体上讲,JDK中,Map是个顶级接口。好让人理解的是Map家族的成员名称也大多(除了hashTable好像剩下都是map结尾)都是以map结尾的,所以还算是好找,然后Map的继承/实现类分两大类。一种是线程安全的,位于util下的concurrent包下,还有一种就是非线程安全的,直接位于util包下。下图表示java.util下的类的层次结构


java.util的层次结构

我一直觉得map家族那么大,一口吃不成胖子,既然只解读hashMap源码,还是围绕这个为主,我们主要讲的也是直属util下的非线程安全的Map类。下面的结构图就是Map家族的结构。(我用xmind画的,只能说尽力了)


Map家族结构图

刚刚我就说了这个结构图只是直属util包下的,其实concurrentMap也是继承自Map,但是我这里就没写,感兴趣的可以自己去看看。如果图表示的不明白,还有一份记录,就是我挨个类对着记下来的,虽然有点乱,但是还蛮好懂的,另外记录是implement就是implement,是extends就是extend。
然后出了hashMap,其实别的类也都蛮有意思,比如hashMap的子类LinkedHashMap,是有序hashMap、TreeMap可对其内部元素的各种排序。反正有兴趣的可以去找,我这里回到主题:HashMap解读。

HashMap源码

先从HashMap中的常量说起:
  /**
     * The default initial capacity - MUST be a power of two.
     */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

这个变量的命名,翻译成中文可以理解为初始容量。
1<<4以二进制表示的,1位移四位变成10000。二进制的10000即为十进制的16.要注意这个备注MUST be a power of two.必须是二的幂数。具体的原因后面讲。

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

这个变量的命名中文翻译加载因子
什么是加载因子?
加载因子是表示Hsah表中元素的填满的程度.若:加载因子越大,填满的元素越多,好处是,空间利用率高了,但:冲突的机会加大了.反之,加载因子越小,填满的元素越少,好处是:冲突的机会减小了,但:空间浪费多了.
冲突的机会越大,则查找的成本越高.反之,查找的成本越小.因而,查找时间就越小.
因此,必须在 "冲突的机会""空间利用率"之间寻找一种平衡折衷. 这种平衡与折衷本质上是数据结构中有名的"时-空"矛盾的平衡与折衷.
然后在HashMap中,加载因子默认是0.75。也就是默认长度16,加载因子0.75,则HashMap的默认容量是16*0.75也就是12。

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

越往后接触代码,越觉得英语的重要性。例如HashMap的源码中,英文注释真的很清晰明了,然而我英文不好,只能靠着百度翻译勉强过活.......


上文中注解的意思

其实连蒙带猜加上看翻译,可以理解这个值为HashMap的最大容量。如果我们想要直接在创建的时候就声明此HashMap的容量,则参数一定要小于1<<30。(个人理解,说的不准确欢迎指出)
然后我们继续看,1<<30是多少。同样这也是一个二进制的表达,1后面三十个0、换成十进制的话反正我是直接百度出这个值是多少的——1073741824.
也就是HashMap的最大容量十亿多。


1<<30
static final int TREEIFY_THRESHOLD = 8;

这个其实要从哈希表的存储说起:
哈希表的存储过程如下:
1. 根据 key 计算出它的哈希值 h。
2. 假设HashMap的长度为 n,那么这个键值对应该放在第 (h % n) 个单位(格子)中。
3. 如果该格子中已经有了键值对,就使用开放寻址法或者拉链法解决冲突。

在使用拉链法解决哈希冲突时,每个单位中其实是一个链表,属于同一个单位(格子)的所有键值对都会排列在链表中。
TREEIFY_THRESHOLD这个设置是当链表的长度过长,自动转化成树,这个值表示当某个格子中,链表长度大于 8 时,有可能会转化成树。

 static final int UNTREEIFY_THRESHOLD = 6;

这个概念涉及到了哈希表的扩容。从上面开始讲,默认哈希表初始容量16,加载因子0.75、也就是当存储到了12的时候就已经满了,当再往里插入第13个元素的时候,会触发HashMap 的扩容,(注意HashMap的扩容都是成倍扩容的,也就是从16扩容到32)
因此即使 key 的哈希值不变,对格子个数取余的结果也会发生改变,因此所有键值对的存放位置都有可能发生改变,这个过程也称为重哈希(rehash)。
也就是有可能之前一个格子中链表元素个数8个以上,以树的形式存储,但是等重哈希以后链表的长度变短了。
而上面这个值UNTREEIFY_THRESHOLD是指重哈希以后,链表长度低于6的从树形存储回复成链表存储。

static final int MIN_TREEIFY_CAPACITY = 64;

在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

至此,HashMap源码中的六个常量解释完毕。其实每一个名字和注释都属于见名知意的,用心去理解理解,查查资料,很容易明白的。我在理解这块的时候参考了一个大大的文章,写的真的真的很清楚明白,这里贴出来,大家可以参考下。贼清楚的讲解HashMap源码
顺着大大的思路还提到了一点,当负载因子是0.75的时候,出现八连链表的几率是亿分之六。
这就是为什么单元格中链表长度超过 8 以后要变成红黑树,因为在正常情况下出现这种现象的几率小到忽略不计。一旦出现,几乎可以认为是哈希函数设计有问题导致的。
Java 对哈希表的设计一定程度上避免了不恰当的哈希函数导致的性能问题,每一个单元格中的链表可以与红黑树切换。

HashMap构造器:
HashMap构造器

如图所示,HashMap有四个构造方法

  1. 无参构造,默认初始容量16,加载因子0.75。
  2. 一个int参数构造,该参数是初始容量。而加载因子仍然是默认的0.75。
  3. 两个参数的构造,一个是int型的初始容量,一个是float型的加载因子。
    在这个方法中,参数初始容量必须大于0,否则报错。而如果初始容量大于默认的最大值(也就是上文说的1073741824),则自动赋值为该最大值。否则的话通过一个封装好的方法获取这个数的下一个最近的2的高次幂,并赋值给初始容量(因为HashMap的容量一定是2的幂)。
    float参数必须大于0并且是一个float型数据,否则报错。如果这个参数合法则赋值给加载因子。
  4. 参数是一个Map的子类,在传入后代码判断这个Map子类的初始容量,大于0则获取离该数字最近的下一个2的幂次方。(大于最大值自动转换为最大值)。然后冲哈希,并将每个元素放入HashMap中。
    用到的重要方法:
    这个方法可以获取一个数的下一个最近的2的高次幂。如果这个数大于HashMap默认的最大值,则设置为最大值。
    获取一个整数下一个最近的2的高次幂

    如图所示,参数的一个Map子类,首先判断子类的有效性,然后获取该对象的容量(用到了上一个方法),重哈希,最后遍历将每一个元素存到HashMap中。
    将一个Map子类转换成HashMap方法
重哈希:

这个其实也是一个重要方法,但是因为太长所以我决定以代码的形式贴出来。顺便做一些说明

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

上面是方法的完整代码,我一步一步根据理解解释
首先threshold 这个变量的意思:
threshold表示当HashMap的size大于threshold时会执行resize操作。
threshold=capacity*loadFactor。
如果在HashMap的容量大于0的情况下:

 if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;}

其实因为我们设置的hashMap的容量大于MAXIMUM_CAPACITY则自动变为MAXIMUM_CAPACITY,所以可以理解HashMap的容量为最大值时,threshold2,也就是integer的最大值。
(newCap = oldCap << 1) 是扩容后的容量,如果仍然小于最大值 MAXIMUM_CAPACITY,并且扩容前已经大于16,则可以正常扩容(也就是原来的基础上
2)。
如果容量不大于0则进入第二个分支,判断threshold是否大于0,如果大于0,则将threshold赋值给新的容量。
如果这两个分支都没走,进入第三个分支,也就是给容量赋值默认容量16,给threshold赋值容量*加载因子。
继续往下走代码,判断newThr是否为0(走了上面的第二个分支才会为0),如果是则赋值。
最后将肯定有值的newThr再反赋值给threshold 。
这一步做完下面就是分配内存,如果oldTab == null,则 返回newTab。
如果oldTab != null,则需要将原内存地址中的数据拷贝给newTab的地址。
因此,我们在扩充HashMap的时候,不需要重新计算hash了,一部分不用变,另一部分的变化也是有规律的。我看了好多介绍这块的文章,只能说略有了解,但是也不是十分清楚原理,但是基本是实现还是差不多理解(我居然莫名的想到的标记整理之类的,虽然不太相同,但是都是比一个个重新整理简单又方便的多,这句就是闲聊)。

Node<K,V>

这是一个HashMap中的静态内部类,类比于Map类中的entry。而且很巧的是这个Node也正是继承了Map.Entry的。
方法也很简单,简单的重写了HashCode和equals。然后构造方法hash,key,value还有个next。基本方法getKey,getValue,setValue。

Hash
Hash

这个挺好理解的,其中按位异或^,两个二进制数对比,
未完待续。。。

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

推荐阅读更多精彩内容