4

从缓存入门到并发编程三要素详解 Java中 volatile 、final 等关键字解析案例 - 有点小...

 1 year ago
source link: https://www.cnblogs.com/malongfeistudy/p/16750965.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

从缓存入门到并发编程三要素详解 Java中 volatile 、final 等关键字解析案例

引入高速缓存概念

  1. 在计算机在执行程序时,以指令为单位来执行,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。

  2. 由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行指令的速度很快,而从内存读取数据和向内存写入数据的过程相对很慢,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此就引入了高速缓存

  3. 特性:缓存(Cache memory)是硬盘控制器上的一块内存,是硬盘内部存储和外界接口之间的缓冲器。

高速缓存作用呢?

  1. ​ 相当于提前加载,猜测你可能会用到硬盘相邻存储地址的数据,它会提前进行加载到缓存中,后面你需要时,CPU就不需要去硬盘读取数据,直接读取缓存中的数据传输到内存中就OK了,由于读取缓存的速度远远高于读取硬盘时磁头读写的速度,所以能够明显的改善性能。

  2. 对写入动作进行缓存

    ​ 硬盘接到写入数据的指令之后,并不会马上将数据写入到盘片上,而是先暂时存储在缓存里,然后发送一个“数据已写入”的信号给系统,这时系统就会认为数据已经写入,并继续执行下面的工作,而硬盘则在空闲(不进行读取或写入的时候)时再将缓存中的数据写入到盘片上。

  3. 换到应用程序层面也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据同步到主存当中

举个简单的例子,比如下面的这段代码:

