十个方面学习Java8

前言

从编程语言特性来说java8绝对是一次革命性的的改进,有了JDK 8之后, Java语言的表达力、简洁性有了很大提高。毫无疑问,Java 8是自Java 5发布以来最大的一次版本升级。

一、Lambda表达式

1.1 匿名内部类

匿名内部类就是没有名字的内部类,匿名内部类除了可以作用于抽象类,也可用于接口。如果没有匿名内部类,我们的代码是这样的:

public class Main2 {
    public static void main(String[] args) {
        new Thread(new Thread1()).start();
    }
}
//假设中间有1000行代码
class Thread1 implements Runnable{
    @Override
    public void run() {
        System.out.println("aaa");
    }
}

以下是Thread构造方法声明和Runnable接口声明:

// Thread的构造方法
public Thread(Runnable target)
// Runnable接口
public interface Runnable {
    public abstract void run();
}

new一个Thread类需要传递一个Runnable的示例,在不使用匿名内部类的情况下,需要先声明一个实现Runnable接口的类Thread1,再创建该类的实例对象作为构造方法参数传递。这种写法有明显的缺点:

  • main方法中,想看看Thread1到底执行了什么代码,还要找到Thread1这个类,然后查看run()方法中的内容。
  • Thread1这个类只需要用一次,但是显示的创建Thread1类显得麻烦。

匿名内部类突出了具体执行的代码逻辑,并且减少了创建类的繁琐。因此,有了匿名内部类后的代码如下:

public class Main2 {
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("aaa");
            }
        }).start();
    }
}
1.2 Lambda表达式

JDK8中提供了新的语法:Lambda表达式,使以上使用匿名内部类的写法更为简洁:

public class Main2 {
    public static void main(String[] args) {
        new Thread(
            () -> System.out.println("aaa")
        ).start();
    }
}

这是一种新的语法,而不是一种技术点,Lambda语法看起来确实奇怪,甚至并不像Java的语法规则。先不考虑Lambda是怎样写的,先来看看使用Lambda和匿名内部类的不同点:

// 使用匿名内部类:
new Runnable() {
    @Override
    public void run() {
        System.out.println("aaa");
    }
}
// 使用Lambda表达式:
() -> System.out.println("aaa")// 注意这里没有";"

对比发现,Lambda表达式比匿名内部类少了以下代码,而这些代码也是重复代码,每个匿名内部类的实现都有这些重复代码:
在这里插入图片描述
其中System.out.println("aaa");是run()方法的具体实现。再来看一个有参数的匿名内部类:

public class Main2 {
    public static void main(String[] args) {
        List<Student> list = new ArrayList<Student>();
        
        list.add(new Student("aaa", 1));
        list.add(new Student("bbb", 2));
        
        Collections.sort(list, new Comparator<Student>() {
            @Override
            public int compare(Student o1, Student o2) {
                return o1.getAge().compareTo(o2.getAge());
            }
        });
    }
}
class Student{
    private String name;
    private Integer age;
	// Getters and Setters
	// Constructor
}

以下是方法和接口的声明:

public static <T> void sort(
	@NotNull java.util.List<T> list,
	@Nullable java.util.Comparator<? super T> c
)
public interface Comparator<T> {
	int compare(T o1, T o2);
}

使用匿名内部类和使用Lambda表达式后的对比代码如下:

// 使用匿名内部类
new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        return o1.getAge().compareTo(o2.getAge());
    }
}
// 使用Lambda表达式
(Student o1, Student o2) -> o1.getAge().compareTo(o2.getAge())

对比发现,Lambda表达式比匿名内部类少了以下代码,而这些代码也是重复代码,每个匿名内部类的实现都有这些重复代码:
在这里插入图片描述Lambda表达式比匿名内部类更加简化,省去了重复代码,只保留了如下两个部分:方法参数列表方法的具体实现代码,这两个部分也是写代码和读代码的时候最关注的部分。Lambda表达式由三部分组成:

  • (Student o1, Student o2),这个部分是参数列表,标志着"有什么"。
  • ->,这是Lambda操作符,将Lambda表达式分为左右两部分,标志着"拿左边的参数执行右边的代码"
  • o1.getAge().compareTo(o2.getAge()),这个部分是具体实现代码,标志着"干什么"

再看一个实现Callable接口的例子:

// 使用匿名内部类
FutureTask<Integer> futureTask = new FutureTask(new Callable() {
   @Override
    public Integer call() throws Exception {
        return 1;
    }
});
// 使用Lambda表达式
FutureTask<Integer> futureTask2 = new FutureTask(() -> 1);
1.3 什么时候才能使用Lambda表达式

看下面的代码:

public interface A{
    void get(String str);
    void set();
}
class B{
    private A aa;
    public B(A aa) {  this.aa = aa;  }

    public void test(){
    }
}

接口A中,声明了两个抽象方法,Lambda表达式不知道该实现哪一个方法,因此这种情况下使用Lambda表达式是错误的(这个例子也可以说明,即使两个抽象方法的参数不同也不行)。将接口A换做抽象类A,使用Lambda表达式仍然报错。

new B(() ->  System.out.println("aaa")).test();
// 编译错误,mutiple non-overriding abstract methods found

总结:当且仅当接口中只有一个抽象方法时,才可以使用Lambda表达式。Lambda表达式只能实现只有一个抽象方法的接口

1.4 Lambda表达式的使用细节

在这个小节中整理一下Lambda表达式的书写规范。

  • 当Lambda体中只有一条语句时,大括号可以省略:

    (Student o1, Student o2) ->  o1.getAge().compareTo(o2.getAge())
    
  • 当Lambda体中有多条语句,大括号不能省:

    Collections.sort(new ArrayList<Student>(), (Student o1, Student o2) -> {
        System.out.println("aaa");
        return 0;
    });
    
  • 当Lambda体的大括号省略,则不能写分号和return。如果有大括号,则必须写分号和return(有返回值)

    () ->  1 // () -> return 1; 报错
    
  • 方法参数类型有时可以省略(依赖于类型推断,后面会讲),建议不省略,省略后的代码可读性变差。

    (o1, o2) ->  o1.getAge().compareTo(o2.getAge())
    
  • 当方法参数只有一个,小括号可省略(此时不能写参数类型):

    x -> Sysout.out.println(x)
    // Integer x -> Sysout.out.println(x) 报错
    // 注意,如果省去参数类型,则所有的都要省去,以下代码是错误的:
    // (o1, Student o2) -> o1.getAge().compareTo(o2.getAge())
    
  • 使用IDEA,Alt+Enter可以方便的转为Lambda表达式(截至现在,eclipse还没有相应的功能):
    在这里插入图片描述

  • Lambda表达式中可以访问外部变量,但是不能修改(其本质还是final的,只不过JDK8不需要显示声明变量为final):
    在这里插入图片描述

JDK1.8可以不用final修饰, 但外部变量也不可更改, 即相当于隐性的final修饰。
——《为什么内部类引用的外部变量必须用final修饰》

1.5 Lambda表达式的优缺点

Lambda表达式能大幅度减少代码篇幅,使得代码更加简洁。但是Lambda表达式缺点也很明显:

  • 对于面向对象语言来说,Lambda语法诡异,可读性差、难以编写。

    为了尝试这个新特性而将它强行加入到OOP的程序之中,可读性的降低也会变的自然而然。
    ——《java 8新特性lambda表达式优劣浅谈》

  • 速度并不一定更快(以后的章节会详细介绍)。
  • 具有一定的局限性
  • IDE工具支持的还是不够好(虽然IDEA的支持已经很强了)。

二、接口的改变

2.1 函数式接口

在上一节当中,得到一个结论:Lambda表达式只能用于只有一个抽象方法的接口。在JDK8中,把这样的接口叫做函数式接口(FunctionalInterface)。函数式接口可以用注解@FunctionalInterface声明,该注解只约束该接口必须是一个函数式接口(就像@Override注解只约束该方法必须是复写的方法一样),否则报错。JDK8不仅把之前的函数式接口部分用@FunctionalInterface声明了,还提供了许多新的函数式接口。函数式接口可以适用于Lambda表达式。

  • 比如RunnableComparator等老接口,在JDK8中被@FunctionalInterface声明了。但是注意:Comparable接口虽然也只有一个抽象方法,但是JDK8并没有用@FunctionalInterface标注,不过仍然可以使用Lambda表达式。

JDK8提供的一些典型的函数式接口如下:
(1)Consumer<T>:消费者接口,表示接受单个输入参数并且不返回结果的操作,有输入无返回。

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

(2)Supplier<T>:供应者接口,表示不接受输入参数并且返回结果的操作,无输入有返回。

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

(3)Function<T,R>:转换型接口,表示接受单个输入参数并且返回单个结果的操作,有输入有返回。当返回类型和输入类型一致的时候,既可以用Function<T, T>,也可以用JDK8提供的另一个接口:UnaryOperator<T>:表示对单个操作数产生与其操作数相同类型的结果的操作。这个接口继承了Function

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

(4)Predicate<T>:判断型接口,表示接受单个输入参数并且返回布尔值结果的操作,有输入有返回。

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

在本节中,这些接口目前没有好的应用,在以后的Stream流中会大量应用,以下举一个综合的使用例子,但是并无实际意义:

public class Main {
    public static void main(String[] args) {
        convert(
                () -> "abc",
                str -> str.length(),
                length -> length + 3,
                result -> result == 6,
                flag -> System.out.println(flag)
        );
    }
	// 该方法:产生一个字符串,获取这个字符串的长度,将这个长度+3,判断结果是否等于6,输出结果
    public static void convert(Supplier<String> supplier,
                               Function<String, Integer> function,
                               UnaryOperator<Integer> operator,
                               Predicate<Integer> predicate,
                               Consumer<Boolean> consumer
    ) {
        consumer.accept(
                predicate.test(
                        operator.apply(
                                function.apply(
                                        supplier.get()
                                )
                        )
                )
        );
        
    }
}
2.2 接口的默认方法和静态方法

在JDK8中,允许在接口上添加默认方法和静态方法,JDK8中的接口可以是这样的:

interface InterfaceDemo{
 // 默认方法aaa(),用default修饰,接口中不在只包含抽象方法,默认方法和静态方法都有方法的实现。
 // 只能用接口的实例化对象访问
    default void aaa(){
        System.out.println("");
    }
 // 静态方法bbb(),用static修饰,只能用InterfaceDemo.bbb()访问
    static void bbb(){
        System.out.println("");
    }
 // 普通抽象方法ccc()
    void ccc();
}

接口新增的默认方法和静态方法语法上简单,有几个注意的点:

  • 接口的静态方法只能通过接口名访问。(接口的实例化对象是不能访问静态方法的,与类不同)
  • 接口的默认方法只能通过接口的实例化对象访问,默认方法可以被重写。
  • 在接口继承接口和类实现多接口的时候需要注意默认方法的指明。如下代码所示:
interface InterfaceDemo{
    default void aaa(){
        System.out.println("aaa");
    }
}
interface InterfaceDemo2{
    default void aaa(){
        System.out.println("aaa");
    }
}
// 两个接口有重名的默认方法,在类同时实现这些接口的时候,必须复写接口中的默认方法。
class B implements InterfaceDemo, InterfaceDemo2{
    @Override
    // 类复写接口的默认方法,不再是default 
    public void aaa() {
    // 可以用super关键字指明使用哪个接口的默认方法
        InterfaceDemo.super.aaa();
    }
}

本小节只介绍了基本语法和使用细节,但是本身并无实际意义,Java中的许多接口都增加的默认方法,例如:ComparatorCollection等。详细的应用在Stream元素流中介绍。

public class Main {
    public static void main(String[] args) {
        List<Student> list = new ArrayList<Student>();
        list.add(new Student("aaa", 10));
        list.add(new Student("bbb", 20));
        list.add(new Student("ccc", 1));

		// Comparator新增默认方法reversed()
		// reversed()调用了Collections接口的静态方法reverseOrder()
		// 默认方法无法进行参数的类型推断,因此(Comparator<Student>)不能省略
        Collections.sort(list, ((Comparator<Student>) (o1, o2) -> {
            return o1.getAge().compareTo(o2.getAge());
        }).reversed());
        Collections.sort(list, (o1, o2) -> o1.getAge().compareTo(o2.getAge()));
    }
}
class Student{
    private String name;
    private Integer age;

    // Getters and Setters
    // Constructor
}
2.3 关于接口改变的一些认识

不管是Lambda表达式,还是接口的新特性,都是为了支持JDK8的函数式编程。可以说,增加Lambda的目的,其实就是为了支持函数式编程,而为了支持Lambda表达式,才有了函数式接口。

函数式编程:属于"结构化编程"的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。并且将函数作为数据自由传递。函数式编程的思想早就提出了,Java8只是支持了这一点。
——《什么是函数式编程?》

关于函数式编程的最佳实践在以后的Stream元素流章节中介绍,在这里讨论另一个问题:Java接口的改变引发关于接口和抽象类的新的思考。

Java 8的接口上的default method最初的设计目的是让已经存在的接口可以演化——添加新方法而不需要原本已经存在的实现该接口的类做任何改变(甚至不需要重新编译)就可以使用该新版本的接口。但是这反而违背了Java的一个核心思想:尽量把问题暴露在编译时。
——《知乎:Java 8接口有default method后是不是可以放弃抽象类了?》

有关于这个问题见参考资料吧。

三、方法引用

3.1 什么是方法引用

在使用Lambda表达式的时候,通常会有这样的情况:有一个具体的方法完全满足Lambda体的方法实现,例如:

public class Main {
    public static void main(String[] args) {
        Consumer consumer = (var) -> System.out.println(var);
        consumer.accept("a");
    }
}

Consumer,消费类型接口,有输入无输出。在Lambda体中,完全用Sysout来实现了。可以理解println()方法完全和accept()方法有相同之处,都是有参无返回值,或者说,这其实就是Consumer.accept()方法的一个实现。在看一个例子:

public class Main {
    public static void main(String[] args) {
        Supplier<Double> supplier = () -> Math.random();
        supplier.get();
    }
}

Supplier,供给型接口,无参有返回。在Lambda体中,完全用Math.random()实现。这个方法也是无参有返回值,相当于是Supplier.get()的一个实现。与上个例子不同的是,println()是对象的方法,random()是类的方法。

Lambda实质是抽象方法的实现,换句话说,只要=右边能实现抽象方法,接口就可以被实例化。像这样,Lambda体完全由其他方法实现的Lambda语句,可以通过方法引用进行代替。就好比被引用的方法“仿佛” implements了抽象方法的一样。道理有点类似于接口和接口的实现。如下代码所示:

public class Main {
    public static void main(String[] args) {
        Consumer consumer = System.out::println;
        consumer.accept("a");// 被引用的方法println like a accept()方法
        
        Supplier<Double> supplier = Math::random;
        supplier.get();// 被引用的方法random like a get()方法
    }
}

但是,方法引用其实也是Lambda语句,方法引用的操作符为::,左边是引用谁的方法,右边是引用哪个方法。方法引用不加()

3.2 方法引用的细节

在上一节中已经说明了对象的方法引用和类的方法引用两种方式:对象明::实例方法名,类名::静态方法名。接下来讨论其他的情况:

  • 类名::实例方法名。方法引用的基本要求是参数列表和返回值类型与抽象方法相同。但是此种情况例外: Lambda 参数列表的第一个参数,是实例方法的调用者,第二个参数(或无参)是实例方法的参数。如下代码所示:
    public class Main {
        public static void main(String[] args) {
            String str = "aaa";
            Function<String, Integer> function = (s) -> s.length();
            Function<String, Integer> function2 = String::length;
    
    		String[] strArray = {"a", "c", "b"};
            Arrays.sort(strArray, (s1, s2) -> s1.compareTo(s2));
            Arrays.sort(strArray, String::compareTo);
        }
    }
    
  • 构造方法引用。
    public class Main {
        public static void main(String[] args) {
            Supplier supplier = Thread::new;
        }
    }
    
  • 父类方法引用和当前类方法引用。
    class B{
        public Integer bbb(){
            return 1;
        }
    }
    class C extends B{
        public Integer ccc(){
            return 2;
        }
        public void test(){
            Supplier supplier = this::ccc;
            Supplier supplier2 = super::bbb;
        }
    }
    

四、Optional类

Optional类是Java 8中提供的一个帮助开发人员避免NPE异常(NullPointerException)的类。 Optional类是通过Google Guava的Optional引进来的。在说这个类之前,有必要说说NPE异常和传统的解决方案。参考了很多文章,都没有很好的解释为什么要使用Optional,有些情况下,使用Optional是更加复杂的。Optional并没有解决NPE异常,只是强制开发人员去思考并规避NPE的发生。

4.1 NPE异常和对null的思考

对于引用类型而言,如果一个指针什么都没有指向,而开发人员误把它当作了已经指向了一个对象的指针来使用,此时空指针异常就会被抛出。空指针异常只发生在对象上而非基本类型上。

Integer i = null; // i为一个指向Integer类型的引用,为null则意味着“我什么都不指向”
// int i = null; 基本类型不存在指针指向,此行报错

如果一个对象的引用为null,例如List list = null,这个引用将什么也做不了。另一方面,在实际的项目开发中,null到底代表了什么意思?一个集合为null和一个集合为空显然是不同的,但是说出二者的不同又是不容易的。Null在实际开发中是一个含糊的语义,因此在开发规范中被认为这是一种错误,开发人员应该尽可能的规避null的出现。

95%的集合类不接受null值作为元素。我们认为,拒绝null值对开发者更有帮助。——Google Guava

public class PersonDao{
	public List<Person> getPersons(){
		// 进行数据,但是没有任何数据
		// 返回null,当上层调用该方法时,上层代码必须判断是否为null,增加了不便
		return null; // 另一方面,返回结果为null,到底意味着查询成功?失败?还是别的含义
		// return new ArrayList<Person>(); Java开发手册推荐返回一个空的集合而不是null
	}
}
// 对于调用者而言,都返回null,那到底代表key不存在还是key对应的值为null?
Map map = new HashMap();
map.get("a"); // 返回null

Map map2 = new HashMap();
map2.put("a", null);
map2.get("a");// 返回null

开发人员不该写出具有“二义性”的代码,如果查询的结果为空,那么应该明确返回空集合而非null。另一方面,不要在Set中使用null,或者把null作为map的键值。使用特殊值代表null会让查找操作的语义更清晰。开发人员不应该轻率的使用null,并且仔细想想,null值的键在你的项目中到底表达了什么语义。

4.2 链式操作的NPE异常

实际的开发过程中,链式代码写起来更方便一些,代码也更简洁。虽然链式操作的每一步都依赖于返回值类型。以下代码只展示链式操作,并无实际意义:

public int getLength(Map<String, Person> map){
	return map.get("a").getName().trim().toLowerCase().length();
}
// 如果能写上面的代码,绝不会写下面的代码
Person p = map.get("a");
String name = p.getName();
// ....

链式操作有一个显著的问题,就是NPE的发生。因为输入参数无法保证,并且中间环节仍可能产生null值。因此常规做法通常是参数检查,但是因此不能进行链式操作:

public int getLength(Map<String, Person> map){
	if (map == null)
		return 0;
	Person p = map.get("a");
	if (p == null)
		return 0;
	// ...
}
4.3 Optional类

使用Optional的原因:1.强制开发人员思考null的意义,并避免无意义的null值产生。2.对链式操作提供便利。Optional是一个容器类,声明为:Class Optional<T>,这个容器可以存放任意对象,尽管这个对象可能为null。JDK8对此次新增的,有可能返回null的方法进行了包装,为Optional类型。那么将返回值封装成Optional,怎么就能让开发人员规避null的产生呢?

4.3.1 作为上层调用者,处理返回的Optional结果

举个例子:假设有PersonDao.getPersons()方法,在以前,这个方法返回的是List<Persons>,这样返回给上层调用者的时候,会给上层调用者带来不少if-else判断非null的麻烦。当返回值为Optional<List<Person>>时,就可以规避这样的问题,以下代码实例了取出Dao查询结果集中第一个Person的名字的长度:

List<Person> list = dao.getPersons();
if (list == null) 
	return 0;
Person p = list.get(0);
if (p == null)
	return 0;
//...
//上面这种方法,必须进行null的判断以避免NPE异常,
// 使用Optional作为返回值,无需进行null校验进行链式操作
PersonDao dao = new PersonDao();
Optional<List<Person>> persons = dao.getPersons();
Integer length = persons.map(list -> list.get(0)) // return Optional<Person>
        .map(Person::getName)// return Optional<String>
        .map(String::length)// return Optional<Integer>
        .orElse(0);

map()的方法声明:Optional Optional.map(Function mapper)。map()方法的参数是一个Function,类型转换接口。上述例子,分别将List转为Person,Person转为String,String转为Integer。但是无论Function怎么转换,map方法都将转换的结果用Optional容器进行封装,使得返回结果仍然是一个Optional,于是可以继续调用Optional.map()进行链式操作。

orElse()方法的源码如下,这是一段经常使用的代码逻辑:结果不为null则返回结果,如果为null则返回另一个替代的结果。其中value指的是Optional容器中的对象。实例代码中最后一句orElse(0)的意思是:将Optional容器中计算的结果取出,如果长度不为null,就返回长度,如果为null,就返回0。

public T orElse(T other) {
    return value != null ? value : other;
}

只要对象在Optional容器中,操作就是安全的,无需考虑null的情况。但是当从容器中取结果的时候,需要进行null的判断。Optional提供了几种方法,供开发人员从容器中往外取结果:

  • get():如果Optional中的结果不为null,则返回结果;如果为null,则抛出NoSuchElementException
  • orElse(T other):如果Optional中的结果不为null,则返回结果;如果为null,则返回替代结果other。
  • orElseGet(Supplier<? extends T> other):如果Optional中的结果不为null,则返回结果;如果为null,则返回由Supplier创建的结果。
  • orElseThrow(Supplier<? extends X> exceptionSupplier):如果Optional中的结果不为null,则返回结果;如果为null,则抛出由Supplier创建的异常。
4.3.2 作为被调用者,将返回值封装成Optional

在上个例子中PersonDao.getPersons()方法返回Optional<List<Person>>,这样上层在调用的时候,就不必层层进行null值判断,代码简化了不少。接下来讲,PersonDao如何将查询结果list封装为Optional。

class PersonDao{
    public Optional<List<Person>> getPersons(){
        List<Person> list = null;
        // list = select data result
        return  Optional.ofNullable(list);
    }
}

Optional提供了如下几个方法,可以进行包装:

  • Optional.of(T value):如果value不为null,就往Optional容器里放,否则抛出NPE异常。
  • Optional.ofNullable(T value):如果value不为null,就往Optional容器里放,否则返回一个空的Optional容器(只是容器中的内容为空的,但是容器对象本身存在)。

由上可以看出,通过Optional的静态对象创建的Optional容器本身一定不为null,因此,保证了链式操作的每一步都不会为null。另一方面,开发人员不应该写出Optional opt = null;这样的代码。

4.3.3 Optional的其他方法和问题
  • void ifPresent(Consumer<? super T> consumer):如果存在值,则使用该值调用指定的消费者,否则不执行任何操作。
  • boolean isPresent():如果存在值返回 true,否则为 false 。

Optional的合理使用依赖于被调用方法的返回值类型,如果PersonDao.getPersons()方法返回的就是List<Person>,那么在调用方写出这样的代码是遭人吐槽的:

List<Person> list = dao.getPersons();
if (Optional.ofNullable(list).isPresent()){
	//...
}

另一方面,也不应该用Optional去封装类的成员变量,因为类的成员变量是通过类的实例访问的。

class Person{
	private Optional<String> name; // 这么做是没有必要的,反而会使得程序更为复杂
}
4.4 总结

Optional类是一种傻瓜式的保护,通过Optional的of()/ofNullable()方法构造的非null容器,确保了所有的中间操作不会发生NPE异常,减少了中间环节的if判断。当开发人员从容器中取值的时候,Optiona将迫使开发人员进行思考:对于容器中的null结果将如何处理。

五、Stream元素流

