我們都知道,當(dāng)多個線程并發(fā)地操作同一共享資源的時候,容易發(fā)生線程安全問題,解決這個問題的一個辦法是加鎖,那么問題來了:加鎖就一定線程安全了嗎?
各位小伙伴,你們的答案是什么?是,還是不是?
其實這種面試問題,面試官可能會希望你能根據(jù)不同的場景展開闡述,而不是簡單的回答是或不是,這既可表現(xiàn)出你對多線程中的線程安全問題的理解到位,同時也體現(xiàn)了你分析問題的能力比別的候選人強(qiáng),考慮問題周到。
1. 加同一個內(nèi)置鎖或者顯式獨占鎖,一定線程安全
這種方式實際上是將并行變成了串行,所有需要進(jìn)入同步區(qū)的線程,都需要先獲取到這把鎖,一旦某個線程獲取到了鎖,其他線程就需要等待,即同時間在同步區(qū)范圍內(nèi),只能允許一個線程進(jìn)行共享資源的訪問,因此會降低性能!
1) 加同一個內(nèi)置鎖
import java.util.concurrent.CountDownLatch;
public class ThreadSafeDemo {
private int anInt = 0;
public synchronized void incr() {
anInt++;
}
public void decr() {
synchronized (this) {
anInt--;
}
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
if (threadIdx % 2 == 0) { // threadIdx 等于 0、2、4 時
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
demo.incr();
}
latch.countDown();
}).start();
} else { // threadIdx 等于 1、3 時
new Thread(() -> {
for (int i = 10000; i > 0; i--) {
demo.decr();
}
latch.countDown();
}).start();
}
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 期望值:10000
System.out.println("當(dāng)前 anInt 的值為:" + demo.anInt);
}
}
如以上代碼,開啟 5 個并發(fā)線程,其中 3 個線程分別自增 10000,2 個線程分別自減 10000,所以最終期望正確的值應(yīng)該是 30000 - 20000 = 10000,執(zhí)行結(jié)果如下:

結(jié)果正確,線程安全。
2) 加同一個顯式獨占鎖
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadSafeDemo {
private int anInt = 0;
public void incr() {
anInt++;
}
public void decr() {
anInt--;
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ReentrantLock lock = new ReentrantLock();
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
if (threadIdx % 2 == 0) { // threadIdx 等于 0、2、4 時
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
// 顯式獨占鎖加鎖
lock.lock();
demo.incr();
// 顯式獨占鎖解鎖
lock.unlock();
}
latch.countDown();
}).start();
} else { // threadIdx 等于 1、3 時
new Thread(() -> {
for (int i = 10000; i > 0; i--) {
// 顯式獨占鎖加鎖
lock.lock();
demo.decr();
// 顯式獨占鎖解鎖
lock.unlock();
}
latch.countDown();
}).start();
}
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 期望值:10000
System.out.println("當(dāng)前 anInt 的值為:" + demo.anInt);
}
}
同 1) 一樣,只不過這里換成了顯式的獨占鎖(ReentrantLock),所以執(zhí)行結(jié)果是一樣的!
2. 加不同的鎖,一定線程不安全
我們對 1 中的內(nèi)置鎖部分代碼做一些修改,注意 incr() 和 decr() 方法:
import java.util.concurrent.CountDownLatch;
public class ThreadSafeDemo {
private static int anInt = 0;
public synchronized void incr() {
anInt++;
}
public static synchronized void decr() {
anInt--;
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
if (threadIdx % 2 == 0) { // threadIdx 等于 0、2、4 時
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
demo.incr();
}
latch.countDown();
}).start();
} else { // threadIdx 等于 1、3 時
new Thread(() -> {
for (int i = 10000; i > 0; i--) {
ThreadSafeDemo.decr();
}
latch.countDown();
}).start();
}
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 期望值:10000
System.out.println("當(dāng)前 anInt 的值為:" + anInt);
}
}
執(zhí)行結(jié)果如下:

可以看到,結(jié)果并不正確,線程不安全。
那這是為什么呢?其實就是因為這里有兩把鎖,不同的鎖,也就不能保證多線程對同一共享資源的并發(fā)操作是線程安全的。也就是說 0、2、4 線程獲取的鎖跟 1、3 線程獲取的鎖不是同一個鎖,0、2、4 線程獲取的鎖作用的對象是調(diào)用 incr() 這個方法的對象,也就是 demo,而 1、3 線程獲取的鎖作用的對象是 ThreadSafeDemo 這個類的 Class 對象,跟 synchronized (ThreadSafeDemo.class) {...} 的作用是類似的。
3. 加同一讀寫鎖,不一定線程安全
1 中使用的是獨占鎖,會降低性能。實際上在一些場景下,多線程也可以同時訪問共享資源,而不會產(chǎn)生線程安全的問題。例如多線程的“讀”操作與“讀”操作之間。
下面以 Java 8 的 ReentrantReadWriteLock 例子作示例說明,該示例參考了 Oracle 官方的 API 文檔中的例子,>> 傳送門:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ThreadSafeDemo {
/**
* 數(shù)據(jù)
*/
private String data = null;
/**
* 緩存是否有效
*/
private volatile boolean cache = false;
public String getDataFromDb() {
// 模擬從數(shù)據(jù)庫中獲取數(shù)據(jù),耗時 0.5 秒
String data = null;
try {
TimeUnit.MILLISECONDS.sleep(500L);
data = String.valueOf(System.currentTimeMillis());
System.out.println("[" + Thread.currentThread().getName()
+ "] 緩存無效,從數(shù)據(jù)庫中獲取數(shù)據(jù):" + data);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return data;
}
public void use() {
System.out.println("[" + Thread.currentThread().getName()
+ "] 當(dāng)前 data 的值為:" + data);
}
public static void main(String[] args) {
CountDownLatch latch = new CountDownLatch(5);
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ThreadSafeDemo demo = new ThreadSafeDemo();
for (int threadIdx = 0; threadIdx < 5; threadIdx++) {
new Thread(() -> {
// 獲取讀鎖:⑴
rwLock.readLock().lock();
// 如果緩存無效
if (!demo.cache) {
// 釋放讀鎖(讀鎖不能升級為寫鎖):⑴ 處獲取的
rwLock.readLock().unlock();
// 獲取寫鎖
rwLock.writeLock().lock();
try {
// 再次檢查緩存是否有效,因為其他線程有可能先于當(dāng)前線程獲取到寫鎖并修改了它的值
if (!demo.cache) {
demo.data = demo.getDataFromDb();
// 緩存設(shè)為有效
demo.cache = true;
}
// 獲取讀鎖(在釋放寫鎖之前,再獲取讀鎖,進(jìn)行鎖降級):⑵
rwLock.readLock().lock();
} finally {
// 釋放寫鎖,此時線程仍持有讀鎖(⑵ 處獲取的)
rwLock.writeLock().unlock();
}
}
try {
// 模擬 1 秒的處理時間,并打印出當(dāng)前值
TimeUnit.SECONDS.sleep(1);
demo.use();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 釋放讀鎖:⑴ 或 ⑵ 處獲取的
rwLock.readLock().unlock();
}
latch.countDown();
}).start();
}
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
執(zhí)行結(jié)果:

乍一看,這不是正確的嗎?別急,我們再來加點東西看看:
new Thread(() -> {
// 獲取讀鎖:⑴
rwLock.readLock().lock();
// 如果緩存無效
if (!demo.cache) {
// 錯誤示范,在讀鎖里面修改了數(shù)據(jù)
demo.cache = true;
demo.data = demo.getDataFromDb();
demo.cache = false;
// 釋放讀鎖(讀鎖不能升級為寫鎖):⑴ 處獲取的
rwLock.readLock().unlock();
// Omit code...
}
// Omit code...
}).start();
如以上代碼,在前面的代碼基礎(chǔ)上,⑴ 處第一次獲取到讀鎖后,在釋放讀鎖之前,對共享資源進(jìn)行了修改,執(zhí)行結(jié)果如下:

可以看到,因為在讀鎖區(qū)域內(nèi)對共享資源進(jìn)行了修改,導(dǎo)致出現(xiàn)了線程安全問題,而這種問題是由于不正確地使用了讀寫鎖導(dǎo)致的。也就是說,在使用讀寫鎖時,不能在讀鎖范圍內(nèi)對共享資源進(jìn)行“寫”操作,需要理解讀寫鎖的適用場景并且正確地使用它。
總結(jié)
這次通過一個面試題,簡單地梳理了一下多線程的線程安全問題與鎖的關(guān)系,希望對各位能有幫助!由于個人能力所限,如果各位小伙伴在閱讀文章時發(fā)現(xiàn)有錯誤的地方,歡迎反饋給我勘正,萬分感謝。