JEP 181:基于嵌套的访问控制
概述
引入 nests,这是一种与 Java 编程语言中现有的嵌套类型概念相一致的访问控制上下文。Nests 允许在逻辑上属于同一代码实体、但被编译为不同 class 文件的类,相互访问对方的私有成员,而无需编译器插入扩展访问权限的桥接方法。
非目标
该 JEP 不涉及大规模的访问控制,例如模块。
动机
许多 JVM 语言支持在单个源文件中包含多个类(例如 Java 的嵌套类),或者将非类的源代码构件转换为类文件。然而,从用户的角度来看,这些通常被认为都属于“同一个类”,因此用户期望它们共享一个通用的访问控制机制。为了满足这些期望,编译器通常需要通过添加访问桥接器将 private
成员的访问权限扩大到 package
:对私有成员的调用被编译为目标类中编译器生成的包级私有方法的调用,而该方法又会访问预期的私有成员。这些桥接器破坏了封装性,略微增加了部署应用程序的大小,并可能让用户和工具感到困惑。引入一个正式的概念,即一组类文件形成一个 nest(巢),其中 nest mates(巢友)共享一个通用的访问控制机制,可以以更简单、更安全、更透明的方式直接实现预期的结果。
通用访问控制上下文的概念也出现在其他地方,例如 Unsafe.defineAnonymousClass()
中的宿主类机制,其中动态加载的类可以使用宿主的访问控制上下文。对嵌套成员关系的正式概念定义将使该机制建立在更坚实的基础上(但为 defineAnonymousClass()
提供一个受支持的替代方案将是另一项工作)。
描述
《Java 语言规范》允许类和接口相互嵌套。在顶层声明的范围内(JLS 7.6),可以出现任意数量的嵌套类型。这些嵌套类型之间可以无限制地相互访问(JLS 6.6.1),包括私有字段、方法和构造函数。我们可以将一个顶层类型及其所有嵌套的类型描述为形成一个 nest(巢),而一个巢中的两个成员则被称为 nestmates(巢友)。
私有访问在包含的顶层类型的整个声明中是完全的(无差异的,扁平的)。(可以将其视为一个顶层类型定义了一种“迷你包”,在这个“迷你包”中会授予额外的访问权限,甚至超出了提供给同一个 Java 包中其他成员的权限。)
如今,JVM 访问规则不允许巢友之间的私有访问。为了提供允许的访问权限,Java 源代码编译器必须引入一层间接访问。例如,对私有成员的调用会被编译为对目标类中编译器生成的包私有(package-private)桥接方法的调用,而该桥接方法随后会调用预期的私有方法。这些访问桥接方法仅在需要满足巢内请求的成员访问时才会生成。
由于 JVM 不支持嵌套类之间的私有访问,这带来的另一个后果是核心反射也同样拒绝访问。从一个嵌套类到另一个嵌套类的反射方法调用(使用 java.lang.reflect.Method.invoke
)会抛出 IllegalAccessError
(除非已禁用访问控制)。鉴于反射调用的行为应与源代码级别的调用相同,这令人感到意外。同样地,MethodHandle
API 拒绝直接“查找”私有嵌套类方法,但通过 Lookup.in
提供特殊支持,以允许表达源代码级别的调用语义。
通过在 JVM 中编纂“嵌套成员(nestmates)”的概念及其相关的访问规则,我们简化了 Java 源代码编译器的工作,加强了现有的访问检查,并消除了核心反射和 MethodHandle
API 中的意外行为。我们还允许未来的增强功能利用“嵌套(nest)”的概念。例如:
- 在泛型特化中,每个特化的类型都可以作为泛型类型的嵌套类创建。
- 一个安全且受支持的
Unsafe.defineAnonymousClass()
API 替代方案可以将新类创建为现有类的嵌套类。 - “密封类”的概念可以通过只允许作为嵌套类的子类来实现。
- 真正私有的嵌套类型可以实现(当前私有的嵌套类型是用包访问权限定义的)。
嵌套类文件属性
现有的类文件格式定义了 InnerClasses
和 EnclosingMethod
属性(JVMS 4.7.6 和 4.7.7),以允许 Java 源代码编译器(如 javac
)具体化源代码级别的嵌套关系。每个嵌套类型都被编译到它自己的类文件中,不同的类文件通过这些属性的值“链接”在一起。虽然这些属性足以让 JVM 确定嵌套成员关系,但它们并不直接适用于访问控制,并且本质上与单一的 Java 语言概念相关联。
为了允许更广泛、更通用的嵌套成员概念,而不仅仅局限于 Java 语言中的嵌套类型,同时为了实现高效的访问控制检查,提议对类文件格式进行修改以定义两个新属性。其中一个嵌套成员(通常是顶级类)被指定为 嵌套宿主(nest host),并包含一个属性 (NestMembers
) 用于标识其他静态已知的嵌套成员。其他每个嵌套成员都有一个属性 (NestHost
) 来标识其嵌套宿主。
嵌套类的 JVM 访问控制
我们将通过在 JVMS 5.4.4 中添加类似以下的条款来调整 JVM 的访问规则:
一个字段或方法 R 对于类或接口 D 是可访问的,当且仅当满足以下任一条件时:
- ...
- R 是私有的,并且是在另一个类或接口 C 中声明的,同时 C 和 D 是嵌套伙伴(nestmates)。
对于类型 C 和 D 要成为嵌套成员(nestmates),它们必须具有相同的嵌套宿主(nest host)。如果类型 C 在其 NestHost
属性中列出了 D,则表明 C 声称为由 D 所托管的嵌套的成员。如果 D 也在其 NestMembers
属性中列出了 C,则该成员关系得到验证。D 默认是它所托管的嵌套的成员。
一个没有 NestHost
或 NestMembers
属性的类,会隐式地形成一个嵌套结构,其自身作为嵌套主机,并且是唯一的嵌套成员。
放宽的访问规则将影响以下活动期间的访问检查:
- 解析字段和方法 (JVMS 5.4.3.2 等)
- 解析方法句柄常量 (JVMS 5.4.3.5)
- 解析调用点说明符 (JVMS 5.4.3.6)
- 通过
java.lang.reflect.AccessibleObject
的实例检查 Java 语言访问权限 - 在对
java.lang.invoke.MethodHandles.Lookup
进行查询期间检查访问权限
随着访问规则的改变,以及对字节码规则进行适当的调整,我们可以允许生成调用字节码的简化规则:
- 对于私有嵌套类构造函数,使用
invokespecial
- 对于私有的非接口嵌套类实例方法,使用
invokevirtual
- 对于私有的接口嵌套类实例方法,使用
invokeinterface
;以及 - 对于私有的嵌套类静态方法,使用
invokestatic
这放宽了现有的约束,即私有接口方法必须使用 invokespecial
(JVMS 6.5)来调用,并且更普遍地允许使用 invokevirtual
来调用私有方法,而不是增加围绕 invokespecial
的复杂使用规则。可以对 MethodHandle
调用的语义进行类似的更改(其反映了调用字节码的约束)。
嵌套成员验证
在依赖嵌套成员访问的访问检查进行之前,必须先验证嵌套成员的身份。这一验证过程可能会晚至成员访问时才发生,或者早至类的验证阶段,也可能在两者之间的某个时刻(例如方法的即时编译期间)进行。嵌套成员身份的验证要求加载嵌套宿主类(如果尚未加载)。为了避免可能不必要的类加载,嵌套成员身份的验证应尽可能延迟进行,即在访问检查时执行。这减轻了因依赖嵌套成员访问而要求嵌套宿主类必须存在所带来的兼容性影响。
为了保持嵌套的完整性,建议至少在最初时,禁止使用任何形式的类转换或类重定义来修改嵌套的类文件属性。
Nestmate 反射 API
当我们引入新的类文件属性时,按照惯例应该提供一种使用核心反射机制来检查/查询这些属性的方法。目前设想在 java.lang.Class
中添加三个方法:getNestHost
、getNestMembers
和 isNestmateOf
。
受影响的规范和 API
所提出的变更虽然概念上很简单,但会影响所有明确或隐含涉及访问控制或与方法调用模式相关的规范和 API。这些包括:
- 《Java 虚拟机规范》
- Classfile 属性变更
- 访问控制规则变更
- 调用字节码规则变更
- 核心反射
Method
调用规则Field
访问规则
MethodHandle
查找规则- 类转换/重新定义:JVM TI 和
java.lang.instrument
API、JDWP 和 JDI (com.sun.jdi.VirtualMachine
)- 禁止修改与 nest 相关的 classfile 属性
- Pack200 规范
- 识别新的 classfile 属性
对 Java 源代码编译器的影响
提议的变更简化了将 Java 源代码结构映射到类文件的规则,因此会对选择利用它们的 Java 源代码编译器产生一些影响:
- 正确生成与嵌套相关的类文件属性
- 省略之前所需的访问桥接方法,并生成对私有嵌套成员的直接成员访问指令
- 发出正确/合适的调用字节码
- 能够将其他合成方法更改为私有,而不是包私有(甚至可以消除它们,或者用共享但私有的方法句柄常量替换它们)
javac
编译器在生成最新版本的类文件时,将更新为充分利用嵌套成员(nestmates)。(旧版本的类文件仍将像现在一样使用访问桥接等方式生成。)
对其他工具的影响
任何操作 classfiles 的工具,或生成及处理字节码的工具,都有可能受到这些更改的影响。此类工具至少必须能够容忍新的 classfile 属性的存在,并适应字节码规则的变化。例如:
javap
类文件检查工具,- Pack200 实现,以及
- ASM 字节码操作框架,它在 JDK 内部也被使用。
开放问题
访问检查的额外复杂性是需要被审查的内容。特别是围绕嵌套宿主类(nest host class)解析及其可能引发的错误等问题。我们已经遇到并解决了一个问题,即编译器线程需要加载一个嵌套宿主类——这在编译器线程中是不被允许的。我们需要确保实现能够处理这些情况,并保证引入这些内容不会对规范产生影响。
替代方案
我们可以根据需要继续在 Java 编译器中生成桥接方法。这是一个难以预测的过程。例如,Project Lambda 在存在内部类的情况下难以解析方法句柄常量,从而产生了一种新的桥接方法。由于编译器生成的桥接方法既棘手又难以预测,因此它们也容易出错,并且很难被各种工具(包括反编译器和调试器)分析。
最初的提议考虑使用现有的 InnerClasses
和 EnclosingMethod
属性来建立嵌套关系。但引入特定的嵌套相关属性不仅使嵌套关系比仅与语言级别的嵌套类型相关更加通用,还允许更高效的实现。此外,如果我们选择进行积极的嵌套成员验证检查,这将改变现有属性的语义,从而引发兼容性问题。虽然 javac
编译器可能会继续保持“内部类”和“嵌套成员”属性的一致性,但这只是编译器的选择,而 JVM 将完全独立地对待它们。
在确定当前方法之前,曾有关于如何最好地通过类文件属性来表达嵌套关系的讨论。其中一个建议是采用分散的方法,即每个嵌套体都用一个 UUID 来标识。该讨论得出以下结论:
这样的提议包含两个部分:
基于 UUID 的新巢式命名约定。这是 JVM 中的一个新概念,需要新的基础设施来管理(生成、转码、验证、反射、调试)。这意味着会有新的错误和新的攻击面。在没有决定性优势的情况下,最好重用现有的命名空间,特别是 JVM 的类型名称字典。
单向链接。UUID 是一个纯粹的身份标识,不包含内容,因此也没有其巢成员的列表。巢成员通过 UUID 指向巢。任何类只需提及适当的 UUID,就可以将自身注入到同一个包中的巢中。单向链接意味着无法枚举一个巢。这使得一些基于密封类型的优化变得复杂。巢的安全性和可密封性降低到与包相同。PRIVATE 只是默认作用域访问控制的别名。
对不起,与当前提议相比,这两部分对我都没有吸引力。
测试
我们将需要一套广泛的 JVM 测试,以验证新的访问规则和支持嵌套成员的字节码语义调整。
同样,我们需要对核心反射、方法句柄、变量句柄以及外部访问 API(如 JDWP、JVM TI 和 JNI)进行额外的测试。
由于此处未提出任何语言更改,因此不需要新的语言合规性测试。
在修改 javac
编译器以利用嵌套成员访问权限后,对于嵌套成员的充分功能测试将自然地从语言合规性测试中产生。
风险与假设
新规则必须与新的类文件版本号相关联,因为我们需要确保 Java 源编译器仅在针对能够理解这些规则的 JVM 时,才会生成依赖于这些新属性和规则的字节码。由此可得,JVM 只有在类文件的版本号合适时,才会识别并依据新属性进行操作。新的类文件版本会给整个 Java 生态系统中的工具带来负担,但我们不认为 Nestmates 会是目标 JDK 发布版本中唯一依赖新类文件版本号的技术。
放宽访问限制带来的合规风险很小。所有如今能够编译和运行的 Java 语言访问操作,在引入嵌套成员(nestmate)变更后,无需对源代码进行任何修改,仍然可以正常编译和运行。当前通过 setAccessible
禁用访问检查以反射访问嵌套成员的代码,在嵌套成员变更后仍将继续正确运行——但可以对其进行更新,从而不再禁用访问检查。
检查禁止行为的合规性测试在某些情况下可能会失败。例如:
- 目前,直接反射访问私有嵌套方法会失败(除非禁用了访问检查),但应用这些更改后将会“意外地”成功。
- 一个测试
invokeinterface
不能用于私有接口方法的测试现在会失败,因为应用这些更改后它就可以被使用了。
由于该提案放宽了访问限制,因此对用户兼容性的风险很小或没有风险。但是,如果用户“发现”并利用了访问桥接方法,那么在桥接方法被移除后,他们将无法继续这样做。这种风险非常小,因为桥接方法本来就没有稳定的名称。
由于所提议的规则只会在单个运行时包内授予新的访问权限,因此对系统完整性几乎没有风险。通过消除桥接方法的需求,不同顶级类之间的潜在访问将被系统地减少。
嵌套成员验证需要存在嵌套宿主类,即使该类本身未被使用(除了作为嵌套成员的容器之外)。这可能会在三个领域产生影响:
-
类加载的顺序可能会发生变化,因为在直接使用嵌套宿主(nest host)之前,可能需要嵌套宿主来进行访问检查。这预计不会成为问题,因为类只是被加载,而没有被初始化,并且依赖于类加载顺序(不同于类初始化顺序)的情况非常少见。
-
这可能会影响那些从其分布式形式中删除未使用类的测试/应用程序,并且嵌套宿主未被使用。通过在需要进行嵌套成员访问检查时才执行嵌套成员验证,我们旨在尽量减少此问题的影响,但在某些情况下,最终用户将不得不更改他们分发代码的方式。我们认为这是一个非常小的风险,因为纯粹将顶级类用作只包含静态嵌套类型的无状态容器并不常见,在这种情况下,嵌套类型会依赖彼此之间的私有访问。
-
嵌套宿主的解析还会将类加载(以及可能出现的相关异常)引入到 JVM 的访问检查逻辑中。这主要是 JVM 实现者需要关注的问题。必须小心确保所有可能导致 VM 访问检查的路径要么排除了加载嵌套宿主的可能性,要么能够处理它。同样地,对于可能出现的异常也是如此。从用户的角度来看,由于 Java 代码很少对类加载可能发生的时间和地点做出假设,并且只有在类文件格式不正确的情况下才会发生异常,因此这方面带来的风险非常小。