当前位置: 首页 > news >正文

为什么 ArrayList 和 LinkedList 是线程不安全的?

在 Java 并发编程中,ArrayListLinkedList都是“臭名昭著”的线程不安全者。它们的线程安全性问题,根源都在于内部状态(如元素数组、大小、节点链接)的修改操作并非原子性,且缺乏同步机制。当多条线程同时修改同一个实例时,就会产生数据污染、元素丢失等诡异问题。

下面我们分别剖析它们的不安全根源。

一、ArrayList 的线程不安全之源

ArrayList 的底层是一个动态数组elementData和一个整型变量size来记录元素个数。它的不安全主要体现在三个方面:

1. 核心操作缺乏原子性(以add(E e)方法为例)
一个简单的add方法,在代码层面可能只是一行,但在 JVM 执行时却分解为多个步骤:

public boolean add(E e) { modCount++; // 步骤1:修改次数加1(用于迭代器快速失败) add(e, elementData, size); // 步骤2:调用私有重载方法执行添加 return true; } private void add(E e, Object[] elementData, int s) { if (s == elementData.length) { // 步骤3:检查是否需要扩容 elementData = grow(); // 步骤4:扩容(返回新数组) } elementData[s] = e; // 步骤5:在索引 s 处放入元素 size = s + 1; // 步骤6:更新 size }

从这段真实代码可以看出,一个简单的add操作被分解为多个步骤,并且这些步骤在并发执行时并非原子操作。

多线程风险演示:

  • 场景:线程A和线程B同时执行add,此时size = 5,且elementData.length > 5(无需扩容)。
  • 可能的时间片轮转
    • 1. 线程A执行完步骤5(elementData[5] = a),但还没来得及执行步骤6更新size,CPU切换到了线程B。
    • 2. 线程B开始执行add,它读取到的size仍然是5。于是它也执行步骤5,将elementData[5] = b将线程A刚刚放入的元素a覆盖了
    • 3. 接着,线程B执行步骤6,将size更新为6。
    • 4. 之后线程A继续执行步骤6,又将size更新为7(基于它之前保存的s=5计算s+1)。
  • 结果:最终size为7,但elementData[5]位置实际存放的是ba已丢失,且elementData[6]位置是null。这就是典型的元素覆盖和索引错乱

2. 扩容机制引发的并发异常
add操作触发扩容时,风险进一步加剧。上面的代码中,grow()方法负责扩容:

// ArrayList 的扩容核心 private Object[] grow(int minCapacity) { // 获取当前数组的容量 int oldCapacity = elementData.length; // 判断是否已经初始化过 if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 情况 1: 数组已经有容量或是已初始化的空数组 // 计算新的容量,使用 ArraysSupport.newLength 方法 // 参数说明: // - oldCapacity: 旧容量 // - minCapacity - oldCapacity: 最小需要增长的容量(必需增长) // - oldCapacity >> 1: 首选增长量(右移 1 位相当于除以 2,即增长 50%) int newCapacity = ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity >> 1); // 复制原数组到新容量的数组,并更新引用 return elementData = Arrays.copyOf(elementData, newCapacity); } else { // 情况 2: 默认空数组,首次扩容 // 取默认容量 (通常是 10) 和最小需要容量的较大值 return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; } }
  • 风险:假设线程A和线程B同时检测到需要扩容(即s == elementData.length)。它们都会进入grow()方法。
    • 线程A先执行Arrays.copyOf,创建了一个新数组,并将elementData指向它。
    • 紧接着,线程B也执行Arrays.copyOf,但此时它引用的elementData仍然是旧的数组(因为线程A的赋值可能还没被线程B看到,或者线程B已经读到了旧数组的引用),于是基于旧数组又创建了一个新数组,并再次将elementData指向它。
  • 结果
    • 扩容失效:最终数组容量可能只是线程B计算的容量,但实际需要的容量可能更大(线程A已经添加了元素)。
    • 元素丢失:线程A在新数组中添加的元素,随着elementData被线程B的引用覆盖而全部丢失。更严重的是,线程A之后还会尝试在它自己创建的数组(已被抛弃)中放入元素,这些操作全部无效。

3. 迭代过程中的“快速失败(fail-fast)”
当使用迭代器(Iterator)遍历 ArrayList 时,迭代器内部会保存一个expectedModCount值,初始等于集合的modCount(修改次数)。每次遍历都会检查两者是否一致。

  • 风险:如果在一个线程遍历的同时,另一个线程对 ArrayList 进行了结构修改(增、删),modCount就会变化,导致两者不相等。此时,正在遍历的线程会立即抛出ConcurrentModificationException,这就是为了在并发环境下快速暴露问题,而不是让程序在不确定的状态下继续运行。

4.总结

  • 数据丢失:多个线程同时执行add()操作,可能导致后写入的数据覆盖前一个数据,或size值更新错乱。
  • 扩容异常:线程A和线程B同时检测到需要扩容,都创建了新数组并尝试复制。最终可能只有一个线程的数组生效,另一个线程添加的元素全部丢失。
  • 快速失败:当一个线程使用迭代器遍历时,另一个线程修改了集合结构(增删),迭代器会立即抛出ConcurrentModificationException

二、LinkedList 的线程不安全之源

LinkedList 基于双向链表实现,其节点Node包含item(数据)、next(后置指针)、prev(前置指针)。它的线程不安全主要体现在对指针和链接状态的并发修改上。

1. 链接过程的非原子性(以addLast(E e)为例)
向链表尾部添加元素,需要维护多个节点间的指针关系:

// linkLast 方法 void linkLast(E e) { // 保存当前的尾节点引用 final Node<E> l = last; // 创建新节点 // 参数说明: // - l: 前驱节点(原尾节点) // - e: 新节点存储的元素 // - null: 后继节点为 null(因为要放在尾部) final Node<E> newNode = new Node<>(l, e, null); // 步骤1:更新尾节点指针,将 last 指针指向新节点 last = newNode; if (l == null) // 如果链表为空,也要设置 first 指针(原链表为空,新节点既是头节点也是尾节点) first = newNode; else // 步骤2:将原尾节点的 next 指针指向新节点(原链表非空,将原尾节点的 next 指向新节点) l.next = newNode; // 步骤3:增加 size(链表大小加 1) size++; // 修改计数器加 1(用于快速失败机制) modCount++; }
  • 风险:线程A和线程B同时执行linkLast
    • 它们都可能读到同一个l(原尾节点)。
    • 线程A执行完步骤1,将last指向了自己的新节点newNodeA
    • 线程B接着执行步骤1,将last指向了自己的新节点newNodeB,此时last被线程B覆盖。
    • 然后线程A尝试执行步骤2,将原尾节点lnext指向newNodeA
    • 线程B也尝试执行步骤2,将原尾节点lnext指向newNodeB
  • 结果:最终last指向newNodeB,但原尾节点的next可能指向newNodeA(取决于最后谁执行了步骤2),这会导致链表出现环状结构节点丢失,使得后续遍历或操作陷入死循环或产生错误结果。
2. 数据结构整体被破坏

ArrayList类似,对size的并发增减、在链表中间插入或删除节点时,多个线程同时修改节点的prevnext指针,极有可能导致指针错乱,使得链表不再是一个线性的、可正确遍历的结构,甚至出现空指针异常死循环

例如,在中间插入节点时,需要同时修改前驱节点的next和后继节点的prev。若两个线程同时在相近位置插入,可能造成:

  • 断链:某个节点的next指向了新节点,但新节点的prev却未能正确指回,导致逆向遍历中断。
  • 成环:指针交叉引用,形成循环链表,使遍历永远无法结束。
  • 空指针异常:某个节点被错误地置为null,导致后续访问抛出NPE
3.size与真实节点数不一致

size++操作不具备原子性,多个线程同时执行size++会导致最终值小于实际添加次数(丢失更新)或大于实际节点数(重复计数)。结合节点丢失的情况,size将完全无法反映链表中有效节点的数量,使依赖size()的业务逻辑(如分页、循环判断)失效。

4. 迭代器快速失败(ConcurrentModificationException

ArrayList一致,LinkedList也通过modCount变量记录结构性修改次数。当一个线程使用迭代器遍历时,如果另一个线程对链表进行了增删操作,modCount的改变会立即被迭代器检测到,并抛出ConcurrentModificationException,以防止在已损坏或状态不一致的结构上继续操作。

三、风险对比与最佳实践

集合类

不安全根源

主要风险表现

ArrayList

基于数组,对索引位置数组引用的并发赋值非原子;扩容时复制覆盖;modCount机制触发快速失败。

元素覆盖/丢失、数组索引越界、ConcurrentModificationException

LinkedList

基于链表,对多个节点指针prevnext)和头尾引用的并发修改非原子;size变量并发增减。

节点丢失、链表结构损坏(如出现环)、空指针异常、遍历死循环

核心结论与最佳实践:

  1. 局部变量优先:如前两份材料所述,在绝大多数情况下,将ArrayListLinkedList定义为方法内部的局部变量,是避免线程安全问题最简单、最有效的方法。
  2. 必须共享则加锁或使用并发容器:如果确实需要在多线程间共享,请不要直接使用它们。
    1. 对于ArrayList,可以根据场景选用Collections.synchronizedList(new ArrayList<>())(简单同步)或CopyOnWriteArrayList(读多写少)。
    2. 对于LinkedList,如果作为队列使用,推荐使用ConcurrentLinkedQueue(基于 CAS 的无锁并发队列)或LinkedBlockingQueue(阻塞队列);如果仍需 List 操作,可考虑Collections.synchronizedList(new LinkedList<>()),但要注意并发性能。
http://www.jsqmd.com/news/503449/

相关文章:

  • 如何用Waifu Diffusion v1.3在5分钟内创作专业级动漫角色
  • DCDC模块电源滤波实战:如何正确选择X/Y安规电容实现±5V稳定输出
  • 死锁 详解
  • ai coding工具共性(四)skill
  • 从ENVI FLAASH到地表参量反演:一份完整的遥感数据处理实战指南
  • yz-女生-角色扮演-造相Z-Turbo与Python爬虫结合:自动采集并生成动漫角色数据集
  • 从零到一:在Ubuntu 18.04上构建PX4-Autopilot开发环境全攻略
  • Cosmos-Reason1-7B数据库设计助手:基于MySQL的智能ER图生成与优化
  • AMD SMU调试工具深度解析:实现处理器性能调优的终极指南
  • 电源设计必看:X/Y电容选型避坑指南(附漏电流计算公式)
  • GPU Power Brake设置全攻略:主动与被动模式详解及性能影响实测
  • ArcGIS进阶:从数据到洞察,土地利用时空演变分析与可视化全流程
  • 从Docker Compose到生产环境:我的DolphinScheduler高可用架构演进实录
  • Aprilgrid标定板参数详解:如何选择最适合你的tsize和tspace?
  • 2025美赛论文排版终极指南:从Word到LaTeX的5种O奖模板实战
  • Claude Skills大揭秘:让你的AI不仅能说会道,更能高效执行!
  • 社区生鲜买菜小程序前端功能版块设计及玩法介绍
  • 开启图像处理之旅:C# 与 OpenCV 的奇妙结合
  • Dva + ECharts 实战:如何优化React大屏项目的性能与可维护性
  • 正则化实战:用Python实现L1和L2正则化并比较它们的实际效果
  • 无人机 RGB+热红外融合检测建筑裂缝与渗漏,34 层高楼约 2 小时
  • 相机标定常见误区解析:为什么你的重投影误差总是降不下来?
  • ROS2新手必看:解决‘无法定位软件包‘错误的5个实用技巧(含rosdep常见问题)
  • 一天一个开源项目(第55篇):Spec Kit - GitHub 开源的规范驱动开发工具包
  • YOLO12与增强现实结合:实时物体标注系统
  • 别再被坐标系搞晕了!UniApp中getLocation的WGS84与GCJ02区别详解及实战转换方案
  • 告别卡顿!G-Helper:华硕笔记本玩家的终极性能优化神器
  • 使用ROS1和Pycharm高效转换Realsense相机bag文件为MP4格式
  • Android Media3实战:从ExoPlayer集成到自定义播放器开发(附完整代码)
  • 2026年3月优质的河北铸铁闸门厂家选择指南:平面、拱形、铸铁镶铜、双向止水、机闸一体铸铁闸门厂家 - 海棠依旧大