DelayedOperationPurgatory之Timer

purgatory的超时检测

当一个DelayedOpeartion超时(timeout)时,它需要被检测出来,然后调用它的回调方法。这个事情看起来很简单,但做好也并不容易。

0.8.x的Kafka的实现简单明了,但是效率不高。这些版本的Kafka的delayed request实现了java.util.concurrent.DelayQueue要求的DelayedItem接口。这些请求被放个DelayQueue, 然后有一个专门的线程从DelayQueue里poll这些请求出来,所以被poll出来的元素也就是已过期的元素。

这样做的坏处是DelayQueue里插入和删除的时间复杂度为O(logn),当其中元素很多时,还是挺费CPU。而且这个实现中,当一个请求被satisfied以后,它并没有被立即从DelayedQueue里移除(因为删除特定的元素的时间复杂度为O(n)),而是隔一定数目的请求就遍历这个Queue,从中移除元素(这样做的开销会小于单独移除特定的元素)。所以,如果这个purge interval设置不好的话,就可能会OOM~

0.9.0开始,purgatory采用了新的基于timing wheel的实现,可以参见之前的blog中翻译的Kafka之Purgatory Redesign Proposal (翻译)

下面看一下0.9.0的源码里对于超时检测的实现。

TimingWheel

TimingWheel的原理可以看一下这篇文章惊艳的时间轮定时器Kafka之Purgatory Redesign Proposal (翻译)也有提到其实现的原理。

Kafka的实现大体上跟通用的Timing Wheel差不多,但是结合了一些Kafka使用的特点,比如使用DelayQueue来驱动时间轮,以及bucket采用双端链表的实现。

注释里讲的例子

TimingWheel类的注释里也讲到了它的原理,并且举了一个例子,可以方便理解它的运作。下面介绍一下注释里提到的例子。不过大体看下是怎么回事就行了,这个注释本身的逻辑就有些问题(如果不是我理解错的话)。而且这个例子只是说明了一下概念。

u代表最小的时间粒度,n代表时间轮的大小,即一个时间轮有多少个桶。设u等于1,等于3,起始时间是c。那么此时,不同级别的桶是下面这样的:

* level    buckets* 1        [c,c]   [c+1,c+1]  [c+2,c+2]* 2        [c,c+2] [c+3,c+5]  [c+6,c+8]* 3        [c,c+8] [c+9,c+17] [c+18,c+26]

bucket超时(expire)的时间,依据于bucket的起始时间。所以在c+1时刻,[c, c], [c, c+2]和[c, c+8]就都超时了。

级别1的时钟移动到到c+1, 并且创建了[c+3, c+3]

级别2和级别3的时钟停在c,因为它们的时钟移动的单位分别是3和9。所以,级别2和3并不会有新的桶创建。

需要说明的是,级别2的[c, c+2]不会收到任何task,因为这个区间已经被级别1覆盖了。对于级别3的[c, c+8]也是一样,因为它的这个区间被级别2覆盖了。这样做有些浪费,但是却简化了实现。

* 1        [c+1,c+1]  [c+2,c+2]  [c+3,c+3]* 2        [c,c+2]    [c+3,c+5]  [c+6,c+8]* 3        [c,c+8]    [c+9,c+17] [c+18,c+26]

在c+2时刻,[c+1, c+1]变得超时。级别1的时钟走到了c+2, 并且创建了[c+4, c+4]。

* 1        [c+2,c+2]  [c+3,c+3]  [c+4,c+4]* 2        [c,c+2]    [c+3,c+5]  [c+6,c+8]* 3        [c,c+8]    [c+9,c+17] [c+18,c+18]

在c+3时刻,[c+2, c+2]变得超时,级别2移动到了c+3,并且创建了[c+5, c+5]和[c+9, c+11]

* 1        [c+3,c+3]  [c+4,c+4]  [c+5,c+5]* 2        [c+3,c+5]  [c+6,c+8]  [c+9,c+11]* 3        [c,c+8]    [c+9,c+17] [c+8,c+11]

设计TimingWheel应考虑的问题

整体的设计就是按照proposal提到的来,但是有些细节需要考虑一下

先得明确一些东西的定义:

  • time unit 对一个TimingWheel,它走一个格对应的物理时间定义为这个TimingWheel的time unit。在源码中,tickMs这个TimingWheel的构造器参数决定了它的time unit。
  • bucket 一个bucket代表了一个时间段,它的结束时间 - 开始时间 + 1  = time unit,开始时间(物理时间)总是time unit的整数倍。一个TimingWheel由固定数量的bucket组成,这些bucket的代表的时间段互不重叠,并且完全覆盖了这个TimingWheel当前代表的整个时间段。
  • size 一个TimingWheel的大小即是它包含多少个bucket,也就是它走一圈的时间/time unit
  • current time 当前这个TimgWheel的指针指向的那个bucket的开始时间。因此current time也总是time unit的整数倍。

实际上,上述的概念可以有不同的定义,这些定义也决定了TimingWheel的一个特定实现。上边提到的定义,是跟源码里的TimingWheel的行为一致的。

当前时间的bucket的expire time

有了这些概念,就可确定一个事情,这个事情就是上边提到的注释里有些混乱的一个概念:是否认为current time所指的那个bucket是expire的?这个概念也决定了当TimingWheel tick一次的时候,是新指向的bucket变得过期,还是之前的bucket变得过期。在一个hierachical timing wheel中,高级的bucket在过期以后,需要把它里边的元素重新插入低级的timing wheel, 以保证整个timer(所以这些timing wheel构成一个timer)的计时精度。这就决定了,一个bucket的expire time就是这个bucket的start time。根据current time的定义,这也就决定了一个TimingWheel当前指向的那个bucket是一个已经expire的bucket,也就是在这个wheel 走动的时候,它新指向的那个bucket变得过期。

在Kafka中,bucket对应于TimerTaskList类。

何时会overflow

一个timing wheel有它可以host的时间段。这个时间段由几个参数决定

  • currenTime 当前时间
  • tickMs 每个bucket的大小,在Kafka中物理时间的单位是毫秒。所以,tickMs是指一个桶有多少毫秒
  • wheelSize 这个timing wheel有多少个bucket

根据这些参数,一个timing wheel可以存放的request的过期时间分布在[currentTime, currentTime + tickMs * wheelSize - 1], 包含两端的时间。

当把一个request加到一个timing wheel,而这个request的expire time超过了上边时间段的右端,就需要把它溢出到更高级timing wheel。

根据expire time把元素放在适当的桶内

这个操作的关键在于控制它的时间复杂度,它实际上可以分成两步(这个有点像把大象放进冰箱要几步……):

  1. 找到合适的桶
  2. 把item放到桶里

找到合适的桶

每个timing wheel的桶的数目是固定的,这比较适于构建一个bucket array来保存桶,况且数组的寻址更放一些。Kafka里的实现是使用的数组。

那么已知一个item, 已知它的expire time(并且没有溢出),它应该放在哪个桶里呢?

如果我们把整个时间域按照这个timing wheel的tickMs无数多的桶,第一个桶的开始时间是Unix epoch的0毫秒,那么可以根据expire time求出这个item应该放入的桶的编号,也就是expire time/tickMs。

例如,当前的timing wheel里的桶的编号为0, 1, 2, 3。 currentTime为0。

在tick一次以后,0号桶就可以被重用了,我们自然可以把4号桶放进去。这时,这个timing wheel的桶编号为4, 1, 2, 3。仍然符合我们前边提到的一个timing wheel的边界。同样,再tick一次以后,这个timing wheel的桶的编号变成了4, 5, 2, 3。这种方式,相当于每个桶能放的值为它在数组里的index + n * wheelSize (n为非负整数)。

这样就有了根据expireTime确定桶的index的公式: (expireTime/tickMs) % wheelSize。

      // Put in its own bucket
      val virtualId = expiration / tickMs
      val bucket = buckets((virtualId % wheelSize.toLong).toInt)
      bucket.add(timerTaskEntry)

      // Set the bucket expiration time
      if (bucket.setExpiration(virtualId * tickMs)) {
        queue.offer(bucket)
      }

这里也可以看出来一个bucket的expirationTime是virtualId * tickMs, 也就是它的起始时间。

注意,只有保证每个timing wheel里所有元素的时间都在[currentTime, currentTime + tickMs * wheelSize - 1],这个公式才不违反我们之前对timing wheel规则的定义。

放在桶里

在Kafka里,bucket是用双端链表实现的,因此insert一个元素的开销是O(1)的。

Timer的运转

以上对于Timing Wheel的规则的约定,只是定义了一种数据结构和它的运转规则。但是Timing Wheel自身是不会随着物理时间的前进而改变的,也是就说它需要外部驱动。

如何驱动

对于Timing Wheel而言,这走动就是它的current time前进。随着current time的改变,会有bucket变得过期,其它人可以从Timing Wheel中取出这些桶处理(这里需要考虑到同步的问题,即tick这个动作需要持有timing wheel的锁)。改变current time,对于Kafka的实现对应于Timing Wheel的advance方法。

现在有两个问题需要解决:

  1. 如何根据使得TimingWheel根据物理时间走动。前边的那个purgatory redesign proposal中提到过,一个简单的方式是使用一个线程周期性地醒来,驱动TimingWheel前进,但这样做的坏处是当Timing Wheel里的元素(准确是说是非空的桶)很稀疏时,周期性地唤醒线程检查的是一种浪费。所以Kafka使用了一种“按需”唤醒线程的方式,也就是DelayQueue。每个bucket的实现了Delay接口,getDelay(获取超时时间)返回这个bucket的expire time,也就是它的开始时间。ExpiredOperationReaper线程会通过DelayQueue的poll方法阻塞自己(当前的实现是最多阻塞200毫秒,因为默认的最低级的wheel的tickMs为1ms, 所以这个timeout时间是可以接受的),当有bucket expire,它会从DelayQueue里取出它来。Kafka的ExpiredOperationReaper的doWork方法(它会被线程一执调用)是这样的

        override def doWork() {
          timeoutTimer.advanceClock(200L)
            estimatedTotalOperations.getAndSet(delayed)
            debug("Begin purging watch lists")
            val purged = allWatchers.map(_.purgeCompleted()).sum
            debug("Purged %d elements from watch lists.".format(purged))
          }

    它会调用timeoutTimer的advanceClock方法,来改变timer的当前时间(不大对呀,还有很多事要做的)。是的,不只是改变当前时间这会简单,改变timer的当前时间一定和处理超时的bucket是一体的。而且advanceClock(200L)并不是把Timing wheel前进200ms的意思,而是传进去了一个200ms的超时时间……。Timer的advanceClock方法实际上长这样,感觉叫做"processExpiredBucketsAndAdvanceClock"更靠谱点~

      def advanceClock(timeoutMs: Long): Boolean = {
        var bucket = delayQueue.poll(timeoutMs, TimeUnit.MILLISECONDS) //取出超时的bucket,最多阻塞timeoutMs,当前的实现中就是200ms
        if (bucket != null) {
          writeLock.lock() //持有写锁(ReentrantReadWriteLock里的写锁),开始处理超时的bucket
          try {
            while (bucket != null) {
              timingWheel.advanceClock(bucket.getExpiration()) //改变timing wheel的当前时间至这个超时的bucket的expire time
              bucket.flush(reinsert)//处理bucket里的元素
              bucket = delayQueue.poll()//继续poll其它超时的元素
            }
          } finally {
            writeLock.unlock()//释放锁
          }
          true
        } else {
          false
        }
      }

    这里边的点让人困惑的是timingWheel.advanceClock(bucket.getExpiration())这一句,它把timing wheel的当前时间设成了bucket的expire time。为什么是这个时间呢?因为bucket的expire time就是它的起始时间,由于poll总是取出最新过期的bucket,所以poll出来的bucket的expire time基本就是当前的物理时间。所以这么设置以后,当writeLock被释放后,往timer插入新的元素时,才能根据这个元素的物理时间放入到合适的桶里。如果bucket的curernt time跟物理时间差距太大,那么timer的计时就会不准(timer的准确性在后边会分析一下)。

  2. 过期的桶如何处理?由于Kafka的bucket是一个数组里的元素,因此除非在过期后复制出里边的所有元素,否则就需要在持有Timing Wheel的锁期间完成所有元素在过期后的回调函数,清空这个bucket,然后才能释放锁。因此,如果在驱动器timer前进的线程里调用这些这些元素的回调函数(比如返回响应啥的),那么这个驱动器线程持有锁的时间可能会相当长,而且在它处理完一元素之前,是不能驱动timer前进的,由于处理时间不可控,所以timer就不准了。所以Kafka使用了一个单独的线程池来执行回调。

     private def addTimerTaskEntry(timerTaskEntry: TimerTaskEntry): Unit = {
        if (!timingWheel.add(timerTaskEntry)) {
          // Already expired or cancelled
          if (!timerTaskEntry.cancelled)
            taskExecutor.submit(timerTaskEntry.timerTask)
        }
      }

    当timingWheel.add返回false时,代表这个timerTaskEntry(也就是bucket里的一个元素,它持有一个TimerTask,DelayedOperation继承了TimerTask)要不是已期expire了,要不是被cancel了。如果是expire了,就把它持有的TimerTask提交到taskExecutor这个ExecutorSerivce中(TimerTask实现了runnable接口)。对于DelayedOperation,也就是是它的forceComplete回调会被执行,对于DelayedFetch和DelayedOperation,也就是会产生和发送响应。

    对于过期的bucket,分成两类:1.里边的item都过期了,需要提交给taskExecutor执行 2. 这是一个高级的Timing Wheel,因此里边的元素需要放入低级别的Timing Wheel。Kafka对二者进行了统一抽象,即用一个reinsert方法完成上边两种处理,而reinsert调用的也就是上边的addTimerTaskEntry方法。

    private[this] val reinsert = (timerTaskEntry: TimerTaskEntry) => addTimerTaskEntry(timerTaskEntry)

    addTimerTaskEntry方法会调用TimingWheel的add方法,它会根据TaskEntry的不同状态,进行不同的处理

      def add(timerTaskEntry: TimerTaskEntry): Boolean = {
        val expiration = timerTaskEntry.timerTask.expirationMs
    
        if (timerTaskEntry.cancelled) {
          // Cancelled
          false
        } else if (expiration < currentTime + tickMs) {
          // Already expired
          false
        } else if (expiration < currentTime + interval) {
          // Put in its own bucket
          ...
          true
        } else {
          // Out of the interval. Put it into the parent timer
          if (overflowWheel == null) addOverflowWheel()
          overflowWheel.add(timerTaskEntry)
        }
      } 

