Java单例设计模式


反射与单例设计模式

单例模式可以保证在一个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


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录