同步机制的其它作用:原子性、可见性和有序性
synchronized和Lock不仅可以确保原子性、可见性还可以确保有序性。
在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先具体看一下这三个概念:
原子性
同步代码块和同步方法可以确保以原子的方式执行操作。
原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作是原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。
下面的代码演示了非原子的操作:
public class UnAtomicTest {
public static void main(String[] args) throws Exception {
final Counter counter = new Counter();
Thread t1 = new Thread(new Runnable() {
public void run() {
for (int i=0; i<10000; i++)
counter.access();
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
for (int i=0; i<10000; i++)
counter.access();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
}
static class Counter {
private static int count;
public void access() {
count++;
}
public int get() {
return count;
}
}
}
执行结果:
image.png不幸的是,UnAtomicTest
并非线程安全的,尽管它在单线程环境中能正确运行。这个类很可能会丢失一些更新操作,虽然递增操作count++
是一种紧凑的语法,使其看上去只是一个操作,但是这个操作并非原子的,因而它不会作为一个不可分割的操作来执行。实际上,它包含了三个操作:读取count
的值,将值加1,然后将计算结果写入count
。这是一个“读取——修改——写入”的操作序列,并且其结果状态依赖于之前的状态。
通过把Counter.access
方法改成同步方法,就能保证原子性:
static class Counter {
private static int count;
public synchronized void access() {
count++;
}
public synchronized int get() {
return count;
}
}
执行结果如下:
image.png非原子的64位操作
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作和写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读取操作和写入操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,即时不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
可见性
加锁(synchronized同步)的功能不仅仅局限于互斥行为,还包括内存可见性。我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且还希望确保当一个线程修改了对象状态后,其他线程能够看到该变化。而线程的同步恰恰也能够实现这一点。
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。为了确保所有的线程都能看到共享变量的最新值,可以在所有执行读操作或写操作的线程上加上同一把锁。下图示例了同步的可见性保证。
image.png当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放前,A看到的变量值在B获得锁后同样可以由B看到。换句话说,当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。
现在考虑如下代码:
public class MutableInteger
private int value;
public int get(){
return value;
}
public void set(int value){
this.value = value;
}
}
以上代码中,get和set方法都在没有同步的情况下访问value。如果value被多个线程共享,假如某个线程调用了set,那么另一个正在调用get的线程可能会看到更新后的value值,也可能看不到。
通过对set和get方法进行同步,可以使MutableInteger成为一个线程安全的类,如下:
public class SynchronizedInteger
{
private int value;
public synchronized int get(){
return value;
}
public synchronized void set(int value){
this.value = value;
}
}
对set和get方法进行了同步,加上了同一把对象锁,这样get方法可以看到set方法中value值的变化,从而每次通过get方法取得的value的值都是最新的value值。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4
这段代码有4个语句,那么可能的一个执行顺序是:
image.png那么可不可能是这个执行顺序呢: 语句2-->语句1-->语句4-->语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
虽然重排序不会影响单线程程序执行的结果,但是多线程呢?下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此时线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单线程的执行,但是会影响到线程并发执行的正确性。内存级的重排序会使程序的行为变得不可预测。如果没有同步,那么推断出执行顺序是非常困难的,而要确保在程序中正确地使用同步却是非常容易的。
同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM(Java Memory Model)提供的可见性保证。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
竞态条件
竞态条件(Race Condition):当计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。最常见的竞态条件为:先检测后执行,延迟初始化。
先检测后执行:执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定和不可判断的(由于指令重排序的原因),从而导致执行结果出现各种问题。
image.png对于main线程,如果文件a不存在,则创建文件a,但是在判断文件a不存在之后,Task线程创建了文件a,这时候先前的判断结果已经失效,(main线程的执行依赖了一个错误的判断结果)此时文件a已经存在了,但是main线程还是会继续创建文件a,导致Task线程创建的文件a被覆盖、文件中的内容丢失等等问题。
延迟初始化:最典型即为单例。
public class ObjFactory {
private Obj instance;
public Obj getInstance() {
if (instance == null) {
instance = new Obj();
}
return instance;
}
}
image.png
线程a和线程b同时执行getInstance(),线程a看到instance为空,创建了一个新的Obj对象,此时线程b也需要判断instance是否为空,此时的instance是否为空取决于不可预测的时序:包括线程a创建Obj对象需要多长时间以及线程的调度方式,如果b检测时,instance为空,那么b也会创建一个instance对象。
与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。
网友评论