我有以下代碼片段,我正在嘗試查看它是否會在某些時候崩潰/行為不端。HashMap 是從多個執行緒中呼叫的,這些執行緒在put同步塊內,get而不是在同步塊內。這段代碼有問題嗎?如果是這樣,鑒于我只使用putandget這種方式,并且沒有或涉及任何操作putAll,我需要進行哪些修改才能看到這種情況發生clear。
import java.util.HashMap;
import java.util.Map;
public class Main {
Map<Integer, String> instanceMap = new HashMap<>();
public static void main(String[] args) {
System.out.println("Hello");
Main main = new Main();
Thread thread1 = new Thread("Thread 1"){
public void run(){
System.out.println("Thread 1 running");
for (int i = 0; i <= 100; i ) {
System.out.println("Thread 1 " i "-" main.getVal(i));
}
}
};
thread1.start();
Thread thread2 = new Thread("Thread 2"){
public void run(){
System.out.println("Thread 2 running");
for (int i = 0; i <= 100; i ) {
System.out.println("Thread 2 " i "-" main.getVal(i));
}
}
};
thread2.start();
}
private String getVal(int key) {
check(key);
return instanceMap.get(key);
}
private void check(int key) {
if (!instanceMap.containsKey(key)) {
synchronized (instanceMap) {
if (!instanceMap.containsKey(key)) {
// System.out.println(Thread.currentThread().getName());
instanceMap.put(key, "" key);
}
}
}
}
}
我檢查過的內容:
- Java同步HashMap中的size()、put()、remove()、get()是原子的嗎?
- 擴展 HashMap<K,V> 并僅同步 puts
- 為什么同步變更操作時需要同步HashMap.get(key)?
uj5u.com熱心網友回復:
我稍微修改了您的代碼:
System.out.println()從“熱”回圈中洗掉,它在內部同步- 增加了迭代次數
- 將列印更改為僅在出現意外值時列印
我們可以做和嘗試的還有很多,但這已經失敗了,所以我停在那里。下一步我們會將整個內容重寫為jcsctress。
瞧,正如預期的那樣,有時這會發生在我的配備 Temurin 17 的 Intel MacBook Pro 上:
Exception in thread "Thread 2" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
at com.gitlab.janecekpetr.playground.Playground.getVal(Playground.java:35)
at com.gitlab.janecekpetr.playground.Playground.lambda$0(Playground.java:21)
at java.base/java.lang.Thread.run(Thread.java:833)
代碼:
private record Val(int index, int value) {}
private static final int MAX = 100_000;
private final Map<Integer, Integer> instanceMap = new HashMap<>();
public static void main(String... args) {
Playground main = new Playground();
Runnable runnable = () -> {
System.out.println(Thread.currentThread().getName() " running");
Val[] vals = new Val[MAX];
for (int i = 0; i < MAX; i ) {
vals[i] = new Val(i, main.getVal(i));
}
System.out.println(Stream.of(vals).filter(val -> val.index() != val.value()).toList());
};
Thread thread1 = new Thread(runnable, "Thread 1");
thread1.start();
Thread thread2 = new Thread(runnable, "Thread 2");
thread2.start();
}
private int getVal(int key) {
check(key);
return instanceMap.get(key);
}
private void check(int key) {
if (!instanceMap.containsKey(key)) {
synchronized (instanceMap) {
if (!instanceMap.containsKey(key)) {
instanceMap.put(key, key);
}
}
}
}
uj5u.com熱心網友回復:
為了具體解釋@PetrJane?ek 回答中出色的偵查作業:
Java 中的每個欄位都附有一枚邪惡的硬幣。每當任何執行緒讀取該欄位時,它都會翻轉這個硬幣。這不是一枚公平的硬幣——它是邪惡的。如果這會毀掉你的一天,它會連續翻轉 10,000 次(例如,你的代碼可能依賴于硬幣翻轉以某種方式著陸,否則它將無法作業。硬幣是邪惡的:你可能會遇到這種情況只會毀掉你的一天,在你所有的廣泛測驗中,硬幣翻轉正面,在生產的第一周,所有的都是正面翻轉。然后大的新潛在客戶演示了你的應用程式,硬幣開始翻轉一些反面你)。
coinflip 決定使用該欄位的哪個變體 - 因為每個執行緒可能有也可能沒有該欄位的本地快取。當您從任何執行緒寫入欄位時?硬幣被翻轉,反面,本地快取被更新,沒有更多的事情發生。從任何執行緒讀取?硬幣被翻轉。在尾部,使用本地快取。
當然,這并不是真正發生的事情(你的 JVM 實際上并沒有邪惡的硬幣,也不是為了得到你),但是 JMM(Java 記憶體模型)以及現代硬體的現實,意味著這種抽象非常有效:在撰寫并發代碼時,它會可靠地導致正確的答案,即,被多個執行緒接觸的任何欄位都必須有保護,或者在多執行緒訪問會話的整個持續時間內永遠不能改變'。
您可以通過建立所謂的 Happens Before 關系來強制 JVM 以您想要的方式拋硬幣。這是 JMM 使用的明確術語。如果 2 行代碼具有 Happens-Before 關系(根據 JMM 的 HB 關系建立操作串列,一個定義為“發生在”另一行之前),那么這是不可能的(缺少 JVM 本身的錯誤)觀察 HA 線的任何副作用,而不是觀察 HB 線的所有副作用。(也就是說:就您的代碼而言,“發生在”行之前發生在“發生在”行之前,盡管這有點像 schrodiner 的貓情況。如果您的代碼實際上并沒有查看這些檔案一種你能說出來的方式,那么 JVM 可以自由地不這樣做。它不會,將是CPU、作業系統、JVM 版本、版本和月相的某種組合,它們結合起來使用它)。
一小部分常見的 HB/HA 建立條件:
synchronized(lock)塊內的第一行是相對于任何其他執行緒中該塊的命中的 HA。- 退出一個
synchronized(lock)塊是相對于進入任何synchronized(lock)塊的任何其他執行緒的 HB ,假設兩個locks 是相同的參考。 thread.start()是相對于執行緒將運行的第一行的 HB。- “自然”HB/HA:如果 X 和 Y 由同一個執行緒運行并且 X 在代碼中“之前”,則 X 行相對于 Y 行是 HB。您無法撰寫
x = 5; y = x;并且已y被x未見證x = 5發生的版本設定(當然,如果另一個執行緒也在修改x,除非您有 HB/HA 與任何行正在這樣做,否則所有賭注都將關閉)。 - 對 volatile 的寫入和讀取建立 HB/HA,但您通常無法保證哪個方向。
這解釋了您的代碼可能失敗的方式:該get()呼叫與正在呼叫的其他執行緒絕對沒有建立 HB/HA 關系put(),因此 get() 呼叫可能會或可能不會使用HashMap內部使用的各種欄位的本地快取變體,具體取決于對邪惡的硬幣(這是當然打一些領域,這將是private在領域HashMap實作某個地方,所以你不知道哪些,但HashMap顯然有長壽命的狀態,這意味著領域都參與)。
那么,為什么您實際上沒有像 JMM 所說的那樣“看到”您的代碼 asplode 呢?因為硬幣是邪惡的。你不能依賴這條推理:“我寫了一些代碼,如果我需要的同步沒有按照我想要的方式發生,我應該會失敗。我運行了很多次,它從未失敗,因此,顯然這段代碼是并發安全的,我可以在我的生產代碼中使用它”。這根本不可靠。這就是為什么你需要思考:邪惡!那枚硬幣是來抓我的!因為如果你不這樣做,你可能會想寫這樣的測驗代碼。
您應該害怕撰寫多個執行緒與同一欄位互動的代碼。你應該向后彎腰以避免它。使用訊息佇列。通過使用資料庫在執行緒之間進行聊天,資料庫對這些東西有更好的原語(事務和隔離級別)。重寫代碼,使其預先獲取一堆引數,然后在完全不通過欄位與其他執行緒互動的情況下運行,直到全部完成,然后回傳結果(然后使用例如 fork/join 框架來實作)所有作業)。只需依靠每個傳入請求都是它自己的執行緒這一事實,就可以使您的網路服務器具有高性能并使用所有內核,因此您使用所有內核唯一需要做的就是讓那么多人訪問您的服務器同一時間。如果您沒有足夠的請求,那就太好了!
如果您確實認為從多個執行緒與同一場互動是正確的答案,那么您需要考慮 NASA 對與這些場互動的線路上的火星探測器進行編程,因為根本無法依賴測驗。這并不像聽起來那么難——尤其是如果您將與相關領域的實際互動降到最低并不斷思考:“我是否建立了 HB/HA”?
在這種情況下,我認為 Petr 的判斷是正確的:System.out.println速度很慢,并且會執行各種同步操作。JMM 是一攬子交易,可交換:一旦 HB/HA 建立起來,HB 線所改變的一切都可以被 HA 線中的代碼觀察到,并加入自然規則,這意味著 HA 線之后的所有代碼都無法觀察到HB 線之前的任何線所做的事情尚不可見。換句話說,System.out.println 以某種順序相互宣告 HB/HA,但您不能依賴它 (System.out未指定同步。但是,幾乎每個實作都會這樣做。您不應該依賴實作細節,我可以簡單地給你寫一些合法的java代碼,編譯,運行,并且不違反任何合同,因為你可以設定System.outwith System.setOut- 與System.out!)互動時不同步。在這種情況下,邪惡代幣通過故意未指定的行為以“意外”同步的形式出現System.out。
uj5u.com熱心網友回復:
以下解釋更符合 JMM 中使用的術語。如果您想更深入地了解該主題,可能會很有用。
2 操作在訪問相同地址且至少有 1 次寫入時發生沖突。
2 動作是并發的,當它們沒有按發生前的關系排序時(它們之間沒有發生前的邊)。
2 當動作發生沖突和并發時,它們處于資料競爭中。
當您的程式中存在資料競爭時,可能會發生奇怪的問題,例如指令的意外重新排序、可見性問題或原子性問題。
那么是什么構成了happens-before關系。如果 volatile 讀取觀察到特定的 volatile 寫入,則在寫入和讀取之間存在先發生邊緣。這意味著讀取不僅會看到該寫入,還會看到該寫入之前發生的所有事情。還有其他的發生前邊緣的來源,例如監視器的釋放和隨后對同一監視器的獲取。并且當A在程式順序中出現在B之前時,A,B之間存在先發生邊緣。注意:happens-before 關系是可傳遞的,所以如果 A 發生在 B 之前,B 發生在 C 之前,那么 A 發生在 C 之前。
在您的情況下,您的 get/put 操作是沖突的,因為它們訪問相同的地址并且至少有 1 次寫入。
put/get 操作是并發的,因為在寫入和讀取之間沒有發生之前的邊緣,因為即使寫入釋放了監視器,get 也不會獲取它。
由于 put/get 操作是并發且沖突的,因此它們處于資料競爭中。
解決此問題的最簡單方法是在同步塊中執行 map.get(使用相同的監視器)。這將引入所需的先發生邊緣,并使操作順序而不是并發,因此,資料競爭消失了。
一個性能更好的解決方案是使用 ConcurrentHashMap。代替單個中央鎖,有許多鎖,并且可以同時獲取它們以提高可伸縮性和性能。我不打算深入研究 ConcurrentHashMap 的優化,因為會造成混亂。
[編輯] 除了資料競爭之外,您的代碼還受到競爭條件的影響。
轉載請註明出處,本文鏈接:https://www.uj5u.com/caozuo/409600.html
標籤:
上一篇:如何在打字稿中使用抽象類?
