并发三要素 : 可见性, 原子性, 有序性

简介: 并发三要素:可见性, 原子性, 有序性,并发问题该怎样解决,怎样实现数据同步,这篇文章为您解决

♨️本篇文章记录的为JUC知识中可见性, 原子性, 有序性相关内容,适合在学Java的小白,也适合复习中,面试中的大佬🙉🙉🙉。
♨️如果文章有什么需要改进的地方还请大佬不吝赐教❤️🧡💛

💖个人主页
如果大家对JUC相关知识感兴趣请点击这里👉👉👉JUC专栏学习

@[TOC]
众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

CPU 增加了缓存,以均衡与内存的速度差异;// 导致 可见性问题
操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;// 导致 原子性问题
编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致 有序性问题

在这里插入图片描述

可见性: CPU 缓存引起

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

举个例子:

public class Main{
   
   
    private int a = 0;
    public void ad(){
   
   
        a = 1;
    }
    public void re(){
   
   
        int j = a;
    }
}

现有两个线程 A ,B。A 执行 ad () ,B 执行 re (), 当线程 A 执行 a =1 时,会先把 a 的初始值加载到 CPU-A 的高速缓存中,然后赋值为 1,那么在 CPU-A 的高速缓存当中 i 的值变为 1 了,却没有立即写入到主存当中。此时线程 B 执行 j = a ,它会先去主存读取 a 的值并加载到 CPU-B 的缓存当中,注意此时内存当中 a 的值还是 0,那么就会使得 j 的值为 0,而不是 1。

原子性:分时复用引起

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

举个例子:

public class Main{
   
   
    private int a = 0;
    public void ad(){
   
   
        a += 1;
    }
}

这里需要注意的是:a += 1 需要三条 CPU 指令

  • 将变量 a 从内存读取到 CPU 寄存器;
  • 在 CPU 寄存器中执行 a+ 1 操作;
  • 将最后的结果 i 写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

由于 CPU 分时复用(线程切换)的存在,线程 A 执行了第一条指令后,就切换到线程 B 执行,假如线程 B 执行了这三条指令后,再切换会线程 A 执行后续两条指令,将造成最后写到内存中的 a 值是 2 而不是 3。

有序性:重排序引起

有序性:即程序执行的顺序按照代码的先后顺序执行。

举个例子:

public class Main{
   
   
    private int a = 0;
    private boolean b = false;
    public void show(){
   
   
        a = 1;            //语句1  
        b = true;       //语句2  
    }
}

上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句 1 是在语句 2 前面的,那么 JVM 在真正执行这段代码的时候会保证语句 1 一定会在语句 2 前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

在这里插入图片描述
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)

下面我们来说一下Java是如何处理这三个问题的

JMM (Java 内存模型)

JAVA 是通过 JMM 来解决 并发问题的

JMM 本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:

  • volatile、synchronized 和 final 三个关键字
  • Happens-Before 规则

原子性:

Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

可见性:

Java 提供了 volatile 关键字来保证可见性。

  • 当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
  • 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性:

在 Java 里面,可以通过 volatile 关键字来保证一定的 “有序性”(具体原理在后边讲述)。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然 JMM 是通过 Happens-Before 规则来保证有序性的。

Happens-Before 规则

先行发生原则 :一个操作无需控制就能先于另一个操作完成。

  1. 单一线程原则:在一个线程内,在程序前面的操作先行发生于后面的操作。
  2. 管程锁定规则 :一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
  3. volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  4. 线程启动规则 :Thread 对象的 start () 方法调用先行发生于此线程的每一个动作。
  5. 线程加入规则 :Thread 对象的结束先行发生于 join () 方法返回。
  6. 线程中断规则 :对线程 interrupt () 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted () 方法检测到是否有中断发生。
  7. 对象终结规则 :一个对象的初始化完成 (构造函数执行结束) 先行发生于它的 finalize () 方法的开始。
  8. 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

线程安全的实现方法

阻塞同步

synchronized 和 ReentrantLock。
synchronized 👉👉👉点击这里

非阻塞同步

CAS

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施 (不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换 (Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet () 和 getAndIncrement () 等方法都使用了 Unsafe 类的 CAS 操作。

以下代码使用了 AtomicInteger 执行了自增的操作。

private AtomicInteger cnt = new AtomicInteger();

public void add() {
   
   
    cnt.incrementAndGet();
}
//以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt() 。
public final int incrementAndGet() {
   
   
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
//以下代码是 getAndAddInt() 源码
public final int getAndAddInt(Object var1, long var2, int var4) {
   
   
    int var5;
    do {
   
   
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile (var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt () 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。

可以看到 getAndAddInt () 在一个循环中进行,发生冲突的做法是不断的进行重试。

ABA

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

在这里插入图片描述

如果这篇【文章】有帮助到你💖,希望可以给我点个赞👍,创作不易,如果有对Java后端或者对redis感兴趣的朋友,请多多关注💖💖💖
💖个人主页:阿千弟
如果大家对JUC相关知识感兴趣请点击这里👉👉👉JUC专栏学习

目录
相关文章
|
3月前
|
安全 Java 编译器
【面试问题】说说原子性、可见性、有序性?
【1月更文挑战第27天】【面试问题】说说原子性、可见性、有序性?
|
12天前
|
缓存 安全 Java
多线程的三大特性:原子性、可见性和有序性
多线程的三大特性:原子性、可见性和有序性
16 0
|
4月前
|
缓存 安全 Java
3.线程安全之可见性、有序性、原子性是什么?
3.线程安全之可见性、有序性、原子性是什么?
43 0
3.线程安全之可见性、有序性、原子性是什么?
|
4月前
|
缓存 Java
13.synchronized总结:怎么保证可见性、有序性、原子性?
13.synchronized总结:怎么保证可见性、有序性、原子性?
42 0
13.synchronized总结:怎么保证可见性、有序性、原子性?
|
4月前
|
缓存 安全 Java
5.volatile是什么?怎么保证可见性?
5.volatile是什么?怎么保证可见性?
41 0
5.volatile是什么?怎么保证可见性?
|
4月前
|
安全 Java
7.volatile怎么通过内存屏障保证可见性和有序性?
7.volatile怎么通过内存屏障保证可见性和有序性?
29 0
7.volatile怎么通过内存屏障保证可见性和有序性?
|
9月前
volatile 的作用是什么?能保证原子性吗?能保证有序性吗?
volatile 的作用是什么?能保证原子性吗?能保证有序性吗?
70 0
|
缓存 Java 编译器
并发编程(三)原子性&可见性&有序性
并发编程(三)原子性&可见性&有序性
102 0
|
Java
简单说说原子性、可见性、有序性
原子性、可见性、有序性
305 0
|
算法 Java 编译器
线程安全性详解(原子性、可见性、有序性)()2
线程安全性详解(原子性、可见性、有序性)
168 0
线程安全性详解(原子性、可见性、有序性)()2