原創|《菜鳥讀併發》併發編程必知必會概念


點擊上方「碼農進階之路」,選擇設爲星標java

回覆面經獲取面試資料程序員

這是大師兄《菜鳥讀併發》系列文章第一篇,一步一步帶你走進併發的世界,一塊兒探索併發編程的奧妙!那麼,讓咱們一塊兒來開始學習之旅吧!web

什麼是進程?

  • 系統運行程序的基本單位
  • 程序的一次執行過程,系統運行一個程序便是一個進程從建立,運行到消亡的過程,因此進程是動態的
  • 任務管理器中window 當前運行的進程都是以.exe 爲後綴的

在 Java 中,當咱們啓動 main 函數時其實就是啓動了一個 JVM 的進程,而 main 函數所在的線程就是這個進程中的一個線程,也稱主線程面試

什麼是線程?

  • 線程是一個比進程更小的執行單位(線程也被稱爲輕量級進程,在產生一個線程,或是在各個線程之間做切換工做時,負擔要比進程小得多)
  • 一個進程在其執行的過程當中能夠產生多個線程
  • 同類的多個線程共享進程的堆和方法區資源
  • 每一個線程有本身的程序計數器、虛擬機棧和本地方法棧
  • 線程是操做系統裏的一個概念,雖然各類不一樣的開發語言如Java、C#等都對其進行了封裝,可是萬變不離操做系統。Java 語言裏的線程本質上就是操做系統的線程,它們是一一對應的
Java 程序天生就是多線程程序,咱們能夠經過 JMX 來看一下一個普通的 Java 程序有哪些線程,代碼以下:
public class MultiThread { public static void main(String[] args) { // 獲取 Java 線程管理 MXBean ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); // 不須要獲取同步的 monitor 和 synchronizer 信息,僅獲取線程和線程堆棧信息 ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false); // 遍歷線程信息,僅打印線程 ID 和線程名稱信息 for (ThreadInfo threadInfo : threadInfos) { System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName()); } }}

上述程序輸出以下(輸出內容可能不一樣,不用太糾結下面每一個線程的做用,只用知道 main 線程執行 main 方法便可):算法

[5] Attach Listener //添加事件[4] Signal Dispatcher // 分發處理給 JVM 信號的線程[3] Finalizer //調用對象 finalize 方法的線程[2] Reference Handler //清除 reference 線程[1] main //main 線程,程序入口

從上面的輸出內容能夠看出:一個 Java 程序的運行是 main 線程和多個其餘線程同時運行。數據庫

圖解進程和線程的關係

從 JVM 角度說進程和線程之間的關係 下圖是 Java 內存區域,經過下圖咱們從 JVM 的角度來講一下線程和進程之間的關係。若是你對 Java 內存區域 (運行時數據區) 這部分知識不太瞭解的話能夠閱讀一下這篇文章:《多是把 Java 內存區域講的最清楚的一篇文章》[1]編程

從上圖能夠看出:一個進程中能夠有多個線程,多個線程共享進程的堆和方法區 (JDK1.8 以後的元空間)資源,可是每一個線程有本身的程序計數器、虛擬機棧 和 本地方法棧。緩存

總結:
  • 一個進程在其執行的過程當中能夠產生多個線程
  • 線程是進程劃分紅的更小的運行單位。(線程也被稱爲輕量級進程,在產生一個線程,或是在各個線程之間做切換工做時,負擔要比進程小得多)
  • 同類的多個線程共享進程的堆和方法區資源
  • 每一個線程有本身的程序計數器、虛擬機棧和本地方法棧
  • 線程執行開銷小,但不利於資源的管理和保護;而進程正相反
  • 線程和進程最大的不一樣在於基本上各進程是獨立的,而各線程則不必定,由於同一進程中的線程極有可能會相互影響。

思考:爲何程序計數器、虛擬機棧和本地方法棧是線程私有的呢?爲何堆和方法區是線程共享的呢?微信

程序計數器

程序計數器主要有下面兩個做用:多線程

