0
  • 聊天消息
  • 系統(tǒng)消息
  • 評論與回復
登錄后你可以
  • 下載海量資料
  • 學習在線課程
  • 觀看技術(shù)視頻
  • 寫文章/發(fā)帖/加入社區(qū)
會員中心
創(chuàng)作中心

完善資料讓更多小伙伴認識你,還能領(lǐng)取20積分哦,立即完善>

3天內(nèi)不再提示

關(guān)于并發(fā)編程與線程安全的思考與實踐

OSC開源社區(qū) ? 來源:OSCHINA 社區(qū) ? 作者:OSCHINA 社區(qū) ? 2023-05-11 10:04 ? 次閱讀

來源| OSCHINA 社區(qū)

作者 | 京東云開發(fā)者-京東健康 張娜

一、并發(fā)編程的意義與挑戰(zhàn)

并發(fā)編程的意義是充分的利用處理器的每一個核,以達到最高的處理性能,可以讓程序運行的更快。而處理器也為了提高計算速率,作出了一系列優(yōu)化,比如:

1、硬件升級:為平衡 CPU 內(nèi)高速存儲器和內(nèi)存之間數(shù)量級的速率差,提升整體性能,引入了多級高速緩存的傳統(tǒng)硬件內(nèi)存架構(gòu)來解決,帶來的問題是,數(shù)據(jù)同時存在于高速緩存和主內(nèi)存中,需要解決緩存一致性問題。

2、處理器優(yōu)化:主要包含,編譯器重排序、指令級重排序、內(nèi)存系統(tǒng)重排序。通過單線程語義、指令級并行重疊執(zhí)行、緩存區(qū)加載存儲 3 種級別的重排序,減少執(zhí)行指令,從而提高整體運行速度。帶來的問題是,多線程環(huán)境里,編譯器和 CPU 指令無法識別多個線程之間存在的數(shù)據(jù)依賴性,影響程序執(zhí)行結(jié)果。

并發(fā)編程的好處是巨大的,然而要編寫一個線程安全并且執(zhí)行高效的代碼,需要管理可變共享狀態(tài)的操作訪問,考慮內(nèi)存一致性、處理器優(yōu)化、指令重排序問題。比如我們使用多線程對同一個對象的值進行操作時會出現(xiàn)值被更改、值不同步的情況,得到的結(jié)果和理論值可能會天差地別,此時該對象就不是線程安全的。而當多個線程訪問某個數(shù)據(jù)時,不管運行時環(huán)境采用何種調(diào)度方式或者這些線程如何交替執(zhí)行,這個計算邏輯始終都表現(xiàn)出正確的行為,那么稱這個對象是線程安全的。因此如何在并發(fā)編程中保證線程安全是一個容易忽略的問題,也是一個不小的挑戰(zhàn)。

所以,為什么會有線程安全的問題,首先要明白兩個關(guān)鍵問題:

1、線程之間是如何通信的,即線程之間以何種機制來交換信息

2、線程之間是如何同步的,即程序如何控制不同線程間的發(fā)生順序。

二、Java 并發(fā)編程

Java 并發(fā)采用了共享內(nèi)存模型,Java 線程之間的通信總是隱式進行的,整個通信過程對程序員完全透明。

2.1 Java 內(nèi)存模型

為了平衡程序員對內(nèi)存可見性盡可能高(對編譯器和處理的約束就多)和提高計算性能(盡可能少約束編譯器處理器)之間的關(guān)系,JAVA 定義了Java 內(nèi)存模型(Java Memory Model,JMM),約定只要不改變程序執(zhí)行結(jié)果,編譯器和處理器怎么優(yōu)化都行。所以,JMM 主要解決的問題是,通過制定線程間通信規(guī)范,提供內(nèi)存可見性保證。

JMM 結(jié)構(gòu)如下圖所示:
bf49d276-ef56-11ed-90ce-dac502259ad0.png

以此看來,線程內(nèi)創(chuàng)建的局部變量、方法定義參數(shù)等只在線程內(nèi)使用不會有并發(fā)問題,對于共享變量,JMM 規(guī)定了一個線程如何和何時可以看到由其他線程修改過后的共享變量的值,以及在必須時如何同步的訪問共享變量。

為控制工作內(nèi)存和主內(nèi)存的交互,定義了以下規(guī)范:

?所有的變量都存儲在主內(nèi)存 (Main Memory) 中。

?每個線程都有一個私有的本地內(nèi)存 (Local Memory),本地內(nèi)存中存儲了該線程以讀 / 寫共享變量的拷貝副本。

?線程對變量的所有操作都必須在本地內(nèi)存中進行,而不能直接讀寫主內(nèi)存。
?不同的線程之間無法直接訪問對方本地內(nèi)存中的變量。

具體實現(xiàn)上定義了八種操作:

1.lock:作用于主內(nèi)存,把變量標識為線程獨占狀態(tài)。

2.unlock:作用于主內(nèi)存,解除獨占狀態(tài)。

3.read:作用主內(nèi)存,把一個變量的值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存。

4.load:作用于工作內(nèi)存,把 read 操作傳過來的變量值放入工作內(nèi)存的變量副本中。

5.use:作用工作內(nèi)存,把工作內(nèi)存當中的一個變量值傳給執(zhí)行引擎。

6.assign:作用工作內(nèi)存,把一個從執(zhí)行引擎接收到的值賦值給工作內(nèi)存的變量。

7.store:作用于工作內(nèi)存的變量,把工作內(nèi)存的一個變量的值傳送到主內(nèi)存中。

8.write:作用于主內(nèi)存的變量,把 store 操作傳來的變量的值放入主內(nèi)存的變量中。

這些操作都滿足以下原則:

?不允許 read 和 load、store 和 write 操作之一單獨出現(xiàn)。

?對一個變量執(zhí)行 unlock 操作之前,必須先把此變量同步到主內(nèi)存中(執(zhí)行 store 和 write 操作)。

2.2 Java 中的并發(fā)關(guān)鍵字

Java 基于以上規(guī)則提供了 volatile、synchronized 等關(guān)鍵字來保證線程安全,基本原理是從限制處理器優(yōu)化和使用內(nèi)存屏障兩方面解決并發(fā)問題。如果是變量級別,使用 volatile 聲明任何類型變量,同基本數(shù)據(jù)類型變量、引用類型變量一樣具備原子性;如果應(yīng)用場景需要一個更大范圍的原子性保證,需要使用同步塊技術(shù)。Java 內(nèi)存模型提供了 lock 和 unlock 操作來滿足這種需求。虛擬機提供了字節(jié)碼指令 monitorenter 和 monitorexist 來隱式地使用這兩個操作,這兩個字節(jié)碼指令反映到 Java 代碼中就是同步塊 - synchronized 關(guān)鍵字。

這兩個字的作用:volatile 僅保證對單個 volatile 變量的讀 / 寫具有原子性,而鎖的互斥執(zhí)行的特性可以確保整個臨界區(qū)代碼的執(zhí)行具有原子性。在功能上,鎖比 volatile 更強大,在可伸縮性和執(zhí)行性能上,volatile 更有優(yōu)勢。

2.3 Java 中的并發(fā)容器與工具類

2.3.1 CopyOnWriteArrayList

CopyOnWriteArrayList 在操作元素時會加可重入鎖,一次來保證寫操作是線程安全的,但是每次添加刪除元素就需要復制一份新數(shù)組,對空間有較大的浪費。

publicEget(int index){
        returnget(getArray(), index);
    }

    publicbooleanadd(E e){
        finalReentrantLock lock =this.lock;
        lock.lock();
        try{
            Object[] elements =getArray();
            int len = elements.length;
            Object[] newElements =Arrays.copyOf(elements, len +1);
            newElements[len]= e;
            setArray(newElements);
            returntrue;
        }finally{
            lock.unlock();
        }
    }


2.3.2 Collections.synchronizedList(new ArrayList<>());

這種方式是在 List 的操作外包加了一層 synchronize 同步控制。需要注意的是在遍歷 List 是還得再手動做整體的同步控制。

publicvoidadd(int index,E element){
        // SynchronizedList 就是在 List的操作外包加了一層synchronize同步控制
        synchronized(mutex){list.add(index, element);}
    }
    publicEremove(int index){
        synchronized(mutex){return list.remove(index);}
    }


2.3.3 ConcurrentLinkedQueue

通過循環(huán) CAS 操作非阻塞的給隊列添加節(jié)點,

publicbooleanoffer(E e){
        checkNotNull(e);
        finalNode newNode =newNode(e);

        for(Node t = tail, p = t;;){
            Node q = p.next;
            if(q ==null){
                // p是尾節(jié)點,CAS 將p的next指向newNode.
                if(p.casNext(null, newNode)){
                    if(p != t) 
                        //tail指向真正尾節(jié)點
                        casTail(t, newNode);
                    returntrue;
                }
            }
            elseif(p == q)
                // 說明p節(jié)點和p的next節(jié)點都等于空,表示這個隊列剛初始化,正準備添加節(jié)點,所以返回head節(jié)點
                p =(t !=(t = tail))? t : head;
            else
                // 向后查找尾節(jié)點
                p =(p != t && t !=(t = tail))? t : q;
        }
    }

三、線上案例

3.1 問題發(fā)現(xiàn)

在互聯(lián)網(wǎng)醫(yī)院醫(yī)生端,醫(yī)生打開問診 IM 聊天頁,需要加載幾十個功能按鈕。在 2022 年 12 月抗疫期間,QPS 全天都很高,高峰時是平日的 12 倍,偶現(xiàn)報警提示按鈕顯示不全,問題出現(xiàn)概率大概在百萬分之一。

3.2 排查問題的詳細過程

醫(yī)生問診 IM 頁面的加載屬于業(yè)務(wù)黃金流程,上面的每一個按鈕就是一個業(yè)務(wù)線的入口,所以處在核心邏輯的上的報警均使用自定義報警,該類報警不設(shè)置收斂,無論何種異常包括按鈕個數(shù)異常就會立即報警。

1. 根據(jù)報警信息,開始排查,卻發(fā)現(xiàn)以下問題:

(1)沒有異常日志:順著異常日志的 logId 排查,過程中竟然沒有異常日志,按鈕莫名其妙的變少了。

(2)不能復現(xiàn):在預發(fā)環(huán)境,使用相同入?yún)ⅲ?a target="_blank">接口正常返回,無法復現(xiàn)。

2. 代碼分析,縮小異常范圍:

醫(yī)生問診 IM 按鈕處理分組進行:

// 多個線程結(jié)果集合
    List multiButtonList =newArrayList<>();
// 多線程并行處理
    Future multiButtonFuture = joyThreadPoolTaskExecutor.submit(()->{
        List multiButtonListTemp =newArrayList<>();
        buttonTypes.forEach(buttonType ->{
            multiButtonListTemp.add(appButtonInfoMap.get(buttonType));
        });
        multiButtonList.addAll(multiButtonListTemp);
        return multiButtonListTemp;
    });
3. 增加日志線上觀察

由于并發(fā)場景容易引發(fā)子線程失敗的情況,對各子線程分支增加必要節(jié)點日志上線后觀察:

(1)發(fā)生異常的請求處理過程中,所有子線程正常處理完成

(2)按鈕缺少個數(shù)隨機等于子線程中處理的按鈕個數(shù)

(3)初步判斷是 ArrayList 并發(fā) addAll 操作異常

4. 模擬復現(xiàn)

使用 ArrayList 源碼模擬復現(xiàn)問題:

(1)ArrayList 源碼分析:
     publicbooleanaddAll(Collection  c){
         Object[] a = c.toArray();
         int numNew = a.length;
         ensureCapacityInternal(size + numNew);// Increments modCount
 
         //以當前size為起點,向數(shù)組中追加本次新增對象
         System.arraycopy(a,0, elementData, size, numNew);
 
         //更新全局變量size的值,和上一步是非原子操作,引發(fā)并發(fā)問題的根源
         size += numNew;
         return numNew !=0;
     }
 
     privatevoidensureCapacityInternal(int minCapacity){
         if(elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA){
             minCapacity =Math.max(DEFAULT_CAPACITY, minCapacity);
         }
 
         ensureExplicitCapacity(minCapacity);
     }
 
     privatevoidensureExplicitCapacity(int minCapacity){
         modCount++;
 
         // overflow-conscious code
         if(minCapacity - elementData.length >0)
             grow(minCapacity);
     }
 
     privatevoidgrow(int minCapacity){
         // overflow-conscious code
         int oldCapacity = elementData.length;
         int newCapacity = oldCapacity +(oldCapacity >>1);
         if(newCapacity - minCapacity <0)
             newCapacity = minCapacity;
         if(newCapacity - MAX_ARRAY_SIZE >0)
             newCapacity =hugeCapacity(minCapacity);
         // minCapacity is usually close to size, so this is a win:
         elementData =Arrays.copyOf(elementData, newCapacity);
     }
 
(2) 理論分析在 ArrayList 的 add 操作中,變更 size 和增加數(shù)據(jù)操作,不是原子操作。bf6fcab2-ef56-11ed-90ce-dac502259ad0.png


(3)問題復現(xiàn)復制源碼創(chuàng)建自定義類,為方便復現(xiàn)并發(fā)問題,增加停頓
publicbooleanaddAll(Collection  c){
         Object[] a = c.toArray();
         int numNew = a.length;
         //第1次停頓,獲取當前size
         try{
             Thread.sleep(1000*timeout1);
         }catch(InterruptedException e){
             e.printStackTrace();
         }
         ensureCapacityInternal(size + numNew);// Increments modCount
 
         //第2次停頓,等待copy
         try{
             Thread.sleep(1000*timeout2);
         }catch(InterruptedException e){
             e.printStackTrace();
         }
         System.arraycopy(a,0, elementData, size, numNew);
 
         //第3次停頓,等待size+=
         try{
             Thread.sleep(1000*timeout3);
         }catch(InterruptedException e){
             e.printStackTrace();
         }
         size += numNew;
         return numNew !=0;
     }
bf87207c-ef56-11ed-90ce-dac502259ad0.png

3.3 解決問題

使用線程安全工具 Collections.synchronizedList 創(chuàng)建 ArrayList :

List multiButtonList =Collections.synchronizedList(newArrayList<>());
上線觀察后正常。

3.4 總結(jié)反思

使用多線程處理問題已經(jīng)變得很普遍,但是對于多線程共同操作的對象必須使用線程安全的類。

另外,還要搞清楚幾個靈魂問題:

(1)JMM 的靈魂:Happens-before 原則

(2)并發(fā)工具類的靈魂:volatile 變量的讀 / 寫 和 CAS

審核編輯:湯梓紅

聲明:本文內(nèi)容及配圖由入駐作者撰寫或者入駐合作網(wǎng)站授權(quán)轉(zhuǎn)載。文章觀點僅代表作者本人,不代表電子發(fā)燒友網(wǎng)立場。文章及其配圖僅供工程師學習之用,如有內(nèi)容侵權(quán)或者其他違規(guī)問題,請聯(lián)系本站處理。 舉報投訴
  • 處理器
    +關(guān)注

    關(guān)注

    68

    文章

    18927

    瀏覽量

    227248
  • cpu
    cpu
    +關(guān)注

    關(guān)注

    68

    文章

    10702

    瀏覽量

    209401
  • 編程
    +關(guān)注

    關(guān)注

    88

    文章

    3521

    瀏覽量

    93276
  • 編譯器
    +關(guān)注

    關(guān)注

    1

    文章

    1602

    瀏覽量

    48896
  • 線程安全
    +關(guān)注

    關(guān)注

    0

    文章

    13

    瀏覽量

    2452

原文標題:關(guān)于并發(fā)編程與線程安全的思考與實踐

文章出處:【微信號:OSC開源社區(qū),微信公眾號:OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。

收藏 人收藏

    評論

    相關(guān)推薦

    Rust的多線程編程概念和使用方法

    和字段、常見用法以及多線程的一些實踐經(jīng)驗。由淺入深帶你零基礎(chǔ)玩轉(zhuǎn)Rust的多線程編程。 線程的基本概念和使用方法 Thread是Rust中
    的頭像 發(fā)表于 09-20 11:15 ?824次閱讀

    如何利用多線程去構(gòu)建一種TCP并發(fā)服務(wù)器

    一、實驗?zāi)康暮鸵?了解TCP/IP協(xié)議2掌握Socket編程,熟悉基于TCP和UDP的傳輸模型3掌握多線程編程4掌握基于TCP的并發(fā)服務(wù)器設(shè)計二、實驗內(nèi)容和原理實驗內(nèi)容:編寫C程序,
    發(fā)表于 12-22 08:03

    移動應(yīng)用高級語言開發(fā)——并發(fā)探索

    、精準內(nèi)存屏障等手段可以實現(xiàn)性能優(yōu)秀的多線程程序,但也存在一定的問題:線程和鎖方案的優(yōu)化依賴軟件工程有良好的并發(fā)實踐規(guī)范和資深并發(fā)程序開發(fā)者
    發(fā)表于 08-28 17:08

    HarmonyOS使用多線程并發(fā)能力開發(fā)

    一、多線程并發(fā)概述 1、簡介 并發(fā)模型是用來實現(xiàn)不同應(yīng)用場景中并發(fā)任務(wù)的編程模型,常見的并發(fā)模型
    發(fā)表于 09-25 15:23

    關(guān)于小流域防災(zāi)預警體系建設(shè)的實踐思考

    關(guān)于小流域防災(zāi)預警體系建設(shè)的實踐思考概述: 小流域是防臺減災(zāi)的薄弱環(huán)節(jié). 臨海小流域溪壩損毀占整個水利損失的一大部分, 成為整個防洪體系中的最薄弱
    發(fā)表于 04-21 16:16 ?20次下載

    VC-MFC多線程編程詳解

    VC編程關(guān)于 MFC多線程編程的詳解文檔
    發(fā)表于 09-01 15:01 ?0次下載

    關(guān)于Linux下多線程編程技術(shù)學習總結(jié)

    Linux下多線程編程技術(shù) 作為一個IT人員,不斷的學習和總結(jié)是我們這個職業(yè)習慣,所以我會將每個階段的學習都會通過一點的總結(jié)來記錄和檢測自己的學習效果,今天為大家總結(jié)了關(guān)于Linux下多線程
    發(fā)表于 04-22 03:12 ?2141次閱讀
    <b class='flag-5'>關(guān)于</b>Linux下多<b class='flag-5'>線程</b><b class='flag-5'>編程</b>技術(shù)學習總結(jié)

    七種常見的并發(fā)編程模型簡介

    1. 線程與鎖 線程與鎖模型有很多眾所周知的不足,但仍是其他模型的技術(shù)基礎(chǔ),也是很多并發(fā)軟件開發(fā)的首選。 2. 函數(shù)式編程 函數(shù)式編程日漸重
    的頭像 發(fā)表于 03-15 17:21 ?4568次閱讀

    JAVA并發(fā)編程實踐

    JAVA并發(fā)編程實踐資料免費下載。
    發(fā)表于 06-01 15:31 ?15次下載

    關(guān)于Actor并發(fā)模型的解析

    并發(fā)模型是用來實現(xiàn)不同應(yīng)用場景中并發(fā)任務(wù)的編程模型,通過合理地使用多線程,可以縮減應(yīng)用程序的開發(fā)和維護成本,同時還能更好地提升應(yīng)用程序在多核設(shè)備中的運行性能。隨著IoT時代下應(yīng)用場景的
    的頭像 發(fā)表于 07-18 09:23 ?1868次閱讀

    什么是線程安全?如何理解線程安全

    在多線程編程中,線程安全是必須要考慮的因素。
    的頭像 發(fā)表于 05-30 14:33 ?1847次閱讀
    什么是<b class='flag-5'>線程</b><b class='flag-5'>安全</b>?如何理解<b class='flag-5'>線程</b><b class='flag-5'>安全</b>?

    線程池的兩個思考

    今天還是說一下線程池的兩個思考。 池子 我們常用的線程池, JDK的ThreadPoolExecutor. CompletableFutures 默認使用了
    的頭像 發(fā)表于 09-30 11:21 ?2938次閱讀
    <b class='flag-5'>線程</b>池的兩個<b class='flag-5'>思考</b>

    線程安全怎么辦

    線程安全一直是多線程開發(fā)中需要注意的地方,可以說,并發(fā)安全保證了所有的數(shù)據(jù)都安全。 1
    的頭像 發(fā)表于 10-10 15:00 ?299次閱讀
    <b class='flag-5'>線程</b><b class='flag-5'>安全</b>怎么辦

    如何知道你的代碼是否線程安全

    并發(fā)編程時,如果多個線程訪問同一資源,我們需要保證訪問的時候不會產(chǎn)生沖突,數(shù)據(jù)修改不會發(fā)生錯誤,這就是我們常說的 線程安全 。 那什么情況
    的頭像 發(fā)表于 11-01 11:42 ?556次閱讀
    如何知道你的代碼是否<b class='flag-5'>線程</b><b class='flag-5'>安全</b>

    mfc多線程編程實例

    (圖形用戶界面)應(yīng)用程序的開發(fā)。在這篇文章中,我們將重點介紹MFC中的多線程編程。 多線程編程在軟件開發(fā)中非常重要,它可以實現(xiàn)程序的并發(fā)執(zhí)行
    的頭像 發(fā)表于 12-01 14:29 ?1151次閱讀