创建型设计模式

第二篇章 创建型设计模式

GoF 是 “Gang of Four”(四人帮)的简称,它们是指 4 位著名的计算机科学家:Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides。他们合作编写了一本非常著名的关于设计模式的书籍《Design Patterns: Elements of Reusable Object-Oriented Software》(设计模式:可复用的面向对象软件元素)。这本书在软件开发领域具有里程碑式的地位,对面向对象设计产生了深远影响。

GoF 提出了 23 种设计模式,将它们分为三大类:

  1. 创建型模式(Creational Patterns):这类模式主要关注对象的创建过程。它们分别是:
  • 单例模式(Singleton)

  • 工厂方法模式(Factory Method)

  • 抽象工厂模式(Abstract Factory)

  • 建造者模式(Builder)

  • 原型模式(Prototype)

  1. 结构型模式(Structural Patterns):这类模式主要关注类和对象之间的组合。它们分别是:
  • 适配器模式(Adapter)

  • 桥接模式(Bridge)

  • 组合模式(Composite)

  • 装饰模式(Decorator)

  • 外观模式(Facade)

  • 享元模式(Flyweight)

  • 代理模式(Proxy)

  1. 行为型模式(Behavioral Patterns):这类模式主要关注对象之间的通信。它们分别是:
  • 职责链模式(Chain of Responsibility)

  • 命令模式(Command)

  • 解释器模式(Interpreter)

  • 迭代器模式(Iterator)

  • 中介者模式(Mediator)

  • 备忘录模式(Memento)

  • 观察者模式(Observer)

  • 状态模式(State)

  • 策略模式(Strategy)

  • 模板方法模式(Template Method)

  • 访问者模式(Visitor)

这些设计模式为面向对象软件设计提供了一套可复用的解决方案。掌握和理解这些模式有助于提高软件开发人员的编程技巧和设计能力。

第一章 单例设计模式

单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

一、为什么要使用单例

1、表示全局唯一

如果有些数据在系统中应该且只能保存一份,那就应该设计为单例类。如:

  • 配置类:在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,应该被映射为一个唯一的【配置实例】,此时就可以使用单例,当然也可以不用。

  • 全局计数器:我们使用一个全局的计数器进行数据统计、生成全局递增ID等功能。若计数器不唯一,很有可能产生统计无效,ID重复等。

public class GlobalCounter {
    private AtomicLong atomicLong = new AtomicLong(0);
    private static final GlobalCounter instance = new GlobalCounter();
    // 私有化无参构造器
    private GlobalCounter() {}
    public static GlobalCounter getInstance() {
        return instance;
    }
    public long getId() { 
        return atomicLong.incrementAndGet();
    }
}
// 查看当前的统计数量
long courrentNumber = GlobalCounter.getInstance().getId();

以上代码也可以实现全局ID生成器的代码。

2、处理资源访问冲突

如果让我们设计一个日志输出的功能,你不要跟我杠,即使市面存在很多的日志框架,我们也要自己设计。

如下,我们写了简单的小例子:

public class Logger {
    private String basePath = "D://info.log";

