Java 基础体系 · 第 40/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 可见性与包:访问控制、封装、模块边界和依赖方向

在 Java 中,“能不能访问”不是一个单一问题。至少要区分四层边界:

  1. 语言访问控制private、无修饰符、protectedpublic
  2. 包(package)边界:类型和成员是否属于同一个包。
  3. 模块(module)边界:包所在模块是否被读取,包是否被导出或开放。
  4. 运行时反射边界:反射或 MethodHandles 是否被模块系统允许。

这些边界按顺序叠加。一个类型即使声明为 public,也可能因为所在包没有导出而无法被另一个模块编译使用;一个包即使被 exports,其中的 private 成员也不会因此变成普通可访问成员。


1. 先区分几个核心概念

1.1 类型、成员、访问者

考虑下面的代码:

package com.example.account;

public class Account {
    private String owner;

    public void rename(String newOwner) {
        this.owner = newOwner;
    }
}

这里有三个不同的访问对象:

  • Account 是一个顶层类型
  • ownerAccount字段成员
  • renameAccount方法成员

还需要区分访问者:

Account account = new Account();
account.rename("Alice");

访问者可能是:

  • 同一个类;
  • 同一个顶层类中的嵌套类;
  • 同一个包中的其他类型;
  • 不同包中的子类;
  • 不同包中的非子类;
  • 另一个模块中的类型;
  • 运行时反射代码。

Java 的访问检查会同时考虑“被访问成员的修饰符”和“访问发生的位置”。

1.2 import 不会授予访问权限

import com.example.account.Account;

import 只允许使用简单名称 Account,不会改变访问权限。下面两种写法在权限上完全等价:

import com.example.account.Account;

Account account;
com.example.account.Account account;

如果类型不可访问,改成全限定名也不能绕过检查。


2. Java 语言层面的四种访问控制

2.1 private:声明类内部的实现细节

private 成员只能在声明它的顶层类或接口的主体范围内访问:

package com.example.account;

public class Account {
    private String owner;

    private void validateOwner(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("owner is blank");
        }
    }

    public void rename(String newOwner) {
        validateOwner(newOwner);
        owner = newOwner;
    }
}

private 的直接效果是:

  • 同一个类的方法可以访问;
  • 同一个类的嵌套类可以访问;
  • 同一个包中的其他顶层类不能访问;
  • 子类不能访问;
  • 其他模块不能访问。

例如:

package com.example.account;

public class AccountPrinter {
    public static void print(Account account) {
        // 编译错误:owner has private access in Account
        // System.out.println(account.owner);
    }
}

嵌套类与 private

Java 源代码层面,同一个顶层类内部的嵌套类型可以互相访问私有成员:

public class Outer {
    private int value = 42;

    static class Nested {
        static int read(Outer outer) {
            return outer.value;
        }
    }

    public static void main(String[] args) {
        System.out.println(Nested.read(new Outer()));
    }
}

这不是因为 NestedOuter 属于同一个包,而是因为访问发生在同一个顶层类型的嵌套范围内。现代 JVM 还使用 nest-based access 机制表达这类嵌套关系,但源代码判断应以 Java 语言规则为准。

private 的典型用途不是“防止任何形式的观察”,而是限制实现细节的直接依赖。类可以通过方法控制状态变化,从而维护不变量:

public final class Money {
    private final long cents;

    private Money(long cents) {
        if (cents < 0) {
            throw new IllegalArgumentException("negative money");
        }
        this.cents = cents;
    }

    public static Money ofCents(long cents) {
        return new Money(cents);
    }

    public long cents() {
        return cents;
    }
}

调用方无法直接构造一个负数 Money,因为构造器和字段都不是公开的。封装在这里体现为:

允许的对象状态=所有能够通过公开操作建立的状态\text{允许的对象状态} = \text{所有能够通过公开操作建立的状态}

如果字段直接公开,调用方可能绕过校验;如果状态只能通过受控操作建立,类就能把合法性检查集中在自身内部。


2.2 无修饰符:包访问权限

成员或顶层类型没有写访问修饰符时,通常称为包私有默认访问权限

package com.example.account;

class AccountValidator {
    static boolean validOwner(String owner) {
        return owner != null && !owner.isBlank();
    }
}

它只能被同一个包中的代码访问:

package com.example.account;

public class AccountService {
    public boolean accepts(String owner) {
        return AccountValidator.validOwner(owner);
    }
}

不同包中的代码不能访问:

package com.example.billing;

public class BillingService {
    public boolean accepts(String owner) {
        // 编译错误:
        // AccountValidator is not public in com.example.account
        // return AccountValidator.validOwner(owner);
        return true;
    }
}

包私有常用于把多个类型组成一个实现协作单元:

  • AccountService 暴露业务入口;
  • AccountValidator、缓存、解析器等实现类型只在包内协作;
  • 包外只能看到公开 API。

但是,包私有不是强安全边界。它主要是 Java 编译器层面的可见性规则,不等价于进程隔离,也不阻止拥有足够权限的调试器、字节码工具或某些运行时机制观察类信息。

顶层类型只有两种访问级别:

public class PublicType {
}

或者:

class PackageType {
}

顶层类型不能声明为 privateprotected。因为顶层类型没有外层类作为访问范围,privateprotected 在这里没有相应的语言语义。


2.3 public:语言层面允许更广泛的访问

public 表示该声明在 Java 语言访问控制上不受包范围限制,但它仍然要满足其他条件:

package com.example.account;

public class Account {
    public String owner() {
        return "Alice";
    }
}

一个 public 类型中的 public 方法只有在以下条件都满足时,其他代码才能正常使用:

  1. 声明它的类型本身可访问;
  2. 调用方能够解析到该类型;
  3. 如果跨模块,调用方读取被调用方模块;
  4. 该类型所在的包对调用方模块导出;
  5. 调用表达式满足普通 Java 语言规则。

因此下面的类型并不能真正形成公开 API:

package com.example.internal;

public class InternalParser {
    public static String parse(String input) {
        return input.trim();
    }
}

如果 com.example.internal 没有被模块导出,那么它虽然在语言层面是 public,却不能被其他模块正常编译依赖。


2.4 protected:包内访问,或受限的子类访问

protected 有两个独立入口:

  1. 同一个包内的代码可以访问;
  2. 不同包中的子类可以访问,但受到额外限制。
package com.example.base;

public class Base {
    protected int value = 10;
}

同包代码可以直接访问:

package com.example.base;

public class SamePackage {
    public int read(Base base) {
        return base.value;
    }
}

不同包的子类可以通过自身继承关系访问:

package com.example.derived;

import com.example.base.Base;

public class Child extends Base {
    public int readOwnValue() {
        return value;       // 合法
    }

    public int readThisValue() {
        return this.value;  // 合法
    }
}

但“是子类”不意味着可以通过任意 Base 对象访问:

package com.example.derived;

import com.example.base.Base;

public class Child extends Base {
    public int read(Base base) {
        // 编译错误:
        // value has protected access in Base
        // return base.value;
        return this.value;
    }
}

跨包子类访问实例 protected 成员时,访问必须通过当前子类类型或其子类型的对象路径,而不能通过一个仅声明为基类类型的任意对象路径完成。

可以用下面的对照理解:

public class Child extends Base {
    public int readChild(Child child) {
        return child.value;     // 合法
    }

    public int readBase(Base base) {
        // return base.value;   // 不合法
        return this.value;      // 合法
    }
}

protected 因而不是“对所有子类完全公开”,也不是“只有子类可见”。同包非子类也可以访问,这正是 protected 最容易被误解的地方。


3. 访问控制的完整判断

可以把一个普通 Java 访问粗略表达为:

允许访问=LM\text{允许访问} = L \land M

其中:

  • LL 表示 Java 语言层面的访问条件成立;
  • MM 表示模块系统条件成立。

3.1 语言条件 LL

对于成员访问:

  • private:访问位置满足私有成员规则;
  • 无修饰符:访问者与声明者属于同一个包;
  • protected:访问者在同包,或者是满足限制的子类访问;
  • public:成员本身公开,并且声明它的类型也可访问。

