All findings · Lucene finding · LFV-323 · ramusage-sizeofcollection-int-overflow

RamUsageEstimator.sizeOfCollection adds the backing-array bytes in int arithmetic, so collections of 536,870,908 or more elements are estimated 4 GiB or more too small (negative for a list of nulls)

RamUsageEstimator.sizeOfCollection adds a collection's backing-array size in 32-bit int arithmetic, so from 536,870,908 elements on it wraps: a list of 536,870,908 nulls is estimated at -2,147,483,616 bytes instead of about 2.1 GB. Every release since 8.2.0 has it; by default no Lucene code path sizes a collection that large.

Summary

Priority
Low, rank 1196RamUsageEstimator.sizeOfCollection overflows only for collections of 536M+ elements and has no Lucene caller.
Impact
minor
Since
8.2.0 (2019)introduced 2019-06-27 · about 7 years
Affected
8.2.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: an SMT bit-vector model, then a JUnit test

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

One line of integer arithmetic: the method adds the modelled array, 16 + n × 4 bytes, as an int, which is wrong once n reaches 536,870,908.

Background: how RamUsageEstimator sizes a collection

  1. RamUsageEstimator is Lucene's helper for estimating how many bytes a Java object takes on the heap. Lucene's query cache sizes queries with it, and downstream memory budgets add up its estimates.
  2. For a collection, sizeOfCollection assumes it is backed by an array, like ArrayList: the collection object, plus one array of references (a 16-byte header and 4 bytes per element with compressed references; 24 and 8 without), plus each element's own estimate.
  3. A Java int holds at most 2,147,483,647. Arithmetic past that silently wraps around to negative numbers; a long holds up to about 9.2 × 10^18.
The array part of RamUsageEstimator.sizeOfCollection's estimate, 16 + n × 4 bytes, computed in a 32-bit int: at n = 536,870,907 it is 2,147,483,644 and fits; at n = 536,870,908 it would be 2,147,483,648, one past the largest int (2,147,483,647), so it wraps to -2,147,483,648 and the estimate becomes -2,147,483,616 bytes instead of 2,147,483,672.
How 16 + n × 4, computed in a 32-bit int, wraps to a negative number at n = 536,870,908, while the same sum in long gives the right size. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Make a list of 536,870,908 elements, for example Arrays.asList(new Object[536870908]), and ask RamUsageEstimator.sizeOfCollection(list) how many bytes it uses.
  2. You expect at least its backing array: a 16-byte header plus 536,870,908 references of 4 bytes (compressed references, the usual case), 2,147,483,648 bytes, which RamUsageEstimator.shallowSizeOf(array) reports correctly. With the 24-byte list object: 2,147,483,672.
  3. The method adds 16 + 536,870,908 × 4 in an int. The largest int is 2,147,483,647, so 2,147,483,648 wraps to -2,147,483,648; with the 24-byte list object and rounding to 8 bytes you get -2,147,483,616.
  4. One element fewer gives the right 2,147,483,672. From 536,870,908 elements on (268,435,453 with 8-byte references) every estimate is 4 GiB or more too small; with real elements it usually stays positive, for example 40% too small for Integer elements at the threshold.

Who is affected

Only code that sizes one collection of 536,870,908 or more elements (2 GiB of references) with this @lucene.internal estimator, typically downstream memory accounting. By default no Lucene code path does (the sandbox CoveringQuery could, with a clause limit raised far above 1024). A budget that sums the estimates under-counts by 4 GiB or more.

Mechanism, terms and numbers793 words

RamUsageEstimator.sizeOfCollection estimates a collection's heap size as the collection object, plus one array of references, plus each element's estimate. It adds the array part as NUM_BYTES_ARRAY_HEADER + collection.size() * NUM_BYTES_OBJECT_REF (RamUsageEstimator.java:307), where both constants and Collection.size() are int, so the sum is computed in 32-bit arithmetic and only then widened to long. From 536,870,908 elements on (2^29 - 4, with 4-byte references and a 16-byte array header; 268,435,453 with 8-byte references and a 24-byte header; both with the default 8-byte object alignment) it wraps. sizeOfCollection(Arrays.asList(new Object[536870908])) returns -2,147,483,616 bytes, while shallowSizeOf of the backing array correctly returns 2,147,483,648.

