Skip to content

[LANG-1837] Fix race conditions in lazily initialized toString caches - #1811

Open
aosen-xiong wants to merge 1 commit into
apache:masterfrom
aosen-xiong:read-lazy-tostring-once
Open

aosen-xiong wants to merge 1 commit into
apache:masterfrom
aosen-xiong:read-lazy-tostring-once

Conversation

@aosen-xiong

Copy link
Copy Markdown

Jira: LANG-1837

Fraction.toString(), Fraction.toProperString(), Range.toString() and CharRange.toString() cache their result in a non-volatile field and read that field twice: once for the null check and once for the return.

if (toString == null) {
    toString = ...;
}
return toString;

If the first read sees a value written by another thread, the Java Memory Model does not order the second read after that write, so the second read may still return null (JLS 17.4) and the method can return null. All three classes are documented as immutable.

This change reads each field once into a local variable, then tests and returns the local. There is no functional change for single-threaded callers.

Precedent

  • In this project: a648173, "Fix race condition in Fraction.hashCode()". This pull request does the same for the remaining lazily initialized fields in these classes.
  • In OpenJDK, the same double read was filed and fixed as a bug four times:
    • JDK-8302822, "Method/Field/Constructor/RecordComponent::getGenericInfo() is not thread safe" (review). Its description: "the genericInfo field is read twice, and the second read returned may be null under race conditions".
    • JDK-8291061, "Improve thread safety of FileTime.toString and toInstant" (review), for an immutable class with two lazily initialized fields.
    • JDK-8261404, "Class.getReflectionFactory() is not thread-safe" (review).
    • JDK-8166842, "String.hashCode() has a non-benign data race".

Testing

The existing FractionTest, RangeTest and CharRangeTest cover the string formats and pass. I did not add a test that fails without the change: the stale read is permitted by the memory model but cannot be triggered on demand.

How this was found

By a static analysis for this pattern, confirmed by reading the code. I have not observed a failure.

Thanks for your contribution to Apache Commons! Your help is appreciated!

Before you push a pull request, review this list:

  • Read the contribution guidelines for this project.
  • Read the ASF Generative Tooling Guidance if you use Artificial Intelligence (AI).
  • I used AI to create any part of, or all of, this pull request. Which AI tool was used to create this pull request, and to what extent did it contribute? Claude Opus 5.5. It helped draft the four-method patch and this description; I reviewed both. The commit carries a Co-Authored-By line.
  • Run a successful build using the default Maven goal with mvn; that's mvn on the command line by itself. BUILD SUCCESS on JDK 21: 85,511 tests, 0 failures, 0 Checkstyle violations.
  • Write unit tests that match behavioral changes, where the tests fail if the changes to the runtime are not applied. This may not always be possible, but it is a best practice. Not possible here; see "Testing" above.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body. Note that a maintainer may squash commits during the merge process.

Fraction.toString(), Fraction.toProperString(), Range.toString() and
CharRange.toString() cache their result in a non-volatile field and read
that field twice: once for the null check and once for the return. If
the first read sees a value written by another thread, the Java Memory
Model does not order the second read after that write, so the second
read may still return null and the method can return null.

Read each field once into a local variable, then test and return the
local. This follows up on a648173 ("Fix race condition in
Fraction.hashCode()") for the remaining lazily initialized fields in
these immutable classes. There is no functional change for
single-threaded callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 6, 2026 20:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@garydgregory
garydgregory marked this pull request as draft October 6, 2026 21:33
@garydgregory

Copy link
Copy Markdown
Member

@aosen-xiong
There's nothing in the PR like a unit test to prevent a regression. Switched to draft.

@aosen-xiong

Copy link
Copy Markdown
Author

Hi @garydgregory, thanks for your reply.

You are right, there is no regression test in this PR. However, for this kind of concurrency issue, I am not aware of any stable regression test we can add to trigger this failure in CI before the fix and pass after the fix.

The OpenJDK PRs showed the same pattern. See openjdk/jdk#9608 and https://github.com/openjdk/jdk/pull/6870/changes.

@aosen-xiong
aosen-xiong marked this pull request as ready for review October 9, 2026 03:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants