Java单例模式

什么是单例模式

单例对象的类必须保证只有一个实例存在,这也可以作为对意图实现单例模式的代码进行检验的标准。
单例的实现可以分为两大类–懒汉模式和饿汉模式,他们区别在于:

  • 懒汉式:指全局的单例实例在第一次被使用时构建。
  • 饿汉式:指全局的单例实例在类装载时构建,

从他们的区别也能看出来,日常我们使用的较多的是懒汉式的单例,毕竟按需加载才能做到资源的最大化利用

懒汉式单例

懒汉式单例的实现方式。

  1. 1 简单版本
//Version 1
public class Single1{
	private static Single1 instance;
	public static Single1 getInstance(){
		if(instance==null){
			instance=new Single1();
		}
		return instance;
	}
}

或者再进一步,把构造器改为私有的,这样能够防止被外部的类调用

//Version 1.1
public class Single1{
	private static Single1 instance;
	private Single1(){
		if(instance==null){
			instance=new Single1();
		}
		return instance;
	}
}

每次获取instance之前先进性判断,如果instance为空就new一个出来,否则就直接返回已存在的instance。
这种写法在大多数的时候也是没什么问题的。问题在于,当多线程工作的时候,如果有多个线程同时运到if(instance==null),都判断为null,那么两个线程就各自会创建一个实例,这样一来,就不是单例了。

  1. 2 synchronized版本
    既然可能会因为多线程导致问题,那么加上一个同步锁来试一下!
//Version 2
public class Single2{
	private static Single2 instance;
	private Single2(){}
	public static synchronized Single2 getInstance(){
		if(instance==null){
			instance=new Single2();
		}
		return instance;
	}
}

在加上synchronized关键字之后,getInstance方法就会上锁了,如果有两个线程(S1、S2)同时执行到这个方法时,会有其中一个线程S1获得同步锁,得以继续执行,而另一个线程S2这需要等待,当S1执行完毕getInstance之后(完成了null判断、对象创建、获得返回值之后),S2线程才会继续执行。这段代码也就避免了Version 1中,可能出现以为多线程导致多个实例的情况。

但是,这种写法也有一个问题:给getInstance方法枷锁,虽然避免了可能会出现的多个实例问题,但是会强制除S1之外的所有线程等待,实际上会对线程的执行效率造成负面影响。

  1. 3 双重检查(Double-Check)版本
    Version 2代码相对于Version 1代码的效率问题,其实只是为了解决1%的几率问题,而实用了一个100%出现的防护盾。做一个优化思路,就是把100%出现的防护盾,也改为1%的几率出现,使之只出现在可能会导致多个实例出现的地方。
//Version 3
public class Single3{
	private static Single3 instance;
	private Single3(){}
	public static Single3 pugetInstance(){
		if(instance==null){
			synchronized(Single3.class){
				if(instance==null){
					instance=new Single3();
				}
			}
		}
		return instance;
	}
}

这个版本的代码看起来有点复杂,注意其中两次if(instance==null)的判断,这个叫做 双重检查 Double - Check。

  • 第一个if(instance==null),其实是为了解决Version 2中的效率问题,只有instancenull的时候,才进入synchronized的代码段,大大减少了几率。

  • 第二个if(instance==null),则是跟Version 2一样,是为了防止可能出现多个实例的情况。

  • 这段代码看起来已经完美无瑕了,当然,只是“看起来”,还是有小概率出现问题的。
    为什么这里可能还会出现问题,我们就得弄清楚几个概念:原子操作、指令重拍。

    • 知识点: 什么是原子操作?
      简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度被打断的操作。
      比如,简单的赋值是一个原子操作:
      m=6;//这是个原子操作
      假如m原先的值为0,那么对于这种操作,要么执行成功,m变成了6,要么是没执行,m还是0,而不会出现诸如m=3这种中间态–即使是在并发的线程中。
      而声明并赋值就不是一个原子操作:
      int n=6;//这不是一个原子操作
      对于这个语句,至少有两个操作:
      ①、声明一个变量n
      ②、给n赋值为6
      这样就会有一个中间状态:变量n已经被声明了但是还没有被赋值的状态。
      这样,在多线程中,由于线程执行顺序的不确定性,如果两个线程都使用m,就可能会导致不稳定的结果出现。

    • 知识点:什么是指令重排?
      简单来说,就是计算机为了提高执行效率,会做一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。
      比如:
      int a;//语句 1
      a=8;//语句 2
      int b=9;//语句 3
      int c=a+b;//语句4
      正常来说,对于顺序结构,执行的顺序是自上到下,也即语句1、语句2、语句3、语句4。
      但是,由于指令重排的原因,因为不能影响最终的结果,所以,实际执行的顺序可能会变成3124或是1324。
      由于语句3和4没有原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序。

    在了解了原子操作和指令重排的概念之后,我们继续砍Version 3代码的问题。
    主要在于instance=new Single3() 这句,这并非是一个原子操作,事实上在JVM中这句话大概做了下面3件事。

    • instance分配内存
    • 调用instance的构造函数来初始化成员变量,形成实例
    • instance对象指向分配的内存空间(执行完这步instance才是非null 了)但是在JVM的即时编译器中存在指令重排的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是1-2-3-4也可能是1-3-2-4。如果是后者,则在3执行完毕、2未执行之前,被线程S2抢占了,这时instance已经是非null了(但却没有初始化),所以线程S2会直接返回instance,然后使用,然后顺理成章的报错。

    由于有一个instance已经不为null但是仍没有完全初始化的中间状态,而这个时候,如果有其他线程刚好运行到第一层if(instance==null)这里,这里读取到的instance已经不为null了,所以就直接把这个中间状态的instance拿去用了,就会产生问题。
    这里的关键在于–线程S1对instance的写操作没有完成,线程S2就执行了读操作。

  1. 4 终极版本:volatile
    对于Version 3中可能出现的问题(当然这种概率已经非常小了,但毕竟还是有的嘛),解决方案是:只需要给instance的声明加上volatile关键字即可,`Version 4版本:
//Version 4
public class Single4{
	private static volatile Single4 instance;
	private Single4(){}
	public static Single4 getInstance(){
		if(instance==null){
			synchronized(Single4.class){
				if(instance==null){
					instance=new Single4();
				}
			}
		}
		return instance;
	}
}

volatile关键字的一个作用是禁止指令重排,把instance声明为volatile之后,对它的写操作就会有一个内存屏障(什么是内存屏障?),这样,在它的赋值完成之前,就不会调用读操作。
注意:volatile阻止不了instance=new Single4()这句话内部[1-2-3-4]的指令重排,而是保证了在一个写操作([1-2-3-4])完成之前,不会调用读操作if(instance==null)
也就彻底防止了Version 3中的问题发生。

饿汉式单例

由于类装载的过程时由类加载器ClassLoader来执行的,这个过程也是由JVM来保证同步的,所以这种方式先天就有一个优势–能够免于许多由多线程引起的问题。

  1. 1 饿汉式单例的实现方式
//饿汉式实现
public class SingleB{
	private static final SingleB INSTANCE = new SingleB();
	private SingleB(){}
	public static SingleB getInstance(){
		return INSTANCE;
	}
}

对于一个饿汉式单例的写法来说,它基本上是完美的。
所以他的缺点也只是饿汉式单例本身的缺点,由于INSTANCE的初始化是在类加载时进行的,而类的加载时由ClassLoader来做的,所以开发者本来对于它初始化的时机就很难去准确把握:

    • 1.可能由于初始化的太早,造成资源的浪费
    • 2.如果初始化本身依赖于一些其他数据,那么也就很难保证其他数据会在它初始化之前准备好。

当然,如果所需的单例占用的资源很少,并且也不依赖于其他数据,那么这种实现方式也是很好的。

  • 知识点:什么时候是类装载时?
    前面提到了单例在类装载时被实例化,那究竟什么是“类装载时”呢?
    不严格的说,大致有这么几个条件会触发一个类被加载:
    1. new一个对象时
    2. 使用反射创建它的实例时
    3. 子类被加载时,如果父类还没被加载,就先加载父类
    4. JVM启动时执行的主类会首先被加载
    5. 一些其他的实现方法:
      5.1 静态内部类
      《Effective Java》一书的第一版中推荐了一种写法:
//Effective Java 第一版推荐写法
public class Singleton{
	private static class SingletonHolder{
		private static final Singleton INSTANCE=new Singleton();
	}
	private Singleton(){}
	public static final Singleton getInstance(){
		return SingletonHolder.INSTANCE;
	}
}
  • 这种写法非常巧妙:

    • 对于内部类SingletonHolder,它是一个饿汉式的单例实现,在SingletonHolder初始化的时候ClassLoader来保证同步,使INSTANCE是一个真·单例。
    • 同时,由于SingletonHolder是一个内部类,只在外部类的Singleton的getInstance()中被使用,所以它被加载的时机也就是在getInstance()方法第一次被调用的时候。
    • 他利用了ClassLoader来保证了同步,同时又能让开发者控制类加载的时机。从内部看是一个饿汉式的单例,但是从外部来看,又的确是懒汉式的实现。

    5.2 枚举

//Effective Java 第二版推荐写法
public enum SingleInstance{
	INSTANCE;
	public void fun1(){
		// do something
	}
}

//使用
SingleInstance.INSTANCE.fun1();
创建枚举实例的过程是线程安全的,在功能上与共有域方法相近,但是它更简介,无偿的提供了序列化机制,绝对防止对此实例化,即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

[微信紅包]恭喜发財,大吉大利!

你的鼓励将是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值