java面試題:多線程交替輸出偶數和奇數

一個面試題:實現兩個線程A,B交替輸出偶數和奇數

問題:創建兩個線程A和B,讓他們交替打印0到100的所有整數,其中A線程打印偶數,B線程打印奇數

這個問題配合java的多線程,很多種實現方式

在具體實現之前,首先介紹一下java並發編程中共享變量的可見性問題。

可見性問題:

在java內存模型(JMM,java Memory Model)中定義了程序中各種共享變量的訪問規則。

這裡的共享變量指的是可以在線程之間共享的變量,包括實例字段,靜態字段和構成數組對象的元素。

不包括局部變量和方法參數(這些都是在虛擬機棧中,是線程私有的)

在java內存模型中,規定所有的變量都保存在主內存中(JVM內存中的一個空間)。

此外,對於每個線程,都擁有自己的工作內存,在工作內存中存儲了該線程使用的共享變量的主內存副本(從主內存中拷貝過來的)。

每個線程只能在工作內存中對共享變量的副本進行操作(讀,賦值),不能直接讀寫主內存中的數據。

各個線程之間也無法訪問對方工作內存中的變量副本,所有的線程只能通過主內存來完成變量的值傳遞。

img

在java內存模型中,定義了8中原子操作來完成工作內存與主內存之間的拷貝與同步。

這裡重點關注一下,兩個線程同時讀取與修改同一個共享變量的問題。

當我們創建了一個靜態變量之後,它就會被保存在主內存中。如果有兩個線程A,B要訪問這個靜態變量並對其進行修改,線程會讀取(read操作)這個變量的值並放到(load操作)線程的工作內存中的變量,線程在執行完修改指令後,將修改後的值賦值給(assign操作)工作內存中的變量,然後執行store操作將工作內存中變量的值傳送到主內存中,然後使用write操作將傳遞過來的值放入到主內存變量中。

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虛擬機