HashMap深度分析

作者: 技术见闻 | 来源:发表于2016-05-12 16:58 被阅读11411次

    这次主要是分析下HashMap的工作原理,为什么我会拿这个东西出来分析,原因很简单,以前我面试的时候,偶尔问起HashMap,99%的程序员都知道HashMap,基本都会用Hashmap,这其中不仅仅包括刚毕业的大学生,也包括已经工作5年,甚至是10年的程序员。HashMap涉及的知识远远不止put和get那么简单。本次的分析希望对于面试的人起码对于面试官的问题有所应付

    ** 一、先来回忆下我的面试过程**

    ** 问:“你用过HashMap,你能跟我说说它吗?”**

    ** 答:**“当然用过,HashMap是一种<key,value>的存储结构,能够快速将key的数据put方式存储起来,然后很快的通过get取出来”,然后说“HashMap不是线程安全的,
    HashTable是线程安全的,通过synchronized实现的。HashMap取值非常快”等等。这个时候说明他已经很熟练使用HashMap的工具了。

    问:“你知道HashMap 在put和get的时候是怎么工作的吗?”

    答:“HashMap是通过key计算出Hash值,然后将这个Hash值映射到对象的引用上,get的时候先计算key的hash值,然后找到对象”。这个时候已经显得不自信了。

    问:“HashMap的key为什么一般用字符串比较多,能用其他对象,或者自定义的对象吗?为什么?”

    答:“这个没研究过,一般习惯用String。”

    问:“你刚才提到HashMap不是线程安全的,你怎么理解线程安全。原理是什么?几种方式避免线程安全的问题。”

    答:“线程安全就是多个线程去访问的时候,会对对象造成不是预期的结果,一般要加锁才能线程安全。”

    其实,问了以上那些问题,我基本能判定这个程序员的基本功了,一般技术中等,接下来的问题没必要问了。

    从我的个人角度来看,HashMap的面试问题能够考察面试者的线程问题、Java内存模型问题、线程可见与不可变问题、Hash计算问题、链表结构问题、二进制的&、|、<<、>>等问题。所以一个HashMap就能考验一个人的技术功底了。

    二、概念分析

    1、HashMap的类图结构

     此处的类图是根据JDK1.6版本画出来的。如下图1:

    202221148131465.png

      图(一)

    2、HashMap存储结构

    ** **HashMap的使用那么简单,那么问题来了,它是怎么存储的,他的存储结构是怎样的,很多程序员都不知道,其实当你put和get的时候,稍稍往前一步,你看到就是它的真面目。其实简单的说HashMap的存储结构是由数组和链表共同完成的。如图:

    210003116887371.png

    从上图可以看出HashMap是Y轴方向是数组,X轴方向就是链表的存储方式。大家都知道数组的存储方式在内存的地址是连续的,大小固定,一旦分配不能被其他引用占用。它的特点是查询快,时间复杂度是O(1),插入和删除的操作比较慢,时间复杂度是O(n),链表的存储方式是非连续的,大小不固定,特点与数组相反,插入和删除快,查询速度慢。HashMap可以说是一种折中的方案吧。

    3、HashMap基本原理

    1、首先判断Key是否为Null,如果为null,直接查找Enrty[0],如果不是Null,先计算Key的HashCode,然后经过二次Hash。得到Hash值,这里的Hash特征值是一个int值。

    2、根据Hash值,要找到对应的数组啊,所以对Entry[]的长度length求余,得到的就是Entry数组的index。

    3、找到对应的数组,就是找到了所在的链表,然后按照链表的操作对Value进行插入、删除和查询操作。

    4、HashMap概念介绍

    变量 术语 说明
    size 大小 HashMap的存储大小
    threshold 临界值 HashMap大小达到临界值,需要重新分配大小。
    loadFactor 负载因子 HashMap大小负载因子,默认为75%。
    modCount 统一修改 HashMap被修改或者删除的次数总数。
    Entry 实体 HashMap存储对象的实际实体,由Key,value,hash,next组成。

    5、HashMap初始化

    默认情况下,大多数人都调用new HashMap()来初始化的,我在这里分析new HashMap(int initialCapacity, float loadFactor)的构造函数,代码如下:

    public HashMap(int initialCapacity, float loadFactor) {
         // initialCapacity代表初始化HashMap的容量,它的最大容量是MAXIMUM_CAPACITY = 1 << 30。
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
            if (initialCapacity > MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
    
         // loadFactor代表它的负载因子,默认是是DEFAULT_LOAD_FACTOR=0.75,用来计算threshold临界值的。
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
    
            // Find a power of 2 >= initialCapacity
            int capacity = 1;
            while (capacity < initialCapacity)
                capacity <<= 1;
    
            this.loadFactor = loadFactor;
            threshold = (int)(capacity * loadFactor);
            table = new Entry[capacity];
            init();
        }
    

    由上面的代码可以看出,初始化的时候需要知道初始化的容量大小,因为在后面要通过按位与的Hash算法计算Entry数组的索引,那么要求Entry的数组长度是2的N次方。

    6、HashMap中的Hash计算和碰撞问题

    HashMap的hash计算时先计算hashCode(),然后进行二次hash。代码如下:

    // 计算二次Hash    
    int hash = hash(key.hashCode());
    
    // 通过Hash找数组索引
    int i = indexFor(hash, table.length);
    

    先不忙着学习HashMap的Hash算法,先来看看JDK的String的Hash算法。代码如下:

     /**
         * Returns a hash code for this string. The hash code for a
         * <code>String</code> object is computed as
         * <blockquote><pre>
         * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
         * </pre></blockquote>
         * using <code>int</code> arithmetic, where <code>s[i]</code> is the
         * <i>i</i>th character of the string, <code>n</code> is the length of
         * the string, and <code>^</code> indicates exponentiation.
         * (The hash value of the empty string is zero.)
         *
         * @return  a hash code value for this object.
         */
        public int hashCode() {
            int h = hash;
            if (h == 0 && value.length > 0) {
                char val[] = value;
    
                for (int i = 0; i < value.length; i++) {
                    h = 31 * h + val[i];
                }
                hash = h;
            }
            return h;
        }
    

    从JDK的API可以看出,它的算法等式就是s[0]31^(n-1) + s[1]31^(n-2) + ... + s[n-1],其中s[i]就是索引为i的字符,n为字符串的长度。这里为什么有一个固定常量31呢,关于这个31的讨论很多,基本就是优化的数字,主要参考Joshua Bloch's Effective Java的引用如下:

    The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.

    大体意思是说选择31是因为它是一个奇素数,如果它做乘法溢出的时候,信息会丢失,而且当和2做乘法的时候相当于移位,在使用它的时候优点还是不清楚,但是它已经成为了传统的选择,31的一个很好的特性就是做乘法的时候可以被移位和减法代替的时候有更好的性能体现。例如31i相当于是i左移5位减去i,即31i == (i<<5)-i。现代的虚拟内存系统都使用这种自动优化。

    现在进入正题,HashMap为什么还要做二次hash呢? 代码如下:

    static int hash(int h) {
            // This function ensures that hashCodes that differ only by
            // constant multiples at each bit position have a bounded
            // number of collisions (approximately 8 at default load factor).
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }
    

    回答这个问题之前,我们先来看看HashMap是怎么通过Hash查找数组的索引的。

    /**
         * Returns index for hash code h.
         */
        static int indexFor(int h, int length) {
            return h & (length-1);
        }
    

    其中h是hash值,length是数组的长度,这个按位与的算法其实就是h%length求余,一般什么情况下利用该算法,典型的分组。例如怎么将100个数分组16组中,就是这个意思。应用非常广泛。

    既然知道了分组的原理了,那我们看看几个例子,代码如下:

            int h=15,length=16;
            System.out.println(h & (length-1));
            h=15+16;
            System.out.println(h & (length-1));
            h=15+16+16;
            System.out.println(h & (length-1));
            h=15+16+16+16;
            System.out.println(h & (length-1));
    

    运行结果都是15,为什么呢?我们换算成二进制来看看。

    System.out.println(Integer.parseInt("0001111", 2) & Integer.parseInt("0001111", 2));
    
    System.out.println(Integer.parseInt("0011111", 2) & Integer.parseInt("0001111", 2));
    
    System.out.println(Integer.parseInt("0111111", 2) & Integer.parseInt("0001111", 2));
    
    System.out.println(Integer.parseInt("1111111", 2) & Integer.parseInt("0001111", 2));
    

    这里你就发现了,在做按位与操作的时候,后面的始终是低位在做计算,高位不参与计算,因为高位都是0。这样导致的结果就是只要是低位是一样的,高位无论是什么,最后结果是一样的,如果这样依赖,hash碰撞始终在一个数组上,导致这个数组开始的链表无限长,那么在查询的时候就速度很慢,又怎么算得上高性能的啊。所以hashmap必须解决这样的问题,尽量让key尽可能均匀的分配到数组上去。避免造成Hash堆积。

    回到正题,HashMap怎么处理这个问题,怎么做的二次Hash。

     static int hash(int h) {
            // This function ensures that hashCodes that differ only by
            // constant multiples at each bit position have a bounded
            // number of collisions (approximately 8 at default load factor).
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }
    

    这里就是解决Hash的的冲突的函数,解决Hash的冲突有以下几种方法:
    1. 开放定址法
    线性探测再散列,二次探测再散列,伪随机探测再散列)
     2. 再哈希法
    3. 链地址法
    4. 建立一 公共溢出区

    而HashMap采用的是链地址法,这几种方法在以后的博客会有单独介绍,这里就不做介绍了。

    7、HashMap的put()解析

    以上说了一些基本概念,下面该进入主题了,HashMap怎么存储一个对象的,代码如下:

     /**
         * Associates the specified value with the specified key in this map.
         * If the map previously contained a mapping for the key, the old
         * value is replaced.
         *
         * @param key key with which the specified value is to be associated
         * @param value value to be associated with the specified key
         * @return the previous value associated with <tt>key</tt>, or
         *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
         *         (A <tt>null</tt> return can also indicate that the map
         *         previously associated <tt>null</tt> with <tt>key</tt>.)
         */
        public V put(K key, V value) {
            if (key == null)
                return putForNullKey(value);
            int hash = hash(key.hashCode());
            int i = indexFor(hash, table.length);
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
    
            modCount++;
            addEntry(hash, key, value, i);
            return null;
        }
    

    从代码可以看出,步骤如下:

    (1) 首先判断key是否为null,如果是null,就单独调用putForNullKey(value)处理。代码如下:

     /**
         * Offloaded version of put for null keys
         */
        private V putForNullKey(V value) {
            for (Entry<K,V> e = table[0]; e != null; e = e.next) {
                if (e.key == null) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
            modCount++;
            addEntry(0, null, value, 0);
            return null;
        }
    

    从代码可以看出,如果key为null的值,默认就存储到table[0]开头的链表了。然后遍历table[0]的链表的每个节点Entry,如果发现其中存在节点Entry的key为null,就替换新的value,然后返回旧的value,如果没发现key等于null的节点Entry,就增加新的节点。

    (2) 计算key的hashcode,再用计算的结果二次hash,通过indexFor(hash, table.length);找到Entry数组的索引i。

    (3) 然后遍历以table[i]为头节点的链表,如果发现有节点的hash,key都相同的节点时,就替换为新的value,然后返回旧的value。

    (4) modCount是干嘛的啊? 让我来为你解答。众所周知,HashMap不是线程安全的,但在某些容错能力较好的应用中,如果你不想仅仅因为1%的可能性而去承受hashTable的同步开销,HashMap使用了Fail-Fast机制来处理这个问题,你会发现modCount在源码中是这样声明的。

    volatile关键字声明了modCount,代表了多线程环境下访问modCount,根据JVM规范,只要modCount改变了,其他线程将读到最新的值。其实在Hashmap中modCount只是在迭代的时候起到关键作用。

    private abstract class HashIterator<E> implements Iterator<E> {
            Entry<K,V> next;    // next entry to return
            int expectedModCount;    // For fast-fail
            int index;        // current slot
            Entry<K,V> current;    // current entry
    
            HashIterator() {
                expectedModCount = modCount;
                if (size > 0) { // advance to first entry
                    Entry[] t = table;
                    while (index < t.length && (next = t[index++]) == null)
                        ;
                }
            }
    
            public final boolean hasNext() {
                return next != null;
            }
    
            final Entry<K,V> nextEntry() {
            // 这里就是关键
                if (modCount != expectedModCount)
                    throw new ConcurrentModificationException();
                Entry<K,V> e = next;
                if (e == null)
                    throw new NoSuchElementException();
    
                if ((next = e.next) == null) {
                    Entry[] t = table;
                    while (index < t.length && (next = t[index++]) == null)
                        ;
                }
            current = e;
                return e;
            }
    
            public void remove() {
                if (current == null)
                    throw new IllegalStateException();
                if (modCount != expectedModCount)
                    throw new ConcurrentModificationException();
                Object k = current.key;
                current = null;
                HashMap.this.removeEntryForKey(k);
                expectedModCount = modCount;
            }
    
        }
    

    使用Iterator开始迭代时,会将modCount的赋值给expectedModCount,在迭代过程中,通过每次比较两者是否相等来判断HashMap是否在内部或被其它线程修改,如果modCount和expectedModCount值不一样,证明有其他线程在修改HashMap的结构,会抛出异常。

    所以HashMap的put、remove等操作都有modCount++的计算。

    (5) 如果没有找到key的hash相同的节点,就增加新的节点addEntry(),代码如下:

    void addEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
            table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
            if (size++ >= threshold)
                resize(2 * table.length);
        }
    

    这里增加节点的时候取巧了,每个新添加的节点都增加到头节点,然后新的头节点的next指向旧的老节点。

    (6) 如果HashMap大小超过临界值,就要重新设置大小,扩容,见第9节内容。

    8、HashMap的get()解析

    理解上面的put,get就很好理解了。代码如下:

     public V get(Object key) {
            if (key == null)
                return getForNullKey();
            int hash = hash(key.hashCode());
            for (Entry<K,V> e = table[indexFor(hash, table.length)];
                 e != null;
                 e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                    return e.value;
            }
            return null;
        }
    

    别看这段代码,它带来的问题是巨大的,千万记住,HashMap是非线程安全的,所以这里的循环会导致死循环的。为什么呢?当你查找一个key的hash存在的时候,进入了循环,恰恰这个时候,另外一个线程将这个Entry删除了,那么你就一直因为找不到Entry而出现死循环,最后导致的结果就是代码效率很低,CPU特别高。一定记住。

    9、HashMap的size()解析

    HashMap的大小很简单,不是实时计算的,而是每次新增加Entry的时候,size就递增。删除的时候就递减。空间换时间的做法。因为它不是线程安全的。完全可以这么做。效力高。

    9、HashMap的reSize()解析

    当HashMap的大小超过临界值的时候,就需要扩充HashMap的容量了。代码如下:

    void resize(int newCapacity) {
            Entry[] oldTable = table;
            int oldCapacity = oldTable.length;
            if (oldCapacity == MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return;
            }
    
            Entry[] newTable = new Entry[newCapacity];
            transfer(newTable);
            table = newTable;
            threshold = (int)(newCapacity * loadFactor);
        }
    

    从代码可以看出,如果大小超过最大容量就返回。否则就new 一个新的Entry数组,长度为旧的Entry数组长度的两倍。然后将旧的Entry[]复制到新的Entry[].代码如下:

    void transfer(Entry[] newTable) {
            Entry[] src = table;
            int newCapacity = newTable.length;
            for (int j = 0; j < src.length; j++) {
                Entry<K,V> e = src[j];
                if (e != null) {
                    src[j] = null;
                    do {
                        Entry<K,V> next = e.next;
                        int i = indexFor(e.hash, newCapacity);
                        e.next = newTable[i];
                        newTable[i] = e;
                        e = next;
                    } while (e != null);
                }
            }
        }
    

    在复制的时候数组的索引int i = indexFor(e.hash, newCapacity);重新参与计算。

    至此,HashMap还有一些迭代器的代码,这里不一一做介绍了,在JDK1.7版本中HashMap也做了一些升级,具体有Hash因子的参与。

    今天差不多完成了HashMap的源码解析,下一步将会分析ConcurrencyHashMap的源码。ConcurrencyHashMap弥补了HashMap线程不安全、HashTable性能低的缺失。是目前高性能的线程安全的HashMap类。

    很晚了,希望对大家有所帮助,晚安。

    相关文章

      网友评论

      • 进击云原生:阿里服务器双11活动
        1核2G云服务器1年仅需 99.5 元,1核2G云服务器3年仅需 298.5 元。平均每天3毛钱。

        这个还有后续玩法,参团人数靠前的团,瓜分100W奖金,我们团现在422多人了,是有资格的。

        新老用户都可参与,搭建自己的网站必备
        https://m.aliyun.com/act/team1111/#/share?params=N.FF7yxCciiM.4t0hxur1
      • Java耕耘者:你好!我们是Java耕耘者专注于程序员Java开发公众号“Java这点事”。我们很赞赏你的文章,希望能获得转载授权。授权后,你的文章将会在公众号“Java这点事”、发布。我们会注明来源和作者姓名。
        非常感谢~~~
      • Java团长:👍👍👍
      • 06e82fbd21bc:大佬ConcurrencyHashMap的分析有 po 上面么
      • 43f01e0f54a7:希望更新下 现在都是看1.7 1.8区别了,二次哈希算法也不同了,另外那个问题 为啥字符串做key好,有人说下嘛
        ostreamBaba:多线程设计模式的Immutable模式,在HashMap这种需要频繁读的容器中,String的不变性保证了hashCode的唯一性,所以我们就可以安全地缓存对象的哈希值,而不需要每次都其进行取哈希值的运算,可以提高hashMap的性能。
        43f01e0f54a7:@岁月人 :+1::+1:
        6469256faf9f:额,不是说String比较好,是因为String是不可变对象
      • 0c45406e8da8:文章很棒。我们侠课岛正好在找远程录制课程视频或图文教程的朋友,我们会给到课程的需求大纲,每一节课程需要你来详细展开写一些代码举例和讲解清楚,对经验积累和创新能力有一定的要求。有兴趣联系我,微信:zhimadt
      • 7b68fbb83730:虽然说的还不错,但是少了很多why=_=:sweat: 不过既然是并发环境,使用Hash Map怕不是脑袋进水了。。。
      • 6cc01719e0fe:不错不错,收藏了。

        推荐下,源码圈 300 胖友的书单整理:http://t.cn/R0Uflld


      • 先生_吕:终于找到博主的简书了,深度好文啊
      • MCNU云原生:至今为止看过对于hashmap解析最全面透彻的文章!很赞!
      • StormMa:可以可以,上次面百度就是这个套路,不过幸好最后过了,mark一下,方便以后深入分析集合源码
      • 風芷劍傷:学习了
      • 9178c6412231:果然是大牛,大家伙都看的懂得才是干货,好极了
      • 70cc73236eb3:的确很不错
      • 无敌翔哥:写的很不错,但如果能写java8的HashMap就更好了。
      • NKming:请问为什么要二次hash, 能具体描述下吗?
        11amok:如果刚好是等差数列,比如15,31,47。。都对16取模,结果都是15,那么他们都会存在同一个链表中,链表太长,效率降低。这些等差数列的数的低4位都是1111,但是高位不相同。二次hash将高位与低位进行异或运算,使得本来相同的低位变的不同,取模后的结果自然不同了,分布也就更均匀。
        chonrp27512:@NKming 为了让hash散列的更均匀,减少碰撞的几率。
      • 尸情化异:jdk8的hashmap加入了红黑树~~
      • 768a5b7ea6be:那么你就一直因为找不到Entry而出现死循环。因为它是双向链表?
        6935bd02ed56:我觉得这点作者的确要做一个细致的分析,很多人都搞不懂,导致死循环的原因,可以参照http://www.cnblogs.com/szlbm/p/5512619.html
        ssslinppp: @长青sky 可以参考 http://www.importnew.com/22011.html
        ssslinppp: @长青sky 这个我感觉作者没有搞清楚,应该是在扩容的时候形成了循环链表,导致在get时一直出不来,重点是分析为什么在扩容的时候会形成循环链表
      • dda4c710aaec:好帖~~
      • 163f843d0c42:100个赞
      • leeyaf:原文 http://www.cnblogs.com/wuhuangdi/p/4175991.html
        技术见闻:@leeyaf 证明什么
        leeyaf:@DeveloperLeebo 博客园并没有指向微博的连接,头像也不一样,并不能证明
        452fc051a53d:@leeyaf 看看头像和微博,再看看你这个链接的作者
      • leeyaf:这文章貌似非原创

      本文标题:HashMap深度分析

      本文链接:https://www.haomeiwen.com/subject/rodprttx.html