【问题标题】:Extremely slow parsing of time zone with the new java.time API使用新的 java.time API 解析时区非常慢
【发布时间】:2016-03-26 06:36:14
【问题描述】:

我刚刚将一个模块从旧的 java 日期迁移到新的 java.time API,并注意到性能大幅下降。它归结为使用时区解析日期(我一次解析数百万个日期)。

解析没有时区的日期字符串 (yyyy/MM/dd HH:mm:ss) 速度很快 - 比使用旧的 java 日期快大约 2 倍,在我的 PC 上每秒大约 150 万次操作。

但是,当模式包含时区 (yyyy/MM/dd HH:mm:ss z) 时,使用新的 java.time API 时性能会下降约 15 倍,而使用旧 API 时,它的速度与没有时区时差不多。请参阅下面的性能基准。

是否有人知道我是否可以使用新的java.time API 以某种方式快速解析这些字符串?目前,作为一种解决方法,我使用旧 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

更新:

我刚刚对 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% 的时间花在区域解析上。

好吧,那么答案可能是编写我自己的区域解析器,这也不太好,因为那时我无法通过appendPattern() 构建DateTimeFormatter

【问题讨论】:

  • 我可以确认在 ZoneTextPrinterParser 类中的第 3718 行调用了静态方法 ZoneRulesProvider.getAvailableZoneIds(),即 return new HashSet&lt;&gt;(ZONES.keySet());。但是,对于一个时区,每次 parse 调用仅调用一次。创建集合似乎很耗时,因为它包含数百个对象。
  • 也许这段代码最近变了?我正在用 java 1.8.60 测试它。在那里,ZoneRulesProvider.getAvailableZoneIds() 在第 3715 行被调用,并且每次都无条件调用,而不仅仅是每个区域一次。
  • 我相信所有版本的所有源代码都可以在线获得,并且很容易检查。只是现在我正在为潜在的解决方法写一个答案。顺便说一句,我正在看 1.8.25。
  • @OlivierGrégoire 这是劳力士,我很确定它是准确的......
  • OpenJDK bug 8066291 似乎相关。那里的投诉来自ZoneIdPrinterParser,但似乎是同一个问题。

标签: java performance java-8 java-time jmh


【解决方案1】:

正如您的问题和我的评论中所述,ZoneRulesProvider.getAvailableZoneIds() 会在每次需要解析时区时创建一组新的所有可用时区的字符串表示形式(static final ConcurrentMap&lt;String, ZoneRulesProvider&gt; ZONES 的键)。1

幸运的是,ZoneRulesProvider 是一个设计为子类的abstract 类。方法protected abstract Set&lt;String&gt; provideZoneIds() 负责填充ZONES。因此,如果子类提前知道要使用的所有个时区,则它只能提供所需的时区。由于该类将提供比包含数百个条目的默认提供者更少的条目,因此它有可能显着减少 getAvailableZoneIds() 的调用时间。

ZoneRulesProvider API 提供有关如何注册的说明。注意provider不能注销,只能补充,所以去掉默认provider,添加自己的provider并不是一件简单的事情。系统属性java.time.zone.DefaultZoneRulesProvider 定义默认提供程序。如果它返回null(通过System.getProperty("..."),则加载了JVM 臭名昭著的提供程序。使用System.setProperty("...", "fully-qualified name of a concrete ZoneRulesProvider class") 可以提供他们自己的提供程序,这是在第 2 段中讨论的。

最后,我建议:

  1. 子类化abstract class ZoneRulesProvider
  2. 仅使用所需的时区实现protected abstract Set&lt;String&gt; provideZoneIds()
  3. 将系统属性设置为此类。

我不是自己做的,但我确信它会由于某种原因而失败认为它会起作用。


1问题的 cmets 中建议调用的确切性质可能在 1.8 版本之间发生了变化。

编辑:找到更多信息

前面提到的默认ZoneRulesProviderfinal class TzdbZoneRulesProvider,位于java.time.zone。该类中的区域从路径中读取:JAVA_HOME/lib/tzdb.dat(在我的情况下,它位于 JDK 的 JRE 中)。该文件确实包含许多区域,这是一个sn-p:

 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

然后,如果有人找到一种方法来创建仅包含所需区域的类似文件并加载该文件,那么性能问题将可能不会肯定会得到解决。

【讨论】:

  • 我明白了。感谢您的详细回答。它帮助我更深入地挖掘并了解了有关 java 时间实现的一些知识。没有办法通过 API 将我自己的解析器插入 DateTimeFormatter 构建器,唯一的方法似乎真的是按照您的建议弯曲 ZoneRulesProvider。我想我暂时不会这样做,它需要一些额外的部署配置,并且可能会干扰系统的其他部分。使用旧的 API 解析暂时还好,说不定哪天会优化新的解析器。
【解决方案2】:

这个问题是由ZoneRulesProvider.getAvailableZoneIds() 引起的,它每次都复制了一组时区。错误JDK-8066291 跟踪了该问题,并已在 Java SE 9 中修复。它不会被向后移植到 Java SE 8,因为错误修复涉及规范更改(该方法现在返回不可变集而不是可变集)。

附带说明一下,其他一些与解析有关的性能问题已被向后移植到 Java SE 8,因此请始终使用最新的更新版本。

【讨论】:

    猜你喜欢
    • 2016-08-12
    • 1970-01-01
    • 2011-12-18
    • 2013-09-03
    • 1970-01-01
    • 2015-04-28
    • 1970-01-01
    • 2022-06-10
    • 2014-11-02
    相关资源
    最近更新 更多