前言

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(1)

金三银四才过去没多久,眼看着便又要秋招了,所以为大家写了这篇文章,来自一个刚参加完字节面试并高分通过的朋友亲口所述,除了字节的 offer,他还分别通过了京东、百度以及腾讯阿里巴巴这些公司的面试,所以他的经验还是有一定价值的,准备参加秋招的朋友可以收藏一下,权当做个参考,如果真的对你的面试产生了一些帮助,我不胜荣幸。

他参加面试前所用的一些资料我也全都拿过来了,可以无偿分享给需要的朋友,直接点击领取就可以!

那话不多说,坐稳扶好,发车喽!

一面二面连着一起,三面因为过了五一所以隔了很久,hr 面在三面后一天

一面:45 分钟

项目:介绍项目需求,设计思路,主要技术(因为问到的是 ai 相关的项目,因此没多问技术)

一、JAVA1.垃圾回收算法典型的垃圾回收算法

在 JVM 规范中并没有明确 GC 的运作方式,各个厂商可以采用不同的方式去实现垃圾回收器。这里讨论几种常见的 GC 算法。

标记-清除算法(Mark-Sweep)

最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图:

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(2)

从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

复制算法(Copying)

为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(3)

这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。

标记-整理算法(Mark-Compact)

结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(4)

分代收集算法(Generational Collection)

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用 Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(5)

而老生代因为每次只回收少量对象,因而采用 Mark-Compact 算法。

另外,不要忘记在 Java 基础:Java 虚拟机(JVM)中提到过的处于方法区的永生代(Permanet Generation)。它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。

对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。

如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。当对象在 Survivor 区躲过一次 GC 后,其年龄就会 1。默认情况下年龄到达 15 的对象会被移到老生代中。

典型的垃圾收集器

垃圾收集算法是垃圾收集器的理论基础,而垃圾收集器就是其具体实现。下面介绍 HotSpot 虚拟机提供的几种垃圾收集器。

3.1. Serial/Serial Old

最古老的收集器,是一个单线程收集器,用它进行垃圾回收时,必须暂停所有用户线程。Serial 是针对新生代的收集器,采用 Copying 算法;而 Serial Old 是针对老生代的收集器,采用 Mark-Compact 算法。优点是简单高效,缺点是需要暂停用户线程。

ParNew

Seral/Serial Old 的多线程版本,使用多个线程进行垃圾收集。

Parallel Scavenge

新生代的并行收集器,回收期间不需要暂停其他线程,采用 Copying 算法。该收集器与前两个收集器不同,主要为了达到一个可控的吞吐量。

Parallel Old

Parallel Scavenge 的老生代版本,采用 Mark-Compact 算法和多线程。

CMS

Current Mark Sweep 收集器是一种以最小回收时间停顿为目标的并发回收器,因而采用 Mark-Sweep 算法。

G1

G1(Garbage First)收集器技术的前沿成果,是面向服务端的收集器,能充分利用 CPU 和多核环境。是一款并行与并发收集器,它能够建立可预测的停顿时间模型。

2.cms 和 g1 区别

CMS:以获取最短回收停顿时间为目标的收集器,基于并发“标记清理”实现

过程

1、初始标记:独占 PUC,仅标记 GCroots 能直接关联的对象

2、并发标记:可以和用户线程并行执行,标记所有可达对象

3、重新标记:独占 CPU(STW),对并发标记阶段用户线程运行产生的垃圾对象进行标记修正

4、并发清理:可以和用户线程并行执行,清理垃圾

优点:

并发,低停顿

缺点

1、对 CPU 非常敏感:在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分线程使应用程序变慢

2、无法处理浮动垃圾:在最后一步并发清理过程中,用户县城执行也会产生垃圾,但是这部分垃圾是在标记之后,所以只有等到下一次 gc 的时候清理掉,这部分垃圾叫浮动垃圾

3、CMS 使用“标记-清理”法会产生大量的空间碎片,当碎片过多,将会给大对象空间的分配带来很大的麻烦,往往会出现老年代还有很大的空间但无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 FullGC,为了解决这个问题 CMS 提供了一个开关参数,用于在 CMS 顶不住,要进行 FullGC 时开启内存碎片的合并整理过程,但是内存整理的过程是无法并发的,空间碎片没有了但是停顿时间变长了

G1:是一款面向服务端应用的垃圾收集器

特点

1、并行于并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。

2、分代收集:分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其它收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。也就是说 G1 可以自己管理新生代和老年代了。

3、空间整合:由于 G1 使用了独立区域(Region)概念,G1 从整体来看是基于“标记-整理”算法实现收集,从局部(两个 Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片。

4、可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用这明确指定一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒

3.stop the world 一般怎么处理

这个一两句话说不清楚,感兴趣的朋友可以自己上网搜一下教程

4.判断对象是否存活

判断对象是否存活一般有两种方式:

引用计数:每个对象有一个引用计数属性,新增一个引用时计数加 1,引用释放时计数减 1,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题。

可达性分析(Reachability Analysis):从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。

在 Java 语言中,GC Roots 包括:

虚拟机栈中引用的对象。

方法区中类静态属性实体引用的对象。

方法区中常量引用的对象。

本地方法栈中 JNI 引用的对象。

二、计算机网络:1.TCp 三次握手四次挥手过程及各个状态

三次握手第一次握手:主机 A 发送位码为 SYN=1,随机产生 seq number=10001 的数据包到服务器,主机 B 由 syn=1 知道,A 要求建立联机,此时状态为 SYN_SENT; 第二次握手:主机 B 收到请求后要确认联机信息,向 A 发送 ack number=(主机 A 的 seq 1),syn=1,ack=1,随机产生 seq=20001 的包,此时状态由 LISTEN 变为 SYN_RECV; 第三次握手:主机 A 收到后检查 ack number 是否正确,即第一次发送的 seq number 1,以及位码 ack 是否为 1,若正确,主机 A 会再发送 ack number=(主机 B 的 seq 1),ack=1,主机 B 收到后确认 seq 值与 ack=1 则连接建立成功,双方状态 ESTABLISHED。

完成三次握手,主机 A 与主机 B 开始传送数据

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(6)

FIN_WAIT_1: 这个状态要好好解释一下,其实 FIN_WAIT_1 和 FIN_WAIT_2 状态的真正含义都是表示等待对方的 FIN 报文。而这两种状态的区别 是:FIN_WAIT_1 状态实际上是当 SOCKET 在 ESTABLISHED 状态时,它想主动关闭连接,向对方发送了 FIN 报文,此时该 SOCKET 即 进入到 FIN_WAIT_1 状态。而当对方回应 ACK 报文后,则进入到 FIN_WAIT_2 状态,当然在实际的正常情况下,无论对方何种情况下,都应该马 上回应 ACK 报文,所以 FIN_WAIT_1 状态一般是比较难见到的,而 FIN_WAIT_2 状态还有时常常可以用 netstat 看到。

FIN_WAIT_2:上面已经详细解释了这种状态,实际上 FIN_WAIT_2 状态下的 SOCKET,表示半连接,也即有一方要求 close 连接,但另外还告诉对方,我暂时还有点数据需要传送给你,稍后再关闭连接。

TIME_WAIT: 表示收到了对方的 FIN 报文,并发送出了 ACK 报文,就等 2MSL 后即可回到 CLOSED 可用状态了。如果 FIN_WAIT_1 状态下,收到了对方同时带 FIN 标志和 ACK 标志的报文时,可以直接进入到 TIME_WAIT 状态,而无须经过 FIN_WAIT_2 状态。

CLOSING: 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送 FIN 报文后,按理来说是应该先收到(或同时收到)对方的 ACK 报文,再收到对方的 FIN 报文。但是 CLOSING 状态表示你发送 FIN 报文后,并没有收到对方的 ACK 报文,反而却也收到了对方的 FIN 报文。什 么情况下会出现此种情况呢?其实细想一下,也不难得出结论:那就是如果双方几乎在同时 close 一个 SOCKET 的话,那么就出现了双方同时发送 FIN 报 文的情况,也即会出现 CLOSING 状态,表示双方都正在关闭 SOCKET 连接。

CLOSE_WAIT: 这种状态的含义其实是表示在等待关闭。怎么理解呢?当对方 close 一个 SOCKET 后发送 FIN 报文给自己,你系统毫无疑问地会回应一个 ACK 报文给对 方,此时则进入到 CLOSE_WAIT 状态。接下来呢,实际上你真正需要考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以 close 这个 SOCKET,发送 FIN 报文给对方,也即关闭连接。所以你在 CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。 LAST_ACK: 这个状态还是比较容易好理解的,它是被动关闭一方在发送 FIN 报文后,最后等待对方的 ACK 报文。当收到 ACK 报文后,也即可以进入到 CLOSED 可用状态了。

2.accept connect listen 对应三次握手什么阶段
  1. Connect()函数:是一个阻塞函数 通过 TCp 三次握手父服务器建立连接

客户端主动连接服务器 建立连接方式通过 TCP 三次握手通知 Linux 内核自动完成 TCP 三次握手连接 如果连接成功为 0 失败返回值-1

一般的情况下 客户端的 connect 函数 默认是阻塞行为 直到三次握手阶段成功为止。

2.服务器端的 listen() 函数:不是一个阻塞函数: 功能:将套接字 和 套接字对应队列的长度告诉 Linux 内核

他是被动连接的 一直监听来自不同客户端的请求 listen 函数只要 作用将 socketfd 变成被动的连接监听 socket 其中参数 backlog 作用 设置内核中队列的长度 。

3.accept() 函数 阻塞:从处于 established 状态的队列中取出完成的连接 当队列中没有完成连接时候 会形成阻塞,直到取出队列中已完成连接的用户连接为止。

问题一:服务器没有及时调用 accept 函数取走完成连接的队列怎么办?

服务器的连接队列满掉后,服务器不会对再对建立新连接的 syn 进行应答,所以客户端的 connect 就会返回 ETIMEDOUT。但实际上 Linux 的并不是这样的 当 TCP 连接队列满了之后 Linux 并不会书中所说的拒绝连接,只是会延时连接。

三、操作系统:1.linux c 程序布局

一个程序本质上都是由 BSS 段、data 段、text 段三个组成的。可以看到一个可执行程序在存储(没有调入内存)时分为代码段、数据区和未初始化数据区三部分。

程序编译后生成的目标文件至少含有这三个段,这三个段的大致结构图如下所示:

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(7)

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(8)

text 段和 data 段在编译时已经分配了空间,而 BSS 段并不占用可执行文件的大小,它是由链接器来获取内存的

bss 段(未进行初始化的数据)的内容并不存放在磁盘上的程序文件中。其原因是内核在程序开始运行前将它们设置为 0。需要存放在程序文件中的只有正文段和初始化数据段。

data 段(已经初始化的数据)则为数据分配空间,数据保存到目标文件中。

数据段包含经过初始化的全局变量以及它们的值。BSS 段的大小从可执行文件中得到,然后链接器得到这个大小的内存块,紧跟在数据段的后面。当这个内存进入程序的地址空间后全部清零。包含数据段和 BSS 段的整个区段此时通常称为数据区。

可执行程序在运行时又多出两个区域:栈区和堆区。

(4)栈区:由编译器自动释放,存放函数的参数值、局部变量等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存放到栈中。然后这个被调用的 函数再为他的自动变量和临时变量在栈上分配空间。每调用一个函数一个新的栈就会被使用。栈区是从高地址位向低地址位增长的,是一块连续的内存区域,最大容 量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。

(5)堆区:用于动态分配内存,位于 BSS 和栈中间的地址区域。由程序员申请分配和释放。堆是从低地址位向高地址位增长,采用链式存储结构。频繁的 malloc/free 造成内存空间的不连续,产生碎片。当申请堆空间时库函数是按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。

下图将体现 c 的源文件对应存储空间:

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(9)

此时程序还没有被放入内存,只是在硬盘存储的情况,此时 bss 并未占用空间。bss 在链接的时候被获得内存空间。

下图表示程序运行,即程序在内存时的存储布局:

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(10)

四、智力题:

一枚硬币不均匀,如何把他设计成公平硬币(拒绝采样)这个题留给你们自己思考,发散一下思维,毕竟谁还没被智力题欺负过呢!

五、算法及数据结构:堆排序建堆及排序过程

这里只简单的提一下吧建堆从第一个非叶子结点开始向下交换(即每次调整都是从父节点、左孩子节点、右孩子节点三者中选择最大者跟父节点进行交换(交换之后可能造成被交换的孩子节点不满足堆的性质,因此每次交换之后要重新对被交换的孩子节点进行调整)。有了初始堆之后就可以进行排序了)排序排序过程为取出堆顶元素的操作,实现细节(取出堆顶元素与堆尾元素交换,将堆顶元素向下调整堆———在每一个调整过程中当不向下调整时即为调整结束)。取出的顺序为先第一个非叶子结点,第二个非叶子结点…一直到堆顶

这里还问了一下堆调整的时间复杂度,关于这个也不赘述了

手撕:最长无重复子串

思路与解法思路 1: 暴力法,实际解题中不会使用暴力法,这并不代表我们可以忽略它。索引从字符串的第一位开始,将后面的字符依次加入到 set 里面。如果 set 里面已经有了该字符,此次循环结束,内循环结束后记录 size。字符串的每一位都用这种方法去计算,得到的最大的 size 即是答案。

代码如下(不是 Java 的也看得懂,我进行了关键语法的注释,下同)

public int lengthOfLongestSubstring(String s) { int maxLen = 0; for(int i = 0; i < s.length(); i ){ // 创建一个存放字符的集合 HashSet<Character> set = new HashSet<>(); for(int j = i; j < s.length(); j ) { // 判断集合是否存在第 j 个字符 if(set.contains(s.charAt(j))) break; set.add(s.charAt(j)); } maxLen = Math.max(maxLen,set.size()); } return maxLen; }

复制代码

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(11)

这里也只贴这一种解法吧,还有很多种有趣的解法,大伙也可以发散一下思维。

二面:1 小时 10 分钟

项目:设计思路,遇到的最大问题,怎么保证分布式情况下主键全局唯一(项目聊了很久很久....)场景题:大量主机向一台服务器发送消息,怎么保证性能(当时没太懂什么意思,就谈了谈 io 模型,被问用没用过 epoll)

计算机网络:1.同 tcp 三次四次老八股

这个前面说了,这里就跳过了

2.tcp keep alive 实现原理

其实 keepalive 的原理就是 TCP 内嵌的一个心跳包以服务器端为例,如果当前 server 端检测到超过一定时间(默认是 7,200,000 milliseconds ,也就是 2 个小时)没有数据传输,那么会 向 client 端发送一个 keep-alive packet (该 keep-alive packet 就是 ACK 和当前 TCP 序列号减一的组合),此时 client 端应该为以下三种情况之一:

  1. client 端仍然存在,网络连接状况良好。此时 client 端会返回一个 ACK 。 server 端接收到 ACK 后重置计时器,在 2 小时后再发送探测。如果 2 小时内连接上有数据传输,那么在该时间基础上向后推延 2 个小时。
  2. 客户端异常关闭,或是网络断开。在这两种情况下, client 端都不会响应。服务器没有收到对其发出探测的响应,并且在一定时间(系统默认为 1000 ms )后重复发送 keep-alive packet ,并且重复发送一定次数( 2000 XP 2003 系统默认为 5 次 , Vista 后的系统默认为 10 次)。
  3. 客户端曾经崩溃,但已经重启。这种情况下,服务器将会收到对其存活探测的响应,但该响应是一个复位,从而引起服务器对连接的终止。
3.tcp 粘包

https://www.cnblogs.com/sui776265233/p/9289858.html,不明白 tcp 粘包的可以看看这篇文章

三面:1 小时

项目:问了所有项目,设计思路,遇到最大的困难

这一块你如实说自己的经历就可以了,没什么好说的

操作系统:1.复盘了一面没答好的 malloc(从内存布局,空闲块链表分配算法以及系统调用全都说了一遍)2.copyonwriteCopyOnWrite 思想

写入时复制(CopyOnWrite,简称 COW)思想是计算机程序设计领域中的一种通用优化策略。其核心思想是,如果有多个调用者(Callers)同时访问相同的资源(如内存或者是磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者修改资源内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此做法主要的优点是如果调用者没有修改资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

通俗易懂的讲,写入时复制技术就是不同进程在访问同一资源的时候,只有更新操作,才会去复制一份新的数据并更新替换,否则都是访问同一个资源。

JDK 的 CopyOnWriteArrayList/CopyOnWriteArraySet 容器正是采用了 COW 思想,它是如何工作的呢?简单来说,就是平时查询的时候,都不需要加锁,随便访问,只有在更新的时候,才会从原来的数据复制一个副本出来,然后修改这个副本,最后把原数据替换成当前的副本。修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。这点要跟读写锁区分一下。

源码分析

我们先来看看 CopyOnWriteArrayList 的 add() 方法,其实也非常简单,就是在访问的时候加锁,拷贝出来一个副本,先操作这个副本,再把现有的数据替换为这个副本。

public boolean add(E e) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; Object[] newElements = Arrays.copyOf(elements, len 1); newElements[len] = e; setArray(newElements); return true; } finally { lock.unlock(); } }

复制代码

CopyOnWriteArrayList 的 get(int index) 方法就是普通的无锁访问。

public E get(int index) { return get(getArray(), index); } @SuppressWarnings("unchecked") private E get(Object[] a, int index) { return (E) a[index]; }

复制代码

优点和缺点1.优点

对于一些读多写少的数据,写入时复制的做法就很不错,例如配置、黑名单、物流地址等变化非常少的数据,这是一种无锁的实现。可以帮我们实现程序更高的并发。

CopyOnWriteArrayList 并发安全且性能比 Vector 好。Vector 是增删改查方法都加了 synchronized 来保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而 CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于 Vector。

2.缺点

数据一致性问题。这种实现只是保证数据的最终一致性,在添加到拷贝数据而还没进行替换的时候,读到的仍然是旧数据。

内存占用问题。如果对象比较大,频繁地进行替换会消耗内存,从而引发 Java 的 GC 问题,这个时候,我们应该考虑其他的容器,例如 ConcurrentHashMap

计算机网络:

1.如何在应用层保证 udp 可靠传输 2.https 和 http 区别以及 https 的 ssl 握手机制 3.https 为什么要采用对称和非对称加密结合的方式

智力题:

1.rand5 到 rand7,算是拒绝采样吧,和一面思路很像 2.有 32 个大小相同的石头,有一把称,请问最少称多少次可以找出质量最大的石头第二大呢第 n 大呢

手撕:

蛇形遍历数组

谈心:

平常看什么书,看哪些技术网站能干多久反问

hr 面

这个没什么好说的,扯不到技术,就随便聊聊,别把天聊死了就行。

没被问到数据库有点诧异,毕竟准备最充分的就是数据库,明显感觉部门重视操作系统和计算机网络,算法无论是手撕还是口述都有,所以还是需要重视。

小伙伴们有兴趣想了解内容和更多相关学习资料的请点赞收藏 评论转发 关注我,后面会有很多干货。

我有一些面试题、架构、设计类资料可以说是程序员面试必备!所有资料都整理到网盘了,需要的话欢迎下载!私信我回复【07】即可免费获取

字节跳动java社招第三面(字节跳动Java岗一二三面全经过分享)(12)

原文出处:zhuanlan.zhihu.com/p/549567922/edit

,