  • 字節碼解釋器經過改變程序計數器來依次讀取指令,從而實現代碼的流程控制,如:順序執行、選擇、循環、異常處理。
  • 在多線程的狀況下,程序計數器用於記錄當前線程執行的位置,從而當線程被切換回來的時候可以知道該線程上次運行到哪兒了。(保存現場和恢復現場,程序計數器私有主要是爲了線程切換後能恢復到正確的執行位置)

注意:

  • 若是執行的是 native方法,那麼程序計數器記錄的是undefined地址
  • 只有執行的是Java代碼時程序計數器記錄的纔是下一條指令的地址

虛擬機棧

  • 每一個Java方法在執行的同時會建立一個棧幀用於存儲局部變量表、操做數棧、常量池引用等信息
  • 從方法調用直至執行完成的過程,就對應着一個棧幀在Java 虛擬機棧中入棧和出棧的過程

本地方法棧

  • 本地方法棧則爲虛擬機使用到的 Native 方法服務
  • 和虛擬機棧所發揮的做用很是類似,區別是:虛擬機棧爲虛擬機執行 Java方法(也就是字節碼)服務
  • 在 HotSpot 虛擬機中和 Java 虛擬機棧合二爲一。

總結:爲了保證線程中的局部變量不被別的線程訪問到,虛擬機棧和本地方法棧是線程私有的。

堆和方法區

  • 堆和方法區是全部線程共享的資源,其中堆是進程中最大的一塊內存.
  • 主要用於存放新建立的對象(全部對象都在這裏分配內存)
  • 方法區主要用於存放已被加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

併發與並行的區別?

  • 併發:同一時間段,多個任務都在執行(單位時間內不必定同時執行);
  • 並行:單位時間內,多個任務同時執行。

爲何要使用多線程呢?

先從整體上來講:

  • 從計算機底層來講:
  1. 線程能夠比做是輕量級的進程,是程序執行的最小單位,線程間的切換和調度的成本遠遠小於進程。
  2. 多核 CPU 時代意味着多個線程能夠同時運行,這減小了線程上下文切換的開銷。
  • 從當代互聯網發展趨勢來講:
  1. 如今的系統動不動就要求百萬級甚至千萬級的併發量,而多線程併發編程正是開發高併發系統的基礎
  2. 利用好多線程機制能夠大大提升系統總體的併發能力以及性能。

再深刻到計算機底層來探討:

  • 單核時代:在單核時代多線程主要是爲了提升CPU和IO設備的綜合利用率。

舉個例子:當只有一個線程的時候會致使CPU計算時,IO 設備空閒;進行IO操做時,CPU空閒。咱們能夠簡單地說這二者的利用率目前都是50%左右。可是當有兩個線程的時候就不同了,當一個線程執行CPU計算時,另一個線程能夠進行IO操做,這樣兩個的利用率就能夠在理想狀況下達到 100%了。

  • 多核時代: 多核時代多線程主要是爲了提升CPU利用

舉個例子:假如咱們要計算一個複雜的任務,咱們只用一個線程的話,CPU只會一個CPU核心被利用到,而建立多個線程就可讓多個CPU核心被利用到,這樣就提升了 CPU 的利用率。

併發編程的目的是什麼?

  • 爲了能提升程序的執行效率提升程序運行速度

併發編程可能會遇到什麼問題?

  • 內存泄漏
  • 上下文切換
  • 死鎖
  • 受限於硬件和軟件的資源閒置問題

什麼是上下文切換?

  1. 多線程編程中通常線程的個數都大於CPU核心的個數,而一個 CPU 核心在任意時刻只能被一個線程使用
  2. 爲了讓這些線程都能獲得有效執行,CPU採起的策略是爲每一個線程分配時間片並輪轉的形式。
  3. 當一個線程的時間片用完的時候就會從新處於就緒狀態讓給其餘線程使用,這個過程就屬於一次上下文切換
  4. 任務從保存到再加載的過程就是一次上下文切換,當前任務在執行完 CPU時間片切換到另外一個任務以前會先保存本身的狀態,以便下次再切換回這個任務時,能夠再加載這個任務的狀態。
  5. 多線程不必定比單線程快的緣由多是線程建立和上下文切換的緣由致使的

上下文切換一般是計算密集型的。也就是說,它須要至關可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都須要納秒量級的時間。因此,上下文切換對系統來講意味着消耗大量的CPU時間,事實上,多是操做系統中時間消耗最大的操做。

Linux 相比與其餘操做系統(包括其餘類 Unix 系統)有不少的優勢,其中有一項就是,其上下文切換和模式切換的時間消耗很是少。

減小上下文切換的方法:
  1. 無鎖併發編程,能夠將數據id進行hash算法取模分段,不一樣線程處理不一樣的數據(分段鎖)
  2. cas算法,java中Atomic包使用的cas算法更新數據
  3. 使用最少線程,合理設置線程數量,避免大量的線程處於等待
  4. 使用協成,單線程實現任務調度,而且在單線程維護多個任務的切換

性能分析工具Lmbench3能夠測量上下文切換的時間,vmstat能夠測量上下文切換的次數(cs表示),具體分析看個人另一篇博客文章

資源限制併發的挑戰:

(受限資源,併發執行的任務任然串行執行,這時候反而增長了上下文切換的時間和調度時間)

