java学习之路

Java Util Concurrent并发编程(五)volat

2020-11-20  本文已影响0人  唯有努力不欺人丶

JMM

JMM是什么呢?

JVM:java虚拟机
JMM: java内存模型。是一个概念,一个不存在的东西。
主内存和工作内存:我们线程在工作中都会有一个缓存区域。有些值都过来会缓存起来。然后下次使用的时候直接使用。
关于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不保证原子性

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


juc中的包
这三个中,只剩下我框起来的咱们还没看了。点开就能发现,里面就是各种数值类型的原子类。但是原子类的底层不是我们熟悉的synchronized或者lock。它的底层是cas。这个我们后面介绍。
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可以保证可见性不可以保证原子性。并且禁止指令重排。

单例模式

单例模式其实也是细分好多类型的。下面一一列举:
饿汉式单例
这里其实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是有构造器的。而且根据刚刚的文字描述,我们可以大胆猜测一下,指不定我们这个枚举儿子也是这个构造器,只不过我们面上没看到而已。去试试代码:
事实证明猜对了
其实这个猜测大家也可以用事实做证据。反编译一下字节码啥的。
反正至此,我们已经能创建出无法被破坏的单例啦, 所以你学会了如何彻底玩转单例模式么?
本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利,周末愉快哟!
上一篇下一篇

猜你喜欢

热点阅读