Timer的准确性

理想情况下,Timer应该在一个TimerTask的expiration time检测到它超时,然后执行回调。但是,实际情况是这样的吗? 之所以会有此疑问,是由于以下几点:

  1. 驱动它的是前边提到的这些逻辑,而不是直接调用的JDK跟时间有关的方法(像是Thread.sleep这种的)。那就得看一下这些逻辑能不能使timer准确
  2. timer本身是有精度的,就是tickMs。

假设一个bucket在物理时间T被poll了出来,T恰好等于这个bucket的expiration time, 也就是它的起始时间。bucket的expiration time是由TimingWheel的add方法确定的

      val virtualId = expiration / tickMs
      val bucket = buckets((virtualId % wheelSize.toLong).toInt)
      bucket.add(timerTaskEntry)
      // Set the bucket expiration time
      if (bucket.setExpiration(virtualId * tickMs)) {
        queue.offer(bucket)
      }

所以,一个TimerTask被放进的那个桶的的expiration time实际上是根据这个TimerTask的expiration time确定的,这是一个物理时间。由此可知,一个TimerTask总是在自己所属的那个相于Unix epoch 0时刻的全局bucket(也就是id为virtualId那个bucket)的起始时刻被poll出来。但是被poll出来并不代表着这个TimerTask的回调会被执行,实际的执行时刻取决于接下怎么办。

