【非广告,纯干货】10年IT老兵拿下阿里p7技术专家后的万字面经分享!
说明:本文整理自石杉架构班学员LEO同学在儒猿技术交流群的面经分享
⼤家好,⾃我介绍⼀下:10年经验,普本毕业,坐标北京,这次跳槽进⼊了阿⾥。分享⼀下这次⾯试经验,以及平时学习的积累。
我的⼯作年限算是⽐较⻓,都有中年危机了,跟着石杉⽼师的架构课学习了两年,做技术⼀路⾛过只有脚踏实地的学习总结还有多积累、多思考才能有所进步,本次跳槽其实我是整整准备了⼀年半,充分利⽤周末和休假的时间学习提⾼,看⽯杉⽼师的课程的同时⼀定同步的做笔记,重要部分标红,我还看了很多相关书籍,书籍⾥的例⼦也是每个都必须敲⼀遍,看书的同时也做笔记把重要的记下来并标红,⾯试前⼀周做突击⽤
一

面试了哪些公司?
阿⾥巴巴、快⼿、滴滴、京东数科,拿到了哪些公司的offer:阿⾥巴巴、快⼿。由于已经拿到了⼼仪的offer,就没有继续约其他⼤⼚的⾯试了
二

面试前的准备
java基础,代表的有原⽣的List、Map、并发和线程池、TCP、⽹络等知识点对应的⽼师的课程:
- java架构课程的JDK源码剖析系列,还有架构课程⾥⾯的其他专题,
- 互联⽹Java⼯程师⾯试突击(第⼀⼆三季)
- 儒猿技术窝上⾯的所有专栏
这个⼀集不漏的需要看完看懂,⽼师画的图看⾃⼰再⼿动默写⼏遍理解原理,这些基础知识太重要,必!问!
三

面试官提问的部分问题
这些问题我都会结合⽂字+流程图/原理图,做⾮常深⼊的解答问题:简述HashMap的底层原理
(1) hash算法:为什么要⾼位和低位做异或运算?答:让⾼位也参与hash寻址运算,降低hash冲突
(2) hash寻址:为什么是hash值和数组.length - 1进⾏与运算?答:因为取余算法效率很低,按位与运算效率⾼
(3) hash冲突的机制:链表,超过8个以后,红⿊树(数组的容量⼤于等于64)
(4) 扩容机制:数组2倍扩容,重新寻址(rehash),hash & n - 1,判断⼆进制结果中是否多出⼀个bit的1,如果没多,那么就是原来的index,如果多了出来,那么就是index + oldCap,通过这个⽅式。就避免了rehash的时候,⽤每个hash对新数组.length取模,取模性能不⾼,位运算的性能⽐较⾼,JDK 1.8以后,优化了⼀下,如果⼀个链表的⻓度超过了8,就会⾃动将链表转换为红⿊树,查找的性能, 是O(logn),这个性能是⽐O(n)要⾼的
(5) 红⿊树是⼆叉查找树,左⼩右⼤,根据这个规则可以快速查找某个值
(6) 但是普通的⼆叉查找树,是有可能出现瘸⼦的情况,只有⼀条腿,不平衡了,导致查询性能变成O(n),线性查询了
(7) 红⿊树,红⾊和⿊⾊两种节点,有⼀⼤堆的条件限制,尽可能保证树是平衡的,不会出现瘸腿的情况
(8) 如果插⼊节点的时候破坏了红⿊树的规则和平衡,会⾃动重新平衡,变⾊(红 <-> ⿊),旋转,左旋转,右旋转
问题 :volatile关键字底层原理,volatile关键字是否可以禁⽌指令重排以及如何底层如何实现的指令重排
(1) 这⾥贴下⽯杉⽼师在讲volatile关键字底层原理画的图:硬件级别的原理:

下⾯是我根据⽼师的思路学习的笔记
(2) 主动从内存模型开始讲起,原⼦性、可⻅性、有序性的理解,volatile关键字的原理java内存模型:

(3) 可⻅性:⼀个线程修改了变量,其他线程能⻢上读取到该变量的最新值read(从主存读取),load(将主存读取到的值写⼊⼯作内存),use(从⼯作内存读取数据来计算),assign(将计算好的值重新赋值到⼯作内存中),store(将⼯作内存数据写⼊主存),write(将store过去的变量值赋值给主存中的变量) 这个是流程图:

(4) volatile读的内存语义如下:当读⼀个volatile变量时,JMM会把该线程对应的本地内存置为⽆效。线程接下来将从主内存中读取共享变量。
这个是流程图:

(4-1)当读flag变量后,本地内存B包含的值已经被置为⽆效。此时,线程B必须从主内存中读取共享变量,线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成⼀致。
(4-2)volatile写和volatile读的内存语义总结:
- 线程A写⼀个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B读⼀个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
- 线程A写⼀个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B 发送消息。
(5) 锁的释放和获取的内存语义:当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新 到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为⽆效。从⽽使得被监视器保护的临界区代码必须 从主内存中读取变量。
(6) 有序性:基于happens-before原则来看volatile关键字如何保证有序性这个是流程图:http://note.youdao.com/s/BPU2J7te

happens-before规则
(6-1)程序顺序规则:⼀个线程中的每个操作,happens-before于该线程中的任意后续操作。
(6-2)监视器锁规则:对⼀个锁的解锁,happens-before于随后对这个锁的加锁。
(6-3)volatile变量规则:对⼀个volatile变量域的写,happens-before于任意后续对这个volatile域的读
(6-4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(6-5)start()规则:如果线程A执⾏操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6-6)join()规则:如果线程A执⾏操作ThreadB.join()并成功返回,那么线程B中的任意操作happens- before与线程A从ThreadB.join()操作成功返回。
(7) 原⼦性:volatile关键字不能保证原⼦性,唯⼀的场景就是在32位虚拟机,对long/double变量的赋值写是原⼦的,volatile关键字底层原理,lock指令以及内存屏
(8) lock指令:volatile实现的两条原则
(8-1)Lock前缀指令会引起处理器缓存回写到内存。
(8-2)⼀个处理器的缓存回写到内存会导致其他处理器的缓存失效。
(8-3)缓存⼀致性协议:

问题: 线程有⼏种状态,状态之间的变化是怎样的?
Java线程在运⾏的声明周期中可能处于6种不同的状态,在给定的⼀个时刻,线程只能处于其中的⼀个状态。这⾥我整理了⼏张图:


问题: 简述线程池的原理,⾃定义线程池的参数以及每个参数的意思,线程池有哪⼏种,分别的应⽤场景举例
⼤家先看下这个构造图:

corePoolSize: 线程池⾥应该有多少个线程
maximumPoolSize: 如果线程池⾥的线程不够⽤了,等待队列还塞满了,此时有可能根据不同的线程池的类型,可能会增加⼀些线程出来,但是最多把线程数量增加到maximumPoolSize指定的数量 keepAliveTime + TimeUnit: 如果你的线程数量超出了corePoolSize的话,超出corePoolSize指定数量的线程,就会在空闲keepAliveTime毫秒之后,就会⾃动被释放掉
workQueue: 你的线程池的等待队列是什么队列
threadFactory: 在线程池⾥创建线程的时候,你可以⾃⼰指定⼀个线程⼯⼚,按照⾃⼰的⽅式创建线程出来
RejectedExecutionHandler: 如果线程池⾥的线程都在执⾏任务,然后等待队列满了,此时增加额外线 程也达到了maximumPoolSize指定的数量了,这个时候实在⽆法承载更多的任务了,此时就会执⾏这个东
⻄(拒绝策略)
上⾯的基本参数的意义以外,我还推荐⼤家看下美团技术团队写的《Java线程池实现原理及其在美团业务中的实践》
https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html 这篇⽂章,写的⾮常⼲。
问题: 简述OSI七层⽹络模型,TCP/IP四层⽹络模型
OSI七层⽹络模型,⽹络的七层加⼯从下到上主要包括物理层,数据链路层,⽹络层,传输层,会话层, 表示层,应⽤层
这个是OSI七层⽹络模型:

问题: 简述TCP三次握⼿以及四次挥⼿TCP三次握⼿的过程如下:
(1)客户端发送SYN(seq=x)报⽂给服务器端,进⼊SYN_SEND状态。
(2)服务器端收到SYN报⽂,回应⼀个SYN(seq=y)和ACK(ack = x+1)报⽂,进⼊SYN_RECV状态。
(3)客户端收到服务器端的SYN报⽂,回应⼀个ACK(ack=y+1)报⽂,进⼊Established状态。TCP三次握⼿的过程图:

