TL;DR: 从面向对象出发,再回到面向对象。
Java是一门纯粹的面向对象语言,所有对象继承自java.lang.Object。直白一点说,我们即使实现一个简单的功能、写一道算法题,也要先定义一个类(class),然后再在类里面实现。
因此,在我看来,所有Java的语言特性及其背后的基本原理、设计理念都可以追溯到面向对象当中。我们不妨先问自己一个常见的面试题:谈谈你对面向对象的看法?
类和对象
回答好这个问题并不容易。
首先要厘清的是对象(Object)的概念。对象指具体实体,可以是一本书、一篇博客,也可以是一只猫、一只老虎。它们可以有各种各样的属性(field);也可以有各种各样的方法(method)。对之进行划分、抽象,从而得到逻辑实体,就是类——例如,猫和老虎都可以划分为猫科动物。相应地,也可以反过来说,对象是类的一个实例(An object can be defined as an instance of a class)。
再举个例子,我们在面试中遇到了一道算法题。这道具体的算法题就是一个对象。但“算法题”这个概念则是类,毕竟除了眼前这道题,还有leetcode,codeforces等刷题平台上的无数算法题。也因此,我们不难理解,现实中我们遇到的往往都是对象。当我们用Java编程时,我们要做的就是从对象出发,对其进行抽象和模拟。这也正是面向对象的字面意思。
表示
要面向对象,我们面临的一个问题是如何去表示对象/类。更具体地说,就是如何去表示对象/类的属性(field)和方法(method)。
对于属性,Java提供了8种基本类型(primitive data types):byte/char/boolean/short/int/long/float/double。它们是内置类型,和基类java.lang.Object无关,可以通过字面量进行赋值。为了与Java的类机制相适应,基本类型都有对应的包装类型。包装类型的赋值如果涉及到字面量会进行自动装箱和拆箱。利用数组、链表等不同的数据结构,对这些类型加以组织,在自定义的类中引入所需的类型,我们就自然而然地完成了属性的表示。
至于方法,Java在基类java.lang.Object预先定义了一系列的通用方法,包括:hashCode/equals/clone/toString/getClass/finalize/notify/notifyAll/wait。
如果要引入新的方法,我们可以直接在类中进行定义。
这也正是面向对象的魅力所在:把数据和函数封装在同一个对象/类,对外隐藏了实现细节,对内能让程序员更容易理解程序的构成。
存储
Java程序是运行在机器上的。运行前,我们所写的代码(或者说.java文件)会被编译成字节码(.class文件)。通过类加载器,字节码被加载到JVM内存,由字节码校验器检测是否有运行时错误,之后再传递给解释器翻译成机器码,最后交由操作系统执行。
笼统地说,当字节码文件加载到JVM内存后,与程序运行直接相关的类/对象的信息由堆栈结构的运行时数据区域存储和管理。
-
堆:对象在堆中分配内存。对象的属性(成员变量)的值也将在堆中体现。Java对于堆内存提供自动垃圾回收机制。
-
栈:方法执行时创建一个栈帧,存储局部变量、常量池引用等信息。
-
方法区:存放已被加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据。运行时常量池属于方法区的一部分。可以进行垃圾回收以进行常量池的回收和类的卸载。
优化
以上大致说明了Java实现面向对象的整体思路。但目前看来,它还存在许多问题。
比如说,在封装中,我们把属性和方法封装成了类的成员变量以及成员方法。但是如果我们要控制外部对它的访问权限要怎么做呢?答案是使用相应的关键字。public赋予了最大的访问权限;不加修饰符的情况表示包级可见。protected表示子类可见。而private则是仅自己可见。
封装好以后,类里可能存在各种成员方法。如果成员方法出现同名冲突该怎么办?最直接的方法当然是修改函数名。但很多时候,我们恰恰需要重名。比如继承体系里,子类继承父类,子类有时候要实现一个与父类同名的方法(重写)。又或者同一个类中,为了更好地体现功能,两个方法名字要求相同。通过使它们参数类型、个数、顺序至少有一个不同,编译时便能区分这两个方法(重载)。
以上两种都是Java多态机制的内容。其中又可以分为编译时多态和运行时多态。显然,重写是运行时多态,而重载则是编译时多态。
多态一定程度上解决了表面上相同(同名)、实质上不同(不同参数、隶属不同的类)。但是如果在不同的类中,存在相同代码,能否复用它们呢?
对应的解决方案是继承。在Java中,继承可以表现为子类继承externs父类,也可以表现为实现implements接口。
还有我们在程序运行总是非常关心的的效率问题。这往往要深入到Java内存模型中。比如前面我们提到了对象存储在堆中。随着程序运行,堆内存难免会越来越多,甚至造成OutOfMemoryError。这就需要一个高效的垃圾回收机制。而因为内存模型中,工作内存和主内存是独立的。为了解决堆内存和堆外内存来回拷贝的问题,JDK 1.4 中新引入了 NIO 类。它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。
这些解决方案显然并不是相互独立的,而是相互依存的。比如我们看到在封装中,为了控制权限,需要考虑是否存在继承关系。它们都是Java为了更好地面向对象而所做出的努力。
总结
在准备面试掌握Java时,更重要的是结合面向对象来理解每个知识点:Java为什么会这么做?具体它是怎么做的?带来的好处是什么。最后我们以一道简单的面试题为例。
Q: String为什么是不可变的?
A: 字符串在现实中是一种常见的对象。在作为hash值,可以确保hash值也不可变,且只计算一次。在作为参数时,可以保证参数的不变性从而提供安全性。同时天生具有线程安全性。同时,String不可变性确保了字符串常量池能发挥作用。一个String对象一旦被创建,会从字符串常量池中取得引用。
在Java8中,String使用char数组存储数据。其实现利用了final关键字,确保其不能被继承、方法不能被子类重写,且初始化之后不能被改变。Java9之后改用了byte数组,同时使用coder来标识编码。
StringBuilder和StringBuffer能解决不可变带来的不便。前者线程不安全;后者线程安全,内部使用synchronized进行同步。两者都继承自AbstractStringBuilder,底层与String类似,Java8是char数组,Java9以后是byte数组。