反射与单例设计模式
单例模式可以保证在一个JVM进程中某一个类只有唯一的对象实例,从而保证一些核心对象的唯一性。而单例设计模式可以分为饿汉式
单例和饿汉式
单例
饿汉式单例在单例设计模式下不存在线程同步的处理问题,而懒汉式单例需要在每次返回对象前进行对象是否实例化的判断,就有可能出现线程处理不同步的问题,出现线程不安全情况如下,解决办法是给对象实例过程加锁,双重校验
首先我们来一步步实现单例模式
1. 饿汉式单例模式
饿汉式单例模式是将对象的创建权交给了jvm,一旦进程开始运行,则该对象就被初始化,由于创建时机是在程序初始化时,所以一旦创建出来之后,就是单例的。但是注意,这种机制依然会被反射所破解,原单例模式以及破解方法如下
public class Hungry {
private byte[] data1 = new byte[1024*1024];
private byte[] data2 = new byte[1024*1024];
private byte[] data3 = new byte[1024*1024];
private byte[] data4 = new byte[1024*1024];
private Hungry(){//注意构造器私有
}
private final static Hungry HUNGRY = new Hungry();//一旦类被加载,就生成该单例对象
public static Hungry getInstance(){//提供对外访问的方法
return HUNGRY;
}
}
@org.junit.Test
public void HungryTest() throws Exception {
Constructor<?> declaredConstructor = Class.forName("Test.Hungry").getDeclaredConstructor();
declaredConstructor.setAccessible(true);
Object o = declaredConstructor.newInstance();
Hungry instance = Hungry.getInstance();
if(instance.equals(o)){
System.out.println("他们是一个对象");
}else{
System.out.println("不是同一个对象");
System.out.println(o.hashCode());
System.out.println(instance.hashCode());
}
System.out.println(o.getClass());
}
- 即使是使用反射来创建实例,那也是调用类中的构造器来实现的,所以可在构造器中做文章,在构造的时候做校验
private Hungry(){//注意构造器私有
if(HUNGRY!=null){
throw new RuntimeException("请不要尝试通过反射来破坏我的单例!");
}
}
Caused by: java.lang.RuntimeException: 请不要尝试通过反射来破坏我的单例!
at Test.Hungry.<init>(Hungry.java:10)
... 32 more
这种方式是的资源分配全部在进程初始化的时候就做完了,因此在后边即便有多线程,也不会导致资源的错误分配,因此是线程安全的哈
2.懒汉式单例模式
我们知道饿汉式单例是在进程一运行,在类被加载这个阶段的时候,就产生了这个单例,那么如果说,我整个进程的运行周期中,这个类都没有被用到,但是像上面这样这个单例,搞了很多个大数组来占空间但是又用不到,就很浪费空间。
所以的话,我们能不能想一个办法,使得就是说,我要用到这个单例类了,我才去构造,才去加载
,这种模式就是懒汉式单例了。
由于加载的时机是不确定的,因此类的资源分配的过程是在进程运行的任意时刻,因此在多线程环境下是不安全的,如果采用异步的方法来调用,那么很有可能导致资源的重复分配,因此的话要做同步线程保护,保证单例安全
public class Lazy {
private static volatile Lazy LAZY;
private Lazy(){
if(LAZY!=null){
throw new RuntimeException("请不要尝试通过反射来破坏我的单例!");
}
}
public static Lazy getInstance(){
if(LAZY == null){
synchronized (Lazy.class){
if(LAZY == null){
LAZY = new Lazy();
}
}
}
return LAZY;
}
}
- 来看一下几个比较关键的技术点吧
首先是volatile
,为啥实例对象要用volatitle
来修饰?
volatile
是JAVA提供的轻量级同步机制,它不会引起线程上下文的切换和调度。与之相对的synchronized
是重量级锁。先来了解一下volatile
这种锁机制为我们提供了什么
- 保证可见性
可见性:指当多个线程访问同一个变量的时候,一个线程修改了这个变量的值,其他线程能够立即
看到修改的值
在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。
这张图讲述了不可见
的原理,在多线程环境下,各个线程有自己的内存空间,对于普通的共享变量来讲,线程A将其修改为某个值发生在线程A
的本地内存中,此时还未同步到主内存中去,而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。
这种问题的解决方法可以通过加锁来进行同步,一种想法就是在一个线程操作共享变量的时候,对这个共享变量加锁,然后其他线程就无法对这个共享变量进行读写了,但是这种方式的实现是通过synchronized
或者Lock
等重量级来实现的,比较合理的方式是volatile
其原理是:当写一个volatile
变量的时候,线程本地内存中的变量会被强制刷新到主内存中去,这个写操作会导致其他线程中的volatile
变量缓存无效,这实际上也是一种缓存一致性的实现方法
- 禁止指令重排
这个性质可以保证程序执行的顺序按照代码的先后顺序执行,重排序不会影响单线程的运行结果,但是对多线程会有相当大的影响,单例模式中的DCL(双重校验锁)
就是一个例子,其原理是生成一个内存屏障,这个屏障的作用是在编译器生成的指令流中确定一个点,使得此点之前的所有读写操作都执行后才可以执行此点之后的操作。
好了。言归正传,为什么我们的单例模式要用volatile
呢
public class TestInstance{
private volatile static TestInstance instance;
public static TestInstance getInstance(){ //1
if(instance == null){ //2
synchronized(TestInstance.class){ //3
if(instance == null){ //4
instance = new TestInstance(); //5
}
}
}
return instance; //6
}
}
如果没有volatile
关键字,在第五行会出现问题,instance = new TestInstance();
可以分级为三行代码
a. memory = allocate()
//分配内存
b. ctorInstanc(memory)
//初始化对象
c. instance = memory
//设置instance指向刚分配的地址
上面的代码在编译运行的时候,可能会出现排序a-b-c
排序为a-c-b
,这是因为我们发现b和c均依赖于a,但是b和c并不互相依赖,所以编译器可能会将bc语句进行重排序。
假设此时A执行的过程中发生指令重排序,也就是先执行了a和c,没有执行过b,那么a线程执行了c导致实例指向了一段地址,然后这时候发生了中断,然后B进程上CPU执行了,然后B上来发现,诶你这个instance不是null,指向了一个内存区域,那么应该就是被实例化了吧,然后这时候它就会把一个没有执行过b
的实例返回回去,这时候这个实例没有经过初始化,是危险的,所以的话,需要生成一个内存屏障,保证实例对象经历内存分配
->初始化
->指向内存空间
->完成
,才是合理的
然后或许你会有这种问题,如果在按照顺序执行的过程中,A线程执行了ab,然后时间片超时,B上了CPU,这时候B发现它是空,会不会去初始化一个新的实例呢?
,这个肯定是存在的,解决办法是给这个实例加锁,也就是说如果有线程在生成这个共享变量的时候,其他线程阻塞并等待,手段就是给这个实例的类模板加锁,这样就肯定能保证只生成一个对象
public static Lazy getInstance(){
if(LAZY == null){//如果没有这个对象
synchronized (Lazy.class){//给对象加锁,防止生成多个实例
if(LAZY == null){//加锁完毕后,第一个进来的肯定会检测到null,然后生成实例
LAZY = new Lazy();
}//一个线程结束之后,其他线程再进来,这时候就会检测到非null了直接返回
}
}
return LAZY;
}
好了,单例模式的核心就是上面的分析,但是现在还有一个问题,就是这个过程依然可以通过反射
来破解单例,而且几乎是无解的,破解代码如下
Class objectClass = Singleton.class;
Constructor constructor = objectClass.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton instance = Singleton.getInstance();
Field flag = objectClass.getDeclaredField("flag");
flag.setAccessible(true);
flag.set(instance , true);
Singleton newInstance = (Singleton) constructor.newInstance();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
这个方法把通过反射创建实例和调用静态方法getInstance()
获得实例的位置互换了,所以一开始通过反射创建实例调用构造器
,此时构造器中判断instance!=null
是无用的。这是为什么呢?如果反射先走的构造器创建实例方法,那么instance!=null
的异常就不会触发,无论后续如何完善,看了一些资料都是说加信号量等方法,但是均可以通过反射来修改其内部数据,都是没办法阻止反射的破解的。
唯一的办法是使用enum
类,enum类可以保证仅有实例存在于虚拟机中。
public enum Lazy {
INSTANCE;
private byte[] data1 = new byte[1024*1024];
private byte[] data2 = new byte[1024*1024];
private byte[] data3 = new byte[1024*1024];
private byte[] data4 = new byte[1024*1024];
private Lazy(){
System.out.println("构造器方法");
}
public static Lazy getInstance(){
return INSTANCE;
}
}
我们发现如果想要破解反射,无非就是从clone()
方法入手,construcotr.newInstance()
方法入手,从序列化反序列化入手,但是jvm为我们解决了这个问题。
具体原理见:https://blog.csdn.net/CS5686/article/details/123968025?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522166324087716782395383287%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=166324087716782395383287&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-1-123968025-null-null.142^v47^control_1,201^v3^control_1&utm_term=%E5%8D%95%E4%BE%8B%E6%A8%A1%E5%BC%8F%20enum&spm=1018.2226.3001.4187