前言

承接上一篇 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角度,是由三个指令完成的:

  1. load,把数据从内存读到CPU寄存器中。
  2. add,把寄存器中的数据进行+1。
  3. save,把寄存器中的数据,保存到内存中。

如果是多个线程同时执行上述代码,由于线程之间的调度顺序是“随机”的,就会导致在有些调度顺序下,上述的逻辑就会出现问题。
在这里插入图片描述
此处只画出了这四种情况,但整个过程存在无数种情况,可能存在t1执行了一次,而t2执行了n次。

结合上述情况的讨论,不难看出,在多线程程序中,线程的随机调度,会使得两个线程执行逻辑的先后顺序存在多种可能。因此,我们必须要保证在所有可能的情况下,代码都是正确的。

产生线程安全问题的原因

  1. 操作系统中,线程的调度顺序是随机的(抢占式执行,系统内核里实现的)。—罪魁祸首
  2. 两个线程在针对同一个变量进行修改
    (1)一个线程针对一个变量修改不会出现问题。
    (2)两个线程针对不同变量修改不会出现问题。
    (3)两个线程针对同一个变量进行读取不会出现问题。
  3. 修改操作不是原子操作。
    此处的count++就属于非原子操作。
    非原子操作可通过加锁变为原子操作
  4. 内存可见性问题。
  5. 指令重排序问题。

要想解决线程安全问题就要从上述原因入手。

加锁—解决线程安全问题

如何给代码加锁?

使用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,才真正释放锁。

死锁

死锁发生情况:

  1. 一个线程,针对一把锁,连续加锁两次,如果是不可重入锁,就死锁了。
  2. 两个线程,两把锁,循环等待互斥资源。(此时无论是不是可重入锁,都会发生死锁)
    两个线程t1、t2,两把锁A、B;当前状况为t1获取了锁A,t2获取了锁B,此时t1尝试获取锁B,t2尝试获取锁A,两个线程循环等待对方释放锁,产生死锁。
    比如说家门钥匙锁在车里,车钥匙锁在家里了。
  3. n个线程,m把锁。此时更容易出现死锁。
    经典描述:哲学家就餐问题。

哲学家就餐问题:

  1. 场景描述:
    一张圆桌周围坐着 5 位哲学家,相邻两位哲学家中间放一根筷子,总共 5 根筷子。
    哲学家只有两种行为:思考、吃面;吃面必须同时拿到左手、右手两根筷子,吃完后将两根筷子放回原位,继续思考。
    模拟并发逻辑:每位哲学家对应一个独立线程,每根筷子是共享锁资源,哲学家拿筷子等价于线程获取锁。
    默认规则:每位哲学家吃面时,先拿左手筷子,再拿右手筷子。
  2. 死锁发生过程:
    若同一时刻 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. 互斥使用(锁的基本特性):
    当一个线程持有一把锁之后,另一个线程也想获取锁,就要阻塞等待。
  2. 不可抢占(锁的基本特性):
    当锁被线程1拿到之后,线程2只能等线程1主动释放,不能强行抢过来。
  3. 请求保持(代码结构):
    一个线程尝试获取多把锁,先拿到锁1之后,再尝试获取锁2,获取时,锁1不会释放。—吃着碗里瞧着锅里。
  4. 循环等待/环路等待(代码结构):
    等待的依赖关系形成环了。

要想解决死锁,破坏至少一个上述4个条件即可,前两个是锁的基本特性,破坏不了,所以就可以调整代码结构,避免“锁嵌套”;约定加锁的顺序,避免循环等待。

volatile 关键字

volatile 两大核心作用:

  1. 保证内存可见性
  2. 禁止指令重排序

内存可见性

计算机运行的程序/代码,经常要访问数据,这些依赖的数据,往往会存储在内存中。

比如说,定义一个变量,变量就在内存中,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的工作:

  1. load 读取内存中 isQuit 的值到寄存器中。
  2. 通过 cmp 指令比较寄存器的值是否为零,决定是否要循环。
  3. 由于这个循环速度飞快,短时间内,会进行大量的 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

  1. 它们是用来协调多个线程的执行顺序的。
    之前的 join 也是一种干预方式,但是它是影响线程结束的先后顺序。
  2. wait—等待
    让指定线程进入阻塞状态。
  3. notify—通知
    唤醒对应的阻塞状态的线程。
  4. wait 和 notify 都是 Object 的方法,随便定义一个对象都可以使用 wait、notify。

wait 方法

wait 在执行时,要做三件事:

  1. 释放当前的锁。
  2. 让线程进入阻塞。
  3. 当线程被唤醒时,重新获取到锁。

而释放锁的前提是,先加上锁。

代码示例

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更可控。

全文总结

  1. 多线程抢占式调度、共享变量修改、非原子操作,是产生线程安全问题的核心原因,日常并发编程一定要重点规避。
  2. synchronized同步锁可以保证代码原子性,分为同步代码块、同步实例方法、同步静态方法三种用法,同时它是可重入锁,同一线程多次加锁不会出现死锁。
  3. 多把锁嵌套使用极易引发死锁,死锁必须同时满足四大必要条件,开发中只需统一加锁顺序、减少锁嵌套,就能有效避免死锁发生。
  4. volatile关键字主要解决内存可见性和指令重排序问题,但是它无法保证操作的原子性,不能替代synchronized实现加锁同步。
  5. wait和notify用于线程之间的通信协作,调用必须依赖同步锁,执行wait会主动释放锁,可以有效避免线程饥饿问题;notify唤醒单个等待线程,notifyAll唤醒全部等待线程。

本文结合案例讲解了 Java 并发高频核心知识点,配套代码可直接运行。后续会继续更新 Java 多线程相关内容,欢迎点赞收藏、一起交流学习!

🔗 系列文章导航

本篇是「Java并发编程系列」的连载内容,点击链接查看完整系列:

🔹 上一篇:Java并发编程(三)|线程API:如何让你的线程“听话执行”
🔹 下一篇:Java 并发编程(五)|四大并发实战:单例、阻塞队列、定时器与线程池
👉 点击直达「Java并发编程」专栏合集

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