一个volatile引发的 murder case

起因
case1

面试官:说一下你对volatile的理解?

我:
概念:保证线程间访问共享变量的可见性。

面试官:具体一点。

原理:volatile底层是通过C语言汇编前缀指令lock实现,他做的事情是,某一线程副本变量一修改,变量值会立即回写到主内存,这一操作会加一把锁,防止其他线程脏读,MESI缓存一致性协议会通过cpu总线嗅探机制将其他线程副本变量失效,重新读取主内存共享变量值。

case2

面试官:你的解释能不能让门口卖冰棍的老奶奶明白呢?

我(卡布达超级进化):

public class Lakers {

    private static boolean isFlag = false;


    public static void main(String[] args) throws InterruptedException {
        
        new Thread(()-> {
                System.out.println("james go");
                while (!isFlag) {
                }
                System.out.println("Already interrupted");

        },"james").start();

        TimeUnit.SECONDS.sleep(2);

        new Thread(()-> {
                justDoIt();
                System.out.println("davis downtown!");

        },"davis").start();
    }

    private static void justDoIt() {
        isFlag = true;
    }
}

代码描述:起初flag=true,线程james启动,正在while循环,2s后线程davis启动将isFlag改为true,讲道理james线程while (! true)终止,执行打印操作。
在这里插入图片描述

好长时间过去了,为什么线程还没有终止?

原因:首先,线程没有终止是因为他还在while循环,上述代码线程davis中已经做了修改isFlag=true,我们就结合下图来分析一下:

请大家记住编号,要考!
在这里插入图片描述
分析:线程1也就是james线程读取主内存共享变量值,加载到工作内存中,所以while(true)循环,CPU空转,2s后线程2也就是davis线程启动,同样读取主内存isFlag变量值并且加载到工作内存中,修改isFlag变量值为true,将变量值写到主内存当中,此时主内存和线程2中变量值isFlag都为true,但是线程1无法读取主内存中的值,也就是说这一过程对线程1是不可见的,还有一点,在整个过程中我们无法确定线程2修改完成之后会什么时间写入主内存。
在没有规定JMM理想情况下,执行流程是这样子的:
CPU缓存一致性协议MESI
M: Modify(修改)
E: Exclusive(独享)
S: Shared(共享)
I: Invalid(失效)
起初线程1也就是james线程会从主内存中读取isFlag,并且通过CPU总线嗅探机制读取到线程1工作内存当中,并置为E状态,2s后CPU发送指令,线程2也就是davis线程读取主内存isFlag变量值,线程1通过嗅探总线把缓存置为S状态,此时,线程1和线程2的缓存都为S状态,线程2对isFlag变量进行修改,缓存置为M状态,线程1在S状态类似监听,发现了线程2缓存为M状态,通过嗅探机制将缓存置为I无效状态。一个M状态的缓存行必须得写入主内存,缓存状态置为E独享,线程1读取主内存isFlag变量值,线程2通过嗅探总线发现线程1读取主内存共享变量的值,将缓存置为S共享状态,线程1缓存状态也置为S,最终线程1会停止。
按照多核CPU多线程MESI缓存一致性协议,共享变量必定会在某一时刻保持数据统一,但是为什么线程1不会终止呢?
这里与JMM内存模型有关,他屏蔽了操作系统和各种硬件对内存访问的差异性,所以需要添加volatile关键字来解决。

private volatile static boolean isFlag = false;

执行结果:
在这里插入图片描述
针对isFlag修改操作,我们看一下汇编指令:

 private static void justDoIt() {
        isFlag = true;
    }

JVM初始化参数:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VisibilityThreadTest.justDoIt
添加文件:
在这里插入图片描述

看打印结果:
在这里插入图片描述
多核缓存架构图:
在这里插入图片描述

