转载

一个Thread.join()面试题的思考

最近参加了一家公司的面试,不知道为啥现在公司面试都喜欢安排在下午2点,应该是他们刚刚午休结束吧,没办法只能牺牲自己的午休时间,好不容易经过一个多小时的地铁终于到了目标公司,人事的小姑娘直接把我领到会议室给了一个笔试卷子就撤了,那就开始做题目吧。

2. 题目

第一题是关于线程并发的,直接就让我犯了迷糊:

// 写出这段程序的最后输出结果

public class ThreadJoinTest {
    public static void main(String[] args) throws Exception {
        // TODO Auto-generated method stub
        // 定义两个锁对象。
        Object lock1 = new Object();
        Object lock2 = new Object();
        
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                // 开始线程主体之前先获取锁对象 'lock1' 。
                synchronized(lock1) {
                    // 打印线程开始执行信息。
                    System.out.println("thread1 start");
                    try {
                        // 休眠一分钟,模拟耗时任务。
                        Thread.sleep(1000);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    // 获取锁对象 'lock2',注意此时线程仍然持有锁 'lock1',
                    // 也就是说线程是在持有锁'lock1'的前提下尝试获取锁对象'lock2'。
                    synchronized(lock2) {
                        System.out.println("thread1 end");
                    }
                    // 线程释放锁对象'lock2'。
                }
                // 线程释放锁对象'lock1'。
            }
        };
		
        Thread thread2 = new Thread() {
            @Override
            public void run() {
                // 开始线程主体之前先获取锁对象'lock2'。
                synchronized(lock2) {
                    // 打印线程开始执行信息。
                    System.out.println("thread2 start");
                    try {
                        // 休眠一分钟,模拟耗时任务。
                        Thread.sleep(1000);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    // 在线程持有锁对象'lock2'的情况下尝试获取锁对象'lock1'。
                    synchronized(lock1) {
                        System.out.println("thread2 end");
                    }
                    // 释放锁对象'lock1'。
                }
                // 释放锁对象'lock2'。
            }
        };
        
        // 启动线程
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        // 主线程休眠两分钟,模拟耗时任务。
        Thread.sleep(2000);
        // 打印主线程结束信息。
        System.out.println("main thread end");
    }
}
复制代码

我看到题目的第一印象以为考察的线程并发和死锁问题,自己认为程序的执行过程如下:

  1. 首先线程 thread1 先启动执行并获取了锁对象 lock1 ,这时会直接打印 thread1 start ,然后休眠了1分钟来模拟耗时任务;
  2. 然后线程 thread2 也已经启动并获取了锁对象 lock2 ,这时会直接打印 thread2 start ,然后休眠了1分钟来模拟耗时任务;
  3. 当线程 thread1 休眠结束准备继续往下执行,需要获取锁对象 lock2 ,由于这时线程 thread2 持有了锁 lock2 ,所以线程 thread1 由于无法获取锁对象处于阻塞状态;
  4. 当线程 thread2 休眠结束准备继续往下执行,需要获取锁对象 lock1 ,由于这是线程 thread1 持有了锁 lock1 ,所以线程 thread2 由于无法获取锁对象处于阻塞状态;
  5. 在前面的过程中, thread1 持有 lock1 等待 lock2 ,与此同时 thread2 持有 lock2 等待 lock1 ,明显处于死锁状态,所以这两个线程谁也无法继续向下执行;
  6. 由于 thread1thread2 处于死锁状态,都无法继续向下执行,那主线程就会获得执行的机会,进而打印 main thread end

综上,我最后给出的结果是:

thread1 start
thread2 start
main thread end
复制代码

在和面试官当面交流的时候,特意谈到这个题目,面试官说这道题主要考察的是 join 的用法,显然我对这个没有正确的认识,最后给的答案自然也是错误的。

3. 思考

3.1 用法

既然花了一下午的时间去参加了面试,至少要有一点点的收获吧,不然岂不是在浪费生命,所以回来还是稍微查了下 Thread.join() 的含义和用法,我们直接来看下源码里对这个方法的描述。

