3 pontos por GN⁺ 2023-07-22 | 1 comentários | Compartilhar no WhatsApp
  • rjvm, um projeto paralelo para aprender Rust, é uma JVM de brinquedo para aprendizado, mas foi implementada até o ponto de ler .class e jar reais 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.jar real do OpenJDK 7, então classes como java.lang.StackTraceElement são carregadas de um JDK real
  • O código é dividido em três crates Rust: reader, vm e vm_cli, cobrindo desde o parsing de .class até 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

  • rjvm não para em um parser simples e implementa diretamente vários comportamentos da JVM
    • controle de fluxo como if e for
    • 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
  • O código de teste inclui exemplos que usam Throwable, Exception e StackTraceElement
  • Como não há I/O real, em vez de System.out.println é usado um método nativo chamado tempPrint
  • O exemplo usa o rt.jar real do OpenJDK 7 e até java.lang.StackTraceElement vem 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 .class e inclui tipos que modelam seu conteúdo
    • vm: fornece, em forma de biblioteca, uma máquina virtual capaz de executar código
    • vm_cli: inclui um lançador simples de linha de comando, semelhante ao executável java
  • Está sendo considerada a possibilidade de separar o crate reader em um repositório próprio e publicá-lo no crates.io

Parsing de arquivos .class

  • Código Java é compilado por javac em arquivos .class e normalmente distribuído em arquivos .jar, que usam o formato zip
  • Para executar código Java, primeiro é preciso carregar os arquivos .class que 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 reader faz 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 CallFrame dentro de uma CallStack
  • 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 rjvm també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::arraycopy e Throwable::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

  • Value modela os valores que podem ser armazenados em variáveis locais, elementos da pilha e campos de objetos
  • Value é implementado como um enum Rust e inclui os seguintes estados
    • Uninitialized
    • Int(i32)
    • Long(i64)
    • Float(f32)
    • Double(f64)
    • Object(AbstractObject<'a>)
    • Null
  • Tipos soma como o enum de 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 Object simples, baseada em Vec<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, AbstractObject modela 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
  • rjvm modela instruções de bytecode Java com o tipo Instruction
  • 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, for e while
  • 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 com push e 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 catch corresponde 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_instruction usa o Result do 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 push e a execução continua na posição do handler catch
  • O Result do Rust e pattern matching se encaixam bem para expressar esse comportamento na estrutura do código

Coleta de lixo

  • O último grande marco do rjvm foi 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 rjvm e o funcionamento da JVM

1 comentários

 
GN⁺ 2023-07-22
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_collection está marcado como unsafe[1], mas não há explicação sobre o que o chamador precisa garantir para chamá-lo com segurança
    Fico 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 erro 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...

    • Esta VM mantém sua própria pilha de chamadas virtual, em vez da pilha de chamadas nativa, então isso é bastante intuitivo
      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
    • Garbage collectors são basicamente uma contabilidade minuciosa, então tendem a ser relativamente fáceis, mas, no momento em que você começa com garbage collection concorrente, eles ficam infernalmente difíceis
  • É 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álido

    • A mesma coisa foi dita no reddit sobre genéricos, e está correta
      O 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); }
    • Talvez esteja se referindo à operação 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...
    • Em geral, está correto. Genéricos raramente afetam a reflexão, mas a reflexão também não é suportada, e, de modo geral, eles são substituídos em tempo de compilação pela classe ou interface mais próxima
      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

  • Também vale ver https://jacobin.org/, uma implementação de JVM 17 escrita em Go

    • Há também https://github.com/lihaoyi/Metascala, uma JVM implementada em Scala que roda sobre a JVM
    • É um nome bem interessante para um projeto de programação
      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 Err de Result e esse lifetime é invariante, como acontece aqui por causa de vm e call_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...

  • É 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

    • Só para constar, o MMTk é exclusivo para x86
      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

    • Sinto o mesmo
      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
    • Sinceramente, quase metade das vezes que abro o HN sinto síndrome do impostor
      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 :-)
    • Não sou especialista em Rust nem o autor original, mas posso falar usando outra tecnologia, sockets, como exemplo
      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 sockets
      Se 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
    • Basta dividir em partes pequenas
      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
    • O que importa é quanto você programa no tempo livre. Em média, quantas horas por semana
      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 :-)

    • Há um tutorial no-std que escreve um kernel de demonstração em Rust: https://os.phil-opp.com
      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