文章内容参考以下博客,并对其中volatile关键原理进行提炼,从各方面出发解剖volatile关键字。
[1] https://mp.weixin.qq.com/s/bm3VVYp_r2vWLiIUFpC-4g(Java技术迷公众号)
[2] (2条消息) Java volatile关键字最全总结:原理剖析与实例讲解(简单易懂)_老鼠只爱大米的博客-CSDN博客_java volatile
因为内存和CPU之间存在速度差异,CPU使用三级高速缓存来平衡内存和CPU之前的速度差。L1,L2,L3 高速缓存集成到 CPU,L0 也就是寄存器,寄存器离 CPU 最近,访问速度也最快,基本没有时延。
大家可以打开window的任务管理器,点击性能可以查看到CPU的情况,从下面的图中我们可以得知,我这个CPU有8个物理处理器(8核CPU),16个逻辑处理器(8核16线程)。还能分析高速缓存L1,L2,L3分别为512KB,4.0MB,8.0MB。
这里需要介绍一下CPU的物理核心和逻辑核心分别代表什么,CPU的物理核心代表一个CPU的处理单元的个数,比如: CPU 包含 4 个物理核心 8 个逻辑核心(4核8线程)。4 个物理核心表示在同一时间可以允许 4 个线程并行执行。而逻辑核心代表的是:处理器利用超线程的技术将一个物理核心模拟出了两个逻辑核心。
一个物理核心在同一时间只会执行一个线程,而超线程芯片可以做到线程之间快速切换,当一个线程在访问内存的空隙,超线程芯片可以马上切换去执行另外一个线程。因为切换速度非常快,所以在效果上看到是 8 个线程在同时执行。(引用文章【1】)
CPU缓存模型如下图:
可以看到L3是多核共用的,而L2,L1是属于CPU核心独立占有的。我们可以在Linux系统中在/sys/devices/system/cpu/目录下看多CPU设备的描述信息。该目录下有多少个cpux就代表有多少个逻辑核心。
假设我们进入第一个逻辑核心:/sys/devices/system/cpu/cpu0/cache,会发现一下目录:
进入每个index目录,每个目录都会有以下部分或者全部的文件,分别为:
CPU 的高速缓存结构,引入高速缓存的目的在于消除 CPU 与内存之间的速度差距。数据在 CPU 高速缓存中的存取并不是以单独的变量或者单独的指针为单位存取的。而是以缓存行为存取单位。
CPU 高速缓存中存取数据的基本单位叫做缓存行 cache line。缓存行存取字节的大小为 2 的倍数,在不同的机器上,缓存行的大小范围在 32 字节到 128 字节之间。目前所有主流的处理器中缓存行的大小均为 64 字节
一般现在的计算机CPU基本都是64字节为大小的存储行,这也就意味着每次 CPU 从内存中获取数据或者写入数据的大小为 64 个字节,即使你只读一个 bit,CPU 也会从内存中加载 64 字节数据进来。
比如你访问一个 long 型数组,当 CPU 去加载数组中第一个元素时也会同时将后边的 7 个元素一起加载进缓存中。这样一来就加快了遍历数组的效率。
long 类型在 Java 中占用 8 个字节,一个缓存行可以存放 8 个 long 型变量。
事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构,如果你的数据结构中的项在内存中不是彼此相邻的(比如链表),这样就无法利用 CPU 缓存的优势。由于数据在内存中不是连续存放的,所以在这些数据结构中的每一个项都可能会出现缓存行未命中(程序局部性原理)的情况。
Netty 利用数组实现的自定义 SelectedSelectionKeySet 类型替换掉了 JDK 利用 HashSet 类型实现的 sun.nio.ch.SelectorImpl#selectedKeys。目的就是利用 CPU 缓存的优势来提高 IO 活跃的 SelectionKeys 集合的遍历性能。(引用文章【1】)
并发编程三大基本概念:
原子性: jave关于并发编程原子性的包有:java.concurrent.Atomic.* 包,该包下的一切方法都是符合原子性的,如何描述原子性,一个很经典的例子就是银行账户转账问题。假设:从账户A向账户B转1000元,那么比如会切分为两个操作,分别是:(1).从账户A减去1000元 (2).往账户B加上1000元,如果要保证原子性的话,那么这两个操作要么全部成功,要么全部失败,不可以存在(1)成功(2)失败,反之也不可。
可见性:Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。(引用文章【2】)
有序性:在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序。Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。(引用文章【2】)
这里直接给我另一篇博客的链接,该博客详细地介绍了锁的概念和,Java中锁的实现的介绍。(2条消息) JAVA锁_鸭梨的药丸哥的博客-CSDN博客
在前面介绍过CPU的缓存模型,CPU的缓存行和并发编程基础概念。下面的讲述的volatile关键字原理与这三者的关系十分密切。
volatile拥有以下特性:
以下内容参考或直接引用(引用文章【1】)
假设我们现在定义一个类FalseSharding,FalseSharding代码如下,字段 a,b 之间逻辑上是独立的,它们之间一点关系也没有,分别用来存储不同的数据,数据之间也没有关联。
public class FalseSharding {
volatile long a;
volatile long b;
}
根据CPU缓存行介绍,我们可以得知我们一般CPU的缓存行大小为64字节,而字段 a,b 总共16字节。所以字段 a,b 有可能同时存在一个缓存行中。
如果恰好字段a,b 被 CPU 读进了同一个缓存行,而此时有两个线程,线程a用来修改字段a,同时线程b用来读取字段 b。那么就会出现下面这种情况:
为了解决缓存不一致性问题,volatile使用以下2种解决方法:
Lock前缀指令和缓存一致性协议介绍如下:
- Lock 前缀指令可以使修改线程所在的处理器中的相应缓存行数据被修改后立马刷新回内存中,并同时锁定所有处理器核心中缓存了该修改变量的缓存行,防止多个处理器核心并发修改同一缓存行;
- 缓存一致性协议主要是用来维护多个处理器核心之间的 CPU 缓存一致性以及与内存数据的一致性。每个处理器会在总线上嗅探其他处理器准备写入的内存地址,如果这个内存地址在自己的处理器中被缓存的话,就会将自己处理器中对应的缓存行置为无效,下次需要读取的该缓存行中的数据的时候,就需要访问内存获取。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lyyHxeoJ-1649253305976)(F:\笔记文档\笔记图片\4.jpg)]
根据Lock前缀指令和缓存一致性协议,在volatile标识的数据可能会出现以下这两者情况:
第一种情况
第二种情况
0 将字段 a 所在的缓存行刷新回内存的时候,处理器 core1 会在总线上嗅探到字段 a 的内存地址正在被其他处理器修改,所以将自己的缓存行置为失效。