原子性的定义:个或者多个操作在 CPU执行 的过程中不被中断的特性称为 「原子性

理解这个特性有助于帮你分析并发中 Bug 出现的原因,比如可以使用给它分析出 long 型变量在 32 位的机器上读写可能出现诡异的 Bug,造成明明已经把变量成功写入内存重新读出来却不是自己写入的

【因为 long 是8个 byte组成的基本变量其长度为64位,而 Java 内存模型允许 64位的变量分两次分别读取其 高32位 和 低32位的值,所以对其读取和写入是非原子操作的,在并发环境下可能出现的问题。】

原子性问题到底如何解决?

原子性问题的「源头」:线程切换」,导致一组操作没有执行完就切换到了别的线程。

操作系统中的线程切换依赖 CPU中断 这个特性,所以禁用 CPU发生中断就能够禁用线程切换。 【但是这不就把操作系统的意义完全抹杀了,又回到了当初一台电脑运行一个程序的年代?】

早期单核年代,使用 禁用CPU中断 来禁止线程切换这个方案是可行的,并且也有不少应用案例。 但是现在早已经是多核年代,所以已经不适合我们这个时代了。

这里以 32位 CPU 上执行对 long 型变量的写操作为例来说明这个问题:long 型变量是64位的变量,在 32位 CPU 上执行写操作会被拆分为两次写操作 —— 写高32位 与 写低三十二位,如下图所示:

在「单核 CPU」 场景下,同一时刻只有一个线程执行该写入操作,禁止 CPU中断,意味着 操作系统 不会重新调度线程,也就是禁止了线程的切换。,从而避免了原子性问题。

那么获得了 CPU 使用权的线程就可以不间断地执行,所以两次的写入操作一定是:『要么都执行,要么都没有被执行』这样的操作具有原子性

但是在 多核场景 下,同一时刻,可能有两个线程在同时执行,一个线程在 「CPU-1」 上,一个线程在 **「CPU-2」**上,此时 禁止CPU 中断,只能保证 单个CPU 上的线程连续执行,而并不能保证 「同一时刻,所有CPU上只有一个线程执行」,所以如果这两个线程同时对 long 型变量写入其高32位的值,那么就有可能出现开头提到的 Bug

"同一时刻只有一个线程执行",这个条件非常重要,我们称其为 互斥

如果我们能够保证对 共享变量 的修改是「互斥」的,那么无论是 单核CPU 还是 多核CPU ,就都能够保证原子性。

简易锁模型

谈到互斥,在结合现实生活中的经验,怎样保证某项物品被你独占?把东西放在某个上锁的房子里,且只有你有钥匙。

编程中也是如此,同时下面有一个简易的锁模型:

image-20200410144308887

我们将一段需要 「互斥执行」 的代码称为 「临界区」

线程在进入 「临界区」 之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁。 否则说明锁被其他线程持有,则该线程进入等待状态,直到有锁的线程释放锁

持有锁的线程执行完临界区的代码后,执行解锁操作 unlock()

这个过程不难理解,同时脑海中也会浮现对应的 Java 代码 。

// 同步代码块中的代码,就是所谓的临界区
synchronized {
	// ...
}

但是有没有想过,这里我们 锁的是什么 保护的是什么【我还要多几个疑问,比如锁的本质 到底是什么?】

改进后的锁模型

现实世界中,锁和锁要保护的资源有着「对应关系,你家的锁保护你家的东西,我用我家的锁保护我家的东西。

并发编程的世界中,锁和被保护的资源也有这个对应关系,但是这个关系在上面的模型中没有体现出来,所以需要将 简易的锁模型进行一下改进

首先,我们需要将 临界区要保护的资源标注出来,如图汇总临界区内增加了一个元素:「受保护的资源R」

其次,我们要保护资源 R 就得为它创建一把锁 LR【<--- 为被保护的资源创建特定的锁。】

最后,针对这把锁 LR,我们还需要在**进出** 「临界区」 时增加 加锁解锁 操作。

另外在 「锁LR」「受保护资源」之间,作者特意用了一条线进行关联,这个「关联关系」非常重要。 很多并发 BUG 的出现都是因为忽略了这个关系,然后出现了类似 「锁自家门保护别人家财产」 的事情。<---【这个比喻非常形象,一下就让我明白了这种对应关系的重要性】

而且这样的 BUG 非常不好诊断,因为表面上看起来是已经加锁了的,但是因为锁与被保护的资源并不对应,所以并没有起到互斥的效果。

Java 语言提供的锁技术: synchronized

锁是一种通用的技术方案Java 语言提供的 synchronized 关键字,就是锁的一种实现。

synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本都是下面这种形式:

