Java 並發編程之美-線程相關的基礎知識

2021-02-23 GitChat精品課

借用 Java 並發編程實踐中的話:編寫正確的程序並不容易,而編寫正常的並發程序就更難了;相比於順序執行的情況,多線程的線程安全問題是微妙而且出乎意料的,因為在沒有進行適當同步的情況下多線程中各個操作的順序是不可預期的。

並發編程相比 Java 中其他知識點學習起來門檻相對較高,學習起來比較費勁,從而導致很多人望而卻步;而無論是職場面試和高並發高流量的系統的實現卻都還離不開並發編程,從而導致能夠真正掌握並發編程的人才成為市場比較迫切需求的。

本 Chat 作為 Java 並發編程之美系列的開篇,首先通過通俗易懂的方式先來和大家聊聊多線程並發編程線程有關基礎知識(本文結合示例進行講解,定會讓你耳目一新),具體內容如下:

什麼是線程?線程和進程的關係。

線程創建與運行。創建一個線程有那幾種方式?有何區別?

線程通知與等待,多線程同步的基礎設施。

線程的虛假喚醒,以及如何避免。

等待線程執行終止的 join 方法。想讓主線程在子線程執行完畢後在做一點事情?

讓線程睡眠的 sleep 方法,sleep 的線程會釋放持有的鎖?

線程中斷。中斷一個線程,被中斷的線程會自己終止?

理解線程上下文切換。線程多了一定好?

線程死鎖,以及如何避免。

守護線程與用戶線程。當 main 函數執行完畢,但是還有用戶線程存在的時候,JVM 進程會退出?



在討論什麼是線程前有必要先說下什麼是進程,因為線程是進程中的一個實體,線程本身是不會獨立存在的。進程是代碼在數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,線程則是進程的一個執行路徑,一個進程至少有一個線程,進程中的多個線程是共享進程的資源的。

作業系統在分配資源時候是把資源分配給進程的,但是 CPU 資源就比較特殊,它是分派到線程的,因為真正要佔用 CPU 運行的是線程,所以也說線程是 CPU 分配的基本單位。

Java 中當我們啟動 main 函數時候其實就啟動了一個 JVM 的進程,而 main 函數所在線程就是這個進程中的一個線程,也叫做主線程。

如圖一個進程中有多個線程,多個線程共享進程的堆和方法區資源,但是每個線程有自己的程序計數器,棧區域。

其中程序計數器是一塊內存區域,用來記錄線程當前要執行的指令地址,那麼程序計數器為何要設計為線程私有的呢?前面說了線程是佔用 CPU 執行的基本單位,而 CPU 一般是使用時間片輪轉方式讓線程輪詢佔用的,所以當前線程 CPU 時間片用完後,要讓出 CPU,等下次輪到自己時候在執行,那麼如何知道之前程序執行到哪裡了?其實程序計數器就是為了記錄該線程讓出 CPU 時候的執行地址,待再次分配到時間片時候就可以從自己私有的計數器指定地址繼續執行了。

另外每個線程有自己的棧資源,用於存儲該線程的局部變量,這些局部變量是該線程私有的,其它線程是訪問不了的,另外棧還用來存放線程的調用棧幀。

堆是一個進程中最大的一塊內存,堆是被進程中的所有線程共享的,是進程創建時候分配的,堆裡面主要存放使用 new 操作創建的對象實例。

方法區則是用來存放進程中的代碼片段的,是線程共享的。


Java 中有三種線程創建方法,分別為實現 Runnable 接口的run方法、繼承 Thread 類並重寫 run 方法、使用 FutureTask 方式。

首先看下繼承 Thread 方法的實現:

public class ThreadTest {        public static class MyThread extends Thread {        @Override        public void run() {            System.out.println("I am a child thread");        }    }    public static void main(String[] args) {                MyThread thread = new MyThread();                thread.start();    }}

如上代碼 MyThread 類繼承了 Thread 類,並重寫了 run 方法,然後調用了線程的 start 方法啟動了線程,當創建完 thread 對象後該線程並沒有被啟動執行。

當調用了 start 方法後才是真正啟動了線程。其實當調用了 start 方法後線程並沒有馬上執行而是處於就緒狀態,這個就緒狀態是指該線程已經獲取了除 CPU 資源外的其它資源,等獲取 CPU 資源後才會真正處於運行狀態。

當 run 方法執行完畢,該線程就處於終止狀態了。使用繼承方式好處是 run 方法內獲取當前線程直接使用 this 就可以,無須使用 Thread.currentThread() 方法,不好的地方是 Java 不支持多繼承,如果繼承了 Thread 類那麼就不能再繼承其它類,另外任務與代碼沒有分離,當多個線程執行一樣的任務時候需要多份任務代碼,而 Runable 則沒有這個限制,下面看下實現 Runnable 接口的 run 方法方式:

   public static class RunableTask implements Runnable{        @Override        public void run() {            System.out.println("I am a child thread");        }    } public static void main(String[] args) throws InterruptedException{        RunableTask task = new RunableTask();        new Thread(task).start();        new Thread(task).start();}

如上面代碼,兩個線程公用一個 task 代碼邏輯,需要的話 RunableTask 可以添加參數進行任務區分,另外 RunableTask 可以繼承其他類,但是上面兩種方法都有一個缺點就是任務沒有返回值,下面看最後一種是使用 FutureTask:

public static class CallerTask implements Callable<String>{        @Override        public String call() throws Exception {            return "hello";        }    }    public static void main(String[] args) throws InterruptedException {            FutureTask<String> futureTask  = new FutureTask<>(new CallerTask());                new Thread(futureTask).start();        try {                      String result = futureTask.get();            System.out.println(result);        } catch (ExecutionException e) {            e.printStackTrace();        }}

註:每種方式都有自己的優缺點,應該根據實際場景進行選擇。



Java 中 Object 類是所有類的父類,鑑於繼承機制,Java 把所有類都需要的方法放到了 Object 類裡面,其中就包含本節要講的通知等待系列函數,這些通知等待函數是組成並發包中線程同步組件的基礎。

下面講解下 Object 中關於線程同步的通知等待函數。


首先談下什麼是共享資源,所謂共享資源是說該資源被多個線程共享,多個線程都可以去訪問或者修改的資源。另外本文當講到的共享對象就是共享資源。

當一個線程調用一個共享對象的 wait() 方法時候,調用線程會被阻塞掛起,直到下面幾個事情之一發生才返回:

其它線程調用了該共享對象的 notify() 或者 notifyAll() 方法;

其它線程調用了該線程的 interrupt() 方法設置了該線程的中斷標誌,該線程會拋出 InterruptedException 異常返回。

另外需要注意的是如果調用 wait() 方法的線程沒有事先獲取到該對象的監視器鎖,則調用 wait() 方法時候調用線程會拋出 IllegalMonitorStateException 異常。

那麼一個線程如何獲取到一個共享變量的監視器那?

(1)執行使用 synchronized 同步代碼塊時候,使用該共享變量作為參數:

synchronized(共享變量){         }

(2)調用該共享變量的方法,並且該方法使用了 synchronized 修飾:

synchronized void add(int a,int b){       }

另外需要注意的是一個線程可以從掛起狀態變為可以運行狀態(也就是被喚醒)即使該線程沒有被其它線程調用 notify(),notifyAll() 進行通知,或者被中斷,或者等待超時,這就是所謂的虛假喚醒。

雖然虛假喚醒在應用實踐中很少發生,但是還是需要防範於未然的,做法就是不停的去測試該線程被喚醒的條件是否滿足,不滿足則繼續等待,也就是說在一個循環中去調用 wait() 方法進行防範,退出循環的條件是條件滿足了喚醒該線程。

   synchronized (obj) {             while (條件不滿足){               obj.wait();               }    }

