[Java併發] 1. 線程同步(同步器)
文章目錄
- [Java併發] 1. 線程同步(同步器)
- 一、synchronized關鍵字
- 1. 給對象加鎖
- 2. synchronized對方法加鎖, 同步方法和非同步方法是否可以同時調用
- 3. 髒讀問題
- 4. synchronized是可重入鎖
- 5. 出現異常默認情況鎖會被釋放
- 6. 引用變量指向對象的改變對鎖的影響
- 7. 不要將字符串常量作爲鎖定對象
- 8. 同步代碼中的語句越少越好
- 二、volatile關鍵字
- 三、ReentrantLock可重入鎖
- 四、ThreadLocal 線程局部變量
一、synchronized關鍵字
對於synchronized的理解:
由於java的每個對象都有一個內置鎖,當用此關鍵字修飾方法時,內置鎖會保護整個方法。在調用該方法前,需要獲得內置鎖,否則就處於阻塞狀態。synchronized關鍵字也可以修飾靜態方法,此時如果調用該靜態方法,將會鎖住整個類。
1. 給對象加鎖
1.1 new一個對象作爲鎖
synchronized(Object)
對括號內的對象加鎖,任何線程要執行synchronized
代碼塊中的代碼,都必須要先拿到該對象的鎖,當代碼塊執行完畢時,鎖就會釋放,被其他線程獲取。
public class T {
private int count = 10;
private final Object lock = new Object(); // 鎖對象
public void m() {
synchronized (lock) { // 任何線程要執行下面的代碼,都必須先拿到lock鎖,鎖信息記錄在堆內存對象中的,不是在棧引用中
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
// 當上述synchronized代碼塊執行完畢後,鎖就會被釋放,然後被其他線程獲取
}
}
注意:synchronized(lock)是鎖住堆內存中的lock指向的Object對象,而引用變量lock是位於棧內存中的。
1.2 直接鎖定自身對象
在1.1中,每次使用鎖都新建一個毫無其他功能的鎖對象比較麻煩,因此我們可以直接對this
對象加鎖,即synchronized(this)
。
public class T {
private int count = 10;
public void m() {
synchronized (this) { // 任何線程要執行下面的代碼,必須先拿到this鎖
// synchronized鎖定的不是代碼塊,而是this對象
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
1.3 synchronized修飾方法
若整個方法內所有代碼都被synchronized
修飾,則可以使synchronized
關鍵字修飾整個方法。
public class T {
private int count = 10;
public synchronized void m() { // 等同於synchronized(this),鎖定當前堆內存對象
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
1.4 synchronized鎖定靜態方法
類中靜態方法和靜態屬性屬性不需要new一個對象就可以訪問,沒有new出來,就沒有this引用的存在,所以當鎖定一個靜態方法時,相當於鎖定的是當前類的class對象。
public class T {
private static int count = 10;
public static synchronized void m() {//等同於synchronized(T.class)
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
// 上邊m()方法與下邊mm()方法等價
public static synchronized void mm() {
synchronized (T.class) {
// 這裏不能使用synchronized(this),因爲靜態方法不需要實例對象即可訪問
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
}
}
1.5. synchronized鎖住線程的run方法
public class T implements Runnable{
private int count = 10;
@Override
public /*synchronized*/ void run() {
count--;
System.out.println(Thread.currentThread().getName() + " count = " + count);
}
public static void main(String[] args) {
T t = new T();
for (int i = 0; i < 5; i++) {
new Thread(t).start(); //這裏new的所有線程的鎖住的是同一個上邊的t對象
}
}
}
run
方法不加synchronized
:因爲不保證原子性,每個線程在執行count--
和輸出操作之間,可能有別的線程來執行count--
,導致前後數據不一致。
加上synchronized
關鍵字:相當於是一個原子操作,一個run
方法執行完畢釋放了鎖,下一個線程才能拿到鎖執行run
方法。
2. synchronized對方法加鎖, 同步方法和非同步方法是否可以同時調用
public class T {
public synchronized void m1() {
System.out.println(Thread.currentThread().getName() + "m1 start...");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "m1 end...");
}
public void m2() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "m2 ...");
}
public static void main(String[] args) {
T t = new T();
new Thread(() -> t.m1(), "t1").start();//Java8中的lamba表達式
new Thread(() -> t.m2(), "t2").start();
// new Thread(t::m1, "t1").start(); //更簡潔的寫法
// new Thread(t::m2, "t2").start();
/**
new Thread(new Runnable() { //最原始的寫法
@Override
public void run() {
t.m1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
t.m2();
}
}).start();
*/
}
}
同步方法與非同步方法是可以同時調用的。只有synchronized修飾的方法在運行過程中才需要申請鎖,普通方法是不需要申請的。在同步方法m1()執行的同時,非同步方法m2()也在執行
3. 髒讀問題
業務代碼中,對業務寫方法加鎖,而對業務讀方法不加鎖,容易產生髒讀問題(dirty read)。
髒讀,不可重複讀,幻讀
import java.util.concurrent.TimeUnit;
public class Account {
String name;
double balance;//賬戶餘額爲成員變量 默認爲0.0
public synchronized void set(String name, double balance) {//寫操作
this.name = name;
//下面這段是爲了放大在this.name = name與this.balance = balance的執行間可能有別的業務代碼執行的情形,比如getbalance(),因爲這裏它是非鎖定方法仍然可以訪問name
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.balance = balance;
}
public /* synchronized */ double getBalance(String name) {//讀操作
return this.balance;
}
public static void main(String[] args) {
Account a = new Account();
new Thread(() -> a.set("張三", 100.0)).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));//0.0
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a.getBalance("zhangsan"));//100.0
}
}
set
方法在初始化時會休眠2s,我們調用set
方法後1s讀取餘額,顯示爲0,再過2s後餘額才變爲100.0,因此允不允許髒讀?要根據實際業務場景斟酌使用
4. synchronized是可重入鎖
4.1 一個同步方法可以調用另一個同步方法
一個同步方法可以調用另外一個同步方法:若一個線程已搶到某對象的鎖,再申請時仍然會得到該對象的鎖。因爲這是在同一個線程以內,無非就是給鎖上的數字加一(同一線程,同一把鎖)
import java.util.concurrent.TimeUnit;
public class T implements Runnable {
@Override
public synchronized void run() {
System.out.println("m1 start...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();// 在同步方法m1()中調用同步方法m2(),不會發生死鎖,因爲這是在同一線程內的調用
System.out.println("m1 end");
}
synchronized void m2() {
System.out.println("m2 start");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2 end");
}
public static void main(String[] args) {
T t = new T();
new Thread(t::run).start();
}
}
程序輸出如下,沒有發生死鎖,且m1()
方法會等待m2()
方法結束後繼續運行,說明這是函數調用,而非線程並行。
m1 start
m2 start
m2 end
m1 end
4.2 子類的同步方法可以調用父類的同步方法
子類的同步方法可以調用父類的同步方法也不會發生死鎖,兩個方法鎖住的this
指向的都是同一個子類對象。
import java.util.concurrent.TimeUnit;
public class T {
// 父類同步方法
synchronized void m2() {
System.out.println("father method start");
System.out.println("father method lock:" + this);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("father method end");
}
}
class TT extends T {
// 子類同步方法
@Override
synchronized void m1() {
System.out.println("child method start");
System.out.println("child method lock:" + this);
super.m();
System.out.println("child method end");
}
public static void main(String[] args) {
TT tt = new TT();
new Thread(tt::m1).start();
}
}
程序輸出結果如下,沒有發生死鎖,且m1()
方法會等待m2()
方法結束後繼續運行,說明這是函數調用,而非線程並行; 另外也可以看到父子的同步方法持有的是同一把鎖。
child method start
child method lock:thread01.TT@2dd5c6ac
father method start
father method lock:thread01.TT@2dd5c6ac
father method end
child method end
5. 出現異常默認情況鎖會被釋放
若synchronized
修飾的代碼塊中出現異常,線程進行異常處理後會馬上釋放鎖(與ReentrantLock
正相反).
import java.util.concurrent.TimeUnit;
public class T {
int i = 0;
// 同步方法,計數到5拋出異常
synchronized void m() {
System.out.println(Thread.currentThread().getName() + " start");
while (true) {
i++;
System.out.println(Thread.currentThread().getName() + ": " + i);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 計數到5拋出異常
if (i == 5) {
int error = 1 / 0;
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "線程1").start();
new Thread(t::m, "線程2").start();
}
}
程序執行結果:線程1拋出異常後馬上釋放鎖,鎖被線程2搶到並開始執行。
解決方法: 使用try-catch
捕獲異常。
try{
if (i == 5) {
int error = 1 / 0;
}
}catch(Exception e){
System.out.println("除法溢出");
}
6. 引用變量指向對象的改變對鎖的影響
synchronized
鎖住的是堆中o
對象的實例,而不是o
對象的引用,synchronized
是針對堆中o
對象的實例進行計數。
- 若在程序運行過程中,,引用
o
指向對象的屬性發生改變,鎖狀態不變。 - 若在程序運行過程中,引用
o
指向的對象發生改變,則鎖狀態改變,原本搶到的鎖作廢,線程會去搶新鎖。因此實際編程中常將鎖對象的引用用final
修飾,保證其指向的鎖對象不發生改變。(final
修飾引用時,該引用所指向的屬性可以改變,但該引用不能再指向其他對象)
public class T {
Object o = new Object();
// 該方法鎖住的o對象引用沒有被設爲final
void m() {
synchronized (o) {
while (true) {
System.out.println(Thread.currentThread().getName() + "正在運行");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "線程1").start();
// 在這裏讓程序睡一會兒,保證兩個線程得到的o對象不同
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread thread2 = new Thread(t::m, "線程2");
// 改變鎖引用,使得線程2也有機會運行,否則一直都是線程1運行
t.o = new Object();
thread2.start();
}
}
程序輸出如下,看到主線程睡了3秒之後,線程1和線程2交替運行,他們各自搶到了不同的鎖
線程1正在運行
線程1正在運行
線程1正在運行
線程2正在運行
線程1正在運行
線程2正在運行
線程1正在運行
線程2正在運行
...
如果沒有改變鎖引用,將會一直是線程1在運行。
7. 不要將字符串常量作爲鎖定對象
因爲字符串常量池的存在,兩個不同的字符串引用可能指向同一字符串對象。
public class T {
// 兩個字符串常量,作爲兩同步方法的鎖
String s1 = "Hello";
String s2 = "Hello";
// 同步m1方法以s1爲鎖
void m1() {
synchronized (s1) {
while (true) {
System.out.println(Thread.currentThread().getName() + ":m1 is running");
}
}
}
// 同步m2方法以s2爲鎖
void m2() {
synchronized (s2) {
while (true) {
System.out.println(Thread.currentThread().getName() + ":m1 is running");
}
}
}
public static void main(String[] args) {
T t = new T();
// 輸出兩個鎖的哈希碼
System.out.println(t.s1.hashCode());
System.out.println(t.s2.hashCode());
new Thread(t::m1, "線程1").start();
new Thread(t::m2, "線程2").start();
}
}
程序執行結果如下,實際上m1
和m2
其實鎖定的是同一對象,即兩個字符串常量指向的是同一對象,有一個線程永遠得不到鎖。
69609650
69609650
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running
線程1:m1 is running
這種情況還會發生比較詭異的現象,比如你用到一個類庫,在該類庫中代碼鎖定了字符串 “Hello”,但是你看不到源碼,然後你在自己代碼中也鎖定了 “Hello”, 這時就會發生非常詭異的死鎖阻塞,因爲你的程序和使用到的類庫不經意間使用了同一把鎖。
8. 同步代碼中的語句越少越好
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* synchronized的優化
* 同步代碼中的語句越少越好
* 比較m1和m2
*/
public class T {
int count = 0;
synchronized void m1() {
// do something need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 業務邏輯中只有下面這句需要sync,這時不應該給整個方法上鎖
count ++;
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
void m2() {
// do something need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 業務邏輯中只有下面這句需要sync,這時不應該給整個方法上鎖
// 採用細粒度的鎖, 可以使用線程爭用時間變短,從而提高效率
synchronized(this) {
count ++;
}
// do something need not sync
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 10; i ++) {
threads.add(new Thread(t::m1, "thread-" + i));
}
threads.forEach((o) -> o.start());
threads.forEach((o) -> {
try {
o.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
二、volatile關鍵字
volatile關鍵字, 是一個變量在多個線程間可見
1. volatile的可見性
volatile
關鍵字向編譯器聲明該變量是易變的,每次對volatile
關鍵字的修改會通知給所有相關線程.
- 在Java內存模型JMM中,所有對象以及信息都存放在主內存中(包含堆,棧),而每個線程在CPU中都有自己的獨立空間,存儲了需要用到的變量的副本。
- 線程對共享變量的操作,都會先在自己CPU中的工作內存中進行,然後再同步給主內存。若不加
volatile
關鍵字修飾,每個線程都有可能直接從自己CPU中的工作內存讀取內存,這樣如果另一個線程修改了原變量,該線程卻未必知道,從而引起同步問題;而加以volatile
關鍵字修飾後,每個線程對該變量進行修改後都會馬上通知給所有線程。
下面的程序中,running變量存在於主內存的t對象中,當線程t1開通的時候, 會把running值從內存中讀到t1線程的工作區,在運行中直接使用這個copy,並不會每次都去讀取內存,這樣, 當主線程修改running的值後,t1線程感知不到, 所以不會停止運行。
使用volatile, 將會強制所有線程都去對內存中讀取running的值, 緩存過期通知
import java.util.concurrent.TimeUnit;
public class T {
/**
* https://www.cnblogs.com/Mushrooms/p/5151593.html
*
* 補充內容:分享點兒知識,內容就是CPU內部的寄存器。就這個程序來說,有兩個線程。一個是主線程,
* 一個是自己啓動的線程。當自己啓動的線程運行時,running這個變量的值會被CPU把值從內存中讀到
* CPU中的寄存器(即CPU中的cache)中。爲什麼這麼做呢?因爲CPU的速度要比內存的速度快,內存的速
* 度比硬盤快。所以要把running中的數據copy一份到內存中處理。但是,沒有加volatile關鍵字的變
* 量running,當主線程已經把running改爲false,自己啓動的線程依然不能停下來。因爲它讀的是CPU
* 中running。主線程改的內存中的running。兩個線程讀寫的變量的存儲位置不同。
*
* 而volatile關鍵字就是爲了解決這個問題而出現的。其作用是,當主線程對內存中的變量running修改
* 後,就會通知CPU中的變量running,你那個值已經不是最新的了。這時候,自己啓動的線程會重新讀一
* 遍內存中的running變量。
*/
volatile boolean running = true; //對比一下有無volatile的情況下,整個程序運行結果的區別
void m() {
System.out.println("m start");
while (running) {
//死循環。只有running=false時,才能執行後面的語句
}
System.out.println("m end");
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "t1").start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 將running變量設爲false,觀察線程是否被終止
t.running = false;
}
}
運行結果表明,如果不對running
變量加以volatile
修飾,則對running``變量的修改不能終止子線程,說明在主線程中對
running`的修改對子線程不可見.
但是如果在while
死循環體中加入一些語句或sleep
一段時間之後,可見性問題可能會消失,這是因爲加入語句後,CPU就可能會出現空閒,並同步主內存中的內容到工作內存,但這是不確定的,因此在這種情況下還是儘量要加上volatile
。
2. volatile不保證原子性
volatile
只能保證可見性,但不能保證原子性。 即只會在讀變量的操作進行檢查,不會檢查寫回變量的時候之前讀入變量的值是否已經被修改。
volatile
不能解決多個線程同時修改一個變量帶來的線程安全問題, 也就是說volatile
不能代替synchronized
。
/*10個線程分別執行10000次count++,count是對象t的成員變量,按理來說最終count=100000,
但是最終每次執行結果都不一樣,count一直小於100000,說明volatile不具備原子性*/
import java.util.ArrayList;
import java.util.List;
public class T {
volatile int count = 0;
void m() {
for (int i = 0; i < 10000; i++) {
count ++;//++操作不具備原子性
}
}
public static void main(String[] args) {
T t = new T();
List<Thread> threads = new ArrayList<Thread>();
for (int i = 0; i < 10; i ++) {
threads.add(new Thread(t::m, "thread-" + i));
}
threads.forEach((o) -> o.start());
threads.forEach((o) -> {
try {
//join()方法阻塞調用此方法的線程,直到線程t完成,此線程再繼續。通常用於在main()主線程內,等待其它線程完成再結束main()主線程。
o.join();//相當於在main線程中同步o線程,o執行完了,main線程纔有執行的機會
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(t.count);
}
}
使用synchronized保證原子性和可見性
使用synchronized
解決,輸出count爲10000。
int count = 0;
synchronized void m() { //m方法加了synchronized修飾,保證了原子性和可見性
for (int i=0; i<10000; i++) {
count ++ ;
}
}
更高效:使用AtomicXXX類
AtomXXX
類本身方法都是原子性的, 但不能保證多方法連續調用的原子性。
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger count = new AtomicInteger(0);
/*synchronized*/ void m() { //不需要加鎖了
for (int i = 0; i < 10000; i++) {
// 如果加上了if (count.get() < 1000)
// 則在for循環兩條語句中間,即這個位置是沒有原子性的
count.incrementAndGet(); // 具備原子性,用來替換count++;
}
}
3. volatile與synchronized關鍵字區別
- volatile 只能保證可見性,效率高
- synchronized 既保證可見性,有保證原子性,效率低
4. 面試題:監控容器內元素個數
題目:寫兩個線程,線程1添加10個元素到容器中,線程2實時監控元素的個數,當容器中元素個數達到5時,線程2給出提示並立即結束
思路:容器選用ArrayList<Object>
,調用其add()
方法添加元素,調用size()
方法得到容器中元素個數。
方法1 volatile
線程2一直輪詢,將容器設爲volatile
保證線程間可見性,使線程2可以收到通知。
public class MyContainer {
// 主要容器,設爲volatile保證線程間可見性
private volatile List<Object> list = new ArrayList<>();
public void add(Object ele) {
list.add(ele);
}
public int size() {
return list.size();
}
public static void main(String[] args) {
MyContainer container = new MyContainer();
// 線程1,每隔一秒向容器中添加一個元素
new Thread(() -> {
for (int i = 0; i < 10; i++) {
container.add(new Object());
//這個部分可能被線程2搶佔
System.out.println("add " + i);
// 每隔一秒添加一個元素
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "線程1").start();
// 線程2,輪詢容器內元素個數
new Thread(() -> {
while (true) {
if (container.size() == 5) {
//這個部分可能被線程1搶佔
break;
}
}
System.out.println("監測到容器長度爲5,線程2立即退出");
}, "線程2").start();
}
}
評價:
- 不夠精確: 若當
container.size == 5
還未執行break
時,被其他線程搶佔;或container.add()
之後還未打印,就被線程2搶佔並判斷到container.size == 5
並退出了。 - 損耗性能: 線程2一直在走
while(true)
循環,浪費性能。我們避免用到死循環。
方法2 wait/notifyAll
使用wait/notify機制,當線程1寫入5個元素後通知線程2。
① 運用這種方法,必須保證t2先執行,先讓t2監聽纔可以
② wait會釋放鎖, 而notify與sleep不會釋放鎖
③ 因此notify之後,t1必須釋放鎖, t2退出後,也必須notify, 通知t1繼續執行
鎖的轉移過程:
- 先啓動
線程2
並使主線程睡2秒以確保線程2
先搶到鎖。 線程2
搶到鎖後調用wait()
,讓其釋放鎖並阻塞,以確保線程1
獲得鎖。線程1
搶到鎖後開始向容器內添加元素。當線程1
添加了5個元素後調用notify()
通知線程2
並調用wait()
釋放鎖並阻塞,以確保線程2獲得鎖。線程2
搶到鎖後輸出語句並退出,退出之前調用notify()
喚醒線程1
,因爲線程2
退出後會釋放鎖,因此這時不用調用wait()
釋放鎖。
public class MyContainer {
// 主要容器,因爲只有線程1對其進行修改和查詢操作,所以不用加volatile關鍵字
private List<Object> list = new ArrayList<>();
public void add(Object ele) {
list.add(ele);
}
public int size() {
return list.size();
}
public static void main(String[] args) {
MyContainer container = new MyContainer();
final Object lock = new Object(); // 鎖對象
// 線程2先啓動並進入wait狀態,等待被線程1喚醒
new Thread(() -> {
synchronized (lock) {
System.out.println("線程2啓動");
if (container.size() != 5) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("監測到容器長度爲5,線程2立即退出");
// 線程1喚醒線程2後立刻睡眠了,因此線程2退出前要再次喚醒線程1
lock.notify();
System.out.println("線程2結束");
}
}, "線程2").start();
// 主線程睡2秒鐘再創建線程1,確保線程2先得到鎖
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 線程1,每隔一秒向容器中添加一個元素
new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 10; i++) {
container.add(new Object());
System.out.println("add " + i);
// 當容器中元素個數達到5時,喚醒線程2並退出線程1
if (container.size() == 5) {
lock.notify();
// notify()方法不會釋放鎖,因此即使通知了線程2,也不能讓線程2立刻執行
// 所以要先將線程1 wait()住,讓其釋放鎖給線程2,等待線程2退出前再通知喚醒線程1
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 每隔一秒添加一個元素
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}, "線程1").start();
}
}
評價:當不涉及同步,只涉及線程通信的時候,用synchronized+wait/notify
機制就顯得太重了,實際編程中常用封裝層次更深的類庫實現線程間通信.
方法3 CountDownLatch
使用門閂鎖CountDownLatch
類鎖住線程2,並等待線程1撤去門閂釋放線程2。
public class MyContainer {
// 主要容器,因爲門閂鎖只是一種同步方式,不保證可見性,因此需要用volatile修飾
private volatile List<Object> list = new ArrayList<>();
public void add(Object ele) {
list.add(ele);
}
public int size() {
return list.size();
}
public static void main(String[] args) {
MyContainer container = new MyContainer();
// 門閂鎖,構造函數中傳入門閂數,使用其countDown()方法撤掉一條門閂
// 當門閂數爲0時,門會打開,兩個線程都會被執行
CountDownLatch latch = new CountDownLatch(1);
// 線程2先啓動並調用await()讓其被門閂鎖鎖住
new Thread(() -> {
System.out.println("線程2啓動");
if (container.size() != 5) {
try {
// 讓線程被門閂鎖鎖住,等待門閂的開放,而不是進入等待隊列
latch.await();
// 可以指定等待時間
// latch.await(5000, TimeUnit.MILLISECONDS)
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("監測到容器長度爲5,線程2立即退出");
}, "線程2").start();
// 主線程睡2秒鐘再創建線程1,確保線程2先得到鎖
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 線程1,每隔一秒向容器中添加一個元素
new Thread(() -> {
System.out.println("線程1 啓動");
for (int i = 0; i < 10; i++) {
container.add(new Object());
System.out.println("add " + i);
// 當容器中元素個數達到5時,撤去一個門閂,打開門閂鎖,兩個線程都會被執行
if (container.size() == 5) {
latch.countDown();
}
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "線程1").start();
}
}
評價:
- 門閂鎖不涉及鎖定,當
count
的值爲5時線程1並不會停止運行。 - 使用Latch(門閂)的
await
和countdown
方法代替wait
,notify
來進行通知,通信方式簡單, 同時可以指定等待時間。 - 當不涉及同步,只有涉及線程通信的時候,用
synchronized + wait/notify
就太重了,這時應該考慮使用CountDownLatch/cyclicbarrier/semaphore
。
門閂鎖CountDownLatch
在框架中使用的非常廣泛,如在Spring
框架中,要先實例化所有Properties
和Service
對象後才能實例化Bean
對象。因此我們給初始化Bean
對象的線程上一個兩道門閂的門閂鎖,初始化完畢所有Properties
對象後撤去一道門閂,初始化完畢所有Service
對象後再撤去一道門閂,兩道門閂撤去後,門閂鎖打開,創建Bean
的線程開始執行。
三、ReentrantLock可重入鎖
1. ReentrantLock替代synchronized
ReentrantLock
可以完全替代synchronized
,提供了一種更靈活的鎖。
ReenTrantLock
必須手動釋放鎖,爲防止發生異常,必須將同步代碼用try
包裹起來,在finally
代碼塊中釋放鎖。
public class T {
ReentrantLock lock = new ReentrantLock();
// 使用ReentrantLock的寫法
private void m1() {
// 嘗試獲得鎖
lock.lock(); //等於synchronized(this)
try {
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
// 使用synchronized的寫法
private synchronized void m2() {
System.out.println(Thread.currentThread().getName());
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m1, "t1").start();
new Thread(t::m2, "t2").start();
}
}
2. ReentrantLock獲取鎖的方法
2.1 嘗試鎖tryLock()
-
使用
tryLock()
方法可以嘗試獲得鎖,返回一個boolean
值,指示是否獲得鎖。 -
可以給
tryLock
方法傳入阻塞時長,當超出阻塞時長時,線程退出阻塞狀態轉而執行其他操作。
public class T {
ReentrantLock lock = new ReentrantLock();
void m() {
boolean isLocked = false; // 記錄是否得到鎖
// 改變下面兩個量的大小關係,觀察輸出
int synTime = 4; // 同步操作耗時
int waitTime = 2; // 獲取鎖的等待時間
try {
isLocked = lock.tryLock(waitTime, TimeUnit.SECONDS); // 線程在這裏阻塞waitTime秒,嘗試獲取鎖
if (isLocked) {
// 若waitTime秒內得到鎖,則執行同步操作
for (int i = 1; i <= synTime; i++) {
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "持有鎖,執行同步操作");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 使用tryLock()方法,嘗試解除標記時,一定要先判斷當前線程是否持有鎖
if (isLocked) {
lock.unlock();
}
}
// 執行非同步操作
while (true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "沒持有鎖,執行非同步操作");
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t::m, "線程1").start();
new Thread(t::m, "線程2").start();
}
}
-
設置同步操作耗時4秒,獲取鎖的等待時間爲2秒,輸出結果顯示線程2在阻塞時間內沒能搶到鎖,直接執行非阻塞方法。
線程1持有鎖,執行同步操作 線程1持有鎖,執行同步操作 線程2沒持有鎖,執行非同步操作 線程1持有鎖,執行同步操作 線程2沒持有鎖,執行非同步操作 線程1持有鎖,執行同步操作 線程2沒持有鎖,執行非同步操作 線程1沒持有鎖,執行非同步操作 線程2沒持有鎖,執行非同步操作 線程1沒持有鎖,執行非同步操作 ...
-
設置同步操作耗時4秒,獲取鎖的等待時間爲5秒,輸出結果顯示線程2在阻塞時間內成功搶到鎖,先執行完同步方法才執行非同步方法。
線程1持有鎖,執行同步操作 線程1持有鎖,執行同步操作 線程1持有鎖,執行同步操作 線程1持有鎖,執行同步操作 線程2持有鎖,執行同步操作 線程1沒持有鎖,執行非同步操作 線程2持有鎖,執行同步操作 線程1沒持有鎖,執行非同步操作 線程2持有鎖,執行同步操作 線程1沒持有鎖,執行非同步操作 線程2持有鎖,執行同步操作 線程1沒持有鎖,執行非同步操作 線程2沒持有鎖,執行非同步操作 ....
2.2 可中斷鎖lockInterruptibly()
使用lockInterruptibly()
以一種可被中斷的方式獲取鎖。獲取不到鎖時線程進入阻塞狀態,但這種阻塞狀態可以被中斷。調用被阻塞線程的interrupt()
方法可以中斷該線程的阻塞狀態,並拋出InterruptedException
異常。
interrupt()方法只能中斷線程的阻塞狀態.若某線程已經得到鎖或根本沒去嘗試獲得鎖,則該線程當前沒有處於阻塞狀態,因此不能被interrupt()方法中斷.
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
// 線程1一直佔用着lock鎖
new Thread(() -> {
lock.lock();
try {
System.out.println("線程1啓動");
TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);// 線程一直佔用鎖
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "線程1").start();
// 線程2搶不到lock鎖,若不被中斷則一直被阻塞
Thread t2 = new Thread(() -> {
try {
lock.lockInterruptibly(); // 嘗試獲取鎖,若獲取不到鎖則一直阻塞
System.out.println("線程2啓動");
} catch (InterruptedException e) {
System.out.println("線程2阻塞過程中被中斷");
} finally {
if (lock.isLocked()) {
try {
lock.unlock(); // 沒有鎖定進行unlock就會拋出IllegalMonitorStateException異常
} catch (Exception e) {
}
}
}
}, "線程2");
t2.start();
// 4秒後中斷線程2
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.interrupt();//告訴t2別等了,拋出異常
}
輸出:
線程1啓動
線程2阻塞過程中被中斷
並不是所有處於阻塞狀態的線程都可以被interrupt()
方法中斷,要看該線程處於具體的哪種阻塞狀態。阻塞狀態包括普通阻塞、等待隊列、鎖池隊列。
- 普通阻塞: 調用
sleep()
方法的線程處於普通阻塞,調用其interrupt()
方法可以中斷其阻塞狀態並拋出InterruptedException
異常。 - 等待隊列: 調用鎖的
wait()
方法將持有當前鎖的線程轉入等待隊列,這種阻塞狀態只能由鎖對象的notify()
方法喚醒,而不能被線程的interrupt()
方法中斷。 - 鎖池隊列: 嘗試獲取鎖但沒能成功搶到鎖的線程會進入鎖池隊列:
- 爭搶
synchronized
鎖的線程的阻塞狀態不能被中斷。 - 使用
ReentrantLock的lock()
方法爭搶鎖的線程的阻塞狀態不能被中斷。 - 使用
ReentrantLock
的tryLock()
和lockInterruptibly()
方法爭搶鎖的線程的阻塞狀態可以被中斷。
- 爭搶
關於interrupted()方法的使用,可以查看這篇文章Java中interrupt的使用,總結來說,就是interrupt()方法不能打斷線程,但是會給該線程發送一個interrupt信號,讓該線程自己決定如何處理該信號,但有一種特殊情況:若該線程正處於阻塞狀態,調用其interrupt()方法會拋出InterruptedException.
2.3 公平鎖
公平鎖:誰等的時間長,誰獲得鎖
在初始化ReentrantLock
時給其fair
參數傳入true
,可以指定該鎖爲公平鎖。默認的synchronized
爲非公平鎖。
CPU
默認的進程調度是不公平的,也就是說,CPU
不能保證等待時間較長的線程先被執行。但公平鎖可以保證等待時間較長的線程先被執行。
public class T implements Runnable {
private static ReentrantLock lock = new ReentrantLock(true);// 指定鎖爲公平鎖
@Override
public void run() {
for (int i = 0; i < 100; i++) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "持有鎖");
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
T t = new T();
new Thread(t, "線程1").start();
new Thread(t, "線程2").start();
}
}
程序輸出發現兩個線程嚴格交替執行。
四、ThreadLocal 線程局部變量
/*ThreadLocal是使用空間換時間,synchronized是使用時間換空間。
* 比如在Hibernate中的session就存在於ThreadLocal中,避免Synchronized的使用
* 線程局部變量屬於每個線程都有自己的,線程間不共享,互不影響*/
public class ThreadLocalTest {
static ThreadLocal<Person> tL = new ThreadLocal<>(); //每個線程的tL互不影響
public static void main(String[] args) {
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(tL.get());
}).start();
new Thread(()-> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
tL.set(new Person());
}).start();
}
static class Person {
String name = "zhangsan";
}
}
第二個線程設置了值,但是第一個線程get得到的是null
,說明線程局部變量是互不影響的。
整理自視頻:馬士兵老師java多線程高併發編程