并发编程之JMM模型&Volatile底层原理

并发编程的本质

并发编程为什么要有?为什么我们要做并发编程,并发编程是实质是什么?我觉得并发编程是一种艺术,在现在的时代,硬件的性能在不断的提升,以前的单核CPU现在已经发展成为了多核CPU,性能已经不断提升,如果我们还保留着以前的单线程程序的话,那么只会出现硬件性能过剩的情况;我们来思考一个问题并发编程的本质是解决什么问题?并发编程在一定的意义上其实就是多线程编程,那么多线程编程中主要有哪些,多线程编程中会涉及到同步操作,线程互斥操作,大的任务进行拆解,并发处理;所以并发编程的实质就是为了解决我们的业务需求,从而尽可能的压榨CPU的性能,达到性能的最大化;可以说在现在的大多数应用程序中,多线程的身影无处不在,单线程的应用已经成为过去时,如果非要说多线程的应用场景是什么,我可以说多线程的应用场景无处不在,适用于任何的场景下。

JMM模型

JMM模型就是JAVA的内存模型,但是这里我更喜欢叫JAVA的线程内存模型,我们都知道我们的JVM的内存模型中有堆,元空间还有虚拟机栈,虚拟机栈由执行引擎执行时会在虚拟机栈中创建栈桢来保存我们的变量信息和操作信息,所以JMM模型和虚拟机就有很大的关系,当我们创建一个对象时,比如new User()和 new Thread()这两者有什么区别呢?相同点是都时在堆区创建一个java对象,而我们都知道Thread是java的线程对象,通过它可以启动一个线程去执行我们的任务,而User只是一个普通的java对象,那么他们两者有什么区别呢?当我们创建一个线程Thread,其实不是JAVA开辟了一个线程,Thread底层启动线程和线程的操作是调用的native本地方法的,所以其实创建线程是交给了内核去做的,JVM本身是不具备调度CPU的权限的 ,是由内核去调度CPU的,比如看下图:
在这里插入图片描述
说白了就是JVM不具备调用CPU的权限,是交给了操作系统去执行的,实现从用户态向内核态的转变。我们都知道JAVA应用程序可以在windows、linux、unix等操作系统上运行,但是我们编写的多线程程序只有一份,也没有在程序中区分操作系统而执行不同的代码块,所以java应用程序创建线程交给操作系统内核的时候这个时候是屏蔽了不同操作系统的差异的,我们可以把它抽象出来,抽象出来就是我们不管操作系统的类型,我写的这个多线程在不同的操作系统上都能执行,并且都能执行成功而达到预期。

并发编程带来的风险

并发编程是好,并发编程会带来性能的提升,但是它就真的好,无脑使用吗?不是的,并发编程还是要根据实际的业务场景和熟练的程度来使用,否则就会带来一定的风险,那么会带来哪些风险?1.性能的问题:就是说我们的业务场景是否需要启动多线程来执行我们的这些任务,这个要根据数据量和处理的任务复杂度来考虑,比如有个需求可以用单线程执行的,你非要使用多线程来执行,那么性能不一定会提升,反而会降低系统性能,为什么呢?因为多线程是要考虑到线程间的上下文切换带来的性能损耗的;是不是系统出现了一些问题,会有人经常给你说,这段程序操作的原因是因为多线程的上下文切换带来的性能开销导致了一些问题;而单线程是一直占用cpu的执行权限的,它可以一直执行,直到执行完成,所有是否采用多线程还是要根据要处理的任务的复杂度以及数据量的大小,多线程在这种情况下就不一定会达到性能提升的效果,我们来看下多线程的上下文切换的图:
在这里插入图片描述
所以要根据实际的情况考虑是否采用多线程,多线程是好,可以尽可能的压榨CPU的性能,但是多线程会有线程的上下文切换,所以这个要考虑到性能的开销来觉得是否采用多线程。2.线程的活跃性问题:线程的活跃性问题有饥饿,死锁和活锁 饥饿:就是在多线程的环境下,比如有些线程被调了线程的优先级非常低,那么有可能这个线程永远都不会被调度,永远处于饥饿的状态,类似于死锁一样,永远不会被调度;所以这种情况下,要特别注意这种情况的发生,也就是说线程的优先级是可以影响到线程获取CPU执行周期,一个线程启动了永远得不到CPU的执行时间周期,那么就会被活活饿死。死锁:死锁这个比较好理解,就是Thrad1锁了a对象,依赖b对象,而Thrad2锁了b对象,依赖a对象,那么就会出现相互依赖,永远无法释放对象锁,就会出现死锁

