首頁 >電腦教學 >電腦知識 >一個單例模式,沒必要這麼卷吧

一個單例模式,沒必要這麼卷吧

WBOY
WBOY轉載
2024-02-22 18:25:02488瀏覽

老貓的設計模式專欄已經偷偷發車了。不甘願做crud boy?看了幾遍的設計模式還記不住?那就不要刻意記了,跟上老貓的步伐,在一個個有趣的職場故事中領悟設計模式的精髓。還等什麼?趕快上車吧

將系統軟體比喻為江湖,設計原則就是OO程式設計師的武功心法,設計模式則是招式。光有心法還不夠,需要結合招式才行。心法和招式相輔相成,才能應付強敵(如「坑爹系統」),靈活應變,戰無不勝。

一個單例模式,沒必要這麼卷吧

#故事

之前讓小貓梳理的業務流程以及程式碼流程基本上已經梳理完畢【系統梳理大法&程式碼梳理大法】。從代碼側而言也搞清楚了系統臃腫的原因【違背設計原則】。小貓逐漸步入正軌,他決定從一些簡單的業務場景入手,開始著手優化系統程式碼。那麼什麼樣的業務程式碼,動了之後影響最小呢?小貓看了看,打算就從氾濫創建的線程池著手吧,他打算用單例模式做一次重構。

在小貓接手的系統中,執行緒池的創建通常是根據需要在具體的類別中直接實作多執行緒功能。因此,在許多service服務類別中都會發現創建線程池的踪跡。

寫在前面

為了解決上述小貓的問題,我們計劃使用單例模式來建立一個共享執行緒池執行器,並結合工廠模式根據業務類型進行分類管理。

接下來,我們就單例模式開始吧。

一個單例模式,沒必要這麼卷吧

#概要

單例模式定義

單例模式(Singleton)是一種設計模式,旨在確保系統中只有一個特定類別的實例,並提供一個全域存取點以便外部存取。這種模式的出現是為了控制系統中某個類別的實例數量,節省系統資源並方便存取。透過單例模式,可以有效管理系統中的物件實例,避免多次建立相同類型的對象,提高系統效能和資源利用率。單例模式的實作方式有多種,包括懶漢式、餓漢式、雙重檢查鎖定等。在軟體開發中,單例模式經常被用於需要唯一實例的場景,如配置資訊、日誌記錄、執行緒池等。透過單例模式,可以簡化系統架構,降低耦合度,提高程式碼的可維護性和可擴充性。

一個單例模式,沒必要這麼卷吧

#單例模式簡單示意圖

餓漢式單例模式

什麼叫做餓漢式單例?為了方便記憶,老貓是這麼理解的,餓漢給人的形象就是有食物就迫不及待去吃的形象。那麼餓漢式單例模式的圖像也就是當類別創建的時候就迫不及待地去創建單例對象,這種單例模式是絕對線程安全的,因為這種模式在尚未產生線程之前就已經創建了單例。

看一下範例,如下:

/**
 * 公众号:程序员老猫
 * 饿汉单例模式 
 */
public class HungrySingleton {

private static final HungrySingleton HUNGRY_SINGLETON = new HungrySingleton();

//构造函数私有化,保证不被new方式多次创建新对象
private HungrySingleton() {
}

public static HungrySingleton getInstance(){
return HUNGRY_SINGLETON;
}
}

我們來看看上述案例的優缺點:

  • 優點:線程安全,類別載入時完成初始化,取得物件的速度較快。
  • 缺點:由於類別載入的時候就完成了物件的創建,有的時候我們無需呼叫的情況下,物件已經存在,這樣的話就會造成記憶體浪費。

當前硬體和伺服器的發展,快於軟體的發展,另外的,微服務和集群化部署,大大降低了橫向擴展的門檻和成本,所以老貓覺得當前的內存其實是不值錢的,所以上述這種單例模式硬說其缺點有多嚴重其實也不然,個人覺得這種模式用在實際開發過程中其實是沒有問題的。

其實在我們日常使用的spring框架中,IOC容器本身就是一個餓漢式單例模式,spring啟動的時候就將對象加載到了內存中,這裡咱們不做展開,等到後續咱們梳理到spring源程式碼的時候再展開來說。

懶漢式單例模式

上述餓漢單例模式我們說它的缺點是浪費內存,因為其在類別加載的時候就創建了對象,那麼針對這種內存浪費的解決方案,我們就有了“懶漢模式”。對於這種類型的單例模式,老貓是這麼理解的,懶漢的定義給人的直覺感覺是懶惰、拖延。那麼對應的模式上來說,這種方案創建對象的方法也是在程式使用對象前,先判斷該對像是否已經實例化(判空),若已實例化直接返回該類對象,否則則先執行實例化操作。

