计算机程序的思维逻辑 (32) - 剖析日期和时间

1,987 阅读16分钟

本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》(马俊昌著),由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买:京东自营链接

本节和下节,我们讨论在Java中如何进行日期和时间相关的操作。

日期和时间是一个比较复杂的概念,Java API中对它的支持不是特别好,有一个第三方的类库反而特别受欢迎,这个类库是Joda-Time,Java 1.8受Joda-Time影响,重新设计了日期和时间API,新增了一个包java.time。

虽然之前的设计有一些不足,但Java API依然是被大量使用的,本节介绍Java 1.8之前API中对日期和时间的支持,下节介绍Joda-Time,Java 1.8中的新API与Joda-Time比较类似,暂时就不介绍了。

关于日期和时间,有一些基本概念,我们先来看下。

基本概念

时区

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

时刻和Epoch Time (纪元时)

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

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

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

如何表示1970年以前的时间呢?使用负数。

年历

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

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

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

简单总结下,时刻是一个绝对时间,对时刻的解读,如年月日周时分秒等,则是相对的,与年历和时区相关。

Java日期和时间API

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

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

还有两个相关的类:

  • TimeZone: 表示时区
  • Locale: 表示国家和语言

下面,我们来看这些类。

Date

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

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

private transient long fastTime;

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

Date有两个构造方法:

public Date(long date) {
    fastTime = date;
}

public Date() {
    this(System.currentTimeMillis());
}

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

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

返回毫秒数

public long getTime() 

判断与其他Date是否相同

public boolean equals(Object obj)

主要就是比较内部的毫秒数是否相同。

与其他Date进行比较

public int compareTo(Date anotherDate)

Date实现了Comparable接口,比较也是比较内部的毫秒数,如果当前Date的毫秒数小于参数中的,返回-1,相同返回0,否则返回1。

除了compareTo,还有另外两个方法,与给定日期比较,判断是否在给定日期之前或之后,内部比较的也是毫秒数。

public boolean before(Date when)
public boolean after(Date when)

哈希值

public int hashCode()

哈希值算法与Long类似。

TimeZone

TimeZone表示时区,它是一个抽象类,有静态方法用于获取其实例。

获取当前的默认时区,代码为:

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

获取默认时区,并输出其ID,在我的电脑上,输出为:

Asia/Shanghai

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

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

在我的电脑上,输出为:

Asia/Shanghai

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

java -Duser.timezone=Asia/Shanghai xxxx

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

获取美国东部时区

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

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

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

国家和语言Locale

Locale表示国家和语言,它有两个主要参数,一个是国家,另一个是语言,每个参数都有一个代码,不过国家并不是必须的。

比如说,中国的大陆代码是CN,台湾地区的代码是TW,美国的代码是US,中文语言的代码是zh,英文是en。

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

  • Locale.US:表示美国英语
  • Locale.ENGLISH:表示所有英语
  • Locale.TAIWAN:表示台湾中文
  • Locale.CHINESE:表示所有中文
  • Locale.SIMPLIFIED_CHINESE:表示大陆中文

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

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

在我的电脑上,输出为:

zh_CN

Calendar

Calendar类是日期和时间操作中的主要类,它表示与TimeZone和Locale相关的日历信息,可以进行各种相关的运算。

我们先来看下它的内部组成。

内部组成

与Date类似,Calendar内部也有一个表示时刻的毫秒数,定义为:

protected long  time;

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

protected int   fields[];

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

  • Calendar.YEAR:表示年
  • Calendar.MONTH:表示月,一月份是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是抽象类,不能直接创建对象,它提供了四个静态方法,可以获取Calendar实例,分别为:

public static Calendar getInstance()
public static Calendar getInstance(Locale aLocale)
public static Calendar getInstance(TimeZone zone)
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对象可以方便的获取年月日等日历信息。

来看代码,输出当前时间的各种信息:

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有关,在写作时,我的电脑上的输出为:

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或毫秒数设置时间:

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

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

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

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

public void add(int field, int amount)

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

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

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的这些方法中一个比较方便和强大的地方在于,它能够自动调整相关的字段。

比如说,我们知道二月份最多有29天,如果当前时间为1月30号,对Calendar.MONTH字段加1,即增加一月,Calendar不是简单的只对月字段加1,那样日期是2月30号,是无效的,Calendar会自动调整为2月最后一天,即2月28或29。

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

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还有一个类似的方法:

public void roll(int field, int amount)

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

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,即:

calendar.roll(Calendar.MINUTE, 3);

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

转换为Date或毫秒数

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

public final Date getTime()
public long getTimeInMillis() 

Calendar的比较

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

public boolean equals(Object obj)
public int compareTo(Calendar anotherCalendar)
public boolean after(Object when)
public boolean before(Object when)

DateFormat

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

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

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

Date的字符串表示与TimeZone和Locale都是相关的,除此之外,还与两个格式化风格有关,一个是日期的格式化风格,另一个是时间的格式化风格。

DateFormat定义了四个静态变量,表示四种风格,SHORT、MEDIUM、LONG和FULL,还定义了一个静态变量DEFAULT,表示默认风格,值为MEDIUM,不同风格输出的信息详细程度不同。

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

public final static DateFormat getDateTimeInstance()
public final static DateFormat getDateInstance()
public final static DateFormat getTimeInstance()

getDateTimeInstance既处理日期也处理时间,getDateInstance只处理日期,getTimeInstance只处理时间,看下面代码:

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()));

输出为:

2016-8-15 14:15:20
2016-8-15
14:15:20

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

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

比如,看下面代码:

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()));

输出为:

2016年8月15日 下午2:15

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

public void setTimeZone(TimeZone zone)

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

SimpleDateFormat

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

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()));

输出为:

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

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

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

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

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

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

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()));

输出为:

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

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

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

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();
}

输出为:

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

代码将字符串解析为了一个Date对象,然后使用另外一个格式进行了输出,这里SSS表示三位的毫秒数。

需要注意的是,parse会抛出一个受检异常(checked exception),异常类型为ParseException,调用者必须进行处理。

局限性

至此,关于Java 1.8之前的日期和时间相关API的主要内容,我们就介绍的差不多了,这里我们想强调一下这些API的一些局限性。

Date中的过时方法

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

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

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

3916-9-15

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

Calendar操作比较啰嗦臃肿

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

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

下一节,我们会介绍Joda-Time,相比Calendar,Joda-Time要简洁方便的多。

DateFormat的线程安全性

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

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

  • 每次使用DateFormat都新建一个对象
  • 使用线程同步
  • 使用ThreadLocal
  • 使用Joda-Time,Joda-Time是线程安全的

后续文章我们再介绍线程同步和ThreadLocal。

小结

本节介绍了Java中(1.8之前)的日期和时间相关API,Date表示时刻,与年月日无关,Calendar表示日历,与时区和Locale相关,可进行各种运算,是日期时间操作的主要类,DateFormat/SimpleDateFormat在Date和字符串之间进行相互转换。

这些API存在着一些不足,操作比较复杂,代码比较臃肿,还有线程安全的问题,实际中一个常用的第三方库是Joda-Time,下一节,让我们一起来看下。


未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。