浅析 Java 中的密封类(Sealed Classes) 在 class 文件中是如何实现的
JDK 17 正式支持 密封类(Sealed Classes),那么密封类在 class 文件中是如何实现的呢?本文对此进行探讨。
要点
- The
PermittedSubclassesattribute records the classes and interfaces that are authorized to directly extend or implement the current class or interface (§5.3.5). (大意是,PermittedSubclasses属性中记录了密封类的信息,但是精准的表述请读者朋友参考这里的英文) - 密封类所
permit的子类,会有什么特殊的标记- 如果子类
A是sealed class,则A.class中会有PermittedSubclasses属性 - 如果子类
B是final class,则B.class中的access_flags中的ACC_FINAL这个bit会是 - 如果子类
C是non-sealed class,则C.class中不需要任何特殊表示
- 如果子类
正文
准备工作:一个密封类的例子
小明学习了密封类(Sealed Classes)的知识后,决定应用所学的知识写点代码。早上醒来后,小明信心满满,决定规划一下今天要做的事情,于是写了这样的代码 ⬇️ (请将以下代码保存为 要做的事情.java,不过正常情况下还是不要用有汉字的类名,这里的例子仅供娱乐)
public sealed class 要做的事情 permits 吃饭, 运动, 睡觉 {
}
sealed class 吃饭 extends 要做的事情 {
}
final class 吃早饭 extends 吃饭 {
}
final class 吃午饭 extends 吃饭 {
}
final class 吃晚饭 extends 吃饭 {
}
final class 睡觉 extends 要做的事情 {
}
// 还没想好要从事哪种运动,所以就让“运动”是 non-sealed class 吧
non-sealed class 运动 extends 要做的事情 {
}
从这段代码可以看出来,小明其实还是没想好今天到底要做什么。不过我们的重点在于密封类,就别管小明了。这里涉及的类有点多,我画了张类图来表示它们之间的关系 ⬇️
classDiagram
要做的事情 <|-- 吃饭
吃饭 <|-- 吃早饭
吃饭 <|-- 吃午饭
吃饭 <|-- 吃晚饭
要做的事情 <|-- 睡觉
要做的事情 <|-- 运动
密封类的子类只会有 种情况,这个例子里都出现了,具体情况如下表所示 ⬇️
| 子类 | 特点 |
|---|---|
吃饭 | ⬅️ 它也是一个 sealed class,它 permit 的子类是:吃早饭, 吃午饭,吃晚饭 |
睡觉 | ⬅️ 它是 final class |
运动 | ⬅️ 它是 non-sealed class |
密封类(Sealed Classes) 在 class 文件中是如何实现的
现在我们来分析密封类(Sealed Classes)在 class 文件中是如何实现的。
一个猜测是,class 文件中可能会用 access_flags 中的某一个 bit 来表示这个 class 是密封类。
说到这里,先补充一下 access_flags 具体是什么。
关于 access_flags 的补充说明
Java Virtual Machine Specification 中的 4.1. The ClassFile Structure 小节 提到了 class 文件的结构 ⬇️ 在下图中绿色箭头所示位置,可以看到 access_flags (可以将 u2 简单理解成 2 byte 的无符号数)。
在 Java Virtual Machine Specification 中的 4.1. The ClassFile Structure 小节 还可以找到如下的表格,其中说明了 access_flags 中每个 bit 的含义。
这个表格中并没有和密封类(Sealed Classes)直接相关的 bit。
怎么回事,莫非理解有误?我们再去看看 class 文件。
用如下命令可以编译 要做的事情.java。编译后会得到若干个 class 文件
javac 要做的事情.java
用 javap -v -p 要做的事情 命令可以查看 要做的事情.class 文件的具体内容。
主要的结果如下(开头的几行我略去了) ⬇️
public class 要做的事情
minor version: 0
major version: 66
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // 要做的事情
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 1, attributes: 2
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "":()V
#4 = Utf8 java/lang/Object
#5 = Utf8
#6 = Utf8 ()V
#7 = Class #8 // 要做的事情
#8 = Utf8 要做的事情
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 SourceFile
#12 = Utf8 要做的事情.java
#13 = Utf8 PermittedSubclasses
#14 = Class #15 // 吃饭
#15 = Utf8 吃饭
#16 = Class #17 // 运动
#17 = Utf8 运动
#18 = Class #19 // 睡觉
#19 = Utf8 睡觉
{
public 要做的事情();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 1: 0
}
SourceFile: "要做的事情.java"
PermittedSubclasses:
吃饭
运动
睡觉
上面的结果的第 行是 flags: (0x0021) ACC_PUBLIC, ACC_SUPER。
所以 access_flags 的值为 0x0021 = 0x0020 + 0x0001。
0x0001表明要做的事情这个class是public的0x0020比较特殊,从下图的描述中看,Java SE 8及之后,JVM认为所有的class的这个bit都被置位,所以可以先不管这个bit的具体含义。
看了 class 文件中的 access_flags 后,可以确认,密封类 不是 通过 access_flags 来实现的。
上文已经展示了 javap -v -p 要做的事情 命令的完整结果,考虑到它比较短,我们可以在里面找找是否有其他内容包含了密封类的信息。
这个结果的最后几行如下 ⬇️
PermittedSubclasses:
吃饭
运动
睡觉
这部分看起来属于 class 文件的属性(Attributes)部分。
说到这里,先补充一下 Attributes 具体是什么。
关于 Attributes 的补充说明
Java Virtual Machine Specification 中的 4.1. The ClassFile Structure 小节 提到了 class 文件的结构 ⬇️ 在下图中绿色箭头所示位置,可以看到 Attributes。
关于它的详细介绍,请参考 Java Virtual Machine Specification 中的 4.7. Attributes 小节。
由于我们现在只关心 PermittedSubclasses 这个属性,所以直接前往对应的文档 ⬇️
Java Virtual Machine Specification 中的 4.7.31. The PermittedSubclasses Attribute 小节
从下图绿色线上以及绿色框里的文字可以看出, PermittedSubclasses 属性中的确保存了密封类的信息 ⬇️ 绿色框里的文字特别指出了密封类 不是 通过 access_flags 来实现的。
由于这个描述出自 The Java Virtual Machine Specification,所以来源可靠,我把这个描述复制到下方 ⬇️
查看 PermittedSubclasses 属性各个 byte 的值
在 Java Virtual Machine Specification 中的 4.7.31. The PermittedSubclasses Attribute 小节 中可以找到 PermittedSubclasses 属性的具体格式 ⬇️
PermittedSubclasses_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 number_of_classes;
u2 classes[number_of_classes];
}
在这里,可以将 u2/u4 简单理解为 2 byte/4 byte 的无符号数。
从上方的格式可以推算出,一个 PermittedSubclasses 属性的长度会是 个 byte( 表示 number_of_classes,即当前类 permit 的直接子类的数量)。
由于 PermittedSubclasses 属性刚好在 要做的事情.class 文件的末尾,所以用比较暴力的方法也可以查看它的内容。
我们可以用以下命令来查看 要做的事情.class 中的每个 byte 的值。
od -t x1 要做的事情.class
下图红框里的值就是 PermittedSubclasses 对应的 个 byte()。
下方的表格展示了这 个 byte 的含义 ⬇️ 我们可以看到这里的结果和 javap -v -p 要做的事情 命令给出的结果是一致的。
| 类型 | 用十六进制表示的值 | 用十进制表示的值 | 含义 | |
|---|---|---|---|---|
attribute_name_index | u2 | 0x000d | ||
attribute_length | u4 | 0x00000008 | 表示这个属性还剩 个 byte 那么长 | |
number_of_classes | u2 | 0x0003 | 表示数组长度为 ⬇️ | |
classes 数组 | 数组中有个 u2 元素 | 数组中的值分别是 0x000e, 0x0010, 0x0012 | 数组中的值分别是 14, 16, 18 |
验证剩余的类
1. 吃饭.class
既然密封类的信息是保存在 PermittedSubclasses 属性中的,那么在 吃饭.class 中应该也能找到 PermittedSubclasses 属性。我们来验证一下。
用如下的命令可以查看 吃饭.class 的内容 ⬇️
javap -v -p 吃饭
主要的结果如下(开头的几行我略去了) ⬇️
class 吃饭 extends 要做的事情
minor version: 0
major version: 66
flags: (0x0020) ACC_SUPER
this_class: #7 // 吃饭
super_class: #2 // 要做的事情
interfaces: 0, fields: 0, methods: 1, attributes: 2
Constant pool:
#1 = Methodref #2.#3 // 要做的事情."":()V
#2 = Class #4 // 要做的事情
#3 = NameAndType #5:#6 // "":()V
#4 = Utf8 要做的事情
#5 = Utf8
#6 = Utf8 ()V
#7 = Class #8 // 吃饭
#8 = Utf8 吃饭
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 SourceFile
#12 = Utf8 要做的事情.java
#13 = Utf8 PermittedSubclasses
#14 = Class #15 // 吃早饭
#15 = Utf8 吃早饭
#16 = Class #17 // 吃午饭
#17 = Utf8 吃午饭
#18 = Class #19 // 吃晚饭
#19 = Utf8 吃晚饭
{
吃饭();
descriptor: ()V
flags: (0x0000)
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method 要做的事情."":()V
4: return
LineNumberTable:
line 4: 0
}
SourceFile: "要做的事情.java"
PermittedSubclasses:
吃早饭
吃午饭
吃晚饭
最后确实有 PermittedSubclasses 属性,而其中的内容刚好就是 吃饭 的那 个子类。
2. 睡觉.class
用如下的命令可以查看 睡觉.class 的内容 ⬇️
javap -v -p 睡觉
主要的结果如下(开头的几行我略去了) ⬇️
final class 睡觉 extends 要做的事情
minor version: 0
major version: 66
flags: (0x0030) ACC_FINAL, ACC_SUPER
this_class: #7 // 睡觉
super_class: #2 // 要做的事情
interfaces: 0, fields: 0, methods: 1, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // 要做的事情."":()V
#2 = Class #4 // 要做的事情
#3 = NameAndType #5:#6 // "":()V
#4 = Utf8 要做的事情
#5 = Utf8
#6 = Utf8 ()V
#7 = Class #8 // 睡觉
#8 = Utf8 睡觉
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 SourceFile
#12 = Utf8 要做的事情.java
{
睡觉();
descriptor: ()V
flags: (0x0000)
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method 要做的事情."":()V
4: return
LineNumberTable:
line 16: 0
}
SourceFile: "要做的事情.java"
- 由于
睡觉这个class没有被sealed修饰,所以睡觉.class没有PermittedSubclasses属性 - 由于
睡觉这个class是final的,所以它的access_flags中的ACC_FINAL这个bit是
3. 运动.class
用如下的命令可以查看 运动.class 的内容 ⬇️
javap -v -p 运动
主要的结果如下(开头的几行我略去了) ⬇️
class 运动 extends 要做的事情
minor version: 0
major version: 66
flags: (0x0020) ACC_SUPER
this_class: #7 // 运动
super_class: #2 // 要做的事情
interfaces: 0, fields: 0, methods: 1, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // 要做的事情."":()V
#2 = Class #4 // 要做的事情
#3 = NameAndType #5:#6 // "":()V
#4 = Utf8 要做的事情
#5 = Utf8
#6 = Utf8 ()V
#7 = Class #8 // 运动
#8 = Utf8 运动
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 SourceFile
#12 = Utf8 要做的事情.java
{
运动();
descriptor: ()V
flags: (0x0000)
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method 要做的事情."":()V
4: return
LineNumberTable:
line 21: 0
}
SourceFile: "要做的事情.java"
这次没有任何特殊的内容。那么 non-sealed 是如何体现的呢?
可以这样想,既然 要做的事情 这个 class permit 了 吃饭, 运动, 睡觉,
那么对这些子类而言,只会有如下的 种情况。既然情况 和 情况 都有各自的体现方式,那么情况 就不需要任何特殊的体现方式了。换言之,如果既不是情况 又不是情况 ,那就只能是情况 了。
| 编号 | 子类的具体情况 | 在子类的 class 文件中如何体现 |
|---|---|---|
子类 A 是 sealed class | A.class 中会有 PermittedSubclasses 属性 | |
子类 B 是 final class | B.class 的 access_flags 中的 ACC_FINAL 这个 bit 是 | |
子类 C 是 non-sealed class | C.class 不需要任何特殊表示 |
参考资料
- The Java Virtual Machine Specification 中的
- 4.1. The
ClassFileStructure - 4.7. Attributes
- 4.7.31. The
PermittedSubclassesAttribute
- 4.1. The