[阿里必考]JDK源码分析之HashMap

789 阅读10分钟

原文首发于:语雀面试资料共享, 欢迎各位大佬前来贡献。

HashMap 底层是基于 数组 + 链表 组成的,通过 链地址法 不过在 jdk1.7 和 1.8 中具体实现稍有不同。

JDK 1.7

1.7 中的数据结构图: image.png 先来看看 1.7 中的实现。 image.png 这是 HashMap 中比较核心的几个成员变量;看看分别是什么意思?

  • 初始化桶大小为16,因为底层是数组,所以这是数组默认的大小 。
  • 桶最大值 1<<30
  • 默认的负载因子(0.75
  • table 真正存放数据的数组。
  • Map 存放数量的大小。
  • 桶大小,可在初始化时显式指定。
  • 负载因子,可在初始化时显式指定。

初始化值

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        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);

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

传入的初始化值 initialCapacity 会自动转换成离该值最近 & 大于该值的2的N次方。JDK1.7使用一个循环,而JDK1.使用tableSizeFor 方法代替了循环,这个方法也很巧妙。

负载因子

给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,扩容后的大小是当前的2倍。而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能,因此通常建议能提前预估 HashMap 的大小最好,尽量的减少扩容带来的性能损耗。

根据代码可以看到其实真正存放数据的是 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;  这个数组,那么它又是如何定义的呢? image.png Entry 是 HashMap 中的一个内部类,从他的成员变量很容易看出:

  • key 就是写入时的键。
  • value 自然就是值。
  • 开始的时候就提到 HashMap 是由数组和链表组成,所以这个 next 就是用于实现链表结构。
  • hash 存放的是当前 key 的 hashcode。

知晓了基本结构,那来看看其中重要的写入、获取函数。

put方法

代码来自github.com/openjdk/jdk…

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

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

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

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

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
  • 判断当前数组是否需要初始化。
  • 如果 key 为空,则 put 一个空值进去。
  • 根据 key 计算出 hashcode (h = key.hashCode()) ^ (h >>> 16) 。
  • 根据计算出的 hashcode 定位出所在桶。
  • 如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
  • 如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。
  • 当调用 addEntry 写入 Entry 时需要判断是否需要扩容。如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位。而在 createEntry 中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表。

get方法

代码来自github.com/openjdk/jdk…

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);
    return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    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 != null && key.equals(k))))
            return e;
    }
    return null;
}
  • 首先也是根据 key  计算出 hashcode ,然后定位到具体的桶中。
  • 判断该位置是否为链表。
  • 不是链表就根据 key  的 hashcode  是否相等来返回值。
  • 为链表则需要遍历直到 key  及 hashcode  相等时候就返回值。
  • 啥都没取到就直接返回 null  。

hashCode 和 equals 是否要一起重写?

由上面 put方法 可知,先计算 key 的 hashCode() 方法,决定 key 存在哪一个数组下标中,才会调用 equals() 方法判断是否是同一个 key ,决定是否替换值。 所以必须要一起重写,才能保证在 HashMap 中数组下标相同,如果不同,根本就不会调用 equals() 方法了。

JDK 1.8

jdk1.8的实现和1.7大部分一样。 但是jdk1.7的缺陷是:

当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)

所以1.8的实现是:当链表长度超过 8 时,会变成红黑树。 结构图: image.png 先来看看几个核心的成员变量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
transient Node<K,V>[] table;
/**
 * Holds cached entrySet(). Note that AbstractMap fields are used
 * for keySet() and values().
 */
transient Set<Map.Entry<K,V>> entrySet;
/**
 * The number of key-value mappings contained in this map.
 */
transient int size;

和 1.7 大体上都差不多,还是有几个重要的区别:

  • TREEIFY_THRESHOLD=8 用于判断是否需要将链表转换为红黑树的阈值。
  • HashEntry 修改为 Node。

Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。 再来看看核心方法。

put方法

源码github.com/openjdk/jdk… image.png 看似要比 1.7 的复杂,我们一步步拆解:

  1. 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
  2. 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
  3. 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key  的 hashcode  与写入的 key  是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
  4. 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
  5. 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
  6. 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
  7. 如果在遍历过程中找到 key 相同时直接退出遍历。
  8. 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
  9. 最后判断是否需要进行扩容。

get方法

源码:github.com/openjdk/jdk…

public V get(Object key) {
    Node<K,V> e;
    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 && // always check first node
            ((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);
        }
    }
    return null;
}

get 方法看起来就要简单许多了。

  • 首先将 key hash 之后取得所定位的桶。
  • 如果桶为空则直接返回 null 。
  • 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
  • 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
  • 红黑树就按照树的查找方式返回值。
  • 不然就按照链表的方式遍历匹配返回值。

从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)

并发不安全

HashMap 原有的问题也都存在。

public static void main(String[] args) throws InterruptedException {
        int N = 100000;
        final HashMap<String, String> map = new HashMap<String, String>();
        //final HashMap<String, String> map = new HashMap<String, String>(N); //指定容量,避免扩容
        Thread[] ts = new Thread[N];
        for (int i = 0; i < N; i++) {
            ts[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    map.put(UUID.randomUUID().toString(), "");
                }
            });
        }
        for (int i = 0; i < ts.length; i++) {
            ts[i].start();
        }
        for (Thread t : ts) {
            t.join();
        }
        System.out.println("end");
    }

image.png 上面代码多执行几遍,会发生死循环。 如果在JDK1.8及其以上版本,

  • 可能会发生死循环
  • 还有可能发生 java.util.HashMap$Node cannot be cast to java.util.HashMap$TreeNode 

大部分博主只提到了死循环,而没有提到类型强壮异常

死循环是如何发生

是扩容引起的。

源码分析

由上面可知 put方法 流程

  1. 判断key是否已经存在, key 存在,替换。不存在,插入新元素
  2. 插入新元素后,检查 (size++ >= threshold) 会发生扩容,新容量是现在的二倍。
  3. 扩容调用 resize() 方法。这里会新建一个更大的数组,并通过transfer方法,移动元素,移动的逻辑也很清晰,遍历原来table中每个位置的链表,并对每个元素进行重新hash,在新的newTable找到归宿,并插入。

resize() 方法源码

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

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

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

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

/**
  * Transfers all entries from current table to newTable.
  */
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);
        }
    }

案例分析

假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容(为了验证效果,假设负载因子是 1 )。 假设此时有两个线程,线程1线程2同时在插入第三个节点, resize() 方法就会同时执行,两个线程都会新建一个新的数组。 image.png 假设 线程2 在执行到Entry<K,V> next = e.next;之后,cpu时间片用完了,这时变量 e 指向节点 a ,变量 next 指向节点 b  。

线程1继续执行,很不巧, a 、 b 、 c 节点 rehash (即调用 indexFor 方法) 之后又是在同一个位置 7 ,开始移动节点 第一步,移动节点a image.png 第二步,移动节点b image.png 第三步,移动节点c image.png 这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下: image.png

这时,在 线程2 中,变量 e 指向节点 a ,变量 next 指向节点 b ,开始执行循环体的剩余逻辑。 image.png 执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系 image.png 变量 e 又重新指回节点 a ,只能继续执行循环体,这里仔细分析下: 1、执行完Entry<K,V> next = e.next;,目前节点 a 没有 next ,所以变量 next 指向 null ; 2、e.next = newTable[i]; 其中 newTable[i] 指向节点 b ,那就是把 a 的 next 指向了节点 b ,这样 a 和 b 就相互引用了,形成了一个环; 3、newTable[i] = e 把节点a放到了数组i位置; 4、e = next; 把变量 e 赋值为 null ,因为第一步中变量 next 就是指向 null ; 所以最终的引用关系是这样的: image.png

节点a和b互相引用,形成了一个环,只要遍历这个数组下面的该环形链表,就会发生死循环。

JDK1.7可以通过设置初始容量避免扩容,从而避免死循环发生。 但是JDK1.8避免了死循环发生,但是无法避免下面的类 类型强转异常发生 

类型强转异常是如何发生

JDK1.8源码:

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

两个线程1和线程2同时插入。

  1. 线程1插入第2个元素执行到上面源码14行被CPU挂起了
  2. 线程2已经插入到第8个元素了,已经把链表变成了红黑树。
  3. 线程1被唤醒,类型强转失败。

参考:

crossoverjie.top/2018/07/23/… (存档)

juejin.cn/post/684490…存档)