引言
FAQ
概述了 Java
内存模型,重排序,同步,final
字段和 volatile
字段,以及双重校验锁。
因为内容较多,分为上下两篇
final
字段是如何改变值的?
final
字段是如何改变值的?
老内存模型中 final
字段是如何改变值的一个最好例子是 String
类的实现。
一个 String
是一个有 3
个字段的对象 - 一个字符数组,针对数组的偏置值,以及长度。这样实现 String
,而不是仅仅拥有一个字符数组,是为了让多个 String
和 StringBuffer
对象可以共享同一个字符数组,这样可以避免额外的对象分配和复制。所以,方法 String.substring()
可以通过创建一个 String
,共享同一个字符数组的方式,只需要修改偏置值和长度。对于 String
类,这 3
个字段都是 final
类型的
String s1 = "/usr/tmp";
String s2 = s1.substring(4);
字符串 s2
有一个偏置值 4
,以及长度 4
。不过在老内存模型中,另一个线程可能能够看到偏置值为默认值 0
,然后变成正确的值 4
,这就好像字符串 s2
从 “/usr
” 变成了 “/tmp
“。
起初的内存模型允许这种行为,一些虚拟机已经出现了这种行为。新的内存模型认为这种行为是非法的。
个人见解
利用 String
的例子说明了原先的内存模型中 final
字段的缺陷,会出现线程安全的问题
final
字段在新的 Java
内存模型(JMM
)下如何工作?
final
字段在新的 Java
内存模型(JMM
)下如何工作?
final
字段的值可以在对象构造器中设置。假设对象被“正确”的构造,那么一旦对象构造完成,这个 final
字段的值将被所有线程可见,不需要同步操作。另外,final
字段引用的其它对象或数组改变后,final
字段的值也一定会改变(the visible values for any other object or array referenced by those final fields will be at least up-to-date as the final fields
)。
怎么样才是对象被正确的构造?简单的说,对象引用不应该在构造期间”泄露”。(举例:Safe construction techniques)换句话说,不应该放置构造期间的对象引用在另一个线程可能会看到它的地方;不要赋值给静态字段;不要将其注册为其它对象的监听器,等等(In other words, do not place a reference to the object being constructed anywhere another thread might be able to see it; do not assign it to a static field, do not register it as a listener with any other object, and so on.
)。这些事情应该在构造完成后操作,而不是在构造期间。
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;
}
}
}
上面这个类是一个 final
类被正确使用的例子。一个线程执行 reader
方法能够保证看到 f.x
的值是 3
,因为它是 final
类型,但是不能保证 f.y
的值是 4
,因为它不是 final
类型。
为什么:在 reader 方法中首先判断静态实例变量 f 是否存在,如果存在,表明调用了构造器,那么 final 字段 x 的值指向最新值 3,但是字段 y 的最新值可能保存在缓存,没有刷新回内存,此时另一个线程读取时仍旧为默认值 0
如果构造器如下:
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
那么线程从 global.obj
读取 this
的引用,无法保证能够看到 x
值为 3
。
为什么:通过指令重排序,在 final 变量就泄露了当前对象的引用,那么此时调用变量 x 的值为默认值
如果字段是 final
修饰的,那么能够保证构造器结束后,代码能够看到该字段所指向的对象或者数组最新的值。所以,这种情况下,不需要担心其它线程看见了这个字段引用指向的数组,但是看不到数组最新的值(you can have a final pointer to an array and not have to worry about other threads seeing the correct value for the array reference, but incorrect values for the content of the array.
)。再次声明,这个 “正确” 指的是 “对象构造器结束时最新的值” 而不是 “最新有效的值”(we mean "up to date as of the end of the object's constructor", not "the latest value available"
)。
讲了上面这段之后,如果一个线程构造了一个不可变对象(也就是说,一个仅包含 final
字段的对象),如果想要确保它能够被其它线程正确的看见,那么通常还是需要使用同步操作。不然,没有其它办法可以确保其它线程可以看见这个不可变对象的引用。程序从 final
字段得到的保证应该
在深入理解你的代码中并发是如何管理的情况下小心的调试。
如果你使用 JNI
方法来改变 final
字段,没有明确的定义。
个人见解
正确构造后的 final
字段能够被所有线程可见,但是无法确定 final
字段引用的数组或者对象是最新的值,所以需要结合实际程序,在多线程情况下最好是执行同步操作。
参考:Java内存模型FAQ(九)在新的Java内存模型中,final字段是如何工作的
volatile
做了什么?
volatile
做了什么?
volatile
字段是一个特殊的字段,它被用于线程之间沟通状态。对 volatile
字段的每一次读都将看到所有线程对它进行的最后一次写;实际上,它被程序员指定为永远不会因为缓存或者重排序而看到 “陈旧” 的值。编译器和运行时环境禁止将它分配在寄存器中。volatile 字段还确保在写完之后,会从缓存刷新回内存,这样就可以对所有线程可见。类似的,在 volatile
字段读之前,缓存的值必须失效,这样当前线程看到的值是来自主内存的。对 volatile
变量的重排序访问还有额外的限制。
在旧内存模型中,对 volatile
变量的访问无法重排序,但是可以对其它非 volatile
变量进行重排序访问。这削弱了 volatile
字段作为多个线程之间信号条件的可用性。
在新内存模型中, volatile
变量仍旧无法被重排序,差异是 volatile
变量周围的正常变量也不会很容易被重排序。写入一个 volatile
字段和一次监视器释放有相同的内存影响,读一次 volatile
字段和一次监视器获取有相同的内存影响。实际上,因为新的内存模型对 volatile
字段对其周围字段访问设置了更严格的约束条件,当线程 A
写入 volatile
字段 f
时,任何对线程 A
可见的变量也对写入字段 f
的线程 B
可见。
下面是一个简单的 volatile
字段使用的例子:
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}
假设一个线程正在调用方法 writer
,另一个线程正在调用方法 reader
。在方法 writer
中,对 v
的写操作释放了对 x
的写操作到内存,这样对 v
的读取从内存中获取了那个值(应该指 x
)。因此,如果 reader
方法看到了 v
的值为 true
,这也保证了对 x
的写入 42
操作一定 happends-before
它。这在旧内存模型不可能是真的,因为 v
不是 volatile
修饰的,所以编译器仍旧可以重排序它的读操作,在 reader
方法中 x
的值仍旧可能得到 0
。
事实上,volatile
的语义基本上加强到同步的水平。对 volatile
字段的每一次读和写就像半个同步操作,即保证了可见性。
Note: 为了正确的设置 happends-before
关系,两个线程应该去访问同一个 volatile
变量。如果线程 A
访问 volatile
字段 f
,线程 B
访问 volatile
字段 g
,那么线程 A
可见的变量不会对线程 B
也同样可见。对 volatile
变量的获取和释放应该匹配(即执行在同一个 volatile
字段)到同一个语义。
个人见解
两个线程对同一个 volaitle
变量进行读写操作(Note:先写再读),那么对读线程可见的变量对写线程同样可见,仅在这种情况下使用 volatile
即可保证线程安全,其它情况必须增加同步操作。
JLS 8.3.1.4
小节提出一个示例:
class Test {
static volatile int i = 0, j = 0;
static void one() { i++; j++; }
static void two() {
System.out.println("i=" + i + " j=" + j);
}
}
变量 i,j
都是 volatile
声明的,线程 A
不断读取方法 one
,线程 B
不断读取方法 two
。这种情况下,仍有可能打印出 j
的值比 i
大,存在这种情况,在线程 B
读取 i
的值后,线程 A
执行了方法 one
,然后线程 B
再读取 j
。
final
和 volatile
不能同时使用,会产生编译错误
新的内存模型是否修复了”双重校验锁”(double-checked locking
)的问题?
double-checked locking
)的问题?
臭名昭著的双重校验锁(也被称为多线程单例模式)被设计用来支持延迟初始化(lazy initialization
),以避免同步的开销。在早期的 JVM
中,同步速度很慢,所以开发者愿意去延迟加载。其实现代码如下:
// double-checked-locking - don't do this!
private static Something instance = null;
public Something getInstance() {
if (instance == null) { // single checked
synchronized (this) {
if (instance == null) // double checked
instance = new Something();
}
}
return instance;
}
这种代码结构看起来非常聪明 - 避免了在公共代码路径下进行同步。它只有一个问题 – 不工作。为什么?最明显的原因是因为初始化 instance
的写入和对 instance
字段的写入可以被编译器或缓存重排序,这样将出现返回一个部分构造的实例。结果就是我们读取了一个未初始化的对象。还有很多其它原因关于为什么这是错误的,以及为什么对它进行算法修正(algorithmic corrections
)是错误的。在旧内存模型中没有方法可以修复它。更多的信息请查看 Double-checked locking: Clever, but broken 和 The “Double Checked Locking is broken” declaration
许多人假定 volatile
关键字的使用将解决双重校验锁提出的问题。在 JDK 1.5
之前的 JVM
,volatile
无法确保它工作;在新的内存模型中,声明 instance
为 volatile
将修复这个问题,因为这样就在构造线程对 Something
的初始化以及其它线程对它的读取之间存在一个 happens-before
关系。
相关,使用需求持有者(Demand Holder
)方式,不仅线程安全而且更加容易理解:
private static class LazySomethingHolder {
public static Something something = new Something();
}
public static Something getInstance() {
return LazySomethingHolder.something;
}
这段代码保证是正确的,因为 static
字段的初始化保证;如果在静态初始化器中设置字段,对所有访问该类的线程而言是可见的。
个人见解
为什么叫 “双重校验锁”:因为有两次实例检查
为什么会出现问题:上面的解释没有很清晰,Double-checked locking: Clever, but broken 的 So what's broken about DCL?
小节比较详细的介绍了重排序问题,翻译一下:
class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null) {
synchronized {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}
双重校验锁依赖于对 resource 字段的非同步使用。这似乎是无害的,但是并不是。假设线程 A 在同步块中,执行表达式 resource = new Resource();这是线程 B 也进入了方法 getResource()。
考虑初始化对内存的影响:
* 新的 Resource 对象的内存被分配;
* Resource 的构造器被调用,初始化新对象的成员变量;
* resource 字段将引用这个新创建的对象。
然而,因为线程 B 没有在同步块中执行,它可能看到另一种顺序的内存操作:
* 分配内存;
* 赋值引用给 resource;
* 调用构造器。
假设线程 B 在对象内存已分配,字段已引用时进入。那么它看到了 resource 非空,跳过了同步块,返回了一个部分构造的 Resource 对象引用。不用说,结果既不是预期的,也不是期望的。
解决方法:上文提到,在旧内存模型中无法解决双重校验锁的问题。但是在新的内存模型中,声明字段为 volatile
即可。
原因:声明字段为 volatile
,保证了对变量的可见性,读操作不会得到一个不完整的实例。
参考:
Fixing Double-Checked Locking using Volatile
如果我写一个虚拟机,应该怎么办?
你应该看 http://gee.cs.oswego.edu/dl/jmm/cookbook.html
我应该关心哪些事情?
你应该关心哪些事情?并行 bug
是很难调试的。它们经常在测试阶段没有出现,而是等到程序在高负荷下才出现,并且很难去复现和发现。你最好花费时间去确保程序是正确同步的;尽管这也不容易,但是它比花费时间调试一个坏的同步程序更简单。