【设计模式系列(二)】彻底搞懂单例模式

时间:2022-07-24
本文章向大家介绍【设计模式系列(二)】彻底搞懂单例模式,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

文章中涉及到的代码,可到这里来拿:https://gitee.com/daijiyong/DesignPattern

概念:单例模式(Singleton Pattern)是指确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点

关键点是:单例类中有一个静态的变量,私有化构造方法,提供唯一全局调用方法,并选择一个时机进行初始化

属于创建型模式

它主要是为了解决:一个全局使用的类频繁地创建与销毁所造成的资源开销

单例模式比较简单,最复杂的地方在于如何保证多线程、序列化等情况下

仍然保证单例实例的唯一性

使用场景:

  • 配置文件,如ServletContext、ServletConfig、ApplicationContext、数据库连接池
  • 要求生产唯一序列号
  • WEB 中的计数器,不用每次刷新都在数据库中同步一次,可以用单例先缓存起来

1. 饿汉式

public class HungrySingleton {
    private static final HungrySingleton instance = new HungrySingleton();
    private HungrySingleton() {
    }
    public static HungrySingleton getInstance() {
        return instance;
    }
}

在类加载的时候实例就初始化了

所以基于 classloader 机制很好的避免了多线程情况下的同步问题

还有一种写法是这样的

public class HungrySingleton {
    private static HungrySingleton instance;
    static {
        instance = new HungrySingleton();
    }
    private HungrySingleton() {
    }
    public static HungrySingleton getInstance() {
        return instance;
    }
}

类加载顺序:

先静态后动态、先上后下、先属性后方法

所以这种写法也能满足在类加载的时候就能初始化的要求

懒汉式的优点是:执行效率高,性能高(没有加锁)

缺点:不过不是明确需要初始化这个实例,存在内存浪费的情况

2. 懒汉式单例

为了解决饿汉式中存在的内存浪费的情况

我们可以采用懒汉式

public class LazySimpleSingleton {
    private static LazySimpleSingleton instance;
    private LazySimpleSingleton() {
    }
    public static LazySimpleSingleton getInstance() {
        if (instance == null) {
            instance = new LazySimpleSingleton();
        }
        return instance;
    }
}

懒汉式在类加载的时候不会初始化单例

只有被被外部调用的时候才会创建

下面我们测试一下多线程情况下,这种单例的实现方式能否安全

写一个实现了Runable接口的类

在run方法中调用单例模式的实例

/**
 * @author daijiyong
 */
public class TestLazySingletonThread implements Runnable {
    public void run() {
        System.out.println(Thread.currentThread().getName() + LazySimpleSingleton.getInstance());
    }
}

编写主函数,写三个线程进行测试

/**
 * @author daijiyong
 */
public class Test {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new TestLazySingletonThread());
        Thread thread2 = new Thread(new TestLazySingletonThread());
        Thread thread3 = new Thread(new TestLazySingletonThread());
        thread1.start();
        thread2.start();
        thread3.start();
    }
}

显而易见,多线程下并没有保证线程安全

创建了多个单例的对象

其中线程1用的是一个实例

线程0和线程2用的另外一个实例

为什么会出现这个情况呢?

首先我们得知道,Java中线程类的start()方法,并不是会立马执行当前线程

仅仅是告知cpu,你需要执行当前线程,具体什么时候执行,看心情

所以虽然我们是按照thread1、thread2、thread3的顺序执行的start()方法,但是执行顺序却并一定是这样的

而且他们三个的执行顺序可能在不同的时间点,也是不一样的,完全随缘

所以,即便打印出来的两个线程的实例是一样的,也不代表这个单例只被创建了一次

也有可能是创建了两个实例,但是在返回结果之前,第二个实例已经创建完了,将第一个实例覆盖了

怎么优化?第一个想到的应该就是加锁

public class LazySimpleSingleton {
    private static LazySimpleSingleton instance;
    private LazySimpleSingleton() {
    }
    public synchronized static LazySimpleSingleton getInstance() {
        if (instance == null) {
            instance = new LazySimpleSingleton();
        }
        return instance;
    }
}

这样随便执行,就不会出现线程不安全的问题了

但是当我这打了一个断点,查看三个进程执行情况的时候发现了下面的问题

线程0是执行runing状态,但是其他两个线程是监听monitor状态

当执行的线程特别多的时候,会就导致有大量的线程处于等待监听状态

synchronized关键字,是解决了线程安全问题

但是导致在同一个时间只能有一个调用,性能会极速下降

为了解决这个,我们还有一个办法

双重检验方法

public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton instance;
    private LazyDoubleCheckSingleton() {
    }
    public static LazyDoubleCheckSingleton getInstance() {
        //是否要线程阻塞
        if (instance == null) {
            synchronized (LazyDoubleCheckSingleton.class) {
                //是否要创建实例
                if (instance == null) {
                    instance = new LazyDoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

第一个检验是为了判断,是否已经初始化了单例

如果已经创建了,则不进行线程阻塞,直接返回

第二个检验是为了判断,在没有初始化的情况下,需要初始化,此时是否因为线程阻塞的原因,已经初始化了

如果已经初始化,则直接返回,如果没有,则进行线程初始化

除了双重检验,还有一个更好的方法来实现懒汉模式

静态内部类

public class LazyStaticInnerClassSingleton {
    private LazyStaticInnerClassSingleton() {
        //防止通过Java反射机制创建实例
        if (LazyHolder.INSTANCE != null) {
            throw new RuntimeException("不允许非法访问");
        }
    }
    private static LazyStaticInnerClassSingleton getInstance() {
        return LazyHolder.INSTANCE;
    }
    private static class LazyHolder {
        private static final LazyStaticInnerClassSingleton INSTANCE = new LazyStaticInnerClassSingleton();
    }
}

这个看起来是饿汉式的写法

但是其实是懒汉式的

这个主要是利用了java类加载的时候,默认是不会加载内部类的机制

只有在调用使用内部类的时候才会对内部类实现初始化

 classloader 机制保证了初始化 instance 时只有一个线程

这样就很好的解决了线程不安全的问题

优雅、高级、装*

3. 枚举式单例

public enum EnumSingleton {
    /**
     * 实例
     */
    INSTANCE;
    private Object data;
    public Object getData() {
        return data;
    }
    public void setData(Object data) {
        this.data = data;
    }
    public static EnumSingleton getInstance() {
        return INSTANCE;
    }
}

这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法

它更简洁,利用枚举类自身特点

不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化

为什么呢?

我们可以看一下源码

从Java的源码可以看到

Java在底层就禁止了通过反射的方法创建枚举对象

所以在原生层面就解决了反射机制破坏单例模式的问题

那枚举类是如何保证线程安全的呢?

这个问题问的好,我们在看看源码

由此可知,枚举类在加载的时候

会将每一个枚举类的实例元素放到一个Map当中

这个操作在程序启动、类加载的时候就完成了

之后每次取对象,都是从map中拿的

所以绝对线程安全

但是枚举方式跟饿汉方式一样

是存在内存浪费的情况的

为了解决这个问题,还有一种写法

4. 容器式单例

public class ContainerSingleton {
    private static Map<String, Object> ioc = new ConcurrentHashMap<String, Object>(16);
    private ContainerSingleton() {
    }
    public static Object getInstance(String className) {
        if (!ioc.containsKey(className)) {
            try {
                synchronized (ioc) {
                    Object instance = Class.forName(className).newInstance();
                    ioc.put(className, instance);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return ioc.get(className);
    }
}

将每一个实例都缓存到统一的容器当中,使用唯一标识获取实例

当容器中不存在时,则对容器加锁并创建,之后返回

如果已经存在,则直接返回

使用这种方法可以完美的解决多线程和反射带来的问题

5. ThreadLocal单例

public class ThreadLocalSingleton {
    private static final ThreadLocal<ThreadLocalSingleton> threadLocalInstance =
            new ThreadLocal<ThreadLocalSingleton>() {
                @Override
                protected ThreadLocalSingleton initialValue() {
                    return new ThreadLocalSingleton();
                }
            };
    private ThreadLocalSingleton() {
    }
    public static ThreadLocalSingleton getInstance() {
        return threadLocalInstance.get();
    }
}

ThreadLocal单例能够保证在一个线程内部的全局唯一,天生线程安全

跨线程的时候,保证不是同一个单例实例

这种实现方式的应用场景非常清晰了

用在多线程中,保证在一个线程中的单例实现

6. 总结

单例模式

优点:

减少内存开销

避免对资源的多重占用

设置全局访问点,严格控制访问

缺点:

没有接口,扩展困难,如果要扩展,只能修改代码,违背了开闭原则