Java基础3

Java基础3

Exceptions

What are Exceptions

当我们调用一个将字符串变成大写的函数、传入的却是一个空指针。运行后Java就会给我们一个报错: “name is null“ 。

Java给我们的报错信息非常有用,因为他能帮我们将发生错误的每一句代码都列出,并且一直到最深处。

Types of Exceptions

Checked exception

比如说我想读取一个文件,但是这个文件刚刚被删掉了,这时候就会报错。Java 强迫我们在写这类代码的时候一定要做检查,是否存在该文件。否则在编译 的时候就会报错

当我们新建了一个FileReader对象、要打开一个 file.txt文件时,Java会提醒我们要为其添加一个 exception,否则连编译都过不了。包括接下来我们要学的线程中的 sleep()、join()函数。

Unchecked exception/Runtime exception

如同我们刚才讲的例子: NullPointerException 还有其他几种 Runtime Exceptions ,它们不会在编译前就报错。因此我们要养成良好的编程习惯以及积累经验来规避这些问题

  • ArithmeticException
  • IllegalArgumentException
  • IndexOutOfBoundsException
  • IllegalStateException

Checked 和 Unchecked Excption 主要区别在:

  • Runtime exceptions:
    • 在定义方法时不需要声明会抛出runtime exception;
    • 在调用这个方法时不需要捕获这个runtime exception;

runtime exception是从java.lang.RuntimeException或java.lang.Error类衍生出来的。

  • Checked exceptions:
    • 定义方法时必须声明所有可能会抛出的checked exception;
    • 在调用这个方法时,必须捕获它的checked exception,不然就得把它的exception传递下去;

从逻辑的角度来说,checked exceptions和runtime exception是有不同的使用目的的。checked exception用来指示一种调用方能够直接处理的异常情况。而runtime exception则用来指示一种调用方本身无法处理或恢复的程序错误

Error

第三种是我们束手无策的错误,比如写了个无限循环,或者是内存溢出了。我们必须规避这种低级错误。

Exceptions Hierarchy

现在我们来看一下 Exception 的组织结构。首先,最上层的是 Throwable Class ,这个class中包含了Exception classError两个类。 Exception 类中又包括 RuntimeException class 也就是unchecked Exception

Catching Exceptions

对于 checked exception, 我们可以用 try-catch 代码块来捕捉错误:

这样既可以打印 StackTrace来帮助我们找到错误,又可以让整个程序正常结束。最终,问题出在 Java.io库中的open0函数。

Catching Multiple Types of Exceptions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ExceptionsDemo {
public static void show() {
try {
var reader = new FileReader("File.txt");
var value = reader.read();
new SimpleDateFormat().parse("");
}
catch (FileNotFoundException | ParseException e) {
e.printStackTrace();
}
catch (IOException e){
System.out.println("Could not read data");
}
}

}

如果我们想要捕捉多个 Exception,我们可以使用多个 catch块。如果报错的信息是一样的,我们还可以用 或 运算符将它们放在一起。

注意,IOException 这个 catch block不能放到前面,否则会报错:因为IOException 包含了所有输入输出的错误,是一个“兜底“的类,如果将其放在第一个,就导致FileNotFoundException被IOException处在的代码块率先捕捉了。

The finally Block

如果reader成功打开、我做好处理之后,想要把文件关掉,这时候应该怎么写?

我们应该把 reader.close()放在try代码块中吗? 显然不行,因为如果try成功打开了文件,但在var value = reader.read();时抛出了一个错误,会直接跳到catch块,try后面的代码就不再被执行了。

我们应该直接把 reader.close()放在catch之后吗? 貌似也不行。因为这样如果在未来我们在try-catch 和 close两者之间插入新的代码,并抛出新的错误的时候,reader还是不会被正常关闭。

因此,我们应该使用 finally block.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ExceptionsDemo {
public static void show() {
FileReader reader = null;//一定要预设为null不然finally block不好做判断
try {
reader = new FileReader("File.txt");
var value = reader.read();
new SimpleDateFormat().parse("");
}
catch (IOException e){
System.out.println("Could not read data");
}
finally {
if(reader!=null){
try{
reader.close();
}catch (IOException e){
e.printStackTrace();
}
}
}
}
}

finally在try代码块正常被进入执行,jvm正常执行处理的情况下,是一定会被执行的。 如果在 try block中存在return;那么finally的执行时间是:retrun表达式执行之后,在return返回操作之前。

The try-with-resources Statement

但是像刚才那样写finally block 会比较丑。我们有更好的方法来实现: 就是将申明对象、打开文件都放到 try后面的括号当中,如下:

1
2
3
4
5
6
7
8
9
10
public class ExceptionsDemo {
public static void show() {
try(var reader = new FileReader("File.txt")) {
var value = reader.read();
}
catch (IOException e){
System.out.println("Could not read data");
}
}
}

这种方法叫做 try-with-resources Statement,这样一来,我们就不用显式地写 close() 了,jvm会自动帮我们生成和上面的finally block一样的代码。

https://docs.oracle.com/en/java/javase/16/docs/api/index.html

在官方的文档中,我们可以找到AutoCloseable这个接口,这个接口下所有的子类都可以用这种try-with-resources Statement方法来自动关闭。比如说:FileReader、FileWriter这样的类。

Throwing Exceptions

之前我们做的都是找到错误、抓住错误,但是现在我们要来主动抛出一个错误。

首先我们来讲 defensive programming ,也就是说遇到了错误,我们主动抛出并终结整个程序,如:

我们新建了一个类,这里面会对value值进行一个判断,如果value小于0,就会抛出一个 IllegalArgumentException()错误,并结束整个程序。

如果我们的程序对value值得符号要求很高,如果输入不合法的值对整个程序的性能造成很大的影响(如库、框架等供多人使用的程序),那么我们就应该适用这种 defensive programming 的方法,严格要求。

那么如果要throw一个 Checked Exception,该怎么写呢?比如说我在 Account 账户中抛出一个 IOException()这类异常,然后我必须在main函数中做一个 try-catch block 来接住这个exception

注意了,在 Account 类中的 desposit 函数如果要抛出一个 IOException的话,必须在函数声明后面标明:throws IOException来告诉调用方我这个方法可能会抛出一个异常,而你调用者需要接收。

Re-throwing Exceptions

现在我们在 ExceptionDemo 中接收 Account 抛出来的信息,然后再main函数中调用 ExceptionDemo.show(), 如果我现在希望ExceptionDemo收到异常信息后能将 StackTrace 记录到日志,并让main函数向用户打印一个有好的信息,应该怎么办?

这时候,我们应该 Re-throwing ,也就是说在收到Account 发出的异常信号的时候,ExceptionsDemo再向它的调用方(也就是main) 抛出一个错误,类似于一个接力的效果。然后在main中用try-catch 接收信号并打印一些信息:

Custom Exceptions

Java已经为我们提供了很多基础异常类了,但是有些时候我们还是需要为我们的项目客制化一些异常。

比如说刚才那个例子,我在Account 中设立一个 withdraw(取钱的函数),方法逻辑是:如果要取出的钱大于账户余额,那么就抛出一个异常。但这时用java提供的标准异常也不太贴切,因此我们可以自定义一个异常。

首先我们要创建一个自定义的异常类,异常类要以 Exception作为结尾,要有辨识度。然后我们要决定这个异常类是属于 checked exception (继承Exception类) 还是 unchecked exception (继承RuntimeException类),然后

继承Exception 类之后,我们要自定义异常警告。因为 异常类是一个有参构造函数,因此我们还需要设置super("异常信息") 。这里我们也提供了两种构造函数,一种默认构造函数直接设置异常信息为 Insufficient funds in your account, 第二种则是让调用者自定义报错信息的有参构造函数。

Chaining Exceptions

Chaining Exception 就是将一个更广泛的异常包裹一个比较具体的异常。那刚才的例子来说,我们有一个比较具体的异常: InsufficientFundsException() 但是造成取钱失败的异常还可能有很多种,因此我们可以创建一个更加广泛地异常类 AccountException() :

1
2
3
4
5
6
7
package com.company.exceptions;
//这里我super中传入的是一个异常的原因,比如 InsufficientFundsException 这个异常类
public class AccountException extends Exception{
public AccountException(Exception cause){
super(cause);
}
}

然后在 Account 类当中,当取款大于余额的时候,我们会向上抛出一个 AccountException类,并在这个类中传入原因:InsufficientFundsException,告诉调用者这是因为余额不够导致的账户异常。这就是 chain exception

1
2
3
4
5
6
7
8
9
10
11
12
public class Account {
private float balance;

public void deposit(float value) throws IOException {
if(value<=0)
throw new IOException();
}
public void withdraw(float value) throws AccountException {
if(value > balance)
throw new AccountException(new InsufficientFundsException());
}
}

在ExceptionsDemo类中,我们捕捉一个 AccountException类,然后通过 e.getCause() 获取AccountException中的异常类型并通过getMessage()打印出改原因的异常信息。

1
2
3
4
5
6
7
8
9
10
11
public class ExceptionsDemo {
public static void show() throws IOException {
var account = new Account();
try {
account.withdraw(10);
} catch (AccountException e) {
var cause = e.getCause();
System.out.println(cause.getMessage());
}
}
}

Generics

泛型就是参数化类型

  • 适用于多种数据类型执行相同的代码
  • 泛型中的类型在使用时指定
  • 泛型归根到底就是C++中的“模版”

The Need for Generics

比如我创建了一个 List() 类,如下:

那么如果我想创建一个 User 类的List,就要创建一个新的 UserList 类,久而久之,类就变得很繁杂。这时候我们就需要用到泛型了。

A Poor Solution

遇到这种情况,使用 Object Class 是一种下策。因为虽然 所有的类都继承自Object类,但是有很多缺点

  1. 比如我向Object List中存放了很多的元素(如Integer.valueOf(1)), 现在我想通过get 取出 List当中的第一个元素,但是这时候返回的类型是 Object,要得到int类型的返回结果我们必须进行强制类型转换,否则就会造成报错。
  2. 此外,List的管理会变得比较混乱,我们会搞不清楚 List中到底存储着什么类型的数据。

Generic Classes

我们新创建一个 GenericList 类,需要在尖括号中一般使用 E或者T 来代表种类。

这里我们声明一个 类型为 T 的数组,因为我们没有办法直接 new T[10],因为我们不知道传进来的是 int还是string,没办法实例化。这里我们必须创建一个 Object数组然后将其用强制类型转换变成 T 类型。

然后 我们在main函数就可以声明各种类型的 list了,注意在声明的时候要传入数据类型,而且调用get()方法的时候也不用进行强制类型转换了。

Generics and Primitive Types

在声明泛型类实例的时候,我们不能穿入 Primitive Types,即 int, boolean,float 之类

如果我们要将这些类型的数据存入到泛型类实例当中,我们就必须要使用到 Wrapper class

Java中每一个 Primitive Type 都有一个对应的 Wrapper class(包装类)

  • int -> Integer
  • float -> Float
  • boolean -> Boolean

Constraints

如果我们只想让我们的泛型存储数值类型的数据,那么我们可以让 <T extends Number>

所有的包装类(Integer、Long、Byte、Double、Float、Short)都是抽象类 Number 的子类。

包装类 基本数据类型
Boolean boolean
Byte byte
Short short
Integer int
Long long
Float float
Double Double

这时候,我再创建一个 String 类型的泛型实例就会报错了。

除了extends Number 之外,我们还可以 extends Comparable ,也就是只能使用可比较的数据类型: String, Integer等,但是如果我们直接传入 User,就会报错,因为我们自己申明的User类暂时是不能比较的。

要想 User能够比较,我们还需要在声明 User 类的时候让其 implements Comparable,如下:

1
2
3
4
5
6
7
public class User implements Comparable<User>{

@Override
public int compareTo(User o) {
return 0;
}
}

注意了,这里要在 Comparable后面用尖括号写上User,然后让Idea自动生成一个 compareTo 的重写函数,这样重写函数中的参数就是User,否则就是Object,而Object范围太广,不太安全。

还可以是 extends Cloneable,是另外一个很有用的接口。比如:<T extends Comparable & Cloneable >

Type Erasure

现在我们在底层看看泛型是怎么实现的。

我们首先看看没有限制条件的 Generics 的 Bytecode:

我们发现在ByteCode里面,我们声明的T类型的数组都变成了 Object数组。 也就是说和我们一开始自己写的Object数组是一样的,那么泛型和Object数组的区别在哪里呢?

在于编译的时候Java会帮助我们检查数据类型是否正确,而自己写的Object数组则不会判断,我们可以传入数字也可以是实例化的对象。因此,泛型更便于我们对数据进行管理。

当我们对泛型做出限定的时候,如 Comparable、Number, ByteCode中数据类型也会从Object变为Number、Comparable等。

Comparable Interface

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/lang/Comparable.html

我们上面说了,可以通过 User implements Comparable来把User类变成可比较的类,注意了,这里我们要传入比较的数据类型,否则Java会默认是两个Object在做比较。

如下面这段代码,我们在User里面创建一个points的变量,然后对其进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.company.generics;

public class User implements Comparable<User>{
private int points;
public User(int points){
this.points = points;
}
@Override
public int compareTo(User other) {
// this < o -> -1
// this == o -> 0
// this > o -> 1
return points- other.points;
}
}

在main函数中,我们就可以对两个User实例进行比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 import com.company.generics.User;

public class Main {

public static void main(String[] args) {
var user1 = new User(10);
var user2 = new User(20);
if(user1.compareTo(user2)<0)
System.out.println("user1<user2");
else if(user1.compareTo(user2)==0)
System.out.println("user1==user2");
else
System.out.println("user1>user2");
}

}

Generic Methods

除了泛型类,还有泛型方法。 在使用限定词的时候,类是 implements, 而方法则是 extends

比如我创建一个 Utils 类,里面有一个max方法,用来返回两个对象中较大的那一个:

1
2
3
4
5
6
7
package com.company.generics;

public class Utils {
public static <T extends Comparable<T>> T max(T first ,T second){
return (first.compareTo(second)>0 )? first:second;
}
}

在函数定义时,需要在尖括号中写 <T extends Comparable<T>> 返回数据类型也为T,然后利用compareTo 函数来比较二者的大小:

1
2
3
4
public static void main(String[] args) {
User max_user = Utils.max(new User(10),new User(20));
System.out.println(max_user);
}

结果输出的却是这样的结果:com.company.generics.User@3f99bd52

是因为返回的对象调用 toString()函数,因此输出了一个hashcode,要解决这个问题,我们需要在 编写 User类的时候重写 toString() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class User implements Comparable<User>{
private int points;

public User(int points){
this.points = points;
}
@Override
public int compareTo(User other) {
// this < o -> -1
// this == o -> 0
// this > o -> 1
return points- other.points;
}

@Override
public String toString() {
return "User{" +
"points=" + points +
'}';
}

}

再次运行得到: User{points=20}

Multiple Type Parameters

不管是泛型类还是泛型方法都可以传入多种不同类型的参数,我们各给出一个例子:

泛型类

这里我们创建了一个 键值对的类,K 代表key的数据类型,V代表value的数据类型

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.company.generics;

import java.security.Key;

public class KeyValuePair<K,V> {
private K key;
private V value;

public KeyValuePair(K key,V value){
this.key = key;
this.value = value;
}
}

同样的,对于方法我们也可以使用多个类型

1
2
3
public static <T,V> void print(T key,V value){
System.out.println(key + "="+ value);
}

Generic Classes and Inheritance

当我们用一个类来继承泛型类的时候,关系可如下图所示:

也就是说Integer继承了Number,在一般的实例中,Number 是可以接收 Interger类型的参数的;但是在泛型类的实例中 如Box<Number> 只能接收数据类型为Number的数据,不接受 Box<Integer>Box<Double>的参数。

所以说在泛型类中,虽然传入的类之间存在继承关系,但是他们的泛型类是不存在继承关系的。原因在于它们都继承自Object类型,但是二者相互独立。

我们用刚才的例子看一下:

首先我们创建一个 Instructor 类,并让其继承自User

1
2
3
4
5
6
7
package com.company.generics;

public class Instructor extends User{
public Instructor(int points) {
super(points);
}
}

然后我们在Utils中声明两个方法,一个是普通的 printUser方法,另一个则是接收 GenericList<User>printUsers方法

1
2
3
4
5
6
public static void printUser(User user){
System.out.println(user);
}
public static void printUsers(GenericList<User> users){
System.out.println(users.get(0));
}

结果在 main 函数当中,可以用printUser来打印一个 Instructor实例,但是不能用 printUsers来打印一个 GenericList<Instructor> 实例:

为了解决这个问题,我们可以用Wildcards(术语叫通配符,其实就类似于扑克中的万能牌)

Wildcards

我们可以将 printUsers方法这样写:问号就是通配符,代表着一个unknown的数据类型。

1
2
3
public static void printUsers(GenericList<?> users){
System.out.println(users.get(0));
}

这样虽然解决了问题,但是这样一来,我们可以向 printUsers传入任何数据类型,比如Integer,String等,把他们当做Users打印出来显然是不行的。因此在使用通配符之后还需要加上限定条件

? + extends

当我们使用了通配符 ?之后,相当于java创建了一个我们看不见的 类叫做 CAP#1 用来存放未知的数据类型。 因为可能有多个通配符,所以CAP后面的编号也不同。

如果我们用 ?+extends User 的话,就相当于 Class CAP#1 继承了User,而 Instructor 也是继承User的,因此这时候可以向printUsers传入 User以及它的子类,而不能传入 Integer、String这种数据类型了。

