詹姆斯·高斯林:整整十年过去了!你小子还不会用我的Java8?

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: 本篇来好好盘盘JDK1.8特性在日常开发中的最佳实践!

引言

距离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抽象类:

001.png

通过这种设计,当顶层接口新增了某个方法时,作为底层的业务实现类,不一定需要强制实现此方法,只需要在抽象类中实现即可。如果需要实现该方法的业务实现类,重写父类(抽象类)实现的方法即可。

其实这也是Java8之前,所有框架,包括JDK源码在内都在使用的一种方式,如果不这么做,比如JDK官方想对Collection接口新增一个方法,那就需要修改它的所有实现类,这听起来就非常恐怖。正因如此,中间包一层抽象类,这种方式可以最大程度上保证接口灵活性,这同样是为什么大家在看各种源码时,会发现为什么有那么多开头以Abstract……命名类的原因。

002.png

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类型,那么执行对应方法时,就自然能推断出对应位置的lambdaZhuZiCallback接口的实现!

其次,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自带的这些函数式接口呢?来个例子感受一下。

需求:实现两个数字的加减乘除计算。

如果用之前的思维来实现,要么就分别定义加、减、乘、除四个方法,要么就传一个运算符,在用ifswitch判断,以此实现不同的计算逻辑,但现在可以用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("竹子爱熊猫");

上面这个例子中,zhuZiZhuZi类的一个实例对象,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);

上面这段代码,表示创建一个长度为10int数组。

三、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也是按流水线(管道)模式工作,如下:

003.png

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调试出来:

004.png

如图所示,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实际上是个容器,它可以保存一个指定类型的值,或者保存nullOptional提供了许多避免、检测空值的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处理日期时间更加困难,两者的劣势如下:

  • DateCalendar的设计都存在问题:
    • Date:它时间点是从格林时间开始的偏移量,导致它既不是纯粹的日期类,也不是存粹的时间类;
    • Calendar:试图将日期、时间的计算、格式化、解析等功能都聚集在一起,使得API过于复杂;
  • DateCalendar都是可变对象,多线程环境存在线程安全问题,需要额外加锁避免并发问题;
  • Date本身不包含时区信息,处理不同时区要进行额外转换,Calendar时区的时区处理API比较复杂;
  • Date并未提供日期格式化相关的API,想要将日期转变为特定格式,需依赖SimpleDateFormat类;
  • DateCalendar缺乏某些特定的功能,如闰秒的处理、更大的日期范围、更细的时间维度、时区转换能力等;
  • ⑥……

综上,对日期与时间的操作,一直是令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较为常用的方法,下面来看看LocaleTimeLocaleTime只持有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格式、无时区信息的日期与时间,如果某个字段需要保留日期、时间信息,比如注册时间,就可以使用这个类型,它是LocaleDateLocaleTime功能的缝合者,具备这两者的大多数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-时间点(时间戳)

Instanttime包中专门用来表达时间戳的类,你可以将其看待成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-262024-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包的锁机制、使用元空间代替方法区、强化泛型推导机制……这些特性,在本文中都未做说明,而这些不算那么重要的特性,就留给大家自行探讨啦!

所有文章已开始陆续同步至公众号:竹子爱熊猫,想在微信上便捷阅读的小伙伴可搜索关注~

相关文章
|
存储 关系型数据库 MySQL
熬了整整30天,java面向对象编程基础实验报告
熬了整整30天,java面向对象编程基础实验报告
熬了整整30天,java面向对象编程基础实验报告
|
12天前
|
Java 开发者
Java多线程编程中的常见误区与最佳实践####
本文深入剖析了Java多线程编程中开发者常遇到的几个典型误区,如对`start()`与`run()`方法的混淆使用、忽视线程安全问题、错误处理未同步的共享变量等,并针对这些问题提出了具体的解决方案和最佳实践。通过实例代码对比,直观展示了正确与错误的实现方式,旨在帮助读者构建更加健壮、高效的多线程应用程序。 ####
|
20天前
|
安全 Java 测试技术
Java并行流陷阱:为什么指定线程池可能是个坏主意
本文探讨了Java并行流的使用陷阱,尤其是指定线程池的问题。文章分析了并行流的设计思想,指出了指定线程池的弊端,并提供了使用CompletableFuture等替代方案。同时,介绍了Parallel Collector库在处理阻塞任务时的优势和特点。
|
3天前
|
缓存 Java 开发者
Java多线程编程的陷阱与最佳实践####
本文深入探讨了Java多线程编程中常见的陷阱,如竞态条件、死锁和内存一致性错误,并提供了实用的避免策略。通过分析典型错误案例,本文旨在帮助开发者更好地理解和掌握多线程环境下的编程技巧,从而提升并发程序的稳定性和性能。 ####
|
3天前
|
安全 Java 开发者
Java中的多线程编程:从基础到实践
本文深入探讨了Java多线程编程的核心概念和实践技巧,旨在帮助读者理解多线程的工作原理,掌握线程的创建、管理和同步机制。通过具体示例和最佳实践,本文展示了如何在Java应用中有效地利用多线程技术,提高程序性能和响应速度。
24 1
|
11天前
|
安全 Java 开发者
Java 多线程并发控制:深入理解与实战应用
《Java多线程并发控制:深入理解与实战应用》一书详细解析了Java多线程编程的核心概念、并发控制技术及其实战技巧,适合Java开发者深入学习和实践参考。
|
11天前
|
Java 开发者
Java多线程编程的艺术与实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的技术文档,本文以实战为导向,通过生动的实例和详尽的代码解析,引领读者领略多线程编程的魅力,掌握其在提升应用性能、优化资源利用方面的关键作用。无论你是Java初学者还是有一定经验的开发者,本文都将为你打开多线程编程的新视角。 ####
|
10天前
|
存储 安全 Java
Java多线程编程中的并发容器:深入解析与实战应用####
在本文中,我们将探讨Java多线程编程中的一个核心话题——并发容器。不同于传统单一线程环境下的数据结构,并发容器专为多线程场景设计,确保数据访问的线程安全性和高效性。我们将从基础概念出发,逐步深入到`java.util.concurrent`包下的核心并发容器实现,如`ConcurrentHashMap`、`CopyOnWriteArrayList`以及`BlockingQueue`等,通过实例代码演示其使用方法,并分析它们背后的设计原理与适用场景。无论你是Java并发编程的初学者还是希望深化理解的开发者,本文都将为你提供有价值的见解与实践指导。 --- ####
|
16天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
43 9
|
13天前
|
安全 Java 开发者
Java多线程编程中的常见问题与解决方案
本文深入探讨了Java多线程编程中常见的问题,包括线程安全问题、死锁、竞态条件等,并提供了相应的解决策略。文章首先介绍了多线程的基础知识,随后详细分析了每个问题的产生原因和典型场景,最后提出了实用的解决方案,旨在帮助开发者提高多线程程序的稳定性和性能。