对于顶层类型:

  • public:语言层面可被其他包引用;
  • 无修饰符:只能被同包代码引用。

例如:

package com.example.api;

class Hidden {
    public static void run() {
    }
}

即使 runpublic,下面的访问仍然失败:

package com.example.client;

import com.example.api.Hidden;

public class Client {
    public void call() {
        // 编译错误:Hidden 本身不是 public
        // Hidden.run();
    }
}

访问成员时,不能只看成员修饰符,还必须先检查声明成员的类型是否可访问。

3.2 模块条件 MM

当访问跨越命名模块时,至少要检查:

  • 访问方模块是否读取目标模块;
  • 目标包是否导出给访问方;
  • 目标类型和成员是否满足 Java 语言访问控制。

因此跨模块访问一个公开类型的典型条件是:

可访问=readsexportspublic\text{可访问} = \text{reads} \land \text{exports} \land \text{public}

其中:

  • reads 来自 requires
  • exports 来自目标模块的 module-info.java
  • public 来自 Java 类型或成员声明。

同一个模块内部不需要通过 exports 访问自己的包;exports 的作用是向其他模块提供包的普通编译和运行时 API。


4. 包不是目录,也不是模块

4.1 包名与目录结构

下面的文件声明属于包 com.example.account

package com.example.account;

public class Account {
}

通常文件路径写成:

com/example/account/Account.java

目录结构是编译器和构建工具常用的组织方式,但真正决定 Java 包归属的是 package 声明和编译上下文,不是目录名字本身。

同一个包中的类型可以使用包私有成员:

package com.example.account;

class AccountRules {
    static boolean valid(String value) {
        return value != null;
    }
}
package com.example.account;

public class AccountService {
    public boolean accepts(String value) {
        return AccountRules.valid(value);
    }
}

com.example.accountcom.example.billing 是不同包,即使它们的前缀相同,也不会因此获得访问权限。包名中的点号没有继承关系:

com.example
com.example.account
com.example.account.internal

这三个包彼此不是父子包。com.example.account.internal 不会自动获得 com.example.account 的包私有访问权。

4.2 包与模块的关系

模块由 module-info.java 描述,模块可以包含多个包:

com.example.account module
├── com.example.account
├── com.example.account.internal
└── com.example.account.persistence

包解决的是:

  • 类型命名空间;
  • 包私有访问;
  • 代码组织。

模块解决的是:

  • 模块间依赖;
  • 哪些包作为外部 API 导出;
  • 反射是否可以深入访问;
  • 模块图和封装边界。

一个模块可以包含不导出的内部包:

module com.example.account {
    exports com.example.account;
}

这表示:

  • com.example.account 对其他可读取该模块的模块导出;
  • com.example.account.internal 如果存在但没有导出,就是模块内部实现;
  • 同模块内的代码仍然可以使用内部包;
  • 其他模块不能正常编译引用内部包。

包私有和模块封装是两种不同层次的限制:

同一包       :可以使用包私有声明
同一模块不同包:只能使用符合语言规则的公开成员
跨模块       :还需要 reads 和 exports

5. JPMS:requiresexportsopens

5.1 requires:建立模块读取关系

假设有两个模块:

src/
├── com.example.api/
│   ├── module-info.java
│   └── com/example/api/Api.java
└── com.example.app/
    ├── module-info.java
    └── com/example/app/Main.java

com.example.api/module-info.java

module com.example.api {
    exports com.example.api;
}

Api.java

package com.example.api;

public class Api {
    public static String message() {
        return "hello";
    }
}

com.example.app/module-info.java

module com.example.app {
    requires com.example.api;
}

Main.java

package com.example.app;

import com.example.api.Api;

public class Main {
    public static void main(String[] args) {
        System.out.println(Api.message());
    }
}

使用 Java 25 编译和运行:

javac -d out \
  --module-source-path src \
  -m com.example.api,com.example.app

java \
  --module-path out \
  -m com.example.app/com.example.app.Main

预期输出:

hello

这里有两条必要关系:

com.example.app --requires--> com.example.api
com.example.api --exports--> com.example.api

如果删除 requires,编译器无法把 com.example.api 作为 com.example.app 的可读模块;如果删除 exports,即使模块被读取,com.example.app 也不能把 com.example.api.Api 作为普通外部 API 使用。

典型错误会类似于:

package com.example.api is not visible
(package com.example.api is declared in module com.example.api,
but module com.example.app does not read it)

或:

package com.example.api is not visible
(package com.example.api is declared in module com.example.api,
which does not export it to module com.example.app)

具体错误文本可能随编译器上下文略有差异,但失败原因分别对应“没有读取”和“没有导出”。

5.2 exports:导出普通 API

可以只导出特定包:

module com.example.api {
    exports com.example.api;
}

也可以限定导出对象:

module com.example.api {
    exports com.example.api to com.example.app;
}

限定导出称为限定导出。只有指定模块能够把该包作为普通 Java API 使用。

exports com.example.api to com.example.app;

并不表示:

  • 该包中的所有成员都变成 public
  • 其他模块可以访问 private 成员;
  • 反射可以任意修改私有字段;
  • 未被指定的模块自动获得访问权限。

exports 只是在模块边界上允许其他模块进行普通的、受 Java 语言访问控制约束的访问。

5.3 requires transitive:把依赖暴露给 API 使用者

普通依赖:

module com.example.service {
    requires com.example.api;
}

表示 com.example.service 自己需要 com.example.api,但使用 com.example.service 的模块不会因为这个声明自动读取 com.example.api

传递依赖:

module com.example.service {
    requires transitive com.example.api;
}

表示读取 com.example.service 的模块也能通过模块图读取 com.example.api

这通常适用于公共 API 暴露了依赖模块中的类型。例如:

package com.example.service;

import com.example.api.Account;

public class AccountService {
    public Account find() {
        return null;
    }
}

如果 Account 出现在 AccountService 的公开方法签名中,那么 com.example.api 已经成为 com.example.service 的 API 依赖。此时使用 requires transitive 可能是合理的,因为 API 使用者需要解析 Account

但如果依赖类型只出现在实现内部:

package com.example.service;

public class AccountService {
    public void refresh() {
        // 内部使用 com.example.api 的实现
    }
}

通常不应仅为了方便而使用 requires transitive。否则模块会把本来属于内部实现的依赖扩散给自己的使用者。


6. exportsopens 解决不同问题

6.1 opens 允许深度反射

module com.example.account {
    exports com.example.account;
    opens com.example.account;
}

opens 主要服务于运行时反射框架,例如对象序列化、依赖注入、ORM 映射等。它允许反射代码在满足运行时权限条件时深入访问非公开成员。

也可以限定开放给某个模块:

module com.example.account {
    opens com.example.account.persistence
        to com.example.orm;
}

6.2 opens 不等于 exports

module com.example.account {
    opens com.example.account.internal;
}

这不会让其他模块可以在 Java 源代码中正常写:

import com.example.account.internal.InternalEntity;

因为 opens 不提供普通编译访问。反过来:

module com.example.account {
    exports com.example.account;
}

也不会让框架能够访问:

private String passwordHash;

因此:

声明 普通编译访问 public 类型 深度反射访问非公开成员
无声明
exports p 不保证
opens p
exports p; opens p;

6.3 反射失败的表现

如果模块没有开放某个包,反射框架尝试执行深度访问时可能出现:

java.lang.reflect.InaccessibleObjectException

例如:

field.setAccessible(true);

在强封装模块环境下可能失败。运维上可以使用:

java \
  --add-opens com.example.account/com.example.account.persistence=com.example.orm \
  ...

--add-opens 是运行时启动参数,不是源代码中的模块 API 设计。它适合迁移旧框架、诊断或临时兼容;长期使用会降低模块边界的实际强度。

如果问题是普通 public 类型无法被另一个模块访问,通常应该检查 exportsrequires,而不是使用 --add-opens--add-exports--add-opens 也不能混用:

  • --add-exports:补充普通导出,主要解决公开 API 的模块可见性;
  • --add-opens:补充深度反射开放,主要解决非公开成员反射。

7. 模块路径、类路径与特殊模块

7.1 命名模块

包含 module-info.java 的模块是命名模块。它有明确的:

  • 模块名;
  • requires
  • exports
  • opens
  • usesprovides 服务声明。

命名模块的依赖图由模块解析器建立。命名模块不能通过随意访问类路径上的类型来绕过其声明式依赖。

7.2 未命名模块

传统类路径上的类通常属于未命名模块。未命名模块没有显式的 module-info.java,其包不会按照命名模块的方式声明导出和开放。

这会造成一个常见错觉:代码放在类路径上时可以正常访问,改放到模块路径后却失败。原因不是 public 语义改变了,而是模块边界开始参与访问判断。

应用迁移到模块化时,需要重新检查:

类路径上的可见性
≠
模块路径上的可见性

7.3 自动模块

没有 module-info.java、但作为模块路径上的 JAR 使用时,可能形成自动模块。自动模块有模块名推导规则,并具有比显式命名模块宽松的读取和导出行为。

自动模块适合迁移阶段,但不应把它当作最终封装设计。依赖关系、导出范围和模块名都不如显式模块描述精确。


8. 封装不是“把字段改成 private”这么简单

封装是把状态表示和状态变化规则限制在类型内部,并通过受控 API 暴露行为。

下面的设计允许调用方直接破坏不变量:

public class BankAccount {
    public long balance;
}

调用方可以写:

account.balance = -100;

改为:

public final class BankAccount {
    private long balance;

    public long balance() {
        return balance;
    }

    public void deposit(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
        balance += amount;
    }

    public void withdraw(long amount) {
        if (amount <= 0 || amount > balance) {
            throw new IllegalArgumentException("invalid withdrawal");
        }
        balance -= amount;
    }
}

此时合法状态由操作前置条件决定:

deposit:amount>0withdraw:0<amountbalance\begin{aligned} &\text{deposit}: amount > 0 \\ &\text{withdraw}: 0 < amount \le balance \end{aligned}

private 字段只解决“谁能直接改字段”;方法中的校验、并发控制和事务边界才决定封装是否真正维护了业务不变量。

例如多线程下,即使字段是 private,下面的操作仍可能有竞态:

public void withdraw(long amount) {
    if (amount <= balance) {
        balance -= amount;
    }
}

如果两个线程同时读取相同余额,检查和扣减不是一个原子操作。封装没有自动提供线程安全性。可以使用锁或其他并发原语:

public synchronized void withdraw(long amount) {
    if (amount <= 0 || amount > balance) {
        throw new IllegalArgumentException("invalid withdrawal");
    }
    balance -= amount;
}

这里应区分三个问题:

  • private:谁能访问字段;
  • 不变量:哪些状态合法;
  • 并发安全:多个线程同时操作时状态是否仍然合法。

它们相关,但不是同一个概念。


9. 依赖方向:访问关系不等于架构方向

依赖方向描述的是一个组件是否需要另一个组件的类型、行为或实现。

可以把依赖表示为有向图:

ABA \rightarrow B

表示 A 依赖 B。例如:

com.example.app
        |
        v
com.example.service
        |
        v
com.example.repository

在模块系统中,这通常对应:

module com.example.app {
    requires com.example.service;
}
module com.example.service {
    requires com.example.repository;
}

依赖方向至少涉及三种不同关系:

  1. 名称依赖:代码中引用了另一个包或模块的类型;
  2. 编译依赖:编译时需要目标模块的 API;
  3. 运行时依赖:运行时需要目标模块、类或服务实现。

访问权限只能回答“某个引用是否被允许”,不能自动保证依赖方向合理。

9.1 public 可能扩大依赖面

package com.example.service;

import com.example.repository.Repository;

public class Service {
    public Repository repository() {
        return null;
    }
}

Repository 出现在公开方法返回值中,于是调用方也必须理解 Repository。即使模块图没有使用 requires transitive,这个公开签名也已经形成了 API 层面的耦合。

更稳定的边界通常是返回服务层自己的类型:

package com.example.service;

