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

简介: 我们都知道,Java中的ArrayList是非线程安全的,这个知识点太熟了,甚至面试的时候都很少问了。

image.png


你好,我是看山。


我们都知道,Java中的ArrayList是非线程安全的,这个知识点太熟了,甚至面试的时候都很少问了。


但是我们真的清楚原理吗?或者知道多线程情况下使用ArrayList会发生什么?


前段时间,我们就踩坑了,而且直接踩了两个坑,今天就来扒一扒。


翠花,上源码

上代码之前先说下ArrayList的add逻辑:


检查队列中数组是否还没有添加过元素

如果是,设置当前需要长度为10,如果否,设置当前需要长度为当前队列长度+1

判断需要长度是否大于数组大小

如果是,需要扩容,将数组长度扩容1.5倍(第一次扩容会从0直接到10,后续会按照1.5倍的步幅增长)

数组中添加元素,队列长度+1

附上代码,有兴趣的可以在看看源码。


/**
 * Appends the specified element to the end of this list.
 *
 * @param e element to be appended to this list
 * @return <tt>true</tt> (as specified by {@link Collection#add})
 */
public boolean add(E e) {
    // 判断数组容量是否足够,如果不足,增加1.5倍,size是当前队列长度
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 给下标为size的赋值,同时队列长度+1,下标从0开始
    elementData[size++] = e;
    return true;
}
private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
    // 判断是否首次添加元素,如果是,返回默认队列长度,现在是10
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    // 如果不是首次添加元素,就返回当前队列长度+1
    return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // overflow-conscious code
    // 如果需要的长度大于队列中数组长度,扩容,如果可以满足需求,就不用扩容
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}
/**
 * Increases the capacity to ensure that it can hold at least the
 * number of elements specified by the minimum capacity argument.
 *
 * @param minCapacity the desired minimum capacity
 */
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    // 这里就是扩容1.5倍的代码
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

就是这么不安全

从上面代码可以看出,ArrayList中一丁点考虑多线程的元素都没有,完全的效率优先。


奇怪的ArrayIndexOutOfBoundsException

先做一个假设,此时数组长度达到临界边缘,比如目前容量是10,现在已经有9个元素,也就是size=9,然后有两个线程同时向队列中增加元素:


线程1开始进入add方法,获取size=9,调用ensureCapacityInternal方法进行容量判断,此时数组容量是10,不需要扩容

线程2也进入add方法,获取size=9,调用ensureCapacityInternal方法进行容量判断,此时数组容量还是10,也不需要扩容

线程1开始赋值值了,也就是elementData[size++] = e,此时size变成10,达到数组容量极限

线程2此次开始执行赋值操作,使用的size=10,也就是elementData[10] = e,因为下标从0开始,目前数组容量是10,直接报数组越界ArrayIndexOutOfBoundsException。

仅仅差了一步,线程2就成为了抛异常的凶手。但是抛出异常还是好的,因为我们知道出错了,可以沿着异常


诡异的null元素

这种情况不太容易从代码中发现,得对代码稍加改造,elementData[size++] = e这块代码其实执行了两步:


elementData[size] = e;
size++;

假设还是有两个线程要赋值,此时数组长度还比较富裕,比如数组长度是10,目前size=5:


线程1开始进入add方法,获取size=5,调用ensureCapacityInternal方法进行容量判断,此时数组容量是10,不需要扩容

线程2也进入add方法,获取size=5,调用ensureCapacityInternal方法进行容量判断,此时数组容量还是10,也不需要扩容

线程1开始赋值,执行elementData[size] = e,此时size=5,在执行size++之前,线程2开始赋值了

线程2开始赋值,执行elementData[size] = e,此时size还是5,所以线程2把线程1赋的值覆盖了

线程1开始执行size++,此时size=6

线程2开始执行size++,此时size=7

也就是说,添加了2个元素,队列长度+2,但是真正加入队列的元素只有1个,有一个被覆盖了。


这种情况不会立马报错,排查起来就很麻烦了。而且随着JDK 8的普及,可能随手使用filter过滤空元素,这样就不会立马出错,直到出现业务异常之后才能发现,到那时,错误现场已经不见了,排查起来一头雾水。


有同学会问,源码中是elementData[size++] = e,是一行操作,为什么会拆成两步执行呢?其实这得从JVM字节码说起了。


通过JVM字节码说说第二种异常出现的原因

先来一段简单的代码:


public class Main {
    public static void main(String[] args) {
        int[] nums = new int[3];
        int index = 0;
        nums[index++] = 5;
    }
}

通过javac Main.java和javap -v -l Main.class组合操作得到字节码:


下面那些中文是我后加的备注,备注中还列出了局部变量表和栈值的变化,需要有点耐心。


public class Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#12         // java/lang/Object."<init>":()V
   #2 = Class              #13            // Main
   #3 = Class              #14            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               main
   #9 = Utf8               ([Ljava/lang/String;)V
  #10 = Utf8               SourceFile
  #11 = Utf8               Main.java
  #12 = NameAndType        #4:#5          // "<init>":()V
  #13 = Utf8               Main
  #14 = Utf8               java/lang/Object
{
  public Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1                                                                               局部变量表                             栈
         0: iconst_3                     // 将int型(3)推送至栈顶                                                      args                                3
         1: newarray       int           // 创建一个指定原始类型(如int, float, char…)的数组,并将其引用值压入栈顶          args                                数组引用
         3: astore_1                     // 将栈顶引用型数值存入第二个本地变量                                            args, nums=数组引用                  null
         4: iconst_0                     // 将int型(0)推送至栈顶                                                       args, nums=数组引用                  0
         5: istore_2                     // 将栈顶int型数值存入第三个本地变量                                             args, nums=数组引用, index=0         null
         6: aload_1                      // 将第二个引用类型本地变量推送至栈顶                                             args, nums=数组引用, index=0         数组引用
         7: iload_2                      // 将第三个int型本地变量推送至栈顶                                               args, nums=数组引用, index=0         0, 数组引用
         8: iinc          2, 1           // 将指定int型变量增加指定值(i++, i--, i+=2),也就是第三个本地变量增加1            args, nums=数组引用, index=1         0, 数组引用
        11: iconst_5                     // 将int型(5)推送至栈顶                                                        args, nums=数组引用, index=1         5, 0, 数组引用
        12: iastore                      // 将栈顶int型数值存入指定数组的指定索引位置                                       args, nums=数组引用, index=1         null
        13: return                       // 从当前方法返回void
      LineNumberTable:
        line 3: 0                        // int[] nums = new int[3];
        line 4: 4                        // int index = 0;
        line 5: 6                        // nums[index++] = 5;
        line 6: 13                       // 方法结尾默认的return
}

从上面的字节码可以看到,nums[index++] = 5这一句会被转为5个指令,是从6到12。大体操作如下:


将数组、下标压入栈

给下标加值

将新值压入栈

取栈顶三个元素开始给元素指定下标赋值

也即是说,错误出在数组赋值操作时先将数组引用和下标同时压入栈顶,与下标赋值是两步,在多线程环境中,就有可能出现上面说到的null值存在。


解法

其实解法也很简单,就是要意识到多线程环境,然后不使用ArrayList。可以使用Collections.synchronizedList()返回的同步队列,也可以使用CopyOnWriteArrayList这个队列,或者自己扩展ArrayList,将add方法做成同步方法。


文末总结

ArrayList整个类的操作都是非线程安全的,一旦在多线程环境中使用,就可能会出现问题。上面提到add操作就会有两种异常行为,一个是数组越界异常,一个是出现丢数且出现空值。这还只是最简单的add操作,如果add、addAll和get混合使用使用时,异常情况就更多了。所以,使用的时候一定要注意是不是单线程操作,如果不是,果断使用其他队列防雷。


目录
相关文章
|
2月前
|
并行计算 Java 数据处理
SpringBoot高级并发实践:自定义线程池与@Async异步调用深度解析
SpringBoot高级并发实践:自定义线程池与@Async异步调用深度解析
192 0
|
1月前
|
安全
List并发线程安全问题
【10月更文挑战第21天】`List` 并发线程安全问题是多线程编程中一个非常重要的问题,需要我们认真对待和处理。只有通过不断地学习和实践,我们才能更好地掌握多线程编程的技巧和方法,提高程序的性能和稳定性。
152 59
|
14天前
|
安全 Java 开发者
Java 多线程并发控制:深入理解与实战应用
《Java多线程并发控制:深入理解与实战应用》一书详细解析了Java多线程编程的核心概念、并发控制技术及其实战技巧,适合Java开发者深入学习和实践参考。
|
26天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
2月前
|
存储 安全 Java
代码审查:从 ArrayList 说线程安全
我们在编码和做代码审查的过程中,要对涉及到多线程使用的场景时刻绷着一根弦,将隐患拒之门外。
37 4
|
2月前
|
Java
【编程进阶知识】揭秘Java多线程:并发与顺序编程的奥秘
本文介绍了Java多线程编程的基础,通过对比顺序执行和并发执行的方式,展示了如何使用`run`方法和`start`方法来控制线程的执行模式。文章通过具体示例详细解析了两者的异同及应用场景,帮助读者更好地理解和运用多线程技术。
29 1
|
3月前
|
网络协议 C语言
C语言 网络编程(十四)并发的TCP服务端-以线程完成功能
这段代码实现了一个基于TCP协议的多线程服务器和客户端程序,服务器端通过为每个客户端创建独立的线程来处理并发请求,解决了粘包问题并支持不定长数据传输。服务器监听在IP地址`172.17.140.183`的`8080`端口上,接收客户端发来的数据,并将接收到的消息添加“-回传”后返回给客户端。客户端则可以循环输入并发送数据,同时接收服务器回传的信息。当输入“exit”时,客户端会结束与服务器的通信并关闭连接。
|
3月前
|
数据采集 消息中间件 并行计算
进程、线程与协程:并发执行的三种重要概念与应用
进程、线程与协程:并发执行的三种重要概念与应用
62 0
|
3月前
|
C语言
C语言 网络编程(九)并发的UDP服务端 以线程完成功能
这是一个基于UDP协议的客户端和服务端程序,其中服务端采用多线程并发处理客户端请求。客户端通过UDP向服务端发送登录请求,并根据登录结果与服务端的新子线程进行后续交互。服务端在主线程中接收客户端请求并创建新线程处理登录验证及后续通信,子线程创建新的套接字并与客户端进行数据交换。该程序展示了如何利用线程和UDP实现简单的并发服务器架构。
|
4月前
|
Rust 并行计算 安全
揭秘Rust并发奇技!线程与消息传递背后的秘密,让程序性能飙升的终极奥义!
【8月更文挑战第31天】Rust 以其安全性和高性能著称,其并发模型在现代软件开发中至关重要。通过 `std::thread` 模块,Rust 支持高效的线程管理和数据共享,同时确保内存和线程安全。本文探讨 Rust 的线程与消息传递机制,并通过示例代码展示其应用。例如,使用 `Mutex` 实现线程同步,通过通道(channel)实现线程间安全通信。Rust 的并发模型结合了线程和消息传递的优势,确保了高效且安全的并行执行,适用于高性能和高并发场景。
68 0