Java设计模式之单例模式

本文深入解析单例模式,包括其概念、优缺点、应用场景及多种实现方式,如饿汉式、懒汉式、DCL、静态内部类、注册式、枚举式和ThreadLocal式单例,探讨了如何防止单例被破坏。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.单例模式介绍

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

特点

  1. 只有一个实例
  2. 自我实例化
  3. 提供全局访问点

意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

主要解决:一个全局使用的类频繁地创建与销毁。

何时使用:当您想控制实例数目,节省系统资源的时候。
如何解决:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。
关键代码:构造函数是私有的。

2.单例模式的优缺点

优点:在内存里只有一个实例,节省资源,减少性能开销,提高系统效率,同时也能够严格控制客户对它的访问。
缺点:因为系统中只有一个实例,这样就导致了单例类的职责过重,违背了“单一职责原则”,同时也没有抽象类,这样扩展起来有一定的困难。

3.单例模式的应用场景

重量级对象,不需要多个实例,如线程池、数据库连接池

4.常见的实现方式

4.1 饿汉式

是在类加载机制的时候就立即初始化,并且创建单例对象。绝对线程安全,在线程还没出现以前就被实例化了,不可能存在访问安全问题。

public class HungryPattern {
    private HungryPattern(){
    }
    private static final HungryPattern hungryPattern = new HungryPattern();
    public static HungryPattern getSingleton(){
        return hungryPattern;
    }
}

  • 优点: 没有加任何的锁、执行效率比较高,在用户体验上来说,比懒汉式更好
  • 缺点: 类加载的时候就初始化,不管用与不用都占着空间,如果项目中有大量单例对象,则可能会浪费大量内存空间

也可以使用静态代码块的方式实现

public class HungryPattern {

    //静态代码块方式实现
    private static final HungryPattern hungryPattern;
    static{
       hungryPattern = new HungryPattern();
    }

    public static HungryPattern getSingleton(){
        return hungryPattern;
    }
}

这两种写法都非常的简单,也非常好理解,饿汉式适用在单例对象较少的情况

4.2 懒汉模式

只有在真正使用的时候,才开始实例化

4.2.1 普通写法
public class LazyPattern {
    private LazyPattern(){
    }
    private static LazyPattern lazyPattern = null;
    public static LazyPattern getSingleton(){
        if(lazyPattern == null){
            lazyPattern = new LazyPattern();
        }
        return lazyPattern;
    }
}

上面的写法是最简单的一种懒汉式单例写法,但是存在线程安全问题,多线程情况下会有一定几率返回多个单例对象,这明显违背了单例对象原则,可以使用synchronized来解决线程安全问题。

4.2.2 synchronized写法
public class LazyPattern {
    private LazyPattern(){
    }
    private static LazyPattern lazyPattern = null;
    public static synchronized LazyPattern  getSingleton(){
        if(lazyPattern == null){
            lazyPattern = new LazyPattern();
        }
        return lazyPattern;
    }
}

synchronized写法仅仅是在getSingleton()方法上面加了synchronized关键字,其他地方没有任何变化。用 synchronized加锁,在线程数量比较多情况下,如果CPU分配压力上升,会导致大批量线程出现阻塞,从而导致程序运行性能大幅下降。那么,有没有一种更好的方式,既 兼顾线程安全又提升程序性能呢?答案是肯定的。接下来就再介绍一种双重检查锁(double-checked locking)单例写法

4.2.3 DCL(double-checked locking) 单例
public class LazyPattern {
    private LazyPattern(){
    }
    private static volatile LazyPattern lazyPattern = null;
    private static LazyPattern getSingleton(){
        if(lazyPattern == null){ //1
            synchronized (LazyPattern.class){ //2
                if(lazyPattern == null){ //3
                    lazyPattern = new LazyPattern(); //4
                }
            }
        }
        return lazyPattern;
    }
}

这里的写法将同步放在了方法里面的第一个非空判断之后,这样可以确保对象不为空的时候不会被阻塞,但是第二个非空判断的意义是什么呢?我们假设线程A首先获得锁,进入了3,还没有释放锁的时候,线程B又进来了,这时候因为线程还没有执行对象初始化,所以判空成立,会进入2等待获得锁,这时候当线程A释放锁之后,线程B会进入到3,这时候因为第二个判空判断对象不为空了,所以就会直接返回,如果没有第2个判空,这时候就会产生新的对象了,所以需要两次判空!

大家可能注意到这里的变量定义上加了volatile关键字,为什么呢?这是因为DCL有可能会存在失效的情况:
4 :lazyPattern = new LazyPattern();
大致存在以下三步:

  1. 分配内存给对象
  2. 初始化对象
  3. 将初始化好的对象和内存地址建立关联(赋值)

而这3步由于CPU指令重排序,不能保证一定按顺序执行,假如线程A正在执行new的操作,第1步和第3步都执行完了,但是第2步还没执行完,这时候线程B进入到方法中的第1行代码,判空不成立,所以直接返回了对象,而这时候对象并没有初始化完全,所以就会报错了,指令重排发生的概率很低,但是还是有可能发生的,解决这个问题的办法就是使用volatile关键字,禁止指令重排序(jdk1.5之后),保证按顺序执行上面的三个步骤。关于volatile关键字,可以参考这篇文章Java多线程volatile底层原理详解

4.2.4 静态内部类写法
public class LazyPattern {
    //静态内部类写法
    private LazyPattern(){
    }
   
    public static class SingletonHolder{
        private static final LazyPattern lazyPattern = new LazyPattern();
    }
    
    public static LazyPattern getSingleton(){
        return SingletonHolder.lazyPattern;
    }
}

外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化,故而不占内存,第一次调用getSingleton()方法时虚拟机才会加载SingletonHoler类。

问题

单例模式真的能够实现实例的唯一性吗?

答案是否定的,利用反射或者反序列化都可以破坏单例模式

public class Test {
    public static void main(String[] args) throws Exception {
        Class clazz = Class.forName("singleton.LazyPattern");
        Constructor constructor = clazz.getDeclaredConstructor(null);
        constructor.setAccessible(true);

        //通过反射获取单例对象
        Object o = constructor.newInstance();

        Object o1 = LazyPattern.getSingleton();
        LazyPattern singleton = LazyPattern.getSingleton();
        System.out.println(o == o1);  // false
 } 

上面这个结果输出的结果为false,说明产生了2个对象,我们可以用一个很简单的方法来防止单例被破坏,在构造方法中加一个判断即可

private LazyPattern(){
        if(singleton != null){
            throw new RuntimeException("不允许构造多个实例");
        }
    }

这样虽然防止了反射破坏单例,但是依然可以被序列化破坏单例,下面就让我们验证一下序列化是如何破坏单例的!

首先对上面的类实现序列化接口

public class LazyPattern implements Serializable {

接下来开始对单例对象类进行序列化和反序列化测试:

//测试反序列化破坏单例
public class Test2 {
    public static void main(String[] args) {
        LazyPattern l1 = null;
        LazyPattern l2 = LazyPattern.getSingleton();

        FileOutputStream fos = null;
        try{
            //会在当前项目路径下创建LazyPatternSingleton.obj文件
            fos = new FileOutputStream("LazyPatternSingleton.obj");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(l2);
            oos.flush();
            oos.close();

            FileInputStream fis = new FileInputStream("LazyPatternSingleton.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            Object o = ois.readObject();
            ois.close();
            System.out.println(o == l2);  //false
        }catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

输出结果为false,说明产生了2个对象,那么我们应该如何防止序列化破坏单例呢?我们可以在LazyPattern类加上readResolve方法就可以防止序列化破坏单例

//防止序列化和反序列化破坏单例
public Object readResolve() {
    return lazyPattern;
}

为什么类中只要有readResolve方法,就可以防止序列化和反序列化破坏单例呢?

这是因为JDK源码中会检验一个类中是否存在一个readResolve()方法,如果存在,则会放弃通过序列化产生的对象,而返回原本的对象,也就是说,在校验是否存在readResolve()方法前产生了一个对象,只不过这个对象会在发现类中存在readResolve()方法后丢掉,然后返回原本的单例对象,保证了单例的唯一性,这种写法虽然保证了单例唯一,但是过程中类也是会被实例化两次,假如创建对象的频率增大,就意味着内存分配的开销也随之增大,那么有没有办法从根本上解决问题呢?那么下面就让继续介绍一下注册式单例

4.3 注册式单例

注册式单例就是将每一个实例都保存到某一个地方,然后使用唯一的标识获取实例

public class ContainerSingleton {
    private ContainerSingleton() {
    }
    public static Map<String, Object> ioc = new ConcurrentHashMap<>();

    public static Object getBean(String className) {
        synchronized (ioc) {
            if(ioc.containsKey(className)) {
                Object obj = null;
                try{
                   obj = Class.forName(className).newInstance();
                   ioc.put(className, obj);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InstantiationException e) {
                    e.printStackTrace();
                } catch (ClassNotFoundException e) {
                    e.printStackTrace();
                }
            }
            return ioc.get(className);
        }
    }

}

容器式写法适用于创建实例非常多的情况,便于管理。但是,是非线程安全的,spring中的单例就是属于此种写法。

4.4 枚举式

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;
    }
   
}

枚举式单例是《Effective java》一书中推荐的写法,非常高效。

关于枚举类的实现方式,本人暂时还没搞懂,可以看一下写篇文章为什么使用枚举类实现单例模式

4.5 ThreadLocal式单例

ThreadLocal不能保证其创建的对象是全局唯一,但是能保证在单个线程中是唯一的,天生的线程安全

public class ThreadLocalSingleton {
    private ThreadLocalSingleton(){

    }

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

    private static ThreadLocalSingleton getSingleton() {
        return threadLocal.get();
    }
 }

参考:

单例模式的8种写法及如何防止单例被破坏

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值