java并发编程-JMM及并发特性(一)

java并发编程-JMM及并发特性(一),第1张

java并发编程-JMM及并发特性(一) 前言

        前面已经把mysql 和 jvm相关知识做了一遍复习与梳理。这一章开始就开始java并发编程的知识记录与讲解,并发编程相对前面的内容,会更复杂,更难一些。我也梳理了很久的一个顺序以及需要说明的内容。不过没关系,付出总是有回报的,学习能够让自己更加强大。希望在学习完整个java并发编程后,能在工作和学习中有所帮助。

        这一篇理论的地方特别多,需要理解的地方也是很多,所以需要静下心来好好的读一读,缕一缕这些逻辑,一个点没有明白就需要多看两遍。

并发特性

        我们老是听到并发,高并发等关键词,面试和工作中也是比较频发各种各样的并发问题处理,有些问题还特别奇怪(本人也比较烦去梳理并发问题原因),因为并发问题牵扯到太多的情况,比如内存共享,执行顺序,冲突等等等。。。。都需要一一去验证。那么我们就从原理上面去分析这些情况的发生是为什么

并发与并行

        首先我们要了解什么是并发,并发的概念是什么

        众所周知计算机是通过cpu来执行各种各样的指令来解决我们的问题,运行我们的程序,最早的cpu是单核,慢慢的演变到现在的多核多线程,那么这么多的核的作用是啥? 最明显的就是快

        并行 :  指在同一时刻,多个任务或指令在多个处理器下共同执行 

        并发:同一时刻只有一个指令在处理器下执行,多个指令在cpu下被快速的轮换执行,使得给我们的感觉是多个指令在同时执行,但是实际上只是并不是同时执行,只是分成若干个时间片,多个线程快速交替执行

区别:

        它们都是为了最大化cpu的利用产生的

        并行在多处理器下存在,并发可以在单处理器和多处理器下都存在,并发是并行的假象,更像是模仿并行,可以理解为一个天才(并行)两个手一起写字,能写两份文件,还有一个东施效颦的(并发)也想这样,但是左手不会写,一个手在两份文件上交替的去写文件。

        一个手两边抓,肯定会有问题。万一你写着写着忘了写哪儿了呢,一边英文一边中文,结果写反了呢,等等问题出现。

并发的三大特性

        三大特性也是并发bug的三大源头,解决了这三个问题,基本都能解决并非的bug(绝大部分)

可见性:当一个线程对变量进行了修改,别的线程也能看到这个修改

        如何保证可见性                 通过 volatile 关键字保证可见性。                 通过 内存屏障保证可见性。                 通过 synchronized 关键字保证可见性。                 通过 Lock保证可见性。                 通过 final 关键字保证可见性

有序性:程序按代码的顺序执行,jvm会指令重排(jvm的优化)所以会有顺序问题        

        如何保证有序性                 通过 volatile 关键字保证可见性。                 通过 内存屏障保证可见性。                 通过 synchronized关键字保证有序性。                 通过 Lock保证有序性

原子性:一个或者多个 *** 作,要么都成功,要么都失败

        如何保证原子性                 通过 synchronized 关键字保证原子性。                 通过 Lock保证原子性。                 通过 CAS保证原子性。

下面做一下简单的示例,看一下以上三个问题:

 可见性问题:

package com.demo;


public class VisibilityProblem {

    private boolean flag = false;

    public void refresh(){
        flag = true;
        System.out.println("修改flag的值为:"+flag);
    }

    public void run(){
        System.out.println("开始执行");
        int i = 0;
        while(!flag){
            i++;
        }
        System.out.println("跳出循环"+i);
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityProblem vp = new VisibilityProblem();

        //开始执行run的死循环, flag作为循环条件
        Thread t1 = new Thread(() -> vp.run());
        t1.start();

        //休眠一秒然后在去修改flag的值
        Thread.sleep(1000);
        Thread t2 = new Thread(() -> vp.refresh());
        t2.start();
    }

}

运行结果如下:

明明线程2去修改了flag的值,但是还是没有跳出循环,对于线程1而言,并没有看见线程2做的修改,出现可见性问题

有序性问题:

public class ReOrderTest {
    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;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            //两个线程交替该值,按逻辑来说这个循环永远不会退出,
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();//线程1执行完才允许其他线程执行
            thread2.join();

            System.out.println("第" + i + "次(" + x + "," + y + ")");
            //当x y的值都为0时退出
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

运行结果:

 可以看到,如果按照代码逻辑,线程2在获取a的值时应该已经被线程1所修改为a = 1了,所以y的值应该永远不为0,可是在多次运行后,总是能退出,x y都同时为0,这就是线程有序性问题,x与y大部分是0,1但还是有一些因为指令重排的问题变成了1,0(自行测试就能看到,而且可以尝试把线程去掉,只做赋值就形成了我们按代码逻辑推算出来的永远不会退出)

原子性问题:

        最常见的就是i++  大家都知道i++不保证原子性,就是说你循环10w次i++ ,算出来不一定是10w,可能会少很多

 以上三个就是并发问题得三大特点了。可以思考下为啥会出现这些问题?解决方案我在上面也已经写出来了,当然后续我们把原理弄清楚,解决起来就得心应手了

JMM内存模型

        java虚拟机规定了java得内存模型jmm (Java Memory Model,Java内存模型),用来屏蔽掉在各个 *** 作系统和硬件之间得差异,达到一致得并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。

 

 看到这个图,其实我们可见性问题得原因就能清楚了,线程1获取了变量的值,在线程2改变了以后虽然主内存中的变量是改变了,但是对于线程1还是使用的本地内存的变量副本,导致跳不出循环

JMM与硬件内存架构的关系         Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬 件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和 CPU内部的寄存器中。如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:

内存交互 *** 作         关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、 如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种 *** 作来完成:         lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。         unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。         read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用         load(载入):作用于工作内存的变量,它把read *** 作从主内存中得到的变量值放入工作内存的变量副本中。         use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个 *** 作。         assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个 *** 作。         store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的 *** 作。         write(写入):作用于主内存的变量,它把store *** 作从工作内存中一个变量的值传送到主内存的变量中。 下面是执行的流程示例: Java内存模型还规定了在执行上述八种基本 *** 作时,必须满足如下规则:         1.如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load *** 作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write *** 作。但 Java内存模型只要求上述 *** 作必须按顺序执行,而没有保证必须是连续执行。         2.不允许read和load、store和write *** 作之一单独出现         3.不允许一个线程丢弃它的最近assign的 *** 作,即变量在工作内存中改变了之后必须同步到主内存中。         4.不允许一个线程无原因地(没有发生过任何assign *** 作)把数据从工作内存同步回主内存中。         5.一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化 ( load或assign)的变量。即就是对一个变量实施use和store *** 作之前,必须先执行过了assign和load *** 作。         6.一个变量在同一时刻只允许一条线程对其进行lock *** 作,但lock *** 作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock *** 作,变量才会被解锁。lock和unlock必须成对出现         7.如果对一个变量执行lock *** 作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign *** 作初始化变量的值         8.如果一个变量事先没有被lock *** 作锁定,则不允许对它执行unlock *** 作;也不允许去unlock一个被其他线程锁定的变量。 对一个变量执行unlock *** 作之前,必须先把此变量同步到主内存中(执行store和write *** 作)

JMM的内存可见性保证 按程序类型,Java程序的内存可见性保证可以分为下列3类:         单线程程序。 单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。         正确同步的多线程程序。 正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。         未同步/未正确同步的多线程程序。 JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。 JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。 未同步程序在JMM中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。         1)顺序一致性模型保证单线程内的 *** 作会按程序的顺序执行,而 JMM不保证单线程内的 *** 作会按程序的顺序执行,比如正确同步的多线程程序在临界区内的重排序。         2)顺序一致性模型保证所有线程只能看到一致的 *** 作执行顺序,而 JMM不保证所有线程能看到一致的 *** 作执行顺序。         3)顺序一致性模型保证对所有的内存读/写 *** 作都具有原子性,而 JMM不保证对64位的long型和double型变量的写 *** 作具有原子性(32位处理器)。         JVM在32位处理器上运行时,可能会把一个64位long/double型变量的写 *** 作拆分为两个32位的写 *** 作来执行。这两个32位的写 *** 作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写 *** 作将不具有原子性。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写 *** 作拆分为两个32位的写 *** 作来执行,任意的读 *** 作在JSR-133中都必须具有原子性 volatile volatile的特性         可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。         原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合 *** 作不具有原子性 (基于这点,我们通常会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。          有序性:对volatile修饰的变量的读写 *** 作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。 可以这理解, 对于volatile的读写 *** 作,都是直接从主内存 *** 作 实现原理: JMM内存交互层面实现         volatile修饰的变量的read、load、use *** 作和assign、store、write必须是连续的,即修 改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量 *** 作对多线程 的可见性。 硬件层面实现         通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个 *** 作称为“缓存锁定”, 缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回 写到内存会导致其他处理器的缓存无效。         lock前缀指令的作用         1. 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执 行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。         2. LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。         3. LOCK前缀指令会 等待它之前所有的指令完成、并且所有缓冲的写 *** 作写回内存 (也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的 *** 作会导致其他cache中的副本失效。 volatile重排序规则:         指令重排序的意义: JVM能根据处理器特性( CPU多级缓存系统、多核处理器等)适当的对机 器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。          也就是说,最终结果与它顺序化情况的结果相等 jvm有可能自动调整顺序,上面的有序性问题代码也有体现指令重排,关键就在于,单线程没问题,多线程就会有问题 volatile禁止重排序场景 :         1. 第二个 *** 作是volatile写,不管第一个 *** 作是什么都不会重排序         2. 第一个 *** 作是volatile读,不管第二个 *** 作是什么都不会重排序         3. 第一个 *** 作是volatile写,第二个 *** 作是volatile读,也不会发生重排序 JMM内存屏障插入策略         1. 在每个volatile写 *** 作的前面插入一个StoreStore屏障         2. 在每个volatile写 *** 作的后面插入一个StoreLoad屏障         3. 在每个volatile读 *** 作的前面插入一个LoadLoad屏障         4. 在每个volatile读 *** 作的后面插入一个LoadStore屏障

        x86处理器不会对读-读、读-写和写-写 *** 作做重排序, 会省略掉这3种 *** 作类型对应的内存屏障。仅会对写-读 *** 作做重排序,所以volatile写-读 *** 作只需要在volatile写后插入StoreLoad屏障

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5683188.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存