Java 并发
B站 Java ⾯试
昨天看到 B 站公布了今 年第三季度的财报,净利润达到了2.4 亿元,这是 B 站上市以来⾸次单季度
盈利,也就是 B站发展那么多年,终于实现了盈利。
这是好事,B站作为⻓视频⾼质量的平台,相信很多⼈都是在 B 站上学编程,作为受益的 ⽤⼾,肯
定也是希望平台能持续经营下去。
刚好 B 站 25 届校招薪资也开奖了,⽬前收集到的 B站 25 届校招的薪资情况:
后端开发:31k x 15 = 46w ,同学 bg 硕⼠ 985 ,base 上海
后端开发:28k x 15 = 42w ,同学 bg 未知,base 上海后端开发:27k x 15 = 40w ,同学 bg 硕⼠海⻳,base 上海
测试开发:28k x 15 = 42w ,同学 bg 未知,base 上海这些 offer 应该是 sp 、ssp offer 级别的,因为爆料的⼈不算很多,所以数据不够全。 普通 offer ,我猜测⼤概率是 25k x 15 。
B 站开的确实还是很有诚意的,基本上跟字节跳动这类⼀线⼤⼚差不多了。
这次,给⼤家分享 Java 后端岗位的 B站的校招⾯经,全程基本上都是在问Java 相关的知识点了,
知识点⽐较多,⽽且有些也 ⽐较细节。
Java 集合+Java 并发+JVM+Spring 问的⼀个遍,⼿撕代码环节,还有要⼿写单例模式+算法,还
是有⼀定难度。

Java集合
HashMap 的实现原理
从 JDK 1.7 和 JDK 1.8 版本区别回答:
在 JDK 1.7 版本之前, HashMap 数据结构是数组和链表,HashMap 通过哈希算法将元素的键
(Key )映射到数组中的槽位(Bucket )。如果多个键映射到同⼀个槽位,它们会以 链表的形式
存储在同⼀个槽位上,因为链表的查询时间是O(n) ,所以冲突很严重,⼀个索引上的链表⾮常⻓,效率就很低了。
所以在 JDK 1.8 版本的时候做 了优化,当⼀个链表的⻓度超过8的时候就转换数据结构,不再使
⽤链表存储,⽽是使⽤红⿊树,查找时使⽤红⿊树,时间复杂度O(log n ),可以提⾼查询性
能,但是在数量较少时,即数量⼩于6时,会将红⿊树转换回链表。

## HashMap 的put(key,val) 和get(key) 过程存储对象时,我们将K/V 传给put ⽅法时,它调⽤hashCode 计算hash 从⽽得到bucket 位置,进⼀
步存储,HashMap 会根据当前bucket 的占⽤情况⾃动调整容量(超过Load Facotr 则resize 为原来
的2倍)。获取对象时,我们将K传给get ,它调⽤hashCode 计算hash 从⽽得到bucket 位置,并进⼀步调⽤
equals() ⽅法确定键值对。如果发⽣碰撞的时候,Hashmap 通过链表将产⽣碰撞冲突的元素组织
起来,在Java 8 中,如果⼀个bucket 中碰撞冲突的元素超过某个限制(默认是8) ,则使⽤红⿊树来替 换链表,从⽽提⾼速度。
HashMap ⼀般⽤什么 做Key
string为啥String 适合做Key 呢
String 对象是不可变的,⼀旦创建就不能被修改,这确保了Key 的稳定性。如果Key 是可变的,可能
会导致hashCode 和equals ⽅法的不⼀致,进⽽影响HashMap 的正确性。
HashMap 的扩容机制
hashMap 默认的负载因⼦是0.75 ,即如果hashmap 中的元素个数超过了总容量75% ,则会触发扩
容,扩容分为两个 步骤:
第1步是对哈希表⻓度的扩展(2倍)
第2步是将旧哈希表中的数据放到新的哈希表中。
因为我们使 ⽤的是2次幂的扩展(指⻓度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。如我们从16 扩展为32 时,具体的变化 如下所⽰:


rehash 因此元素在重新计算hash 之后,因为n变为2倍,那么n-1 的mask 范围在 ⾼位多1bit( 红⾊),因此新的index 就会发⽣这样的变化 :resize 因此,我们在扩充HashMap 的时候,不需要重新计算hash ,只需要看看 原来的hash 值新增的那个bit 是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap” 。可以看看 下图为16 扩充为32 的resize ⽰意图:

resize16-32 这个设计 确实⾮常的巧妙,既省去了重新计算hash 值的时间,⽽且同时,由于新增的
1bit 是0还是1可以认为是随机的,因此resize 的过程,均匀的把之前的冲突的节点分散到新的
bucket 了。
HashMap 的⼤⼩为什么 是2的n次⽅⼤⼩呢
在 JDK1.7 中,HashMap 整个扩容过程就是分别 取出数组元素,⼀般该元素是最后⼀个放⼊链表
中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数
组中的下标,然后进⾏交换。这样的扩容⽅式会将原来哈希冲突的单向链表尾部变成扩 容后单向
链表的头部。
⽽在 JDK 1.8 中,HashMap 对扩容操作做了优化。由于扩容数组的⻓度是 2 倍关系,所以对于假
设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化 (左移⼀位就是 2 倍),在扩容中只⽤判断原来的 hash 值和左移动的⼀位(newtable 的值)按位与操作是 0 或 1 就⾏,0 的话索引
不变,1 的话索引变成原索引加上扩容前数组。
之所以能通过这 种“与运算“来重新分配索引,是因为 hash 值本来 就是随机的,⽽ hash 按位与上
newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组⻓度的数值索引
处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。
说⼀下Java 中的List
List 是⼀个接⼝,它继承⾃Collection 接⼝,表⽰⼀种有序的集合,允许存储重复元素。List 接⼝的
常⻅实现类包括ArrayList 、LinkedList 和Vector 。
ArrayList :基于数组实现的动态数组,⽀持随机访问和快速插⼊、删除操作。适合需要频繁访
问元素的场景。
LinkedList :基于双向链表实现的列表,⽀持快速插⼊、删除操作,但访问元素的效率较低。适
合需要频繁插⼊、删除元素的场景。
Vector :类似于ArrayList ,但是是 线程安全的,⽀持同步操作。不过由于性能较差,通常不推荐
在新代码中使⽤。
Java 并发
说⼀下volatite 关键字
volatite 作⽤有 2 个:
保证变量对所有线程的可⻅性。当⼀条线程修改了变量值,新值对于其他线程来说是⽴即可以
得知的。
禁⽌指令重排序优化。使⽤ volatile 变量进⾏写操作,汇编指令带有 lock 前缀,相当于⼀个内
存屏障,编译器不会将后⾯的指令重排到内存屏障之前。
volatile 可以保 证线程安全吗
volatile 关键字可以保 证可⻅性,但不能保证原⼦性,因此不能完全保证线程安全。volatile 关键字
⽤于修饰变量,当⼀个线程修改了volatile 修饰的变量的值,其他线程能够⽴即看到最新的值,从
⽽避免了线程之间的数据不⼀致。
但是,volatile 并不能解决多线程并发下的复合操作问题,⽐如i++ 这种操作不是原⼦操作,如果多
个线程同时对i进⾏⾃增操作,volatile 不能保证线程安全。对于复合操作,需要使⽤synchronized
关键字或者Lock 来保证原⼦性和线程安全。
说⼀下线程池的常⻅配置
线程池是为了 减少频繁的创建线程和销毁线程带来的性能损耗。线程池的构造函数有7个参数:

corePoolSize :线程池核⼼线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize ,那么即使这些线程处于空闲状态,那也不 会被销毁。maximumPoolSize :限制了线程池能创建的最⼤线程总数(包括核⼼线程和⾮核⼼线程),当
corePoolSize 已满 并且 尝试将新任务加 ⼊阻塞队列失败(即队列已满)并且 当前线程数 <maximumPoolSize ,就会创建新线程执⾏此任务,但是当 corePoolSize 满 并且 队列满 并且
线程数已达 maximumPoolSize 并且 ⼜有新任务提交时,就会触发拒绝策略。
keepAliveTime :当线程池中线程的数量⼤于corePoolSize ,并且某个线程的空闲时间超过了
keepAliveTime ,那么这个线程就会被销毁。
unit :就是keepAliveTime 时间的单位。
workQueue :⼯作队列。当没有空闲的线程执⾏新任务时,该任务就会被放⼊⼯作队列中,等
待执⾏。
threadFactory :线程⼯⼚。可以⽤来给线 程取名字等等
handler :拒绝策略。当⼀个新任务交给线 程池,如果此时线程池中有空闲的线程,就会直接执
⾏,如果没有空闲的线程,就会将该任务加 ⼊到阻塞队列中,如果阻塞队列满了,就会创建⼀
个新线程,从阻塞队列头部取出⼀个任务来执⾏,并将新任务加 ⼊到阻塞队列末尾。如果当前
线程池中线程的数量等于maximumPoolSize ,就不会创建新线程,就会去执⾏拒绝策略。
假如现在有15 个任务 5个核⼼线程 最⼤线程是10 ⼯作队列是5 请问执⾏顺
序是怎样的
- ⾸先,当向线程池提交任务时,会先使⽤核⼼线程来执⾏任务。如果核⼼线程数未满(⼩于 5
个),新提交的任务会直接由核⼼线程来执⾏。
当核⼼线程都在执⾏任务时(已达到 5 个),新提交的任务会被放⼊⼯作队列中。
当⼯作队列已满(已达到 5 个),且当前运⾏的线程数⼩于最⼤线程数(⼩于 10 个),会创建新的线程来执⾏任务。
当⼯作队列已满且已达到最⼤线程数时,后续提交的任务将根据线程池的拒绝策略来处理,默
认的拒绝策略是抛出 RejectedExecutionException 。
说⼀下ThreadLocal
ThreadLocal 可以理解为线程本地变量,他会在每个线程都创建⼀个副本,那么在线程之间访问内
部副本变量就⾏了,做到了线程之间互相隔离,相⽐于synchronized 的做法是⽤空间来换时间。
ThreadLocal 有⼀个静态内部类ThreadLocalMap ,ThreadLocalMap ⼜包含了⼀个Entry 数组,Entry
本⾝是⼀个弱引 ⽤,他的key 是指向ThreadLocal 的弱引 ⽤,Entry 具备了保存key value 键值对的能⼒。
弱引 ⽤的⽬的是为了 防⽌内存泄露,如果是强引 ⽤那么ThreadLocal 对象除⾮线程结束否则始终⽆法被回收,弱引 ⽤则会在下⼀次GC 的时候被回收。
但是这样还是会存在内存泄露的问题,假如key 和ThreadLocal 对象被回收之后,entry 中就存在key 为null ,但是value 有值的entry 对象,但是永远没办法被访问到,同样除⾮线程结束运⾏。
但是只要ThreadLocal 使⽤恰当,在使⽤完之后调⽤remove ⽅法删除Entry 对象,实际上是不会出现这个问题的。

ThreadLocal 是⼀个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享
的,这样就可以避免资源竞争带来的多线程的问题,这种解决多线程的安全问题和lock( 这⾥的lock
指通过synchronized 或者Lock 等实现的锁) 是有本 质的区别的:lock 的资源是多个线程共享的,所以访问的时候需要加锁。
ThreadLocal 是每个线程都有⼀个副本,是不需要加锁的。
lock 是通过时间换空间的做法。
ThreadLocal 是典型的通过空间换时间的做法。
JVM
jvm 的内存结构
根据 JVM8 规范,JVM 运⾏时内存共分为虚拟机栈、堆、元空间、程序计数器、本地⽅法栈五个
部分。还有⼀部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。

JVM 的内存结构主要分为以下⼏个部分:
元空间:元空间的本质和永久代类似,都是对JVM 规范中⽅法区的实现。不过元空间与永久代
之间最⼤的区别在于:元空间并不在虚拟机中,⽽是使⽤本地内存。
Java 虚拟机栈:每个线程有⼀个私有的栈,随着线程的创建⽽创建。栈⾥⾯存着的是⼀种叫“栈
帧”的东西,每个⽅法会创建⼀个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引
⽤)、操作数栈、⽅法出⼝等信息。栈的⼤⼩可以固定也可以动态扩展。
本地⽅法栈:与虚拟机栈类似,区别是虚拟机栈执⾏java ⽅法,本地⽅法站执⾏native ⽅法。在
虚拟机规范中对本地⽅法栈中⽅法使⽤的语⾔、使⽤⽅法与数据结构没有强制规定,因此虚拟
机可以⾃由实现它。
** 程序计数器:** 程序计数器可以看成是当前线程所执 ⾏的字节码的⾏号指⽰器。在任何 ⼀个确
定的时刻,⼀个处理器( 对于多内核来说是⼀个内核)都只会执⾏⼀条线程中的指令。因此,
为了 线程切换后能恢复到正确的执⾏位置,每条线程都需要⼀个独⽴的程序计数器, 我们称这
类内存区域为“线程私有”内存。
堆内存:堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和
数组都在堆上进⾏分配。这部分空间可通过 GC 进⾏回收。当申请不到空间时会抛出
OutOfMemoryError 。堆是JVM 内存占⽤最⼤,管理最复杂的⼀个区域。其唯⼀的⽤途就是存放
对象实例:所有的对象实例及数组都在对上进⾏分配。jdk1.8 后,字符串常量池从永久代中剥离
出来,存放在队中。
直接内存:直接内存并不是虚拟机运⾏时数据区的⼀部分,也不 是Java 虚拟机规范中农定义的
内存区域。在JDK1.4 中新加⼊了NIO(New Input/Output) 类,引⼊了⼀种基于通道 (Channel) 与缓冲区(Buffer )的I/O ⽅式,它可以使 ⽤native 函数库直接分配堆外内存,然后通脱⼀个存储
在Java 堆中的DirectByteBuffer 对象作为这块内存的引⽤进⾏操作。这样能在⼀些场景中显著提
⾼性能,因为避免了在Java 堆和Native 堆中来回复制数据。
类加载机制
类从被加载到虚拟机内存开始,到卸载出内存为⽌,它的整个⽣命周 期包括以下 7 个阶段:
加载
验证
准备
解析
初始化
使⽤
卸载
验证、准备、解析 3 个阶段统称为连接。

Load Class JVM 中类的装载是由类加载器, 也就是ClassLoader ,和它的⼦类来实现的,Java 中的
类加载器是⼀个重要的 Java 运⾏时系统组 件,它负责 在运⾏时查找和装⼊类⽂件中的类。
由于 Java 的跨平台性, 经过编译的 Java 源程序并 不是⼀个可执⾏程序, ⽽是⼀个或多个类⽂
件。当 Java 程序需要使⽤某个类时,JVM 会确保这个类已经被加载、连接( 验证、 准备和解析)
和初始化。
类的加载是指把类的.class ⽂件中的数据读⼊到内存中,通常是创建⼀个字节数组读⼊.class ⽂件,然后产⽣与所加载类对应的 Class 对象。
加载完成后, Class 对象还不完整, 所以此时的类还不可⽤。当类被加载后就进⼊连接阶段, 这
⼀阶段包括验证、准备( 为静态变量分配内存并设置默认的初始值) 和解析( 将符号引⽤替换为
直接引⽤) 三个 步骤。
最后 JVM 对类进⾏初始化,包括:
1) 如果类存在直接的⽗类并且这个类还没有被初始化,那么就先初始化⽗类;
2) 如果类中存在初始化语句, 就依次执⾏这些初始化语句。创建对象的过程

在Java 中创建对象的过程包括以下⼏个步骤:
- 类加载检查:虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定
位到⼀个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过 、解析和初始化过。
如果没有,那必须先执⾏相应的类加载过 程。
- 分配内存:在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩
在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出 来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象
头),这⼀步操作保 证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访
问到这些字段的数据类型所对应的零值。
- 进⾏必要设置,⽐如对象头:初始化零值完成之后,虚拟机要对对 象进⾏必要的设置,例如这
个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄
等信息。这些信息存放在对象头中。另外,根据虚拟机当前运⾏状态的不同,如是否启 ⽤偏向
锁等,对象头会有不同的设置⽅式。
- 执⾏ init ⽅法:在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但
从 Java 程序的视⻆来看,对象创建才刚开始—— 构造函数,即class ⽂件中的⽅法还没有执⾏,所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以⼀
般来说,执⾏ new 指令之后会接着执⾏⽅法,把对象按照程序员的意愿 进⾏初始化,这样⼀个
真正可⽤的对象才算完全被构造出来。
对象的⽣命周 期
对象的⽣命周 期包括创建、使⽤和销毁三个 阶段:
创建:对象通过关键字new 在堆内存中被实例化,构造函数被调⽤,对象的内存空间被分配。
使⽤:对象被引⽤并执⾏相应的操作,可以通过引⽤访问对象的属性和⽅法,在程序运⾏过程
中被不断使⽤。
销毁:当对象不再被引⽤时,通过垃圾 回收机制⾃动回收对象所占⽤的内存空间。垃圾 回收器
会在适当的时候检测并回收不再被引⽤的对象,释放对象占⽤的内存空间,完成对象的销毁过
程。
Spring
SpringAOP 怎么实现的
Spring AOP 的实现依赖于动态代理技术。动态代理是在运⾏时动态⽣成代理对象,⽽不是在编译
时。它允许开发者在运⾏时指定要代理的接⼝和⾏为,从⽽实现在不修改源码的情况下增强⽅法
的功能。
Spring AOP ⽀持两种动态代理:
基于JDK 的动态代理:使⽤java.lang.reflect.Proxy 类和java.lang.reflect.InvocationHandler 接⼝实
现。这种⽅式需要代理的类实现⼀个或多个接⼝。
基于CGLIB 的动态代理:当被代理的类没有实现接⼝时,Spring 会使 ⽤CGLIB 库⽣成⼀个被代理
类的⼦类作为代理。CGLIB (Code Generation Library )是⼀个第三⽅代码⽣成库,通过继承⽅
式实现代理。
AOP 实现有哪些注解
常⽤的注解包括:
@Aspect :⽤于定义切⾯,标注在切⾯类上。
@Pointcut :定义切点,标注在⽅法上,⽤于指定连接点。
@Before :在⽅法执⾏之前执⾏通知。
@After :在⽅法执⾏之后执⾏通知。
@Around :在⽅法执⾏前后都执⾏通知。
@AfterReturning :在⽅法执⾏后返回结果后执⾏通知。
@AfterThrowing :在⽅法抛出异常后执⾏通知。
@Advice :通⽤的通知类型,可以替代@Before 、@After 等。JDK 动态代理和cglib 有啥区别
JDK 代理只能对实现接⼝的类⽣成代理;CGLib 是针对类实现代理,对指定的类⽣成⼀个⼦类,
并覆盖其中的⽅法,这种通过继承类的实现⽅式,不能代理final 修饰的类。
JDK 代理使⽤的是反射机制实现aop 的动态代理,CGLib 代理使⽤字节码处理框架ASM ,通过修
改字节码⽣成⼦类。所以jdk 动态代理的⽅式创建代理对象效率较⾼,执⾏效率较低,CGLib 创
建效率较低,执⾏效率⾼。
JDK 动态代理机制是委托机制,具体说动态实现接⼝类,在动态⽣成的实现类⾥⾯委托hanlder
去调⽤原始实现类⽅法,CGLib 则使⽤的继承机制,具体说被代理类和代理类是继承关系,所以代理类是可以赋值给被代理类的,如果被代理类有接⼝,那么代理类也可以赋值给接⼝。

