从面试暖场题到挖出JDK Bug: 谈谈你对 HashMap, ConcurrentHashMap 的理解

参考文章

JAVA 面试的暖场题

Java 开发中用的比较多的集合类有哪些?

  • 如果答案中包含了 HashMap, 那很自然地引到下一个问题

谈谈你对 HashMap 的理解, 底层的基本实现。

  • HashMap 是计算机数据结构哈希表 ( hash table ) 的一种实现。
    • Tips: java 中的另一个哈希表实现类的名称就是 Hashtable, 由于历史原因, 命名还不是驼峰的, 后续阅读中需要注意区分我们所谈论的是数据结构的概念哈希表(hash table) , 还是 JAVA 的实现类 Hashtable

HashMap, Hashtable, TreeMap, HashSet 有什么区别 (这里最重要的是区分底层数据结构的差异)

  • Hashtable 和 HashMap 是可比的, 因为底层数据结构都是哈希表。
    • Hashtable 是一个 java 早期版本的哈希表实现
      • 不支持 null 键和值
      • 线程安全
      • 同步开销较大, 现已很少被推荐使用
    • HashMap 是应用更加广泛的哈希表实现
      • 支持 null 键和值
      • 线程不安全
  • TreeMap 底层数据结构是红黑树
    • get, put, remove 等操作时间复杂度是 O ( l o g ( n ) ) O(log(n)) Olog(n)
    • 支持元素的顺序访问
  • HashSet 要实现的数据结构是集合(Set), 但是底层实现使用的是 HashMap
    • 没想到吧, 看下源码就会发现, HashSet 基本上就是一个限制了功能的 HashMap

既然 HashMap 不是线程安全的, 多线程访问 HashMap 会出现什么问题

  • JDK1.8 以前(JDK1.8 对 HashMap 的扩容机制进行了优化), 如果同时有两个线程对 HashMap 进行写入操作, 引发 HashMap 的扩容操作, 可能会导致桶数组后的链表出现环, 继而导致再有线程调用 get(key) 时候陷入死循环,耗尽 CPU 资源
  • 后文会进行详细说明

用什么方案解决线程安全需求

  • ConcurrentHashMap

ConcurrentHashMap 如何高效实现线程安全

  • 后文展开叙述

先修知识

首先, HashMap 所对应的数据结构的学术名称是哈希表(Hash Table) , 其基本要素包含

  • 哈希函数(Hash Function )
  • 桶数组( Array Of Bucket), 其实就是一个数组, 桶是已经约定俗称的名称
  • 哈希表就是一个将数据的键(Key), 进行哈希计算(Hash Function)并对数组长度取模, 获得索引 , 存储到相应的数组位置中。 如下图就展示了哈希表如何存储一个电话号码本, 通过这个数据结构, 我们可以快速通过姓名检索到其对应的电话号码。
    在这里插入图片描述

细心的同学必须要骂了, HashMap 的链表哪去了? HashMap 底层不应该是下图这样的吗?
在这里插入图片描述

HashMap 确实大致类似(细节差异在后文中可以看到)上图这样, 在桶数组的后面追加了链表, 但是这其实不是数据结构哈希表(hash table) 的固定实现, 这种做法严格意义上来说只是解决哈希冲突(hash collision) 的一种方法而已。 具体而言, 哈希冲突解决主要有如下 2 大类策略:

  • 独立成链法

    • 独立成链法中, 相同的索引上有往往有某种数据结构串联起来的多个数据项(Entry)
    • 桶的后面可以跟的数据结构有
      • 链表(最常见)
      • 自平衡二叉树(Java 8 中的 HashMap 实现就采用了这种方案
      • 其他数据结构
  • 开放定址法

    • 在这种策略下, 所有的数据项都存储在桶数组本身。
    • 在这种策略下, 当一个冲突发生时, 由于原本应该插入的桶位置已经被占用, 新进的元素需要以已被占用位置为起始点, 用某种方法,再次找到一个空置的位置插入。
      • 线性探测法 : 从被占用的桶位置开始, 以固定间隔(通常是 1 ) 向后寻找空余位置
      • 平方探测法 : 从被占用的桶位置开始, 以固定间隔( k 2 k^2 k2 ) 向后寻找空余位置
      • 双重哈希法:第 i 次冲突发生以后,通过另一个hash 函数 h 2 h_2 h2 计算出的间隔( i ⋅ h 2 ( k ) ) m o d    ∣ T ∣ i \cdot h_2(k) ) \mod |T| ih2(k))modT)寻找空余位置
    • 这种策略有一个明显的缺点是: 存储的数据项数量无法超过桶数组的长度。 所以在应用中往往会伴随着强制扩容(resizing), 带来相应的开销

HashMap 的实现细节( JDK1.8 )

首先 HashMap 的桶结构的声明代码如下:

    transient Node<K,V>[] table;

示意图如下
在这里插入图片描述

Node 元素的声明如下

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

        Node(int hash, K key, V value, Node<K,V> next) {
   
   
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
		// ... 省略
}

示意图
在这里插入图片描述

HashMap 的构造函数

public HashMap(int initialCapacity, float loadFactor){
   
     
    // ... 省略
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

可以看到 HashMap 构造函数中没有立刻初始化 Node<K,V>[] table , 采用了延迟加载的策略。

这里值得注意的是, 初始容量(initialCapacity) 和 负载系数(loadFactor)是影响 HashMap 性能的两个参数。

  • 初始容量(initialCapacity)
    • 桶数组创建时的大小
  • 装填系数(loadFactor)
    • 一种衡量何时需要重新调整 HashMap 大小, 并进行再散列的参数, 后面展开描述

HashMap 的 put 方法:

public V put(K key, V value) {
   
   
    return putVal(hash(key), key, value, false, true);// 注意这里已经对 key 调用了 hash 方法, 计算了对应的 hashcode
}

可以看到有两个参数被默认设置成了 false 和 true, 分别是 onlyIfAbsent, evict .

  • onlyIfAbsent 为 false 表示, 如果放置的元素已经存在, 就予以替换
  • evict 参数在 HashMap 类中无意义, 因为搜索一下可以发现, 只有一个方法 void afterNodeInsertion(boolean evict) { } 使用了这个参数, 而这个方法体是空的。 LinkedHashMap 继承了 HashMap 实现了这个方法体, 这里不做展开叙述。

下面为 putVal 的方法体源码,添加了注释用于说明代码逻辑。


    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)
        	// table 为空时, 通过 resize() 方法进行初始化
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)// 注意到此处指针 p 已经被指向了桶中的一个元素
        	// 此处通过(n - 1) & hash 计算出该元素在桶数组中的下标, 如果此位置为空,则可以直接放置该元素
        	// 为什么通过 (n - 1) & hash 计算下标在文章后面详细解释
            tab[i] = newNode(hash, key, value, null);
        else {
   
   
        	// 下面对应桶的位置已经被占用的情况, 属于 hash 取模后索引·冲突解决的部分
            Node<K,V> e; K k;// 初始化 element 指针 e, 如果当前待插入的 key 值经过后续的搜索后, 发现已经存在, 该指针会已经存在的元素位置, 否则为空
            if (p.hash == hash && // 桶中已经放置的元素hash值是否和当前待放置的元素hash值相等
                ((k = p.key) == key || (key != null && key.equals(k))))// 且桶中已经放置的元素 key 值和当前待放置的元素 key 值相同
                e = p; // 指向已经存在的元素位置 
            else if (p instanceof TreeNode)
            // 如果桶中已经放置的元素是一个树节点,说明这个桶的位置上已经发生多次冲突, 属于这个位置的多个元素以自平衡二叉树的结构, 连接在这个桶的后面了所以新的待放置的元素需要插入到这颗树中,故调用 putTreeVal
            // 此处传入当前 hashMap 的引用 this 的原因是, putTreeVal() 是一个定义在静态内部类 TreeNode 的方法, 该方法内部需要调用一个定义在 HashMap 类的非静态方法 newTreeNode() , 而静态内部类是不能直接访问外部类的非静态成员的, 所以需要传入引用
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)    
            else {
   
   
            // 桶的位置上是一个链表头
                for (int binCount = 0; ; ++binCount) {
   
   // binCount 用于计数链表中的元素个数
                    if ((e = p.next) == null) {
   
   // p.next 为空说明到达链表尾
                        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))))
                        // 如果在遍历的过程中发现链表中已经存在该 key 值相同的元素,跳出循环 
                        break;
                    p = e;
                }
            }
            if (e != null) {
   
    // existing mapping for key
            // 这个地方针对桶中或桶后链表中发现key值相同元素的情形
            // 根据onlyIfAbsent 参数决定是否对已有元素的值进行替换
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);// 用于LinkedHashMap 的方法, 对于HashMap无意义
                return oldValue;
            }
        }
        ++modCount; //HashMap的数据被修改的次数,这个变量用于迭代过程中的Fail-Fast机制,其存在的意义在于保证发生了线程安全问题时,能及时的发现(操作前备份的count和当前modCount不相等)并抛出异常终止操作。
        if (++size > threshold)// hashMap 节点数目大于阈值, 进行扩容
            resize();
        afterNodeInsertion(evict);// 用于LinkedHashMap 的方法, 对于HashMap无意义
        return null;
    }

上述代码实现的示意图如下
在这里插入图片描述

结合注释通读代码后, 我们先回答注释中没有解决的问题:

  • 桶的索引计算过程为什么是 (n - 1) & hash
    • 答: 这就是一个运算技巧, 当 l e n g t h = 2 n length = 2^n length=2n 时, X % l e n g t h = X & ( l e n g t h − 1 ) X \% length = X \& (length - 1) X%length=X&(length1) , 实际上就是简单的对 hash 值以数组长度取模
    • HashMap 的桶数组大小永远都是 2 n 2^n 2n, 扩容也是翻倍当前的大小

这个问题进一步拓展:

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

注意, 这里获取 hash 值的计算调用的是HashMap 中的一个方法 hash(key) , 并没有直接调用 key 的 hashCode()方法来直接产生hash值。

    static final int hash(Object key) {
   
   
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 这里的运算逻辑是, 将 key 的 hashCode 方法返回值 与 其本身右移16位的值作 “异或” 操作。
  • 这样做的效果是, hashCode 的高位数据被右移到了低位, 与原有的低位数据做了异或运算, 这样是为了解决有些数据的 key 值计算后的 hash 差异主要在高位, 如果将这种数据取余后, 很容易会发生 hash 碰撞。(例如 100000001 和900000001 对 16 取余结果都是 1 ) , 进行这种运算后, 高位的差异就会在低位得到体现, 减小发生碰撞的概率。

引申问题: 考虑到 HashMap 最常见的 key 类型是 StringString 类的 hashCode() 是怎样实现的呢

下面是 String 类 hashCode() 的源码

    public int hashCode() {
   
   
        int h = hash;
        if (h == 0 && value.length > 0) {
   
   
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
   
   
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

上面的计算逻辑是: S t r i n g [ 0 ] ∗ 3 1 n − 1 + S t r i n g [ 1 ] ∗ 3 1 n − 2 + ⋯ + S t r i n g [ n − 1 ] String [0]*31^{n-1} + String[1]*31^{n-2} + \cdots +String[n-1] String[0]31n1+String[1]31n2++String[n1]

  • 这就是简单的一个多项式。 乘数被选为 31 的原因有一些内在的原因
  • Joshua Bloch 在 《Effective Java》 中解释:
    • “选 31 作为乘数是因为它是一个素数, 如果是一个偶数的话, 当乘数溢出以后, 这个数的部分信息就被丢失了, 因为乘 2 就等同于二进制的左移操作。 但为什么不选用奇数做乘数的原因就没那么清晰了, 但是这是一个传统。 31 有一个很好的性质, 就是乘以 31 可以通过二进制位移以及一次减法操作快速实现 , 因为: 31 ∗ i = = ( i < < 5 ) − i 31 * i == (i << 5)-i 31i==(i<<5)i, 现代的虚拟机实现可以自动对这个运算进行优化”
    • 选 31 这个数字的原因还有一个来自卡耐基梅隆大学教授更为精妙的解答:
      • ASCII 码定义的 26 个字母里, 大小写字母第6个bit位是恰好都不一样的. 而对英语而言, 日常生活中小写字母比大写字母的使用频率更高, 选用 31 作为乘数, 可以把这个 bit 位出现1的概率降低, 摊给低位, 详细内容参考 hashCode 设计的道理.

在这里插入图片描述

hashCode 的碰撞攻击

  • String 对象的 hashCode 计算方法有一个缺点是, 前缀相同的字符串很容易得到相同的 hashCode, 例如字符串a 与字符串 b 有相同的前缀, 要得到相同的 hashCode, 只需要满足下列条件:

31 ∗ ( b [ n − 2 ] − a [ n − 2 ] ) = = ( a [ n − 1 ] − b [ n − 1 ] ) 31*(b[n-2] - a[n-2]) == (a[n-1] - b[n-1]) 31(b[n2]a[n2])==(a[n1]b[n1])

 String a = "Aa";
 String b = "BB";
 System.out.println(a.hashCode());
 System.out.println(b.hashCode());
 System.out.println(31 * ('C' - 'D') == ('B' - 'a'));
 System.out.println(31 * ('B' - 'A') == ('a' - 'B'));
 System.out.println("common_prefixDB".hashCode());
 System.out.println("common_prefixCa".hashCode());
  • 了解 String 默认的 hashCode 这个特点以后, 就会发现, HashMap 以 String 作为Key , 其实出现 hash 碰撞还挺容易的,这里就可以看到 Java 8 中为 HashMap 添加树化机制的深意了

HashMap 的小结

粗略了解了 HashMap 的底层实现后, 我们可以总结如下:

  • HashMap 解决冲突的方法是独立成链法, 而非开放定址法
  • jdk1.8 中的 HashMap 在桶后面追加的数据结构既有可能是链表, 也有可能从链表转化为树。
  • 从 String 的 hashCode 实现算法可以发现, 对于使用 String 作为 key 的 HashMap, 冲突并不难构造, 当 HashMap 的冲突过多, 如果没有树化机制, 链表元素过长时, 会严重影响检索效率。 一线互联网公司就发生共利用这种原理进行拒绝服务攻击的案例。

HashMap 的线程安全问题

HashMap为什么不是线程安全,并发操作Hashmap会带来什么问题 ?

  • 我觉得如果是非算法岗位, 面试这种细节的面试官就有点没意思了, 对于一个有计算机理论基础的同学来讲, 肯定了解并发操作一个数据结构会引发线程安全问题, 要想让一个数据结构线程安全, 需要考虑很多问题, 细究一个在设计之初就没把线程安全当目标的数据结构如何产生问题, 实在是有点学究了。 但是出于不平等关系, 还是展开学习一下这块内容
  • HashMap 在并发环境可能出现死循环占用 CPU 的问题(java 8 之后, 不会再出现死循环问题

由于死循环的引发是HashMap 扩容机制导致的, 而jdk1.8 又在扩容机制上进行了优化, 所以不得不先了解一下 HashMap 的扩容机制

HashMap ( JDK1.7 ) 的扩容机制

看一下 jdk1.7 中 resize() 方法

void resize(int newCapacity) {
   
   
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
   
    //(1)
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity]; //(2)
    transfer(newTable, initHashSeedAsNeeded(newCapacity)); //(3)
    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) {
   
   
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
   
   // 遍历旧的桶数组
        while(null != e) {
   
    // 桶数组该位置处有元素,可能是一个,也可能是一个链表
            Entry<K,V> next = e.next; // 构造一个 next 指针指向 e 的下一个元素
            if (rehash) {
   
   // 如果需要重新计算 hash
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity); // 根据新的容量以及该元素的hash值, 计算它在新数组中对应的索引
            // 下面的操作完成链表“头插法”
            // 因为新数组的对应索引处 newTable[i] 处可能存在已迁移的元素或链表
            // 使用头插法, 不管newTable[i] 处是否已经存在元素, 都可以正确的把元素迁移过
            e.next = newTable[i]; 
            newTable[i] = e;
            e = next;
        }
    }
}
}

下面绘图展示一下这个头插法的过程, 假设旧数组 oldTab 长度为2, 新数组长度为 4

  • 执行完 Entry<K,V> next = e.next; 后, 效果如下
    在这里插入图片描述

  • 执行完e.next = newTable[i]; , 效果如下
    在这里插入图片描述

  • 执行完newTable[i] = e;, 效果如下
    在这里插入图片描述

  • 然后 e=next , 跳出循环

HashMap(JDK1.7 及更早版本) 并发写入操作可能导致链表出现环

了解了 JDK1.7 的扩容机制以后, 可以看一下如果有两个线程并发写入, 同时执行transfer() 函数会发生什么

首先假设 Thread1 开始执行 transfer 方法, 执行完 Entry<K,V> next = e.next; 之后停下了, 形成如下效果

在这里插入图片描述

然后, Thread 2 得到调度

  • 注意, Thread 2 是意识不到 Thread 1 的存在的, 此时 Thread 1 尚未对 HashMap 的结构做出任何改变

假设 Thead2 一口气执行完了 transfer 函数,完成了头插法的全部流程, 就会变成下面这样
在这里插入图片描述

现在 Thread 1 得到调度执行,此时, 数据结构已经变成如下这样。
在这里插入图片描述
从上图看出, 由于 Thread 2 的执行, 导致线程1 在什么都没做的时候, 所处理的数据结构发生了如下变化

  • 原本 key = 7 的后继元素是 null, 现在变成了 key = 3 的元素(头插法导致的链表元素顺序颠倒

然后 Thread 1 执行完e.next = newTable[i]; , 变成如下效果
在这里插入图片描述

然后Thead1 执行完newTable[i] = e;, 效果如下
在这里插入图片描述

上述流程完成于以后, 链表成功出现了一个环, 调用get 访问到这个环时, 就会造成死循环

在这里插入图片描述

HashMap(JDK1.8) 的扩容机制

阅读 resize() 方法前, 值得先看一下 HashMap 构造函数的源码


    public HashMap() {
   
   
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    public HashMap
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值