百万架构师第四十六课:并发编程的原理(一)|JavaGuide ...

打印 上一主题 下一主题

主题 833|帖子 833|积分 2499

百万架构师系列文章阅读体验感更佳

原文链接:https://javaguide.net

并发编程的原理

课程目标


  • JMM 内存模型
  • JMM 如何解决原子性、可见性、有序性的问题
  • Synchronized 和  volatile
回顾

​        线程的转换,线程的停止。基于 CPU 的内存模型,硬件架构,高速缓存,和它的一些线程的并行执行所带来的问题,在 CPU 层面上提供了解决方案,比如说 总线锁、缓存锁的方式解决这些问题。
​        在 JAVA 层面,统一了规范,JMM 定义了共享内存系统中多个线程同时访问内存时的规范。去屏蔽硬件和操作系统的内存访问的差异。它和 JVM 是有点类似的。 JVM 的出现是为了提供了一个在操作系统层面上一个假造机,他可以真正地实现一次编译,随处运行的效果。JMM 也是类似的道理,他终极实现了 JAVA 程序在各个平台下都能够实现一致的内存访问效果。

​        在 JMM 定义了 8 种内存的操作。
​        lock 就是锁定,锁定我们的主内存的变量,包管他变成一个线程的独占状态,这个是一个开放式的指令。

​        CPU 层面的解决方式是总线锁和缓存锁。
​        而 JMM 是在我们的 CPU 和我们的应用层之间抽象的一个模型,去解决底层的一个问题。

  • 多线程通讯的两种方式

    • 共享内存
    • 消息通报

​      内存中存在一个共享变量的值,当多个线程访问主内存的时间,一个线程 1 先去改变工作内存从 1 -> 2,主内存从 1 变成 2, 线程2 去访问的时间,就变成 2 。这是消息共享内存的通报方式。
​        消息通报,就是 wait /  notify ,这种就是线程中央没有公共状态,线程之间去发送消息,它都是通过一种阻塞、等候、释放锁的方式,去唤醒去改变共享变量的通讯的数据。
在 JMM 模型中会有一个问题,什么时间同步到主内存,什么时间同步到另一个线程的内存。

  • 可见性问题?
  • 原子性问题
    ​        当我们线程同时去访问共享变量的时间,当两个线程同时运行,同时去对这个值去 + 1 ,末了结果 不对,导致的原子性问题。
  • 有序性?
    ​        包含编译器和 CPU 层面的有序性的问题。
​        JMM 是基于我们物理模型的抽象。抽象内存模型在硬件抽象模型里有它的映射关系。

  • 主内存 JVM 层面的堆内存,堆内存是从我们的物理内存里边去分别一块内存去分配给这个进程。物理内存的一部分。
  • 工作内存 CPU 的高速缓存和 CPU 的寄存器。
CPU 层面上有解决方案,但是 JMM 怎么去解决。
有序性问题原因


  • 编译器的指令重排序
  • 处理惩罚器的指令重排序
  • 内存系统的重排序,(内存访问的次序性,多线程访问内存是没有次序的。)
JMM怎么解决原子性、可见性、有序性的问题?

​        在 JAVA 中提供了一系列和并发处理惩罚相关的关键字,比如 volatile 、 Synchronized 、 final 、 j.u.c 等,这些就是Java内存模型封装了底层的实现后提供给开辟人员使用的关键字,在开辟多线程代码的时间,我们可以 synchronized 等关键词来控制并发,使得我们不须要关心底层的编译器优化、缓存一致性的问题了,所以在JAVA 内存模型中,除了定义了一套规范,还提供了开放的指令在底层举行封装后,提供给开辟人员使用。

  • Synchronized 是“万能”的。
原子性保障

​        在 JAVA 中提供了两个高级的字节码指令 monitorenter 和 monitorexit ,在Java中对应的 Synchronized 来包管代码块内的操作是原子的。
可见性

​         JAVA 中的 volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用volatile来包管多线程操作时变量的可见性。除了volatile,JAVA中的 synchronized 和 final 两个关键字也可以实现可见性。
有序性

