从数据错乱到聊天室狂欢:我与Java多线程的爱恨纠葛

多线程并发安全,这可是每个 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 的判断没通过,还在疯狂地拿,最终导致系统资源耗尽。

这就是典型的并发安全问题。我来给你慢动作回放一下"案发经过":

  1. beans 当前为 20。
  2. 线程T1进入 getBean(),判断 beans > 0,条件成立。
  3. T1正准备执行 return beans--,突然,Thread.yield() 发挥作用,线程T1被切换,暂停了!此时 beans 仍然是 20
  4. 线程T2登场!它也进入 getBean(),判断 beans > 0(20当然大于0),条件成立。
  5. T2顺利执行 return beans--。它拿到了第20颗豆子,然后 beans 变成了 19。
  6. 风水轮流转,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 对象实例)。我可以在大楼的"大厅"里放一个公共的"信箱",让所有线程都能访问!

解决方案:

  1. 创建共享集合 :在 Server 类里,定义一个集合,用来存放所有客户端的输出流。

    java 复制代码
    public class Server {
        // 这个集合就是我们的大厅"信箱",用来存放所有客户端的输出流
        private List<PrintWriter> allOut = new ArrayList<>();
        // ...
    }
  2. 维护共享集合 :在每个 ClientHandler 线程的 run 方法里:

    • 上线 :当一个客户端连接成功,立即把它对应的 PrintWriter 输出流添加到 allOut 集合里。
    • 广播 :当收到这个客户端发来的消息后,遍历 allOut 集合,把消息发送给每一个输出流。
    java 复制代码
    private 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);
                }
            }
        }
    }
  3. 再次踩坑与最终方案! 正当我以为大功告成时,一个新的并发问题出现了!ArrayList线程不安全的!想象一下:

    • 线程A正在 for 循环遍历 allOut 准备广播消息。
    • 就在此时,一个新客户端D连接进来,线程D执行了 allOut.add(pw),修改了集合的结构!
    • 线程A继续遍历,就会立刻抛出 ConcurrentModificationException

    最终"恍然大悟"💡: 对共享资源的所有访问(读和写),都必须加锁!

    java 复制代码
    private 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 是理解其原理的基础。)

客户端的自我救赎:收发消息互不干扰

在解决了服务端的问题后,我发现客户端又"卡"住了。我的客户端代码是这样的:

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)就会乱成一锅粥。

  1. 识别临界资源 :对任何可能被多个线程同时修改的数据(比如 beans 数量、共享的 allOut 集合),都要保持高度警惕。
  2. 锁住该锁的synchronized 是你的救星。但不要滥用,用同步块精细地控制锁的范围,既能保证安全,又能提升性能。
  3. 警惕共享状态 :当多个线程需要协作时,必然会引入共享状态(共享变量、集合等)。记住,对这个共享状态的每一次读写,都可能需要同步。
  4. I/O 与线程是天生一对 :在网络编程中,为了不让一个阻塞的I/O操作(如 accept(), readLine())卡死整个程序,多线程几乎是必然的选择。

希望我今天的分享,能帮你揭开多线程并发的神秘面纱。它虽然复杂,但一旦你掌握了它的规律,就能编写出功能强大、性能卓越的应用程序。

好了,今天就聊到这。你有没有遇到过什么让你印象深刻的并发 Bug?欢迎在评论区分享你的"事故"现场!我们一起成长!👋

相关推荐
想用offer打牌3 分钟前
一站式了解责任链模式🥹
后端·设计模式·架构
小码编匠14 分钟前
面向工业应用的点云相机控制接口库(含C#调用示例)
后端·c#·.net
Luffe船长25 分钟前
springboot将文件插入到指定路径文件夹,判断文件是否存在以及根据名称删除
java·spring boot·后端·spring
程序员清风2 小时前
RocketMQ发送消息默认是什么策略,主同步成功了就算成功了?异步写?还是要大部分从都同步了?
java·后端·面试
罗政2 小时前
小区物业管理系统源码+SpringBoot + Vue (前后端分离)
vue.js·spring boot·后端
杨同学technotes2 小时前
Spring Kafka进阶:实现多态消息消费
后端·kafka
雨中散步撒哈拉2 小时前
3、做中学 | 二年级上期 Golang数据类型和常量/变量声明使用
开发语言·后端·golang
小黑随笔3 小时前
【Golang 实战 ELK 日志系统全流程教程(一):ELK 是什么?为什么要用 ELK?】
后端·elk·golang
Code季风3 小时前
深入实战 —— Protobuf 的序列化与反序列化详解(Go + Java 示例)
java·后端·学习·rpc·golang·go
深栈解码3 小时前
OpenIM 源码深度解析系列(十二):群聊读扩散机制场景解析
后端