关于高并发:jmmvolatile学习

44次阅读

共计 16461 个字符,预计需要花费 42 分钟才能阅读完成。

一、古代计算机实践模型与工作形式
古代计算机模型是基于 - 冯诺依曼计算机模型
计算机在运行时,先从内存中取出第一条指令,通过控制器的译码,按指令的要求,从存
储器中取出数据进行指定的运算和逻辑操作等加工,而后再按地址把后果送到内存中去。接下
来,再取出第二条指令,在控制器的指挥下实现规定操作。依此进行上来。直至遇到进行指
令。
程序与数据一样存贮,按程序编排的程序,一步一步地取出指令,主动地实现指令规定的
操作是计算机最根本的工作模型。这一原理最后是由美籍匈牙利数学家冯. 诺依曼于 1945 年提
进去的,故称为冯. 诺依曼计算机模型。
计算机五大外围组成部分:

  1. 控制器 (Control):是整个计算机的中枢神经,其性能是对程序规定的管制信息进行解
    释,依据其要求进行管制,调度程序、数据、地址,协调计算机各局部工作及内存与外设的访
    问等。
  2. 运算器 (Datapath):运算器的性能是对数据进行各种算术运算和逻辑运算,即对数据进
    行加工解决。
  3. 存储器 (Memory):存储器的性能是存储程序、数据和各种信号、命令等信息,并在需
    要时提供这些信息。
  4. 输出 (Input system):输出设施是计算机的重要组成部分,输出设施与输出设备合你为
    外部设备,简称外设,输出设施的作用是将程序、原始数据、文字、字符、管制命令或现场采
    集的数据等信息输出到计算机。常见的输出设施有键盘、鼠标器、光电输出机、磁带机、磁盘
    机、光盘机等。
  5. 输入 (Output system):输出设备与输出设施同样是计算机的重要组成部分,它把外算
    机的两头后果或最初后果、机内的各种数据符号及文字或各种管制信号等信息输入进去。微机
    罕用的输出设备有显示终端 CRT、打印机、激光印字机、绘图仪及磁带、光盘机等。
    下图 - 冯诺依曼计算机模型图


古代计算机硬件构造原理图

CPU 内部结构划分

1、管制单元
管制单元是整个 CPU 的指挥控制中心,由指令寄存器 IR(Instruction Register)、指令
译码器 ID(Instruction Decoder)和 操作控制器 OC(Operation Controller)等组成,
对协调整个电脑有序工作极为重要。它依据用户事后编好的程序,顺次从存储器中取出各条指
令,放在指令寄存器 IR 中,通过指令译码(剖析)确定应该进行什么操作,而后通过操作控制
器 OC,按确定的时序,向相应的部件收回微操作管制信号。操作控制器 OC 中次要包含:节奏
脉冲发生器、管制矩阵、时钟脉冲发生器、复位电路和启停电路等管制逻辑。

2、运算单元
运算单元是运算器的外围。能够执行算术运算(包含加减乘数等根本运算及其附加运算)
和逻辑运算(包含移位、逻辑测试或两个值比拟)。绝对管制单元而言,运算器承受管制单元
的命令而进行动作,即运算单元所进行的全副操作都是由管制单元收回的管制信号来指挥的,
所以它是执行部件。

3、存储单元
存储单元包含 CPU 片内缓存 Cache 和寄存器组,是 CPU 中临时存放数据的中央,外面
保留着那些期待解决的数据,或曾经解决过的数据,CPU 拜访寄存器所用的工夫要比拜访内
存的工夫短。寄存器是 CPU 外部的元件,寄存器领有十分高的读写速度,所以在寄存器之间
的数据传送十分快。采纳寄存器,能够缩小 CPU 拜访内存的次数,从而进步了 CPU 的工作
速度。寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,别离存放相应
的数据;而通用寄存器用处宽泛并可由程序员规定其用处。
计算机硬件多 CPU 架构:

在 window 上的工作管理器能够看见

多 CPU
一个古代计算机通常由两个或者多个 CPU,如果要运行多个程序(过程)的话,如果只有
一个 CPU 的话,就意味着要常常进行过程上下文切换,因为单 CPU 即使是多核的,也只是多个
处理器外围,其余设施都是共用的,所以 多个过程就必然要常常进行过程上下文切换,这个代
价是很高的。

