分享

volatile关键字的原理和要避免的误区

 编程一生 2022-03-09

背景

    最近做code review看到有的同学在承载缓存数据的变量里加了volatile关键字。想起来之前项目中也看到有的同学习惯在从配置中心获取的配置数据的变量上加volatile。今天就来探讨一下这个volatile加的有没有必要。

volatile关键字的作用

1>防止指令重排

2>禁用工作内存缓冲区,直接使用主内存。

经典使用场景

场景1

public static Singleton getInstance() {
//第一次null检查
if (instance == null) {
synchronized (Singleton.class) { //1
//第二次null检查
if (instance == null) { //2
instance = new Singleton();//3
}
}
}
return instance;
}
如果不用volatile,则因为内存模型允许所谓的“无序写入”,可能导致失败。某个线程可能会获得一个未完全初始化的实例。

场景2

private volatile int value;
//操作,没有synchronized,提高性能
public int getValue() {
return value;
}

//写操作,必synchronized。因x++不是原子操作
public synchronized int increment() {
return value++;
}
这段代码,可实现一个线程间安全的计数器。因为加了valatile关键字。每次线程都能取到最新值做加减。

要避免的误区

在代码评审的时候看到volatile被滥用的情况。说说我个人的看法:很少变化,对时间不是特别敏感的情况下不建议用volatile关键字。
举个例子:从公司的配置中心取到一个配置数据。不建议用volatile。

一般来说配置中心的架构是下面这个样子

一条数据从用户变更到集中存储的配置中心,配置中心下发到真正使用的机器上,之前公司是要经过90s(客户端90s为周期定时去配置中心取最新数据)。
加了volatile关键字在这种场景只是能更快的看到这个最新值而已。下面我们来测试下这个【更快】有多久。
public class VolatileTest {
private boolean endRun = false;
@Test
public void noVolatile() throws Exception {
Runnable r1 = new Runnable() {
public void run() {
int i = 0;
while (!endRun) {
System.out.println("I am still running" + i++);
}
}
};
Runnable r2 = new Runnable() {
public void run() {
endRun = true;
}
};
new Thread(r1).start();
new Thread(r2).start();
Thread.sleep(9000);
System.out.println("end run");
}
}
这个代码里,在第一个线程使用endRun这个变量。第二个线程去改变endRun这个变量的值。一旦第一个线程看到了第二个线程的值的变化,就会马上停止循环。

运行结果如下:

说明经过了两个循环的时间,线程就读到了另外一个线程变化的值。对照下面的时间延迟表,我们来计算下:

平均执行一行简单代码要执行5个指令。如上执行一个指令需要1ns。每次循环执行2行代码,从运行结果来看共执行了2次。共5*1*2*2=20ns。实际数据应该不是如此,而且是变化的。但是应该都是ns级别的。

相比较90s的可见性延迟,ns级别可以忽略不计。

再看看为了早ns级看到结果,所花费的开销:volatile关键字本质是让L1缓存、L2缓存这种cpu缓存失效了,直接主存访问。如果要访问的字段在L1缓存里,从配置中心取的数据1天变化一次。以字段放在L2缓存为例。加了volatile关键字,访问时间要从4ns上升到100ns,如果这个变量每个请求都要访问,每秒QPS是1000。则1天为了取这个数据将多花1*24*3600*1000*(100-4)ns约等于8300ms。
相比获得的收益来讲,代价要大出好几个数量级。但是本身的时间开销本来就很小,坦白说一般的系统一天多花个8.3s也是可以接受的。但是这样的变量多了,也是个不小的负担。而且这个负担会随着系统压力增加而加重。
定时将缓存加载到内存原理相同,不建议使用volatile。

其他引申思考
上面结论是在对时间敏感度不高的情况下不建议用volatile,但是对于一般的系统,用了对系统的影响也还好。最怕的事情是做了一个实际上意义并不大,却引入系统风险的优化。
上周我review同学的代码,这个同学是个有技术追求的同学。喜欢写代码的时候进行些小优化。这是个好习惯。但是对review代码的人要求很高。因为普通的业务逻辑影响的就是那么一块范围内的。但是优化却可能会影响其他部分。
这次他在好几处,把原来打印更新缓存的日志里,原来只打印:【更新缓存成功】的地方加了个【更新缓存成功,影响数据XX条】。这个XX取的是guava Cache的.size()方法。
这段代码我仔细看了guava cache的初始化方法,这个初始化方法非常复杂,里面用了几处断言(不能为空)。这个初始化方法没有统一的try catch捕获异常,一旦有地方抛出异常。有可能会没有完全实例化。我把自己的这个想法提了出来。他通过代码走查梳理向我证明了,确实不会有空指针的情况。我同意他是对的。但是这整个过程说明了我对这件事情的谨慎。如果我们上线了一个功能,功能有问题,新功能上线会有灰度、观察期慢慢上量的过程,影响不会多大。但是改了其他部分,特别是感觉绝对不会有问题的部分。如果出现问题了,自己团队的信任分一下子就会降下来了。以后再进行变更,需要反复证明影响,非常被动。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多