如上代碼是經典的調用共享變量 wait() 方法的實例,首先通過同步塊獲取 obj 上面的監視器鎖,然後通過 while 循環內調用 obj 的 wait() 方法。

下面從生產者消費者例子來加深理解,如下面代碼是一個生產者的例子,其中 queue 為共享變量,生產者線程在調用 queue 的 wait 方法前,通過使用 synchronized 關鍵字拿到了該共享變量 queue 的監視器,所以調用 wait() 方法才不會拋出 IllegalMonitorStateException 異常,如果當前隊列沒有空閒容量則會調用 queued 的 wait() 掛起當前線程,這裡使用循環就是為了避免上面說的虛假喚醒問題,這裡假如當前線程虛假喚醒了,但是隊列還是沒有空餘容量的話,當前線程還是會調用 wait() 把自己掛起。

synchronized (queue) {        while (queue.size() == MAX_SIZE) {        try {                        queue.wait();        } catch (Exception ex) {            ex.printStackTrace();        }    }        queue.add(ele);    queue.notifyAll();    } }

synchronized (queue) {        while (queue.size() == 0) {        try                        queue.wait();        } catch (Exception ex) {            ex.printStackTrace();        }    }        queue.take();    queue.notifyAll();    } }

另外當一個線程調用了共享變量的 wait() 方法後該線程會被掛起,同時該線程會暫時釋放對該共享變量監視器的持有,直到另外一個線程調用了共享變量的 notify() 或者 notifyAll() 方法才有可能會重新獲取到該共享變量的監視器的持有權(這裡說有可能,是因為考慮到多個線程第一次都調用了 wait() 方法,所以多個線程會競爭持有該共享變量的監視器)。

借用上面這個例子來講解下調用共享變量 wait() 方法後當前線程會釋放持有的共享變量的鎖的理解。

如上代碼假如生產線程 A 首先通過 synchronized 獲取到了 queue 上的鎖,那麼其它生產線程和所有消費線程都會被阻塞,線程 A 獲取鎖後發現當前隊列已滿會調用 queue.wait() 方法阻塞自己,然後會釋放獲取的 queue 上面的鎖,這裡考慮下為何要釋放該鎖?

如果不釋放,由於其它生產線程和所有消費線程已經被阻塞掛起,而線程 A 也被掛起,這就處於了死鎖狀態。這裡線程 A 掛起自己後釋放共享變量上面的鎖就是為了打破死鎖必要條件之一的持有並等待原則。關於死鎖下面章節會有講到,線程 A 釋放鎖後其它生產線程和所有消費線程中會有一個線程獲取 queue 上的鎖進而進入同步塊,這就打破了死鎖。

最後再舉一個例子說明當一個線程調用共享對象的 wait() 方法被阻塞掛起後,如果其它線程中斷了該線程,則該線程會拋出 InterruptedException 異常後返回:

public class WaitNotifyInterupt {    static Object obj = new Object();    public static void main(String[] args) throws InterruptedException {                Thread threadA = new Thread(new Runnable() {            public void run() {                try {                    System.out.println("---begin---");                                        obj.wait();                    System.out.println("---end---");                } catch (InterruptedException e) {                    e.printStackTrace();                }            }        });        threadA.start();        Thread.sleep(1000);        System.out.println("---begin interrupt threadA---");        threadA.interrupt();        System.out.println("---end interrupt threadA---");    }}

運行上面代碼輸出為:

如上代碼 threadA 調用了共享對 obj 的 wait() 方法後阻塞掛起了自己,然後主線程在休眠1s後中斷了 threadA 線程,可知中斷後 threadA 在 obj.wait() 處拋出了 java.lang.IllegalMonitorStateException 異常後返回後終止。

void wait(long timeout) 方法


該方法相比 wait() 方法多一個超時參數,不同在於如果一個線程調用了共享對象的該方法掛起後,如果沒有在指定的 timeout ms 時間內被其它線程調用該共享變量的 notify() 或者 notifyAll() 方法喚醒,那麼該函數還是會因為超時而返回。

需要注意的是如果在調用該函數時候 timeout 傳遞了負數會拋出 IllegalArgumentException 異常。

void wait(long timeout, int nanos) 方法


內部是調用 wait(long timeout),如下代碼:只是當 nanos>0 時候讓參數一遞增1。

public final void wait(long timeout, int nanos) throws InterruptedException {        if (timeout < 0) {            throw new IllegalArgumentException("timeout value is negative");        }        if (nanos < 0 || nanos > 999999) {            throw new IllegalArgumentException(                                "nanosecond timeout value out of range");        }        if (nanos > 0) {            timeout++;        }        wait(timeout);    }



一個線程調用共享對象的 notify() 方法後,會喚醒一個在該共享變量上調用 wait 系列方法後被掛起的線程,一個共享變量上可能會有多個線程在等待,具體喚醒哪一個等待的線程是隨機的。

另外被喚醒的線程不能馬上從 wait 返回繼續執行,它必須獲取了共享對象的監視器後才可以返回,也就是喚醒它的線程釋放了共享變量上面的監視器鎖後,被喚醒它的線程也不一定會獲取到共享對象的監視器,這是因為該線程還需要和其它線程一塊競爭該鎖,只有該線程競爭到了該共享變量的監視器後才可以繼續執行。

類似 wait 系列方法,只有當前線程已經獲取到了該共享變量的監視器鎖後,才可以調用該共享變量的 notify() 方法,否者會拋出 IllegalMonitorStateException 異常。


不同於 nofity() 方法在共享變量上調用一次就會喚醒在該共享變量上調用 wait 系列方法被掛起的一個線程,notifyAll() 則會喚醒所有在該共享變量上由於調用 wait 系列方法而被掛起的線程。

最後本小節最後講一個例子來說明 notify() 和 notifyAll() 的具體含義和一些需要注意的地方,代碼實例如下:

private static volatile Object resourceA = new Object();public static void main(String[] args) throws InterruptedException {        Thread threadA = new Thread(new Runnable() {        public void run() {                        synchronized (resourceA) {                System.out.println("threadA get resourceA lock");                try {                    System.out.println("threadA begin wait");                    resourceA.wait();                    System.out.println("threadA end wait");                } catch (InterruptedException e) {                                        e.printStackTrace();                }            }        }    });        Thread threadB = new Thread(new Runnable() {        public void run() {            synchronized (resourceA) {                System.out.println("threadB get resourceA lock");                try {                    System.out.println("threadB begin wait");                    resourceA.wait();                    System.out.println("threadB end wait");                } catch (InterruptedException e) {                                        e.printStackTrace();                }            }        }    });        Thread threadC = new Thread(new Runnable() {        public void run() {            synchronized (resourceA) {                System.out.println("threadC begin notify");                resourceA.notifyAll();            }        }    });        threadA.start();    threadB.start();    Thread.sleep(1000);    threadC.start();        threadA.join();    threadB.join();    threadC.join();    System.out.println("main over");}

輸出結果:

如上代碼開啟了三個線程,其中線程 A 和 B 分別調用了共享資源 resourceA 的 wait() 方法,線程 C 則調用了 nofity() 方法。

閱讀全文請掃描

下方二維碼,

還可以加入讀者圈與作者聊天~:


相關焦點

  • JAVA並發編程:線程並發工具類Callable、Future 和FutureTask的使用
    2、代碼示例package cn.lspj.ch2.future;import java.util.concurrent.Callable;import java.util.concurrent.ExecutionException
  • Java並發編程學習前期知識上篇
    Java並發編程-前期準備知識-上我們先來看看幾個大廠真實的面試題:從上面幾個真實的面試問題來看,我們可以看到大廠的面試都會問到並發相關的問題。所以Java並發,這個無論是面試還是在工作中,並發都是會遇到的。
  • Java多線程並發編程中並發容器第二篇之List的並發類講解
    Java多線程並發編程中並發容器第二篇之List的並發類講解概述本文我們將詳細講解list對應的並發容器以及用代碼來測試ArrayList、vector以及CopyOnWriteArrayList在100個線程向list中添加1000個數據後的比較
  • Java並發編程之支持並發的list集合你知道嗎
    Java並發編程之-list集合的並發.我們都知道Java集合類中的arrayList是線程不安全的。那麼怎麼證明是線程不安全的呢?怎麼解決在並發環境下使用安全的list集合類呢?本篇是《凱哥(凱哥Java:kagejava)並發編程學習》系列之《並發集合系列》教程的第一篇:本文主要內容:怎麼證明arrayList不是線程安全的?怎麼解決這個問題?以及遇到問題解決的四個步驟及從源碼來分析作者思路。一:怎麼證明arrayList在並發情況下是線程不安全的呢?
  • 原創】Java並發編程系列01|開篇獲獎感言
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫為什麼要學並發編程我曾聽一個從事15年開發工作的技術人員說過並發編程中涉及作業系統、CPU、內存等等多方面的知識,如果某一塊知識缺乏,理解起來自然會有困難。由於涉及知識較多,學習起來很容易摸不著頭緒,學習了一個點但是不能跟其他點聯繫起來。
  • Java並發編程之驗證volatile的可見性
    Java並發編程之驗證volatile的可見性通過系列文章的學習,凱哥已經介紹了volatile的三大特性。1:保證可見性 2:不保證原子性 3:保證順序。那麼怎麼來驗證可見性呢?本文凱哥將通過代碼演示來證明volatile的可見性。
  • 「原創」Java並發編程系列09|基礎乾貨
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫本文為何適原創並發編程系列第9篇。現在,我們進入正題:介紹並發編程的基礎性概念。
  • java線程的基礎問題講解
    1.1並發編程的目的:並發編程是為了讓程序運行得更快,當 並不是啟動更多的線程就能讓程序最大限度地並發執行,受限於死鎖和上下文切換問題。所以上下文切換會影響線程的執行速度。join 的作用多線程一定比單線程快嗎?不一定,在測試中並發數量沒超過百萬次的時候,串行比並發速度更快,因為線程有線程的創建和上下文切換的開銷。
  • 【堪稱經典】JAVA多線程和並發基礎面試問答
    進程和線程之間有什麼不同?一個進程是一個獨立(self contained)的運行環境,它可以被看作一個程序或者一個應用。而線程是在進程中執行的一個任務。Java運行環境是一個包含了不同的類和程序的單一進程。線程可以被稱為輕量級進程。線程需要較少的資源來創建和駐留在進程中,並且可以共享進程中的資源。2. 多線程編程的好處是什麼?
  • 「原創」Java並發編程系列02|並發編程三大核心問題
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫寫在前面編寫並發程序是比較困難的,因為並發程序極易出現Bug,這些Bug有都是比較詭異的,很多都是沒辦法追蹤,而且難以復現。
  • Java四大名著是什麼?java程式設計師提高技能的經典編程書籍推薦
    學任何語言,基本的語法知識不能少,首推,Java四大名著( java編程思想+Effective java中文版+Java核心技術卷12),下面來具體介紹以下包含Java四大名著在內的java程式設計師類圖書。
  • 徹底理解Java並發編程原理!
    Java並發編程為四大部分:計算機並發基礎知識、JDK內置並發框架、JDK並發包剖析以及其它並發知識。具體包括線程的狀態、Java線程調度策略、線程優先級、並發模型、悲觀鎖樂觀鎖、JDK各種同步器、JDK內置AQS同步器、線程與IO、Java線程與JVM線程、阻塞與喚醒機制、JDK並發包各種工具剖析、自旋、JDK內置並發鎖、CAS、synchronized、線程池、線程之間的協作等並發方面知識及原理進行深入淺出的講解。
  • Java多線程並發工具類-信號量Semaphore對象講解
    Java多線程並發工具類-Semaphore對象講解通過前面的學習,我們已經知道了Java多線程並發場景中使用比較多的兩個工具類:做加法的CycliBarrier對象以及做減法的CountDownLatch對象並對這兩個對象進行了比較。我們發現這兩個對象要麼是做加法,要麼是做減法的。那麼有沒有既做加法也做減法的呢?
  • 大數據基礎:Java多線程入門
    在大數據開發學習當中,Java基礎是非常重要的一部分,打好了Java基礎,才能在後續的大數據框架技術學習階段,也能有所主力。而Java當中的一個重要知識點,就是多線程。今天的大數據基礎分享,我們就主要來講講Java多線程入門基礎。
  • Java並發包下Java多線程並發之讀寫鎖鎖學習第五篇-讀寫鎖
    Java多線程並發之讀寫鎖本文主要內容:讀寫鎖的理論;通過生活中例子來理解讀寫鎖;讀寫鎖的代碼演示;讀寫鎖總結。通過理論(總結)-例子-代碼-然後再次總結,這四個步驟來讓大家對讀寫鎖的深刻理解。本篇是《凱哥(凱哥Java:kagejava)並發編程學習》系列之《Lock系列》教程的第七篇:《Java並發包下鎖學習第七篇:讀寫鎖》。一:讀寫鎖的理論什麼是讀寫鎖?
  • 「原創」Java並發編程系列06|你不知道的final
    本文轉載自【微信公眾號:java進階架構師,ID:java_jiagoushi】經微信公眾號授權轉載,如需轉載與原文作者聯繫final在Java中是一個保留的關鍵字,可以修飾變量、方法和類。那麼fianl在並發編程中有什麼作用呢?本文就在對final常見應用總結基礎上,講解final並發編程中的應用。
  • Java多線程編程必備基礎知識
    另外,線程是進程中的一個實體,是被系統獨立調度和分派的基本單位,線程自己不擁有系統資源,只擁有一點兒在運行中必不可少的資源,但它可與同屬一個進程的其它線程共享進程所擁有的全部資源。一個線程可以創建和撤銷另一個線程,同一進程中的多個線程之間可以並發執行。由於線程之間的相互制約,致使線程在運行中呈現出間斷性。線程也有就緒、阻塞和運行三種基本狀態。
  • Java多線程工具類之循環柵欄計數器
    本篇是《凱哥(凱哥Java:kagejava)並發編程學習》系列之《並發工具類》教程的第二篇:《Java多線程下循環計數器》。一:CyclicBarrier是什麼cycBar是什麼呢?如果站在多線程並發場景下來分析的話:旅遊團中每個成員都是一個線程,入口集合點就是屏障(Barrier),每個成員都必須到達集合點(循環到達Cyclic)且人數和旅遊團人數相等的時候,才能觸發旅遊車發車去下一個景點的線程。還有一個例子更容易理解:集齊七龍珠,召喚神龍。相信看過《七龍珠》的都知道這個吧。想要召喚神龍的觸發點就是集齊七個龍珠。
  • Alibaba架構師從零開始,一步一步帶你進入並發編程的世界
    第4章中講解了Executor接口與ThreadPoolExecutor線程池的使用,可以說本章中的知識也是Java並發包中主要的應用技術點,線程池技術也在眾多的高並發業務環境中使用。掌握線程池能更有效地提高程序運行效率,更好地統籌線程執行的相關任務。
  • Java基礎知識點面試手冊(線程+JDK8)
    此為下篇,內容包括:高並發編程,Java8新特性。高並發編程多線程和單線程的區別和聯繫:答:在單核 CPU 中,將 CPU 分為很小的時間片,在每一時刻只能有一個線程在執行,是一種微觀上輪流佔用 CPU 的機制。