7.5 剖析日期和时间

7.5 剖析日期和时间

本节,我们讨论Java 中日期和时间处理相关的API。日期和时间是一个比较复杂的概念,Java 8之前的设计有一些不足,业界有一个广泛使用的第三方类库Joda-Time, Java 8受Joda-Time影响,重新设计了日期和时间API,新增了一个包java.time。虽然Java 8之前的API有一些不足,但依然是被大量使用的,本节只介绍Java 8之前的API。关于Java 8的API,它使用了Lambda表达式,我们还没介绍,所以留待到第26章介绍。

下面,我们先来看一些基本概念,然后再介绍Java的日期和时间API。

7.5.1 基本概念

关于日期和时间,有一些基本概念,包括时区、时刻、纪元时、年历等。

1.时区

我们都知道,同一时刻,世界上各个地区的时间可能是不一样的,具体时间与时区有关。全球一共有24个时区,英国格林尼治是0时区,北京是东八区,也就是说格林尼治凌晨1点,北京是早上9点。0时区的时间也称为GMT+0时间,GMT是格林尼治标准时间,北京的时间就是GMT+8:00。

2.时刻和纪元时

所有计算机系统内部都用一个整数表示时刻,这个整数是距离格林尼治标准时间1970年1月1日0时0分0秒的毫秒数。为什么要用这个时间呢?更多的是历史原因,本书就不介绍了。

格林尼治标准时间1970年1月1日0时0分0秒也被称为Epoch Time(纪元时)。

这个整数表示的是一个时刻,与时区无关,世界上各个地方都是同一个时刻,但各个地区对这个时刻的解读(如年月日时分秒)可能是不一样的。

对于1970年以前的时间,使用负数表示。

3.年历

我们都知道,中国有公历和农历之分,公历和农历都是年历,不同的年历,一年有多少月,每月有多少天,甚至一天有多少小时,这些可能都是不一样的。

比如,公历有闰年,闰年2月是29天,而其他年份则是28天,其他月份,有的是30天,有的是31天。农历有闰月,比如闰7月,一年就会有两个7月,一共13个月。

公历是世界上广泛采用的年历,除了公历,还有其他一些年历,比如日本也有自己的年历。Java API的设计思想是支持国际化的,支持多种年历,但没有直接支持中国的农历,本书主要讨论公历。

简单总结下,时刻是一个绝对时间,对时刻的解读,则是相对的,与年历和时区相关。

7.5.2 日期和时间API

Java API中关于日期和时间,有三个主要的类。

  • Date:表示时刻,即绝对时间,与年月日无关。
  • Calendar:表示年历,Calendar是一个抽象类,其中表示公历的子类是Gregorian-Calendar。
  • DateFormat:表示格式化,能够将日期和时间与字符串进行相互转换,DateFormat也是一个抽象类,其中最常用的子类是SimpleDateFormat。

还有两个相关的类:

  • TimeZone:表示时区。
  • Locale:表示国家(或地区)和语言。

下面,我们来看这些类。

1. Date

Date是Java API中最早引入的关于日期的类,一开始,Date也承载了关于年历的角色,但由于不能支持国际化,其中的很多方法都已经过时了,被标记为了@Deprecated,不再建议使用。

Date表示时刻,内部主要是一个long类型的值,如下所示:

1
private transient long fastTime;

fastTime表示距离纪元时的毫秒数,此处,关于transient关键字,我们暂时忽略。

Date有两个构造方法:

1
2
3
4
5
6
public Date(long date) {
fastTime = date;
}
public Date() {
this(System.currentTimeMillis());
}

第一个构造方法是根据传入的毫秒数进行初始化;第二个构造方法是默认构造方法,它根据System.currentTimeMillis()的返回值进行初始化。System.currentTimeMillis()是一个常用的方法,它返回当前时刻距离纪元时的毫秒数。

Date中的大部分方法都已经过时了,其中没有过时的主要方法有下面这些:

1
2
3
4
5
6
7
public long getTime() //返回毫秒数
public boolean equals(Object obj) //主要就是比较内部的毫秒数是否相同
//与其他Date进行比较,如果当前Date的毫秒数小于参数中的返回-1,相同返回0,否则返回1
public int compareTo(Date anotherDate)
public boolean before(Date when) //判断是否在给定日期之前
public boolean after(Date when) //判断是否在给定日期之后
public int hashCode() //哈希值算法与Long类似

2. TimeZone

TimeZone表示时区,它是一个抽象类,有静态方法用于获取其实例。获取当前的默认时区,代码为:

1
2
TimeZone tz = TimeZone.getDefault();
System.out.println(tz.getID());

获取默认时区,并输出其ID,在笔者的计算机中,输出为:

1
Asia/Shanghai

默认时区是在哪里设置的呢?可以更改吗?Java中有一个系统属性user.timezone,保存的就是默认时区。系统属性可以通过System.getProperty获得,如下所示:

1
System.out.println(System.getProperty("user.timezone"));

在笔者的计算机中,输出为:

1
Asia/Shanghai

系统属性可以在Java启动的时候传入参数进行更改,如:

1
java -Duser.timezone=Asia/Shanghai xxxx

TimeZone也有静态方法,可以获得任意给定时区的实例。比如,获取美国东部时区:

1
TimeZone tz = TimeZone.getTimeZone("US/Eastern");

ID除了可以是名称外,还可以是GMT形式表示的时区,如:

1
TimeZone tz = TimeZone.getTimeZone("GMT+08:00");

3. Locale

Locale表示国家(或地区)和语言,它有两个主要参数:一个是国家(或地区);另一个是语言,每个参数都有一个代码,不过国家(或地区)并不是必需的。比如,中国内地的代码是CN,中国台湾地区的代码是TW,美国的代码是US,中文语言的代码是zh,英文语言的代码是en。

Locale类中定义了一些静态变量,表示常见的Locale,比如:

  • Locale.US:表示美国英语。
  • Locale.ENGLISH:表示所有英语。
  • Locale.TAIWAN:表示中国台湾地区所用的中文。
  • Locale.CHINESE:表示所有中文。
  • Locale.SIMPLIFIED_CHINESE:表示中国内地所用的中文。

与TimeZone类似,Locale也有静态方法获取默认值,如:

1
2
Locale locale = Locale.getDefault();
System.out.println(locale.toString());

在笔者的计算机中,输出为:

1
zh_CN

4. Calendar

Calendar类是日期和时间操作中的主要类,它表示与TimeZone和Locale相关的日历信息,可以进行各种相关的运算。我们先来看下它的内部组成,与Date类似,Calendar内部也有一个表示时刻的毫秒数,定义为:

1
protected long time;

除此之外,Calendar内部还有一个数组,表示日历中各个字段的值,定义为:

1
protected int fields[];

这个数组的长度为17,保存一个日期中各个字段的值,都有哪些字段呢?Calendar类中定义了一些静态变量,表示这些字段,主要有:

  • Calendar.YEAR:表示年。
  • Calendar.MONTH:表示月,1月是0, Calendar同样定义了表示各个月份的静态变量,如Calendar.JULY表示7月。
  • Calendar.DAY_OF_MONTH:表示日,每月的第一天是1。
  • Calendar.HOUR_OF_DAY:表示小时,为0~23。
  • Calendar.MINUTE:表示分钟,为0~59。
  • Calendar.SECOND:表示秒,为0~59。
  • Calendar.MILLISECOND:表示毫秒,为0~999。
  • Calendar.DAY_OF_WEEK:表示星期几,周日是1,周一是2,周六是7,Calenar同样定义了表示各个星期的静态变量,如Calendar.SUNDAY表示周日。

Calendar是抽象类,不能直接创建对象,它提供了多个静态方法,可以获取Calendar实例,比如:

1
2
public static Calendar getInstance()
public static Calendar getInstance(TimeZone zone, Locale aLocale)

最终调用的方法都是需要TimeZone和Locale的,如果没有,则会使用上面介绍的默认值。getInstance方法会根据TimeZone和Locale创建对应的Calendar子类对象,在中文系统中,子类一般是表示公历的GregorianCalendar。

getInstance方法封装了Calendar对象创建的细节。TimeZone和Locale不同,具体的子类可能不同,但都是Calendar。这种隐藏对象创建细节的方式,是计算机程序中一种常见的设计模式,它有一个名字,叫工厂方法,getInstance就是一个工厂方法,它生产对象。

与new Date()类似,新创建的Calendar对象表示的也是当前时间,与Date不同的是, Calendar对象可以方便地获取年月日等日历信息。来看代码,输出当前时间的各种信息:

1
2
3
4
5
6
7
8
9
Calendar calendar = Calendar.getInstance();
System.out.println("year: "+calendar.get(Calendar.YEAR));
System.out.println("month: "+calendar.get(Calendar.MONTH));
System.out.println("day: "+calendar.get(Calendar.DAY_OF_MONTH));
System.out.println("hour: "+calendar.get(Calendar.HOUR_OF_DAY));
System.out.println("minute: "+calendar.get(Calendar.MINUTE));
System.out.println("second: "+calendar.get(Calendar.SECOND));
System.out.println("millisecond: " +calendar.get(Calendar.MILLISECOND));
System.out.println("day_of_week: " + calendar.get(Calendar.DAY_OF_WEEK));

具体输出与执行时的时间和默认的TimeZone以及Locale有关,比如,在笔者的计算机中的一次输出为:

1
2
3
4
5
6
7
8
year: 2016
month: 7
day: 14
hour: 13
minute: 55
second: 51
millisecond: 564
day_of_week: 2

内部,Calendar会将表示时刻的毫秒数,按照TimeZone和Locale对应的年历,计算各个日历字段的值,存放在fields数组中,Calendar.get方法获取的就是fields数组中对应字段的值。

Calendar支持根据Date或毫秒数设置时间:

1
2
public final void setTime(Date date)
public void setTimeInMillis(long millis)

也支持根据年月日等日历字段设置时间,比如:

1
2
3
4
public final void set(int year, int month, int date)
public final void set(int year, int month, int date,
int hourOfDay, int minute, int second)
public void set(int field, int value)

除了直接设置,Calendar支持根据字段增加和减少时间:

1
public void add(int field, int amount)

amount为正数表示增加,负数表示减少。

比如,如果想设置Calendar为第二天的下午2点15,代码可以为:

1
2
3
4
5
6
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.HOUR_OF_DAY, 14);
calendar.set(Calendar.MINUTE, 15);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);

Calendar的这些方法中一个比较方便和强大的地方在于,它能够自动调整相关的字段。比如,我们知道2月最多有29天,如果当前时间为1月30号,对Calendar.MONTH字段加1,即增加一月,Calendar不是简单的只对月字段加1,那样日期是2月30号,是无效的,Calendar会自动调整为2月最后一天,即2月28日或29日。

再如,设置的值可以超出其字段最大范围,Calendar会自动更新其他字段,如:

1
2
3
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR_OF_DAY, 48);
calendar.add(Calendar.MINUTE, -120);

相当于增加了46小时。

内部,根据字段设置或修改时间时,Calendar会更新fields数组对应字段的值,但一般不会立即更新其他相关字段或内部的毫秒数的值,不过在获取时间或字段值的时候, Calendar会重新计算并更新相关字段。

简单总结下,Calenar做了一项非常烦琐的工作,根据TimeZone和Locale,在绝对时间毫秒数和日历字段之间自动进行转换,且对不同日历字段的修改进行自动同步更新。

除了add方法,Calendar还有一个类似的方法:

1
public void roll(int field, int amount)

与add方法的区别是,roll方法不影响时间范围更大的字段值。比如:

1
2
3
4
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, 13);
calendar.set(Calendar.MINUTE, 59);
calendar.add(Calendar.MINUTE, 3);

calendar首先设置为13:59,然后分钟字段加3,执行后的calendar时间为14:02。如果add改为roll,即:

1
calendar.roll(Calendar.MINUTE, 3);

则执行后的calendar时间会变为13:02,在分钟字段上执行roll方法不会改变小时的值。

Calendar可以方便地转换为Date或毫秒数,方法是:

1
2
public final Date getTime()
public long getTimeInMillis()

与Date类似,Calendar之间也可以进行比较,也实现了Comparable接口,相关方法有:

1
2
3
4
public boolean equals(Object obj)
public int compareTo(Calendar anotherCalendar)
public boolean after(Object when)
public boolean before(Object when)

5. DateFormat

DateFormat类主要在Date和字符串表示之间进行相互转换,它有两个主要的方法:

1
2
public final String format(Date date)
public Date parse(String source)

format将Date转换为字符串,parse将字符串转换为Date。

Date的字符串表示与TimeZone和Locale都是相关的,除此之外,还与两个格式化风格有关,一个是日期的格式化风格,另一个是时间的格式化风格。DateFormat定义了4个静态变量,表示4种风格:SHORT、MEDIUM、LONG和FULL;还定义了一个静态变量DEFAULT,表示默认风格,值为MEDIUM,不同风格输出的信息详细程度不同。

与Calendar类似,DateFormat也是抽象类,也用工厂方法创建对象,提供了多个静态方法创建DateFormat对象,有三类方法:

1
2
public final static DateFormat getDateTimeInstance()
public final static DateFormat getDateInstance()

public final static DateFormat getTimeInstance()

getDateTimeInstance方法既处理日期也处理时间,getDateInstance方法只处理日期,get-TimeInstance方法只处理时间。看下面的代码:

1
2
3
4
5
6
7
8
9
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
System.out.println(DateFormat.getDateTimeInstance()
.format(calendar.getTime()));
System.out.println(DateFormat.getDateInstance()
.format(calendar.getTime()));
System.out.println(DateFormat.getTimeInstance()
.format(calendar.getTime()));

输出为:

1
2
3
2016-8-15 14:15:20
2016-8-15
14:15:20

每类工厂方法都有两个重载的方法,接受日期和时间风格以及Locale作为参数:

1
2
DateFormat getDateTimeInstance(int dateStyle, int timeStyle)
DateFormat getDateTimeInstance(int dateStyle, int timeStyle, Locale aLocale)

比如,看下面的代码:

1
2
3
4
5
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
System.out.println(DateFormat.getDateTimeInstance(DateFormat.LONG,
DateFormat.SHORT, Locale.CHINESE).format(calendar.getTime()));

输出为:

1
2016年8月15日 下午2:15

DateFormat的工厂方法里,我们没看到TimeZone参数,不过,DateFormat提供了一个setter方法,可以设置TimeZone:

1
public void setTimeZone(TimeZone zone)

DateFormat虽然比较方便,但如果我们要对字符串格式有更精确的控制,则应该使用SimpleDateFormat这个类。

6. SimpleDateFormat

SimpleDateFormat是DateFormat的子类,相比DateFormat,它的一个主要不同是,它可以接受一个自定义的模式(pattern)作为参数,这个模式规定了Date的字符串形式。先看个例子:

1
2
3
4
5
6
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
SimpleDateFormat sdf = new SimpleDateFormat(
"yyyy年MM月dd日 E HH时mm分ss秒");
System.out.println(sdf.format(calendar.getTime()));

输出为:

1
2016年08月15日 星期一 14时15分20秒

SimpleDateFormat有个构造方法,可以接受一个pattern作为参数,这里pattern是:

1
yyyy年MM月dd日 E HH时mm分ss秒

pattern中的英文字符a~z和A~Z表示特殊含义,其他字符原样输出,这里:

  • yyyy:表示4位的年。
  • MM:表示月,用两位数表示。
  • dd:表示日,用两位数表示。
  • HH:表示24小时制的小时数,用两位数表示。
  • mm:表示分钟,用两位数表示。
  • ss:表示秒,用两位数表示。
  • E:表示星期几。

这里需要特意提醒一下,hh也表示小时数,但表示的是12小时制的小时数,而a表示的是上午还是下午,看代码:

1
2
3
4
5
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd hh:mm:ss a");
System.out.println(sdf.format(calendar.getTime()));

输出为:

1
2016/08/15 02:15:20 下午

更多的特殊含义可以参看SimpleDateFormat的API文档。如果想原样输出英文字符,可以将其用单引号括起来。

除了将Date转换为字符串,SimpleDateFormat也可以方便地将字符串转化为Date,看代码:

1
2
3
4
5
6
7
8
9
String str = "2016-08-15 14:15:20.456";
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
try {
Date date = sdf.parse(str);
SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy年M月d h:m:s.S a");
System.out.println(sdf2.format(date));
} catch (ParseException e) {
e.printStackTrace();
}

输出为:

1
2016年8月15 2:15:20.456 下午

代码将字符串解析为了一个Date对象,然后使用另外一个格式进行了输出,这里SSS表示三位的毫秒数。需要注意的是,parse会抛出一个受检异常,异常类型为ParseException,调用者必须进行处理。

7.5.3 局限性

至此,关于Java 8之前的日期和时间相关API的主要内容基本就介绍完了。Date表示时刻,与年月日无关,Calendar表示日历,与时区和Locale相关,可进行各种运算,是日期时间操作的主要类,DateFormat/SimpleDateFormat在Date和字符串之间进行相互转换。这些API存在着一些局限性,下面强调一下。

1. Date中的过时方法

Date中的方法参数与常识不符合,过时方法标记容易被人忽略,产生误用。比如,看如下代码:

1
2
Date date = new Date(2016,8,15);
System.out.println(DateFormat.getDateInstance().format(date));

想当然的输出为2016-08-15,但其实输出为:

1
3916-9-15

之所以产生这个输出,是因为Date构造方法中的year表示的是与1900年的差,month是从0开始的。

2. Calendar操作比较烦琐

Calendar API的设计不是很成功,一些简单的操作都需要多次方法调用,写很多代码,比较臃肿。

另外,Calendar难以进行比较复杂的日期操作,比如,计算两个日期之间有多少个月,根据生日计算年龄,计算下个月的第一个周一等。

3. DateFormat的线程安全性

DateFormat/SimpleDateFormat不是线程安全的。关于线程概念,第15章会详细介绍,这里简单说明一下。多个线程同时使用一个DateFormat实例的时候,会有问题,因为DateFormat内部使用了一个Calendar实例对象,多线程同时调用的时候,这个Calendar实例的状态可能就会紊乱。

解决这个问题大概有以下方案:

  • 每次使用DateFormat都新建一个对象。
  • 使用线程同步(第15章介绍)。
  • 使用ThreadLocal(第19章介绍)。
  • 使用Joda-Time或Java 8的API,它们是线程安全的。