Java™ 教程(同步)

同步

線程主要經過共享對字段和引用對象的引用字段的訪問來進行通訊,這種通訊形式很是有效,但可能產生兩種錯誤:線程干擾和內存一致性錯誤,防止這些錯誤所需的工具是同步。html

可是,同步可能會引入線程競爭,當兩個或多個線程同時嘗試訪問同一資源並致使Java運行時更慢地執行一個或多個線程,甚至暫停它們執行,飢餓和活鎖是線程競爭的形式。java

本節包括如下主題:c++

  • 線程干擾描述了當多個線程訪問共享數據時如何引入錯誤。
  • 內存一致性錯誤描述了由共享內存的不一致視圖致使的錯誤。
  • 同步方法描述了一種簡單的語法,能夠有效地防止線程干擾和內存一致性錯誤。
  • 隱式鎖和同步描述了一種更通用的同步語法,並描述了同步是如何基於隱式鎖的。
  • 原子訪問討論的是不能被其餘線程干擾的操做的通常概念。

線程干擾

考慮一個名爲Counter的簡單類:git

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }

}

Counter的設計爲每次increment的調用都會將c加1,每次decrement的調用都會從c中減去1,可是,若是從多個線程引用Counter對象,則線程之間的干擾可能會妨礙這種狀況按預期發生。程序員

當兩個操做在不一樣的線程中運行但做用於相同的數據時,會發生干擾,這意味着這兩個操做由多個步驟組成,而且步驟序列交疊。github

對於Counter實例的操做彷佛不可能進行交錯,由於對c的兩個操做都是單個簡單的語句,可是,即便是簡單的語句也能夠由虛擬機轉換爲多個步驟,咱們不會檢查虛擬機採起的具體步驟 — 只需知道單個表達式c++能夠分解爲三個步驟:編程

  1. 檢索c的當前值。
  2. 將檢索的值增長1。
  3. 將增長的值存儲在c中。

表達式c--能夠以相同的方式分解,除了第二步是遞減而不是遞增。segmentfault

假設在大約同一時間,線程A調用increment,線程B調用decrement,若是c的初始值爲0,則它​​們的交錯操做可能遵循如下順序:api

  1. 線程A:檢索c
  2. 線程B:檢索c
  3. 線程A:遞增檢索值,結果是1
  4. 線程B:遞減檢索值,結果是-1
  5. 線程A:將結果存儲在c中,c如今是1
  6. 線程B:將結果存儲在c中,c如今是-1

線程A的結果丟失,被線程B覆蓋,這種特殊的交錯只是一種可能性,在不一樣的狀況下,多是線程B的結果丟失,或者根本沒有錯誤,由於它們是不可預測的,因此難以檢測和修復線程干擾錯誤。安全

內存一致性錯誤

當不一樣的線程具備應該是相同數據的不一致視圖時,會發生內存一致性錯誤,內存一致性錯誤的緣由很複雜,超出了本教程的範圍,幸運的是,程序員不須要詳細瞭解這些緣由,所須要的只是避免它們的策略。

避免內存一致性錯誤的關鍵是理解先發生關係,這種關係只是保證一個特定語句的內存寫入對另外一個特定語句可見,要了解這一點,請考慮如下示例,假設定義並初始化了一個簡單的int字段:

int counter = 0;

counter字段在兩個線程A和B之間共享,假設線程A遞增counter

counter++;

而後,不久以後,線程B打印出counter

System.out.println(counter);

若是兩個語句已在同一個線程中執行,則能夠安全地假設打印出的值爲「1」,但若是兩個語句在不一樣的線程中執行,則打印出的值可能爲「0」,由於沒法保證線程A對counter的更改對線程B可見 — 除非程序員在這兩條語句之間創建了先發生關係。

有幾種操做能夠建立先發生關係,其中之一是同步,咱們將在下面的部分中看到。

咱們已經看到了兩種建立先發生關係的操做。

  • 當一個語句調用Thread.start時,與該語句具備一個先發生關係的每一個語句也與新線程執行的每一個語句都有一個先發生關係,致使建立新線程的代碼的效果對新線程可見。
  • 當一個線程終止並致使另外一個線程中的Thread.join返回時,已終止的線程執行的全部語句與成功join後的全部語句都有一個先發生關係,線程中代碼的效果如今對執行join的線程可見。

有關建立先發生關係的操做列表,請參閱java.util.concurrent包的Summary頁面

同步方法

Java編程語言提供了兩種基本的同步語法:同步方法和同步語句,下兩節將介紹兩個同步語句中較爲複雜的語句,本節介紹同步方法。

要使方法同步,只需將synchronized關鍵字添加到其聲明:

public class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }
}

若是countSynchronizedCounter的一個實例,那麼使這些方法同步有兩個效果:

  • 首先,不可能對同一對象上的兩個同步方法的調用進行交錯,當一個線程正在爲對象執行同步方法時,調用同一對象的同步方法的全部其餘線程阻塞(暫停執行),直到第一個線程使用完對象爲止。
  • 其次,當一個同步方法退出時,它會自動與同一個對象的同步方法的任何後續調用創建一個先發生關係,這能夠保證對象狀態的更改對全部線程均可見。

請注意,構造函數沒法同步 — 將synchronized關鍵字與構造函數一塊兒使用是一種語法錯誤,同步構造函數沒有意義,由於只有建立對象的線程在構造時才能訪問它。