public class T0915 {

    final static Object a = new Object();
    final static Object b = new Object();

    public static void main(String[] args) throws InterruptedException {
       new Thread(()->{
           System.out.println(Thread.currentThread().getName()+"开始执行...");
           synchronized (a){
               System.out.println(Thread.currentThread().getName()+" 获得a对象执行...");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               synchronized (b){
                System.out.println(Thread.currentThread().getName()+" 依赖b对象执行...");

            }
           }
       },"Thread1").start();


        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"开始执行...");
            synchronized (b){
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+" 获得b对象执行...");
                synchronized (a){
                    System.out.println(Thread.currentThread().getName()+" 依赖a对象执行...");

                }
            }
        },"Thread2").start();
    }
}

活锁:活锁是什么意思呢?活锁就是说你获取了锁没有意义,就是说你获取了锁没有干正事儿,比如两个线程,第一个线程获取了锁,发现需要让步给其他锁,其他锁获取了锁,也让给了其他锁,这样一来一往,其实大家都灭有干正事儿就是活锁


public class LiveLockTest {
    
    

    /**
     * 定义一个勺子,ower 表示这个勺子的拥有者
     */
    static class Spoon {
    
    
        Diner owner;

        public Spoon(Diner diner) {
    
    
            this.owner = diner;
        }

        public String getOwnerName() {
    
    
            return owner.getName();
        }

        public void setOwner(Diner diner) {
    
    
            this.owner = diner;
        }

        //表示正在用餐
        public void use() {
    
    
            System.out.println(owner.getName() + " 用这个勺子吃饭.");
        }
    }

    /**
     * 定义一个晚餐类
     */
    static class Diner {
    
    

        private boolean isHungry;
        //用餐者的名字
        private String name;

        public Diner(boolean isHungry, String name) {
    
    
            this.isHungry = isHungry;
            this.name = name;
        }

        //和某人吃饭
        public void eatWith(Diner diner, Spoon sharedSpoon) {
    
    
            try {
    
    
                synchronized (sharedSpoon) {
    
    
                    while (isHungry) {
    
    
                        //当前用餐者和勺子拥有者不是同一个人,则进行等待
                        while (!sharedSpoon.getOwnerName().equals(name)) {
    
    
                            sharedSpoon.wait();
                        }
                        if (diner.isHungry()) {
    
    
                            System.out.println(diner.getName()
                                    + " 饿了," + name + "把勺子给他.");
                            sharedSpoon.setOwner(diner);
                            sharedSpoon.notifyAll();
                        } else {
    
    
                            //用餐
                            sharedSpoon.use();
                            sharedSpoon.setOwner(diner);
                            isHungry = false;
                        }
                        Thread.sleep(500);
                    }
                }
            } catch (InterruptedException e) {
    
    
                System.out.println(name + " is interrupted.");
            }
        }

        public boolean isHungry() {
    
    
            return isHungry;
        }

        public void setHungry(boolean hungry) {
    
    
            isHungry = hungry;
        }

        public String getName() {
    
    
            return name;
        }

        public void setName(String name) {
    
    
            this.name = name;
        }
    }

    public static void main(String[] args) {
    
    
        final Diner ant = new Diner(true, "ant");
        final Diner monkey = new Diner(true, "monkey");
        final Spoon sharedSpoon = new Spoon(monkey);

        Thread h = new Thread(()->ant.eatWith(monkey, sharedSpoon));
        h.start();


        Thread w = new Thread(()->monkey.eatWith(ant, sharedSpoon));
        w.start();

//        try {
    
    
//            Thread.sleep(10000);
//        } catch (InterruptedException e) {
    
    
//            e.printStackTrace();
//        }
        //   h.interrupt();
        //   w.interrupt();

    }
}

