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

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

clipboard.png

Lambdab表达式带来的好处就不再做过多的介绍了,这里重点介绍几点,在使用Lambda表达式过程中可能遇到的"陷阱"

0x00 Effectively Final

在使用Lambda表达式的过程中,经常会遇到如下的问题

labmda1.png

图中的sayWords为什么一定要是final类型,effectively final又是什么?

但,如果改为如下,貌似问题又解决了

labmda2.png

似乎,只要对sayWords不做变动就可以

如果将sayWords从方法体的变量提到类的属性中,情况又会有变化,即使对sayWords有更改,也会编译通过

labmda3.png

难道,就是因为局部变量和类属性的区别?

在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 VariablesInstance Variables在JVM内存中的区别

Local VariablesThread存储在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是一致的

Effectively Final更深入的解释,可以参考Why Do Local Variables Used in Lambdas Have to Be Final or Effectively Final?

小结:

  1. Lambda表达式中可以直接引用Instance Variables
  2. Lambda表达式中引用Local Variables,必须为finaleffectively final( assigned only once)

0x01 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-exption-1.jpg

在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为受检异常,该段将会程序编译失败

lambda-exption-2.jpg

按照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());
        }
    };
}

但出乎意料,程序依然编译失败

lambda-exption-4.jpg

查看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-functionVavr

小结:

  1. Lambda表达式抛出非受检异常,可以在Lambda表达式内部或外部直接使用try…catch捕获处理
  2. Lambda表达式抛出受检异常,可以在Lambda表达式内部直接使用try…catch捕获处理,如果需要在Lambda表达式外部捕获处理,必须在FunctionalInterface接口上显式声明throws

0x02 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

小结:

  1. Lambda表达式中,this在词法上绑定到周围的类 (定义该Lambda表达式时所处的类)
  2. 匿名类中,this在词法上绑定到匿名类
  3. 匿名类中,如果需要引用周围类this,需要使用qualified this

0x03 其他

在排查问题的时候,查看异常栈是必不可少的一种方法,其会记录异常出现的详细记录,包括类名、方法名行号等等信息

那,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表达式中的"陷阱"不仅限于此,也希望大家能够一起来讨论


订阅号

阅读 4.6k

推荐阅读
林中小舍
用户专栏

工作中的坑点及经验

51 人关注
41 篇文章
专栏主页