​        在 JAVA 中,可以使用 synchronized 和 volatile 来包管多线程之间操作的有序性。实现方式有所区别:
volatile 关键字会禁止指令重排。 synchronized 关键字包管同一时刻只允许一条线程操作。
volatile如何包管可见性

volatile 是一个轻量级的锁。(解决可见性、防止指令重排)
下载hsdis工具 ,https://sourceforge.net/projects/fcml/files/fcml-1.1.1/hsdis-1.1.1-win32-amd64.zip/download
解压后存放到jre目次的server路径下

JDK 下边的 JRE 就行

然后跑main函数,跑main函数之前,加入如下假造机参数:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*App.getInstance(替换成实际运行的代码)
  1. public class ThreadDemo {
  2.     private static volatile ThreadDemo instance = null;
  3.     public static ThreadDemo getInstance() {
  4.         if (instance==null){
  5.             instance = new ThreadDemo();
  6.         }
  7.         return instance;
  8.     }
  9.     public static void main(String[] args) {
  10.         ThreadDemo.getInstance();
  11.     }
  12. }
复制代码
  1. 0x0000000002bf6098: lock add dword ptr [rsp],0h  ;*putstatic instance
  2.                                                 ; - com.darian.multiplethread2.ThreadDemo::getInstance@13 (line 8)
复制代码
没有加 volatile ,是没有锁的。
​         volatile 变量修饰的共享变量,在举行写操作的时间会多出一个 lock 前缀的汇编指令,这个指令在前面我们讲解CPU高速缓存的时间提到过,会触发总线锁大概缓存锁,通过缓存一致性协议来解决可见性问题对于声明 volatile 的变量举行写操作,JVM就会向处理惩罚器发送一条Lock前缀的指令,把这个变量所在的缓存行的数据写回到系统内存,再根据我们前面提到过的 MESI 的缓存一致性协议,来包管多 CPU 下的各个高速缓存中的数据的一致性。
volatile防止指令重排序

​        指令重排的目的是最大化的进步CPU利用率以及性能,CPU的乱序执行优化在单核时代并不影响正确性,但是在多核时代的多线程能够在不同的核心上实现真正的并行,一旦线程之间共享数据,就可能会出现一些不可预料的问题指令重排序必须要遵循的原则是,不影响代码执行的终极结果,编译器和处理惩罚器不会改变存在数据依赖关系的两个操作的执行次序,(这里所说的数据依赖性仅仅是针对单个处理惩罚器中执行的指令和单个线程中执行的操作.)这个语义,实际上就是 as-if-serial 语义,不管怎么重排序,单线程程序的执行结果不会改变,编译器、处理惩罚器都必须遵守 as-if-serial 语义。

    1. public class VolatileDemo {
    2.     public static void main(String[] args) {
    3.         // as-if-serial
    4.         int a = 2;
    5.         int b = 3;
    6.         int c = a + b;
    7.     }
    8. }
    复制代码
    ​        编译器在编译的时间,以及 CPU 在执行的时间,都会存在相应的指令执行,所以在编译以后,可能会对我们的指令做一个次序的调整。可能会先执行 b = 3 ,在去执行 a = 2 ,终极会满足不会影响终极的运行结果。终极的结果是不会变的。
多核心多线程下的指令重排影响


  1. public class VolatileSortDemo {
  2.     private static int x = 0, y = 0;
  3.     private static int a = 0, b = 0;
  4.     public static void main(String[] args) throws InterruptedException {
  5.         Thread t1 = new Thread(() -> {
  6.             a = 1;
  7.             x = b;
  8.         });
  9.         Thread t2 = new Thread(() -> {
  10.             b = 1;
  11.             y = a;
  12.         });
  13.         t1.start();
  14.         t2.start();
  15.         t1.join();
  16.         t2.join();
  17.         System.out.println("[x=" + x + "]\t[y=" + y + "]");
  18.     }
  19. }
复制代码
​        如果不思量编译器重排序和缓存可见性问题,上面这段代码可能会出现的结果是

  • x=0,y=1;
  • x=1,y=0;
  • x=1,y=1
