來源:blog.csdn.net/u014454538/article/details/98515807 1. Java中的線程安全 Java線程安全:狹義地認為是多線程之間共用數據的訪問。 Java語言中各種操作共用的數據有5種類型:不可變、絕對線程安全、相對線程安全、線程相容、線程獨立 ① 不可 ...
來源:blog.csdn.net/u014454538/article/details/98515807
1. Java中的線程安全
- Java線程安全:狹義地認為是多線程之間共用數據的訪問。
- Java語言中各種操作共用的數據有5種類型:不可變、絕對線程安全、相對線程安全、線程相容、線程獨立
① 不可變
- 不可變(Immutable) 的對象一定是線程安全的,不需要再採取任何的線程安全保障措施。
- 只要能正確構建一個不可變對象,該對象永遠不會在多個線程之間出現不一致的狀態。
- 多線程環境下,應當儘量使對象成為不可變,來滿足線程安全。
如何實現不可變?
- 如果共用數據是基本數據類型,使用final關鍵字對其進行修飾,就可以保證它是不可變的。
- 如果共用數據是一個對象,要保證對象的行為不會對其狀態產生任何影響。
- String是不可變的,對其進行substring()、replace()、concat()等操作,返回的是新的String對象,原始的String對象的值不受影響。而如果對StringBuffer或者StringBuilder對象進行substring()、replace()、append()等操作,直接對原對象的值進行改變。
- 要構建不可變對象,需要將內部狀態變數定義為final類型。如
java.lang.Integer
類中將value定義為final類型。
Java 面試題最全整理:https://www.javastack.cn/mst/
private final int value;
常見的不可變的類型:
- final關鍵字修飾的基本數據類型
- 枚舉類型、String類型
- 常見的包裝類型:Short、Integer、Long、Float、Double、Byte、Character等
- 大數據類型:BigInteger、BigDecimal
註意:原子類 AtomicInteger 和 AtomicLong 則是可變的。
對於集合類型,可以使用 Collections.unmodifiableXXX()
方法來獲取一個不可變的集合。
- 通過
Collections.unmodifiableMap(map)
獲的一個不可變的Map類型。 Collections.unmodifiableXXX()
先對原始的集合進行拷貝,需要對集合進行修改的方法都直接拋出異常。
例如,如果獲得的不可變map對象進行put()、remove()、clear()操作,則會拋出UnsupportedOperationException異常。
② 絕對線程安全
絕對線程安全的實現,通常需要付出很大的、甚至不切實際的代價。
Java API中提供的線程安全,大多數都不是絕對線程安全。
例如,對於數組集合Vector的操作,如get()、add()、remove()都是有synchronized關鍵字修飾。有時調用時也需要手動添加同步手段,保證多線程的安全。
下麵的代碼看似不需要同步,實際運行過程中會報錯。
import java.util.Vector;
/**
* @Author: lucy
* @Version 1.0
*/
public class VectorTest {
public static void main(String[] args) {
Vector<Integer> vector = new Vector<>();
while(true){
for (int i = 0; i < 10; i++) {
vector.add(i);
}
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
System.out.println("獲取vector的第" + i + "個元素: " + vector.get(i));
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<vector.size();i++){
System.out.println("刪除vector中的第" + i+"個元素");
vector.remove(i);
}
}
}).start();
while (Thread.activeCount()>20)
return;
}
}
}
出現ArrayIndexOutOfBoundsException
異常,原因:某個線程恰好刪除了元素i,使得當前線程無法訪問元素i。
Exception in thread "Thread-1109" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 1
at java.util.Vector.remove(Vector.java:831)
at VectorTest$2.run(VectorTest.java:28)
at java.lang.Thread.run(Thread.java:745)
需要將對元素的get和remove構造成同步代碼塊:
synchronized (vector){
for (int i = 0; i < vector.size(); i++) {
System.out.println("獲取vector的第" + i + "個元素: " + vector.get(i));
}
}
synchronized (vector){
for (int i=0;i<vector.size();i++){
System.out.println("刪除vector中的第" + i+"個元素");
vector.remove(i);
}
}
③ 相對線程安全
- 相對線程安全需要保證對該對象的單個操作是線程安全的,在必要的時候可以使用同步措施實現線程安全。
- 大部分的線程安全類都屬於相對線程安全,如Java容器中的Vector、HashTable、通過
Collections.synchronizedXXX()
方法包裝的集合。
④ 線程相容
- Java中大部分的類都是線程相容的,通過添加同步措施,可以保證在多線程環境中安全使用這些類的對象。
- 如常見的ArrayList、HashTableMap都是線程相容的。
⑤ 線程對立
- 線程對立是指:無法通過添加同步措施,實現多線程中的安全使用。
- 線程對立的常見操作有:Thread類的suspend()和resume()(已經被JDK聲明廢除),
System.setIn()
和System.setOut()
等。
2. Java的枚舉類型
通過enum關鍵字修飾的數據類型,叫枚舉類型。
- 枚舉類型的每個元素都有自己的序號,通常從0開始編號。
- 可以通過values()方法遍歷枚舉類型,通過name()或者toString()獲取枚舉類型的名稱
- 通過ordinal()方法獲取枚舉類型中元素的序號
public class EnumData {
public static void main(String[] args) {
for (Family family : Family.values()) {
System.out.println(family.name() + ":" + family.ordinal());
}
}
}
enum Family {
GRADMOTHER, GRANDFATHER, MOTHER, FATHER, DAUGHTER, SON;
}
可以將枚舉類型看做普通的class,在裡面定義final類型的成員變數,便可以為枚舉類型中的元素賦初值。
要想獲取枚舉類型中元素實際值,需要為成員變數添加getter方法。
雖然枚舉類型的元素有了自己的實際值,但是通過ordinal()方法獲取的元素序號不會發生改變。
public class EnumData {
public static void main(String[] args) {
for (Family family : Family.values()) {
System.out.println(family.name() + ":實際值" + family.getValue() +
", 實際序號" + family.ordinal());
}
}
}
enum Family {
GRADMOTHER(3), GRANDFATHER(4), MOTHER(1), FATHER(2), DAUGHTER(5), SON(6);
private final int value;
Family(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
3. Java線程安全的實現
① 互斥同步
互斥同步(Mutex Exclusion & Synchronization)是一種常見的併發正確性保障手段。
- 同步:多個線程併發訪問共用數據,保證共用數據同一時刻只被一個(或者一些,使用信號量)線程使用。
- 互斥:互斥是實現同步的一種手段,主要的互斥實現方式:臨界區(Critical Section)、互斥量(Mutex)、信號量(Semaphore)。
同步與互斥的關係:
- 互斥是原因,同步是結果。
- 同步是目的,互斥是方法。
Java中,最基本的實現互斥同步的手段是synchronized關鍵字,其次是JUC包中的ReentrantLock。
關於synchronized關鍵字:
- 編譯後的同步塊,開始處會添加monitorenter指令,結束處或異常處會添加monitorexit指令。
- monitorenter和monitorexit指令中都包含一個引用類型的參數,分別指向加鎖或解鎖的對象。如果是同步代碼塊,則為synchronized括弧中明確指定的對象;如果為普通方法,則為當前實例對象;如果為靜態方法,則為類對應的class對象。
- JVM執行monitorenter指令時,要先嘗試獲取鎖:如果對象沒被鎖定或者當前線程已經擁有該對象的鎖,則鎖計數器加1;否則獲取鎖失敗,進入阻塞狀態,等待持有鎖的線程釋放鎖。
- JVM執行monitorexit指令時,鎖計數器減1,直到計數器的值為0,鎖被釋放。(synchronized是支持重進入的)
- 由於阻塞或者喚醒線程都需要從用戶態(User Mode)切換到核心態(Kernel Mode),有時鎖只會被持有很短的時間,沒有必要進行狀態轉換。可以讓線程在阻塞之前先自旋等待一段時間,超時未獲取到鎖才進入阻塞狀態,這樣可以避免頻繁的切入到核心態。其實,就是後面自旋鎖的思想。
關於ReentrantLock:
- 與synchronized關鍵字相比,它是API層面的互斥鎖(lock()、unlock()、try...finally)。
- 與synchronized關鍵字相比,具有可中斷、支持公平與非公平性、可綁定多個Condition對象的高級功能。
- 由於synchronized關鍵字被優化,二者的性能差異並不是很大,如果不是想使用ReentrantLock的高級功能,優先考慮使用synchronized關鍵字。
② 非阻塞同步
(1)CAS概述
互斥同步最大的性能問題是線程的阻塞和喚醒,因此又叫阻塞同步。
互斥同步採用悲觀併發策略:
- 多線程併發訪問共用數據時,總是認為只要不加正確的同步措施,肯定會出現問題。
- 無論共用數據是否存在競爭,都會執行加鎖、用戶態和心態的切換、維護鎖計數器、檢查是否有被阻塞的線程需要喚醒等操作。
隨著硬體指令集的發展,我們可以採用基於衝突檢測的樂觀併發策略:
- 先進行操作,如果不存在衝突(即沒有其他線程爭用共用數據),則操作成功。
- 如果有其他線程爭用共用數據,產生了衝突,使用其他的補償措施。
- 常見的補償措施:不斷嘗試,直到成功為止,比如迴圈的CAS操作。
樂觀併發策略的許多實現都不需要將線程阻塞,這種同步操作叫做非阻塞同步。
非阻塞同步依靠的硬體指令集:前三條是比較久遠的指令,後兩條是現代處理器新增的。
- 測試和設置(Test and Set)
- 獲取並增加(Fetch and Increment)
- 交換(Swap)
- 比較並交換(Compare and Swap,即CAS)
- 載入鏈接/條件存儲(Load Linked/ Store Conditional,即LL/SC)
什麼是CAS?
- CAS,即Compare and Swap,需要藉助處理器的cmpxchg指令完成。
- CAS指令需要三個操作數:記憶體位置V(Java中可以簡單的理解為變數的記憶體地址)、舊的期待值A、新值B。
- CAS指令執行時,當且僅當V符合舊的預期值A,處理器才用新值B更新V的值;否則,不執行更新。
- 不管是否更新V的值,都返回V的舊值,整個處理過程是一個原子操作。
原子操作:所謂的原子操作是指一個或一系列不可被中斷的操作。
Java中的CAS操作:
- Java中的CAS操作由
sun.misc.Unsafe
中的compareAndSwapInt()、compareAndSwapLong()等幾個方法包裝提供。實際無法調用這些方法,需要採用反射機制才能使用。 - 在實際的開發過程中,一般通過其他的Java API調用它們,如JUC包原子類中的
compareAndSet(expect, update)
、getAndIncrement()等方法。這些方法內部都使用了Unsafe類的CAS操作。 - Unsafe類的CAS操作,通過JVM的即時編譯器編譯後,是一條與平臺相關的CAS指令。
除了偏向鎖,Java中其他鎖的實現方式都是用了迴圈的CAS操作。
(2)通過迴圈的CAS實現原子操作
通過++i或者i++可以實現計數器的自增,在多線程環境下,這樣使用是非線程安全的。
public class UnsafeCount {
private int i = 0;
private static final int THREADS_COUNT = 200;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
UnsafeCount counter = new UnsafeCount();
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
counter.count();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println("多線程調用計數器i,運行後的值為: " + counter.i);
}
public void count() {
i++;
}
}
運行以上的代碼發現:當線程數量增加,每個線程調用計數器的次數變大時,每次運行的結果是錯誤且不固定的。
為了實現實在一個多線程環境下、線程安全的計數器,需要使用AtomicInteger的原子自增運算。
import java.util.concurrent.atomic.AtomicInteger;
public class SafeCount {
private AtomicInteger atomic = new AtomicInteger(0);
private static final int THREAD_COUNT = 200;
public static void main(String[] args) {
SafeCount counter = new SafeCount();
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j=0;j<10000;j++){
counter.count();
}
}
});
threads[i].start();
}
while (Thread.activeCount()>1){
Thread.yield();
}
System.out.println("多線程調用線程安全的計數器atomic:"+counter.atomic);
}
public void count() {
// 調用compareAnSet方法,使用迴圈的CAS操作實現計數器的原子自增
for (; ; ) {
int expect = atomic.get();
int curVal = expect + 1;
if (atomic.compareAndSet(expect, curVal)) {
break;
}
}
}
}
與非線程安全的計數器相比,線程安全的計數器有以下特點:
- 將int類型的計數器變數i,更換成具有CAS操作的AtomicInteger類型的計數器變數atomic。
- 進行自增運算時,通過迴圈的CAS操作實現atomic的原子自增。
- 先通過atomic.get()獲取expect的值,將expect加一得到新值,然後通過
atomic.compareAndSet(expect, curVal)
這一方法實現CAS操作。 - 其中compareAndSet()返回的true或者false,表示此次CAS操作是否成功。如果返回false,則不停地重覆執行CAS操作,直到操作成功。
上面的count方法實現的AtomicInteger原子自增,可以只需要調用incrementAndGet()一個方法就能實現。
public void count() {
// 調用incrementAndGet方法,實現AtomicInteger的原子自增
atomic.incrementAndGet();
}
因為incrementAndGet()方法,封裝了通過迴圈的CAS操作實現AtomicInteger原子自增的代碼。
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
(3)CAS操作存在的問題
1. ABA問題
- 在執行CAS操作更新共用變數的值時,如果一個值原來是A,被其他線程改成了B,然後又改回成了A。對於該CAS操作來說,它完全感受不到共用變數值的變化。這種操作漏洞稱為CAS操作的ABA問題。
- 解決該問題的思路是,為變數添加版本號,每次更新時版本號遞增。這種場景下就成了1A --> 2B --> 3A。CAS操作就能檢測到共用變數的ABA問題了。
- JUC包中,也提供了相應的帶標記的原子引用類AtomicStampedReference來解決ABA問題。
- AtomicStampedReference的compareAndSet()方法會首先比較期待的引用是否等於當前引用,然後檢查期待的標記是否等於當前標記。如果全部相等,則以原子操作的方式將新的引用和新的標記更新到當前值中。
- 但是AtomicStampedReference目前比較雞肋,如果想解決AB問題,可以使用鎖。
2. 迴圈時間過長,開銷大
迴圈的CAS操作如果長時間不成功,會給CPU帶來非常大的執行開銷。
3. 只能保證一個共用變數的原子操作
- 只對一個共用變數執行操作時,可以通過迴圈的CAS操作實現。如果是多個共用變數,迴圈的CAS操作無法保證操作的原子性。
- 取巧的操作:將多個共用變數合為一個變數進行CAS操作。JDK1.5開始,提供了AtomicReference類保證引用對象之間的原子性,可以將多個變數放在一個對象中進行CAS操作。
③ 無同步方案
同步只是保證共用數據爭用時正確性的一種手段,如果不存在共用數據,自然無須任何同步措施。
(1)棧封閉
多個線程訪問同一個方法的局部變數時,不會出現線程安全問題。
因為方法中的局部變數不會逃出該方法而被其他線程訪問,因此可以看做JVM棧中數據,屬於線程私有。
(2)可重入代碼(Reentrant Code)
可重入代碼又叫純代碼(Pure Code),可在代碼執行的任何時候中斷他它,轉去執行另外一段代碼(包括遞歸調用它本身),控制權返回後,原來的程式不會出現任何錯誤。
所有可重入的代碼都是線程安全,並非所有線程安全的代碼都是可重入的。
可重入代碼的共同特征:
- 不依賴存儲在堆上的數據和公用的系統資源
- 用到的狀態量都由參數中傳入
- 不調用非可重用的方法
如何判斷代碼是否具備可重入性?如果一個方法,它的返回結果是可預測的。只要輸入了相同的數據,就都能返回相同的結果,那它就滿足可重入性,當然也就是線程安全的。
(3)線程本地存儲(TLS)
線程本地存儲(Thread Local Storage):
- 如果一段代碼中所需要的數據必須與其他代碼共用,那就看看這些共用數據的代碼是否能保證在同一個線程中執行。
- 如果能保證,我們就可以把共用數據的可見範圍限制在同一個線程內。
- 這樣,無須同步也能保證線程之間不出現數據爭用的問題。
TLS的重要應用實例:經典的Web交互模型中,一個請求對應一個伺服器線程,使得Web伺服器應用可以使用。
Java中沒有關鍵字可以將一個變數定義為線程所獨享,但是Java中創建了java.lang.ThreadLocal
類提供線程本地存儲功能。
- 每一個線程內部都包含一個ThreadLocalMap對象,該對象將ThreadLocal對象的hashCode值作為key,即ThreadLocal.threadLocalHashCode,將本地線程變數作為value,構成鍵值對。
- ThreadLocal對象是當前線程ThreadLocalMap對象的訪問入口,通過
threadLocal.set()
為本地線程添加獨享變數;通過threadLocal.get()
獲取本地線程獨享變數的值。 - ThreadLocal、ThreadLocalMap、Thread的關係:Thread對象中包含ThreadLocalMap對象,ThreadLocalMap對象中包含多個鍵值對,每個鍵值對的key是ThreadLocal對象的hashCode,value是本地線程變數。
ThreadLocal的編程實例:
- 想為某個線程添加本地線程變數,必須通過ThreadLocal對象在該線程中進行添加,構造出的鍵值對自動存入該線程的map中;
- 想要獲取某個線程的本地線程變數,必須在該線程中獲取,會自動查詢該線程的map,獲得ThreadLocal對象對應的value。
- 通過ThreadLocal對象重覆為某個線程添加鍵值對,會覆蓋之前的value。
public class TLS {
public static void main(String[] args) {
ThreadLocal<String> threadLocal1 = new ThreadLocal<>();
ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
// 設置當前線程的本地線程變數
threadLocal1.set("thread1");
threadLocal2.set(1);
System.out.println(threadLocal1.get() + ": " + threadLocal2.get());
// 使用完畢後要刪除,避免記憶體泄露
threadLocal1.remove();
threadLocal2.remove();
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
threadLocal1.set("thread2");
threadLocal2.set(2);
System.out.println(threadLocal1.get() + ": " + threadLocal2.get());
threadLocal1.remove();
threadLocal2.remove();
}
});
thread1.start();
thread2.start();
// 沒有通過ThreadLocal為主線程添加過本地線程變數,獲取到的內容都是null
System.out.println(threadLocal1.get()+": "+threadLocal2.get());
}
}
對ThreadLocal的正確理解:
- ThreadLocal適用於線程需要有自己的實例變數,該實例變數可以在多個方法中被使用,但是不能被其他線程共用的場景。
- 由於不存在數據共用,何談同步?因此ThreadLocal 從理論上講,不是用來解決多線程併發問題的。
ThreadLocal的實現:
最原始的想法:ThreadLocal維護線程與實例的映射。既然通過ThreadLocal對象為線程添加本地線程變數,那就將ThreadLocalMap放在ThreadLocal中。
原始想法存在的缺陷:多線程併發訪問ThreadLocal中的Map,需要添加鎖。這是, JDK 未採用該方案的一個原因。
優化後的方法:Thread維護ThreadLocal與實例的映射。Map是每個線程所私有,只能在當前線程通過ThreadLocal對象訪問自身的Map。不存在多線程併發訪問同一個Map的情況,也就不需要鎖。
優化後存在記憶體泄露的情況:JDK1.8中,ThreadLocalMap每個Entry對ThreadLocal對象是弱引用,對每個實例是強引用。當ThreadLocal對象被回收後,該Entry的鍵變成null,但Entry無法被移除。使得實例被Entry引用無法回收,造成記憶體泄露。
近期熱文推薦:
1.1,000+ 道 Java面試題及答案整理(2022最新版)
4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這才是優雅的方式!!
覺得不錯,別忘了隨手點贊+轉發哦!