• 数据共享性是线程安全的主要原因之一。
  • 如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。
  • 在多线程编程中,数据共享是不可避免的。
  • 最典型的场景是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中的数据。

互斥性

  • 资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。
  • 我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。
  • 所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。
  • 如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。
  • 对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。
  • 但是对共享数据的写操作,一般就需要保证互斥性。

可见性

  • 线程只能操作自己工作空间中的数据。
  • 每个工作线程都有自己的工作内存,所以当某个线程修改完某个变量之后,在其他的线程中,未必能观察到该变量已经被修改。

如何保证可见性

volatile

  • volatile 关键字要求被修改之后的变量要求立即更新到主内存,每次使用前从主内存处进行读取。
  • 当修饰引用类型的时候, 只能保证引用本身的可见性, 不能保证内部字段的可见性。

synchronized 加锁

synchronized 保证 unlock 之前必须先把变量刷新回主内存。

示例如下

/**
 * @author BNTang
 */
public class VisibilityTest {

    private static boolean running = true;

    private static void method() {
        System.out.println("start");
        while (running) {
            // 如果添加了打印语句, 变量不加 volatile 程序也会结束
            // 因为在println当中触发了程序的可见性,使用 synchronized 修饰
            System.out.println("hello");
        }
        System.out.println("end");
    }

    public static void main(String[] args) {
        new Thread(VisibilityTest::method).start();
        SleepTools.sleepSecond(1);
        running = false;
    }
}
/**
 * @author BNTang
 */
public class VisibilityTest {
    /**
     * 成员变量是线程共享的
     */
    private static volatile boolean running = true;

    private static void method() {
        System.out.println("start");
        while (running) {
        }
        System.out.println("end");
    }

    public static void main(String[] args) {
        // 创建一个线程  新线程
        new Thread(VisibilityTest::method).start();

        // 主线程 main
        SleepTools.sleepSecond(1);
        running = false;
    }
}
/**
 * @author BNTang
 */
public class VisibilityTest {

    private static boolean running = true;

    private static void method() {
        System.out.println("start");
        while (running) {
        }
        System.out.println("end");
    }

    public static void main(String[] args) {
        // 程序一直结束不了
        new Thread(VisibilityTest::method).start();
        SleepTools.sleepSecond(1);
        running = false;
    }
}

原子性

  • 原子性就是指对数据的操作是一个独立的、不可分割的整体。
  • 换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行到一半的时候被其他线程所修改。

示例

1.X = 5

  • 是一个写操作
  • 具有原子性

2.Y = X

  • 不具有原子性
  • 先把数据 X 读取到工作空间
  • 再把 X 的值写给 Y
  • 是一个读写操作, 不具有原子性

3.i++

  • 不具有原子性
  • 读 i 到工作空间
  • +1 后写给 i
  • 刷新结果到内存

4.a = a + 1

  • 不具有原子性
  • 读 a 到工作空间
  • +1
  • 刷新结果到内存

如何保证原子性

  • Synchronized
  • JUC Lock 加锁
  • 被 synchronized 关键字或其他锁包裹起来的操作也可以认为是原子的。
  • 从一个线程观察另外一个线程的时候,看到的都是一个个原子性的操作。

有序性

为了提高性能,编译器和处理器可能会对指令做重排序。

编译器优化的重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令级并行的重排序

  • 现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。
  • 如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序

由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

程序中的顺序不一定就是执行的顺序,编译时重排序,指令重排序。

如何保证有序性

通过 volatilesynchronized 可以保证程序的有序性。

数据依赖性

什么是数据依赖性

不同的程序指令之间的顺序是不允许进行交换的,即可称这些程序指令之间存在数据依赖性。

哪些指令不允许重排

写后读: a = 1;b = a;写一个变量之后,再读这个位置。
写后写: a = 1;a = 2;写一个变量之后,再写这个变量。
读后写: a = b;b = 1;读一个变量之后,再写这个变量。

发现这里每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果。

多线程下的指令重排

TODO

并发编程特性与volatile

as-if-serial

重新排序后的运行结果和之前的结果要保持一致。

Happen-Before

Happen-Before

  • Happen-Before 被翻译成先行发生原则,意思就是当 A 操作先行发生于 B 操作。
  • 则在发生 B 操作的时候,操作 A 产生的影响能被 B 观察到。
  • “影响” 包括修改了内存中的共享变量的值、发送了消息、调用了方法等。

