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

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

原子性问题的源头就是 线程切换,但在多核 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 锁的内容有一些差异:app

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

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

临界区: 咱们把须要互斥执行的代码当作为临界区

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

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

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

在学习 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 锁举例来讲明如何解决原子性问题,主要是帮助你们创建宏观的理念,用于解决原子性问题,这样后续你看到不管什么锁,只要脑海中回想起本节说明的模型,你会发现都是换汤不换药,学习起来就很是轻松了.

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

更多信息请访问我的博客:https://dayarch.top/

灵魂追问

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

提升效率工具


推荐阅读


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

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

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

相关文章
相关标签/搜索