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

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 本篇来好好盘盘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面向对象编程基础实验报告
|
8天前
|
安全 Java 测试技术
Java并行流陷阱:为什么指定线程池可能是个坏主意
本文探讨了Java并行流的使用陷阱,尤其是指定线程池的问题。文章分析了并行流的设计思想,指出了指定线程池的弊端,并提供了使用CompletableFuture等替代方案。同时,介绍了Parallel Collector库在处理阻塞任务时的优势和特点。
|
17天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
4天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
21 9
|
7天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
4天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
7天前
|
Java
JAVA多线程通信:为何wait()与notify()如此重要?
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是实现线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件满足时被唤醒,从而确保数据一致性和同步。相比其他通信方式,如忙等待,这些方法更高效灵活。 示例代码展示了如何在生产者-消费者模型中使用这些方法实现线程间的协调和同步。
21 3
|
5天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
6天前
|
Java
java小知识—进程和线程
进程 进程是程序的一次执行过程,是系统运行的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 线程 线程,与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比
17 1
|
7天前
|
Java UED
Java中的多线程编程基础与实践
【10月更文挑战第35天】在Java的世界中,多线程是提升应用性能和响应性的利器。本文将深入浅出地介绍如何在Java中创建和管理线程,以及如何利用同步机制确保数据一致性。我们将从简单的“Hello, World!”线程示例出发,逐步探索线程池的高效使用,并讨论常见的多线程问题。无论你是Java新手还是希望深化理解,这篇文章都将为你打开多线程的大门。