Java多线程join()未达预期、并发写ArrayList数据丢失问题咨询
问题背景
首次接触Thread线程编程时,尝试实现如下逻辑:将存储数字的ArrayList拆分为4个分片,启动4个独立线程分别识别各分片内的奇数、偶数,分别存入evens或odds列表。确认业务逻辑无问题,但线程运行结果始终不符合预期。
完整代码如下:
OddEvenFinder(Runnable实现类)
package ThreadRace; public class OddEvenFinder implements Runnable { private final int id; private final int size; public OddEvenFinder(int id, int size) { this.id = id; this.size = size; } @Override public void run() { int start = id * this.size; int end = start + this.size; while (start < end) { if (Starter.numbers.get(start) % 2 == 0) { Starter.evens.add(start); } else { Starter.odds.add(start); } start++; } } }
Starter(测试启动类)
package ThreadRace; import java.util.ArrayList; import java.util.List; public class Starter { public static List<Integer> numbers = new ArrayList<>(); public static List<Integer> evens = new ArrayList<>(); public static List<Integer> odds = new ArrayList<>(); public static void main(String[] args) throws InterruptedException { for (int i = 1; i <= 10000; i++) { numbers.add(i); } OddEvenFinder f1 = new OddEvenFinder(0, numbers.size() / 4); OddEvenFinder f2 = new OddEvenFinder(1, numbers.size() / 4); OddEvenFinder f3 = new OddEvenFinder(2, numbers.size() / 4); OddEvenFinder f4 = new OddEvenFinder(3, numbers.size() / 4); Thread thread1 = new Thread(f1); Thread thread2 = new Thread(f2); Thread thread3 = new Thread(f3); Thread thread4 = new Thread(f4); thread1.start(); thread2.start(); thread3.start(); thread4.start(); thread1.join(); thread2.join(); thread3.join(); thread4.join(); System.out.println(evens.size()); System.out.println(odds.size()); } }
异常现象
测试数据总元素共10000个,奇偶各5000,按预期evens和odds列表长度应均为5000,但实际运行时两者总长度仅在3000-4000区间。
添加打印语句调试后,问题几乎消失,修改后的run方法代码如下:
@Override public void run() { int start = id * this.size; int end = start + this.size; while (start < end) { System.out.println("Thread number " + (this.id + 1) + " is working"); if (Starter.numbers.get(start) % 2 == 0) { System.out.println(start + " added to evens"); Starter.evens.add(start); } else { System.out.println(start + " added to odds"); Starter.odds.add(start); } start++; } }
此时运行结果接近正确值(约4999/5000);若将numbers大小缩小到4000-5000量级,运行结果完全正确。
核心疑问
join()方法为何没有达到等待线程执行完成的效果?对join()的理解存在什么误区?- 为何添加打印文本的调试语句后,程序运行结果的准确率会大幅提升?
关于join()的认知误区
join()本身的功能完全符合预期:它确实会阻塞主线程,直到对应工作线程执行完毕才会继续向下执行。你遇到的元素丢失问题和join()没有任何关系,核心误区是把「线程正常执行完毕」等同于「线程内的并发操作不会产生数据错误」。
元素丢失的根因是**ArrayList是线程不安全的容器**:ArrayList底层基于动态数组实现,它的add()方法没有做任何同步控制,整个操作拆分为「在数组对应位置写入元素」「更新列表size计数」两个非原子步骤。多线程同时调用add()时会出现三类问题:
- 两个线程同时拿到相同的数组写入下标,后写入的值覆盖先写入的值
- 两个线程同时读取到相同的size值,更新size时互相覆盖,导致最终size计数小于实际添加的元素数量
- 当数组容量不足触发扩容时,多线程并发执行数组拷贝逻辑会直接导致元素丢失
所有线程确实都在join()的控制下执行完成了,只是执行过程中并发修改非线程安全容器,导致了数据丢失。
加打印语句后准确率提升的原因
System.out.println()是自带同步锁的方法:查看JDK源码可以发现,PrintStream类的println()方法被synchronized关键字修饰,同一时间只能有一个线程执行打印逻辑。
多线程调用打印方法时,会因为争抢内置锁产生随机的阻塞等待,大幅降低了多个线程同时调用ArrayList.add()的概率,相当于无意中给线程操作加了弱同步效果,所以数据丢失的概率会明显下降。但这只是概率层面的缓解,没有从根本上解决线程安全问题,所以数据量较大时还是会出现偶发的元素丢失;而数据量较小时并发冲突概率本来就低,加上打印带来的阻塞效果,就会表现为运行结果完全正确。
修复方案
要彻底解决这个问题,只需要保证列表修改操作的线程安全即可,可选方案包括:
- 用
Collections.synchronizedList()包装普通ArrayList,生成线程安全的列表代理 - 使用JUC包下的线程安全容器,比如
CopyOnWriteArrayList - 手动给
evens.add()和odds.add()操作加synchronized同步锁,保证同一时间只有一个线程能修改列表
内容的提问来源于stack exchange,提问作者Umut Emre Önder

