HashMap JDK1.7 和JDK1.8

前言

本文主要讲解HashMap的底层数据结构、存取原理、扩容机制、线程安全性、java 7 和java 8版本的对比等方面。如果你正在学习HashMap,希望对你有帮助。
如果你需要目录,可以查看这篇关于简书生成目录的文章

.

简介


HashMap 是由数组和链表组合构成的数据机构。JDK1.7 和 JDK 1.8 的HashMap底层略有不同。

HashMap根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,具有很快的访问速度,但遍历顺序是不确定的。

HashMap最多只允许一条记录的键为null,允许多条记录的值为null。

HashMap是非线程安全的。
.

底层数据结构


HashMap 是数组 + 链表 + 红黑树(JDK1.8 新增了红黑树部分)构成的。

结构图如下:

HashMap结构图.png

HashMap是一个存储Key-Value键值对的集合,每一个键值对也称Entry(Java8中叫Node),这些Entry分散存储在一个数组中,这个数组就是HashMap的主干。

HashMap_Node源码.png

数组存放的是Node,通过查看Node的源码,可以发现每个节点都会保存自身的hash、key、value 以及下一个节点的引用next。

之所以JDK1.8加入了红黑树,是为了提高效率,因为在JDK1.7的时候只有链表,存放的数据一多就容易导致链表变长,降低了效率。

只有在链表的长度不小于8,而且数组的长度不小于64的时候才会将链表转化为红黑树

.

存取原理


在JDK1.7的时候,HashMap存放数据采用的是头插法,JDK1.8改为尾插法

static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

