线程的生命周期(三)

265 阅读5分钟

前言

上篇我们介绍了JVM线程与内核线程的状态差异,并对比了JVM的RUNNABLE和内核线程的Ready和Running.今天我们重点讲解JVM的BLOCKED,WAITING和TIMED_WAITING.虽然它们都对应同一个内核状态,但它们之间有什么具体差别呢,或者说为什么JVM将休眠又细分为三个状态呢?

对象与监视器

我们知道,在java多线程开发过程中要经常使用锁来保护临界区数据.根据不同用法和场景锁有着不同的分类,其中一种就是显式锁和隐式锁. 所谓显示锁就是需要在代码中显式地的加锁和解锁,而隐式锁则是JVM来完成上述过程.比如synchronized关键字对应一种隐式锁,而Lock则对应显示锁.

那synchronized是如何使用锁来保护临界区数据呢? 先看一段代码:8个线程同时给count加1,下面的程序可以保证我们得到正确结果,而如果不加锁是无法保证的.

public class LifeCycleBlocked {
  private final static Object LOCK=new Object();
  private volatile int count;

  private void syncMethod(){
    synchronized (LOCK){
      count++;
      ThreadUtil.println(Thread.currentThread() ,String.valueOf(count));
    }
  }

  public static void main(String[] args) {
    String[] names={"t1","t2","t3","t4","t5","t6","t7","t8"};
    LifeCycleBlocked lifeCycleBlocked =new LifeCycleBlocked();
    Stream.of(names).forEach(i->{
      new Thread(()-> lifeCycleBlocked.syncMethod(),i).start();
    });
  }
}

可以看到,我们在使用synchronized时需要先声明一个对象(LOCK),这个对象在声明时需要声明为final类型.对于任意的java对象,JVM会给它关联一个entry set(入口集合),wait set(等待集合)和monitor(监视器).

网上大部分文章把wait set翻译为等待队列,个人感觉不是很合适. 因为队列的结构是FIFO,但是监视器在选择时并不是按照FIFO的方式选择,它跟具体的JVM实现有关系,所以我这里翻译为"等待集合".

synchronized的用法还有其他几种,但原理都一样,我们就以上述代码为例进行讲解.

那锁,入口集合,等待集合和监视器分别是什么,它们之间又有什么关系呢?看下图

三个线程t1,t2和t3都想访问如下临界区(Critical Section)代码

{
   count++;
   ThreadUtil.println(Thread.currentThread() ,String.valueOf(count));
}

那如何保证只有一个线程来访问呢?监视器把这三个线程先放到入口集合中,并根据特定算法选择其中一个,比如t1被选中.然后把t1的线程ID放到LOCK对象头的指定区域,表示t1线程已经获得了锁,等t1获取时间片后执行临界区代码.

如果临界区代码中有诸如LOCK.wait()或LOCK.wait(long ms)操作,那么t1被监视器放到LOCK对象关联的等待集合中.此时t1不再参与CPU的线程调度,也就是说它不再被分配时间片.除非有其他线程调用LOCK.notify或LOCK.notifyall(),或者到达超时时间来通知t1,否则t1将一直等待下去. 当然,等待的过程中发生异常也会退出临界区.

所谓通知就是监视器将t1从等待集合移到入口集合,然后重新选择.如果t1又被有幸选中,那么它将从LOCK.wait()方法返回并继续运行剩下的代码直到结束

如果没有诸如LOCK.wait或者LOCK.wait(long ms)操作,那么t1一直运行直到结束.运行结束后t1会释放锁,线程终结.其他线程继续重复上述过程,直到所有的线程运行完成.

通过上述过程我们发现,监视器就像总管,它根据不同操作控制线程的运转:

  • 当线程竞争锁时,把它们都放到入口集合,一旦某个线程被选中,那么总管就把该线程的ID值更新到对象头的指定内存位置
  • 当选中的线程执行wait()或wait(long ms)操作时,总管又把它放到等待集合.此时t1会释放锁,这样其他线程会重新竞争该锁
  • 当其他线程调用notify()或notifyAll()时,或者到达指定的timeout时,总管又把该线程移到入口集合中重新参与"翻牌". 对于锁,从广义来说就是LOCK对象,而具体来说就是LOCK对象头那块存放线程ID的内存区域,哪个线程ID赋值到了该区域就表示它占有了这把锁.

注意: 监视器并不是上来就把t1 ID放到锁中,而是为了提高性能有个锁升级的过程.关于锁升级以及对象头具体的数据结构,这里就不再详述, 我们的重点是搞清楚三个状态的区别.

梳理完整个过程,我们就可以轻松地区分BLOCKED,WAITING和TIMED_WAITING的区别了:

  • 处在入口集合的线程,其状态为BLOCKED
  • 调用wait方法的线程处在WAITING状态
  • 调用wait(long ms)方法的线程处在TIMED_WAITING状态

总结

我们通过分析隐式锁的工作流程了解了几个比较晦涩的概念:锁,监视器,入口集合和等待集合. 然后通过线程的流转,阐明了线程状态的区别.

需要注意的是,其他一些操作也会将线程置为WAITING或TIMED_WAITING状态,比如Thread.join(),LockSupport.park()会将线程置为WAITING状态,Thread.sleep(long ms),Thread.join(long ms),LockSupport.parkUntil(long deadline),LockSupport.parkNanos(long nanos)会将线程置为TIMED_WAITING. 这里有个java线程的状态机,详细而全面的总结了所有的操作 www.uml-diagrams.org/examples/ja…

至此,我们完成了线程生命周期的讲解,并重点分析了相关状态来加深对synchronized隐式锁的认识.如果大家有任何疑问或发现任何错误,欢迎留言交流.