自学内容网 自学内容网

Java中的ArrayList、LinkedList如何进行线程安全的操作、为什么ArrayList不是线程安全的?

线程安全的操作用法:

ArrayList

1.使用 Vector :

例:将 原本创建的 ArrayList 的代码 List list = new ArrayList(); 替换为 List arrayList = new Vector<>(); 

原理:Vector 类实现线程安全的方式是在其众多方法(如 add、removed、get 等常用方法)上都使用了 synchronized 关键字进行修饰。这意味着当多个线程同时访问 Vector 实例的这些方法时,同一时刻只有一个线程能够进入方法执行操作,其他线程需要等待锁被释放后才能进入,从而保证了在多线程环境下数据的一致性和操作的完整性。例如,线程 A 正在执行 vector.add(element)操作时,线程 B 如果也想执行 vector 的添加或其他操作,就必须等待线程 A 执行完并释放锁之后才行。

2.使用Collections.synchronizedList(list):

例:List<String> list = Collections.synchronizedList(new ArrayList<>());,后续操作这个List对象时,实际上修改的是原来 ArrayList 里的数据;

原理:Collections.synchronizedList 方法返回的是一个包装后的列表对象,它属于SynchronizedCollection 类型。在这个包装类中,对列表的各种操作方法(如 add、remove、get等)都添加了synchronized 修饰符。这里加锁的对象是当前 SynchronizedConllection 实例对象。不过需要注意的是,由于其内部数据没有用 volatile 关键字修饰,所以在使用迭代器的地方需要额外加锁,像间接用到迭代器的操作(比如 toString、equals、hashCode、containsAll 等方法)也要加锁,以避免在多线程环境下出现数据不一致的情况。例如,当一个线程正在通过迭代器遍历列表元素时,另一个线程如果修改列列表结构(添加或删除元素),若不加锁就可能导致迭代过程出现异常或者获取到错误的数据。

3.使用 JUC 中的 CopyOnWriteArrayList:

例:CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<String>();,它比较适用于读多写少的并发场景。

原理:它的核心原理是 “写时复制” 。在进行写操作(如 add、set、remove等可变操作)时,总是要先将原来内部存储数据的数组复制到一个新的数组,然后在新数组上进行相应的修改操作,最后将内部引用指向新的数组。这样做的好处是读数据时不用加锁,因为他保存了一份数据快照,多个线程可以同时读取这个快照数据,不会互相干扰。而且任何可变的操作都通过 ReentrantLock 来控制并发,保证同一时刻只有一个线程能进行写操作,避免了多个线程同时修改数据导致的不一致问题。例如,在一个有大量读操作和少量写操作的系统中,像配置信息列表的读取和偶尔的修改场景,使用 CopyOnWriteArrayList 可以在保证线程安全的同时,提高读操作的并发性能。

LinkedList

1. 使用 Collections.synchronizedList(List):

例:public static List linkedList = Collections.synchronizedList(new LinkedList());,和 ArrayList 中使用此方法类似。

原理:同样是对传入的LinkedList 进行包装,返回的包装类中的所有方法都加上了 synchronized 修饰符,加锁对象是当前 SynchronizedCollection 实例。这样就使得对这个包装后的 LinkedList 各种操作(如添加、删除、获取元素等)在多线程环境下能按顺序执行,避免并发冲突,保证数据的一致性。

2.使用 JUC 中的 ConcurrentLinkedQueue:

例:ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue();

