深入理解计算机时间系统-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.HOUR12小时制的小时
Calendar.HOUR_OF_DAY24小时制的小时
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提供的几种日期时间数据结构列表如下:

类型名称占用空间展示格式表示范围
YEAR1 bytesYYYY1901——2155
DATE4 bytesYYYY-MM-DD1000-01-01——9999-12-31
TIME3 bytesHH:MM:SS-838:59:59——838:59:59
DATETIME8 bytesYYYY-MM-DD HH:MM:SS1000-01-01 00:00:00——9999-12-31 23:59:59
TIMESTAMP4 bytesYYYY-MM-DD HH:MM:SS1970-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.DateDATE
java.sql.TimeTIME
java.sql.TimestampTIMESTAMP
java.time.LocalDateDATE
java.time.LocalTimeTIME
java.time.LocalDateTimeDATETIME
java.time.ZonedDateTimeTIMESTAMP
java.time.InstantTIMESTAMP

在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) 的“时间段”长度是如何控制的;以上这些底层问题如何影响我们的程序设计等。

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值