加州大学圣地亚哥分校(美国)校训:“愿知识之光普照大地。”
夏季,收获的季节,可看着A股大盘一直趴着,又是徒劳了半年,不免稍有伤神。罢了罢了,聊聊可爱的技术吧,这东西才是养家糊口的根本。这年头,任何技能必须具有强大的变现能力,如果NO,请换道。可能很多朋友们不认同,俗话说:三百六十行行行出状元。OK,比如送外卖的小哥,送出刘翔的速度,送出吉尼斯纪录,也是肉眼可见的天花板吧。哈哈,上述纯扯淡,正事开干。
这篇文章我不想简单地告诉你volatile的语义作用,JMM如何保证可见性等,咱们其实可以系统聊聊,前世今生还是很有必要的。那就从硬件系统架构/多核CPU主存可见性/JVM的底层实现讲讲,来龙去脉了解清楚了,那才叫真的理解,而不是简单地死记硬背,这才算是科学地学习。
一 硬件系统架构演变
我们知道,运行在计算机上的程序,指令是由CPU执行的,数据是存储在主存中的,CPU从主存中读取数据执行指令,再回写到主存中。CPU执行指令的速度是非常快的,但读取写入主存相对较慢的,有人拖后腿了,怎么办呢?大家有没有听说过CPU高速缓存,就是来解决拖后腿问题的。
1/CPU高速缓存
CPU高速缓存为单个CPU所有,只有运行在这个CPU上的线程才能访问。缓存系统是以缓存行为单位存储的,一般是64个字节。按级分为L1 cache / L2 cache / 多核心共享L3 cache。执行流程如下:
a/首先CPU使用自己的寄存器,然后使用速度更快的L1,其中L1D缓存数据,L1I缓存指令;
b/L1缓存和次快的L2做数据同步,L2缓存和L3做数据同步;
c/L3为多个CPU共享的,与主内存做数据同步;
2/缓存写入主存
a/直写(write-through)
直写是透过本级缓存,直接把数据写到下一级缓存中,或直接写到主存中,同时更新缓存中的数据,缓存行永远和它对应的内存内容相匹配。
b/回写(write-back)
缓存并不会立即把写操作传递到下一级,而是仅修改本级缓存中的数据,并且把对应的缓存数据标记为脏数据,脏数据会触发回写,即把里面的内容写到对应的内存或下一级缓存中,回写后,脏数据就变干净了。
3/CPU缓存一致性方案
a/通过在总线上加LOCK#锁的方式
这是一种独占式的方式,在同一时刻只能运行一个CPU,效率较为低下;
b/通过缓存一致性协议,保证多核CPU对共享数据的可见性,主要有:
窥探技术:
所有内存传输都发生在一条共享的总线上,对所有CPU可见;每个CPU不停地窥探总线上发生的数据交换,并追踪其他缓存在做什么。缓存是CPU独享的,内存是CPU共享的,缓存访问内存是需要仲裁的,即在同一个指令周期中,只有一个缓存可以读写内存。
MESI协议:是缓存行四种状态的缩写,如下
已修改缓存行(Modified),该缓存行已经被所属的CPU修改了,其他CPU持有的该缓存行的拷贝也会变成失效状态;
独占缓存行(Exclusive),和主存内容保持一致的拷贝,其他CPU持有的这份内容的拷贝变成失效状态;
共享缓存行(Shared),和主存内容保持一致的拷贝,其他CPU也可持有拷贝,但只能读取,不允许写入;
无效缓存行(Invalid),CPU中的缓存行无效了;
总结来看,只有某个CPU独占了这个缓存行,才能够写入,即处于M或E的状态;当CPU想读取该缓存行,该缓存行必须是共享状态。
二 指令重排序
我们知道,CPU在执行指令的时候为了提升性能,会有一定的指令重排。执行结果与预期结果一致,则重排一定是基于规则,volatile能够提供一定的有序性,禁止一定的指令重排。这里介绍下不同级别的重排序,如下:
1/编译器优化的重排序
编译器在不改变单线程程序的语义前提下,可重新安排语句的执行顺序;
2/指令级并行的重排序
如果不存在数据依赖性,处理器可以改变指令的执行顺序,采用的是指令级并行技术,将多条指令重叠执行;
3/内存系统的重排序
由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去是乱序执行。
小结:就Java程序而言,从java代码到CPU执行序列,也要经过上述的重排序。分析来看,重排序会造成内存可见性问题。要想解决问题,需要了解重排序遵循的准则,才能找到对应的方案。
这里介绍下指令重排中单线程和多线程遵循的准则:
1/as-if-serial
只针对于单线程运行的程序,不管怎么怎么重排序,都不会改变执行结果。就是这么硬气,编译器/运行时和处理器重排序必须遵循。这里通俗理解下:主要是对指令之间数据具有依赖性禁止重排序。满足as-if-serial基准,单线程程序看起来像是按顺序执行的,避免了内存可见性问题。
2/happen-before
happen-before原则是Java内存模型对指令重排的约束,如A happen-before B,则A 操作的结果将对B可见,但实际中A 的执行顺序未必在B之前,只要执行结果与预期一致即可。JMM基于happen-before 原则保证多线程的内存可见性,具体准则如下:
a/程序顺序规则:一个线程中的每个操作,happen-before于该线程中任意后续的操作;
b/监视器锁规则:对一个监视器的解锁,happen-before于随后对这个监视器的加锁;
c/传递性:if A happen-before B,B happen-before C,则A happen-before C;
d/线程的start方法happen-before于线程的后续所有操作;
e/线程上的所有操作happen-before于其他线程在该线程上join返回成功后的操作;
f/volatile变量规则:对一个volatile域的写,happen-before于任意后续对这个域的读。
三 volatile原理
1/基本语义
volatile用来修饰变量或对象,有两层语义:
a/保证可见性,但不保证原子性;
b/提供一定程度的有序性(happen-before),禁止指令重排序。
2/原理剖析
jvm是通过内存屏障来实现volatile的,内存屏障有四种类型,如下:
a/LoadLoad:指令形如Load1;LoadLoad;Load2,其语义是Load1的装载要先于Load2的装载;
b/LoadStore:指令形如Load1;LoadStore;Store2,其语义是Load1的装载要先于Store2存储指令刷新到内存;
c/StoreLoad:指令形如Store1;StoreLoad;Load2,其语义是Store1存储指令刷新到内存要先于Load2的装载;
d/StoreStore:指令形如Store1;StoreStore;Store2,其语义是Store1存储指令刷新到内存要先于Store2的存储;
举例,以两个操作读写为例看下插入的内存屏障,如下:
操作 | 普通读 | 普通写 | volatile读 | volatile写 |
---|---|---|---|---|
普通读 | LoadStore | |||
普通写 | StoreStore | |||
volatile读 | LoadLoad | LoadStore | LoadLoad | LoadStore |
volatile写 | StoreLoad | StoreStore |
demo世界不孤单,请阅:
/**
* @author 阿伦故事
* @Description:测试volatile的作用
* 对比去掉全局变量num的volatile的修饰看下测试结果
* */
@Slf4j
public class VolatileTest {
//声明volatile全局变量
private volatile int num = 0;
public static void main(String[] args) {
VolatileTest volatileTest = new VolatileTest();
//创建一个用于volatile写的线程并启动
new Thread(()->{
log.info("Thread name:"+Thread.currentThread().getName()+"--sleep");
try {
Thread.sleep(500);
volatileTest.num = 5;
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("Thread name:"+Thread.currentThread().getName()+"--dead");
}).start();
//创建一个用于volatile读的线程并启动
new Thread(()->{
log.info("Thread name:"+Thread.currentThread().getName()+"--num="+volatileTest.num);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("Thread name:"+Thread.currentThread().getName()+"--dead--num="+volatileTest.num);
}).start();
}
}
特此声明:
分享文章有完整的知识架构图,将从以下几个方面系统展开:
1 基础(Linux/Spring boot/并发)
2 性能调优(jvm/tomcat/mysql)
3 高并发分布式
4 微服务体系
如果您觉得文章不错,请关注阿伦故事,您的支持是我坚持的莫大动力,在此受小弟一拜!
每篇福利:
网友评论