Meu nome é Alexander Kotsyuruba, lidero o desenvolvimento de serviços internos na DomKlik. Muitos desenvolvedores Java com experiência passam a entender a estrutura interna da JVM. Para facilitar essa jornada do Java Samurai, decidi apresentar o básico da Java Virtual Machine (JVM) e trabalhar com o bytecode em uma linguagem simples.O que é um bytecode misterioso e onde ele mora?Vou tentar responder a essa pergunta usando o exemplo de decapagem.Por que preciso de uma JVM e bytecode?
A JVM se originou sob o slogan Write Once Run Anywhere (WORA) na Sun Microsystems. Ao contrário do conceito Write Once Compile Anywhere (WOCA) , o WORA implica uma máquina virtual para cada sistema operacional que executa o código compilado uma vez (bytecode).Escrever uma vez executado em qualquer lugar (WORA)Write Once Compile Anywhere (WOCA) AJVM e o bytecode são a base do conceito WORA e nos salvam das nuances e da necessidade de compilar para cada sistema operacional.Bytecode
Para entender o que é um bytecode, vejamos um exemplo. Obviamente, esse código não faz nada de útil, servirá apenas para análises posteriores.Fonte:class Solenya(val jarForPickles: Any? = Any(), var ingredientsCount: Int = 0) {
fun add(ingredient: Any) {
ingredientsCount = ingredientsCount.inc()
}
fun warmUp(duration: Int) {
for (x in 1..duration)
println("Warming")
}
init {
val jarForPickles = takeJarForPickles()
val pickles = Any()
val water = Any()
add(pickles)
add(water)
warmUp(10)
}
private fun takeJarForPickles(): Any = openLocker()
private fun openLocker(): Any = takeKeyForLocker()
private fun takeKeyForLocker(): Any = {}
}
Usando as ferramentas Intellij IDEA integradas ( Ferramentas -> Kotlin -> Mostrar Kotlin Bytecode ), obtemos um bytecode desmontado (apenas uma parte é mostrada no exemplo):...
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L5
L6
LINENUMBER 12 L6
RETURN
L7
LOCALVARIABLE this Lcom/company/Solenya; L0 L7 0
LOCALVARIABLE ingredient Ljava/lang/Object; L0 L7 1
LOCALVARIABLE $i$f$add I L1 L7 2
MAXSTACK = 2
MAXLOCALS = 5
public final warmUp(I)V
L0
LINENUMBER 19 L0
ICONST_1
ISTORE 2
...
À primeira vista - um conjunto incompreensível de instruções. Para entender como e com o que eles trabalham, você precisará mergulhar na cozinha interna da JVM.JVM Kitchen
Vejamos a memória de tempo de execução da JVM:Podemos dizer que a JVM é a nossa cozinha. Em seguida, considere os participantes restantes:Área de método - Livro de receitas
A área Método armazena o código compilado para cada função. Quando um thread começa a executar uma função, em geral, recebe instruções dessa área. Na verdade, é um livro de receitas culinárias que detalha como cozinhar tudo, desde ovos mexidos até a zarzuela catalã.Tópico 1..N - Team Cooks
Os fluxos seguem rigorosamente as instruções prescritas por eles (área de método), para isso, possuem PC Register e JVM Stack. Você pode comparar cada fluxo com um cozinheiro que executa a tarefa dada a ele, seguindo exatamente as receitas do livro de receitas.Registro de PC - Anotações de campo
Program Counter Register - o contador de comandos do nosso fluxo. Ele armazena o endereço da instrução que está sendo executada. Na cozinha, essas seriam algumas notas em que página do livro de receitas estamos agora.Pilha de Jvm
Pilha de quadros. Um quadro é alocado para cada função, dentro da qual o encadeamento atual trabalha com variáveis e operandos. Como parte da analogia com a preparação de nossos pickles, este poderia ser um conjunto de operações aninhadas:-> -> -> ...
Frame - Desktop
A moldura atua como a área de trabalho do cozinheiro, na qual repousa uma placa de corte e recipientes assinados.Variáveis locais - Contêineres assinados
Essa é uma matriz de variáveis locais (tabela de variáveis locais) que, como o nome indica, armazena os valores, tipo e escopo das variáveis locais. É semelhante aos contêineres assinados, onde você pode adicionar resultados intermediários da atividade profissional.Pilha de operando - placa de corte
A pilha do operando armazena argumentos para instruções da JVM. Por exemplo, valores inteiros para a operação de adição, referências a objetos de pilha etc.O exemplo mais próximo que posso dar é uma placa de corte na qual um tomate e pepino se transformam em salada em um ponto. Diferentemente das variáveis locais, colocamos no quadro apenas o que executaremos na próxima instrução.Heap - tabela de distribuição
Como parte do trabalho com o quadro, operamos em links para objetos; os próprios objetos são armazenados na pilha. Uma diferença importante é que o quadro pertence a apenas um encadeamento, e as variáveis locais "ativam" enquanto o quadro está ativo (a função é executada). E o heap é acessível para outros fluxos e permanece até o coletor de lixo ser ativado. Por analogia com a cozinha, podemos dar um exemplo com uma tabela de distribuição, que por si só é comum. E é limpo por uma equipe separada de produtos de limpeza.Cozinha JVM. Um olhar por dentro. Trabalhar com Moldura
Vamos começar com a função warmUp
:
fun warmUp(duration: Int) {
for (x in 1..duration)
println("Warming...")
}
Função bytecode desmontada: public final warmUp(I)V
L0
LINENUMBER 19 L0
ICONST_1
ISTORE 2
ILOAD 1
ISTORE 3
ILOAD 2
ILOAD 3
IF_ICMPGT L1
L2
LINENUMBER 20 L2
LDC "Warming..."
ASTORE 4
L3
ICONST_0
ISTORE 5
L4
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 4
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L5
L6
LINENUMBER 19 L6
ILOAD 2
ILOAD 3
IF_ICMPEQ L1
IINC 2 1
L7
GOTO L2
L1
LINENUMBER 21 L1
RETURN
L8
LOCALVARIABLE x I L2 L7 2
LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
LOCALVARIABLE duration I L0 L8 1
MAXSTACK = 2
MAXLOCALS = 6
Inicialização de estrutura - Preparação para o local de trabalho
Para executar esta função, um quadro será criado no fluxo da pilha da JVM. Deixe-me lembrá-lo de que a pilha consiste em uma matriz de variáveis locais e pilha de operandos.- Para que possamos entender quanta memória alocar para esse quadro, o compilador forneceu meta-informações sobre esta função (explicação no comentário do código):
MAXSTACK = 2
MAXLOCALS = 6
- Também temos informações sobre alguns elementos da matriz de variáveis locais:
LOCALVARIABLE x I L2 L7 2
LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
LOCALVARIABLE duration I L0 L8 1
- Os argumentos da função ao inicializar o quadro caem em variáveis locais. Neste exemplo, o valor da duração será gravado na matriz com o índice 1.
Assim, inicialmente o quadro ficará assim:Comece a executar instruções
Para entender como o quadro funciona, basta armar-se com uma lista de instruções da JVM ( listagens de instruções do bytecode Java ) e percorrer o rótulo L0
: L0
LINENUMBER 19 L0
ICONST_1
ISTORE 2
ILOAD 1
ISTORE 3
ILOAD 2
ILOAD 3
IF_ICMPGT L1
ICONST_1 - add 1
(Int) na pilha operando:
iStore 2 - valor puxar (o tipo Int) da pilha operando e grava em variáveis locais com o índice 2:
Estas duas operações pode ser interpretada de Java-código: int x = 1
.ILOAD 1 - carregar o valor de variáveis locais com índice 1 na pilha operando:
iStore 3 - valor puxar (o tipo Int) da pilha operando e escreve para as variáveis locais com um índice de 3:
Estas duas operações pode ser interpretada de Java-código: int var3 = duration
.ILOAD 2 - carrega um valor de variáveis locais com o índice 2 na pilha de operandos.ILOAD 3 - carregar valor de variáveis locais com índice 3 na pilha de operandos:
IF_ICMPGT L1- instrução para comparar dois valores inteiros da pilha. Se o valor "inferior" for maior que o "superior", vá para o rótulo L1
. Após executar esta instrução, a pilha ficará vazia.Aqui está a aparência dessas linhas de bytecode Java: int x = 1;
int var3 = duration;
if (x > var3) {
....L1...
Descompilamos o código usando o Intellij IDEA no caminho Kotlin -> Java : public final void warmUp(int duration) {
int x = 1;
int var3 = duration;
if (x <= duration) {
while(true) {
String var4 = "Warming";
boolean var5 = false;
System.out.println(var4);
if (x == var3) {
break;
}
++x;
}
}
}
Aqui você pode ver variáveis não utilizadas ( var5
) e a ausência de uma chamada de função println()
. Não se preocupe, isso ocorre devido às especificidades da compilação de funções embutidas ( println()
) e expressões lambda. Praticamente não haverá custos indiretos para a execução dessas instruções; além disso, o código morto será excluído graças ao JIT. Este é um tópico interessante, que deve ser dedicado a um artigo separado.Fazendo uma analogia com uma cozinha, essa função pode ser descrita como uma tarefa para um cozinheiro "ferver água por 10 minutos". Além disso, nosso profissional em sua área:- abre um livro de receitas (área de método);
- encontra instruções sobre como ferver água (
warmUp()
); - prepara o local de trabalho, alocando uma placa quente (pilha de operandos) e recipientes (variáveis locais) para armazenamento temporário de produtos.
Cozinha JVM. Um olhar por dentro. Trabalhar com Heap
Considere o código:val pickles = Any()
Bytecode desmontado: NEW java/lang/Object
DUP
INVOKESPECIAL java/lang/Object.<init> ()V
ASTORE 3
NEW java / lang / Object - alocação de memória para um objeto de classe Object
do heap. O objeto em si não será colocado na pilha, mas um link para ele na pilha:DUP - duplicação do elemento "top" da pilha. Um link é necessário para inicializar o objeto, o segundo para salvá-lo em variáveis locais:INVOKESPECIAL java / lang / Object. <init> () V - inicialização do objeto da classe correspondente ( Object
) pelo link da pilha:O ASTORE 3 é o último passo, salvando a referência ao objeto em variáveis locais com o índice 3.Fazendo uma analogia com a cozinha, eu compararia a criação de um objeto de classe com a culinária em uma tabela compartilhada (pilha). Para fazer isso, você precisa alocar espaço suficiente na tabela de distribuição, retornar ao local de trabalho e enviar uma nota com o endereço (referência) no contêiner apropriado (variáveis locais). E somente depois disso comece a criar um objeto da classe.Cozinha JVM. Um olhar por dentro. Multithreading
Agora considere este exemplo: fun add(ingredient: Any) {
ingredientsCount = ingredientsCount.inc()
}
Este é um exemplo clássico do problema de encadeamento. Temos uma contagem de ingredientes ingredientsCount
. Uma função add
, além de adicionar um ingrediente, executa um incremento ingredientsCount
.Um bytecode desmontado fica assim: ALOAD 0
ALOAD 0
GETFIELD com/company/Solenya.ingredientsCount : I
ICONST_1
IADD
PUTFIELD com/company/Solenya.ingredientsCount : I
O estado de nossa pilha de operandos conforme as instruções são executadas:Ao trabalhar em um thread, tudo será executado corretamente. Se houver vários threads, o seguinte problema pode ocorrer. Imagine que os dois threads simultaneamente tenham o valor do campo ingredientsCount
e o tenham gravado na pilha. Em seguida, o estado da pilha de operandos e o campo ingredientsCount
podem ficar assim:A função foi executada duas vezes (uma vez por cada thread) e o valor ingredientsCount
deve ser igual a 2. Mas, na verdade, um dos threads trabalhou com um valor desatualizado ingredientsCount
e, portanto, o resultado real é 1 (problema de atualização perdida).A situação é semelhante ao trabalho paralelo de uma equipe de chefs que adicionam especiarias ao prato. Imagine:- Há uma tabela de distribuição na qual o prato (Heap) se encontra.
- Existem dois cozinheiros na cozinha (Thread * 2).
- Cada cozinheiro tem sua própria mesa de corte, onde prepara uma mistura de especiarias (JVM Stack * 2).
- Tarefa: adicione duas porções de especiarias ao prato.
- Na tabela de distribuição, encontra-se um pedaço de papel com o qual lêem e no qual escrevem qual parte foi adicionada (
ingredientsCount
). E para economizar temperos:
- Antes de iniciar a preparação dos temperos, o cozinheiro deve ler em um pedaço de papel que o número de temperos adicionados não é suficiente;
- depois de adicionar temperos, o cozinheiro pode escrever quantos, em sua opinião, temperos são adicionados ao prato.
Sob tais condições, pode ocorrer uma situação:- O cozinheiro 1 leu que foram adicionadas 3 porções de especiarias.
- O cozinheiro # 2 leu que foram adicionadas 3 porções de especiarias.
- Ambos vão para as mesas e preparam uma mistura de especiarias.
- Ambos os chefs adicionam especiarias (3 + 2) ao prato.
- O cozinheiro 1 escreve que foram adicionadas 4 porções de especiarias.
- O cozinheiro n ° 2 escreve que foram adicionadas 4 porções de especiarias.
Conclusão: os produtos estavam faltando, o prato ficou apimentado etc.Para evitar tais situações, existem várias ferramentas, como travas, funções de segurança de linha, etc.Resumir
É extremamente raro um desenvolvedor precisar rastrear o código de bytes, a menos que isso seja específico do seu trabalho. Ao mesmo tempo, entender o trabalho do bytecode ajuda a entender melhor o multithreading e as vantagens de um idioma específico e também ajuda a crescer profissionalmente.Vale a pena notar que estes estão longe de todas as partes da JVM. Há muitas "coisas" mais interessantes, por exemplo, pool constante, verificador de bytecode, JIT, cache de código, etc. Mas, para não sobrecarregar o artigo, concentrei-me apenas nos elementos necessários para um entendimento comum.Links Úteis: