并发编程专题——第一章(深入理解java内存模型)

说到并发编程,其实有时候觉得,开发中真遇到这些所谓的并发编程,场景多吗,这应该是很多互联网的在职人员,一直在考虑的事情,也一直很想问,但是又不敢问,想学习的同时,网上这些讲的又是乱七八糟,那么本章开始,带你走进并发编程专题在讲专题之前,我想多说两句,可能市面上的开发,对操作系统或者多线程了解的还不是特别深入,也就会经常写一些代码质量不是很高,那么在讲并发之前,我希望可以看看这节,绝对精彩!!

1、什么是JMM模型?

我先用官方的话来描述下,请仔细看:

       Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段 和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线

程创建时 JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问, 但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要

将变量从主内存拷贝的自 己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作 主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个 线程的私有数据区域,因此不同的线程间无法访

问对方 的工作内存,线程间的通信(传值)必 须通过主内存来完成。

从上面一段文字中我们提出几个关键的点:

  • JMM叫做Java内存模型,描述的是一组规则,或者规范,定义程序中各种不同变量的访问方式,比如new关键字创建字段,static修饰的静态字段,以及数组对象等等。
  • JVM和JMM不是一个东西,两个完全是没有关联。JVM针对的是内存的管理。JMM是一组规范。
  • JMM规定,所有变量都存储在主内存,所有线程都可以去访问。但是线程对变量的操作必须在工作内存中进行。
  • 工作内存,和主内存不是一回事。工作内存是每个线程私有的数据区域。

 

所以面试中经常会问到JVM和JMM之间的关系和区别,记住要回答,没有什么关联,JMM是定义程序中变量访问的规范,主要指各个变量在共享和私有数据区域的访问方式,JVM是虚拟机对内存的管理。

既然JMM是描述变量访问规范,那么就有了三大特性:

  • 原子性

  • 有序性

  • 可见性

JMM与Java内存区域唯一相似点就是,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法 区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈 以及本地方法

栈。

主内存======共享数据区域(堆+方法区)

工作内存======线程私有数据区域(程序计数器,虚拟机栈,本地方法栈等)

再结合我的灵魂画师的图理解下:

并发编程专题——第一章(深入理解java内存模型)_第1张图片

这样,我想大家应该很好的掌握到了JMM的特点和概念性的东西。

什么是主内存?

主内存,主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可

能会发生线程安全问题。

什么是工作内存?

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝), 每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线

程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

       根据JVM虚拟机规范,主内存与工作内存的数据存储类型以及操作方式,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型 (boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本

地变量是引用类型,那么该变量的引用会存储在工作内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量而言,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static

变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。

这些文字看起来枯燥无味,但是我还是希望很多基础理念的东西,一定要去静下心去看,理解下。

附上一张图理解下;

并发编程专题——第一章(深入理解java内存模型)_第2张图片

其实Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽

象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算 机硬件内存架构是一个相互交叉的关

系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)

我写个类来描述下上面的文字,

initFlag表示共享变量,存在主内存中,不要把这个和JVM的堆搞混淆了,我讲的还是JMM约定,规范,而不同的虚拟机会根据这个规范自己去实现内存的管理策略

此时我开了两个线程,严格来说是3个线程,其中包括main,当然main是来驱动这两个线程执行的。

线程1和线程2,由于都会拿到这个initFlag共享变量,对于JMM规范来说,这两个线程不是直接去操作initFlag这个变量,而是先报保存一份这个变量的副本到自己的工作内存中并发编程专题——第一章(深入理解java内存模型)_第3张图片

线程1这个while循环,判断的是自己的工作内存中的initFlag这个变量的值

线程2这个refresh,是向initFlag中写一个新的值进去,为true,也是一样,先修改自己的工作内存initFlag的值,再写回去主内存,将这个值修改。

线程1休眠500毫秒后执行线程2。

li并发编程专题——第一章(深入理解java内存模型)_第4张图片

与此同时,对于JMM中一个变量如何从主内存拷贝到工作 内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

2、数据同步八大原子操作

(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态

(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后 的变量才可以被其他线程锁定

(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存 中,以便随后的load动作使用

(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工 作内存的变量副本中

(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎

(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内 存的变量

(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存 中,以便随后的write的操作

(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值 传送到主内存的变量中

前两个我们先不看,后面讲到Synchronized关键字再详细说,先从2开始

3、可见性案例描述

我给个图可以看下这个上述过程。

并发编程专题——第一章(深入理解java内存模型)_第5张图片

那么我运行程序看看结果,你会发现线程1会一直卡在当前的状态出不来,因为啥?因为线程1读到的是工作区域的副本,主内存这个时候的值已经是被刷新成initFlag为true了,不信我再开个线程看看?

并发编程专题——第一章(深入理解java内存模型)_第6张图片

开个t3,你会发现,确实被修改了,但是t1线程,一直在循环中,一直没退出来。

并发编程专题——第一章(深入理解java内存模型)_第7张图片

原因就是上面规范说的,每个线程只能看到自己私有的东西,别的线程里的东西他是无法得知的;

所以我上面引出了并发编程中的,三个特性,可见性,原子性,有序性。

原子性,其实我们很早就熟悉过,比如synchronized关键字,事务的原子性,一系列的原子操作,都会对这个概念比较熟悉,这个特性我先不说。

来看看可见性,既然每个线程只能看到自己私有的东西,看不到其他线程对共享资源操作的变化,更不可能去跨线程去访问别的线程的资源,除非有一种机制,在某个线程修改了某个变量或者共享资源的同时,你就可以主动通知到我,让我重新去读取主内存

中最新的数据,那么,这个时候,就叫做可见性。

就引出了一个关键字,volatile,这想必应该都听过,我来介绍下:

Volatile——>java的并发轻量级锁机制,是对Synchronized关键字的有意补充,先看看怎么使用

如果我对initFlag这个共享变量加上这个关键字,那么线程2在修改initFlag后,会主动通知线程1,我已经改了这个变量,你自己的丢掉把,重新读取主内存最新的值!!!

对于Volatile底层原理,我会单独起一个章节讲,先用起来再说。

并发编程专题——第一章(深入理解java内存模型)_第8张图片

那我加这个关键字和不加这个关键字,在字节码上有什么区别呢?

右键——External Tool——>show byte code

如果不知道idea怎么看反编译后的class文件,参考我的博客:https://blog.csdn.net/qq_31821733/article/details/117198235

解析后的字节码是这样的:加了volatile关键字,多了个ACC_VOLATILE;

并发编程专题——第一章(深入理解java内存模型)_第9张图片

所以加了Volatile关键字后,线程1马上就可以看到线程2的更改并及时通知!

那这个时候我会思考一个问题,如果我不加这个关键字,我在线程1中写行代码,count++;

线程1会不会看到线程2的修改呢?

比如这样,运行下,结果是看不到。。很明显你没有通知到线程1

并发编程专题——第一章(深入理解java内存模型)_第10张图片

但是我这个时候int改成Integer呢?我靠这是什么现象??int不行,Integer可以,Integer是int的包装类,仅此而已。。。

并发编程专题——第一章(深入理解java内存模型)_第11张图片

那我一旦给这个count修饰Volatile后呢?很奇怪,为什么我给counter加了这个关键字,线程2会通知线程1,initFlag的这个改变呢?

并发编程专题——第一章(深入理解java内存模型)_第12张图片

这些都是很玄学的情况,而我为此去寻求了答案,甚至去StackOverFlow问了下,众说纷纭,但是我最终理解了一个东西,是Volatile的本质!!

叫做及时可见,意思是说,如果你不加Volatile关键字,其他线程不会立马看到,但是,也一定会在最终看到,而Volatile,解决了及时可见性!!!!

而不加的时候,线程能看到的时机,你确定不了。

因为规范是死的,实现是活的,阿里有虚拟机,ibm有自己实现的虚拟机,华为也有,谷歌也有,而这些虚拟机都是实现了这些规范,内部很多不一样的细节,肯定是灵活的。

这些语义,不会去描述多线程的程序该如何执行,而是描述多线程程序允许表现出的行为。任何执行策略,只要产生的是允许的行为,那他就是一个可接受的执行策略。——来自JSR133中文规范

所以你不写Volatile,线程1,有一天也能看到initFlag的变化。

可是 为什么while空循环中,会必须要加Volatile才可以生效呢?因为空循环,在底层硬件中,执行具有最高的优先级,拿到时间片后基本不太可能释放了,内部没有任何代码,根本不需要停下来,而你要在while循环体内写点东西,

就会很大概率出现线程的中断,上下文切换。

所以上面的一些奇怪的现象,很有可能是这些变量,在同一个缓存行中,比如counter的变量和initFlag变量,在一个缓存行中,而其中只要一个变量失效了,会通知重新读,那么这个时候会顺带把缓存行中其他的变量最新值,也从主内存中读过来

但是这个没法证明!!

看下一个问题:

4、Volatile可以保证我们的原子性吗?

来看个案例,我计数,10*1000结果应该是10000次

但是结果并不是,结合上面的说法,每个线程在往主内存写的时候,是没有告诉别的线程嘛,循环10个线程,每个线程拿到的值修改后,都没有通知其他线程,所以导致每个线程拿到的值都不是主内存的最新的值

那么我加个volatile关键字试试?

并发编程专题——第一章(深入理解java内存模型)_第13张图片

加了Volatile后,哎,为什么还不是10000?难道上面讲错了?原则上任意一方改了东西后,会通知另一方啊,这读者讲错了吗??你说count++这就一行代码,难道不是原子的吗?

并发编程专题——第一章(深入理解java内存模型)_第14张图片

实际上,并发的三大特性中,原子性,volatile解决不了;

count++你看起来是一行代码,实际上在底层中,是执行了3步,

第一步:线程读取count=0

第二步:线程count = count + 1

第三步:写回主内存,并通知其他的线程。

而每个线程是基于时间片去轮询的,如果刚刚好,假设线程1拿到cpu,执行到count=0之后,此时中断了,这个时候线程1失去了cpu的使用权,然后当拿回来cpu的时候,时间片此时用完了。

此时cpu被线程2拿走去执行了,而线程2此时假如开始读也是count=0,正常走完,然后通知线程1,count刷新了你去读把,而此时线程1要循环1000次,本来要加到1000的,但是因为第一次刚好读到了count=0,于是中断,等恢复现场的时候,发现时间片用

完了,没办法,本次循环就被浪费了,再加上有人通知我要拿新的值,那我线程1接下来的count = count+1也要丢弃了。于是没能把count+1写回内存。

所以就出现了上述的情况。不要觉得没有这么巧的事情,如果你的服务器是面向toC的,服务器可以并发好几千个线程,每一步都会发现线程去抢夺cpu资源的情况。

并发编程专题——第一章(深入理解java内存模型)_第15张图片

所以count++中三步,每一步是原子的,但是在整体中,他不是原子的,所以volatile是没法保证原子性的。

如果你想要保证原子性,就加synchronized关键字,这个比较简单我就不多说了,关于原子性我后面会单独讲的!!!

而且Volatile只能修饰成员变量,开发中你不可能说原子性的代码只有一行count++,肯定是很多行代码,所以这也就论证了synchronized可以保证原子,而volatile不可以

 

5、有序性案例

有序性是相对理解比较复杂的,我这里也说下

在我们很早的一个认知里,cpu在执行代码过程中,是有序执行,按照顺序的执行的。

时间上具有优先级,原则上写在上面的代码比写在下面的代码具有优先级。

但是在程序世界里,他不一定是这么执行的,很多情况,会对你的指令进行重排!!!!

cpu会认为,你的程序在指令重排后,执行的效率会更好更快!!而cpu会把你的指令重排,但是不会影响你的结果,要是影响你的结果,那就基本GG了。

来看一个案例,有四个变量a b x y;

我开了一个循环,两个线程,分别对a=1,x=b; b=1,y=a

并发编程专题——第一章(深入理解java内存模型)_第16张图片

如果我们不考虑指令重排我们看看x和y有哪些可能!!一共只有3种结果,你不管怎么倒腾。

并发编程专题——第一章(深入理解java内存模型)_第17张图片

但是一旦出现了指令重排,就会是这样的情况

并发编程专题——第一章(深入理解java内存模型)_第18张图片

指令已经颠倒!!然后我们执行下程序,看运气,运气好我们很快可以看到。

并发编程专题——第一章(深入理解java内存模型)_第19张图片

代码贴出来:

public class MemoryBarrier {

    private static int x = 0, y = 0, 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(() -> {
                shortWait(10000);
                a = 1;
                x = b;
            });

            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();

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

    /**
     * 等待一段时间,时间单位纳秒
     *
     * @param interval
     */
    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }

}

的确发生了指令重排,那么指令重排,volatile是可以解决的

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱 序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如 何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。

所以jvm最终底层会调用硬件方面的内存屏障策略。

在JVM中提供了4种内存屏障的指令

并发编程专题——第一章(深入理解java内存模型)_第20张图片

第一个LoadLoad,表示Load1和Load2两个读指令之间,加了个LoadLoad指令,表示这两个读不可以进行重排,不可以把load2放在load1的前面去执行

其他的我就不多解释,和第一个一样,所以你的变量加了volatile关键字后,cpu在底层执行你的指令中,就会禁止将你的程序读,写指令,进行优化重排!!!因为会找到你的读写指令,在其中加上内存屏障指令。

用官方的话来说:内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。

如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障,就是上面说的4中指令,禁止在内存屏障前后的指令执行重排序优化。

Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。 总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化

下面看一个非常典型的禁止重排优化的例子DCL,如下:

public class DoubleCheckLock {
    private static DoubleCheckLock instance;

    private DoubleCheckLock() {
    }

    public static DoubleCheckLock getInstance() {
        //第一次检测
        if (instance == null) {
            //同步
            synchronized (DoubleCheckLock.class) {
                if (instance == null) {
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题,你可能觉得我在和你吹牛逼,说你不是加了锁吗,怎么还会有问题吗?原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可

能没有完成初始化!!!

我们知道new创建对象,你看起来是一行代码intance = new DoubleCheckLock();

这一步对象的创建其实分为几步去做的。

  • memory = allocate()://分配对象内存空间
  • init(memory)://初始化对象
  • instance = memory://设置instance指向刚分配的地址,此时instance!=null

但是一旦发生了指令重排!

  • memory = allocate()://分配对象内存空间
  • instance = memory://设置instance指向刚分配的地址,此时instance!=null,但是对象还没初始化完成!
  • init(memory)://初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,都是创建对象的,结果都是拿到一个对象;

因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已经初始化完成,如果你此时代码里要调用实例里面的方法,

那无疑是有问题的,因为你还没初始化对象,你只是给了个引用指向,对象里啥都没有;

也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。就中规中矩来对象创建。

这样的情况会在用户量大的时候,以及比如你的服务挂了然后重启的一瞬间,所有流量打上来,这个时候懒汉式DCL的方式,就会出现极大的问题!!!

所以加了volatile关键字,就可以防止对象创建过程中指令重排!!!

本章我就讲了这些关键点,和面试中会问到的一些知识点,下一章我会说关于——JMM-CPU缓存一致性协议MESI,欢迎提出宝贵意见!!

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(并发编程专题,java)