美文网首页
百万架构师第四十六课:并发编程的原理(一)|JavaGuide

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

作者: JavaGuide_net | 来源:发表于2025-03-03 21:34 被阅读0次

原文链接

并发编程的原理

课程目标

  1. JMM 内存模型
  2. JMM 如何解决原子性、可见性、有序性的问题
  3. Synchronizedvolatile

回顾

线程的转换,线程的停止。基于 CPU 的内存模型,硬件架构,高速缓存,和它的一些线程的并行执行所带来的问题,在 CPU 层面上提供了解决方案,比如说 总线锁、缓存锁的方式解决这些问题。

在 JAVA 层面,统一了规范,JMM 定义了共享内存系统中多个线程同时访问内存时的规范。去屏蔽硬件和操作系统的内存访问的差异。它和 JVM 是有点类似的。 JVM 的出现是为了提供了一个在操作系统层面上一个虚拟机,他可以真正地实现一次编译,到处运行的效果。JMM 也是类似的道理,他最终实现了 JAVA 程序在各个平台下都能够实现一致的内存访问效果。

[图片上传失败...(image-a3ffea-1741101421427)]

在 JMM 定义了 8 种内存的操作。

lock 就是锁定,锁定我们的主内存的变量,保证他变成一个线程的独占状态,这个是一个开放式的指令。
JavaGuide_并发编程_原理1_线程的模型.png
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路径下

JavaGuide_并发编程_原理1_hisdis放在jre中.png

JDK 下边的 JRE 就行

JavaGuide_并发编程_原理1_ide配置.png

然后跑main函数,跑main函数之前,加入如下虚拟机参数:

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*App.getInstance(替换成实际运行的代码)

public class ThreadDemo {
    private static volatile ThreadDemo instance = null;

    public static ThreadDemo getInstance() {
        if (instance==null){
            instance = new ThreadDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        ThreadDemo.getInstance();
    }
}
0x0000000002bf6098: lock add dword ptr [rsp],0h  ;*putstatic instance
                                                ; - 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` 语义。
  • public class VolatileDemo {
        public static void main(String[] args) {
            // as-if-serial
            int a = 2;
            int b = 3;
            int c = a + b;
        }
    }
    

    编译器在编译的时候,以及 CPU 在执行的时候,都会存在相应的指令执行,所以在编译以后,可能会对我们的指令做一个顺序的调整。可能会先执行 b = 3 ,在去执行 a = 2 ,最终会满足不会影响最终的运行结果。最终的结果是不会变的。

多核心多线程下的指令重排影响

public class VolatileSortDemo {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread t2 = new Thread(() -> {
            b = 1;
            y = a;
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("[x=" + x + "]\t[y=" + y + "]");
    }
}
如果不考虑编译器重排序和缓存可见性问题,上面这段代码可能会出现的结果是
  • 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 执行,那么他们的执行顺序可能是。

[图片上传失败...(image-19ef3e-1741101421427)]

  • 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
JavaGuide_并发编程_原理1_store_barrier_模型.png
  `store barrier`称为 写屏障 ,相当于 `storestore barrier` ,  强制所有在 storestore 内存屏障之前的所有指令先执行。都要在该内存屏障之前执行,并发送缓存失效的信号。所有在 `storestore barrier` 指令之后的store 指令,都必须在 `storestore barrier` 屏障之前的指令执行完后再被执行。也就是禁止了写屏障前后的指令进行重排序,使得所有 `store barrier` 之前发生的内存更新都是可见的(这里的可见指的是修改值可见以及操作结果可见)
load barrier

[图片上传失败...(image-2fa6cd-1741101421427)]

 `load barrier`称为读屏障,相当于`loadload barrier` ,强制所有在 `load barrier` 读屏障之后的 `load` 指令,都在 `load barrier` 屏障之后执行。也就是禁止对 `load barrier` 读屏障前后的 `load` 指令进行重排序, 配合 `store barrier` ,使得所有 `store barrier` 之前发生的内存更新,对 `load barrier` 之后的 `load` 操作是可见的。
Full barrier
JavaGuide_并发编程_原理1_full_barrier_模型.png
 `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为什么不能保证原子性

public class Demo {
    static volatile int i;

