设计模式之单例模式详解

·  阅读 194

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

单例模式

定义

  • 是指确保一个类在任何情况下都只有一个实例,并只有一个全局访问点
  • 它属于创建型模式
  • 隐藏其所有的构造方法;因为单例模式全局只有一个实例,所以不可以让用户通过构造方法进行创建对象,只能通过我开放的接口来创建对象

单例模式常见的写法

饿汉式单例:在单例类第一次加载的时候创建实例

代码展示

第一种写法

public class SingletonPatternOne {

    private static final SingletonPatternOne instance = new SingletonPatternOne();

    private SingletonPatternOne() {}

    public SingletonPatternOne getInstance(){
        return instance;
    }
}
复制代码

这里需要把构造方法进行私有化,因为在单例中,全局只能有一个实例,所以这里要把构造私有了防止用户通过new 的方法来创建对象

其实这个饿汉模式还有一些写法,就是将其创建对象的代码到一个静态代码块里面

public class SingletonPatternOne {

    private static final SingletonPatternOne instance;

    static {
        instance = new SingletonPatternOne();
    }

    private SingletonPatternOne() {}

    public SingletonPatternOne getInstance(){
        return instance;
    }
}
复制代码

注:这两种写法的效果是一样

问:为啥称这种写法是饿汉式的呢?

答:因为这种创建对象的方式好比一个饥饿的汉子,看到吃的东西,上来就吃,和这个创建对象的方法一样,当这个类加载时就直接创建了一个对象(野史,小编不知道这种说法对不对,听别人说的,(^__^) 嘻嘻……)

优缺点

优点:

  • 执行效率高,性能高,没有任何的锁

缺点:

  • 在特定的条件下,可能会出现内存浪费的情况(比如:因为是这个变量是通过static修饰的,当类进行加载的时候,就算不使用它也会创建出来,所以这里会浪费内存),如果项目中有大量的单例的时候不适合用饿汉式单例

懒汉式单例:只有当其它类使用它的时候才会创建

懒汉式的写法是在类中先声名一个变量,只有在使用的时候才会创建它,第一次使用的时候创建出来,后面直接返回这个变量就好

public class LazySingletonPattern {

    private static LazySingletonPattern instance;

    private LazySingletonPattern(){}

    public static LazySingletonPattern getInstance(){
        if(instance == null){
            instance = new LazySingletonPattern();
        }
        return instance;
    }

}
复制代码

同样和饿汉式的写法类似,构造方法也需要私有化,因为不可以让用户自行通过构造方法来创建对象

在获取实例对象的时候通过if判断出是否创建过这个对象如果创建过就直接返回结果,否则就创建出一个对象

但是:这种写法在多线程模式下会出现线程不安全的问题

优缺点

优点:

  • 节省了内存,省下了不必要的内存消耗

缺点:

  • 会出现线程不安全的问题

这个懒汉式线程不安全是怎么一回事呢?

先来写个测试多线程下出现问题的栗子

public class TestTaskSingletonRunnable implements Runnable{
    @Override
    public void run() {
        LazySingletonPattern instance = LazySingletonPattern.getInstance();
        System.out.println(Thread.currentThread().getName() + ":" + instance);
    }
}
复制代码

这个实现在Runnable接口,在run方法里面获取到单例模式下创建的实例

public class TestSingletonPattern {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new TestTaskSingletonRunnable());
        Thread thread2 = new Thread(new TestTaskSingletonRunnable());

        thread1.start();
        thread2.start();
    }
}
复制代码

这里要启动两个线程才可以看出获取的是不是同一个实例

我这里执行了两个这个代码,结果有一次是同一个实例,有一次是不同的实例

但是这里要注意一点,同一实例的那个不一定是只创建了一次实例,它也可能是创建了两次实例

运行结果有如下情况:

  • 相同的结果
    • 正常运行顺序执行
    • 第二次创建的对象在第一次打印前覆盖第一次创建的对象
  • 不同的结果
    • 同时进行if条件,按执行的顺序返回

第一种(正常运行顺序执行)的情况就不说了,这个也很好理解,执行完线程一,打印完成后,再执行第二个线程,第二种 情况和第三种情况要好好聊聊

聊聊产生错误的原因

一.第二次创建的对象在第一次打印前覆盖第一次创建的对象

image-20211123163454512

我们先来解释下这张图来聊聊产生这一问题的原因.

当程序启动的时候,代码执行到了图1的第10行代码,因为我在这里打了一个断点

看图2:我将Thread-0线程的代码执行到11行代码,因为这个时候instance变量是null,

看图3:这个时候我切换到了Thread-1线程,这个时候可以看到第10行代码的if判断的条件还是true,在图2虽然进入了if条件,因为我并没有让图2中的代码执行创建对象,所以在图3中这里的判断条件仍然是为true的

看图4:将Thread-1线程代码执行到11行代码(也就是if里面),到这里细心的小伙伴就会发现,此时,Thread-0和Thread-1的代码现在都执行到了11行代码(也就是同时都在if判断条件里面)

看图5:我们再次来到Thread-0线程将代码执行到第13行代码.这时我还没有让代码执行打印实例地址.再切换到Thread-1让代码执行到第13行代码.你就会发现,Thread-0线程一开始创建的对象就会被替换成Thread-1线程所创建的对象了

image-20211123165456937

这就是当在多线程模式下,打印相同地址有可能产生的一个原因,还有一个原因就是线程顺序执行(这个比较好理解就不说了)

二.不同的结果

image-20211123170015695

看图1:当程序启动的时候来到第10行代码(这个是Thread-0)

看图2:我让Thread-0线程的代码执行到了第11行代码,这个时候还没有创建对象

看图3:切换到了Thread-1线程,这个时候因为Thread-0线程还没有创建对象,所里这里的if判断的结果是true,

看图4:将Thread-1线程的代码执行到第11行代码,

看图5:切换到Thread-0线程,点击绿色的小按扭后,会自动来到Thread-1线程的断点处,再次点击这个按扭.你就会发现这两个线程打印结果的地址不一样

image-20211123170507005

这个就是在多线程环境下产生不是一个实例的原因

如何解决在多线程环境下产生错误的问题

先来一个简单粗暴的方法,直接给getInstance()方法加一个synchronized关键字

public class LazySingletonPattern {

    private static LazySingletonPattern instance;

    private LazySingletonPattern(){}

    public synchronized static LazySingletonPattern getInstance(){
        if(instance == null){
            instance = new LazySingletonPattern();
        }
        return instance;
    }

}
复制代码

注:在使用synchronized锁静态方法的时候,它其实是锁的这个类,也可以称为类锁

image-20211123171451448

加了synchronized修饰后不管怎么运行代码(多线程环境下),使用的都是同一个实例,不会出现上面我后说的那两种情况(第二次创建的对象在第一次打印前覆盖第一次创建的对象和不同的结果)

为啥加了synchronized关键字就没有问题了呢?这个关键字为啥这么牛?

还是接着能过断点方式来查明原因

image-20211123171829054

看上图,当Thread-0线程执行到这个方法的时候,你们看下Thread-1线程,这个时候Thread-1线程已经处于一个MONITOR的状态,只有等Thread-1线程执行完成之后,它才会继续执行,所以这种情况下,它不会出现上面所说的错误的情况

但是通过synchronized修饰后,会出现一个新的问题,它会降低系统的性能

为啥会这样说,我先来张图你们就明白了,这时我再新加一个线程Thread-2

image-20211123172327946

看这张图,当程序中的Thread-0线程运行到这个方法里面的时候Thread-1和Thread-2都阻塞住了,它们都在等待Thread-0线程执行完成

这里可以举一个例子

image-20211123172823732

比如这一个马戏团里面,只有一个门,但是这个时候门坏掉了,工作人员在修理(这个是好比Thread-0在创建对象),其它的人(这个好比其它的线程)只能在后面干等着,

这样不就影响了性能了嘛

这里还有一种优化的写法,双重检查锁

public class LazyDoubleCheckSingletonPattern {

    private static volatile LazyDoubleCheckSingletonPattern instance;

    private LazyDoubleCheckSingletonPattern(){}

    public LazyDoubleCheckSingletonPattern getInstance(){
        if(instance == null){
            synchronized (LazyDoubleCheckSingletonPattern.class){
                if(instance == null){
                    instance = new LazyDoubleCheckSingletonPattern();
                }
            }
        }
        return instance;
    }

}
复制代码

这和写法虽然也用到了synchronized,当一个线程进入后其它的线程也会阻塞掉,但是总比写在方法上好一些,这样写可以过滤掉一些不需要阻塞的时候(当一线程要获取实例的时候,这个实例已经被创建了出来.)就可以直接返回了

