理解 Java 内存模型(JMM)与 volatile 关键字 以及 指令重排

目录

引言

1. Java 内存模型(JMM)

1.1 主内存与工作内存

1.2 内存间的交互操作

1.3 可见性问题

1.4 有序性问题

2. volatile 关键字

2.1 可见性

2.2 禁止指令重排序

2.3 volatile 的使用场景

2.4 volatile 不保证原子性

3.指令重排

4. 总结


引言

在 Java 并发编程中,理解 Java 内存模型(Java Memory Model, JMM)以及 volatile 关键字的作用是至关重要的。它们帮助我们确保多线程环境下的可见性、有序性和原子性。本文将深入探讨 JMM 和 volatile 关键字以及指令重排,并解释它们如何影响多线程程序的执行。

1. Java 内存模型(JMM)

JMM 本身是一种抽象的概念,并不真实存在,它描述的是一组规则或者规范~

Java 内存模型定义了 Java 程序中各种变量(线程共享变量)的访问规则,以及在并发环境下如何保证这些变量的可见性、有序性和原子性。JMM 的主要目标是解决多线程环境下的内存可见性问题。

JMM 关于同步的规定:

1、线程解锁前,必须把共享变量的值刷新回主内存

2、线程加锁前,必须读取主内存的最新值到自己的工作内存

3、加锁解锁是同一把锁

1.1 主内存与工作内存

在 JMM 中,内存分为两类:

  • 主内存(Main Memory):所有线程共享的内存区域,存储了所有的变量(实例字段、静态字段等)。

  • 工作内存(Working Memory):每个线程都有自己的工作内存,存储了该线程使用到的变量的副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接操作主内存中的变量。

此处的主内存和工作内存跟JVM内存划分(堆、 栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部 分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存 对应的是寄存器和高速缓存。

JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所 以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写 入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个 线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了 一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

JMM的内存模型

线程A感知不到线程B操作了值的变化!如何能够保证线程间可以同步感知这个问题呢?

只需要使用 Volatile关键字即可!volatile 保证线程间变量的可见性,简单地说就是当线程A对变量X进行了修改后, 在线程A后面执行的其他线程能看到变量X的变动,更详细地说是要符合以下两个规则 :

线程对变量进行修改之后,要立刻回写到主内存。

线程对变量读取的时候,要从主内存中读,而不是缓存。

各线程的工作内存间彼此独立,互不可见,在线程启动的时候,虚拟机为每个内存分配一块工作内存, 不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(非线程内构造的对象)的 副本,即,为了提高执行效率。

1.2 内存间的交互操作

JMM 定义了以下几种内存间的交互操作:

  • read:从主内存中读取变量的值到工作内存。

  • load:将 read 操作读取的值放入工作内存中的变量副本。

  • use:当线程执行字节码指令时,使用工作内存中的变量值。

  • assign:将工作内存中的变量值赋给一个新的值。

  • store:将工作内存中的变量值传送到主内存。

  • write:将 store 操作传送的值写入主内存中的变量。

这些操作必须按照一定的顺序执行,以确保多线程环境下的内存可见性。

1.3 可见性问题

在多线程环境下,一个线程对共享变量的修改可能不会立即对其他线程可见。这是因为每个线程都有自己的工作内存,线程对变量的操作首先发生在工作内存中,之后才会同步到主内存。如果同步不及时,其他线程可能读取到过期的数据。

1.4 有序性问题

为了提高性能,编译器和处理器可能会对指令进行重排序。这种重排序在单线程环境下不会影响程序的执行结果,但在多线程环境下可能会导致意想不到的结果。JMM 通过 happens-before 规则来保证指令的有序性。

2. volatile 关键字

volatile 是 Java 提供的一种轻量级的同步机制,用于确保变量的可见性和有序性。当一个变量被声明为 volatile 时,它具有以下特性:

2.1 可见性

volatile 变量的修改会立即被写入主内存,并且每次读取 volatile 变量时都会从主内存中读取最新的值。这确保了多个线程之间对 volatile 变量的可见性。

验证:

//Volatile 用来保证数据的同步,也就是可见性
public class JMMVolatileDemo01 {
    // volatile 不加volatile没有可见性
    // 不加 volatile 就会死循环,这里给大家将主要是为了面试,可以避免指令重排
    private volatile static int num = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (num==0){ //此处不要编写代码,让计算机忙的不可开交
            }
        }).start();
        Thread.sleep(1000);
        num = 1;
        System.out.println(num);
    }
 }

2.2 禁止指令重排序

volatile 变量的读写操作不会被重排序。编译器在生成字节码时,会插入内存屏障(Memory Barrier)来禁止指令重排序,确保 volatile 变量的操作按照代码的顺序执行。

2.3 volatile 的使用场景

volatile 适用于以下场景:

状态标志:当一个变量作为状态标志时,可以使用 volatile 来确保多个线程能够及时看到状态的改变。例如:

volatile boolean running = true;

public void stop() {
    running = false;
}

public void run() {
    while (running) {
        // do something
    }
}

单例模式的双重检查锁定(Double-Checked Locking):在单例模式中,volatile 可以确保实例的可见性和有序性。

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

2.4 volatile 不保证原子性

虽然 volatile 可以保证可见性和有序性,但它并不能保证原子性。例如,volatile 变量不适合用于计数器等需要原子操作的场景。如果需要保证原子性,可以使用 synchronized 或 java.util.concurrent.atomic 包中的原子类。

验证 volatile 不保证原子性

原子性理解: 不可分割,完整性,也就是某个线程正在做某个具体的业务的时候,中间不可以被加塞或者被分割,需要整体完整,要么同时成功,要么同时失败。

举例:

public class JMMVolatileDemo02 {
    private  static int num = 0;
    public static void add(){
        num++;
    }
    // 结果应该是 num 为 2万,测试看结果
    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000; j++) {
                    add();
                }
            },String.valueOf(i)).start();
        }
 // 需要等待上面20个线程都全部计算完毕,看最终结果
        while (Thread.activeCount()>2){ // 默认一个 main线程  一个 gc 线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+" "+num);
    }
}

 思考
这段代码的结果为什么不是20000?

代码中,num++ 操作在多线程环境下并不是原子操作,因此会导致结果不符合预期。具体来说,num++ 实际上分为三个步骤:

  1. 读取:从主内存中读取 num 的值到工作内存。

  2. 修改:在工作内存中对 num 的值进行加 1 操作。

  3. 写入:将修改后的值写回主内存。

由于多个线程可能同时执行这些操作,且没有同步机制来保证操作的原子性,因此可能会出现以下情况:

  • 线程 A 读取 num 的值为 100。

  • 线程 B 也读取 num 的值为 100。

  • 线程 A 对 num 进行加 1 操作,得到 101,并写回主内存。

  • 线程 B 也对 num 进行加 1 操作,得到 101,并写回主内存。

最终,num 的值只增加了 1,而不是预期的 2。这种情况下,多个线程的并发操作导致了数据的不一致性。

解决方案

要解决这个问题,可以使用以下几种方法:

1. 使用 synchronized 关键字

通过 synchronized 关键字来保证 add() 方法的原子性:

public class JMMVolatileDemo02 {
    private static int num = 0;

    public synchronized static void add() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    add();
                }
            }, String.valueOf(i)).start();
        }

        while (Thread.activeCount() > 2) { // 默认一个 main线程  一个 gc 线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + " " + num);
    }
}

输出结果:20000

2. 使用 java.util.concurrent.atomic 包中的原子类

Java 提供了 AtomicInteger 等原子类,可以保证对 int 类型变量的原子操作:

import java.util.concurrent.atomic.AtomicInteger;

public class JMMVolatileDemo02 {
    private static AtomicInteger num = new AtomicInteger(0);

    public static void add() {
        num.incrementAndGet();
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    add();
                }
            }, String.valueOf(i)).start();
        }

        while (Thread.activeCount() > 2) { // 默认一个 main线程  一个 gc 线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + " " + num.get());
    }
}

输出结果:20000

 3. 如果使用 volatile 关键字呢?

public class JMMVolatileDemo02 {
    private volatile static int num = 0;
    public static void add(){
        num++;
    }
    // 结果应该是 num 为 2万,测试看结果
    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000; j++) {
                    add();
                }
            },String.valueOf(i)).start();
        }
         // 需要等待上面20个线程都全部计算完毕,看最终结果
        while (Thread.activeCount()>2){ // 默认一个 main线程  一个 gc 线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+" "+num);
    }
    
}

输出结果:不到20000的随机数

volatile 关键字可以保证变量的可见性,但不能保证复合操作的原子性。因此,volatile 不能解决 num++ 的原子性问题。

3.指令重排

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。

处理器在进行重排序时必须要考虑指令之间的数据依赖性。

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

重排理解测试1:

public class TestHappensBefore {
    public static void main(String[] args) {
        int x = 11;  // 语句1
        int y = 12;  // 语句2
        x = x + 5;   // 语句3
        y = x * x;   // 语句4
    }
    // 指令顺序预测:  1234   2134  1324
    // 问题:请问语句4可以重排后变成第一条吗? 答案:不可以
}

重排理解测试2:

案例:

// 多线程环境中线程交替执行,由于编译器优化重排的存在
// 两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
public class TestHappensBefore {
    int a = 0;
    boolean flag = false;
    public void m1(){
        a = 1;         // 语句1
        flag = true;   // 语句2
    }
    public void m2(){
        if (flag){
            a = a + 5; // 语句3
            System.out.println("m2=>"+a);
        }
    }
 }

指令重排小结:

volatile 实现了禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。

先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU 指令,它的作用有两个:

1、保证特定操作的执行顺序。

2、保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会告诉编译器 和CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说,通过插入内存屏障禁止 在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

经过,可见性,原子性,指令重排的话,线程安全性获得保证:

工作内存与主内存同步延迟现象导致的可见性问题,可以使用 synchronized 或 volatile 关键字解决,它 们都可以使一个线程修改后的变量立即对其他线程可见。

对于指令重排导致的可见性问题 和 有序性问题,可以利用 volatile 关键字解决,因为 volatile 的另外一 个作用就是禁止重排序优化。

4. 总结

Java 内存模型(JMM)定义了多线程环境下变量的访问规则,确保可见性、有序性和原子性。volatile 关键字是 JMM 中的一种轻量级同步机制,用于保证变量的可见性和有序性,但它不能保证原子性。在多线程编程中,合理使用 volatile 可以避免一些常见的并发问题,但在需要原子操作的场景中,仍需使用其他同步机制。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值