java字节码_Java字节码:弯曲规则

java字节码

编译Java程序时,不会将其翻译为可执行的机器代码,而是由javac编译器产生Java字节码,该字节码用作向Java虚拟机描述程序的中间格式。 尽管Java虚拟机使用相同的名称,但它没有Java编程语言的概念,而是专门处理字节码指令。

Java字节码的最初目的之一是减小Java程序的大小。 小程序作为一种新兴的互联网时代的语言,例如,小程序将需要最少的下载时间。 因此,发送单个字节作为指令比发送人类可读的单词和符号更可取。 但是,尽管进行了这种翻译,但用字节码表示的Java程序在很大程度上仍与原始源代码相似。

随着时间的流逝,除Java外,其他语言的开发人员创建了将这些语言转换为Java字节码的编译器。 如今,语言到Java字节码编译器的列表几乎是无止境的,几乎每种编程语言都可以在Java虚拟机上执行。 此外,Java 7中最近引入的invokedynamic字节码指令提高了在JVM上有效运行动态语言的能力。

但是,除了invokedynamic指令之外,Java字节码仍然是特定于Java的。 在撰写本文时,字节码指令集在很大程度上反映了Java编程语言的功能集。 例如,即使动态JVM语言可能暗示相反的情况,Java程序中的任何值仍保持严格类型。 此外,虚拟机严格执行面向对象的范例。 因此,JVM语言中与对象在语义上不相关的任何功能仍需要表示为某个类内部的方法。

但是,Java虚拟机越来越多。 尽管虚拟机具有以Java为中心的性质,但它仍然支持Java编译器会拒绝的字节码指令的组合。 在简要介绍字节码格式的基础知识之后。 本文探讨了一些无法表达为Java源代码的JVM功能。

Java字节码基础

几乎没有开发人员直接使用Java字节码,乍一看,这种虚拟机语言似乎过于复杂。 但是实际上,字节码格式很容易理解。 顾名思义,Java虚拟机代表的计算机通常不作为实际硬件存在,而实际上仅作为其自身的程序存在。 Java字节码用作此虚拟计算机的合法机器代码指令集。

每个程序都维护一个内部操作数堆栈,并且任何字节码指令都针对当前正在执行的方法在该操作数堆栈上进行操作。 要处理任何值,必须首先将这些值压入堆栈。 这些值一旦进入堆栈,就可以用作操作的输入。 让我们看一个例子:假设我们想将数字1和2相加。 必须先通过执行字节码指令iconst_1iconst_2将这两个值都压入操作数堆栈。 通过将这两个数字放在堆栈上, iadd指令现在可以通过将它们从堆栈顶部弹出来使用这两个值。 结果,该指令将消耗值的总和推回堆栈。

同样,通过首先将其参数推入操作数堆栈来执行方法。 执行该方法时,Java虚拟机然后将所有参数弹出堆栈,以将其传递给调用的方法。 非静态方法还接收被调用方法的实例作为隐式第一个参数。 返回之后,非空方法最终将其返回值推回操作数堆栈。

为了确保不使用不兼容的参数调用任何方法,Java虚拟机在加载新的字节码时会执行验证程序。 除其他外,验证程序可确保虚拟机永远不会到达不一致的状态,在这种状态下,执行的字节码指令期望的值不同于在操作数堆栈上找到的值。 如果验证者不能保证这种一致性,则它拒绝加载的类并引发VerifyError 。 Java编译器当然不会创建这样的字节码。 但是,其他语言编译器的实现者同样受此静态一致性检查的约束,并且不得生成会使验证者的一致性检查失败的字节码。

部分删除的类型

但是,验证程序不会审核Java编程语言强加的所有规则。 此类规则的一个著名示例是泛型类型的运行时擦除。 在编译过程中转换泛型类型时,它会减少到其最一般的边界。 当然,这在断言用于与通用类型进行交互的字节码时会缩小验证器的功能。 由于在编译过程中擦除了这些通用类型,因此验证程序只能确保可分配性以擦除通用类型。 因为这会损害JVM验证加载的代码的能力,所以Java编译器会明确警告有关通用类型的潜在不安全用法。

请注意,泛型类型没有完全擦除,而是作为元信息嵌入到类文件中。 一些框架通过反射API提取此元信息,并根据发现的信息更改其行为。 毕竟,泛型类型仅对Java虚拟机隐藏,而不能完全擦除。

取消检查已检查的异常

JVM和Java编程语言之间鲜为人知的区别是对检查异常的处理。 对于已检查的异常,Java编译器通常会确保将其捕获在方法中或显式声明为抛出该异常。 但是,这仅是Java编译器的约定,而不是Java虚拟机的功能。 在运行时,可以独立于任何声明引发已检查的异常。

