阐述volatile关键字:这段代码是线程安全的吗?

3
我想通过一个例子来说明volatile的使用和重要性,如果省略volatile,这个例子将不会得到良好的结果。
但是我并不太习惯使用volatile。以下代码的思路是,如果省略volatile,则会导致无限循环,并且如果存在volatile,则可以完全保证线程安全。以下代码是否线程安全?您有没有其他现实而简短的使用volatile的代码示例,如果没有volatile,它将显然给出错误的结果?
以下是代码:
public class VolatileTest implements Runnable {

    private int count;
    private volatile boolean stopped;

    @Override
    public void run() {
        while (!stopped) {
            count++;
        }
        System.out.println("Count 1 = " + count);
    }

    public void stopCounting() {
        stopped = true;
    }

    public int getCount() {
        if (!stopped) {
            throw new IllegalStateException("not stopped yet.");
        }
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest vt = new VolatileTest();
        Thread t = new Thread(vt);
        t.start();
        Thread.sleep(1000L);
        vt.stopCounting();
        System.out.println("Count 2 = " + vt.getCount());
    }
}
6个回答

10

维克多是正确的,你的代码存在原子性和可见性的问题。

这是我的版本:

    private int count;
    private volatile boolean stop;
    private volatile boolean stopped;

    @Override
    public void run() {
        while (!stop) {
            count++; // the work
        }
        stopped = true;
        System.out.println("Count 1 = " + count);
    }

    public void stopCounting() {
        stop = true;
        while(!stopped)
           ; //busy wait; ok in this example
    }

    public int getCount() {
        if (!stopped) {
            throw new IllegalStateException("not stopped yet.");
        }
        return count;
    }

}

如果一个线程观察到stopped==true,那么可以保证工作已经完成并且结果可见。

从volatile写入到volatile读取(在同一变量上)存在happens-before关系,因此如果有两个线程同时执行volatile读取和写入,它们将具有同步顺序。

   thread 1              thread 2

   action A
       |
 volatile write  
                  \
                     volatile read
                          |  
                       action B

动作A发生在动作B之前;在A中的写入对B是可见的。


4
这个例子本身存在问题,因为 count++ 操作不是原子操作。你必须在这个操作周围加锁,或者使用非阻塞的 AtomicInteger - Bruno Reis
(除非您确保只有一个线程将调用方法run(),否则上面的代码不能被称为线程安全 - Bruno Reis

1

对我来说,一直很难以令人信服地说明并发问题:好吧,happens-before和其他东西都很好,但为什么要关心呢?有真正的问题吗?有很多编写不良、同步不良的程序——它们大部分时间仍然可以工作。

我曾经在“大部分时间工作VS工作”的辩论中找到了一个出路,但老实说,这是一个弱势的方法。所以我需要一个能够明显区别的例子——最好是痛苦的。

因此,这里有一个实际上展示了差异的版本:

public class VolatileExample implements Runnable {
    public static boolean flag = true; // do not try this at home

    public void run() {
        long i = 0;
        while (flag) {
            if (i++ % 10000000000L == 0)
                System.out.println("Waiting  " + System.currentTimeMillis());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new VolatileExample());
        thread.start();
        Thread.sleep(10000L);
        flag = false;
        long start = System.currentTimeMillis();
        System.out.println("stopping " + start);
        thread.join();
        long end = System.currentTimeMillis();
        System.out.println("stopped  " + end);
        System.out.println("Delay: " + ((end - start) / 1000L));
    }
}

简单运行显示:

Waiting  1319229217263
stopping 1319229227263
Waiting  1319229242728
stopped  1319229242728
Delay: 15

也就是说,一个正在运行的线程需要超过十秒(这里是15秒)才能注意到有任何变化。

使用volatile,您可以:

Waiting  1319229288280
stopping 1319229298281
stopped  1319229298281
Delay: 0

也就是说,几乎立即退出。currentTimeMillis的分辨率约为10ms,因此差异超过1000倍。

请注意,这是苹果版本的(前)Sun JDK,并带有-server选项。等待10秒是为了让JIT编译器发现循环足够热,并对其进行优化。

希望这可以帮到您。


1
简化@Elf的例子,其中另一个线程将永远无法获得由其他线程更新的值。删除System.out.println,因为println中有同步代码,而out是静态的,这样可以帮助其他线程获取flag变量的最新值。
public class VolatileExample implements Runnable {
   public static boolean flag = true; 


  public void run() {
     while (flag);
  }

  public static void main(String[] args) throws InterruptedException {
    Thread thread = new Thread(new VolatileExample());
    thread.start();
    Thread.sleep(1000L);
    flag = false;
    thread.join();
  }
}

0

错误的代码,我们不能假设 x = 1,即使 y 已经是 2:

Class Reordering {
  int x = 0, y = 0;
  public void writer() {
    x = 1;
    y = 2;
  }

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}

volatile关键字的使用示例:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

来源:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html


0

更新 我的回答是错误的,请参考irreputable的回答。


它不是线程安全的,因为只有一个写线程,所以对于count的访问是不安全的。如果有另一个写线程,则count值将与更新次数不一致。

getCount方法中检查stopped的易失性可以确保主线程看到count值。这就是《Java并发编程实践》书中所谓的“在同步机制上进行附带操作”。


整型的更新是原子性的,根据JLS的规定。然而,在变量没有被同步锁保护时,读写顺序可能会出现问题,如果没有使用volatile关键字。 - Chris Dennett
getCount中异常的想法是确保在写入count完成后,只有一个外部线程才能访问count。而我认为写入volatile变量可以保证所有线程都能看到count。这不是正确的吗? - JB Nizet
1
可能需要更新,但代码正在使用++,这是读取-更改-存储操作。 - Victor Sorokin
@Victor @JB 这不安全。在run()线程中没有volatile写入,对count的写入可能不会被刷新;主线程可能会观察到0 - irreputable
1
谢谢大家的回答。我知道我会漏掉一些东西。我很高兴我不是唯一一个犯这个错误的人 :-) - JB Nizet
显示剩余8条评论

0
为了说明在并发编程中 volatile 关键字的重要性,你只需要确保在不同的线程中修改和读取 volatile 字段即可。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接