You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Java多线程join()未达预期、并发写ArrayList数据丢失问题咨询

多线程并发修改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()时会出现三类问题:

  1. 两个线程同时拿到相同的数组写入下标,后写入的值覆盖先写入的值
  2. 两个线程同时读取到相同的size值,更新size时互相覆盖,导致最终size计数小于实际添加的元素数量
  3. 当数组容量不足触发扩容时,多线程并发执行数组拷贝逻辑会直接导致元素丢失
    所有线程确实都在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 06:45:41