落泪!滴滴一面,凉了。。

图解学习网站:https://xiaolincoding.com

大家好,我是小林。

今天就来分享 Java 同学面试滴滴后端开发的面经,主要是问了Java+MySQL+系统+网络+算法,都是比较经典面试题,不算难。

可惜最后同学还是挂了,挂了没关系,重在复盘每一次的面经,针对面试中不理解或者不明白的问题看书继续加强理解,针对已经知道,但是讲不清楚的问题,自己对着镜子面前练习。

考察的知识内容,我帮大家罗列了一下:

  • 操作系统:进程线程协程、进程状态、io 模型、io 多路复用
  • 计算机网络:http 请求头、状态码
  • Java:集合、JVM
  • mysql:事务、并发问题
  • 算法:二分

操作系统

进程,线程,协程的区别是什么?

  • 首先,我们来谈谈进程。进程是操作系统中进行资源分配和调度的基本单位,它拥有自己的独立内存空间和系统资源。每个进程都有独立的堆和栈,不与其他进程共享。进程间通信需要通过特定的机制,如管道、消息队列、信号量等。由于进程拥有独立的内存空间,因此其稳定性和安全性相对较高,但同时上下文切换的开销也较大,因为需要保存和恢复整个进程的状态。
  • 接下来是线程。线程是进程内的一个执行单元,也是CPU调度和分派的基本单位。与进程不同,线程共享进程的内存空间,包括堆和全局变量。线程之间通信更加高效,因为它们可以直接读写共享内存。线程的上下文切换开销较小,因为只需要保存和恢复线程的上下文,而不是整个进程的状态。然而,由于多个线程共享内存空间,因此存在数据竞争和线程安全的问题,需要通过同步和互斥机制来解决。
  • 最后是协程。协程是一种用户态的轻量级线程,其调度完全由用户程序控制,而不需要内核的参与。协程拥有自己的寄存器上下文和栈,但与其他协程共享堆内存。协程的切换开销非常小,因为只需要保存和恢复协程的上下文,而无需进行内核级的上下文切换。这使得协程在处理大量并发任务时具有非常高的效率。然而,协程需要程序员显式地进行调度和管理,相对于线程和进程来说,其编程模型更为复杂。

进程的状态(五种状态),如何切换?

一个完整的进程状态的变迁如下图:

进程五种状态的变迁 再来详细说明一下进程的状态变迁:

  • _NULL -> 创建状态_:一个新进程被创建时的第一个状态;
  • _创建状态 -> 就绪状态_:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
  • _就绪态 -> 运行状态_:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
  • _运行状态 -> 结束状态_:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
  • _运行状态 -> 就绪状态_:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
  • _运行状态 -> 阻塞状态_:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
  • _阻塞状态 -> 就绪状态_:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;

你了解过哪些io模型?

  • 阻塞I/O模型:应用程序发起I/O操作后会被阻塞,直到操作完成才返回结果。适用于对实时性要求不高的场景。
  • 非阻塞I/O模型:应用程序发起I/O操作后立即返回,不会被阻塞,但需要不断轮询或者使用select/poll/epoll等系统调用来检查I/O操作是否完成。适合于需要进行多路复用的场景,例如需要同时处理多个socket连接的服务器程序。
  • I/O复用模型:通过select、poll、epoll等系统调用,应用程序可以同时等待多个I/O操作,当其中任何一个I/O操作准备就绪时,应用程序会被通知。适合于需要同时处理多个I/O操作的场景,比如高并发的服务端程序。
  • 信号驱动I/O模型:应用程序发起I/O操作后,可以继续做其他事情,当I/O操作完成时,操作系统会向应用程序发送信号来通知其完成。适合于需要异步I/O通知的场景,可以提高系统的并发能力。
  • 异步I/O模型:应用程序发起I/O操作后可以立即做其他事情,当I/O操作完成时,应用程序会得到通知。异步I/O模型由操作系统内核完成I/O操作,应用程序只需等待通知即可。适合于需要大量并发连接和高性能的场景,能够减少系统调用次数,提高系统效率。

有了解过io多路复用吗?

IO多路复用是一种高效的IO处理方式,它允许单个进程或线程同时监视多个文件描述符,如网络连接或文件句柄。当这些描述符中的任何一个就绪时,比如有数据可读或可写,多路复用机制就能够通知应用程序进行相应的读写操作。这种机制的核心优势在于,它可以在不增加额外线程或进程的情况下,处理大量的并发连接,从而显著地提高系统的并发性和响应能力。常见的IO多路复用技术包括select、poll和epoll等。这些技术各有特点,但核心思想都是通过一个线程来管理多个连接,减少系统资源的消耗,并提高程序运行的效率。select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。poll 通过两个方面,很好解决了 select/poll 的问题。

  • _第一点_,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  • _第二点_, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

从下图你可以看到 epoll 相关的接口作用:

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

计算机网络

Http请求头有哪些

下面是一个HTTP请求的请求头:

代码语言:javascript
复制
GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html
Connection: keep-alive
Upgrade-Insecure-Requests: 1
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
Cache-Control: max-age=0

常见的请求字段如下表所示:

字段名

说明

示例

Accept

能够接受的回应内容类型(Content-Types)

Accept: text/plain

Accept-Charset

能够接受的字符集

Accept-Charset: utf-8

Accept-Encoding

能够接受的编码方式列表

Accept-Encoding: gzip, deflate

Authorization

用于超文本传输协议的认证的认证信息

Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==

Cache-Control

用来指定在这次的请求/响应链中的所有缓存机制 都必须 遵守的指令

Cache-Control: no-cache

Connection

该浏览器想要优先使用的连接类型

Connection: keep-alive Connection: Upgrade

Cookie

服务器通过 Set- Cookie (下文详述)发送的一个 超文本传输协议Cookie

Cookie: $Version=1; Skin=new;

Content-Length

以 八位字节数组 (8位的字节)表示的请求体的长度

Content-Length: 348

Content-Type

请求体的 多媒体类型

Content-Type: application/x-www-form-urlencoded

Host

服务器的域名(用于虚拟主机 ),以及服务器所监听的传输控制协议端口号

Host: en.wikipedia.org:80 Host: en.wikipedia.org

User-Agent

浏览器的浏览器身份标识字符串

User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/21.0

Origin

发起一个针对 跨来源资源共享 的请求

Origin: http://www.example-social-network.com

常见的状态码(分类举例说明)

五大类 HTTP 状态码

  • 1xx 类状态码属于提示信息,是协议处理中的一种中间状态,实际用到的比较少。
  • 2xx 类状态码表示服务器成功处理了客户端的请求,也是我们最愿意看到的状态。
  • 3xx 类状态码表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是重定向
  • 4xx 类状态码表示客户端发送的报文有误,服务器无法处理,也就是错误码的含义。
  • 5xx 类状态码表示客户端请求报文正确,但是服务器处理时内部发生了错误,属于服务器端的错误码。

301和302有什么区别

重定向状态码如下,301 和 302 都会在响应头里使用字段 Location,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。

  • 301 Moved Permanently」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。
  • 302 Found」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。

重定向是指将一个URL请求转发到另一个URL的过程。重定向的作用包括:

  • 更改URL:通过重定向,可以更改URL,使其更易于记忆、更友好或更有意义。例如,将长而复杂的URL重定向到简洁的、易于理解的URL。
  • 网站迁移:当网站进行重构、更换域名或更改URL结构时,通过重定向旧的URL到新的URL,可以让用户和搜索引擎正确地访问和索引新的内容。

Java八股

常用的集合有哪些?

List是有序的Collection,使用此接口能够精确的控制每个元素的插入位置,用户能根据索引访问List中元素。常用的实现List的类有LinkedList,ArrayList,Vector,Stack。

  • ArrayList是容量可变的非线程安全列表,其底层使用数组实现。当几何扩容时,会创建更大的数组,并把原数组复制到新数组。ArrayList支持对元素的快速随机访问,但插入与删除速度很慢。
  • LinkedList本质是一个双向链表,与ArrayList相比,,其插入和删除速度更快,但随机访问速度更慢。

Set不允许存在重复的元素,与List不同,set中的元素是无序的。常用的实现有HashSet,LinkedHashSet和TreeSet。

  • HashSet通过HashMap实现,HashMap的Key即HashSet存储的元素,所有Key都是用相同的Value,一个名为PRESENT的Object类型常量。使用Key保证元素唯一性,但不保证有序性。由于HashSet是HashMap实现的,因此线程不安全。
  • LinkedHashSet继承自HashSet,通过LinkedHashMap实现,使用双向链表维护元素插入顺序。
  • TreeSet通过TreeMap实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。

Map 是一个键值对集合,存储键、值和之间的映射。Key 无序,唯一;value 不要求有序,允许重复。Map 没有继承于 Collection 接口,从 Map 集合中检索元素时,只要给出键对象,就会返回对应的值对象。主要实现有TreeMap、HashMap、HashTable、LinkedHashMap、ConcurrentHashMap

  • HashMap:JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突),JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间
  • LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
  • HashTable:数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
  • TreeMap:红黑树(自平衡的排序二叉树)
  • ConcurrentHashMap:Node数组+链表+红黑树实现,线程安全的(jdk1.8以前Segment锁,1.8以后volatile + CAS 或者 synchronized)

hashtable 和concurrentHashMap有什么区别

  • 底层数据结构:
    • jdk7之前的ConcurrentHashMap底层采用的是分段的数组+链表实现,jdk8之后采用的是数组+链表/红黑树;
    • HashTable采用的是数组+链表,数组是主体,链表是解决hash冲突存在的。
  • 实现线程安全的方式:
    • jdk8以前,ConcurrentHashMap采用分段锁,对整个数组进行了分段分割,每一把锁只锁容器里的一部分数据,多线程访问不同数据段里的数据,就不会存在锁竞争,提高了并发访问;jdk8以后,直接采用数组+链表/红黑树,并发控制使用CAS和synchronized操作,更加提高了速度。
    • HashTable:所有的方法都加了锁来保证线程安全,但是效率非常的低下,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。

Java的垃圾回收器有哪些?

  • Serial 收集器,串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。
  • ParNew 收集器,ParNew 收集器其实就是 Serial 收集器的多线程版本。
  • Parallel 收集器,Parallel Scavenge 收集器类似 ParNew 收集器,Parallel 收集器更关注系统的吞吐量。
  • Parallel Old 收集器,Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法
  • CMS 收集器,CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
  • G1 收集器,G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征

垃圾回收的方法有哪些?

  • 标记-清除算法:标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。标记-清除算法有两个缺陷,一个是效率问题,标记和清除的过程效率都不高,另外一个就是,清除结束后会造成大量的碎片空间。有可能会造成在申请大块内存的时候因为没有足够的连续空间导致再次 GC。
  • 复制算法:为了解决碎片空间的问题,出现了“复制算法”。复制算法的原理是,将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。复制算法解决了空间碎片的问题。但是也带来了新的问题。因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。
  • 标记-整理算法:复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时,会执行较多的复制操作,效率就会下降。而老年代的对象在 GC 之后的存活率就比较高,所以就有人提出了“标记-整理算法”。标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
  • 分代回收算法:分代收集是将内存划分成了新生代和老年代。分配的依据是对象的生存周期,或者说经历过的 GC 次数。对象创建时,一般在新生代申请内存,当经历一次 GC 之后如果对还存活,那么对象的年龄 +1。当年龄超过一定值(默认是 15,可以通过参数 -XX:MaxTenuringThreshold 来设定)后,如果对象还存活,那么该对象会进入老年代。

有哪些优化java程序的办法

调整新生代和老年代的比例、线程池、减少GC

mysql

mysql几种事务隔离

SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:

  • 读未提交(_read uncommitted_),指一个事务还没提交时,它做的变更就能被其他事务看到;
  • 读提交(_read committed_),指一个事务提交之后,它做的变更才能被其他事务看到;
  • 可重复读(_repeatable read_),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别
  • 串行化(_serializable_ );会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

按隔离水平高低排序如下:

针对不同的隔离级别,并发事务时可能发生的现象也会不同。

也就是说:

  • 在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;
  • 在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;
  • 在「可重复读」隔离级别下,可能发生幻读现象,但是不可能脏读和不可重复读现象;
  • 在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。

脏读和幻读有什么区别

脏读

如果一个事务「读到」了另一个「未提交事务修改过的数据」,就意味着发生了「脏读」现象。

举个栗子。

假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后再执行更新操作,如果此时事务 A 还没有提交事务,而此时正好事务 B 也从数据库中读取小林的余额数据,那么事务 B 读取到的余额数据是刚才事务 A 更新后的数据,即使没有提交事务。

因为事务 A 是还没提交事务的,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。

幻读

在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

举个栗子。

假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。

接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。

然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。

算法

有序数组1-100,删除一个数,如何查找

可以通过二分查找的方式来查找并删除一个数。

首先,将数组从中间分成两部分,比较中间元素和要查找的数的大小关系。如果中间元素等于要查找的数,则删除该元素并将数组整体向前移动一位。如果中间元素大于要查找的数,则在左半部分继续进行二分查找。如果中间元素小于要查找的数,则在右半部分继续进行二分查找。

重复以上步骤,直到找到要删除的数或者确定该数不在数组中。如果找到要删除的数,则将该数删除并将数组整体向前移动一位。

以下是java实现代码:

代码语言:javascript
复制
public class DeleteNumber {
public static void main(String[] args) {
    int[] arr = new int[100];
    for (int i = 0; i < 100; i++) {
        arr[i] = i + 1;
    }

    int target = 50; // 要删除的数
    int index = binarySearch(arr, target);
    if(index != -1){
        deleteNumber(arr, index);
        System.out.println("删除成功!");
    }else{
        System.out.println("未找到该数!");
    }
}

public static int binarySearch(int[] arr, int target) {
    int left = 0;
    int right = arr.length - 1;

    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }

    return -1;
}

public static void deleteNumber(int[] arr, int index) {
    for (int i = index; i < arr.length - 1; i++) {
        arr[i] = arr[i + 1];
    }
}

}

在上面的代码中,假设要删除的数是50,然后调用binarySearch方法查找50的索引,如果找到则调用deleteNumber方法删除该数。最后打印删除成功的消息。