3、共享模型之管程

本文详细介绍了多线程环境下共享资源的临界区问题及其解决方案,重点讨论了Java中`synchronized`关键字的使用,包括同步互斥、线程安全变量分析和Monitor概念。同时,讲解了`wait/notify`、`Park & Unpark`以及`ReentrantLock`等线程同步机制,深入剖析了相关原理和应用场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本章小结

本章我们需要重点掌握的是

  • 分析多线程访问共享资源时,哪些代码片段属于临界区
  • 使用 synchronized 互斥解决临界区的线程安全问题
    • 掌握 synchronized 锁对象语法
    • 掌握 synchronzied 加载成员方法和静态方法语法
    • 掌握 wait/notify 同步方法
  • 使用 lock 互斥解决临界区的线程安全问题
    • 掌握 lock 的使用细节:可打断、锁超时、公平锁、条件变量
  • 学会分析变量的线程安全性、掌握常见线程安全类的使用
  • 了解线程活跃性问题:死锁、活锁、饥饿
  • 应用方面
    • 互斥:使用 synchronized 或 Lock 达到共享资源互斥效果
    • 同步:使用 wait/notify 或 Lock 的条件变量来达到线程间通信效果
  • 原理方面
    • monitor、synchronized 、wait/notify 原理
    • synchronized 进阶原理
    • park & unpark 原理
  • 模式方面
    • 同步模式之保护性暂停
    • 异步模式之生产者消费者
    • 同步模式之顺序控制

3.1 共享带来的问题

Java 的体现

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

static int counter = 0;
public static void main(String[] args) throws InterruptedException {
	Thread t1 = new Thread(() -> {
		for (int i = 0; i < 5000; i++) {
			counter++;
		 }
	 }, "t1");
	 
	Thread t2 = new Thread(() -> {
		for (int i = 0; i < 5000; i++) {
			counter--;
		 }
	 }, "t2");
	 
	t1.start();
	t2.start();
	t1.join();
	t2.join();
	log.debug("{}",counter);
}

问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i

而对应 i--也是类似:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

线程1static igetstatic i 读取 0iconst_1 准备常数 1iadd 加法, 线程内 i = 1putstatic i 写入 1getstatic i 读取 1iconst_1 准备常数 1isub 减法, 线程内 i = 0putstatic i 写入 0线程1static i

但多线程下这 8 行代码可能交错运行:

出现负数的情况:

线程1线程2static igetstatic i 读取 0iconst_1 准备常数 1isub 减法, 线程内 i = -1上下文切换getstatic i 读取 0iconst_1 准备常数 1iadd 加法, 线程内 i = 1putstatic i 写入 1上下文切换putstatic i 写入 -1线程1线程2static i

出现正数的情况:

线程1线程2static igetstatic i 读取 0iconst_1 准备常数 1iadd 加法, 线程内 i = 1上下文切换getstatic i 读取 0iconst_1 准备常数 1isub 减法, 线程内 i = -1putstatic i 写入 -1上下文切换putstatic i 写入 1线程1线程2static i

临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

例如,下面代码中的临界区

static int counter = 0;

static void increment() 
// 临界区
{ 
	counter++; }
	
static void decrement() 
// 临界区
{ 
	counter--; 
}

竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

3.2 synchronized 解决方案

* 应用之互斥

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量

本次课使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换

注意
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

synchronized

语法

synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}

解决

static int counter = 0;
static final Object room = new Object();

public static void main(String[] args) throws InterruptedException {
	Thread t1 = new Thread(() -> {
		for (int i = 0; i < 5000; i++) {
			synchronized (room) {
				counter++;
			 }
		 }
	 }, "t1");
	 
	Thread t2 = new Thread(() -> {
		for (int i = 0; i < 5000; i++) {
			synchronized (room) {
				counter--;
			 }
		 }
	 }, "t2");
	 
	t1.start();
	t2.start();
	t1.join();
	t2.join();
	log.debug("{}",counter);
}

你可以做这样的类比:

  • synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人
    进行计算,线程 t1,t2 想象成两个人
  • 当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行
    count++ 代码
  • 这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了
  • 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
  • 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥
    匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count-- 代码

用图来表示

线程1线程2static i锁对象尝试获取锁拥有锁getstatic i 读取 0iconst_1 准备常数 1isub 减法, 线程内 i = -1上下文切换尝试获取锁,被阻塞(BLOCKED)上下文切换putstatic i 写入 -1拥有锁释放锁,并唤醒阻塞的线程拥有锁getstatic i 读取 -1iconst_1 准备常数 1iadd 加法, 线程内 i = 0putstatic i 写入 0拥有锁释放锁,并唤醒阻塞的线程线程1线程2static i锁对象

思考

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

为了加深理解,请思考下面的问题

  • 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
  • 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象
  • 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象

3.3 方法上的 synchronized

普通方法

class Test{
	public synchronized void test() {
	
	}
}

// 等价于

class Test{
	public void test() {
		synchronized(this) {
		
		}
	}
}

静态方法

class Test{
	public synchronized static void test() {
	
	}
}

// 等价于

class Test{
	public static void test() {
		synchronized(Test.class) {
		
		}
	}
}

3.4 变量的线程安全分析

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全?

  • 局部变量是线程安全的
  • 但局部变量引用的对象则未必
    • 如果该对象没有逃离方法的作用访问,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全

常见线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的

  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的,见后面分析

不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的

3.5 Monitor 概念

Java 对象头

以 32 位虚拟机为例

普通对象

在这里插入图片描述
数组对象

在这里插入图片描述
其中 Mark Word 结构为

在这里插入图片描述

* 原理之 Monitor(锁)

* 原理之 synchronized

* 原理之 synchronized 进阶

3.6、wait notify

* 原理之 wait / notify

API 介绍

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待
  • obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
  • obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒

它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法

* 模式之保护性暂停

* 模式之生产者消费者

3.7、Park & Unpark

基本使用

它们是 LockSupport 类中的方法

// 暂停当前线程
LockSupport.park(); 
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
  • 先 park 再 unpark
Thread t1 = new Thread(() -> {
	log.debug("start...");
	sleep(1);
	log.debug("park...");
	LockSupport.park();
	log.debug("resume...");
},"t1");
t1.start();

sleep(2);
log.debug("unpark...");
LockSupport.unpark(t1);

输出

18:42:52.585 c.TestParkUnpark [t1] - start... 
18:42:53.589 c.TestParkUnpark [t1] - park... 
18:42:54.583 c.TestParkUnpark [main] - unpark... 
18:42:54.583 c.TestParkUnpark [t1] - resume...
  • 先 unpark 再 park
Thread t1 = new Thread(() -> {
	log.debug("start...");
	sleep(2);
	log.debug("park...");
	LockSupport.park();
	log.debug("resume...");
}, "t1");

t1.start();
sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);

输出

18:43:50.765 c.TestParkUnpark [t1] - start... 
18:43:51.764 c.TestParkUnpark [main] - unpark... 
18:43:52.769 c.TestParkUnpark [t1] - park... 
18:43:52.769 c.TestParkUnpark [t1] - resume...

特点

与 Object 的 wait & notify 相比

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify

3.8、重新理解线程状态转换

在这里插入图片描述

假设有线程 Thread t

情况 1 NEW --> RUNNABLE

  • 当调用 t.start() 方法时,由 NEW --> RUNNABLE

情况 2 RUNNABLE <--> WAITING

t 线程用 synchronized(obj) 获取了对象锁后

  • 调用 obj.wait() 方法时,t 线程从 RUNNABLE --> WAITING
  • 调用 obj.notify() , obj.notifyAll() , t.interrupt() 时
    • 竞争锁成功,t 线程从 WAITING --> RUNNABLE
    • 竞争锁失败,t 线程从 WAITING --> BLOCKED

情况 3 RUNNABLE <--> WAITING

  • 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE --> WAITING
    • 注意是当前线程在t 线程对象的监视器上等待
  • t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE

情况 4 RUNNABLE <--> WAITING

  • 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE --> WAITING
  • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE

情况 5 RUNNABLE <--> TIMED_WAITING

t 线程用 synchronized(obj) 获取了对象锁后

  • 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
  • t 线程等待时间超过了 n 毫秒,或调用 obj.notify() , obj.notifyAll() , t.interrupt() 时
    • 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
    • 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED

情况 6 RUNNABLE <--> TIMED_WAITING

  • 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING
    • 注意是当前线程在t 线程对象的监视器上等待
  • 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从TIMED_WAITING --> RUNNABLE

情况 7 RUNNABLE <--> TIMED_WAITING

  • 当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
  • 当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING --> RUNNABLE

情况 8 RUNNABLE <--> TIMED_WAITING

  • 当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
  • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从TIMED_WAITING--> RUNNABLE

情况 9 RUNNABLE <--> BLOCKED

  • t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE --> BLOCKED
  • 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED --> RUNNABLE ,其它失败的线程仍然 BLOCKED

情况 10 RUNNABLE <--> TERMINATED

  • 当前线程所有代码运行完毕,进入 TERMINATED

3.9、ReentrantLock

相对于 synchronized 它具备如下特点

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量

与 synchronized 一样,都支持可重入

基本语法

// 获取锁
reentrantLock.lock();
try {
	// 临界区
} finally {
	// 释放锁
	reentrantLock.unlock();
}

可重入

可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

可打断

try {
	// 获可打断取锁
	lock.lockInterruptibly();
	// 临界区
 } catch (InterruptedException e) {
	e.printStackTrace();
	log.debug("等锁的过程中被打断");
	return;
 } finally {
	// 释放锁
	lock.unlock();
 }

注意如果是不可中断模式,那么即使使用了 interrupt 也不会让等待中断

锁超时

  • 立刻失败
log.debug("启动...");
if (!lock.tryLock()) {
	log.debug("获取立刻失败,返回");
	return;
 }
try {
	log.debug("获得了锁");
 } finally {
	lock.unlock();
 }
  • 超时失败
log.debug("启动...");
try {
	if (!lock.tryLock(1, TimeUnit.SECONDS)) {
		log.debug("获取等待 1s 后失败,返回");
		return;
 	}
 } catch (InterruptedException e) {
	e.printStackTrace();
 }
try {
	log.debug("获得了锁");
 } finally {
	lock.unlock();
 }

公平锁

ReentrantLock 默认是不公平的,通过 new ReentrantLock(true); 设置公平锁

公平锁一般没有必要,会降低并发度,后面分析原理时会讲解

条件变量

synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待

ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比

  • synchronized 是那些不满足条件的线程都在一间休息室等消息
  • 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒

使用要点:

  • await 前需要获得锁
  • await 执行后,会释放锁,进入 conditionObject 等待
  • await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
  • 竞争 lock 锁成功后,从 await 后继续执行

* 同步模式之顺序控制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值