class X {
    // 修饰非静态方法
    synchronized void foo() {
        // 临界区
    }

    // 修饰静态方法
    synchronized static void bar() {
        // 临界区
    }

    // 修饰代码块
    Object obj = new Object();

    void baz() {
        synchronized (obj) {
            // 临界区
        }
    }
}

这里只要使用了 synchronized 关键字,Java 编译器 自动在被修饰的方法或代码块之前加锁,代码执行完出临界区时执行解锁

这样的好处是 lock() 加锁和 unlock() 解锁一定成对出现,同时避免了程序员显示的进行处理,提升了封装性。

同时还避免了可能出现的严重的问题 ---> 如果程序员手动编写解锁代码的话,一旦没有解锁则将出现死锁问题。其他线程只能一直等待获取锁。

问题又来了, synchronized 里的 加锁 lock() 和 解锁 unlock() 锁定的对象在哪里? 上面例子中只有一个 synchronized(obj) 是有明确对象的,那么方法上的锁,锁对象是什么呢?

这是 Java 的一条隐式规则:

当修饰静态方法时,锁对象是当前Class 对象,在修饰非静态方法时,锁对象是当前的实例对象 this

【↑ 这也是能回答之前我那个问题:锁的本质是什么? 】

对于上面的例子, synchronized 修饰静态方法相当于:

class X {
    // 修饰静态方法
    synchronized(X.class)static void bar() {
        // 临界区
    }
}

修饰非静态方法,相当于:

class X {
    // 修饰非静态方法
    synchronized(this) void foo() {
        // 临界区
    }
}

【类似Java 中 隐式 this 的使用,不需要额外指明的规则。】

用 synchronized 解决 count += 1 问题

之前说过 count += 1 是一个非原子操作,存在并发问题,现在则可以尝试使用 synchronized 来解决这个问题了。【实际上之前的地方已经使用内置锁解决了这个问题,毕竟我先学完了 《jcip》 的前5章基础章节】

代码如下所示: SafeCalc 这个类有两个方法:一个 get() 方法,用来获得 value 的值,另一个是 addOne() 方法,用来给 value 加1,并且 addOne() 方法使用 synchronized 关键字修饰。

public class SafeCalc {
    long value = 0L;

    long get() {
        return value;
    }

    synchronized void addOne() {
        value += 1;
    }
}

这里可以看到, addOne() 方法被 synchronized关键字修饰后,无论是单核CPU还是多核CPU,都只有一个线程能执行 addOne() 方法,所以一定能保证 「原子操作」,那么是否存在可见性问题?

根据上篇文章中的 管程中锁的规则可知:

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

这里的 「管程」 指的就是 synchronized 也叫做**「内置锁/互斥锁/监视器」** ,我们知道 synchronized 修饰的 「临界区」「互斥」 的,也就是 同一时刻只有一个线程执行临界区的代码

而 "对一个锁解锁 Happens-Before 后续对这个锁的加锁" 指的是 前一个线程的解锁操作对后一个线程的加锁操作可见。

综合考虑 Happens-Before 的传递性规则,我们就可以知道 一个线程在临界区修改共享变量(该操作在解锁之前) 对后续进入临界区(该操作在加锁之后)的线程是可见的。

【同时《jcip》中也强调了,锁不仅能保证操作的原子性,也能保证可见性,原来就是这个 Hapens-Before 保证的】

按照这个规则,如果多个线程同时执行 addOne() 方法,可见性是可以保证的,也就是如果有1000个 执行 addOne() 方法,最终 value 的值一定是1000。

但是这里对于 get() 方法就不一定了。 执行 addOne() 方法后, value 的值对 get() 方法的可见性是没法保证的。

管程中锁的规则只对后续对这个锁的加锁的可见性保证【也就是同一把锁,jcip 中反复强调的 同一把锁原则,在这里突然明白了】,而 get() 方法并没有加锁,所以无法保证可见性。 所以为了保证 get() 读到的是最新的 value 值, 只需对其也进行加锁即可:

public class SafeCalc {
    long value = 0L;

    synchronized long get() {
        return value;
    }

    synchronized void addOne() {
        value += 1;
    }
}

将上面的代码转为之前提到的 「锁模型」 就是下面图示的这个样子。

get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源使用的是 this 这把锁来保护,线程进入临界区 getOne()addOne() 必须先获得 this 这把锁,这样 get()addOne() 也是互斥的。

↑ 这里可以清楚的看到,最重要的就是「同一把锁」保护对应的资源,这里也是 jcip 中反复强调的概念。

这个模型更像现实世界中 电影票或者球赛门票的管理系统:一个座位只允许一个人使用,这个座位就是「受保护的资源」,进入的检票口 就是 Java 类里的方法,门票就是保护资源的 「锁」,Java 里的检票工作是由 synchronzied 解决的。

