Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

8345668: ZoneOffset.ofTotalSeconds performance regression #22854

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/java.base/share/classes/java/time/ZoneOffset.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012, 2023, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2012, 2024, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
Expand Down Expand Up @@ -424,11 +424,17 @@ public static ZoneOffset ofTotalSeconds(int totalSeconds) {
throw new DateTimeException("Zone offset not in valid range: -18:00 to +18:00");
}
if (totalSeconds % (15 * SECONDS_PER_MINUTE) == 0) {
return SECONDS_CACHE.computeIfAbsent(totalSeconds, totalSecs -> {
ZoneOffset result = new ZoneOffset(totalSecs);
Integer totalSecs = totalSeconds;
ZoneOffset result = SECONDS_CACHE.get(totalSecs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, each call may allocate an Integer object. The maximum number of ZoneOffsets that need to be cached here is only 148. Using AtomicReferenceArray is better than AtomicConcurrentHashMap.

Copy link
Contributor

@wenshao wenshao Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example:

static final AtomicReferenceArray<ZoneOffset> MINUTES_15_CACHE = new AtomicReferenceArray<>(37 * 4);

    public static ZoneOffset ofTotalSeconds(int totalSeconds) {
        // ...
        int minutes15Rem = totalSeconds / (15 * SECONDS_PER_MINUTE);
        if (totalSeconds - minutes15Rem * 15 * SECONDS_PER_MINUTE == 0) {
            int cacheIndex = minutes15Rem + 18 * 4;
            ZoneOffset result = MINUTES_15_CACHE.get(cacheIndex);
            if (result == null) {
                result = new ZoneOffset(totalSeconds);
                if (!MINUTES_15_CACHE.compareAndSet(cacheIndex, null, result)) {
                    result = MINUTES_15_CACHE.get(minutes15Rem);
                }
            }
            return result;
        }
       // ...
    }

Copy link
Member Author

@naotoj naotoj Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Shaojin,
Thanks for the suggestion, but I am not planning to improve the code more than backing out the offending fix at this time. (btw, cache size would be 149 as 18:00 and -18:00 are inclusive)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I submit a PR to make this improvement?

Copy link
Member

@liach liach Dec 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wenshao I agree with your proposal. Also for this part:

ZoneOffset result = MINUTES_15_CACHE.get(cacheIndex);
if (result == null) {
    result = new ZoneOffset(totalSeconds);
    if (!MINUTES_15_CACHE.compareAndSet(cacheIndex, null, result)) {
        result = MINUTES_15_CACHE.get(minutes15Rem);
    }
}

I recommend a rewrite:

ZoneOffset result = MINUTES_15_CACHE.getPlain(cacheIndex);
if (result == null) {
    result = new ZoneOffset(totalSeconds);
    ZoneOffset existing = MINUTES_15_CACHE.compareAndExchange(cacheIndex, null, result);
    return existing == null ? result : existing;
}

The getPlain is safe because ZoneOffset is thread safe, so you can use the object when you can observe a ZoneOffset object reference. Also compareAndExchange avoids extra operations if we failed to racily set the computed ZoneOffset.

if (result == null) {
result = new ZoneOffset(totalSeconds);
var existing = SECONDS_CACHE.putIfAbsent(totalSecs, result);
if (existing != null) {
result = existing;
}
ID_CACHE.putIfAbsent(result.getId(), result);
return result;
});
}
return result;
} else {
return new ZoneOffset(totalSeconds);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012, 2022, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2012, 2024, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
Expand Down Expand Up @@ -309,7 +309,13 @@ public Iterator<Entry<String, Long>> getTextIterator(Chronology chrono, Temporal

private Object findStore(TemporalField field, Locale locale) {
Entry<TemporalField, Locale> key = createEntry(field, locale);
return CACHE.computeIfAbsent(key, e -> createStore(e.getKey(), e.getValue()));
Object store = CACHE.get(key);
if (store == null) {
store = createStore(field, locale);
CACHE.putIfAbsent(key, store);
store = CACHE.get(key);
}
return store;
}

private static int toWeekDay(int calWeekDay) {
Expand Down
10 changes: 8 additions & 2 deletions src/java.base/share/classes/java/time/format/DecimalStyle.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012, 2022, Oracle and/or its affiliates. All rights reserved.
* Copyright (c) 2012, 2024, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
Expand Down Expand Up @@ -160,7 +160,13 @@ public static DecimalStyle ofDefaultLocale() {
*/
public static DecimalStyle of(Locale locale) {
Objects.requireNonNull(locale, "locale");
return CACHE.computeIfAbsent(locale, DecimalStyle::create);
DecimalStyle info = CACHE.get(locale);
if (info == null) {
info = create(locale);
CACHE.putIfAbsent(locale, info);
info = CACHE.get(locale);
}
return info;
}

private static DecimalStyle create(Locale locale) {
Expand Down
53 changes: 53 additions & 0 deletions test/micro/org/openjdk/bench/java/time/ZoneOffsetBench.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.openjdk.bench.java.time;

import java.time.ZoneOffset;
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.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

@BenchmarkMode(Mode.AverageTime)
@State(Scope.Benchmark)
@Fork(1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2)
@Measurement(iterations = 5)
public class ZoneOffsetBench {

@Benchmark
public void ofTotalSeconds() {
for (int i = 0; i < 1_000; i++) {
ZoneOffset.ofTotalSeconds(0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This benchmark method should accept a Blackhole, and the return value of ofTotalSeconds must be sent to the Blackhole.consume method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This benchmark currently works probably because the cache interactions in ofTotalSeconds, which means JIT compilation cannot prove it is side-effect free. Had it been as simple as a decimal computation or if the cache becomes a stable map, JIT compilation can eliminate the static factory method call entirely, and the benchmark would be measuring the performance of no-op invocation.

}
}
}