JUC-2

38

6、LockSupport和线程中断

6.1、线程中断机制

​ 什么是线程中断?我们的第一反应应该就是让线程不要在运行了,而能够使用到的方法有Thread.stop, Thread.suspend, Thread.resume,但可以看到这几个方法都已经被打上废弃的注解了,说明大佬们已经不建议我们使用这些方法来中断线程的运行,因为这样强行的中断可能会造成资源不被释放,数据不一致,线程不安全等问题。这样显然是不够优雅的,那我们如何才能优雅的中断线程呢?那当然是线程自己来控制了。

​ 若要中断一个线程,你需要手动调用该线程的interrupt方法,该方法也仅仅是将线程对象的中断标识设成true;接着你需要自己写代码不断地检测当前线程的标识位,如果为true,表示别的线程要求这条线程中断,

此时究竟该做什么需要你自己写代码实现。

每个线程对象中都有一个标识,用于表示线程是否被中断;该标识位为true表示中断,为false表示未中断;

通过调用线程对象的interrupt方法将该线程的标识位设为true;可以在别的线程中调用,也可以在自己的线程中调用

线程中断相关方法

MethodDescription
public void interrupt()实例方法, 实例方法interrupt()仅仅是设置线程的中断状态为true,不会停止线程
public static boolean interrupted()静态方法,Thread.interrupted(); 判断线程是否被中断,并清除当前中断状态 这个方法做了两件事: 1 返回当前线程的中断状态 2 将当前线程的中断状态设为false
public boolean isInterrupted()实例方法, 判断当前线程是否被中断(通过检查中断标志位)

中断线程的几种方式

1、通过volatile关键字来实现

public class VolatileInterruptDemo {

    private static volatile boolean flag = true;

    public static void main(String[] args) throws InterruptedException {

        CountDownLatch countDownLatch = new CountDownLatch(1);
        new Thread(() -> {
            while (flag) {
                System.out.println("我正在干活。。。");
            }
            System.out.println(Thread.currentThread().getName() + ": 啊,我被打断了,溜了溜了");
            countDownLatch.countDown();
        }, "t1").start();

        try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }

        flag = false;

        countDownLatch.await();
        System.out.println(Thread.currentThread().getName() + ": 其它线程也结束了,我也没啥事了,溜了溜了");
    }
}

2、通过AtomicXXX来控制

public class AtomXXXInterruptDemo {
    private static final AtomicBoolean flag = new AtomicBoolean(true);

    public static void main(String[] args) {
        new Thread(() -> {
            while (flag.get()) {
                System.out.println(Thread.currentThread().getName() + ": 干活,干活...");
            }
            System.out.println(Thread.currentThread().getName() + ": 结束干活...");
        }, "t2").start();

        try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }

        flag.set(false);

        System.out.println(Thread.currentThread().getName() + ": 没人干活了,我也不干了...");
    }
}

3、通过线程的interrupt方法来中断

public class ThreadInterruptDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (!Thread.interrupted()) {
                System.out.println(Thread.currentThread().getName() + ": 在干活,勿扰...");
            }
            System.out.println(Thread.currentThread().getName() + ": 哎哟你干嘛,不干了,你好烦!🏀");
        }, "t1");
        t1.start();


        try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }

        t1.interrupt();

        try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }

        System.out.println(Thread.currentThread().getName() + ": 俺干了一秒不干了也溜了...");
    }

‼️线程的中断状态为true,是否线程就会立即停止呢?

​ 答案是NO,NO,NO!!!调用interrupt只是给了一个中断标识,不是中断线程,如果不针对该标志做任何响应处理,线程该继续运行还是继续运行。

​ 如果线程正处于sleep,wait,join等状态,在其它线程中调用interrupt中断该线程那么该线程会立即退出阻塞状态并抛出interruptedException的异常。

只打了标识,并不会影响线程的运行

public class InterruptDemo {

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 300; i++) {
                System.out.println("i: " + i);
            }
            System.out.println("current thread interrupt status: " + Thread.currentThread().isInterrupted());
        }, "t1");

        t1.start();

        System.out.println("before t1 interrupt, current t1 interrupt status: " + t1.isInterrupted());

        t1.interrupt();

        try { Thread.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }

        System.out.println("after t1 interrupt, current t1 interrupt status: " + t1.isInterrupted());

        try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); }

        System.out.println("after t1 shutdown, current t1 interrupt status: " + t1.isInterrupted());
    }
}

**⚠️ sleep(wait, join等)状态下被打断会清除中断标识 **

