前言
一個程序在運行起來的時候會轉換成進程,通常含有多個線程。通常情況下,一個進程中的比較耗時的操作(如長循環、文件上傳下載、網絡資源獲取等),往往會採用多線程來解決。
比如顯示生活中,銀行取錢問題、火車票多個售票窗口的問題,通常會涉及到並發的問題,從而需要多線程的技術。
當進程中有多個並發線程進入一個重要數據的代碼塊時,在修改數據的過程中,很有可能引發線程安全問題,從而造成數據異常。例如,正常邏輯下,同一個編號的火車票只能售出一次,卻由於線程安全問題而被多次售出,從而引起實際業務異常。
一般我們常說某某類是線程安全的,某某是非線程安全的。其實線程安全並不是一個 “非黑即白” 單項選擇題。按照 “線程安全” 的安全程度由強到弱來排序,我們可以將 java 語言中各種操作共享的數據分為以下 5 類:
-
不可變
在 java 語言中,不可變的對象一定是線程安全的,無論是對象的方法實現還是方法的調用者,都不需要再採取任何的線程安全保障措施。如 final 關鍵字修飾的數據不可修改,可靠性最高。
絕對線程安全
絕對的線程安全完全滿足 Brian GoetZ 給出的線程安全的定義,這個定義其實是很嚴格的,一個類要達到 “不管運行時環境如何,調用者都不需要任何額外的同步措施” 通常需要付出很大的代價。 -
相對線程安全
相對線程安全就是我們通常意義上所講的一個類是 “線程安全” 的。它需要保證對這個對象單獨的操作是線程安全的,我們在調用的時候不需要做額外的保障措施,但是對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正確性。
在 java 語言中,大部分的線程安全類都屬於相對線程安全的,例如 Vector、HashTable、Collections 的 synchronizedCollection () 方法保證的集合。 -
線程兼容
線程兼容就是我們通常意義上所講的一個類不是線程安全的。
線程兼容是指對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在並發環境下可以安全地使用。Java API 中大部分的類都是屬於線程兼容的。如與前面的 Vector 和 HashTable 相對應的集合類 ArrayList 和 HashMap 等。 -
線程對立
線程對立是指無論調用端是否採取了同步錯誤,都無法在多線程環境中並發使用的代碼。由於 java 語言天生就具有多線程特性,線程對立這種排斥多線程的代碼是很少出現的。
一個線程對立的例子是 Thread 類的 supend () 和 resume () 方法。如果有兩個線程同時持有一個線程對象,一個嘗試去中斷線程,另一個嘗試去恢復線程,如果並發進行的話,無論調用時是否進行了同步,目標線程都有死鎖風險。正因此如此,這兩個方法已經被廢棄啦。
提示:以下是本篇文章正文內容,下面案例可供參考
第一種實現線程安全的方式
同步代碼塊
public class ThreadSynchronizedSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
@Override
public void run() {
while (tickets > 0) {
// 同步代碼塊
synchronized (this) {
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "--->售出第: " + tickets + " 票");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
tickets--;
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "--->售票結束!");
}
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchronizedSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1號窗口");
Thread thread2 = new Thread(sell, "2號窗口");
Thread thread3 = new Thread(sell, "3號窗口");
Thread thread4 = new Thread(sell, "4號窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
備註:
在使用 synchronized 代碼塊時,可以與 wait ()、notify ()、nitifyAll () 一起使用,從而進一步實現線程的通信。其中 wait () 方法會釋放佔有的對象鎖,當前線程進入等待池,釋放 cpu,而其他正在等待的線程即可搶佔此鎖,獲得鎖的線程即可運行程序;線程的 sleep () 方法則表示,當前線程會休眠一段時間,休眠期間,會暫時釋放 cpu,但並不釋放對象鎖,也就是說,在休眠期間,其他線程依然無法進入被同步保護的代碼內部,當前線程休眠結束時,會重新獲得 cpu 執行權,從而執行被同步保護的代碼。wait () 和 sleep () 最大的不同在於 wait () 會釋放對象鎖,而 sleep () 不會釋放對象鎖。
notify () 方法會喚醒因為調用對象的 wait () 而處於等待狀態的線程,從而使得該線程有機會獲取對象鎖。調用 notify () 後,當前線程並不會立即釋放鎖,而是繼續執行當前代碼,直到 synchronized 中的代碼全部執行完畢,才會釋放對象鎖。JVM 會在等待的線程中調度一個線程去獲得對象鎖,執行代碼。
需要注意的是,wait () 和 notify () 必須在 synchronized 代碼塊中調用。notifyAll () 是喚醒所有等待的線程
接下來,我們通過下一個程序,使得兩個線程交替打印 “A” 和 “B” 各 10 次。請見下述實例代碼:
package com.my.annotate.thread;
public class ThreadDemo {
static final Object obj = new Object();
//第一個子線程
static class ThreadA implements Runnable {
@Override
public void run() {
int count = 5;
while (count > 0) {
synchronized (ThreadDemo.obj) {
System.out.println("A-----" + count);
count--;
synchronized (ThreadDemo.obj) {
//notify()方法會喚醒因為調用對象的wait()而處於等待狀態的線程,從而使得該線程有機會獲取對象鎖。
//調用notify()後,當前線程並不會立即釋放鎖,而是繼續執行當前代碼,直到synchronized中的代碼全部執行完畢,
ThreadDemo.obj.notify();
try {
ThreadDemo.obj.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
}
}
static class ThreadB implements Runnable {
@Override
public void run() {
int count = 5;
while (count > 0) {
synchronized (ThreadDemo.obj) {
System.out.println("B-----" + count);
count--;
synchronized (ThreadDemo.obj) {
//notify()方法會喚醒因為調用對象的wait()而處於等待狀態的線程,從而使得該線程有機會獲取對象鎖。
//調用notify()後,當前線程並不會立即釋放鎖,而是繼續執行當前代碼,直到synchronized中的代碼全部執行完畢,
ThreadDemo.obj.notify();
try {
ThreadDemo.obj.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
}
}
public static void main(String[] args) {
new Thread(new ThreadB()).start();
new Thread(new ThreadA()).start();
}
}
第二種實現線程安全的方式
同步方法
package com.my.annotate.thread;
public class ThreadSynchroniazedMethodSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
@Override
public void run() {
//同步方法
while (tickets > 0) {
synMethod();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "--->售票結束");
}
}
}
synchronized void synMethod() {
synchronized (this) {
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "---->售出第 " + tickets + " 票 ");
tickets--;
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchroniazedMethodSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1號窗口");
Thread thread2 = new Thread(sell, "2號窗口");
Thread thread3 = new Thread(sell, "3號窗口");
Thread thread4 = new Thread(sell, "4號窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
第三種實現線程安全的方式
Lock 鎖機制,通過創建 Lock 對象,採用 lock () 加鎖,unlock () 解鎖,來保護指定的代碼塊
package com.my.annotate.thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadLockSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
Lock lock = new ReentrantLock();
@Override
public void run() {
// Lock鎖機制
while (tickets > 0) {
try {
lock.lock();
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "--->售出第: " + tickets + " 票");
tickets--;
} catch (Exception e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
} finally {
lock.unlock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "--->售票結束!");
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadLockSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "1號窗口");
Thread thread2 = new Thread(sell, "2號窗口");
Thread thread3 = new Thread(sell, "3號窗口");
Thread thread4 = new Thread(sell, "4號窗口");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
由於 synchronized 是在 JVM 層面實現的,因此系統可以監控鎖的釋放與否;而 ReentrantLock 是使用代碼實現的,系統無法自動釋放鎖,需要在代碼中的 finally 子句中顯式釋放鎖 lock.unlock ()。另外,在並發量比較小的情況下,使用 synchronized 是一個不錯的選擇;但是在並發量比較高的情況下,其性能下降會很嚴重,此時 ReentrantLock 是一個不錯的方案。
二、線程安全的實現方法
保證線程安全以是否需要同步手段分類,分為同步方案和無需同步方案。
1、互斥同步
互斥同步是最常見的一種並發正確性保障手段。同步是指在多線程並發訪問共享數據時,保證共享數據在同一時刻只被一個線程使用(同一時刻,只有一個線程在操作共享數據)。而互斥是實現同步的一種手段,臨界區、互斥量和信號量都是主要的互斥實現方式。因此,在這 4 個字裡面,互斥是因,同步是果;互斥是方法,同步是目的。
在 java 中,最基本的互斥同步手段就是 synchronized 關鍵字,synchronized 關鍵字編譯之後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 這兩個字節碼質量,這兩個字節碼指令都需要一個 reference 類型的參數來指明要鎖定和解鎖的對象。
此外,ReentrantLock 也是通過互斥來實現同步。在基本用法上,ReentrantLock 與 synchronized 很相似,他們都具備一樣的線程重入特性。
互斥同步最主要的問題就是進行線程阻塞和喚醒所帶來的性能問題,因此這種同步也成為阻塞同步。從處理問題的方式上說,互斥同步屬於一種悲觀的並發策略,總是認為只要不去做正確地同步措施(例如加鎖),那就肯定會出現問題,無論共享數據是否真的會出現競爭,它都要進行加鎖。
2、非阻塞同步
隨著硬件指令集的發展,出現了基於衝突檢測的樂觀並發策略,通俗地說,就是先進行操作,如果沒有其他線程爭用共享數據,那操作就成功了;如果共享數據有爭用,產生了衝突,那就再採用其他的補償措施。(最常見的補償錯誤就是不斷地重試,直到成功為止),這種樂觀的並發策略的許多實現都不需要把線程掛起,因此這種同步操作稱為非阻塞同步。
非阻塞的實現 CAS(compareandswap):CAS 指令需要有 3 個操作數,分別是內存地址(在 java 中理解為變量的內存地址,用 V 表示)、舊的預期值(用 A 表示)和新值(用 B 表示)。CAS 指令執行時,CAS 指令指令時,當且僅當 V 處的值符合舊預期值 A 時,處理器用 B 更新 V 處的值,否則它就不執行更新,但是無論是否更新了 V 處的值,都会返回 V 的舊值,上述的處理過程是一個原子操作。
CAS 缺點:
ABA 問題:因為 CAS 需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是一個值原來是 A,變成了 B,又變成了 A,那麼使用 CAS 進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。
ABA 問題的解決思路就是使用版本號。在變量前面追加版本號,每次變量更新的時候把版本號加一,那麼 A-B-A 就變成了 1A-2B-3C。JDK 的 atomic 包裡提供了一個類 AtomicStampedReference 來解決 ABA 問題。這個類的 compareAndSet 方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置為給定的更新值。
3、無需同步方案
要保證線程安全,並不是一定就要進行同步,兩者沒有因果關係。同步只是保證共享數據爭用時的正確性的手段,如果一個方法本來就不涉及共享數據,那它自然就無需任何同步操作去保證正確性,因此會有一些代碼天生就是線程安全的。
1)可重入代碼
可重入代碼(ReentrantCode)也稱為純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼,而在控制權返回後,原來的程序不會出現任何錯誤。所有的可重入代碼都是線程安全的,但是並非所有的線程安全的代碼都是可重入的。
可重入代碼的特點是不依賴存儲在堆上的數據和公用的系統資源、用到的狀態量都是由參數中傳入、不調用 非可重入的方法等。
(類比:synchronized 擁有鎖重入的功能,也就是在使用 synchronized 時,當一個線程得到一個對象鎖後,再次請求此對象鎖時是可以再次得到該對象的鎖)
2)線程本地存儲
如果一段代碼中所需的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行?如果能保證,我們就可以把共享數據的可見範圍限制在同一個線程之內。這樣無需同步也能保證線程之間不出現數據的爭用問題。
符合這種特點的應用並不少見,大部分使用消費隊列的架構模式(如 “生產者 - 消費者” 模式)都會將產品的消費過程儘量在一個線程中消費完。其中最重要的一個應用實例就是經典的 Web 交互模型中的 “一個請求對應一個服務器線程(Thread-per-Request)” 的處理方式,這種處理方式的廣泛應用使得很多 Web 服務器應用都可以使用線程本地存儲來解決線程安全問題。
三、線程的生命周期以及五種基本狀態
Java 線程具有五中基本狀態:
新建狀態(New):當線程對象創建後就是進入到了新建狀態,如:Thread t = new MyThread ();
就緒狀態(Runnable):當調用線程對象的 start () 方法,線程即進入到了就緒狀態。處於就緒狀態的線程,只是說明此線程已經做好了準備,隨時等待 CPU 調度執行,並不是執行了 start () 此線程就會執行。
運行狀態(Running):當 CPU 調度處於就緒狀態的線程的時候,此時線程才會得以真正的執行,即進入運行狀態。
注:就緒狀態是進入運行狀態的唯一入口,也就是說線程進入運行狀態的前提是已經進入到了就緒狀態。
阻塞狀態(Blocked):處於運行狀態的線程由於某種原因,暫時放棄對 CPU 的使用權,停止執行,此時進入阻塞狀態,知道進入到就緒狀態,才有機會再次被 CPU 調用以進入到運行狀態,根據產生阻塞狀態的三原因,阻塞狀態可以分為三種:
等待阻塞 --》運行狀態的線程執行 wait () 方法,使線程進入到阻塞狀態
同步阻塞 --》線程獲取同步鎖失敗,因為同步鎖被其他線程所佔用,這時線程就會進入同步阻塞狀態;
其他阻塞 --》通過調用線程的 sleep () 或 join () 或發出了 I/O 請求的時候線程會進入阻塞狀態,當 sleep () 狀態超時,join () 等待線程終止或者超時,或者 I/O 處理完畢,線程就會重新轉入就緒狀態。
死亡狀態(Dead):線程執行完了或者因一場退出了 run () 方法,該線程就結束了生命周期。
參考代碼見Gitee