The estimate is then always too small by a multiple of 4 GiB (with 4-byte references 8 GiB from 1,610,612,732 elements on; up to 16 GiB with 8-byte references), and it is negative only when the elements add little: with Integer elements it is positive but 40% low at the threshold. sizeOfObject on a collection and sizeOfMap on a map with collection values reach the same line. Every other array formula in the class widens to long first. With default settings no Lucene code path sizes a collection that large (the sandbox CoveringQuery would, but only after the application raises the clause limit from 1024 to more than 536,870,907), so in practice only callers of this @lucene.internal helper, typically downstream memory accounting, are affected. It is in every release since 8.2.0 (2019) and on main; the one-line cast (long) collection.size() * NUM_BYTES_OBJECT_REF fixes it and passes Lucene's core tests.

The code

private static long sizeOfCollection(Collection<?> collection, int depth, long defSize) {   // RamUsageEstimator.java:298
  if (collection == null) {
    return 0;
  }
  long size = shallowSizeOf(collection);                                                   // :302
  if (depth > MAX_DEPTH) {
    return size;
  }
  // assume array-backed collection and add per-object references                         // :306
  size += NUM_BYTES_ARRAY_HEADER + collection.size() * NUM_BYTES_OBJECT_REF;               // :307, all int
  for (Object o : collection) {
    size += sizeOfObject(o, depth, defSize);
  }
  return alignObjectSize(size);                                                            // :311
}

The same class sizes the same array correctly in shallowSizeOf(Object[]):

return alignObjectSize(
    (long) NUM_BYTES_ARRAY_HEADER + (long) NUM_BYTES_OBJECT_REF * arr.length);             // :512-513

The walk for Arrays.asList(new Object[536870908]), 4-byte references

  1. shallowSizeOf(list) = 24 bytes (:302).
  2. :307, in int: 16 + 536,870,908 × 4 = 2,147,483,648. That is one more than Integer.MAX_VALUE, so it wraps to -2,147,483,648, and size = 24 - 2,147,483,648 = -2,147,483,624.
  3. The elements are null and add 0.
  4. alignObjectSize rounds with Java's %, which keeps the sign, so the negative sum moves 8 bytes toward zero: -2,147,483,616.

With 536,870,907 elements the sum is 2,147,483,644, which fits, and the result is the correct 2,147,483,672. Adding one null element drops the estimate by 4,294,967,288 bytes (2^32 - 8).

What bounds it

  • Threshold. (2^31 - 1 - header) / ref + 1: 536,870,908 = 2^29 - 4 with 4-byte references and a 16-byte header, 268,435,453 with 8-byte references and a 24-byte header (:129, :133; both with the default 8-byte object alignment). The header addition overflows first. For these two layouts a Z3 bit-vector model of the line found these minimal sizes and proved that no smaller size overflows.
  • Size of the error. Always a multiple of 2^32 bytes before alignment (2^32 - 8 after it where the result is negative). With 4-byte references it is 4 GiB from 536,870,908 to 1,610,612,731 elements and 8 GiB from 1,610,612,732 on (nCopies(Integer.MAX_VALUE, null) gives 40 bytes instead of 8,589,934,632). With 8-byte references it reaches 16 GiB (48 bytes instead of 17,179,869,232 at Integer.MAX_VALUE).
  • Negative only sometimes. With null elements (4-byte references) the result is negative in two ranges: from 536,870,908 to 1,073,741,810 elements, and again from 1,610,612,732 to 2,147,483,634, where the int sum wraps a second time. In between it is positive but 4 GiB low (it climbs from 0 back to 2,147,483,672); above the second range it is 0 to 40 bytes, 8 GiB low. With real elements it is usually positive but 4 GiB low: about 3% of the total with the default 256-byte size for unknown objects, 40-50% with shared Integer elements at the threshold (20-25% further up). A "not negative" check alone misses most cases.
  • Entry points. Both public sizeOfCollection overloads (:285, :294); sizeOfObject on a Collection (:393, :403, the Collection case at :434); a collection value in a map passed to the public sizeOfMap (:247, :256, via :275). The hppc *ObjectHashMap.ramBytesUsed methods do not reach it: they pass the iteration cursor, not the value, to sizeOfObject (IntObjectHashMap.java:446-447).
  • Not affected. sizeOfMap's own sums (:269-277), shallowSizeOf(Object[]) (:511-514) and shallowSizeOfArray (:561-574) add in long. Of the file's 16 multiplications (on 15 lines), :307 is the only one done in int.