public class InterruptDemo2 {

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (true) {
                if (Thread.currentThread().isInterrupted()) {
                    System.out.println("中止了");
                    break;
                }
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    // if any thread has interrupted the current thread. The interrupted status of the current thread is cleared when this exception is thrown.
                    // ⚠️ 休眠状态下被interrupt,那么中断标识将会被清除此时会导致线程不会停止,所以抛出异常时需要再进行一次打断
                    Thread.currentThread().interrupt();
                    e.printStackTrace();
                }

            }
        }, "t1");

        t1.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t1.interrupt();
    }
}

线程中断方法小总结:

  • interrupt()方法是一个实例方法

​ 它通知目标线程中断,也就是设置目标线程的中断标志位为true,中断标志位表示当前线程已经被中断了。

  • isInterrupted()方法也是一个实例方法

​ 它判断当前线程是否被中断(通过检查中断标志位)并获取中断标志。

  • Thread类的静态方法interrupted()

​ 返回当前线程的中断状态(boolean类型)且将当前线程的中断状态设为false,此方法调用之后会清除当前线程 的中断标志位的状态(将中断标志置为false了),返回当前值并清零置false

6.2、LockSupport

提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具。

image-20230323134403361

​ 在Java 6中,LockSupport增加了park(Object blocker)、parkNanos(Object blocker,long nanos) 和parkUntil(Object blocker,long deadline)3个方法,用于实现阻塞当前线程的功能,其中参数 blocker是用来标识当前线程在等待的对象(以下称为阻塞对象),该对象主要用于问题排查和系统监控。

6.3 、线程的等待唤醒机制

  1. 通过Object类中的wait()和notify()来实现

    public class ObjectWaitAndNotifyDemo {
    
        public static void main(String[] args) {
            Object lock = new Object();
    
            new Thread(() -> {
                synchronized (lock) {
                    try {
                        System.out.println(Thread.currentThread().getName() + ": 我进入了等待,好烦");
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
    
                    System.out.println(Thread.currentThread().getName() + ": 等待结束,溜了溜了");
                }
    
            }, "t1").start();
    
            try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
    
            new Thread(() -> {
                synchronized (lock) {
                    // 唤醒
                    lock.notify();
                    System.out.println(Thread.currentThread().getName() + ": 我来叫那龟儿不要等了,没结果的");
                }
            }, "t2").start();
        }
    }
    
  2. 通过Condition的await和signal来让线程进入等待和唤醒线程

    import java.util.concurrent.locks.Condition;
    import java.util.concurrent.locks.ReentrantLock;
    
    /**
     * @Description: 通过Condition的await和signal来让线程进入等待和唤醒线程
     * @Author: xuhao
     * @Date: 2023/3/23 14:08
     */
    public class ConditionAwaitSignalDemo {
    
        public static void main(String[] args) {
            ReentrantLock lock = new ReentrantLock();
            Condition condition = lock.newCondition();
    
            new Thread(() -> {
                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + ": 我准备干事(睡觉)了");
                    condition.await();
                    System.out.println(Thread.currentThread().getName() + ": 哎呦你干嘛,你好烦🐔🏀。。。");
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }, "t1").start();
    
            try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
    
            new Thread(() -> {
                lock.lock();
    
                try {
                    System.out.println(Thread.currentThread().getName() + ": 大白天的睡觉,我要叫醒那小子去打篮球");
                    condition.signal();
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
    
            }, "t2").start();
        }
    }
    
    
  3. 通过LockSupport的park()和unpark()

    public class LockSupportParkUnparkDemo {
    
        public static void main(String[] args) {
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < 3; i++) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ": 又是搬砖的一天,干了" + (i + 1) + "秒了💦");
                }
                System.out.println(Thread.currentThread().getName() + ": 好累,准备睡觉");
                LockSupport.park();
            }, "t1");
            t1.start();
    
            try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); }
    
            System.out.println(Thread.currentThread().getName() + ": 想睡觉,没门,继续起来干活");
            LockSupport.unpark(t1);
        }
    }
    

小总结(注意点⚠️):

1、Object的wait和notify方法:

​ 可以使用上方的第一种例子进行测试,如果将wait或这notify不在同步代码块中使用会抛出IllegalMonitorStateException,因为当前线程未获取到监视器锁所以自然就无法释放监视器锁。

image-20230323151242048

​ 第二个需要注意的点就是notify不能先于wait,是无法唤醒线程的,会一直阻塞。因为notify是唤醒等待列表的一个等待的线程,如果先于wait那么列表中没有线程再等待那么将没有任何效果,过后的wait又重新进入等待就会导致一直阻塞。

2、Condition也是要再Lock锁块之内,可以从下图看出获得Condition然后调用await和signal都需要获得锁,也就是必须进入Lock.lock()。

image-20230323153308773

​ 其次Condition的await、signal 和 Object的 wait、notify一样,要保证调用的先后顺序,否则无法正确唤醒。

3、LockSupport的park、unpark:

image-20230323154252554

LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。

LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能, 每个线程都有一个许可(permit),permit只有两个值1和零,默认是零。可以把许可看成是一种(0,1)信号量(Semaphore),但与 Semaphore 不同的是,许可的累加上限是1。

LockSupport在JDK1.6引入了可以传入阻塞对象的park(Object blocker) ,方便问题排查和 系统监控。

LockSupport支持唤醒代码写在等待之前,但是park和unpark要一一匹配,要成对,不能说少了某一个

7、JMM(Java Memory Model)

引出JMM,为什么需要JMM

​ 因为有这么多级的缓存(cpu和物理主内存的速度不一致的),CPU的运行并不是直接操作内存而是先把内存里边的数据读到缓存,而内存的读和写操作的时候就会造成不一致的问题。

image-20230323164851137

Java虚拟机规范中试图定义一种Java内存模型(java Memory Model,简称JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

JMM的概述

​ JMM(Java内存模型)是一种抽象的概念,并不真实存在,它描述的是一组约定或规范。通过这组规范定义的程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性展开的

7.1、JMM规范的三大特性

1、可见性

​ Java中普通的共享变量不保证可见性,因为数据被修改并写回主内存的时机是不确定的,多线程并发下可能会发生‘脏读’,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存拷贝的副本。对变量的操作(读取、赋值等)都发生在线程自身的工作内存之中,而不能直接操作住内存中变量,不同线程之间也不能访问其它线程的工内存中的变量,线程之间的变量值传递都需要通过主内存来完成。

image-20230323175829967

​ 如果不能保证可见性那么就可能会发生脏读,如下面的例子:

​ 线程B已经将修改后的变量A得结果刷回主内存中了,但是此时线程A工作内存中的A的副本的值还是1,这就是脏数据了。

image-20230324103601849

2、有序性

​ 有序性是指线程中指令执行的顺序和程序代码中书写的顺序一致。如果程序的执行顺序和代码中书写的顺序一致,那么程序的执行结果就是可预测的。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序。指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读",简单说:

两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。

image-20230324170541069

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

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

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

3、原子性

​ 一个操作要么全部执行成功,要么就完全不执行,不存在中间状态,多线程环境下,操作不能被其它线程打断或干扰。

JMM规范下多线程对变量的读取流程:

​ 由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到的线程自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,简要的访问过程如下:

image-20230324112510764

JMM定义了线程和主内存之间的抽象关系

1、线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)

2、每个线程都有一个私有的本地工作内存,本地工作内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)

7.2、JMM之happens-before原则

​ 在JMM中,如果一个操作A的执行结果需要对另一个操作B可见,那么这两个操作之间必须要存在happens-before原则。又称为A操作先行发生于B。两个操作可能在同一个线程内也可以不在同一个线程。

7.2.1、happens-before的规则

  • 程序顺序规则:一个线程中的每个操作,happens-before该线程中的后续任意操作,意思就是前一个操作的结果一定对后一个操作可见。

  • 监视器锁规则:一个对象的解锁操作,happens-before后续对该对象的加锁操作。

  • volatile变量规则:一个volatile域的写操作,happens-before与后续任意一个对该volatile域的读操作。

  • 传递性规则:A happens-before B, B happens-before C,那么A happens-before C。

  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。

  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。

  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。

  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。

happens-before总原则:

​ 如果一个操作A的执行结果需要对另一个操作B可见,那么操作A happens-before于操作B,也就是A操作执行顺序排在B操作之前。

​ 两个操作之间存在happens-before原则,并不意味着一个操作必须要在后一个操作之前执行(这里我的理解这个之前是指的时间上的之前,也就是操作A执行完立马是B),happens-before仅要求前一个操作(操作结果)对后一个操作可见,且前一个操作的执行顺序在后一个操作之前即可。

7.3、重排序

​ 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

7.3.1、数据依赖性

​ 如果两个操作访问同一个变量,且这两个操作中有一个为操作,此时这两个操作之间就存在数据依赖性。

image-20230327105441585

表中的任意一种操作,只要执行顺序发生了重排,执行的结果就会改变。

