Uma JVM escrita em Rust
(andreabergia.com)rjvm, um projeto paralelo para aprender Rust, é uma JVM de brinquedo para aprendizado, mas foi implementada até o ponto de ler.classejarreais e executar bytecode Java- Os recursos não suportados são threads, reflexão, anotações, I/O, compilador JIT e interning de strings, mas inclui controle de fluxo, criação de objetos, chamadas de método, exceções e coleta de lixo
- O exemplo usa o
rt.jarreal do OpenJDK 7, então classes comojava.lang.StackTraceElementsão carregadas de um JDK real - O código é dividido em três crates Rust:
reader,vmevm_cli, cobrindo desde o parsing de.classaté pilha de chamadas, métodos nativos e modelagem de valores e objetos - O último marco foi um stop-the-world semispace copying collector e, depois de atingir o objetivo, o projeto foi encerrado sem plano de corrigir bugs conhecidos
rjvm, uma JVM em Rust para aprendizado
rjvmé uma Java Virtual Machine de brinquedo criada para aprender Rust- O código está disponível no GitHub
- Como é uma implementação voltada a aprendizado, e não uma JVM séria para produção, ela não oferece os seguintes recursos
- threads
- reflexão
- anotações
- I/O
- compilador JIT
- interning de strings
- Genéricos estavam inicialmente na lista de recursos não suportados, mas na prática funcionam
Até onde foi implementada
rjvmnão para em um parser simples e implementa diretamente vários comportamentos da JVM- controle de fluxo como
ifefor - tipos primitivos e criação de objetos
- chamadas de métodos virtuais e estáticos
- tratamento de exceções
- coleta de lixo
- resolução de classes a partir de arquivos
jar
- controle de fluxo como
- O código de teste inclui exemplos que usam
Throwable,ExceptioneStackTraceElement - Como não há I/O real, em vez de
System.out.printlné usado um método nativo chamadotempPrint - O exemplo usa o
rt.jarreal do OpenJDK 7 e atéjava.lang.StackTraceElementvem do JDK real
Três crates Rust
- O projeto segue o padrão de um projeto Rust e é composto por três crates
reader: lê arquivos.classe inclui tipos que modelam seu conteúdovm: fornece, em forma de biblioteca, uma máquina virtual capaz de executar códigovm_cli: inclui um lançador simples de linha de comando, semelhante ao executáveljava
- Está sendo considerada a possibilidade de separar o crate
readerem um repositório próprio e publicá-lo no crates.io
Parsing de arquivos .class
- Código Java é compilado por
javacem arquivos.classe normalmente distribuído em arquivos.jar, que usam o formatozip - Para executar código Java, primeiro é preciso carregar os arquivos
.classque contêm o bytecode gerado pelo compilador - O arquivo de classe também inclui as informações necessárias para execução e resolução de tipos
- metadados como nome da classe e nome do arquivo-fonte
- nome da superclasse
- interfaces implementadas
- campos, tipos dos campos e anotações
- descritores de métodos, cláusulas
throws, anotações e informações de genéricos - bytecode, tabela de handlers de exceção e tabela de números de linha
- O crate
readerfaz o parsing do arquivo de classe e retorna uma struct Rust que modela a classe e seu conteúdo
Execução de métodos e pilha de chamadas
- A principal API do crate
vméVm::invoke, usada para executar métodos - Cada método em execução possui um
CallFramedentro de umaCallStack - Ao executar
main, a pilha de chamadas começa vazia e um novo frame é criado para iniciar a execução - A cada chamada de função, um novo frame é adicionado à pilha de chamadas e, quando a execução do método termina, esse frame é removido
- A maioria dos métodos é implementada em bytecode Java, mas
rjvmtambém suporta métodos nativos- métodos nativos não são bytecode Java; são implementações diretas da própria JVM
- exemplos incluem
System::currentTimeMillis,System::arraycopyeThrowable::fillInStackTrace - em
rjvm, eles são implementados como funções Rust
- A JVM é uma máquina virtual baseada em pilha, então as instruções de bytecode operam principalmente sobre a pilha de valores
- Cada frame de chamada também tem associada uma pilha de valores e um conjunto de variáveis locais identificadas por índice
Modelagem de valores e objetos
Valuemodela os valores que podem ser armazenados em variáveis locais, elementos da pilha e campos de objetosValueé implementado como umenumRust e inclui os seguintes estadosUninitializedInt(i32)Long(i64)Float(f32)Double(f64)Object(AbstractObject<'a>)Null
- Tipos soma como o
enumde Rust são adequados para representar que um único valor pode ser de vários tipos possíveis - O armazenamento de objetos começou com uma struct
Objectsimples, baseada emVec<Value>, contendo referência à classe e valores dos campos - Depois da implementação do coletor de lixo, isso mudou para uma implementação de nível mais baixo, com bastante uso de ponteiros e casts
- Atualmente,
AbstractObjectmodela objetos reais ou arrays, sendo um ponteiro para um array de bytes com algumas palavras de cabeçalho e os valores dos campos
Execução de instruções de bytecode
- Executar um método é processar instruções de bytecode uma a uma
- Existem mais de 200 instruções da JVM, codificadas em 1 byte no bytecode
- Muitas instruções são seguidas por argumentos, e algumas têm tamanho variável
rjvmmodela instruções de bytecode Java com o tipoInstruction- Ao executar um método, a pilha de valores e o array de variáveis locais são mantidos, e o contador de programa é inicializado em 0, apontando para a próxima instrução a executar
- Em geral, depois da execução de uma instrução, o contador de programa avança para a próxima, mas instruções de salto podem movê-lo para outra posição
- Instruções de salto são usadas para implementar controles de fluxo como
if,forewhile - Há também uma família separada de instruções que chamam outros métodos
- a definição de qual método chamar pode envolver resolução virtual ou estática
- depois de resolver o método alvo, um novo frame é adicionado à pilha de chamadas e a execução começa
- se o valor de retorno não for
void, ele é colocado na pilha compushe a execução é retomada
Tratamento de exceções
- Exceções quebram o fluxo normal de controle, podem encerrar um método antecipadamente e se propagar pela pilha de chamadas, o que torna sua implementação complexa
- Cada bloco
catchcorresponde a uma entrada da tabela de exceções do método - Cada entrada na tabela de exceções contém as informações necessárias para localizar um handler
- o intervalo de contador de programa ao qual se aplica
- o endereço da primeira instrução do bloco
catch - o nome da classe de exceção capturada por aquele bloco
CallFrame::execute_instructionusa oResultdo Rust para representar o resultado da execução de uma instrução- O resultado da execução de uma instrução é dividido em quatro estados
- teve sucesso e a execução do método atual continua
- teve sucesso e, por ser uma instrução
return, o método atual termina com um valor de retorno - falhou por erro interno da VM
- falhou porque uma exceção Java foi lançada
- O loop de execução do método faz o parsing da instrução, move o contador de programa para o próximo endereço e então executa a instrução
- Quando ocorre uma exceção, é buscado um handler compatível com a posição atual da instrução
- se não houver handler, a exceção é propagada ao chamador
- se houver handler, o objeto de exceção é colocado novamente na pilha com
pushe a execução continua na posição do handlercatch
- O
Resultdo Rust e pattern matching se encaixam bem para expressar esse comportamento na estrutura do código
Coleta de lixo
- O último grande marco do
rjvmfoi a implementação do coletor de lixo - O algoritmo escolhido foi um stop-the-world semispace copying collector
- Como não há threads, a abordagem stop-the-world se encaixa naturalmente
- A implementação é uma variação mais simples do algoritmo de Cheney, e o código está em gc.rs
- Essa abordagem divide a memória disponível em dois semispaces
- um é a região ativa usada para alocação de objetos
- o outro permanece como região não utilizada
- quando a região ativa enche, os objetos vivos são copiados para o outro semispace
- todas as referências de objeto são atualizadas para apontar para as novas cópias
- os papéis dos dois semispaces são então trocados
- Esse procedimento é comparado, por analogia, a um blue-green deployment
- O algoritmo tem vantagens e desvantagens bem claras
- não é possível usar até metade da memória máxima, então há bastante desperdício de memória
- a alocação é muito rápida porque ocorre por incremento de ponteiro
- como os objetos são copiados e compactados, não é preciso lidar com fragmentação de memória
- a compactação dos objetos pode melhorar o desempenho ao aproveitar melhor as cache lines
- VMs Java reais normalmente usam coletores geracionais mais sofisticados, como G1 ou parallel GC
O ponto final do projeto
- Ao construir o
rjvm, o autor aprendeu muito sobre Rust e implementação de máquinas virtuais, e ficou especialmente satisfeito por ter implementado um coletor de lixo realmente funcional - O coletor de lixo não é altamente refinado, mas funciona de fato
- Como o objetivo original foi alcançado, o projeto é encerrado aqui
- Há bugs conhecidos, mas não há planos para corrigi-los
- Rust foi uma linguagem divertida de usar na implementação de uma JVM, e textos futuros devem abordar em mais detalhes a implementação do
rjvme o funcionamento da JVM
1 comentários
Opiniões do Hacker News
A parte difícil na implementação de garbage collection é garantir que todas as referências sejam corretamente capturadas como raízes, especialmente em coletores móveis
O método
do_garbage_collectionestá marcado comounsafe[1], mas não há explicação sobre o que o chamador precisa garantir para chamá-lo com segurançaFico curioso para saber como se garante que todas as referências ao heap sejam capturadas como raízes, e isso não é um problema trivial[2][3][4]
Além disso, clonei o repositório e rodei
cargo test, e todos os testes falharam com o erroshould 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...
Assim, é possível percorrer essa pilha para encontrar parâmetros e variáveis locais e usá-los como raízes
Essa abordagem tem custo de desempenho, mas torna o rastreamento do garbage collection muito mais simples e também facilita implementar primitivas de concorrência e de fluxo de controle, como corrotinas ou continuations
É um bom projeto, parabéns
Mas a parte “não há suporte a: genéricos” parece um pouco estranha
Fico me perguntando em que sentido a JVM precisaria dar suporte a genéricos
No nível do bytecode, por causa do type erasure, não daria para pensar em tudo simplesmente como
Object, ou seja, um tipo de referência? Ou você está falando do parser de definições de classe? Mesmo assim, além da sintaxe básica, não parece haver lógica para verificar se o arquivo de classe é válidoO que realmente precisa ser feito é basicamente ler o atributo Signature, que contém as informações de genéricos de classes, métodos e campos (https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.ht...)
Na verdade, acabei de testar e o código abaixo também funciona :-)
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...Por exemplo, ela é gerada quando você escreve algo como
final Main value = list.get(0);http://henrikeichenhardt.blogspot.com/2013/05/how-are-java-g...
Por outro lado, a ausência de interning de strings é muito estranha. A implementação também é relativamente trivial, e sem isso fica difícil chamar de JVM
O fato de strings se tornarem iguais por referência é importante e faz parte da JLS
A ausência de threads torna toda a tentativa algo no nível de um projeto de brinquedo
Muito legal. Quando entrei no trabalho com Java em 1992, ele se chamava Oak, e o grupo em que eu estava estava considerando escrever um sistema operacional inteiro em Java
A ideia era reduzir a superfície de ataque de um sistema operacional embarcado deixando apenas o mínimo necessário em “código de máquina”, ou seja, métodos nativos
Originalmente, o Java tinha como alvo rodar em lugares como TVs e eletrodomésticos, e na época os métodos nativos eram feitos em C, não em Rust
Uma JVM feita em Rust acrescenta uma segurança de memória bem forte a todo esse processo
Ele escreve muita lógica do sistema operacional em Java ou Kotlin, mas ao mesmo tempo mistura muitos serviços de sistema em código nativo, conectados entre si pelo famoso — ou infame — Binder IPC
[0] - https://en.wikipedia.org/wiki/Singularity_(operating_system)
[1] - https://en.wikipedia.org/wiki/Midori_(operating_system)
Também vale ver https://jacobin.org/, uma implementação de JVM 17 escrita em Go
Os Jacobinos eram um clube político revolucionário da Revolução Francesa na década de 1790, e também é o nome da revista em https://jacobin.com
Fico curioso se eles esbarraram em limitações por causa dos parâmetros de lifetime desta assinatura
fn execute_instruction(&mut self, vm: &mut Vm<'a>, call_stack: &mut CallStack<'a>, instruction: Instruction) -> Result, MethodCallFailed<'a>>Se você adiciona um lifetime à variante
ErrdeResulte esse lifetime é invariante, como acontece aqui por causa devmecall_stack, normalmente não consegue usar o operador?nem retornos antecipados[1]Isso torna o tratamento de erros mais verboso e mais difícil de ler; fico curioso se essa foi de fato a experiência
[1] https://users.rust-lang.org/t/nll-and-early-return-not-allow...
'anão é usado nas referências mutáveis devmoucall_stack, então parece que esse não é o problemaO problema não é exatamente a invariância em si, mas a forma como Rust infere o lifetime de referências mutáveis; este código evita isso
Nesse caso, não sei o que significa o
'aemVMeCallStackSe eles podem ser criados com um lifetime arbitrário sem restrições, até mesmo
'static[1][2][3], então isso não impõe nenhuma restriçãoFico curioso para saber o que esse parâmetro de lifetime está fazendo aqui e por que não o removem
[1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[2] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[3] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
É um excelente projeto de aprendizado, e é bom ver que o autor está se divertindo com ele
Implementar uma VM do zero é realmente divertido, e aprendi muito fazendo esse tipo de trabalho no passado
Se quiser experimentar adicionar coleta de lixo, vale dar uma olhada no MMTk (https://www.mmtk.io/)
Ele tem algoritmos de coleta de alta qualidade projetados para serem plugados em várias VMs, e é escrito em Rust
Tentei usá-lo em um projeto de brinquedo, mas desisti porque uso Mac
Muito bem feito
Criar uma VM é sempre divertido, e combinado com o sistema de tipos do Rust deve ter sido uma experiência de aprendizado interessante
Se estiver procurando emprego, pode entrar em contato pelo Twitter, Mastodon ou e-mail da empresa. Você deve conseguir encontrar olhando o ID de usuário aqui
Ver projetos incríveis assim dá uma sensação de ficar esmagado
Seria bom se o autor original explicasse como começar com Rust e dominar o básico até um nível em que dê para tentar algo desse tipo
Não quero desviar demais do assunto, mas pessoalmente tenho lutado bastante com esse sentimento ultimamente
Trabalho como desenvolvedor de software profissional há quase 10 anos e, olhando para meu cargo atual e minha capacidade real de entregar produtos, sei que sou competente e que não sou um desenvolvedor de mentira
Mas, quando leio blogs de desenvolvedores hoje em dia, fico sobrecarregado, como se eu não soubesse o suficiente e não fosse um desenvolvedor “de verdade”
Acho que esse sentimento surge porque criei na minha cabeça uma imagem ideal de desenvolvedor e fico me comparando com esse padrão imaginário
Ao mesmo tempo em que admiro pessoas com muito conhecimento profundo e que se expressam de forma clara e concisa, acabo pensando por que eu não sou assim
Depois do trabalho e de cuidar da família, quase não sobra energia para fazer mais alguma coisa, e sei que programação não é tudo, mas ainda tenho vontade de aprender mais e crescer
Sei que isso não é saudável nem racional, mas ultimamente é um sentimento difícil de afastar
Eu já tive um pouco de experiência com VMs no passado e, alguns anos atrás, escrevi uma pequena série de posts no blog
Em um emprego anterior, também mexi um pouco com bytecode da JVM para resolver um problema bem específico de um cliente
Além disso, li há alguns anos o excelente https://craftinginterpreters.com/ e tirei ideias dali
Ainda assim, este projeto foi claramente grande e complexo
Levou muito tempo e, como muitos dos meus projetos paralelos, ficou abandonado algumas vezes, mas fico feliz por tê-lo terminado :-)
Recentemente me aprofundei em sockets e, até duas semanas atrás, eu só tinha uma compreensão de alto nível obtida lendo por cima páginas de manual, documentação, posts de blog etc.
Eu queria entender os fundamentos de redes, então decidi ler o máximo possível e, uma semana depois, já tinha aprendido o suficiente para escrever código com sockets em Python e C
Como conheço Python razoavelmente bem, depois de me aprofundar ficou bem mais fácil entender a biblioteca
socketsSe você quer ficar melhor na tecnologia A na linguagem X, recomendo ler ou assistir ao máximo de conteúdo sobre a tecnologia A e criar algo na linguagem Y
Depois, ao voltar para a linguagem X, você já terá aprendido bastante dos conceitos em torno da tecnologia A
Uma VM de linguagem simples provavelmente terá uma representação de objetos em memória, um interpretador de bytecode, um coletor de lixo simples e um loader
O interpretador de bytecode pode ser visto como uma pilha, uma forma de representar funções nessa pilha e um loop que interpreta cada bytecode e move o contador de programa
Se forem 0 horas, claro que não há nada a criticar e há muitas outras coisas em que se concentrar, mas não é surpreendente que alguém que fez projetos paralelos por uma média de 10 a 20 horas por semana durante anos produza algo impressionante
Fazendo propaganda descarada de um projeto parecido: https://github.com/tenaf0/rust-jvm3
Estou criando um sistema operacional gratuito para clones AT 386(486). É só um hobby, não algo grande e profissional como o gnu :-)
osdev.org, sandpile.org, RBIL e freevga também valem a consulta
A maior dor de cabeça é o suporte a hardware
Também há muitos bons livros antigos em papel com receitas para coisas como I/O de portas confiável ou truques de hardware não documentados
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, incluindo chamadas de BIOS em modo real
PC Interrupts
Undocumented PC
PC Intern
Programmer's Guide To The EGA, VGA, And Super VGA Cards
Graphics Programming Black Book Special Edition
Também vale a pena experimentar avanços no desenvolvimento de sistemas operacionais depois das eras de kernel monolítico, microkernel e híbrido
Estruturas baseadas em capabilities como seL4 têm várias vantagens intrínsecas em desempenho e segurança, incluindo capabilities e excelente IPC
Uma camada de compatibilidade POSIX também é importante. Até sistemas operacionais embarcados sem conceitos de threads ou processos podem implementar POSIX
Hypervisors são muito mais fáceis de adicionar quando há Intel VT-[xd]; sem isso, dá para recorrer à emulação. Emulação por tradução tem desempenho muito bom
É preciso se tornar hábil em generalizar e tornar rápidos os handlers de interrupção, evitar condições de corrida e usar padrões lock-free
Também é necessário reescrever ou capturar por trap instruções não suportadas, incluindo x87 e MMX
O motivo pelo qual microkernels puros falharam foi o aumento da complexidade de ordenar e gerenciar vários recursos de forma transacional
Arquiteturas de microkernel têm, em teoria, grandes vantagens de segurança e operação, mas não chegaram a se estabelecer amplamente em sua forma pura