    public static void main(String[] args) {
        i = 10;
    }
}

然后通过 javap -c Demo.class ,去查看字节码

{
  static volatile int i;
    descriptor: I
    flags: (0x0048) ACC_STATIC, ACC_VOLATILE

ACC_VOLATILE

accessFlags.hpp
bool is_volatile    () const         { return (_flags & JVM_ACC_VOLATILE    ) != 0; }

bytecodeinterpreter.cpp
// 把结果写回到栈中
// Now store the result on the stack
//
TosState tos_type = cache->flag_state();
int field_offset = cache->f2_as_index();
if (cache->is_volatile()) {
    if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
        OrderAccess::fence();
    }
    if (tos_type == atos) {
        VERIFY_OOP(obj->obj_field_acquire(field_offset));
        SET_STACK_OBJECT(obj->obj_field_acquire(field_offset), -1);
    }else if (tos_type == itos) {  // int 型的数据
        SET_STACK_INT(obj->int_field_acquire(field_offset), -1);
    }
    //。。。。。。。。。。。
}

拿到这个值,去看这个 cache 是不是 volatile 修饰的。

oop.inline.hpp
static void   release_store(volatile jint*  P, jint v);

根据不同的操作系统,有不同的实现。 JMM 是为了解决不同的系统做的处理方案。

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
inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore()  { compiler_barrier(); }
inline void OrderAccess::storeload()  { fence();            }

假如说 是 storeload() 然后,调用 fence() 方法,

inline void OrderAccess::fence() {
   // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
  __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
  __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  compiler_barrier();
}

汇编指令。 就是内存屏障。storeload 就是一个内存屏障。

JavaGuide_并发编程_原理1_storeload_内存屏障.png

OrderAccess::storeload();

是永远追加在后边的。是为了避免 `volatile` 写操作后边,有一些 `volatile` 读写操作的重排序。因为编译器,没办法去判断,`volatile` 后边是不是还要去插入。为了保证正确实现 `volatile` 语义,实现了悲观策略。我最终都要加上 这个屏障。
public class VolatileDemo1 {
    private static volatile boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 9;
            while (!stop){
                i++;
            }
        });
        thread.start();
        Thread.sleep(1000);
        stop = true;
    }
}
对于 `volatile` 修饰的变量,如果其他的线程对这个值进行了一个变更的话,他会去加一个内存屏障,他会去保证我们的可见性。我们会保证在我们的CPU 层面,就是我们的汇编指令层面,它实际上会去发起一个 LOCK 的汇编指令,这个 LOCK 指令最终做的就是把我们的这个缓存更新到我们的内存里边。
原子性

对符合操作的原子性是没有办法保证原子性的!!!

public class VolatileIncrDemo {
    volatile int i = 0;

    public void incr() {
        i++;
    }

    public static void main(String[] args) {
        new VolatileIncrDemo().incr();
    }
}

javap -c volatileIncrDemo.class 之后

  public void incr();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field i:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field i:I
      10: return
对一个原子递增的操作,会分为三个步骤:
  1. 读取 volatile 变量的值到 local ;
  2. 增加变量的值;
  3. 把 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 是干嘛的?
  1. 可以保证可见性、防止内存重排序
  2. #LOCK , - > 缓存锁 (MESI)
  3. 内存屏障
使用场景

线程的关闭。

java.util.concurrent.locks.AbstractQueuedSynchronizer

private volatile int state;

成员变量 state 的定义。

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

<center>原文链接:https://javaguide.net</center>

来源于: https://javaguide.net

相关文章

网友评论

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

      本文链接:https://www.haomeiwen.com/subject/bmvcmjtx.html