​ 编译器和处理器在执行重排时会遵循数据依赖性,不会改变两个存在数据依赖关系的操作的执行的顺序。这里所说的遵循依赖性是指单个线程和单个处理器内。

7.3.2、as-if-serial语义

​ as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

8、volatile与JMM

8.1、volatile的特点

​ 可见性:对一个volatile修饰的变量读,总是能看到任意线程对该变量最后的写入。

​ 原子性:对单个volatile修饰的变量进行读和写具有原子性,但对于复合操作如++i, i++不具备原子性。

​ 有序性:指令禁重排

8.2、volatile的内存语义

​ 写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新回主内存。

​ 读:当读一个volatile变量时,JMM会把该线程本地内存中的共享变量值作废,并重新从主内存读取。

8.3、volatile能够保证可见性和有序性的原因

​ volatile是通过内存屏障来保证可见性和有序的。

​ 内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。

​ 内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。

8.3.1 JVM提供的4中内存屏障

image-20230327161536547

而JVM内存屏障的原理(以linux x86平台下的JVM实现为例):

​ 1、可以在魔法类Unsafe下找到增加内存屏障的native方法。

image-20230327162423177

​ 2、而native方法的实现又在JVM的源码之中

image-20230327163046284

image-20230327163157301

​ orderAccess_linux_x86.inline.hpp:

orderAccess_linux_x86.inline.hpp

8.3.2、happens-before之volatile变量规则

第一个操作第二个操作(普通读/写)第二个操作(volatile读)第二个操作(volatile写)
普通读/写可重排可重排不可重排
volatile读不可重排不可重排不可重排
volatile写可重排不可重排不可重排

小总结:

​ 第一个操作为volatile读,无论第二个操作是什么都不可以发生重排。

​ 第二个操作为volatile写,无论第一个操作是什么都不可以发生重排。

​ 第一个操作为volatile写,第二个操作为volatile读,不可以发生重排。

8.3.3 、volatile内存屏障插入策略

  • volatile写:

    • 之前插入StoreStore屏障
    • 之后插入StoreLoad屏障
  • volatile读:

    • 之后插入LoadLoad屏障
    • 之后插入LoadStore屏障

8.4、volatile变量的读写过程

image-20230328173226974

  1. lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  2. read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用。
  3. load(载入):作用于工作内存的变量,把read操作传输到工作内存中的变量放入到线程的工作内存中。
  4. use(使用):作用于工作内存的变量,把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用该变量的指令时都会执行该操作。
  5. assign(赋值):作用于工作内存的变量,把一个从执行引擎中接收到的值赋给工作内存中的变量。
  6. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中。
  7. write(写入):作用于主内存的变量,把store操作传输到主内存中的变量放入到主内存中。
  8. unlock(解锁):作用于主内存的变量,把处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

8.5、volatile为什么不保证原子性

​ 通过上面的volatile变量的读写过程可以看出, read-load-use 和 assign-store-write 成为了两个不可分割的原子操作,use和assign之间是不受lock和unlock控制的,是由CPU来进行操作的,所以这期间存在一个空隙,有可能变量会被其他线程读取,导致写丢失一次。

特殊情况:

  • 运算结果并不依赖变量的当前值(结果对产生中间结果不依赖),或确保只有单个线程修改变量的值。
  • 变量不和其它的状态变量共同参与不变约束。

8.6、volatile禁重排

重排序

​ 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序。

​ 不存在数据依赖关系,可以重排序;

​ 存在数据依赖关系,禁止重排序;

但重排后的指令绝对不能改变原有的串行语义!

重排序的分类和执行流程

  • 编译器优化重排序:编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序

  • 指令集并行重排序:处理器使用指令级并行技术来将多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  • 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行

8.7、多线程环境下DCL(Double Check Lock)的问题及解决办法

public class DCLDemo {
    // 此处变量应该加volatile增加多线程之间的可见性、同时禁止指令重排,防止obj对象新建分配内存后还没进行初始化就被使用
    private static Object obj = null;

    public static Object getObj() {
        if (obj == null) {
            synchronized (DCLDemo.class) {
                if (obj == null) {
                    // 分为3步
                    // 1、分配新建对象的内存空间
                    // 2、初始化对象
                    // 3、把新建对象的引用赋值给obj
                    obj = new Object();
                }
            }
        }
        return obj;
    }
}  

如代码中注释所示,obj = new Object()分为3步。

多线程环境下,可能发生重排序导致2,3乱序,后果就是其他线程得到的是null而不是完成初始化的对象。

image-20230330112322118

解决办法

