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 | public Date(long date) { |
第一个构造方法是根据传入的毫秒数进行初始化;第二个构造方法是默认构造方法,它根据System.currentTimeMillis()的返回值进行初始化。System.currentTimeMillis()是一个常用的方法,它返回当前时刻距离纪元时的毫秒数。
Date中的大部分方法都已经过时了,其中没有过时的主要方法有下面这些:
1 | public long getTime() //返回毫秒数 |
2. TimeZone
TimeZone表示时区,它是一个抽象类,有静态方法用于获取其实例。获取当前的默认时区,代码为:
1 | TimeZone tz = TimeZone.getDefault(); |
获取默认时区,并输出其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 | Locale locale = Locale.getDefault(); |
在笔者的计算机中,输出为:
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 | public static Calendar getInstance() |
最终调用的方法都是需要TimeZone和Locale的,如果没有,则会使用上面介绍的默认值。getInstance方法会根据TimeZone和Locale创建对应的Calendar子类对象,在中文系统中,子类一般是表示公历的GregorianCalendar。
getInstance方法封装了Calendar对象创建的细节。TimeZone和Locale不同,具体的子类可能不同,但都是Calendar。这种隐藏对象创建细节的方式,是计算机程序中一种常见的设计模式,它有一个名字,叫工厂方法,getInstance就是一个工厂方法,它生产对象。
与new Date()类似,新创建的Calendar对象表示的也是当前时间,与Date不同的是, Calendar对象可以方便地获取年月日等日历信息。来看代码,输出当前时间的各种信息:
1 | Calendar calendar = Calendar.getInstance(); |
具体输出与执行时的时间和默认的TimeZone以及Locale有关,比如,在笔者的计算机中的一次输出为:
1 | year: 2016 |
内部,Calendar会将表示时刻的毫秒数,按照TimeZone和Locale对应的年历,计算各个日历字段的值,存放在fields数组中,Calendar.get方法获取的就是fields数组中对应字段的值。
Calendar支持根据Date或毫秒数设置时间:
1 | public final void setTime(Date date) |
也支持根据年月日等日历字段设置时间,比如:
1 | public final void set(int year, int month, int date) |
除了直接设置,Calendar支持根据字段增加和减少时间:
1 | public void add(int field, int amount) |
amount为正数表示增加,负数表示减少。
比如,如果想设置Calendar为第二天的下午2点15,代码可以为:
1 | Calendar calendar = Calendar.getInstance(); |
Calendar的这些方法中一个比较方便和强大的地方在于,它能够自动调整相关的字段。比如,我们知道2月最多有29天,如果当前时间为1月30号,对Calendar.MONTH字段加1,即增加一月,Calendar不是简单的只对月字段加1,那样日期是2月30号,是无效的,Calendar会自动调整为2月最后一天,即2月28日或29日。
再如,设置的值可以超出其字段最大范围,Calendar会自动更新其他字段,如:
1 | Calendar calendar = Calendar.getInstance(); |
相当于增加了46小时。
内部,根据字段设置或修改时间时,Calendar会更新fields数组对应字段的值,但一般不会立即更新其他相关字段或内部的毫秒数的值,不过在获取时间或字段值的时候, Calendar会重新计算并更新相关字段。
简单总结下,Calenar做了一项非常烦琐的工作,根据TimeZone和Locale,在绝对时间毫秒数和日历字段之间自动进行转换,且对不同日历字段的修改进行自动同步更新。
除了add方法,Calendar还有一个类似的方法:
1 | public void roll(int field, int amount) |
与add方法的区别是,roll方法不影响时间范围更大的字段值。比如:
1 | Calendar calendar = Calendar.getInstance(); |
calendar首先设置为13:59,然后分钟字段加3,执行后的calendar时间为14:02。如果add改为roll,即:
1 | calendar.roll(Calendar.MINUTE, 3); |
则执行后的calendar时间会变为13:02,在分钟字段上执行roll方法不会改变小时的值。
Calendar可以方便地转换为Date或毫秒数,方法是:
1 | public final Date getTime() |
与Date类似,Calendar之间也可以进行比较,也实现了Comparable接口,相关方法有:
1 | public boolean equals(Object obj) |
5. DateFormat
DateFormat类主要在Date和字符串表示之间进行相互转换,它有两个主要的方法:
1 | public final String format(Date date) |
format将Date转换为字符串,parse将字符串转换为Date。
Date的字符串表示与TimeZone和Locale都是相关的,除此之外,还与两个格式化风格有关,一个是日期的格式化风格,另一个是时间的格式化风格。DateFormat定义了4个静态变量,表示4种风格:SHORT、MEDIUM、LONG和FULL;还定义了一个静态变量DEFAULT,表示默认风格,值为MEDIUM,不同风格输出的信息详细程度不同。
与Calendar类似,DateFormat也是抽象类,也用工厂方法创建对象,提供了多个静态方法创建DateFormat对象,有三类方法:
1 | public final static DateFormat getDateTimeInstance() |
public final static DateFormat getTimeInstance()
getDateTimeInstance方法既处理日期也处理时间,getDateInstance方法只处理日期,get-TimeInstance方法只处理时间。看下面的代码:
1 | Calendar calendar = Calendar.getInstance(); |
输出为:
1 | 2016-8-15 14:15:20 |
每类工厂方法都有两个重载的方法,接受日期和时间风格以及Locale作为参数:
1 | DateFormat getDateTimeInstance(int dateStyle, int timeStyle) |
比如,看下面的代码:
1 | Calendar calendar = Calendar.getInstance(); |
输出为:
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 | Calendar calendar = Calendar.getInstance(); |
输出为:
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 | Calendar calendar = Calendar.getInstance(); |
输出为:
1 | 2016/08/15 02:15:20 下午 |
更多的特殊含义可以参看SimpleDateFormat的API文档。如果想原样输出英文字符,可以将其用单引号括起来。
除了将Date转换为字符串,SimpleDateFormat也可以方便地将字符串转化为Date,看代码:
1 | String str = "2016-08-15 14:15:20.456"; |
输出为:
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 | Date date = new Date(2016,8,15); |
想当然的输出为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,它们是线程安全的。