共享可變性設計中存在風險以及解決方法(三)

本篇是《Java虛擬機併發編程》第五章的閱讀筆記

本篇解決的是可見性的問題,在(一)(二)的基礎上對代碼進行重構

package com.periodictask;

import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class EnergySource {
    private final long MAXLEVEL = 100;
    private long level = MAXLEVEL;
    private static final ScheduledThreadPoolExecutor replenishTimer = 
            new ScheduledThreadPoolExecutor(10, 
            new java.util.concurrent.ThreadFactory(){
                public Thread newThread(Runnable runnable){
                    Thread thread = new Thread(runnable);
                    thread.setDaemon(true);
                    return thread;
                }
            });
    private ScheduledFuture<?> replenishTask;

    private EnergySource(){}

    private void init(){
        replenishTask = replenishTimer.scheduleAtFixedRate(new Runnable(){
            public void run(){
                System.out.println(System.nanoTime()/1.0e9);
                replenish();
            }
        }, 0, 1, TimeUnit.SECONDS);
    }

    public static EnergySource create(){
        final EnergySource energySource = new EnergySource();
        energySource.init();
        return energySource;
    }

    public long getUnitsAvailable(){
        return level;
    }

    public boolean useEnergy(final long units){
        if(units>0 && level>=units){
            level -= units;
            return true;
        }
        return false;
    }

    public void stopEnergySource(){
        replenishTask.cancel(false);
    }

    private void replenish(){
        if(level < MAXLEVEL){
            level++;
        }
    }
}

在程序開發中,讓線程在合適的時間跨越內存柵欄是很重要的事情

  1. 在(一)中提到過,在構造函數中跨越內存柵欄是不合適的。
  2. 在訪問共享變量的時候,必須保證訪問共享可變變量level的方法要跨越內存柵欄(即對level的訪問進行同步,即讀和寫是互斥的,否則會讀到髒數據)。

如果沒能正確的處理好跨越內存柵欄的問題,我們就無法保證所有線程在未來的任意時間段內都能即使看到變量值的變化

有很多方法可以保證讓EnergySource的函數都跨越內存柵欄,其中最簡單的方法是在所有方法前面加上synchronized關鍵字。雖然有點簡單粗暴,但我們還是先通過這種方法來保證變量的可見性,然後在根據其缺點進行改進(先跑起來,然後再優化)

  1. 所有與讀寫共享可變變量level有關的方法都需要跨越內存柵欄,所有我們將會把所有的相關方法都用synchronized進行修飾。
  2. 在最初的版本中,由於replenish()裏面是一個死循環,所以不能用synchronized進行修飾(原因就是如果同步,就變成該方法就只能由一個線程執行,因爲它不會釋放鎖),而最新的版本則沒有這個問題。

//Ensure visibility ... other issues pending
    //...
    public synchronized long getUnitsAvailable(){
        return level;
    }

    public synchronized boolean useEnergy(final long units){
        if(units>0 && level>=units){
            level -= units;
            return true;
        }
        return false;
    }

    public synchronized void stopEnergySource(){
        replenishTask.cancel(false);
    }

    private synchronized void replenish(){
        if(level < MAXLEVEL){
            level++;
        }
    }
}

上面是更該部分的代碼

  1. 在上面的代碼中,我們在需要訪問的level變量函數的上面加上synchronized關鍵字,這樣我們實現了線程安全的變量訪問
  2. 訪問有final修飾的不可變變量則無需穿越內存柵欄,因爲這些值是不會變的,並且(CPU)緩存的值和內存裏的值也都是完全相同的

當然,類似於上述做法其實是過於保守的,雖然可以保證線程的安全,但是效率很低。

使用synchronized的問題

  1. synchronized關鍵字的作用域是整個對象,於是整個程序的併發粒度就被限死在對象級別上,在任意時刻,一個對象最多隻能接受一個同步操作。

如果對象上的所有操作(例如在一個集合中添加或刪除數據等)都是互斥的,那性能可能還不算太差。

然而如果對象支持多個可以併發執行的操作(如drive()和sing()),並且這些操作需要與其他互斥(drive()和tweet())操作進行同步,那麼對象實例級別的同步將會對程序的執行速度產生很大影響。

解決方法

在這種情況下,我們需要在對象的相關方法中創建多個同步點,用細粒度的同步控制來提高併發執行速度。

而在本例中,就沒有必要採用對象級別的同步。

因爲變量level是EnergySource類裏唯一的可變字段,所以我們將同步操作直接作用在它上面。

該方法會產生的問題

當然,這種方法並不能總是奏效,因爲如果某個類裏面定義了多個字段,那麼我們需要對這些字段都進行保護,所以我們可能需要定義多個顯示的Lock實例來應對這種情況,在(四)中會說到。

雖然將同步操作直接作用到變量level上是可行的。但是這個方案有個小問題,即Java是不允許對象long這樣的基礎類型加鎖(因爲Java在讀寫long的時候會分兩個步驟,這樣會導致線程不安全),所以我們需要將level變量的類型從long改爲AtomicLong來規避這個限制。如此一來,我們就可以針對改變了的訪問實現細粒度的線程安全了。

修改後的代碼

package com.enhanceconcurrency;

import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class EnergySource {
    private final long MAXLEVEL = 100;
    private final AtomicLong level = new AtomicLong(MAXLEVEL);
    private static final ScheduledThreadPoolExecutor replenishTimer = 
            new ScheduledThreadPoolExecutor(10);
    private ScheduledFuture<?> replenishTask;

    private EnergySource(){}

    private void init(){
        replenishTask = replenishTimer.scheduleAtFixedRate(new Runnable(){
            public void run(){
                System.out.println(System.nanoTime()/1.0e9);
                replenish();
            }
        }, 0, 1, TimeUnit.SECONDS);
    }

    public static EnergySource create(){
        final EnergySource energySource = new EnergySource();
        energySource.init();
        return energySource;
    }

    public long getUnitsAvailable(){
        return level.get();
    }

    public boolean useEnergy(final long units){
        final long currentLevel = level.get();
        if(units>0 && currentLevel>=units){
            return level.compareAndSet(currentLevel, currentLevel - units);
        }
        return false;
    }

    public void stopEnergySource(){
        replenishTask.cancel(false);
    }

    private void replenish(){
        if(level.get() < MAXLEVEL){
            level.incrementAndGet();
        }
    }

    public static void main(String[] args){
        EnergySource.create().useEnergy(10);
    }
}
  1. 由於AtomicLong自身就能保證對其所持有的值在多線程併發環境下的可見性和線程安全性,所以我們去掉了getUnitsAvailable()函數前面點synchronized修飾符。
  2. 基於同樣的理由,我們也去掉了useEnergy()函數前面的synchronized修飾符。

這一做法在改進併發的同時還帶來了一些語義上的變化。在之前版本中,在檢查剩餘可用電量時,我們鎖住了該函數不然其他任何線程訪問。所以只要發現有足夠的電量,我們就一定能夠獲取到。

當然之前也說過,這種做法會嚴重降低併發度,即當一個線程正在進行操作的時候,所有與該類相關的其他交互操作都會被阻塞。

在改進版本中,多個線程可以在沒持有互斥鎖的情況下同時競爭電源電量的使用權。如果兩個或兩個以上的線程同時更改level的值,則只有一個會請求成功,而其他請求則需要重試。顯而易見,我們既加快了讀操作的速度,同時有增強了寫操作的安全性。

3.當然replenish()同樣不需要使用互斥鎖,因爲這裏面的操作都是線程安全的。

4.由於程序中極少調用stopEnergySource()方法,因此沒必要在這個點上做更細粒度的鎖控制,所以最終選擇保留synchronized關鍵字


所以當你在寫項目的時候,找到那些需要既不損失線程安全性有需要提高併發度的地方。請檢測一些這些地方是否可以用Lock對象類代替針對整個對象實例粒度的synchronized關鍵字。當然在替換的同時,請保證所有參與讀寫可變狀態的方法都進行恰當的同步

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章