[线程]单例模式 及 指令重排序
文章目录
- 一. 单例模式
- 饿汉模式
- 懒汉模式
- 单例模式中涉及到的线程安全问题
- 二. 指令重排序引起线程安全问题
一. 单例模式
单例模式, 是一种经典的设计模式
设计模式:
类似于棋谱, 把编程中各种经典的问题场景给你盘一盘, 并给出一下解决方案
遇到这种场景, 代码就这样写, 绝对不会很差
单例模式, 就是指单个实例, 整个进程中的某个类, 有且只有一个对象(不能new很多对象)
例如: 某些业务角度, 有些对象就应该是单例的
比如, 你写的服务器, 要从硬盘上加载100G的数据到内存中, 肯定要写一个类, 封装上述加载操作, 并且写一些获取 / 处理数据的业务逻辑, 这样的类, 就应该是单例的, 因为一个实例, 就要管理100G的内存数据, 搞多个实例, 就是N * 100G的内存数据, 机器肯定吃不消, 也没必要搞这么多重复的
保证类只有一个实例, 需要让编译器来帮我们做一个强制的检查, 通过一些编码上的技巧, 使编译器可以发现咱们代码中是否有多个实例, 并且在尝试创建多个实例的时候, 直接编译出错
单例模式, 有很多种不同的写法, 这里我们只介绍两种: 饿汉模式, 懒汉模式
用Singleton类表示单例模式, instance作为实例
饿汉模式
第一步:
static修饰的, 其实是"类属性", 就是在"类对象"上的, 每个类的类对象在JVM中只有一个, 那么里面的静态成员就只有一份了
static成员初始化的时机是在类加载的时候, 此处可以简单理解为, JVM一启动, 就立即加载
此时, instance就是一个实例(引用), 该实例只有一份(static修饰)
第二步:
后续, 我们想要拿到这个对象, 直接调用getInstance, 而不是new
第三步:
将构造方法设成private, ,这样就防止后续又new了这个类的实例
类之外的代码, 尝试new的时候, 势必要调用构造方法, 但是由于构造方法是私有的, 无法调用, 就会编译出错!!
懒汉模式
懒汉模式, 不是在程序启动的时候创建实例, 而是砸以第一次使用的时候才去创建(如果不使用了, 就会把创建实例的代价节省下来)
这样用一个条件判断, 就保证了在第一次调用的时候才创建对象, 如果没调用, 就不创建了
如果代码中存在多个单例类, 使用饿汉模式, 就会导致这些实例都是在程序启动的时候扎堆创建的, 可能把程序启动的时间拖慢
如果是懒汉模式, 啥时候首次调用, 啥时候创建, 调用时机是分散的, 就不太会出现卡顿问题
单例模式中涉及到的线程安全问题
结合线程不安全的原因:1. 线程抢占式执行 2. 多个变量同时修改同一个变量 3. 修改操作不是原子的
饿汉模式:
创建实例的时机是在java进程启动时, 比main调用的时机还早, 那么后续代码里创建线程, 一定比实例创建还要迟, 后续线程调用getInstance的时候, 意味着上述实例早就有了, 而getInstance只干一件事, 就是读取上述静态变量的值, 此时多个线程读取同一变量, 是线程安全的
懒汉模式:
上述代码, 在cpu上的执行顺序, 可能如下:
此时t1已经new了之后, t2会继续在new一次
此时, instance是static修饰的, 只有一个引用
执行t1时, instance指向t1new的对象, 执行t2后, 不会创建其他引用, 还是instance指向t2new的对象, 会覆盖掉t1对象
此时t1对象没有引用指向了, 所以就会被GC回收
但是虽然还是一个对象, 但是毕竟是创建过, 时间开销还是客观存在的
此时, 我们可以通过加锁来解决这个问题
把if和new打包成一个原子操作:
此时, t2只能等t1创建完对象之后, 在判断条件, 此时条件不成立, 直接返回t1创建的对象了
但是, 加锁是个非常低效的操作, 我们只有在第一次调用getInstance方法时, 才需要加锁, 后续再调用, 只是读操作, 并不存在线程安全问题, 不必加锁了
所以我们对代码进行改进:
加上判断条件, 如果此时是第一次调用getInstance, 那么就需要加锁
外层条件是判断是否需要加锁
里层条件是判断是否需要new对象
碰巧两次条件相同, 但是缺一不可!!
但是, 上述代码还可能会涉及到内存可见性问题
比如, t1线程修改了instance引用, t2有可能读不到, 但是这种概率比较小
若依我们可以给对象加上volatile
另一方面, volatile不仅能够解决内存可见性问题, 还可以解决指令重排序的问题
二. 指令重排序引起线程安全问题
指令重排序, 也是编译器的一种优化策略
编译器有很多种优化策略, 把读内存优化到读寄存器, 指令重排序, 循环展开, 条件分支预测…
我们写的代码, 最终会编译成一系列的二进制指令, 正常来说, CPU应该是按照顺序, 一条一条的执行的
但是编译器比较智能, 会根据实际情况, 生成二进制指令的执行顺序, 和你最初写的代码的顺序可能会存在差异, 但是前提是逻辑是等价的, 这么优化的目的就是为了提高效率
在单线程下, 编译器进行指令重排序的操作, 一般都是没问题的, 编译器能准确的识别出, 哪些操作可以重排序, 而不会影响到逻辑
但是, 多线程下, 判定就可能不准确了, 就可能会出现重排序后, 逻辑发生了改变, 从而引起bug
这行代码, 其实还可以简单分成三个步骤(如果从指令的维度来看, 这里其实有几十条指令, 不止):
1.申请内存空间
2.调用构造方法(对内存空间进行初始化)
3.把此时内存空间的地址, 赋值给instance引用
在指令重排序的优化策略下, 上述执行的顺序不一定是123, 可能是132
上述执行顺序, 就出现了bug
要想解决上述问题, 就需要引入volatile
volatile不仅仅能够解决内存可见性问题, 也能针对对象读写操作的指令重排序问题(在很多地方都会发生重排序, volatile特指的是, 针对某个对象的读写操作过程中, 不会发生重排序)
其实, 指令重排序问题很能验证, 本身就是一个 小概率事件, 即使不加volatile, 运行很多次, 也是正确的, 但是指不定啥时候会出现问题, 我们能做的就是把volatile该加的加上即可
面试中让你现场写一个单例模式代码
正确的写法:
先写最初的版本 -> 加上锁 -> 加上双重if -> 加上volatile