并发编程之内存可见性
在上篇线程安全中,我们已经知道需要使用锁来同步管理对可变状态的访问操作。今天我们来看下并发编程的内存可见性问题。
同步代码块除了实现原子性或者临界区之外,其还保证了内存可见性,即保证其他线程可以看到状态的变化结果。
private static boolean stop =false;
private static int number = 0;
public static class ReaderThread extends Thread
{
public void run()
{
while(!stop)
{
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args)
{
new ReaderThread().start();
number=42;
stop=true;
}
在代码中,主线程和读线程都将访问stop和number。主线程启动读线程并将number设置为42、stop设置为true。读线程一直循环知道发现stop为true停止
并输出number的值。由于代码没有足够的同步机制,以及可能会存在指令重排序,所以最终可能会输出42,也可能会输出0,也有可能会一直循环无法停下来。
注意:
在没有同步的情况下,编译器、处理器、运行时等都可能对操作执行顺序进行调整,因此无法对内存的操作顺序进行判定。
由于cpu多级缓存的存在,多线程共享状态的修改涉及到各级缓存、内存之间的同步问题。
一、失效数据
在上边的例子中,读线程可能会读取stop变量失效的值,从而导致程序无限循环而无法停止下来。
失效的数据可能导致引用失效、意外的异常、非法的数据、死循环等问题。
例如下边的UnSafeInteger类并不是线程安全的,可能导致一个线程set之后,另外一个线程get的时候可能得不到最新的值。
package com.codeartist;
public class UnSafeInteger {
private int value;
public int get()
{
return this.value;
}
public void set(int value)
{
this.value = value;
}
}
我们可以通过对set和get使用同一个锁进行同步,这样就可以防止get到失效的值。
package com.codeartist;
public class SyncInteger {
private int value;
public synchronized int get()
{
return this.value;
}
public synchronized void set(int value)
{
this.value = value;
}
}
二、非原子的64位操作和Volatile
失效的数据最终读取的还是之前线程设置的值,由于变量的读取和写入操作都是原子性的,所以绝大多数的变量都可以保证这个最低安全性。
读取和写入64位的long和double变量的操作会分解为两个32位的操作。在多个线程写入和读取时可能导致读取某个值的高32位和另外一个值的低32位。
java和.NET都提供了一种弱的同步机制,即volatile变量,访问volatile变量不会加锁导致阻塞。
volatile通过确保不会对共享状态变量施加的操作进行重排序,也不会将其缓存到cpu不可见的地方,从而保证了共享状态的内存可见性。
volatile变量值确保可见性,并不能确保操作的原子性,所以其只适合作为简单的标志判断操作是否完成、中断等。
只有满足以下条件的时候,才应该考虑使用volatile
1.对该变量的操作不依赖变量的当前值,比如自增、自减都是不适合使用的。
2.该变量不会与其他状态变量一起作为不变性条件。
3.访问该变量不需要加锁进行同步。
三、锁定与可见性
加锁可以确保某个线程以一种可预测的方式查看其他线程的执行结果,即下图中,当线程A获取M锁执行同步代码块的时看到的变量x的值,待锁释放之后,线程B也可以看到。
只要保证读取和写入操作使用同一个锁同步,即可以保证操作的互斥性,也可以保证所有线程都能看到共享变量的最新值的内存可见性。