Java8 HashMap

HashMap数据结构

数组 + 链表

HashMap要实现哈希表的效果,尽量实现O(1)级别的增删改查,它的具体实现是同时使用了数组和链表

首先HashMap的内部定义了静态类Node来实现Entry接口

 static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

Node实际上就是一个链表。

另外HashMap还定义了一个数组table来存储各个链表的表头

transient Node<K,V>[] table;

数组 + 红黑树

在Java 8中,HashMap相比之前的版本有了一个改动,那就是树化。当满足一定条件时,HashMap会将链表转化成红黑树,这是为了提高性能。

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
}

HashMap相关字段

initialCapacity

刚刚提到了HashMap实际上就是数组加链表构成的,而initialCapacity就是数组初始的长度,也可以理解为HashMap的容量。HashMap的容量必须是2的N次方,至于原因后面会详细描述,默认长度是16

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

HashMap并不要求用户在指定HashMap容量时必须传入一个2的N次方的整数,而是通过下面的方法计算出比指定整数大的最小的2^N。

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

下面详细解释一下为什么经过上述位移操作就能得到想要的结果。

首先,我们思考下2^N次方的二进制都有什么特征

0000 0010  // 2
0000 0100  // 4
0000 1000  // 8
0001 0000  // 16

可以看到对于2^N次方,它只有一位为1,其他都为0。那么对于任意一个整数,如果我们找到它的最高位1(从左往右出现的第一个1),那么我们只要将这个1向左移一位,同时将其他位置为0就可以得到大于目标值的最小的2^N次方。

可以结合下面的公式来理解

2^0 + 2^1 + 2^2 + …… + 2^(n-1) = 2^n -1

2^0 + 2^1 + 2^2 + …… + 2^(n-1) < 2^n

OK,现在可以将问题转化为如何将一个数的最高位1向左移一位,并且其他位置为0.其实很简单,只要我们将将低于最高位1的位置都变成1,然后在加1就可以得到。我们以20为例

0001 0100  // 20 

0001 1111 // 将低于最高位1的位置都变成1

0010 0000 // 0001 1111 + 1

现在在回过头看函数的实现

n |= n >>> 1

1. 将n向右移一位,那么最高位1也向右移了一位
2. 将n | (n>>>1),那么可知最高位1的下一位也变成了1

我们以20为例,最高位为5,经过操作之后第4位变成了1

0001 0100  // 20
0000 1010  // 20 >>> 1

0001 1110  // 20 | (20 >>> 1)

由于在Java中,int占4个字节,也就是32位,所有经过5次位移就可以将所有位都置为1。

这里有一种特殊情况,如果输入的数本身就是2^N次方,那么经过移位后得到的是2^(N+1)次方,以16为例

0001 0000  // 16

0001 1111 // 低于最高位都置为1

0010 0000 // 0001 1111 + 1 得32

可以看到得到的结果不符合预期,我们应该返回16才对。对于这种情况该怎么处理呢?很简单,我们只需要把输入数在处理之前做一个减1操作即可,为什么这样做呢?

原因很简单,如果一个数不是2的乘方,那么它本身减1之后,最高位的位置是不会变的,而我们后续会把最高位后的所有低位都变成1,所以这些低位本身是1或者是0都是不重要的,例如

0000 1101 - 1 = 0000 1100

0000 1101 --> 0000 1111
0000 1100 --> 0000 1111 不论是原值还是减1之后的值,经过位移操作之后得到的都是0000 1111

而如果输入数本身就是2的乘方,那么我们减1之后,最高位的位置会往右移动一位(剩余都是1),我们把它+1之后,又变成了它的原值。

loadFactor

当数据不断被put到HashMap,容量逐渐趋向饱和,那么就越容易造成哈希冲突,因此需要对HashMap进行扩容。而loadFactor(装载因子)就是用来决定扩容的时机。默认值是0.75

 /**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

相关常量

// HashMap的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

// 链表的长度超过该阈值,则尝试进行将链表变化红黑树
static final int TREEIFY_THRESHOLD = 8

// 链表的长度小于该阈值,则尝试将红黑数变成链表
static final int UNTREEIFY_THRESHOLD = 6;

// 当数组的长度超过该阈值,才会将链表变成红黑树
static final int MIN_TREEIFY_CAPACITY = 64;

// 当前map存储键值对的个数
transient int size;

// HashMap结构被修改的次数,put,remove等操作都会导致次数增加
transient int modCount;

// 扩容阈值,当map存储的键值对超过这个阈值就会进行扩容
// threshold = capacity * loadFactor
int threshold;

构造函数

// 无参构造函数,默认capacity为16,默认loadFactor为0.75
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 指定initialCapacity,loadFactor为默认值
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}


// 最终的构造函数
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);
}

可以看到HashMap在初始化的时候,只是计算了loadFactor以及threshold两个参数的值,并没有初始化数组table。

这是因为HashMap属于懒加载,只有首次进行put操作时,HashMap才会初始化table

寻址方式

对于put或者get操作,HashMap会取得key的hashcode,将hashcode与数组的长度取模,得到的结果作为数组的index。在计算机中取模的代价远高于位操作的代价,因此HashMap要求数组的长度必须为2的N次方。此时将Key的哈希值对2^N-1进行与运算,其效果即与取模等效。

// 当n = 2^N
 hash % n = hash & (n - 1)

hash

为了减少哈希冲突,HashMap并不是简单的把key的hashcode作为hash值,而是进行一定的优化,其中java8和之前的java版本对比有了其他的改进。

在java8之前的版本,HashMap通过如下方法使得最终的哈希值的二进制形式中的1尽量均匀分布从而尽可能减少哈希冲突

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

而在java8中,采用如下的方法计算hash

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

前面我们已经知道HashMap通过hash & (n - 1)来决定数据的位置,很显然这种计算方式决定了数据的位置只和低位的数值有关,这样会使得哈希冲突的可能性增加。

因此HashMap让高16位的值不变,低16位与高16位进行异或操作,最终得到hash值。

put

下面我们来看下,在java8中,HashMap如何插入一个元素。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

可以看到put的主要实现都在putVal这个函数里

/**
 * Implements Map.put and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; // 节点数据
    Node<K,V> p; // 待插入节点
    int n, i; // n 为数据长度,i为待插入节点在数组中的索引

    // table是否已经初始化,若无,则进行初始化,具体过程在resize中实现
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 计算待插入节点的索引,i = (n - 1) & hash
    // 若无冲突,则直接插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 插入的key是否已经在map中
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // HashMap是否树化    
        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;
            }
        }
        // 该key之前就在map中,返回旧value
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // map容量超过阈值,进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

代码看不清楚的话,直接来看流程图。

Java8中,HashMap最大的变动就是增加了树化处理,当链表中的数据超过8个时,HashMap会尝试将链表改造成红黑树

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 当数组的长度小于MIN_TREEIFY_CAPACITY,只是进行扩容,而不是树化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

可以看到当tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY为true时,只会进行扩容处理,而没有进行树化;MIN_TREEIFY_CAPACITY规定了HashMap可以树化的最小表容量为64,这是因为当一开始哈希表容量较小是,哈希碰撞的几率会比较大,而这个时候出现长链表的可能性会稍微大一些,这种原因下产生的长链表,我们应该优先选择扩容而避免这类不必要的树化。

那么为什么要进行树化呢?因为链表的查询效率远远低于数组,当过多的数据连成链表时,会降低查询效率。

resize

我们都知道数据是无法自动扩容的,只能重新计算新的容量,创建新的数组,并将所有数据拷贝到新的数组中,最后在释放掉旧的数据。

Java8对resize方法进行了优化,具体区别我们来仔细看下

Java8之前的resize

对于Java8之前的版本,它创建一个新的,长度为原来Capacity两倍的数组,保证新的Capacity仍为2的N次方,从而保证上述寻址方式仍适用。同时需要通过如下transfer方法将原来的所有数据全部重新插入(rehash)到新的数组中

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];
      newTable[i] = e;
      e = next;
    }
  }
}

该方法并不保证线程安全,而且在多线程并发调用时,可能出现死循环。其执行过程如下

  1. 遍历原数组中的元素
  2. 对链表上的每一个节点遍历:用next取得要转移那个元素的下一个,将e转移到新数组的头部,使用头插法插入节点
  3. 循环2,直到链表节点全部转移
  4. 循环1,直到所有元素全部转移

单线程rehash

在单线程下,扩容操作没有问题,扩容后链表顺序倒转(因为使用头插法)

多线程rehash

这里假设有两个线程同时执行了put操作并引发了rehash,执行了transfer方法,并假设线程一进入transfer方法并执行完next = e.next后,因为线程调度所分配时间片用完而“暂停”,此时线程二完成了transfer方法的执行。此时状态如下

接着线程1被唤醒,继续执行第一轮循环的剩余部分

e.next = newTable[1] = null
newTable[1] = e = key(5)
e = next = key(9)

结果如图

接着执行下一轮循环,结果状态图如下所示

继续下一轮循环,结果状态图如下所示

此时循环链表形成,并且key(11)无法加入到线程1的新数组。在下一次访问该链表时会出现死循环

Java8的resize

Java8也规定了每次扩容都为之前的两倍(n*2),但是Java8不会重新计算每个值在数组中的索引位置。这是因为由于扩容为之前的2倍,新的索引位置只有两种情况,一是索引没有变化,而是原位置+扩容长度(即偏移值为扩容长度大小)

前面就说过,数据的在数组的索引是由hash & (n-1)计算得到的,其中n为数组长度。

我们假设当前数组length=16,两个数据的hash值分别为21和5

从上图可以看到,在扩容前21(0001 0101)和5(0000 0101)计算后得到的索引都为5,在扩容后,21得到的新索引为5+16 = 21,而5得到的新索引还是5

我们可以看到,数组扩容为原来的两倍后,只是将最高位往左移了一位


因此在进行&运算之后,和扩容前相比只可能出现两种情况,一种是毫无影响,一种为原位置 + 扩容长度

扩容前        扩容后
01xxx        11xxx
  &            &
mnxxx        mnxxx
m n 扩容前 扩容后
0 0 00xxx 00xxx
0 1 01xxx 01xxx
1 0 00xxx 10xxx
1 1 01xxx 11xxx

那么源码中是如何判断这两种情况呢?从表中我们也可以看到,只有m=0,则索引不变,m = 1,则索引偏移。那么如何判断m的值呢,其实只要 hash & n 就可以得到,源码实现如下,oldCap是原数组的大小,即 n

if ((e.hash & oldCap) == 0) {
    ...
} else {
    ...
}

接下来,我们来看下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;
    // map已经初始化
    if (oldCap > 0) {
        // map容量超过最大值,不进行扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 进行扩容,map容量*2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // map构造时指定了initialCapacity参数
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // map构造时未指定任何参数
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // map构造时指定了initialCapacity参数,重新计算扩容阈值(threshold)
    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;
                //表头只有一个数据,重新计算index
                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
                //扩容后,索引没有发生变化,用loHead表示表头
                    Node<K,V> loHead = null, loTail = null;
                    //扩容后,索引发生变化(原有值+扩容长度),用hiHead表示
                    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;
}

resize的操作涉及了很多场景

  • 无参构造:新容量是默认容量值16,新阀值是默认容量值16 * 负载因子0.75 = 12
  • 有参构造:新容量是由传入的initialCapacity数据计算出大且最近的2^n的值,新阀值是新容量 * 0.75
  • 正常扩容:旧容量大于等于最大容量值,设置新阀值为Integer.MAX_VALUE,结束扩容;否则新容量double操作

fast-fail

在使用迭代器的过程中如果HashMap被修改,那么ConcurrentModificationException将被抛出,也即Fast-fail策略

当HashMap的iterator()方法被调用时,会构造并返回一个新的EntryIterator对象,并将EntryIterator的expectedModCount设置为HashMap的modCount(该变量记录了HashMap被修改的次数)

HashIterator() {
    expectedModCount = modCount;
    Node<K,V>[] t = table;
    current = next = null;
    index = 0;
    if (t != null && size > 0) { // advance to first entry
        do {} while (index < t.length && (next = t[index++]) == null);
    }
}

在通过该Iterator的next方法访问下一个Entry时,它会先检查自己的expectedModCount与HashMap的modCount是否相等,如果不相等,说明HashMap被修改,直接抛出ConcurrentModificationException

总结

  1. Java8中HashMap相比之前的版本,在存储结构,寻址方式,resize等方面都进行了一定的改造优化
  2. HashMap的容量必须是2^N,是因为在计算数组索引时,位运算比取模运算性能更高
  3. HashMap使用的Lazy模式,初始化时不会创建table,而是直到put数据时才会创建table数组
  4. HashMap在resize时,索引只有两种情况,一是无变化,和原索引相等,另一种是原索引 + 扩容长度

参考文献


Reprint please specify: wbl Java8 HashMap

Previous
从EnableScheduling说起 从EnableScheduling说起
在之前一篇文章Spring Boot定时任务,我们已经了解了如何在Spring Boot中创建并管理定时任务,今天我们通过源码来看下Spring Boot中的具体实现。 EnableScheduling注解要想在Spring Boot中创建
2020-03-12
Next
Java Reference--2 Java Reference--2
Reference生命周期当Reference引用的对象不可达时,Reference对象自身会被放入一个pending队列。同时有一个引用处理线程会不断从队列里面拉取Reference对象进行处理。 Reference成员变量// Refe
2020-02-23