public record AccountView(String id, String owner) {
}
public class Service {
    public AccountView find(String id) {
        return new AccountView(id, "Alice");
    }
}

此时仓储实现可以变化,而调用方不需要依赖持久化层类型。

9.2 包私有也会形成隐含耦合

同一个包中的多个类型可以互相使用包私有成员:

com.example.order
├── OrderService.java
├── OrderValidator.java
└── OrderRepository.java

这有利于隐藏包外实现,但包内类型之间可能形成紧密耦合。将所有实现类放入一个超大包,并不会自动得到良好的架构;它只是把边界从“模块外部”缩小到“包外部”。

因此依赖方向需要结合包结构设计:

com.example.order.api
        ^
        |
com.example.order.internal

如果 API 包依赖 internal 包中的实现类型,方向就发生了反转。更稳定的结构通常是:

com.example.order.internal
        |
        v
com.example.order.api

或者让内部实现依赖一个更小的端口接口:

application -> port <- infrastructure

模块的 exports 可以强化这种边界,但不能替代类型设计。一个导出了内部类型的模块,仍然可能形成不必要的外部耦合。

9.3 模块图中的环

如果:

module A {
    requires B;
}

同时:

module B {
    requires A;
}

就形成:

A -> B -> A

命名模块的解析要求依赖图满足模块系统的约束;循环依赖通常导致模块解析失败,而不是等到某个方法调用时才失败。

循环依赖的修复不能靠把成员改成 public。常见的结构性修复是:

  • 抽取较小的 API 模块;
  • 将共同抽象放入第三个模块;
  • 使用服务接口与 uses / provides 解耦实现;
  • 将实现依赖反转为接口依赖。

模块声明表达的是组件级依赖方向;访问修饰符表达的是类型和成员级可见性。二者必须同时设计。


10. 访问控制的失败阶段

同一个问题可能在不同阶段表现为不同错误。

10.1 编译期:源代码不可见

例如目标包没有导出:

package com.example.internal is not visible

或者类型本身不是 public

X is not public in com.example.internal;
cannot be accessed from outside package

此时程序尚未生成可运行的合法引用。优先检查:

package 声明
类型和成员修饰符
module-info.java
requires
exports
编译使用的 --module-source-path 或 --module-path

10.2 链接或运行期:解析到类型但访问检查失败

如果编译产物与运行时模块路径不一致,可能出现:

  • ModuleResolutionException
  • FindException
  • NoClassDefFoundError
  • IllegalAccessError

例如编译时使用了某个版本的模块,运行时却加载了不兼容的模块版本。此时不能只检查源代码,还要检查实际加载的模块:

java --show-module-resolution \
  --module-path out \
  -m com.example.app/com.example.app.Main

该选项可以帮助观察模块解析结果和依赖关系。

10.3 反射期:深度访问未开放

如果普通代码可访问一个 public 类型,但框架反射访问其私有字段失败,典型错误是:

InaccessibleObjectException

诊断时应先判断框架真正需要什么:

  • 只调用公开构造器和方法:检查 exports
  • 需要访问私有字段或私有构造器:检查 opens
  • 依赖 JDK 内部非公开 API:还可能涉及强封装和迁移问题。

不要用 --add-opens 解决所有模块错误,因为它只能解决深度反射问题,不能替代普通模块依赖或 API 导出。


11. 一个完整的边界判断示例

假设有类型:

模块:com.example.account
包:com.example.account.internal
类型:public class TokenStore
成员:private String token

从另一个模块 com.example.app 访问时,逐步判断:

情况一:包未导出

module com.example.account {
}

结果:

com.example.app 不能把 TokenStore 作为普通 Java 类型导入

即使 TokenStorepublic,也失败。

情况二:包导出,但成员是 private

module com.example.account {
    exports com.example.account.internal;
}

下面的代码仍然失败:

store.token

因为 exports 只解决模块包边界,不改变 private 的语言规则。

情况三:包开放但未导出

module com.example.account {
    opens com.example.account.internal;
}

普通源码导入仍然失败,但满足条件的反射代码可能访问 token

情况四:包限定导出给其他模块

