Java下如何保證多線程安全


前言

        可能有人會覺得,只要我寫代碼的時候不去開啟其他線程,那么就不會有多線程的問題了。
        然而事實並非如此,如果僅僅是一些簡單的測試代碼,確實代碼都會順序執行而不是並發執行,但是Java應用最廣泛的web項目中,絕大部分(如果不是所有的話)web容器都是多線程的——以tomcat為例, 每一個進來的請求都需要一個線程,直到該請求結束。 這樣一來,即使本身不打算多線程運行的代碼,實際上幾乎都會以多線程的方式執行。
        在 Spring 注冊的 bean(默認都是單例),在設為單例的 bean 中出現的成員變量或靜態變量,都必須注意是否存在多線程競爭導致的多線程不安全的問題。
        ——可見,有些時候確實都是人在江湖,身不由己。積累多線程的知識是必不可少的。

1.為什么會有多線程不安全的問題

1.1.寫不安全

        上面講到 web 容器會多線程訪問 JVM,這里還有一個問題,為什么多線程時就會存在多線程不安全呢?這是因為在 JVM 中的內存管理,並不是所有內存都是線程私有的,Heap(Java堆)中的內存是線程共享的。
        而 Heap 中主要是存放對象的,這樣多個線程訪問同一個對象時,就會使用到同一塊內存了,在這塊內存中存着的成員變量就會受到多個線程的操作。
如下圖所示:

 

 

因為是增加2和3,結果應該是15才對,但是因為多線程的原因,導致結果是12或13。

1.2.讀不安全

        上面的寫操作不安全是一方面,事實上 Java 中還存在更加糟糕的問題,就是讀到的數據也不一致。
        因為多個線程雖然訪問對象時是使用的同一塊內存(這塊內存可稱為主內存),但是為了提高效率,每個線程有時會都會將讀取到的值緩存在本線程內(具體因不同 JVM 的實現邏輯而有不同,所以緩存不是必然的),這些緩存的數據可稱為副本數據。
        這樣,就會出現,某個值已經被某個線程更改了,但是其他線程卻不知道,也不去主內存更新數據的情況。
如下圖所示:
 
上圖的情況,其實線程的並發度相對要低一點,但即使是其他線程更改的數據,有的線程也不知道,因為讀不安全導致了數據不一致。

2.如何讓多線程安全

        既然已經知道了會發生不安全的問題,那么要怎么解決這些問題呢?

2.1.讀一致性

        Java 中針對上述“讀不安全”的問題提供了關鍵字 volatile 來解決問題,被 volatile 修飾的成員變量,在內容發生更改的時候,會通知所有線程去主內存更新最新的值,這樣就解決了讀不安全的問題,實現了讀一致性。
        但是,讀一致性是無法解決寫一致性的,雖然能夠使得每個線程都能及時獲取到最新的值,但是1.1中的寫一致性問題還是會存在。
        既然如此,Java 為啥還要提供 volatile 關鍵字呢?這並非多余的存在,在某些場景下只需要讀一致性的話,這個關鍵字就能夠滿足需求而且性能相對還不錯,因為其他的能夠保證“讀寫”都一直的辦法,多多少少存在一些犧牲。

2.2.寫一致性

        Java 提供了三種方式來保證讀寫一致性,分別是互斥鎖、自旋鎖、線程隔離。

2.2.1.互斥鎖

        互斥鎖只是一個鎖概念,在其他場景也叫做獨占鎖、悲觀鎖等,其實就是一個意思。它是指線程之間是互斥的,某一個線程獲取了某個資源的鎖,那么其他線程就只能睡眠等待。
        在 Java 中互斥鎖的實現一般叫做同步線程鎖,關鍵字 synchronized,它鎖住的范圍是它修飾的作用域,鎖住的對象是: 當前對象(對象鎖) 或 類的全部對象(類鎖) ——鎖釋放前,其他線程必將阻塞,保證鎖住范圍內的操作是原子性的,而且讀取的數據不存在一致性問題。
  • 對象鎖:當它修飾方法、代碼塊時,將會鎖住當前對象
  • 類鎖:修飾類、靜態方法時,則是鎖住類的所有對象
注意: 鎖住的永遠是對象,鎖住的范圍永遠是 synchronized 關鍵字后面的花括號划定的代碼域。

2.2.2.自旋鎖

        自旋鎖也只是一個鎖概念,在其他場景也叫做樂觀鎖等。
        自旋鎖本質上是不加鎖,而是通過對比舊數據來決定是否更新:
 
        如上所示,不管線程1與線程2哪個先執行,哪個后執行,結果都會是15,由此實現了讀寫一致性。而因為步驟3的更新失敗而在步驟4中更新數據后再此嘗試更新的過程,就叫做自旋——自旋只是個概念:表示 操作失敗后,線程會循環進行上一步的操作,直到成功為止。
        這種方式避免了線程的上下文切換以及線程互斥等,相對於互斥鎖而言,它允許並發的存在(互斥鎖不存在並發,只能同步進行)。
        在 Java 的 java.util.concurrent.atomic 包 中提供了自旋的操作類,諸如 AtomicInteger、AtomicLong 等,都能夠達到此目的。
 

  1. 上面代碼中的18行的代碼,直接對一個int變量++操作,這是多線程不安全的
  2. 其中注釋掉的19、20、21行代碼則是加上了同步線程鎖的寫法,同步的操作使得多線程安全
  3. 下面的25行代碼則是基於自旋鎖的操作,也是多線程安全的
        但是,如果並發度很高的話,就會導致某些線程一直都無法更新成功(因為一直有其他線程更改了值),會使得線程長時間占用CPU和線程。所以自旋鎖是屬於低並發的解決方案。
        另外,直接使用這些自旋的操作類還是太過原始,所以Java還在這個基礎上封裝了一些類,能夠簡單直接地接近於 synchronized 那么方便地對某段代碼上鎖,即是 ReentrantLock 以及 ReentrantReadWriteLock,限於篇幅,這里不詳細介紹他們的使用。

2.2.3.線程隔離

        既然自旋鎖只是低並發的解決方案,那么遇到高並發要如何處理呢?答案是將成員變量設成線程隔離的,也就是說每個線程都各自使用自己的變量,互相自己是不相關的。這樣自然也做到了多線程安全。但是這種做法是讓所有線程都互相隔離的了,所以他們之間是不存在互相操作的。
        在 Java 中提供了 ThreadLocal 類來實現這種效果:
// 聲明線程隔離的變量,變量類型通過泛型決定
private static ThreadLocal<Integer> localInt = new ThreadLocal<>();

// 獲取泛型類的對象
Integer integer = localInt.get();

if (integer==null){
    integer = 0;
}

// 將泛型對象設到變量中
localInt.set(++integer);

 

總結

        本文主要講了為什么會出現多線程不安全的原因,其中涉及讀不安全與寫不安全。Java 使用 volatile 關鍵字實現了讀一致性,使用同步線程鎖(synchronized)、自旋操作類(AtomicInteger等 )以及線程隔離類(ThreadLocal )來實現了寫一致性,這三種方法中,同步線程鎖效率最低,自旋操作類在非高並發的場景可大大提高效率,但是要想實現真正的高並發,還是需要用到線程隔離類來實現。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM