點擊上方藍色字體,選擇「標星公眾號」
優質文章,第一時間送達
作者 | Arnold-zhao
來源 | urlify.cn/ryQFJn
66套java從入門到精通實戰課程分享
出現線程安全的問題本質是因為:
主內存和工作內存數據不一致性以及編譯器重排序導致。
所以理解上述兩個問題的核心,對認知多線程的問題則具有很高的意義;
簡單理解CPUCPU除了控制器、運算器等器件還有一個重要的部件就是寄存器。其中寄存器的作用就是進行數據的臨時存儲。
CPU的運算速度是非常快的,為了性能CPU在內部開闢一小塊臨時存儲區域,並在進行運算時先將數據從內存複製到這一小塊臨時存儲區域中,運算時就在這一小快臨時存儲區域內進行。我們稱這一小塊臨時存儲區域為寄存器。
CPU讀取指令是往內存裡面去讀取的,讀一條指令放到CPU中,CPU去執行,對內存的讀取速度比較慢,所以從內存讀取的速度去決定了這個CPU的執行速度的。所以無論我們的CPU怎麼去升級,但是如果這方面速度沒有解決的話,其的性能也不會得到多大的提升。
為了彌補這個缺陷,所以添加了高速緩存的機制,如ARM A11的處理器,它的1級緩存中的容量是64KB,2級緩存中的容量是8M,
通過增加cpu高速緩存的機制,以此彌補伺服器內存讀寫速度的效率問題;
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從入門到入土學習路線圖
👇👇👇
感謝點讚支持下哈