【Java并发基础】加锁机制解决原子性问题
前言
原子性指一个或多个操作在CPU执行的过程不被中断的特性。前面提到原子性问题产生的源头是线程切换,而线程切换依赖于CPU中断。于是得出,禁用CPU中断就可以禁止线程切换从而解决原子性问题。但是这种情况只适用于单核,多核时不适用。
以在 32 位 CPU 上执行 long 型变量的写操作为例来说明。
long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位,如下图所示,图来自【参考1】)。

在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,即禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行。所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
但是在多核场景下,同一时刻,可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上。此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行。如果这两个线程同时向内存写 long 型变量高 32 位的话,那么就会造成我们写入的变量和我们读出来的是不一致的。
所以解决原子性问题的重要条件还是为:同一时刻只能有一个线程对共享变量进行操作,即互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性。
下面将介绍实现互斥访问的方案,加锁机制。
锁模型
我们把一段需要互斥执行的代码称为临界区。
线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;
否则就等待或阻塞,直到持有锁的线程释放锁。持有锁的线程执行完临界区的代码后,执行解锁 unlock()。
锁和锁要保护的资源是要对应的。这个指的是两点:①我们要保护一个资源首先要创建一把锁;②锁要锁对资源,即锁A应该用来保护资源A,而不能用它来锁资源B。
所以,最后的锁模型如下:(图来自【参考1】)

Java提供的锁技术: synchronized
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。
synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例如下:
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
与上面的锁模型比较,可以发现synchronized修饰的方法和代码块都没有显式地有加锁和释放锁操作。但是这并不代表没有这两个操作,这两个操作Java编译器会帮我们自动实现。Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样的好处在于代码更简洁,并且Java程序员也不必担心会忘记释放锁了。
然后我们再观察可以发现:只有修饰代码块的时候,锁定了一个 obj 对象。那么修饰方法的时候锁了什么呢?
这是Java的一个隐式规则:
- 当修饰静态方法时,锁的是当前类的 Class 对象,在上面的例子中就是 X.class;
- 当修饰非静态方法时,锁定的是当前实例对象 this。
对于上面的例子,synchronized 修饰静态方法相当于:
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
修饰非静态方法,相当于:
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}
内置锁
每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。被synchronized关键字修饰的方法或者代码块,称为同步代码块(Synchronized Block)。线程在进入同步代码块之前会自动获取锁,并且在退出同步代码块时自动释放锁,这在前面也提到过。
Java的内置锁相当于一种互斥体(或互斥锁),这也就是说,最多只有一个线程能够持有这个锁。由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子的方式执行。
内置锁是可重入的
当某个线程请求一个由其他线程所持有的锁时,发出请求的线程会被阻塞。然而,由于内置锁是可重入的,所以当某个线程试图获取一个已经由它自己所持有的锁时,这个请求就会成功。
重入实现的一个方法是:为每个锁关联一个获取计数器和一个所有者线程。
当计数器值为0时,这个锁就被认为是没有被任何线程持有的。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将计数器加1。如果同一个线程再次获取这个锁,计数器将加1,而当线程退出同步代码块时,计数器会相应地减1。当计数器为0时,这个锁将被释放。
下面这段代码,如果内置锁是不可重入的,那么这段代码将发生死锁。
public class Widget{
public synchronized void doSomething(){
....
}
}
public class LoggingWidget extends Widget{
public synchronized void doSomething(){
System.out.println(toString() + ": call doSomething");
super.doSomething();
}
}
使用synchronized解决count+=1问题
前面我们介绍原子性问题时提到count+=1存在原子性问题,那么现在我们使用synchronized来使count+=1成为一个原子操作。
代码如下所示。
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
SafeCalc 这个类有两个方法:一个是 get() 方法,用来获得 value 的值;另一个是 addOne() 方法,用来给 value 加 1,并且 addOne() 方法我们用 synchronized 修饰。下面我们分析看这个代码是否存在并发问题。
addOne() 方法,被 synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作。
那么可见性呢?是否可以保证一个线程调用addOne()使value加一的结果对另一个线程后面调用addOne()时可见?
答案是可以的。这就需要回顾到我们上篇博客提到的Happens-Before规则其中关于管程中的锁规则:对同一个锁的解锁 Happens-Before 后续对这个锁的加锁。即,一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
此时还不能掉以轻心,我们分析get()方法。执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?答案是这个可见性没有保证。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。所以,最终的解决办法为也是用synchronized修饰get()方法。
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
代码转换成我们的锁模型为:(图来自【参考1】)

