0%

Design Pattern

看待事物都可以按照这个顺序:发现问题、分析问题、理顺逻辑、寻求证据、解决问题

设计模式相关内容介绍,尤其着重Java的单例模拟,看完必有收获。

常见的设计模式有哪些?

设计模式分为 3 大类型共 23 种:

  1. 创建型:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式。

  2. 结构型:适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式。

  3. 行为型:策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。

最常见的设计模式有:单例模式、工厂模式、代理模式、构造者模式、责任链模式、适配器模式、观察者模式等。

如何用java写一个单例模式?

参考文章:你确定,你真的理解了单例模式么?

只能生成一个实例的类是实现了Singleton(单例)模式的类。由于设计模式在面向对象编程中起到了举足轻重的作用,所以在面试中很多公司都会问。而在常用的设计模式中,Singleton是唯一一个能够用短短几十行代码完整实现的设计模式,所以写一个Singleton是一个很常见的面试题

单例模式虽然看起来简单,但是如果往深了挖,又可以考察出候选者对于并发、类加载、序列化等知识点的掌握。

什么是单例模式?

如上面所说,只能生成一个实例的类是实现了Singleton(单例模式)的类,也就是一个单例模式的类只有一个实例,并且提供一个全局可以访问的入口(比如getInstance()方法)。比如《火影忍者》中漩涡鸣人特别喜欢用的影分身之术,实际上,每一个影分身都只对应着一个真身。

为什么要有只有一个实例的这种类?我们为什么需要它?

理由一:为了节省内存、节省计算。很多情况下,我们只需要一个实例,如果出现了更多实例,反而是浪费。

举例:

1
2
3
4
5
6
7
public class ExpensiveResource {
public ExpensiveResource() {
field1 = // 查询数据库
field2 = // 对查到的数据做大量计算
field3 = // 加密、压缩等耗时操作
}
}

这个类在构造的时候,需要查询数据库,对查到的数据做大量的计算,然后还要进行加密、压缩等非常耗时的操作。所以在第一次构造这个类的时候,我们就要花费很多时间来初始化这个对象。

假设数据库在一段时间不变,那么我们其实只需要使用这一个实例完成任务即可。如果每次都重新生成新的实例,浪费资源,十分没有必要。

理由二:为了保证结果的正确。比如我们需要一个全局计数器用来统计人数。如果有多个实例,反而会造成混乱。

理由三:方便管理。很多工具类,我们只需要一个实例,通过一个统一的入口,获取这个单例。太多实例不但没有帮助,只会让人眼花缭乱。

单例模式使用场景?

  1. 无状态的工具类:日志工具、字符串工具等。——日志工具,不论在哪里使用,我们只需要它帮我们记录日志信息,除此功能之外并不需要在它的实例对象上存储任何状态,所以我们只需要一个实例对象就可以了。

  2. 全局信息类:全局计数、环境变量。——比如我们要记录某个网站的访问次数,而且不希望有的访问记录被记录在对象A上,而有的被记录在对象B上。此时我们就可以让这个类为单例,在需要计数的时候拿出来用就可以了。

单例模式常见写法,这里列举五种:饿汉式、懒汉式、双重检查式、静态内部类式、枚举式

单例模式的实现

下面按照写法的难易程度逐层递进:
相对简单的饿汉式:

1
2
3
4
5
6
7
8
9
10
11
// 饿汉式写法
public class Singleton {

private static Singleton singleton = new Singleton();

private Singleton() {}

public static Singleton getInstance() {
return singleton;
}
}

类的第一行,用static修饰实例,并且把构造函数用private修饰。

注:static关键字本身很重要的一个用途就是实现单例模式。单例模式特点是只能有一个实例,为了实现这一功能,必须隐藏类的构造函数,即把构造函数声明为private,并提供一个创建对象的方法。由于构造对象被声明为private,所以外界无法直接创建这个对象,只能通过该类提供的方法来获取类的对象,要达到这样的目的只能把创建对象的方法声明为static.

饿汉式优点:这种写法比较简单,在类装载的时候就完成了实例化,避免了线程同步的问题。

饿汉式缺点:类装载的时候就完成了实例化,没有达到懒加载的效果,这点是最大缺陷。所以如果自始至终都没使用过这个实例,就可能会造成内存的浪费。

饿汉式写法的变种:静态代码块形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 饿汉式另一种写法,静态代码块
public class Singleton {
private static Singleton singleton;

static {
singleton = new Singleton();
}

private Singleton() {}

public static Singleton getInstance() {
return singleton;
}
}

这种写法和最开始的饿汉式的方式类似,只是把类实例化的过程放在了静态代码块中。同样,在类装载的过程中会执行静态代码块中的代码,完成实例的初始化,所以静态代码块的优缺点和饿汉式是一样的。静态代码块写法就是饿汉式的写法。

接下来看第二种懒汉式

1
2
3
4
5
6
7
8
9
10
11
12
13
// 线程不安全的懒汉式写法
public class Singleton {
private static Singleton singleton;

private Singleton() {}

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

但是需要注意,上面这个懒汉式写法只能用于单线程。因为如果一个线程进入了 if(singleton == null) 判断语句块,还没来得及往下执行,另一个线程也通过了这个判断语句,此时会多次创建实例。所以这里需要注意,在多线程环境下,不能用上面这种懒汉式写法,它是错误的。

当然,懒汉式写法可以进行升级,让其成为线程安全的懒汉式写法。升级之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 线程安全的懒汉式写法
public class Singleton {
private static Singleton singleton;

private Singleton() {}

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

加上了synchronized关键字,为了解决刚才的线程安全问题。缺点是效率太低了,每个线程在想获得类的实例的时候,执行getInstance的时候都要进行同步,虽然保证了多个线程不能同时访问,但是这在大多数情况下是没有必要的。

为了解决这个问题,衍生出了双重检查模式。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 双重检查模式写法
public class Singleton {

private static volatile Singleton singleton;

private Singleton() {}

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

重点在getInstance方法,我们进行了两次 if(singleton == null) 的判断,这样可以保证线程安全了。可以想象,在第一次初始化了singleton对象之后,再次调用代码块的时候,第一个if判断就可以让执行跳过整个代码块,返回之前已经初始化过的singleton,避免破坏单例。

这种写法的优势是,不仅保证了线程安全,而且延迟加载,效率也更高。

这里会有一个面试经常会问到的问题:”为什么要有两个if判断呢?去掉第二个if,可以么?(或者说,为什么要double-check?去掉第二个check行不行呢?)”

结论是,不行。为什么不行?我们来考虑这样的情景:

有两个线程同时调用getInstance()方法,由于singleton是空的,所以两个线程都可以通过第一重if判断。然后由于锁机制的存在,会有一个线程进入到第二个判断语句,而另一个线程会在外等候。过了一小段时间,第一个线程完成了对singleton的创建操作,它会退出synchronized的保护区域。此时第二个线程会进入到运行区域。此时如果没有第二个if判断,那么第二个线程也会创建一个实例,这就破坏了单例,这肯定是不行的。

当然,第一个check也是不能去掉的。如果去掉了第一个check,那么所有线程都会串行执行,效率低下。所以,两个check都是需要保留的

此外,在双重检查模式中,我们给singleton加了 volatile 关键字。为什么要增加volatile呢?

原因在于, singleton = new Singleton(); 这句话不是一个原子操作。事实上,在JVM中,这句话至少做了3件事

  1. 给singleton分配内存空间
  2. 调用Singleton的构造函数等来初始化singleton
  3. 将singleton对象指向分配的内存空间(执行完这步,singleton就不是Null了)

但是这里需要注意1、2、3步骤的顺序。因为存在着重排序的优化。也就是说,第二步和第三步这两者的顺序是不能保证的

最终的执行顺序可能是1-2-3,也可能是1-3-2。如果是1-3-2,那么如果第一个线程正在创建的时候,另一个线程也进来了,那么在进行第一重判断的时候会直接跳过整个代码块,直接返回singleton对象。而此时因为singleton还没有被初始化,所以会有空指针报错。哪怕最后线程1最后的初始化了,但是只是姗姗来迟,程序已经报错了。

用图解详细分析一下:
lfTzJs.png

总结用volatile的原因:它可以防止上面重排序的发生,可以避免拿到未完成初始化的对象。

下面来看静态内部类的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 静态内部类写法
public class Singleton {

private Singleton() {}

private static class SingletonInstance {
private static final Singleton singleton = new Singleton();
}

public static Singleton getInstance() {
return SingletonInstance.singleton;
}

}

这种写法和饿汉式所采用的机制类似,都才用了类装载的机制:

1
2
3
private static class SingletonInstance {
private static final Singleton singleton = new Singleton();
}

以此保证我们初始化实例时只有一个线程,而且是JVM帮助我们保证了线程安全性。

但是,饿汉式有一个特点,就是只要Singleton这个类被加载了,就会实例化单例对象。而静态内部类方式在Singleton类被装载的时候,并不会立刻实例化,而是在需要实例的时候,也就是调用getInstance()方法:

1
2
3
public static Singleton getInstance() {
return SingletonInstance.singleton;
}

的时候,才会去完成对singleton实例的实例化。

静态内部类写法优点:

  1. 代码简洁,和双重检查的单例模式对比,静态内部类的单例实现代码更加简洁,清晰明了。
  2. 延迟初始化,调用getInstance()才会初始化Singleton对象。
  3. 线程安全。JVM在执行类的初始化阶段,会获得一个可以同步多个线程对同一个类的初始化锁。

在介绍枚举写法之前,做一个小总结:

静态内部类的写法与双重检查模式的优点是一样的,都避免了线程不安全的问题,并且延迟加载,效率高。

可以看出,静态内部类和双重检查都是不错的写法。但是这两种方法都有一个缺陷:不能防止被反序列化。

最后,枚举类的写法:

1
2
3
4
5
6
7
// 枚举类写法实现单例模式
public enum Singleton {
INSTANCE;
public void whatereverMethod() {

}
}

枚举类是在JDK1.5之后新增的方法。它不仅可以避免多线程同步的问题,而且还能防止反序列化和反射(这两种方法可以创建新的对象)破坏单例模式。

上面讲了五种方式,但是可以说,实现单例模式最好的方式,是利用枚举。这个观点其实是Josh Bloch的。他曾经在Effective Java中写道:”使用枚举实现单例的方法,虽然还没有被广泛采用,但是单元素的枚举类型应成为了实现Singleton的最佳方法。”

他如此推崇枚举,主要还是因为枚举写法的优点:

  1. 代码简洁。枚举的写法不需要我们去考虑懒加载或者线程安全等问题。同时,代码短小精悍,比其他任何写法都更加简洁。
  2. 线程安全有保障。通过反编译枚举类,我们可以发现枚举种的各个枚举项,都是通过static代码块来定义和初始化的。他们会在类被加载的时候完成初始化。而java类的加载由JVM保证线程安全。所以,创建一个Enum类型的枚举是线程安全的。
  3. 最重要的优点:防止破坏单例。java专门对枚举的序列化做了单独的规定。在序列化的时候,仅仅会将枚举对象的name属性输出到结果中。而在反序列化时,会通过 java.lang.Enum 的valueOf方法来根据名字查找对象,而不会新建一个新的对象。这就防止了反序列化导致的单例破坏问题的出现。而针对反射,枚举类同样有防御措施。反射在通过newInstance创建对象时,会检查这个类是否是枚举类。如果是,会抛出: IllegalArgumentException("Cannot reflecatively create enum objects") 这个异常,反射创建对象会失败。可以说,java针对枚举做的工作是非常全面的,枚举,是java亲生的。

可以看出,枚举这种方式可以防止反序列化和反射破坏单例,在这一点上与其他方式相比,优势巨大。安全问题不容小觑,一旦通过反序列化或者反射生成了多个实例,那么单例模式就彻底没用了。

总结:
lhCFII.png

需要注意,如果使用了线程不安全的写法,在并发的情况下可能产生多个实例,那么不仅会影响性能,更可能造成数据错误等严重的后果。

回答面试问题过程中,可以先从饿汉式、懒汉式说起,一步一步地分析每一种的优缺点,并且对写法进行演进。重点需要关注,双重检查模式为什么需要两次检查?为什么要是用volatile关键字?最后再说枚举类写法的优点和其背后的原理。

此外,在工作中,如果遇到了全局信息类、无状态工具类等场景,推荐使用枚举的写法实现单例模式。

备注(static、volatile)

static关键字用法

static关键字在Java中主要有四种定义的类型:成员变量、成员方法、代码块和内部类

成员变量

Java语言没有全局变量的概念,但是可以通过static达到全局变量的效果。

不同于普通的实例变量(new出来对象之后才定义的对象),用static修饰的变量为静态变量,只要静态变量所在的类被加载,这个静态变量就会被分配空间,后面每次只要使用这个变量,就是唯一的

静态变量只有一个,被类拥有,所有对象都共享这个静态变量,而实例对象是与具体对象相关的。需要注意的是,与C++语言不同的是,在Java语言中,不能在方法中定义static变量。

成员方法

static方法是类的方法,不需要创建对象就可以被调用,而非static方法是对象的方法,只有对象被创建出来后才可以被使用。

static方法中不能使用this和super关键字,不能调用非static方法,只能访问所属类的静态成员变量和成员方法,因为当static方法被调用时,这个类的对象可能还没被创建,即使已经被创建了,也无法确定调用哪个对象的方法。同理,static方法也不能访问非static类型的变量。

static代码块

static代码块,即静态代码块,在类中是独立于成员变量和成员函数的代码块的。注意它不会被定义在任何一个方法体内,JVM在加载类时会执行static代码块,如果有多个static代码块,JVM会按顺序执行。static代码块经常被用于初始化静态变量,而且只会被加载一次。

static内部类

static内部类指被声明为static的内部类,他可以不依赖于外部实例对象而被实例化,而通常的内部类需要在外部类实例化后才能实例化。静态内部类不能与外部类有相同的名字,不能访问外部类的普通成员变量,只能访问外部类中的静态成员和静态方法(包括私有类型)。

static和final组合

被final修饰的变量,都是指不能被修改的。

在Java中,static关键字经常和final结合使用,用来修饰成员变量和成员方法,修饰之后的变量类似C/C++中的全局变量。

  • 对于变量,若使用static final修饰,则表示一旦赋值,就不可以修改,并且通过类名可以访问。
  • 对于方法,若使用static final修饰,则表示该方法不可覆盖并且可以通过类名直接访问。

在Java中,不能在成员函数内部定义static变量

volatile有什么用

用Java语言编写的程序中,有时为了提高程序的运行效率,编译器会自动对其进行优化,把经常访问的变量缓存起来,程序在读取这个变量时有可能会直接从缓存(例如寄存器)中来读取这个值,而不会去内存中读取。这样做的一个好处是提高了程序的运行效率,但当遇到多线程编程时,某个变量的值可能因为其他线程的使用而改变了,但是因为该值的缓存的值不会改变,所以会造成程序读取的值和其实际的值不一致。

举个可以解决问题的例子,在本次线程内,当读取一个变量时,为了提高读取速度,优先把变量存入到缓存中,之后再取变量的值时,直接从缓存中读。当变量值改变的时候,需要把新的值复制到该缓存中,以便保持一致。

volatile是一个类型修饰符(type specifier),当初设计它的用途就是用来修饰被不同线程访问和修改的变量,被volatile修饰之后,系统默认每次使用它的时候都是从内存中提取,而不会利用缓存。在使用了volatile修饰之后,所有线程看到的同一变量的值都是相同的。

一个代码例子:

1
2
3
4
5
6
7
8
9
10
11
public class MyThread implements Runnable {
private volatile Boolean flag;
public void stop() {
flag = false;
}
public void run() {
while(flag) {
; // do something
}
}
}

这段代码可以停止线程,也是最常用的一种方法。如果变量flag没有被volatile修饰,那么当这个线程的run方法在判断flag值的时候,使用的有可能是缓存中的值,此时就不能即时地获取其他线程对flag所做的操作,因此会导致线程不能及时地停止。

需要注意,volatile不能保证操作原子性,所以volatile不能代替synchronized,此外volatile会阻止编译器对代码的优化,降低执行效率,所以一般来说能不用就不用volatile.

常用的设计模式与使用场景

例如,在回答 “你知道哪几种设计模式” 这个问题时,不但能说出几种设计模式,以及适合哪类场景,而且还能指出哪些著名的框架在处理什么问题时使用了哪种设计模式,或者自己在处理某个项目的什么场景时,使用了哪种设计模式,取得了什么效果,这样肯定会给面试官留下非常好的印象。

1.工厂模式:Spring如何创建Bean

工厂模式是创建不同类型实例时常用的方式,例如 Spring 中的各种 Bean 是有不同 Bean 工厂类进行创建的。

2.代理模式:Motan服务的动态代理

代理模式,主要用在不适合或者不能直接引用另一个对象的场景,可以通过代理模式对被代理对象的访问行为进行控制。Java 的代理模式分为静态代理和动态代理。静态代理指在编译时就已经创建好了代理类,例如在源代码中编写的类;动态代理指在 JVM 运行过程中动态创建的代理类,使用动态代理的方法有 JDK 动态代理、CGLIB、Javassist 等。面试时遇到这个问题可以举个动态代理的例子,比如在 Motan RPC 中,是使用 JDK 的动态代理,通过反射把远程请求进行封装,使服务看上去就像在使用本地的方法。

3.责任链模式:Netty消息处理的方式

责任链模式有点像工厂的流水线,链上每一个节点完成对对象的某一种处理,例如 Netty 框架在处理消息时使用的 Pipeline 就是一种责任链模式。

4.适配器模式:SLF4J如何支持Log4J

适配器模式,类似于我们常见的转接头,把两种不匹配的对象来进行适配,也可以起到对两个不同的对象进行解藕的作用。例如我们常用的日志处理框架 SLF4J,如果我们使用了 SLF4J 就可以跟 Log4j 或者 Logback 等具体的日志实现框架进行解藕。通过不同适配器将 SLF4J 与 Log4j 等实现框架进行适配,完成日志功能的使用。

5.观察者模式:GRPC是如何支持流式请求的

观察者模式也被称作发布订阅模式,适用于一个对象的某个行为需要触发一系列事件的场景,例如 gRPC 中的 Stream 流式请求的处理就是通过观察者模式实现的。

6.构造者模式:PB序列化中的Builder

构造者模式,适用于一个对象有很多复杂的属性,需要根据不同情况创建不同的具体对象,例如创建一个 PB 对象时使用的 builder 方式。