设计模式之单例

设计模式是面向对象解决问题的一种技巧,不分语言的,这里以java来实现

目录

一、定义篇

1.什么是单例

2.单例模式三要点:

3.单例模式的特点:

 优点:

  缺点:

4.最基本的实现方案

二、具体实现篇

1.饿汉式(简单可用)

2.懒汉式(线程不安全,不可用)

存在的问题:

3.同步方法的懒汉式(同步方法效率低,不推荐)

4.双重校验锁(可用)

存在的问题

扩展知识:

5.静态内部类(推荐)

6.枚举(《Effective Java》推荐,不常见)

7.不同实现方式总结比较

三、其他关注点

1.Java反射攻击破坏单例模式

2.反序列化攻击破坏单例模式   

3.单例模式中的单例对象会不会被垃圾回收?

4.多JVM/ClassLoader的系统使用单例类

五、应用篇:

1.JDK中中的应用:

2.具体的场景 

3.Spring(IOC框架)实现的单例

4.应用总结篇

五、可参考的书籍


 

 单例模式(Singleton Pattern)是设计模式中比较常用的一种,下面来总结单例模式的知识,包括:

 

       1、理解什么是单例模式、单例模式有什么优点/缺点、单例模式的应用场景;

       2、再来看看Java单例模式的6种代码实现方式、每种实现方式有什么需要注意的;

       3、后面再来了解Java单例模式其他值得关注的地方,如比较静态方法、以及Java反射、反序列化、垃圾回收的影响等。
 

一、定义篇

1.什么是单例

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

作用:单例模式的作用在于保证整个程序在一次运行的过程中,被单例模式声明的类的对象要有且只有一个。

设计模式之单例_第1张图片

UML图

 模式角色

  一个类使用了单例模式,称该类为单例类,如图中的Singleton。

 

2.单例模式三要点:

      (1)、单例类只能有一个实例

      这是最基本的,真正做到整个系统中唯一并不容易,通常还要考虑反射破坏、序列化/反序列化、对象垃圾回收等问题。

      (2)、单例类必须自己创建自己的唯一实例

      通常给实例构造函数protected或private权限。

      (3)、单例类必须给所有其他对象提供这一实例

      通常定义静态方法getInstance()返回。
 

3.单例模式的特点:

 优点:

       (1)、提供了对唯一实例的受控访问,避免对资源的多重占用。

       (2)、在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例。

       (3)缩小名空间,避免全局变量污染空间,但比类操作更灵活。

  缺点:

       (1)、由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。

       (2)、 单例类的职责过重,在一定程度上违背了"单一职责原则"。

      因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。

       所以也不应过多使用单例模式。

4.最基本的实现方案

单例模式的从实现步骤上来讲,分为三步:

  1. 构造方法私有,保证无法从外部通过 new 的方式创建对象。
  2. 对外提供获取该类实例的静态方法
  3. 类的内部创建该类的对象,通过第 2 步的静态方法返回

 

二、具体实现篇
 

1.饿汉式(简单可用)

        Lazy 初始化:否;

       多线程安全:是;

       描述:这种方式比较常用,它基于JVM的类加载器机制避免了多线程的同步问题,对象在类装载时就实例化,所以称为饿汉式。在虚拟机加载改类的时候,将会在初始化阶段为类静态变量赋值,也就是在虚拟机加载该类的时候(此时可能并没有调用 getInstance 方法)就已经调用了 new BasicSingleTon(); 创建了改对象的实例。

       优点:没有加锁,执行效率会提高。

       缺点:没有Lazy初始化,可能有时候不需要使用,浪费内存。

       代码实例:

public class Singleton {
 
    //创建唯一实例
    private static Singleton instance = new Singleton();
   
    //第一步构造方法私有
    private Singleton (){}
    
    //第二部暴露静态方法返回唯一实例
    public static Singleton getInstance() {
        return instance;
 
    }
 
}

2.懒汉式(线程不安全,不可用)

Lazy 初始化:是;

      多线程安全:否;

      描述:

      延迟加载的方式,是在我们编码过程中尽可能晚的实例化话对象,也就是避免在类的加载过程中,让虚拟机去创建这个实例对象。能够在getInstance()时再创建对象,所以称为懒汉式。这种实现最大的问题就是不支持多线程。因为没有加锁同步。

 

     代码实例:

public class Singleton {
 
    private static Singleton instance;
 
    private Singleton (){}
     
    public static Singleton getInstance() {
 
        //延迟初始化 在第一次调用 getInstance 的时候创建对象
        if (instance == null) {
            instance = new Singleton();
        }
 
        return instance;
    }
 
}

存在的问题:

对于单线程模式上述的延迟加载已经算的上是很好的单例实践方式了。

但是

一方面Java 是一个多线程的内存模型。

静态变量存在于虚拟机的方法区中,该内存空间被线程共享,上述实现无法保证对单例对象的修改保证内存的可见性,原子性。

设计模式之单例_第2张图片

 

另一方面,newInstance 方法本身就不是一个原子类操作(分为两步第一步判空,第二步调用 new 来创建对象)

 

所以结论是上述两种实现方式不适合多线程的引用场景。

 

那么对于多线程环境下单例实现模式,存在的问题,我们可以举个简单的例子

假设有两个线程都需要这个单例的对象,线程 A 率先进入语句 if (singleTon == null) 得到的结果为 true,此时 CPU 切换线程 B 去执行,由于 A 线程并没有进行 new Singleton();的操作,那么 B 线程在执行语句 singleTon == null的结果认为 true,紧接着 B 线程创建了改类的实例对象,当 CPU 重新回到 A 线程去执行的时候,又会创建一个类的实例,这就导致了,所谓的单例并不真正的唯一,也就会产生错误。

为了解决这个缺点,我们能想到方法首先就是加锁,使用 synchronized 关键字来保证,在执行 getInstance 的时候不会发生线程的切换。
 

3.同步方法的懒汉式(同步方法效率低,不推荐)

 Lazy 初始化:是

      多线程安全:是

      描述:

      除第一次使用,后面getInstance()不需要同步;每次同步,效率很低。

      代码实例:

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

还有一种差不多的修饰代码块

 public static SyncSingleTon getInstance() {

   synchronized(SyncSingleTon.class){
       if (singleTon == null) {
           singleTon = new SyncSingleTon();
       }
   }
   return singleTon;
}

但是对于效率会产生影响,因为如果我们可预料的线程切换场景并不是那么频繁,那么synchronizedgetInstance方法加锁,将会带来很大效率丢失,比如单线程的模式下。

4.双重校验锁(可用)

 Lazy 初始化:是;

      多线程安全:是;

      描述:

      这种方式采用双锁机制,安全且在多线程情况下能保持高性能。

      实例变量需要加volatile 关键字保证易变可见性,JDK1.5起才可用。

      代码实例:

public class Singleton {
 
    private  static Singleton singleton;
 
    private Singleton (){}
 
    public static Singleton getSingleton() {
 
        //这次判空是避免了,保证的多线程只有第一次调用getInstance 的时候才会加锁初始化
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                 }
             }
        }
 
        return singleton;
    }
 
}

上述方案很好的解决了,最开始的实现在效率上的损失,比如在多个线程场景中,即使在第一次if (singleTon == null)判空操作中让出 CPU 去执行,那么在另一个线程中也会在同步代码中初始化改单例对象,待 CPU 切换回来的时候,也会在第二次判空的时候得到正确结果。

存在的问题

当我们都认为这一切的看上去很完美的时候,JVM 又给我提出了个难题,那就是指令重排。

什么是指令重排,指令重排的用大白话来简单的描述,就是说在我们的代码运行时,JVM 并不一定总是按照我们想让它按照编码顺序去执行我们所想象的语义,它会在 “不改变” 原有代码语句含义的前提下进行代码,指令的重排序。

所有线程在执行java程序时必须要遵守 intra-thread semantics(译为 线程内语义是一个单线程程序的基本语义)。intra-thread semantics 保证重排序不会改变单线程内的程序执行结果。换句话来说,intra-thread semantics 允许那些在单线程内,不会改变单线程程序执行结果的重排序。

