首頁 > Java > java教程 > 主體

Java並發程式設計之物件的共享實作方案

WBOY
發布: 2023-04-23 17:25:07
轉載
1587 人瀏覽過

1.可見性

通常,我們無法保證執行讀取操作的執行緒能看到其他執行緒寫入的值,因為每個執行緒都由自己的快取機制。為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。

public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        public void run() {
            while (!ready)
                Thread.yield();
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}
登入後複製

以上程式碼,看起來會輸出42,但事實上很可能根本無法終止,因為讀取執行緒永遠看不到ready的值;很有可能輸出0,因為讀執行緒看到了寫入ready的值,卻沒有看到之後寫入number的值,這種現象稱為「重新排序」。在沒有同步的情況下,編譯器、處理器、執行時間等都有可能對操作的執行順序進行一些意想不到的調整。

所以,只要有資料在多個執行緒之間共享時,就應該使用正確的同步。

1.1 失效資料

除非使用同步,否則很可能會獲得變數的失效值。失效值可能不會同時出現,一個執行緒可能會獲得一個變數的最新值,而獲得另一個變數的失效值。失效資料也可能導致一些令人困惑的故障,如:意料之外的異常、被破壞的資料結構、不精確的計算、無限循環等等。

1.2 非原子的64位元操作

對於非volatile類型的long和double變量,JVM允許將64位元的讀取操作或寫入操作分解為兩個32位元的操作。所以,很可能會讀取到最新值的高32位元和失效值的低32值,造成讀取到是一個隨機值。除非用關鍵字volatile來聲明它們,或用鎖保護起來。

1.3 加鎖與可見性

當某執行緒執行由鎖定保護的同步程式碼區塊時,可以看到其他執行緒之前在同一同步程式碼區塊中的所有操作結果。如果沒有同步,將無法實現上述保證。加鎖的意思不僅限於互斥行為,還包括可見性。為了確保所有執行緒都能看到共享變數的最新值,所有執行讀取操作或寫入操作的執行緒都必須在同一個鎖上同步。

1.4 volatile變數

當把變數宣告為volatile類型後,編譯器和執行時間都不會將該變數上的操作也其他記憶體運算一起重新排序。 volatile變數不會被緩存在暫存器或其他處理器不可見的地方,因此在讀取volatile變數時總是會傳回最新寫入的值。加鎖機制既可以確保可見性又可以確保原子性,而volatile變數只能確保可見性。

當且僅當滿足以下所有條件時,才應該使用volatile變數:

  • 對變數的寫入運算不依賴變數的目前值,或能確保只用單一執行緒更新變數的值。

  • 該變數不會與其他狀態變數一起納入不變性條件中。

  • 在存取變數時不需要加鎖。

2. 發布與洩漏

發布一個物件是指,是物件能夠在目前作用域之外的程式碼中使用。發布物件的方式包括:非私人變數的參考、方法呼叫傳回的參考、發佈內部類別物件隱含外部類別的參考等等。當某個不應該發布的物件被發布是,就被稱為洩漏。

public class ThisEscape {
   private int status;
   public ThisEscape(EventSource source) {
      source.registerListener(new EventListener() {
         public void onEvent(Event e) {
            doSomething(e);
         }
      });
      status = 1;
   }

   void doSomething(Event e) {
      status = e.getStatus();
   }

   interface EventSource {
      void registerListener(EventListener e);
   }

   interface EventListener {
      void onEvent(Event e);
   }

   interface Event {
      int getStatus();
   }
}
登入後複製

由於內部類別的實例包含了外部類別實例的隱含引用,當ThisEscape發布EventListener時,也隱含發布了ThisEscape實例本身。但在此時,變數status還沒有被初始化,造成了this引用在建構子中洩漏。可以使用一個私有的建構函式和一個公共的工廠方法,避免不正確的建構過程:

public class SafeListener {
    private int status;
    private final EventListener listener;
    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
        status = 1;
    }
    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }

    void doSomething(Event e) {
        status = e.getStatus();
    }

    interface EventSource {
        void registerListener(EventListener e);
    }

    interface EventListener {
        void onEvent(Event e);
    }

    interface Event {
        int getStatus();
    }
}
登入後複製

3. 執行緒封閉

一種避免使用同步的方式就是不共享。如果僅在單線程內存取數據,就不需要同步,這稱為線程封閉。線程封閉是程式設計中的考慮因素,必須在程式中實現。 Java也提供了一些機制來幫助維護線程封閉,例如局​​部變數和ThreadLocal。

3.1 Ad-hoc執行緒封閉

Ad-hoc執行緒封閉是指,維護執行緒封閉性的職責完全由程式實作來承擔。使用volatile變數是實現Ad-hoc執行緒封閉的一種方式,只要能確保只有單一執行緒對共享volatile變數執行寫入操作,那麼就可以安全低在這些變數上進行「讀取-修改-寫入」操作,volatile變數的可見度又保證了其他執行緒能夠看到最新的值。

Ad-hoc執行緒封閉是非常脆弱的,因此在程式中盡量少使用。在可能的情況下,使用其他執行緒封閉技術,例如:棧封閉、ThreadLocal。

3.2 堆疊封閉

在堆疊封閉中,只能透過局部變數才能存取物件。它們位於執行緒的堆疊中,其他執行緒無法存取。即使這些對像是非線程安全的對象,它們仍然是線程安全的。然而,值得注意的是,只要編寫程式碼的人才知道哪些物件是棧封閉的。如果沒有明確的說明,後續的維護人員很容易錯誤的洩漏這些物件。

3.3 ThreadLocal类

