ARTICLE DETAIL

资讯详情

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

Java类和对象核心解析:从底层原理到面试考点

Java类和对象核心解析:从底层原理到面试考点 类和对象是Java的绝对地基。不管你后面学集合、学框架、还是去刷面试题绕来绕去都得回到这两个词上。很多新手看教程时总觉得概念飘忽什么“类是模板对象是实例”听起来像绕口令真正上手写代码时照样懵。我写这篇文章的目的很简单把类和对象拆开嚼碎从定义类到创建对象从语法细节到内存变化再到面试最爱挖的坑一条线捋清楚。不管是刚入门Java的同学还是准备校招社招的开发者都能在这里找到可以直接用的干货。我没有打算堆砌术语所有概念都会配代码和场景说明。你会看到我是怎么理解一个类的也会看到new一个对象时JVM里到底发生了什么。建议你打开IDE边看边敲光看不练是真的记不住。1. 类和对象的基本理解1.1 类是什么对象是什么我见过太多人把“类”和“对象”挂在嘴边但问到本质时却说不出所以然。其实你只要抓住一个类比就行类是一张产品设计图对象是根据这张图生产出来的真实产品。设计图上会标注这个产品有哪些属性比如颜色、尺寸、重量有哪些行为比如开灯、播放音乐。但设计图本身不能发光也不能被拿在手里使用。只有按照设计图“生产”出来的实体才可以真正被操作。在Java里这张设计图就叫类Class生产出来的每一个实体就叫对象Object。另外一个关键点是Java是面向对象语言不是面向类语言。类只是代码层面的一种组织方式真正的运行逻辑都在对象上。我经常看到新人写了一个类里面塞满了静态方法然后全程用类名调用这其实就退化成了面向过程。面向对象的精髓是让对象去负责自己的行为而不是让一个外部工具类替它做所有事。1.2 类的实体化对象是运行时的存在类在编译期是源代码文件在运行期也是JVM里的一块“类模板”元数据。但对象不一样对象是程序运行到new那一刻在堆内存里实实在在分配出来的一块空间。你可以创建多个对象它们各自拥有独立的属性存储空间互不干扰。// 定义一个类 public class Dog { String name; int age; void bark() { System.out.println(name is barking); } }上面的Dog是一个类它只是说明书。只有当执行new Dog()时才会产生一只实际的狗public class Main { public static void main(String[] args) { Dog dog1 new Dog(); dog1.name 旺财; dog1.age 2; dog1.bark(); // 旺财 is barking Dog dog2 new Dog(); dog2.name 来福; dog2.age 3; dog2.bark(); // 来福 is barking } }dog1和dog2是两个完全独立的对象你修改dog1的名字不会影响到dog2。这就是对象的独立性。同时dog1和dog2都指向同一个类模板Dog所以它们拥有相同的行为定义。2. 类的定义与对象的创建2.1 类的完整语法结构从字段到方法定义一个类没有你想象的那么玄乎核心就是“属性 方法”的组合。不过想让一个类显得规范、易维护你必须在一开始就养成良好的习惯。一个标准的类通常包含以下几部分访问修饰符public表示这个类可以跨包访问不写就默认包私有。字段属性用来描述对象的状态。构造器特殊方法用于初始化对象。方法描述行为。可选内部类、静态变量、常量等。下面是一个比较完整的例子注意我加了private修饰字段并通过方法来访问。这是封装的基本要求后面我还会细讲。public class Student { // 字段用private对外隐藏细节 private String name; private int score; // 构造器无参构造 public Student() { // 默认值由JVM赋予name为nullscore为0 } // 构造器带参构造 public Student(String name, int score) { this.name name; this.score score; } // 公开的getter和setter public String getName() { return name; } public void setName(String name) { this.name name; } public int getScore() { return score; } public void setScore(int score) { this.score score; } // 自定义行为方法 public void showInfo() { System.out.println(姓名 name 成绩 score); } }看到this关键字了吗当方法参数和字段名冲突时this.name指向当前对象的字段防止歧义。这就是this最常见的用法。2.2 构造器对象初始化的关键节点很多人忽视构造器的意义其实它决定了一个对象“出生”时的状态。如果你只写了带参构造却没有显式写无参构造那么new Student()就会直接编译报错。因为Java编译器只在你一个构造器都没写时才自动给一个默认无参构造。我强烈建议在一个类中同时显式定义无参构造和全参构造。无参构造配合setter可以分步赋值全参构造方便一次性初始化两种场景在真实开发中都会用到。比如从数据库查询出一个对象你可能会用全参构造快速组装如果是前端表单提交你更可能先用无参构造创建对象再逐个set值。另外还有一个“坑”构造器是可以私有的。把构造器设为private外部就无法new这个类了。这正是单例模式的基础面试里经常问。下面是一个最简单的饿汉式单例public class Singleton { private static Singleton instance new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } }这里private Singleton() {}阻止了外部创建对象所有的使用方都必须通过getInstance()获得同一个实例。这就是“私有构造器”背后的实际价值。2.3 new对象时JVM到底做了什么每次执行new Student()JVM并不是简简单单分配一块内存就完事。了解这个过程对排查空指针异常和内存问题很有帮助。检查类是否已加载如果没有则先加载类模板。在堆内存中为对象分配空间。将字段初始化先赋默认值null、0、false再执行构造器里的赋值语句。执行构造器的代码块。通过栈中的引用变量指向这块堆内存。我打个比方new Student()就像一个工厂接到订单后先检查图纸类加载接着空出一块场地分配内存然后按照图纸上的初始状态摆放设备默认值初始化再把订单里的具体要求加上去构造器赋值最后把场地地址写进你的通讯录栈引用。这解释了一个经典问题为什么String是引用类型却可以直接比较值因为String类的特殊性。而对于普通对象你用比较的是引用地址而不是内容。这一点在面试问答环节我会重点提。3. 面向对象的核心特性封装、继承、多态3.1 封装用private保护数据用方法提供入口封装不是单纯把字段设为private而是通过访问控制让外部不能随意篡改对象内部状态。就好比你家里的电器面板你按下按钮就能使用但你不能直接去碰里面的高压电路。private字段就是“高压电路”public方法就是“按钮面板”。实际开发中封装的一个典型应用是校验逻辑。比如学生成绩不可能为负数也几乎不可能超过100分。如果字段是public外部可以直接给score赋一个1000后果不可预知。但有了setter你就能在入口处拦截public void setScore(int score) { if (score 0 || score 100) { throw new IllegalArgumentException(成绩不合理); } this.score score; }这样一来任何非法赋值都会被挡在门外。我见过很多团队为了图省事直接把字段设成public最后查问题查得焦头烂额。封装带来的不仅仅是安全性更是一种“约束的集中管理”它让类的设计者掌握了数据的合法性规则。3.2 继承抽取共性代码复用继承解决的是“多个类之间共享代码”的问题。比如学生、老师、管理员都是人它们共有的属性和行为姓名、年龄、自我介绍就可以放到父类Person里子类用extends关键字继承再写自己特有的部分。public class Person { protected String name; protected int age; public Person(String name, int age) { this.name name; this.age age; } public void introduce() { System.out.println(我是 name 今年 age 岁); } } public class Student extends Person { private int score; public Student(String name, int age, int score) { super(name, age); // 调用父类构造器 this.score score; } public void study() { System.out.println(name 正在学习); } }注意我用了protected关键字。它表示子类、同包类可以访问外部不可随意访问比private更宽松比public更安全。这在父类中非常常用属于一个需要仔细拿捏的访问级别。继承还有一个重要的“隐藏知识”子类构造器必须调用父类构造器要么显式写super(...)要么隐式调用父类无参构造。如果父类没有无参构造子类又没有显式调用super(...)就会编译报错。3.3 多态同一个行为不同表现多态是我当初学Java时最绕的一个点理解核心就一句话父类引用指向子类对象。比如Person p new Student(张三, 18, 90); p.introduce(); // 调用的是子类重写后的逻辑这里p的静态类型是Person但运行时实际对象是Student。如果Student重写了introduce()方法调用p.introduce()会走子类的实现而不是父类的。这就引出了**重写Override和重载Overload**的区别重写子类对父类方法进行重新定义方法签名必须一样。重载同一个类中方法名相同参数列表不同跟返回类型无关。多态大大提升了程序的扩展性。比如你有多种支付方式只要它们都实现同一个接口就能用统一的方式处理。这里顺带提一句接口在Java里也是“类”的延伸很多面试题都会问“抽象类和普通类的区别”往往就是围绕接口和抽象类展开的。4. 内部类与对象的高级操作4.1 内部类分类与使用场景内部类不是必须的但用得恰当会让代码结构很清晰。Java里有四种内部类成员内部类定义在类内部可以访问外部类的所有成员。局部内部类定义在方法内部只在方法内有效。匿名内部类没有类名的内部类常用于快速实现接口或抽象类。静态内部类用static修饰不持有外部类对象引用。成员内部类一个典型场景是外部类的某个逻辑非常依赖一个辅助类而这个辅助类只服务当前外部类。比如HashMap里就有一个Node内部类用来存放键值对。你不希望在外部独立看到Node但它又必须是HashMap的一部分把它设计成内部类再合适不过。下面是四种内部类的一个快速示例public class Outer { private int num 10; // 成员内部类 class Inner { void show() { System.out.println(num); // 可以直接访问外部类私有成员 } } // 静态内部类 static class StaticInner { void show() { // 不能直接访问外部类非静态成员 } } void method() { // 局部内部类 class Local { } // 匿名内部类比如new一个接口 Runnable r new Runnable() { Override public void run() { System.out.println(匿名内部类); } }; } }匿名内部类在Java 8之后大部分被Lambda表达式替代但很多老代码还是这种写法。至少你要能看懂。4.2 对象数组与去重热搜词里有“对象数组去重”这个在实际开发中非常常见。比如从数据库查出一批用户对象你需要按某个字段去重。Java 8之后用Stream可以写得极其优雅ListUser users ...; ListUser distinctUsers users.stream() .collect(Collectors.toMap(User::getId, Function.identity(), (old, latest) - old)) .values() .stream() .collect(Collectors.toList());代码逻辑是toMap时指定主键为id如果遇到重复id就保留旧值第三个参数然后把Map的values转回List。这种做法的好处是内存可控、逻辑清晰不会像嵌套for循环那样低效又难读。如果不想用lambda那就老老实实维护一个Set遍历时判断是否包含。但注意普通Set判断的是对象引用你必须重写equals和hashCode否则同id不同引用的两个对象会被当成不同元素。很多新人在这里翻车就是因为没有重写这两个方法。4.3 匿名对象临时使用的好帮手匿名对象就是没有赋给任何变量的对象。比如new Student(李四, 20, 85).showInfo();这种写法适合只调用一次方法的场景。优点是不用额外声明变量代码更简洁。缺点是对象生命周期很短调用完之后即刻变成垃圾频繁使用会增加GC压力。在循环里创建匿名对象尤其要小心能复用就尽量复用。5. 面试高频考点与疑难排查实录5.1 类和对象相关的五个高频面试题我整理了这些年面试别人和被别人面试时出现频率极高的几个问题每个都值得你反复琢磨。1. 类和对象的区别是什么类是一类事物的模板定义属性和方法的集合对象是类的具体实例拥有独立的内存空间。类是编译期存在的抽象对象是运行期具体的值。2. 为什么Java里main方法必须是static因为main是程序入口JVM启动时还没有任何对象存在只能通过类名直接调用。所以main必须是staticpublic要能被JVM访问返回类型必须是void参数是String[] args用于接收命令行参数。3. 构造器能不能被static修饰不能。构造器是给new用的new的过程是在堆上分配对象这天然是一个实例化的过程。而static成员属于类本身不依赖对象存在。两者逻辑矛盾所以编译都不允许。4.和equals有什么区别比较的是引用地址equals是Object类的方法默认也相当于但很多类比如String、Integer重写了equals用来比较内容。因此比较两个对象的“业务相等性”时必须重写equals同时重写hashCode否则在HashSet、HashMap里会出错。5. 抽象类和接口有什么区别抽象类重在“复用”可以有字段、构造器可以定义普通方法用extends继承接口重在“能力契约”字段默认是public static final方法默认是抽象方法Java 8后可以有默认方法和静态方法。在Java 8以后两者语法边界越来越模糊但设计意图完全不一样。面试时如果能举一个实际场景说明非常加分。5.2 空指针异常的三大来源与排查技巧空指针NullPointerException是所有Java开发者的老朋友。它大多跟对象有关而且多在你想当然时出现。我总结三个高频来源调用了一个本应初始化的对象但对象的初始化被条件判断跳过了。数组或集合中某个位置的元素是null直接取出来调用方法。方法返回值可能为null没有判空就继续操作。排查空指针最快的手段是看异常堆栈它精确到是哪一行代码抛出的。然后检查那一行调用的对象到底哪个是null。如果是链式调用比如a.getB().getC().getName()一旦中间某个环节返回null整个链路就断了。Java 8之后可以用Optional链式处理大幅降低空指针风险。Optional.ofNullable(user) .map(User::getAddress) .map(Address::getCity) .ifPresent(System.out::println);这样的写法优雅且安全但也要注意别滥用全部套Optional会让代码变得啰嗦。只在可能为null的传递环节使用。5.3 重写equals和hashCode的正确姿势前面我反复提过equals和hashCode这里展开讲一下。如果你要判断两个Person对象是否“同一个人”标准是id相同那么Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Person)) return false; Person person (Person) o; return id person.id; } Override public int hashCode() { return Objects.hash(id); }为什么hashCode也必须重写因为基于哈希的容器HashMap、HashSet先根据hashCode定位桶再用equals比较桶内元素。如果只重写equals不重写hashCode那么两个业务相等的对象可能哈希值不同导致它们被放到不同桶里容器就会误判它们不相等。IDE一般提供自动生成快捷键不要再手写样板代码。6. 实操心得更好的组织类和对象6.1 控制类的大小一个类一个职责我见过很多一千行以上的“上帝类”什么功能都往里塞最后改一处崩三处。设计类的时候一定要坚持单一职责原则一个类只干一件事。比如学生类只管学生的数据和行为学生成绩统计这件事应该交给一个专门的ScoreAnalyzer类而不是让Student既保存个人信息又负责分析全班成绩。控制类大小的一个实用标准是如果你没法在一个屏幕里看完这个类的所有字段和方法说明它已经太大了。此时就该考虑拆分。拆分的维度通常是“变化的频率”——经常一起变化的成员放一起互相独立的拆开。6.2 对象创建时的内存优化技巧创建对象的开销不可忽视特别是在循环和并发场景。下面是一些我实际用过的优化手段使用构建器模式Builder Pattern替代大量参数构造器。当构造器参数超过三四个时代码可读性断崖式下降用Builder可以清晰看到每个字段的赋值。尽量复用对象。比如字符串拼接别在循环里用用StringBuilder。频繁创建短生命周期对象会加重GC负担。能用基本类型就用基本类型别动不动包装成Integer或Long。对象池仅用于创建成本极高的对象比如数据库连接、线程。普通小对象随便new反而更省心。构建器模式虽然会多写一点代码但换个角度说它也是为后续扩展留了空间。比如这个类以后要增加一个必填字段用构造器会直接破坏旧代码用Builder则只需新增方法兼容性极好。6.3 我踩过的一个典型坑子类重写方法时误用了private曾经有一次我在子类继承父类时想重写父类的某个方法结果把子类里的方法写成了private。编译不报错但运行时走的是父类方法因为子类私有方法对父类引用完全不可见。这种“看似重写实则新写”的问题是继承体系里最容易埋的坑之一。解决办法很简单重写方法时权限绝不能低于父类而且尽量加上Override注解让编译器帮你检查。后来我养成了一个习惯所有重写方法都强制加Override。第一呢可以让编译器确认确实是重写第二以后维护代码的人一眼能看出意图。这是一个零成本但收益很大的好习惯。如果你正在练习类和对象我建议你手写一个“图书馆管理系统”的小栗子有Book类、Reader类、BorrowRecord类用集合存对象再实现借书、还书、查询。把封装、继承、多态、对象数组去重全部用上。做完这个项目你对类和对象的理解会完全不同。我个人在实际操作中的体会是类和对象的概念不是背出来的而是写出来的。每多写一个类你就能多理解一层“为什么要有对象”这个看似天真的问题。等你写多了自然会在脑海里自动形成“这个行为属于谁”的判断。到那天你才算真正迈进了面向对象的大门。
返回列表