文章目录
1、单例模式
单例模式:采取一定的方法保证在整个的软件系统中,对某个类只能存在一个对象实例,并且只能通过该类提供的一个静态方法取得其对象实例。
- 饿汉式:在类加载的时候就完成实例化,避免了线程同步的问题,但可能造成内存浪费
package cn.blog.six.single;
public class HungryMan {
private HungryMan() {}
private final static HungryMan HUNGRY_MAN;
static {
HUNGRY_MAN = new HungryMan();
}
public static HungryMan getInstance() {
return HUNGRY_MAN;
}
}
- 懒汉式:延迟加载即需要时候才去加载,需要注意线程同步的问题
2、懒汉式
2.1、初始版本
package cn.blog.six.single;
public class LazyMan {
private LazyMan() {}
private static LazyMan lazyMan;
public static LazyMan getInstance() {
if (lazyMan == null) {
lazyMan = new LazyMan();
}
return lazyMan;
}
}
可以看到上面的懒汉式代码只能在单线程中使用,多线程并发获取实例就存在返回多个实例的可能,因此这样的写法不安全。
2.2、同步方法synchronized保证线程安全
可通过给getInstance方法加上synchronized关键字保证方法内线程安全(代码不作演示),但是这时候也会存在一个性能上的问题:每个线程在想获得类的实例时候执行getInstance()方法都要进行同步,而其实这个方法只执行一次实例化代码就够了,后面的想获得该类实例,直接return就行了,因此同步方法进行同步的效率太低了,开发中也不会使用。
2.3、同步代码块
package cn.blog.six.single;
public class LazyMan1 {
private static LazyMan1 lazyMan;
private LazyMan1() {}
public LazyMan1 getInstance() {
if (lazyMan == null) {
synchronized (LazyMan1.class) {
lazyMan = new LazyMan1();
}
}
return lazyMan;
}
}
使用同步代码块的主要目的是想对同步方法的优化改进,但是同步代码块并不能起到线程同步的作用,因为当进入if语句块线程若发生因其它原因阻塞就有可能导致其它线程进入if语句块,最后还是会返回多个实例对象。
2.4、DCL懒汉式(双重检测锁模式)⭐⭐⭐
package cn.blog.six.single;
public class LazyMan2 {
private static LazyMan2 lazyMan;
private LazyMan2() {}
public static LazyMan2 getInstance() {
if (lazyMan == null) {
synchronized (LazyMan2.class) {
if (lazyMan == null) {
lazyMan = new LazyMan2();
}
}
}
return lazyMan;
}
}
Double-Check概念是多线程开发中常使用到的,如上代码使用双重if判断保证了线程安全,结果也是我们想要的(多线程操作最后只返回一个实例)。实例化的操作只执行了一次即使后面线程再次进入同步代码块,也不会执行if语句块内容,而是直接返回实例化的对象。在开发中推荐使用这种单例设计模式:线程安全、延迟加载(效率较高)
2.5、静态内部类⭐⭐
package cn.blog.six.single;
public class LazyMan3 {
private LazyMan3() {}
private static class LazyManInstance {
private static final LazyMan3 LAZY_MAN = new LazyMan3();
}
public static LazyMan3 getInstance() {
return LazyManInstance.LAZY_MAN;
}
}
这种方式采用了类装载的机制来保证初始化实例时只有一个线程。静态内部类方式在LazyMan3类被装载时并不会立即实例化,而是在调用getInstance方法,才会装载LazyManInstance类从而完成LazyMan3的实例化。类的静态属性只会在第一次加载类的时候初始化,所以在这里,JVM帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。推荐使用:避免了线程不安全,利用静态内部类特点实现延迟加载,效率高
3、深入分析DCL
3.1、DCL真的安全吗
相对线程安全:在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的
synchronizedCollection()方法包装的集合等。这句话是来自《深入理解Java虚拟机第3版》,书中也通过对Vector线程安全进行测试,举证了即使Vector也是会存在线程不安全的情况。那么我们先尝试一下使用反射破解DCL的结构看看能否成功,代码及结果如下:通过反射技术获取到私有的构造函数也是可以获取返回不同的实例对象!
package cn.blog.six.single;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
public class Main {
public static void main(String[] args) throws NoSuchMethodException {
Class<LazyMan2> clazz = LazyMan2.class;
Constructor<LazyMan2> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
LazyMan2 lazyMan2 = null;
try {
lazyMan2 = constructor.newInstance();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
System.out.println("p1===" + lazyMan2);
}).start();
}
System.out.println("p2===" + LazyMan2.getInstance());
}
}
3.2、DCL优化
既然DCL也不能保证线程安全,那么我们就应该进一步优化,在构造函数内部加入全局唯一的标志位判断(就能避免初始化实例对象后被人修改),再使用synchronized代码块保证线程安全严禁并发调用私有构造函数去获取实例对象。查看结果:可以看到初始化一个实例对象后,试图使用反射获取构造函数去破坏单例结构是不可能实现的。
小问号你是否有很多朋友?相信看到这样已经颠覆大家对单例模式的认知了,其实吧上面的单例也是存在问题的,如果别人通过反射获取了你整个类的结构,就会发现你是通过定义了一个判断位标志去阻止实例化,通过反射获取到这个标志位变量把值改为true还是可以通过调用构造函数去获取不同的实例对象的,类似无限套娃!如果有人真的要破坏你的代码,这些做法是不能从根本上解决问题的。
package cn.blog.six.single;
public class LazyMan2 {
private static LazyMan2 lazyMan;
private static boolean flag = true;
private LazyMan2() {
synchronized (LazyMan2.class) {
if (flag) {
flag = false;
} else {
throw new RuntimeException("不要使用反射破解!");
}
}
}
public static LazyMan2 getInstance() {
if (lazyMan == null) {
synchronized (LazyMan2.class) {
if (lazyMan == null) {
lazyMan = new LazyMan2();
}
}
}
return lazyMan;
}
}
3.3、DCL指令重排问题
DCL的问题不只是无法解决万能的反射,还有一个非常致命的问题:无法控制cpu指令重排,指令重排就涉及JMM的基本知识了,其实指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理,之所以指令重排就是为了提升效率。举个例子:如下我们可以很肯定的说出来sum = 2,但是运行结果一定是2吗?cpu的指令重排在极端的情况下运行顺序不一定1234,有可能是2134(不影响结果),有可能是1243(结果出错),而且Java里面的运算符操作并非原子操作,因此x++和x+y内部也可能会出现指令重排。没错在程序的世界中就是有可能存在这么诡异的操作,但是目前为止我是没遇到过。
int x = 0; //1
int y = 1; //2
x= x++; //3
int sum = x + y; //4
因此DCL中的 lazyMan = new LazyMan2()语句是有可能出现指令重排的,尤其是在高并发情况下,试想一下当一个线程进入内层if语句块,指令重排的结果假设是:先给lazyMan分配内存然后指向这个内存空间,但此时还没初始化对象,正好另一个线程进入外层if语句块判断lazyMan已分配内存不为null就会直接返回lazyMan,但其实这个时候lazyMan是还没完成初始化的!
public static LazyMan2 getInstance() {
if (lazyMan == null) {
synchronized (LazyMan2.class) {
if (lazyMan == null) {
lazyMan = new LazyMan2();
}
}
}
return lazyMan;
}
上面说的结果是不是很荒谬?可能你在本地实验几百万次都不会发生一次,但在生产环境高并发请求场景下说不定分分钟就会发生指令重排的情况,Java提供了一个关键字是可以禁止指令重排的:volatile!只需把实例对象使用其修饰即可,volatile原语级别有兴趣的朋友可以深入了解一下。
private volatile static LazyMan2 lazyMan;
4、安全的单例模式
4.1、枚举类实现单例
jdk1.5之前需要自定义枚举类,jdk1.5后新增关键字enum修饰类可以定义枚举类,若枚举类只有一个对象则可以作为一种单例模式的实现方式。
package cn.blog.six.single;
public enum LazyMan4 {
INSTANCE;
public void method() {
//功能代码...
}
}
4.2、枚举类如何实现单例
枚举类是如何打败反射实现安全的单例模式呢?我们继续通过反射来搞一下:这就奇了怪了上面代码我们明明没有定义了构造函数应该是存在空参构造函数的,为什么会找不到这个构造函数呢?
于是通过idea查看.class文件,发现确实存在空参构造函数的,那么为什么还是找不到呢?
不相信自己的眼睛,去查看一下字节码文件也确实存在的。邪乎吧?
实在遭不住了去看一下jdk官方文档,原来枚举类只存在唯一一个构造函数就是Enum(String name, int ordinal),这下终于清楚了情况!
按照构造函数的参数类型我们再来获取一下枚举类实例吧:这下的结果总算正确了,结论是不能通过反射破坏枚举结构,从newInstance源码看到若反射若要修改的类型为ENUM就会抛出错误IllegalArgumentException!因此可以通过枚举类型实现单例模式!
5、总结
总结一下上面说到的单例模式(除了枚举类型其它类型都无法躲过反射)
- 饿汉式:线程安全,不能延迟加载,有可能造成资源浪费,开发中不推荐使用
- DCL懒汉式:线程安全,可以延迟加载,开发中推荐使用
- 静态内部类:线程安全,可以延迟加载,开发中推荐使用
- 枚举:线程安全,不能延迟加载,开发中推荐使用且任你反射怎么强都没用