JAVA并发编程递进篇,探索线程安全性volatile关键字如何保证可见性

Posted 1994july

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JAVA并发编程递进篇,探索线程安全性volatile关键字如何保证可见性相关的知识,希望对你有一定的参考价值。

一开始就直接上代码,直接来看一段木有使用volatile关键字的线程调用代码吧:

public class VolatileDemo {
    public static boolean stop = false;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            int i = 0;
            while(!stop) {
                i++;
                //System.out.println("result:" + i);
                /*
                try {
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                */
            }
        },"myThread");
        t.start();
        Thread.sleep(1000);
        stop=true;
    }

}

很显然运行main()方法后,循环并没有结束,程序一直处于运行状态。

如果我们要使得循环结束该怎么做呢?

一、Volatile关键字的使用递进

1.1 System.out.println

使用print打印i的值,发现循环就被终止了。这是为什么呢?我们不妨来看下println()方法的源码吧。

public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
 }

底层方法使用synchronized关键字,这个同步会防止循环期间对变量stop的值缓存。

从IO角度来说**,print本质上是一个IO的操作**,我们知道磁盘IO的效率一定要比CPU的计算效率慢得多,所以IO可以使得CPU有时间去做内存刷新的事情,从而导致这个现象。比如我们可以在里面定义一个new File()。同样会达到效果。

1.2 Thread.sleep(0)

增加Thread.sleep(0)也能生效,是和cpu、以及jvm、操作系统等因素有关系。

官方文档上是说,Thread.sleep没有任何同步语义,编译器不需要在调用Thread.sleep之前把缓存在寄存器中的写刷新到给共享内存、也不需要在Thread.sleep之后重新加载缓存在寄存器中的值。

技术图片

编译器可以自由选择读取stop的值一次或者多次,这个是由编译器自己来决定的。 Thread.sleep(0)导致线程切换,线程切换会导致缓存失效从而读取到了新的值。

1.3 Volatile关键字

public volatile static boolean stop = false;

我们在stop变量加上volatile关键字进行修饰,可以查看汇编指令,使用HSDIS工具进行查看。

  • 在IDEA中加入VM options:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileDemo.*

运行程序后,在输出的结果中,查找下 lock 指令,会发现,在修改带有volatile 修饰的成员变量时,会多一个 lock 指令。

0x00000000034e49f3: lock add dword ptr [rsp],0h  ;*putstatic stop
                                                ; - com.sy.sa.thread.VolatileDemo::<clinit>@1 (line 5)
0x00000000034e4643: lock add dword ptr [rsp],0h  ;*putstatic stop
                                                ; - com.sy.sa.thread.VolatileDemo::<clinit>@1 (line 5)

运行加了volatile关键字的代码,发现中多了lock汇编指令。那么lock指令是怎么保证可见性的呢?

1.3.1 什么是可见性?

在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性。

1.3.2 硬件方面了解可见性本质

硬件方面将从CPU、内存、磁盘I/O 三方面着手。

1.3.2.1 CPU的高速缓存

因为高速缓存的存在,会导致一个缓存一致性问题。 技术图片

1.3.2.2 总线锁和缓存锁

总线锁,简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。

如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要协会到主内存中的,就可以采用缓存锁来解决问题。

所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。

总线锁和缓存锁怎么选择,取决于很多因素,比如CPU是否支持、以及存在无法缓存的数据时(比较大或者快约多个缓存行的数据),必然还是会使用总线锁。

1.3.2.3 缓存一致性

MSI ,MESI 、MOSI ... 为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESIMESI表示缓存行的四种状态,分别是:

  • M(Modify): 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致;
  • E(Exclusive): 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改;
  • S(Shared): 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致;
  • I(Invalid): 表示缓存已经失效。

1.3.2.4 MESI带来的优化

各CPU通过消息传递来更新各个缓存行的状态。在CPU中引入了Store Bufferes。 技术图片 CPU0 只需要在写入共享数据时,直接把数据写入到 store bufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。 当收到其他所有CPU发送了invalidate acknowledge消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。

技术图片

指令重排序

来关注下面这段代码,假设分别有两个线程,分别执行executeToCPU0和executeToCPU1,分别由两个不同的CPU来执行。引入Store Bufferes之后,就可能出现 b==1返回true ,但是assert(a==1)返回false。很多肯定会表示不理解,这种情况怎么可能成立?那接下来我们去分析一下,写一段伪代码吧。

executeToCPU0(){
  a=1;
  b=1;
}
executeToCPU1(){
  while(b==1){
    assert(a==1);
 }
}

