访问修饰符的实现原理
Java 提供了四种访问级别来控制类、字段和方法的可见性:public、protected、默认(包级私有)和 private。大多数开发者知道"谁能访问谁"的规则,但很少有人思考过:这些规则到底是编译器在管,还是 JVM 在管?如果我绕过编译器直接改字节码,private 还是 private 吗?
四种访问级别速览
先用一张表建立全局认知:
| 修饰符 | 同类 | 同包 | 不同包的子类 | 不同包的非子类 |
|---|---|---|---|---|
public |
✅ | ✅ | ✅ | ✅ |
protected |
✅ | ✅ | ✅ | ❌ |
| 默认(不写) | ✅ | ✅ | ❌ | ❌ |
private |
✅ | ❌ | ❌ | ❌ |
四个访问范围的精确定义
表格中的"同类""同包"听起来直觉上很好理解,但其中有一些容易踩坑的细节,逐一说清楚。
同类(Same Class):指的就是同一个 .java 文件中编译出来的同一个类。所有四种访问级别在同类内都不受限制——哪怕是 private 字段,在类自己的方法中也能随意读写。
public class Person {
private int age;
public void birthday() {
this.age++; // ✅ 同类内部访问 private,完全没问题
}
public void copyAge(Person other) {
this.age = other.age; // ✅ 同类的不同实例之间也可以!
}
}
注意最后一点——private 的限制是类级别,不是对象级别。Person 类的一个实例可以访问另一个 Person 实例的 private 字段,只要代码写在 Person 类内部。
同包(Same Package):指的是 package 声明完全相同的类。关键词是"完全相同"——Java 中子包不算同包。
// ✅ 以下两个类是"同包":
package com.example.service; // ClassA.java
package com.example.service; // ClassB.java
// ❌ 以下两个类不是"同包"(差一级也不行):
package com.example; // ClassC.java
package com.example.service; // ClassD.java
这个规则跟目录结构的直觉不同。在文件系统中,com/example/service 确实是 com/example 的子目录,但在 Java 的访问控制中,com.example 和 com.example.service 是两个完全独立的包,没有父子关系。
比喻:包名就像门牌号。"幸福路 10 号"和"幸福路 10 号 301 室"虽然看起来有从属关系,但在 Java 看来它们是两个不同的地址——你拿着 10 号的钥匙,打不开 301 室的门。
不同包的子类(Subclass in Different Package):指的是继承了目标类,但声明在不同 package 中的类。这个范围只对 protected 有意义——它比"同包"多了一层"继承关系"的通行证:
// package com.base
public class Animal {
protected String name; // protected 成员
}
// package com.pets(不同的包)
public class Dog extends Animal { // Dog 是 Animal 的子类
void test() {
System.out.println(this.name); // ✅ 不同包的子类可以访问 protected
}
}
不同包的非子类(Unrelated Class in Different Package):指的是既不在同一个包中、又没有继承关系的类。这是最"外面"的访问范围,只有 public 成员才对它们可见:
// package com.other(不同的包)
public class Stranger { // 不是 Animal 的子类
void test() {
Animal animal = new Animal();
System.out.println(animal.name); // ❌ protected 对"不同包的非子类"不可见
}
}
用一张图来总结四个范围的从属关系:
┌──────────────────────────────────────────────┐
│ public 可见的范围 │
│ ┌────────────────────────────────────────┐ │
│ │ protected 可见的范围 │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ 默认(包级私有)可见的范围 │ │ │
│ │ │ ┌────────────────────────────┐ │ │ │
│ │ │ │ private 可见的范围 │ │ │ │
│ │ │ │ (同类) │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
│ │ │ + 同包的其他类 │ │ │
│ │ └──────────────────────────────────┘ │ │
│ │ + 不同包的子类 │ │
│ └────────────────────────────────────────┘ │
│ + 不同包的非子类 │
└──────────────────────────────────────────────┘
这张表描述的是语言层面的规则。接下来我们深入底层,看看这些规则是如何被强制执行的。
访问修饰符的本质:字节码中的 access_flags
访问修饰符并不仅仅是给编译器看的语法标记——它们会被编码到 .class 文件的字节码中,以位掩码(bitmask)的形式存在。
ClassFile 结构中的 access_flags
每个 .class 文件、每个字段(field_info)、每个方法(method_info)都包含一个 access_flags 字段:
ClassFile {
u4 magic; // 0xCAFEBABE
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[];
u2 access_flags; // ← 就是这个
...
}
访问标志位对照表
| 修饰符 | 标志名 | 掩码值 | 含义 |
|---|---|---|---|
public |
ACC_PUBLIC |
0x0001 |
可以从包外部访问 |
private |
ACC_PRIVATE |
0x0002 |
仅当前类可访问 |
protected |
ACC_PROTECTED |
0x0004 |
当前类、子类、同包可访问 |
| 默认(包级私有) | 无专门标志 | — | 不设置以上三个标志 |
注意一个细节:默认访问级别没有对应的标志位。当 ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED 都未设置时,JVM 就将其视为包级私有。
比喻:访问修饰符就像门禁卡的权限等级。
public是万能卡,private是仅限本人的指纹锁。而编译器和 JVM 就像两道安检关卡——编译器是前台保安,JVM 是金库的密码锁。即使你骗过了保安(绕过编译器),密码锁(JVM 验证器)仍然会拦住你。
用 javap 看真实字节码
我们可以用 javap -v 反编译 .class 文件来验证:
public class Demo {
public int a;
protected int b;
int c; // 默认(包级私有)
private int d;
}
编译后执行 javap -v Demo.class,关于字段的部分:
public int a;
descriptor: I
flags: (0x0001) ACC_PUBLIC
protected int b;
descriptor: I
flags: (0x0004) ACC_PROTECTED
int c;
descriptor: I
flags: (0x0000) // ← 没有任何标志 = 包级私有
private int d;
descriptor: I
flags: (0x0002) ACC_PRIVATE
可以看到,c 的 flags 是 0x0000——三个访问标志都没设,JVM 按包级私有处理。
两层保护:编译期 + 运行期
访问修饰符的规则被双重执行,这是 Java 安全性的重要保障。
第一层:编译器检查
javac 在编译阶段就会进行访问权限检查。当你在一个类中尝试访问另一个类的 private 字段时,编译器直接报错,根本不会生成字节码:
public class Outside {
void tryAccess() {
Demo demo = new Demo();
System.out.println(demo.d); // ❌ 编译错误:d has private access in Demo
}
}
第二层:JVM 验证器检查
即使你绕过了编译器(比如手动修改字节码、使用不同语言生成字节码),JVM 的字节码验证器(Bytecode Verifier)在类加载时仍然会检查访问权限。
JVM 在执行 getfield、putfield、invokevirtual 等指令时,会进行字段解析(Field Resolution)或方法解析(Method Resolution),此时会检查:
- 目标字段/方法的
access_flags - 访问者所在的类与目标类的关系(同类、同包、子类)
如果违反了访问规则,JVM 会抛出 IllegalAccessError:
这不是编译错误,而是运行时错误——
说明字节码绕过了编译器,但没能骗过 JVM。
反射的特殊通道
java.lang.reflect 提供了一种"合法绕过"访问控制的方式:
Field field = Demo.class.getDeclaredField("d");
field.setAccessible(true); // 关闭访问检查
int value = (int) field.get(demo); // 现在可以读取 private 字段
setAccessible(true) 本质上是告诉 JVM:"我知道这是 private 的,但我有充分的理由需要访问它。" 这在框架开发中非常常见(如 Spring 的依赖注入、ORM 框架的字段映射),但在 JDK 9 引入模块系统后,跨模块的反射访问受到了更严格的限制。
protected 的微妙规则
四种修饰符中,protected 的规则最容易被误解。很多人以为"子类就能访问 protected 成员",但实际情况更复杂。
规则精确描述
protected 成员可以在以下情况被访问:
- 同包内的任何类(跟默认访问一样)
- 不同包的子类中,但有一个关键限制——只能通过
this(当前类的实例)访问,不能通过父类的其他实例访问
// package com.a
public class Parent {
protected int x = 10;
}
// package com.b
public class Child extends Parent {
public void test() {
// ✅ 通过 this 访问(this 是 Child 实例)
System.out.println(this.x);
// ✅ 通过同类型实例访问
Child another = new Child();
System.out.println(another.x);
// ❌ 编译错误!不能通过父类实例访问
Parent parent = new Parent();
System.out.println(parent.x); // 编译报错
}
}
为什么这样设计?
这个限制的目的是防止子类"偷看"其他子类的实现细节。
想象一下,如果 Child 可以访问任何 Parent 实例的 protected 成员,那么它也能访问 AnotherChild(Parent 的另一个子类)的实例的 protected 成员——这就破坏了 AnotherChild 的封装性。
Parent
├── Child
└── AnotherChild
如果 Child 能通过 Parent 引用访问 protected 成员,
那它就能"偷看" AnotherChild 的 protected 数据——这不安全。
内部类与 private:编译器的"障眼法"
在前面 04-keywords 那篇文章中我们提到了静态内部类和成员内部类。这里揭示一个有趣的底层实现细节:内部类可以访问外部类的 private 成员,但 JVM 层面并不认识"内部类"这个概念。
问题
Java 的内部类在编译后会变成独立的 .class 文件:
public class Outer {
private int secret = 42;
class Inner {
void peek() {
System.out.println(secret); // 可以访问 private!
}
}
}
编译后生成:
Outer.classOuter$Inner.class
从 JVM 的角度看,这是两个独立的类。Outer$Inner 要访问 Outer 的 private 字段 secret——按照 private 的规则,这是不允许的。
解决方案:合成方法(Synthetic Methods)
编译器偷偷在 Outer 类中生成了一个包级私有的合成方法:
// 编译器在 Outer 中自动生成(开发者在源码中看不到)
static int access$000(Outer outer) {
return outer.secret;
}
然后 Outer$Inner 的 peek() 方法实际上调用的是这个合成方法:
// Inner.peek() 的真实字节码
aload_0
getfield Outer$Inner.this$0 // 获取外部类引用
invokestatic Outer.access$000(Outer) // 通过合成方法间接访问
用 javap -p Outer.class 可以看到这个合成方法:
static int access$000(Outer);
descriptor: (LOuter;)I
flags: (0x1008) ACC_STATIC, ACC_SYNTHETIC // ← ACC_SYNTHETIC 标记
ACC_SYNTHETIC(0x1000)标志表示这个方法是编译器自动生成的,不是开发者写的。
JDK 11+ 的改进:Nestmates
在 JDK 11 之前,上述的合成方法是唯一的解决方案,但它有两个缺点:
- 性能开销:每次访问 private 成员都要经过一次方法调用
- 安全隐患:合成方法是包级私有的,同包的其他类理论上也能调用
JDK 11 引入了 Nest-Based Access Control(JEP 181),让 JVM 原生支持嵌套类成员之间的 private 访问:
// JDK 11+ 的 .class 文件中新增属性
NestHost: Outer // Inner 声明自己的"巢主"
NestMembers: Outer$Inner // Outer 声明自己的"巢成员"
JVM 在进行访问检查时,如果发现访问者和被访问者属于同一个"巢"(nest),就直接放行,不再需要合成方法。
access_flags 的完整标志表
除了访问修饰符,access_flags 还包含其他修饰信息:
| 标志名 | 值 | 含义 | 可用于 |
|---|---|---|---|
ACC_PUBLIC |
0x0001 |
public | 类、字段、方法 |
ACC_PRIVATE |
0x0002 |
private | 字段、方法(内部类) |
ACC_PROTECTED |
0x0004 |
protected | 字段、方法 |
ACC_STATIC |
0x0008 |
static | 字段、方法 |
ACC_FINAL |
0x0010 |
final | 类、字段、方法 |
ACC_SYNCHRONIZED |
0x0020 |
synchronized | 方法 |
ACC_VOLATILE |
0x0040 |
volatile | 字段 |
ACC_TRANSIENT |
0x0080 |
transient | 字段 |
ACC_ABSTRACT |
0x0400 |
abstract | 类、方法 |
ACC_SYNTHETIC |
0x1000 |
编译器生成 | 类、字段、方法 |
ACC_ENUM |
0x4000 |
枚举类型 | 类、字段 |
这些标志位是可以组合的。比如一个 public static final 字段的 flags = 0x0001 | 0x0008 | 0x0010 = 0x0019。
深度架构追问
"Java 的访问控制是编译时检查还是运行时检查?"
两者都有。 编译器是第一道防线,拒绝编译违反访问规则的代码。JVM 验证器是第二道防线,在类加载和链接阶段检查字节码中的访问权限。这种双重保护确保了即使绕过编译器(如通过字节码操作工具),私有成员仍然是安全的。
"反射能访问 private 成员吗?"
可以。setAccessible(true) 可以关闭 JVM 的访问检查。但在 JDK 9+ 的模块系统下,跨模块的反射访问需要在 module-info.java 中通过 opens 指令显式开放。没有开放的模块,即使 setAccessible(true) 也会抛出 InaccessibleObjectException。
"为什么接口的方法默认是 public?"
接口的设计初衷是定义行为契约——它声明"实现这个接口的类必须提供这些方法"。既然是契约,调用方必须能看到这些方法,所以 public 是唯一合理的默认选择。在 JDK 9 之前,接口的所有方法都只能是 public,之后才允许 private 方法(但仅用于 default 方法间的代码复用,不对外暴露)。
总结
| 层面 | 机制 | 何时执行 |
|---|---|---|
| 源码层 | public / protected / 默认 / private 关键字 |
编码时 |
| 编译层 | javac 语义分析阶段的访问权限检查 | 编译时 |
| 字节码层 | access_flags 位掩码(ACC_PUBLIC 等) |
写入 .class 时 |
| JVM 层 | 字节码验证器 + 字段/方法解析时的权限检查 | 类加载 + 运行时 |
| 反射层 | setAccessible(true) 可绕过检查 |
运行时 |
| 模块层 | JDK 9+ module-info.java 的 exports / opens |
编译时 + 运行时 |
访问修饰符从表面上看只是四个关键字,但从编译器到 JVM 到模块系统,形成了一条完整的访问控制链路。理解这条链路,不仅能夯实底层内功,更能帮你理解 Java 安全模型和框架(如 Spring、Jackson)底层的反射机制。