[单例模式]

[设计模式]

设计模式是软件工程中的一种常见做法, 它可以理解为"模板", 是针对一些常见的特定场景, 给出的一些比较好的固定的解决方案

不同语言适用的设计模式是不一样的. 这里我们接下来要谈到的是java中典型的设计模式. 而且由于设计模式比较适合有一定编程经验之后, 再去详细学习, 所以我们本篇文章就只讨论几个经典的java设计模式

  • 单例模式

在实际开发中, 某个进程中, 我们不希望某个类有多个实例对象, 希望它有且仅有一个实例对象而且不能再创建出来. --> 这个时候我们就可以使用单例模式这样的设计模式. 单例模式有两种写法, 一种叫饿汉模式, 一种叫懒汉模式. 下面我们就详细讨论一下这两种单例模式的写法.

1. 饿汉模式

"饿"的意思是"迫切的", 放到代码中意思就是需要我们在类被加载的时候就创建出这个单例的实例. 

class Singleton {
    private static Singleton instance = new Singleton();
    public static Singleton getInstance() { //获取Singleton的实例对象, 但是每次获取的都是相同的对象instance.
        return instance;
    }
    private Singleton() {} //单例模式中最关键的部分: 将构造方法设置为私有. 防止在类外再创建出其他实例对象.
}
public class Demo24 {
    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        //两次获取到的对象应该是同一个对象. 我们可以在下面验证一下 (== 比较的是两个对象在内存中的地址,也就是它们是否指向同一个实例对象)
        System.out.println(s1 == s2);
    }
}

 我们可以看到, 饿汉模式中,

(1) static修饰instance, 说明这里的instance是类成员(一个类只有一份, 随着类的加载而创建出来)

(2) static修饰的类方法 getInstance() 每次返回的都是同一个对象 instance.

(3) 将SIngleton类的构造方法设置为私有, 这就保证了在类外无法通过构造方法再创建出新的对象.

我们通过在main方法中创建两个引用s1和s2, 看到s1和s2指向的是同一个对象. 这也就代表了Singleton这个类只有一个实例. 

[注]: 上述单例模式只能避免程序员失误, 调用了Singleton的构造方法创建新对象;  而无法避免程序员故意破坏单例模式(比如, 我们可以通过反射的方式拿到构造方法).

2. 懒汉模式

我们先通过一个形象的例子来理解饿汉和懒汉的区别: 比如我们现在有一个编辑器, 要打开一个非常大的文本文档. (1) 饿汉: 一启动, 就把所有的文本内容全都读取到内存中, 然后显示到界面. (2) 懒汉: 先只加载出一部分数据, 随着用户的翻页操作, 再按需加载剩下的内容.  根据上述表述, 我们可以确定, 懒汉模式一定比饿汉模式加载出来的速度更快, 用户的体验也就会更好. 所以, 我们日常开发中, 很多地方都青睐于使用懒汉模式.

class SingletonLazy {
    private static SingletonLazy instance = null;
    public static SingletonLazy getInstance() {
        if (instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
        // 如果instance为空,则创建一个实例对象; 如果instance不为空,则直接返回instance
    }
    private SingletonLazy() {}
}

public class Demo25 {
    public static void main(String[] args) {
        SingletonLazy s1 = SingletonLazy.getInstance();
        SingletonLazy s2 = SingletonLazy.getInstance();
        System.out.println(s1 == s2);
    }
}

如上述代码, 当我们首次调用getInstance时, 由于此时对象还没有创建, instance这个引用为空, 所以就会进入if分支, 创建出SingletonLazy对象. 后续如果再重复调用getInstance, 结果都不会再创建新的实例, 而是直接返回instancomen

 我们还是通过在main方法中创建两个引用s1和s2, 看到s1和s2指向的是同一个对象. 这也就代表了SingletonLazy这个类只有一个实例.

3. 单例模式的线程安全问题

知道了单例模式的两种写法之后, 我们现在要判断: 这两种写法是否存在线程安全问题呢? (在多线程环境下, 多个线程调用getInstance, 是否会出现问题?)

(1) 饿汉模式:

我们可以看到, 饿汉模式的getInstance方法只涉及读操作, 并没有涉及任何写操作. 而我们在多个线程同时修改同一个变量时, 才容易出现线程安全问题. 所以饿汉模式是线程安全的.

(2) 懒汉模式:

