Compares entire object graphs including nested structures
*
Handles circular references safely
*
Provides detailed difference descriptions for troubleshooting
*
Supports numeric comparisons with configurable precision
*
Supports selective ignoring of custom {@code equals()} implementations
*
Supports string-to-number equality comparisons
*
*
*
Options:
*
*
* IGNORE_CUSTOM_EQUALS (a {@code Set>}):
*
*
{@code null} — Use all custom {@code equals()} methods (ignore none).
*
Empty set — Ignore all custom {@code equals()} methods.
*
Non-empty set — Ignore only those classes’ custom {@code equals()} implementations.
*
*
*
* ALLOW_STRINGS_TO_MATCH_NUMBERS (a {@code Boolean}):
* If set to {@code true}, allows strings like {@code "10"} to match the numeric value {@code 10}.
*
*
*
*
The options {@code Map} acts as both input and output. When objects differ, the difference
* description is placed in the options {@code Map} under the "diff" key
* (see {@link DeepEquals#deepEquals(Object, Object, Map) deepEquals}).
*
"diff" output notes:
*
*
Empty lists, maps, and arrays are shown with (∅) or [∅]
*
A Map of size 1 is shown as Map(0..0), an int[] of size 2 is shown as int[0..1], an empty list is List(∅)
*
Sub-object fields on non-difference path shown as {..}
*
Map entry shown with 《key ⇨ value》 and may be nested
*
General pattern is [difference type] ▶ root context ▶ shorthand path starting at a root context element (Object field, array/collection element, Map key-value)
*
If the root is not a container (Collection, Map, Array, or Object), no shorthand description is displayed
*
*
Example usage:
*
* Map<String, Object> options = new HashMap<>();
* options.put(IGNORE_CUSTOM_EQUALS, Set.of(MyClass.class, OtherClass.class));
* options.put(ALLOW_STRINGS_TO_MATCH_NUMBERS, true);
*
* if (!DeepEquals.deepEquals(obj1, obj2, options)) {
* String diff = (String) options.get(DeepEquals.DIFF); // Get difference description
* // Handle or log 'diff'
*
* Example output:
* // Simple object difference
* [field value mismatch] ▶ Person {name: "Jim Bob", age: 27} ▶ .age
* Expected: 27
* Found: 34
*
* // Array element mismatch within an object that has an array
* [array element mismatch] ▶ Person {id: 173679590720000287, first: "John", last: "Smith", favoritePet: {..}, pets: Pet[0..1]} ▶ .pets[0].nickNames[0]
* Expected: "Edward"
* Found: "Eddie"
*
* // Map with a different value associated to a key (Map size = 1 noted as 0..0)
* [map value mismatch] ▶ LinkedHashMap(0..0) ▶ 《"key" ⇨ "value1"》
* Expected: "value1"
* Found: "value2"
*
*
*
*
Security and Performance Configuration:
*
DeepEquals provides configurable security and performance options through system properties.
* Default safeguards are enabled to prevent excessive resource consumption:
* Licensed 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
*
* 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.
*/
@SuppressWarnings("unchecked")
public class DeepEquals {
// Option keys
public static final String IGNORE_CUSTOM_EQUALS = "ignoreCustomEquals";
public static final String ALLOW_STRINGS_TO_MATCH_NUMBERS = "stringsCanMatchNumbers";
public static final String DIFF = "diff";
public static final String DIFF_ITEM = "diff_item";
public static final String INCLUDE_DIFF_ITEM = "deepequals.include.diff_item";
private static final String DEPTH_BUDGET = "__depthBudget";
private static final String EMPTY = "∅";
private static final String TRIANGLE_ARROW = "▶";
private static final String ARROW = "⇨";
private static final String ANGLE_LEFT = "《";
private static final String ANGLE_RIGHT = "》";
// Thread-safe UTC date formatter for consistent formatting across locales
// Using SafeSimpleDateFormat to handle re-entrant calls safely
private static final SafeSimpleDateFormat TS_FMT;
static {
TS_FMT = new SafeSimpleDateFormat("yyyy-MM-dd HH:mm:ss");
TS_FMT.setTimeZone(TimeZone.getTimeZone("UTC"));
}
// Strict Base64 pattern that properly validates Base64 encoding
// Matches strings that are properly padded Base64 (groups of 4 chars with proper padding)
private static final Pattern BASE64_PATTERN = Pattern.compile(
"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$");
// Precompiled patterns for sensitive data detection
// Note: HEX_32_PLUS and UUID_PATTERN use lowercase patterns since strings are lowercased before matching
private static final Pattern HEX_32_PLUS = Pattern.compile("^[a-f0-9]{32,}$");
private static final Pattern SENSITIVE_WORDS = Pattern.compile(
".*\\b(password|pwd|secret|token|credential|auth|apikey|api_key|secretkey|secret_key|privatekey|private_key)\\b.*");
private static final Pattern UUID_PATTERN = Pattern.compile(
"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$");
private static final double SCALE_DOUBLE = 1e12; // Aligned with DOUBLE_EPSILON (1/epsilon)
private static final float SCALE_FLOAT = 1e6f; // Aligned with FLOAT_EPSILON (1/epsilon)
// ThreadLocal stack of Sets to handle re-entrant formatValue() calls
// Each re-entrant call gets its own Set for circular reference detection
private static final ThreadLocal>> formattingStack =
ThreadLocal.withInitial(ArrayDeque::new);
// ThreadLocal to track max depth budget for current comparison (handles re-entrant calls via stack)
private static final ThreadLocal> maxDepthBudgetStack = ThreadLocal.withInitial(ArrayDeque::new);
// Epsilon values for floating-point comparisons
private static final double DOUBLE_EPSILON = 1e-12;
private static final float FLOAT_EPSILON = 1e-6f;
// Configuration for security-safe error messages - removed static final, now uses dynamic method
// Fields that should be redacted in error messages for security
// Note: "auth" removed to avoid false positives like "author" - it's caught by SENSITIVE_WORDS regex
private static final Set SENSITIVE_FIELD_NAMES = CollectionUtilities.setOf(
"password", "pwd", "passwd", "secret", "token", "credential",
"authorization", "authentication", "api_key", "apikey", "secretkey"
);
// Default security limits
private static final int DEFAULT_MAX_COLLECTION_SIZE = 100000;
private static final int DEFAULT_MAX_ARRAY_SIZE = 100000;
private static final int DEFAULT_MAX_MAP_SIZE = 100000;
private static final int DEFAULT_MAX_OBJECT_FIELDS = 1000;
private static final int DEFAULT_MAX_RECURSION_DEPTH = 1000000; // 1M depth for heap-based traversal
// Security configuration methods
private static boolean isSecureErrorsEnabled() {
return Boolean.parseBoolean(System.getProperty("deepequals.secure.errors", "false"));
}
private static int getMaxCollectionSize() {
String maxSizeProp = System.getProperty("deepequals.max.collection.size");
if (maxSizeProp != null) {
try {
int value = Integer.parseInt(maxSizeProp);
return Math.max(0, value); // 0 means disabled
} catch (NumberFormatException e) {
// Fall through to default
}
}
return DEFAULT_MAX_COLLECTION_SIZE;
}
private static int getMaxArraySize() {
String maxSizeProp = System.getProperty("deepequals.max.array.size");
if (maxSizeProp != null) {
try {
int value = Integer.parseInt(maxSizeProp);
return Math.max(0, value); // 0 means disabled
} catch (NumberFormatException e) {
// Fall through to default
}
}
return DEFAULT_MAX_ARRAY_SIZE;
}
private static int getMaxMapSize() {
String maxSizeProp = System.getProperty("deepequals.max.map.size");
if (maxSizeProp != null) {
try {
int value = Integer.parseInt(maxSizeProp);
return Math.max(0, value); // 0 means disabled
} catch (NumberFormatException e) {
// Fall through to default
}
}
return DEFAULT_MAX_MAP_SIZE;
}
private static int getMaxObjectFields() {
String maxFieldsProp = System.getProperty("deepequals.max.object.fields");
if (maxFieldsProp != null) {
try {
int value = Integer.parseInt(maxFieldsProp);
return Math.max(0, value); // 0 means disabled
} catch (NumberFormatException e) {
// Fall through to default
}
}
return DEFAULT_MAX_OBJECT_FIELDS;
}
private static int getMaxRecursionDepth() {
String maxDepthProp = System.getProperty("deepequals.max.recursion.depth");
if (maxDepthProp != null) {
try {
int value = Integer.parseInt(maxDepthProp);
return Math.max(0, value); // 0 means disabled
} catch (NumberFormatException e) {
// Fall through to default
}
}
return DEFAULT_MAX_RECURSION_DEPTH;
}
/**
* Calculate the depth of the current item in the object graph by counting
* the number of parent links. This is used for heap-based depth tracking.
*/
// Class to hold information about items being compared
private final static class ItemsToCompare {
private final Object _key1;
private final Object _key2;
private final ItemsToCompare parent;
private final String fieldName;
private final int[] arrayIndices;
private final Object mapKey;
private final Difference difference; // New field
private final int depth; // Track depth for recursion limit
// Modified constructors to include Difference
// Constructor for root
private ItemsToCompare(Object k1, Object k2) {
this(k1, k2, null, null, null, null, null, 0);
}
// Constructor for differences where the Difference does not need additional information
private ItemsToCompare(Object k1, Object k2, ItemsToCompare parent, Difference difference) {
this(k1, k2, parent, null, null, null, difference, parent != null ? parent.depth + 1 : 0);
}
// Constructor for field access with difference
private ItemsToCompare(Object k1, Object k2, String fieldName, ItemsToCompare parent, Difference difference) {
this(k1, k2, parent, fieldName, null, null, difference, parent != null ? parent.depth + 1 : 0);
}
// Constructor for array access with difference
private ItemsToCompare(Object k1, Object k2, int[] indices, ItemsToCompare parent, Difference difference) {
this(k1, k2, parent, null, indices, null, difference, parent != null ? parent.depth + 1 : 0);
}
// Constructor for map access with difference
private ItemsToCompare(Object k1, Object k2, Object mapKey, ItemsToCompare parent, boolean isMapKey, Difference difference) {
this(k1, k2, parent, null, null, mapKey, difference, parent != null ? parent.depth + 1 : 0);
}
// Base constructor
private ItemsToCompare(Object k1, Object k2, ItemsToCompare parent,
String fieldName, int[] arrayIndices, Object mapKey, Difference difference, int depth) {
this._key1 = k1;
this._key2 = k2;
this.parent = parent;
this.fieldName = fieldName;
this.arrayIndices = arrayIndices;
this.mapKey = mapKey;
this.difference = difference;
this.depth = depth;
}
@Override
public boolean equals(Object other) {
if (!(other instanceof ItemsToCompare)) {
return false;
}
ItemsToCompare that = (ItemsToCompare) other;
// Only compare the actual objects being compared (by identity)
return _key1 == that._key1 && _key2 == that._key2;
}
@Override
public int hashCode() {
return EncryptionUtilities.finalizeHash(System.identityHashCode(_key1) * 31 + System.identityHashCode(_key2));
}
}
/**
* Performs a deep comparison between two objects, going beyond a simple {@code equals()} check.
*
* This method is functionally equivalent to calling
* {@link #deepEquals(Object, Object, Map) deepEquals(a, b, new HashMap<>())},
* which means it uses no additional comparison options. In other words:
*
*
{@code IGNORE_CUSTOM_EQUALS} is not set (all custom {@code equals()} methods are used)
*
{@code ALLOW_STRINGS_TO_MATCH_NUMBERS} defaults to {@code false}
*
*
*
* @param a the first object to compare, may be {@code null}
* @param b the second object to compare, may be {@code null}
* @return {@code true} if the two objects are deeply equal, {@code false} otherwise
* @see #deepEquals(Object, Object, Map)
*/
public static boolean deepEquals(Object a, Object b) {
return deepEquals(a, b, new HashMap<>());
}
/**
* Performs a deep comparison between two objects with optional comparison settings.
*
* In addition to comparing objects, collections, maps, and arrays for equality of nested
* elements, this method can also:
*
*
Ignore certain classes' custom {@code equals()} methods according to user-defined rules
Handle floating-point comparisons with tolerance for precision
*
Detect and handle circular references to avoid infinite loops
*
*
*
Options:
*
*
* {@code DeepEquals.IGNORE_CUSTOM_EQUALS} (a {@code Collection>}):
*
*
{@code null} — Use all custom {@code equals()} methods (ignore none). Default.
*
Empty set — Ignore all custom {@code equals()} methods.
*
Non-empty set — Ignore only those classes’ custom {@code equals()} implementations.
*
*
*
* {@code DeepEquals.ALLOW_STRINGS_TO_MATCH_NUMBERS} (a {@code Boolean}):
* If set to {@code true}, allows strings like {@code "10"} to match the numeric value {@code 10}. Default false.
*
*
*
*
If the objects differ, a difference description string is stored in {@code options}
* under the key {@code "diff"}. To avoid retaining large object graphs, the {@code "diff_item"}
* object is only stored if {@code "deepequals.include.diff_item"} is set to {@code true} in options.
*
* @param a the first object to compare, may be {@code null}
* @param b the second object to compare, may be {@code null}
* @param options a map of comparison options and, on return, possibly difference details
* @return {@code true} if the two objects are deeply equal, {@code false} otherwise
* @see #deepEquals(Object, Object)
*/
public static boolean deepEquals(Object a, Object b, Map options) {
Deque depthStack = maxDepthBudgetStack.get();
// Calculate max depth budget from user options and system configuration
Integer userBudget = (options != null && options.get(DEPTH_BUDGET) instanceof Integer)
? (Integer) options.get(DEPTH_BUDGET) : null;
int configured = getMaxRecursionDepth();
// Determine max depth: use tighter of user budget or configured limit
// Use Integer.MAX_VALUE to mean "no limit" (ArrayDeque doesn't allow null)
int maxDepth = Integer.MAX_VALUE;
if (userBudget != null && userBudget > 0) {
maxDepth = userBudget;
}
if (configured > 0) {
maxDepth = Math.min(maxDepth, configured);
}
// Push onto stack
depthStack.push(maxDepth);
try {
Set visited = new ConcurrentSet<>();
return deepEquals(a, b, options, visited);
} finally {
depthStack.pop();
if (depthStack.isEmpty()) {
maxDepthBudgetStack.remove(); // Clean up ThreadLocal when no nested calls
}
// Only remove formattingStack if empty (to support re-entrant calls)
Deque> fmtStack = formattingStack.get();
if (fmtStack != null && fmtStack.isEmpty()) {
formattingStack.remove();
}
}
}
private static boolean deepEquals(Object a, Object b, Map options, Set visited) {
Deque stack = new ArrayDeque<>();
boolean result = deepEquals(a, b, stack, options, visited);
if (!result && !stack.isEmpty()) {
// Store the breadcrumb difference string
ItemsToCompare top = stack.peek();
String breadcrumb = generateBreadcrumb(stack);
((Map) options).put(DIFF, breadcrumb);
// Optionally store the ItemsToCompare object (can retain large graphs)
// Only include if explicitly requested to avoid memory retention
Boolean includeDiffItem = (Boolean) options.get(INCLUDE_DIFF_ITEM);
if (includeDiffItem != null && includeDiffItem) {
((Map) options).put(DIFF_ITEM, top);
}
}
return result;
}
// Heap-based deepEquals implementation
private static boolean deepEquals(Object a, Object b, Deque stack,
Map options, Set visited) {
final Collection> ignoreCustomEquals = (options != null)
? (Collection>) options.get(IGNORE_CUSTOM_EQUALS) : null;
final boolean allowAllCustomEquals = ignoreCustomEquals == null;
final boolean hasNonEmptyIgnoreSet = (ignoreCustomEquals != null && !ignoreCustomEquals.isEmpty());
final boolean allowStringsToMatchNumbers = (options != null) && convert2boolean(options.get(ALLOW_STRINGS_TO_MATCH_NUMBERS));
stack.addFirst(new ItemsToCompare(a, b));
// Read max depth from ThreadLocal stack (set by entry point)
final Deque depthStack = maxDepthBudgetStack.get();
final Integer maxRecursionDepth = (depthStack != null && !depthStack.isEmpty()) ? depthStack.peek() : Integer.MAX_VALUE;
// Hoist size limits once at the start to avoid repeated system property reads
final int maxCollectionSize = getMaxCollectionSize();
final int maxArraySize = getMaxArraySize();
final int maxMapSize = getMaxMapSize();
final int maxObjectFields = getMaxObjectFields();
while (!stack.isEmpty()) {
ItemsToCompare itemsToCompare = stack.removeFirst();
// Skip if already visited
if (!visited.add(itemsToCompare)) {
continue;
}
// Security check: prevent excessive recursion depth (heap-based depth tracking, read from ThreadLocal)
if (maxRecursionDepth != Integer.MAX_VALUE && itemsToCompare.depth > maxRecursionDepth) {
throw new SecurityException("Maximum recursion depth exceeded: " + itemsToCompare.depth + " > " + maxRecursionDepth);
}
final Object key1 = itemsToCompare._key1;
final Object key2 = itemsToCompare._key2;
// Same instance is always equal to itself, null or otherwise.
if (key1 == key2) {
continue;
}
// If either one is null, they are not equal (key1 == key2 already checked)
if (key1 == null || key2 == null) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.VALUE_MISMATCH));
return false;
}
// Handle all numeric comparisons first
if (key1 instanceof Number && key2 instanceof Number) {
if (!compareNumbers((Number) key1, (Number) key2)) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.VALUE_MISMATCH));
return false;
}
continue;
}
// Handle String-to-Number comparison if option is enabled
if (allowStringsToMatchNumbers &&
((key1 instanceof String && key2 instanceof Number) ||
(key1 instanceof Number && key2 instanceof String))) {
try {
if (key1 instanceof String) {
if (compareNumbers(convert2BigDecimal(key1), (Number) key2)) {
continue;
}
} else {
if (compareNumbers((Number) key1, convert2BigDecimal(key2))) {
continue;
}
}
} catch (Exception ignore) { }
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.VALUE_MISMATCH));
return false;
}
if (key1 instanceof AtomicBoolean && key2 instanceof AtomicBoolean) {
if (!compareAtomicBoolean((AtomicBoolean) key1, (AtomicBoolean) key2)) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.VALUE_MISMATCH));
return false;
} else {
continue;
}
}
final Class> key1Class = key1.getClass();
final Class> key2Class = key2.getClass();
// Handle Enums - they are singletons, use reference equality
if (key1Class.isEnum() && key2Class.isEnum()) {
if (key1 != key2) { // Enum comparison is always == (same as Enum.equals)
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.VALUE_MISMATCH));
return false;
}
continue;
}
// Handle primitive wrappers, String, Date, Class, UUID, URL, URI, Temporal classes, etc.
if (Converter.isSimpleTypeConversionSupported(key1Class)) {
if (key1 instanceof Comparable> && key2 instanceof Comparable>) {
try {
if (((Comparable)key1).compareTo(key2) != 0) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.VALUE_MISMATCH));
return false;
}
continue;
} catch (Exception ignored) { } // Fall back to equals() if compareTo() fails
}
if (!key1.equals(key2)) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.VALUE_MISMATCH));
return false;
}
continue;
}
// List and Deque interfaces define order as required as part of equality
// Both represent ordered sequences that allow duplicates, so they can be compared
boolean key1Ordered = key1 instanceof List || key1 instanceof Deque;
boolean key2Ordered = key2 instanceof List || key2 instanceof Deque;
if (key1Ordered && key2Ordered) {
// Both are ordered collections - compare with order
if (!decomposeOrderedCollection((Collection>) key1, (Collection>) key2, stack, itemsToCompare, maxCollectionSize)) {
// Push VALUE_MISMATCH so parent's container-level description (e.g. "collection size mismatch")
// takes precedence over element-level differences
ItemsToCompare prior = stack.peek();
if (prior != null) {
stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH));
}
return false;
}
continue;
} else if (key1Ordered || key2Ordered) {
// One is ordered (List/Deque), the other is not
// Check if the non-ordered one is still a Collection
if (key1 instanceof Collection && key2 instanceof Collection) {
// Both are collections but different categories
// Check if the non-ordered one is a Set (which has specific equality semantics)
boolean key1IsSet = key1 instanceof Set;
boolean key2IsSet = key2 instanceof Set;
if (key1IsSet || key2IsSet) {
// Set vs List/Deque is a type mismatch - they have incompatible equality semantics
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
return false;
}
// Neither is a Set, so we have a plain Collection vs List/Deque
// This can happen with Collections.unmodifiableCollection() wrapping a List
// Compare as unordered collections (fall through)
} else {
// One is an ordered collection, the other is not a collection at all
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
return false;
}
}
// Unordered Collection comparison
if (key1 instanceof Collection) {
if (!(key2 instanceof Collection)) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.COLLECTION_TYPE_MISMATCH));
return false;
}
if (!decomposeUnorderedCollection((Collection>) key1, (Collection>) key2,
stack, options, visited, itemsToCompare, maxCollectionSize)) {
// Push VALUE_MISMATCH so parent's container-level description (e.g. "collection size mismatch")
// takes precedence over element-level differences
ItemsToCompare prior = stack.peek();
if (prior != null) {
stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH));
}
return false;
}
continue;
} else if (key2 instanceof Collection) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.COLLECTION_TYPE_MISMATCH));
return false;
}
// Map comparison
if (key1 instanceof Map) {
if (!(key2 instanceof Map)) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
return false;
}
if (!decomposeMap((Map, ?>) key1, (Map, ?>) key2, stack, options, visited, itemsToCompare, maxMapSize)) {
// Push VALUE_MISMATCH so parent's container-level description (e.g. "map value mismatch")
// takes precedence over element-level differences
ItemsToCompare prior = stack.peek();
if (prior != null) {
stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH));
}
return false;
}
continue;
} else if (key2 instanceof Map) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
return false;
}
// Array comparison
if (key1Class.isArray()) {
if (!key2Class.isArray()) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
return false;
}
if (!decomposeArray(key1, key2, stack, itemsToCompare, maxArraySize)) {
// Push VALUE_MISMATCH so parent's container-level description (e.g. "array element mismatch")
// takes precedence over element-level differences
ItemsToCompare prior = stack.peek();
if (prior != null) {
stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH));
}
return false;
}
continue;
} else if (key2Class.isArray()) {
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
return false;
}
// Must be same class if not a container type
if (!key1Class.equals(key2Class)) { // Must be same class
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
return false;
}
// Special handling for Records (Java 14+) - use record components instead of fields
if (ReflectionUtils.isRecord(key1Class)) {
if (!decomposeRecord(key1, key2, stack, itemsToCompare)) {
return false;
}
continue;
}
// If there is a custom equals and not ignored, compare using custom equals
if (hasCustomEquals(key1Class)) {
boolean useCustomEqualsForThisClass = hasNonEmptyIgnoreSet && !ignoreCustomEquals.contains(key1Class);
if (allowAllCustomEquals || useCustomEqualsForThisClass) {
// No Field-by-field break down
if (!key1.equals(key2)) {
// Custom equals failed. Call "deepEquals()" below on failure of custom equals() above.
// This gets us the "detail" on WHY the custom equals failed (first issue).
Map newOptions = new HashMap<>(options);
newOptions.put("recursive_call", true);
// Create new ignore set preserving existing ignored classes
Set> ignoreSet = new HashSet<>();
if (ignoreCustomEquals != null) {
ignoreSet.addAll(ignoreCustomEquals);
}
ignoreSet.add(key1Class);
newOptions.put(IGNORE_CUSTOM_EQUALS, ignoreSet);
// Pass remaining depth budget to recursive call to prevent unbounded recursion
// Calculate remaining budget: maxRecursionDepth - current depth
if (maxRecursionDepth != Integer.MAX_VALUE) {
int remainingBudget = maxRecursionDepth - itemsToCompare.depth;
if (remainingBudget > 0) {
newOptions.put(DEPTH_BUDGET, remainingBudget);
} else {
// No budget left, skip recursive call
return false;
}
}
// Make recursive call to find the actual difference
newOptions.put(INCLUDE_DIFF_ITEM, true); // Need diff_item for internal use
deepEquals(key1, key2, newOptions);
// Get the difference and add it to our stack
ItemsToCompare diff = (ItemsToCompare) newOptions.get(DIFF_ITEM);
if (diff != null) {
stack.addFirst(diff);
}
return false;
}
continue;
}
}
// Decompose object into its fields (not using custom equals)
decomposeObject(key1, key2, stack, itemsToCompare, maxObjectFields);
}
return true;
}
/**
* Calculate safe HashMap initial capacity to prevent integer overflow.
* HashMap uses capacity = size * 4/3 to account for 0.75 load factor.
* For large sizes approaching Integer.MAX_VALUE, this can overflow.
*
* @param size the expected number of elements
* @return safe initial capacity, capped at Integer.MAX_VALUE
*/
private static int safeHashMapCapacity(int size) {
if (size < 0) {
return 16; // Default capacity for invalid sizes
}
// Calculate capacity = size * 4 / 3, but check for overflow
// Max safe size before overflow: Integer.MAX_VALUE * 3 / 4 = 1,610,612,735
if (size > 1_610_612_735) {
return Integer.MAX_VALUE; // Cap at max to prevent overflow
}
// Safe to multiply: size * 4 won't overflow since size <= 1,610,612,735
long capacity = (long) size * 4 / 3;
// Ensure minimum capacity of 16
return Math.max(16, (int) capacity);
}
private static boolean decomposeRecord(Object rec1, Object rec2, Deque stack, ItemsToCompare currentItem) {
// Get record components using reflection (Java 14+ feature)
Object[] components = ReflectionUtils.getRecordComponents(rec1.getClass());
if (components == null) {
// Fallback to regular object decomposition if record components unavailable
return decomposeObject(rec1, rec2, stack, currentItem, Integer.MAX_VALUE);
}
// Compare each record component
for (Object component : components) {
String componentName = ReflectionUtils.getRecordComponentName(component);
if (componentName == null) {
// Skip components we can't access (reflection failure)
continue;
}
Object value1 = ReflectionUtils.getRecordComponentValue(component, rec1);
Object value2 = ReflectionUtils.getRecordComponentValue(component, rec2);
// Push component values for comparison with proper naming
stack.addFirst(new ItemsToCompare(value1, value2, componentName, currentItem, Difference.FIELD_VALUE_MISMATCH));
}
return true;
}
// Create child options for nested comparisons, preserving semantics and
// strictly *narrowing* any inherited depth budget.
/**
* Create child options for nested comparisons.
*
* OPTIMIZATION: Since DEPTH_BUDGET is now tracked via ThreadLocal instead of being passed
* through options, the options map is stable and can be reused in most cases. This eliminates
* the need to allocate a new HashMap on every collection/map comparison.
*
* We only create a new map if the parent contains "output" keys (DIFF, DIFF_ITEM) that
* shouldn't propagate to child comparisons.
*/
private static Map sanitizedChildOptions(Map options, ItemsToCompare currentItem) {
if (options == null) {
return Collections.emptyMap(); // Shared empty map
}
// Check if parent only contains "input" keys that should propagate
// (ALLOW_STRINGS_TO_MATCH_NUMBERS, IGNORE_CUSTOM_EQUALS, and legacy DEPTH_BUDGET)
boolean hasOnlyInputKeys = true;
for (String key : options.keySet()) {
if (!ALLOW_STRINGS_TO_MATCH_NUMBERS.equals(key) &&
!IGNORE_CUSTOM_EQUALS.equals(key) &&
!DEPTH_BUDGET.equals(key)) { // DEPTH_BUDGET may exist from user input (ignored now)
hasOnlyInputKeys = false;
break;
}
}
if (hasOnlyInputKeys) {
// Parent map is clean, reuse it! (Massive memory savings)
return (Map) options;
}
// Parent has extra keys (DIFF, DIFF_ITEM, etc.), create clean copy
Map child = new HashMap<>(3);
Object allow = options.get(ALLOW_STRINGS_TO_MATCH_NUMBERS);
if (allow != null) {
child.put(ALLOW_STRINGS_TO_MATCH_NUMBERS, allow);
}
Object ignore = options.get(IGNORE_CUSTOM_EQUALS);
if (ignore != null) {
child.put(IGNORE_CUSTOM_EQUALS, ignore);
}
// Note: DEPTH_BUDGET is now tracked via ThreadLocal, not copied through options
// Intentionally do NOT copy DIFF, "diff_item", "recursive_call", etc.
return child;
}
/**
* Compares two unordered collections (e.g., Sets) deeply.
*
* @param col1 First collection.
* @param col2 Second collection.
* @param stack Comparison stack.
* @param options Comparison options.
* @param visited Visited set used for cycle detection.
* @return true if collections are equal, false otherwise.
*/
private static boolean decomposeUnorderedCollection(Collection> col1, Collection> col2,
Deque stack, Map options,
Set visited, ItemsToCompare currentItem, int maxCollectionSize) {
// Security check: validate collection sizes
if (maxCollectionSize > 0 && (col1.size() > maxCollectionSize || col2.size() > maxCollectionSize)) {
throw new SecurityException("Collection size exceeds maximum allowed: " + maxCollectionSize);
}
// Check sizes first
if (col1.size() != col2.size()) {
stack.addFirst(new ItemsToCompare(col1, col2, currentItem, Difference.COLLECTION_SIZE_MISMATCH));
return false;
}
// Group col2 items by hash for efficient lookup (with slow-path fallback)
// Pre-size to avoid rehashing: capacity = size * 4/3 to account for 0.75 load factor
Map> hashGroups = new HashMap<>(safeHashMapCapacity(col2.size()));
for (Object o : col2) {
int hash = deepHashCode(o);
hashGroups.computeIfAbsent(hash, k -> new ArrayList<>()).add(o);
}
final Map childOptions = sanitizedChildOptions(options, currentItem);
// Find first item in col1 not found in col2
for (Object item1 : col1) {
int hash1 = deepHashCode(item1);
List