Java併發編程-知識前瞻(第一章)

前言:

Java併發編程學習分享的目標:

  • Java併發編程中常用的工具用途與用法;

  • Java併發編程工具實現原理與設計思路;

  • 併發編程中遇到的常見問題與解決方案;

  • 根據實際情景選擇更合適的工具完成高效的設計方案

學習分享團隊:
學而思培優-運營研發團隊
Java併發編程分享小組:
@沈健 @曹偉偉 @張俊勇 @田新文 @張晨
本章分享人:@張晨

學習分享大綱:

01

初識併發

什麼是併發,什麼是並行? 

用個JVM的例子來講解,在垃圾回收器做併發標記的時候,這個時候JVM不僅可以做垃圾標記,還可以處理程序的一些需求,這個叫併發。在做垃圾回收時,JVM多個線程同時做回收,這叫並行。

02

爲什麼要學習併發編程

直觀原因
1)JD的強制性要求
隨着互聯網行業的飛速發展,併發編程已經成爲非常熱門的領域,也是各大企業服務端崗位招聘的必備技能。

2)從小牛通往大牛的必經之路
架構師是軟件開發團隊中非常重要的角色,成爲一名架構師是許多搞技術人奮鬥的目標,衡量一個架構師的能力指標就是設計出一套解決高併發的系統,由此可見高併發技術的重要性,而併發編程是底層的基礎。無論遊戲還是互聯網行業,無論軟件開發還是大型網站,都對高併發技術人才存在巨大需求,因此,爲了工作爲了提升自己,學習高併發技術刻不容緩。

3)面試過程中極容易踩坑
面試的時候爲了考察對併發編程的掌握情況,經常會考察併發安全相關的知識和線程交互的知識。例如在併發情況下如何實現一個線程安全的單例模式,如何完成兩個線程中的功能交互執行。

以下是使用雙檢索實現一個線程安全的單例懶漢模式,當然也可以使用枚舉或者單例餓漢模式。

    private static volatile  Singleton singleton;    private Singleton(){};    public Singleton getSingleton(){        if(null == singleton){            synchronized(Singleton.class){                if(null == singleton){                    singleton = new Singleton();                }            }        }        return singleton;    }

在這裏第一層空判斷是爲了減少鎖控制的粒度,使用volatile修飾是因爲在jvm中new Singleton()會出現指令重排,volatile避免happens before,避免空指針的問題。從一個線程安全的單例模式可以引申出很多,volatile和synchronized的實現原理,JMM模型,MESI協議,指令重排,關於JMM模型後序會給出更詳細的圖解。

除了線程安全問題,還會考察線程間的交互。 例如使用兩個線程交替打印出A1B2C3…Z26

考察的重點並不是要簡單的實現這個功能,通過此面試題,可以考察知識的整體掌握情況,多種方案實現,可以使用Atomicinteger、ReentrantLock、CountDownLat ch。下圖是使用LockSupport控制兩個線程交替打印的示例,LockSupport內部實現的原理是使用UNSAFE控制一個信號量在0和1之間變動,從而可以控制兩個線程的交替打印。

4)併發在我們工作使用的框架中處處可見,tom cat,netty,jvm,Disruptor

熟悉JAVA併發編程基礎是掌握這些框架底層知識的基石,這裏簡單介紹下高併發框架Disruptor的底層實現原理,做一個勾勒的作用:
Martin Fowler在一篇LMAX文章中介紹,這一個高性能異步處理框架,其單線程一秒的吞吐量可達六百萬

Disruptor核心概念

Disruptor特徵

  • 基於事件驅動

  • 基於"觀察者"模式、"生產者-消費者"模型

  • 可以在無鎖的情況下實現網絡的隊列操作

RingBuffer執行流程

Disruptor底層組件,RingBuffer密切相關的對象:Sequ enceBarrier和Sequencer;

SequenceBarrier是消費者和RingBuffer之間的橋樑。在Disruptor中,消費者直接訪問的是SequenceBarrier,由SequenceBarrier減少RingBuffer的隊列衝突。

SequenceBarrier 通過waitFor方法當消費者速度大於生產者的生產速度時,消費者可通過waitFor方法給予生產者一定的緩衝時間,協調生產者和消費者的速度問題,waitFor執行時機:

Sequencer是生產者和緩衝區RingBuffer之間的橋樑,生產者通過Sequencer向RingBuffer申請數據存放空間,通過WaitStrategy使用publish方法通知消費者,WaitStrategy是消費者沒有數據可以消費時的等待策略。每個生產者或者消費者線程,會先申請可以操作的元素在數組中的位置,申請到之後,直接在該位置寫入或者讀取數據,整個過程通過原子變量CAS,保證操作的線程安全,這就是Disruptor的無鎖設計。

以下是五大常用等待策略:
BlockingWaitStrategy:Disruptor的默認策略是BlockingWaitStrategy。在BlockingWaitStrategy內部是使用鎖和condition來控制線程的喚醒。BlockingWaitStrategy是最低效的策略,但其對CPU的消耗最小並且在各種不同部署環境中能提供更加一致的性能表現。

SleepingWaitStrategy:SleepingWaitStrategy 的性能表現跟 BlockingWaitStrategy 差不多,對 CPU 的消耗也類似,但其對生產者線程的影響最小,通過使用LockSupport.parkNanos(1)來實現循環等待。

YieldingWaitStrategy:YieldingWaitStrategy是可以使用在低延遲系統的策略之一。YieldingWaitStrategy將自旋以等待序列增加到適當的值。在循環體內,將調用Thread.yield()以允許其他排隊的線程運行。在要求極高性能且事件處理線數小於 CPU 邏輯核心數的場景中,推薦使用此策略;例如,CPU開啓超線程的特性。

BusySpinWaitStrategy:性能最好,適合用於低延遲的系統。在要求極高性能且事件處理線程數小於CPU邏輯核心數的場景中,推薦使用此策略;例如,CPU開啓超線程的特性。

目前,包括Apache Storm、Camel、Log4j2在內的很多知名項目都應用了Disruptor以獲取高性能。

5)JUC是併發大神Doug Lea靈魂力作,堪稱典範(第一個主流嘗試,它將線程,鎖和事件之外的抽象層次提升到更平易近人的方式:併發集合, fork/join 等等)

通過併發編程設計思維的學習,發揮使用多線程的優勢

  • 發揮多處理器的強大能力

  • 建模的簡單性

  • 異步事件的簡化處理

  • 響應更靈敏的用戶界面

那麼學不好併發編程基礎會帶來什麼問題呢

1)多線程在日常開發中運用中處處都是,jvm、tomcat、netty,學好java併發編程是更深層次理解和掌握此類工具和框架的前提由於計算機的cpu運算速度和內存io速度有幾個數量級的差距,因此現代計算機都不得不加入一層儘可能接近處理器運算速度的高速緩存來做緩衝:將內存中運算需要使用的數據先複製到緩存中,當運算結束後再同步回內存。如下圖:

因爲jvm要實現跨硬件平臺,因此jvm定義了自己的內存模型,但是因爲jvm的內存模型最終還是要映射到硬件上,因此jvm內存模型幾乎與硬件的模型一樣:

操作系統底層數據結構,每個CPU對應的高速緩存中的數據結構是一個個bucket存儲的鏈表,其中tag代表的是主存中的地址,cache line是偏移量,flag對應的MESI緩存一致性協議中的各個狀態。

MESI緩存一致性狀態分別爲:

M:Modify,代表修改

E:Exclusive,代表獨佔

S:Share,代表共享

I:Invalidate,代表失效

以下是一次cpu0數據寫入的流程:

  • 在CPU0執行一次load,read和write時,在做write之前flag的狀態會是S,然後發出invalidate消息到總線;

  • 其他cpu會監聽總線消息,將各cpu對應的cache entry中的flag狀態由S修改爲I,並且發送invalidate ack給總線

  • cpu0收到所有cpu返回的invalidate ack後,cpu0將flag變爲E,執行數據寫入,狀態修改爲M,類似於一個加鎖過程

考慮到性能問題,這樣寫入修改數據的效率太過漫長,因此引入了寫緩衝器和無效隊列,所有的修改操作會先寫入寫緩衝器,其他cpu接收到消息後會先寫入無效隊列,並返回ack消息,之後再從無效隊列消費消息,採用異步的形式。當然,這樣就會產生有序性問題,例如某些entry中的flag還是S,但實際上應該標識爲I,這樣訪問到的數據就會有問題。運用volitale是爲了解決指令重排帶來的無序性問題,volitale是jvm層面的關鍵字,MESI是cpu層面的,兩者是差了幾個層次的。

2)性能不達標,找不到解決思路。

3)工作中可能會寫出線程不安全的方法
以下是一個多線程打印時間的逐步優化案例

new Thread(new Runnable() {    @Override    public void run() {        System.out.println(new ThreadLocalDemo01().date(10));    }}).start();new Thread(new Runnable() {    @Override    public void run() {        System.out.println(new ThreadLocalDemo01().date(1007));    }}).start();

優化1,多個線程運用線程池複用

for(int i = 0; i < 1000; i++){    int finalI = i;    executorService.submit(new Runnable() {        @Override        public void run() {            System.out.println(new ThreadLocalDemo01().date2(finalI));        }    });}executorService.shutdown();
public String date2(int seconds){        Date date = new Date(1000 * seconds);        String s = null;//        synchronized (ThreadLocalDemo01.class){//            s = simpleDateFormat.format(date);//        }        s = simpleDateFormat.format(date);        return s;}

優化2,線程池結合ThreadLocal​​​​​​​

public String date2(int seconds){    Date date = new Date(1000 * seconds);    SimpleDateFormat simpleDateFormat = ThreadSafeFormatter.dateFormatThreadLocal.get();    return simpleDateFormat.format(date);}

在多線程服用一個SimpleDateFormat時會出現線程安全問題,執行結果會打印出相同的時間,在優化2中使用線程池結合ThreadLocal實現資源隔離,線程安全。

4)許多問題無法正確定位
踩坑:crm仿真定時任務阻塞,無法繼續執行
問題:crm仿真運用schedule配置的定時任務在某個時間節點後的所有定時任務均未執行
原因:定時任務配置導致的問題,@Schedule配置的定時任務如果未配置線程池,在啓動類使用@EnableScheduling啓用定時任務時會默認使用單線程,後端配置了多定時任務,會出現問題.配置了兩定時任務A和B,在A先佔用資源後如果一直未釋放,B會一直處於等待狀態,直到A任務釋放資源後,B開始執行,若要避免多任務執行帶來的問題,需要使用以下方法配置:
​​​​​​​

@Bean public ThreadPoolTaskScheduler taskScheduler(){   ThreadPoolTaskScheduler scheduler = new       ThreadPoolTaskScheduler();   scheduler.setPoolSize(10);   return scheduler; }

crm服務由於定時任務配置的不多,並且在資源足夠的情況下,任務執行速度相對較快,並未設置定時任務的線程池

定時任務里程序方法如何造成線程一直未釋放,導致阻塞。

 

在問題定位時,產生的問題來自CountDownLatch無法歸零,導致整個主線程hang在那裏,無法釋放。

在api中當調用await時候,調用線程處於等待掛起狀態,直至count變成0再繼續,大致原理如下:

因此將目光焦點轉移至await方法,使當前線程在鎖存器倒計數至零之前一直等待,除非線程被中斷或超出了指定的等待時間。如果當前計數爲零,則此方法立刻返回true 值。如果當前計數大於零,則出於線程調度目的,將禁用當前線程,且在發生以下三種情況之一前,該線程將一直處於休眠狀態:由於調用 countDown() 方法,計數到達零;或者其他某個線程中斷當前線程;或者已超出指定的等待時間。

Executors.newFixedThreadPool這是個有固定活動線程數。當提交到池中的任務數大於固定活動線程數時,任務就會放到阻塞隊列中等待。CRM該定時任務裏爲了加快任務處理,運用多線程處理,設置的CountDownLatch的count大於ThreadPoolExecutor的固定活動線程數導致任務一直處於等待狀態,計數無法歸零,導致主線程一直無法釋放,從而導致crm一臺仿真服務的定時任務處於癱瘓狀態。

03

如何學習java併發編程

爲了學習好併發編程基礎,我們需要有一個上帝視角,一個宏觀的概念,然後由點及深,掌握必備的知識點。我們可以從以下兩張思維導圖列舉出來的逐步進行學習。

必備知識點

04

線程

列舉了如此多的案例都是圍繞線程展開的,所以我們需要更深地掌握線程,它的概念,它的原則,它是如何實現交互通信的。

以下的一張圖可以更通俗地解釋進程、線程的區別

進程: 一個進程好比是一個程序,它是 資源分配的最小單位 。同一時刻執行的進程數不會超過核心數。不過如果問單核CPU能否運行多進程?答案又是肯定的。單核CPU也可以運行多進程,只不過不是同時的,而是極快地在進程間來回切換實現的多進程。電腦中有許多進程需要處於「同時」開啓的狀態,而利用CPU在進程間的快速切換,可以實現「同時」運行多個程序。而進程切換則意味着需要保留進程切換前的狀態,以備切換回去的時候能夠繼續接着工作。所以進程擁有自己的地址空間,全局變量,文件描述符,各種硬件等等資源。操作系統通過調度CPU去執行進程的記錄、回覆、切換等等。

