Java 線程安全問題的本質

2021-02-25 java1234

點擊上方藍色字體,選擇「標星公眾號」

優質文章,第一時間送達

  作者 |  Arnold-zhao

來源 |  urlify.cn/ryQFJn

66套java從入門到精通實戰課程分享

出現線程安全的問題本質是因為:

主內存和工作內存數據不一致性以及編譯器重排序導致。

所以理解上述兩個問題的核心,對認知多線程的問題則具有很高的意義;

簡單理解CPU

CPU除了控制器、運算器等器件還有一個重要的部件就是寄存器。其中寄存器的作用就是進行數據的臨時存儲。

CPU的運算速度是非常快的,為了性能CPU在內部開闢一小塊臨時存儲區域,並在進行運算時先將數據從內存複製到這一小塊臨時存儲區域中,運算時就在這一小快臨時存儲區域內進行。我們稱這一小塊臨時存儲區域為寄存器。

CPU讀取指令是往內存裡面去讀取的,讀一條指令放到CPU中,CPU去執行,對內存的讀取速度比較慢,所以從內存讀取的速度去決定了這個CPU的執行速度的。所以無論我們的CPU怎麼去升級,但是如果這方面速度沒有解決的話,其的性能也不會得到多大的提升。

為了彌補這個缺陷,所以添加了高速緩存的機制,如ARM A11的處理器,它的1級緩存中的容量是64KB,2級緩存中的容量是8M,
通過增加cpu高速緩存的機制,以此彌補伺服器內存讀寫速度的效率問題;

JVM虛擬機類比於作業系統

JVM虛擬計算機平臺就類似於一個作業系統的角色,所以在具體實現上JVM虛擬機也的確是借鑑了很多作業系統的特點;

JAVA中線程的工作空間(working memory)就是CPU的寄存器和高速緩存的抽象描述,cpu在計算的時候,並不總是從內存讀取數據,它的數據讀取順序優先級 是:寄存器-高速緩存-內存;
而在JAVA的內存模型中也是同等的,Java內存模型中規定了所有的變量都存儲在主內存中,每條線程還有自己的工作內存(類似於CPU的高速緩存),線程的工作內存中保存了該線程使用到的變量到主內存副本拷貝,線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,而不能直接讀寫主內存中的變量,操作完成後再將變量寫回主內存。不同線程之間無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要在主內存來完成。基本關係如下圖:

注意:這裡的Java內存模型,主內存、工作內存與Java內存區域模型的Java堆、棧、方法區不是同一層次內存劃分,這兩者基本上沒有關係。

重排序

在執行程序時,為了提高性能,編譯器和處理器常常會對指令進行重排序。一般重排序可以分為如下三種:

舉例如下:

public class Singleton {
    public static  Singleton singleton;

    /**
     * 構造函數私有,禁止外部實例化
     */
    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

如上,一個簡單的單例模式,按照對象的構造過程,實例化一個對象1、可以分為三個步驟(指令):
1、 分配內存空間。
2、 初始化對象。
3、 將內存空間的地址賦值給對應的引用。
但是由於作業系統可以對指令進行重排序,所以上面的過程也可能變為如下的過程:
1、 分配內存空間。
2、 將內存空間的地址賦值給對應的引用。
3、 初始化對象 。

所以,如果出現並發訪問getInstance()方法時,則可能會出現,線程二判斷singleton是否為空,此時由於當前該singleton已經分配了內存地址,但其實並沒有初始化對象,則會導致return 一個未初始化的對象引用暴露出來,以此可能會出現一些不可預料的代碼異常;

當然,指令重排序的問題並非每次都會進行,在某些特殊的場景下,編譯器和處理器是不會進行重排序的,但上述的舉例場景則是大概率會出現指令重排序問題(關於指令重排序的概念後續給出詳細的地址)

原創聲明:作者:Arnold.zhao 博客園地址:https://www.cnblogs.com/zh94

匯總

所以,如上可知,多線程在執行過程中,數據的不可見性,原子性,以及重排序所引起的指令有序性 三個問題基本是多線程並發問題的三個重要特性,也就是我們常說的:

並發的三大特性:原子性,有序性,可見性;

原子性:代碼操作是否是原子操作(如:i++ 看似一個代碼片段,實際的執行中將會分為三步執行,則必然是非原子化的操作,在多線程的場景中則會出現異常)
有序性:CPU執行代碼指令時的有序性;
可見性:由於工作線程的內存與主內存的數據不同步,而導致的數據可見性問題;

一些解釋

但是,問題就真的有那麼複雜嗎?如果按照上面所說的問題,i++是非原子操作,就會出現並發異常的問題,new Object() 就會出現重排序的並發問題,那麼Java開發還能做嗎。。我隨便寫個方法代碼,豈不是就會出現並發問題?但是為什麼我開發了這麼久的代碼,也沒有出現過方法並發導致的異常問題啊?
燒的麻袋;
這裡就要說明另外一個問題,JVM的線程棧,JVM線程棧中是線程獨有的內存空間(如:程序計數器以線程棧幀)而線程棧幀中的局部變量表則用來存儲當前所執行方法的基本數據類型(包含 reference, returnAddress等),所以當方法在被線程執行的過程中,相關的對象引用信息,以及基本類型的數據都是線程獨有的,並不會出現多個線程訪問時的並發問題,也就是簡單來說:一個方法內的變量定義以及方法內的業務代碼,是不會出現並發問題的。多個線程並不會共享一個方法內的變量數據,而是每個方法內的定義都屬於當前該執行線程的獨有棧空間中。(所以通過Java線程棧的這一獨特特性自然當中則為我們省了很多事項;)

但是由於我們的線程的數據操作不可能每次都去訪問主存中的數據,對於線程所使用到的變量需要copy至線程內存中以增加我們的執行速度,所以就引出了我們上述所提到的並發問題的本質問題,線程工作空間和主內存的數據不同步而導致的數據共享時的可見性問題;

如:此時定義一個簡單的類

class Person{
    int a = 1;
    int b = 2;

    public void change() {
        a = 3;
        b = a;
    }

    public void print() {
        String result = "b=" + b + ";a=" + a;
        System.out.println(result);
    }
    
    public static void main(String[] args) {
        while (true) {
            final Person test = new Person();
            new Thread(() -> {
                Thread.sleep(10);
                test.change();
            }).start();

            new Thread(() -> {
                Thread.sleep(10);
                test.print();
            }).start();
        }
    }
}

如上,假設此時多個線程同時訪問change()以及print() 方法,則可能會出現print所輸出的結果是:b=2;a=1或者b=3;a=3;這兩種都是正常現象,但還有可能是會輸出結果是:b=2;a=3以及b=3;a=1;

Person類所定義的變量a和b,按照JVM內存區域劃分,在對象實例化後則都是存儲到數據堆中;
按照我們上述關於線程工作內存的解釋來看,此時線程在執行change()方法和print()方法時,由於兩個方法都有關於外部變量的引用,所以需要copy主內存中的這兩個變量副本到對應的線程工作內存中進行操作,執行完以後再同步至主內存中。

此時在A線程執行完change()方法後,a=3,b=3;但此時a=3在執行完成後還沒有同步到主內存,但b=3此時已經提供至主內存了,那麼此時B線程執行print()數據輸出後,則得到的是結果是:b=3;a=1;同理也可以得到b=2;a=3的可能性結果;所以此處則由於線程共享變量的可見性問題,而導致了上述的問題;

正是由於存在上述所提到的線程並發所可能引起的種種問題,所以JDK則也有了後續的一系列多線程玩法:ThreadLocal,CountDownLatch,ReentrantLock,Unsafe,synchronized,volatile,Executor,Future 這些供開發者在開發程序時用來對多線程保駕護航的助手類,以及JDK已經自身開發好的支持線程安全的一些工具類,StringBuffer,CopyOnWriteArrayList, ConcurrentHashMap,AtomicInteger等,供開發者開箱即用;後續針對這些JDK自身所提供的一些類的玩法會做進一步說明,順便系統整理下腦中的信息,形成有效的知識結構;End;

粉絲福利:Java從入門到入土學習路線圖

👇👇👇

感謝點讚支持下哈 

相關焦點

  • java基礎|驗證ArrayList的線程不安全
    ,助力你從菜鳥到大牛,記得收藏哦~~https://www.javastudy.cloud驗證ArrayList的線程不安全主體思路和上一篇驗證i++線程不安全是一致的:https://www.javastudy.cloud/articles/2019/11/05/1572962139693.html驗證ArrayList代碼如下:
  • Java編寫線程安全類的7個技巧
    幾乎每個Java應用程式都會用到線程。例如,Tomcat是在單獨的工作線程中處理每個請求,胖客戶機(Fat Client)在專用工作線程中處理長時間運行的請求。本文將跟你一起探討如何以線程安全的方式來編寫類。
  • 使用Lock鎖:java多線程安全問題解決方案之Lock鎖
    今天我們來學習一下Lock鎖,它是java 1.5之後出現的接口 java.util.concurrent.locks.Lock接口LockLock鎖可以提升多個線程的讀寫效率。如何來使用Lock鎖?既然Lock是一個接口,那麼就需要有一個實現類,java.util.concurrent.locks.ReentrantLock implements Lock接口,這個java底層已經提供了給我,ReentrantLock 的意思是 可重入鎖,ReentrantLock和synchronized關鍵字都可以用來實現線程之間的同步互斥
  • 40個Java多線程問題總結
    7、什麼是線程安全又是一個理論的問題,各式各樣的答案有很多,我給出一個個人認為解釋地最好的:如果你的代碼在多線程下執行和在單線程下執行永遠都能獲得一樣的結果,那麼你的代碼就是線程安全的。(4)線程非安全這個就沒什麼好說的了,ArrayList、LinkedList、HashMap等都是線程非安全的類8、Java中如何獲取到線程dump文件死循環、死鎖、阻塞、頁面打開慢等問題,打線程dump是最好的解決問題的途徑。
  • Java開發多線程是如何解決安全問題的?
    Java序言:提到線程安全,可能大家首先想到的是確保接口對共享變量的操作要具備 原子性。實際上,在多線程編程中我們需要同時關注可見性,順序性和原子性。可見性 當多個線程並發訪問共享變量時,一個線程對共享變量的修改,其它線程能夠立即看到。可見性問題是好多人忽略或者理解錯誤的一點。 CPU從主內存中讀數據的效率相對來說不高,現在主流的計算機中,都有幾級緩存。
  • java線程的基礎問題講解
    1.1並發編程的目的:並發編程是為了讓程序運行得更快,當 並不是啟動更多的線程就能讓程序最大限度地並發執行,受限於死鎖和上下文切換問題。所以上下文切換會影響線程的執行速度。join 的作用多線程一定比單線程快嗎?不一定,在測試中並發數量沒超過百萬次的時候,串行比並發速度更快,因為線程有線程的創建和上下文切換的開銷。
  • JAVA多線程 集合同步
    原文連結:http://www.javamadesoeasy.com/2015/12/how-to-synchronize-arraylist-in-java-to.html幾乎所有的集合非線程安全的?
  • 面試前必看Java線程面試題
    java.lang.Thread 類的實例就是一個線程但是它需要調用java.lang.Runnable接口來執行,由於線程類本身就是調用的Runnable接口所以你可以繼承java.lang.Thread 類或者直接調用Runnable接口來重寫run()方法實現線程。更多詳細信息請點擊這裡。5. 什麼是線程安全?Vector是一個線程安全類嗎?
  • 為什麼SimpleDateFormat不是線程安全的?
    能說一下 SimpleDateFormat 線程安全問題嗎,以及如何解決?🙋同事小剛:用過的,平時就是在全局定義一個 static 的 SimpleDateFormat,然後在業務處理方法中直接使用的,至於線程安全... 這個... 倒是沒遇到過線程安全問題。
  • Java多線程synchronized
    本篇主要介紹Java多線程中的同步,也就是如何在Java語言中寫出線程安全的程序,如何在Java語言中解決非線程安全的相關問題。
  • 【107期】SimpleDateFormat 的線程安全問題與解決方案
    問題重現 import java.text.ParseException;import java.text.SimpleDateFormat;import java.util.Date;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors
  • Java 線程面試題 Top 50
    java.lang.Thread 類的實例就是一個線程但是它需要調用java.lang.Runnable接口來執行,由於線程類本身就是調用的Runnable接口所以你可以繼承java.lang.Thread 類或者直接調用Runnable接口來重寫run()方法實現線程。4) 用Runnable還是Thread?
  • Java面試題-多線程篇十三
    兩種方式:java.lang.Thread 類的實例就是一個線程但是它需要調用java.lang.Runnable接口來執行,由於線程類本身就是調用的Runnable接口所以你可以繼承java.lang.Thread 類或者直接調用Runnable接口來重寫run()方法實現線程。
  • java的線程創建方式
    Thread類java語言中的Thread類是一個基本的線程類,用於創建線程、中斷線程、獲取線程的基本信息、運行狀態等。我們首先了解下利用Thread類創建線程實例的二種方式。繼承Thread類創建線程//繼承Thread實現自己的線程類class MyThread extends Thread{//重寫run方法,給線程賦予工作任務 @Override public void run() { //任務內容…… System.out.println("當前線程是:"+Thread.currentThread
  • 初學Java多線程:向線程傳遞數據的三種方法
    初學Java多線程:向線程傳遞數據的三種方法 本文講述在學習Java多線程中需要學習的向線程傳遞數據的三種方法。由於線程的運行和結束是不可預料的,因此,在傳遞和返回數據時就無法象函數一樣通過函數參數和return語句來返回數據。
  • Java中枚舉的線程安全性及序列化問題
    本文將深入分析枚舉的源碼,看一看枚舉是怎麼實現的,他是如何保證線程安全的,以及為什麼用枚舉實現的單例是最好的方式。要想看源碼,首先得有一個類吧,那麼枚舉類型到底是什麼類呢?是enum嗎?AUTUMN, WINTER            });        }都是static類型的,因為static類型的屬性會在類被加載之後被初始化,我們在深度分析Java的ClassLoader機制(源碼級別)和Java類的加載、連結和初始化兩個文章中分別介紹過,當一個Java類第一次被真正使用到的時候靜態資源被初始化、Java類的加載和初始化過程都是線程安全的
  • Java基礎知識點面試手冊(線程+JDK8)
    volatile 關鍵字經典:https://www.jianshu.com/p/195ae7c77afe解析:關於指令重排序的問題,可以查閱 DCL 雙檢鎖失效相關資料。答:通過關鍵字sychronize可以防止多個線程進入同一段代碼,在某些特定場景中,volatile相當於一個輕量級的sychronize,因為不會引起線程的上下文切換。
  • Java創建線程安全的單例singleton
    3、多線程下的安全單例,第二種方式就是雙重檢查,下面的實現方式有問題嗎?實現雙重檢查不在方法上添加synchronized,在instance為null的時候才會添加鎖,這樣效率提高了不少,但是在多線程下面可能會出現問題。
  • Java 多線程 —— 深入理解 volatile 的原理以及應用
    推薦閱讀:《java 多線程—線程怎麼來的》這一篇主要講解一下volatile的原理以及應用,想必看完這一篇之後,你會對volatile的應用原理以及使用邊界會有更深刻的認知。在介紹之前,我們先拋出2個問題:volatile究竟是如何保證共享變量的同步的?i++操作為何對虛擬機來說不是原子操作? 對變量進行volatile聲明以後,會有以下特徵:1、可見性。保證此變量對所有線程是可見的。2、原子性。
  • 【堪稱經典】JAVA多線程和並發基礎面試問答
    所以在其他處於等待狀態的線程上調用這些方法是沒有意義的。這就是為什麼這些方法是靜態的。它們可以在當前正在執行的線程中工作,並避免程式設計師錯誤的認為可以在其他非運行線程調用這些方法。16.如何確保線程安全?