qinghuan
← Java SE / 全部笔记

单元 18 / Java SE

P0 · 必会主线L1 · 基础

包、访问权限与 static/final:组织代码的边界

先把类放进包并正确运行,再分清访问权限、属于类的 static 成员和限制重新赋值的 final。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能组织多包源码、设置输出根目录,并避免把请求状态存入共享字段。

学习理由、准备要求与知识点

为什么学它

包名、访问权限与共享成员决定项目组织和对象状态边界。

先读这些内容

知识点清单

  • 完整类名与包目录
  • import 与 classpath
  • 四类访问权限
  • protected 跨包约束
  • static 共享成员
  • 实例方法与 this
  • final 的不同作用
  • 引用固定与对象可变

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

包用来区分和组织类名。例如 package learn.notes; 中的公开类 Demo,完整名字是 learn.notes.Demo。把文件放在 src/learn/notes/Demo.java 后,可用 javac -d out src/learn/notes/Demo.java 编译,再用 java -cp out learn.notes.Demo 运行。

类路径要指向输出根目录 out。import 只是让源码中能用较短名字,不会复制类,也不会把整个包一次性加载进来。

访问权限说明哪些代码能直接使用某个成员。public 允许能访问该类型的调用者使用;成员不写访问修饰符时通常只允许同包访问;private 保护类型内部实现;protected 还提供与继承有关的访问。跨包子类使用 protected 时,对访问位置和所操作对象的类型仍有限制,不能理解为子类能随意访问任何父类对象的受保护成员。

static 成员属于对应已加载类的状态或行为,实例成员则关联具体对象。静态方法没有 this,不能直接读取某个未指定实例的字段。final 变量限制重新赋值,final 方法禁止重写,final 类禁止继承;三者针对不同边界。final List 仍可以指向可变列表,static final 也不自动意味着深度不可变或适合保存当前请求数据。

文中这几个词,先认一认

完整类型名
把包名和类名连起来的名字,例如 learn.notes.Demo,用来避免同名类混淆。
包访问
成员不写访问修饰符时通常采用的规则:同一个包里的代码可以访问。
静态成员
与已加载的类关联的成员,不是每创建一个对象就单独拥有一份。
编译期常量
例如 final int DAYS=7:变量是基本类型、用 final 固定,并且初始值由编译期可确定的常量表达式给出。基本类型或 String 的 final 变量还得满足这个初始化条件;只写 final、但值要运行时调用方法取得,就不算。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

源代码的包声明决定类型命名,目录对应包结构是常见工程约定;编译器的 -d 输出参数会依据包名组织产物目录。运行时依据类路径根与完整名寻找类,所以从包目录内部随意启动容易把根位置弄错。排错时列出完整名、输出根和实际 class 路径,三者应该能够互相推导。

访问权限服务于维护边界,而不是替代身份认证。把方法改成 private 可以减少普通代码直接调用,却不能说明外部网络请求已经授权。模块系统还可能对跨模块访问增加边界,因此 Java17 中 public 并不是绕过一切封装的通行证。反射篇会讨论合法访问,不采用强行开启所有模块来掩盖设计问题。

static 字段由同一个已加载类的多个实例共享,但不能一概说“整个进程只有一份”。应用、插件或隔离测试可能使用不同类加载器,分别加载出同名类,各自保存一份静态状态。另外,有些常量会在编译时直接写进调用方的代码。因此改了库里的常量后,如果只替换库文件,旧调用方未必看到新值。需要运行时调整的配置,应和这类常量分开设计。

正在加载流程图…

包名、输出位置与类路径根互相对应;命名成功不等于访问许可已经满足。
查看流程图源码(Mermaid)
flowchart TD
 A["package learn.notes"] --> B["完整名 learn.notes.Demo"]
 B --> C["javac -d out 组织产物"]
 C --> D["out/learn/notes/Demo.class"]
 D --> E["classpath 指向 out"]
 E --> F["java 以完整类名启动"]
 B --> G["访问控制再决定成员可用性"]

按顺序看,每一步做了什么

  1. 写明包声明与完整类型名。
  2. 让源码目录与包结构保持一致。
  3. 从输出根配置 classpath。
  4. 按最小需要选择成员可见性。
  5. 区分静态共享、引用 final 与不可变值。

03对照实现代码

点分名称校验为何保留空段 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.compiler/javax/lang/model/SourceVersion.java · javax.lang.model.SourceVersion#isName(CharSequence,SourceVersion)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import javax.lang.model.SourceVersion;
public final class SourceModel {
    private SourceModel() {} // 工具类无需实例状态。
    public static boolean packageShape(String name) {
        if (name == null) return false;
        // 负 limit 保留末尾空段,才能拒绝以点结尾的名字。
        for (String part : name.split("\\.", -1)) {
            if (!SourceVersion.isIdentifier(part)
                    || SourceVersion.isKeyword(part, SourceVersion.RELEASE_17))
                return false;
        }
        return true;
    }
}

真实 SourceVersion.isName 将点分名字拆为各段,对每段检查标识符规则与指定源版本的关键字限制。模型固定 RELEASE_17 并把 null 定义成 false,避免把最新运行时语言规则误当成文章目标版本。它只模拟普通名称形状校验,不证明这个包或类型实际存在。

split 的负 limit 保留尾部空段,因此 com.demo. 不会被当成 com.demo。中间连续两个点、开头点和空字符串也会形成非法段。看似小小的参数决定边界输入是否被完整检查,源码阅读时不应只注意主循环而忽略库调用的具体重载与参数。

Java 标识符不是只有 ASCII 字母、数字和下划线,真实字符规则包含 Unicode,并对起始位置与后续位置作不同限制。模型调用公开 SourceVersion 方法保持这项语义,而不自制一个过窄正则。业务命名约定可以主动收窄为小写英文,但应承认那是工程规则而非语言全部能力。

名字本身符合格式,不代表放进任何 Java 位置都合法;受限标识符、模块、类型解析和访问控制还需要编译上下文。静态检查工具要说明只查了哪一层,不能把一个返回真假值的名称检查当成 javac。这个例子也展示了工具类用静态方法和私有构造器的常见写法。

代码先调用谁,再调用谁

  1. isName 获取字符串表示
  2. 按点拆分并保留空段
  3. 逐段调用 isIdentifier 与版本化 isKeyword
  4. 返回词法名称结果,后续解析仍由编译上下文完成

04动手写一个例子

求多个包名的最长公共包前缀

给定一组 Java17 点分名称,返回按完整段比较的最长公共前缀。每项先做 SourceVersion 名称校验;空输入返回空字符串,非法名称拒绝。本题只处理命名结构,不解析真实项目。

输入 1
com.demo.api;com.demo.service
输出 1
com.demo
输入 2
com.a;company.a
输出 2
空字符串
输入 3
[]
输出 3
空字符串

直接比较字符前缀会把 com.a 与 company.a 的相似字符误当成共同包,因此应以点分段为单位。先保存第一个名字的段数组,令共同段数等于其长度;以后每个名字只与仍然共同的前缀比较,一旦某一段不同就缩短共同段数。

共同段数只减不增,表示已经检查的全部名字共享哪些完整段。即使它降到零,也应继续验证后面的输入是否合法,因为接口承诺拒绝任意非法名称;为了提前返回空结果而跳过校验,会使错误处理依赖输入顺序。

最终用点连接前共同段,空输入与完全无公共段都返回空字符串。本题不把空字符串当成可输入的默认包名,因为默认包与普通点分包名的工程用途不同。需要支持默认包时应显式扩展契约,而不是让空输入、空名称和无共同前缀三种情况混在一起。

为什么这样写是对的处理完每个名字后,common 是已处理全部名字共同拥有的最长完整前缀段数。

时间 O(L),L 为所有名称的字符总数。额外空间 O(L) 上界,包含拆分结果和输出。

每个名称进行词法校验与段拆分;比较只读取其前缀且不会超出该名称长度,因此累计扫描受总字符量约束。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 42 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.Arrays;
import javax.lang.model.SourceVersion;
public class Main {
    static String commonPackage(String... names) {
        if (names == null) throw new IllegalArgumentException("names");
        String[] prefix = null;
        int common = 0;
        for (String name : names) {
            // 即使公共前缀已为空,也继续验证剩余输入。
            if (name == null || !SourceVersion.isName(name, SourceVersion.RELEASE_17))
                throw new IllegalArgumentException("invalid name");
            String[] parts = name.split("\\.", -1);
            if (prefix == null) { prefix = parts; common = parts.length; }
            else {
                int i = 0;
                // 只按完整段比较,防止把相似字符当成相同包。
                while (i < common && i < parts.length
                        && prefix[i].equals(parts[i])) i++;
                common = i;
            }
        }
        return prefix == null ? "" : String.join(".", Arrays.copyOf(prefix, common));
    }
    static void check(String wanted, String... names) {
        if (!wanted.equals(commonPackage(names))) throw new AssertionError(wanted);
    }
    static void reject(String... names) {
        try { commonPackage(names); } catch (IllegalArgumentException e) { return; }
        throw new AssertionError("must reject");
    }
    public static void main(String[] args) {
        // 正常、相似字符、空输入、相同名称和空段边界。
        check("com.demo","com.demo.api","com.demo.service");
        check("","com.a","company.a");
        check("");
        check("learn.notes","learn.notes","learn.notes");
        reject("com..demo");
        reject("com.demo.");
        reject("a","b","class");
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 空输入合法,输入中的空名称非法。
  • 相同字符前缀不等于相同包段。
  • 末尾点必须保留并识别为空段。
  • 公共前缀归零后仍验证剩余名称。
进阶选读 · 可选深入:用前缀树建立包索引

场景大量类型需要按包层级导航和查找祖先。

做法把每个包段作为树边,节点保存直接类型与子包;明确大小写和模块归属,删除时按引用数量清理空节点。

复杂度构建与查询成本通常随总段数或路径深度增长,存储为节点与边数量。

好处与代价索引更快但需同步增删与版本变化,单次求公共前缀不必提前建树。

05项目里怎么用

默认先给成员较窄的可见性,再根据真实调用关系开放。public API 一旦被其它模块依赖,改名、删除或改变约定的成本都会增加。包可以帮助聚合同一职责,但不能只按照所有 controller、所有 service 的机械分类判断边界,还应考虑哪些实现细节应共同隐藏。

当前请求、用户身份和临时构建结果不适合放入随意共享的 static 可变字段。不同请求会互相覆盖,测试之间也可能污染。共享缓存或配置确实可以是静态或单例管理,但必须有明确生命周期、更新策略和并发保证;static 本身不提供这些机制。

固定协议值、部署配置和不可变对象要分开设计。真正固定的值可以做编译期常量,环境地址应由配置提供;集合即使作为常量,也要检查容器和元素是否仍可修改。final 只能限制对应的重新赋值,不能替你复制数据、同步线程或检查访问权限,评审时要看它具体保护了什么。

容易踩的坑

  • 把包目录当成 classpath 根。
  • 认为 import 会复制或加载整个包。
  • 把 final 引用当作深度不可变。
  • 将请求私有数据保存到 static 可变字段。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
包访问实现限制依赖范围且方便同包协作跨包复用受限模块内部细节
公开接口调用契约清晰且可跨边界维护兼容成本增加稳定对外能力
静态工具方法没有实例状态且调用直接依赖替换较难,不适合隐藏可变环境纯函数与稳定通用计算

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么 java -cp out/learn/notes Demo 找不到预期类?

展开参考答案与推导

包声明使二进制名字为 learn.notes.Demo,查找根应是 out,并使用完整名字启动。把根移进包目录再用短类名,会与 class 文件记录的名字不一致。

02static final List 是否一定不能 add?

展开参考答案与推导

不一定。static 描述共享成员,final 限制字段重新绑定;列表能否 add 取决于具体容器。即使容器不可修改,内部元素也可能可变,需要另外说明。

03名称合法是否证明类型可访问?

展开参考答案与推导

没有。词法名称校验不解析类路径、模块导出或成员访问权限。要知道某次引用是否合法,仍需在完整编译上下文中解析目标并检查访问控制。

换个情况,还会处理吗

01改了库中的常量,旧应用仍读到旧值,可能为什么?

展开参考答案与推导

满足编译期常量条件的值可能已经内联到调用方产物。应重新编译依赖方并核对发布版本;会变化的环境参数应作为配置读取,不能依靠替换常量库实现动态更新。

02两个请求拿到了对方用户信息,先查什么共享状态?

展开参考答案与推导

检查 static 字段、单例实例字段及共享缓存中是否保存了请求专属数据。定位写入和读取的完整链路,再调整作用域;仅给变量加 final 不会修复跨请求共享。

03只想开放给同包使用,需要 public 吗?

展开参考答案与推导

通常包访问即可满足同包协作,减少对外契约。具体仍要看模块与嵌套类型关系;先按真实调用者选择最小可见性,而不是为了省编译错误把所有成员公开。

写代码试一试

01增加前缀本身也是输入的测试

输入 / 条件
a.b;a.b.c
输出 / 预期
a.b

提示 比较不能越过较短名称。

展开参考答案与推导

加入 check("a.b","a.b","a.b.c")。公共段数应保持二,不能因为后一项更长就扩大;不变量规定它只减少或不变,不会在后续名字中重新增长。

02把工程包名限制为小写 ASCII

输入 / 条件
Com.demo
输出 / 预期
拒绝

提示 区分工程约定与语言规则。

展开参考答案与推导

在语言名称校验后增加项目约定检查,例如每段只接受小写字母开头和允许字符集合。文档应写明这是项目规范,不要宣称大写或 Unicode 包名违反 Java 语言。

03实现不可实例化常量工具类

输入 / 条件
new Names()
输出 / 预期
编译失败

提示 构造器 private。

展开参考答案与推导

声明 final 类并提供 private 构造器,把纯函数设为 static。不要因此放入可变请求数据;不可实例化只是对象创建边界,并没有自动建立线程安全或不可变性。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能组织多包源码、设置输出根目录,并避免把请求状态存入共享字段。

本篇重点加练

重点练类加载入口与 final 的具体限制,减少仅凭关键字推断运行行为。

  • import 与 classpath
  • final 的不同作用

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01排错实验

    在练习目录建立两个包,各含一个同名 Note 类,分别从终端和 IDE 编译运行;记录完整类名、源文件目录、输出目录和 classpath 的对应关系。

    提交内容
    目录树、源代码、自写调用检查及命令记录。
    怎样算完成
    • 包名与磁盘路径职责区分
    • 同名类引用可明确解析

    练到的知识点完整类名与包目录 · import 与 classpath

  2. 作业 02代码推演

    设计 p.base.Parent、同包 Peer 与跨包 Child,在每类中尝试访问四种权限成员;对 protected 用不同静态类型的接收者分别编译验证。

    提交内容
    独立编译用例和权限矩阵。
    怎样算完成
    • 测试包含跨包继承接收者条件
    • 失败样例不与其他错误混杂

    练到的知识点四类访问权限 · protected 跨包约束

  3. 作业 03代码推演

    分别对 final 局部整数、final 对象引用和 final 方法设计一项允许操作及一项受限制操作;每个失败样例单独编译。

    提交内容
    关键字作用对象矩阵与编译证据。
    怎样算完成
    • 限制指向具体语法对象
    • 不由一个样例概括全部 final

    练到的知识点final 的不同作用

综合应用

  1. 作业 04原理问答

    一个计数器类同时有实例计数和 static 总计数,创建两个对象并改变各自状态;画出成员归属并说明 this 在实例方法和 static 上下文中的使用边界。

    提交内容
    对象图、代码样例和推演记录。
    怎样算完成
    • 类级与实例级状态分开
    • 调用形式不替代实际归属

    练到的知识点static 共享成员 · 实例方法与 this

  2. 作业 05编程实践

    实现配置对象,final 字段保存一个可变数组;先写出是否允许调用方修改内容的合同,再实现构造与读取方法,并测试输入数组和返回值被修改后的行为。

    提交内容
    Java 实现、自写别名/空输入/边界测试和运行证据。
    怎样算完成
    • final 的限制描述准确
    • 所选隔离合同得到验证

    练到的知识点final 的不同作用 · 引用固定与对象可变

  3. 作业 06面试场景

    测试类单独运行成功,整套测试运行计数却累积;同时 IDE 能启动而终端找不到主类。把两类故障分别定位到需要收集的证据,避免混合修改。

    提交内容
    两条排查树及最小复现草案。
    怎样算完成
    • 共享状态与类路径问题分开
    • 每步检查有明确目标

    练到的知识点完整类名与包目录 · import 与 classpath · static 共享成员

  4. 作业 07排错实验

    创建同包的 final 类与调用类,从终端指定输出目录和 classpath 运行;另在独立文件尝试继承该类,分别记录编译与运行阶段证据。

    提交内容
    目录结构、自写调用检查、编译失败及成功运行记录。
    怎样算完成
    • 类路径与继承约束分开验证
    • 文件副本不会混用

    练到的知识点import 与 classpath · final 的不同作用

  5. 作业 08设计评审

    工具库打成 jar 后,消费者写了 import 却未加入 classpath。列出 import 与类路径分别需要承担的责任,设计一个最小验证流程。

    提交内容
    依赖查找图与编译运行检查。
    怎样算完成
    • 源码名称解析和类可用性区分
    • 验证不依赖 IDE 隐式配置

    练到的知识点import 与 classpath

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成接口设计后:为可扩展的解析器库划定公开 API、子类扩展点与内部实现,说明哪些类或方法可以限制继承,制定兼容性测试。

    提交内容
    访问级别表、扩展样例与变更评审规则。
    怎样算完成
    • 权限依据使用方需要选择
    • 不以全部 public 代替设计

    练到的知识点四类访问权限 · protected 跨包约束 · final 的不同作用

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    审查“类声明为 final 后就能作为完全安全的共享库对象”的说法。限定在已学字段与引用知识内提出检查样本,列出该声明不能单独证明的合同。

    提交内容
    对象模型和合同证据表。
    怎样算完成
    • 推理依据具体成员行为
    • 编译限制与运行保证分别表述

    练到的知识点import 与 classpath · final 的不同作用

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