JUC并发编程 - 4. 共享模型之管程 - 4.1 共享带来的问题


🚀 JUC并发编程 - 4. 共享模型之管程

这一节我们聚焦 共享模型,理解多线程操作共享资源时出现的各种问题,以及 Java 提供的解决方案。


📚 本章内容

  • 共享问题

  • synchronized

  • 线程安全分析

  • Monitor

  • wait/notify

  • 线程状态转换

  • 活跃性

  • Lock


4.1 共享带来的问题

小故事

老王(操作系统)有一台超厉害的算盘(CPU),为了赚钱,他租给别人用。

👦 小南 和 👧 小女(两个线程)来租算盘,分别执行自己的计算任务,并按使用时间付费。

 

但小南工作时:

  • 有时候打个盹(sleep

  • 有时候去厕所(阻塞 I/O

  • 有时候抽烟,没烟就啥也干不了(wait

这些情况都属于 阻塞状态,算盘就闲置了,老王很不爽,觉得浪费。

而且,小女也急着用算盘,如果老是被小南霸着,就不公平。

于是老王灵机一动:
“轮流用!小南你用一会,小女你再用一会。”

结果:
当小南阻塞时,算盘可以分给小女使用,不浪费;反之亦然。

🧠 理论理解

当多个线程共享同一块数据时,尤其是读写操作交织,就会产生所谓的 “并发冲突”

关键问题:

  • 单线程中,CPU 执行是顺序的,数据一致性有保障。

  • 多线程中,CPU 会进行上下文切换,导致线程操作共享资源的步骤交错

这就引出了两个核心概念:

  • 临界区(Critical Section):
    包含对共享资源的读写的那段代码区域。

  • 竞态条件(Race Condition):
    多线程在临界区交替执行时,由于顺序不确定,导致程序结果不可预测。

这正是并发编程中最“诡异”的问题之一:看似简单的代码,却因为线程交错执行,出现各种意料之外的结果。


🏢 企业实战理解

阿里巴巴:
在电商高并发秒杀系统中,如果库存扣减的逻辑没有加锁保护,会导致“超卖”现象,用户明明看着有库存,却最终下单失败,这就是典型的共享资源冲突。

美团:
在骑手实时调度系统中,大量线程同时修改“订单状态”,需要保证状态修改是原子的,否则会出现“同一个订单同时被两个骑手接单”的风险。

NVIDIA:
在 GPU 多任务并行计算中,线程块(warp)共享显存,如果对显存的读写不加同步,会导致数据污染,影响整个矩阵计算的精度。

 

💬 面试题(字节跳动)
问:多线程操作共享资源时为什么会出现数据不一致问题?根本原因是什么?

参考答案

根本原因是非原子性操作
虽然一个语句看起来是单个操作(如 counter++),但底层其实拆成多步(读取 → 修改 → 写回),而线程切换可能发生在任意一步,这就导致了共享数据在多线程场景下的数据不一致。

Java 内存模型(JMM)下,线程的工作内存和主存交换数据也加剧了这种问题。

💬 场景题(字节跳动)
你开发一个接口统计请求次数,代码用 counter++ 记录访问量,压测时发现数据不稳定,实际请求 10000 次,结果有时是 10000,有时是 9000 多,有时 10000 多。请分析原因和优化思路。

参考答案

原因:

  • counter++ 不是原子操作,多个线程在高并发下交替执行,发生了数据竞争,导致丢失或覆盖。

优化思路:

1️⃣ 使用 AtomicInteger 替代 int,通过 CAS 保证原子性
2️⃣ 或者用 synchronized 包裹自增逻辑
3️⃣ 更好的做法:用 Redis 的原子自增命令实现分布式统计

 


 

 

笔记本事故

最近的任务比较复杂,计算中要记录中间结果。由于小南和小女脑容量不够(线程的工作内存有限),老王准备了一个笔记本(主存)帮忙记录。

流程是这样的:

1️⃣ 小南读到笔记本的初始值 0,做了 +1 运算,还没写回。

2️⃣ 老王喊停:“小南,你时间到了!” 小南只好把 1 记在脑子里,离开了。

3️⃣ 老王叫小女上场,小女看笔记本还是 0,做了 -1 运算,写回 -1

4️⃣ 老王再喊小南回来:“继续干!” 小南把脑中的 1 写了回去。

🔎 结果:
小南和小女都没错,但最后笔记本的值是 1,不是预期的 0


💻 Java 实例

我们用 Java 代码模拟一下 👇:

static int counter = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            counter++;
        }
    }, "t1");

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            counter--;
        }
    }, "t2");

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.debug("最终结果: {}", counter);
}

看起来很简单:
两个线程,一个做 +1,一个做 -1,各5000次,按理结果是 0 对吧?

❗ 实际上,多次运行,你会发现结果可能是 0、正数、负数……


🔬 问题分析

 

原因:自增/自减不是原子操作。

例如 counter++ 实际上拆成了如下 JVM 字节码指令:

getstatic i     // 读取 counter
iconst_1        // 准备 1
iadd            // 执行加法
putstatic i     // 写回 counter

counter-- 同理:

getstatic i     // 读取 counter
iconst_1        // 准备 1
isub            // 执行减法
putstatic i     // 写回 counter

🧩 内存模型

在 Java 内存模型中,静态变量的自增/自减涉及 主存线程工作内存 之间的数据交换。

✅ 单线程没问题:

1️⃣ 读取 i(主存)
2️⃣ 加/减
3️⃣ 写回 i

 

❌ 多线程会交错:

示例1:出现负数

 

线程1线程2
读取 0
加 1
读取 0
减 1
写入 1
写入 -1

示例2:出现正数

 

线程1线程2
读取 0
读取 0
加 1
减 1
写入 1
写入 -1

结果是不可预测的!

🧠 理论理解

counter++ 看似简单,其实底层是三步:

1️⃣ 读取 counter(从主存 → 线程的工作内存)

2️⃣ +1 操作

3️⃣ 写回 counter(从工作内存 → 主存)

这三步是分开的!如果两个线程“同时”执行到 读取 阶段,都会拿到相同的值,接下来无论怎么加/减,都会导致最终结果覆盖、丢失。

根源:非原子性。


🏢 企业实战理解

字节跳动:
在短视频点赞/评论等高并发场景中,后端会用 Redis 的 原子自增 替代传统数据库的自增,避免出现“丢点赞”现象。

华为云:
后台服务统计 PV/UV 时会使用 CAS(Compare And Swap)保证并发写入的安全性,而不是简单的 count++

 

💬 面试题(腾讯)
问:为什么 counter++ 不是线程安全的?它背后具体拆解成了什么步骤?

参考答案

counter++ 在 Java 字节码层面会被拆分为:

1️⃣ getstatic(读取 counter)
2️⃣ iconst_1(准备 1)
3️⃣ iadd(执行 +1)
4️⃣ putstatic(写回 counter)

这四步不是原子操作。多个线程可能在 1️⃣ 读取完成后被切换走,导致“旧值”被多次读取并修改,进而出现数据覆盖。

💬 场景题(腾讯)
你写了两个线程,一个做 counter++,一个做 counter--,结果预期是 0,但运行几次之后发现结果可能是 0,也可能是正数/负数。请用底层原理解释这个现象。

参考答案

counter++counter-- 都是非原子性操作,每次都拆解成 读取 → 修改 → 写回 三步。

当两个线程同时读取时,都会拿到旧值 0,即使一个加 1、一个减 1,最后各自写回时就会出现:

  • 线程1:+1 写入 1

  • 线程2:-1 写入 -1

它们覆盖了彼此的结果,导致数据不一致

 


3️⃣ 临界区 & 竞态条件


🔒 临界区(Critical Section)

👉 问题根源: 多线程“同时”操作 共享资源 时出现的 竞态条件(Race Condition)

  • ✔ 读共享资源(无风险)

  • ❗ 读+写共享资源(高风险)

比如上面的 counter++ / counter--,这块代码就是 临界区
多个线程访问共享资源,并且至少有一个线程在修改它。

💬 面试题(阿里)
问:什么是临界区?什么是竞态条件?这两者有什么联系?

参考答案

  • 临界区(Critical Section):
    指多线程环境下,访问共享资源的代码块。

  • 竞态条件(Race Condition):
    指多个线程在临界区内交替执行,由于执行顺序不同,导致程序行为或结果不确定。

关系:临界区是“战场”,竞态条件是“结果”。
只要存在共享资源 + 多线程修改,就有临界区,也就有发生竞态的风险。

 

💬 场景题(阿里)
一次并发测试中,你发现线程池中多个线程读写同一个 List,有时会抛出 ConcurrentModificationException,有时数据丢失。请分析原因,指出代码的“临界区”在哪,并提出解决方案。

参考答案

原因:

  • 多个线程同时对 ArrayList 进行写操作,ArrayList 本身不是线程安全的。

临界区:

  • list.add()list.remove() 等涉及共享资源的代码块就是典型临界区

