线程同步
线程安全 vs 线程同步
线程安全
线程安全的核心会来到线程同步,我认为线程同步是为了保证线程安全
当多个线程共享同一份数据,并且至少有一个线程会对数据进行写操作时,如果不采取任何保护措施,就极易产生线程安全问题。
线程安全是一个类、方法或代码块在被多个线程同时访问时,其行为仍然正确、数据保持一致,无需调用方额外同步,就称为线程安全。
线程安全的目标是保证程序在并发环境下的正确性。它不会出现竞态条件,结果可预测、可重复,一万个人同时去取钱,银行要保证剩下的钱是正确的
如何判断一段代码是否线程安全?问自己三个问题:
- 是否有共享可变状态?(无共享 → 天然安全)
- 是否所有访问路径都受保护?(同步、原子操作等)
- 复合操作是否原子?(如 “检查再更新”)
如果答案都是“是”,那它很可能是线程安全的。
线程同步
而线程同步是通过特定机制(如锁、信号量等),控制多个线程对共享资源的访问顺序,确保同一时间只有一个线程能操作临界区(Critical Section)。是实现线程安全的一种技术手段。
一万个人同时去取钱,需要几台机子,每台机子排队进行,一次只让一个人操作,这个排队机制就是线程同步。
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。
1 | class Counter { |
两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
对于上述的加法,一个完整的流程是从方法区中加载这个变量的值,然后进行相加操作,然后存回这个值到方法区的变量实现更新,这就是一行语句,实际上对应多个操作
所以说,多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行,即某一个线程执行时,其他线程必须等待
通过加锁和解锁的操作,就能保证一个操作对应的所有指令总是在一个线程执行期间,这样就能保证这行代码的原子性,因为它执行中不会有其他线程会进入此指令区间干扰。
即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
实现线程同步有很多方式
一些线程状态管理的方法
start() vs run()
首先,我们来看看start()方法。
在Java中,当你调用 Thread
类的start()方法时,会执行一系列的操作,最终的结果是启动一个新的线程,并在这个新的线程上执行run()方法中的代码,关于start()源码如下
1 | // Thread.java (JDK 21) |
然后,我们再来看run(),run()是一个普通的方法,它是用来存放我们想要在线程中执行的代码的。
1 | // Thread.java (JDK 21) |
run()从holder.task取出构造时传入的Runnable对象。直接调用task.run()在当前线程中执行,实际上,它就是一个普通方法调用,不涉及任何线程创建或切。- 所以说,这就来到
run()的本质了,本身不产生新线程!
1 | public class StartVsRun { |
run() 被
主线程直接调用,根本没有创建新线程,所以说,start()才是真正的多线程。
run()
只是一个普通方法,直接调用会在当前线程执行,无法实现并发。start()
才是触发 JVM 创建新线程的入口。
1 | public class StartVsRun { |
start() 创建了新线程,并在其中执行
run()。
那么,继承 Thread 重写 run() 和传入
Runnable 有什么区别?
- 功能相同,因为源码中最终都通过
holder.task.run()执行,但。。。。。。。Runnable更灵活,而且符合组合优先继承原则
sleep()
sleep()方法是Java线程开发中的一个概念,属于线程处于定时等待(TIMED_WAITING)状态时所使用的方法。
调用它,会使当前线程暂停执行指定时间,期间不参与 CPU
调度,但是不释放锁,而且可被 interrupt()
中断,抛出 InterruptedException
sleep(long millis)—— 毫秒级睡眠
sleep(long millis, int nanos)—— 纳秒级睡眠sleep(Duration duration)1
2
3
4
5public static void sleep(Duration duration) throws InterruptedException {
long nanos = NANOSECONDS.convert(duration);
if (nanos < 0) return; // 负数 Duration 直接返回(无操作)
// 后续逻辑同上
}它会自动适配 JDK21 的虚拟线程模型
对于
sleep0(),private static native void sleep0(long nanos) throws InterruptedException;,它是
JVM(HotSpot)用 C++ 实现的 native 方法,它会挂起当前线程,不参与 CPU
调度,不释放已持有的 synchronized 锁
1 | public class SleepLockTest { |
它可以被这样中断
1 | public class SleepInterruptTest { |
wait()/
notify() / notifyAll()
为什么这三个方法放在一起,因为他们是 Object 类的
这些方法明明是控制线程行为的,为什么定义在 Object 类里,而不是 Thread 类中?
因为 Java 的锁是对象级别的,不是线程级别的,任何对象都可以作为
synchronized 的锁对象,我们
synchronized的是对象,也不是线程啊,这个后面synchronized的时候会说的
| 方法 | 作用 | 关键特性 |
|---|---|---|
wait() |
当前线程释放锁并进入等待状态 | 必须在 synchronized 块内调用 |
notify() |
唤醒一个等待中的线程 | 随机选择(不保证公平) |
notifyAll() |
唤醒所有等待中的线程 | 安全但可能有性能开销 |
对于多线程协调运行,原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
一个线程调用 Object 的
wait()方法,使其线程被阻塞;另一线程调用 Object 的
notify()/notifyAll() 方法,wait()
阻塞的线程继续执行。
实现 wait/notify 机制的条件:
- 调用
wait()线程和notify()线程必须拥有相同对象锁。 wait()方法和notify()/notifyAll()方法必须在synchronized方法或代码块中。
对于 wait(),在执行
wait()方法前,当前线程必须已获得对象锁。调用它时会阻塞当前线程,进入等待状态,在当前
wait() 处暂停线程。同时,wait()
方法执行后,会立即释放获得的对象锁。
同样,在执行notify()
方法前,当前线程也必须已获得锁。调用 notify()
方法后,会通知一个执行了wait()方法的阻塞等待线程,使该等待线程重新获取到对象锁,然后继续执行
wait() 后面的代码。
但是,与wait() 方法不同,执行 notify()
后,不会立即释放对象锁,而需要执行完 synchronize
的代码块或方法才会释放锁,所以接收通知的线程也不会立即获得锁,也需要等待执行
notify() 方法的线程释放锁后再获取锁。这是很自然的事情。
如果是想通知所有等待状态的线程,可使用 notifyAll()
方法,就能唤醒所有线程。
对于 wait()
源码,看着这一个就够了,其中参数是设置超时时间,当等待时间大于设置的超时时间后,会继续往
wait(long) 方法后的代码执行。
1 | public final void wait(long timeoutMillis) throws InterruptedException { |
对于wait()源码的注释
- 必须持有对象监视器(monitor) → 否则抛
IllegalMonitorStateException - 释放锁:调用
wait()时自动释放当前对象的锁 - 进入等待集(wait set):线程被挂起,直到被通知/中断/超时
- 重新获取锁:被唤醒后必须重新竞争锁,成功后才返回
而对于notify() 和 notifyAll(),源码如下
根据其中的注解,我们能知道
- 必须持有对象监视器 → 否则抛
IllegalMonitorStateException - 不释放锁:调用后继续持有锁,直到退出
synchronized块 - 唤醒线程:
notify():从等待集中随机选择一个线程唤醒notifyAll():唤醒所有等待线程
对于它们的协作,我写这样的一个代码,大家就能理解了
1 | public class ProducerConsumer { |
- 消费者
wait()调用后,消费者线程释放了其lock对象的锁 - 生产者才能进入
synchronized(lock)块 - 其中,
notify()只是发送信号,不立即切换线程
join()
join()是 Thread
类中的一个方法,当我们需要让线程按照自己指定的顺序执行的时候,就可以利用这个方法,因为,它的作用是让调用此方法的线程被阻塞,仅当该方法完成以后,才能继续运行。
如果作用于
main( )主线程时,会等待其他线程结束后再结束主线程。
1 | public class Test { |
说明在调用t1.join( )方法以后,main()的线程被阻塞,等待t1线程的继续运行,然后等到t1线程完成以后,主线程不再阻塞,main()线程再结束。
可以看出
join()有主要两点,线程如何被阻塞,线程又是如何被唤醒
然后我们分析一下
join()方法的源码,不出意外也是一个有时间参数一个没有的重载
join()
方法的核心目的是让当前线程(调用者线程)等待,直到目标线程(被调用
join()
的线程)执行完毕(终止),虽然都是阻塞然后唤醒,但是join()
方法的内部实现完全不同,取决于你等待的是一个平台线程还是一个虚拟线程。
虚拟线程
1 | if (this instanceof VirtualThread vthread) { |
虚拟线程是由 JVM 在用户态调度的,其生命周期状态对 JVM 是完全透明的。因此,JVM 可以非常高效地实现
join,通常不需要昂贵的内核级同步原语(如Object.wait/notify)。它可以简单地将当前调用者虚拟线程挂起(park),并注册一个回调,当目标虚拟线程终止时,自动唤醒(unpark)调用者线程。这种方式开销极小,非常适合高并发场景。
如果目标线程不是虚拟线程,那么就会走传统的、基于 Java 对象监视器(Monitor)的实现路径。
1 | synchronized (this) { // 锁定目标线程对象 |
整个逻辑包裹在 synchronized (this)
块中。这意味着调用者线程必须先获取目标线程对象的内置锁。
等待分两者情况
- 无限等待 (
millis <= 0): 只要目标线程还活着 (isAlive() == true),就调用wait(0)。wait(0)会让当前线程释放锁并进入等待状态,直到被其他线程notify()或notifyAll()唤醒。 - 超时等待 (
millis > 0): 进入一个do-while循环。每次循环都会计算剩余的等待时间delay,然后调用wait(delay)。即使超时返回,也会再次检查线程是否已终止。这是因为wait可能被虚假唤醒,所以必须用while循环来确保条件真正满足。
那么,是谁在目标线程完成任务了,需要结束的时候,调用
notifyAll() 来唤醒所有等待它的线程呢?
- 一个平台线程的
run()方法执行完毕,无论是正常结束还是抛出异常,JVM 都会执行线程的清理工作。 - 在这个清理过程中,JVM 会自动在该线程对象上调用
notifyAll(),唤醒进程的方法位于jvm中。就会唤醒所有因为调用join()而在该线程对象上wait()的线程。
yield()
`yield()方法会让当前线程放弃 CPU
时间片,把执行权交给同等优先级的其他线程,而且不会释放锁,线程依然处于RUNNABLE状态,不会进入阻塞状态,但是至于能否成功释放由JVM决定。由于这个特性,一般编程中用不到此方法。
也就是说,yield()
就是让当前线程暂时让路,但并不一定马上就中断这个线程来执行这一步
那么来看源代码
1 | public static void yield() { |
很明显,yield()
方法的核心目的是向线程调度器发出一个提示,当前正在执行的线程愿意暂时放弃它当前对
CPU
的使用权,让调度器有机会去运行其他具有相同或更高优先级的Runnable线程。这意味着:
- 调度器可以采纳这个建议,立即暂停当前线程,并切换到另一个线程。
- 调度器也可以完全忽略这个建议,继续让当前线程执行。
因此,yield()
不能保证线程会让步,它的行为是平台相关且不可预测的。
源码中的 Javadoc 对 yield()
的描述非常精准,值得逐句分析
“A hint to the scheduler that the current thread is willing to yield its current use of a processor. The scheduler is free to ignore this hint.”
- 明确其“提示”性质:它只是一个建议,调度器有完全的自由决定是否采纳。
“Yield is a heuristic attempt to improve relative progression between threads that would otherwise over-utilise a CPU. Its use should be combined with detailed profiling and benchmarking to ensure that it actually has the desired effect.”
- 使用场景:主要用于解决某些线程过度占用 CPU,导致其他线程“饥饿”的情况,试图改善线程间的相对进度。
- 强烈警告:它的效果是启发式的(heuristic),必须通过详细的性能分析和基准测试来验证它是否真的带来了预期的好处。盲目使用很可能适得其反。
“It is rarely appropriate to use this method. It may be useful for debugging or testing purposes, where it may help to reproduce bugs due to race conditions. It may also be useful when designing concurrency control constructs such as the ones in the {@link java.util.concurrent.locks} package.”
- 不推荐在生产代码中使用:官方明确指出,在绝大多数应用代码中都不应该使用
yield()。- 少数适用场景:
- 调试和测试:通过强制线程切换,可以更容易地复现由竞态条件(race condition)引起的偶发性 bug。
- 构建底层并发工具:在
java.util.concurrent包等高级并发库的内部实现中,开发者可能会用它来微调自旋锁(spin-lock)等低级同步原语的行为。
为什么 yield() 和 sleep() 方法都是静态的呢?
- 静态方法不需要实例化对象,直接通过类名调用就能执行,所以说,
Thread.sleep()方法操作都应该是针对当前执行的线程本身。而线程本身还没有开始执行时,无法通过线程实例去调用sleep(),因此它必须是静态方法。对于Thread.yield()方法同样,那其实,Thread 中的不少方法都是 static 的,因为,它们的作用都是针对线程调度机制本身,而不是某一个特定的线程,因此它们必须是静态的。
他俩有什么区别?
yield()的作用并不像sleep()那样强制线程停止,而是给了线程调度器一个提示,意思是我愿意暂停,给其他同等优先级的线程执行机会,但是由于操作系统复杂的线程调度策略,这并不意味着当前线程立刻停止。sleep()是让线程在指定的时间内进入休眠状态,并且完全不参与 CPU 的竞争,直到休眠时间结束,线程才会再次进入就绪队列,等待 CPU 调度。
synchronized关键字
锁的本质是什么
在 Java 多线程中,锁的本质是一种由 JVM 或 JDK 提供的、用于协调多个线程对共享可变状态进行互斥访问的同步机制。它通过强制线程串行化地执行临界区代码(互斥)以及在加锁/解锁边界插入内存屏障,从而同时保证了操作的原子性、数据的可见性和执行的有序性,最终确保了程序的线程安全性。
可重入、自动释放、非公平
锁的核心价值,就是通过一套规则,一套规则的三个要点就是上面的内容,从而一次性解决三大并发问题,实现线程安全
锁通过以下机制来保证线程安全:
- 互斥
- 锁确保在同一时刻,只有一个线程能够进入被保护的代码区域,也就是临界区
- 无论临界区内有多少行代码,它们都会被完整地执行完,中间不会被其他线程打断。所以说,临界区内是原子性的。一个公共厕所只有一个隔间,门上的锁确保了同一时间只有一个人能使用。
- 内存屏障
- JVM
在执行加锁(
lock)和解锁(unlock)操作时,会插入特殊的 内存屏障 指令。- 加锁时 (
synchronized进入 /lock()):- 清空工作内存:强制当前线程清空其 CPU 缓存中关于共享变量的副本。
- 从主内存加载:要求线程必须从主内存中重新加载最新的共享变量值。
- 禁止重排序:确保临界区内的代码不会被重排到加锁操作之前。
- 解锁时 (
synchronized退出 /unlock()):- 刷新到主内存:强制将当前线程工作内存中对共享变量的所有修改,立即刷新回主内存。
- 禁止重排序:确保临界区内的代码不会被重排到解锁操作之后。
- 加锁时 (
- 通过这一清一刷的过程,锁天然地保证了可见性,修改对其他线程立即可见和有序性,临界区代码的执行顺序对外部线程是确定的。
- JVM
在执行加锁(
我们再看 Java 中具体的锁实现分为两种
内置锁:
synchronized,JVM 级别的锁显式锁:
ReentrantLock等,JDK API 级别的锁,基于AbstractQueuedSynchronizer (AQS)框架实现。
使用synchronized关键字
二编:JDK21里,偏向锁已彻底移除,他不是废除了还是什么
@Deprecated,它是把源码已删除,所以我不会再说一句,对于JDK 21 锁升级路径是无锁 → 轻量级锁 → 重量级锁,不再有“偏向锁”阶段
作为 Java
最古老却依然核心的同步机制,Java程序使用synchronized关键字对一个对象进行加锁:
- 对于原子性,
synchronized关键字保证临界区代码“不可分割”执行 - 对于可见性,
synchronized关键字释放锁前强制刷新工作内存到主内存 - 对于有序性,
synchronized关键字通过内存屏障禁止指令重排序
synchronized保证了代码块在任意时刻最多只有一个线程能执行。我们把上面的代码用synchronized改写如下:
1 | class Counter { |
其中
1
2
3synchronized(Counter.lock) { // 获取锁
...
} // 释放锁它表示用
Counter.lock实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { ... }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是0。
上述情况就是用synchronized同步代码块,我们来概括一下如何使用synchronized:
找出修改共享变量的线程代码块;
选择一个共享实例作为锁;
多个线程各自的
synchronized锁住的必须是同一个对象才能生效锁!因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但多个不同的锁在同一时刻可以被多个线程分别获取。
因此,使用
synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。但是,如果多个组之间不存在竞争,它们不会互相干扰,本来可以多线程并发执行的事情,就完全可以使用多个不同的锁对象来锁住
1
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
59
60
61
62
63
64
65class Counter {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static int studentCount = 0;
public static int teacherCount = 0;
}
class AddStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock1) {
Counter.studentCount += 1;
}
}
}
}
class DecStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock1) {
Counter.studentCount -= 1;
}
}
}
}
class AddTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock2) {
Counter.teacherCount += 1;
}
}
}
}
class DecTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock2) {
Counter.teacherCount -= 1;
}
}
}
}
public class Test {
public static void main(String[] args) throws Exception {
var ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
for (var t : ts) {
t.start();
}
for (var t : ts) {
t.join();
}
System.out.println(Counter.studentCount);
System.out.println(Counter.teacherCount);
}
}
上述代码的4个线程对两个共享变量分别进行读写操作,
AddStudentThread和DecStudentThread,AddTeacherThread和DecTeacherThread,组之间不存在竞争,因此,我这里使用了两个不同的锁,这样才能最大化地提高执行效率。使用
synchronized(lockObject) { ... }。
此外,它也可以锁当前对象中的方法
1 | public class Counter { |
锁其他对象的方法
1 | public class GlobalCounter { |
使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。很明显,我们运行这个代码会感到变慢,因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。
在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁
最后,synchronized 是 JVM 内置的
可重入互斥锁,通过 monitor 机制
实现线程对共享资源的互斥访问。
synchronized
的一些底层机制
Monitor 机制是保证synchronized原子性和有序性的,它是
Java 并发的基座,那么它是怎么样实现的。
Monitor(监视器) 是一种 同步原语,由 C.A.R. Hoare 在 1974 年提出,用于解决多线程对共享资源的互斥访问和线程协作问题。
- 同一时刻,只有一个线程能进入 Monitor 的临界区;
- 线程可以在 Monitor 内 等待某个条件成立,并被其他线程唤醒。
在 JVM 中,每个 Java 对象都关联一个 Monitor(也叫内置锁或Intrinsic Lock)。该 Monitor 包含三个关键组件:
- Owner:当前持有锁的线程
- Entry Set(入口队列):等待获取锁的线程队列(阻塞状态)
- Wait Set(等待队列):调用 wait() 后进入等待的线程队列
当线程执行 synchronized(obj) 时,会尝试获取
obj 的 Monitor;
- 若 Monitor 未被占用,则线程成为 Owner;
- 若已被占用,则进入 Entry Set 阻塞;
- 若线程在同步块内调用 obj.wait(),则:
- 释放 Monitor;
- 进入 Wait Set;
- 等待被
notify()唤醒后重新竞争锁。
所以说,Monitor
是对象级别的,不是方法或代码块级别的。synchronized
只是语法糖,真正实现锁机制的其实是对象自己。
而且synchronized还有一个 happens-before
保证,它是实现可见性的重要内容
1 | // 线程A |
通过
happens-before规则,释放锁前强制将工作内存修改刷新至主内存;获取锁时清空工作内存,从主内存重新加载最新值。线程A释放锁 → happens-before → 线程B获取同一把锁→ 线程B必然看到线程A在同步块内所有修改
不需要synchronized的操作
实际上,不需要
synchronized的操作有很多,不可变对象无需同步只是一个代表性的那么,当操作满足以下任一条件时,无需
synchronized
- 无共享可变状态(根本不存在竞争)
- 状态不可变(共享但只读)
- 操作本身是原子的(硬件/CAS 保证)
- 状态被线程封闭(每个线程独享)
不可变对象无需同步
如果多线程读写的是一个不可变对象,那么无需同步,因为对象创建后状态永不改变,多线程读取永远安全。
1 | // 1. String(JDK 核心不可变类) |
那么,无状态类也是一个道理,类不保存任何状态,方法调用就没有副作用。所以说,Controller / Service 层应尽量设计为无状态,才能安全部署在多线程容器(如 Tomcat)中。
ThreadLocal 线程本地存储
这个也后面细说吧,只知道不用就行了
原子操作无锁并发
JDK 提供的原子类,常见的如下
| 类 | 用途 | 示例 |
|---|---|---|
AtomicInteger |
原子整数 | counter.incrementAndGet() |
AtomicLong |
原子长整数 | idGenerator.getAndIncrement() |
AtomicReference<T> |
原子引用 | AtomicReference<Config> configRef |
AtomicBoolean |
原子布尔 | isRunning.set(false) |
这个后面细说
单独使用原子操作
下面说的 JVM
规范定义的几种原子操作,单独使用也不用synchronized
volatile关键字
volatile是Java提供的一种轻量级同步机制,仅能修饰变量(包括实例变量、静态变量,不能修饰方法、代码块或局部变量)。其核心作用是保证共享变量在多线程环境下的可见性和有序性,但不保证原子性。这一特性决定了它的适用场景具有明确边界,既不能滥用,也不能在需要原子性的场景中替代锁。
volatile 是一个轻量级的同步机制,它主要解决的是可见性和有序性问题,但不保证原子性。
可见性:当一个线程修改了一个被
volatile标记了的变量的值,其他线程能够立即看到修改后的值。- 这是因为 volatile 变量不会被缓存在寄存器或其他处理器不可见的地方,而且如果修改,会立即被刷新到主内存,同时其他线程中缓存的该变量副本会被标记为无效,后续访问时必须从主内存重新读取最新值,因此保证了每次读取 volatile 变量都会从主内存中读取最新的值,而且保证是所有线程都可见的
有序性:
volatile变量的读写操作具有一定的有序性,即禁止了指令重排序优化,就是禁止编译器自动重新排序。这意味着,在一个线程中,对
volatile变量的写操作一定发生在后续对这个变量的读操作之前。对于 volatile 解决的有序性问题,典型案例就是懒加载情况下的单例模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 这是双重检查锁定实现的懒汉式单例
public class SingletonDCLCorrect {
// volatile禁止指令重排序,保证实例完整初始化
private static volatile SingletonDCLCorrect instance;
private SingletonDCLCorrect() {}
public static SingletonDCLCorrect getInstance() {
if (instance == null) { // 第一次检查,避免每次加锁
synchronized (SingletonDCLCorrect.class) {
// 第二次检查:防止多个线程同时通过第一次检查时重复创建对象
if (instance == null) {
instance = new SingletonDCLCorrect();
}
}
}
return instance;
}
}
非原子性:
volatile不保证原子性,单个volatile变量的读写是原子操作,但复合操作还是会有线程安全问题,因为它仅对单个读写进行保障,涉及不到多个操作例子还是加减一个变量的例子,在上面
synchroized用过的
简单说一下 volatile关键字的底层内容
JVM 在编译时,会在 volatile
变量的读写操作前后插入特定的 内存屏障(Memory
Barrier) 指令。
- 写屏障 (Store Barrier): 在
volatile写操作之后插入。它确保,该次写操作之前的所有普通变量的修改都会被强制刷新到主内存,并且禁止重排,确保这次保证同步的写操作不会被调整到其他写操作的前面 - 读屏障 (Load Barrier): 在
volatile读操作之后插入。它确保,当前线程 CPU 缓存中所有共享变量的副本失效,而且也是一样的禁止重排
这些内存屏障,共同构建了 happens-before 关系,从而保证了可见性和有序性。
volatile
关键字通常用于以下场景,和synchronized差不太多,但是有点区别
- 当多个线程共享一个变量,并且至少有一个线程会修改这个变量时。
- 当需要确保变量的修改对所有线程立即可见时。
- 当变量的状态不需要依赖于之前的值,或者不需要与其他状态变量共同参与不变约束时。
根据它的使用情况,所以,通常情况下,在以下情况,volatile
关键字几乎是会被使用的
状态标志位
在多线程程序中, volatile 关键字用于表示一个状态标志位,例如程序运行状态或中断使能状态。这些状态标志位通常会被多个线程访问和修改,使用 volatile 可以确保它们的可见性和有序性。
1
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
29public class Test {
// volatile 作为状态标志
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) { // 每次循环都从主内存读取最新值
// 执行一些工作
System.out.println("Working...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 保持中断状态
break;
}
}
System.out.println("Worker thread stopped.");
});
worker.start();
// 主线程等待3秒后,请求工作线程停止
Thread.sleep(3000);
running = false; // 修改标志,对工作线程立即可见
worker.join(); // 等待工作线程真正结束
System.out.println("Main thread finished.");
}
}
双重检查锁:参考单例模式的双重检查锁
JVM规范定义的几种原子操作
JVM规范定义了几种原子操作,这些操作默认原子,不用使用synchronized等进行锁同步
基本类型(
long和double除外)赋值,例如:int n = m;long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。引用类型赋值,例如:
List<String> list = anotherList。
单条原子操作的语句不需要同步。例如下面示例就不需要同步。
1 | public void set(int m) { |
但是,如果是多行赋值语句,就必须保证是同步操作,因为你这是把多个原子操作组合了,原子操作之间肯定还是要保证同步的,多线程连续读写多个变量时,同步的目的是为了保证程序逻辑正确。不仅是赋值,对读也是一样,因为本质上,多个原子操作组合的,如果涉及到会互相干扰,就需要加同步锁
1 | class Point { |
但是有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。
1 | class Point { |
1 | class Point { |
这样就就不再需要写同步,因为this.ps = ps是引用赋值的原子操作。不过要注意,读方法在复制int[]数组的过程中仍然需要同步。
同步方法
使用synchronized修饰的方法称为同步方法,同一时刻只有一个线程可以执行该方法
例如,我们这样编写一个类
1 | public class Counter { |
我们知道Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要。
让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。所以更好的方法是把synchronized逻辑封装起来,封装到你的类里,这样在调用的时候,就可以不用去管那些同步逻辑了。并且,我们注意到,synchronized锁住的对象是this,即当前实例,这又使得创建多个当前实例的时候,它们之间互不影响,可以并发执行
现在,对于上面例子,Counter类,多线程可以正确调用,它是线程安全的,而如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe)
还有一些不变类,例如String,Integer,LocalDate,枚举类,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。
然后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程的情况下什么也不考虑就直接拿来使用,但是,如果所有线程都只读取,不写入,你把他们当作了不变类,那就没这个事情了
当然,synchronized可以修饰方法,它表示整个方法都必须用this实例加锁。
1 | public synchronized void add(int n) { // 锁住this |
而,如果我们对一个静态方法使用
synchronized
修饰符时,它锁住的并不是某个对象实例,而是该类对应的
Class
对象,这是因为静态方法属于类本身,而不是类的任何特定实例。
因此,对static方法添加synchronized,锁住的是该类的Class实例,JVM
在加载类时会为每个类创建一个唯一的 java.lang.Class
实例,这个实例在整个 JVM 生命周期中是全局唯一的 。
因此,synchronized static
方法实现的是类级别的互斥访问,而非“实例级别”
1 | public class Counter { |
而上面,t1,t2两个线程,虽然面对的是两个不同的 Counter
类实例,但是由于static synchronized是在类这个级别上同步了,锁的是
Counter.class,全局唯一
,所以它们不会同时进入increment()方法,它们是线程安全的
在保护类级别资源,考虑有必要对整个类的所有实例及类本身都保证线程安全的时候,可以使用static synchronized这样做,但其粗粒度锁可能导致性能瓶颈,尤其是在高并发场景下
- 多个无关的静态同步方法会互相阻塞,因为它们共用一把类锁 。
- 长时间持有类锁会阻塞其他线程对任何静态同步方法的访问 。





