单例模式是创建型模式的一种,他提供了创建对象的最佳模式。此模式涉及到一个单一的类,该类负责创建自己的实例,并确保只有单个对象被创建。此类提供了访问其唯一对象的方式,可以直接访问,可以不用实例化该类的对象。
注意:
1. 单例类只有一个实例;
2. 单例类必须自己创建自己的实例类;
3. 单例类必须给所有其他对象开放(即提供该实例类)。
如何保证一个类在内存中只能有一个实例呢?
1. 构造私有;
2. 使用私有静态成员变量初始化本身对象;
3. 对外提供静态公共方法获取本身对象。
介绍
- 有何目的:保证一个类只有一个实例,并提供一个访问他的全局访问点。
- 解决什么:一个全局使用的类频繁的创建与销毁。
- 如何解决:判断系统是否有这个实例,有则返回,无则创建。
- 何时使用:想控制创建实例数目,节省系统资源。
- 关键代码:私有的构造函数。
- 实际应用:例如要求生成唯一序列号,网站访问人数等。
- 优点:减少内存开销及资源的占用。
- 缺点:没有接口,不能继承,与单一职责原则冲突(一个类应只关心内部逻辑,不管外部如何实例化)。
实现
提供了两种实现模式:饿汉式与懒汉式(延迟加载)。
一、饿汉式实现
例如:
public class Student {
// 2:成员变量初始化本身对象
//static修饰的变量在new对象时,不存在多线程问题
private static Student student = new Student();
// 构造私有
private Student() {}
// 3:对外提供公共方法获取对象
public static Student getSingletonInstance() {
return student;
}
public void sayHello(String name) {
System.out.println("hello," + name);
}
}
饿汉式单例模式是线程安全的。
static修饰的变量在new对象时,不存在多线程问题。JVM通过类加载器去加载一个类的时候,默认针对该流程是加锁的,也就是线程安全的。类加载的时候,会初始化类的静态成员,其实就是调用clinit()方法。
如何判断存在线程安全问题?
- 是否存在共享数据(存储数据的成员变量);
- 是否存在多线程;
- 是否是非原子性操作。
二、懒汉式实现(延迟加载)
疑问:饿汉式既然是线程安全的,为什么还要用懒汉式实现方式?
如果存在很多对象,需要单例模式去管理。而有的对象不需要创建,如果都使用饿汉式去创建,就会造成资源的浪费。
懒汉式有三种方式:
- 双重检查锁方式
- 静态内部类方式
- 枚举方式
懒汉式实现的思想:需要对象的时候,再去创建对象。
懒汉式实现的步骤:
- 构造私有;
- 定义私有静态成员变量,但先不初始化;
- 定义公开静态方法,获取本身对象(有对象就返回已有对象,没有对象,再去创建)。
2.1 静态内部类方式:
public class Student {
private Student() {}
/*
* 此处使用一个内部类来维护单例 JVM在类加载的时候,是互斥的,所以可以由此保证线程安全问题
*/
private static class SingletonFactory {
private static Student student = new Student();
}
/* 获取实例 */
public static Student getSingletonInstance() {
return SingletonFactory.student;
}
}
2.2 双重检查锁方式演变
public class Student {
//1:构造私有
private Student(){}
//2:定义私有静态成员变量,先不初始化
private static Student student = null;
//3:定义公开静态方法,获取本身对象
public static Student getSingletonInstance(){
//没有对象,再去创建
if (student == null) {
student = new Student();
}
//有对象就返回已有对象
return student;
}
}
这种方式基本满足懒汉式基本要求,但却存在线程安全的问题。如何解决线程安全的问题 ?
首先想到对getSingletonInstance方法加synchronized关键字。
如下:
public class Student {
private Student(){}
private static Student student = null;
// 此处考验对synchronized知识点的掌握情况
public static synchronized Student getSingletonInstance(){
if (student == null) {
student = new Student();
}
return student;
}
}
synchronized关键字作用
- 确保线程互斥地访问同步代码
- 保证共享变量的修改能够及时可见
- 有效解决重排序问题
synchronized关键字锁住的是这个对象,这样的用法,在性能上会有所下降。因为每次调用getSingletonInstance()方法,都要对对象上锁。事实上,只有在第一次创建对象的时候加锁,之后就不需要了。所以需要在此基础上做出优化。
将synchronized关键字加在方法体内部
如下:
public class Student {
private static Student student = null;
private Student() {}
public static Student getSingletonInstance() {
if (student == null) {
// 采用这种方式,对于对象的选择会有问题
// JVM优化机制:先分配内存空间,再初始化
synchronized (Student.class) {
if (student == null) {
student = new Student();
//student.setName("ss")
//new ---- 开辟JVM中堆空间---产生堆内存地址保存到栈内存的student引用中---创建对象
// 存在的问题:指令重排
}
}
}
return student;
}
//student.getName();
}
这样做似乎解决了之前性能的问题,将synchronized关键字加在了内部,也就是说当调用的时候是不需要加锁的,只有在instance为null,并创建对象的时候才需要加锁,性能有一定的提升。
但是如果发生以下情况,就会出现问题。
-
在Java指令中创建对象和赋值操作是分开进行的,也就是说instance = new Singleton();语句是分两步执行的。
JVM中的对象创建过程是什么流程?
Student student = new Student();// 高级语言
a)new关键字会触发Student类的类加载(如果已加载,则此步骤作废)
b)根据Class对象中的信息,去开辟相应大小的内存空间
c)初始化Student对象,就是完成成员变量的初始化操作(到这一步,我们才能说该对象是可用的)
d)将开辟出来的内存空间地址,赋值给栈空间的变量student以上步骤,其实都是通过字节码指令去完成的(物理机器直接操作的都是CPU指令(原子性其实是相对我们CPU指令来说的))
指令重排序(JIT即时编译器优化)
有序性:代码执行时有序的。
如果两行代码交换不会影响最终的执行结果,那么JIT即时编译器就会根据情况去进行指令重排序。可见如果程序之间没有依赖性,指令就可能发生重排(happend-before先行发生原则(六大原则))。
-
JVM并不保证这两个操作的先后顺序,也就是说有可能JVM会为新的Singleton实例分配空间,然后直接赋值给instance成员,然后再去初始化这个Singleton实例(分析创建对象的步骤中,c和d这两个步骤,有没有依赖性呢?答案是它们两者之间没有依赖性,那么就有可能发生指令重排序。也就是说有可能先执行d再执行c)。
-
这样就可能出错了,我们以A、B两个线程为例:
a)A、B线程同时进入了第一个if判断;
b)A首先进入synchronized块,由于instance为null,所以它执行instance = new Singleton();
c)由于JVM内部的优化机制(指令重排序),JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance成员(注意此时JVM没有开始初始化这个实例),然后A离开了synchronized块。
d)B进入synchronized块,由于instance此时不是null,因此它马上离开了synchronized块并将结果返回给调用该方法的程序。
e)此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。
所以程序还是有可能发生错误,其实程序在运行过程是很复杂的,从这点我们就可以看出,尤其是在写多线程环境下的程序更有难度,有挑战性。我们对该程序做进一步优化。
双重检查加锁
如下:
public class Student{
private volatile static Student student;
private Student() {
}
public static Student getSingletonInstance() {
if (student == null) {
synchronized (Student.class) {
if (student == null) {
student = new Student();
}
}
}
return student;
}
}
volatile关键字的作用:
- 禁止指令重排序
- 禁止使用CPU缓存
可见性
- 在CPU单核时代,线程1和线程2使用的是同一个CPU缓存,所以线程之间的数据是可见的。
- 在CPU多核时代,线程1在A核,线程2在B核,每个核都有自己的CPU缓存空间,如果线程1产生的数据缓存没有同步到线程2对应的CPU缓存,则会出现可见性问题。
CPU、内存、磁盘的处理速度不同。为了提供CPU使用率,硬件提供了在内存与CPU之间的高速缓存,即CPU缓存。
网友评论