同步機制簡介
線程同步機制是一套用於協調線程之間的數據訪問的機制。該機制可以保障線程安全。Java平台提供的線程同步機制包括: 鎖,volatile關鍵字,final關鍵字,static關鍵字,以及相關的API,如Object.wait()/Object.notify()等
鎖
線程安全問題的產生前提是多個線程並發訪問共享數據。
將多個線程對共享數據的並發訪問轉換為串行訪問,即一個共享數據一次只能被一個線程訪問。鎖就是用這種思路來保障線程安全的。鎖(Lock)可以理解為對共享數據進行保護的一個許可證,對於同一個許可證保護的共享數據來說,任何線程想要訪問這些共享數據必須先持有該許可證。一個線程只有在持有許可證的情況下才能對這些共享數據進行訪問,並且一個許可證一次只能被一個線程持有。
線程在結束對共享數據的訪問后必須釋放其持有的許可證。
一線程在訪問共享數據前必須先獲得鎖,獲得鎖的線程稱為鎖的持有線程。一個鎖一次只能被一個線程持有,鎖的持有線程在獲得鎖之后和釋放鎖之前這段時間所執行的代碼稱為臨界區(CriticalSection)。鎖具有排他性(Exclusive),即一個鎖一次只能被一個線程持有,這種鎖稱為排它鎖或互斥鎖(Mutex)
JVM把鎖分為內部鎖和顯示鎖兩種,內部鎖通過synchronized
關鍵字實現;顯示鎖通過java.concurrent.locks.Lock
接口的實現類實現
鎖的作用
鎖可以實現對共享數據的安全訪問。保障線程的原子性,可見性與有序性。
-
鎖是通過互斥保障原子性。一個鎖只能被一個線程持有,這就保證臨界區的代碼一次只能被一個線程執行。使得臨界區代碼所執行的操作自然而然的具有不可分割的特性,即具備了原子性。
-
可見性的保障是通過寫線程沖刷處理器緩存和讀線程刷新處理器緩存這兩個動作實現的。在java平台中,鎖的獲得隱含着刷新處理器緩存的動作,鎖的釋放隱含着沖刷處理器緩存的動作。
-
鎖能夠保障有序性,寫線程在臨界區所執行的代碼在讀線程所執行的臨界區看來像是完全按照源碼順序執行的。
使用鎖保障線程的安全性,必須滿足以下條件:這些線程在訪問共享數據時必須使用同一個鎖即使是讀取共享數據的線程也需要使用同步鎖
鎖相關的概念
- 可重入性:可重入性(Reentrancy)描述這樣一個問題,一個線程持有該鎖的時候能再次(多次)申請該鎖。如果一個線程持有一個鎖的時候還能夠繼續成功申請該鎖,稱該鎖是可重入的,否則就稱該鎖為不可重入的
- 鎖的征用與調度:Java平台中內部鎖屬於非公平鎖,顯示Lock鎖既支持公平鎖又支持非公平鎖,后續展開講
- 鎖的粒度:一個鎖可以保護的共享數據的數量大小稱為鎖的粒度,鎖保護共享數據量大,稱該鎖的粒度粗,否則就稱該鎖的粒度細。鎖的粒度過粗會導致線程在申請鎖時會進行不必要的等待,鎖的粒度過細會導致頻繁調用鎖,增加鎖調度的開銷。
內部鎖 synchronized
任意對象都可以作為同步鎖。所有對象都自動含有單一的鎖(監視器)。
同步方法的鎖:
- 靜態方法(
類名.class
) - 非靜態方法(
this
) - 同步代碼塊:自己指定,很多時候也是指定為
this
或類名.class
或者用常量
- 必須確保使用同一個資源的多個線程共用一把鎖,這個非常重要,否則就無法保證共享資源的安全
- 一個線程類中的所有靜態方法共用同一把鎖(類名.class),所有非靜態方法共用同一把鎖(this),同步代碼塊指定需謹慎
同步的范圍
1、如何找問題,即代碼是否存在線程安全問題?(非常重要)
(1)明確哪些代碼是多線程運行的代碼
(2)明確多個線程是否有共享數據
(3)明確多線程運行代碼中是否有多條語句操作共享數據
2、如何解決呢?(非常重要)
對多條操作共享數據的語句,只能讓一個線程都執行完,在執行過程中,其他線程不可以參與執行。即所有操作共享數據的這些語句都要放在同步范圍中
3、切記
范圍太小:沒鎖住所有有安全問題的代碼
范圍太大:沒發揮多線程的功能。
鎖的釋放
釋放鎖的操作
- 當前線程的同步方法、同步代碼塊執行結束。
- 當前線程在同步代碼塊、同步方法中遇到break、return終止了該代碼塊、該方法的繼續執行。
- 當前線程在同步代碼塊、同步方法中出現了未處理的Error或Exception,導致異常結束。
- 當前線程在同步代碼塊、同步方法中執行了線程對象的wait()方法,當前線程暫停,並釋放鎖。
不會釋放鎖的操作
- 線程執行同步代碼塊或同步方法時,程序調用Thread.sleep()、Thread.yield()方法暫停當前線程的執行
- 線程執行同步代碼塊時,其他線程調用了該線程的suspend()方法將該線程掛起,該線程不會釋放鎖(同步監視器)。
- 應盡量避免使用suspend()和resume()來控制線程
- 同步過程中線程出現異常,會自動釋放鎖對象
示例
public class SynchonizedTest {
public static void main(String[] args) {
SynchonizedTest test = new SynchonizedTest();
SynchonizedTest test2 = new SynchonizedTest();
new Thread(new Runnable() {
@Override
public void run() {
test.add();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
// test.add(); //同一把鎖,線程同步
test2.add(); //不是同一把鎖,無法同步
}
}).start();
}
public void add(){
synchronized (this) { //鎖為this對象
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "-->" + i);
}
}
}
}
死鎖
什么是死鎖
不同的線程分別占用對方需要的同步資源不放棄,都在等待對方放棄自己需要的同步資源,就形成了線程的死鎖
出現死鎖后,不會出現異常,不會出現提示,只是所有的線程都處於阻塞狀態,無法繼續
出現死鎖的原因
在多線程程序中,同步時可能需要使用多個鎖,如果獲得鎖的順序不一致,可能會導致死鎖
如何避免
當需要獲得多個鎖時,所有線程獲得鎖的順序保持一致即可
示例
public class DeadLock {
public static void main(String[] args) {
SubThread thread1 = new SubThread();
thread1.setName("a");
thread1.start();
SubThread thread2 = new SubThread();
thread2.setName("b");
thread2.start();
}
static class SubThread extends Thread{
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
@Override
public void run() {
if ("a".equals(Thread.currentThread().getName())) {
synchronized (lock1) {
System.out.println("a線程獲得了lock1,還需要獲取lock2");
synchronized (lock2) {
System.out.println("a線程獲得了lock1和lock2");
}
}
}
if ("b".equals(Thread.currentThread().getName())) {
synchronized (lock2) {
System.out.println("b線程獲得了lock2,還需要獲取lock1");
synchronized (lock1) {
System.out.println("b線程獲得了lock1和lock2");
}
}
}
}
}
}
輸出結果:
a線程獲得了lock1,還需要獲取lock2
b線程獲得了lock2,還需要獲取lock1
上面的例子出現了死鎖,線程a獲得了lock1,還需要lock2,而線程b獲得了lock2還需要lock1,彼此都需要對方占用的資源,由於沒有獲得所有資源導致獲得的鎖也不會釋放,造成死鎖。
若使線程a和線程b獲取鎖的順序一致則不會出現死鎖
public class DeadLock {
public static void main(String[] args) {
SubThread thread1 = new SubThread();
thread1.setName("a");
thread1.start();
SubThread thread2 = new SubThread();
thread2.setName("b");
thread2.start();
}
static class SubThread extends Thread{
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
@Override
public void run() {
if ("a".equals(Thread.currentThread().getName())) {
//線程a先獲取lock1再獲取lock2
synchronized (lock1) {
System.out.println("a線程獲得了lock1,還需要獲取lock2");
synchronized (lock2) {
System.out.println("a線程獲得了lock1和lock2");
}
}
}
if ("b".equals(Thread.currentThread().getName())) {
//線程b先獲取lock1再獲取lock2
synchronized (lock1) {
System.out.println("b線程獲得了lock2,還需要獲取lock1");
synchronized (lock2) {
System.out.println("b線程獲得了lock1和lock2");
}
}
}
}
}
}
輸出結果
a線程獲得了lock1,還需要獲取lock2
a線程獲得了lock1和lock2
b線程獲得了lock2,還需要獲取lock1
b線程獲得了lock1和lock2
輕量級同步機制volative
一旦一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾之后,那么就具備了兩層語義
- 保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,新值對其他線程來說是立即可見的。
- 禁止進行指令重排序。
保證可見性
1、什么是可見性?
在說volatile保證可見性之前,先來說說什么叫可見性。談到可見性,又不得不說JMM(java memory model)內存模型。JMM內存模型是邏輯上的划分,及並不是真實存在。Java線程之間的通信就由JMM控制。JMM的抽象示意圖如下:
如上圖所示,我們定義的共享變量,是存儲在主內存中的,也就是計算機的內存條中。線程A去操作共享變量的時候,並不能直接操作主內存中的值,而是將主內存中的值拷貝回自己的高速緩存中,修改后寫入到高速緩存,並沒有立即將值刷回到主存中,導致其余線程讀取的是修改之前的值
public class VolatileTest {
public static void main(String[] args) {
MyData myData = new MyData();
new Thread("AAA") {
@Override
public void run() {
try {
Thread.sleep(3000);
// 睡3秒后調用changeNumber方法將number改為60
System.err.println(Thread.currentThread().getName()
+ " update number to " + myData.changeNumber());
} catch (InterruptedException e) {
e.printStackTrace();
}
};
}.start();
// 主線程
while (myData.number == 0) {
}
// 如果主線程讀取到的一直都是最開始的0,
//將造成死循環,這句話將無法輸出
System.err.println(Thread.currentThread().getName()
+ " get number value is " + myData.number);
}
}
// 驗證可見性
class MyData {
// int number = 0; // 沒加volatile關鍵字
volatile int number = 0;
int changeNumber() {
return this.number = 60;
}
}
輸出結果:
//不加volatile關鍵字
AAA update number to 60
//加了volatile關鍵字
AAA update number to 60
main get number value is 60
那么為什么可以保證可見性呢?
volatile的原理和實現機制
前面講述了源於volatile關鍵字的一些使用,下面我們來探討一下volatile到底如何保證可見性和禁止指令重排序的。
下面這段話摘自《深入理解Java虛擬機》:
“觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的匯編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令”
lock前綴指令實際上相當於一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:
- 它確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
- 它會強制將對緩存的修改操作立即寫入主存;
- 如果是寫操作,它會導致其他CPU中對應的緩存行無效。
不保證原子性
1、什么叫原子性?
所謂原子性,就是說一個操作不可被分割或加塞,要么全部執行,要么全不執行。
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保證前面的線程都執行完
Thread.yield();
System.out.println(test.inc);
}
}
大家想一下這段程序的輸出結果是多少?也許有些朋友認為是10000。但是事實上運行它會發現每次運行結果都不一致,都是一個小於10000的數字。
2、volatile不保證原子性解析
java程序在運行時,JVM將java文件編譯成了class文件。我們使用javap命令對class文件進行反匯編,就可以查看到java編譯器生成的字節碼。最常見的 i++ 問題,其實 反匯編后是分三步進行的。
- 第一步:將i的初始值裝載進工作內存;
- 第二步:在自己的工資內存中進行自增操作;
- 第三步:將自己工作內存的值刷回到主內存。
我們知道線程的執行具有隨機性,假設現在i的初始值為0,有A和B兩個線程對其進行++操作。首先兩個線程將0拷貝到自己工作內存,當線程A在自己工作內存中進行了自增變成了1,還沒來得及把1刷回到主內存,這是B線程搶到CPU執行權了。B將自己工作內存中的0進行自增,也變成了1。然后線程A將1刷回主內存,主內存此時變成了1,然后B也將1刷回主內存,主內存中的值還是1么兩個線程分別進行了一次自增操作后,inc只增加了1,出現了寫丟失的情況。這是因為i++本應該是一個原子操作,但是卻被加塞了其他操作。所以說volatile不保證原子性。
volatile能保證有序性嗎
什么叫指令重排?
使用javap命令可以對class文件進行反匯編,查看到程序底層到底是如何執行的。像 i++ 這樣一個簡單的操作,底層就分三步執行。在多線程情況下,計算機為了提高執行效率,就會對這些步驟進行重排序,這就叫指令重排
在前面提到volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。
volatile關鍵字禁止指令重排序有兩層意思:
1)當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對后面的操作可見;在其后面的操作肯定還沒有進行;
2)在進行指令優化時,不能將在對volatile變量訪問的語句放在其后面執行,也不能把volatile變量后面的語句放到其前面執行。
可能上面說的比較繞,舉個簡單的例子:
//x、y為非volatile變量
//flag為volatile變量
x = 2; //語句1
y = 0; //語句2
flag = true; //語句3
x = 4; //語句4
y = -1; //語句5
由於flag變量為volatile變量,那么在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5后面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。
並且volatile關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。
那么我們回到前面舉的一個例子:
//線程1:
context = loadContext(); //語句1
inited = true; //語句2
//線程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
這個例子有可能語句2會在語句1之前執行,就可能導致context還沒被初始化,而線程2中就使用未初始化的context去進行操作,導致程序出錯。
這里如果用volatile關鍵字對inited變量進行修飾,就不會出現這種問題了,因為當執行到語句2時,必定能保證context已經初始化完畢。
使用volatile關鍵字的場景
synchronized關鍵字是防止多個線程同時執行一段代碼,那么就會很影響程序執行效率,而volatile關鍵字在某些情況下性能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因為volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件:
-
對變量的寫操作不依賴於當前值
-
該變量沒有包含在具有其他變量的不變式中
實際上,這些條件表明,可以被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。
事實上,我的理解就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程序在並發時能夠正確執行。
下面列舉幾個Java中使用volatile的幾個場景。
1.狀態標記量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
volatile boolean inited = false;
//線程1:
context = loadContext();
inited = true;
//線程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
2.double check
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
為什么要這樣寫:傳送門