锁和受保护资源的关系

之前说过,受保护资源和锁之间的**「关联关系」** 非常重要,那么他们之间具体是怎样的关系呢?

一个**「合理」的关系是:受保护资源和锁之间是 「N:1| 的关系。也就是使用一把锁保护多个资源**。 比如之前球赛门票管理,一个座位只能对应一张票,如果产生重复的票,则代表多个人对应一个座位【多个锁保护同一个资源】,肯定会出现问题

在现实世界中,可以使用多把锁来保护同一个资源,但是在并发领域是不行的。

并发领域的锁和现实世界的锁不是完全匹配的,但是可以用一把锁保护多个资源可以与现实中的 "包场" 做类比。

将上面的例子稍作改动,将 value 修改为静态变量,将 addOne() 修改为静态方法,此时 get()addOne() 是否存在并发问题呢?

class SafeCalc {
    static long value = 0L;

    synchronized long get() {
        return value;
    }

    synchronized static void addOne() {
        value += 1;
    }
}

这里可以很明显的发现,改动后的代码中存在两把锁: thisSafeCalc.class

两把锁保护一个资源 静态变量 value 那么就可能导致并发问题

所以 JCIP 中反复强调,使用保护多个资源需要使用 「同一把锁」

总结

互斥锁在并发领域中的知名度极高,只要有了并发问题大家最先容易想到的就是通过加锁来解决,因为大家都知道加锁能够保证临界区代码的互斥性。 这样的理解虽然正确,但是不够完善。

临界区的代码是操作受保护资源的有效路径,类似于球场的入口,入口一定要门票,也就是加锁。但不是随便一把锁都能有效,所以必须深入分析 锁定的对象和受保护资源之间的关系综合考虑受保护资源的访问路径,多方面考量才能保证用好互斥锁。

synchronized 是 Java 在语言层面提供的互斥原语,Java 中还有很多其他类型的锁,但是作为互斥锁,其原理是相同的。

锁一定要有一个锁定的对象,至于这个对象要保护的资源以及在哪里进行加锁,就是设计层面的事情了。

个人总结:

本章对于 synchronized 进行了一个例子与简单的介绍,通过这一章的学习,我对 JCIP 中反复强调的 同一把锁的重要性背后的具体原因弄明白了,感觉很值得。

课后思考:

下面代码使用 synchronized 修饰的同步带吗块是否正确?存在哪些问题?能解决可见性和原子性问题吗?

class SafeCalc {
    long value = 0L;

    long get() {
        synchronized (new Object()) {
            return value;
        }
    }

    void addOne() {
        synchronized (new Object()) {
            value += 1;
        }
    }
}
个人思考:

之前反复强调了保护资源要使用同一把锁,这里每次的锁对象 都是一个变化的 Object 对象,所以存在锁对象不一致的问题。

高质量留言:

「加锁本质」就是在锁对象的对象头中写入当前线程id,但是new object每次在内存中都是新对象,所以加锁无效。

经过JVM「逃逸分析的优化」后,这个sync代码直接会被优化掉,所以在运行时该代码块是无锁的。 <---【指出了 JVM 底层的优化机制】

互斥锁锁住了一个代码段 除非获得互斥锁,否则其他的线程不能访问这段代码。 代码段中包含了对被保护资源的操作。
但是 这把锁似乎可以是任何的对象。 这个锁对象可以和被保护资源有或者没有任何包含关系。有包含关系的就是用this,
没有包含关系的情况比如:

public class DemoClass
{
  private final Object lock = new Object();
  public void demoMethod(){
    synchronized (lock)
    {
      //other thread safe code
    }
  }
}

对于class levelsynchronized,我的理解是static变量属于类而被所有实例共用。所以用object.class这个对象作为锁非常合适。这也等价于

public class DemoClass
{
  // 作为锁的类的 静态 Object 对象。 所有 DemoClass 使用同一把锁
  private final static Object lock = new Object();

  public void demoMethod()
  {
    //Lock object is static
    synchronized (lock)
    {
      //other thread safe code
    }
  }
}

然而用object.class作为来保护一个非静态资源不太合适了。例如

class X {
 // 修饰静态方法
 synchronized(X.class) void bar() {
  // 临界区
 }
}

类的不同实例都可能来竞争这同一个锁,会导致并发程序非常「低效|。

另外,不要用String字面量来作为锁,可能会被其他的对象引用,导致死锁

不要用non-final字段来作为锁,non final的对象可能会随时被改变,而导致两个线程synchronize on different object,从而无法起到保护临界区资源的效果

其他链接:

深入理解 Java 锁与线程

Java 中的 Monitor 机制

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。