设计模式之美笔记8

记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步

单例模式

问题:

  • 为什么要使用单例
  • 单例存在哪些问题
  • 单例与静态类的区别
  • 有什么替代的解决方案

1. 为什么要使用单例

单例设计模式singleton design pattern,一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫单例设计模式。

2. 应用场景

为什么需要单例模式?能解决哪些问题?

实战案例1:处理资源访问冲突
public class Logger {
    private FileWriter writer;
    public Logger(){
        File file = new File("xxx/xx/log.txt");
        writer = new FileWriter(file,true);//true表示追加写入
    }
    
    public void log(String message){
        writer.write(message);
    }
}

// logger类的应用示例:
public class UserController {
    private Logger logger = new Logger();
    
    public void login(String username,String password){
        //...省略业务逻辑代码...
        logger.log(username+" logined!");
    }
}

public class OrderController {
    private Logger logger = new Logger();
    
    public void create(OrderVo order){
        //...省略业务逻辑代码...
        logger.log("Created an order: "+order.toString());
    }
}

上述代码中,所有的日志都写入到同一个文件中。在UserController和OrderController中,分别创建两个Logger对象。在web容器的servlet多线程环境下,如果两个servlet线程同时执行login()和create()两个方法,并同时写日志到log.txt中,可能存在日志信息相互覆盖的情况。

为什么相互覆盖?可以类比理解。多线程下,如果两个线程同时给共享变量加1,最后结果可能并不是加2,而是只加了1,因为是竞争资源。同理,这里的log.txt文件也是竞争资源,可能存在相互覆盖。

如何解决?首先想到加锁。给log()方法加互斥锁(java的synchronized关键字)。同一时刻只允许一个线程调用执行log()方法。

public void log(String message){
  synchronized(this){
    writer.write(message);
  }
}

但是这样真的能解决多线程写入日志时互相覆盖的问题吗?不能,因为锁是对象级别的锁,不同的对象之间不共享这把锁。不同线程下,通过不同的对象调用执行log()方法,锁不起作用。

其实,FileWriter本身就是线程安全的,内部实现本身就加了对象级别的锁。再加对象锁多此一举。

那如何解决呢?只需要把对象锁换成类级别的锁即可。

public void log(String message){
  synchronized(Logger.class){//类级别的锁
    writer.write(message);
  }
}

此外,解决资源竞争问题的方法还有很多,分布式锁就是常听到的解决方案。不过,实现一个安全可靠、无bug、高性能的分布式锁,不容易。此外,并发队列(如Java的BlockingQueue)也可解决该问题。多个线程同时往并发队列写日志,一个单独的线程负责将并发队列的数据,写入到日志文件。也稍微复杂。

相较来说,单例模式就简单多了。相对于类级别的锁的好处:不用创建那么多Logger对象,一方面节省内存空间,另一方面节省文件句柄(对OS说,文件句柄也是资源,不能随便浪费)。

将Logger设计为单例类,程序中只允许创建一个Logger对象,所有的线程共享使用的这个Logger对象,共享一个FileWriter对象。而FileWriter本身就是对象级别的线程安全的,避免多线程下写日志互相覆盖。

重新设计后:

public class Logger {
    private FileWriter writer;
    private static final Logger instance = new Logger();
    
    public Logger(){
        File file = new File("xxx/xx/log.txt");
        writer = new FileWriter(file,true);//true表示追加写入
    }
    
    public static Logger getInstance(){
        return instance;
    }
    
    public void log(String message){
        writer.write(message);
    }
}

// logger类的应用示例:
public class UserController {    
    public void login(String username,String password){
        //...省略业务逻辑代码...
        Logger.getInstance().log(username+" logined!");
    }
}
实战案例2:表示全局唯一类

从业务上说,如果有些数据在系统中只应保存一份,比较适合设计为单例类,如配置信息类。在系统中,只有一个配置文件,被加载到内存后,以对象的形式存在,理应只有一份。还有,唯一递增ID号码生成器,如果程序有两个对象,会存在生成重复ID的情况,应设计为单例。

public class IdGenerator {
    //AtomicLong是一个java并发库提供的原子变量类型
    //将一些线程不安全需要加锁的复合操作封装为线程安全的原子操作,如下面的incrementAndGet()
    private AtomicLong id = new AtomicLong(0);
    private static final IdGenerator instance = new IdGenerator();
    
    private IdGenerator(){}
    public static IdGenerator getInstance(){
        return instance;
    }
    public long getId(){
        return id.incrementAndGet();
    }
}

//使用举例
long id = IdGenerator.getInstance().getId();

当然,这两个代码实例Logger、IdGenerator设计的都并不优雅,如何改造留到之后处理。