使用ThreadLocal是一种更规范的线程封闭方式,它能是线程中的某个值与保存值的对象关联起来。如下代码,通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接:

public class ConnectionDispenser {
    static String DB_URL = "jdbc:mysql://localhost/mydatabase";

    private ThreadLocal<Connection> connectionHolder
        = new ThreadLocal<Connection>() {
            public Connection initialValue() {
                try {
                    return DriverManager.getConnection(DB_URL);
                } catch (SQLException e) {
                    throw new RuntimeException("Unable to acquire Connection, e");
                }
        };
    };

    public Connection getConnection() {
        return connectionHolder.get();
    }
}
登入後複製

从概念上看,你可以将ThreadLocal视为包含了Map对象,其中保存了特定于改线程的值,但ThreadLocal的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾被回收。

4. 不变性

如果某个对象在被创建后其状态就不能被修改,那么这个对象就被称为不可变对象。满足同步需求的另一种方法就是使用不可变对象。不可变对象一定是线程安全的。当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能改变

  • 对象的所有域都是final类型

  • 对象是正确创建的,在对象创建期间,this引用没有泄露

public final class ThreeStooges {
    private final Set<String> stooges = new HashSet<String>();

    public ThreeStooges() {
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }

    public boolean isStooge(String name) {
        return stooges.contains(name);
    }
}
登入後複製

上述代码中,尽管stooges对象是可变的,但在它构造完成后无法对其修改。stooges是一个final类型的引用变量,因此所有的对象状态都通过一个final域访问。在构造函数中,this引用不能被除了构造函数之外的代码访问到。

4.1 final域

final类型的域是不能修改的,但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的。final域的对象在构造函数中不会被重排序,所以final域也能保证初始化过程的安全性。和“除非需要更高的可见性,否则应将所有的域都声明为私用域”一样,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯。

4.2 使用volatile类型来发布不可变对象

因式分解Sevlet将执行两个原子操作:

  • 更新缓存

  • 通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的结果

每当需要一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据:

public class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i, BigInteger[] factors) {
        lastNumber = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }

    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i))
            return null;
        else
            return Arrays.copyOf(lastFactors, lastFactors.length);
    }
}
登入後複製

当线程获取了不可变对象的引用后,不必担心另一个线程会修改对象的状态。如果要更新这些变量,可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据:

public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache = new OneValueCache(null, null);

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResponse(resp, factors);
    }
}
登入後複製

5 安全发布

5.1 不正确的发布

像这样将对象引用保存到公有域中就是不安全的:

public Holder holder;
public void initialize(){
    holder = new Holder(42);
}
登入後複製

由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态。除了发布对象的线程外,其他线程可以看到Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。

public class Holder {
    private int n;

    public Holder(int n) {
        this.n = n;
    }

    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false.");
    }
}
登入後複製

上述代码,即使Holder对象被正确的发布,assertSanity也有可能抛出AssertionError。因为线程看到Holder引用的值是最新的,但由于重排序Holder状态的值却是时效的。

5.2 不可变对象与初始化安全性

即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

5.3 安全发布的常用模式

要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全发布:

  • 在静态初始化函数里初始化一个对象引用。

  • 将对象的引用保存到volatile类型的域或者AtomicReference对象中。

  • 将对象的引用保存到某个正确构造对象的final类型域中。

  • 将对象的引用保存到一个由锁保护的域中。

线程安全库中的容器类提供了以下的安全发布保证:

  • 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程。

  • 通过将某个对象放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中,可以将该对象安全地发布到任何从这些容器中访问该对象的线程。

  • 透過將某個物件放入BlockingQueue或ConcurrentLinkedQueue中,可以將該物件安全地發佈到任何從這些佇列中存取該物件的執行緒。

5.4 事實不可變物件

如果物件從技術上來看是可變的,但其狀態在發布後不會再改變,那麼把這種對象稱為事實不可變物件。在沒有額外的同步的情況下,任何線程都可以安全地使用被安全地發布的事實不可變物件。例如維護一個Map對象,其中保存了每位使用者的最新登入時間:

public Map lastLogin =
Collections.synchronizedMap(new HashMap如果Date物件的值在放入Map後就不會改變,那麼synchronizedMap中的同步機制就足以使Date值被安全地發布,並且在存取這些Date值時不需要額外的同步。

5.5 可變對象

對於可變對象,不僅在發布對像是需要使用同步,而且在每次對象訪問時同樣需要使用同步來確保後續修改操作的可見性。物件的發布需求取決於它的可變性:

  • 不可變物件可以透過任意機制來發布。

  • 事實不可變物件必須透過安全方式來發布。

  • 可變物件必須透過安全方式來發布,而且必須是線程安全的或用某個鎖保護。

5.6 安全的共享物件

在並發程式中使用和共享物件時,可以使用一些實用的策略,包括:

  • 執行緒封閉。線程封閉的物件只能由一個線程擁有,物件被封閉在該線程中,並且只能由這個線程修改。

  • 只讀共享。在沒有額外同步的情況下,共享的唯讀物件可以由多個執行緒並發訪問,但任何執行緒都不能修改它。共享的唯讀物件包括不可變物件和事實不可變物件。

  • 線程安全共享。線程安全的物件在其內部實現同步,因此多個線程可以透過物件的公共介面來存取而不需要進一步的同步。

  • 保護物件。被保護的物件只能透過持有特定的鎖來存取。保護對象包括封裝在其他執行緒安全性物件中的對象,以及已發佈的並由某個特定鎖保護的對象。

#

以上是Java並發程式設計之物件的共享實作方案的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:yisu.com
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板