深入Synchronized各種使用方法
深入學習Synchronized各種使用方法
在Java當中synchronized通常是用來標記一個方法或者代碼塊。在Java當中被synchronized標記的代碼或者方法在同一個時刻只能夠有一個線程執行被synchronized修飾的方法或者代碼塊。因此被synchronized修飾的方法或者代碼塊不會出現數據競爭的情況,也就是說被synchronized修飾的代碼塊是並發安全的。
Synchronized關鍵字
synchronized關鍵字通常使用在下面四個地方:
- synchronized修飾實例方法。
- synchronized修飾靜態方法。
- synchronized修飾實例方法的代碼塊。
- synchronized修飾靜態方法的代碼塊。
在實際情況當中我們需要仔細分析我們的需求選擇合適的使用synchronized方法,在保證程序正確的情況下提升程序執行的效率。
Synchronized修飾實例方法
下面是一個用Synchronized修飾實例方法的代碼示例:
public class SyncDemo {
private int count;
public synchronized void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
SyncDemo syncDemo = new SyncDemo();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
syncDemo.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
syncDemo.add();
}
});
t1.start();
t2.start();
t1.join(); // 阻塞住線程等待線程 t1 執行完成
t2.join(); // 阻塞住線程等待線程 t2 執行完成
System.out.println(syncDemo.count);// 輸出結果為 20000
}
}
在上面的代碼當中的add
方法只有一個簡單的count++
操作,因為這個方法是使用synchronized
修飾的因此每一個時刻只能有一個線程執行add
方法,因此上面打印的結果是20000。如果add
方法沒有使用synchronized
修飾的話,那麼線程t1和線程t2就可以同時執行add
方法,這可能會導致最終count
的結果小於20000,因為count++
操作不具備原子性。
上面的分析還是比較明確的,但是我們還需要知道的是synchronized
修飾的add
方法一個時刻只能有一個線程執行的意思是對於一個SyncDemo
類的對象來說一個時刻只能有一個線程進入。比如現在有兩個SyncDemo
的對象s1
和s2
,一個時刻只能有一個線程進行s1
的add
方法,一個時刻只能有一個線程進入s2
的add
方法,但是同一個時刻可以有兩個不同的線程執行s1
和s2
的add
方法,也就說s1
的add
方法和s2
的add
是沒有關係的,一個線程進入s1
的add
方法並不會阻止另外的線程進入s2
的add
方法,也就是說synchronized
在修飾一個非靜態方法的時候「鎖」住的只是一個實例對象,並不會「鎖」住其它的對象。其實這也很容易理解,一個實例對象是一個獨立的個體別的對象不會影響他,他也不會影響別的對象。
Synchronized修飾靜態方法
Synchronized修飾靜態方法:
public class SyncDemo {
private static int count;
public static synchronized void add() {
count++; // 注意 count 也要用 static 修飾 否則編譯通過不了
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
SyncDemo.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
SyncDemo.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(SyncDemo.count); // 輸出結果為 20000
}
}
上面的代碼最終輸出的結果也是20000,但是與前一個程序不同的是。這裡的add
方法用static
修飾的,在這種情況下真正的只能有一個線程進入到add
代碼塊,因為用static
修飾的話是所有對象公共的,因此和前面的那種情況不同,不存在兩個不同的線程同一時刻執行add
方法。
你仔細想想如果能夠讓兩個不同的線程執行add
代碼塊,那麼count++
的執行就不是原子的了。那為什麼沒有用static
修飾的代碼為什麼可以呢?因為當沒有用static
修飾時,每一個對象的count
都是不同的,內存地址不一樣,因此在這種情況下count++
這個操作仍然是原子的!
Sychronized修飾多個方法
synchronized修飾多個方法示例:
public class AddMinus {
public static int ans;
public static synchronized void add() {
ans++;
}
public static synchronized void minus() {
ans--;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
AddMinus.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
AddMinus.minus();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(AddMinus.ans); // 輸出結果為 0
}
}
在上面的代碼當中我們用synchronized
修飾了兩個方法,add
和minus
。這意味着在同一個時刻這兩個函數只能夠有一個被一個線程執行,也正是因為add
和minus
函數在同一個時刻只能有一個函數被一個線程執行,這才會導致ans
最終輸出的結果等於0。
對於一個實例對象來說:
public class AddMinus {
public int ans;
public synchronized void add() {
ans++;
}
public synchronized void minus() {
ans--;
}
public static void main(String[] args) throws InterruptedException {
AddMinus addMinus = new AddMinus();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
addMinus.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
addMinus.minus();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(addMinus.ans);
}
}
上面的代碼沒有使用static
關鍵字,因此我們需要new
出一個實例對象才能夠調用add
和minus
方法,但是同樣對於AddMinus
的實例對象來說同一個時刻只能有一個線程在執行add
或者minus
方法,因此上面代碼的輸出同樣是0。
Synchronized修飾實例方法代碼塊
Synchronized修飾實例方法代碼塊
public class CodeBlock {
private int count;
public void add() {
System.out.println("進入了 add 方法");
synchronized (this) {
count++;
}
}
public void minus() {
System.out.println("進入了 minus 方法");
synchronized (this) {
count--;
}
}
public static void main(String[] args) throws InterruptedException {
CodeBlock codeBlock = new CodeBlock();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
codeBlock.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
codeBlock.minus();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(codeBlock.count); // 輸出結果為 0
}
}
有時候我們並不需要用synchronized
去修飾代碼塊,因為這樣並發度就比較低了,一個方法一個時刻只能有一個線程在執行。因此我們可以選擇用synchronized
去修飾代碼塊,只讓某個代碼塊一個時刻只能有一個線程執行,除了這個代碼塊之外的代碼還是可以並行的。
比如上面的代碼當中add
和minus
方法沒有使用synchronized
進行修飾,因此一個時刻可以有多個線程執行這個兩個方法。在上面的synchronized
代碼塊當中我們使用了this
對象作為鎖對象,只有拿到這個鎖對象的線程才能夠進入代碼塊執行,而在同一個時刻只能有一個線程能夠獲得鎖對象。也就是說add
函數和minus
函數用synchronized
修飾的兩個代碼塊同一個時刻只能有一個代碼塊的代碼能夠被一個線程執行,因此上面的結果同樣是0。
這裡說的鎖對象是this
也就CodeBlock
類的一個實例對象,因為它鎖住的是一個實例對象,因此當實例對象不一樣的時候他們之間是沒有關係的,也就是說不同實例用synchronized
修飾的代碼塊是沒有關係的,他們之間是可以並發的。
Synchronized修飾靜態代碼塊
public class CodeBlock {
private static int count;
public static void add() {
System.out.println("進入了 add 方法");
synchronized (CodeBlock.class) {
count++;
}
}
public static void minus() {
System.out.println("進入了 minus 方法");
synchronized (CodeBlock.class) {
count--;
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
CodeBlock.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
CodeBlock.minus();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(CodeBlock.count);
}
}
上面的代碼是使用synchronized
修飾靜態代碼塊,上面代碼的鎖對象是CodeBlock.class
,這個時候他不再是鎖住一個對象了,而是一個類了,這個時候的並發度就變小了,上一份代碼當鎖對象是CodeBlock
的實例對象時並發度更大一些,因為當鎖對象是實例對象的時候,只有實例對象內部是不能夠並發的,實例之間是可以並發的。但是當鎖對象是CodeBlock.class
的時候,實例對象之間時不能夠並發的,因為這個時候的鎖對象是一個類。
應該用什麼對象作為鎖對象
在前面的代碼當中我們分別使用了實例對象和類的class對象作為鎖對象,事實上你可以使用任何對象作為鎖對象,但是不推薦使用字符串和基本類型的包裝類作為鎖對象,這是因為字符串對象和基本類型的包裝對象會有緩存的問題。字符串有字符串常量池,整數有小整數池。因此在使用這些對象的時候他們可能最終都指向同一個對象,因為指向的都是同一個對象,線程獲得鎖對象的難度就會增加,程序的並發度就會降低。
比如在下面的示例代碼當中就是由於鎖對象是同一個對象而導致並發度下降:
import java.util.concurrent.TimeUnit;
public class Test {
public void testFunction() throws InterruptedException {
synchronized ("HELLO WORLD") {
System.out.println(Thread.currentThread().getName() + "\tI am in synchronized code block");
TimeUnit.SECONDS.sleep(5);
}
}
public static void main(String[] args) {
Test t1 = new Test();
Test t2 = new Test();
Thread thread1 = new Thread(() -> {
try {
t1.testFunction();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
try {
t2.testFunction();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread1.start();
thread2.start();
}
}
在上面的代碼當中我們使用兩個不同的線程執行兩個不同的對象內部的testFunction
函數,按道理來說這兩個線程是可以同時執行的,因為執行的是兩個不同的實例對象的同步代碼塊。但是上面代碼的執行首先一個線程會進入同步代碼塊然後打印輸出,等待5秒之後,這個線程退出同步代碼塊另外一個線程才會再進入同步代碼塊,這就說明了兩個線程不是同時執行的,其中一個線程需要等待另外一個線程執行完成才執行。這正是因為兩個Test
對象當中使用的"HELLO WORLD"
字符串在內存當中是同一個對象,是存儲在字符串常量池中的對象,這才導致了鎖對象的競爭。
下面的代碼執行的結果也是一樣的,一個線程需要等待另外一個線程執行完成才能夠繼續執行,這是因為在Java當中如果整數數據在[-128, 127]
之間的話使用的是小整數池當中的對象,使用的也是同一個對象,這樣可以減少頻繁的內存申請和回收,對內存更加友好。
import java.util.concurrent.TimeUnit;
public class Test {
public void testFunction() throws InterruptedException {
synchronized (Integer.valueOf(1)) {
System.out.println(Thread.currentThread().getName() + "\tI am in synchronized code block");
TimeUnit.SECONDS.sleep(5);
}
}
public static void main(String[] args) {
Test t1 = new Test();
Test t2 = new Test();
Thread thread1 = new Thread(() -> {
try {
t1.testFunction();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
try {
t2.testFunction();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread1.start();
thread2.start();
}
}
Synchronized與可見性和重排序
可見性
-
當一個線程進入到
synchronized
同步代碼塊的時候,將會刷新所有對該線程的可見的變量,也就是說如果其他線程修改了某個變量,而且線程需要在Synchronized
代碼塊當中使用,那就會重新刷新這個變量到內存當中,保證這個變量對於執行同步代碼塊的線程是可見的。 -
當一個線程從同步代碼塊退出的時候,也會將線程的工作內存同步到內存當中,保證在同步代碼塊當中修改的變量對其他線程可見。
重排序
Java編譯器和JVM當發現能夠讓程序執行的更快的時候是可能對程序的指令進行重排序處理的,也就是通過調換程序指令執行的順序讓程序執行的更快。
但是重排序很可能讓並發程序產生問題,比如說當一個在synchronized
代碼塊當中的寫操作被重排序到synchronized
同步代碼塊外部了這顯然是有問題的。
在JVM的實現當中是不允許synchronized
代碼塊內部的指令和他前面和後面的指令進行重排序的,但是在synchronized
內部的指令是可能與synchronized
內部的指令進行重排序的,比較著名的就是DCL單例模式
,他就是在synchronized
代碼塊當中存在重排序的,如果你對DCL單例模式
還不是很熟悉,你可以閱讀這篇文章的DCL單例
模式部分。
總結
在本篇文章當中主要介紹了各種synchronized
的使用方法,總結如下:
- Synchronized修飾實例方法,這種情況不同的對象之間是可以並發的。
- Synchronized修飾實例方法,這種情況下不同的對象是不能並發的,但是不同的類之間可以進行並發。
- Sychronized修飾多個方法,這多個方法在統一時刻只能有一個方法被執行,而且只能有一個線程能夠執行。
- Synchronized修飾實例方法代碼塊,同一個時刻只能有一個線程執行代碼塊。
- Synchronized修飾靜態代碼塊,同一個時刻只能有一個線程執行這個代碼塊,而且不同的對象之間不能夠進行並發。
- 應該用什麼對象作為鎖對象,建議不要使用字符串和基本類型的包裝類作為鎖對象,因為Java對這些進行優化,很可能多個對象使用的是同一個鎖對象,這會大大降低程序的並發度。
- 程序在進入和離開Synchronized代碼塊的時候都會將線程的工作內存刷新到內存當中,以保證數據的可見性,這一點和
volatile
關鍵字很像,同時Synchronized代碼塊中的指令不會和Synchronized代碼塊之間和之後的指令進行重排序,但是Synchronized代碼塊內部可能進行重排序。
更多精彩內容合集可訪問項目://github.com/Chang-LeHung/CSCore
關注公眾號:一無是處的研究僧,了解更多計算機(Java、Python、計算機系統基礎、算法與數據結構)知識。