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

概述

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

这需要分情况讨论,在单核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. tensorflow 小记——如何对张量做任意行求和,得到新tensor(一种方法:列表生成式)

    希望实现图片上的功能 import tensorflow as tfa = tf.range(10,dtype=float)b = aa = tf.reshape(a,[-1,1])a = tf.ti ...

  2. Go语言的context包从放弃到入门

    目录 一.Context包到底是干嘛用的 二.主协程退出通知子协程示例演示 主协程通知子协程退出 主协程通知有子协程,子协程又有多个子协程 三.Context包的核心接口和方法 context接口 e ...

  3. 在DLL中使用对话框

    在 DLL 中使用对话框资源与在 EXE 中使用是有所区别的,处理不当便会造成断言失败.原因是因为 CDialog::Create 与 CreateEx 默认使用当前进程中的资源(Dialog Tem ...

  4. 一、什么是Jmeter?Jmeter安装?Jmeter的启动?

    什么是Jmeter Apache JMeter 是 Apache 组织开发的基于 Java 的压力测试工具,也可以进行接口测试.它是一个开源的,100%基于Java的应用程序,带有图形界面.它旨在分析 ...

  5. mysql 8.0 改变数据目录和日志目录(二)

    一.背景 原数据库数据目录:/data/mysql3306/data,日志文件目录:/data/mysql3306/binlog 变更后数据库目录:/mysqldata/3306/data,日志文件目 ...

  6. 接口测试工具 Jmeter使用笔记(一:编写一个http请求)

    记录学习过程 一.安装Jmeter 1.JAVA环境 JDK下载地址http://java.sun.com/javase/downloads/index.jsp 配置系统变量: (1)JAVA_HOM ...

  7. mysql位函数的使用

    查询每个月的访问天数 mysql> create table t1 (year YEAR(4),month int(2) unsigned zerofill,day int(2) u nsign ...

  8. MySQL中的 ”SELECT FOR UPDATE“ 一次实践

    背景 最近工作中遇到一个问题,两个不同的线程会对数据库里的一条数据做修改,如果不加锁的话,会得到错误的结果. 就用了MySQL中for update 这种方式来实现 本文主要测试主键.唯一索引和普通索 ...

  9. babel 与 ast

    什么是 babel Babel 是一个工具链,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中. 什 ...

  10. 多任务-python实现-迭代器相关(2.1.12)

    @ 目录 1.需求 2.斐波那契数列演示 3.并不是只有for循环能接收可迭代数据类型,list,tuple也可以 1.需求 类比 早上起来吃包子 1.买1年的包子,放在冰箱,每天拿一个 2.每天下楼 ...