PSJay Blog

#FIXME, seriously

Java并发总结(二):同步与原子性

| Comments

接上篇:Java并发总结(一):线程基础

每一个线程自顾自的做自己的工作固然好。但是线程之间经常会相互影响(竞争或者合作),比如多个线程需要同时操作一个资源(比如一个对象)。这个时候,如果不进行同步,就可能会引发难以预料的错误。

举一个《thinking in java》第四版中的例子。有一个EvenGenerator类,它的next()方法用来生成偶数。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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对象产生的数字进行检验。

1
2
3
4
5
6
7
8
9
10
11
public class Test {

  public static void main(String[] args) {
      EvenGenerator generator = new EvenGenerator();
      Thread t1 = new Thread(new EvenChecker(generator));
      Thread t2 = new Thread(new EvenChecker(generator));

      t1.start();
      t2.start();
  }
}

显然,在一般情况下,EvenGenerator的next()方法产生的数字肯定是一个偶数,因为在方法体里进行两次"++currentValue"的操作。但是运行这个程序,输出的结果竟然像下面这样(并不是每次都是这个一样的结果,但是程序总会因这样的情况而终止):

849701不是一个偶数!

错误出在哪里呢?程序中有“危险”注释的哪一行便可能引发潜在的错误。因为很可能某个线程在执行完这一行只进行了一次递增之后,CPU时间片被另外一个线程夺去,于是就生产出了奇数。

解决的办法,就是给EvenGenerator的next()方法加上synchronized关键字,像这样:

1
2
3
4
5
public synchronized int next() {
      ++currentValue;
      ++currentValue;
      return currentValue;
}

这个时候这个方法就不会在并发环境下生产出奇数了。因为synchronized关键字保证了一个对象在同一时刻,最多只有一个synchronized方法在执行。

synchronized

每一个对象本身都隐含着一个锁对象,这个锁对象就是用来解决并发问题的互斥量(mutex)。要调用一个对象的synchronized方法的线程,必须持有这个对象的锁对象,在执行完毕之后,必须释放这个锁对象,以让别的线程得到这个锁对象。因为一个对象仅有一个锁对象,这就保证了在同一时刻,最多只有一个线程能够调用并执行这个对象的synchronized方法。其他想调用这个对象的synchronized方法的线程必须等待当前线程释放锁。就像上面举的例子,在同一时刻,最多只有一个EvenChecker能调用EvenGenerator的next()方法,这就保证了不会出现currentValue只递增一次,CPU时间片就被别的线程夺去的情况。

再来考虑一下前几天发生的事情。因为日本地震海啸以及核爆炸的缘故,有人造谣说,咱国内已经受到了核污染,吃含碘的东西能够减轻核辐射带来的影响。于是就有投机的人在淘宝上开了一家网店,专卖碘片,一块钱一片。生意十分火爆。有很多个买家不断地在买碘片,一直到把钱给用光。买家买碘片的这些钱都打到了卖家的同一个银行账号里。所以,结果就是,买家所有的钱最后都到了卖家的银行账户里,卖家银行账号里的总额就是所有买家在买碘片之前的现金总计。

所以可以这样来设计类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
 * 银行账户类
 * @author PSJay
 *
 */
public class BankAccount {

  private int total = 0;

  public void add(int n) {
      total += n;
  }

  public int getTotal() {
      return total;
  }
}
/**
 * 买家类
 * @author PSJay
 *
 */
public class Customer implements Runnable{

  private BankAccount account;
  private int cash;

  public Customer(int cash, BankAccount account) {
      this.cash = cash;
      this.account = account;
  }

  public void cost(int n) {
      cash -= n;
      account.add(n);
  }

  @Override
  public void run() {
      while(cash > 0) {  //直至将钱用光
          cost(1);
      }
      System.out.println("total: " + account.getTotal());   //打印出银行账户的总计金额
  }
}
//测试类
public class Test {

  public static void main(String[] args) {

      BankAccount account = new BankAccount();
      for(int i = 0; i < 100; i++) {
          new Thread(new Customer(100000, account)).start();
      }
  }
}

正如代码所示,有100个聪明的向往健康长寿的又有钱的买家各自用10万块不断地狂买碘片。

你可能注意到了,BankAccount类的add()方法并不是synchronized的。为什么呢?因为add()方法里只有一句话,那就是“total += n;”,所以不会出现第一个例子中那样多句话执行到一半被打断的问题。这是正确的么?实践是检验真理的唯一标准,上述程序的某次运行结果如下:

(省略了N行)
total: 7861909
total: 7881906
total: 7995946
total: 8001495
total: 8081441

oops!居然出问题了!最后卖家银行账户里的总额并不是100*100000 = 10000000。看吧,无故少了这么多钱,有谁会愿意去这样的“吞钱”银行开设账户呢? 那么问题究竟出在哪里了呢?BankAccount的add()方法不是只有一句话么,难道这一句话也能被打断?回答就是:这一句话确实能够被打断,因为这样的操作不具有原子性(atomicity),关于原子性稍后再总结。先谈谈怎么解决这个银行吞钱的问题。当然,如上面所说的,给add()方法和getTotal()加上synchronized就行了。

synchronized除了能修饰方法之外,还能创建同步块。如果有时候一个方法里面只有几句话需要同步,你可以考虑这种写法:

1
2
3
4
5
6
7
public void doSomething() {
     //一些操作
     synchronized(this) {
          //一些需要被同步的操作
     }
     //另外一些操作
}

其中,synchronized后的括号内必须要为一个对象。表示:要执行同步块里的这些操作一定要当前线程取得括号内的这个对象的锁才行。常用的就是this,表示当前对象。在一些高级应用中,可能会用到其他对象的锁。

你也可以显式的使用锁对象来实现同步,Java提供了一些Lock类,本篇总结中不打算包含这些内容。

原子性(atomicity)

具有原子性的操作被称为原子操作。原子操作在操作完毕之前不会线程调度器中断。在Java中,对除了long和double之外的基本类型的简单操作都具有原子性。简单操作就是赋值或者return。比如"a = 1;“和 "return a;"这样的操作都具有原子性。但是在Java中,上面买碘片例子中的类似"a += b"这样的操作不具有原子性,所以如果add方法不是同步的就会出现难以预料的结果。在某些JVM中"a += b"可能要经过这样三个步骤:

  1. 取出a和b

  2. 计算a+b

  3. 将计算结果写入内存

如果有两个线程t1,t2在进行这样的操作。t1在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,于是t2开始执行,t2执行完毕后t1又把没有完成的第三步做完。这个时候就出现了错误,相当于t2的计算结果被无视掉了。所以上面的买碘片例子在同步add方法之前,实际结果总是小于预期结果的,因为很多操作都被无视掉了。

类似的,像"a++“这样的操作也都不具有原子性。所以在多线程的环境下一定要记得进行同步操作。 有一些并发大牛可以利用原子性避免同步而写出“免锁”的代码。Goetz开玩笑说:

如果你能编写出一个牛逼的高性能的JVM,你就可以考虑考虑是否可以避免使用同步。

所以,在成为这样牛的大牛之前,还是老老实实使用同步吧。

Java SE引入了原子类,比如AtomicInter,AtomicLong等等。

volatile

上面提到了,对long和double的简单操作不具有原子性。但是,一旦给这两个类型的属性加上volatile修饰符,对它们的简单操作就会具有原子性(当然这是说的在Java SE5之后的故事)。

在一些情况下即便是原子操作也可能会引发一些错误,特别是在多处理器的环境下。因为多处理器的计算机可以将内存中的值暂时储存在寄存器或者本地内存缓冲区中。所以,运行在不同处理器上的线程取同一个内存位置的值可能不相同。有一些编译器也会自作主张地优化指令,使得上述情况发生。你当然可以用同步锁来解决这些问题,不过volatile也能解决。

如果给一个变量加上volatile修饰符,就相当于:每一个线程中一旦这个值发生了变化就马上刷新回主存,使得各个线程取出的值相同。编译器不要对这个变量的读、写操作做优化。

但是值得注意的是,除了对long和double的简单操作之外,volatile并不能提供原子性。所以,就算你将一个变量修饰为volatile,但是对这个变量的操作并不是原子的,在并发环境下,还是不能避免错误的发生!比如在碘片例子中,将BankAccount类写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
public class BankAccount {

  private volatile int total = 0;

  public void add(int n) {
      total += n;
  }

  public int getTotal() {
      return total;
  }
}

即便total被volatile修饰,但是由于add方法不是同步的,所以不能避免错误的发生!

Comments