Java核心知识体系7:线程安全性讨论
Java核心知識體系1:泛型機制詳解
Java核心知識體系2:注解機制詳解
Java核心知識體系3:異常機制詳解
Java核心知識體系4:AOP原理和切面應用
Java核心知識體系5:反射機制詳解
Java核心知識體系6:集合框架詳解
1 為什么需要多線程
我們都知道,CPU、內存、I/O 設備的速度是有極大差異的,為了合理利用 CPU 的高性能,平衡這三者的速度差異,計算機體系結構、操作系統、編譯程序都做出了優化,主要體現為:
- CPU增加了緩存,均衡了與內存之間的速度差異,但會導致可見性問題
- 操作系統增加了進程、線程,以分時復用 CPU,進而均衡 CPU 與 I/O 設備的速度差異,但會導致原子性問題
- 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用,但會導致有序性問題
從上面可以看到,雖然多線程平衡了CPU、內存、I/O 設備之間的效率,但是同樣也帶來了一些問題。
2 線程不安全案例分析
如果有多個線程,對一個共享數據進行操作,但沒有采取同步的話,那操作結果可能超出預想,產生不一致。
下面舉個粒子,設置一個計數器count,我們通過1000個線程同時對它進行增量操作,看看操作之后的值,是不是符合預想中的1000。
public class UnsafeThreadTest {
private int count = 0;
public void add() {
count += 1;
}
public int get() {
return count;
}
}
public static void main(String[] args) throws InterruptedException {
final int threadNum = 1000;
UnsafeThreadTest threadTest = new UnsafeThreadTest();
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorSvc = Executors.newCachedThreadPool();
// 執行并發計數
for (int idx = 0; idx < threadNum; idx ++) {
executorSvc.execute(() -> {
threadTest.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
// 關閉線程池
executorSvc.shutdown();
System.out.println("最終計數:" + threadTest.get());
}
最終計數:994 // 結果跟預期的 1000 不一樣
3 并發出現問題的原因
可以看到,上述代碼輸出的結果跟預期的 1000 不一樣,我們需要理清楚發生了什么問題?
★ 并發三要素:可見性、原子性、有序性
3.1 可見性:由CPU緩存引起
CPU緩存是一種高速緩存,用于存儲CPU最近使用的數據。由于CPU緩存比主存儲器更快,因此CPU會盡可能地使用緩存,以提高程序的性能。但是,這也會導致可見性問題。
可見性問題是指當一個線程修改了一個共享變量的值時,另一個線程可能無法立即看到這個修改。
我們舉個簡單的例子,看下面這段代碼:
// 主存中 index 的值默認為 10
System.out.println("主存中的值:" + index);
// Thread1 執行賦值
index = 100;
// Thread2 執行的
threadA = index;
因為Thread1修改后的值可能仍然存儲在CPU緩存中,而沒有被寫回主存儲器。這種情況下,Thread2無法讀取到修改后的值,所以導致錯誤信息。
具體來說,當多個線程同時運行在同一個處理器上時,它們共享該處理器的緩存。如果一個線程修改了某個共享變量的值,該值可能被存儲在處理器緩存中,并且未被立即寫回到主存儲器中。
因此,當另一個線程試圖讀取該變量的值時,它可能會從主存儲器中讀取舊的值 10,而不是從處理器緩存中讀取已更新的值 100。
3.2 原子性: 由分時復用引起
原子性:原子性是指一個操作在執行過程中不可分割,即該操作要么完全執行,要么完全不執行。
我們舉個簡單的例子,看下面這段代碼:
// 主存中 index 的值默認為 10
System.out.println("主存中的值:" + index);
// Thread1 執行增值
index += 1;
// Thread2 執行增值
index += 1
以上的信息可以看出:
- 主存的值為10
- i += 1 這個操作實際執行三條 CPU 指令
- 變量 i 從內存讀取到 CPU寄存器;
- 在CPU寄存器中執行 i + 1 操作;
- 將最后的結果i寫入內存,因為有緩存機制,所以最終可能寫入的是 CPU 緩存而不是內存。
- 由于CPU分時復用(線程切換)的存在,Thread1執行了第一條指令后,就切換到Thread2執行,Thread2全部執行完成之后,再切換會Thread1執行后續兩條指令,將造成最后寫到內存中的index值是11而不是12。
3.3 有序性: 重排序引起
有序性:即程序執行的順序按照代碼的先后順序執行。
重排序(Reordering)是指在計算機系統中,由于處理器優化或編譯器優化等原因,導致指令執行的順序與程序代碼中的順序不一致。重排序可能會引起有序性錯誤,即在并發或多線程環境中,程序執行的順序與代碼的先后順序不一致,導致程序結果不正確或出現意外的結果。
我們舉個簡單的例子,看下面這段代碼:
int idx = 10;
boolean isCheck = true;
idx += 1; // 執行語句1
isCheck = false; // 執行語句2
上面代碼定義了一個int型變量,定義了一個boolean類型變量,然后分別對兩個變量進行操作。
從代碼順序上看,執行語句1是在執行語句2前面的,那么JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎? 不一定,為什么呢? 這里可能會發生指令重排序(Instruction Reorder)。
重排序(Reordering)是指在計算機系統中,由于處理器優化或編譯器優化等原因,導致指令執行的順序與程序代碼中的順序不一致。重排序可能會引起有序性錯誤,即在并發或多線程環境中,程序執行的順序與代碼的先后順序不一致,導致程序結果不正確或出現意外的結果。
重排序引起的有序性錯誤主要有以下幾種情況:
- 指令重排序:處理器為了優化程序的執行,可能會對指令進行重排序。這種重排序不會改變單線程程序的執行結果,但可能會影響多線程程序的行為。例如,一個線程修改了一個共享變量的值,但由于指令重排序,另一個線程在讀取該變量時可能讀取到過時的值。
- 內存訪問重排序:處理器為了提高程序的執行效率,可能會對內存訪問進行重排序。例如,一個線程先讀取一個共享變量的值,然后再寫入該值,但由于內存訪問重排序,處理器可能會先執行寫入操作,再執行讀取操作,從而導致其他線程無法正確地讀取到修改后的值。
- 同步操作重排序:在并發或多線程環境中,同步操作可能會被重排序。例如,一個線程先釋放了一個鎖,然后再執行另一個操作,但由于同步操作重排序,釋放鎖的操作可能會先于另一個操作執行,從而導致其他線程無法正確地獲取鎖。
為了避免重排序引起的有序性錯誤,可以采用一些同步機制來確保程序的執行順序,如內存屏障(Memory barrier,intel 稱為 memory fence)、指令fence等。這些同步機制可以確保指令的執行順序與代碼中的順序一致,避免指令重排序和內存訪問重排序等問題。同時,也可以使用串行化(Serialization)或事務內存(Transactional memory)等技術來保證并發程序的有序性。
4 總結
- CPU、內存、I/O 設備的速度是有極大差異的,多線程 的實現是為了合理利用 CPU 的高性能,平衡這三者的速度差異
- 多線程情況下,并發產生問題的三要素:可見性、原子性、有序性
- 可見性:由CPU緩存引起
- 原子性: 由分時復用引起
- 有序性: 重排序引起
總結
以上是生活随笔為你收集整理的Java核心知识体系7:线程安全性讨论的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 梦见过白事是什么意思
- 下一篇: Android 实现APP可切换多语言