A beginner’s guide to Java time zone handling
Basic time notions
Most web applications have to support different time-zones and properly handling time-zones is no way easy. To make matters worse, you have to make sure that timestamps are consistent across various programming languages (e.g. JavaScript on the front-end, Java in the middle-ware and MongoDB as the data repository). This post aims to explain the basic notions of absolute and relative time.
Epoch
An epoch is a an absolute time reference. Most programming languages (e.g Java, JavaScript, Python) use the Unix epoch (Midnight 1 January 1970) when expressing a given timestamp as the number of milliseconds elapsed since a fixed point-in-time reference.
Relative numerical timestamp
The relative numerical timestamp is expressed as the number of milliseconds elapsed since epoch.
Time zone
The coordinated universal time (UTC) is the most common time standard. The UTC time zone (equivalent to GMT) represents the time reference all other time zones relate to (through a positive/negative offset).
UTC time zone is commonly refereed as Zulu time (Z) or UTC+0. Japan time zone is UTC+9 and Honolulu time zone is UTC-10. At the time of Unix epoch (1 January 1970 00:00 UTC time zone) it was 1 January 1970 09:00 in Tokyo and 31 December 1969 14:00 in Honolulu.
ISO 8601
ISO 8601 is the most widespread date/time representation standard and it uses the following date/time formats:
Time zone | Notation |
---|---|
UTC | 1970-01-01T00:00:00.000+00:00 |
UTC Zulu time | 1970-01-01T00:00:00.000+Z |
Tokio | 1970-01-01T00:00:00.000+09:00 |
Honolulu | 1969-12-31T14:00:00.000-10:00 |
Java time basics
java.util.Date
java.util.Date is definitely the most common time-related class. It represents a fixed point in time, expressed as the relative number of milliseconds elapsed since epoch. java.util.Date is time zone independent, except for the toString method which uses a the local time zone for generating a String representation.
java.util.Calendar
The java.util.Calendar is both a Date/Time factory as well as a time zone aware timing instance. It’s one of the least user-friendly Java API class to work with and we can demonstrate this in the following example:
@Test public void testTimeZonesWithCalendar() throws ParseException { assertEquals(0L, newCalendarInstanceMillis("GMT").getTimeInMillis()); assertEquals(TimeUnit.HOURS.toMillis(-9), newCalendarInstanceMillis("Japan").getTimeInMillis()); assertEquals(TimeUnit.HOURS.toMillis(10), newCalendarInstanceMillis("Pacific/Honolulu").getTimeInMillis()); Calendar epoch = newCalendarInstanceMillis("GMT"); epoch.setTimeZone(TimeZone.getTimeZone("Japan")); assertEquals(TimeUnit.HOURS.toMillis(-9), epoch.getTimeInMillis()); } private Calendar newCalendarInstance(String timeZoneId) { Calendar calendar = new GregorianCalendar(); calendar.set(Calendar.YEAR, 1970); calendar.set(Calendar.MONTH, 0); calendar.set(Calendar.DAY_OF_MONTH, 1); calendar.set(Calendar.HOUR_OF_DAY, 0); calendar.set(Calendar.MINUTE, 0); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); calendar.setTimeZone(TimeZone.getTimeZone(timeZoneId)); return calendar; }
At the time of Unix epoch (the UTC time zone), Tokyo time was nine hours ahead, while Honolulu was ten hours behind.
Changing a Calendar time zone preserves the actual time while shifting the zone offset. The relative timestamp changes along with the Calendar time zone offset.
Joda-Time and Java 8 Date Time API simply make java.util.Calandar obsolete so you no longer have to employ this quirky API.
org.joda.time.DateTime
Joda-Time aims to fix the legacy Date/Time API by offering:
- both immutable and mutable date structures
- a fluent API
- better support for ISO 8601 standard
With Joda-Time this is how our previous test case looks like:
@Test public void testTimeZonesWithDateTime() throws ParseException { assertEquals(0L, newDateTimeMillis("GMT").toDate().getTime()); assertEquals(TimeUnit.HOURS.toMillis(-9), newDateTimeMillis("Japan").toDate().getTime()); assertEquals(TimeUnit.HOURS.toMillis(10), newDateTimeMillis("Pacific/Honolulu").toDate().getTime()); DateTime epoch = newDateTimeMillis("GMT"); assertEquals("1970-01-01T00:00:00.000Z", epoch.toString()); epoch = epoch.toDateTime(DateTimeZone.forID("Japan")); assertEquals(0, epoch.toDate().getTime()); assertEquals("1970-01-01T09:00:00.000+09:00", epoch.toString()); MutableDateTime mutableDateTime = epoch.toMutableDateTime(); mutableDateTime.setChronology(ISOChronology.getInstance().withZone(DateTimeZone.forID("Japan"))); assertEquals("1970-01-01T09:00:00.000+09:00", epoch.toString()); } private DateTime newDateTimeMillis(String timeZoneId) { return new DateTime(DateTimeZone.forID(timeZoneId)) .withYear(1970) .withMonthOfYear(1) .withDayOfMonth(1) .withTimeAtStartOfDay(); }
The DateTime fluent API is much easier to use than java.util.Calendar#set. DateTime is immutable but we can easily switch to a MutableDateTime if it’s appropriate for our current use case.
Compared to our Calendar test case, when changing the time zone the relative timestamp doesn’t change a bit, therefore remaining the same original point in time.
It’s only the human time perception that changes (1970-01-01T00:00:00.000Z and 1970-01-01T09:00:00.000+09:00 pointing to the very same absolute time).
Relative vs Absolute time instances
When supporting time zones, you basically have two main alternatives: a relative timestamp and an absolute time info.
Relative timestamp
The numeric timestamp representation (the numbers of milliseconds since epoch) is a relative info. This value is given against the UTC epoch but you still need a time zone to properly represent the actual time on a particular region.
Being a long value, it’s the most compact time representation and it’s ideal when exchanging huge amounts of data.
If you don’t know the original event time zone, you risk of displaying a timestamp against the current local time zone and this is not always desirable.
Absolute timestamp
The absolute timestamp contains both the relative time as well as the time zone info. It’s quite common to express timestamps in their ISO 8601 string representation.
Compared to the numerical form (a 64 bit long) the string representation is less compact and it might take up to 25 characters (200 bits in UTF-8 encoding).
The ISO 8601 is quite common in XML files because the XML schema uses a lexical format inspired by the ISO 8601 standard.
An absolute time representation is much more convenient when we want to reconstruct the time instance against the original time zone. An e-mail client might want to display the email creation date using the sender’s time zone, and this can only be achieved using absolute timestamps.
Puzzles
The following exercise aims to demonstrate how difficult is to properly handle an ISO 8601 compliant date/time structure using the ancient java.text.DateFormat utilities.
java.text.SimpleDateFormat
First we are going to test the java.text.SimpleDateFormat parsing capabilities using the following test logic:
/** * DateFormat parsing utility * @param pattern date/time pattern * @param dateTimeString date/time string value * @param expectedNumericTimestamp expected millis since epoch */ private void dateFormatParse(String pattern, String dateTimeString, long expectedNumericTimestamp) { try { Date utcDate = new SimpleDateFormat(pattern).parse(dateTimeString); if(expectedNumericTimestamp != utcDate.getTime()) { LOGGER.warn("Pattern: {}, date: {} actual epoch {} while expected epoch: {}", new Object[]{pattern, dateTimeString, utcDate.getTime(), expectedNumericTimestamp}); } } catch (ParseException e) { LOGGER.warn("Pattern: {}, date: {} threw {}", new Object[]{pattern, dateTimeString, e.getClass().getSimpleName()}); } }
Use case 1
Let’s see how various ISO 8601 patterns behave against this first parser:
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "1970-01-01T00:00:00.200Z", 200L);
Yielding the following outcome:
Pattern: yyyy-MM-dd'T'HH:mm:ss.SSS'Z', date: 1970-01-01T00:00:00.200Z actual epoch -7199800 while expected epoch: 200
This pattern is not ISO 8601 compliant. The single quote character is an escape sequence so the final ‘Z’ symbol is not treated as a time directive (e.g. Zulu time). After parsing, we’ll simply get a local time zone Date reference.
This test was run using my current system default Europe/Athens time zone, which as of writing this post, it’s two hours ahead of UTC.
Use case 2
According to java.util.SimpleDateFormat documentation the following pattern: yyyy-MM-dd’T’HH:mm:ss.SSSZ should match an ISO 8601 date/time string value:
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "1970-01-01T00:00:00.200Z", 200L);
But instead we got the following exception:
Pattern: yyyy-MM-dd'T'HH:mm:ss.SSSZ, date: 1970-01-01T00:00:00.200Z threw ParseException
So this pattern doesn’t seem to parse the Zulu time UTC string values.
Use case 3
The following patterns works just fine for explicit offsets:
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "1970-01-01T00:00:00.200+0000", 200L);
Use case 4
This pattern is also compatible with other time zone offsets:
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", "1970-01-01T00:00:00.200+0100", 200L - 1000 * 60 * 60);
Use case 5
To match the Zulu time notation we need to use the following pattern:
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "1970-01-01T00:00:00.200Z", 200L);
Use case 6
Unfortunately, this last pattern is not compatible with explicit time zone offsets:
dateFormatParse("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "1970-01-01T00:00:00.200+0000", 200L);
Ending-up with the following exception:
Pattern: yyyy-MM-dd'T'HH:mm:ss.SSSXXX, date: 1970-01-01T00:00:00.200+0000 threw ParseException
org.joda.time.DateTime
As opposed to java.text.SimpleDateFormat, Joda-Time is compatible with any ISO 8601 pattern. The following test case is going to be used for the upcoming test cases:
/** * Joda-Time parsing utility * @param dateTimeString date/time string value * @param expectedNumericTimestamp expected millis since epoch */ private void jodaTimeParse(String dateTimeString, long expectedNumericTimestamp) { Date utcDate = DateTime.parse(dateTimeString).toDate(); if(expectedNumericTimestamp != utcDate.getTime()) { LOGGER.warn("date: {} actual epoch {} while expected epoch: {}", new Object[]{dateTimeString, utcDate.getTime(), expectedNumericTimestamp}); } }
Joda-Time is compatible with all standard ISO 8601 date/time formats:
jodaTimeParse("1970-01-01T00:00:00.200Z", 200L); jodaTimeParse("1970-01-01T00:00:00.200+0000", 200L); jodaTimeParse("1970-01-01T00:00:00.200+0100", 200L - 1000 * 60 * 60);
Conclusion
As you can see, the ancient Java Date/Time utilities are not easy to work with. Joda-Time is a much better alternative, offering better time handling features.
If you happen to work with Java 8, it’s worth switching to the Java 8 Date/Time API, being designed from scratch but very much inspired by Joda-Time.
- Code available on GitHub.
Reference: | A beginner’s guide to Java time zone handling from our JCG partner Vlad Mihalcea at the Vlad Mihalcea’s Blog blog. |