你真的懂單例模式麼

本文首發於個人我的博客

什麼是單例

在開發中,單例模式應該是每一個人都會用的,可是你真的深刻了解過單例模式麼?但願這篇文章能給你更加深刻的認識。html

wikipedia中這麼介紹

單例模式,也叫單子模式,是一種經常使用的軟件設計模式。在應用這個模式時,單例對象的類必須保證只有一個實例存在。許多時候整個系統只須要擁有一個的全局對象,這樣有利於咱們協調系統總體的行爲。好比在某個服務器程序中,該服務器的配置信息存放在一個文件中,這些配置數據由一個單例對象統一讀取,而後服務進程中的其餘對象再經過這個單例對象獲取這些配置信息。這種方式簡化了在複雜環境下的配置管理。git

實現單例模式的思路是:一個類能返回對象一個引用(永遠是同一個)和一個得到該實例的方法(必須是靜態方法,一般使用getInstance這個名稱);當咱們調用這個方法時,若是類持有的引用不爲空就返回這個引用,若是類保持的引用爲空就建立該類的實例並將實例的引用賦予該類保持的引用;同時咱們還將該類的構造函數定義爲私有方法,這樣其餘處的代碼就沒法經過調用該類的構造函數來實例化該類的對象,只有經過該類提供的靜態方法來獲得該類的惟一實例。github

單例模式在多線程的應用場合下必須當心使用。若是當惟一實例還沒有建立時,有兩個線程同時調用建立方法,那麼它們同時沒有檢測到惟一實例的存在,從而同時各自建立了一個實例,這樣就有兩個實例被構造出來,從而違反了單例模式中實例惟一的原則。 解決這個問題的辦法是爲指示類是否已經實例化的變量提供一個互斥鎖(雖然這樣會下降效率)。web

蘋果官方定義

蘋果官方示例中以下定義單例設計模式

static MyGizmoClass *sharedGizmoManager = nil;
 
+ (MyGizmoClass*)sharedManager
{
    if (sharedGizmoManager == nil) {
        sharedGizmoManager = [[super allocWithZone:NULL] init];
    }
    return sharedGizmoManager;
}
 
+ (id)allocWithZone:(NSZone *)zone
{
    return [[self sharedManager] retain];
}
 
- (id)copyWithZone:(NSZone *)zone
{
    return self;
}
 
- (id)retain
{
    return self;
}
 
- (NSUInteger)retainCount
{
    return NSUIntegerMax;  //denotes an object that cannot be released
}
 
- (void)release
{
    //do nothing
}
 
- (id)autorelease
{
    return self;
}

複製代碼

問題:爲何用了allocWithZone

官方文檔描述

The isa instance variable of the new instance is initialized to a data structure that describes the class; memory for all other instance variables is set to 0.性能優化

You must use an init... method to complete the initialization process. For example:bash

>TheClass *newObject = [[TheClass allocWithZone:nil] init];
複製代碼

Do not override allocWithZone: to include any initialization code. Instead, class-specific versions of init... methods.服務器

This method exists for historical reasons; memory zones are no longer used by Objective-C.多線程

文檔提到,使用allocWithZone是由於保證分配對象的惟一性

緣由是單例類只有一個惟一的實例,而平時咱們在初始化一個對象的時候, [[Class alloc] init],實際上是作了兩件事。 alloc 給對象分配內存空間,init是對對象的初始化,包括設置成員變量初值這些工做。而給對象分配空間,除了alloc方法以外,還有另外一個方法: allocWithZone.app

而實踐證實,使用alloc方法初始化一個類的實例的時候,默認是調用了 allocWithZone 的方法。因而覆蓋allocWithZone方法的緣由已經很明顯了:爲了保持單例類實例的惟一性,須要覆蓋全部會生成新的實例的方法,若是有人初始化這個單例類的時候不走[[Class alloc] init] ,而是直接 allocWithZone, 那麼這個單例就再也不是單例了,因此必須把這個方法也堵上。

allocWithZone已經被廢棄了

This method exists for historical reasons; memory zones are no longer used by Objective-C

前面說了 allocWithZone是爲了保證單例的惟一性,然而,文檔中又說了allocWithZone已經被廢棄了,只是由於歷史緣由才保留了這個接口。因此咱們應該怎麼使用單例呢?

現代單例模式實現

在前輩大牛的指引下,後人總能站的更高,看得更遠

現代通常單例實現以下

+ (instancetype)sharedInstance
{
  static dispatch_once_t onceToken = 0;
  __strong static id _sharedObject = nil;
  dispatch_once(&onceToken, ^{
    _sharedObject = [[self alloc] init]; // or some other init method
  });
  return _sharedObject;
}

複製代碼

dispatch_once

@synchronizeddispatch_once對比

咱們之因此使用dispatch_once 主要是由於爲了加鎖保證單例的惟一性,由於蘋果官方推薦的allocWithZone已經被廢棄了。那麼問題來了,若是要加鎖來保證單例的惟一性,也能夠用@synchronized呀,爲何用的是 dispatch_once,而不是@synchronized

國外有開發者作過性能測試@synchronized 和dispatch_once對比。在單線程和多線程狀況下測試了 @synchronizeddispatch_once 實現單例的性能對比,結果以下:

Single threaded results
-----------------------
  @synchronized: 3.3829 seconds
  dispatch_once: 0.9891 seconds

Multi threaded results
----------------------
  @synchronized: 33.5171 seconds
  dispatch_once: 1.6648 seconds
複製代碼

能夠看到,dispatch_once 在線程競爭環境下性能顯著優於 @synchronized

dispatch_once分析

Objective-C 中,@synchronized 是用 NSRecursiveLock 實現的,而且隱式添加一個 exception handler,若是有異常拋出,handler 會自動釋放互斥鎖。而 dispatch_once 之因此擁有高性能是由於它省去了鎖操做,代替的是大量的原子操做,該原子操做內部不是靠 pthread 等鎖來實現,而是直接利用了 lock 的彙編指令,靠底層 CPU 指令來支持的。

咱們以下代碼

#import "YZPerson.h"

@implementation YZPerson
+ (instancetype)sharedInstance
{
    static dispatch_once_t onceToken = 0;
    __strong static id _sharedObject = nil;
    NSLog(@"before dispatch_once onceToken = %ld",onceToken);
    dispatch_once(&onceToken, ^{
          NSLog(@"before dispatch_once onceToken = %ld",onceToken);
        _sharedObject = [[self alloc] init]; // or some other init method
    });
      NSLog(@"before dispatch_once onceToken = %ld",onceToken);
    return _sharedObject;
}
@end
複製代碼

dispatch_once以前,進行中,以後,分別打印onceToken的值。

屢次調用單例

[YZPerson sharedInstance];
[YZPerson sharedInstance];
[YZPerson sharedInstance];
[YZPerson sharedInstance];
[YZPerson sharedInstance];
複製代碼

輸出結果爲

iOS-單例模式[8255:91704] before dispatch_once onceToken = 0
iOS-單例模式[8255:91704] before dispatch_once onceToken = 772
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
iOS-單例模式[8255:91704] before dispatch_once onceToken = -1
複製代碼
  • 經過輸出咱們能夠發現,在 dispatch_once 執行前,onceToken 的值是 0,由於 dispatch_once_t 是由 typedef long dispatch_once_t 而來,因此在 onceToken 還沒被手動賦值的狀況下,0 是編譯器給 onceToken 的初始化賦值。
  • dispatch_once 執行過程當中,onceToken 是一個很大的數字,這個值是 dispath_once 內部實現中一個局部變量的地址,並非一個固定的值。
  • dispatch_once 執行完畢,onceToken 的值被賦爲 -1。以後再次調用的時候,onceToken已是-1了,就直接跳過dispatch_once的執行

dispatch_once 使用場景

因此 dispatch_once 的實現須要知足如下三種場景的需求:

  1. dispatch_once 第一次執行,block 被調用,調用結束需標記 onceToken
  2. dispatch_once 第一次執行過程當中,有其它線程執行該 dispatch_once,則其它線程的請求須要等待 dispatch_once 的第一次執行結束才能被處理。
  3. dispatch_once 第一次執行已經結束,有其它線程執行該 dispatch_once,則其它線程直接跳過 block 執行後續任務。

因爲場景 1 只會發生一次,場景 2 發生的次數也是有限的,甚至根本不會發生,而場景 3 的發生次數多是很是高的數量級,也正是影響 dispatch_once 性能的關鍵所在。

對於場景三的優化:

OC中,dispatch_once的代碼是開源的,咱們直接查看源碼

#ifdef __BLOCKS__
__OSX_AVAILABLE_STARTING(__MAC_10_6,__IPHONE_4_0)  
DISPATCH_EXPORT DISPATCH_NONNULL_ALL DISPATCH_NOTHROW  
void  
dispatch_once(dispatch_once_t *predicate, dispatch_block_t block);

DISPATCH_INLINE DISPATCH_ALWAYS_INLINE DISPATCH_NONNULL_ALL DISPATCH_NOTHROW  
void  
_dispatch_once(dispatch_once_t *predicate, dispatch_block_t block)  
{
    // 告訴 CPU *predicate 等於 ~0l 的可能性很是高,
    // 這就使得 CPU 預測不進入 if 分支,提早取後續指令,譯碼,
    // 甚至提早計算一些結果,提升效率,
    // 場景 3 的性能優化主要在此體現
    if (DISPATCH_EXPECT(*predicate, ~0l) != ~0l) {
        dispatch_once(predicate, block);
    }
}
#undef dispatch_once
#define dispatch_once _dispatch_once
#endif
複製代碼

經過宏定義 #define dispatch_once _dispatch_once可知,咱們實際調用的是 _dispatch_once方法,而且是強制 inlineDISPATCH_EXPECT__builtin_expect((x), (v)) 的宏替換,long __builtin_expect (long EXP, long C) 是 GCC 提供的內建函數來處理分支預測,EXP 爲一個整型表達式,這個內建函數的返回值也是 EXP,C 爲一個編譯期常量。這個函數至關於告訴編譯器,EXP == C 的可能性很是高,其做用是幫助編譯器判斷條件跳轉的預期值,編譯器會產生相應的代碼來優化 CPU 執行效率,CPU 遇到條件轉移指令時會提早預測並裝載某個分支的指令,避免跳轉形成時間亂費,但並無改變其對真值的判斷,若是分支預測錯了,就會丟棄以前的指令,從正確的分支從新開始執行。

對於場景一,場景二的處理:

dispatch_once 的寫入端來保證,實現以下:

struct _dispatch_once_waiter_s {  
    volatile struct _dispatch_once_waiter_s *volatile dow_next;
    // _dispatch_thread_semaphore_t 是 unsigned long 類型的別名,用來表示信號量
    _dispatch_thread_semaphore_t dow_sema;
};

// 將 DISPATCH_ONCE_DONE 定義爲 _dispatch_once_waiter_s 類型的指針,
// ~0l 是 long 的 0 取反,也就是一大堆 1(輸出爲 -1),是個無效的指針,
// 即指向的地址不可能爲一個有效的 _dispatch_once_waiter_s 類型,
// 用來標記 onceToken,表示 dispatch_once 第一次執行已經完成
#define DISPATCH_ONCE_DONE ((struct _dispatch_once_waiter_s *)~0l)

#ifdef __BLOCKS__
void  
dispatch_once(dispatch_once_t *val, dispatch_block_t block)  
{
    // dispatch_block_t 的類型定義:typedef void (^dispatch_block_t)(void)
    struct Block_basic *bb = (void *)block;
    // 執行 block 最終是調用 C 函數
    dispatch_once_f(val, block, (void *)bb->Block_invoke);
}
#endif

// val 即外部傳入的 &onceToken,ctxt 傳入指向 block 的指針,可取到 block 上下文,
// dispatch_function_t 的類型定義:typedef void (*dispatch_function_t)(void *)
// func 是 block 內部的函數指針,指向函數執行體,執行它就是執行 block
DISPATCH_NOINLINE  
void  
dispatch_once_f(dispatch_once_t *val, void *ctxt, dispatch_function_t func)  
{
    // volatile 是一個類型修飾符,用來修飾被不一樣線程訪問和修改的變量,
    // 遇到這個關鍵字聲明的變量,編譯器對訪問該變量的代碼就再也不進行優化,
    // 優化器在用到這個變量時必須從新從它所在的內存讀取數據,而不是使用保存在寄存器裏的備份
    struct _dispatch_once_waiter_s * volatile *vval =
            (struct _dispatch_once_waiter_s**)val;
    // dow 意爲 dispatch_once waiter
    struct _dispatch_once_waiter_s dow = { NULL, 0 };
    struct _dispatch_once_waiter_s *tail, *tmp;
    _dispatch_thread_semaphore_t sema;

    // dispatch_atomic_cmpxchg 是原子比較交換函數 __sync_bool_compare_and_swap 的宏替換,
    // 原理是大體以下(真正的實現並不是如此):
    //     if(*vval == NULL)
    //     {
    //         *vval = &dow;
    //         return true;
    //     }
    //     else
    //     {
    //         return false;
    //     }
    // 當 dispatch_once 第一次執行時,*vval 爲 0,
    // 則 *vval 被值賦值爲 &dow 並返回 true,
    // 此時 *vval 的值是相似上文中的 140734723410256
    if (dispatch_atomic_cmpxchg(vval, NULL, &dow)) {
        // 空的宏替換,什麼都不作
        dispatch_atomic_acquire_barrier();
        // _dispatch_client_callout 實際上就是調用了func,執行了 block,即初始化並寫入 obj
        _dispatch_client_callout(ctxt, func);

        dispatch_atomic_maximally_synchronizing_barrier();

        // dispatch_atomic_xchg 原子交換函數 __sync_swap 的宏替換,
        // 執行的操做是:
        //         temp = *vval;
        //         *vval = DISPATCH_ONCE_DONE;
        tmp = dispatch_atomic_xchg(vval, DISPATCH_ONCE_DONE);
        tail = &dow;
        // 若在 block 執行過程當中,沒有其它線程進入線程等待分支來等待,
        // 則 *vval == &dow,即 tmp == &dow,while 循環不會被執行,分支結束,
        // 如有其它線程進入線程等待分支來等待,那麼會構造一個信號量鏈表,
        // *vval 變爲信號量鏈的頭部,&dow 爲鏈表的尾部,
        // 則在此 while 循環中,遍歷鏈表來 signal 每一個信號量
         while (tail != tmp) {
            // 由於線程等待分支會中途將 val(即 *vval)賦值爲 &dow,
            // 而後再爲 val->dow_next 賦值,
            // 在 val->dow_next 賦值以前其值爲 NULL,須要等待,
            // pause 就像 nop,延遲空等,主要是提升性能和節省 CPU 耗電
            while (!tmp->dow_next) {
                _dispatch_hardware_pause();
            }
            sema = tmp->dow_sema;
            tmp = (struct _dispatch_once_waiter_s*)tmp->dow_next;
            _dispatch_thread_semaphore_signal(sema);
        }
    } else {
        dow.dow_sema = _dispatch_get_thread_semaphore();
        for (;;) {
            tmp = *vval;
            // 若是發現 *vval 已經爲 DISPATCH_ONCE_DONE,則直接break,
            // 而後調用 _dispatch_put_thread_semaphore 銷燬信號量
            if (tmp == DISPATCH_ONCE_DONE) {
                break;
            }
            // 空的宏替換,什麼都不作
            dispatch_atomic_store_barrier();
            // 若是 *vval 不爲 DISPATCH_ONCE_DONE,則進行原子比較並交換操做,
            // 若是期間有其它線程同時進入線程等待分支並交錯修改鏈表,則可能致使 *vval != tmp,
            // 則 for 循環從新開始,從新獲取一次 vval 來進行一樣的操做,
            // 若 *vval == tmp,則將 *vval 賦值爲 &dow,
            // 接着執行 dow.dow_next = tmp 增長鏈表節點,而後等待信號量,
            // 當 block 執行分支完成並遍歷鏈表來 signal 時,結束等待往下執行
            if (dispatch_atomic_cmpxchg(vval, tmp, &dow)) {
             dow.dow_next = tmp;
                _dispatch_thread_semaphore_wait(dow.dow_sema);
            }
        }
        _dispatch_put_thread_semaphore(dow.dow_sema);
    }
}
複製代碼

因爲 CPU 的流水線特性,有一種邊緣情況可能出現。假如線程 a 在初始化並寫入 obj 還沒有完成時,線程 b 讀取了 obj,則此時 obj 爲 nil,而線程 b 在線程 a 置 predicateDISPATCH_ONCE_DONE 以後讀取 predicate,線程 b 會認爲 obj 初始化已經完成,將空的 obj 返回,那麼接下來關於 obj 函數調用可能會致使程序崩潰。

假如寫入端能在 初始化並寫入 obj 與 置 predicateDISPATCH_ONCE_DONE 之間等待足夠長的時間,即知足 Ta > Tb,那上述的問題就都解決了。所以 dispatch_once 在執行了 block 以後,會調用 dispatch_atomic_maximally_synchronizing_barrier() 宏函數,在 intel 處理器上,這個函數編譯出的是 cpuid 指令,並強制將指令流串行化,在其餘廠商處理器上,這個宏函數編譯出的是合適的其它指令,這些指令都將耗費可觀數量的 CPU 時鐘週期,以保證 Ta > Tb。

總結,爲了性能的優化,dispatch_once作到了極致

宏定義

前面說了這麼多單例,實際使用的時候,咱們能夠用宏來定義,之後只須要一行就能夠了。

#define SYNTHESIZE_SINGLETON_FOR_CLASS_HEADER(className) \
\
+ (className *)sharedInstance;

#define SYNTHESIZE_SINGLETON_FOR_CLASS(className) \
\
+ (className *)sharedInstance { \
static className *sharedInstance = nil; \
static dispatch_once_t onceToken; \
dispatch_once(&onceToken, ^{ \
sharedInstance = [[self alloc] init]; \
}); \
return sharedInstance; \
}
複製代碼

使用的時候

//YZPerson類單例的聲明
SYNTHESIZE_SINGLETON_FOR_CLASS_HEADER(YZPerson)
// YZPerson類單例的實現
SYNTHESIZE_SINGLETON_FOR_CLASS(YZPerson)
複製代碼

參考資料:

蘋果關於allocWithZone的文檔

GCD 中 dispatch_once 的性能與實現

從 Objective-C 裏的 Alloc 和 AllocWithZone 談起

@synchronized 和dispatch_once對比

更多資料,歡迎關注我的公衆號,不定時分享各類技術文章。

相關文章
相關標籤/搜索