Java並發編程入門,看這一篇就夠了


Java並發編程一直是Java程序員必須懂但又是很難懂的技術內容。這里不僅僅是指使用簡單的多線程編程,或者使用juc的某個類。當然這些都是並發編程的基本知識,除了使用這些工具以外,Java並發編程中涉及到的技術原理十分豐富。

於是乎,就誕生了想寫點東西記錄下,以提升理解和對並發編程的認知。為什么需要用到並發?凡事總有好壞兩面,之間的trade-off是什么,也就是說並發編程具有哪些挑戰?以及在進行並發編程時應該了解和掌握的概念是什么?並發編程的三大特性是什么?這篇文章主要以這四個問題來談一談。

一.為什么要用到並發

一直以來,硬件的發展極其迅速,也有一個很著名的"摩爾定律",可能會奇怪明明討論的是並發編程為什么會扯到了硬件的發展,這其中的關系應該是多核CPU的發展為並發編程提供的硬件基礎。摩爾定律並不是一種自然法則或者是物理定律,它只是基於認為觀測數據后,對未來的一種預測。按照所預測的速度,我們的計算能力會按照指數級別的速度增長,不久以后會擁有超強的計算能力,正是在暢想未來的時候,2004年,Intel宣布4GHz芯片的計划推遲到2005年,然后在2004年秋季,Intel宣布徹底取消4GHz的計划,也就是說摩爾定律的有效性超過了半個世紀戛然而止。但是,聰明的硬件工程師並沒有停止研發的腳步,他們為了進一步提升計算速度,而不是再追求單獨的計算單元,而是將多個計算單元整合到了一起,也就是形成了多核CPU。短短十幾年的時間,家用型CPU,比如Intel i7就可以達到4核心甚至8核心。而專業服務器則通常可以達到幾個獨立的CPU,每一個CPU甚至擁有多達8個以上的內核。因此,摩爾定律似乎在CPU核心擴展上繼續得到體驗。因此,多核的CPU的背景下,催生了並發編程的趨勢,通過 並發編程的形式可以將多核CPU的計算能力發揮到極致,性能得到提升 。

頂級計算機科學家Donald Ervin Knuth如此評價這種情況:在我看來,這種現象(並發)或多或少是由於硬件設計者無計可施了導致的,他們將摩爾定律的責任推給了軟件開發者。

另外,在特殊的業務場景下先天的就適合於並發編程。比如在圖像處理領域,一張1024X768像素的圖片,包含達到78萬6千多個像素。即時將所有的像素遍歷一邊都需要很長的時間,面對如此復雜的計算量就需要充分利用多核的計算的能力。又比如當我們在網上購物時,為了提升響應速度,需要拆分,減庫存,生成訂單等等這些操作,就可以進行拆分利用多線程的技術完成。 面對復雜業務模型,並行程序會比串行程序更適應業務需求,而並發編程更能吻合這種業務拆分 。正是因為這些優點,使得多線程技術能夠得到重視,也是一名Java學習者應該掌握的:

  • 充分利用多核CPU的計算能力;
  • 方便進行業務拆分,提升應用性能

二. 並發編程有哪些挑戰

多線程技術有這么多的好處,難道就沒有一點缺點或者挑戰么,就在任何場景下就一定適用么?很顯然不是。

 

2.1 頻繁的上下文切換

時間片是CPU分配給各個線程的時間,因為時間非常短,所以CPU不斷通過切換線程,讓我們覺得多個線程是同時執行的,時間片一般是幾十毫秒。而每次切換時,需要保存當前的狀態起來,以便能夠進行恢復先前狀態,而這個切換時非常損耗性能,過於頻繁反而無法發揮出多線程編程的優勢。通常減少上下文切換可以采用無鎖並發編程,CAS算法,使用最少的線程和使用協程。

  • 無鎖並發編程:可以參照concurrentHashMap鎖分段的思想,不同的線程處理不同段的數據,這樣在多線程競爭的條件下,可以減少上下文切換的時間。
  • CAS算法:利用Atomic下使用CAS算法來更新數據,使用了樂觀鎖,可以有效的減少一部分不必要的鎖競爭帶來的上下文切換
  • 使用最少線程:避免創建不需要的線程,比如任務很少,但是創建了很多的線程,這樣會造成大量的線程都處於等待狀態
  • 協程:在單線程里實現多任務的調度,並在單線程里維持多個任務間的切換

由於上下文切換也是個相對比較耗時的操作,所以在"java並發編程的藝術"一書中有過一個實驗,並發累加未必會比串行累加速度要快。 可以使用Lmbench3測量上下文切換的時長 vmstat測量上下文切換次數

2.2 線程安全(死鎖)

多線程編程中最難以把握的就是臨界區線程安全問題,稍微不注意就會出現死鎖的情況,一旦產生死鎖就會造成系統功能不可用。

public class DeadLockDemo {
    private static String resource_a = "A";
    private static String resource_b = "B";

    public static void main(String[] args) {
        deadLock();
    }

    public static void deadLock() {
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (resource_a) {
                    System.out.println("get resource a");
                    try {
                        Thread.sleep(3000);
                        synchronized (resource_b) {
                            System.out.println("get resource b");
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (resource_b) {
                    System.out.println("get resource b");
                    synchronized (resource_a) {
                        System.out.println("get resource a");
                    }
                }
            }
        });
        threadA.start();
        threadB.start();

    }
}
在上面的這個demo中,開啟了兩個線程threadA, threadB,其中threadA占用了resource_a, 並等待被threadB釋放的resource _b。threadB占用了resource _b正在等待被threadA釋放的resource _a。因此threadA,threadB出現線程安全的問題,形成死鎖。同樣可以通過jps,jstack證明這種推論:
"Thread-1":
  waiting to lock monitor 0x000000000b695360 (object 0x00000007d5ff53a8, a java.lang.String),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x000000000b697c10 (object 0x00000007d5ff53d8, a java.lang.String),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at learn.DeadLockDemo$2.run(DeadLockDemo.java:34)
        - waiting to lock <0x00000007d5ff53a8(a java.lang.String)
        - locked <0x00000007d5ff53d8(a java.lang.String)
        at java.lang.Thread.run(Thread.java:722)
"Thread-0":
        at learn.DeadLockDemo$1.run(DeadLockDemo.java:20)
        - waiting to lock <0x00000007d5ff53d8(a java.lang.String)
        - locked <0x00000007d5ff53a8(a java.lang.String)
        at java.lang.Thread.run(Thread.java:722)

Found 1 deadlock.

如上所述,完全可以看出當前死鎖的情況。

那么,通常可以用如下方式避免死鎖的情況:

  1. 避免一個線程同時獲得多個鎖;
  2. 避免一個線程在鎖內部占有多個資源,盡量保證每個鎖只占用一個資源;
  3. 嘗試使用定時鎖,使用lock.tryLock(timeOut),當超時等待時當前線程不會阻塞;
  4. 對於數據庫鎖,加鎖和解鎖必須在一個數據庫連接里,否則會出現解鎖失敗的情況

所以,如何正確的使用多線程編程技術有很大的學問,比如如何保證線程安全,如何正確理解由於JMM內存模型在原子性,有序性,可見性帶來的問題,比如數據臟讀,DCL等這些問題(在后續篇幅會講述)。而在學習多線程編程技術的過程中也會讓你收獲頗豐。

2.3 資源限制的挑戰

  • 什么是資源限制

資源限制指在進行並發編程時,程序的執行速度受限於計算機硬件資源或軟件資源。

硬件資源包括:帶寬的上傳下載速度、硬盤讀寫速度和CPU的處理速度等

軟件資源包括:線程池大小、數據庫的連接數等

  • 資源限制引發的問題

在並發編程中,代碼執行速度加快的原則是將代碼中的串行部分變成並行執行,但有可能由於資源限制問題,導致程序仍按串行執行,此時程序不僅不會變快,反而更慢,因為增加了上下文切換和資源調度的時間。

  • 如何解決資源限制的問題

