volatile的用途
volatile的作用
1.线程可见性
volatile 底层是 lock add
指令
LOCK 用于在多处理器中执行指令时对共享内存的独占使用。 它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效(所以要重新去内存读,便实现了线程可见性)。
package com.mashibing.testvolatile;
public class T01_ThreadVisibility {
// ⚠️ volatile 修改了这块内存马上通知其它线程重新读取
private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
while (flag) {
//do sth
}
System.out.println("end");
}, "server").start();
Thread.sleep(1000);
flag = false; // ⚠️ 如果不加volatile,server线程不会停止
}
}
2.防止指令重排序
什么是指令重排序?
指令重排序。如果出现x,y同时等于0,则CPU一定发生了指令重排序。
⚠️ 指令重排序的原则:不管怎么进行指令重排序,单线程内程序的执行结果不能被改变。
package com.mashibing.jvm.c3_jmm;
public class T04_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() {
//由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
//shortWait(100000);
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);
}
}
系统底层如何实现数据一致性
- MESI如果能解决,就使用MESI
- 如果不能,就锁总线
系统底层如何保证有序性
- 内存屏障sfence mfence lfence等系统原语
- 锁总线
volatile如何解决指令重排序
JVM的内存屏障 🤔
屏障两边的指令不可以重排!保障有序! ⚠️这是JVM规范层级的要求(具体实现看hotspot的源码),不是CPU层级的实现
规范:hanppens-before原则(JVM规定重排序必须遵守的规则)、as if serial(不管如何重排序,单线程执行结果不会改变)
volatile实现细节
hotspot实现:
bytecodeinterpreter.cpp
int field_offset = cache->f2_as_index();
if (cache->is_volatile()) {
if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
OrderAccess::fence();
}
orderaccess_linux_x86.inline.hpp
inline void OrderAccess::fence() {
if (os::is_MP()) {
// ⚠️ always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
}
LOCK 用于在多处理器中执行指令时对共享内存的独占使用。 它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效(所以要重新去内存读,便实现了线程可见性)。 另外还提供了有序的指令无法越过这个内存屏障的作用(别的指令无法越过lock指令。)
面试题:DCL单例需不需要加volatile? 🤔
必须要加!创建对象过程中,有一个半初始化状态,volatile可以禁止指令重排序。
案例:
Object t = new Object();
// DCL 双重检查锁
public class Mgr06{
// ⚠️问题:DCL单例需不需要加volatile?
private static volatile Mgr06 INSTANCE; // JIT
private Mgr(){}
public static Mgr06 getInstance(){
// 业务逻辑代码省略
if(INSTANCE == null){
// Double Check Lock 双重检查锁
synchronized(Mgr06.class){
if(INSTANCE==null){ // 第二次检查,防止在上锁过程中有人new了
try{
Thread.sleep(1);
}catch(InterruptedException e){
e.printStackTrace();
}
INSTANCE = new Mgr06();
}
}
}
return INSTANCE;
}
public void m(){System.out.printlb("m");}
}
此时发生了指令重排序,这时t便与半初始化的对象建立了联系。⚠️ thread2就会使用半初始化状态的对象!
用hsdis观察synchronized和volatile
-
安装hsdis (自行百度)
-
代码
public class T { public static volatile int i = 0; public static void main(String[] args) { for(int i=0; i<1000000; i++) { m(); n(); } } public static synchronized void m() { } public static void n() { i = 1; } }
-
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly T > 1.txt
- 发现最终 synchronized 底层是
lock cmpxchg
指令;volatile 底层是lock add
指令
伪共享和缓存行
ps:与volatile实现没什么关系
在计算机系统中,内存是以缓存行为单位存储的,一个缓存行存储字节的数量为2的倍数,按块读取。在不同的机器上,缓存行大小为32字节到256字节不等,通常来说为64字节。伪共享指的是在多个线程同时读写同一个缓存行的不同变量的时候,尽管这些变量之间没有任何关系,但是在多个线程之间仍然需要同步,从而导致性能下降的情况。在对称多处理器结构的系统中,伪共享是影响性能的主要因素之一,由于很难通过走查代码的方式定位伪共享的问题,因此,大家把伪共享称为“性能杀手”。
为了通过增加线程数来达到计算能力的水平扩展,我们必须确保多个线程不能同时对一个变量或者缓存行进行读写。我们可以通过代码走查的方式,定位多个线程读写一个变量的情况,但是,要想知道多个线程读写同一个缓存行的情况,我们必须先了解系统内存的组织形式,如下图所示。
从上图看到,线程1在CPU核心1上读写变量X,同时线程2在CPU核心2上读写变量Y,不幸的是变量X和变量Y在同一个缓存行上,每一个线程为了对缓存行进行读写,都要竞争并获得缓存行的读写权限,如果线程2在CPU核心2上获得了对缓存行进行读写的权限,那么线程1必须刷新它的缓存后才能在核心1上获得读写权限,这导致这个缓存行在不同的线程间多次通过L3缓存来交换最新的拷贝数据,这极大的影响了多核心CPU的性能。如果这些CPU核心在不同的插槽上,性能会变得更糟。
JVM中优化的方法
所有的Java对象都有8字节的对象头,前四个字节用来保存对象的哈希码和锁的状态,前3个字节用来存储哈希码,最后一个字节用来存储锁状态,一旦对象上锁,这4个字节都会被拿出对象外,并用指针进行链接。剩下4个字节用来存储对象所属类的引用。对于数组来讲,还有一个保存数组大小的变量,为4字节。每一个对象的大小都会对齐到8字节的倍数,不够8字节部分需要填充。
为了保证效率,Java编译器在编译Java对象的时候,通过字段类型对Java对象的字段进行排序,如下表所示。因此,我们可以在任何字段之间通过填充长整型的变量把热点变量隔离在不同的缓存行中,通过减少伪同步,在多核心CPU中能够极大的提高效率。
顺序 | 类型 | 字节数量 |
---|---|---|
1 | double | 8字节 |
2 | long | 8字节 |
3 | int | 4字节 |
4 | float | 4字节 |
5 | short | 2字节 |
6 | char | 2字节 |
7 | boolean | 1字节 |
8 | byte | 1字节 |
9 | 对象引用 | 4字节或者8字节 |
10 | 子类字段 | 重新排序 |
一些说明:
-
伪共享
- 一个cache lien可以被多个不同的线程所使用。如果有其他线程修改了v2的值,线程1和线程2将会强制重新加载cache line。你可以会疑惑我们只是修改了v2的值不应该会影响其他变量,为啥线程1和线程2需要重新加载cache line呢。然后,即使对于多个线程来说这些更新操作是逻辑独立的,但是一致性的保持是以cache line为基础的,而不是以单个独立的元素。这种明显没有必要的共享数据的方式被称作“False sharing”。
-
缓存行对齐
- 缓存行64个字节是CPU同步的基本单位,缓存行隔离会比伪共享效率要高
-
需要注意,JDK8引入了@sun.misc.Contended注解,来保证缓存行隔离效果
- 要使用此注解,必须去掉限制参数:-XX:-RestrictContended
-
另外,java编译器或者JIT编译器有可能会去除没用的字段,所以填充字段必须加上volatile
package com.mashibing.juc.c_028_FalseSharing; public class T02_CacheLinePadding { private static class Padding { public volatile long p1, p2, p3, p4, p5, p6, p7; // } private static class T extends Padding { public volatile long x = 0L; } public static T[] arr = new T[2]; static { arr[0] = new T(); arr[1] = new T(); } 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.nanoTime(); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println((System.nanoTime() - start)/100_0000); } }
MESI Cache 一致性协议
缓存一致性协议是CPU级别的一种方法,volatile这是JVM层面的实现,JVM采取的是最偷懒的办法–锁总线。
cpu每个cache line 标记四种状态(额外两位)
缓存锁实现之一,有些无法被缓存的数据或者跨越多个缓存行的数据依然必须使用总线锁。
既已览卷至此,何不品评一二: