6.jdk源码阅读之HashMap(下)

1.写在前面

上一篇文章,我们从往HashMap中put元素开始,详细分析了创建HashMap和put元素的过程,这篇文章我想和大家一起来深入的再研究下HashMap,我先抛出几个问题:

  1. jdk7 和 jdk8 中 HashMap的源码和底层实现发生了什么变化? 为什么要这么改?要解决什么问题?
  2. jdk8 中HashMap为什么线程不安全? 有具体例子吗? 什么情况下会有线程安全问题?

2.JDK 7 中的 HashMap 实现

在 JDK 7 中,HashMap 的底层实现主要是通过数组和链表的组合来实现的。每个桶(bucket)是一个链表,所有具有相同哈希值的键值对都存储在同一个链表中。

2.1 数组和链表

HashMap 使用一个数组来存储链表的头节点。每个数组的元素(桶)是一个链表,链表中的每个节点存储一个键值对。

2.2 哈希冲突处理

当多个键的哈希值相同(即发生哈希冲突)时,这些键值对会被存储在同一个链表中。

2.3 扩容

当负载因子超过设定值时,HashMap 会进行扩容(rehash),将容量加倍,并重新分配所有键值对。

2.4 性能问题

当哈希冲突频繁发生时,链表会变得很长,导致查找、插入和删除操作的时间复杂度从平均的 O(1) 退化到最坏的 O(n)。

2.5 多线程操作时的死循环

在 JDK 7 中,HashMap 在多线程环境下使用不当确实可能导致死循环。这主要发生在 HashMap 进行扩容(rehash)的过程中。如果多个线程同时对 HashMap 进行插入操作,可能会导致链表形成环形结构,从而引发死循环。

以下是一个简单的示例,展示了在多线程环境下如何导致 HashMap 死循环:

import java.util.HashMap;
import java.util.Map;

public class HashMapInfiniteLoopExample {
    private static final Map<Integer, Integer> map = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程同时对 HashMap 进行插入操作
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                map.put(i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                map.put(i, i);
            }
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程完成
        thread1.join();
        thread2.join();

        System.out.println("Map size: " + map.size());
    }
}

在上述代码中,如果 HashMap 发生扩容,并且两个线程同时对同一个桶进行操作,可能会导致链表形成环形结构。此时,如果另一个线程试图遍历这个桶中的链表,就会陷入死循环,导致 CPU 使用率飙升,程序无法继续执行。我们通过画图来说明死循环的形成过程。

2.5.1 jdk7 HashMap的基本结构

在这里插入图片描述
如上图所示,在JDK1.7中HashMap的底层数据实现是数组+链表。

2.5.2 头插法

在这里插入图片描述
如上图所示,在JDK7中HashMap的插入是头插法,这也是造成死循环的根本原因。在JDK8中改成了尾插法,这个我们稍后再详细看。

2.5.3 HashMap正常扩容过程

在这里插入图片描述
如上图所示,扩容的过程概括来说就是先生成一个更大容量的HashMap,然后把旧HashMap中的数据迁移到新HashMap,问题就发生在迁移的过程中。前面提到JDK7 HashMap 是采用的头插法,这样原先是A->B->C,迁移到新的HashMap之后就会变成C->B->A。

2.5.4 死循环形成

在这里插入图片描述

按照我们上面的代码举例,有两个线程并发在往HashMap中put元素,发现需要扩容,然后都进行扩容。此时T1和T2指向的是链表的头结点元素A,而T1和T2的下一个节点,也就是T1.next和T2.next指向的是B节点。
在这里插入图片描述
假设由于某种原因,T2线程挂起了,T1线程先进行了扩容,扩容完成后如上图右侧所示。因为是头插法,所以HashMap的顺序已经发生了改变,但线程T2对于发生的一切是不可知的,所以它的指向元素依然没变,如上图展示的那样,T2指向的是A元素,T2.next指向的节点是B元素。
在这里插入图片描述
这个时候T2线程恢复了,准备开始执行扩容。因为T1执行完扩容之后B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩完容完之后的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成死循环了。
JDK7 HashMap造成死循环的源码如下,大家可以对照的上图和源码自行走一遍流程:

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

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

3. JDK 8 中的 HashMap 实现

在 JDK 8 中,HashMap 的实现进行了优化,主要引入了红黑树来替代链表,以提高在哈希冲突严重时的性能。

  • 通过引入红黑树来替代长链表,在哈希冲突严重时,显著提高了查找、插入和删除操作的性能
  • 在 JDK 7 中,如果所有键都映射到同一个桶,链表会变得很长,导致性能退化到 O(n)。通过引入红黑树,最坏情况下的时间复杂度也能保持在 O(log n)。
  • 在多线程环境下,链表的转换和扩容过程更加稳定,减少了死循环的风险。

3.1 数组、链表和红黑树

HashMap 仍然使用一个数组来存储链表或红黑树的头节点。当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树

3.2 红黑树

当链表中的元素数量超过阈值时,链表会转换为红黑树。红黑树的查找、插入和删除操作的时间复杂度为 O(log n),比链表的 O(n) 更高效

3.3 哈希冲突处理

仍然使用链表来处理哈希冲突,但当链表长度超过阈值时,转换为红黑树。

3.4 扩容

扩容机制与 JDK 7 类似,但在重新分配键值对时,红黑树的节点会保持树的结构。

4. jdk8 HashMap 在多线程环境下还有可能死循环吗?

在 JDK 8 中,HashMap 引入了一些新的机制和优化,例如使用红黑树来替代长链表,以提高性能和解决哈希冲突严重时的最坏情况。然而,HashMap 仍然不是线程安全的,在多线程环境下使用时,仍然可能会出现一些问题,包括死循环或数据丢失。主要有以下几种情况:

4.1 扩容(Rehashing)

与 JDK 7 类似,JDK 8 中的 HashMap 在扩容过程中,如果多个线程同时进行插入操作,可能会导致数据结构的不一致,甚至形成环形链表,导致死循环

4.2 红黑树转换

当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树。如果多个线程同时对同一个桶进行操作,可能会导致红黑树结构的不一致,从而引发不可预知的问题

4.3 代码例子

以下是一个简单的示例,展示了在多线程环境下使用 JDK 8 的 HashMap 可能导致的问题:

import java.util.HashMap;
import java.util.Map;

public class HashMapInfiniteLoopExample {
    private static final Map<Integer, Integer> map = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程同时对 HashMap 进行插入操作
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                map.put(i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 10000; i < 20000; i++) {
                map.put(i, i);
            }
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程完成
        thread1.join();
        thread2.join();

        System.out.println("Map size: " + map.size());
    }
}

4.4 解决方法

为了避免上述问题,可以使用线程安全的集合类,例如 ConcurrentHashMap。ConcurrentHashMap 通过使用分段锁或其他并发控制机制,确保在多线程环境下的安全性。

以下是使用 ConcurrentHashMap 的示例代码:

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    private static final Map<Integer, Integer> map = new ConcurrentHashMap<>();

    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程同时对 ConcurrentHashMap 进行插入操作
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                map.put(i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 10000; i < 20000; i++) {
                map.put(i, i);
            }
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程完成
        thread1.join();
        thread2.join();

        System.out.println("Map size: " + map.size());
    }
}

系列文章

1.JDK源码阅读之环境搭建

2.JDK源码阅读之目录介绍

3.jdk源码阅读之ArrayList(上)

4.jdk源码阅读之ArrayList(下)

5.jdk源码阅读之HashMap

6.jdk源码阅读之HashMap(下)

7.jdk源码阅读之ConcurrentHashMap(上)

  • 20
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

至真源

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值