那么我们上述双重检验锁的单例实现问题主要出在哪里呢?问题出在 singleTon = new SyncSingleTon();这句话在执行的过程。首先应该进行对象的创建操作大体可以分为三步:

 (1)分配内存空间。

 (2)初始化对象即执行构造方法。

 (3)设置 Instance 引用指向该内存空间。 
  
 那么如果有指令重排的前提下,这三部的执行顺序将有可能发生变化: 
  
 (1)分配内存空间。 
  
 (2)设置 Instance 引用指向该内存空间。 
  
 (3)初始化对象即执行构造方法。 
  
上面类初始化描述的步骤 2 和 3 之间虽然被重排序了, 但是这个重排序在没有改变单线程程序的执行结果。那么再多线程的前提下这将会造成什么样的后果呢?我们假设有两个线程同时想要初始化这个类, 这两个线程的执行如下图所示:

如果按照上述的语义去执行,单看线程 A 中的操作虽然指令重排了,但是返回结果并不影响。但是这样造成的问题也显而易见,b 线程将返回一个空的 Instance,可怕的是我们认为这一切是正常执行的。

为了解决上述问题我们可以从两个方面去考虑:

避免指令重排
让 A 线程完成对象初始化后,B 再去判断 instance == null
通过 Volatile 避免指令重排序
对于 Volatile 关键字,这里不做详细的描述,读者需要了解的是,volatile 作用有以下两点:

可以保证多线程条件下,内存区域的可见性,即使用 volatile 声明的变量,将对在一个线程从内主内存(线程共享的内存区域)读取变量,并写入后,通知其他线程,改变量被我改变了,别的线程在使用的时候,将会重新从主内存中去读改变量的最新值。

可以保证再多线程的情况下,指令重排这个操作将会被禁止。 

IBM公司高级软件工程师Peter    Haggar 2004年在IBM developerWorks上发表了一篇名为《双重检查锁定及单例模式——全面理解这一失效的编程习语》的文章,对JDK    1.5之前的双重检查锁定及单例模式进行了全面分析和阐述,参考链接:http://www.ibm.com/developerworks/cn/java/j-dcl.html 

那么改造完成的双重检锁的单例将会是这样的:
 

public class VolatileSingleTon {

    //使用 Volatile 保证了指令重排序在这个对象创建的时候不可用
    private volatile static  VolatileSingleTon singleTon = null;

    public static VolatileSingleTon getInstance() { 
        if (singleTon == null) {
            synchronized (VolatileSingleTon.class) {
                if (singleTon == null) {
                    singleTon = new VolatileSingleTon();
                }
            }
        }
        return singleTon;
    }
    private VolatileSingleTon() {}
}

由于 volatile 关键字是在 JDK 1.5 之后被明确了有禁止指令重排的语义的,1.5之前不可用

但是不推荐这种方式

那么我们怎么去给一个对象的初始化过程去加锁呢,看起来好像没思路。

这里我们需要补充一个知识点,是有关 JVM 在类的初始化阶段期间,将会去获取一个锁,这个锁的作用是可以同步多个线程对同一个类的初始化操作。JVM 在类初始化期间会获得一个称做初始化锁的东西,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。

我们可以理解为:如果一个线程在初始化一个类的时候,将会为这个初始化过程上锁,当此时有其他的线程尝试初始化这个类的时候,将会查看这个锁的状态,如果这个锁没有被释放,那么将会处于等待锁释放的状态。这和我们用的 synchronized 机制很相似,只是被用在类的初始化阶段。

对于静态内部类,相信读者一定清楚它不依靠外部类的存在而存在。在编译阶段将作为独立的一个类,生成自己的 .class 文件。并且在初始化阶段也是独立的,也就是说拥有上述所说的初始化锁。
 

扩展知识:

注意这里的初始化是指在JVM 类加载过程中 加载->链接(验证,准备,解析)->初始化 中的初始化。这个初始化过程将为类的静态变量赋具体的值。

对于一个类的初始化时机有一下几种情况:

1) 使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
 

那么我们可以有如下思路:

  1. 返回该类的对象依赖于一个静态内部类的初始化操作。
  2. 在这个静态内部类初始化的时候,生成外部类的对象,然后在 getInstance 中返回

5.静态内部类(推荐)

       Lazy 初始化:是;

      多线程安全:是;

      描述:

      同样利用了JVM类加载机制来保证初始化实例对象时只有一个线程,静态内部类SingletonHolder 类只有第一次调用 getInstance 方法时,才会装载从而实例化对象。

      代码实例:

 

public class Singleton {
 
    private static class SingletonHolder {
 
       private static final Singleton INSTANCE = new Singleton();
    }
 
    private Singleton (){}
 
    public static final Singleton getInstance() {
 
        return SingletonHolder.INSTANCE;
    }
 
} 

6.枚举(《Effective Java》推荐,不常见)

 Lazy 初始化:否;

 多线程安全:是;

      描述:

      从Java1.5开始支持enum特性;无偿提供序列化机制,绝对防止多次实例化,即使在面对复杂的序列化或者反射攻击的时候。这是因为 Enum 类的创建本身是就是线程安全的,枚举的思想其实是通过共有的静态 final 与为每个枚举常量导出实例的类,由于没有可访问的构造器,所以不能调用枚举常量的构造方法去生成对应的对象,因此在《Effective Java》 中,枚举类型为类型安全的枚举模式,枚举也被称为单例的泛型化。不过,用这种方式写不免让人感觉生疏,这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法。

      代码实例:

public enum Singleton {
 
    //定义一个枚举的元素,就代表Singleton实例
    INSTANCE;
 
    /*
    **假如还定义有下面的方法,调用:Singleton.INSTANCE.doSomethingMethod();
    */
 
    public void doSomethingMethod() {
 
    }
 
} 

7.不同实现方式总结比较

一般情况下,使用最基本、最简单的第一种饿汉式就行了(JDK中有不少使用该种方式),需要延时加载的使用静态内部类方式,需要高安全性的可以使用第6种枚举方式。
 


三、其他关注点

1.Java反射攻击破坏单例模式

式除枚举方式外,其他的给实例构造函数protected或private权限,依然可以通过相关反射方法,改变其权限,创建多个实例,如下:

测试

public class Test {
 
    public static void main(String args[]) {
     
        Singleton singleton = Singleton.getInstance();
 
       try {
 
            Constructor constructor = Singleton.class.getDeclaredConstructor();
            constructor.setAccessible(true);
            Singleton singletonnew = constructor.newInstance();
            System.out.println(singleton == singletonnew);
 
        } catch (Exception e) {
 
        }     
    }
}

改进方法:
可以给构造函数加上判断,限制创建多个实例,如下:

private Singleton() {
 
    if (null != Singleton.singleton) {
 
        throw new RuntimeException();
    }
}

2.反序列化攻击破坏单例模式   

很多语言、框架都支持对象的序列化,对象序列化后再进行存储或传输,以获得更好的效率,之后再反序列化得到同样的对象信息。

 同样,前面6种Java单例模式实现方式除枚举方式外,其他方式用一样的序列化数据,可以多次反序列出多个不同的实例对象。

 对于Java语言提供的序列化/反序列化机制,需要单例类实现java.io.Serializable接口;而在在反序列化时会调用实例的readResolve()方法,只要加入该方法,并在方法中指定返回单例对象,就不会再新建一个对象,如下:
 

private Object readResolve() {
    return Singleton.singleton;
}

另外,最好还要确保该类的所有实例域都为基本类型,或者是transient的。否则,还是可能受到攻击破坏。

3.单例模式中的单例对象会不会被垃圾回收?

 对于JDK1.2后的JVM HotSpot来说,判断对象可以回收需要经过可达性分析,由于单例对象被其类中的静态变量引用,所以JVM认为对象是可达的,不会被回收。

      另外,对于JVM方法区回收,由堆中存在单例对象,所以单例类也不会被卸载,其静态变量引用也不会失效。

4.多JVM/ClassLoader的系统使用单例类

 不同ClassLoader加载同一个类,对类本身的对象(Singleton.class)来说是不一样的,所以可以创建出不同的单例对象,对不同JVM的情况更是如此,这些在JavaEE开发中还是比较常见。      所以,在多JVM/ClassLoader的系统使用单例类,需要注意单例对象的状态,最好使用无状态的单例类。

总结:

单例模式:

      (1)、单例模式可以在一些应用场景带来很好的效果,但不能滥用,因为单例模式并不是一种很好的模式。

      (2)、单例模式有多种实现方式,没有特殊要求的,用最基本、最简单的饿汉式,需要延时加载的使用静态内部类方式,需要高安全性的可以使用枚举方式;

      (3)、对其他关注点应有所了解,有时间可以深入探究,扩展知识面。
 

五、应用篇:

单例模式是一种对象创建型模式,用来编写一个类,在整个应用系统中只能有该类的一个实例对象。

1.JDK中中的应用:

       java.lang.Runtime#getRuntime()

 

public class Runtime {
    private static Runtime currentRuntime = new Runtime();

    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {}

    //其他的一些代码就在此省略
}

      java.text.NumberFormat#getInstance()

这个有点复杂正在看

public abstract class NumberFormat extends Format  {

    protected NumberFormat() {
    
    }

    public final static NumberFormat getInstance() {
        return getInstance(Locale.getDefault(Locale.Category.FORMAT), NUMBERSTYLE);
    }

    private static NumberFormat getInstance(Locale desiredLocale,
                                           int choice) {
        LocaleProviderAdapter adapter;
        adapter = LocaleProviderAdapter.getAdapter(NumberFormatProvider.class,
                                                   desiredLocale);
        NumberFormat numberFormat = getInstance(adapter, desiredLocale, choice);
        if (numberFormat == null) {
            numberFormat = getInstance(LocaleProviderAdapter.forJRE(),
                                       desiredLocale, choice);
        }
        return numberFormat;
    }
    
    private static NumberFormat getInstance(LocaleProviderAdapter adapter,
                                            Locale locale, int choice) {
        NumberFormatProvider provider = adapter.getNumberFormatProvider();
        NumberFormat numberFormat = null;
        switch (choice) {
        case NUMBERSTYLE:
            numberFormat = provider.getNumberInstance(locale);
            break;
        case PERCENTSTYLE:
            numberFormat = provider.getPercentInstance(locale);
            break;
        case CURRENCYSTYLE:
            numberFormat = provider.getCurrencyInstance(locale);
            break;
        case INTEGERSTYLE:
            numberFormat = provider.getIntegerInstance(locale);
            break;
        }
        return numberFormat;
    }

}

      java.awt.GraphicsEnvironment#getLocalGraphicsEnvironment()
 

public abstract class GraphicsEnvironment {

    protected GraphicsEnvironment() {
    
    }

    private static GraphicsEnvironment localEnv;

    public static synchronized GraphicsEnvironment getLocalGraphicsEnvironment() {
        if (localEnv == null) {
            localEnv = createGE();
        }

        return localEnv;
    }

    //其他部分省略

}

关于CreateGE()是通过反射创建的

Proxy.newInstance()

ToolKit.getDefaultToolKit

Desktop.getDesktop

JDK JAVA API 中的日历类型 Calendar 
方法Calendar.getInstance();
整个Jvm 系统中只需要一个 日历

 

2.具体的场景 

1.Struct1的核心控制器

2.网站的计数器,一般也是采用单例模式实现,否则难以同步。比如在线人数统计

3.web容器中的Servlet也是只实例化一份

4.分布式下的session

分布式可以说是当下很热门的概念,那么单例模式在分布式环境下是否依然实用呢?我以分布式Session举例吧,分布式Session常见的做法就是用一个全局缓存(如Redis)存储Session对象,多个Web应用共享这个Session对象,这个不就是活生生的单例吗?还有就是分布式ID某种意义上也可以说是单例模式,起码生成出来的分布式ID需要全局唯一。
数据共享时

5.一般RPG网络游戏账号下都支持多个游戏角色,但是每个游戏角色只能允许同一时刻一个地方登陆。有些游戏甚至一个账号只能由在一个地方登陆。PC的QQ和移动端的微信也是这样的,同一时间只能允许一台设备登陆。


6.windows任务管理器,任何时候只能打开一个,如果第二次打开,就会把已经打开的任务管理器激活到当前窗口。

 7.windows的Recycle Bin(回收站)也是典型的单例应用。在整个系统运行过程中,回收站一直维护着仅有的一个实例。

8.应用程序的日志应用,一般都何用单例模式实现,这一般是由于共享的日志文件一直处于打开状态,因为只能有一个实例去操作,否则内容不好追加

9. Web应用的配置对象的读取,一般也应用单例模式,这个是由于配置文件是共享的资源。

10.数据库连接池的设计一般也是采用单例模式,因为数据库连接是一种数据库资源。数据库软件系统中使用数据库连接池,主要是节省打开或者关闭数据库连接所引起的效率损耗,这种效率上的损耗还是非常昂贵的,因为何用单例模式来维护,就可以大大降低这种损耗。

11.多线程的线程池的设计一般也是采用单例模式,这是由于线程池要方便对池中的线程进行控制

12.操作系统的文件系统,也是大的单例模式实现的具体例子,一个操作系统只能有一个文件系统。

13.Spring中的默认Bean

3.Spring(IOC框架)实现的单例

 Spring的一个核心功能控制反转(Inversion of Contro,IOC),或称依赖注入(dependency injection ,DI):
 
      在整个应用中(一般只用一个IOC容器),默认只创建Bean的一个实例,多次注入同一具体类时都是注入同一个实例。
      IOC容器来实现过程简述如下:
      当需要注入Bean时,IOC容器首先解析配置找到具体类,然后判断其作用域(@Scope注解);
      如果是默认的单例@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON),则查找容器中之前有没有为其创建了Bean实例;
      如果有则直接注入该Bean实例,如果没有生成一个放到容器中保存(ConcurrentHashMap -- map.put(bean_id, bean)),再注入。

      注:其中解析配置查找具体类、生成Bean实例和注入过程都是通过Java反射机制实现的。

      从上面可以了解到,Spring实现的单例和我们所说的单例设计模式不是一个概念:
      前者是IOC容器通过Java反射机制实现,后者只是一种编程方法(套路)。
      但总的来说,它们都可以实现“单例”。

关于Spring源码在单例中的具体实现可参考https://www.cnblogs.com/chengxuyuanzhilu/p/6404991.html

扩展:

深入一点的问题

spring工厂中创建的bean为singleton模式和prototype格式,为什么没有使用静态类的形式?或者可以选择非静态类的静态方法?

    1、静态类是在JVM加载的时候就占用内存的,而单例模式,可以在使用的时候再进行加载。也就是单例可以延迟初始化。

     2、单例可以继承类,可以实现接口,而静态类不能。

     3、单例类可以被集成,他的方法可以被覆写。

     4、单例类可以被用来多台。

     5、而针对非静态类的静态方法,如果一个类不需要扩展的话可以用这样的方式,用单例也可以,但是需要扩展的类,最好是使用单例。

 

4.应用总结篇

单例模式应用的场景一般发现在以下条件下:

  (1)资源共享的情况下,避免由于资源操作时导致的性能或损耗等。如上述中的日志文件,应用配置。

  (2)控制资源的情况下,方便资源之间的互相通信。如线程池等。

 

五、可参考的书籍

1、《Design Patterns》GoF 3.5 SINGLETON(单例)

2、《Head First设计模式》5 单例模式

3、《Java与模式》第15章 单例(SINGLETON)模式

4、《大话设计模式》第21章 有些类也需要计划生育—单例模式

5、《Effective Java》第二版 第3条 用私有构造器或者枚举类型强化Singleton属性  等等
 

你可能感兴趣的:(单例与静态内部类,设计模式)