Depois de trabalhar com o código de bytes Java por um bom tempo e fazer algumas pesquisas adicionais sobre este assunto, aqui está um resumo das minhas descobertas:
Executar código em um construtor antes de chamar um super construtor ou construtor auxiliar
Na linguagem de programação Java (JPL), a primeira instrução de um construtor deve ser uma invocação de um super construtor ou outro construtor da mesma classe. Isso não se aplica ao código de bytes Java (JBC). No código de bytes, é absolutamente legítimo executar qualquer código antes de um construtor, desde que:
- Outro construtor compatível é chamado em algum momento após esse bloco de código.
- Esta chamada não está dentro de uma declaração condicional.
- Antes dessa chamada do construtor, nenhum campo da instância construída é lido e nenhum de seus métodos é chamado. Isso implica o próximo item.
Defina os campos da instância antes de chamar um super construtor ou construtor auxiliar
Como mencionado anteriormente, é perfeitamente legal definir um valor de campo de uma instância antes de chamar outro construtor. Existe até um hack herdado que permite explorar esse "recurso" nas versões Java anteriores ao 6:
class Foo {
public String s;
public Foo() {
System.out.println(s);
}
}
class Bar extends Foo {
public Bar() {
this(s = "Hello World!");
}
private Bar(String helper) {
super();
}
}
Dessa forma, um campo pode ser definido antes da invocação do super construtor, o que não é mais possível. No JBC, esse comportamento ainda pode ser implementado.
Ramificar uma chamada de super construtor
Em Java, não é possível definir uma chamada de construtor como
class Foo {
Foo() { }
Foo(Void v) { }
}
class Bar() {
if(System.currentTimeMillis() % 2 == 0) {
super();
} else {
super(null);
}
}
Até o Java 7u23, o verificador da HotSpot VM, no entanto, não atendia a essa verificação, razão pela qual era possível. Isso foi usado por várias ferramentas de geração de código como uma espécie de hack, mas não é mais legal implementar uma classe como esta.
Este último foi apenas um bug nesta versão do compilador. Nas versões mais recentes do compilador, isso é novamente possível.
Definir uma classe sem nenhum construtor
O compilador Java sempre implementará pelo menos um construtor para qualquer classe. No código de bytes Java, isso não é necessário. Isso permite a criação de classes que não podem ser construídas, mesmo quando se usa reflexão. No entanto, o uso sun.misc.Unsafe
ainda permite a criação de tais instâncias.
Definir métodos com assinatura idêntica, mas com tipo de retorno diferente
Na JPL, um método é identificado como exclusivo por seu nome e seus tipos de parâmetros brutos. No JBC, o tipo de retorno bruto é considerado adicionalmente.
Definir campos que não diferem por nome, mas apenas por tipo
Um arquivo de classe pode conter vários campos com o mesmo nome, desde que declarem um tipo de campo diferente. A JVM sempre se refere a um campo como uma tupla de nome e tipo.
Lance exceções verificadas não declaradas sem capturá-las
O tempo de execução Java e o código de byte Java não estão cientes do conceito de exceções verificadas. É apenas o compilador Java que verifica se as exceções verificadas são sempre capturadas ou declaradas se forem lançadas.
Usar invocação de método dinâmico fora das expressões lambda
A chamada chamada de método dinâmico pode ser usada para qualquer coisa, não apenas para expressões lambda do Java. O uso desse recurso permite, por exemplo, alternar a lógica de execução em tempo de execução. Muitas linguagens de programação dinâmica que se resumem ao JBC melhoraram seu desempenho usando esta instrução. No código de byte Java, você também pode emular expressões lambda no Java 7 em que o compilador ainda não permitiu o uso de invocação de método dinâmico enquanto a JVM já entendia a instrução.
Use identificadores que normalmente não são considerados legais
Já imaginou usar espaços e uma quebra de linha no nome do seu método? Crie seu próprio JBC e boa sorte para revisão de código. Os únicos caracteres ilegais para identificadores são .
, ;
, [
e /
. Além disso, métodos que não são nomeados <init>
ou <clinit>
não podem conter <
e >
.
Reatribuir final
parâmetros ou a this
referência
final
parâmetros não existem no JBC e, consequentemente, podem ser reatribuídos. Qualquer parâmetro, incluindo a this
referência, é armazenado apenas em uma matriz simples na JVM, o que permite reatribuir a this
referência no índice0
dentro de um único quadro de método.
Reatribuir final
campos
Desde que um campo final seja atribuído dentro de um construtor, é legal reatribuir esse valor ou até mesmo não atribuir um valor. Portanto, os dois construtores a seguir são legais:
class Foo {
final int bar;
Foo() { } // bar == 0
Foo(Void v) { // bar == 2
bar = 1;
bar = 2;
}
}
Para static final
campos, é permitido até reatribuir os campos fora do inicializador de classe.
Trate os construtores e o inicializador de classe como se fossem métodos
Esse é um recurso mais conceitual, mas os construtores não são tratados de maneira diferente no JBC que os métodos normais. É apenas o verificador da JVM que garante que os construtores chamam outro construtor legal. Fora isso, é apenas uma convenção de nomenclatura Java que os construtores devem ser chamados <init>
e que o inicializador de classe é chamado <clinit>
. Além dessa diferença, a representação de métodos e construtores é idêntica. Como Holger apontou em um comentário, você pode até definir construtores com tipos de retorno diferentes de void
ou um inicializador de classe com argumentos, mesmo que não seja possível chamar esses métodos.
Crie registros assimétricos * .
Ao criar um registro
record Foo(Object bar) { }
O javac irá gerar um arquivo de classe com um único campo nomeado bar
, um método acessador nomeado bar()
e um construtor usando um único Object
. Além disso, um atributo de registro para bar
é adicionado. Ao gerar manualmente um registro, é possível criar, uma forma diferente de construtor, pular o campo e implementar o acessador de maneira diferente. Ao mesmo tempo, ainda é possível fazer a API de reflexão acreditar que a classe representa um registro real.
Chame qualquer super método (até Java 1.1)
No entanto, isso só é possível para as versões 1 e 1.1 do Java. No JBC, os métodos são sempre despachados em um tipo de destino explícito. Isso significa que para
class Foo {
void baz() { System.out.println("Foo"); }
}
class Bar extends Foo {
@Override
void baz() { System.out.println("Bar"); }
}
class Qux extends Bar {
@Override
void baz() { System.out.println("Qux"); }
}
foi possível implementar Qux#baz
para invocar Foo#baz
enquanto pulava Bar#baz
. Embora ainda seja possível definir uma chamada explícita para chamar outra implementação de super método que não seja a superclasse direta, isso não tem mais efeito nas versões Java após a 1.1. No Java 1.1, esse comportamento era controlado pela configuração do ACC_SUPER
sinalizador que permitiria o mesmo comportamento que chama apenas a implementação direta da superclasse.
Definir uma chamada não virtual de um método declarado na mesma classe
Em Java, não é possível definir uma classe
class Foo {
void foo() {
bar();
}
void bar() { }
}
class Bar extends Foo {
@Override void bar() {
throw new RuntimeException();
}
}
O código acima sempre resultará em um RuntimeException
quando foo
é chamado em uma instância de Bar
. Não é possível definir o Foo::foo
método para chamar seu próprio bar
método, definido em Foo
. Como bar
é um método de instância não particular, a chamada é sempre virtual. No entanto, com o código de bytes, é possível definir a invocação para usar o INVOKESPECIAL
código de operação que vincula diretamente a bar
chamada do método Foo::foo
à Foo
versão. Esse opcode é normalmente usado para implementar invocações de super métodos, mas você pode reutilizá-lo para implementar o comportamento descrito.
Anotações do tipo granulação fina
Em Java, as anotações são aplicadas de acordo com o @Target
que as anotações declaram. Usando a manipulação de código de bytes, é possível definir anotações independentemente desse controle. Além disso, é possível, por exemplo, anotar um tipo de parâmetro sem anotar o parâmetro, mesmo que a @Target
anotação se aplique aos dois elementos.
Defina qualquer atributo para um tipo ou seus membros
Na linguagem Java, só é possível definir anotações para campos, métodos ou classes. No JBC, você pode basicamente incorporar qualquer informação nas classes Java. Para usar essas informações, você não pode mais confiar no mecanismo de carregamento de classe Java, mas precisa extrair as meta informações por conta própria.
Overflow e implicitamente atribuir byte
, short
, char
e boolean
valores
Os últimos tipos primitivos normalmente não são conhecidos no JBC, mas são definidos apenas para tipos de matriz ou para descritores de campo e método. Nas instruções do código de bytes, todos os tipos nomeados ocupam o espaço de 32 bits, o que permite representá-los como int
. Oficialmente, apenas os int
, float
, long
e double
existem tipos no código byte que toda a necessidade de conversão explícita pelo Estado de verificador do JVM.
Não libera um monitor
Na synchronized
verdade, um bloco é composto de duas instruções, uma para adquirir e outra para liberar um monitor. No JBC, você pode adquirir um sem liberá-lo.
Nota : Em implementações recentes do HotSpot, isso leva a um IllegalMonitorStateException
no final de um método ou a uma liberação implícita se o método for finalizado por uma exceção em si.
Adicione mais de uma return
instrução a um inicializador de tipo
Em Java, mesmo um inicializador de tipo trivial como
class Foo {
static {
return;
}
}
é ilegal. No código de bytes, o inicializador de tipo é tratado como qualquer outro método, ou seja, as instruções de retorno podem ser definidas em qualquer lugar.
Criar loops irredutíveis
O compilador Java converte loops em instruções goto no código de bytes Java. Tais instruções podem ser usadas para criar loops irredutíveis, o que o compilador Java nunca cria.
Definir um bloco de captura recursivo
No código de bytes Java, você pode definir um bloco:
try {
throw new Exception();
} catch (Exception e) {
<goto on exception>
throw Exception();
}
Uma instrução semelhante é criada implicitamente ao usar um synchronized
bloco em Java, em que qualquer exceção ao liberar um monitor retorna à instrução para liberá-lo. Normalmente, nenhuma exceção deve ocorrer em tal instrução, mas se ocorrer (por exemplo, a obsoleta ThreadDeath
), o monitor ainda será liberado.
Chame qualquer método padrão
O compilador Java requer que várias condições sejam atendidas para permitir a chamada de um método padrão:
- O método deve ser o mais específico (não deve ser substituído por uma sub interface que é implementada por qualquer tipo, incluindo super tipos).
- O tipo de interface do método padrão deve ser implementado diretamente pela classe que está chamando o método padrão. No entanto, se a interface
B
estender a interface, A
mas não substituir um método A
, o método ainda poderá ser chamado.
Para código de bytes Java, apenas a segunda condição conta. O primeiro é, no entanto, irrelevante.
Invoque um super método em uma instância que não seja this
O compilador Java apenas permite chamar um método super (ou padrão da interface) em instâncias de this
. No código de bytes, no entanto, também é possível invocar o super método em uma instância do mesmo tipo semelhante à seguinte:
class Foo {
void m(Foo f) {
f.super.toString(); // calls Object::toString
}
public String toString() {
return "foo";
}
}
Acessar membros sintéticos
No código de bytes Java, é possível acessar membros sintéticos diretamente. Por exemplo, considere como no exemplo a seguir a instância externa de outra Bar
instância é acessada:
class Foo {
class Bar {
void bar(Bar bar) {
Foo foo = bar.Foo.this;
}
}
}
Isso geralmente é verdade para qualquer campo, classe ou método sintético.
Definir informações de tipo genérico fora de sincronização
Embora o tempo de execução Java não processe tipos genéricos (depois que o compilador Java aplica o apagamento do tipo), essas informações ainda são anexadas a uma classe compilada como meta-informação e tornadas acessíveis por meio da API de reflexão.
O verificador não verifica a consistência desses String
valores codificados por metadados . Portanto, é possível definir informações sobre tipos genéricos que não correspondem à eliminação. Como concepção, as seguintes afirmações podem ser verdadeiras:
Method method = ...
assertTrue(method.getParameterTypes() != method.getGenericParameterTypes());
Field field = ...
assertTrue(field.getFieldType() == String.class);
assertTrue(field.getGenericFieldType() == Integer.class);
Além disso, a assinatura pode ser definida como inválida, de modo que uma exceção de tempo de execução seja lançada. Essa exceção é lançada quando as informações são acessadas pela primeira vez e avaliadas preguiçosamente. (Semelhante aos valores da anotação com erro.)
Anexar informações meta meta apenas para certos métodos
O compilador Java permite incorporar o nome do parâmetro e as informações do modificador ao compilar uma classe com o parameter
sinalizador ativado. No formato de arquivo da classe Java, essas informações são armazenadas por método, o que torna possível incorporar apenas essas informações a determinados métodos.
Estrague tudo e faça um crash pesado na sua JVM
Como exemplo, no código de byte Java, você pode definir para chamar qualquer método em qualquer tipo. Normalmente, o verificador reclama se um tipo não conhece esse método. No entanto, se você invocar um método desconhecido em uma matriz, encontrei um bug em alguma versão da JVM, na qual o verificador perderá isso e sua JVM terminará assim que a instrução for chamada. Isso dificilmente é um recurso, mas é tecnicamente algo que não é possível com o Java compilado por javac . Java tem algum tipo de validação dupla. A primeira validação é aplicada pelo compilador Java, a segunda pela JVM quando uma classe é carregada. Ignorando o compilador, você pode encontrar um ponto fraco na validação do verificador. Esta é mais uma afirmação geral do que um recurso.
Anotar o tipo de receptor de um construtor quando não houver classe externa
Desde o Java 8, métodos não estáticos e construtores de classes internas podem declarar um tipo de receptor e anotar esses tipos. Os construtores de classes de nível superior não podem anotar seu tipo de receptor, pois a maioria não declara um.
class Foo {
class Bar {
Bar(@TypeAnnotation Foo Foo.this) { }
}
Foo() { } // Must not declare a receiver type
}
Como Foo.class.getDeclaredConstructor().getAnnotatedReceiverType()
, no entanto, retorna uma AnnotatedType
representação Foo
, é possível incluir anotações de tipo para Foo
o construtor diretamente no arquivo de classe em que essas anotações são lidas posteriormente pela API de reflexão.
Use instruções de código de bytes não utilizados / herdados
Como outros o nomearam, também o incluirei. O Java anteriormente fazia uso de sub-rotinas pelas instruções JSR
e RET
. A JBC até conhecia seu próprio tipo de endereço de retorno para esse fim. No entanto, o uso de sub-rotinas complicou demais a análise de código estático, razão pela qual essas instruções não são mais usadas. Em vez disso, o compilador Java duplicará o código que compila. No entanto, isso basicamente cria uma lógica idêntica e é por isso que eu realmente não considero alcançar algo diferente. Da mesma forma, você pode, por exemplo, adicionar oNOOP
instrução de código de bytes que também não é usada pelo compilador Java, mas isso também não permitiria que você conseguisse algo novo. Como apontado no contexto, essas "instruções de recurso" mencionadas agora são removidas do conjunto de códigos legais que os tornam ainda menos um recurso.