文章目录
什么是单例模式?
在解释如何在高并发环境下如何保证单例模式的线程安全之前,先简单解释一下单例的概念。单例模式是为确保一个类只有一个实例,并为整个系统提供一个全局访问点的一种模式方法。
单例的特点:
- 在任何情况下,单例类永远只有一个实例存在;
- 单例需要有能力为整个系统提供这一唯一实例;
鉴于单例的特点,单例对象通常作为程序中的存放配置信息的载体,因为它能保证其他对象读到一致的信息。例如在某个服务器程序中,该服务器的配置信息可能存放在文件中,这些配置数据由某个单例对象统一读取,服务进程中的其他对象如果要获取这些配置信息,只需访问该单例对象即可。这种方式极大地简化了在复杂环境下,尤其是多线程环境下的配置管理,但是随着应用场景的不同,也可能带来一些同步问题。
单例模式的几种实现方式
1.立即加载 / “饿汉模式”
立即加载 / “饿汉模式”:☞使用类的时候就已经将对象创建完毕。
单例类:
package com.kiger.study._05_singleInstance.singlein_0;
public class MyObject {
private static MyObject myObject = new MyObject();
private MyObject() {}
public static MyObject getInstance() {
return myObject;
}
}
线程类
package com.kiger.study._05_singleInstance.singlein_0;
public class MyThread extends Thread {
@Override
public void run() {
System.out.println(MyObject.getInstance().hashCode());
}
}
调用线程测试类
package com.kiger.study._05_singleInstance.singlein_0;
public class Run {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.start();
t2.start();
t3.start();
}
}
运行结果:
控制台打印的hashCode是同一个值,说明对象是同一个,实现了饿汉式单例设计模式。
此版本的为立即加载,缺点是不能有其他的实例变量,因为getInstance()方法没有同步,所以可能出现线程安全问题。但由于我们这次讨论的是单例模式,一个类只能有一个实例,所以“饿汉”式单例可以认为是线程安全 。
2.延迟加载 / “懒汉模式”
延迟加载 / “懒汉模式”:☞在调用getInstance()方法时实例才被创建,常见的方法就是在getInstance()方法中进行new实例化。
缺点
修改一下饿汉式单例类:
package com.kiger.study._05_singleInstance.singleton_1;
public class MyObject {
private static MyObject myObject;
private MyObject() {}
public static MyObject getInstance() {
try {
if (myObject == null) {
// 添加延时,可以让多个线程进入该判断块,
// 从而创建多个对象,来演示延时加载的线程安全问题
Thread.sleep(1000);
myObject = new MyObject();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
运行结果:
控制台打印出了3种hashCode,说明创建出了3个对象,并没有实现单例。如果是单线程的情况,此方式可以使用,但如果是在多线程环境下,这就是"错误的单例模式"。
解决方案:
①对getInstance()方法加锁
既然多个线程可以同时调用getInstance()方法,首先想到的是对getInstance()方法加锁 ,利用synchronsized关键字,继续修改代码如下:
package com.kiger.study._05_singleInstance.singleton_2;
public class MyObject {
private static MyObject myObject;
private MyObject() {}
synchronized public static MyObject getInstance() {
try {
if (myObject == null) {
// 添加延时,可以让多个线程进入该判断块,
// 从而创建多个对象,来演示延时加载的线程安全问题
Thread.sleep(2000);
myObject = new MyObject();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
运行结果:
控制台打印出的hashCode值是一致的,说明是一个对象,实现了单例。但是在高并发且要求响应速度快的业务场景下,如果响应速度慢用户体验度就会下降,这是不允许的。上述这种实现方式的运行效率非常低下。getInstance()是同步运行的,下一个线程想要取得对象,则必须等上一个线程释放锁之后,才可以继续执行。
②尝试synchronized代码块提高效率?
同步方法是对方法的整体进行持锁,改成同步代码块会不会效率有提升呢?继续修改代码:
package com.kiger.study._05_singleInstance.singleton_3;
public class MyObject {
private static MyObject myObject;
private MyObject() {}
public static MyObject getInstance() {
try {
synchronized (MyObject.class) {
if (myObject == null) {
// 添加延时,可以让多个线程进入该判断块,
// 从而创建多个对象,来演示延时加载的线程安全问题
Thread.sleep(2000);
myObject = new MyObject();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
运行结果:
静态方法中的同步代码块的锁对应为 类.class
从代码可以看出,其实和同步方法没有什么较大的改进,其实效率并没有得到提高。
③针对某些重要代码进行单独同步?
其实我们发现,我们仅仅是想创建一个实例对象。而只有myObject为null时,才会去new 实例对象。我们是不是可以只针对new 实例对象这个模块进行单独的同步,而其他的代码不需要同步呢?答案是可以的。继续修改代码:
package com.kiger.study._05_singleInstance.singleton_3;
public class MyObject {
private static MyObject myObject;
private MyObject() {}
public static MyObject getInstance() {
try {
if (myObject == null) {
// 添加延时,可以让多个线程进入该判断块,
// 从而创建多个对象,来演示延时加载的线程安全问题
Thread.sleep(2000);
synchronized (MyObject.class) {
myObject = new MyObject();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
运行结果:
出现上述结果的原因:假设三个线程同时进入到getInstance()方法,同时判断myObject为null,那么就会都去准备初始化对象,由于锁住了new实例对象语句,所以三个线程同步依次创建了一个单独的singleton对象,故得到三种hashCode。
④DCL(Double-check lock)双重检查锁机制
其实上述问题在于,同步锁之前进行了一次对象判断是否为空之后,后续就再未进行对象为空判断,导致线程并发进入new实例对象模块,虽然是同步过程,但是却依次创建了一个对象。我们其实只要在同步模块内new实例之前再进行一次判断singleton是否为空即可避免该问题发生,俗称DCL双检查锁机制。
修改代码如下:
package com.kiger.study._05_singleInstance.singleton_4;
public class MyObject {
private volatile static MyObject myObject;
private MyObject() {}
public static MyObject getInstance() {
try {
if (myObject == null) {
// 添加延时,可以让多个线程进入该判断块,
// 从而创建多个对象,来演示延时加载的线程安全问题
Thread.sleep(2000);
synchronized (MyObject.class) {
if (myObject == null) {
myObject = new MyObject();
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return myObject;
}
}
运行结果:
注意:如果相关变量没有使用volatile修饰会出现空指针异常,具体查看java虚拟机指令重排序和内存屏障相关知识。
3.static代码块
之所以说静态代码块是饿汉式单例的变种,是因为静态代码块中的代码在主动使用类的时候就已经执行了,和饿汉式单例的原理一样。
注意:静态代码块是在类被主动使用时才会执行
具体参考该博客:Java误区: 静态代码块,会在类被加载时自动执行?
继续修改代码如下:
package com.kiger.study._05_singleInstance.singleton_5;
public class MyObject {
private static MyObject instance;
static {
instance = new MyObject();
}
private MyObject() {}
public static MyObject getInstance() {
return instance;
}
}
运行结果:
在调用TestSingleton类的时候,类加载的顺序是:静态变量->静态代码块->静态方法,所以在调用静态方法时,静态变量singleton已经实例化了,而且只会初始化一次,所以是线程安全的 。
4.静态内置类(推荐)
可能会有人问:java中静态代码块是线程安全的,我们在做线程安全的单例模式的时候为什么还要使用静态内部类来实现了?
解答:
①静态内部类是你调用它的时候才会被加载执行
②静态代码块,使用外部类时就会加载执行
③相比之下节省了内存
具体可以参考:静态内部类何时初始化
package com.kiger.study._05_singleInstance.singleton_6;
public class MyObject {
private static class MyObjectHandler {
private static MyObject myObject = new MyObject();
}
private MyObject() {}
public static MyObject getInstance() {
return MyObjectHandler.myObject;
}
}
运行结果:
5.enum枚举数据类型(推荐)
在介绍使用枚举类实现线程安全的单例模式之前,先简单介绍一下枚举类的特性:
- enum和 class,interface的地位一样;
- enum定义的枚举类默认继承了java.lang.Enum,而不是继承Object类。枚举类可以实现一个或多个接口;
枚举类的所有实例都必须放在第一行展示,不需使用new 关键字,不需显式调用构造器。自动添加public static final修饰,这一点保证了它可以以类型安全的形式来表示;
- 使用enum定义、非抽象的枚举类默认使用final修饰,不可以被继承;
枚举类的构造器只能是私有的(此点性质满足了实现单例模式的条件);
- 枚举中可以和java类一样定义方法;
那我们什么时候使用枚举类比较合适呢?
有的时候一个类的对象是有限且固定的,这种情况下我们使用枚举类就比较方便。
首先,在枚举中我们明确了构造方法限制为私有,在我们访问枚举实例时会执行构造方法,同时每个枚举实例都是static final类型的,也就表明只能被实例化一次。在调用构造方法时,我们的单例被实例化。 也就是说,因为enum中的实例被保证只会被实例化一次。
我们使用枚举类模拟数据库连接代码如下:
package com.kiger.study._05_singleInstance.singleton_8;
public enum DataSourceEnum {
DATASOURCE;
class DBConnection {
//其实我们可以直接在枚举类的构造函数中做一些初始化,
//这里只是为了方便显示表明初始化了一个类:声明了一个内部类
//内部类也可以做一些动作:表现为网络连接,数据库连接,线程池
}
private DBConnection connection = null;
private DataSourceEnum() {
connection = new DBConnection();
}
public DBConnection getConnection() {
return connection;
}
}
测试代码如下:
package com.kiger.study._05_singleInstance.singleton_8;
import com.kiger.study._05_singleInstance.singleton_8.DataSourceEnum.DBConnection;
public class Run {
public static void main(String[] args) {
DBConnection con1 = DataSourceEnum.DATASOURCE.getConnection();
DBConnection con2 = DataSourceEnum.DATASOURCE.getConnection();
System.out.println(con1 == con2);
}
}
测试结果为:true
结语
除了以上方式之外,还有通过序列化与反序列化方式实现单例;而且还可以通过类似注册表的方式来动态加载某些类的单例,在此就不再进行介绍了。随着现在Java技术的更新,在开发当中使用后两种方式来实现单例模式也占据了绝大部分,懒汉式的单例模式是不推荐的。