网易的Java服务端技术面总结
说下这次面试的过程:猎头先联系我问我有没有去网易面试的意向,我本着想混混面试经验的目的就答应下来了。猎头联系我的第3天,网易就安排了第一次技术面。面试是远程进行的,耗时1个半小时。下面来分析下技术面一面的内容(部分问题忘记了,这里列出我有印象的):
面试官的问题:
- ArrayList的结构是怎么样的?如何保证在扩容数组时保证高效率?如果考虑扩容 add的时间复杂度是多少?
- HashMap的结构是怎么样的?除了数组加链表(红黑树)这种结构,还有没有其他结构的HashMap(这个问题我记得不是很清楚,有可能问的是还有没有其他结构的Map)?
- List<? super T>、List<? extend T>、Enum<E extend Enum<E>>三种泛型的区别。1、2里面可以加什么类型的对象,取出的对象类型是什么?
- Java有哪些原生线程池?构建这些线程池传的参数都有什么?这些参数作用是什么?
- 类是如何被加载的?ClassLoader是怎么工作的?一个class能否被两个不同的ClassLoader加载?
- 说一下你最近做过的一个大功能。
题目解说:
网易的技术一面试题都并不难,考的比较基础而且实在。主要是围绕候选人基础知识的方向去考,没有太多很偏很难的问题。如果想要回答好这些题,并且给面试官留下好的印象。那么就不能在回答的时候仅仅就问题本身作答,要回答的有深度并且扩展更多自己有把握的知识点,比如:说出问题背后知识的原理和设计哲学;说出自己使用的经验和心得,我这里只对问题本身做一些知识背景介绍,各位可以思考一下当你面对这些问题时,什么样的答案才是让你自己满意的答案。
-
ArrayList(面试老搭档了)
- 底层是用数组来保存有序数据的,每一次add都先判断数组长度是否足够,如果数组长度不够就扩容,然后往当前位置的后一位插入数据。remove的过程并不会缩小数据组的长度,如果remove的位置后面还有数据,就把后面的数据复制前一位,然后把最后一位置空。
- 先创建一个扩容后长度的数组,然后用System.arraycopy方法来对数据对象进行浅复制,最终得到扩容后的数组
- 我们基本可以把每次扩容看作是数组长度翻一倍,这样的话add的时间复杂度就是log2
-
HashMap(面试老搭档了)
- 结构:数组加链表的形式,key的hash值决定这个键值对在数组中的位置,如果出现位置重复(碰撞),那么该位置就会以链表(或红黑树)的结构存多个值。
- 还有没有其他结构的Map:树+链表的形式也可以。理论上只要能通过key快速定位键值对在这个结构中的位置就行,树结构就是在树中搜索key所在位置找到键值对。
-
泛型(泛型加上类的继承、接口实现,能有效扩大泛型的使用场景)
- List<? super T>:能添加的元素是T或者以T为父类的对象(添加的任意元素能够直接转化成T的任何一个超类),取出来的对象为Object类型。含义:Lits泛型接受T,或者其父类之中包含T的对象(有多重继承的只要父类链条中包含T即可)。List<? super T>是被设计用来添加数据的泛型,并且只能添加T类型或其子类类型的元素。下面的代码使得这一点更容易理解。
class Car {} class Bus extends Car {} void genSuperTest() { List<? super Car> carList = new ArrayList<>(); List<? super Bus> busList = new ArrayList<>(); fatherList.add(new Car()); // 编译通过 fatherList.add(new Bus()); // 编译通过,Bus的直接父类是Car(Bus能转成Car),所以能够加进List<? super Car>里面 sonList.add(new Car()); // 编译错误 - Car不能转成Bus对象 sonList.add(new Bus()); // 编译通过 } void test(List<? super Bus> list) { Object obj = list.get(0); // 编译通过,注意这里取出来的对象被抹除为Object类型了 }
- List<? extend T>:无法往这个list中add对象,这里为什么不能add对象也是一个考点 ———— List<? extend T>要满足队列中任意元素都是T的一个子类,也就是不能存在既有A又有B的情况(A和B都是T的子类)。并且取出的对象类型是T。List<? extends T>是被设计用来读取数据的泛型,并且读取类型为T。
private static void genExtendTest() { List<? extends Car> fatherList = new ArrayList<>(); List<? extends Bus> sonList = new ArrayList<>(); fatherList.add(new Car()); // 编译错误,无法往List<? extend T>中add对象 fatherList.add(new Bus()); // 编译错误,无法往List<? extend T>中add对象 sonList.add(new Car()); // 编译错误,无法往List<? extend T>中add对象 sonList.add(new Bus()); // 编译错误,无法往List<? extend T>中add对象 } private static void test(List<? extends Bus> list) { Bus bus = list.get(0); // 编译通过,并且取出来的对象就是Bus }
关于List<? super T>和List<? extend T>更多信息可以看:https://blog.csdn.net/qq_33591903/article/details/82746794
- 关于Enum<E extend Enum<E>>:https://segmentfault.com/a/1190000038778953
-
Java原生线程池(下面这个四个是原生线程池):一开始我所理解的Java原生线程池就是
java.util.concurrent.Executors
中几个公开的静态方法生成的线程池。所以我就简单说了一下这个几个线程池的内容(简单介绍和用途):-
FixedThreadPool
:固定线程数量的线程池,线程池中线程数量不会超过用户指定的线程数量。任务队列最大长度:Integer.MAX_VALUE(注意避免OOM)。实现类是:ThreadPoolExecutor
; -
SingleThreadExecutor
:单线程的线程池,线程池中最多只会存在一个线程。任务队列最大长度:Integer.MAX_VALUE(注意避免OOM)。实现类是:ThreadPoolExecutor
; -
CachedThreadPool
:可缓存线程的线程池。如果线程池空闲,会按照一定策略回收空闲线程(空闲线程存活时间为60s)。当线程池中没有空闲线程时,它会创建一个新线程来执行新任务(不会进入任务队列等待执行)。实现类是:ThreadPoolExecutor
; -
ScheduledThreadPoolExecutor
:支持定时任务和周期任务的线程池。线程池中线程数量不会超过用户指定的线程数量。实现类是:ScheduledThreadPoolExecutor
;
关于Java有哪些原生线程池的问题,我在面试期间的回答主要是上面这些。但是面试之后我回顾这个知识点的时候发现,我的回答可能并不是面试官想要的答案。上面这些线程池讲到底都是ThreadPoolExecutor
,只不过是不同的策略。FixedThreadPool
;SingleThreadExecutor
;CachedThreadPool
在线程创建和任务执行上机理都是一样的。
如果从更深层次考虑,我认为回答:ForkJoinPool
;ScheduledExecutorService
;ThreadPoolExecutor
会更合理。下面是对这三个线程池实现类的介绍: -
ForkJoinPool
:该线程池的实现机制就是将大任务拆成多个小任务(fork过程),然后再将多个小任务处理结果汇总得到最终的结果(join过程)。就是分治的思想,提供一个线程池来解决大规模的计算问题; -
ScheduledExecutorService
:这个线程池时在ThreadPoolExecutor
的基础上,添加了定时任务和周期任务执行的功能; -
ThreadPoolExecutor
:常用线程池;
下图是java原生线程池的接口、类实现的继承图:
image.png
-
关于线程池再推荐一篇文章:https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html 里面对线程池进行了系统全面的讲解。
- 类的加载(你写的代码到JVM执行的流程):
- 类是如何被加载的:
- 编译:先是编译器将源码文件(一般是xxx.java文件)编译成.class文件,在编译器中完成;
- 加载:将.class文件加载到内存中,在JVM中完成;
- 验证:验证加载的.class文件是否符合虚拟机规范(这一步一般的编译器也会做,但是虚拟机要保证正确性),在JVM中完成;
- 准备:为该类分配内存(静态变量占的空间等等),在JVM中完成;
- 解析:解析类、方法、字段名为引用(调用方法的时候通过引用来调用),在JVM中完成;
- 初始化:静态变量赋值、static模块执行,在JVM中完成;
- ClassLoader是怎么工作的:这里我通过源码来解释:
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException { Class<?> clazz = findLoadedClass(className); // 1. 判断自己这个ClassLoader是否已加载了这个class if (clazz == null) { ClassNotFoundException suppressed = null; try { clazz = parent.loadClass(className, false); // 2. 委托自己的父加载器加载 } catch (ClassNotFoundException e) { suppressed = e; } if (clazz == null) { try { clazz = findClass(className); // 3. 自己的父加载器都没有加载到,就自己加载 } catch (ClassNotFoundException e) { e.addSuppressed(suppressed); throw e; } } } return clazz; }
- 一个class能否被两个不同的ClassLoader加载(这个知识点就涉及到我的知识盲区了):答案是可以。JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例(ClassLoader对象)加载的。只有两者同时满足的情况下,JVM才认为这两个class是相同的。
下面这段是验证代码(需要用到第三方包:com.itranswarp:compiler:1.0):
public class ClassLoaderTest { static final String JAVA_SOURCE_CODE = "package com.test; public class Test {}"; @Test public void test() throws Exception { JavaStringCompiler compiler = new JavaStringCompiler(); // 将源码解析为二进制流,以供ClassLoader加载(同一份源码,转换出来的二进制流是一样的) Map<String, byte[]> results = compiler.compile("Test.java", JAVA_SOURCE_CODE); // 自定义的classLoader加载class Class<?> clazz = compiler.loadClass("com.test.Test", results); JavaStringCompiler compiler1 = new JavaStringCompiler(); // clazz和clazz1是由不同的ClassLoader对象加载出来的 Class<?> clazz1 = compiler1.loadClass("com.test.Test", results); assertNotEquals(clazz, clazz1); assertNotEquals(clazz.newInstance().getClass(), clazz1.newInstance().getClass()); } }
- 类是如何被加载的:
- 根据自己实际情况说明。建议不要投机取巧,把自己明明没有参与的功能说成是自己完成的。
个人总结:
这次面试还是比较顺利的,题目难度比较低不过面试官喜欢深挖,有几次甚至问到很偏的源码实现。因为疫情还没有彻底结束,所以采用的是远程的方式。
个人对面试的两点建议:
- 如果想面试成功一家行业内顶尖的公司,我能给出的最好的建议就是:自己水平一定要达到这家公司的层次,现在的面试内卷程度已经不是几年前了,没有真本事很难通过。
- 老生常谈的问题 —— 就算没有换工作的意向我也建议一年参加有几次面试,这对自己保持危机意识有很大的帮助。在一家公司稳定工作太久,安逸的生活很容易让自己忘掉互联网的残酷。面试尤其是大公司的面试是我能想到成本最低、收益最高的让自己保持危机意识的方法了。
最后吐槽一下:远程面试真的很别扭。