Java 包、访问控制与常用修饰符
包、访问控制和修饰符决定代码如何组织、哪些内容能被外部访问、哪些值不能修改。它们看起来是语法细节,实际影响项目分层、封装边界和线上可维护性。
为什么需要包
包的核心作用是组织代码和避免类名冲突。
flowchart TD
A["业务系统"] --> B["controller 包"]
A --> C["service 包"]
A --> D["repository 包"]
A --> E["domain 包"]
B --> F["接收请求"]
C --> G["处理业务"]
D --> H["访问数据库"]
E --> I["表达业务对象"]如果所有类都放在一个目录里,随着项目变大,会出现:
- 类名冲突。
- 职责混乱。
- 无法看出调用边界。
- 新人不知道代码应该放哪里。
package 和 import
package com.company.order.service;
import java.math.BigDecimal;
import com.company.order.domain.Order;
public class OrderService {
public BigDecimal calculatePayAmount(Order order) {
return order.getPrice().multiply(BigDecimal.valueOf(order.getCount()));
}
}package 声明当前类的位置,import 引入其他类。
包名通常使用公司域名倒写:
com.company.project.module访问控制
| 修饰符 | 同类 | 同包 | 子类 | 任意包 |
|---|---|---|---|---|
private | 可以 | 不可以 | 不可以 | 不可以 |
| 默认不写 | 可以 | 可以 | 不可以 | 不可以 |
protected | 可以 | 可以 | 可以 | 不可以 |
public | 可以 | 可以 | 可以 | 可以 |
访问控制的本质是封装边界。
public class Account {
private long balance;
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("充值金额必须大于 0");
}
balance += amount;
}
public long getBalance() {
return balance;
}
}balance 不直接暴露,是因为外部如果能随便改余额,业务规则就失效了。封装不是“为了麻烦”,而是为了让数据变化必须经过规则。
protected 的真实规则
protected 很容易被误解成“任意包子类都能随便访问父类 protected 成员”。更准确地说:
- 同一个包内,
protected和默认访问差不多,同包类可以访问。 - 不同包的子类,可以在继承关系内部访问父类的 protected 成员。
- 不同包的子类,不能拿一个父类引用随便访问 protected 成员。
示例:
package com.company.common;
public class BaseService {
protected void checkTenant() {
System.out.println("tenant checked");
}
}package com.company.order;
import com.company.common.BaseService;
public class OrderService extends BaseService {
public void createOrder() {
checkTenant(); // 可以,子类内部访问继承来的 protected 方法
this.checkTenant(); // 可以
}
public void call(BaseService baseService) {
// baseService.checkTenant(); // 不可以,不同包下不能通过父类引用随便访问
}
}为什么要这样设计?因为 protected 的目标是给继承扩展留入口,不是把成员半公开给外部任意调用。如果外部可以通过父类引用访问,就会破坏封装边界。
选择口诀:
| 场景 | 建议 |
|---|---|
| 只给当前类使用 | private |
| 只给同包协作类使用 | 默认访问 |
| 给子类扩展使用 | protected |
| 作为模块对外 API | public |
业务项目里不要为了“方便调用”一律 public。接口一旦公开,后续改签名、改语义、删方法的成本都会很高。
static
static 表示属于类,而不是属于某个对象。
public class OrderNoGenerator {
private static long sequence = 0;
public static synchronized String nextNo() {
sequence++;
return "ORDER-" + sequence;
}
}flowchart TD
A["OrderNoGenerator 类"] --> B["static sequence"]
C["对象 1"] --> A
D["对象 2"] --> A
E["对象 3"] --> A多个对象共享同一份静态变量,所以静态变量在并发场景下要特别小心。
常见用途:
| 用途 | 示例 |
|---|---|
| 工具方法 | Math.max() |
| 常量 | public static final String SUCCESS = "SUCCESS" |
| 工厂方法 | LocalDate.now() |
| 全局缓存 | 需要谨慎,防止内存泄漏 |
static 初始化和类加载
静态变量和静态代码块在类初始化阶段执行,通常只执行一次。
public class StaticInitDemo {
static int value = initValue();
static {
System.out.println("static block, value=" + value);
}
private static int initValue() {
System.out.println("init value");
return 100;
}
public static void main(String[] args) {
System.out.println(StaticInitDemo.value);
System.out.println(StaticInitDemo.value);
}
}流程:
flowchart TD
A["首次主动使用类"] --> B["JVM 加载 Class"]
B --> C["准备 static 字段默认值"]
C --> D["按源码顺序执行 static 赋值和 static 块"]
D --> E["类初始化完成"]
E --> F["后续访问直接使用已初始化结果"]注意“按源码顺序”很重要:
public class StaticOrderDemo {
static int a = b + 1;
static int b = 10;
public static void main(String[] args) {
System.out.println(a); // 1
System.out.println(b); // 10
}
}a 初始化时,b 还只是准备阶段的默认值 0,所以 a = 1。这类问题在线上常表现为“静态配置、静态缓存初始化顺序不符合预期”。
static 变量的并发风险
静态变量被所有线程共享。如果它是可变数据,就要考虑线程安全。
错误示例:
public class LoginContextBad {
public static Long currentUserId;
}两个请求并发进来时,请求 A 刚写入 currentUserId=1,请求 B 可能立刻覆盖成 2,A 后面读到的就可能是 B 的用户。这就是串数据。
正确思路:
- 请求级上下文不要放 static 变量。
- Web 请求参数通过方法参数、上下文对象、
ThreadLocal传递。 ThreadLocal在线程池中必须finally remove。- 全局缓存如果用 static,要考虑并发集合、容量、过期和释放。
flowchart TD
A["static 可变变量"] --> B["所有线程共享"]
B --> C{"是否保存请求级数据"}
C -- "是" --> D["高风险:串用户、串租户、串 traceId"]
C -- "否" --> E{"是否线程安全"}
E -- "否" --> F["加锁、并发集合或改为不可变"]
E -- "是" --> G["仍需考虑容量和生命周期"]final
final 表示“不能再改变绑定关系”。
| 修饰位置 | 含义 |
|---|---|
| 修饰变量 | 变量只能赋值一次 |
| 修饰方法 | 子类不能重写 |
| 修饰类 | 不能被继承 |
public final class MoneyUtils {
public static final String CURRENCY_CNY = "CNY";
private MoneyUtils() {
}
}注意:final 修饰引用类型时,不能改变引用指向,但对象内部状态仍可能变化。
import java.util.ArrayList;
import java.util.List;
public class FinalReferenceDemo {
public static void main(String[] args) {
final List<String> names = new ArrayList<>();
names.add("Tom");
// names = new ArrayList<>(); // 编译失败
}
}static final 和常量内联
public static final 常用于定义常量,但要区分“编译期常量”和“运行期常量”。
编译期常量:
public class ApiConstants {
public static final String VERSION = "v1";
public static final int MAX_RETRY = 3;
}如果其他模块编译时引用了 ApiConstants.VERSION,编译器可能直接把 "v1" 写进调用方 class 文件,这叫常量内联。
风险是:你把依赖 jar 里的 VERSION 改成 "v2",但调用方没有重新编译,调用方运行时可能仍然使用旧的 "v1"。
运行期常量:
public class ApiConstants {
public static final String VERSION = System.getProperty("api.version", "v1");
}这种值不是编译期能确定的,调用方不会简单内联成固定字面量。
商业建议:
- 状态码、错误码、字段名这些稳定值可以用常量。
- 会频繁变化的配置不要做成跨模块编译期常量,应该走配置中心、数据库或环境变量。
- 公共 jar 中的常量变更后,要推动依赖方重新编译发布。
不可变对象怎么设计
final 是不可变设计的一部分,但不是全部。一个更接近生产的不可变值对象:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ImportBatch {
private final String batchNo;
private final String hospitalCode;
private final List<String> fileNames;
public ImportBatch(String batchNo, String hospitalCode, List<String> fileNames) {
if (batchNo == null || batchNo.trim().isEmpty()) {
throw new IllegalArgumentException("批次号不能为空");
}
if (hospitalCode == null || hospitalCode.trim().isEmpty()) {
throw new IllegalArgumentException("医院编码不能为空");
}
if (fileNames == null) {
throw new IllegalArgumentException("文件名列表不能为空");
}
this.batchNo = batchNo;
this.hospitalCode = hospitalCode;
this.fileNames = new ArrayList<>(fileNames);
}
public String getBatchNo() {
return batchNo;
}
public String getHospitalCode() {
return hospitalCode;
}
public List<String> getFileNames() {
return Collections.unmodifiableList(fileNames);
}
}关键点:
- 类用
final,防止子类破坏不可变语义。 - 字段用
private final。 - 构造方法里校验必填字段。
- 传入集合时做防御性拷贝。
- 返回集合时给只读视图,不暴露内部可变集合。
如果直接 this.fileNames = fileNames,调用方仍然可以在对象创建后修改原始 List,所谓不可变就失效了。
this 和 super
this 表示当前对象,super 表示父类部分。
class BaseService {
public void check() {
System.out.println("基础校验");
}
}
class OrderService extends BaseService {
private String name;
public OrderService(String name) {
this.name = name;
}
@Override
public void check() {
super.check();
System.out.println(this.name + " 订单校验");
}
}this.name 访问当前对象字段,super.check() 调用父类方法。
对象初始化顺序
flowchart TD
A["类加载"] --> B["静态变量和静态代码块"]
B --> C["创建对象"]
C --> D["父类成员变量和代码块"]
D --> E["父类构造方法"]
E --> F["子类成员变量和代码块"]
F --> G["子类构造方法"]Demo:
class Parent {
static {
System.out.println("Parent static");
}
{
System.out.println("Parent block");
}
Parent() {
System.out.println("Parent constructor");
}
}
class Child extends Parent {
static {
System.out.println("Child static");
}
{
System.out.println("Child block");
}
Child() {
System.out.println("Child constructor");
}
}
public class InitOrderDemo {
public static void main(String[] args) {
new Child();
}
}理解初始化顺序可以帮助排查:配置还没初始化、静态变量提前使用、父类构造调用子类重写方法等问题。
父类构造方法不要调用可重写方法
class BaseTask {
BaseTask() {
init();
}
protected void init() {
System.out.println("base init");
}
}
class ImportTask extends BaseTask {
private String path = "data.csv";
@Override
protected void init() {
System.out.println(path.length()); // 可能空指针
}
}创建子类对象时,会先执行父类构造方法。父类构造方法调用 init() 时,由于动态绑定,会执行子类重写后的 init(),但此时子类字段可能还没完成初始化。结论:构造方法里不要调用可被子类重写的方法。
商业 Demo:分层包和访问边界
订单模块可以这样划分:
com.company.order
├── controller
├── service
├── repository
├── domain
└── support领域对象控制自己的状态:
package com.company.order.domain;
public class Order {
private String status = "CREATED";
public void pay() {
if (!"CREATED".equals(status)) {
throw new IllegalStateException("只有待支付订单才能支付");
}
this.status = "PAID";
}
public String getStatus() {
return status;
}
}Service 调用领域方法,而不是绕过规则直接改字段:
package com.company.order.service;
import com.company.order.domain.Order;
public class OrderService {
public void pay(Order order) {
order.pay();
// 保存订单、记录流水、发送事件
}
}这种划分让“谁能改订单状态”变得清楚。否则字段、工具类、静态方法到处互相调用,项目越大越难维护。
商业开发常见场景
| 场景 | 推荐做法 |
|---|---|
| Controller、Service、Repository 分层 | 使用包名表达职责 |
| 实体字段 | 字段 private,通过方法控制修改 |
| 常量 | public static final |
| 工具类 | final class 加私有构造方法 |
| 不希望子类改核心逻辑 | 方法声明为 final |
常见风险
public 暴露太多
类和方法一旦被大量外部代码依赖,后面修改成本就很高。能包内访问就不要 public,能 private 就不要默认暴露。
static 保存用户数据
不要用静态变量保存当前用户、请求数据、订单上下文。这些数据属于一次请求或一个线程,放到静态变量会串数据,并且有并发安全问题。
final 不是绝对不可变
final List<User> 只能保证引用不换,但不能保证列表内容不变。如果要不可变集合,应使用不可变集合或只读视图。
线上排查流程
flowchart TD
A["修饰符相关问题"] --> B{"表现是什么"}
B -- "状态被随意修改" --> C["检查字段是否 public 或 setter 是否过宽"]
B -- "请求串数据" --> D["检查 static 是否保存请求上下文"]
B -- "初始化值异常" --> E["检查 static 初始化顺序"]
B -- "常量更新不生效" --> F["检查是否编译期常量内联且调用方未重编译"]
B -- "子类初始化空指针" --> G["检查父类构造是否调用可重写方法"]
B -- "不可变对象被改" --> H["检查集合是否缺少防御性拷贝"]final、finally、finalize 深入区分
这三个词很像,但处在完全不同的层面:
| 名称 | 所属领域 | 解决什么问题 |
|---|---|---|
final | 语言修饰符 | 限制变量重新赋值、方法重写、类继承 |
finally | 异常处理语法 | 无论正常还是异常,都尽量执行收尾逻辑 |
finalize | Object 方法 | 对象被 GC 前可能执行的回调,已经不推荐 |
final 的“不可变”不是绝对不可变
final 修饰基本类型时,值不能再改。
final int maxRetry = 3;
// maxRetry = 5; // 编译失败final 修饰引用类型时,不能让变量指向另一个对象,但对象内部内容能不能变,要看对象本身是否可变。
import java.util.ArrayList;
import java.util.List;
public class FinalObjectDemo {
public static void main(String[] args) {
final List<String> hospitals = new ArrayList<>();
hospitals.add("A医院"); // 可以,修改的是对象内容
// hospitals = new ArrayList<>(); // 不可以,修改的是引用指向
}
}所以真正的不可变对象通常还需要:
- 字段使用
private final。 - 不暴露可变集合的原始引用。
- 类本身不提供修改状态的方法。
- 必要时使用防御性拷贝。
医疗采集平台里,像“采集批次号、医院编码、接口编码”这类创建后不应改变的值,适合放到不可变对象中,避免后续链路被意外修改。
finally 是收尾,不是事务补偿
finally 通常用于释放资源:
Connection connection = null;
try {
connection = dataSource.getConnection();
// 执行业务 SQL
} finally {
if (connection != null) {
connection.close();
}
}它解决的是“这段代码执行完后,资源要尽量释放”。它不能保证业务一定成功,也不能替代数据库事务回滚、MQ 补偿或分布式事务。
更推荐的资源释放方式是 try-with-resources,它能自动关闭实现了 AutoCloseable 的资源。
try (Connection connection = dataSource.getConnection()) {
// 执行业务 SQL
}finalize 为什么不推荐
finalize 最大的问题是不确定:
- 不确定什么时候执行。
- 不确定一定会执行。
- 执行线程和业务线程无关。
- 方法里如果写慢逻辑,会拖累 GC 相关流程。
- 对象还可能在
finalize中“自救”,让生命周期更复杂。
所以不要把关闭文件、释放连接、归还资源这类关键逻辑放进 finalize。生产代码应使用显式关闭、try-with-resources、连接池和生命周期回调。
flowchart TD
A["对象不再可达"] --> B["GC 发现对象"]
B --> C{"是否有 finalize"}
C -- "没有或已执行过" --> D["可回收"]
C -- "有且未执行" --> E["放入低优先级队列"]
E --> F{"finalize 是否及时执行"}
F -- "不确定" --> G["不能依赖它释放关键资源"]
F -- "执行并重新建立引用" --> H["对象可能暂时复活"]如果不会区分会怎样
| 误区 | 后果 |
|---|---|
以为 final List 就是集合内容不可变 | 共享集合仍可能被修改,导致线程安全或数据串改 |
在 finally 里写复杂业务补偿 | 异常链路更乱,补偿失败更难追踪 |
在 finally 中 return | 可能吞掉异常,让真实错误消失 |
依赖 finalize 关闭资源 | 资源长期不释放,连接耗尽或文件句柄泄漏 |
用 final 类到处封死扩展点 | 测试、代理、继承扩展困难 |
本章小结
包负责组织代码,访问控制负责划清边界,static 表示类级共享,final 表示不能重新绑定或不能继承修改。写业务代码时不要只追求“能访问”,而要思考“是否应该被访问”。
