Java Numeric Types
Purpose
Pick a numeric representation that can hold the values the domain actually has, and keep it
correct through arithmetic, comparison and every boundary it crosses. Two failure modes: the
exact decimal value held in double, where error can cross a rounding or reconciliation
boundary after repeated operations; and the boxed primitive whose ==, null and allocation
behaviour differ from the primitive it looks like.
Workflow
- Inspect compatibility and contracts. Read compiler release/toolchains, serializer and
JDBC/database versions, schema constraints and consumer numeric types. The references use
Java 17-compatible code (records and
Stream.toList()need Java 16+,RandomGeneratorJava 17+). Preserve the target; use existing classes/collectors rather than upgrading. If range, rounding or boundary policy is absent, identify the missing decision before changing persisted values or a public JSON representation. - Classify the quantity. Exact decimal (money, tax, decimal contractual units) →
BigDecimalor integral minor units. Counting/identity →int/long. Physical measurement or statistics where bounded floating-point error is acceptable →double. Never decide by what the JSON happens to contain. - Fix precision, scale and rounding policy with the domain type, not ad hoc at call sites.
Any operation that can be inexact needs a specified
RoundingModeand either result scale orMathContext; exact-only operations may deliberately throw. - Bound the range. Check whether any product, sum or difference can exceed the type — ids, byte counts, milliseconds, accumulators — and use exact arithmetic where it can.
- Choose primitive or boxed deliberately. Primitive unless absence is meaningful or a generic/collection requires the box.
- Check the boundaries. Database column type and precision, JSON representation, the
consumer's own numeric limits. A
longabove JavaScript's exact integer range is not safe as a browser JSON number. - Verify with adversarial values:
0.1 + 0.2,Integer.MAX_VALUE + 1, a nullInteger,1.0versus1.00, a negative operand to%,NaNin a comparator.
Rules
- Do not use
floatordoublewhere decimal identity or exact conservation is required. They are binary floating point:0.1has no exact representation, and repeated rounding error can accumulate or cancel depending on the algorithm. UseBigDecimalor integral units with a domain-defined scale. Use floating point when its range, throughput and error model fit—and specify tolerances and treatment of NaN/infinity/signed zero. - Construct a decimal received as text directly from that text; routing it through
doublealready loses information.BigDecimal.valueOf(double)preserves the double's canonical decimal rendering and is usually the right conversion when a double is the actual source.new BigDecimal(double)deliberately captures the exact binary floating-point value (new BigDecimal(0.1)is0.1000000000000000055511151231257827…), which then propagates through subsequent operations; use it only when that exact binary value is the intended data. divide(divisor)throwsArithmeticExceptionwhen the exact quotient has a non-terminating decimal expansion—including1/3. This can be a useful exactness assertion. Otherwise choose an overload with an explicit result scale and rounding mode, or a domainMathContextwhen significant-digit precision is the policy. Never invent a default: contractual and regulatory rules decide where and how rounding occurs.BigDecimal.equalscompares value and scale, so1.0does not equal1.00. Compare numerically withcompareTo(other) == 0, and never putBigDecimalin aHashSetor use it as a map key expecting numeric identity.TreeSetusescompareToand will silently treat them as one element — see java-object-contracts.- Normalize to a fixed scale only when defined by the domain/ledger contract—not
blindly to
Currency.getDefaultFractionDigits(), which is an ISO default and returns-1for pseudocurrencies. UsesetScale(domainScale, roundingMode), notstripTrailingZerosfor fixed-scale money. For scale-insensitive non-money identity, canonicalization may be appropriate.stripTrailingZeros().toString()can produce6E+2;toPlainString()avoids exponents but requires bounded precision/scale for untrusted values because expansion can be huge. Choose the wire format explicitly. - Integer arithmetic wraps silently. Use
Math.addExact,subtractExact,multiplyExact,incrementExactandtoIntExactwherever an overflow would be a defect rather than a wrap — id arithmetic, sizes, durations in millis, accumulators.(low + high) / 2in a binary search can overflow;low + ((high - low) >>> 1)is safe for ordered nonnegative array bounds. IntegerMIN_VALUE / -1also overflows silently; reject that pair when exact division is required, and range-check narrowing casts before they discard bits. %with a negative dividend can yield a negative remainder (exact multiples yield zero), which breaks the standard "hash into a bucket" idiom. With a positive bucket count, useMath.floorMod(x, n)(and understandfloorDiv) when the operand can be negative—a partition index computed from a hash is the case that reaches production. Zero divisors still fail, and a negative divisor changes the result range.- Do not use
==for boxed numeric value equality. Boxing of certain constant expressions in the JLS guarantees identity in the-128..127range; HotSpot may cache more (forInteger, implementation flags can affect it), while separately created boxes need not be identical. Use null-safeequalsor deliberately unbox after proving non-null. - An unboxing operation on a
nullbox throwsNullPointerExceptionat a place with no visible dereference:map.get(key) > 0,int total = nullableInteger, a ternary mixingIntegerandint. Where a value may be absent, keep it boxed and check, or model the absence explicitly — see java-null-safety. - Prefer primitives when absence/object identity is not part of the model; use boxed types when a collection, a generic type parameter, or a
nullable column requires them. In bulk paths, boxes that escape caches/JIT elimination can
materialize one object per value: use
IntStream/LongStream,int[],IntFunctionand friends rather thanStream<Integer>andList<Integer>— and confirm with allocation-profiling before restructuring code that is not hot. - Mixing a boxed and a primitive operand auto-unboxes the box, so
Integer.equalssemantics and==semantics can both apply in the same expression depending on the other operand's type. Make the conversion explicit rather than relying on the reader to apply the rules. - NaN makes primitive equality/order surprising:
NaN != NaN, and</>are false.Double.comparesupplies the total order used by Java comparators, including signed zero; choose deliberately whether that representation order matches domain equality. Reject NaN and infinity at ingress when the domain forbids them. - Do not use
doublefor time arithmetic and do not do date arithmetic in millis.Instant,DurationandPeriodexist;System.nanoTime()is monotonic and meaningful only as a difference,System.currentTimeMillis()is wall-clock and can jump backwards. - For random numbers, use
ThreadLocalRandomfor independent non-secure concurrent draws,RandomGenerator(Java 17+) when algorithm/splitting/jump semantics matter, andSecureRandomfor security-bearing tokens, nonces and unguessable ids. Do not make performance or reproducibility claims aboutMath.random()without measuring the target JDK, and never use it for security. - Numbers change meaning at boundaries. JavaScript Number cannot distinguish every integer
outside
[-(2^53-1), 2^53-1]; some larger integers remain exactly representable. Use a string contract for large ids/exact decimals when binary-number consumers must preserve them, or an explicitly verified lossless parser contract. Treat a number-to-string API change as a compatibility migration. In the database, useDECIMAL/NUMERICwith an explicit precision for money—notFLOAT/REAL—and make Java's scale/rounding policy compatible with the column and driver behaviour.
Deliverable
State the representation, valid range, equality and rounding stages, boundary encoding and behavior on overflow/absence/non-finite input. Show the failing input and checks actually run; separate arithmetic tests from serializer/database round trips and measured allocation evidence.
Diagnostic map
| Symptom | Distinguish with | Likely direction |
|---|---|---|
| totals differ by cents across paths/services | capture unrounded operands, scale and rounding stage at every boundary | centralize the contractual rounding/allocation policy; replay the same inputs |
ArithmeticException in decimal arithmetic |
separate divide-by-zero, non-terminating quotient and UNNECESSARY loss |
fix invalid input or select the specified scale/precision and rounding policy |
| map/set cannot find a visually equal decimal | log toPlainString(), scale(), class and collection kind |
normalize in a value type or use equality/order consistent with the requirement |
| negative bucket/index only for some hashes | reproduce MIN_VALUE, negative operands and positive divisor |
use floorMod; remove abs(x) % n |
| id changes only in JavaScript/browser clients | compare original digits and test values around 2^53 | use a string contract end-to-end |
| high allocation rate in an arithmetic/bulk pipeline | profile allocation sites and escaped boxes/BigDecimal operations |
specialize representation only after correctness and benchmark validation |
References
- Decimals, money and rounding — read when modelling a
monetary or exact-decimal value, when choosing between
BigDecimaland minor units, when rounding or allocation of a total across parts is involved, or when decimals cross a database or an API. - Integers, boxing and overflow — read when
choosing between primitive and boxed types, when arithmetic could overflow, when
==ornullbehaviour on boxed values is in question, or when boxing shows up in an allocation profile.