AI工具人
提示词工程师

Java HashMap源码浅析

  之前虽然很频繁使用java的hashmap,但一直都是纯用,至于里面怎么实现的,一直都是糊里糊涂的。今年4月份跳槽找工作,大概看了一下HashMap的源码,在面试过程中也被多位面试官问到HashMap的相关问题,有些问题也没回答出来。本来几个月前就想着写一篇相关源码解析的博客(主要是加深自己的理解),一直拖到现在,接下来就跟我一起看下HashMap的实现(基于jdk8)。

  
  HashMap实现了Map,Cloneable,Serializable三个接口,Map定义了必要方法,Cloneable意味着HashMap是可被clone的,Serializable以为这HashMap可以被序列化。但是AbstractMap是什么鬼?打开AbstractMap的源码就会发现注释第一句就告诉你,AbstractMap给Map提供了一个骨架,事实上它实现了Map的几乎所有的方法,除了entrySet()。AbstractMap既然实现了Map接口,那为什么HashMap还要实现Map接口? 好吧关于这个问题,有人说是个作者的一个失误,见StackOverflow,我觉得这个说法站不住脚啊,如果是个失误,为啥到jdk10还没被修复,跳过这个问题。
  打开HashMap的源码,跳过一堆注释,最先看到的是几个常量,这几个常量是缺省时的默认值,非常重要,它们决定这HashMap在使用过程中内部的发生的一些行为,我们挨个看一下。

long serialVersionUID = 362498820763181265L;   //用来在序列化时校验用的  
int DEFAULT_INITIAL_CAPACITY = 1 << 4;   //缺省时HashMap的初始容量 16
int MAXIMUM_CAPACITY = 1 << 30;  //最大容量  
float DEFAULT_LOAD_FACTOR = 0.75f;  //缺省时的默认负载因子,当map中的存储量占总容量的75%以上,hashmap就会把自身容量扩张一倍
int TREEIFY_THRESHOLD = 8;  // 触发树化阀值
int MIN_TREEIFY_CAPACITY = 64;  // 真正树化阀值 

  了解一个类,应该最先从其构造函数开始,HashMap可选的构造参数并不多,最核心的就这一个构造方法,如下。

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

  这个构造函数做的事很简单,只是初始化了两个值,一个是负载因子,如果用户用自定义切校验通过就会抛弃默认的0.75负载因子。另一个是threshold,loadFactor是个负载因子的比例,threhold才是实际值,HashMap会根据threhold具体值来做resize()。这里我们顺带看下tableSizeFor()的实现。

    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;
    }

  这个函数就就是找到最小那个比cap大的2^n。
  如上所示,其实构造函数什么都没做。内部空间初始化也没做,那什么时候做呢?其实HashMap存储初始化的动作都是插入值的时候做的,这样设计有个好处,可以避免你虽然new了一个HashMap,但写入数据导致的资源浪费。
   接下来我们看下HashMap内部是怎么存储数据的。HashMap中有个内部静态类Node<K,V>,这个用来存储数据的实体,看下代码。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        /**
        * 省略一些简单方法
        */
    }

  这个类里除了用户可见的K,V之外,还额外存储了hash值,这个hash值就是K的hash值,单独存下是为了优化,不需要每次用到的时候都计算K的hash值。next这个就是单链表的next指针了,HashMap解决hash值冲突的方式就是是开一条单链表,Jdk8之后就不仅仅是单链表了,后续我们会详解介绍。
  HashMap中有个Node<K,V>[] table; 这个数组就是所有数据存储的地方了。它会在第一次插入数据时发现table为null候才被初始化。

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;
    }

  既然贴出来了这段代码了,我们就先来看下这段,再看HashMap的resize()。最开始就检查table,如果table是null,就用resize做初始化,然后在安插新节点。如果遇到hash值碰撞,如果发现是同一个key,就覆盖。否在有两种情况,要么单链表插入,要么红黑树插入,一个HashMap的内部存储可能长这个样子,树化后的某些单链表可能会变成一颗树(示意图,并不代表真实情况)。

  这里就遇到jdk对于hashmap指针碰撞的优化了,如果过多的K,V都有同样的hash值,就会变成一条非常长的链,增删查的性能就非常低了。所以jdk8在链表长度达到8的时候就会触发链表的树话,把单链表变成一个红黑树,可以把各种操作的时间复杂度从O(n)降低到O(logn)。
  事实上,链表长度到8,虽然会触发树化,但并不一定会树化,而是会优先选择resize(),只有链表长度达到64才会树化,其实我并不理解为什么要再设个64的阀值才会树化,可能是在节点树少的时候,树的平局操作时间复杂度不会比单链表的优太多吧。
  我觉得在HashMap中resize()是HashMap的核心,也是很多问题的根源。虽然resize()对用户不可见,但它是保证HashMap性能最重要的一般,当然它也是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;
    }

  大体上就是新建一个newTab,是原来table大小的两倍,然后把原有数据做放到newTab中。先不管Tree是怎么裂变的(还没搞懂),我们来看下单链表怎么裂变的。 这里直接把一个单链表裂变成了两个。在odltab中第j位置的单链表,裂变成两条单链表,一条放newtab中j位置,另一条放newtab中i+oldcap的位置,每个j都是这么做的,就是这么神奇, 很长时间我一直没理解这么简单处理,如何保证在同一条链表中的节点都有相同的hash值?
  其实也很简单,就是按hash值第oldCap那位0和1做了区分。因为我们cap每次都是以2为系数增长的,然后按cap-1为掩码来计算位置的,当容量扩大一倍后,只会有一部分最高位是1的才需要移动。比如hash值最后4位都是是0101的节点,其在newtab中新的位置要么是10101,要么是00101。
  今天最后一个问题,为什么HashMap不是线程安全的? 罪魁祸首还是resize(),如果在多线程插入的情况下,多个线程有可能同时执行resize(),多线程操作一个单链表,很大几率会出现数据一致性的问题,甚至有可能会在链表中出现环。

赞(1) 打赏
未经允许不得转载:XINDOO » Java HashMap源码浅析

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫

微信扫一扫