再次重复:要编写正确的并发程序,关键问题在于:在访问共享的,可变的状态时,需要进行正确的管理.
如在(二)中所述,同步可以确保以原子的方式执行操作,比如关键字 synchronized 可用于实现原子性或者确定临界区.实际上,同步还有另一个重要的方面:内存可见性.我们不仅仅是希望防止在某个线程使用对象状态的同时, 有其他线程在修改该状态.而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化.如果没有同步,则无法实现.
可见性
在多线程环境下,当读操作和写操作在不同的线程中执行时,通常无法确保读操作能够适时地看到其他线程写入的值,有时候甚至是根本不可能的事.为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制.
重排序
先看一个现象,重排序:在没有同步的情况下,编译器,处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整.在缺少同步的情况下,Java 内存模型允许编译器对操作顺序进行重排序,并将数值缓存在寄存器中,还允许 CPU 对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中.允许重排序是因为可以让 JVM 充分利用现代多核处理器的强大性能.
正是因为重排序的原因,在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论.
如下面的程序中,没有使用同步,有可能读线程永远都看不到 ready 的值;也有可能读线程看到了写入 ready 的值,但是没有看到 number 的值;还有可能得到失效的值等.
幸运的是,有一种简单的方法能够避免这些复杂问题:只要有数据在多个线程之间共享,就使用正确的同步.
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值
number = 42;
//写入ready值
ready = true;
}
}
非原子的 64 位操作
Java 内存模型要求,变量的读取操作和写入操作都必须是原子操作,但是对于非 volatile 类型的 long 和 double 变量 (8 个字节),JVM 允许将 64 位的读操作或写操作分解为两个 32 位的操作.(这是因为在编写 Java 虚拟机规范时,许多主流处理器架构还不能有效地提供 64 位数值的原子操作)
所以当读取一个非 volatile 类型的 long 或 double 变量时,如果对该变量的读操作和写操作在不同的线程中执行时,那么很可能会读取到某个值的高 32 位和另一个值的低 32 位.
因此即便不考虑失效数据问题,在多线程程序中使用共享且可变的 long 和 double 等类型变量也是不安全的,除非用关键字 volatile 声明它们或者用锁保护起来.
加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果.
当线程 A 执行某个同步代码块时,线程 B 随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A 看到的变量值在 B 获得锁后同样可以由 B 看到.也就是说,当线程 B 执行由锁保护的同步代码块时,可以看到线程 A 之前在同一个同步代码块中的所有操作结果.
所以说为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的.否则,如果一个线程在未持有正确的锁的情况下读取某个变量,可能会读到一个失效值.加锁不仅仅局限于互斥行为,还包括内存可见性.为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步.
volatile 变量
上一节提到 volatile 类型变量也是一种同步机制,不过稍弱.它主要用来确保将变量的更新操作通知到其他线程.当把变量声明为 volatile 类型后,编译器和运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序.并且 volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,所以在读取 volatile 类型的变量时,总是返回最新写入的值.
可按如下理解 volatile 变量:将它的读操作和写操作分别看成 get 方法和 set 方法.但是在访问 volatile 变量时不会执行加锁的操作,所以不会使执行线程阻塞,因此 volatile 变量时一种比 synchronized 关键字更轻量级的同步机制.
volatile 变量对可见性的影响比 volatile 变量本身更为重要.从内存可见性角度来看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量则相当于进入同步代码块.
public class SynchronizedInteger() {
private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value
}
}
仅当 volatile 变量能简化代码的实现以及对同步策略的验证时,才应该使用它们.如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用 volatile 变量.
volatile 变量通常用做某个操作完成,发生中断或者状态的标志.使用时要非常小心,比如 volatile 的语义不足以确保递增操作的原子性,除非能确保只有一个线程对变量执行写操作.(比起 volatile,原子变量提供了 "读 - 改 - 写" 的原子操作,常常作为一种 "更好的 volatile 变量")
所以:加锁机制既能确保可见性又能确保原子性,而 volatile 变量只能确保可见性.
volatile 变量的正确使用方式包括:确保它们自身状态的可见性;确保它们所引用对象的状态的可见性;以及标识一些重要的程序生命周期事件的发生(比如初始化,关闭).
当且仅当满足以下条件时,才应该使用 volatile 变量:
对变量的写入操作不依赖变量的当前值,或者可以确保只有单个线程更新变量的值.
该变量不会与其他状态变量一起纳入不变性条件中.
在访问变量时不需要加锁.
来源: https://juejin.im/post/5a5d981df265da3e3d492460