i = i + 1;
  • 当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

  • 这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了(存在临界区)。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存区(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。

比如有两个线程像下列执行顺序:

  1. 线程一执行 i = i + 1,线程二执行var = i
  2. 线程二此时去主存中获取变量 i,线程一只是在高速缓存中更新了变量,还未将变量i写会主存
  3. 线程二读到的i不是最新值,此时多线程导致数据不一致

​ 类似上面这种情况即为缓存一致性问题读写场景、双写场景都会存在缓存一致性问题,但读读不会。前提是需要在多线程运行的环境下,并且需要多线程去访问同一个共享变量。

​ 这里的共享又可以回到上文中,即为上面所说,他们每个线程都有自己的高速缓存区,但是都是从同一个主存同步获取变量。

那么这种问题应该怎样解决呢?

解决缓存不一致问题(硬件层面)

  1. 总线加锁模式
    • 由于CPU在执行命令和其他组件进行通信的时候都需要通过总线,倘若对总线加锁的话,线程一执行i = i + 1 整个命令过程中,其他线程是无法访问主存的。
    • 优缺只有一个,可以解决本问题;缺点的话除了优点全是缺点,效率低,成本高·····(谁也不会让一个主存同时只能干一件事)
  2. 缓存一致性协议
    • 协议可以保证每个缓存中使用的共享变量的副本是一致的,原理:CPU对主存中的共享变量有写入操作时,会立即通知其他CPU将该变量缓存行置为无效状态。其他CPU发现该变为无效状态时,就会重新去主存中读取该变量最新值。
    • 优点就是可以解决问题,读多写少效率还OK;缺点就是实现繁琐,较耗费性能,在对于写多的场景下效率很不可观

问题线程为什么会不安全?

​ 答:共享资源不能及时同步更新,归根于 分时系统 上下文切换时 指令还未执行完毕 (没有写回结果) 更新异常

引入并解释并发编程特性

​ 众所周知现在的互联网大型项目,都是采用分布式架构同时具有其“三高症状”高并发、高可用、高性能。高并发为其中最重要的特性之一,在高并发场景下并发编程就显得尤为重要,其并发编程的特性为原子性、可见性、有序性

原子性指的是一个或多个操作要么全部执行成功要么全部执行失败,期间不能被中断,也不存在上下文切换,线程切换会带来原子性的问题。

  • 变量赋值问题:

    • b 变量赋值的底层字节码指令被分为两步:第一步先定义 int b;第二步再赋值为 10。

    • 两条指令之间不具有原子性,且在多线程下会发生线程安全性问题

      int b = 10;

可见性指的是当前线程对共享变量的修改对其他线程来说是可见的。以下案例中假设不会出现多线程原子性问题(比如多个线程写入覆盖问题等),即保证一次变量操作底层执行指令为原子性的。

例如上述变量在读写场景下,不能保证其可见性,导致写线程完成修改指令时但为同步到主存中,读线程并不能获得最新值。这就是对于B线程来说没有满足可见性。

  • 案例解析:final关键字

    • final 变量可以保证其他线程获取的该变量的值是唯一的。变量指成员变量或者静态变量

    • b 变量赋值的底层字节码指令被分为两步:第一步先定义 int b;第二步再赋值为 10

      final a = 10; int b = 10;
    • final修饰的变量在其指令后自动加入了写屏障,可以保证其变量的可见性

    • a 可以保证其他线程获取的值唯一;b 不能保证其他线程获取到的值一定是 10,有可能为 0。

    • 读取 final 变量解析 :

      • 不加 final 读取变量时去堆内存寻找,final 变量是在栈空间,读取速度快
      • 读取 final 变量时,直接将其在栈中的值复制一份,不用去 getstatic ,性能得到提升
      • 注意:不是所有被 final 修饰的变量都在栈中。当数值超过变量类型的 MAX_VALUE 时,将其值存入常量池中
      • 读取变量的速度:栈 > 常量池 > 堆内存
  • final 可以加强线程安全,而且符合面向对象编程开闭原则中的close,例如子类不可继承、方法不可重写、初始化后不可改变、非法访问(如修饰参数时,该参数为只读模式)等

有序性指的是程序执行的顺序按照代码的先后顺序执行。

在Java中有序性问题会时常出现,由于我们的JVM在底层会对代码指令的执行顺序进行优化(提升执行速度且保证结果),这只能保证单线程下安全,不能保证多线程环境线程安全,会导致指令重排发生有序性问题。

案例:排名世界第一的代码被玩坏了的单例模式

DCL(double checked):加入 volatile 保证线程安全,其实就是保证有序性。

上代码:其中包括了三个问题并且有详细注释解释。(鸣谢itheima满一航老师)

  1. 为什么加入 volatile 关键字?
  2. 对比实现3(给静态代码块加synchronized) 说出这样做的意义?
  3. 为什么要在这里加空判断,之前不是判断过了吗?
final class SingletonLazyVolatile { private SingletonLazyVolatile() { } // 问题1:为什么加入 volatile 关键字? // 答: 防止指令重排序 造成返回对象不完整。 如 TODO private static volatile SingletonLazyVolatile INSTANCE = null; // 问题2:对比实现3(给静态代码块加synchronized) 说出这样做的意义? // 答:没有锁进行判断、效率较高 public static SingletonLazyVolatile getInstance() { if (INSTANCE != null) { return INSTANCE; } // 问题3:为什么要在这里加空判断,之前不是判断过了吗? // 答:假入t1 先进入判断空成立,先拿到锁, 然后到实例化对象这一步(未执行) // 同时 线程 t2 获取锁进入阻塞状态,若 t1 完成创建对象后,t2 没有在同步块这进行判空,t2 会再新创建一个对象, // 导致 t1 的对象被覆盖 造成线程不安全。 synchronized (SingletonLazyVolatile.class) { // t1 if (INSTANCE != null) { return INSTANCE; } INSTANCE = new SingletonLazyVolatile(); // t1 这行代码会发生指令重排序,需要加入 volatile // 如:先赋值指令INSTANCE = new SingletonLazyVolatile,导致实例不为空,下一个线程会判空失败直接返回该对象 // 但是构造方法()指令还没执行,返回的就是一个不完整的对象。 return INSTANCE; } }}

通过对并发编程的三要素介绍,也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

补充volatile知识:

  • volatile 只保证可见性(多线程下对变量的修改是可见的)、有序性(禁止进行指令重排序)

  • volatile 的底层实现原理是内存屏障(内存栅栏),Memory Barrier(Memory Fence),内存屏障会提供3个功能:

    • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
    • 它会强制将对缓存的修改操作立即写入主存
    • 如果是写操作,它会导致其他CPU中对应的缓存行无效
  • volatile修饰之后的变量会加入读写屏障

    • 写屏障(sfence):保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    • 读屏障(lfence):保证在该屏障之后的, 对共享变量的读取,加载的是主存中的最新数据

    • 对 volatile 变量的写指令后会加入写屏障

    • 对 volatile 变量的读指令前会加入读屏障

关于volatile 的用途像两阶段终止、单例双重锁等等:

两阶段终止--volatile

@Log public class TwoPhaseStop { // 监控线程 private Thread monitorThread; // 多线程共享变量 单线程写入(停止线程) 多线程读取 使用 volatile private volatile boolean stop = false; // 启动监控线程 public void start() { monitorThread = new Thread(() -> { log.info("开始监控"); while (true) { log.info("监控中"); Thread currentThread = Thread.currentThread(); if (stop) { log.info("正在停止"); break; } try { log.info("正常运行"); Thread.sleep(5000); } catch (InterruptedException e) { // sleep出现被打断异常后、被打断后会清除打断标记 // 需要重新打断标记 currentThread.interrupt(); } } log.info("已停止"); },"monitor"); monitorThread.start(); } // 停止监控线程 public void stop() { stop = true; monitorThread.interrupt(); } }

下篇预告:synchronized 和 volatile 区别和底层原理


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK