1. 什么是共享资源
你已经见过共享资源了。一个家庭居住的房子是这个家庭的共享资源。办公室的冰箱是所有同事的共享资源。这个概念不难理解。
在编程中,所谓共享资源,是指可被多个线程同时访问的变量、对象或数据结构。它可以是:
- 用于统计已处理订单数量的计数变量。
- 一个由某些线程填充、由其他线程处理的请求列表。
- 一个由多个线程写入的已打开文件。
- 一个被程序不同部分使用的数据库连接。
在 Java 中,只要某个对象或变量可能被多个线程访问,它就潜在地变成了“共享资源”。
共享资源示例:全局计数器
public class Counter {
public int value = 0;
}
如果有多个线程要递增这个计数器,它们都会访问同一个变量 value——这就是共享资源。
2. 并发访问的问题
在单线程程序里一切都很简单:一个线程——一个执行者——它会沿着代码像火车沿着铁轨那样直行。但一旦引入多个线程,就会开始真正的“刀光剑影”:线程可能在最意想不到的地方相互干扰。
Race condition(竞态条件)
Race condition 是一种情况:程序的结果取决于线程动作如何“交错”。也就是说,即便多次运行同一个程序,结果也可能不同——这不是 bug,而是多线程的“特性”。
经典示例:两个线程递增计数器
我们来模拟一个简单场景:有一个共享计数器,两个线程各自将其递增一千次。
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // 危险点!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("期望值: 2000");
System.out.println("实际值: " + counter.value);
}
}
屏幕上会看到什么?
有时是 2000,有时是 1985,有时是 1937……为什么?因为操作 counter.value++ 不是原子操作!它由三个步骤组成:
- 读取当前的 counter.value。
- 将它加 1。
- 写回。
如果两个线程同时读取到相同的值,并各自递增然后回写——最终会丢掉一次递增。这就是丢失更新(lost update)。
对象处于不一致状态
如果你有一个由多个字段组成的复杂对象,而线程同时修改不同的字段,对象就可能落入“奇怪”的或不一致的状态。比如,账户余额减少了,但操作历史没有更新——客户慌了,会计也惊了。
3. 为什么需要同步
同步是一种告诉程序的方式:“停!这段代码同一时间只能由一个线程执行!其他线程——请等待!” 这就像厕所门上的告示“打扫中!请勿入内!”:只要里面有人,外面的人就得等(并在心里吐槽那个待太久的人)。
保证数据完整性
如果希望计数器总是被正确递增,就必须禁止多个线程同时修改它的值。
示例:对计数器递增进行同步
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
现在,如果两个线程调用 increment(),同一时刻只有一个线程能够执行该方法。另一个线程会等待直到第一个结束。
示意图:同步时发生了什么
+-------------------+
| Thread 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Thread 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Thread 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Thread N |/
+-------------------+
所有线程都会在受保护的代码段前排队。同一时间只有一个线程能进入“临界区”(synchronized 代码块)。
4. 同步方式概览
在 Java 中,同步并不是唯一的做法,而是一整套“武器库”,用于防止对共享资源的并发访问。
关键字 synchronized
它是 Java 中最基本的同步工具,可以用两种方式:
同步方法
public synchronized void increment() {
value++;
}
同步代码块
public void increment() {
synchronized (this) {
value++;
}
}
这里的 this 是加锁的对象。当一个线程在执行该代码块时,其他想要进入基于同一对象的相同代码块的线程将会等待。
来自 java.util.concurrent 的专用类
- Lock、ReentrantLock——比 synchronized 更灵活的替代方案。
- ReadWriteLock——将锁分为读锁和写锁。
- Semaphore——限制同时执行代码的线程数量。
- CountDownLatch、CyclicBarrier 等——用于协调线程的工作.
重要:今天我们只做基础入门——这些类我们稍后再详细讨论。
5. 实用示例:带多线程计数器的应用
假设我们在为某个服务编写用户访问统计。每个线程代表一位用户,它会递增一个共享计数器。
不使用同步
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("期望值: 30000");
System.out.println("实际值: " + counter.value);
}
}
结果:几乎总是小于 30000。有时——小得多!为什么?因为线程会相互“打架”。
使用同步:修正错误
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("期望值: 30000");
System.out.println("实际值: " + counter.value);
}
}
结果:总是 30000。太好了,同步生效了!
6. 有用的细节
可视化:race condition 是如何发生的
我们用一张小表来展示两个线程如何“丢失”一次递增:
| 步骤 | 线程 1 | 线程 2 | value 的值 |
|---|---|---|---|
| 1 | 读取 value=0 | 0 | |
| 2 | 读取 value=0 | 0 | |
| 3 | 增加到 1 | 0 | |
| 4 | 增加到 1 | 0 | |
| 5 | 写入 1 | 1 | |
| 6 | 写入 1 | 1 |
何时需要同步
并非总是需要同步。当变量活在它自己的小世界里,且只被一个线程使用时——可以放松。但一旦与其他线程共享,就必须使用同步。即便看起来“一切应该没问题”——也不要相信。竞态错误很狡猾:它可能长期潜伏,然后在最不合适的时刻突然爆发。
展望:还有哪些同步手段
今天我们仅仅认识了基础工具——synchronized。在后续课程中我们会讨论:
- 对象监视器如何工作以及锁的类型。
- 什么是静态同步方法(static + synchronized)。
- volatile 的作用与用法。
- 现代的同步类有哪些(Lock、Semaphore 等)。
7. 使用共享资源时的常见错误
错误 1:忽视多线程。
最常见的错误之一是没有考虑变量可能会被多个线程访问。即使现在程序是单线程的,日后有人加入了线程——bug 就会“凭空出现”。
错误 2:同步不足或过度同步。
如果不对共享资源进行同步——你会得到竞态条件和不一致的数据。如果把所有地方都加上同步,程序会被锁拖垮变得缓慢。始终只同步真正需要的那部分。
错误 3:在错误的对象上同步。
如果在不同的对象上进行同步(例如在局部变量或字符串字面量上),这并不能保护共享资源。所有线程必须在同一个对象上同步。
错误 4:指望非原子操作具有原子性。
操作 i++ 不是原子!即使变量声明为 volatile,也不会让递增变成原子。此类操作需要同步。
错误 5:“我很幸运,一切都运行正常”。
Race condition 可能在你的电脑上不出现,但一定会在服务器或用户那边出现。编写多线程程序时,绝不要靠“运气”。
GO TO FULL VERSION