JMM和底层原理

计算机原理

从表中可以看到,内存的读取速度和CPU的执行速度存在数量级上的差距。所以计算机引入缓存,弥补CPU和内存之间的速度差距。
在这里插入图片描述
真实的CPU结构
真实CPU结构中,存在L1、L2、L3缓存。其中L1缓存又分L1指令缓存和L1数据缓存。
在这里插入图片描述
在这里插入图片描述
CPU引入高速缓存带来的问题

看下面实例,A处理器处理的两行代码和B处理器处理的两行代码,x和y的输出结果不是唯一的,有可能时最终得到x=y=0。也可能得到x=2,y=0。还可能是x=0,y=1。这是由于处理器引入高速缓存(L1、L2)带来的问题。每个处理器都有属于自己的高速缓存,里面的数据没有写入到主存前,对其他处理器是不可见的。
在这里插入图片描述

伪共享
CPU读取数据到高速缓存不是单个变量读取,而是读取目标变量所在的整个缓存行。缓存行是高速缓存从主存读取数据的最小单位。一般的,一个缓存行的数据大小为64字节,一个long变量大小8个字节,一个缓存行可以放下8个long型变量。

如下图,线程1要读取x,线程2要读取y,但是因为x、y在主存中的存储地址很近,所以线程1和线程2都读取了x、y两个变量到自己的缓存。这时候,在某些情况下(如使用volatile修饰x),线程1对x值的更新可能会影响到线程2下对数据y的更新效率。反正同理。这被称为“伪共享”。即线程对主存中共享变量的更新可能会影响到其他线程对其他变量的更新。

解决伪共享的问题,可以使用填充法或者使用java8的注解@sun.misc.Contended(需要在jvm启动时设置-XX:-RestrictContended才会生效)。使用填充法本质上是空间换时间,在高性能并发框架distruptor中用到了这种方式(百万级吞吐量)。CurrentHashMap则使用了注解的方式,读取这个注解的类的数据时,会自动补齐缓存行数据,而不会读取其他非目标数据。填充法的效果有点不稳定,原因未知。所以推荐使用注解的方式。
在这里插入图片描述
缓存一致性协议
绝大多数计算机缓存一致性协议指MESI。MESI指缓存行的四种状态(修改、独享、共享、无效)。通过缓存一致性协议可以使得各个CPU的缓存可以间接通信。比如CPU1读取a变量(缓存行为E独享),然后CPU2也读取了变量a,这时候CPU1和CPU2的a所在的缓存行状态为S共享的,如果CPU1把a+1(缓存行变为M),写入内存后,CPU2通过嗅探总线得知CPU1已经修改了a,CPU2会把a所在的缓存行设置为I无效,重新去主存读取a的值,并把a所在的缓存行设为E独享状态。各个CPU都会去嗅探总线,关心其他CPU对主存共享数据的操作。如果发现自己已缓存数据被修改了,就会马上获知,就像多个CPU之间进行了通信一样。

不存在多个CPU同一个时钟周期同时读取共享数据到缓存的情况。硬件有仲裁机制,一个时钟周期只有一个CPU指令可以执行。
在这里插入图片描述

Java内存模型JMM

上面聊了计算机原理,是计算机底层的知识点。接下来聊聊JMM,是属于Java层面的内存模型,可以看成是比计算机原理更高一层的抽象。这两者有联系,也有区别。

Java内存模型给每个Java线程都分配了工作内存,工作内存之间是不可互相访问的。工作内存是一个抽象概念,它包括CPU寄存器、高速缓存和部分主存。
在这里插入图片描述
在这里插入图片描述
JMM带来的并发安全问题

  1. 可见性问题
    假设线程1和线程2都同时读取了count值到自己的工作内存中,当线程2修改count值后,将count新值刷新回自己的工作内存,这时候线程1是不知道count被修改了的。解决可见性问题可以使用volatile关键字或者锁synchronization。
    在这里插入图片描述
  2. 竞争问题
    在可见性问题的基础上,如果这时候线程1再对count+1,线程2将count刷新回主存,根据缓存一致性协议,线程1会重新从主存加载count=2。注意到,其中对count做了两次+1,但是最终结果却是2,这就最终造成了并发安全问题。解决竞争问题可以使用锁synchronization,将count的并行操作变成串行操作。
    在这里插入图片描述
  3. 重排序问题
    Java源代码的最终执行会经过三个重排序过程来优化指令执行顺序。分别为编译器重排序、CPU指令重排序、内存系统重排序。

编译器重排序:编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
CPU指令重排序:现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
在这里插入图片描述
下面列表中不能重排序,如果重排序了,会导致在单线程下程序结果发生意料之外的错误。如果单线程都不正确,多线程下更加不用考虑。所以提出了as-if-serial,它的语义是不管如何重排序,都必须保证代码在单线程下的运行正确。因此,程序员不用担心重排序造成结果错误问题。这是数据依赖性保证的。
在这里插入图片描述

重排序除了考虑数据依赖性,还会考虑控制依赖性。控制依赖性代码如,可以看到3和4存在控制依赖关系。如果是单线程下,3和4做了重排序,并不会影响最终结果的正确,所以允许这种重排序的发生。但是如果多线程下,3和4做了重排序,可能会导致最终结果出现问题。比如线程A对1和2做了重排序,先执行了2,然后线程B执行i=a*a,最后线程A才执行a=1。这个就和单线程下执行结果不一致。因此多线程下,如果控制依赖关系的代码出现重排序,可能导致程序结果错误。
在这里插入图片描述
在这里插入图片描述

内存屏障

如上面控制依赖关系的代码发生重排序时,导致程序发生错误。这时候需要加入内存屏障来禁止重排序,保证某段程序代码的执行顺序,并强制刷出cache数据到主存,保证可见性

四种内存屏障
并不是所有处理器都支持四种内存屏障,如Intel的x86处理器只支持StoreLoad类型的内存屏障。
理解四种重排序很简单,从名字就可以看出来store表示内存可见,load仅表示指令加载先后。

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。
在这里插入图片描述
临界区
在使用synchronization关键字后,加锁临界区插入了内存屏障,即同步代码块前的代码结果对同步代码块内部可见,同步代码块内部代码结果对同步代码块后面代码可见。但是同步代码块内部是可以重排序的。没有加volatile关键字的单例模式就是因为同步代码块内的singleton = new xxx()发生了重排序(这行代码可以简单分为三个步骤,1.new一段内存空间;2.空间初始化;3.将singleton指向这段内存空间),最终导致1、3、2的执行顺序,使得下一个线程拿到了还没有空间初始化的引用,引发空指针异常。
在这里插入图片描述

Happens-Before

在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。

JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变

下面定义来自《深入理解Java虚拟机》

用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)

怎么理解上面定义
上面的定义看起来很矛盾,其实它是站在不同的角度来说的。
1)站在Java程序员的角度来说:JMM保证,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)站在编译器和处理器的角度来说:JMM允许,两个操作之间存在happens-before关系,不要求Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的。

Happen-Before原则:
JMM为我们提供了以下的Happens-Before规则:
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
7 )线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

volatile

volatile语义:
1、可见性:当写一个volatile变量时,会马上把该变量从本地内存刷新到主存。如果其他线程也加载了这个volatile变量,结合缓存一致性协议,会把内存行置为无效,重新去主存读。
2、有序性:可以看出是可见性的原因。因为在对volatile写入操作的前后都会加入内存屏障。
3、原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。所以不是任何场景下,volatile修饰的变量都满足原子性。

volatile写:
在这里插入图片描述
volatile读:
在这里插入图片描述
实现原理

被volatile关键字修饰的变量会存在一个“lock:”的前缀,lock前綴指令實際上相當於一個內存屏障,內存屏障會提供3個功能:
  1)它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
  2)它會強制將對緩存的修改操作立即寫入主存;
  3)如果是寫操作,它會導致其他CPU中對應的緩存行無效。

final

有些类会把所有成员变量用final修饰,并在构造函数内初始化成员变量。为什么要用final修饰呢?除了让成员变量变得不可变外,还涉及到final的内存语义。final同样会插入内存屏障来限制重排序。

final的两个重排序规则
下面以代码举例。
在这里插入图片描述
1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。即拿到对象引用前,其中的final域成员变量 j 必须是已经完成了构造函数内的赋值。

总结:写final域的重排序规则可以确保在对象引用为任意线程可见之前,对象的final域已经被正常的初始化了,而普通域不具有这样的保证。
在这里插入图片描述
2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

总结:读final域的重排序规则可以确保在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
在这里插入图片描述
final域的语义实现

1、会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。

2、读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。

自定义类时,如果成员变量不涉及修改操作,用final修饰是良好的编程习惯。

锁的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

总结:释放锁前,会把同步代码块内的共享变量刷新到缓存。获取锁时,同步代码块内的共享变量的值都是最新的。

synchronization的实现原理
Synchronized通过成对的MonitorEnter和MonitorExit指令来实现。当执行MonitorEnter指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁。synchronized使用的锁是存放在Java对象头里面的MarkWord里。
在这里插入图片描述

了解各种锁

自旋锁

通过有限的自旋操作等待其他持有锁的线程释放锁,避免线程挂起导致内核态和用户态之间的切换。但是自旋本质是让CPU做无用功,所以自旋一段时间后,如果还没有获取锁,就会使得线程进入阻塞状态。

自旋锁适合竞争不激烈的资源。如果资源竞争激烈,会导致大量线程在自旋做无用功,这样反而降低效率。

自旋锁从jdk1.7后由jvm自动控制开启。

锁的四种状态以及它们之间的转换过程

一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

  1. 无锁状态:这个最容易理解,某个竞争资源没有任何线程访问的时候,该资源处于无锁状态。
  2. 偏向锁:某个竞争资源被第一个线程访问时,进入偏向锁状态,记录线程ID,是否是偏向锁设置为1。
  3. 轻量级锁:当有第二个线程对已持有偏向锁的资源尝试使用CAS获取锁时,会失败,该资源的锁状态会从偏向锁升级为轻量级锁状态。撤销偏向锁线程ID,设置是否是偏向锁状态为0。所有线程重新开始竞争该资源,对竞争成功的线程,修改对象头相关信息,表示自己拥有这把锁。其他试图争夺这把锁的线程会自旋尝试获取锁。
  4. 当其他线程自旋一定次数仍然未获取锁时,锁升级为重量级锁。此时未获取锁的线程会进入挂起等待状态,等待已获取锁的线程在释放锁时,唤醒等待的线程重新争夺锁。

总结:偏向锁表示当前只有一个线程在使用竞争资源,此时不存在竞争线程,就和单线程效果一样。此时如果一旦由其他线程竞争锁,偏向锁马上升级为轻量级锁。轻量级锁争夺的过程存在自旋操作,自旋一定次数仍然失败后,锁升级为重量级锁。
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值