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 class
和Error
两个类。 Exception 类中又包括 RuntimeException class
也就是unchecked Exception
Catching Exceptions
对于 checked exception, 我们可以用 try-catch
代码块来捕捉错误:
这样既可以打印 StackTrace来帮助我们找到错误,又可以让整个程序正常结束。最终,问题出在 Java.io库中的open0函数。
Catching Multiple Types of Exceptions
1 | public class ExceptionsDemo { |
如果我们想要捕捉多个 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 | public class ExceptionsDemo { |
finally在try代码块正常被进入执行,jvm正常执行处理的情况下,是一定会被执行的。 如果在 try block中存在return;那么finally的执行时间是:retrun表达式执行之后,在return返回操作之前。
The try-with-resources Statement
但是像刚才那样写finally block 会比较丑。我们有更好的方法来实现: 就是将申明对象、打开文件都放到 try后面的括号当中,如下:
1 | public class ExceptionsDemo { |
这种方法叫做 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 | package com.company.exceptions; |
然后在 Account 类当中,当取款大于余额的时候,我们会向上抛出一个 AccountException类,并在这个类中传入原因:InsufficientFundsException,告诉调用者这是因为余额不够导致的账户异常。这就是 chain exception
1 | public class Account { |
在ExceptionsDemo类中,我们捕捉一个 AccountException类,然后通过 e.getCause()
获取AccountException中的异常类型并通过getMessage()
打印出改原因的异常信息。
1 | public class ExceptionsDemo { |
Generics
泛型就是参数化类型
- 适用于多种数据类型执行相同的代码
- 泛型中的类型在使用时指定
- 泛型归根到底就是C++中的“模版”
The Need for Generics
比如我创建了一个 List()
类,如下:
那么如果我想创建一个 User 类的List,就要创建一个新的 UserList 类,久而久之,类就变得很繁杂。这时候我们就需要用到泛型了。
A Poor Solution
遇到这种情况,使用 Object Class
是一种下策。因为虽然 所有的类都继承自Object类,但是有很多缺点
- 比如我向Object List中存放了很多的元素(如Integer.valueOf(1)), 现在我想通过get 取出 List当中的第一个元素,但是这时候返回的类型是 Object,要得到int类型的返回结果我们必须进行强制类型转换,否则就会造成报错。
- 此外,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 | public class User implements Comparable<User>{ |
注意了,这里要在 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 | package com.company.generics; |
在main函数中,我们就可以对两个User实例进行比较
1 | import com.company.generics.User; |
Generic Methods
除了泛型类,还有泛型方法。 在使用限定词的时候,类是 implements, 而方法则是 extends
比如我创建一个 Utils 类,里面有一个max方法,用来返回两个对象中较大的那一个:
1 | package com.company.generics; |
在函数定义时,需要在尖括号中写 <T extends Comparable<T>>
返回数据类型也为T,然后利用compareTo
函数来比较二者的大小:
1 | public static void main(String[] args) { |
结果输出的却是这样的结果:com.company.generics.User@3f99bd52
是因为返回的对象调用 toString()函数,因此输出了一个hashcode,要解决这个问题,我们需要在 编写 User类的时候重写 toString()
函数
1 | public class User implements Comparable<User>{ |
再次运行得到: User{points=20}
Multiple Type Parameters
不管是泛型类还是泛型方法都可以传入多种不同类型的参数,我们各给出一个例子:
泛型类
这里我们创建了一个 键值对的类,K 代表key的数据类型,V代表value的数据类型
1 | package com.company.generics; |
同样的,对于方法我们也可以使用多个类型
1 | public static <T,V> void print(T key,V value){ |
Generic Classes and Inheritance
当我们用一个类来继承泛型类的时候,关系可如下图所示:
也就是说Integer继承了Number,在一般的实例中,Number 是可以接收 Interger类型的参数的;但是在泛型类的实例中 如Box<Number>
只能接收数据类型为Number的数据,不接受 Box<Integer>
和 Box<Double>
的参数。
所以说在泛型类中,虽然传入的类之间存在继承关系,但是他们的泛型类是不存在继承关系的。原因在于它们都继承自Object类型,但是二者相互独立。
我们用刚才的例子看一下:
首先我们创建一个 Instructor
类,并让其继承自User
类
1 | package com.company.generics; |
然后我们在Utils中声明两个方法,一个是普通的 printUser
方法,另一个则是接收 GenericList<User>
的printUsers
方法
1 | public static void printUser(User user){ |
结果在 main 函数当中,可以用printUser来打印一个 Instructor实例,但是不能用 printUsers来打印一个 GenericList<Instructor>
实例:
为了解决这个问题,我们可以用Wildcards(术语叫通配符,其实就类似于扑克中的万能牌)
Wildcards
我们可以将 printUsers方法这样写:问号就是通配符,代表着一个unknown的数据类型。
1 | public static void printUsers(GenericList<?> users){ |
这样虽然解决了问题,但是这样一来,我们可以向 printUsers
传入任何数据类型,比如Integer,String等,把他们当做Users打印出来显然是不行的。因此在使用通配符之后还需要加上限定条件
? + extends
当我们使用了通配符 ?之后,相当于java创建了一个我们看不见的 类叫做 CAP#1
用来存放未知的数据类型。 因为可能有多个通配符,所以CAP后面的编号也不同。
如果我们用 ?+extends User
的话,就相当于 Class CAP#1 继承了User,而 Instructor 也是继承User的,因此这时候可以向printUsers传入 User以及它的子类,而不能传入 Integer、String这种数据类型了。
1 | public static void printUsers(GenericList<? extends User> users){ |
比如这里我们可以用 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
提到集合就不得不提一下数组,好多集合底层都是依赖于数组的实现。数组一旦初始化后,长度就确定了,存储数据对象不能达到动态扩展,其次数组存储元素不便于对数组进行添加
、修改
、删除操作
,而且数组可以存储重复元素。这个时候集合对作用显现出来了。集合分为Collection
和Map
两种体系。Collection的集合类的继承树如下图所示:
化简可得:
Collection 接口有 3 种子类型集合: List
、Set
和 Queue
,再下面是一些抽象类,最后是具体实现类。常用的有 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 | public class GenericList<T> implements Iterable<T>{ |
在main函数中,我们用iterator来接收迭代器,这里返回的是一个String 类型的迭代器。
iterator中两个内置方法很重要,一个是 hasNext(), 也就是用来判断是否存在下一个元素;还有一个是next()即让迭代器指向下一个元素。使用while循环,可以遍历对象中的所有元素。(虽然hasNext()和next()我暂时还没有重写)
1 | public static void main(String[] args) { |
其实,while循环可以这样来简化:
1 | for (var item : list){ |
在底层的bytecode都是一样的。
使用了Iterable接口,我们就没有必要在GenericList类中将 private T[] items
设置成 public T[] items
,照样可以迭代。
The Iterator Interface
现在我们继续实现我们自建的 GenericList,刚才只是说了我们可以实现什么功能,但并没有将方法都写出来。
首先我们要在 GenericList
中创建一个新的类,叫做ListIterator
, 这个类讲接入 Iterator
接口并重写 hasNext()
、next()
两个函数。并让GenericList
中的 iterator
方法的返回一个 ListIterator
对象
1 | public class GenericList<T> implements Iterable<T>{ |
这样,一个建议的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 | public class CollectionsDemo { |
但是要注意了, collection是不支持通过索引访问的,即我们不能使用中括号或者 add(index,element)
这种方法来添加元素的。
The List Interface
1 | public class ListDemo { |
The Comparable Interface
在泛型那章已经讲过 Comparable Interface了,一般来说我们自定义的类如果要实现Comparable接口的话,一般需要重写 compareTo 和 toString 两个方法,如下面这个比较字符串的 Customer类
1 | package com.company.collections; |
The Comparator Interface
现在我们虽然实现了 Customer的排名,但实现起来却比较的麻烦,需要在类内实现。这时Comparator这个接口就可以在类外实现对象的比较。
简单来说,Comparable就是定义一个单独的对象比较器,继承自Comparator接口,实现compare()方法
比如现在 Customer 中多了一个 Email 参数,然后我们就新建一个 EmailComparator
类如下:
在这个类中我们重写 compare方法并按照email进行排序
1 | package com.company.collections; |
然后在main函数中我们对Customer数组进行一个排序
1 | public static void main(String[] args) { |
打印得到 [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 | public static void show(){ |
The Set Interface
https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/Set.html
和C++一样,Set 是不包含重复的元素的. 如果把一个ArrayList放到Set当中,也会将其变成一个不重复的对象:
1 | public static void main(String[] args) { |
现在来介绍一些Set中的方法
set其实就是一个集合,数学上集合的操作在Java中都有对应的方法。
比如 并,就可以用 addAll()
方法求得
交,可以用 retainAll()
方法求得
A-B, 可以用 removeAll()
方法求得
1 | Set<String> set1 = new HashSet<>(Arrays.asList("a","b","c")); |
The Map Interface
在Java和C++中,哈希表可以用 Map接口来实现,在C#、python中,可以用Dictionary(词典)来实现
1 | var c1 = new Customer("a","e1"); |
但是注意了,map是不能够被迭代的,因此我们没用办法用for each loop来直接遍历map,但是我们有其他的方法:
我们可以使用map内置的 entrySet方法来获得每一组键值对
1 | for (var entry : map.entrySet()) { |
当然,也可以使用map.values()
直接获得value
1 | for (customer : map.values()) { |
Lambda Expressions
Functional Interfaces
函数式接口,是指内部只有一个抽象方法的接口。注意,只能有一个,并且是抽象的方法
比如说我声明一个 Printer
接口:
1 | package com.company.lambda; |
然后用一个ConsolePrinter
类来实现这个接口:
1 | package com.company.lambda; |
最后在LambdasDemo类中将实例传入到参数为接口的greet()方法中,完成打印。
1 | package com.company.lambda; |
这与我们之前Java基础2中关于接口的操作思路一样,但是有时候我们并不想创建这样一个功能如此特殊的类来实现接口,因为用一次以后可能再也不会使用了。因此接下来我们要来介绍匿名内部类。
Anonymous Inner Classes
匿名内部类就是没有名字的、方法内部的类,通常用来简化代码的编写。
匿名内部类的使用场景: 我们只使用一次接口并用来实现某些特殊的功能的时候
匿名类是不能有名称的类,所以没办法引用它们。必须在创建时,作为new语句的一部分来声明它们。
比如:
1 | package com.company.lambda; |
匿名内部类,虽然已经方便了许多。但是更好的方法是使用 Lambda Expression
Lambda Expressions
Lambda 表达式的作用就像是一个匿名内部类,但是又不属于类。比如说我们重写刚才的匿名内部类代码:
1 | public static void show(){ |
也就是说使用 Lambda expression 可以代替一个类。在这里我们甚至不需要写 message
的数据类型,因为当我们使用Lambda表达式的时候Java会根据调用它的方法(这里是greet()
)找到对应的接口及其数据类型。
当我们只传入1个参数的时候,参数不需要用括号包裹。但是当我们传入0个或者多个参数的时候,需要使用小括号包裹。
如果这个Lambda Expression花括号中只含有一句代码,那么花括号也可以被省略,如下:
1 | greet(message -> System.out.println(message)) |
Variable Capture
如果我们使用一个匿名内部类,我们可以在类内新建一些变量,但是在 Lambda Expression
中,是不能新建变量的。但是可以使用当前方法中定义的变量,比如:
1 | public static void show(){ |
也可使用当前类中定义的静态变量:
1 | public class LambdasDemo { |
当然,如果要使用非静态变量的话,需要将 show()方法也定义成非静态的。
Method References
方法引用一共有四种,目的是用来简化Lambda表达式的。一般引用格式是: 类名+静态方法名,要求是引用的静态方法跟 Lambda表达式是客观等价的(参数值、参数类型、返回值一致)
比如说刚才的例子中 ,println
方法就和Lambda表达式是客观等价的,因此我们可以直接简化为:
1 | public void show(){ |
当然,我们也可以在类中自定义一个静态函数然后实现函数引用:
在这里我们定义了静态函数 LambdaDemo
并在 greet中引用了它
1 | public class LambdasDemo { |
在上图,第四种方法就是引用一个构造函数。可以使用 类+new
的方法
1 | public class LambdasDemo { |
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 | default Consumer<T> andThen(Consumer<? super T> after) { |
我们发现这个函数的返回值也是一个 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 | public class LambdasDemo { |
这边要注意的是,如果我们不调用 getRandom.get()
,这个Lambda 表达式是不会执行的。这叫做:Lazy evaluation.
和Consumer接口一样,Supplier接口也有多种变形: DoubleSupplier
,BooleanSupplier
,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 | public class LambdasDemo { |
Composing Functions
因为 Function接口也有 andThen()
方法,所以我们也可以链式使用 Function接口。
这里我们 同样定义了两个 lambda函数,第一个是将字符串中的’:’替换成’-‘。第二个是在字符串外面添加花括号。
我们有两种方式实现链式Function
我认为第一种更加直观:
1 | public class LambdasDemo { |
第二种:
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 | public class LambdasDemo { |
这里我们设计了一个Lambda函数用来判断输入的String类型的字符串的长度是否大于5.
打印得到 false
Combining Predicates
将 Predicates结合起来又有些不太一样,因为这些都是条件。因此我们可以将两个条件通过 and()
,or()
变成一个新的 Predicate,比如说:
1 | public class LambdasDemo { |
还有一个方法,叫做 negate()
,也就是将条件取反变成新的条件,如 hasLeftBrace.negate()
1 | System.out.println(hasBothBrace.test("{sss")); // false |
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 | public class LambdasDemo { |
The UnaryOperator Interface
https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/util/function/UnaryOperator.html
UnaryOperator<T>
接口是一种特殊的 Function()接口,即接受一个参数并返回一个值,但它们的类型必须相同
1 | public class LambdasDemo { |
这里我们新建了两个 UnaryOperator
对象,一个用来+1,一个用来求平方,使用andThen()
将它们复合
Streams
Java8 引入了 Stream,这可以让你以一种声明的方式处理数据。也就是说,Stream使用了一种类似用SQL 语句从数据库查询数据的直观方式来提供一种对Java集合(collection)运算的表达的高阶抽象。这可以让我们写出高效率的、干净、简洁的代码。
比如:
1 | List<Integer> transactionsIds = |
Imperative vs Functional Programming
首先我们来看命令式编程和函数式编程之间的区别。
下面给出 Imperative Code的例子:我们看到命令式编程完全就是一步一步执行下去的,仿佛就是我们在对计算机下命令。
1 | public static void show(){ |
现在我们用函数式编程来重写上面这段命令式编程的代码:
1 | var count2 = movies.stream() |
命令式编程更像是我们告诉电脑应该怎么做:循环,判断;而stream则是直接show出来它做了什么:filter+count
它就好比是对Collection中的元素流通过管道,并在管道中进行筛选、分流、聚合等操作,最终得到我们想要的结果。
Creating a Stream
我们可以从这几处来创建流:
- From collections
- From arrays
- From an arbitrary number of objects
- Infinite/ finite streams
Stream 提供了新的方法 ‘forEach’ 来迭代流中的每个数据
1 | public static void show(){ |
Mapping Elements
map 方法用于映射每个元素到对应的结果。
1 | public static void show(){ |
此外还有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 | var stream = Stream.of(List.of(1,2,3),List.of(4,5,6)); |
Filtering Elements
filter 方法用于通过设置的条件过滤出元素, 这里我们要分清 stream的两种类型的操作:Intermediate Operation 以及 Terminal Operation
Intermediate Operation 就是 map()、filter()
之类的操作,它们会继续返回一个stream供我们后续操作
Terminal Operation 的例子就是 forEach()
,它会直接在终端输出结果
如果只使用 Intermidiate Operation那么终端中什么都不会输出。为了使filter更加利于理解,我们可以将筛选条件单独定义成一个变量,如下面这个例子:
注:Predicate <T>
接口是一个函数式接口,它接受一个输入参数,然后返回一个布尔值结果。该接口用于测试对象是 true 或 false。
1 | public static void show(){ |
Slicing Streams
slicing stream是一个大类别,包含了 limit(n)
、skip(n)
、takeWhile(predicate)
、dropWhile(predicate)
这些方法
limit(n)
顾名思义,就是只限制 n 条数据,如下:
skip(n)
顾名思义,就是跳过前n条数据。
假设现在有1000条电影数据,每10条一页,我想看第三页的数据,应该怎么编写代码:
1 | movies.stream() |
takeWhile(predicate)
这个方法传入一个实现了 predicate接口的 lambda表达式,用来筛选满足条件的数据。但是注意了,这个和filter是不一样的。filter是筛选所有满足条件的数据,而takeWhile()方法则是一碰到不符合条件的数据就立即停止。
上面的例子中,虽然第三条数据的 likes<15,但是 takeWhile()在遇到第二条数据的时候就已经停止了筛选。
dropWhile(predicate)
dropWhile则和takeWhile恰好相反。就是去除掉那些符合条件的数据,直到遇到第一条不满足条件的数据为止。比如说刚才一模一样的代码,将takeWhile改成dropWhile,就会打印 b和c,因为会把a去除掉,而遇到b的时候就停止筛选了
Sorting Streams
我们之前介绍了 Comparable
和Comparator
接口,是用来对对象进行排序的方法。
现在在stream中我们可以简化写法:
1 | movies.stream() |
如果我们要倒序排列,那么:.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 | public class StreamsDemo { |
一开始我们筛选出了集合中点赞数大于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 | public static void show(){ |
findAny()
和 findFirst()
逻辑相同,只不过是返回任意一个集合中的对象。
max(comparator)
这个方法需要接受一个 comparator 对象作为比较的依据. 用来返回拥有 最大参数的对象
1 | var result= movies.stream() |
min(comparator)
和max()的逻辑相同。
Reducing a Stream
Collectors
Grouping Elements
Partitioning Elements
Primitive Type Streams
Concurrency and Multi-threading
Processes and Threads
我们在CSAPP中已经了解了进程和线程的关系。在一个进程中,一定有一个主线程,还可以有其他支线程。比如说我们在迅雷中一次下载了三个文件,那么这三个文件就可以占据三个线程。
现在我们来看一下关于这台电脑中关于线程的信息:
1 | public class Main { |
第一句是打印当前这个项目使用的进程数,下面一句打印当前电脑总的可用线程数。
因为 m1 是四大核四小盒,这里打印出来的是一共8个进程 。当我在 i7-9750(六核) 上运行时,打印得到12个线程。
Starting a Thread
现在我们来讲怎么创建一个线程。要创建一个线程,我们首先要让一个类引入 Runnable 接口,并在其中重写 run函数。Runnable接口是Java.lang 中一个内置的接口。引入这个接口就代表着这个任务将被在一条线程中执行。这个接口中只有一个函数: run()
, 当启动线程开始运作之后,会自动调用 run()
函数。
1 | package com.company.concurrency; |
然后,在 ThreadDemo
类中,将一个 DownloadFileTask()
实例传入,并调用 start()
函数启动线程。
1 | public class ThreadDemo { |
打印结果如上图所示:首先打印的是当前所在的线程,也就是 main,然后我们进行了一个循环,调用了十个新的线程,并依次打印出他们的名字。
Pausing a Thread
现在我们尝试将一个线程“挂起”一段时间以模仿下载的过程。
要让线程挂起,我们可以使用 sleep()
方法,这里我选择让线程挂起5秒钟(注意,5000是以毫秒为单位,但并不是非常精确的5000毫秒,这和底层操作系统有关系)
1 | public class DownloadFileTask implements Runnable { |
如果我直接写 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 | public class DownloadFileTask implements Runnable { |
这边我进行一个无限循环,然后判断主线程是否给我发送了一个 Interrupt信号,如果是就return掉,否则就继续打印。
注意了,如果线程正在挂起时向其发送 interrupted
信号,这样是会报错的。因此我们在用 thread.sleep()
的时候,需要用 try-catch
来包裹。
Concurrency Issues
在编写并行程序的时候会遇到一些问题:
- 当很多不同的线程共用一个对象的时候,对对象的某些参数进行修改会导致“堵车”。这就好比三个人像同时吃掉一个汉堡。我们将这种情况叫做 “Race Condition”,
- 另一种情况就是,当一个线程对一个对象进行了修改,但是修改后的内容仅它自己可见,那么不同线程就会看到一个对象不同的状态。我们将这种情况叫做 ”Visibility Problem”
我们必须要写出 Thread-safe Code
来规避这些问题。在很多Java的文档中,对一个类的描述是 Thread Safe
也就是这个类可以再很多并行的线程中使用。
Race Conditions
当很多线程都想修改一个对象的时候,就出现了竞争关系。
比如说我有以下代码:
ThreadDemo类
在这个调用的类当中,我们创建一个线程数组,
1 | package com.company.concurrency; |
DownloadFileTask
类
在这个Run函数当中,我们做一个10000次的循环,每一次循环都调用status对象的 incrementTotalBytes()
函数。用来模拟下载一个 10000 bits的文件。
1 | package com.company.concurrency; |
DownloadStatus
类
在这个类中,有一个下载总比特数的私有变量,当有线程中的对象调用incrementTotalBytes()
的时候,totalBytes就会自增1
1 | package com.company.concurrency; |
在预期的情况下,我们打开了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 | public void incrementTotalBytes(){ |
这样一来,线程调用incrementTotalBytes
方法时,它不必关心同步逻辑,因为synchronized
代码块在incrementTotalBytes
方法内部。并且,我们注意到,synchronized
锁住的对象是this
,即当前实例,这又使得创建多个DownloadStatus
实例的时候,它们之间互不影响,可以并发执行。
当我们锁住的是 this
实例的时候,实际上可以用 synchronized
来修饰这个方法,因此这两种方法是等价的:
1 | public synchronized void incrementTotalBytes(){ |
因此,用synchronized
修饰的方法就是同步方法,它表示整个方法都必须用this
实例加锁。不能对其他实例加锁。
但是,对 this
实例加锁也是有缺点的。比如说:我又新建了一个totalFiles变量来记录已下载完成的文件总数。因为文件一多,很可能是两个文件同时下载完成的,因此我们也需要用 synchronized
关键字来修饰
那么问题来了:incrementTotalByts
和incrementTotalFiles
这两个方法都给 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
和一个 setter
将isDown
设为True
然后在DownloadFileTask
类中,我们在下载结束后调用 status.done()
将isDone()
设置为True并输出Download complete
最后在ThreadDemo
类中,新建两个线程,第一个线程传入DownloadFileTask对象,第二个线程里面是个 Lambda表达式,它会一直询问status中的变量isDone是否为True,一直到下载完成 ,isDone==True,才会跳出循环并输出totalBytes
的值。
我们运行这个demo,却发现程序迟迟不打印totalBytes
的值,事实上如果我们不关闭这个程序,它就会一直运行下去。
为什么会发生这种事情?原因就在于 thread1
和thread2
两个线程之间并没有完全同步,我们注意到虽然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
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 | public class ThreadDemo { |
Concurrent Collections
当并发高的时候,使用 Synchronized Collection 会导致CPU占用过高、性能下降。这时候我们可以用 Concurrent Collection
在Concurrent官方文档 中,我们看到有 ConcurrentHashMap
,ConcurrentLinkedDeque
等并发集合
以Map为例,假如我们要对一个HashMap进行高并发的操作,我们就可以使用 ConcurrentHashMap
类。事实上,ConcurrentHashMap
和HashMap
都是对 Map
接口的实现类。
1 | public class ThreadDemo { |