线程间通信与等待、通知机制
# 线程间通信与等待、通知机制
# 交替打印-练习
编写一个程序,使用两个线程交替打印数字1~100。线程A打印奇数,线程B打印偶数。要求输出顺序为1,2,3,4,...,99,100。
# 思考与提示
2个线程要按照一定顺序执行,就表明彼此之间有一个类似信号的东西,就像红绿灯,红灯停,绿灯行。程序员就是通过锁来实现各个线程的彼此之间的——协做执行
,而不是各自执行各自的。一定不要有感觉这个很难的念头,再难的问题也可以分解成多个简单的问题。
使用
wait()
和notify()
方法实现线程间协作。lock和Condition
# Synchronized的版本
public class Bemo {
int a = 1;
public static void main(String[] args) {
Bemo dema = new Bemo();
Runnable t1 = () -> {
synchronized (dema){
while (dema.a<101){
if(dema.a % 2 == 1){
System.out.println("t1---"+dema.a);
dema.a++;
dema.notify();
}else {
try {
dema.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
Runnable t2 = () -> {
synchronized (dema){
while (dema.a<101){
if(dema.a % 2 == 0){
System.out.println("t2:"+dema.a);
dema.a++;
dema.notify();
}else {
try {
dema.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
Thread tt1 = new Thread(t1);
Thread tt2 = new Thread(t2);
tt1.start();
tt2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 一些问题
以上是一个简单的参考,但这里还有一些问题:
- 【问题】tt1一定比tt2先执行吗?
答案是:tt1 不一定比 tt2 先执行。
原因在于:
tt1.start(); tt2.start();
只是告诉 JVM “让这两个线程准备运行”,并不保证谁先获得 CPU 执行权。- 线程调度是由操作系统和 JVM 的调度器决定的,无法人为保证顺序,即使你写在
start()
语句的先后顺序里,执行顺序仍然是不可预测的。 - 所以,有可能
tt2
先拿到锁,开始打印偶数;也可能tt1
先拿到锁,开始打印奇数;甚至可能main
线程在tt1
、tt2
启动后还插队执行一会儿。
- 【问题】:其中的变量a 是否需要加关键字 final进行修饰?
不需要给 a
加 final
!
原因在于 final
在 Java 中表示“引用或值不可重新赋值”,而你这个程序中 a
是一个不断递增的计数器,需要修改,所以不能 final
。
你可能是担心在多线程下“可见性”问题,是否需要 volatile
或 final
来保证线程安全?
在你这个程序中:
a
的所有读写都在synchronized(dema)
块中进行;synchronized
已经保证了可见性和互斥:- 进入同步块时,会从主内存中读取
a
; - 离开同步块时,会把修改后的
a
刷回主内存; - 所以不用额外加
volatile
或final
。
- 进入同步块时,会从主内存中读取
# 加final修饰
- 【问题】:为什么有时候编译器会提示,给变量加上final?【值得关注】
比如:局部变量在 Lambda 或匿名内部类中使用
int num = 10;
Runnable r = () -> {
num++; // ❌ 编译错误
};
2
3
4
5
为什么要使用final修饰呢。
核心原因是:匿名内部类或 Lambda 捕获的局部变量,本质上是把它复制一份到内部类对象里,而不是直接引用原来的栈变量。为了避免歧义,Java 要求这个变量是 final
或“effectively final”。
以下是它的执行过程:
int x = 10;
new Thread(new Runnable() {
public void run() {
System.out.println(x);
}
}).start();
2
3
4
5
6
7
x
是在 main
方法里的一个 局部变量,存储在 栈帧 中。
new Runnable() {...}
会生成一个匿名内部类对象,这个对象在 堆 中。
当你在匿名类中访问 x
时,Java 编译器其实做了 “值捕获”:
- 它会把
x
的值拷贝到内部类对象中,作为一个 隐藏字段。 - 执行
System.out.println(x);
时,其实访问的是对象里的副本。
假设允许你在匿名类外部修改 x
:
int x = 10;
Runnable r = new Runnable() {
public void run() {
System.out.println(x);
}
};
x = 20; // 修改了 x
r.run(); // 输出啥?
2
3
4
5
6
7
8
9
这里会产生歧义:
- 线程
run()
时访问的x
,是修改前的副本还是修改后的新值? - 因为局部变量
x
是栈上的,生命周期跟方法调用结束绑定,但Runnable
对象可能在方法退出后还存活并运行,这样就可能访问到一个已经销毁的栈变量。
为了避免这种混乱,Java 的设计选择了:
- 不允许捕获可变的局部变量。
- 要么明确
final
,要么“effectively final”(即从未修改)。
对多线程共享变量来说,
final
只保证引用不变,不保证线程安全。
final
更常见用途:
- 修饰变量:值或引用不变;
- 修饰方法:方法不能被子类重写;
- 修饰类:类不能被继承。
# 其他参考-答案
import java.util.concurrent.atomic.AtomicInteger;
public class CounterExample {
// 方法1:使用synchronized
private static int counter1 = 0;
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// 方法1:synchronized
Thread[] threads1 = new Thread[100];
for (int i = 0; i < 100; i++) {
threads1[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
synchronized (lock) {
counter1++;
}
}
});
threads1[i].start();
}
for (Thread t : threads1) t.join();
System.out.println("counter1: " + counter1); // 应输出100000
// 方法2:使用AtomicInteger
AtomicInteger counter2 = new AtomicInteger(0);
Thread[] threads2 = new Thread[100];
for (int i = 0; i < 100; i++) {
threads2[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter2.incrementAndGet(); // 原子操作
}
});
threads2[i].start();
}
for (Thread t : threads2) t.join();
System.out.println("counter2: " + counter2.get()); // 应输出100000
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 死锁例子-编写
什么叫死锁,死锁是指两个或多个线程在执行过程中,因争夺共享资源而相互等待,导致这些线程都无法继续执行下去的现象。
简单说,线程 A 等线程 B 释放资源,线程 B 又等线程 A 释放资源,结果谁也不让谁,程序卡住了。
请用代码模拟一个死锁例子:
# 一个例子-常量池
public class Cemo {
Integer a = 0;
Integer b = 0;
public static void main(String[] args) {
Cemo cemo = new Cemo();
Thread t1 = new Thread(() -> {
synchronized (cemo.a){
System.out.println("t1-----获取 aaaa");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cemo.b){
System.out.println("t1-----获取 bbb");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (cemo.b){
System.out.println("t2-----获取 bbb");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (cemo.a){
System.out.println("t2-----获取 aaa");
}
}
});
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 解析与细节
上述例子,考虑执行顺序:
- 如果t1先运行,它会获取a的锁,然后睡眠。
- 在t1睡眠期间,t2运行,获取b的锁,然后睡眠。
- t1醒来后尝试获取b的锁,但b的锁已经被t2持有,所以t1等待。
- t2醒来后尝试获取a的锁,但a的锁已经被t1持有,所以t2等待。
此时,t1持有a等待b,t2持有b等待a,形成循环等待,因此会发生死锁。
但是注意:这里使用的锁是Integer对象,而Integer是 immutable(不可变)的,并且有缓存机制(-128到127之间的整数会被缓存)。
由于a和b都是0,所以它们实际上是同一个缓存对象(即cemo.a和cemo.b是同一个对象,因为0在缓存范围内)。
所以,实际上只有一个锁对象
(Integer.valueOf(0)),因此不会死锁
。
如果a和b的值不同(比如一个0,一个1),那么就是两个不同的锁对象,就会发生死锁。
# 修改后的例子
Integer a = 0;
Integer b = 1;
// 需要的改动
2
3
另外,即使a和b都是0,如果我们使用new Integer(0)来创建,那么也会是两个不同的对象,也会死锁。
此外注意,自动装箱使用的是Integer.valueOf(),对于0会返回缓存对象。
注意:字符串,String也是有常量池的,如果
String a = "123"; String b = "123";
也是不会发生死锁的,因为是同一个对象。
# Lock版本
下面使用的锁是可重入锁,可重入锁(ReentrantLock)是 Java 并发包 (java.util.concurrent.locks
) 中提供的一种高级线程同步机制,它比传统的 synchronized
关键字更灵活、功能更强大。
# 可重入锁
可重入锁是一种允许同一个线程多次获取同一把锁的同步机制。当一个线程持有锁时,它可以再次获取该锁而不会被阻塞,这就是"可重入"的含义。
public static void main(String[] args) {
Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();
Runnable t1 = () -> {
lock1.lock();
System.out.println("t1 ------ lock111");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
lock2.lock();
System.out.println("t1 ------ lock222");
lock2.unlock();
lock1.unlock();
};
Runnable t2 = () -> {
lock2.lock();
System.out.println("t2 ------ lock222");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
lock1.lock();
System.out.println("t2 ------ lock111");
lock1.unlock();
lock2.unlock();
};
Thread t3 = new Thread(t1);
Thread t4 = new Thread(t2);
t3.start();
t4.start();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 注意事项
在获取锁之后,一定要注意释放,否则会导致死锁,推荐使用finally。
public class ReentrancyDemo {
private final ReentrantLock lock = new ReentrantLock();
public void outer() {
lock.lock();
try {
System.out.println("外层方法获取锁");
inner(); // 调用内层方法
} finally {
lock.unlock();
}
}
public void inner() {
lock.lock(); // 同一个线程可以再次获取锁
try {
System.out.println("内层方法获取锁,锁计数: " + lock.getHoldCount());
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ReentrancyDemo demo = new ReentrancyDemo();
demo.outer();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
当然,ReentrantLock 的高级特性也是synchronized所不具备的。
- 尝试获取锁
public class TryLockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void tryLockMethod() {
// 尝试获取锁,立即返回结果
if (lock.tryLock()) {
try {
System.out.println("获取锁成功,执行任务");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
} else {
System.out.println("获取锁失败,执行其他操作");
}
}
public void tryLockWithTimeout() {
try {
// 尝试在指定时间内获取锁
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try {
System.out.println("在2秒内获取锁成功");
Thread.sleep(1000);
} finally {
lock.unlock();
}
} else {
System.out.println("在2秒内获取锁失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
- 公平锁与非公平锁
public class FairLockDemo {
// 创建公平锁
private final ReentrantLock fairLock = new ReentrantLock(true);
// 创建非公平锁(默认)
private final ReentrantLock nonFairLock = new ReentrantLock(false);
public void fairLockMethod() {
fairLock.lock();
try {
System.out.println("公平锁: " + Thread.currentThread().getName());
} finally {
fairLock.unlock();
}
}
public void nonFairLockMethod() {
nonFairLock.lock();
try {
System.out.println("非公平锁: " + Thread.currentThread().getName());
} finally {
nonFairLock.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 适合使用 ReentrantLock 的场景:
- 需要尝试获取锁:使用
tryLock()
避免死锁- 需要可中断的锁获取:使用
lockInterruptibly()
- 需要公平性:要求线程按申请顺序获取锁
- 需要多个条件变量:使用多个 Condition 实现精细等待/通知
- 需要知道锁的状态:使用
isLocked()
,getHoldCount()
等方法
# 锁加final的原因
在 Java 并发编程中,将 Lock
和 Condition
声明为 final
是一种重要的最佳实践,这主要是出于线程安全、内存可见性和代码可靠性的考虑。
# 防止重新赋值
final
关键字确保引用不会被重新指向另一个对象,这避免了在多线程环境下可能出现的竞态条件:
// 不安全:锁引用可能被改变
private ReentrantLock lock = new ReentrantLock();
public void unsafeMethod() {
// 可能被另一个线程改变锁引用
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // 可能解锁的是不同的锁对象!
}
}
// 线程可能执行这样的代码
public void changeLock() {
lock = new ReentrantLock(); // 改变了锁引用
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
如果锁引用不是 final
的,一个线程可能在另一个线程获取锁后但释放锁前改变锁引用,导致线程尝试释放一个不同的锁对象。
# 避免空指针异常
final
字段必须在构造过程中初始化,这避免了空指针异常:
public class Container {
private final ReentrantLock lock; // 必须在构造函数中初始化
public Container() {
lock = new ReentrantLock(); // 必须初始化
}
// 如果没有final,可能会忘记初始化
}
2
3
4
5
6
7
8
9
# 内存可见性
非 final
字段不能保证在不同线程间的可见性:
public class VisibilityIssue {
private ReentrantLock lock; // 非final
public void init() {
lock = new ReentrantLock(); // 初始化
}
public void useLock() {
// 其他线程可能看不到初始化后的lock,看到的是null
lock.lock(); // 可能抛出NullPointerException
try {
// ...
} finally {
lock.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在 Java 内存模型中,final
字段的初始化保证对所有线程立即可见,无需额外的同步。