33

Java8 Stream流

 4 years ago
source link: http://www.cnblogs.com/yulinfeng/p/12561664.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

第三章 Stream流

《Java8 Stream编码实战》的代码全部在 https://github.com/yu-linfeng/BlogRepositories/tree/master/repositories/stream-coding ,一定要配合源码阅读,并且不断加以实践,才能更好的掌握Stream。

对于初学者,必须要声明一点的是,Java8中的Stream尽管被称作为“流”,但它和文件流、字符流、字节流 完全没有任何关系 。Stream流使程序员得以站在更高的抽象层次上对集合进行操作。也就是说Java8中新引入的Stream流是针对集合的操作。

3.1 迭代

我们在使用集合时,最常用的就是迭代。

public int calcSum(List<Integer> list) {
    int sum = 0;
    for (int i = 0; i < list.size(); i++) {
        sum += list.get(i);
    }
    return sum;
}

com.coderbuff.chapter3_stream.chapter3_1.ForDemo#calcSum

例如,我们可能会对集合中的元素累加并返回结果。这段代码由于for循环的样板代码并不能很清晰的传达程序员的意图。也就是说,实际上除了方法名叫“计算总和”,程序员必须阅读整个循环体才能理解。你可能觉得一眼就能理解上述代码的意图,但如果碰上下面的代码,你还能一眼理解吗?

public Map<Long, List<Student>> useFor(List<Student> students) {
    Map<Long, List<Student>> map = new HashMap<>();
    for (Student student : students) {
        List<Student> list = map.get(student.getStudentNumber());
        if (list == null) {
            list = new ArrayList<>();
            map.put(student.getStudentNumber(), list);
        }
        list.add(student);
    }
    return map;
}

阅读完这个循环体以及包含的if判断条件,大概可以知道这是想使用“studentNumber”对“Student”对象分组。这段代码在Stream进行重构后,将会变得非常简洁和 易读

public Map<Long, List<Student>> useStreamByGroup(List<Student> students) {
    Map<Long, List<Student>> map = students.stream().collect(Collectors.groupingBy(Student::getStudentNumber));
    return map;
}

当第一次看到这样的写法时,可能会认为这样的代码可读性不高,不容易测试。我相信,当你在学习掌握Stream后会重新改变对它的看法。

3.2 Stream

3.2.1 创建

要想使用Stream,首先要创建一个流,创建流最常用的方式是直接调用集合的 stream 方法。

/**
 * 通过集合构造流
 */
private void createByCollection() {
    List<Integer> list = new ArrayList<>();
    Stream<Integer> stream = list.stream();
}

com.coderbuff.chapter3_stream.chapter3_2.StreamCreator#createByCollection

也能通过数组构造一个流。

/**
 * 通过数组构造流
 */
private void createByArrays() {
    Integer[] intArrays = {1, 2, 3};
    Stream<Integer> stream = Stream.of(intArrays);
    Stream<Integer> stream1 = Arrays.stream(intArrays);
}

com.coderbuff.chapter3_stream.chapter3_2.StreamCreator#createByArrays

学习Stream流,掌握集合创建流就足够了。

3.2.2 使用

对于Stream流操作共分为两个大类: 惰性求值及时求值

所谓惰性求值,指的是操作最终不会产生新的集合。及时求值,指的是操作会产生新的集合。举以下示例加以说明:

/**
 * 通过for循环过滤元素返回新的集合
 * @param list 待过滤的集合
 * @return 过滤后的集合
 */
private List<Integer> filterByFor(List<Integer> list) {
    List<Integer> filterList = new ArrayList<>();

    for (Integer number : list) {
        if (number > 1) {
            filterList.add(number);
        }
    }
    return filterList;
}

com.coderbuff.chapter3_stream.chapter3_3.Example#filterByFor

通过for循环过滤元素返回新的集合,这里的“过滤”表示排除不符合条件的元素。我们使用Stream流过滤并返回新的集合:

/**
 * 通过Stream流过滤元素返回新的集合
 * @param list 待过滤的集合
 * @return 新的集合
 */
private List<Integer> filterByStream(List<Integer> list) {
    return list.stream()
            .filter(number -> number > 1)
            .collect(Collectors.toList());
}

com.coderbuff.chapter3_stream.chapter3_3.Example#filterByStream

Stream操作时,先调用了 filter 方法传入了一个Lambda表达式代表过滤规则,后调用了 collect 方法表示将流转换为List集合。

按照常理来想,一个方法调用完后,接着又调用了一个方法,看起来好像做了两次循环,把问题搞得更复杂了。但实际上,这里的 filter 操作是 惰性求值 ,它并不会返回新的集合,这就是Stream流设计精妙的地方。既能在保证可读性的同时,也能保证性能不会受太大影响。

所以使用Stream流的理想方式就是, 形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果。

我们不需要去记哪些方法是惰性求值,如果方法的返回值是Stream那么它代表的就是惰性求值。如果返回另外一个值或空,那么它代表的就是及早求值。

3.2.3 常用的Stream操作

map

map操作不好理解,它很容易让人以为这是一个转换为Map数据结构的操作。实际上他是将集合中的元素类型,转换为另外一种数据类型。

例如,你想将“学生”类型的集合转换为只有“学号”类型的集合,应该怎么做?

/**
 * 通过for循环提取学生学号集合
 * @param list 学生对象集合
 * @return 学生学号集合
 */
public List<Long> fetchStudentNumbersByFor(List<Student> list) {
    List<Long> numbers = new ArrayList<>();
    for (Student student : list) {
        numbers.add(student.getStudentNumber());
    }
    return numbers;
}

com.coderbuff.chapter3_stream.chapter3_4.StreamMapDemo#fetchStudentNumbersByFor

这是只借助JDK的“传统”方式。如果使用Stream则可以直接通过 map 操作来获取只包含学生学号的集合。

/**
 * 通过Stream map提取学生学号集合
 * @param list 学生对象集合
 * @return 学生学号集合
 */
public List<Long> fetchStudentNumbersByStreamMap(List<Student> list) {
    return list.stream()
               .map(Student::getStudentNumber)
               .collect(Collectors.toList());
}

com.coderbuff.chapter3_stream.chapter3_4.StreamMapDemo#fetchStudentNumbersByStreamMap

map 传入的是一个方法,同样可以理解为传入的是一个“行为”,在这里我们传入方法“getStudentNumber”表示将通过这个方法进行转换分类。

“Student::getStudentNumber”叫 方法引用 ,它是“student -> student.getStudentNumber()”的简写。表示 直接引用已有Java类或对象的方法或构造器 。在这里我们是需要传入“getStudentNumber”方法,在有的地方,你可能会看到这样的代码“Student::new”,new调用的就是构造方法,表示创建一个对象。方法引用,可以将我们的代码变得更加紧凑简洁。

我们再举一个例子,将小写的字符串集合转换为大写字符串集合。

/**
 * 通过Stream map操作将小写的字符串集合转换为大写
 * @param list 小写字符串集合
 * @return 大写字符串集合
 */
public List<String> toUpperByStreamMap(List<String> list) {
    return list.stream()
               .map(String::toUpperCase)
               .collect(Collectors.toList());
}

com.coderbuff.chapter3_stream.chapter3_4.StreamMapDemo#toUpperByStreamMap

filter

filter ,过滤。这里的过滤含义是“排除不符合某个条件的元素”,也就是返回true的时候保留,返回false排除。

我们仍然以“学生”对象为例,要排除掉分数低于60分的学生。

/**
 * 通过for循环筛选出分数大于60分的学生集合
 * @param students 待过滤的学生集合
 * @return 分数大于60分的学生集合
 */
public List<Student> fetchPassedStudentsByFor(List<Student> students) {
    List<Student> passedStudents = new ArrayList<>();
    for (Student student : students) {
        if (student.getScore().compareTo(60.0) >= 0) {
            passedStudents.add(student);
        }
    }
    return passedStudents;
}

com.coderbuff.chapter3_stream.chapter3_4.StreamFilterDemo#fetchPassedStudentsByFor

这是我们通常的实现方式,通过for循环能解决“一切”问题,如果使用Stream filter一行就搞定。

/**
 * 通过Stream filter筛选出分数大于60分的学生集合
 * @param students 待过滤的学生集合
 * @return 分数大于60分的学生集合
 */
