[Java併發-5] synchronized同步鎖


前文描述了Java多線程編程,多線程的方式提高了系統資源利用和程序效率,但多個線程同時處理共享的數據時,就將面臨線程安全的問題。

例如,下面模擬這樣一個場景:一個售票處有3個售票員,出售20張票。

public class SellTickets {
    public static void main(String[] args) {
        TicketSeller seller = new TicketSeller();

        Thread t1 = new Thread(seller, "窗口1");
        Thread t2 = new Thread(seller, "窗口2");
        Thread t3 = new Thread(seller, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

class TicketSeller extends Thread {
    private static int tickets = 20;

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {

            }
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票");
            }
        }
    }
}

運行後,發現會出現多個售票員出售同一張票的現象:
在這裏插入圖片描述
爲了解決線程安全的問題,Java提供了多種同步鎖。

1 synchronized 原理概述

1.1 操作系統層面

synchronized的底層是使用操作系統的mutex lock實現的。下面先了解一些相關的概念。

  • 內存可見性:同步塊的可見性是由以下兩個規則獲得的:
    1. 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值。
    2. 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write操作)。
  • 操作原子性:持有同一個鎖的兩個同步塊只能串行地進入

鎖的內存語義:

  • 當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。
  • 當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效,從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。

鎖釋放和鎖獲取的內存語義:

  • 線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發出了(線程A對共享變量所做修改的)消息。
  • 線程B獲取一個鎖,實質上是線程B接收了之前某個線程發出的(在釋放這個鎖之前對共享變量所做修改的)消息。
  • 線程A釋放鎖,隨後線程B獲取這個鎖,這個過程實質上是線程A通過主內存向線程B發送消息

在這裏插入圖片描述

Mutex Lock

監視器鎖(Monitor)本質是依賴於底層的操作系統的Mutex Lock(互斥鎖)來實現的。每個對象都對應於一個可稱爲" 互斥鎖" 的標記,這個標記用來保證在任一時刻,只能有一個線程訪問該對象。

互斥鎖:用於保護臨界區,確保同一時間只有一個線程訪問數據。對共享資源的訪問,先對互斥量進行加鎖,如果互斥量已經上鎖,調用線程會阻塞,直到互斥量被解鎖。在完成了對共享資源的訪問後,要對互斥量進行解鎖。

mutex的工作方式:
在這裏插入圖片描述

  1. 申請mutex,如果成功,則持有該mutex,如果失敗,則進行spin自旋. spin的過程就是在線等待mutex, 不斷髮起mutex gets, 直到獲得mutex或者達到spin_count限制爲止
  2. 依據工作模式的不同選擇yiled還是sleep
  3. 若達到sleep限制或者被主動喚醒或者完成yield, 則重複1-2步,直到獲得爲止

由於Java的線程是映射到操作系統的原生線程之上的,如果要阻塞或喚醒一條線程,都需要操作系統來幫忙完成,這就需要從用戶態轉換到內核態中,因此狀態轉換需要耗費很多的處理器時間。所以synchronized是Java語言中的一個重量級操作。在JDK1.6中,虛擬機進行了一些優化,譬如在通知操作系統阻塞線程之前加入一段自旋等待過程,避免頻繁地切入到核心態中。

synchronized與java.util.concurrent包中的ReentrantLock相比,由於JDK1.6中加入了針對鎖的優化措施(見後面),使得synchronized與ReentrantLock的性能基本持平。ReentrantLock只是提供了synchronized更豐富的功能,而不一定有更優的性能,所以在synchronized能實現需求的情況下,優先考慮使用synchronized來進行同步。

1.2 JVM層面

synchronized用的鎖是存在Java對象頭裏的,那麼什麼是Java對象頭呢?

Hotspot虛擬機的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。其中Klass Point是是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例,Mark Word用於存儲對象自身的運行時數據,它是實現輕量級鎖和偏向鎖的關鍵,所以下面將重點闡述。