static int indexFor(int hash, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

.

采用头插法(JDK1.7)

HashMap在put数据的时候会根据 index = indexFor(hash, table.length) 计算出index值,如:put("name", ''xiaoming')的时候就通过上面式子计算出 index = 2 ,那么这个键值对就存放到数组下标为2的位置。

我们知道数组的长度是有限的,在有限的长度里面使用哈希就会有概率两个key都hash到一个值上,这个时候新来的key就会取代原位置上的key,原位置的key就顺推到新来的key后面去,形成一个链表。

头插法:新来的值会取代原有的值,原有的值顺推到链表中去。

为什么这么设计?因为作者认为后来的值被查找的可能性更大一些,提升查找的效率。

.

确定key的存放位置(JDK1.7)

从上面我们知道HashMap是如何存储数据的,接下来,我们需要了解HashMap是如何确定key在数组中的存放位置

我们知道 index 是通过 indexFor(int hash, int length) 得到的,而hash值是通过 hash(Object key) 得到的。那么如何使得得到的下标 index 在数组中均匀分布呢?

可以利用Key的hash值和HashMap的长度做取模运算,但取模运算效率低,在HashMap中是这样子来计算index的:index = hash(Key) & (length - 1)

当 length 总是 2ⁿ 时,上面式子就等价于对 length 取模。

2ⁿ - 1 用二进制表示,所有位都是 1 ,根据 & 运算的规则,可以知道Hash算法最终得到的index结果,完全取决于Key的HashCode值的最后几位

这里有个问题:为什么HashMap的长度必须是16或者是2的幂?

答:为了实现均匀分布。因为只要是2的幂,那么 Length - 1 的值的所有二进制位都为1,这种情况下index的值就等同于HashCode最后几位的值。只要HashCode是分布均匀的,Hash(Key) 的结果就是均匀的。而其他数可能会导致有些index的结果出现几率更大或有些index结果永远不会出现。

.

确定key的存放位置(JDK1.8)

JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位(扰动函数)实现的:

(h = key.hashCode()) ^ (h >>> 16)

使用扰动函数的目的:为了混合原始哈希码的高位和低位,以此来加大低位随机性,降低发生hash冲突的概率。

下面一个例子,n为数组的长度

HashMap_计算下标.png

.

扩容机制


HashMap采用懒加载,构造完HashMap对象后并未初始化数组table,而是在第一调用 put() 时调用resize方法进行初始化。

当向HashMap容器添加元素的时候,会判断当前容器中的元素个数,如果大于或等于阈(yù)值 ——当前数组的长度乘以负载因子(LoadFactor),就需要扩容数组,java里面的数组是无法动态增加长度的,方法就是使用一个新的数组替换旧数组。新数组的长度是原数组长度的2倍。

对于resize的源码,JDK1.7和JDK1.7是不同的,区别在于JDK1.8加入了红黑树,更为复杂,但本质区别不大。

.

扩容过程(JDK1.7)

jdk1.7的扩容做法就是简单的创建一个新的Entry空数组(长度是原数组的2倍),然后遍历原数组,将所有的Entry重新Hash到新的数组中去,最后修改阈值。

转移过程示例:

HashMap_链表转移过程.png

为什么需要重新Hash呢?

答:因为长度扩大后,Hash的规则也随之改变。Hash的公式index = HashCode(key) & (length - 1)

.

线程不安全性

为什么JDK1.7使用头插法,JDK1.8使用尾插法?

JDK1.7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。

JDK1.8在同样的前提下不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。

在容量为2的容器中用不同的线程插入A、B、C,如果在resize之前打断点,则意味着A、B、C都插入了,但是还未扩容,那么有可能出现一种情况:在同一个index上的链表中是这样子的: A —> B —> C

因为使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

也就是说在resize的时候,有可能出现这一种情况:在同一个index上, A先放进来,然后B后放进来,就出现了这样子的情况:B —> A —> B

可以看出来形成了一个环形链表了,取值的时候需要遍历链表,这个时候就会出现死循环

而JDK1.8改用尾插法便可以解决这一个问题。

HashMap 的线程不安全性主要体现在:

  • 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。

  • 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

虽然JDK1.8不会出现死循环,当是仍然不可以将HashMap用于多线程中,put/get方法都是未加同步锁的,容易出现:上一秒put的值,下一秒get的时候还是原值,所以无法保证线程安全。

多线程环境下应该使用 ConcurrentHashMap

.

扩容过程(JDK1.8)

jdk1.8 引入红黑树,因此扩容过程多了对红黑树的处理,并且对重Hash做了优化,比较复杂。

resize() 的源码如下:

final Node<K,V>[] resize() {

    // 把当前底层数组赋值给oldTab,为数据迁移工作做准备 —— 拿到原数组的引用
    Node<K,V>[] oldTab = table;
    
    // 获取当前数组的大小,如果未初始化数组,则为0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    
    // 获取扩容前的阈值(容量 * 负载因子)
    int oldThr = threshold;
    
    // 定义并初始化新数组长度和目标阈值
    int newCap, newThr = 0;
    
    // 判断是初始化数组还是扩容,等于或小于0表示需要初始化数组,大于0表示需要扩容数组。
    // 若 oldCap > 0 表示需扩容而非初始化
    if (oldCap > 0) {
        // 判断数组长度是否已经是默认最大,MAXIMUM_CAPACITY = 1 << 30 = 2^30
        // 超过默认最大容量则不再进行扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 阈值设置为最大
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } 
        // 如果新数组长度扩大2倍后小于最大容量,并且原数组容量大于等于默认初始化容量(16)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 目标阈值扩展2倍,数组长度扩展2倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0)
        // 说明调用的是HashMap的有参构造函数,因为无参构造函数并没有对threshold进行初始化
        newCap = oldThr;
    // 表示需要初始化数组而不是扩容,零初始阈值表示使用默认值
    else {
        // 说明调用的是HashMap的无参构造函数
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 计算目标阈值
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 当目标阈值为0时需重新计算,公式:容量(newCap)* 负载因子(loadFactor)
    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;
            // 将数组内此下标中的数据赋值给Node类型的变量e,并判断非空
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                
                // 1 - 普通元素判断:判断数组内此下标中是否只存储了一个元素,
                //                 是的话表示这是一个普通元素,并开始转移
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                
                // 2 - 红黑树判断:判断此下标内是否是一颗红黑树,是的话进行数据迁移
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                
                // 3 - 链表判断:若此下标内包含的数据既不是普通元素又不是红黑树,
                //              则它只能是一个链表,进行数据转移
                else { // preserve order
                    // 之所以定义两个头两个尾对象,是由于链表中的元素的下标在扩容后,
                    // 要么不变,要么是原下标+oldCap
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 判断元素是否需要改变索引,从而分成两个链表
                        // 一个存放位置不变的元素,一个存放位置需要改变的元素
                        // 注意:位置需要改变的那个链表中的元素在新数组中的index是一样的
                        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;
}

.

JDK1.8 对重Hash的优化

回顾一下jdk1.8的计算key在数组的下标的方式:获取key的hashCode,然后将hashCode的16位异或低16位获得hash,最后将 hash & (n - 1) 得到 index,如果将原数组转移到新数组中都需要这样子重新计算Hash,那对整体的性能肯定有影响。

通过观察转移过程可以发现,每次扩容后数组的长度都是原来的2倍,也就是说,数组的长度是 2ⁿ ,所以,元素的新位置要么是在原位置,要么是在原来的位置上移动原数组长度的位置。

上面的源码是通过 if ((e.hash & oldCap) == 0) 来将原链表分成两个,一个存位置不需要改变的元素,一个存位置需要改变的元素,然后遍历完一个index下的链表后,再将两个链表分别移到新数组当中去。即源码中的 newTab[j] = loHead;newTab[j + oldCap] = hiHead;

看下图,其中 hash1 是 key1 对应的 hashCode 与高位运算的结果:

HashMap_重hash优化计算index.png

图中 n是数组的长度,扩容后 n 会变成原来的2倍,就相当于在原来的 n-1 的二进制表示中的高位多1bit(红色),因此新的index不必要重新计算hash,只需要看 n - 1 新增的那个1bit位对应的 key 的hash的那个位是 0 还是 1,如果是0,那么index不变,如果是1,那么index = 原来的index + 原来数组的长度。

看下图:

HashMap_确定index是否需要改变.png

(e.hash & oldCap) == 0 为什么是oldCap 而不是 oldCao - 1 ?

假设原数组长度为16,那么 oldCap 的二进制为 1 0000 , oldCap - 1 的二进制为 0 1111

我们需要判断的是长度扩大后那个新增位是 0 还是 1

而 oldCap 的二进制有一个1并且这个1对应上了那个新增位,此时进行 & 运算就可以得知新增位是0还是1

.

重写 equals() 必须重写 hashCode()


在java中,所有类的祖先类都是 Object,Object类中有两个方法 equalshashCode,这两个方法都可以用来比较两个对象是否相等。

对于值类型,== 比较的是两个变量的值,而对于引用类型,== 比较的是两个变量的地址。

Object的equals方法源码:

// 可以看到Object类的equals方法比较的是地址。
public boolean equals(Object obj) {
        return (this == obj);
}

hashCode()方法源码中有一段注释:

在 Java 应用程序执行期间,在对同一对象多次调用 hashCode 方法时,必须一致地返回相同的整数,前提是将对象进行 equals 比较时所用的信息没有被修改。从某一应用程序的一次执行到同一应用程序的另一次执行,该整数无需保持一致。

如果根据 equals(Object) 方法,两个对象是相等的,那么对这两个对象中的每个对象调用 hashCode 方法都必须生成相同的整数结果。

如果根据 equals(java.lang.Object) 方法,两个对象不相等,那么对这两个对象中的任一对象上调用 hashCode 方法不要求一定生成不同的整数结果。但是,程序员应该意识到,为不相等的对象生成不同整数结果可以提高哈希表的性能。

所以,源码的注释来看,两方法同时重写是很必要的。

前提条件,当我们在使用HashMap、HashSet等集合时:

如果重写了equals方法而不重写hashCode方法,那么就会出现通过equals方法比较相等的两个对象因hashCode不同,而被存到HashMap中数组的不同index下(我们知道key的index是根据hashCode计算而来的),这就会导致HashMap存了2个一样的key,那么在调用HashMap方法的时候就会出现逻辑错误。

.

TODO:


四个构造方法的分析

put/get 方法源码研读

扩容时红黑树的转移过程

.

常见面试题


HashMap的底层数据结构?

hash冲突是什么?解决hash冲突常见的方法有哪些?HashMap是如何解决hash冲突的?

HashMap是如何计算hash的?为什么要用hashCode的高16位异或低16位(扰动函数)?

HashMap的存取原理?

HashMap的默认初始化大小是多少?为什么是这么多?为什么大小都是 2ⁿ ?

HashMap的负载因子是多少?为什么是这么多?

说一下HashMap的扩容机制?什么时候进行扩容?扩容的过程是怎么样的?

HashMap为什么线程不安全?有什么有可替代的集合?

Java 7 和 Java 8 的区别是什么?Java 8 为什么要增加红黑树?链表何时转为红黑树?

Java 7 为什么使用头插法而java8改用尾插法?

Java 8 对重hash的优化是怎么样的?为什么是oldCap 而不是 oldCao - 1 ?

.

鸣谢


在学习HashMap底层原理的时候,拜读了以下文章,收获良多,在此感谢一下作者!

Java 8系列之重新认识HashMap

阿里面试官没想到一个HashMap,我能跟他扯半小时

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