专栏首页眯眯眼猫头鹰的小树杈猫头鹰的深夜翻译:Volatile的原子性, 可见性和有序性

猫头鹰的深夜翻译:Volatile的原子性, 可见性和有序性

为什么要额外写一篇文章来研究volatile呢?是因为这可能是并发中最令人困惑以及最被误解的结构。我看过不少解释volatile的博客,但是大多数要么不完整,要么难以理解。我会从并发中最重要的一些因素开始说起:

原子性 原子性是不可分割的操作。它们要么全部实现,要么全部不实现。Java中原子操作的最佳例子是将一个值赋给变量。

可见性 可见性是指:无论是哪个线程对一个共享的变量作出的修改或是带来的影响,读其他的线程都是可见的。

有序性 有序性是指源码中指令是否会被编译器出于优化而改变执行顺序。有可能一个线程中的动作相对于另一个线程出现乱序。

现在举一个例子来理解这些因素:

public class MyApp
{
    private int count = 0;
    public void upateVisitors() 
    {
       ++count; //increment the visitors count
    }
}

Hint: read-modify-write

这一段代码中有一个试图更新应用(网页)的访客数量的方法。这段代码的问题在于++count指令不是原子性的,它包含三条独立的指令:

temp = count;   (read)
temp = temp + 1;   (modify)
count = temp;  (write)

因此,当一个线程正在执行此操作时,此指令可以被另一个线程预占。从而不是原子性操作。假设count的值为10,并且有如下的执行顺序:

我们会发现:在某个很不巧合的时刻,两个线程同时读取到了值(10),然后彼此将其值加一。所以在这个过程有一个递增的操作丢失了。当实际输出取决于线程交错的结果时,这种情况被称为竞争条件(race condition)。这里丢失了一次递增。那么并发的哪些方面在这里缺失了?原子性。再考虑一个创建单例的例子(当然也是不好的例子):

public Singleton getInstance()
{
   if(_instance == null)
   { 
      _instance = new Singleton();
   }
}

Hint: check-then-act

再一次的,可能有两个线程都判断这实例为null,并且都进入了if代码块。这会导致两个实例的创建。这里的问题在于代码块不是原子性的,而且实例的变化对别的线程不可见。这种不能同时在多个线程上执行的部分被称为关键部分(critical section)。对于关键部分,我们需要使用synchronized块和synchronized方法。

还是原子性 为了确保原子性,我们通常使用锁来确保互斥。参考下面的例子,一个银行账户使用synchronized方法上锁。

class BankAccount {
 private int accountBalance;
 synchronized int getAccountBalance() {
    return accountBalance;  
 }
 synchronized void setAccountBalance(int b) throws IllegalStateException {
    accountBalance = b;
    if (accountBalance < 0) {
     throw new IllegalStateException("Sorry but account has negative Balance");
    }
 }
 void depositMoney(int amount) {
    int balance = getAccountBalance();
    setAccountBalance(balance + amount);
 }
 void withdrawMoney(int amount) {
    int balance = getAccountBalance();
    setAccountBalance(balance - amount);
 }
}

对共享变量balance的访问通过锁来保护,从而数据竞争不会有问题。这个类有问题吗?是有的。假设一个线程调用depositMoney(50)而另一个线程调用withdrawMoney(50),并且balance的初始值为100。理想情况下操作完成后balance应该为0。但是我们无法保证得到这个结果:

  • depositMoney操作读取的balance值为100
  • withdrawMoney操作读取的balance值也是100,它在此基础上减去50元并将其设为50元。
  • 最终depositMoney在之前看到的balance值的基础上加上50,并将其设为150。

再次因为没有保证原子性而丢失了一个更新。如果两种方法都被声明为同步,则将在整个方法期间确保锁定,并且改变将以原子方式进行。

再谈可见性 如果一个线程的操作对另一个线程可见,那么其他线程也会观察到它的所有操作的结果。考虑下面的例子:

public class LooperThread extends Thread
{
    private boolean isDone = false;
    public void run() 
    {
       while( !isDone ) {
          doSomeWork();
       }
    }
    public void stopWork() {
       isDone = true;
    }
}

这里缺失了什么?假设LooperThread的一个实例正在运行,主线程调用了stopWord来中止它。这两个线程之间没有实现同步。编译器会以为在第一个线程中没有对isDone执行写入操作,并且决定只读入isDone一次。于是,线程炸了!部分JVM可能会这样做,从而使其变成无限循环。因此答案显然是缺乏可见性。

再谈有序性 有序性是关于事情发生的顺序。考虑下面的例子:

在上述情况下,线程2能打印出value = 0吗?其实是有可能的。在编译器重新排序中result=true可能会在value=1之前出现。value = 1也可能不对线程2可见,然后线程2将加载value = 0。我们可以使用volatile解决这个问题吗?

CPU架构(多层RAMs) CPU现在通常多核,并且线程将在不同核心上运行。另外还有不同级别的高速缓存,如下图所示:

