ARTICLE DETAIL

资讯详情

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

Java类和对象深度解析:从内存布局到new关键字底层原理

Java类和对象深度解析:从内存布局到new关键字底层原理 先别急着敲代码。很多初学者学Java背下了“类是模板、对象是实例”这句话但真到写项目时仍然不知道怎么设计类、怎么创建对象甚至看不懂别人代码里那些嵌套调用的对象到底是怎么回事。这其实不是智商问题而是从一开始就没有把类和对象这两个最基础的概念理解透。这篇博文是我梳理的Java类和对象系列第一篇目标是帮你把这层窗户纸捅破搞懂类与对象的本质区别、对象在内存中到底怎么存在、构造方法背后发生了什么以及new关键字这一瞬间JVM帮你做了哪些事。这篇文章适合三类人一是刚学完Java语法、正在面向对象门口徘徊的初学者二是学了一段时间但总觉得“会写但不懂原理”的自我怀疑者三是准备Java面试、需要把基础概念组织成逻辑清晰表述的人。文中所有代码示例都是我实际在JDK 8和JDK 17环境下跑过的你可以直接复制运行验证。1. 为什么非要搞出“类”和“对象”这两个概念1.1 从面向过程到面向对象的思维转变我们先想想如果没有类和对象写程序是什么样子。最典型的就是C语言那套思路定义一堆结构体存数据再写一堆函数处理这些数据数据和操作数据的逻辑是分开的。假设你在做一个学生管理系统会定义Student结构体存放姓名、学号、成绩然后写printStudent(struct Student s)、updateScore(struct Student s, int newScore)这样一堆函数。当系统变大函数越来越多你的大脑就要同时维护两套东西数据结构长什么样哪些函数能操作哪些数据。这种割裂感在代码量超过一万行时会变得非常痛苦。面向对象则换了一个思路把数据和操作数据的方法打包进同一个容器里这个容器就是“类”。你不再思考“有一个学生结构体和一堆处理学生的函数”而是想“有一个学生类它知道自己叫什么、考了多少分它自己能打印自己、能修改成绩”。代码的组织方式从“以函数为中心”变成了“以对象为中心”更贴合人认识世界的方式——世界里到处都是对象人、车、订单、银行账户每个对象都有自己的属性和行为。1.2 类是图纸对象是房子最常见的比喻就是把类比作图纸、对象比作按图纸盖出来的房子。图纸定义了房子有门、有窗、有墙、有屋顶但图纸本身不能住人真正能住人的是依照图纸建造出来的那一栋栋具体房子。每栋房子都有同样的结构但可以有不同的颜色、不同的门牌号、住着不同的人。这个比喻在绝大多数场景下都是成立的但我要补充一个容易被人忽略的细节类和对象在Java中除了概念上的“模板与实例”关系之外还有一个非常重要的本质差异——类只存在于方法区和堆内存的元数据区域是一份被所有对象共享的“类型信息”而对象则是在堆内存中真实分配的一块“数据区域”每创建一个都会占据实实在在的空间。这块内存的分析我放到第三部分细讲这里你只要建立“类是抽象概念对象是具体实体”这个意识就够了。1.3 类解决的是“复用”对象解决的是“隔离”很多文章讲到这里就结束了但我还想再深挖一层。类和对象的划分本质上是在解决软件工程里两个永恒的矛盾复用和隔离。复用靠的是类。你写一次类可以new出千千万万个对象类的代码只有一份却被所有对象共享。这就是“一次编写、到处实例化”。类的存在让你不需要为每一个学生都写一份打印成绩的代码。隔离靠的是对象。每个对象拥有独立的成员变量存储空间李雷和张小明虽然都是Student类的实例但李雷的学号和张小明的学号互不干扰、各自独立。成员方法虽然在内存中只有一份但执行的时候通过this关键字知道当前操作的是哪个对象的数据。如果你能把这个底层逻辑想明白后面学继承、多态、接口的时候就会顺很多——这些高级特性都是建立在“类负责复用、对象负责隔离”这个基本矛盾之上的。2. 类的定义与组成第一个Java类的解剖2.1 成员变量与成员方法的基本用法我直接写一个最常见的、带实际业务语义的类来剖析。假设我们要做一个简单的图书管理系统先定义Book类public class Book { // 成员变量属性 private String title; private String author; private double price; private int stock; // 成员方法行为 public void showInfo() { System.out.println(《 title 》 作者 author 价格 price 库存 stock); } public void sell(int count) { if (count stock) { System.out.println(库存不足当前库存 stock); return; } stock - count; System.out.println(成功售出 count 本剩余库存 stock); } }这段代码里有几个关键点需要特别注意。第一成员变量声明在类体内、方法外它的作用域是整个类。每个Book对象都会有一份独立的title、author、price、stock。第二成员方法不需要加static关键字。我经常见到初学者问为什么main方法要加static而showInfo不用加原因是main方法是程序入口由JVM直接调用此时还没有任何对象存在所以必须是静态的而showInfo必须要有一个具体的Book对象才能调用它操作的是“某一个具体图书”的数据所以不能是静态的。简单来说如果一个方法需要访问对象的成员变量它就不能加static。第三private关键字把title这些成员变量封装了起来外部不能直接book.title xxx。这种封装后面会细讲这里先记住成员变量一般设为private通过公开的方法来访问和修改。2.2 类的加载与对象创建的区别要真正理解类是什么绕不开类加载机制但第一篇文章我们不深入JVM内部只说最关键的一点。类的代码在磁盘上是.java源文件经过javac编译后变成.class字节码文件。当你第一次使用某个类时比如new Book()JVM的类加载器把这个.class文件加载到内存的方法区解析出类的结构信息——有哪些字段、哪些方法、访问权限是什么——这些信息构成了一份“类模板”。这个过程最反直觉的地方在于类的模板信息在内存中只有一份不管你new多少个对象所有对象都共享同一份类结构信息。而对象是这份模板“复制”出来的具体数据。模板里定义的是“有哪些属性和行为”对象里装的是“属性具体是什么值”。本质上类加载是在建立规则对象创建是在按规则填充数据。2.3 对象创建的标准姿势与初始化过程创建对象的标准姿势只有一种new 类名()。很多人以为new只是一个“分配内存”的动作实际上它背后有四个连续的步骤Book book new Book();第一步类加载检查。JVM检查Book这个类是否已经被加载、连接、初始化。如果还没加载就触发类加载流程。这一步很多人不知道但它是new动作能够执行的前提。第二步分配内存。JVM在堆内存中为新的Book对象划分一块空间。这块空间大小在类加载完成后就已经确定——因为每个对象需要存储的成员变量个数和类型已经固定了。第三步初始化默认值。JVM把分配到的内存空间所有字节清零于是所有成员变量都有了默认值int是0double是0.0boolean是false引用类型是null。这个细节BUG很多但也很容易考到后面单独说。第四步执行构造方法。JVM调用Book类的构造方法把我们传进去的参数赋值给成员变量完成真正的初始化。很多人知道new是“创建对象”但不知道new的背后有这么多讲究。这种认知差距就体现在面试和实际排错上当面试官问“new一个对象的过程中发生了什么”或者程序突然出现空指针时你能不能在脑海里还原出变量默认值是null这一环。3. 构造方法对象诞生的第一道关卡3.1 默认构造方法到底是怎么来的如果我在Book类里不写任何构造方法new Book()依然能正常执行。为什么因为编译器在编译时自动为类添加了一个无参构造方法它长这样public Book() { super(); // 调用父类的无参构造方法这里父类是Object }这个自动生成的构造方法又被称为默认构造方法。关键在于一旦你自己写了任何一个构造方法这个默认无参构造方法就不会再自动生成了。这是初学者踩得非常多的坑。public class Book { private String title; private double price; public Book(String title, double price) { this.title title; this.price price; } }写了这个带参构造方法之后再执行new Book()就会编译报错因为Book类此时已经不存在无参构造方法了。解决办法有两个要么显式补一个无参构造方法要么调用带参构造方法创建对象。很多框架比如Spring在反射创建对象时依赖无参构造方法所以业界有个不成文的约定只要写了带参构造方法就顺手补一个无参构造方法避免以后踩坑。3.2 构造方法的三大特殊性构造方法从语法上看很像普通方法但它有三个普通方法不具备的特点。第一构造方法的名字必须和类名完全一致。这一点没有任何商量余地甚至连大小写都必须是精确匹配。所以类名首字母大写是一种规范也是一种防呆设计——构造方法的大写字母提醒你这是一个构造方法。第二构造方法没有返回值类型连void都不能写。很多人误以为构造方法“返回了对象”更准确的说法是构造方法负责初始化新创建的对象执行完之后new表达式会返回这个对象的引用。构造方法内部不需要、也不能写return语句来返回对象。第三构造方法不能是static。这是逻辑上的必然——static是类级别的构造方法是实例级别的两者语义冲突。如果允许static构造方法那它到底是在为哪一个对象做初始化JVM无法回答。其实构造方法还有一个很容易被忽略的点构造方法也是方法所以它可以重载。new Book()和new Book(Java核心技术, 99.0)其实调用的是两个不同的构造方法。这是对象的多种创建方式的基础也是工厂模式等设计模式的基础场景。比如public class Book { private String title; private double price; private int stock; public Book() { this(未知书名, 0.0, 0); } public Book(String title, double price) { this(title, price, 100); } public Book(String title, double price, int stock) { this.title title; this.price price; this.stock stock; } }三个构造方法通过this(...)互相调用避免了重复代码。这种写法很常见核心思想就是越全的构造方法负责真正的赋值其他构造方法负责提供不同粒度的默认值。注意this(...)必须写在构造方法的第一行否则编译报错这是Java语法层面的硬性规定。3.3 this关键字的两副面孔this关键字在刚接触的时候比较抽象本质上它代表“当前对象”的引用。具体来说有两个使用场景。场景一在成员方法中通过this.xxx访问当前对象的成员变量。最常见的场景是构造方法参数名和成员变量名相同需要用this来区分public Book(String title, double price) { this.title title; this.price price; }等号左边的this.title是成员变量等号右边的title是构造方法的参数。没有this的修饰Java会按照“就近原则”把它当成参数来用导致成员变量根本没被赋值。场景二在一个构造方法中调用另一个构造方法也就是上面写过的this(...)。需要注意的是这个this(...)调用只能出现在构造方法的第一行而且一个构造方法里只能调用一次。还有一个很多人没注意的细节静态方法里不能使用this。因为静态方法不属于任何具体对象它没有“当前对象”的概念。如果你在静态方法里写this.title编译器会直接报错。这反过来印证了前面那句话this是对象级别的概念static是类级别的概念两者水火不容。4. 对象的内存布局与生命周期4.1 栈、堆与方法区各司其职要真正理解对象必须把它放进内存里看一遍。我画不了图就用文字还原一下Book book new Book(Java核心技术, 99.0);这行代码在JVM内存中发生了什么。栈虚拟机栈里有一个局部变量表每调用一个方法JVM就会为这个方法分配一块栈帧局部变量表就存在于栈帧中。在我们这行代码里栈上有一个变量book它存的不是对象本身而是对象在堆内存中的地址引用。堆内存里有一块区域被划出来存储Book对象的实际数据——title指向某个字符串对象price是99.0这个double值。真正new出来的对象实体都在这里。方法区则存放类的结构信息Book类的字段定义、方法字节码、常量池等。所有Book对象通过一个指向方法区的引用共享这些类信息。这和C语言中的指针非常相似Java的引用本质上就是一种受控的指针只是你不能像C语言那样对引用做算术运算。所以Book book new Book(...)实际上做了三件事在堆上分配内存创建对象、把对象地址赋值给栈上的局部变量、局部变量book成为操作这个对象的唯一入口。4.2 成员变量有默认值局部变量没有这一点我前面提过但值得单独展开因为它既是基础知识的常客也是实际开发中空指针异常的根源之一。成员变量不管你有没有显式赋值JVM在分配对象内存时会做一次零值初始化所以成员变量永远有值。数值类型默认0/0.0boolean默认falsechar默认\u0000引用类型默认null。局部变量Java编译器强制要求局部变量必须显式初始化后使用。下面代码直接编译失败public void demo() { int count; System.out.println(count); // 编译错误可能尚未初始化变量count }为什么Java要区别对待我的理解是成员变量存储于堆中对象被创建后可能被其他线程访问零值初始化靠JVM底层批量操作性能开销可以接受而局部变量存储在栈帧中使用前必然要经过赋值操作强制显式初始化能提前发现一部分低级错误。这种设计让Java程序少了不少不确定行为。在实际开发中引用类型的成员变量默认值是null如果忘记判空就直接调用对象的方法就会出现空指针异常。工作中最常见的排查方式是看堆栈信息定位到哪一行访问了null引用然后往前回溯这一个引用到底是在哪里赋的值。养成“创建对象后立即检查是否可能为null”的习惯能帮你躲掉大量夜间上线事故。4.3 对象的生命周期一个对象从创建到被回收大致经历四个阶段创建、使用、不可达、回收。创建new表达式执行完成对象在堆中拥有了自己的数据。使用栈上的引用指向堆中的对象程序通过引用来调用对象的方法、访问对象的数据。不可达当没有任何变量引用这个对象时它就变成了“垃圾”。比如局部变量超出作用域、引用被设置为null、引用被重新指向其他对象。回收垃圾收集器GC会在某个不确定的时机回收不可达对象的内存。这个时机程序员无法精确控制所以不能指望用析构函数来释放资源。Java的做法是用try-with-resources或finally块来管理需要手动关闭的资源。关于回收有个常见误解“对象在方法结束时就被回收了”。实际上方法结束只意味着栈上的局部变量消失堆中的对象是否被回收完全取决于还有没有其他引用指向它。比如public class BookStore { private Book book new Book(Java核心技术, 99.0); public Book getBook() { return book; // 方法结束book局部变量消失但BookStore.book仍引用着该对象 } }只要一个对象还可以从GC Roots出发被访问到它就不会被回收。这也是为什么成员变量被对象持有的对象生命周期通常比局部变量更长。5. 方法重载与对象行为设计5.1 重载的规则与方法匹配同一类的多个方法方法名相同但参数列表不同就叫方法重载。这里“参数列表不同”有三种情况参数个数不同、参数类型不同、参数顺序不同。返回类型不同不算重载仅仅改变返回类型、参数完全一样是编译不过的。public class Calculator { public int add(int a, int b) { return a b; } public double add(double a, double b) { return a b; } public int add(int a, int b, int c) { return a b c; } }编译器在编译期就能确定调用哪个重载版本。这一点与重写不同——重写是运行时多态重载是编译期静态绑定。很多人会忽略参数顺序不同的重载public void print(String name, int age) {} public void print(int age, String name) {}两个方法的参数类型顺序不同所以是合法的重载。但实际开发中不建议这么写调用方很难分清哪个参数在前代码可读性很差。调用重载方法时编译器会进行类型匹配先做精确匹配再尝试自动类型提升。比如有一个add(int, int)方法调用add(1, 2)精确匹配如果没有int版本的add只有add(double, double)那么add(1, 2)也能编译通过因为int会隐式提升为double。5.2 重载在类设计中的应用价值重载最实际的价值就是让你可以用同一个方法名表达同一类操作只是参数的粒度不同。这在真实项目里最典型的就是各种构造函数重载——我们前面写的三个Book构造方法就是一种重载。另一个非常常见的使用场景是“默认参数模拟”。Java没有像Python那样的默认参数语法重载就是实现类似效果的标准手段public class Logger { public void log(String message) { log(message, INFO); } public void log(String message, String level) { System.out.println([ level ] message); } }外面调用时既可以log(系统启动)也可以log(用户登录, DEBUG)。这种方式比让调用方每次手动传level参数要友好得多同时保持了API的简洁性。在设计自己的类时我的经验是重载方法之间一定要有清晰的层级关系最好让参数少的版本调用参数多的版本实现默认值逐级递增。这样即使以后要加参数也只需要改最底层的方法上层调用方不需要任何改动。5.3 静态方法 vs 实例方法该怎么选这部分不算新概念但和“类与对象”这个主题咬得很紧。我在前面反复提到了static这里把选择标准整理清楚。静态方法不依赖对象状态只依赖传入的参数。判断标准是“方法内部是否访问了成员变量或调用了实例方法”。如果完全没有就可以考虑设计成静态方法。典型例子是工具类方法比如Math.max()、Collections.sort()。实例方法需要访问对象的成员变量或调用其他实例方法。判断标准是“方法的行为和某个具体对象的数据相关”。典型例子是book.showInfo()——它要打印这部书的标题、价格和库存没有具体对象就无法执行。静态方法通过类名直接调用Calculator.add(1, 2)。实例方法必须通过对象引用调用book.sell(2)。很多人纠结于访问时机其实有一套直觉判断法把方法放到一个“这些操作是在针对谁的数据做事”的问句里——答案是任何一个对象都能执行同样的操作那就是静态答案必须是某一个具体对象那就是实例。还有一个容易混淆的点静态方法可以访问静态成员变量但不能直接访问实例成员变量。反过来实例方法可以同时访问静态成员变量和实例成员变量。因为在类加载时静态变量已经存在而实例变量此时可能还没有任何对象。这种“从类到对象逐级可见”的规则是理解访问控制的关键前提。6. 面试常见问题与避坑要点6.1 高频题new一个对象的过程完整表述这道面试题几乎每个面Java岗位的人都会被问到。我建议你按这个顺序回答既完整又逻辑清晰首先说类加载检查类是否已加载如果没有则触发类加载流程把类的结构信息加载到方法区。然后说内存分配在堆中为对象分配内存分配方式根据垃圾收集器不同可能是指针碰撞或空闲列表。接着说默认初始化将内存空间清零所有成员变量赋默认值。然后执行构造方法设置对象的初始化状态比如给成员变量赋值。最后返回引用把堆中对象的地址赋值给栈上的局部变量。能按这个顺序把new的完整生命周期讲清楚至少说明你对JVM基础是有概念的不是背了“创建了一个新对象”这种白开水答案。6.2 高频题this和super的区别一个立足当前类一个立足父类。this可以调用本类的构造方法、成员变量和成员方法super可以调用父类的构造方法、成员变量和成员方法。两者最关键的使用限制完全一致调用构造方法时都必须写在第一行且只能调用一次。有个延伸问题this()和super()能同时出现吗不可能因为第一行只能写一个。那如果构造方法里既不写this()也不写super()呢编译器会自动加上super()调用父类的无参构造方法。所以父类必须有无参构造方法或者子类构造方法里显式调用父类的带参构造方法否则编译失败。6.3 高频题equals和的区别这个问题经常出现在“对象”相关面试中但很多初学者答不全。比较的是栈中的值。对基本类型来说比较的是数值对引用类型来说比较的是引用地址两个引用是否指向堆中同一个对象。equals是Object类的方法默认实现等价于比较的是引用。但很多类重写了equals比如String的equals比较的是字符内容。所以在判断两个对象是否“逻辑相等”时应该用equals而不是。String s1 new String(hello); String s2 new String(hello); System.out.println(s1 s2); // false两个不同对象 System.out.println(s1.equals(s2)); // true内容相同注意我在前面特别强调了“栈中的值”这个说法引用类型的比较的是引用值地址并不是“比较内容”。这个区分在做面试回答时特别加分因为大多数人只会背“值类型比较值、引用类型比较地址”你能具体到“栈中存储的引用地址”就能体现出理解深度。6.4 写类时的高频“扫雷”清单把成员变量设置成public外部随意修改破坏封装。项目里普遍用的是private getter/setter这不是形式主义是为了以后可以在setter里加校验逻辑。写了带参构造方法后忘记补无参构造方法导致框架反射创建对象失败。这个坑很大尤其是接Spring项目的新手报错信息里会出现No default constructor found定位到实体类时检查构造方法就对了。构造方法里用Book book new Book()递归创建对象造成栈溢出。这个属于例子比较极端但也说明构造方法的调用链需要小心。成员变量不初始化以为引用类型为null用起来没问题结果一调用方法就空指针。记住那句老话任何对象都有可能为null调用方法之前先做判断或确保它已经被赋值。重载和重写混淆。一个发生在编译期、一个发生在运行期一个看参数列表、一个看方法签名和方法声明。两者都是面向对象的重要工具但作用阶段完全不同。7. 类设计的一点个人体会写完这篇基础篇最后分享一点我自己的体会。类设计不是靠语法堆出来的而是靠对业务场景的拆解。初学者最容易犯的毛病是“一个类写到底”——把所有方法和数据全部塞进一个类里然后类就膨胀成几百行。这个阶段做不了太复杂的架构分析但有一个简单的原则一个类只干一件事而且要能明确说出它的领域含义。我看到Book类里有showInfo和sell就足够合理但如果里面出现了saveToFile、sendEmail这样的方法就该考虑把文件操作和邮件操作拆出去了。另外对象和类的关系一定要建立在“多例”的直觉上。一个类可以被实例化成很多个对象每个对象的成员变量互不干扰这是Java程序能处理真实世界复杂度的大前提。面试题里经常会问“下面代码输出什么”输出的核心就是看面试者有没有建立这个多例直觉。类和对象系列的第一篇我把最基础但也最重要的内容都覆盖了。接下来第二篇可以讲封装、继承、多态第三篇可以深入方法重写、抽象类与接口第四篇可以讲内部类和匿名内部类。如果你跟着这个系列一路读下来Java面向对象的地基会打得非常扎实。有任何细节我没讲透的欢迎在评论区一起讨论。
返回列表