看一下範例,如下:

/**
 * 公众号:程序员老猫
 * 懒汉式单例模式
 */
public class LazySingleton {
private LazySingleton() {
}

private static LazySingleton lazySingleton = null;
public static LazySingleton getInstance() {
if (lazySingleton == null) {
lazySingleton =new LazySingleton();
}
return lazySingleton;
}
}

上面這種單例模式創建對象,記憶體問題看起來是已經解決了,但是這種創建方式真的就線程安全了麼?咱們接下來寫個簡單的測試demo:

public class Main {
public static void main(String[] args) {
Thread thread1 = new Thread(()->{
LazySingleton lazySingleton = LazySingleton.getInstance();
System.out.println(lazySingleton.toString());
});
Thread thread2 = new Thread(()->{
LazySingleton lazySingleton = LazySingleton.getInstance();
System.out.println(lazySingleton.toString());
});
thread1.start();
thread2.start();
System.out.println("end");
}
}

執行輸出結果如下:

end
LazySingleton@3fde6a42
LazySingleton@2648fc3a

從上述的輸出我們很容易地發現,兩個執行緒所得到的物件是不同的,當然這個是有一定機率性質的。所以在這種多執行緒請求的場景下,就出現了執行緒安全性問題。

聊到共享变量访问线程安全性的问题,我们往往就想到了锁,所以,咱们在原有的代码块上加上锁对其优化试试,我们首先想到的是给方法代码块加上锁。

加锁后代码如下:

public class LazySingleton {

private LazySingleton() {
}

private static LazySingleton lazySingleton = null;
public synchronized static LazySingleton getInstance() {
if (lazySingleton == null) {
lazySingleton =new LazySingleton();
}
return lazySingleton;
}
}

经过上述同样的测试类运行之后,我们发现问题似乎解决了,每次运行之后得到的结果,两个线程对象的输出都是一致的。

我们用线程debug的方式看一下具体的运行情况,如下图:

一個單例模式,沒必要這麼卷吧

线程输出

我们可以发现,当一个线程进行初始化实例时,另一个线程就会从Running状态自动变成了Monitor状态。试想一下,如果有大量的线程同时访问的时候,在这样一个锁的争夺过程中就会有很多的线程被挂起为Monitor状态。CPU压力随着线程数的增加而持续增加,显然这种实现对性能还是很有影响的。

那还有优化的空间么?当然有,那就是大家经常听到的“DCL”即“Double Check Lock” 实现如下:

/**
 * 公众号:程序员老猫
 * 懒汉式单例模式(DCL)
 * Double Check Lock
 */
public class LazySingleton {

private LazySingleton() {
}
//使用volatile防止指令重排
private volatile static LazySingleton lazySingleton = null;
public static LazySingleton getInstance() {
if (lazySingleton == null) {
synchronized (LazySingleton.class) {
if(lazySingleton == null){
lazySingleton =new LazySingleton();
}
}
}
return lazySingleton;
}
}

通过DEBUG,我们来看一下下图:

一個單例模式,沒必要這麼卷吧

双重校验锁

这里引申一个常见的问题,大家在面试的时候估计也会碰到。问题:为什么要double check?去掉第二次check行不行?

回答:当2个线程同时执行getInstance方法时,都会执行第一个if判断,由于锁机制的存在,会有一个线程先进入同步语句,而另一个线程等待,当第一个线程执行了new Singleton()之后,就会退出synchronized的保护区域,这时如果没有第二重if判断,那么第二个线程也会创建一个实例,这就破坏了单例。

问题:这里为什么要加上volatile修饰关键字?回答:这里加上该关键字主要是为了防止”指令重排”。关于“指令重排”具体产生的原因我们这里不做细究,有兴趣的小伙伴可以自己去研究一下,我们这里只是去分析一下,“指令重排”所带来的影响。

lazySingleton =new LazySingleton();

这样一个看似简单的动作,其实从JVM层来看并不是一个原子性的行为,这里其实发生了三件事:

  • 给LazySingleton分配内存空间。
  • 调用LazySingleton的构造函数,初始化成员字段。
  • 将LazySingleton指向分配的内存空间(注意此时的LazySingleton就不是null了)

在此期间存在着指令重排序的优化,第2、3步的顺序是不能保证的,最后的执行顺序可能是1-2-3,也可能是1-3-2,假如执行顺序是1-3-2,我们看看会出现什么问题。看一下下图:

一個單例模式,沒必要這麼卷吧

指令重排执行

从上图中我们看到虽然LazySingleton不是null,但是指向的空间并没有初始化,最终被业务使用的时候还是会报错,这就是DCL失效的问题,这种问题难以跟踪难以重现可能会隐藏很久。

JDK1.5之前JMM(Java Memory Model,即Java内存模型)中的Cache、寄存器到主存的回写规定,上面第二第三的顺序无法保证。JDK1.5之后,SUN官方调整了JVM,具体化了volatile关键字,private volatile static LazySingleton lazySingleton;只要加上volatile,就可以保证每次从主存中读取(这涉及到CPU缓存一致性问题,感兴趣的小伙伴可以研究研究),也可以防止指令重排序的发生,避免拿到未完成初始化的对象。

上面这种方式可以有效降低锁的竞争,锁不会将整个方法全部锁定,而是锁定了某个代码块。其实完全做完调试之后我们还是会发现锁争夺的问题并没有完全解决,用到了锁肯定会对整个代码的执行效率带来一定的影响。所以是否存在保证线程的安全,并且能够不浪费内存完美的解决方案呢?一起看下下面的解决方案。

内部静态类单例模式

这种方式其实是利用了静态对象创建的特性来解决上述内存浪费以及线程不安全的问题。在这里我们要弄清楚,被static修饰的属性,类加载的时候,基本属性就已经加载完毕,但是静态方法却不会加载的时候自动执行,而是等到被调用之后才会执行。并且被STATIC修饰的变量JVM只为静态分配一次内存。(这里老猫不展开去聊static相关知识点,有兴趣的小伙伴也可以自行去了解一下更多JAVA中static关键字修饰之后的类、属性、方法的加载机制以及存储机制)

所以综合这一特性,我们就有了下面这样的写法:

public class LazyInnerClassSingleton {
private LazyInnerClassSingleton () {
}

public static final LazyInnerClassSingleton getInstance() {
return LazyHolder.LAZY;
}

private static class LazyHolder {
private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
}
}

上面这种写法,其实也属于“懒汉式单例模式”,并且这种模式相对于“无脑加锁”以及“DCL”以及“饿汉式单例模式”来说无疑是最优的一种实现方式。

但是深度去追究的话,其实这种方式也会有问题,这种写法并不能防止反序列化和反射生成多个实例。我们简单看一下反射的破坏的测试类:

public class DestructionSingletonTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class enumSingletonClass = LazyInnerClassSingleton.class;
//枚举默认有个String 和 int 类型的构造器
Constructor constructor = enumSingletonClass.getDeclaredConstructor();
constructor.setAccessible(true);
//利用反射调用构造方法两次直接创建两个对象,直接破坏单例模式
LazyInnerClassSingleton singleton1 = (LazyInnerClassSingleton) constructor.newInstance();
LazyInnerClassSingleton singleton2 = (LazyInnerClassSingleton) constructor.newInstance();
}
}

这里序列化反序列化单例模式破坏老猫偷个懒,因为下面会有写到,有兴趣的小伙伴继续看下文,老猫觉得这种破坏场景在真实的业务使用场景比较极端,如果不涉及底层框架变动,光从业务角度来看,上面这些单例模式的实现已经管够了。当然如果硬是要防止上面的反射创建单例两次问题也能解决,如下:

public class LazyInnerClassSingleton {
private LazyInnerClassSingleton () {
if(LazyHolder.LAZY != null) {
throw new RuntimeException("不允许创建多个实例");
}
}

public static final LazyInnerClassSingleton getInstance() {
return LazyHolder.LAZY;
}

private static class LazyHolder {
private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
}
}

写到这里,可能大家都很疑惑了,咋还没提及用单例模式优化线程池创建。下面这不来了么,老猫个人觉得上面的这种方式进行创建单例还是比较好的,所以就用这种方式重构一下线程池的创建,具体代码如下:

public class InnerClassLazyThreadPoolHelper {
public static void execute(Runnable runnable) {
ThreadPoolExecutor threadPoolExecutor = ThreadPoolHelperHolder.THREAD_POOL_EXECUTOR;
threadPoolExecutor.execute(runnable);
}
/**
 * 静态内部类创建实例(单例).
 * 优点:被调用时才会创建一次实例
 */
public static class ThreadPoolHelperHolder {
private static final int CPU = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = CPU + 1;
private static final int MAXIMUM_POOL_SIZE = CPU * 2 + 1;
private static final long KEEP_ALIVE_TIME = 1L;
private static final TimeUnit TIME_UNIT = TimeUnit.SECONDS;
private static final int MAX_QUEUE_NUM = 1024;

private ThreadPoolHelperHolder() {
}

private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TIME_UNIT,
new LinkedBlockingQueue(MAX_QUEUE_NUM),
new ThreadPoolExecutor.AbortPolicy());
}
}

以上是一個單例模式,沒必要這麼卷吧的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:mryunwei.com。如有侵權,請聯絡admin@php.cn刪除