深入理解計算機時間系統-Java應用篇

注:本人原創,首發於https://mp.weixin.qq.com/s/oMQ–gLOGMXpblM4g8TnZA。轉載請註明出處。


什麼是時間?這是一個物理概念和哲學問題。物理學認爲時間是一種尺度,一個標量,藉着時間,事件發生之先後可以按過去-現在-未來之序列得以確定(時間點),進而事件之間的間隔長短亦得以衡量(時間段)。哲學上認爲時間是宇宙的基本結構,是一個會依序列方式出現的維度。或主張時間“本身並不存在,而是我們表達事物方式的產物”。
故宮日晷

計算機科學是建立在現實物理世界的基礎上的,要儘量匹配地球自轉公轉的結果,同時要匹配一系列人爲規定的概念(如時區、夏令時)。這就帶來了一系列問題:計算機如何描述及存儲時間點和時間段、如何匹配不同時區和計時方式、如何轉換時間的表示方法、如何獲取當前時間、如何控制時間精度、如何感知時間流逝等一系列問題。本篇文章盡筆者能力清晰深入地探究這個問題。

一. 常識知識

1. 時區

時區是地球上的同一塊區域使用的同一個時間定義。世界各個國家位於地球不同位置上,因此不同國家,特別是東西跨度大的國家日出、日落時間必定有所偏差。這些偏差就是所謂的時差。

2. 夏令時

所謂“夏令時”(Daylight Saving Time,簡稱D.S.T.),是指在夏天太陽昇起的比較早時,將時鐘撥快一小時,以提早日光的使用。這個構想於1784年由美國班傑明·富蘭克林提出來,1915年德國成爲第一個正式實施夏令日光節約時間的國家,以削減燈光照明和耗電開支。 進夏令時時間要撥快一小時,出夏令時時間再撥回來。但這跟UTC或GMT完全沒有關係,完全是人爲行爲。

3. UTC和GMT

  • UTC是“協調世界時”(Universal Time Coordinated)的英文縮寫,是由國際無線電諮詢委員會規定和推薦,並由國際時間局(BIH)負責保持的以秒爲基礎的時間標度。個人理解爲按規定的統一計量單位延伸的時間標度。
  • GMT(Greenwich Mean Time)是格林尼治平均時間。由於地球軌道並非圓形,其運行速度又隨着地球與太陽的距離改變而出現變化。在格林尼治子午線上的平太陽時稱爲世界時(UT0),又叫格林尼治平時(GMT)。 個人理解爲實際觀測計算不受人爲控制的太陽運行週期的時間標度。
  • 若以“世界標準時間”的角度來說,UTC比GMT來得更加精準。兩者誤差值必須保持在0.9秒以內,若大於0.9秒則由位於巴黎的國際地球自轉事務中央局發佈閏秒,使UTC與地球自轉週期一致。

二. Java關於日期時間的獲取、表示及格式轉換

時間的表示可以分爲時間點和時間段。時間點又可以分爲“相對時間”和“絕對時間”(不是相對論裏那個),人們一般理解表述的“現在幾點”、“掛鐘上顯示什麼時間”是“相對時間”,即本地時間,是沒有時區屬性的。但是如果要表示一個客觀發生的時間點就要用到“絕對時間”,這個時間點在每個時區的掛鐘上顯示的都不同。
時間的表示還關係到精度問題,如精確到天、秒還是毫秒、納秒,都有不同的表示方法。
下面以Java爲例,較爲詳細地介紹關於日期時間的獲取、表示及格式轉換方法。

1. System.currentTimeMillis()

這是我們最常用的獲取當前時間的方法,靜態方法System.currentTimeMillis() 返回UTC時間從1970年1月1日00:00到現在的總毫秒數,返回類型爲long。我們所有需要做的就是一行代碼:

Long time = System.currentTimeMillis();

ps:爲什麼是從1970年1月1日開始?
Unix是1969年發佈的雛形,最早是基於硬件60Hz的時間計數。1971年底出版的《Unix Programmer’s Manual》裏定義的Unix Time是以1971年1月1日00:00:00作爲起始時間,每秒增長60。之後考慮到32位整數的範圍,如果每秒60個數字,則兩年半就會循環一輪。於是改成了以秒爲計數單位。這個循環週期有136年之長,就不在乎起始時間是1970還是1971年了,於是就改成了人工記憶、計算比較方便的1970年。
“The date was programmed into the system sometime in the early 70s only because it was convenient to do so, according to Dennis Ritchie, one the engineers who worked on Unix at Bell Labs at its inception.”

趣聞:32位Unix時間戳的範圍是 1971年1月1日00:00:00 ~ 2038年1月19日03:14:07(UTC),超過這一範圍則會越界。2016年出現過蘋果用戶將手機時間設爲1971年之前,然後iPhone變磚了。現在iPhone的解決方法是不允許手動設置年份 😛

注意,java.lang包在該方法的註釋中提到,當返回值的時間單位是毫秒時,值的粒度取決於底層操作系統,可能粒度會大於1ms。同時高併發場景下要小心該方法的性能消耗。爲什麼會這樣?什麼時候會出現這種情況?下篇會從該方法的源碼入手深入探究。

2. System.nanoTime()

Java7的API文檔中說明:該方法返回正在運行的Java虛擬機的高分辨率時間源的當前值,以納秒爲單位。此方法只能用於測量經過的時間,與系統或鐘錶時間等任何其他概念無關。在同一個Java虛擬機實例中,此方法的所有調用都使用相同的時間原點,其他虛擬機實例可能使用不同的時間原點。此方法提供納秒級精度,但不一定是納秒級分辨率,但是最少和 currentTimeMillis() 方法的分辨率一樣高。

也就是說,nanoTime() 方法返回的數字絕對值沒有意義,僅當計算在Java虛擬機的同一實例中獲得的兩個此值之間的差異時,此方法返回的值纔有意義。常用的方法是:

Long startTime = System.nanoTime();
doSomething();
Long estimatedTime = System.nanoTime() - startTime;

那所謂的“隨機起點”在不同平臺上是如何實現的?System.nanoTime() 和 System.currentTimeMillis() 有沒有什麼關係?也會在下篇中一併提及。

3. java.util.Date

Date是Java最早提供的用來封裝日期時間的類,由於不易於國際化且很多參數計算不符合日常認知或不正確(具體可以見源碼),很多獲取年、月、日、小時等數據的方法都過時了不推薦使用(@Deprecated),被Calendar類的方法代替。這裏選一些還在使用的關鍵字段和方法進行說明。
Date類有兩個關鍵的成員變量:

// 記錄當前時間戳
private transient long fastTime;

/*
 * cdate對象是 BaseCalendar.Date類,繼承自sun.util.calendar.CalendarDate。
 * 包含很多已計算好的日期時間相關變量,如 dayOfWeek(所在星期的第幾天)、leapYear(是否是閏年)等。
 * 如果 cdate 對象爲空,用 fastTime 變量代表精確到毫秒的時間。
 * 如果 cdate.isNormalized() 方法返回 true,則 fastTime 和 cdate 已經同步過。
 * 如果 cdate.isNormalized() 方法返回 false,則忽略 fastTime 的值,使用 cdate 代表時間。
 */
private transient BaseCalendar.Date cdate;

Date類提供的兩個構造函數,看源碼清晰明瞭:

// 無參構造方法,創建當前時間的Date類
public Date() {
    this(System.currentTimeMillis());
}
// 傳入一個Unix時間戳,創建特定時間的Date類
public Date(long date) {
    fastTime = date;
}
// 其他通過年月日創建的構造方法已被 Calendar.set() 和 DateFormat.parse() 等方法替代,不再展示

Date類型存儲日期時間實際存儲的是Unix時間戳,所以可以表示絕對時間,支持絕對時間的比較。典型的Date類型數據結構如下圖:
Date類型的數據結構舉例

一個小問題:上文我們看到構造方法中並沒有賦值 cdate 變量,那麼調試的時候顯示的 cdate 是如何被初始化的呢?
答案是:IDE調試的時候爲了顯示變量值,調用了 toString 方法,至於爲什麼會初始化,參考該類 toString() 方法源碼。

