前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >【Java并发系列】volatile

【Java并发系列】volatile

作者头像
章鱼carl
发布2022-03-31 10:37:26
发布2022-03-31 10:37:26
19600
代码可运行
举报
文章被收录于专栏:章鱼carl的专栏章鱼carl的专栏
运行总次数:0
代码可运行

简介

多线程的同步问题其实就是要解决线程并发所带来的工作内存之间以及和主内存的数据不一致性问题(一份数据如果在物理空间中存在不止一份,就需要付出维护数据一致性的成本,例如HDFS冗余备份机制、Redis缓存一致性等)。

数据一致性包含三大特性:原子性、可见性、有序性

volatile利用内存屏障保证多线程并发时对共享变量的可见性和有序性,但不具备原子性,而原子性在Java中之前介绍过是由Unsafe的CAS保证的。

所以,对于数据一致性问题,Java提供的元解决方案就是:

(1) volatile保证可见性、有序性

(2) CAS保证原子性

可见性


当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。(可以理解为缓存失效)

例如,

(1) 共享变量s在主内存中的初始值为0:

(2) 线程A读取到工作内存中后修改为3

(3) 此时,线程B读取到工作内存中的还是主内存中的0

(4) 线程A将工作内存中的3写回主内存,现在B工作内存中的0就是脏数据

代码语言:javascript
代码运行次数:0
运行
复制
public class VolatileTest {
    // public static int finished = 0;
    public static volatile int finished = 0;
    
    private static void checkFinished() {
        while (finished == 0) {
            // do nothing
        }
        System.out.println("finished");
    }

    private static void finish() {
        finished = 1;
    }

    public static void main(String[] args) throws InterruptedException {
        // 起一个线程检测是否结束
        new Thread(() -> checkFinished()).start();
        Thread.sleep(100);
        // 主线程将finished标志置为1
        finish();
        System.out.println("main finished");
    }
}

对于finished,使用volatile修饰时,程序可以正常结束,不使用volatile修饰时,程序永远不会结束。

因为,不使用volatile修饰时,checkFinished所在线程读取的的是一直缓存在工作内存中的原始值,一直都不会跳出while循环。

有序性


Java的有序性可以概括为:在本线程中观察,本线程的所有的操作都是有序的;如果从另其他线程中观察本线程,本线程的所有操作都是无序的。

指令重排

指令重排是指编译器编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。

因为JVM的指令重排在线程内部是不可感知的,但是从其它线程看却并非如此,例如

代码语言:javascript
代码运行次数:0
运行
复制
// 两个操作在一个线程
int i = 0;
int j = 1;

JVM在执行的时候为了充分利用CPU的处理能力,可能会先执行int j = 1,也就是重排序了,但这在线程内是不会有错误的,而在其它线程看来,如果同时也读取了i,j,结果就不一定了。

代码语言:javascript
代码运行次数:0
运行
复制
public class VolatileTest3 {
  private static Config config = null;
  private static volatile boolean initialized = false;

  public static void main(String[] args) {
    // 线程1负责初始化配置信息
    new Thread(() -> {
      config = new Config();
      config.name = "config";
      initialized = true;
    }).start();

    // 线程2检测到配置初始化完成后使用配置信息
    new Thread(() -> {
      while (!initialized) {
        LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100));
      } 
      // do sth with config
      String name = config.name;
    }).start();
  }
}

class Config {
  String name;
}

线程1负责初始化配置,线程2检测到配置初始化完毕,使用配置来干一些事。

如果initialized不用volatile修饰,可能会出现重排序,比如在初始化配置之前把initialized的值设置为了true。

内存屏障

内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行。

内存屏障有两个作用:

(1)阻止屏障两侧的指令重排序;

(2)强制把写缓冲区/高速缓存中的数据回写到主内存,让缓存中相应的数据失效;

内存屏障共分为四种类型:

LoadLoad屏障:

抽象场景:Load1; LoadLoad; Load2

Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障:

抽象场景:Store1; StoreStore;Store2

Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见

LoadStore屏障:

抽象场景:Load1; LoadStore;Store2

在Store2被写入前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:

抽象场景:Store1; StoreLoad;Load2

在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。

在一个变量被volatile修饰后,JVM会为我们做两件事:

1. 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。

2. 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

或许这样说有些抽象,例子:

代码语言:javascript
代码运行次数:0
运行
复制
boolean contextReady = false;

在一个线程中执行:

代码语言:javascript
代码运行次数:0
运行
复制
context = loadContext();
contextReady = true;

我们给contextReady 增加volatile修饰符,会带来什么效果呢?

由于加入了StoreStore屏障,屏障上方的普通写入语句 context =loadContext() 和屏障下方的volatile写入语句 contextReady = true 无法交换顺序,从而成功阻止了指令重排序。

原子性


代码语言:javascript
代码运行次数:0
运行
复制
public class VolatileTest5 {
    public static volatile int counter = 0;
    
    public static void increment() {
        counter++;
    }

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        IntStream.range(0, 100).forEach(i->
                new Thread(()-> {
                    IntStream.range(0, 1000).forEach(j->increment());
                    countDownLatch.countDown();
                }).start());

        countDownLatch.await();
        System.out.println(counter);
    }
}

这段代码中,我们起了100个线程分别对counter自增1000次,一共应该是增加了100000,但是实际运行结果却永远不会达到100000。

increment()方法的字节码:

代码语言:javascript
代码运行次数:0
运行
复制
0 getstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
3 iconst_1
4 iadd
5 putstatic #2 <com/coolcoding/code/synchronize/VolatileTest5.counter>
8 return

counter++被分解成了四条指令:

(1)getstatic,获取counter当前的值并入栈

(2)iconst_1,入栈int类型的值1

(3)iadd,将栈顶的两个值相加

(4)putstatic,将相加的结果写回到counter中

中间的两步iconst_1和iadd在执行的过程中,可能counter的值已经被修改了,这时并没有重新读取主内存中的最新值,所以volatile在counter++这个场景中并不能保证其原子性。

什么时候适合用volatile?(两条件都需满足)

(1) 运行结果并不依赖变量的当前值(如果依赖当前值,那么修改必定要先读取,多个线程之间就会乱序),或者,只有单一的线程修改变量的值(只有一个线程修改不会出现乱序)。

(2) 变量不需要与其他的状态变量共同参与不变约束(多个变量的修改时间点可能不一致,造成比较的时间点不同步)。

说白了,就是volatile本身不保证原子性,那就要增加其它的约束条件来使其所在的场景本身就是原子的。

参考:

https://blog.csdn.net/tangtong1/article/details/90349764

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-11-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 章鱼沉思录 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 可见性
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档