设计模式——单例模式

关于单例模式,这是面试时最容易遇到的问题。当时以为很简单的内容,深挖一下,也可以关联出类加载、序列化等知识。

饿汉式

我们先来看看基本的饿汉式写法:

1
2
3
4
5
6
7
8
9
10
public class Hungry {

private static final Hungry instance = new Hungry();

private Hungry() {}

public Hungry getInstance() {
return instance;
}
}

优点:写法简答,不需要考虑多线程等问题。

缺点:如果该实例从未被用到的话,相当于资源浪费。

static 代码块

我们也可以用 static 代码块的方式,实现饿汉式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Hungry {

private static final Hungry instance;

static {
instance = new Hungry();
}

private Hungry() {}

public Hungry getInstance() {
return instance;
}
}

这就是利用了 static 代码块的功能:它是随着类的加载而执行,只执行一次,并优先于主函数。

懒汉式

我们先来看看基本的懒汉式写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Lazy {

private static volatile Lazy instance;

private Lazy(){}

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

这里就涉及到了很多知识点,让我们一一讲解。

volatile

1
2
3
4
5
6
7
8
9
10
这里使用 volatile,主要是为了禁止指令重排序。

主要就是针对 instance = new Lazy(); 这1行命令,在 JVM 中至少对应3条指令:
1. 给 instance 分配内存空间。
2. 调用 Lazy 的构造方法等来初始化 instance。
3. 将 instance 对象指向分配的内存空间(执行完这一步,instance 就不是 null 了)。

这里需要注意,JVM 会对指令进行优化排序,就是第 2 步与第 3 步的顺序是不一定的,可能是 1-2-3 ,也可能是 1-3-2 。

如果是后者,可能1个线程执行完 1-3 之后,另一个线程进入了

以上这一段想必就是大家平常看到的解释了,原本我对此也是深信不疑的,但是因为本地一直无法复现,因此让我产生了怀疑。

查阅资料后,可能是和以下两点有关。

Intel 64/IA-32架构下的内存访问重排序

指令重排发生在处理器平台,对于Java来说是看不到的,因为Jvm基于线程栈,所有的读写都对应了 store 操作,而Intel 64/IA-32架构下处理器不需要LoadLoad、LoadStore、StoreStore屏障,因此不会发生需要这三种屏障的重排序。所以,store 操作之间是不会重排序的。

JMM

JMM 抽象地将内存分为主内存和本地内存,各个线程有各自的本地内存。

如果2个线程在执行Lazy.getInstance()方法,instance作为 static 修改的变量,处于主内存中,两个线程会各自复制instance到本地内存中,当线程1执行instance = new Lazy();方法,除非全部结束,否则不会将本地内存中的instance写回主内存中。

以上也可能是我想错了,但欢迎大家一起探讨。

double-check

为什么要有双重检查呢?

1
2
第二个 if 判定:是为了保证当有两个线程同时通过了第一个 if 判定,一个线程获取到锁,生成了 Lazy 的一个实例,然后第二个线程获取到锁,如果没有第二个 if 判断,那么此时会再次生成生成 Lazy 的一个实例。
第一个 if 判定:是为了保证多线程同时执行,如果没有第一个 if 判断,所有线程都会串行执行,效率低下。

静态内部类

也可以利用静态内部类来实现:

1
2
3
4
5
6
7
8
9
10
11
12
public class Lazy {

private Lazy() {}

private static class InnerLazy {
private static final Lazy INSTANCE = new Lazy();
}

public static Lazy getInstance() {
return InnerLazy.INSTANCE;
}
}

为什么这样能实现懒加载呢?

因为只有当调用InnerLazy.INSTANCE时,才会对 InnnerLazy 类进行初始化,然后才会调用 Lazy 的构造方法,这也是由类加载机制保证的:

1
2
遇到 new 、getstatic、putstatic 或者 invokestatic 这 4 条字节码指令时,如果没有对类进行初始化,则需要先触发其初始化。
这4个指令对应的 Java 场景是:使用 new 新建一个 Java 对象,访问或者设置一个类的静态字段,访问一个类的静态方法的时候。

优缺点

以上方法的优缺点:

优点:使用的时候才会进行初始化,拥有更好的资源优化。

缺点:

  1. 除去最后一种静态内部类之外,写法都比较繁琐。
  2. 如果使用反射或者反序列化,依旧可以强制生成新的实例。

针对第2点,我们可以举例子来说明一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Lazy implements Serializable {

public String name;

private Lazy() {
name = String.valueOf(System.currentTimeMillis());
}

private static class InnerLazy {
private static final Lazy INSTANCE = new Lazy();
}

public static Lazy getInstance() {
return InnerLazy.INSTANCE;
}

public void print() {
System.out.println("Lazy print : " + name);
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException, ClassNotFoundException {
Lazy instance1 = Lazy.getInstance();
instance1.print();

// 反射
Lazy instance3 = Lazy.class.newInstance();
instance3.print();
System.out.println(instance1 == instance3);

// 反序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("file"));
oos.writeObject(instance1);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("file"));
Lazy instance2 = (Lazy) ois.readObject();
instance2.print();
System.out.println(instance1 == instance2);
}
}

输出结果为:

1
2
3
4
5
Lazy print : 1583410057762
Lazy print : 1583410057768
false
Lazy print : 1583410057762
false

说明反射和反序列化,都会破坏以上写法的单例特征。那该如何解决呢?

  1. 针对反射,解决起来比较简单,可以在构造方法中判断一下 InnerLazy.INSTANCE ,如果不为 null ,则抛出异常。
  2. 针对反序列化,可以实现接口 Serializable ,重写 readResolve 方法,返回单例对象 InnerLazy.INSTANCE。

看看修改后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package singleton;

import java.io.*;

public class Lazy implements Serializable {

public String name;

private Lazy() {
if (InnerLazy.INSTANCE != null) {
throw new RuntimeException("can not be invoked");
}
name = String.valueOf(System.currentTimeMillis());
}

private static class InnerLazy {
private static final Lazy INSTANCE = new Lazy();
}

public static Lazy getInstance() {
return InnerLazy.INSTANCE;
}

public void print() {
System.out.println("Lazy print : " + name);
}

private Object readResolve() {
return InnerLazy.INSTANCE;
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException, ClassNotFoundException {
Lazy instance1 = Lazy.getInstance();
instance1.print();

// 反序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("file"));
oos.writeObject(instance1);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("file"));
Lazy instance2 = (Lazy) ois.readObject();
instance2.print();
System.out.println(instance1 == instance2);

// 反射
Lazy instance3 = Lazy.class.newInstance();
instance3.print();
System.out.println(instance1 == instance3);
}
}

运行结果为:

1
2
3
4
5
6
7
8
9
10
11
Lazy print : 1583409803987
Lazy print : 1583409803987
true
Exception in thread "main" java.lang.RuntimeException: can not be invoked
at singleton.Lazy.<init>(Lazy.java:11)
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at java.lang.Class.newInstance(Class.java:442)
at singleton.Lazy.main(Lazy.java:46)

枚举类

针对上面的缺点,我们也可以用 enum 解决。来看看写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package singleton;

import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;

public enum Singleton {

INSTANCE;

private String name;

private Singleton() {
name = String.valueOf(System.currentTimeMillis());
}

public void print() {
System.out.println("Lazy print : " + name);
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException {
Singleton instance1 = Singleton.INSTANCE;
instance1.print();
// 反序列化
ObjectMapper objectMapper = new ObjectMapper();
String content = objectMapper.writeValueAsString(instance1);
Singleton instance3 = objectMapper.readValue(content, Singleton.class);
System.out.println(instance1 == instance3);
instance3.print();
// 反射
Singleton instance2 = Singleton.class.newInstance();
System.out.println(instance1 == instance2);
instance2.print();
}
}

运行结果为:

1
2
3
4
5
6
7
8
9
10
Lazy print : 1583409004276
true
Lazy print : 1583409004276
Exception in thread "main" java.lang.InstantiationException: singleton.Singleton
at java.lang.Class.newInstance(Class.java:427)
at singleton.Singleton.main(Singleton.java:31)
Caused by: java.lang.NoSuchMethodException: singleton.Singleton.<init>()
at java.lang.Class.getConstructor0(Class.java:3082)
at java.lang.Class.newInstance(Class.java:412)
... 1 more

首先,枚举是不能被反射生成实例的,这也就解决了反射破坏单例的问题。

其次,在序列化枚举类型时,只会存储枚举类的引用和枚举常量的名称。随后的反序列化的过程中,这些信息被用来在运行时环境中查找存在的枚举类型对象,这也就解决了序列化破坏单例的问题。

但需要注意:这种方法属于饿汉模式,所以有浪费资源的隐患,但如果你的单例对象并不占用资源,没有状态变量,那么这种方式就很适合你。

总结

以上就是我关于单例模式的一些理解,简单的问题,也可以关联出并发、类加载、序列化等重要知识。

有兴趣的话可以访问我的博客或者关注我的公众号、头条号,说不定会有意外的惊喜。

https://death00.github.io/

公众号:健程之道

健健 wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
如果您感觉文章不错,也愿意支持一下作者的话