ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java类与对象:从new到内存回收的完整实践指南

Java类与对象:从new到内存回收的完整实践指南 1. 先把概念捋直类不是对象对象也不只是一个模板做出来的东西很多Java初学者在学面向对象的时候第一个卡住的地方不是语法而是类和对象到底啥关系这个看似简单的问题。我面试过不少简历写着熟悉面向对象编程的候选人一问类是什么对象是什么能真正答清楚的寥寥无几。最常见的回答是类就是模板对象就是根据模板创建出来的实例这个说法不算错但它只回答了现象没有回答本质导致后面写代码时一遇到抽象的设计问题就发怵。类Class在Java里的本质是一种对一类事物的抽象描述。它描述的不是某个具体的东西而是这一类东西共同拥有的状态和行为。比如说学生这个概念我们知道学生有学号、姓名、班级有上课、考试的行为——但学生本身不是一个实体它是对所有学生共同特征的归纳。对象Object则是这个抽象描述的具体落地张三是学生学号2024001在软件工程二班期末考试考了85分。张三就是一个对象他拥有学生这个类描述的所有结构但被填充了具体的、属于自己的值。我更喜欢用合同和签了字的合同来类比。类是一张空白的合同模板上面规定了哪些栏目必须有字段、合同能执行哪些约定方法但栏目里还没有内容对象是这张合同被具体签署之后的版本每一项都填上了真实的数据。同一张模板可以签出无数份内容不同的合同同一个类也能new出无数个状态不同的对象。这就是类和对象最核心的区别类是定义对象是定义的具体化。进一步说Java要求所有代码都写在类里所有操作都通过对象来完成除了静态成员后面会单独讲这不是Java故意为难你而是它把面向对象作为语言的基本世界观程序是由互相协作的对象组成的而不是由一条条从头到尾执行的指令组成的。你写的每一个new User()都是在向JVM申请一块内存用来承载User这个类描述的所有字段然后把这块内存的地址交给你让你可以通过这个地址去操作数据。这个过程我在下一节会拆开讲但先记住一句话类是代码层面的存在对象是运行时内存里的存在。另一个容易混淆的点是对象和变量不是一回事。User user new User()这行代码里user是一个变量它保存的只是对象在内存中的地址引用对象本身在堆内存里。这就像你把好友的手机号存在通讯录里通讯录里的条目和好友本人是两回事。搞清楚这一点后面理解垃圾回收、浅拷贝深拷贝、方法传参都会顺畅很多。2. 从new到对象落地构造方法、初始化顺序和内存分配2.1 new一行代码背后发生了什么很多初学者把new当作创建一个对象的口令会写就行从来不问为什么new User()这个表达式返回之后就能得到一个可用的对象。我建议每个Java开发者至少把new的过程在脑子里过一遍因为很多诡异Bug的根源恰恰是你以为你对对象做了初始化但初始化根本没执行到。执行new User()时JVM大致做了四件事类加载检查如果User类还没有被加载到JVM第一次使用这个类时会先触发类加载把.class文件里的字节码读进内存并执行静态变量的初始化和静态代码块。分配内存在堆内存里划出一块区域大小正好能装下User的所有实例字段对象头、对齐填充这些先不管原理上有这个概念就行。实例字段默认初始化这块内存会被清零所以int字段默认是0boolean是false引用类型是null。这是语言层面的保证即使你没写初始化代码字段也有一个确定的初值不会出现未初始化的随机值。调用构造方法执行你写的构造方法或默认构造方法里的代码把对象真正设置成你期望的状态。这种默认清零机制是个非常重要但在实际工作中容易被忽略的点。我见过一个线上问题某团队把新的订单状态字段加进实体类后忘了在新创建的订单里显式设置状态结果查数据库发现所有新订单状态是0默认值直接导致下游判断全部走了错误分支。这不是JVM的问题是没理解默认初始化只保证有值不保证值正确。2.2 构造方法的重载与this()调用构造方法是对象创建的入口设计不好整个类的易用性都会受影响。构造方法的名字必须和类名一致没有返回值这是语法层面的强制规则。它最大的价值在于强制调用者提供必要的初始数据。一个User类如果没有学号、没有姓名后面所有逻辑都没法跑那你就不应该提供new User()这种无参构造逼着调用方传参数进来。Java允许一个类里有多个构造方法只要参数列表不同就行这叫作构造方法重载。比如User()、User(String id)、User(String id, String name)可以共存调用时编译器根据参数数量和类型自动匹配。这种设计非常实用但我发现不少人在重载时会写出大量重复代码public User() { this.id 未知; this.name 匿名; } public User(String id) { this.id id; this.name 匿名; } public User(String id, String name) { this.id id; this.name name; }三个构造方法里都有字段赋值的逻辑一旦字段增加或规则变化就要改动三处。正确的做法是用this()在构造方法之间相互调用只留一个核心构造方法做真正的初始化public User() { this(未知, 匿名); } public User(String id) { this(id, 匿名); } public User(String id, String name) { this.id id; this.name name; }这里有个语法细节必须注意this()调用必须写在构造方法的第一行否则编译器直接报错。原因也好理解你想让本构造方法在核心构造方法的基础上补充逻辑那就必须先让核心构造方法完成基础初始化顺序不能乱。这也是我经常强调的对象初始化顺序问题的一个侧面。2.3 初始化顺序静态块、实例块、构造方法的执行次序除了构造方法类里还能写静态代码块static {}和实例代码块{}它们都会在特定时机执行。面试爱考实际项目里也偶尔会遇到比如静态资源初始化、工具类注册但很多人在写的时候根本没意识到顺序问题导致初始化Bug。完整的初始化顺序是这样的加载类时先执行静态变量赋值和静态代码块且只执行一次每次创建对象时先执行实例变量赋值和实例代码块最后执行构造方法体的代码。看个例子就清楚了public class InitDemo { static int staticValue initStatic(); int instanceValue initInstance(); static { System.out.println(静态代码块执行); } { System.out.println(实例代码块执行); } public InitDemo() { System.out.println(构造方法执行); } static int initStatic() { System.out.println(静态变量初始化); return 1; } int initInstance() { System.out.println(实例变量初始化); return 2; } }第一次new InitDemo()时控制台输出顺序是静态变量初始化 - 静态代码块执行 - 实例变量初始化 - 实例代码块执行 - 构造方法执行。第二次再new时静态部分不再输出只有后三步。这就是静态成员属于类、只有一份实例成员属于对象、每new一个都重新初始化的直接体现。我实际项目里用到这个特性的场景是一个配置中心客户端需要保证全局只有一个连接实例所以它的构造方法是私有的通过静态方法getInstance()返回单例对象同时用静态代码块做连接参数的预加载。理解了初始化顺序你才能控制好哪些东西必须提前准备哪些东西可以延迟到创建对象时再处理。3. 类成员怎么设计才不容易翻车static、final、访问权限的实际取舍3.1 static成员的归属陷阱static这个关键字在Java里很容易被滥用。初学阶段很多人图省事把什么都写成static因为不用创建对象就能直接调用真方便。但搞清楚static的本质职责后你会发现很多场景根本不该用它。static修饰的成员属于类本身不属于任何一个对象。它是所有对象共享的一份数据放在方法区或元空间的类信息里和对象在堆里的内存是分开的。这就是为什么静态方法里不能直接访问实例字段、不能调用实例方法——因为静态方法执行时根本不知道当前是哪个对象。我见过最典型的翻车场景是有人写了一个工具类里面放了一个static变量做计数器结果在Web应用里发现计数错乱。原因就是static变量全局只有一份多线程并发操作时互相覆盖而且所有请求线程共享这同一个数据根本不是每个业务场景单独计数的预期效果。static变量天然就是全局状态全局状态在哪门语言里都是麻烦能不用尽量不用。那static该用在哪我的经验是三个方向常量public static final String STATUS_SUCCESS SUCCESS这是static最常见的合理用法Java常量约定用static final组合。纯工具方法比如Math.max()、Objects.isNull()方法的执行结果只依赖参数不依赖任何实例状态也没有副作用。单例模式的静态持有通过static字段保存全局唯一的实例比如数据库连接池。如果一个方法里有static成员变量的读写那这个方法就不太可能是纯工具方法了你要警惕这个信号。3.2 final修饰变量和对象引用的区别第二个容易踩坑的是final。很多人以为final int[] arr new int[3]之后这个数组就不能修改了其实大错特错。final修饰引用类型变量时保证的是这个引用的指向不可变也就是你不能再把arr重新指向另一个数组但arr[0] 100是完全可以的数组里的内容变了引用还是那个引用。同理final User user new User(张三); user.setName(李四); // 合法对象内部状态可以改 user new User(王五); // 编译报错引用不能重新赋值这个特性和不可变对象是两个概念。要让一个对象真正不可变你还需要所有字段都final且没有setter、类不被继承用final修饰类、保证没有方法把内部可变对象暴露出去。JDK里的String就是典型不可变类所以它才能安全地被用作HashMap的键、缓存大量共享。在项目里我推荐尽量用final修饰方法参数和局部变量一方面是给阅读代码的人明确信号这个变量中途不会换指向另一方面能减少不小心的重复赋值错误。编译器的帮助越早介入运行时的问题就越少。3.3 访问权限控制不是给别人添麻烦是给自己省麻烦Java的四种访问修饰符private、default包级、protected、public面试背起来很简单实际设计时却经常乱用。最常见的问题是把所有字段都写成private、所有方法都写成public看上去封装得很好实际上类变成了一个没有任何控制的门洞。访问权限的设计原则应该是字段尽可能private对外暴露的方法尽可能少暴露出去的方法尽可能稳定。为什么要强调暴露的方法要稳因为一旦你把这方法发布出去了调用方就会依赖它将来想改内部实现就得先处理所有调用点。我接手过一个老项目有一个公有方法getUserInfo()返回Map内部一开始装的是姓名和电话后来业务要加地址调用方有的直接改Map内容、有的按固定key取数一改就全乱。如果当初设计成返回一个带字段的用户对象或者提供一个getAddress()的专用方法后续扩展会优雅得多。private的意义也常被低估。它在Java里不只是外部不能访问的开关更是类的实现细节隔离。类内部怎么组织字段、怎么缓存数据、怎么保证数据一致性只要private就能自由调整不惊动外部世界。所谓封装本质就是这种把自己变化关在门里、对外留下稳定接口的能力。4. 对象的内存生命周期栈上引用、堆上对象与GC回收时机4.1 引用与对象的关系拿遥控器看电视对象创建之后它不是孤立存在的而是通过引用被代码使用。栈和堆的分工很多资料都写过但我还是想用最朴素的方式再说一遍栈上存的是局部变量堆里存的是对象本体局部变量保存的是对象在堆里的地址。就像遥控器存在你手里栈里的引用电视机放在客厅堆里的对象你通过遥控器按键来控制电视而不是把电视搬到手里。这里有一个初学者经常误解的点User a new User()和User b a之后a和b是两个变量但它们指向的是同一个对象。所以b.setName(李四)会影响a看到的数据。这不是Bug这是Java引用的正常行为。如果你想要两个独立的对象就得自己再new一个然后把a的字段挨个复制过去或者用后面的深拷贝方案。这个特性在开发里既是便利也是坑。便利在于把对象传给方法时方法里改的就是原对象省去了返回值回传的麻烦后面讲到值传递时还要细化。坑在于不同代码块持有同一个对象的引用时很容易出现我没改这个对象怎么数据变了的诡异问题其实是有别的引用在改。4.2 对象什么时候会被回收Java不像C/C需要手动释放内存JVM的垃圾回收GC会自动回收不再被使用的对象。但不再被使用的判断标准是什么简单说从GC Roots出发没有任何引用链能到达这个对象时它就成为垃圾了。GC Roots包括栈上的局部变量引用、静态变量引用、活跃线程等。这就像电视台的信号源遥控器都没了、信号线都拔了电视机自然就是台废电视可以请师傅搬走。这个机制给开发者的启示是别把长期生存的对象塞进不必要持有的集合里。最常见的失误是在实例对象里维护一个ListListener往里添加监听器但从不移除导致这些监听器一直引用着某些大对象GC回收不掉内存占用越来越高最终OOM。这类问题的根因不是内存不够而是对象生命周期管理失误。我在项目里排查过一次内存泄漏一个全局缓存Map的value是某个业务上下文对象每次请求结束都要往Map里塞一份却忘了按业务键删除导致上下文对象越积越多。降低堆内存后虽然能缓解症状但正确的修复方式是确认Map的清理逻辑要么在任务结束时显式删除要么用带过期时间的缓存组件。4.3 深拷贝浅拷贝拷贝出来的到底是不是同一个对象java对象深度拷贝是高搜索词也是实际开发里真容易出问题的地方。浅拷贝和深拷贝的区别一句话就能说清浅拷贝新对象复制了原对象所有字段的值。但如果是引用类型字段复制过来的只是那个引用——新旧两个对象的纸不同但纸上引用的人是同一个。深拷贝连引用指向的对象也重新复制一份新旧对象之间彻底独立。Java里Object.clone()默认是浅拷贝而且需要实现Cloneable接口否则会抛CloneNotSupportedException。这在设计上算是个notorious标记接口用起来很不方便。我不太推荐在业务代码里靠重写clone()做深拷贝容易漏掉某个嵌套引用。更稳妥的方案是序列化拷贝比如把对象转成JSON再转回来或者用JSON工具库的复制方法// 用Jackson做深拷贝 ObjectMapper mapper new ObjectMapper(); User newUser mapper.readValue(mapper.writeValueAsBytes(oldUser), User.class);这种方案的缺点是性能比手工复制差但对于数据结构不太变态的业务对象正确性远比那点性能更重要。还有一点要记住深拷贝之后newUser oldUser一定是false因为它们是两个完全不同的对象但newUser.equals(oldUser)可能是true取决于你有没有重写equals下一节讲。5. 实战里最常见的对象翻车现场判空、值传递、equals与5.1 判断对象为空很多人第一步就写错判断对象为空这种基础操作在高搜索词里居然排得上号说明它确实困扰过不少人。Java里判断对象是否为null标准写法就是user null这么简单但实际项目里有几个衍生问题第一个是工具类的选择。Java 7之后推荐用Objects.isNull(obj)和Objects.nonNull(obj)比手写obj null更语义化而且能配合Stream和Optional使用。不过别过度用Optional.ofNullable(...).ifPresent(...)嵌套——Optional的本意是让调用方意识到值可能为空不是让你写更长的链式调用。第二个是判空和判空内容是两回事。一个List对象不为null不代表它里面有元素可能是空集合一个String不为null不代表它有内容可能是空字符串。我见过有人写if (list ! null)就认为list里有数据结果循环体完全不执行排查半天发现list.size() 0。成熟的写法是先判null再判list.isEmpty()或者用CollectionUtils.isEmpty(list)这类现成工具。第三个是Java 8引入的Optional很多人以为有了它就不用判空了其实它更合适的定位是用在方法返回值上让调用方醒目地知道这个结果可能不存在而不是在每个参数上都包一层Optional。5.2 方法传参Java到底是值传递还是引用传递这个几乎是Java面试必考题而且网上争论不休。我的结论先说Java只有值传递。传入方法的是变量的值而引用类型变量的值就是对象的内存地址。所以方法里修改引用指向的对象时外部能看到效果但方法里试图让引用重新指向另一个对象时外部完全不受影响。public void changeUser(User user) { user.setName(李四); // 外部user.name变为李四因为改的是同一个对象 user new User(王五); // 外部user仍然指向原来的对象不受影响 }很多人误以为能改对象内容就说明是引用传递其实这是把对象本体和引用变量混为一谈了。引用传递意味着方法能改变外部变量本身的指向Java做不到。理解这一点对排bug很重要如果方法里重新给参数赋值不要指望能把这个新对象带出去要么return回去要么把结果放进一个容器对象里。顺带说一个我在项目里踩过的坑有一个老系统的方法签名是void process(ListString list)里面判断业务规则不满足时直接list new ArrayList()想清空数据。结果调用方检查list还是原值业务数据没清掉废了半天劲才发现是Java值传递在作祟。从那以后我处理方法内可能要整体替换入参对象时一律改成返回新对象或者让调用方自己处理。5.3 equals与hashCode为什么必须一起重写对象的相等性判断是类和对象绕不开的话题。直接用比较两个对象比的是引用地址而不是内容。如果需要语义上的相等——比如两个User对象的id一样、name一样就认为是同一个人的记录——就要重写equals()方法。而一旦重写了equals()几乎必须同时重写hashCode()。原因是Java的集合类HashMap、HashSet判断两个对象是否重复时先比hashCode()如果hashCode相同才继续比equals()。如果你只让两个逻辑相同的对象能equals相等但hashCode不同它们放进HashSet时会被认定为两个不同元素放进HashMap时可能定位到不同桶导致get()找不到之前put()进去的值。举例来说一个User类重写equals比较id和name却不重写hashCode。那么User u1 new User(1, 张三); User u2 new User(1, 张三); // u1.equals(u2) - true HashSetUser set new HashSet(); set.add(u1); set.add(u2); // 你期望set.size()为1实际是2这个Bug非常隐蔽因为它不报错只是数据比预期多一份。我处理过的问题中有不少缓存key失效或配置去重失败的根因都出在这里。所以规则很简单重写equals必须重写hashCode而且两者的判断字段要保持一致。现代IDE一键生成这两个方法很方便但生成之后你得知道为什么要这么做不然改字段时忘了同步就埋雷。6. 面试和项目里关于类和对象的那些高频考法6.1 抽象类和普通类的区别以及接口的定位抽象类和普通类的区别是高搜索词也是Java面试几乎必问的基础题。抽象类用abstract修饰它和普通类的核心区别在于两件事抽象类不能直接new必须有子类继承它并实现所有抽象方法才能创建子类的对象。抽象类里可以有抽象方法只有声明、没有方法体普通类不行。它的价值在于把一类事物共同的骨架提取出来但细节留给子类补充。比如动物抽象类定义吃这个抽象方法猫和狗各自实现不同的吃法。这个设计模式对应的是模板方法思想——公共算法流程在父类写死变动的步骤做成抽象方法交给子类。对比之下普通类更适合用于结构完整、可以被直接实例化的场合。如果你的类只是想约束拥有哪些方法而不关注具体实现应该用接口interface。接口是更纯粹的契约实现类必须提供这些方法但根本不关心你怎么实现。抽象类介于普通类和接口之间既能提供公共实现又能预留扩展点。我在实际项目里经常用抽象类做骨架工具类比如一个AbstractExcelExporter把打开文件、写表头、关闭资源的流程写好只留下writeBody()抽象方法给具体业务子类实现。这样每个导出类只管自己的数据公共流程完全复用也不容易出现每个子类各写各的、流程参数不一致的问题。顺便提一个常见误区抽象类不是没写完的普通类它的设计意图就是强制子类完成特定步骤。如果你发现一个抽象类没有抽象方法那它和普通类差别不大用继承的意义就存疑了。6.2 类设计的内聚与外延一个类该承担多少职责最后聊一个不只是面试、更是日常写代码是否优雅的问题一个类该放哪些东西、不该放哪些东西。这个没有绝对标准但有一条被反复验证的原则——单一职责一个类应该只有一个引起它变化的原因。翻译成白话如果业务上只需要改用户资料就动了你某个类改订单价格也动了这个类那这个类就承担了两种责任迟早会变成谁也不愿意碰的上帝类。我重构过的老代码里有一类文件叫xxxCommon什么方法都往里塞解析Excel的、发短信的、转数字的、算时间的。每一个新需求都往里加一个方法久而久之这个类上千行依赖它的人几十个任何小改动都要回归测试一大片。我的改进思路通常是先按领域拆分——用户相关放UserService订单相关放OrderService纯工具逻辑再单独放Util类然后把对外暴露的方法数量压到最少类内部的私有方法尽量拆细让每个方法只做一件明确的事。代码的可读性和可维护性提升其实在类和对象的边界是否划得清楚这一步就已经决定了。另外有一个词叫组合优于继承放到类设计里同样适用。继承是为了复用实现但它容易把父类的方法不经选择地暴露给子类形成脆弱的类体系。很多时候与其让Teacher extends Person、Student extends Person不如用组合Teacher里持有一个Person对象作为字段把人的属性委托给它。这样职责更清楚不同维度复用起来也不互相干扰。我强调这一点是因为类和对象不只是语法练习它们是你表达业务逻辑的基本单位——边界设好了后面的路会好走很多。
返回列表