代码改变世界

让面试官心服口服:Thread.sleep、synchronized、LockSupport.park的线程阻塞有何区别?

2020-11-22 18:19  tera  阅读(2936)  评论(4编辑  收藏  举报

前言

在日常编码的过程中,我们经常会使用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级别上面对同一个打断方法时会有不同的表现形式