单例模式:饿汉式,懒汉式及线程安全的懒汉式,最优单例模式的写法

单例模式介绍:

单例模式指的是,保证一个类只有一个实例,并且提供一个全局可以访问的入口。举个例子,这就好比是“分身术”,但是每个“分身”其实都对应同一个“真身”。

使用单例模式的理由/好处:

其中一个理由,那就是为了节省内存、节省计算。很多情况下,我们只需要一个实例就够了,如果出现了更多的实例,反而属于浪费。举个例子,我们就拿一个初始化比较耗时的类来说:

public class ExpensiveResource {

    public ExpensiveResource() {

        field1 = // 查询数据库

        field2 = // 然后对查到的数据做大量计算

        field3 = // 加密、压缩等耗时操作

    }

}

上面的类在进行构造的时候,需要查询数据库并对查到的数据做大量计算,所以在第一次构造时,我们花了很多时间来初始化这个对象。但是假设我们数据库里的数据是不变的,并把这个对象保存在了内存中,那么以后就用同一个实例了,如果每次都重新生成新的实例,实在是没必要。

第二个理由,那就是为了保证结果的正确。比如我们需要一个全局的计数器,用来统计人数,那么如果有多个实例,反而会造成混乱。

另外呢,就是为了方便管理。很多工具类,我们只需要一个实例,那么我们通过统一的入口,比如通过 getInstance 方法去获取这个单例是很方便的,太多实例不但没有帮助,反而会让人眼花缭乱。

单例模式有哪些适用场景:

无状态的工具类:

比如日志工具类,不管是在哪里使用,我们需要的只是它帮我们记录日志信息,除此之外,并不需要在它的实例对象上存储任何状态,这时候我们就只需要一个实例对象。

全局信息类:

比如我们在一个类上记录网站的访问次数,并且不希望有的访问被记录在对象 A 上,有的却被记录在对象 B 上,这时候我们就可以让这个类成为单例,需要计数的时候拿出来用即可。

单例模式的几种实现方式:

饿汉式

饿汉式经典写法
public class Singleton {
	//定义一个私有的静态的 Singleton 变量,并进行初始化赋值(创建一个对象给变量赋值)
    private static Singleton singleton = new Singleton();
    //私有空参数构造方法,不让用户直接创建对象
    private Singleton(){}
    //定义一个公共的静态方法,返回 Singleton 对象
    public static Singleton getInstance(){
        return singleton;
    }
}

特点:
由 JVM 的类加载机制保证了线程安全,因为在类被加载时便会把实例生成出来,避免了线程同步的问题,但同样也是其缺点,假设我们最终没有使用到这个实例的话,便会造成不必要的开销。

下面我们再来看下饿汉式的变种——静态代码块形式

饿汉式变种写法:静态代码块形式
public class Singleton {

    private static Singleton singleton;
    
    static {

        singleton = new Singleton();

    }

    private Singleton() {}

    public static Singleton getInstance() {
        return singleton;
    }

}

特点
优缺点和经典写法一样,只是写法有些不同。

懒汉式

懒汉式经典写法
public class Singleton {
	//在类中定义一个私有的静态的 Singleton 变量,不进行初始化赋值
    private static Singleton singleton;
	//私有空参数构造方法,不让用户直接创建对象
    private Singleton() {}
	//在类中定义一个公共的静态成员方法,返回 Singleton 对象,保证无论调用多少次方法,只返回一个对象
    public static Singleton getInstance() {

        if (singleton == null) {

            singleton = new Singleton();

        }

        return singleton;

    }

}

特点:

这种写法的优点在于,只有在 getInstance 方法被调用的时候,才会去进行实例化,所以不会造成资源浪费,但是在创建的过程中,并没有考虑到线程安全问题,如果有两个线程同时执行 getInstance 方法,就可能会创建多个实例。所以这里需要注意,不能使用这种方式,这是错误的写法

为了避免发生线程安全问题,我们可以对前面的写法进行升级,那么线程安全的懒汉式的写法是怎样的呢。

懒汉式线程安全写法:第一种

public class Singleton {

    private static Singleton singleton;

    private Singleton() {}

    public static synchronized Singleton getInstance() {

        if (singleton == null) {

            singleton = new Singleton();

        }

        return singleton;

    }

}

特点:
我们在 getInstance 方法上加了 synchronized 关键字,保证同一时刻最多只有一个线程能执行该方法,这样就解决了线程安全问题。但是这种写法的缺点也很明显:如果有多个线程同时获取实例,那他们不得不进行排队,多个线程不能同时访问,然而这在大多数情况下是没有必要的。

懒汉式线程安全写法:第二种
public class Singleton {

    private static Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {

        if (singleton == null) {

            synchronized (Singleton.class) {

                singleton = new Singleton();

            }

        }

        return singleton;

    }

}

特点:
这种写法是错误的。将synchronized 放在getInstance方法内部,它的本意是想缩小同步的范围,但是从实际效果来看反而得不偿失。因为假设有多个线程同时通过了 if 判断,那么依然会产生多个实例,这就破坏了单例模式。

所以,为了解决这个问题,在这基础上就有了“双重检查模式”。

懒汉式线程安全写法:第三种
public class Singleton {

    private static volatile Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {

        if (singleton == null) {

            synchronized (Singleton.class) {

                if (singleton == null) {

                    singleton =  new Singleton();

                }

            }

        }

        return singleton;

    }

}

特点:
对singleton对象添加volatile,将
这种写法的优点就是不仅做到了延迟加载,而且是线程安全的,同时也避免了过多的同步环节。推荐这种写法。我们重点来看一下 getInstance 方法,这里面有两层 if 判空,下面我们分别来看一下每个 if 的作用。

这里涉及到一个常见的问题,面试官可能会问你,“为什么要 double-check?去掉第二次的 check 行不行?”这时你需要考虑这样一种情况,有两个线程同时调用 getInstance 方法,并且由于 singleton 是空的 ,所以两个线程都可以通过第一个 if。

然后就遇到了 synchronized 锁的保护,假设线程 1 先抢到锁,并进入了第二个 if ,那么线程 1 就会创建新实例,然后退出 synchronized 代码块。接着才会轮到线程 2 进入 synchronized 代码块,并进入第二层 if,此时线程 2 会发现 singleton 已经不为 null,所以直接退出 synchronized 代码块,这样就保证了没有创建多个实例。假设没有第二层 if,那么线程 2 也可能会创建一个新实例,这样就破坏了单例,所以第二层 if 肯定是需要的。

而对于第一个 check 而言,如果去掉它,那么所有线程都只能串行执行,效率低下,所以两个 check 都是需要保留。

相信你可能看到了,我们在双重检查模式中,给 singleton 这个对象加了 volatile 关键字,那为什么要用 volatile 呢?这是因为 new 一个对象的过程,其实并不是原子的,至少包括以下这 3 个步骤:

  • 1、给 singleton 对象分配内存空间;
  • 2、调用 Singleton 的构造函数等,来进行初始化;
  • 3、把 singleton 对象指向在第一步中分配的内存空间,而在执行完这步之后,singleton 对象就不再是 null 了。

这里需要留意一下这 3 个步骤的顺序,因为存在重排序,所以上面所说的三个步骤的顺序,并不是固定的。虽然看起来是 1-2-3 的顺序,但是在实际执行时,也可能发生 1-3-2 的情况,也就是说,先把 singleton 对象指向在第一步中分配的内存空间,再调用 Singleton 的构造函数。

如果发生了 1-3-2 的情况,线程 1 首先执行新建实例的第一步,也就是分配单例对象的内存空间,然后线程 1 因为被重排序,所以去执行了新建实例的第三步,也就是把 singleton 指向之前的内存地址,在这之后对象不是 null,可是这时第 2 步并没有执行。假设这时线程 2 进入 getInstance 方法,由于这时 singleton 已经不是 null 了,所以会通过第一重检查并直接返回 singleton 对象并使用,但其实这时的 singleton 并没有完成初始化,所以使用这个实例的时候会报错。

最后,线程 1“姗姗来迟”,才开始执行新建实例的第二步——初始化对象,可是这时的初始化已经晚了,因为前面已经报错了。

到这里关于“为什么要用 volatile”问题就讲完了,使用 volatile 的意义,我认为主要在于呢,它可以防止刚讲到的重排序的发生,也就避免了拿到没完成初始化的对象。

接下来要讲到的这种方式,静态内部类的写法,利用了类装载时由 JVM 所保证的单线程原则,进而保证了线程安全。

懒汉式线程安全写法:第四种
public class Singleton {


    private Singleton() {}

    private static class SingletonInstance {

        private static final Singleton singleton = new Singleton();

    }
 
    public static Singleton getInstance() {

        return SingletonInstance.singleton;

    }

}

相比于饿汉式在类加载时就完成实例化,这种静态内部类的写法并不会有这个问题,这种写法只有在调用 getInstance 方法时,才会进一步完成内部类的 singleton 的实例化,所以不存在内存浪费的问题。

这里简单做个小总结,静态内部类写法与双重检查模式的优点一样,都是避免了线程不安全的问题,并且延迟加载,效率高。

可以看出,静态内部类和双重检查的写法都是不错的写法,但是它们不能防止被反序列化生成多个实例,那有没有更好的写法呢?最后我们来看枚举方式的写法。

枚举单例模式

枚举单例模式的写法

public enum Singleton {

    INSTANCE;

    public void myMethod() { 

    }

}

前面我们讲了饿汉式、懒汉式、双重检查、静态内部类、枚举这 5 种写法,有了这么多方法可以实现单例,这时你可能会问了,那我该怎么选择,用哪种单例的实现方案最好呢?

Joshua Bloch(约书亚·布洛克)在《Effective Java》一书中明确表达过一个观点:“使用枚举实现单例的方法,虽然还没有被广泛采用,但是单元素的枚举类型已经成为了实现 Singleton 的最佳方法。”

为什么他会更为推崇枚举模式的单例呢?这就不得不回到枚举写法的优点上来说了,枚举写法的优点有这么几个:

首先就是写法简单枚举的写法不需要我们自己考虑懒加载、线程安全等问题。同时,代码也比较“短小精悍”,比任何其他的写法都更简洁,很优雅。

第二个优点是线程安全有保障,枚举类的本质也是一个 Java 类,但是它的枚举值会在枚举类被加载时完成初始化,所以依然是由 JVM 帮我们保证了线程安全。

前面几种实现单例的方式,其实是存在隐患的,那就是可能被反序列化生成新对象,产生多个实例,从而破坏了单例模式。接下来要说的枚举写法的第 3 个优点,它恰恰解决了这些问题。

对 Java 官方文档中的相关规定翻译如下:“枚举常量的序列化方式不同于普通的可序列化或可外部化对象。枚举常量的序列化形式仅由其名称组成;该常量的字段值不存在于表单中。要序列化枚举常量,ObjectOutputStream 将写入枚举常量的 name 方法返回的值。要反序列化枚举常量,ObjectInputStream 从流中读取常量名称;然后,通过调用 java.lang.Enum.valueOf 方法获得反序列化常量,并将常量的枚举类型和收到的常量名称作为参数传递。”

也就是说,对于枚举类而言,反序列化的时候,会根据名字来找到对应的枚举对象,而不是创建新的对象,所以这就防止了反序列化导致的单例破坏问题的出现。

对于通过反射破坏单例而言,枚举类同样有防御措施。反射在通过 newInstance 创建对象时,会检查这个类是否是枚举类,如果是,就抛出 IllegalArgumentException(“Cannot reflectively create enum objects”) 异常,反射创建对象失败。

可以看出,枚举这种方式,能够防止序列化和反射破坏单例,在这一点上,与其他的实现方式比,有很大的优势。安全问题不容小视,一旦生成了多个实例,单例模式就彻底没用了。

总结:

最后我来总结一下。今天我讲解了单例模式什么是,它的作用、用途,以及 5 种经典写法,其中包含了饿汉式、懒汉式、双重检查方式、静态内部类方式和枚举的方式,最后我们还经过对比,看到枚举方式在写法、线程安全,以及避免序列化、反射攻击上,都有优势。

这里也跟大家强调一下,如果使用线程不安全的错误的写法,在并发情况下可能产生多个实例,那么不仅会影响性能,更可能造成数据错误等严重后果。

如果是在面试中遇到这个问题,那么你可以从一开始的饿汉式、懒汉式说起,一步步分析每种写法的优缺点,并对写法进行演进,然后重点讲一下双重检查模式为什么需要两次检查,以及为什么需要 volatile 关键字,最后再说到枚举类写法的优点和背后的原理,相信这一定会为你的面试加分。

另外在工作中,要是遇到了全局信息类、无状态工具类等场景的时候,推荐使用枚举的写法来实现单例模式。

  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值