解决方案:

1️⃣ 使用 Collections.synchronizedList() 包装
2️⃣ 或者改为 CopyOnWriteArrayList
3️⃣ 高并发下考虑 ConcurrentLinkedQueue 等无锁队列


⚠ 竞态条件(Race Condition)

当多个线程在临界区交替执行时,由于执行顺序不同,导致最终结果不可预测,就发生了竞态条件。

解决方案(后续章节讲解):

  • synchronized

  • Lock

  • 原子变量

🧠 理论理解

“临界区” 是潜在问题发生的“战场”;
“竞态条件” 是因为多个线程抢占战场,导致结果混乱。

核心点:

  • 临界区:只要共享资源+读写就一定有临界区。

  • 竞态条件:是指由于执行顺序不同,结果不确定。

解决方法的本质就是 锁住临界区,让多个线程 有序排队 执行。


🏢 企业实战理解

腾讯:
在 QQ 消息推送系统中,消息队列是典型的临界区,腾讯采用 多级锁分段机制,把大锁拆成小锁,提高并发性能。

Google:
在 Chrome 浏览器的多线程渲染中,DOM 树操作是一个临界区,Google 团队用细粒度锁、异步任务分发等技术避免阻塞渲染主线程。

 


4️⃣ CPU 上下文切换  

🧠 理论理解

上下文切换是 CPU 在多个线程之间切换时的一个“保存-恢复”过程:

  • 保存当前线程的执行状态(寄存器、栈等)

  • 加载新线程的状态

这个过程本身是有开销的,而“竞态条件”很多时候正是因为上下文切换时机不同导致。

🏢 企业实战理解

美团:
高并发请求中,过多的上下文切换导致线程池“雪崩”现象,美团通过限流、熔断等手段减少切换。

OpenAI:
在 GPT 推理引擎中,推理过程会尽量绑定 CPU 核心,避免多次切换带来的性能抖动。

 

💬 面试题(美团)
问:什么是上下文切换?它对并发程序的影响是什么?

参考答案

上下文切换是指 CPU 保存当前线程状态并切换到另一个线程执行的过程。涉及:

  • 保存/恢复 CPU 寄存器

  • 切换堆栈、程序计数器

  • 更新操作系统调度信息

影响:

  • 开销大: 每次切换消耗 CPU 资源

  • 易出问题: 临界区操作时若发生切换,会导致竞态条件出现

优化建议:

  • 降低线程数量(避免过度并发)

  • 使用线程池复用线程,减少频繁创建和销毁

💬 场景题(美团)
在一次性能优化中,你发现线程池开了 1000 个线程,但服务器只有 8 核,结果 CPU 使用率很高但吞吐量反而下降。请解释原因。

参考答案

原因:

  • 过多线程同时争抢 CPU,导致上下文切换频繁,大量 CPU 时间浪费在保存/恢复线程状态上,实际用于任务执行的时间反而变少。

优化:

  • 降低线程池大小,控制在 CPU 核心数 * 2 左右

  • 使用异步/事件驱动架构减少阻塞线程

 

5️⃣ Java 内存模型

💬 面试题(华为)
问:Java 内存模型(JMM)中,为什么线程操作共享变量时会出现“可见性问题”?

参考答案

JMM 定义了主内存和线程工作内存的交互:

  • 每个线程有自己的工作内存(类似缓存)

  • 操作共享变量时,线程会从主内存拷贝到工作内存再执行

问题:
当一个线程修改了共享变量的值但未及时刷新到主存时,其他线程依然看到的是旧值,造成可见性问题

解决方案:

  • 使用 volatile

  • 使用锁(synchronized、ReentrantLock)

 

💬 场景题(华为云)
你在实现一个标志位 running = true 控制线程运行,期望某个线程能检测到 running = false 及时停止。但实际中发现,设置 running = false 后,线程依然无限循环。请分析原因和解决方法。

参考答案

原因:

  • running 是普通变量,可见性问题导致其他线程没有立刻看到更新后的值(仍然在本地缓存中读取旧值)。

解决方法:

1️⃣ 将 running 声明为 volatile
2️⃣ 或者用 synchronized 封装读取和修改


✅ 总结

这一节我们通过一个有趣的小故事,讲清楚了:

  • 线程共享模型的本质

  • 为什么会出现线程安全问题

  • 临界区 & 竞态条件的概念

  • 以及 Java 中经典的竞态演示

在并发编程中,共享资源 + 多线程修改 是 bug 的温床!下一节我们将进入 synchronized 解决方案 👊。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏驰和徐策

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值