Stream元素流是Java8的重要内容,是函数式编程的最佳实践,是对前几小节的总和应用。因此,需要有Lambda、JDK8接口和方法引用的基础。另一方面,由于IO Stream先入为主,Java8的Stream API就更难理解一点,因为它们完全不是一回事,虽然都叫Stream“流”。

5.1 操作数据的传统方式

无论是SQL还是Java,经常会对数据进行操作整理。SQL中有筛选,分组和排序等等。在Java中,也可以对数据进行整理,一个需求:在若干字符串中,求出以A开头,并且最长的字符串有多长。

思路:对字符串数组进行过滤,将以A开头的字符串加入到List中;对List进行遍历,得到所有字符串的长度;取出最大值。代码如下所示:

public class Main2 {
    public static void main(String[] args) {
        String[] strs = {"abc", "A", "Adcded"};
        List<String> list = new ArrayList<String>();// 用于存放筛选后的字符串
        List<Integer> lengthList = new ArrayList<Integer>();//用于存放字符串长度
        
        int longest = -1;
        for (String str : strs) {
            if (str.startsWith("A")){
                list.add(str);
            }
        }

        for (String str : list) {
            lengthList.add(str.length());
        }

        longest = Collections.max(lengthList);
        System.out.println(longest);
    }
}

这样的写法效率低,对于筛选取值完全可以一次遍历就能得到结果,代码如下:

public class Main2 {
    public static void main(String[] args) {
        String[] strs = {"abc", "A", "Adcded"};
        int longest = -1;
        for (String str : strs) {
            if (str.startsWith("A")){
                int lenth = str.length();
                longest = Math.max(lenth, longest);
            }
        }
        System.out.println(longest);
    }
}

Java8的Stream API为对数据的操作提供了新的解决方案,在使用Stream之前,先回顾一下PredicateFunction的用法。以下代码,使用Predicate接口,判断字符串是否以A开头。使用Function接口,求出字符串的长度:

public class Main2 {
    public static void main(String[] args) {
        String str = "Aaaaa";
        Predicate<String> predicate = (s) -> s.startsWith("A");
        System.out.println(predicate.test(str));

        Function<String, Integer> function = (s) -> s.length();
        System.out.println(function.apply(str));
    }
}
5.2 Stream API的使用

Java8提供的Stream,在java.util.stream包中。Stream的使用,分为三步骤:创建数据流,操作数据流,收集结果。类比SQL的查询语句可能更容易理解一些。select 'A' from 'B' where 'C'

  • 创建数据流,若要对若干数据进行操作,必须用这些数据生成一个Stream流。对数据的操作其实是在操作Stream流。相当于SQL语句中的B部分。
  • 操作数据流,例如:Stream.filter(Predicate predicate),若Predicate接口返回false,Stream中的对应数据将被筛选掉。Stream.mapToInt(Function function),根据Function接口的转换规则,将Stream中的数据转为int数据。相当于SQL语句中的C部分。
  • 收集结果,也叫终端操作。在Stream API中,只有进行结果的收集操作时,对数据的操作才会真正的被执行,有点类似于“懒加载”的概念。因此,几乎所有的情况下,收集结果都是必要的。相当于SQL语句中的A部分。
public class Main2 {
    public static void main(String[] args) {
        List<String> strings = Arrays.asList("abc", "A", "Adcded");
        OptionalInt longest = strings.stream() // 使用strings里的数据,创建一个数据流
                .filter(s -> s.startsWith("A")) // 对流进行筛选操作
                .mapToInt(s -> s.length()) // 对流进行转换操作
                .max(); // 收集结果
        System.out.println(longest.getAsInt());
    }
}

接下来详细介绍Stream的三个步骤。

5.2.1 创建数据流

流可以通过多种方法创建,包括但不限于:

  • 通过Collection实例对象的stream()和parallelStream()方法:new ArrayList().stream();
  • 通过数组工具类Arrays的stream方法:Arrays.stream(new int[]{1,2,3});
  • 随机数流可以通过Random对象的ints()/doubles()等方法获得:new Random().ints();
  • 通过Stream的静态方法:
    Stream.of(1,1,2,3);
    LongStream.range(1,5); // 1,2,3,4 ——前闭后开区间
    LongStream.rangeClosed(1,5); // 1,2,3,4,5 ———闭区间
    IntStream.generate(() -> 1); // 1,1,1,1....无限循环,这将创建一个无限流
    Stream.iterate(0, x->x+2); // 0,2,4,6,8...无限循环,这将创建一个无限流
    
5.2.2 终端操作

终端操作用来获取最终结果,也是对结果的收集。Stream有如下几种终端操作:

Stream<Integer> stream = Stream.of(1, 2, 3);

// forEach(void forEach(Consumer action)):对流中的每个元素执行操作,接收一个Consumer
stream.forEach(System.out::println);

// void forEachOrdered(Consumer action):在并行流中与forEach不同。 在下一节并行流会讲
stream.forEachOrdered(System.out::println);

// Object[] toArray():返回一个包含此流的元素的数组。 注意返回Object[]
Object[] objects = stream.toArray();

// Optional	reduce(BinaryOperator accumulator):对流中的元素进行规约合并
// BinaryOperator<T> extends BiFunction<T,T,T> :接收两个参数,返回一个参数
Optional<Integer> sum = stream.reduce(Integer::sum);

// <R,A> R 	collect(Collector<? super T,A,R> collector):使用 Collector对此流的元素进行规约
List<Integer> collect = stream.collect(Collectors.toList());

// Optional	min(Comparator comparator) or Optional max(Comparator comparator):最小最大值
Optional<Integer> min = stream.min(Integer::compareTo);

// long count():返回流中元素个数
long count = stream.count();

// Iterator<T> iterator():返回此流的迭代器
Iterator<Integer> iterator = stream.iterator();

// boolean anyMatch(Predicate predicate):判断此流中是否有满足Predicate的元素,只要有一个满足就返回true
boolean exist = stream.anyMatch(i -> i%2==0);

// boolean allMatch(Predicate predicate):判断此流中的元素是否全部满足Predicate
boolean all = stream.allMatch(i -> i%2==0);

重点介绍三个比较复杂的方法:forEach()、reduce()和collect(),其他的方法比较简单。

(1)、forEach()方法:对流中的每一个元素进行一个Consumer操作。由于Consumer.accept()方法返回void,这意味着“对流中的每个元素进行+1”这样改变原始值的需求,使用forEach(x->x+=1)是没有意义的,因为无从获取结果。更重要的是,forEach(x->x+=1)也无法完成对流中元素的自增,这就像是方法的局部变量不改变原始参数一样。执行过程类似于如下代码:

public static void forEach(List<Integer> list){
    for (int x : list) {
        Consumer<Integer> consumer = i -> i+=1;
        consumer.accept(x);// 局部变量不能修改原始值
    }
}

forEach()只适用于从流中读值:

Stream<Integer> stream = Stream.of(1, 2, 3);
List list = new ArrayList();
stream.forEach(x->list.add(x));
// 可以使用forEach进行这样的操作,但是单从例子上来说,并没有collect(Collectors.toList())方便

(2)、reduce()方法:对流中的元素,根据BinaryOperator的规则将Stream中的元素进行计算后返回一个唯一的值。reduce()方法有三种重载方式:

  • Optional<T> reduce(BinaryOperator<T> accumulator):参数为BinaryOperator,之前讲过UnaryOperator是接收一个T,返回一个T。BinaryOperator是接收两个T,返回一个T。比如接收两个数,返回两数之和;接收两个数,返回最大者。
    Stream<Integer> stream = Stream.of(1, 2, 3);
    stream.reduce((i, j) -> i+j); // 接收两个int,返回一个int
    stream.reduce(Integer::sum); 
    
  • T reduce(T identity, BinaryOperator<T> accumulator):第一个参数是一个基准值,第二个参数是BinaryOperator。比如:在0的基础上累加,在-1的基础上求最小值。
    stream.reduce(0, Integer::sum);
    stream.reduce(-1, Integer::min);
    
  • U reduce(U identity, BiFunction<U,T,U> accumulator, BinaryOperator<U> combiner):注意这个方法,流中元素的类型为T,这个reduce方法返回的是U,也就是说可以返会和流中元素类型不同的值。例如:将流中的(1,2,3)元素进行字符串拼接为“123”:
    Stream<Integer> stream = Stream.of(1, 2, 3);
    // 第一个参数基准值,类型为方法返回值类型
    // 第二个参数BiFunction<U,T,U>,接收一个Integer(T),一个String(U),返回String(U)。该参数负责“如何将String和Integer结合,以返回String”
    // 第三个参数BinaryOperator,接收两个String(U),返回一个String(U)。该参数负责“如何将String和String结合,返回新的String”,在并行流中该参数会有用。
    String result = stream.reduce("", (str, integer) -> str + integer, (s1, s2) -> s1 + s2);
    

(3)、collect()方法:另一种聚合方式,和reduce规约类似,但是使用方法不同,并且通过Collectors工具类提供的方法,使得使用collect()方法的功能更加强大。该方法有两种重载方式:

  • R collect(Supplier<R> supplier, BiConsumer<R> accumulator, BiConsumer<R,R> combiner):假设返回值类型为String,那么三个参数将分别负责:String如何产生,String如何消费(Consumer)流中的元素,同类型的String如何合并。之前讨论过Consumer接口无法修改原始值的问题,以下代码将什么也不输出:
    Stream<Integer> stream = Stream.of(1, 2, 3);
    String str = stream.collect(
    	String::new, 
    	(s, integer) -> s += integer, // 局部变量无法修改原始数据
    	(s, s2) -> s += s2
    );
    System.out.println(str);
    
    解决这个问题可以使用引用的方式,以下示例就可以将流中的数据连接为字符串:
    Stream<Integer> stream = Stream.of(1, 2, 3);
    StringBuilder collect = stream.collect(
            () -> new StringBuilder(""), 
            (stringBuilder, integer) -> stringBuilder.append(integer), 
            (stringBuilder, stringBuilder2) -> stringBuilder.append(stringBuilder2)
    );
    
    还有一种常见的情况,经常需要把流中的元素收集到一个集合中去,如下代码所示:
    Stream<Integer> stream = Stream.of(1, 2, 3);
    List<Integer> list = stream.collect(
            ArrayList::new, // List类型如何产生
            ArrayList::add, // List如何消费(Consumer)流中的元素
            List::addAll // 同类型如何合并
    );
    
  • R collect(Collector collector):该方法接收一个Collector,JDK提供了Collectors工具类专门用于处理这种情况,并提供了大量常用的方法,开发人员无需像上面一样自己实现三个参数。这是collect()功能强大的地方。对于“将流中的元素收集到一个集合”这个需求,只需要一句stream.collect(Collectors.toList())就能完成。
5.2.2.1 Collectors工具类

(1) 转成集合

  • toList:将流中的数据收集到List中,默认为ArrayList。
    Stream<Integer> stream = Stream.of(1, 2, 3);
    List<Integer> integers = stream.collect(Collectors.toList());
    
  • toSet:将流中的数据收集到Set中,默认为HashSet。
    Stream<Integer> stream = Stream.of(1, 2, 3);
    Set<Integer> integers = stream.collect(Collectors.toSet());
    
  • toCollection:如果要将流中的数据收集到其他集合中,比如LinkedList或者TreeSet。该方法接收一个Collection,因此不能使用该方法转Map。
    Stream<Integer> stream = Stream.of(1, 2, 3);
    stream.collect(Collectors.toCollection(TreeSet::new));
    stream.collect(Collectors.toCollection(LinkedList::new));
    
  • toMap:将流中的数据收集到Map中,默认为HashMap。该方法有三种重载方式,分别有两个参数,三个参数,四个参数:
    List<Person> list = new ArrayList<Person>();
    list.add(new Person(1, "aa"));
    list.add(new Person(2, "bb"));
    // toMap(Function keyMapper, Function valueMapper)
    Map<Integer, String> map = list.stream().collect(
    	Collectors.toMap(
    	   Person::getId, // 第一个参数负责“如何通过流中的Person类型生成map的key”
    	   Person::getName // 第二个参数负责“如何通过流中的Person类型生成map的value”
    	));
    
    List<Person> list = new ArrayList<Person>();
    list.add(new Person(1, "aa"));
    list.add(new Person(1, "bb")); 
    // 如果还按照上面的例子,将Person的id作为key,则抛IllegalStateException: Duplicate key
    // toMap(Function keyMapper, Function valueMapper, BinaryOperator mergeFunction)
    Map<Integer, String> map = list.stream().collect(
            Collectors.toMap(
               Person::getId, // 第一个参数负责“如何通过流中的Person类型生成map的key”
               Person::getName, // 第二个参数负责“如何通过流中的Person类型生成map的value”
               (s1, s2) -> {	// 第三个参数负责“当key值相同的时候,对应的value如何操作”
                   return s1 + s2; // 将key对应的两个value拼接
                   // return s2; 用后者覆盖,和map的add()一样
               }
            ));
    
    List<Person> list = new ArrayList<Person>();
    list.add(new Person(2, "bb"));
    list.add(new Person(3, "cc"));
    Map<Integer, String> maps = new HashMap<Integer, String>();
    maps.put(1, "aa");
    // toMap(Function keyMapper, Function valueMapper, BinaryOperator mergeFunction, Supplier mapSupplier)
    list.stream().collect(
            Collectors.toMap(
                Person::getId, // 第一个参数负责“如何通过流中的Person类型生成map的key”
                Person::getName, // 第二个参数负责“如何通过流中的Person类型生成map的value”
                (s1, s2) -> { // 第三个参数负责“当key值相同的时候,对应的value如何操作”
                    return s2;
                },
                ()->maps // 第四个参数,将流中的数据收集到哪个map中去(这个map可能原来就存在)
            ));
    

(2) 收集数据:之前的reduce(),写法基本上类似于stream.reduce(Integer::sum),现在介绍collect的方式。

Stream<Integer> stream = Stream.of(2, 3, 1, 5, 4);
// Collectors.summarizingInt(ToIntFunction mapper) 获得一个数据摘要,可以通过摘要获得其他信息
IntSummaryStatistics collect = stream.collect(Collectors.summarizingInt(i -> i));
collect.getAverage();
collect.getCount();
collect.getMax();
collect.getMin();
collect.getSum();
// 这只是一种综合的方式,其他的方法(averagingInt(),minBy(),summingInt()等)参照JDK8 API

(3) 操作数据段

  • 连接字符串
    Stream<String> stream = Stream.of("a", "b", "c", "c");
    String str1 = stream.collect(Collectors.joining()); // 直接连接:abcd
    String str2 = stream.collect(Collectors.joining("-")); // 以-连接:a-b-c-d
    String str3 = stream.collect(Collectors.joining("-", "[", "]")); // 以-连接,前缀[,后缀]:[a-b-c-d]
    
  • 数据划分,Collectors.partitioningBy()接收一个Predicate,根据判断结果划分两组。以下示例代码将流中的数据根据奇偶性分为两组:
    // Collectors.partitioningBy(Predicate predicate)将返回一个HashMap<boolean, ArrayList>
    // 若要返回到其他集合,请使用partitioningBy(Predicate predicate, Collector downstream)
    // 以下代码返回值:{false=[1, 3], true=[2, 4]}
    Stream<Integer> stream = Stream.of(1,2,3,4);
    Map<Boolean, List<Integer>> collect = stream.collect(Collectors.partitioningBy(i -> i%2 == 0));
    
  • 数据分组,数据划分的结果是以true和false为key的两组数据,但是有的时候需要根据某一项进行分组。使用Collectors.groupingBy(Function classifier),以下示例代码根据Person的ID分组
    // groupingBy“根据给定的字段id进行分组”,参数Function负责“如何通过流中的Person元素得到id这个字段”
    List<Person> list = new ArrayList<Person>();
    list.add(new Person(1, "aaa"));
    list.add(new Person(1, "bbb"));
    list.add(new Person(2, "ccc"));
    
    Map<Integer, List<Person>> collect = list.stream().collect(Collectors.groupingBy(Person::getId));
    
5.2.3 中间操作