  • 原子操作问题

像这种  先条件判定, 再修改 的操作, 其实是典型的线程不安全代码. 

比如, 我们现在有两个线程同时调用getInstance方法. 线程t1先执行if判断, 判断出instance为空. 此时t2线程插入进来了, 执行t2线程的if判断, 那么t2的判断结果同样是空, t2就会执行new SingletonLazy() 创建出一个新的对象. 而再切换回t1线程, 由于t1对instance的判断也为空, 所以, t1也会执行new SingletonLazy() 创建出一个新的对象. 那么这样的话, Singletonlazy类就被实例化了两次. 而单例模式要求类只能被实例化一次.

([注]: 虽然说后面创建的实例覆盖了前面创建的实例, 前面创建的实例没有引用变量引用的话很块回被销毁回收, 但是创建实例对象这个过程本身的开销就很大(比如有的类一个实例就要100个G), 所以我们仍然认为这个代码是有bug的)

所以, 为了解决上述线程不安全问题, 我们就需要进行"加锁"操作. 将条件判断和创建操作作为一个整体加上锁, 这样一来, if判断和new创建操作这个整体就成了一个"原子"操作.  这就保证了某个线程在顺序执行这两个操作的时候不会有别的线程插入进来.

class SingletonLazy {
    private static SingletonLazy instance = null;
    public static Object locker = new Object(); 
    public static SingletonLazy getInstance() {
        synchronized(locker) {
            if (instance == null) {
                instance = new SingletonLazy();
            }
            return instance;
            // 如果instance为空,则创建一个实例对象; 如果instance不为空,则直接返回instance
        }
    }
    private SingletonLazy() {}
}

试想一下, 上述代码, 如果已经完成了创建对象的操作之后, 后续如果再调用getInstance, 就再也不会进入if分支中去了, 都是简单的读操作(return instance). 那么只有读操作的话, 不加锁也是线程安全的. 我们知道, 加锁这个操作, 对程序性能的影响还是挺大的.  所以, 我们只需要在第一次执行这个方法的时候(没有创建出对象的时候)加锁即可, 其他时候再执行这个方法, 都是线程安全的, 不需要加锁. 

那么, 如何判断当前是不是第一次调用这个方法呢? --> 看是否已经创建出了实例对象, 如果还没有instance对象, 那就是第一次调用, 需要对里面的判断-创建对象 操作 加锁;  如果已经有了instance对象, 那就不是第一次调用, 就不需要加锁, 直接返回instance.

依据上述思考, 我们对代码做出如下修改:

 public static SingletonLazy getInstance() {
        if (instance == null) { //外层if: 判断是否应该加锁
            synchronized(locker) {
                if (instance == null) { //内层if: 判断是否要创建对象
                    instance = new SingletonLazy();
                }
                // 如果instance为空,则创建一个实例对象; 如果instance不为空,则直接返回instance
            }
        }
        return instance;
    }

注意: 这里, 外层if和内层if虽然条件恰好是一样的, 但是作用是完全不同的. 外层的if作用是: 判断是否要加锁. 内层if的作用是: 判断是否要创建新的对象.

  • 指令重排序问题

编译器在执行创建对象的代码时, 为了提高性能, 可能会进行"指令重排序"操作.

 instance = new SingletonLazy();

编译器在执行这个创建对象代码的时候, 会经过如下步骤: (1) 分配内存空间.  (2) 执行构造方法.  (3) 将对象的内存空间地址赋给引用变量.   正常来说, 是按照(1) -> (2) -> (3) 的顺序执行的. 但是编译器为了优化性能, 也可能按照(1) -> (3) -> (2) 的顺序执行.

 public static SingletonLazy getInstance() {
        if (instance == null) { //外层if: 判断是否应该加锁
            synchronized(locker) {
                if (instance == null) { //内层if: 判断是否要创建对象
                    instance = new SingletonLazy();
                }
                // 如果instance为空,则创建一个实例对象; 如果instance不为空,则直接返回instance
            }
        }
        return instance;
    }

那么, 我们试想, 如果在执行完(1) -> (3) 之后, 此时有别的线程切入, 执行if (instance == null) 判定, 那么此时判定instance就不为空了, 因为语句指向了内存空间(即使这个内存空间里什么都没有). 判定完instance不为空之后, 就会直接return instance. 那么如果这个线程拿到instance之后, 如果再调用里面的某个方法. 那么此时就会出现错误!!! (因为instance指向的内存空间是未初始化的).

那么如何解决这个情况呢? --> volatile. 我们可以在instance前面加上一个volatile修饰, 告诉系统, instance这个引用是"易变的, 易失的". 那么此时系统就会放弃对new SingletonLazy() 这个创建对象操作的优化, 按照(1) -> (2) -> (3) 的顺序执行创建对象操作.这样的话, 就不会出现上述问题了~

加上volatile的代码最终如下:

class SingletonLazy {
    private static volatile SingletonLazy instance = null;
    public static Object locker = new Object();
    public static SingletonLazy getInstance() {
        if (instance == null) { //外层if: 判断是否应该加锁
            synchronized(locker) {
                if (instance == null) { //内层if: 判断是否要创建对象
                    instance = new SingletonLazy();
                }
                // 如果instance为空,则创建一个实例对象; 如果instance不为空,则直接返回instance
            }
        }
        return instance;
    }
    private SingletonLazy() {}
}

那么这样一个单例模式的代码无论在执行效率还是在线程安全上就都没有任何问题了.

好了, 本篇文章就介绍到这里啦, 大家如果有疑问欢迎评论, 如果喜欢小编的文章, 记得点赞收藏~~

上一篇:【Linux:tcp三次握手和四次挥手】


下一篇:鲜花:bitset求解高维偏序