Date類還有很多常用的成員方法,可以用 long getTime( ) 和 void setTime(long time) 進行該Date對象日期時間的獲取和設定(毫秒級別);可以用 boolean after(Date date)、boolean before(Date date)、int compareTo(Date date)、boolean equals(Object date)等方法比較兩個日期時間的先後順序。具體的比較簡單,不展開詳述。

4. java.sql.Date、java.sql.Time 和 java.sql.Timestamp

java.sql.Date、java.sql.Time 和 java.sql.Timestamp 都繼承自 java.util.Date 類,是專門用於數據庫連接的。由於繼承關係,從數據結構來看和它們的父類區別不大。最主要的區別在於 Timestamp 類可以表示至納秒級,其 fastTime 字段從秒之後被截掉,毫秒至納秒精度保存在特有的 nanos 字段中。可參考下圖:
java.sql.Date、java.sql.Time 和 java.sql.Timestamp的時間表示
但是要注意 Timestamp 類的納秒精度可能是“假的”,構造方法源碼如下:

public Timestamp(long time) {
    super((time/1000)*1000);
    nanos = (int)((time%1000) * 1000000);
    if (nanos < 0) {
        nanos = 1000000000 + nanos;
        super.setTime(((time/1000)-1)*1000);
    }
}

可以看出,在將 fastTime 字段強行截掉之後,進行 毫秒值直接乘1,000,000 的操作後賦給了 nanos 字段,成爲了“只能表示到毫秒的納秒級精確度”。當然,還可以通過 setNanos(int n) 方法給納秒數賦精確值。

雖然數據結構看來沒什麼特別,但是如果涉及到Timestamp類的父子類型轉換或時間的比較,就要小心一些“坑”。

  1. equals() 方法的不對稱性
    java.sql.Timestamp 類和其父類 java.util.Date 的 equals() 方法是不符合對稱性的。舉例如下:
    equals() 方法的不對稱性
    這是由於java.sql.Timestamp 類的 equals() 方法對於非本類的實例直接返回false,jdk中給出瞭解釋:

The Timestamp.equals(Object) method never returns true when passed an object that isn’t an instance of java.sql.Timestamp, because the nanos component of a date is unknown. As a result, the Timestamp.equals(Object) method is not symmetric with respect to the java.util.Date.equals(Object) method. Also, the hashCode method uses the underlying java.util.Date implementation and therefore does not include nanos in its computation.
意爲:傳遞一個不是java.sql.Timestamp實例的對象時,Timestamp.equals(Object)方法永遠不會返回true,因爲日期的nanos組件是未知的。因此,Timestamp.equals(Object)方法與java.util.Date.equals(Object)方法不對稱。此外,hashCode方法使用底層的java.util.Date實現,因此在其計算中不包括nanos。

equals() 源碼如下:

public boolean equals(java.lang.Object ts) {
    if (ts instanceof Timestamp) {
        return this.equals((Timestamp)ts);
    } else {
        // 非Timestamp類型直接返回false
        return false;
    }
}
// Timestamp類型的equals判斷
public boolean equals(Timestamp ts) {
    if (super.equals(ts)) {
        if  (nanos == ts.nanos) {
            return true;
        } else {
            return false;
        }
    } else {
        return false;
    }
}
  1. 時間比較類方法的“異常”
    現象舉例如下,兩個有毫秒之差的時間點,after() 方法返回不符合客觀事實:
    compareTo() 和 after() 方法返回不同
    探究其原因。
    父類 java.util.Date 中 after() 方法的實現如下:
public boolean after(Date when) {
    return getMillisOf(this) > getMillisOf(when);
}

java.sql.Timestamp 類沒有重寫 after(Date d) 方法,只寫了after(Timestamp t) 方法,如下:

public boolean after(Timestamp ts) {
    return compareTo(ts) > 0;
}

所以上圖傳參爲 java.util.Date 類,程序走的是父類的 after() 方法,而 java.sql.Timestamp 類也沒有重寫 getMillisOf() 方法,所以也是使用父類的:

static final long getMillisOf(Date date) {
    if (date.cdate == null || date.cdate.isNormalized()) {
        return date.fastTime;
    }
    BaseCalendar.Date d = (BaseCalendar.Date) date.cdate.clone();
    return gcal.getTime(d);
}

