专栏首页健程之道设计模式——单例模式

设计模式——单例模式

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

饿汉式

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

public class Hungry {

    private static final Hungry instance = new Hungry();

    private Hungry() {}

    public Hungry getInstance() {
        return instance;
    }
}

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

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

static 代码块

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

public class Hungry {

    private static final Hungry instance;

    static {
        instance = new Hungry();
    }

    private Hungry() {}

    public Hungry getInstance() {
        return instance;
    }
}

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

懒汉式

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

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

这里使用 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

为什么要有双重检查呢?

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

静态内部类

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

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 的构造方法,这也是由类加载机制保证的:

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

优缺点

以上方法的优缺点:

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

缺点:

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

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

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);
    }
}

输出结果为:

Lazy print : 1583410057762
Lazy print : 1583410057768
false
Lazy print : 1583410057762
false

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

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

看看修改后的代码:

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);
    }
}

运行结果为:

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 解决。来看看写法:

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();
    }
}

运行结果为:

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

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

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

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

总结

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

本文分享自微信公众号 - 健程之道(JianJianCoder),作者:健健壮

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-03-05

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 设计模式——原型模式

    设计模式中,单例模式应该是大家最为熟悉的了,那如果我们需要对一个对象进行多次复制的话,大家会用什么呢?这就要用到今天要讲的原型模式了。

    健程之道
  • 力扣77——组合

    原题url:https://leetcode-cn.com/problems/combinations/

    健程之道
  • 力扣86——分隔链表

    给定一个链表和一个特定值 x,对链表进行分隔,使得所有小于 x 的节点都在大于或等于 x 的节点之前。

    健程之道
  • 读源码——Guava-Cache

    今天,听同事介绍了Cuava-cache,这是个老牌缓存了,虽然近来被Caffine的出现遮盖了风头,但依然不能掩盖它往日的辉煌,至少在我们团队,还有很多项目在...

    早安嵩骏
  • 资讯 | 携程首度引入VR技术,攻略社区将全面升级

    近日携程攻略客户端2.4正式上线Apple Store和各大安卓市场,新版本突破性地引入了VR游记功能,用户通过携程攻略客户端即可预览VR场景。 此外,用户可以...

    携程技术
  • Redis 架构

    Linux有Linux kernal,我们的客户端,进行连接,首先到达的是Linux kernal,在Linux的早期版本,只有read和write进行文件读写...

    Parker
  • 3.5 Git 分支 - 远程分支

    远程引用是对远程仓库的引用(指针),包括分支、标签等等。 你可以通过 git ls-remote (remote)来显式地获得远程引用的完整列表,或者通过 ...

    shaonbean
  • 《算法图解》第六章笔记

    Zoctopus
  • 《算法图解》第六章笔记_广度优先搜索

    Zoctopus
  • 带团队,要不要把员工推向前台?

    2011-2015在58的5年,是我快速成长的5年,感谢领导wutp,除了教会我《带团队,不能太强势》,同时我还从他身上学会,“要把自身本领传授给队友,并把员工...

    架构师之路

扫码关注云+社区

领取腾讯云代金券