對於硬件資源限制:考慮使用集群方式並行執行程序。

對於軟件資源限制:考慮使用資源池將資源復用,例如數據庫連接池等

  • 資源限制情況下進行並發編程

根據不同的資源限制調整程序的並發度。

3. 應該了解的概念

3.1 同步VS異步

同步和異步通常用來形容一次方法調用。同步方法調用一開始,調用者必須等待被調用的方法結束后,調用者后面的代碼才能執行。而異步調用,指的是,調用者不用管被調用方法是否完成,都會繼續執行后面的代碼,當被調用的方法完成后會通知調用者。比如,在超時購物,如果一件物品沒了,你得等倉庫人員跟你調貨,直到倉庫人員跟你把貨物送過來,你才能繼續去收銀台付款,這就類似同步調用。而異步調用了,就像網購,你在網上付款下單后,什么事就不用管了,該干嘛就干嘛去了,當貨物到達后你收到通知去取就好。

3.2 並發與並行

並發和並行是十分容易混淆的概念。並發指的是多個任務交替進行,而並行則是指真正意義上的“同時進行”。實際上,如果系統內只有一個CPU,而使用多線程時,那么真實系統環境下不能並行,只能通過切換時間片的方式交替進行,而成為並發執行任務。真正的並行也只能出現在擁有多個CPU的系統中。

3.3 阻塞和非阻塞

阻塞和非阻塞通常用來形容多線程間的相互影響,比如一個線程占有了臨界區資源,那么其他線程需要這個資源就必須進行等待該資源的釋放,會導致等待的線程掛起,這種情況就是阻塞,而非阻塞就恰好相反,它強調沒有一個線程可以阻塞其他線程,所有的線程都會嘗試地往前運行。

3.4 臨界區

臨界區用來表示一種公共資源或者說是共享數據,可以被多個線程使用。但是每個線程使用時,一旦臨界區資源被一個線程占有,那么其他線程必須等待。

4.並發編程的三大特性

並發編程有三大特性:原子性、可見性、有序性。
原子性:是指在一次操作或多次操作中,要么所有的操作都得到執行,要么都不執行。【類似於事務】
  • JMM只保證了基本讀取和賦值的原子性操作
  • 多個原子性操作的組合不再是原子性操
  • 可以使用synchronized/lock保證某些代碼片段的原子性
  • 對於int等類型的自增操作,可以通過java.util.concurrent.atomic.*保證原子性

 

可見性:是指一個線程對共享變量進行了修改,其他線程可以立即看到修改后的值。
有序性:是指代碼在執行過程中的先后順序是有序的。【Java編譯器會對代碼進行優化,執行順序可能與開發者編寫的順序不同(指令重排)】
並發編程時,保證三大特性的方式有三種:
1、使用volatile關鍵字修飾變量
  • 當一個變量被volatile關鍵字修飾時,對於共享變量的讀操作會直接在主存中進行,對於共享變量的寫操作是先修改本地內存,修改結束后直接刷到主存中。(未被volatile修飾的變量被修改后,什么時候最新值會被刷到主存中是不確定的)
2、使用synchronized關鍵字修飾方法或代碼塊
  • synchronized關鍵字能保證同一時刻只有一個線程獲得鎖然后執行同步方法,並且確保鎖釋放之前,會將修改的變量刷入主存。
3、使用JUC提供的顯式鎖Lock
  • Lock能保證同一時刻只有一個線程獲得鎖然后執行同步方法,並且確保鎖釋放之前,會將修改的變量刷入主存。

最后,本文主要對Java並發編程開發需要的知識點作了簡單的講解,這里每一個知識點都可以用一篇文章去講解,由於篇幅原因不能對每一個知識點都詳細介紹,我相信通過本文你會對Java的並發編程會有更近一步的了解。如果您發現還有缺漏或者有錯誤的地方,可以在評論區補充,謝謝。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM