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 enum 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=%E5%8D%95%E4%BE%8B%E6%A8%A1%E5%BC%8F

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

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

目录
相关文章
|
16天前
|
设计模式 测试技术 Python
《手把手教你》系列基础篇(九十二)-java+ selenium自动化测试-框架设计基础-POM设计模式简介(详解教程)
【7月更文挑战第10天】Page Object Model (POM)是Selenium自动化测试中的设计模式,用于提高代码的可读性和维护性。POM将每个页面表示为一个类,封装元素定位和交互操作,使得测试脚本与页面元素分离。当页面元素改变时,只需更新对应页面类,减少了脚本的重复工作和维护复杂度,有利于团队协作。POM通过创建页面对象,管理页面元素集合,将业务逻辑与元素定位解耦合,增强了代码的复用性。示例展示了不使用POM时,脚本直接混杂了元素定位和业务逻辑,而POM则能解决这一问题。
33 6
|
14天前
|
设计模式 Java 测试技术
《手把手教你》系列基础篇(九十四)-java+ selenium自动化测试-框架设计基础-POM设计模式实现-下篇(详解教程)
【7月更文挑战第12天】在本文中,作者宏哥介绍了如何在不使用PageFactory的情况下,用Java和Selenium实现Page Object Model (POM)。文章通过一个百度首页登录的实战例子来说明。首先,创建了一个名为`BaiduHomePage1`的页面对象类,其中包含了页面元素的定位和相关操作方法。接着,创建了测试类`TestWithPOM1`,在测试类中初始化WebDriver,设置驱动路径,最大化窗口,并调用页面对象类的方法进行登录操作。这样,测试脚本保持简洁,遵循了POM模式的高可读性和可维护性原则。
18 2
|
14天前
|
设计模式 安全 C++
C++一分钟之-C++中的设计模式:单例模式
【7月更文挑战第13天】单例模式确保类只有一个实例,提供全局访问。C++中的实现涉及线程安全和生命周期管理。基础实现使用静态成员,但在多线程环境下可能导致多个实例。为解决此问题,采用双重检查锁定和`std::mutex`保证安全。使用`std::unique_ptr`管理生命周期,防止析构异常和内存泄漏。理解和正确应用单例模式能提升软件的效率与可维护性。
17 2
|
17天前
|
设计模式 安全 Java
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
Java面试题:设计模式如单例模式、工厂模式、观察者模式等在多线程环境下线程安全问题,Java内存模型定义了线程如何与内存交互,包括原子性、可见性、有序性,并发框架提供了更高层次的并发任务处理能力
33 1
|
15天前
|
设计模式 Java 测试技术
《手把手教你》系列基础篇(九十三)-java+ selenium自动化测试-框架设计基础-POM设计模式实现-上篇(详解教程)
【7月更文挑战第11天】页面对象模型(POM)通过Page Factory在Java Selenium测试中被应用,简化了代码维护。在POM中,每个网页对应一个Page Class,其中包含页面元素和相关操作。对比之下,非POM实现直接在测试脚本中处理元素定位和交互,代码可读性和可维护性较低。
14 0
|
17天前
|
安全 Java API
Java面试题:解释synchronized关键字在Java中的作用,并讨论其使用场景和限制。
Java面试题:解释synchronized关键字在Java中的作用,并讨论其使用场景和限制。
14 0
|
17天前
|
存储 运维 前端开发
Java面试题:什么是Java的异常处理机制?列举常见的异常类,并说明使用场景
Java面试题:什么是Java的异常处理机制?列举常见的异常类,并说明使用场景
26 0
|
17天前
|
设计模式 安全 Java
Java面试题:什么是单例模式?如何在Java中实现单例模式?
Java面试题:什么是单例模式?如何在Java中实现单例模式?
12 0
|
17天前
|
安全 Java 数据处理
Java面试题:什么是Java中的Optional类及其使用场景?
Java面试题:什么是Java中的Optional类及其使用场景?
16 0
|
17天前
|
设计模式 安全 Java
Java面试题:解释单例模式的实现方式及其优缺点,讨论线程安全性的实现。
Java面试题:解释单例模式的实现方式及其优缺点,讨论线程安全性的实现。
16 0