java并发的两个核心问题:
1:线程间如何通信
2:线程间如何同步
1:通行通过共享变量,Java内存模型
2:同步是指不同线程不同操作的相对操作顺序。java层面:happen-before。cpu层面:内存屏障。
三大性质分别是原子性、可见性、有序性。(硬件层面)
可见性通过内存屏障实现。
因为java跨平台,所以需要一个统一的简单的规范,就是happen-beore。同时又要根据不同平台采用不同的实现
内存模型抽象结构:
线程-工作内存-主存。
JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,也就是在虚拟机中将共享变量存储到内存以及从内存中取出共享变量的底层细节。
不同架构的物理计算机可以有不一样的内存模型,Java虚拟机也有自己的内存模型。Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,简称JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。
通过这些规则来规范对内存的读写操作从而保证指令的正确性。需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。
java程序员不可能直接去控制底层线程对寄存器高速缓存内存之间的同步,那么java从语法层面,应该给开发人员提供一种解决方案,比如voldatile、happen-before。
重排序:
为了提高效率采取的优化。
包括:
1:编译器重排序:编译的时候重排序
2:指令级并行重排序:cpu对指令对重排序
3:内存重排序:由于缓存的问题(实际上是store buffer),读取还未刷新的主内存等情况。
上述对单线程不影响,没有共享变量的多线程也不影响,但是影响存在共享变量的多线程。
由于存在重排序,所以后面的代码B可能被排在A前面,影响了A的可见性,java通过禁止重排序(happen-before)实现内存可见性,即前面的结果对后面可见
禁止重排序-同步:
JMM在生成指令序列的适当位置插入内存屏障来禁止处理器重排序
一致性
缓存一致性是操作系统应该考虑的,比如MESI
内存一致性是应用应该考虑的。(说的是内存访问顺序,同步问题)
缓存一致性的两种方式:
1:加锁:强一致性,内存总线加锁(耗资源,已被淘汰)
2:缓存一致性协议:最终一致性,每个处理器通过嗅探总线判断自己的数据是否过期。当处理器发现自己缓存行的数据被修改时会将数据置为无效。当使用时发现无效就会从主内存从新读取。MESI
内存一致性:通过内存屏障一致性保证访问顺序不被重排序。
MESI保证三层缓存与【内存间】的相关性,则内存屏障只需要保证store buffer(可以认为是寄存器与L1 Cache间的一层缓存)与L1 Cache间的相干性。
LoadBuffer、StoreBuffer
用来优化类似MESI的阻塞,因为在MESI协议下某CPU改变共享变量时,需要发送消息给其他CPU并得到回应,在此期间某CPU就属于等待状态,这是不允许的,所以需要先将数据存储在另一个地方,然后去执行其他命令。
但同时带来了内存重排序的问题,导致可见性问题。处理器已然不知道什么时候优化是允许的,而什么时候并不允许。干脆处理器将这个任务丢给了写代码的人。因此JVM通过封装CPU提供的内存屏障,实现自己的内存屏障,实现部分代码顺序执行(同步),在java中就是happen-before。
内存屏障:
解决由于LoadBuffer、StoreBuffer带来的缓存不一致性
内存屏障:一组CPU指令,仅在硬件级别解决重排序问题。主要用于在多核cpu的情形下可以强制同步cpu中缓存不一致的情况。
1:防止指令之间的重排序:通过强制使内存屏障之前的(部分或全班)代码先执行。
2:实现可见性:通过把store buffer中的修改写到缓存和主内存中。
这里理解顺序很重要。
JMM内存访问协议新描述(JSR-133)
JVM定义了4种自己定义的JMM内存屏障,然后根据不同平台进行不同的优化、调用不同CPU提供的原生的内存屏障(x86提供了读屏障和写屏障还有full屏障,需要以此为demo了解一下)。(可以查看反编译代码,同时Intel 64/IA-32架构下写操作之间不会发生重排序StoreStore会被省略,这种架构下也不会对逻辑上有先后依赖关系的操作进行重排序,所以LoadLoad也会变省略。所以看不到)
JMM内存访问协议旧描述
JMM内存模型8大原子操作+volatile,8大原子操作会根据volatile采取对内存操作的限制,比如说对绑定read、load、use为一个连续操作,这样就限制了volatile修饰对变量在每次use之前都要从内存中读取数据。其实就是上面说对内存屏障,只是换了一种【描述】。
上述是Java内存模型的访问协议的描述,本质没有变,只是描述变了。所以无需纠结有没有这样的代码,只要都是一样的访问协议就可以了。PS:通过两者都说过64位的数据类型可能不同步也应该猜到他们可能是一回事。
另外JAVA并没有将这些方法开放给开发人员,而是提供了happen-before规则,只要符合这个规则,就能实现可见性,大大提高了开发人员的效率。实际上每个规则下,JAVA编译器都插入了JVM提供的内存屏障。
happen-before
是一种协议,只要符合该协议,就能实现禁止重排序和可见性。
4大与程序员相关的规则(顺序规则、锁规则、volatile规则、传递性规则)
8大自身规则
为了限制多线程并发,同时给予底层尽可能的自由。
volatile的内存语义:
写:改完就写到主内存,原子性。
读:会读取最新的指,原子性。
关于volatile的先写后读,可以这么理解,银行问题:当一个线程存钱,一个线程取钱。计算完后,某个获取钱的操作一定是在另一个读取数额之前完成。除非在对钱操作这部分代码没有多线程。否则先读取数额,暂停,另一个线程正常执行,然后第一个线程操作完数额再写回去,第二个线程就相当于没执行
可见性的规范实现:针对任意平台
通过Load、Store搭配的内存屏障实现禁止重排序。
volatile(Java)
说法1:在每个volatile写操作前插入StoreStore屏障,这样就能让其他线程修改A变量后,把修改的值对当前线程可见,在写操作后插入StoreLoad屏障,这样就能让其他线程获取A变量的时候,能够获取到已经被当前线程修改的值
在每个volatile读操作前插入LoadLoad屏障,这样就能让当前线程获取A变量的时候,保证其他线程也都能获取到相同的值,这样所有的线程读取的数据就一样了,在读操作后插入LoadStore屏障;这样就能让当前线程在其他线程修改A变量的值之前,获取到主内存里面A变量的的值。
是基于JMM内存模型的。
说法2:编译成汇编代码后插入伪内存屏障Lock。是因为lock指令实现了内存屏障的功能,但是是根据汇编语言得出的,是基于平台的。是平台对JMM优化后的结果。
统一的目的:
1:将当前处理器缓存行写回主内存。
2:使其他CPU里缓存了该内存地址的数据无效
volatile只保证了可见性,所以Volatile适合直接赋值或读取的场景。如i++则不适合。因为i++操作时,分为Load、Increment、Store、Memory Barriers四个步骤,即装载、新增、存储和内存屏障四个步骤,第四步则是保证jvm让最新的变量值在所有线程可见,但从Load、Increment、到Store是不安全的,中间如果其他的CPU线程修改值将会存在问题。
另外关于happen-before,volatile是在编译器重排序阶段和指令重排序阶段,禁止【单线程】在内存屏障前后重排序,然后通过内存可见性禁止内存重排序。
JAVA内存间交互操作
上面很多其实是底层实现,而JMM内存模型只有工作内存和主内存。所以JMM内存模型提供的8个原子操作,是一种封装
PS:
需要重点强调的是,Thread.sleep 和 Thread.yield 都没有任何同步语义。特别是,编 译器不需要在 sleep 或 yield 调用之前将寄存器中缓存的写操作回写到共享主存,也 不需要在 sleep 或 yield 调用之后重新加载缓存在寄存器里的值。例如,下面(存在 问题的)的代码段中,假设 this.done 是一个非 volatile boolean 字段:
while (!this.done)
Thread.sleep(1000);
编译器可以只读取 this.done 字段一次,然后在每次循环中重用缓存起来的值。这 意味着即使有其他线程改变了 this.done 的值,循环也可能永远不会停止。
问题
如果我们的缓存总是保证一致性,那么为什么我们在写并发程序时要担心可见性?