3P by GN⁺ | ★ favorite | 댓글 1개
  • Rust를 배우기 위한 사이드 프로젝트 rjvm학습용 토이 JVM이지만, 실제 .classjar를 읽고 Java 바이트코드를 실행하는 범위까지 구현함
  • 지원하지 않는 기능은 스레드, 리플렉션, 애너테이션, I/O, JIT 컴파일러, 문자열 인터닝이지만, 제어 흐름, 객체 생성, 메서드 호출, 예외, 가비지 컬렉션은 포함됨
  • 예제는 OpenJDK 7의 실제 rt.jar를 사용해 java.lang.StackTraceElement 같은 클래스가 실제 JDK에서 로드됨
  • 코드는 reader, vm, vm_cli 세 Rust 크레이트로 나뉘며, .class 파싱부터 호출 스택, 네이티브 메서드, 값과 객체 모델링까지 다룸
  • 마지막 마일스톤은 stop-the-world semispace copying collector였고, 목표를 달성한 뒤 프로젝트는 중단되며 알려진 버그 수정 계획도 없음

학습용 Rust JVM rjvm

  • rjvm은 Rust를 배우기 위해 만든 토이 Java Virtual Machine
  • 코드는 GitHub에 공개되어 있음
  • 진지한 JVM 구현체라기보다 학습 목적의 구현이라 다음 기능은 지원하지 않음
    • 스레드
    • 리플렉션
    • 애너테이션
    • I/O
    • JIT 컴파일러
    • 문자열 인터닝
  • 제네릭은 처음에는 미지원 목록에 있었지만 실제로는 동작함

어디까지 구현됐나

  • rjvm은 단순 파서에 그치지 않고 여러 JVM 동작을 직접 구현함
    • if, for 같은 제어 흐름
    • 원시 타입과 객체 생성
    • 가상 메서드와 정적 메서드 호출
    • 예외 처리
    • 가비지 컬렉션
    • jar 파일에서 클래스 해석
  • 테스트 코드에는 Throwable, Exception, StackTraceElement를 사용하는 예제가 포함됨
  • 실제 I/O가 없기 때문에 System.out.println 대신 tempPrint라는 네이티브 메서드를 사용함
  • 예제는 OpenJDK 7의 실제 rt.jar를 사용하며, java.lang.StackTraceElement도 실제 JDK에서 가져옴

세 개의 Rust 크레이트

  • 프로젝트는 표준 Rust 프로젝트이며, 세 개의 크레이트로 구성됨
    • reader: .class 파일을 읽고 그 내용을 모델링하는 타입을 포함함
    • vm: 코드를 실행할 수 있는 가상 머신을 라이브러리 형태로 제공함
    • vm_cli: java 실행 파일과 비슷한 단순한 명령줄 런처를 포함함
  • reader 크레이트는 별도 저장소로 분리해 crates.io에 게시하는 방안이 검토되고 있음

.class 파일 파싱

  • Java 코드는 javac로 컴파일되어 .class 파일이 되고, 보통 zip 형식인 .jar 파일로 배포됨
  • Java 코드를 실행하려면 먼저 컴파일러가 만든 바이트코드를 담은 .class 파일을 로드해야 함
  • 클래스 파일에는 실행과 타입 해석에 필요한 정보가 함께 들어 있음
    • 클래스 이름과 소스 파일 이름 같은 메타데이터
    • 슈퍼클래스 이름
    • 구현한 인터페이스
    • 필드와 필드 타입, 애너테이션
    • 메서드 디스크립터, throws 절, 애너테이션, 제네릭 정보
    • 바이트코드, 예외 핸들러 테이블, 라인 번호 테이블
  • reader 크레이트는 클래스 파일을 파싱해 클래스와 그 내용을 모델링하는 Rust struct를 반환함

메서드 실행과 호출 스택

  • vm 크레이트의 주요 API는 Vm::invoke이며, 메서드 실행에 사용됨
  • 실행 중인 각 메서드는 CallStack 안에서 하나의 CallFrame을 가짐
  • main 실행 시 호출 스택은 처음에 비어 있고, 실행을 위해 새 프레임이 만들어짐
  • 함수 호출마다 새 프레임이 호출 스택에 추가되고, 메서드 실행이 끝나면 해당 프레임은 제거됨
  • 대부분의 메서드는 Java 바이트코드로 구현되지만, rjvm네이티브 메서드도 지원함
    • 네이티브 메서드는 Java 바이트코드가 아니라 JVM 자체가 직접 구현하는 메서드임
    • System::currentTimeMillis, System::arraycopy, Throwable::fillInStackTrace 같은 예가 있음
    • rjvm에서는 Rust 함수로 구현됨
  • JVM은 스택 기반 가상 머신이라 바이트코드 명령이 주로 값 스택 위에서 동작함
  • 각 호출 프레임에는 값 스택과 인덱스로 식별되는 로컬 변수 집합이 연결됨

값과 객체 모델링

  • Value는 로컬 변수, 스택 요소, 객체 필드에 저장될 수 있는 값을 모델링함
  • Value는 Rust enum으로 구현되며 다음 상태를 포함함
    • Uninitialized
    • Int(i32)
    • Long(i64)
    • Float(f32)
    • Double(f64)
    • Object(AbstractObject<'a>)
    • Null
  • Rust의 enum 같은 합 타입은 하나의 값이 여러 타입 중 하나일 수 있다는 점을 표현하기에 잘 맞음
  • 객체 저장은 처음에는 클래스 참조와 필드 값을 담은 Vec<Value> 기반의 단순 Object 구조체로 시작함
  • 가비지 컬렉터 구현 뒤에는 포인터와 캐스트를 많이 쓰는 더 낮은 수준의 구현으로 바뀜
  • 현재 AbstractObject는 실제 객체나 배열을 모델링하며, 헤더 워드 몇 개와 필드 값들을 담은 바이트 배열 포인터임

바이트코드 명령 실행

  • 메서드 실행은 바이트코드 명령을 하나씩 처리하는 과정임
  • JVM 명령은 200개가 넘고, 바이트코드에서는 1바이트로 인코딩됨
  • 많은 명령에는 인자가 뒤따르며 일부 명령은 가변 길이임
  • rjvmInstruction 타입으로 Java 바이트코드 명령을 모델링함
  • 메서드 실행 시 값 스택과 로컬 변수 배열을 유지하고, 다음 실행할 명령 주소인 프로그램 카운터를 0으로 초기화함
  • 일반적으로 명령 실행 뒤 프로그램 카운터는 다음 명령으로 이동하지만, 점프 명령은 다른 위치로 이동시킬 수 있음
  • 점프 명령은 if, for, while 같은 흐름 제어문 구현에 사용됨
  • 다른 메서드를 호출하는 명령은 별도 계열로 존재함
    • 어떤 메서드를 호출할지 정하는 방식에는 가상 조회와 정적 조회 등이 있음
    • 대상 메서드를 해석한 뒤 새 프레임을 호출 스택에 추가하고 실행을 시작함
    • 반환값이 void가 아니면 반환값이 스택에 push되고 실행이 재개됨

예외 처리

  • 예외는 일반적인 제어 흐름을 깨고, 메서드에서 조기 반환되며 호출 스택을 따라 전파될 수 있어 구현이 복잡함
  • catch 블록은 메서드의 예외 테이블 항목 하나에 대응함
  • 예외 테이블 항목에는 핸들러를 찾는 데 필요한 정보가 들어 있음
    • 적용되는 프로그램 카운터 범위
    • catch 블록의 첫 명령 주소
    • 해당 블록이 잡는 예외 클래스 이름
  • CallFrame::execute_instruction는 Rust의 Result를 사용해 명령 실행 결과를 표현함
  • 명령 실행 결과는 네 가지 상태로 나뉨
    • 성공했고 현재 메서드 실행을 계속함
    • 성공했으며 return 명령이라 현재 메서드가 반환값과 함께 종료됨
    • 내부 VM 오류 때문에 실행 실패함
    • Java 예외가 던져져 실행 실패함
  • 메서드 실행 루프는 명령을 파싱하고 프로그램 카운터를 다음 주소로 이동한 뒤 명령을 실행함
  • 예외가 발생하면 현재 명령 위치에 맞는 예외 핸들러를 찾음
    • 핸들러가 없으면 예외를 호출자에게 전파함
    • 핸들러가 있으면 예외 객체를 스택에 다시 push하고 catch 핸들러 위치에서 실행을 계속함
  • Rust의 Result와 패턴 매칭은 이 동작을 코드 구조로 표현하는 데 잘 맞음

가비지 컬렉션

  • rjvm의 마지막 주요 마일스톤은 가비지 컬렉터 구현임
  • 선택한 알고리듬은 stop-the-world semispace copying collector임
  • 스레드가 없기 때문에 stop-the-world 방식은 자연스럽게 성립함
  • 구현은 Cheney's algorithm의 더 단순한 변형이며, 코드는 gc.rs에 있음
  • 이 방식은 사용 가능한 메모리를 두 개의 semispace로 나눔
    • 하나는 활성 영역으로 객체 할당에 사용됨
    • 다른 하나는 사용하지 않는 영역으로 남아 있음
    • 활성 영역이 가득 차면 살아 있는 객체를 다른 semispace로 복사함
    • 모든 객체 참조를 새 복사본을 가리키도록 갱신함
    • 두 semispace의 역할을 교체함
  • 이 절차는 blue-green deployment와 유사한 방식으로 비유됨
  • 알고리듬의 장단점은 뚜렷함
    • 최대 메모리의 절반을 사용할 수 없어 메모리 낭비가 큼
    • 포인터를 증가시키는 방식이라 할당이 매우 빠름
    • 객체를 복사하고 압축하므로 메모리 단편화를 다루지 않아도 됨
    • 객체 압축은 캐시 라인 활용 개선으로 성능을 높일 수 있음
  • 실제 Java VM은 G1이나 parallel GC 같은 더 정교한 세대별 가비지 컬렉터를 일반적으로 사용함

프로젝트의 종료 지점

  • rjvm을 만들면서 Rust와 가상 머신 구현을 많이 배웠고, 특히 실제로 동작하는 가비지 컬렉터 구현에 만족함
  • 가비지 컬렉터는 완성도가 높지는 않지만 실제로 동작함
  • 원래 목표를 달성했기 때문에 프로젝트는 여기서 중단됨
  • 알려진 버그가 있지만 이를 고칠 계획은 없음
  • Rust는 JVM 구현에 사용하면서 즐거웠던 언어이며, 이후 글에서 rjvm 구현과 JVM 동작 방식을 더 자세히 다룰 예정임

댓글과 토론

Hacker News 의견들
  • 가비지 컬렉션 구현에서 어려운 부분은 모든 참조가 제대로 루트로 잡히는지 확인하는 것, 특히 이동형 수집기에서는 더 그렇다는 점임
    do_garbage_collection 메서드가 unsafe로 표시돼 있는데[1], 호출하는 쪽에서 안전하게 호출하려면 무엇을 보장해야 하는지 설명이 없음
    힙에 대한 모든 참조가 루트로 잡힌다는 걸 어떻게 보장하는지 궁금하고, 이건 사소한 문제가 아님[2][3][4]
    또 저장소를 클론해서 cargo test를 실행해 보니 모든 테스트가 should be able to add entries to the classpath: InvalidEntry(".../vm/rt.jar") 오류로 실패함: vm/tests/integration/real_code_tests.rs:15:10
    [1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
    [2] https://manishearth.github.io/blog/2021/04/05/a-tour-of-safe...
    [3] https://without.boats/blog/shifgrethor-iii/
    [4] https://coredumped.dev/2022/04/11/implementing-a-safe-garbag...

    • 이 VM은 네이티브 호출 스택 대신 자체 가상 호출 스택을 유지하므로 꽤 직관적임
      그래서 그 스택을 순회해 매개변수와 지역 변수를 찾아 루트로 사용할 수 있음
      이런 방식은 성능 비용이 있지만, 가비지 컬렉션 추적은 훨씬 단순해지고 코루틴이나 continuation 같은 동시성·제어 흐름 원시 기능도 구현하기 쉬워짐
    • 가비지 컬렉터는 기본적으로 꼼꼼한 회계라서 꽤 쉬운 편이지만, 동시 가비지 컬렉션을 시작하는 순간 지옥처럼 어려워짐
  • 좋은 프로젝트이고 축하함
    다만 “지원하지 않는 것: 제네릭”이라는 부분이 조금 이상하게 느껴짐
    JVM에서 제네릭을 어떤 의미로 지원해야 하는지 궁금함
    바이트코드 수준에서는 타입 소거 때문에 전부 Object, 즉 참조 타입이라고 단순하게 생각해도 되는 것 아닌가? 아니면 클래스 정의 파서를 말하는 건가? 그렇다 해도 기본 구문 외에는 클래스 파일이 유효한지 검사하는 로직은 없어 보임

    • 제네릭에 대해서는 reddit에서도 같은 이야기가 나왔고, 맞는 말임
      실제로 해야 할 일은 클래스, 메서드, 필드의 제네릭 정보를 담는 Signature 속성을 읽는 정도임 (https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.ht...)
      실제로 방금 테스트해 보니 아래 코드도 동작함 :-)
      public class Generic { public static void main(String[] args) { List strings = new ArrayList(10); strings.add("hey"); strings.add("hackernews"); for (String s : strings) { tempPrint(s); } } private static native void tempPrint(String value); }
    • 아마 checkcast 연산을 말하는 걸 수도 있음: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.ht...
      예를 들어 final Main value = list.get(0); 같은 코드를 쓰면 생성됨
      http://henrikeichenhardt.blogspot.com/2013/05/how-are-java-g...
    • 대체로 맞음. 제네릭은 드물게 리플렉션에 영향이 있지만, 리플렉션도 지원하지 않는 상태고 전반적으로 컴파일 시 가장 가까운 클래스나 인터페이스로 대체됨
      반면 문자열 인터닝이 없는 건 매우 이상함. 구현도 사소한 편인데, 이게 없으면 JVM이라 보기 어려움
      문자열이 참조 기준으로 같아지는 것은 중요하고 JLS의 일부임
      스레드가 없다는 점은 전체 시도를 장난감 프로젝트 수준으로 만듦
  • 정말 멋짐. 1992년에 Java 작업에 합류했을 때는 이름이 Oak였고, 내가 있던 그룹은 Java로 전체 운영체제를 작성하는 방향을 보고 있었음
    “기계어”, 즉 네이티브 메서드로 필요한 최소 부분만 남기면 임베디드 운영체제의 공격 표면을 줄일 수 있다는 아이디어였음
    원래 Java는 TV나 가전제품 같은 곳에서 돌리는 걸 목표로 했고, 당시 네이티브 메서드는 Rust가 아니라 C로 작업했음
    Rust로 만든 JVM은 이 전체 과정에 꽤 강한 메모리 안전성을 더해 줌

  • Go로 작성된 JVM 17 구현으로 https://jacobin.org/도 볼 만함

    • JVM 위에서 실행되는 Scala로 구현한 JVM인 https://github.com/lihaoyi/Metascala도 있음
    • 프로그래밍 프로젝트 이름으로는 꽤 흥미로움
      Jacobins는 1790년대 프랑스 혁명기의 혁명 정치 클럽이었고, https://jacobin.com에 있는 잡지 이름이기도 함
  • 이 시그니처의 수명 매개변수 때문에 한계에 부딪혔는지 궁금함
    fn execute_instruction(&mut self, vm: &mut Vm<'a>, call_stack: &mut CallStack<'a>, instruction: Instruction) -> Result, MethodCallFailed<'a>>
    ResultErr 변형에 수명을 추가하고 그 수명이 불변이면, 여기서는 vmcall_stack 때문에 그렇듯, 보통 ? 연산자나 조기 반환을 쓸 수 없게 됨[1]
    그러면 오류 처리가 더 장황하고 읽기 어려워지는데, 실제로도 그런 경험이었는지 궁금함
    [1] https://users.rust-lang.org/t/nll-and-early-return-not-allow...

  • 훌륭한 학습 프로젝트이고, 작성자가 재미있게 하고 있다니 좋음
    VM을 바닥부터 구현하는 건 정말 즐겁고, 과거에 그런 작업을 하면서 많이 배웠음
    가비지 컬렉션을 붙여 보고 싶다면 MMTk를 살펴봐도 좋음 (https://www.mmtk.io/)
    여러 VM에 꽂아 쓸 수 있도록 설계된 고품질 수집 알고리즘들이 있고 Rust로 작성돼 있음

    • 참고로 MMTK는 x86 전용
      장난감 프로젝트에 쓰려다가 Mac이라 포기했음
  • 아주 잘 만들었음
    VM을 만드는 일은 언제나 재미있고, Rust의 타입 시스템과 결합하면 흥미로운 학습 경험이었을 것 같음
    일자리를 찾고 있다면 Twitter, Mastodon, 또는 회사 이메일로 연락해도 좋음. 여기 사용자 ID를 보면 찾을 수 있을 것임

  • 이런 멋진 프로젝트를 보면 압도되는 느낌이 듦
    Rust를 어떻게 시작하고 기본기를 어느 정도 익혀야 이런 걸 시도할 수 있는지, 원 작성자가 설명해 주면 좋겠음

    • 마찬가지로 느낌
      너무 딴길로 새고 싶진 않지만, 개인적으로 요즘 이런 감정과 꽤 싸우고 있음
      전문 소프트웨어 개발자로 거의 10년 일했고, 현재 직책과 실제로 제품을 내보내는 능력을 보면 내가 유능하다는 것도 알고 가짜 개발자가 아니라는 것도 앎
      그런데 요즘 개발자 블로그를 보면 내가 충분히 알지 못하고 “진짜” 개발자가 아닌 것처럼 압도됨
      머릿속에 이상적인 개발자상을 만들어 두고 그 상상 속 기준과 나를 비교하면서 이런 감정이 생기는 것 같음
      깊은 지식이 많고 명확하고 간결하게 표현하는 사람들을 존경하면서도, 왜 나는 저렇지 않은지 생각하게 됨
      일 끝나고 가족을 돌보고 나면 뭔가 더 할 에너지가 거의 없고, 프로그래밍이 전부는 아니라는 것도 알지만 더 배우고 성장하고 싶은 마음은 있음
      건강하지도 합리적이지도 않다는 걸 알지만, 요즘은 떨쳐내기 어려운 감정임
    • 솔직히 HN을 열 때마다 절반쯤은 가면 증후군을 느낌
      예전에 VM 경험이 조금 있었고, 몇 년 전 블로그에 짧은 글 시리즈를 쓴 적도 있음
      이전 직장에서는 고객의 아주 특이한 문제를 해결하려고 JVM 바이트코드를 조금 만져 보기도 했음
      또 몇 년 전 훌륭한 https://craftinginterpreters.com/를 읽었고, 거기서 아이디어를 얻었음
      하지만 이 프로젝트는 분명 크고 복잡했음
      시간이 많이 걸렸고, 내 많은 사이드 프로젝트처럼 몇 번 방치되기도 했지만, 끝내서 기쁨 :-)
    • Rust 전문가는 아니고 원 작성자도 아니지만, 다른 기술인 소켓을 예로 말할 수 있음
      최근 소켓을 깊게 파고들었는데, 2주 전만 해도 매뉴얼 페이지, 문서, 블로그 글 등을 대충 읽어 얻은 높은 수준의 이해만 있었음
      네트워킹 기초를 이해하고 싶어서 최대한 많이 읽기로 했고, 일주일 뒤에는 Python과 C로 소켓 코드를 쓸 정도로 배웠음
      Python은 꽤 잘 알기 때문에 깊게 파고든 뒤 sockets 라이브러리를 보는 게 더 잘 이해됐음
      기술 A를 언어 X로 더 잘하고 싶다면, 기술 A에 대해 최대한 많이 읽거나 보고, 언어 Y로 뭔가 만들어 보는 걸 권함
      그러고 나서 언어 X로 돌아오면 기술 A 주변 개념은 이미 상당히 익힌 상태가 됨
    • 작게 나누면 됨
      단순한 언어 VM에는 메모리 안의 객체 표현, 바이트코드 인터프리터, 간단한 가비지 컬렉터, 로더가 있을 것임
      바이트코드 인터프리터는 스택, 그 스택 위에서 함수를 표현하는 방법, 각 바이트코드를 해석하고 프로그램 카운터를 움직이는 루프로 볼 수 있음
    • 여가 시간에 코딩을 얼마나 하는지가 중요함. 평균적으로 주당 몇 시간인지
      0시간이라면, 물론 비난할 일은 아니고 집중할 다른 일도 많지만, 몇 년 동안 평균 주 10~20시간씩 사이드 프로젝트를 한 사람이 인상적인 결과물을 내는 건 놀랍지 않음
  • 비슷한 프로젝트를 뻔뻔하게 홍보하자면: https://github.com/tenaf0/rust-jvm3

  • 386(486) AT 클론용으로 무료 운영체제를 만들고 있음. 그냥 취미이고, gnu처럼 크고 전문적인 건 아님 :-)

    • Rust로 데모 커널을 작성하는 no-std 튜토리얼이 있음: https://os.phil-opp.com
      osdev.org, sandpile.org, RBIL, freevga도 참고할 만함
      가장 큰 골칫거리는 하드웨어 지원임
      신뢰할 수 있는 포트 I/O나 문서화되지 않은 하드웨어 트릭 같은 레시피가 담긴 좋은 빈티지 종이책도 많음
      Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4
      Microsoft MS-DOS Programmer's Reference, real-mode BIOS 호출도 포함
      PC Interrupts
      Undocumented PC
      PC Intern
      Programmer's Guide To The EGA, VGA, And Super VGA Cards
      Graphics Programming Black Book Special Edition
      또 단일 커널, 마이크로커널, 하이브리드 시대 이후의 운영체제 개발 진전도 실험해 볼 가치가 있음
      seL4 같은 capability 기반 구조는 capability와 뛰어난 IPC를 포함해 성능과 보안에서 본질적 장점이 여럿 있음
      POSIX 호환 계층도 중요함. 스레드나 프로세스 개념이 없는 임베디드 운영체제도 POSIX를 구현할 수 있음
      하이퍼바이저는 Intel VT-[xd]가 있으면 훨씬 쉽게 추가할 수 있고, 없으면 에뮬레이션으로 후퇴하면 됨. 변환식 에뮬레이션은 성능이 매우 좋음
      인터럽트 핸들러를 일반화하고 빠르게 만들며, 경쟁 상태를 피하고, 락프리 패턴을 쓰는 데 능숙해져야 함
      x87과 MMX를 포함해 지원하지 않는 명령어를 다시 쓰거나 트랩하는 것도 필요함
      순수 마이크로커널이 실패한 이유는 여러 자원을 트랜잭션 방식으로 순서화하고 관리하는 데 드는 복잡성이 커졌기 때문임
      마이크로커널 아키텍처에는 이론적으로 보안과 운영상 큰 장점이 있지만, 순수한 형태로 널리 자리 잡지는 못했음