3.线程的数据安全问题:线程数据安全问题就是比较常见而且也是多线程编程的一个老话题了,针对线程安全问题,一般通过锁来控制线程的数据安全,锁机制在java多线程开发过程中大体分为两种,内置锁和JUC锁:内置锁:使用Synchornized,synchronized是java的关键字,如果简简单单的通过看底层源码的方式是没有办法看到的,需要通过汇编指令来看才能理解这个锁的机制,synchronized锁的对象,不是代码块,这个概念一定要弄清楚,因为经常会听到说锁代码块,锁方法,其实它是锁的对象;synchronized锁是不需要程序员释放的,它本身会自己释放锁,底层有一个锁升级的过程,依次为自适应锁(自旋锁)、偏向锁、轻量级锁、重量级锁,synchronized是基于object monitor的机制来实现的,默认是非公平锁。JUC锁:JUC下面的锁比较常用的有ReentrantLock和LockSupport,这个锁是JDK的已有的实现,JUC锁是独占锁、共享锁、重量级锁、公平锁、非公平锁以及读写锁;JUC锁都有一个特征如果你手动加锁了,记得要手动释放,jvm是不会给你释放锁的,一般我们会在finally中去释放锁,保证整个锁能够释放。

计算机组成

在这里插入图片描述
计算机的组成如上图,其中CPU是计算机的大脑部分,主要负责运算,CPU有算数逻辑单元、寄存器、PC、还有高速缓存,现在的CPU都有三级缓存,每一级缓存的容量都是递增的,其中第三极缓存是CPU中共享的;CPU通过系统总线将数据从主内存中加载到缓存中,然后进入寄存器,最后进入ALU进行运算,最后在通过总线回写到主内存中,如:
在这里插入图片描述
CPU中的L1、L2是和其他CPU不共享的,其中L3是每个CPU共享的,当需要CPU需要计算时,从主内存加载到L3,然后再一步一步加载到寄存器中,最后完在ALU中完成运算,最后再回写到主存中;
我们了解了计算机的大体组成过后,我们来思考一个问题多线程编程中并发有哪些特性呢?并发特性:通俗点说就是保证原子性、有序性、可见性,那么如何保证呢?下面我们通过例子来分析

并发之可见性

public class T0916 {
    
    

    private static boolean falg = true;


    public static void main(String[] args) throws InterruptedException {
    
    
        T0916 t0916 = new T0916();
        new Thread(() -> t0916.thread1(),"thread1").start();
        Thread.sleep(2000);
        new Thread(() -> t0916.thread2(),"thread2").start();

    }


    private void thread1(){
    
    
        System.out.println(Thread.currentThread().getName()+"开始启动...");
        int i = 1;
        while (falg){
    
    
            i++;
        }
        System.out.println(Thread.currentThread().getName()+" 结束 i="+i);
    }

    private void thread2(){
    
    
        System.out.println(Thread.currentThread().getName()+"开始启动...");
        falg = false;
        System.out.println(Thread.currentThread().getName()+"执行结束...");
    }
}

这个例子的大体意思就是定义了一个全局变量falg=true,我启动一个线程,执行while,然后2s过后,我启动另一个线程,修改了falg的值,看下线程1是否能够退出?执行截图如下:
在这里插入图片描述
从图中可以看出,线程一直没有退出,为什么呢?明明我的线程2已经修改了值了,为什么不退出呢?我们修改下程序,我们在循环中执行以下System.out.println看下:
在这里插入图片描述
可以看到线程退出了,是可以的,那么我再修改下程序,我让线程睡眠一下:
在这里插入图片描述
还是可以,那么是为什么呢?我们再来每次让线程等待10微妙,首先增加一个方法:

public static void shortWait(long interval){
    
    
    long start = System.nanoTime();
    long end;
    do{
    
    
        end = System.nanoTime();
    }while(start + interval >= end);
}

然后执行:
在这里插入图片描述
可以看到还是不行,那么每次等待长一点呢,修改成20微妙,看下
在这里插入图片描述
可以看到是可以的,那么这个例子到现在,到底是为什呢?首先,最后一个每次循环等待一个时间片段,当10微妙的时候是不行的,当20微妙的时候可以的,为什么呢,我们先不说为什么可以,我们来看个图
在这里插入图片描述
看上图的JMM线程内存模型是不是有点懂了? 首先我们启动线程1,线程1会创建虚拟机栈,并且创建栈帧来存储线程1的的局部变量表和操作数栈信息,其中falg是全局变量,是从主存加载过来的,在本地内存也就是虚拟机栈中是falg变量的一个副本,首先加载过程是从主存通过指令read读取过来,然后load到虚拟机栈的本地内存中,然后通过执行引擎从用户态切换到内核态,执行我们的while程序,记着,这个时候是从本地内存读取的falg;那么2s过后,线程2启动,线程2也是和线程1一样的方式将falg修改过后,但是这个时候不一样的是修改了falg的值会通过指令assgin回写到主存中;此时,我们的线程1还在使用本地内存的falg的副本,它根本就不知道falg的值已经被修改了,所以这个就涉及到可见性问题。首先我们测试的最后一种方式,每次循环停止20微妙的情况下,是可以退出循环的,为什么呢,因为这个时候缓存失效了,本地内存的缓存falg失效了,又重新重主存加载了一份新的过来,那么这个时候falg值已经发生了变化,所以能退出循环,那为什么10微妙不行,所以这个就是本地缓存的失效时间,简单来说就是,如果长时间没有使用这个缓存值的时候,可能jvm就会重新重主存中将这个值加载过来覆盖新的变量副本。System.out.println:在循环中执行System.out.println为什么能够退出呢?不知道大家有没有看过System.out.println的底层,如果看过,你就会注意到System.out.println的实现有Synchronized

