《Java 并发编程实战》03 阅读笔记

93 阅读3分钟

Java 是如何解决原子性问题的?

原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?
而操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。 作者认为,在单核 CPU 时代,这个方案的确是可行的,但是并不适合多核场景。

如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性。 也即同一时刻只有一个线程执行,称之为互斥。 详见 简易锁模型和改进后的锁模型 两张示例图。

Java 语言通过 synchronized 关键字实现了锁 synchronized 既可以用来修饰方法,也可以用来修饰代码块。
Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()
synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢?

当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X
当修饰非静态方法的时候,锁定的是当前实例对象 this

值得注意的是,修饰代码块的时候,需锁定一个 Object 对象。

来看一段代码

class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作,那是否有可见性问题呢?
根据“对一个锁解锁 Happens-Before 后续对这个锁的加锁”这个规则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

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

最后,记得 get() 方法也要 synchronized 一下(详见保护临界区 get()addOne() 的示意图)。

再看一段代码

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

改动后的代码是用两个锁保护一个资源,由于临界区 get()addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne()value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了(详见两把锁保护一个资源的示意图)。

不过,作者认为倒是可以用同一把锁来保护多个资源。

只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。
但作者认为,还得深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。