計算機程式的思維邏輯 (32) - 剖析日期和時間

swiftma發表於2016-10-19

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (32) - 剖析日期和時間

本節和下節,我們討論在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程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (32) - 剖析日期和時間

相關文章