线程顺序执行与等待和通知
# 线程顺序执行与等待和通知
# 等待wait()和通知notify()
为了支持多线程之间的协作,JDK提供了两个非常重要的接口线程等待wait()方法
和通知notify()
方法。这两个方法并不是在Thread类中的,而是输出Object
类。这也意味着任何对象都可以调用这两个方法。
当在一个对象实例上调用wait()
方法后,当前线程就会在这个对象上等待。这是什么意思呢?
比如,线程A中,调用了obj.wait()方法,那么线程A就会停止继续执行,而转为等待状态。等待到何时结束呢?线程A会一直等到其他线程调用了obj.notify()方法把它唤醒为止。这时,obj对象就俨然成为多个线程之间的有效通信手段。
如果一个线程调用了object.wait()
,那么它就会进入object对象的等待队列。这个等待队列中,可能会有多个线程,因为系统运行多个线程同时等待某一个对象。当object.notify()
被调用时,它就会从这个等待队列中,随机选择一个线程,并将其唤醒
这里希望大家注意的是,这个选择是不公平的,并不是先等待的线程会优先被选择,这个选择完全是随机的
。
如果某个线程进入了wait(),而没被唤醒,那它将会一直等待。
在进行程序编写的时候,一定注意最后一个线程是否能从wait中醒来,否则你的程序将一直处于运行中,无法停止。
除了notify()
方法外,Object对象还有一个类似的notifyAll()
方法,它和notify()的功能基本一致,但不同的是,它会唤醒在这个等待队列中所有等待的线程,而不是随机选择一个。”
if(number>0){
this.wait();
}
System.out.println("this number");
this.notify();
2
3
4
5
一旦执行了 wait()
方法,当前线程会释放对象的锁并进入等待状态。在等待状态下,线程会暂停执行,不会继续执行 wait()
方法后面的代码,直到被其他线程唤醒。
以上是一个简单的例子。此时不会打印this number。
# 注意
Object.wait()方法并不是可以随便调用的。它必须包含在对应的synchronzied语句中,无论是wait()或者notify()都需要首先获得目标对象的一个监视器。而在他们执行后,也会释放这个监视器。
而Thread类的sleep方法可以在任何地方(sleep可以在任何地方睡觉)
wait不需要捕获异常,sleep需要捕获异常(可能发生超时等待)
- Wait() -- 会释放锁
- Sleep() -- 不会释放锁
Object.wait()和Thread.sleep()方法都可以让线程等待若干时间。除了wait()可以被唤醒外,另外一个主要区别就是
wait()
方法会释放目标对象的锁,而Thread.sleep()
方法不会释放任何资源。怎么记忆呢?可以类比现实生活中,我们睡觉是不会让出床位的,只是无法做事而已
而wait对应——让出,排队的时候,你一旦让了,别人就可以先你一步去缴费了,因此它会让出锁
# 等待线程结束join()和谦让yield()
等待线程结束join()
和 谦让yield()
是两个用于线程交互和调度的方法,分别服务于不同的目的。
# join-等待当前线程
很多时候,一个线程的输入可能非常依赖于另外一个或者多个线程的输出,此时,这个线程就需要等待依赖线程执行完毕,才能继续执行。
多线程的本质其实就是多个线程,像队列排队一样在竞争cpu(多个cpu则有多个队列),而join,从名字看,似乎可以理解成插队(假如某个队列中)。
JDK提供了join()
操作来实现这个功能,如下所示,显示了2个join()方法:
public final void join() throws InterruptedException
public final synchronized void join(long millis) throws InterruptedException
2
第一个join()方法表示无限等待,它会一直阻塞当前线程,直到目标线程执行完毕。
第二个方法给出了一个最大等待时间,如果超过给定时间目标线程还在执行,当前线程也会因为“等不及了”,而继续往下执行。
join()
:无限期等待,直到目标线程终结。join(long millis)
:最多等待millis
毫秒。join(long millis, int nanos)
:等待更精确的时间。
比如:当在某个线程 A 上调用线程 B 的 join()
方法时,线程 A 将会阻塞(暂停执行),直到线程 B 完成其任务并终止。
Thread threadB = new Thread(() -> {
// 执行线程B的任务
});
threadB.start();
try {
threadB.join(); // 线程A在此阻塞,直到线程B执行完毕
} catch (InterruptedException e) {
// 处理中断
}
// 这里只有在 threadB 完成后才会执行
System.out.println("Thread B has finished.");
2
3
4
5
6
7
8
9
10
11
12
13
14
# 总结与本质
核心作用
让当前执行线程等待(进入阻塞状态),直到被调用 join()
的那个线程(目标线程)执行完毕为止。
你可以把它理解为一种**线程间的“同步”或“顺序控制”**机制。例如,主线程启动了多个子线程进行数据加载,它必须等待所有子线程都加载完毕后,才能继续执行后续的汇总工作。
join()方法的本质是 wait()
:理解其基于 wait()/notify()
的实现,有助于明白它会释放目标线程对象的锁。
工作原理
join()
方法内部是基于 wait()
机制实现的。当线程A调用线程B的 join()
方法时,线程A会获取线程B对象的锁,然后调用 wait()
方法进入等待状态。当线程B执行结束时,JVM会自动调用 B.notifyAll()
,从而唤醒正在等待线程B对象的所有线程(即所有调用了 B.join()
的线程)。
# yield-谦让
yield()
方法是一种线程礼让操作,它提示当前运行线程主动放弃 CPU 时间片,使调度器有机会将 CPU 执行权分配给其他同优先级的就绪线程。调用 yield()
不会阻止线程的执行,只是将其状态由运行态(Running)切换回可运行态(Runnable)。
但要注意,让出CPU并不表示当前线程不执行了。当前线程在让出CPU后,还会进行CPU资源的争夺,当前线程还可能会再次分配到然后继续执行,当然也可能不会,从而让其他线程先执行。
# 练习题
编写一个Java程序,使用两种不同的方式(Thread
类和Runnable
接口)创建并启动线程,分别输出“Hello from Thread 1”和“Hello from Thread 2”。要求线程顺序执行(先执行Thread 1,再执行Thread 2)。
// 参考答案
Thread thread = new Thread(() -> {
System.out.println("hello world1");
});
Runnable runnable = () -> {
System.out.println("hello world-2");
};
Thread thread1 = new Thread(runnable);
thread.start();
thread.join();
thread1.start();
2
3
4
5
6
7
8
9
10
11
12
13
14
# 解析
代码能够保证顺序执行的关键在于 thread.join()
这一行:
- 主线程启动
thread
- 主线程调用
thread.join()
,这意味着:- 主线程会阻塞(暂停执行)
- 主线程将一直等待,直到
thread
线程完全执行结束
- 只有在
thread
执行完毕(打印完 "hello world1" 并终止)后,主线程才会从join()
方法中返回,继续执行下一行代码 - 主线程随后启动
thread1
,从而保证了thread1
的打印操作总是在thread
之后
如果没有join那行,则可能会出现
hello world1
hello world-2
# 或者
hello world-2
hello world1
2
3
4
5
# 更多
join方法可以写在某个run方法中吗?即某个线程执行时,使用
当然可以!这是一个非常实际且强大的用法。join()
方法完全可以写在某个线程的 run()
方法内部。
当一个线程(我们称之为 “当前线程”)在其执行过程中调用 otherThread.join()
时,它的行为是:“当前线程”会暂停自身执行,进入阻塞状态,等待 otherThread
线程完全执行完毕后,再继续执行自己后面的代码。
下面是一个典型的场景:一个“汇总线程”必须等待两个“数据加载线程”都完成后才能开始工作。
public class JoinInsideThread {
public static void main(String[] args) {
// 创建并启动数据加载线程A
Thread dataLoaderA = new Thread(() -> {
try {
Thread.sleep(1000); // 模拟加载数据耗时
System.out.println("数据A加载完毕!");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
// 创建并启动数据加载线程B
Thread dataLoaderB = new Thread(() -> {
try {
Thread.sleep(1500); // 模拟加载数据耗时,比A稍长
System.out.println("数据B加载完毕!");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
dataLoaderA.start();
dataLoaderB.start();
// 创建并启动汇总线程,它将等待A和B
Thread summaryThread = new Thread(() -> {
System.out.println("汇总线程开始,正在等待数据加载...");
try {
// !!!关键点:在summaryThread的run方法内调用join!!!
dataLoaderA.join(); // 汇总线程在此阻塞,等待dataLoaderA结束
System.out.println("汇总线程检测到DataLoaderA已完成。");
dataLoaderB.join(); // 汇总线程继续阻塞,等待dataLoaderB结束
System.out.println("汇总线程检测到DataLoaderB已完成。");
// 只有A和B都完成后,才会执行下面的代码
System.out.println("所有数据就绪,开始执行汇总计算...");
// ... 这里是汇总计算的逻辑 ...
} catch (InterruptedException e) {
e.printStackTrace();
}
});
summaryThread.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
# 锁的使用
# 练习题-多个线程操作共享变量
从一个练习题说起,编写一个多线程程序,模拟3个线程对共享变量counter
进行10000次自增操作(counter++
)。要求最终counter
的值为30000。
很明显,这里涉及到并发,线程安全等问题。
public class Dema {
int a = 0;
public static void main(String[] args) throws InterruptedException {
Dema dema = new Dema();
ReentrantLock lock = new ReentrantLock();
Runnable t1 = () -> {
synchronized (dema){
for (int i = 0; i < 10000; i++) {
dema.a++;
}
}
};
Runnable t2 = () -> {
synchronized (dema){
for (int i = 0; i < 10000; i++) {
dema.a++;
}
}
};
Runnable t3 = () -> {
synchronized (dema){
for (int i = 0; i < 10000; i++) {
dema.a++;
}
}
//lock.lock();
//for (int i = 0; i < 10000; i++) {
// dema.a++;
//}
//lock.unlock();
};
Thread tt1 = new Thread(t1);
Thread tt2 = new Thread(t2);
Thread tt3 = new Thread(t3);
tt1.start();
tt2.start();
tt3.start();
tt1.join();
tt2.join();
tt3.join();
System.out.println("最终 synchronized 结果为-- a:");
System.out.println(dema.a);
}
}
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
54
55
56
57
58
# 解析与追问
# join的作用
如果去掉代码中的
tt1.join();
tt2.join();
tt3.join();
2
3
可能会发生 每次结果都不同,且结果始终远小于30000的情况,为什么呢?
即使去掉,join后,原代码也有锁呀,应该是线程安全的,为什么仍每次结果都不同。
原因:
结果不是30000的原因在于主线程没有等待其他线程执行完成就打印了结果。这是一个非常常见的多线程编程误区。
让我们仔细分析你的代码执行流程:
- 创建了三个线程并启动它们
- 立即执行
System.out.println("最终 synchronized 结果为-- a:");
和System.out.println(dema.a);
- 此时,三个线程很可能还在执行中,或者甚至还没开始执行
换句话说,你是在所有线程完成工作之前就打印了结果,所以看到的值是未完成累加的值(很可能是0)。
除了使用,join,还可以使用
- CountDownLatch(更优雅)
欢迎大家自行查阅,以下是一个简单示例
- countdownlatch
import java.util.concurrent.CountDownLatch;
public class Dema {
volatile int a = 0;
public static void main(String[] args) throws InterruptedException {
Dema dema = new Dema();
CountDownLatch latch = new CountDownLatch(3); // 创建计数器,初始值为3
Runnable task = () -> {
synchronized (dema){
for (int i = 0; i < 10000; i++) {
dema.a++;
}
}
latch.countDown(); // 完成一个任务,计数器减1
};
new Thread(task).start();
new Thread(task).start();
new Thread(task).start();
latch.await(); // 等待计数器变为0(即所有任务完成)
System.out.println("最终 synchronized 结果为-- a:" + dema.a); // 应该是30000
}
}
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
# 变量是否要加volatile
上述代码中是否需要对变量,加上关键字-volatile,像这样 volatile int a = 0;
,
volatile的作用:起到禁止指令重排(有序性)和可见性。
答案是:不用,原因如下
- synchronized 已经提供了足够的同步,保证了可见性和原子性
- volatile 在这里是多余的,因为 synchronized 块内的操作已经保证了:
- 原子性:
a++
操作不会被中断 - 可见性:一个线程对
a
的修改对其他线程立即可见
- 原子性:
# 哪个线程先执行
就上述例子,哪个线程会先执行
Thread tt1 = new Thread(t1);
Thread tt2 = new Thread(t2);
Thread tt3 = new Thread(t3);
tt1.start();
tt2.start();
tt3.start();
2
3
4
5
6
7
8
9
tt1一定是先执行的吗?
答案:不,tt1
不一定先执行。 即使你先调用了 tt1.start()
,也无法保证 tt1
的 run()
方法中的代码会先于 tt2
或 tt3
的代码执行。
# 为什么无法保证执行顺序?
当你调用 thread.start()
时,你并不是直接命令线程立刻运行。你只是向系统申请了一个新的执行流,并告诉系统这个线程已经就绪,可以被调度执行了。实际的执行顺序由操作系统的线程调度器(Thread Scheduler) 决定。
线程调度器决定执行顺序时,会考虑多种因素,以下几个关键因素决定了这种不确定性:
- 操作系统调度策略:不同的操作系统(Windows, Linux, macOS)有不同的线程调度算法。
- CPU 核心数:如果机器有多个 CPU 核心,多个线程可能同时在不同的核心上并行执行。在这种情况下,谈论“先”“后”几乎没有意义。
- 系统负载:操作系统中正在运行的其他进程和线程会竞争 CPU 资源,这会影响调度器的决策。
- 线程优先级:虽然你可以通过
setPriority()
方法给线程设置优先级(Thread.MAX_PRIORITY
等),但这只是一个提示(hint),而不是命令。调度器完全可以忽略它。依赖优先级来控制执行顺序是非常不可靠的。 - 时间片轮转:即使一个线程先开始运行,它也可能在执行完之前就被调度器中断(挂起),以便给其他线程运行的机会。
# 原子类的使用
上述的例子中,还有一种方式可以实现,而且不用加锁也可以实现
import java.util.concurrent.atomic.AtomicInteger;
public class DemaAtomic {
// 使用 AtomicInteger 代替 volatile int
AtomicInteger a = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
DemaAtomic dema = new DemaAtomic();
Runnable t1 = () -> {
for (int i = 0; i < 10000; i++) {
dema.a.incrementAndGet(); // 原子性的增加操作
}
};
Runnable t2 = () -> {
for (int i = 0; i < 10000; i++) {
dema.a.incrementAndGet();
}
};
Runnable t3 = () -> {
for (int i = 0; i < 10000; i++) {
dema.a.incrementAndGet();
}
};
Thread tt1 = new Thread(t1);
Thread tt2 = new Thread(t2);
Thread tt3 = new Thread(t3);
tt1.start();
tt2.start();
tt3.start();
tt1.join();
tt2.join();
tt3.join();
System.out.println("最终 AtomicInteger 结果为 a:" + dema.a.get()); // 一定是30000
}
}
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
即使用原子类,AtomicInteger
使用 AtomicInteger
即使不加锁也可以实现最终结果为30000。这是因为 AtomicInteger
的 incrementAndGet()
方法是一个原子操作,它保证了在多线程环境下的线程安全性。
为什么它能保证原子性呢?AtomicInteger
的实现基于 CAS (Compare-And-Swap) 操作,这是一种无锁的同步机制。
而,CAS 操作是硬件级别的原子操作,通常由 CPU 直接支持。
但,它只适用于线程竞争不太激烈的场景。
特性 | AtomicInteger (CAS) | synchronized (锁) |
---|---|---|
机制 | 乐观锁,无阻塞 | 悲观锁,阻塞 |
性能 | 高(无上下文切换) | 较低(有上下文切换) |
适用场景 | 低竞争到中等竞争 | 高竞争场景 |
是否会导致死锁 | 不会 | 可能 |
编程复杂度 | 低 | 中等 |
# 不同的锁lock和synchronized
在上述的例子中,多个线程共享一个变量,对齐进行增加,如果同时使用lock和synchronized,结果是否正确呢(为 30000)?
public class Dema {
int a = 0;
public static void main(String[] args) throws InterruptedException {
Dema dema = new Dema();
ReentrantLock lock = new ReentrantLock();
Runnable t1 = () -> {
synchronized (dema){
for (int i = 0; i < 10000; i++) {
dema.a++;
}
}
};
Runnable t2 = () -> {
synchronized (dema){
for (int i = 0; i < 10000; i++) {
dema.a++;
}
}
};
Runnable t3 = () -> {
//synchronized (dema){
// for (int i = 0; i < 10000; i++) {
// dema.a++;
// }
//}
lock.lock();
for (int i = 0; i < 10000; i++) {
dema.a++;
}
lock.unlock();
};
Thread tt1 = new Thread(t1);
Thread tt2 = new Thread(t2);
Thread tt3 = new Thread(t3);
tt1.start();
tt2.start();
tt3.start();
tt1.join();
tt2.join();
tt3.join();
System.out.println("最终 synchronized 结果为-- a:");
System.out.println(dema.a);
}
}
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
54
55
56
57
58
# 解析-不同的锁机制
程序运行后,它的最终结果可能不是30000的原因在于:你混合使用了两种不同的同步机制(synchronized 和 ReentrantLock),但它们保护的是同一个共享资源,却没有使用相同的锁对象。
让我们仔细分析你的代码:
- 线程 t1 和 t2 使用
synchronized(dema)
进行同步 - 线程 t3 使用
ReentrantLock
进行同步
问题是:这两种同步机制使用不同的锁对象:
synchronized(dema)
使用dema
对象作为锁lock.lock()
使用lock
对象(ReentrantLock实例)作为锁
由于锁对象不同,这些同步机制之间无法提供互斥访问。这意味着:
- 线程 t1 和 t2 之间可以互斥(因为它们使用相同的锁对象
dema
) - 线程 t3 只与自己互斥(因为它使用不同的锁对象
lock
) - 但线程 t3 与线程 t1/t2 之间没有互斥,它们可以同时访问和修改
a
# 错误的发生
由于 t3 与 t1/t2 之间没有同步,它们可能同时执行 a++
操作,导致之前提到的竞争条件:
- t1 读取
a
的值(比如100) - t3 也读取
a
的值(也是100) - t1 计算 100+1=101 并写入
- t3 计算 100+1=101 并写入
- 结果:两次增加操作只实现了一次有效增加
上述的错误是因为,锁的不同,或者说同步机制不同;
扩展:
synchronized(xx.class)
使用的是类对象作为锁(每个类在JVM中都有一个唯一的Class对象)。这与实例锁(synchronized(this)
或synchronized(实例对象)
)完全不同。最后是一些关于ReentrantLock的注意事项:
- 不能直接用
Lock
锁住一个任意对象(如dema
)Lock
实例自己就是锁对象,而不是用来锁其他对象- 可以通过使用统一的 Lock 实例来实现相同的线程同步效果
- 在大多数情况下,为需要同步的资源创建一个专用的
Lock
实例是最佳实践
# 使用Lock修正
public class Dema {
int a = 0;
private final ReentrantLock lock = new ReentrantLock(); // 统一的锁实例
public static void main(String[] args) throws InterruptedException {
Dema dema = new Dema();
Runnable t1 = () -> {
dema.lock.lock(); // 所有线程使用同一个锁实例
try {
for (int i = 0; i < 10000; i++) {
dema.a++;
}
} finally {
dema.lock.unlock();
}
};
Runnable t2 = () -> {
dema.lock.lock(); // 使用同一个锁实例
try {
for (int i = 0; i < 10000; i++) {
dema.a++;
}
} finally {
dema.lock.unlock();
}
};
Runnable t3 = () -> {
dema.lock.lock(); // 使用同一个锁实例
try {
for (int i = 0; i < 10000; i++) {
dema.a++;
}
} finally {
dema.lock.unlock();
}
};
// 创建和启动线程...
// 最终结果一定是30000
}
}
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
# 总结与关键事项
所以要想实现数据在并发的情况下正确,需要做到以下三点:
- 一致性原则:保护同一共享资源的所有线程必须使用相同的同步机制和锁对象。
- 可见性原则:确保对一个变量的修改对其他线程立即可见(
volatile
或同步机制可以保证)。 - 原子性原则:确保复合操作(如
a++
)作为一个不可分割的单元执行。