Java并发编程(四)|线程安全:如何让你的并发代码不“打架”
线程安全、synchronized 锁、死锁与 volatile、wait/notify 详解
前言
承接上一篇 Java 线程核心 API、线程生命周期相关内容,本篇深入多线程核心痛点:线程安全问题。从复现并发 bug 入手,讲解非原子操作底层原理、synchronized 同步锁三种使用方式、可重入锁机制;分析多锁场景下的死锁成因与规避手段;介绍 volatile 关键字解决内存可见性问题;最后讲解 wait/notify 线程等待通知机制,实现多线程有序协作,全部配套可运行实操代码。
线程安全
有些代码,在单线程环境下执行,完全正确,但当多个线程同时执行时,可能就会出现bug,这种现象就是“线程安全问题/线程不安全”。
典型线程不安全案例
//线程安全
public class Demo{
static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
//对count变量自增50000次
for(int i=0;i<50000;i++) {
count++;
}
});
Thread t2=new Thread(()->{
//对count变量自增50000次
for(int i=0;i<50000;i++) {
count++;
}
});
t1.start();
t2.start();
//一定要加上这两个join,要确保两个线程都自增完毕再打印结果
t1.join();
t2.join();
System.out.println("count="+count);
}
}
预期的效果是两个线程各自自增50000次,最终count的值应该是100000。
实际结果:
count=56103
两个线程同时执行,实际结果与预期结果相差很大,并且每次运行结果也不一样。
为什么会出现这种情况?
count++这个操作,在cpu角度,是由三个指令完成的:
- load,把数据从内存读到CPU寄存器中。
- add,把寄存器中的数据进行+1。
- save,把寄存器中的数据,保存到内存中。
如果是多个线程同时执行上述代码,由于线程之间的调度顺序是“随机”的,就会导致在有些调度顺序下,上述的逻辑就会出现问题。
此处只画出了这四种情况,但整个过程存在无数种情况,可能存在t1执行了一次,而t2执行了n次。
结合上述情况的讨论,不难看出,在多线程程序中,线程的随机调度,会使得两个线程执行逻辑的先后顺序存在多种可能。因此,我们必须要保证在所有可能的情况下,代码都是正确的。
产生线程安全问题的原因
- 操作系统中,线程的调度顺序是随机的(抢占式执行,系统内核里实现的)。—罪魁祸首
- 两个线程在针对同一个变量进行修改。
(1)一个线程针对一个变量修改不会出现问题。
(2)两个线程针对不同变量修改不会出现问题。
(3)两个线程针对同一个变量进行读取不会出现问题。- 修改操作不是原子操作。
此处的count++就属于非原子操作。
非原子操作可通过加锁变为原子操作。- 内存可见性问题。
- 指令重排序问题。
要想解决线程安全问题就要从上述原因入手。
加锁—解决线程安全问题
如何给代码加锁?
使用synchronized关键字,搭配一个代码块{},同时指定一个“锁对象”,这个锁对象是什么不重要,重要的是通过这个锁对象来区分两个线程是否在竞争同一个锁。
在已经加锁的状态中,另一个线程尝试同样针对同一个对象加锁,就会产生“锁冲突/锁竞争”,后一个线程就会阻塞,一直等到前一个线程解锁为止。如果不是针对同一个对象加锁,就不会有锁竞争,仍然是并发执行。
给经典案例加锁
//线程安全
public class Demo{
static int count=0;
public static void main(String[] args) throws InterruptedException {
Object locker=new Object();
Thread t1=new Thread(()->{
//对count变量自增50000次
for(int i=0;i<50000;i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2=new Thread(()->{
//对count变量自增50000次
for(int i=0;i<50000;i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
运行结果为:
count=100000
按照这种方式编写代码,两个线程的执行过程就会相互影响。由于锁的竞争,lock操作会出现阻塞,阻塞到另一个线程unlock之后,该线程的lock才能执行完毕。
注意:一定要是针对同一个锁对象加锁,否则这两个线程就不会出现锁竞争,不会阻塞等待,线程安全问题仍然存在。
synchronized 除了修饰代码块之外,还可以修饰一个实例方法或一个静态方法。
实例方法线程不安全案例
class Counter{
public int count;
public void increase() {
count++;
}
}
//synchronized 使用方法
public class Demo{
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1=new Thread(()->{
for(int i=0;i<50000;i++) {
counter.increase();
}
});
Thread t2=new Thread(()->{
for(int i=0;i<50000;i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+counter.count);
}
}
运行结果为:
count=73857
给案例加锁
class Counter{
public int count;
//让synchronized 修饰一个方法,此时就是使用this作为锁对象
synchronized public void increase() {
count++;
}
/*等价于
* public void increase(){
* synchronized(this){
* count++;
* }
* }
* */
}
//synchronized 使用方法
public class Demo{
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1=new Thread(()->{
for(int i=0;i<50000;i++) {
counter.increase();
}
});
Thread t2=new Thread(()->{
for(int i=0;i<50000;i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+counter.count);
}
}
运行结果为:
count=100000
如果修饰静态方法,相当于是针对类对象加锁。
类对象在一个Java进程中,是唯一的。
synchronized public static void increase() {
}
/*等价于
* public static void increase(){
* //Counter.class---类对象
* synchronized(Counter.class){
*
* }
* }
* */
synchronized 底层特性:可重入锁
所谓的可重入锁,指的是,一个线程,连续针对同一把锁,加锁两次,不会出现死锁。
让锁记录一下,是哪个线程把这个锁对象锁住的,后续再加锁的时候,如果加锁线程就是持有锁的线程,就直接加锁成功。
synchronized(locker) {
synchronized(locker) {
}//(2)
}//(1)
问:上述代码中,synchronized 是可重入锁,没有因为第二次加锁而死锁,但是当代码执行到 }(2)时,锁是否应该释放?进一步的,如果上述加锁过程有n层,释放时机该如何判定?
不能释放。
无论此处有多少层,都要在最外层才能释放锁。
引用计数。锁对象中,不仅要记录是谁拿到了锁,还要记录,锁被加了几次。每加锁一次,计数器就 +1,每解锁一次,计数器就 -1,出了最外层的大括号,计数器为0,才真正释放锁。
死锁
死锁发生情况:
- 一个线程,针对一把锁,连续加锁两次,如果是不可重入锁,就死锁了。
- 两个线程,两把锁,循环等待互斥资源。(此时无论是不是可重入锁,都会发生死锁)
两个线程t1、t2,两把锁A、B;当前状况为t1获取了锁A,t2获取了锁B,此时t1尝试获取锁B,t2尝试获取锁A,两个线程循环等待对方释放锁,产生死锁。
比如说家门钥匙锁在车里,车钥匙锁在家里了。- n个线程,m把锁。此时更容易出现死锁。
经典描述:哲学家就餐问题。
哲学家就餐问题:
- 场景描述:
一张圆桌周围坐着 5 位哲学家,相邻两位哲学家中间放一根筷子,总共 5 根筷子。
哲学家只有两种行为:思考、吃面;吃面必须同时拿到左手、右手两根筷子,吃完后将两根筷子放回原位,继续思考。
模拟并发逻辑:每位哲学家对应一个独立线程,每根筷子是共享锁资源,哲学家拿筷子等价于线程获取锁。
默认规则:每位哲学家吃面时,先拿左手筷子,再拿右手筷子。- 死锁发生过程:
若同一时刻 5 位哲学家同时饥饿,全部执行“先拿左手筷子”操作:
每位哲学家都成功抢占左手筷子(拿到锁),此时 5 根筷子全部被占用;
接下来所有人尝试获取右手筷子,但右手筷子已经被隔壁哲学家持有,没有任何人能凑齐两根筷子吃面。
所有线程无限阻塞等待对方释放筷子,程序永久卡死,死锁形成。
死锁代码示例
public class Demo{
private static Object locker1=new Object();
private static Object locker2=new Object();
public static void main(String[] args) {
Thread t1=new Thread(()->{
synchronized (locker1) {
//sleep 很重要,要确保t1和t2都分别拿到了一把锁之后,再进行后续动作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO 自动生成的 catch 块
e.printStackTrace();
}
synchronized(locker2) {
System.out.println("t1 加锁成功");
}
}
});
Thread t2=new Thread(()->{
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO 自动生成的 catch 块
e.printStackTrace();
}
synchronized(locker1) {
System.out.println("t2 加锁成功");
}
}
});
t1.start();
t2.start();
}
}
死锁代码中,两个synchronized 是嵌套关系。
- 嵌套关系,是在占有一把锁的前提下,获取另一把锁,可能会出现死锁。
- 并列关系,则是先释放前面的锁,再获取下一把锁,不会出现死锁。
死锁会导致线程卡住,无法进行后续工作,那么该如何解决/避免死锁呢?
死锁的成因
死锁的成因涉及到四个必要条件:
- 互斥使用(锁的基本特性):
当一个线程持有一把锁之后,另一个线程也想获取锁,就要阻塞等待。- 不可抢占(锁的基本特性):
当锁被线程1拿到之后,线程2只能等线程1主动释放,不能强行抢过来。- 请求保持(代码结构):
一个线程尝试获取多把锁,先拿到锁1之后,再尝试获取锁2,获取时,锁1不会释放。—吃着碗里瞧着锅里。- 循环等待/环路等待(代码结构):
等待的依赖关系形成环了。
要想解决死锁,破坏至少一个上述4个条件即可,前两个是锁的基本特性,破坏不了,所以就可以调整代码结构,避免“锁嵌套”;约定加锁的顺序,避免循环等待。
volatile 关键字
volatile 两大核心作用:
- 保证内存可见性
- 禁止指令重排序
内存可见性
计算机运行的程序/代码,经常要访问数据,这些依赖的数据,往往会存储在内存中。
比如说,定义一个变量,变量就在内存中,CPU使用这个变量时,就会把这个内存中的数据,先读出来,放到CPU的寄存器中,再参与运算。
但是,CPU读取内存的这个操作,非常慢。
读取速度:读寄存器 >> 读内存 >> 读硬盘。
为了提高效率,编译器就可能对代码做出优化,把一些本来要读内存的操作,优化成读取寄存器,减少读内存的次数,也就可以提高整体程序的效率了。
代码示例
import java.util.Scanner;
public class Demo{
private static int isQuit=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
while(isQuit==0) {
}
System.out.println("t1 退出");
});
t1.start();
Thread t2=new Thread(()->{
System.out.println("请输入isQuit:");
Scanner scanner=new Scanner(System.in);
//预期效果:一旦用户输入的值不为0,t1线程就会结束
isQuit=scanner.nextInt();
});
t2.start();
}
}
但是,当用户输入1时,t1线程并没有结束。
是由多线程引起的线程安全问题。
之前是两个线程,同时修改一个变量。
此时是,一个线程读,一个线程修改,也可能会有问题。
此处的问题,就是“内存可见性”情况引起的。
线程t1的工作:
- load 读取内存中 isQuit 的值到寄存器中。
- 通过 cmp 指令比较寄存器的值是否为零,决定是否要循环。
- 由于这个循环速度飞快,短时间内,会进行大量的 load 和 cmp 操作。此时,编译器/JVM 就发现,虽然进行了这么多次 load,但是 load 出来的结果都一样,并且 load 操作非常耗费时间,所以编译器就做了一个大胆的决定—只是第一次循环时从内存读取 isQuit 的值,后续直接从寄存器读取isQuit,而不是内存。—编译器优化。
后续线程t2修改 isQuit 之后,t1感知不到 isQuit 的变化。—这个问题就称为“内存可见性”。
提高效率的前提是保证逻辑不变,由于修改 isQuit 代码是另一个线程的操作,编译器没有正确判定,以为没人修改 isQuit,就做出了上述优化,从而出现bug。
volatile 就是解决方案。
在多线程环境下,编译器对于是否要进行这样的优化,判定不一定准,就需要程序员通过 volatile 关键字,告诉编译器,你不要进行优化。
解决方案
用 volatile 关键字修饰变量,禁止编译器进行优化。
import java.util.Scanner;
public class Demo{
private volatile static int isQuit=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
while(isQuit==0) {
}
System.out.println("t1 退出");
});
t1.start();
Thread t2=new Thread(()->{
System.out.println("请输入isQuit:");
Scanner scanner=new Scanner(System.in);
//预期效果:一旦用户输入的值不为0,t1线程就会结束
isQuit=scanner.nextInt();
});
t2.start();
}
}
运行结果为:
请输入isQuit:
1
t1 退出
主内存 vs 工作内存
关于内存可见性,还涉及到一个关键概念,JVM(Java Memory Model,Java 内存模型),它把内存分为两块:主内存和工作内存。
主内存和工作内存,其实就是,内存和寄存器的另一种说法。
对于上述代码的理解就是,t1线程,对应 isQuit 变量,本身是在主内存中的,由于此处的优化,就会把 isQuit 变量放到工作内存中,进一步的,当t2线程修改主内存中的 isQuit 时,不会影响到t1的工作内存。
volatile 和 synchronized 都能对线程安全起到一定的积极作用,但是,它们各司其职,volatile 是不能保证原子性的。
wait & notify
wait & notify
- 它们是用来协调多个线程的执行顺序的。
之前的 join 也是一种干预方式,但是它是影响线程结束的先后顺序。- wait—等待
让指定线程进入阻塞状态。- notify—通知
唤醒对应的阻塞状态的线程。- wait 和 notify 都是 Object 的方法,随便定义一个对象都可以使用 wait、notify。
wait 方法
wait 在执行时,要做三件事:
- 释放当前的锁。
- 让线程进入阻塞。
- 当线程被唤醒时,重新获取到锁。
而释放锁的前提是,先加上锁。
代码示例
public class Demo{
public static void main(String[] args) throws InterruptedException {
Object object=new Object();
System.out.println("wait 之前");
object.wait();
System.out.println("wait 之后");
}
}
运行结果:
wait 之前
Exception in thread “main” java.lang.IllegalMonitorStateException
at java.lang.Object.wait(Native Method)
at java.lang.Object.wait(Unknown Source)
at Demo.main(Demo.java:210)
报出的异常为非法锁状态。
代码改正
public class Demo{
public static void main(String[] args) throws InterruptedException {
Object object=new Object();
synchronized (object) {
System.out.println("wait 之前");
//把 wait 要放到 synchronized 里面来调用,保证确实是拿到了锁的
object.wait();//触发阻塞等待,直到其他线程调用notify唤醒
System.out.println("wait 之后");
}
}
}
wait 除了无参版本,还有一个带参数的版本。
带参数的版本就是指定超时时间,避免wait无休止的等待下去。
notify 方法
notify 方法是唤醒等待的线程。
核心规则:wait/notify 必须写在 synchronized 同步代码块内,否则抛异常 IllegalMonitorStateException
代码示例
public class Demo{
public static void main(String[] args) {
Object object=new Object();
Thread t1=new Thread(()->{
synchronized (object) {
System.out.println("wait 之前");
//把 wait 要放到 synchronized 里面来调用,保证确实是拿到了锁的
try {
object.wait();
} catch (InterruptedException e) {
// TODO 自动生成的 catch 块
e.printStackTrace();
}//触发阻塞等待,直到其他线程调用notify唤醒
System.out.println("wait 之后");
}
});
Thread t2=new Thread(()->{
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
// TODO 自动生成的 catch 块
e.printStackTrace();
}
synchronized (object) {
System.out.println("进行通知");
object.notify();
}
});
t1.start();
t2.start();
}
}
使用 wait notify 还可以避免“线程饿死”。
一个线程一直拿不到 CPU 执行机会,长期无法运行,一直等待,就叫线程饿死。
比如当n个线程针对同一把锁,线程1先加锁,再释放锁,这个过程在CPU上执行,线程1已经在CPU上了,没有调度过程,而其他n-1个线程等待锁,都是阻塞状态,没在CPU上执行,需要有一个系统调度的过程 => 可能会导致线程1重复加锁,又释放锁 => 线程饿死。
让线程1 wait,(wait内部本身就会释放锁,并且进入阻塞状态),线程1不会参与后续竞争,不会占据CPU,给其他线程提供机会。
notify vs notifyAll
notify:一次唤醒一个线程。
notifyAll:一次唤醒全部线程。
相比之下,notify更可控。
全文总结
- 多线程抢占式调度、共享变量修改、非原子操作,是产生线程安全问题的核心原因,日常并发编程一定要重点规避。
- synchronized同步锁可以保证代码原子性,分为同步代码块、同步实例方法、同步静态方法三种用法,同时它是可重入锁,同一线程多次加锁不会出现死锁。
- 多把锁嵌套使用极易引发死锁,死锁必须同时满足四大必要条件,开发中只需统一加锁顺序、减少锁嵌套,就能有效避免死锁发生。
- volatile关键字主要解决内存可见性和指令重排序问题,但是它无法保证操作的原子性,不能替代synchronized实现加锁同步。
- wait和notify用于线程之间的通信协作,调用必须依赖同步锁,执行wait会主动释放锁,可以有效避免线程饥饿问题;notify唤醒单个等待线程,notifyAll唤醒全部等待线程。
本文结合案例讲解了 Java 并发高频核心知识点,配套代码可直接运行。后续会继续更新 Java 多线程相关内容,欢迎点赞收藏、一起交流学习!
🔗 系列文章导航
本篇是「Java并发编程系列」的连载内容,点击链接查看完整系列:
🔹 上一篇:Java并发编程(三)|线程API:如何让你的线程“听话执行”
🔹 下一篇:Java 并发编程(五)|四大并发实战:单例、阻塞队列、定时器与线程池
👉 点击直达「Java并发编程」专栏合集
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)