我们在这篇文章中主要讨论如何使用互斥锁来解决并发编程中的原子性问题。

概述

并发编程中的原子性问题的源头是线程切换,那么禁止线程切换可以解决原子性问题吗?

这需要分情况讨论,在单核CPU的情况下,同一时刻只有一个线程执行,禁止CPU中断,就意味着操作系统不会重新调度线程,也就禁止了线程切换,这样获取CPU使用权的线程就可以不间断的执行。

在多核CPU的情况下,同一时刻,有可能有两个线程同时执行,一个线程执行在CPU-1上,另外一个线程执行在CPU-2上,这时禁止CPU中断,只能保证某一个CPU上的线程连续执行,但并不能保证只有一个线程在运行。

同一时刻只有一个线程执行,我们称之为互斥,如果我们能够保证对共享变量的修改是互斥的,那么无论是单核CPU还是多核CPU,就都能保证原子性了。

如何能做到呢?答案就是互斥锁。

互斥锁模型

互斥锁简易模型

当我们谈论互斥锁时,我们一般会把一段需要互斥执行的代码称为临界区,下面是一个简单的示意图。



当线程进入临界区之前,首先尝试加锁,如果成功,可以进去临界区,如果失败,需要等待。当临界区的代码被执行完毕或者发生异常时,线程释放锁。

互斥锁改进模型

上面的模型虽然直观,但是过于简单,我们需要考虑2个问题:

  • 我们锁的是什么?
  • 我们保护的又是什么?

在现实世界中,锁和锁要保护的资源是有对应关系的,通俗的讲,你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。

在并发编程的世界中,锁和资源也应该有类似的对应关系。

下面是改进后的锁模型。



首先,我们要把临界区中要保护的资源R标注出来,然后,我们为资源R创建一个锁LR,最后,在我们进入和离开临界区时,需要对锁LR进行加锁和解锁操作。

通过这样的处理,我们就在锁和资源之间建立了关联关系,不会出现类似于“用我家的锁去保护你家的资源”的问题。

Java世界中的互斥锁

在Java语言中,我们通过synchronized关键字来实现互斥锁。

synchronized关键字可以应用在方法上,也可以直接应用在代码块中。

我们来看下面的示例代码。

public class SynchronizedDemo {

	// 修饰实例方法
synchronized void updateData() {
// 业务代码
} // 修饰静态方法
synchronized static void retrieveData() {
// 业务代码
} // 修饰代码块
Object obj = new Object(); void createData() {
synchronized(obj) {
// 业务代码
}
}
}

和我们描述的互斥锁模型相比,我们并没有在上述代码中看到加锁和解锁相关的代码,这是因为Java编译器已经自动为我们在synchronized关键字修改的方法或者代码块前后添加了加锁和解锁逻辑。这样做的好处是我们不用担心执行加锁操作后,忘了解锁操作。

synchronized中的锁和锁对象

我们在使用synchronized关键字时,它锁定的对象是什么呢?如果没有显式指定锁对象,Java有如下默认规则

  • 当修饰静态方法时,锁定的是当前类的Class对象。
  • 当修饰非静态方法时,锁定的是当前实例对象this。

根据上述规则,下面的代码是等价的。

// 修饰实例方法
synchronized void updateData() {
// 业务代码
} // 修饰实例方法
synchronized(this) void updateData2() {
// 业务代码
}
	// 修饰静态方法
synchronized static void retrieveData() {
// 业务代码
} // 修饰静态方法
synchronized(SynchronizedDemo.class) static void retrieveData2() {
// 业务代码
}

synchronized示例

我们在之前的文章中描述过count=count+1的例子,当时没有做并发控制,结果引发了原子性问题,我们现在看一下,如何使用synchronized关键字来解决并发问题。

首先我们来复习一下Happens-Before规则,synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码,而Happens-Before中的“对一个锁解锁Happens-Before后续对这个锁的加锁”,指的是前一个线程解锁操作对后一个线程的加锁操作是可见的,然后结合Happens-Before传递性原则,我们可以得出前一个线程在临界区修改的共享变量,对于后续完成加锁进入临界区的线程是可见的。

下面是修改后的代码:

public class ConcurrencySafeAddDemo {

	private long count = 0;

	private synchronized void safeAdd() {
int index = 0;
while (index < 10000) {
count = count + 1;
index++;
}
} private void reset() {
this.count = 0;
} private void addTest() throws InterruptedException { List<Thread> threads = new ArrayList<Thread>(); for (int i = 0; i < 6; i++) {
threads.add(new Thread(() -> {
this.safeAdd();
}));
} for (Thread thread : threads) {
thread.start();
} for (Thread thread : threads) {
thread.join();
} threads.clear(); System.out.println(String.format("Count is %s", count));
} public static void main(String[] args) throws InterruptedException {
ConcurrencySafeAddDemo demoObj = new ConcurrencySafeAddDemo();
for (int i = 0; i < 10; i++) {
demoObj.addTest();
demoObj.reset();
}
}
}

执行结果如下。

Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000

这里和我们的预期是一致的。

和第一版的代码相比,我们只是用synchronized关键字修饰了safeAdd()方法。

锁与受保护的资源的关系

对于互斥锁来说,锁与受保护的资源之间的关联关系非常重要,那么这两者之间到底是什么关系呢?一个合理的解释是:锁与受保护的资源之间是N:1的关系,也就是说:

  • 一个锁可以应用到多个受保护资源
  • 一个受保护资源上只能有一个锁

我们可以用球赛门票来做类比,其中座位是资源,门票是锁。一个座位只能用一张门票来保护,如果是“包场”的情况,一张包场门票就可以对应多个座位。不会出现一个座位有多张门票的情况。

同理,在互斥锁的场景下,如果两个锁使用了不同的锁对象,那么这两个所对应的临界区不是互斥的。 这一点很重要,忽视它的话,很容易引发莫名其妙的并发问题。

例如,我们把上面示例代码中的safeAdd()方法改成下面的样子,它还能正常工作吗?

	private void safeAdd() {
int index = 0;
synchronized(new Object()) {
while (index < 10000) {
count = count + 1;
index++;
}
}
}

这里,我们在为synchronized关键字设置锁对象时,每次都新建一个Object对象,那么每个线程在运行到这里时,都是使用不同的锁对象,那么临界区中的代码就不是互斥的,最后得出的结果也不会是我们期望的。

Count is 17355
Count is 18215
Count is 19244
Count is 20863
Count is 60000
Count is 60000
Count is 60000
Count is 20430
Count is 60000
Count is 60000

一个锁保护多个资源

上面我们谈到一个互斥锁可以保护多个资源,但是一个资源不可以被多个互斥锁保护。

那么,我们如何用一个锁来保护多个资源呢?

一个锁保护多个没有关联关系的资源

对于多个没有关联关系的资源,我们很容易用一个锁去保护。

以银行账户为例,银行账户可以有取款操作,也有修改密码操作,那么账户余额和账户密码就是两个没有关联关系的资源。

我们来看下面的示例代码。

public class BankAccountLockDemo {

	private double balance;
private String password; private Object commonLockObj = new Object(); // 取钱
private void withdrawMoney(double amount) {
synchronized(commonLockObj) {
// 业务代码
balance = balance - amount;
}
} // 修改密码
private void changePassword(String newPassword) {
synchronized(commonLockObj) {
// 业务代码
password = newPassword;
}
}
}

我们可以看到,上述代码使用了共享锁commonLockObj来保护balance和password,是可以正常工作的。

但是这样做存在的问题是取款和修改密码操作不能同时进行,从业务角度看,这两块业务是没有关联的, 应该是可以并行的。

解决办法是每个业务使用各自的互斥锁对相关资源进行保护。上述代码中可以创建两个锁对象:balanceLockObjpasswordLockObj,这样两个业务操作就不会互相影响了,这样的锁也被称为细粒度锁

一个锁保护多个有关联关系的资源

对于有关联关系的资源,情况会复杂一些。

我们以转账操作为例进行说明,转账的过程会涉及两个账户的余额,这两个余额就是两个有关联关系的资源。

我们来看下面的示例代码。

public class BankAccountTransferLockDemo {
private double balance; private Object lockObj = new Object(); private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
synchronized(lockObj) {
sourceAccount.balance = sourceAccount.balance - amount;
targetAccount.balance = targetAccount.balance + amount;
}
}
}

上述代码有问题吗? 答案是有问题。

看上去我们在操作balance的时候,使用了加锁处理,但是需要注意这里的锁对象是lockObj,是一个Object对象,如果此时有其他业务也需要操作相同账户的balance,例如存取款操作,其他业务是没有办法使用lockObj来创建锁的,从而造成多个业务同时操作balance,引发并发问题。

问题的解决办法是我们创建的锁需要能够覆盖受保护资源的所有场景。

回到我们上面的示例,如果使用Object对象作为锁对象不能覆盖所有相关业务,那么我们需要升级锁对象,将其由Object对象变为Class对象,代码如下:

	private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
synchronized(BankAccountTransferLockDemo.class) {
sourceAccount.balance = sourceAccount.balance - amount;
targetAccount.balance = targetAccount.balance + amount;
}
}

上述资源之间的关联关系,如果用更具体、更专业的语言来描述,其实是一种“原子性”的特征,原子性有两层含义:1) CPU指令级别的原子性,2)业务含义上的原子性。

“原子性”的本质什么?

原子性的表象是不可分割,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。

解决原子性问题,就是要保证中间状态对外不可见,这也是互斥锁要解决的问题。