1
2
3
public static void printUsers(GenericList<? extends User> users){
User x = users.get(0);
}

比如这里我们可以用 User来接收get函数的返回值,因为User是 Cap#1的父类,但是不能用Instructor来接收,因为Instructor和Cap#1是”兄弟“关系,是独立的两个类。

但是,我们不能在这里使用 add()函数。因为CAP#1是个抽象概念,我们没有办法实例化一个CAP#1对象并将其加到Users当中去。

因此如果选择 ?+extends 对象是只读不可写的

? + super

super和extends则是刚好相反。只可写不可读

在使用了super关键词后,?相当于 User类的父类,也就是 Object Class

因此GenericList<? super User> users 之后,Java会把users看做是:GenericList<Object> temp 这个对象

这时候我们调用 add方法,因为添加的对象都是Object的子类,因此不会报错。然而我们却无法使用get方法了,因为这时候get的返回类型为Object,但是Java并不知道你用什么类型来接收。很可能两种数据类型是不兼容的,因此不能使用:

总结: 可读 可写
?+extends ×
?+super ×

Collections

提到集合就不得不提一下数组,好多集合底层都是依赖于数组的实现。数组一旦初始化后,长度就确定了,存储数据对象不能达到动态扩展,其次数组存储元素不便于对数组进行添加修改删除操作,而且数组可以存储重复元素。这个时候集合对作用显现出来了。集合分为CollectionMap两种体系。Collection的集合类的继承树如下图所示:

化简可得:

Collection 接口有 3 种子类型集合: ListSetQueue,再下面是一些抽象类,最后是具体实现类。常用的有 ArrayList用来当数组用,LinkedList即链表;在Queue下常用的是PriorityQueue,即优先队列;在Set下常用 Hashset 用来做哈希映射。

简单的来说,Java中的collection类似于C++中的stl,有多种封装好的数据结构。

The Iterable Interface

下面是 Iterable的官方文档:

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/lang/Iterable.html

在这个接口中一共有三个函数,我们要重写iterator()这个函数,因为这个函数会返回一个迭代器对象。迭代器的数据类型取决于泛型对象的数据类型

当一个类继承了 Iterable之后,他就可以被迭代了。这里,我们先用自建的代码来模拟 ArratList 。这里我虽然重写了Iterator但是没有写任何代码,只是为了做个演示。

1
2
3
4
5
6
7
public class GenericList<T> implements Iterable<T>{
private T[] items = (T[])new Object[10];
@Override
public Iterator<T> iterator() {
return null;
}
}

在main函数中,我们用iterator来接收迭代器,这里返回的是一个String 类型的迭代器。

iterator中两个内置方法很重要,一个是 hasNext(), 也就是用来判断是否存在下一个元素;还有一个是next()即让迭代器指向下一个元素。使用while循环,可以遍历对象中的所有元素。(虽然hasNext()和next()我暂时还没有重写)

1
2
3
4
5
6
7
8
public static void main(String[] args) {
var list = new GenericList<String>();
var iterator = list.iterator();
while(iterator.hasNext()){
var current = iterator.next();
System.out.println(current);
}
}

其实,while循环可以这样来简化:

1
2
3
for (var item : list){
System.out.println(item);
}

在底层的bytecode都是一样的。

使用了Iterable接口,我们就没有必要在GenericList类中将 private T[] items设置成 public T[] items,照样可以迭代。

The Iterator Interface

现在我们继续实现我们自建的 GenericList,刚才只是说了我们可以实现什么功能,但并没有将方法都写出来。

首先我们要在 GenericList中创建一个新的类,叫做ListIterator, 这个类讲接入 Iterator接口并重写 hasNext()next() 两个函数。并让GenericList中的 iterator方法的返回一个 ListIterator对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class GenericList<T> implements Iterable<T>{
private T[] items = (T[]) new Object[10];
private int count;

public void add(T item) {
items[count++] = item;
}

public T get(int index) {
return items[index];
}

@Override
public Iterator<T> iterator() {
//返回一个ListIterator实例,并将自身(GenericList)当做参数传入。
return new ListIterator(this);
}
// ListIterator 类是定义在Generic 里面的,因此能访问到私有变量
private class ListIterator implements Iterator<T> {

private GenericList<T> list;
private int index;
//定义 ListIterator的有参构造函数,传入参数类型为 GenericList<T>
public ListIterator(GenericList<T> list) {
this.list = list;
}
//重写 hasNext()函数
@Override
public boolean hasNext() {
return (index < list.count);
}
//重写next函数,返回list中的下一个元素
@Override
public T next() {
return list.items[index++];
}
}
}

这样,一个建议的ArrayList就做完了,我们可以测试一下让其遍历输出:

The Collection Interface

学会了ArrayList原理之后,我们正式来讲讲 Collection 接口, 这是官方文档

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/Collection.html

在文档中,我们看到,Collection实现了 Iterable<E> 接口,这说明 collection中的子接口以及子类都是可以迭代的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class CollectionsDemo {
public static void show(){
Collection<String> collection = new ArrayList<>();
collection.add("a");
collection.add("b");
collection.add("c");
Collections.addAll(collection,"d","e","f");

collection.remove("a");
System.out.println(collection);
System.out.println(collection.size());

var objectArray = collection.toArray();
var stringArray = collection.toArray(new String[5]);
System.out.println(objectArray);
System.out.println(stringArray);

Collection<String> other = new ArrayList<>();
other.addAll(collection);

System.out.println(collection.contains("a"));
collection.clear();
System.out.println(collection.isEmpty());
}
}

但是要注意了, collection是不支持通过索引访问的,即我们不能使用中括号或者 add(index,element)这种方法来添加元素的。

The List Interface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ListDemo {
public static void show(){
List<String> list = new ArrayList<>();
//List 同样也有 add 操作,但是list可以通过index来决定插入的位置
list.add("a");
list.add("b");
list.add("c");
list.add(0,"!");
System.out.println(list);
// 打印得到: [!, a, b, c], 同样也有 addAll 方法。
Collections.addAll(list,"d","e","f");
// set 是替换的方法,这里我们将 list中的第一个元素(!)替换成 "a+"
list.set(0,"a+");
System.out.println(list);
//打印得到:[a+, a, b, c, d, e, f]
//删除第一个元素
list.remove(0);
System.out.println(list);
//打印得到:[a, b, c, d, e, f]

//打印出元素的"b"的位置,打印得: 1
System.out.println(list.indexOf("b"));
//打印出元素"ss"的位置,打印得: -1 因为ss并不在list中
System.out.println(list.indexOf("ss"));
// 打印出list的子列表,即[a,b],因为不包括list[2]
System.out.println(list.subList(0,2));
}
}

The Comparable Interface

在泛型那章已经讲过 Comparable Interface了,一般来说我们自定义的类如果要实现Comparable接口的话,一般需要重写 compareTo 和 toString 两个方法,如下面这个比较字符串的 Customer类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.company.collections;

public class Customer implements Comparable<Customer>{
private String name;

public Customer(String name) {
this.name = name;
}

@Override
public int compareTo(Customer other) {
// this < other -> -1
// this == other -> 0
// this > other -> 1
return name.compareTo(other.name);
}

@Override
public String toString() {
return name;
}
}

The Comparator Interface

现在我们虽然实现了 Customer的排名,但实现起来却比较的麻烦,需要在类内实现。这时Comparator这个接口就可以在类外实现对象的比较。

简单来说,Comparable就是定义一个单独的对象比较器,继承自Comparator接口,实现compare()方法

比如现在 Customer 中多了一个 Email 参数,然后我们就新建一个 EmailComparator类如下:

在这个类中我们重写 compare方法并按照email进行排序

1
2
3
4
5
6
7
8
9
10
11
package com.company.collections;

import java.util.Comparator;

public class EmailComparator implements Comparator<Customer>{

@Override
public int compare(Customer o1, Customer o2) {
return o1.getEmail().compareTo(o2.getEmail());
}
}

然后在main函数中我们对Customer数组进行一个排序

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
List<Customer> customers = new ArrayList<>();
customers.add(new Customer("b", "e3"));
customers.add(new Customer("a", "e2"));
customers.add(new Customer("c", "e1"));
//在调用sort的时候,将比较器放在第二个参数上
Collections.sort(customers,new EmailComparator());
System.out.println(customers);
}

打印得到 [c, a, b],说明确实是按照email进行排列的

The Queue Interface

现在我们来讲 队列接口,这是Queue<E> 的文档:

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/Queue.html

我们看到在 Queue接口下比较常用的就是 ArrayDeque(先进先出)、LinkedList(即继承自Queue又继承自List)、PriorityQueue等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void show(){
Queue<String> queue = new ArrayDeque<>();
//和list一样,queue也有add函数,但是加到队列的末尾
queue.add("c");
queue.add("a");
queue.add("b");
// peek()就是获取队列的第一个元素
var front = queue.peek();
System.out.println(front);// c
System.out.println(queue);// [c, a, b]
var remove = queue.remove();// 删除第一个元素
System.out.println(remove);// c
System.out.println(queue);// [a, b]
//poll()和remove()都是删除队列第一个元素
//他们的唯一区别就是当队列为空时,poll返回null而remove会抛出异常
System.out.println(queue.poll());// a
queue.poll();
System.out.println(queue.poll());// null
}

The Set Interface

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/Set.html

和C++一样,Set 是不包含重复的元素的. 如果把一个ArrayList放到Set当中,也会将其变成一个不重复的对象:

1
2
3
4
5
6
public static void main(String[] args) {
Collection<String> collection = new ArrayList<>();
Collections.addAll(collection, "a", "b", "c", "c");
Set<String> set = new HashSet<>(collection);
System.out.println(set);//[a, b, c]
}

现在来介绍一些Set中的方法

set其实就是一个集合,数学上集合的操作在Java中都有对应的方法。

比如 ,就可以用 addAll() 方法求得

,可以用 retainAll()方法求得

A-B, 可以用 removeAll()方法求得

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Set<String> set1 = new HashSet<>(Arrays.asList("a","b","c"));
Set<String> set2 = new HashSet<>(Arrays.asList("d","b","c"));

//Union
set1.addAll(set2);
System.out.println(set1);//[a, b, c, d]

//复原
set1 = new HashSet<>(Arrays.asList("a","b","c"));
// Intersection
set1.retainAll(set2);
System.out.println(set1);//[b, c]

//复原
set1 = new HashSet<>(Arrays.asList("a","b","c"));
//Difference
set1.removeAll(set2);
System.out.println(set1);//[a]

The Map Interface

在Java和C++中,哈希表可以用 Map接口来实现,在C#、python中,可以用Dictionary(词典)来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var c1 = new Customer("a","e1");
var c2 = new Customer("b","e2");
var unknown = new Customer("Unknown","");

Map<String,Customer>map = new HashMap<>();
map.put(c1.getEmail(),c1);// email作为key,而Customer对象作为value
map.put(c2.getEmail(),c2);

var customer = map.get("e1");
System.out.println(customer);//a

customer = map.get("e10");//如果按一个不存在的key去找,会返回10
System.out.println(customer);//null

//找到就返回e10配对的对象,否则就返回unknown
customer = map.getOrDefault("e10",unknown);
System.out.println(customer);//Unknown

var exists = map.containsKey("e10");
System.out.println(exists);//false

map.replace("e1",new Customer("a++","e1"));
System.out.println(map);//{e1=a++, e2=b}

但是注意了,map是不能够被迭代的,因此我们没用办法用for each loop来直接遍历map,但是我们有其他的方法:

我们可以使用map内置的 entrySet方法来获得每一组键值对

1
2
3
4
5
6
7
8
9
10
11
12
for (var entry : map.entrySet()) {
System.out.println(entry);
}
//输出:e1=a++ e2=b
for (var entry : map.entrySet()) {
System.out.println(entry.getValue());
}
//输出值 :a++ b
for (var entry : map.entrySet()) {
System.out.println(entry.getKey());
}
//输出索引 key: e1 e2

当然,也可以使用map.values()直接获得value

1
2
3
for (customer : map.values()) {
System.out.println(customer);
}

Lambda Expressions

Functional Interfaces

函数式接口,是指内部只有一个抽象方法的接口。注意,只能有一个,并且是抽象的方法

比如说我声明一个 Printer接口:

1
2
3
4
5
package com.company.lambda;

public interface Printer {
void print(String message);
}

然后用一个ConsolePrinter类来实现这个接口:

1
2
3
4
5
6
7
8
package com.company.lambda;

public class ConsolePrinter implements Printer{
@Override
public void print(String message) {
System.out.println(message);
}
}

最后在LambdasDemo类中将实例传入到参数为接口的greet()方法中,完成打印。

1
2
3
4
5
6
7
8
9
10
11
12
package com.company.lambda;

public class LambdasDemo {
public static void show(){
greet(new ConsolePrinter());
}


public static void greet(Printer printer){
printer.print("Hello World!");
}
}

这与我们之前Java基础2中关于接口的操作思路一样,但是有时候我们并不想创建这样一个功能如此特殊的类来实现接口,因为用一次以后可能再也不会使用了。因此接下来我们要来介绍匿名内部类。

Anonymous Inner Classes

匿名内部类就是没有名字的、方法内部的类,通常用来简化代码的编写。

匿名内部类的使用场景: 我们只使用一次接口并用来实现某些特殊的功能的时候

匿名类是不能有名称的类,所以没办法引用它们。必须在创建时,作为new语句的一部分来声明它们。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.company.lambda;

public class LambdasDemo {
public static void show(){
greet(new Printer() {
@Override
public void print(String message) {
System.out.println(message);
}
});
}


public static void greet(Printer printer){
printer.print("Hello World!");
}
}

匿名内部类,虽然已经方便了许多。但是更好的方法是使用 Lambda Expression

Lambda Expressions

Lambda 表达式的作用就像是一个匿名内部类,但是又不属于类。比如说我们重写刚才的匿名内部类代码:

1
2
3
4
5
public static void show(){
greet(message->{
System.out.println(message);
});
}

也就是说使用 Lambda expression 可以代替一个类。在这里我们甚至不需要写 message的数据类型,因为当我们使用Lambda表达式的时候Java会根据调用它的方法(这里是greet())找到对应的接口及其数据类型。

当我们只传入1个参数的时候,参数不需要用括号包裹。但是当我们传入0个或者多个参数的时候,需要使用小括号包裹。

如果这个Lambda Expression花括号中只含有一句代码,那么花括号也可以被省略,如下:

1
greet(message -> System.out.println(message))

Variable Capture

如果我们使用一个匿名内部类,我们可以在类内新建一些变量,但是在 Lambda Expression 中,是不能新建变量的。但是可以使用当前方法中定义的变量,比如:

1
2
3
4
5
6
7
public static void show(){
String prefix = "-";

greet((message)->{
System.out.println(prefix+message);
});
}

也可使用当前类中定义的静态变量:

1
2
3
4
5
6
7
8
9
10
11
public class LambdasDemo {
public static String prefix = "-";
public static void show(){
greet((message)->{
System.out.println(prefix+message);
});
}
public static void greet(Printer printer){
printer.print("Hello World!");
}
}

当然,如果要使用非静态变量的话,需要将 show()方法也定义成非静态的。

Method References

方法引用一共有四种,目的是用来简化Lambda表达式的。一般引用格式是: 类名+静态方法名,要求是引用的静态方法跟 Lambda表达式是客观等价的(参数值、参数类型、返回值一致)

比如说刚才的例子中 ,println方法就和Lambda表达式是客观等价的,因此我们可以直接简化为:

1
2
3
public  void show(){
greet(System.out::println);
}

当然,我们也可以在类中自定义一个静态函数然后实现函数引用:

在这里我们定义了静态函数 LambdaDemo 并在 greet中引用了它

1
2
3
4
5
6
7
8
9
public class LambdasDemo {
public static void print(String message){}
public void show(){
greet(LambdasDemo::print);
}
public static void greet(Printer printer){
printer.print("Hello World!");
}
}

在上图,第四种方法就是引用一个构造函数。可以使用 类+new 的方法

1
2
3
4
5
6
7
8
9
10
11
public class LambdasDemo {
public LambdasDemo(String message){
}

public static void show(){
greet(LambdasDemo::new);
}
public static void greet(Printer printer){
printer.print("Hello World!");
}
}

Built-in Functional Interfaces

文档:https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/package-summary.html

Java提供了四种内建的函数式接口:Consumer、Supplier、 Function、Predicate

Consumer接口的意思是它值接收一个参数,且不返回任何东西。就好像把一个值给消费掉了。

Supplier接口的意思是它并不接收任何参数,但返回一个值,就好像它在提供一个值。

Function接口的意思是它会把一个值映射到另一个值上, obj map(obj)

Predicate接口的意思是判断一个对象是否符合某个条件,bool test(condition)

现在我们来一一介绍四种接口

The Consumer Interface

文档:https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/Consumer.html

此外这个接口还有几个变形,比如 BiConsumer也就是接收两个参数但是不返回任何值

这里我们引用一个例子:

List集合中的 forEach()方法就实现了一个 Consumer接口,因此我们要传入一个值:

1
list.forEach(System.out::println);

这就是Declarative Programming, 也就是说这句命令做了些啥。

Chaining Consumer

现在我们来说说 链式的Consumer,在这里我们定义了两个lambda函数分别实现 Consumer接口:一个是打印原来的元素,另一个打印大写后的元素。

然后我们使用forEach方法的时候,先调用print,在print后调用内建的 addThen,又可以调用一个 Consumer对象.可以一直这样调用下去,比如:list.forEach(print.andThen(printUpperCase).andThen(print));

调用顺序是:对于每一个元素,前调用print方法,然后调用printUpperCase方法,即先打印小写字母再打印大写祖母

我们查看Consumer的源码就能看出原理:

1
2
3
4
default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (T t) -> { accept(t); after.accept(t); };
}

我们发现这个函数的返回值也是一个 Consumer的对象,当一个Consumer调用andThen,它先执行前面的对象,再调用after执行后面的对象,这样就会使我们的传入的两个Consumer对象按照顺序执行。

The Supplier Interface

文档:https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/Consumer.html

这个接口只有一个方法,即get

这里我们创建了一个 getRandom 的 Lambda 表达式,用来随机生成一个数。

1
2
3
4
5
6
7
public class LambdasDemo {
public static void show(){
Supplier<Double> getRandom = () -> Math.random();
var random = getRandom.get();
System.out.println(random);
}
}

这边要注意的是,如果我们不调用 getRandom.get(),这个Lambda 表达式是不会执行的。这叫做:Lazy evaluation.

和Consumer接口一样,Supplier接口也有多种变形: DoubleSupplierBooleanSupplier,IntSupplier等,这些接口只能返回特定类型的数据。

The Function Interface

文档:https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/Function.html

基本模板:Interface Function<T,R>

这个接口需要设置两个数据类型 T和R.文档中这么解释:

T - the type of the input to the function

R - the type of the result of the function

当然还有 Function 接口的变形如 BiFunction<T,U,R>,也就是设置三个数据类型,两个接收的和一个返回的:

T - the type of the first argument to the function

U - the type of the second argument to the function

R - the type of the result of the function

还有像 IntFunction<R>这种接口,因为它已经规定了接收值得数据类型为int,因此只要确定返回值类型 R即可;和IntFunction<R>相对的是 ToIntFunction<T>接口,它规定了返回值的数据类型为int,因此我们要确定其接收值得数据类型T

这里我们创建了 一个 Lambda函数map,其作用就是接收一个String类型的字符串并返回Integer类型的该字符串的长度。

1
2
3
4
5
6
7
public class LambdasDemo {
public static void show(){
Function<String,Integer> map = str->str.length();
var length = map.apply("Sky");
System.out.println(length);
}
}

Composing Functions

因为 Function接口也有 andThen()方法,所以我们也可以链式使用 Function接口。

这里我们 同样定义了两个 lambda函数,第一个是将字符串中的’:’替换成’-‘。第二个是在字符串外面添加花括号。

我们有两种方式实现链式Function

我认为第一种更加直观:

1
2
3
4
5
6
7
8
9
10
public class LambdasDemo {
public static void show(){
Function<String,String> replaceColon = str -> str.replace(":","-");
Function<String,String> addBraces = str -> "{"+str+"}" ;
var result = replaceColon
.andThen(addBraces)
.apply("key:value");
System.out.println(result);
}
}

第二种:

1
result = addBraces.compose(replaceColon).apply("Key:Value");

打印后得到: {key-value}

The Predicate Interface

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/Predicate.html

我们使用这个接口来筛选数据。最重要的就是这个 test()方法,它会判断t是否符合某些条件。

此外还有 BiPredicate<T,U>,用来检测传入的两个参数是否符合某些条件;IntPredicate 它只接受Integer类型的数据并返回一个布尔值。

1
2
3
4
5
6
7
8
public class LambdasDemo {
public static void show(){
Predicate<String> isLongerThan5 = str -> str.length() >5;
var result = isLongerThan5.test("sky");
System.out.println(result);

}
}

这里我们设计了一个Lambda函数用来判断输入的String类型的字符串的长度是否大于5.

打印得到 false

Combining Predicates

将 Predicates结合起来又有些不太一样,因为这些都是条件。因此我们可以将两个条件通过 and(),or() 变成一个新的 Predicate,比如说:

1
2
3
4
5
6
7
8
9
public class LambdasDemo {
public static void show(){
Predicate<String> hasLeftBrace = str->str.startsWith("{");
Predicate<String> hasRightBrace = str->str.endsWith("}");

var hasBothBrace =hasLeftBrace.and(hasRightBrace);
var eitherOneBrace = hasLeftBrace.or(hasRightBrace);
}
}

还有一个方法,叫做 negate(),也就是将条件取反变成新的条件,如 hasLeftBrace.negate()

1
2
System.out.println(hasBothBrace.test("{sss"));		// false
System.out.println(eitherOneBrace.test("{sss")); // true

The BinaryOperator Interface

现在我们来介绍一种特殊的函数式接口: BinaryOperator<T>

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/BinaryOperator.html

这个接口是一个特殊的 BiFunction()接口,即接受两个参数,并返回一个值,这三个值得类型都必须相同。

比如说这里我们定义一个 BinaryOperator的函数为add,作用是将两个Integer类型的整数相加。我们还可以利用 andThen()将BinaryOperator和Function两个类型的函数复合起来,求两数之和的平方数。

1
2
3
4
5
6
7
8
9
10
public class LambdasDemo {
public static void show(){
// a,b -> a+b -> square
BinaryOperator<Integer> add = (a,b)->a+b;
Function<Integer,Integer> square = a->a*a;
var result = add.andThen(square).apply(1,2);
System.out.println(result);
//结果为9
}
}

The UnaryOperator Interface

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/UnaryOperator.html

UnaryOperator<T>接口是一种特殊的 Function()接口,即接受一个参数并返回一个值,但它们的类型必须相同

1
2
3
4
5
6
7
8
9
public class LambdasDemo {
public static void show(){
UnaryOperator<Integer> square = n->n*n;
UnaryOperator<Integer> increment = n->n+1;

var result = increment.andThen(square).apply(1);
System.out.println(result);//打印的4
}
}

这里我们新建了两个 UnaryOperator 对象,一个用来+1,一个用来求平方,使用andThen()将它们复合

Streams

Java8 引入了 Stream,这可以让你以一种声明的方式处理数据。也就是说,Stream使用了一种类似用SQL 语句从数据库查询数据的直观方式来提供一种对Java集合(collection)运算的表达的高阶抽象。这可以让我们写出高效率的、干净、简洁的代码。

比如:

1
2
3
4
5
6
List<Integer> transactionsIds = 
widgets.stream()
.filter(b -> b.getColor() == RED)
.sorted((x,y) -> x.getWeight() - y.getWeight())
.mapToInt(Widget::getWeight)
.sum();

Imperative vs Functional Programming

首先我们来看命令式编程和函数式编程之间的区别。

下面给出 Imperative Code的例子:我们看到命令式编程完全就是一步一步执行下去的,仿佛就是我们在对计算机下命令。

1
2
3
4
5
6
7
8
9
10
11
12
public static void show(){
List<Movie> movies = List.of(
new Movie("a",10),
new Movie("b",15),
new Movie("c",20)
);
// Imperative Programming
int count = 0;
for(var movie : movies)
if(movie.getLikes()>10)
count++;
}

现在我们用函数式编程来重写上面这段命令式编程的代码:

1
2
3
var count2 = movies.stream()
.filter(movie -> movie.getLikes()>10)
.count();

命令式编程更像是我们告诉电脑应该怎么做:循环,判断;而stream则是直接show出来它做了什么:filter+count

它就好比是对Collection中的元素流通过管道,并在管道中进行筛选、分流、聚合等操作,最终得到我们想要的结果。

Creating a Stream

我们可以从这几处来创建流:

  • From collections
  • From arrays
  • From an arbitrary number of objects
  • Infinite/ finite streams

Stream 提供了新的方法 ‘forEach’ 来迭代流中的每个数据

1
2
3
4
5
6
7
8
9
10
11
12
public static void show(){
//从数组中创建流
int [] numbers = {1,2,3};
Arrays.stream(numbers)
.forEach(n -> System.out.println(n));
//从随机数中创建流
var stream = Stream.generate(()->Math.random());
stream
.limit(3)//这里如果不做限制,会有无限多个随机数产生
.forEach(n -> System.out.println(n));

}

Mapping Elements

map 方法用于映射每个元素到对应的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void show(){
List<Movie> movies = List.of(
new Movie("a",10),
new Movie("b",15),
new Movie("c",20)
);
//最基础的就是map方法,他获取movies中的每个元素并取得他们的标题,然后输出
movies.stream()
.map(movie -> movie.getTitle())
.forEach(name-> System.out.println(name));
//mapToInt则是将得到的数据全部转换为 int 类型的数据,同样还有mapToDouble
movies.stream()
.mapToInt(movie -> movie.getLikes())
.forEach(likes -> System.out.println(likes));

}

此外还有flatmap()方法,The flatMap() operation has the effect of applying a one-to-many transformation to the elements of the stream, and then flattening the resulting elements into a new stream.

首先我们用 of()创建一个含有两个 List 集合的流,然后让其打印元素。

然后我们让其调用flatMap,让 传入的List集合扁平化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var stream = Stream.of(List.of(1,2,3),List.of(4,5,6));
stream.forEach(list -> System.out.println(list));
/* 打印得
[1, 2, 3]
[4, 5, 6]
*/
stream
.flatMap(list -> list.stream())
.forEach(n -> System.out.println(n));
/* 打印得
1
2
3
4
5
6
*/
//注意,如果我们调用的是map(),那么打印出来的是这个流对象,而不是流中的元素:
/*
java.util.stream.ReferencePipeline$Head@3a71f4dd
java.util.stream.ReferencePipeline$Head@7adf9f5f
*/

Filtering Elements

filter 方法用于通过设置的条件过滤出元素, 这里我们要分清 stream的两种类型的操作:Intermediate Operation 以及 Terminal Operation

Intermediate Operation 就是 map()、filter() 之类的操作,它们会继续返回一个stream供我们后续操作

Terminal Operation 的例子就是 forEach(),它会直接在终端输出结果

如果只使用 Intermidiate Operation那么终端中什么都不会输出。为了使filter更加利于理解,我们可以将筛选条件单独定义成一个变量,如下面这个例子:

注:Predicate <T> 接口是一个函数式接口,它接受一个输入参数,然后返回一个布尔值结果。该接口用于测试对象是 true 或 false。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void show(){
var movies = List.of(
new Movie("a",10),
new Movie("b",15),
new Movie("c",20)
);
// m 是一个参数传递到Predicate 接口的 test 方法
// 如果 m.getLikes()>10,那么就会返回 true
Predicate<Movie> isPopular = m -> m.getLikes() > 10;
movies.stream()
.filter(isPopular)
.forEach(movie -> System.out.println(movie.getTitle()));
}

Slicing Streams

slicing stream是一个大类别,包含了 limit(n)skip(n)takeWhile(predicate)dropWhile(predicate) 这些方法

limit(n)

顾名思义,就是只限制 n 条数据,如下:

skip(n)

顾名思义,就是跳过前n条数据。

假设现在有1000条电影数据,每10条一页,我想看第三页的数据,应该怎么编写代码:

1
2
3
4
movies.stream()
.skip(20)
.limit(10)
.forEach(m -> System.out.println(m.getTitle()));

takeWhile(predicate)

这个方法传入一个实现了 predicate接口的 lambda表达式,用来筛选满足条件的数据。但是注意了,这个和filter是不一样的。filter是筛选所有满足条件的数据,而takeWhile()方法则是一碰到不符合条件的数据就立即停止。

上面的例子中,虽然第三条数据的 likes<15,但是 takeWhile()在遇到第二条数据的时候就已经停止了筛选。

dropWhile(predicate)

dropWhile则和takeWhile恰好相反。就是去除掉那些符合条件的数据,直到遇到第一条不满足条件的数据为止。比如说刚才一模一样的代码,将takeWhile改成dropWhile,就会打印 b和c,因为会把a去除掉,而遇到b的时候就停止筛选了

Sorting Streams

我们之前介绍了 ComparableComparator接口,是用来对对象进行排序的方法。

现在在stream中我们可以简化写法:

1
2
3
4
movies.stream()
.sorted((a,b)->a.getTitle().compareTo(b.getTitle()))//原来我们这么写
.sorted(Comparator.comparing(Movie::getTitle))//现在我们可以这么写
.forEach(System.out::println);

如果我们要倒序排列,那么:.sorted(Comparator.comparing(Movie::getTitle).reversed())即可

Getting Unique Elements

我们可以通过 stream中的 distinct()方法来获得集合中非重复的数据,比如说:

Peeking Elements

peek和map 有点相似,但是peek接收的是一个 Consumer,而map接受的是一个Function。

Consumer是没有返回值的,它只是对Stream中的元素进行某些操作,但是操作之后的数据并不返回到Stream中,所以Stream中的元素还是原来的元素。

而Function是有返回值的,这意味着对于Stream的元素的所有操作都会作为新的结果返回到Stream中。

我们常常用 peek()来debug我们的程序,因为它不会对Stream的元素作任何操作,又不是一个Terminal的操作会把Stream终止。

通过下面这个例子,我们能更深刻的理解 peek和map之间的差别了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StreamsDemo {
public static void show(){
var movies = List.of(
new Movie("a",10),
new Movie("b",15),
new Movie("c",20)
);
movies.stream()
.filter(m->m.getLikes()>10)
.peek(m -> System.out.println("filtered: "+m.getTitle()))
.map(m->m.getTitle())
.peek(t -> System.out.println("mapped: "+t))
.forEach(System.out::println);
}
}

一开始我们筛选出了集合中点赞数大于10的数据,这里是两个Movie对象b和c

然后我们通过peek对其进行了一个输出

之后我们通过map将对象映射成他们的名字了,因此现在stream中只有两个字符串 b和c

然后我们再通过peek对其进行输出,这时候我们直接打印t和上面那样调用 getTitle()的效果是一样的,因为map已经对流进行了映射。

Simple Reducers

刚才我们讲的一系列操作,目的是创建客制化的管道。接下来我们来讲Reducer,其目的就是将流中的元素直接变成一个对象。比如说count(),它直接返回集合中的元素数量;

anyMatch(predicate), 返回布尔值,只要含有符合条件的就返回true;

allMatch(predicate)以及 noneMatch(predicate),逻辑和 anyMatch相同

findFirst() 返回 Optional 类,本质上,这是一个包含有可选值的包装类,这意味着 Optional 类既可以含有对象也可以为空。我们可以通过get()获取Optional中的对象本体。findFirst也就是返回集合中的第一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
public static void show(){
var movies = List.of(
new Movie("a",10),
new Movie("b",15),
new Movie("c",20)
);

var result= movies.stream()
.findFirst()
.get();
System.out.println(result.getTitle());
}

findAny()findFirst()逻辑相同,只不过是返回任意一个集合中的对象。

max(comparator) 这个方法需要接受一个 comparator 对象作为比较的依据. 用来返回拥有 最大参数的对象

1
2
3
4
var result= movies.stream()
.max(Comparator.comparing(Movie::getLikes))
.get();
System.out.println(result.getTitle());

min(comparator)和max()的逻辑相同。

Reducing a Stream

Collectors

Grouping Elements

Partitioning Elements

Primitive Type Streams

Concurrency and Multi-threading

Processes and Threads

我们在CSAPP中已经了解了进程和线程的关系。在一个进程中,一定有一个主线程,还可以有其他支线程。比如说我们在迅雷中一次下载了三个文件,那么这三个文件就可以占据三个线程。

现在我们来看一下关于这台电脑中关于线程的信息:

1
2
3
4
5
6
7
public class Main {

public static void main(String[] args) {
System.out.println(Thread.activeCount());
System.out.println(Runtime.getRuntime().availableProcessors());
}
}

第一句是打印当前这个项目使用的进程数,下面一句打印当前电脑总的可用线程数。

因为 m1 是四大核四小盒,这里打印出来的是一共8个进程 。当我在 i7-9750(六核) 上运行时,打印得到12个线程。

Starting a Thread

现在我们来讲怎么创建一个线程。要创建一个线程,我们首先要让一个类引入 Runnable 接口,并在其中重写 run函数。Runnable接口是Java.lang 中一个内置的接口。引入这个接口就代表着这个任务将被在一条线程中执行。这个接口中只有一个函数: run() , 当启动线程开始运作之后,会自动调用 run()函数。

1
2
3
4
5
6
7
8
package com.company.concurrency;

public class DownloadFileTask implements Runnable {
@Override
public void run(){
System.out.println("Downloading a file: "+Thread.currentThread().getName());
}
}

然后,在 ThreadDemo 类中,将一个 DownloadFileTask() 实例传入,并调用 start()函数启动线程。

1
2
3
4
5
6
7
8
9
public class ThreadDemo {
public static void show(){
System.out.println(Thread.currentThread().getName());
for (var i = 0;i<10;i++){
Thread thread = new Thread(new DownloadFileTask());
thread.start();
}
}
}

打印结果如上图所示:首先打印的是当前所在的线程,也就是 main,然后我们进行了一个循环,调用了十个新的线程,并依次打印出他们的名字。

Pausing a Thread

现在我们尝试将一个线程“挂起”一段时间以模仿下载的过程。

要让线程挂起,我们可以使用 sleep() 方法,这里我选择让线程挂起5秒钟(注意,5000是以毫秒为单位,但并不是非常精确的5000毫秒,这和底层操作系统有关系)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DownloadFileTask implements Runnable {
@Override
public void run(){
System.out.println("Downloading a file: "+Thread.currentThread().getName());

try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Download complete: "+ Thread.currentThread());
}
}

如果我直接写 Tread.sleep(5000),那么 idea会报错,我们需要用 try/catch 将其包裹起来。打印结果如下:

如果我们只有一个线程来下载这10个文件,那么就需要用50秒的时间,但是现在我们有10个线程,所以只需要5秒就能完成任务。

如果我们现在要下载成百上千的文件,但是我们电脑没有那么多线程。这时候,就要用到 JVM 中的 Thread Scheduler,这是用来决定 java中每条线程执行的时间。所以当任务量大于线程数的时候,JVM 会执行分时操作。也就是每一个线程都能分到一点时间,让我们用户看起来像是在并行下载。

Joining a Thread

join()方法是Thread类中的一个方法,该方法的定义是等待该线程终止。其实就是join()方法将挂起调用线程的执行,直到被调用的对象完成它的执行。

比如说,一开始我不使用 join(),这样,主线程就运行主进程自己的代码,只是开辟了一条线程运行其他的代码。因此我们看到打印出来的先是主线程的运行结果

但是使用了 join()方法之后,主线程就会等待子线程结束后再运行。比如说:

Interrupting a Thread

有时候我们必须要去终止一个运行中的线程,这时候就需要用到 thread.interrupt() 这个函数了。通常终止一个线程的逻辑是: 调用者发出一个interrupt信号,被调用的线程将对收到的信号做一个判断,如果是interrupt信号,就终止运行中的线程。否则就“充耳不闻”。

1
2
3
4
5
6
7
8
9
10
11
public class DownloadFileTask implements Runnable {
@Override
public void run(){
System.out.println("Downloading a file: "+Thread.currentThread().getName());

for (var i = 0;i< Integer.MAX_VALUE;i++){
if(Thread.currentThread().isInterrupted()) return;
System.out.println("Downloading byte"+ i);
}
}
}

这边我进行一个无限循环,然后判断主线程是否给我发送了一个 Interrupt信号,如果是就return掉,否则就继续打印。

注意了,如果线程正在挂起时向其发送 interrupted 信号,这样是会报错的。因此我们在用 thread.sleep() 的时候,需要用 try-catch 来包裹。

Concurrency Issues

在编写并行程序的时候会遇到一些问题:

  1. 当很多不同的线程共用一个对象的时候,对对象的某些参数进行修改会导致“堵车”。这就好比三个人像同时吃掉一个汉堡。我们将这种情况叫做 “Race Condition”,
  2. 另一种情况就是,当一个线程对一个对象进行了修改,但是修改后的内容仅它自己可见,那么不同线程就会看到一个对象不同的状态。我们将这种情况叫做 ”Visibility Problem”

我们必须要写出 Thread-safe Code 来规避这些问题。在很多Java的文档中,对一个类的描述是 Thread Safe 也就是这个类可以再很多并行的线程中使用。

Race Conditions

当很多线程都想修改一个对象的时候,就出现了竞争关系。

比如说我有以下代码:

ThreadDemo类

在这个调用的类当中,我们创建一个线程数组,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.company.concurrency;

import java.util.ArrayList;
import java.util.List;

public class ThreadDemo {
public static void show(){
var status = new DownloadStatus();
List<Thread> threads = new ArrayList<>();
for(var i = 0;i<10;i++){
var thread = new Thread(new DownloadFileTask(status));
thread.start();
threads.add(thread);
}
//将所有的线程都打开后,我们还需要等待所有的线程都停止。这里使用一个循环来解决。
for (var thread : threads){
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(status.getTotalBytes());
}
}

DownloadFileTask

在这个Run函数当中,我们做一个10000次的循环,每一次循环都调用status对象的 incrementTotalBytes()函数。用来模拟下载一个 10000 bits的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.company.concurrency;

public class DownloadFileTask implements Runnable {
private DownloadStatus status;

public DownloadFileTask(DownloadStatus status) {
this.status = status;
}

@Override
public void run(){
for (var i = 0;i< 10_000;i++){
if(Thread.currentThread().isInterrupted()) return;
status.incrementTotalBytes();
}
}
}

DownloadStatus

在这个类中,有一个下载总比特数的私有变量,当有线程中的对象调用incrementTotalBytes()的时候,totalBytes就会自增1

1
2
3
4
5
6
7
8
9
10
11
12
package com.company.concurrency;

public class DownloadStatus {
private int totalBytes;

public int getTotalBytes(){
return totalBytes;
}
public void incrementTotalBytes(){
totalBytes++;
}
}

在预期的情况下,我们打开了10 个线程,每个线程都会下载10_000比特的数据,那么totalBytes的结果应该是100_000, 但是我们多次运行之后,一直都是八九万,并没有到十万。这是因为发生了Race condition,线程在互相争抢修改同一个数据的时候,会发生数据丢失。

当我们调用 incrementTotalBytes() 的时候,电脑会从内存中找到totalBytes的值,然后存储至cpu,然后cpu对值进行加1操作;操作结束后,这个值会被重新存储至内存中。

那么现在假设两个线程同时读取了 totalBytes然后对其进行加1操作,这时候,CPU也只是会将totalBytes加上1而已,并不会加上2。这就是导致了刚才的数据丢失。

Strategies for Thread Safety

我们有一些写出 Thread Safe Code 的策略:

  • Confinement

这个概念很简单,就是原本是多个线程操作一个对象,现在变成了每个线程都操作属于它自己的对象,最后将对象中的值加起来就得到最后的总值。

  • Immutability

这个操作更加直白,就是我们将要操作的对象变成不可改变的。比如说 String 对象就是 Immutable 的,因为当我们对一个 String 对象进行修改的时候会创建一个新的String,之前的String并不会遭改变

  • Synchronization

同步操作,这使得同一个对象在不同的线程之间可以协调、同步。我们可以利用lock以及Synchronize关键词来实现这个操作.

我们使用锁将”有争议的部分”锁起来,一次只能让一个线程来访问,这样就能做到隔离的效果。然而,这样很容易造成死锁,因此不推荐使用

  • Atomic Object

原子对象。之前 若要对totalBytes 进行修改需要进行3个步骤,但是当我们使用原子对象的时候,只需要一个步骤即可,这就防止两个线程同时操作一个对象的情况出现了。

