设计模式之单例模式|8月更文挑战

870 阅读10分钟

image.png

今天我们正式进入java设计模式的学习之旅,先从单例模式开始讲起。

大家可以查看我设计模式专栏篇的引文,关注我的设计模式专栏:java设计模式攻坚准备

话不多说,进入正题

单例模式

  • 单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

  • 我们在编程开发中经常会遇到这种场景:需要保证一个类只有一个实例,哪怕多线程同时访问,而且需要提供一个全局访问此实例的点。可以总结出一个理论,单例模式主要解决的是一个全局使用的类,被频繁地创建与销毁,从而提升代码的整体性能

  • 应该看到这里大概都有所了解了,晦涩的文字对于单例模式的描述也比较容易理解了。那么我们看下经典的单例场景:

  • 如数据库的连接池不会反复创建,Spring中一个单例模式Bean的生成和使用,代码中需要设置全局的一些属性并保存。

这就是单例模式,那么针对上述描述我们下面我们用实际案例和代码来让你有更深入的理解。

7+2种单例模式实现方式

单例模式的实现方式比较多,但是唯一只有一个目的,永远只创建一个实例(对象),下面我们先对7种常见的案例一一进行阐述,

为什么还有2种呢,对的 因为我们只有一个目的,保证只创建一个实例,那么任何方式只要满足这个都可以,我会写出两种不怎么常见的,但是个人认为可以使用的方式来设计单例模式。

1、静态类

/**
 * 单例静态类
 * @Date 2021/8/1 5:43 下午
 * @Author yn
 */
public class Singleton_01 {
    public static Map<String,Object> cacheInfo = new ConcurrentHashMap<>();
}


public static void main(String[] args) {
    Map<String,Object> map =  Singleton_01.cacheInfo;
}

这种静态类方式在日常的业务开发中很常见,它可以在第一次运行时直接初始化Map类,用于全局访问,使用静态类方式更加方便

2、懒汉模式(线程不安全)

/**
 * 单例之懒汉模式
 * @Date 2021/8/1 5:43 下午
 * @Author yn
 */
public class Singleton_02 {

    private static Singleton_02 instance;

    private Singleton_02(){

    }
    //获取对象实例
    public static Singleton_02 getInstance(){
        if (null != instance){
            return instance;
        }
        //如果为空 则内部new对象
        instance = new Singleton_02();
        return instance;
    }
}

//获取对象
public static void main(String[] args) {
    Singleton_02 singleton_02 =  Singleton_02.getInstance();
}

单例模式有一个特别重要的特点是不允许外部直接创建,也就是 new Singleton_02(),因此这里在默认的构造函数上添加了私有属性private。

思考:如果有多个访问者同时获取对象实例,会不会造成多个同样的实例并存。答案是肯定的。 如果当第一次访问还没有创建完成实例(正在创建中),结果第二个线程请求进来了,都会走到new实例当中

3、懒汉模式(线程安全)

我们在上述的基础之上做下处理:添加 synchronized 锁控制

/**
 * 单例之懒汉模式 synchronized加持
 * @Date 2021/8/1 5:43 下午
 * @Author yn
 */
public class Singleton_01 {

    private static Singleton_01 instance;

    private Singleton_01(){
    }

    //获取对象实例加 synchronized 控制,
    public static synchronized Singleton_01 getInstance(){
        if (null != instance){
            return instance;
        }
        //如果为空 则内部new对象
        instance = new Singleton_01();
        return instance;
    }
}

synchronized加锁,便保证了每次只能有一个线程加锁成功,那便只能new一次对象。

此种模式虽然解决了线程不安全的问题,但由于把锁加到方法中后,所有的访问因为需要锁占用,导致资源浪费。除非在特殊情况下,否则不建议用此种方式实现单例模式。

4、饿汉模式(线程安全)

/**
 * 饿汉模式 线程安全
 * @Date 2021/8/1 5:43 下午
 * @Author yn
 */
public class Singleton_03 {

    private static Singleton_03 instance = new Singleton_03();

    private Singleton_03(){
    }

    //获取对象实例
    public static  Singleton_03 getInstance(){
        return instance;
    }
}

这种方式与开头的第一个实例化 Map 基本一致,在程序启动时直接运行加载,后续有外部需要使用时获取即可。这种方式并不是懒加载,也就是说无论程序中是否用到这样的类,都会在程序启动之初进行创建。

5、类的内部类(线程安全)

/**
 * 单例模式 匿名内部类
 * @Date 2021/8/1 5:43 下午
 * @Author yn
 */
public class Singleton_04 {

    private static class  singletonHolder {
        private static Singleton_04 instance = new Singleton_04();
    }

    private Singleton_04(){
    }

    //获取对象实例
    public static  Singleton_04 getInstance(){
        return singletonHolder.instance;
    }
}

使用类的静态内部类实现的单例模式,既保证了线程安全,又保证了懒汉模式,同时不会因为加锁而降低性能。

这主要是因为JVM虚拟机可以保证多线程并发访问的正确性,也就是一个类的构造方法在多线程环境下可以被正确地加载。这也是推荐使用的一种单例模式。

虚拟机会保证一个类的类构造器在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器,其他线程都需要阻塞等待,直到活动线程执行方法完毕。

所以线程安全!!!

6、双重锁校验(线程安全)

/**
 * 双重锁校验(线程安全)
 * @Date 2021/8/1 5:43 下午
 * @Author yn
 */
public class Singleton_05 {

    private static volatile Singleton_05 instance;

    private Singleton_05(){

    }

    //获取对象实例
    public static  Singleton_05 getInstance(){
        if (null != instance){
            return instance;
        }
        synchronized (Singleton_05.class){
            if (null == instance){
                instance = new Singleton_05();
            }
        }
        return instance;
    }
}

这种方式是极为弥补懒汉模式的不足,无需每次都去上锁。双重锁的方式是方法级锁的优化,减少了获取实例的耗时。可以适当运用此模式,

7、枚举单例(线程安全)

/**
 *  单例模式之枚举
 * @Date 2021/8/1 8:04 下午
 * @Author yn
 */
public enum Singleton_06 {

    INSTANCE;

    public void testInstace(){
        System.out.println("我是单例模式之枚举 --> -->");
    }
}

此种方式可能是平时最少用到的。但是这种方式解决了最主要的线程安全、自由串行化和单一实例问题。调用方式如下:

public static void main(String[] args) {
    //调用输出
    Singleton_06.INSTANCE.testInstace();
}

相比之下,你就会发现,枚举实现单例的代码会精简很多。

这种写法虽然在功能上与共有域的方法接近,但是它更简洁。即使在面对复杂的串行化或反射攻击时,也无偿地提供了串行化机制,绝对防止对此实例化。单元素的枚举类型已经成为实现Singleton的最佳方法(个人认为),值得推荐运用!

其实,并不是使用枚举就不需要保证线程安全,只不过线程安全的保证不需要我们关心而已。也就是说,其实在“底层”还是做了线程安全方面的保证的。这就涉及到jvm的问题,可以通过反编译enum类可了解到底层的东西,今天在这里不赘述。

OK,到这里,经典的七种创建单例模式的方式已经讲完,接下来再说两种方法来达到单例的目的。

利用 CAS“AtomicReference” 实现单例(线程安全)

/**
 * 利用cas思想实现只有一个实例
 * @Date 2021/8/1 5:43 下午
 * @Author yn
 */
public class Singleton_07 {

    //利用cas思想管理线程安全
    private static final AtomicReference<Singleton_07> INSTANCE = new AtomicReference<Singleton_07>();

    private Singleton_07(){

    }

    //获取对象实例
    public static final Singleton_07 getInstance(){

        for (;;){
            //通过 AtomicReference get方法实现线程安全
            //cas思想 每次都去查询是否存在当前对象
            Singleton_07 instance = INSTANCE.get();
            if (null != instance) return instance;
            INSTANCE.compareAndSet(null,new Singleton_01());
            return INSTANCE.get();
        }
    }

    public static void main(String[] args) {
        //一次请求
        System.out.println(Singleton_07.getInstance());
        //二次请求
        System.out.println(Singleton_07.getInstance());
        
        //类实例同一个
    }
}

Java 并发库提供了很多原子类支持并发访问的数据安全性,如:AtomicInteger、AtomicBoolean、AtomicLong 和 AtomicReference。AtomicReference 可以封装引用一个V实例,

上面支持并发访问的单例模式就是利用了这种特性。使用CAS的好处是不需要使用传统的加锁方式,而是依赖CAS的忙等算法、底层硬件的实现保证线程安全。相对于其他锁的实现,没有线程的切换和阻塞也就没有了额外的开销,并且可以支持较大的并发。当然,CAS也有一个缺点就是忙等,如果一直没有获取到,会陷于死循环。

利用 ThreadLocal实现单例

如果大家对ThreadLocal有不明白的地方,可以看我的历史文章: 干货!ThreadLocal 使用场景


/**
 * 利用ThreadLocal 实现单例,只保存一个对象实例
 * @Date 2021/8/1 8:30 下午
 * @Author yn
 */
public class AppContext {
    private static final ThreadLocal<AppContext> local = new ThreadLocal<>();
    private Map<String,Object> data = new HashMap<>();
    public Map<String, Object> getData() {
        return getAppContext().data;
    }
    //批量存数据
    public void setData(Map<String, Object> data) {
        getAppContext().data.putAll(data);
    }
    //存数据
    public void set(String key, String value) {
        getAppContext().data.put(key,value);
    }
    //取数据
    public void get(String key) {
        getAppContext().data.get(key);
    }
    //初始化的实现方法
    private static AppContext init(){
        AppContext context = new AppContext();
        local.set(context);
        return context;
    }
    //做延迟初始化
    public static AppContext getAppContext(){
        AppContext context = local.get();
        if (null == context) {
            context = init();
        }
        return context;
    }
    //删除实例
    public static void remove() {
        local.remove();
    }
}

上面的代码实现实际上就是懒汉式初始化的扩展,只不过用 ThreadLocal 替换静态对象来存储唯一对象实例。之所会选择 ThreadLocal,就是因为 ThreadLocal 相比传统的线程同步机制更有优势。

在传统的同步机制中,我们通常会通过对象的锁机制来保证同一时间只有一个线程访问单例类。这时该类是多个线程共享的,我们都知道使用同步机制时,什么时候对类进行读写、什么时候锁定和释放对象是有很烦琐要求的,这对于一般的程序员来说,设计和编写难度相对较大

而 ThreadLocal 则会为每一个线程提供一个独立的对象副本,从而解决了多个线程对数据的访问冲突的问题。正因为每一个线程都拥有自己的对象副本,也就省去了线程之间的同步操作

所以说,现在绝大多数单例模式的实现基本上都是采用的 ThreadLocal 这一种实现方式。

总结

虽然单例模式只是一个很平常的模式,但在各种的实现上却需要用到Java的基本功,包括懒汉模式、饿汉模式、线程是否安全、静态类、内部类、加锁和串行化等。在日常开发中,我们要根据实际情况去选择其中的一种方式。

感谢您的阅读,创作不易,欢迎点赞,关注, 转发,感谢,大家可以点击我头像查看历史干货文章。

设计模式本月我持续更新,欢迎关注下方的设计模式专栏,我会持续更新 我们下期再见!