引言
距离2014
年发布的JDK1.8
(俗称Java8
),至今为止已经过去了十个年头,而JDK22
在今年也已经正式发布,不过令人可惜的是,尽管JDK
推出了这么多新版本,大家却成为了编程界里屹然不动的钉子户,甚至如今有句耳熟能详的口头语:新的版本随你发,我用我的Java8!
新版本没人用,这不算什么遗憾,毕竟技术为业务提供服务,而Java8
已然够用,再加上其生态最为繁华,特性也最为稳定,大多数企业不愿意升版也可谓是情有可原。不过值得一提的是,很多小伙伴虽然在用JDK1.8
,但对Java8
的特性至今还未完全吃透,为此工作中很多代码依旧在用之前的语法撰写,写出来的代码“又大又长”。
大!还长!这对男人来说是个好事,但放在写出来的代码里,显得就并没有那么美妙了,身为JDK1.8
钉子户的我们,虽说不去升级到新版本,但至少还是要用已有的特性,将日常开发中的代码写的更优雅才行~,正因如此,本篇来好好盘盘JDK1.8
特性在日常开发中的最佳实践!
一、Java8接口的最佳实践
Java8
的重头戏就是Lambda
表达式和Stream
流,重头戏放到后面讲,我们先来看看Java8
中接口的特性,在日常开发中也挺有用,不过在此之前,为了更好的理解新的接口特性,就先简单看看之前存在的弊端。
我们日常的开发习惯总是先定义interface
接口,再撰写对应的实现类,可是这种方式有种很大的问题,就是代码不好维护,因为接口中定义的方法,实现类需要全都将其实现,比如会员等级权益的业务中,不同等级的会员具备不同权限。要开发这个功能,通常会先定义一个接口:
public interface IMemberEquityService {
/*
* 权益一
* */
void equity1();
/*
* 权益二
* */
void equity2();
}
正因为不同等级的会员,能享受到权益有所不同,如果只弄一个实现类,就需要在一个方法里,通过大量if
来区分实现不同的权限,这无疑会让代码变得臃肿不堪,更好的做法是借助Java
的多态特性,将不同等级的会员权益,创建不同的实现类来编写具体逻辑,如下:
/**
* 普通会员权益实现类
*/
public class MemberEquityServiceImpl implements IMemberEquityService {
}
/**
* 高级会员权益实现类
*/
public class VIPMemberEquityServiceImpl implements IMemberEquityService {
}
/**
* 超级会员权益实现类
*/
public class SVIPMemberEquityServiceImpl implements IMemberEquityService {
}
通过这种方式,能让代码更便于维护,看起来也更加优雅。不过在享受好处的同时,也存在一个致命缺陷,即接口内新增定义了某个权益时,比如新增一个equity3()
方法,根据Java
的接口特性,所有实现类必须实现新增的方法,但是这个权益不一定所有等级的会员都具备,咋整?
为了接口的可拓展性,在以往的JDK
版本中,我们不得不在中间加入一个abstract
抽象类:
通过这种设计,当顶层接口新增了某个方法时,作为底层的业务实现类,不一定需要强制实现此方法,只需要在抽象类中实现即可。如果需要实现该方法的业务实现类,重写父类(抽象类)实现的方法即可。
其实这也是Java8
之前,所有框架,包括JDK
源码在内都在使用的一种方式,如果不这么做,比如JDK
官方想对Collection
接口新增一个方法,那就需要修改它的所有实现类,这听起来就非常恐怖。正因如此,中间包一层抽象类,这种方式可以最大程度上保证接口灵活性,这同样是为什么大家在看各种源码时,会发现为什么有那么多开头以Abstract……
命名类的原因。
Java
接口这种特性在之前的版本中,令人饱受折磨,而到了Java8
以后,就算你设计时没包一层抽象类,也大可不必担心,因为有了两个新的接口特性:接口默认方法与静态方法。
1.1、接口默认方法
在接口中,使用default
关键字修饰的方法称之为接口默认方法。默认方法一定要有默认实现,也就是直接在接口里实现方法体,当一个类实现该接口时,既可以选择直接继承它,也选择重新实现将其覆盖,如下:
/*
* 权益四
* */
default void equity4() {
System.out.println("会员权益4的默认实现");
}
默认方法允许在接口中添加新的方法,而无需修改实现该接口的类,这对扩展现有接口或添加新功能特别有用,因为接口中提供了默认的实现,所以不必改动所有子类实现,能最大程度上保持与已有代码的兼容性。
当然,默认方法除开可以提升接口拓展的灵活性外,在日常开发中还有另外的玩法,比如下面这种写法:
@Repository
public interface XxxMapper {
/*
* 查询分页数据
* */
PageVO<?> selectPage(……);
/*
* 查询分页数据
* */
default xxx selectXxx() {
// 基于selectPage()方法继续补全逻辑(不用写XML)
PageVO<?> page = this.selectPage(……);
// 省略其他代码……
}
}
比如使用MyBatis
开发时,Dao
层通常是接口结合XML
的形式,如果你有个需求,可以基于前面已经写好的方法继续实现,这时就能直接通过默认方法来继续补齐逻辑~
1.2、接口静态方法
在接口里用static
修饰的方法称为接口静态方法,它的作用和默认方法的逻辑类似,如下:
static void equity5() {
System.out.println("会员权益5的默认实现");
}
不过和默认方法的区别在于:静态方法属于接口本身,而默认方法属于具体的实例,静态方法的调用方式如下:
IMemberEquityService.equity5();
由于静态方法与接口实现类无关,因此可以在不创建接口实例的前提下被调用。接口静态方法一般用来实现一些常用的、与实例无关的功能,比如与接口相关的工具方法或辅助方法等。
接口有了默认方法和静态方法,可以让你的代码变得更优雅,比如某个接口方法在所有子类中的实现都一样,这就可以直接将这种可共用的逻辑,抽象到接口中来定义成默认方法,从而减少子类中的冗余实现。
二、优雅使用Java8的前置知识
简单了解Java8
新增的接口特性后,下面来看看用好Java8
的前置知识,主要是三大块:Lambda表达式、函数式接口、函数引用。
2.1、Lambda表达式
在JDK1.8之前,一个方法能接收的入参类型,都只能是“值类型”,要么是基本数据类型,要么就是一个引用对象,如果想要将另一个方法作为入参怎么办?在之前的版本中只能通过匿名内部类来拐着弯实现,不过匿名内部类依赖于接口,所以先定义一个接口:
public interface ZhuZiCallback {
/*
* 回调方法
* */
void callback(ZhuZi zhuZi);
}
下面来看如何将这个回调方法作为入参传递给一个方法:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ZhuZi {
private Long id;
private String name;
}
public class Test {
/*
* 创建完对象后,触发指定的回调逻辑
* */
public static void create(long id, String name, ZhuZiCallback zhuZiCallback) {
ZhuZi zhuZi = new ZhuZi(id, name);
zhuZiCallback.callback(zhuZi);
}
public static void main(String[] args) {
Test.create(88888888, "竹子爱熊猫", new ZhuZiCallback() {
@Override
public void callback(ZhuZi zhuZi) {
System.out.println("我是创建完竹子对象后的回调,创建的对象为:" + zhuZi);
}
});
}
}
/*
* 执行结果:
* 我是创建完竹子对象后的回调,创建的对象为:ZhuZi(id=88888888, name=竹子爱熊猫)
* */
来看上面这个回调事件的例子,其中的ZhuZiCallback
是一种动作,我们真正关心的只有callback()
方法里的逻辑而已,可是Java
中不支持直接传递函数,所以为了将这个回调方法传递给要执行的create()
方法,必须得new
一个匿名内部类,写起来费劲不说,还不美观!
到了JDK1.8
,就可以直接用Lambda
表达式来代替,上述代码可以优化成:
public static void main(String[] args) {
Test.create(88888888, "竹子爱熊猫", zhuZi -> {
System.out.println("我是创建完竹子对象后的回调,创建的对象为:" + zhuZi);
});
}
这样写起来更简单,看起来更优雅!不过值得注意的是,Test.create()
方法的第三个入参,仍然是ZhuZiCallback
这个接口类型,至于为什么可以用Lambda
表达式代替,这一点放在后面再聊,下面重点说说Lambda
表达式。
2.1.1、Lambda表达式的语法
Lambda
表达式,是JDK1.8
从函数式编程语言中“借鉴”而来的特性,Lambda
允许将一个函数作为方法的入参。而Lambda
表达式的基础语法由三部分组成:
()
包裹的参数列表、–>
符号、{}
包裹的函数体。
通过前面的例子来套入分析下:
Test.create(88888888, "竹子爱熊猫", (ZhuZi zhuZi) -> {
System.out.println("我是创建完竹子对象后的回调,创建的对象为:" + zhuZi);
});
ZhuZi
代表是入参的类型,zhuZi
代表是方法的参数名,这个名字你想叫啥就叫啥。->
是Lambda
表达式的固定语法,这个是固定的语法糖,不能改变成→、_>、=>
或其他箭头。最后就是{}
这对花括号包裹的代码块,实际上就是具体要执行的函数体,就跟方法体一样。
掌握上述基本语法后,下面再来看几类变种写法,先来看无参数的lambda
写法:
/**
* 无参数回调
*/
public interface NoArgsCallback {
void callback();
}
public class Test {
public static void noArgs(NoArgsCallback noArgsCallback) {
noArgsCallback.callback();
}
public static void main(String[] args) {
Test.noArgs(() -> {
System.out.println("我是无参数的lambda语法……");
});
}
}
/*
* 执行结果:
* 我是无参数的lambda语法……
* */
注意看上面无参数的lambda
写法,和之前的唯一区别在于:如果对应的函数没有入参,那么参数列表部分就用()
小括号代替即可。再来看看多参数:
/**
* 无参数回调
*/
public interface MultipleArgsCallback {
void callback(int arg1, String arg2);
}
public class Test {
public static void multipleArgs(int arg1, String arg2,
MultipleArgsCallback multipleArgsCallback) {
multipleArgsCallback.callback(arg1, arg2);
}
public static void main(String[] args) {
Test.multipleArgs(1, "竹子爱熊猫", (int a, String b) -> {
System.out.println("我是" + b + ",想要" + a + "个点赞!");
});
}
}
/*
* 执行结果:
* 我是竹子爱熊猫,想要1个点赞!
* */
与无参数的写法对比,如果函数存在多个入参,只需要用()
将参数列表包起来、多个参数用,
逗号隔开就行,函数存在多少个入参,这里就需要定义多少个参数,顺序与函数定义的入参列表一一对应。好了,再回去看到只有一个入参的lambda
案例:
Test.create(88888888, "竹子爱熊猫", (ZhuZi zhuZi) -> {
System.out.println("我是创建完竹子对象后的回调,创建的对象为:" + zhuZi);
});
// 可以优化为:
Test.create(88888888, "竹子爱熊猫", zhuZi ->
System.out.println("我是创建完竹子对象后的回调,创建的对象为:" + zhuZi)
);
区别在哪儿呢?优化之后的写法,参数列表没有()
包裹了,函数体也没用{}
包裹了,sout
这行代码最后的;
分号也去掉了,为啥可以这样写?因为这个案例中,参数只有一个,所以可以省略()
;函数体也只有一行代码,所以{}
也可以省略不写~
最关键的是参数竟然可以不用声明类型了!这是什么原因呢?这跟
lambda
的原理有关系。
2.1.2、Lambda表达式原理浅谈
大家可以发现,尽管Java
身为强类型限制的语言,可在上面的lambda
表达式例子中,参数列表可以不强制声明参数类型,Why
?
首先要明白,lambda
表达式在Java中的实现,本质上跟匿名内部类很接近,只不过是将匿名内部类的写法简化了而已。同时,注意观察上面无参、单参、多参这三个例子,大家就会发现,每个例子中都需要单独定义一个接口,并且每个接口内只有一个方法,这种接口也被称之为函数式接口(后面细说)。 正因如此,我们写的每一个lambda
表达式,实际上就是在实现这个函数式接口的抽象方法。
lambda
表达式能在Java环境中正常运行,这得益于Java8的类型推导机制,以之前的例子作为说明:
// 接口定义
public interface ZhuZiCallback {
void callback(ZhuZi zhuZi);
}
// 业务方法
public static void create(long id, String name, ZhuZiCallback zhuZiCallback) {
ZhuZi zhuZi = new ZhuZi(id, name);
zhuZiCallback.callback(zhuZi);
}
// lambda表达式
Test.create(88888888, "竹子爱熊猫", zhuZi ->
System.out.println("我是创建完竹子对象后的回调,创建的对象为:" + zhuZi)
);
在执行lambda
表达式时,Java编译器会基于上下文(即表达式所在的位置)推断其类型,怎么推断出来的?其实很简单,上述create()
方法的第三个入参为ZhuZiCallback
类型,那么执行对应方法时,就自然能推断出对应位置的lambda
是ZhuZiCallback
接口的实现!
其次,Lambda
表达式实现了接口里的有且仅有的一个抽象方法,那么编译器自然也能知道表达式就是callback()
方法的实现。最后再来看参数,其实逻辑也差不多,毕竟已经确定了Lambda
表达式对应的接口方法,那么参数列表肯定就对应着接口方法的入参,这时再显式声明类型的意义也不大了,因为编译器可以直接推导出来。
在此之前Java一直是强类型语言,即编码时必须要为每个变量声明类型,所以Java8中的类型推导机制并不算强大,大家从上面也能感受出来,想用Lambda的前提是定义一个接口、接口里还只能有一个方法,只有这样编译器才能完成类型推导工作。不过到了后续高版本的JDK中,类型推导机制得到了很大完善,如果大家有用过
JDK17、21
等版本,就会发现写出来的代码,和最开始的Java可谓是两门语言了……
2.2、函数式接口
归功于类型推导机制,我们可以在Java8中使用lambda
来使得代码简洁化,不过经过上阶段的学习会发现一个致命问题:每写一个Lambda表达式,就需要单独定义一个接口,如果真是这样,Lambda省下来的代码,又全都在接口定义上补回去了,这有点拆东墙补西墙的味道。
JDK
官方显然也想到了这一点,所以提供了一个java.util.function
包,这里面定义了一系列可复用的、使用频率较高的函数式接口,以此避免日常开发过程中重复定义类似的接口,可到底啥叫做函数式接口?函数式接口是Java8新增的一种接口定义。
但说到底,函数式接口跟普通的接口写法都一样,唯一的区别在于:函数式接口就是一个只具有一个抽象方法的特殊接口(可以定义多个方法,但其他的方法只能是default或static)。同时,也可以用@FunctionalInterface
注解来将一个接口声明函数式接口,不过这个注解加不加,都不影响表达式的执行,仅仅只是起到编译校验的作用,如:
@FunctionalInterface
public interface A {
void a();
default void b() {
}
}
这个接口只有一个抽象方法,所以编译能正常通过,再看个反例:
@FunctionalInterface
public interface B {
void a();
void b();
}
这个接口有多个抽象方法,所以编译会提示错误。OK,接着来看看java.util.function
包下提供的函数式接口,这里列几个常用:
| 接口 | 描述 | 示例 |
| :-: | :-: | :-: |
| Supplier | 无入参,返回一个结果 | () -> {return 0;};
|
| Function | 单个入参,返回一个结果 | i -> {return i * 100;};
|
| Consumer | 单个入参,无返回结果 | str -> System.out.println(str);
|
| Predicate | 单个入参,返回一个布尔值结果 | str -> {return str.isEmpty();}
|
| …… | …… | …… |
当然,还有一系列和命名上述类似,但是以Bi……
开头的函数式接口,例如BiFunction
,其实这就是前面的增强版,只是支持两个入参罢了。好了,那么我们该如何使用JDK自带的这些函数式接口呢?来个例子感受一下。
需求:实现两个数字的加减乘除计算。
如果用之前的思维来实现,要么就分别定义加、减、乘、除四个方法,要么就传一个运算符,在用if
或switch
判断,以此实现不同的计算逻辑,但现在可以用lambda
表达式来换一种实现方式:
/*
* 计算两个数字的方法
* */
public static int calculate(int x, int y, BiFunction<Integer, Integer, Integer> calculateModel) {
return calculateModel.apply(x, y);
}
public static void main(String[] args) {
int a = 4;
int b = 2;
// 加法计算
int result1 = calculate(a, b, (x, y) -> x + y);
System.out.println("两数之和:" + result1);
// 减法计算
int result2 = calculate(a, b, (x, y) -> x - y);
System.out.println("两数之差:" + result2);
// 乘法计算
int result3 = calculate(a, b, (x, y) -> x * y);
System.out.println("两数之积:" + result3);
// 除法计算
int result4 = calculate(a, b, (x, y) -> x * y);
System.out.println("两数之商:" + result4);
}
上述代码的运行结果如下:
两数之和:6
两数之差:2
两数之积:8
两数之商:8
这个例子中,我们基于JDK提供的函数式接口,完成了一个小需求的开发。函数式接口和lambda
表达式结合,能使得程序更加灵活,允许将一个函数作为参数传递。
每种表达式的写法,就是某个函数式接口的实现,所以每个表达式都需要特定函数式接口进行对应,而function
包中提供给我们这么多函数式接口,就是为了让我们写Lambda
表达式更加方便。但是作为表达式,它的写法、入参数量、返回结果多种多样,当遇到特殊情况没有现场的函数式接口时,这就需要你自己定义特定的函数式接口,然后才能写对应的Lambda表达式。
2.3、函数引用
前面熟悉了lambda
表达式的语法,以及跟函数式接口之间的关系后,下面再来看看另一种语法糖,即函数引用,这种语法能让你的代码更简洁。
2.3.1、方法引用
Consumer<String> print = (String param) -> {
System.out.println(param);
};
print.accept("竹子爱熊猫");
看上述案例,这个表达式的作用为是打印接收到的参数,按之前说的简化方式,可以改成:
Consumer<String> print = param -> System.out.println(param);
但其实上述这种写法还能继续精简,变成下面这样:
Consumer<String> print = System.out::println;
这是啥写法?这就是方法引用,为啥可以这么写呢?因为System.out.println()
方法的入参数量、入参类型、返回类型(Void
),和当前lambda
表达式的参数列表完全一致,因此可以直接简写为::
,再来个例子:
ZhuZi zhuZi = new ZhuZi();
Consumer<String> setValue = zhuZi::setName;
setValue.accept("竹子爱熊猫");
上面这个例子中,zhuZi
是ZhuZi
类的一个实例对象,setName
是这个实例的一个方法,写法为:实例对象名::实例方法名,这被称为实例对象的方法引用。
除开实例对象+实例方法可以这么写之外,类+静态方法、类+实例方法都是可以的,如下:
/*
* 静态方法引用
* */
// lambda写法:Function<Long, Long> f = x -> Math.abs(x);
Function<Long, Long> f = Math::abs;
Long result = f.apply(-3L);
/*
* 实例方法引用
* */
// lambda写法:BiPredicate<String, String> b = (x,y) -> x.equals(y);
BiPredicate<String, String> b = String::equals;
b.test("a", "b");
第一个例子中,abs()
是Math
类的一个静态方法,Function<Long>
接口中唯一的apply()
抽象方法,入参列表、出参类型与abs()
方法的相同,都是接收一个Long
类型参数,因此可以简写为:类名::静态方法名。
第二个例子中,equals()
为String
类定义的实例方法,BiPredicate<String, String>
接口中唯一test()
抽象方法,入参、出参也与equals
方法的入参完全一致,都是接收两个String
类型,返回boolean
类型,所以也可以简写为:类名::实例方法名。
2.3.2、构造函数引用
前面静态方法、实例方法都可以简写,那么构造方法可不可以呢?答案也是可以,格式为:类名::new,如下:
//Function<Integer, StringBuffer> fun = n -> new StringBuffer(n);
Function<Integer, StringBuffer> fun = StringBuffer::new;
StringBuffer buffer = fun.apply(10);
Function
接口的apply()
方法接收一个Integer
参数,并且返回一个StringBuffer
对象,这与StringBuffer
类的一个构造方法StringBuffer(int capacity)
对应,所以同样可以简写。
除开基本的引用对象外,数组对象是不是也是对象?答案当然是,所以数组对象构造器也可以这样引用,如下:
// Function<Integer, int[]> fun = n -> new int[n];
Function<Integer, int[]> fun = int[]::new;
int[] array = fun.apply(10);
上面这段代码,表示创建一个长度为10
的int
数组。
三、Stream流最佳实践
好了,前面的知识讲完后,下面来看看Java8
中的重头戏,也就是Stream
流,Stream
流是对JDK集合框架体系的增强,它提供了声明性、可并行化、函数式风格的集合操作,专注于对集合对象进行各种非常便利、高效的聚合操作,能用极少的代码,完成之前版本中需要写大量for、if
才能完成的集合处理逻辑,能使代码更加清晰、简洁和易于维护。
不过想用好Stream
流的前提是熟悉lambda
表达式,因为Stream
需要借助于Lambda
来提高编程效率和程序可读性。好了,为了后面便于讲述各类API,先来做些前提准备:
/*
* 熊猫实体类
* */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Panda {
// 熊猫编号
private Long id;
// 熊猫姓名
private String name;
// 熊猫性别,0:雄性,1:雌性
private Integer sex;
// 熊猫年龄
private Integer age;
// 熊猫身高
private BigDecimal height;
}
/*
* 熊猫视图类
* */
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class PandaVO extends Panda {
// 最喜欢的食物
private ZhuZi favoriteFood;
}
上面定义了两个实体类,主要用于模拟日常开发中的各类业务数据,下面再来初始化下数据:
// 案例数据
Panda panda1 = new Panda(888L, "花花", 1, 4, new BigDecimal("142.22"));
Panda panda2 = new Panda(222L, "飞云", 1, 8, new BigDecimal("133.09"));
Panda panda3 = new Panda(333L, "萌兰", 0, 3, new BigDecimal("88.88"));
Panda panda4 = new Panda(444L, "丫丫", 1, 4, new BigDecimal("111.11"));
Panda panda5 = new Panda(555L, "七仔", 0, 4, new BigDecimal("121.66"));
Panda panda6 = new Panda(666L, "肥肥", 1, 3, new BigDecimal("168.99"));
List<Panda> pandas = Arrays.asList(panda1, panda2, panda3, panda4, panda5, panda6);
这里有个pandas
集合,如果在Java7中,想要找出其中性别为雌性、年龄大于三岁的熊猫,而后找到其中年龄最大的熊猫怎么实现?
// 初始化变量
Panda pandaWithMinAge = null;
int minAge = Integer.MAX_VALUE;
// 遍历列表来找到最小年龄的熊猫
for (Panda panda : pandas) {
// 过滤掉雄性、年龄小于3岁的熊猫
if (1 == panda.getSex() && panda.getAge() > 3) {
int age = panda.getAge();
// 判断符合条件的熊猫,是否比已知的最小熊猫要小
if (age < minAge) {
minAge = age;
pandaWithMinAge = panda;
}
}
}
System.out.println(pandaWithMinAge);
上面代码不算多对吧?可以来看Stream
流,更加简单:
Panda pandaWithMinAge = pandas.stream()
.filter(panda -> 1 == panda.getSex() && panda.getAge() > 3)
.min(Comparator.comparingInt(Panda::getAge)).get();
System.out.println(pandaWithMinAge);
是的,你没看错,前面的循环+判断,Java8中两行代码就能搞定!好了,简单对Stream
流有个概念后,下面来正式接触下Stream
流。
3.1、Stream流初相识
从上面的例子中,能明显感受出用Stream
流处理集合更加便捷,同时编码工作量更小、更简洁,相信大家在日常工作中也用过Stream
,不过许多人仅仅只停留在基本的map()、collect()、filter()
这类操作,为了诸位能对Stream
有更深入的掌握,下面来重新认识一下它。
Stream
保留了函数式编程经典的链式编码风格,即可以将所有代码写成一行,通过.
不断拼接各类流操作。而实际上,Stream
也是按流水线(管道)模式工作,如下:
Stream
不同的API
就好比工厂流水线上的一道道工序,处理集合内的元素时,就好比一个个货物,会挨个经过各道工序处理。当然,既然是流水线,那肯定有开始和结束的“工序”,所以Stream
流中的API
总共分为三大类:
- ①开始操作:好比流水线的开头,创建一个
Stream
流; - ②中间操作:
Stream
流中间的工序,经过一个中间函数后,流并不会中断,可以继续经过其他工序; - ③终止操作:类似于流水线的最后一道工序,经过本道工序后,流就结束了。
3.1.1、开始操作(Start Operation)
创建一个Stream
流被称为获取数据源,而获取的方式有很多,最常用的就是从集合或数组中生成:
Collection.stream()
:通过Collection
的子类创建流;Collection.parallelStream()
:通过Collection
的子类创建并行流;Arrays.stream(array)
:通过Arrays
工具类传入数组创建流;Stream.of(T t)
:通过Stream
类的API
创建流对象;Stream.concat(Stream a, Stream b)
:合并两个流为一个新流;Stream.empty()
:创建一个没有任何元素的空流;
所谓的创建流,就是获得一个Stream
对象,当然还有另外的方式,比如各种类库自带的方法,比如BufferedReader.lines()
等,又或者通过java.util.Spliterator
类来自己构建(这种了解即可)。
3.1.2、中间操作(Intermediate Operation)
当Stream
流对象被创建出来后,在后面就可以跟零或多个中间操作,这些中间操作可以对流中的元素进行处理,处理后又会返回一个新的流交给下道工序使用,API
清单如下:
| 方法 | 描述 |
| :-: | :-: |
| filter() | 可以按照指定要求过滤出符合条件的元素 |
| limit() | 截取操作,只保留流中前N
个元素 |
| skip() | 跳跃操作,跳过流中前N
个元素 |
| map() | 映射操作,将流中每个元素转变为其他类型 |
| flatMap() | 多重映射,同map()作用,但是一对多映射 |
| distinct() | 去重操作,相同元素只保留流中出现的第一个 |
| peek() | 遍历操作,类似于循环,但不会终止流 |
| sorted() | 排序操作,可以根据指定规则对流内元素排序 |
其实中间操作还可以细分为有状态、无状态两类操作,所谓的有状态,就是每处理一个元素,必须要知道流中其他元素的状态,如sort()、distinct()
方法。反之,无状态即不需要知道流中其他元素的状态,每个元素都可以独立处理,如map()、filter()
方法。
重点说明:Stream流中所有中间操作都是惰性的,比如
ids.stream().sorted()
这行代码,并不会触发流的遍历动作,只有真正出现终止操作时才会遍历处理流。
3.1.3、终止操作(Terminal Operation)
厂里打螺丝的流水线也会有尽头,Stream
流亦不例外,而会导致流结束的操作,则被称之为终止操作。切记!一个流只能执行一个终止操作,当执行一个终止操作后,流对象就走到了生命尽头,所以终止操作一定要是流的最后一个动作!同时切记,终止操作的出现,会触发流真正的遍历过程,并生成最终的结果。
再来看看Stream
流的终止操作:
| 方法 | 描述 |
| :-: | :-: |
| foreach() | 遍历流中的每一个元素 |
| forEachOrdered() | 按顺序遍历流中的每一个元素 |
| iterator() | 将流对象转变为迭代器对象 |
| toArray() | 将流转变为数组 |
| collect() | 将流转变为指定的集合对象 |
| anyMatch() | 判断流中是否有一个元素满足给定条件 |
| allMatch() | 判断流中所有元素是否都满足给定条件 |
| noneMatch() | 判断流中所有元素是否都不满足给定条件 |
| reduce() | 对流中的所有元素执行累积操作 |
| findAny() | 获取流中任意一个满足条件的元素 |
| findFirst() | 获取流中第一个满足条件的元素 |
| count() | 统计流中最终的元素数量 |
| max() | 获取流中最大的元素 |
| min() | 获取流中最小的元素 |
同样值得说明的是,终止操作也可以分为短路、非短路两类,短路操作是指不需要处理完所有元素就可以结束流,如findFirst()、anyMatch()
方法;反之,非短路操作则需要完整处理整个流,如allMatch()、foreach()
等,而短路操作的效率更高,毕竟无需遍历流中所有元素。
前面说过,终止操作就是流的最后一道工序,执行完后会自动关闭流,无需手动关闭,来个例子证明:
Stream<Panda> stream = pandas.stream();
long count = stream.count();
Object[] array = stream.toArray();
上面count()、toArray()
都是终止操作,运行代码则会出现stream has already been operated upon or closed
提示,表示流已经被关闭。
3.2、Stream流实战
经过上阶段,对Stream
流有整体认知后,下面基于最开始给出的数据,来模拟实际开发中的各种集合处理场景,以此加深对各类API
的掌握程度。
先来看个简单的,就是打印输出pandas
集合中的每个元素,用stream
一行代码搞定:
pandas.stream().forEach(panda -> {
System.out.println(panda);
});
// 或者可以简化为:
pandas.stream().forEach(System.out::println);
这就是stream+lambda
的简洁性,一行代码清晰干脆。其实中间的.stream()
也可以去掉,因为Java8
中为所有集合类都增加了forEach()
方法。
再继续上其他案例来巩固Stream
其他API
的印象,来个题目,统计pandas
集合中雄性大熊猫的数量:
long malePandaNum = pandas.stream()
.filter(panda -> 0 == panda.getSex())
.count();
这里的filter()
相当于之前的if
,只有满足给定条件的元素,才会被转接给下道工序,而count()
则是对每个元素计数,最终得到了符合条件的元素数量,执行过程也可以通过IDEA
调试出来:
如图所示,2019
版本以上的IDEA
支持Stream-Trace
调试,能清晰观察到每一步操作的具体过程(也支持链路式断点,即写在一行里也支持分开打断点)。
再来继续加深印象,新的需求要获得雌性大熊猫中最小的年龄,实现如下:
Optional<Panda> femalePandaMinAge = pandas.stream()
// 先过滤出所有雌性大熊猫
.filter(panda -> 1 == panda.getSex())
// 再根据年龄字段求出最小的值
.min(Comparator.comparing(Panda::getAge));
这也是个很简单需求,那如果我想要获取所有雌性大熊猫,并保存成另一个集合呢?
List<Panda> femalePandas = pandas.stream()
// 先找出所有雌性大熊猫
.filter(panda -> 1 == panda.getSex())
// 将过滤后的元素输出到另一个集合
.collect(Collectors.toList());
这里用到了一个Collectors
类,这个类有很大的作用,后面细聊,继续往下看。
在平时工作中,如果我们要批量提取集合中的某个字段去做批量查询,这该怎么办呢?如下:
List<Long> pandaIds = pandas.stream()
// 只保留熊猫的编号
.map(Panda::getId)
// 将得到的编号统一输出到另一个集合
.collect(Collectors.toList());
上面这种方式能十分快捷的将一个集合中,所有元素的某个字段值提取出来。当然,map()
的作用是映射,你也可以将Panda
对象转变成其他对象,比如开发中的实体类集合转VO
类集合,如下:
// 定义一个竹子实例
ZhuZi zhuZi = new ZhuZi(1L, "黄金竹子");
List<PandaVO> pandaVos = pandas.stream()
// 先过滤出所有雌性大熊猫
.filter(panda -> 1 == panda.getSex())
// 再将过滤后的每个Panda对象,转变成PandaVO对象
.map(panda -> {
// 这里可以转变成任意类型的对象
PandaVO pandaVO = new PandaVO();
pandaVO.setId(panda.getId());
pandaVO.setName(panda.getName());
pandaVO.setSex(panda.getSex());
pandaVO.setAge(panda.getAge());
pandaVO.setHeight(panda.getHeight());
pandaVO.setFavoriteFood(zhuZi);
return pandaVO;
})
// 将每个转变后的PandaVO对象放入另一个集合
.collect(Collectors.toList());
上面就是过滤+映射结合的例子,其实并不难理解,主要搞明白“映射”的概念即可,不过还有个flatMap()
咋用的?来看例子:
List<Panda> newPandas = pandas.stream()
// 进行一对多映射处理
.flatMap(panda -> {
// 先创建一个新的Panda集合(可以是其他类型)
List<Panda> pandaList = new ArrayList<>();
// 往集合里添加元素(这里实际可以是多个)
pandaList.add(panda);
// 将新的集合转变成stream流
return pandaList.stream();
})
// 将所有元素输出到新的集合中
.collect(Collectors.toList());
这个例子中,就是典型的一对多映射,flatMap()
要求返回的是stream
流对象,所以需要将List
转变成流,最后collect()
时,会拼接每个流对象,然后输出到一个集合。
好了,再来看个需求,有时候我们在处理集合数据时,可能想先遍历一次所有元素,为每个元素进行一些特殊处理后,再执行其他操作。但map()
方法会改变对象类型,forEach()
方法会导致流结束掉,这时就不得不再开启一个新的流,有没有好方法呢?有,来看:
pandas.stream()
// 遍历处理每个元素,给每个熊猫的姓名加个前缀
.peek(panda -> {
panda.setName("熊猫:" + panda.getName());
})
// 再遍历打印输出每个元素
.forEach(System.out::println);
如果存在上面我说的需求,就可以使用peek()
方法,该方法属于中间操作,不会导致流关闭、不会改变元素类型,但有人说它不安全,比如这样写:
pandas.stream().peek(System.out::println);
可能预期的想法是遍历打印所有元素,可是一点执行啥也没有,然后就传出了“peek不安全,不一定会执行”的说法, 实则不然,这明显是对Stream
理解不够深刻,再来看个例子:
pandas.stream().filter(panda -> panda.getSex() == 1);
System.out.println(pandas);
这里的预期是啥?只保留雌性大熊猫(sex=1
),然后输出,可是执行结果呢?同样不会过滤,为啥? 在前面我们就提到过,所有中间操作都是懒加载式的,没有出现终止操作前都不会执行,peek()
也不例外,为此,peek()
本身没有安全隐患,只是用的人不规范罢了。
好了,下面来快速过一下其他API
,代码如下:
/*
* 获取集合中为雌性、且年龄小于3的前两只熊猫
* */
List<Panda> limitPandas = pandas.stream()
// 过滤掉雄性、并且年龄小于3的熊猫
.filter(panda -> 1 == panda.getSex() && panda.getAge() > 3)
// 只保留前两个符合条件的元素
.limit(2)
// 将得到的元素输出到另一个集合
.collect(Collectors.toList());
/*
* 跳过前两只熊猫,并根据年龄排序(倒序)
* */
List<Panda> skipDescPandas = pandas.stream()
// 跳过前两只熊猫
.skip(2)
// 根据年龄字段排倒序(升序去掉.reversed()即可)
.sorted(Comparator.comparing(Panda::getAge))
// 输出到另一个集合
.collect(Collectors.toList());
/*
* 如果雄性熊猫中,有一只年龄大于3岁,则输出一句话
* */
boolean flag = pandas.stream()
// 过滤掉雌性熊猫
.filter(panda -> 0 == panda.getSex())
// 判断雄性熊猫中是否有一只年龄大于3岁
.anyMatch(panda -> panda.getAge() > 3);
// 条件成立输出一句话
if (flag) {
System.out.println("我是竹子爱熊猫");
}
好了,上面的代码基本上将列出来的API
都过了一遍,大家可以阅读其中的注释去理解,这里不做过多说明,下面再来看一个例子,如果我要求和所有雌性熊猫的身高怎么办?大家可以先试着用stream
实现一下,代码如下:
BigDecimal femaleTotalHeight = pandas.stream()
// 过滤出所有雌性大熊猫
.filter(panda -> 0 == panda.getSex())
// 只保留年龄字段
.map(Panda::getHeight)
// 对年龄字段求和(第一个参数为默认值,也可以理解成初始值,没有元素时就返回这个)
.reduce(BigDecimal.ZERO, BigDecimal::add);
有人或许想着用sum()
方法,但这个方法只存在于IntStream
这类流对象、或者先调用mapToInt()
这类方法才行,但目前身高字段是BigDecimal
类型,这个类型也是开发中经常用到的,这时我们就可以用到reduce()
方法对所有元素执行积累运算就好啦~
3.3、Collectors转换器
上阶段我们大致将Stream
流中的API
过了一遍,其中collect()
操作大量使用到了Collectors
这个类,不过前面一直没展开讲解,因为它比较大,能帮我们实现特别多的需求。
Collector
也叫收集器,主要配合collect
方法一起使用,可以对流中的元素进行各种汇总操作,如转换、统计、分组、分区等等,这是Stream
流中最重要的一个类,下面来看看它的API
,先说常用的元素汇总:
toCollection()
:将流的元素汇总成一个Collection
集合;toList()
:将流的元素汇总成一个List
集合;toSet()
:将流的元素汇总成一个Set
集合;toMap()
:将流的元素汇总成一个Map
集合;toConcurrentMap()
:将流的元素汇总成一个ConcurrentMap
集合。
再来看下数据统计相关的方法;
counting()
:统计流内的元素数量;summingInt()
:对流内int
元素求和(类似方法还有~Long()、~Double()
);averagingInt()
:对流内int
元素求平均值(类似方法还有~Long()、~Double()
);maxBy()
:获取流内元素指定字段的最大值;minBy()
:获取流内元素指定字段的最小值;summarizingInt()
:汇总统计流内int
元素的数量、综合,以及最大、最小、平均值。
最后再来看下分组、分区和连接方法:
groupingBy()
:根据指定字段对流内的元素进行分组;partitioningBy()
:根据某个条件将流内所有元素分成两个区;joining()
:使用给定的字符,将流内所有元素连接成一个字符串。
这里列出来了Collector
收集器中最常用的一些方法,下面还是用之前的pandas
集合,来对每种类型做个快速实践。
3.3.1、元素汇总
所谓的元素汇总,即是指将流内元素转换成特定集合,toCollection()、toList()、toSet()
这三个不讲了,参数都不用传直接调用即可,特别简单,下面重点来看转Map
。
日常开发中,我们经常会遇到一个需求:以集合元素的某个字段作为Key,将List集合转换为Map集合,而这个需求在Stream
里面很容易就能实现:
/*
* 以熊猫编号作为Key,熊猫姓名作为Value,将pandas集合转变成Map
* */
Map<Long, Panda> pandaIdMap = pandas.stream()
// 第一个参数代表Key,第二个参数代表Value
.collect(Collectors.toMap(Panda::getId, Panda::getName));
那再变换一个需求,我现在想以熊猫编号作为Key
,整个熊猫对象作为Value
,该怎么处理呢?如下:
Map<Long, Panda> pandaMap = pandas.stream()
.collect(Collectors.toMap(Panda::getId, Function.identity()));
这段代码和前一段的区别就是,代表Value
的参数不一样了,Function
是个函数式接口,Function.identity()
表示传入什么就返回什么,而流中每个元素都是Panda
对象,所以返回的也是panda
对象。
好了,再来看个问题,如果我要以年龄作为Key
,整个对象作为Value
呢?有人说简单,看我的:
Map<Integer, Panda> pandaAgeMap = pandas.stream()
.collect(Collectors.toMap(Panda::getAge, Function.identity()));
大家可以试着运行一下这句代码,会发现执行报错提示Duplicate key
,为什么?因为年龄中有重复的值,所以Key
冲突了,这怎么办?别急,这样写就行:
Map<Integer, Panda> pandaMap = pandas.stream()
.collect(Collectors.toMap(
// 以年龄作为Key
Panda::getAge,
// 以整个对象作为Value
Function.identity(),
// 如果出现冲突,用新值覆盖老值
(oldPanda, newPanda) -> newPanda)
);
这时需要我们传入第三个条件,当出现键冲突时,用用新值覆盖老值即可,当然,你要保留老值的话,箭头后面填oldPanda
即可。
3.3.2、数据统计
上面讲了toMap()
这个开发中十分常用的方法,下面来看下数据统计的API,这里就快速过一下,毕竟比较简单:
/*
* 求和流内的元素(collect()前面可以拼其他API)
* */
Long count = pandas.stream().collect(Collectors.counting());
/*
* 求和所有雌性熊猫的总年龄
* */
Integer totalAge = pandas.stream()
// 过滤出所有雌性熊猫
.filter(panda -> 1 == panda.getSex())
// 提取出每只熊猫的年龄
.map(Panda::getAge)
// 对每只熊猫的年龄进行求和
.collect(Collectors.summingInt(age -> age));
/*
* 求出所有雄性熊猫的平均年龄
* */
Double avgAge = pandas.stream()
.filter(panda -> 0 == panda.getSex())
.map(Panda::getAge)
// 对熊猫的年龄进行求平均值
.collect(Collectors.averagingInt(age -> age));
/*
* 获取年龄最大的熊猫
* */
Optional<Panda> maxAge = pandas.stream().
// 根据年龄字段先排序,接着获取年龄最大的熊猫
collect(Collectors.maxBy(Comparator.comparing(Panda::getAge)));
/*
* 获取所有熊猫年龄的汇总统计数据
* */
IntSummaryStatistics statistics = pandas.stream()
.map(Panda::getAge)
.collect(Collectors.summarizingInt(stats -> stats));
大家可以参考代码上的注释去理解,不浪费太多篇章在这里啦。
3.3.3、连接、分组与分区
在平时我们或许需要将一个Long
集合转变成每个元素以,
逗号隔开的字符串,这时就会用到循环拼接,而Stream
中却很简单,如下:
/*
* 将所有熊猫ID以,拼接成字符串
* */
String pandaIds = pandas.stream()
// 先将熊猫编号转为字符串
.map(panda -> Long.toString(panda.getId()))
// 再使用收集器为每个元素之间拼接,逗号
.collect(Collectors.joining(","));
这个很简单就不过多解释,下面来看看类似于SQL
里的group
分组,比如根据熊猫年龄分组:
Map<Integer, List<Panda>> ageGroup = pandas.stream()
// 对流内元素进行分组
.collect(Collectors.groupingBy(
// 根据年龄字段分组
Panda::getAge,
// 相同组的元素归纳到一个集合
Collectors.toList())
);
是不是特别简单,照葫芦画瓢,套入前面的统计方法,我们还能得出每个分组的数量:
Map<Integer, Long> ageGroupCount = pandas.stream()
.collect(Collectors.groupingBy(
// 根据年龄字段分组
Panda::getAge,
// 统计每组的元素数量
Collectors.counting())
);
不止这两个Collectors
方法能逃进来,其实你可以无限套之前介绍过的API
,感兴趣可以自己去试下~
最后再聊下分区,比如根据熊猫的性别分区,代码如下:
Map<Boolean, List<Panda>> pandaPartition = pandas.stream()
// 根据熊猫性别分区,sex == 0代表雄性(true),反之为false
.collect(Collectors.partitioningBy(panda -> 0 == panda.getSex()));
这个分区和分组有点类似,只不过是个Boolean
类型的Key
,意味着最多就两个分区,理解了分组,就自然理解了分区。
3.4、Stream并行流
到这里,大多数有关Stream
流的API
已经阐述完毕,但唯独漏了一点,就是并行流,说人话就是:用多线程去执行某个流操作,开启方式如下:
List<Panda> femalePandas = pandas.parallelStream()
.filter(panda -> 1 == panda.getSex())
.collect(Collectors.toList());
也就是把stream()
方法换成parallelStream()
就可以了,你无需多写一行多线程的代码,并发流模式就能够充分利用多核处理器的优势,底层会使用《Fork/Join线程池》来拆分任务和加速处理过程。 正因如此,Stream
可以酸是一个函数式语言+多核时代综合影响出现的产物,对比传统的循环、迭代器处理集合数据,Stream
流显得更为现代化与高效。
不过使用parallelStream
要注意的问题是:它底层是使用的ForkJoin
,而ForkJoin
里面的线程依赖于ForkJoinPool
来运行,而在Java8中为ForkJoinPool
添加了一个静态通用线程池(commonPool
),这个线程池用来处理那些没有被显式提交到任何线程池的任务。
它拥有的默认线程数量等于运行计算机上的处理器数量,为此要记住,目前Java
进程里所有使用parallelStream
的地方,实际上是公用的同一个ForkJoinPool
!这意味着什么?
意味着虽然parallelStream
提供了更简单的并发执行的实现,但并不意味着更高的性能,在某些场景下反而会存在风险。比如CPU
资源紧张,并行流只会加剧CPU
资源竞争,而不会带来性能提升。又或者大量底层都使用到了并行流或者CompletableFuture
,那公共线程池反而会因为任务堆积导致执行缓慢。
四、Java8的其他特性
上面着重讲述了Stream
这出重头戏后,下面我们再来看看Java8
中的其他开发中常用的特性。
4.1、空指针的天敌-Option
空指针异常(NPE
),是程序开发中出现频次最高的Bug,什么情况下会出现空指针异常呢?
ZhuZi zhuZi = null;
System.out.println(zhuZi.getName());
上面这段代码就会抛出空指针异常,因为zhuZi
这个变量指向的是null
,而getName()
方法又属于实例对象的成员,实例对象都不存在,null.getName()
自然会抛出空指针异常。为了避免NPE
出现,我们在编码过程中,每次使用不确定的数据来源时,如数据库查询结果、外部传入的参数等,都得先套个if
判断。
以前,Google
公司著名的Guava
项目,为了尽量减少空值判断的if
数量,在该类库中引入了Optional
类,通过使用检查空值的方式来防止NPE
。受到Guava
的“启发”,Java8中也吸纳了Optional
类作为标准JDK的一部分,那什么是Optional
?
Optional
实际上是个容器,它可以保存一个指定类型的值,或者保存null
,Optional
提供了许多避免、检测空值的API
,从而减少显式进行空值检测的if
数量,先来看看创建Optional
对象的方法:
| 方法 | 描述 |
| :-: | :-: |
| Optional.of(T value) | 创建一个Optional对象,值不能为空,否则会抛出NPE
|
| Optional.ofNullable(T value) | 创建一个Optional对象,允许值为空 |
| Optional.empty() | 创建一个代表空的Optional对象 |
再来看看Optional
的其他常用方法:
| 方法 | 描述 |
| :-: | :-: |
| isPresent() | 判断op对象是否包含值,有值返回true |
| ifPresent(Consumer<? super T> consumer) | 如果op对象包含值,则执行给定lambda表达式 |
| filter(Predicate<? super T> predicate) | 过滤op对象的值是否满足给定条件 |
| map(Function<? super T, ? extends U> mapper) | 将op对象包含的值,转变为新的值 |
| flatMap(Function<? super T, Optional< U > > mapper) | 作用同map(),更强大,支持一对多 |
| orElse(T other) | 如果op对象的值为空,则返回给定的other对象 |
| orElseGet(Supplier<? extends T> other) | 如果op对象的值为空,则执行给定的lambda并返回 |
| orElseThrow(Supplier<? extends X> exceptionSupplier) | 如果值为空,则执行lambda抛出给定异常 |
好,在之前为了防止NPE
,代码会这么写:
/*
* 获取名字长度
* */
public static int getNameLength(ZhuZi zhuZi) {
if (Objects.nonNull(zhuZi)) {
String name = zhuZi.getName();
if (name != null && !name.isEmpty()) {
return name.length();
}
}
return 0;
}
现在用Optional
则可以改成:
public static int getNameLength(ZhuZi zhuZi) {
return Optional.ofNullable(zhuZi)
.map(ZhuZi::getName)
.filter(name -> !name.isEmpty())
.map(String::length)
.orElse(0);
}
其实这样看起来代码量差不多,不过好处在于Optional
可以一行代码写完,更符合函数式编程的风格。但对于习惯Java以前编码风格的小伙伴来说,前面那种if
风格更直观,而且更顺手一点……。当然,有时候还会有些作用,比如下述场景:
public ZhuZi getZhuZiByXXX() {
// 从数据库根据条件查询数据集合
ZhuZi zhuZi = db.selectByXXX();
return Optional.ofNullable(zhuZi).orElse(new ZhuZi());
}
比如这个从数据库查询数据的场景,如果数据库未查询到数据,就会返回一个null
,这时外部直接使用就会出现NPE
,为此,我们通过Optional
包一层,如果为空则手动new
一个对象出去,方能有效避免NEP
出现。不过这种方式治标不治本,毕竟new
出去的ZhuZi
对象,所有字段都是null
,外部使用时,一不留神或许还会继续出现NEP
,所以Optional
只适用于部分场景,日常开发中要不要用,就取决于各位自己啦~
4.2、更强大日期类型-Date/Time API
在Java8之前与日期时间相关的API
,标准的java.util.Date
存在许多问题,以及后来的java.util.Calendar
设计的过于复杂,几乎让Java处理日期时间更加困难,两者的劣势如下:
- ①
Date
和Calendar
的设计都存在问题:Date
:它时间点是从格林时间开始的偏移量,导致它既不是纯粹的日期类,也不是存粹的时间类;Calendar
:试图将日期、时间的计算、格式化、解析等功能都聚集在一起,使得API
过于复杂;
- ②
Date
和Calendar
都是可变对象,多线程环境存在线程安全问题,需要额外加锁避免并发问题; - ③
Date
本身不包含时区信息,处理不同时区要进行额外转换,Calendar
时区的时区处理API
比较复杂; - ④
Date
并未提供日期格式化相关的API
,想要将日期转变为特定格式,需依赖SimpleDateFormat
类; - ⑤
Date
和Calendar
缺乏某些特定的功能,如闰秒的处理、更大的日期范围、更细的时间维度、时区转换能力等; - ⑥……
综上,对日期与时间的操作,一直是令Java开发者痛苦的地方之一,日常工作中想要快速、便捷的使用日期/时间格式,不得不自己封装工具类,这种情况造就了一个可替换标准日期/时间处理、且功能非常强大的Java API
的诞生:Joda-Time
。
正因如此,Java8中再一次对日期/时间相关的标准API
动刀,通过发布新的Date-Time API(JSR310)
来进一步加强对日期与时间的处理。当然,如果对Joda-Time
库熟悉的小伙伴,就会发现Java8引入的java.time
包,很大程度上受到Joda-Time
的影响,并且”吸取“了其精髓并加以改进(实际上连类的命名都一模一样)。
4.2.1、LocaleDate、LocalTime、LocaleDateTime
LocaleDate
只持有ISO-8601
格式的日期部分,并且没有时区信息,通常用于表示生日等不需要时间的值,常用API
清单如下:
// 获取当前日期
LocalDate now = LocalDate.now();
// 根据给定年月日创建一个日期对象
LocalDate date = LocalDate.of(25, 5, 2024);
// 根据给定字符串解析一个日期对象
LocalDate parseDate = LocalDate.parse("2024-05-25");
// 获取年份,类似的API还有getMonth、getDayOfMonth
int year = now.getYear();
// 获取日期是一年的第几天,类似的API还有getDayOfWeek、getDayOfMonth
int dayOfYear = now.getDayOfYear();
// 在给定日期的增加一天,类似的API还有plusMonths、plusYears、plusWeeks
LocalDate plus1Days = now.plusDays(1);
// 在给定日期上减去一天,类似的API还有minusMonths、minusYears、minusWeeks
LocalDate minus1Days = now.minusDays(1);
// 判断两个日期是否相同,true相同,false代表不同
boolean isEquals = now.equals(date);
// 比较两个日期大小,前者小于后者返回-1,相等返回0,大于返回正整数
int x = now.compareTo(date);
// 将日期转换为指定格式的字符串
String format = now.format(DateTimeFormatter.ofPattern("yyyy年MM月dd日"));
上述则是LocaleDate
较为常用的方法,下面来看看LocaleTime
。LocaleTime
只持有ISO-8601
格式的时间部分,也没有时区信息,常用API
清单如下:
// 获取当前时间
LocalTime now = LocalTime.now();
// 根据给定时分秒创建时间对象
LocalTime time = LocalTime.of(11, 11, 11);
// 将给定字符串解析成时间对象
LocalTime parseTime = LocalTime.parse("11点11分11秒", DateTimeFormatter.ofPattern("HH点mm分ss秒"));
// 获取小时数,类似的API:getMinute(分)、getSecond(秒)、getNano(纳秒)
int hour = now.getHour();
// 增加一小时,类似的API:plusMinute、plusSecond、plusNano
LocalTime plusHours = now.plusHours(1);
// 减少一小时,类似的API:minusMinute、minusSecond、minusNano
LocalTime minusHours = now.minusHours(1);
// 判断两个时间是否相同,true相同,false代表不同
boolean isEquals = time.equals(now);
// 比较两个时间大小,前者小于后者返回-1,相等返回0,大于返回正整数
int x = time.compareTo(now);
// 将时间转换为指定格式的字符串
String format = now.format(DateTimeFormatter.ofPattern("HH点mm分ss秒"));
大家看下来回发现,其实java.time
包下每个Local
开头的类,内部API
的命名大致相同,这极大程度上降低了使用门槛,只要学会其中一种类型,其他的都能照葫芦画瓢。
LocaleDateTime
代表ISO-8601
格式、无时区信息的日期与时间,如果某个字段需要保留日期、时间信息,比如注册时间,就可以使用这个类型,它是LocaleDate
与LocaleTime
功能的缝合者,具备这两者的大多数API
,因此不再介绍重复的,来说些不同的:
// 创建两个日期-时间对象
LocalDateTime now = LocalDateTime.now();
LocalDateTime dateTime = LocalDateTime.of(2024, 5, 25, 11, 11, 11);
// 判断前面的时间是否大于后面的时间
boolean after = now.isAfter(dateTime);
// 判断前面的时间是否小于后面的时间
boolean before = now.isBefore(dateTime);
// 设置特定的小时,类似的API:withYear、withMonth、withMinute、withSecond、withNano
LocalDateTime withHour = now.withHour(11);
// 将日期设置为当前年的第一天
LocalDateTime withDayOfYear = now.withDayOfYear(1);
// 将设置日期设置为当前月的第二天
LocalDateTime withDayOfMonth = now.withDayOfMonth(2);
4.2.4、Instant-时间点(时间戳)
Instant
是time
包中专门用来表达时间戳的类,你可以将其看待成Date
类的增强版(Date
实际上就是时间戳),因为它最细维度能支持到纳秒级别,常用API
如下:
// 获取当前时间戳(纳秒级)
Instant now = Instant.now();
// 从将毫秒级时间戳转换为纳秒级时间戳,基准为格林威治开始时间(1970-01-01 00:00:00)
Instant ofEpochSecond = Instant.ofEpochMilli(11);
// 在时间戳的基础上增加10秒,类似API:plusMillis(毫秒)、plusNanos(纳秒)
Instant plusSeconds = now.plusSeconds(10);
// 在时间戳的基础上减少10秒,类似API:minusMillis(毫秒)、minusNanos(纳秒)
Instant minusSeconds = now.minusSeconds(10);
// 将时间戳转换为毫秒级时间戳
long epochMilli = now.toEpochMilli();
// 获取当前时间戳的秒数
long epochSecond = now.getEpochSecond();
除开上述方法外,java.time
包中都有的方法,如isAfter()、isBefore()、compareTo()、equals()
等都有,作用也类似,这里不重复赘述。
4.2.5、ZoneId、ZonedDateTime
ZoneId
是Java8引入的java.time
包中的一个类,用于表示时区标识符,时区是地球上用于确定本地时间的地理区域,ZoneId
的常用方法如下:
// 获取系统默认时区
ZoneId defaultZoneId = ZoneId.systemDefault();
// 获取Java中所有的可用时区
Set<String> availableZoneIds = ZoneId.getAvailableZoneIds();
// 获取一个特定的时区(上海)
ZoneId shanghaiZoneId = ZoneId.of("Asia/Shanghai");
ZonedDateTime
是带时区信息的LocalDateTime
类型,如果你需要特定时区的日期/时间,那么ZonedDateTime
是你的不二选择,它需要与ZoneId
结合起来一起使用:
// 获取默认时区的日期-时间对象
ZonedDateTime now = ZonedDateTime.now();
// 获取特定时区的日期-时间对象
ZonedDateTime zonedDatetimeFromZone = ZonedDateTime.now(ZoneId.of("America/Los_Angeles"));
// 获取now对象的时区
ZoneId zone = now.getZone();
至于ZonedDateTime
的其他方法,与LocalDateTime
完全相同,这里就不做过多赘述。
4.2.6、Clock、Duration、Period
Clock
类允许获取基于时区的当前时间,并支持创建自定义的时钟实例,以满足特定的应用程序需求,啥意思呢?如下:
// 协调世界时,又称为世界统一时间、世界标准时间、国际协调时间
Clock utc = Clock.systemUTC();
// 获取特定时区的Clock对象
Clock shanghai = Clock.system(ZoneId.of("Asia/Shanghai"));
// 获取默认时区的当前时间戳(纳秒级)
Instant instant = utc.instant();
上述案例中,我们指定了上海时区,然后就可以获取到上海时区的当前时刻、日期与时间,Clock
可以用来替换System.currentTimeMillis()
与TimeZone.getDefault()
。
Period
可以使两个日期间的计算变得十分简单,Duration
可以使两个时间类型的计算很简单,下面来看两个例子:
// 创建两个日期时间实例
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime from = LocalDateTime.parse("2024-04-26 11:11:11", formatter);
LocalDateTime to = LocalDateTime.parse("2024-05-27 12:12:12", formatter);
Duration duration = Duration.between(from, to);
System.out.println("两个时间相差天数:" + duration.toDays());
System.out.println("两个时间相差分钟数:" + duration.toMinutes());
System.out.println("两个时间相差秒数:" + duration.getSeconds());
System.out.println("两个时间相差毫秒数:" + duration.toMillis());
// 获取两个日期实例
LocalDate fromDate = from.toLocalDate();
LocalDate toDate = to.toLocalDate();
Period period = Period.between(fromDate, toDate);
System.out.println("两个时间天数之差:" + period.getDays());
System.out.println("两个时间月数之差:" + period.getMonths());
System.out.println("两个时间年数之差:" + period.getYears());
不过值得说明的是,Period
只会计算两个日期每个单位上的差值,如上面的2024-04-26
和2024-05-27
天数之差会等于1
,而并非预期中的31
天,执行结果如下:
两个时间相差天数:31
两个时间相差分钟数:44701
两个时间相差秒数:2682061
两个时间相差毫秒数:2682061000
两个时间相差天数:1
两个时间相差月数:1
两个时间相差年数:0
好了,其实这个两个类还有许多其他API
,但用的不多就不展开讲述了,也包括time
包中的其他类,这里也不做展开,日常开发中真要用到时,在网上找个Java8
版本的时间工具类即可。
最后要记住,time包下的所有类,创建出的实例都是不可变的,比如你用LocalDateTime的plusHours()方法,将天数往后推一天,这时会产生一个新的LocalDateTime对象,而并不会在原对象的基础上进行修改。这种机制能在多线程环境下,保证进行各类API
操作的安全性。
五、Java8特性篇总结
一点点认真看到这里的小伙伴,相信以后一定能在工作摒弃掉一些传统的编程习惯,更好的使用Java8来完成日常开发,节省代码量、增加摸鱼时间!当然,其实Java8中Stream
流的API
还是有点复杂,为了更好的开发体验,其实我们还可以继续封装工具类,如果大家感兴趣,后续我再出篇封装的篇章,给诸位整理一个更易用的工具类~
不过通篇看下来,大家其实不难发现,Java8中的许多特性,要么是从其他语言“借鉴”过来的,要么是从优秀的三方类库“吸纳”过来的,总之就是糅合了百家之长推出的版本。这也是Java
语言诞生后,第一次重大的版本变更,但不管怎么说,正是因为加入了这么多的优秀特性,才让Java8这个版本盛行至今,才能造就出编程语言历史上最大的钉子户群体~
OK,讲到这里其实篇幅已经很长了,但Java8中还有许多其他特性没讲到,比如之前讲过的《更强大优雅的异步API-CompletableFuture》, 也包括支持重复注解解析、扩展自定义注解类型、增强字节码保留参数名、加强lock
包的锁机制、使用元空间代替方法区、强化泛型推导机制……这些特性,在本文中都未做说明,而这些不算那么重要的特性,就留给大家自行探讨啦!
所有文章已开始陆续同步至公众号:竹子爱熊猫,想在微信上便捷阅读的小伙伴可搜索关注~