TCP四次挥⼿的过程如下:

学习资料:⽯杉⽼师在架构班讲的:《讲给Java⼯程师听的⼤⽩话⽹络课程》
推荐书籍: 《⽹络是怎样连接的》《图解TCP/IP》 《图解⽹络硬件》 《图解HTTP》
问题: CMS垃圾回收的过程
这个是JVM内存划分的图:

这⾥援引下儒猿群群友根据《从 0 开始带你成为JVM实战⾼⼿》专栏 总结出来的图,分享给⼤家
https://www.processon.com/view/link/5e69db12e4b055496ae4a673
CMS的⼯作机制相对复杂,垃圾回收过程包含如下4个步骤
(1) 初始标记:只标记和GC Roots直接关联的对象,速度很快,需要暂停所有⼯作线程。
(2) 并发标记:和⽤户线程⼀起⼯作,执⾏GC Roots跟踪标记过程,不需要暂停⼯作线程。
(3) 重新标记:在并发标记过程中⽤户线程继续运⾏,导致在垃圾回收过程中部分对象的状态发⽣变化, 为了确保这部分对象的状态正确性,需要对其重新标记并暂停⼯作线程。
(4) 并发清除:和⽤户线程⼀起⼯作,执⾏清除GC Roots不可达对象的任务,不需要暂停⼯作线程。
问题: G1与CMS的区别,你们公司使⽤的是哪个,为什么?(这个需要结合⾃⼰的业务场景回答) 相对于CMS垃圾收集器,G1垃圾收集器两个突出的改进。
(1) 基于标记整理算法,不产⽣内存碎⽚。
(2) 可以精确地控制停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收。
问题: JVM参数举例,讲讲为什么这么设置,为了避免fullGC的停顿对系统的影响,有哪些解决⽅案?由于⽂本不⽅便贴代码,贴在在了有道云笔记⾥⾯:

