一、概述
记录时间 [2024-11-11]
多线程 01:Java 多线程学习导航,线程简介,线程相关概念的整理
多线程 02:线程实现,创建线程的三种方式,通过多线程下载图片案例分析异同(Thread,Runnable,Callable)
多线程 03:知识补充,静态代理与 Lambda 表达式的相关介绍,及其在多线程方面的应用
多线程 04:线程状态,线程的五大基本状态及状态转换,以及线程使用方法、优先级、守护线程的相关知识
Java 多线程学习主要模块包括:线程简介;线程实现;线程状态;线程同步;线程通信问题;拓展高级主题。
本文讲述线程同步相关知识,包括线程同步机制,线程同步涉及的三大不安全案例(不安全买票 / 取款 / 集合),及这些案例的完善方法。同时,通过 synchronized(同步)和 Lock(锁),我们能解决多线程修改共享资源引起的访问冲突,实现线程同步。
此外,文章还介绍了死锁的知识,如死锁产生的条件,死锁的案例,以及如何避免死锁等。
二、线程同步机制
1. 相关概念
并发(Concurrency)是指计算机系统中多个任务在同一时间段内交错执行的能力。虽然这些任务可能不是真正同时进行的(即并行,Parallelism),但从宏观上看,它们似乎是在同一时间执行的。并发编程的目标是提高程序的效率和响应性,尤其是在处理 I/O 密集型任务或多用户环境时。
多线程是实现并发的一种常见方式。每个进程可以包含多个线程,这些线程共享进程的资源(如内存地址空间),但每个线程有自己的栈和程序计数器。在带来方便的同时,也带来了访问冲突的问题。
线程同步是多线程编程中的一个重要概念,它主要用于确保多个线程在访问共享资源(如变量或数据结构)时不会发生冲突。
简言之,多个线程操作同一个资源。
如果多个线程同时修改同一个资源而没有适当的同步机制,可能会导致数据损坏或其他不可预测的行为。
队列和锁是多线程编程中非常重要的同步机制。结合使用队列和锁可以有效地管理和协调多个线程之间的任务分配和资源共享,提高程序的可靠性和性能。
在 Java 中,为了保证数据在方法中被访问时的正确性,在访问时加入锁机制(Synchronized),当一个线程获取了某个对象的锁之后,它就拥有了对该资源的独占访问权,此时其他试图访问同一资源的线程将不得不等待。一旦该线程完成了对资源的操作并释放了锁,其他等待中的线程就可以继续尝试获取锁以访问资源。
需要注意的是,一个线程持有锁会导致其他所有需要此锁的线程挂起;且加锁 / 释放锁会导致比较多的上下文切换和调度延时,引起性能问题;如果优先级高的线程等待优先级低的线程释放锁,会导致性能倒置。属于是牺牲部分性能来保证安全性。
2. 举例说明
在现实生活中,多个线程同时操作同一个资源的例子有很多。
例如,抢票时,当后台仅剩 1 张票时,所有用户都能看到这张票。如果不对接入的抢票行为进行有效控制,每个用户都可能同时尝试抢票,从而导致超售现象。这种情况不仅会影响用户体验,还会给系统带来安全隐患。
在银行取款的场景中,当账户余额只剩下最后一笔资金时,所有持有账户的客户都能看到这笔余额。如果不对接入的取款请求进行有效的控制,每个客户都可能同时尝试取款,从而导致账户余额不足的问题。这种情况不仅会影响客户的体验,还可能引发金融风险和系统的不稳定性。
为了防止这种情况发生,银行系统通常会采用各种同步机制来确保每次取款操作的原子性和一致性。例如,使用互斥锁来保证在同一时间内只有一个取款请求能够访问账户余额,其余用户会进入等待队列,有序排队以免引起混乱。
抢票系统亦然。
三、不安全案例
下面例举三个多线程的并发问题案例,这些问题都指出了——线程同步机制的重要性。
分别是不安全买票、不安全取钱,以及不安全集合。
在多线程环境下,多个线程共享进程的资源,同时每个线程在自己的工作内存交互。它们会把进程共享的信息复制一份到自己的工作内存,然后处理这些东西,可能同时对进程的资源进行修改,从而导致了数据不一致问题。
1. 不安全买票
多线程抢票中存在的并发问题主要包括以下几点:
- 当多个线程试图同时更新同一份数据(例如剩余票数)时,如果没有适当的同步措施,可能导致数据的最终状态不符合预期。例如,两个线程几乎同时检查到还有 1 张票可用,两者都尝试购买这张票,最终可能导致这张票被卖出两次。
- 在某些情况下,如果调度算法不公平,某些线程可能永远得不到执行机会,尤其是当其他线程总是优先获得资源时。在抢票场景中,这可能导致部分用户永远无法成功购票。
- 在多线程环境下,如果对共享数据的操作不是原子性的,或者没有正确使用同步机制,可能会导致数据不一致,如数据丢失或数据损坏。
通过编写测试代码来更好地理解。
买票操作类
- 设置系统中剩余的票数;
- 完善具体的买票操作逻辑:判断是否有余票,卖出一张票总数减一;
- 设置多线程买票的入口,以及线程停止标志位。
// 1. 买票操作类
class BuyTicket implements Runnable {
// 2. 设置票数,私有属性安全
private int ticketNums = 10;
// 4. 设置线程停止标志位
private boolean flag = true;
@Override
public void run() {
// 7. 多线程买票的入口
while (flag) {
try {
buy();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 3. 具体的买票操作逻辑
private void buy() throws InterruptedException {
// 5. 如果票卖完了就结束
if (ticketNums <= 0) {
flag = false;
return;
}
// 8. 设置延时,增加问题的发生性
Thread.sleep(100);
// 6. 有票就卖
System.out.println(Thread.currentThread().getName() + "买到了票" + ticketNums--);
}
}
多线程启动类
- 创建三个线程,模拟三类用户抢票。
/*
不安全的买票
线程不安全,可能有负数,有拿到同一张票的情况
每个线程在自己的工作内存交互,内存控制不当会造成数据不一致
*/
public class UnsafeBuyTicket {
public static void main(String[] args) {
// 9. 创建三类多线程抢票用户
BuyTicket station = new BuyTicket();
new Thread(station,"小明").start();
new Thread(station,"元元").start();
new Thread(station,"黄牛党").start();
}
}
测试结果
从他们的抢票结果中不难发现,有买到重复票的,也有买到不正常的票的,此时的抢票程序是不安全的。
# 不安全,结果不唯一
元元买到了票10
黄牛党买到了票9
小明买到了票10
黄牛党买到了票8
元元买到了票6
小明买到了票7
小明买到了票5
黄牛党买到了票3
元元买到了票4
元元买到了票2
黄牛党买到了票1
小明买到了票0
2. 不安全取钱
在多线程环境中,不安全取钱的问题主要源于并发访问和修改共享资源(如银行账户余额)时缺乏适当的同步机制。这些问题可能导致数据不一致、资金丢失或重复扣款等严重后果。
例如,一个线程正在更新账户余额,而另一个线程在此期间读取了旧的余额值,并基于这个旧值进行进一步的操作。
通过编写测试代码来更好地理解。
银行账户类
银行账户中包含了账户名、卡内余额。
// 1. 银行账户
class Account {
// 卡里余额
private int money;
// 卡名
private String name;
public Account(int money, String name) {
this.money = money;
this.name = name;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
取钱业务类
银行取钱业务类,完善取款逻辑。
- 判断卡内余额是否充足,如果余额不足,则不能取款;
- 取款成功后更新账户余额。
// 2. 银行取钱业务
class Drawing extends Thread {
// 银行账户
private Account account;
// 待取金额
private int drawMoney;
// 手里的金额
private int nowMoney;
public Drawing(Account account, int drawMoney, String name) {
// 调用父类的构造函数,传入子类的名字
// 而这里的父类是线程 Thread 类,所以获取的线程名就是子类传入的名字
// getName() 是父类 Thread 的方法,子类使用父类的方法
// this.getName() == Thread.currentThread().getName(),表示线程名
super(name);
this.account = account;
this.drawMoney = drawMoney;
}
// 3. 取钱的逻辑,多线程取钱的入口
@Override
public void run() {
// 判断余额
if (account.getMoney() - drawMoney < 0) {
// this.getName() == Thread.currentThread().getName(),表示线程名
System.out.println(Thread.currentThread().getName() + "取款失败,余额不足");
return;
}
// 延时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 取钱
// 账户余额
account.setMoney(account.getMoney() - this.drawMoney);
// 手里的金额
this.nowMoney = this.nowMoney + this.drawMoney;
System.out.println(this.getName() + "取了" + this.drawMoney);
System.out.println(Thread.currentThread().getName() + "手里有" + this.nowMoney);
System.out.println(this.account.getName() + "账户余额为" + this.account.getMoney());
}
}
多线程启动类
模拟多个用户同时同账户取款。如你和委托人同时取出一张卡里的余额。
/*
不安全的取钱
两个人对同一个银行账户取钱
*/
public class UnsafeBank {
// 4. 两个用户对同一个账户取钱
public static void main(String[] args) {
Account account = new Account(100, "基础账户");
Drawing you = new Drawing(account, 50, "你");
Drawing another = new Drawing(account, 100, "委托人");
you.start();
another.start();
}
}
测试结果
观察取款后的到手金额、账户余额,不难发现金额总数对不上,是不安全的。
# 不安全,结果不唯一
委托人取了100
委托人手里有100
你取了50
基础账户账户余额为50
你手里有50
基础账户账户余额为50
3. 不安全集合
在多线程环境中,线程不安全的集合类可能会导致各种并发问题,如数据不一致、死锁、竞态条件等。
例如,启动 10000 个线程,每个线程尝试将自身的名称存入某个集合中,但最终集合中的内容数量少于 10000 个。原因在于多个线程同时运行时,存在重复写入的情况,把内容添加到了同一个位置,导致部分线程的写入操作被覆盖或忽略。
/*
线程不安全的集合
启动 10000 个线程,往某个集合中存入线程名称,实际集合内容小于 10000 个
原因:线程同时运行,存在重复写入的情况
*/
public class UnsafeList {
public static void main(String[] args) throws InterruptedException {
// 1. 新建集合
List<String> list = new ArrayList<String>();
// 2. 启动 1000 个线程
for (int i = 0; i < 10000; i++) {
new Thread(()->{
// 往某个集合中存入线程名称,追加数据
list.add(Thread.currentThread().getName());
}).start();
}
// 延时
Thread.sleep(5000);
// 3. 计算集合实际容量
System.out.println(list.size());
}
}
四、Synchronized(同步)
1. 相关概念
synchronized
是 Java 中用于线程同步的关键字。它提供了一种简单且有效的方法来确保多个线程在访问共享资源时不会发生冲突。
synchronized
关键字可以用于方法或代码块,确保在同一时间只有一个线程可以执行被标记为 synchronized
的代码段。
被 synchronized
声名为同步方法 / 同步代码块后,每个对象有一把锁,当一个线程获取了某个对象的锁之后,它就拥有了对该资源的独占访问权,此时其他试图访问同一资源的线程将不得不等待(阻塞)。一旦该线程完成了对资源的操作并释放了锁,其他等待中的线程就可以继续尝试获取锁以访问资源。
相当于本来是一窝蜂上去抢,用了 synchronized 就要排队,等上一个人用完了才能用。
注意,方法中需要修改的内容才需要锁,只读部分不用。锁太多了会造成资源浪费。
2. 使用方式
- 给方法添加修饰符,变成同步方法
// 同步方法默认锁的是 this,就是这个对象本身,或者是 class
private synchronized void buy() {}
- 用代码块确定被锁的对象,形成同步代码块
synchronized (obj) {
// obj 同步监视器,代表需要被监视的对象,推荐使用共享资源
// 中间包裹修改资源的代码(不安全的代码)
}
同步监视器(obj)的执行过程:
- 第一个线程访问,锁定同步监视器,执行其中代码;
- 第二个线程访问,发现同步监视器被锁定,无法访问;
- 第一个线程访问完毕,解锁同步监视器;
- 第二个线程访问,发现同步监视器没有锁,然后锁定并访问。
3. 完善不安全案例
接下来,我们通过 synchronized
同步来完善一下上面的三大不安全案例。
安全买票
将买票方法 buy()
变成同步方法即可。
/*
synchronized 同步方法
同步方法无需指定同步监视器
同步方法的同步监视器是 this,就是这个对象本身,或者是 class
*/
private synchronized void buy() throws InterruptedException {
// 5. 如果票卖完了就结束
if (ticketNums <= 0) {
flag = false;
return;
}
// 8. 设置延时,增加问题的发生性
Thread.sleep(100);
// 6. 有票就卖
System.out.println(Thread.currentThread().getName() + "买到了票" + ticketNums--);
}
安全的情况下的出票结果:
# 安全的情况(不唯一)
# 出票结果是正常的
元元买到了票10
元元买到了票9
小明买到了票8
小明买到了票7
小明买到了票6
黄牛党买到了票5
黄牛党买到了票4
小明买到了票3
小明买到了票2
小明买到了票1
安全取款
- 先确定同步监视器,是共享资源,这里是银行账户
account
- 锁的是变化的对象,需要增删改操作的对象,这里是银行账户
account
- 使用同步代码块,包裹修改账户的操作代码
synchronized (account) {}
/*
synchronized (obj)
锁的对象默认是本身 synchronized (this)
实际需要锁的是变化的对象,需要增删改操作的对象
obj 可以是任何对象,推荐使用共享资源作为同步监视器
所以这里我们需要锁住账户,而不是银行
*/
// 3. 取钱的逻辑,多线程取钱的入口
@Override
public void run() {
synchronized (account) {
// 判断余额
if (account.getMoney() - drawMoney < 0) {
// this.getName() == Thread.currentThread().getName(),表示线程名
System.out.println(Thread.currentThread().getName() + "取款失败,余额不足");
return;
}
// 延时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 取钱
// 账户余额
account.setMoney(account.getMoney() - this.drawMoney);
// 手里的金额
this.nowMoney = this.nowMoney + this.drawMoney;
System.out.println(this.getName() + "取了" + this.drawMoney);
// System.out.println(Thread.currentThread().getName() + "手里有" + this.nowMoney);
System.out.println(this.account.getName() + "账户余额为" + this.account.getMoney());
}
}
安全集合
- 锁的是变化的对象,需要增删改操作的对象,这里是集合
list
- 使用同步代码块,包裹集合操作代码
new Thread(()->{
synchronized (list) {
// 往某个集合中存入线程名称,追加数据
list.add(Thread.currentThread().getName());
}
}).start();
当然,Java 中提供了另一种方式(JUC),来确保多线程操作集合的安全性。
需要用到 Java 的并发包,前面线程创建使用的 Callable 接口也用到了这个包。
编写代码,测试 JUC 安全类型的集合。
import java.util.concurrent.CopyOnWriteArrayList;
// 测试 JUC 安全类型的集合
public class TestJUC {
public static void main(String[] args) throws InterruptedException {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<String>();
for (int i = 0; i < 10000; i++) {
new Thread(()->{
list.add(Thread.currentThread().getName());
}).start();
}
Thread.sleep(3000);
System.out.println(list.size());
}
}
五、Lock(锁)
从 JDK 5.0 开始,Java 提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。同步锁使用 Lock
对象充当。
Lock
是 Java 中 java.util.concurrent.locks
包提供的一个接口,用于实现更灵活和高级的锁机制,控制多个线程对共享资源进行访问。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock
对象加锁,线程开始访问共享资源之前,应先获得 Lock
对象。
与传统的 synchronized
关键字相比,Lock
接口提供了更多的功能和更好的性能特性。以下是 Lock
接口的主要特性和使用方法。
1. Lock 接口的主要特性
- 可重入性:
Lock
支持可重入锁,即一个线程可以多次获取同一个锁而不会导致死锁。 - 公平锁:可以指定锁是否为公平锁,公平锁会按照请求锁的顺序来分配锁,而非公平锁则不一定。
- 尝试锁定:可以尝试获取锁而不被阻塞,如果锁不可用则立即返回。
- 锁中断:可以中断一个正在等待锁的线程。
- 锁条件:可以与条件变量一起使用,实现更复杂的同步机制。
2. 常用的 Lock 实现
ReentrantLock
:最常用的Lock
实现,支持可重入锁、非公平锁 / 公平锁。它不仅拥有与synchronized
相同的并发性和内存语义,而且可以显式地加锁、释放锁。ReentrantReadWriteLock
:读写锁,允许多个读取者同时访问资源,但只允许一个写入者独占访问资源。
使用方法
定义 Look 锁,加锁和解锁。
需要用到 try-catch-finally
代码块。
// 定义 look 锁
private final ReentrantLock lock = new ReentrantLock();
try {
// 加锁
lock.lock();
{
// 不安全代码块
}
} finally {
// 解锁
lock.unlock();
}
3. 案例分析
给不安全的买票案例加上 Lock 锁,实现线程同步。
public class TestLock {
public static void main(String[] args) {
// 注意要给同一个对象创建多线程,不同对象用的资源可能不是同一份
TestLock2 testLock2 = new TestLock2();
for (int i = 0; i < 3; i++) {
new Thread(testLock2).start();
}
}
}
// 依旧是买票的例子
class TestLock2 implements Runnable {
int ticketNums = 10;
// 定义 look 锁
private final ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
try {
// 加锁
lock.lock();
if (ticketNums > 0) {
System.out.println(ticketNums--);
Thread.sleep(1000);
} else {
break;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 解锁
lock.unlock();
}
}
}
}
4. Synchronized / Lock
Lock
是显式锁,需要手动开启和关闭(别忘记关锁);synchronized
是隐式锁,出了作用域自动释放。Lock
只有代码块锁;synchronized
有代码块锁和方法锁。- 使用
Lock
锁,JVM 将花费较少的时间来调度线程,性能更好。且Lock
提供更多的子类,扩展性更好。 - 优先使用顺序:
Lock
> 同步代码块 > 同步方法- 同步代码块(已经进入了方法体,分配了相应资源)
- 同步方法(在方法体之外)
六、死锁
死锁(Deadlock)是指两个或多个线程在执行过程中由于竞争资源而造成的一种僵局,这些线程都在等待对方释放资源,结果导致所有涉及的线程都无法继续执行。
死锁是多线程编程中常见的问题之一,严重影响程序的性能和可靠性。
1. 四个必要条件
发生死锁需要同时满足四个条件,即死锁发生的四个必要条件:
- 互斥条件(Mutual Exclusion):资源不能被多个线程同时共享,即一个资源只能被一个线程占用。
- 占有并等待条件(Hold and Wait):一个线程已经持有一个资源,同时又申请新的资源,但新的资源已经被其他线程占用。
- 不可抢占条件(No Preemption):已经分配给线程的资源不能被强制释放,只能由占用它的线程自行释放。
- 循环等待条件(Circular Wait):存在一个线程等待链,形成一个闭环,即每个线程都在等待下一个线程持有的资源。
2. 案例分析
某一个同步块同时拥有两个以上对象的锁时,就有可能发生死锁问题。
例如,模拟一下化妆过程,假设有两个线程灰姑凉和白雪公主,分别需要访问两个资源口红和镜子。
- 灰姑凉先获取了口红的锁,白雪公主先获取了镜子的锁。
- 然后灰姑凉在持有口红的同时,尝试获取镜子的锁;白雪公主在持有镜子的同时,尝试获取口红的锁。
- 结果两个线程都在等待对方释放资源,形成了死锁。
// 死锁:多个线程抱着对方需要的资源,然后形成僵持
public class DeadlockExample2 {
public static void main(String[] args) {
Makeup girl1 = new Makeup(0, "灰姑凉");
Makeup girl2 = new Makeup(1, "白雪公主");
girl1.start();
girl2.start();
}
}
// 1. 口红类
class Lipstick {
}
// 2. 镜子类
class Mirror {
}
// 3. 化妆类
class Makeup extends Thread {
// 资源只有一份,用 static 来保证只有一份
static Lipstick lipstick = new Lipstick();
static Mirror mirror = new Mirror();
// 选择
int choice;
// String girlName;
Makeup(int choice, String girlName) {
// 使用化妆品的人
super(girlName);
this.choice = choice;
}
@Override
public void run() {
try {
makeup();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 化妆方法,互相持有对方的锁,就是要拿到对方的资源
void makeup() throws InterruptedException {
if (choice == 0) {
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
Thread.sleep(1000);
System.out.println(this.getName() + "等待镜子的锁......");
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
}
}
} else {
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
Thread.sleep(2000);
System.out.println(this.getName() + "等待口红的锁......");
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
}
}
}
}
}
观察测试结果:
- 两个线程各自持有一把锁;
- 两个线程都在等待对方持有的锁;
- 发生了死锁。
灰姑凉获得了口红的锁
白雪公主获得了镜子的锁
灰姑凉等待镜子的锁......
白雪公主等待口红的锁......
3. 避免死锁的策略
只要破坏四个必要条件中的任意一个或多个,就能避免死锁发生。
- 破坏互斥条件:尽量减少资源的互斥访问,使用不可变对象或线程安全的数据结构。
- 破坏占有并等待条件:要求线程在申请新资源之前释放已持有的资源。
- 破坏不可抢占条件:允许强制释放资源,但这通常很难实现。
- 破坏循环等待条件:对资源进行编号,要求线程按照编号顺序申请资源。
例如,我们对上述死锁案例进行修改,破坏占有并等待条件,要求线程在申请新资源之前释放已持有的资源。
当灰姑凉释放了口红锁之后,才能请求镜子的锁;当白雪公主释放了镜子锁之后,才能请求口红的锁。
// 当灰姑凉释放了口红锁之后,才能请求镜子的锁;当白雪公主释放了镜子锁之后,才能请求口红的锁。
void makeup() throws InterruptedException {
if (choice == 0) {
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
Thread.sleep(1000);
System.out.println(this.getName() + "等待镜子的锁......");
}
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
}
} else {
synchronized (mirror) {
System.out.println(this.getName() + "获得了镜子的锁");
Thread.sleep(2000);
System.out.println(this.getName() + "等待口红的锁......");
}
synchronized (lipstick) {
System.out.println(this.getName() + "获得了口红的锁");
}
}
}
结果如下,避免了死锁。
灰姑凉获得了口红的锁
白雪公主获得了镜子的锁
灰姑凉等待镜子的锁......
白雪公主等待口红的锁......
白雪公主获得了口红的锁
灰姑凉获得了镜子的锁
参考资料
狂神说 Java 多线程:https://www.bilibili.com/video/BV1V4411p7EF
TIOBE 编程语言走势: https://www.tiobe.com/tiobe-index/
Typora 官网:https://www.typoraio.cn/
Oracle 官网:https://www.oracle.com/
Notepad++ 下载地址:https://notepad-plus.en.softonic.com/
IDEA 官网:https://www.jetbrains.com.cn/idea/
Java 开发手册:https://developer.aliyun.com/ebook/394
Java 8 帮助文档:https://docs.oracle.com/javase/8/docs/api/
MVN 仓库:https://mvnrepository.com/