在 《死磕GOF23种设计模式之单例模式》 中,其中双重检查锁使用到了volatile关键字,本篇文章就带大家深入了解一下volatile相关的知识。
volatile是Java提供的一种轻量级的同步机制,在并发编程中扮演着比较重要的角色。与synchronized相比,volatile更轻量级。
首先,我们先来看一段代码:
package com.secbro2.others.testVolatile; /** * @author zzs */ public class TestVolatile { private static boolean status = false; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (!status) { } }).start(); Thread.sleep(100L); status = true; System.out.println("status is " + status); } }
一个实体类,包含一个status属性,默认值为false,在main方法中启动一个线程,线程内当status变为true时停止,当为false时一直执行,然后线程睡眠100毫秒,随后将status改为true,并打印修改之后的结果。那么,线程中的while方法此时是否也随之结束呢?答案是否定的!
当执行此端代码时,我们会发现,虽然已经打印出“status is true”,但线程并没有停止,一直在执行。这是为什么呢?
上面的例子如果在单线程中,上面的业务逻辑肯定和我们预期的结果一致。但在多线程模型中,共享变量status在线程之间是“不可见”的。
所谓可见性,是当一个线程修改了共享变量,修改之后的值对其他线程来说可以立即获得,这便是线程之间的可见性。上面的例子正是因为没有做到线程之间的可见性,因此在主线程上修改了status值,另外一个线程却没有获取到,因此一致循环执行。
Java虚拟机的内存模型(Java Memory Model,JMM),决定线程对共享变量的写入是否对其他线程可见。JMM定义了线程和主线程内存之间的抽象关系:共享变量存储在主内存(Man Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的变量。
用synchronized和Lock可以解决线程同步的问题,但针对上面的问题使用它们太重量级了。此时volatile的作用彰显出来了,当volatile修饰变量后有以下作用:
修改过后的变量为:
package com.secbro2.others.testVolatile; /** * @author zzs */ public class TestVolatile { private static volatile boolean status = false; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (!status) { } }).start(); Thread.sleep(100L); status = true; System.out.println("status is " + status); } }
此时再执行程序,会发现当status被修改之后,程序马上停止了。
多线程的另外一个问题就是原子性操作,当一个操作不是原子性的,那么多线程同时操作就可能导致并发问题。首先看一个示例:
package com.secbro2.others.testVolatile; /** * @author zzs */ public class Counter { private volatile int inc = 0; private void increase() { inc++; } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); for (int i = 0; i < 10; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { counter.increase(); } }).start(); } Thread.sleep(3000L); System.out.println(counter.inc); } }
执行结果为:
执行结果并不是预期的10000。这就说明volatile虽然可以保证可见性,但并不能保证原子性。可见性能够保证,每个线程每次读取到的值为最新值,但读取之后的再操作就没办法保证。比如上面的例子,inc的自增操作包含三步:读取inc的值,进行加1,写入工作内存,也就是说inc的自增操作并不是原子性的。
对上面的代码进行修改,使用synchronized关键字,即可保证线程的安全:
package com.secbro2.others.testVolatile; /** * @author zzs */ public class Counter { private volatile int inc = 0; private synchronized void increase() { inc++; } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); for (int i = 0; i < 10; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { counter.increase(); } }).start(); } Thread.sleep(3000L); System.out.println(counter.inc); } }
输出结果是预期的10000。当然,也可以使用AtomicInteger来保证递增的原子性,这里不再举例说明。
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
int a = 10; //语句1 int r = 2; //语句2 a = a + 3; //语句3 r = a*a; //语句4
比如上面的代码,可能的执行顺序为:语句2,语句1,语句3,语句4,但不会是:语句2,语句1,语句4,语句3。
volatile关键字还能禁止指令的重排序,所以能在一定程序上保证有序性。
volatile关键字禁止指令重排序有两层意思:
当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
具体示例在将单例模式的双重检查锁中已经讲到,实现代码如下:
package com.secbro2.gof23.singleton; /** * Singleton Patterns<br/> * <p> * Double checked lock and volatile; * * @author zzs */ public class SingletonThreadSafe2 { private static volatile SingletonThreadSafe2 instance; private SingletonThreadSafe2() {} public static SingletonThreadSafe2 getInstance() { if (instance == null) { synchronized (SingletonThreadSafe2.class) { if (instance == null) { instance = new SingletonThreadSafe2(); } } } return instance; } public void helloSingleton() { System.out.println("Hello SingletonThreadSafe1!"); } }
通过上面的讲解,我们了解的volatile的基本作用和示例,它的应用场景比如状态标记量和双重检查。但我们需要明白的是,volatiled可以解决一部分线程并发问题,但它并不能像synchronized那样真正的达到同步锁的目的。
更多技术、架构、管理等知识分享,请关注微信公众号:程序新视界(ID:ershixiong_see_world)