實現(xiàn)線程安全的11種方法,你學(xué)會了嗎?
Java原生支持多線程,意味著通過在獨立的線程中并發(fā)運行,JVM能夠提升應(yīng)用程序的性能。
盡管多線程是一個強大的特性,但它也有代價。在多線程環(huán)境中,我們需要時刻崩著線程安全這根弦,即在不同的線程可以訪問相同的資源,而不會暴露錯誤行為或產(chǎn)生不可預(yù)測的結(jié)果,這種編程方法被稱為“線程安全”。
一、無狀態(tài)實現(xiàn)
在大多數(shù)情況下,多線程中的錯誤是由于多個線程之間錯誤地共享狀態(tài)導(dǎo)致的。
因此,我們首先要探討的方法是:使用無狀態(tài)實現(xiàn)來達到線程安全。
為了更好地理解這種方法,先創(chuàng)建一個簡單的工具類,它有一個靜態(tài)方法用于計算數(shù)字的階乘:
public class MathUtils {
public static BigInteger factorial(int number) {
BigInteger f = new BigInteger("1");
for (int i = 2; i <= number; i++) {
f = f.multiply(BigInteger.valueOf(i));
}
return f;
}
}
factorial()方法是一個無狀態(tài)的確定性函數(shù):給定特定的輸入,總是得到相同的輸出。
該方法既不依賴外部狀態(tài),也不維護狀態(tài)。因此,它被認為是線程安全的,可以同時被多個線程安全地調(diào)用。
所有線程都可以安全地調(diào)用factorial()方法,并將獲得預(yù)期的結(jié)果,而不會相互干擾,也不會改變該方法為其他線程生成的輸出。
因此,無狀態(tài)實現(xiàn)是實現(xiàn)線程安全的最簡單方法。
二、不可變實現(xiàn)
如果我們需要在不同線程之間共享狀態(tài),我們可以通過使類不可變來創(chuàng)建線程安全的類。
不可變性是一個強大的、與語言無關(guān)的概念,在Java中很容易實現(xiàn)。在函數(shù)式編程中,很重要的一個技巧就是不可變,參見什么是函數(shù)式編程?。
簡單地說,當一個類實例在構(gòu)造后其內(nèi)部狀態(tài)不能被修改時,它就是不可變的。
在Java中創(chuàng)建不可變類的最簡單方法是聲明所有字段為私有且為final,并且不提供設(shè)置器:
public class MessageService {
privatefinal String message;
public MessageService(String message) {
this.message = message;
}
public String getAndPrint() {
System.out.println(message);
return message;
}
public String getMessage() {
return message;
}
}
一個MessageService對象,在其構(gòu)造后其狀態(tài)不能改變,所以是線程安全的。
此外,如果MessageService是可變的,但多個線程對其只有只讀權(quán)限,它也是線程安全的。
如我們所見,不可變性是實現(xiàn)線程安全的另一種方式。
三、線程局部字段
在面向?qū)ο缶幊蹋∣OP)中,對象實際上需要通過字段維護狀態(tài),并通過一個或多個方法實現(xiàn)行為。
如果我們確實需要維護狀態(tài),我們可以使用線程局部字段來創(chuàng)建線程安全的類,線程局部字段在線程之間就不共享狀態(tài)。
我們可以通過在Thread類中定義私有字段輕松創(chuàng)建字段為線程局部的類。
比如,我們可以定義一個Thread類,它存儲一個整數(shù)數(shù)組:
public class ThreadA extends Thread {
private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
@Override
public void run() {
numbers.forEach(System.out::println);
}
}
另一個Thread類可能持有一個字符串數(shù)組:
public class ThreadB extends Thread {
private final List<String> letters = Arrays.asList("a", "b", "c", "d", "e", "f");
@Override
public void run() {
letters.forEach(System.out::println);
}
}
在這兩種實現(xiàn)中,類都有自己的狀態(tài),但不與其他線程共享。因此,這些類是線程安全的。
類似地,我們可以通過將ThreadLocal實例分配給一個字段來創(chuàng)建線程局部字段。
考慮以下StateHolder類:
public class StateHolder {
private String state;
public StateHolder(String state) {
this.state = state;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
}
我們可以很容易地使其成為一個線程局部變量:
public class ThreadState {
public static final ThreadLocal<StateHolder> statePerThread =
ThreadLocal.withInitial(() -> new StateHolder("active"));
public static StateHolder getState() {
return statePerThread.get();
}
}
線程局部字段與普通類字段非常相似,不同之處在于每個通過setter/getter訪問它們的線程,都會獲得該字段的獨立初始化副本,以便每個線程都有自己的狀態(tài)。
四、同步集合
我們可以通過使用集合框架中包含的同步包裝器輕松創(chuàng)建線程安全的集合。
比如,我們可以創(chuàng)建一個線程安全的集合:
Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();
請記住,同步集合在每個方法中使用內(nèi)部鎖,這意味著這些方法一次只能被一個線程訪問,而其他線程將被阻塞,直到第一個線程釋放該方法的鎖。
五、并發(fā)集合
作為同步集合的替代方案,我們可以使用并發(fā)集合來創(chuàng)建線程安全的集合。
Java提供了java.util.concurrent包,其中包含幾個并發(fā)集合,比如ConcurrentHashMap:
Map<String,String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");
與同步集合不同,并發(fā)集合通過將數(shù)據(jù)分割成段來實現(xiàn)線程安全。例如,在ConcurrentHashMap中,多個線程可以獲取不同映射段的鎖,因此多個線程可以同時訪問該映射。
由于并發(fā)線程訪問的固有優(yōu)勢,并發(fā)集合比同步集合性能更高。
需要注意的是,無論同步集合還是并發(fā)集合,都是集合本身線程安全,其內(nèi)容并不是。
六、原子對象
我們還可以使用Java提供的原子類集合來實現(xiàn)線程安全,包括AtomicInteger、AtomicLong、AtomicBoolean和AtomicReference。
原子類允許我們執(zhí)行原子操作,這些操作是線程安全的,而無需使用同步。
為了理解這解決了什么問題,讓我們看一下以下Counter類:
public class Counter {
private int counter = 0;
public void incrementCounter() {
counter += 1;
}
public int getCounter() {
return counter;
}
}
假設(shè)在一個競爭條件下,兩個線程同時訪問incrementCounter()方法。
理論上,counter字段的最終值將為2。但我們不能確定結(jié)果,因為線程同時執(zhí)行相同的代碼塊,并且遞增操作不是原子的。可以參見在多線程中使用ArrayList會發(fā)生什么?的說明。
讓我們使用AtomicInteger對象創(chuàng)建Counter類的線程安全實現(xiàn):
public class AtomicCounter {
private final AtomicInteger counter = new AtomicInteger();
public void incrementCounter() {
counter.incrementAndGet();
}
public int getCounter() {
return counter.get();
}
}
這是線程安全的,因為雖然遞增操作++需要多個操作,但incrementAndGet是原子的。
七、同步方法
前面的方法對于集合和基本類型非常有用,但有時我們需要更復(fù)雜的控制邏輯。
因此,我們可以使用的另一種常見方法是:實現(xiàn)同步方法來實現(xiàn)線程安全。
簡單地說,一次只能有一個線程訪問同步方法,同時阻止其他線程訪問該方法。其他線程將保持阻塞,直到第一個線程完成或該方法拋出異常。
我們可以通過將incrementCounter()方法變?yōu)橥椒椒▉硪粤硪环N方式創(chuàng)建其線程安全版本:
public synchronized void incrementCounter() {
counter += 1;
}
我們通過在方法簽名前加上synchronized關(guān)鍵字創(chuàng)建了一個同步方法。
由于一次只能有一個線程訪問同步方法,一個線程將執(zhí)行incrementCounter()方法,其他線程將依次執(zhí)行。不會發(fā)生任何重疊執(zhí)行。
同步方法依賴于使用“內(nèi)部鎖”或“監(jiān)視器”,內(nèi)部鎖是與特定類實例相關(guān)聯(lián)的隱式內(nèi)部實體。具體參加synchronized 鎖同步。
在多線程上下文中,“監(jiān)視器”只是對鎖在相關(guān)對象上執(zhí)行的角色的引用,它強制對一組指定的方法或語句進行獨占訪問。
當一個線程調(diào)用同步方法時,它獲取內(nèi)部鎖,線程執(zhí)行完方法后,會釋放鎖,允許其他線程獲取鎖并訪問該方法。
我們可以在實例方法、靜態(tài)方法和語句(同步語句)中實現(xiàn)同步。
八、同步語句
有時,如果我們只需要使方法的一部分線程安全,同步整個方法可能有些過度。
我們再重構(gòu)incrementCounter()方法:
public void incrementCounter() {
// 其他未同步的操作
synchronized(this) {
counter += 1;
}
}
假設(shè)該方法現(xiàn)在執(zhí)行一些其他不需要同步的操作,我們通過將相關(guān)的狀態(tài)修改部分包裝在同步塊中來僅同步這部分。
與同步方法不同,同步語句必須指定提供內(nèi)部鎖的對象,通常是this引用。
同步是有代價的,通過同步代碼塊,能夠僅同步方法的相關(guān)部分。
(一)其他對象作為鎖
我們可以通過利用另一個對象作為監(jiān)視器鎖(而不是this)來稍微改進Counter類的線程安全實現(xiàn)。
這不僅在多線程環(huán)境中為共享資源提供了協(xié)調(diào)訪問,還使用外部實體來強制對資源的獨占訪問:
public class ObjectLockCounter {
privateint counter = 0;
privatefinal Object lock = new Object();
public void incrementCounter() {
synchronized (lock) {
counter += 1;
}
}
public int getCounter() {
return counter;
}
}
我們使用一個普通的Object實例來實現(xiàn)互斥,它提高了鎖級別的安全性。
當使用this進行內(nèi)部鎖時,攻擊者可以通過獲取內(nèi)部鎖并觸發(fā)拒絕服務(wù)(DoS)條件來導(dǎo)致死鎖。
相反,當使用其他對象時,無法從外部訪問這個私有對象,攻擊者很難獲取鎖并導(dǎo)致死鎖。
(二)注意事項
盡管我們可以使用任何Java對象作為內(nèi)部鎖,但我們應(yīng)該避免使用String進行鎖定:
public class Class1 {
privatestaticfinal String LOCK = "Lock";
// 使用LOCK作為內(nèi)部鎖
}
publicclass Class2 {
privatestaticfinal String LOCK = "Lock";
// 使用LOCK作為內(nèi)部鎖
}
乍一看,似乎這兩個類使用了兩個不同的對象作為它們的鎖。然而,由于字符串駐留,這兩個“Lock”值實際上可能在字符串池中引用同一個對象。也就是說,Class1和Class2共享同一個鎖!
除了String,我們應(yīng)該避免使用任何可緩存或可重用的對象作為內(nèi)部鎖。比如,Integer.valueOf()方法緩存小數(shù)字。因此,即使在不同的類中調(diào)用Integer.valueOf(1)也會返回同一個對象。
九、易失性字段
同步方法和塊對于解決線程之間的變量可見性問題很方便,即便如此,常規(guī)類字段的值可能會被CPU緩存。因此,即使對特定字段進行了同步更新,其他線程可能也看不到這些更新。
為了防止這種情況,我們可以使用易失性類字段(通過volatile關(guān)鍵字標記):
public class Counter {
private volatile int counter;
// 標準的構(gòu)造函數(shù)/獲取器
}
通過使用volatile關(guān)鍵字,我們指示JVM和編譯器將counter變量存儲在主內(nèi)存中。這樣,我們確保每次JVM讀取counter變量的值時,它實際上是從主內(nèi)存中讀取,而不是從CPU緩存中讀取。同樣,每次JVM寫入counter變量時,值將被寫入主內(nèi)存。
此外,使用易失性變量確保給定線程可見的所有變量也將從主內(nèi)存中讀取。
比如:
public class User {
private String name;
private volatile int age;
// 標準的構(gòu)造函數(shù)/獲取器
}
在這種情況下,每次JVM將age易失性變量寫入主內(nèi)存時,它也會將非易失性name變量寫入主內(nèi)存。這確保了兩個變量的最新值都存儲在主內(nèi)存中,因此對變量的后續(xù)更新將自動對其他線程可見。
類似地,如果一個線程讀取易失性變量的值,該線程可見的所有變量也將從主內(nèi)存中讀取。
易失性變量提供的這種擴展保證被稱為完全易失性可見性保證。
十、可重入鎖
Java提供了一組改進的鎖實現(xiàn),其行為比上面討論的內(nèi)部鎖稍微復(fù)雜一些。
對于內(nèi)部鎖,鎖獲取模型相當嚴格:一個線程獲取鎖,然后執(zhí)行一個方法或代碼塊,最后釋放鎖,以便其他線程可以獲取它并訪問該方法。內(nèi)部鎖沒有實現(xiàn)檢查排隊的線程并優(yōu)先訪問等待時間最長的線程,即屬于非公平鎖。
ReentrantLock實例允許我們做到這一點,防止排隊的線程遭受某些類型的資源饑餓:
public class ReentrantLockCounter {
privateint counter;
privatefinal ReentrantLock reLock = new ReentrantLock(true);
public void incrementCounter() {
reLock.lock();
try {
counter += 1;
} finally {
reLock.unlock();
}
}
public int getCounter() {
return counter;
}
}
ReentrantLock構(gòu)造函數(shù)接受一個可選的公平性布爾參數(shù),當設(shè)置為true且多個線程試圖獲取鎖時,JVM將優(yōu)先考慮等待時間最長的線程并授予其訪問鎖的權(quán)限,即實現(xiàn)公平鎖。
十一、讀寫鎖
我們可以使用讀寫鎖實現(xiàn)來實現(xiàn)線程安全。讀寫鎖實際上使用一對相關(guān)聯(lián)的鎖,一個用于只讀操作,另一個用于寫入操作。
因此,只要沒有線程正在寫入資源,就可以有多個線程讀取該資源。此外,寫入資源的線程將阻止其他線程讀取它。
以下是我們?nèi)绾问褂米x寫鎖:
public class ReentrantReadWriteLockCounter {
privateint counter;
privatefinal ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
privatefinal Lock readLock = rwLock.readLock();
privatefinal Lock writeLock = rwLock.writeLock();
public void incrementCounter() {
writeLock.lock();
try {
counter += 1;
} finally {
writeLock.unlock();
}
}
public int getCounter() {
readLock.lock();
try {
return counter;
} finally {
readLock.unlock();
}
}
}
文末總結(jié)
在本文中,我們了解了Java中的線程安全是什么,并深入研究了實現(xiàn)線程安全的11種方法。