HashMap源码实现分析

HashMap源码实现分析

1、前言

HashMap 顾名思义,就是用hash表的原理实现的Map接口容器对象,那什么又是hash表呢。java

咱们对数组都很熟悉,数组是一个占用连续内存的数据结构,学过C的朋友对这一点影响确定更为深入。既然是一段连续的内存,数组的特色就显而易见了,一旦你知道要查第几个数据,时间复杂度就是O(1),可是对于插入操做就很困难;还有一种数据结构你也必定很熟悉,那就是链表,链表由一组指向(单向或者双向)的节点链接的数据结构,它的特色是内存不连续,查找困难,可是插入删除都很容易。git

那有没有一种查找容易,插入删除查找都容易的数据结构呢, 没错,它就是hash表。程序员

本篇,咱们就来讨论:github

  • HashMap的数据结构实现方式
  • HashMap是怎么作到为get、put操做提供稳定的时间复杂度的
  • HashMap何时从单节点转成链表又是何时从链表转成红黑树
  • HashMap初始化时为何要给自定义的初始容量。
  • HashMap如何保证容量始终是2的幂
  • HashMap为什么要保证容量始终是2的幂
  • HashMap的hash值如何计算
  • HashMap为何是线程不安全的

要了解HashMap 最好的方式就是看源码,本篇内容基于Jdk1.8HashMap源码。面试

2、HashMap的基本要素

磨刀不误砍柴功,想了解HashMap的原理,必然绕不过HashMap源码中的如下几个变量:数组

  • DEFAULT_INITIAL_CAPACITY: 初始容量 1<<4也就是16
  • MAXIMUM_CAPACITY:最大容量 1<<30。
  • DEFAULT_LOAD_FACTOR:负载因子,默认是0.75。什么意思呢,好比说你定义了一个初始容量为16的HashMap,当你不断向里面添加元素后,最多到初始容量的0.75,HashMap就会触发扩容操做。
  • threshold:下一个触发扩容操做的阈值,threshold = CAPACITY * LOAD_FACTOR。
  • TREEIFY_THRESHOLD:链表转红黑树阈值,默认为8,超过8就会执行链表转红黑树方法,可是注意转红黑树方法中会判断当前size是否大于64,只有大于64才转红黑树,不然执行resize()操做
  • UNTREEIFY_THRESHOLD: 红黑树转链表阈值,默认为6,顾名思义,红黑树节点小于6就会转成链表。
  • Node<K, V> implements Map.Entry<K, V> HashMap存放数据的基本单位,里面存有hash值、key、value、next。
  • Node<K, V>[] table:存放Node节点的数组,HashMap最底层数组,数组元素能够为单节点Node、多节点链表、多节点红黑树。

以上内容,有个印象就好,没必要每一个都记得。但这些概念对理解HashMap相当重要。安全

3、正文

3.1 HashMap 数据结构

HashMap的数据结构很简单,它是一个Node类型的数组,每一个元素能够为单节点、多节点链表、多节点红黑树。关键的问题是,这么简单的结构怎么实现的put、get都很快? 何时从单节点转成链表又是何时从链表转成红黑树?数据结构

3.1.1 HashMap如何实现put、get操做时间复杂度为O(1)~O(n)?

咱们知道,查找一个数组的元素,当咱们不知道index的时候,复杂度是很高的,可是当咱们知道index的时候,这个复杂度就是O(1)级别的。HashMap使用的就是这个原理。 对于get操做,首先根据key计算出hash值,而这个hash值执行操做(n - 1) & hash后就是它所在的index,在最好的状况下,该index刚好只有一个节点且hash值和key的hash值相同,那么时间复杂度就是O(1),当该节点为链表或者红黑树时,时间复杂度会上升,可是因为HashMap的优化(链表长度、红黑树长度相对于HashMap容量不会过长,过长会触发resize操做),因此最坏的状况也就是O(n),可能还会小于这个值。多线程

对于put操做,咱们知道,数组插入元素的成本是高昂的,HashMap巧妙的使用链表和红黑树代替了数组插入元素须要移动后续元素的消耗。这样在最好的状况下,插入一个元素,该index位置刚好没有元素的话,时间复杂度就是O(1),当该位置有元素且为链表或者红黑树的状况下,时间复杂度会上升,可是最坏的状况下也就是O(n)。架构

3.1.2 HashMap何时从单节点转成链表又是何时从链表转成红黑树?

单节点转链表很简单,当根据新加入的值计算出来的index处有元素时,若元素为单节点,则从节点转为链表。 链表转红黑树有两个条件:

  • 链表长度大于TREEIFY_THRESHOLD,默认阈值是8

  • HashMap长度大于64

当同时知足这两个条件,那么就会触发链表转红黑树的操做。

3.2 HashMap初始化时为何要给自定义的初始容量?

为啥前辈们都要求定义一个HashMap的时候必定要使用构造函数HashMap(int initialCapacity)指定初始容量呢?

在阿里的《Java开发手册》中是这样说明的:

  1. 【推荐】集合初始化时,指定集合初始值大小。

说明:HashMap 使用 HashMap(int initialCapacity) 初始化,

正例:initialCapacity = (须要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loader

factor)默认为 0.75,若是暂时没法肯定初始值大小,请设置为 16(即默认值)。

反例:HashMap 须要放置 1024 个元素,因为没有设置容量初始大小,随着元素不断增长,容

量 7 次被迫扩大,resize 须要重建 hash 表,严重影响性能。

这个问题在HashMap源码中是显而易见的,每次put函数中都会检查当前size是否大于threshold,若是大于就会进行扩容,新容量是原来容量的二倍。那么问题就来了,当你要存大量数据到HashMap中而又不指定初始容量的话,扩容会被一次接一次的触发,很是消耗性能。

而初始容量和负载因子给多少好呢,平常开发中如无必要不建议动负载因子,而是根据要使用的HashMap大小肯定初始容量,这也不是说为了不扩容初始容量给的越大越好, 越大申请的内存就越大,若是你没有这么多数据去存,又会形成hash值过于离散。

3.3 HashMap如何保证容量始终是2的幂

HashMap使用方法tableSizeFor()来保证不管你给值是什么,返回的必定是2的幂:

static final int tableSizeFor(int cap)
    {
        int n = cap - 1; // 做用:保证当cap为2的幂时,返回原值而不是二倍,如8 返回8 而不是16
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }复制代码

首先咱们来看操做:

n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16复制代码

假设 n=01000000, n |= n >>> 1后 n=01100000,n |= n >>> 2后n=01111000,n |= n >>> 4;后n=01111111,咱们能够发现,上述5步操做能够将一个32位数第一位为1的后面全部位全变为1。这样再执行n + 1操做后,该数就必为2的幂次方了。如01111111+1 = 10000000。 那又为何要保证必定是2的幂次方呢?不是不行吗?

3.3.1 HashMap为什么要保证容量始终是2的幂

说到这个问题不得不说执行put()方法时,是如何根据hash值在table中定位。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
    {
        ......
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        ......复制代码

能够看到,它使用了一个 (n - 1) & hash的操做,n为当前hashmap的容量,而容量必定为2的幂次方,n-1的二进制低位都为1,举例:16=0000000000010000,15=0000000000001111,这样的处理好处在于,当执行(n - 1) & hash的操做时,元素的位置仅取决于低位而与高位无关(这种无关性随着HashMap容量的增大而减少),这个逻辑优势是,不管你的hash值有多大,我都锁定了你的取值范围小于当前容量,这样作避免了hash值过于离散的状况,而当HashMap扩容时又能够同时增大hash值的取值范围,缺点是增长了hash碰撞的可能性,为了解决这个问题HashMap修改了hash值的计算方法来增长低位的hash复杂度。

3.3.2 HashMap计算hash值

不废话,直接上源码:

static final int hash(Object key)
    {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }复制代码

hash方法用 key的hash值异或上key的hash值的高16位,为何要这样作呢? 首先,h>>>16 的值高16位都为0,这样h^(h>>>16)时,高16位的值不会有任何变化,可是低16位的值混杂了key的高16位的值,从而增长了hash值的复杂度,进一步减小了hash值同样的几率。

3.4 HashMap为何是线程不安全的

在Jdk1.7中,形成HashMap线程不安全的缘由之一是transfer函数,该函数使用头查法在多线程的状况下很容易出现闭环链表从而致使死循环,同时还有数据丢失的问题,Jdk1.8中没有transfer函数而是在resize函数中完成了HashMap扩容或者初始化操做,resize采用尾插法很好的解决了闭环链表的问题,可是依旧避免不了数据覆盖的问题。 在HashMap的put操做中:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
    {
        Node<K, V>[] tab;
        Node<K, V> p;
        int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        ......复制代码

在执行完 if ((tab = table) == null || (n = tab.length) == 0)判断且为true的状况下,会直接进行赋值,可是在多线程的环境下,当两个线程同时完成判断,线程1刚赋值完,线程2再进行赋值,就形成了数据覆盖的问题。 这只是最简单的现象,咱们要想线程安全,首先要有多线程安全的处理逻辑,很明显HashMap是没有这样的逻辑的,那么不少为单线程设计的逻辑就很大可能出问题,因此HashMap为何是线程不安全的?它自己设计就不支持多线程下的操做,因此不应有此问。 若是想要线程安全的使用基于hash表的map,可使用ConcurrentHashMap,该实现get操做是无锁的,put操做也是分段锁,性能很好。 因此说术业有专攻,每一个容器的实现都有它对应的优缺点。咱们须要学会的是分析面对的每一种状况,合理的使用不一样的容器去解决问题。

HashMap基本的原理和对应实现就说到这里了,更深刻的话题如:红黑树插入节点、平衡红黑树、遍历红黑树,能够直接看红黑树对应的原理和实现。

须要源码注释的请戳这里源码解析


最后,最近不少小伙伴找我要Linux学习路线图,因而我根据本身的经验,利用业余时间熬夜肝了一个月,整理了一份电子书。不管你是面试仍是自我提高,相信都会对你有帮助!目录以下:

免费送给你们,只求你们金指给我点个赞!

电子书 | Linux开发学习路线图

也但愿有小伙伴能加入我,把这份电子书作得更完美!

有收获?但愿老铁们来个三连击,给更多的人看到这篇文章

推荐阅读:

相关文章
相关标签/搜索