讓面試官心服口服:Thread.sleep、synchronized、LockSupport.park的執行緒阻塞有何區別?

tera發表於2020-11-22

前言

在日常編碼的過程中,我們經常會使用Thread.sleep、LockSupport.park()主動阻塞執行緒,或者使用synchronized和Object.wait來阻塞執行緒保證併發安全。此時我們會發現,對於Thread.sleep和Object.wait方法是會丟擲InterruptedException,而LockSupport.park()和synchronized則不會。而當我們呼叫Thread.interrupt方法時,除了synchronized,其他執行緒阻塞的方式都會被喚醒。

於是本文就來探究一下Thread.sleep、LockSupport.park()、synchronized和Object.wait的執行緒阻塞的原理以及InterruptedException的本質

本文主要分為以下幾個部分

1.Thread.sleep的原理

2.LockSupport.park()的原理

3.synchronized執行緒阻塞的原理

4.ParkEvent和parker物件的原理

5.Thread.interrupt的原理

6.對於synchronized打斷原理的擴充套件

1.Thread.sleep的原理

Thread.java

首先還是從java入手,檢視sleep方法,可以發現它直接就是一個native方法:

public static native void sleep(long millis) throws InterruptedException;

為了檢視native方法的具體邏輯,我們就需要下載openjdk和hotspot的原始碼了,下載地址:http://hg.openjdk.java.net/jdk8

檢視Thread.c:jdk原始碼目錄src/java.base/share/native/libjava

可以看到對應的jvm方法是JVM_Sleep:

static JNINativeMethod methods[] = {
    ...
    {"sleep",            "(J)V",       (void *)&JVM_Sleep},
    ...
};

檢視jvm.cpp,hotspot目錄src/share/vm/prims

找到JVM_Sleep方法,我們關注其重點邏輯:

方法的邏輯中,首先會做2個校驗,分別是睡眠時間和執行緒的打斷標記。其實這2個資料的校驗都是可以放到java層,不過jvm的設計者將其放到了jvm的邏輯中去判斷。

如果睡眠的時間為0,那麼會呼叫系統級別的睡眠方法os::sleep(),睡眠時間為最小時間間隔。在睡眠之前會儲存執行緒當前的狀態,並將其設定為SLEEPING。在睡眠結束之後恢復執行緒狀態。

接著就是sleep方法的重點,如果睡眠時間不為0,同樣需要儲存和恢復執行緒的狀態,並呼叫系統級別的睡眠方法os::sleep()。當然睡眠的時間會變成指定的毫秒數。

最重要的區別是,此時會判斷os::sleep()的返回值,如果是打斷狀態,那麼就會丟擲一個InterruptException!這裡其實就是InterruptException產生的源頭

JVM_ENTRY(void, JVM_Sleep(JNIEnv* env, jclass threadClass, jlong millis))
  JVMWrapper("JVM_Sleep");
	//如果睡眠的時間小於0,則丟擲異常。這裡資料的校驗在jvm層邏輯中校驗
  if (millis < 0) {
    THROW_MSG(vmSymbols::java_lang_IllegalArgumentException(), "timeout value is negative");
  }
  //如果執行緒已經被打斷了,那麼也丟擲異常
  if (Thread::is_interrupted (THREAD, true) && !HAS_PENDING_EXCEPTION) {
    THROW_MSG(vmSymbols::java_lang_InterruptedException(), "sleep interrupted");
  }
  ...
  //這裡允許睡眠時間為0
  if (millis == 0) {
    ...{
      //獲取並儲存執行緒的舊狀態
      ThreadState old_state = thread->osthread()->get_state();
      //將執行緒的狀態設定為SLEEPING
      thread->osthread()->set_state(SLEEPING);
      //呼叫系統級別的sleep方法,此時只會睡眠最小時間間隔
      os::sleep(thread, MinSleepInterval, false);
      //恢復執行緒的狀態
      thread->osthread()->set_state(old_state);
    }
  } else {
    //獲取並儲存執行緒的舊狀態
    ThreadState old_state = thread->osthread()->get_state();
    //將執行緒的狀態設定為SLEEPING
    thread->osthread()->set_state(SLEEPING);
    //睡眠指定的毫秒數,並判斷返回值
    if (os::sleep(thread, millis, true) == OS_INTRPT) {
        ...
        //丟擲InterruptedException異常
        THROW_MSG(vmSymbols::java_lang_InterruptedException(), "sleep interrupted");
    }
    //恢復執行緒的狀態
    thread->osthread()->set_state(old_state);
  }
JVM_END

檢視os_posix.cpp,hotspot目錄src/os/posix/vm

我們接著檢視os::sleep()方法:

首先獲取執行緒的SleepEvent物件,這個是執行緒睡眠的關鍵

根據是否允許打斷分為2個大分支,其中邏輯大部分是相同的,區別在於允許打斷的分支中會在迴圈中額外判斷打斷標記,如果打斷標記為true,則返回打斷狀態,並在外層方法中丟擲InterruptedException

最終執行緒睡眠是呼叫SleepEvent物件的park方法完成的,該物件內部的原理後面統一說

int os::sleep(Thread* thread, jlong millis, bool interruptible) {
  //獲取thread中的_SleepEvent物件
  ParkEvent * const slp = thread->_SleepEvent ;
  ...
  //如果是允許被打斷
  if (interruptible) {
    //記錄下當前時間戳,這是時間比較的基準
    jlong prevtime = javaTimeNanos();

    for (;;) {
      //檢查打斷標記,如果打斷標記為ture,則直接返回
      if (os::is_interrupted(thread, true)) {
        return OS_INTRPT;
      }
      //執行緒被喚醒後的當前時間戳
      jlong newtime = javaTimeNanos();
      //睡眠毫秒數減去當前已經經過的毫秒數
      millis -= (newtime - prevtime) / NANOSECS_PER_MILLISEC;
      //如果小於0,那麼說明已經睡眠了足夠多的時間,直接返回
      if (millis <= 0) {
        return OS_OK;
      }
      //更新基準時間
      prevtime = newtime;
      //呼叫_SleepEvent物件的park方法,阻塞執行緒
      slp->park(millis);
    }
  } else {
    //如果不能打斷,除了不再返回OS_INTRPT以外,邏輯是完全相同的
    for (;;) {
      ...
      slp->park(millis);
      ...
    }
    return OS_OK ;
  }
}

所以Thread.sleep的在jvm層面上是呼叫thread中SleepEvent物件的park()方法實現阻塞執行緒,在此過程中會通過判斷時間戳來決定執行緒的睡眠時間是否達到了指定的毫秒。

InterruptedException的本質是一個jvm級別對打斷標記的判斷,並且jvm也提供了不可打斷的sleep邏輯。

2.LockSupport.park()的原理

除了我們經常使用的Thread.sleep,在jdk中還有很多時候需要阻塞執行緒時使用的是LockSupport.park()方法(例如ReentrantLock),接下去我們同樣需要看下LockSupport.park()的底層實現

LockSupport.java

從java程式碼入手,檢視LockSupport.park()方法,可以看到它直接呼叫了Usafe類中的park方法:

public static void park() {
    UNSAFE.park(false, 0L);
}

Unsafe.java

檢視Unsafe.park,可以看到是一個native方法

public native void park(boolean var1, long var2);

檢視unsafe.cpp,hotspot目錄src/share/vm/prims

找到park方法,這個方法就比sleep簡單粗暴多了,直接呼叫thread中的parker物件的park()方法阻塞執行緒

UNSAFE_ENTRY(void, Unsafe_Park(JNIEnv *env, jobject unsafe, jboolean isAbsolute, jlong time)) {
  ...
  //簡單粗暴,直接呼叫thread中的parker物件的park方法阻塞執行緒
  thread->parker()->park(isAbsolute != 0, time);
  ...
} UNSAFE_END

