你好,我是yes。
这系列的题都是图文并茂,以理解为主,可以延伸的我都尽量延伸了~
答案都是我原创手打的,如果错误,欢迎指正~
看完的都是勇士,请留言扣 1 让我知道你的牛皮。
话不多说,GOGOGO!
1.你觉得 Java 好在哪儿?
这种笼统的问题如果对某些知识点没有深入、系统地认识绝对会蒙!
所以为什么经常碰到面试官问你一些空、大的问题?其实就是考察你是否有形成体系的理解。
回到问题本身。我觉得可以从跨平台、垃圾回收、生态三个方面来阐述。
首先 Java 是跨平台的,不同平台执行的机器码是不一样的,而 Java 因为加了一层中间层 JVM ,所以可以做到一次编写多平台运行,即 「Write once,Run anywhere」。
编译执行过程是先把 Java 源代码编译成字节码,字节码再由 JVM 解释或 JIT 编译执行,而因为 JIT 编译时需要预热的,所以还提供了 AOT(Ahead-of-Time Compilation),可以直接把字节码转成机器码,来让程序重启之后能迅速拉满战斗力。
(解释执行比编译执行效率差,你想想每次给你英语让你翻译阅读,还是直接给你看中文,哪个快?)
Java 还提供垃圾自动回收功能,虽说手动管理内存意味着自由、精细化地掌控,但是很容易出错。
在内存较充裕的当下,将内存的管理交给 GC 来做,减轻了程序员编程的负担,提升了开发效率,更加划算!
然后现在 Java 生态圈太全了,丰富的第三方类库、网上全面的资料、企业级框架、各种中间件等等,总之你要的都有。
基本上这样答差不多了,之后等着面试官延伸。
当然这种开放性问题没有固定答案,我的回答仅供参考。
2.如果让你设计一个 HashMap 如何设计?
这个问题我觉得可以从 HashMap 的一些关键点入手,例如 hash函数、如何处理冲突、如何扩容。
可以先说下你对 HashMap 的理解。
比如:HashMap 无非就是一个存储 <key,value> 格式的集合,用于通过 key 就能快速查找到 value。
基本原理就是将 key 经过 hash 函数进行散列得到散列值,然后通过散列值对数组取模找到对应的 index 。
所以 hash 函数很关键,不仅运算要快,还需要分布均匀,减少 hash 碰撞。
而因为输入值是无限的,而数组的大小是有限的所以肯定会有碰撞,因此可以采用拉链法来处理冲突。
为了避免恶意的 hash 攻击,当拉链超过一定长度之后可以转为红黑树结构。
当然超过一定的结点还是需要扩容的,不然碰撞就太严重了。
而普通的扩容会导致某次 put 延时较大,特别是 HashMap 存储的数据比较多的时候,所以可以考虑和 redis 那样搞两个 table 延迟移动,一次可以只移动一部分。
不过这样内存比较吃紧,所以也是看场景来 trade off 了。
不过最好使用之前预估准数据大小,避免频繁的扩容。
基本上这样答下来差不多了,HashMap 几个关键要素都包含了,接下来就看面试官怎么问了。
可能会延伸到线程安全之类的问题,反正就照着 currentHashMap 的设计答。
3.并发类库提供的线程池实现有哪些?
虽说阿里巴巴Java 开发手册禁止使用这些实现来创建线程池,但是这问题我被问过好几次,也是热点。
问着问着就会延伸到线程池是怎么设计的。
我先来说下线程池的内部逻辑,这样才能理解这几个实现。
首先线程池有几个关键的配置:核心线程数、最大线程数、空闲存活时间、工作队列、拒绝策略。
- 默认情况下线程不会预创建,所以是来任务之后才会创建线程(设置prestartAllCoreThreads可以预创建核心线程)。
- 当核心线程满了之后不会新建线程,而是把任务堆积到工作队列中。
- 如果工作队列放不下了,然后才会新增线程,直至达到最大线程数。
- 如果工作队列满了,然后也已经达到最大线程数了,这时候来任务会执行拒绝策略。
- 如果线程空闲时间超过空闲存活时间,并且线程线程数是大于核心线程数的则会销毁线程,直到线程数等于核心线程数(设置allowCoreThreadTimeOut 可以回收核心线程)。
我们再回到面试题来,这个实现指的就是 Executors 的 5 个静态工厂方法:
- newFixedThreadPool
- newWorkStealingPool
- newSingleThreadExecutor
- newCachedThreadPool
- newScheduledThreadPool
newFixedThreadPool
这个线程池实现特点是核心线程数和最大线程数是一致的,然后 keepAliveTime 的时间是 0 ,队列是无界队列。
按照这几个设定可以得知它任务线程数是固定,如其名 Fixed。
然后可能出现 OOM 的现象,因为队列是无界的,所以任务可能挤爆内存。
它的特性就是我就固定出这么多线程,多余的任务就排队,就算队伍排爆了我也不管。
因此不建议用这个方式来创建线程池。
newWorkStealingPool
这个是1.8才有的,从代码可以看到返回的就是 ForkJoinPool,我们1.8用的并行流就是这个线程池。
比如users.parallelStream().filter(...).sum();
用的就是 ForkJoinPool 。
从图中可以看到线程数会参照当前服务器可用的处理核心数,我记得并行数是核心数-1。
这个线程池的特性从名字就可以看出 Stealing,会窃取任务。
每个线程都有自己的双端队列,当自己队列的任务处理完毕之后,会去别的线程的任务队列尾部拿任务来执行,加快任务的执行速率。
至于 ForkJoin 的话,就是分而治之,把大任务分解成一个个小任务,然后分配执行之后再总和结果,再详细就自行查阅资料啦~
newSingleThreadExecutor
这个线程池很有个性,一个线程池就一个线程,一个人一座城,配备的也是无界队列。
它的特性就是能保证任务是按顺序执行的。
newCachedThreadPool
这个线程池是急性子,核心线程数是 0 ,最大线程数看作无限,然后任务队列是没有存储空间的,简单理解成来个任务就必须找个线程接着,不然就阻塞了。
cached 意思就是会缓存之前执行过的线程,缓存时间是 60 秒,这个时候如果有任务进来就可以用之前的线程来执行。
所以它适合用在短时间内有大量短任务的场景。如果暂无可用线程,那么来个任务就会新启一个线程去执行这个任务,快速响应任务。
但是如果任务的时间很长,那存在的线程就很多,上下文切换就很频繁,切换的消耗就很明显,并且存在太多线程在内存中,也有 OOM 的风险。
newScheduledThreadPool
其实就是定时执行任务,重点就是那个延时队列。
关于 Java 的几个定时任务调度相关的:Timer、DelayQueue 和 ScheduledThreadPool,我之前文章都分析过了,还介绍了时间轮在netty和kafka中的应用,有兴趣的可以看看。
4.如果让你设计一个线程池如何设计?
这种设计类问题还是一样,先说下理解,表明你是知道这个东西的用处和原理的,然后开始 BB。基本上就是按照现有的设计来说,再添加一些个人见解。
线程池讲白了就是存储线程的一个容器,池内保存之前建立过的线程来重复执行任务,减少创建和销毁线程的开销,提高任务的响应速度,并便于线程的管理。
我个人觉得如果要设计一个线程池的话得考虑池内工作线程的管理、任务编排执行、线程池超负荷处理方案、监控。
初始化线程数、核心线程数、最大线程池都暴露出来可配置,包括超过核心线程数的线程空闲消亡配置。
任务的存储结构可配置,可以是无界队列也可以是有界队列,也可以根据配置分多个队列来分配不同优先级的任务,也可以采用 stealing 的机制来提高线程的利用率。
再提供配置来表明此线程池是 IO 密集还是 CPU 密集型来改变任务的执行策略。
超负荷的方案可以有多种,包括丢弃任务、拒绝任务并抛出异常、丢弃最旧的任务或自定义等等。
线程池埋好点暴露出用于监控的接口,如已处理任务数、待处理任务数、正在运行的线程数、拒绝的任务数等等信息。
我觉得基本上这样答就差不多了,等着面试官的追问就好。
注意不需要跟面试官解释什么叫核心线程数之类的,都懂的没必要。
当然这种开放型问题还是仁者见仁智者见智,我这个不是标准答案,仅供参考。
5. GC 如何调优?
GC 调优这种问题肯定是具体场景具体分析,但是在面试中就不要讲太细,大方向说清楚就行,不需要涉及具体的垃圾收集器比如 CMS 调什么参数,G1 调什么参数之类的。
GC 调优的核心思路就是尽可能的使对象在年轻代被回收,减少对象进入老年代。
具体调优还是得看场景根据 GC 日志具体分析,常见的需要关注的指标是 Young GC 和 Full GC 触发频率、原因、晋升的速率 、老年代内存占用量等等。
比如发现频繁会产生 Full GC,分析日志之后发现没有内存泄漏,只是 Young GC 之后会有大量的对象进入老年代,然后最终触发 Ful GC。所以就能得知是 Survivor 空间设置太小,导致对象过早进入老年代,因此调大 Survivor 。
或者是晋升年龄设置的太小,也有可能分析日志之后发现是内存泄漏、或者有第三方类库调用了 System.gc等等。
反正具体场景具体分析,核心思想就是尽量在新生代把对象给回收了。
基本上这样答就行了,然后就等着面试官延伸了。
6.动态代理是什么?
动态代理就是一个代理机制,动态是相对于静态来说的。
代理可以看作是调用目标的一个包装,通常用来在调用真实的目标之前进行一些逻辑处理,消除一些重复的代码。
静态代理指的是我们预先编码好一个代理类,而动态代理指的是运行时生成代理类。
动态更加方便,可以指定一系列目标来动态生成代理类(AOP),而不像静态代理需要为每个目标类写对应的代理类。
代理也是一种解耦,目标类和调用者之间的解耦,因为多了代理类这一层。
常见的动态代理有 JDK 动态代理 和 CGLIB。
7.JDK 动态代理与 CGLIB 区别?
JDK 动态代理是基于接口的,所以要求代理类一定是有定义接口的。
CGLIB 基于ASM字节码生成工具,它是通过继承的方式来实现代理类,所以要注意 final 方法。
之间的性能随着 JDK 版本的不同而不同,以下内容取自:haiq的博客
- jdk6 下,在运行次数较少的情况下,jdk动态代理与 cglib 差距不明显,甚至更快一些;而当调用次数增加之后, cglib 表现稍微更快一些
- jdk7 下,情况发生了逆转!在运行次数较少(1,000,000)的情况下,jdk动态代理比 cglib 快了差不多30%;而当调用次数增加之后(50,000,000), 动态代理比 cglib 快了接近1倍
- jdk8 表现和 jdk7 基本一致
基本上这样答差不多了,我们再看看 JDK 动态代理实现原理:
- 首先通过实现 InvocationHandler 接口得到一个切面类。
- 然后利用 Proxy 根据目标类的类加载器、接口和切面类得到一个代理类。
- 代理类的逻辑就是把所有接口方法的调用转发到切面类的 invoke() 方法上,然后根据反射调用目标类的方法。
再深一点点就是代理类会现在静态块中通过反射把所有方法都拿到存在静态变量中,我之前反编译看过代理类,我忙写了一下,大致长这样:
这一套下来 JDK 动态代理原理应该就很清晰了。
再来看下 CGLIB,其实和 JDK 动态代理的实现逻辑是一致,只是实现方式不同。
Enhancer en = new Enhancer(); //2.设置父类,也就是代理目标类,上面提到了它是通过生成子类的方式 en.setSuperclass(target.getClass()); //3.设置回调函数,这个this其实就是代理逻辑实现类,也就是切面,可以理解为JDK 动态代理的handler en.setCallback(this); //4.创建代理对象,也就是目标类的子类了。 return en.create();
然后它是通过字节码生成技术而不是反射来实现调用的逻辑,具体就不再深入了。
8.注解是什么原理?
注解其实就是一个标记,可以标记在类上、方法上、属性上等,标记自身也可以设置一些值。
有了标记之后,我们就可以在解析的时候得到这个标记,然后做一些特别的处理,这就是注解的用处。
比如我们可以定义一些切面,在执行一些方法的时候看下方法上是否有某个注解标记,如果是的话可以执行一些特殊逻辑(RUNTIME类型的注解)。
注解生命周期有三大类,分别是:
- RetentionPolicy.SOURCE:给编译器用的,不会写入 class 文件
- RetentionPolicy.CLASS:会写入 class 文件,在类加载阶段丢弃,也就是运行的时候就没这个信息了
- RetentionPolicy.RUNTIME:会写入 class 文件,永久保存,可以通过反射获取注解信息
所以我上文写的是解析的时候,没写具体是解析啥,因为不同的生命周期的解析动作是不同的。
像常见的:
就是给编译器用的,编译器编译的时候检查没问题就over了,class文件里面不会有 Override 这个标记。
再比如 Spring 常见的 Autowired ,就是 RUNTIME 的,所以在运行的时候可以通过反射得到注解的信息,还能拿到标记的值 required 。
所以注解就是一个标记,可以给编译器用、也能运行时候用。
9.反射用过吗?
如果你用过那就不用我多说啥了,场景说一下,然后等着面试官继续挖。
如果没用过那就说生产上没用过,不过私下研究过反射的原理。
反射其实就是Java提供的能在运行期可以得到对象信息的能力,包括属性、方法、注解等,也可以调用其方法。
一般的编码不会用到反射,在框架上用的较多,因为很多场景需要很灵活,所以不确定目标对象的类型,届时只能通过反射动态获取对象信息。
PS:对反射不了解的,可以网上查查,这里不深入了。
10.能说下类加载过程吗?
类加载顾名思义就是把类加载到 JVM 中,而输入一段二进制流到内存,之后经过一番解析、处理转化成可用的 class 类,这就是类加载要做的事情。
二进制流可以来源于 class 文件,或者通过字节码工具生成的字节码或者来自于网络都行,只要符合格式的二进制流,JVM 来者不拒。
类加载流程分为加载、连接、初始化三个阶段,连接还能拆分为:验证、准备、解析三个阶段。
所以总的来看可以分为 5 个阶段:
- 加载:将二进制流搞到内存中来,生成一个 Class 类。
- 验证:主要是验证加载进来的二进制流是否符合一定格式,是否规范,是否符合当前 JVM 版本等等之类的验证。
- 准备:为静态变量(类变量)赋初始值,也即为它们在方法区划分内存空间。这里注意是静态变量,并且是初始值,比如 int 的初始值是 0。
- 解析:将常量池的符号引用转化成直接引用。符号引用可以理解为只是个替代的标签,比如你此时要做一个计划,暂时还没有人选,你设定了个 A 去做这个事。然后等计划真的要落地的时候肯定要找到确定的人选,到时候就是小明去做一件事。
解析就是把 A(符号引用) 替换成小明(直接引用)。符号引用就是一个字面量,没有什么实质性的意义,只是一个代表。直接引用指的是一个真实引用,在内存中可以通过这个引用查找到目标。 - 初始化:这时候就执行一些静态代码块,为静态变量赋值,这里的赋值才是代码里面的赋值,准备阶段只是设置初始值占个坑。
这个问题我觉得回答可以比我写的更粗,几个阶段一说,大致做的说一说就 ok 了。
想要知道更详细的流程可以看下《深入理解虚拟机Java》虚拟机的类加载章节。
11.双亲委派知道不?来说说看?
类加载机制一问基本上就会接着问双亲委派。
双亲委派的意思是:
如果一个类加载器需要加载类,那么首先它会把这个类加载请求委派给父类加载器去完成,如果父类还有父类则接着委托,每一层都是如此。
一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。
这里的双亲其实就指的是父类,没有mother。
父类也不是我们平日所说的那种继承关系,只是调用逻辑是这样。
关于双亲委派我之前写过文章,我把一些比较重要的内容拷过来:
Java 自身提供了 3 种类加载器:
- 启动类加载器(Bootstrap ClassLoader),它是属于虚拟机自身的一部分,用 C++ 实现的,主要负责加载
<JAVA_HOME>\lib
目录中或被-Xbootclasspath指定的路径中的并且文件名是被虚拟机识别的文件。它是所有类加载器的爸爸。 - 扩展类加载器(Extension ClassLoader),它是Java实现的,独立于虚拟机,主要负责加载
<JAVA_HOME>\lib\ext
目录中或被java.ext.dirs系统变量所指定的路径的类库。 - 应用程序类加载器(Application ClassLoader),它是Java实现的,独立于虚拟机。主要负责加载用户类路径(classPath)上的类库,如果我们没有实现自定义的类加载器那这玩意就是我们程序中的默认加载器。
所以一般情况类加载会从应用程序类加载器委托给扩展类再委托给启动类,启动类找不到然后扩展类找,扩展类加载器找不到再应用程序类加载器找。
双亲委派模型不是一种强制性约束,也就是你不这么做也不会报错怎样的,它是一种JAVA设计者推荐使用类加载器的方式。
为什么要双亲委派?
它使得类有了层次的划分。就拿 java.lang.Object 来说,加载它经过一层层委托最终是由Bootstrap ClassLoader来加载的,也就是最终都是由Bootstrap ClassLoader去找<JAVA_HOME>\lib中rt.jar里面的java.lang.Object加载到JVM中。
这样如果有不法分子自己造了个java.lang.Object,里面嵌了不好的代码,如果我们是按照双亲委派模型来实现的话,最终加载到JVM中的只会是我们rt.jar里面的东西,也就是这些核心的基础类代码得到了保护。
因为这个机制使得系统中只会出现一个java.lang.Object。不会乱套了。你想想如果我们JVM里面有两个Object,那岂不是天下大乱了。
那你知道有违反双亲委派的例子吗?
典型的例子就是:JDBC。
JDBC 的接口是类库定义的,但实现是在各大数据库厂商提供的 jar 包中,那通过启动类加载器是找不到这个实现类的,所以就需要应用程序加载器去完成这个任务,这就违反了自下而上的委托机制了。
具体做法是搞了个线程上下文类加载器,通过 setContextClassLoader() 默认设置了应用程序类加载器,然后通过 Thread.current.currentThread().getContextClassLoader() 获得类加载器来加载。
12.JDK 和 JRE 的区别?
JRE(Java Runtime Environment)指的是 Java 运行环境,包含了 JVM 和 Java 类库等。
JDK(Java Development Kit) 可以视为 JRE 的超集,还提供了一些工具比如各种诊断工具:jstack,jmap,jstat 等。
13.用过哪些 JDK 提供的工具?
这个就考察你平日里面有没有通过一些工具进行问题的分析、排查。
如果你用过肯定很好说,比如之前排查内存异常的时候用 jmap dump下来内存文件用 MAT 进行分析之类的。
如果没用过的话可以试试,自己找场景试验一下。
我列几个之前写过文章的工具,建议自己用用,还是很简单的。
- jps:虚拟机进程状况工具
- jstat:虚拟机统计信息监视工具
- jmap:Java内存映像工具
- jhat:虚拟机堆转储快照分析工具
- jstack:Java堆栈跟踪工具
- jinfo:Java配置信息工具
- VisualVM:图形化工具,可以得到虚拟机运行时的一些信息:内存分析、CPU 分析等等,在 jdk9 开始不再默认打包进 jdk 中。
工具其实还有很多,看看下面这个截图。
更详细的可以去《深入理解虚拟机Java》第四章查看。
总之就是自己找机会用用,没机会就自己给自己创造机会,防范于未然。
14.接口和抽象类有什么区别?
接口:只能包含抽象方法,不能包含成员变量,当 has a 的情况下用接口。
接口是对行为的抽象,类似于条约。在 Java 中接口可以多实现,从 has a 角度来说接口先行,也就是先约定接口,再实现。
抽象类: 可以包含成员变量和一般方法和抽象方法,当 is a 并且主要用于代码复用的场景下使用抽象类继承的方式,子类必须实现抽象类中的抽象方法。
在 Java 中只支持单继承。从 is a 角度来看一般都是先写,然后发现代码能复用,然后抽象一个抽象类。
15.什么是序列化?什么是反序列化?
序列化其实就是将对象转化成可传输的字节序列格式,以便于存储和传输。
因为对象在 JVM 中可以认为是“立体”的,会有各种引用,比如在内存地址Ox1234 引用了某某对象,那此时这个对象要传输到网络的另一端时候就需要把这些引用“压扁”。
因为网络的另一端的内存地址 Ox1234 可以没有某某对象,所以传输的对象需要包含这些信息,然后接收端将这些扁平的信息再反序列化得到对象。
所以反序列化就是将字节序列格式转换成对象的过程。
我再扩展一下 Java 序列化。
首先说一下 Serializable,这个接口没有什么实际的含义,就是起标记作用。
来看下源码就很清楚了,除了 String、数组和枚举之外,如果实现了这个接口就走writeOrdinaryObject
,否则就序列化就抛错。
serialVersionUID 又有什么用?
private static final long serialVersionUID = 1L;
想必经常会看到这样的代码,这个 ID 其实就是用来验证序列化的对象和反序列化对应的对象ID 是否一致。
所以这个 ID 的数字其实不重要,无论是 1L 还是 idea 自动生成的,只要序列化时候对象的 serialVersionUID 和反序列化时候对象的 serialVersionUID 一致的话就行。
如果没有显示指定 serialVersionUID ,则编译器会根据类的相关信息自动生成一个,可以认为是一个指纹。
所以如果你没有定义一个 serialVersionUID 然后序列化一个对象之后,在反序列化之前把对象的类的结构改了,比如增加了一个成员变量,则此时的反序列化会失败。
因为类的结构变了,生成的指纹就变了,所以 serialVersionUID 就不一致了。
所以 serialVersionUID 就是起验证作用。
Java 序列化不包含静态变量
简单地说就是序列化之后存储的内容不包含静态变量的值,看下下面的代码就很清晰了。