3. 如何实现单例

有几种简单的经典方式,要实现一个单例,需要关注的无非几个方面:

  • 构造函数是要private权限的,才能避免外部new创建实例
  • 考虑对象创建时的线程安全问题
  • 考虑是否支持延迟加载
  • 考虑getInstance()性能是否高(是否加锁)
1. 饿汉式

较为简单,类加载时,instance静态实例就已经创建并初始化好了,instance实例创建过程是线程安全的。不过不支持延迟加载。

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static final IdGenerator instance = new IdGenerator();
    private IdGenerator(){}
    public static IdGenerator getInstance(){
        return instance;
    }
    public long getId(){
        return id.incrementAndGet();
    }
}

饿汉式初始化耗时长,采用饿汉式,将耗时的初始化操作,提前到程序启动时完成,避免在程序运行时,再去初始化导致性能问题。

如果实例占用资源多,按照fail-fast的设计原则,也希望在程序启动时,就将实例初始化好。如果资源不够,尽早报错,可以立即去修复。

2. 懒汉式

优势是支持延迟加载

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static IdGenerator instance;
    private IdGenerator(){}
    public static synchronized IdGenerator getInstance(){
        if (instance == null){
            instance = new IdGenerator();
        }
        return instance;
    }
    public long getId(){
        return id.incrementAndGet();
    }
}

缺点是给getInstance()加了锁,导致并发度很低。如果该单例被频繁使用,会导致频繁的加锁、释放锁,不可取。

3. 双重检测

既支持延迟加载,又支持高并发。只要instance被创建后,即使再调用getInstance()方法也不会再进入加锁逻辑。

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static IdGenerator instance;
    private IdGenerator(){}
    public static synchronized IdGenerator getInstance(){
        if (instance == null){
            synchronized (IdGenerator.class){
                if (instance == null){
                    instance = new IdGenerator();
                }
            }
        }
        return instance;
    }
    public long getId(){
        return id.incrementAndGet();
    }
}

这种实现方式有些问题,因为指令重排,可能导致IdGenerator对象被new出来,并赋值给instance后,还没来得及初始化(执行构造函数的代码逻辑),就被另一个线程使用。

解决这个问题,需要给instance成员变量加上volatile关键字,禁止指令重排。当然,只有低版本的java才有该问题,高版本的java已经在jdk内部解决该问题。

4. 静态内部类

比双检锁更简单的方法,利用java的静态内部类。

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private IdGenerator(){}
    private static class SingletonHolder{
        private static final IdGenerator instance = new IdGenerator();
    }
    public static IdGenerator getInstance(){
        return SingletonHolder.instance;
    }
    public long getId(){
        return id.incrementAndGet();
    }
}

SingletonHolder是个静态内部类,当外部类IdGenerator被加载时,并不会创建SingletonHolder实例对象。只有调用getInstance()时,SingletonHolder才会被加载,这时才会创建instance。instance的唯一性、创建过程的线程安全型都有jvm保证。既保证线程安全,又能延迟加载。

5. 枚举

最简单,就是枚举,保证实例创建的线程安性和实例的唯一性。

public enum IdGenerator {
    INSTANCE;
    private AtomicLong id = new AtomicLong(0);
    public long getId(){
        return id.incrementAndGet();
    }
}

4. 单例存在的问题

1. 对OOP特性的支持不友好

OOP的四大特性:封装、抽象、继承和多态。单例对于抽象、继承和多态的支持都不好。

IdGenerator的使用方式违背了基于接口而非实现的设计原则。也就违背了广义的OOP的抽象特性。如果之后我们希望对不同的业务采用不同的ID生成算法。如订单ID和用户ID采用不同的ID生成器来生成。为应对这个需求变化,需要修改所有用到IdGenerator的地方,改动很大。

此外,对于继承、多态支持也不友好。单例类理论上可以被继承、实现多态,但实现很奇怪,导致代码可读性变差。因此,如果选择将某个类设计为单例类,意味着放弃继承和多态,损失了应对未来需求变化的扩展性。

2. 单例会隐藏类之间的依赖关系

代码可读性非常重要,通过构造方法、参数传递等方式声明的类之间的依赖关系,通过查看方法的定义,很容易识别。但是,单例类不需要显式创建、不需要依赖参数传递,在方法中直接调用即可。如果代码复杂,调用关系非常隐蔽。

3. 单例对代码的扩展性不友好

单例只有一个对象实例,如果有天想要创建两个实例或多个实例,对代码有较大的改动。

可能会说,会有这种需求吗?既然大部分情况下都用来表示全局类,怎么会需要两个或多个实例呢?

实际需求并不少见,以数据库连接池为例。设计初期,觉得应该只有一个数据库连接池,方便控制对数据库连接资源的消耗。设计为单例类。之后发现有些SQL语句运行很慢,执行时长期占用连接资源,导致其他事情了请求无法响应。希望将慢SQL和其他SQL隔离开,需要创建两个数据库连接池,避免慢SQL影响其他SQL的执行。

如果设计为单例类,显然无法适应这样的需求变更。所以,数据库连接池、线程池这类资源池,最好不要设计为单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计为单例类。

4. 单例对代码的可测试性不友好

如果单例类依赖比较重的外部资源,如DB,写单元测试时,希望mock的方式替换,但单例这种硬编码的使用方式,无法mock替换。

此外,如果单例类持有成员变量(如IdGenerator的id成员变量),实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是个可变全局变量,也就是说,它的成员变量可被修改,编写单元测试时,还要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,导致测试结果相互影响的问题。

5. 单例不支持有参构造函数

如创建一个连接池的单例对象,没法通过参数指定连接池的大小。这种有哪些解决方案呢?

第一种:创建完实例后,再调用init()方法传递参数。需要注意,在使用这个单例类时,先调用init()方法,才能调用getInstance()方法,否则代码会抛异常。

public class Singleton {
    private static Singleton instance = null;
    private final int paramA;
    private final int paramB;
    
    private Singleton(int paramA,int paramB){
        this.paramA = paramA;
        this.paramB = paramB;
    }
    
    public static Singleton getInstance(){
        if (instance==null){
            throw new RuntimeException("Run init() first.");
        }
        return instance;
    }
    
    public synchronized static Singleton init(int paramA,int paramB){
        if (instance==null){
            throw new RuntimeException("Singleton has been created.");
        }
        instance = new Singleton(paramA,paramB);
        return instance;
    }
}

Singleton.init(10,50);//先init,再使用
Singleton singleton = Singleton.getInstance();

第二种:将参数放到getInstance()方法中

public class Singleton {
    private static Singleton instance = null;
    private final int paramA;
    private final int paramB;

    private Singleton(int paramA,int paramB){
        this.paramA = paramA;
        this.paramB = paramB;
    }

    public synchronized Singleton getInstance(int paramA,int paramB){
        if (instance==null){
            instance = new Singleton(paramA,paramB);
        }
        return instance;
    }
}

Singleton singleton = Singleton.getInstance(10,50);

不过,上面代码稍有点问题,如果如下执行两次getInstance()方法,获取到的singleton1和singleton2的paramA和paramB都是10和50,也就是第二次的参数没有起作用。

Singleton singleton = Singleton.getInstance(10,50);
Singleton singleton = Singleton.getInstance(20,30);

第三种:将参数放到另一个全局变量中,具体代码如下。Config是一个存储了paramA和paramB值的全局变量。里面的值既可以像下面的代码通过静态常量定义,也可从配置文件加载得到。这种方式最值得推荐。

public class Config {
    public static final int PARAM_A = 123;
    public static final int PARAM_B = 123;
}

public class Singleton {
    private static Singleton instance = null;
    private final int paramA;
    private final int paramB;
    
