JAVA SE笔记第二部分:
反射、注解、范型、集合、IO、日期和时间
反射
反射是指程序运行期间可以拿到一个对象的所有信息。
反射是为了解决在运行期,对某个实例一无所知的情况下,如何调用其方法。
除了int
等基本类型外,Java的其他类型全部都是class
(包括interface
)。
Class类
每加载一种class
,JVM就为其创建一个Class
类型的实例,并关联起来。
它长这样:
1 | public final class Class { |
以String
类为例,当JVM加载String
类时,它首先读取String.class
文件到内存,然后,为String
类创建一个Class
实例并关联起来:
1 | Class cls = new Class(String); |
1 | 一个Class实例包含了该class的所有完整信息: |
获取Class实例
方法一:直接通过一个class
的静态变量class
获取:
1 | Class cls = String.class; |
方法二:如果我们有一个实例变量,可以通过该实例变量提供的getClass()
方法获取:
1 | String s = "Hello"; |
方法三:如果知道一个class
的完整类名,可以通过静态方法Class.forName()
获取:
1 | Class cls = Class.forName("java.lang.String"); |
Class
类提供了以下几个方法来获取字段:
- Field getField(name):根据字段名获取某个public的field(包括父类)
- Field getDeclaredField(name):根据字段名获取当前类的某个field(不包括父类)
- Field[] getFields():获取所有public的field(包括父类)
- Field[] getDeclaredFields():获取当前类的所有field(不包括父类)
一个Field
对象包含了一个字段的所有信息:
getName()
:返回字段名称,例如,"name"
;getType()
:返回字段类型,也是一个Class
实例,例如,String.class
;getModifiers()
:返回字段的修饰符,它是一个int
,不同的bit表示不同的含义。
1 | import java.lang.reflect.Field; |
1 | f.setAccessible(true); |
调用Field.setAccessible(true)
的意思是,别管这个字段是不是public
,一律允许访问。
setAccessible(true)
可能会失败。如果JVM运行期存在SecurityManager
,那么它会根据规则进行检查,有可能阻止setAccessible(true)
。例如,某个SecurityManager
可能不允许对java
和javax
开头的package
的类调用setAccessible(true)
,这样可以保证JVM核心库的安全。
设置字段值是通过Field.set(Object, Object)
实现的,其中第一个Object
参数是指定的实例,第二个Object
参数是待修改的值。
Field f = c.getDeclaredField("name");
f.setAccessible(true);
f.set(p, "Xiao Hong");
调用方法
Class
类提供了以下几个方法来获取Method
:
Method getMethod(name, Class...)
:获取某个public
的Method
(包括父类)Method getDeclaredMethod(name, Class...)
:获取当前类的某个Method
(不包括父类)Method[] getMethods()
:获取所有public
的Method
(包括父类)Method[] getDeclaredMethods()
:获取当前类的所有Method
(不包括父类)
1 | Class stdClass = Student.class; |
调用方法
一个Method
对象包含一个方法的所有信息:
getName()
:返回方法名称,例如:"getScore"
;getReturnType()
:返回方法返回值类型,也是一个Class实例,例如:String.class
;getParameterTypes()
:返回方法的参数类型,是一个Class数组,例如:{String.class, int.class}
;getModifiers()
:返回方法的修饰符,它是一个int
,不同的bit表示不同的含义。
1 | // reflection |
调用非public方法
为了调用非public方法,我们通过Method.setAccessible(true)
允许其调用.
setAccessible(true)
可能会失败。如果JVM运行期存在SecurityManager
,那么它会根据规则进行检查,有可能阻止setAccessible(true)
。例如,某个SecurityManager
可能不允许对java
和javax
开头的package
的类调用setAccessible(true)
,这样可以保证JVM核心库的安全。
使用反射调用方法时,仍然遵循多态原则:即总是调用实际类型的覆写方法(如果存在)。
调用构造方法
如果通过反射来创建新的实例,可以调用Class提供的newInstance()方法:
1 | Person p = Person.class.newInstance(); |
1 | import java.lang.reflect.Constructor; |
通过Class实例获取Constructor的方法如下:
getConstructor(Class...)
:获取某个public
的Constructor
;getDeclaredConstructor(Class...)
:获取某个Constructor
;getConstructors()
:获取所有public
的Constructor
;getDeclaredConstructors()
:获取所有Constructor
。
获取继承关系
获取父类的Class
1 | Class i = Integer.class; |
获取interface
1 | import java.lang.reflect.Method; |
getInterfaces()
只返回当前类直接实现的接口类型,并不包括其父类实现的接口类型
继承关系
如果是两个Class
实例,要判断一个向上转型是否成立,可以调用isAssignableFrom()
:
1 | // Integer i = ? |
动态代理
class
和interface
的区别:
- 可以实例化
class
(非abstract
); - 不能实例化
interface
。
所有interface
类型的变量总是通过向上转型并指向某个实例的:
1 | CharSequence cs = new StringBuilder(); |
动态代理(Dynamic Proxy)机制:在运行期动态创建某个interface
的实例。
在运行期动态创建一个interface
实例的方法如下:
- 定义一个
InvocationHandler
实例,它负责实现接口的方法调用; - 通过
Proxy.newProxyInstance()
创建interface
实例,它需要3个参数:- 使用的
ClassLoader
,通常就是接口类的ClassLoader
; - 需要实现的接口数组,至少需要传入一个接口进去;
- 用来处理接口方法调用的
InvocationHandler
实例。
- 使用的
- 将返回的
Object
强制转型为接口。
1 | import java.lang.reflect.InvocationHandler; |
注解
使用注解
注释会被编译器直接忽略,注解则可以被编译器打包进入class文件,因此,注解是一种用作标注的“元数据”。
1 | // this is a component: |
Java的注解可以分为三类:
- 第一类是由编译器使用的注解,例如:
@Override
:让编译器检查该方法是否正确地实现了覆写;@SuppressWarnings
:告诉编译器忽略此处代码产生的警告。
这类注解不会被编译进入.class
文件,它们在编译后就被编译器扔掉了。
第二类是由工具处理
.class
文件使用的注解,比如有些工具会在加载class的时候,对class做动态修改,实现一些特殊的功能。这类注解会被编译进入.class
文件,但加载结束后并不会存在于内存中。这类注解只被一些底层库使用,一般我们不必自己处理。第三类是在程序运行期能够读取的注解,它们在加载后一直存在于JVM中,这也是最常用的注解。例如,一个配置了
@PostConstruct
的方法会在调用构造方法后自动被调用(这是Java代码读取该注解实现的功能,JVM并不会识别该注解)。
定义一个注解时,还可以定义配置参数。配置参数可以包括:
- 所有基本类型;
- String;
- 枚举类型;
- 基本类型、String、Class以及枚举的数组。
定义注解
定义一个注解时,还可以定义配置参数。配置参数可以包括:
- 所有基本类型;
- String;
- 枚举类型;
- 基本类型、String、Class以及枚举的数组。
定义注解
Java语言使用@interface
语法来定义注解(Annotation
),它的格式如下:
1 | public @interface Report { |
注解的参数类似无参数方法,可以用default
设定一个默认值(强烈推荐)。最常用的参数应当命名为value
。
元注解
有一些注解可以修饰其他注解,这些注解就称为元注解(meta annotation)。Java标准库已经定义了一些元注解,我们只需要使用元注解,通常不需要自己去编写元注解。
@Target
使用@Target
可以定义Annotation
能够被应用于源码的哪些位置:
- 类或接口:
ElementType.TYPE
; - 字段:
ElementType.FIELD
; - 方法:
ElementType.METHOD
; - 构造方法:
ElementType.CONSTRUCTOR
; - 方法参数:
ElementType.PARAMETER
。
定义注解@Report
可用在方法上,我们必须添加一个@Target(ElementType.METHOD)
:
1 |
|
定义注解@Report
可用在方法或字段上,可以把@Target
注解参数变为数组{ ElementType.METHOD, ElementType.FIELD }
:
1 |
|
另一个重要的元注解@Retention
定义了Annotation
的生命周期:
- 仅编译期:
RetentionPolicy.SOURCE
; - 仅class文件:
RetentionPolicy.CLASS
; - 运行期:
RetentionPolicy.RUNTIME
。
如果@Retention
不存在,则该Annotation
默认为CLASS
。因为通常我们自定义的Annotation
都是RUNTIME
,所以,务必要加上@Retention(RetentionPolicy.RUNTIME)
这个元注解:
1 | @Retention(RetentionPolicy.RUNTIME) |
@Repeatable
使用@Repeatable
这个元注解可以定义Annotation
是否可重复。这个注解应用不是特别广泛。
1 | @Repeatable(Reports.class) |
经过@Repeatable
修饰后,在某个类型声明处,就可以添加多个@Report
注解:
1 | @Report(type=1, level="debug") |
@Inherited
使用@Inherited
定义子类是否可继承父类定义的Annotation
。@Inherited
仅针对@Target(ElementType.TYPE)
类型的annotation
有效,并且仅针对class
的继承,对interface
的继承无效:
1 | @Inherited |
在使用的时候,如果一个类用到了@Report
:
1 | @Report(type=1) |
则它的子类默认也定义了该注解:
1 | public class Student extends Person { |
如何定义Annotation
我们总结一下定义Annotation
的步骤:
第一步,用@interface
定义注解:
1 | public @interface Report { |
第二步,添加参数、默认值:
1 | public @interface Report { |
把最常用的参数定义为value()
,推荐所有参数都尽量设置默认值。
第三步,用元注解配置注解:
1 | @Target(ElementType.TYPE) |
其中,必须设置@Target
和@Retention
,@Retention
一般设置为RUNTIME
,因为我们自定义的注解通常要求在运行期读取。一般情况下,不必写@Inherited
和@Repeatable
。
处理注解
SOURCE
类型的注解主要由编译器使用,因此我们一般只使用,不编写。CLASS
类型的注解主要由底层工具库使用,涉及到class的加载,一般我们很少用到。只有RUNTIME
类型的注解不但要使用,还经常需要编写。
注解定义后也是一种class
,所有的注解都继承自java.lang.annotation.Annotation
,因此,读取注解,需要使用反射API。
反射API
判断某个注解是否存在于Class
、Field
、Method
或Constructor
:
Class.isAnnotationPresent(Class)
Field.isAnnotationPresent(Class)
Method.isAnnotationPresent(Class)
Constructor.isAnnotationPresent(Class)
读取Annotation:
Class.getAnnotation(Class)
Field.getAnnotation(Class)
Method.getAnnotation(Class)
Constructor.getAnnotation(Class)
使用注解
在某个JavaBean中,我们可以使用该注解:
1 | public class Person { |
但是,定义了注解,本身对程序逻辑没有任何影响。我们必须自己编写代码来使用注解。这里,我们编写一个Person
实例的检查方法,它可以检查Person
实例的String
字段长度是否满足@Range
的定义:
1 | void check(Person person) throws IllegalArgumentException, ReflectiveOperationException { |
这样一来,我们通过@Range
注解,配合check()
方法,就可以完成Person
实例的检查。注意检查逻辑完全是我们自己编写的,JVM不会自动给注解添加任何额外的逻辑。
范型
类型ArrayList<T>
可以向上转型为List<T>
。
要特别注意:不能把ArrayList<Integer>
向上转型为ArrayList<Number>
或List<Number>
。
编写泛型
编写泛型类时,要特别注意,泛型类型<T>
不能用于静态方法。例如:
1 | public class Pair<T> { |
多个泛型类型
泛型还可以定义多种类型。例如,我们希望Pair
不总是存储两个类型一样的对象,就可以使用类型<T, K>
:
1 | public class Pair<T, K> { |
擦拭法
擦拭法是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的。
Java使用擦拭法实现泛型,导致了:
- 编译器把类型
<T>
视为Object
; - 编译器根据
<T>
实现安全的强制转型。
因为Java泛型的实现方式——擦拭法,Java泛型的局限:
- 局限一:
<T>
不能是基本类型,例如int
,因为实际类型是Object
,Object
类型无法持有基本类型:
1 | Pair<int> p = new Pair<>(1, 2); // compile error! |
- 局限二:无法取得带泛型的
Class
。如Pair<T>
通过getClass()
得到的永远是Pair<Object>
- 局限三:无法判断带泛型的类型
- 局限四:不能实例化
T
类型.(不能使用new T()
)
泛型继承
一个类可以继承自一个泛型类。例如:父类的类型是Pair<Integer>
,子类的类型是IntPair
,可以这么继承:
1 | public class IntPair extends Pair<Integer> {} |
使用的时候,因为子类IntPair
并没有泛型类型,所以,正常使用即可:
1 | IntPair ip = new IntPair(1, 2); |
前面讲了,我们无法获取Pair<T>
的T
类型,即给定一个变量Pair<Integer> p
,无法从p
中获取到Integer
类型。
但是,在父类是泛型类型的情况下,编译器就必须把类型T
(对IntPair
来说,也就是Integer
类型)保存到子类的class文件中,不然编译器就不知道IntPair
只能存取Integer
这种类型。
在继承了泛型类型的情况下,子类可以获取父类的泛型类型。
extends通配符
Pair<? extends Number>
使得方法接收所有泛型类型为Number
或Number
子类的Pair
类型。
1 | static int add(Pair<? extends Number> p) { |
这种使用<? extends Number>
的泛型定义称之为上界通配符(Upper Bounds Wildcards),即把泛型类型T
的上界限定在Number
了。
List<? extends Integer>
的限制:
- 允许调用
get()
方法获取Integer
的引用; - 不允许调用
set(? extends Integer)
方法并传入任何Integer
的引用(null
除外)。
使用extends限定T类型
在定义泛型类型Pair<T>
的时候,也可以使用extends
通配符来限定T
的类型:
1 | public class Pair<T extends Number> { ... } |
现在,我们只能定义:
1 | Pair<Number> p1 = null; |
super通配符
Pair<? super Integer>
表示,方法参数接受所有泛型类型为Integer
或Integer
父类的Pair
类型。
使用<? super Integer>
通配符表示:
- 允许调用
set(? super Integer)
方法传入Integer
的引用; - 不允许调用
get()
方法获得Integer
的引用。
PECS原则
PECS原则:Producer Extends Consumer Super。
即:如果需要返回T
,它是生产者(Producer),要使用extends
通配符;如果需要写入T
,它是消费者(Consumer),要使用super
通配符。
无限定通配符
Java的泛型还允许使用无限定通配符(Unbounded Wildcard Type),即只定义一个?
:
1 | void sample(Pair<?> p) {} |
因为<?>
通配符既没有extends
,也没有super
,因此:
- 不允许调用
set(T)
方法并传入引用(null
除外); - 不允许调用
T get()
方法并获取T
引用(只能获取Object
引用)。 - 既不能读,也不能写,那只能做一些
null
判断:
<?>
通配符有一个独特的特点,就是:Pair<?>
是所有Pair<T>
的超类
范型和反射
可以声明带泛型的数组,但不能用new
操作符创建带泛型的数组:
1 | Pair<String>[] ps = null; // ok |
必须通过强制转型实现带泛型的数组:
1 |
|
如果在方法内部创建了泛型数组,最好不要将它返回给外部使用。
集合
Collection
标准库自带的java.util
包提供了集合类:Collection
,它是除Map
外所有其他集合类的根接口。Java的java.util
包主要提供了以下三种类型的集合:
List
:一种有序列表的集合,例如,按索引排列的Student
的List
;Set
:一种保证没有重复元素的集合,例如,所有无重复名称的Student
的Set
;Map
:一种通过键值(key-value)查找的映射表集合,例如,根据Student
的name
查找对应Student
的Map
。
Java集合的设计有几个特点:一是实现了接口和实现类相分离,例如,有序表的接口是List
,具体的实现类有ArrayList
,LinkedList
等,二是支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:
1 | List<String> list = new ArrayList<>(); // 只能放入String类型 |
最后,Java访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。
由于Java的集合设计非常久远,中间经历过大规模改进,我们要注意到有一小部分集合类是遗留类,不应该继续使用:
Hashtable
:一种线程安全的Map
实现;Vector
:一种线程安全的List
实现;Stack
:基于Vector
实现的LIFO
的栈。
还有一小部分接口是遗留接口,也不应该继续使用:
Enumeration<E>
:已被Iterator<E>
取代。
List
List<E>
接口,可以看到几个主要的接口方法:
在末尾添加一个元素:
boolean add(E e)
在指定索引添加一个元素:
boolean add(int index, E e)
删除指定索引的元素:
int remove(int index)
删除某个元素:
int remove(Object e)
获取指定索引的元素:
E get(int index)
获取链表大小(包含元素的个数):
int size()
1 | // 创建1 |
编写equals方法
equals()
方法要求我们必须满足以下条件:
- 自反性(Reflexive):对于非
null
的x
来说,x.equals(x)
必须返回true
; - 对称性(Symmetric):对于非
null
的x
和y
来说,如果x.equals(y)
为true
,则y.equals(x)
也必须为true
; - 传递性(Transitive):对于非
null
的x
、y
和z
来说,如果x.equals(y)
为true
,y.equals(z)
也为true
,那么x.equals(z)
也必须为true
; - 一致性(Consistent):对于非
null
的x
和y
来说,只要x
和y
状态不变,则x.equals(y)
总是一致地返回true
或者false
; - 对
null
的比较:即x.equals(null)
永远返回false
。
equals()
方法的正确编写方法:
- 先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
- 用
instanceof
判断传入的待比较的Object
是不是当前类型,如果是,继续比较,否则,返回false
; - 对引用类型用
Objects.equals()
比较,对基本类型直接用==
比较。
1 | public boolean equals(Object o) { |
使用Map
1 | import java.util.HashMap; |
遍历
1 | // keySet |
equals和hashCode
对应两个实例a
和b
:
- 如果
a
和b
相等,那么a.equals(b)
一定为true
,则a.hashCode()
必须等于b.hashCode()
; - 如果
a
和b
不相等,那么a.equals(b)
一定为false
,则a.hashCode()
和b.hashCode()
尽量不要相等。
1 |
|
编写equals()
和hashCode()
遵循的原则是:
equals()
用到的用于比较的每一个字段,都必须在hashCode()
中用于计算;equals()
中没有使用到的字段,绝不可放在hashCode()
中计算。
map扩容
map扩容要重新确定hashCode计算索引位置,频繁扩容对HashMap
的性能影响很大。如果我们确定要使用一个容量为10000
个key-value
的HashMap
,更好的方式是创建HashMap
时就指定容量:
1 | Map<String, Integer> map = new HashMap<>(10000); |
虽然指定容量是10000
,但HashMap
内部的数组长度总是2n,因此,实际数组长度被初始化为比10000
大的16384
(214)。
EnumMap
EnumMap
,它在内部以一个非常紧凑的数组存储value,并且根据enum
类型的key直接定位到内部数组的索引,并不需要计算hashCode()
,不但效率最高,而且没有额外的空间浪费。
1 | Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class); |
TreeMap
使用TreeMap
时,放入的Key必须实现Comparable
接口。String
、Integer
这些类已经实现了Comparable
接口,因此可以直接作为Key使用。
1 | ┌───┐ |
如果作为Key的class没有实现Comparable
接口,那么,必须在创建TreeMap
时同时指定一个自定义排序算法:
1 | import java.util.* |
Comparator
接口要求实现一个比较方法,它负责比较传入的两个元素a
和b
,如果a<b
,则返回负数,通常是-1
,如果a==b
,则返回0
,如果a>b
,则返回正数,通常是1
。TreeMap
内部根据比较结果对Key进行排序。
使用Properties
Properties
表示一组配置,用于读取配置文件。
由于历史遗留原因,
Properties
内部本质上是一个Hashtabel
读取配置文件
用Properties
读取配置文件,一共有三步:
- 创建
Properties
实例; - 调用
load()
读取文件; - 调用
getProperty()
获取配置。
典型的配置文件(Java默认配置文件以.properties
为扩展名,每行以key=value
表示,以#
课开头的是注释。):
1 | # setting.properties |
读取:
1 | String f = "setting.properties"; |
写入配置文件
1 | Properties props = new Properties(); |
Set
成员方法
Set
用于存储不重复的元素集合,主要提供以下几个方法:
- 将元素添加进
Set<E>
:boolean add(E e)
- 将元素从
Set<E>
删除:boolean remove(Object e)
- 判断是否包含元素:
boolean contains(Object e)
最常用的Set
实现类是HashSet
,实际上,HashSet
仅仅是对HashMap
的一个简单封装。
接口
Set
接口并不保证有序,而SortedSet
接口则保证元素是有序的:
HashSet
是无序的,因为它实现了Set
接口,并没有实现SortedSet
接口;TreeSet
是有序的,因为它实现了SortedSet
接口。
1 | ┌───┐ |
使用Queue
Queue<String> q = new LinkedList<>();
Queue
定义了以下几个方法:
int size()
:获取队列长度;boolean add(E)
/boolean offer(E)
:添加元素到队尾;E remove()
/E poll()
:获取队首元素并从队列中删除;E element()
/E peek()
:获取队首元素但并不从队列中删除。
throw Exception | 返回false或null | |
---|---|---|
添加元素到队尾 | add(E e) | boolean offer(E e) |
取队首元素并删除 | E remove() | E poll() |
取队首元素但不删除 | E element() | E peek() |
使用PriorityQueue
Queue<String> q = new PriorityQueue<>();
PriorityQueue
允许我们提供一个Comparator
对象来判断两个元素的顺序。
1 | import java.util.Comparator; |
使用Deque
Queue
和Deque
出队和入队的方法:
Queue | Deque | |
---|---|---|
添加元素到队尾 | add(E e) / offer(E e) | addLast(E e) / offerLast(E e) |
取队首元素并删除 | E remove() / E poll() | E removeFirst() / E pollFirst() |
取队首元素但不删除 | E element() / E peek() | E getFirst() / E peekFirst() |
添加元素到队首 | 无 | addFirst(E e) / offerFirst(E e) |
取队尾元素并删除 | 无 | E removeLast() / E pollLast() |
取队尾元素但不删除 | 无 | E getLast() / E peekLast() |
Deque<String> deque = new LinkedList<>();
使用Stack
Stack
只有入栈和出栈的操作:
- 把元素压栈:
push(E)
; - 把栈顶的元素“弹出”:
pop(E)
; - 取栈顶元素但不弹出:
peek(E)
。
在Java中,我们用Deque
可以实现Stack
的功能:
- 把元素压栈:
push(E)
/addFirst(E)
; - 把栈顶的元素“弹出”:
pop(E)
/removeFirst()
; - 取栈顶元素但不弹出:
peek(E)
/peekFirst()
。
使用Iterator
1 | for (Iterator<String> it = list.iterator(); it.hasNext(); ) { |
如果我们自己编写了一个集合类,想要使用for each
循环,只需满足以下条件:
- 集合类实现
Iterable
接口,该接口要求返回一个Iterator
对象; - 用
Iterator
对象迭代集合内部数据。
1 | import java.util.*; |
Collections
Collections
是JDK提供的工具类,同样位于java.util
包中。它提供了一系列静态方法,能更方便地操作各种集合。
创建空集合
- 创建空List:
List<T> emptyList()
- 创建空Map:
Map<K, V> emptyMap()
- 创建空Set:
Set<T> emptySet()
1 | // 下面两种方法等价 |
创建单元素集合
- 创建一个元素的List:
List<T> singletonList(T o)
- 创建一个元素的Map:
Map<K, V> singletonMap(K key, V value)
- 创建一个元素的Set:
Set<T> singleton(T o)
1 | List<String> list1 = List.of(); // empty list |
排序
1 | Collections.sort(list); |
洗牌
1 | Collections.shuffle(list); |
不可变集合
Collections
还提供了一组方法把可变集合封装成不可变集合:
- 封装成不可变List:
List<T> unmodifiableList(List<? extends T> list)
- 封装成不可变Set:
Set<T> unmodifiableSet(Set<? extends T> set)
- 封装成不可变Map:
Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)
1 | List<String> mutable = new ArrayList<>(); |
线程安全集合
Collections
还提供了一组方法,可以把线程不安全的集合变为线程安全的集合:
- 变为线程安全的List:
List<T> synchronizedList(List<T> list)
- 变为线程安全的Set:
Set<T> synchronizedSet(Set<T> s)
- 变为线程安全的Map:
Map<K,V> synchronizedMap(Map<K,V> m)
IO
InputStream / OutputStream
IO流以byte
(字节)为最小单位,因此也称为字节流。
Reader/Writer
Reader
和Writer
表示字符流,字符流传输的最小数据单位是char
。Reader
和Writer
本质上是一个能自动编解码的InputStream
和OutputStream
。
同步和异步
同步IO是指,读写IO时代码必须等待数据返回后才继续执行后续代码,它的优点是代码编写简单,缺点是CPU执行效率低。
异步IO是指,读写IO时仅发出请求,然后立刻执行后续代码,它的优点是CPU执行效率高,缺点是代码编写复杂。
Java标准库的包java.io
提供了同步IO,而java.nio
则是异步IO。上面我们讨论的InputStream
、OutputStream
、Reader
和Writer
都是同步IO的抽象类,对应的具体实现类,以文件为例,有FileInputStream
、FileOutputStream
、FileReader
和FileWriter
。
File对象
1 | import java.io.File; |
创建删除文件
当File对象表示一个文件时,可以通过createNewFile()
创建一个新文件,用delete()
删除该文件
1 | File file = new File("/path/to/file"); |
程序需要读写一些临时文件,File对象提供了createTempFile()
来创建一个临时文件,以及deleteOnExit()
在JVM退出时自动删除该文件。
1 | public class Main { |
遍历文件和目录
当File对象表示一个目录时,可以使用list()
和listFiles()
列出目录下的文件和子目录名。listFiles()
提供了一系列重载方法,可以过滤不想要的文件和目录:
1 | File f = new File("/usr/local/bin"); |
Path对象
Path
对象,它位于java.nio.file
包。Path
对象和File
对象类似,但操作更加简单:
1 | public class Main { |
InputStream
InputStream
并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read()
,签名如下:
1 | public abstract int read() throws IOException; |
这个方法会读取输入流的下一个字节,并返回字节表示的int
值(0~255)。如果已读到末尾,返回-1
表示不能继续读取了。
FileInputStream
read()
读取输入流的下一个字节.
1 | public void readFile() throws IOException { |
实际上,编译器并不会特别地为
InputStream
加上自动关闭。编译器只看try(resource = ...)
中的对象是否实现了java.lang.AutoCloseable
接口,如果实现了,就自动加上finally
语句并调用close()
方法。InputStream
和OutputStream
都实现了这个接口,因此,都可以用在try(resource)
中。
利用缓冲区一次读取多个字节
1 | public void readFile() throws IOException { |
ByteArrayInputStream模拟InputStream
1 | import java.io.InputStream; |
OutputStream
OutputStream
也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b)
,签名如下:
1 | public abstract void write(int b) throws IOException; |
这个方法会写入一个字节到输出流。要注意的是,虽然传入的是int
参数,但只会写入一个字节,即只写入int
最低8位表示字节的部分(相当于b & 0xff
)。
OutputStream
也提供了close()
方法关闭输出流,以便释放系统资源。要特别注意:OutputStream
还提供了一个flush()
方法,它的目的是将缓冲区的内容真正输出到目的地。
1 | public void writeFile() throws IOException { |
ByteArrayOutputStream
可以在内存中模拟一个OutputStream
1 | public class Main { |
可以同时写两个文件
1 | // 读取input.txt,写入output.txt: |
Filter模式
Java的IO标准库提供的InputStream
根据来源可以包括:
FileInputStream
:从文件读取数据,是最终数据源;ServletInputStream
:从HTTP请求读取数据,是最终数据源;Socket.getInputStream()
:从TCP连接读取数据,是最终数据源;- …
为了解决依赖继承会导致子类数量失控的问题,JDK首先将InputStream
分为两大类:
一类是直接提供数据的基础InputStream
,例如:
- FileInputStream
- ByteArrayInputStream
- ServletInputStream
- …
一类是提供额外附加功能的InputStream
,例如:
- BufferedInputStream
- DigestInputStream
- CipherInputStream
- …
当我们需要给一个“基础”InputStream
附加各种功能时,我们先确定这个能提供数据源的InputStream
,因为我们需要的数据总得来自某个地方,例如,FileInputStream
,数据来源自文件:
1 | InputStream file = new FileInputStream("test.gz"); |
紧接着,我们希望FileInputStream
能提供缓冲的功能来提高读取的效率,因此我们用BufferedInputStream
包装这个InputStream
,得到的包装类型是BufferedInputStream
,但它仍然被视为一个InputStream
:
1 | InputStream buffered = new BufferedInputStream(file); |
最后,假设该文件已经用gzip压缩了,我们希望直接读取解压缩的内容,就可以再包装一个GZIPInputStream
:
1 | InputStream gzip = new GZIPInputStream(buffered); |
无论我们包装多少次,得到的对象始终是InputStream
,我们直接用InputStream
来引用它,就可以正常读取:
1 | ┌─────────────────────────┐ |
上述这种通过一个“基础”组件再叠加各种“附加”功能组件的模式,称之为Filter模式(或者装饰器模式:Decorator)。它可以让我们通过少量的类来实现各种功能的组合:
1 | ┌─────────────┐ |
类似的,OutputStream
也是以这种模式来提供各种功能:
1 | ┌─────────────┐ |
操作Zip
ZipInputStream
是一种FilterInputStream
,它可以直接读取zip包的内容:
1 | is=>operation: InputStream |
JarInputStream
是从ZipInputStream
派生,它增加的主要功能是直接读取jar文件里面的MANIFEST.MF
文件。因为本质上jar包就是zip包,只是额外附加了一些固定的描述文件。
读取Zip文件
1 | try (ZipInputStream zip = new ZipInputStream(new FileInputStream(...))) { |
写入Zip文件
1 | try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) { |
读取classpath资源
从classpath读取文件就可以避免不同环境下文件路径不一致的问题:如果我们把default.properties
文件放到classpath中,就不用关心它的实际存放路径。
在classpath中的资源文件,路径总是以
/
开头
1 | try (InputStream input = getClass().getResourceAsStream("/default.properties")) { |
如果我们把默认的配置放到jar包中,再从外部文件系统读取一个可选的配置文件,就可以做到既有默认的配置文件,又可以让用户自己修改配置:
1 | Properties props = new Properties(); |
序列化
把一个Java对象变为byte[]
数组,需要使用ObjectOutputStream
。
1 | public class Main { |
反序列化
ObjectInputStream
负责从一个字节流读取Java对象:
1 | try (ObjectInputStream input = new ObjectInputStream(...)) { |
readObject()
可能抛出的异常有:
ClassNotFoundException
:没有找到对应的Class;InvalidClassException
:Class不匹配。
为了避免这种class定义变动导致的不兼容,Java的序列化允许class定义一个特殊的serialVersionUID
静态变量,用于标识Java类的序列化“版本”,通常可以由IDE自动生成。如果增加或修改了字段,可以改变serialVersionUID
的值,这样就能自动阻止不匹配的class版本:
1 | public class Person implements Serializable { |
实际上,Java本身提供的基于对象的序列化和反序列化机制既存在安全性问题,也存在兼容性问题。更好的序列化方法是通过JSON这样的通用数据结构来实现,只输出基本类型(包括String)的内容,而不存储任何与代码相关的信息。
Reader
除了特殊的
CharArrayReader
和StringReader
,普通的Reader
实际上是基于InputStream
构造的,因为Reader
需要从InputStream
中读入字节流(byte
),然后,根据编码设置,再转换为char
就可以实现字符流。如果我们查看FileReader
的源码,它在内部实际上持有一个FileInputStream
。
InputStream | Reader |
---|---|
字节流,以byte 为单位 |
字符流,以char 为单位 |
读取字节(-1,0~255):int read() |
读取字符(-1,0~65535):int read() |
读到字节数组:int read(byte[] b) |
读到字符数组:int read(char[] c) |
java.io.Reader
是所有字符输入流的超类,它最主要的方法是:
1 | public int read() throws IOException; |
这个方法读取字符流的下一个字符,并返回字符表示的int
,范围是0
~`65535。如果已读到末尾,返回
-1`。
FileReader
1 | public void readFile() throws IOException { |
指定编码
1 | try(Reader reader = new FileReader("1.txt",StandardCharsets.UTF_8)){ |
读取到缓冲区
1 | public void readFile() throws IOException { |
charArrayReader
CharArrayReader
可以在内存中模拟一个Reader
,它的作用实际上是把一个char[]
数组变成一个Reader
,这和ByteArrayInputStream
非常类似:
1 | try (Reader reader = new CharArrayReader("Hello".toCharArray())) { |
StringReader
StringReader
可以直接把String
作为数据源,它和CharArrayReader
几乎一样:
1 | try (Reader reader = new StringReader("Hello")) { |
InputStreamReader
Reader
本质上是一个基于InputStream
的byte
到char
的转换器,那么,如果我们已经有一个InputStream
,想把它转换为Reader
,是完全可行的。InputStreamReader
就是这样一个转换器,它可以把任何InputStream
转换为Reader
。示例代码如下:
1 | // 持有InputStream: |
构造InputStreamReader
时,我们需要传入InputStream
,还需要指定编码,就可以得到一个Reader
对象。上述代码可以通过try (resource)
更简洁地改写如下:
1 | try (Reader reader = new InputStreamReader(new FileInputStream("src/readme.txt"), "UTF-8")) { |
上述代码实际上就是FileReader
的一种实现方式。
使用try (resource)
结构时,当我们关闭Reader
时,它会在内部自动调用InputStream
的close()
方法,所以,只需要关闭最外层的Reader
对象即可。
Writer
OutputStream | Writer |
---|---|
字节流,以byte 为单位 |
字符流,以char 为单位 |
写入字节(0~255):void write(int b) |
写入字符(0~65535):void write(int c) |
写入字节数组:void write(byte[] b) |
写入字符数组:void write(char[] c) |
无对应方法 | 写入String:void write(String s) |
Writer
是所有字符输出流的超类,它提供的方法主要有:
- 写入一个字符(0~65535):
void write(int c)
; - 写入字符数组的所有字符:
void write(char[] c)
; - 写入String表示的所有字符:
void write(String s)
。
1 | try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) { |
CharArrayWriter
CharArrayWriter
可以在内存中创建一个Writer
,它的作用实际上是构造一个缓冲区,可以写入char
,最后得到写入的char[]
数组,这和ByteArrayOutputStream
非常类似:
1 | try (CharArrayWriter writer = new CharArrayWriter()) { |
StringWriter
StringWriter
也是一个基于内存的Writer
,它和CharArrayWriter
类似。实际上,StringWriter
在内部维护了一个StringBuffer
,并对外提供了Writer
接口。
OutputStreamWriter
除了CharArrayWriter
和StringWriter
外,普通的Writer实际上是基于OutputStream
构造的,它接收char
,然后在内部自动转换成一个或多个byte
,并写入OutputStream
。因此,OutputStreamWriter
就是一个将任意的OutputStream
转换为Writer
的转换器:
1 | try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) { |
上述代码实际上就是FileWriter
的一种实现方式。这和上一节的InputStreamReader
是一样的。
PrintStream和PrintWriter
PrintStream
在OutputStream
接口上提供了一组写入各种数据的方法:print(int)
,print(boolean)
以及一组println()
(自动加换行符)
System.out
是系统默认提供的PrintStream
PrintStream
最终输出的总是byte数据,而PrintWriter
则是扩展了Writer
接口,它的print()
/println()
方法最终输出的是char
数据。两者的使用方法几乎是一模一样的:
1 | public class Main { |
使用Files
将一个文件的全部内容读取为一个byte[]
1 | byte[] data = Files.readAllBytes(Paths.get("/path/to/file.txt")); |
把一个文件的全部内容读取为String
1 | // 默认使用UTF-8编码读取: |
写入文件
1 | // 写入二进制文件: |
Files
工具类还有copy()
、delete()
、exists()
、move()
等快捷方法操作文件和目录。
Files
提供的读写方法,受内存限制,只能读写小文件,例如配置文件等,不可一次读入几个G的大文件。读写大型文件仍然要使用文件流,每次只读写一部分文件内容。
日期与时间
夏令时,就是夏天开始的时候,把时间往后拨1小时,夏天结束的时候,再把时间往前拨1小时。
计算夏令时请使用标准库提供的相关类,不要试图自己计算夏令时。
在计算机中,通常使用Locale
表示一个国家或地区的日期、时间、数字、货币等格式。Locale
由语言_国家
的字母缩写构成,例如,zh_CN
表示中文+中国,en_US
表示英文+美国。语言使用小写,国家使用大写。
对于日期来说,不同的Locale,例如,中国和美国的表示方式如下:
- zh_CN:2016-11-30
- en_US:11/30/2016
Date和Calendar
Epoch Time
又称为时间戳,在不同的编程语言中,会有几种存储方式:
- 以秒为单位的整数:1574208900,缺点是精度只能到秒;
- 以毫秒为单位的整数:1574208900123,最后3位表示毫秒数;
- 以秒为单位的浮点数:1574208900.123,小数点后面表示零点几秒。
Java程序中,时间戳通常是用long
表示的毫秒数,即:
1 | long t = 1574208900123L; |
转换成北京时间就是2019-11-20T8:15:00.123
。要获取当前时间戳,可以使用System.currentTimeMillis()
,这是Java程序获取时间戳最常用的方法。
标准库API
Java标准库有两套处理日期和时间的API:
- 一套定义在
java.util
这个包里面,主要包括Date
、Calendar
和TimeZone
这几个类; - 一套新的API是在Java 8引入的,定义在
java.time
这个包里面,主要包括LocalDateTime
、ZonedDateTime
、ZoneId
等。
旧API
Date
java.util.Date
是用于表示一个日期和时间的对象,它实际上存储了一个long类型的以毫秒表示的时间戳。
1 | public class Main { |
Date
对象有几个严重的问题:它不能转换时区,除了toGMTString()
可以按GMT+0:00
输出外,Date总是以当前计算机系统的默认时区为基础进行输出。此外,我们也很难对日期和时间进行加减,计算两个日期相差多少天,计算某个月第一个星期一的日期等。
Calendar
Calendar相比Date多了日期计算功能。年份不需要转换,月份要加一,星期1~7分别表示周日、周一、…、周六
1 | public class Main { |
利用Calendar
进行时区转换的步骤是:
- 清除所有字段;
- 设定指定时区;
- 设定日期和时间;
- 创建
SimpleDateFormat
并设定目标时区; - 格式化获取的
Date
对象(注意Date
对象无时区信息,时区信息存储在SimpleDateFormat
中)。
本质上时区转换只能通过
SimpleDateFormat
在显示的时候完成。
新API
从Java 8开始,java.time
包提供了新的日期和时间API,主要涉及的类型有:
- 本地日期和时间:
LocalDateTime
,LocalDate
,LocalTime
; - 带时区的日期和时间:
ZonedDateTime
; - 时刻:
Instant
; - 时区:
ZoneId
,ZoneOffset
; - 时间间隔:
Duration
。
以及一套新的用于取代SimpleDateFormat
的格式化类型DateTimeFormatter
。
LocalDateTime
1 | LocalDate d = LocalDate.now(); // 当前日期 |
ISO 8601规定的日期和时间分隔符是T
。标准格式如下:
- 日期:yyyy-MM-dd
- 时间:HH:mm:ss
- 带毫秒的时间:HH:mm:ss.SSS
- 日期和时间:yyyy-MM-dd’T’HH:mm:ss
- 带毫秒的日期和时间:yyyy-MM-dd’T’HH:mm:ss.SSS
DateTimeFormatter
1 | // 自定义格式化: |
ZonedDateTime
可以简单地把ZonedDateTime
理解成LocalDateTime
加ZoneId
。
1 | // 方法1 |
Instant
当前时间戳在java.time
中以Instant
类型表示,我们用Instant.now()
获取当前时间戳,效果和System.currentTimeMillis()
类似
1 | Instant now = Instant.now(); |
最佳实践
旧API转新API
如果要把旧式的Date
或Calendar
转换为新API对象,可以通过toInstant()
方法转换为Instant
对象,再继续转换为ZonedDateTime
:
1 | // Date -> Instant: |
从上面的代码还可以看到,旧的TimeZone
提供了一个toZoneId()
,可以把自己变成新的ZoneId
。
新API转旧API
如果要把新的ZonedDateTime
转换为旧的API对象,只能借助long
型时间戳做一个“中转”:
1 | // ZonedDateTime -> long: |
从上面的代码还可以看到,新的ZoneId
转换为旧的TimeZone
,需要借助ZoneId.getId()
返回的String
完成。
在数据库中存储日期和时间
除了旧式的java.util.Date
,我们还可以找到另一个java.sql.Date
,它继承自java.util.Date
,但会自动忽略所有时间相关信息。这个奇葩的设计原因要追溯到数据库的日期与时间类型。
在数据库中,也存在几种日期和时间类型:
DATETIME
:表示日期和时间;DATE
:仅表示日期;TIME
:仅表示时间;TIMESTAMP
:和DATETIME
类似,但是数据库会在创建或者更新记录的时候同时修改TIMESTAMP
。
在使用Java程序操作数据库时,我们需要把数据库类型与Java类型映射起来。下表是数据库类型与Java新旧API的映射关系:
数据库 | 对应Java类(旧) | 对应Java类(新) |
---|---|---|
DATETIME | java.util.Date | LocalDateTime |
DATE | java.sql.Date | LocalDate |
TIME | java.sql.Time | LocalTime |
TIMESTAMP | java.sql.Timestamp | LocalDateTime |
实际上,在数据库中,我们需要存储的最常用的是时刻(Instant
),因为有了时刻信息,就可以根据用户自己选择的时区,显示出正确的本地时间。所以,最好的方法是直接用长整数long
表示,在数据库中存储为BIGINT
类型。
通过存储一个long
型时间戳,我们可以编写一个timestampToString()
的方法,非常简单地为不同用户以不同的偏好来显示不同的本地时间:
import java.time.*; import java.time.format.*; import java.util.Locale;
Run
对上述方法进行调用,结果如下:
1 | 2019年11月20日 上午8:15 |