多執行緒的同步
為什麼引入同步機制
多執行緒為什麼要採用同步機制,因為不同的執行緒有自己的棧,棧中可能引用了多個物件,而多個執行緒可能引用到了堆中的同一個或多個物件,而執行緒的棧記憶體當中的資料只是臨時資料,最終都是要重新整理到堆中的物件記憶體,這裡的重新整理並不是最終的狀態一次性重新整理,而是在程式執行的過程中隨時重新整理(肯定有固定的機制,暫不考慮),也許在一個執行緒中被應用物件中的某一個方法執行到一半的時候就將該物件的變數狀態重新整理到了堆的物件記憶體中,那麼再從多執行緒角度來看,當多個執行緒對同一個物件中的同一個變數進行讀寫的時候,就會出現類似資料庫中的併發問題。
假設銀行裡某一使用者賬戶有1000元,執行緒A讀取到1000,並想取出這1000元,並且在棧中修改成了0但還沒有重新整理到堆中,執行緒B也讀取到1000,此時賬戶重新整理到銀行系統中,則賬戶的錢變成了0,這個時候也想去除1000,再次重新整理到行系統中,賬號的錢變成0,這個時候A,B都取出1000元,但是賬戶只有1000,顯然出現了問題。針對上述問題,假設我們新增了同步機制,那麼就可以很容易的解決。
怎樣解決這種問題呢,線上程使用一個資源時為其加鎖即可。訪問資源的第一個執行緒為其加上鎖以後,其他執行緒便不能再使用那個資源,除非被解鎖。
程式碼:
package com.java.test;
/**
* Created by xiaofandiy03 on 2018/4/14.
*/
public class DrawMoneyTest {
public static void main(String[] args)
{
Bank bank = new Bank();
Thread t1 = new MoneyThread(bank);// 從銀行取錢
Thread t2 = new MoneyThread(bank);// 從取款機取錢
t1.start();
t2.start();
}
}
class Bank{
private int money =1000;
public int getMoney(int number)
{
if(number <0)
{
return -1;
}else if(number >money) {
return -2;
}else if(money <0)
{
return -3;
}else {
try {
Thread.sleep(1000);
}catch (InterruptedException e)
{
e.printStackTrace();
}
}
money-=number;
System.out.println("Left Money :" +money);
return number;
}
}
class MoneyThread extends Thread
{
private Bank bank;
public MoneyThread(Bank bank)
{
this.bank = bank;
}
@Override
public void run()
{
System.out.println(bank.getMoney(1000));
}
}複製程式碼
怎麼解決這種問題呢,解決的方案是加鎖。
你想要進行對一組加鎖的程式碼進行操作嗎?想的話就先拿到鎖,拿到鎖之後就可以操作被加鎖的程式碼,倘若拿不到鎖的話就只能等著,因為等的執行緒太多了,這就是執行緒的阻塞。
競態條件和記憶體可見性
競態條件
當多執行緒訪問和操作同一物件的時候計算的正確性取決於多個執行緒的交替執行時序時,就會發生競態條件
最常見的競態條件為:
- 先檢測後執行。執行依賴於檢測的結果,而檢測結果依賴於多個執行緒的執行時序,而多個執行緒的執行時序通常情況下是不固定不可判斷的,從而導致執行結果出現各種問題。
- 延遲初始化(最典型即為單例)
上文中說到的加鎖就是為了解決這個問題,常見的解決方案有:
- 使用synchronized關鍵字
- 使用顯式鎖(Lock)
- 使用原子變數
記憶體可見性
關於記憶體可見性問題要先從記憶體和cpu的配合談起,記憶體是一個硬體,執行速度比CPU慢幾百倍,所以在計算機中,CPU在執行運算的時候,不會每次運算都和記憶體進行資料互動,而是先把一些資料寫入CPU中的快取區(暫存器和各級快取),在結束之後寫入記憶體。這個過程是及其快的,單執行緒下並沒有任何問題。
但是在多執行緒下就出現了問題,一個執行緒對記憶體中的一個資料做出了修改,但是並沒有及時寫入記憶體(暫時存放在快取中);這時候另一個執行緒對同樣的資料進行修改的時候拿到的就是記憶體中還沒有被修改的資料,也就是說一個執行緒對一個共享變數的修改,另一個執行緒不能馬上看到,甚至永遠看不到。
這就是記憶體的可見性問題。
解決這個問題的常見方法是:
- 使用volatile關鍵字
- 使用synchronized關鍵字或顯式鎖同步
執行緒同步方法
同步方法:
即有synchronized關鍵字修飾方法。悠悠java每個物件都有一個內建鎖,放用關鍵字修飾方法時,內建所會保護整個方法。在呼叫該方法錢,獲得內建鎖,否則就處於阻塞狀態。
class Bank{
private int money =1000;
public synchronized int getMoney(int number)
{
if(number <0)
{
return -1;
}else if(number >money) {
return -2;
}else if(money <0)
{
return -3;
}else {
try {
Thread.sleep(1000);
}catch (InterruptedException e)
{
e.printStackTrace();
}
}
money-=number;
System.out.println("Left Money :" +money);
return number;
}
}複製程式碼
synchronized關鍵字也可以修飾靜態方法,此時如果呼叫該靜態方法,將會鎖住整個類
同步程式碼塊
即有synchronized關鍵字修飾的語句塊。被該關鍵字修飾的語句塊會自動被加上內建鎖,從而實現同步
class Bank{
private int money =1000;
public int getMoney(int number)
{
synchronized (this) {
if (number < 0) {
return -1;
} else if (number > money) {
return -2;
} else if (money < 0) {
return -3;
} else {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
money -= number;
}
System.out.println("Left Money :" +money);
return number;
}
}
複製程式碼
同步是一種高開銷的操作,因此應該儘量減少同步的內容。通常沒有必要同步整個方法,使用synchronized程式碼塊同步關鍵程式碼即可。
使用重入鎖實現執行緒同步
在JavaSE5.0中新增了一個java.util.concurrent包來支援同步。ReentrantLock類是可重入、互斥、實現了Lock介面的鎖, 它與使用synchronized方法和快具有相同的基本行為和語義,並且擴充套件了其能力。
ReenreantLock類的常用方法有:
ReentrantLock() : 建立一個ReentrantLock例項
lock() : 獲得鎖
unlock() : 釋放鎖
注:ReentrantLock()還有一個可以建立公平鎖的構造方法,但由於能大幅度降低程式執行效率,不推薦使用
eentrantLock具有和synchronized相似的作用,但是更加的靈活和強大。
它是一個重入鎖(synchronized也是),所謂重入就是可以重複進入同一個函式,這有什麼用呢?
假設一種場景,一個遞迴函式,如果一個函式的鎖只允許進入一次,那麼執行緒在需要遞迴呼叫函式的時候,應該怎麼辦?退無可退,有不能重複進入加鎖的函式,也就形成了一種新的死鎖。
重入鎖的出現就解決了這個問題,實現重入的方法也很簡單,就是給鎖新增一個計數器,一個執行緒拿到鎖之後,每次拿鎖都會計數器加1,每次釋放減1,如果等於0那麼就是真正的釋放了鎖。
volatile 關鍵字
當一個共享變數被volatile修飾的時候,他會保證變數被修改之後立馬在記憶體中更新,另一執行緒在取值時候需要去記憶體中讀取新的值。
volatile可以保證變數的記憶體可見性,但是不能保證原子性,對於b++這個操作來說,並不是一步到位的,而是分好幾步的,讀取白那兩,定義常量1,變數b加1,結果同步到記憶體。雖然在每一步中獲取的都是變數的最新值,但是沒有保證b++的原子性,自然無法做到執行緒安全。
使用區域性變數實現執行緒同步
如果使用ThreadLocal管理變數,則每一個使用該變數的執行緒都獲得該變數的副本,副本之間相互獨立,這樣每一個執行緒都可以隨意修改自己的變數副本,而不會對其他執行緒產生影響。現在明白了吧,原來每個執行緒執行的都是一個副本,也就是說存錢和取錢是兩個賬戶,知識名字相同而已。所以就會發生上面的效果。
a.ThreadLocal與同步機制都是為了解決多執行緒中相同變數的訪問衝突問題