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:
- Reduza um problema de O (N * M) para um problema de O (N + M) e
- Divida o problema em pedaços mais gerenciáveis.
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:
- Representação compacta.
- Rápido e fácil de decodificar e executar.
- Rápido e fácil de implementar nas máquinas mais comuns.
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).
- Então, por que o sistema P é praticamente esquecido? Principalmente uma situação de preços. O sistema P foi vendido decentemente nos Apple II, Commodore SuperPets etc. Quando o IBM PC foi lançado, o sistema P era um sistema operacional suportado, mas o MS-DOS custou menos (do ponto de vista da maioria das pessoas, foi essencialmente lançado de graça) e rapidamente teve mais programas disponíveis, pois é para isso que a Microsoft e a IBM (entre outros) escreveram.
- Por exemplo, é assim que a fuligem funciona.