// Thread.java

/**
 * Waits for this thread to die.
 *
 * <p> An invocation of this method behaves in exactly the same
 * way as the invocation
 *
 * <blockquote>
 * {@linkplain #join(long) join}{@code (0)}
 * </blockquote>
 *
 * @throws  InterruptedException
 *          if any thread has interrupted the current thread. The
 *          <i>interrupted status</i> of the current thread is
 *          cleared when this exception is thrown.
 */
public final void join() throws InterruptedException {
    join(0);
}

复制代码

源码里对这个方法的描述只有简单的一句话“等待这个线程的消亡”,也就说一个线程在调用另一个线程的 join 方法后就要等待这个线程消亡后才能继续往下执行,相当于把并发的线程在这个时间点变成串行执行序列了。

在理解了这点后,再回过头来看看上面的题目,在 thread1thread2 的死锁等待方面的分析都是正确的,关键点在于主线程在这之后是否还可以继续往下执行。由于在主线程中调用了 thread1.join()thread2.join() ,就表明主线程必须等待这两个线程执行完才能继续执行,但 thread1thread2 已经处于死锁状态,是不可能消亡的,这也就导致主线程无法继续下去了,所以最后的输出结果应该是:

thread1 start
thread2 start
复制代码

我自己也在回来之后运行过这段代码,结果和分析的一致,也算弄明白了 Thread.join() 是咋回事了。

3.2 实现原理

在弄明白 Thread.join() 的用法和含义是不是就圆满结束了?当然不是,我们尽可能地了解其内部的实现原理。

简单来说就是要知道两个问题:

Thread.join()

3.2.1 如何停止

一切来源于代码,我们自然要到代码去寻找答案,还是再来看下 Thread.join() 的声明和定义:

// Thread.java

/**
 * Waits for this thread to die.
 */
public final void join() throws InterruptedException {
    // 直接调用另一个重载函数。
    join(0);
}
    
/**
 * Waits at most {@code millis} milliseconds for this thread to
 * die. A timeout of {@code 0} means to wait forever.
 *
 * <p> This implementation uses a loop of {@code this.wait} calls
 * conditioned on {@code this.isAlive}. As a thread terminates the
 * {@code this.notifyAll} method is invoked. It is recommended that
 * applications not use {@code wait}, {@code notify}, or
 * {@code notifyAll} on {@code Thread} instances.
 *
 */
public final synchronized void join(long millis)
throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;

    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (millis == 0) {
        while (isAlive()) {
            // 当线程还处于存活状态时,就一直等待。
            wait(0);
        }
    } else {
        while (isAlive()) {
            // 等待时间没有直接使用参数指定的 millis,原因是为了保持退出循环的可能。
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            // 当线程还处于存活状态时,就等待一段时间。
            wait(delay);
            // 更新 now 时间信息,是为了等待时间结束后,再次进到这个循环时能够由于 delay <= 0 而直接退出循环。
            now = System.currentTimeMillis() - base;
        }
    }
}
复制代码

这个函数的代码量并不大,逻辑也比较容易理解,就是在线程A中调用线程B的 join() 方法后,这个线程A就会处于对线程B的 wait 状态,根据传入的参数不同可以处于一直等待也可以只等待一段时间。

3.2.2 如何恢复

既然线程A在调用线程B的 join 方法后就会处于 wait 状态,那线程A又是在何时恢复执行的呢?这里只介绍不带参数的 join 方法,即一直等待的情况。从 join 方法的介绍中可知,要等到线程B的消亡,线程A才能恢复,这是如何实现的呢?

// Thread.java

/**
 * This method is called by the system to give a Thread
 * a chance to clean up before it actually exits.
 */
private void exit() {
    if (group != null) {
        // 调用销毁回调
        group.threadTerminated(this);
        group = null;
    }
    /* Aggressively null out all reference fields: see bug 4006245 */
    target = null;
    /* Speed the release of some of these resources */
    threadLocals = null;
    inheritableThreadLocals = null;
    inheritedAccessControlContext = null;
    blocker = null;
    uncaughtExceptionHandler = null;
}
复制代码

在线程真正退出之前,系统会调用 exit 方法来进行一些回收操作,从代码可以看到除了 group.threadTerminated() 之外都是一些置空操作,很可能起到恢复作用的逻辑就藏在 group.threadTerminated() 里面,这里的 groupThreadGroup 的实例,是线程在初始化的时候创建的,可以简单理解为这个线程属于这类线程组的。

直接来看 ThreadGroup.threadTerminated() 的代码:

/**
 * Notifies the group that the thread {@code t} has terminated.
 *
 * <p> Destroy the group if all of the following conditions are
 * true: this is a daemon thread group; there are no more alive
 * or unstarted threads in the group; there are no subgroups in
 * this thread group.
 *
 * @param  t
 *         the Thread that has terminated
 */
void threadTerminated(Thread t) {
    synchronized (this) {
        remove(t);

        if (nthreads == 0) {
            // 唤醒所有的等待线程。
            notifyAll();
        }
        if (daemon && (nthreads == 0) &&
            (nUnstartedThreads == 0) && (ngroups == 0))
        {
            destroy();
        }
    }
}
复制代码

很明显,在线程被销毁的时候会调用 notifyAll() 来唤醒所有等待线程,所以线程A才能在线程B消亡的时候恢复运行。

4. 拓展

其实 Thread 里面除了 join() 方法,还有一个 yield() 值得我们关注,由于这个方法相对简单,在这里只是简单地提到并不会详细讲解,废话不多说,还是直接来看源码:

/**
 * A hint to the scheduler that the current thread is willing to yield
 * its current use of a processor. The scheduler is free to ignore this
 * hint.
 *
 * <p> Yield is a heuristic attempt to improve relative progression
 * between threads that would otherwise over-utilise a CPU. Its use
 * should be combined with detailed profiling and benchmarking to
 * ensure that it actually has the desired effect.
 *
 * <p> It is rarely appropriate to use this method. It may be useful
 * for debugging or testing purposes, where it may help to reproduce
 * bugs due to race conditions. It may also be useful when designing
 * concurrency control constructs such as the ones in the
 * {@link java.util.concurrent.locks} package.
 */
public static native void yield();
复制代码

这个方法是个 native 方法,我们无法直接看到它的内部实现,那就看下它的声明,里面提到两点重要信息:

  1. 作用:告诉线程调度器当前的线程打算放弃对处理器的使用,至于处理器是否会由于这个信息进而重新调用线程,要看具体的调度策略;
  2. 使用场景:一般是用来进行调试用的,因为这个方法无法保证当前的线程调用这个方法后,其他线程一定会得到处理器,也就无法用来控制线程之间的执行顺序。

直接给出一个简单的例子:

public class ThreadYieldTest {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        YieldThread thread1 = new YieldThread("thread_1");
        YieldThread thread2 = new YieldThread("thread_2");
        thread1.start();
        thread2.start();
    }

    private static class YieldThread extends Thread {
        private String name;
        public YieldThread(String name) {
            super(name);
            this.name = name;
        }
        
        @Override
        public void run() {
            for (int i = 0; i < 50; i ++) {
                System.out.println(name + " : " + i);
                if (i == 25) {
                    // 在线程执行到一半的时候,调用 yield 方法尝试放弃执行。
                    System.out.println(name + ":  yield");
                    Thread.yield();
                }
            }
        }
    }
}
复制代码

有兴趣的同学可以多次运行这段程序看看结果,从结果也可以发现并不是每次 thread1 或者 thread2 在执行 yield 后另一个线程都可以获取处理器进而开始执行的,正是由于这个不确定性,不建议大家在代码里用这个方法来控制线程之间的执行。

5. 总结

通过一个简单的面试题,能够学习到一点知识,对自己也是一种提升,感觉自己平时对这些基础问题的思考太少了,在埋头解决 bug 之余,还是要注意学习积累知识的。

原文  https://juejin.im/post/5db11544518825647178eb5b
正文到此结束
Loading...