public void println(String x) {
    
    
    synchronized (this) {
    
    
        print(x);
        newLine();
    }
}

也就是说 synchronized 它是通过一种内存屏障的方式来保证变量的可见性的,当falg赋予了可见性的保证过后,那么它是可以被第一时间读取到新的值的;Thread.sleep(0):这个为什么也可以了,我们直接睡眠0s,这个时候只要执行了Thread.sleep,不管你睡眠多少s,都会读到最新的falg值,为什么呢?我在文章的中间记录了线程的上下文切换,什么意思呢?上下文?上下文的意思就是说当切换回来过后会重新加载上下文信息,而Thread.sleep是要让出cpu的执行时间周期权限,那么当时间到了过后又重新加载了上下文,比如falg变量, 所以这个时候falg是false,就会跳出循环,我们关注的不是说Thread.sleep多少s,而且Thread.sleep这个动作已经让出了cpu的执行权限,而当再次唤醒时,是要重新加载上下文信息的,所以Thread.sleep是可以的。我们修改下程序,不加System.out.println,也不加Thread.sleep,也不用每次循环去等待多少微妙,我们只修改下falg变量的修饰,如:

public class T0916 {
    
    

    private volatile static boolean falg = true;


    public static void main(String[] args) throws InterruptedException {
    
    
        T0916 t0916 = new T0916();
        new Thread(() -> t0916.thread1(),"thread1").start();
        Thread.sleep(2000);
        new Thread(() -> t0916.thread2(),"thread2").start();

    }


    private void thread1(){
    
    
        System.out.println(Thread.currentThread().getName()+"开始启动...");
        int i = 1;
        while (falg){
    
    
            i++;
        }
        System.out.println(Thread.currentThread().getName()+" 结束 i="+i);
    }

    private void thread2(){
    
    
        System.out.println(Thread.currentThread().getName()+"开始启动...");
        falg = false;
        System.out.println(Thread.currentThread().getName()+"执行结束...");
    }
    public static void shortWait(long interval){
    
    
        long start = System.nanoTime();
        long end;
        do{
    
    
            end = System.nanoTime();
        }while(start + interval >= end);
    }

执行结果:
在这里插入图片描述
是不是可以退出的,所以搞了这么半天其实就是一个变量的可见性的问题,我们在并发编程中很常用的一个关键字volatile,标示我们的这个变量对所有的线程都是可见的,它只能保证可见性,没有办法保证原子性,也就是说所有的线程在使用它或者修改它的时候都可以可见的,可以看到的最新值,但是不能保证其最终值,因为可能其他线程已经修改了它。像synchronized也是能保证可见性的。volatile不仅能保证可见性,它还和内存屏障有关系,还有指令重排等。所以这个例子结合了JMM模型来说就是为了描述清楚并发编程的三大特性之可见性和有序性的解释。volatile是java中的内置关键字,我们从程序代码中是没有办法去查看它的具体实现的,如果我们要看它是如何实现的,就要借助于汇编的指令来查看,汇编这块,我自己也不太懂,所以也就没有办法去研究它的具体实现,但是我们通过java编译的指令去大概查询下它生成的汇编指令,在启动命令输入-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp,就可以看到汇编的一些指令,但是我这边有点问题,没有办法输出,如果输出的话就可以看到一个指令lock addl $0x0,(%rsp);后面有空了去试下看;为了搞懂volatile,我们在硬件层面去分析下,以前有人给我说过我们做程序一定要有机械同感,什么意思呢?我们平时开发程序的时候,根本就不知道这程序性能如何,我们的代码在这个硬件配置上运行,是否能达到性能最优,可能很多人都不知道,只是模糊的概念,说白了现在大多数程序员只是把 功能实现了,这些实现的功能的硬件层面是如何运行的,可能大多数人都不知道,比如文章前所说new Thread和普通的new User有什么区别,可能大多数人都不知道,都不知道创建线程最终是由内核去处理的,所以我们要尽可能的去压榨硬件的性能,让其达到性能最优,我们做程序的一定要有机械同感的思想,思想很重要,比如JMM线程内存模型中,本地虚拟机栈中的栈内存主要保存方法执行的一些栈信息,而全局的变量对象信息在虚拟机栈中是一个副本,那么只有重新加载过来才会有最新的数据,这只是一个例子,我们要理解它的过程,而不是原来是这样;再比如说Mq,大家都知道的,那么不同的服务器是如何能够读取到其他的服务器发过来的消息,就是她最终把消息落盘到了一个指定的地方,大家都去那里拿,这只是一种思想而已,所以为了很好的理解这块,这边我把JMM模型升级到CPU级别,来分析下,比如我的两个线程对同一个变量i进行修改,线程1 i+1,线程2 i+2,如图:
在这里插入图片描述
看了上图,我们来思考一个问题,为什么CPU不直接把i这个变量从主存直接读取到寄存器中,而是要通过高速缓存一级一级的读取到寄存器中呢?因为CPU从主内存中直接读取到寄存器中要花费160多个时钟周期,我之前好像是在那个文章上看到的,而从高速缓存中加载是要快的很多,这个暂不讨论,从上图就可以看到是不是我们的i这个变量最后的值是不确定的,要么1要么2,数据不安全的,但是目前的CPU不可能是这样的,它自己有锁机制,那比如我们锁总线呢?类似于java中的锁,我直接把总线锁了,行不行呢?我们看下图:
在这里插入图片描述
这样是能保证了变量i的安全了,但是这样做是不是CPU设计的多核就毫无用处了,那我要多核CPU干嘛,所以总线锁这个肯定不可取的,但是总线锁也是存在的,如果MESI缓存一致性协议不能使用,好像就是使用的总线锁,我也有点记不清楚了;如果采用缓存一次性协议MESI,那么性能就好很多,但是MESI的锁缓存行cacheLine的大小只有64byte,数据超过了64byte就不能使用MESI或者MOESI,那具体缓存一致性协议MESI是如何做的呢?
在这里插入图片描述
MESI的这个动态图不太好画,简单描述下,就是比如我们的Thread1读取到了i这个变量到CPU的高速缓存区域过后,进行了M独占,那么这个时候我们的总线有个功能交易嗅探机制,嗅探到了Thread1独占了i这个变量,那么这个时候其他线程读取到的i都是I无效的,当Thread1完成了i+1的操作过后,这个时候I就变成了M,而将i回写到了主存过后,那么这个时候每个线程取到的i都是S共享状态了,线程2对i进行加载成了E,然后修改完成过后又是M,这个时候线程1的i是I无效的,最后回写主存,线程1重新读取i,这个时候大家都是共享的,所以这个就是MESI缓存一致性协议,当然这个性能也不是最高,最后提出了一个MOESI,这个和MESI有什么区别呢?就是MESI每次修改数据都要回写主存,其他线程又从主存读取,而MOESI这个协议是直接通过广播机制将修改的值广播到其他核的高速缓存区,其他线程不用从新从主存读取,这个就是MOESE和MESI缓存一致性协议的区别。以上的CPU缓存一致性协议不是默认触发的,因为很多计算过程是不需要启动缓存一致性协议的,只有当我们告诉cpu,需要启动缓存一致性协议才会发送,而在java开发中,只要你将对象或者变量加入了volatile关键字过后,生成的汇编指令为lock addl $0x0,(%rsp),lock就告诉cpu启动缓存一致性协议,lock本身不是内存屏障,但是在缓存一致性协议中起到了内存屏障的作用。总结:java应用程序中声明的对象或者变量如果不加volatile关键字,那么cpu是不会默认启动缓存一致性协议的,当加了volatile关键字过后,汇编指令生成lock指针,告诉CPU要启动缓存一致性协议,这样就保证了每个线程在写的时候都能读到最新的值,但是不能保证原子性,所以cpu的缓存一致性协议在读的时候可以说是原子性的,但是在整个过程中不是原子的,所以volatile是增加了内存屏障,禁止指令重排,从而使每个线程都能读取到最新的值。

猜你喜欢

转载自blog.csdn.net/scjava/article/details/108673512
今日推荐