引言

多线程对比多进程,很重要的一个优点是可以共享同一地址空间,自由地访问共享变量,但是这也带来了很多问题,如果对共享变量的访问不加限制,就会出现很多数据问题,这篇文章,我们来看由共享变量导致的数据竞争问题和临界区的概念。

竞争条件(竞态条件)

与共享变量一样,数据竞争也是多线程编程中的概念而不是java语言层面的问题。为了更清楚地理解,在给出竞争条件的定义之前,我们还是举一个很简单的java的例子来看什么数据竞争(在《java并发编程实战》中,数据竞争被称为竞态条件)。

  1. public class UnsafeCount {
  2. private static int count = 0;
  3. public static void getNext(){
  4. count++;
  5. }
  6. public static void main(String[] args) throws InterruptedException {
  7. Thread thread1 = new Thread(new Runnable() {
  8. @Override
  9. public void run() {
  10. for(int i=0;i<500;i++){
  11. getNext();
  12. }
  13. }
  14. });
  15. Thread thread2 = new Thread(new Runnable() {
  16. @Override
  17. public void run() {
  18. for(int i=0;i<500;i++){
  19. getNext();
  20. }
  21. }
  22. });
  23. thread1.start();
  24. thread2.start();
  25. //等待较长时间 让thread1和thread2执行完成
  26. Thread.sleep(5000);
  27. System.out.println(count);
  28. }
  29. }

运行的结果不能保证是1000,因为count++操作并不是原子操作,它包含3个独立的操作:取count的值,将count加一,写回count的值,因此,两个线程可能同时取到9,然后同时对9加1,最后同时将10写回。像这种两个或者多个线程读写共享数据,并且最终的结果取决于线程运行的精确时序的情况,称为竞态条件。
还有一种典型的竞态条件是先检查后执行,先检查后执行的一种常见情况是延迟初始化,看下面的例子:

  1. public class UnsafeInit {
  2. private SimpleObject instance = null;
  3. public SimpleObject getInstance(){
  4. if(instance == null){
  5. instance = new SimpleObject();
  6. }
  7. return instance;
  8. }
  9. }

当多个线程同时执行getInstance时,就可能出现竞态条件,最后可能两个线程拿到的并不是同一个SimpleObject。

临界区

实际上,凡是涉及共享数据读写的情况都可能引发数据竞争的问题,我们需要做的是在找到某种途径来避免多个线程同时读写共享的数据,也就是说,在一个线程读写共享数据的时候,其他线程不能读写共享数据。
避免多个线程同时访问共享数据,就可以理解为互斥。
我们把对共享内存进行访问的程序片段称为临界区,上面例子中,

  1. count++;

  1. if(instance == null){
  2. instance = new SimpleObject();
  3. }

都是临界区,如果我们能够使多个线程不同时处于临界区中,就能避免竞争条件。
那么怎么保证这一点,也就是怎么实现互斥呢?有很多种方法,但是总体来说会分为两类:忙等待互斥和基于线程阻塞/唤醒的互斥。下面,我们会陆续介绍这两种互斥方法。

小结

虽然我给出了java的例子来帮助理解数据竞争和临界区,但是请不要将这两个概念仅仅局限在java语言中,同理,后面要介绍到的实现互斥的方式,也是操作系统或者说硬件指令集提出的解决方案,java作为一门编程语言,不可避免的会遇到数据竞争的问题,而它的解决方式是利用操作系统或者硬件指令集提供的能力来实现的。所以后面讲互斥的实现时,我们的关注点仍然不是java语言本身。