java并发编程系列-解决有序性、可见性、原子性问题

359 阅读6分钟

我们其实已经分析过了造成可见性和有序性的原因,就是 CPU 缓存和编译器优化问题。我们似乎能够想到的解决方案就是禁止编译器优化和直接操作内存上的变量不经过缓存,但是如果直接这样简单粗暴,可能会带来性能问题,我们都知道 CPU 和内存之间存在巨大的速度差异。

那么 Java 是如何解决这个问题的呢?那就需要 Java 内存模型,Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatileSynchronized 关键字,以及 Happens-Before 规则

1. Java 内存模型自带 Happens-Before 规则

程序顺序性原则

其实这个原则比较好理解,就是在线程执行过程中,按照程序顺序,前面的操作先行发生于后续的操作,第 1 行代码 i = 0; 先行发生于第 2 行代码 is = true;,也就是程序在之前修改过的变量对程序后续操作都是可见的。

示例:2-1
public void demo() {
    int i = 0;//1
    boolean is = true;//2
}
管程锁的规则

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

管程是属于一种进程同步互斥工具,一种通用的同步原语,在 Java 里 Synchronized 就是它的内置锁实现,所以 Synchronized 也称为内置锁。

示例:2-2
synchronized (this) { // 自动加锁
  // m 是共享变量, 初始值 = 1
  if (m > 0) {
      m = 10; 
  }  
} // 自动解锁

所以结合管程锁的规则,可以这样理解:假设 m 的初始值是 1,线程 A 执行完代码块后 的值会变成 10 (执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 m 的写操作,也就是线程 B 能够看到 m == 12。这个也是符合我们直觉的,应该不难理解。

自动释放锁 volatile 变量规则

这条规则是指对一个 volatile 变量的写操作,先行发生于后续对这个 volatile 变量的读操作。

其实我们可以这样来理解:volatile 可以让 a 线程在写这个变量之前读取到它的最新值。

传递性原则

如果操作 1 先行发生于操作 2,操作 2 先行发生于操作 3,那么操作 1 先行发生于操作 3。

线程启动规则

Thread 对象的 start() 方法先行发生与该线程的每个动作。

其实我们可以这样理解:假如我们在主线程中启动了某个子线程,那么主线程在调用子线程的 start() 方法的方法之前的所有主线程修改共享变量的操作对子线程都是可见的。

例如:

示例:2-3
public static void main(String[] args) {
        int b  = 10;
        Thread a = new Thread(()->{
            //这里可以读取到b=100;
        });
        //修改共享变量
        b = 100;
        a.start();

}
线程终止规则

线程中的所有操作都先行发生与对此线程的终止检测,可以通过 Thread.join() 和 Thread.isAlive() 的返回值等手段检测线程是否已经终止执行。

该规则的意思其实:

示例:2-4 
public static void main(String[] args) throws InterruptedException {
        int b = 10;
        Thread a = new Thread(()->{
            b = 100;
        });
        a.start();
        a.join();
        //此时可以读取到子线程对变量b的修改
 }
线程中断规则

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。

对象终结规则

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

解决原子性问题

我们前面了解到原子性问题产生的原因是线程切换导致的,那么如何解决线程切换导致得原子性问题呢?

其实要解决原子性问题的根本就是同一时刻只有一个线程执行,也称为互斥,谈到互斥大家都知道加锁。

Synchronized(内置锁)

Synchronized 是 Java 提供的加锁关键字,可以用来修饰方法(静态、非静态方法)、代码块。

示例:

示例:2-5
public class LockDemo {
  private Object obj = new Object();

  // 修饰非静态方法
  public synchronized void a() {
    // 临界区
  }
  // 修饰静态方法
  public synchronized static void b() {
    // 临界区
  }
  // 修饰代码块
  public void c() {
    synchronized(obj) {
      // 临界区
    }
  }
}  

Synchronized 加锁和释放锁都是 JVM 层面完成的,我们在使用 Synchronized 的时候不用考虑释放锁的问题,但是我们得明确加锁和释放锁总是成对出现的。

这里有一点需要注意,在使用同步代码块得时候我创建了一个 obj 作为同步代码块的锁,但是在修饰非静态方法和静态方法的时候我并没有手动设置锁,这里 JVM 是怎么做的呢?

  1. 修饰非静态方法的时候,默认以当前对象为锁(this)
  2. 修饰静态方法的时候,默认以当前类的 Class(X.class)
用 Synchronized 解决常见问题 count+=1

大家都知道 count+=1 并不是一个原子性操作,需要三条 CPU 指令才能完成,如何用 Synchronized 解决该问题,其实大家很容易就能写出下面的代码。

示例:2-6
public class CountDemo {
  private long count = 0L;

  public synchronized void add() {
    count += 1;
  }

  public long get() {
    return count;
  }

}

我相信大家都很容易写出这样的代码,但是这段代码有没有问题,很多人只注意既然 count+=1 有问题,那我就把它当临界区,然后给它加上锁就好。对于后续线程想要修改这个变量 count 都必须调用 add,这个方法是安全的,但是如果有线程需要获取 count 的值,这时就会调用 get(),这时问题就出现了。get() 并没有加锁操作,可见性就没办法保证。

解决办法也很简单,给 get 方法加上 Synchronized 或同步块就行:

示例:2-7
public class CountDemo {
  private long count = 0L;

  public synchronized void add() {
    count += 1;
  }

  public long get() {
      //注意 这里的锁和add()方法必须得是同一把锁this
    synchronized(this) {
         return count;
    }
  }
}

锁和资源

上面示例 2-6 中加上同步代码快得时候,我特意注释了下,必须得使用同一把锁。在并发编程领域无法使用不同锁去锁住同一资源。

示例:2-8
public class CountDemo {
  private long count = 0L;

  public synchronized static void add() {
    count += 1;
  }

  public long get() {
      //注意 这里的锁和add()方法必须得是同一把锁this
    synchronized(this) {
         return count;
    }
  }
}

示例 2-7 我将原来的非静态方法改成了静态方法,这样有什么问题,结合前面得知识点,我们很容易知道两个方法使用了不同得锁,一个使用本类 Class,一个使用当前对象,所以在调用 get() 方法无法保证 count 的可见性(当然我们也可以使用 volatile 来保证变量的可见性)。