你有没有发现,Java 并发学习最魔幻的一点是:

我们以为自己在学“锁”,其实我们真正该学的是——共享状态怎么死的

很多人写并发代码的第一反应是:加锁。
但加锁并不能让程序“正确”,它只是让错误更难出现,更难复现,更难定位。

这篇文章我不打算再写那种“synchronized vs ReentrantLock 对比表”了(你已经看过太多)。
我们换个角度:从 4 个小文件里,看见并发编程最重要的三件事:

  1. 线程是怎么被创建出来的(Runnable → Lambda)

  2. 共享资源是怎么被破坏的(Ticket.number)

  3. 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 并发真正的入门门槛

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