1、使用volatile修饰obj对象,保证多线程下的可见性,且禁止obj在创建过程中的指令重排。

2、使用私有静态内部类来构造obj对象。(推荐用)

public class InnerclzSafeSingleton {

    private static class SafeObj {
        private static Object INSTANCE = new Object();
    }
    
    public static Object getObj() {
        return SafeObj.INSTANCE;
    }

    public static void main(String[] args) {
        Object instance = InnerclzSafeSingleton.getObj();
        Object instance1 = InnerclzSafeSingleton.getObj();
        // always true
        // instance == instance1;
    }
}

8.8、总结

8.8.1、内存屏障是啥

​ 是一个屏障指令,使得CPU或者编译器对屏障指令前和后所发出的对内存操作 执行一个排序的约束。

内存屏障的具体作用

1、禁止屏障两边的操作重排序

2、执行写操作时,强制将线程工作内存中的变量值刷回主内存

3、执行读操作时,作废工作内存中缓存的变量,并重新从主内存中读取最新数据

四大屏障指令

  1. 写前StoreStore

    image-20230403092236423

  2. 写后StoreLoad(有其它三种屏障的效果)

    image-20230403092304557

  3. 读后LoadLoad

    image-20230403092413264

  4. 读后LoadStore

    image-20230403092455896

为什么申明一个变量为volatile会添加内存屏障

image-20230403095209589

通过字节码可以很直观的看到在class的fields中status变量的访问标志是ACC_VOLATILE,JVM将字节码生成机器码时就会将内存屏障插入其中。

常见X86操作系统实现内存屏障的原理

​ 常用的X86(包括AMD64)系列的底层提供了如下几条添加内存屏障的机器指令:

1、lfence :用于保证前面的读操作完成之后再进行后续的读写操作,从而避免出现乱序读取的情况

2、sfence:用于保证前面的写操作完成之后再进行后续的读写操作,从而避免写入缓存但尚未刷新到内存的情况

3、mfence:用于保证前面的所有读写操作完成之后再执行后面的读写操作,从而控制内存访问的顺序

X86处理器不会对读-读读-写写-写操作做重排序,会省略掉这3种操作类型对应的内存屏障,仅会对写-读操作做重排序。所以volatile写-读操作只需要在volatile写后插入StoreLoad屏障。The JSR-133 Cookbook (oswego.edu)

image-20230403113449253

在x86处理器中,有三种方法可以实现实现StoreLoad屏障的效果,分别为:

  • mfence指令:上文提到过,能实现全能型屏障,具备lfence和sfence的能力。
  • cpuid指令:cpuid操作码是一个面向x86架构的处理器补充指令,它的名称派生自CPU识别,作用是允许软件发现处理器的详细信息。
  • lock指令前缀:总线锁。lock前缀只能加在一些特殊的指令前面。

​ 实际上HotSpot关于volatile的实现就是使用的lock指令,只在volatile标记的地方加上带lock前缀指令操作,并没有参照JMM规范的屏障设计而使用对应的mfence指令。

加上-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp JVM参数再次执行main方法,可得到汇编码,从中可以看出其中也有lock addl $0x0,(%rsp)

image-20230403154818564

addl $0x0,(%rsp)其实是一个空操作。add是加的意思,0x0是16进制的0,rsp是x86架构中的一个寄存器,它代表栈指针(Stack Pointer)。合起来就是把寄存器的值加0,加0是不是等于什么都没有做?这段汇编码仅仅是lock指令的一个载体而已。lock前缀只能加在一些特殊的指令前面,add就是其中一个指令。

​ X86系列的操作系统的lock指令的特性:

  1. 确保指令执行的原子性:使用lock前缀能够确保带有该前缀的指令在执行过程中不会被中断,从而确保了指令的原子性。
  2. 防止编译器优化:编译器可能会对指令进行重排序或者省略,但是使用lock前缀可以防止编译器对这些指令进行优化,确保它们按照程序员的意图执行。
  3. 实现互斥访问:lock前缀可以将一个指令或一组指令置于临界区域,防止其他线程同时访问这部分共享内存,从而避免出现竞态条件问题。

⚠️⚠️在实现内存屏障(Memory Barrier)的效果时,lock指令可以与其他机制结合使用,从而确保原子操作和可见性。

​ 具体来说,当使用lock前缀进行原子操作时,CPU会自动锁住总线,并防止其他CPU同时访问共享内存,这可以确保这些指令在执行过程中不会被中断,从而确保了指令的原子性。而在解锁后,CPU会将缓存中的数据立即刷新到主内存中,从而确保了可见性。