多线程并发安全,这可是每个 Java 程序员的"成年礼"。想当年,我因为这个问题,可是被生产环境的 Bug "毒打"过好几次。而聊天室的例子,更是把并发、I/O、线程通信这些知识点完美地串在了一起。
坐稳了,老司机又要发车了,这次我们去探索多线程的奇妙(和危险)世界!
😎 从数据错乱到聊天室狂欢:我与Java多线程的爱恨纠葛
嘿,各位在代码世界里砥砺前行的伙伴们!又是我,你们的老朋友,一个热衷于分享"踩坑"与"爬坑"经验的老码农。
今天咱们要聊的话题,可以说是 Java 中最迷人也最危险的领域之一:多线程并发。如果说程序是一场精密的戏剧,那多线程就是让多个演员同时登台表演。演好了,是精彩绝伦的交响乐;演砸了,那就是一场灾难性的舞台事故。😱
别怕,今天我不跟你念经似的背概念。我这就把当年让我头皮发麻的几个真实项目场景掏出来,带你亲身体验一下,从"数据去哪儿了"的崩溃,到"原来锁是这么回事"的顿悟,再到一个功能完善的多人聊天室的诞生。
场景一:"幽灵豆子"事件,一个 beans--
引发的血案 👻
我遇到了什么问题?
那是在一个资源分配系统里,有一个"资源池",里面有固定数量的资源。为了简化问题,我们把它想象成一个桌子(Table
),上面有20颗豆子(beans
)。现在有两个线程,代表两个人,同时从桌子上拿豆子。
我的代码直观又简单:
java
class Table {
private int beans = 20; // 桌上有20颗豆子
public int getBean() {
if (beans == 0) {
throw new RuntimeException("没有豆子了!");
}
// 为了放大问题,我们让线程在这里"思考"一下人生
Thread.yield();
return beans--;
}
}
// 两个线程同时疯狂拿豆子
Thread t1 = new Thread(() -> { while(true) table.getBean(); });
Thread t2 = new Thread(() -> { while(true) table.getBean(); });
t1.start();
t2.start();
Thread.yield()
是一个神奇的方法,它会建议线程调度器:"我累了,让别人先跑吧"。我用它来模拟线程执行到一半时,CPU时间片恰好用完的场景。
我满怀期待地运行程序,结果控制台打印出来的豆子数量让我大跌眼镜!有时候会打印出两个相同的数字,比如两个线程都拿到了第15颗豆子!更糟糕的是,最后豆子变成了负数,然后程序因为 beans == 0
的判断没通过,还在疯狂地拿,最终导致系统资源耗尽。
这就是典型的并发安全问题。我来给你慢动作回放一下"案发经过":
beans
当前为 20。- 线程T1进入
getBean()
,判断beans > 0
,条件成立。 - T1正准备执行
return beans--
,突然,Thread.yield()
发挥作用,线程T1被切换,暂停了!此时beans
仍然是 20。 - 线程T2登场!它也进入
getBean()
,判断beans > 0
(20当然大于0),条件成立。 - T2顺利执行
return beans--
。它拿到了第20颗豆子,然后beans
变成了 19。 - 风水轮流转,T1又被唤醒了。它从刚才暂停的地方继续,也执行了
return beans--
。注意!它之前已经判断过了,所以不会再判断了!它也拿到了第20颗豆子,然后beans
变成了 18。
看到了吗?同一颗豆子,被两个人拿走了!这就是因为 getBean()
这个操作不是原子性的,它被分成了"检查"和"修改"两步,而线程切换就发生在了这两步之间。
我是如何用 [synchronized] 解决的?
"恍然大悟"的瞬间💡: 这个问题的根源在于,拿豆子的"房间"(getBean
方法)谁都能随便进。我需要一个"门卫",一次只允许一个人进去,拿完出来后,下一个人才能进。这个"门卫",就是 synchronized
关键字!
解决方案1:同步方法(简单粗暴,但有效)
我给 getBean
方法加上 synchronized
,把它变成一个同步方法。
java
// 加上synchronized,就像给方法上了一把锁
public synchronized int getBean() {
if (beans == 0) {
throw new RuntimeException("没有豆子了!");
}
Thread.yield();
return beans--;
}
加上之后,世界清净了。synchronized
保证了任何时候,只有一个线程能进入 getBean()
方法。一个线程从进入到退出方法的整个过程,都不会被其他线程打断。这就好像给这个方法加了一把锁,线程进去前先拿锁,出来后还锁。没拿到锁的线程,只能乖乖在门外排队。
解决方案2:同步块(更精细的控制,性能更优)
后来在另一个场景,比如一个"在线购物"的方法,我发现整个方法都上锁太浪费了。
java
public void buy() {
System.out.println("1. 挑衣服..."); // 这个过程大家可以同时进行,没必要排队
Thread.sleep(5000);
// 只有"试衣服"这个环节需要排队,因为试衣间只有一个
System.out.println("2. 试衣服...");
Thread.sleep(5000);
System.out.println("3. 结账离开..."); // 这个也可以并发
}
如果给整个 buy
方法上锁,那所有人连"挑衣服"都得排队,效率太低了!我只需要锁住最关键的部分------"试衣服"。这就是同步块大显身手的时候。
java
public void buy() {
System.out.println(t.getName()+":正在挑衣服...");
Thread.sleep(5000);
// 使用同步块,精确锁定需要排队的代码
synchronized (this) { // this就是那个"锁对象"
System.out.println(t.getName() + ":正在试衣服...");
Thread.sleep(5000);
}
System.out.println(t.getName()+":结账离开");
}
知识点串讲 & 踩坑指南:
- 同步监视器对象(锁) :
synchronized
后面括号里的那个对象,就是"锁"。- 对于同步方法
public synchronized void method()
,锁就是this
对象。 - 对于静态同步方法
public static synchronized void method()
,锁是这个类的Class
对象(比如Shop.class
)。
- 对于同步方法
- 🚨 巨坑警告 :所有需要排队的线程,必须使用同一个锁对象 !如果你写成
synchronized (new Object())
,那就完了!因为每个线程都 new 了一个自己的新锁,相当于每个人都自带一把锁,然后开了不同的门,根本起不到排队的作用!
场景二:聊天室进化史,从"单线程"到"多线程"再到"广播风暴" 🚀
这个场景,咱们接着上次的聊天室说。上次我们解决了服务端接待多个客户端的问题,靠的就是"来一个客户端,开一个新线程"的策略。
java
// 服务端主线程
while(true) {
Socket socket = serverSocket.accept();
// 每来一个连接,就开启一个新线程去处理
Thread t = new Thread(new ClientHandler(socket));
t.start();
}
我遇到了什么问题?(广播难题)
现在,客户端A、B、C都连上来了,每个客户端都由一个独立的 ClientHandler
线程在服务。但是,当A发送一条消息给服务端后,服务端如何把这条消息转发给B和C呢?
ClientHandler-A
线程只拥有 A 的 Socket
输出流,它根本不知道 B 和 C 的输出流在哪里。线程之间就像被关在不同的房间里,无法直接通信。
我是如何用 [共享集合+同步] 解决的?
"恍然大悟"的瞬间💡: 线程们虽然在不同的房间,但它们都在同一栋"大楼"里(Server
对象实例)。我可以在大楼的"大厅"里放一个公共的"信箱",让所有线程都能访问!
解决方案:
-
创建共享集合 :在
Server
类里,定义一个集合,用来存放所有客户端的输出流。javapublic class Server { // 这个集合就是我们的大厅"信箱",用来存放所有客户端的输出流 private List<PrintWriter> allOut = new ArrayList<>(); // ... }
-
维护共享集合 :在每个
ClientHandler
线程的run
方法里:- 上线 :当一个客户端连接成功,立即把它对应的
PrintWriter
输出流添加到allOut
集合里。 - 广播 :当收到这个客户端发来的消息后,遍历
allOut
集合,把消息发送给每一个输出流。
javaprivate class ClientHandler implements Runnable { public void run() { // ... 省略获取pw的代码 PrintWriter pw = new PrintWriter(...); // 关键步骤1:将自己的输出流存入共享集合 allOut.add(pw); String message; while ((message = br.readLine()) != null) { System.out.println(host + "说:" + message); // 关键步骤2:遍历共享集合,广播消息 for (PrintWriter o : allOut) { o.println(host + "说:" + message); } } } }
- 上线 :当一个客户端连接成功,立即把它对应的
-
再次踩坑与最终方案! 正当我以为大功告成时,一个新的并发问题出现了!
ArrayList
是线程不安全的!想象一下:- 线程A正在
for
循环遍历allOut
准备广播消息。 - 就在此时,一个新客户端D连接进来,线程D执行了
allOut.add(pw)
,修改了集合的结构! - 线程A继续遍历,就会立刻抛出
ConcurrentModificationException
!
最终"恍然大悟"💡: 对共享资源的所有访问(读和写),都必须加锁!
javaprivate class ClientHandler implements Runnable { public void run() { // ... PrintWriter pw = ...; // 添加时加锁 synchronized (allOut) { allOut.add(pw); } // ... while ((message = br.readLine()) != null) { // 遍历时也要加锁! synchronized (allOut) { for (PrintWriter o : allOut) { o.println(host + "说:" + message); } } } // (别忘了下线时也要加锁移除pw) } }
(专业提示:对于这种读写频繁的共享集合,使用
java.util.concurrent
包下的线程安全集合如CopyOnWriteArrayList
会是更高效、更优雅的选择,但用synchronized
是理解其原理的基础。) - 线程A正在
客户端的自我救赎:收发消息互不干扰
在解决了服务端的问题后,我发现客户端又"卡"住了。我的客户端代码是这样的:
java
// 客户端主线程
while(true) {
String line = scanner.nextLine(); // 1. 等待用户输入
pw.println(line); // 2. 发送给服务端
line = br.readLine(); // 3. 读取服务端的回信
System.out.println(line);
}
问题在于,如果我(客户端)不说话(卡在 scanner.nextLine()
),我就永远收不到别人发来的消息(br.readLine()
得不到执行)。这可不行!
最终解决方案:客户端也要多线程!
我必须把"发消息"和"收消息"这两个互相阻塞的操作,分到两个线程里去!
- 主线程 :专门负责读取用户键盘输入,然后通过
PrintWriter
发送出去。 - 一个新的后台线程(守护线程) :专门负责一个死循环,不断地通过
BufferedReader
读取从服务端发来的消息,并打印到控制台。
java
// 客户端 start() 方法
public void start() {
// 启动一个专门接收消息的线程
Thread handler = new Thread(new ServerHandler());
handler.setDaemon(true); // 设置为守护线程
handler.start();
// 主线程负责发送消息
// ... while循环读取Scanner并pw.println(line) ...
}
// 专门负责接收消息的内部类
private class ServerHandler implements Runnable {
public void run() {
try {
// ... 获取BufferedReader
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) { /* ... */ }
}
}
将接收线程设置为守护线程 (setDaemon(true)
)是个好习惯。这样,当我们的主线程(发送消息的线程)结束后,这个接收线程也会被JVM自动杀死,程序就能干净地退出了。
总结一下今天的心得体会
多线程的世界,就像一个繁忙的十字路口,没有红绿灯(synchronized
)就会乱成一锅粥。
- 识别临界资源 :对任何可能被多个线程同时修改的数据(比如
beans
数量、共享的allOut
集合),都要保持高度警惕。 - 锁住该锁的 :
synchronized
是你的救星。但不要滥用,用同步块精细地控制锁的范围,既能保证安全,又能提升性能。 - 警惕共享状态 :当多个线程需要协作时,必然会引入共享状态(共享变量、集合等)。记住,对这个共享状态的每一次读写,都可能需要同步。
- I/O 与线程是天生一对 :在网络编程中,为了不让一个阻塞的I/O操作(如
accept()
,readLine()
)卡死整个程序,多线程几乎是必然的选择。
希望我今天的分享,能帮你揭开多线程并发的神秘面纱。它虽然复杂,但一旦你掌握了它的规律,就能编写出功能强大、性能卓越的应用程序。
好了,今天就聊到这。你有没有遇到过什么让你印象深刻的并发 Bug?欢迎在评论区分享你的"事故"现场!我们一起成长!👋