为解决应⽤在午⾼峰发⽣ full gc ⽽影响系统响应时间问题, 考虑低峰期主动进⾏ full gc 对 old 区进⾏释放.确保启动参数中 -XX:+DisableExplicitGC 项被删除, 该参数作⽤是禁⽌ System.gc() 调⽤. (启动参数⼀般配在 start 脚本中)在启动参数中加⼊ -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses, 该参数的作⽤是主动 System.gc() 时调⽤ CMS 算法进⾏ gc 操作.
问题: 内存模型以及分区,需要详细到每个区放什么
JVM 分为堆区和栈区,还有⽅法区,初始化的对象放在堆⾥⾯,引⽤放在栈⾥⾯, class 类信息常量池(static 常量和 static 变量)等放在⽅法区
(1) ⽅法区:主要是存储类信息,常量池(static 常量和 static 变量),编译后的代码(字 节码)等数据
(2) 堆:初始化的对象,成员变量 (那种⾮ static 的变量),所有的对象实例和数组都要 在堆上分配
(3) 栈:栈的结构是栈帧组成的,调⽤⼀个⽅法就压⼊⼀帧,帧上⾯存储局部变量表,操 作数栈,⽅法出
⼝等信息,局部变量表存放的是 8 ⼤基础类型加上⼀个应⽤类型,所 以还是⼀个指向地址的指针
(4) 本地⽅法栈:主要为 Native ⽅法服务
(5) 程序计数器:记录当前线程执⾏的⾏号
问题: JVM内存分那⼏个区,每个区的作⽤是什么?java 虚拟机主要分为以下⼀个区:
⽅法区:
1. 有时候也成为永久代,在该区内很少发⽣垃圾回收,但是并不代表不发⽣ GC,在这⾥ 进⾏的 GC 主要是对⽅法区⾥的常量池和对类型的卸载
2. ⽅法区主要⽤来存储已被虚拟机加载的类的信息、常量、静态变量和即时编译器编译后 的代码等数据。
3. 该区域是被线程共享的。
4. ⽅法区⾥有⼀个运⾏时常量池,⽤于存放静态编译产⽣的字⾯量和符号引⽤。该常量池具有动态性,也就是说常量并不⼀定是编译时确定,运⾏时⽣成的常量也会存在这个常量池中。
虚拟机栈:
1. 虚拟机栈也就是我们平常所称的栈内存,它为 java ⽅法服务,每个⽅法在执⾏的时候都会创建⼀个栈帧,⽤于存储局部变量表、操作数栈、动态链接和⽅法出⼝等信息。
2. 虚拟机栈是线程私有的,它的⽣命周期与线程相同。
3. 局部变量表⾥存储的是基本数据类型、returnAddress 类型(指向⼀条字节码指令的地 址)和对象引⽤, 这个对象引⽤有可能是指向对象起始地址的⼀个指针,也有可能是代表 对象的句柄或者与对象相关联的位置。局部变量所需的内存空间在编译器间确定
4. 操作数栈的作⽤主要⽤来存储运算结果以及运算的操作数,它不同于局部变量表通过索 引来访问,⽽是压栈和出栈的⽅式 5.每个栈帧都包含⼀个指向运⾏时常量池中该栈帧所属⽅法的引⽤,持有这个引⽤是为了 ⽀持⽅法调⽤过程中的动态连接.动态链接就是将常量池中的符号引⽤在运⾏期转化为直接 引⽤。
本地⽅法栈和虚拟机栈类似,只不过本地⽅法栈为 Native ⽅法服务。
堆:java 堆是所有线程所共享的⼀块内存,在虚拟机启动时创建,⼏乎所有的对象实例都在这 ⾥创建,因此该区域经常发⽣垃圾回收操作。
程序计数器内存空间⼩,字节码解释器⼯作时通过改变这个计数值可以选取下⼀条需要执⾏的字节码 指令,分⽀、循环、跳转、异常处理和线程恢复等功能都需要依赖这个计数器完成。该内 存区域是唯⼀⼀个java 虚拟机规范没有规定任何 OOM 情况的区域。
问题: 堆⾥⾯的分区:Eden,survival (from+ to),⽼年代,各⾃的特点。
堆⾥⾯分为新⽣代和⽼⽣代(java8 取消了永久代,采⽤了 Metaspace),新⽣代包 含 Eden+Survivor 区,survivor 区⾥⾯分为 from 和 to 区,内存回收时,如果⽤的是复 制算法,从 from 复制到 to,当经过⼀次或者多次 GC 之后,存活下来的对象会被移动 到⽼年区,当 JVM 内存不够⽤的时候,会触发 Full GC,清理 JVM ⽼年区 当新⽣区满了之后会触发 YGC,先把存活的对象放到其中⼀个Survice 区,然后进⾏垃圾清理。
因为如果仅仅清理需要删除的对象,这样会导致内存碎 ⽚,因此⼀般会把 Eden 进⾏完全的清理,然后整理内存。那么下次 GC 的时候,就会使⽤下⼀个 Survive,这样循环使⽤。如果有特别⼤的对象,新⽣代放不下, 就会使⽤⽼年代的担保,直接放到⽼年代⾥⾯。因为 JVM 认为,⼀般⼤对象的存 活时间⼀般⽐较久远。
问题: 如何判断⼀个对象是否存活?(或者GC对象的判定⽅法) 判断⼀个对象是否存活有两种⽅法:
1. 引⽤计数法
所谓引⽤计数法就是给每⼀个对象设置⼀个引⽤计数器,每当有⼀个地⽅引⽤这个对象时,就将计数器加⼀,引⽤失效时,计数器就减⼀。当⼀个对象的引⽤计数器为零时,说明此对象没有被引⽤,也就是“死对象”,将会被垃圾回收. 引⽤计数法有⼀个缺陷就是⽆法解决循环引⽤问题,也就是说当对象 A 引⽤对象 B,对象 B ⼜引⽤者对象 A,那么此时 A,B对象的引⽤计数器都不为零,也就造成⽆法完成垃圾回 收,所以主流的虚拟机都没有采⽤这种算法。
2. 可达性算法(引⽤链法)
该算法的思想是:从⼀个被称为 GC Roots 的对象开始向下搜索,如果⼀个对象到 GC Roots 没有任何引⽤链相连时,则说明此对象不可⽤。在 java 中可以作为 GC Roots 的对象有以下⼏种: • 虚拟机栈中引⽤的对象,⽅法区类静态属性引⽤的对象 • ⽅法区常量池引⽤的对象
本地⽅法栈 JNI 引⽤的对象 虽然这些算法可以判定⼀个对象是否能被回收,但是当满⾜上述条件时,⼀个对象⽐不⼀定会被回收。当⼀个对象不可达 GC Root 时,这个对象并 不会⽴⻢被回收,⽽是出于⼀个死缓的阶段,若要被真正的回收需要经历两次标记,如果对象在可达性分析中没有与 GC Root 的引⽤链,那么此时就会被第⼀次标记并且进⾏ ⼀次筛选,筛选的条件是是否有必要执⾏finalize()⽅法。当对象没有覆盖 finalize()⽅法或者已被虚拟机调⽤过,那么就认为是没必要的。
如果该对象有必要执⾏ finalize()⽅法,那么这个对象将会放在⼀个称为 F-Queue 的对队 列中,虚拟机会触发⼀个 Finalize()线程去执⾏,此线程是低优先级的,并且虚拟机不会承诺⼀直等待它运⾏完,这是因为如果 finalize()执⾏缓慢或者发⽣了死锁,那么就会造成 F- Queue 队列⼀直等待,造成了内存回收系统的崩溃。GC 对处于 F-Queue 中的对象进⾏ 第⼆次被标记,这时,该对象将被移除”即将回收”集合,等待回收。
问题: 服务类加载过多引发的OOM问题如何排查
如果服务出现⽆法调⽤接⼝假死的情况,⾸先要考虑的是两种问题
(1) 第⼀种问题,这个服务可能使⽤了⼤量的内存,内存始终⽆法释放,因此导致了频繁GC问题。也许每秒都执⾏⼀次Full GC,结果每次都回收不了多少,最终导致系统因为频繁GC,频繁Stop the World,接⼝调⽤出现频繁假死的问题
(2) 第⼆种问题,可能是这台机器的CPU负载太⾼了,也许是某个进程耗尽了CPU资源,导致你这个服务的线程始终⽆法得到CPU资源去执⾏,
也就⽆法响应接⼝调⽤的请求。
这也是⼀种情况。
在内存使⽤这么⾼的情况下会发⽣什么?
第⼀种,是内存使⽤率居⾼不下,导致频繁的进⾏Full GC,gc带来的stop the world问题影响了服务。
第⼆种,是内存使⽤率过多,导致JVM⾃⼰发⽣OOM。
第三种,是内存使⽤率过⾼,也许有的时候会导致这个进程因为申请内存不⾜,直接被操作系统把这个进
程给杀掉了
问题: 如何在JVM内存溢出的时候⾃动dump内存快照?
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/usr/local/app/oom
第⼀个参数意思是在OOM的时候,⾃动dump内存快照出来,第⼆个参数是说把内存快照放到哪去
⾃⼰阅读的书籍举例:《实战Java虚拟机:JVM故障诊断与性能优化(第2版)》
Netty知识点对应的⽼师的课程:《Netty核⼼功能精讲以及核⼼源码剖析》 问题:NIO开发的话为什么选择netty
不选择Java原⽣NIO编程的原因
(1) NIO的类库和API的繁杂,使⽤麻烦,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等。
(2) 需要具备其他的额外技能做铺垫,例如熟悉Java多线程编程。这是因为NIO编程涉及到Reactor模 式,你必须对多线程和⽹络编程⾮常熟悉,才能写出⾼质量的NIO程序。
(3) 可靠性能⼒补⻬,⼯作量和难度都⾮常⼤。例如客户端⾯临重连、⽹络闪断、半包读写、失败缓存、⽹络拥塞和异常码流的处理的问题,NIO编程的特点就是功能开发相对容易,但是可靠性能⼒补⻬⼯作量和难度都⾮常⼤
(4) JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%
为什么选择Netty
(1) API使⽤简单,开发⻔槛低;
(2) 功能强⼤,预置了多种编解码弄能,⽀持多种主流协议;
(3) 定制能⼒强,可以通过ChannelHandler对通信框架进⾏灵活地扩展;
(4) 性能⾼,通过与其他业界主流的NIO框架对⽐,Netty的综合性能最优;
(5) 成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发⼈员不需要再为NIO的BUG⽽烦恼;
(6) 社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功能会加⼊;
(7) 经历了⼤规模的商业应⽤考验,质量得到验证。
问题: 简述TCP粘包拆包以及解决⽅案
开局⼀个图:

假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端⼀次读取到的字节数是不确定的,故可能存在以下4种情况