所以LockSupport.park方法是不會丟擲InterruptedException異常的。當一個執行緒呼叫LockSupport.park阻塞後,如果被喚醒,那麼就直接執行之後的邏輯。而對於打斷的響應則需要使用該方法的使用者在Java級別的程式碼上通過呼叫Thread.interrupted()判斷打斷標記自行處理。

相比而言Thread.sleep則設計更為複雜,除了在jvm級別上對打斷作出響應,更提供了不可被打斷的邏輯,保證呼叫該方法的執行緒一定可以阻塞指定的時間,而這個功能是LockSupport.park所做不到的。

3.synchronized執行緒阻塞的原理

再看一下synchronized線上程阻塞上的原理。synchronized本身其實都可寫幾篇文章來探討,不過本文僅關注於其執行緒阻塞部分的邏輯。

synchronized的阻塞包括2部分:

1.呼叫synchronized(obj)時,如果沒有搶到鎖,那麼會進入佇列等待,並阻塞執行緒。

2.獲取到鎖之後,呼叫obj.wait()方法進行等待,此時也會阻塞執行緒。

先來看情況一。因為這種情況並非是呼叫類中的某個方法,而是一個關鍵字,因此我們是無法從某個類檔案入手。那麼我們就需要直接檢視位元組碼了。

首先建立一個簡單的java類

public class Synchronized{
    public void test(){
        synchronized(this){
        }
    }
}

編譯成.class檔案後,再檢視其位元組碼

javac Synchronized.java
javap -v Synchronized.class

synchronized關鍵字在位元組碼上體現為monitorentermonitorexit指令。

public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         ...
         3: monitorenter
         4: aload_1
         5: monitorexit
         ...

檢視bytecodeInterpreter.cpp,hotspot目錄/src/share/vm/interpreter

該檔案中的方法都是用來解析各種位元組碼命令的。接著我們找到monitorenter方法:

這個方法就是synchronized關鍵字的具體加鎖邏輯,十分複雜,這裡只是展示方法的入口在哪裡。

CASE(_monitorenter): {
  ...
}

檢視objectMonitor.cpp,hotspot目錄/src/share/vm/runtime

最終synchronized的執行緒阻塞邏輯是由objectMonitor物件負責的,所以我們直接檢視該物件的相應方法。找到enter方法:

跳過其中大部分邏輯,我們看到EnterI方法,正是在該方法中阻塞執行緒的。

void ObjectMonitor::enter(TRAPS) {
  ...
  //阻塞執行緒
  EnterI(THREAD);
  ...
}

檢視EnterI方法

這個方法會在一個死迴圈中嘗試獲取鎖,如果獲取失敗則呼叫當前執行緒的ParkEventpark()方法阻塞執行緒,否則就退出迴圈

當然特別注意的是,這個方法是在一個死迴圈中呼叫的,因此在java級別來看,synchronized是不可打斷的,執行緒會一直阻塞直到它獲取到鎖為止。

void ObjectMonitor::EnterI(TRAPS) {
  //獲取當前執行緒物件
  Thread * const Self = THREAD;
  ...
  for (;;) {
    //嘗試獲取鎖
    if (TryLock(Self) > 0) break;
    ...
    //呼叫ParkEvent的park()方法阻塞執行緒
    if (_Responsible == Self || (SyncFlags & 1)) {
      Self->_ParkEvent->park((jlong) recheckInterval);
    } else {
      Self->_ParkEvent->park();
    }
    ...
  }
  ...
}

接著來看情況二:

檢視objectMonitor.cpp,hotspot目錄/src/share/vm/runtime

最終Object.wait()的執行緒阻塞邏輯也是由objectMonitor物件負責的,所以我們直接檢視該物件的相應方法。找到wait方法:

可以看到wait()方法中對執行緒的打斷作出了響應,並且會丟擲InterruptedException,這也正是java級別的Object.wait()方法會丟擲該異常的原因

執行緒阻塞和synchronized一樣,是由執行緒的ParkEvent物件的park()方法完成的

void ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS) {
  //獲取當前執行緒物件
  Thread * const Self = THREAD;
  //檢查是否可以打斷
  if (interruptible && Thread::is_interrupted(Self, true) && !HAS_PENDING_EXCEPTION) {
    ...
    //丟擲InterruptedException
    THROW(vmSymbols::java_lang_InterruptedException());
  }
  if (interruptible && (Thread::is_interrupted(THREAD, false) || HAS_PENDING_EXCEPTION)) {
    //如果執行緒被打斷了,那就什麼都不做
  } else if (node._notified == 0) {
    //呼叫ParkEvent的park()方法阻塞執行緒
    if (millis <= 0) {
      Self->_ParkEvent->park();
    } else {
      ret = Self->_ParkEvent->park(millis);
    }   
  }
}

所以對於synchronizedObject.wait來說,最終都是呼叫thread中ParkEvent物件的park()方法實現執行緒阻塞的

而在java層面上synchronized本身是不響應執行緒打斷的,但是Object.wait()方法卻是會響應打斷的,區別正是在於jvm級別的邏輯處理上有所不同。

4.ParkEvent和parker物件的原理

Thread.sleep、synchronized和Object.wait底層分別是利用執行緒SleepEventParkEvent物件的park方法實現執行緒阻塞的。因為這2個物件實際是一個型別的,因此我們就一起來看一下其park方法究竟做了什麼

檢視thread.cpp,hotspot目錄src/share/vm/runtime

找到SleepEventParkEvent的定義,從後面的註釋就可以發現,ParkEvent就是供synchronized()使用的,而SleepEvent則是供Thread.sleep使用的:

ParkEvent * _ParkEvent;    // for synchronized()
ParkEvent * _SleepEvent;   // for Thread.sleep

檢視park.hpp,hotspot目錄src/share/vm/runtime

在標頭檔案中能找到ParkEvent類的定義,繼承自os::PlatformEvent

class ParkEvent : public os::PlatformEvent {
  ...
}

檢視os_linux.hpp,hotspot目錄src/os/linux/vm

以linux系統為例,在標頭檔案中可以看到PlatformEvent的具體定義:

我們關注的重點首先是2個private的物件,一個pthread_mutex_t,表示作業系統級別的訊號量,一個pthread_cond_t,表示作業系統級別的條件變數

其次是定義了3個方法,park()、unpark()、park(jlong millis),控制執行緒的阻塞和繼續執行

class PlatformEvent : public CHeapObj<mtInternal> {
 private:
  ...
  pthread_mutex_t _mutex[1];
  pthread_cond_t  _cond[1];
  ...
  void park();
  void unpark();
  int  park(jlong millis); // relative timed-wait only
  ...
};

檢視os_linux.cpp,hotspot目錄src/os/linux/vm

接著我們就需要去看park方法的具體實現,這裡我們主要關注3個系統底層方法的呼叫

pthread_mutex_lock(_mutex):鎖住訊號量

status = pthread_cond_wait(_cond, _mutex):釋放訊號量,並在條件變數上等待

status = pthread_mutex_unlock(_mutex):釋放訊號量

void os::PlatformEvent::park() { 
    ...
    //鎖住訊號量
    int status = pthread_mutex_lock(_mutex);
    while (_Event < 0) {
      //釋放訊號量,並在條件變數上等待
      status = pthread_cond_wait(_cond, _mutex);
    }
    //釋放訊號量
    status = pthread_mutex_unlock(_mutex);
}

可以看到ParkEvent的park()方法底層最終是呼叫系統函式pthread_cond_wait完成執行緒阻塞的操作。

而執行緒的parker物件的park()方法本質和ParkEvent是完全一致的,最終也是呼叫系統函式pthread_cond_wait完成執行緒阻塞的操作,區別只是在於多了一個絕對時間的判斷:

檢視os_linux.cpp,hotspot目錄src/os/linux/vm

void Parker::park(bool isAbsolute, jlong time) {
  ...
  if (time == 0) {
    //這裡是直接長時間等待
    _cur_index = REL_INDEX; 
    status = pthread_cond_wait(&_cond[_cur_index], _mutex);
  } else {
    //這裡會根據時間是否是絕對時間,分別等待在不同的條件上
    _cur_index = isAbsolute ? ABS_INDEX : REL_INDEX;
    status = pthread_cond_timedwait(&_cond[_cur_index], _mutex, &absTime);
  }
  ...
}

5.Thread.interrupt的原理

上面看了3中執行緒阻塞的原理,那麼接著自然是需要看一下執行緒打斷在jvm層面上到底做了什麼。我們跳過程式碼搜尋的過程,直接看最後一步的原始碼

檢視os_posix.cpp,hotspot目錄src/os/posix/vm

找到interrupt方法,這個方法正是打斷的重點,其中一共做了2件事情:

1.將打斷標記置為true

2.分別呼叫thread中的ParkEvent、SleepEvent和Parker物件的unpark()方法

void os::interrupt(Thread* thread) {
  ...
  //獲得c++執行緒對應的系統執行緒
  OSThread* osthread = thread->osthread();
  //如果系統執行緒的打斷標記是false,意味著還未被打斷
  if (!osthread->interrupted()) {
    //將系統執行緒的打斷標記設為true
    osthread->set_interrupted(true);
    //這個涉及到記憶體屏障,本文不展開
    OrderAccess::fence();
    //這裡獲取一個_SleepEvent,並呼叫其unpark()方法
    ParkEvent * const slp = thread->_SleepEvent ;
    if (slp != NULL) slp->unpark() ;
  }

  //這裡依據JSR166標準,即使打斷標記為true,依然要呼叫下面的2個unpark
  if (thread->is_Java_thread())
    //如果是一個java執行緒,這裡獲取一個parker物件,並呼叫其unpark()方法
    ((JavaThread*)thread)->parker()->unpark();

  ParkEvent * ev = thread->_ParkEvent ;
  //這裡獲取一個_ParkEvent,並呼叫其unpark()方法
  if (ev != NULL) ev->unpark() ;
}

通過對3個park物件park()方法的瞭解,在unpark中必然是呼叫系統級別的signal方法:

void os::PlatformEvent::unpark() {
  ...
  if (AnyWaiters != 0) {
    //喚醒條件變數
    status = pthread_cond_signal(_cond);
  }
  ...
}

所以對於Thread.interrupt來說,它最重要的事情其實是呼叫3個unpark()方法物件喚醒執行緒,而我們老生常談的修改打斷標記,反倒是沒那麼重要。是否響應該標記、是在jvm層上響應還是在java層上響應等等邏輯,都取決於實際需要。

6.對於synchronized的擴充套件

在synchronized的原理部分,我們看到執行緒的阻塞是在一個死迴圈中執行的,因此在java級別上看來是不可打斷的。

如果瞭解synchronized的原理(不瞭解也沒關係,一會兒會有實際示例),可以知道當執行緒沒有搶到鎖時會進入一個佇列並阻塞,而執行緒的正常喚醒順序會按照入佇列的順序依次進行。

然而,如果我們仔細看jvm的邏輯,可以發現在迴圈中,每當執行緒被喚醒後都會去呼叫TryLock方法嘗試獲取鎖,那麼結合我們對Thread.interrupt方法的瞭解

我們就可以大膽推測,雖然在java級別上synchronized不可打斷,但是如果我們不斷地呼叫Thread.interrupt方法就能使得執行緒直接插隊獲取鎖,而不必按照入佇列的順序了!

接下來我們來看示例

1.synchronized的順序性

這裡我們先讓一個執行緒獲取到鎖,之後啟動3個執行緒等待在鎖上。

@Test
public void synchronizedTest() throws InterruptedException {
    int size = 3;
    Object lock = new Object();
    //讓第一個執行緒獲取鎖後阻塞1秒鐘
    new Thread(() -> {
        synchronized (lock) {
            System.out.println("Thread Lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Lock Over");
        }
    }).start();
    TimeUnit.MILLISECONDS.sleep(10);
    //啟動3個執行緒,並等待第一個執行緒釋放鎖,每個執行緒啟動間隔10毫秒,保證入佇列的順序性
    int count = 1;
    for (int i = 0; i < size; i++) {
        int m = count++;
        TimeUnit.MILLISECONDS.sleep(10);
        new Thread(() -> {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName());
            }
        }, "thread--" + m).start();
    }
    TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);
}

最終輸出結果,可以看到synchronized的佇列遵循先入後出的原則

Thread Lock
Lock Over
thread--3
thread--2
thread--1

2.執行緒打斷對佇列順序的影響

在啟動3個執行緒入佇列之前,我們先啟動一個單獨的執行緒。並且在主執行緒的最後,我們在一個死迴圈中不斷呼叫該單獨執行緒的interrupt方法。

@Test
public void synchronizedTest() throws InterruptedException {
    int size = 3;
    Object lock = new Object();
    new Thread(() -> {
        synchronized (lock) {
            System.out.println("Thread Lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Lock Over");
        }
    }).start();
    TimeUnit.MILLISECONDS.sleep(10);
    //啟動一個單獨的執行緒,用來測試synchronized的打斷
    Thread interruptThread = new Thread(() -> {
        synchronized (lock) {
            System.out.println("interruptThread");
        }
    });
    interruptThread.start();
    TimeUnit.MILLISECONDS.sleep(10);
    int count = 1;
    for (int i = 0; i < size; i++) {
        int m = count++;
        TimeUnit.MILLISECONDS.sleep(10);
        new Thread(() -> {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName());
            }
        }, "thread--" + m).start();
    }
    //在主執行緒中不斷打斷單獨執行緒
    for(;;){
        interruptThread.interrupt();
    }
}

輸出如下,按照先入後出的原則,這個單獨的執行緒應該是最後一個獲取到鎖的。然而在主執行緒不斷地打斷下,它成功地完成了插隊!而其他沒有被打斷的執行緒依然按照約定的順序依次喚醒。有興趣的同學可以嘗試去掉最後的打斷,再執行一次。

Thread Lock
Lock Over
interruptThread
thread--3
thread--2
thread--1

最後總結一下Thread.sleep、LockSupport.park和synchronized執行緒阻塞方式的區別,這裡我分幾個層次來總結

1.系統級別:這3種方式沒有區別,最終都是呼叫系統的pthread_cond_wait方法

2.c++執行緒級別:Thread.sleep使用的是執行緒的SleepEvent物件,LockSupport.park使用的是執行緒的Parker物件,synchronizedObject.wait使用的是執行緒的ParkEvent物件

3.java級別:Thread.sleep可打斷並丟擲異常;LockSupport.park可打斷,且不會丟擲異常;synchronized不可打斷;Object.wait可打斷並丟擲異常

4.InterruptedException其實僅僅是jvm邏輯上對打斷標記的判斷而已

5.Thread.interrupt的本質在於修改打斷標記,並呼叫3個unpark()方法喚醒執行緒

4.更概括來說,無論是哪種執行緒阻塞的方式,在系統級別和c++執行緒級別來說都是可打斷的。而jvm通過程式碼邏輯使得3種執行緒阻塞的方式在java級別上面對同一個打斷方法時會有不同的表現形式

相關文章