内存可见性
volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,倘若能恰当的合理的使用volatile,自然是美事一桩。
为了能比较清晰彻底的理解volatile,我们一步一步来分析。首先来看看如下代码;
public class volatileTest {
/*volatile*/ boolean running =true;
void m(){
System.out.println("m start");
while(running){
}
System.out.println("m end");
}
public static void main(String[] args) {
volatileTest v=new volatileTest();
new Thread(v::m,"t1").start();
try{
TimeUnit.SECONDS.sleep(1);
}catch (InterruptedException e){
e.printStackTrace();
}
v.running=false;
}
}
输入结果:m start
这里可以看到虽然后面将running=false,但是并没有生效,m end并没有输出。
将/*volatile*/释放之后打印结果:
m start
m end
很明显可以看到加了volatile之后线程里面值修改是有效的,所以才会输出m end。
那为什么这样加了volatile就不同了呢?首先这里需要我们看看JMM (java 内存模型)。
java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果。
JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝。每次线程先去主内存中拿值放到自己的缓存中,线程对变量的所有操作都必须在自己的缓存中进行,而不能直接读写主内存中的变量。
大概了解了JMM的简单定义后,问题就很容易理解了,对于普通的共享变量来讲,比如我们上文中的running,主线程将其修改为false这个动作发生在主线程中,线程t1并没有并没有读取到主内存中的running'值更新,所以就导致了上述的问题。那么这种共享变量在多线程模型中的不可见性如何解决呢?比较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,有点炮打蚊子的意思。比较合理的方式其实就是volatile,效率比synchronized高太多。
volatile具备两种特性,第一就是保证共享变量对所有线程的可见性。将一个共享变量声明为volatile后,会有以下效应:
1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去;
2.这个写会操作会导致其他线程中的缓存无效。
所以加了valatile之后,线程会将变量的最新值同步给线程的缓存中。
留意复合类操作
但是需要注意的是,我们一直在拿volatile和synchronized做对比,仅仅是因为这两个关键字在某些内存语义上有共通之处,volatile并不能完全替代synchronized,它依然是个轻量级锁,在很多场景下,volatile并不能胜任。看下这个例子:
public class volatileTest {
volatile int num = 0;
void m(){
for (int i = 0; i <10000 ; i++) {
num++;
}
}
public static void main(String []args) throws InterruptedException {
volatileTest v= new volatileTest();
List<Thread> threads= new ArrayList<>();
for (int i = 0; i <10 ; i++) {
threads.add(new Thread(v::m,"thread"+i));
}
threads.forEach((o)->o.start());
threads.forEach((o)->{
try {
o.join();
}catch (InterruptedException e){
e.printStackTrace();
}
});
System.out.println(v.num);
}
}
输出结果:39572 每次运行输出结果都不一致。总是小于100000的。
针对这个示例,一些同学可能会觉得疑惑,如果用volatile修饰的共享变量可以保证可见性,那么结果不应该是100000么?
问题就出在num++这个操作上,因为num++不是个原子性的操作,而是个复合操作。我们可以简单讲这个操作理解为由这三步组成:
1.读取
2.加一
3.赋值
所以,在多线程环境下,有可能线程A将num读取到本地内存中,此时其他线程可能已经将num增大了很多,线程A依然对过期的num进行自加,重新写到主存中,最终导致了num的结果不合预期,而是小于100000。
解决num++操作的原子性问题
针对num++这类复合类的操作,可以使用java并发包中的原子操作类原子操作类是通过循环CAS的方式来保证其原子性的。看下面代码。简单的变量保证原子性和可见性可以使用并法宝里面的原子操作类完成。更加复杂的就要用到同步锁或者lock了
volatile AtomicInteger num = new AtomicInteger(0);;
void m(){
for (int i = 0; i <10000 ; i++) {
num.incrementAndGet();//原子性的num++,通过循环CAS方式
}
}
总结:
简单总结下,volatile是一种轻量级的同步机制:保证共享变量对所有线程的可见性;。同时需要注意的是,volatile对于单个的共享变量的读/写具有原子性,但是像num++这种复合操作,volatile无法保证其原子性,需要使用并发包中的原子操作类,通过循环CAS地方式来保证num++操作的原子性。
因此volatile适合于只有一个线程写,多个线程读的场景,因为它只能确保可见性。