字节码功能在 Java 语言中不可用

2022-08-31 07:36:56

目前(Java 6)有没有一些你可以在Java字节码中做的事情,而你不能在Java语言中做?

我知道两者都是图灵完备的,所以把“可以做”读成“可以做得更快/更好,或者只是以不同的方式”。

我正在考虑额外的字节码,例如,它不能使用Java生成,除了特定的字节码是用于将来的版本。invokedynamic


答案 1

在使用Java字节代码一段时间并就此问题进行了一些其他研究之后,以下是我的发现摘要:

在调用超级构造函数或辅助构造函数之前,在构造函数中执行代码

在 Java 编程语言 (JPL) 中,构造函数的第一个语句必须是对超级构造函数或同一类的另一构造函数的调用。对于 Java 字节码 (JBC) 则不然。在字节码中,在构造函数之前执行任何代码都是绝对合法的,只要:

  • 在此代码块之后的某个时间调用另一个兼容的构造函数。
  • 此调用不在条件语句中。
  • 在此构造函数调用之前,不会读取构造实例的任何字段,也不会调用其任何方法。这意味着下一项。

在调用超级构造函数或辅助构造函数之前设置实例字段

如前所述,在调用另一个构造函数之前设置实例的字段值是完全合法的。甚至存在一个遗留的黑客攻击,使其能够在6之前的Java版本中利用此“功能”:

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();
  }
}

这样,可以在调用超级构造函数之前设置一个字段,但是这不再可能。在 JBC 中,仍可以实现此行为。

分支超级构造函数调用

在Java中,不可能定义构造函数调用,例如

class Foo {
  Foo() { }
  Foo(Void v) { }
}

class Bar() {
  if(System.currentTimeMillis() % 2 == 0) {
    super();
  } else {
    super(null);
  }
}

然而,在Java 7u23之前,HotSpot VM的验证器确实错过了这次检查,这就是为什么这是可能的。这被几个代码生成工具用作一种黑客攻击,但实现这样的类不再合法。

后者只是这个编译器版本中的一个错误。在较新的编译器版本中,这再次成为可能。

定义不带任何构造函数的类

Java 编译器将始终为任何类实现至少一个构造函数。在 Java 字节码中,这不是必需的。这允许创建即使在使用反射时也无法构造的类。但是,使用 still 允许创建此类实例。sun.misc.Unsafe

定义签名相同但返回类型不同的方法

在 JPL 中,方法通过其名称和原始参数类型标识为唯一。在 JBC 中,还考虑了原始返回类型。

定义不因名称而异但仅按类型而不同的字段

类文件可以包含多个同名的字段,只要它们声明不同的字段类型即可。JVM 始终将字段称为名称和类型的元组。

引发未声明的已检查异常而不捕获它们

Java 运行时和 Java 字节代码不知道已检查异常的概念。只有 Java 编译器才能验证已检查的异常是否始终被捕获或声明(如果这些异常被抛出)。

在 lambda 表达式之外使用动态方法调用

所谓的动态方法调用可以用于任何事情,而不仅仅是Java的lambda表达式。例如,使用此功能允许在运行时切换出执行逻辑。许多归结为 JBC 的动态编程语言都通过使用此指令提高了性能。在 Java 字节码中,您还可以在 Java 7 中模拟 lambda 表达式,其中编译器尚不允许使用动态方法调用,而 JVM 已经理解了该指令。

使用通常不被视为合法的标识符

有没有想过在你的方法名称中使用空格和换行符?创建您自己的JBC,祝代码审查好运。标识符的唯一非法字符是 、 和 。此外,未命名或不能包含 和 的方法。.;[/<init><clinit><>

重新分配最终参数或引用

final参数在 JBC 中不存在,因此可以重新分配。任何参数(包括引用)仅存储在 JVM 中的简单数组中,允许在单个方法帧内的索引处重新分配引用。thisthis0

重新分配最终字段

只要在构造函数中分配了最终字段,重新分配此值甚至根本不分配值都是合法的。因此,以下两个构造函数是合法的:

class Foo {
  final int bar;
  Foo() { } // bar == 0
  Foo(Void v) { // bar == 2
    bar = 1;
    bar = 2;
  }
}

对于字段,甚至允许在类初始值设定项外部重新分配字段。static final

将构造函数和类初始值设定项视为方法

这更像是一个概念性功能,但在 JBC 中,构造函数的处理方式与普通方法没有任何不同。只有 JVM 的验证器才能确保构造函数调用另一个合法构造函数。除此之外,它只是一个Java命名约定,必须调用构造函数,并且类初始值设定项称为 。除了这种差异之外,方法和构造函数的表示形式是相同的。正如 Holger 在注释中指出的那样,您甚至可以定义具有非返回类型的构造函数或具有参数的类初始值设定项,即使无法调用这些方法。<init><clinit>void

创建非对称记录*

创建记录时

record Foo(Object bar) { }

javac将生成一个类文件,其中包含一个名为 的单个字段,一个名为的访问器方法和一个构造函数,该字段采用单个。此外,还添加了 的记录属性。通过手动生成记录,可以创建不同的构造函数形状,以跳过字段并以不同的方式实现访问器。同时,仍然可以使反射 API 相信该类表示实际记录。barbar()Objectbar

调用任何超级方法(直到 Java 1.1)

但是,这仅适用于 Java 版本 1 和 1.1。在 JBC 中,方法始终在显式目标类型上调度。这意味着

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"); }
}

可以在跳转时实现调用。虽然仍然可以定义显式调用来调用另一个超方法实现,而不是直接超类的实现,但这在1.1之后的Java版本中不再有任何影响。在Java 1.1中,此行为是通过设置标志来控制的,该标志将启用仅调用直接超类的实现的相同行为。Qux#bazFoo#bazBar#bazACC_SUPER

定义在同一类中声明的方法的非虚拟调用

在Java中,不可能定义类

class Foo {
  void foo() {
    bar();
  }
  void bar() { }
}

class Bar extends Foo {
  @Override void bar() {
    throw new RuntimeException();
  }
}

上述代码将始终导致在 的实例上调用 when。无法定义该方法来调用其自己的方法,该方法在 中定义。与非私有实例方法一样,调用始终是虚拟的。但是,使用字节码,可以定义调用以使用操作码,该操作码将方法调用直接链接到 的版本。此操作码通常用于实现超级方法调用,但您可以重用该操作码来实现所描述的行为。RuntimeExceptionfooBarFoo::foobarFoobarINVOKESPECIALbarFoo::fooFoo

细粒度类型注释

在 Java 中,根据注释声明的注释来应用注释。使用字节码操作,可以独立于此控件定义批注。此外,例如,即使注释同时适用于这两个元素,也可以在不注释参数的情况下对参数类型进行批注。@Target@Target

为类型或其成员定义任何特性

在Java语言中,只能为字段,方法或类定义注释。在JBC中,你基本上可以将任何信息嵌入到Java类中。但是,为了利用这些信息,您不能再依赖 Java 类装入机制,而是需要自己提取元信息。

溢出并隐式分配字节整型、字符布尔

后一种基元类型在 JBC 中通常不是已知的,但只为数组类型或字段和方法描述符定义。在字节码指令中,所有命名类型都占用32位的空间,允许将它们表示为。正式地说,只有 、 和 类型存在于字节码中,它们都需要根据 JVM 验证器的规则进行显式转换。intintfloatlongdouble

未释放显示器

块实际上由两个语句组成,一个用于获取,另一个用于释放监视器。在 JBC 中,您可以在不释放它的情况下获取一个。synchronized

注意:在最近的 HotSpot 实现中,这会导致方法末尾的 a,或者如果方法本身被异常终止,则会导致隐式释放。IllegalMonitorStateException

向类型初始值设定项添加多个 return 语句

在Java中,即使是一个简单的类型初始值设定项,例如

class Foo {
  static {
    return;
  }
}

是非法的。在字节码中,类型初始值设定项的处理方式与任何其他方法一样,即可以在任何地方定义 return 语句。

创建不可简化的循环

Java 编译器将循环转换为 Java 字节码中的 goto 语句。这样的语句可以用来创建不可约的循环,而 Java 编译器从来不会这样做。

定义递归捕获块

在 Java 字节码中,您可以定义一个块:

try {
  throw new Exception();
} catch (Exception e) {
  <goto on exception>
  throw Exception();
}

在 Java 中使用块时,将隐式创建类似的语句,其中释放监视器时的任何异常都将返回到释放此监视器的指令。通常,这样的指令不应该发生异常,但如果它会(例如,已弃用的指令),监视器仍然会被释放。synchronizedThreadDeath

调用任何默认方法

Java 编译器需要满足几个条件才能允许调用默认方法:

  1. 该方法必须是最具体的方法(不得被任何类型(包括超类型)实现的子接口覆盖)。
  2. 默认方法的接口类型必须由调用默认方法的类直接实现。但是,如果接口扩展了接口,但没有覆盖 中的方法,仍然可以调用该方法。BAA

对于 Java 字节码,只有第二个条件才算数。然而,第一个是无关紧要的。

在不是这样的实例上调用超级方法

Java 编译器只允许在 的实例上调用超级(或接口缺省)方法。但是,在字节码中,也可以在类似于以下内容的相同类型的实例上调用 super 方法:this

class Foo {
  void m(Foo f) {
    f.super.toString(); // calls Object::toString
  }
  public String toString() {
    return "foo";
  }
}

访问合成成员

在 Java 字节码中,可以直接访问综合成员。例如,考虑如何在以下示例中访问另一个实例的外部实例:Bar

class Foo {
  class Bar { 
    void bar(Bar bar) {
      Foo foo = bar.Foo.this;
    }
  }
}

这通常适用于任何合成字段、类或方法。

定义不同步的泛型类型信息

虽然 Java 运行时不处理泛型类型(在 Java 编译器应用类型擦除之后),但此信息仍作为元信息保留在已编译的类中,并通过反射 API 进行访问。

验证程序不检查这些元数据编码值的一致性。因此,可以定义与擦除不匹配的泛型类型的信息。作为一个自负,以下断言可能是正确的:String

Method method = ...
assertTrue(method.getParameterTypes() != method.getGenericParameterTypes());

Field field = ...
assertTrue(field.getFieldType() == String.class);
assertTrue(field.getGenericFieldType() == Integer.class);

此外,可以将签名定义为无效,以便引发运行时异常。当首次访问信息时,会引发此异常,因为信息是懒惰地评估的。(类似于带有错误的批注值。

仅为某些方法追加参数元信息

Java 编译器允许在编译启用了标志的类时嵌入参数名称和修饰符信息。但是,在 Java 类文件格式中,此信息是按方法存储的,因此可以只为某些方法嵌入此类方法信息。parameter

搞砸了事情,并硬崩溃了你的JVM

例如,在 Java 字节码中,可以定义调用任何类型上的任何方法。通常,如果某个类型不知道这种方法,验证程序将进行投诉。但是,如果你在数组上调用一个未知的方法,我发现一些JVM版本中有一个错误,其中验证器会错过这个错误,一旦调用指令,你的JVM就会完成。虽然这几乎不是一个功能,但从技术上讲,这是javac编译的Java无法实现的。Java有某种双重验证。第一个验证由 Java 编译器应用,第二个验证由 JVM 在装入类时应用。通过跳过编译器,您可能会在验证程序的验证中找到弱点。不过,这更像是一个通用声明,而不是一个功能。

在没有外部类时批注构造函数的接收器类型

从 Java 8 开始,非静态方法和内部类的构造函数可以声明接收器类型并注释这些类型。顶级类的构造函数不能注释其接收方类型,因为它们大多数不声明一个。

class Foo {
  class Bar {
    Bar(@TypeAnnotation Foo Foo.this) { }
  }
  Foo() { } // Must not declare a receiver type
}

然而,由于确实返回了一个表示,因此可以直接在类文件中包含构造函数的类型注释,这些注释稍后由反射 API 读取。Foo.class.getDeclaredConstructor().getAnnotatedReceiverType()AnnotatedTypeFooFoo

使用未使用/旧版字节代码指令

由于其他人命名了它,我也将包括它。Java 以前通过 and 语句使用子例程。为此,JBC甚至知道自己类型的回邮地址。但是,子例程的使用确实使静态代码分析过于复杂,这就是为什么不再使用这些指令的原因。相反,Java 编译器将复制它编译的代码。然而,这基本上创造了相同的逻辑,这就是为什么我并不真正认为它能实现不同的东西。类似地,例如,您可以添加Java编译器不使用的字节码指令,但这也不能真正让您实现新的东西。正如在上下文中指出的那样,这些提到的“功能指令”现在已从法律操作码集中删除,这确实使它们成为一项功能。JSRRETNOOP


答案 2

据我所知,Java 6 支持的字节码中没有一些主要功能也无法从 Java 源代码访问。造成这种情况的主要原因显然是Java字节码在设计时考虑了Java语言。

但是,有些功能不是由现代Java编译器生成的:

  • ACC_SUPER标志

    这是一个可以在类上设置的标志,它指定如何处理此类的字节码的特定角大小写。它是由所有现代Java编译器设置的(如果我没记错的话,“现代”是>= Java 1.1),只有古代Java编译器才会生成未设置的类文件。此标志仅出于向后兼容性原因而存在。请注意,从 Java 7u51 开始,由于安全原因,ACC_SUPER将被完全忽略。invokespecial

  • / 字节码。jsrret

    这些字节码用于实现子例程(主要用于实现块)。自 Java 6 以来,它们不再生产。它们被弃用的原因是它们使静态验证复杂化了很多,却没有太大的收益(即,使用的代码几乎总是可以用正常的跳转重新实现,开销很小)。finally

  • 在一个类中有两个仅在返回类型上不同的方法。

    Java语言规范不允许在同一类中两个方法,当它们仅在返回类型上不同时(即相同的名称,相同的参数列表,...)。但是,JVM规范没有这样的限制,因此一个类文件可以包含两个这样的方法,只是没有办法使用普通的Java编译器生成这样的类文件。在这个答案中有一个很好的例子/解释。


推荐