JDK8 新特性最全讲解,囊括面试高频知识点

JDK8 新特性最全讲解,囊括面试高频知识点

序言

JDK 8 日渐成为项目开发中的主流。

但平时在和很多小伙伴的交流和面试中,发现很多人仍停留在 JDK 7 及以前的认知层面,Lambda 表达式、方法引用、Stream 流、default 关键字,很少使用,甚至还有不少小伙伴不知道怎么用!!

不客气地说,不掌握 JDK 8 的新特性,面试通过基本很难很难。换位思考,若不掌握,你面试不慌吗?

本文会帮你详细梳理 JDK 8 中的新特性,有原理讲解,有示例实战,助力你面试起飞。

提前预警,本文很长!但一定是干货满满的,对于技术文章而言,短小精悍的特点并不是好事,因此我写的文章都偏长,注重干货,注重前因后果,做到知其然更要知其所以然。如果你有耐心读下去,一定会有较大收获。

如果没耐心看下去,或没时间看,请直接跳到最后的第四节,可微信公众号收藏留以备用~

一、Lambda 表达式

在 Java 语言中使用 Lambda 表达式,是 JDK 8 推出的最重要特性之一。它能够简化我们的传统操作。

在具体描述 Lambda 表达式之前,我们需要补充一些基础知识:什么是函数式接口。

1.1 函数式接口的定义

提到函数式接口(unctional interface),就牵扯到一个注解:@FunctionInterface。

所谓函数式接口,是指的一类添加了 @FunctionInterface 注解的接口。换言之,只要一个接口有 @FunctionInterface 注解,那这个接口就是函数式接口。

举个例子就明白了。

当你在对任务 taskA 处理时,如果想异步处理,不影响主干流程的继续进行,你会怎么做?

初级版:新增一个类,实现 Runnable 接口

你会说很简单呐,另起一个线程去执行任务 taskA 就可以了呀,喏,如下:

/\*\* \* @author: sss \*/
public class TaskAThread implements Runnable {
    @Override
    public void run() {
        // process taskA
        ...
    }
}

public class Main {
    public static void main(String[] args) {
        // new 一个新线程,执行任务A
        Runnable taskA = new TaskAThread();
        new Thread(taskA).start();

        // 主线程继续做其他事情
        System.out.println("do other things...");
    }
}

这种方式是可以实现,但有没有其他方式呢?

进阶版:使用匿名内部类

有些小伙伴明显的发现了上面代码中的问题:繁琐!!只是为了创建一个线程并使用它的 run() 方法,还要新增一个类,没有必要,直接使用匿名类就解决啦:

public class Main {
    public static void main(String[] args) {
        // 通过匿名类来创建一个新线程,执行任务A
        Runnable taskA = new Runnable() {
            @Override
            public void run() {
                // process taskA
                ...
            }
        };
        new Thread(taskA).start();

        // 主线程继续做其他事情
        System.out.println("do other things...");
    }
}

通过匿名类的方式,省去了新增一个类的操作,大大简化。但若使用 Lambda 的方式,会更加简洁。

高级版:使用 Lambda 表达式

public static void main(String[] args) {
    // 通过匿名类来创建一个新线程,执行任务A
    new Thread(() -> {
        System.out.println("正在异步处理 taskA 中...");
        // do things
        ...
    }).start();

    // 主线程继续做其他事情
    System.out.println("do other things...");
}

有没有发现很神奇,类似 () -> {...} 的这种箭头式写法竟然能通过编译!而且还能运行(不信的小伙伴可以试试)!这种就是 Lambda 表达式的其中一种写法,不理解的小伙伴也没关系,我们后面会详细解释。

也许这种 Lambda 写法很多小伙伴见过,并习以为常,但为什么可以运行,你知道根本原因吗?

这里就体现出函数式接口的作用了。我们去看一下 JDK 7 和 JDK 8 中关于 Runnable 接口的定义,如下。大家有发现什么不同点了吗?

眼尖的小伙伴一定发现了,JDK 8 中多了个注解 @FunctionalInterface。这就是为何能在 JDK 8 中可以使用这种箭头式的 Lambda 写法。

本小节最开始时我们也提到了此注解。从上图也能看出,@FunctionalInterface 是JDK 8 中新引入的一个注解,它定义了一类新的接口(即函数式接口),该类接口有且只能有一个抽象方法。

它主要用于编译期的错误检查,如果一个接口不包含抽象方法(Serializable、Cloneable 等标记接口),或者包含多个抽象方法,都不符合 @FunctionalInterface 注解的定义,加了就会出错,如下这种:

// 错误示例 1
@FunctionalInterface
interface InvalidInterfaceA {
}

// 错误示例 2
@FunctionalInterface
interface InvalidInterfaceB {
    void testA();
    void testB();
}

正确示范:

@FunctionalInterface
interface InvalidInterfaceC {
    void testC();
}

@FunctionalInterface
interface InvalidInterfaceD {
    void testD();
    default void testE() {
        System.out.println("this is a default method.");
    }
}

@FunctionalInterface 修饰的接口,只能有一个抽象方法,但并代表只能有一个方法声明,像上面的 InvalidInterfaceD 接口,还有 default 关键字修饰的 testE() 方法,但这是一个有默认实现的方法,并不是抽象方法,因此接口 InvalidInterfaceD 依然符合函数式接口的定义。

另外,我们仔细看下注解的描述片段: 第一块内容是使用 @FunctionalInterface 注解需满足的 2 个条件:

  • 必须是接口,不能是注解、枚举或类,限定了使用的类型范围
  • 被注解的接口,必须满足函数式接口的定义,即只能有一个抽象函数

第二块内容是 @FunctionalInterface 注解的功能已内置于编译器的处理逻辑中:不管一个接口是否添加了 @FunctionalInterface 注解,只要该接口满足函数式接口的定义,编译器都会把它当做函数式接口。

看下面的例子:

interface MathOperation {
    int operation(int a, int b);
}

public static void main(String args[]) {
    MathOperation addition = (int a, int b) -> a + b;
}

上面的 MathOperation 接口,并没有添加 @FunctionalInterface 注解,但依然可以使用 Lambda 表达式,就是因为它符合函数式接口的定义,JDK 8 的编译器默认将其当做函数式接口(上面代码中的箭头表达式不懂没关系,我们下面会详细讲解)。

在 JDK 8 中,推出了一个新的包 java.util.function,它里面内置了一些我们常用的函数式接口,如 Predicate、Supplier、Consumer 等接口。

1.2 什么是 Lambda 表达式

总结了很久,发现还是很难用语言来定义什么是 Lambda 表达式,它更适合结合示例来说明。

1.2.1 示例 1

还是以上面的异步线程执行任务 A 为例。在 Lambda 表达式之前,我们最精简的写法就是使用匿名类,但若用 Labmda 表达式,则可直接简化成一行代码。看下面代码示例的对比:

public static void main(String[] args) {
    // 使用匿名内部类
    new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("正在异步处理 taskA 中...");
        }
    }).start();

    // 使用 Lambda 表达式
    new Thread(() -> System.out.println("正在异步处理 taskA 中...")).start();
}

上面的示例中,使用 Lambda 表达式,进一步简化了匿名类,这也是 Lambda 表达式最常用的功能。

1.2.2 示例 2

为进一步强化大家对 Lambda 表达式的理解,再举一个最常用的示例,集合类的遍历操作。在 JDK 8 以前,List 的遍历操作,要么用 for 循环,要么用迭代器(Iterator):

public static void main(String[] args) {
    List<String> strList = Arrays.asList("a", "b", "c", "d");
    // 方式1
    for (int i = 0; i < strList.size(); i++) {
        System.out.println(strList.get(i));
    }
    // 方式2,语法糖,本质还是下面的方式3
    for (String str : strList) {
        System.out.println(str);
    }
    // 方法3
    Iterator<String> iterator = strList.iterator();
    while (iterator.hasNext()) {
        System.out.println(iterator.next());
    }
}

上面的代码中,方式 2 是一种语法糖,本质上还是方法 3,大家可通过编译之后的 .class 文件来查看。但在 JDK 8 中,我们可使用 forEach() 方式来实现 Lambda 表达式下的遍历操作。

strList.forEach(str -> System.out.println(str));

进一步探究,forEach() 是怎么做到的,看下其源码:

default void forEach(Consumer\<? super T\> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

/\*\* \* Represents an operation that accepts a single input argument and returns no \* result. Unlike most other functional interfaces, {@code Consumer} is expected \* to operate via side-effects. \* \* \<p\>This is a \<a href="package-summary.html"\>functional interface\</a\> \* whose functional method is {@link \#accept(Object)}. \* \* @param \<T\> the type of the input to the operation \* \* @since 1.8 \*/
@FunctionalInterface
public interface Consumer\<T\> {
    void accept(T t);

    default Consumer\<T\> andThen(Consumer\<? super T\> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

forEach() 的形参是一个 Consumer 对象,而 Comsumer 接口又是一个有 @FunctionalInterface 注解的函数式接口,其抽象方法是 accept(T t)。

此时,如果我们撇开 Lambda 表达式,使用匿名类,依然可以做到,如下:

strList.forEach(new Consumer<String>() {
    @Override
    public void accept(String str) {
        System.out.println(str);
    }
});

既然 Consumer 是一个函数式接口,就可以使用更简洁的 Lambda 表达式:

strList.forEach(str -> System.out.println(str));

1.2.3 小结

有了前面两个示例,你应该对 Lambda 表达式有个大体的印象了。

若一个方法的形参是一个接口类型,且该接口是一个函数式接口(即只有一个抽象方法),那么就可以使用 Lambda 表达式来替代其对应的匿名类,达到易读、简化的目的。

通常,Lambda 表达式的格式如下:

() -> {...}
或
(xxx) -> {...}

从前面的示例也可以看到,Lambda 表达式其实就代表了一个接口的实例对象,并且这个接口还得是一个函数式接口,即只能有一个抽象方法,这个抽象方法的具体实现,就是 Lambda 表达式中箭头的右侧 body 部分。

1.3 Lambda 表达式特性及示例

前面我们初识了 Lambda 表达式,那么,它又有哪些特性呢?

特性 1:由箭头将表达式分为左、右两个部分

必须是形如 () -> {...} 的形式。

特性 2:入参可为零个、一个、多个

当为零个时,箭头左侧的括号不可省略:

() -> {System.out.println("test expression!");};
() -> 123;

当入参为 1 个时,箭头左侧的圆括号可省略:

(x) -> {System.out.println(x);};
x => x + 2;

当入参为多个时,左侧括号不能省略:

(x, y, z) -> {
    System.out.println(x);
    System.out.println(y);
    System.out.println(z);
};

以上都是合法表达式。但是,这并不意味着他们可以独立存在。若不给这些表达式赋左值,则编译器会报错:Not s statement

前面我们也有提到,Lambda 表达式其实是一个实例对象,因此,赋左值,自然是赋值给某个特定类型的实例。它是如何赋值的呢?可手动指定,也可根据 IDE 自动生成(此时编译器会自动推断左值类型)。在正常使用过程中,我们往往都会有目的的手动赋左值。

特性 3:入参类型声明可省略,编译器会做自动类型推断

List<String> strList = Arrays.asList("a", "b", "c", "d");
strList.forEach(str -> {
    System.out.println(str);
});

上方代码中,Lambda 表达式中的 str 局部变量,不需要再次声明类型,因为编译器会从 strList 变量中推断出 str 变量的类型为 String。

特性 4:表达式右侧的 body 中,只有一条语句,则可省略大括号,否则不可省略

上面的 strList 变量的 forEach() 方式的遍历,可简化为如下形式:

strList.forEach(str -> System.out.println(str));

特性 5:表达式的返回值是可选的

上面的 forEach() 方式,就是没有返回值的,也可认为是 void。

1.4 为何引入 Lambda 表达式

我们先来简述下几种常见的编程范式。

1.4.1 几种常见的编程范式

编程范式代表了计算机编程语言的典型风格和编程方式,通俗来说,编程范式就是对各种编程语言的分类,分类的依据,就是对各类编程语言的行为和处理方式进行抽象拔高,再看是否都是一类。

这么说比较抽象,举几种常见的编程范式:命令式编程声明式编程函数式编程

我们看一个具体示例:

你眼前有一个水果篮,里面放了一堆的苹果和桔子。这时候,你老板跟你说:“小张,交给你一个事儿,你从水果篮中一个个拿出水果,如果是桔子,则放回,继续从水果篮中拿下一个水果,如果是苹果,再看是否有 M 标签,如果没有,则放回,如果有 M 标签,再看这个苹果是否已坏掉,如果坏掉,则返回,如果没坏掉,则把该苹果挑出来”,然后你很快就按老板的指示圆满完成了任务。

这时,如果你老板是程序员,你是计算机,那么你老板就在使用命令式编程。他会把每一步该怎么做都告诉你,然后你只需要严格按照他要求的去做就可以完成任务。

但是,我们考虑另外一种情况:

你老板跟你说:“小张,交给你一件事,把水果篮里的贴了 M 标签的没有坏掉的苹果都捡出来”。然后你按照老板的要求,一个个把符合条件的苹果捡出来。

此时,老板并没有告诉你该怎么一步步的把符合条件的苹果捡出来,它只是告诉了你他想要的是什么(what),但并没有告诉你该怎么做(how),这种就是声明式编程

一般来说,绝大多数的程序员都是使用的命令式编程的风格,像 Java、C、C++ 等,都属于命令式编程语言,它们都需要由程序员来严格指定每一步该怎么做,语言本身是不会做任何特殊逻辑处理。这和冯诺依曼体系的计算机一致,指令存储在内存中,由 CPU 一条条执行指令做运算,并将数据再放回内存。

从编程范式的角度来看,像 Java、C++ 等这些高级编程语言,本质上和更接近机器语言的汇编语言没有区别,都是基于冯诺依曼体系计算机模式的思想,都是命令式编程。相比汇编语言,高级语言只是更符合我们人类认知的习惯和便于理解、编写,但编译后,还是变成了天书般的机器语言。

我们经常接触的 SQL 语句,其实就是声明式编程。如下面的语句:

#### 找出所有学生的数学成绩
select name,
       age,
       course,
       score
  from student
 where course= "math";

上面的 SQL 语句,只是声明了需要什么(找出所有学生的数学成绩),但至于怎么找,语言层面不需要关心,交给数据库系统来处理。

函数式编程,是近几年火起来的一种编程范式,但其早就存在于我们周围,像 JavaScript 就是一种函数式编程语言。函数式语言最鲜明的特点,是允许将函数作为入参传递给另一个函数,且也可以返回一个函数。像我们常用的 Java 语言,其函数是无法独立存在的,必须声明在某个类的内部,换句话说,Java 中的函数是依附于某个特定类的,且服务于该类的域变量。因此若要按等级来划分,对象或变量的级别是高于函数的。但在函数式编程语言中,函数可当做参数传递,也可作为返回值,我们称之为高阶函数。看下面的示例:

def sum(x):
    def add(y):
        return x + y;
    return add;

sum2 = sum(2);
elementB = sum(7);
a = sum2(3); \#\#\# 2 + 3 = 5
b = elementB(1); \#\#\# 7 + 1 = 8
print a; \#\#\# 输出5
print b; \#\#\# 输出8

示例中,sum() 函数内部定义了 add() 函数,两者各自有一个入参,且 sum() 函数的返回值是 add() 函数。那么这里的 sum() 就是一个高阶函数。它做了件什么事情呢?很简单,求两个数值的和。在 Java 中,它是怎么实现的呢?

public int sum(int x, int y) {
    return x + y;
}

这是 Java 中的写法,但函数式编程的计算思想和我们常规理解的不同,它使用了两个函数来实现。比如前面的示例中,要计算 2+3,首先通过函数 sum(2) 得到一个变量 sum2,它同时也是一个函数,即 add() 函数,我们再次把数字 3 作为参数传进去:sum2(3),就得到了求和的值 6。

通过以上的示例对比,就能发现函数式编程的核心思想:通过函数来操作数据,复杂逻辑的实现是通过多个函数的组合来实现的。相比声明式编程和命令式编程,它是一种更高级别的抽象:汇编语言要求我们如何用机器能理解的语言来写代码(指令);高级语言如 Java、C++ 则是使用易于人理解的方式,但如何做,还需要我们来一步步设定,仍未逃脱指令式的思维模式;函数式编程,通过函数来操作数据,至于函数内部做了什么,交给其他函数来组合实现。

1.4.2 为何引入 Lambda

因为 Lambda 表达式是属于函数式编程的范围(将函数视作变量或对象),且后面要讲到的 Stream 流,都属于函数式编程的范围,所以,这个问题的问法是可以再扩大化,即:为何会引入函数式编程的用法?

原因 1:使得代码更简洁,可读性强

如果你有仔细阅读前面的介绍,你会发现,Lambda 表达式本质上就是一个函数,就是其对应的函数式接口的那个唯一抽象方法的具体实现!再来回顾一下代码:

new Thread(() -> System.out.println("this is a Lambda expression!")).start();

Thread 类的有参构造函数 Thread(Runnable runnable),本来参数是一个 Runnable 对象,

但 Java 作为一枚面向对象的编程语言,除了像 int、double、char 等 8 种基本数据类型,其他的一切都是对象,包括类(class)、接口(interface)、枚举(Enum)、数组(Array)。但函数并不是对象,它只能依附于对象而存在,按层级划分的话,函数是低于对象的,它是无法作为一个方法的入参或者返回值的。

在这种限制下,Java 的部分功能代码就难免出现臃肿的现象。比如:难看又无法避免的匿名内部类、集合类的过滤、求和、转换等操作。而 Lambda 表达式的出现,就避免了这种臃肿。

而函数式编程的优点就是使用简洁、可读性高(只看函数名就知道要做什么操作),如下的 Stream 流操作:

List<String> nameList = Arrays.asList("tom", "kate", "jim", "david");
List<String> newNameList = nameList
    .stream()
    .filter(name -> name.length() > 3)
    .map(name -> name.toUpperCase())
    .sorted()
    .collect(Collectors.toList());

上面代码要实现的功能一目了然,没有大量的匿名内部类,没有多余的中间变量,没有复杂的逻辑计算。若摒弃 JDK 8 的写法,则需要使用又臭又长的代码,耗费两倍不止的时间才能实现。

所以,从可读性、易用性角度讲,函数式编程的写法完胜 JDK 7 以前的 Java 式写法。

原因 2:传递行为,而不止是传递值,更便于功能复用

因为函数是代表了一连串行为的集合,代表的是一组动作,而不止是一个数据,举个例子就明白了,看下面的示例:

// 给定一个整数集合
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

// 求所有元素的和
private Integer sumAll(List\<Integer\> list) {
    int sum = 0;
    for (Integer ele : list) {
        sum += ele;
    }
    return sum;
}

// 求所有偶数元素的和
private Integer sumEven(List\<Integer\> list) {
    int sum = 0;
    for (Integer ele : list) {
        if (ele % 2 == 0) {
            sum += ele;
        }
    }
    return sum;
}

// 求所有奇数元素的和
private Integer sumOdd(List\<Integer\> list) {
    int sum = 0;
    for (Integer ele : list) {
        if (ele % 2 == 1) {
            sum += ele;
        }
    }
    return sum;
}
// 求所有大于3的元素的和
private Integer sumLargerThan3(List\<Integer\> list) {
    int sum = 0;
    for (Integer ele : list) {
        if (ele > 3) {
            sum += ele;
        }
    }
    return sum;
}

作为一个有追求的程序员,对上面的这种代码是不能忍的,重复度太高了有木有!除了元素的判断条件不同,其他处理方式都相同。

那,对于上面的代码,我们能怎么优化呢?大家也许会想到策略模式,每一种处理,都对应一个不同的计算策略,设计模式用起来:

public interface sumStrategy {
    Integer sum(List\<Integer\> list);
}

public class SumAllStrategy implements sumStrategy {
    @Override
    public Integer sum(List\<Integer\> list) {
        int sum;
        for (Integer ele : list) {
            sum += ele;
        }
        return sum;
    }
}

public class SumEvenStrategy implements sumStrategy {
    @Override
    public Integer sum(List\<Integer\> list) {
        ...
    }
}

// 实际调用
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// 示例1:当想求所有元素的和时,使用 SumAllStrategy 类
Strategy strategy1 = new SumAllStrategy();
strategy1.sum(list);

// 示例2:当想求所有偶数元素的和时,使用 SumEvenStrategy 类
Strategy strategy2 = new SumEvenStrategy();
strategy2.sum(list);

虽然设计模式用起来了,逼格也高起来了,然并卵,代码量依然没有减少,代码并没有做到复用的目的。

有了 Lambda 表达式,以上的一切都变得简单起来,我们可以依赖一个函数式接口:Predicate 接口。

// @since 1.8
@FunctionalInterface
public interface Predicate\<T\> {
    /\*\* \* Evaluates this predicate on the given argument. \* \* @param t the input argument \* @return {@code true} if the input argument matches the predicate, \* otherwise {@code false} \*/
    boolean test(T t);

    ...
 }

里面唯一的抽象方法 test(T t),一个入参,然后返回一个布尔值,很符合这里的元素判断。

Lambda 的使用如下:

private Integer sum(List\<Integer\> list, Predicate condition) {
    int sum = 0;
    for (Integer ele : list) {
        if (condition.test(ele)) {
            sum += ele;
        }
    }
    return sum;
}

// 实际使用
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// 示例1:求所有元素的和
int sum = sum(list, x -> true);
// 示例2:求所有偶数元素的和
sum = tester.sum(list, x -> (int)x % 2 == 0);
// 示例3:求所有大于5的元素的和
sum = tester.sum(list, x -> (int)x > 5);

通过 Lambda 表达式,使用一个函数就搞定一切。

在上面的示例中,多个重复代码片段的唯一异同点,就是对元素的判断行为不同。而 Lambda 表达式,就可以把不同的判断行为当做参数传入 sum() 方法中,达到复用的目的。

原因 3:流的并行化操作

新引入的 Stream 流操作,可以串行,也可以并行:

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// 串行
Integer reduce = list.stream().reduce((a, b) -> a + b).get();
// 并行,相比串行多了 parallel() 函数
Integer reduce = list.stream().parallel().reduce((a, b) -> a + b).get();

1.5 与匿名类的区别

在一定程度上,Lambda 表达式是对匿名内部类的一种替代,避免了冗余丑陋的代码风格,但又不能完全取而代之。

我们知道,Lambda 表达式简化的是符合函数式接口定义的匿名内部类,如果一个接口有多个抽象方法,那这种接口不是函数式接口,也无法使用 Lambda 表达式来替换。

举个示例:

public interface DataOperate {
    public boolean accept(Integer value);

    public Integer convertValue(Integer value);
}

public static List\<Integer\> process(List\<Integer\> valueList, DataOperate operate) {
    return valueList.stream()
        .filter(value -> operate.accept(value))
        .map(value -> operate.convertValue(value))
        .collect(Collectors.toList());
}

public static void main(String[] args) {
    List<Integer> valueList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

    // 示例场景1: 将大于3的值翻倍,否则丢弃,得到新数组
    List<Integer> newValueList1 = process(valueList, new DataOperate() {
        @Override
        public boolean accept(Integer value) {
            return value > 3 ? true : false;
        }

        @Override
        public Integer convertValue(Integer value) {
            return value * 2;
        }
    });

    // 示例场景2:将为偶数的值除以2,否则丢弃,得到新数组
    List<Integer> newValueList2 = process(valueList, new DataOperate() {
        @Override
        public boolean accept(Integer value) {
            return value % 2 == 0 ? true : false;
        }

        @Override
        public Integer convertValue(Integer value) {
            return value / 2;
        }
    });
}

上面示例中的 DataOperate 接口,因存在两个接口,是无法使用 Lambda 表达式的,只能在调用的地方通过匿名内部类来实现。

若 DataOperate 接口多种不同的应用场景,要么使用匿名内部类来实现,要么就优雅一些,使用设计模式中的策略模式来封装一下,Lambda 在这里是不适用的。

1.6 变量作用域

不少人在使用 Lambda 表达式的尝鲜阶段,可能都遇到过一个错误提示:

Variable used in lambda expression should be final or effectively final

以上报错,就涉及到外部变量在 Labmda 表达式中的作用域,且有以下几个语法规则。

1.6.1 变量作用域的规则

规则 1:局部变量不可变,域变量或静态变量是可变的

何为局部变量?局部变量是指在我们普通的方法内部,且在 Lambda 表达式外部声明的变量。

在 Lambda 表达式内使用局部变量时,该局部变量必须是不可变的。如下的代码展示中,变量 a 就是一个局部变量,因在 Lambda 表达式中调用且改变了值,在编译期就会报错:

public class AClass {
    private Integer num1 = 1;
    private static Integer num2 = 10;

    public void testA() {
        int a = 1;
        int b = 2;
        int c = 3;
        a++;
        new Thread(() -> {
            System.out.println("a=" + a); // 在 Lambda 表达式使用前有改动,编译报错
            b++; // 在 Lambda 表达式中更改,报错
            System.out.println("c=" + c); // 在 Lambda 表达式使用之后有改动,编译报错

            System.out.println("num1=" + this.num1++); // 对象变量,或叫域变量,编译通过
            AClass.num2 = AClass.num2 + 1;
            System.out.println("num2=" + AClass.num2); // 静态变量,编译通过
        }).start();
        c++;
    }
}

上面的代码中,变量 a、b、c 都是局部变量,无论在 Lambda 表达式前、表达式中或表达式后修改,都是不允许的,直接编译报错。而对于域变量 num1,以及静态变量 num2,不受此规则限制。

规则 2:表达式内的变量名不能与局部变量重名,域变量和静态变量不受限制

不解释,看代码示例:

public class AClass {
    private Integer num1 = 1;
    private static Integer num2 = 10;

    public void testA() {
        int a = 1;
        new Thread(() -> {
            int a = 3; // 与外部的局部变量重名,编译报错
            Integer num1 = 232; // 虽与域变量重名,允许,编译通过
            Integer num2 = 11; // 虽与静态变量重名,允许,编译通过
        }).start();
    }
}

友情提醒:虽然域变量和静态变量可以重名,从可读性的角度考虑,最好也不用重复,养成良好的编码习惯。

规则 3:可使用 this、super 关键字,等同于在普通方法中使用

public class AClass extends ParentClass {
    @Override
    public void printHello() {
        System.out.println("subClass: hello budy!");
    }

    @Override
    public void printName(String name) {
        System.out.println("subClass: name=" + name);
    }

    public void testA() {
        this.printHello();  // 输出:subClass: hello budy!
        super.printName("susu"); // 输出:ParentClass: name=susu

        new Thread(() -> {
            this.printHello();  // 输出:subClass: hello budy!
            super.printName("susu"); // 输出:ParentClass: name=susu
        }).start();

    }
}

class ParentClass {
    public void printHello() {
        System.out.println("ParentClass: hello budy!");
    }

    public void printName(String name) {
        System.out.println("ParentClass: name=" + name);
    }
}

对于 this、super 关键字,大家记住一点就行啦:在 Lambda 表达式中使用,跟在普通方法中使用没有区别!

规则 4:不能使用接口中的默认方法(default 方法)

public class AClass implements testInterface {
    public void testA() {
        new Thread(() -> {
            String name = super.getName(); // 编译报错:cannot resolve method 'getName()'
        }).start();
    }
}

interface testInterface {
    // 默认方法
    default public String getName() {
        return "susu";
    }
}

1.6.2 为何要 final?

不管是 Lambda 表达式,还是匿名内部类,编译器都要求了变量必须是 final 类型的,即使不显式声明,也要确保没有修改。那大家有没有想过,为何编译器要强制设定变量为 final 或 effectively final 呢?

原因 1:引入的局部变量是副本,改变不了原本的值

看以下代码:

public static void main(String args[]) {
    int a = 3;
    String str = "susu";
    Susu123 susu123 = (x) -> System.out.println(x * 2 + str);
    susu123.print(a);
}

interface Susu123 {
    void print(int x);
}

在编译器看来,main 方法所在类的方法是如下几个:

public class Java8Tester {
    public Java8Tester(){
    }
    public static void main(java.lang.String[]){
        ...
    }
    private static void lambda$main$0(java.lang.String, int);
        ...
    }
}

可以看到,编译后的文件中,多了一个方法 lambda$main$0(java.lang.String, int),这个方法就对应了 Lambda 表达式。它有两个参数,第一个是 String 类型的参数,对应了引入的 局部变量 str,第二个参数是 int 类型,对应了传入的变量 a。

若在 Lambda 表达式中修改变量 str 的值,依然不会影响到外部的值,这对很多使用者来说,会造成误解,甚至不理解。既然在表达式内部改变不了,那就索性直接从编译器层面做限制,把有在表达式内部使用到的局部变量强制为 final 的,直接告诉使用者:这个局部变量在表达式内部不能改动,在外部也不要改啦!

原因 2:局部变量存于栈中,多线程中使用有问题

大家都知道,局部变量是存于 JVM 的栈中的,也就是线程私有的,若 Lambda 表达式中可直接修改这边变量,会不会引起什么问题?

很多小伙伴想到了,如果这个 Lambda 表达式是在另一个线程中执行的,是拿不到局部变量的,因此表达式中拥有的只能是局部变量的副本。

如下的代码:

public static void main(String args[]) {
    int b = 1;
    new Thread(() -> System.out.println(b++));
}

假设在 Lambda 表达式中是可以修改局部变量的,那在上面的代码中,就出现矛盾了。变量 b 是一个局部变量,是当前线程私有的,而 Lambda 表达式是在另外一个线程中执行的,它又怎么能改变这个局部变量 b 的值呢?这是矛盾的。

原因 3:线程安全问题

举一个经常被列举的一个例子:

public void test() {
    boolean flag = true;
    new Thread(() -> {
        while(flag) {
            ...
            flag = false;
        }
    });
    flag = false;
}

先假设 Lambda 表达式中的 flag 与外部的有关联。那么在多线程环境中,线程 A、线程 B 都在执行 Lambda 表达式,那么线程之间如何彼此知道 flag 的值呢?且外部的 flag 变量是在主线程的栈(stack)中,其他线程也无法得到其值,因此,这是自相矛盾的。

**小结:**前面我们列举了多个局部变量必须为 final 或 effectively final 的原因,而 Lambda 表达式并没有对实例变量或静态变量做任何约束。

虽然没做约束,大家也应该明白,允许使用,并不代表就是线程安全的,看下面的例子:

// 实例变量
private int a = 1;

public static void main(String args[]) {
    Java8Tester java8Tester = new Java8Tester();
    java8Tester.test();
    System.out.println(java8Tester.a);

}

public void test() {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> this.a++).start();
    }
}

以上的代码,并不是每次执行的结果都是 11,因此也存在线程安全问题。

1.7 Java 中的闭包

前面已经把 Lmabda 表达式讲的差不多了,是时候该讲一下闭包了。

闭包是函数式编程中的一个概念。在介绍 Java 中的闭包前,我们先看下 JavaScript 语言中的闭包。

function func1() {
  var s1 = 32;
    incre = function() {
        s1 + 1;
    };
    return function func2(y) {
        return s1 + y;
    };
}

tmp = func1();
console.log(tmp(1)); // 33

incre();
console.log(tmp(1)); // 34

上面的 JavaScript 示例代码中,函数 func2(y) 就是一个闭包,特征如下:

  • 第一点,它本身是一个函数,且是一个在其他函数内部定义的函数;
  • 第二点,它还携带了它作用域外的变量 s1,即外部变量

正常来说,语句 tmp = func1(); 在执行完之后,func1() 函数的声明周期就结束啦,并且变量 s1 还使用了 var 修饰符,即它是一个方法内的局部变量,是存在于方法栈中的,在该语句执行完后,是要随 func1() 函数一起被回收的。

但在执行第二条语句 console.log(tmp(1)); 时,它竟然没有报错,还仍然保有变量 s1 的值!

继续往下看。在执行完第三条语句 incre(); 后,再次执行语句 console.log(tmp(1));,会发现输出值是 34。这说明在整个执行的过程中,函数 func2(y) 是持有了变量 s1 的引用,而不单纯是数值 32!

通过以上的代码示例,我们可以用依据通俗的话来总结闭包:

闭包是由函数和其外部的引用环境组成的一个实体,并且这个外部引用必须是在堆上的(在栈中就直接回收掉了,无法共享)。

在上面的 JavaScript 示例中,变量 s1 就是外部引用环境,而且是 capture by Reference

说完 JavaScript 中的闭包,我们再来看下 Java 中的闭包是什么样子的。Java 中的内部类就是一个很好的阐述闭包概念的例子。

public class OuterClass {
    private String name = "susu";

    private class InnerClass {
        private String firstName = "Shan";

        public String getFullName() {
            return new StringBuilder(firstName).append(" ").append(name).toString();
        }

        public OuterClass getOuterObj() {
            // 通过 外部类.this 得到对外部环境的引用
            return OuterClass.this;
        }
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        InnerClass innerClass = outerClass.new InnerClass();
        System.out.println(innerClass.getFullName());

        outerClass.name = "susu1";
        System.out.println(innerClass.getFullName());

        System.out.println(Objects.equals(outerClass, innerClass.getOuterObj()));
    }
}

###### 输出 ####
Shan susu
Shan susu1
true

上面的例子中,函数 getFullName() 就是一个闭包函数,其持有一个外部引用的变量 name,从输出结果可以看到,引用的外部变量变化,输出值也会跟随变化的,也是 capture by reference

内部类可以通过 外部类.this 来得到对外部环境的引用,上面示例的输出结果为 true 说明了这点。在内部类的 getFullName() 方法中,可直接引用外部变量 name,其实也是通过内部类持有的外部引用来调用的,比如,该方法也可以写成如下形式:

public String getFullName() {
    return new StringBuilder(firstName).append(" ").append(OutClass.this.name).toString();
}

OutClass.this 就是内部类持有的外部引用。

内部类可以有多种形式,比如匿名内部类,局部内部类,成员内部类(上面的示例中 InnerClass 类就是),静态内部类(可用于实现单例模式),这里不再一一列举。

对于 Lambda 表达式,在一定条件下可替换匿名内部类,但都是要求引入的外部变量必须是 final 的,前面也解释了为何变量必须是 final 的。宽泛理解,Lambda 表达式也是一种闭包,也是在函数内部引入了外部环境的变量,但不同于 JavaScript 语言中的闭包,函数内一直持有外部变量,即使对应的外部函数已经销毁,外部变量依然可以存在并可以修改,Java 中 Lambda 表达式中对外部变量的持有,是一种值拷贝,Lambda 表达式内并不持有外部变量的引用,实际上是一种 capture by value,所以 Java 中的 Lambda 表达式所呈现的闭包是一种伪闭包。

1.8 Consumer、Supplier 等函数式接口

说实话,在第一次看到这类函数式接口的定时时,我是一脸懵逼的,这类接口有什么用?看不懂有什么含义,这类接口定义的莫名其妙。

就像 Consumer 接口的定义:

@FunctionalInterface
public interface Consumer\<T\> {
    /\*\* \* Performs this operation on the given argument. \* \* @param t the input argument \*/
    void accept(T t);

    default Consumer\<T\> andThen(Consumer\<? super T\> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

单看 accept(T t) 抽象方法,需传入一个入参,没有返回值。这个方法做了啥?有什么语义上的功能吗?木有!我们都知道,Java 是一门面向对象的语言,一切皆对象。我们自定义的类(比如:HashMap、ArrayList)或方法(如:getName()、execute()),都是有一定的语义(semantic)信息的,是暗含了它的使用范围和场景的,通俗点说,我们明显的可以知道它们可以干啥。

但回过头看 accept(T t) 这个抽象方法,你却不知道它是干啥的。其实,对于函数式接口中的抽象方法,它们是从另外一个维度去定义的,即结构化(structure)的定义。它们就是一种结构化意义的存在,本身就不能从语义角度去理解。

这里介绍几种常见的函数式接口的用法。

Consumer 接口:消费型函数式接口

从其抽象方法 void accept(T t) 来理解,就是一个参数传入了进去,整个方法的具体实现都与当前这个参数有关联。这与列表元素的循环获取很像,比如集合类的 Foreach() 方法:

default void forEach(Consumer\<? super T\> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

再举一个例子。在日常开发中,可能会遇到连接,如数据库的连接,网络的连接等,假设有这么一个连接类:

public class Connection {

    public Connection() {
    }

    public void operate() {
        System.out.println("do something.");
    }

    public void close() {

    }

每次使用时,都需要创建连接、使用连接和关闭连接三个步骤,比如:

public void executeTask() {
    Connection conn = new Connection();
    try {
        conn.operate();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        conn.close();
    }
}

当有多处代码都需要用到此类用法时,就需要在多处去创建连接、使用和关闭连接等操作。这样有没有什么问题呢?万一某处代码忘记关闭其创建的连接对象,就可能会导致内存泄漏!

有没有比较好的方式呢?

可以将这部分常用代码做抽象,且不允许外部随意创建连接对象,只能自己创建自己的对象,如下:

public class Connection {

    private Connection() {

    }

    public void operate() {
        System.out.println("do something.");
    }

    public void close() {

    }

    public static void useConnection(Consumer\<Connection\> consumer) {
        Connection conn = new Connection();
        try {
            consumer.accept(conn);
        } catch (Exception e) {
        } finally {
            conn.close();
        }
    }
}

注意,上面的构造函数是私有的,从而避免了由外部创建 Connection 对象,同时在其内部提供了一个静态方法 useConnection(),入参就是一个 Consumer 对象。当我们外部想使用时,使用如下调用语句即可:

Connection.useConnection(conn -> conn.operate());

  • Supplier 接口:供给型函数式接口

接口定义如下:

public interface Supplier\<T\> {
    /\*\* \* Gets a result. \* \* @return a result \*/
    T get();
}

抽象方法 T get() 没有入参,返回一个对象,和前面的 Consumer 接口的 void accept(T t) 抽象方法正好相反。

看下基本用法:

// 示例 1
Supplier<Integer> supplier1 = () -> Integer.valueOf(32);
System.out.println(supplier1.get());  // 32

// 示例 2
Supplier<Runnable> supplier2 = () -> () -> System.out.println("abc");
supplier2.get().run(); // abc

第 2 个示例,你有没有看糊涂?其等价代码如下:

Supplier<Runnable> supplier2 = () -> {
    Runnable runnable = () -> System.out.println("abc");
    return runnable;
};
supplier2.get().run();

像 Predicate、BiConsumer 等其他函数式接口,这里不再一一列举,感兴趣的小伙伴可自行查阅学习。

二、方法引用

一句话介绍:

方法引用(Method Reference)是在 Lambda 表达式的基础上引申出来的一个功能。

先不铺展概念,从一个示例开始说起。

2.1 小示例

List<Integer> list = Arrays.asList(1, 2, 3);
list.forEach(num -> System.out.println(num));

上面是一个很普通的 Lambda 表达式:遍历打印列表的元素。

相比 JDK 8 版本以前的 for 循环或 Iterator 迭代器方式,这种 Lambda 表达式的写法已经是一种很精简且易读的改进。但有没有更精简的改进?

答案是有!下面就有请方法引用出场:

list.forEach(System.out::println);

没用过这种方式的小伙伴,可能会纳闷:这是什么鬼?为什么编译器竟然不报错?该怎么理解?

这其实就是一种方法引用。中间的两个冒号“::”,就是 Java 语言中方法引用的特有标志,出现它,就说明使用到了方法引用。

因为 Foreach() 方法的形参是 Consume<T> 对象,所以,上面方法引用的方式等同于如下表达:

Consumer<Integer> consumer = System.out::print;
list.forEach(consumer);

有木有很神奇?System.out::print 语句的左值可以是一个 Consumer<Integer> 对象。从编译器的角度来理解,等号右侧的语句是一种方法引用,那么编译器会认为该语句引用的是 Consumer<T> 接口的 accept(T t) 抽象方法。

下面来细细拆分一下输出语句:System.out.println();

System 是一个可不变类,包含了多个域变量和静态方法,之所以能使用 System.out 这种形式,就因为 out 是它的一个静态变量,且是一个 PrintStream 对象:

/\*\* \* The "standard" output stream. This stream is already \* open and ready to accept output data. Typically this stream \* corresponds to display output or another output destination \* specified by the host environment or user. \* \<p\> \* For simple stand-alone Java applications, a typical way to write \* a line of output data is: \* \<blockquote\>\<pre\> \* System.out.println(data) \* \</pre\>\</blockquote\> \* \<p\> \* See the \<code\>println\</code\> methods in class \<code\>PrintStream\</code\>. \* \* @see java.io.PrintStream\#println() \* @see java.io.PrintStream\#println(boolean) \* @see java.io.PrintStream\#println(char) \* @see java.io.PrintStream\#println(char[]) \* @see java.io.PrintStream\#println(double) \* @see java.io.PrintStream\#println(float) \* @see java.io.PrintStream\#println(int) \* @see java.io.PrintStream\#println(long) \* @see java.io.PrintStream\#println(java.lang.Object) \* @see java.io.PrintStream\#println(java.lang.String) \*/
public final static PrintStream out = null;

而 println(xxx) 是 PrintStream 类里一个普通方法。println(xxx) 方法有多个重载,不同点在入参的类型,可以使 int、float、double、char、char[]、boolean、long 等。

public void println(T x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

前面啰嗦那么多,重点来了!

println(xxx) 方法的特点是只有一个入参,没有出参。这个和 Consumer<T> 函数式接口的 accept(T t) 是不是很像?这也是方法引用的精髓:

只要一个已存在的方法,其入参类型、入参个数和函数式接口的抽象方法相同(不考虑两者的返回值),就可以使用该方法(如本例中的 println(xxx)),来指代函数式接口的抽象方法(如本例中的 accept(T t) 方法),等于是该抽象方法的一种实现,也不需要继承该函数式接口。

直接用已存的类名 + 两个冒号 + 方法名即可:类名::方法名。注意,这里的方法名是不带括号的。

这个比 Lambda 表达式还省事,Lambda 表达式是在不继承接口的基础上,直接用形如 () -> {} 的方式变相实现了抽象方法,方法引用是直接用已存的方法来指代该抽象方法!

总结一下,方法引用解决了什么问题?

它解决了代码功能复用的问题,使得表达式更为紧凑,可读性更强,借助已有方法来达到传统方式下需多行代码才能达到的目的。

2.2 方法引用的语法

方法引用的语法很简单。

使用一对冒号 :: 来完成,分为左右两个部分,左侧为类名或对象名,右侧为方法名或 new 关键字。有以下四种类型:

  1. 构造器引用,形式为 类名::new
  2. 静态方法引用,形式为 类名::方法名
  3. 类特定对象的方法引用,形式为 类对象::方法名
  4. 类的任意对象引用,形式为 类名::方法名

看个非常简单的示例,对应了上面的四种引用类型。

public class Animal {
    private String name;

    public Animal() {
    }

    public Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public static Animal getInstance(Supplier\<Animal\> supplier) {
        return supplier.get();
    }

    public void guard(Animal animal) {
        System.out.println(this.getName() + " guard " + animal.getName());
    }

    public void sleep() {
        System.out.println(this.getName() + " sleep.");
    }

    public static void bodyCheck(Animal animal) {
        System.out.println("body check " + animal.getName());
    }
}

定义了一个简单的 Animal 类,包含了静态方法、普通方法、有参构造函数等。

接下来,我们看下基于这个 Animal 类,四种方法引用类型的使用:

public static void main(String[] args) {
    List<Animal> animalList = new ArrayList<Animal>() {{
        add(new Animal("sheep"));
        add(new Animal("cow"));
    }};

    System.out.println("---- 构造器引用 ----");
    Animal pig = Animal.getInstance(Animal::new);
    pig.sleep();

    System.out.println("\\n---- 静态对象的引用 ----");
    animalList.forEach(Animal::bodyCheck);

    System.out.println("\\n---- 类特定对象的引用 ----");
    Animal dog = new Animal("dog");
    animalList.forEach(dog::guard);

    System.out.println("\\n---- 类的任意对象的引用 ----");
    animalList.forEach(Animal::sleep);
}

如果上面的代码你都理解了,那方法引用你也已经基本掌握了。

下面,针对方法引用的这几种类型,各自再详细解释。

2.3 方法引用的几种类型

2.3.1 构造器引用

语法很简单:类名::方法名,使用方式如下:

// 示例 1
Supplier<List<Integer>> supplier1 = ArrayList::new;
List<Integer> list = supplier1.get();

// 示例 2
Supplier<Animal> supplier2 = Animal::new;
Animal animal = supplier2.get();

之所以能赋值给 Supplier 接口,是因为其抽象方法 get() 没有入参,与类的无参构造函数一致。

@FunctionalInterface
public interface Supplier\<T\> {

    /\*\* \* Gets a result. \* \* @return a result \*/
    T get();
}

这里还需要注意一点,自定义的类必须有“无参构造函数”,否则编译器会报错

我们都知道,当创建一个类后,如果不显式声明构造函数,编译器会默认加一个无参构造函数。但如果有显式声明一个或多个有参构造函数,则编译器不再默认追加无参构造函数。如下:

public class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }
}

上面代码中的 Animal 类只有一个构造函数 Animal(String name),不再有无参构造函数。这种方式下使用构造器引用就会报错:

Supplier<Animal> supplier = Animal::new; // 编译报错:Cannot resolve constructor 'Animal'

2.3.2 静态方法引用

语法为 类名::静态方法名

还是以上面的 Animal 类为例,为了更好展示静态方法引用,相比上面的示例,我们适当做一下调整:

public class Animal {
    private String name;
    private Integer weight;

    public Animal() {
    }

    public Animal(String name, Integer weight) {
        this.name = name;
        this.weight = weight;
    }

    public String getName() {
        return name;
    }

    public Integer getWeight() {
        return weight;
    }

    ...

    public static void bodyCheck(Animal animal) {
        System.out.println("body check " + animal.getName());
    }

    public static Integer compareByName(Animal one, Animal another) {
        return one.getName().compareTo(another.getName());
    }

    public Integer compareByWeight(Animal one, Animal another) {
        return one.getWeight() - another.getWeight();
    }
}

Animal 类有两个成员变量 name 和 weight,它有多个方法,其中包括两个静态方法 compareByName() 和 compareByWeight()。

给定一个 Animal 对象列表,如果我们想根据名称排序,可以怎么做?你想到了几种方式?

第一种:利用 Collections.sort(List<T> list) 方法

这种方式,需要 Animal 类实现 Coparable<T> 接口,给出 compareTo(T t) 抽象方法的具体实现,如下所示:

public class Animal implements Comparable {

    ...

    @Override
    public int compareTo(Object o) {
        Animal another = (Animal)o;
        return this.getName().compareTo(another.toString());
    }
}

// 调用
Collections.sort(animalList);

这种方式在 JDK 7 版本及以前使用的比较多。

第二种:利用 Collections.sort(List<T> list, Comparator<? super T> c) 方法

在集合类 Collections<T> 中,还有一个 sort(List<T> list) 的重载方法 sort(List list, Comparator<? super T> c)

使用该方法,Animal 类就无需再实现 Comparable<T> 接口,在 JDK 7 版本及以前,使用匿名内部类来调用此方法即可。相比第一种方式,结构上轻便了很多,代码实现如下:

Collections.sort(animalList, new Comparator<Animal>() {
    @Override
    public int compare(Animal o1, Animal o2) {
        return o1.getName().compareTo(o2.getName());
    }
});

第三种:利用 Lambda 表达式

和第二种类似,只不过随着 JDK 8 版本中 Lambda 表达式的出现,可替换以往的匿名内部类,代码实现上做到更简洁:

// Lambda 表达式的实现
Collections.sort(animalList, (a, b) -> a.getName().compareTo(b.getName()));

第四种:借助方法引用

在第一种方式中,Animal 类还要实现 Comparable<T> 接口,然后做 compare() 抽象方法的具体实现。整个实现上是过于笨重的,太形式化。

有了方法引用,就可以大大减轻这种不必要的形式化。因为 Animal 类中已经有了类似的比较方法,即静态方法 compareByName()。

直接用这个方法代替 compare() 方法不就行啦,如下:

Collections.sort(animalList, Animal::compareByName);

是不是很简单!没有接口实现,也没有匿名内部类,以一种优雅的方式达到了相同的目的,这也是方法引用的魅力之处。

我个人理解,方法引用的出现,就是为了去优化冗余且过于形式化的代码,直接用短平快的方式解决。

第五种:利用 List 接口的 sort() 默认方法

除了 Collections 集合类,List 接口中,也提供了列表的排序方法。

// 匿名内部类实现
animalList.sort(new Comparator<Animal>() {
    @Override
    public int compare(Animal o1, Animal o2) {
        return o1.getName().compareTo(o2.getName());
    }
});

// Lambda 表达式实现
animalList.sort((a, b) -> a.getName().compareTo(b.getName()));

// 静态方法引用的实现
animalList.sort(Animal::compareByName);

第六种:Stream() 流排序

Stream() 流是 JDK 8 中新引入的功能,排序代码如下:

// 方式 1:Lambda 表达式实现
animalList = animalList
    .stream()
    .sorted((a, b) -> a.getName().compareTo(b.getName()))
    .collect(Collectors.toList());

// 方式 2:静态方法引用
animalList = animalList
    .stream()
    .sorted(Animal::compareByName)
    .collect(Collectors.toList());

2.3.3 类特定对象的引用

在前一节的第五种方式中,我们可以替换为类特定对象的引用。

语法:类对象::普通方法名

在上面的 Animal 类中,有一个普通方法:

public Integer compareByWeight(Animal one, Animal another) {
    return one.getWeight() - another.getWeight();
}

compareByWeight() 就是一个普通的实例方法,但它的定义依然与 Comparable 接口的 compare() 抽象方法定义是一致的。所以也可以使用在方法引用中。

怎么使用呢?方式如下:

Animal dog = new Animal("dog", 40);
animalList.sort(dog::compareByWeight);

类特定对象的引用、静态方法引用,两者在使用上没有区别,都达到一样的目的,只是方式不同,一个是类 + 静态方法名,一个是类对象 + 普通方法名。

2.3.4 类的任意对象的引用

语法:类名::普通方法名

从语法上看,与前面 2.3.2 小节的静态方法引用类似,都是类名 + 方法名的方式,只不过一个是普通方法,一个是静态方法,但这是不是意味着两者在含义上也是类似的呢?

答案是否定的。

对于 2.3.2 节的静态方法引用,以及 2.3.3 节的类特定对象的引用,它们的重点都是在引出方法,只不过引出的方式不同。

public class Animal {
    private String name;
    private Integer weight;

    public Animal(String name, Integer weight) {
        this.name = name;
        this.weight = weight;
    }

    ...

    public static Integer compareByName(Animal one, Animal another) {
        return one.getName().compareTo(another.getName());
    }

    public Integer compareByWeight(Animal one, Animal another) {
        return one.getWeight() - another.getWeight();
    }
}

// 静态方法引用
animalList.sort(Animal::compareByName);

// 类的特定对象的引用
Animal dog = new Animal("dog", 40);
animalList.sort(dog::compareByWeight);

就像上面的代码中,“类的特定对象的引用”示例中,换个 Animal 对象,依然能达到同样的效果:

Animal cat = new Animal("cat", 15);
animalList.sort(cat::compareByWeight);

好了,现在回到本小节的主题:类的任意对象的引用。我们可以怎么用呢?

在继续讲之前,我们先回头再观察下前面面代码中的 compareByWeight(xx, xxx) 方法。有没有发现它的两个参数有点儿冗余?另外,如果是两个参数,这个方法放在任何一个类中都可以使用,完全可以把它抽到一个工具类中使用,没必要放在这个类中。如果要放在该类中,可以换一种方式,传递一个参数即可:

public Integer compareByWeight(Animal another) {
    return this.getWeight() - another.getWeight();
}

调用代码如下:

animalList.sort(Animal::compareByWeight);

这里很多人都会疑惑,方法引用的前提,不都是入参个数都要一样吗?但 compareByWeight(Animal another) 方法只有一个参数,而 sort() 方法的形参 Comparator<T> 对应的抽象方法 compare(T o1, T o2) 是两个参数:

default void sort(Comparator\<? super E\> c) {
    Object[] a = this.toArray();
    Arrays.sort(a, (Comparator) c);
    ListIterator<E> i = this.listIterator();
    for (Object e : a) {
        i.next();
        i.set((E) e);
    }
}

@FunctionalInterface
public interface Comparator\<T\> {
    int compare(T o1, T o2);
}

这就是“类的任意对象的引用”这种类型的特殊之处。方法引用会默认将第一个入参作为当前类的一个调用对象,其余参数继续作为方法的入参。在本例中,compare(T o1, T o2) 方法是需要接入两个 Animal 对象的,但第一个对象 o1 可以作为当前 Animal 类的一个对象,剩下的 o2 继续作为引用方法 compareByWeight() 的参数,即:

o1.compareByWeight(o2)

这也是为何称为“类的任意对象的引用”。

为加深理解,我们再举一个例子。

前面的 Animal 类中,有一个 sleep() 普通方法和 bodyCheck(xx) 静态方法:

public class Animal {
    private String name;
    ...
    public void sleep() {
        System.out.println(this.getName() + " sleep.");
    }
    public static void bodyCheck(Animal animal) {
        System.out.println("body check " + animal.getName());
    }
}

Animal::sleep 构成了“类的任意对象的引用”,Animal::bodyCheck 构成了“静态方法引用”,它们都可以用在如下表达式中:

animalList.forEach(Animal::sleep);
animalList.forEach(Animal::bodyCheck);

sleep() 方法虽然没有入参,但依然可以用在 forEach() 方法中,因为 Consumer<T> 接口的 accept(T t) 抽象方法有一个入参,而该入参就可以作为 Animal 类的一个对象,来调用 sleep() 方法。

2.4 小结

如上所述,方法引用有多种类型,在实际使用过程中,可灵活运用。

说到底,跟 Lambda 表达式一样,它还是一种语法糖,为我们的开发工作提效。为达到同样的目标,相比传统实现方式,这种语法糖减轻了代码量,使用更轻便,不再拘泥于特定场景下囿于面向对象语言规则而产生的笨重表达,是对它们的一种轻量级替代。

三、default 关键字

default 是 Java 中的关键字之一,其他像 while、long、boolean、for、static 等,也都是已经在用的关键字,自身不能作为变量名称使用,下面的代码中,就将 default 作为 String 类型的一个变量名,编译器会报错:

String default= "abc";  // compile error!

在 JDK 7 及以前的版本中,default 关键字要么被用在了 switch 控制语句中,作为默认兜底条件,或者被用在注解(annotation)定义中,用于定义字段的默认值。

但在 JDK 8 中,它被赋予了更广的应用,还可以用在接口中,用来修饰非抽象的方法,称为默认方法(default method),其他地方也有称为 defender methodvirtual extension method

3.1 使用示例

  • 首先,default 关键字用在接口中,用来修饰方法;
  • 其次,该方法不能使抽象方法,即必须是一个已经有了实现的方法,称为默认方法
  • 最后,同一个接口内,可以有多个默认方法。

自定义接口和默认方法:

public interface InterfaceA {
    default void testMethod(); // compile error! 会提示:Extension method should have a body.

    // 默认方法一
    default void print() {
        System.out.println("InterfaceA: an ordinary method.");
    }

    // 默认方法二
    default String getName(){
        return "InterfaceA";
    }
}

在方法 testMethod() 前添加 default 关键字,编译器不通过。

我们再自定义一个类,继续以示例说明 default 这种关键字的用法:

// 示例 1
class MainTest1 implements InterfaceA {
}

// 示例 2
class MainTest2 implements InterfaceA {
    @Override
    public void print() {
        System.out.println("MainTest2: override from InterfaceA.");
    }
}

public static void main(String[] args) {
    MainTest1 mainTest1 = new MainTest1();
    mainTest1.print();

    MainTest2 mainTest2 = new MainTest2();
    mainTest2.print();
}

--- 输出 ----
InterfaceA: an ordinary method.
MainTest2: override from InterfaceA.

在实现类 MainTest1 中,并没有做任何动作,编译依然通过;在实现类 MainTest2 中,对接口中的默认方法做了重写 @Override。

从输出结果看,接口中的默认方法可以直接作为实现类的方法使用。当然,也可以在实现类中重写该默认方法。因此,默认方法的这种方式,和父类与子类的模式相同。

既然实现类能重写接口中的默认方法,那么,在实现类中可以直接使用接口中的默认方法吗?

答案是可以。示例如下:

public class MainTest1 implements InterfaceA {
    public void testMethod() {
        // 方式 1:直接使用,跟调用在本类中声明的方法一样
        print();
        // 方式 2:使用接口名 + super 关键字
        InterfaceA.super.print();
    }
}

下面我们看下 JDK 8 的类库中使用默认方法的示例。

在 Java 的类库中,也有很多接口添加了 default 方法,最有名的莫过于像 List、Set 等集合类的 Foreach() 方法:

public interface Iterable\<T\> {
    Iterator\<T\> iterator();

    default void forEach(Consumer\<? super T\> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }
}

List、Set 接口的父接口是 Collection 接口,而 Collection 的父接口是 Iterable 接口。

另外,这里多提一个知识点。像 HashMap、ConcurrentHashMap 类的 Foreach() 方法,是来自于 Map 接口的默认方法

public interface Map\<K,V\> {
    ...

    default void forEach(BiConsumer\<? super K, ? super V\> action) {
        Objects.requireNonNull(action);
        for (Map.Entry<K, V> entry : entrySet()) {
            K k;
            V v;
            try {
                k = entry.getKey();
                v = entry.getValue();
            } catch(IllegalStateException ise) {
                // this usually means the entry is no longer in the map.
                throw new ConcurrentModificationException(ise);
            }
            action.accept(k, v);
        }
    }
}

从以上的示例中,可以发现,default 关键字使用起来很简单,就是在接口中添加一个有具体实现的普通方法,唯一与普通方法不同的是,在接口中需要使用 default 关键字来修饰。

3.2 为何新增 default 方法?

上一小节中,给大家展示了 default 关键字的用法。使用起来很容易,但是,我们更应该去进一步思考下,好端端的在接口中添加一个默认方法,背后有着什么样的思考和考量。

在继续读下文之前,你知道为何要在接口中设置这种默认方法吗?

我们知道,按 Java 语言的规范,一个实现类要实现其接口,要么实现接口中的所有抽象方法,要么该实现类声明为抽象类(使用 abstract 修饰符)。你有没有想过,这种规范,会带来什么问题?

也许有些同学想到了!那就是不够灵活,扩展性变差!

因为,一旦一个接口发布了(比如 Collection 接口),且它的实现类较多时(比如 HashSet、ArrayList 类等),那么这个接口就无法再做结构性的改动!当你想在该接口中新增一个抽象方法时,那么其所有的实现类都要改动去实现该方法,这是 JDK 7 及以前的版本中带来的问题。

世上所有的事物都是迭代发展的,谁都没办法在初始时就设计好一切,能够应付往后遇到的所有场景。

在 Java 语言中,因为接口和实现类的诸多规范,使得 Java 设计者不得不在最初定义接口时,就得定好接口所有的场景和操作方法,因为往后随着实现类的逐渐增多,再想在该接口中添加一个新的方法,那是不太现实的!

当发展到 JDK 8 的时候,这种蹩脚的扩展性已经无以为继了,并且 JDK 8 中还出现了 Lambda 表达式这种高阶语法。况且集合类的操作在平时使用的太频繁了,如果不在原本的集合类接口中新增能使用 Lambda 表达式的方法,Lambda 表达式的推出就没有了意义!

为了解决扩展性差的问题,同时解决 Lambda 表达式的更广泛使用,default 方法就诞生了。它的出现,实现了新增方法的向前兼容(Backward Compatibility)特性,可在接口中随意添加默认方法,灵活性和扩展性大大提高!

3.3 菱形继承问题(Diamond Problem)

在享受 default 关键字带来的便利时,也引入了一个旧的问题——菱形继承问题(Diamond Problem)。

什么是菱形继承问题?简单来说,就是指一个类 A 有两个父类(比如 C++ 语言),且这两个父类都有一个同名的公共方法 test(xxx),那么通过子类对象调用该方法时,会引起歧义,编译器也不知道该调用哪个父类中的 test(xxx) 方法。

因为 Java 语言的单继承特性,JDK 8 及以前的版本中,是不存在菱形继承问题的。但 default 关键字的出现,又引出了这种问题。

先看下面的示例,你觉得会编译通过吗?

// 接口 A
public interface InterfaceA {
    default void print() {
        System.out.println("InterfaceA: an ordinary method.");
    }
}

// 接口 B
public interface InterfaceB {
    default void print() {
        System.out.println("interfaceB : ordinary method.");
    }
}

// 这里编译会报错吗?
public class MainTest implements InterfaceA, InterfaceB {
}

上面的 MainTest 类会编译报错,错误信息如下:

错误信息:MainTest inherits unrelated defaults for print() from types InterfaceA and InterfaceA

原因在于,MainTest 类实现了两个接口 InterfaceA 和 InterfaceB,且这个两个接口都有同名的默认方法 print(),编译器是不知道调用哪个方法的。

对于这种情况,必须要重写接口的默认方法了。可以重新写方法体,如果就是想用默认方法,那就利用 super 关键字来实现。

public class MainTest implements InterfaceA, InterfaceB {
    @Override
    public void print() {
        ....
    }
}

或
public class MainTest implements InterfaceA, InterfaceB {
    @Override
    public void print() {
        InterfaceA.super.print();
    }
}

3.4 静态方法

对于 JDK 8 版本,接口内不仅可以定义默认方法,还可以定义静态方法。

什么是静态方法?举个示例:

public interface InterfaceA {

    default void print() {
        System.out.println("InterfaceA: an ordinary method.");
    }

    static void sayHello(String name) {
        System.out.println("InterfaceA: hello " + name);
    }
}

sayHello(String name) 就是一个定义在接口中的静态方法。

可以看到,对于静态方法,是不需要使用 default 关键字来修饰的,直接添加一个 static 修饰符即可。

下面看下其特性。

public interface InterfaceA {
    default void print() {
        System.out.println("InterfaceA: an ordinary method.");
    }
    // 静态默认方法
    static void sayHello(String name) {
        System.out.println("InterfaceA: hello " + name);
    }
}

public interface InterfaceB {
    default void print() {
        System.out.println("interfaceB : ordinary method.");
    }

    static void sayHello(String name) {
        System.out.println("InterfaceB: hello " + name);
    }
}

public class MainTest implements InterfaceA {
    @Override
    public void print() {
        System.out.println("MainTest: an Override Method.");
    }

    public void testMethod() {
        print();  // 输出:MainTest: an Override Method.
        InterfaceA.super.print(); // 输出:InterfaceA: an ordinary method.
    }

    void sayHello(String name) {
        System.out.println("MainTest: hello " + name);
    }
}

// 调用
public static void main(String[] args) {
    MainTest mainTest = new MainTest();
    mainTest.print();

    mainTest.sayHello("susu");
    InterfaceA.sayHello("susu");
    InterfaceB.sayHello("susu");
}

---- 输出 ----
MainTest: an Override Method.
MainTest: hello susu
InterfaceA: hello susu
InterfaceB: hello susu

从以上执行结果,总结几条特性:

  • 特性 1:静态方法不可被重写(即 @Override),会编译报错:Method does not override method from its superclass
  • 特性 2:同一个实现类实现多个接口,允许这多个接口中声明同名的静态方法;
  • 特性 3:调用方式只有一种:接口名.静态方法,eg: InterfaceA.sayHello("susu");

下面,再举一个类库中的集合排序示例。

List<Integer> list = Arrays.asList(6, 8, 10, 4, 3, 2, 1);
Collections.sort(list);
list.sort(Integer::compareTo);

在 Comparator<T> 接口中,还提供了一个静态方法 comparing()。

public static <T, U extends Comparable<? super U>> Comparator\<T\> comparing( Function\<? super T, ? extends U\> keyExtractor) {
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

有了该方法,列表的排序我们还可以这么用:

list.sort(Comparator.comparing(a -> a));

虽然这么写有点儿冗余,但这种方式给我们提供了扩展的可能性。

比如,最常见的手写面试题,就是将一组数据,按奇偶性分开,偶数在前,奇数在后,相对顺序保持不变。这个可以咋做?你可以新开两个数组,然后各自放奇偶数,然后再合并。

但在 Java 中,你可以直接用 1 行代码搞定:

List<Integer> list = Arrays.asList(6, 8, 10, 4, 3, 2, 1);
list.sort(Comparator.comparing(a -> a % 2));

---- list 数组 ----
[6, 8, 10, 4, 2, 11, 7, 3, 1]

在此基础上,如果再在奇偶数内部做排序,你想到怎么做了吗?你可以用归并,也可以使用冒泡。但在 Java 中,你可以使用 2 行搞定:

List<Integer> list = Arrays.asList(11, 6, 8, 7, 10, 4, 3, 2, 1);
list.sort(Integer::compareTo);
list.sort(Comparator.comparing(a -> a % 2));

---- list 数组 ----
[2, 4, 6, 8, 10, 1, 3, 7, 11]

如果想让奇数在前,偶数在后,直接调用 reversed() 方法即可:

list.sort(Integer::compareTo);
list.sort(Comparator.comparing((Integer a) -> a % 2).reversed());

---- list 数组 ----
[1, 3, 7, 11, 2, 4, 6, 8, 10]

如果想在奇偶数内部做降序:

List<Integer> list = Arrays.asList(11, 6, 8, 7, 10, 4, 3, 2, 1);
Collections.sort(list, Collections.reverseOrder());
list.sort(Comparator.comparing((Integer a) -> a % 2));

---- list 数组 ----
[10, 8, 6, 4, 2, 11, 7, 3, 1]

集合类的 sort() 排序方法,内部使用的是 TimeSort 排序方法,它是一种结合了归并排序和插入排序的排序算法,这里不再赘述,感兴趣的同学可自行了解。

3.5 小结

default 关键字,极大的提高了接口的扩展性,这也使得 Lambda 表达式在集合类的诸多接口中被广泛使用。

本文就是在展示 default 关键字如何使用的基础上,更进一步的解释了为何允许在接口中添加 default 方法,希望对你有所帮助!

四、收尾

本文对 JDK 8 中的 Lambda 表达式、闭包、Stream 流、方法引用、default 关键字等诸多新特性做了详细介绍,不仅以示例来展示如何使用,更将其背后这么设计的原因也都一并深挖了出来。

本文内容很多,可能很多同学都看不到这里,就匆匆关掉了页面,或者就点击了“收藏”后再也没有打开过。

但只要屏目前的你看到了,就说明我写的这篇文章是有价值的。如果你能足够耐心把这篇文章吃透,我相信也你会有非常大的收获。

广大读者的认可,就是我耗大量精力来做总结和分享给你们的最大动力。

本文虽然长,但有很多知识点还没有覆盖到,比如 Stream 流的讲解只是浅尝辄止,只浮于使用层面,并没有深挖其背后的原因。这些,我会很快在我的公众号(码不停蹄的小鼠松,ID: busy_squirrel)中呈现给大家,欢迎大家关注,关注后,我们会有更多交流,你也会看到更多的精品文章。


欢迎关注我的公众号,回复关键字“Java” ,将会有大礼相送!!! 祝各位面试成功!!!

发布了112 篇原创文章 · 获赞 2 · 访问量 5556

猜你喜欢

转载自blog.csdn.net/weixin_41818794/article/details/104393717
今日推荐