長度 內容 說明
32/64bit Mark Word 存儲對象的hashCode 或鎖信息
32/64bit Class Metadata Address 存儲對象類型數據的指針
32/64bit Array length 數組的長度(如果當前對象是數組)

Mark Word

Mark Word用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等等。Java對象頭一般佔有兩個機器碼(在32位虛擬機中,1個機器碼等於4字節,也就是32bit),但是如果對象是數組類型,則需要三個機器碼,因爲JVM虛擬機可以通過Java對象的元數據信息確定Java對象的大小,但是無法從數組的元數據來確認數組的大小,所以用一塊來記錄數組長度。

對象頭信息是與對象自身定義的數據無關的額外存儲成本,但是考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘量多的數據,它會根據對象的狀態複用自己的存儲空間,也就是說,Mark Word會隨着程序的運行發生變化,變化狀態如下(32位虛擬機):
在這裏插入圖片描述

Monitor

什麼是Monitor?我們可以把它理解爲一個同步工具,也可以描述爲一種同步機制,它通常被描述爲一個對象。

與一切皆對象一樣,所有的Java對象是天生的Monitor,每一個Java對象都有成爲Monitor的潛質,因爲在Java的設計中 ,每一個Java對象自打孃胎裏出來就帶了一把看不見的鎖,它叫做內部鎖或者Monitor鎖。

Monitor 是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor關聯(對象頭的MarkWord中的LockWord指向monitor的起始地址),同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。

其結構如下:
在這裏插入圖片描述

  • Owner:初始時爲NULL表示當前沒有任何線程擁有該monitor record,當線程成功擁有該鎖後保存線程唯一標識,當鎖被釋放時又設置爲NULL。
  • EntryQ:關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的線程。
  • RcThis:表示blocked或waiting在該monitor record上的所有線程的個數。
  • Nest:用來實現重入鎖的計數。HashCode:保存從對象頭拷貝過來的HashCode值(可能還包含GC age)。
  • Candidate:用來避免不必要的阻塞或等待線程喚醒,因爲每一次只有一個線程能夠成功擁有鎖,如果每次前一個釋放鎖的線程喚醒所有正在阻塞或等待的線程,會引起不必要的上下文切換(從阻塞到就緒然後因爲競爭鎖失敗又被阻塞)從而導致性能嚴重下降。Candidate只有兩種可能的值,0表示沒有需要喚醒的線程,1表示要喚醒一個繼任線程來競爭鎖。

2 synchronized 使用

synchronized是Java中的關鍵字,是一種同步鎖,它修飾的對象有以下幾種:

序號 類別 作用範圍 作用對象
1 同步代碼塊 被synchronized修飾的代碼塊 調用這個代碼塊的單個對象
2 同步方法 被synchronized修飾的方法 調用該方法的單個對象
3 同步靜態方法 被synchronized修飾的靜態方法 靜態方法所屬類的所有對象
4 同步類 被synchronized修飾的代碼塊 該類的所有對象

2.1 同步代碼塊

同步代碼塊就是將需要的同步的代碼使用同步鎖包裹起來,這樣能減少阻塞,提高程序效率。

同步代碼塊格式如下:

    synchronized(對象){
    	同步代碼;
    }

同樣對於文章開頭賣票的例子,進行線程安全改造,代碼如下:

public class SellTickets {
    public static void main(String[] args) {
        TicketSeller seller = new TicketSeller();

        Thread t1 = new Thread(seller, "窗口1");
        Thread t2 = new Thread(seller, "窗口2");
        Thread t3 = new Thread(seller, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

class TicketSeller implements Runnable {
    private static int tickets = 100;

    @Override
    public void run() {
        while (true) {
            synchronized (this) {
                try {
                    Thread.sleep(10);
                    if (tickets > 0) {
                        System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

同步代碼塊的關鍵在於鎖對象,多個線程必須持有同一把鎖,纔會實現互斥性。

將上面代碼中的 synchronized (this) 改爲 synchronized (new Objcet()) 的話,線程安全將得不到保證,因爲兩個線程的持鎖對象不再是同一個。

又比如下面這個例子:

public class SyncTest implements Runnable {
    // 共享資源變量
    int count = 0;

    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + ":" + count++);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
//        test1();
        test2();
    }

    public static void test1() {
        SyncTest syncTest1 = new SyncTest();
        Thread thread1 = new Thread(syncTest1, "thread-1");
        Thread thread2 = new Thread(syncTest1, "thread-2");
        thread1.start();
        thread2.start();
    }

    public static void test2() {
        SyncTest syncTest1 = new SyncTest();
        SyncTest syncTest2 = new SyncTest();

        Thread thread1 = new Thread(syncTest1, "thread-1");
        Thread thread2 = new Thread(syncTest2, "thread-2");
        thread1.start();
        thread2.start();
    }
}

從輸出結果可以看出,test2() 方法無法實現線程安全,原因在於我們指定鎖爲this,指的就是調用這個方法的實例對象,然而 test2() 實例化了兩個不同的實例對象 syncTest1,syncTest2,所以會有兩個鎖,thread1與thread2分別進入自己傳入的對象鎖的線程執行 run() 方法,造成線程不安全。

如果要使用這個經濟實惠的鎖並保證線程安全,那就不能創建出多個不同實例對象。如果非要想 new 兩個不同對象出來,又想保證線程同步的話,那麼 synchronized 後面的括號中可以填入SyncTest.class,表示這個類對象作爲鎖,自然就能保證線程同步了。

synchronized(xxxx.class){
  //todo
}

一個線程訪問一個對象的synchronized代碼塊時,別的線程可以訪問該對象的非synchronized代碼塊而不受阻塞。

例如下面的例子:

public class SyncTest {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread1 = new Thread(counter, "線程-1");
        Thread thread2 = new Thread(counter, "線程-2");
        thread1.start();
        thread2.start();
    }
}

class Counter implements Runnable {
    private int count = 0;

    public void countAdd() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + " 同步計數:" + (count++));
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public void printCount() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + " 非同步輸出:" + count);
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void run() {
        String threadName = Thread.currentThread().getName();
        if (threadName.equals("線程-1")) {
            countAdd();
        } else if (threadName.equals("線程-2")) {
            printCount();
        }
    }
}

我們也可以用synchronized 給對象加鎖。這時,當一個線程訪問該對象時,其他試圖訪問此對象的線程將會阻塞,直到該線程訪問對象結束。也就是說誰拿到那個鎖誰就可以運行它所控制的那段代碼,,例如下例:

public class SyncTest {
    public static void main(String args[]) {
        Account account = new Account("zhang san", 10000.0f);
        AccountOperator accountOperator = new AccountOperator(account);

        final int THREAD_NUM = 5;
        Thread threads[] = new Thread[THREAD_NUM];
        for (int i = 0; i < THREAD_NUM; i++) {
            threads[i] = new Thread(accountOperator, "Thread-" + i);
            threads[i].start();
        }
    }
}

class Account {
    String name;
    double amount;

    public Account(String name, double amount) {
        this.name = name;
        this.amount = amount;
    }

    //存錢
    public void deposit(double amt) {
        amount += amt;
        try {
            Thread.sleep(0);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    //取錢
    public void withdraw(double amt) {
        amount -= amt;
        try {
            Thread.sleep(0);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public double getBalance() {
        return amount;
    }
}

class AccountOperator implements Runnable {
    private Account account;

    public AccountOperator(Account account) {
        this.account = account;
    }

    public void run() {
        synchronized (account) {
            String name = Thread.currentThread().getName();
            account.deposit(500);
            System.out.println(name + "存入500,最新餘額:" + account.getBalance());
            account.withdraw(400);
            System.out.println(name + "取出400,最新餘額:" + account.getBalance());
            System.out.println(name + "最終餘額:" + account.getBalance());
        }
    }
}

同步鎖可以使用任意對象作爲鎖,當沒有明確的對象作爲鎖,只是想讓一段代碼同步時,可以創建一個特殊的對象來充當鎖:

class Test implements Runnable {
   private byte[] lock = new byte[0];  // 特殊的instance變量
   public void method() {
      synchronized(lock) {
         // todo 同步代碼塊
      }
   }
 
   public void run() {
 
   }
}

2.2 同步方法

Synchronized修飾一個方法很簡單,就是在方法的前面加synchronized,synchronized修飾方法和修飾一個代碼塊類似,只是作用範圍不一樣,修飾代碼塊是大括號括起來的範圍,而修飾方法範圍是整個函數。

public synchronized void method(){
   // todo
}

下面用同步函數的方式解決售票場景的線程安全問題,代碼如下:

public class SellTickets {
    public static void main(String[] args) {
        TicketSeller seller = new TicketSeller();

        Thread t1 = new Thread(seller, "窗口1");
        Thread t2 = new Thread(seller, "窗口2");
        Thread t3 = new Thread(seller, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

class TicketSeller implements Runnable {
    private static int tickets = 100;

    @Override
    public void run() {
        while (true) {
            sellTickets();
        }
    }

    public synchronized void sellTickets() {
        try {
            Thread.sleep(10);
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() + "正在出售第" + (tickets--) + "張票");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

同步方法有以下特徵:

  1. synchronized關鍵字不能繼承。 雖然可以使用synchronized來定義方法,但synchronized並不屬於方法定義的一部分,因此,synchronized關鍵字不能被繼承。如果在父類中的某個方法使用了synchronized關鍵字,而在子類中覆蓋了這個方法,在子類中的這個方法默認情況下並不是同步的,而必須顯式地在子類的這個方法中加上synchronized關鍵字纔可以。當然,還可以在子類方法中調用父類中相應的方法,這樣雖然子類中的方法不是同步的,但子類調用了父類的同步方法,因此,子類的方法也就相當於同步了。
  2. 在定義接口方法時不能使用synchronized關鍵字。
  3. 構造方法不能使用synchronized關鍵字,但可以使用synchronized代碼塊來進行同步。

2.3 同步靜態方法

Synchronized也可修飾一個靜態方法,靜態方法是不屬於當前實例的,而是屬性類的,那麼這個鎖就是類的class對象鎖。同步靜態方法可以解決同步方法和同步代碼塊中的一個問題:new 兩個對象的話,等於有兩把鎖,無法保證線程安全。

public class SyncTest {
    public static void main(String args[]) {
        SyncThread syncThread1 = new SyncThread();
        SyncThread syncThread2 = new SyncThread();
        Thread thread1 = new Thread(syncThread1, "Thread-1");
        Thread thread2 = new Thread(syncThread2, "Thread-2");
        thread1.start();
        thread2.start();
    }
}

class SyncThread implements Runnable {
    private static int count = 0;

    public synchronized static void method() {
        for (int i = 0; i < 5; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public synchronized void run() {
        method();
    }
}

syncThread1 和 syncThread2 是 SyncThread 的兩個對象,但在 thread1 和 thread2 併發執行時卻保持了線程同步。這是因爲run中調用了靜態方法method,而靜態方法是屬於類的,所以syncThread1和syncThread2相當於用了同一把鎖。

2.4 同步類

Synchronized還可作用於一個類,用法如下:

class ClassName {
   public void method() {
      synchronized(ClassName.class) {
         // todo
      }
   }
}

同步類與同步靜態方法有相同的效果,該類的所有對象都是持有同一把鎖:

public class SyncTest {
    public static void main(String args[]) {
        SyncThread syncThread1 = new SyncThread();
        SyncThread syncThread2 = new SyncThread();
        Thread thread1 = new Thread(syncThread1, "Thread-1");
        Thread thread2 = new Thread(syncThread2, "Thread-2");
        thread1.start();
        thread2.start();
    }
}

class SyncThread implements Runnable {
    private static int count = 0;

    public void method() {
        synchronized (SyncThread.class) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public synchronized void run() {
        method();
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章