Подтвердить что ты не робот

Чрезвычайно медленный парсинг часового пояса с новым API java.time

Я просто переносил модуль со старых дат java на новый API java.time и заметил огромное снижение производительности. Он сводился к разбору дат с часовым поясом (я анализирую миллионы из них за раз).

Анализ строки даты без часового пояса (yyyy/MM/dd HH:mm:ss) выполняется быстро - примерно в 2 раза быстрее, чем со старой датой java, около 1,5 млн операций в секунду на моем ПК.

Однако, когда шаблон содержит часовой пояс (yyyy/MM/dd HH:mm:ss z), производительность падает примерно на 15 раз с новым API java.time, тогда как со старым API он работает так же быстро, как без часового пояса. См. Таблицу производительности ниже.

Есть ли у кого-нибудь идеи, могу ли я как-то быстро разобрать эти строки с помощью нового API java.time? На данный момент, как обходной путь, я использую старый API для синтаксического анализа, а затем преобразовываю Date в Instant, что не особенно приятно.

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OperationsPerInvocation;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

@OutputTimeUnit(TimeUnit.MILLISECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(1)
@Fork(1)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@State(Scope.Thread)
public class DateParsingBenchmark {

    private final int iterations = 100000;

    @Benchmark
    public void oldFormat_noZone(Blackhole bh, DateParsingBenchmark st) throws ParseException {

        SimpleDateFormat simpleDateFormat = 
                new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");

        for(int i=0; i<iterations; i++) {
            bh.consume(simpleDateFormat.parse("2000/12/12 12:12:12"));
        }
    }

    @Benchmark
    public void oldFormat_withZone(Blackhole bh, DateParsingBenchmark st) throws ParseException {

        SimpleDateFormat simpleDateFormat = 
                new SimpleDateFormat("yyyy/MM/dd HH:mm:ss z");

        for(int i=0; i<iterations; i++) {
            bh.consume(simpleDateFormat.parse("2000/12/12 12:12:12 CET"));
        }
    }

    @Benchmark
    public void newFormat_noZone(Blackhole bh, DateParsingBenchmark st) {

        DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
                .appendPattern("yyyy/MM/dd HH:mm:ss").toFormatter();

        for(int i=0; i<iterations; i++) {
            bh.consume(dateTimeFormatter.parse("2000/12/12 12:12:12"));
        }
    }

    @Benchmark
    public void newFormat_withZone(Blackhole bh, DateParsingBenchmark st) {

        DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
                .appendPattern("yyyy/MM/dd HH:mm:ss z").toFormatter();

        for(int i=0; i<iterations; i++) {
            bh.consume(dateTimeFormatter.parse("2000/12/12 12:12:12 CET"));
        }
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder().include(DateParsingBenchmark.class.getSimpleName()).build();
        new Runner(opt).run();    
    }
}

И результаты для операций 100K:

Benchmark                                Mode  Cnt     Score     Error  Units
DateParsingBenchmark.newFormat_noZone    avgt    5    61.165 ±  11.173  ms/op
DateParsingBenchmark.newFormat_withZone  avgt    5  1662.370 ± 191.013  ms/op
DateParsingBenchmark.oldFormat_noZone    avgt    5    93.317 ±  29.307  ms/op
DateParsingBenchmark.oldFormat_withZone  avgt    5   107.247 ±  24.322  ms/op

UPDATE:

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

@Benchmark
public void newFormat_zoneOnly(Blackhole bh, DateParsingBenchmark st) {

    DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
            .appendPattern("z").toFormatter();

    for(int i=0; i<iterations; i++) {
        bh.consume(dateTimeFormatter.parse("CET"));
    }
}

В пакете java.time есть класс, называемый ZoneTextPrinterParser, который внутренне создает копию набора всех доступных часовых поясов в каждом вызове parse() (через ZoneRulesProvider.getAvailableZoneIds()), и это подотчетно для 99% времени, проведенного в разборе зоны.

Ну, тогда ответ может состоять в том, чтобы написать собственный анализатор зоны, что тоже не слишком хорошо, потому что тогда я не смог построить DateTimeFormatter через appendPattern().

4b9b3361

Ответ 1

Как отмечено в вашем вопросе и в моем комментарии, ZoneRulesProvider.getAvailableZoneIds() создает новый набор всех строковых представлений доступных часовых поясов (ключи static final ConcurrentMap<String, ZoneRulesProvider> ZONES) каждый раз, когда необходимо очистить часовую зону. 1

К счастью, ZoneRulesProvider - это класс abstract, который предназначен для подкласса. Метод protected abstract Set<String> provideZoneIds() отвечает за заполнение ZONES. Таким образом, подкласс может предоставлять только необходимые часовые пояса, если он знает заранее все используемые часовые пояса. Поскольку класс будет предоставлять меньше записей, чем поставщик по умолчанию, содержащий сотни записей, он может значительно сократить время вызова getAvailableZoneIds().

ZoneRulesProvider API содержит инструкции по его регистрации. Обратите внимание, что провайдеры не могут быть отменены, только дополнены, поэтому не просто удалить поставщика по умолчанию и добавить свой собственный. Системное свойство java.time.zone.DefaultZoneRulesProvider определяет поставщика по умолчанию. Если он возвращает null (через System.getProperty("..."), то загружается незарегистрированный провайдер JVM. Используя System.setProperty("...", "fully-qualified name of a concrete ZoneRulesProvider class"), вы можете предоставить своего поставщика, что обсуждается во втором абзаце.

В заключение я предлагаю:

  • Подкласс abstract class ZoneRulesProvider
  • Реализует protected abstract Set<String> provideZoneIds() только с необходимыми часовыми поясами.
  • Задайте системное свойство для этого класса.

Я не делал этого сам, но я уверен, что по какой-то причине он не сработает. подумайте, что это сработает.


1 В комментариях к вопросу предлагается указать, что точный характер обращения мог измениться между 1.8 версиями.

Изменить: найденная информация

Вышеупомянутое значение по умолчанию ZoneRulesProvider равно final class TzdbZoneRulesProvider, расположенному в java.time.zone. Области этого класса считываются с пути: JAVA_HOME/lib/tzdb.dat (в моем случае это в JDK JRE). Этот файл действительно содержит много регионов, вот фрагмент:

 TZDB  2014cJ Africa/Abidjan Africa/Accra Africa/Addis_Ababa Africa/Algiers 
Africa/Asmara 
Africa/Asmera 
Africa/Bamako 
Africa/Bangui 
Africa/Banjul 
Africa/Bissau Africa/Blantyre Africa/Brazzaville Africa/Bujumbura Africa/Cairo Africa/Casablanca Africa/Ceuta Africa/Conakry Africa/Dakar Africa/Dar_es_Salaam Africa/Djibouti 
Africa/Douala Africa/El_Aaiun Africa/Freetown Africa/Gaborone 
Africa/Harare Africa/Johannesburg Africa/Juba Africa/Kampala Africa/Khartoum 
Africa/Kigali Africa/Kinshasa Africa/Lagos Africa/Libreville Africa/Lome 
Africa/Luanda Africa/Lubumbashi 
Africa/Lusaka 
Africa/Malabo 
Africa/Maputo 
Africa/Maseru Africa/Mbabane Africa/Mogadishu Africa/Monrovia Africa/Nairobi Africa/Ndjamena 
Africa/Niamey Africa/Nouakchott Africa/Ouagadougou Africa/Porto-Novo Africa/Sao_Tome Africa/Timbuktu Africa/Tripoli Africa/Tunis Africa/Windhoek America/Adak America/Anchorage America/Anguilla America/Antigua America/Araguaina America/Argentina/Buenos_Aires America/Argentina/Catamarca  America/Argentina/ComodRivadavia America/Argentina/Cordoba America/Argentina/Jujuy America/Argentina/La_Rioja America/Argentina/Mendoza America/Argentina/Rio_Gallegos America/Argentina/Salta America/Argentina/San_Juan America/Argentina/San_Luis America/Argentina/Tucuman America/Argentina/Ushuaia 
America/Aruba America/Asuncion America/Atikokan America/Atka 
America/Bahia

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

Ответ 2

Эта проблема вызвана ZoneRulesProvider.getAvailableZoneIds(), которая каждый раз копировала набор временных зон. Ошибка JDK-8066291 отследила проблему, и она была исправлена ​​в Java SE 9. Она не будет передана в Java SE 8, потому что ошибка fix включало изменение спецификации (теперь метод возвращает неизменяемый набор вместо изменчивого).

В качестве побочного примечания некоторые другие проблемы с производительностью при разборе были переданы в Java SE 8, поэтому всегда используйте последний выпуск обновлений.

Ответ 3

В ответ на JodaStephen. Я тестирую DateTimeFormatter с использованием JDK 1.8.0-191 (который является самым последним доступным на сегодняшний день 2018-12-14) и все еще вижу проблему с производительностью при разборе часового пояса.

Это вызов, который я выполняю:

ZonedDateTime.parse(timestampStr, DateTimeFormatter.ofPattern("MMM d, yyyy h:mm:ss a zzz"));

Это трассировка стека, которую я вижу:

"[email protected]" prio=5 tid=0x10 nid=NA runnable
  java.lang.Thread.State: RUNNABLE
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.prefixLength(DateTimeFormatterBuilder.java:4525)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4390)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4397)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4397)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4397)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add(DateTimeFormatterBuilder.java:4385)
      at java.time.format.DateTimeFormatterBuilder$ZoneTextPrinterParser.getTree(DateTimeFormatterBuilder.java:4132)
      at java.time.format.DateTimeFormatterBuilder$ZoneIdPrinterParser.parse(DateTimeFormatterBuilder.java:4243)
      at java.time.format.DateTimeFormatterBuilder$CompositePrinterParser.parse(DateTimeFormatterBuilder.java:2364)
      at java.time.format.DateTimeFormatter.parseUnresolved0(DateTimeFormatter.java:2107)
      at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:2036)
      at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1948)
      at java.time.ZonedDateTime.parse(ZonedDateTime.java:598)
...

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

java.time.format.DateTimeFormatterBuilder$ZoneTextPrinterParser.getTree(DateTimeFormatterBuilder.java:4128)

Я тестировал на jdk-9.0.4 и jdk-11.0.1. Дамп на JDK 9.0.4:

"[email protected]" prio=5 tid=0x26 nid=NA runnable
  java.lang.Thread.State: RUNNABLE
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.prefixLength(DateTimeFormatterBuilder.java:4514)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4379)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4386)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4386)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4386)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add0(DateTimeFormatterBuilder.java:4386)
      at java.time.format.DateTimeFormatterBuilder$PrefixTree.add(DateTimeFormatterBuilder.java:4374)
      at java.time.format.DateTimeFormatterBuilder$ZoneTextPrinterParser.getTree(DateTimeFormatterBuilder.java:4117)
      at java.time.format.DateTimeFormatterBuilder$ZoneIdPrinterParser.parse(DateTimeFormatterBuilder.java:4232)
      at java.time.format.DateTimeFormatterBuilder$CompositePrinterParser.parse(DateTimeFormatterBuilder.java:2353)
      at java.time.format.DateTimeFormatter.parseUnresolved0(DateTimeFormatter.java:2049)
      at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1978)
      at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1890)
      at java.time.ZonedDateTime.parse(ZonedDateTime.java:598)
...

Итак, знаете ли вы (или кто-либо из присутствующих здесь), в каком выпуске JDK 8 или JDK 9 решена проблема?

Большое спасибо!