【设计模式实践笔记】第五节:单例模式

728 阅读7分钟

单例模式

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

单例模式是常用的创建型设计模式之一,主要是用来解决一个全局使用的类被频繁地创建与销毁。

单例有非常多种的实现方式,核心思路就是保持类只被实例化一次。

简单的单例

那么最简单的单例就是我们使用静态变量来完成实例化的过程,这里注意一下,静态变量只能帮助我们保持唯一实例,除此之外还要提供一个静态方法作为全局访问点。

public class Singleton {
    // 静态字段保持唯一实例
    private static final Singleton INSTANCE = new Singleton();
​
    // 静态方法提供单例访问点
    public static Singleton getInstance() {
        return INSTANCE;
    }
​
    // 私有构造器防止被外部实例化
    private Singleton() {
    }
}

上面这种实现方式我们称为 饿汉式 单例。

为什么叫饿汉式单例呢?从上述结构可以看出,无论有没有外部客户使用实例,总会有一个对象在加载时就已经实例化了,就像一个饿汉一样,你别管我吃不吃面包,先买回来再说。

所以也很容易看出来这种实现方式的弊端,还没用呢就已经初始化好了,如果实例占用资源较多,后面又没有客户用到它,那属实有点浪费,要是能用到的时候再加载就更好了!

懒加载

懒加载就是在真正用到的时候再去加载资源,放在单例模式里,可以很容易的通过修改访问点来实现。

public class Singleton {
    // 静态字段保持唯一实例
    private static Singleton instance = null;
​
    // 静态方法提供单例访问点
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
​
    // 私有构造器防止被外部实例化
    private Singleton() {
    }
}

这种实现方式利用懒加载的思想,只在第一次获取实例的时候进行初始化。这种写法看起来是很美好的,不过遗憾的是没有考虑到并发的场景,虽然最终态是能够满足唯一实例的要求,但是过程中会由于并发而导致出现多个实例的情况。

那么我们如何解决并发问题呢?

并发问题

最常见的方式是使用 synchronized 字段对 getInstance() 方法进行加锁。

public synchronized static Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

这种方式虽然能够保证线程安全,但是效率不高,实际上我们也不会这样去写,大多数场景下我们都不会去主动的实现懒加载和控制并发。

原因有很多种,一个是在高版本的 JVM 中已经做了优化,通常情况下不会在加载 class 的时候立马就初始化静态变量;另一个原因是实际开发中用的都不是严格的单例,只是约定了要保持 一个类仅有一个实例 ,具体的单例维护要么是交给 IOC 容器,例如 web 项目中交给 Spring 托管的各种配置类和服务类,要么是把初始化和保证单例的工作交给具体的业务方来做,具体的例子后面会讲到。

单例破坏问题

我们知道Java语言中提供了反射和序列化相关的API,这些API非常强大,我们可以用它来实现各种牛逼的框架和酷炫的组件,当然也可以拿它来做一些坏坏的事情!

反射破坏

我们思考一下,虽然单例类的构造器是私有的,但是反射工具提供了更改屏蔽构造器访问检查的方法 setAccessible(true) ,那我们如果通过反射来实例化多个对象是不是可行的呢?

验证代码:

public class SingletonTest {
    @Test
    public void init() throws Exception {
        Singleton s1 = Singleton.getInstance();
​
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton s2 = constructor.newInstance();
​
        System.out.println(s1.hashCode());
        System.out.println(s2.hashCode());
    }
}

执行发现输出结果并不相同,出现了两个实例,这意味着我们通过反射破坏了单例!

即使这种写法的单例模式可能出现破坏问题,那如果我们仍然希望继续使用这种方式的话,也还是有方法进行解决的,比如说在类里增加一个计数器,记录私有构造器执行的次数,如果超过1次就报错。虽然能够解决反射造成的破坏问题,但是也增加了单例模式的复杂性,这并不是我们想要的,业界也不常关心这个问题,而是选择通过约定来尽量避免不恰当的使用反射。

Josh BlochEffective Java 中提倡使用枚举类来实现单例模式,这种方式是利用Java自身能保证枚举类单例的特性来实现单例模式,而且也能够保证线程安全和解决反射破坏、序列化破坏的问题。

public enum Singleton {
    // 唯一枚举:
    INSTANCE;
​
    public String doSomething() {
        return "我是单例的方法!";
    }
}

测试类:

public class SingletonTest {
    @Test
    public void testEnum() {
        Singleton singleton = Singleton.INSTANCE;
        System.out.println(singleton.doSomething());
    }
}

输出结果:

我是单例的方法!

枚举模式是目前已知相对完美的解决方案,但是在实际生产项目中由于一般都会使用比较成熟的框架或者另有约定,所以这种方式也并不常用。

单例实战

我们学习一种设计模式,不光要了解它是什么,还要从实践的角度出发,真真正正的体验一把这个东西到底该怎么用,有啥好处,我不用行不行。单例模式也一样,作为一名开发老手,我相信即使你在项目中没有刻意引入单例模式,它也在无时无刻的影响着你。

为什么这么说呢?在 并发问题 这一小节已经说过,经过长时间的发展,我们实际使用的已经不是严格的单例模式,主要特征是只要保证单例类“有且仅有”一个实例即可称为单例。

那么实际开发的话,我们经常有两种方式可选,一种是项目使用了开发框架,例如 Spring Boot ,那 Spring 托管的 bean 如果没有被特殊设置,默认就是单例的。另一种方式是跟框架无关,我们人为的约定一个 bean 只被创建一次即可,例如 Elasticsearch 的客户端或者各种开放平台的客户端,皆是如此。

光说不练假把式,下面我们手写一个开放平台的客户端,来体验一把。

开放平台客户端

// 客户端
public class OpenClient {
    private OpenConfig config;
​
    public OpenClient(OpenConfig config) {
        System.out.println("初始化客户端");
        this.config = config;
    }
  
    public String execute(OpenRequest request) {
        return request.getMethod() + "执行成功!";
    }
}
​
// 配置
public class OpenConfig {
    // 接口地址
    private String apiUrl;
    // 应用标识
    private String appKey;
    // 应用密钥
    private String secret;
    // 省略getter、setter
}
​
// 接口服务类
public class OpenService {
    private static final String API_URL = "http://open.suzaku.tech/rest";
    private static final String APP_KEY = "java_note";
    private static final String APP_SECRET = "123456";
    private static OpenClient openClient;
​
    static {
        OpenConfig config = new OpenConfig();
        config.setApiUrl(API_URL);
        config.setAppKey(APP_KEY);
        config.setSecret(APP_SECRET);
​
        openClient = new OpenClient(config);
    }
​
    private OpenClient getOpenClient() {
        return openClient;
    }
​
    // 测试方法
    public String hello(String world) {
        OpenRequest request = new OpenRequest();
        request.setMethod("hello");
        request.setParams(world);
​
        return getOpenClient().execute(request);
    }
}

测试类:

public class OpenServiceTest {
​
    @Test
    public void hello() {
        OpenService service = new OpenService();
        OpenService service1 = new OpenService();
        OpenService service2 = new OpenService();
        String result = service.hello("world");
        System.out.println(result);
    }
}

输出:

初始化客户端
hello执行成功!

测试代码里面对 OpenService 做了多次实例化操作,可以看到 OpenClient 只被初始化了一次,保持了单例。

实际上 OpenService 中没有成员变量,完全没有必要创建多个实例,如果结合 Spring 使用,我们只需要在 OpenService 上面使用 @Service 或者 @Component 注解,就可以让 OpenService 也变成单例,这种方式也是我们使用最多的。

小结

现在我们不仅了解了单例的概念、优点和适用场景,更通过开放平台客户端的实践学习了单例的使用姿势。就当下的开发环境而言,Spring 已经成了业界进行Java开发的事实标准,我们平常的开发过程也更在意的是约定,而不是单例的实现方式。

复习一下,单例保证了一个类有且仅有一个实例;单例节省资源,效率更高;单例可以被用在各种地方,通常会结合 Spring 一起使用。

实践出真知,让我们一起加油吧!

关注「Java实践笔记」公众号获取全部源代码!

版权声明:本文为公众号「Java实践笔记」的原创文章,转载请联系作者,附上原文链接及本声明。 公众号合集链接

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