原则

  • 程序次序规则(Program Order Rule)在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。
  • 锁定规则(Monitor Lock Rule)一个 Unlock 的操作肯定先于下一次 Lock 的操作, 后一次加锁必须等前一次解锁, 这里必须是同一个锁。同理我们可以认为在 synchronized 同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。
  • volatile 变量规则(volatile Variable Rule)对同一个 volatile 的变量,先行发生的写操作,肯定早于后续发生的读操作。
  • 线程启动规则(Thread Start Rule)Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程中止规则(Thread Termination Rule)Thread 对象的中止检测(如:Thread.join())操作,必行晚于线程中所有操作。
  • 线程中断规则(Thread Interruption Rule)对线程的 interruption() 调用,先于被调用的线程检测中断事件 (Thread.interrupted()) 的发生。
  • 对象中止规则(Finalizer Rule)一个对象的初始化方法先于一个方法执行 Finalizer() 方法。
  • 传递性(Transitivity)如果操作 A 先于操作 B、操作 B 先于操作 C, 则操作 A 先于操作 C。

volatile

内存模型

并发编程特性与volatile

volatile 关键字的作用

  • 当一个变量加上 volatile, 就是告诉虚拟机, 每一次要使用变量时, 总是要从主内存当中读取。
  • 如果要修改修饰的变量, 一定要把修改完后的值, 刷回主内存,即不会出现数据脏读的现象。
  • 被 volatile 修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

特点

  • 保证共享性
  • 保证有序性
  • 不能保证原子性

不是线程安全的, 只能保存变量的可见性, 不能保证变量的原子性, 对共享变量的修改,其他的线程马上能感知到,不能保证原子性(读写, i++)。

volatile 的实现原理

/**
 * @author BNTang
 */
public class Test {
    public static volatile int counter = 1;

    public static void main(String[] args) {
        counter = 2;
        System.out.println(counter);
    }
}

字节码

查看字节码, 使用 JavaP 查看字节码, 找到字节码执行反编译操作

javap -v Test

并发编程特性与volatile

使用 idea 外部工具

并发编程特性与volatile

  • Arguments:-v $FileClass$
  • Working directory:$OutputPath$

并发编程特性与volatile

并发编程特性与volatile

结论

  • 修饰 counter 字段的 public、static、volatile 关键字
  • 在字节码层面分别是以下访问标志:ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
  • volatile 在字节码层面,就是使用访问标志:ACC_VOLATILE 来表示
  • 供后续操作此变量时判断访问标志是否为 ACC_VOLATILE,来决定是否遵循 volatile 的语义处理

如上是在 JVM 层面所看到的

反编译

HSDIS

HSDIS (HotSpot disassembler) 一个 Sun 官方推荐的 HotSpot 虚拟机 JIT 编译代码的反汇编插件,其实际上就是一个动态库。这里我们直接从网上下载与我们系统对应的编译后文件,然后直接将其放置到 JDK 的 bin 目录下即可

把下载好的 hsdis 解压放到 bin 目录当中 (jdk11 是放在 bin 目录当中,jdk8 是放在 jre\bin\server 目录当中)

并发编程特性与volatile

在程序运行时添加参数

并发编程特性与volatile

-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly,*Test.main

以上内容只需要修改的地方就是修改类名,前面的 * 号不要去掉,然后再加上对应的方法名称即可,配置好了在次运行程序发现控制台输出结果如下

并发编程特性与volatile

观察结果

volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令

并发编程特性与volatile

并发编程特性与volatile

lock 前缀指令实际上相当于是一个内存屏障(也称内存栅栏)内存屏障会提供 3 个功能

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
  2. 它会强制将对缓存的修改操作后的数据立即写入主存
  3. 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。对缓存行进行加锁处理

volatile 禁止指令重排原理

JMM 对于 volatile重排序的禁止规则

当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序

并发编程特性与volatile

当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序

并发编程特性与volatile

当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序

并发编程特性与volatile

volatile 是通过编译器在生成字节码时,在指令序列中添加 “内存屏障” 来禁止指令重排序的

硬件层面的内存屏障

  • sfence:写屏障 (Store Barrier) 在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,保证写入的数据立刻对其他线程可见
  • lfence:读屏障 (Load Barrier) 读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,保证读取的是最新的数据
  • mfence:即全能屏障 (modify / mix Barrier) 兼具 sfence 和 lfence 的功能
  • lock 前缀:lock 不是内存屏障,而是一种锁

