Java 8 是目前最常用的 JDK 版本,在增强代码可读性、简化代码方面,相比 Java 7 增加了很多功能,比如 Lambda、Stream 流操作、并行流(ParallelStream)、Optional 可空类型、新日期时间类型等。
使用 Java 8 简化代码
Lambda 表达式可以帮我们用简短的代码实现方法的定义,给了我们复用代码的更多可能性。利用这个特性,我们可以把集合的投影、转换、过滤等操作抽象成通用的接口,然后通过 Lambda 表达式传入其具体实现,这也就是 Stream 操作。
我们看一个具体的例子。这里有一段 20 行左右的代码,实现了如下的逻辑:
- 把整数列表转换为 Point2D 列表;
- 遍历 Point2D 列表过滤出 Y 轴 >1 的对象;
- 计算 Point2D 点到原点的距离;
- 累加所有计算出的距离,并计算距离的平均值。
private static double calc(List<Integer> ints) {
//临时中间集合
List<Point2D> point2DList = new ArrayList<>();
for (Integer i : ints) {
point2DList.add(new Point2D.Double((double) i % 3, (double) i / 3));
}
//临时变量,纯粹是为了获得最后结果需要的中间变量
double total = 0;
int count = 0;
for (Point2D point2D : point2DList) {
//过滤
if (point2D.getY() > 1) {
//算距离
double distance = point2D.distance(0, 0);
total += distance;
count++;
}
}
//注意count可能为0的可能
return count >0 ? total / count : 0;
}
现在,我们可以使用 Stream 配合 Lambda 表达式来简化这段代码。简化后一行代码就可以实现这样的逻辑,更重要的是代码可读性更强了,通过方法名就可以知晓大概是在做什么事情。比如:
map 方法传入的是一个 Function,可以实现对象转换;
filter 方法传入一个 Predicate,实现对象的布尔判断,只保留返回 true 的数据;
mapToDouble 用于把对象转换为 double;
通过 average 方法返回一个 OptionalDouble,代表可能包含值也可能不包含值的可空 double。
下面的第三行代码,就实现了上面方法的所有工作:
List<Integer> ints = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
double average = calc(ints);
double streamResult = ints.stream()
.map(i -> new Point2D.Double((double) i % 3, (double) i / 3))
.filter(point -> point.getY() > 1)
.mapToDouble(point -> point.distance(0, 0))
.average()
.orElse(0);
//如何用一行代码来实现,比较一下可读性
assertThat(average, is(streamResult));
有关 Optional 可空类型
其实,类似 OptionalDouble、OptionalInt、OptionalLong 等,是服务于基本类型的可空对象。此外,Java8 还定义了用于引用类型的 Optional 类。使用 Optional,不仅可以避免使用 Stream 进行级联调用的空指针问题;更重要的是,它提供了一些实用的方法帮我们避免判空逻辑。
如下是一些例子,演示了如何使用 Optional 来避免空指针,以及如何使用它的 fluent API 简化冗长的 if-else 判空逻辑:
@Test(expected = IllegalArgumentException.class)
public void optional() {
//通过get方法获取Optional中的实际值
assertThat(Optional.of(1).get(), is(1));
//通过ofNullable来初始化一个null,通过orElse方法实现Optional中无数据的时候返回一个默认值
assertThat(Optional.ofNullable(null).orElse("A"), is("A"));
//OptionalDouble是基本类型double的Optional对象,isPresent判断有无数据
assertFalse(OptionalDouble.empty().isPresent());
//通过map方法可以对Optional对象进行级联转换,不会出现空指针,转换后还是一个Optional
assertThat(Optional.of(1).map(Math::incrementExact).get(), is(2));
//通过filter实现Optional中数据的过滤,得到一个Optional,然后级联使用orElse提供默认值
assertThat(Optional.of(1).filter(integer -> integer % 2 == 0).orElse(null), is(nullValue()));
//通过orElseThrow实现无数据时抛出异常
Optional.empty().orElseThrow(IllegalArgumentException::new);
}
Optional类常用的方法
Java 8 类对于函数式 API 的增强
除了 Stream 之外,Java 8 中有很多类也都实现了函数式的功能。
比如,要通过 HashMap 实现一个缓存的操作,在 Java 8 之前我们可能会写出这样的 getProductAndCache 方法:先判断缓存中是否有值;如果没有值,就从数据库搜索取值;最后,把数据加入缓存。
private Map<Long, Product> cache = new ConcurrentHashMap<>();
private Product getProductAndCache(Long id) {
Product product = null;
//Key存在,返回Value
if (cache.containsKey(id)) {
product = cache.get(id);
} else {
//不存在,则获取Value
//需要遍历数据源查询获得Product
for (Product p : Product.getData()) {
if (p.getId().equals(id)) {
product = p;
break;
}
}
//加入ConcurrentHashMap
if (product != null)
cache.put(id, product);
}
return product;
}
而在 Java 8 中,我们利用 ConcurrentHashMap 的 computeIfAbsent 方法,用一行代码就可以实现这样的繁琐操作:
private Product getProductAndCacheCool(Long id) {
return cache.computeIfAbsent(id, i -> //当Key不存在的时候提供一个Function来代表根据Key获取Value的过程
Product.getData().stream()
.filter(p -> p.getId().equals(i)) //过滤
.findFirst() //找第一个,得到Optional<Product>
.orElse(null)); //如果找不到Product,则使用null
}
computeIfAbsent 方法在逻辑上相当于:
if (map.get(key) == null) {
V newValue = mappingFunction.apply(key);
if (newValue != null)
map.put(key, newValue);
}
使用 Stream 简化集合操作
//通过stream方法把List或数组转换为流
@Test
public void Stream(){
Arrays.asList("a1", "a2", "a3").stream().forEach(System.out::println);
Arrays.stream(new int[]{1, 2, 3}).forEach(System.out::println);
}
//通过Stream.of方法直接传入多个元素构成一个流
@Test
public void of() {
String[] arr = {"a", "b", "c"};
Stream.of(arr).forEach(System.out::println);
Stream.of("a", "b", "c").forEach(System.out::println);
Stream.of(1, 2, "a").map(item -> ((Serializable) item).getClass().getName()).forEach(System.out::println);
}
//通过Stream.iterate方法使用迭代的方式构造一个无限流,然后使用limit限制流元素个数
@Test
public void iterate()
{
Stream.iterate(2, item -> item * 2).limit(10).forEach(System.out::print);
System.out.println();
Stream.iterate(BigInteger.ZERO, n -> n.add(BigInteger.TEN)).limit(10).forEach(System.out::print);
}
//通过Stream.generate方法从外部传入一个提供元素的Supplier来构造无限流,然后使用limit限制流元素个数
@Test
public void generate()
{
Stream.generate(() -> "test").limit(3).forEach(System.out::println);
Stream.generate(Math::random).limit(10).forEach(System.out::println);
}
//通过IntStream或DoubleStream构造基本类型的流
@Test
public void primitive()
{
//演示IntStream和DoubleStream
IntStream.range(1, 3).forEach(System.out::println);
IntStream.range(0, 3).mapToObj(i -> "x").forEach(System.out::println);
IntStream.rangeClosed(1, 3).forEach(System.out::println);
DoubleStream.of(1.1, 2.2, 3.3).forEach(System.out::println);
//各种转换,后面注释代表了输出结果
System.out.println(IntStream.of(1, 2).toArray().getClass()); //class [I
System.out.println(Stream.of(1, 2).mapToInt(Integer::intValue).toArray().getClass()); //class [I
System.out.println(IntStream.of(1, 2).boxed().toArray().getClass()); //class [Ljava.lang.Object;
System.out.println(IntStream.of(1, 2).asDoubleStream().toArray().getClass()); //class [D
System.out.println(IntStream.of(1, 2).asLongStream().toArray().getClass()); //class [J
//注意基本类型流和装箱后的流的区别
Arrays.asList("a", "b", "c").stream() // Stream<String>
.mapToInt(String::length) // IntStream
.asLongStream() // LongStream
.mapToDouble(x -> x / 10.0) // DoubleStream
.boxed() // Stream<Double>
.mapToLong(x -> 1L) // LongStream
.mapToObj(x -> "") // Stream<String>
.collect(Collectors.toList());
}
Stream 流式操作,用于对集合进行投影、转换、过滤、排序等,更进一步地,这些操作能链式串联在一起使用,类似于 SQL 语句,可以大大简化代码。可以说,Stream 操作是 Java 8 中最重要的内容,也是这个课程大部分代码都会用到的操作。
我们先定义一个订单类、一个订单商品类和一个顾客类,用作后续 Demo 代码的数据结构:
//订单类
@Data
public class Order {
private Long id;
private Long customerId;//顾客ID
private String customerName;//顾客姓名
private List<OrderItem> orderItemList;//订单商品明细
private Double totalPrice;//总价格
private LocalDateTime placedAt;//下单时间
}
//订单商品类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderItem {
private Long productId;//商品ID
private String productName;//商品名称
private Double productPrice;//商品价格
private Integer productQuantity;//商品数量
}
//顾客类
@Data
@AllArgsConstructor
public class Customer {
private Long id;
private String name;//顾客姓名
}
filter
filter 方法可以实现过滤操作,类似 SQL 中的 where。我们可以使用一行代码,通过 filter 方法实现查询所有订单中最近半年金额大于 40 的订单,通过连续叠加 filter 方法进行多次条件过滤:
//最近半年的金额大于40的订单
orders.stream()
.filter(Objects::nonNull) //过滤null值
.filter(order -> order.getPlacedAt().isAfter(LocalDateTime.now().minusMonths(6))) //最近半年的订单
.filter(order -> order.getTotalPrice() > 40) //金额大于40的订单
.forEach(System.out::println);
map
map 操作可以做转换(或者说投影),类似 SQL 中的 select。为了对比,我用两种方式统计订单中所有商品的数量,前一种是通过两次遍历实现,后一种是通过两次 mapToLong+sum 方法实现:
//计算所有订单商品数量
//通过两次遍历实现
LongAdder longAdder = new LongAdder();
orders.stream().forEach(order ->
order.getOrderItemList().forEach(orderItem -> longAdder.add(orderItem.getProductQuantity())));
//使用两次mapToLong+sum方法实现
assertThat(longAdder.longValue(), is(orders.stream().mapToLong(order ->
order.getOrderItemList().stream()
.mapToLong(OrderItem::getProductQuantity).sum()).sum()));
//order.getOrderItemList().stream().mapToLong(OrderItem::getProductQuantity).sum()这个表示订单明细的商品数量
//order.getOrderItemList().stream().mapToLong(OrderItem::getProductQuantity).sum()).sum(); 然后再把所有订单统计的订单明细的商品数量加起来 就得出了订单中所有商品的数量
显然,后一种方式无需中间变量 longAdder,更直观。
这里再补充一下,使用 for 循环生成数据,是我们平时常用的操作,也是这个课程会大量用到的。现在,我们可以用一行代码使用 IntStream 配合 mapToObj 替代 for 循环来生成数据,比如生成 10 个 Product 元素构成 List:
mapToObj
mapToObj 是 Java 8 中 IntStream 类的一个方法,它属于流(Stream)API 的一部分。这个方法用于将整数流(IntStream)转换成对象流(Stream),其中每个整数都被映射(转换)为一个对象。
Stream<T> mapToObj(ToIntFunction<? super T> mapper)
- T 是流中的元素类型。
- ToIntFunction 是一个函数接口,它接受一个参数并返回一个整数。
- mapper 是一个函数,它将流中的每个元素映射为一个新的对象。
在流操作中,mapToObj 通常用于将原始类型的流转换为对象类型的流,这样就可以对流中的每个元素应用更复杂的转换逻辑。
假设我们有一个 IntStream,我们想要将每个整数转换为一个字符串表示:
IntStream intStream = IntStream.of(1, 2, 3, 4, 5);
Stream<String> stringStream = intStream.mapToObj(i -> "Number: " + i);
在这个例子中,mapToObj 接受一个 lambda 表达式 i -> "Number: " + i,它将每个整数 i 转换为一个字符串 "Number: " + i。这样,我们就将一个整数流转换为了一个字符串流。
//把IntStream通过转换Stream<Project>
System.out.println(IntStream.rangeClosed(1,10)
.mapToObj(i->new Product((long)i, "product"+i, i*100.0))
.collect(toList()));
flatMap
接下来,我们看看 flatMap 展开或者叫扁平化操作,相当于 map+flat,通过 map 把每一个元素替换为一个流,然后展开这个流。
比如,我们要统计所有订单的总价格,可以有两种方式:
- 直接通过原始商品列表的商品个数 * 商品单价统计的话,可以先把订单通过 flatMap 展开成商品清单,也就是把 Order 替换为 Stream,然后对每一个 OrderItem 用 mapToDouble 转换获得商品总价,最后进行一次 sum 求和;
- 利用 flatMapToDouble 方法把列表中每一项展开替换为一个 DoubleStream,也就是直接把每一个订单转换为每一个商品的总价,然后求和。
//直接展开订单商品进行价格统计
System.out.println(orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.mapToDouble(item -> item.getProductQuantity() * item.getProductPrice()).sum());
//另一种方式flatMap+mapToDouble=flatMapToDouble
System.out.println(orders.stream()
.flatMapToDouble(order ->order.getOrderItemList().stream()
.mapToDouble(item -> item.getProductQuantity() * item.getProductPrice())).sum());
这两种方式可以得到相同的结果,并无本质区别。
sorted
sorted 操作可以用于行内排序的场景,类似 SQL 中的 order by。比如,要实现大于 50 元订单的按价格倒序取前 5,可以通过 Order::getTotalPrice 方法引用直接指定需要排序的依据字段,通过 reversed() 实现倒序:
//大于50的订单,按照订单价格倒序前5
orders.stream().filter(order -> order.getTotalPrice() > 50)
.sorted(comparing(Order::getTotalPrice).reversed())
.limit(5)
.forEach(System.out::println);
distinct
distinct 操作的作用是去重,类似 SQL 中的 distinct。比如下面的代码实现:
- 查询去重后的下单用户。使用 map 从订单提取出购买用户,然后使用 distinct 去重。
- 查询购买过的商品名。使用 flatMap+map 提取出订单中所有的商品名,然后使用 distinct 去重。
//去重的下单用户
System.out.println(orders.stream().map(order -> order.getCustomerName()).distinct().collect(joining(",")));
//所有购买过的商品
System.out.println(orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.map(OrderItem::getProductName)
.distinct().collect(joining(",")));
skip & limit
skip 和 limit 操作用于分页,类似 MySQL 中的 limit。其中,skip 实现跳过一定的项,limit 用于限制项总数。比如下面的两段代码:
- 按照下单时间排序,查询前 2 个订单的顾客姓名和下单时间;
- 按照下单时间排序,查询第 3 和第 4 个订单的顾客姓名和下单时间。
//按照下单时间排序,查询前2个订单的顾客姓名和下单时间
orders.stream()
.sorted(comparing(Order::getPlacedAt))
.map(order -> order.getCustomerName() + "@" + order.getPlacedAt())
.limit(2).forEach(System.out::println);
//按照下单时间排序,查询第3和第4个订单的顾客姓名和下单时间
orders.stream()
.sorted(comparing(Order::getPlacedAt))
.map(order -> order.getCustomerName() + "@" + order.getPlacedAt())
.skip(2).limit(2).forEach(System.out::println);
collect
collect 是收集操作,对流进行终结(终止)操作,把流导出为我们需要的数据结构。“终结”是指,导出后,无法再串联使用其他中间操作,比如 filter、map、flatmap、sorted、distinct、limit、skip。
在 Stream 操作中,collect 是最复杂的终结操作,比较简单的终结操作还有 forEach、toArray、min、max、count、anyMatch 等,我就不再展开了,你可以查询JDK 文档,搜索 terminal operation 或 intermediate operation。
接下来,我通过 6 个案例,来演示下几种比较常用的 collect 操作:
- 第一个案例,实现了字符串拼接操作,生成一定位数的随机字符串。
- 第二个案例,通过 Collectors.toSet 静态方法收集为 Set 去重,得到去重后的下单用户,再通过Collectors.joining 静态方法实现字符串拼接。
- 第三个案例,通过 Collectors.toCollection 静态方法获得指定类型的集合,比如把 List转换为 LinkedList。
- 第四个案例,通过 Collectors.toMap 静态方法将对象快速转换为 Map,Key 是订单 ID、Value 是下单用户名。
- 第五个案例,通过 Collectors.toMap 静态方法将对象转换为 Map。Key 是下单用户名,Value 是下单时间,一个用户可能多次下单,所以直接在这里进行了合并,只获取最近一次的下单时间。
- 第六个案例,使用 Collectors.summingInt 方法对商品数量求和,再使用 Collectors.averagingInt 方法对结果求平均值,以统计所有订单平均购买的商品数量。
//生成一定位数的随机字符串
System.out.println(random.ints(48, 122)
.filter(i -> (i < 57 || i > 65) && (i < 90 || i > 97))
.mapToObj(i -> (char) i)
.limit(20)
.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append)
.toString());
//所有下单的用户,使用toSet去重后实现字符串拼接
System.out.println(orders.stream()
.map(order -> order.getCustomerName()).collect(toSet())
.stream().collect(joining(",", "[", "]")));
//用toCollection收集器指定集合类型
System.out.println(orders.stream().limit(2).collect(toCollection(LinkedList::new)).getClass());
//使用toMap获取订单ID+下单用户名的Map
orders.stream()
.collect(toMap(Order::getId, Order::getCustomerName))
.entrySet().forEach(System.out::println);
//使用toMap获取下单用户名+最近一次下单时间的Map
orders.stream()
.collect(toMap(Order::getCustomerName, Order::getPlacedAt, (x, y) -> x.isAfter(y) ? x : y))
.entrySet().forEach(System.out::println);
//订单平均购买的商品数量
System.out.println(orders.stream().collect(averagingInt(order ->
order.getOrderItemList().stream()
.collect(summingInt(OrderItem::getProductQuantity)))));
可以看到,这 6 个操作使用 Stream 方式一行代码就可以实现,但使用非 Stream 方式实现的话,都需要几行甚至十几行代码。
有关 Collectors 类的一些常用静态方法,我总结到了一张图中,
groupBy
groupBy 是分组统计操作,类似 SQL 中的 group by 子句。它和后面介绍的 partitioningBy 都是特殊的收集器,同样也是终结操作。分组操作比较复杂,为帮你理解得更透彻,我准备了 8 个案例:
- 第一个案例,按照用户名分组,使用 Collectors.counting 方法统计每个人的下单数量,再按照下单数量倒序输出。
- 第二个案例,按照用户名分组,使用 Collectors.summingDouble 方法统计订单总金额,再按总金额倒序输出。
- 第三个案例,按照用户名分组,使用两次 Collectors.summingInt 方法统计商品采购数量,再按总数量倒序输出。
- 第四个案例,统计被采购最多的商品。先通过 flatMap 把订单转换为商品,然后把商品名作为 Key、Collectors.summingInt 作为 Value 分组统计采购数量,再按 Value 倒序获取第一个 Entry,最后查询 Key 就得到了售出最多的商品。
- 第五个案例,同样统计采购最多的商品。相比第四个案例排序 Map 的方式,这次直接使用 Collectors.maxBy 收集器获得最大的 Entry。
- 第六个案例,按照用户名分组,统计用户下的金额最高的订单。Key 是用户名,Value 是 Order,直接通过 Collectors.maxBy 方法拿到金额最高的订单,然后通过 collectingAndThen 实现 Optional.get 的内容提取,最后遍历 Key/Value 即可。
- 第七个案例,根据下单年月分组统计订单 ID 列表。Key 是格式化成年月后的下单时间,Value 直接通过 Collectors.mapping 方法进行了转换,把订单列表转换为订单 ID 构成的 List。
- 第八个案例,根据下单年月 + 用户名两次分组统计订单 ID 列表,相比上一个案例多了一次分组操作,第二次分组是按照用户名进行分组。
//按照用户名分组,统计下单数量
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, counting()))
.entrySet().stream().sorted(Map.Entry.<String, Long>comparingByValue().reversed()).collect(toList()));
//按照用户名分组,统计订单总金额
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, summingDouble(Order::getTotalPrice)))
.entrySet().stream().sorted(Map.Entry.<String, Double>comparingByValue().reversed()).collect(toList()));
//按照用户名分组,统计商品采购数量
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName,
summingInt(order -> order.getOrderItemList().stream()
.collect(summingInt(OrderItem::getProductQuantity)))))
.entrySet().stream().sorted(Map.Entry.<String, Integer>comparingByValue().reversed()).collect(toList()));
//统计最受欢迎的商品,倒序后取第一个
orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.map(Map.Entry::getKey)
.findFirst()
.ifPresent(System.out::println);
//统计最受欢迎的商品的另一种方式,直接利用maxBy
orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
.entrySet().stream()
.collect(maxBy(Map.Entry.comparingByValue()))
.map(Map.Entry::getKey)
.ifPresent(System.out::println);
//按照用户名分组,选用户下的总金额最大的订单
orders.stream().collect(groupingBy(Order::getCustomerName, collectingAndThen(maxBy(comparingDouble(Order::getTotalPrice)), Optional::get)))
.forEach((k, v) -> System.out.println(k + "#" + v.getTotalPrice() + "@" + v.getPlacedAt()));
//根据下单年月分组,统计订单ID列表
System.out.println(orders.stream().collect
(groupingBy(order -> order.getPlacedAt().format(DateTimeFormatter.ofPattern("yyyyMM")),
mapping(order -> order.getId(), toList()))));
//根据下单年月+用户名两次分组,统计订单ID列表
System.out.println(orders.stream().collect
(groupingBy(order -> order.getPlacedAt().format(DateTimeFormatter.ofPattern("yyyyMM")),
groupingBy(order -> order.getCustomerName(),
mapping(order -> order.getId(), toList())))));
partitionBy
partitioningBy 用于分区,分区是特殊的分组,只有 true 和 false 两组。比如,我们把用户按照是否下单进行分区,给 partitioningBy 方法传入一个 Predicate 作为数据分区的区分,输出是 Map<Boolean, List>:
public static <T>
Collector<T, ?, Map<Boolean, List<T>>> partitioningBy(Predicate<? super T> predicate) {
return partitioningBy(predicate, toList());
}
测试一下,partitioningBy 配合 anyMatch,可以把用户分为下过订单和没下过订单两组:
//根据是否有下单记录进行分区
System.out.println(Customer.getData().stream().collect(
partitioningBy(customer -> orders.stream().mapToLong(Order::getCustomerId)
.anyMatch(id -> id == customer.getId()))));
{
true=[Customer{id=1, name='Alice'}, Customer{id=2, name='Bob'}, Customer{id=3, name='Charlie'}],
false=[]
}
Idea在debug模式下有个Trace Current Stream Chain功能,可以很好的查看数据变化。