HashMap死循环【基于JDK1.7】情景重现与分析(带示意图)

简介

JDK1.7 中 HashMap 多线程成环问题,几乎已经成了Java程序员人人需要了解的知识了,本文就带大家来一切重现一下 HashMap 死循环 ?

前置知识复习

阅读本文前,最好确保你知道 JDK1.7 实现的 HashMap (以下简称 HashMap)learn more 有以下几个特点:

  • 首先 HashMap 采用的数据结构是数组 + 链表

  • 使用 new HashMap() 创建对象时,如果构造器参数中没有传入数据,那么就不会调用 inflateTable 来为 table 数组申请内存。

  • HashMap 的成员变量 threshold 表示扩容发生的边界值,loadFactor 表示负载因子,table.length 可以表示内部容量。

  • HashMap 扩容判断是在调用 put 或者 putAll 的时候,且如果需要扩容,扩容(resize)发生在实际插入新数据(createEntry)之前。以 put 举例,扩容条件是 (size >= threshold) && (null != table[bucketIndex]) 这个稍后举例。

  • HashMap 当前内部容量总是 \(2^n\)每次扩容 Entry 数组长度扩大 1 倍,即新容量为 \(2^{n+1}\)。threshold 总是等于 capacity * loadFactor

  • HashMap 的扩容方法是 resize,扩容时需要创建一个新的 Entry 数组,然后把数据从旧的 Entry 数组迁移到新的 Entry 数组,这个过程的迁移函数是 transfer

哈希算法

哈希算法就是把任意长度的消息压缩至固定长度的消息摘要。相同的输入经过 hash 函数计算得到的结果总是相同的。

虽然从理论上讲,只要哈希结果长度足够长,那么不同的输入,就会产生各不相同的散列值。
然而事实上,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出。此时就会产生哈希冲突。
解决哈希冲突的方法有很多种,HashMap 采用的是链式地址法,也就产生了数组 + 链表的数据结构。

HashMap 利用了哈希算法,把当前元素的关键字通过某个函数映射到数组中的某个位置。

算法:先求输入对象 key 的 hashCode,然后再经过高低位异或计算(^) 得到最终的散列值 hash,然后再调用 indexFor,得到数组下标。

通过反射访问 HashMap 成员方法

现在,我们不能直接调用 indexForhashcapacity 这些成员方法,所以我写了一个 HashMapWrapper 来封装反射逻辑。

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;

public class HashMapWrapper {
    // 代理 java.util.HashMap 类
    private HashMap hashMap;

    public HashMapWrapper(HashMap hashMap) {
        this.hashMap = hashMap;
    }

    // 计算对象 k 在 Entry 数组的数组下标
    public int indexFor(Object k) {
        return indexFor(hash(k), capacity());
    }

    // 通过反射运行 HashMap#hash(Object)
    int hash(Object k) {
        try {
            Method hash = HashMap.class.getDeclaredMethod("hash", Object.class);
            hash.setAccessible(true);
            return (Integer) hash.invoke(hashMap, k);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }

    // 通过反射运行 HashMap#indexFor(int, int)
    int indexFor(int h, int length) {
        try {
            Method indexFor = HashMap.class.getDeclaredMethod("indexFor", int.class, int.class);
            indexFor.setAccessible(true);
            return (Integer) indexFor.invoke(hashMap, h, length);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }

    // 通过反射运行 HashMap#capacity()
    int capacity() {
        try {
            Method capacity = HashMap.class.getDeclaredMethod("capacity");
            capacity.setAccessible(true);
            return (Integer) capacity.invoke(hashMap);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }

    // 获取 Entry 数组指定下标下的链表
    LinkedList<Object> getKeysOfTable(int index) {
        LinkedList<Object> result = new LinkedList<Object>();
        try {
            // 通过反射访问 HashMap#table 即 Entry 数组
            Field table = HashMap.class.getDeclaredField("table");
            table.setAccessible(true);
            Object[] array = (Object[]) table.get(hashMap);
            getKeys(array[index], result);
            return result;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    // 递归函数,辅助遍历 Entry 链表结构,并将 Entry 的 key 添加到 keys 链表中
    private void getKeys(Object o, LinkedList<Object> keys) throws Exception {
        if (o == null) return;
        Field key = o.getClass().getDeclaredField("key");
        key.setAccessible(true);
        keys.add(key.get(o));
        Field next = o.getClass().getDeclaredField("next");
        next.setAccessible(true);
        getKeys(next.get(o), keys);
    }
}

找出发生哈希冲突的输入值

对应不同的关键字可能获得相同的 hash 值,即 key1≠key2,但是 f(key1)=f(key2)。这种现象就是哈希冲突。

现在我想要找出一组能够产生哈希冲突的关键字。而且成环的条件至少需要 2 个 Entry 元素,且 next 互相指向彼此。
因此,我需要找的这组关键字,扩容前会在同一地址发生哈希冲突,且扩容后仍然会在同一地址发生哈希冲突

import java.util.HashMap;

public class Main {

    public static void main(String[] args) {
        HashMap<Integer, Object> hashMap = new HashMap<Integer, Object>(4);
        HashMapWrapper wrapper = new HashMapWrapper(hashMap);
        // new HashMap 之后,不会立即给 Entry 数组分配内存
        // HashMap 是允许 null 作为 key 的,因此我用 null 来触发 inflateTable 初始化 Entry 数组
        hashMap.put(null, null);
        for (int i = 0; i < 100; i++) {
            int index = wrapper.indexFor(i);
            // 寻找在数组下标为 1 的位置发生冲突的关键字
            if (index == 1) {
                System.out.print(i);
                System.out.print(" ");
            }
        }

    }
}

初始容量为 4 时,在数组下标为 1 位置发生哈希冲突的关键字如下:

1 5 9 13 16 20 24 28 35 39 43 47 50 54 58 62 65 69 73 77 80 84 88 92 99

修改初始容量为 8 时,在数组下标为 1 位置发生哈希冲突的关键字如下:

1 9 16 24 35 43 50 58 69 77 84 92

我挑选了 1 9 16 24 作为我的实验值。

单一线程 HashMap 扩容

import java.util.HashMap;

public class ShowNoProblem {

    public static void main(String[] args) {
        HashMap<Integer, Object> hashMap = new HashMap<Integer, Object>(4, 0.5f);
        HashMapWrapper wrapper = new HashMapWrapper(hashMap);
        hashMap.put(1, null);
        hashMap.put(9, null);
        System.out.println("扩容前容量:" + wrapper.capacity());
        System.out.println(wrapper.getKeysOfTable(1));
        hashMap.put(16, null);
        System.out.println("扩容后容量:" + wrapper.capacity());
        System.out.println(wrapper.getKeysOfTable(1));
    }
}

控制台输出结果如下:

扩容前容量:4
[9, 1]
扩容后容量:8
[16, 1, 9]

HashMap.Entrykey, value, hash, next 四个属性。图中主要展示了三种属性,并且反映出了 HashMap 的数组 + 链表的数据结构。

resize 扩容函数

void resize(int newCapacity) {
    // 暂存老 Entry 数组的引用
    Entry[] oldTable = table;
    // 暂存老 Entry 数组的内部容量
    int oldCapacity = oldTable.length;
    // 条件成立:已经达到最大容量,无法继续扩容
    if (oldCapacity == MAXIMUM_CAPACITY) {
        // 提高扩容临界阈值,这样就不会再扩容了
        threshold = Integer.MAX_VALUE;
        return;
    }
    // 申请一个新的数组,容量为新的容量
    // 如果是 put 单个元素时引起的扩容,newCapacity 等于 2 * oldCapacity,扩大一倍
    // 如果是 putAll 多个元素时引起的扩容,newCapacity 等于 2^n * oldCapacity。
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    // 修改扩容阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer 转移函数

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable, boolean rehash) {
    // 执行 transfer 之前,已经申请了一块新的数组,新的容量就等于新的数组长度
    int newCapacity = newTable.length;
    // 外层:顺序遍历老数组上的 Entry 引用
    for (Entry<K,V> e : table) {
        // 从头结点开始依次遍历链表中的所有元素
        // e 表示当前正在访问的 Entry,e 相当于“遍历移动指针”
        while(null != e) {
            // 暂存 e 的 next,防止修改 e.next 引起的遍历指针丢失
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            // 使用“头插法”把 e 从老 Entry 数组转移到新 Entry 数组
            // 先修改 e 的 next 指向,指向新 Entry 数组的当前头结点
            e.next = newTable[i];
            // 修改新 Entry 数组的头结点为 e
            newTable[i] = e;
            // 此时,移动当前遍历结点为暂存的 next 结点。
            // 表示的是老 Entry 数组中的下一个元素,现在已经是新的头结点了
            e = next;
        }
    }
}

createEntry 创建

使用“头插法”向链表中添加新的元素。

void createEntry(int hash, K key, V value, int bucketIndex) {
    // 暂存当前的链表头结点,防止老的头结点丢失
    Entry<K,V> e = table[bucketIndex];
    // new 一个新的 Entry 覆盖头结点,且新的 Entry 头结点的 next 指向老的头结点 e
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    // HashMap 内部元素数量 + 1
    size++;
}

多线程 HashMap 成环问题再现

import java.util.HashMap;

public class ShowProblem {

    public static void main(String[] args) {
        final HashMap<Integer, Object> hashMap = new HashMap<Integer, Object>(4, 0.5f);
        final HashMapWrapper wrapper = new HashMapWrapper(hashMap);
        hashMap.put(1, null);
        hashMap.put(9, null);
        new Thread(new Runnable() {
            public void run() {
                hashMap.put(16, null);
                System.out.println("扩容后容量:" + wrapper.capacity());
                System.out.println(wrapper.getKeysOfTable(1));
            }
        }, "a").start();
        new Thread(new Runnable() {
            public void run() {
                hashMap.put(24, null);
                System.out.println("扩容后容量:" + wrapper.capacity());
                System.out.println(wrapper.getKeysOfTable(1));
            }
        }, "b").start();
    }
}

预先设置断点

直接运行这段代码,不容易复现此问题,我们需要借助断点。在以下几个位置设置条件断点:

首先,HashMap resize 方法内 581 行和 582 行设置条件断点,条件为 Thread.currentThread().getName().equals("a") || Thread.currentThread().getName().equals("b")

然后,在 HashMap transfer 方法 594 行设置断点,条件为 Thread.currentThread().getName().equals("a")

Debug 顺序

启动 Debug ,线程 a 和线程 b 的代码停留在 transfer 这一行:

我的这次运行时,红色 √ 是在线程 a,如果你的 红色 √ 在线程 b,那你切换到线程 a。

继续运行线程 a 的代码,让 a 停留在 transfer 方法 next 暂存 e.next 指针之后。

这时候,不要再运行线程 a 的代码了,选中线程 b,并让线程 b 的 transfer 方法执行完。

此时切换到线程 a 进行观察,你发现 next.next == e 成立。此时已经成环。

我知道,这样子还是不够直观,并不太容易记住到底怎么成环的,下面我再给出示意图。

成环示意图

为了表现出过程,这次画图我简化一点,Entry 中只显示 key 关键字,next 指针直接用箭头表示。

线程 a 和线程 b 执行过分别申请了新 table 数组,并作为参数传给 transfer 方法。

Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));

线程 a 执行到 transfer 的 594 行时被我停住了,线程 b 也刚好执行到 transfer 的 594 行,情况如图所示:

如上图所示,局部变量 e,next 以及方法参数 newTable 都在线程 a 和线程 b 的栈帧中存放着。此时,两个 newTable 都是空的。

线程 b 继续运行,执行完 transfer 方法,停在 HashMap 的 582 行 this.table = newTable; (即使让线程 b 都执行完,结果还是一样的,你可以试一试),此时结果如下:

即顺序遍历线程 b 中的 newTable[1] 位置的链表,key的值依次为 1, 9。上图绿色连线就可以表示线程 b 的 newTable。

现在转而执行 线程 a 的代码

// newTable[1]等于 null, 所以 Entry(key=9).next 指向 null
e.next = newTable[i];  
// 头结点指向 Entry(key=9)
newTable[i] = e; 

继续看下图:

如上图所示,红色连线表示就是线程 a 的 newTable[1] 上的链表。

// 当前线程 a 的栈帧中的 e 被修改为 Entry(key=1)
e = next;

(... 新的一轮循环, 此时 e 指向 Entry(key=1) )

// next 指向 Entry(key=9)
Entry<K,V> next = e.next;
(...省略部分代码)
// newTable[1]等于 Entry(key=9), 所以 Entry(key=1).next 指向 Entry(key=9)
e.next = newTable[i];  
// 修改头结点指向 Entry(key=1)
newTable[i] = e; 

线程 a 运行到 HashMap 的 600 行 e = next 停住时,此时的情形如下图:

如上图所示,我们发现此时线程 a 的 newTable[1] 的链表已经和线程 b 的 newTable[1] 的链表重合了。而且线程 a 栈帧上的 e 和 next 居然交换位置了!

transfer 方法并不会创建新的 Entry,所以线程 a 和线程 b 是用的同样的 Entry 对象。线程 b 在之前的操作,影响了现在线程 a 的执行。

线程 a 再运行一轮循环:

// 当前线程 a 的栈帧中的 e 被修改为 Entry(key=9)
e = next;

(... 新的一轮循环, 此时 e 指向 Entry(key=9) )

// next 指向 Entry(key=1)
Entry<K,V> next = e.next;
(...省略部分代码)
// newTable[1]等于 Entry(key=1), 所以 Entry(key=9).next 指向 Entry(key=1)
e.next = newTable[i];  
// 修改头结点指向 Entry(key=9)
newTable[i] = e; 

然后停在 HashMap 600 行,观察此时的状态:

此时就已经成环了。

我们可以再让线程 a 执行一轮循环:

依旧是停在 HashMap 600 行,之后就是最后这两个状态不断切换,不断循环。

参考文档

Java集合之一—HashMap

1.7 hashmap 多线程成环问题(仅为个人记录)

【高并发】终于弄懂为什么局部变量是线程安全的了!!

posted @ 2021-02-23 15:49  极客子羽  阅读(583)  评论(0编辑  收藏  举报