get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的。
锁和受保护资源的关系
受保护资源和锁之间的关联关系非常重要,一个合理的关系为:锁和受保护资源之间的关联关系是 1:N 。
拿球赛门票管理来类比,一个座位(资源)可以用一张门票(锁)来保护,但是不可以有两张门票预定了同一个座位,不然这两个人就会fight。
在现实中我们可以使用多把锁锁同一个资源,如果放在并发领域中,线程A获得锁1和线程B获得锁2都可以访问共享资源,那么达到互斥访问共享资源的目的。所以,在并发编程中使用多把锁锁同一个资源不可行。或许有人会想:要同时获得锁1和锁2才可以访问共享资源,这样应该是就可行的。我觉得是可以的,但是能用一个锁就可以保护资源,为什么还要加一个锁呢?
多把锁锁一个资源不可以,但是我们可以用同一把锁来保护多个资源,这个对应到现实球赛门票就是可以用一张门票预定所有座位,即“包场”。
下面举一个在并发编程中使用多把锁来保护同一个资源将会出现的并发问题:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
把 value 改成静态变量,把 addOne() 方法改成静态方法。
仔细观察,就会发现改动后的代码是用两个锁保护一个资源。get()所使用的锁是this,而addOne()所使用的锁是SafeCalc.class。两把锁保护一个资源的示意图如下(图来自【参考1】)。
由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题。

小结
Synchronized是 Java 在语言层面提供的互斥原语,Java中还有其他类型的锁。但是作为互斥锁,原理都是一样的,首先要有一个锁,然后是要锁住什么资源以及在哪里加锁就需要在设计层面考虑。
最后一个主题提的锁和受保护资源的关系非常重要,在使用锁时一定要好好注意。
参考:
[1]极客时间专栏王宝令《Java并发编程实战》
[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016
【Java并发基础】加锁机制解决原子性问题的更多相关文章
- Java 并发基础
Java 并发基础 标签 : Java基础 线程简述 线程是进程的执行部分,用来完成一定的任务; 线程拥有自己的堆栈,程序计数器和自己的局部变量,但不拥有系统资源, 他与其他线程共享父进程的共享资源及 ...
- java并发基础(二)
<java并发编程实战>终于读完4-7章了,感触很深,但是有些东西还没有吃透,先把已经理解的整理一下.java并发基础(一)是对前3章的总结.这里总结一下第4.5章的东西. 一.java监 ...
- java并发基础及原理
java并发基础知识导图 一 java线程用法 1.1 线程使用方式 1.1.1 继承Thread类 继承Thread类的方式,无返回值,且由于java不支持多继承,继承Thread类后,无法再继 ...
- 【搞定 Java 并发面试】面试最常问的 Java 并发基础常见面试题总结!
本文为 SnailClimb 的原创,目前已经收录自我开源的 JavaGuide 中(61.5 k Star![Java学习+面试指南] 一份涵盖大部分Java程序员所需要掌握的核心知识.欢迎 Sta ...
- java并发基础(五)--- 线程池的使用
第8章介绍的是线程池的使用,直接进入正题. 一.线程饥饿死锁和饱和策略 1.线程饥饿死锁 在线程池中,如果任务依赖其他任务,那么可能产生死锁.举个极端的例子,在单线程的Executor中,如果一个任务 ...
- Java并发基础概念
Java并发基础概念 线程和进程 线程和进程都能实现并发,在java编程领域,线程是实现并发的主要方式 每个进程都有独立的运行环境,内存空间.进程的通信需要通过,pipline或者socket 线程共 ...
- 【Java并发基础】并发编程bug源头:可见性、原子性和有序性
前言 CPU .内存.I/O设备之间的速度差距十分大,为了提高CPU的利用率并且平衡它们的速度差异.计算机体系结构.操作系统和编译程序都做出了改进: CPU增加了缓存,用于平衡和内存之间的速度差异. ...
- 【Java并发基础】Java内存模型解决有序性和可见性
前言 解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化.但是,禁用这两者又会影响程序性能.于是我们要做的是按需禁用CPU缓存和编译器的优化. 如何按需禁用CPU缓存和编译 ...
- 【Java并发基础】使用“等待—通知”机制优化死锁中占用且等待解决方案
前言 在前篇介绍死锁的文章中,我们破坏等待占用且等待条件时,用了一个死循环来获取两个账本对象. // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, targe ...
随机推荐
- iview+vue查询分页实现
本文为实战坑记录 子组件(共用的搜索组件) <template> <div> <h2>{{pdbTitle}}</h2> <Form ref=&q ...
- ZR8.2 DP
DP 1CF1101D 我们发现,最终答案一定和质因数有关 我们发现\(w_i <= 2*10^5\)级别的树,他的素因子的个数不会非常多(\(<=10\)) 然后我们就设 gcd是\(d ...
- Microsoft Ignite The Tour Beijing 记录: Learn Connect Explore
坦率的说,这是我第一次以讲师的身份参加微软的Ignite大会.同时我也很开心能作为微软社区MVP来参加这个活动.而我的演讲主题也和我的社区有关——Unity.C#以及跨平台开发. 这篇用来记录MSIg ...
- Struts2 类型转换(易百教程)
在HTTP请求中的一切都被视为一个String由协议.这包括数字,布尔值,整数,日期,小数和一切.每一件事情是一个字符串,将根据HTTP.然而,Struts类可以有任何数据类型的属性.Struts的自 ...
- vim 方式快捷编辑代码
说明 **I: ** 行首插入 **a: ** 追加 **A: ** 行尾插入 **R: ** 替换文字 **v: ** 选择 **ctrl-v: ** 选择举行区域 **x: ** 删除 **dd: ...
- 从0开始.NET CORE认证
引子 最近在学习IdentityServer4,看了园子里大神们的文章,但是看完之后,能明白这样做可以达到业务需求.但是为什么这样做可以达到业务需求,我用其他方式不行吗?为什么这样做可以呢.也就是老话 ...
- $POJ1742\ Coins$ 多重背包+贪心
Vjudge传送门 $Sol$ 首先发现这是一个多重背包,所以可以用多重背包的一般解法(直接拆分法,二进制拆分法...) 但事实是会TLE,只能另寻出路 本题仅关注“可行性”(面值能否拼成)而不是“最 ...
- MyBatis原理-延迟加载,一级缓存,二级缓存设置
一.延迟加载 resultMap中的association和collection标签具有延迟加载的功能. 延迟加载的意思是说,在关联查询时,利用延迟加载,先加载主信息.使用关联信息时再去加载关联信息. ...
- Mybatis 学习过程中出现空指针异常的错误【已解决】
Mybatis 学习过程中出现空指针异常的错误[已解决] 以下是写的小测试的代码 bean层 Player类(篮球队队员) bean层 Team类(篮球队) dao层 TeamDao.xml配置文件 ...
- Javascript用途,语法特点,难点,调试工具,引入方式,命名规范,变量声明及赋值,数据类型,运算符
JavaScript用来干什么 数据的验证 将动态的内容写入到网页当中(ajax) 对事件做出相应 读写html当中的内容 检测浏览器 创建cookies 模拟动画 语法特点 基于对象和事件驱动的松散 ...