러스트로 작성한 JVM
(andreabergia.com)- Rust를 배우기 위한 사이드 프로젝트
rjvm은 학습용 토이 JVM이지만, 실제.class와jar를 읽고 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는 Rustenum으로 구현되며 다음 상태를 포함함UninitializedInt(i32)Long(i64)Float(f32)Double(f64)Object(AbstractObject<'a>)Null
- Rust의
enum같은 합 타입은 하나의 값이 여러 타입 중 하나일 수 있다는 점을 표현하기에 잘 맞음 - 객체 저장은 처음에는 클래스 참조와 필드 값을 담은
Vec<Value>기반의 단순Object구조체로 시작함 - 가비지 컬렉터 구현 뒤에는 포인터와 캐스트를 많이 쓰는 더 낮은 수준의 구현으로 바뀜
- 현재
AbstractObject는 실제 객체나 배열을 모델링하며, 헤더 워드 몇 개와 필드 값들을 담은 바이트 배열 포인터임
바이트코드 명령 실행
- 메서드 실행은 바이트코드 명령을 하나씩 처리하는 과정임
- JVM 명령은 200개가 넘고, 바이트코드에서는 1바이트로 인코딩됨
- 많은 명령에는 인자가 뒤따르며 일부 명령은 가변 길이임
rjvm은Instruction타입으로 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 같은 동시성·제어 흐름 원시 기능도 구현하기 쉬워짐 - 가비지 컬렉터는 기본적으로 꼼꼼한 회계라서 꽤 쉬운 편이지만, 동시 가비지 컬렉션을 시작하는 순간 지옥처럼 어려워짐
- 이 VM은 네이티브 호출 스택 대신 자체 가상 호출 스택을 유지하므로 꽤 직관적임
-
좋은 프로젝트이고 축하함
다만 “지원하지 않는 것: 제네릭”이라는 부분이 조금 이상하게 느껴짐
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의 일부임
스레드가 없다는 점은 전체 시도를 장난감 프로젝트 수준으로 만듦
- 제네릭에 대해서는 reddit에서도 같은 이야기가 나왔고, 맞는 말임
-
정말 멋짐. 1992년에 Java 작업에 합류했을 때는 이름이 Oak였고, 내가 있던 그룹은 Java로 전체 운영체제를 작성하는 방향을 보고 있었음
“기계어”, 즉 네이티브 메서드로 필요한 최소 부분만 남기면 임베디드 운영체제의 공격 표면을 줄일 수 있다는 아이디어였음
원래 Java는 TV나 가전제품 같은 곳에서 돌리는 걸 목표로 했고, 당시 네이티브 메서드는 Rust가 아니라 C로 작업했음
Rust로 만든 JVM은 이 전체 과정에 꽤 강한 메모리 안전성을 더해 줌- 개인적으로는 Android가 어느 정도 그걸 달성했다고 봄
운영체제 로직을 Java나 Kotlin으로 많이 작성하지만, 동시에 네이티브 코드로 된 시스템 서비스도 많이 섞고, 유명하거나 악명 높은 Binder IPC로 서로 연결함 - 이 아이디어는 분명 몇 번 시도된 적이 있음 [0, 1]
[0] - https://en.wikipedia.org/wiki/Singularity_(operating_system)
[1] - https://en.wikipedia.org/wiki/Midori_(operating_system) - 한동안 사용자용은 아니었지만 JavaOS가 있었던 것 같음: https://en.wikipedia.org/wiki/JavaOS
- 형제 댓글들 외에도 SavageJE, microEJ, PTC와 Aonix의 bare metal Java 런타임, SunSPOT의 SquawkVM 같은 것들이 있음
- 개인적으로는 Android가 어느 정도 그걸 달성했다고 봄
-
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>>
Result의Err변형에 수명을 추가하고 그 수명이 불변이면, 여기서는vm과call_stack때문에 그렇듯, 보통?연산자나 조기 반환을 쓸 수 없게 됨[1]
그러면 오류 처리가 더 장황하고 읽기 어려워지는데, 실제로도 그런 경험이었는지 궁금함
[1] https://users.rust-lang.org/t/nll-and-early-return-not-allow...- 정정하자면, 여기서는 불변 수명
'a가vm이나call_stack의 가변 참조에 쓰이지 않으므로 이 문제는 아닌 것 같음
문제가 불변성 자체라기보다 Rust가 가변 참조의 수명을 추론하는 방식인데, 이 코드는 그걸 피하고 있음
그렇다면VM과CallStack에 붙은'a가 무슨 의미인지 모르겠음
제한 없는 임의의 수명, 심지어'static으로도 만들 수 있다면[1][2][3] 아무 제약을 걸지 않는 셈임
이 수명 매개변수는 여기서 무엇을 하고 있고, 왜 제거하지 않는지 궁금함
[1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[2] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[3] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
- 정정하자면, 여기서는 불변 수명
-
훌륭한 학습 프로젝트이고, 작성자가 재미있게 하고 있다니 좋음
VM을 바닥부터 구현하는 건 정말 즐겁고, 과거에 그런 작업을 하면서 많이 배웠음
가비지 컬렉션을 붙여 보고 싶다면 MMTk를 살펴봐도 좋음 (https://www.mmtk.io/)
여러 VM에 꽂아 쓸 수 있도록 설계된 고품질 수집 알고리즘들이 있고 Rust로 작성돼 있음- 참고로 MMTK는 x86 전용임
장난감 프로젝트에 쓰려다가 Mac이라 포기했음
- 참고로 MMTK는 x86 전용임
-
아주 잘 만들었음
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를 포함해 지원하지 않는 명령어를 다시 쓰거나 트랩하는 것도 필요함
순수 마이크로커널이 실패한 이유는 여러 자원을 트랜잭션 방식으로 순서화하고 관리하는 데 드는 복잡성이 커졌기 때문임
마이크로커널 아키텍처에는 이론적으로 보안과 운영상 큰 장점이 있지만, 순수한 형태로 널리 자리 잡지는 못했음
- Rust로 데모 커널을 작성하는 no-std 튜토리얼이 있음: https://os.phil-opp.com