解决原子性问题?脑海中有这个模型就可以了

上一篇文章 可见性有序性,Happens-before来搞定,解决了并发三大问题中的两个,今天我们就聊聊如何解决原子性问题

原子性问题的源头就是 线程切换,但在多核 CPU 的大背景下,不允许线程切换是不可能的,正所谓「魔高一尺,道高一丈」,新规矩来了:

互斥: 同一时刻只有一个线程执行

实际上,上面这句话的意思是: 对共享变量的修改是互斥的,也就是说线程 A 修改共享变量时其他线程不能修改,这就不存在操作被打断的问题了,那么如何实现互斥呢?

对并发有所了解的小伙伴马上就能想到 这个概念,并且你的第一反应很可能就是使用 synchronized,这里列出来你常见的 synchronized 的三种用法:

public class ThreeSync {

    private static final Object object = new Object();

    public synchronized void normalSyncMethod(){
        //临界区
    }

    public static synchronized void staticSyncMethod(){
        //临界区
    }

    public void syncBlockMethod(){
        synchronized (object){
            //临界区
        }
    }
}

三种 synchronized 锁的内容有一些差别:

  • 对于普通同步方法,锁的是当前实例对象,通常指 this
  • 对于静态同步方法,锁的是当前类的 Class 对象,如 ThreeSync.class
  • 对于同步方法块,锁的是 synchronized 括号内的对象

我特意在三种 synchronized 代码里面添加了「临界区」字样的注释,那什么是临界区呢?

临界区: 我们把需要互斥执行的代码看成为临界区

说到这里,和大家串的知识都是表层认知,如何用锁保护有效的临界区才是关键,这直接关系到你是否会写出并发的 bug,了解过本章内容后,你会发现无论是隐式锁/内置锁 (synchronized) 还是显示锁 (Lock) 的使用都是在找寻这种关系,关系对了,一切就对了,且看

上面锁的三种方式都可以用下图来表达:

线程进入临界区之前,尝试加锁 lock(), 加锁成功,则进入临界区(对共享变量进行修改),持有锁的线程执行完临界区代码后,执行 unlock(),释放锁。针对这个模型,大家经常用抢占厕所坑位来形容:

在学习 Java 早期我就是这样记忆与理解锁的,但落实到代码上,我们很容易忽略两点:

  1. 我们锁的是什么?
  2. 我们保护的又是什么?

将这两句话联合起来就是你的锁能否对临界区的资源起到保护的作用?所以我们要将上面的模型进一步细化

现实中,我们都知道自己的锁来锁自己需要保护的东西 ,这句话翻译成你的行动语言之后你已经明确知道了:

  1. 你锁的是什么
  2. 你保护的资源是什么

CPU 可不像我们大脑这么智能,我们要明确说明我们锁的是什么,我们要保护的资源是什么,它才会用锁保护我们想要保护的资源(共享变量)

拿上图来说,资源 R (共享变量) 就是我们要保护的资源,所以我们就要创建资源 R 的锁来保护资源 R,细心的朋友可能发现上图几个问题:

LR 和 R 之间有明确的指向关系
我们编写程序时,往往脑子中的模型是对的,但是忽略了这个指向关系,导致自己的锁不能起到保护资源 R 的作用(用别人家的锁保护自己家的东西或用自己家的锁保护别人家的东西),最终引发并发 bug,所以在你勾画草图时,要明确找到这个关系

左图 LR 虚线指向了非共享变量
我们写程序的时候很容易这么做,不确定哪个是要保护的资源,直接大杂烩,用 LR 将要保护的资源 R 和没必要保护的非共享变量一起保护起来了,举两个例子来说你就明白这么做的坏处了

  1. 编写串行程序时,是不建议 try...catch 整个方法的,这样如果出现问是很难定位的,道理一样,我们要用锁精确的锁住我们要保护的资源就够了,其他无意义的资源是不要锁的
  2. 锁保护的东西越多,临界区就越大,一个线程从走入临界区到走出临界区的时间就越长,这就让其他线程等待的时间越久,这样并发的效率就有所下降,其实这是涉及到锁粒度的问题,后续也都会做相关说明

作为程序猿还是简单拿代码说明一下心里比较踏实,且看:

public class ValidLock {

    private static final Object object = new Object();

    private int count;

    public synchronized void badSync(){
        //其他与共享变量count无关的业务逻辑
        count++;
    }

    public void goodSync(){
        //其他与共享变量count无关的业务逻辑
        synchronized (object){
            count++;
        }
    }
}

这里并不是说 synchronized 放在方法上不好,只是提醒大家用合适的锁的粒度才会更高效

在计数器程序例子中,我们会经常这么写:

public class SafeCounter {

    private int count;

    public synchronized void counter(){
        count++;
    }

    public synchronized int getCount(){
        return count;
    }
}

下图就是上面程序的模型展示:

这里我们锁的是 this,可以保护 this.count。但有些同学认为 getCount 方法没必要加 synchronized 关键字,因为是读的操作,不会对共享变量做修改,如果不加上 synchronized 关键字,就违背了我们上一篇文章 happens-before 规则中的监视器锁规则:

对一个锁的解锁 happens-before 于随后对这个锁的加锁
也就是说对 count 的写很可能对 count 的读不可见,也就导致脏读

上面我们看到一个 this 锁是可以保护多个资源的,那用多个不同的锁保护一个资源可以吗?来看一段程序:

public class UnsafeCounter {

    private static int count;

    public synchronized void counter(){
        count++;
    }

    public static synchronized int calc(){
        return count++;
    }
}

睁大眼睛仔细看,一个锁的是 this,一个锁的是 UnsafeCounter.class, 他们都想保护共享变量 count,你觉得如何?下图就是行面程序的模型展示:

两个临界区是用两个不同的锁来保护的,所以临界区没有互斥关系,也就不能保护 count,所以这样加锁是无意义的

总结

  1. 解决原子性问题,就是要互斥,就是要保证中间状态对外不可见
  2. 锁是解决原子性问题的关键,明确知道我们锁的是什么,要保护的资源是什么,更重要的要知道你的锁能否保护这个受保护的资源(图中的箭头指向)
  3. 有效的临界区是一个入口和一个出口,多个临界区保护一个资源,也就是一个资源有多个并行的入口和多个出口,这就没有起到互斥的保护作用,临界区形同虚设
  4. 锁自己家门能保护资源就没必要锁整个小区,如果锁了整个小区,这严重影响其他业主的活动(锁粒度的问题)

本文以 synchronized 锁举例来说明如何解决原子性问题,主要是帮助大家建立宏观的理念,用于解决原子性问题,这样后续你看到无论什么锁,只要脑海中回想起本节说明的模型,你会发现都是换汤不换药,学习起来就非常轻松了.

到这里并发的三大问题 有序性,可见性,原子性都有了解决方案,这是远看并发,让大家有了宏观的概念;但面试和实战都是讲求细节的,接下来我们由远及近,逐步看并发的细节,顺带说明那些面试官经常会问到的问题

灵魂追问

  1. 多个锁锁一个资源一定会有问题吗?
  2. 什么时候需要锁小区,而不能锁某一户呢?
  3. 银行转账,两人互转和别人给自己转,用什么样的锁粒度合适呢?

提高效率工具


推荐阅读


欢迎持续关注公众号:「日拱一兵」

  • 前沿 Java 技术干货分享
  • 高效工具汇总 | 回复「工具」
  • 面试问题分析与解答
  • 技术资料领取 | 回复「资料」

以读侦探小说思维轻松趣味学习 Java 技术栈相关知识,本着将复杂问题简单化,抽象问题具体化和图形化原则逐步分解技术问题,技术持续更新,请持续关注......

原文地址:https://www.cnblogs.com/FraserYu/p/11570380.html

时间: 2024-10-31 06:57:27

解决原子性问题?脑海中有这个模型就可以了的相关文章

解决在django中应用keras模型时出现的ValueError("Tensor %s is not an element of this graph." % obj)问题

用keras训练好模型,再在django初始化加载模型,这个过程没有问题,但是在调用到模型执行model.predict()的时候就报错: raise ValueError("Tensor %s is not an element of this graph." % obj) ValueError: Tensor Tensor("dense_2/Softmax:0", shape=(?, 2), dtype=float32) is not an element of

解决ubuntu alt+tab中有两个切换窗口

症状,就是在alt+tab的时候,出现了两个切换器.前面的切换器是带预览的.后面的那个切换器是系统自带的. 安装Compiz sudo apt-get install compiz-plugins sudo apt-get install compizconfig-settings-manager 然后打开compizconfig setting manager 然后提示: 据说,这软件玩坏了会进不了桌面,所以,还是少动为好. 然后主要是更改这里: 取消勾选,就可以了. 原文地址:https:/

Java内存模型(JMM)

1. 概述 多任务和高并发是衡量一台计算机处理器的能力重要指标之一.一般衡量一个服务器性能的高低好坏,使用每秒事务处理数(Transactions Per Second,TPS)这个指标比较能说明问题,它代表着一秒内服务器平均能响应的请求数,而TPS值与程序的并发能力有着非常密切的关系.在讨论Java内存模型和线程之前,先简单介绍一下硬件的效率与一致性. 2.硬件的效率与一致性 由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理

Java内存模型(转载)

1. 概述 多任务和高并发是衡量一台计算机处理器的能力重要指标之一.一般衡量一个服务器性能的高低好坏,使用每秒事务处理数(Transactions Per Second,TPS)这个指标比较能说明问题,它代表着一秒内服务器平均能响应的请求数,而TPS值与程序的并发能力有着非常密切的关系.在讨论Java内存模型和线程之前,先简单介绍一下硬件的效率与一致性. 2.硬件的效率与一致性 由于计算机的存储设备与处理器的运算能力之间有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理

Java内存模型知识点小结---《深入理解Java内存模型》(程晓明)读书总结

一.Java内存模型介绍 内存模型的作用范围: 在Java中,所有实例域.静态域和数组元素存放在堆内存中,线程之间共享,下文称之为“共享变量”.局部变量.方法参数.异常处理器等不会在线程之间共享,不存在内存可见性问题,也不受内存模型的影响. 重排序与可见性: 现代编译器在编译源码时会做一些优化处理,对代码指令进行重排序:现代流水线结构的处理器为了提高并行度,在执行时也可能对指令做一些顺序上的调整.重排序包括编译器重排序.指令级并行重排序和内存系统重排序等.一般来说,编译器和处理器在做重排序的时候

java内存模型二

并发编程模型的分类 在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体).通信是指线程之间以何种机制来交换信息.在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递. 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信.在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信. 同步是指程序用于控制不同线程之间操作发生相对顺序的机制.在共享内存并发

深入理解Java虚拟机笔记---原子性、可见性、有序性

Java内存模型是围绕着并发过程中如何处理原子性.可见性.有序性这三个特征来建立的,下面是这三个特性的实现原理: 1.原子性(Atomicity) 由Java内存模型来直接保证的原子性变量操作包括read.load.use.assign.store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的.如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock与unlock操作直接开放给用户使用,但是却提供了更

深入理解java内存模型

深入理解Java内存模型(一)——基础 并发编程模型的分类 在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体).通信是指线程之间以何种机制来交换信息.在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递. 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信.在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信. 同步是指程序用于控制不同线程之

Java内存模型(转)

深入理解Java内存模型(一)——基础 并发编程模型的分类 在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体).通信是指线程之间以何种机制来交换信息.在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递. 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信.在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信. 同步是指程序用于控制不同线程之