硬件层数据一致性
关于Cache Line缓存行如下图所示:
老CPU会使用总线锁来保证数据一致性,即在L3 Cache层,要读取数据时,CPU的一个核(或虚拟核)就会锁住该内存。
新CPU用各种各样的一致性协议,例如intell的CPU中,使用缓存锁MESI + 总线锁。
MESI
MESI表示用四种状态来标记缓存行,每一种状态的简单解释如下:
Modeified: 该缓存行只被缓存在该CPU的缓存中,并且是被修改过的,即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
简单来说就是:我改过,别人那边看这块缓存行的状态为Invalid。
Exclusive: 该缓存行只被缓存在该CPU的缓存中,它是未被修改过的,与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态shared。同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。
简单来说就是:只有我在用
Shared: 该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致,当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废,变成无效状态Invalid。
简单来说就是:我读时别人也在读
Invalid: 该缓存是无效的,可能有其它CPU修改了该缓存行。
但是要注意,跨越多个缓存行的数据依然必须使用总线锁。
伪共享
由于CPU读取缓存是以缓存行为基本单位,并且缓存行的多数实现为64 bytes。假设有这样一种情况,一块缓存行中存储了数据x和y,CPU1要读x必须同时读取x和y。若此时CPU2想读这块缓存行的数据y时就需要等待。这就是为共享问题。
针对这个问题,可以使用缓存行对齐,来提升代码执行效率,代码如下:
public class Test {
private volatile long x = 0L;
private static Test[] arr = new Test[2];
static {
arr[0] = new Test();
arr[1] = new Test();
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});
final long start = System.currentTimeMillis();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(System.currentTimeMillis() - start);
}
}
上面这段代码平均执行时间为200+毫秒。如果在变量x上方加上代码private volatile long p1, p2, p3, p4, p5, p6, p7;再跑一下,平均执行时间减少为100+毫秒,性能确实有提升。
乱序问题
CPU为了提高指令执行效率,需要解决的性能瓶颈为对内存的访问。CPU的速度往往会比去主存中读数据要快两个数量级。所以如第一张图所示,CPU会引入L1 Cache、 L2 Cache甚至L3 Cache。此时,如果CPU执行时需要访问的数据不在Cache中,则需要到主存中读取。那么在读取到数据的这段时间内,CPU会继续执行其他没有依赖关系的指令,这就会导致乱序问题。以下代码可以证明乱序问题:
public class Test {
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;
while(true) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(() -> {
a = 1;
x = b;
});
Thread other = new Thread(() -> {
b = 1;
y = a;
});
one.start();other.start();
one.join();other.join();
String result = String.format("第%s次,x = %s, y = %s", i, x, y);
if(x == 0 && y == 0) {
System.err.println(result);
break;
}
}
}
}
正常理解下,x与y的值可能是(0, 1)或(1, 0),但是如果同时为0,则能证明乱序问题。
由于每一层Cache的读取速度又相差很多,例如L1 Cache和L2 Cache的读取速度相差二三十倍,所以CPU又会使用另外一个缓冲区叫做合并写存储缓冲区。即CPU会把待写入的数据从L2 Cache写到合并写缓冲区,该缓冲区大小通畅也为64 bytes。当缓冲区去满了之后再写入L1 Cache。这个缓冲区允许cpu在写入或者读取该缓冲区数据的同时继续执行其他指令,这就缓解了CPU读数据时的性能影响。再看下面这段代码:
public class Test {
private static final int ITERATIONS = Integer.MAX_VALUE;
private static final int ITEMS = 1 << 24;
private static final int MASK = ITEMS - 1;
private static final byte[] arrayA = new byte[ITEMS];
private static final byte[] arrayB = new byte[ITEMS];
private static final byte[] arrayC = new byte[ITEMS];
private static final byte[] arrayD = new byte[ITEMS];
private static final byte[] arrayE = new byte[ITEMS];
private static final byte[] arrayF = new byte[ITEMS];
public static void main(final String[] args) {
for (int i = 1; i <= 3; i++) {
System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
System.out.println(i + " SplitLoop duration (ns) = " + runCaseTwo());
}
}
public static long runCaseOne() {
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) { // 同时改变六个位置 可能会有一些并发的控制占用计算资源
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}
public static long runCaseTwo() {
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) { // 改其中的三个位置
int slot = i & MASK;
byte b = (byte) i;
arrayA[slot] = b;
arrayB[slot] = b;
arrayC[slot] = b;
}
i = ITERATIONS;
while (--i != 0) { // 改另外的三个位置
int slot = i & MASK;
byte b = (byte) i;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return System.nanoTime() - start;
}
}
直观上会认为case one会比case two执行时间要快,但是实际情况相反。因为在case one中,合并写缓冲区被64 bytes的数据填满后,数据会写入L1 Cache。此时CPU等待另外64 bytes的数据由L2 Cache写入缓冲区,所以效率会低很多。
如何保证特定情况下不乱序
CPU层面如何规范
X86 Intel会使用如内存屏障: sfence: 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
lfence: 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
mfence: 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。
除了内存屏障,如x86上还有原子指令”lock …” 该指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用内存屏障或原子指令来实现变量可见性和保持程序顺序。
JVM层面如何规范(JSR133)
LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障: 对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
**LoadStore屏障:**对于这样的语句Load1; LoadStore; Store2, 在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
**StoreLoad屏障:**对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
相关扩展:
- volatile的实现细节
- synchronized实现细节 可阅读juejin.cn/post/706707… 这篇文章来学习。
对象大小(64位机)
使用java -XX:+PrintCommandLineFlags -version可以看到-XX:+UseCompressedClassPointers和-XX:+UseCompressedOops这两个配置,下面会说明。
普通对象
- 对象头即
markword为8字节; - ClassPointer指针,表示对象属于哪个类。配置
-XX:+UseCompressedClassPointers开启,该指针占4字节,若不开启则为8字节; - 实例数据和引用类型,配置
-XX:+UseCompressedOops开启,该项占4字节,若不开启则为8字节; - Padding对齐:将对象占用内存书对齐为8的倍数;
数组对象
- 对象头:同普通对象
- ClassPointer指针:同普通对象
- 数组长度:4字节
- 数组数据
- Padding对齐:同普通对象
markword详解(32位)
markword内的数据与synchronized锁升级相关。同样可阅读juejin.cn/post/706707… 这篇文章来学习。
对象定位
-
句柄池
-
直接指针