Exposure and likelihood359 words
  • Who. Code that passes one huge collection to sizeOfCollection, sizeOfObject or (as a map value) sizeOfMap. RamUsageEstimator is public and @lucene.internal; downstream projects use it for memory accounting. A read-only code search found sizeOfCollection call sites in 26 files of one downstream search engine, among them a query circuit breaker (read, not run).
  • Not Lucene itself, by default. No in-tree production code calls sizeOfCollection directly. The only in-tree caller that passes a Collection is the sandbox CoveringQuery (sizeOfObject(queries, ...), CoveringQuery.java:82-85), whose constructor rejects more than IndexSearcher.getMaxClauseCount() sub-queries (:70-72; default 1024). The limit is the application's to set: IndexSearcher.setMaxClauseCount(int) is public and accepts any value of at least 1, so an application that raises it above 536,870,907 and passes that many sub-queries does reach the line. No query parser builds a CoveringQuery. The other callers pass arrays, maps whose values are not collections, or scalars.
  • Default vs opt-in. No setting is involved, apart from that clause limit. The threshold depends only on the JVM's object layout: the reference size and the array header, which the class derives from the reference size and the object alignment. With the default 8-byte alignment it is 536,870,908 with compressed references and 268,435,453 with 8-byte references (-XX:-UseCompressedOops, or heaps too large for compressed references). A non-default -XX:ObjectAlignmentInBytes can change the header (for example 32 bytes with 8-byte references and 16-byte alignment, which gives 268,435,452, by arithmetic); the test computes n from the runtime constants.
  • Scale. Either threshold is 2 GiB of references in one collection.
  • Effect. A memory budget that sums these estimates under-counts by 4 GiB (at most 8 or 16 GiB) and trips later than intended. Nothing in Lucene uses the value to decide matching, filtering, deletions or what is written. With compressed references, elements of 28 bytes or more push the correct estimate to about 16 GiB before the threshold, so a budget of half the heap trips first (executed probe).
  • Likelihood: contrived (a judgement). It takes one collection with 2 GiB of references, sized by this estimator, and no default Lucene path builds or sizes one; accidental triggering looks unlikely.

Minimal repro

One JUnit test, public API only, deterministic and heap-free: Collections.nCopies makes a virtual list of 536,870,908 nulls (the size is computed from the JVM's constants), and the test asserts that its estimated size is not negative. It runs in about 5 s.

TestRamUsageEstimatorLargeCollection.java33 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.util;

import java.util.Collections;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRamUsageEstimatorLargeCollection extends LuceneTestCase {

  public void testSizeOfLargeCollectionIsNotNegative() {
    // n: smallest size whose header + n * ref bytes exceed an int; expected >= 0 bytes, actual < 0
    int n =
        (Integer.MAX_VALUE - RamUsageEstimator.NUM_BYTES_ARRAY_HEADER)
                / RamUsageEstimator.NUM_BYTES_OBJECT_REF
            + 1;
    long bytes = RamUsageEstimator.sizeOfCollection(Collections.nCopies(n, null));
    assertTrue("sizeOfCollection of " + n + " elements: expected >= 0, got " + bytes, bytes >= 0);
  }
}
Command
cp $REPRO/TestRamUsageEstimatorLargeCollection.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestRamUsageEstimatorLargeCollection -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
sizeOfCollection(Collections.nCopies(536870908, null)) returns a size of at least 0 bytes (2,147,483,672 with the fix).
Observed
AssertionError: sizeOfCollection of 536870908 elements: expected >= 0, got -2147483616, with assertions on and off.

All files of this repro: repro/LFV-323/

More files, environment, full output302 words
  • Why nCopies. It is a virtual list, so the test needs no heap beyond the default; the method still iterates the 536,870,908 nulls (about 5 s). The stronger, contract-based form compares sizeOfCollection(Arrays.asList(backing)) with shallowSizeOf(backing) (-2,147,483,616 against 2,147,483,648), but it needs a 2 GiB array (-Xmx4g). The virtual list holds no backing array, so for nCopies the "expected" size is the method's own array model, not memory the list retains; the Arrays.asList comparison is the stronger demonstration.
  • Smallest input. n is computed from the runtime constants, so it is the smallest failing size on any JVM layout: with the default 8-byte alignment 536,870,908 with compressed references, 268,435,453 with -XX:-UseCompressedOops (then: expected >= 0, got -2147483608). 536,870,907 gives the correct 2,147,483,672.
  • As the regression test: this same test, for example as a method of TestRamUsageEstimator. It passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestRamUsageEstimatorLargeCollection.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.TestRamUsageEstimatorLargeCollection -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestRamUsageEstimatorLargeCollection > testSizeOfLargeCollectionIsNotNegative FAILED
    java.lang.AssertionError: sizeOfCollection of 536870908 elements: expected >= 0, got -2147483616
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C88601A39A7659BE]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.util.TestRamUsageEstimatorLargeCollection.testSizeOfLargeCollectionIsNotNegative(TestRamUsageEstimatorLargeCollection.java:31)
:lucene:core:test (FAILURE): 1 test, 1  failure

