Thinking in java 之并發其二:資源共享

一、 多線程資源共享問題

在單線程的情況下,我們很少去考慮資源沖突的問題。而在多線程中,單個實例的某個方法或者變量會經常出現被多個線程訪問的情況。最常見的問題,在線程A訪問f()進行到一半時,線程B也調用了f()方法。這很容易導致資源使用時出現我們不愿意見到的情況。比如下面這個例子。
Thinking in Java 中,以生產整數作為測試用例,先用一個虛擬類作為生產整數的標準

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;
    @Override
    public int next() {
        // TODO Auto-generated method stub
        ++currentEvenValue;
        Thread.yield();
        ++currentEvenValue;
        return currentEvenValue;
    }
    public static void main(String[] args) {
        EvenChecker.test(new EvenGenerator());
    }
}

EvenGenerator 是一個偶數生成器,它包含一個變量 currentEvenValue,初始值是0。同時,它的 next() 方法會對 currentEvenValue 進行兩次自增操作,并返回自增后的值。在理想情況下,我們每次通過next() 獲得的都是偶數。

但是,當多個任務對 next() 進行調用時,是否會出現,currentEvenValue 完成第一次自增之后,其他任務也開始調用 next() 并且自增兩次,此時,我們將會獲得一個奇數。為了證明這一點,我們通過 EvenChecker 來對 EvenGenerator 進行多線程操作。

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 generator,int ident) {
        this.generator = generator;
        this.id = ident;
    }
    @Override
    public void run() {
        // TODO Auto-generated method stub
        while(!generator.isCanceled()) {
            int val=generator.next();
            if(val % 2 != 0) {
                System.out.println(val + " not even!");
                generator.cancel();
            }else {
                System.out.println(val + " is even!");
            }

        }

    }

    public static void test(IntGenerator generator,int count) {
        System.out.println("print Ctrl+C to exit");
        ExecutorService exec = Executors.newCachedThreadPool();
        for(int i=0;i<count;i++) {
            exec.execute(new EvenChecker(generator,i));
        }
        exec.shutdown();
    }
    public static void test(IntGenerator generator) {
        test(generator,10);
    }

}

EvenChecker 會創建多個線程,每個線程會都對不斷的調用 EvenGenerator 的 next() 方法。當 next() 返回一個偶數時,該線程會繼續進行對 next() 的調用。而當出現奇數時,任務被終止。

無論實驗多少次,EvenChecker 總會在某個時刻終止,說明,的確會出現上文所述的情況。(注意:main 方法在 EvenGenerator 里)

在進行多線程開發,共享資源需要被謹慎處理。通過一些手段,可以保證,當一個任務使用某個資源時,其他任務只能等待該任務使用完成。

二、給資源上鎖

一個行之有效的辦法是在對出現資源沖突的方法或代碼塊使用 synchronized 關鍵字。
對于一個特定對象,當一個任務在使用被 synchronized 修飾的資源時,對象里所有被 synchronized 的資源都會被鎖定,我們將之稱之為“上鎖”,而“解鎖”則是在任務完成對資源的調用之后自動實現。“解鎖”之后的資源可以再一次被其他任務使用。
現在使用 synchronized 完善上文的代碼。
首先需要建立一個偶數生成器,并用 synchronized 修飾它的 next() 方法。

public class SynchronizedGenerator extends IntGenerator {

    private int currentEvenValue = 0;
    @Override
    public synchronized int next() {
        // TODO Auto-generated method stub
        ++currentEvenValue;
        Thread.yield();
        ++currentEvenValue;
        return currentEvenValue;
    }

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

}

然后,用 EvenChecker 來使用 synchronizedGenerator。結果是,除非我么手動停止,否則程序任務將會無限的循環下去。

synchronized 除了可以修飾方法,也可以修飾方法內部的某個代碼塊(通常稱這個代碼塊為“臨界區”)。因此,當我們只是想防止方法中的部分代碼(而不是整個方法)被多個線程同時訪問時,也可以使用synchronized。被 synchronized 修飾的代碼塊也被成為“同步控制塊”。

synchronized 的上鎖和解鎖過程,是 java 幫我們自動去實現的。如果需要一個顯性的上鎖和解鎖過程,可以使用 java.util.concurrent.locks中的顯示互斥機制。我們可以在程序運行到某個位置時上鎖或者解鎖。下面的代碼,是使用 lock 實現互斥的例子。

package ThreadTest.SycnSourceTest;

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

public class MutexEvenGenerator extends IntGenerator {

    private int currentEvenValue = 0;
    private Lock lock = new ReentrantLock();
    @Override
    public int next() {
        // TODO Auto-generated method stub
        lock.lock();
        try {
            ++currentEvenValue;
            Thread.yield();
            ++currentEvenValue;
            return currentEvenValue;
        }finally {
            lock.unlock();
        }

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

從運行結果來看,lock 的確起到了和 synchronized 同等的效果。

為了保證在任務的最后都能夠正確的解鎖,我們必須在 finally 塊中對 lock 進行解鎖。

除了能夠顯性的執行“鎖”操作,lock 還可以用來實現“如果一段時間未能獲取鎖,則放棄獲取鎖這一行為”的操作。我們甚至能夠自己指定“獲取鎖”這一行為的嘗試時間。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class AttemptLocking {

    private ReentrantLock lock = new ReentrantLock();
    public void untimed() {
        boolean captured = lock.tryLock();
        try {
            System.out.println("tryLock(): "+captured);
        }finally {
            if(captured)
                lock.unlock();
        }
    }

    public void timed() {
        boolean captured = false;
        try {
            captured = lock.tryLock(2, TimeUnit.SECONDS);
        }catch(InterruptedException e) {
            throw new RuntimeException();
        }
        try {
            System.out.println("tryLock(2,TimeUnit.SECONDS): "+captured);
        }finally {
            if(captured)
                lock.unlock();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        final AttemptLocking al = new AttemptLocking();
        al.untimed();
        al.timed();
        new Thread() {
            {setDaemon(true);}
            public void run() {
                al.lock.lock();
                System.out.println("acquired");
            }
        }.start();
        TimeUnit.MILLISECONDS.sleep(1000);
        al.untimed();
        al.timed();
    }

}
/*output:
tryLock(): true
tryLock(2,TimeUnit.SECONDS): true
acquired
tryLock(): false
tryLock(2,TimeUnit.SECONDS): false*/

這段程序時這樣的,主程序第一次調用 al.timed 和 al.timed 的時候,它們都順利的獲得鎖。然后我們 新建了一個 Thread 這個 Thread 獲取的 al 的鎖,并且一直沒有釋放,所以當我們再執行 al.timed 和 al.timed 是,就會出現獲取失敗的結果。

tyrLock 有自己的默認嘗試時間,或者我們通過構造參數的方式去定義它的嘗試時間,嘗試時間結束之后,tryLock會放棄獲取鎖的操作。

三、原子性和易變性

原子操作是指不能被線程調度機制中斷的操作,即,一旦操作發生,它必然會在切換到其他線程之前完成。但是“原子操作不需要進行同步控制”是一個錯誤的結論。

原子性可以應用于除了 long 和 double 之外的所有基本類型之上的操作,可以保證它們會被當做不可分(原子)的操作來操作內存。但是,通過在定義 long 和 double 時使用 volatile 關鍵字就會獲得(簡單的賦值與返回值操作)原子性。

同事,volatile 還確保了應用 中的可視性。如果將一個域聲明為 volatile,那么只要對這個域產生寫操作,那么所有的讀操作就都可以看到這個修改。簡而言之,volatile 域上發生的變化會變立刻寫入到主存中。

volatile 與 sychronized:如果一個域會被多個任務訪問,那么它應該是 volatile 的,否則這個域就應該只能經由同步來訪問。如果一個域已經用 sychronized 來防護,那就不必將其設置為 volatile的。相比較于 volatile,更應該優先使用sychronized。

為了滿足一些性能優化需求,java 為我們提供了,AtomicInteger、AtomicLong、AtomReference 等特殊的原子性變量類。這些類的操作是機器級別的原子操作,因此在使用它們時,不必擔心。

一個任務所有的寫入操作,對于這個任務的讀操作都是可視的,因此,如果它只需要保證這個任務的內部可視,不必將其設置為 volatile的。

重點:當一個域的值依賴于它之前的值(比如遞增一個計數器),volatile 就無非進行工作。或者某個域的值收到其他域的值限制,它也無法工作。(例如,Range 類的 lower 和 upper 邊界必須遵循 lower <= upper 的限制)。

四、在其他對象上同步

首先,先看下面這段代碼:

import java.util.concurrent.TimeUnit;

class DualSynch {
    private Object syncObject = new Object();
    public synchronized void f() {
        for(int i=0;i<5;i++) {
            System.out.println("f()");
            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void g() {
        synchronized(syncObject) {
            for(int i=0;i<5;i++) {
                System.out.println("g()");
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

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

輸出的結果告訴我們,f() 和 g() 這兩個方法顯然不受同步控制的影響。這是因為它們的鎖是兩個不同的鎖,f() 鎖針對的事該對象自己(this),而g() 鎖針對的是 syncObject。如果我們將 synchronized(syncObject) 換成 synchronized(this),就會得到不一樣的結果。

五、線程的本地存儲

防止任務在共享資源上產生沖突的第二種方式是根除對變量的共享。線程的本地存儲可以為每個任務創造一個相應存儲塊,即如果有5個任務需要用到變量 x,本地線程就會生成5個用于 X 的不同的存儲塊。

package ThreadTest.SycnSourceTest.concurrency;

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

class Accessor implements Runnable{
    private final int id;
    public Accessor(int idn) {id=idn;}
    public void run() {
        while(!Thread.currentThread().isInterrupted()) {
            ThreadLocalVariableHolder.increment();
            System.out.println(this);
        }
    }
    public String toString() {
        return "#"+id+": "+ThreadLocalVariableHolder.get();
    }

}
public class ThreadLocalVariableHolder {

    public static ThreadLocal<Integer> value = new ThreadLocal<Integer>() {
        private Random rand = new Random(47);
        protected synchronized Integer initialValue() {
            return rand.nextInt(10000);
        }
    };
    public static void increment() {
        value.set(value.get()+1);
    }
    public static int get() {return value.get();}

    public static void main(String[] args) throws InterruptedException {
        // TODO Auto-generated method stub
        ExecutorService exec = Executors.newCachedThreadPool();
        for(int i=0;i<5;i++) {
            exec.execute(new Accessor(i));
        }
        TimeUnit.MILLISECONDS.sleep(4);
        exec.shutdown();
    }


}

上述的代碼中,每個任務都似乎在獨立的計數,彼此不受影響。這是因為每個單獨的線程都被分配了自己的存儲空間。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容