上文有提到,java.util.Date 會對 fastTime 和 cdate 進行同步,由於 Timestamp 類在其繼承父類的 fastTime 和 cdate 變量中不存儲毫秒數據,所以調用父類的 after() 方法時, 只有毫秒差異的時間調用 getMillisOf() 方法返回的結果是相同的。所以,java.sql.Timestamp 向父類 java.util.Date轉型時會丟失毫秒
JDK文檔中對此的說明爲:

Due to the differences between the Timestamp class and the java.util.Date class mentioned above, it is recommended that code not view Timestamp values generically as an instance of java.util.Date. The inheritance relationship between Timestamp and java.util.Date really denotes implementation inheritance, and not type inheritance.
意爲:建議代碼不要將 Timestamp 值一般視爲java.util.Date的實例。 Timestamp 和 java.util.Date 之間的繼承關係實際上表示實現繼承,而不是類型繼承

如果不確定類型的情況下要進行時間的比較,儘量使用 compareTo() 方法,可以保證正確性。

5. java.util.Calendar

Calendar類是一個日曆抽象類,提供了一組對年月日時分秒星期等日期信息的操作的函數,並針對不同國家和地區的日曆提供了相應的子類,即本地化。比如公曆 GregorianCalendar ,佛曆(泰國使用)BuddhistCalendar,日本歷 JapaneseImperialCalendar 等(沒有中國農曆太不友好了=_=)。從JDK1.1版本開始,在處理日期和時間時系統推薦使用Calendar類進行實現。在設計上,Calendar類的功能要比Date類強大很多,而且在實現方式上也比Date類要複雜一些。
首先我們來直觀地看一下Calendar類能表示些什麼,打印一個新建的Calendar實例:

// 代碼:
Calendar calendar = Calendar.getInstance();
System.out.println(calendar);

// 打印結果,字段含義都是字面意思:
java.util.GregorianCalendar[
    time=1564912275912,
    areFieldsSet=true, 
    areAllFieldsSet=true, 
    lenient=true, 
    zone=sun.util.calendar.ZoneInfo[
        id="Asia/Shanghai", 
        offset=28800000, 
        dstSavings=0, 
        useDaylight=false, 
        transitions=19, 
        lastRule=null
    ], 
    firstDayOfWeek=1, 
    minimalDaysInFirstWeek=1, 
    ERA=1, 
    YEAR=2019, 
    MONTH=7, 
    WEEK_OF_YEAR=32, 
    WEEK_OF_MONTH=2, 
    DAY_OF_MONTH=4, 
    DAY_OF_YEAR=216, 
    DAY_OF_WEEK=1, 
    DAY_OF_WEEK_IN_MONTH=1, 
    AM_PM=1, 
    HOUR=5, 
    HOUR_OF_DAY=17, 
    MINUTE=51, 
    SECOND=15, 
    MILLISECOND=912, 
    ZONE_OFFSET=28800000, 
    DST_OFFSET=0
]

Calendar類可以通過靜態工廠方法或new子類的方式來獲得實例:

  1. getInstance()方法,有四個重載方法,參數是時區和地區,如果不傳會取服務器默認的時區和地區。(地區現在是專門爲了區分泰國和日本)
    1.1 getInstance()
    1.2 getInstance(TimeZone zone)
    1.3 getInstance(Locale aLocale)
    1.4 getInstance(TimeZone zone,Locale aLocale)
  2. 新建子類對象
Calendar calendar = new GregorianCalendar();

Calendar類可以實現帶時區的年月日時分秒星期等對Unix時間戳的轉換,內部通過子類複雜的 computeTime() 方法進行計算。可以使用 getTime() 方法返回 java.util.Date 類型的時間,可以使用 getTimeInMillis() 方法返回當前Unix時間戳,也可以通過 get(int field) 方法獲取其他年月日等單獨信息,部分可用 field 列表如下:

常量 含義
Calendar.YEAR 年份
Calendar.MONTH 月份
Calendar.DATE 日期
Calendar.DAY_OF_MONTH 日期,和上面的字段意義完全相同
Calendar.HOUR 12小時制的小時
Calendar.HOUR_OF_DAY 24小時制的小時
Calendar.MINUTE 分鐘
Calendar.SECOND
Calendar.DAY_OF_WEEK 星期幾
Calendar.DAY_OF_YEAR 今年的第幾天

也可以通過多個 set 重載方法設定各種值。
同時, add() 方法支持對單個值的加減,從而實現時間推移的計算,傳入負數即爲減,示例如下:
Calendar類時間推移計算
GregorianCalendar 對象可以直接使用 isLeapYear(int year) 接口判斷是否閏年。
要注意兩個設定上的問題:在 Calendar 中 MONTH 這個域並不是從1到12的,而是0表示一月,11表示十二月。 DAY_OF_WEEK 域星期天是1,星期一是2,依次類推。爲了避免用錯,Calendar 類已經爲我們定義好了常量,如一月可以直接 Calendar.JANUARY

6. java.text.SimpleDateFormat

SimpleDateFormat 是一個以語言環境敏感的方式來格式化和分析日期的類。SimpleDateFormat 允許選擇任何用戶自定義的日期時間格式來運行。如:
SimpleDateFormat日期時間格式化
還有更多可表示的模式,對應符號不在此給出。

值得一提的是,在後端接口開發時,接口返回的日期時間格式可能是和框架序列化方式有關的。如 springboot 中使用 jackson 作爲默認的 json 工具,不同版本 jackson 對於日期時間的默認序列化方式不同。1.5.10.RELEASE 版本的 springboot 默認 2.8.10 版本的 jackson,Date類返回的默認格式是Unix時間戳;2.0.5.RELEASE 版本的 springboot 默認 2.9.6 版本的 jackson,Date類返回的默認格式類似 “2019-08-04T13:43:21.535+0000” 。如果想規定返回格式可以在 spring 中配置,或直接使用 SimpleDateFormat 格式化成 String 後再返回。

7. Java7中日期時間類的線程安全問題

症狀如下圖,開多個線程使用同一個 SimpleDateFormat 實例,會出現解析失敗:
線程安全問題舉例
說明在多線程場景下 SimpleDateFormat 是有線程安全問題的。究其原因,SimpleDateFormat 類繼承自 DateFormat 類,DateFormat 實例中維護了一個 Calendar 對象,parse() 方法會調用 Calendar 對象的方法去根據給定格式設置屬性值,而 Calendar 對象的 fields、time、zone 等表示字段都是線程不安全的。如果 SimpleDateFormat 是單例,Calendar 對象一定也是多線程共用一個的。
解決方法:

  1. 使用局部變量
    這也是我們常用的方法,每次請求新建一個 SimpleDateFormat 的實例。雖然常用,但是實際開銷是較大的;
  2. 給 parse() 方法加 synchronized
    既然是由於調用 Calendar 設置時出的線程安全問題,加鎖當然可以解決。但是系統性能會下降,權衡利弊個人認爲還不如1方法;
  3. 使用 ThreadLocal 爲每個線程維護一個 SimpleDateFormat 實例,起碼同一線程內可以共享一個實例減少了不少開銷,上述代碼可修改如下:
public class Main {
    private static ThreadLocal<DateFormat> sdfThreadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

    public static void main(String[] args) {
        for (int i = 0; i < 100; ++i) {
            Thread thread = new Thread(() -> {
                try {
                    System.out.println(sdfThreadLocal.get().parse("2019-08-04 22:17:27"));
                } catch (Exception e) {
                    System.out.println("解析失敗");
                }
            });
            thread.start();
        }
    }
}

8. Java8 中的新類型

由於舊版 Java 中的日期時間 API 存在線程不安全、某些設計不符合日常直覺、時區處理複雜等問題,Java8 中提供了一些新的 API。包括Instant、LocalDate、LocalTime、LocalDateTime、ZonedDateTime、Period、Duration、DateTimeFormatter等。
首先直觀看一下這些類裏都有什麼:
Java8 中新日期時間類概覽

8.1. Instant

Instant,中文可譯爲“瞬間”,表示了時間線上一個確切的點,可以表示納秒級別的時刻(雖然 now() 構造方法得出的納秒數和 java.sql.Timestamp 類一樣也是“假的”,是從 System.currentTimeMillis() 得來的)。Instant是時區無關的,如何理解這個“時區無關”?即始終是對標協調世界時(UTC)即格林尼治零時區的,個人覺得可以理解爲“Unix時間戳的更精確表示形式”。
Instant 類有四種實例化方法:
Instant 類的四種實例化方法
由上上圖可知,Instant 對象中保存了 seconds(距離初始時間的秒數)和 nanos(當前秒的第幾納秒),可以通過以下get開頭的方法獲取,傳入 field 也可以獲取毫秒、微秒級的時間。
Instant的多種get方法

8.2. LocalDate、LocalTime 和 LocalDateTime

字面含義,LocalDate 表示本地日期,LocalTime 表示本地時間,LocalDateTime 表示日期加時間。Java8中支持日期和時間的分別表示。

API都較爲簡單,來講兩個需要理解的注意點:

  • 爲什麼叫“Local”?
    Local 表示“本地時間”,即和時區沒有關係。比如“你的生日是哪天”,並沒有人會說“格林尼治時間的幾月幾日”,而只是像日曆頁上的一格,“幾月幾日”的概念;再比如“新年的鐘聲幾點敲響”,也不會全球在同一時間過新年,而是當地掛鐘上的零點,沒有時區屬性。那什麼樣的時間不是“Local”的?就是時間線上的一個固定時間點,事情就在那一刻發生了,雖然地球上每個角落的太陽位置不同,牆上掛鐘顯示的數字也不同,但都是時間這個座標軸上的同一點。比如北京時間2003年10月15日9時00分03秒497毫秒,神舟五號成功發射,就不是一個“Local”的時間。
  • 什麼叫“分別表示日期和時間”?
    LocalDate 類只表示日期,而不是這個日期所在的時間(如java.util.Date中的 2019-08-05 表示的實際是這一天的00:00這個瞬間)。

以 LocalDate 爲例說明API,剩餘兩個類大同小異。
LocalDate 可以通過三種方法創建實例:
LocalDate 創建實例
可以通過各種get方法得到日期相關字段,如字面意思:
LocalDate 的多種 get 方法
可以增減字段值:
LocalDate 支持增減字段值
以及一些原來要很複雜代碼的操作,現在可以簡化:
取各種關聯日期的操作
還可以獲取指定時區的當前日期時間,或添加時區屬性,轉化成下面要介紹的 ZonedDateTime,注意這裏沒有進行時間的時區變換,而是僅僅添加了時區屬性,更印證了上文說的“Local”的含義。拿 LocalDateTime 舉例:
LocalDateTime 轉化成 ZonedDateTime

8.3. ZonedDateTime

ZonedDateTime 可以被理解爲 LocalDateTime 的外層封裝,它的內部存儲了一個 LocalDateTime 的實例,專門用於普通的日期時間處理,此外它還定義了 ZoneId 實例和 ZoneOffset 實例來描述時區的概念。調試信息顯示如下:
ZonedDateTime 數據結構

產生 ZonedDateTime 實例的幾種方法如下,如字面意思較好理解:

public static ZonedDateTime now();
public static ZonedDateTime now(ZoneId zone);
public static ZonedDateTime of(LocalDate date, LocalTime time, ZoneId zone)
public static ZonedDateTime of(LocalDateTime localDateTime, ZoneId zone)
public static ZonedDateTime ofInstant(Instant instant, ZoneId zone)
public static ZonedDateTime of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneId zone)

其他方法操作和 LocalDateTime 類似,不多贅述。

8.4. DateTimeFormatter

DateTimeFormatter 類作爲 Java8 中用於表示日期時間的類,與原有DateFormat 類最大的不同就在於它是線程安全的,其他使用上的操作基本類似。舉例如下:
DateTimeFormatter 進行時間格式轉換

8.5. Period 和 Duration

Java8 添加了處理時間差的功能,用 Period 處理兩個日期之間的差值,用 Duration 處理兩個時間之間的差值。between() 方法等大大簡化了計算兩個日期時間之間差值的操作,舉例如下:
方便的差值計算

8.6. Java8 日期時間小結