通过内存屏障禁止了指令重排序

X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)。

  • Store Memory Barrier(写屏障):告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
  • Load Memory Barrier(读屏障):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
  • Full Memory Barrier(全屏障):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
volatile int a=0;
executeToCpu0(){
  a=1;
  //storeMemoryBarrier()写屏障,写入到内存
  b=1;
 
 // CPU层面的重排序
  //b=1;
  //a=1;
}
executeToCpu1(){
  while(b==1){  //true
    loadMemoryBarrier(); //读屏障
    assert(a==1) //false
 }
}

1.3.3 软件方面了解可见性本质

1.3.3.1 JMM(Java内存模型)

简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没主动限制编译器对于指令的重排序,也就是说在JMM这个模型之上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是建立在不同的操作系统和硬件层面之上对问题进行了统一的抽象,然后再Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题。

1.3.3.2 JMM解决可见性有序性

其实通过前面的内容分析我们发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。

而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。

1.3.3.3 Volatile底层的原理

通过javap -v VolatileDemo.class 分析汇编指令。

public static volatile boolean stop;
 descriptor: Z
 flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
int field_offset = cache->f2_as_index();
     if (cache->is_volatile()) {
      if (tos_type == itos) {
       obj->release_int_field_put(field_offset, STACK_INT(-1));
     } else if (tos_type == atos) {
       VERIFY_OOP(STACK_OBJECT(-1));
       obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
       OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >>
CardTableModRefBS::card_shift], 0);
     } else if (tos_type == btos) {
       obj->release_byte_field_put(field_offset, STACK_INT(-1));
     } else if (tos_type == ltos) {
       obj->release_long_field_put(field_offset, STACK_LONG(-1));
     } else if (tos_type == ctos) {
       obj->release_char_field_put(field_offset, STACK_INT(-1));
     } else if (tos_type == stos) {
       obj->release_short_field_put(field_offset, STACK_INT(-1));
     } else if (tos_type == ftos) {
       obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
     } else {
       obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
     }
      OrderAccess::storeload();
    }

1.3.4 Happens-Before模型

除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。

从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。

1.3.4.1 程序顺序规则

可以认为是as-if-serial语义。

  • 不能改变程序的执行结果(在单线程环境下,执行的结果不变)
  • 依赖问题, 如果两个指令存在依赖关系,是不允许重排序
int a=0;
int b=0;
void test(){
  int a=1;   a
  int b=1;   b
  //int b=1;
  //int a=1;
  int c=a*b;  c
}

a happens -before b ; b happens before c

1.3.4.2 传递性规则

a happens-before b , b happens- before c, a happens-before c

1.3.4.3 volatile变量规则

  • volatile 修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作.
  • 内存屏障机制来防止指令重排.
public class VolatileExample{
  int a=0;
  volatile boolean flag=false;
  public void writer(){
    a=1;             1
    flag=true; //修改       2
 }
  public void reader(){
    if(flag){ //true       3
      int i=a;  //1      4
   }
 }
}
  • 1 happens-before 2 是否成立? 是 -> ?
  • 3 happens-before 4 是否成立? 是
  • 2 happens -before 3 ->volatile规则
  • 1 happens-before 4 ; i=1成立.

1.3.4.4 监视器锁规则

对一个锁的解锁,happens-before 于随后对这个锁的加锁

int x=10;
synchronized(this){
  //后续线程读取到的x的值一定12
  if(x<12){
    x=12;
 }
}
x=12;

1.3.4.5 start规则

如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start()操作 happens-before 线程 B 中的任意操作

public class StartDemo{
  int x=0;
  Thread t1=new Thread(()->{
    //读取x的值 一定是20
    if(x==20){
     
   }
 });
  x=20;
  t1.start();
 
}

1.3.4.6 Join规则

如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程A 从 ThreadB.join()操作成功返回

public class Test{
  int x=0;
  Thread t1=new Thread(()->{
    x=200;
 });
  t1.start();
  t1.join(); //保证结果的可见性。
  //在此处读取到的x的值一定是200.
}
来源:站长

以上是关于JAVA并发编程递进篇,探索线程安全性volatile关键字如何保证可见性的主要内容,如果未能解决你的问题,请参考以下文章

Java并发编程基础(入门篇)

Java并发编程volatile域

JAVA - 并发编程 - 线程安全方案

Java开发之高并发编程篇——安全访问的集合

Java软件开发 | 高并发编程篇之——安全访问的集合

探索并发编程------ Java多线程开发技巧