本文目錄:1.幾個基本的概念2.創建線程的兩種方法3.線程相關的常用方法4.多線程安全問題和線程同步 4.1 多線程安全問題 4.2 線程同步 4.3 同步代碼塊和同步函數的區別以及鎖是什麼 4.4 單例懶漢模式的多線程安全問題5.死鎖(DeadLock) 1.幾個基本的概念 本文涉及到的一些概念, ...
本文目錄:
1.幾個基本的概念
2.創建線程的兩種方法
3.線程相關的常用方法
4.多線程安全問題和線程同步
4.1 多線程安全問題
4.2 線程同步
4.3 同步代碼塊和同步函數的區別以及鎖是什麼
4.4 單例懶漢模式的多線程安全問題
5.死鎖(DeadLock)
1.幾個基本的概念
本文涉及到的一些概念,有些是基礎知識,有些在後文會展開詳細的說明。
- 進程(Process):一個程式運行起來時在記憶體中開闢一段空間用來運行程式,這段空間包括heap、stack、data segment和code segment。例如,開一個QQ就表明開了一個QQ進程。
- 線程(Thread):每一個進程中都至少有一個線程。線程是指程式中代碼運行時的運行路徑,一個線程表示一條路徑。例如QQ進程中,發送消息、接收消息、接收文件、發送文件等各種獨立的功能都需要一個線程來執行。
- 進程和線程的區別:從資源的角度來考慮,進程主要考慮的是CPU和記憶體,而線程主要考慮的是CPU的調度,某進程中的各線程之間可以共用這個進程的很多資源。
從粒度粗細來考慮,進程的粒度較粗,進程上下文切換時消耗的CPU資源較多。線程的粒度要小的多,雖然線程也會切換,但因為共用進程的上下文,相比進程上下文切換而言,同進程內的線程切換時消耗的資源要小的多的多。在JAVA中,除了java運行時啟動的JVM是一個進程,其他所有任務都以線程的方式執行,也就是說java應用程式是單進程的,甚至可以說沒有進程的概念。 - 線程組(ThreadGroup):線程組提供了一些批量管理線程的方法,因此通過將線程加入到線程組中,可以更方便地管理這些線程。
- 線程的狀態:就緒態、運行態、睡眠態。還可以分為存活和死亡,死亡表示線程結束,非死亡則存活,因此存活包含就緒、運行、睡眠。
- 中斷睡眠(interrupt):將線程從睡眠態強制喚醒,喚醒後線程將進入就緒隊列等待cpu調度。
- 併發操作:多個線程同時操作一個資源。這會帶來多線程安全問題,解決方法是使用線程同步。
- 線程同步:讓線程中的某些任務原子化,即要麼全部執行完畢,要麼不開始執行。通過互斥鎖來實現同步,通過監視這個互斥鎖是否被誰持有來決定是否從睡眠態轉為就緒態(即從線程池中出去),也就是是否有資格去獲取cpu的執行權。線程同步解決了線程安全的問題,但降低了程式的效率。
- 死鎖:線程全睡眠了無法被喚醒,導致程式卡死在某一處無法再執行下去。典型的是兩個同步線程,線程1持有A鎖,且等待B鎖,但線程2持有B鎖且等待A鎖,這樣的僵局會造成死鎖。但需要註意的是,死鎖並非都是因為僵局,只要兩邊的線程都無法繼續向下執行代碼(或者兩邊的線程池都無法被喚醒,這是等價的概念,因為鎖等待也會讓進程進入睡眠態),則都是死鎖。
還需需要明確的一個關鍵點是:CPU對就緒隊列中每個線程的調度是隨機的(對我們人類來說),且分配的時間片也是隨機的(對人類來說)。
2.創建線程的兩種方法
Java中有兩種創建線程的方式。
創建線程方式一:
- 繼承Thread類(在java.lang包中),並重寫該類的run()方法,其中run()方法即線程需要執行的任務代碼。
- 然後new出這個類對象。這表示創建線程對象。
- 調用start()方法開啟線程來執行任務(start()方法會調用run()以便執行任務)。
例如下麵的代碼中,在主線程main中創建了兩個線程對象,先後並先後調用start()開啟這兩個線程,這兩個線程會各自執行MyThread中的run()方法。
class MyThread extends Thread {
String name;
String gender;
MyThread(String name,String gender){
this.name = name;
this.gender = gender;
}
public void run(){
int i = 0;
while(i<=20) {
//除了主線程main,其餘線程從0開始編號,currentThread()獲取的是當前線程對象
System.out.println(Thread.currentThread().getName()+"-----"+i+"------"+name+"------"+gender);
i++;
}
}
}
public class CreateThread {
public static void main(String[] args) {
MyThread mt1 = new MyThread("malong","Male");
MyThread mt2 = new MyThread("Gaoxiao","Female");
mt1.start();
mt2.start();
System.out.println("main thread over");
}
}
上面的代碼執行時,有三個線程,首先是主線程main創建2個線程對象,並開啟這兩個線程任務,開啟兩個線程後主線程輸出"main thread over",然後main線程結束。在開啟兩個線程任務後,這兩個線程加入到了就緒隊列等待CPU的調度執行。如下圖。因為每個線程被cpu調度是隨機的,執行時間也是隨機的,所以即使mt1先開啟任務,但mt2可能會比mt1線程先執行,也可能更先消亡。
創建線程方式二:
- 實現Runnable介面,並重寫run()方法。
- 創建子類對象。
- 創建Thread對象來創建線程對象,並將實現了Runnable介面的對象作為參數傳遞給Thread()構造方法。
- 調用start()方法開啟線程來執行run()中的任務。
class MyThread implements Runnable {
String name;
String gender;
MyThread(String name,String gender){
this.name = name;
this.gender = gender;
}
public void run(){
int i = 0;
while(i<=200) {
System.out.println(Thread.currentThread().getName()+"-----"+i);
i++;
}
}
}
public class CreateThread2 {
public static void main(String[] args) {
//創建子類對象
MyThread mt = new MyThread("malong","Male");
//創建線程對象
Thread th1 = new Thread(mt);
Thread th2 = new Thread(mt);
th1.start();
th2.start();
System.out.println("main thread over");
}
}
這兩種創建線程的方法,無疑第二種(實現Runnable介面)要好一些,因為第一種創建方法繼承了Thread後就無法繼承其他父類。
3.線程相關的常用方法
Thread類中的方法:
isAlive()
:判斷線程是否還活著。活著的概念是指是否消亡了,對於運行態、就緒態、睡眠態的線程都是活著的狀態。currentThread()
:返回值為Thread,返回當前線程對象。getName()
:獲取當前線程的線程名稱。setName()
:設置線程名稱。給線程命名還可以使用構造方法Thread(String thread_name)
或Thread(Runnable r,String thread_name)
。getPriority()
:獲取線程優先順序。優先順序範圍值為1-10(預設值為5),相鄰值之間的差距對cpu調度的影響很小。一般使用3個欄位MIN_PRIORITY、NORM_PRIORITY、MAX_PRIORITY分別表示1、5、10三個優先順序,這三個優先順序可較大地區分cpu的調度。setPriority()
:設置線程優先順序。run()
:封裝的是線程開啟後要執行的任務代碼。如果run()中沒有任何代碼,則線程不做任何事情。start()
:開啟線程並讓線程開始執行run()中的任務。toString()
:返回線程的名稱、優先順序和線程組。sleep(long millis)
:讓線程睡眠多少毫秒。join(t1)
:將線程t1合併到當前線程,並等待線程t1執行完畢後才繼續執行當前線程。即讓t1線程強制插隊到當前線程的前面並等待t1完成。yield()
:將當前正在執行的線程退讓出去,以讓就緒隊列中的其他線程有更大的幾率被cpu調度。即強制自己放棄cpu,並將自己放入就緒隊列。由於自己也在就緒隊列中,所以即使此刻自己放棄了cpu,下一次還是可能會立即被cpu選中調度。但畢竟給了機會給其它就緒態線程,所以其他就緒態線程被選中的幾率要更大一些。
Object類中的方法:
wait()
:線程進入某個線程池中併進入睡眠態。等待notify()或notifyAll()的喚醒。notify()
:從某個線程池中隨機喚醒一個睡眠態的線程。notifyAll()
:喚醒某個線程池中所有的睡眠態線程。
這裡的某個線程池是由鎖對象決定的。持有相同鎖對象的線程屬於同一個線程池。見後文。
一般來說,wait()和喚醒的notify()或notifyAll()是成對出現的,否則很容易出現死鎖。
sleep()和wait()的區別:(1)所屬類不同:sleep()在Thread類中,wait()則是在Object中;(2)sleep()可以指定睡眠時間,wait()雖然也可以指定睡眠時間,但大多數時候都不會去指定;(3)sleep()不會拋異常,而wait()會拋異常;(4)sleep()可以在任何地方使用,而wait()必須在同步代碼塊或同步函數中使用;(5)最大的區別是sleep()睡眠時不會釋放鎖,不會進入特定的線程池,在睡眠時間結束後自動蘇醒並繼續往下執行任務,而wait()睡眠時會釋放鎖,進入線程池,等待notify()或notifyAll()的喚醒。
java.util.concurrent.locks包中的類和它們的方法:
-
Lock類中:
lock()
:獲取鎖(互斥鎖)。unlock()
:釋放鎖。newCondition()
:創建關聯此lock對象的Condition對象。
-
Condition類中:
await()
:和wait()一樣。signal()
:和notify()一樣。signalAll()
:和notifyAll()一樣。
4.多線程安全問題和線程同步
4.1 多線程安全問題
線程安全問題是指多線程同時執行時,對同一資源的併發操作會導致資源數據的混亂。
例如下麵是用多個線程(視窗)售票的代碼。
class Ticket implements Runnable {
private int num; //票的數量
Ticket(int num){
this.num = num;
}
//售票
public void sale() {
if(num>0) {
num--;
System.out.println(Thread.currentThread().getName()+"-------"+remain());
}
}
//獲取剩餘票數
public int remain() {
return num;
}
public void run(){
while(true) {
sale();
}
}
}
public class ConcurrentDemo {
public static void main(String[] args) {
Ticket t = new Ticket(100);
//創建多個線程對象
Thread t1 = new Thread(t);
Thread t2 = new Thread(t);
Thread t3 = new Thread(t);
Thread t4 = new Thread(t);
//開啟多個線程使其執行任務
t1.start();
t2.start();
t3.start();
t4.start();
}
}
執行結果大致如下:
以上代碼的執行過程大致如下圖:
共開啟了4個線程執行任務(不考慮main主線程),每一個線程都有4個任務:
- ①判斷if條件
if(num>0)
; - ②票數自減
num--
; - ③獲取剩餘票數
return num
; - ④列印返回的num數量
System.out.println(Thread.currentThread().getName()+"-------"+remain())
。
這四個任務的共同點也是關鍵點在於它們都操作同一個資源Ticket對象中的num,這是多線程出現安全問題的本質,也是分析多線程執行過程的切入點。
當main線程開啟t1-t4這4個線程時,它們首先進入就緒隊列等待被CPU隨機選中。(1).假如t1被先選中,分配的時間片執行到任務②就結束了,於是t1進入就緒隊列等待被CPU隨機選中,此時票數num自減後為99;(2).當t3被CPU選中時,t3所讀取到的num也為99,假如t3分配到的時間片在執行到任務②也結束了,此時票數num自減後為98;(3).同理t2被選中執行到任務②結束後,num為97;(4).此時t3又被選中了,於是可以執行任務③,甚至是任務④,假設執行完任務④時間片才結束,於是t3的列印語句列印出來的num結果為97;(5).t1又被選中了,於是任務④列印出來的num也為97。
顯然,上面的代碼有幾個問題:(1)有些票沒有賣出去了但是沒有記錄;(2)有的票重覆賣了。這就是線程安全問題。
4.2 線程同步
java中解決線程安全問題的方法是使用互斥鎖,也可稱之為"同步"。解決思路如下:
(1).為待執行的任務設定給定一把鎖,擁有相同鎖對象的線程在wait()時會進入同一個線程池睡眠。
(2).線程在執行這個設了鎖的任務時,首先判斷鎖是否空閑(即鎖處於釋放狀態),如果空閑則去持有這把鎖,只有持有這把鎖的線程才能執行這個任務。即使時間片到了,它也不是釋放鎖,只有wait()或線程結束時才會安全地釋放鎖。
(3).這樣一來,鎖被某個線程持有時,其他線程在鎖判斷後就繼續會線程池睡眠去了(或就緒隊列)。最終導致的結果是,(設計合理的情況下)某個線程一定完整地執行完一個任務,其他線程才有機會去持有鎖並執行任務。
換句話說,使用同步線程,可以保證線程執行的任務具有原子性,只要某個同步任務開始執行了就一定執行結束,且不允許其他線程參與。
讓線程同步的方式有兩種,一種是使用synchronized(){}
代碼塊,一種是使用synchronized關鍵字修飾待保證同步的方法。
class Ticket implements Runnable {
private int num; //初始化票的數量
private Object obj = new Object();
Ticket(int num){
this.num = num;
}
//售票
public void sale() {
synchronized(obj) { //使用同步代碼塊封裝需要保證原子性的代碼
if(num>0) {
num--;
System.out.println(Thread.currentThread().getName()+"-------"+remain());
}
}
}
//獲取剩餘票數
public int remain() {
return num;
}
public void run(){
while(true) {
sale();
}
}
}
class Ticket implements Runnable {
private int num; //初始化票的數量
Ticket(int num){
this.num = num;
}
public synchronized void sale() { //使用synchronized關鍵字,方法變為同步方法
if(num>0) {
num--;
System.out.println(Thread.currentThread().getName()+"-------"+remain());
}
}
//獲取剩餘票數
public int remain() {
return num;
}
public void run(){
while(true) {
sale();
}
}
}
使用同步之後,if(num>0)
、num--
、return num
和print(num)
這4個任務就強制具有原子性。某個線程只要開始執行了if語句,它就一定會繼續執行直到執行完print(num),才算完成了一整個任務。只有完成了一整個任務,線程才會釋放鎖(當然,也可能繼續判斷while(true)併進入下一個迴圈)。
4.3 同步代碼塊和同步函數的區別以及鎖是什麼
前面的示例中,同步代碼塊synchronized(obj){}中傳遞了一個obj的Object對象,這個obj可以是任意一個對象的引用,這些引用傳遞給代碼塊的作用是為了標識這個同步任務所屬的鎖。
而synchronized函數的本質其實是使用了this作為這個同步函數的鎖標識,this代表的是當前對象的引用。但如果同步函數是靜態的,即使用了static修飾,則此時this還沒出現,它使用的鎖是"類名.class"這個位元組碼文件對象,對於java來說,這也是一個對象,而且一個類中一定有這個對象。
使用相同的鎖之間會互斥,但不同鎖之間則沒有任何影響。因此,要保證任務同步(原子性),這些任務所關聯的鎖必須相同。也因此,如果有多個同步任務(各自保證自己的同步性),就一定不能都使用同步函數。
例如下麵的例子中,寫了兩個相同的sale()方法,並且使用了flag標記讓不同線程能執行這兩個同步任務。如果出現了多線程安全問題,則表明synchronized函數和同步代碼塊使用的是不同對象鎖。如果將同步代碼塊中的對象改為this後不出現多線程安全問題,則表明同步函數使用的是this對象。如果為sale2()加上靜態修飾static,則將obj替換為"Ticket.class"來測試。
class Ticket implements Runnable {
private int num; //初始化票的數量
boolean flag = true;
private Object obj = new Object();
Ticket(int num){
this.num = num;
}
//售票
public void sale1() {
synchronized(obj) { //使用的是obj標識鎖
if(num>0) {
num--;
try{Thread.sleep(1);} catch (InterruptedException i){} //為了確保num--和println()分開,加上sleep
System.out.println(Thread.currentThread().getName()+"===sale1==="+remain());
}
}
}
public synchronized void sale2() { //使用this標識鎖
if(num>0) {
num--;
try{Thread.sleep(1);} catch (InterruptedException i){}
System.out.println(Thread.currentThread().getName()+"===sale2==========="+remain());
}
}
//獲取剩餘票數
public int remain() {
return num;
}
public void run(){
if(flag){
while(true) {
sale1();
}
} else {
while(true) {
sale2();
}
}
}
}
public class Mytest {
public static void main(String[] args) {
Ticket t = new Ticket(200);
//創建多個線程對象
Thread t1 = new Thread(t);
Thread t2 = new Thread(t);
//開啟多個線程使其執行任務
t1.start();
try{Thread.sleep(1);} catch (InterruptedException i){}
t.flag = false;
t2.start();
}
}
以下是執行結果中的一小片段,出現了多線程安全問題。而如果將同步代碼塊中的obj改為this,則不會出現多線程安全問題。
Thread-0===sale1===197
Thread-1===sale2===========197
Thread-0===sale1===195
Thread-1===sale2===========195
Thread-1===sale2===========193
Thread-0===sale1===193
Thread-0===sale1===191
Thread-1===sale2===========191
4.4 單例懶漢模式的多線程安全問題
單例餓漢式:
class Single {
private static final Single s = new Single();
private Single(){};
public static Single getInstance() {
return s;
}
}
單例懶漢式:
class Single {
private static Single s = null;
private Single(){};
public static getInstance(){
if(s==null) {
s = new Single();
}
return s;
}
}
當多線程操作單例餓漢式和懶漢式對象的資源時,是否有多線程安全問題?
class Demo implements Runnable {
public void run(){
Single.getInstance();
}
}
以上面的代碼為例。當多線程分別被CPU調度時,餓漢式中的getInstance()返回的s,s是final屬性修飾的,因此隨便哪個線程訪問都是固定不變的。而懶漢式則隨著不同線程的來臨,不斷new Single()
,也就是說各個線程獲取到的對象s是不同的,存在多線程安全問題。
只需使用同步就可以解決懶漢式的多線程安全問題。例如使用同步方法。
class Single {
private static Single s = null;
private Single(){};
public static synchronized getInstance(){
if (s == null){
s = new Single();
}
return s;
}
}
這樣一來,每個線程來執行這個任務時,都將先判斷Single.class這個對象標識的鎖是否已經被其他線程持有。雖然解決了問題,但因為每個線程都額外地判斷一次鎖,導致效率有所下降。可以採用下麵的雙重判斷來解決這個效率降低問題。
class Single {
private static Single s = null;
private Single(){};
public static getInstance(){
if (s == null) {
synchronized(Single.class){
if (s == null){
s = new Single();
}
return s;
}
}
}
}
這樣一來,當第一個線程執行這個任務時,將判斷s==null
為true,於是執行同步代碼塊並持有鎖,保證任務的原子性。而且,即使在最初判斷s==null
後切換到其他線程了,也沒有關係,因為總有一個線程會執行到同步代碼塊並持有鎖,只要持有鎖了就一定執行s= new Single()
,在這之後,所有的線程在第一階段的"s==null"判斷都為false,從而提高效率。其實,雙重判斷的同步懶漢式的判斷次數和餓漢式的判斷次數幾乎相等。
5.死鎖(DeadLock)
最典型的死鎖是僵局問題,A等B,B等A,誰都不釋放,造成僵局,最後兩個線程都無法執行下去。
例如下麵的代碼示例,sale1()中,obj鎖需要持有this鎖才能完成任務整體,而sale2()中,this鎖需要持有obj鎖才能完成任務整體。當兩個線程都開始執行任務後,就開始產生死鎖問題。
class Ticket implements Runnable {
private int num;
boolean flag = true;
private Object obj = new Object();
Ticket(int num){
this.num = num;
}
public void sale1() {
synchronized(obj) { //obj鎖
sale2(); //this鎖
}
}
public synchronized void sale2() { //this鎖
synchronized(obj){ //obj鎖
if(num>0) {
num--;
try{Thread.sleep(1);} catch (InterruptedException i){}
System.out.println(Thread.currentThread().getName()+"========="+remain());
}
}
}
//獲取剩餘票數
public int remain() {
return num;
}
public void run(){
if(flag){
while(true) {
sale1();
}
} else {
while(true) {
sale2();
}
}
}
}
public class DeadLockDemo {
public static void main(String[] args) {
Ticket t = new Ticket(200);
//創建多個線程對象
Thread t1 = new Thread(t);
Thread t2 = new Thread(t);
//開啟多個線程使其執行任務
t1.start();
try{Thread.sleep(1);} catch (InterruptedException i){}
t.flag = false;
t2.start();
}
}
為了避免死鎖,儘量不要在同步中嵌套同步,因為這樣很容易造成死鎖。
註:若您覺得這篇文章還不錯請點擊右下角推薦,您的支持能激發作者更大的寫作熱情,非常感謝!