女:欢迎收听我们的Java面试小课堂,上一期我们聊了Java的基础语法,今天我们来深入探讨一下Java的灵魂——面向对象。我们总说Java是面向对象的语言,那这个“面向对象”和另一个常听到的“面向过程”,它俩到底有什么区别呢?
男:问得好,这个问题是理解Java设计哲学的起点。简单来说,它们是两种解决问题的思路。面向过程,就像一个厨师严格按照菜谱做菜,第一步做什么,第二步做什么,一步步执行,关心的是“过程”和“步骤”。而面向对象,则更像一个餐厅老板,他不去关心具体怎么炒菜,而是先想好需要哪些角色,比如厨师、服务员、收银员,然后让这些“对象”各司其职,通过他们之间的协作来完成“招待客人”这个任务。
女:哦,一个关注做事流程,一个关注角色分工。那面向对象有什么好处呢?
男:没错。相比于面向过程,面向对象因为把功能和数据都封装在“对象”里,所以代码的“易维护性”和“易扩展性”都更好。就像餐厅想增加一个“外卖员”的角色,不会影响到厨师和服务员的工作。而且通过“继承”这样的特性,我们可以很方便地复用代码,比如基于“厨师”这个角色,派生出“川菜厨师”、“粤菜厨师”,提高开发效率。
女:那是不是说面向对象就一定比面向过程好呢?
男:这倒不是,它们各有适用场景。面向过程简单直接,适合处理一些不那么复杂的任务。而面向对象更适合构建大型、复杂的系统。很多人有个误区,觉得面向过程性能一定比面向对象高,其实这不绝对,性能更多取决于具体的实现和运行机制,而不是编程范式本身。
女:明白了。那在面向对象编程里,我们第一步就是要创建对象。创建一个对象用的是什么运算符?另外,我们常说的“对象实体”和“对象引用”又有什么不同?
男:创建对象,我们用的是new运算符。new这个动作,会在内存的“堆”区域里,创建一个实实在在的对象实例,也就是你说的“对象实体”。但我们程序里直接操作的,通常是一个“对象引用”,它存储在内存的“栈”区域,就像一个遥控器,指向堆里的那个对象实体。
女:我好像听过一个比喻,说对象实体是气球,对象引用是牵着气球的绳子。
男:这个比喻非常形象!一根绳子可以不系气球(引用为null),也可以系一个气球。而一个气球,可以被很多根绳子系着(一个对象可以有多个引用指向它)。
女:那我们怎么判断两个对象是不是“相等”呢?是比较对象本身,还是比较牵着它的绳子?
男:这就引出了“对象相等”和“引用相等”的区别。用==运算符比较,就是比较“引用相等”,也就是看两根绳子是不是指向同一个气球。而我们通常说的“对象相等”,是想知道两个不同的气球,它们里面的内容(比如颜色、大小)是不是一样的,这就要用equals()方法了。
女:我来举个例子看对不对。String str1 = "hello"; 和 String str3 = "hello";,因为它们都指向字符串常量池里同一个"hello",所以str1 == str3是true。但String str2 = new String("hello");,这是在堆里新创建了一个对象,所以str1 == str2就是false了。但它们的内容都是"hello",所以用equals()方法比较都是true。
男:完全正确!理解了这个,==和equals的区别就掌握了。
女:我们创建对象时,会用到构造方法。如果一个类我没有给它写任何构造方法,这个程序还能正常跑吗?
男:完全可以。因为如果你不写,Java编译器会“贴心”地为你提供一个默认的、无参数的构造方法。这就是为什么我们new一个对象时,后面总要跟一对括号。但这里有个小陷阱:一旦你手动添加了任何一个构造方法,比如一个有参数的,那编译器就不再提供那个默认的无参构造方法了。所以,一个好习惯是,如果自定义了有参构造方法,最好也把无参的构造方法也写上,能避免不少坑。
女:原来是这样。那构造方法本身有哪些特点呢?它能被子类重写(override)吗?
男:构造方法有三个很明显的特点:第一,它的名字必须和类名完全相同;第二,它没有返回值,连void都不能写;第三,它在new对象的时候会自动被调用。关于重写,答案是“不能”。构造方法是用来创建对象的,每个类的创建方式都是独特的,子类不能也无需去重写父类的构造方法。但是,构造方法可以被“重载”(overload),也就是说一个类里可以有多个构造方法,只要它们的参数列表不同就行。
女:聊到面向对象,就离不开它的三大特征:封装、继承和多态。您能通俗地解释一下吗?
男:当然。先说“封装”,它就像我们用空调,我们只需要用遥控器(方法)来调节温度、模式,而不需要关心空调内部复杂的电路和零件(属性)。封装就是把对象的内部状态信息隐藏起来,只提供有限的、安全的方法给外部访问。
女-:那“继承”呢?
男:继承,就是“龙生龙,凤生凤”。比如,“狗”和“猫”都继承自“动物”,它们都拥有“吃”和“睡”这些动物的共性。同时,“狗”可以有自己独特的“看家”技能,“猫”有“抓老鼠”的技能。继承让我们可以复用代码,并建立起类与类之间的层次关系。
女:那“多态”呢?这个我感觉最抽象。
男:多态,可以理解为“一种事物,多种形态”。比如,我们说“动物在移动”,对于鸟来说,这个移动是“飞”;对于鱼来说,是“游”;对于狗来说,是“跑”。在代码里,就是父类的引用可以指向子类的对象,比如 Animal animal = new Dog();。当你调用animal.move()这个方法时,程序在运行时会自动去执行Dog类里重写过的那个run()方法。这就是多态,它让我们的代码更灵活,更具有扩展性。
女:讲到继承和抽象,就不得不提“接口”和“抽象类”,它们俩长得挺像,都有抽象方法,都不能被实例化,那它们有什么共同点和区别呢?
男:它们确实有共性,都是为了定义一种“规范”。但它们的设计目的完全不同。抽象类,强调的是“is-a”的关系,是一种所属关系,比如“狗”是一个“动物”,它主要是为了代码复用。而接口,强调的是“has-a”的能力,是一种行为契约,比如“鸟”和“飞机”都能“飞”,它们都可以实现一个Flyable接口,但它俩没什么亲缘关系。
女:所以一个是“你是什么”,一个是“你能做什么”。
男:精辟!另外一个核心区别是,Java里一个类只能继承一个父类(单继承),但可以实现多个接口。所以接口的扩展性更强。还有,从Java 8开始,接口变得更强大了,可以有带方法体的default方法和static方法,这让接口和抽象类的界限变得有些模糊,但它们的设计初衷没变。
女:我们操作对象时,有时需要复制一个对象。我听说有深拷贝和浅拷贝,它们有什么区别?还有个引用拷贝又是什么?
男:这三个概念非常重要。我还是用比喻来解释。“引用拷贝”最简单,Person p2 = p1;,这就相当于你只是多复制了一把p1家的钥匙给了p2,p1和p2进的还是同一个家,操作的是同一个对象。
女:那浅拷贝呢?
男:浅拷贝,就像你买了个新房子(新对象),但房子里的家电(对象内部的引用类型属性),你是直接把旧房子的搬过来了。比如Person对象里有个Address对象。浅拷贝后,新旧两个Person对象是独立的,但它们共享同一个Address对象。你改了新Person的地址,旧的那个也跟着变了。
女:我明白了,这就容易出问题。那深拷贝呢?
男:深拷贝就是彻底的复制。不仅买了新房子,还把所有家电也买了一套一模一样新的。也就是说,不仅Person对象是新的,它里面的Address对象也是新复制出来的。这样,两个Person对象就完全独立,互不影响了。
女:讲得太清楚了!现在我们来聊聊一个特殊的类——Object类。它是所有类的父类,它里面有哪些常见的方法呢?
男:Object类里定义了Java对象最基本的一些行为。比如我们刚才聊到的equals()和hashCode(),还有返回对象运行时类型的getClass(),创建对象副本的clone(),以及用于多线程的wait(), notify(), notifyAll()等等。这些方法是所有Java对象都具备的。
女:刚才我们详细对比了==和equals()。那hashCode()这个方法具体有什么用呢?
男:hashCode()返回的是一个整数,叫哈希码或散列码。它的主要作用,是在像HashSet、HashMap这种基于哈希表的集合中,快速确定一个对象应该存放的位置。
女:那为什么要有hashCode呢?只用equals不行吗?
男:好问题!想象一下,一个HashSet集合里已经有了一万个元素,现在你要新加一个,怎么判断它是不是重复了?如果只用equals,你得把新元素和那一万个旧元素挨个比较,效率太低了。有了hashCode就不一样了,HashSet会先计算新元素的哈希码,然后直接找到对应的位置。如果那个位置上一个元素都没有,那说明肯定不重复,直接放进去就行了。
女:那如果那个位置上已经有元素了呢?
男:如果位置上有元素,说明它们的哈希码可能一样,这时候才会调用equals()方法,来精确判断这两个对象是不是真的相等。所以,hashCode就像一个快速筛选器,它大大减少了equals方法的调用次数,极大地提高了效率。
女:我明白了,先用hashCode粗筛,再用equals精判。那为什么重写equals()的时候,必须也要重写hashCode()方法呢?
男:这是Java里一条非常重要的规定。因为规定了:如果两个对象通过equals()方法判断是相等的,那么它们的hashCode()值必须相同。如果你只重写了equals(),而没重写hashCode(),就会破坏这个约定。比如你把两个内容相同的Person对象放进HashSet,equals认为它们是同一个,但hashCode可能不同,HashSet就会把它们都存进去,这就导致集合里出现了重复元素,程序就出错了。
女:原来如此,它们俩必须“同生共死”。聊完了Object,我们来谈谈另一个非常非常重要的类——String。我们经常听到String、StringBuffer、StringBuilder,它们三者有什么区别呢?
男:这是个经典面试题。它们最核心的区别有三点:第一,可变性。String是不可变的,任何对String的修改,比如拼接,都会产生一个全新的String对象。而StringBuffer和StringBuilder是可变的,它们是在原对象上进行修改,效率更高。
女:那StringBuffer和StringBuilder又有什么区别?
男:区别在于线程安全性。StringBuffer是线程安全的,它的方法都加了synchronized同步锁,适合在多线程环境下使用。而StringBuilder是非线程安全的,但正因为没有锁的开销,它的性能比StringBuffer要高一些。所以,总结一下:操作少量数据用String;单线程下操作大量数据用StringBuilder;多线程下用StringBuffer。
女:你刚才说String是不可变的,这到底是为什么呢?
男:String的不可变性,是Java设计者精心设计的结果。主要有两点:第一,String内部用来存字符串的那个字符数组(在Java 9后是字节数组),被private和final修饰了,外部无法访问,也无法改变它的指向。而且String类本身没有提供任何可以修改这个数组内容的方法。第二,String类本身也被final修饰了,这意味着它不能被继承。这就杜绝了有人通过写一个子类来破坏它的不可变性。
女:设计得真严谨!那我们平时用加号+来拼接字符串,比如"a" + "b",底层到底发生了什么?是用StringBuilder吗?

