Thinking in Java---多執行緒學習筆記(2)

acm_lkl發表於2020-04-04

多執行緒中的一個核心問題就是對共享資源的訪問問題。因為不能準確的知道一個執行緒在何時執行,所以如果多個執行緒對共享資源進行修改的化,結果可能就出錯了。解決這一衝突的基本思路就是當一個資源被一個任務使用時,在其上加鎖;這樣其它的任務就不能再訪問這個資源,直到上面的鎖開啟;這樣就可以實現一個序列化的訪問共享資源。Java中提供了多種對訪問共享資源的臨界區程式碼進行加鎖的方法,下面對這些方法進行一個歸納總結。
下面先給出一段多執行緒併發訪問的程式碼:

package lkl;

public abstract class IntGenerator {

    private volatile boolean canceled= false;
    public abstract int next();
    public void cancel(){canceled = true;}
    public boolean isCanceled(){return canceled;}
}



public class EvenGenerator extends IntGenerator{

    private int currentEvenValue =0;
    public synchronized int next(){  
        ++currentEvenValue; ///可能在這發生不正確的中斷
        //Thread.currentThread().yield();//如果我們通過yield()來強化這種錯誤,則幾乎每次都會出現錯誤
        ++currentEvenValue;
        return currentEvenValue;
    }

    public static void main(String[] args){
        EvenChecker.test(new EvenGenerator());
    }
}

package lkl;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class EvenChecker implements Runnable {

    private IntGenerator generator;
    private final int id;
    public EvenChecker(IntGenerator g , int ident){
        generator =g;
        id = ident;
    }

    public void run(){
        while(!generator.isCanceled()){
            int val = generator.next();
            if(val%2 !=0){
                System.out.println(val +" not even");
                generator.cancel();
            }
        }
    }

    public static void test(IntGenerator gp , int count){
        ExecutorService exec = Executors.newCachedThreadPool();
        for(int i=0; i<count ; i++)
             exec.execute(new EvenChecker(gp,i));
        exec.shutdown();
    }
    public static void test(IntGenerator gp){
        test(gp,10);
    }
}
//輸出結果:
1383 not even
1387 not even
1385 not even
1381 not even

這段程式碼的邏輯提供一個產生偶數的物件,這個物件有一個變數初始化為0,然後每次呼叫next(),這個變數都會自加兩次;我們開了幾個執行緒對這個物件不斷的呼叫next()函式,退出的條件是檢測到這個變數為奇數。如果以一般的眼光看,這些執行緒應該會無限執行下去,但真正的結果是有很高的一個頻率會退出。而問題正如註釋所示,可能在兩次自加期間出現中斷,這樣就會出現錯誤的結果。如果我們呼叫yield()方法強化這種隨機的中斷,則每次呼叫都會出錯。下面我們要使用幾種不同的加鎖方式來修復這個問題,總的來說可以分成兩類:一是對整個方法進行加鎖,二是隻對臨界區程式碼進行加鎖。

一.對整個方法進行加鎖
要想對整個方法進行加鎖,那麼使用synchronized關鍵字進行修飾是最簡單有效的方法;synchronized關鍵字可以包裝在當前執行緒訪問資源時,其它試圖訪問這個資源的執行緒阻塞。使用synchronized對共享資源的訪問進行控制的一般邏輯是,先將共享資源包裝進一個物件,然後把所有操作這個資源的函式都宣告成synchronized型別。另外還有注意的是我們一定要將共享的域宣告成private的,這樣sychronzied才可以正確的作用。針對每個類,也有一個鎖(作為類的Class物件的一個部分),所以synchronized static 方法就可以在類的範圍內防止對static資料的併發訪問。
對於上面的程式碼我們只需要用synchronized對修改共享變數的next()函式進行修飾,就可以解決問題了:

private int currentEvenValue =0;
    public synchronized int next(){  ///使用synchronized關鍵字修飾,包裝互斥
        ++currentEvenValue; ///可能在這發生不正確的中斷
        //使用synchronized修飾後,就算呼叫yield()方法也不會出問題
        Thread.currentThread().yield();
        ++currentEvenValue;
        return currentEvenValue;
    }

除了使用synchronized關鍵字進行加鎖外,還可以自己顯式的加鎖和解鎖;這依靠與concurrent類庫中的Lock類。常用的Lock類的子類是ReentrantLock,這個類允許你嘗試獲取但是最終未獲得鎖,這樣如果其它人已經取得這個鎖,那麼你就可以離開去執行一些其它的事情,而不是在這裡阻塞了。使用Lock物件一般來說比synchronized要寫更多的程式碼,但是也更具有靈活性一些。使用Lock物件進行鎖定時,一般要用try{}finally{}語句,以保證鎖正確的釋放。使用Lock改寫上面的程式碼如下:

package lkl;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.*;

///使用Lock物件進行顯式的互斥
//這種方式加大程式碼量,但是也更加的靈活
public class MutexEvenGenerator extends IntGenerator{

    private int currentEventValue = 0;
    private Lock lock = new ReentrantLock();
    public  int next(){

        lock.lock(); ///加鎖,一次只允許一個執行緒進入臨界區
        try{
            ++currentEventValue;
            Thread.yield();  //yield()是靜態方法
            ++currentEventValue;
            return currentEventValue;
        }
        finally{
            lock.unlock(); ///finally語句保證一定能解鎖,不會出現死鎖現象
        }
    }
    public static void main(String[] args){
        EvenChecker.test(new MutexEvenGenerator());
    }
}

二.只對臨界區進行加鎖
臨界區指的是訪問共享資源的那一段程式碼。上面的所有的加鎖方式都是對整個方法進行的加鎖,但是有時候我們可以只是需要防止多個執行緒同時訪問方法內部的部分程式碼而不是防止訪問整個方法。很容易想到這樣做的好處是可以提高效率。同樣的也有兩種方法可以實現臨界區的訪問控制,使用Lock物件控制臨界區和上面的示例沒有什麼不同,只是加鎖和解鎖的位置稍有不同而已。使用synchronized對臨界區進行控制,則必須要傳入一個物件才行,這個物件的鎖被用來控制臨界區的程式碼的同步。具體的格式如下:

synchronized(syncObject){
//The code can be accessed
//by only one task at a time
}

這也被稱為同步控制塊;在進入這段程式碼前,必須得到synObject物件的鎖,如果其它執行緒已經得到這個鎖,那麼就只有等到鎖被釋放以後,才能進入臨界區。因為synchronized藉助一個物件的鎖,所以我們可以實現兩個任務可以同時進入同一個物件,只要這個物件的方法是在不同的鎖上同步的即可。如下面的程式碼所示:

package lkl;

///synchronized同時對多個物件加鎖的情況

class DualSynch{
    private Object syncObject = new Object();
    public synchronized void f(){
        for(int i=0; i<5; i++){
            System.out.println("f()");
            Thread.yield();
        }
    }
    public void g(){
        //可以將SyncObject改成this試試
        //this表示的是當前物件
        synchronized(syncObject){
            for(int i=0;i<5;i++){
                System.out.println("g()");
                Thread.yield();
            }
        }
    }
}

public class SyncObject {

    public static void main(String[] args){
        final DualSynch ds = new DualSynch();
        new Thread(){
            public void run(){
                ds.f();
            }
        }.start();
        ds.g();
    }/*
        g()
        f()
        f()
        f()
        f()
        f()
        g()
        g()
        g()
        g()
    */
}

相關文章