中间操作对于Stream而言,不是必须的,从前几小节的例子可以看出,Stream只需开启和终端操作就可以。但是仅是终端操作在有些情况下无法满足需求,例如“对数据去重,取出前三个”这样常见的功能。Stream的中间操作返回的都是一个新的流,这也是Stream支持链式操作的原因。

  • map(Functionmapper):进行流的转换,将流中的每个元素进行Function转换,返回Function执行的结果组成的流。
  • filter(Predicate predicate):进行流的过滤,根据Predicate的结果,过滤掉结果为false的那些元素。
  • distinct():对流中的元素进行去重。
  • sorted(Comparator comparator):对流中的元素进行排序。
  • skip(Long n):跳过n元素后,返回剩下元素组成的流。
  • limit(long maxSize):返回前maxSize个数据的流
5.3 Stream API其他问题
5.3.1 Stream的性能问题

之前看到很多关于讨论Stream效率的问题,都是从遍历角度出发的。在Stream API之后,对于集合的遍历方式又多了一种,目前对于ArrayList的遍历方式分为三种:普通for循环,增强for循环,stream.forEach。

关于具体的实验,在这里不在浪费篇幅,只给出最终结论:仅仅对于简单遍历而言,普通for循环的效率最高,其次是增强for循环,最后是stream.forEach。

但是果真如此吗?对于单线程遍历而言,虽然Stream遍历的效率最低,但是随着数据量的增大,和循环内处理的代码逻辑的增多,三者的效率相差几乎不大(虽然效率排名没有改变,但是几乎相当)。因此,对于网上说的“Stream效率比传统编程效率低5倍”这样的结论,过于片面。也可以认为,在数据量少的情况下,Stream创建和回收、以及线程池的调度消耗的性能占比会高一些(关于Stream的执行过程,之后讨论)。

另一方面,Stream API对于并行流的支持,使得开发多线程编程变得极其简单,在大量数据的情况下,并行流的执行效率极高。有关于并行流,下一节讨论。

总结:对于简单操作推荐使用外部迭代手动实现,对于复杂操作,或者支持多核的情况下,请使用Stream

5.3.2 与集合的区别

流与集合有以下几种不同:

  • 没有存储。 流不是存储元素的数据结构; 相反,它通过计算操作的流水线传送诸如数据结构,数组,生成器功能或I / O通道的源的元件。
  • 功能性质。 流上的操作产生结果,但不会修改其来源。 例如,fillter()方法从Stream会生成新的Stream,而不是从源集合中删除元素。
  • 懒惰寻求。 许多流操作(如过滤,映射或重复删除)可以懒惰地实现,从而暴露优化的机会。 例如,“找到具有三个连续元音的第一个String ”不需要检查所有的输入字符串。 流操作分为中间( Stream生产)操作和终端(价值或副作用生成)操作。 中间操作总是懒惰。
  • 可能无限。 虽然集合的大小有限,但流不需要。虽然有limit(n)或者findFirst()等方法允许在无限流上的计算在有限的时间内完成。 但是iterate()和generate()确实是无限的。
  • 消耗品。流中的元素只能在流的一生中访问一次。 像Iterator一样 ,必须生成一个新流来重新访问源的相同元素。
    ——《Java8 API中文文档》
5.3.3 Stream是如何执行的

在本章节最开始,提出一个需求:求出以A开头,并且最长的字符串有多长。用Stream实现如下:

public static void main(String[] args) {
    String[] strs = {"abc", "A", "Adcded"};
    
    Optional<Integer> length = Arrays.stream(strs)
            .filter(s -> s.startsWith("A"))
            .map(s -> s.length())
            .max(Integer::compareTo);

    System.out.println(length.get());
}

从直白的方式看,filter将流中的元素进行遍历,将筛选后的数据放入一个临时容器,执行map的时候,将这个容器进行传递,map方法对该容器再次遍历。。。这样的话,执行过程类似于本章开篇中的第一种方法。Stream流若如此执行,那么存在迭代效率过多,产生中间结果过多,性能必定低。其实Stream的执行过程,大致类似于开篇中的第二种解决方法。

String[] strs = {"abc", "A", "Adcded"};
int longest = -1;
for (String str : strs) {
    if (str.startsWith("A")){
        int lenth = str.length();
        longest = Math.max(lenth, longest);
    }
}

Stream的中间操作是不会立即执行的,当调用终止操作时将之前的中间操作叠加到一起,在一次迭代中全部执行掉。以下代码将什么也不输出。

Arrays.stream(strs)
      .filter(s -> s.startsWith("A"))
      .peek(System.out::println)
      .map(s -> s.length());

由于中间操作只是一种标记,只有结束操作才会触发实际计算。因此也说中间操作总是懒惰的。通过以下两个实例,大致说明Stream的执行过程。

IntStream.range(1, 10)
   .peek(x -> System.out.print("\nA" + x))
   .limit(3)
   .peek(x -> System.out.print("B" + x))
   .forEach(x -> System.out.print("C" + x));

输出为: A1B1C1 A2B2C2 A3B3C3

  1. 中间操作是懒惰的,也就是中间操作不会对数据做任何操作,直到遇到了最终操作。而最终操作,都是比较热情的。他们会往前回溯所有的中间操作。也就是当执行到最后的forEach操作的时候,它会回溯到它的上一步中间操作,上一步中间操作,又会回溯到上上一步的中间操作,…,直到最初的第一步。
  2. 第一次forEach执行的时候,会回溯peek 操作,然后peek会回溯更上一步的limit操作,然后limit会回溯更上一步的peek操作,顶层没有操作了,开始自上向下开始执行,输出:A1B1C1 。第二次forEach执行的时候,然后会回溯peek 操作,然后peek会回溯更上一步的limit操作,然后limit会回溯更上一步的peek操作,顶层没有操作了,开始自上向下开始执行,输出:A2B2C2
  3. 当第四次forEach执行的时候,然后会回溯peek 操作,然后peek会回溯更上一步的limit操作,到limit的时候,发现limit(3)这个job已经完成,这里就相当于循环里面的break操作,跳出来终止循环。

——《深入理解Java8中Stream的实现原理》

第二个例子:

IntStream.range(1, 10)
   .peek(x -> System.out.print("\nA" + x))
   .skip(6)
   .peek(x -> System.out.print("B" + x))
   .forEach(x -> System.out.print("C" + x));

输出为: A1 A2 A3 A4 A5 A6 A7B7C7 A8B8C8 A9B9C9

  1. 第一次forEach执行的时候,会回溯peek操作,然后peek会回溯更上一步的skip操作,skip回溯到上一步的peek操作,顶层没有操作了,开始自上向下开始执行,执行到skip的时候,因为执行到skip,这个操作的意思就是跳过,下面的都不要执行了,也就是就相当于循环里面的continue,结束本次循环。输出:A1
  2. 第二次forEach执行的时候,会回溯peek操作,然后peek会回溯更上一步的skip操作,skip回溯到上一步的peek操作,顶层没有操作了,开始自上向下开始执行,执行到skip的时候,发现这是第二次skip,结束本次循环。输出:A2
  3. 第七次forEach执行的时候,会回溯peek操作,然后peek会回溯更上一步的skip操作,skip回溯到上一步的peek操作,顶层没有操作了,开始自上向下开始执行,执行到skip的时候,发现这是第七次skip,已经大于6了,它已经执行完了skip(6)的job了。这次skip就直接跳过,继续执行下面的操作。输出:A7B7C7…直到循环结束。

——《深入理解Java8中Stream的实现原理》

有关更多的信息参加参考文档。

5.3.4 一些细节

1)Stream API还提供了一个方法:Stream<T> peek(Consumer action),这个方法需要Stream的实例对象调用,遍历流中的元素,执行Consumer,并返回一个Stream。这应该被认为一个中间操作,因为如果没有终止操作,他将什么都不执行,以下代码什么都不会输出。

Stream<Integer> stream = Stream.of(1, 2, 3);
Stream<Integer> stream2 = stream.peek(System.out::println);
// stream2.count(); 当流进行终止操作的时候,peek()才被执行

2)流中的元素只能在流的一生中访问一次。 像Iterator一样 ,必须生成一个新流来重新访问源的相同元素。也就是说,流一旦执行终止操作之后,就会被关闭,无法复用。以下代码将抛出非法状态异常IllegalStateException

