第二章 多線程編程的目標與挑戰--《java多線程編程實戰指南》

2.1 串行、併發與並行

併發就是在一段時間內以交替的方式去完成多個任務,而並行就是以齊頭並進的方式去完成多個任務。

從硬件的角度來說,在一個處理器一次只能夠運行一個線程的情況下,由於處理器可以使用時間片分配的技術來實現在同一段時間內運行多個線程,因此一個處理器就可以實現併發。而並行則需要靠多個處理器在同一時刻各自運行一個線程來實現。多線程編程的實質就是將任務的處理方式由串行改爲併發,即實現併發化。

2.2 竟態

竟態是指計算的正確性依賴於相對時間順序或者線程的交錯。竟態往往伴隨着讀取髒數據問題,即線程讀取到一個過時的數據、丟失更新問題,即一個線程對數據所做的更新沒有體現在後續其他線程對該數據的讀取上。

竟態的兩種模式:read-modify-write(讀-改-寫)和check-then-act(檢測而後行動)

read-modify-write(讀-改-寫):讀取一個共享變量的值(read),然後根據該值做一些計算(modify),接着更新該共享變量的值(write)。

check-then-act(檢測而後行動):讀取某個共享變量的值,根據變量的值決定下一步的動作是什麼。

package JavaCoreThreadPatten.capter02;

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

/**
 * Request ID生成器:最後3位000-999循環遞增生成
 */
public class RequestIdGenerator {
    private final static RequestIdGenerator INSTANCE = new RequestIdGenerator();
    private final static short SEQ_UPPER_LIMIT = 999;
    private short sequence = 1;

    private RequestIdGenerator(){
    }

    public synchronized short nextSequence(){
        if(sequence >=SEQ_UPPER_LIMIT){
            sequence = 0;
        }else {
            sequence++;
        }
        return sequence;
    }

    public String nextID(){
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyMMddHHmmss");
        String timestamp = simpleDateFormat.format(new Date());
        DecimalFormat decimalFormat = new DecimalFormat("000");
        //生成請求序列號
        short sequenceNo = nextSequence();
        return "0049"+timestamp+decimalFormat.format(sequenceNo);
    }

    public static RequestIdGenerator getInstance(){
        return INSTANCE;
    }

    public static void main(String[] args){
        System.out.println(RequestIdGenerator.getInstance().nextID());
    }
}
package JavaCoreThreadPatten.capter02;

import java.util.Random;
import java.util.concurrent.TimeUnit;

/**
 * 模擬RequestIDGenerator在實際環境多線程使用
 */
public class RaceConditionDemo {
    public static void main(String[] args){
        //客戶端線程數
        Thread[] threads = new Thread[Runtime.getRuntime().availableProcessors()*2];
        for(int i=0;i<threads.length;i++){
            threads[i] = new WorkThread(i,10);
        }
        /**
         * 啓動所有線程
         */
        for(Thread t:threads){
            t.start();
        }
    }

    /**
     * 模擬業務線程
     */
    static class WorkThread extends Thread{
        private final int requestCount;

        public WorkThread(int id,int requestCount) {
            super("worker--"+id);
            this.requestCount = requestCount;
        }

        @Override
        public void run() {
            int i = requestCount;
            String requestID;
            RequestIdGenerator requestIdGenerator = RequestIdGenerator.getInstance();
            while (i-- > 0){
                //生成request ID
                requestID = requestIdGenerator.nextID();
                processRequest(requestID);
            }
        }
        //模擬請求處理
        private void processRequest(String requestId){
            //模擬請求處理耗時
            try {
                TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.printf("%s get requestId:%s %n",Thread.currentThread().getName(),requestId);
        }
    }
}

線程安全問題表現爲3個方面:原子性、可見性和有序性

2.3 原子性

原子性:對於涉及共享變量訪問的操作,若該操作從其執行線程以外的任意線程來看是不可分割的,那麼該操作就是原子操作,該操作具有原子性。原子性是不可分割的,是指訪問某個共享變量的操作從其執行線程以外的任何線程來看,該操作要麼已經執行結束要麼尚未發生,即其他線程不會看到該操作執行了部分的中間效果。

package JavaCoreThreadPatten.capter02;

public class AtomicityExample {

    private ServerInfo serverInfo;
    public void updateHostInfo(String ip,String port){
        synchronized (serverInfo){
            serverInfo.setIp(ip);
            serverInfo.setPort(port);
        }
    }

    public void connectionToHost(){
        connect(serverInfo.getIp(),serverInfo.getPort());
    }

    private void connect(String ip,String port){

    }

    public static class ServerInfo{
        private String ip;
        private String port;

        public String getIp() {
            return ip;
        }

        public void setIp(String ip) {
            this.ip = ip;
        }

        public String getPort() {
            return port;
        }

        public void setPort(String port) {
            this.port = port;
        }
    }
}

原子操作是針對多線程環境下的一個概念,首先原子操作是針對共享變量的操作而言;其次原子操作是從該操作的執行線程以外的線程來描述,也就是說他只有在多線程環境下有意義。

java中有兩種方式來實現原子性。一種使使用鎖,鎖具有排他性,即它能夠保障一個共享變量在任意一個時刻只能夠被一個線程訪問;另一種是利用處理器提供的專門CAS指令,CAS指令實現原子性的方式與鎖實現原子性的方式實質上是相同的,差別在於鎖通常是在軟件這一層實現的,而CAS是直接在硬件這一層實現的。

2.4 可見性

可見性就是指一個線程對共享變量的更新的結果對於讀取相應共享變量的線程而言是否可見的問題。

package JavaCoreThreadPatten;

import java.util.concurrent.TimeUnit;

public class VisibilityDemo {
    public static void main(String[] args){
        TimeConsumingTask timeConsumingTask = new TimeConsumingTask();
        Thread thread = new Thread(timeConsumingTask);
        thread.start();
        try {
            TimeUnit.SECONDS.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        timeConsumingTask.cancel();
    }
}
class TimeConsumingTask implements Runnable{

    private boolean toCancel = false;

    @Override
    public void run() {
        while (!toCancel){
            System.out.println(1);
            if(doExecute()){
                break;
            }
        }
        if(toCancel){
            System.out.println("Task was canceled..");
        }else {
            System.out.println("Task done");
        }
    }

    private boolean doExecute(){
        System.out.println("executing...");
        try {
            TimeUnit.MILLISECONDS.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    public void cancel(){
        toCancel = true;
        System.out.println(this + "cencel() method");
    }
}

可見性問題也與計算機的存儲系統有關:

程序中的變量可能會被分配到寄存器而不是主內存中進行存儲。每個處理器都有其寄存器,而一個處理器無法讀取另外一個處理器上的寄存器中的內容。因此,如果兩個線程分別運行在不同的處理器上,而這兩個線程所共享的變量卻被分配到寄存器上進行存儲,那麼可見性問題就會產生。另外,即便某個共享變量是被分配到內存中進行存儲的,也不能保證該變量的可見性。這是因爲處理器對主內存的訪問並不是直接訪問,而是通過其高速緩存子系統進行的。一個處理器上運行的線程對變量的更新可能只是更新到該處理器的寫緩衝器中,還沒有到達該處理器的高速緩存中,更不用說到主內存中了。而一個處理器的寫緩衝器中的內容無法被另外一個處理器讀取,因此運行在另外一個處理器上的線程無法看到這個線程對其某個共享變量的更新。即便一個處理器上運行的線程對共享變量的更新結果被寫入到該處理器的高速緩存,由於該處理器將這個變量更新的結果通知給其他處理器的時候,其他處理器可能僅僅將這個更新通知的內容存入無效化隊列中,而沒有直接根據更新通知的內容更新其高速緩存的相應內容,這就導致了其他處理器上運行的其他線程後續在讀取相應共享變量時,從相應處理器的高速緩存中讀取到的變量是一個過時的值。

處理器並不是直接與主內存打交道而執行內存的讀、寫操作,而是通過寄存器、高度緩存、寫緩衝器和無效化隊列等部件執行內存的讀、寫操作的。

雖然一個處理器的高速緩存中的內容不能被另外一個處理器直接讀取,但是一個處理器可以通過緩存一致性協議來讀取其他處理器的高速緩存中的數據,並將讀到的數據更新到該處理器的高速緩存中。這種一個處理器從其自身處理器緩存以外的其他存儲部件中讀取到數據並將結果反映(更新)到該處理器的高速緩存的過程,我們稱之爲緩存同步。相應的,我們稱這些存儲部件的內容是可同步的,這些存儲部件包括處理器的高速緩存、主內存。緩存同步使得一個處理器(上運行的線程)可以讀取到另外一個處理器(上運行的線程)對共享變量所做的更新,即保障了可見性。因此,爲了保障可見性,我們必須使一個處理器對共享變量所做的更新最終被寫入該處理器的高速緩存或者主內存中(而不是始終停留在其寫緩衝器中),這個過程被稱爲沖刷處理器緩存。並且,一個處理器在讀取共享變量的時候,如果其他處理器在此之前已經更新了該變量,那麼該處理器必須從其他處理器的高速緩存或者主內存中對相應的變量進行緩存同步。這個過程被稱爲刷新處理器緩存。因此,可見性的保障是通過使更新共享變量的處理器執行沖刷處理器緩存的動作,並使讀取共享變量的處理器執行刷新處理器緩存的動作來實現的。

2.5 有序性

重排序:編譯器可能改變兩個操作的先後順序;處理器可能不是完全依照程序的目標代碼所指定的順序執行指令;一個處理器上執行的多個操作,從其他處理器的角度來看其順序可能與目標代碼所指定的順序不一致。

重排序分爲指令重排序和存儲子系統重排序

指令重排序

編譯器處於性能的考慮,在其認爲不影響程序(單線程程序)正確性的情況下可能會對源代碼順序進行調整,從而造成程序順序與相應的源代碼順序不一致。java靜態編譯器(javac)基本上不會執行指令重排序,而JIT編譯器則可能執行指令重排序。

現代處理器爲了提高指令執行效率,往往不是按照程序順序注意執行指令的,而是動態調整指令的順序,做到哪條指令就緒就先執行哪條指令,這就是處理器的亂序執行。在亂序執行的處理器中,指令是一條一條按照程序順序被處理器讀取的,然後這些指令中哪條就緒了哪條就會先被執行,而不是完全按照程序順序執行。這些指令執行的結果會被先存入重排序緩衝器,而不是直接被寫入寄存器或者主內存。重排序緩衝器會將各個指令的執行結果按照相應的指令被處理器讀取的順序提交到寄存器或者內存中去。在亂序執行的情況下,儘管指令的執行順序可能沒有完全按照程序順序,但是由於指令的執行結果的提交仍然是按照程序的順序來的,因此處理器的指令重排序並不會對單線程程序的正確性產生影響。

存儲子系統重排序

主內存與處理器之間有高速緩衝器和寫緩衝器,處理器通過高速緩衝器訪問主內存,使用寫緩衝器提高寫主內存的效率,寫緩衝器和高速緩衝器統稱爲存儲子系統。

即使在處理器嚴格按照程序順序執行兩個內存訪問操作的情況下,在存儲子系統的作用下其他處理器對這兩個操作的感知順序仍然可能與程序順序不一致,這種線程就是存儲子系統重排序。

指令重排序重排序對象是指令,實實在在地對指令的順序進行調整,而存儲子系統重排序是一種現象而不是一種動作,它並沒有真正對指令執行順序進行調整,而只是造成了一種指令的執行順序像是被調整過的一樣,其重排序的對象是內存操作的結果。

保證內存訪問的順序性

編譯器、處理器都會遵守一定的規則,從而給單線程程序創造一種假象--指令是按照源代碼順序執行的,這種假象就是貌似串行語義

有序性的保障可以理解爲通過某些措施使得貌似串行語義擴展到多線程程序,即重排序要麼不發生,要麼即使發生了也不會影響多線程程序的正確性。有序性的保障可以理解爲從邏輯上部分禁止重排序,禁止重排序是通過調用處理器提供相應的指令(內存屏障)來實現的。

2.6 上下文切換

上下文切換在某種程度上可以看做多個線程共享一個處理器的產物。

單處理器上的多線程是通過時間片分配的方式實現的。時間片決定了一個線程可以連續佔用處理器運行的時間長度。當一個進程中的一個線程由於其時間片用完或者其自身的原因被迫或者主動暫停其運行時,另一個線程可以被操作系統選中佔用處理器開始或者繼續運行。這種一個線程被暫停,即被剝奪處理器的使用權,另一個線程被選中開始或者繼續運行的過程就叫做線程上下文切換。

連續運行的線程實際上是以斷斷續續運行的方式使其任務進展的,意味着在切出和切入的時候操作系統需要保存和恢復相應線程的進度信息,即切入和切出那一刻相應線程所執行的任務進行到什麼程度了,這個進度信息被稱爲上下文。它一般包括通用寄存器的內容和程序計數器的內容。在切出時,操作系統需要將上下文保存到內存中,以便被切出的線程稍後佔用處理器繼續其運行時能夠在此基礎上進展。在切入時,操作系統需要從內存中加載被選中線程的上下文,以在之前運行的基礎上繼續進展。

java應用中線程從RUNNABLE狀態轉爲非RUNNABLE狀態稱爲被暫停,反之被喚醒。

上下文切換分爲自發性上下文切換和非自發性上下文切換:

自發性上下文切換指線程由於其自身因素導致的切出或者發起I/O操作或者等待其他線程持有的鎖,如

非自發性上下文切換指線程由於線程調度器的原因被迫切出,如java虛擬機在進行Full GC時會stop the world,暫停所有的其他線程

上下文切換開銷包括直接開銷和間接開銷:

直接開銷:1.操作系統保存和恢復上下文所需的開銷;2.線程調度器進行線程調度的開銷;

間接開銷:1.處理器高速緩存重新加載的開銷;上下文切換也可能導致整個一級高速緩存中的內容被沖刷,即一級高速緩存中的內容會被寫入下一級高速緩存或者主內存中。

多線程編程中使用的線程數量越多,程序的計算效率可能反而越低。

2.7 線程的活性故障

  • 死鎖
  • 鎖死
  • 活鎖
  • 飢餓

2.8 資源爭用與調度

資源的調度分爲公平和不公平兩種:公平調度簡單來說就是按照順序調度,優點是不會出現飢餓現象;非公平調度的優點就是吞吐率較高。

非公平調度吞吐率高的原因是資源的持有線程釋放該資源的時候等待隊列中的一個線程會被喚醒,而該線程從被喚醒到其繼續運行可能需要一段時間。在該時間內,新來的活躍線程可以先被授予該資源的獨佔權。如果這個新來的線程佔用該資源的時間不長,那麼它完全有可能在被喚醒的線程繼續其運行前釋放相應的資源,從而不影響該被喚醒的線程申請資源,這種情況下,非公平調度策略帶來一個好處--可能減少上下文切換的次數。

非公平調度策略是我們多數情況下的首選資源調度策略。其優點是吞吐率較大;缺點是自願申請者申請資源所需的時間偏差可能較大,並可能導致飢餓現象。公平調度策略適合在資源的持有線程佔用資源的時間相對長或資源的平均申請時間間隔相對長的情況下,或者對資源申請所需的時間偏差有所要求的情況下使用。其優點是線程申請資源所需的時間偏差較小,並且不會導致飢餓現象;缺點是吞吐率小。

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