java面試題:多執行緒交替輸出偶數和奇數
一個面試題:實現兩個執行緒A,B交替輸出偶數和奇數
問題:建立兩個執行緒A和B,讓他們交替列印0到100的所有整數,其中A執行緒列印偶數,B執行緒列印奇數
這個問題配合java的多執行緒,很多種實現方式
在具體實現之前,首先介紹一下java併發程式設計中共享變數的可見性問題。
可見性問題:
在java記憶體模型(JMM,java Memory Model)中定義了程式中各種共享變數的訪問規則。
這裡的共享變數指的是可以線上程之間共享的變數,包括例項欄位,靜態欄位和構成陣列物件的元素。
不包括區域性變數和方法引數(這些都是在虛擬機器棧中,是執行緒私有的)
在java記憶體模型中,規定所有的變數都儲存在主記憶體中(JVM記憶體中的一個空間)。
此外,對於每個執行緒,都擁有自己的工作記憶體,在工作記憶體中儲存了該執行緒使用的共享變數的主記憶體副本(從主記憶體中拷貝過來的)。
每個執行緒只能在工作記憶體中對共享變數的副本進行操作(讀,賦值),不能直接讀寫主記憶體中的資料。
各個執行緒之間也無法訪問對方工作記憶體中的變數副本,所有的執行緒只能通過主記憶體來完成變數的值傳遞。
在java記憶體模型中,定義了8中原子操作來完成工作記憶體與主記憶體之間的拷貝與同步。
這裡重點關注一下,兩個執行緒同時讀取與修改同一個共享變數的問題。
當我們建立了一個靜態變數之後,它就會被儲存在主記憶體中。如果有兩個執行緒A,B要訪問這個靜態變數並對其進行修改,執行緒會讀取(read操作
read操作和load操作必須按順序執行,store操作和write操作也必須按順序執行。
但是這裡存在一個問題,即變數的可見性,read/load和store/write雖然是按順序執行,但卻不是連續執行的,也就是說工作記憶體中的變數值在修改完並複製給工作記憶體中的變數後,並不是立即執行store/write操作的,這就導致主記憶體中的變數值無法實時的得到更新。這時候如果另一個執行緒要讀取主記憶體中該變數的值,仍然是舊值,無法讀取到新值。只有在回寫完成,才能在主記憶體中讀取到新的值。
這裡我們用一個例子來展示變數的可見性問題,使用錯誤的方法來實現兩個執行緒交替輸出偶數和奇數
方案1:使用自旋檢查(迴圈檢查)來實現執行緒交替輸出
/*
定義兩個執行緒A和B,讓兩個執行緒按順序交替輸出偶數和奇數(A輸出偶數,B輸出奇數)
*/
public class ThreadNum {
public static int flag = 0; //定義一個靜態全域性變數,作為標誌位
public static void main(String[] args) {
Thread r1 = new Thread( //執行緒1用來輸出偶數
()->{
while(flag<=100){
while(flag%2==1&&flag<=100); //迴圈判斷,如果flag是偶數就跳出迴圈去flag
System.out.println(Thread.currentThread().getName()+"列印:"+flag);
flag++;//自增1,flag變成奇數
}
}
);
Thread r2 = new Thread(//執行緒B用來輸出奇數
()->{
while(flag<100){
while(flag%2==0&&flag<100);//迴圈判斷,如果flag為奇數就跳出迴圈去列印flag
System.out.println(Thread.currentThread().getName()+"列印:"+flag);
flag++; //自增1,flag變成偶數
}
}
);
r1.setName("執行緒A");
r2.setName("執行緒B");
r1.start();
r2.start();
}
}
/*
程式執行結果:
執行緒A列印:0
執行緒B列印:1
執行緒A列印:2
執行緒B列印:3
執行緒A列印:4
執行緒B列印:5
在這裡死迴圈,無法繼續列印
*/
這個程式在執行時可能會死迴圈,兩個執行緒會在while(flag%2==0&&flag<=100);
和while(flag%2==1&&flag<100);
這裡死迴圈。分析一下原因:
靜態變數flag是一個普通變數,無法保證對所有的執行緒的可見性。
所以當執行緒B在打印出flag的值5之後,執行自增操作,將自己工作記憶體內的變數值更新為6,但是並沒有立即更新到主記憶體中(應為工作記憶體中的值更新後並不會直接寫入到主記憶體中),即便是更新到了主記憶體中,但是java記憶體模型沒有規定主記憶體中變數值發生改變後會立即更新執行緒工作記憶體中對應的變數副本的值,此時執行緒A在執行迴圈,它讀取的flag值始終是工作記憶體中的舊值5,導致無法跳出迴圈。
這樣對於flag的值:
執行緒A工作記憶體中:flag=5,仍然為舊值,無法跳出迴圈while(flag%2==1&&flag<100);
執行緒B工作記憶體中:flag從5變成6,然後執行迴圈while(flag%2==0&&flag<=100);
,同樣無法跳出
主記憶體中:flag開始值為5,當從執行緒B中得到更新後的值,變成6.但是不會主動將更新後的值傳遞給執行緒B。
為了解決這個變數的可見性問題,java引入了volatile型變數,來保證共享變數的改變對所有執行緒的可見性。
當一個執行緒修改了這個變數的值,新的值對其他執行緒來說是立即可見的。當其他執行緒讀取自己被volatile修飾的該變數時,會直接從主存中讀取資料從而重新整理自己工作記憶體中的資料,保證讀取到最新的值。
修改後的實現方法:
方案2:使用volatile型變數和自旋檢查來實現交替輸出:
/*
定義兩個執行緒A和B,讓兩個執行緒按順序交替輸出偶數和奇數(A輸出偶數,B輸出奇數)
*/
public class ThreadNum {
public static volatile int flag = 0; //使用volatile 來保證flag對兩個執行緒的可見性
private static final int N = 200;
public static void main(String[] args) {
//AtomicInteger flag = new AtomicInteger(0);
Thread r1 = new Thread( //執行緒A用來輸出偶數
()->{
while(flag<=N){
while(flag%2==1&&flag<=N); //迴圈判斷當前flag是否是偶數
//lock.lock(); //先獲取鎖
System.out.println(Thread.currentThread().getName()+"列印:"+flag);
flag++;//自增1
//lock.unlock();//釋放鎖
}
}
);
Thread r2 = new Thread(//執行緒B用來輸出奇數
()->{
while(flag<N){
while(flag%2==0&&flag<N);
//lock.lock();
System.out.println(Thread.currentThread().getName()+"列印:"+flag);
flag++;
// lock.unlock();
}
}
);
r1.setName("執行緒A");
r2.setName("執行緒B");
r1.start();
r2.start();
}
}
/**
可以成功實現輸出
執行緒A列印:0
執行緒B列印:1
執行緒A列印:2
執行緒B列印:3
執行緒A列印:4
執行緒B列印:5
執行緒A列印:6
執行緒B列印:7
執行緒A列印:8
執行緒B列印:9
執行緒A列印:10
...
執行緒A列印:194
執行緒B列印:195
執行緒A列印:196
執行緒B列印:197
執行緒A列印:198
執行緒B列印:199
執行緒A列印:200
**/
在上面的方案中,執行緒之間通過自旋檢查來保證併發性,也就是當過某個執行緒發現當前自己無法進行輸出時,他會迴圈檢查對應的條件,知道條件滿足,執行緒執行輸出操作。
在這種方案中,執行緒沒有被阻塞,時鐘在佔用CPU執行迴圈。
另一種實現方案是利用互斥鎖來保證執行緒之間的併發性(有序執行),同一時刻,只有獲取到鎖的執行緒才能對變數進行操作(主要是修改)。而無法獲得鎖的執行緒會堵塞,知道鎖被釋放,他們才有機會獲取鎖。
同時,採用條件變數(Condition)的await()和signal()方法來實現實現兩個執行緒的交替輸出
對於獲取到鎖lock的執行緒,如果當前無法滿足輸出要求(比如flag不是奇數),該執行緒會被掛起(await()),同時將鎖釋放並等待。
而其他可以進行輸出的執行緒,在操作完之後,會呼叫signal()方法或者signalAll()方法,來喚醒被掛起的執行緒,同時自己釋放鎖,使得被喚醒的執行緒可以再次嘗試獲取鎖,並錯上次被掛起的位置繼續執行。
採用volatile 型變數和ReentrantLock鎖以及Condition條件變數的實現方案
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/*
定義兩個執行緒A和B,讓兩個執行緒按順序交替輸出偶數和奇數(A輸出偶數,B輸出奇數)
*/
public class ThreadPrint2 {
public static volatile int flag = 0; //volatile修飾變數保證對執行緒的可見性
private static final int N = 50;
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();//宣告一個鎖物件,
Condition c = lock.newCondition();//建立這個鎖對應的一個條件變數
Thread r1 = new Thread( //執行緒A用來輸出偶數
()->{
while(flag<=N){
try{
lock.lock();//首先獲取鎖
if(flag%2==1){//如果當前值為奇數,就將執行緒阻塞掛起
c.await();//將當前執行緒掛起
}
System.out.println(Thread.currentThread().getName()+"列印:"+flag);
flag++;//自增1
c.signal(); //喚醒其他因為這個條件而被被掛起的執行緒
}catch(InterruptedException e){
e.printStackTrace();
}finally{
//這裡必須在finally程式碼塊中來釋放鎖,防止應其他異常導致執行緒中斷,但是鎖 //卻沒有釋放,導致出現死鎖
lock.unlock();
}
}
}
);
Thread r2 = new Thread(//執行緒B用來輸出奇數
()->{
while(flag<N){
try{
lock.lock();//首先獲取鎖
if(flag%2==0){//如果當前值為偶數,就將執行緒阻塞掛起
c.await();
}
System.out.println(Thread.currentThread().getName()+"列印:"+flag);
flag++;//自增1
c.signal();
}catch(InterruptedException e){
e.printStackTrace();
}finally{
lock.unlock();
}
}
}
);
r1.setName("執行緒A");
r2.setName("執行緒B");
r1.start();
r2.start();
}
}
參考書籍:java併發程式設計的藝術,深入理解Java虛擬機器