Balking模式:再談線程安全的單例模式

話題:Balking模式:再談線程安全的單例模式

上一篇文章中,我們提到可以用**“多線程版本的if”**來理解Guarded Suspension模式,不同於單線程中的if,這個“多線程版本的if”是需要等待的,而且還很執着,必須要等到條件爲真。但很顯然這個世界,不是所有場景都需要這麼執着,有時候我們還需要快速放棄

需要快速放棄的一個最常見的例子是各種編輯器提供的自動保存功能。自動保存功能的實現邏輯一般都是隔一定時間自動執行存盤操作,存盤操作的前提是文件做過修改,如果文件沒有執行過修改操作,就需要快速放棄存盤操作。下面的示例代碼將自動保存功能代碼化了,很顯然AutoSaveEditor這個類不是線程安全的,因爲對共享變量changed的讀寫沒有使用同步,那如何保證AutoSaveEditor的線程安全性呢?

class AutoSaveEditor{

  //文件是否被修改過
  boolean changed=false;
  
  //定時任務線程池
  ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();
  
  //定時執行自動保存
  void startAutoSave(){
    ses.scheduleWithFixedDelay(()->{
      autoSave();
    }, 5, 5, TimeUnit.SECONDS);  
  }
  
  //自動存盤操作
  void autoSave(){
  //沒有改變
    if (!changed) {
      return;
    }
    changed = false;
    //執行存盤操作
    //省略且實現
    this.execSave();
  }
  
  //編輯操作
  void edit(){
    //省略編輯邏輯
    ......
    changed = true;
  }
}

解決這個問題相信你一定手到擒來了:讀寫共享變量changed的方法autoSave()和edit()都加互斥鎖就可以了。這樣做雖然簡單,但是性能很差,原因是鎖的範圍太大了。那我們可以將鎖的範圍縮小,只在讀寫共享變量changed的地方加鎖,實現代碼如下所示。

//自動存盤操作
void autoSave(){
  synchronized(this){
    if (!changed) {
      return;
    }
    changed = false;
  }
  //執行存盤操作
  //省略且實現
  this.execSave();
}

//編輯操作
void edit(){
  //省略編輯邏輯
  ......
  synchronized(this){
    changed = true;
  }
}

如果你深入地分析一下這個示例程序,你會發現,示例中的共享變量是一個狀態變量,業務邏輯依賴於這個狀態變量的狀態:當狀態滿足某個條件時,執行某個業務邏輯,其本質其實不過就是一個if而已,放到多線程場景裏,就是一種“多線程版本的if”。這種“多線程版本的if”的應用場景還是很多的,所以也有人把它總結成了一種設計模式,叫做Balking模式

Balking模式的經典實現

Balking模式本質上是一種規範化地解決“多線程版本的if”的方案,對於上面自動保存的例子,使用Balking模式規範化之後的寫法如下所示,你會發現僅僅是將edit()方法中對共享變量changed的賦值操作抽取到了change()中,這樣的好處是將併發處理邏輯和業務邏輯分開。

boolean changed=false;

//自動存盤操作
void autoSave(){
  synchronized(this){
    if (!changed) {
      return;
    }
    changed = false;
  }
  //執行存盤操作
  //省略且實現
  this.execSave();
}

//編輯操作
void edit(){
  //省略編輯邏輯
  ......
  change();
}

//改變狀態
void change(){
  synchronized(this){
    changed = true;
  }
}

用volatile實現Balking模式

前面我們用synchronized實現了Balking模式,這種實現方式最爲穩妥,建議你實際工作中也使用這個方案。不過在某些特定場景下,也可以使用volatile來實現,但使用volatile的前提是對原子性沒有要求。
之前有一個RPC框架路由表的案例,在RPC框架中,本地路由表是要和註冊中心進行信息同步的,應用啓動的時候,會將應用依賴服務的路由表從註冊中心同步到本地路由表中,如果應用重啓的時候註冊中心宕機,那麼會導致該應用依賴的服務均不可用,因爲找不到依賴服務的路由表。爲了防止這種極端情況出現,RPC框架可以將本地路由表自動保存到本地文件中,如果重啓的時候註冊中心宕機,那麼就從本地文件中恢復重啓前的路由表。這其實也是一種降級的方案。

自動保存路由表和前面介紹的編輯器自動保存原理是一樣的,也可以用Balking模式實現,不過我們這裏採用volatile來實現,實現的代碼如下所示。之所以可以採用volatile來實現,是因爲對共享變量changed和rt的寫操作不存在原子性的要求,而且採用scheduleWithFixedDelay()這種調度方式能保證同一時刻只有一個線程執行autoSave()方法。

//路由表信息
public class RouterTable {

  //Key:接口名
  //Value:路由集合
  ConcurrentHashMap<String, CopyOnWriteArraySet<Router>> 
    rt = new ConcurrentHashMap<>(); 
       
  //路由表是否發生變化
  volatile boolean changed;
  
  //將路由表寫入本地文件的線程池
  ScheduledExecutorService ses= Executors.newSingleThreadScheduledExecutor();
  
  //啓動定時任務
  //將變更後的路由表寫入本地文件
  public void startLocalSaver(){
    ses.scheduleWithFixedDelay(()->{
      autoSave();
    }, 1, 1, MINUTES);
  }
  
  //保存路由表到本地文件
  void autoSave() {
    if (!changed) {
      return;
    }
    changed = false;
    //將路由表寫入本地文件
    //省略其方法實現
    this.save2Local();
  }
  
  //刪除路由
  public void remove(Router router) {
    Set<Router> set=rt.get(router.iface);
    if (set != null) {
      set.remove(router);
      //路由表已發生變化
      changed = true;
    }
  }
  
  //增加路由
  public void add(Router router) {
    Set<Router> set = rt.computeIfAbsent(
      route.iface, r -> new CopyOnWriteArraySet<>());
    set.add(router);
    //路由表已發生變化
    changed = true;
  }
}

Balking模式有一個非常典型的應用場景就是單次初始化,下面的示例代碼是它的實現。這個實現方案中,我們將init()聲明爲一個同步方法,這樣同一個時刻就只有一個線程能夠執行init()方法;init()方法在第一次執行完時會將inited設置爲true,這樣後續執行init()方法的線程就不會再執行doInit()了。

class InitTest{

  boolean inited = false;
  
  synchronized void init(){
    if(inited){
      return;
    }
    //省略doInit的實現
    doInit();
    inited=true;
  }
}

線程安全的單例模式本質上其實也是單次初始化,所以可以用Balking模式來實現線程安全的單例模式,下面的示例代碼是其實現。這個實現雖然功能上沒有問題,但是性能卻很差,因爲互斥鎖synchronized將getInstance()方法串行化了,那有沒有辦法可以優化一下它的性能呢?

class Singleton{
  private static Singleton singleton;
  //構造方法私有化  
  private Singleton(){}
  //獲取實例(單例)
  public synchronized static Singleton getInstance(){
    if(singleton == null){
      singleton=new Singleton();
    }
    return singleton;
  }
}

辦法當然是有的,那就是經典的**雙重檢查(Double Check)**方案,下面的示例代碼是其詳細實現。在雙重檢查方案中,一旦Singleton對象被成功創建之後,就不會執行synchronized(Singleton.class){}相關的代碼,也就是說,此時getInstance()方法的執行路徑是無鎖的,從而解決了性能問題。不過需要你注意的是,這個方案中使用了volatile來禁止編譯優化,至於獲取鎖後的二次檢查,則是出於對安全性負責。

class Singleton{
  private static volatile Singleton singleton;
  //構造方法私有化  
  private Singleton() {}
  
  //獲取實例(單例)
  public static Singleton getInstance() {
    //第一次檢查
    if(singleton==null){
      synchronize{Singleton.class){
        //獲取鎖後二次檢查
        if(singleton==null){
          singleton=new Singleton();
        }
      }
    }
    return singleton;
  }
}

總結

Balking模式和Guarded Suspension模式從實現上看似乎沒有多大的關係,Balking模式只需要用互斥鎖就能解決,而Guarded Suspension模式則要用到管程這種高級的併發原語;但是從應用的角度來看,它們解決的都是“線程安全的if”語義,不同之處在於,Guarded Suspension模式會等待if條件爲真,而Balking模式不會等待。

Balking模式的經典實現是使用互斥鎖,你可以使用Java語言內置synchronized,也可以使用SDK提供Lock;如果你對互斥鎖的性能不滿意,可以嘗試採用volatile方案,不過使用volatile方案需要你更加謹慎。

當然你也可以嘗試使用雙重檢查方案來優化性能,雙重檢查中的第一次檢查,完全是出於對性能的考量:避免執行加鎖操作,因爲加鎖操作很耗時。而加鎖之後的二次檢查,則是出於對安全性負責。雙重檢查方案在優化加鎖性能方面經常用到。

Demo

1
public class BalkingClient {//阻攔,阻礙,場景就是別人做了我就不做了,類似服務生服務
    public static void main(String[] args) {
        //觀察的對象 BalkingData 
        BalkingData balkingData = new BalkingData("\\balking.txt", "===BEGIN====");
        new CustomerThread(balkingData).start();
        new WaiterThread(balkingData).start();
    }
}
2
public class BalkingData {//前面有Future模式,Suspension模式,這裏是發現有人做了就不做了 近的服務生做了遠的服務生就不做了
    private final String fileName;

    private String content;

    private boolean changed;

    public BalkingData(String fileName, String content) {
        this.fileName = fileName;
        this.content = content;
        this.changed = true;
    }

    public synchronized void change(String newContent) {
        this.content = newContent;
        this.changed = true;
    }

    public synchronized void save() throws IOException {
        if (!changed) {
            return;
        }

        doSave();
        this.changed = false;
    }

    private void doSave() throws IOException {
        System.out.println(Thread.currentThread().getName() +
         " calls do save,content=" + content);
         //實現Closeable接口自動幫你關掉
        try (Writer writer = new FileWriter(fileName, true)) {
            writer.write(content);
            writer.write("\n");
            writer.flush();
        }
    }
}
3
public class CustomerThread extends Thread {

    private final BalkingData balkingData;

    private final Random random = new Random(System.currentTimeMillis());

    public CustomerThread(BalkingData balkingData) {
        super("Customer");
        this.balkingData = balkingData;
    }

    @Override
    public void run() {
        try {
            balkingData.save();
            for (int i = 0; i < 20; i++) {
                balkingData.change("No." + i);
                Thread.sleep(random.nextInt(1000));
                balkingData.save();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
4
public class WaiterThread extends Thread {

    private final BalkingData balkingData;

    public WaiterThread(BalkingData balkingData) {
        super("Waiter");
        this.balkingData = balkingData;
    }

    @Override
    public void run() {
        for (int i = 0; i < 200; i++) {
            try {
                balkingData.save();
                Thread.sleep(1_000L);
            } catch (IOException e) {
                e.printStackTrace();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章