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
...
总共有四种情况:
- 正常输出;
- 输出值为null;
- 数组越界异常;
- 某些线程没有输出值;
解释:
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)!