Android高级进阶-Java多线程编程之volatile关键字

锁在多线程编程或者说并发编程中极为重要,善用锁有助于避免程序出现意想不到的错误。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。其最终结果是:


image.png

为什么呢?明明第二个线程修改了flag变量的值为true,那第一个线程中while(!flag)应该不会进入循环才对,应该最终会打印22222才对。其实这里就是因为两个线程内存不可见性导致,两个线程中的flag都是变量flag变量的一个副本,第二个线程修改flag=true并不影响第一个线程中的flag。其实在IDEA中已经有所提醒了:


image.png

那我们在申明flag变量的地方加上volatile关键字对flag变量进行修饰后再执行结果:
public static volatile boolean flag = false;
image.png

⚠️ 注意: ⚠️ 这个时候,我将上面例子换一个顺序再执行,结果又不尽相同:

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();

    }

}

这个时候到执行结果如下:
image.png

这里就需要抛出一个疑问,不是说两个线程中的变量都是副本么?这里第一个线程改了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:

image.png

volatile 的内存可见性就说这么多了,这里再放一张图,线程工作内存和主存之间的关系:(偷来的图,反正我画的贼难看...)
image.png

  • 防止指令重排:一个对象的赋值过程有四个步骤(指令):new出一个对象存放在栈中、对象引用的赋值(堆中)、对象执行构造方法,对象栈与堆之间的引用建立连接。如下代码:
public class ObjTest {

    public static void main(String[] args) {
        Obj obj = new Obj();
    }
}

class Obj{
    int i = 10;
}

转换字节码后的指令四个过程,过程如:


image.png

在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。但是我们实际运行过程中会碰到这种情况,如下图:


image.png

可以发现,在循环第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关键字到此结束,有不同见解的请直接评论区指出,唯有不足才有继续成长的空间!
这里借这片文章再说一下,由于这段时间真的挺忙的,所以很少学习,也很少更新博客公众号,尽量多挤出来时间来学习和记录分享吧。

你可能感兴趣的:(Android高级进阶-Java多线程编程之volatile关键字)