一、什么是单例?
单例模式(Singleon),是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。
二、单例的种类有哪些?之间有什么区别?
1、懒汉式与饿汉式区别:
1.1懒汉式默认不会实例化,外部什么时候调用什么时候new。饿汉式在类加载的时候就实例化,并且创建单例对象。
1.2、懒汉式是延时加载,在需要的时候才创建对象,而饿汉式是在虚拟机启动的时候就会创建。
1.3、懒汉式在多线程中是线程不安全的,而饿汉式是不存在多线程安全问题的。
2、懒汉模式:
2.1、创建一个最简单的懒汉式单例【方法1】
//最简单的一种懒汉式单例模式
public class SingleTest {
//定义一个私有类变量来存放单例,私有的目的是指外部无法直接获取这个变量,而要使用提供的公共方法来获取。
private static SingleTest instance;
public SingleTest () {}
//定义一个公共的公开的方法来返回该类的实例。
public static SingleTest getInstance() {
//第一次访问去创建实例
if (instance == null) {
instance = new SingleTest ();
}
//否则直接返回实例
return instance;
}
}
注意:
上面是最简单的一种懒汉式单例,但是这样写会有一些问题需要改进:
1、构造器为public,这样外部可以调用,要改为私有的private,防止外部调用。
2、这种方式在多线程下是不安全的。 额。。 好吧!那么我们接着改进完善,让它越来越完美。
2.2、创建一个线程安全的懒汉式单例【方法2】
// 把构造函数设置为私有,并使用synchronized修饰词来修饰方法,保证线程同步从而达到线程安全。
public class SingleTest {
private static SingleTest instance;
//定义私有构造器,表示只在类内部使用,只能在内部创建。
private SingleTest () {}
//使用synchronized修饰达到线程同步从而保证线程安全。
public static synchronized SingleTest getInstance() {
if (instance == null) {
instance = new SingleTest ();
}
return instance;
}
}
注意:
以上方法修改之后解决了第一次创建遗留的两个问题:
1、修改构造函数为private,避免外部调用。
2、使用synchronized修饰方法(也就是获取对象的锁),此时就避免多线程同时访问同一个对象,保证线程同步,从而达到线程安全。
额。。看上去已经好了,但是还有问题
举个例子吧:
现在有A、B、C三个线程来访问此对象创建单例。由于线程执行顺序是由CPU心情决定的,所以不能保证谁先先访问到。
假设:
1、A线程先创建单例,getInstance()使用synchronized同步锁,这个时候A线程就获得了getInstance()的锁。(synchronized同步锁不了解的同学可以先学习下多线程中的线程同步)。
2、此时B、C线程也被CPU调度来创建单例。但是这个时候A线程已经获取了getInstance()的锁,那么B、C将无法调用getInstance()方法。B、C线程就会一直等待,直到A线程执行完毕才可以访问。
3、这样的话就造成了线程阻塞,影响性能。
2.3、让我们改变同步锁的位置试一试。。【方法3】
// 改变了synchronizaed同步锁的位置,双重检查判断
public class SingleTest {
private static SingleTest instance;
private SingleTest () {}
public static SingleTest getInstance() {
//先进行实例判断,只有当不存在的时候才去创建实例。
//这样就解决了99%的已经获取实例但是还要去获取同步锁的问题。
if (instance == null) {
//用synchronized 同步代码块
//注意:此处不能用this,因为this不能和static同用。
synchronized (SingleTest.class) {
if (instance == null) {
instance = new SingleTest();
}
}
}
//如果已经获取实例,那么直接返回就可以,不必要去获取同步锁,也就不会影响其他线程,造成线程阻塞问题。
return instance;
}
}
感觉是不是已经不错了,也解决了上面写法的效率问题,为什么说解决了效率问题呢?不是解决是提升效率哈哈。。。那么我们来分析一下吧:
1、首先我们单例的定义和含义:“单例对象的类必须保证只有一个实例存在”。那么我们用单例其实就是为了限制一个类不能多个实例,也就是说只能被创建一次,明白这点之后我们来看【方法2】的代码。。。
2、【方法2】A、B、C三个线程来创建实例,只有第一个访问的线程才会走:if(instance)下面的代码 instance = new SingleTest();来创建实例。否则都会直接返回这个实例 return instance;。100次调用,1次new,99次直接return。创建实例的概率为1%,获取实例的概率为99%。
3、但是我们如果要像【方法2】中用synchronized同步锁来修饰getInstance()方法,那么不管是需要创建实例还是获取实例都会只能被一个线程调用,那么性能肯定会浪费很多,其实我们只关心1%创建实例。99%都是浪费性能。
4、先在判断if(instance)==null,如果是,那代表第一次来获取实例,接着我们把同步锁加在创建实例的代码块上,这样就减少了获取线程的性能消耗,只有在需要创建的时候才会加同步锁,才可能会造成线程阻塞,只有1%的情况会影响性能,而不是【方法二】100%会影响。所以性能会得到提升。否则直接返回实例 return instance。
OK,好像这样写既提升了性能,还保证了线程安全,已经很完善了,真的是这样吗?真的是安全了吗?跟着我继续看,看一看还能不能继续改进,哈哈!
2.4、让我们继续改进吧!【方法4】
//使用volatile修饰变量,具有原子性、可见性和防止指令重排的特性。
public class SingleTest {
private static volatile SingleTest instance;
private SingleTest() {}
public static SingleTest getInstance() {
if (instance == null) {
synchronized (SingleTest.class) {
if (instance == null) {
instance = new SingleTest();
}
}
}
return instance;
}
}
【方法4】中我们只在变量上加了一个volatile修饰词,那么为什么要这么做呢?这样做有什么好处?我们接着分析:
1、我们了解下原子操作、指令重排这两个知识点。
2、什么是原子操作呢?
简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度被打断的操作。
小例子:
a=1;//赋值,把值1赋给a,这是原子操作。
假如m原先的值为1,那么对于这个操作,要么执行成功m变成了6,要么是没执行m还是1,而不会出现诸如m=3这种中间态——即使是在并发的线程中。
int a=1;//先声明一个变量,再把值赋给这个变量,这不是原子操作。
因为这个操作对于计算机来说是两步:
1、声明变量 int a;
2、进行赋值 a=1;
这样的话单线程情况下是没有问题的,那么在多线程下就会出现问题,因为多线程的执行顺序是不确定的。接下来大家需要了解一个新的知识点:指令重排。
3、什么是指令重排呢?
简单来说,就是计算机为了提高执行效率,会做的一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。
4、上述案例
第一步:int a;
第二步:a=1;
计算机在运行的时候不一定会按照正常的顺序1——>2步执行,它可能会是2——>1,由于计算机的指令重排的特性,无论他们的执行顺序是什么都不会对运行结果造成影响。
5、接下来我们再看我们【方法4】创建单例方法。
主要在于singleton = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情
1、内存区域分配内存给singleton
2、 调用 Singleton 的构造函数来初始化成员变量,形成实例
3、 将singleton对象指向分配的内存空间(执行完这步 singleton才是非 null 了)
分析完之后,是不是稍微明白了点,为什么说方法【4】及时加了同步锁也不是线程安全的。
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第1步和第3步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程B抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程B会直接返回 instance,然后使用,然后顺理成章地报错。
为了保证原子性和禁止指令重排,所以我们valutile修饰词。从而解决小概率线程不安全事件。
以上的话基本就是懒汉式单例模式的几种写法以及写法的深入了解。大家多多思考。
3、饿汉模式
3.1、我们来创建一个饿汉式的单例模式【方法1】
//饿汉式单例实现
public class SingleTest {
private static final SingleTest INSTANCE = new SingleTest();
private SingleTest() {}
public static SingleTest getInstance() {
return INSTANCE;
}
}
饿汉式的写法这样就可以了,那么我们来说下饿汉式的缺点吧。
1、所以它的缺点也就只是饿汉式单例本身的缺点所在了——由于INSTANCE的初始化是在类加载时进行的,而类的加载是由ClassLoader来做的,所以开发者本来对于它初始化的时机就很难去准确把握。
2、过早的就会被实例化,可能会造成资源浪费。
3、如果初始化本身依赖于一些其他数据,那么也就很难保证其他数据会在它初始化之前准备好。
3.2让我们继续改进一下【方法2】
public class Singleton {
//创建一个内部静态类
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
通过内部静态类来实现对初始化的控制。
1、对于内部类SingletonHolder,它是一个饿汉式的单例实现,在SingletonHolder初始化的时候会由ClassLoader来保证同步,使INSTANCE是一个真·单例。
2、由于SingletonHolder是一个内部类,只在外部类的Singleton的getInstance()中被使用,所以它被加载的时机也就是在getInstance()方法第一次被调用的时候。
总结:它利用了ClassLoader来保证了同步,同时又能让开发者控制类加载的时机。从内部看是一个饿汉式的单例,但是从外部看来,又的确是懒汉式的实现。
4、使用枚举创建单例【最好的方式】
4.1、枚举创建单例
//枚举单例实现
public enum SingleEnum {
INSTANCE;
public void getInstance() {
System.out.print("do something");
}
}
public static void main(String[] args) {
SingleEnum.INSTANCE.getInstance();
}
这样写的优点:
1、写法很简洁。
2、JVM保证线程安全。
3、防止反序列化和反射的破坏。
三、单例有哪些优点和缺点呢?
优点:
1、在内存里只有一个实例,减少了内存的开销,避免频繁的创建和销毁实例。
2、避免对资源的多重占用(比如写文件操作),提升了性能。
3、提供了对唯一实例的受控访问。
缺点:
1、不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。
2、由于单利模式中没有抽象层,因此单例类的扩展有很大的困难。
3、从设计原则方面说,单例类的职责过重,在一定程度上违背了“单一职责原则”。
4、滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失。
四、单例模式的使用场景:
1、WEB 中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。
2、创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。