Skip to content

Java 包、访问控制与常用修饰符

包、访问控制和修饰符决定代码如何组织、哪些内容能被外部访问、哪些值不能修改。它们看起来是语法细节,实际影响项目分层、封装边界和线上可维护性。

为什么需要包

包的核心作用是组织代码和避免类名冲突。

mermaid
flowchart TD
    A["业务系统"] --> B["controller 包"]
    A --> C["service 包"]
    A --> D["repository 包"]
    A --> E["domain 包"]
    B --> F["接收请求"]
    C --> G["处理业务"]
    D --> H["访问数据库"]
    E --> I["表达业务对象"]

如果所有类都放在一个目录里,随着项目变大,会出现:

  1. 类名冲突。
  2. 职责混乱。
  3. 无法看出调用边界。
  4. 新人不知道代码应该放哪里。

package 和 import

java
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 引入其他类。

包名通常使用公司域名倒写:

text
com.company.project.module

访问控制

修饰符同类同包子类任意包
private可以不可以不可以不可以
默认不写可以可以不可以不可以
protected可以可以可以不可以
public可以可以可以可以

访问控制的本质是封装边界。

java
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 成员”。更准确地说:

  1. 同一个包内,protected 和默认访问差不多,同包类可以访问。
  2. 不同包的子类,可以在继承关系内部访问父类的 protected 成员。
  3. 不同包的子类,不能拿一个父类引用随便访问 protected 成员。

示例:

java
package com.company.common;

public class BaseService {
    protected void checkTenant() {
        System.out.println("tenant checked");
    }
}
java
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
作为模块对外 APIpublic

业务项目里不要为了“方便调用”一律 public。接口一旦公开,后续改签名、改语义、删方法的成本都会很高。

static

static 表示属于类,而不是属于某个对象。

java
public class OrderNoGenerator {
    private static long sequence = 0;

    public static synchronized String nextNo() {
        sequence++;
        return "ORDER-" + sequence;
    }
}
mermaid
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 初始化和类加载

静态变量和静态代码块在类初始化阶段执行,通常只执行一次。

java
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);
    }
}

流程:

mermaid
flowchart TD
    A["首次主动使用类"] --> B["JVM 加载 Class"]
    B --> C["准备 static 字段默认值"]
    C --> D["按源码顺序执行 static 赋值和 static 块"]
    D --> E["类初始化完成"]
    E --> F["后续访问直接使用已初始化结果"]

注意“按源码顺序”很重要:

java
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 变量的并发风险

静态变量被所有线程共享。如果它是可变数据,就要考虑线程安全。

错误示例:

java
public class LoginContextBad {
    public static Long currentUserId;
}

两个请求并发进来时,请求 A 刚写入 currentUserId=1,请求 B 可能立刻覆盖成 2,A 后面读到的就可能是 B 的用户。这就是串数据。

正确思路:

  1. 请求级上下文不要放 static 变量。
  2. Web 请求参数通过方法参数、上下文对象、ThreadLocal 传递。
  3. ThreadLocal 在线程池中必须 finally remove
  4. 全局缓存如果用 static,要考虑并发集合、容量、过期和释放。
mermaid
flowchart TD
    A["static 可变变量"] --> B["所有线程共享"]
    B --> C{"是否保存请求级数据"}
    C -- "是" --> D["高风险:串用户、串租户、串 traceId"]
    C -- "否" --> E{"是否线程安全"}
    E -- "否" --> F["加锁、并发集合或改为不可变"]
    E -- "是" --> G["仍需考虑容量和生命周期"]

final

final 表示“不能再改变绑定关系”。

修饰位置含义
修饰变量变量只能赋值一次
修饰方法子类不能重写
修饰类不能被继承
java
public final class MoneyUtils {
    public static final String CURRENCY_CNY = "CNY";

    private MoneyUtils() {
    }
}

注意:final 修饰引用类型时,不能改变引用指向,但对象内部状态仍可能变化。

java
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 常用于定义常量,但要区分“编译期常量”和“运行期常量”。

编译期常量:

java
public class ApiConstants {
    public static final String VERSION = "v1";
    public static final int MAX_RETRY = 3;
}

如果其他模块编译时引用了 ApiConstants.VERSION,编译器可能直接把 "v1" 写进调用方 class 文件,这叫常量内联。

风险是:你把依赖 jar 里的 VERSION 改成 "v2",但调用方没有重新编译,调用方运行时可能仍然使用旧的 "v1"

运行期常量:

java
public class ApiConstants {
    public static final String VERSION = System.getProperty("api.version", "v1");
}

这种值不是编译期能确定的,调用方不会简单内联成固定字面量。

商业建议:

  1. 状态码、错误码、字段名这些稳定值可以用常量。
  2. 会频繁变化的配置不要做成跨模块编译期常量,应该走配置中心、数据库或环境变量。
  3. 公共 jar 中的常量变更后,要推动依赖方重新编译发布。

不可变对象怎么设计

final 是不可变设计的一部分,但不是全部。一个更接近生产的不可变值对象:

java
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);
    }
}

