写点什么

Java 内存模型和 volatile、final 等关键字

用户头像
麻瓜镇
关注
发布于: 2020 年 05 月 10 日

在读写共享对象的时候,Java不保证linearizability以及sequential consistency。原因是遵守它们会很大程度的违反编译器的各种优化策略,比如寄存器的分配、冗余读操作的消除等等,这些优化都是基于编译器的重排序优化。在单线程程序中,编译器对指令的重排序是透明的,不需要太关心,但是在多线程程序中,一个线程可能会收到另一个线程的指令重排序的影响,产生意料之外的结果,大部分是错误的。

为了解决这个问题,Java Memory Model定了一些规范,只要程序满足这些规则,就满足sequential consistency。这些规则非常的复杂,本文重点关注一些直观的规则,可以满足绝大部分场景。

我们先来看一段代码,是经典的double checked locking模式。

public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}

这是一个经典的并发单例的代码,但是在JDK5之前是有bug的。我们先分析一下代码的逻辑。首先我们跳过第一个check,看到synchronized,因为是多线程下的单例,需要使用同步原语synchronized来避免多个线程同时看到instancenull然后创建了多个instance。回头开头的第一个check,这是一个优化,因为当instance已经创建好了之后,就可以直接返回创建好的单例了,没有必要多线程竞争做第二个check。

看上去一切都好,但是有一个隐藏了很深的问题,举例来说明:

  1. 线程A注意到instance为空,尝试获取class的锁,并成功,开始初始化单例。

  2. 在Java中,如果构造函数被内联了,共享变量会在空间分配好之后立刻被更新,这时内联的构造函数还没有执行完,但是instance已经不为空了。

  3. 线程B此时会注意到instance已经不为空了,这里有两种常见的错误场景

  4. 此时instance没有初始化完成,但是因为instance已经不为null了,线程B会认为instance已经初始化完成,直接将其返回。

  5. 此时instance已经初始化完成,但是instance还没有扩散到线程B使用的内存(cache coherence)。

在Java的内存模型中,并发场景中的对象都保存在共享内存中,同时在每个线程有一个私有的工作内存,在每个工作内存中保存着对象的cache。在没有显式的同步操作的前提下,线程对自己的缓存区域写操作,不会立刻同步到所有的cache中,线程对对象的读操作,也不会将最新的值同步到本地cache。很多时候你会感到JVM保持了cache的更新,但是实际上JVM并没有责任这样做。所以我们只能保证线程自己的读写,在该线程的视角下是有顺序的。

一般语境下,synchronized表示原子操作或者互斥等同步术语,在Java中,它还意味着一个线程的cache和共享内存的同步。有些同步事件会让线程将cache写回到共享内存,让其他线程可以及时看到变量值的更新,有些同步事件会让一个线程将自己的cache失效,强制去读共享内存中的最新值。同步事件满足linearizable,它们是全局有序的,并且每个线程都遵守这个顺序。下面我们介绍一下几个常见的同步事件。

锁和synchronized代码块

线程可以通过两种方式达到互斥的效果:一种是synchronized代码块或synchronized方法,这种会获取一个隐式的锁;另一种就是显式的锁,比如ReentrantLock等。这种方法效果是一样的。

如果对一个成员的访问都是通过同一把锁,那么这些读写访问满足linearizable。当一个线程释放锁的时候,它修改过的成员,会将最新的value同步到共享内存中;当一个线程获取到锁的时候,它会让自己的工作内存也就是cache失效,确保从共享内存中读到最新的value。

Volatile成员

volatile成员满足linearizable。读一个volatile成员就像获取一把锁:线程的工作区失效,成员的最新value被线程读进来;写一个volatile成员就像释放一把锁:立刻将最新值同步到共享内存。

它和锁的区别在于读写不是原子操作。举一个例子,加入我们有一个volatile成员x,那么多个线程执行x++这个语句,并不能保证线程之间不产生竞争。volatile一个常见的使用场景就是多个线程对一个成员读,只有一个成员会对这个线程写。其实单例就是这样一个场景,只有第一个获取到锁的线程执行到了单例的初始化,其余的线程都是读取单例。

java.util.concurrent.atomic包包含一些满足linearizable的类型,比如AtomicReference<T>或者AtomicInteger,它们的compareAndSet()set()方法的行为和volatile写类似,而get()方法和volatile读类似。

Final成员

final成员一但初始化后就不可修改了,当final成员在构造函数中初始化时,需要遵守一定的规则,就可以保证final成员被所有的线程看到正确的value。例如下面的代码片段:

class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x; int j = f.y;
}
}
}

因为xfinal成员,所以任何一个线程访问reader()中可以确保x的值一定是3,但是y不一定是4

但是,如非必要,不要在构造函数中将this传给其他函数,否则其他线程通过this访问final成员的时候,final成员初始化的值可能还没有被同步到共享内存中。



发布于: 2020 年 05 月 10 日阅读数: 61
用户头像

麻瓜镇

关注

还未添加个人签名 2017.12.29 加入

还未添加个人简介

评论

发布
暂无评论
Java内存模型和volatile、final等关键字