Scala JVM 바이트코드로 손을 더럽히다
게시 됨: 2022-03-11Scala 언어는 기능 및 객체 지향 소프트웨어 개발 원칙의 탁월한 조합과 입증된 JVM(Java Virtual Machine)을 기반으로 한 구현 덕분에 지난 몇 년 동안 계속해서 인기를 얻었습니다.
Scala는 Java 바이트코드로 컴파일되지만 Java 언어의 인식된 많은 단점을 개선하도록 설계되었습니다. 완전한 기능적 프로그래밍 지원을 제공하는 Scala의 핵심 구문에는 Java 프로그래머가 명시적으로 빌드해야 하는 많은 암시적 구조가 포함되어 있으며 일부는 상당한 복잡성을 포함합니다.
Java 바이트코드로 컴파일되는 언어를 생성하려면 Java Virtual Machine의 내부 작동에 대한 깊은 이해가 필요합니다. Scala의 개발자들이 성취한 것을 이해하려면 내부로 들어가서 Scala의 소스 코드가 컴파일러에 의해 어떻게 해석되어 효율적이고 효과적인 JVM 바이트코드를 생성하는지 살펴봐야 합니다.
이 모든 것이 어떻게 구현되는지 살펴보겠습니다.
전제 조건
이 기사를 읽으려면 Java Virtual Machine 바이트코드에 대한 기본적인 이해가 필요합니다. 완전한 가상 머신 사양은 Oracle의 공식 문서에서 얻을 수 있습니다. 전체 사양을 읽는 것은 이 기사를 이해하는 데 중요하지 않으므로 기본에 대한 빠른 소개를 위해 기사 하단에 짧은 가이드를 준비했습니다.
아래에 제공된 예제를 재현하고 추가 조사를 진행하려면 Java 바이트코드를 디스어셈블하는 유틸리티가 필요합니다. Java Development Kit는 여기에서 사용할 자체 명령줄 유틸리티인 javap
를 제공합니다. javap
작동 방식에 대한 빠른 데모는 하단의 가이드에 포함되어 있습니다.
그리고 물론 예제를 따라 하려는 독자에게는 Scala 컴파일러의 작동 설치가 필요합니다. 이 기사는 Scala 2.11.7을 사용하여 작성되었습니다. Scala의 다른 버전은 약간 다른 바이트 코드를 생성할 수 있습니다.
기본 Getter 및 Setter
Java 규칙은 항상 public 속성에 대한 getter 및 setter 메소드를 제공하지만, Java 프로그래머는 각각의 패턴이 수십 년 동안 변경되지 않았음에도 불구하고 이를 직접 작성해야 합니다. 대조적으로 Scala는 기본 getter와 setter를 제공합니다.
다음 예를 살펴보겠습니다.
class Person(val name:String) { }
Person
클래스 내부를 살펴보자. 이 파일을 scalac
로 컴파일하면 $ javap -p Person.class
를 실행하면 다음이 제공됩니다.
Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }
Scala 클래스의 각 필드에 대해 필드와 해당 getter 메서드가 생성되는 것을 볼 수 있습니다. 필드는 비공개이고 최종적인 반면 메서드는 공개입니다.
Person
소스에서 val
을 var
로 바꾸고 다시 컴파일하면 필드의 final
수정자가 삭제되고 setter 메서드도 추가됩니다.
Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }
val
또는 var
가 클래스 본문 내에 정의되어 있으면 해당 private 필드 및 접근자 메서드가 생성되고 인스턴스 생성 시 적절하게 초기화됩니다.
클래스 수준 val
및 var
필드의 이러한 구현은 일부 변수가 클래스 수준에서 중간 값을 저장하는 데 사용되며 프로그래머가 직접 액세스하지 않는 경우 이러한 각 필드를 초기화하면 수업 발자국. 이러한 필드에 private
한정자를 추가한다고 해서 해당 접근자가 삭제되는 것은 아닙니다. 그들은 단지 비공개가 될 것입니다.
변수 및 함수 정의
m()
메서드가 있고 이 함수에 대한 세 가지 다른 Scala 스타일 참조를 생성한다고 가정해 보겠습니다.
class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }
m
에 대한 이러한 각 참조는 어떻게 구성됩니까? 각 경우에 m
이 언제 실행됩니까? 결과 바이트코드를 살펴보겠습니다. 다음 출력은 javap -v Person.class
의 결과를 보여줍니다(많은 불필요한 출력 생략).
Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return
상수 풀에서 m()
메서드에 대한 참조가 인덱스 #30
에 저장되어 있음을 알 수 있습니다. 생성자 코드에서 이 메소드는 초기화 중에 두 번 호출되며, invokevirtual #30
명령어는 바이트 오프셋 11에 먼저 나타나고 오프셋 19에 나타납니다. 첫 번째 호출 다음에 결과를 할당하는 putfield #22
명령어가 옵니다. 상수 풀의 인덱스 #22
에서 참조하는 필드 m1
에 이 메서드를 추가합니다. 두 번째 호출은 동일한 패턴으로 이어지며 이번에는 상수 풀의 #24
에 인덱싱된 필드 m2
에 값을 할당합니다.
즉, val
또는 var
로 정의된 변수에 메서드를 할당하면 해당 변수에 메서드의 결과 만 할당됩니다. 생성된 m1()
및 m2()
메서드는 단순히 이러한 변수에 대한 getter임을 알 수 있습니다. var m2
의 경우 setter m2_$eq(int)
가 생성되어 다른 setter와 마찬가지로 필드의 값을 덮어쓰는 것을 볼 수 있습니다.
그러나 키워드 def
를 사용하면 다른 결과가 나타납니다. 반환할 필드 값을 가져오는 대신 m3()
메서드에는 invokevirtual #30
명령도 포함되어 있습니다. 즉, 이 메서드가 호출될 때마다 m()
을 호출하고 이 메서드의 결과를 반환합니다.
따라서 스칼라는 클래스 필드로 작업하는 세 가지 방법을 제공하며 이는 val
, var
및 def
키워드를 통해 쉽게 지정됩니다. Java에서는 필요한 setter와 getter를 명시적으로 구현해야 하며 수동으로 작성된 상용구 코드는 훨씬 덜 표현적이고 오류가 발생하기 쉽습니다.
게으른 값
지연 값을 선언하면 더 복잡한 코드가 생성됩니다. 이전에 정의한 클래스에 다음 필드를 추가했다고 가정합니다.
lazy val m4 = m
javap -p -v Person.class
를 실행하면 이제 다음이 표시됩니다.
Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn
이 경우 필드 m4
의 값은 필요할 때까지 계산되지 않습니다. 특별한 개인 메서드 m4$lzycompute()
는 지연 값을 계산하기 위해 생성되고 필드 bitmap$0
은 상태를 추적하기 위해 생성됩니다. 메서드 m4()
는 이 필드의 값이 0인지 확인하여 m4
가 아직 초기화되지 않았음을 나타냅니다. 이 경우 m4$lzycompute()
가 호출되어 m4
를 채우고 값을 반환합니다. 이 개인 메서드는 또한 bitmap$0
값을 1로 설정하므로 다음에 m4()
가 호출될 때 초기화 메서드 호출을 건너뛰고 대신 m4
값을 반환합니다.
여기에서 Scala가 생성하는 바이트코드는 스레드로부터 안전하고 효과적이도록 설계되었습니다. 스레드로부터 안전하기 위해 지연 계산 방법은 monitorenter
/ monitorexit
명령 쌍을 사용합니다. 이 동기화의 성능 오버헤드는 지연 값을 처음 읽을 때만 발생하므로 이 방법은 계속 유효합니다.
게으른 값의 상태를 나타내는 데 1비트만 필요합니다. 따라서 지연 값이 32개 이하이면 단일 int 필드가 모두 추적할 수 있습니다. 소스 코드에 두 개 이상의 지연 값이 정의되어 있으면 위의 바이트 코드가 컴파일러에 의해 수정되어 이 목적을 위해 비트 마스크를 구현합니다.
다시 말하지만, Scala를 사용하면 Java에서 명시적으로 구현해야 하는 특정 종류의 동작을 쉽게 활용하여 노력을 절약하고 오타의 위험을 줄일 수 있습니다.
가치로서의 기능
이제 다음 Scala 소스 코드를 살펴보겠습니다.
class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }
Printer
클래스에는 String => Unit
유형의 output
필드가 하나 있습니다. 이 함수는 String
을 취하고 Unit
유형의 객체를 반환합니다(Java의 void
와 유사). 기본 메서드에서 이러한 개체 중 하나를 만들고 이 필드를 지정된 문자열을 인쇄하는 익명 함수에 할당합니다.
이 코드를 컴파일하면 4개의 클래스 파일이 생성됩니다.
Hello.class
는 메인 메소드가 단순히 Hello$.main()
을 호출하는 래퍼 클래스입니다:
public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return
숨겨진 Hello$.class
에는 기본 메서드의 실제 구현이 포함되어 있습니다. 바이트코드를 보려면 특수 문자로 해석되지 않도록 명령 셸의 규칙에 따라 $
를 올바르게 이스케이프해야 합니다.
public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return
이 메서드는 Printer
를 만듭니다. 그런 다음 익명 함수 s => println(s)
를 포함하는 Hello$$anonfun$1
을 만듭니다. Printer
는 이 개체를 output
필드로 사용하여 초기화됩니다. 이 필드는 스택에 로드되고 피연산자 "Hello"
로 실행됩니다.
아래에서 익명 함수 클래스인 Hello$$anonfun$1.class
를 살펴보겠습니다. apply()
메서드를 구현하여 Scala의 Function1
( AbstractFunction1
으로)을 확장한 것을 볼 수 있습니다. 실제로, 두 개의 apply()
메서드를 생성합니다. 하나는 다른 하나를 래핑하고, 함께 유형 검사를 수행하고(이 경우 입력은 String
) 익명 함수를 실행합니다( println()
을 사용하여 입력 인쇄).
public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn
위의 Hello$.main()
메서드를 다시 살펴보면 오프셋 21에서 익명 함수의 실행이 해당 apply( Object )
메서드 호출에 의해 트리거된다는 것을 알 수 있습니다.
마지막으로 완전성을 위해 Printer.class
의 바이트코드를 살펴보겠습니다.
public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return
여기에서 익명 함수가 val
변수처럼 취급된다는 것을 알 수 있습니다. output
클래스 필드에 저장되고 getter output()
이 생성됩니다. 유일한 차이점은 이 변수가 이제 Scala 인터페이스 scala.Function1
( AbstractFunction1
이 수행함)을 구현해야 한다는 것입니다.
따라서 이 우아한 Scala 기능의 비용은 값으로 사용할 수 있는 단일 익명 함수를 표시하고 실행하기 위해 생성된 기본 유틸리티 클래스입니다. 특정 애플리케이션에 대한 의미를 파악하려면 이러한 기능의 수와 VM 구현의 세부사항을 고려해야 합니다.
스칼라 특성
Scala의 특성은 Java의 인터페이스와 유사합니다. 다음 특성은 두 가지 메서드 시그니처를 정의하고 두 번째 시그니처의 기본 구현을 제공합니다. 구현 방법을 살펴보겠습니다.
trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) }
두 가지 엔터티가 생성됩니다. Similarity.class
, 두 메서드를 모두 선언하는 인터페이스 및 기본 구현을 제공하는 합성 클래스 Similarity$class.class
.
public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return
클래스가 이 특성을 구현하고 isNotSimilar
메서드를 호출하면 스칼라 컴파일러는 바이트코드 명령어 invokestatic
을 생성하여 동반 클래스에서 제공하는 정적 메서드를 호출합니다.

복잡한 다형성 및 상속 구조는 특성에서 생성될 수 있습니다. 예를 들어, 구현 클래스뿐만 아니라 여러 특성이 모두 동일한 서명으로 메서드를 재정의하고 super.methodName()
을 호출하여 다음 특성에 제어를 전달할 수 있습니다. Scala 컴파일러는 이러한 호출을 만나면 다음을 수행합니다.
- 이 호출에서 가정하는 정확한 특성을 결정합니다.
- 특성에 대해 정의된 정적 메서드 바이트코드를 제공하는 동반 클래스의 이름을 결정합니다.
- 필요한
invokestatic
명령어를 생성합니다.
따라서 우리는 트레이트의 강력한 개념이 상당한 오버헤드로 이어지지 않는 방식으로 JVM 수준에서 구현되고 Scala 프로그래머가 런타임에 너무 비싸지 않을까 걱정하지 않고 이 기능을 즐길 수 있음을 알 수 있습니다.
싱글톤
Scala는 object
키워드를 사용하여 싱글톤 클래스의 명시적 정의를 제공합니다. 다음 싱글톤 클래스를 고려해 보겠습니다.
object Config { val home_dir = "/home/user" }
컴파일러는 두 개의 클래스 파일을 생성합니다.
Config.class
는 매우 간단합니다.
public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn
이것은 싱글톤의 기능을 포함하는 합성 Config$
클래스의 데코레이터일 뿐입니다. javap -p -c
를 사용하여 해당 클래스를 검사하면 다음 바이트 코드가 생성됩니다.
public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return
다음으로 구성됩니다.
- 다른 개체가 이 싱글톤 개체에 액세스하는 데 사용되는 합성 변수
MODULE$
. - 정적 이니셜라이저
{}
(클래스<clinit>
라이저라고도 함) 및Config$
비공개 메서드는MODULE$
를 초기화하고 해당 필드를 기본값으로 설정하는 데 사용됩니다. - 정적 필드
home_dir
에 대한 getter 메서드입니다. 이 경우 하나의 방법일 뿐입니다. 싱글톤에 더 많은 필드가 있으면 변경 가능한 필드에 대한 설정자뿐만 아니라 더 많은 게터가 있습니다.
싱글톤은 대중적이고 유용한 디자인 패턴입니다. Java 언어는 언어 수준에서 이를 지정하는 직접적인 방법을 제공하지 않습니다. 오히려 Java 소스에서 구현하는 것은 개발자의 책임입니다. 반면에 Scala는 object
키워드를 사용하여 명시적으로 싱글톤을 선언하는 명확하고 편리한 방법을 제공합니다. 내부를 살펴보면 알 수 있듯이 저렴하고 자연스러운 방식으로 구현됩니다.
결론
이제 Scala가 여러 암시적 및 기능적 프로그래밍 기능을 정교한 Java 바이트 코드 구조로 컴파일하는 방법을 보았습니다. Scala의 내부 작동을 엿봄으로써 우리는 Scala의 힘에 대해 더 깊이 이해할 수 있고 이 강력한 언어를 최대한 활용할 수 있습니다.
또한 이제 우리는 언어를 스스로 탐색할 수 있는 도구를 갖게 되었습니다. 케이스 클래스, 커링 및 목록 이해와 같이 이 기사에서 다루지 않은 Scala 구문의 많은 유용한 기능이 있습니다. 다음 단계의 Scala 닌자가 되는 방법을 배울 수 있도록 이러한 구조에 대한 Scala의 구현을 직접 조사하는 것이 좋습니다.
자바 가상 머신: 단기 집중 과정
Java 컴파일러와 마찬가지로 Scala 컴파일러는 소스 코드를 Java Virtual Machine에서 실행할 Java 바이트 코드를 포함하는 .class
파일로 변환합니다. 두 언어가 내부적으로 어떻게 다른지 이해하려면 두 언어가 모두 대상으로 하는 시스템을 이해해야 합니다. 여기에서는 Java Virtual Machine 아키텍처, 클래스 파일 구조 및 어셈블러 기본의 몇 가지 주요 요소에 대한 간략한 개요를 제공합니다.
이 가이드는 위의 기사와 함께 팔로우를 활성화하기 위한 최소한의 내용만 다룰 것입니다. JVM의 많은 주요 구성 요소는 여기에서 논의되지 않지만 완전한 세부 사항은 여기의 공식 문서에서 찾을 수 있습니다.
javap
로 클래스 파일 디컴파일하기
상수 풀
필드 및 메서드 테이블
JVM 바이트코드
메서드 호출과 호출 스택
피연산자 스택에서 실행
지역 변수
맨 위로 돌아가기
javap
로 클래스 파일 디컴파일하기
Java는 .class
파일을 사람이 읽을 수 있는 형식으로 디컴파일하는 javap
명령줄 유틸리티와 함께 제공됩니다. Scala와 Java 클래스 파일은 모두 동일한 JVM을 대상으로 하기 때문에 javap
를 사용하여 Scala에서 컴파일된 클래스 파일을 검사할 수 있습니다.
다음 소스 코드를 컴파일해 봅시다.
// RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }
이것을 scalac RegularPolygon.scala
로 컴파일하면 RegularPolygon.class
가 생성됩니다. 그런 다음 javap RegularPolygon.class
를 실행하면 다음이 표시됩니다.
$ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
이것은 단순히 클래스의 공개 멤버의 이름과 유형을 보여주는 클래스 파일의 매우 간단한 분석입니다. -p
옵션을 추가하면 개인 구성원이 포함됩니다.
$ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
이것은 아직 많은 정보가 아닙니다. 메소드가 Java 바이트코드에서 구현되는 방법을 보려면 -c
옵션을 추가해 보겠습니다.
$ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }
조금 더 흥미롭습니다. 그러나 전체 이야기를 실제로 얻으려면 javap -p -v RegularPolygon.class
에서와 같이 -v
또는 -verbose
옵션을 사용해야 합니다.
여기에서 마침내 클래스 파일에 실제로 무엇이 있는지 확인합니다. 이 모든 것이 무엇을 의미합니까? 가장 중요한 몇 가지 부분을 살펴보겠습니다.
상수 풀
C++ 응용 프로그램의 개발 주기에는 컴파일 및 연결 단계가 포함됩니다. Java의 개발 주기는 연결이 런타임에 발생하기 때문에 명시적 연결 단계를 건너뜁니다. 클래스 파일은 이 런타임 연결을 지원해야 합니다. 즉, 소스 코드가 필드나 메서드를 참조할 때 결과 바이트 코드는 관련 참조를 기호 형식으로 유지해야 하며 응용 프로그램이 메모리에 로드되고 런타임 링커에서 실제 주소를 확인할 수 있으면 역참조할 준비가 되어 있어야 합니다. 이 기호 형식에는 다음이 포함되어야 합니다.
- 클래스 이름
- 필드 또는 메소드 이름
- 유형 정보
클래스 파일 형식 사양에는 링커에 필요한 모든 참조 테이블인 상수 풀이 라는 파일 섹션이 포함됩니다. 다양한 유형의 항목이 포함되어 있습니다.
// ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...
각 항목의 첫 번째 바이트는 항목 유형을 나타내는 숫자 태그입니다. 나머지 바이트는 항목 값에 대한 정보를 제공합니다. 바이트 수와 해석 규칙은 첫 번째 바이트가 나타내는 유형에 따라 다릅니다.
예를 들어 상수 정수 365
를 사용하는 Java 클래스에는 다음 바이트 코드가 있는 상수 풀 항목이 있을 수 있습니다.
x03 00 00 01 6D
첫 번째 바이트 x03
은 항목 유형 CONSTANT_Integer
를 식별합니다. 이것은 다음 4바이트에 정수 값이 포함되어 있음을 링커에 알립니다. (16진수의 365는 x16D
임에 유의하십시오.) 이것이 상수 풀의 14번째 항목인 경우 javap -v
는 다음과 같이 렌더링합니다.
#14 = Integer 365
많은 상수 유형은 상수 풀의 다른 곳에서 더 "원시적인" 상수 유형에 대한 참조로 구성됩니다. 예를 들어 예제 코드에는 다음 명령문이 포함되어 있습니다.
println( "Calculating perimeter..." )
문자열 상수를 사용하면 상수 풀에 두 개의 항목이 생성됩니다. 하나는 CONSTANT_String
유형이고 다른 하나는 CONSTANT_Utf8
유형입니다. Constant_UTF8
유형의 항목에는 문자열 값의 실제 UTF8 표현이 포함됩니다. CONSTANT_String
유형의 항목에는 CONSTANT_Utf8
항목에 대한 참조가 포함됩니다.
#24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...
Utf8
유형의 항목을 참조하고 String
유형의 항목이 아닌 다른 유형의 상수 풀 항목이 있기 때문에 이러한 복잡성이 필요합니다. 예를 들어, 클래스 속성에 대한 참조는 클래스 이름, 속성 이름 및 속성 유형에 대한 일련의 참조를 포함하는 CONSTANT_Fieldref
유형을 생성합니다.
#1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I
상수 풀에 대한 자세한 내용은 JVM 설명서를 참조하세요.
필드 및 메서드 테이블
클래스 파일에는 클래스에 정의된 각 필드(예: 속성)에 대한 정보가 포함된 필드 테이블 이 있습니다. 이는 필드의 이름과 유형, 액세스 제어 플래그 및 기타 관련 데이터를 설명하는 상수 풀 항목에 대한 참조입니다.
유사한 메소드 테이블 이 클래스 파일에 있습니다. 그러나 이름 및 유형 정보 외에도 각각의 비추상 메서드에 대해 JVM에서 실행될 실제 바이트 코드 명령과 아래에서 설명하는 메서드의 스택 프레임에서 사용하는 데이터 구조가 포함되어 있습니다.
JVM 바이트코드
JVM은 자체 내부 명령어 세트를 사용하여 컴파일된 코드를 실행합니다. -c
옵션과 함께 javap
를 실행하면 출력에 컴파일된 메소드 구현이 포함됩니다. 이 방법으로 RegularPolygon.class
파일을 검사하면 getPerimeter()
메서드에 대한 다음 출력을 볼 수 있습니다.
public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn
실제 바이트코드는 다음과 같을 수 있습니다.
xB2 00 17 x12 19 xB6 00 1D x27 ...
각 명령어는 JVM 명령어를 식별하는 1바이트 opcode 로 시작하고 특정 명령어의 형식에 따라 0개 이상의 명령어 피연산자가 뒤따릅니다. 이들은 일반적으로 상수 값이거나 상수 풀에 대한 참조입니다. javap
는 바이트코드를 다음을 표시하는 사람이 읽을 수 있는 형식으로 유용하게 변환합니다.
- 오프셋 또는 코드 내 명령어의 첫 번째 바이트 위치입니다.
- 명령의 사람이 읽을 수 있는 이름 또는 니모닉 입니다.
- 피연산자의 값(있는 경우)입니다.
#23
과 같이 파운드 기호로 표시되는 피연산자는 상수 풀의 항목에 대한 참조입니다. 우리가 볼 수 있듯이 javap
는 또한 풀에서 정확히 무엇을 참조하고 있는지 식별하는 유용한 주석을 출력에 생성합니다.
아래에서 몇 가지 일반적인 지침에 대해 설명합니다. 전체 JVM 명령어 세트에 대한 자세한 내용은 설명서를 참조하십시오.
메서드 호출과 호출 스택
각 메서드 호출은 로컬로 선언된 변수 또는 메서드에 전달된 인수와 같은 항목을 포함하는 자체 컨텍스트로 실행할 수 있어야 합니다. 이것들은 함께 스택 프레임 을 구성합니다. 메서드를 호출하면 새 프레임이 생성되어 호출 스택 맨 위에 배치됩니다. 메서드가 반환되면 현재 프레임이 호출 스택에서 제거되고 폐기되고 메서드가 호출되기 전에 유효했던 프레임이 복원됩니다.
스택 프레임에는 몇 가지 고유한 구조가 포함됩니다. 두 가지 중요한 것은 피연산자 스택 과 다음에 논의되는 지역 변수 테이블 입니다.
피연산자 스택에서 실행
많은 JVM 명령어는 프레임의 피연산자 스택 에서 작동합니다. 바이트코드에 상수 피연산자를 명시적으로 지정하는 대신 이러한 명령어는 피연산자 스택 맨 위에 있는 값을 입력으로 사용합니다. 일반적으로 이러한 값은 프로세스의 스택에서 제거됩니다. 일부 지침은 스택 맨 위에 새 값을 배치하기도 합니다. 이러한 방식으로 JVM 명령어를 결합하여 복잡한 작업을 수행할 수 있습니다. 예를 들어, 다음과 같은 표현이 있습니다.
sideLength * this.numSides
getPerimeter()
메서드에서 다음과 같이 컴파일됩니다.
8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul
- 첫 번째 명령어인
dload_1
은 로컬 변수 테이블(다음에 설명됨)의 슬롯 1에서 피연산자 스택으로 개체 참조를 푸시합니다. 이 경우 메서드 인수sideLength
.- 다음 명령어aload_0
은 로컬 변수 테이블의 슬롯 0에 있는 개체 참조를 피연산자 스택으로 푸시합니다. 실제로 이것은 거의 항상 현재 클래스인this
에 대한 참조입니다. - 이것은 인스턴스 메소드
numSides()
를 실행하는 다음 호출invokevirtual #31
을 위한 스택을 설정합니다.invokevirtual
은 스택에서 상위 피연산자(this
에 대한 참조)를 꺼내어 메서드를 호출해야 하는 클래스를 식별합니다. 메서드가 반환되면 결과가 스택에 푸시됩니다. - 이 경우 반환된 값(
numSides
)은 정수 형식입니다. 다른 이중 값과 곱하려면 이중 부동 소수점 형식으로 변환해야 합니다. 명령어i2d
는 스택에서 정수 값을 꺼내고 부동 소수점 형식으로 변환한 다음 스택에 다시 푸시합니다. - 이 시점에서 스택에는 맨 위에
this.numSides
의 부동 소수점 결과가 포함되고 그 뒤에 메서드에 전달된sideLength
인수 값이 포함됩니다.dmul
은 스택에서 이 상위 두 값을 꺼내고 부동 소수점 곱을 수행하고 그 결과를 스택에 푸시합니다.
메서드가 호출되면 새 피연산자 스택이 스택 프레임의 일부로 생성되며 여기서 연산이 수행됩니다. 여기서 용어에 주의해야 합니다. "스택"이라는 단어는 호출 스택 , 메서드 실행에 대한 컨텍스트를 제공하는 프레임 스택 또는 JVM 명령이 작동하는 특정 프레임의 피연산자 스택 을 참조할 수 있습니다.
지역 변수
각 스택 프레임은 지역 변수 테이블을 유지합니다. This typically includes a reference to this
object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap
with the -v
option will include information about how each method's stack frame should be set up, including its local variable table:
public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D
In this example, there are two local variables. The variable in slot 0 is named this
, with the type RegularPolygon
. This is the reference to the method's own class. The variable in slot 1 is named sideLength
, with the type D
(indicating a double). This is the argument that is passed to our getPerimeter()
method.
Instructions such as iload_1
, fstore_2
, or aload [n]
, transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this
, the instruction aload_0
is commonly seen in any method that operates on its own class.
This concludes our walkthrough of JVM basics.