面试题2:实现Singleton模式

题目:设计一个类,我们只能生成该类的一个实例。

由于设计模式在面向对象程序设计中起着举足轻重的作用,在面试过程中很多公司都喜欢问一些与设计模式相关的问题。在常用的模式中,Singleton是唯一一个能够用短短几十行代码完整实现的模式。因此,写一个Singleton的类型是一个很常见的面试题。

如果你看过我之前写的设计模式专栏,那么这道题思路你会很开阔。

单例模式的要点有三个:一是某个类只能有一个实例;二是它必须自行创建这个实例;三是它必须自行向整个系统提供这个实例

我们下面来看一下它的实现

懒汉式写法

public class LazySingleton {

    private static LazySingleton lazySingleton = null;

    private LazySingleton() {

    }

    public static LazySingleton getInstance() {
if(lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}

关键就是将构造器私有,限制只能通过内部静态方法来获取一个实例。

但是这种写法,很明显不是线程安全的。如果多个线程在该类初始化之前,有大于一个线程调用了getinstance方法且lazySingleton == null 判断条件都是正确的时候,这个时候就会导致new出多个LazySingleton实例。可以这么改一下:

这种写法叫做DoubleCheck。针对类初始化之前多个线程进入 if(lazySingleton == null) 代码块中情况

这个时候加锁控制,再次判断 if(lazySingleton == null) ,如果条件成立则new出来一个实例,轮到其他的线程判断的时候自然就就为假了,问题大致解决。

public class LazyDoubleCheckSingleton {

    private static LazyDoubleCheckSingleton lazySingleton = null;

    private LazyDoubleCheckSingleton() {

    }

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

但是即使是这样,上面代码的改进有些问题还是无法解决的。

因为会有重排序问题。重排序是一种编译优化技术,属于《编译原理》的内容了,这里不详细探讨,但是要告诉你怎么回事。

正常来说,下面的这段代码

lazySingleton = new LazyDoubleCheckSingleton();

执行的时候是这样的

  1. 分配内存给这个对象
  2. 初始化对象
  3. 设置LazyDoubleCheckSingleton指向刚分配的内存地址。

但是编译优化后,可能是这种样子

  1. 分配内存给这个对象
  2. 设置LazyDoubleCheckSingleton指向刚分配的内存地址。
  3. 初始化对象

2 步骤 和 3 步骤一反,就出问题了。(前提条件,编译器进行了编译优化)

比如说有两个线程,名字分别是线程1和线程2,线程1进入了 if(lazySingleton == null) 代码块,拿到了锁,进行了 new LazyDoubleCheckSingleton()的执行,在加载构造类的实例的时候,设置LazyDoubleCheckSingleton指向刚分配的内存地址,但是还没有初始化对象。线程2判断 if(lazySingleton == null) 为假,直接返回了lazySingleton,又进行了使用,使用的时候就会出问题了。

画两张图吧:

重排序的情况如下:

再看出问题的地方

当然这个很好改进,从禁用重排序方面下手,添加一个volatile。不熟悉线程安全可以参考这篇文章【Java并发编程】线程安全性详解

    private volatile static LazyDoubleCheckSingleton lazySingleton = null;

方法不止一种嘛,也可以利用对象初始化的“可见性”来解决,具体来说是利用静态内部类基于类初始化的延迟加载,名字很长,但是理解起来并不困难。(使用这种方法,不必担心上面编译优化带来的问题)

类初始化的延迟加载与JVM息息相关,我们演示的例子的只是被加载了而已,而没有链接和初始化。

我们看一下实现方案:

定义一个静态内部类,其静态字段实例化了一个单例。获取单例需要调用getInstance方法间接获取。

public class StaticInnerClassSingleton {

    private static class InnerClass{
private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
} public static StaticInnerClassSingleton getInstance() {
return InnerClass.staticInnerClassSingleton;
}
}

如果对内部类不熟悉,可以参考这篇文章【Java核心技术卷】深入理解Java的内部类



懒汉式的介绍就到这里吧,下面再看看另外一种单例模式的实现


饿汉式写法

演示一下基本的写法

public class HungrySingleton {

    // 类加载的时候初始化
private final static HungrySingleton hungrySingleton = new HungrySingleton(); /*
也可以在静态块里进行初始化
private static HungrySingleton hungrySingleton; static {
hungrySingleton = new HungrySingleton();
}
*/
private HungrySingleton() { } public static HungrySingleton getInstance() {
return hungrySingleton;
} }

饿汉式在类加载的时候就完成单例的实例化,如果用不到这个类会造成内存资源的浪费,因为单例实例引用不可变,所以是线程安全的

同样,上面的饿汉式写法也是存在问题的

我们依次看一下:

首先是序列化破坏单例模式

先保证饿汉式能够序列化,需要继承Serializable 接口。

import java.io.Serializable;

public class HungrySingleton implements Serializable {

    // 类加载的时候初始化
private final static HungrySingleton hungrySingleton = new HungrySingleton(); /*
也可以在静态块里进行初始化
private static HungrySingleton hungrySingleton; static {
hungrySingleton = new HungrySingleton();
}
*/
private HungrySingleton() { } public static HungrySingleton getInstance() {
return hungrySingleton;
} }

我们测试一下:

import lombok.extern.slf4j.Slf4j;

import java.io.*;

@Slf4j
public class Test { public static void main(String[] args) throws IOException, ClassNotFoundException {
HungrySingleton hungrySingleton = HungrySingleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton"));
oos.writeObject(hungrySingleton); File file = new File("singleton");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)); HungrySingleton newHungrySingleton = (HungrySingleton) ois.readObject(); log.info("结果 {}",hungrySingleton);
log.info("结果 {}",newHungrySingleton);
log.info("对比结果 {}",hungrySingleton == newHungrySingleton);
}
}

结果:

结果发现对象不一样,原因就涉及到序列化的底层原因了,我们先看解决方式:

饿汉式代码中添加下面这段代码

private Object readResolve() {
return hungrySingleton;
}

重新运行,这个时候的结果:

原因出在readResolve方法上,下面去ObjectInputStream源码部分找找原因。(里面都涉及到底层实现,不要指望看懂)

在一个读取底层数据的方法上有一段描述

就是序列化的Object类中可能定义有一个readResolve方法。我们在二进制数据读取的方法中看到了是否判断

private Object readOrdinaryObject()方法中有这段代码,如果存在ReadResolve方法,就去调用。不存在,不调用。联想到我们在饿汉式添加的代码,大致能猜到怎么回事了吧。

另外一种情况就是反射攻击破坏单例

演示一下

import lombok.extern.slf4j.Slf4j;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException; @Slf4j
public class Test { public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class objectClass = HungrySingleton.class; Constructor constructor = objectClass.getDeclaredConstructor();
constructor.setAccessible(true); // 强行打开构造器权限
HungrySingleton instance = HungrySingleton.getInstance();
HungrySingleton newInstance = (HungrySingleton) constructor.newInstance(); log.info("结果{}",instance);
log.info("结果{}",newInstance);
log.info("比较结果{}",newInstance == instance);
}
}



