写点什么

别再说你不懂 Java 内存模型了!!!

  • 2023-04-21
    湖南
  • 本文字数:3512 字

    阅读完需:约 12 分钟

JMM(Java Memory Model)并发模型是 Java 多线程编程中的重要概念之一。在 Java 多线程编程中,我们需要了解 JMM 并发模型,才能够编写高效、可靠的多线程程序。我们将探讨 JMM 并发模型的基本概念,以及如何在 Java 多线程编程中使用它。我们将深入探讨 JMM 内存模型、原子性、可见性和有序性等关键概念。

并发三大特性

原子性

原子性是指一个操作是不可被中断的,要么执行完成,要么不执行,不存在执行了一半的情况。在多线程编程中,原子性非常重要,因为多个线程可以同时访问共享资源,如果多个线程同时修改同一个共享变量,就可能会出现数据竞争问题,导致程序出现错误。

示例
public class AtomicTest {    private static int counter = 0;    public static void main(String[] args) throws InterruptedException {        for (int i = 0; i < 10; i++) {            new Thread(()->{                for (int j = 0; j < 10000; j++) {                    counter++;                }            }).start();        }        Thread.sleep(3000);        System.out.println(counter);    }}
复制代码
执行结果
16816
复制代码
保证原子性方案
  • 使用 CAS 原子操作类如 AtomicInteger、AtomicLong、AtomicReference 等

  • 使用 synchronized

  • 使用 ReentrantLock

可见性

可见性是指一个线程对共享变量的修改能够被其他线程及时感知到。在多线程编程中,如果一个线程修改了一个共享变量的值,但是其他线程并不知道这个变量已经被修改,就可能导致程序出现错误。

示例
public class VisibilityDemo {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (flag) { // do something } System.out.println("Thread 1 exit"); }).start();
Thread.sleep(1000);
new Thread(() -> { flag = false; System.out.println("Thread 2 set flag to false"); }).start(); }}
复制代码

在这个 demo 中,有两个线程,一个线程会一直执行一个 while 循环,直到共享变量 flag 被修改为 false,另一个线程会在 1 秒钟后将 flag 设置为 false。在这个过程中,如果没有可见性保证,那么第一个线程可能永远无法感知到共享变量 flag 的修改,从而导致程序陷入死循环。

保证可见性方案
  • 使用 volatile

  • 使用 synchronized

  • 使用 ReentrantLock

  • 使用内存屏障

有序性

有序性是指操作的执行顺序符合程序的逻辑顺序,或者说操作的结果按照一定的顺序被其他线程观察到。在多线程编程中,有序性也是一个非常重要的概念,因为多个线程可以同时访问共享资源,如果它们观察到的操作顺序不符合程序的逻辑顺序,就可能会导致程序出现错误。

示例

在并发情况下 AB 两个线程去执行下面方法

public class ReorderExample {    private int x = 0;    private boolean flag = false;    //线程A执行此方法    public void write() {        x = 42;        flag = true;    }    //线程B执行此方法    public void read() {        if (flag) {            System.out.println(x);        } else {            System.out.println(1);        }    }}
复制代码

来分析一下输出的结果有几种可能

  1. A 线程先执行完毕然后执行 B 线程输出 42

  2. B 线程先执行完毕然后执行 A 线程输出 1

  3. A 线程执行到 x=42,CPU 进行了上下文切换到 B 线程输出 1


还存在一种可能会输出 42,JAVA 中在指令不存在依赖的情况下,会进行顺序的调整,这种现象叫做指令重排序,也就是编译的时候,x= 42 和 flag = true 的顺序可能会发生改变,也就是 A 线程执行到了 flag = true 还未执行 x = 42,CPU 进行了上下文切换到 B 线程条件为 true 输出 0。

保证有序性方案
  • 使用 volatile

  • 使用 synchronized

  • 使用 ReentrantLock

  • 使用内存屏障

Java 内存模型详解

Java 内存模型(Java Memory Model,JMM)定义了 Java 虚拟机(JVM)中的线程之间如何访问共享内存的方式。JMM 定义了一套规则,确保在不同的线程之间共享数据时,数据的可见性、原子性和有序性。

从上图可知线程 A 和线程 B 之间要进行通信必须线程 A 把本地内存中更新过的值刷新回主内存,线程 B 在从主内存中读取相线程 A 更新过的值。

Java 内存模型 8 种原子操作

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用

  • load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。

  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

可见性深入分析

上面提到保证可见性的方案都是靠内存屏障实现的,除了内存屏障的方式在还有在 CPU 进行上下文切换的时候也会有可见性.

内存屏障

内存屏障(Memory Barrier),也称为内存栅栏,是一种硬件或软件机制,用于控制指令序列中内存访问的顺序。在 Java 中,JMM 使用内存屏障来保证多线程程序中的可见性和有序性。


在 JMM 中,内存屏障分为两种类型:读屏障和写屏障。读屏障用于确保读操作的可见性,即确保一个线程读取到的是另一个线程最新写入的值。写屏障用于确保写操作的顺序性,即确保一个线程写入的值在另一个线程读取之前已经被更新。

锁的内存屏障体现
  • 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。

  • 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中

synchronized 关键字的作用是确保多个线程访问共享资源时的互斥性和可见性。在获取锁之前,线程会将共享变量的最新值从主内存中读取到线程本地的缓存中,释放锁时会将修改后的共享变量的值刷新到主内存中,以保证可见性。

volatile 的内存屏障体现
  • 对 volatile 变量的写指令后会加入写屏障

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


当一个线程修改一个 volatile 变量的值时,JVM 会强制将该变量的新值刷新到主内存中,当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

volatile 内存屏障如何保证有序性

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。


  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障

  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障

  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障

  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

总结

Java 中的多线程编程存在着一些常见的问题,其中最重要的问题是多个线程之间对共享变量的访问。在这种情况下,使用 volatile 关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序。同时,使用 synchronized 关键字可以保证可见性,并且保证了原子性(互斥性)。


具体来说,当使用 volatile 关键字修饰一个共享变量时,Java 虚拟机会在读取或写入该变量的时候插入内存屏障指令,以确保在一个线程修改了该变量后,其他线程能够立即看到这个变化。同时,内存屏障还可以防止编译器将操作顺序进行优化,从而保证指令不会被重排。


而使用 synchronized 关键字则可以保证更强的同步保障,除了保证可见性外,还可以保证原子性,也就是说,在一个线程获取了对象的锁之后,其他线程无法访问该对象的任何同步方法或同步代码块。这样就可以保证共享变量的读取和写入操作的原子性,从而避免了并发访问带来的一系列问题。


在更底层,Java 内存模型(JMM)通过内存屏障来实现内存的可见性以及禁止重排序。内存屏障是一种特殊的 CPU 指令,它可以强制 CPU 和缓存将对内存的访问进行同步,从而保证不同线程之间对共享变量的读写顺序不会发生重排,同时也能保证内存可见性。JMM 中的内存屏障可以分为读屏障、写屏障和全屏障三种类型,用于控制内存访问的顺序和可见性。


作者:码下客

链接:https://juejin.cn/post/7224068169341108283

来源:稀土掘金

用户头像

还未添加个人签名 2021-07-28 加入

公众号:该用户快成仙了

评论

发布
暂无评论
别再说你不懂Java内存模型了!!!_Java_做梦都在改BUG_InfoQ写作社区