    private FileWriter writer;
    public Logger() {
        File file = new File(basePath);
        try {
            writer = new FileWriter(file, true); //true表示追加写入
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void log(String message) {
        try {
            writer.write(message);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void setBasePath(String basePath) {
        this.basePath = basePath;
    }
}

当然,任何的设计都不是拍脑门,这是我们写的v1版本,他很可能会存在很多的bug,设计结束之后,我们可能是这样使用的:

@RestController("user")
public class UserController {

    public Result login(){
        // 登录成功
        Logger logger = new Logger();
        logger.log("tom logged in successfully.");
        
        // ...
        return new Result();
    }
}

当然,其他千千万的代码,我们都是这样写的。这样就会产生如下的问题:多个logger实例,在多个线程中,同时操作同一个文件,就可能产生相互覆盖的问题。因为tomcat处理每一个请求都会使用一个新的线程(暂且不考虑多路复用)。此时日志文件就成了一个共享资源,但凡是多线程访问共享资源,我们都要考虑并发修改产生的问题。

有的同学可能想到如下的解决方案,加锁呀,代码如下:

public synchronized void log(String message) {
    try {
        writer.write(message);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

事实上这样加锁毫无卵用,方法级别的锁可以保证new出来的同一个实例多线程下可以同步执行log方法,然而你却new了很多:

img

其实,writer方法本身也是加了锁的,我们这样加锁就没有了意义:

public void write(String str, int off, int len) throws IOException {
    synchronized (lock) {
        char cbuf[];
        if (len <= WRITE_BUFFER_SIZE) {
            if (writeBuffer == null) {
                writeBuffer = new char[WRITE_BUFFER_SIZE];
            }
            cbuf = writeBuffer;
        } else {    // Don't permanently allocate very large buffers.
            cbuf = new char[len];
        }
        str.getChars(off, (off + len), cbuf, 0);
        write(cbuf, 0, len);
    }
}

当然,加锁是一定能解决共享资源冲突问题的,我们只要放大锁的范围从【this】到【class】,这个问题也是能解决的,代码如下:

img

public void log(String message) {
    synchronized (Logger.class) {
        try {
            writer.write(message);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

从以上的内容我们也发现了:

  • 如果使用单个实例输出日志,锁【this】即可。

  • 如果要保证JVM级别防止日志文件访问冲突,锁【class】即可。

  • 如果要保证集群服务级别的防止日志文件访问冲突,加分布式锁即可。

如果我们是一个简单工程,对日志输入要求不高。单例模式的解决思路就十分合适,既然同一个Logger无法并行输出到一个文件中,那么针对这个日志文件创建多个Logger实例也就失去了意义,如果工程要求我们所有的日志输出到同一个日志文件中,这样其实并不需要创建大量的Logger实例,这样的好处有:

  • 一方面节省内存空间。

  • 另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)。

按照这个设计思路,我们实现了 Logger 单例类。具体代码如下所示:

public class Logger {
    private String basePath = "D://log/";
    private static Logger instance = new Logger();
    private FileWriter writer;

    private Logger() {
        File file = new File(basePath);
        try {
            writer = new FileWriter(file, true); //true表示追加写入
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static Logger getInstance(){
        return instance;
    }

    public void log(String message) {
        try {
            writer.write(message);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public void setBasePath(String basePath) {
        this.basePath = basePath;
    }
}

除此之外,并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据写入到日志文件。这种方式实现起来也稍微有点复杂。当然,我们还可将其延伸至消息队列处理分布式系统的日志

二、如何实现一个单例

常见的单例设计模式,有如下五种写法,在编写单例代码的时候要注意以下几点:

1、构造器需要私有化

2、暴露一个公共的获取单例对象的接口

3、是否支持懒加载(延迟加载)

4、是否线程安全

1、饿汉式

饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。从名字中我们也可以看出这一点。具体的代码实现如下所示:

public class EagerSingleton {  
    private static Singleton instance = new Singleton();  
    private Singleton (){}  
    public static Singleton getInstance() {  
        return instance;  
    }  
}

事实上,恶汉式的写法在工作中反而应该被提倡,面试中不问,只是应为他简单。很多人觉得饿汉式不能支持懒加载,即使不使用也会浪费资源,一方面是内存资源,一方面会增加初始化的开销。

1、现代计算机不缺这一个对象的内存。

2、如果一个实例初始化的过程复杂那更加应该放在启动时处理,避免卡顿或者构造问题发生在运行时。满足 fail-fast 的设计原则。

2、懒汉式

有饿汉式,对应地,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载,具体的代码实现如下所示:

public class LazySingleton {  
    private static Singleton instance;  
    private Singleton (){}  

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

以上的写法本质上是有问题,当面对大量并发请求时,其实是无法保证其单例的特点的,很有可能会有超过一个线程同时执行了new Singleton();

当然解决他的方案也很简单,加锁呗:

public class Singleton {  
    private static Singleton instance;  
    private Singleton (){}  

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