public List<Student> fetchPassedStudentsByStreamFilter(List<Student> students) {
    return students.stream()
            .filter(student -> student.getScore().compareTo(60.0) >= 0)
            .collect(Collectors.toList());
}

com.coderbuff.chapter3_stream.chapter3_4.StreamFilterDemo#fetchPassedStudentsByStreamFilter

sorted

排序,也是日常最常用的操作之一。我们常常会把数据按照修改或者创建时间的倒序、升序排列,这步操作通常会放到SQL语句中。但如果实在是遇到要对集合进行排序时,我们通常也会使用 Comparator.sort 静态方法进行排序,如果是复杂的对象排序,还需要实现 Comparator 接口。

/**
 * 通过Collections.sort静态方法 + Comparator匿名内部类对学生成绩进行排序
 * @param students 待排序学生集合
 * @return 排好序的学生集合
 */
private List<Student> sortedByComparator(List<Student> students) {
    Collections.sort(students, new Comparator<Student>() {
        @Override
        public int compare(Student student1, Student student2) {
            return student1.getScore().compareTo(student2.getScore());
        }
    });
    return students;
}

com.coderbuff.chapter3_stream.chapter3_4.StreamSortedDemo#sortedByComparator

关于 Comparator 可以查看这篇文章《 似懂非懂的Comparable与Comparator 》。简单来讲,我们需要实现 Compartor 接口的 compare 方法,这个方法有两个参数用于比较,返回1代表前者大于后者,返回0代表前者等于后者,返回-1代表前者小于后者。

当然我们也可以手动实现冒泡算法对学生成绩进行排序,不过这样的代码大多出现在课堂教学中。

/**
 * 使用冒泡排序算法对学生成绩进行排序
 * @param students 待排序学生集合
 * @return 排好序的学生集合
 */
private List<Student> sortedByFor(List<Student> students) {
    for (int i = 0; i < students.size() - 1; i++) {
        for (int j = 0; j < students.size() - 1 - i; j++) {
            if (students.get(j).getScore().compareTo(students.get(j + 1).getScore()) > 0) {
                Student temp = students.get(j);
                students.set(j, students.get(j + 1));
                students.set(j + 1, temp);
            }
        }
    }
    return students;
}

com.coderbuff.chapter3_stream.chapter3_4.StreamSortedDemo#sortedByFor

在使用Stream sorted后,你会发现代码将变得无比简洁。

/**
 * 通过Stream sorted对学生成绩进行排序
 * @param students 待排序学生集合
 * @return 排好序的学生集合
 */
private List<Student> sortedByStreamSorted(List<Student> students) {
    return students.stream()
                   .sorted(Comparator.comparing(Student::getScore))
                   .collect(Collectors.toList());
}

com.coderbuff.chapter3_stream.chapter3_4.StreamSortedDemo#sortedByStreamSorted

简洁的后果就是,代码变得不那么好读,其实并不是代码的可读性降低了,而只是代码不是按照你的习惯去写的。而大部分人恰好只习惯墨守成规,而不愿意接受新鲜事物。

上面的排序是按照从小到大排序,如果想要从大到小应该如何修改呢?

Compartor.sort 方法和for循环调换if参数的位置即可。

return student1.getScore().compareTo(student2.getScore()); 
修改为
return student2.getScore().compareTo(student1.getScore());
if (students.get(j).getScore().compareTo(students.get(j + 1).getScore()) > 0)
修改为
if (students.get(j).getScore().compareTo(students.get(j + 1).getScore()) < 0)

这改动看起来很简单,但如果这是一段 没有注释并且不是你本人写的代码 ,你能一眼知道是按降序还是升序排列吗?你还能说这是可读性强的代码吗?

如果是Stream操作。

return students.stream()
               .sorted(Comparator.comparing(Student::getScore))
               .collect(Collectors.toList());
修改为
return students.stream()
               .sorted(Comparator.comparing(Student::getScore).reversed())
               .collect(Collectors.toList());

这就是 声明式编程 ,你只管叫它做什么,而不像 命令式编程 叫它如何做。

reduce

reduce 是将传入一组值,根据计算模型输出一个值。例如求一组值的最大值、最小值、和等等。

不过使用和读懂 reduce 还是比较晦涩,如果是简单最大值、最小值、求和计算,Stream已经为我们提供了更简单的方法。如果是复杂的计算,可能为了代码的可读性和维护性还是建议用传统的方式表达。

我们来看几个使用 reduce 进行累加例子。

/**
 * Optional<T> reduce(BinaryOperator<T> accumulator);
 * 使用没有初始值对集合中的元素进行累加
 * @param numbers 集合元素
 * @return 累加结果
 */
private Integer calcTotal(List<Integer> numbers) {
    return numbers.stream()
            .reduce((total, number) -> total + number).get();
}

com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotal

reduce 有3个重载方法,

第一个例子调用的是 Optional<T> reduce(BinaryOperator<T> accumulator); 它只有 BinaryOperator 一个参数,这个接口是一个 函数接口 ,代表它可以接收一个Lambda表达式,它继承自 BiFunction 函数接口,在 BiFunction 接口中,只有一个方法:

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

这个方法有两个参数。也就是说,传入 reduce 的Lambda表达式需要“实现”这个方法。如果不理解这是什么意思,我们可以抛开Lambda表达式,从纯粹传统的接口角度去理解。

首先, Optional<T> reduce(BinaryOperator<T> accumulator); 方法接收 BinaryOperator 类型的对象,而 BinaryOperator 是一个接口并且继承自 BiFunction 接口,而在 BiFunction 中只有一个方法定义 R apply(T t, U u) ,也就是说我们需要实现 apply 方法。

其次,接口需要被实现,我们不妨传入一个匿名内部类,并且实现 apply 方法。

private Integer calcTotal(List<Integer> numbers) {
    return numbers.stream()
            .reduce(new BinaryOperator<Integer>() {
                @Override
                public Integer apply(Integer integer, Integer integer2) {
                    return integer + integer2;
                }
            }).get();
}

最后,我们在将匿名内部类改写为Lambda风格的代码,箭头左边是参数,右边是函数主体。

private Integer calcTotal(List<Integer> numbers) {
    return numbers.stream()
            .reduce((total, number) -> total + number).get();
}

至于为什么两个参数相加最后就是不断累加的结果,这就是 reduce 的内部实现了。

接着看第二个例子:

/**
 * T reduce(T identity, BinaryOperator<T> accumulator);
 * 赋初始值为1,对集合中的元素进行累加
 * @param numbers 集合元素
 * @return 累加结果
 */
private Integer calcTotal2(List<Integer> numbers) {
    return numbers.stream()
            .reduce(1, (total, number) -> total + number);
}

com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotal2

第二个例子调用的是 reduceT reduce(T identity, BinaryOperator<T> accumulator); 重载方法,相比于第一个例子,它多了一个参数“identity”,这是进行后续计算的初始值, BinaryOperator 和第一个例子一样。

第三个例子稍微复杂一点,前面两个例子集合中的元素都是基本类型,而现实情况是,集合中的参数往往是一个 对象 我们常常需要对对象中的某个字段做累加计算,比如计算学生对象的总成绩。

我们先来看for循环怎么做的:

/**
 * 通过for循环对集合中的学生成绩字段进行累加
 * @param students 学生集合
 * @return 分数总和
 */
private Double calcTotalScoreByFor(List<Student> students) {
    double total = 0;
    for (Student student : students) {
        total += student.getScore();
    }
    return total;
}

com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotalScoreByFor

要按前文的说法,“这样的代码充斥了样板代码,除了方法名,代码并不能直观的反应程序员的意图,程序员需要读完整个循环体才能理解”,但凡事不是绝对的,如果换做 reduce 操作:

/**
 * <U> U reduce(U identity,
 *                  BiFunction<U, ? super T, U> accumulator,
 *                  BinaryOperator<U> combiner);
 * 集合中的元素是"学生"对象,对学生的"score"分数字段进行累加
 * @param students 学生集合
 * @return 分数总和
 */
private Double calcTotalScoreByStreamReduce(List<Student> students) {
    return students.stream()
            .reduce(Double.valueOf(0),
                    (total, student) -> total + student.getScore(),
                    (aDouble, aDouble2) -> aDouble + aDouble2);
}

com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotalScoreByStreamReduce

这样的代码,已经不是样板代码的问题了,是大部分程序员即使读十遍可能也不知道要表达什么含义。但是为了学习Stream我们还是要硬着头皮去理解它。

Lambda表达式不好理解,过于简洁的语法,也代表更少的信息量,我们还是先将Lambda表达式还原成匿名内部类。

private Double calcTotalScoreByStreamReduce(List<Student> students) {
    return students.stream()
            .reduce(Double.valueOf(0), new BiFunction<Double, Student, Double>() {
                @Override
                public Double apply(Double total, Student student) {
                    return total + student.getScore();
                }
            }, new BinaryOperator<Double>() {
                @Override
                public Double apply(Double aDouble, Double aDouble2) {
                    return aDouble + aDouble2;
                }
            });
}

reduce 的第三个重载方法 <U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner); 一共有3个参数,与第一、二个重载方法不同的是,第一、第二个重载方法参数和返回类型都是泛型“T”,意思是入参和返回都是同一种数据类型。但在第三个例子中,入参是 Student 对象,返回却是 Double ,显然不能使用第一、二个重载方法。

第三个重载方法的第一个参数类型是泛型“U”,它的返回类型也是泛型“U”,所以第一个参数类型,代表了返回的数据类型,我们必须将第一个类型定义为 Double 例子中的入参是 Double.valueOf(0) 表示了累加的初始值为0,且返回值是 Double 类型 。第二个参数可以简单理解为“应该如何计算,累加还是累乘”的计算模型。最难理解的是第三个参数,因为前两个参数类型看起来已经能满足我们的需求,为什么还有第三个参数呢?

当我在第三个参数中加上一句输出时,发现它确实没有用。

private Double calcTotalScoreByStreamReduce(List<Student> students) {
    return students.stream()
            .reduce(Double.valueOf(0), new BiFunction<Double, Student, Double>() {
                @Override
                public Double apply(Double total, Student student) {
                    return total + student.getScore();
                }
            }, new BinaryOperator<Double>() {
                @Override
                public Double apply(Double aDouble, Double aDouble2) {
                    System.out.println("第三个参数的作用");
                    return aDouble + aDouble2;
                }
            });
}

控制台没有输出“第三个参数的作用”,改变它的返回值最终结果也没有任何改变,这的确表示它 真的没有用

第三个参数在这里的确没有用,这是因为我们目前所使用的Stream流是串行操作,它在 并行Stream流 中发挥的是 多路合并 的作用,在下一章会继续介绍并行Stream流,这里就不再多做介绍。

对于 reduce 操作,我的个人看法是, 不建议在现实中使用 。如果你有累加、求最大值、最小值的需求,Stream封装了更简单的方法。如果是特殊的计算,不如直接按for循环实现,如果一定要使用Stream对学生成绩求和也不妨换一个思路。

前面提到 map 方法可以将集合中的元素类型转换为另一种类型,那我们就能把学生的集合转换为分数的集合,再调用 reduce 的第一个重载方法计算总和:

/**
 * 先使用map将学生集合转换为分数的集合
 * 再使用reduce调用第一个重载方法计算总和
 * @param students 学生集合
 * @return 分数总和
 */
private Double calcTotalScoreByStreamMapReduce(List<Student> students) {
    return students.stream()
            .map(Student::getScore)
            .reduce((total, score) -> total + score).get();
}

com.coderbuff.chapter3_stream.chapter3_4.StreamReduceDemo#calcTotalScoreByStreamMapReduce

min

min 方法能返回集合中的最小值。它接收一个 Comparator 对象,Java8对 Comparator 接口提供了新的静态方法 comparing ,这个方法返回 Comparator 对象,以前我们需要手动实现 compare 比较,现在我们只需要调用 Comparator.comparing 静态方法即可。

/**
 * 通过Stream min计算集合中的最小值
 * @param numbers 集合
 * @return 最小值
 */
private Integer minByStreamMin(List<Integer> numbers) {
    return numbers.stream()
                  .min(Comparator.comparingInt(Integer::intValue)).get();
}

com.coderbuff.chapter3_stream.chapter3_4.StreamMinDemo#minByStreamMin

Comparator.comparingInt 用于比较int类型数据。因为集合中的元素是Integer类型,所以我们传入Integer类型的iniValue方法。如果集合中是对象类型,我们直接调用 Comparator.comparing 即可。

/**
 * 通过Stream min计算学生集合中的最低成绩
 * @param students 学生集合
 * @return 最低成绩
 */
private Double minScoreByStreamMin(List<Student> students) {
    Student minScoreStudent = students.stream()
            .min(Comparator.comparing(Student::getScore)).get();
    return minScoreStudent.getScore();
}

com.coderbuff.chapter3_stream.chapter3_4.StreamMinDemo#minScoreByStreamMin

max

min 的用法相同,含义相反取最大值。这里不再举例。

summaryStatistics

求和操作也是常用的操作,利用 reduce 会让代码晦涩难懂,特别是复杂的对象类型。

好在Streaam提供了求和计算的简便方法—— summaryStatistics ,这个方法并不是Stream对象提供,而是 IntStream ,可以把它当做处理基本类型的流,同理还有 LongStreamDoubleStream

summaryStatistics 方法也不光是只能求和,它还能求最小值、最大值。

例如我们求学生成绩的平均分、总分、最高分、最低分。

/**
 * 学生类型的集合常用计算
 * @param students 学生
 */
private void calc(List<Student> students) {
    DoubleSummaryStatistics summaryStatistics = students.stream()
            .mapToDouble(Student::getScore)
            .summaryStatistics();
    System.out.println("平均分:" + summaryStatistics.getAverage());
    System.out.println("总分:" + summaryStatistics.getSum());
    System.out.println("最高分:" + summaryStatistics.getMax());
    System.out.println("最低分:" + summaryStatistics.getMin());
}

com.coderbuff.chapter3_stream.chapter3_4.StreamSummaryStatisticsDemo#calc

返回的 summaryStatistics 包含了我们想要的所有结果,不需要我们单独计算。 mapToDouble 方法将Stream流按“成绩”字段组合成新的 DoubleStream 流, summaryStatistics 方法返回的 DoubleSummaryStatistics 对象为我们提供了常用的计算。

灵活运用好 summaryStatistics ,一定能给你带来更少的bug和更高效的编码。

3.3 Collectors

前面的大部分操作都是以 collect(Collectors.toList()) 结尾,看多了自然也大概猜得到它是将流转换为集合对象。最大的功劳当属Java8新提供的类—— Collectors 收集器。

Collectors 不但有 toList 方法能将流转换为集合,还包括 toMap 转换为Map数据类型,还能 分组

/**
 * 将学生类型的集合转换为只包含名字的集合
 * @param students 学生集合
 * @return 学生姓名集合
 */
private List<String> translateNames(List<Student> students) {

    return students.stream()
                   .map(Student::getStudentName)
                   .collect(Collectors.toList());
}

com.coderbuff.chapter3_stream.chapter3_4.StreamCollectorsDemo#translateNames

/**
 * 将学生类型的集合转换为Map类型,key=学号,value=学生
 * @param students 学生集合
 * @return 学生Map
 */
private Map<Long, Student> translateStudentMap(List<Student> students) {
    return students.stream()
            .collect(Collectors.toMap(Student::getStudentNumber, student -> student));
}

com.coderbuff.chapter3_stream.chapter3_4.StreamCollectorsDemo#translateStudentMap

/**
 * 按学生的学号对学生集合进行分组返回Map,key=学生学号,value=学生集合
 * @param students 学生集合
 * @return 按学号分组的Map
 */
private Map<Long, List<Student>> studentGroupByStudentNumber(List<Student> students) {
    return students.stream()
            .collect(Collectors.groupingBy(Student::getStudentNumber));
}

com.coderbuff.chapter3_stream.chapter3_4.StreamCollectorsDemo#studentGroupByStudentNumber

关注公众号( CoderBuff )回复“ stream ”抢先获取PDF完整版。

近期教程:

《ElasticSearch6.x实战教程》

《Redis5.x入门教程》

《Java8 编码实战》

这是一个能给程序员加buff的公众号 (CoderBuff)

EZvY7br.png!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK