Se alguém precisar de JVMs diferentes para arquiteturas diferentes, não consigo descobrir qual é a lógica por trás da introdução desse conceito. Em outras linguagens, precisamos de compiladores diferentes para máquinas diferentes, mas em Java exigimos JVMs diferentes. Qual é a lógica por trás da introdução do conceito de JVM ou dessa etapa extra?
37
Respostas:
A lógica é que o bytecode da JVM é muito mais simples que o código-fonte Java.
Os compiladores podem ser considerados, em um nível altamente abstrato, como tendo três partes básicas: análise, análise semântica e geração de código.
A análise consiste em ler o código e transformá-lo em uma representação em árvore na memória do compilador. A análise semântica é a parte em que analisa essa árvore, descobre o que significa e simplifica todas as construções de alto nível até as de nível inferior. E a geração de código pega a árvore simplificada e a grava em uma saída plana.
Com um arquivo de bytecode, a fase de análise é bastante simplificada, pois é escrita no mesmo formato de fluxo de bytes simples que o JIT usa, em vez de um idioma de origem recursivo (estruturado em árvore). Além disso, grande parte do trabalho pesado da análise semântica já foi realizada pelo compilador Java (ou outra linguagem). Portanto, tudo o que precisa fazer é ler o código por fluxo, fazer análise mínima e análise semântica mínima e executar a geração de código.
Isso torna a tarefa que o JIT deve executar muito mais simples e, portanto, muito mais rápida de executar, preservando ainda os metadados de alto nível e as informações semânticas que possibilitam escrever teoricamente código de plataforma única e fonte única.
fonte
Representações intermediárias de vários tipos são cada vez mais comuns no design do compilador / tempo de execução, por alguns motivos.
No caso de Java, o motivo número um inicialmente foi provavelmente a portabilidade : o Java foi amplamente comercializado inicialmente como "Write Once, Run Anywhere". Embora você possa conseguir isso distribuindo o código-fonte e usando compiladores diferentes para atingir plataformas diferentes, isso tem algumas desvantagens:
Outras vantagens de uma representação intermediária incluem:
fonte
Parece que você está se perguntando por que não distribuímos apenas o código-fonte. Deixe-me mudar essa questão: por que não distribuímos código de máquina?
Claramente, a resposta aqui é que Java, por design, não assume que sabe qual é a máquina onde seu código será executado; pode ser um desktop, um supercomputador, um telefone ou qualquer coisa entre e além. Java deixa espaço para o compilador JVM local fazer sua parte. Além de aumentar a portabilidade do seu código, isso tem o benefício de permitir que o compilador faça coisas como tirar proveito das otimizações específicas da máquina, se elas existirem, ou ainda produzir pelo menos código funcional, se não existirem. Coisas como instruções SSE ou aceleração de hardware podem ser usadas apenas nas máquinas que os suportam.
Visto sob essa luz, o raciocínio para usar o código de bytes em vez do código-fonte bruto é mais claro. Ficar o mais próximo possível da linguagem bruta da máquina nos permite perceber ou perceber parcialmente alguns dos benefícios do código da máquina, como:
Note que eu não mencionei uma execução mais rápida. O código fonte e o código de bytes são ou podem (em teoria) ser totalmente compilados no mesmo código de máquina para execução real.
Além disso, o código de bytes permite algumas melhorias em relação ao código da máquina. É claro que existem a independência da plataforma e as otimizações específicas de hardware que mencionei anteriormente, mas também existem serviços como o compilador JVM para produzir novos caminhos de execução a partir do código antigo. Isso pode ser para corrigir problemas de segurança ou se novas otimizações forem descobertas ou tirar proveito das novas instruções de hardware. Na prática, é raro ver grandes mudanças dessa maneira, porque pode expor bugs, mas é possível, e é algo que acontece de maneiras pequenas o tempo todo.
fonte
Parece haver pelo menos duas questões possíveis diferentes aqui. Um é realmente sobre compiladores em geral, com Java basicamente apenas um exemplo do gênero. O outro é mais específico para Java, os códigos de bytes específicos que ele usa.
Compiladores em geral
Vamos primeiro considerar a questão geral: por que um compilador usaria uma representação intermediária no processo de compilação do código-fonte para executar em algum processador em particular?
Redução de complexidade
Uma resposta para isso é bastante simples: converte um problema de O (N * M) em um problema de O (N + M).
Se recebermos N idiomas de origem e M destinos, e cada compilador for completamente independente, precisamos de compiladores N * M para traduzir todos esses idiomas de origem para todos esses destinos (onde um "destino" é algo como uma combinação de um processador e SO).
Se, no entanto, todos esses compiladores concordarem com uma representação intermediária comum, poderemos ter N front-ends do compilador que traduzem os idiomas de origem para a representação intermediária e M back-ends do compilador que traduzem a representação intermediária em algo adequado para um destino específico.
Segmentação de Problemas
Melhor ainda, separa o problema em dois domínios mais ou menos exclusivos. Pessoas que conhecem / se preocupam com design de linguagem, análise e coisas assim podem se concentrar nos front-ends do compilador, enquanto pessoas que conhecem conjuntos de instruções, design de processador e coisas assim podem se concentrar no back-end.
Assim, por exemplo, considerando algo como LLVM, temos muitos front-ends para vários idiomas diferentes. Também temos back-ends para vários processadores diferentes. Um especialista em idiomas pode escrever um novo front-end para o seu idioma e suportar rapidamente muitos destinos. Um cara de processador pode escrever um novo back-end para seu destino sem lidar com o design, a análise de idiomas, etc.
Separar compiladores em um front end e back end, com uma representação intermediária para se comunicar entre os dois, não é original no Java. É uma prática bastante comum há muito tempo (desde muito antes de o Java aparecer).
Modelos de distribuição
Na medida em que o Java adicionou algo novo a esse respeito, estava no modelo de distribuição. Em particular, mesmo que os compiladores tenham sido separados internamente por partes de front-end e back-end por um longo tempo, eles geralmente eram distribuídos como um único produto. Por exemplo, se você comprou um compilador Microsoft C, internamente ele tinha um "C1" e um "C2", que eram o front-end e o back-end respectivamente - mas o que você comprou foi apenas "Microsoft C" que incluía ambos peças (com um "driver de compilador" que coordenava as operações entre os dois). Mesmo que o compilador tenha sido construído em duas partes, para um desenvolvedor normal usando o compilador, foi apenas uma coisa que foi traduzida do código-fonte para o objeto, sem nada visível no meio.
Java, em vez disso, distribuiu o front-end no Java Development Kit e o back-end na Java Virtual Machine. Todo usuário de Java tinha um back-end do compilador para segmentar qualquer sistema que estivesse usando. Os desenvolvedores de Java distribuíram o código no formato intermediário; portanto, quando um usuário o carregava, a JVM fazia o necessário para executá-lo em sua máquina específica.
Precedentes
Observe que esse modelo de distribuição também não era totalmente novo. Apenas por exemplo, o sistema P UCSD funcionou de maneira semelhante: os front-ends do compilador produziram código P, e cada cópia do sistema P incluía uma máquina virtual que fazia o necessário para executar o código P nesse destino específico 1 .
Código de bytes Java
O código de bytes Java é bastante semelhante ao código P. É basicamente instruções para uma máquina bastante simples. Essa máquina pretende ser uma abstração das máquinas existentes, por isso é bastante fácil traduzir rapidamente para praticamente qualquer destino específico. A facilidade da tradução foi importante desde o início, porque a intenção original era interpretar os códigos de bytes, da mesma forma que o P-System (e, sim, é exatamente assim que as implementações iniciais funcionavam).
Forças
O código de bytes Java é fácil para um front-end do compilador produzir. Se (por exemplo) você tiver uma árvore bastante típica que representa uma expressão, é muito fácil atravessá-la e gerar código bastante diretamente a partir do que você encontra em cada nó.
Os códigos de bytes Java são bastante compactos - na maioria dos casos, muito mais compactos do que o código-fonte ou o código de máquina dos processadores mais comuns (e, principalmente, para a maioria dos processadores RISC, como o SPARC que a Sun vendeu quando projetou o Java). Isso foi particularmente importante na época, porque uma das principais intenções do Java era oferecer suporte a applets - código incorporado em páginas da web que seriam baixadas antes da execução - no momento em que a maioria das pessoas acessava o nós via modems através de linhas telefônicas por volta de 28,8 kilobits por segundo (embora, é claro, ainda houvesse muitas pessoas usando modems mais antigos e lentos).
Fraquezas
A principal fraqueza dos códigos de bytes Java é que eles não são particularmente expressivos. Embora eles possam expressar os conceitos presentes em Java muito bem, eles não funcionam tão bem para expressar conceitos que não fazem parte do Java. Da mesma forma, embora seja fácil executar códigos de bytes na maioria das máquinas, é muito mais difícil fazer isso de uma maneira que tira o máximo proveito de qualquer máquina em particular.
Por exemplo, é bastante rotineiro que, se você realmente deseja otimizar códigos de bytes Java, faça basicamente alguma engenharia reversa para convertê-los para trás de uma representação como código de máquina e transformá-los novamente em instruções SSA (ou algo semelhante) 2 . Em seguida, você manipula as instruções SSA para fazer sua otimização e depois traduz a partir daí para algo que atinja a arquitetura com a qual você realmente gosta. Mesmo com esse processo bastante complexo, no entanto, alguns conceitos estranhos ao Java são suficientemente difíceis de expressar que é difícil traduzir de algumas linguagens de origem em código de máquina que é executado (até próximo) de maneira ideal nas máquinas mais comuns.
Sumário
Se você está perguntando por que usar representações intermediárias em geral, dois fatores principais são:
Se você está perguntando sobre as especificidades dos códigos de bytes Java e por que eles escolheram essa representação específica em vez de outra, então eu diria que a resposta volta em grande parte à intenção original e às limitações da Web da época , levando às seguintes prioridades:
Ser capaz de representar muitos idiomas ou executar de maneira ideal em uma ampla variedade de alvos eram prioridades muito mais baixas (se eram consideradas prioridades).
fonte
Além das vantagens apontadas por outras pessoas, o bytecode é muito menor, portanto é mais fácil distribuir e atualizar e ocupa menos espaço no ambiente de destino. Isso é especialmente importante em ambientes com muita restrição de espaço.
Também facilita a proteção do código fonte protegido por direitos autorais.
fonte
O sentido é que a compilação do código de bytes para o código da máquina é mais rápida do que interpretar o código original para o código da máquina na hora certa. Mas precisamos de interpretações para tornar nosso aplicativo multiplataforma, porque queremos usar nosso código original em todas as plataformas, sem alterações e sem preparativos (compilações). Então, primeiro o javac compila nossa fonte em código de bytes, então podemos executá-lo em qualquer lugar e ele será interpretado pela Java Virtual Machine para codificar mais rapidamente. A resposta: economiza tempo.
fonte
Originalmente, a JVM era uma intérprete pura . E você obtém o intérprete com melhor desempenho se o idioma que você está interpretando for o mais simples possível. Esse era o objetivo do código de bytes: fornecer uma entrada eficientemente interpretável para o ambiente de tempo de execução. Essa decisão única colocou o Java mais próximo de uma linguagem compilada do que de uma linguagem interpretada, conforme julgado por seu desempenho.
Somente mais tarde, quando ficou claro que o desempenho das JVMs de interpretação ainda era ruim, as pessoas investiram o esforço para criar compiladores just-in-time de bom desempenho. Isso diminuiu um pouco a diferença para linguagens mais rápidas como C e C ++. (No entanto, alguns problemas de velocidade inerentes ao Java permanecem, portanto você provavelmente nunca obterá um ambiente Java que tenha um desempenho tão bom quanto o código C.
Obviamente, com as técnicas de compilação just-in-time disponíveis, poderíamos voltar a distribuir realmente o código-fonte e compilá-lo just-in-time no código de máquina. No entanto, isso diminuiria bastante o desempenho da inicialização até que todas as partes relevantes do código fossem compiladas. O código de bytes ainda é uma ajuda significativa aqui, porque é muito mais simples de analisar do que o código Java equivalente.
fonte
O código-fonte do texto é uma estrutura que pretende ser fácil de ser lida e modificada por um ser humano.
O código de bytes é uma estrutura que pretende ser fácil de ser lida e executada por uma máquina.
Como tudo o que a JVM faz com o código é lido e executado, o código de bytes é mais adequado para consumo pela JVM.
Percebo que ainda não houve exemplos. Pseudo-exemplos tolos:
Obviamente, o código de bytes não se trata apenas de otimizações. Grande parte disso é sobre a capacidade de executar código sem ter que se preocupar com regras complicadas, como verificar se a classe contém um membro chamado "foo" em algum lugar mais abaixo no arquivo quando um método se refere a "foo".
fonte