簡單介紹了 Java8 的一些處理日期時間的新API,可以說對比之前的版本是有很大的改進的。

  • 首先,原有的 Date、Calendar 等類過於泛泛,既可以表示日期又可以表示時間,還能進行時區轉換,結果就是各方面都差點意思。Java8 區分了日期和時間的分別表示,使得不同的業務需求有專門對應的數據結構進行設計;
  • 其次,由於 java.sql.Date、java.sql.Time、java.sql.Timestamp 都繼承自 java.util.Date,所以本質上他們都是時區相關的。Java8 區分了本地時間和帶時區的時間的表示,ZonedDateTime 的時區轉換也非常方便;
  • 再次,提供了時間差的直接計算方法,不用先換算成Unix時間戳再做減法再做除法等麻煩的步驟;
  • 最重要的是,他們都是不可變類!!!線程安全!!!

三. 對時間的存儲

講完表示再來看日期時間的存儲方法。以MySQL數據庫爲例,介紹數據存儲的方式,以及與Java程序的交互。

1. MySQL的日期時間類型介紹

將MySQL提供的幾種日期時間數據結構列表如下:

類型名稱 佔用空間 展示格式 表示範圍
YEAR 1 bytes YYYY 1901——2155
DATE 4 bytes YYYY-MM-DD 1000-01-01——9999-12-31
TIME 3 bytes HH:MM:SS -838:59:59——838:59:59
DATETIME 8 bytes YYYY-MM-DD HH:MM:SS 1000-01-01 00:00:00——9999-12-31 23:59:59
TIMESTAMP 4 bytes YYYY-MM-DD HH:MM:SS 1970-01-01 00:00:01——2038-01-19 03:14:07 (UTC)

1.1. YEAR 類型用於表示年份,默認是4位,可以直接插入4位數字或字符串。由於YEAR類型佔用空間很小,如果只想表示年份,並在其表示範圍內,不失是一種很好的選擇。
1.2. DATE 類型用於表示日期,以 YYYY-MM-DD 格式顯示。指“日曆頁上的日期”,沒有時區概念,類似於 Java8 中的 LocalDate。
1.3. TIME 類型用於表示時間,以 HH:MM:SS 格式顯示,精度爲秒。指“掛鐘顯示的時間”,沒有時區概念,類似於 Java8 中的 LocalTime。
1.4. DATETIME 類型是 DATE 和 TIME 的結合,佔8位,它把日期和時間封裝到格式爲 “YYYYMMDDHHMMSS” 的整數中,可以記錄較 TIMESTAMP 更長的時間。沒有時區概念,類似於 Java8 中的 LocalDateTime。
1.5. TIMESTAMP 類型也是表示日期加時間,但是表示的時間較短,和32位 Unix 時間戳相同。TIMESTAMP 類型表示的時間與時區有關,MySQL服務器、操作系統、客戶端連接等都有時區設置,插入日期時會先轉換爲本地時區後再存放,查詢日期時會將日期轉換爲本地時區後再顯示。如果插入時沒有指定 TIMESTAMP 列的值,則系統默認設置爲 ‘0000-00-00 00:00:00’,也可以手動設置爲添加當前時間。

2. MySQL的日期時間類型比較與選擇

YEAR、DATE、TIME 三種類型都功能不同,YEAR 存年份,DATE 存日期,TIME 存時間,按業務需求進行挑選即可。
主要比較 DATETIME 和 TIMESTAMP 類型:

  • 時區屬性不同:DATETIME 無時區屬性,TIMESTAMP支持時區變換;
  • 表示範圍不同:DATETIME 表示範圍更大,爲1000-01-01 00:00:00——9999-12-31 23:59:59,TIMESTAMP 只能表示32位Unix時間戳的範圍;
  • 空間佔用不同:TIMESTAMP 只要 4 bytes,效率更高。
  • 綜上:若有明確的需要時區轉換或不需要時區轉換的問題,則根據業務需求選擇對應的,否則會出現邏輯錯誤;else if 32位Unix時間戳的範圍夠用則推薦選擇 TIMESTAMP 類型,因爲空間效率更高。

