0. 单例导言
单例模式就是说系统中对于某类的只能有一个对象,不可能出来第二个。
单例模式就是在程序运行中只实例化一次,创建一个全局唯一对象,有点像 Java 的静态变量,但是单例模式要优于静态变量,静态变量在程序启动的时候JVM就会进行加载,如果不使用,会造成大量的资源浪费,单例模式能够实现懒加载,能够在使用实例的时候才去创建实例。开发工具类库中的很多工具类都应用了单例模式,比例线程池、缓存、日志对象等,它们都只需要创建一个对象,如果创建多份实例,可能会带来不可预知的问题,比如资源的浪费、结果处理不一致等问题。
单例的实现思路
- 静态化实例对象
- 私有化构造方法,禁止通过构造方法创建实例
- 提供一个公共的静态方法,用来返回唯一实例
单例的好处
- 只有一个对象,内存开支少、性能好
- 避免对资源的多重占用
- 在系统设置全局访问点,优化和共享资源访问
单例模式的写法有饿汉模式、懒汉模式、双重检查锁模式、静态内部类单例模式、枚举类实现单例模式五种方式,其中懒汉模式、双重检查锁模式两种方式,如果你写法不当,在多线程情况下会存在不是单例或者单例出异常等问题。
单例模式也是23中设计模式中在面试时少数几个会要求写代码的模式之一。主要考察的是多线程下面单例模式的线程安全性问题。
1.多线程安全单例模式实例一(饿汉模式)
public class Singleton {
private static Singleton sin=new Singleton(); ///直接初始化一个实例对象
private Singleton(){ ///private类型的构造函数,保证其他类对象不能直接new一个该对象的实例
}
public static Singleton getSin(){ ///该类唯一的一个public方法 ,提供接口
return sin;
}
}
优点:
- 由于使用了static关键字,保证了在引用这个变量时,关于这个变量的所以写入操作都完成,所以保证了JVM层面的线程安全
缺点:
- 不能实现懒加载,造成空间浪费,如果一个类比较大,我们在初始化的时就加载了这个类,但是我们长时间没有使用这个类,这就导致了内存空间的浪费。
上述代码中的一个缺点是该类加载的时候就会直接new 一个静态对象出来,当系统中这样的类较多时,会使得启动速度变慢,不能实现懒加载 。现在流行的设计都是讲“延迟加载”,我们可以在第一次使用的时候才初始化第一个该类对象。所以这种适合在小系统。
2.多线程安全单例模式实例二(懒汉模式)
public class Singleton {
private static Singleton instance;
private Singleton (){
}
public static synchronized Singleton getInstance(){ //对获取实例的方法进行同步
if (instance == null)
instance = new Singleton();
return instance;
}
}
优点
- 实现了懒加载,节约了内存空间
缺点
- 在不加锁的情况下,线程不安全,可能出现多份实例
- 在加锁的情况下,会是程序串行化,使系统有严重的性能问题
懒汉模式是一种偷懒的模式,在程序初始化时不会创建实例,只有在使用实例的时候才会创建实例,所以懒汉模式解决了饿汉模式带来的空间浪费问题。
上述代码中的一次锁住了一个方法( synchronized锁住的是Singleton.class,但是执行该方法时才会对其加锁,所以说是锁住了“方法”), 这个粒度有点大 ,改进就是只锁住其中的new语句就OK。就是所谓的“双重锁”机制。
3.多线程安全单例模式实例三(双重同步锁)
public class Singleton {
private static Singleton instance;
private Singleton (){
}
public static Singleton getInstance(){ //对获取实例的方法进行同步
// 第一次判断,如果这里为空,不进入抢锁阶段,直接返回实例
if (instance == null){
synchronized(Singleton.class){ //只在进行new时,锁住Singleton.class
// 抢到锁之后再次判断是否为空
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题,在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。什么是指令重排?,看下面这个例子,简单了解一下指令从排序
private SingletonObject4(){
1: int x = 10;
2: int y = 30;
3: Object o = new Object();
}
上面的构造函数SingletonObject4(),我们编写的顺序是1、2、3,JVM 会对它进行指令重排序,所以执行顺序可能是3、1、2,也可能是2、3、1,不管是那种执行顺序,JVM 最后都会保证所以实例都完成实例化。 如果构造函数中操作比较多时,为了提升效率,JVM 会在构造函数里面的属性未全部完成实例化时,就返回对象。双重检测锁出现空指针问题的原因就是出现在这里,当某个线程获取锁进行实例化时,其他线程就直接获取实例使用,由于JVM指令重排序的原因,其他线程获取的对象也许不是一个完整的对象,所以在使用实例的时候就会出现空指针异常问题。
要解决双重检查锁模式带来空指针异常的问题,只需要使用volatile关键字,volatile关键字严格遵循happens-before原则,即在读操作前,写操作必须全部完成。添加volatile关键字之后的单例模式代码:
// 添加volatile关键字
private static volatile SingletonObject5 instance;
private SingletonObject5(){
}
public static SingletonObject5 getInstance(){
if (instance == null)
synchronized (SingletonObject5.class){
if (instance == null){
instance = new SingletonObject5();
}
}
return instance;
}
}
添加volatile关键字之后的双重检查锁模式是一种比较好的单例实现模式,能够保证在多线程的情况下线程安全也不会有性能问题。
4.多线程安全单例模式实例四(私有静态内部类)
静态内部类单例模式也称单例持有者模式,实例由内部类创建,由于 JVM 在加载外部类的过程中, 是不会加载静态内部类的, 只有内部类的属性/方法被调用时才会被加载, 并初始化其静态属性。静态属性由static修饰,保证只被实例化一次,并且严格保证实例化顺序。静态内部类单例模式代码如下:
public class Singleton {
public Singleton() {
}
private static class Inner{ //静态内部类:单例持有者
private static Singleton s=new Singleton();
}
public static Singleton getSingle(){
return Inner.s;
}
}
在类Singleton 里面有个私有内部类,别人访问不了。在内部类中直接new一个Singleton出来,提供一个外部接口getSingle,返回的是内部类new出来的静态对象。这种方式既不用加锁,也能实现懒加载,因为只有执行到return Inner.s;
才会初始化Inner并把Singleton new出来。(内部类的class文件在编译是创建,内部类的实例在new时创建)
静态内部类单例模式是一种优秀的单例模式,是开源项目中比较常用的一种单例模式。在没有加任何锁的情况下,保证了多线程下的安全,并且没有任何性能影响和空间的浪费。
5.多线程安全单例模式实例五(枚举)
枚举类实现单例模式是 effective java 作者极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是所用单例实现中唯一一种不会被破坏的单例实现模式。枚举单例模式代码如下:
public class SingletonObject {
//构造方法
private SingletonObject(){
}
/**
* 枚举类型是线程安全的,并且只会装载一次
*/
private enum Singleton{
INSTANCE;
private final SingletonObject instance;
Singleton(){
instance = new SingletonObject();
}
private SingletonObject getInstance(){
return instance;
}
}
public static SingletonObject getInstance(){
return Singleton.INSTANCE.getInstance();
}
}
相比之下,你就会发现,枚举实现单例的代码会精简很多。枚举中添加方法参考这篇文章。
上面的双重锁校验的代码之所以很臃肿,是因为大部分代码都是在保证线程安全。为了在保证线程安全和锁粒度之间做权衡,代码难免会写的复杂些。但是,这段代码还是有问题的,因为他无法解决反序列化会破坏单例的问题。枚举可解决线程安全问题。
在Java圣经《Effective Java》中,Joshua Bloch大佬如是说:枚举单例可以有效防御两种破坏单例(即使单例产生多个实例)的行为:反射攻击与序列化攻击。
破坏单例模式的方法及解决办法
1、除枚举方式外, 其他方法都会通过反射的方式破坏单例,反射是通过调用构造方法生成新的对象,所以如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例,解决办法如下:
private SingletonObject1(){
if (instance !=null){
throw new RuntimeException("实例已经存在,请通过 getInstance()方法获取");
}
}
2、如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例,所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象。
public Object readResolve() throws ObjectStreamException {
return instance;
}
参考:
网友评论