Stream<Integer> stream = Stream.of(1, 2, 3);
stream.count(); // 执行完终止操作后,这个流就被关闭了
stream.max(Integer::compareTo);
// IllegalStateException : stream has already been operated upon or closed

3)在流操作的过程中,不能修改流的数据源。否则会抛出ConcurrentModificationException

ArrayList<String> list = new ArrayList<String>(Arrays.asList("a", "A"));
ArrayList<String> results = new ArrayList<>();
list.stream()
	.filter(s -> s.startsWith("A"))
    .forEach(s -> list.add("sdf"));  

4)Stream的副作用,下例与上面的不同之处在于,上面是修改流的数据源,下面的例子是对其他的数据进行写操作。

特别说明:副作用不应该被滥用,也许你会觉得在Stream.forEach()里进行元素收集是个不错的选择,就像下面代码中那样,但遗憾的是这样使用的正确性和效率都无法保证,因为Stream可能会并行执行。大多数使用副作用的地方都可以使用归约操作更安全和有效的完成。——《深入理解Java8中Stream的实现原理》

// 错误的收集方式
ArrayList<String> results = new ArrayList<>();
stream.filter(s -> pattern.matcher(s).matches())
      .forEach(s -> results.add(s));
      
// 正确的收集方式
List<String>results = stream.filter(s -> pattern.matcher(s).matches())
             .collect(Collectors.toList());

虽然以上代码是可以运行的,并且某些情况下运行结果也是正确的,但是存在安全隐患。详情请见参考资料。

避免在stream的中间操作中对lambda表达式之外的属性产生写操作,或者说不要去修改函数外部的状态,尤其是在并行的stream中,这种操作导致的结果是不可预测的。——《Java8 Stream的副作用》

5)留意装箱。自动装箱和拆箱操作会大大降低性能。Java8中有原始类型流(IntStream、LongStream、DoubleStream)来避免这种操作,但凡有可能都应该使用这些原始流。

六、ParallelStream并行流

6.1 并行流的原理

一句话,并行流通过Fork-Join框架执行。参照Java之JUC

Fork-Join在JDK7中编写起来比较复杂,在JDK8中,提供了并行流供开发人员使用,屏蔽了底层复杂的原理。只需要一句话:Stream.of(1, 2, 3).parallel(),就可以开启并行流。后续的中间操作和终止操作和上一章节的内容完全一样,只有内部执行过程不同:首先对任务进行拆分,并行流将创建一个线程池,用多个线程并发去执行各个子任务,当所有子任务执行完毕时,再将每个子任务的结果进行合并。并行流的效率极高。

6.2 如何使用并行流

创建并行流分两种情况:1.对象是流,则使用stream.parallel()。2.对象是容器,则使用list.parallelStream()

从并行流切换到顺序流:使用sequential()方法。

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8);
stream.parallel().map(i -> i * i).sequential().sorted().forEach(System.out::println);
6.3 问题总结

在之前的章节中,留下了如下几个问题:

  1. collect()的重载方法中有一个参数mergeFunction,用于同类型的合并。在并行流中,由于是多个线程共同执行。对于“ 将流中的元素,收集到一个List中 ”这样的需求,多个线程会将流中的一部分数据收集到一个临时的List中,等到合并阶段,就产生了“ List如何与List合并 ”这样的问题,因此,这个参数就是负责“并行流处理完任务如何合并”这个功能。
  2. Stream的副作用。使用并行流会带来一个显著的问题,由于任务的划分以及线程执行的不可控带来的不稳定结果。如果这种不稳定对你的程序无影响,则可以使用并行流。简单的如下面例子所示:将不会输出1, 4, 9…因此,forEach()循环是显示运用了Stream的副作用,除此之外,开发人员应尽量避免Stream的副作用。
    Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8);
    stream.parallel().map(i->i*i).forEach(System.out::println);
    // 输出36 25 9 16 1 49 4 64,输出结果不可控
    

其他的细节:

  1. 线程池的创建与调度是比较耗费资源的,因此,并行流在大数据量和复杂操作时适用。
  2. 要考虑流背后的数据结构是否易于分解。例如,ArrayList的拆分效率比LinkedList高很多,因为前者用不着遍历就可以平均拆分,而后者则必须遍历。
  3. 不应该在并行流的时候使用sort()、limit()、findFirst()等方法。sort()方法只能保证线程执行的子任务内有序,但是并不能保证合并后是有序的,这在排序场景下显然是错误的结果。而limit()、findFirst()等方法,显然在单线程的情况下更适合,使用并行流,更加耗费性能。

七、更好的类型推断

在第一章Lambda表达式中提到过,Lambda表达式的参数类型可以省略,这基于Java8的类型推断。如下代码所示。

// 并未明确指明o1,o2的类型为Person
(o1, o2) ->  o1.getAge().compareTo(o2.getAge());

类型推断在Java7的时候就有了,Java8为了Lambda表达式,进一步增强了类型推断的功能。在Java7之前,写出的泛型是这样的:

// 左右两边的泛型声明都要写
List<String> list = new ArrayList<String>();

上面的写法比较冗余,在声明变量的的时候已经指明了参数类型,为什么还要在初始化对象时再次指定?在Java7之后,改进了这点,泛型是这样的:

// 后面无需再次声明泛型类型,但是<>必须保留,否则就是不带泛型的ArrayList
List<String> list = new ArrayList<>();

在Java8中的类型推断主要有两方面的增强:

  1. 根据方法参数来自动推断泛型的类型
  2. 支持泛型类型的方法传递
List<String> list1 = new ArrayList<>();
list1.add("asa");
list1.stream().map(s->s.length()).sorted().forEach(System.out::println);

map()方法能根据list的泛型类型推断出参数s的类型,map方法的返回类型为Integer,将这个类型继续传递给sorted()方法。

八、新的日期时间API

8.1 Java8之前的时间处理

在Java 8之前,对日期和时间的处理有java.util.Date类和java.util.Calendar类,时间的格式化是java.text.SimpleDateFormat类。在Web开发中,常见的使用方式有如下几种:

// 前端传一个时间字符串,后台进行转换存储
String date = "2019-10-10";
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
Date date1 = simpleDateFormat.parse(date);

// 设置一个对象的出生日期为2910-11-07
Person p = new Person();
p.setBirthday(new Date(119, 10, 7)); //2019-11-7

传统时间处理方式的缺点:

  1. 初始化的时候,年份要减去1900,月份要从0开始。
  2. SimpleDateFormat的线程安全问题。
  3. 对日期的计算需要另外使用Calendar类。
  4. SimpleDateFormat是java.text包下的,设计缺陷。
8.2 Java8的时间处理

只要和时间或日期相关的,都可以使用Java8提供的java.time包。包中有如下几个常用的类:

  • 给人看的时间有LocalDate、LocalTime、LocalDateTime。
  • 给机器看的时间Instant。
  • 表示一段时间的Duration、Period。
  • 用于进行时间格式化的DateTimeFormatter。
  • 代表时区的ZoneId、ZoneOffset。
8.2.1 LocalDate、LocalTime、LocalDateTime

这三个类都是人使用的日期,如 2007-12-03 T10:15:30 。三个类维护三种不同的信息:LocalDate 该类只包含日期,不包含时间,也不包含时区信息。LocalTime 该类只包含时间,不包含日期。LocalDateTime 该类既包含日期,又包含时间。这三个类有很多共有的方法,构造对象和获取时间大多相同。以下以LocalDateTime为例说明相同的方法:

// 静态方法构造一个实例对象,秒和纳秒可以不传,构成函数重载
LocalDateTime.of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond);
// 接收一个LocalDate和一个LocalTime,这两个对象仍然可以通过of方法构造
LocalDateTime.of(LocalDate date, LocalTime time);
// 以当前时间初始化一个LocalDateTime
LocalDateTime now = LocalDateTime.now();

localDateTime.getYear(); // 返回年,2019
localDateTime.getMonthValue(); // 返回月
localDateTime.getHour(); // 返回小时
// 获取秒,纳秒等方法雷同,只有一个较为特殊,没有getDay()方法
localDateTime.getDayOfYear(); // 返回这一天是这个年的第几天,2月1日是第38天
localDateTime.getDayOfMonth(); // 返回这一天是这个月的第几天,2月7日是第7天
localDateTime.getDayOfWeek(); // 返回星期几的英文:MONDAY、THURSDAY

// 日期比较
int localDateTime.compare(LocalDateTime other);
boolean isBefore(LocalDateTime other);
boolean isAfter(LocalDateTime other);

// 对日期运算
localDateTime.plusDays();
localDateTime.plusHours(); //获得在此基础上增加指定小时数的时间
localDateTime.minusDays();
localDateTime.withHours(); // 将小时设置为某个点

接下来说点不同之处:

// LocalDate独有的:
LocalDate localDate = LocalDate.of(2018, 01, 01);
localDate.lengthOfMonth(); // 得到这个月一共多少天
localDate.lengthOfYear(); // 得到这年一共多少天
localDate.isLeapYear(); // 是否是闰年
8.2.2 Instant

Instant是便于计算机处理的时间,以Unix元年时间(UTC时区1970年1月1日午夜时分)为起点进行计算。它所包含的是由秒及纳秒所构成的数字,因此,对于getYear()这类的操作是不支持的。

// 构造方法,从Unix元年开始过了10秒的这一刻
Instant instant = Instant.ofEpochSecond(10);
8.2.3 Duration和Period

求两个时间之间的间隔,这两个类都可以做到。一个典型的应用如下:

Instant before = Instant.now();
Thread.sleep(1000);
Instant after = Instant.now();
Duration between = Duration.between(before, after);
System.out.println(between.toMillis());
LocalDate before = LocalDate.of(2019, 11, 11);
LocalDate after = LocalDate.of(2018, 11, 11);
Period period = Period.between(before, after);
System.out.println(period.getDays());// 将输出0
  1. 不能将LocalDate对象传给Duration,但是可以传给Period,否则会抛出UnsupportedTemporalTypeException: Unsupported unit: Seconds
  2. Period.getDays()方法将仅仅返回Day的数据,而不是总天数
8.2.4 日期转换器DateTimeFormatter

DateTimeFormatter是java.time.format包下的类,专门用于日期格式化。常用的格式化类型以DateTimeFormatter的常量封装。另一方面,无论是字符串转日期还是日期转字符串,调用的都是日期的方法。DateTimeFormatter的日期格式化是线程安全的。

LocalDate before = LocalDate.of(2019, 11, 11);
// 将日期转字符串,调用日期对象的方法。
String format = before.format(DateTimeFormatter.ISO_LOCAL_DATE);
// 将字符串转日期,调用日期类的静态方法。
LocalDate parse = LocalDate.parse(format, DateTimeFormatter.ISO_LOCAL_DATE);

DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd
DateTimeFormatter.ISO_LOCAL_DATE_TIME // yyyy-MM-dd T hh:mm:ss

除了已提供的日期格式,开发人员可以自定义日期格式。

// 第一种方式,以DateTimeFormatter.ofPattern指定
LocalDateTime before = LocalDateTime.of(2019, 11, 11, 13, 26);
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String format = before.format(dateTimeFormatter);
// 第二种方式,通过DateTimeFormatterBuilder自定义生成DateTimeFormatter
LocalDateTime before = LocalDateTime.of(2019, 11, 11, 13, 26);
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendText(ChronoField.YEAR)
        .appendLiteral("年")
        .appendText(ChronoField.MONTH_OF_YEAR)
        .appendText(ChronoField.DAY_OF_MONTH)
        .appendLiteral("号")
        .parseCaseInsensitive().toFormatter();
String format = before.format(formatter);
System.out.println(format); // 2019年十一月11号

九、Base64编码器

9.1 什么是Base64编码

Base64编码可以将二进制数据转为某些特定字符的编码,这些特定字符一共64个(A-Z,a-z,0-9,+,/),所以称作Base64。

Base64 最早用于解决邮件传输中的二进制数据问题,在以前老的邮件传输协议如SMTP,只支持ASCII字符传递,因此如果要传输二进制文件,如图片、视频是无法实现的。Base64 就可以用来将二进制文件内容编码为只包含ASCII字符的内容,这样就可以传输了。

Base64有许多版本的演进,例如在Web开发时,经常将HTTP传输的参数和URL进行Base64编码。这是一种针对特殊场景演进的Base64编码。这样的Base64也是,URLBase64.

标准的Base64并不适合直接放在URL里传输,因为URL编码器会把标准Base64中的“/”和“+”字符变为形如“%XX”的形式,而这些“%”号在存入数据库时还需要再进行转换,因为ANSI SQL中已将“%”号用作通配符。为解决此问题,可采用一种用于URL的改进Base64编码,它在末尾填充’='号,并将标准Base64中的“+”和“/”分别改成了“-”和“_”,这样就免去了在URL编解码和数据库存储时所要作的转换,避免了编码信息长度在此过程中的增加,并统一了数据库、表单等处对象标识符的格式。——《Base64 百度百科》

Base64以三个字节为一组,将这三个字节划分为四组数据段(38=64),然后在拆分后的每个数据段的高位补两个0,再次组成8位的一个字节。解码的时候,先将数据转为二进制,四个字节为一组,去掉首两位0再结合成三个字节。例如:

原数据:11111111 11111111 11111111
Base64编码后的数据:00111111 00111111 00111111 00111111

从上面的编码方式可以看到,Base64可以对高位的ASCII码数据降为低位的数据。另一方面,由于对原数据的拆分,生成的新数据具有一定程度的加密。例如:

原数据:1 2 3 -> 00000001 00000010 00000011
编码后:0 16 8 3 -> 00000000 00010000 00001000 00000011

当然,原数据并不总是3的倍数,但是Base64编码为了能顺利的解码,将编码后的字符串的长度固定为4个字符的倍数,为了如此,要在末尾进行填充。在URLBase64中,总是填充"="。

9.2 使用Base64编码

在JDK8以前,使用Base64编码的方式通常是使用org.apache.commons包下的Base64类。该类提供了两个方法分别进行Base64编码和解码。注意,Base64是将字节进行拆分,方法的入参为字节数组,回参也是字节数组。

new Base64().encode(byte[] bytes); // 编码
new base64().decode(byte[] bytes); // 解码

JDK8中,内置了Base64的编码器,在java.util.Base64中。与org.apache.commons不同的是,该类提供了静态方法getEncoder()来获取一个编码器,getDecoder()获取一个解码器。使用方法如下:

String str = "{key:value}";
byte[] bytes = Base64.getEncoder().encode(str.getBytes()); // e2tleTp2YWx1ZX0=
byte[] decode = Base64.getDecoder().decode(bytes);

注意,System.out.println(bytes)输出的是地址,而不是编码后的结果。若想对编码后的结果进行输出,可以使用new String(bytes),然后输出该字符串。因为该操作常常进行,Base64类提供了更直接的方法:

Base64.getEncoder().encodeToString(str.getBytes());

接下来介绍一些其他的方法:

Base64. getUrlEncoder(); // 返回一个编码器,编码规则适配URLBase64的编码方案
Base64.getMimeEncoder(); // 返回一个编码器,编码规则适配MIME协议的编码方案
Encoder.withoutPadding(); // 末尾不进行填充

十、JVM的改变

JDK8真正开始废弃永久代,而使用元空间(Metaspace)。元空间直接使用物理内存。相应的,原来配置方法区的JVM参数将不在适用。有关于JVM的讨论,下一篇文章中会详细写。

JDK8以前:

  • 堆区
    -Xms128m 最小的堆内存为128M,默认值为物理内存的1/64
    -Xmx512m 最大的堆内存为512M,默认值为物理内存的1/4
  • 方法区
    -XX:PermSize=128M 方法区的最小空间为128M
    -XX:MaxPermSize=512M 方法区的最大空间为512M

JDK8不再有方法区:

  • 堆区(无变化)
    -Xms128m 最小的堆内存为128M,默认值为物理内存的1/64
    -Xmx512m 最大的堆内存为512M,默认值为物理内存的1/4
  • 元数据区(直接使用物理内存,一般不设置)
    -XX:MetaspaceSize=128M 元数据区的最小空间为128M
    -XX:MaxMetaspaceSize=512M 元数据区的最大空间为512M

参考资料:

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值