HashMap详解

一、HashMap的底层原理?

1. 面试题分析

        HashMap是 Java 程序员使用频率最高的用于映射 (键值对 ) 处理的数据类型。随着 JDK (Java Developmet Kit)版本的更新, JDK1.8 对 HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。本文结合JDK1.7 和JDK1.8 的区别,深入探讨 HashMap 的结构实现和功能原理。

2. 介绍

        Java为数据结构中的映射定义了一个接口 java.util.Map ,此接口主要有四个常用的实现类,分别是HashMap Hashtable LinkedHashMap TreeMap ,类继承关系如下图所示:

1. HashMap

        它根据键的hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为null ,允许多条记录的值为 null HashMap 非线程安全,即任一时刻可以有多个线程同时写HashMap ,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap

2. Hashtable

        Hashtable 是遗留类,很多映射的常用功能与HashMap 类似,不同的是它承自Dictionary 类,并且是线程安全的,任一时间只有一个线程能写Hashtable ,并发性不如 ConcurrentHashMap ,因为 ConcurrentHashMap 引入了分段锁。 Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用HashMap 替换,需要线程安全的场合可以用ConcurrentHashMap 替换。

3. LinkedHashMap

        LinkedHashMap HashMap 的一个子类,保存了记录的插入顺序,在用Iterator 遍历 LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。

4. TreeMap

        TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap 。在使用 TreeMap 时, key 必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的Comparator ,否则会在运行时抛出 java.lang.ClassCastException 类型的异常。

3. 存储结构-字段(示意图+源码)

         1. 从结构实现来讲, HashMap 是数组 + 链表 + 红黑树( JDK1.8 增加了红黑树部分)实现的,如下如所示

        a. 从源码可知, HashMap 类中有一个非常重要的字段,就是 Node[] table ,即哈希桶数组,明显它是一个Node 的数组。我们来看Node[JDK1.8] 是何物。
static class Node<K, V> implements Map.Entry<K, V> {
        final int hash; // 用来定位数组索引位置 final K key; V value; Node<K,V> next; //链 表的下一个node

        Node(int hash, K key, V value, Node<K, V> next) { ...}

        public final K getKey() { ...}

        public final V getValue() { ...}

        public final String toString() { ...}

        public final int hashCode() { ...}

        public final V setValue(V newValue) { ...}

        public final boolean equals(Object o) { ...}
    }
        Node HashMap 的一个内部类,实现了 Map.Entry 接口,本质是就是一个映射 ( 键值对) 。上图中的每个黑色圆点就是一个 Node 对象。
        b. HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash 后, 得到数组下标,把数据放在对应下标元素 的链表上。 例如程序执行下面代码:
map.put("name", "qixi");
        系统将调用"name" 这个 key hashCode()方法得到其hashCode 值(该方 法适用于每个Java 对象),然后再通过 Hash 算法的后两步运算(高位运 算和取模运算,下文有介绍)来定位该键值对的存储位置,有时两个 key 会定位到相同的位置,表示发生了 Hash 碰撞。
        当然Hash 算法计算结果越分散均匀,Hash 碰撞的概率就越小, map 的存取效率就会越高。
如果哈希桶数组很大,即使较差的 Hash算法也会比较分散,如果哈希桶 数组数组很小,即使好的Hash 算法也会出现较多碰撞,所以就需要在空 间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组 的大小,并在此基础上设计好的hash 算法减少 Hash 碰撞。
        那么通过什么方式来控制map使得 Hash 碰撞的概率又小,哈希桶数组( Node[] table ) 占用空间又少呢?答案就是好的Hash 算法和扩容机制。
        map.put("name", "qixi");在理解 Hash 和扩容流程之前,我们得先了解下HashMap 的几个字段。 从 HashMap 的默认构造函数源码可知,构造函数就是对下面几个字段进 行初始化,源码如下:
int threshold;              // 所能容纳的key-value对极限 
final float loadFactor;     // 负载因子 
int modCount; 
int size;
        首先,Node[] table 的初始化长度 length( 默认值是16) Load factor 为负载因子 ( 默认值是 0.75) threshold HashMap 所能容纳的最大数据量的Node( 键值对 ) 个数。
        threshold = length * Load factor 。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
        结合负载因子的定义公式可知,threshold就是在此Load factor length( 数组长度 ) 对应下允许的最大元素数目,超过这个数目就重新resize( 扩容 ) ,扩容后的 HashMap 容量是之前容量的两倍。
        默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子 Load factor 的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor 的值,这个值可以大于1
        size 这个字段其实很好理解,就是 HashMap中实际存在的键值对数量。
        注意和table的长度length 、容纳最大键值对数量 threshold 的区别。
        而modCount字段主要用来记录 HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put 新键值对,但是某个key 对应的 value 值被覆盖不属于结构变化。
        在HashMap 中,哈希桶数组 table 的长度 length 大小必须为 2 n 次方 ( 一定是合数 ) ,这是一种非常规的设计,常规的设计是把桶的大小设计为素数。
        相对来说素数导致冲突的概率要小于合数Hashtable初始化桶大小为 11 ,就是桶大小设计为素数的应用( Hashtable 扩容后不能保证还是素数)。
        HashMap 采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap 定位哈希桶索引位置时,也加入了高位参与运算的过程。
        这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。
        于是,在JDK1.8 版本 中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8 ) 时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap 的性能,其中会用到红黑树的插入、删除、查找等算法。

4. 功能实现-方法

HashMap 的内部功能实现很多,本文主要从根据key 获取哈希桶数组索引位置、 put 方法的详细执行、扩容过程三个具有代表性的点深入展开讲解。
1. 确定哈希桶数组索引位置
        不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap 里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。
        HashMap 定位数组索引位置,直接决定了hash方法的离散性能。
        先看看源码的实现(方法一 + 方法二):
    // 方法一:
    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 h, int length) {
        // jdk1.7的源码,jdk1.8没 有这个方法,但是实现原理一样的
        return h & (length - 1);  //第 三步 取模运算 
    }
        这里的Hash 算法本质上就是三步:取 key hashCode 值、高位运算、取模运算。
        对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash 码值总是相同的。
        我们首先想到的就是把hash 值对数组长度取模运算,这样一来,元素的 分布相对来说是比较均匀的。
        但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table 数组的哪个索引处。
        这个方法非常巧妙,它通过h & (table.length-1) 来得到该对象的保存位,而 HashMap 底层数组的长度总是2 n 次方,这是 HashMap 在速度上的优化。
        当length总是 2 n 次方时, h& (length- 1) 运算等价于对 length 取模,也就是 h%length ,但是& % 具有更高的效率。
        在JDK1.8的实现中,优化了高位运算的算法,通过hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16) ,主要是从速度、功效、质量来考虑的,这么做可以在数组 table length 比较小的时候,也能保证考虑到高低Bit 都参与到 Hash 的计算中,同时不会有太大的开销。
        下面举例说明下,n table 的长度。

4. Change变形延伸

        hashmap的扩容

练习题

4 道练习 (2 必做 , 2 个选做 )



二、HashMap的内部数据结构?

1.面试题分析

        根据题目要求我们可以知道:
                该题是一个数据结构问题,可以从HashMap的底层数据结构进行分析;
                建议从多个不同jdk版本进行分析;
        分析需要全面并且有深度

        容易被忽略的坑

                分析片面
                没有深入

2.HashMap数据结构介绍

        JDK1.8版本的,内部使用数组 + 链表红黑树
        数据结构图:

         HashMap的数据插入原理

        原理图:

        1. 判断数组是否为空,为空进行初始化 ;
        2. 不为空,计算 k hash 值,通过 (n - 1) & hash 计算应当存放在数组中的下标 index;
        3. 查看 table[index] 是否存在数据,没有数据就构造一个 Node 节点存放在 table[index] 中;
        4. 存在数据,说明发生了 hash 冲突 ( 存在二个节点 key hash 值一样 ), 继续判断 key 是否相等,相等,用新的value 替换原数据 (onlyIfAbsent false)
        5. 如果不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中; (如果当前节点是树型节点证明当前已经是红黑树了 )
        6. 如果不是树型节点,创建普通 Node 加入链表中;判断链表长度是否大于 8 并且数组长度大于 64 ,大于的话链表转换为红黑树;
        7. 插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

HashMap怎么设定初始容量大小?

        一般如果 new HashMap() 不传值,默认大小是 16 ,负载因子是 0.75 , 如果自己传入初始大小 k ,初始化大小为 大于k的 2 的整数次方,例如如果传 10 ,大小为 16
        (补充说明: 实现代码如下)
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        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;
    }
    123456789
        补充说明:下图是详细过程,算法就是让初始二进制右移1 2 4 8 16 位,分别与自己位或,把高位第一个为1 的数通过不断右移,把高位为 1 的后面全变为 1 ,最后再进行 +1 操作, 111111 + 1 = 1000000 = 26
        (符合大于50 并且是 2 的整数次幂 )

