(一)二十三种设计模式之单例模式

引言

设计模式是开发者前辈们在开发中总结出来的一些解决问题的模板或者说思路,它并不是一套代码规范。在开发过程中,再碰到一些设计模式可以解决的问题时,我们不一定要使用这些设计模式,但是,使用设计模式可以是我们的代码更加规范,使用更加简便。

文章主要参考了王争编著的《设计模式之美》一书。

一、什么是单例模式

如果你询问一个了解设计模式的开发者:“你常用的设计模式都有什么?”,那么我猜他的回答中大概率会有单例模式。

在开发过程中我们常常碰到一个类只需要创建一个对象就够了,例如日志写入类等。我们称这种只需要创建一个对象的类称为单例类,设计这种单例类的设计模式就称为单例模式。在单例模式中,不适用new来创建单例对象,而是用过单例类中的静态方法getInstance()来获取单例对象。

因为它主要解决对象的创建问题,所以属于设计模式中的创建型设计模式

就用上面的日志写入类举例,为什么要用单例模式?

如果现在有一个UserController和一个OrderController都需要使用Logger类中的log()进行日志写入,在不使用单例模式的情况下,需要在两个Controller中都new一个Logger类的实例,然后都调用Logger对象中的log()方法。这样做在多线程的情况下就会产生线程安全的问题,假如Logger中是使用FileWriter进行IO的,那么在两个Controller同时调用log()时,就会竞争写入文件的资源,导致日志相互覆盖。

面对这种情况当然也有解决方法,就是在log()方法中加上一个synchronized(Logger.class)同步块,使用类级别的锁,保证每次只能有一个线程使用log()。

上述方法当然可以解决线程安全的问题,但是更规范的作法还是使用设计模式中的单例模式可以很好的解决这个问题。

二、 单例模式的实现方法

在讲实现方法之前,我们应该知道,实现单例模式应该关注的点是哪些?如下:

  • 单例类所有的构造方法必须是private访问权限的(避免外部使用new创建对象,导致单例类的实例不唯一)
  • 对象创建时的线程安全问题
  • 是否支持延迟加载
  • getInstance()方法的性能是否足够高

对下面讲的一些实现单例模式的方法,都需要关注一下上述四个点。 


 1. “饿汉”式

 为什么叫“饿汉”式呢?我个人理解是,饥不择食,在JVM加载该单例类时,单例对象logger就会被创建并加载。

特点:不支持延迟加载、支持高并发

public class Logger {

    private final FileWriter writer;
    private static final Logger logger = new Logger();

    private Logger() {
        // 创建writer实例,true表示写入日志时施行追加写入
        try {
            writer = new FileWriter(new File("/log.txt"), true);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void log(String msg) throws IOException {
        // 这里因为FileWriter本身是加了对象锁的,因此不需要对log()方法进行同步
        writer.write(msg);
    }

    public static Logger getInstance() {
        return logger;
    }

}

2. “懒汉”式

这里也说一说我个人的理解,“懒汉”式就是说,在加载该单例类时不会创建单例对象,对象的创建延迟到了调用getInstance()方法时,就,很“懒”。

因为将实例的创建移到了getInstance()方法中,因此,在多线程环境下为了避免单例对象的不唯一,必须限制getInstance()方法的并发度为1。

特点:支持延迟加载、不支持高并发

可以参考下面代码,可以与上面“饿汉”式作比较。

public class Logger {

    private final FileWriter writer;
    private static Logger logger;

    private Logger() {
        // 创建writer实例,true表示写入日志时施行追加写入
        try {
            writer = new FileWriter(new File("/log.txt"), true);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void log(String msg) throws IOException {
        // 这里因为FileWriter本身是加了对象锁的,因此不需要对log()方法进行同步
        writer.write(msg);
    }

    public synchronized static Logger getInstance() {
        if (logger == null) {
            logger = new Logger();
        }
        return logger;
    }

}

3. 双重检测

 双重检测是一种结合了“懒汉”式和“饿汉”式的实现方式。这种方式在getInstance()方法中加了两重检测,在第二重检测中创建对象,并且在第二重检测上加了类级别的锁。

特点:支持高并发、支持延迟加载

观察下面代码的getInstance()方法。加入现在有多个线程同时调用了getInstance()方法,并且单例对象还没有初始化。那么这些线程都会通过第一重检测,来到synchronized处,因为是类级别的锁,因此只能由一个线程得到锁,进入第二重检测,然后创建单例对象,返回对象。后续对象陆续得到锁到达第二重检测,但此时单例对象已经创建,所以直接返回。

public class Logger {

    private final FileWriter writer;
    private static volatile Logger logger;

    private Logger() {
        // 创建writer实例,true表示写入日志时施行追加写入
        try {
            writer = new FileWriter(new File("/log.txt"), true);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void log(String msg) throws IOException {
        // 这里因为FileWriter本身是加了对象锁的,因此不需要对log()方法进行同步
        writer.write(msg);
    }

    public static Logger getInstance() {
        if (logger == null) {
            synchronized (Logger.class) {
                if (logger == null) {
                    logger = new Logger();
                }
            }
        }
        return logger;
    }

}

 *注意:双重检测也是存在一点问题的。CPU指令重排序可能会导致在Logger类的对象被关键字new创建并赋值给logger之后,还没来得及初始化(执行构造方法中的代码逻辑),就被另一个线程使用了。这样,另一个线程就使用了一个没有完整初始化Logger类的对象。要解决这个问题,我们只需要给logger成员变量添加volatile关键字来禁止指令重排序。

4. 静态内部类

静态内部类是一种相较于双重检测更加简单的实现。它类似于“饿汉”式,但是支持延迟加载。

特点:支持高并发、支持延迟加载

该私有内部类在Logger类被加载时并不会被加载,只有当getInstance()方法第一次被调用时才会被加载。至于单例对象的唯一性和线程安全问题又JVM保证,因此不需要加锁。 

public class Logger {

    private final FileWriter writer;

    private static final class Singleton {
        private static final Logger logger = new Logger();
    }

    private Logger() {
        // 创建writer实例,true表示写入日志时施行追加写入
        try {
            writer = new FileWriter(new File("/log.txt"), true);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void log(String msg) throws IOException {
        // 这里因为FileWriter本身是加了对象锁的,因此不需要对log()方法进行同步
        writer.write(msg);
    }

    public static Logger getInstance() {
        return Singleton.logger;
    }

}

5. 枚举

通过枚举类本身的特性来保证线程安全和单例对象的唯一性。

特点:支持高并发,不支持延迟加载

public enum Logger {

    // 单例对象
    LOGGER;

    private final FileWriter writer;

    Logger() {
        try {
            writer = new FileWriter(new File("/log.txt"), true);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void log(String msg) throws IOException {
        // 这里因为FileWriter本身是加了对象锁的,因此不需要对log()方法进行同步
        writer.write(msg);
    }

}

 *注意:使用这种实现并不需要getInstance()方法,直接调用枚举类中的LOGGER对象即可。

三、单例模式的弊端

 1. 单例模式隐藏了类之间的依赖关系

我们知道,代码的可读性是非常重要的。至少我们在后面检查代码时能看懂自己写的是什么。在阅读代码时,我们总是希望能够一眼就看出类与类之间的依赖关系。一般情况下,我们可以通过查看该类的构造方法、参数传递等清楚的得知类与类之间的依赖关系。但是单例模式不需要显示的创建对象,它往往都是在方法中直接调用getInstance()方法获取对象,因此我们需要阅读该类中所有的方法才能清楚的知道该类到底依赖了哪些单例类。

2. 单例模式扩展性差

假如现在一个系统中的数据库连接池我们使用了单例模式进行设计。但是后来发现系统中有一些非常复杂的Sql语句执行起来非常的耗时,因此想再增加一个连接池来执行这些复杂的Sql,原来的连接池执行简单的Sql,以此来提高系统性能。但是原来的连接池使用了单例模式进行的设计,因此就需要重新修改代码,非常的麻烦。

3. 单例模式影响代码的可测性

 单元测试是实际开发中非常重要的一环。在单元测试时,假如单例类中的一些成员变量是可修改的,一些使用了单例类的方法相当于共有这些可修改的成员变量,测试效果可能会因此受到影响。

4. 单例模式不支持有参的构造方法

这里提供三种解决思路:

(1)通过init()方法初始化参数

public class ConnectionPool {

    private static ConnectionPool pool = null;
    private final String username;
    private final String password;

    private ConnectionPool(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public synchronized static void init(String username, String password) {
        if (pool != null) {
            throw new RuntimeException("ConnectionPool has been created.");
        }
        pool = new ConnectionPool(username, password);
    }

    public static ConnectionPool getInstance() {
        if (pool == null) {
            throw new RuntimeException("init connection pool first.");
        }
        return pool;
    }
    
}

(2)将参数的获取放到getInstance()方法中

public class ConnectionPool {

    private static ConnectionPool pool = null;
    private final String username;
    private final String password;

    private ConnectionPool(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public synchronized static ConnectionPool getInstance(String username, String password) {
        if (pool == null) {
            pool = new ConnectionPool(username, password);
        }
        return pool;
    }

}

(3)将参数设置为全局变量(推荐使用)

Config类,放置配置信息

public class Config {

    public static final String USERNAME = "root";
    public static final String PASSWORD = "123456";
    
}
public class ConnectionPool {

    private static ConnectionPool pool = null;
    private final String username;
    private final String password;

    private ConnectionPool() {
        this.username = Config.USERNAME;
        this.password = Config.PASSWORD;
    }

    public synchronized static ConnectionPool getInstance() {
        if (pool == null) {
            pool = new ConnectionPool();
        }
        return pool;
    }

}

结语

单例模式是设计模式种比较简单的一类,但是也存在着一些问题。在许多情况下,单例模式是由许多替代方案的,例如使用工厂模式,IoC(控制反转)容器(例如Spring)的DI(依赖注入)、静态方法等。

但是单例模式的一些优点,例如设计简单、可以不通过new来优雅地创建对象等,也让单例模式不至于被淘汰。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值