JEP 181: Nest-Based Access Control | 基于嵌套的访问控制
摘要
引入 嵌套(nests)的概念,这是一种访问控制上下文,它与 Java 编程语言中现有的嵌套类型概念保持一致。嵌套允许逻辑上属于同一代码实体的类(但编译为不同的类文件)能够相互访问对方的私有成员,而无需编译器插入扩大访问权限的桥接方法。
非目标
此 JEP 不涉及模块等大型规模的访问控制。
动机
许多 JVM 语言支持在单个源文件中包含多个类(如 Java 的嵌套类),或将非类源代码工件转换为类文件。然而,从用户的角度来看,这些通常都被视为“同一个类”中的一部分,因此用户期望它们共享相同的访问控制机制。为了保持这些期望,编译器经常需要通过添加访问桥接来扩大 private
成员的访问权限至 package
:对私有成员的调用被编译为对目标类中编译器生成的包私有方法的调用,后者进而访问目标私有成员。这些桥接破坏了封装性,稍微增加了已部署应用程序的大小,并可能使用户和工具感到困惑。一个正式的类文件组形成 嵌套(nest)的概念,其中 嵌套伙伴(nest mates)共享一个共同的访问控制机制,能够以更简单、更安全、更透明的方式直接实现所需的结果。
在其他地方也出现了共同访问控制上下文的概念,例如在 Unsafe.defineAnonymousClass()
中的宿主类机制中,动态加载的类可以使用宿主的访问控制上下文。嵌套成员身份的正式概念将使这一机制更加稳固(但实际上为 defineAnonymousClass()
提供受支持的替代方案将是一项单独的工作。)
说明
Java 语言规范允许类和接口相互嵌套。在顶级声明的范围内(JLS 7.6),可以出现任意数量的嵌套类型。这些嵌套类型彼此之间的访问不受限制(JLS 6.6.1),包括私有字段、方法和构造函数。我们可以将顶级类型及其内部所有嵌套类型描述为一个“嵌套集”,嵌套集中的两个成员被称为“嵌套伙伴”。
在整个包含顶级类型的声明中,私有访问是完整的(无差别、平坦的)。(可以将其视为顶级类型定义了一个类似于“小型包”的概念,在这个“小型包”内部,提供了额外的访问权限,甚至超出了提供给同一 Java 包中其他成员的权限。)
目前,根据 JVM 的访问规则,嵌套伙伴之间的私有访问是不被允许的。为了提供允许的访问,Java 源代码编译器必须引入一级间接性。例如,对私有成员的调用被编译成对目标类中编译器生成的包私有桥接方法的调用,该方法转而调用预期的私有方法。这些访问桥接方法仅在需要满足嵌套内部请求的成员访问时才生成。
JVM 不支持嵌套内部私有访问的另一个后果是,核心反射也拒绝访问。从一个嵌套伙伴到另一个嵌套伙伴的反射方法调用(使用 java.lang.reflect.Method.invoke
)会引发 IllegalAccessError
(除非已禁用访问控制)。这令人惊讶,因为反射调用应该与源代码级别的调用行为相同。类似地,MethodHandle
API 拒绝直接“查找”私有嵌套伙伴方法,但提供了通过 Lookup.in
的特殊支持,以允许表达源代码级别的调用语义。
通过在 JVM 中编码嵌套伙伴概念和相关访问规则,我们简化了 Java 源代码编译器的工作,加强了现有的访问检查,并消除了核心反射和 MethodHandle
API 中的意外行为。我们还为未来的增强功能提供了利用“嵌套”概念的机会。例如:
- 在 泛型特化 中,每个特化类型都可以作为泛型类型的嵌套伙伴创建。
- 一个安全且受支持的
Unsafe.defineAnonymousClass()
API 的替代方案可以将新类作为现有类的嵌套伙伴创建。 - “密封类”的概念可以通过仅允许作为嵌套伙伴的子类来实现。
- 可以实现真正私有的嵌套类型(目前私有嵌套类型定义为具有包访问权限)。
嵌套类文件属性
现有的类文件格式定义了 InnerClasses
和 EnclosingMethod
属性(JVMS 4.7.6 和 4.7.7),以允许 Java 源代码编译器(如 javac
)将源代码级别的嵌套关系具体化。每个嵌套类型都被编译成自己的类文件,这些类文件通过这些属性的值“链接”在一起。尽管这些属性足以让 JVM 确定嵌套伙伴的关系,但它们并不直接适用于访问控制,而且本质上与单一的 Java 语言概念相关联。
为了支持更广泛、更一般的嵌套伙伴概念,而不仅仅是 Java 语言的嵌套类型,同时也为了高效的访问控制检查,建议修改类文件格式以定义两个新属性。一个嵌套成员(通常是顶级类)被指定为 嵌套宿主,并包含一个属性(NestMembers
)来标识其他静态已知的嵌套成员。每个其他嵌套成员都有一个属性(NestHost
)来标识其嵌套宿主。
JVM 对嵌套伙伴的访问控制
我们将通过向 JVMS 5.4.4 添加类似以下条款来调整 JVM 的访问规则:
字段或方法 R 对于类或接口 D 是可访问的,当且仅当以下条件中的任何一项为真:
- ...
- R 是私有的,且声明在不同的类或接口 C 中,且 C 和 D 是嵌套伙伴。
对于类型 C 和 D 要成为嵌套伙伴,它们必须具有相同的嵌套宿主。类型 C 如果在其 NestHost
属性中列出 D,则声称自己是 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
调用的语义(这反映了调用字节码的限制)。
嵌套成员验证
在进行依赖嵌套伙伴访问的访问检查之前,必须验证嵌套成员身份。这可能发生在成员访问时,也可能发生在类验证时,或者介于这两者之间的某个时刻,例如在方法的即时编译(JIT)时。嵌套成员验证要求如果嵌套宿主类尚未加载,则必须加载它。为了避免可能的不必要的类加载,嵌套成员验证应尽可能晚地进行,即在访问检查时。这有助于缓解由于要求嵌套伙伴访问时嵌套宿主类必须存在而引入的不兼容性问题。
为了保持嵌套集的完整性,建议至少在最初阶段,禁止使用任何形式的类转换或类重新定义来修改嵌套类文件属性。
嵌套伙伴反射 API
由于我们引入了新的类文件属性,因此通常应提供一种使用核心反射来检查 / 查询这些属性的方法。目前设想在 java.lang.Class
中有三个方法:getNestHost
、getNestMembers
和 isNestmateOf
。
受影响的规范和 API
虽然提议的更改在概念上很简单,但它们会影响所有明确或隐式涉及访问控制或与方法调用模式相关的规范和 API。这些包括:
- Java 虚拟机规范
- 类文件属性更改
- 访问控制规则更改
- 调用字节码规则更改
- 核心反射
Method
调用规则Field
访问规则
MethodHandle
查找规则- 类转换 / 重新定义:JVM TI 和
java.lang.instrument
API、JDWP 和 JDI (com.sun.jdi.VirtualMachine
)- 禁止修改与嵌套相关的类文件属性
- Pack200 规范
- 识别新的类文件属性
对 Java 源代码编译器的影响
提议的更改简化了将 Java 源代码构造映射到类文件的规则,因此选择使用它们的 Java 源代码编译器会受到以下一些影响:
- 正确生成与嵌套相关的类文件属性
- 省略先前需要的访问桥接方法,并为私有嵌套伙伴成员生成直接成员访问指令
- 发布正确 / 适当的调用字节码
- 能够将其他合成方法更改为私有方法而不是包私有方法(或者甚至消除它们,或用共享但私有的方法句柄常量替换它们)
javac
编译器将更新为在生成最新版本的类文件时充分利用嵌套伙伴。(旧版本将像今天一样使用访问桥接等生成。)
对其他工具的影响
任何操作类文件、生成或处理字节码的工具都可能受到这些更改的影响。至少,这些工具必须容忍新的类文件属性的存在,并允许字节码规则的变化。例如:
javap
类文件检查工具,- Pack200 实现,
- 以及 ASM 字节码操作框架,该框架在 JDK 内部也有使用。
未解决的问题
访问检查的额外复杂性是一个需要研究的问题。特别是关于嵌套宿主类的解析以及可能产生的错误。我们已经遇到并解决了一个问题,即编译器线程需要加载嵌套宿主类,这在编译器线程中是不允许的。我们需要确保实现能够处理这些情况,并确保这些内容的引入不会影响规范。
替代方案
我们可以继续在 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 生态系统中的工具带来负担,但我们不期望嵌套伙伴成为目标 JDK 发布中唯一依赖新类文件版本号的技术。
放宽访问权限带来的合规风险较小。今天所有能够编译和运行的 Java 语言访问,在嵌套伙伴更改后仍然能够编译和运行,无需更改源代码。对于今天通过 setAccessible
禁用访问检查以进行反射访问嵌套伙伴的代码,在嵌套伙伴更改后仍将正确运行,但可以更新为不禁用访问检查。
在某些情况下,检查禁止行为的合规性测试可能会失败。例如:
- 直接反射访问私有嵌套方法目前会失败(除非禁用访问检查),但在应用这些更改后将“意外”成功。
- 测试
invokeinterface
无法用于私有接口方法的测试现在将失败,因为应用这些更改后可以使用它。
由于该提案放宽了访问权限,因此用户兼容性风险很小或没有。但是,如果用户“发现”并利用了访问桥接方法,那么在删除桥接方法后,他们将无法这样做。这种风险非常小,因为桥接方法从一开始就没有稳定的名称。
系统完整性面临的风险很小或没有,因为所提议的规则仅在同一运行时包内赋予新的访问权限。通过消除对桥接方法的需求,不同顶层类之间的潜在访问将系统地减少。
嵌套成员身份验证需要嵌套宿主类的存在,即使该类本身未被使用(仅作为嵌套成员的容器除外)。这可能对以下三个方面产生影响:
类加载的顺序可能会发生变化,因为嵌套宿主类可能需要在任何直接使用嵌套宿主类之前进行访问检查。这预计不会成为问题,因为类只是被加载,而不是被初始化,而且对类加载顺序(与类初始化顺序不同)的依赖非常罕见。
这可能会影响从分发形式中剥离未使用类的测试 / 应用程序,尤其是当嵌套宿主类未被使用时。我们计划将嵌套成员身份验证推迟到需要嵌套伙伴访问检查的时候进行,以最小化此问题的影响,但在某些情况下,最终用户将不得不改变他们分发代码的方式。我们认为这种风险非常小,因为不常见仅将顶层类用作无状态容器,仅包含静态嵌套类型,而嵌套类型将依赖于彼此之间的私有访问。
解析嵌套宿主类也会将类加载(以及可能发生的相关异常)引入 JVM 的访问检查逻辑中。这主要是 JVM 实现者需要关注的问题。必须小心谨慎,确保所有可能导致 VM 访问检查的路径要么排除加载嵌套宿主类的可能性,要么能够处理它。同样地,也需要考虑可能发生的潜在异常。从用户的角度来看,由于 Java 代码很少假设类加载何时何地发生,并且异常只会在类文件格式不正确的情况下发生,因此这方面的风险非常小。