原理:它本身就是专门为高并发场景设计的队列实现类,内部通过一些复杂的无锁算法(基于 CAS 操作等机制)来保证在多线程环境下多个线程对队列进行插入( offer 方法、删除(poll 方法)、获取元素(peek 方法)等操作时的线程安全性,能高效地支持多个线程同时操作队列,而不需要像传统的加锁方式那样带来较大的性能开销,常用于多线程生产者-消费者模式等场景中处理任务队列等情况。

线程不安全问题复现

实例

package org.example.a;
import java.util.ArrayList;
import java.util.List;
class MyThread extends Thread{
    public void run(){
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        Demo.arrayList.add(Thread.currentThread().getName() + " " + System.currentTimeMillis());
    }
}
public class Demo{
    public static List arrayList = new ArrayList();
    public static void main(String[] args) {
        Thread[] threadArray = new Thread[1000];
        for(int i = 0;i < threadArray.length;i++){
            threadArray[i] = new MyThread();
            threadArray[i].start();
        }
        for(int i = 0;i < threadArray.length;i++){
            try {
                threadArray[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        for(int i = 0;i < arrayList.size(); i++){
            System.out.println(arrayList.get(i));
        }
    }
}

运行结果:

Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 49
    at java.util.ArrayList.add(ArrayList.java:459)
    at org.example.a.MyThread.run(Demo.java:13)
Thread-3 1590288167830
Thread-7 1590288167834
Thread-57 1590288167834
...
null
Thread-951 1590288168255
Thread-254 1590288168255
...

总共有四种情况:

  1. 正常输出;
  2. 输出值为null;
  3. 数组越界异常;
  4. 某些线程没有输出值;

解释:

        MyThread 类:

        1. 继承 Thread 类并重写 run 方法来定义一个线程的执行逻辑。这里定义的 MyThread 类就是一个自定义的线程类,每个 MyThread 类的实例代表一个独立的线程,它在运行时会执行 run 方法中的代码逻辑。

        2. run 方法逻辑:Thread.sleep(1000); 这行代码的作用是让当前线程休眠 1000 毫秒(也就是 1 秒)。之所以这么做,是为了模拟多个线程并发操作时的一种常见情况,即各个线程启动时间有先后差异,但在后续操作中又可能同时去访问和修改共享资源(在这里共享资源就是 Demo.arrayList),通过让线程先休眠一会儿,增加了这种并发交错情况出现的可能性,更能凸显出多线程并发操作时可能产生的问题。

        3. 向共享 ArrayList 添加元素:Demo.arrayList.add(Thread.currentThread().getName() + " " + System.currentTimeMillis());  这行代码是核心操作部分。Thread.currentThread().getName()  用于获取当前正在执行的线程的名称,每个线程启动后都有一个默认的名称(格式类似 Thread-数字,数字是按线程启动顺序依次递增的编号),通过这个方法可以区分不同的线程。System.currentTimeMillis() 则获取当前系统的时间戳,这样就可以记录下每个线程执行添加操作的具体时间。最后通过 Demo.arrayList.add(...) 将由线程名称和时间戳组成的字符串添加到 Demo.arrayList 这个共享的 ArrayList 中。这里之所以能直接访问 Demo.arrayList,是因为它被定义为 Demo 类中的静态变量,在同一个类的不同地方(包括内部类 MyThread 中)都可以直接访问。

       

        Demo类:

        1. 定义共享 ArrayList :public static List arrayList = new ArrayList(); 这行代码定义了一个静态的 ArrayList 对象,它被声明为 List 类型(List 是接口,ArrayList 是它的一个实现类,这样声明可以提高代码的灵活性,方便后续更换具体的列表实现类而不影响其他代码)。这个 arrayList 就是多个线程会共同访问和操作的共享资源,在整个程序中只有这一个实例,多个线程都会向它里面添加元素。

        2. main 方法逻辑(启动多个线程):

                2.1 创建线程数组:Thread[] threadArray = new Thread[1000]; 创建了一个长度为 1000 的 Thread 类型数组,这个数组用于存放 1000 个 MyThread 线程对象,后续会通过这个数组来启动和管理这些线程。

                2.2 初始化并启动线程:在 for(int i = 0;i < threadArray.length;i++){...} 这个循环中:threadArray[i] = new MyThread(); 这行代码会在每次循环时创建一个新的 MyThread 线程对象,并将其存放在 threadArray 数组的对应位置上。threadArray[i].start(); 紧接着调用 start 方法来启动这个线程。一旦线程启动,它就会开始执行 MyThread 类中重写的 run 方法里的代码逻辑,也就是先休眠 1 秒,然后向共享的 ArrayList 中添加元素。通过这个循环,就启动了 1000 个独立的线程,它们都会去并发地操作 arrayList

        3. main 方法逻辑(等待线程执行完毕):在另一个 for(int i = 0;i < threadArray.length;i++){...} 循环中:try { threadArray[i].join(); } catch (InterruptedException e) { e.printStackTrace(); } 这里使用了 join 方法,它的作用是让当前线程(也就是 main 线程)阻塞,等待 threadArray 数组中对应的线程执行完毕。也就是说,main 线程会在这里等待每个 MyThread 线程都完成了自己的 run 方法中的操作(要么正常添加元素到 arrayList,要么出现异常),然后才会继续往下执行。这样做的目的是确保在后续遍历输出 arrayList 中的元素时,所有线程对 arrayList 的添加操作都已经结束,避免出现还没添加完就开始遍历输出的情况,保证输出结果是在所有线程操作完成后的最终状态。

        4. main 方法逻辑(遍历输出 ArrayList 元素):for(int i = 0;i < arrayList.size(); i++){ System.out.println(arrayList.get(i)); } 这个循环用于遍历 arrayList 并输出其中的元素。它通过 arrayList.size() 获取当前 arrayList 中元素的个数,然后以这个个数作为循环的终止条件,在循环内部使用 arrayList.get(i) 依次获取每个索引位置上的元素,并通过 System.out.println 输出到控制台。这样就能看到经过 1000 个线程并发操作后 arrayList 中的最终内容情况了。

        然而,由于 ArrayList 本身不是线程安全的,在多个线程并发地向它里面添加元素时,就像前面解释的那样,可能会出现各种问题,比如数组越界异常、添加的元素丢失(值为 null 或者部分线程添加的值没显示出来等情况),这些问题体现了在多线程环境下直接使用非线程安全的 ArrayList 是不安全的,可能导致数据不一致等错误情况出现。

线程不安全的原因分析:

ArrayList 源码:

public boolean add(E e) {
   // 确保ArrayList的长度足够
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // ArrayList加入
    elementData[size++] = e;
    return true;
}
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}
// 如果超过界限 数组长度增长
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    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);
}

在上述过程中,会出问题的地方是:

  •         1. 增加元素;
  •         2. 扩充数组长度;

情景1:增加元素

增加元素过程中较为容易出现问题的地方是elementData[size++] = e;。赋值的过程可以分为两个步骤elementData[size] = e; size++;。

例如size为1,有两个线程,分别加入字符串“a”与字符串“b”:

如果四条语句按照:1,2,3,4执行,那么没有问题:

        1. 线程1执行elementData[size] = e;(也就是element[1] = "a";);

        2. 线程 1 执行size++;(此时size变为 2);

        3. 线程 2 执行elementData[size] = e;(也就是element[2] = “b”;,因为此时size已经是 2 了);

        4. 线程 2 执行size++;size变为 3);

        

如果按照1,3,2,4来执行,就会出错。以下步骤按时间先后排序:

        1. 线程1 赋值 element[1] = “a”; 随后因为时间片用完而中断;

        3. 线程2 赋值 element[1] = “b; 随后因为时间片用完而中断;

                此处导致了之前所说的一个问题(有的线程没有输出); 因为后续的线程将前面的线程的值覆盖了。

        2. 线程1自增 size++; (size=2)

        4. 线程2自增 size++; (size=3)

                此处导致了某些值为null的问题。因为原来size=1, 但是因为线程1与线程2都将值赋值给了element[1],导致了element[2]内没有值,被跳过了。此时指针index指向了3,所以导致了值为null的情况。

情景2:增加元素

例如:size为2,数组长度限制为2,有两个线程,分别加入字符串“a”与字符串“b”:

 如果四条语句按照:1,2,3,4,5,6执行,那么没有问题:

        1. 线程1进入 ensureCapacityInternal 方法,判断是否需要扩容。因为 size 已经等于 elementData.length,所以线程A会调用 grow 方法进行扩容;

        2. 线程1将 "a" 添加到新的位置(elementData[2] = a);

        3. 线程1执行 size ++ (此时size 变为3);

        4. 线程2同样进入 ensureCapacityInternal 方法,判断是否需要扩容。此时 size 已经是3,而 elementData.length 是4(因为刚刚扩容过),所以不需要再扩容;

        5. 线程2将 "b" 添加到 elementData[3]

        6. 线程2执行 size ++ (此时size 变为4);

如果按照1,4,2,3,5,6来执行,就会出错。以下步骤按时间先后排序:

        1. 线程1调用 add("a") 并进入 ensureCapacityInternal 方法,判断是否需要扩容。发现 size 等于 elementData.length,准备调用 grow 方法进行扩容,但此时被操作系统暂停(时间片用完或其他原因);

        4. 线程2调用 add("a") 并进入 ensureCapacityInternal 方法,也判断是否需要扩容。同样发现 size 等于 elementData.length,准备调用 grow 方法进行扩容,但此时被操作系统暂停(时间片用完或其他原因);

        2. 线程1继续执行,完成扩容操作,并将 "a" 添加到 elementData[2];

        3. 然后增加 size 到3;

        5. 线程2继续执行,它之前已经判断过不需要扩容(因为它看到的 size 和 elementData.length 都是2),于是直接将 "b" 添加到 elementData[3]

        6. 线程2增加 size 到4;

        在这个过程中,尽管两个线程都进行了扩容判断,但由于线程调度的原因,线程A完成了扩容,而线程B在扩容前就已经决定不再扩容,从而导致了线程B直接使用了旧的数组长度信息,进而访问了超出数组边界的位置(即 elementData[3]),这可能引发数组越界异常。

        由此处可以看出因为数组的当前指向size并未进行加锁的操作,导致了数组越界的情况出现。


原文地址:https://blog.csdn.net/weixin_63172268/article/details/144358056

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!