Call Graph の基本概念

Call Graph は ノード = メソッドエッジ = 呼び出し関係 で表現される有向グラフだ。 A.methodX() が内部で B.methodY() を呼ぶとき、 グラフには A.methodX → B.methodY というエッジが追加される。

💡 同クラス内 vs クロスクラス呼び出し

同じクラス内のメソッド呼び出し(this.helper() 等)は内部の処理フローを表す。 クロスクラス呼び出し(inventoryClient.checkStock() 等)はモジュール間の依存を表す。 Jarviz などの可視化ツールでは後者を中心に分析することが多い。

INVOKE系バイトコード命令

Javaコンパイラはメソッド呼び出しをバイトコードの INVOKE 命令に変換する。 どの命令が使われるかによって呼び出しの性質が変わる。

命令用途解決タイミング
invokevirtual インスタンスメソッド(通常) 実行時(ポリモーフィズム)
invokeinterface インターフェースメソッド 実行時(動的ディスパッチ)
invokespecial コンストラクタ・superprivate コンパイル時(静的解決)
invokestatic static メソッド コンパイル時(静的解決)
invokedynamic ラムダ・メソッド参照(Java 8+) 実行時(Bootstrap Method)

⚠️ 静的解析の落とし穴

invokevirtual / invokeinterface はバイトコード上では宣言型のクラス/インターフェースを指すだけで、 実際にどの実装クラスのメソッドが呼ばれるかは実行時まで決まらない。 静的解析だけでは「可能性のある呼び出し先」の列挙に留まることが多い。

静的解析:ASMによるCall Graph抽出

ASM の MethodVisitor を使うと、各メソッド内の INVOKE 命令を逐一読み取れる。 これによってメソッド単位の呼び出し関係を網羅的に抽出できる。

Java — ASM でメソッド呼び出し関係を抽出
import org.objectweb.asm.*;
import java.io.*;
import java.util.*;
import java.util.jar.*;

/** メソッド呼び出し関係を抽出して List<CallEdge> に格納 */
public class CallGraphExtractor {

    record CallEdge(
        String sourceClass, String sourceMethod,
        String targetClass, String targetMethod
    ) {}

    private final List<CallEdge> edges = new ArrayList<>();

    public void analyzeJar(String jarPath) throws IOException {
        try (JarFile jar = new JarFile(jarPath)) {
            Enumeration<JarEntry> entries = jar.entries();
            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                if (entry.getName().endsWith(".class")) {
                    try (InputStream is = jar.getInputStream(entry)) {
                        analyzeClass(new ClassReader(is));
                    }
                }
            }
        }
    }

    private void analyzeClass(ClassReader cr) {
        cr.accept(new ClassVisitor(Opcodes.ASM9) {

            private String currentClass;

            @Override
            public void visit(int version, int access, String name,
                    String signature, String superName, String[] interfaces) {
                currentClass = name.replace('/', '.');
            }

            @Override
            public MethodVisitor visitMethod(int access, String name,
                    String descriptor, String signature, String[] exceptions) {
                String currentMethod = name + descriptor;

                return new MethodVisitor(Opcodes.ASM9) {
                    @Override
                    public void visitMethodInsn(int opcode, String owner,
                            String mName, String mDescriptor, boolean isInterface) {

                        // java.*, sun.* など標準ライブラリは除外
                        String targetClass = owner.replace('/', '.');
                        if (targetClass.startsWith("java.") ||
                            targetClass.startsWith("sun.") ||
                            targetClass.startsWith("com.sun.")) return;

                        edges.add(new CallEdge(
                            currentClass, currentMethod,
                            targetClass, mName + mDescriptor
                        ));
                    }
                };
            }
        }, 0);
    }

    public List<CallEdge> getEdges() { return edges; }

    public static void main(String[] args) throws Exception {
        CallGraphExtractor extractor = new CallGraphExtractor();
        extractor.analyzeJar("target/myapp.jar");
        extractor.getEdges().forEach(e ->
            System.out.printf("%s#%s → %s#%s%n",
                e.sourceClass(), e.sourceMethod(),
                e.targetClass(), e.targetMethod()));
    }
}

動的解析:Java Agentによるトレース

動的解析は実際にアプリケーションを実行し、呼ばれたメソッドをリアルタイムで記録する。 静的解析では取りにくいポリモーフィズムの実際の呼び出し先を正確に把握できる。

Java — シンプルな Java Agent でメソッドエントリをトレース
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;

/** -javaagent:tracer-agent.jar で起動時にアタッチする */
public class CallTraceAgent {

    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader, String className,
                    Class<?> classBeingRedefined,
                    ProtectionDomain domain, byte[] classfileBuffer) {

                // 対象パッケージのみ変換
                if (!className.startsWith("com/example/")) return null;

                ClassReader cr = new ClassReader(classfileBuffer);
                ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);

                cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
                    private String currentClass;

                    @Override
                    public void visit(int version, int access, String name,
                            String sig, String superName, String[] ifaces) {
                        currentClass = name.replace('/', '.');
                        super.visit(version, access, name, sig, superName, ifaces);
                    }

                    @Override
                    public MethodVisitor visitMethod(int access, String name,
                            String descriptor, String signature, String[] exceptions) {
                        MethodVisitor mv = super.visitMethod(
                                access, name, descriptor, signature, exceptions);
                        // メソッド開始時にログ出力を挿入
                        return new MethodVisitor(Opcodes.ASM9, mv) {
                            @Override
                            public void visitCode() {
                                // System.err に呼び出しログを出力
                                mv.visitLdcInsn("CALL:" + currentClass + "#" + name);
                                mv.visitMethodInsn(Opcodes.INVOKESTATIC,
                                    "com/example/TraceLogger", "log",
                                    "(Ljava/lang/String;)V", false);
                                super.visitCode();
                            }
                        };
                    }
                }, ClassReader.EXPAND_FRAMES);

                return cw.toByteArray();
            }
        });
    }
}

// TraceLogger(別クラス)
class TraceLogger {
    public static void log(String msg) {
        System.err.println(msg);
    }
}
Shell — Java Agent を使ってアプリを起動
java -javaagent:tracer-agent.jar \
     -jar myapp.jar \
     2> call-trace.log

静的 vs 動的の使い分け

観点静的解析(ASM)動的解析(Java Agent)
精度 インターフェース経由は不確定 実際に呼ばれた呼び出し先を記録
網羅性 全コードパスをスキャン 実行されたパスのみ
実行コスト JARを読むだけ アプリ起動・実行が必要
セットアップ容易やや複雑 Agent JAR作成が必要
ラムダ対応 invokedynamic は難しい 実行時に解決済み

実用的なアドバイス

初めに静的解析で全体の依存マップを作り、怪しい箇所(ポリモーフィズムが多い箇所など)を動的解析で補完するアプローチが現実的だ。 両者の結果をマージすることで精度と網羅性を両立できる。

Call Graphの限界と対策

課題具体例対策
ポリモーフィズム service.process() — 実装クラスが実行時に決まる 動的解析で補完 / クラス階層を辿って候補を列挙
ラムダ・メソッド参照 list.forEach(this::handle) invokedynamic の Bootstrap Methodを解析(複雑)
リフレクション Method m = cls.getMethod("process"); m.invoke(obj); 動的解析が唯一の手段。文字列リテラルから候補推定も
外部フレームワーク Spring の @Autowired による DI フレームワーク固有のアノテーション解析を追加

💡 「取れるものを取る」現実的な割り切り

完全な Call Graph を作ることは理論的にも困難(ライス定理)。 目的が「ホットスポットの発見」「依存の俯瞰」であれば、 多少の不確かさがあっても静的解析で80〜90%の関係を把握するだけで十分に価値がある。 次回の PART 04 では実際にコードを書いて関係情報をJSON/JSONL形式に出力する。