ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

吃透Java权限修饰符:从public到private的可访问性边界

2026/9/10 19:41:31 拓冰建站 浏览量
吃透Java权限修饰符:从public到private的可访问性边界 写这篇笔记的起因是前阵子帮一个朋友排查线上问题翻到一段把业务字段全部设成public的代码——那个类的字段就这么裸奔在系统里谁都能改改完还不一定知道是哪个环节动的手。于是聊到权限修饰符他说“public/private谁不会啊不就是访问范围大小吗”这话对但也不全对。很多时候我们在 IDE 里点两下就能补全修饰符但真要讲清楚“为什么这里要用 protected 而不是 public”“为什么这个字段死活不该给外面改”很多人就开始含糊了。尤其在 Java 面试里权限修饰符几乎是被当成“八股文”来背的但背归背实际写代码时该用错的照样用错。这篇笔记就把我对这几个修饰符的理解重新梳理一遍从基础语义到跨包访问的坑、从重写约束到反射对抗尽量讲透而不是背熟。1. 权限修饰符的职责边界决定“谁能看见”而不是“谁来执行”先把最基础的东西砸实。Java 里一共有四种访问控制级别从宽到窄依次是public、protected、默认也叫包私有不写修饰符、private。很多人脑子里只有“public 最大private 最小”这个印象但四个层级在不同的场景下有完全不同的行为尤其protected和默认修饰符是面试里最容易混淆的一组。1.1 四个修饰符的访问矩阵从一张表讲清楚我先把访问矩阵列出来这张表建议存下来后面所有讨论都围绕它展开。修饰符同类同包类异包子类异包非子类public可以可以可以可以protected可以可以可以特殊规则后面详谈不可以默认不写可以可以不可以不可以private可以不可以不可以不可以注意几个细节。第一protected和默认修饰符的区别在“异包子类”这一列。也就是说一个类如果在不同包下继承了父类那么在子类的代码里是可以访问父类protected成员的但默认修饰符不行。这个差别直接决定了框架设计里“模板方法”模式为什么爱用 protected。第二同包下protected和默认修饰符看起来效果一样——都能访问。所以很多人就误以为 protected 比默认修饰符“只多了一个异包子类的权限”。方向是对的但具体的“异包子类访问规则”有个大坑这在后面第三章单独讲。第三很多人忽略的一点是表格里的“可以访问”指的是代码所在的位置。比如“同类”那一列指的是在这个类内部写代码访问自己的成员“异包非子类”那列指的是在另一个包里跟这个类没有任何继承关系的代码。把这层语义理顺了后面分析问题才有基础。权限修饰符管的是“这段代码能不能看见那个成员”而不是“那个成员的内存有没有被分配”——成员一直都在只是编译器让不让你碰的问题。1.2 为什么命名为“修饰符”可见性的本质是编译期契约Java 里管public、private这类关键字叫修饰符modifier这个“修饰”不是装饰的意思而是“限定、约束”。它约束的不是运行时的行为而是编译期的“可见性契约”。这个概念很关键因为很多人理解成“private 成员在运行时被锁住了别人碰不到”。实际上不是这样。Java 的访问控制在绝大多数情况下是编译器检查javac在编译时扫描到某处代码访问了无权访问的成员直接报编译错误根本不会等到运行时。哪怕你想用歪门邪道绕过检查比如用反射private也能被强行打开——后文第 5 章会专门聊这个。你可以把权限修饰符类比成公司的门禁卡。public是访客都可以进的公共大厅private是只有本人刷卡才能进的工位区。但门禁卡只决定“你进不进得来”不决定“你进来之后能不能偷看别人屏幕”。换句话说修饰符管的是“可见边界”不是“数据加密”。过了编译这一关之后类加载阶段 JVM 的校验器verifier还会做一次字节码层面的访问检查防止有人手工修改 class 文件的访问标记。这个是第二道防线但一般开发者感知不到。绝大多数情况下权限修饰符的拦阻都发生在编译期这就是为什么 IDE 里写错修饰符时不是运行报错而是红波浪线立刻标出来。2. 封装为什么不是限制public/private 背后是一份 API 契约很多刚接触 Java 的人会有一个疑问既然public给得最多那是不是把所有东西都设成public最方便反正都能访问。确实从“能不能跑”的角度来说全 public 的程序完全能跑甚至跑得很欢。但从工程维护的角度来说这是灾难的开始。权限修饰符的本质不是“限制你”而是“告诉别人哪些是承诺哪些是细节”。2.1 从“全局变量随手可见”到“最小暴露原则”假设你写了一个缓存工具类内部用一个HashMap存数据。如果字段直接 public外部代码就能执行cache.map.clear()把你的缓存清空甚至往里塞一些你意料之外的数据。这时候你的类就完全失控了。但如果你把字段设为private只对外暴露两个方法get(String key)和put(String key, Object value)那外部代码能做的就只有你允许做的事。这就是最小暴露原则对外暴露的 API 越少后续改动时你的自由空间越大。你可以把HashMap换成ConcurrentHashMap可以加过期策略可以加缓存统计只要对外方法签名不变调用方完全无感知。但如果字段是 public 的换数据结构这种本来很普通的内部优化就会因为外部代码直接引用了字段而变成破坏性变更。这个道理听起来很朴素但实际项目里大量代码坏就坏在“图省事”三个字上。很多新人对 public 的理解是“方便”对 private 的理解是“麻烦”这个认知反了。public 是“一旦发布就极难撤回的承诺”private 才是“留给自己的自由”。2.2 现实中如何划分 public 接口和 private 实现细节那实际操作中怎么划分我的经验是遵循几个粗糙但实用的原则。字段一律 private。哪怕你想对外暴露也通过 getter/setter 转发因为 getter/setter 是方法方法可以在将来加逻辑字段不行。对外提供的能力用 public 方法表达。比如工具类的静态方法、业务类的服务方法。类内部协作的辅助方法用 private。如果一个 private 方法大到你想测试它那说明该拆了把它拆成包内可见的另一个类的方法用默认修饰符这样同包下的测试类能直接测。默认修饰符适合包内协作。同一个包里的类比如一个主类带几个辅助类它们之间可以直接访问彼此的包私有成员而不必暴露到包外。protected 留给“子类要重写”的场景。模板方法模式就是典型父类定义好骨架流程其中某一步用 protected 方法暴露给子类去实现。这里要特别强调 getter/setter 不是银弹。很多人写一个类所有字段都自动生成 getter/setter看起来是封装了其实只是把 public 字段换成了 public 方法思想没变。真正的封装是这个类暴露的方法集合要能表达这个类的能力而不是表达这个类的字段明细。一个User类如果有getName()、setName()、getAge()、setAge()、getAddress()、setAddress()……这本质上还是一堆数据袋子谈不上封装。3. protected 的隐藏陷阱跨包子类为何连“自己人”都访问不了如果面试官想用权限修饰符挖坑protected 绝对是最佳选择。因为四张表里只有 protected 的“异包子类”这一格带了个“特殊规则”而这个特殊规则恰恰是大多数人凭直觉猜错的。3.1 跨包子类访问 protected 的两个条件先看 Java 语言规范里对 protected 成员跨包访问的定义。一个类 C 的 protected 成员在另一个包的子类 S 中是可以被访问的但有限制访问必须通过 S 类型的对象或 S 的子类类型的对象来发起。换句话说S 的代码里能访问的不是 C 的所有 protected 成员而是“通过 S 或 S 的子类引用”访问的那些。这句话绕直接看代码。package com.example.a; public class Parent { protected void doSomething() { System.out.println(parent do something); } }再看子类它在另一个包里。package com.example.b; import com.example.a.Parent; public class Child extends Parent { public void testOk() { // 情况1通过 this 访问this 是 Child 实例可以 this.doSomething(); // 情况2直接调用隐含 this可以 doSomething(); // 情况3通过 new Child() 访问引用类型是 ChildS可以 new Child().doSomething(); } }这三行都是合法的因为它们都符合“通过 S 类型的引用”这个条件。但下面这些写法编译直接报错。package com.example.b; import com.example.a.Parent; public class Child extends Parent { public void testNotOk() { // 情况4通过父类引用访问引用类型是 ParentC不行 Parent p new Parent(); p.doSomething(); // 编译错误 } public void testAlsoNotOk(Child other) { // 情况5另一个子类对象如果这个类是 Child这里的 other 也是 Child // 等等这个其实是可以的因为 other 类型是 Child(也就是 S 自身) other.doSomething(); // 可以 } }情况4 是最大的坑。很多人的直觉是parent 是子类的父类我在子类里访问父类对象上的 protected 方法应该没问题吧结果编译器告诉你不行。原因就是刚才那句规范跨包子类访问 protected 成员引用类型必须是子类自身或其子类不能是父类类型。更隐蔽的是情况5 的变体。如果你还有一个类叫Sibling它和Child一样继承自Parent那么Child类内部访问Sibling实例上的 protected 成员也是不行的因为Sibling既不是Child也不是Child的子类虽然它们是“同一个爹”。package com.example.b; public class Sibling extends Parent { // 什么都不做 } public class Child extends Parent { public void testSibling() { Sibling s new Sibling(); s.doSomething(); // 编译错误Sibling 不是 Child 或 Child 的子类 } }这个规则抽象一下就是跨包 protected 访问保护的其实是“子类给自己用”而不是“所有兄弟互相用”。同厂不同部门的兄弟团队没经过管事部门授权是不能随便动对方手上那批资源的。只有在同一个包内大家才能相互访问 protected 成员——这时候它退化成包私有级别的访问。3.2 实测子类中 new 父类对象会报什么错我把上面情况4 的代码编了一遍javac的报错信息是错误: doSomething() 在 Parent 中是 protected 访问受保护注意这个报错很有迷惑性。它只说“protected 访问受保护”并没有告诉你“应该用什么类型的引用”。如果你没理解引用类型这一层规则很容易一头雾水觉得“我明明在子类里啊为什么不能访问父类的 protected 方法”。网上很多 Java 面试八股文爱问这个其实就是考这条规范。那正确的写法是什么如果你确实想通过一个引用调用那就把引用声明成子类类型。或者直接在子类里写一个转发方法public void exposeDoSomething() { doSomething(); // this 调用完全没问题 }很多框架就是这么干的。父类定义 protected 的生命周期钩子子类在内部方法里调用永远用this隐式引用。3.3 记忆口诀和判断步骤为了避免每次都被绕晕我自己总结了一套三步判断法遇到 protected 跨包访问的问题按顺序套就行。当前代码所在的类 S是不是父类 C 的子类不是直接不能访问。被访问的引用是什么类型 TT 必须是 S 或 S 的子类否则不能访问。如果引用是 S 的类型再看这个引用指向的对象是否真的发生了继承关系——其实到第 2 步就已经能判断了。口诀就是在子类里用自己能看见的类型去访问。不要在子类里拿父类类型去 new 对象再访问人家的 protected 成员那不是“子类访问父类”那是“外部人访问另一个类的受保护资源”。4. 方法重写的可见性约束只许放大不许缩小权限修饰符不只是影响“能不能访问”还影响“能不能重写”。这个点很容易在面试里跟多态结合着考。4.1 为什么子类不能降低可见性Java 的规则是子类重写父类方法时访问修饰符的访问级别不能比父类方法更低。也就是说父类是 public子类重写时必须是 public父类是 protected子类重写成 protected 或 public 都行但不能降成默认或 private。这条规则底层原因就是多态。看个场景package com.example.a; public class Base { public void handle() { System.out.println(base handle); } }假设子类能把方法降成 privatepackage com.example.b; public class Sub extends Base { private void handle() { // 假设编译器允许 System.out.println(sub handle); } }那么外部代码持有的是Base类型的引用调用handle()时方法的可见性是由静态类型Base决定的——Base.handle 是 public外部可以调但实际执行的是 Sub 重写后的逻辑。这样一来外部代码就能通过父类引用调用一个在子类里被标记为 private 的方法私有性就名存实亡了而且编译器已经放行了base.handle()的调用运行期才去子类里找方法逻辑上自相矛盾。Java 干脆把这个口子堵死你重写可以但不能把可见性调小。所以编译器会在重写方法上强制检查修饰符。如果你试图把 public 方法重写成 privatejavac会报错误: Sub 中的 handle() 无法覆盖 Base 中的 handle() 被覆盖的方法为 public报错里最扎眼的就是“被覆盖的方法为 public”这句话翻译过来就是父类是 public你子类不能比我更小气。4.2 接口实现中的可见性默认必须是 public接口的方法还有一个特殊之处Java 8 之前接口方法全是 public abstractJava 8 之后加了 default 方法和 static 方法但默认方法的访问级别依然是 public。实现类在重写接口方法时也只能用 public不能降级。这一点经常被忽略的地方在“包私有实现类”的场景。假设接口是 public 的实现类没有加修饰符包私有那这个实现类对外部的可见性就由类修饰符决定——包外的代码连这个类都看不见更别说调用它实现的方法了。但方法本身在类内部的重写级别仍然是 public修饰符约束的是“方法能不能被调用”类层面的可见性是另一码事。4.3 重写时最容易踩的修饰符配合坑除了可见性不能降级还有一个容易忽略的点父类方法是static的子类如果想“重写”其实只能隐藏hide而且修饰符的组合没那么灵活。static方法不参与多态子类定义一个同签名 static 方法访问级别可以随意因为它们是两个方法只是名字相同。但这不在权限修饰符的管辖范围里不在本文展开。真正和权限修饰符相关的重写场景是父类 protected 方法被子类重写成 public 的用法。这是模板方法模式的标配父类的execute()模板方法里调用一个 protected 的doExecute()钩子子类将其重写为 public 后外部可以直接调子类的钩子也可以让父类模板去调用。这里“重写为 public”其实是契约放宽外部才获得了直接调用的能力如果子类不重写外部依然只能执行父类的模板流程无法直接碰 protected 钩子。这一层设计很多框架里用得非常多理解了之后读源码会顺很多。5. 进阶场景里的权限设计内部类、构造器、反射对抗到了这一层权限修饰符就不再只是“四个关键字选哪个”的问题了而是“怎么利用可见性设计出更安全、更清晰的代码结构”。这才是进阶笔记该有的内容。5.1 内部类的 private 修饰把实现细节藏得再深一层内部类本身也可以有修饰符而且这个修饰符的语义和普通类不一样。普通顶层类只能是 public 或默认但内部类可以是 private、protected。private内部类意味着这个类只能在外部类内部被实例化外面完全不知道它的存在。一个常用场景是外部类的方法返回一个集合但这个集合的真实实现是一个 private 内部类。调用方拿到的是接口类型根本不用关心底层是 ArrayList 还是别的什么。JDK 源码里到处是这种设计ArrayList的迭代器就藏在 private 内部类里HashMap的 EntrySet、KeySet 也是。调用方只看到Iterator、Set这种接口具体实现被修饰符牢牢锁在内部将来想换实现外部零感知。这个技巧在业务代码里也适用。我写过不少“返回一个只读视图”的小工具就是用一个 private 内部类实现接口内部类里把原始数据包一层所有修改方法直接抛 UnsupportedOperationException从根上杜绝调用方误改数据。5.2 私有构造器单例、工具类与静态工厂的修饰符博弈构造器也是成员所以也能用权限修饰符控制。最经典的用法是工具类一个类全是静态方法不需要实例化那就把构造器设为 private防止别人 new 出无意义的对象。Java 的Arrays、Collections这些工具类都是这么干的。private 构造器也是单例模式的基础。无论是饿汉式还是懒汉式核心思路都是构造器 private外部不能 new唯一获取入口是静态工厂方法。这个设计本质上就是在用权限修饰符建立一道“只能通过我给你的入口拿实例”的墙。静态工厂方法也是这一层思想的延伸。私有构造器 静态工厂方法类内部可以控制实例创建的数量、时机、缓存策略这正是 public 构造器做不到的。面试里如果问“为什么推荐静态工厂方法而不是构造器”底层原因有一部分就是权限修饰符带来的控制力。5.3 反射与 setAccessible权限修饰符的边界与反制如果读者之前看过动态代理或者 ORM 框架的源码一定见过setAccessible(true)这个调用。它的作用就是无视权限修饰符强行打开 private 成员。严格来说setAccessible(true)能让反射代码访问原本不可见的字段和方法。Spring 的依赖注入、MyBatis 的结果映射、Jackson 的反序列化全靠这一手才能在 private 字段上为所欲为。这也是为什么我说“权限修饰符是编译期契约不是运行时加密”。它约束的是“正常路径”下的代码访问反射是另一条路能绕过但需要调用者显式授权自己。不过从 Java 9 引入模块系统之后想要反射打开别的模块里的 private 成员还必须加--add-opens参数否则 JVM 会拒绝。这说明 JVM 在模块层面对反射做了收紧但类路径下的代码反射依然能突破 private。所以真正严肃的结论是不要用权限修饰符来保护机密数据。它是设计约束不是安全边界。你可以在字段上写 private但如果有强需求反射照样能拿到。权限修饰符挡的是无知和误用不是恶意攻击。6. 从字节码看权限修饰符javap 反编译下的修饰符标志进阶到一定阶段就不能只看源码层了。权限修饰符最终会被编译到 class 文件的 access_flags 字段里用 javap 反编译一眼就能看出来。6.1 javap 查看 access_flags随手写一个类然后用命令行反编译它的字节码。javac TestModifier.java javap -v TestModifier.class输出里有一段这样的内容public class com.example.test.TestModifier minor version: 0 major version: 52 flags: (0x0021) ACC_PUBLIC, ACC_SUPER注意flags这一行。0x0021 是十六进制拆开看0x0020 是 ACC_SUPER表示这个类要支持新版的 invokespecial 语义0x0001 是 ACC_PUBLIC加起来就是 0x0021。字段和方法的访问级别也有对应的标志位。比如一个private static final字段flags 会是flags: (0x001A) ACC_PRIVATE, ACC_STATIC, ACC_FINAL各标志位对应的值在 JVM 规范里有表常见几个ACC_PUBLIC 0x0001、ACC_PRIVATE 0x0002、ACC_PROTECTED 0x0004、ACC_STATIC 0x0008、ACC_FINAL 0x0010、ACC_SYNCHRONIZED 0x0020、ACC_VOLATILE 0x0040、ACC_TRANSIENT 0x0080。为什么要提字节码因为权限修饰符的“检查”在 javac 阶段确实做了但 class 文件本身存下来的只是这些标志位。JVM 在类加载时会再次校验这些标志位与访问行为是否匹配。比如一个方法在字节码层面拿不到访问权限JVM 的访问控制检查会抛IllegalAccessError。虽然这套机制很少在正常编码中被触发但理解了它你就知道“编译能过运行不一定能过”的说法是怎么来的——编译器和 JVM 各检查一遍双保险。6.2 命名规范与修饰符的选择策略我推荐的默认组合代码规范里对修饰符的顺序是有约定的虽然编译器不强制但读代码的人会有预期。按照 Java 官方推荐的修饰符顺序public / protected / privateabstractstaticfinaltransientvolatilesynchronizednativestrictfp比如public static final是合法的static public final也能编译但读起来就很别扭。多数规范检查工具Checkstyle、PMD都配置了修饰符顺序检查老实按顺序写就好。真正值得说一句的是我在实际编码里默认的修饰符组合。在没有特殊理由的情况下我用这几个默认值字段一律private顶多加final。对外方法public。内部辅助方法private。模板方法中需要子类定制的点protected。包内协作的辅助类默认修饰符。常量public static final但尽量避免 public 常量泛滥因为 public 常量也是一种 API 承诺一旦别人用上了你改值就是破坏性变更。这套组合敲了很多年没踩过大坑。很多新人会问“字段为什么不能 protected 或默认”我的回答是能但没必要。字段是状态状态越容易被外部触碰类的不变量越难维护。protected 字段尤其危险子类和同包都能直接改一旦字段状态失控排查成本极高。如果有子类需要访问父类的某个内部状态最好的做法是提供一个 protected 的 getter 或 setter而不是直接把字段放出去。6.3 与模块系统的一些联想权限的粒度正在变细Java 9 的模块系统引入了一个新的访问控制维度模块导出exports和开放opens。以前权限修饰符控制的是“类/成员级别”的可见性模块系统在之上又加了一层“模块级别”的可见性。一个类即使是 public如果它所在的包没有被模块导出那模块外的代码依然访问不到。这其实是对权限控制粒度的一次升级。从类的 public/private到包的导出/不导出再到模块的开放/不开放控制粒度越来越细。理解了权限修饰符再去学模块系统会有一种“同一套思维在不同层级复用”的感觉都是划定边界只不过从方法级别扩到了模块级别。最后分享几个我在实际项目里的使用习惯权限修饰符这个知识点平时写代码不一定能感受到它的存在但一旦代码规模上来团队的协作人数变多它的价值就会被无限放大。我这几年的体会是真正拉开代码质量差距的往往不是设计模式用得有多花哨而是这些最基本的可见性边界划得有多清晰。一个很实用的习惯是写一个新类的时候先默认把所有成员都设成 private然后逐个问自己“这个真的需要对外暴露吗”。需要的时候再放宽成 public或者 protected。从紧到松的路径比从松到紧安全得多因为把 private 改成 public 是零成本的但把 public 改成 private 可能就要牵连所有调用方了。还有一个排错经验遇到“明明在同一个包里为什么访问不了另一个类的默认修饰符成员”这类问题先检查是不是放在不同包、不同模块里了。我在用 IDE 自动移动类的时候踩过这种坑看起来代码在同一个目录实际上包名被自动调整过默认真空修饰符立刻失效排查半天才找到原因。权限修饰符的生效前提是包结构稳定包路径一动默认修饰符的“同包可见”就变成了“不同包不可见”这类问题很隐蔽要注意。最后一条别迷信“把字段全部 private 就是封装”。封装的本质是行为建模不是字段隐藏。private 只是实现隐蔽的手段真正的目的是让外部代码依赖稳定的接口而不是依赖易变的内部结构。什么时候你写类的时候边界想清楚了什么时候你的 Java 就算真正进阶了。