这里强行破开了private的构造方法的权限,使得能new出来一个单例实例,这不是我们想看到的。

解决方法是在构造方法中抛出异常

   private HungrySingleton() {
if( hungrySingleton != null) {
throw new RuntimeException("单例构造器禁止反射调用");
}
}

这个时候再运行一下

其实对于懒汉式也是有反射破坏单例的问题的,也可以采用类似抛出异常的方法来解决。

饿汉式单例与懒汉式单例类比较

  • 饿汉式单例类在自己被加载时就将自己实例化。单从资源利用效率角度来讲,这个比懒汉式单例类稍差些。从速度和反应时间角度来讲,则比懒汉式单例类稍好些。
  • 懒汉式单例类在实例化时,必须处理好在多个线程同时首次引用此类时的访问限制问题,特别是当单例类作为资源控制器在实例化时必然涉及资源初始化,而资源初始化很有可能耗费大量时间,这意味着出现多线程同时首次引用此类的机率变得较大,需要通过同步化机制进行控制。

枚举

除此之外还有一种单例模式的实现就是枚举

使用枚举的方式实现单例模式是《Effective Java》作者力推的方式,在很多优秀的开源代码中经常可以看到使用枚举方式实现单例模式的地方,枚举类型不允许被继承,同样是线程安全的且只能被实例化一次,但是枚举类型不能够懒加载,对Singleton主动使用,比如调用其中的静态方法则INSTANCE会立即得到实例化。

//枚举类型本身是final的,不允许被继承
public enum Singleton
{
INSTANCE;
//实例变量
private byte[] data = new byte[1024]; Singleton()
{
System.out.println("I want to follow Jeffery.");
} public static void method()
{
//调用该方法则会主动使用Singleton,INSTANCE将会被实例化
} public static Singleton getInstance()
{
return INSTANCE;
}
}

在实际面试中,我们为了展现枚举单例模式,可以写成这样:

public enum Singleton
{
INSTANCE; public static Singleton getInstance()
{
return INSTANCE;
}
}

Java中的枚举其实是一种语法糖,换句话说就是编译器帮助我们做了一些的事情,我们将字节码反编译成Java代码,看看编译器帮我们做了什么,以及探讨为什么使用枚举的方式实现单例模式是《Effective Java》作者力推的方式?

原始代码如下:

public enum EnumClass {
SPRING,SUMMER,FALL,WINTER;
}

反编译后的代码

public final class EnumClass extends Enum
{ public static EnumClass[] values()
{
return (EnumClass[])$VALUES.clone();
} public static EnumClass valueOf(String name)
{
return (EnumClass)Enum.valueOf(suger/EnumClass, name);
} private EnumClass(String s, int i)
{
super(s, i);
} public static final EnumClass SPRING;
public static final EnumClass SUMMER;
public static final EnumClass FALL;
public static final EnumClass WINTER;
private static final EnumClass $VALUES[]; static
{
SPRING = new EnumClass("SPRING", 0);
SUMMER = new EnumClass("SUMMER", 1);
FALL = new EnumClass("FALL", 2);
WINTER = new EnumClass("WINTER", 3);
$VALUES = (new EnumClass[] {
SPRING, SUMMER, FALL, WINTER
});
}
}

对于静态代码块不了解的参考 : Java中静态代码块、构造代码块、构造函数、普通代码块

结合前面的内容,是不是很容易理解了? 除此之外,我们还可以看出,枚举是继承了Enum类的,同时它也是final,即不可继承的。

枚举类型的单例模式的玩法有很多,网上传的比较多的有以下几种:

内部枚举类形式

1.构造方法中实例化对象(上面提到了 注意了吗)

public class EnumSingleton {
private EnumSingleton(){} public static EnumSingleton getInstance(){
return Singleton.INSTANCE.getInstance();
} private enum Singleton{
INSTANCE; private EnumSingleton singleton; //JVM会保证此方法绝对只调用一次
Singleton(){
singleton = new EnumSingleton();
}
public EnumSingleton getInstance(){
return singleton;
}
}
}

2.枚举常量的值即为对象实例

public class EnumSingleton {
private EnumSingleton(){} public static EnumSingleton getInstance(){
return Singleton.INSTANCE.getInstance();
} private enum Singleton{
INSTANCE(new EnumSingleton());
private EnumSingleton singleton; //JVM会保证此方法绝对只调用一次
Singleton(EnumSingleton singleton){
this.singleton = singleton;
}
public EnumSingleton getInstance(){
return singleton;
}
}
}

接口实现形式

对于一个标准的enum单例模式,最优秀的写法还是实现接口的形式:

// 定义单例模式中需要完成的代码逻辑
public interface MySingleton {
void doSomething();
} public enum Singleton implements MySingleton {
INSTANCE {
@Override
public void doSomething() {
System.out.println("I want to follow Jeffery. What about you ?");
}
}; public static MySingleton getInstance() {
return Singleton.INSTANCE;
}
}

我就问!单例模式的面试,你还怕不怕?

