竞态条件和同步
1、竞态条件(Race Condition) :两个以上的线程同时对一个公用的变量进行读写操作,由于执行顺序无法控制,导致结果出现错误。
- 计算输出 : 比如方法调用的结果
- 不受控制:比如线程调度是不受控制的,具有随机性
- 顺序或时机:比如那个线程先执行,执行到哪一行代码由OS切换出去,那个线程切换进来有CPU执行
下面举个浅显的例子,假设有这样一个计算器:
public class Calculator { private final int[] input; private int start; private int end; public Calculator( int start, int end ) { super(); this.input = new int[] { start, end }; reset(); } private void reset() { start = input[0]; end = input[1]; } public int add() { int res = 0; for ( ; start <= end; start++ ) res += start; reset(); return res; } }
我们期望它每次计算都能输出正确的结果,下面有两个线程连续调用计算器100次:
private static class CalculateThread extends Thread { private Calculator calculator; public CalculateThread( Calculator calculator ) { super(); this.calculator = calculator; } public void run() { for ( int i = 0; i < 1000; i++ ) System.out.println( calculator.add() ); } } public static void main( String[] args ) { Calculator calculator = new Calculator( 0, 100 ); new CalculateThread( calculator ).start(); //线程1 new CalculateThread( calculator ).start(); //线程2 }
观察计算结果,可以看到,大部分的调用能够得到正确的和5050,但是会有一些随机的值夹杂其中,例如0、4189、1766…… 而且每次运行的输出结果都不一样。这就是典型的竞态条件场景了,线程1在调用add()方法时,随时可能因为OS的线程调度而暂停运行,这个时候calculator处于一种不一致的中间状态,此时线程2来发起/继续它的add()调用,calculator的两个状态字段的值是不确定的、随机的,因而计算结果必然不可靠。
之所以大部分结果是正确的,是因为CPU分配给线程的时间片相对于add()中的循环来说,是非常漫长的,因此大部分情况下线程能够不受干扰的一次执行完这个方法。
即使是一些非常简单的代码,也可能引入竞态条件:
public class IntCounterTest { private static int counter = 0; private static class CounterThread extends Thread { public void run() { for (int i = 0; i < 10000000; i++) synchronized (this) { counter++; } } } public static void main(String[] args) throws InterruptedException { CounterThread t1 = new CounterThread(); t1.start(); CounterThread t2 = new CounterThread(); t2.start(); t1.join(); t2.join(); System.out.println(counter); } }
上面的例子中,我们让t1、t2两个线程分别把计数器的值增加1000万,期望结果应该是2000万,但是几乎每次运行结果都不正确。 什么原因呢?因为++操作符至少包含三条指令:
- 从main memory 中读取变量到local memory
- 进行变量计算
- 将结果更新到main memory
出现竞态条件的根本原因是一项操作(例如一个方法调用、一次计算)不是原子(atomic)操作。
2、synchronize
是让一个java的方法编程原子的,那么就需要线程在执行这个方法时不受干扰,即其他线程不会来修改共享内存的状态。达到这一目的就是采用互斥锁(Mutex Lock) 机制,该机制可以保持同一时刻只有一个线程对关键的、修改共享资源状态的代码具有访问权。Java语言支持互斥锁机制--synchronize关键字,我们可以对上例的代码进行改写:
private static class CounterThread extends Thread { public void run() { for ( int i = 0; i < 10000000; i++ ) //限制同一时刻只有一个人能访问对象IntCounterTest.class //由于这个类对象是全局唯一的,因此同一时刻只有一个线程能对counter进行递增 synchronized ( IntCounterTest.class ) { counter++; } //在同步块的边界,所有寄存器变量的值被视为无效 } }
这样再执行,结果就总是2000万了。不过使用synchronized的代价是高昂的,在我机器上测试,修改前后代码运行耗时相差超过100倍。Java5提供的AtomicInteger性能相对较好(仍然有10倍性能差距),可以用来代替上例的int。
等待(阻塞)在synchronized块入口的线程,和sleep的线程、wait的线程,从Linux系统的角度来说,都处于S(可中断睡眠)状态。
volatile
在Java中除了long、double以外变量的存储和读取都是原子操作,因此这些变量的读取和存储不会有中间状态。即便如此,没有受保护的共享变量任然是不安全的,因为Java的内存模型允许线程持有共享变量的本地内存(Local memory,往往是寄存器) 拷贝,这就意味着,一个线程修改了某些共享变量后,其他线程可能不会直接发现。解决这个问题有两种方式:
- 禁止变量直接访问,使用synchronize关键字对getter/setter 方法进行保护
- 变量使用volatile生命变量
第二种方式更加的优雅,volatile可以理解为对单个变量的读写进行同步(JSR-133),volatile关键字包括:
可见性 : 确保没次变量的读都去主内存
原子性: 确保没次变量的写都直接更改住内存
有序性: Happens-Before原则,对volatile写操作必须发生在后续读操作后。
volatile关键字可以用在各种变量上,甚至是数组和对象,但是后两者,volatile定义的作用域是在引用(指针)上。