  1. 硬件資源:帶寬的上傳下載速度,硬盤的讀寫速度,cpu的處理速度
  2. 軟件資源:數據庫鏈接數,socket的數量

死鎖的造成:

死鎖通常發生在線程在完成一個件事的時候,須要申請多把鎖,出現線程之間相互持有對方所需的鎖,此時容易發生死鎖(經過dump能夠查看死鎖)

舉個例子
import java.util.List;public class LockTest {  // 例如這個方法上有一個事務註解 public void updateBusiness(List<Long> goodsIds) {
if(goodsIds == null || goodsIds.isEmpty()) { return; } IGoodsDao goodsDao = null;
for(Long gid : goodsIds) { goodsDao.updateGoods(gid, 1); // 將庫存減去1,須要持有該記錄的行鎖 } }}
interface IGoodsDao { // 減庫存 void updateGoods(Long goodsId, int nums);}
  1. 若是一個用戶要購買商品ID 爲  1,3 的商品
  2. 而另一個用戶須要購買一樣的商品,可是在購物車中選擇商品的順序是 3,1
  3. 此時兩個線程同時調用 updateBusiness 方法

執行軌跡以下:

這樣就出現了死鎖。

避免死鎖的方法:
  1. 數據庫鎖的加鎖和解鎖須要在同一個數據庫鏈接裏,不然會出現解鎖失敗
  2. 避免一個線程獲取多個鎖
  3. 嘗試使用定時鎖,lock.tryLock(timeOut)替換內部鎖
  4. 避免一個鎖內同時佔用多個資源,儘可能保證每個鎖內只佔用一個資源

針對上面的例子,一般的解決辦法是,先對鎖申請進行排序。

// 例如這個方法上有一個事務註解 public void updateBusiness(List<Long> goodsIds) {
if(goodsIds == null || goodsIds.isEmpty()) { return; } IGoodsDao goodsDao = null;
Collections.sort(goodsIds);
for(Long gid : goodsIds) { goodsDao.updateGoods(gid, 1); // 將庫存減去1,須要持有該記錄的行鎖 } }

這樣就不會出現死鎖了。

提升併發的兩種方式

  • 無鎖併發編程
  • 分段鎖(數據的id進行hash取模分段,不一樣的線程處理不一樣段的數據)

併發機制的底層實現原理 java代碼在編譯以後會變成java字節碼,字節碼被類加載器加載到jvm裏,jvm執行字節碼,最終須要轉換成彙編指令在CPU上運行,java鎖使用的併發機制依賴於jvm的實現和cpu指令

Java 內存模型

Java 內存模型是個很複雜的規範,能夠從不一樣的視角來解讀,站在咱們這些程序員的視角,本質上能夠理解爲, Java內存模型規範了JVM如何提供按需禁用緩存和編譯優化的方法。具體來講,這些方法包括 volatile 、 synchronized 和 final 三個關鍵字,以及八項 happen - Before 規則,後續的文章咱們會一一來學習。



往期精選

下一篇:原創|《菜鳥讀併發》什麼是線程死鎖,怎麼解決線程死鎖

碼農進階之路

長按二維碼關注 

面經 | 原理 | 源碼 | 實戰 | 工具

點擊留言

本文分享自微信公衆號 - 碼農進階之路(SYJava)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索