被poll出来的bucket里的元素会经过Timer#reinsert -> Timer#addTimerTaskEntry ->  TimingWheel#add 方法,来决定怎么办。这个处理过程前边提到过,重点是,只有被add方法认为是expire和TimerTask,才会被提交到线程池执行。而add方法是根据current time来决定TimerTask是否过期的。

    val expiration = timerTaskEntry.timerTask.expirationMs

    if (timerTaskEntry.cancelled) {
      // Cancelled
      false
    } else if (expiration < currentTime + tickMs) {
      // Already expired
      false
    } else if (expiration < currentTime + interval) {
      // Put in its own bucket

这里就遇到了物理时间和Timer的本地时间不一致的问题,即expiration和currentTime的区别。

currentTime时在poll出来一个bucket以后确定的,逻辑是在Timer的advanceClock方法里,前边也提到过。

    if (bucket != null) {
      writeLock.lock()
      try {
        while (bucket != null) {
          timingWheel.advanceClock(bucket.getExpiration())
          bucket.flush(reinsert)
          bucket = delayQueue.poll()
        }

所以bucket的本地时间,总是落后于物理时间的,落后多少取决于poll返回的时刻与bucket的expiration time的差别,以及获取写锁的时间。这里需要注意的是,这个timingWheel就是最低层的那个timingWheel,而且advanceClock方法会更新它的所有上层TimingWheel的currentTime。

reinsert方法执行前已经更新了timingWheel的currentTime,而TimerTasklist的flush方法会对所有的TimerTask执行resinsert, 而flush方法不会检测这个TimerTaskList里的TimerTask的超时时间,因为它们属于同一个tickMs,这属于由于tickMs带来的误差。

现在的问题在于,如果poll出来的是一个高层级的Timing那Wheel里的bucket,那么接下来的处理会不会带来误差。

假设最低级别的TimingWheel的tickMs是1ms, wheelSize是4,那么第二级的bucket的tickMs就是4,设它的wheelSize也是3。那么,第三级的TimingWheel的tickMs就是12.

1. 设最低级别的TimingWheel比物理时间落后2ms。

假设poll出来的这个bucket里元素为a b c, 它们的过期时间分别为4 5 6 (物理时间)。reinsert执行时的物理时间为6, 而TimingWheel的currentTime为4.

那么在reinsert执行时,所有这个bucket里的元素都被认为已经过期,这是正确的。

2. 假设最低级别的TimingWheel比物理时间落后超过了tickMs, 设落后8ms。

poll出来的是第三级别的bucket,reinsert执行时的物理时间为 21, 这个bucket是在物理时间13被poll出来的,因此前三级wheel的currenTime被设成了12。

poll出来的这个bucket的元素a b c的过期时间分别是 14 20 22。 那么最低层TimingWheel的add方法会认为expiration time在[12, 15]的元素已经过期,所以b和c不会被立即提交给taskExecutor线程池执行,而是被重新插入到最低级的TimingWheel。而由于最低级的TimingWheel的溢出阀值为15,所以b和c会被提交给它的上一级TimingWheel,而第二级TimingWheel可以管理的TimerTask的范围是[12, 23], 因此b和c会被交给第二级的TimingWheel。

但无论如何,它总是会被加入到正确的桶里(这个桶的过期时间符合这个TimerTask的过期时间),这个bucket会在下一次(或者再隔几次……)的poll中被取出来。

只要最低级的TimingWheel的currentTime不会一直固定在一个值,已过期的TimerTask就一定会被提交执行。而且它被执行时间取决于它之前有多少个已经过期的元素,即它不会被无限期地延后(starve), 尽管它可能会在过期后仍然在TimingWheel的层次结构间倒腾。

那么,就可以认为一个TimerTask实际被执行的时间取决于

  1. tickMs带来的误差。由于tickMs的原因,一个TimerTask可能会被分配到一个expiration time比它小的bucket里。但是DelayedOprationPurgatory把tickMs设成1ms, 所以这个误差可以忽略。
  2. Reaper线程从DelayQueue里poll元素的延迟和处理poll出来的元素带来的延迟。这些基本是不可控的。这会使得一个TimerTask肯定会在expiration time时刻之后的某时刻被提交执行。GC和CPU时间的分配都会影响这个延迟。别一个重要因素是获取writeLock的时间,由于只有reapder线程会尝试获取writeLock,所以它只需要面对获取readLock线程的竞争。而每次往Timer add一个TimerTask都会获取readLock。因此只要这个ReentranceReadWriteLock是一个公平锁,这么做就没问题。但是Kafka在实现Timer时并没有把这个ReentrantReadWriteLock设成fair mode。

      // Locks used to protect data structures while ticking
      private[this] val readWriteLock = new ReentrantReadWriteLock()

    因此,如果对锁的竞争很厉害,理论上来说,Reaper可能获取不了写锁,也就是说可能会使JVM产生OOM。

总结

总之,可以认为在压力低时(reaper可以迅速地从DelayQueue中获取元素,并且对读写锁的竞争不激烈,reaper线程可以获取充足的CPU时间), Timer还是挺准确的。如果吞吐量非常大,那就难说了。而且,由于非公平的读写锁,可能会使得插入TimerTask的速度大于取出TimerTask的速度,造成OOM。

有些地方分析的可能不正确,希望读的人能够指出。

时间: 2024-12-23 23:35:46

DelayedOperationPurgatory之Timer的相关文章

PIC32MZ tutorial -- 32-bit Timer

The microcontroller is PIC32MZ2048ECH144 on the PIC32MZ EC Starter Kit. This microcontroller has four 32-bit synchronous timers are available by combining Timer2 with Timer3, Timer4 with Timer5, Timer6 with Timer7, and Timer8 with Timer9. The 32-bit

Timer和TimeTask简介

Timer和TimeTask简介 Timer是一种线程设施,用于安排以后在后台线程中执行的任务.可安排任务执行一次,或者定期重复执行,可以看成一个定时器,可以调度TimerTask.TimerTask是一个抽象类,实现了Runnable接口,所以具备了多线程的能力. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import java . util . TimerTask ; public class MyTask extends TimerTask { private in

duilib 界面库 实现timer定时器

看了大神介绍的duilib感觉已被同龄人狠狠地甩在背后.所以痛下决心,之后要多花时间写代码. 大神教程传送门: http://www.cnblogs.com/Alberl/p/3341956.html 现在的问题是想基于duilib实现一个timer定时器.工程基础大概是在 http://www.cnblogs.com/Alberl/p/3343763.html 因为自己的东西是基于大神的东西写的,所以要把大神的教程看得差不多才知道我在说什么.O(∩_∩)O~~ 前台大概长这个样子: 稍微修改了

Timer类调度任务

Timer类中常用的方法有: public void schedule(TimerTask task,long delay,long period): 重复地以固定的延迟时间去执行一个任务.public void scheduleAtFixedRate(TimerTask,long delay, long period): 重复地以固定的频率去执行一个任务. public void cancel();   终止计时器, 丢弃所有当前已安排的任务. Timer类调度任务,布布扣,bubuko.co

Java Timer 定时器的使用

设置定时任务很简单,用Timer类就搞定了. 一.延时执行首先,我们定义一个类,给它取个名字叫TimeTask,我们的定时任务,就在这个类的main函数里执行. 代码如下:package test;import java.util.Timer;public class TimeTaskTest {   public static void main(String[] args){ Timer timer = new Timer();       timer.schedule(new Task()

C# 定时器Timer

static void Main(string[] args) { #region 定时器 TimerDemo td = new TimerDemo("TimerDemo", 1000); td.Enabled = true; td.TickEvent += TestHandler; Thread timer = new Thread(td.Run); timer.Start(); #endregion Console.ReadLine(); } /// <summary>

Java之旅--定时任务(Timer、Quartz、Spring、LinuxCron)

在Java中,实现定时任务有多种方式.本文介绍4种.Timer和TimerTask.Spring.QuartZ.Linux Cron. 以上4种实现定时任务的方式.Timer是最简单的.不须要不论什么框架,只JDK就能够.缺点是不过个时间间隔的定时器,调度简单.Spring和QuartZ都支持cron,功能都非常强大,Spring的长处是略微简单一点,QuartZ的长处是没有Spring也可使用:Linux Cron是个操作系统级别的定时任务.适用于全部操作系统支持的语言,缺点是精度只能到达分钟

WinForm Timer控件,三级联动[省,市,区]

Timer控件: 组件中的最后一个控件,功能是可以根据用户自定义的时间间隔来触发时间,不会印象窗体本身的其他事件进行. 属性: Enable  设置控件是否启用 Interval  设置事件的频率,以毫秒为单位 事件只有一个:Tick事件 例:使用timer控件获取当前时间并即时变动 private void timer1_Tick(object sender, EventArgs e) { label1.Text = DateTime.Now.ToString("yyyy年MM月dd日hh时m

Date、Calendar、DateFormat、SimpleDateFormat、Timer、TimerTask类

类 Date 在 JDK 1.1 之前,类 Date 有两个其他的函数.它允许把日期解释为年.月.日.小时.分钟和秒值. 它也允许格式化和解析日期字符串.不过,这些函数的 API 不易于实现国际化.从 JDK 1.1 开始,应 该使用 Calendar 类实现日期和时间字段之间转换,使用 DateFormat 类来格式化和解析日期字符串. Date 中的相应方法已废弃. Date() 分配 Date 对象并初始化此对象,以表示分配它的时间(精确到毫秒). Date(long date) 分配 D