初涉并发编程 3——对象的共享

维护并发程序的安全性,其关键在于:正确管理对共享的可变状态的访问

上一节讲述如何让对象同步访问相同数据,这一节介绍如何共享发布对象。

所谓的共享是指,一个对象可被多个线程访问,发布是指,一个对象能够在当前作用域之外的代码引用(这句话实际上是说,使该对象被不在当前作用域的变量引用),也就是说它逃逸出去了,这和之前所学的“逃逸分析”中的逃逸应该是同义的。

可见性

重排序

同步代码块和同步方法不仅能用于实现原子性或确定临界区(Critical Section),同步还有一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止当某个线程在使用对象状态时该状态被其它线程修改,也希望确保当一个线程修改了对象状态后,其它线程能够看到发生的状态变化

考虑如下代码——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42; // 外国人真喜欢这个梗
ready = true;
}
}

这里,ready 和 number 的赋值可能被重排序(Reordering,它是编译器进行优化的结果),导致 number 还没有被赋值时,ready 已经被赋值。因此,这里有可能会输出 0,也有可能该 ReaderThread 会持续循环下去,因为它可能看不到 ready 被修改(为啥?while 不应该会无数次地检查 ready 的值吗?难道重排序会把这个判断条件挪到外面去? 应该是因为现在的 JDK 版本做了很多优化,这种错误不容易复现了)。所以说应该使用锁使 number,ready 的赋值成为原子操作。

我是知道为什么之前看那本 Java 并发程序设计,说多线程破坏了编程语言的抽象性了,这 tm 的把底全揭出来了,甚至都不能相信指令先后执行的顺序……

为了避免这种问题,应当这样来解决——只要有数据在多个线程之间共享,就使用正确的同步

失效数据

该程序显示了缺乏同步的程序中可能产生错误结果的一种情况:失效数据。读线程查看 ready 时可能得到一个失效值,除非每次访问变量时都使用同步。

getter 和 setter 是非线程安全的,因为如果某个线程调用了 setter,另一个调用 getter 的线程可能会看到更新后的 value,也可能看不到。可以使用 synchronized 关键字来修饰 getter 和 setter 以保证可见性。

1
2
3
4
5
6
7
@NotThreadSafe
public class MutableInteger {
private int value;
//线程不安全的,需要同步关键字来修饰
public int get() { return value; }
public void set(int n) { value = n; }
}

需要注意的是,64 位操作(对 double,long)是非原子的,并且它不止会导致失效数据问题。应当使用volatile来声明(这样只能保证可见性)或用锁保护。

volatile——确保可见性

synchronized关键字保证可见性和原子性,而volatile关键字保证变量可见性。也就是说,它保证变量能获得最新的变量值。可见性问题是由于计算机的多级缓存机制引起的。

考察下面的代码,它必定陷入无限循环——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main extends Thread {
private static boolean flag = false;
// 无限循环,等待 flag 变为 true 时才跳出循环
@Override
public void run() {
while (!flag); //这里如果给 while 代码块一个 sysout,或者给 flag 加上 volatile 关键字,则能正常执行
System.out.println(42);
}
public static void main(String[] args) throws Exception {
Thread t = new Main();
t.start();
// sleep 的目的是等待线程启动完毕,也就是说进入 run 的无限循环体了
Thread.sleep(100);
flag = true;
}
}

volatile关键字保证变量的可见性,简单来说,当某个线程对变量进行修改后,在该线程后执行的线程能够看到该变量的更改,这是通过下面两条规则实现的——

  • 线程对变量进行修改后,要立刻写回到主内存中。
  • 线程对变量读取的时候,必须从主内存读,不能从缓存读。

线程启动的时候,线程的栈中不仅包含它的局部变量,也包含线程所需要的共享变量(通常存在堆里)的副本

对于共享的(没有volatile关键字的)变量来说,JVM 约定变量在工作内存中发生变化的时候,必须要回写到主内存(迟早,但并非马上),并且,当对变量读取频率很高的时候,它会持续读取缓存中的值(这就是问题所在!空的 while 循环读取太快了,因此如果添加别的代码,比如 IO,降低读取的速度,就能让 JVM 去读主内存而非缓存了)。对于volatile变量,在工作内存中发生变化的时候,马上就要回写到主内存,读取时则必须要从主内存中读取。

发布和溢出

关于发布(Publish)和逸出(Escape),发布的意义如前所述,逸出是指,本不应该发布的对象被发布

溢出的一个常见情况是发布了 private 变量——

1
2
3
4
class UnsafeStates {
private String[] states = {"AK","AL","DDDA"};
public String[] getStates() { return states; } // 这返回的可是一个数组!这让外界能够更改 states 的状态了!
}

这里,states 就逸出了它的作用域(类中),这个本应是私有的变量被发布了。

构造函数可能会让 this 逸出。

TODO: 摸了

线程封闭

实现线程安全性的最简单方式之一是线程封闭(Thread Confinement)——不共享数据。当某个对象被封闭在一个线程中时,这将自动实现线程安全性,而不需要额外的同步,即使该对象本身不是线程安全的(毕竟对这个对象来说,它所处的是单线程的环境)。

显然,局部变量是线程封闭的。

Ad-hoc 线程封闭

(这什么怪名字)

TODO,我想先看看别的书(Java 并发编程之美)再说。


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!