Skip to content

单例模式

ZHI-XINHUA edited this page Jan 11, 2019 · 1 revision

单例模式

单例模式特定:

  • 单例类只有一个实例。
  • 单例类必须自己创建自己的唯一实例。(不能外部实例化,私有构造器)
  • 单例类必须给所有其他对象提供这一实例(也就是静态调用)

单例模式的优点:

  • 由于单例模式只生成一个实例,减少了系统性能开销,当一个对象的产生需要比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后永久驻留内存的方式来解决。
  • 单例模式可以在系统设置全局的访问点,优化环共享资源访问,例如可以设计 一个单例类,负责所有数据表的映射处理

常见的五种单例模式实现方式

  • 主要
    • 恶汉式:线程安全,调用效率高。但是,不能延时加载。
    • 懒汉式:线程安全,调用效率不高。但是,可以延时加载。
  • 其它
    • 双重检测锁式:由于JVM底层内部模型原因,偶尔会出问题。不建议使用
    • 静态内部类式:线程安全,调用效率高。但是,可以延时加载。
    • 枚举单例:线程安全,调用效率高,不能延时加载

单例模式分饿汉式和饱汉式(懒汉式)

1、饿汉式

饿汉式:第一次加载类时初始化,让jvm保证无需担心多线程问题。

public class HungrySingleton {
    //静态实例化对象
    private static  HungrySingleton singleton = new HungrySingleton();

    //私有化构造函数,防止外部实例化
    private HungrySingleton(){};

    public static HungrySingleton getInstace(){
        return singleton;
    }
}
  • 优点: 静态属性只会在第一次加载类时初始化;此种方式避免了多线程带来的问题,而且也避免了双重检查jvm调优的问题。
  • 缺点: 一旦我访问了Singleton的任何其他的静态域,就会造成实例的初始化,而事实是可能我们从始至终就没有使用这个实例,造成内存的浪费。

总结:这种缺点对于99%的项目是基本没什么影响的。 加载配置文件也是使用这种方式居多。是比较可靠的单例模式实现方式。

2、饱汉式(懒汉式)

饱汉式(懒汉式):用到的时候才加载。这种方式我们必须考虑多线程环境下的情况。

下面看一种比较糟糕的实现方式:

public class BadSingleton {

    //静态实例
    private static BadSingleton badSingleton;

    //私有化构造函数,防止外部实例化
    private  BadSingleton(){}

    //实例化对象
    public static BadSingleton getInstance(){
        if(badSingleton==null){
            badSingleton = new BadSingleton();
        }
        return  badSingleton;
    }
}

从代码可看出,这方式不支持多线程。多线程环境下不能保证创建单例的实例。(==别写这种单例模式==)

针对上述糟糕的实现,问题在于不支持多线程并发,好吧,那就给她一个同步:

public class SynchronizedSingleton {
    //静态实例变量
    private static SynchronizedSingleton singleton;

    //私有化构造函数,防止外部实例化
    private SynchronizedSingleton(){}

    //同步实例化对象
    public synchronized static SynchronizedSingleton getInstace(){
        if(singleton==null){
            singleton = new SynchronizedSingleton();
        }
        return  singleton;
    }
}

这时候支持同步了吧,心里美滋滋的。==,再认真思考一下,给创建方法同步不太好吧。

了解线程的同学都知道,同步会挂起没有获得锁的线程。结合代码分析,A线程首先获得锁,其它所有线程都在方法外面等待。当A执行到创建实例化后new SynchronizedSingleton(),此时实例化对象还已经创建好了。但是其它线程的哥们还只能傻傻等待。所以,==这方式的缺点就在同步粒度控制得不好。==

好吧!好吧!原来是同步粒度控制问题。那就再进一步优化:双重检查

3、双重检测锁式

public class DoubleCheckSingleton {
    //静态对象
    private static DoubleCheckSingleton singleton;


    //私有化构造函数,防止外部实例化
    private DoubleCheckSingleton(){}

    //实例化对象
    public static DoubleCheckSingleton getInstance(){
        if(singleton==null){//1、为空才去同步实例化对象,对比同步方法的方式,这里避免了实例化后的也被同步
            synchronized (DoubleCheckSingleton.class){//2、如果A、B线程进入同步临界区,A线程先获得锁
                if (singleton==null){//3、A线程实例为空,创建实例对象,然后释放锁。B线程获得锁,发现对象不为空,则跳过创建实例。
                    singleton = new DoubleCheckSingleton();
                }
            }
        }
        return  singleton;
    }
}

怎么样?貌似很完美。这么优秀的控制粒度,迫不及待地给大家解析一番:

加入A、B线程同时进入方法,C、D、F...很多线程跟随其后,A、B都判断singleton为空,然后A、B线程进入同步临界区,A线程先获得锁,A线程判断实例为空,创建实例对象,然后释放锁。B线程获得锁,发现对象不为空,则跳过创建实例。 C、D、F后面的线程如果才执行到1步骤,那就直接返回实例,不用进去同步临界区。

这样粒度控制到位了,性能就提高了。

高兴似乎来得太早,对jvm有所理解的都知道,还是有问题。

深入jvm探索,因为虚拟机在执行创建实例的这一步操作的时候并非是原子性操作。当遇到==jvm对字节码调优,调整指令的顺序会发生变化==。

首先要明白在JVM创建新的对象时,主要要经过三步:

  1. 分配内存
  2. 初始化构造器
  3. 将对象指向分配的内存的地址

jvm正常情况按照上述步骤是上述代码是没有问题的,这种情况下JVM是完成了整个对象的构造才将内存的地址交给了对象。

当JVM会针对字节码进行调优,调整指令的顺序:2和3顺序发生改变时,这时将会先将内存地址赋给对象。针对上述的双重加锁,就是说先将分配好的内存地址指给singleton,然后再进行初始化构造器。这时候后面的线程去请求getInstance方法时,会认为singleton对象已经实例化了,直接返回一个引用。 如果在初始化构造器之前,这个线程使用了singleton,就会产生莫名的错误。

针对JVM节码进行调优带来的问题,在想能不能不让JVM对实例调优。灵机一闪,想到了volatile,volatile强行保证线程中对变量所做的任何写入操作对其他线程都是即时可见的。 所以,给静态变量添加volatile 修饰。

public class DoubleCheckSingleton {

    //静态对象,强行保证线程中对变量所做的任何写入操作对其他线程都是即时可见的
    private static volatile DoubleCheckSingleton singleton;

    //私有化构造函数,防止外部实例化
    private DoubleCheckSingleton(){}

    //实例化对象
    public static DoubleCheckSingleton getInstance(){
        if(singleton==null){//为空才去同步实例化对象,对比同步方法的方式,这里避免了实例化后的也被同步
            synchronized (DoubleCheckSingleton.class){//如果A、B线程进入同步临界区,A线程先获得锁
                if (singleton==null){//A线程实例为空,创建实例对象,然后释放锁。B线程获得锁,发现对象不为空,则跳过创建实例。
                    singleton = new DoubleCheckSingleton();
                }
            }
        }
        return  singleton;
    }
}

给静态的实例属性加上关键字volatile,标识这个属性是不需要优化的。

因为加入了volatile关键字,就等于禁止了JVM自动的指令重排序优化,并且强行保证线程中对变量所做的任何写入操作对其他线程都是即时可见的。

请思考一个问题:能不能不用线程就实现饿汉式,毕竟线程嘛总会多多少少存在性能的问题的。 答案是利用内部类实现。

4、静态内部类式

最优方案:

public class InnerClassSingleton {

    //私有化构造函数,防止外部实例化
    private InnerClassSingleton(){};

    public static  InnerClassSingleton getInstance(){
        return SingletonInstance.instance;
    }

    //内部类创建单例模式.
    private static class SingletonInstance{
        static  InnerClassSingleton instance = new InnerClassSingleton();
    }
}

静态内部类在使用的时候才会加载,所以此种方式属于懒汉式。同时也避免了多线程的问题。

上面几种方式需要将构造器私有,外部不能实例化;但是可以利用反射方式去实例化。所以反射可破解

怎么解决?

可以在构造方法中手动抛出异常控制

//私有化构造器
private SingletonDemo01() throws Exception{
   if(s!=null){
   throw new Exception("只能创建一个对象");
   //通过手动抛出异常,避免通过反射创建多个单例对象!
   }
} 

下面介绍一种反射不能破解的实现单例的方式:枚举单例

5、枚举单例

public enum SingletonDemo05 {
    /**
    * 定义一个枚举的元素,它就代表了Singleton的一个实例。
    */
    INSTANCE;
    
    /**
    * 单例可以有自己的操作
    */
    public void singletonOperation(){
    //功能处理
    }
}

测试:

public static void main(String[] args) {
    SingletonDemo05 sd = SingletonDemo05.INSTANCE;
    SingletonDemo05 sd2 = SingletonDemo05.INSTANCE;
    System.out.println(sd==sd2);//true
}

优点:

  • 实现简单
  • 枚举本身就是单例模式。由JVM从根本上提供保障!避免通过反射和反序列化的漏洞!

缺点:

  • 无延迟加载

常见应用场景

  • 项目中,读取配置文件的类,一般也只有一个对象。没有必要每次使用配置文件数据,每次new一个对象去读取。
  • 数据库连接池的设计一般也是采用单例模式,因为数据库连接是一种数据库资源。
  • Application 也是单例的典型应用(Servlet编程中会涉及到)
  • 在Spring中,每个Bean默认就是单例的,这样做的优点是Spring容器可以管理
  • 在servlet编程中,每个Servlet也是单例
  • 在spring MVC框架/struts1框架中,控制器对象也是单例
  • Windows的Task Manager(任务管理器)就是很典型的单例模式

建议

单例模式使用饿汉式或者内部类实现的懒汉式

参考:

菜鸟教程:单例模式

单例模式详解

静态内部类何时初始化