CLOVER🍀

That was when it all began.

Byte Buddyでクラスを再定義(redefine)してみる

これは、なにをしたくて書いたもの?

こちらのエントリーで、Byte Buddyを試してみました。

Byte Buddyでバイトコードを生成・操作してみる - CLOVER🍀

この時はサブクラスを作成しましたが、今回はクラスの再定義(redefine)をやってみたいと思います。

クラスのredefineとrebase

前回はByte Buddyを使って、あるクラスのサブクラスを動的に定義してみました。

Byte Buddyのチュートリアルとしては、他にクラスのredefineとrebaseがあるのですがこの時はスキップしています。

Why runtime code generation? / Creating a class

redefineは、文字通りクラスの再定義です。再定義することで既存のクラスを変更できます。
rebaseは元のクラスの実装を持ちつつ定義を変更します。

こちらがrebase前後のイメージですね。

class Foo {
  String bar() { return "bar"; }
}


class Foo {
  String bar() { return "foo" + bar$original(); }
  private String bar$original() { return "bar"; }
}

要するにrebaseではメソッドが追加され、クラスの構造が変わります。このあとでテストコードを使って動作の確認を
するのですが、この方法ではロード済みのクラスの構造を変更することになり、これが許可されていないためrebaseは
失敗します。
※ClassLoaderでロードしなければいいのですが、そういう使い方はほぼしないと思うので…

使いたかったらJava agentと併用することになるようです。
もちろん、redefineでも対象のクラスがロード済みであればメソッドの追加などはできませんが。

こう見るとredefineとrebaseの使い分けに悩むところですが、rebaseはAOPのような用途に向いていそうです。

ただ近い用途はredefine+Adviceでも実現できそうです。これはまた今度見ることにします。

Advice (Byte Buddy (without dependencies) 1.18.3 API)

こう言ってしまうとさらに使い分けに迷いますが、バイトコードの安全性を取る場合はrebaseを選択するようです。
元のメソッドなどを変更しないので安定していて、フレームワークなどで利用されるようです。

一方でredefine+Adviceの場合はバイトコード操作が複雑になるため、安定性を求める箇所では避けた方がよいという感じに
なりそうです。またAdviceの適用範囲を見るとわかりますが、適用できるタイミングは限定的になっていて、オリジナルの
メソッドを完全にラップするような実装はできません。

このあたりは実際に使ってみて使い分けの感覚を養った方がよさそうですね。

今回はredefineを中心に見ていきます。

環境

今回の環境はこちら。

$ java --version
openjdk 25.0.1 2025-10-21
OpenJDK Runtime Environment (build 25.0.1+8-Ubuntu-124.04)
OpenJDK 64-Bit Server VM (build 25.0.1+8-Ubuntu-124.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.12 (848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 25.0.1, vendor: Ubuntu, runtime: /usr/lib/jvm/java-25-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "6.8.0-90-generic", arch: "amd64", family: "unix"

準備

Maven依存関係などはこちら。

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>net.bytebuddy</groupId>
            <artifactId>byte-buddy</artifactId>
            <version>1.18.3</version>
        </dependency>
        <dependency>
            <groupId>net.bytebuddy</groupId>
            <artifactId>byte-buddy-agent</artifactId>
            <version>1.18.3</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>6.0.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.27.6</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

今回はbyte-buddy-agentも必要になります。byte-buddy-agentとbyte-buddyには依存関係上の関連はないので、
それぞれ依存関係として追加します。

Byte Buddyでクラスを再定義(redefine)してみる

それでは、Byte Buddyでクラスを再定義してみます。

再定義対象のクラスはこちらにします。

src/main/java/org/littlewings/bytebyddy/Foo.java

package org.littlewings.bytebyddy;

public class Foo {
    public String bar() {
        return "bar";
    }

    public String baz() {
        return "baz";
    }
}

確認はテストコードで行います。テストコードの雛形はこちら。

src/test/java/org/littlewings/bytebyddy/ByteBuddyTest.java

package org.littlewings.bytebyddy;

import net.bytebuddy.ByteBuddy;
import net.bytebuddy.agent.ByteBuddyAgent;
import net.bytebuddy.description.modifier.Visibility;
import net.bytebuddy.dynamic.DynamicType;
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.matcher.ElementMatchers;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ByteBuddyTest {
    // ここにテストを書く!
}

まずはなにも考えずにredefineしてみます。

    @Test
    void badRedefine() {
        DynamicType.Unloaded<? extends Foo> dynamicType = new ByteBuddy()
                .redefine(Foo.class)
                .make();

        assertThatThrownBy(() -> dynamicType.load(getClass().getClassLoader()))
                .isExactlyInstanceOf(IllegalStateException.class)
                .hasMessage("Class already loaded: class org.littlewings.bytebyddy.Foo");
    }

これはクラスロード時に失敗します。すでにクラスがClassLoaderによって読み込まれているからですね。

ここで、ロード時にClassLoadingStrategyでブートストラップクラスローダーを使うようにすると、動作するように
なります。

    @Test
    void canRedefine() {
        DynamicType.Unloaded<? extends Foo> dynamicType = new ByteBuddy()
                .redefine(Foo.class)
                .make();

        Class<? extends Foo> clazz = dynamicType.load(ClassLoadingStrategy.BOOTSTRAP_LOADER).getLoaded();
        assertThat(clazz).isNotNull();

        Class<? extends Foo> clazz2 = dynamicType.load(ClassLoadingStrategy.BOOTSTRAP_LOADER, ClassLoadingStrategy.Default.WRAPPER).getLoaded();
        assertThat(clazz2).isNotNull();

        assertThat(clazz.getName()).isEqualTo(Foo.class.getName());
        assertThat(clazz.getName()).isEqualTo(clazz2.getName());
    }

ClassLoadingStrategy (Byte Buddy (without dependencies) 1.18.2 API)

WRAPPERというのは、指定したクラスローダーを親として新しいクラスローダーを作成する戦略で、明示的に
指定しなくてもこの動作になります。

ところが、この方法で定義したClassクラスはクラス名はこそ同じでインスタンス化もできたりするものの、
redefine対象のクラスとして扱うことができません。たとえばキャストしようとすると失敗します。

    @Test
    void canNotCastRedefine() throws ReflectiveOperationException {
        DynamicType.Unloaded<? extends Foo> dynamicType = new ByteBuddy()
                .redefine(Foo.class)
                .make();

        Class<? extends Foo> clazz = dynamicType.load(ClassLoadingStrategy.BOOTSTRAP_LOADER).getLoaded();
        Object foo = clazz.getConstructor().newInstance();

        assertThat(foo.getClass().getName()).isEqualTo(Foo.class.getName());

        assertThatThrownBy(() -> Foo.class.cast(foo))
                .isExactlyInstanceOf(ClassCastException.class)
                .hasMessage("Cannot cast org.littlewings.bytebyddy.Foo to org.littlewings.bytebyddy.Foo");

        assertThat(clazz).isNotEqualTo(Foo.class);
    }

Classクラスとしても同じではないことが確認できます。

ではどうするかというと、ここでbyte-buddy-agentを使います。

    @Test
    void redefineUsingAgent() throws ReflectiveOperationException {
        ByteBuddyAgent.install();

        DynamicType.Unloaded<? extends Foo> dynamicType = new ByteBuddy()
                .redefine(Foo.class)
                .make();

        assertThatThrownBy(() -> dynamicType.load(ClassLoadingStrategy.BOOTSTRAP_LOADER, ClassReloadingStrategy.fromInstalledAgent()))
                .isExactlyInstanceOf(IllegalStateException.class)
                .hasMessage("Bootstrap injection is not enabled");

        Class<? extends Foo> clazz = dynamicType.load(getClass().getClassLoader(), ClassReloadingStrategy.fromInstalledAgent()).getLoaded();

        assertThat(clazz.getName()).isEqualTo(Foo.class.getName());
        assertThat(clazz).isEqualTo(Foo.class);

        Foo foo = clazz.getConstructor().newInstance();
        assertThat(foo.bar()).isEqualTo("bar");
        assertThat(foo.baz()).isEqualTo("baz");

        assertThat(clazz).isEqualTo(Foo.class);
    }

ByteBuddyAgentのインストールが必要です。

        ByteBuddyAgent.install();

そしてブートストラップクラスローダーは使わなくなります。

        assertThatThrownBy(() -> dynamicType.load(ClassLoadingStrategy.BOOTSTRAP_LOADER, ClassReloadingStrategy.fromInstalledAgent()))
                .isExactlyInstanceOf(IllegalStateException.class)
                .hasMessage("Bootstrap injection is not enabled");

代わりにClassReloadingStrategyを使って、インストールしたByteBuddyAgentを使うようにします。

        Class<? extends Foo> clazz = dynamicType.load(getClass().getClassLoader(), ClassReloadingStrategy.fromInstalledAgent()).getLoaded();

こうすることでredefineしたClassクラスを元のクラスのように扱うことができます。

        assertThat(clazz.getName()).isEqualTo(Foo.class.getName());
        assertThat(clazz).isEqualTo(Foo.class);

        Foo foo = clazz.getConstructor().newInstance();
        assertThat(foo.bar()).isEqualTo("bar");
        assertThat(foo.baz()).isEqualTo("baz");

        assertThat(clazz).isEqualTo(Foo.class);

ちなみにこのテストコードをmvn testで実行すると

$ mvn test

以下のように警告されます。

[INFO] Running org.littlewings.bytebyddy.ByteBuddyTest
WARNING: A Java agent has been loaded dynamically ($HOME/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.3/byte-buddy-agent-1.18.3.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release

このままでも動作しますが、気になる場合はメッセージのとおり-XX:+EnableDynamicAgentLoadingを追加しましょう。

$ mvn test -DargLine='-XX:+EnableDynamicAgentLoading'

ByteBuddyAgentを使ったクラスのredefineで驚くところは、なんとクラスのredefine前にインスタンス化した
ものにも適用されることです。

    @Test
    void redefine() throws ReflectiveOperationException {
        ByteBuddyAgent.install();

        Foo beforeFoo = new Foo();

        DynamicType.Unloaded<? extends Foo> dynamicType = new ByteBuddy()
                .redefine(Foo.class)
                .method(ElementMatchers.named("bar"))
                .intercept(FixedValue.value("hello"))
                .make();

        assertThatThrownBy(() -> dynamicType.load(ClassLoadingStrategy.BOOTSTRAP_LOADER, ClassReloadingStrategy.fromInstalledAgent()))
                .isExactlyInstanceOf(IllegalStateException.class)
                .hasMessage("Bootstrap injection is not enabled");

        Class<? extends Foo> clazz = dynamicType.load(getClass().getClassLoader(), ClassReloadingStrategy.fromInstalledAgent()).getLoaded();

        assertThat(clazz.getName()).isEqualTo(Foo.class.getName());
        assertThat(clazz).isEqualTo(Foo.class);

        Foo foo = clazz.getConstructor().newInstance();
        assertThat(foo.bar()).isEqualTo("hello");
        assertThat(foo.baz()).isEqualTo("baz");

        assertThat(beforeFoo.bar()).isEqualTo("hello");
        assertThat(beforeFoo.baz()).isEqualTo("baz");
    }

ただ、この方法でredefineできるといっても、メソッドやフィールドを追加することはできません。クラスの構造自体を
変更することはできないということですね。

    @Test
    void cannotRedefine() throws ReflectiveOperationException {
        ByteBuddyAgent.install();

        DynamicType.Unloaded<? extends Foo> dynamicType = new ByteBuddy()
                .redefine(Foo.class)
                .defineMethod("method1", String.class, Visibility.PUBLIC)
                .intercept(FixedValue.value("test"))
                .make();

        assertThatThrownBy(() -> dynamicType.load(getClass().getClassLoader(), ClassReloadingStrategy.fromInstalledAgent()))
                .isExactlyInstanceOf(UnsupportedOperationException.class)
                .hasMessage("class redefinition failed: attempted to add a method");
    }

ここまでのクラスローダーやクラスの再読み込みに関する話は、こちらの「Loading a class」や「Reloading a class」に
書かれています。

Why runtime code generation? / Creating a class

ちなみにredefineではうまくいったメソッドの再定義も、rebaseではうまくいきません。

    @Test
    void cannotRebase() {
        ByteBuddyAgent.install();

        DynamicType.Unloaded<? extends Foo> dynamicType = new ByteBuddy()
                .rebase(Foo.class)
                .method(ElementMatchers.named("bar"))
                .intercept(FixedValue.value("hello"))
                .make();

        // オリジナルのメソッドが残る(メソッドが増えて、クラスの構造が変わる)ため
        assertThatThrownBy(() -> dynamicType.load(getClass().getClassLoader(), ClassReloadingStrategy.fromInstalledAgent()))
                .isExactlyInstanceOf(UnsupportedOperationException.class)
                .hasMessage("class redefinition failed: attempted to add a method");
    }

これはrebaseがもとの実装を残すため、結果としてクラスの構造が変わるからですね。

このあたりをなんとかしたかったら、本格的にJava agentを使うことになるのだと思います。

今回はここまでにしましょう。

おわりに

Byte Buddyでクラスを再定義(redefine)してみました。

最初にByte Buddyのエントリーを書いた時はこのあたりも含めようとして収集がつかなくなったので、
サブクラスのみにとどめておきました。

次はJava agentかAdviceでしょうか…。