HashMap的哈希函数设计

        hash函数是先拿到 key hashcode ,是一个 32 位的 int 值,然后让 hashcode 的高 16 位和低 16 位进行异或操作。
    static final int hash(Object key){
        int h;
        // 1. 允许key为null,hash = 0;
        // 2. ^ 异或,后面介绍这个算法;
        return (key == null) ? 0 : (h = key.hashCode()) ^ ( h >>> 16);
    }
        这么设计有二点原因:
        1. 一定要尽可能降低 hash 碰撞,越分散越好;
        2. 算法一定要尽可能高效,因为这是高频操作 , 因此采用位运算;
        为什么采用hashcode 的高 16 位和低 16 位异或能降低 hash 碰撞? hash 函数能不能直接用 key 的hashcode?
        因为 key.hashCode() 函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。 int 值范围为-2147483648~2147483647 ,前后加起来大概 40 亿的映射空间。只要哈希函数映射得比较均匀松 散,一般应用是很难出现碰撞的。但问题是一个40 亿长度的数组,内存是放不下的。你想,如果 HashMap数组的初始大小才 16 ,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。源码中模运算就是把散列值和数组长度-1 做一个 " " 操作,位运算比取余 % 运算要快。
        
    static int indexFor(int h, int length) {
        return h & (length - 1);
    }
    12345
        这也正好解释了为什么HashMap 的数组长度要取 2 的整数幂。因为这样(数组长度 -1 )正好相当于一个“低位掩码 操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16 为例, 16-1=15 2 进制表示是 00000000 00000000 00001111 。和某散列值做 操作如下,结果就是截取了最低的四位值。
        10100101 11000100 00100101
    &   00000000 00000000 00001111
    ----------------------------------
        00000000 00000000 00000101 //高位全部归零,只保留末四位 
        
        // 1234
        但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更 要命的是如果散列本身做得不好,分布上成等差数列的漏洞,如果正好让最后几个低位呈现规律性重复。
        此时“ 扰动函数 的价值就体现出来了,说到这里大家应该猜出来了。
        看下面这个图:

        右移16 位,正好是 32bit 的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。最后我们来看一下 Peter Lawley 的一篇专栏文章《 An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了 352 个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。

        结果显示,当HashMap 数组长度为 512 的时候( 29 ),也就是用掩码取低 9 位的时候,在没有扰动函数的情况下,发生了103 次碰撞,接近 30% 。而在使用了扰动函数之后只有 92 次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。
        另外Java1.8 相比 1.7 做了调整, 1.7 做了四次移位和四次异或,但明显 Java 8 觉得扰动做一次就够了,做4次的话,多了可能边际效用也不大,所谓为了效率考虑就改成一次了。
        下面是1.7 hash 代码:
    static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    1234

1.8hash函数做了优化,1.8还有别的优化?

        1. 数组 + 链表改成了数组 + 链表或红黑树;
        2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素, 1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后;
        3. 扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置, 1.8 采用更简单的判断逻辑,位置不变或索引+ 旧容量大小;
        4. 在插入时, 1.7 先判断是否需要扩容,再插入, 1.8 先进行插入,插入完成再判断是否需要扩容;
        
        优化目的:
                1. 防止发生hash 冲突,链表长度过长,将时间复杂度由 O(n) 降为 O(logn) ;
                2. 因为1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;A线程在插入节点B, B 线程也在插入,遇到容量不够开始扩容,重新 hash ,放置元素,采用头插法,后遍历到的B 节点放入了头部,这样形成了环,如下图所示:

        1.7的扩容调用 transfer 代码,如下所示:
     void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K, V> e : table) {
            while (null != e) {
                Entry<K, V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                //A线程如果执行到这一行挂起,B线程开始进行扩容 
                newTable[i] = e;
                e = next;
            }
        }
    }
    12345678910111213141516

3. 扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?

        这是由于扩容是扩大为原数组大小的2 倍,用于计算数组位置的掩码仅仅只是高位多了一个 1 ,怎么理解呢?
        扩容前长度为16 ,用于计算 (n-1) & hash 的二进制 n-1 0000 1111 ,扩容为 32 后的二进制就高位多了1 ,为 0001 1111
        因为是& 运算, 1 和任何数 & 都是它本身,那就分二种情况,如下图:原数据 hashcode 高位第 4 位为0 和高位为 1 的情况;
        第四位高位为0 ,重新 hash 数值不变,第四位为 1 ,重新 hash 数值比原来大 16 (旧数组的容量)

HashMap是线程安全的吗?

        不是,在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题, 1.8 中会有数据覆盖的问题,以1.8 为例,当 A 线程判断 index 位置为空后正好挂起, B 线程开始往 index 位置的写入节点数据,这时A 线程恢复现场,执行赋值操作,就把 A 线程的数据给覆盖了;还有 ++size 这个地方也会造成多线程同时扩容等问题。

         

    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 ((p = tab[i = (n - 1) & hash]) == null)  //多线程执行到这里
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K, V> e;
            K k;
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)     // 这里很重要
                e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1)      // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) {   // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    123456789101112131415161718192021222324252627282930313233343536373839404142

怎么解决这个线程不安全的问题?

        Java中有 HashTable Collections.synchronizedMap 、以及 ConcurrentHashMap 可以实现线程安全的Map。
        HashTable是直接在操作方法上加 synchronized 关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现; ConcurrentHashMap 使用分段锁,降低了锁粒度,让并发度大大提高。

三.扩展内容

        ConcurrentHashMap的分段锁的实现原理吗?
        链表转红黑树是链表长度达到阈值,这个阈值是多少?为什么?
        HashMap内部节点是有序的吗?
        讲讲LinkedHashMap 怎么实现有序的?
        讲讲TreeMap 怎么实现有序的?
        通过CAS synchronized 结合实现锁粒度的降低,讲讲 CAS 的实现以及 synchronized 的实现原理吗?
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值