设计模式(一)—— 单例

161 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第21天,点击查看活动详情

概念

单例模式(Singleton)是一种非常简单且容易理解的设计模式。

顾名思义,单例即单一的实例,确切地讲就是指在某个系统中只存在一个实例,同时提供集中、统一的访问接口。

实现

太阳是太阳系内唯一的恒星实例,那么本文我们就以太阳为例来介绍单例。

饿汉模式

1. 构造类

首先我们先构造一个太阳的类。

public class Sun {
}

太阳类 Sun 中目前什么都没有。接下来我们得确保任何人都不能创建太阳的实例,否则一旦有同学调用 new Sun(),那么程序中就会存在多个 Sun 实例了。

这里有同学可能会疑惑,我们并没有实现构造器,为什么 Sun 类还是会被实例化呢?原始是 Java 会自动为类加上一个无参构造器。

2. 构造方法私有化

为了防止太阳实例泛滥,我们必须禁止外部调用构造器,即我们要实现构造方法私有化,代码如下所示:

public class Sun {
    private Sun() {// 构造方法私有化
    }
}

在上述代码中,我们将太阳类 Sun 的构造方法设为 private,使其私有化。这样太阳类的实例化工作完全属于内部事务,不会被任何外部类创建。既然如此,Sun 类的实例化就交由我们自己了。

3. 永久实例

public class Sun {

    private static final Sun sun = new Sun(); // 自有永久的单例
    
    private Sun() {// 构造方法私有化
    }
}
  • private 关键字确保太阳实例的私有性、不可见性和不可访问性;
  • static 关键字确保太阳的静态性,将太阳放入内存里的静态区,在类加载的时候就初始化了,它与类同在,也就是说它是与类同时期且早于内存堆中的对象实例化的,该实例在内存中永生,内存垃圾收集器(Garbage Collector,GC)也不会对其进行回收;
  • final 键字则确保这个太阳是常量,引用一旦被赋值就不能再修改;
  • new 关键字初始化太阳类的静态实例,并赋予静态常量 sun。

4. 外部访问

单例的太阳对象写好了,可一切皆是私有的,外部怎样才能访问它呢?因此我们需要一个静态方法 getInstance() 来获取太阳的单例对象,同时将其设置为 public 以暴露给外部使用,

public class Sun {

    private static final Sun sun = new Sun(); // 自有永久的单例
    
    private Sun() {// 构造方法私有化
    }
    
    public static Sun getInstance() { // 公开实例
        return sun;
    }
}

如此一来,太阳类实例就算完成了。对外部来说只要调用 Sun.getInstance() 就可以得到太阳对象了,并且不管谁得到,或是得到几次,得到的都是同一个太阳实例。

懒汉模式

1. 单线程

上面我们已经学会了单例模式的“恶汉模式”。让太阳类一开始就准备就绪,然而如果始终没有人获取太阳类的话,那么岂不是白白实例化太阳类,浪费一个内存了。

这个时候就可以借用懒汉模式来构造单例,即在需要的时候构造。

public class Sun {

    private static final Sun sun = new Sun(); // 自有永久的单例
    
    private Sun() {// 构造方法私有化
    }
    
    public static Sun getInstance() { // 公开实例
        if (sun == null) { // 没有才构造
            sun = new Sun();
        }
        return sun;
    }
}

这样的好处是如无请求就不实例化,节省了内存空间;而坏处是第一次请求的时候速度较之前的饿汉初始化模式慢,因为要消耗 CPU 资源去临时造这个太阳(即使速度快到可以忽略不计)。

2. 多线程

上面的程序逻辑看似没问题,但其实在多线程模式下是有缺陷的。试想如果是并发请求的话,if (sun == null) 的判空逻辑就会同时成立,这样就会多次实例化太阳,并且对 sun 进行多次赋值(覆盖)操作,这违背了单例的理念。

那我们再来改良一下,把请求方法加上 synchronized(同步锁)让其同步,如此一来,某线程调用前必须获取同步锁,调用完后会释放锁给其他线程用,也就是给请求排队,一个接一个按顺序来.

public class Sun {

    private static final Sun sun = new Sun(); // 自有永久的单例
    
    private Sun() {// 构造方法私有化
    }
    
    public static Synchronized Sun getInstance() { // 加入同步锁
        if (sun == null) { // 没有才构造
            sun = new Sun();
        }
        return sun;
    }
}

这样我们就可以避免多线程问题,但同时也会引入新的问题。即资源浪费。

当线程调用 Sun 方法时,还没有进入方法内部,就一直加锁排队,这样就会造成线程阻塞,资源与时间被白白浪费。因此我们需要保证多线程并发下逻辑的正确性,Synchronized 关键字加的位置要合理。其代码如下:

public class Sun {

    private volatile static final Sun sun;
    
    private Sun() {// 构造方法私有化
    }
    
    public static Sun getInstance() {
        Synchronized(Sun.class){
            if (sun == null) { // 没有才构造 
                sun = new Sun();
            }
        }
        return sun;
    }
}

我们来看一下上面的代码:

  1. 首先 sun 变量不在是常量,而是需要后续赋值的变量;
  2. 关键字 volatile 对静态变量的修饰则能保证变量值在各线程访问时的同步性、唯一性。
  3. 我们去掉了方法上的关键字 synchronized,使大家都可以同时进入方法并对其进行开发。
  4. 同步块保证我们每次只有一个线程可以进入。

推荐用法

相比“懒汉模式”,其实在大多数情况下我们通常会更多地使用“饿汉模式”,原因在于这个单例迟早是要被实例化占用内存的,延迟懒加载的意义并不大,加锁解锁反而是一种资源浪费,同步更是会降低 CPU 的利用率,使用不当的话反而会带来不必要的风险。越简单的包容性越强,而越复杂的反而越容易出错。

参考文档

  • 《秒懂设计模式》—— 刘韬