Java并发编程实战(3)- 互斥锁的更多相关文章

  1. Java并发编程实战 03互斥锁 解决原子性问题

    文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 摘要 在上一篇文章02Java如何解决可见性和有序性问题当中,我们解决了可见性和 ...

  2. 《Java并发编程实战》笔记-锁与原子变量性能比较

    如果线程本地的计算量较少,那么在锁和原子变量上的竞争将非常激烈.如果线程本地的计算量较多,那么在锁和原子变量上的竞争会降低,因为在线程中访问锁和原子变量的频率将降低. 在高度竞争的情况下,锁的性能将超 ...

  3. Java并发编程实战 04死锁了怎么办?

    Java并发编程文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 前提 在第三篇 ...

  4. Java并发编程实战 05等待-通知机制和活跃性问题

    Java并发编程系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 Java并发编程实 ...

  5. 【Java并发编程实战】----- AQS(二):获取锁、释放锁

    上篇博客稍微介绍了一下AQS,下面我们来关注下AQS的所获取和锁释放. AQS锁获取 AQS包含如下几个方法: acquire(int arg):以独占模式获取对象,忽略中断. acquireInte ...

  6. 【Java并发编程实战】-----“J.U.C”:ReentrantReadWriteLock

    ReentrantLock实现了标准的互斥操作,也就是说在某一时刻只有有一个线程持有锁.ReentrantLock采用这种独占的保守锁直接,在一定程度上减低了吞吐量.在这种情况下任何的"读/ ...

  7. 【Java并发编程实战】-----“J.U.C”:Semaphore

    信号量Semaphore是一个控制访问多个共享资源的计数器,它本质上是一个"共享锁". Java并发提供了两种加锁模式:共享锁和独占锁.前面LZ介绍的ReentrantLock就是 ...

  8. 【Java并发编程实战】-----“J.U.C”:ReentrantLock之一简介

    注:由于要介绍ReentrantLock的东西太多了,免得各位客官看累,所以分三篇博客来阐述.本篇博客介绍ReentrantLock基本内容,后两篇博客从源码级别分别阐述ReentrantLock的l ...

  9. 【java并发编程实战】-----线程基本概念

    学习Java并发已经有一个多月了,感觉有些东西学习一会儿了就会忘记,做了一些笔记但是不系统,对于Java并发这么大的"系统",需要自己好好总结.整理才能征服它.希望同仁们一起来学习 ...

  10. java并发编程实战学习(3)--基础构建模块

    转自:java并发编程实战 5.3阻塞队列和生产者-消费者模式 BlockingQueue阻塞队列提供可阻塞的put和take方法,以及支持定时的offer和poll方法.如果队列已经满了,那么put ...

随机推荐

  1. PyQt(Python+Qt)学习随笔:formLayout的layoutLabelAlignment 属性

    一.引言 Qt Designer的表单布局(formLayout)中,layoutLabelAlignment 用于控制表单布局中标签的水平对齐方式(包括垂直和水平方向两个方向).如图: 此属性实际对 ...

  2. 题解-Words

    题面 Words 有 \(n\) 天,每天插入一个字符集大小为 \(c\) 长度为 \(l\) 的字符串,求每一天建立 \(\tt Trie\) 树的期望节点数(根节点不算)模 \(998244353 ...

  3. 【题解】「CF1182B」Plus from Picture

    这是一道超级水的模拟 + 简单搜索. 说说思路: 先找到中心点,就是自己和上下左右都为 * 的. 上下左右上的所有 * 都删掉,然后再看看有没有多余的 * 如果有输出 NO 否则输出 YES. 比如说 ...

  4. AcWing 407. 稳定的牛分配

    大型补档计划 题目链接 题目看的有点晕(语文差) 总体来说就是让每头牛找个谷仓,不能超过容量,最小化每头牛在的谷仓在自己心目中排名的极差. 显然这个最优性问题不好做,但是转换为判定性问题这就是一个标准 ...

  5. Angular:组件之间的通信@Input、@Output和ViewChild

    ①父组件给子组件传值 1.父组件: ts: export class HomeComponent implements OnInit { public hxTitle = '我是首页的头部'; con ...

  6. STL——容器(Set & multiset)的默认构造 & 带参构造 & 对象的拷贝构造与赋值

    1. 默认构造 set<int> setInt;              //一个存放int的set容器. set<float> setFloat;          //一 ...

  7. redis 存储验证码 基本使用

    1 redis 存储验证码 基本使用 1.1 setting 配置 CACHES = { "default": { "BACKEND": "djang ...

  8. react第十九单元(react+react-router-dom+redux综合案例1)

    第十九单元(react+react-router-dom+redux综合案例1) #课程目标 复习 综合练习 实战能力 #知识点 react react-router redux #授课思路 #案例和 ...

  9. 解决Yii ActiveForm监听submit触发2次submit

    今天用yii框架的ActiveForm需要一个奇怪的问题,点击表单提交时会触发两次submit 产生问题的原因: form挂了2次submit事件,一次是yii activeform自带的,一次是我写 ...

  10. 老哥你能写篇 SpringCloud Alibaba 全家桶吗? 看视频太累 太枯燥了 !

    最喜欢的一句话: 1.01的365次方=37.78343433289 >>>1 0.99的365次方= 0.02551796445229, 每天进步一点点的目标,贵在坚持 前端时间有 ...