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 |