java——面试题 基础(下)
Java提供了哪些IO方式? NIO如何实现多路复用?
典型回答
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。
首先,传统的 java.io
包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。
java.io
包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。
很多时候,人们也把 java.net
下面提供的部分网络 API,比如 Socket
、ServerSocket
、HttpURLConnection
也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
第二,在 Java 1.4 中引入了 NIO 框架(java.nio
包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
考点分析
上面列出的回答是基于一种常见分类方式,即所谓的 BIO、NIO、NIO 2(AIO)。
在实际面试中,从传统 IO 到 NIO、NIO 2,其中有很多地方可以扩展开来,考察点涉及方方面面,比如:
- 基础 API 功能与设计,
InputStream
/OutputStream
和Reader
/Writer
的关系和区别。 - NIO、NIO 2 的基本组成。
- 给定场景,分别用不同模型实现,分析 BIO、NIO 等模式的设计和实现原理。
- NIO 提供的高性能数据操作方式是基于什么原理,如何使用?
- 或者,从开发者的角度来看,你觉得 NIO 自身实现存在哪些问题?有什么改进的想法吗?
知识扩展
首先,需要澄清一些基本概念:
- 区分同步或异步(
synchronous
/asynchronous
)。简单来说,同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系。 - 区分阻塞与非阻塞(
blocking
/non-blocking
)。在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如ServerSocket
新连接建立完毕,或数据读取、写入操作完成;而非阻塞则是不管 IO 操作是否结束,直接返回,相应操作在后台继续处理。
不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。
对于 java.io,我们都非常熟悉,我这里就从总体上进行一下总结,如果需要学习更加具体的操作,你可以通过教程等途径完成。总体上,我认为你至少需要理解:
- IO 不仅仅是对文件的操作,网络编程中,比如 Socket 通信,都是典型的 IO 操作目标。
- 输入流、输出流(
InputStream
/OutputStream
)是用于读取或写入字节的,例如操作图片文件。 - 而
Reader
/Writer
则是用于操作字符,增加了字符编解码等功能,适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节,不管是网络通信还是文件读取,Reader
/Writer
相当于构建了应用逻辑和原始数据之间的桥梁。 BufferedOutputStream
等带缓冲区的实现,可以避免频繁的磁盘读写,进而提高 IO 处理效率。这种设计利用了缓冲区,将批量数据进行一次操作,但在使用中千万别忘了flush
。- 参考下面这张类图,很多 IO 工具类都实现了
Closeable
接口,因为需要进行资源的释放。比如,打开FileInputStream
,它就会获取相应的文件描述符(FileDescriptor
),需要利用try-with-resources
、try-finally
等机制保证FileInputStream
被明确关闭,进而相应文件描述符也会失效,否则将导致资源无法被释放。利用Cleaner
或finalize
机制作为资源释放的最后把关,也是必要的。
下面是整理的一个简化版的类图,阐述了日常开发应用较多的类型和结构关系。
Java NIO 概览
首先,熟悉一下 NIO 的主要组成部分:
-
Buffer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的 Buffer 实现。
-
Channel,类似在 Linux 之类操作系统上看到的文件描述符,是 NIO 中被用来支持批量式 IO 操作的一种抽象。
File 或者 Socket,通常被认为是比较高层次的抽象,而 Channel 则是更加操作系统底层的一种抽象,这也使得 NIO 得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct Memory Access)等。不同层次的抽象是相互关联的,我们可以通过 Socket 获取 Channel,反之亦然。
-
Selector,是 NIO 实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态,进而实现了单线程对多 Channel 的高效管理。
Selector 同样是基于底层操作系统机制,不同模式、不同版本都存在区别,例如,在最新的代码库里,相关实现如下:
Linux 上依赖于 epoll
(http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java)。
Windows 上 NIO2(AIO)模式则是依赖于 iocp(http://hg.openjdk.java.net/jdk/jdk/file/d8327f838b88/src/java.base/windows/classes/sun/nio/ch/Iocp.java)。
Chartset
,提供 Unicode 字符串定义,NIO 也提供了相应的编解码器等,例如,通过下面的方式进行字符串到ByteBuffer
的转换:
Charset.defaultCharset().encode("Hello world!"));
NIO 能解决什么问题?
下面我通过一个典型场景,来分析为什么需要 NIO,为什么需要多路复用。设想,我们需要实现一个服务器应用,只简单要求能够同时服务多个客户端请求即可。
使用 java.io
和 java.net
中的同步、阻塞式 API,可以简单实现。
public class DemoServer extends Thread {
private ServerSocket serverSocket;
public int getPort() {
return serverSocket.getLocalPort();
}
public void run() {
try {
serverSocket = new ServerSocket(0);
while (true) {
Socket socket = serverSocket.accept();
RequestHandler requestHandler = new RequestHandler(socket);
requestHandler.start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
;
}
}
}
public static void main(String[] args) throws IOException {
DemoServer server = new DemoServer();
server.start();
try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream()));
bufferedReader.lines().forEach(s -> System.out.println(s));
}
}
}
// 简化实现,不做读取,直接发送字符串
class RequestHandler extends Thread {
private Socket socket;
RequestHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {
out.println("Hello world!");
out.flush();
} catch (Exception e) {
e.printStackTrace();
}
}
}
其实现要点是:
- 服务器端启动
ServerSocket
,端口 0 表示自动绑定一个空闲端口。 - 调用 accept 方法,阻塞等待客户端连接。
- 利用 Socket 模拟了一个简单的客户端,只进行连接、读取、打印。
- 当连接建立后,启动一个单独线程负责回复客户端请求。
这样,一个简单的 Socket 服务器就被实现出来了。
思考一下,这个解决方案在扩展性方面,可能存在什么潜在问题呢?
大家知道 Java 语言目前的线程实现是比较重量级的,启动或者销毁一个线程是有明显开销的,每个线程都有单独的线程栈等结构,需要占用非常明显的内存,所以,每一个 Client 启动一个线程似乎都有些浪费。
那么,稍微修正一下这个问题,我们引入线程池机制来避免浪费。
serverSocket = new ServerSocket(0);
executor = Executors.newFixedThreadPool(8);
while (true) {
Socket socket = serverSocket.accept();
RequestHandler requestHandler = new RequestHandler(socket);
executor.execute(requestHandler);
}
这样做似乎好了很多,通过一个固定大小的线程池,来负责管理工作线程,避免频繁创建、销毁线程的开销,这是我们构建并发服务的典型方式。这种工作方式,可以参考下图来理解。
如果连接数并不是非常多,只有最多几百个连接的普通应用,这种模式往往可以工作的很好。但是,如果连接数量急剧上升,这种实现方式就无法很好地工作了,因为线程上下文切换开销会在高并发时变得很明显,这是同步阻塞方式的低扩展性劣势。
NIO 引入的多路复用机制,提供了另外一种思路,请参考下面提供的新的版本。
public class NIOServer extends Thread {
public void run() {
try (Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 创建 Selector 和 Channel
serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
serverSocket.configureBlocking(false);
// 注册到 Selector,并说明关注点
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();// 阻塞等待就绪的 Channel,这是关键点之一
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectedKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 生产系统中一般会额外进行就绪状态检查
sayHelloWorld((ServerSocketChannel) key.channel());
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void sayHelloWorld(ServerSocketChannel server) throws IOException {
try (SocketChannel client = server.accept();) { client.write(Charset.defaultCharset().encode("Hello world!"));
}
}
// 省略了与前面类似的 main
}
这个非常精简的样例掀开了 NIO 多路复用的面纱,我们可以分析下主要步骤和元素:
-
首先,通过
Selector.open()
创建一个Selector
,作为类似调度员的角色。 -
然后,创建一个
ServerSocketChannel
,并且向Selector
注册,通过指定SelectionKey.OP_ACCEPT
,告诉调度员,它关注的是新的连接请求。注意,为什么我们要明确配置非阻塞模式呢?这是因为阻塞模式下,注册操作是不允许的,会抛出
IllegalBlockingModeException
异常。 -
Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒。
-
在
sayHelloWorld
方法中,通过SocketChannel
和Buffer
进行数据操作,在本例中是发送了一段字符串。
可以看到,在前面两个样例中,IO 都是同步阻塞模式,所以需要多线程以实现多任务处理。而 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。下面这张图对这种实现思路进行了形象地说明。
在 Java 7 引入的 NIO 2 中,又增添了一种额外的异步 IO 模式,利用事件和回调,处理 Accept
、Read
等操作。 AIO 实现看起来是类似这样子:
AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr);
serverSock.accept(serverSock, new CompletionHandler<>() { // 为异步操作指定 CompletionHandler 回调函数
@Override
public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {
serverSock.accept(serverSock, this);
// 另外一个 write(sock,CompletionHandler{})
sayHelloWorld(sockChannel, Charset.defaultCharset().encode
("Hello World!"));
}
// 省略其他路径处理方法...
});
Java有几种文件拷贝方式?哪一种最高效?
典型回答
Java 有多种比较典型的文件拷贝实现方式,比如:
利用 java.io
类库,直接为源文件构建一个 FileInputStream
读取,然后再为目标文件构建一个 FileOutputStream
,完成写入工作。
public static void copyFileByStream(File source, File dest) throws IOException {
try (InputStream is = new FileInputStream(source);
OutputStream os = new FileOutputStream(dest);){
byte[] buffer = new byte[1024];
int length;
while ((length = is.read(buffer)) > 0) {
os.write(buffer, 0, length);
}
}
}
或者,利用 java.nio
类库提供的 transferTo
或 transferFrom
方法实现。
public static void copyFileByChannel(File source, File dest) throws IOException {
try (FileChannel sourceChannel = new FileInputStream(source).getChannel();
FileChannel targetChannel = new FileOutputStream(dest).getChannel();){
for (long count = sourceChannel.size() ;count>0 ;) {
long transferred = sourceChannel.transferTo(
sourceChannel.position(),
count,
targetChannel); sourceChannel.position(sourceChannel.position() + transferred);
count -= transferred;
}
}
}
当然,Java 标准类库本身已经提供了几种 Files.copy
的实现。
对于 Copy 的效率,这个其实与操作系统和配置等情况相关,总体上来说,NIO transferTo/From
的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。
考点分析
从面试的角度来看,确实是一个面试考察的点,针对上面的典型回答,面试官还可能会从实践角度,或者 IO 底层实现机制等方面进一步提问。这一讲的内容从面试题出发,主要还是为了让你进一步加深对 Java IO 类库设计和实现的了解。
从实践角度,前面并没有明确说 NIO transfer 的方案一定最快,真实情况也确实未必如此。我们可以根据理论分析给出可行的推断,保持合理的怀疑,给出验证结论的思路,有时候面试官考察的就是如何将猜测变成可验证的结论,思考方式远比记住结论重要。
从技术角度展开,下面这些方面值得注意:
- 不同的 copy 方式,底层机制有什么区别?
- 为什么零拷贝(zero-copy)可能有性能优势?
- Buffer 分类与使用。
- Direct Buffer 对垃圾收集等方面的影响与实践选择。
知识扩展
拷贝实现机制分析
先来理解一下,前面实现的不同拷贝方法,本质上有什么明显的区别。
首先,你需要理解用户态空间(User Space)和内核态空间(Kernel Space),这是操作系统层面的基本概念,操作系统内核、硬件驱动等运行在内核态空间,具有相对高的特权;而用户态空间,则是给普通应用和服务使用。你可以参考:https://en.wikipedia.org/wiki/User_space。
当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。
写入操作也是类似,仅仅是步骤相反,你可以参考下面这张图。
所以,这种方式会带来一定的额外开销,可能会降低 IO 效率。
而基于 NIO transferTo
的实现方式,在 Linux 和 Unix 上,则会使用到零拷贝技术,数据传输并不需要用户态参与,省去了上下文切换的开销和不必要的内存拷贝,进而可能提高应用拷贝性能。注意,transferTo
不仅仅是可以用在文件拷贝中,与其类似的,例如读取磁盘文件,然后进行 Socket 发送,同样可以享受这种机制带来的性能和扩展性提高。
transferTo
的传输过程是:
Java IO/NIO 源码结构
在典型回答中提了第三种方式,即 Java 标准库也提供了文件拷贝方法(java.nio.file.Files.copy
)。如果你这样回答,就一定要小心了,因为很少有问题的答案是仅仅调用某个方法。从面试的角度,面试官往往会追问:既然你提到了标准库,那么它是怎么实现的呢?有的公司面试官以喜欢追问而出名,直到追问到你说不知道。
其实,这个问题的答案还真不是那么直观,因为实际上有几个不同的 copy 方法。
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
public static long copy(InputStream in, Path target, CopyOption... options)
throws IOException
public static long copy(Path source, OutputStream out) throws IOException
可以看到,copy 不仅仅是支持文件之间操作,没有人限定输入输出流一定是针对文件的,这是两个很实用的工具方法。
后面两种 copy 实现,能够在方法实现里直接看到使用的是 InputStream.transferTo()
,你可以直接看源码,其内部实现其实是 stream
在用户态的读写;而对于第一种方法的分析过程要相对麻烦一些,可以参考下面片段。简单起见,只分析同类型文件系统拷贝过程。
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
{
FileSystemProvider provider = provider(source);
if (provider(target) == provider) {
// same provider
provider.copy(source, target, options);// 这是本文分析的路径
} else {
// different providers
CopyMoveHelper.copyToForeignTarget(source, target, options);
}
return target;
}
把源码分析过程简单记录如下,JDK 的源代码中,内部实现和公共 API 定义也不是可以能够简单关联上的,NIO 部分代码甚至是定义为模板而不是 Java 源文件,在 build 过程自动生成源码,下面顺便介绍一下部分 JDK 代码机制和如何绕过隐藏障碍。
- 首先,直接跟踪,发现
FileSystemProvider
只是个抽象类,阅读它的源码能够理解到,原来文件系统实际逻辑存在于 JDK 内部实现里,公共 API 其实是通过ServiceLoader
机制加载一系列文件系统实现,然后提供服务。 - 我们可以在 JDK 源码里搜索
FileSystemProvider
和nio
,可以定位到sun/nio/fs,我们知道 NIO 底层是和操作系统紧密相关的,所以每个平台都有自己的部分特有文件系统逻辑。
- 省略掉一些细节,最后我们一步步定位到
UnixFileSystemProvider
→UnixCopyFile.Transfer
,发现这是个本地方法。 - 最后,明确定位到UnixCopyFile.c,其内部实现清楚说明竟然只是简单的用户态空间拷贝!
所以,我们明确这个最常见的 copy 方法其实不是利用 transferTo
,而是本地技术实现的用户态拷贝。
前面谈了不少机制和源码,简单从实践角度总结一下,如何提高类似拷贝等 IO 操作的性能,有一些宽泛的原则:
- 在程序中,使用缓存等机制,合理减少 IO 次数(在网络通信中,如 TCP 传输,window 大小也可以看作是类似思路)。
- 使用
transferTo
等机制,减少上下文切换和额外 IO 操作。 - 尽量减少不必要的转换过程,比如编解码;对象序列化和反序列化,比如操作文本文件或者网络通信,如果不是过程中需要使用文本信息,可以考虑不要将二进制信息转换成字符串,直接传输二进制信息。
掌握 NIO Buffer
Buffer 是 NIO 操作数据的基本工具,Java 为每种原始数据类型都提供了相应的 Buffer 实现(布尔除外),所以掌握和使用 Buffer 是十分必要的,尤其是涉及 Direct Buffer 等使用,因为其在垃圾收集等方面的特殊性,更要重点掌握。
Buffer 有几个基本属性:
- capcity,它反映这个 Buffer 到底有多大,也就是数组的长度。
- position,要操作的数据起始位置。
- limit,相当于操作的限额。在读取或者写入时,limit 的意义很明显是不一样的。比如,读取操作时,很可能将 limit 设置到所容纳数据的上限;而在写入时,则会设置容量或容量以下的可写限度。
- mark,记录上一次 postion 的位置,默认是 0,算是一个便利性的考虑,往往不是必须的。
前面三个是我们日常使用最频繁的,简单梳理下 Buffer 的基本操作:
- 我们创建了一个
ByteBuffer
,准备放入数据,capcity
当然就是缓冲区大小,而 position 就是 0,limit 默认就是capcity
的大小。 - 当我们写入几个字节的数据时,position 就会跟着水涨船高,但是它不可能超过 limit 的大小。
- 如果我们想把前面写入的数据读出来,需要调用 flip 方法,将 position 设置为 0,limit 设置为以前的 position 那里。
- 如果还想从头再读一遍,可以调用 rewind,让 limit 不变,position 再次设置为 0。
更进一步的详细使用,我建议参考相关教程。
Direct Buffer 和垃圾收集
这里重点介绍两种特别的 Buffer。
Direct Buffer
:如果我们看 Buffer 的方法定义,你会发现它定义了isDirect()
方法,返回当前 Buffer 是否是 Direct 类型。这是因为 Java 提供了堆内和堆外(Direct)Buffer,我们可以以它的allocate
或者allocateDirect
方法直接创建。MappedByteBuffer
:它将文件按照指定大小直接映射为内存区域,当程序访问这个内存区域时将直接操作这块儿文件数据,省去了将数据从内核空间向用户空间传输的损耗。我们可以使用FileChannel.map创建MappedByteBuffer
,它本质上也是种 Direct Buffer。
在实际使用中,Java 会尽量对 Direct Buffer 仅做本地 IO 操作,对于很多大数据量的 IO 密集操作,可能会带来非常大的性能优势,因为:
- Direct Buffer 生命周期内内存地址都不会再发生更改,进而内核可以安全地对其进行访问,很多 IO 操作会很高效。
- 减少了堆内对象存储的可能额外维护工作,所以访问效率可能有所提高。
但是请注意,Direct Buffer 创建和销毁过程中,都会比一般的堆内 Buffer 增加部分开销,所以通常都建议用于长期使用、数据较大的场景。
使用 Direct Buffer,我们需要清楚它对内存和 JVM 参数的影响。首先,因为它不在堆上,所以 Xmx 之类参数,其实并不能影响 Direct Buffer 等堆外成员所使用的内存额度,我们可以使用下面参数设置大小:
-XX:MaxDirectMemorySize=512M
从参数设置和内存问题排查角度来看,这意味着我们在计算 Java 可以使用的内存大小的时候,不能只考虑堆的需要,还有 Direct Buffer 等一系列堆外因素。如果出现内存不足,堆外内存占用也是一种可能性。
另外,大多数垃圾收集过程中,都不会主动收集 Direct Buffer,它的垃圾收集过程,就是基于Cleaner
(一个内部实现)和幻象引用(PhantomReference
)机制,其本身不是 public
类型,内部实现了一个 Deallocator
负责销毁的逻辑。对它的销毁往往要拖到 full GC 的时候,所以使用不当很容易导致 OutOfMemoryError
。
对于 Direct Buffer 的回收,我有几个建议:
- 在应用程序中,显式地调用
System.gc()
来强制触发。 - 另外一种思路是,在大量使用 Direct Buffer 的部分框架中,框架会自己在程序中调用释放方法,Netty 就是这么做的,有兴趣可以参考其实现(
PlatformDependent0
)。 - 重复使用 Direct Buffer。
跟踪和诊断 Direct Buffer 内存占用?
因为通常的垃圾收集日志等记录,并不包含 Direct Buffer 等信息,所以 Direct Buffer 内存诊断也是个比较头疼的事情。幸好,在 JDK 8 之后的版本,我们可以方便地使用 Native Memory Tracking(NMT)特性来进行诊断,你可以在程序启动时加上下面参数:
-XX:NativeMemoryTracking={summary|detail}
注意,激活 NMT 通常都会导致 JVM 出现 5%~10% 的性能下降,请谨慎考虑。
运行时,可以采用下面命令进行交互式对比:
// 打印 NMT 信息
jcmd <pid> VM.native_memory detail
// 进行 baseline,以对比分配内存变化
jcmd <pid> VM.native_memory baseline
// 进行 baseline,以对比分配内存变化
jcmd <pid> VM.native_memory detail.diff
我们可以在 Internal 部分发现 Direct Buffer 内存使用的信息,这是因为其底层实际是利用 unsafe_allocatememory
。严格说,这不是 JVM 内部使用的内存,所以在 JDK 11 以后,其实它是归类在 other 部分里。
JDK 9 的输出片段如下,“+”表示的就是 diff 命令发现的分配变化:
-Internal (reserved=679KB +4KB, committed=679KB +4KB)
(malloc=615KB +4KB #1571 +4)
(mmap: reserved=64KB, committed=64KB)
注意:JVM 的堆外内存远不止 Direct Buffer,NMT 输出的信息当然也远不止这些。
谈谈接口和抽象类有什么区别?
典型回答
接口和抽象类是 Java 面向对象设计的两个基础机制。
接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。接口,不能实例化;不能包含任何非常量成员,任何 field
都是隐含着 public static final
的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。Java 标准类库中,定义了非常多的接口,比如 java.util.List
。
抽象类是不能实例化的类,用 abstract
关键字修饰 class,其目的主要是代码重用。除了不能实例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。Java 标准库中,比如 collection
框架,很多通用部分就被抽取成为抽象类,例如 java.util.AbstractList
。
Java 类实现 interface
使用 implements
关键词,继承 abstract class 则是使用 extends
关键词,我们可以参考 Java 标准库中的 ArrayList
。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//...
}
考点分析
这是个非常高频的 Java 面向对象基础问题,看起来非常简单的问题,如果面试官稍微深入一些,你会发现很多有意思的地方,可以从不同角度全面地考察你对基本机制的理解和掌握。比如:
- 对于 Java 的基本元素的语法是否理解准确。能否定义出语法基本正确的接口、抽象类或者相关继承实现,涉及重载(Overload)、重写(Override)更是有各种不同的题目。
- 在软件设计开发中妥善地使用接口和抽象类。你至少知道典型应用场景,掌握基础类库重要接口的使用;掌握设计方法,能够在 review 代码的时候看出明显的不利于未来维护的设计。
- 掌握 Java 语言特性演进。现在非常多的框架已经是基于 Java 8,并逐渐支持更新版本,掌握相关语法,理解设计目的是很有必要的。
知识扩展
会从接口、抽象类的一些实践,以及语言变化方面去阐述一些扩展知识点。
Java 相比于其他面向对象语言,如 C++,设计上有一些基本区别,比如Java 不支持多继承。这种限制,在规范了代码实现的同时,也产生了一些局限性,影响着程序设计结构。Java 类可以实现多个接口,因为接口是抽象方法的集合,所以这是声明性的,但不能通过扩展多个抽象类来重用逻辑。
在一些情况下存在特定场景,需要抽象出与具体实现、实例化无关的通用逻辑,或者纯调用关系的逻辑,但是使用传统的抽象类会陷入到单继承的窘境。以往常见的做法是,实现由静态方法组成的工具类(Utils),比如 java.util.Collections
。
设想,为接口添加任何抽象方法,相应的所有实现了这个接口的类,也必须实现新增方法,否则会出现编译错误。对于抽象类,如果我们添加非抽象方法,其子类只会享受到能力扩展,而不用担心编译出问题。
接口的职责也不仅仅限于抽象方法的集合,其实有各种不同的实践。有一类没有任何方法的接口,通常叫作 Marker Interface,顾名思义,它的目的就是为了声明某些东西,比如我们熟知的 Cloneable、Serializable 等。这种用法,也存在于业界其他的 Java 产品代码中。
从表面看,这似乎和 Annotation 异曲同工,也确实如此,它的好处是简单直接。对于 Annotation,因为可以指定参数和值,在表达能力上要更强大一些,所以更多人选择使用 Annotation。
Java 8 增加了函数式编程的支持,所以又增加了一类定义,即所谓 functional interface,简单说就是只有一个抽象方法的接口,通常建议使用 @FunctionalInterface Annotation
来标记。Lambda 表达式本身可以看作是一类 functional interface,某种程度上这和面向对象可以算是两码事。我们熟知的 Runnable、Callable 之类,都是 functional interface,这里不再多介绍了,有兴趣你可以参考:https://www.oreilly.com/learning/java-8-functional-interfaces 。
还有一点可能让人感到意外,严格说,Java 8 以后,接口也是可以有方法实现的!
从 Java 8 开始,interface 增加了对 default method 的支持。Java 9 以后,甚至可以定义 private default method。Default method 提供了一种二进制兼容的扩展已有接口的办法。比如,我们熟知的 java.util.Collection
,它是 collection 体系的 root interface,在 Java 8 中添加了一系列 default method,主要是增加 Lambda、Stream 相关的功能。类似 Collections 之类的工具类,很多方法都适合作为 default method 实现在基础接口里面。
你可以参考下面代码片段:
public interface Collection<E> extends Iterable<E> {
/**
* Returns a sequential Stream with this collection as its source
* ...
**/
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
}
面向对象设计
谈到面向对象,很多人就会想起设计模式,那些是非常经典的问题和设计方法的总结。来夯实一下基础,先来聊聊面向对象设计的基本方面。
我们一定要清楚面向对象的基本要素:封装、继承、多态。
封装的目的是隐藏事务内部的实现细节,以便提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。我们在日常开发中,因为无意间暴露了细节导致的难缠 bug 太多了,比如在多线程环境暴露内部状态,导致的并发修改问题。从另外一个角度看,封装这种隐藏,也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。
继承是代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。
多态,你可能立即会想到重写(override)和重载(overload)、向上转型。简单说,重写是父子类中相同名字和参数的方法,不同的实现;重载则是相同名字的方法,但是不同的参数,本质上这些方法签名是不一样的,为了更好说明,请参考下面的样例代码:
public int doSomething() {
return 0;
}
// 输入参数不同,意味着方法签名不同,重载的体现
public int doSomething(List<String> strs) {
return 0;
}
// return 类型不一样,编译不能通过
public short doSomething() {
return 0;
}
这里你可以思考一个小问题,方法名称和参数一致,但是返回值不同,这种情况在 Java 代码中算是有效的重载吗? 答案是不是的,编译都会出错的。
进行面向对象编程,掌握基本的设计原则是必须的,介绍最通用的部分,也就是所谓的 S.O.L.I.D 原则。
- 单一职责(Single Responsibility),类或者对象最好是只有单一职责,在程序设计中如果发现某个类承担着多种义务,可以考虑进行拆分。
- 开关原则(Open-Close, Open for extension, close for modification),设计要对扩展开放,对修改关闭。换句话说,程序设计应保证平滑的扩展性,尽量避免因为新增同类功能而修改已有实现,这样可以少产出些回归(regression)问题。
- 里氏替换(Liskov Substitution),这是面向对象的基本要素之一,进行继承关系抽象时,凡是可以用父类或者基类的地方,都可以用子类替换。
- 接口分离(Interface Segregation),我们在进行类和接口设计时,如果在一个接口里定义了太多方法,其子类很可能面临两难,就是只有部分方法对它是有意义的,这就破坏了程序的内聚性。
对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。 - 依赖反转(Dependency Inversion),实体应该依赖于抽象而不是实现。也就是说高层次模块,不应该依赖于低层次模块,而是应该基于抽象。实践这一原则是保证产品代码之间适当耦合度的法宝。
OOP 原则实践中的取舍
值得注意的是,现代语言的发展,很多时候并不是完全遵守前面的原则的,比如,Java 10 中引入了本地方法类型推断和 var
类型。按照,里氏替换原则,我们通常这样定义变量:
List<String> list = new ArrayList<>();
如果使用 var 类型,可以简化为
var list = new ArrayList<String>();
但是,list 实际会被推断为“ArrayList < String >
”
ArrayList<String> list = new ArrayList<String>();
理论上,这种语法上的便利,其实是增强了程序对实现的依赖,但是微小的类型泄漏却带来了书写的便利和代码可读性的提高,所以,实践中我们还是要按照得失利弊进行选择,而不是一味得遵循原则。
OOP 原则在面试题目中的分析
在以往面试中发现,即使是有多年编程经验的工程师,也还没有真正掌握面向对象设计的基本的原则,如开关原则(Open-Close)。看看下面这段代码,改编自朋友圈盛传的某伟大公司产品代码,你觉得可以利用面向对象设计原则如何改进?
public class VIPCenter {
void serviceVIP(T extend User user>) {
if (user instanceof SlumDogVIP) {
// 穷 X VIP,活动抢的那种
// do somthing
} else if(user instanceof RealVIP) {
// do somthing
}
// ...
}
这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。
利用开关原则,我们可以尝试改造为下面的代码:
public class VIPCenter {
private Map<User.TYPE, ServiceProvider> providers;
void serviceVIP(T extend User user) {
providers.get(user.getType()).service(user);
}
}
interface ServiceProvider{
void service(T extend User user) ;
}
class SlumDogVIPServiceProvider implements ServiceProvider{
void service(T extend User user){
// do somthing
}
}
class RealVIPServiceProvider implements ServiceProvider{
void service(T extend User user) {
// do something
}
}
上面的示例,将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展。
谈谈你知道的设计模式?
典型回答
大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。
- 创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式(Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模式(ProtoType)。
- 结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式(Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)等。
- 行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)。
考点分析
这个问题主要是考察你对设计模式的了解和掌握程度,更多相关内容你可以参考:https://en.wikipedia.org/wiki/Design_Patterns。
建议可以在回答时适当地举些例子,更加清晰地说明典型模式到底是什么样子,典型使用场景是怎样的。这里举个 Java 基础类库中的例子供你参考。
首先,我们知道 InputStream
是一个抽象类,标准类库中提供了 FileInputStream
、ByteArrayInputStream
等各种不同的子类,分别从不同角度对 InputStream
进行了功能扩展,这是典型的装饰器模式应用案例。
识别装饰器模式,可以通过识别类设计特征来进行判断,也就是其类构造函数以相同的抽象类或者接口为输入参数。
因为装饰器模式本质上是包装同类型实例,我们对目标对象的调用,往往会通过包装类覆盖过的方法,迂回调用被包装的实例,这就可以很自然地实现增加额外逻辑的目的,也就是所谓的“装饰”。
例如,BufferedInputStream 经过包装,为输入流过程增加缓存,类似这种装饰器还可以多次嵌套,不断地增加不同层次的功能。
public BufferedInputStream(InputStream in)
在下面的类图里,简单总结了 InputStream 的装饰模式实践。
接下来再看第二个例子。创建型模式尤其是工厂模式,在我们的代码中随处可见,举个相对不同的 API 设计实践。比如,JDK 最新版本中 HTTP/2 Client API,下面这个创建 HttpRequest 的过程,就是典型的构建器模式(Builder),通常会被实现成fluent 风格的 API,也有人叫它方法链。
HttpRequest request = HttpRequest.newBuilder(new URI(uri))
.header(headerAlice, valueAlice)
.headers(headerBob, value1Bob,
headerCarl, valueCarl,
headerBob, value2Bob)
.GET()
.build();
使用构建器模式,可以比较优雅地解决构建复杂对象的麻烦,这里的“复杂”是指类似需要输入的参数组合较多,如果用构造函数,我们往往需要为每一种可能的输入参数组合实现相应的构造函数,一系列复杂的构造函数会让代码阅读性和可维护性变得很差。
上面的分析也进一步反映了创建型模式的初衷,即,将对象创建过程单独抽象出来,从结构上把对象使用逻辑和创建逻辑相互独立,隐藏对象实例的细节,进而为使用者实现了更加规范、统一的逻辑。
更进一步进行设计模式考察,面试官可能会:
- 希望你写一个典型的设计模式实现。这虽然看似简单,但即使是最简单的单例,也能够综合考察代码基本功。
- 考察典型的设计模式使用,尤其是结合标准库或者主流开源框架,考察你对业界良好实践的掌握程度。
在面试时如果恰好问到你不熟悉的模式,你可以稍微引导一下,比如介绍你在产品中使用了什么自己相对熟悉的模式,试图解决什么问题,它们的优点和缺点等。
知识扩展
我们来实现一个日常非常熟悉的单例设计模式。看起来似乎很简单,那么下面这个样例符合基本需求吗?
public class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
是不是总感觉缺了点什么?原来,Java 会自动为没有明确声明构造函数的类,定义一个 public 的无参数的构造函数,所以上面的例子并不能保证额外的对象不被创建出来,别人完全可以直接“new Singleton()”,那我们应该怎么处理呢?
不错,可以为单例定义一个 private 的构造函数(也有建议声明为枚举,这是有争议的,我个人不建议选择相对复杂的枚举,毕竟日常开发不是学术研究)。这样还有什么改进的余地吗?
标准类库中很多地方使用懒加载(lazy-load),改善初始内存开销,单例同样适用,下面是修正后的改进版本。
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这个实现在单线程环境不存在问题,但是如果处于并发场景,就需要考虑线程安全,最熟悉的就莫过于“双检锁”,其要点在于:
- 这里的 volatile 能够提供可见性,以及保证 getInstance 返回的是初始化完全的对象。
- 在同步之前进行 null 检查,以尽量避免进入相对昂贵的同步块。
- 直接在 class 级别进行同步,保证线程安全的类方法调用。
public class Singleton {
private static volatile Singleton singleton = null;
private Singleton() {
}
public static Singleton getSingleton() {
if (singleton == null) { // 尽量避免重复进入同步块
synchronized (Singleton.class) { // 同步.class,意味着对同步类方法调用
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
在这段代码中,争论较多的是 volatile 修饰静态变量,当 Singleton 类本身有多个成员变量时,需要保证初始化过程完成后,才能被 get 到。
在现代 Java 中,内存排序模型(JMM)已经非常完善,通过 volatile 的 write 或者 read,能保证所谓的 happen-before,也就是避免常被提到的指令重排。换句话说,构造对象的 store 指令能够被保证一定在 volatile read 之前。
当然,也有一些人推荐利用内部类持有静态对象的方式实现,其理论依据是对象初始化过程中隐含的初始化锁(有兴趣的话你可以参考jls-12.4.2 中对 LC 的说明),这种和前面的双检锁实现都能保证线程安全,不过语法稍显晦涩,未必有特别的优势。
public class Singleton {
private Singleton(){}
public static Singleton getSingleton(){
return Holder.singleton;
}
private static class Holder {
private static Singleton singleton = new Singleton();
}
}
所以,可以看出,即使是看似最简单的单例模式,在增加各种高标准需求之后,同样需要非常多的实现考量。
上面是比较学究的考察,其实实践中未必需要如此复杂,如果我们看 Java 核心类库自己的单例实现,比如java.lang.Runtime,你会发现:
- 它并没使用复杂的双检锁之类。
- 静态实例被声明为 final,这是被通常实践忽略的,一定程度保证了实例不被篡改(反射之类可以绕过私有访问限制),也有有限的保证执行顺序的语义。
private static final Runtime currentRuntime = new Runtime();
private static Version version;
// …
public static Runtime getRuntime() {
return currentRuntime;
}
/** Don't let anyone else instantiate this class */
private Runtime() {}
前面说了不少代码实践,下面一起来简要看看主流开源框架,如 Spring 等如何在 API 设计中使用设计模式。你至少要有个大体的印象,如:
- BeanFactory和ApplicationContext应用了工厂模式。
- 在 Bean 的创建中,Spring 也为不同 scope 定义的对象,提供了单例和原型等模式实现。
- AOP 领域则是使用了代理模式、装饰器模式、适配器模式等。
- 各种事件监听器,是观察者模式的典型应用。
- 类似 JdbcTemplate 等则是应用了模板模式。