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 barriers2nd operation
1st operationNormal LoadNormal StoreVolatile LoadVolatile Store
Normal Load   LoadStore
Normal Store   StoreStore
Volatile LoadLoadLoadLoadStoreLoadLoadLoadStore
Volatile Store  StoreLoadStoreStore

我们来分析一下代码

线程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修饰,不然产生指令重排,会造成空对象的行为。后面我会科普这个玩意。

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值