CPU 多核
一个古代 CPU 除了处理器外围之外还包含寄存器、L1L2L3 缓存这些存储设备、浮点运算
单元、整数运算单元等一些辅助运算设施以及外部总线等。一个多核的 CPU 也就是一个 CPU 上
有多个处理器外围,这样有什么益处呢?比如说当初咱们要在一台计算机上跑一个多线程的程
序,因为是一个过程里的线程,所以须要一些共享一些存储变量,如果这台计算机都是单核单
线程 CPU 的话,就意味着这个程序的不同线程须要常常在 CPU 之间的内部总线上通信,同时还
要解决不同 CPU 之间不同缓存导致数据不统一的问题,所以在这种场景下多核单 CPU 的架构就
能施展很大的劣势,通信都在外部总线,共用同一个缓存。

CPU 寄存器‘
每个 CPU 都蕴含一系列的寄存器,它们是 CPU 内内存的根底。CPU 在寄存器上执行操作的
速度远大于在主存上执行的速度。这是因为 CPU 拜访寄存器的速度远大于主存。

CPU 缓存
即高速缓冲存储器,是位于 CPU 与主内存间的一种容量较小但速度很高的存储器。因为 CPU 的速度远高于主内存,CPU 间接从内存中存取数据要期待肯定工夫周期,Cache 中保留着
CPU 刚用过或循环应用的一部分数据,当 CPU 再次应用该局部数据时可从 Cache 中间接调用,
缩小 CPU 的等待时间,进步了零碎的效率。

一级 Cache(L1 Cache)
二级 Cache(L2 Cache)
三级 Cache(L3 Cache)
内存

一个计算机还蕴含一个主存。所有的 CPU 都能够拜访主存。主存通常比 CPU 中的缓存大得
多。

CPU 读取存储器数据过程 CPU 要取寄存器 XX 的值,只须要一步:间接读取。CPU 要取 L1 cache 的某个值,须要 1 - 3 步(或者更多):把 cache 行锁住,把某个数据拿
来,解锁,如果没锁住就慢了。
CPU 要取 L2 cache 的某个值,先要到 L1 cache 里取,L1 当中不存在,在 L2 里,L2 开始加
锁,加锁当前,把 L2 里的数据复制到 L1,再执行读 L1 的过程,下面的 3 步,再解锁。
CPU 取 L3 cache 的也是一样,只不过先由 L3 复制到 L2,从 L2 复制到 L1,从 L1 到 CPU。CPU 取内存则最简单:告诉内存控制器占用总线带宽,告诉内存加锁,发动内存读申请,
期待回应,回应数据保留到 L3(如果没有就到 L2),再从 L3/ 2 到 L1,再从 L1 到 CPU,之后解
除总线锁定。

多线程环境下存在的问题
缓存一致性问题 在多处理器零碎中,
每个处理器都有本人的高速缓存,而它们又共享同一主内存(MainMemory)。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,然而
也引入了新的问题:缓存一致性(CacheCoherence)。当多个处理器的运算工作都波及同一
块主内存区域时,将可能导致各自的缓存数据不统一的状况,如果真的产生这种状况,那同步
回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,须要各个处理器拜访缓存时都
遵循一些协定,在读写时要依据协定来进行操作,这类协定有 MSI、
MESI(IllinoisProtocol)、MOSI、Synapse、Firefly 及 DragonProtocol,等等

MESI 中每个缓存行都有四个状态,别离是 E(exclusive)、M(modified)、S(shared)、I(invalid)。上面咱们介绍一下这四个状态别离代表什么意思。

M:代表该缓存行中的内容被批改了,并且该缓存行只被缓存在该 CPU 中。这个状态的缓存行中的数据和内存中的不一样,在将来的某个时刻它会被写入到内存中(当其余 CPU 要读取该缓存行的内容时。或者其余 CPU 要批改该缓存对应的内存中的内容时(集体了解 CPU 要批改该内存时先要读取到缓存中再进行批改),这样的话和读取缓存中的内容其实是一个情理)。

E:E 代表该缓存行对应内存中的内容只被该 CPU 缓存,其余 CPU 没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的内容和内存中的内容统一。该缓存能够在任何其余 CPU 读取该缓存对应内存中的内容时变成 S 状态。或者本地处理器写该缓存就会变成 M 状态。

S: 该状态意味着数据不止存在本地 CPU 缓存中,还存在别的 CPU 的缓存中。这个状态的数据和内存中的数据是统一的。当有一个 CPU 批改该缓存行对应的内存的内容时会使该缓存行变成 I 状态。

I:代表该缓存行中的内容时有效的。

例子:假如有两个 CPU。