关键点:

  1. 类用 final,防止子类破坏不可变语义。
  2. 字段用 private final
  3. 构造方法里校验必填字段。
  4. 传入集合时做防御性拷贝。
  5. 返回集合时给只读视图,不暴露内部可变集合。

如果直接 this.fileNames = fileNames,调用方仍然可以在对象创建后修改原始 List,所谓不可变就失效了。

this 和 super

this 表示当前对象,super 表示父类部分。

java
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() 调用父类方法。

对象初始化顺序

mermaid
flowchart TD
    A["类加载"] --> B["静态变量和静态代码块"]
    B --> C["创建对象"]
    C --> D["父类成员变量和代码块"]
    D --> E["父类构造方法"]
    E --> F["子类成员变量和代码块"]
    F --> G["子类构造方法"]

Demo:

java
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();
    }
}

理解初始化顺序可以帮助排查:配置还没初始化、静态变量提前使用、父类构造调用子类重写方法等问题。

父类构造方法不要调用可重写方法

java
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:分层包和访问边界

订单模块可以这样划分:

text
com.company.order
├── controller
├── service
├── repository
├── domain
└── support

领域对象控制自己的状态:

java
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 调用领域方法,而不是绕过规则直接改字段:

java
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> 只能保证引用不换,但不能保证列表内容不变。如果要不可变集合,应使用不可变集合或只读视图。

线上排查流程

mermaid
flowchart TD
    A["修饰符相关问题"] --> B{"表现是什么"}
    B -- "状态被随意修改" --> C["检查字段是否 public 或 setter 是否过宽"]
    B -- "请求串数据" --> D["检查 static 是否保存请求上下文"]
    B -- "初始化值异常" --> E["检查 static 初始化顺序"]
    B -- "常量更新不生效" --> F["检查是否编译期常量内联且调用方未重编译"]
    B -- "子类初始化空指针" --> G["检查父类构造是否调用可重写方法"]
    B -- "不可变对象被改" --> H["检查集合是否缺少防御性拷贝"]

finalfinallyfinalize 深入区分

这三个词很像,但处在完全不同的层面:

名称所属领域解决什么问题
final语言修饰符限制变量重新赋值、方法重写、类继承
finally异常处理语法无论正常还是异常,都尽量执行收尾逻辑
finalizeObject 方法对象被 GC 前可能执行的回调,已经不推荐

final 的“不可变”不是绝对不可变

final 修饰基本类型时,值不能再改。

java
final int maxRetry = 3;
// maxRetry = 5; // 编译失败

final 修饰引用类型时,不能让变量指向另一个对象,但对象内部内容能不能变,要看对象本身是否可变。

java
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<>(); // 不可以,修改的是引用指向
    }
}

所以真正的不可变对象通常还需要:

  1. 字段使用 private final
  2. 不暴露可变集合的原始引用。
  3. 类本身不提供修改状态的方法。
  4. 必要时使用防御性拷贝。

医疗采集平台里,像“采集批次号、医院编码、接口编码”这类创建后不应改变的值,适合放到不可变对象中,避免后续链路被意外修改。

finally 是收尾,不是事务补偿

finally 通常用于释放资源:

java
Connection connection = null;
try {
    connection = dataSource.getConnection();
    // 执行业务 SQL
} finally {
    if (connection != null) {
        connection.close();
    }
}

它解决的是“这段代码执行完后,资源要尽量释放”。它不能保证业务一定成功,也不能替代数据库事务回滚、MQ 补偿或分布式事务。

更推荐的资源释放方式是 try-with-resources,它能自动关闭实现了 AutoCloseable 的资源。

java
try (Connection connection = dataSource.getConnection()) {
    // 执行业务 SQL
}

finalize 为什么不推荐

finalize 最大的问题是不确定:

  1. 不确定什么时候执行。
  2. 不确定一定会执行。
  3. 执行线程和业务线程无关。
  4. 方法里如果写慢逻辑,会拖累 GC 相关流程。
  5. 对象还可能在 finalize 中“自救”,让生命周期更复杂。

所以不要把关闭文件、释放连接、归还资源这类关键逻辑放进 finalize。生产代码应使用显式关闭、try-with-resources、连接池和生命周期回调。

mermaid
flowchart TD
    A["对象不再可达"] --> B["GC 发现对象"]
    B --> C{"是否有 finalize"}
    C -- "没有或已执行过" --> D["可回收"]
    C -- "有且未执行" --> E["放入低优先级队列"]
    E --> F{"finalize 是否及时执行"}
    F -- "不确定" --> G["不能依赖它释放关键资源"]
    F -- "执行并重新建立引用" --> H["对象可能暂时复活"]

如果不会区分会怎样

误区后果
以为 final List 就是集合内容不可变共享集合仍可能被修改,导致线程安全或数据串改
finally 里写复杂业务补偿异常链路更乱,补偿失败更难追踪
finallyreturn可能吞掉异常,让真实错误消失
依赖 finalize 关闭资源资源长期不释放,连接耗尽或文件句柄泄漏
final 类到处封死扩展点测试、代理、继承扩展困难

本章小结

包负责组织代码,访问控制负责划清边界,static 表示类级共享,final 表示不能重新绑定或不能继承修改。写业务代码时不要只追求“能访问”,而要思考“是否应该被访问”。