整条指令代表的意思是:将isFlag压入栈顶,由寄存器参与运算赋值,通过CPU高速缓存最终写入主内存,在执行期间要访问的内存区域会被lock指令锁定,这块区域会被包含在整个缓存行,可以理解为isFlag=true,该缓存行处于M修改状态,在锁定期间,其它cpu无法读写该lock指令访问的内存区域。

那么整个流程又是怎样的呢?

良心巨作:
在这里插入图片描述

流程:lock指令锁定这块区域缓存行(缓存锁定),保证了我们线程2修改完成,也就是assign操作,isFlag=true,根据IA-32手册中关于lock指令作用的描述,此时会立即写回到系统内存,途中经过CPU嗅探总线,通过MESI缓存一致性协议,触发嗅探机制将线程1缓存行状态置为I无效状态,此时线程1 while循环无法使用无效值,要读取主内存中变量值,此时线程2处于store操作中,锁定了主内存缓存行数据,其它线程无法读取。只有主内存数据同步完成之后,线程1读取主内存isFlag=ture值,写回工作内存,最终线程1停止。
StoreLoad屏障: 简单提一下,这里不仅仅是通过MESI缓存一致性协议,还通过StoreLoad屏障,也就是线程2对共享变量isFlag的修改要立即写入主内存并且对所有CPU可见,后续详细解释。

描述(参考IA-32手册):早期的IA-32处理器中,LOCK前缀会使处理器执行当前指令时产生一个LOCK#信号,这种总是引起显式总线锁定出现。当一个CPU从主内存读取到高速缓存,会对整个总线加锁,不存在缓存行锁定的概念,阻挡其它CPU对主内存的读写操作,这种串行执行方式效率是很低的,只有当当前CPU操作数据完成释放锁其它CPU才能读取。

面试官:多线程情况下会出现可见性、有序性、原子性问题,你只解释了可见性,其他问题怎么解决呢?

我:
有序性问题主要是由重排序造成的。

JIT(Just In Time)编译器指令重排
JIT编译器指令重排也就是说生成的机器指令顺序和字节码指令顺序不一致,JIT这么做是为了减少对寄存器的操作次数,减少不影响最终结果的重复指令操作,以达到优化的目的。
CPU指令优化
CPU指令优化造成的,但是为什么指令优化就会导致乱序呢,主要原因还是硬件空闲,CPU总是顺序去执行指令,但是一个指令执行并不是其它指令都在漫长等待,也就是说CPU指令运行并不是串行。比如INC和DEC指令,INC先于DEC执行,但是在INC执行完成之前,DEC执行完毕。CPU指令优化是根据上下文因果做处理,我们所说的乱序执行是因为CPU指令优化导致执行顺序发生变化。
内存系统重排序(属于可见性问题)
下文解释。
举一个例子:

public class MemoryBarrierTest {

    static int x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        //存放a, b的值
        Map<String, Integer> map = new HashMap<>();
        //展示a, b出现的结果
        Set<String> set = new HashSet<>();
        for (;;) {
            x = 0;y = 0;
            map.clear();
            Thread t1 = new Thread(() -> {
                int a = y;
                x = 1;
                map.put("a", a);
            });

            Thread t2 = new Thread(() -> {
                int b = x;
                y = 1;
                map.put("b", b);
            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();
            //将所有a, b可出现的结果添加到set集合
            set.add("a=" + map.get("a") + " " + "b=" + map.get("b"));
            if (set.size() == 4) {
                TimeUnit.SECONDS.sleep(1);
            }
            System.out.println(set);
        }
    }
}

对于a和b,大家肯定都能推断出来一种答案,a=0,b=1,这个没有任何疑问,附图:
在这里插入图片描述
执行顺序,附图:

在这里插入图片描述
但是细心的伙伴发现了,a=1,b=0,怎么出现的?附图:
在这里插入图片描述

只有这两种结果吗,no!

在这里插入图片描述
a=0,b=0 结果所表现出来的执行顺序可能是:

在这里插入图片描述
按照正常的线程处理逻辑,可能会出现这三种结果,每一种结果有“一百万个可能”执行顺序,并不能证明循环下去不会出现CPU指令优化、编译器优化、内存优化导致的问题。但是我们通过打印发现了乱序问题:
在这里插入图片描述

先不考虑CPU指令优化、编译器优化、内存优化,推导a=1,b=1如何出现的:

在这里插入图片描述

当然导致a=1,b=1结果出现的可能有很多,我只列举了一例,但是a=1,b=1必然是出现了乱序问题,那么又是如何导致的?

