Existem muitas semelhanças entre as duas implementações (e na minha opinião: sim, elas são "máquinas virtuais").
Por um lado, ambas são VMs baseadas em pilha, sem noção de "registradores", como estamos acostumados a ver em uma CPU moderna como o x86 ou o PowerPC. A avaliação de todas as expressões ((1 + 1) / 2) é realizada empurrando operandos para a "pilha" e, em seguida, removendo-os da pilha sempre que uma instrução (adicionar, dividir, etc.) precisar consumir esses operandos. Cada instrução envia seus resultados de volta para a pilha.
É uma maneira conveniente de implementar uma máquina virtual, porque praticamente todas as CPUs do mundo têm uma pilha, mas o número de registros geralmente é diferente (e alguns registros são para fins especiais, e cada instrução espera seus operandos em registros diferentes, etc.) )
Portanto, se você for modelar uma máquina abstrata, um modelo puramente baseado em pilha é um bom caminho a percorrer.
Obviamente, máquinas reais não funcionam dessa maneira. Portanto, o compilador JIT é responsável por executar o "registro" das operações do bytecode, essencialmente planejando os registros reais da CPU para conter operandos e resultados sempre que possível.
Então, acho que esse é um dos maiores pontos em comum entre o CLR e a JVM.
Quanto às diferenças ...
Uma diferença interessante entre as duas implementações é que o CLR inclui instruções para criar tipos genéricos e, em seguida, para aplicar especializações paramétricas a esses tipos. Portanto, no tempo de execução, o CLR considera uma Lista <int> como um tipo completamente diferente de uma Lista <>.
Nos bastidores, ele usa o mesmo MSIL para todas as especializações de tipo de referência (para que uma Lista <> use a mesma implementação que uma Lista <Objeto>, com diferentes tipos de conversão nos limites da API), mas cada tipo de valor usa sua própria implementação exclusiva (Lista <int> gera código completamente diferente da Lista <double>).
Em Java, tipos genéricos são apenas um truque de compilador. A JVM não tem noção de quais classes possuem argumentos de tipo e é incapaz de executar especializações paramétricas em tempo de execução.
De uma perspectiva prática, isso significa que você não pode sobrecarregar métodos Java em tipos genéricos. Você não pode ter dois métodos diferentes, com o mesmo nome, diferindo apenas se eles aceitam uma Lista <> ou uma Lista <Data>. Obviamente, como o CLR conhece os tipos paramétricos, não há problemas em lidar com métodos sobrecarregados em especializações de tipos genéricos.
No dia a dia, essa é a diferença que mais noto entre o CLR e a JVM.
Outras diferenças importantes incluem:
O CLR possui encerramentos (implementados como representantes de C #). A JVM suporta fechamentos apenas desde o Java 8.
O CLR possui corotinas (implementadas com a palavra-chave C # 'yield'). A JVM não.
O CLR permite que o código do usuário defina novos tipos de valor (structs), enquanto a JVM fornece uma coleção fixa de tipos de valor (byte, short, int, long, float, double, char, boolean) e permite apenas que os usuários definam novas referências- tipos (classes).
O CLR fornece suporte para declarar e manipular ponteiros. Isso é especialmente interessante porque a JVM e o CLR empregam implementações estritas de coletor de lixo de compactação geracional como estratégia de gerenciamento de memória. Em circunstâncias normais, um GC de compactação rigoroso tem muita dificuldade com ponteiros, porque quando você move um valor de um local de memória para outro, todos os ponteiros (e ponteiros para ponteiros) se tornam inválidos. Mas o CLR fornece um mecanismo de "fixação" para que os desenvolvedores possam declarar um bloco de código dentro do qual o CLR não tem permissão para mover determinados ponteiros. É muito conveniente.
A maior unidade de código na JVM é um 'pacote', como evidenciado pela palavra-chave 'protected', ou possivelmente um JAR (ou seja, Java ARchive), como evidenciado por ser capaz de especificar um jar no caminho de classe e tratá-lo como uma pasta de código. No CLR, as classes são agregadas em 'assemblies', e o CLR fornece lógica para raciocinar e manipular assemblies (carregados em "AppDomains", fornecendo caixas de proteção no nível de sub-aplicativo para alocação de memória e execução de código).
O formato de bytecode CLR (composto de instruções e metadados do MSIL) possui menos tipos de instruções que a JVM. Na JVM, toda operação exclusiva (adicione dois valores int, adicione dois valores flutuantes, etc.) possui sua própria instrução exclusiva. No CLR, todas as instruções MSIL são polimórficas (adicione dois valores) e o compilador JIT é responsável por determinar os tipos dos operandos e criar o código de máquina apropriado. Mas não sei qual é a estratégia preferida. Ambos têm trocas. O compilador HotSpot JIT, para a JVM, pode usar um mecanismo de geração de código mais simples (não precisa determinar os tipos de operandos, porque eles já estão codificados na instrução), mas isso significa que precisa de um formato de bytecode mais complexo, com mais tipos de instruções.
Uso Java (e admiro a JVM) há cerca de dez anos.
Mas, na minha opinião, o CLR é agora a implementação superior, em quase todos os aspectos.