Java и время: часть первая

Восемь лет назад я принимал участие в проектировании и разработке сервиса, который был должен обслуживать запросы пользователей со всех уголков земного шара и координировать их действия. Работая над проектом я понял, что очень часто многие важные аспекты работы со временем просто игнорируются. Иногда это действительно не очень критично: если сервис локален и им пользуются только на определенной территории, либо пользователи естественным образом разделены на почти не взаимодействующие между собой географические кластеры. Однако же, если сервис объединяет пользователей по всему миру, то без четкого понимания принципов работы со временем уже не обойтись. Представим сервис, в котором общие события (совещания например) начинаются в какое-то строго определенное время, а пользователи рассчитывают на это. Какое время им показывать, в какой момент их беспокоить уведомлениями, что такое день рождения и когда можно поздравить человека — в статье я попробую это осмыслить.

688d09fb9b814c19aad3e16a1b352b6e.jpg

Статья не претендует на глубину и/или академичность. Это попытка систематизировать опыт и обратить внимание разработчиков на не очень очевидные аспекты.

В JDK8 появилось новое Date Time API, в котором появилось множество новых полезных классов, но я не буду его упоминать, также как и об отличных сторонних библиотеках. Это отдельная, большая тема, которая заслуживает отдельной статьи; плюс, как я уже упомянул, я стараюсь рассказывать не о какой-то конкретной технологии, а о принципах работы в целом, а в этом плане новое API принципиально ничем не отличается от старого.

Начнем сильно издалека. Прямо очень издалека. Нарисуем ось времени.

3eda3bac84d344eaadd5caee315d0449.png

Прямо тут начинаются разные вопросы. Идет ли время всегда в одном направлении? Идет ли оно равномерно? Непрерывно ли оно? Что принять за единичный вектор? Едина ли эта ось для всех? Зависит ли от положения в пространстве? От скорости движения? Каков минимально измеряемый отрезок?

Собственно тут я готов только задавать вопросы, но никак не отвечать на них. Есть мнение, что никакого времени нет, но я также пока не готов к обсуждению этого вопроса.

Но есть и уже решенные моменты.

Про единицу измерения все ясно — она четко и однозначно специфицирована.

Расстояние от Москвы до Вашингтона составляет примерно 7840000 метров и свет проходит это расстояние по поверхности земли минимум за 0.026 секунды, что совсем немало. Запрос на создание учетной записи пользователем во Владивостоке, будет отработан на московском сервере только через некоторое время. Таким образом информация о происходящих событиях доступна совсем не сразу и зависит от расстояния между точками в пространстве.

Кроме того, сама скорость течения времени зависит от скорости перемещения объекта, причем даже для вполне рядовых около-земных технологий вроде GPS.

Текущая стандартная библиотека обработки времени на Java считает, что никаких релятивистских эффектов не существует и никто не движется на около-световых скоростях, а ось времени одна и едина для всех (по крайней мере в масштабах одной планеты) — и это вполне всех нас устраивает. Возможно впоследствии в JDK #6543 будет реализован новый Java Date Time API, который позволит написать сервис для внутренней офисной системы «Сокола Тысячелетия» с учетом скорости его движения и наличия/отсутствия кротовых нор рядом.

Теперь отметим на временной оси некий момент. Вот, например, прямо сейчас я нажму на кнопку «точка». (Нажал)

f7db1d59109e4f86b72a8be14d2e240d.png

Теперь нужно придумать способ, с помощью которого я смог бы сообщить вам о том, в какой именно момент я нажал эту кнопку. Самый простой способ сделать это — обозначить какой-то момент времени, общий для нас всех, с которого мы все постоянно отсчитываем временные отсчеты. Если этот момент времени обозначен (тот-самый-момент), то я смогу передавать вам число своих отсчетов с этого общего момента, а вы сможете понять отношение между временем нажатия кнопки и своим текущим временем при получении моего значения.

В примитивном физическом мире мы могли бы встретиться и одновременно запустить одинаковые песочные часы. После чего спокойно разойтись по своим делам, а времена событий сообщать в виде высоты песочного столба на нашем экземпляре часов (вероятно часы должны быть очень большими и громоздкими).

c43a7b00e36647f093345a6f722689a2.png

Используемый нами тот-самый-момент, в свою очередь, может быть также измерением —, но уже относительно какого-то более общего и важного события. В нашем случае так и происходит, тот-самый-момент в системе Unix-time (числовое значение 0), которая используется в Java — это временная точка с меткой 00:00:00 1 января 1970 от Р.Х. по UTC уже по другой шкале — Григорианскому календарю.

Для задания временной точки на временной оси в Java существует класс java.util.Date. Вообще, java.util.Date — это позор Java, начиная с самых ранних версий. Во-первых, у него название не отражает суть;, а во-вторых, он mutable. Однако жизнь будет проще, если вы будете воспринимать его как простую обертку над внутренним полем типа long в котором хранится количество миллисекунд с того-самого-момента — и ничего более. Все остальные методы помечены как устаревшие и использовать их не нужно ни в коем случае. Просто запомните, что java.utl.Date тождественен (по своей сути) простому числовому long-значению Unix-time в миллисекундах.

Date moment = new Date(1451665447567L); // Задаем количество миллисекунд Unix-time с того-самого-момента
moment.getTime(); // Узнаем количество миллисекунд Unix-time с того-самого-момента.

Если немного подумать, то становится понятно, что в любом языке есть ограничения точности представления времени. Поэтому java.util.Date (как и любые другие подобные типы) представляет собой вовсе не точку на временной оси, а отрезок. В нашем случае — отрезок миллисекундной длительности. Но с практической точки зрения такая точность нас устраивает, поэтому и дальше будем называть это точкой.

Поскольку представление в Java с самого начала 64-битное, то на наш век хватит точно:

Нам хватит
Date theEnd = new Date(Long.MAX_VALUE);

DateFormat dateFormat = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.LONG, SimpleDateFormat.LONG);
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
String text = dateFormat.format(theEnd);

System.out.println(text);

# August 17, 292278994 7:12:55 AM UTC


Для различных операций с временем таких как чтение/установка/модификация отдельных календарных полей (год, месяц, день, часы, минуты, секунды и прочее) существует класс java.util.Calendar. Он также не без греха — при операциях помните, что месяцы идут с 0 (лучше использовать константы), а дни идут с 1.

java.util.Calendar
    @Test
    public void testSunday() throws Exception {
        Calendar calendar = Calendar.getInstance();
        calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
        calendar.set(2016, Calendar.JANUARY, 5, 12, 30, 0);
        calendar.add(Calendar.DAY_OF_YEAR, -2);

        Assert.assertEquals(Calendar.SUNDAY, calendar.get(Calendar.DAY_OF_WEEK));
    }


Другой мутный момент в java.util.Calendar состоит в том, что при установке в нем полной даты (yyyy, MM, dd, HH, mm, ss) количество миллисекунд не сбрасывается в 0, а остается равным количеству миллисекунд от предыдущего установленного момента (текущего времени, если календарь не менялся). Поэтому, если по условиям задачи в миллисекундах должно быть 0, то это поле нужно сбросить еще одним дополнительным вызовом:

java.util.Calendar
    @Test
    public void testCalendarMs() throws Exception {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        Calendar calendar = Calendar.getInstance(tz);
        calendar.setLenient(false);

        calendar.set(2016, Calendar.APRIL, 20, 12, 0, 0);
        System.out.println(calendar.getTimeInMillis());

        calendar.set(Calendar.MILLISECOND, 0);
        System.out.println(calendar.getTimeInMillis());
    }

1461142800808
1461142800000

Первое число гуляет в первых трех разрядах в зависимости от времени вызова. Это поведение может быть достаточно критичным в тестах.

Для перевода временных меток в точки на оси и обратно существует класс java.text.DateFormat и его наследники.

java.text.DateFormat
    @Test
    public void testFormat() throws Exception {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        dateFormat.setLenient(false);
        dateFormat.setTimeZone(tz);

        Date moment = dateFormat.parse("2005-03-27 01:30:00");
        Assert.assertEquals("2005-03-27 01:30:00", dateFormat.format(moment));
    }


Про java.text.DateFormat и java.util.Calendar обязательно нужно сказать следующее:

  • У обоих классов есть метод setTimezone () для явной установки временной зоны. Крайне желательно всегда его использовать для того, чтобы обозначить, что вы полностью контролируете процесс, а не полагаетесь на временную зону по-умолчанию.
  • У обоих классов есть метод setLenient () для установки «мягкого» режима. В таком режиме оба класса будут снисходительно относиться к ошибкам в календарных метках, пытаясь угадать что же вы имели в виду на самом деле. Тут зависит от ситуации, но я бы рекомендовал угадывание отключать (по умолчанию «мягкий» режим включен).
  • Оба класса потоко-небезопасны. И, если для java.util.Calendar это совершенно ожидаемо (поскольку мы понимаем что у него есть внутреннее состояние), то, в случае java.text.DateFormat, это для многих оказывается сюрпризом. В принципе java.text.DateFormat можно считать «эффективно потоко-безопасным» — если вы не меняете настройки преобразования, но вообще лучше четко следовать описанию в JavaDoc, поскольку настоящая реализация никак не специфицирована.

Также есть (в старом АПИ) еще несколько классов:
java.sql.Timestamp — расширение (subclass) java.util.Date с наносекундной точностью для работы с типом TIMESTAMP в БД
java.sql.Date — расширение (subclass) java.util.Date для работы с типом DATE в БД.
java.sql.Time — расширение (subclass) java.util.Date для работы с типом TIME в БД.

Кроме того, любую временную точку можно хранить в виде обычного long-значения Unix-time в миллисекундах.

Любой, кто работал в глобальной компании со множеством офисов по всему миру (или даже просто по России) знает, что информация которая содержится во фразе «совещание будет в 01 января 2016 в 14:00:00» практически бесполезна. Метка »14:00» не соответствует какой-то конкретной точке на временной оси, а вернее сказать — соответствует сразу нескольким. Для того, чтобы все собрались в видео-переговорках в одно и то же время, организатору совещания нужно указать кое-что еще, а именно временную зону, в которой мы будем интерпретировать метку »14:00». Часто временная зона подразумевается по главенству головного офиса («работаем по московскому времени»), в противном же случае, если временная зона по-умолчанию вообще никак не подразумевается, то нужно задать ее в явном виде, например »01 января 2016 в 14:00:00 MSK» — в этом случае точка на временной оси задается совершенно однозначно и все соберутся на совещание в одно и тоже время.

Для устранения неоднозначности при операциях с временными метками в формате ЧЧ: MM: CC временная зона должна быть указана как при выводе временной метки, так и при вводе.

Можно не указывать временную зону явно, в случаях когда ее можно каким-либо образом подразумевать неявно:

  • временная зона подразумевается одной и той же по умолчанию для всего сервиса;
  • временная зона явно указана в профиле самим пользователем;
  • временную зону пользователя можно вычислить по его положению через гео-координаты;
  • временную зону пользователя можно вычислить по его положению через IP адрес;
  • временную зону пользователя можно вычислить по его положению через косвенные признаки (анализ поведения);
  • текущее смещение временной зоны пользователя можно вычислить через JavaScript;

Наверное стоит отметить, что временная зона сервиса (по умолчанию) и временная зона сервера (по умолчанию) — это в общем совсем не одно и то же. BIOS, cистема, приложение и утилиты могут, например, работать во временной зоне UTC, но при всем этом временной зоной сервиса будет временная зона Москвы. Хотя конечно сильно проще когда они совпадают — в таком случае админы настраивают временные зоны на серверах, а программисты о них не думают вообще. Если это как раз ваш случай — дальше можете не читать.

Одну и ту же временную метку можно выводить по-разному для пользователей — с использованием той временной зоны, которая наиболее привычна для каждого. Например, следующие временные метки указывают на одну и ту же временную точку на временной оси:

Fri Jan  1 16:29:00 MSK 2016
Fri Jan  1 13:29:00 UTC 2016
Fri Jan  1 14:29:00 CET 2016
Fri Jan  1 21:29:00 SGT 2016
Fri Jan  1 22:29:00 JST 2016

Википедия про временные зоны.

В Java информация о временной зоне представлена классом java.util.TimeZone.

Нужно сказать, что временная зона — это не смещение. Нельзя сказать что GMT+3 — это Europe/Moscow. Но можно сказать, что в течение всего 2016-го года временная зона Europe/Moscow будет соответствовать смещению GMT+3. Временная зона — это вся история смещений полностью за весь исторический период, а также другие данные, которые позволяют нам правильно вычислять смещения в разные исторические моменты, а также производить правильные временные расчеты.

Поисследуем временные зоны — для начала посмотрим на Europe/Moscow:

Europe/Moscow
    @Test
    public void testTzMoscow() throws Exception {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        System.out.println(tz.getRawOffset());
        System.out.println(tz.getOffset(System.currentTimeMillis()));
        System.out.println(tz.useDaylightTime());

        System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.ENGLISH));

        System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.FRENCH));
        System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.FRENCH));
        System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.FRENCH));
        System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.FRENCH));
    }

10800000
10800000
false
Moscow Standard Time
MSK
Moscow Daylight Time
MSD
Heure standard de Moscou
MSK
Heure avancée de Moscou
MSD

Видим, что у зоны Europe/Moscow текущее базовое смещение составляет +3 часа относительно UTC, а общее смещение в данный момент составляет также +3 часа. Перевод стрелок на летнее время в перспективе отсутствует. У зоны есть отдельные имена для летнего и зимнего времени.

Теперь посмотрим на парижское время:

Europe/Paris
    @Test
    public void testTzParis() throws Exception {
        TimeZone tz = TimeZone.getTimeZone("Europe/Paris");

        System.out.println(tz.getRawOffset());
        System.out.println(tz.getOffset(System.currentTimeMillis()));
        System.out.println(tz.useDaylightTime());

        System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.ENGLISH));

        System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.FRENCH));
        System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.FRENCH));
        System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.FRENCH));
        System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.FRENCH));
    }

3600000
3600000
true
Central European Time
CET
Central European Summer Time
CEST
Heure d'Europe centrale
CET
Heure d'été d'Europe centrale
CEST

Базовое смещение составляет +1 час относительно UTC, общее смещение в данный момент составляет также +1 час. Переход на летнее время в перспективе у зоны есть. Также присвоено два имени — для зимнего и для летнего времени отдельно.

Теперь посмотрим на зону «GMT+5». Это фактически не совсем временная зона — у нее нет истории, нет летнего времени, а смещение постоянно.

GMT+5
    @Test
    public void testGmt5() throws Exception {
        TimeZone tz = TimeZone.getTimeZone("GMT+5");

        System.out.println(tz.getRawOffset());
        System.out.println(tz.getOffset(System.currentTimeMillis()));
        System.out.println(tz.useDaylightTime());

        System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.ENGLISH));
        System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.ENGLISH));

        System.out.println(tz.getDisplayName(false, TimeZone.LONG, Locale.FRENCH));
        System.out.println(tz.getDisplayName(false, TimeZone.SHORT, Locale.FRENCH));
        System.out.println(tz.getDisplayName(true, TimeZone.LONG, Locale.FRENCH));
        System.out.println(tz.getDisplayName(true, TimeZone.SHORT, Locale.FRENCH));
    }

18000000
18000000
false
GMT+05:00
GMT+05:00
GMT+05:00
GMT+05:00
GMT+05:00
GMT+05:00
GMT+05:00
GMT+05:00

Так и есть, смещение постоянно, составляет +5 часов относительно GMT и никогда не меняется.

Продолжим рассказывать примерами то, что слишком долго объяснять на словах. Для начала посмотрим, что происходило в 2005 году во временной зоне «Europe/Moscow»:

Europe/Moscow — 2005
$ zdump -v /usr/share/zoneinfo/Europe/Moscow | grep 2005
/usr/share/zoneinfo/Europe/Moscow  Sat Mar 26 22:59:59 2005 UT = Sun Mar 27 01:59:59 2005 MSK isdst=0 gmtoff=10800
/usr/share/zoneinfo/Europe/Moscow  Sat Mar 26 23:00:00 2005 UT = Sun Mar 27 03:00:00 2005 MSD isdst=1 gmtoff=14400
/usr/share/zoneinfo/Europe/Moscow  Sat Oct 29 22:59:59 2005 UT = Sun Oct 30 02:59:59 2005 MSD isdst=1 gmtoff=14400
/usr/share/zoneinfo/Europe/Moscow  Sat Oct 29 23:00:00 2005 UT = Sun Oct 30 02:00:00 2005 MSK isdst=0 gmtoff=10800


Отлично, мы видим перевод стрелок на летнее и обратно на зимнее время. Посмотрим на то, что происходит в эти моменты с временными метками. Для начала — переход на зимнее время:

переход на зимнее время
    @Test
    public void testWinterTime() throws Exception {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
        dateFormat.setLenient(false);
        dateFormat.setTimeZone(tz);

        Calendar calendar = Calendar.getInstance();
        calendar.setLenient(false);
        calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
        calendar.set(2005, Calendar.OCTOBER, 29, 22, 0, 0);

        for (int i = 0; i < 62; i++) {
            String mark = dateFormat.format(calendar.getTime());
            System.out.printf("%s - %d, %s\n", mark,
                    tz.getOffset(calendar.getTimeInMillis()),
                    tz.inDaylightTime(calendar.getTime()));
            calendar.add(Calendar.MINUTE, +1);
        }
    }

2005-10-30 02:00:00 MSD - 14400000, true
2005-10-30 02:01:00 MSD - 14400000, true
...
2005-10-30 02:58:00 MSD - 14400000, true
2005-10-30 02:59:00 MSD - 14400000, true
2005-10-30 02:00:00 MSK - 10800000, false
2005-10-30 02:01:00 MSK - 10800000, false

Видим, что после 02:59:00 MSD стрелки сдвигаются на час назад и следующей меткой идет уже 02:00:00 MSK — зимнее время. Также временная зона говорит о том, что летнее время закончилось, а смещение изменилось с GMT+4 на GMT+3.

В примере есть интересный нюанс: c помощью зоны «Europe/Moscow» совершенно невозможно установить в календаре точку соответствующую метке 02:00:00 MSD — устанавливается точка 02:00:00 MSK, что на час позже чем нужно нам. Чтобы задать эту точку как начало отсчета, приходится прибегать к услугам временной зоны UTC, в которой можно установить все. Другим вариантом может быть установка точки 01:00:00 MSD в зоне «Europe/Moscow» и прибавление часа.

Теперь — переход на летнее время:

переход на летнее время
    @Test
    public void testSummerTime() {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
        dateFormat.setLenient(false);
        dateFormat.setTimeZone(tz);

        Calendar calendar = Calendar.getInstance();
        calendar.setLenient(false);
        calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
        calendar.set(2005, Calendar.MARCH, 26, 22, 0, 0);

        for (int i = 0; i <= 60; i++) {
            String mark = dateFormat.format(calendar.getTime());
            System.out.printf("%s - %d, %s\n", mark,
                    tz.getOffset(calendar.getTimeInMillis()),
                    tz.inDaylightTime(calendar.getTime()));

            calendar.add(Calendar.MINUTE, +1);
        }
    }

2005-03-27 01:00:00 MSK - 10800000, false
2005-03-27 01:01:00 MSK - 10800000, false
...
2005-03-27 01:58:00 MSK - 10800000, false
2005-03-27 01:59:00 MSK - 10800000, false
2005-03-27 03:00:00 MSD - 14400000, true
2005-03-27 03:00:01 MSD - 14400000, true

Видно, что после 01:59:00 MSK сразу следует 03:00:00 MSD — то есть перевод стрелок на час вперед. Временная зона сигнализирует, что в этот момент смещение меняется с GMT+3 на GMT+4, а также появляется флаг летнего времени.

Но что будет если мы попробуем обработать метку »2005–03–27 02:30:00» в зоне «Europe/Moscow» — в теории такой метки существовать не должно?

несуществующая метка
    @Test
    public void testMissing() throws Exception {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        dateFormat.setLenient(false);
        dateFormat.setTimeZone(tz);

        Date moment = dateFormat.parse("2005-03-27 02:30:00");
        System.out.println(moment);
    }

java.text.ParseException: Unparseable date: "2005-03-27 02:30:00"

Все верно — в строгом режиме мы получаем исключение.

Посчитаем длительность дня в день перевода стрелок на зимнее время:

перевод на зимнее время
    @Test
    public void testWinterDay() {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        Calendar calendar = Calendar.getInstance();
        calendar.setLenient(false);
        calendar.setTimeZone(tz);
        calendar.set(2005, Calendar.OCTOBER, 30, 0, 0, 0);

        Date time1 = calendar.getTime();

        calendar.add(Calendar.DAY_OF_YEAR, +1);

        Date time2 = calendar.getTime();

        System.out.println(TimeUnit.MILLISECONDS.toHours(time2.getTime() - time1.getTime()));
    }

25

С 2005–10–30 00:00:00 MSD до 2005–10–31 00:00:00 MSK прошло 25 часов, а не 24.

Теперь проверим день перехода на летнее время:

переход на летнее время
    @Test
    public void testSummerDay() {
        TimeZone tz = TimeZone.getTimeZone("Europe/Moscow");

        Calendar calendar = Calendar.getInstance();
        calendar.setLenient(false);
        calendar.setTimeZone(tz);
        calendar.set(2005, Calendar.MARCH, 27, 0, 0, 0);

        Date time1 = calendar.getTime();

        calendar.add(Calendar.DAY_OF_YEAR, +1);

        Date time2 = calendar.getTime();

        System.out.println(TimeUnit.MILLISECONDS.toHours(time2.getTime() - time1.getTime()));
    }

23

C 2005–03–27 00:00:00 MSK до 2005–03–28 00:00:00 MSD прошли 23 часа, а не 24.

Эти два последних примера посвящены тем, кто прибавляет 24×60*60×1000 миллисекунд не как 24 часа, а как календарный день. Вы можете сказать, теперь такой проблемы нет, так как больше нет и переводов на летнее/зимнее время. На это я могу ответить следующее:

  • ваша программа должна работать корректно в любой временной зоне, а не только зоне «Europe/Moscow»;
  • расчеты «назад» (в прошлое) все равно требуют корректного подхода;
  • в 2016 году у нас будут выборы госдумы, а в 2018 будут выборы президента — так что я думаю, что история еще не закончена.

Типы предназначаются для работы с SQL типами TIME и DATE соответственно. Подразумевается, что оба значения от временной зоны не зависят, но к сожалению это не совсем так. Поскольку оба типа являются наследниками java.util.Date — интерпретация дней-часов зависит от временной зоны:

Проблема с типами java.sql
    @Test
    public void testSqlTime() throws Exception {
        // Предположим что сейчас 2015-01-01 01:00:00 MSK
        Calendar calendar = Calendar.getInstance();
        calendar.setTimeZone(TimeZone.getTimeZone("Europe/Moscow"));
        calendar.setTimeInMillis(0);
        calendar.set(2015, Calendar.JANUARY, 10, 1, 0, 0);

        long now = calendar.getTimeInMillis();

        // Создаем инстанс java.sql.Time
        java.sql.Time sqlTime = new java.sql.Time(now);
        java.sql.Date sqlDate = new java.sql.Date(now);

        // Теперь выводим значение времени в временной зоне Europe/London
        DateFormat timeFormat = new SimpleDateFormat("HH:mm:ss");
        timeFormat.setTimeZone(TimeZone.getTimeZone("Europe/London"));
        Assert.assertEquals("22:00:00", timeFormat.format(sqlTime));

        // Теперь выводим значение даты в временной зоне Europe/London
        DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        dateFormat.setTimeZone(TimeZone.getTimeZone("Europe/London"));
        Assert.assertEquals("2015-01-09", dateFormat.format(sqlDate));
    }


В принципе оба типа справляются с задачей переноса информации от бизнес-логики в JDBC-драйвер поскольку обычно код и там и там работает в одной временной зоне, но в более продвинутых случаях, включая сериализацию, надо быть очень аккуратным при использовании этих классов.

В новом API для соответствующих типов подобные проблемы решены.

Большинство знают, что GMT и UTC — это особые обозначения, относительно которых оформляются смещения в других временных зонах. Но не все знают, что UTC и GMT — это не совсем одно и тоже (формально). Я имею в виду то, что метки »2015–12–01 00:00:00 GMT» и »2015–12–01 00:00:00 UTC» обозначают различные (хоть и близкие) точки на временной оси.

GMT вычисляется астрономически по положению земли относительно других объектов. GMT также напрямую используется в качестве временной зоны в некоторых странах.

Поскольку вращение земного шара хаотично замедляется, земля оказывается в одном и том же положении через все увеличивающиеся промежутки времени. Таким образом расстояние между временными точками по соседним меткам по GMT (например »10:00:01» и »10:00:02») может точно не равняться одной секунде.

UTC введен на замену GMT и рассчитывается по атомным часам. Непосредственно в качестве временной зоны не используется (только как опора для смещения).

В UTC расстояние между временными метками (например »10:00:01» и »10:00:02») совершенно одинаковое и строго равно одной секунде. Замедление земного вращения и накапливающееся отличие от GMT решается вводом лишней секунды в году (или даже двух) —, а именно секунды координации (leap second).

Таким образом разница между точками с одинаковыми метками в GMT и UTC никогда не превышает одной секунды.

Пишут, что время UTC практически повсюду вытеснило GMT, и что использовать обозначения смещений в виде GMT+3 уже давно моветон — правильно использовать обозначение UTC+3.

Ни GMT ни UTC летнего времени не имеют.

Надо сказать что Unix-time, который используется в Java, ни UTC ни GMT напрямую не соответствует. С одной стороны в Unix-time разница между соседними метками составляет всегда равно 1 секунду, с другой стороны наличие leap second в Unix-time не предполагается.

Отображаете ли вы временную зону при выводе явно или не отображаете, запрашиваете ли вы временную зону при вводе или не запрашиваете, указываете ли вы временную зону при операциях над временем или не указываете — какая-то временная зона все равно присутствует в этих операциях неявно. Если вы не указали свою — будет использована временная зона по-умолчанию.

Термин временная зона по-умолчанию уже был упомянут несколько раз по тексту выше. Все потому что без этого понятия ничего и объяснить толком нельзя. Все операции с временем, вывод и ввод временных меток требуют временную зону. То что вы ее не указываете, не означает что ее нет — просто она берется по-умолчанию.

Но все снова не так-то просто — по умолчанию для кого и чего?

Начнем с ядра. В мануале к hwclock сказано, что в ядре есть внутренняя концепция временной зоны, но ее почти никто не использует, кроме некоторых редких модулей — вроде драйвера файловой системы FAT. Проинформировать ядро о смене временной зоны можно этой же командой hwclock.

Прикладные приложение определяют временную зону по-умолчанию несколькими способами.

Во-первых, общесистемная временная зона (полная информация о ней) в Ubuntu хранится в файле (может быть симлинком) /etc/localtime, а имя этой временной зоны — в файле /etc/timezone:

$ cat /etc/timezone
Europe/Moscow
$ file /etc/localtime 
/etc/localtime: timezone data, version 2, 15 gmt time flags, 15 std time flags, no leap seconds, 77 transition times, 15 abbreviation chars
$ zdump -v /etc/localtime | head -n 10
/etc/localtime  -9223372036854775808 = NULL
/etc/localtime  -9223372036854689408 = NULL
/etc/localtime  Wed Dec 31 21:29:42 1879 UT = Wed Dec 31 23:59:59 1879 LMT isdst=0 gmtoff=9017
/etc/localtime  Wed Dec 31 21:29:43 1879 UT = Thu Jan  1 00:00:00 1880 MMT isdst=0 gmtoff=9017
/etc/localtime  Sun Jul  2 21:29:42 1916 UT = Sun Jul  2 23:59:59 1916 MMT isdst=0 gmtoff=9017
/etc/localtime  Sun Jul  2 21:29:43 1916 UT = Mon Jul  3 00:01:02 1916 MMT isdst=0 gmtoff=9079
/etc/localtime  Sun Jul  1 20:28:40 1917 UT = Sun Jul  1 22:59:59 1917 MMT isdst=0 gmtoff=9079
/etc/localtime  Sun Jul  1 20:28:41 1917 UT = Mon Jul  2 00:00:00 1917 MST isdst=1 gmtoff=12679
/etc/localtime  Thu Dec 27 20:28:40 1917 UT = Thu Dec 27 23:59:59 1917 MST isdst=1 gmtoff=12679
/etc/localtime  Thu Dec 27 20:28:41 1917 UT = Thu Dec 27 23:00:00 1917 MMT isdst=0 gmtoff=9079

Установить временную зону для системы можно специальной командой для вашего дистрибутива, для Ubuntu это:

$ dpkg-reconfigure tzdata

А также есть вежливая утилита tzselect:

$ tzselect 
Please identify a location so that time zone rules can be set correctly.
Please select a continent, ocean, "coord", or "TZ".
 1) Africa
 2) Americas
 3) Antarctica
 4) Arctic Ocean
 5) Asia
 6) Atlantic Ocean
 7) Australia
 8) Europe
 9) Indian Ocean
10) Pacific Ocean
11) coord - I want to use geographical coordinates.
12) TZ - I want to specify the time zone using the Posix TZ format.
#? 

Вторым способом указания временной зоны является переменная окружения TZ, в которой можно указать идентификатор временной зоны индивидуально для каждой программы и/или пользователя.

$ echo $TZ

$ date
Wed Dec 30 20:18:18 MSK 2015
$ TZ=UTC date
Wed Dec 30 17:18:25 UTC 2015
$ TZ=Europe/London date
Wed Dec 30 17:18:35 GMT 2015
$ TZ=Europe/Paris date
Wed Dec 30 18:18:40 CET 2015

Некоторые программы можно попросить о специфической временной зоне в настройках и/или аргументах командной строки:

$ date --utc
Fri Jan  1 08:34:36 UTC 2016

Кстати, можно попросить date вывести только текущую временную зону без времени:

$ date +%Z
MSK

Но это для обычных программ под libc, а у нас целая платформа Java. Поэтому кроме этих двух перечисленных возможностей у нас есть еще две.

Можно указать аргумент для запуска JVM.

$ cat << EOF | scala -Duser.timezone=Europe/Paris
print("%s\n%s\n".format(java.util.TimeZone.getDefault().getID(), new java.util.Date()))
EOF
...
Europe/Paris
Wed Dec 30 19:24:00 CET 2015

А можно прямо в коде установить временную зону по умолчанию через метод TimeZone.setDefault (TimeZone timeZone):

$ cat << EOF | scala
> java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/Los_Angeles"))
> print("%s\n%s\n".format(java.util.TimeZone.getDefault().getID(), new java.util.Date()))
> EOF
...
America/Los_Angeles
Wed Dec 30 10:25:45 PST 2015

Или даже все сразу:

$ TZ=Europe/London cat << EOF | scala -Duser.timezone=Europe/Paris
java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("America/Los_Angeles"))
print("%s\n%s\n".format(java.util.TimeZone.getDefault().getID(), new java.util.Date()))
EOF
...
America/Los_Angeles
Wed Dec 30 10:37:28 PST 2015

Законодатели и правительства различных стран и даже регионов не сидят сложа руки, регулярно включая/отключая летнее время или даже перемещая регионы между часовыми поясами. Критически важно иметь на системах всю последнюю информацию о подобных изменениях — в противном случае время будет вводиться и выводиться неправильно, люди будут получать СМС-ки во время своего сна, а расчеты вроде »+2 календарных дня» будут неправильными.

В Linux обычные программы на libc используют базу временных зон состоящую из файлов в директории /usr/share/zoneinfo. Эти файлы принадлежат пакету tzdata, за которым активно присматривают разработчики каждого из дистрибутивов. Этот пакет своевременно обновляется и проблем с ним я не помню. В крайнем случае всю информацию можно обновить вручную, если ваша развернутая версия Linux уже больше никем не поддерживается.

К счастью, мне не придется расписывать тут формат содержимого этих файлов, ни историю их возникновения — поскольку на Хабре уже есть отличная статья на эту тему. Не менее отличная статья есть в википедии.

Но не все так просто.

