集合的线程不安全问题
2026/8/6 7:13:07 网站建设 项目流程

一、ArrayList 的线程不安全的问题

要理解ArrayList为什么线程不安全,我们必须扒开它的源码,看看在 CPU 眼里它到底是怎么运作的。

ArrayList的底层本质极其简单:它就是一个普通的数组(Object[] elementData)加上一个记录当前元素数量的整数(int size)。

它内部没有任何像synchronizedLock这样的保护机制。

当我们在代码里调用arrayList.add("数据")时,它的核心源码(简化后)只有两行:

public boolean add(E e) { // 1. 检查内部数组的容量是否足够,不够就触发扩容 ensureCapacityInternal(size + 1); // 2. 把新元素放到数组的当前 size 位置,然后把 size 加 1 elementData[size++] = e; return true; }

问题就出在第 2 步的elementData[size++] = e;

在 CPU 执行指令时,这行代码绝对不是“一步到位”的(不是原子操作),它会被拆分成三个微小的底层步骤:

  1. 读:读取当前size的值。

  2. 写:把新元素e存入数组的elementData[size]位置。

  3. 加:size的值加 1,写回内存。

正因为这三步可以被随时打断,在多线程并发时,会引发两个极其经典的灾难。

1-1、灾难现场一:数据被覆盖丢失(值被吞了)

假设当前ArrayList里面有 5 个元素,也就是说当前的size = 5

现在有线程 A线程 B,同时执行add()操作。

底层并发执行轨迹:

  1. 线程 A执行到“写”这一步:它把自己的数据放到了elementData[5]的位置。此时还没来得及把size变成 6。

  2. 【CPU 发生切换】CPU 暂停了 A,切到了线程 B

  3. 线程 B去内存里读size,因为 A 还没改,B 读到的size依然是5

  4. 线程 B执行“写”操作:它也把自己的数据放到了elementData[5]的位置。

    结果:线程 A 刚刚写进去的数据,被线程 B 无情地覆盖(抹除)了!

  5. 线程 B 接着执行“加”操作,把size改成了6

  6. CPU 切回线程 A,线程 A 从刚才被打断的地方继续执行,它也执行“加”操作,把size强制改成了7

最终诡异的后果:

你明明向集合里add了两次,但数组的第 5 个位置只存了 B 的数据(A 的数据丢失了)。

更诡异的是,size变成了 7,这意味着数组的第 6 个位置(elementData[6])是空的(值为null)。这就导致了集合内部出现了数据空洞和错乱。

1-2、灾难现场二:数组越界异常(直接崩溃)

这个灾难发生在第 1 步的“容量检查”环节。

假设ArrayList底层的数组总长度是 10,现在已经装了 9 个元素(size = 9)。也就是说,只剩最后 1 个空位了

此时,线程 A线程 B同时进来执行add()

底层并发执行轨迹:

  1. 线程 A执行第 1 步ensureCapacityInternal(9 + 1)。发现 10 个容量刚好够装,不需要扩容

  2. 【CPU 发生切换】CPU 暂停了 A,切到了线程 B

  3. 线程 B也执行第 1 步ensureCapacityInternal(9 + 1)。因为 A 还没把数据放进去,size还是 9,B 也发现容量够装,不需要扩容

  4. 线程 A恢复执行,开始塞数据:把数据塞进elementData[9],然后size++变成了10。此时,数组已经完完全全装满了。

  5. 线程 B继续执行,它之前已经检查过认为不需要扩容,于是它直接去塞数据:准备把数据塞进elementData[10]

最终诡异的后果:

数组的下标是从 0 开始的,长度为 10 的数组,最大下标是 9。

线程 B 强行往elementData[10]里写数据,内存瞬间越界。JVM 会立刻抛出极其著名的ArrayIndexOutOfBoundsException(数组越界异常),导致你的业务线程直接崩溃。

总结:

ArrayList为了追求极致的单线程读写速度,完全剥离了锁的开销。

在并发环境下,多个线程同时去读写它内部那个共享的size变量和底层的elementData数组时,没有排队机制,必然导致数据被互相覆盖,或者因为跳过了扩容机制而引发数组越界崩溃。

在企业级开发中,如果在多线程环境下需要用到 List:

  • 绝对不能直接用ArrayList

  • 如果是写多读多的普通场景,我们会用Collections.synchronizedList(new ArrayList<>())把方法全部锁住。

  • 如果是读多写少(比如系统配置列表、黑名单列表),我们会使用并发包里的CopyOnWriteArrayList

二、HashMap的底层原理

2-1、无序性

HashMap中的 key 是绝对无序的,它绝对不会按照你put的顺序来保存数据。

不仅你刚放进去的时候是无序的,甚至在程序运行的过程中,它的顺序还会发生动态改变

第一步:HashMap底层是怎么存数据的?

HashMap的底层,本质上是一个数组默认长度是 16)。你可以把它想象成一排连续的 16 个内存坑位。

当你调用put(key, value)时,HashMap根本不关心这是你第几个放进来的数据,它只关心一件事:这个数据该落到数组的哪一个坑位里?

它的核心寻址逻辑如下:

  1. 算哈希值:它会调用 key 的hashCode()方法,算出一个整数。比如hash("Alice") = 23456

  2. 算数组下标:它用这个哈希值和当前数组的长度做一个位运算(相当于取模运算),算出一个范围在 0 到 15 之间的下标。假设算出来是5

  3. 落位:无论这是你第几个插入的数据,它都会直接被塞进数组下标为5的位置。

第二步:为什么插入顺序会被彻底打乱?

假设我们按顺序执行以下三行代码:

HashMap<String, String> map = new HashMap<>(); map.put("Alice", "111"); // 第 1 个插入 map.put("Bob", "222"); // 第 2 个插入 map.put("Cindy", "333"); // 第 3 个插入

在 CPU 的计算下,底层的落位可能是这样的:

  • 算 "Alice" 的下标,得出5,放在elementData[5]

  • 算 "Bob" 的下标,得出1,放在elementData[1]

  • 算 "Cindy" 的下标,得出14,放在elementData[14]

当你使用for循环去遍历(打印)这个 Map 时:

遍历的底层逻辑,是从数组的下标 0 一直往下循环到 15

所以,它最先碰到的是下标1里的 "Bob",然后是下标5里的 "Alice",最后是 "Cindy"。

打印出来的顺序变成了:Bob -> Alice -> Cindy

你原本的插入顺序,被哈希算法的随机性彻底撕碎了。

第三步:更极端的现象 —— 顺序甚至会中途改变(扩容机制)

这还不是最糟的。在HashMap中,顺序不仅不按put来,它还是动态变化的。

HashMap底层有一个机制叫扩容(Resize)。当数组里的数据塞得太满(默认超过容量的 75%)时,为了防止哈希冲突太严重,HashMap会申请一个比原来大一倍的新数组(比如从 16 扩容到 32)。


扩容时会发生什么?

它会把老数组里的所有数据拿出来,重新计算一次下标,塞到新数组里。

  • 原本在老数组下标5的 "Alice",重新计算后,可能跑到了新数组的下标21

  • 原本在下标14的 "Cindy",计算后可能还在下标14

这就意味着,只要触发了扩容,你昨天遍历HashMap打印出来的顺序,跟今天打印出来的顺序,可能会完全不一样!

第四步:如果没有它有什么弊端?(为什么非要这么设计?)

你可能会问:既然这么乱,为什么 Java 还要设计这种数据结构?

因为极致的查询速度

如果不做哈希运算,而是按你put的顺序把数据挨个排成一列(就像 ArrayList 或 LinkedList),当你想去查map.get("Cindy")的时候,程序必须从头开始,一个一个字符串去比对,直到找到为止。如果里面有 100 万条数据,最差的情况要比对 100 万次。时间复杂度是 O(N)。

HashMap通过哈希算法,直接算出了 "Cindy" 就在下标14。它直接一步跨到内存的那个位置把数据拿出来。时间复杂度是 O(1)。

它是牺牲了“顺序”,换取了“极速的随机读写性能”。

终极解法:如果你在业务里非要保持put顺序怎么办?

在很多业务场景(比如你在内存里缓存了一批菜单,希望在页面上按你的加载顺序显示),我们既想要 HashMap 的查询极速,又想要保持插入的顺序

这时候,你应该抛弃普通的HashMap,去使用它的子类:LinkedHashMap

LinkedHashMap的底层依然有那个哈希数组保证查询速度。但它在代码层面多做了一件事:它在后台偷偷地给每一个落位的节点,加上了before(前驱)和after(后继)两个指针,硬生生地把那些散落在数组各处的节点,用一条双向链表串在了一起。

当你去遍历LinkedHashMap时,它不再去傻傻地循环那个哈希数组,而是顺着这条额外加上的双向链表去遍历。这样,就完美地还原了你当初put进来的顺序。

2-2、底层数据结构与JDK8 的优化——红黑树

【问题】:

在 HashMap 的底层原理中,如果两个完全不同的 key 通过哈希公式算出了同一个数组下标(也就是发生了哈希冲突),HashMap 会怎么处理?存进去的数据会被覆盖吗?

明确地回答你:如果是两个不同的 Key 算出了相同的下标,存进去的数据绝对不会被覆盖。

在计算机科学中,不同的输入数据经过哈希计算后得出相同的结果,这被称为哈希冲突(Hash Collision)。这是不可避免的必然现象。

为了完美地处理这种冲突,Java 的HashMap经历过一次极其重要的底层代码重构(从 JDK 7 到 JDK 8)。我们纯粹从内存数据结构的角度,一步步来看看它是怎么兜底的。

第一阶段:基础解法 —— “拉链法”(单向链表)

你可能以为HashMap的底层数组里,直接存的是你要放的Value。其实不是。

数组的每一个坑位里,存放的是一个Node(节点)对象。这个对象内部有四个极度重要的属性:

  1. int hash(当前 Key 的哈希值)

  2. K key(真正的 Key)

  3. V value(真正的 Value)

  4. Node next(一个指向下一个节点的内存指针)


当冲突发生时,底层的执行轨迹:

  1. 你执行put("Alice", "111")。哈希算出来下标是5。此时elementData[5]是空的,系统直接把 Alice 包装成一个 Node 丢进这个坑位。

  2. 你执行put("Bob", "222")。哈希算出来下标恰好也是5

  3. 系统去elementData[5]一看,发现里面已经住着 Alice 了。

  4. 【核心动作】系统会去调用 Alice 的equals()方法,跟 Bob 对比一下。发现 "Alice" 和 "Bob" 不是同一个字符串。

  5. 既然不是同一个 Key,绝对不能覆盖系统会把 Bob 也包装成一个 Node,然后让 Alice 节点里的next指针,指向 Bob 这个新节点。

结果:在数组的下标5这个位置,不再是一个单一的数据,而是形成了一条单向链表(Alice -> Bob)。

(注:如果你再去put("Alice", "333"),系统依然会算出下标 5,然后遍历链表,用equals()发现存在一模一样的 Key,这时候才会把 111覆盖成 333。)

第二阶段:怎么把数据准确无误地取出来?

既然下标 5 的位置串成了一根链表,那执行get("Bob")时,系统怎么知道哪个是 Bob 的数据?

查询轨迹:

  1. 算哈希,定位到下标5

  2. 拿到下标 5 排在第一位的 Node(也就是 Alice)。

  3. 比较:if (Alice的Key.equals("Bob"))。结果为false

  4. 顺着 Alice 的next指针往下找,拿到下一个 Node(Bob)。

  5. 比较:if (Bob的Key.equals("Bob"))。结果为true

  6. 成功把 Bob 节点里存的value(222)返回。

这就是为什么在 Java 中有一条铁律:

重写对象的hashCode()方法时,必须同时重写equals()方法。因为哈希值只负责帮你找到“属于哪个数组下标”,而equals()负责在发生冲突的链表中,精确地找出“到底哪一个是你的数据”。

第三阶段:遭遇性能绝境与 JDK 8 的终极进化

用单向链表解决冲突,逻辑上很完美,但在极端的并发和海量数据场景下,暴露出了致命的性能漏洞。


绝境推演:

假设你的哈希算法写得很烂,或者黑客在恶意攻击你的服务器,故意构造了 10,000 个能算出同一个哈希值的不同 Key。

当你把这 10,000 个数据全put进去后,HashMap其他 15 个数组坑位全空着,唯独某一个坑位里,挂着一条长达 10,000 个节点的链表!

当你去get最后一个节点时,系统必须从头开始比对,顺着指针往下爬 10,000 次。

后果:HashMap引以为傲的 O(1) 极速查询性能被彻底摧毁,退化成了 O(N) 的线性级慢速查询。

JDK 8 的重构(红黑树的引入):

为了防止链表过长导致性能崩溃,JDK 8 的底层代码中加入了一个阈值:TREEIFY_THRESHOLD = 8

现在的运转逻辑变成了这样:

  1. 冲突了,继续在链表尾部挂载新节点。

  2. 每次挂载完,系统都会检查这条链表的长度。

  3. 如果这条链表的长度达到了 8 个节点(并且整个 HashMap 的数组总长度已经达到了 64)。

  4. 【底层变异】HashMap会瞬间把这条普通的单向链表,撕裂并重构成一种极其复杂的数据结构:红黑树(Red-Black Tree)


为什么换成树?

在一条长度为 10,000 的链表里找数据,最差要找 10,000 次。

而在包含 10,000 个节点的红黑树里找数据,依靠二分查找的逻辑,最多只需要查找约 14 次!

时间复杂度从 O(N) 瞬间被优化到了 O(log N)。

总结

HashMap发生哈希冲突时,底层的演进逻辑是:

  • 少量冲突:采用“数组 + 单向链表”兜底。数据不会覆盖,而是追加到链表尾部。

  • 海量冲突:采用“数组 + 红黑树”兜底。当链表长度超过 8 时,动态转为树结构,强行保住查询性能的下限。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询