為什麼 sun.misc.Unsafe.park 實際上不安全?
1. 概述
Java 提供了某些內部使用的 API,並阻止在其他情況下不必要的使用。 JVM 開發人員給了套件和類別名稱,例如Unsafe ,這應該警告開發人員。然而,通常這並不能阻止開發人員使用這些類別。
在本教程中,我們將了解為什麼Unsafe.park()實際上是不安全的。目的不是嚇唬人,而是為了教育和提供對park()和unpark(Thread)方法的相互作用的更好的見解。
2. Unsafe
Unsafe類別包含一個低階 API,旨在僅與內部程式庫一起使用。但是,即使在引入 JPMS 後, sun.misc.Unsafe仍然可以存取。這樣做是為了保持向後相容性並支援可能使用此 API 的所有程式庫和框架。更詳細的原因在JEP 260中有解釋,
在本文中,我們不會直接使用Unsafe ,而是使用java.util.concurrent.locks套件中的LockSupport類,該類別包裝了對Unsafe:
public static void park() {
UNSAFE.park(false, 0L);
}
public static void unpark(Thread thread) {
if (thread != null)
UNSAFE.unpark(thread);
}
3. park()與wait()
park()和unpark(Thread)功能類似wait()和notify() 。讓我們回顧一下它們的差異,並了解使用第一個而不是第二個的危險。
3.1.缺乏顯示器
與wait()和notify()不同, park()和unpark(Thread)不需要監視器。任何可以獲得對停放線程的引用的程式碼都可以取消停放它。這在低階程式碼中可能很有用,但可能會帶來額外的複雜性和難以偵錯的問題。
監視器是用 Java 設計的,因此如果執行緒一開始沒有取得它,就無法使用它。這樣做是為了防止競爭條件並簡化同步過程。讓我們嘗試在不獲取線程監視器的情況下通知該線程:
@Test
@Timeout(3)
void giveThreadWhenNotifyWithoutAcquiringMonitorThrowsException() {
Thread thread = new Thread() {
@Override
public void run() {
synchronized (this) {
try {
this.wait();
} catch (InterruptedException e) {
// The thread was interrupted
}
}
}
};
assertThrows(IllegalMonitorStateException.class, () -> {
thread.start();
Thread.sleep(TimeUnit.SECONDS.toMillis(1));
thread.notify();
thread.join();
});
}
嘗試在不取得監視器的情況下通知執行緒會導致IllegalMonitorStateException 。這種機制強制執行更好的編碼標準並防止可能出現的難以調試的問題。
現在,讓我們檢查park()和unpark(Thread)的行為:
@Test
@Timeout(3)
void giveThreadWhenUnparkWithoutAcquiringMonitor() {
Thread thread = new Thread(LockSupport::park);
assertTimeoutPreemptively(Duration.of(2, ChronoUnit.SECONDS), () -> {
thread.start();
LockSupport.unpark(thread);
});
}
我們可以透過很少的工作來控制線程。唯一需要的是對線程的引用。這為我們提供了更多的鎖定能力,但同時,它也讓我們面臨更多的問題。
很明顯為什麼park()和unpark(Thread)可能對低階程式碼有幫助,但我們應該在通常的應用程式程式碼中避免這種情況,因為它可能會引入太多的複雜性和不清晰的程式碼。
3.2.有關上下文的信息
事實上,不涉及監視器也可能會減少有關上下文的資訊。換句話說,該線程被停放,並且不清楚為什麼、何時以及是否有其他線程因相同的原因被停放。讓我們運行兩個執行緒:
public class ThreadMonitorInfo {
private static final Object MONITOR = new Object();
public static void main(String[] args) throws InterruptedException {
Thread waitingThread = new Thread(() -> {
try {
synchronized (MONITOR) {
MONITOR.wait();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "Waiting Thread");
Thread parkedThread = new Thread(LockSupport::park, "Parked Thread");
waitingThread.start();
parkedThread.start();
waitingThread.join();
parkedThread.join();
}
}
讓我們使用jstack檢查線程轉儲:
"Parked Thread" #12 prio=5 os_prio=31 tid=0x000000013b9c5000 nid=0x5803 waiting on condition [0x000000016e2ee000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:304)
at com.baeldung.park.ThreadMonitorInfo$$Lambda$2/284720968.run(Unknown Source)
at java.lang.Thread.run(Thread.java:750)
"Waiting Thread" #11 prio=5 os_prio=31 tid=0x000000013b9c4000 nid=0xa903 in Object.wait() [0x000000016e0e2000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x00000007401811d8> (a java.lang.Object)
at java.lang.Object.wait(Object.java:502)
at com.baeldung.park.ThreadMonitorInfo.lambda$main$0(ThreadMonitorInfo.java:12)
- locked <0x00000007401811d8> (a java.lang.Object)
at com.baeldung.park.ThreadMonitorInfo$$Lambda$1/1595428806.run(Unknown Source)
at java.lang.Thread.run(Thread.java:750)
在分析線程轉儲時,很明顯,停放的線程包含較少的資訊。因此,它可能會造成某種線程問題(即使使用線程轉儲)也難以調試的情況。
使用特定並發結構或特定鎖定的另一個好處是在執行緒轉儲中提供更多上下文,從而提供有關應用程式狀態的更多資訊。許多 JVM 並發機制在內部使用park()。但是,如果線程轉儲說明該線程正在等待(例如,在CyclicBarrier上),則它正在等待其他線程。
3.3.中斷標誌
另一個有趣的事情是處理中斷的差異。讓我們回顧一下等待線程的行為:
@Test
@Timeout(3)
void givenWaitingThreadWhenNotInterruptedShouldNotHaveInterruptedFlag() throws InterruptedException {
Thread thread = new Thread() {
@Override
public void run() {
synchronized (this) {
try {
this.wait();
} catch (InterruptedException e) {
// The thread was interrupted
}
}
}
};
thread.start();
Thread.sleep(TimeUnit.SECONDS.toMillis(1));
thread.interrupt();
thread.join();
assertFalse(thread.isInterrupted(), "The thread shouldn't have the interrupted flag");
}
如果我們從等待狀態中斷一個線程, wait()方法將立即拋出InterruptedException並清除中斷標誌。這就是為什麼最佳實踐是使用while循環檢查等待條件而不是中斷標誌。
相反,停放的線程不會立即中斷,而是按照其條件執行。此外,中斷不會導致異常,線程只是從park()方法返回。隨後,中斷標誌不會重置,就像中斷等待線程時發生的情況一樣:
@Test
@Timeout(3)
void givenParkedThreadWhenInterruptedShouldNotResetInterruptedFlag() throws InterruptedException {
Thread thread = new Thread(LockSupport::park);
thread.start();
thread.interrupt();
assertTrue(thread.isInterrupted(), "The thread should have the interrupted flag");
thread.join();
}
不考慮這種行為可能會在處理中斷時導致問題。例如,如果我們在暫停線程上中斷後不重置標誌,則可能會導致微妙的錯誤。
3.4.優先購買許可證
停車和出車的工作原理是二進制信號量。因此,我們可以為線程提供搶佔許可。例如,我們可以取消停放一個線程,這將給它一個許可,隨後的停放不會暫停它,而是會獲取許可並繼續:
private final Thread parkedThread = new Thread() {
@Override
public void run() {
LockSupport.unpark(this);
LockSupport.park();
}
};
@Test
void givenThreadWhenPreemptivePermitShouldNotPark() {
assertTimeoutPreemptively(Duration.of(1, ChronoUnit.SECONDS), () -> {
parkedThread.start();
parkedThread.join();
});
}
該技術可用於一些複雜的同步場景。由於停車使用二進位信號量,我們無法新增許可證,兩個 unpark 呼叫不會產生兩個許可證:
private final Thread parkedThread = new Thread() {
@Override
public void run() {
LockSupport.unpark(this);
LockSupport.unpark(this);
LockSupport.park();
LockSupport.park();
}
};
@Test
void givenThreadWhenRepeatedPreemptivePermitShouldPark() {
Callable<Boolean> callable = () -> {
parkedThread.start();
parkedThread.join();
return true;
};
boolean result = false;
Future<Boolean> future = Executors.newSingleThreadExecutor().submit(callable);
try {
result = future.get(1, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
// Expected the thread to be parked
}
assertFalse(result, "The thread should be parked");
}
在這種情況下,執行緒只有一個許可,第二次呼叫park()方法將停放該執行緒。如果處理不當,這可能會產生一些不良行為。
4。結論
在本文中,我們了解了為什麼park()方法被認為是不安全的。 JVM 開發人員出於特定原因隱藏或建議不要使用內部 API。這不僅是因為它目前可能很危險並會產生意外結果,而且還因為這些 API 將來可能會發生變化,並且無法保證它們的支援。
此外,這些 API 需要對底層系統和技術進行廣泛的了解,而這些系統和技術可能因平台而異。不遵循這一點可能會導致程式碼脆弱和難以調試的問題。
與往常一樣,本文中的程式碼可以在 GitHub 上取得。