본문 바로가기

기술스택/ASM - [Bytecode]

[A Java bytecode engineering library] - [Core API] 3. Method[2/3]

목차

    개인 요약 및 정리

    Overview

    3장 2절은 클래스 멤버(필드, 메서드 등)의 변형, 추가, 제거 방법을 다룬다. 이 과정에서 ClassVisitor와 MethodVisitor 인터페이스의 활용이 중요하다. 여기서는 간단한 변형부터 복잡한 변형까지 다양한 예시를 통해 ASM 라이브러리의 활용 방법을 설명한다.

     

    멤버 제거

    • 필드와 메서드 제거: 특정 조건에 부합하는 필드나 메서드를 찾아 제거합니다. 이를 위해 visitField와 visitMethod 메서드를 오버라이드하고, 조건에 맞지 않는 경우 해당 호출을 전달하지 않습니다(null 반환).

    멤버 추가

    • 필드와 메서드 추가: 새로운 필드나 메서드를 추가하려면, visitField나 visitMethod를 적절한 시점에 호출합니다. 특히, visitEnd 메서드 내에서 추가 작업을 수행하면 클래스의 모든 멤버를 방문한 후 추가 작업을 할 수 있어, 중복을 방지하고 안정성을 높일 수 있습니다.

    변형 체인

    • 변형 체인: 여러 ClassVisitor를 연쇄적으로 연결하여 복잡한 클래스 변형을 구현할 수 있습니다. 각 변형 단계는 독립적으로 작동하며, 이를 통해 여러 변형을 조합하고 순차적으로 적용할 수 있습니다.

    3.2.1. Presentation

     MethodVisitor 추상 클래스의 역할과 이를 통해 메서드의 바이트코드를 어떻게 조작할 수 있는지에 대한 개념.

    MethodVisitor ClassVisitor visitMethod 메서드에 의해 반환되며, 주석, 디버그 정보, 바이트코드 명령어 등 메서드 관련 정보들을 처리하는 메서드를 제공한다.

     

    메서드는 특정 명령어 카테고리에 대응되며, 특정 순서에 따라 호출되어야 한다.

    < 순서 >

    1. 주석, 속성 먼저 방문한다. : 메서드의 바이트코드를 다루기 전에 메서드와 관련된 메타데이터(주석, 속성 등)를 처리해야 한다는 것

    2. 이후 바이트코드 방문

     

    코드는 visitCode visitMaxs 사이에서 정확히 한 번 호출되어야 한다.

     

    ClassWriter 옵션

    메서드의 스택과 프레임 용량 계산 방법
    1. new ClassWriter(0) : 프레임과 로컬 변수, 오퍼핸드 스택 크기를 직접 계산해야 한다. 
    2. new ClassWriter(ClassWriter.COMPUTE_MAXS) : 로컬 변수와 스택 크기를 자동 계산. 프레임은 직접 계산.
    3. new ClassWriter(ClassWriter.COMPUTE_FRAMES) : 모든 것이 자동으로 계산

    자동이 편하기는 하지만, 오버헤드와 cost가 발생한다. 간단한 부분은 ClassWriter를 통해 프레임을 직접 계산하는 과정이 더 나은 효율을 제공할 것.

     

    예제 풀이 및 이해

     

    https://csg1353.tistory.com/184

     

    [ASM 바이트코드 작성] if문과 예외를 포함한 메서드 추가 예제(중요)

    예제 코드 package org.agent.util.asm.testcode.chap03; import org.objectweb.asm.*; import static org.objectweb.asm.Opcodes.*; public class SetF extends ClassVisitor { public SetF(ClassVisitor classVisitor) { super(Opcodes.ASM9, classVisitor); } @Overrid

    csg1353.tistory.com

     

    Stateless transformations(Adapter의 Adapter Chain)

     

    RemoveNopAdapter RemoveNopClassAdapter 예제는 메서드 방문자(MethodVisitor)에서 특정 메서드를 대상으로 그 메서드에만 적용되는 특수한 로직을 구현하기 위해 어댑터를 사용하는 방법을 보여준다.

    이 예제에서는 메서드 바이트코드 중에서 아무런 작업도 하지 않는 NOP(No Operation) 명령어를 제거하는 것을 목적으로 하고 있다.

     

    간단한 Adapter 예제

    https://csg1353.tistory.com/188

     

    [ASM]]MethodVisitor를 통해 특정 메서드 변조하기

    나는 m의 메서드를 찾아, 여기 sysout을 통해 로깅을 붙이고 싶다..! 바로 코드를 보자. (A)의 경우 정상적인 동작을 하고, (B)의 경우 잘못된 코드이다. public class WrappingMethodVisitor extends ClassVisitor { pub

    csg1353.tistory.com

     

    AddTimerAdapter Timer 추가하는 예제(Class C)

    ClassVisitor Override한 MethodVisitor가 모든 메서드 알아서를 순회하듯, visitInsn를 Override해서 재정의하면 마찬가지로 Opcodes가 사용될때 알아서 호출된다.

     

    public class AddTimerAdapter extends ClassVisitor {
        private String owner;
        private boolean isInterface;
        public AddTimerAdapter(ClassVisitor cv) {
            super(ASM9, cv);
        }
    
        //Class 및 인터페이스 방문 시 호출, 속성에 대한 정보를 가져온다.
        @Override public void visit(int version, int access, String name,
                                    String signature, String superName, String[] interfaces) {
            cv.visit(version, access, name, signature, superName, interfaces);
            owner = name; //1. visit name
            isInterface = (access & ACC_INTERFACE) != 0; //2. 인터페이스 여부
    
            System.out.println("VISIT TEST : " + name);
        }
    
    
        @Override public MethodVisitor visitMethod(int access, String name,
                                                   String desc, String signature, String[] exceptions) {
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                    exceptions);
    
            //인터페이스가 아니고, init(생성자)가 아닌경우 적용. 즉, 생성자가 아닌 Class 내부 메서드에 적용
            if (!isInterface && mv != null && !name.equals("<init>")) {
                mv = new AddTimerMethodAdapter(mv);
            }
            return mv;
        }
    
        @Override public void visitEnd() {
    
            //public static timer 전역변수
            if (!isInterface) {
                FieldVisitor fv = cv.visitField(ACC_PUBLIC + ACC_STATIC, "timer",
                        "J", null, null);
                if (fv != null) {
                    fv.visitEnd();
                }
            }
            cv.visitEnd();
        }
    
        class AddTimerMethodAdapter extends MethodVisitor {
            public AddTimerMethodAdapter(MethodVisitor mv) {
                super(ASM9, mv);
            }
            @Override public void visitCode() {
                mv.visitCode();
                mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
                mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
                        "currentTimeMillis", "()J");
                mv.visitInsn(LSUB);
                mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
            }
    
            //1. visitInsn 메서드는 메서드 내의 모든 인스트럭션(opcode)을 순회할 때 ASM 프레임워크에 의해 자동으로 호출된다.
            //2. 즉, opcode가 IRETURN부터 RETURN 사이, 또는 ATHROW일 때 이 로직이 적용된다.
            @Override public void visitInsn(int opcode) {
                if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
                    mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
                    mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
                            "currentTimeMillis", "()J");
                    mv.visitInsn(LADD);
                    mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
                }
                mv.visitInsn(opcode);
            }
            @Override public void visitMaxs(int maxStack, int maxLocals) {
                mv.visitMaxs(maxStack + 4, maxLocals);
            }
        }
    }

     

    Statefull transformations

    Stateful Transformations 챕터에서는 바이트코드 변환 작업 중 상태를 유지하면서 변환을 수행하는 방법에 대해 설명한다.

     

    1. 변환 과정에서 이전에 방문한 노드(라벨이나 프레임 정보)의 상태를 기억하고, 이를 바탕으로 후속 작업 진행

    2. 예를 들어, 라벨이나 프레임 정보의 갱신을 통해 메서드 내부에서의 점프 명령어 처리 / 로컬 변수의 스코프 관리 등이 이에 해당.

     

    Stateful transformations는 복잡한 바이트코드 변환 작업을 수행할 때 중요한 개념이다.

    예를 들어, 메서드의 실행 흐름을 변경하거나 추가적인 코드를 삽입하면서 기존의 라벨이나 프레임 정보를 올바르게 유지 관리해야 할 필요가 있다. 이를 통해 변환된 바이트코드가 여전히 유효하고 실행 가능한 상태를 유지할 수 있다.

     

    상태 기반 변환은 변환 과정에서 중간 상태를 추적하고 관리해야 하므로, 단순 변환보다 더 세심한 주의와 정밀한 구현을 요구한다. 이를 위해 ASM은 다양한 방문자(Visitor)와 어댑터(Adapter)를 제공하여, 개발자가 상태 정보를 쉽게 관리하고 필요한 변환 작업을 효율적으로 수행할 수 있도록 지원한다.

     

    PatternMethodAdapter

    /**
     * 추상 클래스
     * 1. 각 메서드 내 바이트코드 명령이 실행될 때 해당 명령에 해당하는 visitXXXInsn 메서드 호출
     * 2. 방문한 메서드는 visitInsn() 호출
     */
    
    public abstract class PatternMethodAdapter extends MethodVisitor {
        protected final static int SEEN_NOTHING = 0; //패턴 인식 전 초기 상태
        protected int state; //상태 변수(패턴 상태 추척)
    
        public PatternMethodAdapter(int api, MethodVisitor mv) {
            super(api, mv);
        }
    
        @Override
        public void visitInsn(int opcode) {
            visitInsn();
            mv.visitInsn(opcode);
        }
    
        @Override
        public void visitIntInsn(int opcode, int operand) {
            visitInsn();
            mv.visitIntInsn(opcode, operand);
        }
    
        protected abstract void visitInsn();
    }

     

    RemoveAddZeroAdapter

    //PatternMethodAdapter 구현체이자, 특정 MethodVisitor에서 호출될 수 있는 Adapter Class
    /*
    주된 목적 : ICONST_0 다음에 오는 IADD 인스트럭션을 최적화하여 제거하는 것
    ICONST_0은 스택에 정수 0을 푸시하고, IADD는 스택의 상위 두 정수를 더하는 연산.
    만약 IADD 연산 전에 스택에 푸시된 값이 0이라면, 이 연산은 실질적으로 아무런 변화도 주지 않는다(0 + x = x).
    따라서 이러한 패턴을 감지하고 불필요한 IADD 연산을 제거함으로써 실행 시간과 바이트코드의 크기를 최적화할 수 있다.
     */
    public class RemoveAddZeroAdapter extends PatternMethodAdapter {
        private static int SEEN_ICONST_0 = 1; //기본 상태 : 1
        public RemoveAddZeroAdapter(MethodVisitor mv) {
            super(ASM9, mv);
        }
    
        //MethodVisitor 구현체 : 메서드 바이트코드의 각 인스트럭션(opcode)을 방문할 때마다 호출
        @Override public void visitInsn(int opcode) {
            if (state == SEEN_ICONST_0) { //기본 상태일 경우
                if (opcode == IADD) { //그리고 IADD opcodes 요청일 경우
                    state = SEEN_NOTHING; //아무것도 하지 않음 (0 상태 변경)
                    return;
                }
            }
            visitInsn(); //(A) 구현체 호출
            if (opcode == ICONST_0) {  //ICONST_0 opcode일 경우
                state = SEEN_ICONST_0; //기본 상태로 변경
                return;
            }
            mv.visitInsn(opcode);
        }
    
        //PatternMethodAdapter - visitInsn 추상 메서드 구현체
        //위처럼 자동으로 호출되는 것이 아니라, 이 메서드가 필요할 때 호출해서 쓴다. (a)의 경우처럼
        @Override protected void visitInsn() {
            if (state == SEEN_ICONST_0) {
                mv.visitInsn(ICONST_0); //상태가 0이라면 스택에 0 저장
            }
            state = SEEN_NOTHING;
        }
    }

     

     

     

    원문 번역본

    3.2. Interfaces and components

    3.2.1. Presentation

    컴파일된 메서드를 생성하고 변환하기 위한 ASM API는 ClassVisitor의 visitMethod 메서드에 의해 반환되는 MethodVisitor 추상 클래스를 기반으로 한다(그림 3.4 참조). 이 클래스는 다음 장에서 설명하는 주석과 디버그 정보와 관련된 몇 가지 메서드 외에도, 이러한 지시문의 인수의 수와 유형에 기반한 바이트코드 지시문 카테고리당 하나의 메서드를 정의한다(이 카테고리는 3.1.2절에서 제시된 것과 일치하지 않는다). 이러한 메서드는 다음 순서로 호출되어야 한다(일부 추가 제약 사항은 MethodVisitor 인터페이스의 Javadoc에 명시되어 있다):

     

     

    이는 주석과 속성(있는 경우)이 먼저 방문되어야 하며, 추상 메서드가 아닌 메서드의 경우 메서드의 바이트코드가 뒤따라야 함을 의미한다. 이러한 메서드의 경우 코드는 visitCode와 visitMaxs 사이에 정확히 한 번씩 호출되어야 한다.

     

    Figure 3.4.: The MethodVisitor class

     

    따라서 visitCode와 visitMaxs 메서드는 이벤트 시퀀스에서 메서드의 바이트코드 시작과 끝을 감지하는 데 사용될 수 있다. 클래스의 경우와 마찬가지로 visitEnd 메서드는 마지막에 호출되어야 하며, 이벤트 시퀀스에서 메서드의 끝을 감지하는 데 사용된다. ClassVisitor와 MethodVisitor 클래스는 완전한 클래스를 생성하기 위해 결합될 수 있다:

     

     

    하나의 메서드를 마치기 위해 다른 하나를 방문하기 시작할 필요는 없다. 실제로 MethodVisitor 인스턴스는 완전히 독립적이며 cv.visitEnd()가 호출되지 않은 한 어떤 순서로든 사용될 수 있다.

     

     

     

    ASM은 MethodVisitor API를 기반으로 한 세 가지 핵심 컴포넌트를 제공하여 메서드를 생성하고 변환한다:

    • ClassReader 클래스는 컴파일된 메서드의 내용을 파싱하고 인자로 전달된 ClassVisitor에 의해 반환된 MethodVisitor 객체에 해당 메서드를 호출한다.
    • ClassWriter의 visitMethod 메서드는 바이너리 형태로 직접 컴파일된 메서드를 구축하는 MethodVisitor 인터페이스의 구현을 반환한다.
    • MethodVisitor 클래스는 받은 모든 메서드 호출을 다른 MethodVisitor 인스턴스에 위임한다. 이는 이벤트 필터로 볼 수 있다.

     

    ClassWriter 옵션

     

    3.1.5절에서 본 바와 같이, 메서드의 스택 맵 프레임을 계산하는 것은 매우 쉽지 않다.

    모든 프레임을 계산하고, 점프 대상에 해당하거나 무조건적인 점프를 따르는 프레임을 찾은 다음, 이 남은 프레임을 압축해야 한다. 마찬가지로, 메서드의 로컬 변수와 피연산자 스택 부분의 크기를 계산하는 것은 더 쉽지만 여전히 매우 쉽지 않다. 다행히도 ASM은 이를 대신 계산할 수 있다. ClassWriter를 생성할 때 자동으로 계산해야 할 것을 지정할 수 있다:

     

    • new ClassWriter(0)으로는 아무것도 자동으로 계산되지 않는다. 프레임과 로컬 변수 및 피연산자(operands) 스택 크기를 직접 계산해야 한다.
    • new ClassWriter(ClassWriter.COMPUTE_MAXS)로는 로컬 변수와 피연산자 스택 부분의 크기가 대신 계산된다. visitMaxs를 호출해야 하지만, 어떤 인자를 사용해도 무시되고 재계산된다. 이 옵션을 사용하면 여전히 프레임을 직접 계산해야 한다.
    • new ClassWriter(ClassWriter.COMPUTE_FRAMES)로는 모든 것이 자동으로 계산된다. visitFrame을 호출할 필요는 없지만, visitMaxs를 호출해야 한다(인자는 무시되고 재계산된다).

    이러한 옵션을 사용하는 것은 편리하지만 비용이 발생한다:

    COMPUTE_MAXS 옵션은 ClassWriter를 10% 느리게 만들고, COMPUTE_FRAMES 옵션을 사용하면 두 배 더 느려진다. 이는 직접 계산하는 데 걸리는 시간과 비교해야 한다: 특정 상황에서는 ASM에서 사용된 알고리즘에 비해 모든 경우를 처리해야 하는 알고리즘에 비해 종종 더 쉽고 빠른 알고리즘이 있다.

     

    프레임을 직접 계산하기로 선택한 경우, 압축 단계를 ClassWriter 클래스가 대신 처리하도록 할 수 있다. 이를 위해서는 visitFrame(F_NEW, nLocals, locals, nStack, stack)을 사용해 압축되지 않은 프레임을 방문하기만 하면 되는데, 여기서 nLocals와 nStack은 로컬 및 피연산자 스택 크기이며, locals와 stack은 해당 유형을 포함하는 배열이다(Javadoc에서 더 자세한 내용을 참조).

     

    또한 프레임을 자동으로 계산하기 위해서는 때때로 두 주어진 클래스의 공통 슈퍼 클래스를 계산해야 할 수도 있다. 기본적으로 ClassWriter 클래스는 getCommonSuperClass 메서드에서 JVM으로 두 클래스를 로딩하고 리플렉션 API를 사용하여 이를 계산한다. 서로를 참조하는 여러 클래스를 생성하는 경우 문제가 될 수 있는데, 참조된 클래스가 아직 존재하지 않을 수 있기 때문이다. 이 경우 getCommonSuperClass 메서드를 오버라이드하여 이 문제를 해결할 수 있다.

     

    3.2.2. Generating methods

    3.1.3절에서 정의된 getF 메서드의 바이트코드는 mv가 MethodVisitor일 경우 다음 메서드 호출로 생성될 수 있다.

     

     

    첫 번째 호출은 바이트코드 생성을 시작한다. 이어서 이 메서드의 세 가지 지시문을 생성하는 세 번의 호출이 뒤따른다(보다시피 바이트코드와 ASM API 간의 매핑은 꽤 단순하다). visitMaxs 호출은 모든 지시문이 방문된 후에 이루어져야 하며, 이 메서드의 실행 프레임에 대한 로컬 변수와 피연산자 스택 부분의 크기를 정의하는 데 사용된다. 3.1.3절에서 보았듯이, 이 크기는 각 부분마다 1 슬롯이다. 마지막으로 마지막 호출은 메서드 생성을 종료하는 데 사용된다.

     

    setF 메서드와 생성자의 바이트코드는 유사한 방식으로 생성될 수 있다. checkAndSetF가 더 흥미로운 예시일 것이다.

     

    mv.visitCode(); // 메서드의 시작을 나타냅니다.
    mv.visitVarInsn(ILOAD, 1); // 첫 번째 파라미터 값을 로컬 변수에서 로드합니다. (0은 this, 1은 첫 번째 파라미터)
    Label label = new Label(); // 음수일 경우로 점프할 레이블을 생성합니다.
    mv.visitJumpInsn(IFLT, label); // 값이 음수인지 확인하고, 음수라면 'label'로 점프합니다.
    mv.visitVarInsn(ALOAD, 0); // 'this' 참조를 로드합니다.
    mv.visitVarInsn(ILOAD, 1); // 다시 첫 번째 파라미터 값을 로드합니다.
    mv.visitFieldInsn(PUTFIELD, "pkg/Bean", "f", "I"); // 첫 번째 파라미터의 값을 'f' 필드에 저장합니다.
    Label end = new Label(); // 메서드의 종료 부분으로 점프할 레이블을 생성합니다.
    mv.visitJumpInsn(GOTO, end); // 'end' 레이블로 무조건 점프합니다.
    mv.visitLabel(label); // 'label' 레이블 위치입니다. 여기서 부터는 음수일 때 실행됩니다.
    mv.visitFrame(F_SAME, 0, null, 0, null); // 현재 스택과 로컬 변수의 상태를 JVM에 알립니다.
    mv.visitTypeInsn(NEW, "java/lang/IllegalArgumentException"); // `IllegalArgumentException` 인스턴스를 생성합니다.
    mv.visitInsn(DUP); // 생성자 호출을 위해 인스턴스의 복사본을 스택에 둡니다.
    mv.visitMethodInsn(INVOKESPECIAL, "java/lang/IllegalArgumentException", "<init>", "()V"); // 예외 객체의 생성자를 호출합니다.
    mv.visitInsn(ATHROW); // 예외를 던집니다.
    mv.visitLabel(end); // 'end' 레이블 위치입니다. 여기서 부터는 정상적인 종료 부분입니다.
    mv.visitFrame(F_SAME, 0, null, 0, null); // 현재 스택과 로컬 변수의 상태를 JVM에 알립니다.
    mv.visitInsn(RETURN); // 메서드를 종료합니다.
    mv.visitMaxs(2, 2); // 스택의 최대 깊이와 로컬 변수의 최대 수를 설정합니다.
    mv.visitEnd(); // 메서드의 끝을 나타냅니다.

     

    visitCode와 visitEnd 호출 사이에서는 3.1.5절의 끝에서 보여준 바이트코드에 정확히 매핑되는 메서드 호출을 볼 수 있다: 지시문, 라벨 또는 프레임당 하나의 호출(라벨과 end Label 객체의 선언 및 구성만이 예외).

     

    주의: Label 객체는 이 라벨에 대한 visitLabel 호출이 뒤따르는 지시문을 지정한다.

    예를 들어, end는 RETURN 지시문을 지정하고, 이후에 방문되는 프레임이 아니므로, 이는 지시문이 아니다. 동일한 지시문을 지정하는 여러 라벨을 가질 수는 있지만, 라벨은 정확히 하나의 지시문을 지정해야 한다.

     

    다시 말해, 서로 다른 라벨로 visitLabel을 연속해서 호출하는 것은 가능하지만, 지시문에서 사용된 라벨은 visitLabel로 정확히 한 번 방문되어야 한다. 마지막 제약 사항은 라벨을 공유할 수 없다는 것이다: 각 메서드는 자신의 라벨을 가져야 한다.

     

     

    3.2.3. Transforming methods (메서드 변환)

     

    이제 클래스처럼 메서드도 변환될 수 있다는 것을 추측했을 것이다.

    즉, 수신한 메서드 호출을 일부 수정하여 전달하는 메서드 어댑터를 사용함으로써 가능하다: 인수를 변경하는 것은 개별 지시문을 변경하는 데 사용될 수 있으며, 수신한 호출을 전달하지 않으면 지시문이 제거되고, 수신한 호출 사이에 호출을 삽입하면 새로운 지시문이 추가된다.

    MethodVisitor 클래스는 이러한 메서드 어댑터의 기본 구현을 제공하는데, 이는 수신한 모든 메서드 호출을 단순히 전달하는 것 외에는 아무것도 하지 않는다. 메서드 어댑터가 어떻게 사용될 수 있는지 이해하기 위해, 메서드 내의 NOP 지시문을 제거하는 매우 간단한 어댑터를 고려해 보자(이들은 아무 것도 하지 않기 때문에 문제 없이 제거될 수 있다):

     

    public class RemoveNopAdapter extends MethodVisitor {
        public RemoveNopAdapter(MethodVisitor mv) {
            super(ASM4, mv);
            }
        @Override
        public void visitInsn(int opcode) {
            if (opcode != NOP) {
              mv.visitInsn(opcode);
            }
        }
    }

     

    이 어댑터는 다음과 같이 클래스 어댑터 내에서 사용될 수 있다.

    public class RemoveNopClassAdapter extends ClassVisitor {
        public RemoveNopClassAdapter(ClassVisitor cv) {
            super(ASM4, cv);
        }
    
        @Override
        public MethodVisitor visitMethod(int access, String name,
                                         String desc, String signature, String[] exceptions) {
            MethodVisitor mv;
            mv = cv.visitMethod(access, name, desc, signature, exceptions);
            if (mv != null) {
                mv = new RemoveNopAdapter(mv);
            }
            return mv;
        }
    }

     

    다시 말해, 클래스 어댑터는 체인에서 다음 클래스 방문자에 의해 반환된 메서드 방문자를 캡슐화하는 메서드 어댑터를 구성하고 이 어댑터를 반환한다.

    효과는 클래스 어댑터 체인과 유사한 메서드 어댑터 체인의 구성이다(그림 3.5 참조).

     

    Figure 3.5.: Sequence diagram for the RemoveNopAdapter

     

    그러나 이는 필수적인 것은 아니다: 클래스 어댑터 체인과 유사하지 않은 메서드 어댑터 체인을 구축하는 것이 완벽하게 가능하다. 각 메서드는 심지어 서로 다른 메서드 어댑터 체인을 가질 수 있다. 예를 들어, 클래스 어댑터는 생성자가 아닌 메서드에서만 NOP를 제거하기로 결정할 수 있다. 이는 다음과 같이 수행될 수 있다:

     

    ...
    mv = cv.visitMethod(access, name, desc, signature, exceptions);
    if (mv != null && !name.equals("<init>")) {
    mv = new RemoveNopAdapter(mv);
    }
    ...

     

    이 경우 생성자에 대한 어댑터 체인은 더 짧다. 반대로, 생성자에 대한 어댑터 체인은 visitMethod 내에서 생성된 여러 메서드 어댑터가 연결된 더 긴 체인일 수 있었다. 메서드 어댑터 체인은 클래스 어댑터 체인과 다른 토폴로지를 가질 수도 있다. 예를 들어, 클래스 어댑터 체인이 선형일 수 있는 반면, 메서드 어댑터 체인에는 분기가 있을 수 있다:

     

     

    이제 클래스 어댑터 내에서 메서드 어댑터를 사용하고 결합하는 방법을 보았으니, RemoveNopAdapter보다 더 흥미로운 어댑터를 구현하는 방법을 살펴보자.

     

    3.2.4. Stateless transformations (상태 없는 변환)

    프로그램의 각 클래스에서 소요되는 시간을 측정하고 싶다고 가정해 보자. 각 클래스에 정적 타이머 필드를 추가해야 하며, 이 클래스의 각 메서드 실행 시간을 이 타이머 필드에 추가해야 한다. 즉, C와 같은 클래스를 다음과 같이 변환하고 싶다.

     

    public class C {
        public void m() throws Exception {
            Thread.sleep(100);
        }
    }

     

    이 클래스를 이렇게..

    public class C {
        public static long timer;
        public void m() throws Exception {
            timer -= System.currentTimeMillis();
            Thread.sleep(100);
            timer += System.currentTimeMillis();
        }
    }

     

    이를 ASM에서 어떻게 구현할 수 있는지 알아보기 위해, 이 두 클래스를 컴파일하고 이 두 버전에 대해 TraceClassVisitor의 출력을 비교할 수 있다(기본 Textifier 백엔드 또는 ASMifier 백엔드로). 기본 백엔드를 사용하면 다음과 같은 차이점이 나타난다(굵게 표시):

     

     

    메서드 시작 부분에 네 가지 지시문을 추가해야 하며, return 지시문 전에 다른 네 가지 지시문을 추가해야 한다. 또한 최대 operand stack 크기를 업데이트해야 한다. 메서드의 코드 시작 부분은 visitCode 메서드로 방문된다. 따라서 이 메서드를 오버라이딩하여 처음 네 가지 지시문을 추가할 수 있다.

     

     

    이제 모든 RETURN, 그리고 모든 xRETURN 또는 ATHROW 앞에 네 가지 다른 지시문을 추가해야 하는데, 이는 모두 메서드 실행을 종료하는 지시문이다. 이러한 지시문은 인수가 없으므로 visitInsn 메서드에서 방문된다. 그러므로 이 메서드를 오버라이딩하여 우리의 지시문을 추가할 수 있다.

     

    마지막으로 최대 operand stack 크기를 업데이트해야 한다.

    추가한 지시문은 두 개의 long 값을 푸시하므로 operand stack에 네 개의 슬롯이 필요하다. 메서드의 시작에서 operand stack은 처음에 비어 있으므로, 시작 부분에 추가된 네 가지 지시문은 크기가 4인 스택을 필요로 한다는 것을 알 수 있다. 또한 삽입된 코드는 푸시한 만큼의 값을 팝하기 때문에 스택 상태를 변경하지 않는다(팝하는 값만큼 푸시하기 때문). 결과적으로, 원래 코드가 크기 s의 스택을 필요로 하는 경우, 변환된 메서드에 필요한 최대 스택 크기는 max(4, s)이다. 안타깝게도 return 지시문 앞에 네 가지 지시문을 추가하며, 여기서는 이 지시문들 바로 앞의 operand stack 크기를 알 수 없다. 그저 이것이 s 이하임을 알 뿐이다. 결과적으로, return 지시문 앞에 추가된 코드는 최대 s + 4 크기의 operand stack을 필요로 할 수 있다고 말할 수 있다. 이 최악의 시나리오는 실제로는 드물게 발생한다: 일반적인 컴파일러에서 RETURN 전의 operand stack은 반환 값만 포함하므로, 크기가 최대 0, 1, 또는 2이다. 그러나 모든 가능한 경우를 처리하려면 최악의 시나리오를 사용해야 한다(다행히도 최적의 operand stack 크기를 제공할 필요는 없다. 이 최적 값보다 크거나 같은 값을 제공하는 것이 가능하긴 하지만, 이는 스레드의 실행 스택에 메모리를 낭비할 수 있다). 그러면 visitMaxs 메서드를 다음과 같이 오버라이드해야 한다.

     

     

    물론 최대 스택 크기에 대해 신경 쓰지 않고 COMPUTE_MAXS 옵션을 사용하여 최적의 값이 아닌 최악의 경우 값까지 계산하게 할 수도 있다. 그러나 이러한 간단한 변환의 경우 maxStack을 수동으로 업데이트하는 것이 큰 노력이 들지 않는다.

    .

     

    이제 궁금증이 하나 더 생길 수도 있을 것이다. 스택 맵 프레임은 어떨까?

    원래 코드에는 프레임이 포함되어 있지 않았고 변환된 코드에도 포함되어 있지 않지만, 이는 우리가 사용한 특정 코드 때문인가? 프레임을 업데이트해야 하는 상황이 있나? 답은 '그렇지 않다' 이다. 왜냐하면,

     

    1. 삽입된 코드는 operand stack을 변경하지 않으며,

    2. 삽입된 코드에는 점프 지시문이 포함되어 있지 않으며,

    3. 원래 코드의 점프 지시문 - 또는 보다 공식적으로, 제어 흐름 그래프 - 가 수정되지 않기 때문이다. 이는 원래 프레임이 변경되지 않으며, 삽입된 코드에 대해 저장해야 할 새로운 프레임이 없기 때문에 압축된 원래 프레임도 변경되지 않음을 의미한다.

     

    이제 ClassVisitor 및 MethodVisitor 하위 클래스에서 모든 요소를 함께 놓을 수 있다.

     

    public class AddTimerAdapter extends ClassVisitor {
        private String owner;
        private boolean isInterface;
        public AddTimerAdapter(ClassVisitor cv) {
            super(ASM4, cv);
        }
        @Override public void visit(int version, int access, String name,
                                    String signature, String superName, String[] interfaces) {
            cv.visit(version, access, name, signature, superName, interfaces);
            owner = name;
            isInterface = (access & ACC_INTERFACE) != 0;
        }
        @Override public MethodVisitor visitMethod(int access, String name,
                                                   String desc, String signature, String[] exceptions) {
            MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                    exceptions);
            if (!isInterface && mv != null && !name.equals("<init>")) {
                mv = new AddTimerMethodAdapter(mv);
            }
            return mv;
        }
        @Override public void visitEnd() {
            if (!isInterface) {
                FieldVisitor fv = cv.visitField(ACC_PUBLIC + ACC_STATIC, "timer",
                        "J", null, null);
                if (fv != null) {
                    fv.visitEnd();
                }
            }
            cv.visitEnd();
        }
        class AddTimerMethodAdapter extends MethodVisitor {
            public AddTimerMethodAdapter(MethodVisitor mv) {
                super(ASM4, mv);
            }
            @Override public void visitCode() {
                mv.visitCode();
                mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
                mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
                        "currentTimeMillis", "()J");
                mv.visitInsn(LSUB);
                mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
            }
            @Override public void visitInsn(int opcode) {
                if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
                    mv.visitFieldInsn(GETSTATIC, owner, "timer", "J");
                    mv.visitMethodInsn(INVOKESTATIC, "java/lang/System",
                            "currentTimeMillis", "()J");
                    mv.visitInsn(LADD);
                    mv.visitFieldInsn(PUTSTATIC, owner, "timer", "J");
                }
                mv.visitInsn(opcode);
            }
            @Override public void visitMaxs(int maxStack, int maxLocals) {
                mv.visitMaxs(maxStack + 4, maxLocals);
            }
        }
    }

     

    클래스 어댑터는 생성자를 제외한 메서드 어댑터(메서드 방문자)를 인스턴스화하는 데 사용되며, 타이머 필드를 추가하고 변환 중인 클래스의 이름을 메서드 어댑터에서 액세스할 수 있는 필드에 저장하는 데도 사용된다.

     

    3.2.5. Statefull transformations (상태 있는 변환)

     

    이전 섹션에서 본 변환은 로컬이며 현재 지시문 이전에 방문한 지시문에 의존하지 않는다.

    시작 부분에 추가된 코드는 항상 같고 항상 추가되며, 마찬가지로 각 RETURN 지시문 앞에 삽입된 코드도 마찬가지다. 이러한 변환을 상태 없는 변환(stateless transformations)이라고 한다.

     

    이들은 구현하기 쉽지만 가장 간단한 변환만이 이 속성을 검증한다. 보다 복잡한 변환은 현재 지시문 이전에 방문한 지시문에 대한 상태를 기억하는 것을 요구한다.

     

    예를 들어, ICONST_0 IADD 시퀀스의 모든 발생을 제거하는 변환을 고려해 보자.

    이 시퀀스는 0을 추가하는 빈 효과를 가진다. IADD 지시문이 방문될 때, 마지막으로 방문한 지시문이 ICONST_0이었을 때만 제거된다. 이를 위해 메서드 어댑터 내에 상태를 저장해야 한다. 이러한 이유로 이러한 변환을 상태 있는 변환(statefull transformations)이라고 한다.

     

    이 예시를 좀 더 자세히 살펴보자.

    ICONST_0이 방문될 때, 다음 지시문이 IADD인 경우에만 제거해야 한다. 문제는 다음 지시문이 아직 알려지지 않았다는 것이다. 해결책은 이 결정을 다음 지시문으로 연기하는 것이다: 만약 이것이 IADD라면 두 지시문을 모두 제거하고, 그렇지 않으면 ICONST_0과 현재 지시문을 발생시킨다. 어떤 지시문 시퀀스를 제거하거나 대체하는 변환을 구현하기 위해, visitXxx Insn 메서드가 공통 visitInsn() 메서드를 호출하는 MethodVisitor 하위 클래스를 도입하는 것이 편리하다.

    public abstract class PatternMethodAdapter extends MethodVisitor {
        protected final static int SEEN_NOTHING = 0;
        protected int state;
        public PatternMethodAdapter(int api, MethodVisitor mv) {
            super(api, mv);
        }
        @Overrid public void visitInsn(int opcode) {
            visitInsn();
            mv.visitInsn(opcode);
        }
        @Override public void visitIntInsn(int opcode, int operand) {
            visitInsn();
            mv.visitIntInsn(opcode, operand);
        }
    ...
        protected abstract void visitInsn();
    }

     

    그러면 위의 변환은 다음과 같이 구현될 수 있다.

     

    public class RemoveAddZeroAdapter extends PatternMethodAdapter {
        private static int SEEN_ICONST_0 = 1;
        public RemoveAddZeroAdapter(MethodVisitor mv) {
            super(ASM4, mv);
        }
        @Override public void visitInsn(int opcode) {
            if (state == SEEN_ICONST_0) {
                if (opcode == IADD) {
                    state = SEEN_NOTHING;
                    return;
                }
            }
            visitInsn();
            if (opcode == ICONST_0) {
                state = SEEN_ICONST_0;
                return;
            }
            mv.visitInsn(opcode);
        }
        @Override protected void visitInsn() {
            if (state == SEEN_ICONST_0) {
                mv.visitInsn(ICONST_0);
            }
            state = SEEN_NOTHING;
        }
    }

     

    1. visitInsn(int) 메서드는 먼저 시퀀스가 감지되었는지를 테스트한다.

    이 경우 상태를 재초기화하고 즉시 반환하는데, 이는 시퀀스를 제거하는 효과를 가진다. 다른 경우에는 공통 visitInsn 메서드를 호출하는데, 이는 마지막으로 방문한 지시문이 ICONST_0이었다면 ICONST_0을 발생시킨다.

     

    2. 그런 다음 현재 지시문이 ICONST_0인 경우, 이 사실을 기억하고 이 지시문에 대한 결정을 연기하기 위해 반환한다.

    다른 모든 경우에는 현재 지시문이 다음 방문자에게 전달된다.

     

    Labels and frames (라벨과 프레임)

     

    이전 섹션에서 본 바와 같이, 라벨과 프레임은 관련 지시문 바로 전에 방문된다.

    즉, 지시문과 동시에 방문되지만, 그 자체로 지시문은 아니다. 이는 지시문 시퀀스를 감지하는 변환에 영향을 미치지만, 실제로는 이점이다. 제거하는 지시문 중 하나가 점프 지시문의 대상인 경우 어떻게 되는가? 어떤 지시문이 ICONST_0으로 점프할 수 있다면, 이는 이 지시문을 지정하는 라벨이 있다는 것을 의미한다. 두 지시문이 제거된 후 이 라벨은 제거된 IADD 다음의 지시문을 지정하게 되는데, 이는 우리가 원하는 것이다. 그러나 어떤 지시문이 IADD로 점프할 수 있다면, 지시문 시퀀스를 제거할 수 없다(이 점프 전에 스택에 0이 푸시되었는지 확신할 수 없다). 다행히도, 이 경우 ICONST_0과 IADD 사이에 라벨이 있어야 하며, 이는 쉽게 감지될 수 있다. 스택 맵 프레임에 대해서도 같은 추론이 적용된다:

     

    두 지시문 사이에 스택 맵 프레임이 방문된 경우, 이들을 제거할 수 없다. 라벨과 프레임을 패턴 매칭 알고리즘에서 지시문으로 간주함으로써 두 경우 모두 처리할 수 있다. 이는 PatternMethodAdapter에서 수행될 수 있다(visitMaxs도 공통 visitInsn 메서드를 호출한다; 이는 메서드의 끝이 감지되어야 하는 시퀀스의 접두사인 경우를 처리하기 위해 사용된다):

     

    public abstract class PatternMethodAdapter extends MethodVisitor {
    ...
        @Override public void visitFrame(int type, int nLocal, Object[] local,
                                         int nStack, Object[] stack) {
            visitInsn();
            mv.visitFrame(type, nLocal, local, nStack, stack);
        }
        @Override public void visitLabel(Label label) {
            visitInsn();
            mv.visitLabel(label);
        }
        @Override public void visitMaxs(int maxStack, int maxLocals) {
            visitInsn();
            mv.visitMaxs(maxStack, maxLocals);
        }
    }

     

    다음 장에서 볼 것처럼, 컴파일된 메서드는 예를 들어 예외 스택 트레이스에서 사용되는 소스 파일 줄 번호에 대한 정보를 포함할 수 있다. 이 정보는 지시문과 동시에 호출되는 visitLineNumber 메서드와 함께 방문된다. 여기서도 지시문 시퀀스 중간에 줄 번호의 존재는 이를 변환하거나 제거할 가능성에 영향을 미치지 않는다. 따라서 패턴 매칭 알고리즘에서 이를 완전히 무시하는 것이 해결책이다.

     

    A more complex example

     

    이전 예제는 더 복잡한 지시문 시퀀스로 쉽게 일반화될 수 있다. 예를 들어, f = f; 또는 바이트코드에서 ALOAD 0 ALOAD 0 GETFIELD f PUTFIELD f와 같은 자기 필드 할당을 제거하는 변환을 고려해 보자. 이 변환을 구현하기 전에, 이 시퀀스를 인식하는 상태 기계를 설계하는 것이 바람직하다(그림 3.6 참조).

     

    Figure 3.6.: State machine for ALOAD 0 ALOAD 0 GETFIELD f PUTFIELD f

     

    각 전환은 조건(현재 지시문의 값)과 행동(발행해야 하는 지시문 시퀀스, 굵게 표시)으로 라벨링된다. 예를 들어, 현재 지시문이 ALOAD 0이 아닌 경우 S1에서 S0으로의 전환은 이 상태에 도달하기 위해 방문된 ALOAD 0이 발행된다. S2에서 자기 자신으로의 전환은 세 개 이상의 연속적인 ALOAD 0이 발견될 때 발생한다. 이 경우 두 개의 ALOAD 0이 방문된 상태에 머물며 세 번째를 발행한다. 상태 기계가 발견되면 해당 메서드 어댑터를 작성하는 것은 간단하다(다이어그램의 8개의 전환에 해당하는 8개의 switch 케이스):

     

    class RemoveGetFieldPutFieldAdapter extends PatternMethodAdapter {
        private final static int SEEN_ALOAD_0 = 1;
        private final static int SEEN_ALOAD_0ALOAD_0 = 2;
        private final static int SEEN_ALOAD_0ALOAD_0GETFIELD = 3;
        private String fieldOwner;
        private String fieldName;
        private String fieldDesc;
    
        public RemoveGetFieldPutFieldAdapter(MethodVisitor mv) {
            super(mv);
        }
    
        @Override
        public void visitVarInsn(int opcode, int var) {
            switch (state) {
                case SEEN_NOTHING: // S0 -> S1
                    if (opcode == ALOAD && var == 0) {
                        state = SEEN_ALOAD_0;
                        return;
                    }
                    break;
                case SEEN_ALOAD_0: // S1 -> S2
                    if (opcode == ALOAD && var == 0) {
                        state = SEEN_ALOAD_0ALOAD_0;
                        return;
                    }
                    break;
                case SEEN_ALOAD_0ALOAD_0: // S2 -> S2
                    if (opcode == ALOAD && var == 0) {
                        mv.visitVarInsn(ALOAD, 0);
                        return;
                    }
                    break;
            }
            visitInsn();
            mv.visitVarInsn(opcode, var);
        }
    
        @Override
        public void visitFieldInsn(int opcode, String owner, String name,
                                   String desc) {
            switch (state) {
                case SEEN_ALOAD_0ALOAD_0: // S2 -> S3
                    if (opcode == GETFIELD) {
                        state = SEEN_ALOAD_0ALOAD_0GETFIELD;
                        fieldOwner = owner;
                        fieldName = name;
                        fieldDesc = desc;
                        return;
                    }
                    break;
                case SEEN_ALOAD_0ALOAD_0GETFIELD: // S3 -> S0
                    if (opcode == PUTFIELD && name.equals(fieldName)) {
                        state = SEEN_NOTHING;
                        return;
                    }
                    break;
            }
            visitInsn();
            mv.visitFieldInsn(opcode, owner, name, desc);
        }
    
        @Override
        protected void visitInsn() {
            switch (state) {
                case SEEN_ALOAD_0: // S1 -> S0
                    mv.visitVarInsn(ALOAD, 0);
                    break;
                case SEEN_ALOAD_0ALOAD_0: // S2 -> S0
                    mv.visitVarInsn(ALOAD, 0);
                    mv.visitVarInsn(ALOAD, 0);
                    break;
                case SEEN_ALOAD_0ALOAD_0GETFIELD: // S3 -> S0
                    mv.visitVarInsn(ALOAD, 0);
                    mv.visitVarInsn(ALOAD, 0);
                    mv.visitFieldInsn(GETFIELD, fieldOwner, fieldName, fieldDesc);
                    break;
            }
            state = SEEN_NOTHING;
        }
    }

     

    3.2.4절의 AddTimerAdapter 사례와 같은 이유로, 이 섹션에서 제시된 상태 있는 변환은 스택 맵 프레임을 변환할 필요가 없다. 변환 후에도 원래 프레임이 유효하게 유지된다. 심지어 로컬 변수와 operand stack 크기를 변환할 필요도 없다. 마지막으로 상태 있는 변환은 지시문 시퀀스를 감지하고 변환하는 변환에만 국한되지 않는다는 점을 주목해야 한다. 많은 다른 유형의 변환도 상태를 가진다. 이는 다음 섹션에서 제시된 메서드 어댑터의 경우와 같다.