use crate::report::format::{fmt_count, format_bytes};
use crate::report::model::{Report, TriageSeverity, TriageSignal};
const CONCENTRATION_PCT: f64 = 50.0;
const DBB_FLOOR_BYTES: u64 = 64 * 1024 * 1024;
const GC_WASTE_RATIO: f64 = 0.10;
const THREAD_PIN_PCT: f64 = 20.0;
const THREAD_PIN_LOCALS: u64 = 100;
const THREAD_PIN_LOCALS_MIN_PCT: f64 = 10.0;
const GC_ROOT_DOMINANT_PCT: f64 = 50.0;
const PROXY_BLOAT_PCT: f64 = 50.0;
const PROXY_MIN_CLASSES: u64 = 200;
const WEAKREF_FLOOR: u64 = 1000;
const WEAKREF_BYTES_FLOOR: u64 = 5 * 1024 * 1024; const OVERCAP_WASTE_PCT: f64 = 5.0;
const CONSTARR_FLOOR: u64 = 8 * 1024 * 1024;
const OVERCAP_FILL_BP: u32 = 5000;
const DUP_STRINGS_FLOOR_BYTES: u64 = 16 * 1024 * 1024;
const DUP_STRINGS_PCT: f64 = 5.0;
const CHAR_SLACK_FLOOR_BYTES: u64 = 16 * 1024 * 1024;
const CHAR_SLACK_MIN_ARRAYS: u64 = 1000;
const BOXED_FLOOR_INSTANCES: u64 = 5_000_000;
const BOXED_PCT: f64 = 5.0;
const UNBOUNDED_COLL_ELEMENTS: u64 = 1_000_000;
const UNBOUNDED_COLL_PCT: f64 = 20.0;
const SWARM_FLOOR_INSTANCES: u64 = 10_000_000;
const SWARM_PCT: f64 = 10.0;
const SWARM_MAX_INSTANCE_BYTES: u64 = 64;
const CLASSLOADER_EXPLOSION_FLOOR: u64 = 1000;
const THREAD_SWARM_FLOOR: usize = 1000;
const FINALIZER_FLOOR: u64 = 10_000;
const METASPACE_CLASS_FLOOR: u64 = 50_000;
const REFLECT_FLOOR: u64 = 500_000;
const JNI_GLOBAL_FLOOR: u64 = 5_000;
const JNI_GLOBAL_RETAINED_PCT: f64 = 5.0;
const HEAP_SKEW_PCT: f64 = 70.0;
const STATIC_ANCHOR_PCT: f64 = 20.0;
const SESSION_FLOOR: u64 = 100_000;
const CONNECTION_FLOOR: u64 = 1_000;
const LISTENER_FLOOR: u64 = 100_000;
const PARSER_FLOOR: u64 = 100_000;
const INTERNED_STRING_FLOOR: u64 = 2_000_000;
const INTERNED_JNI_FLOOR: u64 = 1_000;
const SPARSE_ARRAY_FILL_BP: u32 = 2_000; const SPARSE_ARRAY_MIN_TRACKED: u64 = 10_000;
const SPARSE_ARRAY_WASTED_PCT: f64 = 5.0;
const BIG_DROP_PCT: f64 = 5.0;
const BIG_DROP_FLOOR: u64 = 64 * 1024 * 1024;
const HEADER_OVERHEAD_PCT: f64 = 20.0;
const COLLISION_HIGH_BP: u32 = 9_000; const COLLISION_MIN_TRACKED: u64 = 100;
const EMPTY_COLL_SHARE_PCT: f64 = 60.0;
const EMPTY_COLL_FLOOR: u64 = 500_000;
const OVERSIZED_PRIM_ARRAY_PCT: f64 = 5.0;
const OVERSIZED_PRIM_ARRAY_FLOOR: u64 = 64 * 1024 * 1024;
const DUP_PRIM_ARRAYS_PCT: f64 = 5.0;
const DUP_PRIM_ARRAYS_FLOOR: u64 = 16 * 1024 * 1024;
pub trait Rule {
fn eval(&self, r: &Report) -> Option<TriageSignal>;
}
fn rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(HeadlineRetainer),
Box::new(Concentration),
Box::new(DominantGcRootType),
Box::new(Shape),
Box::new(OneLeakOrMany),
Box::new(ObjectSwarm),
Box::new(BoxedPrimitiveBloat),
Box::new(ClassloaderLeak),
Box::new(ClassloaderExplosion),
Box::new(MetaspacePressure),
Box::new(ThreadLocalLeak),
Box::new(ThreadPinning),
Box::new(ThreadSwarm),
Box::new(WeakRefEscape),
Box::new(ProxyLambdaBloat),
Box::new(OffHeap),
Box::new(GcWaste),
Box::new(StaticFieldAnchor),
Box::new(JniGlobalRefLeak),
Box::new(HeapCompositionSkew),
Box::new(FinalizerQueueBacklog),
Box::new(CachedReflectionMetadata),
Box::new(SessionScopeLeak),
Box::new(ConnectionLeak),
Box::new(EventListenerAccumulation),
Box::new(ParserOutputAccumulation),
Box::new(InternedStringBloat),
Box::new(DuplicateStrings),
Box::new(CharArraySlack),
Box::new(OverCapacityCollections),
Box::new(LargeUnboundedCollection),
Box::new(SparseObjectArrays),
Box::new(ConstantValueArrays),
Box::new(BigDropConcentration),
Box::new(FixedPerObjectOverhead),
Box::new(HashCollisionHotspot),
Box::new(EmptyCollectionCemetery),
Box::new(OversizedPrimArray),
Box::new(DuplicatePrimArrays),
]
}
pub fn evaluate_triage(r: &Report) -> Vec<TriageSignal> {
let mut signals: Vec<TriageSignal> = rules().iter().filter_map(|rule| rule.eval(r)).collect();
if r.collection_attribution.is_none() {
signals.push(signal(
"collections-not-analyzed",
TriageSeverity::Info,
"Collection Waste Not Analyzed",
"Collection waste not analyzed — re-run with `--collections` to check for wasted capacity."
.to_string(),
None,
));
}
signals
}
fn pct_of(retained: u64, total: u64) -> f64 {
if total > 0 {
retained as f64 / total as f64 * 100.0
} else {
0.0
}
}
fn signal(
id: &str,
severity: TriageSeverity,
title: &str,
detail: String,
anchor: Option<(&str, &str)>,
) -> TriageSignal {
let (anchor, anchor_label) = match anchor {
Some((a, l)) => (Some(a.to_string()), Some(l.to_string())),
None => (None, None),
};
TriageSignal {
id: id.to_string(),
severity,
title: title.to_string(),
detail,
anchor,
anchor_label,
bytes: None,
nav_class: None,
}
}
fn signal_cls(
id: &str,
severity: TriageSeverity,
title: &str,
detail: String,
anchor: Option<(&str, &str)>,
nav_class: impl Into<String>,
) -> TriageSignal {
let mut s = signal(id, severity, title, detail, anchor);
s.nav_class = Some(nav_class.into());
s
}
struct HeadlineRetainer;
impl Rule for HeadlineRetainer {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.leaks.total_shallow;
if let Some(s) = r.leaks.suspects.first() {
let kind = if s.is_single {
"a single object"
} else {
"a class group"
};
Some(signal_cls(
"headline-retainer",
TriageSeverity::Critical,
"Headline Retainer",
format!(
"`{}` ({}) retains {} ({:.1}% of reachable heap).",
s.pretty_class,
kind,
format_bytes(s.retained),
pct_of(s.retained, total),
),
Some(("leak-suspects", "Leak Suspects")),
&s.pretty_class,
))
} else if let Some(o) = r.top.biggest_objects.first() {
Some(signal_cls(
"headline-retainer",
TriageSeverity::Warning,
"Headline Retainer",
format!(
"`{}` retains {} ({:.1}% of reachable heap).",
o.display_class,
format_bytes(o.retained),
pct_of(o.retained, total),
),
Some(("top-consumers", "Top Consumers")),
&o.display_class,
))
} else {
Some(signal(
"headline-retainer",
TriageSeverity::Info,
"Headline Retainer",
"No dominant retainer found.".to_string(),
None,
))
}
}
}
struct Concentration;
impl Rule for Concentration {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.leaks.total_shallow;
let sig = match r.leaks.suspects.first() {
Some(s) if pct_of(s.retained, total) >= CONCENTRATION_PCT => {
let kind = if s.is_single {
"a single object".to_string()
} else {
format!("a class group of {} instances", s.instance_count)
};
let owner = if s.is_single {
r.top.biggest_objects.first().and_then(|o| {
if o.display_class == s.pretty_class {
o.owner.as_deref()
} else {
None
}
})
} else {
None
};
let held_by = match owner {
Some(o) => format!(" held by `{o}`"),
None => String::new(),
};
signal_cls(
"concentration",
TriageSeverity::Critical,
"Concentration",
format!(
"highly concentrated — `{}` ({}){} holds {:.1}% of the heap; freeing this object would reclaim most of the heap.",
s.pretty_class,
kind,
held_by,
pct_of(s.retained, total),
),
Some(("leak-suspects", "Leak Suspects")),
&s.pretty_class,
)
}
Some(_) => signal(
"concentration",
TriageSeverity::Info,
"Concentration",
"diffuse — no suspect exceeds the threshold; retention is spread across multiple roots. Inspect individual suspects to find the most impactful target.".to_string(),
Some(("leak-suspects", "Leak Suspects")),
),
None => signal(
"concentration",
TriageSeverity::Info,
"Concentration",
"diffuse — no dominant retainer found; retention is spread evenly across many roots.".to_string(),
None,
),
};
Some(sig)
}
}
struct DominantGcRootType;
impl Rule for DominantGcRootType {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.leaks.total_shallow;
let top = r.overview.gc_roots_retained_by_type.first()?;
let pct = pct_of(top.retained, total);
if pct < GC_ROOT_DOMINANT_PCT {
return None;
}
Some(signal(
"gc-root-type",
TriageSeverity::Warning,
"Dominant GC-Root Type",
format!(
"{:.1}% of the heap is held by \"{}\" roots — the GC Roots by Type table shows the per-class breakdown.",
pct, top.root_type,
),
Some(("system-overview", "System Overview")),
))
}
}
struct Shape;
impl Rule for Shape {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let hist = &r.overview.dominator_depth_histogram;
if hist.is_empty() {
return None;
}
let total: u64 = hist.iter().map(|b| b.objects).sum();
let max_depth = hist.iter().map(|b| b.depth).max().unwrap_or(0);
let mut cum = 0u64;
let mut p90 = max_depth;
for b in hist {
cum += b.objects;
if cum * 10 >= total * 9 {
p90 = b.depth;
break;
}
}
let shape = if p90 <= 3 {
"shallow (most objects are held within a few hops of a GC root)"
} else {
"deep — long dominator chains suggest nested collections or linked structures; the depth histogram shows the distribution; use the Big Drops table to find the retaining objects"
};
Some(signal(
"shape",
TriageSeverity::Info,
"Heap Shape",
format!("{shape} — 90% of objects within depth {p90}, max depth {max_depth}."),
Some((
"dominator-depth-distribution",
"Dominator-Depth Distribution",
)),
))
}
}
struct OneLeakOrMany;
impl Rule for OneLeakOrMany {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let rc = &r.overview.retention_concentration;
if rc.top1_bp == 0 && rc.num_objects_ge_1pct == 0 {
return None;
}
let top1_pct = rc.top1_bp as f64 / 100.0;
let top10_pct = rc.top10_bp as f64 / 100.0;
let top_obj = r.top.biggest_objects.first();
let detail = match top_obj.map(|o| match o.owner.as_deref() {
Some(owner) => format!("`{}` (held by `{}`)", o.display_class, owner),
None => format!("`{}`", o.display_class),
}) {
Some(name) => format!(
"the single biggest object, {}, retains {:.1}% and the top 10 retain {:.1}% of the heap; {} objects each hold ≥1%.",
name, top1_pct, top10_pct, rc.num_objects_ge_1pct,
),
None => format!(
"the single biggest object retains {:.1}% and the top 10 retain {:.1}% of the heap; {} objects each hold ≥1%.",
top1_pct, top10_pct, rc.num_objects_ge_1pct,
),
};
let nav_class = top_obj
.filter(|o| o.owner.is_none())
.map(|o| o.display_class.clone());
let mut sig = signal(
"one-leak-or-many",
TriageSeverity::Info,
"One Leak or Many",
detail,
Some(("top-consumers", "Top Consumers")),
);
sig.nav_class = nav_class;
Some(sig)
}
}
struct ClassloaderLeak;
impl Rule for ClassloaderLeak {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let dup = r
.overview
.duplicate_classes
.iter()
.max_by_key(|d| d.total_retained)?;
if dup.total_retained < 524_288 {
return None;
}
if dup.loader_count < 5 {
return Some(signal_cls(
"classloader-leak",
TriageSeverity::Info,
"Class-Loader Reload (Low Count)",
format!(
"`{}` is loaded by {} class loaders ({} retained) — possible reload, but count is low; investigate only if count grows.",
dup.pretty_class,
dup.loader_count,
format_bytes(dup.total_retained),
),
Some(("duplicate-classes", "Duplicate Classes")),
&dup.pretty_class,
));
}
Some(signal_cls(
"classloader-leak",
TriageSeverity::Warning,
"Class-Loader Leak",
format!(
"`{}` is loaded by {} class loaders ({} retained) — classic redeploy/hot-reload leak; the old loader is still live. Check for static fields, ThreadLocals, or JNI globals referencing the old class.",
dup.pretty_class,
dup.loader_count,
format_bytes(dup.total_retained),
),
Some(("duplicate-classes", "Duplicate Classes")),
&dup.pretty_class,
))
}
}
struct ThreadLocalLeak;
impl Rule for ThreadLocalLeak {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let n = r.leak_indicators.thread_local_null_key_count;
if n == 0 {
return None;
}
Some(signal(
"threadlocal-leak",
TriageSeverity::Warning,
"ThreadLocal Leak",
format!(
"{} ThreadLocalMap entries have a cleared key — the `ThreadLocal` object was GC'd but the value was never removed. Values accumulate until the thread terminates or `ThreadLocal.remove()` is called. Common in thread-pooled servers.",
fmt_count(n),
),
Some(("leak-indicators", "Leak Indicators")),
))
}
}
struct ThreadPinning;
impl Rule for ThreadPinning {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.leaks.total_shallow;
let t = r.threads.threads.iter().max_by_key(|t| t.retained)?;
let share = pct_of(t.retained, total);
if share < THREAD_PIN_PCT
&& !(t.local_root_count >= THREAD_PIN_LOCALS && share >= THREAD_PIN_LOCALS_MIN_PCT)
{
return None;
}
let who = t
.name
.as_deref()
.or(t.class_name.as_deref())
.unwrap_or("<unknown thread>");
Some(signal(
"thread-pinning",
TriageSeverity::Warning,
"Thread Pinning",
format!(
"thread `{}` retains {} ({:.1}% of heap) via {} thread-local GC root references — a running thread is pinning a disproportionate share of the heap. Inspect the thread's stack frames and ThreadLocal values.",
who,
format_bytes(t.retained),
share,
fmt_count(t.local_root_count),
),
Some(("threads", "Threads")),
))
}
}
struct WeakRefEscape;
impl Rule for WeakRefEscape {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let refs = &r.references;
let only_weak_objects: u64 = [&refs.soft, &refs.weak, &refs.phantom]
.into_iter()
.flatten()
.flat_map(|s| s.only_weakly_retained.iter())
.map(|row| row.objects)
.sum();
let only_weak_retained: u64 = [&refs.soft, &refs.weak, &refs.phantom]
.into_iter()
.flatten()
.flat_map(|s| s.only_weakly_retained.iter())
.map(|row| row.retained)
.sum();
if only_weak_objects < WEAKREF_FLOOR && only_weak_retained < WEAKREF_BYTES_FLOOR {
return None;
}
Some(signal(
"weak-ref-escape",
TriageSeverity::Info,
"Only-Weakly Retained Objects",
format!(
"{} objects only weakly, softly, or phantom-retained, totaling {} — no strong path keeps them alive; GC will reclaim weak referents at the next collection and soft referents under memory pressure. If the count is unexpectedly high, check that no strong reference is silently held alongside the weak one.",
fmt_count(only_weak_objects),
format_bytes(only_weak_retained),
),
Some(("references", "References")),
))
}
}
struct ProxyLambdaBloat;
impl Rule for ProxyLambdaBloat {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let anon = r.leak_indicators.anonymous_class_count;
let loaded = r.overview.classes_loaded;
if loaded < PROXY_MIN_CLASSES {
return None;
}
let share = anon as f64 / loaded as f64 * 100.0;
if share < PROXY_BLOAT_PCT {
return None;
}
Some(signal(
"proxy-lambda-bloat",
TriageSeverity::Info,
"Proxy/Lambda Bloat",
format!(
"{} of {} loaded classes ({:.1}%) are anonymous/generated (lambda/proxy) — possible class-loader churn; cache generated proxies or upgrade to newer Java where lambdas are method handles.",
fmt_count(anon),
fmt_count(loaded),
share,
),
Some(("leak-indicators", "Leak Indicators")),
))
}
}
struct OffHeap;
impl Rule for OffHeap {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let cap = r.leak_indicators.direct_byte_buffer_capacity_sum;
if cap < DBB_FLOOR_BYTES {
return None;
}
Some(signal(
"off-heap",
TriageSeverity::Warning,
"Off-Heap (DirectByteBuffer)",
format!(
"{} of native memory is held by live DirectByteBuffers — not reflected in the on-heap totals, but counts against process RSS and can trigger OS-level OOM.",
format_bytes(cap),
),
Some(("off-heap-nio", "Off-Heap NIO")),
))
}
}
struct GcWaste;
impl Rule for GcWaste {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let o = &r.overview;
if o.heap_fragmentation_ratio < GC_WASTE_RATIO {
return None;
}
let pct = o.heap_fragmentation_ratio * 100.0;
let cluster = o
.unreachable_garbage_roots
.first()
.map(|g| {
format!(
" — largest garbage cluster rooted at `{}` ({})",
g.pretty_class,
format_bytes(g.retained),
)
})
.unwrap_or_default();
let size_desc =
if o.unreachable_retained > o.unreachable_shallow + o.unreachable_shallow / 20 {
format!(
"{} shallow, {} retained",
format_bytes(o.unreachable_shallow),
format_bytes(o.unreachable_retained)
)
} else {
format_bytes(o.unreachable_shallow)
};
Some(signal(
"gc-waste",
TriageSeverity::Warning,
"GC Waste",
format!(
"{:.1}% of the heap is unreachable ({}){}; the GC has not yet collected it. Trigger a full GC (`jcmd <pid> GC.run`) and re-dump — if the count drops sharply, the dump was taken mid-collection.",
pct, size_desc, cluster,
),
Some(("unreachable-objects", "Unreachable Objects")),
))
}
}
struct OverCapacityCollections;
impl Rule for OverCapacityCollections {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.leaks.total_shallow;
let cfr = &r.collections.collection_fill_ratio;
if cfr.tracked == 0 || total == 0 {
return None;
}
let wasted: u64 = cfr
.buckets
.iter()
.filter(|b| b.upper_ratio_bp <= OVERCAP_FILL_BP)
.map(|b| b.wasted)
.sum();
if wasted as f64 / total as f64 * 100.0 < OVERCAP_WASTE_PCT {
return None;
}
Some(signal(
"over-capacity-collections",
TriageSeverity::Info,
"Over-Capacity Collections",
format!(
"{} wasted by under-filled collections (≤50% full across {} tracked) — for lists call `trimToSize()` after bulk population; for all types right-size initial capacity so the backing array is not over-allocated at construction.",
format_bytes(wasted),
fmt_count(cfr.tracked),
),
Some(("collections", "Collections")),
))
}
}
struct ConstantValueArrays;
impl Rule for ConstantValueArrays {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let cpa = &r.collections.constant_primitive_arrays;
if cpa.rows.is_empty() {
return None;
}
let sum: u64 = cpa.rows.iter().map(|row| row.shallow).sum();
if sum < CONSTARR_FLOOR {
return None;
}
let big = cpa.rows.iter().max_by_key(|row| row.shallow)?;
Some(signal_cls(
"constant-value-arrays",
TriageSeverity::Info,
"Constant-Value Arrays",
format!(
"{} in single-value primitive arrays; biggest group `{}` × {} instances — replace duplicates with a shared constant (e.g. `static final byte[] EMPTY = new byte[0]`).",
format_bytes(sum),
big.array_class,
fmt_count(big.objects),
),
Some(("collections", "Collections")),
&big.array_class,
))
}
}
struct ObjectSwarm;
impl Rule for ObjectSwarm {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.overview.total_shallow;
let row = r
.overview
.histogram
.iter()
.filter(|h| {
!h.pretty_class.ends_with("[]")
&& h.instances >= SWARM_FLOOR_INSTANCES
&& (h.instances == 0 || h.shallow / h.instances <= SWARM_MAX_INSTANCE_BYTES)
})
.max_by_key(|h| h.shallow)?;
if pct_of(row.shallow, total) < SWARM_PCT {
return None;
}
Some(signal_cls(
"object-swarm",
TriageSeverity::Warning,
"Object Swarm",
format!(
"{} live `{}` instances ({} shallow, {:.1}% of heap) — many tiny objects accumulating; check for an unbounded queue, growing log buffer, or DTO/event accumulation. Either cap the collection or process and discard entries on-the-fly.",
fmt_count(row.instances),
row.pretty_class,
format_bytes(row.shallow),
pct_of(row.shallow, total),
),
Some(("system-overview", "System Overview")),
&row.pretty_class,
))
}
}
struct BoxedPrimitiveBloat;
impl Rule for BoxedPrimitiveBloat {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
const BOXED: &[&str] = &[
"java.lang.Integer",
"java.lang.Long",
"java.lang.Double",
"java.lang.Float",
"java.lang.Short",
"java.lang.Byte",
"java.lang.Character",
"java.lang.Boolean",
];
let total = r.overview.total_shallow;
let (instances, shallow, worst_class) = r
.overview
.histogram
.iter()
.filter(|h| BOXED.iter().any(|b| h.pretty_class == *b))
.fold((0u64, 0u64, ""), |(inst, sh, worst), h| {
let new_worst = if h.instances > inst || worst.is_empty() {
h.pretty_class.as_str()
} else {
worst
};
(inst + h.instances, sh + h.shallow, new_worst)
});
if instances < BOXED_FLOOR_INSTANCES && pct_of(shallow, total) < BOXED_PCT {
return None;
}
Some(signal(
"boxed-primitive-bloat",
TriageSeverity::Info,
"Boxed-Primitive Bloat",
format!(
"{} boxed-primitive objects ({} shallow, led by `{}`) — consider primitive-specialized collections (e.g. Eclipse Collections, Koloboke).",
fmt_count(instances),
format_bytes(shallow),
worst_class,
),
Some(("boxed-numbers", "Boxed Numbers")),
))
}
}
struct ClassloaderExplosion;
impl Rule for ClassloaderExplosion {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let n = r.overview.classloaders_loaded;
if n < CLASSLOADER_EXPLOSION_FLOOR {
return None;
}
Some(signal(
"classloader-explosion",
TriageSeverity::Warning,
"Class-Loader Explosion",
format!(
"{} live class-loader instances — abnormally high; typical apps use tens. Likely dynamic-class or redeploy leak: check for Groovy/JSP script-engine leaks, CGLIB proxy caching, or undischarged application-server contexts.",
fmt_count(n),
),
Some(("system-overview", "System Overview")),
))
}
}
struct ThreadSwarm;
impl Rule for ThreadSwarm {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let threads = &r.threads.threads;
let count = threads.len();
if count < THREAD_SWARM_FLOOR {
return None;
}
let aggregate_retained: u64 = threads.iter().map(|t| t.retained).sum();
Some(signal(
"thread-swarm",
TriageSeverity::Warning,
"Thread Swarm",
format!(
"{} live threads retaining {} in aggregate — likely unbounded thread creation or a leaking thread pool. Ensure ExecutorServices are shut down when no longer needed; on Java 21+ prefer virtual threads for I/O-bound workloads.",
fmt_count(count as u64),
format_bytes(aggregate_retained),
),
Some(("threads", "Threads")),
))
}
}
struct DuplicateStrings;
impl Rule for DuplicateStrings {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let ds = r.overview.duplicate_strings.as_ref()?;
let total = r.overview.total_shallow;
if ds.approx_wasted_bytes < DUP_STRINGS_FLOOR_BYTES
&& pct_of(ds.approx_wasted_bytes, total) < DUP_STRINGS_PCT
{
return None;
}
let top = ds.top_duplicated.first();
let example = top
.map(|t| format!("; `\"{}\"` repeated {}×", t.text, fmt_count(t.count),))
.unwrap_or_default();
Some(signal(
"duplicate-strings",
TriageSeverity::Info,
"Duplicate Strings",
format!(
"~{} wasted by {} duplicated String values ({} total instances){}. Enable JVM string deduplication (`-XX:+UseStringDeduplication` with G1GC), or intern/pool strings at creation time.",
format_bytes(ds.approx_wasted_bytes),
fmt_count(ds.duplicated_values),
fmt_count(ds.total_string_instances),
example,
),
Some(("duplicate-strings", "Duplicate Strings")),
))
}
}
struct CharArraySlack;
impl Rule for CharArraySlack {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let caw = r
.overview
.duplicate_strings
.as_ref()
.and_then(|ds| ds.char_array_waste.as_ref())?;
if caw.total_wasted_bytes < CHAR_SLACK_FLOOR_BYTES
|| caw.wasteful_arrays < CHAR_SLACK_MIN_ARRAYS
{
return None;
}
Some(signal(
"char-array-slack",
TriageSeverity::Info,
"Char-Array Slack",
format!(
"~{} slack in {} over-allocated char[]/byte[] String backing arrays — common from pre-sized `StringBuilder` allocations that are never fully filled, or `String(byte[], offset, length)` where the source array is larger than the result. Use `new String(str)` to copy-compact, or size StringBuilder capacity to the expected output length.",
format_bytes(caw.total_wasted_bytes),
fmt_count(caw.wasteful_arrays),
),
Some(("duplicate-strings", "Duplicate Strings")),
))
}
}
struct LargeUnboundedCollection;
impl Rule for LargeUnboundedCollection {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let bc = r.biggest_collections.as_ref()?;
let row = bc.combined.iter().max_by_key(|c| c.elements)?;
if row.elements < UNBOUNDED_COLL_ELEMENTS {
let retained_ok = row
.retained
.map(|ret| pct_of(ret, r.leaks.total_shallow) >= UNBOUNDED_COLL_PCT)
.unwrap_or(false);
if !retained_ok {
return None;
}
}
let retained_str = row
.retained
.map(|ret| format!(", retaining {}", format_bytes(ret)))
.unwrap_or_default();
let owner_str = row
.owner
.as_deref()
.map(|o| format!(" (held by `{}`)", o))
.unwrap_or_default();
Some(signal_cls(
"large-unbounded-collection",
TriageSeverity::Warning,
"Large Unbounded Collection",
format!(
"one `{}` holds {} elements{}{} — likely a static or unbounded cache that never evicts. Add a maximum-size eviction policy (e.g. Caffeine/Guava `maximumSize`, `LinkedHashMap` LRU override, or `removeEldestEntry`).",
row.container_class,
fmt_count(row.elements),
retained_str,
owner_str,
),
Some(("biggest-collections", "Biggest Collections")),
&row.container_class,
))
}
}
struct FinalizerQueueBacklog;
impl Rule for FinalizerQueueBacklog {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let row = r
.overview
.histogram
.iter()
.find(|h| h.pretty_class == "java.lang.ref.Finalizer")?;
if row.instances < FINALIZER_FLOOR {
return None;
}
Some(signal(
"finalizer-queue-backlog",
TriageSeverity::Warning,
"Finalizer Queue Backlog",
format!(
"{} live `java.lang.ref.Finalizer` instances — the finalizer thread is falling behind; objects with `finalize()` (e.g. `Deflater`, JDBC connections) accumulate faster than they are drained. Prefer explicit `close()` over relying on `finalize()`.",
fmt_count(row.instances),
),
Some(("system-overview", "System Overview")),
))
}
}
struct MetaspacePressure;
impl Rule for MetaspacePressure {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let n = r.overview.classes_loaded;
if n < METASPACE_CLASS_FLOOR {
return None;
}
Some(signal(
"metaspace-pressure",
TriageSeverity::Warning,
"Metaspace Pressure",
format!(
"{} classes loaded — far above normal; class metadata is likely exhausting Metaspace. Typical cause: CGLIB/Byte Buddy/Groovy proxy generation without caching. Add `-XX:MaxMetaspaceSize` to cap growth, enable proxy caching, and look for repeated `defineClass` call sites.",
fmt_count(n),
),
Some(("system-overview", "System Overview")),
))
}
}
struct CachedReflectionMetadata;
impl Rule for CachedReflectionMetadata {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
const REFLECT_CLASSES: &[&str] = &[
"java.lang.reflect.Method",
"java.lang.reflect.Field",
"java.lang.reflect.Constructor",
];
let total: u64 = r
.overview
.histogram
.iter()
.filter(|h| REFLECT_CLASSES.iter().any(|&c| h.pretty_class == c))
.map(|h| h.instances)
.sum();
if total < REFLECT_FLOOR {
return None;
}
Some(signal(
"cached-reflection-metadata",
TriageSeverity::Info,
"Cached Reflection Metadata",
format!(
"{} live `java.lang.reflect.{{Method,Field,Constructor}}` objects — framework reflection caches are unbounded (typically Spring/Hibernate accumulating per scanned class). Check for uncapped `ReflectionUtils` caches or scanner loops calling `getDeclaredMethods()` without caching the result.",
fmt_count(total),
),
Some(("system-overview", "System Overview")),
))
}
}
struct JniGlobalRefLeak;
impl Rule for JniGlobalRefLeak {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let count = r
.overview
.gc_roots_by_type
.iter()
.find(|row| row.root_type == "JNI Global")
.map(|row| row.count)
.unwrap_or(0);
if count < JNI_GLOBAL_FLOOR {
return None;
}
let total = r.overview.total_shallow;
let retained = r
.overview
.gc_roots_retained_by_type
.iter()
.find(|row| row.root_type == "JNI Global")
.map(|row| row.retained)
.unwrap_or(0);
if pct_of(retained, total) < JNI_GLOBAL_RETAINED_PCT {
return None;
}
Some(signal(
"jni-global-ref-leak",
TriageSeverity::Warning,
"JNI Global-Reference Leak",
format!(
"{} JNI Global roots retaining {} ({:.1}% of heap) — native code is accumulating global references without releasing them; audit `JNI_DeleteGlobalRef` call sites.",
fmt_count(count),
format_bytes(retained),
pct_of(retained, total),
),
Some(("system-overview", "System Overview")),
))
}
}
struct HeapCompositionSkew;
impl Rule for HeapCompositionSkew {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.overview.total_shallow;
if total == 0 {
return None;
}
let dominant = r
.overview
.heap_composition
.by_kind
.iter()
.max_by_key(|k| k.shallow_heap)?;
let pct = pct_of(dominant.shallow_heap, total);
if pct < HEAP_SKEW_PCT {
return None;
}
let hint = match dominant.kind.as_str() {
"Primitive Arrays" => {
"check for bulk-data buffers (NIO, image, audio) or oversized backing stores"
}
"Instances" => "too many small objects — see Object Swarm or Boxed-Primitive Bloat",
"Object Arrays" => {
"sparse arrays or container backing stores; check collection fill ratios"
}
"Class Objects" => {
"many dynamically generated classes — see Class-Loader Explosion or Metaspace Pressure"
}
_ => "inspect the Class Histogram for the dominant contributors",
};
Some(signal(
"heap-composition-skew",
TriageSeverity::Info,
"Heap Composition Skew",
format!(
"{} account for {:.1}% of reachable heap — unusually skewed; {}.",
dominant.kind, pct, hint,
),
Some(("system-overview", "System Overview")),
))
}
}
struct StaticFieldAnchor;
impl Rule for StaticFieldAnchor {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let s = r.leaks.suspects.first()?;
if s.root_type_label != "Sticky Class" {
return None;
}
let total = r.leaks.total_shallow;
let pct = pct_of(s.retained, total);
if pct < STATIC_ANCHOR_PCT {
return None;
}
Some(signal_cls(
"static-field-anchor",
TriageSeverity::Warning,
"Static-Field Anchor",
format!(
"`{}` is anchored via a static field (`Sticky Class` root) and retains {} ({:.1}% of heap) — the object lives for the class-loader lifetime; add eviction, null out the field after use, or replace with a `WeakReference` if the data should be reclaimable.",
s.pretty_class,
format_bytes(s.retained),
pct,
),
Some(("leak-suspects", "Leak Suspects")),
&s.pretty_class,
))
}
}
struct SessionScopeLeak;
impl Rule for SessionScopeLeak {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let row = r
.overview
.histogram
.iter()
.filter(|h| {
let c = &h.pretty_class;
(c.contains("Session") || c.contains("session"))
&& !c.contains("[]")
&& h.instances >= SESSION_FLOOR
})
.max_by_key(|h| h.instances)?;
Some(signal_cls(
"session-scope-leak",
TriageSeverity::Warning,
"Session-Scope Leak",
format!(
"{} live `{}` instances — session objects accumulating without invalidation; check that sessions are expired/invalidated on logout and that an idle-timeout is configured.",
fmt_count(row.instances),
row.pretty_class,
),
Some(("system-overview", "System Overview")),
&row.pretty_class,
))
}
}
struct ConnectionLeak;
impl Rule for ConnectionLeak {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let row = r
.overview
.histogram
.iter()
.filter(|h| {
let c = &h.pretty_class;
!c.ends_with("[]")
&& !c.contains("Weak")
&& !c.contains("Reference")
&& (c.contains("Connection") || c.contains("Socket"))
&& h.instances >= CONNECTION_FLOOR
})
.max_by_key(|h| h.instances)?;
Some(signal_cls(
"connection-leak",
TriageSeverity::Warning,
"Connection / Socket Leak",
format!(
"{} live `{}` objects — exceeds any reasonable pool or connection limit. Wrap acquisitions in try-with-resources, or enable connection-pool leak detection (e.g. HikariCP `leakDetectionThreshold`, c3p0 `unreturnedConnectionTimeout`).",
fmt_count(row.instances),
row.pretty_class,
),
Some(("system-overview", "System Overview")),
&row.pretty_class,
))
}
}
struct EventListenerAccumulation;
impl Rule for EventListenerAccumulation {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let row = r
.overview
.histogram
.iter()
.filter(|h| {
let c = &h.pretty_class;
!c.ends_with("[]")
&& (c.contains("Listener")
|| c.contains("Observer")
|| c.contains("Subscriber"))
&& h.instances >= LISTENER_FLOOR
})
.max_by_key(|h| h.instances)?;
Some(signal_cls(
"event-listener-accumulation",
TriageSeverity::Warning,
"Event-Listener Accumulation",
format!(
"{} live `{}` instances — listeners accumulating without removal; call `removeListener()` / `unsubscribe()` when the component is disposed, or use weak-reference listener registries.",
fmt_count(row.instances),
row.pretty_class,
),
Some(("system-overview", "System Overview")),
&row.pretty_class,
))
}
}
struct ParserOutputAccumulation;
impl Rule for ParserOutputAccumulation {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
const PARSER_PKGS: &[&str] = &[
"org.w3c.dom.",
"com.fasterxml.jackson.",
"com.google.gson.",
"org.dom4j.",
"org.jdom.",
"nu.xom.",
"javax.xml.",
"jakarta.xml.",
];
let row = r
.overview
.histogram
.iter()
.filter(|h| {
!h.pretty_class.ends_with("[]")
&& PARSER_PKGS
.iter()
.any(|pkg| h.pretty_class.starts_with(pkg))
&& h.instances >= PARSER_FLOOR
})
.max_by_key(|h| h.instances)?;
Some(signal_cls(
"parser-output-accumulation",
TriageSeverity::Info,
"Parser-Output Accumulation",
format!(
"{} live `{}` instances — XML/JSON parse results are accumulating; discard documents after processing, or use a streaming parser (SAX/StAX/Jackson streaming) instead of building a full in-memory tree.",
fmt_count(row.instances),
row.pretty_class,
),
Some(("system-overview", "System Overview")),
&row.pretty_class,
))
}
}
struct InternedStringBloat;
impl Rule for InternedStringBloat {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let string_count = r
.overview
.histogram
.iter()
.find(|h| h.pretty_class == "java.lang.String")
.map(|h| h.instances)
.unwrap_or(0);
if string_count < INTERNED_STRING_FLOOR {
return None;
}
let jni_global_count = r
.overview
.gc_roots_by_type
.iter()
.find(|row| row.root_type == "JNI Global")
.map(|row| row.count)
.unwrap_or(0);
if jni_global_count < INTERNED_JNI_FLOOR {
return None;
}
Some(signal(
"interned-string-bloat",
TriageSeverity::Warning,
"Interned-String Bloat",
format!(
"{} live `java.lang.String` instances with {} JNI Global roots — the intern table may be growing without bound from calls to `String.intern()` on dynamic or user-supplied values. Replace with a bounded cache (e.g. Guava `Interner` or `ConcurrentHashMap`) and avoid `intern()` on strings that are not truly constants.",
fmt_count(string_count),
fmt_count(jni_global_count),
),
Some(("system-overview", "System Overview")),
))
}
}
struct SparseObjectArrays;
impl Rule for SparseObjectArrays {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let afr = &r.collections.array_fill_ratio;
if afr.tracked < SPARSE_ARRAY_MIN_TRACKED {
return None;
}
let total_heap = r.leaks.total_shallow;
let (sparse_objects, wasted): (u64, u64) = afr
.buckets
.iter()
.filter(|b| b.upper_ratio_bp <= SPARSE_ARRAY_FILL_BP)
.fold((0, 0), |(obj, w), b| (obj + b.objects, w + b.wasted));
if sparse_objects < SPARSE_ARRAY_MIN_TRACKED
|| pct_of(wasted, total_heap) < SPARSE_ARRAY_WASTED_PCT
{
return None;
}
Some(signal(
"sparse-object-arrays",
TriageSeverity::Info,
"Sparse Object Arrays",
format!(
"{} object arrays are ≤{}% full ({} wasted on null slots) — sparse or multi-dimensional array structures consuming excess memory. Replace with a `HashMap` / `SparseArray`, a `List` that grows on demand, or a dedicated sparse-matrix library.",
fmt_count(sparse_objects),
SPARSE_ARRAY_FILL_BP / 100,
format_bytes(wasted),
),
Some(("collections", "Collections")),
))
}
}
struct BigDropConcentration;
impl Rule for BigDropConcentration {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.overview.total_shallow;
let row = r.dominator_analysis.big_drops.rows.first()?;
if row.drop_bytes < BIG_DROP_FLOOR {
return None;
}
let pct = pct_of(row.drop_bytes, total);
if pct < BIG_DROP_PCT {
return None;
}
Some(signal_cls(
"big-drop-concentration",
TriageSeverity::Critical,
"Dominator-Tree Big Drop",
format!(
"`{}` is the single largest memory bucket: {:.1}% ({}) of the heap \
drops here in the dominator tree — every path from a GC root to those objects \
passes through this one node. Follow the retaining chain to find the GC root that keeps it alive.",
row.display_class,
pct,
format_bytes(row.drop_bytes),
),
Some(("dominator-analysis", "Dominator Analysis")),
&row.display_class,
))
}
}
struct FixedPerObjectOverhead;
impl Rule for FixedPerObjectOverhead {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.overview.total_shallow;
if total == 0 {
return None;
}
let header_bytes: u64 = if r.overview.identifier_size_bits == 32
|| r.overview.compressed_oops.unwrap_or(true)
{
12
} else {
16
};
let overhead = r.overview.total_objects.saturating_mul(header_bytes);
let pct = overhead as f64 / total as f64 * 100.0;
if pct < HEADER_OVERHEAD_PCT {
return None;
}
Some(signal(
"fixed-per-object-overhead",
TriageSeverity::Warning,
"Fixed per-Object Header Overhead",
format!(
"{} ({:.1}% of heap) consumed by JVM object headers alone \
({} objects × {} B each) — consider replacing wrapper objects with \
primitive arrays, off-heap buffers, or primitive-specialized collections.",
format_bytes(overhead),
pct,
fmt_count(r.overview.total_objects),
header_bytes,
),
Some(("object-header-overhead", "Object Header Overhead")),
))
}
}
struct HashCollisionHotspot;
impl Rule for HashCollisionHotspot {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let mcr = &r.collections.map_collision_ratio;
if mcr.tracked < COLLISION_MIN_TRACKED {
return None;
}
let hot: u64 = mcr
.buckets
.iter()
.filter(|b| b.lower_ratio_bp >= COLLISION_HIGH_BP)
.map(|b| b.objects)
.sum();
if hot == 0 {
return None;
}
let pct = pct_of(hot, mcr.tracked);
Some(signal(
"hash-collision-hotspot",
TriageSeverity::Warning,
"Hash-Map Collision Hotspot",
format!(
"{} of {} tracked maps ({:.1}%) have a load factor > {}% — \
over-packed hash tables cause long collision chains and degrade \
lookup performance. Increase initial capacity or lower the load factor \
(pass `initialCapacity` and `loadFactor` to the constructor, default is 0.75).",
fmt_count(hot),
fmt_count(mcr.tracked),
pct,
COLLISION_HIGH_BP / 100,
),
Some(("collections", "Collections")),
))
}
}
struct EmptyCollectionCemetery;
impl Rule for EmptyCollectionCemetery {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let cbs = &r.collections.collections_by_size;
if cbs.tracked == 0 {
return None;
}
let share_pct = pct_of(cbs.empty_count, cbs.tracked);
if share_pct < EMPTY_COLL_SHARE_PCT && cbs.empty_count < EMPTY_COLL_FLOOR {
return None;
}
Some(signal(
"empty-collection-cemetery",
TriageSeverity::Info,
"Empty-Collection Cemetery",
format!(
"{} of {} tracked collections ({:.1}%) are empty — \
pre-allocated but never populated containers waste object-header \
overhead at scale. Use lazy initialization (allocate only when the \
first element is added) or return `Collections.emptyList()` / \
`List.of()` sentinels for the read-only empty case.",
fmt_count(cbs.empty_count),
fmt_count(cbs.tracked),
share_pct,
),
Some(("collections", "Collections")),
))
}
}
struct OversizedPrimArray;
impl Rule for OversizedPrimArray {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let total = r.overview.total_shallow;
let row = r.collections.top_prim_arrays.top_individual.first()?;
if row.shallow < OVERSIZED_PRIM_ARRAY_FLOOR {
return None;
}
let pct = pct_of(row.shallow, total);
if pct < OVERSIZED_PRIM_ARRAY_PCT {
return None;
}
let owner_clause = match &row.owner {
Some(o) => format!(" held by `{o}`"),
None => String::new(),
};
Some(signal_cls(
"oversized-prim-array",
TriageSeverity::Warning,
"Oversized Primitive Array",
format!(
"A single `{}` ({} elements, {}){} accounts for {:.1}% of the heap — \
consider chunking, memory-mapping, or off-heap storage.",
row.array_class,
fmt_count(row.length),
format_bytes(row.shallow),
owner_clause,
pct,
),
Some(("arrays-by-size", "Arrays by Size")),
&row.array_class,
))
}
}
struct DuplicatePrimArrays;
impl Rule for DuplicatePrimArrays {
fn eval(&self, r: &Report) -> Option<TriageSignal> {
let dpa = r.overview.duplicate_prim_arrays.as_ref()?;
let wasted = dpa.total_wasted_bytes;
if wasted == 0 {
return None;
}
let total = r.overview.total_shallow;
if wasted < DUP_PRIM_ARRAYS_FLOOR && pct_of(wasted, total) < DUP_PRIM_ARRAYS_PCT {
return None;
}
Some(signal(
"dup-prim-arrays",
TriageSeverity::Warning,
"Duplicate Primitive Arrays",
format!(
"{} ({:.1}% of heap) wasted by content-identical primitive arrays — \
multiple copies of the same byte[]/int[]/etc. payload could be \
deduplicated or replaced with a shared constant.",
format_bytes(wasted),
pct_of(wasted, total),
),
Some(("duplicate-prim-arrays", "Duplicate Primitive Arrays")),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::model::*;
fn base_report() -> Report {
Report {
schema_version: SCHEMA_VERSION,
generated: String::new(),
truncated_input: false,
overview: SystemOverview::default(),
leaks: LeakSuspects::default(),
top: TopConsumers::default(),
threads: ThreadOverview::default(),
top_components: TopComponents::default(),
alloc_sites: None,
arrays_by_size: ArraysBySize::default(),
dominator_analysis: DominatorAnalysis::default(),
collections: CollectionsAnalysis::default(),
references: ReferencesAnalysis::default(),
collection_attribution: None,
fields_by_size: None,
biggest_collections: None,
collection_contents: None,
leak_indicators: LeakIndicators::default(),
triage: Vec::new(),
waste_summary: None,
top_retainers: Vec::new(),
queries: Vec::new(),
analysis_flags: Default::default(),
obj_graph_flat: None,
type_ref_graph: vec![],
thread_local_analysis: Vec::new(),
framework_analysis: Vec::new(),
field_stats: None,
}
}
#[test]
fn off_heap_fires_above_floor_not_below() {
let mut r = base_report();
r.leak_indicators.direct_byte_buffer_capacity_sum = 1024;
assert!(OffHeap.eval(&r).is_none(), "1 KiB must not fire off-heap");
r.leak_indicators.direct_byte_buffer_capacity_sum = 128 * 1024 * 1024;
let s = OffHeap.eval(&r).expect("128 MiB must fire off-heap");
assert_eq!(s.id, "off-heap");
assert_eq!(s.anchor.as_deref(), Some("off-heap-nio"));
}
#[test]
fn thread_pinning_by_share_and_by_local_count() {
let mut r = base_report();
r.leaks.total_shallow = 1000;
r.threads.threads = vec![ThreadInfo {
retained: 250,
local_root_count: 0,
name: Some("worker-1".into()),
..Default::default()
}];
let s = ThreadPinning.eval(&r).expect("25% share must fire");
assert!(s.detail.contains("worker-1"));
r.threads.threads = vec![ThreadInfo {
retained: 120,
local_root_count: 150,
name: Some("pinner".into()),
..Default::default()
}];
assert!(
ThreadPinning.eval(&r).is_some(),
"150 locals at 12% share must fire"
);
r.threads.threads = vec![ThreadInfo {
retained: 10,
local_root_count: 150,
name: Some("main".into()),
..Default::default()
}];
assert!(
ThreadPinning.eval(&r).is_none(),
"150 locals at 1% share must not fire"
);
r.threads.threads = vec![ThreadInfo {
retained: 10,
local_root_count: 5,
name: Some("idle".into()),
..Default::default()
}];
assert!(ThreadPinning.eval(&r).is_none());
}
#[test]
fn gc_waste_names_the_garbage_root_class() {
let mut r = base_report();
r.overview.heap_fragmentation_ratio = 0.05;
assert!(GcWaste.eval(&r).is_none(), "5% must not fire");
r.overview.heap_fragmentation_ratio = 0.25;
r.overview.unreachable_shallow = 500;
r.overview.unreachable_retained = 900;
r.overview.unreachable_garbage_roots = vec![UnreachableGarbageRoot {
pretty_class: "com.example.Cache".into(),
retained: 800,
objects: 3,
children: vec![],
}];
let s = GcWaste.eval(&r).expect("25% must fire");
assert!(s.detail.contains("com.example.Cache"));
assert!(s.detail.contains("25.0%"));
}
#[test]
fn concentration_owner_join_when_single_suspect_matches_biggest() {
let mut r = base_report();
r.leaks.total_shallow = 1000;
r.leaks.suspects = vec![Suspect {
is_single: true,
pretty_class: "com.example.Big".into(),
instance_count: 1,
retained: 800,
..Default::default()
}];
r.top.biggest_objects = vec![ObjRow {
display_class: "com.example.Big".into(),
retained: 800,
owner: Some("com.example.Holder#field".into()),
..Default::default()
}];
let s = Concentration.eval(&r).expect("always fires");
assert!(s.detail.contains("highly concentrated"));
assert!(s.detail.contains("held by `com.example.Holder#field`"));
}
#[test]
fn over_capacity_and_constant_arrays_silent_without_collections() {
let r = base_report();
assert!(OverCapacityCollections.eval(&r).is_none());
assert!(ConstantValueArrays.eval(&r).is_none());
}
#[test]
fn evaluate_triage_preserves_registry_order() {
let mut r = base_report();
r.leaks.total_shallow = 1000;
r.leaks.suspects = vec![Suspect {
is_single: true,
pretty_class: "A".into(),
instance_count: 1,
retained: 900,
..Default::default()
}];
r.overview.heap_fragmentation_ratio = 0.5;
r.overview.unreachable_shallow = 500;
let fired = evaluate_triage(&r);
let ids: Vec<&str> = fired.iter().map(|s| s.id.as_str()).collect();
let hp = ids.iter().position(|&x| x == "headline-retainer").unwrap();
let cp = ids.iter().position(|&x| x == "concentration").unwrap();
let gp = ids.iter().position(|&x| x == "gc-waste").unwrap();
assert!(hp < cp && cp < gp, "order was {ids:?}");
}
#[test]
fn object_swarm_fires_on_tiny_class_with_huge_count() {
let mut r = base_report();
r.overview.total_shallow = 1_000_000;
r.overview.histogram = vec![HistRow {
pretty_class: "com.app.Event".into(),
instances: 15_000_000,
shallow: 200_000, retained: 200_000,
max_instance_shallow: 13,
incoming_ref_count: 0,
loader_id: 0,
loader_label: None,
root_path: None,
}];
let s = ObjectSwarm
.eval(&r)
.expect("15M tiny objects at 20% must fire");
assert!(s.detail.contains("com.app.Event"));
r.overview.histogram[0].instances = 1_000_000;
assert!(ObjectSwarm.eval(&r).is_none());
}
#[test]
fn boxed_primitive_bloat_fires_on_many_long_instances() {
let mut r = base_report();
r.overview.total_shallow = 1_000_000;
r.overview.histogram = vec![HistRow {
pretty_class: "java.lang.Long".into(),
instances: 8_000_000,
shallow: 128_000_000,
retained: 128_000_000,
max_instance_shallow: 16,
incoming_ref_count: 0,
loader_id: 0,
loader_label: None,
root_path: None,
}];
let s = BoxedPrimitiveBloat
.eval(&r)
.expect("8M Long instances must fire");
assert!(s.detail.contains("java.lang.Long"));
r.overview.histogram[0].pretty_class = "com.example.Foo".into();
assert!(BoxedPrimitiveBloat.eval(&r).is_none());
}
#[test]
fn classloader_explosion_fires_above_threshold() {
let mut r = base_report();
r.overview.classloaders_loaded = 2000;
assert!(ClassloaderExplosion.eval(&r).is_some());
r.overview.classloaders_loaded = 50;
assert!(ClassloaderExplosion.eval(&r).is_none());
}
#[test]
fn thread_swarm_fires_on_high_count() {
let mut r = base_report();
r.leaks.total_shallow = 1_000_000;
r.threads.threads = (0..1500)
.map(|i| ThreadInfo {
retained: 100,
name: Some(format!("worker-{i}")),
..Default::default()
})
.collect();
assert!(ThreadSwarm.eval(&r).is_some(), "1500 threads must fire");
r.threads.threads = r.threads.threads[0..10].to_vec();
assert!(ThreadSwarm.eval(&r).is_none());
}
#[test]
fn duplicate_strings_fires_and_silent_without_data() {
let mut r = base_report();
assert!(DuplicateStrings.eval(&r).is_none());
r.overview.duplicate_strings = Some(crate::pass2::DupStrings {
approx_wasted_bytes: 32 * 1024 * 1024,
duplicated_values: 50_000,
total_string_instances: 200_000,
..Default::default()
});
let s = DuplicateStrings.eval(&r).expect("32 MiB must fire");
assert_eq!(s.id, "duplicate-strings");
r.overview
.duplicate_strings
.as_mut()
.unwrap()
.approx_wasted_bytes = 1024;
assert!(DuplicateStrings.eval(&r).is_none());
}
#[test]
fn char_array_slack_fires_and_silent_without_data() {
let mut r = base_report();
assert!(CharArraySlack.eval(&r).is_none());
r.overview.duplicate_strings = Some(crate::pass2::DupStrings {
char_array_waste: Some(crate::pass2::CharArrayWaste {
arrays_examined: 100_000,
wasteful_arrays: 50_000,
total_wasted_bytes: 32 * 1024 * 1024,
top: Vec::new(),
}),
..Default::default()
});
let s = CharArraySlack.eval(&r).expect("32 MiB slack must fire");
assert_eq!(s.id, "char-array-slack");
r.overview
.duplicate_strings
.as_mut()
.unwrap()
.char_array_waste
.as_mut()
.unwrap()
.wasteful_arrays = 10;
assert!(CharArraySlack.eval(&r).is_none());
}
#[test]
fn large_unbounded_collection_fires_on_element_count() {
let mut r = base_report();
r.leaks.total_shallow = 10_000_000;
assert!(LargeUnboundedCollection.eval(&r).is_none());
r.biggest_collections = Some(BiggestCollections {
combined: vec![BiggestCollectionRow {
kind: "Map".into(),
container_class: "java.util.HashMap".into(),
elements: 2_000_000,
retained: Some(4_000_000),
owner: None,
dominant_value_type: None,
value_type_breakdown: Vec::new(),
obj_index_1based: None,
}],
by_kind: Vec::new(),
truncated: false,
});
let s = LargeUnboundedCollection
.eval(&r)
.expect("2M elements must fire");
assert!(s.detail.contains("java.util.HashMap"));
r.biggest_collections.as_mut().unwrap().combined[0].elements = 100;
r.biggest_collections.as_mut().unwrap().combined[0].retained = Some(100);
assert!(LargeUnboundedCollection.eval(&r).is_none());
}
fn hist_row(class: &str, instances: u64, shallow: u64) -> HistRow {
HistRow {
pretty_class: class.into(),
instances,
shallow,
retained: shallow,
max_instance_shallow: shallow.checked_div(instances).unwrap_or(0),
incoming_ref_count: 0,
loader_id: 0,
loader_label: None,
root_path: None,
}
}
#[test]
fn finalizer_fires_on_high_count() {
let mut r = base_report();
r.overview.histogram = vec![hist_row("java.lang.ref.Finalizer", 20_000, 640_000)];
assert!(FinalizerQueueBacklog.eval(&r).is_some());
r.overview.histogram[0].instances = 100;
assert!(FinalizerQueueBacklog.eval(&r).is_none());
r.overview.histogram = vec![];
assert!(FinalizerQueueBacklog.eval(&r).is_none());
}
#[test]
fn metaspace_pressure_fires_on_high_class_count() {
let mut r = base_report();
r.overview.classes_loaded = 60_000;
assert!(MetaspacePressure.eval(&r).is_some());
r.overview.classes_loaded = 5_000;
assert!(MetaspacePressure.eval(&r).is_none());
}
#[test]
fn cached_reflection_fires_on_method_count() {
let mut r = base_report();
r.overview.histogram = vec![
hist_row("java.lang.reflect.Method", 400_000, 25_600_000),
hist_row("java.lang.reflect.Field", 200_000, 9_600_000),
];
let s = CachedReflectionMetadata
.eval(&r)
.expect("600k reflect objects must fire");
assert!(s.detail.contains("600,000"));
r.overview.histogram[0].instances = 100;
r.overview.histogram[1].instances = 100;
assert!(CachedReflectionMetadata.eval(&r).is_none());
}
#[test]
fn jni_global_ref_fires_on_count_and_share() {
let mut r = base_report();
r.overview.total_shallow = 1_000_000;
r.overview.gc_roots_by_type = vec![crate::report::model::GcRootTypeRow {
root_type: "JNI Global".into(),
count: 8_000,
}];
r.overview.gc_roots_retained_by_type = vec![crate::report::model::GcRootRetainedRow {
root_type: "JNI Global".into(),
count: 8_000,
retained: 100_000, top_classes: Vec::new(),
}];
assert!(JniGlobalRefLeak.eval(&r).is_some());
r.overview.gc_roots_by_type[0].count = 10;
assert!(JniGlobalRefLeak.eval(&r).is_none());
r.overview.gc_roots_by_type[0].count = 8_000;
r.overview.gc_roots_retained_by_type[0].retained = 10; assert!(JniGlobalRefLeak.eval(&r).is_none());
}
#[test]
fn heap_composition_skew_fires_on_dominant_kind() {
let mut r = base_report();
r.overview.total_shallow = 1_000_000;
r.overview.heap_composition.by_kind = vec![
crate::report::model::KindStat {
kind: "Primitive Arrays".into(),
objects: 10_000,
shallow_heap: 750_000,
},
crate::report::model::KindStat {
kind: "Instances".into(),
objects: 50_000,
shallow_heap: 250_000,
},
];
let s = HeapCompositionSkew
.eval(&r)
.expect("75% primitive arrays must fire");
assert!(s.detail.contains("Primitive Arrays"));
r.overview.heap_composition.by_kind[0].shallow_heap = 500_000; assert!(HeapCompositionSkew.eval(&r).is_none());
}
#[test]
fn static_field_anchor_fires_when_sticky_class_dominates() {
let mut r = base_report();
r.leaks.total_shallow = 1_000_000;
r.leaks.suspects = vec![Suspect {
pretty_class: "com.example.AppConfig".into(),
is_single: true,
instance_count: 1,
retained: 400_000,
root_type_label: "Sticky Class".into(),
..Default::default()
}];
let s = StaticFieldAnchor
.eval(&r)
.expect("40% sticky class must fire");
assert!(s.detail.contains("AppConfig"));
r.leaks.suspects[0].root_type_label = "Thread".into();
assert!(StaticFieldAnchor.eval(&r).is_none());
r.leaks.suspects[0].root_type_label = "Sticky Class".into();
r.leaks.suspects[0].retained = 100; assert!(StaticFieldAnchor.eval(&r).is_none());
}
#[test]
fn session_scope_leak_fires_on_name_pattern() {
let mut r = base_report();
r.overview.histogram = vec![hist_row("com.example.UserSession", 200_000, 3_200_000)];
let s = SessionScopeLeak
.eval(&r)
.expect("200k UserSession must fire");
assert!(s.detail.contains("UserSession"));
r.overview.histogram[0].instances = 10;
assert!(SessionScopeLeak.eval(&r).is_none());
}
#[test]
fn connection_leak_fires_on_name_pattern() {
let mut r = base_report();
r.overview.histogram = vec![hist_row("com.mysql.jdbc.ConnectionImpl", 5_000, 800_000)];
let s = ConnectionLeak
.eval(&r)
.expect("5000 ConnectionImpl must fire");
assert!(s.detail.contains("ConnectionImpl"));
r.overview.histogram[0].instances = 5;
assert!(ConnectionLeak.eval(&r).is_none());
}
#[test]
fn event_listener_fires_on_name_pattern() {
let mut r = base_report();
r.overview.histogram = vec![hist_row("com.example.MessageListener", 150_000, 2_400_000)];
assert!(EventListenerAccumulation.eval(&r).is_some());
r.overview.histogram[0].instances = 1_000;
assert!(EventListenerAccumulation.eval(&r).is_none());
}
#[test]
fn parser_output_fires_on_package_pattern() {
let mut r = base_report();
r.overview.histogram = vec![hist_row(
"com.fasterxml.jackson.databind.node.ObjectNode",
200_000,
6_400_000,
)];
assert!(ParserOutputAccumulation.eval(&r).is_some());
r.overview.histogram[0].instances = 10;
assert!(ParserOutputAccumulation.eval(&r).is_none());
r.overview.histogram[0].instances = 500_000;
r.overview.histogram[0].pretty_class = "com.example.Node".into();
assert!(ParserOutputAccumulation.eval(&r).is_none());
}
#[test]
fn interned_string_bloat_requires_both_conditions() {
let mut r = base_report();
r.overview.histogram = vec![hist_row("java.lang.String", 3_000_000, 96_000_000)];
r.overview.gc_roots_by_type = vec![crate::report::model::GcRootTypeRow {
root_type: "JNI Global".into(),
count: 5_000,
}];
assert!(InternedStringBloat.eval(&r).is_some());
r.overview.histogram[0].instances = 100;
assert!(InternedStringBloat.eval(&r).is_none());
r.overview.histogram[0].instances = 3_000_000;
r.overview.gc_roots_by_type[0].count = 5;
assert!(InternedStringBloat.eval(&r).is_none());
}
#[test]
fn sparse_object_arrays_fires_on_low_fill() {
let mut r = base_report();
r.leaks.total_shallow = 1_000_000;
assert!(SparseObjectArrays.eval(&r).is_none());
r.collections.array_fill_ratio = crate::report::model::ArrayFillRatio {
tracked: 50_000,
buckets: vec![crate::report::model::FillRatioBucket {
lower_ratio_bp: 0,
upper_ratio_bp: 2_000, objects: 30_000,
shallow: 600_000,
wasted: 100_000, }],
};
assert!(SparseObjectArrays.eval(&r).is_some());
r.collections.array_fill_ratio.buckets[0].wasted = 10;
assert!(SparseObjectArrays.eval(&r).is_none());
}
#[test]
fn big_drop_concentration_fires_on_large_drop() {
let mut r = base_report();
r.overview.total_shallow = 200 * 1024 * 1024;
r.dominator_analysis.big_drops.rows = vec![crate::report::model::BigDropRow {
obj_index_1based: 1,
display_class: "com.example.Cache".into(),
retained: 150 * 1024 * 1024,
child_count: 5,
largest_child_retained: 10 * 1024 * 1024,
largest_child_class: "java.util.HashMap".into(),
drop_bytes: 140 * 1024 * 1024, }];
let s = BigDropConcentration.eval(&r).expect("large drop must fire");
assert!(s.detail.contains("Cache"));
r.overview.total_shallow = 10_000 * 1024 * 1024;
assert!(BigDropConcentration.eval(&r).is_none());
}
#[test]
fn big_drop_concentration_requires_floor() {
let mut r = base_report();
r.overview.total_shallow = 200 * 1024 * 1024;
r.dominator_analysis.big_drops.rows = vec![crate::report::model::BigDropRow {
obj_index_1based: 1,
display_class: "com.example.Foo".into(),
retained: 50 * 1024 * 1024,
child_count: 1,
largest_child_retained: 10 * 1024 * 1024,
largest_child_class: "java.util.ArrayList".into(),
drop_bytes: 40 * 1024 * 1024,
}];
assert!(BigDropConcentration.eval(&r).is_none());
}
#[test]
fn fixed_per_object_overhead_fires_on_many_small_objects() {
let mut r = base_report();
r.overview.total_objects = 5_000_000;
r.overview.total_shallow = 200 * 1024 * 1024;
r.overview.identifier_size_bits = 64;
r.overview.compressed_oops = Some(false);
let s = FixedPerObjectOverhead
.eval(&r)
.expect("40% header overhead must fire");
assert!(s.detail.contains("5,000,000"));
r.overview.total_objects = 10;
assert!(FixedPerObjectOverhead.eval(&r).is_none());
}
#[test]
fn hash_collision_hotspot_fires_on_dense_maps() {
let mut r = base_report();
r.collections.map_collision_ratio = crate::report::model::MapCollisionRatio {
tracked: 500,
total: 0,
buckets: vec![crate::report::model::FillRatioBucket {
lower_ratio_bp: 9_000,
upper_ratio_bp: 10_000,
objects: 400,
shallow: 0,
wasted: 0,
}],
};
assert!(HashCollisionHotspot.eval(&r).is_some());
r.collections.map_collision_ratio.tracked = 5;
assert!(HashCollisionHotspot.eval(&r).is_none());
}
#[test]
fn empty_collection_cemetery_fires_on_high_empty_share() {
let mut r = base_report();
r.collections.collections_by_size = crate::report::model::CollectionsBySize {
tracked: 1_000,
empty_count: 800, buckets: vec![],
};
assert!(EmptyCollectionCemetery.eval(&r).is_some());
r.collections.collections_by_size.empty_count = 50;
assert!(EmptyCollectionCemetery.eval(&r).is_none());
}
#[test]
fn empty_collection_cemetery_fires_on_absolute_count() {
let mut r = base_report();
r.collections.collections_by_size = crate::report::model::CollectionsBySize {
tracked: 2_000_000,
empty_count: 600_000, buckets: vec![],
};
assert!(EmptyCollectionCemetery.eval(&r).is_some());
}
#[test]
fn oversized_prim_array_fires_on_huge_array() {
let mut r = base_report();
r.overview.total_shallow = 200 * 1024 * 1024;
r.collections.top_prim_arrays.top_individual = vec![crate::report::model::TopArrayRow {
array_class: "byte[]".into(),
length: 100_000_000,
shallow: 100 * 1024 * 1024, obj_index_1based: 1,
owner: None,
non_null: None,
}];
let s = OversizedPrimArray.eval(&r).expect("huge array must fire");
assert!(s.detail.contains("byte[]"));
r.collections.top_prim_arrays.top_individual[0].shallow = 1024;
assert!(OversizedPrimArray.eval(&r).is_none());
}
#[test]
fn duplicate_prim_arrays_fires_on_wasted_bytes() {
let mut r = base_report();
r.overview.total_shallow = 200 * 1024 * 1024;
r.overview.duplicate_prim_arrays = Some(crate::pass2::DupPrimArrays {
total_wasted_bytes: 20 * 1024 * 1024, rows: vec![],
top_array_holders: vec![],
});
let s = DuplicatePrimArrays
.eval(&r)
.expect("large dup-prim waste must fire");
assert!(s.detail.contains("20.0 MB"));
r.overview
.duplicate_prim_arrays
.as_mut()
.unwrap()
.total_wasted_bytes = 1024;
assert!(DuplicatePrimArrays.eval(&r).is_none());
}
}