線程:線程是獨立運行和獨立調度的基本單位(CPU上真正運行的是線程),線程相當於一個進程中不同的執行路徑。

單線程:單線程就是一個叫做“進程”的房子裏面,只住了你一個人,你可以在這個房子裏面任何時間去做任何的事情。你是看電視、還是玩電腦,全都有你自己說的算。想幹什麼幹什麼,想什麼時間做什麼就什麼時間做什麼。

多線程:但是如果你處在一個“多人”的房子裏面,每個房子裏面都有叫做“線程”的住戶:線程1、線程2、線程3、線程4,情況就不得不發生變化了。

在多線程編程中有”鎖”的概念,在你的房子裏面也有鎖。如果你的老婆在上廁所並鎖上門,她就是在獨享這個“房子(進程)”裏面的公共資源“衛生間”,如果你的家裏只有這一個衛生間,你作爲另外一個線程就只能先等待。

線程最爲重要也是最爲麻煩的就是線程間的交互通信過程,下圖是線程狀態的變化過程:

爲了闡述線程間的通信,簡單模擬一個生產者消費者模型:

生產者​​​​​​​

CarStock carStock;public CarProducter(CarStock carStock){    this.carStock = carStock;}
@Overridepublic void run() {    while (true){        carStock.produceCar();    }}public synchronized void produceCar(){  try {    if(cars < 20){      System.out.println("生產者..." + cars);      Thread.sleep(100);      cars++;      notifyAll();    }else {      wait();    }  } catch (InterruptedException e) {    e.printStackTrace();  }}

消費者

CarStock carStock;public CarConsumer(CarStock carStock){    this.carStock = carStock;}
@Overridepublic void run() {    while (true){        carStock.consumeCar();    }}
public synchronized void consumeCar(){    try {        if(cars > 0){            System.out.println("銷售車..." + cars);            Thread.sleep(100);            cars--;            notifyAll();        }else {            wait();        }    } catch (InterruptedException e) {        e.printStackTrace();    }}

消費過程

通信過程

對於此簡單的生產者消費者模式可以運用隊列、線程池等技術對程序進行改進,運用BolckingQueue隊列共享數據,改進後的消費過程

05

併發編程三大特性

併發編程實現機制大多都是圍繞以下三點:原子性、可見性、有序性

1)原子性問題​​​​​​​

for(int i = 0; i < 20; i++){    Thread thread = new Thread(() -> {        for (int j = 0; j < 10000; j++) {            res++;            normal++;            atomicInteger.incrementAndGet();        }    });    thread.start();}

運行結果:

volatile: 170797
atomicInteger:200000
normal:182406

這就是原子性問題,原子性是指在一個操作中就是cpu不可以在中途暫停然後再調度,既不被中斷操作,要不執行完成,要不就不執行。
如果一個操作是原子性的,那麼多線程併發的情況下,就不會出現變量被修改的情況。

2)可見性問題​​​​​​​

class MyThread extends Thread{    public int index = 0;
    @Override    public void run() {        System.out.println("MyThread Start");        while (true) {            if (index == -1) {                break;            }        }        System.out.println("MyThread End");    }}

main線程將index修改爲-1,myThread線程並不可見,這就是可見性問題導致的線程安全,可見性就是指當一個線程修改了線程共享變量的值,其它線程能夠立即得知這個修改。Java內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方法來實現可見性的,無論是普通變量還是volatile變量都是如此,普通變量與volatile變量的區別是volatile的特殊規則保證了新值能立即同步到主內存,以及每使用前立即從內存刷新。因爲我們可以說volatile保證了線程操作時變量的可見性,而普通變量則不能保證這一點。

3)有序性問題

雙檢索單例懶漢模式

有序性: Java內存模型中的程序天然有序性可以總結爲一句話:如果在本線程內觀察,所有操作都是有序的;如果在一個線程中觀察另一個線程,所有操作都是無序的。前半句是指“線程內表現爲串行語義”,後半句是指“指令重排序”現象和“工作內存中主內存同步延遲”現象。

06

思考題

有時爲了儘快釋放資源,避免無意義的耗費,會令部分功能提前結束,例如許多搶名額問題,這裏出一個思考題供大家參考實現:
題:8人百米賽跑,要求前三名跑到終點後停止運行,設計該問題的實現。

參考資料:
1.億級流量Java高併發與網絡編程實戰
2.LMAX文章(http://ifeve.com/lmax/)

下章預告:Volatile和Syncronize關鍵字

  • Volatile關鍵字

  • Synchronized關鍵字Volatile關鍵字
    Synchronized關鍵字

關於好未來技術更多內容請:

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章