對Java多執行緒的一些理解

撲火的蛾發表於2019-01-19

OS中的程式、執行緒

  • 程式:即處於執行期的程式,且包含其他資源,如開啟的檔案、掛起的訊號、核心內部資料、處理器狀態、核心地址空間、一個或多個執行的執行緒、資料段。
  • 執行緒:程式中的活動物件,核心排程的物件不是程式而是執行緒;傳統Unix系統一個程式只包含一個執行緒。

執行緒在Linux中的實現

從Linux核心的角度來說,並沒有執行緒這個概念。Linux把所有的執行緒都當做程式來實現,核心沒有為執行緒準備特別的排程演算法和特別的資料結構。執行緒僅僅被視為一個與其他程式共享某些資源的程式。所以,在核心看來,它就是一個普通的程式。

在Windows或Solaris等作業系統的實現中,它們都提供了專門支援執行緒的機制(lightweight processes)。

寫時拷貝

傳統的fork()系統呼叫直接把所有資源複製給新建立的程式,效率十分低下,因為拷貝的資料也許並不需要。

Linux的fork()使用寫時拷貝實現。核心此時並不複製整個程式地址空間,而是讓父程式和子程式共享一個拷貝。

只有在需要寫入的時候,資料才會被複制,在此之前,只是以只讀方式共享。這種優化可以避免拷貝大量根本就不會被使用的資料(地址空間常常包含幾十M的資料)。

因此,Linux建立程式和執行緒的區別就是共享的地址空間、檔案系統資源、檔案描述符、訊號處理程式等這些不同。

以下是StackOverflow上的一個答案:

alt text

即,在Linux下,程式使用fork()建立,執行緒使用pthread_create()建立;fork()pthread_create()都是通過clone()函式實現,只是傳遞的引數不同,即共享的資源不同。(Linux是通過NPTL實現POSIX Thread規範,即通過輕量級程式實現POSIX Thread,使之前在Unix上的庫、軟體可以平穩的遷移到Linux上)

Java執行緒如何對映到OS執行緒

JVM在linux平臺上建立執行緒,需要使用pthread 介面。pthread是POSIX標準的一部分它定義了建立和管理執行緒的C語言介面。Linux提供了pthread的實現:

pthread_t tid;
if (pthread_create(&tid, &attr, thread_entry_point, arg_to_entrypoint))
{
      fprintf(stderr, "Error creating thread
");
      return;
}
  • tid是新建立執行緒的ID
  • attr是我們需要設定的執行緒屬性
  • thread_entry_point是會被新建立執行緒呼叫的函式指標
  • arg_to_entrypoint是會被傳遞給thread_entry_point的引數

thread_entry_point所指向的函式就是Thread物件的run方法。

無返回值執行緒和帶返回值的執行緒

  • 無返回值:一種是直接繼承Thread,另一種是實現Runnable介面
  • 帶返回值:通過Callable和Future實現

帶返回值的執行緒是我們在實踐中更常用的。

競態條件

當某個計算的正確性取決於多個執行緒的交替執行時序時,那麼就會發生競態條件。

最常見的競態條件型別就是“先檢查後執行”(Check-Then-Act)操作,即通過一個可能失效的觀測結果來決定下一步的動作。

使用“”先檢查後執行“的一種常見情況就是延遲初始化:

public class LazyInitRace {
    private ExpensiveObject instance = null;
    
    public ExpensiveObject getInstance() {
        if (instance == null) {
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

不要這麼做。

Executor框架

使用裸執行緒的缺點

prod環境中,為每個任務分配一個執行緒的方法存在嚴重的缺陷,尤其是當需要建立大量的執行緒時:

  • 執行緒生命週期的開銷非常高:執行緒的建立與銷燬並不是沒有代價的。
  • 資源消耗:會消耗記憶體和CPU,大量的執行緒競爭CPU資源將產生效能開銷。如果你已經擁有足夠多的執行緒使所有CPU處於忙碌狀態,那麼建立更多的執行緒反而會降低效能。
  • 穩定性:可建立的執行緒的數量上存在限制,包括JVM的啟動引數、作業系統對執行緒的限制,如果超出這些限制,很可能會丟擲OutOfMemoryError異常。

Executor基本原理

Executor基於生產者-消費者模式,提交任務的操作相當於生產者,執行任務的執行緒則相當於消費者。

執行緒池的建構函式如下:

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              RejectedExecutionHandler handler) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), handler);
    }

執行緒池大小

  • corePoolSize:核心執行緒數,當執行緒池的執行緒數小於corePoolSize,直接建立新的執行緒
  • 執行緒數大於corePoolSize但是小於maximumPoolSize:如果任務佇列還未滿, 則會將此任務插入到任務佇列末尾;如果此時任務佇列已滿, 則會建立新的執行緒來執行此任務。
  • 執行緒數等於maximumPoolSize:如果任務佇列還未滿, 則會將此任務插入到任務佇列末尾;如果此時任務佇列已滿, 則會由RejectedExecutionHandler處理。

keep-alive

  • keepAliveTime:當我們的執行緒池中的執行緒數大於corePoolSize時, 如果此時有執行緒處於空閒(Idle)狀態超過指定的時間(keepAliveTime), 那麼執行緒池會將此執行緒銷燬。

工作佇列

工作佇列(WorkQueue)是一個BlockingQueue, 它是用於存放那些已經提交的, 但是還沒有空餘執行緒來執行的任務。

常見的工作佇列有一下幾種:

  • 直接切換(Direct handoffs)
  • 無界佇列(Unbounded queues)
  • 有界佇列(Bounded queues)

在生產環境中,禁止使用無界佇列,因為當佇列中堆積的任務太多時,會消耗大量記憶體,最後OOM;通常都是設定固定大小的有界佇列,當執行緒池已滿,佇列也滿的情況下,直接將新提交的任務拒絕,拋RejectedExecutionException 出來,本質上這是對服務自身的一種保護機制,當服務已經沒有資源來處理新提交的任務,因直接將其拒絕。

Java原生執行緒池在生產環境中的問題

在服務化的背景下,我們的框架一般都會整合全鏈路追蹤的功能,用來串聯整個呼叫鏈,主要是記錄TraceIdSpanIdTraceIdSpanId一般都記錄在ThreadLocal中,對業務方來說是透明的。

當在同一個執行緒中同步RPC呼叫的時候,不會存在問題;但如果我們使用執行緒池做客戶端非同步呼叫時,就會導致Trace資訊的丟失,根本原因是Trace資訊無法從主執行緒的ThreadLocal傳遞到執行緒池的ThreadLocal中。

對於這個痛點,阿里開源的transmittable-thread-local解決了這個問題,實現其實不難,可以閱讀一下原始碼:

https://github.com/alibaba/transmittable-thread-local

效能與伸縮性

對效能的思考

提升效能意味著用更少的資源做更多的事情。“資源”的含義很廣,例如CPU時鐘週期、記憶體、網路頻寬、磁碟空間等其他資源。當操作效能由於某種特定的資源而受到限制時,我們通常將該操作稱為資源密集型的操作,例如,CPU密集型、IO密集型等。

使用多執行緒理論上可以提升服務的整體效能,但與單執行緒相比,使用多執行緒會引入額外的效能開銷。包括:執行緒之間的協調(例如加鎖、觸發訊號以及記憶體同步),增加的上下文切換,執行緒的建立和銷燬,以及執行緒的排程等。如果過度地使用執行緒,其效能可能甚至比實現相同功能的序列程式更差。

從效能監視的角度來看,CPU需要儘可能保持忙碌狀態。如果程式是計算密集型的,那麼可以通過增加處理器來提升效能。但如果程式無法使CPU保持忙碌狀態,那增加更多的處理器也是無濟於事的。

可伸縮性

可伸縮性是指:當增加計算資源時(例如CPU、記憶體、儲存容量、IO頻寬),程式的吞吐量或者處理能力能響應的增加。

我們熟悉的三層模型,即程式中的表現層、業務邏輯層和持久層是彼此獨立,並且可能由不同的服務來處理,這很好地說明了提高伸縮性通常會造成效能損失。如果把表現層、業務邏輯層和持久層都融合到某個單體應用中,在負載不高的時候,其效能肯定要高於將應用程式分為多層的效能。這種單體應用避免了在不同層次之間傳遞任務時存在的網路延遲,減少了很多開銷。

然而、當單體應用達到自身處理能力的極限時,會遇到一個嚴重問題:提升它的處理能力非常困難,即無法水平擴充套件。

Amdahl定律

大多數併發程式都是由一系列的並行工作和序列工作組成的。Amdahl定律描述的是:在增加計算資源的情況下,程式在理論上能夠實現最高加速比,這個值取決於程式中可並行元件序列元件所佔的比重。假定F是必須被序列執行的部分,那麼根據Amdahl定律,在包含N個處理器的機器上,最高的加速比為:

alt text

當N趨近於無窮大時,最大的加速比趨近於1/F。因此,如果程式中有50%的計算需要序列執行,那麼最高的加速比只能是2。

上下文切換

執行緒排程會導致上下文切換,而上下文切換是會產生開銷的。若是CPU密集型程式產生大量的執行緒切換,將會降低系統的吞吐量。

UNIX系統的vmstat命令能夠報告上下文切換次數以及在核心中執行時間的所佔比例等資訊。如果核心佔用率較高(超過10%),那麼通常表示排程活動發生得很頻繁,這很可能是由I/O或者鎖競爭導致的阻塞引起的。

>> vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 3235932 238256 3202776    0    0     0    11    7    4  1  0 99  0  0
 
 cs:每秒上下文切換次數
 sy:核心系統程式執行時間百分比
 us:使用者程式執行時間百分比

以上。

原文連結

https://segmentfault.com/a/11…

相關文章