看山聊并发:如果非要在多线程中使用 ArrayList 会发生什么?(第二篇)

简介: 前面写过一篇文章 《如果非要在多线程中使用 ArrayList 会发生什么?》,有读者反馈,Java 11 代码已经修复,还会出现 null 元素。为了便于理解,当时只是通过代码执行顺序说明了异常原因。

image.png

你好,我是看山。


前面写过一篇文章 《如果非要在多线程中使用 ArrayList 会发生什么?》,有读者反馈,Java 11 代码已经修复,还会出现 null 元素。


为了便于理解,当时只是通过代码执行顺序说明了异常原因。其实多线程中还会涉及 Java 内存模型,本文就从这方面说明一下。


对比源码

我们先来看看 Java 11 中,add方法做了什么调整。


Java 8 中add方法的实现:


public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e;
    return true;
}

Java 11 中add方法的实现:


public boolean add(E e) {
    modCount++;
    add(e, elementData, size);
    return true;
}
private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1;
}

两段逻辑的差异在于数组下标是否确定:


elementData[size++] = e;,Java 8 中直接使用size定位并赋值,然后通过size++自增

elementData[s] = e; size = s + 1;,Java 11 借助临时变量s定位并赋值,然后通过size = s + 1给size赋新值

Java 11 的优点在于,为数组指定元素赋值的时候,下标值是确定的。也就是说,只要进入add(E e, Object[] elementData, int s)方法中,就只会处理指定位置的数组元素。并且,size的值也是根据s增加。按照执行顺序推断,最终的结果可能会丢数,但是不会出现 null。(多个线程向同一个下标赋值,即s相等,那最终size也相等。)


验证一下

让我们来验证下。


package com.kuaishou.is.datamart;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class Main {
    public static void main(String[] args) throws InterruptedException {
        List<String> list = new ArrayList<>();
        CountDownLatch latch = new CountDownLatch(1);
        CountDownLatch waiting = new CountDownLatch(3);
        Thread t1 = new Thread(() -> {
            try {
                latch.await();
                for (int i = 0; i < 1000; i++) {
                    list.add("1");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                waiting.countDown();
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                latch.await();
                for (int i = 0; i < 1000; i++) {
                    list.add("2");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                waiting.countDown();
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                latch.await();
                for (int i = 0; i < 1000; i++) {
                    list.add("2");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                waiting.countDown();
            }
        });
        t1.start();
        t2.start();
        latch.countDown();
        waiting.await();
        System.out.println(list);
    }
}

在 Java 8 和 Java 11 中分别执行,果然,出现了ArrayIndexOutOfBoundsException和null的情况。如果没有出现,那就是姿势不对,需要多试几次或者多几个线程。


换个角度想问题

上一篇通过代码执行顺序解释了出现问题的原因,这次再看看 JMM 的原因。

image.png



从上图我们可以看到,Java 为每个线程创建了一个本地内存区域,也就是说,代码运行过程中使用的数据,是线程本地缓存的数据。这份缓存的数据,会与主内存的数据做交换(更新主内存数据或更新本次缓存中的数据)。


我们通过一个时序图看下为什么会出现 null(数组越界异常同理):

image.png



从时序图我们可以看出现,在执行过程中,两个线程取的size值和elementData数组地址,大部分是操作自己本地缓存中的,执行一段时间后,会将本地缓存中的数据写回主内存数据,然后还会从主内存中读取最新数据更新本地缓存数据。异常就在这个交换过程中发生了。


这个时候,可能有读者会想,是不是把size和elementData两个变量加上volatile就可以解决了。如果这样想,那你就想简单。线程安全是整个类设计实现时已经确定了,除了属性需要考虑多线程的影响,方法(主要是会修改属性元素的方法)也需要考虑。


ArrayList的定位是非线程安全的,其中的所有方法都没有考虑多线程下为共享资源加锁。即使size和elementData两个变量都是实时读写主内存,但是add和grow方法还是可能会覆盖另一个线程的数据。


我们从ArrayList的add方法注释可以得知,方法拆分不是为了实现线程安全,而是为了执行效率和内存占用:


This helper method split out from add(E) to keep method bytecode size under 35 (the -XX:MaxInlineSize default value), which helps when add(E) is called in a C1-compiled loop.


所以说,在多线程场景下使用ArrayList,该出现的异常,一个也不会少。


推荐阅读

如果非要在多线程中使用 ArrayList 会发生什么?

如果非要在多线程中使用 ArrayList 会发生什么?(第二篇)

目录
相关文章
|
2月前
|
设计模式 缓存 安全
【JUC】(6)带你了解共享模型之 享元和不可变 模型并初步带你了解并发工具 线程池Pool,文章内还有饥饿问题、设计模式之工作线程的解决于实现
JUC专栏第六篇,本文带你了解两个共享模型:享元和不可变 模型,并初步带你了解并发工具 线程池Pool,文章中还有解决饥饿问题、设计模式之工作线程的实现
186 2
|
5月前
|
Java API 调度
从阻塞到畅通:Java虚拟线程开启并发新纪元
从阻塞到畅通:Java虚拟线程开启并发新纪元
375 83
|
5月前
|
存储 Java 调度
Java虚拟线程:轻量级并发的革命性突破
Java虚拟线程:轻量级并发的革命性突破
347 83
|
7月前
|
机器学习/深度学习 消息中间件 存储
【高薪程序员必看】万字长文拆解Java并发编程!(9-2):并发工具-线程池
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发编程中的强力并发工具-线程池,废话不多说让我们直接开始。
282 0
|
7月前
|
设计模式 运维 监控
并发设计模式实战系列(4):线程池
需要建立持续的性能剖析(Profiling)和调优机制。通过以上十二个维度的系统化扩展,构建了一个从。设置合理队列容量/拒绝策略。动态扩容/优化任务处理速度。检查线程栈定位热点代码。调整最大用户进程数限制。CPU占用率100%
492 0
|
7月前
|
存储 缓存 安全
JUC并发—11.线程池源码分析
本文主要介绍了线程池的优势和JUC提供的线程池、ThreadPoolExecutor和Excutors创建的线程池、如何设计一个线程池、ThreadPoolExecutor线程池的执行流程、ThreadPoolExecutor的源码分析、如何合理设置线程池参数 + 定制线程池。
JUC并发—11.线程池源码分析
|
12月前
|
安全 Java 程序员
ArrayList vs Vector:一场线程安全与性能优化的世纪之争!
在 Java 面试中,ArrayList 和 Vector 是高频考点,但很多人容易混淆。本文通过10分钟深入解析它们的区别,帮助你快速掌握性能、线程安全性、扩容机制等核心知识,让你轻松应对面试题目,提升自信!
304 18
|
安全 Java
线程安全的艺术:确保并发程序的正确性
在多线程环境中,确保线程安全是编程中的一个核心挑战。线程安全问题可能导致数据不一致、程序崩溃甚至安全漏洞。本文将分享如何确保线程安全,探讨不同的技术策略和最佳实践。
203 6
|
安全 Java 开发者
Java 多线程并发控制:深入理解与实战应用
《Java多线程并发控制:深入理解与实战应用》一书详细解析了Java多线程编程的核心概念、并发控制技术及其实战技巧,适合Java开发者深入学习和实践参考。
262 8

热门文章

最新文章