【修炼内功】[Java8] Lambda表达式里的陷阱

29次阅读

共计 9943 个字符,预计需要花费 25 分钟才能阅读完成。

本文已收录【修炼内功】跃迁之路

Lambdab 表达式带来的好处就不再做过多的介绍了,这里重点介绍几点,在使用 Lambda 表达式过程中可能遇到的 ” 陷阱 ”
Effectively Final
在使用 Lambda 表达式的过程中,经常会遇到如下的问题

图中的 sayWords 为什么一定要是 final 类型,effectively final 又是什么?
但,如果改为如下,貌似问题又解决了

似乎,只要对 sayWords 不做变动就可以
如果将 sayWords 从方法体的变量提到类的属性中,情况又会有变化,即使对 sayWords 有更改,也会编译通过

难道,就是因为局部变量和类属性的区别?
在 Java 8 in Action 一书中有这样一段话
You may be asking yourself why local variables have these restrictions. First, there’s a key difference in how instance and local variables are implemented behind the scenes. Instance variables are stored on the heap, whereas local variables live on the stack. If a lambda could access the local variable directly and the lambda were used in a thread, then the thread using the lambda could try to access the variable after the thread that allocated the variable had deallocated it. Hence, Java implements access to a free local variable as access to a copy of it rather than access to the original variable. This makes no difference if the local variable is assigned to only once—hence the restriction. Second, this restriction also discourages typical imperative programming patterns (which, as we explain in later chapters, prevent easy parallelization) that mutate an outer variable.
首先,要理解 Local Variables 和 Instance Variables 在 JVM 内存中的区别
Local Variables 随 Thread 存储在 Stack 栈内存中,而 Instance Variables 则随 Instance 存储在 Heap 堆内存中

Local Variables 的回收取决于变量的作用域,程序的运行一旦超出变量的作用域,该内存空间便被立刻回收另作他用

Instance Variables 的回收取决于引用数,当再没有引用的时候,便会在一个 ” 合适 ” 的时间被 JVM 垃圾回收器回收

试想,如果 Lambda 表达式引用了局部变量,并且该 Lambda 表达式是在另一个线程中执行,那在某种情况下该线程则会在该局部变量被收回后 (函数执行完毕,超出变量作用域) 被使用,显然这样是不正确的;但如果 Lambda 表达式引用了类变量,则该类 (属性) 会增加一个引用数,在线程执行完之前,引用数不会归为零,也不会触发 JVM 对其的回收操作
但这解释不了图 2 的情况,同样是局部变量,只是未对 sayWords 做改动,也是可以通过编译的,这里便要介绍 effectively final
Baeldung 大神的博文中有这样一段话
Accessing a non-final variable inside lambda expressions will cause the compile-time error. But it doesn’t mean that you should mark every target variable as final.According to the“effectively final”concept, a compiler treats every variable as final, as long as it is assigned only once.
It is safe to use such variables inside lambdas because the compiler will control their state and trigger a compile-time error immediately after any attempt to change them.

其中提到了 assigned only once,字面理解便是只赋值了一次,对于这种情况,编译器便会 treats variable as final,对于只赋值一次的局部变量,编译器会将其认定为 effectively final,其实对于 effectively final 的局部变量,Lambda 表达式中引用的是其副本,而该副本的是不会发生变化的,其效果就和 final 是一致的
Throwing Exception
Java 的异常分为两种,受检异常 (Checked Exception) 和非受检异常(Unchecked Exception)

Checked Exception, the exceptions that are checked at compile time. If some code within a method throws a checked exception, then the method must either handle the exception or it must specify the exception using throws keyword.Unchecked Exception, the exceptions that are not checked at compiled time. It is up to the programmers to be civilized, and specify or catch the exceptions.

简单的讲,受检异常必须使用 try…catch 进行捕获处理,或者使用 throws 语句表明该方法可能抛出受检异常,由调用方进行捕获处理,而非受检异常则不用。受检异常的处理是强制的,在编译时检测。

在 Lambda 表达式内部抛出异常,我们该如何处理?
Unchecked Exception
首先,看一段示例
public class Exceptional {
public static void main(String[] args) {
Stream.of(3, 8, 5, 6, 0, 2, 4).forEach(lambdaWrapper(i -> System.out.println(15 / i)));
}

private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) {
return i -> consumer.accept(i);
}
}
该段代码是可以编译通过的,但运行的结果是
> 5
> 1
> 3
> 2
> Exception in thread “main” java.lang.ArithmeticException: / by zero
at Exceptional.lambda$main$0(Exceptional.java:13)
at Exceptional.lambda$lambdaWrapper$1(Exceptional.java:17)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
at java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:580)
at Exceptional.main(Exceptional.java:13)
由于 Lambda 内部计算时,由于除数为零抛出了 ArithmeticException 异常,导致流程中断,为了解决此问题可以在 lambdaWrapper 函数中加入 try…catch
private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) {
return i -> {
try {
consumer.accept(i);
} catch (ArithmeticException e) {
System.err.println(“Arithmetic Exception occurred : ” + e.getMessage());
}
};
}
再次运行
> 5
> 1
> 3
> 2
> Arithmetic Exception occurred : / by zero
> 7
> 3
对于 Lambda 内部非受检异常,只需要使用 try…catch 即可,无需做过多的处理
Checked Exception
同样,一段示例
public class Exceptional {
public static void main(String[] args) {
Stream.of(3, 8, 5, 6, 0, 2, 4).forEach(lambdaWrapper(i -> writeToFile(i)));
}

private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) {
return i -> consumer.accept(i);
}

private static void writeToFile(int integer) throws IOException {
// logic to write to file which throws IOException
}
}
由于 IOException 为受检异常,该段将会程序编译失败

按照 Unchecked Exception 一节中的思路,我们在 lambdaWrapper 中使用 try…catch 处理异常
private static Consumer<Integer> lambdaWrapper(IntConsumer consumer) {
return i -> {
try {
consumer.accept(i);
} catch (IOException e) {
System.err.println(“IOException Exception occurred : ” + e.getMessage());
}
};
}
但出乎意料,程序依然编译失败

查看 IntConsumer 定义,其并未对接口 accept 声明异常
@FunctionalInterface
public interface IntConsumer {
/**
* Performs this operation on the given argument.
*
* @param value the input argument
*/
void accept(int value);
}
为了解决此问题,我们可以自己定义一个声明了异常的 ThrowingIntConsumer
@FunctionalInterface
public interface ThrowingIntConsumer<E extends Exception> {
/**
* Performs this operation on the given argument.
*
* @param value the input argument
* @throws E
*/
void accept(int value) throws E;
}
改造代码如下
private static Consumer<Integer> lambdaWrapper(ThrowingIntConsumer<IOException> consumer) {
return i -> {
try {
consumer.accept(i);
} catch (IOException e) {
System.err.println(“IOException Exception occurred : ” + e.getMessage());
}
};
}
但,如果我们希望在出现异常的时候终止流程,而不是继续运行,可以在获取到受检异常后抛出非受检异常
private static Consumer<Integer> lambdaWrapper(ThrowingIntConsumer<IOException> consumer) {
return i -> {
try {
consumer.accept(i);
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e.getCause());
}
};
}
所有使用了 ThrowingIntConsumer 的地方都需要写一遍 try…catch,有没有优雅的方式?或许可以从 ThrowingIntConsumer 下手
@FunctionalInterface
public interface ThrowingIntConsumer<E extends Exception> {
/**
* Performs this operation on the given argument.
*
* @param value the input argument
* @throws E
*/
void accept(int value) throws E;

/**
* @return a IntConsumer instance which wraps thrown checked exception instance into a RuntimeException
*/
default IntConsumer uncheck() {
return i -> {
try {
accept(i);
} catch (final E e) {
throw new RuntimeException(e.getMessage(), e.getCause());
}
};
}
}
我们在 ThrowingIntConsumer 中定义了一个默认函数 uncheck,其内部会自动调用 Lambda 表达式,并在捕获到异常后将其转为非受检异常并重新抛出
此时,我们便可以将 lambdaWrapper 函数优化如下
private static Consumer<Integer> lambdaWrapper(ThrowingIntConsumer<IOException> consumer) {
return i -> consumer.accept(i).uncheck();
}
unCheck 会将 IOException 异常转为 RuntimeException 抛出
有没有更优雅一些的方式?由于篇幅原因不再过多介绍,感兴趣的可以参考 throwing-function 及 Vavr

this pointer
Java 中,类 (匿名类) 中都可以使用 this,Lambda 表达式也不例外
public class ThisPointer {
public static void main(String[] args) {
ThisPointer thisPointer = new ThisPointer(“manerfan”);
new Thread(thisPointer.getPrinter()).start();
}

private String name;

@Getter
private Runnable printer;

public ThisPointer(String name) {
this.name = name;
this.printer = () -> System.out.println(this);
}

@Override
public String toString() {
return “hello ” + name;
}
}
在 ThisPointer 类的构造函数中,使用 Lambda 表达式定义了 printer 属性,并重写了类的 toString 方法
运行后结果
> hello manerfan
ThisPointer 类的构造函数中,将 printer 属性的定义改为匿名类
public class ThisPointer {
public static void main(String[] args) {
ThisPointer thisPointer = new ThisPointer(“manerfan”);
new Thread(thisPointer.getPrinter()).start();
}

private String name;

@Getter
private Runnable printer;

public ThisPointer(String name) {
this.name = name;
this.printer = new Runnable() {
@Override
public void run() {
System.out.println(this);
}
};
}

@Override
public String toString() {
return “hello ” + name;
}
}
重新运行后结果
> ThisPointer$1@782b1823
可见,Lambda 表达式及匿名类中的 this 指向的并不是同一内存地址
这里我们需要理解,在 Lambda 表达式中它在词法上绑定到周围的类 (定义该 Lambda 表达式时所处的类),而在匿名类中它在词法上绑定到匿名类
Java 语言规范在 15.27.2 描述了这种行为
Unlike code appearing in anonymous class declarations, the meaning of names and the this and super keywords appearing in a lambda body, along with the accessibility of referenced declarations, are the same as in the surrounding context (except that lambda parameters introduce new names).The transparency of this (both explicit and implicit) in the body of a lambda expression – that is, treating it the same as in the surrounding context – allows more flexibility for implementations, and prevents the meaning of unqualified names in the body from being dependent on overload resolution.
Practically speaking, it is unusual for a lambda expression to need to talk about itself (either to call itself recursively or to invoke its other methods), while it is more common to want to use names to refer to things in the enclosing class that would otherwise be shadowed (this, toString()). If it is necessary for a lambda expression to refer to itself (as if via this), a method reference or an anonymous inner class should be used instead.

那,如何在匿名类中如何做到 Lambda 表达式的效果,获取到周围类的 this 呢?这时候就必须使用 qualified this 了,如下
public class ThisPointer {
public static void main(String[] args) {
ThisPointer thisPointer = new ThisPointer(“manerfan”);
new Thread(thisPointer.getPrinter()).start();
}

private String name;

@Getter
private Runnable printer;

public ThisPointer(String name) {
this.name = name;
this.printer = new Runnable() {
@Override
public void run() {
System.out.println(ThisPointer.this);
}
};
}

@Override
public String toString() {
return “hello ” + name;
}
}
运行结果如下
> hello manerfan
其他
在排查问题的时候,查看异常栈是必不可少的一种方法,其会记录异常出现的详细记录,包括类名、方法名行号等等信息
那,Lambda 表达式中的异常栈信息是如何的?
public class ExceptionStack {
public static void main(String[] args) {
new ExceptionStack().run();
}

private Function<Integer, Integer> divBy100 = divBy(100);

void run() {
Stream.of(1, 7, 0, 6).filter(this::isEven).map(this::div).forEach(System.out::println);
}

boolean isEven(int i) {
return 0 == i / 2;
}

int div(int i) {
return divBy100.apply(i);
}

Function<Integer, Integer> divBy(int div) {
return i -> div / i;
}
}
这里我们故意制造了一个 ArithmeticException,并且增加了异常的栈深,运行后的异常信息如下
Exception in thread “main” java.lang.ArithmeticException: / by zero
at ExceptionStack.lambda$divBy$0(ExceptionStack.java:30)
at ExceptionStack.div(ExceptionStack.java:26)
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
at java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:175)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
at java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:418)
at ExceptionStack.run(ExceptionStack.java:18)
at ExceptionStack.main(ExceptionStack.java:12)
异常信息中的 ExceptionStack.lambda$divBy$0 ReferencePipeline$3$1.accept 等并不能让我们很快地了解,具体是类中哪个方法出现了问题,此类问题在很多编程语言中都存在,也希望 JVM 有朝一日可以彻底解决
关于 Lambda 表达式中的 ” 陷阱 ” 不仅限于此,也希望大家能够一起来讨论

正文完
 0