锁在多线程编程或者说并发编程中极为重要,善用锁有助于避免程序出现意想不到的错误。volatile也可以说是锁机制中的一部分吧,之后会陆续学习分享锁机制的内容。
volatile关键字
volatile关键字用于 保持内存可见性 和 防止指令重排序,什么意思呢?
- 保持内存可见性:这里需要知道,CPU执行效率远高于内存,为了有更高的执行效率,内存与CPU之间会有一块缓存(CPU Cache)来做第三者。非volatile关键字的变量,在每个线程在使用这个变量时,将变量从内存拷贝到缓存中,并发编程下,多个线程拷贝的变量都是同一个值,在两个线程中单独改变变量的值,不会影响到其他线程中的变量副本,要实现线程同步(内存可见性)就可以使用volatile关键字来修饰。使用volatile关键字修饰后,每次修改变量的值,JVM都会将值刷新至主存中(这个过程加锁了,防止其他线程同时修改),取值时也都重新从主存中重新获取。实际上,这里遵循了 MESI缓存一致性协议 ,每个线程都有一个 总线嗅探机制,一旦使用volatile关键字后,总线嗅探机制就会启动,类似观察者模式一样,主存中的值一旦发生改变,就会清除线程中的变量副本,再次取值/改值时,都重新从主存中获取。举个例子:
public class JMMTest {
public static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(){
@Override
public void run() {
System.out.println("11111");
while (!flag){
}
System.out.println("22222");
}
}.start();
Thread.sleep(100);
new Thread(){
@Override
public void run() {
System.out.println("33333");
flag = true;
System.out.println("44444");
}
}.start();
}
}
以上代码,开启两个线程,中间睡眠100ms。其最终结果是:
为什么呢?明明第二个线程修改了flag变量的值为true,那第一个线程中while(!flag)应该不会进入循环才对,应该最终会打印22222才对。其实这里就是因为两个线程内存不可见性导致,两个线程中的flag都是变量flag变量的一个副本,第二个线程修改flag=true并不影响第一个线程中的flag。其实在IDEA中已经有所提醒了:
那我们在申明flag变量的地方加上volatile关键字对flag变量进行修饰后再执行结果:
public static volatile boolean flag = false;
⚠️ 注意: ⚠️ 这个时候,我将上面例子换一个顺序再执行,结果又不尽相同:
public class JMMTest {
public static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(){
@Override
public void run() {
System.out.println("33333");
flag = true;
System.out.println("44444");
}
}.start();
Thread.sleep(100);
new Thread(){
@Override
public void run() {
System.out.println("11111");
while (!flag){
}
System.out.println("22222");
}
}.start();
}
}
这个时候到执行结果如下:
这里就需要抛出一个疑问,不是说两个线程中的变量都是副本么?这里第一个线程改了flag值为true应该和第二个线程没有关系的啊????
解释一下:大家都知道代码执行顺序,当执行到flag=true时,某个时段会将flag刷新回主存中,意味着第二个线程开始执行之前,flag值已经被第一个线程修改并且将值刷新回到了主存中,主存中的flag值变为true,第二个线程执行时拷贝的变量副本就已经是true了。如何验证呢?我们再来修改一下代码:
public static void main(String[] args) throws InterruptedException {
new Thread(){
@Override
public void run() {
System.out.println("33333");
flag = true;
System.out.println("44444");
while (flag){
//此时flag是true,虽然第二个线程1000毫秒后将值重新改回false并刷新回主存,
//但是这里的flag在刷新前已经将主存中flag拷贝到了线程工作内存中了,后面的代码将不再执行
}
System.out.println("55555");
}
}.start();
Thread.sleep(100);
new Thread(){
@Override
public void run() {
System.out.println("11111");
while (!flag){
}
System.out.println("22222");
try {
sleep(1000);
flag = false;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
}
运行后的结果可以预知:不会打印55555:
volatile 的内存可见性就说这么多了,这里再放一张图,线程工作内存和主存之间的关系:(偷来的图,反正我画的贼难看...)
- 防止指令重排:一个对象的赋值过程有四个步骤(指令):new出一个对象存放在栈中、对象引用的赋值(堆中)、对象执行构造方法,对象栈与堆之间的引用建立连接。如下代码:
public class ObjTest {
public static void main(String[] args) {
Obj obj = new Obj();
}
}
class Obj{
int i = 10;
}
转换字节码后的指令四个过程,过程如:
在CPU执行指令过程中,第三步和第四步的执行指令顺序可能不一样,在单线程下,第四步指令先执行,后执行第三步指令的情形下,对结果并没有影响,但是在多线程下就可能出现问题。
光说不做不是一枚老程序员的做法,我们验证一下指令重排的效果:
public class VolatileTest {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; ; i++) {
x = 0;
y = 0;
a = 0;
b = 0;
Thread threadOne = new Thread() {
@Override
public void run() {
a = 1;
x = b;
}
};
Thread threadTwo = new Thread() {
@Override
public void run() {
b = 1;
y = a;
}
};
threadOne.start();
threadTwo.start();
threadOne.join();
threadTwo.join();
// String result = "第" + i + "次( x=" + x + ", y="+ + y + ")";
// System.out.println(result);
if (x == 0 && y == 0) {
String result = "第" + i + "次( x=" + x + ", y=" + y + ")";
System.out.println(result);
break;
}
}
}
}
如果不发生指令重排的话,正常的执行结果是:x=0,y=1。但是我们实际运行过程中会碰到这种情况,如下图:
可以发现,在循环第196558次的时候,既然出现了x=0,y=0的情况,发生这种情况的唯一可能就只有threadOne线程中的指令x=b跑到a=1前面,threadTwo中的指令y=a跑到b=1前面才可能发生,因为上面说了,单线程情况下,x=b和a=1谁在前面是不是都没影响,只有在多线程情况下,他们才可能由于指令重排造成意想不到的结果。
上面代码验证了指令重排可能造成的结果,接下来说一个我们最为常用的
如:双重校验锁单例下:
public class TestSingle {
private static TestSingle instance;
int i = 0;
private TestSingle(){
i = 13;
}
public static TestSingle getInstance(){
if(instance == null){
synchronized (TestSingle.class){
if(instance == null){
instance = new TestSingle();
}
}
}
return instance;
}
}
上面代码,可能发生的情景:
线程执行到new TestSingle时,由于指令重排机制,可能执行的顺序是1-2-3-4或者1-2-4-3。1-2是堆栈的内存分配,不会有指令重排的问题,总的来说就可以分为三个步骤:
- 1、分配对象内存(给instance分配内存)。
- 2、调用构造器方法,执行初始化(调用 TestSingle 的构造函数来初始化成员变量)。
- 3、将对象引用赋值给变量(执行完这步 instance就为非 null 了)。
这个时候,指令重排可能为:1-2-3或者是 1-3-2,如果是单线程下没有任何问题,但是多线程下就会有不同的结果了。
1-3-2的结果就有可能是:
线程A:1-3,但是2尚未得到执行时,线程B来了,进入第一个instance==null判断时,instance不为空,直接返回了instance对象,但是线程A还未执行2调用构造器,执行初始化方法。就会造成线程B获取的instance对象未空或者未初始化完成,i 未赋值为13,默认值为0,就造成了意想不到的结果。
所以,为了防止CPU在多线程下指令重排造成的影响,使用关键字volatile来解决。
好了,Java多线程编程之volatile关键字到此结束,有不同见解的请直接评论区指出,唯有不足才有继续成长的空间!
这里借这片文章再说一下,由于这段时间真的挺忙的,所以很少学习,也很少更新博客公众号,尽量多挤出来时间来学习和记录分享吧。