最简单的设计模式是单例?

举报
归思君 发表于 2024/01/02 14:07:19 2024/01/02
【摘要】 单例模式可以说是Java中最简单的设计模式,但同时也是技术面试中频率极高的面试题。因为它不仅涉及到设计模式,还包括了关于线程安全、内存模型、类加载等机制。所以说它是最简单的吗? 今天就分别从单例模式的实现方法和应用场景来介绍一下单例模式

单例模式可以说是Java中最简单的设计模式,但同时也是技术面试中频率极高的面试题。因为它不仅涉及到设计模式,还包括了关于线程安全、内存模型、类加载等机制。所以说它是最简单的吗?
今天就分别从单例模式的实现方法和应用场景来介绍一下单例模式

一、单例模式介绍

1.1 单例模式是什么

单例模式也就是指在整个运行时域中,一个类只能有一个实例对象。

那么为什么要有单例模式呢?这是因为有的对象的创建和销毁开销比较大,比如数据库的连接对象。所以我们就可以使用单例模式来对这些对象进行复用,从而避免频繁创建对象而造成大量的资源开销。

1.2 单例模式的原则

为了到达单例这个全局唯一的访问点的效果,必须让单例满足以下原则:

  1. 阻止类被通过常规方法实例化(私有构造方法)
  2. 保证实例对象的唯一性(以静态方法或者枚举返回实例)
  3. 保证在创建实例时的线程安全(确保多线程环境下实例只有一个)
  4. 对象不会被外界破坏(确保在有序列化、反序列化时不会重新构建对象)

二、单例模式的实现方式

关于单例模式的写法,网上归纳的已经有很多,但是感觉大多数只是列出了写法,不去解释为什么这样写的好处和原理。我偶然在B站看了寒食君归纳的单例模式总结思路还不错,故这里借鉴他的思路来分别说明这些单例模式的写法。

按照单例模式中是否线程安全、是否懒加载和能否被反射破坏可以分为以下的几类

2.1 懒加载

2.1.1 懒加载(线程不安全)

public class Singleton {
    /**保证构造方法私有,不被外界类所创建**/
    private Singleton() {}
    /**初始化对象为null**/
    private static Singleton instance = null;

    public static Singleton getInstance() {
        //判断是否被构造过,保证对象的唯一
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

从上面我们可以看到,通过public class Singleton我们可以全局访问该类;通过私有化构造方法,能够避免该对象被外界类所创建;以及后面的getInstance方法能够保证创建对象实例的唯一。

但是我们可以看到,这个实例不是在程序启动后就创建的,而是在第一次被调用后才真正的构建,所以这样的延迟加载也叫做懒加载

然而我们发现getInstance这个方法在多线程环境下是线程不安全的—如果有多个线程同时执行该方法会产生多个实例。那么该怎么办呢?我们想到可以将该方法变成线程安全的,加上synchronized关键字。

2.1.2 懒加载(线程安全)

public class Singleton {
    /**保证构造方法私有,不被外界类所创建**/
    private Singleton() {}
    /**初始化对象为null**/
    private static Singleton instance;
    
	//判断是否被构造过,保证对象的唯一,而且synchronize也能保证线程安全
    public synchronized static Singleton getInstance() {
        
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

但是我们知道,如果一个静态方法被synchronized所修饰,会把当前类的class 对象锁住,会增大同步开销,降低程序的执行效率。所以可以从缩小锁粒度角度去考虑,把synchronized放到方法里面去,也就是让其修饰同步代码块,如下所示:

public class Singleton {
    /**保证构造方法私有,不被外界类所创建**/
    private Singleton() {}
    /**初始化对象为null**/
    private static Singleton instance;
    
    public static Singleton getInstance() { 
        if (instance == null) {
            //利用同步代码块,锁的是当前实例对象
            synchronized(Singleton.class) {
                instance = new Singleton();
            }
            
        }
        return instance;
    }
}

但是这个时候,我们发现if(instance == null)是没有锁的,所以当两个线程都执行到该语句并都判断为true时,还是会排队创建新的对象,那么有没有新的解决方式?

2.1.3 懒加载(线程安全,双重检测锁)

public class Singleton {
    /**保证构造方法私有,不被外界类所创建**/
    private Singleton() {}
    /**初始化对象**/
    private static Singleton instance;

    public static Singleton getInstance() {
        //第一次判断
        if (instance == null) {
            synchronized (Singleton.class) {
                //第二次判断
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

我们在上一节的代码上再加上一次判断,就是双重检测锁(Double Checked Lock, DCL)。但是上述代码也存在一些问题,比如在instance = new Singleton() 这行代码中,它并不是一个原子操作,实际上是有三步:

  • 给对象实例分配内存空间

  • new Singleton() 调用构造方法,初始化成员字段

  • instance对象指向分配的内存空间

所以会涉及到内存模型中的指令重排,那么这个时候可以用 volatile关键字来修饰 instance对象,防止指令重排,写出如下代码:

public class Singleton {
    /**保证构造方法私有,不被外界类所创建**/
    private Singleton() {}
    /**初始化对象,加上volatile防止指令重排**/
    private volatile static Singleton instance;

    public static Singleton getInstance() {
        //第一次判断
        if (instance == null) {
            synchronized (Singleton.class) {
                //第二次判断
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

此外,我们也可以尝试使用一些乐观锁的方式达到线程安全的效果,比如CAS。

2.1.4 懒加载(线程安全,CAS乐观锁)

public class Singleton {
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
    private static Singleton instance;
    
    private Singleton(){}
    public static final Singleton getInstance() {
        for(;;) {
            Singleton instance = INSTANCE.get();
            if(instance != null) {
                return instance;
            }
            instance = new Singleton();
            if(INSTANCE.compareAndSet(null, instance)) {
                return instance;
            }
        }
    }
}

CAS 是一种乐观锁,依赖于底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并发度,但是如果忙等待一直执行不成功,也会对CPU造成较大的执行开销。

2.2 饿汉(线程安全)

不同于懒加载的延迟实现实例,我们也可以在程序启动时就加载好单例对象:

public class Singleton {
    /**保证构造方法私有,不被外界类所创建**/
    private Singleton() {}
    /**直接获取实例对象**/
    private static Singleton instance = new Singleton();
    
    public static Singleton getInstance() {
        return instance;
    }
}

这样的好处是线程安全,单例对象在类加载时就已经被初始化,当调用单例对象时只是把早已经创建好的对象赋值给变量。缺点就是如果一直没有调用该单例对象的话,就会造成资源浪费。除此之外还有其他的实现方式。

2.3 静态内部类

public class Singleton {
    /**保证构造方法私有,不被外界类所创建**/
    private Singleton() {}
    /**利用静态内部类获取单例对象**/
    private static class SingletonInstance {
        private static final Singleton instance = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonInstance.instance;
    }
}

静态内部类的方法结合了饿汉方式,它们都采用了类加载机制来保证当初始化实例时只有一个线程执行,从而保证了多线程下的安全操作。原因就是JVM在类初始化阶段时会创建一个锁,该锁可以保证多个线程同步执行类初始化工作。

但是静态内部类不会在程序启动时创建单例对象,它是在外界调用 getInstance方法时才会装载内部类,从而完成单例对象的初始化工作,不会造成资源浪费。

然而这种方法也存在缺点,它可以通过反射来进行破坏。下面就该提到枚举方式了

2.4 枚举

枚举是《Effective Java》作者推荐的单例实现方式,枚举只会装载一次,无论是序列化、反序列化、反射还是克隆都不会新创建对象。因此它也不会被反射所破坏。

public class Singleton {
    INSTANCE;
}

所以这种方式是线程安全的,而且无法被反射而破坏

三、单例模式的应用场景

3.1 Windows 任务管理器

在一个windows 系统中只有一个任务管理器,这就是一种单例模式的应用。

3.2 网站的计数器

因为计数器的作用,就必须保证计数器对象保证唯一

3.3 JDK中的单例

3.3.1java.lang.Runtime

Every Java application has a single instance of class Runtime that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from the getRuntime method.

An application cannot create its own instance of this class.

每个java程序都含有唯一的Runtime实例,保证实例和运行环境相连接。当前运行时可以通过getRuntime方法获得

我们来看看具体的代码:

public class Runtime {
    private static Runtime currentRuntime = new Runtime();
    
    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {}

我们发现这就是单例模式的饿汉加载方式。

3.3.2 java.awt.Desktop

类似的,在java.awt.Desktop中也存在单例模式的使用,比如:

public class Desktop {

    private DesktopPeer peer;
    
    private Desktop() {
        peer = Toolkit.getDefaultToolkit().createDesktopPeer(this);
    }
	//懒加载
    public static synchronized Desktop getDesktop(){
        if (GraphicsEnvironment.isHeadless()) throw new HeadlessException();
        if (!Desktop.isDesktopSupported()) {
            throw new UnsupportedOperationException("Desktop API is not " +
                                                    "supported on the current platform");
        }

        sun.awt.AppContext context = sun.awt.AppContext.getAppContext();
        Desktop desktop = (Desktop)context.get(Desktop.class);

        if (desktop == null) {
            desktop = new Desktop();
            context.put(Desktop.class, desktop);
        }

        return desktop;
    }

这种方法就是一种延迟加载的方式。

3.4 Spring Bean 作用域

比较常见的就是Spring Bean作用域里的单例了,这个比较常见,可以通过配置文件进行配置:

<bean class="..."></bean>

参考资料

https://www.zhihu.com/search?type=content&q=单例模式

https://www.bilibili.com/video/BV1pt4y1X7kt?spm_id_from=333.337.search-card.all.click

https://www.jianshu.com/p/137e65eb38ce

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。