# 10.6.0 (Maven Central jar), assertions on and off
java.lang.AssertionError: sizeOfCollection of 536870908 elements: expected >= 0, got -2147483616

# main 645d4fe14a20, -XX:-UseCompressedOops (8-byte references), assertions off
java.lang.AssertionError: sizeOfCollection of 268435453 elements: expected >= 0, got -2147483608

# with the fix, assertions on and off
:lucene:core:test (SUCCESS): 1 test

# the finder's array-backed tests, main ea57360912ed, -Xmx4g, assertions off
sizeOfCollection(536870908 elements) = -2147483616, but the backing Object[] alone is 2147483648 bytes
sizeOfObject(list of 536870908) = -2147483616 < backing array 2147483648

Why it looks unintended

  • The javadoc promises "the size in bytes of a Collection object, including sizes of its values", with no size limit.
  • The class's other array formulas widen to long first, a rule adopted in 2012 for arrays above 2 GB.
  • A negative or 4 GiB-low byte count is no "best effort" estimate: the method gets its own formula wrong.
The full argument175 words
  • The contract. Both public overloads: "Returns the size in bytes of a Collection object, including sizes of its values" (RamUsageEstimator.java:281, :290), with no size limit. The comment above the line says the method models an array-backed collection (:306). 536,870,908 is a legal array length; the finder's test allocated it.
  • The class gets the same array right elsewhere. shallowSizeOf(Object[]) (:511-514) and shallowSizeOfArray (:561-574) cast to long before multiplying. Those casts came in with LUCENE-3867 (#4940, 2012), because the array formulas "can overflow if arrays are greater than 2 GB".
  • History shows no intent. The line came in with the LUCENE-8855 follow-up 7989a863faad "Fix some size estimates and relax test assertions to work under different JVMs." (2019), which replaced the constant NUM_BYTES_ARRAY_HEADER * 2 with the element-count term. Neither the commit nor the LUCENE-8855 discussion (#9898) mentions a size limit.
  • Tests. The only collection test, TestRamUsageEstimator.testCollection (:165-181), uses a 102-element list and accepts a 10% error (:174). No test or javadoc sets a size limit.
The devil's-advocate check195 words

The intended-behaviour checker built the strongest case for "not a bug": RamUsageEstimator is @lucene.internal; its class javadoc says the measurements "may be slightly wrong" and sizeOfObject is a "Best effort attempt"; the array-backed model is itself a heuristic; no Lucene code reaches the size; and with the default 256-byte element size the error is only about 3%.

Why it fails: @lucene.internal is about API stability ("might change in incompatible ways in the next release"), not a waiver for wrong results, and the methods are public and documented without a size limit. "May be slightly wrong" is the class javadoc's caveat for a non-OpenJDK/Oracle-based JVM, that is, for layout assumptions, and "Best effort attempt" describes sizing objects of undetermined type, which fall back to a default size; neither covers wraparound. Here the method gets its own array formula wrong, on HotSpot, by a multiple of 2^32. A negative byte count is never a valid estimate, and with shared Integer elements the error is 40-50% at the threshold, outside the 10% that testCollection accepts. What the argument does establish: priority is low, and "no Lucene code reaches the size" holds only with the default clause limit.

Suggested fix

Recommended A. Cast the element count to long

Widen before multiplying: NUM_BYTES_ARRAY_HEADER + (long) collection.size() * NUM_BYTES_OBJECT_REF, as the class's other array formulas already do. Results below the threshold do not change. Validated: the test passes, and so do the closest suites and all of :lucene:core:test.

     // assume array-backed collection and add per-object references
-    size += NUM_BYTES_ARRAY_HEADER + collection.size() * NUM_BYTES_OBJECT_REF;
+    size += NUM_BYTES_ARRAY_HEADER + (long) collection.size() * NUM_BYTES_OBJECT_REF;
All options and trade-offs3 options · 347 words

Three options; option A was applied in a scratch worktree of main 645d4fe14a20 and run against the minimal test, the closest suites, all core tests and core's static checks.

A. Cast the element count to long (validated by the checks) (recommended)

One line in sizeOfCollection (RamUsageEstimator.java:307). (long) collection.size() * NUM_BYTES_OBJECT_REF is at most (2^31 - 1) × 8, far inside long, and the header is then added in long. Below the threshold the int expression was exact, so no existing estimate moves.

     // assume array-backed collection and add per-object references
-    size += NUM_BYTES_ARRAY_HEADER + collection.size() * NUM_BYTES_OBJECT_REF;
+    size += NUM_BYTES_ARRAY_HEADER + (long) collection.size() * NUM_BYTES_OBJECT_REF;

Trade-offs

None found: sizeOfCollection is not on a search hot path, and one widening costs nothing next to the per-element loop.

What the checks validated

  • The minimal test passes with assertions on and off (gradle, scratch worktree of 645d4fe14a20; also standalone).
  • Closest suites, seed DEADBEEF: TestRamUsageEstimator, the minimal test, the 15 hppc collection and map test classes and TestLRUQueryCache, 482 tests (4 skipped), 0 failures; sandbox TestCoveringQuery, 5 tests (2 skipped), 0 failures.
  • All of :lucene:core:test passes (random seed, 8,898 tests, 321 skipped, 0 failures, the minimal test included).
  • :lucene:core:check -x test passes (formatting, ecj lint, forbidden APIs, license checks).
  • Earlier, on main ea57360912ed: the finder's 3 tests, an independent verifier's 4 tests (with 4-byte and 8-byte references) and a boundary check at every size pass with the same cast.

B. Mirror shallowSizeOf(Object[])

size += alignObjectSize((long) NUM_BYTES_ARRAY_HEADER + (long) NUM_BYTES_OBJECT_REF * collection.size()); also aligns the modelled array on its own, as a real array is.

Trade-offs

More faithful, but with 4-byte references it can raise the estimate of a collection with an odd number of elements by up to 8 bytes: a behaviour change beyond the bug. Not validated.

C. Clamp the result or throw on overflow

Clamp a negative estimate, or use Math.multiplyExact / Math.addExact.

Trade-offs

Not recommended: a clamp misses the more common positive-but-4 GiB-low case, and an exception from an estimator breaks callers that only want a number. Not validated.

Downstream note

Downstream memory accounting, such as a cache or a circuit breaker, that sizes a collection of 536,870,908 or more elements with this estimator under-counts it by 4 GiB or more and so trips later than intended.

How it was found

Semi-formal. While modelling RamUsageEstimator, an AI tool listed the file's 16 multiplications and found only this one computed in int. A Z3 bit-vector model of the line for the default layouts gave the smallest overflowing sizes and proved that no smaller one overflows; a JUnit test on the stock build then reproduced it.

The method in full142 words
  • The hunt unit covered numeric, string and version utilities; the finder was modelling RamUsageEstimator for a no-throw property.
  • Its inventory of the file's 16 multiplications (on 15 lines) showed :307 as the only one evaluated in int; the others multiply in long.
  • A Z3 32-bit bit-vector model of NUM_BYTES_ARRAY_HEADER + n * NUM_BYTES_OBJECT_REF gave the minimal overflowing n: 536,870,908 for 4-byte references and a 16-byte header, 268,435,453 for 8-byte references and a 24-byte header; below each, the expression is exact (unsat). Both sizes are legal array lengths.
  • A public-API JUnit test on the stock main build then failed: Arrays.asList(new Object[536870908]) sized at -2,147,483,616 bytes against 2,147,483,648 for its backing array. Independent verifiers reproduced it with their own tests, corrected the wording (the threshold is 2^29 - 4; the result is not always negative) and found no sign that it is intended.

Prior reports, history and checks

Prior reports

No report found: 119 queries (2026-10-26) over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream code and the web, and a GitHub re-check on 2026-11-01. sizeOfCollection matches no Lucene issue or PR. Upstream main, branch_11x and branch_10_6 still have the line.

Searches5 searches

Closest items, none the same bug: #9898 (LUCENE-8855), which introduced the method; #4940 (LUCENE-3867, 2012), which added the long casts to the array formulas; the 2022 error-prone int-overflow sweep #11910, #11921 and #11923, whose lists do not include this file; #15026 (sizeOf(Query) accuracy) and #16113/#16114 (Accountable ownership); the merged accuracy fixes #16743 and #16296, which do not touch the line; elastic/elasticsearch#149697, a downstream circuit breaker that calls sizeOfCollection and reports no overflow. Gaps: GitHub search returns at most 50 results per query; web search used one engine; private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26three scripted searches plus manual searches, for example sizeOfCollection, "RamUsageEstimator overflow", "RamUsageEstimator negative", "sizeOfObject overflow"; the changed files of all 411 open PRsno report; 0 issues or PRs mention sizeOfCollection; open #12295 and #11873 touch the file but not the line
GitHub re-check (apache/lucene)2026-11-01sizeOfCollection, "RamUsageEstimator overflow", "RamUsageEstimator negative"; the 15 newest issues and PRs that mention RamUsageEstimator; open PRsno report; nothing new since 2026-10-26
JIRA archive2026-10-26text ~ "sizeOfCollection" and the scripted JIRA queries0 results
Mailing lists (dev@, java-user@, issues@, commits@; Solr lists)2026-10-26scripted list queries plus manual searchesno report
Downstream and web (Solr, Elasticsearch, other GitHub projects)2026-10-26GitHub searches and 6 web queriescallers only; elastic/elasticsearch#149697 reports no overflow

History

Introduced on 2019-06-28 by the LUCENE-8855 follow-up 7989a863faad, which replaced a constant with the element-count term; first released in 8.2.0. It fails on every release run from 8.2.0 to 10.6.0 and on main; 8.1.1 has no such method.

Versions tested5 rows
  • Introduced: 7989a863faad "LUCENE-8855: Fix some size estimates and relax test assertions to work under different JVMs." (authored 2019-06-27, committed 2019-06-28; branch_8x cherry-pick 84db2b274286), through the JIRA patch workflow, no PR; the issue is LUCENE-8855, migrated as #9898. It replaced size += NUM_BYTES_ARRAY_HEADER * 2;, the form in which a76c962ee61f had added the method one day earlier and which was never released.
  • Bisect on the 8.2.0 jars, shadowing only RamUsageEstimator: the source of 84db2b274286 overflows (2,147,483,672 at n - 1, -2,147,483,616 at n); its parent does not (a constant 56, an undercount that was never released).
  • The identical line is in all 48 release tags from 8.2.0 to 10.5.2 in the history clone and in the 10.6.0 tag; upstream main (2026-10-30), branch_11x and branch_10_6 still have it. Not executed: 8.3.0-8.11.3, 9.1.0-9.12.2 and 10.1.0-10.5.1. Their tagged source has the identical line, except 8.8.2-8.11.3, which have no tags in the history clone; those lie between 8.8.1 (line present) and 8.11.4 (executed, fails).
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20, a9779ef3c193, ea57360912ed affectedfails with assertions on and off (8-byte references too, on 645d4fe14a20 and ea57360912ed); passes with the fix
10.6.0 affectedfails with assertions on and off (minimal test)
10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4, 8.2.0 affectedfails with the same values (release jars, assertions off)
84db2b274286 vs its parent, on the 8.2.0 jars bisectthe commit overflows at n = 536,870,908; its parent gives a constant 56
8.1.1 and earlier no APIsizeOfCollection does not exist (compile error)

Independent checks

The finder, an independent reproducer, a cross-model adversary, an intended-behaviour devil's advocate, an adjudication and a credibility audit all call it real; the adjudication confirmed it with high confidence. Re-run on three main commits, with 8-byte references and on 10.6.0.

Checks and verdicts330 words

The cross-model reviewer (OpenAI GPT-5.6) gave no verdict in the first review; in a later review it could not run code and agreed on the root cause, while the verifying AI tool's own boundary check supplied the executed evidence.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedMultiplication inventory, Z3 bit-vector model (thresholds, exact below them), a 3-test public-API JUnit failing on main with assertions on and off; the cast passes it and the upstream tests.
cross-model review (first review) no verdictOpenAI GPT-5.6: no answer, which is not a refutation.
independent reproducer realOwn 4-test JUnit from the claim only: 3 of 4 fail, the control below the threshold passes, on main (also with 8-byte references), 10.5.2 and 9.12.3; with the cast 4 of 4 pass.
cross-model adversary real, mediumOpenAI GPT-5.6, could not run code: agrees on the root cause. The verifying AI tool's boundary check matches the long formula up to 536,870,907 and is 4,294,967,288 off at 536,870,908; with the fix it matches at every size.
devil's advocate real, minorNo intended, documented, misuse or unreachable argument holds; Integer elements 40% low (50% at the 8-byte threshold).
adjudication confirmed, highRe-ran the independent reproduction's test (3 of 4 fail, control passes) and the fix check (4 of 4 pass); wording corrections.
credibility audit reproducesFresh seed on main: 3 of 3 fail with the same values; impact unchanged (minor).
re-runs reproducesThe finder's test on main a9779ef3c193 (main refresh); the minimal repro on 645d4fe14a20 (standalone and gradle, assertions on and off, 8-byte references) and on 10.6.0; passes with the fix.
fact checks reproducesAn independent fact check re-ran the minimal test on main 645d4fe14a20 (assertions on and off, and with 8-byte references: fails; with the fix: passes) and probed the null-list result up to Integer.MAX_VALUE. A read-only cross-model fact check (OpenAI GPT-5.6) reviewed the text. Their corrections are adopted in this doc.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,074 words

Finding record

LFV-323, snapshot of 2026-11-06

Registry title
RamUsageEstimator.sizeOfCollection adds the assumed backing-array bytes in int arithmetic, so a collection of 536,870,908 or more elements (268,435,453 with 8-byte references) gets a size estimate that is a multiple of 4 GiB too low, and negative when the elements add little
Area
util / RamUsageEstimator
Priority
Low, rank 1196: RamUsageEstimator.sizeOfCollection overflows only for collections of 536M+ elements and has no Lucene caller.
Impact class
minor
Impact note
Only code that passes one Collection of 536,870,908 or more elements (268,435,453 with 8-byte references, that is at least 2 GiB of references) to the @lucene.internal RamUsageEstimator.sizeOfCollection or sizeOfObject (directly, or as a value inside sizeOfMap or an hppc *ObjectHashMap) gets an estimate that is 4 GiB too low at the threshold (up to 8 GiB, or 16 GiB with 8-byte references, near Integer.MAX_VALUE), negative only when the elements add little; no Lucene code path reaches it (the only in-tree caller that passes a Collection is the sandbox CoveringQuery, capped by the default clause limit of 1024), so it matters only to downstream memory accounting; any configuration, released since 8.2.0.
Summary
You ask RamUsageEstimator.sizeOfCollection (or sizeOfObject) for the size of a very large list, for example Arrays.asList(new Object[536870908]). You expect a size in bytes that is at least the 2,147,483,648 bytes of its backing array, which shallowSizeOf(Object[]) reports correctly. Instead the method adds the array header and the per-element references in int arithmetic, which wraps, so it returns -2,147,483,616; in general the estimate is too low by a multiple of 2^32 bytes from 536,870,908 elements on (268,435,453 with 8-byte references), and it is negative only when the elements' own sizes add little.
Introduced
7989a863faad (master, now main; branch_8x cherry-pick 84db2b274286), the LUCENE-8855 follow-up 'Fix some size estimates and relax test assertions to work under different JVMs.' by sigram (GitHub), no PR (2019 JIRA patch workflow; issue LUCENE-8855, migrated as apache/lucene#9898) [read: git blame (also -w -M -C) at the history head 7cecf795236 gives RamUsageEstimator.java:307 to 7989a863faad, git log -L lists only a76c962ee61f and 7989a863faad, git log -S/-G on the expression finds only 7989a863faad and 84db2b274286; exec: on the 8.2.0 jars with only RamUsageEstimator shadowed, the source of 84db2b274286 overflows (2147483672 at n-1, -2147483616 at n) and the source of its parent 84db2b274286^ does not (constant 56)]; it replaced 'size += NUM_BYTES_ARRAY_HEADER * 2', the form with which a76c962ee61f (LUCENE-8855, 2019-06-26) had added the method one day earlier and which was never released (2019-06-27 (authored; committed and pushed 2019-06-28))
First affected version
8.2.0 [exec]; 8.1.1 and earlier have no sizeOfCollection (compile error) [exec]
Affected versions
8.2.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0 and main (ea57360912ed, a9779ef3c193 and 645d4fe14a20) [exec: the minimal test fails on 10.6.0 and main 645d4fe14a20 with -ea and -da (on main also with 8-byte references); the finder repro fails with -da on 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main, and with -ea on main; the independent reproduction's test and the verifying AI tool's boundary check also fail on 10.5.2 and 9.12.3]; the identical line is in all 48 release tags from 8.2.0 to 10.5.2 that the history clone has (8.8.2-8.11.3 are not in the clone and are bracketed by the 8.8.1 source and the 8.11.4 run) [read]; upstream main e1c4184cb983 (line 307), branch_10x and branch_10_6 (line 309; 10.6.0 was released from branch_10_6 on 2026-10-29) still have it (2026-10-26) [read]; 8.1.1 and earlier: API absent [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by Z3
Upstream and prior reports
none found (2026-10-26: three scripted searches with 119 queries over apache/lucene GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and 6 web queries; 8 manual GitHub searches across apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; manual lists.apache.org searches of the Lucene and Solr lists; the file lists of all 411 open apache/lucene PRs (2 touch RamUsageEstimator.java, #12295 and #11873, neither line 307); GitHub issue and PR search for 'sizeOfCollection' in apache/lucene and JIRA text~'sizeOfCollection' both return 0; the 2022 error-prone int-overflow sweep #11910/#11921/#11923 lists no RamUsageEstimator.java site); not fixed upstream: main e1c4184cb983 line 307, branch_10x and branch_10_6 line 309 still have the int expression and CHANGES.txt has no entry; related, not reports of this bug: #9898 (LUCENE-8855, introduced the method), #4940 (LUCENE-3867, 2012, added the long casts to the array formulas for arrays above 2 GB), #15026 (sizeOf(Query) accuracy), #16113/#16114 (Accountable ownership), #16743 and #16296 (merged RamUsageEstimator accuracy fixes), elastic/elasticsearch#149697 (a downstream circuit breaker that calls sizeOfCollection and reports no overflow)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test with assertions on and off standalone (2026-10-29) and through gradle in a scratch worktree (2026-11-01), and with 8-byte references standalone (2026-11-01); the recommended fix in that worktree with the test, TestRamUsageEstimator, the hppc map tests, TestLRUQueryCache, sandbox TestCoveringQuery, all of :lucene:core:test and :lucene:core:check -x test. Release 10.6.0 (Maven Central jar, SHA-1 checked), assertions on and off. Earlier runs: main ea57360912ed (also with 8-byte references) and a9779ef3c193; the 10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4 and 8.2.0 jars; 8.1.1 (no such method); a commit-level check of 84db2b274286 and its parent on the 8.2.0 jars. Read: upstream main 2f424fd428d (2026-10-30), branch_11x and branch_10_6 still have the line. All runs used OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:280-312 (sizeOfCollection javadoc 'Returns the size in bytes of a Collection object, including sizes of its values'; :306 comment 'assume array-backed collection and add per-object references'; :307 the int expression), :511-514 (shallowSizeOf(Object[]) widens to long)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:34-43, :62, :68, :124-133, :166-169, :240, :247-312, :389-444, :511-514, :561-574 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:470 (sizeOf(query, 32)) (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/internal/hppc/IntObjectHashMap.java:444-450, LongObjectHashMap.java:444-450, CharObjectHashMap.java:446-452, ObjectCursor.java:24-37 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:42 (@lucene.internal), :124-133 (at 645d4fe14a20)
  • lucene/sandbox/src/java/org/apache/lucene/sandbox/search/CoveringQuery.java:69-86; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:80, :255-261 (setMaxClauseCount); search/Multiset.java:31 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:34-43, :280-312, :389-404, :511-514, :561-574 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/TestRamUsageEstimator.java:165-181 (at 645d4fe14a20)
History
  • a Lucene clone: origin/main 2f424fd428d, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e, releases/lucene/10.6.0 (RamUsageEstimator.java read)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.