java架构之路(多线程)JMM和volatile关键字(二)

  貌似两个多月没写博客,不知道年前这段时间都去忙了什么。

  好久以前写过一次和volatile相关的博客,感觉没写的那么深入吧,这次我们继续说我们的volatile关键字。

复习:

  先来简单的复习一遍以前写过的东西,上次我们说了内存一致性协议M(修改)E(独占)S(共享)I(失效)四种状态,还有我们并发编程的三大特性原子性、一致性和可见性。再就是简单的提到了我们的volatile关键字,他可以保证我们的可见性,也就是说被volatile关键字修饰的变量如果产生了变化,可以马上刷到主存当中去。我们接下来看一下我们这次博客的内容吧。

线程:

  何为线程呢?这也是我们面试当中经常问到的。按照官方的说法是:现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序,操作 系统就会创建一个Java进程。现代操作系统调度CPU的最小单元是线程。比如我们启动QQ,就是我们启动了一个进程,我们发起了QQ语音,这个动作就是一个线程。

  在这里多提一句的就是线程分为内核级线程和用户级线程,我们在java虚拟机内的线程一般都为用户级线程,也就是由我们的jvm虚拟机来调用我们的CPU来申请时间片来完成我们的线程操作的。而我们的内核级线程是由我们的系统来调度CPU来完成的,为了保证安全性,一般的线程都是由虚拟机来控制的。

  用户线程:指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。不需要用户态/核心态切换,速度快。操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。
  内核线程: 线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但是仍然比进程的创建和管理操作要快。大多数市场上的操作系统,如Windows,Linux等都支持内核级线程。
  用户级线程就是我们常说的ULT,内核级线程就是我们说的KLT。线程从用户态切换到内核态时会消耗很大的性能和时间,后面说sychronized锁的膨胀升级会说到这个过程。

上下文切换:

  上面我们说过,线程是由我们的虚拟机去CPU来申请时间片来完成我们的操作的,但是不一定马上执行完成,这时就产生了上下文切换。大致就是这样的:

  线程A没有运行完成,但是时间片已经结束了,我们需要挂起我们的线程A,CPU该去执行线程B了,运行完线程B,才能继续运行我们的线程A,这时就涉及到一个上下文的切换,我们把这个暂时挂起到再次运行的过程,可以理解为上下文切换(最简单的理解方式)。

可见性:

   用volatile关键字修饰过的变量,可以保证可见性,也就是volatile变量被修改了,会立即刷到主内存内,让其他线程感知到变量已经修改,我们来看一个事例

public class VolatileVisibilitySample {
    private volatile boolean initFlag = false;

    public void refresh(){
        this.initFlag = true;
        String threadname = Thread.currentThread().getName();
        System.out.println("线程:"+threadname+":修改共享变量initFlag");
    }

    public void load(){
        String threadname = Thread.currentThread().getName();
        int i = 0;
        while (!initFlag){

        }
        System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变"+i);
    }

    public static void main(String[] args){
        VolatileVisibilitySample sample = new VolatileVisibilitySample();
        Thread threadA = new Thread(()->{
            sample.refresh();
        },"threadA");

        Thread threadB = new Thread(()->{
            sample.load();
        },"threadB");

        threadB.start();
        try {
             Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }

}

我们想创建一个全局的由volatile修饰的boolean变量,refresh方法是修改我们的全局变量,load方法是无限循环去检查我们全局volatile修饰过的变量,我们开启两个线程,开始运行,我们会看到如下结果。

 也就是说,我们的变量被修改以后,我们的另外一个线程会感知到我们的变量已经发生了改变,也就是我们的可行性,立即刷回主内存。

有序性:

  说到有序性,不得不提到几个知识点,指令重排,as-if-serial语义和happens-before 原则。

  指令重排:java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

  指令重排一般发生在class翻译为字节码文件和字节码文件被CPU执行这两个阶段。

  as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因 为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被 编译器和处理器重排序。

  happens-before 原则内容如下

  1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

  2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

  3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

  4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见

  5. 传递性A先于B ,B先于C,那么A必然先于C

  6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

  7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

  8. 对象终结规则 对象的构造函数执行,结束先于finalize()方法。

