diff --git a/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java b/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java index ac80f5a..c18a7de 100644 --- a/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java +++ b/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java @@ -55,12 +55,20 @@ public void execute(String[] args, CliConfig config, ProviderRegistry registry, boolean useColor = config.color(); boolean flagsOnly = false; boolean showPhases = false; + boolean tenuring = false; String exportHtml = null; for (int i = 1; i < args.length; i++) { if (args[i].equals("--format=json")) json = true; if (args[i].equals("--suggest-flags")) flagsOnly = true; if (args[i].startsWith("--export=")) exportHtml = args[i].substring(9); if (args[i].equals("--phases")) showPhases = true; + if (args[i].equals("--tenuring")) tenuring = true; + } + + // --tenuring: dedicated age table analysis from debug GC log + if (tenuring) { + printTenuringAnalysis(logFile, useColor); + return; } List events; @@ -324,6 +332,107 @@ private void printPhaseBreakdown(boolean c, GcPhaseAnalyzer.PhaseAnalysis analys + RichRenderer.padLeft("100%", BAR_WIDTH + 25), WIDTH)); } + private void printTenuringAnalysis(Path logFile, boolean c) { + TenuringAnalyzer.TenuringAnalysis analysis; + try { + analysis = TenuringAnalyzer.analyze(logFile); + } catch (IOException e) { + System.err.println("Failed to read GC log: " + e.getMessage()); + return; + } + + System.out.print(RichRenderer.brandedHeader(c, "gclog", "Tenuring analysis from GC age table")); + System.out.println(RichRenderer.boxHeader(c, "Tenuring Analysis", WIDTH, + "file:" + logFile.getFileName())); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + if (analysis.snapshots().isEmpty()) { + System.out.println(RichRenderer.boxLine( + " No age table entries found.", WIDTH)); + System.out.println(RichRenderer.boxLine( + " Enable with: -Xlog:gc+age=debug", WIDTH)); + System.out.println(RichRenderer.boxFooter(c, null, WIDTH)); + return; + } + + // Summary + kv(c, "GC snapshots", String.valueOf(analysis.snapshots().size())); + kv(c, "Min threshold", String.valueOf(analysis.minThreshold())); + kv(c, "Max threshold seen", String.valueOf(analysis.maxThresholdSeen())); + + if (analysis.prematurePromotionDetected()) { + kv(c, "Premature promotion", + AnsiStyle.style(c, AnsiStyle.RED) + "DETECTED" + + AnsiStyle.style(c, AnsiStyle.RESET)); + } + if (analysis.survivorOverflowDetected()) { + kv(c, "Survivor overflow", + AnsiStyle.style(c, AnsiStyle.YELLOW) + "DETECTED" + + AnsiStyle.style(c, AnsiStyle.RESET)); + } + + // Latest age snapshot + if (!analysis.snapshots().isEmpty()) { + TenuringAnalyzer.GcAgeSnapshot latest = analysis.snapshots().getLast(); + section(c, "Latest Age Distribution (GC " + latest.gcId() + ")"); + + var entries = latest.distribution().entries(); + long total = latest.distribution().survivorCapacity(); + + if (!entries.isEmpty()) { + String bold = AnsiStyle.style(c, AnsiStyle.BOLD); + String reset = AnsiStyle.style(c, AnsiStyle.RESET); + System.out.println(RichRenderer.boxLine( + " " + bold + + RichRenderer.padRight("Age", 5) + + RichRenderer.padLeft("Bytes", 12) + + RichRenderer.padLeft("Cumulative", 13) + + " Bar" + + reset, WIDTH)); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + for (var e : entries) { + int pct = total > 0 ? (int) (e.bytes() * 100 / total) : 0; + int barLen = 20 * pct / 100; + String bar = "\u2588".repeat(Math.max(0, barLen)); + boolean atThreshold = e.age() == latest.distribution().tenuringThreshold(); + String lColor = atThreshold ? AnsiStyle.style(c, AnsiStyle.YELLOW) : ""; + String lReset = atThreshold ? AnsiStyle.style(c, AnsiStyle.RESET) : ""; + System.out.println(RichRenderer.boxLine(" " + lColor + + RichRenderer.padLeft(String.valueOf(e.age()), 3) + " " + + RichRenderer.padLeft(RichRenderer.formatKB(e.bytes() / 1024), 10) + " " + + RichRenderer.padLeft(RichRenderer.formatKB(e.cumulativeBytes() / 1024), 10) + " " + + RichRenderer.padRight(bar, 20) + " " + pct + "%" + + lReset, WIDTH)); + } + System.out.println(RichRenderer.emptyLine(WIDTH)); + System.out.println(RichRenderer.boxLine( + " Tenuring: " + latest.distribution().tenuringThreshold() + + " / max: " + latest.distribution().maxTenuringThreshold(), WIDTH)); + } + } + + // Insights + if (!analysis.insights().isEmpty()) { + section(c, "Insights"); + for (String insight : analysis.insights()) { + System.out.println(RichRenderer.boxLine(" \u2192 " + insight, WIDTH)); + } + } + + // Recommendations + if (!analysis.recommendations().isEmpty()) { + section(c, "Recommendations"); + for (int i = 0; i < analysis.recommendations().size(); i++) { + System.out.println(RichRenderer.boxLine( + " " + (i + 1) + ". " + analysis.recommendations().get(i), WIDTH)); + } + } + + System.out.println(RichRenderer.boxFooter(c, + analysis.snapshots().size() + " snapshots", WIDTH)); + } + private void section(boolean c, String title) { System.out.println(RichRenderer.emptyLine(WIDTH)); System.out.println(RichRenderer.boxSeparator(WIDTH)); diff --git a/argus-cli/src/main/java/io/argus/cli/command/GcNewCommand.java b/argus-cli/src/main/java/io/argus/cli/command/GcNewCommand.java index 792fc95..4ce4c91 100644 --- a/argus-cli/src/main/java/io/argus/cli/command/GcNewCommand.java +++ b/argus-cli/src/main/java/io/argus/cli/command/GcNewCommand.java @@ -2,20 +2,26 @@ import io.argus.cli.config.CliConfig; import io.argus.cli.config.Messages; +import io.argus.cli.model.AgeDistribution; import io.argus.cli.model.GcNewResult; +import io.argus.cli.provider.GcAgeProvider; import io.argus.cli.provider.GcNewProvider; import io.argus.cli.provider.ProviderRegistry; import io.argus.cli.render.AnsiStyle; import io.argus.cli.render.RichRenderer; import io.argus.core.command.CommandGroup; +import java.util.List; + /** * Shows young generation GC detail: survivor spaces, tenuring threshold, eden. + * With --age-histogram shows per-age object distribution. */ public final class GcNewCommand implements Command { private static final int WIDTH = RichRenderer.DEFAULT_WIDTH; private static final int BAR_WIDTH = 16; + private static final int AGE_BAR_WIDTH = 20; @Override public String name() { return "gcnew"; } @@ -38,9 +44,11 @@ public void execute(String[] args, CliConfig config, ProviderRegistry registry, String sourceOverride = null; boolean json = "json".equals(config.format()); boolean useColor = config.color(); + boolean ageHistogram = false; for (int i = 1; i < args.length; i++) { if (args[i].startsWith("--source=")) sourceOverride = args[i].substring(9); else if (args[i].equals("--format=json")) json = true; + else if (args[i].equals("--age-histogram")) ageHistogram = true; } String source = sourceOverride != null ? sourceOverride : config.defaultSource(); @@ -96,9 +104,144 @@ public void execute(String[] args, CliConfig config, ProviderRegistry registry, String gcLine = "YGC: " + result.ygc() + " (" + String.format("%.3fs", result.ygct()) + ")"; System.out.println(RichRenderer.boxLine(gcLine, WIDTH)); + // Age histogram + if (ageHistogram) { + System.out.println(RichRenderer.emptyLine(WIDTH)); + System.out.println(RichRenderer.boxSeparator(WIDTH)); + System.out.println(RichRenderer.boxLine( + " " + AnsiStyle.style(useColor, AnsiStyle.BOLD, AnsiStyle.CYAN) + + messages.get("gcnew.age.title") + + AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH)); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + GcAgeProvider ageProvider = registry.findGcAgeProvider(pid, sourceOverride); + if (ageProvider == null) { + System.out.println(RichRenderer.boxLine( + " " + messages.get("gcnew.age.unavailable"), WIDTH)); + } else { + AgeDistribution dist = ageProvider.getAgeDistribution(pid); + renderAgeHistogram(dist, useColor); + } + } + System.out.println(RichRenderer.boxFooter(useColor, null, WIDTH)); } + private void renderAgeHistogram(AgeDistribution dist, boolean useColor) { + List entries = dist.entries(); + + if (entries.isEmpty()) { + System.out.println(RichRenderer.boxLine( + " " + "No age data available. Run with -Xlog:gc+age=debug for live data.", WIDTH)); + if (dist.tenuringThreshold() > 0) { + System.out.println(RichRenderer.boxLine( + " Tenuring: " + dist.tenuringThreshold() + + " / max: " + dist.maxTenuringThreshold(), WIDTH)); + } + return; + } + + // Header + String bold = AnsiStyle.style(useColor, AnsiStyle.BOLD); + String reset = AnsiStyle.style(useColor, AnsiStyle.RESET); + System.out.println(RichRenderer.boxLine( + " " + bold + + RichRenderer.padRight("Age", 5) + + RichRenderer.padLeft("Bytes", 12) + + RichRenderer.padLeft("Cumulative", 13) + + " Bar" + + reset, WIDTH)); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + long total = dist.survivorCapacity(); + if (total == 0 && !entries.isEmpty()) total = entries.getLast().cumulativeBytes(); + + // Group ages >= 6 together + long ageGe6Bytes = 0; + long ageGe6Cumulative = 0; + boolean hasHighAges = false; + + for (AgeDistribution.AgeEntry e : entries) { + if (e.age() >= 6) { + ageGe6Bytes += e.bytes(); + ageGe6Cumulative = e.cumulativeBytes(); + hasHighAges = true; + } + } + + for (AgeDistribution.AgeEntry e : entries) { + if (e.age() >= 6) continue; + renderAgeLine(e.age(), String.valueOf(e.age()), e.bytes(), e.cumulativeBytes(), + total, useColor, dist.tenuringThreshold()); + } + + if (hasHighAges) { + renderAgeLine(-1, "6+", ageGe6Bytes, ageGe6Cumulative, total, useColor, dist.tenuringThreshold()); + } + + System.out.println(RichRenderer.emptyLine(WIDTH)); + + // Summary lines + long survivorCap = dist.survivorCapacity(); + long desiredSize = dist.desiredSurvivorSize(); + int survivorPct = survivorCap > 0 && desiredSize > 0 + ? (int) Math.min(100, total * 100 / desiredSize) : 0; + + System.out.println(RichRenderer.boxLine( + " Tenuring: " + dist.tenuringThreshold() + " / max: " + dist.maxTenuringThreshold(), WIDTH)); + if (desiredSize > 0) { + System.out.println(RichRenderer.boxLine( + " Survivor: " + survivorPct + "% (" + + RichRenderer.formatKB(total / 1024) + + " / " + RichRenderer.formatKB(desiredSize / 1024) + ")", WIDTH)); + } + + // Insights + System.out.println(RichRenderer.emptyLine(WIDTH)); + if (!entries.isEmpty() && total > 0) { + long age1Bytes = entries.getFirst().age() == 1 ? entries.getFirst().bytes() : 0; + int age1Pct = (int) (age1Bytes * 100 / total); + if (age1Pct >= 50) { + System.out.println(RichRenderer.boxLine( + " \u2192 " + age1Pct + "% die at age 1 (healthy)", WIDTH)); + } + } + + // MaxTenuringThreshold suggestion + if (dist.tenuringThreshold() > 4 && !entries.isEmpty()) { + // Find age at which 80% is accumulated + long threshold80 = (long) (total * 0.80); + for (AgeDistribution.AgeEntry e : entries) { + if (e.cumulativeBytes() >= threshold80) { + if (e.age() < dist.maxTenuringThreshold()) { + System.out.println(RichRenderer.boxLine( + " \u2192 Consider -XX:MaxTenuringThreshold=" + e.age(), WIDTH)); + } + break; + } + } + } + } + + private void renderAgeLine(int age, String label, long bytes, long cumulative, + long total, boolean useColor, int tenuringThreshold) { + int pct = total > 0 ? (int) (bytes * 100 / total) : 0; + int barLen = AGE_BAR_WIDTH * pct / 100; + String bar = "\u2588".repeat(Math.max(0, barLen)); + + boolean atThreshold = age == tenuringThreshold; + String color = atThreshold ? AnsiStyle.style(useColor, AnsiStyle.YELLOW) : ""; + String reset = atThreshold ? AnsiStyle.style(useColor, AnsiStyle.RESET) : ""; + + String line = color + + RichRenderer.padLeft(label, 3) + " " + + RichRenderer.padLeft(RichRenderer.formatKB(bytes / 1024), 10) + " " + + RichRenderer.padLeft(RichRenderer.formatKB(cumulative / 1024), 10) + " " + + RichRenderer.padRight(bar, AGE_BAR_WIDTH) + " " + pct + "%" + + reset; + System.out.println(RichRenderer.boxLine(" " + line, WIDTH)); + } + private static void printJson(GcNewResult r) { System.out.println("{\"s0c\":" + r.s0c() + ",\"s1c\":" + r.s1c() + ",\"s0u\":" + r.s0u() + ",\"s1u\":" + r.s1u() diff --git a/argus-cli/src/main/java/io/argus/cli/gclog/TenuringAnalyzer.java b/argus-cli/src/main/java/io/argus/cli/gclog/TenuringAnalyzer.java new file mode 100644 index 0000000..48747fa --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/gclog/TenuringAnalyzer.java @@ -0,0 +1,229 @@ +package io.argus.cli.gclog; + +import io.argus.cli.model.AgeDistribution; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Analyzes GC log files for age table entries from debug-level GC logging + * ({@code -Xlog:gc+age=debug}). + * + *

Example log lines parsed: + *

+ * [0.234s][debug][gc,age] GC(0) Desired survivor size 1048576 bytes, new threshold 6 (max threshold 15)
+ * [0.234s][debug][gc,age] GC(0) - age   1:     524288 bytes,     524288 total
+ * [0.234s][debug][gc,age] GC(0) - age   2:     262144 bytes,     786432 total
+ * 
+ */ +public final class TenuringAnalyzer { + + private static final Pattern DESIRED_LINE = Pattern.compile( + "GC\\((\\d+)\\) Desired survivor size (\\d+) bytes, new threshold (\\d+) \\(max threshold (\\d+)\\)"); + private static final Pattern AGE_LINE = Pattern.compile( + "GC\\((\\d+)\\)\\s*-\\s*age\\s+(\\d+):\\s+(\\d+) bytes,\\s+(\\d+) total"); + private static final Pattern TIMESTAMP = Pattern.compile("\\[(\\d+\\.\\d+)s\\]"); + + /** + * Result of tenuring log analysis. + */ + public record TenuringAnalysis( + List snapshots, + List insights, + List recommendations, + boolean prematurePromotionDetected, + boolean survivorOverflowDetected, + int minThreshold, + int maxThresholdSeen, + int suggestedMaxTenuringThreshold + ) {} + + /** + * Age distribution captured at a single GC event. + */ + public record GcAgeSnapshot( + int gcId, + double timestampSec, + AgeDistribution distribution + ) {} + + /** + * Parses a GC log file and returns tenuring analysis. + * Only processes lines tagged with gc,age (debug level). + */ + public static TenuringAnalysis analyze(Path logFile) throws java.io.IOException { + List lines = java.nio.file.Files.readAllLines(logFile); + return analyzeLines(lines); + } + + /** + * Analyzes a list of log lines (testable without file I/O). + */ + public static TenuringAnalysis analyzeLines(List lines) { + List snapshots = new ArrayList<>(); + + // State for building current snapshot + int currentGcId = -1; + double currentTimestamp = 0; + int currentThreshold = 0; + int currentMaxThreshold = 15; + long currentDesiredSize = 0; + List currentEntries = new ArrayList<>(); + + for (String line : lines) { + if (!line.contains("gc,age") && !line.contains("gc+age")) continue; + + double ts = extractTimestamp(line); + + Matcher dm = DESIRED_LINE.matcher(line); + if (dm.find()) { + int gcId = Integer.parseInt(dm.group(1)); + if (gcId != currentGcId && currentGcId >= 0 && !currentEntries.isEmpty()) { + snapshots.add(buildSnapshot(currentGcId, currentTimestamp, + currentThreshold, currentMaxThreshold, + currentDesiredSize, currentEntries)); + } + currentGcId = gcId; + currentTimestamp = ts >= 0 ? ts : currentTimestamp; + currentDesiredSize = Long.parseLong(dm.group(2)); + currentThreshold = Integer.parseInt(dm.group(3)); + currentMaxThreshold = Integer.parseInt(dm.group(4)); + currentEntries = new ArrayList<>(); + continue; + } + + Matcher am = AGE_LINE.matcher(line); + if (am.find()) { + int gcId = Integer.parseInt(am.group(1)); + if (gcId != currentGcId) continue; // safety guard + int age = Integer.parseInt(am.group(2)); + long bytes = Long.parseLong(am.group(3)); + long cumulative = Long.parseLong(am.group(4)); + currentEntries.add(new AgeDistribution.AgeEntry(age, bytes, cumulative)); + } + } + + // Flush last snapshot + if (currentGcId >= 0 && !currentEntries.isEmpty()) { + snapshots.add(buildSnapshot(currentGcId, currentTimestamp, + currentThreshold, currentMaxThreshold, + currentDesiredSize, currentEntries)); + } + + return buildAnalysis(snapshots); + } + + private static GcAgeSnapshot buildSnapshot(int gcId, double ts, int tt, int mtt, + long desiredSize, + List entries) { + long survivorCap = entries.isEmpty() ? 0 : entries.getLast().cumulativeBytes(); + AgeDistribution dist = new AgeDistribution(List.copyOf(entries), tt, mtt, + desiredSize, survivorCap); + return new GcAgeSnapshot(gcId, ts, dist); + } + + private static TenuringAnalysis buildAnalysis(List snapshots) { + List insights = new ArrayList<>(); + List recommendations = new ArrayList<>(); + + if (snapshots.isEmpty()) { + insights.add("No age table data found. Enable -Xlog:gc+age=debug to collect tenuring data."); + return new TenuringAnalysis(snapshots, insights, recommendations, false, false, 0, 0, -1); + } + + int minThreshold = Integer.MAX_VALUE; + int maxThresholdSeen = 0; + boolean prematurePromotion = false; + boolean survivorOverflow = false; + + for (GcAgeSnapshot snap : snapshots) { + int tt = snap.distribution().tenuringThreshold(); + if (tt < minThreshold) minThreshold = tt; + if (tt > maxThresholdSeen) maxThresholdSeen = tt; + if (tt == 1) prematurePromotion = true; + + // Survivor overflow: used > desired survivor size + long used = snap.distribution().survivorCapacity(); + long desired = snap.distribution().desiredSurvivorSize(); + if (desired > 0 && used > desired * 2) survivorOverflow = true; + } + + // Analyze the last snapshot for current state + GcAgeSnapshot latest = snapshots.getLast(); + List entries = latest.distribution().entries(); + + // Age-1 ratio insight + if (!entries.isEmpty()) { + long total = latest.distribution().survivorCapacity(); + long age1 = entries.getFirst().bytes(); + if (total > 0) { + int age1Pct = (int) (age1 * 100 / total); + if (age1Pct >= 60) { + insights.add(age1Pct + "% of survivor objects die at age 1 (healthy short-lived objects)"); + } else if (age1Pct < 30) { + insights.add("Only " + age1Pct + "% die at age 1 — objects surviving multiple GCs"); + } + } + } + + if (prematurePromotion) { + insights.add("Premature promotion detected: threshold dropped to 1 (survivor space pressure)"); + recommendations.add("Increase survivor space: -XX:SurvivorRatio= or -XX:NewSize"); + } + + if (survivorOverflow) { + insights.add("Survivor overflow detected: objects promoted early due to full survivor space"); + recommendations.add("Increase survivor space with -XX:SurvivorRatio or -Xmn"); + } + + // Suggest MaxTenuringThreshold based on where most data accumulates + int suggested = suggestMaxTenuringThreshold(snapshots); + if (suggested > 0 && suggested < maxThresholdSeen) { + recommendations.add("Consider -XX:MaxTenuringThreshold=" + suggested + + " (most objects promoted by age " + suggested + ")"); + } + + if (minThreshold == Integer.MAX_VALUE) minThreshold = 0; + + return new TenuringAnalysis(snapshots, insights, recommendations, + prematurePromotion, survivorOverflow, minThreshold, maxThresholdSeen, suggested); + } + + /** + * Suggests MaxTenuringThreshold: the age at which 80%+ of objects have been promoted + * (cumulative bytes >= 80% of total), averaged across recent snapshots. + */ + static int suggestMaxTenuringThreshold(List snapshots) { + if (snapshots.isEmpty()) return -1; + + // Use last few snapshots for stability + int start = Math.max(0, snapshots.size() - 5); + int total80PctAge = 0; + int counted = 0; + + for (int i = start; i < snapshots.size(); i++) { + AgeDistribution dist = snapshots.get(i).distribution(); + if (dist.entries().isEmpty() || dist.survivorCapacity() == 0) continue; + + long threshold80 = (long) (dist.survivorCapacity() * 0.80); + for (AgeDistribution.AgeEntry e : dist.entries()) { + if (e.cumulativeBytes() >= threshold80) { + total80PctAge += e.age(); + counted++; + break; + } + } + } + + if (counted == 0) return -1; + return Math.max(1, total80PctAge / counted); + } + + private static double extractTimestamp(String line) { + Matcher m = TIMESTAMP.matcher(line); + return m.find() ? Double.parseDouble(m.group(1)) : -1; + } +} diff --git a/argus-cli/src/main/java/io/argus/cli/model/AgeDistribution.java b/argus-cli/src/main/java/io/argus/cli/model/AgeDistribution.java new file mode 100644 index 0000000..33247c6 --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/model/AgeDistribution.java @@ -0,0 +1,21 @@ +package io.argus.cli.model; + +import java.util.List; + +/** + * Object age distribution from the young generation survivor spaces. + * Age data is extracted from GC log debug output (-Xlog:gc+age=debug) + * or from jcmd GC.heap_info when available. + */ +public record AgeDistribution( + List entries, + int tenuringThreshold, + int maxTenuringThreshold, + long desiredSurvivorSize, + long survivorCapacity +) { + /** + * A single age bucket: objects of this age in the survivor space. + */ + public record AgeEntry(int age, long bytes, long cumulativeBytes) {} +} diff --git a/argus-cli/src/main/java/io/argus/cli/provider/GcAgeProvider.java b/argus-cli/src/main/java/io/argus/cli/provider/GcAgeProvider.java new file mode 100644 index 0000000..23d1268 --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/provider/GcAgeProvider.java @@ -0,0 +1,11 @@ +package io.argus.cli.provider; + +import io.argus.cli.model.AgeDistribution; + +/** + * Provides object age distribution data for young generation survivor spaces. + */ +public interface GcAgeProvider extends DiagnosticProvider { + + AgeDistribution getAgeDistribution(long pid); +} diff --git a/argus-cli/src/main/java/io/argus/cli/provider/ProviderRegistry.java b/argus-cli/src/main/java/io/argus/cli/provider/ProviderRegistry.java index 8bdceda..38996ce 100644 --- a/argus-cli/src/main/java/io/argus/cli/provider/ProviderRegistry.java +++ b/argus-cli/src/main/java/io/argus/cli/provider/ProviderRegistry.java @@ -14,6 +14,7 @@ import io.argus.cli.provider.jdk.JdkEnvProvider; import io.argus.cli.provider.jdk.JdkFinalizerProvider; import io.argus.cli.provider.jdk.JdkGcCauseProvider; +import io.argus.cli.provider.jdk.JdkGcAgeProvider; import io.argus.cli.provider.jdk.JdkGcNewProvider; import io.argus.cli.provider.jdk.JdkGcProvider; import io.argus.cli.provider.jdk.JdkGcUtilProvider; @@ -78,6 +79,7 @@ public final class ProviderRegistry { private final List dynLibsProviders = new ArrayList<>(); private final List classStatProviders = new ArrayList<>(); private final List gcNewProviders = new ArrayList<>(); + private final List gcAgeProviders = new ArrayList<>(); private final List symbolTableProviders = new ArrayList<>(); private final List threadDumpProviders = new ArrayList<>(); private final List buffersProviders = new ArrayList<>(); @@ -133,6 +135,7 @@ private void registerJdkProviders() { dynLibsProviders.add(new JdkDynLibsProvider()); classStatProviders.add(new JdkClassStatProvider()); gcNewProviders.add(new JdkGcNewProvider()); + gcAgeProviders.add(new JdkGcAgeProvider()); symbolTableProviders.add(new JdkSymbolTableProvider()); threadDumpProviders.add(new JdkThreadDumpProvider()); buffersProviders.add(new JdkBuffersProvider()); @@ -330,6 +333,13 @@ public GcNewProvider findGcNewProvider(long pid, String sourceOverride) { return findBest(gcNewProviders, pid, sourceOverride); } + /** + * Finds the best GcAgeProvider for the given PID. + */ + public GcAgeProvider findGcAgeProvider(long pid, String sourceOverride) { + return findBest(gcAgeProviders, pid, sourceOverride); + } + /** * Finds the best SymbolTableProvider for the given PID. */ diff --git a/argus-cli/src/main/java/io/argus/cli/provider/jdk/JdkGcAgeProvider.java b/argus-cli/src/main/java/io/argus/cli/provider/jdk/JdkGcAgeProvider.java new file mode 100644 index 0000000..01d50a9 --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/provider/jdk/JdkGcAgeProvider.java @@ -0,0 +1,153 @@ +package io.argus.cli.provider.jdk; + +import io.argus.cli.model.AgeDistribution; +import io.argus.cli.provider.GcAgeProvider; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * GcAgeProvider that uses {@code jcmd GC.heap_info} to extract object age + * distribution from the survivor spaces. Falls back to jstat for basic survivor + * data when age histogram is not available in heap_info output. + */ +public final class JdkGcAgeProvider implements GcAgeProvider { + + // Patterns for jcmd GC.heap_info / VM.info age table output + private static final Pattern DESIRED_SURVIVOR = Pattern.compile( + "Desired survivor size (\\d+) bytes, new threshold (\\d+) \\(max threshold (\\d+)\\)"); + private static final Pattern AGE_ENTRY = Pattern.compile( + "-\\s+age\\s+(\\d+):\\s+(\\d+) bytes,\\s+(\\d+) total"); + + @Override + public boolean isAvailable(long pid) { + return isJcmdAvailable(); + } + + @Override + public int priority() { return 10; } + + @Override + public String source() { return "jdk"; } + + @Override + public AgeDistribution getAgeDistribution(long pid) { + // Try jcmd GC.heap_info first + try { + ProcessBuilder pb = new ProcessBuilder("jcmd", String.valueOf(pid), "GC.heap_info"); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes()).trim(); + process.waitFor(); + + AgeDistribution result = parseAgeOutput(output); + if (result != null && !result.entries().isEmpty()) return result; + } catch (Exception ignored) {} + + // Fall back to VM.info which may include age table + try { + ProcessBuilder pb = new ProcessBuilder("jcmd", String.valueOf(pid), "VM.info"); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes()).trim(); + process.waitFor(); + + AgeDistribution result = parseAgeOutput(output); + if (result != null && !result.entries().isEmpty()) return result; + } catch (Exception ignored) {} + + // Fall back to jstat for survivor capacity, no per-age data + return fallbackFromJstat(pid); + } + + /** + * Parses age table from jcmd output. + * Matches lines like: + * Desired survivor size 1048576 bytes, new threshold 6 (max threshold 15) + * - age 1: 524288 bytes, 524288 total + */ + static AgeDistribution parseAgeOutput(String output) { + if (output == null || output.isBlank()) return null; + + int tenuringThreshold = 0; + int maxTenuringThreshold = 15; + long desiredSurvivorSize = 0; + List entries = new ArrayList<>(); + + for (String line : output.split("\n")) { + Matcher dm = DESIRED_SURVIVOR.matcher(line); + if (dm.find()) { + desiredSurvivorSize = Long.parseLong(dm.group(1)); + tenuringThreshold = Integer.parseInt(dm.group(2)); + maxTenuringThreshold = Integer.parseInt(dm.group(3)); + continue; + } + + Matcher am = AGE_ENTRY.matcher(line); + if (am.find()) { + int age = Integer.parseInt(am.group(1)); + long bytes = Long.parseLong(am.group(2)); + long cumulative = Long.parseLong(am.group(3)); + entries.add(new AgeDistribution.AgeEntry(age, bytes, cumulative)); + } + } + + long survivorCapacity = entries.isEmpty() ? 0 + : entries.getLast().cumulativeBytes(); + return new AgeDistribution(entries, tenuringThreshold, maxTenuringThreshold, + desiredSurvivorSize, survivorCapacity); + } + + private AgeDistribution fallbackFromJstat(long pid) { + try { + ProcessBuilder pb = new ProcessBuilder("jstat", "-gcnew", String.valueOf(pid)); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = new String(process.getInputStream().readAllBytes()).trim(); + process.waitFor(); + + String[] lines = output.split("\n"); + if (lines.length < 2) return empty(); + + String dataLine = lines[lines.length - 1].trim(); + String[] values = dataLine.split("\\s+"); + if (values.length < 7) return empty(); + + // S0C=0, S1C=1, S0U=2, S1U=3, TT=4, MTT=5, DSS=6 + long s0c = (long) JdkParseUtils.parseDouble(values[0]) * 1024; + long s1c = (long) JdkParseUtils.parseDouble(values[1]) * 1024; + long s0u = (long) JdkParseUtils.parseDouble(values[2]) * 1024; + long s1u = (long) JdkParseUtils.parseDouble(values[3]) * 1024; + int tt = (int) JdkParseUtils.parseLong(values[4]); + int mtt = (int) JdkParseUtils.parseLong(values[5]); + long dss = (long) JdkParseUtils.parseDouble(values[6]) * 1024; + + // Survivor used = whichever is non-zero + long survivorUsed = s0u > 0 ? s0u : s1u; + long survivorCap = s0c > 0 ? s0c : s1c; + + // Without per-age breakdown, return empty entries but with metadata + return new AgeDistribution(List.of(), tt, mtt, dss, survivorCap); + } catch (Exception e) { + return empty(); + } + } + + private static AgeDistribution empty() { + return new AgeDistribution(List.of(), 0, 15, 0, 0); + } + + private static boolean isJcmdAvailable() { + try { + Process p = new ProcessBuilder("jcmd", "--help") + .redirectErrorStream(true).start(); + p.getInputStream().readAllBytes(); + p.waitFor(); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/argus-cli/src/main/resources/messages_en.properties b/argus-cli/src/main/resources/messages_en.properties index 35408e5..627ec34 100644 --- a/argus-cli/src/main/resources/messages_en.properties +++ b/argus-cli/src/main/resources/messages_en.properties @@ -345,3 +345,6 @@ error.profile.unsupported.platform=async-profiler is not supported on this platf error.profile.download.failed=Failed to download async-profiler: %s error.profile.asprof.failed=async-profiler failed: %s error.profile.invalid.type=Invalid profiling type: %s. Use: cpu, alloc, lock, wall. Use: cpu, alloc, lock, wall +gcnew.age.title=Object Age Distribution +gcnew.age.unavailable=Age data unavailable. Enable -Xlog:gc+age=debug for live data. +cmd.gclog.tenuring.desc=Analyze tenuring threshold changes from GC age log diff --git a/argus-cli/src/main/resources/messages_ja.properties b/argus-cli/src/main/resources/messages_ja.properties index 766b4ed..a60193f 100644 --- a/argus-cli/src/main/resources/messages_ja.properties +++ b/argus-cli/src/main/resources/messages_ja.properties @@ -330,3 +330,6 @@ error.profile.unsupported.platform=\u3053\u306E\u30D7\u30E9\u30C3\u30C8\u30D5\u3 error.profile.download.failed=async-profiler\u30C0\u30A6\u30F3\u30ED\u30FC\u30C9\u5931\u6557: %s error.profile.asprof.failed=async-profiler\u5931\u6557: %s error.profile.invalid.type=\u7121\u52B9\u306A\u30D7\u30ED\u30D5\u30A1\u30A4\u30EA\u30F3\u30B0\u30BF\u30A4\u30D7: %s\u3002cpu, alloc, lock, wall\u3092\u4F7F\u7528 +gcnew.age.title=\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u5E74\u9F62\u5206\u5E03 +gcnew.age.unavailable=\u5E74\u9F62\u30C7\u30FC\u30BF\u306F\u5229\u7528\u4E0D\u53EF\u3002-Xlog:gc+age=debug\u3067\u6709\u52B9\u5316\u3057\u3066\u304F\u3060\u3055\u3044\u3002 +cmd.gclog.tenuring.desc=GC\u30A8\u30FC\u30B8\u30ED\u30B0\u304B\u3089\u30C6\u30CB\u30E5\u30A2\u30EA\u30F3\u30B0\u5909\u5316\u3092\u5206\u6790 diff --git a/argus-cli/src/main/resources/messages_ko.properties b/argus-cli/src/main/resources/messages_ko.properties index d409f8c..4def0a6 100644 --- a/argus-cli/src/main/resources/messages_ko.properties +++ b/argus-cli/src/main/resources/messages_ko.properties @@ -330,3 +330,6 @@ error.profile.unsupported.platform=\uC774 \uD50C\uB7AB\uD3FC\uC5D0\uC11C\uB294 a error.profile.download.failed=async-profiler \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: %s error.profile.asprof.failed=async-profiler \uC2E4\uD328: %s error.profile.invalid.type=\uC798\uBABB\uB41C \uD504\uB85C\uD30C\uC77C\uB9C1 \uD0C0\uC785: %s. cpu, alloc, lock, wall \uC0AC\uC6A9 +gcnew.age.title=\uAC1D\uCCB4 \uC5F0\uB839 \uBD84\uD3EC +gcnew.age.unavailable=\uC5F0\uB839 \uB370\uC774\uD130\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. -Xlog:gc+age=debug\uB97C \uC0AC\uC6A9\uD558\uC138\uC694. +cmd.gclog.tenuring.desc=GC \uC5D0\uC774\uC9C0 \uB85C\uADF8\uC5D0\uC11C \uD14C\uB274\uC5B4\uB9C1 \uBCC0\uD654 \uBD84\uC11D