Java EE初阶——线程安全
1. 线程的状态
1. 线程状态分类(Thread.State
枚举)
Java 定义了 6 种线程状态,这些状态均由 java.lang.Thread.State
枚举表示:
-
NEW(新建)
线程对象已创建,但尚未调用start()
方法。此时线程尚未启动,只是一个普通的 Java 对象。 -
RUNNABLE(可运行)
线程已调用start()
方法,正在 JVM 中运行。该状态包含两种实际情况:- READY(就绪):线程已获取除 CPU 外的所有资源,等待操作系统调度。
- RUNNING(运行中):线程正在 CPU 上执行。
-
BLOCKED(阻塞)
线程因等待监视器锁(如进入synchronized
块 / 方法)而被阻塞。线程会在获取锁后恢复为 RUNNABLE 状态。 -
WAITING(无限期等待)
线程因调用以下方法而进入无限期等待状态,必须等待其他线程显式唤醒:Object.wait()
Thread.join()
LockSupport.park()
-
唤醒条件:
-
notify()
/notifyAll()
-
目标线程终止(针对
join()
)
-
-
TIMED_WAITING(限期等待)
线程因调用以下带超时参数的方法而进入限期等待状态,超时后自动唤醒:Thread.sleep(long millis)
Object.wait(long timeout)
Thread.join(long millis)
LockSupport.parkNanos()
LockSupport.parkUntil()
-
TERMINATED(终止)
线程执行完毕(run()
方法正常退出)或因异常终止,线程生命周期结束。
2. 线程的状态和转移
public class ThreadDomo1 {public static void main(String[] args) {Thread t = new Thread(()->{for(int i=0;i<1;i++){}});System.out.println(t.getState());//NEWt.start();while(t.isAlive()){//线程存活System.out.println(t.getState());//RUNNABLE}System.out.println(t.getState());//TERMINATED}
}
- 线程状态不可逆:线程一旦进入 TERMINATED 状态,无法再次启动(调用
start()
会抛出IllegalThreadStateException
)。 - BLOCKED 与 WAITING 的区别:
- BLOCKED 是因等待监视器锁而阻塞。
- WAITING/TIMED_WAITING 是主动调用方法进入等待状态,需显式唤醒或超时。
2. 线程安全
Java 标准库中的线程安全类
1. Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, ⼜没有任何加锁措施.• ArrayList• LinkedList• HashMap• TreeMap• HashSet• TreeSet• StringBuilder2. 但是还有⼀些是线程安全的. 使⽤了⼀些锁机制来控制.• Vector (不推荐使⽤)• HashTable (不推荐使⽤)• ConcurrentHashMap• StringBuffer3. 还有的虽然没有加锁, 但是不涉及 "修改", 仍然是线程安全的• String
线程安全是多线程编程中的核心概念,指的是在多线程环境下,程序的行为和结果与单线程环境下一致,不会出现数据竞争、不一致或其他意外情况。
如果这个代码在单线程环境下运行正确,在多线程环境下产生 bug ,这种情况就称为“线程不安全”或“存在线程安全问题” 。
public class ThreadDomo2 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for(int i=0;i<50000;i++){count++;}});Thread t2 = new Thread(()->{for(int i=0;i<50000;i++){count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);//预期结果 10w}
}
运行代码发现,结果每次都不一样,结果大概在5w-10w之间,这是为什么?
1. 线程不安全原因
1. 【根本原因】线程调度是随机的
操作系统上的线程是“抢占式执行” “随机调度”
2. 修改共享数据
代码结构:进程中多个线程同时修改同一个变量
没有问题:1. 一个线程修改一个变量
2. 多个线程读取同一个变量
3. 多个线程修改不同变量
3. 【直接原因】原子性
多线程同时修改同一个变量操作不是原子操作
count++; 由三个指令构成:
1. load 从内存中读取数据到 cpu 寄存器
2. add 把寄存器数值 +1
3. save 把寄存器的值写回到 内存 中
t1 和 t2 是并发执行的,可能交错执行这三步,导致部分增量丢失。
1,2 为线程安全,其余都为线程不安全
关键在于,要确保前一个线程 save 之后,第二个线程再 load ,否则第二个线程 load 到的结果就是第一个线程自增前的结果,两次自增就只 +1
即一个线程执行 1-n(基本为1次)这自增,被另一个线程覆盖成自增 1 次的情况。
4. 可见性

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.⽬的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的并发效果.• 线程之间的共享变量存在 主内存 (Main Memory).• 每⼀个线程都有⾃⼰的 "⼯作内存" (Working Memory) .• 当线程要读取⼀个共享变量的时候, 会先把变量从主内存拷⻉到⼯作内存, 再从⼯作内存读取数据.• 当线程要修改⼀个共享变量的时候, 也会先修改⼯作内存中的副本, 再同步回主内存.所谓的 "主内存" 才是真正硬件⻆度的 "内存". ⽽所谓的 "⼯作内存", 则是指 CPU 的寄存器和⾼速缓存.CPU 访问⾃⾝寄存器的速度以及⾼速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是⼏千倍, 上万倍).
import java.util.Scanner;public class ThreadDomo3 {public static int flg = 1;//使用 volatile 关键字//public static volatile int flg = 1;public static void main(String[] args) throws InterruptedException {Scanner scan = new Scanner(System.in);Thread t1 = new Thread(()->{while(flg==1){ // 循环检查 flg 的值// 空循环,等待 flg 变为非 1}System.out.println("t1 线程结束");});Thread t2 = new Thread(()->{System.out.print("请输入flg的值:");flg = scan.nextInt();// 从控制台读取输入,修改 flg 的值System.out.println("t2 线程结束");});t1.start();t2.start();}
}
使用 volatile 关键字
在该代码中,我们预期是通过 t2 线程输入一个非 1 数,使 t1 线程结束,事实却是我们输入非 1 整数,t1 线程并未结束,这是为什么?
while(flg == 1);核心指令有两条:
1. load 读取内存中 flg 的值到cpu寄存器中
2. 拿寄存器中获取的值与1进行比较(条件跳转指令)
频繁执行 load 操作和条件跳转,load 操作执行的结果,每次都是一样的,且 load 操作开销远远高于条件跳转,访问寄存器的操作速度远远超过访问内存,此时 jvm 就可能做出代码优化,优化掉 load 操作,以提高循环的执行速度。却导致 t2 线程对共享变量的修改无法及时被 t1 线程看到。造成线程不安全。
这种优化被称为 循环不变代码外提(Loop Invariant Code Motion),它将循环内不变的操作(如 load
)移到循环外,大幅提高执行效率。但在多线程环境下,这种优化会导致 内存可见性问题:即使其他线程修改了 flg
的值,执行优化后的线程仍使用寄存器中的旧值。
volatile 关键字
volatile
是一个用于修饰变量的关键字,主要用于保证变量的内存可见性和禁止指令重排序。
保证可见性每次访问变量必须要重新读取内存,而不会优化到寄存器/缓存中代码在写⼊ volatile 修饰的变量的时候,• 改变线程⼯作内存中volatile变量副本的值• 将改变后的副本的值从⼯作内存刷新到主内存代码在读取 volatile 修饰的变量的时候• 从主内存中读取volatile变量的最新值到线程的⼯作内存中• 从⼯作内存中读取volatile变量的副本
禁止指令重排序
针对被 volatile 修饰的变量必须都要重新读取内存,而不会优化到寄存器/缓存中
- 原理:
volatile
变量会插入内存屏障(Memory Barrier),禁止编译器和处理器对指令进行重排序
5. 指令重排序
- 编译器重排序:编译器为优化性能,可能改变代码的执行顺序。
- 处理器重排序:处理器为提高指令执行效率,可能对指令进行乱序执行。
2. synchronized 关键字 - 监视器锁 monitor lock
针对线程不安全原因3,使用 加锁 的方式,将非原子指令打包成一个整体,确保同一时间,只有该线程可以执行此非原子指令。
synchronized
关键字是实现线程同步的核心机制之一,它基于 ** 监视器锁(Monitor Lock)** 来保证同一时间只有一个线程可以执行被保护的代码块或方法
1、监视器锁的底层原理
-
每个对象都有一个监视器锁
Java 中每个对象(包括类实例和数组)都关联着一个监视器锁(也称为内部锁或互斥锁)。当一个线程试图访问被synchronized
保护的代码时,它必须先获取该对象的监视器锁。 -
锁的获取与释放
- 获取锁:线程进入
synchronized
代码块前,必须获取对象的监视器锁。如果锁已被其他线程持有,则当前线程会被阻塞,进入锁的等待队列。 - 释放锁:线程退出
synchronized
代码块时,会自动释放监视器锁,唤醒等待队列中的其他线程竞争锁。
- 获取锁:线程进入
- JVM 实现
监视器锁的实现依赖于对象头中的 Mark Word。当对象被锁定时,Mark Word 会存储指向锁记录的指针,不同状态(偏向锁、轻量级锁、重量级锁)下的存储结构不同
2. synchronized
的使用方式
1. 同步实例方法(锁对象为 this)
使用当前对象实例(this
)作为锁
直接修饰普通⽅法
public class SynchronizedDomo {public synchronized void method(){//...}
}
- 锁对象:隐式使用当前对象实例(
this
)。 - 作用范围:整个方法体。
- 字节码层面:JVM 使用
ACC_SYNCHRONIZED
标志来标记方法,当线程调用该方法时,会自动获取锁并在方法退出时释放锁。
同步代码块
public class SynchronizedDomo {public void method() {synchronized (this) {// 同步代码}}
}
- 锁对象:显式指定为
this
(当前对象实例)。 - 作用范围:仅包含在
{}
内的代码。 - 字节码层面:使用
monitorenter
和monitorexit
指令实现锁的获取和释放。
2. 同步静态方法(锁对象为类的 Class 对象)
使用类的 Class
对象(即 SynchronizedDomo1.class
)作为锁
synchronized
修饰静态方法
public class SynchronizedDomo {public static synchronized void method(){//...}
}
- 锁对象:隐式使用当前类的
Class
对象(如SynchronizedDomo1.class
)。 - 作用范围:整个静态方法体。
- 字节码层面:JVM 使用
ACC_SYNCHRONIZED
标志来标记静态方法,当线程调用该方法时,会自动获取类的Class
对象锁并在方法退出时释放锁。
同步静态代码块
public class SynchronizedDomo {public static void method() {// 反射 类名.class 获取当前类的 class 对象synchronized (SynchronizedDomo1.class) {// 同步代码}}
}
- 锁对象:显式指定为当前类的
Class
对象。 - 作用范围:仅包含在
{}
内的代码。 - 字节码层面:使用
monitorenter
和monitorexit
指令实现锁的获取和释放。
3. 同步代码块,指定锁对象(locker)
public class SynchronizedDomo1 {//创建锁对象(锁对象可以是任意Object)private Object locker = new Object();public void method(){synchronized (locker){//...}}
}
代码实例
public class ThreadDomo3 {public static int count = 0; //共享变量public static void main(String[] args) throws InterruptedException {//创建对象(任意)作为锁对象Object locker = new Object();Thread t1 = new Thread(()->{for(int i=0;i<50000;i++){// 使用locker作为锁,进入同步块前会获取锁synchronized (locker){count++;}// 退出同步块时自动释放锁}});Thread t2 = new Thread(()->{for(int i=0;i<50000;i++){// 使用locker作为锁,进入同步块前会获取锁synchronized (locker){ count++;} // 退出同步块时自动释放锁//count++;}});// 启动两个线程t1.start();t2.start();// 主线程等待两个线程执行完毕t1.join();t2.join();System.out.println(count);//100000}
}
3. 互斥
-
互斥 指同一时间只允许一个线程访问共享资源,其他线程必须等待。
-
通过 锁(Lock) 或 同步机制(如
synchronized
)实现。
作用
- 保证原子性:防止多个线程同时修改共享数据导致的数据不一致。
- 维护可见性:确保一个线程对共享变量的修改能被其他线程正确看到。
- 防止多个线程同时修改共享数据(如
count++
),造成线程不安全
4. 锁竞争
锁竞争是指多个线程同时尝试获取同一把锁时发生的竞争现象。当锁被一个线程持有时,其他线程必须等待,从而导致线程阻塞和上下文切换,降低程序性能。
竞争程度 | 表现 | 解决方案 |
---|---|---|
低竞争 | 线程偶尔阻塞,性能影响小 | 无优化必要 |
高竞争 | 大量线程阻塞,CPU空转 | 减小锁粒度、无锁算法 |
在上述代码中,两个线程访问共享资源 count, t1 线程
进行了同步保护,t2
线程直接访问,就不会形成锁竞争,t2
线程可能看不到 t1
线程对 count 的修改,count ++的原子性被破坏,造成线程不安全。
5. 可重入
可重入是指同一个线程可以多次获取同一把锁而不会被阻塞。可重入锁会记录锁的持有线程和重入次数,当线程退出同步块时,只有重入次数降为 0 才会真正释放锁。
-
实现原理:
-
synchronized
通过 锁计数器 记录重入次数。 -
ReentrantLock
通过getHoldCount()
获取重入次数。
-
Java 中的可重入锁
synchronized
关键字:隐式支持可重入。ReentrantLock
:显式支持可重入,可通过lock()
和unlock()
方法控制。
public class SynchronizedDomo4 {public static int count = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t = new Thread(()->{// 真正加锁,同时记录锁的持有线程synchronized(locker){ // 第一次获取locker锁,锁计数器为1count++;synchronized (locker){ // 第二次获取同一个locker锁,锁计数器为2count++;synchronized (locker){ // 第三次获取同一个locker锁,锁计数器为3count++;}//解锁,锁计数器为2}//解锁,锁计数器为1}//真正解锁,锁计数器为0});t.start();t.join();System.out.println(count);//3}
}
public class SynchronizedDomo5 {public void A(){synchronized (this){// 子线程获取 this 锁,锁计数器+1 → 1B();}}public void B(){C();}public void C(){D();}public void D(){synchronized (this){// 子线程再次获取 this 锁,计数+1 → 2(已持有锁,可重入)System.out.println("hello");}}public static void main(String[] args) throws InterruptedException {SynchronizedDomo5 s = new SynchronizedDomo5();Thread t = new Thread(()->{s.A();});t.start();}
}
虽然 A()
和 D()
都使用 synchronized (this)
加锁,但由于锁是可重入的,同一个线程可以在持有锁的状态下嵌套调用其他同步方法,不会导致死锁。
概念 | 互斥(Mutual Exclusion) | 锁竞争(Lock Contention) | 可重入性(Reentrancy) |
---|---|---|---|
目标 | 保护共享资源 | 减少锁冲突 | 避免自我阻塞 |
实现手段 | 锁机制 | 锁优化或无锁算法 | 锁计数器 |
关联性 | 互斥导致锁竞争 | 高竞争降低性能 | 可重入减少死锁 |
关键点 | 同一时间只有一个线程能访问共享资源。 | 多个线程争夺同一把锁,导致阻塞和上下文切换。 | 同一个线程可多次获取同一把锁。 |
6. 死锁
两个或多个线程互相持有对方需要的资源,导致所有线程都无法继续执行的状态。
死锁的四个必要条件(Coffman条件)
- 互斥条件:资源不能被共享,同一时间只能被一个线程占用。
- 占有并等待:线程至少已经持有一个资源,同时请求其他线程持有的资源。
- 不可抢占:线程已获得的资源不能被其他线程强行抢占,只能自己释放。
-
循环等待:存在一个线程的循环等待链,每个线程都在等待下一个线程所占用的资源。
public class SynchronizedDomo6 {public static void main(String[] args) {Object locker1 = new Object();Object locker2 = new Object();Thread t1 = new Thread(()->{synchronized (locker1){//休眠1s,为线程2争取获得locker2的时间try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}//尝试获取locker2synchronized (locker2){System.out.println("t1");}}});Thread t2 = new Thread(()->{synchronized (locker2){//休眠1s,为线程1争取获得locker1的时间try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}//尝试获取locker1synchronized (locker1){System.out.println("t2");}}});t1.start();t2.start();}
}
t1
先获取locker1
锁,然后休眠 1 秒,在这 1 秒内,t2
有机会获取locker2
锁。t2
先获取locker2
锁,然后休眠 1 秒,在这 1 秒内,t1
持有locker1
锁。- 当
t1
休眠结束后尝试获取locker2
锁时,t2
已持有locker2
锁;而当t2
休眠结束后尝试获取locker1
锁时,t1
已持有locker1
锁。 - 这样就形成了
t1
等待t2
释放locker2
锁,t2
等待t1
释放locker1
锁的情况,两个线程相互等待对方释放锁,从而导致死锁。程序卡死。
如何避免死锁
-
破坏互斥条件:不是所有资源都能这样做(如打印机必须互斥使用)
-
破坏占有并等待:
-
线程在开始时就获取所有需要的锁,否则不获取任何锁。
-
使用
tryLock()
等非阻塞获取锁的方法
-
-
破坏不可抢占条件:
-
使用可响应中断的锁(如
ReentrantLock
) -
设置获取锁的超时时间
-
-
破坏循环等待条件:
-
对资源进行排序,按固定顺序获取锁
-
使用资源分配图算法检测
-
7. volatile
vs synchronized
特性 | volatile | synchronized |
---|---|---|
可见性 | ✅ 保证可见性 | ✅ 保证可见性 |
原子性 | ❌ 不保证原子性(如 i++ ) | ✅ 保证原子性 |
指令重排序 | ✅ 禁止重排序 | ✅ 禁止重排序 |
性能 | 开销较小,适合轻量级同步 | 开销较大,适合重量级同步 |
使用场景 | 状态标志、单次初始化、禁止重排序 | 复合操作、方法或代码块同步 |