欢迎光临散文网 会员登陆 & 注册

Java ASM详解:类的结构(二)

2022-06-02 18:00 作者:Nickid2018  | 我要投稿

上次专栏讲解了普通类的结构,这篇专栏将继续讲解接口、注解、枚举和记录这四种特殊的类。

一.接口

接口类似于抽象类,内部含有静态方法、公开抽象方法、公开默认实例方法、私有实例方法和常量字段。

声明一个接口,需要在ClassWriter::visit时同时使用ACC_ABSTRACTACC_INTERFACE访问标志。如果只含有ACC_INTERFACE标志,在加载这个类的时候JVM就会抛出java.lang.ClassFormatError: Illegal class modifiers in class *: 0x200的异常。接口的继承本质其实是实现,也就是说接口的父类仍然是Object,但是实现接口列表可以加入其它的接口。

如果接口是一个内部类,在使用时类似于静态内部类,也就是说内部接口不需要外部类实例作为依托。(但是在使用visit时不用写ACC_STATIC,这是一种等效)同样的,如果接口内部有内部类,那么内部类也等效于静态内部类。

紧接着说说接口的字段。接口内只能存在一种字段,那就是公开常量字段,也就是PSF(public static final)字段。在visitField时必须同时使用ACC_PUBLIC、ACC_STATIC和ACC_FINAL访问标志,否则在加载这个类的时候就会抛出java.lang.ClassFormatError: Illegal field modifiers in class *: *的异常。

接着来看看方法。接口不存在构造函数,并且只允许两种访问修饰符:public和private(引入于Java 9),protected不能在接口中使用,不写访问修饰符则默认公开。

接口内可以定义静态方法,与普通的类没有太多差别,只是访问修饰有差别。对于接口内的实例方法,在Java层如果不定义default或者private那就会自动加上abstract,但是对于字节码来说,除了访问修饰外所有的定义都和普通的类一样。

接口也可以使用桥接方法。

下面是个例子,要生成下面的类:

将使用下面的代码:

二.注解

注解类型是特殊的接口,除了接口都具有的特性之外还增加了一些限制。

先说一下声明。注解类型除了接口要求的两个访问标志外还需要添加一个ACC_ANNOTATION标志,且必须只实现(或者说是Java层的继承)于java.lang.annotation.Annotation。(注意:JVM对实现Annotation接口这件事不加以检查,但是如果你使用了反射尝试获取这个注解时会报错)

注解对于方法的要求很严格:要求不能有私有方法、默认方法和静态方法,只能存在公开抽象方法。

到这里你可能会问:注解方法的默认值是怎么实现的?其实默认值不是方法体,而是使用了visitAnnotationDefault这个方法用于写入默认值。

下面我们要生成这个注解:

可以使用下面的代码生成:

三.枚举

枚举是一种特殊的类,主要用于存贮常量。和普通的类相比,它有下面的性质:

  • 所有枚举都继承于java.lang.Enum,并且都为final。

  • 枚举的所有构造函数都是私有的。

  • 自动生成values和valueOf方法。

  • 作为内部类时等效静态。

写入一个枚举,在visit时要将ACC_ENUMACC_FINAL访问标志同时写入,并且父类必须写为java/lang/Enum。由于Enum带有泛型,所以signature也要写入。例如,下面这个枚举:

在声明时必须用下面的代码:

在使用Java编写枚举类时可以写两种字段:一种是普通的字段,这个和普通的类一样,没有限制;另一种就是枚举字段,它必须是枚举类的对象,并且是PSF字段还带有ACC_ENUM访问标志。例如下方的枚举字段A:

在声明时应该遵照下面的方式:

在字节码中,除了上面的两种字段外,枚举中还有一个字段是系统生成用于保存所有枚举字段的私有常量,$VALUES。它的访问标志除了ACC_PRIVATE、ACC_STATIC和ACC_FINAL外还带有ACC_SYNTHETIC,这代表它是编译时自动生成的。它的类型是这个类的数组。