两个if判断的作用:

  • 第一个if
    • 它的作用是过滤出一些不需要创建对象的情况,(比如已经创建过对象)
  • 第二个if
    • 它虽然也是判断了这个实例是否为空,加它的原因是,在多线程环境下,如果两个线程都进入到了第一个if里面,然后其中一个线程创建了对象,第二个线程进入的时候通过if判断下这个实例对象是否为空,如果不为空就不再进行创建对象了,是起到这么一个作用

这里最好是加上volatile关键字,这里有可能会发生指令重排的情况

懒汉式其它写法:内部静态类
public class LazyStaticClassSingletonPattern {

    private LazyStaticClassSingletonPattern(){}

    public LazyStaticClassSingletonPattern getInstance(){
        return LazyHold.INSTANCE;
    }

    private static class LazyHold{
        private static LazyStaticClassSingletonPattern INSTANCE = new LazyStaticClassSingletonPattern();
    }
    
}
复制代码

这里只有使用这个类的时候,里面的静态内部类才会创建,所以这种也是属于懒汉式

优缺点

优点:

  • 能避免浪费内存,性能高

缺点:

  • 能被反射破坏(不但但这种可以被反射破坏,懒汉式其它那几种写法也可以)
如何被反射破坏
public class TestSingletonPattern {
    public static void main(String[] args) {
        try{
            Class<?> clazz = LazyStaticClassSingletonPattern.class;
            Constructor<?> constructor = clazz.getDeclaredConstructor(null);
            constructor.setAccessible(true);
            Object o1 = constructor.newInstance();
            System.out.println(o1);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}
复制代码

image-20211123184533411

这样的话就算不通过公开的方法也可以创建出对象.那怎么办呢?

我们可以在构造方法中判断下这个实例是否被创建出来,如果创建出来就抛出一个异常

public class LazyStaticClassSingletonPattern {

    private LazyStaticClassSingletonPattern(){
        if(LazyHold.INSTANCE != null){
            throw new RuntimeException("不可以非法创建");
        }
    }

    public LazyStaticClassSingletonPattern getInstance(){
        return LazyHold.INSTANCE;
    }

    private static class LazyHold{
        private static LazyStaticClassSingletonPattern INSTANCE = new LazyStaticClassSingletonPattern();
    }

}
复制代码

image-20211123184759063

为啥可以这样写?

因为在类加载的时候静态内部类已经创建出了一个实例, 所以在通过反射创建的时候在构造方法里面,判断条件已经成立了,所以就会抛出这个异常

ThreadLocal单例

public class ThreadLocatlSingletonPattern {

    private static final ThreadLocal<ThreadLocatlSingletonPattern> threadlocalInstance =
            new ThreadLocal<ThreadLocatlSingletonPattern>(){
                @Override
                protected ThreadLocatlSingletonPattern initialValue() {
                    return new ThreadLocatlSingletonPattern();
                }
            };


    private ThreadLocatlSingletonPattern(){}

    public static ThreadLocatlSingletonPattern getInstance(){
        return threadlocalInstance.get();
    }
}
复制代码

注意:这种方法只有在同一个线程里面的才是单例,如果不是在同一线程里面获取到的不是同一个对象,这个有时也会用到,这里只说下,有兴趣的可以看看

扩展:序列化如何破坏单例模式

public class TestSingletonPattern {
    public static void main(String[] args) {
        SingletonPatternOne s1 = null;
        SingletonPatternOne s2 = SingletonPatternOne.getInstance();

        FileOutputStream fos = null;

        try{
            fos = new FileOutputStream("SingletonPatternOne.obj");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(s2);
            oos.flush();
            oos.close();

            FileInputStream fis = new FileInputStream("SingletonPatternOne.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            s1 = (SingletonPatternOne)ois.readObject();
            ois.close();

            System.out.println(s1);
            System.out.println(s2);
            System.out.println(s1 == s2);

        }catch (Exception e){

        }

    }
}
复制代码

这种破坏的方法不过是通过序列化和反序列化的方式实现的.

这个过程是怎么一回事呢?

先是序列化

它将内存中的对象转换成字节码的形式保存在硬盘上

然后将数据从硬盘上取出,通过IO将数据加载到内存里面,将其转换成一个Java对象

如何避免这种问题,只需要在单例类里面加上readResolve方法就好,里面返回对应的实例,

这里以饿汉式为例

public class SingletonPatternOne implements Serializable {

    private static final SingletonPatternOne instance;

    static {
        instance = new SingletonPatternOne();
    }

    private SingletonPatternOne() {}

    public static SingletonPatternOne getInstance(){
        return instance;
    }

    private Object readResolve(){
        return instance;
    }
}
复制代码
分类:
后端
标签: