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

本文探讨了在多线程环境下使用ArrayList时可能出现的问题,通过对比Java 8和11的源码,揭示了Java内存模型(JMM)如何导致数据不一致和异常。通过实例验证和时序图分析,强调了ArrayList本身不是线程安全的,即使对相关变量使用volatile也无法确保线程安全,因为其方法设计未考虑多线程环境。

为了便于理解,当时只是通过代码执行顺序说明了异常原因。其实多线程中还会涉及 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 的原因。

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

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

从时序图我们可以看出现,在执行过程中,两个线程取的 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 ,该出现的异常,一个也不会少。

多线程环境下使用引用数据类型时,有以下注意事项: ### 线程安全问题 引用数据类型的对象可能会被多个线程同时访问和修改,这可能会导致数据不一致或其他并发问题。例如,多个线程同时对一个共享的列表进行添加或删除操作,可能会导致列表状态混乱。为了保证线程安全,可以使用同步机制,如 `synchronized` 关键字或 `Lock` 接口来控制对共享资源的访问。 ```java import java.util.ArrayList; import java.util.List; class SharedList { private final List<Integer> list = new ArrayList<>(); public synchronized void addElement(int element) { list.add(element); } public synchronized int getSize() { return list.size(); } } ``` ### 可见性问题 当一个线程修改了引用数据类型的对象状态时,其他线程可能无法立即看到这个修改。这是因为每个线程可能有自己的缓存,修改可能只存在于某个线程的缓存中,而没有及时刷新到主内存中。可以使用 `volatile` 关键字来保证变量的可见性,或者使用并发容器来避免这个问题。 ```java class SharedObject { private volatile boolean flag = false; public void setFlag() { flag = true; } public boolean getFlag() { return flag; } } ``` ### 死锁问题多线程环境中,如果多个线程相互等待对方释放锁,就可能会发生死锁。在使用引用数据类型时,如果涉及到多个锁的嵌套使用,需要特别注意死锁的问题。可以通过按照相同的顺序获取锁,或者使用超时机制来避免死锁。 ```java class DeadlockExample { private final Object lock1 = new Object(); private final Object lock2 = new Object(); public void method1() { synchronized (lock1) { synchronized (lock2) { // 执行操作 } } } public void method2() { synchronized (lock2) { synchronized (lock1) { // 执行操作 } } } } ``` ### 上下文切换开销 频繁地在多个线程之间切换上下文会带来一定的开销,影响性能。在使用引用数据类型时,需要合理设计线程的数量和任务分配,避免过多的上下文切换。 ### 移动语义与拷贝语义 如果使用 Lambda 表达式创建线程,且 Lambda 表达式按值捕获了大型对象或带有移动语义的资源,需要考虑使用 `mutable` 关键字使 Lambda 可修改捕获的按值捕获项,并允许移动捕获而非复制。若 Lambda 只用于一次性的任务(如传给 `std::thread` 构造函数),可以将其声明为 `std::move_only_function<>` 类型,强制使用移动语义[^3]。 ```cpp #include <iostream> #include <thread> #include <functional> int main() { auto largeObject = std::make_unique<int>(10); auto lambda = [obj = std::move(largeObject)]() mutable { std::cout << *obj << std::endl; }; std::thread t(std::move(lambda)); t.join(); return 0; } ```
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值