From Gossip@Openhome

Java Gossip: 執行緒的同步化

如果您的程式只是一個單執行緒,單一流程的程式,那麼通常您只要注意到程式邏輯的正確,您的程式通常就可以正確的執行您想要的功能,但當您的程式是多執行緒程式,多流程同時執行時,那麼您就要注意到更多的細節,例如在多執行緒共用同一物件的資料時。

如果一個物件所持有的資料可以被多執行緒同時共享存取時,您必須考慮到「資料同步」的 問題,所謂資料同步指的是兩份資料的整體性一致,例如物件A有 name與id兩個屬性,而有一份A1資料有name與id的資料要更新物件A的屬性,如果A1的name與id設定給A物件完成,則稱A1與A同步,如 果A1資料在更新了物件的name屬性時,突然插入了一份A2資料更新了A物件的id屬性,則顯然的A1資料與A就不同步,A2資料與A也不同步。

資料在多執行緒下共享時,就容易因為同時多個執行緒可能更新同一個物件的資訊,而造成物件資料的不同步,因為資料的不同步而可能引發的錯誤通常不易察覺, 而且可能是在您程式執行了幾千幾萬次之後,才會發生錯誤,而這通常會發生在您的產品已經上線之後,甚至是程式已經執行了幾年之後。

這邊舉個簡單的例子,考慮您設計這麼一個類別:

  • PersonalInfo.java
package onlyfun.caterpillar;

public class PersonalInfo {
private String name;
private String id;
private int count;

public PersonalInfo() {
name = "nobody";
id = "N/A";
}

public void setNameAndID(String name, String id) {
this.name = name;
this.id = id;
if(!checkNameAndIDEqual()) {
System.out.println(count +
") illegal name or ID.....");
}
count++;
}

private boolean checkNameAndIDEqual() {
return (name.charAt(0) == id.charAt(0)) ?
true : false;
}
}

在這個類別中,您可以設定使用者的名稱與縮寫id,並簡單檢查一下名稱與id的第一個字是否相同,單就這個類別本身而言,它並沒有任何的錯誤,但如果它被 用於多執行緒的程式中,而且同一個物件被多個執行存取時,就會"有可能"發生錯誤,來寫個簡單的測試程式:
  • SynchronizedDemo.java
package onlyfun.caterpillar;

public class SynchronizedDemo {
public static void main(String[] args) {
final PersonalInfo person = new PersonalInfo();
Thread thread1 = new Thread(new Runnable() {
public void run() {
while(true)
person.setNameAndID("Justin Lin", "J.L");
}
});

Thread thread2 = new Thread(new Runnable() {
public void run() {
while(true)
person.setNameAndID("Shang Hwang", "S.H");
}
});

System.out.println("Start testing.....");

thread1.start();
thread2.start();
}
}

來看一下執行時的一個例子:
Start testing.....
822949) illegal name or ID.....
1443074) illegal name or ID.....
1750512) illegal name or ID.....
2587632) illegal name or ID.....
2805877) illegal name or ID.....
3705555) illegal name or ID.....
4000077) illegal name or ID..... 


看到了嗎?如果以單執行緒的觀點來看,上面的訊息在測試中根本不可能出現,然而在這個程式中卻出現了錯誤,而且重點是,第一次錯誤是發生在第822949 次的設定(您的電腦上可能是不同的數字),如果您在程式完成並開始應用之後,這個時間點可能是幾個月甚至幾年之後。

問題出現哪?在於這邊:
     public void setNameAndID(String name, String id) {
       this.name = name;
       this.id = id;
       if(!checkNameAndIDEqual()) {
           System.out.println(count +
                   ") illegal name or ID.....");
       }
       count++;
    }
 

雖然您設定給它的參數並沒有問題,在某個時間點時,thread1設定了"Justin Lin", "J.L"給name與id,在進行測試的前一刻,thread2可能此時剛好呼叫setNameAndID("Shang Hwang", "S.H"),在name被設定為"Shang Hwang"時,checkNameAndIDEqual()開始執行,此時name等於"Shang Hwang",而id還是"J.L",所以checkNameAndIDEqual()就會傳回false,結果就顯示了錯誤訊息。

您必須同步資料對物件的更新,也就是在有一個執行緒正在設定person物件的資料時,不可以又被另一個執行緒同時進行設定,您可以使用"synchronized"關鍵字來進行這個動作。

"synchronized"的一個使用方式是用於方法上,讓方法作用範圍內都成為被同步化區域,例如:
    public synchronized void setNameAndID(String name,
                                          String id) {
       this.name = name;
       this.id = id;
       if(!checkNameAndIDEqual()) {
           System.out.println(count +
                   ") illegal name or ID.....");
       }
       count++;
    }
 

每個物件內部都會有一個鎖定(lock),當執行緒執行某個物件的同步化方法時,它會在物件上得到這個鎖定,只有取得鎖定的執行緒才可進入同步區,未取得鎖定的執行緒則必須等待,直到有機會取得鎖定,其它執行緒必須等目前執行緒先執行完同步化方法,並解除對物件的鎖定,才有機會取得物件上的鎖定。

就這個例子來說,簡單的說,就是有執行緒在執行setNameAndID()時,會從物件上取得鎖定,其它執行緒必須等待它執行完畢,釋放鎖定之後,才會有機會競爭鎖定,取得鎖定的執行緒才可以執行setNameAndID ()。

以上所介紹的是實例方法同步化(instance method synchronized),同步化的設定不只可用於方法上,也可以用於某個程式區塊上,稱之為實例區塊同步化(instance block synchronized),例如:
    public void setNameAndID(String name, String id) {
        synchronized(this) {
            this.name = name;
            this.id = id;
            if(!checkNameAndIDEqual()) {
                System.out.println(count +
                        ") illegal name or ID.....");
            }
            count++;
        }
    }
 

上面的意思就是在執行緒執行至"synchronized"設定的區塊時取得物件的鎖定,這麼一來其它執行緒暫時無法取得鎖定,因此無法執行物件同步化區塊,這個方式可以應 用於您不想鎖定整個方法區塊,而只是想在共享資料在被執行緒存取時確保同步化時,由於只鎖定方法中的某個區塊,在執行完區塊後即釋放對物件的鎖定,以便讓 其它執行緒有機會取得鎖定,對物件進行操作,在某些時候會比較有效率。

實例區塊同步化的好處是,您也可以對某個物件進行同步化,而像實例方法同步化只針對this,例如在多執行緒存取同一個ArrayList物件時,ArrayList並沒有實作資料存取時的同步化,所以它使用於多執 行緒時,必須注意是否必須對它進行同步化,多個執行緒存取同一個ArrayList時,有可能發生兩個以上的執行緒將資料存入 ArrayList的同一個位置,造成資料的相互覆蓋,為了確保資料存入時的正確性,您可以在存取ArrayList物件時對它進行同步化,例如:
 // arraylist參考至一個ArrayList的一個實例
 synchronized(arraylist) {
    arraylist.add(new SomeClass());
 }
 
除了針對物件同步之外,您還可以針對靜態方法同步化(static method synchronized),例如某個static成員會被多執行緒存取時,則可以如下設定:
public class Some {
    private static int value;

    public synchronized static void some() {
        value++;
        ....
    }
}

進行鎖定時,會鎖定Some.class,因而static成員也受到保護。類似於實例區塊同步化,您也可以在區塊中鎖定整個類別,稱之為類別字面同步化(class literals synchronized),例如:
...
public void doSomething() {
    synchronized(Some.class) {
        ....
    }
}
...

事實上,您也可以使用Collections的synchronizedXXX()等方法來傳回一個同步化的容器物件,例如傳回一個同步化的List:
List list = Collections.synchronizedList(new ArrayList());
 

同步化所犧性的自然就是在於執行緒等待時的延遲,所以同步化的手法不應被濫用,您不用將整個物件的方法都加上"synchronized",有些方法只是單純的傳回某些數值,它並沒有對共用資料進行修改的動作,那麼它就不需要被同步化。