指令重排序问题 为了使得处理器外部的运算单元能尽量被充分利用,处理器可能会对输出代码进行乱序执
行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的后果重组,保障该
后果与程序执行的后果是统一的,但并不保障程序中各个语句计算的先后顺序与输出代码中的
程序统一。因而,如果存在一个计算工作依赖另一个计算工作的两头后果,那么其程序性并不
能靠代码的先后顺序来保障。与处理器的乱序执行优化相似,Java 虚拟机的即时编译器中也有
相似的指令重排序(Instruction Reorder)优化

指令重排序的证实(解决办法:应用 volatile、锁或者手动增加内存屏障)

package com.jiagouedu.jmm;

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() {
            @Override
            public void run() {shortWait(10000);
               a = 1;
               // 也能够通过手动增加 unsafe 办法实现
               UnsafeInstance.reflectGetUnsafe().storeFence();
               x = b;
            }
        });


        Thread t2 = new Thread(new Runnable() {
            @Override
            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);
        }
    }
}


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

}

二、什么是线程
古代操作系统在运行一个程序时,会为其创立一个过程。例如,启动一个 Java 程序,操作
零碎就会创立一个 Java 过程。古代操作系统调度 CPU 的最小单元是线程,也叫轻量级过程
(Light Weight Process),在一个过程里能够创立多个线程,这些线程都领有各自的计数
器、堆栈和局部变量等属性,并且可能访问共享的内存变量。处理器在这些线程上高速切换,
让使用者感觉到这些线程在同时执行。

线程的实现能够分为两类:1、用户级线程 (User-Level Thread) 2、内核线线程(Kernel-Level Thread) 在了解线程分类之前咱们须要先理解零碎的用户空间与内核空间两个概念,以 4G 大小的内
存空间为例

Linux 为内核代码和数据结构预留了几个页框,这些页永远不会被转出到磁盘上。从
0x00000000 到 0xc0000000(PAGE_OFFSET)的线性地址可由用户代码 和 内核代码进行
援用(即用户空间)。从 0xc0000000(PAGE_OFFSET)到 0xFFFFFFFFF 的线性地址只能由
内核代码进行拜访(即内核空间)。内核代码及其数据结构都必须位于这 1 GB 的地址空间
中,然而对于此地址空间而言,更大的消费者是物理地址的虚构映射。
这意味着在 4 GB 的内存空间中,只有 3 GB 能够用于用户应用程序。一个过程只能运行
在用户形式(usermode)或内核形式(kernelmode)下。用户程序运行在用户形式下,而
零碎调用运行在内核形式下。在这两种形式下所用的堆栈不一样:用户形式下用的是个别的堆
栈,而内核形式下用的是固定大小的堆栈(个别为一个内存页的大小)
每个过程都有本人的 3 G 用户空间,它们共享 1GB 的内核空间。当一个过程从用户空间进
入内核空间时,它就不再有本人的过程空间了。这也就是为什么咱们常常说线程上下文切换会
波及到用户态到内核态的切换起因所在
用户线程:指不须要内核反对而在用户程序中实现的线程,其不依赖于操作系统外围,应
用过程利用线程库提供创立、同步、调度和治理线程的函数来管制用户线程。另外,用户线程
是由利用过程利用线程库创立和治理,不依赖于操作系统外围。不须要用户态 / 外围态切换,
速度快。操作系统内核不晓得多线程的存在,因而一个线程阻塞将使得整个过程(包含它的所
有线程)阻塞。因为这里的处理器工夫片调配是以过程为根本单位,所以每个线程执行的工夫
绝对缩小。
内核线程:线程的所有治理操作都是由操作系统内核实现的。内核保留线程的状态和高低
文信息,当一个线程执行了引起阻塞的零碎调用时,内核能够调度该过程的其余线程执行。在
多处理器零碎上,内核能够分派属于同一过程的多个线程在多个处理器上运行,进步过程执行
的并行度。因为须要内核实现线程的创立、调度和治理,所以和用户级线程相比这些操作要慢
得多,然而依然比过程的创立和治理操作要快。大多数市场上的操作系统,如 Windows,
Linux 等都反对内核级线程。

原理区别如下图所示

Java 线程与零碎内核线程关系

Java 线程
JVM 中创立线程有 2 种形式 1. new java.lang.Thread().start()2. 应用 JNI 将一个 native thread attach 到 JVM 中
针对 new java.lang.Thread().start()这种形式,只有调用 start()办法的时候,才会真正的在
JVM 中去创立线程,次要的生命周期步骤有:

  1. 创立对应的 JavaThread 的 instance
  2. 创立对应的 OSThread 的 instance
  3. 创立理论的底层操作系统的 native thread
  4. 筹备相应的 JVM 状态,比方 ThreadLocal 存储空间调配等
  5. 底层的 native thread 开始运行,调用 java.lang.Thread 生成的 Object 的 run()办法
  6. 当 java.lang.Thread 生成的 Object 的 run()办法执行结束返回后, 或者抛出异样终止后,
    终止 native thread
  7. 开释 JVM 相干的 thread 的资源,革除对应的 JavaThread 和 OSThread

针对 JNI 将一个 native thread attach 到 JVM 中,次要的步骤有:

  1. 通过 JNI call AttachCurrentThread 申请连贯到执行的 JVM 实例
  2. JVM 创立相应的 JavaThread 和 OSThread 对象
  3. 创立相应的 java.lang.Thread 的对象
  4. 一旦 java.lang.Thread 的 Object 创立之后,JNI 就能够调用 Java 代码了
  5. 当通过 JNI call DetachCurrentThread 之后,JNI 就从 JVM 实例中断开连接
  6. JVM 革除相应的 JavaThread, OSThread, java.lang.Thread 对象

Java 线程的生命周期:

三、为什么用到并发?并发会产生什么问题?
1、为什么用到并发
并发编程的实质其实就是利用多线程技术,在古代多核的 CPU 的背景下,催生了并发编程
的趋势,通过并发编程的模式能够将多核 CPU 的计算能力施展到极致,性能失去晋升。除此之
外,面对简单业务模型,并行程序会比串行程序更适应业务需要,而并发编程更能吻合这种业
务拆分。
即便是单核处理器也反对多线程执行代码,CPU 通过给每个线程调配 CPU 工夫片来实现 这个机制。工夫片是 CPU 调配给各个线程的工夫,因为工夫片十分短,所以 CPU 通过不停地切
换线程执行,让咱们感觉多个线程是同时执行的,工夫片个别是几十毫秒(ms)。

并发不等于并行:并发指的是多个工作交替进行,而并行则是指真正意义上的“同时进 行”。实际上,如果零碎内只有一个 CPU,而应用多线程时,那么实在零碎环境下不能并行,
只能通过切换工夫片的形式交替进行,而成为并发执行工作。真正的并行也只能呈现在领有多
个 CPU 的零碎中。
并发的长处:

  1. 充分利用多核 CPU 的计算能力;2. 不便进行业务拆分,晋升利用性能;
    并发产生的问题:

高并发场景下,导致频繁的上下文切换
临界区线程平安问题,容易呈现死锁的,产生死锁就会造成零碎性能不可用
其它

CPU 通过工夫片调配算法来循环执行工作,当前任务执行一个工夫片后会切换到下一个
工作。然而,在切换前会保留上一个工作的状态,以便下次切换回这个工作时,能够再加载这
个工作的状态。所以工作从保留到再加载的过程就是一次上下文切换。

线程上下文切换过程:

一、什么是 JMM 模型?
Java 内存模型 (Java Memory Model 简称 JMM) 是一种形象的概念,并不实在存在,它描
述的是一组规定或标准,通过这组标准定义了程序中各个变量(包含实例字段,动态字段和构
成数组对象的元素)的拜访形式。JVM 运行程序的实体是线程,而每个线程创立时 JVM 都会为
其创立一个工作内存 (有些中央称为栈空间),用于存储线程公有的数据,而 Java 内存模型中规
定所有变量都存储在主内存,主内存是共享内存区域,所有线程都能够拜访,但线程对变量的
操作 (读取赋值等) 必须在工作内存中进行,首先要将变量从主内存拷贝的本人的工作内存空
间,而后对变量进行操作,操作实现后再将变量写回主内存,不能间接操作主内存中的变量,
工作内存中存储着主内存中的变量正本拷贝,后面说过,工作内存是每个线程的公有数据区
域,因而不同的线程间无法访问对方的工作内存,线程间的通信 (传值) 必须通过主内存来完 成。

JMM 不同于 JVM 内存区域模型
JMM 与 JVM 内存区域的划分是不同的概念档次,更失当说 JMM 形容的是一组规定,通过
这组规定控制程序中各个变量在共享数据区域和公有数据区域的拜访形式,JMM 是围绕原子
性,有序性、可见性开展。JMM 与 Java 内存区域惟一类似点,都存在共享数据区域和公有数
据区域,在 JMM 中主内存属于共享数据区域,从某个水平上讲应该包含了堆和办法区,而工作
内存数据线程公有数据区域,从某个水平上讲则应该包含程序计数器、虚拟机栈以及本地办法
栈。

线程,工作内存,主内存工作交互图(基于 JMM 标准):

主内存
次要存储的是 Java 实例对象,所有线程创立的实例对象都寄存在主内存中,不论该实例对
象是成员变量还是办法中的本地变量 (也称局部变量),当然也包含了共享的类信息、常量、静
态变量。因为是共享数据区域,多条线程对同一个变量进行拜访可能会产生线程平安问题。

工作内存
次要存储以后办法的所有本地变量信息 (工作内存中存储着主内存中的变量正本拷贝),每
个线程只能拜访本人的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线
程执行的是同一段代码,它们也会各自在本人的工作内存中创立属于以后线程的本地变量,当
然也包含了字节码行号指示器、相干 Native 办法的信息。留神因为工作内存是每个线程的公有
数据,线程间无奈互相拜访工作内存,因而存储在工作内存的数据不存在线程平安问题。

依据 JVM 虚拟机标准主内存与工作内存的数据存储类型以及操作形式,对于一个实例对象
中的成员办法而言,如果办法中蕴含本地变量是根本数据类型
(boolean,byte,short,char,int,long,float,double),将间接存储在工作内存的帧栈构造中,
但假使本地变量是援用类型,那么该变量的援用会存储在性能内存的帧栈中,而对象实例将存
储在主内存 (共享数据区域,堆) 中。但对于实例对象的成员变量,不论它是根本数据类型或者
包装类型 (Integer、Double 等) 还是援用类型,都会被存储到堆区。至于 static 变量以及类自身
相干信息将会存储在主内存中。须要留神的是,在主内存中的实例对象能够被多线程共享,倘
若两个线程同时调用了同一个对象的同一个办法,那么两条线程会将要操作的数据拷贝一份到
本人的工作内存中,执行实现操作后才刷新到主内存


通过对后面的硬件内存架构、Java 内存模型以及 Java 多线程的实现原理的理解,咱们应该
曾经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但 Java 内存模型和硬件内
存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作
内存 (线程公有数据区域) 和主内存 (堆内存) 之分,也就是说 Java 内存模型对内存的划分对硬件内
存并没有任何影响,因为 JMM 只是一种形象的概念,是一组规定,并不理论存在,不论是工作
内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可
能存储到 CPU 缓存或者寄存器中,因而总体上来说,Java 内存模型和计算机硬件内存架构是一
个互相穿插的关系,是一种抽象概念划分与实在物理硬件的穿插。(留神对于 Java 内存区域划分
也是同样的情理)

在明确了 Java 内存区域划分、硬件内存架构、Java 多线程的实现原理与 Java 内存模型的具
体关系后,接着来谈谈 Java 内存模型存在的必要性。因为 JVM 运行程序的实体是线程,而每个
线程创立时 JVM 都会为其创立一个工作内存 (有些中央称为栈空间),用于存储线程公有的数
据,线程与主内存中的变量操作必须通过工作内存间接实现,次要过程是将变量从主内存拷贝
的每个线程各自的工作内存空间,而后对变量进行操作,操作实现后再将变量写回主内存,如
果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程平安问题。
假如主内存中存在一个共享变量 x,当初有 A 和 B 两条线程别离对该变量 x = 1 进行操作,A/ B 线程各自的工作内存中存在共享变量正本 x。假如当初 A 线程想要批改 x 的值为 2,而 B 线程
却想要读取 x 的值,那么 B 线程读取到的值是 A 线程更新后的值 2 还是更新前的值 1 呢?答案是,
不确定,即 B 线程有可能读取到 A 线程更新前的值 1,也有可能读取到 A 线程更新后的值 2,这是
因为工作内存是每个线程公有的数据区域,而线程 A 变量 x 时,首先是将变量从主内存拷贝到 A
线程的工作内存中,而后对变量进行操作,操作实现后再将变量 x 写回主内,而对于 B 线程的也
是相似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,如果 A 线程批改完后
正在将数据写回主内存,而 B 线程此时正在读取主内存,行将 x = 1 拷贝到本人的工作内存中,
这样 B 线程读取到的值就是 x =1,但如果 A 线程已将 x = 2 写回主内存后,B 线程才开始读取的
话,那么此时 B 线程读取到的就是 x =2,但到底是哪种状况先产生呢?

以上对于主内存与工作内存之间的具体交互协定,即一个变量如何从主内存拷贝到工作内
存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下八种操作来完
成。JMM- 同步八种操作介绍(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,开释后的
变量才能够被其余线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,
以便随后的 load 动作应用
(4)load(载入):作用于工作内存的变量,它把 read 操作从主内存中失去的变量值放入工作
内存的变量正本中
(5)use(应用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接管到的值赋给工作内存
的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,
以便随后的 write 的操作
(8)write(写入):作用于工作内存的变量,它把 store 操作从工作内存中的一个变量的值传送
到主内存的变量中

如果要把一个变量从主内存中复制到工作内存中,就须要按程序地执行 read 和 load 操作,
如果把变量从工作内存中同步到主内存中,就须要按程序地执行 store 和 write 操作。但 Java 内
存模型只要求上述操作必须按程序执行,而没有保障必须是间断执行。

同步规定剖析
1)不容许一个线程无起因地(没有产生过任何 assign 操作)把数据从工作内存同步回主内存

2)一个新的变量只能在主内存中诞生,不容许在工作内存中间接应用一个未被初始化(load
或者 assign)的变量。即就是对一个变量施行 use 和 store 操作之前,必须先自行 assign 和 load
操作。
3)一个变量在同一时刻只容许一条线程对其进行 lock 操作,但 lock 操作能够被同一线程反复
执行屡次,屡次执行 lock 后,只有执行雷同次数的 unlock 操作,变量才会被解锁。lock 和
unlock 必须成对呈现。4)如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎应用这个变
量之前须要从新执行 load 或 assign 操作初始化变量的值。
5)如果一个变量当时没有被 lock 操作锁定,则不容许对它执行 unlock 操作;也不容许去 unlock 一个被其余线程锁定的变量。
6)对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操
作)

并发编程的可见性,原子性与有序性问题

原子性 原子性指的是一个操作是不可中断的,即便是在多线程环境下,一个操作一旦开始就不会
被其余线程影响。
在 java 中,对根本数据类型的变量的读取和赋值操作是原子性操作有点要留神的是,对于
32 位零碎的来说,long 类型数据和 double 类型数据(对于根本数据类型,
byte,short,int,float,boolean,char 读写是原子操作),它们的读写并非原子性的,也就是说如
果存在两条线程同时对 long 类型或者 double 类型的数据进行读写是存在互相烦扰的,因为对
于 32 位虚拟机来说,每次原子读写是 32 位的,而 long 和 double 则是 64 位的存储单元,这样会
导致一个线程在写时,操作完前 32 位的原子操作后,轮到 B 线程读取时,恰好只读取到了后 32
位的数据,这样可能会读取到一个既非原值又不是线程批改值的变量,它可能是“半个变
量”的数值,即 64 位数据被两个线程分成了两次读取。但也不用太放心,因为读取到“半个变
量”的状况比拟少见,至多在目前的商用的虚拟机中,简直都把 64 位的数据的读写操作作为原
子操作来执行,因而对于这个问题不用太在意,晓得这么回事即可。
X=10; // 原子性(简略的读取、将数字赋值给变量)
Y = x; // 变量之间的互相赋值,不是原子操作
X++; // 对变量进行计算操作 X = x+1;
可见性 了解了指令重排景象后,可见性容易了,可见性指的是当一个线程批改了某个共享变量的
值,其余线程是否可能马上得悉这个批改的值。对于串行程序来说,可见性是不存在的,因为
咱们在任何一个操作中批改了某个变量的值,后续的操作中都能读取这个变量值,并且是批改
过的新值。

但在多线程环境中可就不肯定了,后面咱们剖析过,因为线程对共享变量的操作都是线程
拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程 A 批改了共享
变量 x 的值,还未写回主内存时,另外一个线程 B 又对主内存中同一个共享变量 x 进行操作,但
此时 A 线程工作内存中共享变量 x 对线程 B 来说并不可见,这种工作内存与主内存同步提早景象
就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过后面的分
析,咱们晓得无论是编译器优化还是处理器优化的重排景象,在多线程环境下,的确会导致程
序轮序执行的问题,从而也就导致可见性问题。
有序性 有序性是指对于单线程的执行代码,咱们总是认为代码的执行是按程序顺次执行的,这样
的了解并没有故障,毕竟对于单线程而言的确如此,但对于多线程环境,则可能呈现乱序现
象,因为程序编译成机器码指令后可能会呈现指令重排景象,重排后的指令与原指令的程序未
必统一,要明确的是,在 Java 程序中,假使在本线程内,所有操作都视为有序行为,如果是多
线程环境下,一个线程中察看另外一个线程,所有操作都是无序的,前半句指的是单线程内保
证串行语义执行的一致性,后半句则指指令重排景象和工作内存与主内存同步提早景象。

JMM 如何解决原子性 & 可见性 & 有序性问题

除了 JVM 本身提供的对根本数据类型读写操作的原子性外,能够通过 synchronized 和 Lock 实现原子性。因为 synchronized 和 Lock 可能保障任一时刻只有一个线程拜访该代码块。
可见性问题
volatile 关键字保障可见性。当一个共享变量被 volatile 润饰时,它会保障批改的值立刻被
其余的线程看到,即批改的值立刻更新到主存中,当其余线程须要读取时,它会去内存中读取
新值。synchronized 和 Lock 也能够保障可见性,因为它们能够保障任一时刻只有一个线程能
访问共享资源,并在其开释锁之前将批改的变量刷新到内存中。
有序性问题
在 Java 外面,能够通过 volatile 关键字来保障肯定的“有序性”(具体原理在下一节讲述 volatile 关键字)。另外能够通过 synchronized 和 Lock 来保障有序性,很显然,synchronized
和 Lock 保障每个时刻是有一个线程执行同步代码,相当于是让线程程序执行同步代码,天然就
保障了有序性。
Java 内存模型:每个线程都有本人的工作内存(相似于后面的高速缓存)。线程对变量的
所有操作都必须在工作内存中进行,而不能间接对主存进行操作。并且每个线程不能拜访其余
线程的工作内存。Java 内存模型具备一些先天的“有序性”,即不须要通过任何伎俩就可能得
到保障的有序性,这个通常也称为 happens-before 准则。如果两个操作的执行秩序无奈从
happens-before 准则推导进去,那么它们就不能保障它们的有序性,虚拟机能够随便地对它
们进行重排序。
指令重排序:java 语言标准规定 JVM 线程外部维持程序化语义。即只有程序的最终后果与
它程序化状况的后果相等,那么指令的执行程序能够与代码程序不统一,此过程叫指令的重排
序。指令重排序的意义是什么?JVM 能依据处理器个性(CPU 多级缓存零碎、多核处理器等)
适当的对机器指令进行重排序,使机器指令能更合乎 CPU 的执行个性,最大限度的施展机器性
能。

as-if-serial 语义 as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了进步并行度),(复线
程)程序的执行后果不能被扭转。编译器、runtime 和处理器都必须恪守 as-if-serial 语义。

为了恪守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因
为这种重排序会扭转执行后果。然而,如果操作之间不存在数据依赖关系,这些操作就可能被
编译器和处理器重排序。

happens-before 准则
只靠 sychronized 和 volatile 关键字来保障原子性、可见性以及有序性,那么编写并发程序
可能会显得非常麻烦,侥幸的是,从 JDK 5 开始,Java 应用新的 JSR-133 内存模型,提供了
happens-before 准则来辅助保障程序执行的原子性、可见性以及有序性的问题,它是判断数
据是否存在竞争、线程是否平安的根据,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 可见。
  6. 线程中断规定 对线程 interrupt()办法的调用后行产生于被中断线程的代码检测到中
    断事件的产生,能够通过 Thread.interrupted()办法检测线程是否中断。
  7. 对象终结规定 对象的构造函数执行,完结先于 finalize()办法


样例:

package com.jiagouedu.jmm;

public class VolatileVisibilitySample {

private volatile boolean initFlag = false;
public void save() {
    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) {

// i++ ;

    }
    System.out.println("线程:"+threadname+"以后线程嗅探到 initFlag 的状 态的扭转");
}


public static void main(String[] args){VolatileVisibilitySample sample = new VolatileVisibilitySample();
    Thread threadA = new Thread(()->{ sample.save(); },"threadA");
    Thread threadB = new Thread(()->{ sample.load(); },"threadB");
    threadB.start();
    try {Thread.sleep(1000);
    } catch (InterruptedException e) {e.printStackTrace();
    }threadA.start();}

}
线程 A 扭转 initFlag 属性之后,线程 B 马上感知到(去除则不能感知到)
volatile 无奈保障原子性

package com.jiagouedu.jmm;

public class VolatileAtomicSample {

private static volatile int counter = 0;


public static void main(String[] args) {for(int i = 0; i < 10; i++) {Thread thread = new Thread(() -> {for(int j = 0; j < 1000; j++) {counter++;}
        });
        thread.start();}


    try {Thread.sleep(1000);
    } catch (Exception e) {e.printStackTrace();
    }
    System.out.println(counter);
}

}

起因在于:mesi 模式下,多个线程获取到 i 值时,因为先进行 ++ 操作的数据,回显到主内存时,其余线程所获取到的值,此时标记为有效状态(解决办法:应用 synchronize 和 volatile)
在并发场景下,i 变量的任何扭转都会立马反馈到其余线程中,然而如此存在多条线程同时
调用 increase()办法的话,就会呈现线程平安问题,毕竟 i ++; 操作并不具备原子性,该操作是
先读取值,而后写回一个新值,相当于原来的值加上 1,分两步实现,如果第二个线程在第一
个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一
个值,并执行雷同值的加 1 操作,这也就造成了线程平安失败,因而对于 increase 办法必须使
用 synchronized 润饰,以便保障线程平安,须要留神的是一旦应用 synchronized 润饰办法
后,因为 synchronized 自身也具备与 volatile 雷同的个性,即可见性,因而在这样种状况下就
齐全能够省去 volatile 润饰变量。

volatile 禁止重排优化
volatile 关键字另一个作用就是禁止指令重排优化,从而防止多线程环境下程序呈现乱序
执行的景象,对于指令重排优化后面已详细分析过,这里次要简略阐明一下 volatile 是如何实
现禁止指令重排优化的。先理解一个概念,内存屏障(Memory Barrier)。



上图中 StoreStore 屏障能够保障在 volatile 写之前,其后面的所有一般写操作曾经对任意
处理器可见了。这是因为 StoreStore 屏障将保障下面所有的一般写在 volatile 写之前刷新到主
内存。
这里比拟有意思的是,volatile 写前面的 StoreLoad 屏障。此屏障的作用是防止 volatile 写与 前面可能有的 volatile 读 / 写操作重排序。因为编译器经常无奈精确判断在
一个 volatile 写的前面 是否须要插入一个 StoreLoad 屏障(比方,一个 volatile 写之后方
法立刻 return)。为了保障能正确 实现 volatile 的内存语义,JMM 在采取了激进策略:
在每个 volatile 写的前面,或者在每个 volatile 读的后面插入一个 StoreLoad 屏障。从整
体执行效率的角度思考,JMM 最终抉择了在每个 volatile 写的前面插入一个 StoreLoad
屏障。因为 volatile 写 - 读内存语义的常见应用模式是:一个 写线程写 volatile 变量,多
个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,抉择在 volatile 写
之后插入 StoreLoad 屏障将带来可观的执行效率的晋升。从这里能够看到 JMM 在实现上
的一个特点:首先确保正确性,而后再去谋求执行效率。

package com.jiagouedu.jmm;

public class VolatileBarrierExample {

int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
    int i = v1;
    int j = v2;
    a = i + j;
    v1 = i + 1;
    v2 = j * 2;
}

}

留神,最初的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,办法立刻 return。此时编 译器可能无奈精确判定前面是否会有 volatile 读或写,为了平安起见,
编译器通常会在这里插 入一个 StoreLoad 屏障。
下面的优化针对任意处理器平台,因为不同的处理器有不同“松紧度”的处理器内
存模 型,内存屏障的插入还能够依据具体的处理器内存模型持续优化。以 X86 处理器为
例,图 3 -21 中除最初的 StoreLoad 屏障外,其余的屏障都会被省略。

后面激进策略下的 volatile 读和写,在 X86 处理器平台能够优化成如下图所示。前文
提到过,X86 处理器仅会对写 - 读操作做重排序。X86 不会对读 - 读、读 - 写和写 - 写操作
做重排序,因而在 X86 处理器中会省略掉这 3 种操作类型对应的内存屏障。在 X86 中,
JMM 仅需 在 volatile 写前面插入一个 StoreLoad 屏障即可正确实现 volatile 写 - 读的内存
语义。这意味着在 X86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为
执行 StoreLoad 屏障开销会比
较大)。
Volatile 和 CAS 的弊病之总线风暴
总线风暴:
在 java 中应用 unsafe 实现 cas, 而其底层由 cpp 调用汇编指令实现的,如果是多核 cpu 是应用 lock cmpxchg 指令,单核 cpu 应用 compxch 指令。如果在短时间内产生大量的 cas 操作在加上 volatile 的嗅探机制则会一直地占用总线带宽,导致总线流量激增,就会产生总线风暴。总之,就是因为 volatile 和 CAS 的操作导致 BUS 总线缓存一致性流量激增所造成的影响。

正文完
 0