从卖票 Demo 到并发思维跃迁:为什么 synchronized 保护的是「共享状态」,而 Lambda 保护的是「代码质量」
你有没有发现,Java 并发学习最魔幻的一点是:
我们以为自己在学“锁”,其实我们真正该学的是——共享状态怎么死的。
很多人写并发代码的第一反应是:加锁。
但加锁并不能让程序“正确”,它只是让错误更难出现,更难复现,更难定位。
这篇文章我不打算再写那种“synchronized vs ReentrantLock 对比表”了(你已经看过太多)。
我们换个角度:从 4 个小文件里,看见并发编程最重要的三件事:
-
线程是怎么被创建出来的(Runnable → Lambda)
-
共享资源是怎么被破坏的(Ticket.number)
-
synchronized 到底保护了什么(临界区 + happens-before)
你上传的 4 个文件分别是:
-
MyTicketDemo.java:空壳 Ticket(最初版本) -
MyTicketDemo(1).java:Runnable 匿名内部类 + synchronized 卖票 -
MyTicketDemo(2).java:Lambda 写法重构线程创建 -
LambdaExpressDemo.java:函数式接口 Foo + Lambda 表达式示例
它们看起来是“课程 demo”,但其实非常适合讲一个更真实的工程故事:
并发代码的质量,不取决于你用了什么锁,而取决于你如何表达意图。
1. 并发学习的第一误区:我们以为“线程安全 = 加锁”
在 MyTicketDemo(1).java / (2).java 里,核心资源是:
class Ticket {
private int number = 50;
public synchronized void sale() {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "\t" +
"卖出第:" + (number--) + "还剩下:" + number);
TimeUnit.MILLISECONDS.sleep(20);
}
}
}
这是经典卖票模型:3 个窗口(A/B/C)同时卖 50 张票。
很多人看到 synchronized 会下意识认为:
“加锁了,所以线程安全了。”
但这是一个非常危险的简化。
真相是:锁并不保护“代码”,锁保护的是“共享状态”
这里的共享状态是什么?是:
-
Ticket.number
而 sale() 方法里所有对 number 的读取、判断、递减,必须是一个整体原子操作:
-
读 number
-
判断 number > 0
-
number--
-
打印
你锁住的不是方法,你锁住的是这段逻辑背后的共享资源一致性。
2. 为什么卖票一定要加锁?因为 if(number > 0) 不是你以为的“一个动作”
把 synchronized 去掉,会发生什么?
你以为的执行顺序:
-
A 看到 number=1 → 卖出 → number=0
-
B 看到 number=0 → 不卖
实际可能是:
-
A 读到 number=1
-
线程切换
-
B 也读到 number=1
-
A number-- → 0
-
B number-- → -1
于是你就会看到经典 bug:
-
卖出第:0 还剩下:-1
-
卖出第:-1 还剩下:-2
这不是“代码写错”,而是:
在并发世界里,“一行代码”从来不是原子的。
if(number > 0) 是典型的 check-then-act 竞态模式。
并发 bug 里最难排查的,就属这一类。
3. synchronized 的价值不在“互斥”,而在“建立 happens-before”
很多人只把 synchronized 当互斥锁用,其实它还有另一个更关键的能力:
内存可见性保证(happens-before)
也就是说:
-
A 线程在 synchronized 里对
number的修改 -
对 B/C 线程是可见的
否则你会遇到另一类更诡异的问题:
-
A 卖完票了(number=0)
-
B 线程还“看见” number=1(旧值缓存)
这就是为什么 synchronized 被称为 Java 内置同步机制:
它不仅锁住临界区,还在进入/退出时插入内存屏障。
所以这段 demo 真正教你的不是“怎么加锁”,而是:
共享状态必须通过同步边界传播。
4. 你写的不是线程,你写的是“任务”:Runnable 到 Lambda 的跃迁
MyTicketDemo(1).java 用的是匿名内部类:
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 51; i++) {
ticket.sale();
}
}
}, "A").start();
MyTicketDemo(2).java 用 Lambda:
new Thread(() -> { for (int i = 0; i < 51; i++) ticket.sale(); }, "A").start();
很多人会觉得这只是语法糖,写法更短。
但我想说:这不是语法糖,这是思维模型升级。
匿名内部类的思维是:“创建一个对象”
你必须写:
-
new Runnable() { ... }
-
override run()
它迫使你把注意力放在“对象结构”上。
Lambda 的思维是:“描述一个行为”
() -> { ... } 代表:
-
这是一个任务(task)
-
这是一个行为(behavior)
-
这是一个可传递的逻辑单元
而并发编程的本质就是:
任务调度 + 状态同步
你用 Lambda 后,代码关注点会自然转移:
-
线程如何执行任务
-
任务如何访问共享状态
这就是为什么 LambdaExpressDemo.java 要单独存在:它在告诉你——
并发工程写得好不好,第一步是“表达清晰”,而不是“锁用得多”。
5. LambdaExpressDemo 的真正意义:函数式接口是“并发可组合性”的起点
LambdaExpressDemo.java 里定义了一个函数式接口:
@FunctionalInterface
interface Foo {
int add(int x, int y);
}
然后:
Foo foo = (x, y) -> {
System.out.println("----come in foo method");
return x + y;
};
System.out.println(foo.add(3, 15));
这看似是 Java8 入门例子,但它背后的意义是:
函数式接口让“行为”可以像数据一样被传递、组合、复用。
而并发编程里最常见的高级写法(线程池、CompletableFuture、Stream 并行流)都依赖这个能力。
换句话说:
-
你写
Runnable,只是为了让线程跑起来 -
你掌握 Lambda/函数式接口,你才能写出“可扩展的并发架构”
6. 卖票 Demo 的隐藏彩蛋:为什么 sleep(20ms) 反而是教学关键?
在 Ticket.sale() 里有一段:
TimeUnit.MILLISECONDS.sleep(20);
很多人以为这是“让输出好看”,其实它是故意放大的并发问题:
-
没有 sleep:线程切换概率低,bug 不容易暴露
-
加了 sleep:扩大临界区执行时间,提高竞争概率
这在工程里对应什么?
线上并发 bug 复现困难
因为你没有“放大窗口”的手段。
而 sleep 就是最粗暴有效的“竞态放大器”。
所以这个 demo 其实在教你一个排障思路:
-
想复现并发 bug:增加线程数、加延迟、加循环次数
-
想定位并发 bug:缩小共享状态、减少临界区、引入日志与断言
7. 独特观点:synchronized 并不“笨”,笨的是把共享状态设计成全局变量
很多文章会说:
-
synchronized 傻瓜式
-
Lock 灵活
-
AQS 高级
但从工程角度看,真正“低级”的不是 synchronized,而是:
你把共享状态设计得太大、太散、太难控制。
在这个 demo 里共享状态只有一个:
-
int number
所以 synchronized 很好用。
但如果共享状态变成:
-
多个字段(库存、订单、余额)
-
多个容器(Map、List)
-
多个对象(跨模块共享)
你会发现:
-
锁粒度难选
-
死锁风险暴增
-
性能问题凸显
因此我给一个非常实战的结论:
并发优化的第一步不是换 Lock,而是“缩小共享状态范围”。
把共享状态收敛到对象内部,synchronized 就会非常香。
8. 最后给一个工程级建议:卖票 Demo 的下一步该怎么写?
如果你想把这套 demo 升级成“面试/项目级别”,我建议按这个路线:
路线 A:演示锁的不足 → 引入 Lock
-
增加超时卖票
-
支持取消(中断)
-
用 Condition 做精准唤醒
路线 B:演示共享状态优化 → 引入原子类
-
用
AtomicInteger替代 int -
对比 CAS 与 synchronized
路线 C:演示架构升级 → 引入线程池
-
把 new Thread 改成 ExecutorService
-
让“任务”与“线程”解耦
这三条路线都能把“卖票”写成一篇更高级的博客。
总结:这 4 个文件串起来,其实讲的是并发的核心哲学
你给的 4 个文件,看似在讲:
-
synchronized
-
Runnable
-
Lambda
但把它们串起来后,真正的主题是:
并发不是语法问题,是建模问题。
你要先把共享状态建模清楚,再谈锁与线程。
最后用一句话收尾:
-
synchronized 解决的是:共享状态一致性
-
Lambda 解决的是:行为表达能力
-
二者合起来,才是 Java 并发真正的入门门槛
更多推荐



所有评论(0)