通过滥用上述对泛型类型的擦除,甚至有可能欺骗Java编译器引发检查异常。 这可以通过将已检查的异常强制转换为运行时异常来实现。 为了防止这种转换产生类型错误,它使用泛型类型进行,将方法转换为字节码时将其删除。 下面的示例演示如何实现这样的泛型转换:

static void doThrow(Throwable throwable) {
  class Unchecker<T extends Throwable> {
    @SuppressWarnings("unchecked")
      private void uncheck(Throwable throwable) throws T {
        throw (T) throwable;
      }
  }
  new Unchecker<RuntimeException>().uncheck(throwable);
}

要在代码中引发此类异常,您只需调用静态doThrow方法即可提供Throwable根,而无需声明显式的throws子句。 定义了uncheck方法以引发泛型异常T ,编译器必须允许该异常,因为T泛型参数(作为Throwable的子类) 可能是 RuntimeException。

由于在编译过程中会擦除通用信息,因此强制转换为T不会转换为字节码指令。 Java编译器会警告不要安全使用泛型,但在此有意忽略此警告。 现在可以使用这种不安全的操作来诱使编译器将任何Throwable“投射”到运行时异常,从而避免了任何明确的throws声明。

乍一看,这似乎不太有用。 但是,在处理lambda表达式时,取消选中检查的异常可能是一个有趣的选择。 Java类库附带的大多数功能接口都不声明已检查的异常。 因此,需要始终在lambda表达式中捕获受检查的异常,以抢占通常与此类函数表达式相关的简洁的好处。

如果使用lambda表达式的方法打算将已检查的异常升级为其调用者,则这尤其不便。 为了实现这种升级,需要将已检查的异常包装在未检查的异常内。 然后需要将该包装异常从Lambda之外捕获,以便可以重新抛出包装的异常。 通过使用上面的技巧取消选中所检查的异常,可以在很大程度上避免这种样板。

interface ExceptionConsumer<T> extends Consumer<T> {

  void doAccept(T t) throws Throwable;

  @Override
  default void accept(T t) {
    try {
      doAccept(t);
    } catch (Throwable throwable) {
      doThrow(throwable);
    }
  }
}

有了取消选中已检查异常的帮助程序接口,现在就可以使用即使在lambda表达式内部也可以抛出已检查异常的方法进行操作。

void doSomething() throws Exception {
  Arrays
    .asList("foo", "bar")
    .forEach((ExceptionConsumer<String>) s -> doSomethingWith(s));
}

void doSomethingWith(String s) throws Exception;

如上面的代码所示,取消选中已检查的异常将允许其传播到外部方法。 不利的一面是,Java编译器不再能够验证lambda表达式的外部方法声明了已检查的异常。 因此,取消检查已检查的异常需要格外小心。

返回类型重载

Java编程语言不将方法的返回类型视为该方法签名的一部分。 因此,即使它们返回不同类型的值,也无法在同一类中定义两个具有相同名称和参数类型的方法。 该决定背后的基本原理是一种情况,其中一种方法因其副作用而被调用,而忽略了其返回值。 在这种情况下,如果Java方法的返回类型超载,则方法调用的分辨率将变得模棱两可。

在Java字节码中,任何方法都由不包含方法返回类型的签名标识。 因此,Java类文件可以包含两个方法,它们的不同之处仅在于它们的返回类型。 因此,调用方法时,字节码必须引用特定的返回类型。 因此,即使Java源代码不需要任何更改,如果被调用方法的返回类型发生更改,也需要重新编译Java程序。 如果不重新编译,JVM将无法将调用站点链接到更改的方法,因为字节码指令不再引用正确的返回类型。

混合型系统

因为方法是由它们的确切签名引用的,所以已编译的Java类需要保留Java编程语言定义的类型。 但是,执行方法时,Java虚拟机将与Java编程语言相比略有不同的类型系统应用于原始类型。 在某种程度上,JVM应用了混合类型系统,在该系统中,它区分用于描述值的类型与执行期间存在的类型。

将值加载到操作数堆栈时,JVM将小于整数的原始类型视为整数。 因此,如果方法变量由例如short而不是int表示,则与程序的字节码表示几乎没有区别。 而是,Java编译器插入其他字节码指令,这些指令在分配值时将值裁剪为允许的short范围。 因此,在方法的主体中使用整数的短裤可能会导致附加的字节码指令,而不是优化程序。

但是,当以字段形式或数组值形式将值存储在堆上时,此均衡不适用。 但是,即使在此级别上,布尔值仍然不存在,而是被编码为单字节值零和一个代表falsetrue的值 。 这归因于以下事实:大多数硬件都不允许显式访问单个位,因此布尔值而是表示为字节。

打破构造函数链

Java编译器要求任何构造函数隐式或显式调用另一个构造函数作为其第一条指令。 为了有效,此调用的构造函数必须由同一类或直接超类声明。 在Java字节码中,此限制仅部分实施。 相反,JVM的验证程序断言最终将调用另一个有效的构造函数。 此外,它验证仅在调用此构造函数之后,才对构造的实例进行任何方法调用和字段读取。 除此之外,在调用另一个构造函数之前执行任何代码都是完全合法的。 同样,甚至可以在调用另一个构造函数之前将值写入构造实例的字段。

为了减少此规则的不稳定,JVM的类型系统包括一个名为undefined的特殊类型。 此外,将对象的构造分为两个单独的字节码指令,其中第一条指令创建未定义的对象,第二条指令通过在其上调用构造函数来完成定义。 在字节码中,构造函数表示为名为<init>的实例方法,该方法也显示在堆栈跟踪中。

只要仍然认为对象是未定义的,JVM的验证程序除了写入其字段之外,还禁止与该实例进行任何有意义的交互。 除此之外,对于Java虚拟机而言,无非就是调用普通方法。 当完全停用JVM的验证程序时,虚拟机甚至能够不调用构造函数或在同一实例上多次调用构造函数。

Final- ISH

Java编程语言严格执行的另一种构造函数约定是对最终字段的一次和唯一一次分配。 最终字段的值一经分配就无法更改。 同时,必须在构造函数的主体内定义一个final字段,以便Java程序进行编译。

当JVM知道最终字段时,JVM的验证程序将强制执行不同的规则。 因此,在字节码中合法的是,只要在声明该字段的类的构造函数调用中进行此重新分配,就可以多次重新分配最终字段。 如果一个构造函数调用同一类的另一个构造函数,则另一个构造函数甚至有可能重新分配一个final字段。 因此,可以将以下Java类合法地表示为字节码:

class Foo {
  final int bar;
  Foo() {
    this(43);
    System.out.println(bar);
    bar = 42;
  }
  Foo(int value) {
    bar = value;
  }
}

在调用上述类的无参数构造函数之后,唯一字段被分配了值42,但值43被打印到控制台。

同时,也有可能完全跳过为最终字段分配值。 在这种情况下,该字段将使用默认值初始化。 有趣的是,在处理静态最终字段时,字节码验证程序根本不会强制执行此类规则。 静态final字段可以在声明类中任意重新分配。

将编译决定延迟到运行时

invokedynamic指令的引入为Java虚拟机的实现带来了一些重大变化。 许多Java开发人员并不认为此新字节码指令的引入意义重大。 这种关于invokedynamic的观点的原因可能是由于无法使用Java编程语言明确定义动态方法调用。

但是,通过显式字节码生成,可以使用invokedynamic来延迟选择任意调用站点的调用目标的决定,直到其首次执行。 这对于动态语言特别有用,在动态语言中,只有在实际执行该方法之前,才能确定有关应调用哪种方法的信息。 乍一看,使用invokedynamic通常看起来等同于使用Java的反射API。 但是,通过使用动态方法调用,可以将方法调用显式链接到调用站点。 对于Java 9,此链接将产生一个堆栈跟踪,该跟踪完全隐藏了动态调用。 除了允许更有效地处理原始类型之外, invokedynamic还可以更好地处理安全性,因为JVM仅允许绑定对声明动态调用站点的类可见的调用目标。

每当以字节码创建一个invokedynamic调用站点时,它都会引用一个所谓的bootstrap方法来决定应调用哪种方法。 引导程序方法是用纯Java实现的,它用作查找例程,用于定位应该为invokedynamic指令调用的方法。 该查找仅执行一次。 但是,以后可以手动重新绑定动态呼叫站点。

通过使用某些伪语法, invokedynamic指令可用于实现根据随机状态调用方法foobar的调用站点。

void someMethod() {
  invokedynamic[Bootstrapper::bootstrap]
}

class Bootstrapper {
  static CallSite bootstrap(MethodHandles.Lookup lookup, Object... args) {
    String name = new Random().nextBoolean() ? "foo": "bar";
    MethodType methodType = MethodType.methodType(void.class);
    return new ConstantCallSite(lookup.findStatic(Bootstrapper.class, 
                                 name, methodType));
  }

  static void foo() {
    System.out.println("foo!");
  }

  static void bar() {
    System.out.println("bar!");
  }
}

首次调用包含invokedynamic指令的方法时,将调用引用的bootstrap方法。 作为第一个参数,它接收一个查找对象,用于查找对包含invokedynamic调用站点的类可见的方法。 根据随机名称分配的结果, invokedynamic调用站点可以通过返回适当的方法绑定到调用foobar 。 绑定该方法后,将不再为同一调用站点再次调用bootstrap方法。 同时,此后将对invokedynamic调用站点进行处理,就好像方法调用已硬编码到以前的动态调用站点一样

翻译自: https://www.infoq.com/articles/Java-Bytecode-Bending-the-Rules/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1

java字节码

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值