Java对象与封装:从字段裸奔到不可变对象的设计进阶 📅 发布时间:2026/9/15 9:20:30 👁 浏览次数: 1. 从一次“字段裸奔”说起对象到底在保护什么先讲一个我早年踩过的坑。那时候刚工作没多久接手一个订单模块前任开发写了一个Order类四个字段全部public连final都没加public class Order { public String status; // 订单状态0-待支付 1-已支付 2-已取消 public BigDecimal amount; public String userId; public Date createTime; }看起来挺简洁对吧问题出在一次需求变更上订单状态需要新增一个“已退款”枚举并且状态流转必须校验顺序不允许从“已取消”直接跳到“已支付”。我当时直接在外层业务代码里到处写order.status 3结果漏改了三处线上出现了一堆脏数据。这就是典型的“字段裸奔”问题。Java对象的核心作用绝不只是装数据的容器它承载的是业务规则与数据完整性的双重职责。而封装Encapsulation就是保证这两点的基础手段。很多Java初学者对“对象”和“封装”的理解停留在表面对象是new出来的实例封装就是把字段设为private然后生成getter/setter。这种理解不能说错但远不够。真正干活的时候你会发现对象的结构设计、字段的访问控制、行为的暴露方式直接决定了项目后期是“改一处跑全盘”还是“牵一发而动全身”。这篇我就结合自己多年的开发经验从JVM对象结构、访问控制、构造器设计、不可变对象、面试应答等多个维度把Java对象与封装这个话题掰开揉碎讲清楚。不管你是在校学生、刚转行的新人还是写了两三年业务代码的老手看完应该都能对“封装”有更深一层的理解。2. 先搞清楚Java对象在内存里长什么样2.1 从JVM视角看“对象是怎么被创建出来的”我们日常写User user new User()的时候JVM到底做了哪些事这块内容面试高频也是理解对象本质的基础。在HotSpot虚拟机中new一个对象主要经历这几步类加载检查JVM检查方法区/元空间中是否能定位到这个类的符号引用如果没有就先触发类加载。分配内存在堆中为对象划分一块内存区域。指针碰撞还是空闲列表取决于垃圾收集器是否带压缩整理功能。内存空间初始化这块内存先被清零保证实例字段不赋值时也有默认值int是0、boolean是false、引用是null。对象头设置设置对象头mark word和类型指针里面存储哈希码、GC分代年龄、锁状态标志等元信息。执行构造方法调用init方法也就是我们写的构造函数进行显式初始化。第五步经常被忽略。很多人以为new完对象字段就有值了其实构造方法执行之前对象的内存早被清零了字段只是默认值。构造方法执行完才真正成为一个“可用”的对象。这里就引出一个经验点构造方法里写的赋值操作本质上是“二次赋值”。第一次是内存清零的默认值第二次才是你写的初始化逻辑。这也是为什么有些性能敏感场景会推荐用Objects.requireNonNull或工厂方法做校验——初始化工作放到构造器里做比放到业务代码里做更安全。2.2 对象组成三件套对象头、实例数据、对齐填充Java对象在内存中由三个区域组成对象头Header存储Mark Word哈希码、锁信息、GC分代年龄等和类型指针指向类元数据。数组对象还包括数组长度。实例数据Instance Data真正存字段值的地方包括从父类继承下来的字段。对齐填充PaddingHotSpot要求对象大小必须是8字节的整数倍不够就补位。这部分内容看着偏底层但对理解封装也有帮助。举个例子对象头里有锁状态标志synchronized加锁时就是改这个Mark Word。如果你设计了一个大量被并发访问的对象方法全部加上synchronized导致锁竞争激烈性能就会断崖式下降。这也是为什么封装时要考虑“最小同步原则”——不要为了图省事把整个方法都锁上。另外实例数据的排列顺序在HotSpot中会受到字段声明顺序和JVM重排策略的影响。JVM会倾向于把宽度相同的字段放在一起节省padding空间。这个对普通开发影响不大但对追求极致性能的高并发中间件开发者来说字段声明的顺序都会影响内存占用。算是“封装设计”底层的一个冷知识。2.3 对象的创建方式不止new一种Java里创建对象的姿势其实有很多种创建方式特点是否调用构造方法new关键字最常用显式调用构造器是反射Class.newInstance / Constructor.newInstance动态创建可访问私有构造器是clone()拷贝已有对象不触发构造器否反序列化从字节流恢复对象不触发构造器否Unsafe.allocateInstance直接分配内存绕过构造器否前两种是日常开发接触最多的。而了解后面几种的意义在于封装设计时必须要想清楚“这个对象允不允许被克隆、被反序列化”。举个例子你设计了一个单例工具类构造器是private保证了外部不能new。但如果你没实现readResolve()反序列化时照样能破坏单例。这种绕过封装机制的“后门”在面试中很常考实际开发中也会遇到——特别是用Redis缓存对象再反序列化的场景单例被破解的坑我是真实踩过的。3. 封装到底在解决什么不是语法问题是设计问题3.1 三个词描述封装的本质隐藏、约束、稳定封装在Java中的实现表面上看是private、protected这些访问修饰符的运用但本质上是设计层面的事。我个人理解封装解决的是三个问题第一隐藏内部实现。对象内部的字段怎么存、算法怎么跑外部调用者根本不需要关心。比如一个UserService里的登录方法底层是查MySQL还是Redis是走密码比对还是走OAuth调用方只关心“传用户名密码对不对”。隐藏带来的好处是你内部随便优化不影响外部接口。第二约束字段状态。这就是我开头踩坑的案例。public字段最大的问题就是任何人都能改改得对不对没人管。封装之后字段改为private修改行为全部收敛到setter方法里你可以在这里加校验、加日志、加状态流转判断外部代码根本绕不过去。第三保持对外契约的稳定。调用方依赖的是对象公开的方法签名而不是内部字段名。只要公开方法不换方法里的实现逻辑随便改对调用方都无感知。这其实是接口稳定性的基础。面试时如果你能把封装讲到这个层面而不是只背“类是对象的模板万物皆对象”这种废话就已经超越大多数候选人了。3.2 数据与行为要不要放在一起面向对象设计有个经典争论数据和行为应该放在同一个类里还是数据放实体类、行为放Service层Java主流的业务开发风格尤其是基于Spring的项目普遍采用贫血模型——实体类只管字段和getter/setter业务逻辑写在Service类里。这种风格简单清晰、易于事务管理但在复杂业务系统里容易演变成“service层几百行大杂烩”。而充血模型DDD领域驱动设计提倡把业务行为内聚到对象本身。比如订单对象自己提供pay()、cancel()方法状态流转逻辑直接写在实体里。这样做的好处是业务规则不容易散落到各处坏处是学习和重构成本高。从封装的角度看行为放哪不是最关键关键是行为的一致性。如果你在Service里处理订单状态流转那所有关于状态变更的判断就应该都用Service的方法而不是在这里改一下、那里又直接set字段。封装的本质不是规定“行为必须放在类内部”而是要求“同一份数据的所有操作路径必须收敛、可控”。3.3 封装与“面向接口编程”的关系对象封装到一定程度后外部拿到的不应该是具体的实现类而应该是抽象接口。Java里经典的例子就是ListListString list new ArrayList(); list new LinkedList(); // 随时可以换实现调用方只依赖List接口的add、get等抽象方法底层是ArrayList还是LinkedList调用方完全无感知。这就是封装带来的“解耦”能力——隐藏了具体数据结构的差异。实际项目中“接口封装”这个词经常被提尤其在后端服务层设计、前端请求层设计里。你封装一个Service接口对外暴露业务方法对内隐藏事务逻辑、缓存策略、数据来源。调用方把Service当黑盒用这就是封装在工程协作中最大的价值。4. 访问控制的艺术四个访问修饰符到底该怎么用4.1 四个级别的访问范围用场景说话Java提供了四级访问控制从宽到窄public任何地方都能访问通常是类对外发布的API。protected同包内可见且不同包的子类可见。常被误解为“子类私有”其实同包其他类也能访问。默认包级私有无修饰符仅同包可见适合内部协作类。private仅类内部可见最严格的封装。实际开发中我对这四个级别的使用场景做了个经验总结修饰符推荐使用场景常见误用private字段、内部辅助方法、常量配合final滥用private导致子类无法扩展默认包内公用工具类、包内协作DTO外部包无法直接使用被迫用publicprotected模板方法模式中的钩子方法、抽象类扩展点被外部非子类类访问同包publicService接口、Controller入参、工具类入口实体类字段全部public一个我在代码评审时反复强调的原则默认用private和包级私有明确需要对外用public只有子类需要扩展时才考虑protected。很多新人一上来就写public getter/setter大合集虽然能跑但把类变成完全敞开的结构后续很难控制。4.2 为什么getter/setter不能无脑生成这里要重点说下IDE的“Generate Getters and Setters”快捷键。它确实方便但也带来了一个坏习惯不管需不需要字段都生成getter/setter。我曾经接手过一个老项目一个Customer类有30多个字段每个字段都有getter和setter然后业务代码里直接customer.setName()到处改。后来需求调整name字段要和另一个字段联动校验结果改了setter方法后发现有三处地方直接通过反射绕过setter改字段因为历史原因用了BeanUtils.copyProperties导致校验全部失效。经验是getter尽量都给因为读操作风险低setter要谨慎不要对每个字段都生成无脑setter。有些字段应该只能在构造器中初始化有些字段只能通过业务方法修改暴露setter反而留下了“绕过业务规则”的口子。比如订单状态字段就不该有setter而应该有pay()、cancel()这样的业务方法内部校验状态流转合法性。这才是封装的正确姿势——暴露行为隐藏状态。4.3 包结构设计对封装的影响Java的默认访问权限无修饰符和protected都依赖“同一个包”这个条件。所以包的划分直接影响了封装策略。常见的坏味道是所有类都放在同一个大包里导致默认访问权限完全失效protected也变得没意义。更合理的做法是按业务模块建包包内部使用默认权限的类作为实现细节包外只暴露接口或facade类。举个例子一个用户模块可以这么组织com.example.user ├── api │ └── UserService.java // public 接口 ├── domain │ ├── User.java // public 实体 │ └── UserRepository.java // 包级私有只在内部使用 ├── impl │ └── UserServiceImpl.java // 包级私有或protected实现这样外部只能拿到User和UserService内部实现细节全被包裹在包边界内。这个设计有个隐性好处代码审查的时候只要看api包和domain包就能清楚这个模块对外提供了什么能力不用深入impl包去翻实现。5. 构造器设计对象初始化的第一道封装防线5.1 无参构造器、全参构造器、静态工厂方法怎么选对象如何被创建是封装设计中容易被忽视的一环。很多人的习惯是写一个无参构造器然后用setter逐字段赋值。这样写的缺点是对象创建出来的一瞬间可能是不完整的、非法的。比如用户对象要求必须同时设置userId和userName如果用无参构造器加setter调用方完全可以只set一个字段产生一个半成品对象。而这种半成品对象流向业务层往往就是空指针和隐蔽bug的来源。我推荐的做法是分场景选择必填字段多、字段之间有约束关系用全参构造器或静态工厂方法让对象从出生就是合法的。字段需要分步设置用建造者模式Builder既保证完整性又提升可读性。创建成本高、逻辑复杂用静态工厂方法比如User.createWithAdminRole()把创建逻辑封装到方法里。Builder模式在Java里已被广泛使用Lombok的Builder注解让实现成本几乎为零。但要注意Builder本质上是把setter的“不完整性”问题转移到了build()调用时所以build()方法最好做一些全量校验。5.2 构造器里的防御性逻辑该放什么、不该放什么构造器里可以放校验逻辑但别放复杂业务逻辑。一个反例是有人把“保存数据库”、“发送通知”这种操作用在构造器里这个对象new出来就开始副作用操作非常难测试和维护。构造器该做的事有三件参数合法性校验非空、范围、格式。字段赋值必要情况下做防御性拷贝。初始化不可变依赖比如final字段。看个例子public class Money { private final BigDecimal amount; private final String currency; public Money(BigDecimal amount, String currency) { this.amount Objects.requireNonNull(amount, amount must not be null); this.currency Objects.requireNonNull(currency, currency must not be null); if (amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(amount must be non-negative); } this.amount amount; this.currency currency; } }这里用final修饰字段配合构造器校验创建出来的Money对象从出生就是合法且不会变的。这种“不可变对象”的设计可以说是封装的极致——状态一旦确定后续永远无法被篡改。5.3 防御性拷贝除了基本类型都要小心这里单独列一节因为这个坑太经典了。看这段代码public class Student { private final ListString courses; public Student(ListString courses) { this.courses courses; // 直接把外部list赋给了内部字段 } public ListString getCourses() { return courses; // 直接把内部list返回给了外部 } }问题在于构造器里传入的list和字段指向同一个引用外部改了list内部也跟着变。getter返回的list也是一样调用方拿到后往里面加元素内部状态就被破坏了。正确做法是构造器里进行防御性拷贝public Student(ListString courses) { this.courses new ArrayList(courses); // 拷贝切断引用 } public ListString getCourses() { return Collections.unmodifiableList(courses); // 返回不可变视图 }简单说凡是引用类型的字段进要拷出也要拷。这个原则特别适用于集合、数组、Date类型。面试中经常考的“为什么getter返回集合要用unmodifiableList”本质就是封装的一致性边界保护。6. 不可变对象与空值处理把“异常态”挡在对象之外6.1 为什么不可变对象是封装的“终极形态”Java核心库里的String、Integer、BigDecimal都是不可变对象。不可变的好处是天然线程安全状态不变并发读写都没有数据竞争问题。可安全共享同一个实例可以随便传给任何人不怕被改坏。适合作为Map的key或缓存对象哈希值不会变。设计不可变对象的标准套路是字段全final、类用final修饰防止被继承后修改行为、构造器里全量赋值并做防御性拷贝、不提供任何setter、getter返回不可变副本。这个套路本身不复杂难的是养成习惯。我参与过一个订单系统最初的Order实体被设计成可变对象代码里到处都是order.setXxx()。后来要对订单做复杂的缓存和异步处理发现线程安全问题非常难处理最后花了一周时间把所有业务逻辑重构成“每次状态变更生成新对象”的模式复杂度显著降低。所以在能接受“每次变更产生新对象”的性能场景下我强烈建议字段多的核心领域对象优先考虑不可变设计。性能上多创建几个小对象对JVM来说成本很低换来的是安全性和可推理性的巨大提升。6.2 Optional不是用来做字段封装的热词里出现了“optional对象操作”这里必须说一点Java 8的Optional不是用来做字段类型的。很多人喜欢在实体类或DTO里写private OptionalString name;这是把Optional用错了地方。Optional的设计初衷是作为返回值强制调用方处理“可能没有值”的情况。作为字段类型不仅不能提升封装性反而会导致序列化异常Jackson等框架对Optional的支持并不完善、性能损耗和代码混乱。正确做法分两种如果是“值可能不存在”的查询结果用Optional做返回类型。如果是字段本身的可选性用null配合Nullable注解或者用空对象模式Null Object Pattern。空对象模式举个例子与其让方法返回null表示“客户不存在”不如返回一个Customer.EMPTY对象字段都是安全的默认值。这样调用方就不需要到处判空。关键是EMPTY对象的内部实现要足够“结实”——所有getter返回默认值所有业务方法返回无害结果。6.3 判断对象为空的正确姿势很多团队在代码里到处写if (object ! null)看起来很常见其实是一种坏味道。更合理的做法是用Objects.requireNonNull在构造器/方法入口做参数校验让“不允许为空”尽早暴露。用Optional.ofNullable包装可能为null的返回值把“可能为空”显式化。用Collections.emptyList()/emptyMap()而不是null让集合字段永远不会出现“为空还要继续调用”的尴尬。判断对象为空的工具类我推荐使用Apache Commons Lang的ObjectUtils.isEmpty()或者自己写一个简单的静态工具方法统一判空入口。切忌每个类里自己写一套风格不一致后面维护起来非常痛苦。7. 实战如何设计一个符合封装原则的订单对象7.1 需求场景与初版设计光讲理论容易飘来看一个具体的完整案例。假设我们要设计一个订单系统的核心领域对象需求如下订单状态待支付、已支付、已取消、已退款。状态流转规则待支付 - 已支付待支付 - 已取消已支付 - 已退款。订单总金额必须大于0。订单号必须在创建时生成后续不可修改。初版设计是这样的public class Order { private String orderId; private BigDecimal totalAmount; private OrderStatus status; private Date createTime; private ListOrderItem items; public Order(BigDecimal totalAmount, ListOrderItem items) { this.totalAmount totalAmount; this.items items; this.orderId generateOrderId(); this.status OrderStatus.PENDING_PAYMENT; this.createTime new Date(); } public void pay() { if (status ! OrderStatus.PENDING_PAYMENT) { throw new IllegalStateException(当前状态不能支付); } this.status OrderStatus.PAID; } public void cancel() { if (status ! OrderStatus.PENDING_PAYMENT) { throw new IllegalStateException(当前状态不能取消); } this.status OrderStatus.CANCELLED; } public void refund() { if (status ! OrderStatus.PAID) { throw new IllegalStateException(当前状态不能退款); } this.status OrderStatus.REFUNDED; } }这个设计是“充血模型”的典型写法状态流转的规则全部内聚在Order类内部外部只能通过pay()、cancel()、refund()这三个业务方法来触发状态变更不可能绕过规则直接改状态。7.2 这个设计好在哪又有什么隐患好处是显而易见的状态一致性强任何状态下都能判断当前能做什么操作非法操作会直接抛异常。可测试性好不需要mock数据库直接new一个Order调用pay()断言状态变化即可。对外接口清晰调用方只需看Order类公开的业务方法就知道这个对象能干什么。但在实际业务中这个纯领域模型会遇到一个现实问题对象的状态需要持久化到数据库。如果Order没有setterMyBatis或Hibernate进行ORM映射时会遇到麻烦——很多ORM框架依赖无参构造器和setter来做字段填充。我的实践经验是折中方案领域核心对象内部用业务方法封装状态变更同时用Setter(AccessLevel.PRIVATE)Lombok把setter级别设为privateORM框架通过反射仍然能写入但业务代码无法调用。或者设计一个internalSetStatus()方法标注Deprecated并在注释里说明仅供ORM使用。这里有个小技巧关键字段可以提供一个package-private的setter这样只有同包的持久化层类能调用业务代码无法访问。既满足了ORM的写入需求又守住了封装的边界。7.3 结合热词DTO对象的封装与映射实际项目中还有一种常见的封装对象——DTOData Transfer Object。它和领域实体不同DTO更偏向传输层和接口层的“数据载体”。之前热词里出现了“对象转QueryWrapper”、“MapStruct映射对象类型”等话题这里正好展开说下。DTO封装的关键问题是字段该不该复用实体对象很多团队为了减少类数量直接用Order实体接收前端请求、直接返回给前端。这样做的坑是前端的入参字段和数据库实体字段混合字段含义不清晰。实体里加了业务逻辑和校验暴露给前端后序列化输出会包含不该暴露的字段比如密码盐值、数据库内部状态。一旦实体结构调整接口就跟着变破坏API兼容性。更合理的做法是为接口层单独定义DTO/VO对象然后用MapStruct或Bean Copy工具做字段映射。注意源对象和目标对象的字段名要尽量一致能减少配置字段类型不一致时提前定义好转换策略比如Long和String互转时间格式统一。这个做法看似多写几个类实则是把“接口边界”和“领域逻辑”做了一次干净的切分属于对象封装思想在工程协作上的延伸。8. 常见封装误区与排查实录8.1 失误一全链路setter面向可变编程这个前面提过但还是要再强调。团队代码评审时我见过最夸张的类有近40个setter方法几乎每个字段都能改。这种类看似“灵活”实际上是公厕——谁都可以进谁都可以破坏内部状态。排查这类问题有个简单指标看代码里有多少“先new对象、再连写五个以上setter”的写法。如果超过一半的对象创建都长这样基本可以判定这个项目的封装失效了。为什么这么说因为对象创建即应处于合法状态后续用一堆setter来逐步拼装就意味着对象在中间态暴露给了业务代码谁也不知道它是否完整。要缓解这个问题除了之前提过的构造器和Builder还可以用一些代码规范工具比如在自定义checkstyle规则里禁止setter命名针对不允许变的字段或者Code Review的重点检查项。8.2 失误二为了封装而封装getter/setter直接透传还有一种反面情况字段确实用了private但getter/setter里没有任何逻辑直接透传。这种“伪封装”和字段直接用public没有本质区别。真正有价值的封装是setter里有校验或者状态变更通知getter里有格式转换或者缓存。如果一个getter/setter内部就是一行return name;那你应该问问自己这个字段真的需要mutable吗能不能让它在构造器里就确定一个我常用的判断标准是如果对象的某个字段在创建之后永远不会变就别给它setter。比如创建时间createTime创建后永不变给setter的唯一意义就是让外部能篡改时间这在业务里往往是不允许的。8.3 失误三只封装数据不封装行为最后一种典型误区是把类当成纯数据结构行为全部放在Service层。这类问题在贫血模型代码库里很常见。举个例子用户姓名的合法性校验逻辑如果UserName是String字段校验逻辑只能放在Service里每个用到的地方都要写一遍或者抽取到工具类。如果把校验放进一个Name值对象里构造时校验并封装显示格式那所有用到Name的地方自动拥有这个能力。重构一小段代码效果立竿见影public class UserName { private final String value; public UserName(String value) { if (value null || value.trim().isEmpty()) { throw new IllegalArgumentException(用户名不能为空); } if (value.length() 30) { throw new IllegalArgumentException(用户名长度不能超过30); } this.value value; } public String display() { // 脱敏展示等逻辑 return value; } }把“行为”和“数据”绑定在一起是面向对象设计的核心思想也是封装最有价值的应用场景。这样改完之后外部代码不再需要关心用户名格式对不对这种“低级问题”对象自己会拒绝非法状态。8.4 排查实录一次因“封装不彻底”引发的线上问题去年我们团队遇到一次线上事故起因特别讽刺。订单详情接口返回的DTO里有一个pointAmount字段积分抵扣金额但运营同学反馈说前端展示的积分抵扣金额有时是负数。排查过程很顺利追了一圈发现积分模块在计算抵扣金额后对pointAmount做了方向调整取绝对值但有一处业务代码直接调用了orderDetail.setPointAmount(-10)绕过了计算服务也没走校验负值就这么一路传到前端。这个问题的本质不是代码逻辑写错而是DTO的封装不彻底——setPointAmount就是IDE自动生成的getter/setter透传没有任何防御能力。修复方式不是在前端加判断而是在setter里加了非负校验并在积分模块的外层调用链路上用构造器或Builder创建DTO阻断直接setter的路径。类似的问题我建议团队后来都做了统一加固对外DTO中涉及金额、数量、状态的字段setter一律改为包内可见或直接去掉改用Builder构造。这样即使业务代码想绕过规则编译期就过不去。9. Java八股文视角封装相关的面试题怎么答才出彩既然热词里反复出现“java面试题”、“java面试八股文”这里就结合封装主题把最可能问到的几类问题梳理一下。9.1 “什么是封装为什么要封装”这类基础题答得和别人一样面试官无感。建议这样组织先给一句话定义封装是把对象的属性和操作属性的行为绑定在一起并对外隐藏内部实现细节只暴露必要的接口。再举自己项目中的真实例子说明封装前的问题和封装后的改进。最后补充说“封装不是语法层面的private”而是设计层面的信息隐藏和契约稳定。如果能把“贫血模型vs充血模型”、“getter/setter不是封装的唯一形式”这些点讲出来就比背书上那几句强太多了。9.2 “getter/setter破坏了封装吗”这个问题很有意思。有些面试官会故意抛出这个观点既然getter和setter把字段的读写又暴露出来了那它和public字段有什么区别参考答案的核心是区分“数据暴露”和“行为暴露”setter不是必须的正确的封装应该是为“状态变更行为”设计方法而不是为每个字段无脑生成setter。getter虽然能读值但返回值可以做防御性拷贝、可以经过计算、可以脱敏这和直接访问字段有本质区别。真正要问的是外部通过getter/setter拿到的数据是否能被用来破坏对象的内部一致性。如果不会说明封装设计是合理的。9.3 “final、static、abstract对封装有什么影响”这类题考察的是对修饰符语法的灵活运用。总结几句final修饰字段让字段不可变是封装强度的提升。final修饰类禁止继承防止子类改变父类行为。工具类常用。static字段属于类级别的“共享状态”偏离对象封装需要格外小心线程安全。abstract方法定义了子类必须实现的行为契约是封装从“具体实现”向“抽象接口”升级的方式。这几句话展开讲组合起来就是个不错的答题框架从“对象内部状态”讲到“类行为契约”再提到“共享状态带来的风险控制”。9.4 手撕代码设计一个不可变类这个题基本是必考了注意几个点类用final修饰。所有字段用private final修饰。构造器里对引用类型字段做防御性拷贝。不提供任何setter。getter对可变引用返回不可变副本。如果类内包含可变对象字段尤其注意。面试时把这六点背下来然后看着写出来基本能过。最后记得提一句Java核心类String就是按这个模式设计的。已经满足大部分面试要求。10. 最后再说一点关于封装的个人体会做了这么多年Java开发我越来越觉得“封装”不是三分钟能讲完的语法点而是一种需要持续修炼的设计直觉。初学阶段做的是“加private、补getter/setter”的机械动作进阶阶段开始思考“哪些字段该暴露、哪些行为该收敛、对象在什么状态才算合法”到了高阶段你会在系统设计的高度考虑“模块之间通过什么对象交互、对象的粒度怎么划分、怎么让依赖方无法绕过规则”。我见过很多项目代码能跑、功能也齐全但一旦需求变更就变得极其痛苦。改一个字段的默认值要在几十个地方找“哪里直接new了这个类”加一个状态校验发现状态字段被三处代码直接赋值。这些问题的根因都是封装不够彻底——对象没有形成自己的“守护边界”所有内部状态赤裸裸地摊在外面任人搅动。反过来封装做得好的项目你会感觉到一种“安全感”拿到一个对象就知道它能做什么、不能做什么改内部实现不用战战兢兢怕影响外部新增需求时只需要在正确的方法里加逻辑不用担心旁路。这种安全感才是封装带给我们最大的价值。如果这篇文章对你有帮助我建议你回头看看自己手头的代码挑一个经常被到处修改字段的类尝试用本文提到的方法重构一番必填字段放进构造器、状态变更用业务方法收敛、引用类型做防御性拷贝、不可变字段去掉setter。重构完你大概率会和我当年一样感叹原来代码可以这么清爽。