大家好,我是程序员牛肉。
最近在刷牛客的时候,发现现在的面试官出笔试题都已经不局限在Hot100,大把大把的同学在面试的时候被考到了与设计模式相关的笔试题。
而在其中,手撕双重检查锁定下的单例模式出现的频率最高。
因此我们用这篇文章来介绍一下设计模式中的单例模式,从最基础的什么是单例模式一路讲到手撕双重校验锁下的单例模式。让你真正理解为什么我们在使用单例模式的时候要使用双重校验锁。
先说说什么是单例模式吧:
在我们的项目开发中,对于一些类我们全局只要一个就足够了。在这种情况下,我们需要保证在全局中这个类不会被重复创建,始终只有一个。无论我们何时何地访问它,得到的都是同一个实例。
而这种设计模式就是单例模式。他在Java中是一个很常见的设计思路,我们常见的Drivemanager其实就是单例模式:
而单例模式的设计从整体上来讲分为两种类型:懒汉式和饿汉式。
饿汉式:
饿汉式单例模式在类加载时就创建实例。这种方式的特点是类加载时立即初始化实例。由于他在类加载的时候就去初始化实例,因此天生就是线程安全的。
[jvm对于类的加载会加类锁,所以多线程的情况下也可以保证实例化阶段是一个线程在执行]
饿汉式从具体的代码层面来讲,可以分为静态变量创建和静态代码块创建:
/**
* 饿汉式 静态变量类型
*/
public class HungrrySingleton {
//私有构造方法
private HungrrySingleton(){};
//全局静态变量
private static HungrrySingleton instance = new HungrrySingleton();
//提供方法访问对应的单例对象
public static HungrrySingleton getInstance(){
return instance;
}
}
/**
* 饿汉式 静态代码块类型
*/
public class HungrrySingletonStaticblock{
//私有构造方法
private HungrrySingletonStaticblock(){}
//在成员位置创建该类
private static HungrrySingletonStaticblock instance;
static {
instance = new HungrrySingletonStaticblock();
}
public static HungrrySingletonStaticblock getInstance(){
return instance;
}
}
懒汉式:
懒汉式单例模式并不会在当前类创建的时候就去创建实例,而是第一次使用的时候才会去创建实例,这种思路可以在一定程度上减少空间的浪费。
代价是由于没有把实例的创建放到类加载阶段,因此单纯的懒汉式单例模式不是线程安全的。
/**
* 懒汉式(存在线程不安全)
*/
public class lazzySingleton {
//私有构造方法
private lazzySingleton(){}
//声明对象
private static lazzySingleton instance;
//对外提供访问方式
public static lazzySingleton getInstance(){
if (instance == null){
instance = new lazzySingleton();
}
return instance;
}
}
由于在这一过程中,我们并没有对getInstance方法加锁,因此可能会出现线程安全性的问题。
假设我们有两个线程:线程A和线程B
线程 A 和 线程 B 同时调用 getInstance()。两个线程都看到 instance 为 null,因此都通过了 if (instance == null) 检查。两个线程都进入了 if 块,分别执行 instance = new lazzySingleton();,导致创建了两个不同的 lazzySingleton 实例。
怎么解决这个问题?太好解决了,直接给getInstance方法加锁就完事了。
于是我们的懒汉式单例模式进入2.0阶段:
class lazzySingletonSync{
//私有构造方法
private lazzySingletonSync(){};
//声明对象
private static lazzySingletonSync instance;
//提供方法访问对应的单例对象
public static synchronized lazzySingletonSync getInstance(){
if (instance == null){
instance = new lazzySingletonSync();
}
return instance;
}}
可是如此简单粗暴的加锁会带来一个问题:明明只有第一次创建这个实例的时候需要加锁,可是我们每一次进入这个方法都要去争夺这个锁。
如果你只是使用这个实例的话,加锁干什么?这不是白白耗费性能嘛。于是我们进行了以下优化:只在第一次创建这个实例的时候加锁。
于是我们迎来了懒汉式的3.0版本:
/**
* 懒汉式(线程安全 双重校验锁)
*/
class lazzySingletonDoubleCheck{
//私有构造方法
private lazzySingletonDoubleCheck(){
}
//声明对象
private static volatile lazzySingletonDoubleCheck instance;
//提供方法访问对应的单例对象
public static lazzySingletonDoubleCheck getInstance(){
if (instance == null)//第一重判断
{
synchronized (lazzySingletonDoubleCheck.class){
if (instance == null)//第二重判断
{
instance = new lazzySingletonDoubleCheck();
}
}
}
return instance;
}}
在这里解释一下为什么要使用volatile关键字:
现代处理器和编译器为了优化性能,可能会对指令执行顺序进行优化,即指令重排序。在创建对象的过程中,new Singleton() 不是一个原子操作,实际上可以分为三个步骤:
-
为对象分配内存。
-
调用构造函数,初始化对象。
-
将对象引用赋值给变量。
由于指令重排序,步骤 2 和步骤 3 可能会被交换。这样,其他线程可能会在对象尚未完全初始化时看到一个非空引用,从而导致程序出现不可预测的行为。
[当一个线程执行到步骤 2 时,instance 已经指向分配的内存空间,但对象还没有被完全初始化。此时,如果另一个线程调用 getInstance(),它会看到 instance 不为 null,并返回这个尚未完全初始化的对象。]
当你能够理解我们上述讲的东西,你也就理解了什么是”双重检查锁定单例模式“。
而除了这种基于手动加锁的形式来实现单例模式之外,我们其实还有其他更好玩的手段:
1.基于静态内部类来构造单例模式
/**
* 懒汉式 (静态内部类方式)
*/
class lazzySingletonInnerClass{
//私有构造方法
private lazzySingletonInnerClass(){}
//静态内部类
private static class InnerClass{
private static final lazzySingletonInnerClass instance = new lazzySingletonInnerClass();
}
//提供方法访问对应的单例对象
public static lazzySingletonInnerClass getInstance(){
return InnerClass.instance;
}
}
静态内部类有两个特点:
-
延迟加载:静态内部类在外部类加载时并不会被立即加载,只有在需要时才加载,从而实现延迟加载。
-
线程安全:JVM 在加载类时会保证类加载的线程安全性,因此静态内部类方案天然地是线程安全的。
多吓人,静态内部类竟然完美符合懒汉式单例模式的要求。而下面这个玩的更花。
2.基于枚举类实现单例模式
**
* 枚举类。
*/
enum EnumSingleton{
INSTANCE;
}
我只能称这个设计为逆天设计。枚举类型的单例实现非常简洁,只需定义一个枚举类型,并声明一个枚举常量即可。并且不需要显式地编写线程安全的代码,因为枚举类型本身是线程安全的。
其实关于单例模式的创建方式也就讲完了,我们最后讲一下如何破坏单例模式。
其实也就两种方法:序列化和反射。序列化我就不讲了,没啥意思。大家可以在网上自己搜一搜,这篇我就只讲反射了。
Java的反射真的太邪恶了。你就算把构造模式设置为私有又能怎么样?我直接用反射修改你的可见性之后调用你。
import java.lang.reflect.Constructor;
public class ReflectionTest {
public static void main(String[] args) {
try {
// 获取Singleton类的构造方法
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
// 设置为可访问
constructor.setAccessible(true);
// 创建新的实例
Singleton instance1 = constructor.newInstance();
Singleton instance2 = constructor.newInstance();
System.out.println(instance1);
System.out.println(instance2);
System.out.println(instance1 == instance2); // 输出 false,表示是不同的实例
} catch (Exception e) {
e.printStackTrace();
}
}
}
那我们要如何预防反射来破坏单例模式呢?网上目前的主流手段是加标志位来标识当前实例是否有被创建过。
public class Singleton {
private static Singleton instance;
private static boolean instanceCreated = false;
private Singleton() {
if (instanceCreated) {
throw new RuntimeException("Singleton instance already created!");
}
instanceCreated = true;
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
在我看来这个设计有点脱裤子放屁。我管你什么标志位不标志位的,你看我用反射修改不修改你就完事了。
标识位记录当前是否有被初始化是吧?我直接用反射给你赋值False。
public class Singleton {
// 获取 Singleton 类的 Class 对象
Class<Singleton> singletonClass = Singleton.class;
// 获取 instanceCreated 字段
Field instanceCreatedField = singletonClass.getDeclaredField("instanceCreated");
// 设置字段为可访问
instanceCreatedField.setAccessible(true);
// 将 instanceCreated 设置为 false
nstanceCreatedField.set(null, false);
}
所以我个人不建议你在面试的时候用“加标志位”来回答面试官的“如何防止反射破坏单例模式”。
那到底要怎么做才能防止反射破坏单例模式呢?
简单!还记得我们前面提到的基于枚举实现单例模式嘛?你试试用反射给我修改一个枚举看看:
/**
* 枚举类。
*/
enum EnumSingleton{
INSTANCE;
}
那话说到这里了,枚举到底是怎么防止邪恶的Java反射机制来修改的?我们看一看源码:
点开Enum的源码,其实就可以发现一个知识点:Enum没有无参构造函数。
因此应该用反射手动的去获取对应的带参数的构造方法:
Constructor<Singleton> constructor =
singletonClass.getDeclaredConstructor(String.class, int.class);
但其实这样还不行,当我们使用构造器来构造新对象的时候,要使用到newInstance方法:
Singleton instance1 = constructor.newInstance();
让我们点到这个newInstance源码中去看一看,会发现这个方法又调用了newInstanceWithCaller方法:
初见端倪了,这里有一个方法进行了判断,之后抛出了一个异常是:“不能反射枚举对象”。
那么这个16384是什么呢?为什么要和当前类的Modifiers搞&运算?Modifiers又是什么?
在Java中,Modifiers 是一个与反射(Reflection)相关的主题,涉及到类、接口、方法和字段的访问修饰符。这些修饰符定义了代码的访问级别和行为特性。更多的详情可以看java.lang.reflect.Modifier这个包。
在这个包中我们找到了答案:
原来枚举类的Modifiers值就是16384。也就是说那段代码的意思是判断当前对象的Modifiers值是不是16384,如果是的话就说明当前类是个枚举类,直接抛出异常,这也是为什么不能对枚举类进行反射的直接原因。
今天关于“手撕双重检查锁定下的单例模式”就介绍到这里了,希望我的文章可以帮到你。后续我也会陆续把所有的设计模式介绍完。
关于单例模式或者设计模式你有什么想说的嘛?欢迎在评论区留言。
关注我,带你了解更多技术干货。