【Java多線程編程實戰】多線程編程的目標與挑戰

多線程編程的目標與挑戰

串行、併發與並行

串行(Sequential)實際上是順序逐一完成多個任務;併發(Concurrent)實際上是以交替的方式利用等待某個任務完成的時間來執行其他任務,即一段時間內可以處理或完成更多的任務;並行(Parallel)是串行的反面,是一種更嚴格、更理想的併發,即並行可以被看做併發的一個特例。
從軟件的角度來說,併發就是在一段時間內以交替的方式取完成多個任務,而並行就行以齊頭並進的方式去完成多個任務。
從硬件的角度來說,在一個處理器一次只能運行一個線程的情況下,由於處理器可以使用時間片(Time-slice)分配的技術來實現在同一段時間內運行多個線程,因此處理器就可以實現併發。而並行則需要靠多個處理器在同一時刻各自運行一個線程來實現。
多線程編程的實質就是將任務的處理方式由從串行改爲併發,即實現並行化。如果一個任務的處理方式可以由串行改爲併發(或者並行),那麼這個任務是可併發化(或者並行化)的。

競態

多線程編程中經常遇到一個問題就是對於同樣的輸入,程序的輸出有時候是正確的,而有時候卻是錯誤的。這種一個計算結果的正確性與時間有關的線程就被稱爲競態(Race Condition)。
一個競態實例:某系統爲了便於跟蹤對其接收到的HTTP請求的處理,會爲其收到的每個HTTP請求分配一個唯一編號(Request ID).Resuest ID是一個固定長度的編碼字符串,其中最後3位是一個在0~999循環遞增的序列號。Request ID生成器RequestIDGenerator代碼如下:

import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

public class RequestIDGenerator implements CircularSeqGenerator{
    /*
    * 保存該類的唯一實例
     */
    private final static RequestIDGenerator INSTANCE = new RequestIDGenerator();
    private final static short SEQ_UPPER_LIMIT = 999;
    private short sequence = -1;
    //私有構造器
    private RequestIDGenerator(){
        //什麼也不做
    }
    /**
     * 生成循環遞增序列號
     * @ruturn
     */
    @Override
    public short nextSequence() {
        if (sequence>=SEQ_UPPER_LIMIT){
            sequence=0;
        }else {
            sequence++;
        }
        return sequence;
    }
    /**
     * 生成一個新的Request ID
     */
    public String nextID(){
        SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss");
        String timestamp = sdf.format(new Date());
        DecimalFormat df = new DecimalFormat("000");

        //生成請求序列號
        short sequenceNo = nextSequence();
        return "0049"+timestamp+df.format(sequenceNo);
    }
    /**
     * 返回該類的唯一實例
     */
    public static RequestIDGenerator getInstance(){
        return INSTANCE;
    }
}

如下代碼模擬了實際環境中的使用情況:每個業務線程(請求處理線程)在處理其接收到的請求前都要先爲該請求去申請一個RequestID.

import util.Tools;

public class HTTPRequestId {
    public static void main(String[] args) throws Exception{
        //客戶端線程數
        int numberOfThread = args.length>0?Short.valueOf(args[0]):Runtime.getRuntime().availableProcessors();
        Thread[] workerThreads = new Thread[numberOfThread];
        for (int i = 0; i <numberOfThread ; i++) {
            workerThreads[i] = new WorkerThread(i,10);
        }
        //待所有線程創建完畢後,再一次將其啓動,以便這些線程能夠儘可能地在同一時間內運行
        for (Thread ct:workerThreads) {
            ct.start();
        }
    }
    //模擬業務線程
    static class WorkerThread extends Thread{
        private final int requestCount;
        public WorkerThread(int id, int requestCount){
            super("worker-"+id);
            this.requestCount=requestCount;
        }

        @Override
        public void run() {
            int i = requestCount;
            String requestID;
            RequestIDGenerator requestIDGen = RequestIDGenerator.getInstance();
            while (i-->0){
                //生成Request ID
                requestID = requestIDGen.nextID();
                processRequest(requestID);
            }
        }
        //模擬請求處理
        private void processRequest(String requestID){
            //模擬請求處理耗時
            Tools.randomPause(50);
            System.out.printf("%s got requestID:%s %n",Thread.currentThread().getName(),requestID);
        }
    }
}

運行結果如下:
競態Demo運行結果

二維表分析法:解釋競態的結果

  • 狀態變量:即類的實例變量、靜態變量
  • 共享變量:即可以被多個線程共同訪問的變量。

導致競態的常見因素是多個線程在沒有采取任何控制措施的情況下併發地更新、讀取同一個共享變量,因此產生了讀取髒數據的問題。
競態(Race Condition)是指計算的正確性依賴於相對時間順序(Relative Timing)或者線程的交錯(Interleaving)。根據這個定義可知,競態不一定就導致計算結果的不正確,它只是不排除計算結果時而正確時而錯誤的可能。

競態的模式與競態產生的條件

競態的兩種模式:read-modify-write和check-then-act

  • read-modify-write(讀-改-寫)操作。讀取一個共享變量的值(read),然後根據該值做一些計算(modify),接着更新該共享變量(write)。
  • check-then-act(檢查而後行動)操作。讀取某個共享變量的值,根據該變量的值決定下一步的動作是什麼。

競態可以看做訪問(讀取、更新)同一組共享變量的多個線程所執行的操作相互交錯(Interleave)。
對於局部變量(包括形式參數和方法體內定義的變量),由於不同的線程各自訪問的是各自的那一份局部變量,因此局部變量的使用不會導致競態!
一個解決方法就是在RequestIDGenerator.nextSequence()的聲明中添加一個synchronized關鍵字:

public class SafeCircularSeqGenerator implements CircularSeqGenerator{
   private short sequence = -1;
   public synchronized short nextSequence(){
       if (sequence>=999){
           sequence = 0;
       }else {
           sequence++;
       }
       return sequence;
   }
}

synchronized關鍵字會使其修飾的方法在任一時刻只能夠被一個線程執行,這使得該方法涉及的共享變量在任一時刻只能有一個線程訪問(讀、寫)。

線程安全性

一般而言,如果一個類在單線程環境下能夠運作正常,並且在多線程環境下,在其使用方法不必爲其做任何改變的情況下也能運行正常,就稱其是線程安全的(Thread-safe)。相應地,稱這個類具有線程安全性(Thread Safety)。
反之,如果一個類在單線程環境下運作正常而在多線程環境下則無法正常運作,那麼就是非線程安全的
因此一個類能夠導致競態,它就是非線程安全的,如果一個類是線程安全的,那麼就不會導致競態。

一個類如果不是線程安全的,就說它在多線程環境下直接使用存在線程安全問題。線程安全問題表現爲3個方面:原子性、可見性和有序性。

原子性

原子性:該操作要麼已經執行結束要麼尚未發生,也就是說,其他線程不會“看到”該操作執行了部分的中間結果。需要注意的是:

  • 原子操作是針對訪問共享變量的操作而言的。
  • 原子操作時從該操作的執行線程以外的線程來描述的。

總的來說,Java中有兩種方式來實現原子性。一種是使用鎖(Lock)。鎖具有排他性,即它能夠保障一個共享變量在任意一個時刻只能夠被一個線程訪問。另一種是利用處理器提供的專門CAS(Compare-and-Swap)指令,CAS指令實現原子性的方式與鎖實現原子性的方式實質上是相同的,差別在於鎖通常是在軟件這一層次實現的,而CAS是直接在硬件(處理器和內存)這一層次實現的。可以被看做“硬件鎖”。
在java中,long型和double型以外的任何類型的變量的寫操作都是原子操作。儘管如此,Java語言規範特別地對於volatile關鍵字修飾的long/double型變量的寫操作具有原子性。注意,volatile關鍵字僅能夠保證變量寫操作的原子性。
在Java語言中,針對任何變量的讀操作都是原子操作。“原子操作+原子操作”所得到複合操作並非原子操作。

可見性

如果一個線程對某個共享變量進行更新之後,後續訪問該變量的線程可以讀取到該更新的結果,那麼我們就成這個線程對該共享變量的更新對其他線程可見,否則不可見。可見性就是指一個線程對共享變量的更新的結果對於讀取相應共享變量的線程而言是否可見的問題。
可見性問題Demo:

import util.Tools;

public class VisibilityDemo {
    public static void main(String[] args) throws InterruptedException{
        TimeConsumingTask timeConsumingTask = new TimeConsumingTask();
        Thread thread = new Thread(new TimeConsumingTask());
        thread.start();
        //指定的時間內任務沒有執行結束的話,就將其取消
        Thread.sleep(10000);
        timeConsumingTask.cancel();
    }
}
class TimeConsumingTask implements Runnable{
    private boolean toCancel = false;

    @Override
    public void run(){
        while (!toCancel){
            if (doExecute()){
                break;
            }
        }
        if (toCancel){
            System.out.println("Task was canceled.");
        }else{
            System.out.println("Task done.");
        }
    }
    private boolean doExecute(){
        boolean isDone = false;
        System.out.println("executing...");
        //模擬實際操作的時間消耗
        Tools.randomPause(50);
        //省略其他代碼
        return isDone;
    }
    public void cancel(){
        toCancel = true;
        System.out.println(this+"cancled.");
    }
}

另一方面,可見性問題與計算機的存儲系統有關。程序中的變量可能會被分配到寄存器(Register)而不是主內存中進行存儲。每個處理器都有其寄存器,而一個處理器無法讀取另一個處理器上的寄存器中的內容。因此,如果兩個線程分別運行在不同的處理器上,而這兩個線程所共享的變量卻被分配到寄存器上進行存儲,那麼可見性問題就會產生。另外,即便某個共享變量是被分配到主內存中進行存儲的,也不能保證該變量的可見性。因爲處理器對主內存的訪問並不是直接訪問,而是通過其高速緩存(Cache)子系統進行的。

處理器並不是直接與主內存(RAM)打交道而執行內存的讀、寫操作,而是通過寄存器(Register)、高速緩存(Cache)、寫緩衝器(Store Buffer, 也稱Write Buffer)和無效化隊列(Invalidate Queue)等部分執行內存的讀、寫操作的。從這個角度來看,這些部件相當於主內存的副本,統稱爲處理器對主內存的緩存,簡稱處理器緩存。

一個處理器可以通過緩存一致性協議(Cache Coherence Protocol)來讀取其他處理器的高速緩存中的數據,並將讀到的數據更新到該處理器的高速緩存中。這種一個處理器從其自身處理器緩存以外的其他存儲部件中讀取數據並將其反映(更新)到該處理器的高速緩存的過程,稱爲緩存同步
因此,爲了保障可見性,必須使一個處理器對共享變量所作的更新最終被寫入該處理器的高速緩存或者主內存中(而不是始終停留在其寫緩衝器中),這個過程被稱爲沖刷處理器緩存。並且一個處理器在讀取共享變量的時候,如果其他處理器在此之前已經更新了該變量,那麼該處理器必須從其他處理器的高速緩存或者主內存中對相應的變量進行緩存同步,這個過程稱爲刷新處理器緩存

因此,可見性的保障是通過使更新共享變量的處理器執行沖刷處理器緩存的動作,並使讀取共享變量的處理器執行刷新處理器緩存的動作來實現的。

在Java平臺中如何保證可見性?變量的聲明中添加一個volatile關鍵字即可。volatile關鍵字所起的一個作用就是提示JIT編譯器被修飾的變量可能被多個線程共享,以阻止JIT編譯器做出可能導致程序運行不正常的優化。另一個作用就是讀取一個volatile關鍵字修飾的變量會使相應的處理器執行刷新處理器緩存的動作,寫一個volatile關鍵字修飾的變量會使相應的處理器執行沖刷處理器緩存的動作,從而保證可見性。
可見性得以保障,並不意味着一個線程能夠看到另一個線程更新的所有變量的值。可見性的保障僅僅意味着一個線程能夠讀取到共享變量的相對新值,而不能保障該線程能夠讀取到相應變量的最新值。

對於同一個共享變量而言,一個線程更新了該變量的值之後,其他線程能夠讀取到這個更新後的值,那麼這個值就被稱爲該變量的相對新值
如果讀取這個共享變量的線程在讀取並使用該變量的時候其他線程無法更新該變量的值,那麼該線程讀取到的相對新值就被稱爲該變量的最新值
單處理器系統是否存在可見性問題?存在。在單處理器環境下,多線程的併發執行實際上是通過時間片(Time Slice)分配實現的。此時,雖然多個線程是運行在同一個處理器上的,但是由於在發生上下文切換(Context Switch)的時候,一個線程對寄存器(Register)變量的修改會被作爲該線程的線程上下文保存起來,這導致另一個線程無法“看到”該線程對這個變量的修改,

可見性與原子性的聯繫與區別?原子性描述的是一個線程對共享變量的更新,要麼完成了,要麼尚未發生,因此保證一個線程所讀取到的共享變量的值要麼是該變量的初始值要麼是該變量的相對新值。可見性描述了一個線程對共享變量的更新對於另外一個線程而言是否可見的問題。保障可見性意味着一個線程可以讀取到相應共享變量的相對新值。

Java語言規範保證父線程在啓動子線程之前對共享變量的更新對於子線程來說是可見的。代碼如下:

import util.Tools;

public class ThreadStartVisibility {
    // 線程間的共享變量
    static int data = 0;
    public static void main(String[] args){
        Thread thread = new Thread(){
            @Override
            public void run() {
                //使當前線程休眠R毫秒(R的值爲隨機數)
                Tools.randomPause(50);
                //讀取並打印變量data的值
                System.out.println(data);
            }
        };
        //在子線程thread啓動前更新變量data的值
        data = 1; //語句①
        thread.start();
        //使當前線程休眠R毫秒(R的值爲隨機數)
        Tools.randomPause(50);
        //在子線程thread啓動後更新變量data的值
        data = 2; //語句②
    }
}

類似地,Java語言規範保證一個線程終止後該線程對共享變量的更新對於調用該線程的join方法的線程而言是可見的。示例代碼如下:

import util.Tools;

public class ThreadJoinVisibility {
    // 線程間的共享變量
    static int data = 0;
    public static void main(String[] args){
        Thread thread = new Thread(){
            @Override
            public void run() {
               //使當前線程休眠R毫秒(R的值爲隨機數)
                Tools.randomPause(50);

                //更新data的值
                data =1;
            }
        };
        thread.start();
        //等待線程thread結束後,main線程才能繼續運行
        try {
            thread.join();
        }catch (InterruptedException e){
            e.printStackTrace();
        }
        //讀取並打印變量data的值
        System.out.println(data);
    }
}

有序性

有序性(Ordering)指在什麼情況下一個處理器上運行的一個線程所執行的內存訪問操作在另一個處理器上運行的其他線程看來是亂序的(Out of Order)。

重排序

處理器可能不是完全依照程序的目標代碼所指定的順序執行指令;另外,一個處理器上執行的多個操作,從其他處理器的角度來看其順序可能與目標代碼所指定的順序不一致。這種現象就是重排序(Recording)。
重排序是對內存訪問有關的操作(讀和寫)所作的一種優化。

定義幾個與內存操作順序有關的術語:

  • 源代碼順序(Source Code):源代碼中所指的內存訪問操作順序
  • 程序順序(Program Order):在給定處理器上運行的目標代碼(Object Code)所指定的內存訪問操作順序。
  • 執行順序(Eexcution Order):內存訪問操作在給定處理器上的實際執行順序
  • 感知順序(Perceived Order):給定處理器所感知到(看到)的該處理器及其他處理器的內存訪問操作發生的順序。

在此基礎上,將重排序劃分爲指令重排序(Instruction Reorder)和存儲子系統重排序兩種。

指令重排序

在源代碼順序與程序順序不一致,或者程序順序與執行順序不一致的情況下,就認爲發生了指令重排序。指令重排序是一種動作,它確確實實對指令的順序做了調整,其重排序的對象是指令。
在Java平臺中,靜態編譯器(javac)基本上不會執行指令重排序,而JIT編譯器則可能執行指令重排序,示例代碼如下:

//JIT編譯器指令重排序
import util.stf.*;

@ConcurrencyTest(iterations = 200000)
public class JITReorderingDemo {
    private int externalDate = 1;
    private Helper helper;

    static class Helper {
        int payloadA;
        int payloadB;
        int payloadC;
        int payloadD;

        public Helper(int externalDate) {
            this.payloadA = externalDate;
            this.payloadB = externalDate;
            this.payloadC = externalDate;
            this.payloadD = externalDate;
        }
    }

    @Actor
    public void createHelper() {
        helper = new Helper(externalDate);
    }

    @Observer({
            @Expect(desc = "Helper is null", expected = -1),
            @Expect(desc = "Helper is not null, but it is not initialized", expected = 0),
            @Expect(desc = "Only 1 field of Helper instance was initialized", expected = 1),
            @Expect(desc = "Only 2 field of Helper instance were initialized", expected = 2),
            @Expect(desc = "Only 3 field of Helper instance were initialized", expected = 3),
            @Expect(desc = "Helper instance was fully initialized", expected = 4)
    })
    public int consume() {
        int sum = 0;
        /**
         * 沒有對共享變量helper進行任何處理,因此存在可見性問題,即當前線程讀取到的變量值可能爲null
         */
        final Helper observedHelper = helper;
        if (null == observedHelper) {
            sum = -1;
        } else {
            sum = observedHelper.payloadA + observedHelper.payloadB + observedHelper.payloadC + observedHelper.payloadD;
        }
        return sum;
    }

    public static void main(String[] args) throws InstantiationException, IllegalAccessException {
        //調用測試工具運行測試代碼
        TestRunner.runTest(com.company.ch2.JITReorderingDemo.class);
    }
}

重排序所具有的兩個特徵:

  • 重排序可能導致線程安全問題。
  • 重排序不是必然出現的。

處理器也可能執行指令重排序,這使得執行順序與程序順序不一致。處理器對指令進行重排序也被稱爲處理器的亂序執行(Out-of-order Execution)。現代處理器不是按照程序順序逐一執行指令的,而是動態調整指令的順序,做到哪條指令就緒就先執行哪條指令。這些指令的執行結果(要進行寫寄存器或者對內存的操作)會被先存入重排序緩衝器(ROB, Reorder Buffer),而不是直接被寫入寄存器或者主內存。重排序緩衝器會將各個指令的執行結果按照相應指令被處理器讀取的順序提交(Commit,即寫入)到寄存器或者內存中去(順序提交)。
處理器的亂序執行還採用了一種被稱爲猜測執行(Speculation)的技術。例如:

//猜測執行代碼示例
public class SpeculativeLoadExample {
    private boolean ready = false;
    private int[] data = new int[]{1, 2, 3, 4, 5, 6, 7, 8};

    public void writer() {
        int[] newData = new int[]{1, 2, 3, 4, 5, 6, 7, 8};
        for (int i = 0; i < newData.length; i++) {
            //此處包含讀內存的操作
            newData[i] = newData[i] - i;
        }
        data = newData;
        //此處包含寫內存的操作
        ready = true;
    }

    public int reader() {
        int sum = 0;
        int[] snapshot;
        if (ready) {
            snapshot = data;
            for (int i = 0; i < snapshot.length; i++) {
                sum += snapshot[i];
            }
        }
        return sum;
    }
}

處理器的指令重排序並不會對單線程程序的正確性產生影響,但是它可能導致多線程程序出現非預期的結果。

存儲子系統重排序

處理器並不是直接訪問主內存,而是通過高速緩存(Cache)訪問主內存的。在此基礎上,現代處理器還引入了寫緩衝器(Store Buffer,也成爲Write Buffer)以提高寫高速緩衝操作(以實現寫主內存)的效率。將寫緩存器和高速緩存統稱爲存儲子系統
存儲子系統重排序是一種現象而不是一種動作,它並沒有真正對指令執行順序進行調整,而只是造成了一種指令的執行順序像是被調整過一樣的現象,其重排序的對象是內存操作的結果。

從處理器的角度看,讀內存操作的實質是從指定的RAM地址加載數據(通過高速緩存加載)到寄存器,因此讀內存操作通常被稱爲Load。寫內存操作的實質是將數據(可能作爲操作數直接存儲到指令中,也可能存儲在寄存器中)存儲到指定地址表示的RAM存儲單元中,因此寫內存操作通常被稱爲Store。所以,內存重排序實際上只有4種可能,即LoadLoad重排序、StoreStore重排序、LoadStore重排序、StoreLoad重排序。
另外,內存重排序與具體的處理器架構有關,基於不同微架構的處理器所允許或者支持的內存重排序是不同的。內存重排序還可能導致線程安全問題。

貌似串行語義

重排序並非雜亂無章的排序或順序調整,而是遵循一定的規則。編譯器、處理器都會遵循這些規則,從而給單線程程序創造一種假象(Illusion)——指令是按照源代碼順序執行的。這種假象稱爲貌似串行語義(As-if-serial Semantics)。貌似串行語義只是從單線程程序的角度保證重排序後的運行結果不影響程序的正確性,它並不保證多線程環境下程序的正確性。
爲了保證貌似串行語義,存在數據依賴關係的語句不會被重排序,只有不存在數據依賴關係的語句纔會被重排序。如果兩個操作(指令)訪問同一個變量(地址),且其中一個操作(指令)爲寫操作,那麼這兩個操作之間就存在數據依賴關係(Data Dependency)。
另外,存在控制依賴關係的語句是可以允許被重排序的。如果一條語句(指令)的執行結果會決定另外一個語句(指令)能否被執行,那麼這兩條語句(指令)之間就存在控制依賴關係(Control Dependency)。存在控制依賴關係的語句會影響處理器對指令序列執行的並行程度。

注意:單處理器上實現的多線程實際上是通過分配時間片(Time Slice)實現的。單處理器的系統也存在重排序現象。那麼單處理器上運行的一個線程上發生的重排序對這個處理器上運行的其他線程而言,其正確性是否會收到影響呢?

  • 編譯器重排序。即靜態編譯器(javac)造成的重排序會對運行在單處理器上的多個線程產生影響。
  • 運行期重排序,包括存儲子系統造成的重排序、JIT編譯器造成的重排序以及處理器的亂序執行所導致的重排序,並不會對單處理器上運行的多線程產生影響。即在這些線程看來處理器像是按照程序順序執行指令。

保證內存訪問的順序性

如何避免重排序導致的線程安全問題呢?實質上就是如何保證感知順序與源代碼順序一致,即有序性。
有序性的保障可以理解爲從邏輯上部分禁止重排序。
從底層的角度來看,禁止重排序是通過調用處理器提供相應的指令(內存屏障)來實現的。

可見性與有序性的聯繫與區別:可見性是有序性的基礎;有序性影響可見性,由於重排序的作用,一個線程對共享變量的更新對於另外一個線程而言可能變得不可見。

上下文切換

上下文切換及其產生原因

單處理器上的多線程其實就是通過這種時間片分配的方式實現的。時間片決定了一個線程可以連續佔用處理器運行的時間長度。當一個進程中的一個線程由於其時間片用完或者其自身的原因被迫或者主動暫停其運行時,另外一個線程可以被操作系統(線程調度器)選擇佔用處理器開始或者繼續其運行。這種一個線程被暫停,即被剝奪處理器的使用權,另外一個線程被選中開始或者繼續運行的過程叫做線程上下文切換
一個線程被剝奪處理器的使用權而被暫停運行就被稱爲切出(Switch Out);一個線程被操作系統選中佔用處理器開始或者繼續其運行就被成爲切入(Switch In)。在切出和切入的時候操作系統需要保存和恢復相應線程的進度信息,這個進度信息就被稱爲上下文(Context)。它一般包括通用寄存器(General Purpose Register)的內容和程序計數器(Program Counter)的內容。

從Java應用的角度看,一個線程的生命週期狀態在RUNNABLE狀態與非RUNNABLE狀態(包括BLOCKED、WAITING和TIMED_WAITING中的任意一個子狀態)之間切換的過程就是一個上下文切換的過程。當一個線程的生命週期狀態由RUNNABLE轉換爲非RUNNABLE時,稱這個線程被暫停。而一個線程的生命週期狀態由非RUNNABLE狀態進入RUNNABLE狀態時,稱爲這個線程被喚醒(Wakeup)。注意,一個線程被喚醒僅代表該線程獲得了一個繼續運行的機會,而並不代表其立刻可以佔用處理器運行

上下文切換的分類及具體原因

按照導致上下文切換的因素劃分:自發性上下文切換(Voluntary Context Switch)和非自發性上下文切換(Involuntary Context Switch)。
自發性上下文切換指線程由於其自身因素導致的切出。從Java平臺的角度看,一個線程在其運行過程中執行下列任意一個方法都會引起自發性上下文切換。

  • Thread.sleep
  • Object.wait()
  • Thread.yield()
  • Thread.join()
  • LockSupport.park()

另外,線程發起I/O操作或者等待其他線程持有的鎖也會導致自發性上下文切換。
非自發性上下文切換指線程由於線程調度器的原因被迫切出。導致非自發性上下文切換的常見因素包括被切出線程的時間片用完或者有一個比被切出線程優先級更高的線程需要被運行。從Java平臺的角度看,Java虛擬機的垃圾回收(Garbage Collect)動作也可能導致非自發性上下文切換。

上下文切換的開銷和測量

從定性的角度看,上下文切換的開銷包括直接開銷間接開銷。其中,直接開銷包括:

  • 操作系統保存和恢復上下文所需的開銷,主要是處理器時間的開銷;
  • 線程調度器進行線程調度的開銷

間接開銷包括:

  • 處理器高速緩存重新加載的開銷;
  • 上下文切換也可能導致整個一級高速緩存中的內存內沖刷(Flush),即一級高速緩存中的內存會被寫入下一級高速緩存(如二級高速緩存)或者主內存(RAM)中。

從定量的角度來看,一次上下文切換的時間消耗是微秒級的。
在Linux平臺下,可以使用Linux內核提供的perf命令來監視Java程序運行中的上下文切換的次數和頻率。在Windows平臺,可以使用Windows自帶的工具perfmon來監視Java程序運行中的上下文切換情況。
多線程編程相比單線程編程意味着更多的上下文切換,因此多線程編程不一定就比單線程編程的計算效率更高。

線程的活性故障

由於資源稀缺性或者程序自身的問題和缺陷導致線程一直處於非RUNNABLE狀態,或者線程雖然處理RUNNABLE狀態但是其要執行的任務卻一直無法進展的線程被稱爲線程活性故障(Liveness Failure)。
常見的活性故障包括以下幾種:

  • 死鎖(Deadlock)
  • 鎖死(Lockout)
  • 活鎖(Livelock):線程可能處於RUNNABLE狀態,但是線程所要執行的任務卻絲毫沒有進展,即線程可能一直在做無用功。
  • 飢餓(Starvation):飢餓就是線程因無法獲取其所需的資源而使得任務執行無法進展的現象。

資源爭用與調度

一次只能夠被一個線程佔用的資源被稱爲排他性(Exclusive)資源。如處理器、數據庫連接、文件等。在一個線程佔用一個排他性資源進行訪問(讀、寫操作)而未釋放其對資源所有權的時候,其他線程試圖訪問該資源的現象被稱爲資源爭用(Resource Contention)。根據數量多少可以分爲高爭用和低爭用。
同一時間內,處於運行狀態(即生命週期狀態爲RUNNABLE的RUNNING子狀態的線程)的線程數量越多,就稱併發的程度越高,簡稱高併發。
雖然高併發增加了爭用的概率,但是高併發並不意味着高爭用。理想狀態是高併發,低爭用。
而多個線程共享同一個資源又會帶來新的問題。即資源的調度問題。獲得資源的獨佔權而又未釋放其獨佔權的線程被稱爲該資源的持有線程。資源調度策略的一個常見特性就是它能否保持公平性。所謂公平性(Fairness)是指資源的申請者(線程)是否按照其申請(請求)資源的順序而被授予資源的獨佔權。如果資源的任何一個先申請者總是能夠比任何一個後申請者先獲得該資源的獨佔權,那麼相應的資源調度策略就是公平的(Fair)。
資源調度的一種常見策略就是排隊。
一般來說,非公平調度策略的吞吐率較高,即單位時間內它可以爲更多的申請者調配資源。公平調度策略的吞吐率較低。非公平調度策略的好處之一是可能減少上下文切換的次數。默認採用非公平調度策略。

發佈了243 篇原創文章 · 獲贊 140 · 訪問量 25萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章