還有一種可選項:每次涉及日期時間時全部用Unix時間戳表示,Java中用long,MySQL中用INT類型,詳見如何正確地處理時間-廖雪峯。好處是體現了“存儲與顯示分離”的原則,且易於比較。但是肉眼無法快速識別時間戳確實帶來了很大的麻煩,況且Java和MySQL開發出那麼多類型就是爲了方便使用(不然上文全都白講了),也可以解決大多數問題,所以個人並不推薦這種做法(也可能是開發經驗不夠,沒有理解到廖老師這個點的精髓)。

3. 與Java的交互

筆者自己總結了Java 和 MySQL 日期時間數據類型的一種映射關係:

Java類型 MySQL映射
java.sql.Date DATE
java.sql.Time TIME
java.sql.Timestamp TIMESTAMP
java.time.LocalDate DATE
java.time.LocalTime TIME
java.time.LocalDateTime DATETIME
java.time.ZonedDateTime TIMESTAMP
java.time.Instant TIMESTAMP

在MySQL數據庫創建表包含各種類型的字段用於測試:

  • DATETIME、TIMESTAMP類型默認精確到秒,如需毫秒或更高精度,需手動指定字段長度,如下:
CREATE TABLE `test_time` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `time1` date DEFAULT NULL,
  `time2` time DEFAULT NULL,
  `time3` year(4) DEFAULT NULL,
  -- 長度爲3精確到毫秒  
  `time4` datetime(3) DEFAULT NULL,
  -- 長度爲6精確到微秒  
  `time5` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=latin1;

手動指定時間精度

  • 使用spring mybatis generator 插件創建 model,自動創建的數據格式都是 java.util.Date。將數據讀出時,無關聯時區的數據格式都會加上當前系統默認時區,缺少的數據會用缺省值填充。這是一種非常浪費且繁瑣且容易出錯的方式。如下圖:
    java.util.Date 類缺少的數據用缺省值填充
  • MySQL 版本在5.1.37以上的,驅動在4.2以上的,可以使用Java8中的新類型,幾乎可以說完美匹配。
    MySQL數據結構和Java8新日期時間數據結構完美匹配

四. 時區轉換的操作

需要時區轉換的時間一定不是“掛鐘上的時間”,而是時間軸上確定的一個“絕對時間”。所以時區轉換分爲兩個方面:由被展示的字符串添加某時區信息後轉爲Java對象,或由固定時區的Java對象轉換時區後展示。下面各種方式實現這兩個轉換:

1. 無腦加減操作

根據目標時區和原時區的時差直接加減,“硬核轉換”,極不推薦。

2. Date + SimpleDateFormat

如下圖(注意,轉爲Date對象的時候自動變爲了系統時區):Date + SimpleDateFormat 時區轉換或者更簡單的利用“z”這個域:Date + SimpleDateFormat 時區轉換

3. ZonedDateTime + DateTimeFormatter

ZonedDateTime + DateTimeFormatter 時區轉換

4. 用時間戳處理

用各種方法得到該時間點的時間戳,然後轉化爲Java對象,添加時區信息,輸出。

5. 與MySQL的交互轉換

按照上述MySQL與Java交互中所述,將MySQL存儲的時間轉換爲Java對象,然後按照2,3方法轉換即可。

五. 總結

本篇文章全面貼近實際開發,首先從日常代碼遇到的問題出發,介紹了一些常識和會遇到的問題。
隨後介紹了Java中日期時間的獲取、數據格式表示及格式轉換方法。其中深入源碼詳細介紹了Java7中的日期時間數據結構,拆解了可能會遇到的線程安全問題及解決辦法,並在使用層面介紹了Java8中日期時間新API及其優點,源碼中的複雜計算方法有待今後研究。
接着在存儲方面介紹了MySQL的日期時間類型及如何選擇的建議,並給出了與Java各種日期時間類型的轉換示例。
最後根據時區轉換的需求給出各種數據結構的時區轉換操作方法。

本篇爲上篇-應用篇,下篇中會詳細解釋一些底層日期時間的處理,如爲什麼不同操作系統獲取當前時間的速度有數量級差異;高併發場景用 System.currenTimeMillis() 會出現什麼問題及怎麼解決;Linux中有哪些時間相關係統調用及他們的區別;系統對於類似 Thread.sleep(long millis) 的“時間段”長度是如何控制的;以上這些底層問題如何影響我們的程序設計等。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章