在lambda的无限while循环中使用Thread.sleep不需要'catch (InterruptedException)' - 为什么?

61

我的问题是关于InterruptedException,它是从Thread.sleep方法中抛出的。在使用ExecutorService时,我注意到了一些我不理解的奇怪行为;这就是我的意思:

ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.submit(() -> {
        while(true)
        {
            //DO SOMETHING
            Thread.sleep(5000);
        }
    });

使用这段代码,编译器没有给我任何错误或消息提示需要捕获来自Thread.sleepInterruptedException。但是当我试图更改循环条件并将“true”替换为某些变量时:

ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.submit(() -> {
        while(tasksObserving)
        {
            //DO SOMETHING
            Thread.sleep(5000);
        }
    });
编译器不断抱怨必须处理InterruptedException异常。有人能解释一下为什么会发生这种情况,以及为什么如果条件设置为true,则编译器会忽略InterruptedException异常吗?
编译器会不断报错,提示必须处理InterruptedException异常。请问为什么会出现这种情况?当条件设为true时,为什么编译器会忽略InterruptedException异常呢?

请您能提供更多关于 DO SOMETHING 的细节吗? 给出精确的答案非常重要。例如,如果 DO SOMETHING 为空,那么带有 while (true) 的第一个代码块将无法编译通过。有关更多详细信息,请参阅我的答案。 - Roman Puchkovskiy
抱歉,我的错:第一个代码块实际上可以编译(我被我的IDE误导了),并且不需要来自OP的其他信息。在新的研究之后,我已经完全重写了我的答案。 - Roman Puchkovskiy
2个回答

62
这是因为这些调用实际上是调用ExecutorService中两个不同的重载方法的调用;每个方法都采用不同类型的单个参数:
  1. <T> Future<T> submit(Callable<T> task); 2. Future<?> submit(Runnable task);
然后,编译器将第一个问题中的lambda转换为Callable<?>函数接口(调用第一个重载方法);而在第二个问题中,将lambda转换为Runnable函数接口(因此调用第二个重载方法),因此需要处理抛出的Exception。但是,在使用Callable的先前情况下不需要。
尽管两个函数接口都不需要任何参数,但是Callable<?>返回一个值:
  1. Callable<?>:V call() throws Exception; 2. Runnable:public abstract void run();
如果我们切换到将代码裁剪为相关部分的示例(以便轻松地仅调查好奇的部分),则可以编写与原始示例等效的代码:
    ExecutorService executor = Executors.newSingleThreadExecutor();

    // LAMBDA COMPILED INTO A 'Callable<?>'
    executor.submit(() -> {
        while (true)
            throw new Exception();
    });
    
    // LAMBDA COMPILED INTO A 'Runnable': EXCEPTIONS MUST BE HANDLED BY LAMBDA ITSELF!
    executor.submit(() -> {
        boolean value = true;
        while (value)
            throw new Exception();
    });

通过这些示例,可以更容易地观察到第一个示例被转换为 Callable<?>,而第二个示例被转换为 Runnable 的原因是由于编译器的推断。在两种情况下,lambda主体都是void-compatible的,因为块中的每个返回语句都具有 return; 的形式。现在,在第一种情况下,编译器执行以下操作:
  1. 检测lambda中的所有执行路径是否声明了已检查异常(从现在开始我们将其称为'异常',仅指'已检查异常')。这包括调用任何声明抛出异常的方法以及显式调用throw new <CHECKED_EXCEPTION>()
  2. 正确地得出lambda的整个主体相当于声明抛出异常的代码块;这当然必须要么处理或重新抛出。
  3. 由于lambda没有处理异常,编译器默认假设这些异常必须被重新抛出。
  4. 安全地推断出此lambda必须匹配一个函数接口不能正常完成,因此是值兼容的。
  5. 由于Callable<?>Runnable都可能与此lambda匹配,编译器选择最具体的匹配(以涵盖所有情况);这是Callable<?>,将lambda转换为它的实例,并创建对submit(Callable<?>)重载方法的调用引用。

而在第二种情况下,编译器执行以下操作:

  1. 检测到lambda表达式中可能存在未声明抛出异常的执行路径(取决于待评估的逻辑)。
  2. 由于并非所有执行路径都声明抛出异常,编译器得出结论:lambda体不一定等同于声明抛出异常的代码块——编译器不关心/注意某些代码部分是否声明它们可能抛出异常,只关注整个体是否声明。
  3. 安全地推断出lambda不是值兼容的,因为它可能会正常完成
  4. 选择Runnable(作为唯一可用的合适的函数接口)并创建对submit(Runnable)重载方法的调用引用。所有这些都要以将处理任何在lambda体内可能发生的异常的责任委托给用户为代价。

这是一个很好的问题-我很享受追寻它的过程,谢谢!


4
我认为你颠倒了Runnable和Callable的概念。第一个代码块不需要捕获InterruptedException,因为lambda被解释为Callable。此外,我不理解你对为什么一个lambda被解释为Callable而另一个不是的解释;两个lambda同样可能抛出InterruptedException。 - VGR
1
你说得对,我在处理更注重的例子时,不小心把它们与问题相对调了。关于对Callable的解释,这是编译器的优化,我会在答案中详细说明。 - Marco R.
2
@MarcoR。这个答案很好,也很有启发性!但是作为一个纯粹为了知识而阅读此问题的读者,我有一个问题:为什么我们可以安全地且不失一般性地将Thread.sleep(在OP中)转换为throw new Exception(在您的答案中)?据我所知,Thread.sleep并不总是会抛出异常,因此我认为在任何情况下编译器都应该根据运行时的系统状态推断出两种情况可能会或可能不会抛出异常。我错过了什么吗? - Ertai87
3
这是一个好问题,我只是措辞不当;问题并不在于 lambda 函数的主体“可能/将会抛出异常”,而在于 lambda 函数的主体相当于“声明抛出异常”(必须捕获或重新抛出)。我修改了答案以更准确地解决这个潜在的混淆。感谢您提醒,如果仍然不清楚或者您有其他问题,请告诉我。 - Marco R.
2
@Ertai87 编译器无法推断运行时行为,因此它推断的不是代码将要做什么,而是代码可能会做什么。在 Callable 的情况下,lambda 主体可以被正确解释为整体能够抛出异常(必须处理或声明重新抛出)。在 Runnable 的情况下,lambda 主体不能被概括为整体抛出异常 - 编译器不关心/注意代码的某些部分是否声明了可能抛出异常,只关心整个主体是否抛出异常。 - Marco R.
显示剩余6条评论

3

简述

ExecutorService接口有两个方法:submit(Callable)submit(Runnable)

  1. 在第一个例子中(包含while(true)的代码段),submit(Callable)submit(Runnable)都匹配,编译器需要在两者之间做出选择
    • 由于CallableRunnable更具体,因此编译器选择了submit(Callable)
    • Callablecall()方法声明了throws Exception,因此不需要在其中捕获异常
  2. 在第二个例子中(包含while (tasksObserving)的代码段),只有submit(Runnable)与之匹配,所以编译器选择了它
    • Runnablerun()方法没有throws声明,因此如果不在run()内部捕获异常会导致编译错误

详细介绍

Java语言规范描述了程序编译期间如何选择方法:$15.2.2

  1. 识别可能适用的方法($15.12.2.1),对于精确匹配、宽松匹配和可变参数匹配,分3个阶段来完成
  2. 从第一步找到的方法中选择最具体的方法($15.12.2.5)。

现在让我们分析OP提供的两个代码片段中的两个submit()方法的情况:

ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.submit(() -> {
        while(true)
        {
            //DO SOMETHING
            Thread.sleep(5000);
        }
    });

并且

ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.submit(() -> {
        while(tasksObserving)
        {
            //DO SOMETHING
            Thread.sleep(5000);
        }
    });

(其中tasksObserving不是最终变量)。

识别潜在适用的方法

首先,编译器必须识别出潜在适用的方法:$15.12.2.1

如果成员是具有n个参数的固定参数数量的方法,那么方法调用的参数数量等于n,并且对于所有i(1≤i≤n),方法调用的第i个参数与该方法的第i个参数的类型是潜在兼容的,下面定义了这一概念。

在同一段落稍后的地方:

根据以下规则,一个表达式可以与目标类型潜在兼容:

如果函数接口类型为void,则lambda表达式(§15.27)可以与其潜在兼容, 如果以下所有条件均为真:

目标类型函数类型的元数与lambda表达式相同。

如果目标类型的函数类型具有void返回值,则lambda主体是语句表达式(§14.8)或void兼容块(§15.27.2)之一。

如果目标类型的函数类型具有非void返回类型,则lambda主体是表达式或值兼容块(§15.27.2)之一。

请注意,在这两种情况下,lambda都是块lambda。

还要注意,Runnable具有void返回类型,因此,为了与Runnable潜在兼容,块lambda必须是void兼容块。同时,Callable具有非void返回类型,因此,块lambda为了与Callable潜在兼容,必须是值兼容块

$15.27.2定义了void兼容块值兼容块的概念。

如果块lambda中的每个return语句的形式为return;,则块lambda主体是void兼容的。

如果块lambda无法正常完成(§14.21),并且块中的每个return语句的形式为return Expression;,则块lambda主体是值兼容的。

让我们看看$14.21中关于while循环的段落:

当且仅当以下至少有一个条件为真时,while语句可以正常完成:

while语句是可达的,并且条件表达式不是值为true的常量表达式(§15.28)。

存在可达的break语句退出while语句。

在这两种情况下,lambda实际上都是块lambda。

在第一种情况下,可以看到有一个带有常量表达式的while循环,其值为true(没有break语句),因此它无法正常完成(见$14.21);同时它没有返回语句,因此第一个lambda是value-compatible
同时,根本没有return语句,因此它也是void-compatible。因此,在第一种情况下,lambda既是void-compatible,也是value-compatible。 在第二种情况下,while循环从编译器的角度来看可以正常完成(因为循环表达式不再是常量表达式),因此整个lambda可以正常完成,因此它不是value-compatible block。但是,因为它不包含任何return语句,所以它仍然是void-compatible block
中间结果是,在第一种情况下,lambda既是void-compatible block,也是value-compatible block;而在第二种情况下,它只是void-compatible block
回想一下我们之前注意到的,这意味着在第一种情况下,lambda将与CallableRunnable都是potentially compatible;而在第二种情况下,lambda只与Runnablepotentially compatible

选择最具体的方法

对于第一种情况,编译器必须在两个方法之间进行选择,因为两者都是potentially applicable。它使用称为“选择最具体方法”的过程来进行选择,并在$15.12.2.5中描述。以下是摘录:

如果T不是S的子类型并且以下条件之一成立,则函数式接口类型S对于表达式e比函数式接口类型T更具体(其中U1 ... Uk和R1是S的捕获函数类型的参数类型和返回类型,V1 ... Vk和R2是T的函数类型的参数类型和返回类型):

如果e是显式类型的lambda表达式(§15.27.1),则以下条件之一成立:

R2是void。

首先,

带有零个参数的lambda表达式是显式类型的。

此外,RunnableCallable都不是彼此的子类,而且Runnable的返回类型为void,因此我们有一个匹配项:CallableRunnable更具体。这意味着在submit(Callable)submit(Runnable)之间,在第一个情况下将选择带有Callable的方法。
至于第二种情况,我们只有一个可能适用的方法submit(Runnable),所以它被选中。

那么为什么会出现更改?

因此,最终我们可以看到编译器在这些情况下选择了不同的方法。在第一个情况下,Lambda被推断为具有在其call()方法上带有throws ExceptionCallable,因此sleep()调用编译。在第二种情况下,它是没有声明任何可抛出异常的run()Runnable,因此编译器会抱怨异常未被捕获。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接