HashMap解析

200 阅读9分钟

1.前言

HashMap是我们日常编程中常用的数据结构

本文将从以下几个方面讲解HashMap:

1)HashMap中的重要概念(基于1.8)

2)HashMap的源码解析(基于1.8)

3)1.7与1.8HashMap的区别

2.HashMap中的重要概念

HashMap是由数组和链表组成的数据结构,数组里存放的单元叫做Node,每一个Node包含一个key-value键值对。

当调用put函数插入数据时,会根据key的hash值找到数组中对应的index,并把他放入数组中。若该数组的index上已经有数据(key值不相同),则会在该index的位置上形成链表(链表数据的插入是在链表的末尾位置),如下图中的index1、3所示。如果链表上的node数量超过8个,链表会转化成红黑树。

HashMap中的几个重要属性:

1)size 表示HashMap中的Node总数量

2)capacity 指HashMap中数组的容量,默认值为16

3)loaFactor 装载因子,用来衡量HashMap满的程度,默认值为0.75

4)threshold 表示size大于threshold时会执行resize操作(扩容,每次扩容会重新创建一个capacity为原来两倍的Node数组,并且会把原来数组中的所有Node重新hash到新的数组中去),threshold=capacity*loaFactor

了解了HashMap中的几个重要属性后,接下来说一下几个关于HashMap的常见问题。

问1:为什么resize操作需要重新hash?

答:因为index = HashCode(Key) & (Length - 1),可以看到与数组的长度有关,resize后数组的长度变化了,所以需要重新进行hash找到对应的index。

问2:HashMap的capacity默认值为什么为16?

答:在使用2的n次幂时,Length - 1的二进制值每一位都为1,这样计算index的结果就等于HashCode的后几位,只要HashCode本身分布均匀,hash的结果就是分布均匀的。

问3:1.7中链表的插入为头部插入,为什么到1.8中变成了尾部插入,这两种方式有什么区别?

答:这个问题留到讲1.7与1.8HashMap的区别时再进行具体讨论。

3.HashMap的源码解析

首先我们来看HashMap的几个构造方法

//创建一个负载因子为0.75的HashMap对象
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; //默认值为0.75
}

//创建一个初始容量为initialCapacity,负载因子为0.75的HashMap对象
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//创建一个初始容量为initialCapacity,负载因子为loadFactor的HashMap对象
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    //最大容量不能超过2的30次方
    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);//1
}

//依据所给定的map中的内容创建一个内容一样的HashMap
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

//1处调用  返回离传入参数最近的2的n次幂   比如3返回4  5返回8
static final int tableSizeFor(int cap) {
    int n = cap - 1;//自减1 防止已经是2的n次幂了
    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;
}

对于tableSizeFor方法里的移位运算和与运算,这里做一个简单的例子:

1)比如传入的参数是17 减一后为16 (对应二进制 10000)

2)然后对10000进行左移1位 得到01000 然后将二者进行或操作 得到11000

3)然后继续对11000进行左移2位操作 得到001100 然后将二者进行或操作 得到111100

4)以此类推最终会得到111111,一个不小于传入参数的且离传入参数最近的2的n次幂

从构造方法中我们可以看出,再实例化HashMap的时候,只对loadFactor和threshold赋了值,并没有进行数组的创建。而数组会在第一次调用put方法时被创建,接下来我们来看put方法

public V put(K key, V value) {
    //这里通过hash(key)算出key对应的hash值
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
    //通过hash值寻找hash桶中的位置会忽略高位,这样做可以综合hashCode的高位和低位,以减少hash碰撞
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //tab为null或者长度为0,则用resize创建一个tab
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;//这里是HashMap的初始化操作,创建数组
    //找到hash值对应的位置,如果当前位置为null,直接存入数据
    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) {//循环直到队列尾部
                //循环到队尾还没找到相同的key,新建node插入到链表尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //找到了相同的key就退出
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { //找到了相同的key,把旧值替换掉
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //LinkedHashMap预留方法
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //容量超过threshold,进行扩容
    if (++size > threshold)
        resize();
    //LinkedHashMap预留方法
    afterNodeInsertion(evict);
    return null;
}

可以看到putVal方法中首先进行了tab是否为null的判断,如果为null就会调用resize方法,resize方法不仅能够进行扩容,还能对node数组进行创建。接下来我们来看看resize方法。

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //原来的table不为空
    if (oldCap > 0) {
        //若大小已经大于等于最大值就不进行扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //对其容量x2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //说明是通过HashMap()以外的三个构造方法创建的,threshold>0,且内容为空,第一次put的时候会调用
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {//说明是通过HashMap()来创建的,threshold=0,且内容为空,第一次put的时候会调用
        //给newCap和newThr均赋默认值
        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];//创建Node数组
    table = newTab;
    //旧table不为null,把值移到新table里
    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)
                    //依据hash值重新找到在新table里的位置
                    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;
                        // 将同一桶中的元素根据(e.hash & oldCap)是否为0分成两种链表
                        // 为0位置不变,否则位置移动oldCap个
                        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;
                        //将头指针放在新table中,位置没变
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        //将头指针放在新table中,位置增加了oldCap个
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

resize方法中会对原始table大小进行判断如果不为0,则newCap = oldCap << 1;如果为0 ,会对newCap赋值。然后依据newCap 创建一个新的node数组。如果老的数组里有内容,则会进行重新的hash操作,把值移到新数组里。最后我们来看一下get方法。

public V get(Object key) {
    Node<K,V> e;
    //找不到key对应的node就返回null
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //数组中的第一个值就是,直接返回第一个值
        if (first.hash == hash && 
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)//红黑树的情况
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {//普通链表的情况
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    //找不到就返回null
    return null;
}

4.HashMap1.7与1.8的区别

1)数据结构:1.7为数组+链表,存放的单元叫Entry;1.8为数组+链表+红黑树,存放的单元叫Node,链表length>8会转化成红黑树,红黑树元素个数<=6会转回链表。

2)数据插入方式:1.7为头插法;1.8为尾插法。

3)hash计算方式:1.7扰动处理(4次位运算,5次异或运算);1.8扰动处理(1次位运算,1次异或运算)。

4)扩容后index的计算方式:1.7全部重新计算;1.8原位置或原位置+oldcap。

最后来回答一下一开始的问3。在多线程操作HashMap的时候,头插可能会由于扩容转移数据时,链表前后顺序导致,产生死循环。而尾插扩容转移数据时,链表前后顺序不变,就不会出现死循环的现象。那么这是否意味着1.8的HashMap可以用于多线程当中呢?答案也是不行的,因为通过之前的源码分析,我们看到put和get方法并没有加上同步锁,在多线程进行操作的时候,依然可能出现put的数据和get的不一样的情况,所以在多线程操作的时候,还是需要用到ConcurrentHashMap,以保证线程安全。