Después de trabajar con código de bytes de Java durante bastante tiempo e investigar un poco más sobre este asunto, aquí hay un resumen de mis hallazgos:
Ejecute código en un constructor antes de llamar a un súper constructor o constructor auxiliar
En el lenguaje de programación Java (JPL), la primera declaración de un constructor debe ser una invocación de un superconstructor u otro constructor de la misma clase. Esto no es cierto para el código de bytes de Java (JBC). Dentro del código de bytes, es absolutamente legítimo ejecutar cualquier código antes de un constructor, siempre que:
- Se llama a otro constructor compatible en algún momento después de este bloque de código.
- Esta llamada no está dentro de una declaración condicional.
- Antes de esta llamada al constructor, no se lee ningún campo de la instancia construida y no se invoca ninguno de sus métodos. Esto implica el siguiente artículo.
Establecer campos de instancia antes de llamar a un súper constructor o constructor auxiliar
Como se mencionó anteriormente, es perfectamente legal establecer un valor de campo de una instancia antes de llamar a otro constructor. Incluso existe un truco heredado que le permite explotar esta "característica" en las versiones de Java anteriores a la 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();
}
}
De esta forma, se podría establecer un campo antes de que se invoque el súper constructor, lo que sin embargo ya no es posible. En JBC, este comportamiento aún se puede implementar.
Ramifica una llamada de súper constructor
En Java, no es posible definir una llamada de constructor como
class Foo {
Foo() { }
Foo(Void v) { }
}
class Bar() {
if(System.currentTimeMillis() % 2 == 0) {
super();
} else {
super(null);
}
}
Sin embargo, hasta Java 7u23, el verificador de HotSpot VM no realizó esta comprobación, por lo que fue posible. Esto fue utilizado por varias herramientas de generación de código como una especie de pirateo, pero ya no es legal implementar una clase como esta.
Este último fue simplemente un error en esta versión del compilador. En las versiones más recientes del compilador, esto es nuevamente posible.
Definir una clase sin ningún constructor.
El compilador de Java siempre implementará al menos un constructor para cualquier clase. En el código de bytes de Java, esto no es obligatorio. Esto permite la creación de clases que no se pueden construir incluso cuando se usa la reflexión. Sin embargo, el uso sun.misc.Unsafe
aún permite la creación de tales instancias.
Definir métodos con firma idéntica pero con diferente tipo de retorno
En el JPL, un método se identifica como único por su nombre y sus tipos de parámetros sin formato. En JBC, el tipo de retorno sin procesar también se considera.
Defina campos que no difieran por nombre sino solo por tipo
Un archivo de clase puede contener varios campos del mismo nombre siempre que declaren un tipo de campo diferente. La JVM siempre se refiere a un campo como una tupla de nombre y tipo.
Lanza excepciones marcadas no declaradas sin atraparlas
El tiempo de ejecución de Java y el código de bytes de Java no conocen el concepto de excepciones comprobadas. Es solo el compilador de Java que verifica que las excepciones marcadas siempre se detectan o declaran si se lanzan.
Use la invocación de métodos dinámicos fuera de las expresiones lambda
La llamada invocación del método dinámico se puede usar para cualquier cosa, no solo para las expresiones lambda de Java. El uso de esta característica permite, por ejemplo, cambiar la lógica de ejecución en tiempo de ejecución. Muchos lenguajes de programación dinámicos que se reducen a JBC mejoraron su rendimiento al usar esta instrucción. En el código de bytes de Java, también podría emular expresiones lambda en Java 7 donde el compilador aún no permitía el uso de la invocación de métodos dinámicos mientras la JVM ya entendía la instrucción.
Utilice identificadores que normalmente no se consideran legales
¿Alguna vez te ha gustado usar espacios y un salto de línea en el nombre de tu método? Cree su propio JBC y buena suerte para la revisión del código. Los únicos caracteres no válidos para identificadores son .
, ;
, [
y /
. Además, los métodos que no tienen nombre <init>
o <clinit>
que no pueden contener <
y >
.
Reasignar final
parámetros o la this
referencia
final
los parámetros no existen en JBC y, en consecuencia, pueden reasignarse. Cualquier parámetro, incluida la this
referencia, solo se almacena en una matriz simple dentro de la JVM, lo que permite reasignar la this
referencia en el índice 0
dentro de un marco de método único.
Reasignar final
campos
Siempre que se asigne un campo final dentro de un constructor, es legal reasignar este valor o incluso no asignar un valor en absoluto. Por lo tanto, los siguientes dos constructores son legales:
class Foo {
final int bar;
Foo() { } // bar == 0
Foo(Void v) { // bar == 2
bar = 1;
bar = 2;
}
}
Para los static final
campos, incluso está permitido reasignar los campos fuera del inicializador de clase.
Trate a los constructores y al inicializador de clase como si fueran métodos
Esto es más una característica conceptual, pero los constructores no reciben un trato diferente dentro de JBC que los métodos normales. Es solo el verificador de JVM lo que asegura que los constructores llamen a otro constructor legal. Aparte de eso, es simplemente una convención de nomenclatura de Java que se debe llamar a los constructores <init>
y que se llama al inicializador de clase <clinit>
. Además de esta diferencia, la representación de métodos y constructores es idéntica. Como Holger señaló en un comentario, incluso puede definir constructores con tipos de retorno distintos void
o un inicializador de clase con argumentos, aunque no es posible llamar a estos métodos.
Crear registros asimétricos * .
Al crear un registro
record Foo(Object bar) { }
javac generará un archivo de clase con un solo campo llamado bar
, un método de acceso llamado bar()
y un constructor tomando un solo Object
. Además, bar
se agrega un atributo de registro para . Al generar manualmente un registro, es posible crear una forma de constructor diferente, omitir el campo e implementar el descriptor de acceso de manera diferente. Al mismo tiempo, todavía es posible hacer que la API de reflexión crea que la clase representa un registro real.
Llame a cualquier súper método (hasta Java 1.1)
Sin embargo, esto solo es posible para las versiones Java 1 y 1.1. En JBC, los métodos siempre se envían en un tipo de destino explícito. Esto 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"); }
}
fue posible implementar Qux#baz
para invocar Foo#baz
mientras saltaba Bar#baz
. Si bien aún es posible definir una invocación explícita para llamar a otra implementación de súper método que la de la súper clase directa, esto ya no tiene ningún efecto en las versiones de Java posteriores a 1.1. En Java 1.1, este comportamiento se controlaba configurando el ACC_SUPER
indicador que permitiría el mismo comportamiento que solo llama a la implementación directa de la superclase.
Definir una llamada no virtual de un método que se declara en la misma clase
En Java, no es posible definir una clase
class Foo {
void foo() {
bar();
}
void bar() { }
}
class Bar extends Foo {
@Override void bar() {
throw new RuntimeException();
}
}
El código anterior siempre dará lugar a una RuntimeException
cuando foo
se invoca en una instancia de Bar
. No es posible definir el Foo::foo
método para invocar su propio bar
método que se define en Foo
. Como bar
es un método de instancia no privado, la llamada siempre es virtual. Sin embargo, con el código de bytes, se puede definir la invocación para usar el INVOKESPECIAL
código de operación que vincula directamente la bar
llamada al método Foo::foo
a Foo
la versión. Este código de operación se usa normalmente para implementar invocaciones de súper métodos, pero puede reutilizar el código de operación para implementar el comportamiento descrito.
Anotaciones de grano fino
En Java, las anotaciones se aplican de acuerdo con lo @Target
que declaran las anotaciones. Mediante la manipulación de código de bytes, es posible definir anotaciones independientemente de este control. Además, por ejemplo, es posible anotar un tipo de parámetro sin anotar el parámetro, incluso si la @Target
anotación se aplica a ambos elementos.
Definir cualquier atributo para un tipo o sus miembros.
Dentro del lenguaje Java, solo es posible definir anotaciones para campos, métodos o clases. En JBC, básicamente puede incrustar cualquier información en las clases de Java. Sin embargo, para utilizar esta información, ya no puede confiar en el mecanismo de carga de clases de Java, sino que necesita extraer la metainformación usted mismo.
Desbordamiento e implícitamente asignar byte
, short
, char
y boolean
los valores
Los últimos tipos primitivos no se conocen normalmente en JBC, pero solo se definen para tipos de matriz o para descriptores de campo y método. Dentro de las instrucciones del código de bytes, todos los tipos nombrados ocupan el espacio de 32 bits que permite representarlos como int
. Oficialmente, sólo los int
, float
, long
y double
existen tipos del código de bytes, que todos necesitamos conversión explícita por la regla de verificador de la JVM.
No liberar un monitor
Un synchronized
bloque en realidad está compuesto por dos declaraciones, una para adquirir y otra para liberar un monitor. En JBC, puede adquirir uno sin liberarlo.
Nota : En implementaciones recientes de HotSpot, esto lleva a un IllegalMonitorStateException
final de un método o a una versión implícita si el método se termina por una excepción en sí misma.
Agregar más de una return
declaración a un inicializador de tipo
En Java, incluso un inicializador de tipo trivial como
class Foo {
static {
return;
}
}
es ilegal. En el código de bytes, el inicializador de tipo se trata como cualquier otro método, es decir, las declaraciones de retorno se pueden definir en cualquier lugar.
Crea bucles irreducibles
El compilador de Java convierte bucles en sentencias goto en código de bytes Java. Dichas declaraciones pueden usarse para crear bucles irreducibles, lo que el compilador de Java nunca hace.
Define un bloque de captura recursivo
En el código de bytes de Java, puede definir un bloque:
try {
throw new Exception();
} catch (Exception e) {
<goto on exception>
throw Exception();
}
Una declaración similar se crea implícitamente cuando se usa un synchronized
bloque en Java donde cualquier excepción al liberar un monitor vuelve a las instrucciones para liberar este monitor. Normalmente, no debería producirse ninguna excepción en dicha instrucción, pero si así fuera (por ejemplo, en desuso ThreadDeath
), el monitor aún se liberaría.
Llamar a cualquier método predeterminado
El compilador de Java requiere que se cumplan varias condiciones para permitir la invocación de un método predeterminado:
- El método debe ser el más específico (no debe ser anulado por una interfaz secundaria implementada por ningún tipo, incluidos los supertipos).
- El tipo de interfaz del método predeterminado debe ser implementado directamente por la clase que llama al método predeterminado. Sin embargo, si la interfaz
B
extiende la interfaz A
pero no anula un método A
, aún se puede invocar el método.
Para el código de bytes de Java, solo cuenta la segunda condición. Sin embargo, el primero es irrelevante.
Invocar un súper método en una instancia que no sea this
El compilador de Java solo permite invocar un método super (o el predeterminado de la interfaz) en instancias de this
. Sin embargo, en el código de bytes, también es posible invocar el súper método en una instancia del mismo tipo similar al siguiente:
class Foo {
void m(Foo f) {
f.super.toString(); // calls Object::toString
}
public String toString() {
return "foo";
}
}
Acceder a miembros sintéticos
En el código de bytes de Java, es posible acceder a miembros sintéticos directamente. Por ejemplo, considere cómo en el siguiente ejemplo Bar
se accede a la instancia externa de otra instancia:
class Foo {
class Bar {
void bar(Bar bar) {
Foo foo = bar.Foo.this;
}
}
}
Esto es generalmente cierto para cualquier campo sintético, clase o método.
Definir información de tipo genérico fuera de sincronización
Si bien el tiempo de ejecución de Java no procesa tipos genéricos (después de que el compilador de Java aplica la eliminación de tipos), esta información todavía se adjunta a una clase compilada como metainformación y se hace accesible a través de la API de reflexión.
El verificador no verifica la consistencia de estos String
valores codificados con metadatos. Por lo tanto, es posible definir información sobre tipos genéricos que no coincida con el borrado. Como consecuencia, las siguientes afirmaciones pueden ser ciertas:
Method method = ...
assertTrue(method.getParameterTypes() != method.getGenericParameterTypes());
Field field = ...
assertTrue(field.getFieldType() == String.class);
assertTrue(field.getGenericFieldType() == Integer.class);
Además, la firma se puede definir como no válida de modo que se genere una excepción de tiempo de ejecución. Esta excepción se produce cuando se accede a la información por primera vez, ya que se evalúa perezosamente. (Similar a los valores de anotación con un error).
Agregar metainformación de parámetros solo para ciertos métodos
El compilador de Java permite incrustar el nombre del parámetro y la información del modificador al compilar una clase con el parameter
indicador habilitado. Sin embargo, en el formato de archivo de clase Java, esta información se almacena por método, lo que hace posible incrustar solo dicha información de método para ciertos métodos.
Arruine las cosas y bloquea tu JVM
Como ejemplo, en el código de bytes de Java, puede definir invocar cualquier método en cualquier tipo. Por lo general, el verificador se quejará si un tipo no conoce dicho método. Sin embargo, si invoca un método desconocido en una matriz, encontré un error en alguna versión de JVM donde el verificador se perderá esto y su JVM finalizará una vez que se invoque la instrucción. Sin embargo, esto no es una característica, pero técnicamente es algo que no es posible con Java compilado javac . Java tiene algún tipo de doble validación. La primera validación es aplicada por el compilador de Java, la segunda por la JVM cuando se carga una clase. Al omitir el compilador, puede encontrar un punto débil en la validación del verificador. Sin embargo, esta es más una declaración general que una característica.
Anotar el tipo de receptor de un constructor cuando no hay clase externa
Desde Java 8, los métodos no estáticos y los constructores de clases internas pueden declarar un tipo de receptor y anotar estos tipos. Los constructores de clases de nivel superior no pueden anotar su tipo de receptor, ya que la mayoría no declaran uno.
class Foo {
class Bar {
Bar(@TypeAnnotation Foo Foo.this) { }
}
Foo() { } // Must not declare a receiver type
}
Foo.class.getDeclaredConstructor().getAnnotatedReceiverType()
Sin embargo, dado que devuelve una AnnotatedType
representación Foo
, es posible incluir anotaciones de tipo para Foo
el constructor directamente en el archivo de clase donde la API de reflexión luego lee estas anotaciones.
Usar instrucciones de código de bytes no utilizados / heredados
Como otros lo nombraron, lo incluiré también. Anteriormente, Java hacía uso de subrutinas por las declaraciones JSR
y RET
. JBC incluso conocía su propio tipo de dirección de retorno para este propósito. Sin embargo, el uso de subrutinas complicaba demasiado el análisis de código estático, por lo que estas instrucciones ya no se utilizan. En cambio, el compilador de Java duplicará el código que compila. Sin embargo, esto básicamente crea una lógica idéntica, por lo que realmente no considero que logre algo diferente. Del mismo modo, podría agregar, por ejemplo, elNOOP
instrucción de código de bytes que tampoco es utilizada por el compilador de Java, pero esto tampoco le permitiría lograr algo nuevo tampoco. Como se señaló en el contexto, estas "instrucciones de características" mencionadas ahora se eliminan del conjunto de códigos de operación legales, lo que las hace aún menos características.