構造將在線程之間共享的對象時,要很是當心對對象的引用不會過早「泄漏」,例如,假設你要維護一個包含每一個類實例的名爲 instancesList,你可能想要將如下行添加到你的構造函數中: instances.add(this);

可是其餘線程能夠在構造對象完成以前使用instances來訪問對象。

同步方法支持一種簡單的策略來防止線程干擾和內存一致性錯誤:若是一個對象對多個線程可見,則對該對象的變量全部讀取或寫入都是經過synchronized方法完成的(一個重要的例外:一旦構造了對象,就能夠經過非同步方法安全地讀取構造對象後沒法修改的final字段),這種策略頗有效,但可能會帶來活性問題,咱們將在本課後面看到。

固有鎖和同步

同步是圍繞稱爲固有鎖或監控鎖的內部實體構建的(API規範一般將此實體簡稱爲「監視器」。),固有鎖在同步的兩個方面都起做用:強制執行對對象狀態的獨佔訪問,並創建對可見性相當重要的先發生關係。

每一個對象都有一個與之關聯的固有鎖,按照約定,須要對對象字段進行獨佔和一致訪問的線程必須在訪問對象以前獲取對象的固有鎖,而後在完成它們時釋放固有鎖。線程在獲取鎖和釋放鎖期間被稱爲擁有固有鎖,只要一個線程擁有固有鎖,沒有其餘線程能夠得到相同的鎖,另外一個線程在嘗試獲取鎖時將阻塞。

當線程釋放固有鎖時,在該操做與同一鎖的任何後續獲取之間創建先發生關係。

同步方法中的鎖

當線程調用同步方法時,它會自動獲取該方法對象的固有鎖,並在方法返回時釋放它,即便返回是由未捕獲的異常引發的,也會發生鎖定釋放。

你可能想知道調用靜態同步方法時會發生什麼,由於靜態方法與類相關聯,而不是與對象相關聯,在這種狀況下,線程獲取與類關聯的Class對象的固有鎖,所以,對類的靜態字段的訪問由一個鎖控制,該鎖與該類的任何實例的鎖不一樣。

同步語句

建立同步代碼的另外一種方法是使用同步語句,與同步方法不一樣,同步語句必須指定提供固有鎖的對象:

public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}

在此示例中,addName方法須要同步更改lastNamenameCount,但還須要避免同步調用其餘對象的方法(從同步代碼中調用其餘對象的方法可能會產生有關活性一節中描述的問題),若是沒有同步語句,則必須有一個單獨的、不一樣步的方法,其惟一目的是調用nameList.add

同步語句對於經過細粒度同步提升併發性也頗有用,例如,假設類MsLunch有兩個實例字段,c1c2,它們從不一塊兒使用,必須同步這些字段的全部更新,可是沒有理由阻礙c1的更新與c2的更新交錯 — 而且這樣作會經過建立沒必要要的阻塞來減小併發性。咱們建立兩個對象只是爲了提供鎖,而不是使用同步方法或使用與此相關聯的鎖。

public class MsLunch {
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() {
        synchronized(lock1) {
            c1++;
        }
    }

    public void inc2() {
        synchronized(lock2) {
            c2++;
        }
    }
}

謹慎使用這種用法,你必須絕對確保對受影響字段的交錯訪問是安全的。

可重入同步

回想一下,線程沒法獲取另外一個線程擁有的鎖,可是一個線程能夠得到它已經擁有的鎖,容許線程屢次獲取同一個鎖可以使可重入同步。這描述了一種狀況,其中同步代碼直接或間接地調用也包含同步代碼的方法,而且兩組代碼使用相同的鎖,在沒有可重入同步的狀況下,同步代碼必須採起許多額外的預防措施,以免線程致使自身阻塞。

原子訪問

在編程中,原子操做是一次有效地同時發生的操做,原子操做不能停在中間:它要麼徹底發生,要麼根本不發生,在操做完成以前,原子操做的反作用在完成以前是不可見的。

咱們已經看到增量表達式(如c++),沒有描述原子操做,即便很是簡單的表達式也能夠定義能夠分解爲其餘操做的複雜操做,可是,你能夠指定爲原子操做:

  • 對於引用變量和大多數原始變量(除longdouble以外的全部類型),讀取和寫入都是原子的。
  • 對於聲明爲volatile的全部變量(包括longdouble),讀取和寫入都是原子的。

原子操做不能交錯,所以可使用它們而不用擔憂線程干擾,可是,這並不能消除全部同步原子操做的須要,由於仍然可能存在內存一致性錯誤。使用volatile變量能夠下降內存一致性錯誤的風險,由於對volatile變量的任何寫入都會創建與以後讀取相同變量的先發生關係,這意味着對volatile變量的更改始終對其餘線程可見。更重要的是,它還意味着當線程讀取volatile變量時,它不只會看到volatile的最新更改,還會看到致使更改的代碼的反作用。

使用簡單的原子變量訪問比經過同步代碼訪問這些變量更有效,但程序員須要更加當心以免內存一致性錯誤,額外的功夫是否值得取決於應用程序的大小和複雜性。

java.util.concurrent包中的某些類提供了不依賴於同步的原子方法,咱們將在高級併發對象一節中討論它們。


上一篇:Thread對象

下一篇:併發活性

相關文章
相關標籤/搜索