聊⼀下Java 中的反射
反射机制是在运⾏时,对于任意⼀个类,都能够知道这个类的所有属性和⽅法;对于任意个对
象, 都能 够调⽤它的任意⼀个⽅法。
在java 中,只要给定类的名字,就可以通过反射机制来获得类的所有信息。这种动态获取的信息以
及动态调⽤对象的⽅法的功能称为Java 语⾔的反射机制。
反射在某些情况下⾮常有⽤,⽐如框架、ORM (对象关系映射)⼯具、动态代理等领域,jdbc 就
是典型的反射,如hibernate ,struts 等框架使⽤反射实现的。
Class.forName('com.mysql.jdbc.Driver.class');// 加载 MySQL 的驱动类这就是反射。写代码
⽤double check 实现单例模式
使⽤volatile 关键字修饰 instance ,防⽌指令重排序
加上同步代码块 保证线程安全(⽐同步⽅法⾼效,因为同步⽅法每次 获取对象都会执⾏,同步
代码块只在第⼀次获取对象时会执⾏,后期会直接返回对象信息)
public class SingleTon {
// volatile 关键字修饰变量 防止指令重排序
private static volatile SingleTon instance = null;
private SingleTon(){}
public static SingleTon getInstance(){
if(instance == null){
//同步代码块 只有在第一次获取对象的时候会执行到 ,第二次及以后访问时 instance变量
synchronized(SingleTon.class){
if(instance == null){
instance = new SingleTon();
}
}
}
return instance;
}
}层序换⾏遍历⼆叉树
层序遍历⼆叉树的思路是使⽤队列来实现。⾸先将根节点⼊队,然后进⼊循环,每次 循环⾸先获
取当前队列的⼤⼩,表⽰当前层的节点个数,然后依次取出这些节点,输出它们的值,并将它们
的⼦节点(如果存在)依次⼊队。这样可以保 证按层级顺序遍历⼆叉树,并在每⼀层输出换⾏符
以区分层级。
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> ret = new ArrayList<List<Integer>>();
if (root == null) {
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
while (!queue.isEmpty()) {
List<Integer> level = new ArrayList<Integer>();
int currentLevelSize = queue.size();
for (int i = 1; i <= currentLevelSize; ++i) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
ret.add(level);
}
return ret;
}
}