  上一段代码看看指令重排的问题。

public class VolatileReOrderSample {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;

        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread t1 = new Thread(new Runnable() {
                public void run() {
                    a = 1;
                    x = b;
                }
            });
            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }

    }
}

我们来分析一下上面的代码

情况1:假设我们的线程1开始执行,线程2还没开始,这时a = 1 ,x = b = 0,因为b的初始值是0,然后开始执行线程2,b = 1,y = a = 1,得到结论x = 0 ,y = 1.

情况2:假设线程1开始执行,将a赋值为1,开始执行线程2,b赋值为1,并且y = a = 1,这时继续运行线程1,x = b = 1,得到结论 x = 1,y = 1.

情况3:线程2优先执行,这时b = 1,y = a = 0,然后运行线程1,a = 1,x = b = 1,得到结论 x = 1,y = 0。

不管怎么谁先谁后,我们都是只有这三种答案,不会产生x = 0且y = 0的情况,我们在下面写出来了x = 0 且 y = 0 跳出循环。我们来测试一下。

运行到第72874次结果了0,0的情况产生了,也就是说,我们t1中的a = 1;x = b;和t2中的b = 1;y = a;代码发生了改变,只有变为

Thread t1 = new Thread(new Runnable() {
    public void run() {
        
        x = b;
        a = 1;
    }
});
Thread t2 = new Thread(new Runnable() {
    public void run() {
        
        y = a;
        b = 1;
    }
});

这种情况才可以产生0,0的情况,我们可以把代码改为

private static volatile int a = 0, b = 0;

继续来测试,我们发现无论我们运行多久都不会发生我们的指令重排现象,也就是说我们volatile关键字可以保证我们的有序性

至少我这里570万次还没有发生0,0的情况。

就是我上次博客给予的表格

Required barriers 2nd operation
1st operation Normal Load Normal Store Volatile Load Volatile Store
Normal Load       LoadStore
Normal Store       StoreStore
Volatile Load LoadLoad LoadStore LoadLoad LoadStore
Volatile Store     StoreLoad StoreStore

我们来分析一下代码

线程1的。

public void run() {
    a = 1;
    x = b;
}

  a = 1;是将a这个变量赋值为1,因为a被volatile修饰过了,我们成为volatile写,就是对应表格的Volatile Store,接下来我们来看第二步,x = b,字面意思是将b的值赋值给x,但是这步操作不是一个原子操作,其中包含了两个步骤,先取得变量b,被volatile修饰过,就成为volatile load,然后将b的值赋给x,x没有被volatile修饰,成为普通写。也就是说,这两行代码做了三个动作,分别是Volatile Store,volatile load和Store写读写,查表格我们看到volatile修饰的变量Volatile Store,volatile load之间是给予了StoreLoad这样的屏障,是不允许指令重排的,所以达到了有序性的目的。

扩展:

  我们再来看一个方法,不用volatile修饰也可以防止指令重排,因为上面我们说过,volatile可以保证有序性,就是增加内存屏障,防止了指令重排,我们可以采用手动加屏障的方式也可以阻止指令重排。我们来看一下事例。

public class VolatileReOrderSample {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;

        for (;;){
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread t1 = new Thread(new Runnable() {
                public void run() {
                    a = 1;
                    UnsafeInstance.reflectGetUnsafe().storeFence();
                    x = b;
                }
            });
            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    UnsafeInstance.reflectGetUnsafe().storeFence();
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }

    }

}

storeFence就是一个有java底层来提供的内存屏障,有兴趣的可以自己去看一下unsafe类,一共有三个屏障 

UnsafeInstance.reflectGetUnsafe().storeFence();//写屏障
UnsafeInstance.reflectGetUnsafe().loadFence();//读屏障
UnsafeInstance.reflectGetUnsafe().fullFence();//读写屏障

通过unsafe的反射来调用,涉及安全问题,jvm是不允许直接调用的。手写单例模式时在超高并发记得加volatile修饰,不然产生指令重排,会造成空对象的行为。后面我会科普这个玩意。

最进弄了一个公众号,小菜技术,欢迎大家的加入

posted @ 2020-01-13 17:44  小菜技术  阅读(423)  评论(0编辑  收藏  举报