    private Singleton(){
        this.paramA = Config.PARAM_A;
        this.paramB = Config.PARAM_B;
    }

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

5. 有什么替代解决方案

为了表示全局唯一,除了使用单例,还可以使用静态方法实现。如

public class IdGenerator {
    private static AtomicLong id = new AtomicLong(0);
    public static long getId(){
        return id.incrementAndGet();
    }
}

//使用举例
long id = IdGenerator.getId();

不过静态方法这种实现,更不灵活,还有另外一种方法:

//1. 老的使用方式
public demoFunction(){
	//...
	long id = IdGenerator.getInstance().getId();
	//...
}

//2. 新的使用方式:注入依赖
public demoFunction(IdGenerator idGenerator){
	long id = idGenerator.getId();
}

//外部调用demoFunction()时,传入idGenerator
IdGenerator idGenerator = IdGenerator.getInstance();
demoFunction(idGenerator);

新的使用方式,将单例生成的对象,作为参数传递给方法(也可通过构造方法传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过对于单例的其他问题如OOP特性支持、扩展性、可测试性不好等问题,还是无法解决。

实际上,要解决这些问题,需要从根本上,寻找其他方式实现全局唯一类。实际上,类对象的全局唯一性可通过多种不同的方式来保证。既可以通过单例模式强制保证,也可通过工厂模式、IOC容器(如spring IOC容器)保证,还可通过程序员自己保证(自己编写代码时,保证不创建两个类对象)。类似于java的内存对象的释放由JVM负责,而C++由程序员自己负责一样。

6. 如何理解单例模式中的唯一性

单例的定义:一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫单例模式。

对象的唯一性的作用范围是什么呢?是线程还是进程唯一?答案是后者。

单例类中对象的唯一性的作用范围是进程内,在进程间不唯一。

7. 如何实现线程唯一的单例

什么是线程唯一的单例,线程唯一和进程唯一的区别

进程唯一指的是进程内唯一,进程间不唯一。线程唯一指的线程内唯一,线程间可以不唯一。而进程唯一还代表了线程内、线程间都唯一。

线程唯一的单例的代码实现很简单。通过一个HashMap存储对象,key是线程ID,value是对象。这样就能不同线程对应不同的对象,同一个线程只能对应一个对象。实际上,java语言本身提供了ThreadLocal工具类,可更轻松的实现线程唯一单例。ThreadLocal底层实现原理也是基于HashMap

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static final ConcurrentHashMap<Long,IdGenerator> instances = new ConcurrentHashMap<>();
    private IdGenerator(){}
    public static IdGenerator getInstance(){
        Long currentThreadId = Thread.currentThread().getId();
        instances.putIfAbsent(currentThreadId,new IdGenerator());
        return instances.get(currentThreadId);
    }
    public long getId(){
        return id.incrementAndGet();
    }
}

8. 如何实现集群环境下的单例

什么是集群唯一的单例

集群相当于多个进程构成的一个集合,集群唯一就相当于进程内唯一,进程间也唯一。不同的进程间共享同一个对象,不能创建同一个类的多个对象。

如果严格按照不同的进程间共享同一个对象来实现,那集群唯一的单例实现起来有点难度。

具体说,需要把这个单例对象序列化并存储到外部共享存储区(如文件),进程在使用该单例对象时,先从外部共享存储区将其读取到内存,并反序列化为对象,再使用,用完后再存储回外部共享存储区。

为保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象后,需要对对象加锁,避免其他进程再将其获取,用完后,显式的把对象从内存中删除,并释放对象的锁。

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static IdGenerator instance;
    private static SharedObjectStorage storage = FileSharedObjectStorage();
    private static DistributedLock lock = new DistributedLock();
    
    private IdGenerator(){}
    public synchronized static IdGenerator getInstance(){
        if (instance == null){
            lock.lock();
            instance = storage.load(IdGenerator.class);
        }
        return instance;
    }
    
    public synchronized void freeInstance(){
        storage.save(this,IdGenerator.class);
        instance = null;//释放对象
        lock.unlock();
    }
    
    public long getId(){
        return id.incrementAndGet();
    }
}

//使用举例
IdGenerator idGenerator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();

9.如何实现一个多例模式

多例,一个类可以创建多个对象,但是个数是有限制的,如智能创建3个对象,如下:

public class BackendServer {
    private long serverNo;
    private String serverAddress;
    private static final int SERVER_COUNT = 3;
    private static final Map<Long,BackendServer> serverInstances = new HashMap<>();
    
    static {
        serverInstances.put(1L,new BackendServer(1L,"192.134.11.111:8080"));
        serverInstances.put(2L,new BackendServer(2L,"192.134.11.112:8080"));
        serverInstances.put(3L,new BackendServer(3L,"192.134.11.113:8080"));
    }
    private BackendServer(long serverNo,String serverAddress){
        this.serverNo = serverNo;
        this.serverAddress = serverAddress;
    }
    public BackendServer getInstance(long serverNo){
        return serverInstances.get(serverNo);
    }
    public BackendServer getRandomInstance(){
        Random r = new Random();
        int no = r.nextInt(SERVER_COUNT)+1;
        return serverInstances.get(no);
    }
}

实际上,对于多例,还有一种理解:同一类型的只能创建一个对象,不同类型的可创建多个对象。这里的“类型”如何理解?

举例,在下面代码中,logger name就是刚才说的类型,同一个logger name获取到的对象实例是相同的,不同的logger name获取到的对象实例是不同的。

public class Logger {
    private static final ConcurrentHashMap<String,Logger> instances = new ConcurrentHashMap<>();
    private Logger(){}
    public static Logger getInstance(String loggerName){
        instances.putIfAbsent(loggerName,new Logger());
        return instances.get(loggerName);
    }
    public void log(){
        //...
    }
}

// l1==l2 l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");

这种多例模式的理解方式类似工厂模式。不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的对象是不同子类的对象。此外,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可创建多个对象。

对于java语言,单例类对象的唯一性的作用范围不是进程,而是类加载器class loader。为什么?

java中两个类来源于同一个class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。

单例类对象的唯一性前提是必须保证该类被同一个类加载器加载。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值