剑指Offer对答如流系列 - 实现Singleton模式的更多相关文章

  1. 剑指 offer set 28 实现 Singleton 模式

    singleton 模式又称单例模式, 它能够保证只有一个实例. 在多线程环境中, 需要小心设计, 防止两个线程同时创建两个实例. 解法 1. 能在多线程中工作但效率不高 public sealed ...

  2. 剑指Offer对答如流系列 - 重建二叉树

    面试题6:重建二叉树 题目描述: 输入某二叉树的前序遍历和中序遍历的结果,请重建出该二叉树.假设输入的前序遍历和中序遍历的结果中都不含重复的数字.例如输入前序遍历序列{1,2,4,7,3,5,6,8} ...

  3. 剑指offer题目系列三(链表相关题目)

    本篇延续上一篇剑指offer题目系列二,介绍<剑指offer>第二版中的四个题目:O(1)时间内删除链表结点.链表中倒数第k个结点.反转链表.合并两个排序的链表.同样,这些题目并非严格按照 ...

  4. 剑指offer题目系列二

    本篇延续上一篇,介绍<剑指offer>第二版中的四个题目:从尾到头打印链表.用两个栈实现队列.旋转数组的最小数字.二进制中1的个数. 5.从尾到头打印链表 题目:输入一个链表的头结点,从尾 ...

  5. 剑指offer题目系列一

    本篇介绍<剑指offer>第二版中的四个题目:找出数组中重复的数字.二维数组中的查找.替换字符串中的空格.计算斐波那契数列第n项. 这些题目并非严格按照书中的顺序展示的,而是按自己学习的顺 ...

  6. 剑指offer自学系列(三)

    题目描述: 输入一个整数数组,实现一个函数来调整该数组中数字的顺序,使得所有的奇数位于数组的前半部分,所有的偶数位于数组的后半部分,并保证奇数和奇数,偶数和偶数之间的相对位置不变,例如{5,1,4,2 ...

  7. 剑指offer-面试题2.实例Singleton模式

    题目:设计一个类,我们只能生成该类的一个实例 这道题显然是对设计模式的考察,很明显是单例模式.什么是单例模式呢,就是就像题目所说的只能生成一 个类的实例.那么我们不难考虑到下面几点: 1.不能new多 ...

  8. 剑指offer自学系列(五)

    题目描述:请实现一个函数用来找出字符流中第一个只出现一次的字符.例如,当从字符流中只读出前两个字符"go"时,第一个只出现一次的字符是"g".当从该字符流中读出 ...

  9. 剑指offer自学系列(四)

    题目描述: 输入一个正整数数组,把数组里面所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个,例如输入数组{3,32,321},输出的最小数字为321323 题目分析: 如果采用穷举法,把 ...

随机推荐

  1. CodeForces 1204 (#581 div 2)

    传送门 A.BowWow and the Timetable •题意 给你一个二进制数,让你求小于这个数的所有4的幂的个数 •思路 第一反应是二进制与四进制转换 (其实不用真正的转换 QwQ) 由于二 ...

  2. vscode 添加golang插件

    安装好git 下列命令中的路径一定要按照自己实际的路径来 mkdir -p $GOPATH/src/golang.org/x  //路径下创建此文件cd $GOPATH/src/golang.org/ ...

  3. Java中大量if...else语句的消除替代方案

    在我们平时的开发过程中,经常可能会出现大量If else的场景,代码显的很臃肿,非常不优雅.那我们又没有办法处理呢? 针对大量的if嵌套让代码的复杂性增高而且难以维护.本文将介绍多种解决方案. 案例 ...

  4. P3810 陌上花开 CDQ分治

    陌上花开 CDQ分治 传送门:https://www.luogu.org/problemnew/show/P3810 题意: \[ 有n 个元素,第 i 个元素有 a_i. b_i. c_i 三个属性 ...

  5. ES6类的继承

    ES6 引入了关键字class来定义一个类,constructor是构造方法,this代表实例对象. constructor相当于python的init 而this 则相当于self 类之间通过ext ...

  6. POJ 2976 Dropping tests [二分]

    1.题意:同poj3111,给出一组N个有价值a,重量b的物品,问去除K个之后,剩下的物品的平均值最大能取到多少? 2.分析:二分平均值,注意是去除K个,也就是选取N-K个 3.代码: # inclu ...

  7. 22.BASE_DIR,os,sys

    原文: BASE_DIR演示 想在bin里调用main里的方法.需要找到目录. import sys,os BASE_DIR = os.path.dirname(os.path.dirname(os. ...

  8. Python学习(三)基础

    一.函数与模块 定义函数: 函数代码块以 def 关键词开头,后接函数标识符名称和圆括号 (). 任何传入参数和自变量必须放在圆括号中间,圆括号之间可以用于定义参数. 函数的第一行语句可以选择性地使用 ...

  9. 从零开始のcocos2dx生活(十)ScrollView

    目录 简介 基础变量 ScrollViewDelegate Direction _dragging _container _touchMoved _bounceable _touchLength 方法 ...

  10. vc调用mysql数据库操作例子

    这里归纳了C API可使用的函数 函数 描述 mysql_affected_rows() 返回上次UPDATE.DELETE或INSERT查询更改/删除/插入的行数. mysql_autocommit ...