zoukankan      html  css  js  c++  java
  • 并发编程之:JMM

    大家好,我是小黑,一个在互联网苟且偷生的农民工。

    上一期给大家分享了关于Java中线程相关的一些基础知识。在关于线程终止的例子中,第一个方法讲到要想终止一个线程,可以使用标志位的方法,我们再来回顾一下代码。

    class MyRunnable implements Runnable {
        // volatile关键字,保证主线程修改后当前线程能够看到被改后的值(可见性)
        private volatile boolean exit = false; 
        @Override
        public void run() {
            while (!exit) { // 循环判断标识位,是否需要退出
                System.out.println("这是我自定义的线程");
            }
        }
        public void setExit(boolean exit) {
            this.exit = exit;
        }
    }
    public class ThreadDemo {
        public static void main(String[] args) {
            MyRunnable runnable = new MyRunnable();
            new Thread(runnable).start();
            runnable.setExit(true); //修改标志位,退出线程
        }
    }
    

    在这个代码中,标志位exit字段在声明时使用了volatile关机字修饰,目的是为了保证在另外一个线程修改后当前线程能够感知到变化,那么这个关键字到底做了些什么呢?这一期我们来详细聊一聊。

    在开始讲volatile关键字之前,需要先和大家聊一聊计算机的内存模型这个玩意儿。

    计算机的内存模型

    所谓内存模型,英文描述是Memory Model,这玩意儿是一个比较底层的东西,它是与计算机硬件有关的一个概念。

    我们都知道,计算机在执行程序的时候,最终是一条条的指令在CPU中执行,在执行过程中往往会存在数据的传递。而数据是存放在主内存上的,对,就是你那个内存条。

    在刚开始CPU的的执行速度还不够快的时候并没有什么问题,但随着CPU技术的不断发展,CPU计算的速度越来越快,但是呢,从主内存上读取和写入数据的速度有点拉胯,跟不上呀,这就导致CPU每次操作主内存都要花费很多的等待时间。

    技术总是要往前发展的,不能因为内存读写慢CPU就不发展了吧,也不能让主内存的读写速度成为瓶颈。

    想必这里大家也应该想到了,就是在CPU和主内存之间加一个高速缓存,将需要的数据在这个高速缓存上复制一份,而这个高速缓存的特点就是读写很快,然后定期的将缓存中的数据和主内存同步。

    image

    到这里问题就解决了吗? too young,too simple啊,这种结构在但线程的情况下是没有问题的,随着计算机能力不断提升,开始支持多线程了,并且CPU牛逼到支持多核,到现在的4核8核16核,在这种情况下是会存在一些问题的,我们来分析一下。

    单核多线程情况:多个线程同时访问一个共享数据,CPU将数据从主内存加载到高速缓存中,多个线程会访问高速缓存中的同一个地址,这样即使在线程切换时,缓存数据也不会失效,因为在单核CPU同一时间只能有一个线程在执行,所以也不会有数据访问的冲突。

    多核多线程情况:每个CPU内核都会复制一份数据到自己的高速缓存,这样的话在不同内核上的两个线程是并行的,这样就会导致两个内核各自缓存的数据发生不一致。这个问题就叫做缓存一致性问题

    image

    除了上面说到的缓存一致性问题,计算机为了使CPU的算力能够被充分利用,会对输入的指令进行乱序处理,叫做处理器优化。很多的编程语言为了提高执行效率,也会对代码的执行顺序重新排序,比如咱们Java虚拟机的即时编译器(JIT)也会做,这个动作叫做指令重排

    int a = 1;
    int b = 2;
    int c = a + b;
    int d = a - b;
    

    比如我们写的这段代码,第三行和第四行的执行顺序就有可能发生改变,这在单线程中并没有问题,但是在多线程情况下,会产生和我们预期不一样的结果。

    其实上面提出的缓存一致性问题,处理器优化,指令重排就对应我们并发编程中的可见性问题,原子性问题,有序性问题。带着这些问题,我们再来看看,在Java中是如何来解决的。

    因为存在这些问题,那么肯定要有一种机制来解决。这种解决的机制就是内存模型

    内存模型定义了一个规范,用来保证共享内存的可见性,有序性,原子性。内存模型是怎么解决的呢?主要采取两种方式:限制处理器优化内存屏障。这里我们先不深究底层原理。

    JMM

    从前面我们知道内存模型是一个规范,用来解决并发情况下的一些问题。不同的编程语言对于这个规范都有对应的实现。那么JMM(Java Memory Model)就是Java语言对于这一规范的具体实现。

    那么JMM具体是如何解决这写问题的呢?我们先来看下面这张图。

    image

    内存可见性问题

    我们一个一个问题来看,首先,如何解决可见性问题

    如上图所示,在JMM中,一个线程对于一个数据的操作,分成了6个步骤。

    分别是:read,load,use,assign,write,store.

    如果说这个变量在声明时,没有使用volatile关键字,那么两个线程是各自复制一份到工作内存,线程B将flag赋值为true,线程A是不可见的。

    那么要想线程A可见,就需要在声明flag这个变量时,加上volatile关键字。那么加了关键字之后JMM是怎么做的呢?这里要分读和写两个情况。

    1. 线程在读取一个volatile变量时,JMM会把工作内存中的该变量置为无效,重新从主内存中读取;
    2. 线程在写一个volatile变量时,会立刻将工作内存中的值刷新到主内存中。

    也就是说,对于volatile关键字修饰的变量,在read,load,use操作必须是一起执行的;assign,write,store操作时一起执行。

    通过这样的方式,就能够解决内存可见性的问题。

    指令重排

    而指令重排这个问题,对于编译器来说,只要该对象声明为volatile的,那么就不会对它进行指令重排的优化。

    而volatile禁止指令重排的这种规则是符合一个叫做happens-before的规则。

    happens-before除了在volatile变量规则外,还有一些其他规则。

    程序次序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。

    管程锁定规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

    volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

    线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。

    线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。

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

    传递性规则:happens-before原则具有传递性,即hb(A, B) , hb(B, C),那么hb(A, C)。

    对象终结规则:一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

    竞态条件

    到这里,大家是不是感觉问题已经都解决了?emmm,我们来看下面这个场景:

    image

    假设上图中的线程A和线程B执行在两个CPU核心上,是并行执行的,它们一起读取到i的值等于0,然后各自加1,然后一起往主内存写。如果线程A和线程B是有先后顺序执行的,i的值最后应该是等于2才对,但是并行情况下是有可能同时操作的,最后写回到主内存中的值只被增加了一次。

    这就好比你的银行卡收到了两笔100块的转账,但是账户上只多了100块。

    对于这种问题通过volatile是无法解决的,volatile不会保证该变量操作的原子性。那我们应该怎么解决呢,就需要使用synchronized对这个操作加锁,保证同一时刻只能有一个线程进行操作。

    总结

    因为CPU和内存之间存在着高速缓存,在多线程并发情况下,可能会存在缓存一致性问题;而CPU对于输入的指令会做一些处理器优化,一些高级语言的编译器也会做指令重排。因为这些问题,会导致我们在并发情况下存在内存可见性问题,有序性问题,而JMM就是Java中为了解决这些问题而出现的。通过volatile关键字可以保证内存可见性,并且会禁止指令重排。但是volatile只能保证操作的有序性,无法保证操作的原子性,所以,为了安全,我们对于共享变量的并发处理要进行加锁。


    好的,今天的内容就到这里,我们下期再见。
    关注公众号【小黑说Java】获取更多干货。

  • 相关阅读:
    CF1051F The Shortest Statement 题解
    CF819B Mister B and PR Shifts 题解
    HDU3686 Traffic Real Time Query System 题解
    HDU 5969 最大的位或 题解
    P3295 萌萌哒 题解
    BZOJ1854 连续攻击游戏 题解
    使用Python编写的对拍程序
    CF796C Bank Hacking 题解
    BZOJ2200 道路与航线 题解
    USACO07NOV Cow Relays G 题解
  • 原文地址:https://www.cnblogs.com/heiz123/p/15196636.html
Copyright © 2011-2022 走看看