美文网首页java学习之路
Java Util Concurrent并发编程(五)volat

Java Util Concurrent并发编程(五)volat

作者: 唯有努力不欺人丶 | 来源:发表于2020-11-20 22:11 被阅读0次

    JMM

    JMM是什么呢?

    JVM:java虚拟机
    JMM: java内存模型。是一个概念,一个不存在的东西。
    主内存和工作内存:我们线程在工作中都会有一个缓存区域。有些值都过来会缓存起来。然后下次使用的时候直接使用。
    关于JMM的一些同步的约定:

    • 线程解锁前,必须把共享变量立刻刷回主存。
    • 线程加锁前,必须读取主存中的最新值到工作内存中。
    • 加锁和解锁是同一把锁。


      JMM八种操作

      下面用代码证明下这个情况:

    public class D1 {
        public static void main(String[] args) throws Exception {
            int num = 0;
            new Thread(()->{
                int i = 0;
                while (num == 0) {
                    System.out.println(i++);
                    
                }
            }).start();
            TimeUnit.SECONDS.sleep(1);
            System.out.println(num);
        }
    }
    

    正常情况下因为过一秒钟后main线程把num赋值为2,所以while会停止的。但是事实上这个会是一个死循环,因为这个匿名线程从主线程读取过来的num就是0。而这个时候,volatile的可见性就可以起作用了。

    volatile
    • volatile能够保证可见性
    • volatile不能保证原子性

    看下图:


    volatile不保证原子性

    如上代码,正常的话应该num是10000*10-1.但是结果明显的比这个小,说明volatile是不保证原子性的。其实这个demo中,num++本身就不是原子性的。看似就一行代码。但是本质上是get。add。set三个操作。所以这里不应该用num++。我们应该用原子类数据类型操作,我们去手册上看下juc下的包:


    juc中的包
    这三个中,只剩下我框起来的咱们还没看了。点开就能发现,里面就是各种数值类型的原子类。但是原子类的底层不是我们熟悉的synchronized或者lock。它的底层是cas。这个我们后面介绍。
    • volatile禁止指令重排
      什么是指令重排?我们写的程序,计算不并不是按照写的那样一模一样执行的。
      源代码->编译期优化的重排->指令并行可能会重排->内存系统也会重排->执行
      而指令重排的时候,会考虑数据之间的依赖性。如下代码:
    int x = 1; //1
    int y = 2; //2
    x = x+5; //3
    y = x*x; //4
    

    如上代码:我们以为是1,2,3,4这样的执行
    但是其实 2,1,3,4或者1,3,2,4也都是可能的
    但是,绝对不可能是 4,1,2,3(因为数据之间的依赖性,4中y是依赖x的)
    上面的代码是不影响结果的,但是有些时候是会影响的,比如下面的图片:


    指令重排导致的诡异情况

    而volatile是可以避免指令重排的:
    这里是内存屏障。CPU指令,作用是:

    • 保证特定的操作执行顺序。
    • 可以保证某些变量的内存可见性。(利用这些特性volatile实现了可见性)

    总结一下:volatile可以保证可见性不可以保证原子性。并且禁止指令重排。

    单例模式

    单例模式其实也是细分好多类型的。下面一一列举:
    饿汉式单例
    这里其实java中经常说的一种模式。饿汉式,因为很饿,所以上来就创建了。用饥不择食理解?(我感觉这里和懒汉式对应着理解比较好记。因为比较懒所以用的时候才创建)。反正大概就是这个意思。
    懒汉式单例
    这个我上面其实就简单的说了,相比于饿汉式的上来就创建,懒汉式是用的时候创建,这个可以节约一些不必要的资源。
    而说到懒汉式,不得不说代码相比于饿汉式要稍微麻烦一点。毕竟饿汉式上来就创建,非人为的,不存在创建多个,但是懒汉式常用的思维是判断是不是创建过了,没创建过才创建。这本身就是容易被多线程访问,如下代码:

    /**
     * 懒汉式单例
     * @author 11511
     *
     */
    public class Lazy {
        
        public static Lazy lazy;
        private Lazy(){
            
        }
        public Lazy getInstance() {
            if(lazy == null) {
                lazy = new Lazy();
            }
            return lazy;
        }
    }
    

    其实我们在构造器中添加个打印语句,同时多线程跑这个getInstance方法很容易就能看出多线程时这个方法是不安全的。

    上面的代码是不对的
    其实最直观的方法是在getInstance方法上加锁。但是这个锁的粒度太大了。非常影响性能。而且以后想要拿这个单例也要一个个排队拿。其实仔细分析就能明白这个锁加在方法上有多不合适了。所以这里最好是把锁加在创建单例对象的语句上面。并且如果加在new语句外面(这样起码创建完成后不会存在锁了)其实也会有问题,比如在外层==null中检测是null,所以进入到等锁的时候,但是这时候别的锁里把这个单例创建出来了。这里其实还是创建了两次的。所以这里有一个经典的模式:双重检测锁模式
    如下代码截图:
    双重检测锁模式
    但是其实这样还是有问题的。我们上面说了指令重排。比如说初始化对象分三步:
    • 分配空间
    • 初始化对象
    • 对象指向空间

    正常我们希望从上往下执行。但是!如果指令重排了,执行的1.3.2。有可能先对象指向空间,然后初始化对象。
    这个时候如果别的线程进入这个方法,因为执行完3觉得初始化完成了,使用了这个单例对象。本质上还没有初始化对象。所以这个时候这个对象是不能使用的,所以会出现各种诡异的问题!所以懒汉式单例还是要在对象上加个volatile来避免指令重排的!(注意:这里的volatile和什么可见性原子性一毛钱关系都莫得,单纯为了避免指令重排!)
    静态内部类
    这个方式其实也挺简单的,属于类加载就创建了的。我直接附上代码:

    /**
     * 静态内部类
     * @author 11511
     *
     */
    public class D2 {
        private D2() {
            
        }
        public static D2 getInstance() {
            return InnerClass.D2;
        }
        
        public static class InnerClass{
            private static final D2 D2 = new D2();
        }
    
    }
    

    反射
    但是,上面说了那么那么多,其实都没啥用。因为java中有个非常非常屌的技术:反射
    只要有反射,任何的私有关键字都是纸老虎。
    反射的知识其实挺多的,我这里就用到什么简单的说两句。
    先去官方手册上把要用到的方法说一说:

    之前说了反射必有的私有构造器,现在把这个构造器获取了
    获取到私有的这个构造器以后,破坏它:
    破坏用的方法是下面图中说明的。
    破坏private的修饰作用
    父子关系
    Contructor创建实例方法

    其实知道了这几个方法,我们就可以无限调用一个私有的构造器方法啦,下面上代码:

    /**
     * 懒汉式单例
     * @author 11511
     *
     */
    public class Lazy {
        
        public static Lazy lazy;
        private Lazy(){
            System.out.println("<<<<<创建单例对象");
        }
        public static Lazy getInstance() {
            if(lazy == null) {
                synchronized (Lazy.class) {//类锁
                    if(lazy == null) {
                        lazy = new Lazy();
                    }
                }
            }
            return lazy;
        }
        public static void main(String[] args) throws Exception{
            Lazy lazy = Lazy.getInstance();
            Constructor<Lazy> constructor = Lazy.class.getDeclaredConstructor();
            constructor.setAccessible(true);
            Lazy lazy2 = constructor.newInstance();
            System.out.println(Lazy.getInstance().equals(lazy));
            System.out.println(lazy.equals(lazy2));
        }
    } 
    
    反射破坏了双重检测锁模式的单例模式

    其实这个问题是可以解决的。因为走了构造器,所以我们可以设置构造器只能调用一次,否则报错。


    设置构造器只能走一起,否则报错

    其实就是在构造器里添加了点东西。这样刚刚那样反射获取对象会报错:


    根据i的值判断构造器只能走一次
    但是!你以为到这里就完事了么?不不不,毕竟这个i也是类种的一个属性。我就不能获取到属性,手动重置它么?如下代码:
    /**
     * 懒汉式单例
     * @author 11511
     *
     */
    public class Lazy {
        
        public static Lazy lazy;
        public static int i;
        private Lazy(){
            synchronized (Lazy.class) {
                if(i == 0) {
                    i++;
                    System.out.println("<<<<<创建单例对象");
                }else {
                    throw new RuntimeException("不要试图破坏单例模式!");
                }
            }
        }
        public static Lazy getInstance() {
            if(lazy == null) {
                synchronized (Lazy.class) {//类锁
                    if(lazy == null) {
                        lazy = new Lazy();
                    }
                }
            }
            return lazy;
        }
        public static void main(String[] args) throws Exception{
            //先看看这个类有什么属性
            Field[] fields = Lazy.class.getDeclaredFields();
            for(Field f:fields) {
                System.out.println(f);
            }
            //如果真的有心的话,可以把这些属性的值都获取到。下面用i做例子
            Field field = Lazy.class.getDeclaredField("i");
            System.out.println(field.getInt(lazy));
            Lazy lazy = Lazy.getInstance();
            //正常途径创建完单例后,把这些值改成没创建单例之前的
            field.set(lazy, 0);
            //再走私有构造器创建一个
            Constructor<Lazy> constructor = Lazy.class.getDeclaredConstructor();
            constructor.setAccessible(true);
            Lazy lazy2 = constructor.newInstance();
            System.out.println(lazy == lazy2);
            
        }
    
    } 
    
    在构造器上加限制也被破解了

    反正就是道高一尺魔高一丈,现在在构造器上加限制也没啥用了。那到底该怎么办呢?从newInstance源码开始找解决办法吧。点进去就会发现:

    newInstance源码
    敲黑板!!兄弟们看到没,红色框起来的这么显眼的一段话:不能反射创建枚举对象!!!
    所以说,想要是单例的,枚举是最安全的(枚举类型是jdk1.5出来的)。所以,到底为什么枚举就安全呢?这个Enum应该应该每个人都知道,我们一步一步测试原因。
    首先创建枚举类,常规测试看会有什么结果
    /**
     * 创建一个枚举类
     * @author 11511
     *
     */
    public enum EnumSingle {
        
        INSTANCE;
        
        public EnumSingle getInstance() {
            return INSTANCE;
        }
    
    }
    

    然后用常规的方法测试看会怎么样?

    image.png
    其实看报错,我觉得是可以理解的,毕竟我们的枚举类根本没写构造器,所以获取无参构造器不存在这个方法没啥问题。可是枚举作为一个类不用构造器的么?这是不可能的。所以我们还得往下找。这个枚举类构造器到底是什么玩意?这里编译器中是进不去Enum里瞅瞅了,所以手册上找吧。Enum是java.lang包下面的,我们去瞅瞅:
    Enum类
    这个类,有点东西啊,我们在手册中能看到Enum是有构造器的。而且根据刚刚的文字描述,我们可以大胆猜测一下,指不定我们这个枚举儿子也是这个构造器,只不过我们面上没看到而已。去试试代码:
    事实证明猜对了
    其实这个猜测大家也可以用事实做证据。反编译一下字节码啥的。
    反正至此,我们已经能创建出无法被破坏的单例啦, 所以你学会了如何彻底玩转单例模式么?
    本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利,周末愉快哟!

    相关文章

      网友评论

        本文标题:Java Util Concurrent并发编程(五)volat

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