ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java基础进阶:面向对象核心与String、包装类实战指南

Java基础进阶:面向对象核心与String、包装类实战指南 经常有人问我Java基础的上篇讲完语法和流程控制之后下一篇到底该学什么、什么时候才算是“真正入门”。说实话语法部分就算全背下来离“能写代码”还差一大截。因为从这一章开始你的脑子里要装下“对象”这个词所有代码都不再是单纯的从上往下执行而是要开始考虑数据在内存里长什么样、方法之间怎么协作、类和类之间是什么关系。很多初学者就是卡在这一段觉得“看懂了但写不出来”其实就是没把面向对象这件事想透。这篇笔记定位是JAVA基础系列的中篇核心只讲两件事面向对象编程封装、继承、多态、接口与抽象类和日常编码里逃不掉的常用基础类String、包装类、StringBuilder。这两个部分是所有Java项目的地基后面学集合、学框架、学Spring都默认你掌握了这里的内容。适合刚看完语法、准备开始写第一个“类”的读者也适合学过但一直对多态、接口这些概念似懂非懂、想回头补一补的同学。中篇我会尽量用实际代码和对比的方式讲少讲名词多讲道理。1. 对象是怎么在内存里活着的从new开始理解引用1.1 栈、堆、方法区到底谁管谁很多人在初学类与对象的时候能背出“类是模板对象是实例”这句话但一旦问“new一个对象到底发生了什么”就又含糊了。我建议你换个思路学Java对象第一件事不是背概念而是先搞懂内存里的三块区域。栈Stack里存的是局部变量和对象的引用堆Heap里真正存对象数据方法区Method Area新版JDK里叫元空间存类信息、静态变量和常量。当你在代码里写Person p new Person()JVM做的事情是在堆里划一块内存给Person对象然后把这块内存的地址赋值给栈里的p。所以p本质上不是那个人本身而是“指向那个人的一张小纸条”。这张小纸条特别关键。你把p传给另一个方法传递的是纸条的副本但两张纸条指向的是同一个堆内存改数据就会影响原对象。这个坑我在实际项目里见过无数次公司内部框架里有人把一个实体对象传进方法后顺手改了字段结果调用方那边的数据全变了排查半天。生活里最贴切的类比是遥控器与电视。你手上的遥控器只是引用电视才是对象。你按遥控器换台电视真的换了台因为遥控器指向的是那台电视本身。再多的遥控器副本都指向同一台电视可一旦有人把电视搬走置为null任何遥控器都没用了。1.2 栈上分配和逃逸分析可以暂不深究但“引用”概念必须烂熟这一节的目的是让你先建立感知别急着去背JVM调优参数。栈上分配、TLAB、逃逸分析这些名词等你工作后用得上再学完全来得及。中篇阶段你只需要做到三点第一区分基本类型和引用类型。int a 10栈上直接存值String s abc如果走字面量赋值s指向的是字符串常量池里的对象。第二搞清楚null不是空对象而是纸条没贴在任何东西上调用方法会抛NullPointerException。第三方法里new出来的对象如果没被外部引用来接收方法结束后就可能被GC回收这是局部变量的生命周期。初学者最容易出问题的是第一点的极端情况。比如Integer a 127; Integer b 127; a b结果是true但Integer c 128; Integer d 128; c d结果是false。这就是因为Integer缓存了-128到127之间的对象超过范围就得各自new引用不同自然不同。这属于包装类的内容后面会展开讲这里先知道有这回事。2. 封装不是私有字段那么简单从getter/setter看设计取舍2.1 为什么要用private把字段藏起来很多新手对封装有个误解觉得封装就是把字段写成private然后让IDE自动生成getter/setter这样就是“规范”了。但这么做的意义到底是什么答案是控制变化。如果字段是public的所有外部代码都能直接改它的值。今天你想给年龄加个校验逻辑比如不允许负值、不允许大于200那你就得把所有改年龄的地方全找出来一个漏掉就是bug。但如果你提供的是setAge(int age)方法校验只需要写在这一处所有入口都会被拦截。这就是封装的第一个价值限制外部随意修改把不合理的值挡在门外。第二个价值是隐藏内部实现。你的类可以内部重构把某些字段计算后缓存或者从数据库读取只要对外的方法签名不变调用方完全无感知。换句话说封装让“变”的部分收敛在一个类内部让“不变”的部分对外稳定。2.2 不是所有字段都要getter/setter做好信息隐藏这里必须补充一个经验别做成“数据类”即所有字段都只是简单get和set没有任何业务逻辑。纯数据类的存在场景很少比如DTO数据传输对象可以这样但业务实体类最好不要只有一个壳。我在实际开发里见过很多“贫血模型”一个类里全是private字段加getter/setter逻辑全部写在Service里。代码能跑但一旦业务逻辑复杂Service就成了上帝类几千行代码堆在那里。中篇阶段不说这么深但你要有这个意识封装的意思是类自己管理自己的状态向外暴露必要的操作而不是把内部全部摊开。比如一个订单类不应该让外部直接order.setStatus(5)更合理的是order.cancel()把状态流转的规则封装进方法内部。你要做到的练习是写一个类时先问自己这个类的哪些状态不允许外部随便改哪些操作是该类自己负责的。2.3 JavaBean规范要懂但别被规范绑架说到getter/setter就绕不开JavaBean规范类必须是public、提供无参构造、字段私有、通过get/set方法访问。这个规范在反射、序列化、持久层框架MyBatis、Hibernate里被大量使用因为它给框架提供了一个统一的约定框架不需要知道你有哪些字段只需要找get/set方法就能读写属性。所以中篇阶段你写类的时候维持JavaBean风格是好事但你要区分“给框架用的对象”和“有业务逻辑的对象”。前者遵守规范后者别被规范限制住。我的习惯是如果是持久化实体会严格JavaBean如果是服务类或者领域对象就会加入具体业务方法不再无脑生成getter/setter。3. 继承、重写和多态Java对象协作的三板斧3.1 继承关系里的“is-a”到底怎么判断继承是面向对象里最容易被滥用的一块。很多人记住“子类继承父类”这句话就去写代码结果造出一堆毫无意义的父类看到两个类有几行重复代码就先想着继承最后维护的时候苦不堪言。判断是否应该用继承标准是子类是否“是一种”父类。猫是一种动物所以Cat extends Animal合理正方形是一种形状所以Square extends Shape合理。但如果你只是想让两个类共用几个方法比如UserService和OrderService都有日志方法那你应该抽取一个工具类或者公共父类但这里往往组合优于继承。实际编码中还有一个铁律如果父类会变子类的行为会跟着变如果某个方法在子类里不想被继承实现那八成是设计出了问题。继承真正的价值在于“统一处理”它跟多态是配套的。你定义一个Animal类型的引用却指向Dog对象调用animal.sound()的时候执行的是Dog里的声音方法。没有继承多态就无从谈起没有多态继承的价值就砍掉了一大半。3.2 重写的完整姿势权限、返回值、异常都不能乱来方法重写是继承中篇必考也是必用的点。规则其实就几条但每条背后都有原因。第一重写方法的访问权限不能比父类更低。父类方法是public子类就不能改成protected因为“子类应该能替代父类”这是里氏替换原则的通俗表达。想象一下调用方用Animal animal new Dog()如果animal指向的实际对象把public方法降级成private那调用方按public调就会报错。第二重写方法的返回类型可以是父类返回类型的子类型这叫“协变返回类型”。比如父类返回Animal子类可以返回Dog这符合直觉子类更具体。第三重写方法抛出的受检异常不能比父类更宽泛。父类不抛异常子类就不能抛受检异常否则调用方按父类的声明做try-catch就覆盖不了。第四父类方法是private或者final的子类不能重写。private方法对子类不可见你写一个一模一样方法名的方法也只是新方法不是重写。很多IDE在重写方法时会带Override注解这个注解不仅是给人看的也是给编译器检查用的。如果注解下面那个方法不满足重写规则会直接编译报错。我建议所有涉及重写的地方都加上Override既能防止手滑写错签名也能清晰表达意图。3.3 重载和重写的区别用签名一句话说清重载是同一个类里方法名相同、参数列表不同重写是子类和父类之间方法签名完全相同、但实现不同。重载看的是调用时传入的参数来决定选哪个方法这是静态绑定编译期就能确定重写看的是对象实际类型来决定调哪个方法这是动态绑定运行期才确定。举个例子你就懂了public class Parent { public void say(String msg) { System.out.println(Parent: msg); } } public class Child extends Parent { Override public void say(String msg) { System.out.println(Child: msg); } public void say(String msg, int times) { for (int i 0; i times; i) { System.out.println(Child: msg); } } }child.say(hello)走的是重写版本输出Child开头child.say(hello, 3)走的是重载版本。这里最容易错的是重载和重写混在一起时的调用选择。你要是写Parent p new Child(); p.say(hello)运行期依然会调到Child的say因为动态绑定只认方法的签名签名相同的情况下执行哪个实现取决于对象的实际类型。3.4 多态的实际价值让代码对扩展开放多态最典型的使用场景是“面向父类编程”。你定义一个方法参数是父类类型传入任意子类都能工作public void makeSound(Animal animal) { animal.sound(); }调用的时候不管传Dog还是Cat都能正确发出对应的声音。新增一个Bird类只要继承Animal并实现sound()现有代码一行都不用改。这就是开闭原则的精神对扩展开放对修改关闭。如果你不理解多态很容易写出满屏if-else的代码。比如按类型分别处理if (animal instanceof Dog) { ... } else if (animal instanceof Cat) { ... }这种代码每增加一种动物就要改一次。用多态就不一样了所有动物子类必须自己提供sound()的实现外部调用者跟具体类型解耦。我在项目里做策略类需求时也常这样处理把不同策略抽成一个接口各有各的实现类业务代码里用接口类型去接收新增策略只需加一个实现类。4. 接口与抽象类别再傻傻分不清选型要看场景4.1 抽象类的核心意义是“抽出共性”抽象类用abstract修饰可以有抽象方法也可以有具体方法最关键的是它不能被实例化。为什么不能new因为抽象类本身就是不完整的它描述的是“一部分行为”但还没到能独立存在的地步。比如你定义一个Animal抽象类里面有sound()抽象方法和eat()具体方法。Cat和Dog都继承它Cat里实现sound()叫喵Dog里实现sound()叫汪但是eat()的公共逻辑比如咀嚼、吞咽可以在抽象类里直接写。这就是抽象类的定位把公共状态和行为沉淀到父类把不确定的行为留给子类。从设计上看抽象类适合“强血缘关系”的类族。子类一定是父类的一种父类里可以保存状态比如Animal有名字字段、腿数量子类直接继承。这是接口很难替代的地方接口无法定义实例字段只能定义常量。4.2 接口的演变从“纯抽象约定”到“带默认实现”早期的接口只有抽象方法没有字段也没有方法体。这种设计的意图很纯粹接口只是一种合同约定实现类必须提供哪些能力至于怎么实现不管。但JDK 8开始接口引入了默认方法和静态方法。默认方法用default关键字可以在接口里给出一个通用的方法实现实现类可以选择不重写直接使用。引入这个特性的背景是集合框架的改造要给已有接口加新方法又不能强制所有实现类都去实现于是给了默认实现。这带来一个容易混淆的点接口和抽象类越来越像都有具体方法了区别到底在哪我的理解是抽象类解决的是“是什么”接口解决的是“能做什么”。一个类只能继承一个抽象类但可以实现多个接口。在Java的单继承体系下接口就是你的扩展通道。实际项目里我的选型习惯是如果多个类有公共状态和公共方法且有强父子关系用抽象类如果只是要定义外部能力、允许多种实现、或者需要跨类族扩展用接口。举个具体的例子做一个支付模块支付宝支付和微信支付并没有血缘关系但它们都需要“付款”能力这时候用Payment接口更合理而不是造一个AbstractPayment抽象类。4.3 接口默认方法多继承冲突怎么处理多实现接口的另一个问题如果两个接口有相同的默认方法实现类必须重写解决冲突。比如接口A和接口B都定义了default void test()实现类C同时实现A和BC必须重写test()否则编译报错。重写时可以指定调用哪个接口的默认方法public class C implements A, B { Override public void test() { A.super.test(); } }这个规则是Java的“菱形问题”处理策略。虽然Java不允许多继承类但接口默认方法的引入还是带来了类似的冲突场景。平时写接口时我建议少用默认方法除非真的是为了给所有实现类提供一个公共、稳定的兜底能力。默认方法用多了会让接口变得臃肿失去“合同”的纯粹性。4.4 JDK 8的函数式接口是懂多态后才能看懂的东西中篇可以不深入Lambda表达式但函数式接口值得提前认识一下。函数式接口是只有一个抽象方法的接口比如Runnable、Comparable。这个单一抽象方法的限制是为了让接口能用Lambda表达式直接实例化。当你理解了“接口是能力约定”之后再看Comparator接口就不难了。它是一个抽象方法compare(T o1, T o2)你可以写Comparator.comparing(User::getAge)来构造一个年龄比较器底层其实就是创建了一个实现Comparator接口的匿名实现类只是Lambda简化了写法。所以学Java基础阶段先别急着背Lambda语法先把“接口作为类型使用、接口可以被实现类多态替换”这件事吃透。有了这个概念你去读Spring源码里大量基于接口的依赖注入就不会慌了。5. String、StringBuilder与包装类日常编码使用频率最高的基础类5.1 字符串不可变到底有什么好处String是不可变类每次修改都会生成新对象。很多新人嫌它浪费内存但其实不可变性是Java设计者的有意为之好处非常实际第一字符串可以安全共享。常量池里的同一个字面量可以被多个变量引用比如String a hello; String b hello;二者指向同一个对象省内存。如果字符串可变一个变量改了其他变量全被牵连。第二字符串适合做Hash键。HashMap的key如果是可变对象hashCode变了就再也找不到原来的键值对了。String不可变它的hashCode可以缓存起来每次调用不用重新计算这也是String作为Map键的首选原因。第三字符串可以被安全地用于类加载、网络协议、数据库驱动等需要传输和校验的场景不会在中途被其他线程修改。5.2 字符串拼接的性能对比、concat、StringBuilder到底差在哪很多人习惯用拼接字符串代码写起来确实爽。但要分场景讲字符串常量拼接比如hello world编译期JVM就会优化成一个字符串这种没性能问题。变量拼接比如在循环里str s每次循环都会new一个StringBuilder然后toString再丢掉循环一万次就新建一万次对象性能极差。正确做法是把循环体内的StringBuilder提取到循环外StringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String result sb.toString();StringBuffer和StringBuilder的区别是前者线程安全方法加了synchronized但正因为加锁单线程下性能比StringBuilder差。绝大多数业务场景都是单线程拼接默认用StringBuilder只有在并发环境下共享缓冲区时才考虑StringBuffer。5.3 字符串比较稳定用equals别秀“”操作这个坑值得单独拉出来说。比较的是引用是否指向同一个对象equals比较的是内容是否相同。看一眼代码String s1 new String(hello); String s2 new String(hello); System.out.println(s1 s2); // false System.out.println(s1.equals(s2)); // trues1和s2分别new了两个对象地址不同所以是false。但如果直接s1 hello; s2 hello;因为常量池的原因两个变量指向同一个对象反而是true。这种差异让“看起来对的代码”在不同写法下结果不同防不胜防。我的建议非常绝对只要是比较字符串内容一律用equals不要用也不要为了秀操作去用intern()。字符串常量池虽然能做引用相等但它不是给你做业务比较用的硬凹这个操作只会埋雷。5.4 包装类的缓存范围与拆箱空指针Java为每种基本类型都提供了包装类比如Integer、Double、Boolean。包装类让基本类型可以参与面向对象的操作比如放入集合因为集合只能放对象。Integer的缓存范围是-128到127源码里写死了low为-128high默认127可以通过JVM参数调上限。这个缓存意味着在此范围内Integer.valueOf(100) Integer.valueOf(100)为true超过范围则false。你最好把这个范围记住出现在选择题和面试题里都是高频考点。更危险的是拆箱。包装类变量可以是null拆箱成基本类型时就会抛NullPointerExceptionInteger count null; int c count; // 运行期NPE遇到三目表达式更要小心。比如Integer a null; int b 1; Integer result (b 0) ? a : b;这个表达式会先自动拆箱a到int类型然后自动装箱成Integer。a是null拆箱阶段直接NPE代码挂在不起眼的位置。我的习惯是一切可能为null的包装类先判空再参与运算除非你非常确定值不为空。6. 中篇阶段常见问题速查与避坑技巧6.1 高频报错场景整理成表这一阶段学员问我的问题里出现频率最高的是下面这些我直接整理成表方便你对照排查报错或异常常见原因解决办法NullPointerException引用未初始化就调用方法包装类直接拆箱先判空再操作必要时使用OptionalClassCastException向下转型前未用instanceof判断转型前检查类型或尽量用多态避免强转重复代码爆多滥用复制粘贴没有抽象父类或接口提取公共方法、公共父类或工具类编译报错重写冲突两个接口默认方法名相同实现类重写并指定调用哪个接口默认方法循环内字符串拼接极慢在循环体内不断新建StringBuilder把StringBuilder提到循环外数值比较结果诡异包装类用了比较比较数值一律用equals或调用intValue子类重写后调不到父类父类引用调用了子类重写的方法误以为是父类逻辑搞清楚动态绑定方法执行看实际对象类型不看引用类型hashCode和equals不一致重写equals但没重写hashCode凡是重写equals必须同步重写hashCode6.2 使用内部类、匿名类和Lambda的注意点如果你已经开始接触内部类有几个点容易踩坑。非静态内部类持有外部类的引用这会导致外部类无法被垃圾回收。比如外部类是Activity内部类长期存活外部类就一直被引用着可能引发内存泄漏。解决办法是用静态内部类或者把内部类需要的字段单独传参。匿名类和Lambda的区别主要是有没有对外部变量的修改需求。Java要求匿名类和Lambda捕获的外部局部变量必须是“事实不可变”的也就是一旦赋值不能再修改。所以你在Lambda里试图给一个外部int变量累加编译直接报错。替代做法是用原子类比如AtomicInteger或者改用数组元素来绕过限制。我不推荐硬绕过最好换一种设计思路从根上避免修改外部变量的需求。6.3 我建议你务必动手完成的三个练习中篇内容光看不行我给几个闭眼能练的题目你照着敲一遍比读十遍文章都管用第一手写一个Animal继承体系包含抽象类、子类、重写方法、多态调用再把Override去掉看编译器什么反应。通过这个练习把继承和多态串起来。第二写一个接口PayService实现AliPayService和WechatPayService模拟下单支付逻辑字段用private并用构造方法赋值感受接口契约与具体实现的拆解。第三设计一个包含字符串列表的类方法里用StringBuilder拼接、用equals做内容比较写完后故意用替换并观察结果差异把包装类和字符串的关键坑亲自踩一遍。6.4 我踩过几次坑之后的经验总结实际上很多人学到这里开始接触IDE里的类结构图、调试器、断点。我建议你从这里开始认真学会用调试器。当你看不懂代码执行到哪一步、对象是什么值、引用指向谁的时候断点比任何讲解都直观。尤其是多态那段把断点停在调用sound()的那一行用调试器的“查看对象”功能看实际类型比背一百遍“动态绑定”都有用。另外提一句学习方法上的体会不要为每一个类都加抽象父类或者接口。中篇的继承、抽象类、接口这些概念是给你工具不是让你每段代码都用上。什么时候该用标准是代码有没有“变化点”。当你发现某段逻辑会在两三个地方出现不同的实现或者一个if-else分支开始膨胀那时候再考虑抽象。过早的抽象是过度设计过晚的抽象是代码腐化。这个度把握不住也没关系写过几次、难受过几次自然就平衡了。这个系列的上篇和中篇学完后你已经能够写出结构完整的面向对象代码了。下一篇安排的是集合框架、泛型和异常处理那时候会大量用到中篇的接口体系——比如List本身就是一个接口ArrayList只是它的实现。所以中篇这些基础打得越扎实下一章越顺利。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进