Java并发编程实战 代码bug,Java并发编程实战(1)- 并发程序的bug源头
概述
并發編程一般屬于編程進階部分的知識,它會涉及到很多底層知識,包括操作系統。
編寫正確的并發程序是一件很困難的事情,由并發導致的bug,有時很難排查或者重現,這需要我們理解并發的本質,深入分析Bug的源頭。
并發程序問題的源頭
為了提升系統性能,在過去幾十年中,我們一直在不斷的提升硬件的設計,包括CPU、內存以及I/O設備,但存在一個主要矛盾:三者之間速度有很大差異,CPU最快,內存其次,I/O設備最慢。
我們編寫的程序,在運行過程中,上述三者都會使用到,在這種情況下,速度慢的內存和I/O設備就會成為瓶頸,為了解決這個問題,計算機體系結構、操作系統和編譯程序做了如下改進:
CPU增加了緩存,以均衡與內存的速度差異。
操作系統增加了進程、線程以及分時復用CPU,從而均衡CPU與I/O設備的速度差異。
編譯程序優化指令執行次序,使得緩存能夠得到更加合理的利用。
并發程序的問題根源也基本來源于上述改進:
緩存引發的可見性問題
線程切換引發的原子性問題
編譯優化引發的有序性問題
接下來我們分別展開描述。
緩存引發的可見性問題
什么是可見性?
可見性是說一個線程對共享變量的修改,另外一個線程能夠立刻看到。
可見性問題是由CPU緩存引起的,它是在CPU變為多核后才出現的,單核CPU并不會存在可見性問題。
我們可以參考下面的示意圖。
如圖所示,當有2個線程同時訪問內存中的變量x時,2個線程運行在不同的CPU上,每個CPU緩存都會保存變量x,線程運行時,會通過CPU緩存來操作x,那么當線程1進行操作后,線程2并不會立刻得到更新后的x,從而引發了問題。
我們來看下面的代碼示例,它顯示了對同一個變量使用多個線程進行加操作,最后判斷變量值是否符合預期。
public class ConcurrencyAddDemo {
private long count = 0;
private void add() {
int index = 0;
while (index < 10000) {
count = count + 1;
index++;
}
}
private void reset() {
this.count = 0;
}
private void addTest() throws InterruptedException {
List threads = new ArrayList();
for (int i = 0; i < 6; i++) {
threads.add(new Thread(() -> {
this.add();
}));
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
threads.clear();
System.out.println(String.format("Count is %s", count));
}
public static void main(String[] args) throws InterruptedException {
ConcurrencyAddDemo demoObj = new ConcurrencyAddDemo();
for (int i = 0; i < 10; i++) {
demoObj.addTest();
demoObj.reset();
}
}
}
程序運行的結果如下。
Count is 18020
Count is 18857
Count is 16902
Count is 16295
Count is 54453
Count is 59475
Count is 56772
Count is 37376
Count is 60000
Count is 60000
我們可以看到,并不是每次返回的結果都是60000。
線程切換引發的原子性問題
什么是原子性?
一個或者多個操作在CPU執行的過程中不被中斷的特性,被稱為原子性。原子性可以保證操作執行的中間狀態,對外是不可見的。
CPU可以保證的原子操作是在CPU指令級別的,并不是高級語言的操作符,而高級語言中的一個操作,可能會包含多個CPU指令。
以上述代碼中的count = count + 1為例,它至少包含了三條CPU指令:
指令1:首先需要把變量count從內存加載到CPU寄存器。
指令2:在寄存器中執行+1操作。
指令3:將結果進行保存,這里可能會保存在CPU緩存,也可能保存在內存中。
上述指令執行過程中,可能會產生”線程切換“,如果多個線程同時執行相同的語句,那么因為線程切換,就會導致結果不是我們期望的。
原子性問題并不只在多核CPU中存在,在單核CPU中也是存在的。
編譯優化引發的有序性問題
什么是有序性?
有序性是指程序按照代碼的先后順序執行。
編譯器為了優化性能,有時候會改變程序中語句的先后順序,一般情況下,這并不會影響程序的最終結果,但有時也會引發意想不到的問題。
我們以典型的單例模式為例進行說明,示例代碼如下。
public class SingletonDemo {
private static SingletonDemo instance;
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized(SingletonDemo.class) {
if (instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
}
一般情況下,假設有兩個線程 A、B 同時調用 getInstance() 方法,他們會同時發現 instance == null ,于是同時對 Singleton.class 加鎖,此時 JVM 保證只有一個線程能夠加鎖成功(假設是線程 A),另外一個線程則會處于等待狀態(假設是線程 B);線程 A 會創建一個 Singleton 實例,之后釋放鎖,鎖釋放后,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功后,線程 B 檢查 instance == null 時會發現,已經創建過 Singleton 實例了,所以線程 B 不會再創建一個 Singleton 實例。
但是,如果我們仔細分析getInstance()方法中的new操作,會發現它包含以下幾步:
分配一塊內存M。
在內存M上初始化SingletonDemo對象。
將M的地址賦值給instance變量。
但編譯器可能會做一些優化,變成下面的樣子:
分配一塊內存M。
將M的地址賦值給instance變量。
在內存M上初始化SingletonDemo對象。
這樣很可能導致線程 B獲取instance之后,在instance初始化沒有完全結束的情況下,調用它的方法,從而引發空指針異常。
上述是我們常見的并發程序的bug源頭,只要我們能夠深刻理解可見性、原子性和有序性在并發場景下的原理,很多并發bug就很容易理解了。
參考資料:
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
以上是生活随笔為你收集整理的Java并发编程实战 代码bug,Java并发编程实战(1)- 并发程序的bug源头的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 廖雪峰老师Git教程代码梳理
- 下一篇: 微信开发者工具一打开代码编辑区文件全部不