面试必问!多线程操作集合避坑指南:用synchronized搞定线程安全
昨天刚吃透线程控制的核心方法,今天就遇到新难题------多线程操作ArrayList、文件等共享资源时,频繁出现数据错乱、数组越界、文件写入重叠!这不仅是开发高频踩坑点,更是面试必问的线程安全核心考点!
本文不堆砌冗余代码(避免与公众号重复),聚焦线程安全本质+ synchronized核心用法+面试避坑点,帮你快速吃透共享资源竞争问题,文末附实战作业+进阶福利,完整可运行源码可通过公众号领取~
关注GZH【咖啡 Java 研习班】,回复「学习资料」领取完整源码、面试题库和多线程学习路线图,技术交流群有资深架构师随时答疑!
一、先踩坑:多线程操作ArrayList的致命问题
很多新手误以为ArrayList是线程安全的,直接在多线程环境中添加数据,结果必然踩坑!
核心问题演示(简化版)
scss
List<String> dataList = new ArrayList<>();
// 两个线程同时向集合添加1000条数据
Runnable addTask = () -> {
for (int i = 0; i < 1000; i++) {
dataList.add(Thread.currentThread().getName() + "-" + i);
try { Thread.sleep(1); } catch (InterruptedException e) {}
}
};
new Thread(addTask, "线程A").start();
new Thread(addTask, "线程B").start();
执行结果与问题本质
理想结果是集合大小2000,但实际常出现大小小于2000 ,甚至抛出IndexOutOfBoundsException(数组越界)。
核心原因:共享资源的非原子操作。ArrayList的add()方法分3步:检查容量→放入元素→size++,多线程并发时会出现"步骤交叉",导致元素覆盖或计数器错误。
面试考点:ArrayList为什么线程不安全?答:add()等方法非原子操作,无同步机制,多线程并发操作会导致数据错乱。
二、解决方案:synchronized的2种核心用法(精准避坑)
synchronized是Java内置同步锁,能保证同一时刻只有1个线程执行目标代码,从根源解决共享资源竞争。核心有2种用法,适配不同场景:
1. 同步代码块:精准锁定核心代码(推荐)
语法:synchronized(锁对象) { 需同步的核心代码 },优势是锁范围小,性能影响低。锁对象需满足"多线程共用同一个",推荐自定义Object锁。
简化实操:修复ArrayList线程安全问题
scss
List<String> dataList = new ArrayList<>();
Object lock = new Object(); // 自定义锁对象(多线程共用)
Runnable safeAddTask = () -> {
for (int i = 0; i < 1000; i++) {
// 只锁定add()核心操作,缩小锁范围
synchronized (lock) {
dataList.add(Thread.currentThread().getName() + "-" + i);
}
try { Thread.sleep(1); } catch (InterruptedException e) {}
}
};
new Thread(safeAddTask, "线程A").start();
new Thread(safeAddTask, "线程B").start();
// 最终集合大小稳定2000,无数据错乱
2. 同步方法:锁定整个方法(简单直接)
语法:在方法前加synchronized,锁对象默认是this(非静态方法)或类对象(静态方法)。适合整个方法都需要同步的场景,比如多线程文件写入。
简化实操:多线程安全写入文件
typescript
class FileWriteTask implements Runnable {
private String filePath;
private String content;
// 同步方法:同一时刻只有1个线程执行
private synchronized void writeToFile() {
try (Writer writer = new FileWriter(filePath, true)) {
writer.write(content + "\n");
System.out.println(Thread.currentThread().getName() + " 写入成功");
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
writeToFile(); // 调用同步方法
}
}
// 多线程共用1个任务对象(保证锁对象一致)
FileWriteTask task = new FileWriteTask("log.txt", "用户登录日志");
new Thread(task, "写入线程1").start();
new Thread(task, "写入线程2").start();
三、synchronized锁对象选择:3个避坑原则(面试高频)
锁对象的选择直接决定同步是否生效,这3个原则必须记牢:
- 锁对象必须唯一:多线程必须共用同一个锁对象,否则同步失效(比如每个线程都新建锁对象);
- 缩小锁范围:优先用同步代码块锁定核心代码,而非整个方法,减少线程阻塞时间,提升性能;
- 避免用常量/包装类当锁:比如String、Integer,这些对象可能被常量池缓存,导致锁范围意外扩大(多个无关线程竞争同一把锁)。
补充考点:synchronized是可重入锁!同一线程可多次获取同一把锁,不会死锁(比如同步方法调用另一个同步方法)。
四、实战升级:多线程学生信息管理(整合核心知识点)
结合集合、IO、同步知识,实现多线程安全的学生信息管理:多个线程添加学生,1个线程查询,保证数据准确。
ini
// 核心逻辑简化版
List<Student> studentList = new ArrayList<>();
Object lock = new Object();
// 添加任务(同步代码块)
Runnable addTask = () -> {
synchronized (lock) {
// 安全添加5个学生(核心逻辑)
for (int i = 0; i < 5; i++) {
studentList.add(new Student("00" + (studentList.size()+1), "学生" + i, 80+i));
}
}
};
// 查询任务(同步代码块)
Runnable queryTask = () -> {
synchronized (lock) {
System.out.println("学生总数:" + studentList.size());
// 遍历查询学生信息
}
};
// 启动线程(先添加再查询)
Thread add1 = new Thread(addTask, "添加线程1");
Thread add2 = new Thread(addTask, "添加线程2");
Thread query = new Thread(queryTask, "查询线程");
add1.start(); add2.start();
add1.join(); add2.join(); // 等待添加完成
query.start(); // 准确查询10个学生信息
完整可运行代码+IO日志记录功能,关注GZH【咖啡 Java 研习班】回复「学习资料」领取。
五、今日小结+作业+明日预告(进阶福利)
小结
线程安全的本质是"共享资源的非原子操作",synchronized通过锁定代码解决竞争问题,两种用法各有适配场景:同步代码块精准高效,同步方法简单直接,掌握锁对象选择原则能避免大部分踩坑。
今日作业(面试高频场景)
用synchronized实现多线程火车票售票系统:10个线程同时售卖100张票,要求:① 无超卖、漏卖;② 记录售票日志(IO写入);③ 处理线程中断异常。
作业答案+多方案对比(同步代码块/方法优劣分析),关注公众号回复「售票系统」领取。
明日预告
synchronized虽好用,但有局限性:锁释放自动、不支持公平锁。明天学习更灵活的同步方案------Lock接口+原子类,解决synchronized痛点,还会讲解无锁同步原理,提升多线程开发灵活性!
如果觉得文章有用,欢迎点赞+收藏+转发!评论区留言你的作业思路,或者遇到的多线程问题,一起交流进步~
关注【咖啡 Java 研习班】,持续输出Java并发、JVM、SpringBoot核心技术,进阶路上不迷路!