  • Partitioning

中文叫分区

Confinement

现在我们重构刚才那段代码。之前发生Race condition的时候,我们只创建了一个 DownloadStatus 对象,十个线程都对一个 totalBytes 变量进行操作,因此它们开始争夺。

我们现在要做的就是“隔离”,简单来说就是给每一个thread都新建一个DownloadStatus,线程的操作只对它自己的DownloadStatus中的TotalBytes进行操作,最后将这十个 TotalBytes变量累加得到最后答案。

因为我们要对每一个线程新建一个下载任务,因此我们还要一个 列表来保存这些Tasks。并且我们不再需要向DownloadFileTask 构造器传入status参数了,因为我们要对每一个Task新建一个DownloadStatus对象 ,但是我们要在 DownloadFileTask类中为status做一个getter,方便外界获取当前的下载状态。

现在我们重构 ThreadDemo 类,因为我们要为每一个线程单独设一个DownloadFileTask,因此为了方便将其中的TotalBytes相加,我们要新建一个List对其进行管理。并在创建的时候将每一个Task加入到数组当中去。最后我们用 Stream 将tasks数组中的所有任务中的totalbytes相加,得到最终结果。

打印得: Optional[100000],即10个线程的下载总和,一个Byte都没丢掉

Locks

上面我们说了隔离,这里我们再提供一种方案。就是设计一个锁,使得同一个对象在同一时间只能被访问修改一次。当一个线程想要正在修改对象的时候,就把这个对象锁起来,别的线程都无法访问。

根据上面的信息,我们现在要在 DownloadStatus上加一个锁。首先我们声明一个lock:

private Lock lock = new ReentrantLock() 也就是一个可重入锁对象。reentrant 锁意味着什么呢?简单来说,它有一个与锁相关的获取计数器,如果拥有锁的某个线程再次得到锁,那么获取计数器就加1,然后锁需要被释放两次才能获得真正释放

然后我们在调用 incrementTotalBytes 的时候先上一个锁,等自增1结束后再解锁。这就好比一个人进了酒店房间办事,然后把门给锁了,办完事后再把门打开。

但是为了程序的正常运行,我们需要用 try-finally block ,因为如果在 totalBytes++的时候抛出了一个异常(我们当然知道自增1不会抛出异常,但是在未来我们自己的程序中这可能是一段很复杂的代码,因此必须要try),那么这个锁就永远无法打开了,会导致死锁。因此我们要保证 lock.unlock() 在任何情况下都能执行。

打印结果:100000

The synchronized Keyword

要让线程之间同步,我们还可以使用 synchronized 关键词。这样我们就不用很麻烦的先锁住、然后再解锁了。

但是Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要。

让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized逻辑封装起来。

比如我们现在就要用synchronized来封装totalBytes++

1
2
3
4
5
public void incrementTotalBytes(){
synchronized (this){
totalBytes++;
}
}

这样一来,线程调用incrementTotalBytes方法时,它不必关心同步逻辑,因为synchronized代码块在incrementTotalBytes方法内部。并且,我们注意到,synchronized锁住的对象是this,即当前实例,这又使得创建多个DownloadStatus实例的时候,它们之间互不影响,可以并发执行。

当我们锁住的是 this实例的时候,实际上可以用 synchronized来修饰这个方法,因此这两种方法是等价的:

1
2
3
public synchronized void incrementTotalBytes(){
totalBytes++;
}

因此,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁。不能对其他实例加锁。

但是,对 this 实例加锁也是有缺点的。比如说:我又新建了一个totalFiles变量来记录已下载完成的文件总数。因为文件一多,很可能是两个文件同时下载完成的,因此我们也需要用 synchronized关键字来修饰

那么问题来了:incrementTotalBytsincrementTotalFiles这两个方法都给 this对象上了锁。那么如果存在某一个时刻,要同时调用这两个方法的时候,必须等其中一个方法运行完之后把this对象解锁了之后才可以继续执行另一个方法。如果这只是一个小型应用,也许没事;但是如果这个应用非常庞大,需要上锁的参数非常多,那么同时调用的时刻会很多,会造成不必要的等待、降低程序的性能。

为了解决这个问题,我们可以给每一个需要上锁的变量新建一个专属对象。并用这个对象传入synchronized关键字。如下图所示:

我们创建了两个Object类型的对象,一个叫totalBytesLock用来锁住totalBytes; 以及totalFilesLock用来锁住totalFiles 变量。

在今后的开发中我们最好选择变量的专属对象来上锁,而不要一直使用this对象

The volatile Keyword

https://blog.csdn.net/u012723673/article/details/80682208

Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地缓存无效,当一个线程修改共享变量后他会立即被更新到主存中,其他线程读取共享变量时,会直接从主内存中读取

我们先来看一个没有 volatile 版本的代码,分析一下里面有什么错误:

首先我们在 DownloadStatus 中新建一个 isDone布尔变量,来表明这个下载任务是否已经完成。并设定一个 getter返回isDone 和一个 setterisDown设为True

然后在DownloadFileTask类中,我们在下载结束后调用 status.done()isDone() 设置为True并输出Download complete

最后在ThreadDemo类中,新建两个线程,第一个线程传入DownloadFileTask对象,第二个线程里面是个 Lambda表达式,它会一直询问status中的变量isDone是否为True,一直到下载完成 ,isDone==True,才会跳出循环并输出totalBytes的值。

我们运行这个demo,却发现程序迟迟不打印totalBytes的值,事实上如果我们不关闭这个程序,它就会一直运行下去。

为什么会发生这种事情?原因就在于 thread1thread2两个线程之间并没有完全同步,我们注意到虽然totalBytes是通过 synchronized关键字修饰的,但是 isDone 并没有同步。因此在thread2看来,isDone始终是False。这种不可见性要从底层的JVM优化机制cache开始说起:

有一个变量,存储在主存中,值为1。现在CPU的两个核分别执行一条线程,将这个变量从主存中读入到CPU当中去,存储在不同的cache中,因为从cache中读取数据要比从主存中读取快得多。但是这两个CPU之间并不知道对方的cache中存的这个变量的值。因此,当CPU1将cache中的变量从1修改到2的时候,CPU2看到的该变量仍然是1,就算CPU将该变量回写到主存当中去,CPU2的cache中因为已经存储了该变量,因此仍然看不到改变后的结果。这就是多线程的不可见性

一种可行但是不建议的方法就是将 DownloadStatus中的isDone()done()方法都使用synchronized关键字修饰。但是我们有更好的方法——volatile

在一开始我们也说了volatile的原理,那就是告诉JVM,我这个变量是随时会变的,是不稳定的。你每次访问必须从主存当中去读取,不能从cache中去读取。

Thread Signalling with wait() and notify()

有时候我们会使用无限循环询问一个变量是否发生变化,比如刚才例子中,一直询问下载完成没。但是这样是很占用CPU的资源的。它可能会重复循环上亿次才能等到结果。

为了优化上面这种情况,我们可以用wait()notify()方法

顾名思义,调用wait()方法后,线程进入等待状态,wait()方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()方法才会返回,然后,继续执行下一条语句。注意,只能在锁对象上调用wait()方法notify()则是在相同的锁对象上作用,完成某件事后发出一个信号,让wait()去接收

比如下面这个例子,当我们要用while来询问isDone()是否为true的时候,我们对status上了一个锁。然后在里面调用wait()让线程2沉睡。再跑到DownloadFileTask类中,当下载完成时我们在 status上锁了的情况下调用 notifyAll()发出讯号。wait()收到后就会跳出循环,执行打印命令。

通过这种机制我们可以降低CPU的负荷,优化程序性能。但同时,在不正确的地方使用wait()notify()可能会造成很多难以解决的问题,因此我们不推荐这种方法。

Atomic Objects

https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/concurrent/atomic/package-summary.html

Atomic object翻译过来就是原子对象,顾名思义,这个对象是不可分割的(不要用夸克抬杠) 。 我们之前做的所有努力,就是想避免多个线程totalBytes++产生竞争关系,因为totalBytes++需要进行三步操作,并不是原子化的。

ava的java.util.concurrent包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic包。

我们以AtomicInteger为例,它提供的主要操作有:

  • 增加值并返回新值:int addAndGet(int delta)
  • 加1后返回新值:int incrementAndGet()
  • 获取当前值:int get()
  • 用CAS方式设置:int compareAndSet(int expect, int update)

现在我们要做的就是将totalBytes++原子化,回到最初发生竞争状态的那段代码,我们要对DownloadStatus中的totalBytes进行修改,将其变成 AtomicInteger类型的数据。这时候就不能直接 return totalBytes 了,一定要调用 get()来获取当前的值;此外还要把++变成 incrementAndGet(),即自增1后返回。

打印结果为: 100_000

原子类的实现原理就是compare and swap(CAS), 比如说我调用 incrementAndGet 的时候原子类型的数据就会比较当前值和期望值,如果他们不相等,就进行交换操作。

使用java.util.concurrent.atomic提供的原子操作可以简化多线程编程:

  • 原子操作实现了无锁的线程安全;
  • 适用于计数器,累加器等。

Adders

虽然说原子类适用于计数器、累加器等,但是当有多个线程同时对一个对象进行累加操作的时候,我们更推荐使用Adder类,它同样可以实现原子化,但是在高并发的情况下,其速度会比 Atomic更快, 简单来说,Adder具有更高的吞吐量。

原理:参考这篇博客

在LongAdder类中,我们使用 intValue()来获得当前totalBytes的值并返回为int 类型,同时在自增1的时候调用increment()方法。 总的来说和 Atomic 类是类似的。

Synchronized Collections

刚才我们讲的都是关于某一个变量的同步,现在我们来学对于一个集合的同步。

如果我们创建一个普通的 ArrayList集合,然后创建两个线程向集合中添加元素。有可能它们会发生 Race condition 导致数据丢失,为了规避这种情况的发生我们可以使用 Synchronized Collection:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ThreadDemo {
public static void show(){
//使用了 synchronizedCollection 能规避线程之间的进制,保持线程之间的同步
Collection<Integer> collection =
Collections.synchronizedCollection(new ArrayList<>());

var thread1 = new Thread(()->{
collection.addAll(Arrays.asList(1,2,3));
});
var thread2 = new Thread(()->{
collection.addAll(Arrays.asList(4,5,6));
});

thread1.start();
thread2.start();

try{
thread1.join();
thread2.join();
}catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(collection);

}
}

Concurrent Collections

当并发高的时候,使用 Synchronized Collection 会导致CPU占用过高、性能下降。这时候我们可以用 Concurrent Collection

Concurrent官方文档 中,我们看到有 ConcurrentHashMap,ConcurrentLinkedDeque等并发集合

以Map为例,假如我们要对一个HashMap进行高并发的操作,我们就可以使用 ConcurrentHashMap类。事实上,ConcurrentHashMapHashMap都是对 Map接口的实现类。

1
2
3
4
5
6
7
8
public class ThreadDemo {
public static void show(){
Map<Integer,String>map = new ConcurrentHashMap<>();
map.put(1,"a");
map.get(1);
map.remove(1);
}
}

The Executive Framework

Introduction

Thread Pools

Executors

Callables and Futures

Asynchronous Programming

Completable Futures

Creating a Completable Future

Implementing an Asynchronous API

Running Code on Completion

Handling Exceptions

Transforming a Completable Future

Composing Completable Futures

Combining Completable Futures

Waiting for Many Tasks to Complete

Waiting for the First Task

Handling timeouts

Project- Best Price Finder

Solution- Getting a Quote

Solution- Getting Many Quotes

Solution- Random Delays

-------------本文结束,感谢您的阅读-------------