在之前的文章中,已经发布了常见的面试题,这里我花了点时间整理了一下对应的解答,由于个人能力有限,不一定完全到位,如果有解答的不合理的地方还请指点,在此谢过。
本文主要描述的是java多线程中面试的可能会出现的内容-等待通知。该部分内容是多线程的核心内容,当然也是阻塞队列实现的底层原理。在面试中碰到的概率不大,实际开发中使用的不多(更多的直接使用阻塞队列),同时本文还会描述几个线程相关的内容,这些内容在面试中可能会出现。如果对java多线程感兴趣的同学可以看下公众号里多线程系列的文章,也许会对你有些帮助。
Java如何创建多线程?Thread的run方法和start方法的区别是什么?
在java语言中,多线程的创建方式有以下几种:
继承thread类:继承thread并且重写run方法,这种方法基本很少被使用。
public class CreateThread extends Thread{
@Override
public void run(){
//do something
}
}
实现runnable接口:因为thread类中提供了用runnable创建的构造函数,并且thread本身也是实现runnable接口,所以使用runnable创建相对比继承thread会来的方便一点。当然核心依然是这个run函数的实现。
public class CreateThread implements Runnable{
@Override
public void run() {
// do something
}
}
实现callable接口+FutureTask:这种方式能够实现的关键点在于FutureTask实现了RunnaleFuture接口,而RunnaleFuture实现了Runnale接口。
public class CreateThread implements Callable<Void>{
@Override
public Void call() throws Exception {
// do something
return null;
}
}
public static void main(String[] args) {
CreateThread createThread = new CreateThread();
FutureTask<Void> futureTask = new FutureTask<>(createThread);
Thread thread = new Thread(futureTask);
thread.start();
return ;
}
从上面三种实现手段来看,继承和实现都可以创建新的线程,而runnable和callable都是实现run方法,区别在于callable可以带返回值。上面三个方法都是对run方法的实现,所以run函数才是重点要做的内容,那是不是可以直接调用run函数。Run函数和start函数的区别是啥呢?
我们先看下start方法哈:
Start方法核心方法是start0。Start0是一个native方法,native关键字是一个本地方法的意思,换句话说是用其他语言实现的,在thread类中有很多native方法,它们都是基于c++实现的,而start0对应c++函数是JVM_StratThread,该函数对应的启动方法是new JavaThread(&thread_entry, sz),对于thread_entry方法的实现中需要调用java中定义的run方法,其对应的函数如下:
上面说了一堆c++的逻辑哈,主要的核心点是start方法会在线程创建后调用run方法。这就是问题的核心,如果我们只是调用run方法的话,那就不会新创建线程去执行,直接就是调用方执行该方法。而如果是start方法的话,会创建一个新的线程,然后去执行run函数。
Sleep函数和wait函数的区别是什么?Notify和notifyall的区别是什么?
sleep和wait在一定的场景下都可以让线程等待。其区别在于以下几点:
- 使用场景上:sleep可以在任何场景下使用,wait需要在获取锁的情况下使用。
- 在锁的程度上:sleep不会释放锁,而wait会释放锁,一般这个问题的核心在于这里。
- 出处:sleep来自于thread类,wait是object类的函数。
Notify和notifyall的区别在于:当wait函数调用的时候,线程释放锁,并且加入等待队列中,notify和notifyall都可以唤醒等待队列中的线程,notifyall是唤醒所有线程,将它们添加到同步队列,notify是唤醒其中的一个,将其添加到同步队列。
condition中线程是如何被唤醒的?什么是等待队列?
Condition的内部有个ConditionObject,ConditionObject是一个条件变量,主要是结合signal和await进行事件的等待通知功能,和Object的wait、notify一样,但是使用上比wait、notify更灵活。内部的实现有个队列维持一个等待的状态(称为等待队列),当获取到ConditionObject的时候,进入到同步队列。同步队列就是AQS中的锁的关键,获取到锁的开始执行操作。当线程调用await方法的时候,进入到等待队列中。下面我们看下等待队列和同步队列的关系。
从上图看出,一个lock可以对应多个condition,每个condition都有一个等对列队与之对应。当队列signal或者signalall的时候会进入同步队列。当线程await的时候,进入等待队列。下面从源码看下await和signal的实现。
await函数:
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();//添加到等待队列中
int savedState = fullyRelease(node);//释放锁
int interruptMode = 0;
while (!isOnSyncQueue(node)) {//如果不是在同步队列
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)//是否是中断
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // 如果取消清除等待者
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
过程主要有以下几个流程:
- 添加到等待队列,这个和之前的添加到同步队列中类似的操作,往尾节点CAS添加
- 释放锁
- 不断循环判断是否不在同步队列中,循环过程判断是否取消等操作
Signal函数:
public final void signal() {
if (!isHeldExclusively())//如果是独占的
throw new IllegalMonitorStateException();//抛出监控异常
Node first = firstWaiter;//获取第一个等待者
if (first != null)
doSignal(first);//唤醒第一个等待者
}
private void doSignal(Node first) {
do {
if ( (firstWaiter = first.nextWaiter) == null)//移动节点的指针关系
lastWaiter = null;
first.nextWaiter = null;
} while (!transferForSignal(first) &&//如果转换成signal状态不成功就继续下一个节点
(first = firstWaiter) != null);
}
final boolean transferForSignal(Node node) {
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))//设置节点的等待状态
return false;
Node p = enq(node);//将节点添加到同步队列
int ws = p.waitStatus;
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))//设置signal的状态
LockSupport.unpark(node.thread);
return true;
}
SignalAll函数:
public final void signalAll() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignalAll(first);//从第一个开始唤醒
}
private void doSignalAll(Node first) {
lastWaiter = firstWaiter = null;
do {
Node next = first.nextWaiter;
first.nextWaiter = null;
transferForSignal(first);//唤醒第一个节点
first = next;
} while (first != null);//依次唤醒
}
Signal函数的过程:取出第一个等待者,将其添加到同步队列。
SignalAll 函数:依次取出所有的节点,并添加到同步队列中。
其实在大部分时候,我们一般用signalAll函数。该函数可以防止signal函数唤醒的线程不是我们想要的线程。
等待通知的经典面试题:使用三个线程1,2,3轮流打印10个数,线程1打印1,线程2打印2,线程3打印3,线程1打印4……
这类题型的实现主要是:
- 创建一个锁和多个condition,依次轮流唤醒,最后一个唤醒第一个,构成一个环。根据题目要求看每个线程依次做什么事情,
- 判断结束条件(比如我的实现是基于deque中是否有值进行)结束条件也很重要,如果还有线程没被唤醒,那就会导致一直结束不了。
- 在主函数中唤醒第一个线程开始操作。唤醒第一个的时候,需要等待其他线程均已经进入了等待队列。
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;
public class ConditionTest {
public static void main(String[] args) {
Deque<Integer> deque = new ArrayDeque<>();
IntStream.range(1,11).forEach(deque::add);
ReentrantLock lock = new ReentrantLock();//创建锁
Condition c1 = lock.newCondition();//创建三个等待变量
Condition c2 = lock.newCondition();
Condition c3 = lock.newCondition();
Thread thread1 = new Thread(new PrintInfo(lock,c1,c2,deque));//创建三个线程
thread1.setName("thread_1");
thread1.start();
Thread thread2 = new Thread(new PrintInfo(lock,c2,c3,deque));
thread2.setName("thread_2");
thread2.start();
Thread thread3 = new Thread(new PrintInfo(lock,c3,c1,deque));
thread3.setName("thread_3");
thread3.start();
wakeUpC1(lock,c1);//唤醒第一个线程
}
private static void wakeUpC1(ReentrantLock lock,Condition condition){
try {
Thread.sleep(100L);//主线程唤醒要等待3个线程都已经进入await状态
} catch (InterruptedException e) {
}
lock.lock();
try{
condition.signal();//唤醒第一个打印的线程
}catch (Exception ex){ }
finally {
lock.unlock();
}
}
private static class PrintInfo implements Runnable{
private ReentrantLock lock ;//锁
private Condition myCondition;//阻塞本线程的条件变量
private Condition signalCondition;//唤醒下一个执行线程的条件变量
private Deque data;
public PrintInfo(ReentrantLock l,Condition c,Condition s,Deque i){
this.myCondition = c;
this.data = i;
this.lock = l;
this.signalCondition = s;
}
@Override
public void run() {
lock.lock();//加锁
try {
while (true) {//死循环
myCondition.await();//先阻塞,等待别人唤醒
if (data.size()>0){//如果还有数据需要打印
System.out.println(Thread.currentThread().getName()
+ " print " + data.pollFirst());
signalCondition.signal();//唤醒下一个打印线程
}else {
signalCondition.signal();//没有数据了,唤醒下一个线程
break;//退出循环
}
}
} catch (Exception ex) { }
finally {
lock.unlock();//释放锁
}
}
}
}
Java多线程的事件等待通知在高级面试中可能会出现,不过在笔试的时候,如果要考察多线程的话,这个一般是个重点内容,难度不高,但是实现的时候,容易出错。在实际开发中,基本比较少用到这块,常用的更多是阻塞队列。本文除了线程的等待通知之外,还对线程的基本创建方式进行了分析,这个在面试中不常问,不过,了解一下可能会对你有一定的帮助,毕竟源码中start和run并没有关系,我们通过看cpp的代码发现其中的调用过程。
如果你觉得对你的学习和面试有些帮助,帮忙点个赞或者转发一下哈,谢谢。
想要了解更多java内容(包含大厂面试题和题解)可以关注公众号,也可以在公众号留言,帮忙内推阿里、腾讯等互联网大厂哈