$VALUES存在的价值是为了values方法和Enum的索引。在介绍它的用途之前先来说说$VALUES和枚举字段的初始化。

枚举字段和普通常量的初始化一样,也是简单的创建、调用构造函数和赋值。但是不同的是,枚举的构造函数和普通的构造函数不同,它默认带有两个形参。实际上,枚举的默认构造函数是这样的:

枚举默认构造函数的写入如下:

如果枚举类有定义构造函数,那么在字节码中仍然需要将这两个形参添加到Java源代码的形参列表之前,且必须调用Enum的这个父类构造函数。

所以枚举字段的创建对于上面的A来说就像下面这样:

$VALUES的赋值不太一样,它是委托到了另一个方法$values生成的。这个方法带有ACC_PRIVATE、ACC_STATIC和ACC_SYNTHETIC访问标志,且方法返回值是该类的数组,形参列表为空。它的作用就是创建一个数组,并将所有枚举字段按顺序存储进数组之中并返回。对于上面的Test就是这样的:

在静态初始化中,$VALUES直接由$values的返回值赋值:

我们用到的values是另一个方法,也是由编译器自动生成的。它返回的是$VALUES的副本,Java的代码像这样:

// 可以注意到这个方法不存在try-catch,即使clone定义了抛出CloneNotSupportedException。JVM对这种异常处理不检查,可以说在字节码范围内,异常处理是可有可无的。

字节码像这样:

另一个会自动创建的方法,valueOf,用Java表示是这样的:

字节码写入如下:

到这里一个完整的枚举才写完。可以看到我们必须写出$VALUES、$values、values、valueOf这些字段和方法,非常的麻烦。即使一个非常简单的枚举也必须有所有这些要素,所以枚举的写入很繁琐,还要注意别忘了它的组件。

四.记录

记录也是一种特殊的类,它在Java 14开始加入。和普通的类相比,它有下面的不同之处:

  • 不能单独定义实例字段,所有终态实例字段都要在类之后的括号定义。

  • 继承于java.lang.Record,且都为final。

  • 自动生成toString、hashCode和equals。(除非自行定义)

  • 作为内部类时等效静态。

写入一个记录,在visit时必须带有ACC_FINAL和ACC_RECORD访问标志,并且要继承java/lang/Record。接下来以下面的记录作为例子:

在写入时要这样定义:

对于记录的终态实例字段(也可以叫记录字段),它只能含有ACC_PRIVATE和ACC_FINAL这两个访问标志。它需要两次定义:一次是普通的字段定义,使用visitField;另一次是记录组件的定义,使用visitRecordComponent,在定义字段之前写入。这里的a就像下面这样定义:

每个记录字段都有自动生成的对应的getter,代码很简单,就像下面这样:

字节码像这样写:

记录的默认构造函数和记录字段有关,形参列表正好和记录字段的顺序一致。对于上面的Test,构造函数是这样的:

转换成字节码如下:

记录必须有toString、hashCode、equals这三个方法,这是因为在Record中声明了它们3个是抽象的。如果我们不自己写这三个方法,那么系统在编译的时候会自动生成。

自动生成的这三个方法都用到了invokedynamic,使用的引导方法都是java.lang.runtime.ObjectMethods.bootstrap。这个方法的定义是:

其中names是记录实例字段的名称序列,用分号;隔开。

下面仅给出toString的代码,另两个除了methodName和type不同外没有差别。

到这里记录才算写入完毕。

到这里类的结构就结束了,接下来的文章将讨论好玩的东西(因为还没想出来)。

这系列专栏没有特殊声明都是Java 17的字节码,请注意使用。

如果对文章内容有问题或文章有错误可以评论区或私信指出。

Java ASM详解:类的结构(二)的评论 (共 条)

分享到微博请遵守国家法律