​        这三种结果,既可能是先后执行t1/t2,也可能是反过来,还可能是t1/t2瓜代执行,但是这段代码的执行结果也有可能是 x=0,y=0 。这就是在乱序执行的情况下会导致的一种结果。因为线程t1内部的两行代码之间不存在数据依赖,因此可以把 x=b 乱序到 a=1 之前。同时线程 t2 中的 y=a 也可以早于t1中的 a=1 执行,那么他们的执行次序可能是。


  • t1: x=b
  • t2:b=1
  • t2:y=a
  • t1:a=1
​      所以从上面的例子来看,重排序会导致可见性问题。但是重排序带来的问题的严重性远远大于可见性,因为并不是所有指令都是简单的读或写,比如 DCL 的部分初始化问题。所以单纯地解决可见性问题还不够,还须要解决处理惩罚器重排序问题。
DCL 的问题


  • 可能会存在指令重排序的半内存、不完整对象的问题。
提供了一种解决方式叫内存屏蔽。
#join 底层是基于 wait notify 来实现的。
内存屏蔽

​        内存屏蔽须要解决我们前面提到的两个问题。一个是编译器的优化乱序和CPU的执行乱序,我们可以分别使用  优化屏蔽  和  内存屏蔽  这两个机制来解决。

  • 优化屏蔽
  • 内存屏蔽
从CPU层面来了解一下什么是内存屏蔽

​        CPU的乱序执行,本质照旧,由于在多CPU的机器上,每个CPU都存在cache,当一个特定数据第一次被特定一个CPU获取时,由于在该CPU缓存中不存在,就会从内存中去获取,被加载到CPU高速缓存中后就能从缓存中快速访问。当某个CPU举行写操作时,它必须确保其他的CPU已经将这个数据从他们的缓存中移除,这样才能让其他CPU 安全地修改数据。显然,存在多个cache时,我们必须通过一个cache一致性协议来避免数据不一致的问题,而这个通讯的过程就可能导致乱序访问的问题,也就是运行时的内存乱序访问。如今的CPU架构都提供了内存屏蔽功能,在 x86的cpu 中,实现了相应的内存屏蔽写屏蔽(store barrier)、读屏蔽(load barrier)和 全屏蔽(Full Barrier),主要的作用是。

  • 防止指令之间的重排序
  • 包管数据的可见性
编译的时间会举行优化,执行的时间乱序执行。
instance = new ThreadDemo(); 分为 3 个操作,分配内存,指向地址,初始化。
store barrier


​          store barrier称为 写屏蔽 ,相当于 storestore barrier ,  逼迫所有在 storestore 内存屏蔽之前的所有指令先执行。都要在该内存屏蔽之前执行,并发送缓存失效的信号。所有在 storestore barrier 指令之后的store 指令,都必须在 storestore barrier 屏蔽之前的指令执行完后再被执行。也就是禁止了写屏蔽前后的指令举行重排序,使得所有 store barrier 之前发生的内存更新都是可见的(这里的可见指的是修改值可见以及操作结果可见)
load barrier


​         load barrier称为读屏蔽,相当于loadload barrier ,逼迫所有在 load barrier 读屏蔽之后的 load 指令,都在 load barrier 屏蔽之后执行。也就是禁止对 load barrier 读屏蔽前后的 load 指令举行重排序, 配合 store barrier ,使得所有 store barrier 之前发生的内存更新,对 load barrier 之后的 load 操作是可见的。
Full barrier


​         full barrier 称为全屏蔽,相当于 storeload ,是一个万能型的屏蔽,因为它同时具备前面两种屏蔽的效果。逼迫了所有在 storeload barrier 之前的 store/load 指令,都在该屏蔽之前被执行,所有在该屏蔽之后的的 store/load 指令,都在该屏蔽之后被执行。禁止对 storeload 屏蔽前后的指令举行重排序。
总结

​        内存屏蔽只是解决 次序一致性问题 ,不解决 缓存一致性问题 ,缓存一致性 是由 cpu的缓存锁 以及 MESI 协议来完成的。而缓存一致性协议只关心缓存一致性,不关心次序一致性。所以这是两个问题。编译器层面如何解决指令重排序问题。
编译层如何解决指令重排序问题?

​        在编译器层面,通过volatile关键字,取消编译器层面的缓存和重排序。包管编译程序时在优化屏蔽之前的指令不会在优化屏蔽之后执行。这就包管了编译时期的优化不会影响到实际代码逻辑次序。如果硬件架构本身已经包管了内存可见性,那么 volatile 就是一个空标志,不会插入相关语义的内存屏蔽。如果硬件架构本身不举行处理惩罚器重排序,有更强的重排序语义,那么 volatile 就是一个空标志,不会插入相关语义的内存屏蔽。
​        在 JMM 中把内存屏蔽指令分为4类,通过在不同的语义下使用不同的内存屏蔽来禁止特定类型的处理惩罚器重排序,从而来包管内存的可见性

  • loadload barrier
  • storestore barrier
  • loadstore barrier
  • storeload barrier
LoadLoad Barriers, load1 ; LoadLoad; load2 , 确保load1数据的装载优先于load2及所有后续装载指令的装载
StoreStore Barriers, store1; storestore;store2 , 确保store1数据对其他处理惩罚器可见优先于store2及所有后续存储
指令的存储
LoadStore Barries, load1;loadstore;store2, 确保load1数据装载优先于store2以及后续的存储指令刷新到内存
StoreLoad Barries, store1; storeload;load2, 确保store1数据对其他处理惩罚器变得可见, 优先于load2及所有后续
装载指令的装载;这条内存屏蔽指令是一个万能型的屏蔽,在前面讲cpu层面的内存屏蔽的时间有提到。它同时具有其他3条屏蔽的效果。
volatile为什么不能包管原子性
  1. public class Demo {
  2.     static volatile int i;
  3.     public static void main(String[] args) {
  4.         i = 10;
  5.     }
  6. }
复制代码
然后通过 javap -c Demo.class ,去检察字节码
  1. {
  2.   static volatile int i;
  3.     descriptor: I
  4.     flags: (0x0048) ACC_STATIC, ACC_VOLATILE
复制代码
ACC_VOLATILE
accessFlags.hpp
  1. bool is_volatile    () const         { return (_flags & JVM_ACC_VOLATILE    ) != 0; }
复制代码
bytecodeinterpreter.cpp
  1. // 把结果写回到栈中
  2. // Now store the result on the stack
  3. //
  4. TosState tos_type = cache->flag_state();
  5. int field_offset = cache->f2_as_index();
  6. if (cache->is_volatile()) {
  7.     if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
  8.         OrderAccess::fence();
  9.     }
  10.     if (tos_type == atos) {
  11.         VERIFY_OOP(obj->obj_field_acquire(field_offset));
  12.         SET_STACK_OBJECT(obj->obj_field_acquire(field_offset), -1);
  13.     }else if (tos_type == itos) {  // int 型的数据
  14.         SET_STACK_INT(obj->int_field_acquire(field_offset), -1);
  15.     }
  16.     //。。。。。。。。。。。
  17. }
复制代码
拿到这个值,去看这个 cache 是不是 volatile 修饰的。
oop.inline.hpp
  1. static void   release_store(volatile jint*  P, jint v);
复制代码
根据不同的操作系统,有不同的实现。 JMM 是为了解决不同的系统做的处理惩罚方案。
  1. inline void OrderAccess::release sotre(volatile jint* p, jint v){*p = v;} // 语言级别的内存屏障
复制代码
volatile 是一个语言级别的 memery barry
​        被 volatile 声明的变量随时都可能发生变化,每次使用的时间,必须从这个变量的对应的内存地址去读取,编译器对这个 volatile 修饰的变量不会去做代码优化。
内存屏蔽提供的几种功能?


  • 确保指令重排序,不会把它后边指令排序到内存屏蔽的前边,也不会把内存屏蔽前边的指令排序到内存屏蔽的后边
  • 逼迫对缓存的修改立即写入到主内存里边。
  • 如果是写操作的话,会导致我们其他 CPU 的缓存无效。
规则


  • 对每个 volatile 写操作的前边会插入 storestore barrier
  • 对每个 volatile 写操作的后边会插入 storeload barrier
  • 对每个 volatile 读操作前边插入 loadload barrier
  • 对每个 volatile 读操作后边插入 loadstore barrier
orderaccess_linux_x86.hpp
  1. inline void OrderAccess::loadload()   { compiler_barrier(); }
  2. inline void OrderAccess::storestore() { compiler_barrier(); }
  3. inline void OrderAccess::loadstore()  { compiler_barrier(); }
  4. inline void OrderAccess::storeload()  { fence();            }
复制代码
如果说 是 storeload() 然后,调用 fence() 方法,
  1. inline void OrderAccess::fence() {
  2.    // always use locked addl since mfence is sometimes expensive
  3. #ifdef AMD64
  4.   __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
  5. #else
  6.   __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
  7. #endif
  8.   compiler_barrier();
  9. }
复制代码
汇编指令。 就是内存屏蔽。storeload 就是一个内存屏蔽。

OrderAccess::storeload();
​        是永远追加在后边的。是为了避免 volatile 写操作后边,有一些 volatile 读写操作的重排序。因为编译器,没办法去判定,volatile 后边是不是还要去插入。为了包管正确实现 volatile 语义,实现了悲观策略。我终极都要加上 这个屏蔽。
  1. public class VolatileDemo1 {
  2.     private static volatile boolean stop = false;
  3.     public static void main(String[] args) throws InterruptedException {
  4.         Thread thread = new Thread(() -> {
  5.             int i = 9;
  6.             while (!stop){
  7.                 i++;
  8.             }
  9.         });
  10.         thread.start();
  11.         Thread.sleep(1000);
  12.         stop = true;
  13.     }
  14. }
复制代码
​        对于 volatile 修饰的变量,如果其他的线程对这个值举行了一个变动的话,他会去加一个内存屏蔽,他会去包管我们的可见性。我们会包管在我们的CPU 层面,就是我们的汇编指令层面,它实际上会去发起一个 LOCK 的汇编指令,这个 LOCK 指令终极做的就是把我们的这个缓存更新到我们的内存里边。
原子性

对符合操作的原子性是没有办法包管原子性的!!!
  1. public class VolatileIncrDemo {
  2.     volatile int i = 0;
  3.     public void incr() {
  4.         i++;
  5.     }
  6.     public static void main(String[] args) {
  7.         new VolatileIncrDemo().incr();
  8.     }
  9. }
复制代码
javap -c volatileIncrDemo.class 之后
  1.   public void incr();
  2.     Code:
  3.        0: aload_0
  4.        1: dup
  5.        2: getfield      #2                  // Field i:I
  6.        5: iconst_1
  7.        6: iadd
  8.        7: putfield      #2                  // Field i:I
  9.       10: return
复制代码
对一个原子递增的操作,会分为三个步骤:


  • 读取 volatile 变量的值到 local ;
  • 增加变量的值;
  • 把 local 的值回写
多个线程同时去执行的话。三个操作。
每个线程可能拿到旧的值去更新。
Synchronized  原子性,避免线程的并行执行
AtomicInteger ( CAS ) 、Lock ( CAS/ LockSupport / AQS / unsafe )
不安全都放到 unsafe ,一般不推荐使用。
Synchronized

  • 可见性
  • 原子性
  • 有序性
总结

内存模型

  • 约束我们线程访问内存的规范。
  • 屏蔽硬件和操作系统的内存访问的差异。
​        JMM 对硬件和操作系统的抽象。定义了,线程之间可以通过共享空间和线程信号去通讯。
volatile 通过 LOCK 来实现锁。

  • 编译器的指令重排序
  • CPU 的指令重排序(内存的乱序访问)
可见性问题
内存屏蔽去解决。
int a = 1;
int b = b ;
a = 1 ; storestore ; b = 2 ; a = 2
Volatile 是干嘛的?


  • 可以包管可见性、防止内存重排序
  • #LOCK  ,  - >  缓存锁 (MESI)
  • 内存屏蔽
使用场景

线程的关闭。
java.util.concurrent.locks.AbstractQueuedSynchronizer
  1. private volatile int state;
复制代码
成员变量 state 的定义。
百万架构师系列文章阅读体验感更佳

原文链接:https://javaguide.net

来源于:  https://javaguide.net
微信公众号:不止极客

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

写过一篇

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表