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


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

    对比源码

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

    Java 8 中 add 方法的实现:

    1. public boolean add(E e) {
    2. ensureCapacityInternal(size + 1);
    3. elementData[size++] = e;
    4. return true;
    5. }
    6. Java 11 中 add 方法的实现:
    7. public boolean add(E e) {
    8. modCount++;
    9. add(e, elementData, size);
    10. return true;
    11. }
    12. private void add(E e, Object[] elementData, int s) {
    13. if (s == elementData.length)
    14. elementData = grow();
    15. elementData[s] = e;
    16. size = s + 1;
    17. }

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

    • 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 也相等。)

    验证一下

    让我们来验证下。

    1. package com.kuaishou.is.datamart;
    2. import java.util.ArrayList;
    3. import java.util.List;
    4. import java.util.concurrent.CountDownLatch;
    5. public class Main {
    6. public static void main(String[] args) throws InterruptedException {
    7. List list = new ArrayList<>();
    8. CountDownLatch latch = new CountDownLatch(1);
    9. CountDownLatch waiting = new CountDownLatch(3);
    10. Thread t1 = new Thread(() -> {
    11. try {
    12. latch.await();
    13. for (int i = 0; i < 1000; i++) {
    14. list.add("1");
    15. }
    16. } catch (InterruptedException e) {
    17. e.printStackTrace();
    18. } finally {
    19. waiting.countDown();
    20. }
    21. });
    22. Thread t2 = new Thread(() -> {
    23. try {
    24. latch.await();
    25. for (int i = 0; i < 1000; i++) {
    26. list.add("2");
    27. }
    28. } catch (InterruptedException e) {
    29. e.printStackTrace();
    30. } finally {
    31. waiting.countDown();
    32. }
    33. });
    34. Thread t2 = new Thread(() -> {
    35. try {
    36. latch.await();
    37. for (int i = 0; i < 1000; i++) {
    38. list.add("2");
    39. }
    40. } catch (InterruptedException e) {
    41. e.printStackTrace();
    42. } finally {
    43. waiting.countDown();
    44. }
    45. });
    46. t1.start();
    47. t2.start();
    48. latch.countDown();
    49. waiting.await();
    50. System.out.println(list);
    51. }
    52. }

    在 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 ,该出现的异常,一个也不会少。

  • 相关阅读:
    基于PHP+MySQL共享自行车租赁管理系统的设计与实现
    Bert-as-service 实战
    JavaScript大神:我们能对 JavaScript 做的最好事情就是让它退役!
    Casein-PEG-N3 络蛋白-聚乙二醇-叠氮 Casein-azide,供应BSA/HSA/Transferrin修饰叠氮
    Shell 和 Shell 脚本 (Shell Script)
    旁路openwrt启用ipv6
    ClickHouse Keeper: Coordination without the drawbacks没有缺点的分布式协作系统
    [02] BLEMotion-Kit 基于QMI8658传感器使用加速度计进行倾斜检测
    设置Mac上Git的多账户配置,用于同时访问GitLab和Gitee
    Kafka架构篇 - 多副本机制
  • 原文地址:https://blog.csdn.net/JavaMonsterr/article/details/126013026