module com.example.account {
    exports com.example.account.internal to com.example.app;
}

只有 com.example.app 能够普通访问其中符合 Java 语言规则的公开类型和成员。另一个模块 com.example.test 仍然不能依赖该包的公开 API。

这四种情况说明,访问权限是逐层收敛的:

public
  ↓
包被导出
  ↓
访问方读取模块
  ↓
成员满足 Java 语言规则
  ↓
如涉及反射,再检查 opens

其中任意一层不满足,都可能导致访问失败。


12. 常见误解与边界

12.1 “public 就等于任何地方都能用”

错误。public 只解决语言层面的声明访问。跨命名模块时还要满足 requiresexports

12.2 “同名包就是同一个包”

不能这样假设。包还处在编译环境、模块和类加载环境中。JPMS 也限制命名模块之间的拆分包。一个包名的字符串相同,不代表不同模块中的代码可以共享包私有权限。

12.3 “子包可以访问父包的包私有成员”

错误。包之间没有父子访问关系:

com.example.order
com.example.order.internal

是两个独立包。

12.4 “protected 只对继承者开放”

不完整。同包中的非子类也能访问 protected 成员;跨包时才需要满足子类访问规则。

12.5 “opens 等于 exports

错误。exports 面向普通 Java API 访问,opens 面向深度反射。一个不能替代另一个。

12.6 “改成反射就能绕过所有权限”

错误。模块系统会参与反射访问检查。没有适当的开放声明或启动参数,深度反射可能失败;即使反射成功,也不代表这样的访问适合作为稳定 API。


13. 设计时如何让依赖方向可见

可以从外到内逐层设置边界:

模块:限制组件之间的依赖
  ↓
包:区分公开 API 和内部实现
  ↓
类型:限制可被创建和继承的对象
  ↓
成员:限制状态和操作入口

一个典型模块声明可以是:

module com.example.order {
    exports com.example.order.api;
    requires com.example.payment.api;
}

目录结构:

com.example.order
├── module-info.java
├── com/example/order/api
│   └── OrderService.java
└── com/example/order/internal
    ├── OrderValidator.java
    └── DefaultOrderService.java

这里的意图是:

  • com.example.order.api 是外部调用入口;
  • com.example.order.internal 是模块实现;
  • 外部模块不能直接依赖内部包;
  • 模块只声明自己真正需要的支付 API;
  • 包私有成员继续用于实现类之间的内部协作。

如果框架需要实例化内部实现,再针对特定框架模块开放必要包:

module com.example.order {
    exports com.example.order.api;
    opens com.example.order.internal to com.example.di;
    requires com.example.payment.api;
    requires com.example.di;
}

这种声明比整个模块无条件 open 更精确,因为它保留了普通 API 和反射入口之间的区别。


14. 最终判断模型

遇到“为什么这个类型或成员不能访问”时,可以按以下顺序推导,而不是直接尝试增加修饰符:

  1. 它是什么?
    顶层类型、成员、嵌套类型,还是反射目标?

  2. 访问发生在哪里?
    同一个类、同一个顶层类、同一个包、子类、不同包,还是不同模块?

  3. 语言规则是否满足?
    检查 private、包私有、protectedpublic,以及声明类型自身的可访问性。

  4. 是否跨模块?
    如果跨模块,访问方是否 requires 目标模块?

  5. 目标包是否导出?
    普通源码访问需要 exports;限定导出还要检查目标模块是否在允许列表中。

  6. 是否是深度反射?
    如果访问私有成员,检查 opens、限定开放或 --add-opens

  7. 依赖方向是否合理?
    如果为了解决访问问题不断扩大 publicexportsrequires transitive,可能是在掩盖包结构或模块职责设计问题。

Java 25 中,访问修饰符、包和模块分别承担不同层次的边界职责。private 控制类型内部实现,包控制局部协作范围,模块控制组件之间的可见 API,opens 控制特殊的运行时反射入口。只有把这些规则和依赖方向一起设计,封装才不只是语法上的隐藏,而会成为可以被编译器、模块解析器和运行时共同验证的结构约束。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。