3、线程同步与原子性
线程安全:
每一个线程只做自己的工作固然好,但是线程之间经常会相互影响(竞争或者合作),多个线程需要同时操作同一个资源(比如一个对象)是常有的事。这个时候,线程安全问题就出现了。
举一个《thinking in java》第四版中的例子。有一个EvenGenerator类,它的next()方法用来生成偶数。如下:
public class EvenGenerator {
private int currentValue = 0;
private boolean cancled = false;
public int next() {
++currentValue; //危险!
++currentValue;
return currentValue;
}
public boolean isCancled() {
return cancled;
}
public void cancle() {
cancled = true;
}
}
另外有一个EvenChecker类,用来不断地检验EvenGenerator的next()方法产生的是不是一个偶数,它实现了Runnable接口。
public class EvenChecker implements Runnable {
private EvenGenerator generator;
public EvenChecker(EvenGenerator generator) {
this.generator = generator;
}
@Override
public void run() {
int nextValue;
while(!generator.isCancled()) {
nextValue = generator.next();
if(nextValue % 2 != 0) {
System.out.println(nextValue + "不是一个偶数!");
generator.cancle();
}
}
}
}
然后创建两个EvenChecker来并发地对同一个EvenGenerator对象产生的数字进行检验。
public class Foo {
public static void main(String[] args) {
EvenGenerator generator = new EvenGenerator();
while(i-->0){
new Thread(new EvenChecker(generator)).start();
new Thread(new EvenChecker(generator)).start();
}
}
}
显然,在一般情况下,EvenGenerator的next()方法产生的数字肯定是一个偶数,因为在方法体里进行两次”++currentValue”的操作。但是运行这个程序,输出的结果竟然像下面这样(并不是每次都是这个一样的结果,但是程序总会因这样的情况而终止):
849701不是一个偶数!
错误出在哪里呢?程序中有“危险”注释的哪一行便可能引发潜在的错误。因为很可能某个线程在执行完这一行只进行了一次递增之后,CPU时间片被另外一个线程夺去,于是就生产出了奇数。
解决的办法,就是给EvenGenerator的next()方法加上synchronized关键字,像这样:
public synchronized int next() {
++currentValue;
++currentValue;
return currentValue;
}
这个时候这个方法就不会在并发环境下生产出奇数了。因为synchronized关键字保证了一个对象在同一时刻,最多只有一个synchronized方法在执行。
synchronized
每一个对象本身都隐含着一个锁对象,这个锁对象就是用来解决并发问题的互斥量(mutex)。要调用被synchronized修饰的Method的线程,必须持有这个对象的锁对象,执行完后,必须释放这个锁对象,以便别的线程能够得到这个锁对象。一个对象仅有一个锁对象,这就保证了在同一时刻,最多只有一个线程能够调用并执行这个被synchronized修饰的方法。其他想调用这个方法的线程必须等待当前线程释放锁。就像上面举的例子,在同一时刻,最多只有一个EvenChecker能调用EvenGenerator的next()方法,这就保证了不会出现currentValue只递增一次,CPU时间片就被别的线程夺去的情况。
synchronized除了能修饰方法之外,还能用来创建同步块。直接用来修饰方法并不是一个好的办法,这会锁住比较多的代码行。所以大多数情况下,使用同步块是一个更好的选择。
public void doSomething() {
//一些操作
synchronized(this) {
//一些需要被同步的操作
}
//另外一些操作
}
其中,synchronized后的括号内必须是一个对象。表示:要执行同步块里的这些操作一定要当前线程取得括号内的这个对象的锁才行。常用的就是this,表示当前对象。在一些高级应用中,可能会用到其他对象的锁。
你也可以显式的使用锁对象来实现同步,Java提供了一些Lock类,此处不做描述。
原子性(atomicity)
具有原子性的操作被称为原子操作。原子操作在操作完毕之前不会线程调度器中断。在Java中,对除了long和double之外的基本类型的简单操作都具有原子性。简单操作就是赋值或者return。比如”a = 1;“和 “return a;”这样的操作都具有原子性。”a += b”这样的操作不具有原子性,在某些JVM中”a += b”可能要经过这样三个步骤:
取出a和b
计算a+b
将计算结果写入内存
如果有两个线程t1,t2在进行这样的操作。t1在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,于是t2开始执行,t2执行完毕后t1又把没有完成的第三步做完。这个时候就出现了错误,相当于t2的计算结果被无视掉了。
类似的,像”a++“这样的操作也都不具有原子性。所以在多线程的环境下一定要记得进行同步操作。
有一些大牛可以利用原子性避免同步而写出“免锁”的代码。如果你能编写出一个牛逼的高性能的JVM,你就可以考虑考虑是否可以避免使用同步。
所以,在成为这样牛的大牛之前,还是老老实实使用同步吧。
Java SE引入了原子类,比如AtomicInter,AtomicLong等等。
volatile
上面提到了,对long和double的简单操作不具有原子性。但是,一旦给这两个类型的属性加上volatile修饰符,对它们的简单操作就会具有原子性(当然这是说的在Java SE5之后的故事)。
在一些情况下即便是原子操作也可能会引发一些错误,特别是在多处理器的环境下。因为多处理器的计算机可以将内存中的值暂时储存在寄存器或者本地内存缓冲区中。所以,运行在不同处理器上的线程取同一个内存位置的值可能不相同。有一些编译器也会自作主张地优化指令,使得上述情况发生。你当然可以用同步锁来解决这些问题,不过volatile也能解决。
如果给一个变量加上volatile修饰符,就相当于:每一个线程中一旦这个值发生了变化就马上刷新回主存,使得各个线程取出的值相同。编译器不要对这个变量的读、写操作做优化。
但是值得注意的是,除了对long和double的简单操作之外,volatile并不能提供原子性。所以,就算你将一个变量修饰为volatile,但是对这个变量的操作并不是原子的,在并发环境下,还是不能避免错误的发生!