单例模式

单例模式

为什么要使用单例?

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

实战案例一:处理资源访问冲突

文中样例

  • 一个往文件中打印日志的 Logger 类。
  • 构造方法中,打开日志文件 log.txt,将写入文件句柄赋值给属性 writer。
  • log() 方法中调用 writer.write() 写入日志内容。
  • 使用此 Logger 类时,首先实例化 new Logger(),然后调用log() 方法写入日志。

样例中问题及解决方案

  • Logger 类中的日志都写到同一个文件 log.txt 中,在多线程环境下,当 Logger 类被多处调用者实例化后,大家同时写 log.txt,可能会存在互相覆盖。
  • 首先我们想到的是加锁方案,在 log() 方法中调用 writer.write() 前加入一个对象锁。但是在不同线程下,不同对象调用并不会共享一把锁,问题依然存在。

进一步解决方案

  • 换成类级别锁,让所有对象都共享一把锁。 具体方案,在 log() 方法中调用 writer.write() 前加入一个类锁。
  • 分布式锁也是一种解决方案,不过,实现一个安全可靠、无bug、高性能的分布式锁,并不是件容易的事情。
  • 并发队列解决方案,多个线程同时往并发队列写日志,一个单独线程将并发队列中数据写入日志文件,也是一个稍复杂的方案。

单例模式解决方案

  • 增加 getInstance() 方法,此方法中返回属性 instance。
  • 而 private static final Logger instance = new Logger()。
  • 调用的时候直接 Logger.getInstance().log(),这样 Logger 类就只会被实例化一次。
  • 好处是可以不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄。

单例 Logger 类设计:

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;

public class Logger {
    private FileWriter writer;
    private static final Logger instance = new Logger();
    private Logger() {
        File file = new File("log.txt");
        try {
            writer = new FileWriter(file, true);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static Logger getInstance() {
        return instance;
    }
    public void log(String message) {
        try {
            synchronized(Logger.this) {
                writer.write(message);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Logger 类的使用:

public class OrderController {
    public void create(String ordername) {
        Logger.getInstance().log(ordername);
    }
}

public class UserController {
    public void login(String username, String password) {
        Logger.getInstance().log(username + " logined!");
    }
}

实战案例二:表示全局唯一类

  • 业务场景:
    • 有些数据在系统中只应保存一份,比如配置信息类。
    • 唯一递增ID号码生成器,不能生成重复的ID。
  • 文中样例:
    • IdGenerator 类中 getInstance() 方法返回 instance。
    • 而 private static final IdGenerator instance = new IdGenerator()。
    • 获取 id 方法 getId() 中,返回 id.incrementAndGet()。
    • 使用的时候,直接 IdGenerator.getInstance().getId()。

如何实现一个单例?

实现一个单例关注点:

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

饿汉式

  • 加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。这样的实现方式不支持延迟加载。
  • 有的人认为,提前初始化实例是一种浪费资源的行为,最好的方法应该在用到的时候再去初始化。理由是实例占用资源多或者初始化耗时长:但是如果初始化耗时长,那我们更不能等到要用它的时候再初始化,会影响到系统的性能。如果实例占用资源多,最好在程序启动的时候就能触发报错。
  • 前文的例子都是饿汉式。

饿汉式实现:

import java.util.concurrent.atomic.AtomicLong;

public class eagerIdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static final eagerIdGenerator instance = new eagerIdGenerator();
    private eagerIdGenerator() {}
    public static eagerIdGenerator getInstance() {
        return instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }

}

懒汉式

  • 懒汉式相对于饿汉式的优势是支持延迟加载。
  • 以前文中的 IdGenerator 类举例,在 getInstance() 方法中才会 new IdGenerator() 赋值给属性 instance。
  • 不过这样的缺点显而易见,我们给 getInstance() 方法加了锁,导致并发度很低,相当于串行。如果这个单例类会被频繁用到,那么频繁加锁、释放锁会降低并发,导致性能瓶颈。

懒汉式实现:

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

双重检测

  • 在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中。即支持延迟加载,又解决并发度低的问题。
  • 我们在 getInstance() 方法中,new IdGenerator() 前加入类级别锁。

双重检测实现:

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

静态内部类

  • 一种比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。
  • 在 IdGenerator 类内部创建一个静态内部类 SingletonHold,在此静态内部类里面添加属性 private static final IdGenerator instance = new IdGenerator()。
  • 当类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。

静态内部类实现:

import java.util.concurrent.atomic.AtomicLong;

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

枚举

  • 一种最简单的实现方式,这种实现方式通过 Java 枚举类型本身的特性,基于枚举类型的单例实现。
  • 在 IdGenerator 类中定义枚举类型 INSTANCE。

枚举实现:

import java.util.concurrent.atomic.AtomicLong;

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

单例存在哪些问题?

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

  • 单例这种设计模式对于OOP四大特性中的抽象、继承、多态都支持得不好。
  • 通过 IdGenerator 这个例子来讲解,生成 ID 的调用处这样写 long id = IdGenerator.getInstance().getId()。如果我们希望针对不同的业务采用不同的 ID 生成算法,我们需要修改所有用到 IdGenerator 类的地方,比如修改成 long id = UserIdGenerator.getIntance().getId(),这样代码的改动就会比较大。
  • 单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。

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

  • 单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

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

  • 单例类只能有一个对象实例,如果我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。
  • 你可能会觉得不会有这样的需求,下面以数据库连接池来举例解释一下。在系统设计初期,我们觉得系统中只应该有一个数据库连接,但之后我们发现,系统中有些 SQL 语句运行得非常慢,我们希望将慢 SQL 与其他 SQL 隔离开来执行。如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。

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

  • 如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。
  • 单例类持有成员变量(比如 IdGenerator中 的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。

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

  • 单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小:
    • 第一种解决思路是:创建完实例之后,先调用 init() 函数传递参数,再调用 getInstance() 方法。
    • 第二种解决思路是:将参数放到 getIntance() 方法中,比如 Singleton singleton = Singleton.getInstance(10,50)。这里有个问题,因为代码中对 instance == null 有判断,那么第一次实例化后,第二次传入的参数是不起作用的。
    • 第三种解决思路是:将参数放到另外一个全局变量中。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。

有何替代解决方案?

  • 为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。
  • 单例除了我们之前讲到的使用方法之外,还有另外一种依赖注入的使用方法。比如 IdGenerator idGenerator = IdGenerator.getInsance();demofunction(idGenerator)。基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。
  • 不过,如果要完全解决OOP特性、扩展性、可测性不友好等问题,我们可能要从根上,寻找其他方式来实现全局唯一类。以通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。

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

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

如何实现线程唯一的单例?

  • “进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。
  • 假设 IdGenerator 是一个线程唯一的单例类。在线程A内,我们可以创建一个单例对象 a。因为线程内唯一,在线程 A 内就不能再创建新的 IdGenerator 对象了,而线程间可以不唯一,所以,在另外一个线程 B 内,我们还可以重新创建一个新的单例对象 b。
  • 代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。

线程唯一单例模式实现:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

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

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

  • 集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
  • 我们需要把这个单例对象序列化并存储到外部共享储区(比如文件)。为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。

如何实现一个多例模式?

  • “多例”指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建3个对象。
  • 对于多例模式,还有一种理解方式:同一类型的只能创建一个对象,不同类型的可以创建多个对象。
  • 在代码中,logger name 就是刚刚说的“类型”,同一个 logger name 获取到的对象实例是相同的,不同的 logger name 获取到的对象实例是不同的。这种多例模式的理解方式有点类似工厂模式,它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象。

多例模式实现:

import java.util.HashMap;
import java.util.Map;
import java.util.Random;

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.168.01.1:8080"));
        serverInstances.put(2L, new BackendServer(2L, "192.168.01.2:8080"));
        serverInstances.put(3L, new BackendServer(3L, "192.168.01.3:8080"));
    }

    private BackendServer(long serverNo, String serverAddress) {
        this.serverAddress = serverAddress;
        this.serverNo = serverNo;
    }

    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);
    }
}
posted @ 2021-10-12 22:09  起床睡觉  阅读(57)  评论(0编辑  收藏  举报