美文网首页
你需要了解锁的前提-volatile

你需要了解锁的前提-volatile

作者: 万古Meth | 来源:发表于2020-06-20 23:44 被阅读0次

    前言

    java 中鼎鼎有名的 AQS 维护 private volatile int state 状态实现了用户态的锁。你如果不了解 volatile ,你看 AQS 的源码应该很难理解为什么Lock 能保证线程安全。

    volatile 绝对是你打通 java 的任督二脉的首要条件。votaile 的特性很简单,可见性禁止指令重拍,如果你自己写代码验证过这两个特点,接下来的内容应该对你帮助不大。

    单例模式懒汉式 的写法(DCL)是可以检验你对 volatile 的了解,这也是面试中被问频率较高的问题。

    本文将会介绍如下内容:

    • volatile 的可见性是什么,有什么用
    • volatile 禁止指令重排是个什么东东
    • 单例模式,饿汉式和懒汉式(DCL)的写法,分析 (DCL)
    • 伪共享是什么,怎么避免伪共享

    volatile

    可见性

    image-20200620152106412

    计算机 CPU主存 交互的逻辑大致如图,CPU 的运算速度是 主存 的 100 倍左右,为了避免 CPU 被主存拖慢速度。当CPU 需要一个数据的时候,会先从 L1 找,找到直接使用;L1 中未找到,会去 L2 中,L2 中找不到会去 L3 ,L3 找不到再去主存加载到 L3,再从 L3加载到 L2 ,再从 L2 加载到 L1

    这样提高的计算速度,同时也面临数据不一致问题。

    主存中现在有一个变量 a=1,CPU1 a+1 之后,将结果 a=2 放入到 L1 去,但是后续代码计算还会用到 a,这时 CPU1 不会将 a=2 同步到主存中去。之后 CPU2 也从主存中取出变量 a(a=1),CPU2 将 a+2 的结果放入到 L1 中。这样就造成了数据不一致问题。缓存一致性协议就是为了解决这个问题的。

    以上是计算机底层的实现原理,JAVA 在自己的虚拟机中执行,也有自己的内存模型,但不管怎么样,底层还是依靠的 CPU 指令集达到缓存一致性。JAVA 的内存模型屏蔽了不同平台缓存一致性协议的不同实现细节,定义了一套自己的内存模型。

    image-20200620153620353

    java 虚拟机中的变量全部储存在主存中,每个线程都有自己的工作内存,工作内存中的变量是主存变量的副本拷贝(使用那些变量,拷贝那些),每个线程只会操作工作内存的变量,当需要保存数据一致性的时候,线程会将工作内存中的变量同步到主存中去。volatile 就是让线程改变了 a 之后,回写到主存中,已达到缓存一致。

    接下来代码体会一下,带不带 volatile 的区别。

    public class VolatileDemo {
        private static  int a = 0;
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                while (a == 0) {
                }
            }, "线程 1").start();
            System.out.println("修改 a=1 之前");
            Thread.sleep(3000);
            a = 1;
            System.out.println("修改 a=1 之后");
        }
    }
    

    运行这个程序,代码会一直运行,不会停止。这是因为 线程1 的工作内存 a 为 0,而主线程尽管修改了 a,但不会达到线程1重新加载主存中的变量 a。

    public class VolatileDemo {
        // 代码的区别只是加了 volatile
        private static volatile int a = 0;
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                while (a == 0) {
    
                }
            }, "线程 1").start();
            System.out.println("修改 a=1 之前");
            Thread.sleep(3000);
            a = 1;
            System.out.println("修改 a=1 之后");
        }
    }
    

    打印 修改 a=1 之后 程序停止。这是因为 volatile 标记的变量 a,主线程修改之后,并同步回主存,当其他的线程再使用变量 a 的时候,java 内存模型会让线程从主存加载变量 a。这就是 volatile可见性 特点。

    禁止指令重排

    java 中的字节码最终都会编译成机器码(CPU 指令)执行,CPU 在保证单线程中执行结果不变的情况下,可以对指令进行指令重排已达到提高执行效率。

    public class VolatileOrdering2 {
        static  int b = 1;
        public static void main(String[] args) throws InterruptedException {
            int a = 0;
            b = 2;
            a += 1;
            System.out.println(a);
        }
    }
    

    上述代码指令重排执行顺序的可能:

    int a=0;
    a+=1;
    System.out.println(a);
    int b = 2;
    

    网上也有人写的 demo 验证可能会发生指令重排的小程序

    public class T04_Disorder {
        private static int x = 0, y = 0;
        private static int a = 0, b =0;
    
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
            for(;;) {
                i++;
                x = 0; y = 0;
                a = 0; b = 0;
                Thread one = new Thread(new Runnable() {
                    public void run() {
                        //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                        //shortWait(100000);
                        a = 1;
                        x = b;
                    }
                });
                Thread other = new Thread(new Runnable() {
                    public void run() {
                        b = 1;
                        y = a;
                    }
                });
                one.start();other.start();
                one.join();other.join();
                String result = "第" + i + "次 (" + x + "," + y + ")";
                if(x == 0 && y == 0) {
                    System.err.println(result);
                    break;
                } else {
                    //System.out.println(result);
                }
            }
        }
        public static void shortWait(long interval){
            long start = System.nanoTime();
            long end;
            do{
                end = System.nanoTime();
            }while(start + interval >= end);
        }
    }
    

    假设指令重排不会发生,那么 result 将不会打印,实际循环 n 次之后会打印 result

    volatile 可以禁止指令重排。

    image-20200620174915155

    大致简单理解,加了内存屏障之后,代码分成 1,2,3部分。1 部分代码你怎么指令重排我不管,但是 1 部分代码执行完了之后,必须执行 2 部分代码,再执行 3 部分代码。

    单例模式

    饿汉式

    public class SingletonDemo {
        private static final SingletonDemo INSTANCE = new SingletonDemo();
        private SingletonDemo() {
        }
        public static SingletonDemo getInstance() {
            return SingletonDemo.INSTANCE;
        }
    }
    

    一般项目中我们用这种用法即可,简单方便,也没谁闲着无聊利用别的手段给你打破单例。

    懒汉式

    饿汉式不管你用不用这个单例,只要类加载,单例就给你初始化好了。有的人就想让其懒加载,节约那可怜的内存,用的时候单例再实例化。

    public class SingletonDemo1 {
        private SingletonDemo1() {
        }
    
        public static SingletonDemo1 getInstance() {
            System.out.println("SingletonDemo1Holder 类加载");
            return SingletonDemo1Holder.getInstance();
        }
    
        private static class SingletonDemo1Holder {
            private static final SingletonDemo1 INSTANCE = new SingletonDemo1();
            public static SingletonDemo1 getInstance() {
                return SingletonDemo1Holder.INSTANCE;
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            System.out.println(SingletonDemo1.getInstance());
            System.out.println(SingletonDemo1.getInstance());
        }
    }
    

    运行的时候加上这个 -XX:+TraceClassLoading 会打印加载的类。

    image-20200620182245333

    从图中我们可以看到调用 SingletonDemo1.getInstance() 的时候,才加载的 SingletonDemo1Holder 类,再实例化单例,达到懒加载的要求。

    DCL 实现单例

    以上单例的实现看着没啥技术含量,下面介绍一下 DCL (Double-checked locking),双重检查锁的实现,这也是面试会问到的点。

    public class SingletonDemo2 {
        // 考点在这里,要不要加 volitale
        private volatile static SingletonDemo2 INSTANCE;
        private SingletonDemo2() {
    
        }
        public static SingletonDemo2 getInstance() {
            if (INSTANCE == null) {
                synchronized (SingletonDemo2.class) {
                    if (INSTANCE == null) {
                        // 对象实例化
                        INSTANCE = new SingletonDemo2();
                    }
                }
            }
            return INSTANCE;
        }
    }
    

    对象实例化实际可以分为几个步骤:

    1、分配对象空间

    2、初始化对象

    3、将对象指向分配的内存空间

    当指令重排的时候,2 和 3 会进行重排序,导致有的线程可能拿到未初始化的对象调用,存在风险问题。

    伪共享

    volatile 给我们带来了变量 可见性 的功能,但是当使用不当,会掉入另一个 伪共享 的坑。先看 demo.

    public class VolatileDemo3 {
        private static volatile Demo[] demos = new Demo[2];
    //    @sun.misc.Contended
        private static final class Demo {
            private volatile long x = 0L;
        }
        static {
            demos[0] = new Demo();
            demos[1] = new Demo();
        }
        public static void main(String[] args) throws InterruptedException {
            Thread thread1 = new Thread(() -> {
                for (long i = 0; i < 10000_0000L; i++) {
                    demos[0].x = i;
                }
            });
            Thread thread = new Thread(() -> {
                for (long i = 0; i < 10000_0000L; i++) {
                    demos[1].x = i;
                }
            });
            long start = System.nanoTime();
            thread.start();
            thread1.start();
            thread.join();
            thread1.join();
            long end = System.nanoTime();
            long runSecond = (end - start) / 100_0000;
            System.out.println("运行毫秒:" + runSecond);
        }
    }
    

    上述代码,存在伪共享的情况,我电脑运行 运行毫秒:2764

    // 运行的时候,需要加上参数 -XX:-RestrictContended
    public class VolatileDemo3 {
        private static volatile Demo[] demos = new Demo[2];
        @sun.misc.Contended
        private static final class Demo {
            private volatile long x = 0L;
        }
        static {
            demos[0] = new Demo();
            demos[1] = new Demo();
        }
        public static void main(String[] args) throws InterruptedException {
            Thread thread1 = new Thread(() -> {
                for (long i = 0; i < 10000_0000L; i++) {
                    demos[0].x = i;
                }
            });
            Thread thread = new Thread(() -> {
                for (long i = 0; i < 10000_0000L; i++) {
                    demos[1].x = i;
                }
            });
            long start = System.nanoTime();
            thread.start();
            thread1.start();
            thread.join();
            thread1.join();
            long end = System.nanoTime();
            long runSecond = (end - start) / 100_0000;
            System.out.println("运行毫秒:" + runSecond);
        }
    }
    

    上述代码,使用 @sun.misc.Contended 避免伪共享,我电脑运行 运行毫秒:813

    相似的用法在 ConcurrentHashMap 可以看到,

    @sun.misc.Contended 
    static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }
    

    上述代码展示了伪共享会降低代码的运行速度。什么是伪共享呢。

    还记得 Cpu 中的 L1 L2 L3 吗,主存中的数据加载到 Cpu 的高速缓存的最小单位就是 缓存行(64 bit)。Cpu 的缓存失效,也是以缓存行为单位失效。

    Cpu 从内存加载数据的时候,它会把可能会用到的数据和目标数据一起加载到 L1/L2/L3 中。上述代码的变量 private static volatile Demo[] demos = new Demo[2]; 这两个变量被一起加载到同一个缓存行中去了,一个线程修改了其中的 demos[0].x 导致缓存行失效,另一个线程修改 demos[1].x = i; 的时候发现缓存行失效,会去主存重新加载新的数据,两个线程相互影响导致不停从内存加载,运行速度自然降低了。

    @sun.misc.Contended 作用就是让其单独在一个缓存行中去。

    我们也可以通过对齐填充,而避免伪共享。

    缓存行 通常都是 64 bit。而 long 为 8 个 bit,我们自己补充 7 个没有用 long 变量就可以让 x 和 7个没用的变量单独一个缓存行

    public class VolatileDemo3 {
        private static volatile Demo[] demos = new Demo[2];
        private static final class Demo {
            private volatile long x = 0L;
            // 缓存行对齐填充的无用数据
            private volatile long pading1, pading2, pading3, pading4, pading5, pading6, pading7;
        }
        static {
            demos[0] = new Demo();
            demos[1] = new Demo();
        }
    
        public static void main(String[] args) throws InterruptedException {
            Thread thread1 = new Thread(() -> {
                for (long i = 0; i < 10000_0000L; i++) {
                    demos[0].x = i;
                }
            });
            Thread thread = new Thread(() -> {
                for (long i = 0; i < 10000_0000L; i++) {
                    demos[1].x = i;
                }
            });
            long start = System.nanoTime();
            thread.start();
            thread1.start();
            thread.join();
            thread1.join();
            long end = System.nanoTime();
            long runSecond = (end - start) / 100_0000;
            System.out.println("运行毫秒:" + runSecond);
        }
    
    
    }
    

    本文由 张攀钦的博客 http://www.mflyyou.cn/ 创作。 可自由转载、引用,但需署名作者且注明文章出处。

    如转载至微信公众号,请在文末添加作者公众号二维码。微信公众号名称:Mflyyou

    相关文章

      网友评论

          本文标题: 你需要了解锁的前提-volatile

          本文链接:https://www.haomeiwen.com/subject/jnmnxktx.html