  1. 仅仅针对某一线程,无论怎么优化都不影响最终结果。显然线程t1中(int a = y; x=1)
    无论怎么重排序都不会影响他自己的结果,不存在任何单线程上下文依赖关系,只保证了指令间的显示因果关系,因为t1不知道他自己会对t2造成什么影响(隐式因果),这也就是编译器、处理器、内存都会保证单线程下的 As-if-serial语义。

  2. 在计算机系统中,各CPU并不是不断地冲击主内存,而是会利用缓存提高自己的性能,也就是说缓存数据并不是与主内存实时同步。
    在这里插入图片描述
    更确切的说这是一个可见性问题,没有volatile的load操作支撑,并且CPU与主存非同步关系可能导致各线程共享变量在某一时刻是不一致的。
    怎么解决呢?

static volatile int x = 0, y = 0;

volatile又是怎么搞定的?

我们简单看一下hotspot源码对volatile的操作。

前方高能:
1. openjdk\hotspot\src\share\vm\utilities\accessFlags.hpp
判断volatile是否存在
在这里插入图片描述
2. openjdk\hotspot\src\share\vm\interpreter\bytecodeInterpreter.cpp
在这里插入图片描述
在这里主要对基本数据类型进行判断,操作(store)主内存的变量值,我们以int类型为例,release_int_field_put方法调用栈。
3. openjdk\hotspot\src\share\vm\oops\oop.hpp
在这里插入图片描述
4. openjdk\hotspot\src\share\vm\oops\oop.inline.hpp
在这里插入图片描述
5. openjdk\hotspot\src\share\vm\runtime\orderAccess.hpp
在这里插入图片描述
6. hotspot\src\os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp
在这里插入图片描述
描述:基于volatile,整套调用流程属于C++ HotSpot原语实现,也就是工作内存数据回写到主内存的过程。
7. 回到openjdk\hotspot\src\share\vm\interpreter\bytecodeInterpreter.cpp
在这里插入图片描述
**8.**openjdk\hotspot\src\share\vm\runtime\orderAccess.hpp
在这里插入图片描述
9. hotspot\src\os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp
在这里插入图片描述
在这里插入图片描述
windowslinux对fence方法的实现是有区别的:
在这里插入图片描述

注意: JVM volatile和C++ volatile相似,但是JVM volatile!=C++ volatile
总结:上述执行流程很明显,针对一个线程写(store)操作,加了StoreLoad屏障,我们看看作者怎么解释的:

// StoreLoad:  Store1(s); StoreLoad; Load2
//
// Ensures that Store1 completes before Load2 and any subsequent load
// operations.  Stores before Store1 may *not* float below Load2 and any
// subseqeuent load operations.
//

作者解释说:确保Store1在Load2和任何后续加载操作之前完成。Store1之前的Stores操作可能不会浮动在Load2和任何后序加载操作之下。

一幅图简单展示加了volatile(java内存屏障)作用:
在这里插入图片描述
总结:内存屏障概念性东西太多,而且晦涩难懂,我尽量用实例演示形式,其他涉及的知识笔者在这不多解释。

面试官:好,那么原子性问题呢?

我:
volatile无法解决原子性问题,但是synchronized能解决原子性问题:

public class AtomicVolatileTest {

    private static volatile int num = 0;

    private static void downtown() {
        num++;
    }

    private static void justDo() throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    downtown();
                }
            });
            threads[i].start();
        }
        //保证分线程在此执行完毕, 最终回到主线程
        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println(num);
        num = 0;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            justDo();
        }
    }
}

看执行结果:
在这里插入图片描述
针对一个加了volatile关键字的num变量,出现10000情况是理想结果,10个线程之间共享变量没有形成相互干扰,那么怎么算是相互干扰?
上代码:

public class AssemblyTest {

    static volatile int james = 0;

    public static void main(String[] args) {
        james++;
    }
}

java代码层面,main方法只对james变量执行了一次++操作,我们看字节码main方法做了什么。
在这里插入图片描述

getstatic: 从类中获取静态字段,并将其值压入栈顶
iconst_1: 将int类型常量1送入栈顶
iadd: 将栈顶两int型数值相加并将结果压入栈顶
putstatic: 用栈顶的值为指定的类的静态字段赋值

汇编:
在这里插入图片描述
大家看到了没,inc 指令没有被lock到,目前num值为0,假设一千万个线程++到num值为10000000,我还在inc过程,inc完毕我的num值是1,lock过程会让其他线程共享变量num失效的,我了GQ,大家知道上述代码为什么会出现小于10000的问题吗?夸张的说法,CPU指令执行速度是相当快的。
结论: 这很明显 ++ 不是原子操作,也就证明了volatile不能解决 ++ 所产生的原子问题,无论是字节码还是反汇编都不能保证,他只能保证基本数据类型基本运算的原子操作。

那么如何解决呢?
1. 上述代码添加synchronized关键字可以解决:

private static synchronized void downtown() {
        num++;
    }

在这里插入图片描述
2. 可以使用JUC包下的AtomicInteger

	private static AtomicInteger curry = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {

        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            Thread thread=new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    curry.incrementAndGet();
                }
            });
            thread.start();
            threads[i] = thread;
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println(curry.get());
    }

结果必然是:10000,大家自行测试。

AtomicInteger底层又是怎么做的呢?
教大家一波S操作,手写一个AtomicInteger:

public class AtomicDoTest {

    private static Unsafe UNSAFE = null;
    private static long NUM_OFFSET;
    int num = 0;

    public int getNum() {
        return num;
    }

    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);
            //从主内存获取该字段的值
            NUM_OFFSET = UNSAFE.objectFieldOffset(AtomicDoTest.class.getDeclaredField("num"));

        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    public final int incrementAndGet() {
        this.num = UNSAFE.getAndAddInt(this, NUM_OFFSET, 1) + 1;
        System.out.println("NUM_OFFSET = " + UNSAFE.getIntVolatile(this, NUM_OFFSET));
        return this.num;
    }

//    public final int incrementAndGet() {
//        UNSAFE.compareAndSwapInt(this, NUM_OFFSET, this.getNum(), this.num + 1);
//        System.out.println("NUM_OFFSET = " + UNSAFE.getIntVolatile(this, NUM_OFFSET));
//        return this.num;
//    }


    public static void main(String[] args) throws InterruptedException {
        AtomicDoTest atomicTest = new AtomicDoTest();
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            Thread thread=new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    atomicTest.incrementAndGet();
                }
            });
            thread.start();
            threads[i] = thread;
        }

        for (Thread thread : threads) {
            thread.join();
        }
        System.out.println(atomicTest.getNum());
    }

}

Unsafe不让你new怎么办,反射获取属性值,通过field.get(Object obj) 方法得到unsafe对象。unsafe中objectFieldOffset方法可以获取主存字段值,你可以通过compareAndSwapInt(底层原子操作指令)方法操作对象属性进行+1,或者你直接采用getAndAddInt方法+1(先从内存获取属性值然后代码层面+1),两种方法都可以实现。
看执行结果:

在这里插入图片描述

面试官邪魅一笑:ok,那么你对Spring FrameWork了解多少呢?

我:
What can I say, Mamba out !
——For Kobe Bryant

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值