当一个volatile变量被任何线程写入一个特定的核心,所有其他核心的值都需要更新,因为每个核心都有其自己的缓存,该缓存内有变量的旧值。消息传递给所有内核以更新值。

volatile 根据Java文档,如果一个变量被声明为volatile,那么Java内存模型(在JDK 5之后)确保所有线程都看到变量的一致值。volatile就像是synchronized的一个亲戚,读取volatile数据就像是进入一个synchronized块,而写入volatile数据就像是从synchronized块中离开。当写入一个volatile值时,这个值直接写入主存而不是本地处理器的缓存,并且通过发送消息提醒其它内核的缓存该值的更新。Volatile不是原子性操作

volatile保证顺序性和可见性但是不保证互斥或是原子性。锁能保证原子性,可视性和顺序性。所以volatile不能代替synchronized。

volatile读与写 volatile提供了顺序性保障,这意味着编译器生成的指令不能以实际源代码指令定义的顺序以外的其他顺序执行操作结果。尽管生成的指令的顺序可能与源代码的原始顺序不同,但所产生的效果必须相同。我们还需要从Java Doc中观察以下关于读写的内容:

当一个线程读取一个volatile变量时,它不仅会看到volatile的最新变化,还会看到导致变化的代码的副作用。

我们需要了解以下有关读写volatile的内容:

  • 当一个线程写入一个volatile变量,另一个线程看到写入,第一个线程会告诉第二个线程关于内存变化的内容,直到它执行写入该volatile变量。
  • 在这里,线程2看到了线程1的内容。

我们可以声明 final 类型的volatile变量吗? 如果一个变量是final的,我们不能改变它的值,volatile就是确保对其他线程可见的共享变量的更改。所以这是不允许的,并会导致编译错误。

为什么我们在并发编程中声明long / double为volatile? 默认情况下long/double的读写不是原子性的。非原子性的double/long写操作会被当做两个写入操作:分别写入前32位和后32位。它可能会导致一个线程看到另一个线程写入的64位值的前32位,而第二个线程看到来自另一个线程写入的后32位。读写volatile的long/double类型变量总是原子性的。

Volatile vs Atomic类

public class MyApp
{
    private volatile int count = 0;
    public void upateVisitors() 
    {
       ++count; //increment the visitors count
    }
}

如果我们将count声明为atomic,这段代码可以正常运行吗?可以的,而且当对变量进行增加或减少操作时,最好使用atomic类。AtomicInteger通常使用volatile或是CAS来实现线程安全。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • leetcode421. Maximum XOR of Two Numbers in an Array

    Given a non-empty array of numbers, a0, a1, a2, … , an-1, where 0 ≤ ai< 231.

    眯眯眼的猫头鹰
  • 深入理解 依赖注入

    相信所有面试java开发的童鞋一定都被问到过是否使用过Spring,是否了解其IOC容器,为什么不直接使用工厂模式,以及究竟IOC和DI区别在于哪里这种问题。今...

    眯眯眼的猫头鹰
  • leetcode474. Ones and Zeroes

    先是用深度优先遍历的思想进行了实现,结果很明显是超时了。接着采用动态规划的思想,其实这题就是背包问题的一个演化。假设已知道m个0和n个1能够从数组中前i个元素最...

    眯眯眼的猫头鹰
  • Java并发编程之验证volatile不能保证原子性

    通过系列文章的学习,凯哥已经介绍了volatile的三大特性。1:保证可见性 2:不保证原子性 3:保证顺序。那么怎么来验证可见性呢?本文凯哥(凯哥Java:k...

    凯哥Java
  • 优化指南,详解 Tomcat 的连接数与线程池

    在使用tomcat时,经常会遇到连接数、线程数之类的配置问题,要真正理解这些概念,必须先了解Tomcat的连接器(Connector)。

    java思维导图
  • Thread源码解析

    我们上学的时候都知道线程有两种方式,要么继承Thread类,要么实现runable接口。根据我们上次对线程池的分析,发现我们对Thread类的理解还比较浅显。所...

    用户5602455
  • 上周并发题的解题思路以及介绍Go语言调度器

    今天的文章我首先说一下上篇文章里的思考题的解决思路,我会给出完整可运行的代码。之后通过观察程序的运行结果里的现象简单介绍Go语言的调度器是如何对goroutin...

    KevinYan
  • Java同步问题面试知识学习

    Java同步问题面试知识学习 同步 在多线程程序中,同步修饰符用来控制对临界区代码的访问。其中一种方式是用synchronized关键字来保证代码的线程安...

    用户1289394
  • 详解 Tomcat 的连接数与线程池

    前言 在使用tomcat时,经常会遇到连接数、线程数之类的配置问题,要真正理解这些概念,必须先了解Tomcat的连接器(Connector)。 在前面的文章 详...

    Java高级架构
  • 干货 | Tomcat 连接数与线程池详解

    在使用tomcat时,经常会遇到连接数、线程数之类的配置问题,要真正理解这些概念,必须先了解Tomcat的连接器(Connector)。

    Java技术栈

扫码关注云+社区

领取腾讯云代金券