前言

原子性指一个或多个操作在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并发基础】加锁机制解决原子性问题的更多相关文章

  1. Java 并发基础

    Java 并发基础 标签 : Java基础 线程简述 线程是进程的执行部分,用来完成一定的任务; 线程拥有自己的堆栈,程序计数器和自己的局部变量,但不拥有系统资源, 他与其他线程共享父进程的共享资源及 ...

  2. java并发基础(二)

    <java并发编程实战>终于读完4-7章了,感触很深,但是有些东西还没有吃透,先把已经理解的整理一下.java并发基础(一)是对前3章的总结.这里总结一下第4.5章的东西. 一.java监 ...

  3. java并发基础及原理

    java并发基础知识导图   一 java线程用法 1.1 线程使用方式 1.1.1 继承Thread类 继承Thread类的方式,无返回值,且由于java不支持多继承,继承Thread类后,无法再继 ...

  4. 【搞定 Java 并发面试】面试最常问的 Java 并发基础常见面试题总结!

    本文为 SnailClimb 的原创,目前已经收录自我开源的 JavaGuide 中(61.5 k Star![Java学习+面试指南] 一份涵盖大部分Java程序员所需要掌握的核心知识.欢迎 Sta ...

  5. java并发基础(五)--- 线程池的使用

    第8章介绍的是线程池的使用,直接进入正题. 一.线程饥饿死锁和饱和策略 1.线程饥饿死锁 在线程池中,如果任务依赖其他任务,那么可能产生死锁.举个极端的例子,在单线程的Executor中,如果一个任务 ...

  6. Java并发基础概念

    Java并发基础概念 线程和进程 线程和进程都能实现并发,在java编程领域,线程是实现并发的主要方式 每个进程都有独立的运行环境,内存空间.进程的通信需要通过,pipline或者socket 线程共 ...

  7. 【Java并发基础】并发编程bug源头:可见性、原子性和有序性

    前言 CPU .内存.I/O设备之间的速度差距十分大,为了提高CPU的利用率并且平衡它们的速度差异.计算机体系结构.操作系统和编译程序都做出了改进: CPU增加了缓存,用于平衡和内存之间的速度差异. ...

  8. 【Java并发基础】Java内存模型解决有序性和可见性

    前言 解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化.但是,禁用这两者又会影响程序性能.于是我们要做的是按需禁用CPU缓存和编译器的优化. 如何按需禁用CPU缓存和编译 ...

  9. 【Java并发基础】使用“等待—通知”机制优化死锁中占用且等待解决方案

    前言 在前篇介绍死锁的文章中,我们破坏等待占用且等待条件时,用了一个死循环来获取两个账本对象. // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, targe ...

随机推荐

  1. document.getElementById()

    使用两个for循环取json数据的时候出错: 代码简化如下: for(var a=0;a<3;a++){ for(var b=0;b<3;b++){ document.getElement ...

  2. js基础——函数

    1.函数声明:通过函数可封装任意多条语句,且可在任意地方.任何时候调用执行. eg. function box(){//无参函数      alert("只有函数被调用,我才会被执行&quo ...

  3. sci,ei,istp三大科技文献检索系统

    印刷版(SCI) 双月刊 ,500种 联机版(SciSearch) 周更新 ,600种 光盘版(带文摘)(SCICDE) 月更新 ,500种(同印刷版) 网络版(SCIExpanded) 周更新 ,6 ...

  4. 消息驱动Bean

    消息驱动bean是专门用来处理基于消息请求的组件.MDB负责处理消息,而EJB容器则负责处理服务(事务,安全,并发,消息确认等),使Bean的开发者集中精力在处理消息的业务逻辑上. 消息驱动Bean. ...

  5. C# 多线程的等待所有线程结束

      //前台线程和后台线程唯一区别就是:应用程序必须运行完所有的前台线程才可以退出://而对于后台线程,应用程序则可以不考虑其是否已经运行完毕而直接退出,//所有的后台线程在应用程序退出时都会自动结束 ...

  6. C# 使用汇编

    本文告诉大家如何在 C# 里面使用汇编代码 请看 C#嵌入x86汇编--一个GPIO接口的实现 - 云+社区 - 腾讯云 C# inline-asm / 嵌入x86汇编 - 苏璃 - CSDN博客 通 ...

  7. Linux 内核提交 urb

    一旦 urb 被正确地创建,并且被 USB 驱动初始化, 它已准备好被提交给 USB 核心来发送 出到 USB 设备. 这通过调用函数 usb_submit_urb 实现: int usb_submi ...

  8. grep工具

    全面搜索正则表达式(Global search regular expression(RE) ,GREP)是一种强大的文本搜索工具,它能使用正则表达式搜索文本,并把匹配的行打印出来. Unix/Lin ...

  9. 在Spring Boot中使用Docker在测试中进行高级功能测试

    最近又学到了很多新知识,感谢优锐课老师细致地讲解,这篇博客记录下自己所学所想. 想更多地了解Spring Boot项目中的功能测试吗?这篇文章带你了解有关在测试中使用Docker容器的更多信息. 本文 ...

  10. VRchat模型之unity

    VRChat模型制作及上传总篇(包含总流程和所需插件):https://www.cnblogs.com/raitorei/p/12015876.html 0.新建工程, 导入VRCSDK及动态骨骼插件 ...