JMM 层面的内存屏障

  • LoadLoad 屏障:像 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕
  • StoreStore 屏障:像 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见
  • LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕
  • StoreLoad 屏障: 对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见

JVM 的实现会在 volatile 读写前后均加上内存屏障,在一定程度上保证有序性

volatile

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障

例如下方的几个栗子:

StoreStoreBarrier
volatile 写操作
StoreLoadBarrier

LoadLoadBarrier
volatile 读操作
LoadStoreBarrier

并发编程特性与volatile

手动添加内存屏障

在 Java 提供的 Api 中有一个名称为 Unsafe 的类

/**
 * @author BNTang
 */
public class Test {
    public static void main(String[] args) {
        Unsafe unsafe = Unsafe.getUnsafe();

        // 添加读屏障
        unsafe.loadFence();

        // 添加写屏障
        unsafe.storeFence();

        // 读写屏障
        unsafe.fullFence();
    }
}

volatile 保证可见性原理

  • 总线加锁
  • 缓存一致性协议 MESI

MESI

MESI 中每个缓存行都有四个状态

  • E(exclusive)独占状态
  • S(shared)共享状态
  • M(modified)修改状态
  • I(invalid)失效状态

过程

  • cpu 在启动的时候,会采用监听模式,一直会监听消息的传递
  • 如果在读取一个变量时,发现被 lock 修饰时,其它 CPU 会监听到现在有人在读取数据
  • 假设现在 cpu1 读取到一个变量 a = 1, 是第一次读,会把这个变量 a 标记成 E(独占状态)
  • 如果此时,有另一个 cpu 也读取变量 a, 此时 cpu1 也会可监听到,并且会把状态更改为 S状态(共享状态) 在 cpu2 当中会也会标记成 S状态(共享状态)

如果两个 cpu 都要对 a 变量进行修改

  • 假设 cpu1 把 a 改为 2
  • cpu2 要把 a 改成 3
  • 会分别在 cpu 当中的缓存行中加锁,一旦加锁成功后,就可以来修改里面的内容,并且把状态标志成 M(已修改状态)
  • 假设 cpu2 缓存行加锁成功,会向消息总线发送一个本地写缓存的消息
  • 如果两个人同时加锁,发消息给消息总线
  • 此时总线就要采取内部仲裁的方式来决定谁先成功
  • 通过总线的高低电位来裁决
  • 消息发成功后,会被 cpu1 捕捉到,cpu1 会把自己当中的变量置为 I(无效状态) 到内存当中再读取最新的数据
  • 在发出消息后,并不是立马就写入到内存当中,会先把写的数据放到一个 store buffer 当中,等 cpu1 把消息变为无效后,才会写到入到内当中
  • 当 cpu1 把消息设置会无效后,会把原来的数据 a = 1 放到一个 queue 队列当中,并且会发送一个消息通过已经置为无效

并发编程特性与volatile

使用场景

一个线程写, 多个线程读

  1. 状态标志(开关模式)
  2. 双重检查锁定(double-checked-locking)DCL

并发编程特性与volatile

/**
 * 单例
 *
 * @author BNTang
 */
public class Singleton {
    /**
     * 实例
     */
    private volatile static Singleton instance = null;

    /**
     * 单例
     */
    private Singleton() {}

    /**
     * 获得实例
     *
     * @return {@link Singleton}
     */
    public static Singleton getInstance() {
        if (instance == null) {

            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  1. 需要利用顺序性

volatile 与 synchronized

  • volatile 只能修饰变量,synchronized 只能修饰方法和语句块
  • synchronized 可以保证 原子性,volatile 不能保证原子性
  • 都可以保证 可见性,但实现原理不同, volatile 对变量加了 lock synchronized 使用 monitorEntermonitorexit monitor
  • volatile 能保证 有序,synchronized 可以保证 有序性,但是代价(重量级)并发退化到串行
  • synchronized 引起阻塞
  • volatile 不会引起阻塞

参考文章

相关文章:

  • 2021-11-03
  • 2021-09-18
  • 2021-06-10
  • 2021-07-06
  • 2022-01-09
  • 2021-10-11
  • 2021-12-13
  • 2021-09-17
猜你喜欢
  • 2021-09-06
  • 2021-08-17
  • 2021-07-08
  • 2022-12-23
  • 2021-05-11
  • 2021-04-15
相关资源
相似解决方案