在高并发的情况下,java的内存模型到底是怎么提供支持的,要说清楚这个问题我们首先要先知道一些硬件层面的的知识,因为java的内存模型都是架构在这些硬件层面上的。
计算机存储结构为金字塔型存储
这种结构主要由寄存器,缓存(cache),内存,硬盘,远程文件存储等这几部分组成。有如下两个特点:
需要注意的是L3高速缓存是主板上被所有CPU所共享的。
之所以是金字塔型的结构主要是局部性原理。
程序局部性原理
刚被访问过的存储单元很可能不久又被访问,通常体现在循环执行的指令。让最近被访问过的信息保留在靠近CPU的存储器中加快处理速度。
刚被访问过的存储单元的邻近单元很有可能不久会被访问,通常体现在顺序执行的指令。将刚被访问的存储单元的邻近单元调到靠近CPU的存储器加快访问。
我们思考这样一个问题,当CPU需要主存中读取一个X数值时,最终会被CPU1和CPU2load到自己的核心中,如果CPU1修改了X的值,设置成了1,CPU2修改了X的值设置成了2,那么就产生了数据的不一致性,那么不同核中的数据要怎么保持一致性呢?也就是说我们运用了金字塔的存储模型,速度会提高。但是会有数据不一致性的问题。要解决这个问题就需要在硬件层面进行解决。方法如下:
总线锁
当CPU1通过总线bus访问x的值时,CPU2是不允许再访问x。因为锁住的是总线,除了访问x,访问其他的数值也是不允许的。
总线锁会锁住总线,使得其他CPU甚至不能访问内存中其他的地址,当时此种方式效率较低。
再老的一些CPU上会采用总线锁的方式。新的CPU采用各种各样的一致性协议。
一致性协议
这里的一致性协议会有很多, 包括MSI、MESI、MOSI Synapse、Firefly及Draqon,intel采用的是MESI,我们这里重点介绍这种协议。
MESI(也称伊利诺斯协议)是一种广泛使用的支持写回策略的缓存一致性协议,该协议被应用在Intel奔腾系列的CPU中。
CPU中每个缓存行使用的4种状态进行标记(使用额外的两位bit表示)
状态 | 描述 |
M(Modified)修改 | 这行数据有效,数据被修改了,和内存中的数据不一样,数据只存在于本cache中。 |
E(Exclusive)独享/互斥 | 这行数据有效,数据和内存中的数据一致,数据只存下于本Cache中 |
S(Shared)共享 | 这行数据有效,数据和内存中的数据一致,数据存在于很多cache中 |
I(Invalid)无效 | 这行数据无效 |
E状态
只有Core 0访问变量x,它的Cache line状态为E(Exclusive)。
S状态
3个Core都访问变量x,它们对应的Cache line为S(Shared)状态。
M状态和状态之间的转化
Core 0修改了x的值之后,这个Cache line变成了M(Modified)状态,其他Core对应的Cache line变成了I(Invalid)状态。
在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听其它Cache的读写操作。每个Cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移。
需要注意的是MESI并没有完全解决锁总线的问题,我们说MESI是缓存锁,范围比起总线锁会小很多。但是有一些无法被缓存的数据或者跨越多个缓存行的数据还是需要总线锁。
现代CPU底层的数据一致性实现是采用总线锁加缓存锁来实现的。
缓存行
当CPU访问某个数据时,会假设该数据附近的数据以后会被访问到,因此,第一次访问这一块区域时,会将该数据连同附近区域的数据(共64字节)一起读取进缓存中,那么这一块数据称为一个Cache Line 缓存行。在一般的x86环境下一个CacheLine是64字节。
伪共享
缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
例如,CPU 1在读取数据X时,会把相邻的Y也会加载到其缓存行中,此时如果CPU2 需要读取数值Y,也会把相邻的X也加载缓存行中,此时如果CPU1修改数值X时根据缓存一致性协议会导致CPU2中的缓存行失效进而重读,如果CPU2修改数值Y时也会导致CPU1中的缓存行失效重读,这样就导致了CPU间彼此影响导致性能损耗。
一般我们通过缓存行对齐就可以解决这样的问题。
缓存行对齐
基于以上问题的分析,在一些情况下,比如会频繁进行操作的数据,可以根据缓存行的特性进行缓存行对齐(即将要操作的数据凑一个缓存行进行操作)下面使用一个示例进行说明:
package com.example.demo;
public class Cacheline_nopadding {
public static class T{
//8字节
private volatile long x = 0L;
}
private static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(long i = 0;i < 1000_0000L;i++){
//volatile的缓存一致性协议MESI或者锁总线,会消耗时间
arr[0].x = i;
}
});
Thread thread2 = new Thread(()->{
for(long i = 0;i< 1000_0000L;i++){
arr[1].x = i;
}
});
long startTime = System.nanoTime();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000);
}
}
总计消耗时间:2508
下面来做一个改造升级,对齐缓存行,重点代码如下
private static class Padding{
//7*8字节
public volatile long p1,p2,p3,p4,p5,p6,p7;
}
public static class T extends Padding{
//8字节
private volatile long x = 0L;
}
通过上述代码做缓存对齐,每次都会有初始的7*8个占位,加上最后一个就是独立的一块缓存行,整理后代码如下:
package com.example.demo;
public class Cacheline_padding {
private static class Padding{
//7*8字节
public volatile long p1,p2,p3,p4,p5,p6,p7;
}
public static class T extends Padding{
//8字节
private volatile long x = 0L;
}
private static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for(long i = 0;i < 1000_0000L;i++){
//volatile的缓存一致性协议MESI或者锁总线,会消耗时间
arr[0].x = i;
}
});
Thread thread2 = new Thread(()->{
for(long i = 0;i< 1000_0000L;i++){
arr[1].x = i;
}
});
long startTime = System.nanoTime();
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("总计消耗时间:"+(System.nanoTime()-startTime)/100_000);
}
}
总计消耗时间:729
从上面可以看到,使用缓存对齐,相同操作情况下对齐后的时间比没对齐的时间减少一半。
上面这种缓存行填充的方法在早期是比较流行的一种解决办法,比较有名的Disruptor框架就采用了这种解决办法提高性能,Disruptor是一个线程内通信框架,用于线程里共享数据。与LinkedBlockingQueue类似,提供了一个高速的生产者消费者模型,广泛用于批量IO读写,在硬盘读写相关的程序中应用十分广泛,Apache旗下的HBase、Hive、Storm等框架都有使用Disruptor。
乱序问题
CPU为了提高指令的执行效率,比如在执行一条读指令(读取内存的一条数据)时,去执行另一条指令,前提是两条指令间没有依赖关系。
我们来看这样的例子
public class Disorder {
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 one = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.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);
}
}
在线程体中未发生乱序问题,那么程序输出的结果就不应该有x=0,y=0的结果,但是根据实际执行情况来看,会输出以下内容
第3932682次 (0,0)
也就是说,是存在乱序问题的。
硬件内存屏障 X86
原子指令
如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序。
这些都是JVM级别定义的一些规范,具体的实现都是由硬件级别的内存屏障进行保障与支持的。
volatile用来修饰成员变量(静态变量和实例变量),被修饰的变量在被修改时能够保证每个线程获取该变量的最新值,从而避免出现数据脏读的现象,也就是我们说的保证数据的可见性。volatile可以保证可见性和有序性,但是不能保证原子性。要保证原子需要synchronized和Lock。
字节码级别
被volatile修饰的变量在字节码级别的access flags中会多一个volatile字符串
JVM级别
在JVM级别就是根据volatile的写或读操作添加了相应的内存屏障。
StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序
硬件层面
通过相应工具查看汇编指令可以发现,在window上是通过lock指令来实现的。
因此,volatile修饰的变量具有以下的特点:
页面更新:2024-04-29
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号