Java использует свою собственную базу с временными зонами. И, если, для OpenJDK как правило можно просто и легко обновить пакет tzdata-java штатным пакетным менеджером, то для Oracle JDK придется либо апгрейдить всю JDK целиком на новую версию, либо пользоваться отдельной специальной утилитой для обновления базы временных зон в уже установленной JDK.

Кстати, упомянутая выше библиотеке Joda-time не использует ни системную базу tzdata, ни базу из JVM — да, у нее есть еще одна своя внутренняя база временных зон, которую нужно также обновлять отдельным и неповторимым способом.

Для python нужно ставить (и затем также не забыть обновлять) отдельную библиотеку.

Для javascript есть куча каких-то сторонних библиотек, как минимум я точно помню что поддержка есть в Google Closure.

Вообще тема того, что тот или иной софт использует свои личные базы с временными зонами, всплывает регулярно. Например, модуль календаря Lightning для почтового клиента Thunderbird хранит свою личную sqlite-базу с часовыми зонами, и поэтому при последних изменениях в нашем государстве мне приходилось делать прямые интервенции в эту базу для корректировки. Иначе все митинги просто плыли по времени.

Вообще, есть ощущение, что в основной массе разработчики не страдают паранойей (как я), про временные зоны никто не думает и в базовые поставки своих платформ tzdata никто не включает — кроме разработчиков JVM.

Отдельное слово я хотел бы сказать про Android. Буду краток — временные зоны в Android это боль. При разработке платформы никто не подумал про отдельный механизм обновления tzdata, как и про то, что у законодателей по всему миру есть страшный зуд к переменам (кто бы мог подумать). Базы с временными зонами меняются только в случае, если вендор прошивки этого захочет. Учитывая то, что некоторые вендоры перестают узнавать свои собственные телефоны уже через полгода, то можно сказать, что на многих аппаратах tzdata просто не обновляется никогда. Продвинутые пользователи меняют текущую временную зону в своих аппаратах на другую, более-менее подходящую текущим условиям (например Europe/Minsk вместо Europe/Moscow). Непродвинутые пользователи все также живут в Europe/Moscow (GMT+4) и просто переводят стрелки — в результате чего временные метки событий во всех программах сдвигаются на час назад. Есть конечно вариант с рутированием и использованием сторонних решений для обновления, но всех пользователей рутировать телефоны не заставишь.

Про необходимость указания временной зоны вместе с меткой уже было сказано. Однако настоящие параноики должны бы указывать еще и систему летоисчисления. Мы не делаем этого, потому как наиболее развитая часть населения земного шара уже договорилась использовать григорианский календарь, хотя мы до сих пор в удовольствием празднуем новый год по юлианскому календарю, а некоторые из нас высказывают другие, отличающиеся точки зрения на то, как именно правильно считать даты.

Есть совершенно другие, порой достаточно странные системы счисления, в которых одно и ту же временную точку можно отобразить совершенно по-иному. Например — календарь Чучхе. Вообще таких систем оказывается достаточно много, а все мы просто не задумываемся, что наш календарь лишь один из многих, возможно самый используемый, но не единственный. Поиграться с некоторыми можно тут.

Високосный год — год в котором 366 дней, а не 365 дней как в обычном году. В високосном году добавляется один день к февралю — 29 февраля.

Формула определения того, что год високосный проста и описана в википедии

А вот с лишней секундой (секунда координации) все сильно сложнее. Суть процесса в том, что земля постоянно немножко замедляется и ее положение относительно звезд по одним и тем же меткам времени постоянно меняется. Если не производить коррекцию то время дня и ночи будет постоянно сдвигаться. Чтобы этого не происходило ученые отслеживают положение земли, вычисляют необходимую коррекцию и вносят ее в план корректировки. Поскольку процесс замедления хаотичен, долгосрочный план по коррекции составить невозможно — определение необходимости ввода секунды коррекции происходит по текущей ситуации. Также теоретически возможен ввод отрицательной секунды координации — в случае если земной шар вдруг наоборот ускорится.

Подразумевается, что при наличии секунды координации, время по UTC течет с появлением 60-й секунды:

23:59:58
23:59:59
23:59:60 # leap second
00:00:00
00:00:01

В концепции Unix-time не существует понятия секунды с номером 60: «Because it does not handle leap seconds, it is neither a linear representation of time nor a true representation of UTC.»

Для того, чтобы хоть как-то соответствовать времени по UTC используется трюк с переводом времени на секунду назад в полночь:

23:59:58
23:59:59
23:59:59 # leap second
00:00:00
00:00:01

Трюк проводится либо через сервисы точного времени, либо самим ядром автономно на основании данных в файле таймзоны.

Это именно хак, который имеет свои негативные последствия:

  • Количество секунд между 23:59:00 и 00:01:00 следующего дня равно 120, а не 121 как должно быть
  • Поскольку одну секунду мы съедаем, все прош

    © Habrahabr.ru