C++ 八股复习笔记
C++ 八股复习笔记
一、计算机网络
001. TCP/IP 模型和 OSI 模型
OSI 是理论上的国际标准,是七层协议体系结构,从上至下分别是应用层、表示层、会话层、运输层、网络层、数据链路层和物理层。而 TCP/IP 模型是实际上的工业标准,是根据实用性设计的四层体系结构,从上至下分别是应用层、运输层、网络层和网络接口层。
TCP/IP 模型的应用层对应 OSI 的应用层、表示层和会话层。将这三层合并后,让应用程序自行管理数据格式会更加高效。应用层负责应用进程间的交互,来完成特定的网络应用,它的协议定义的是应用进程间通信和交互的规则。其中协议有很多,比如 DNS、HTTP、HTTPS 和 SMTP 等等。
TCP/IP 的运输层对应 OSI 的运输层,负责向两台主机中进程间的通信提供通用的数据传输服务,主要使用 TCP 和 UDP 两种协议。其中 TCP 提供的是面向连接、可靠的数据传输服务,而 UDP 提供的是无连接、不可靠的数据传输服务。
TCP/IP 的网络层对应 OSI 的网络层,使用的是无连接的 IP 协议,利用 IP 地址为分组交换网上的不同主机提供通信服务。而 OSI 同时考虑了面向连接和无连接的通信服务。在运输层和网络层对于连接服务的支持上,TCP/IP 模型比 OSI 模型具备更加明确的划分和更加精简的设计。
最下层的网络接口层对应 OSI 模型的数据链路层和物理层,但它没有属于 TCP/IP 体系的具体协议,主要负责将网络层交付的 IP 数据报组装成帧,并利用物理介质,比如光缆或者无线电等等,将比特流发送到物理链路的另一端。比起 OSI 的分层设计,它具有更好的兼容性,对硬件的限制也更少。
002. 从输入 URL 到页面展示
第一步,解析 URL 地址,准备发送 HTTP 请求。第二步,检查本地缓存是否有对应资源,如果有并且缓存有效,就直接返回对应内容;没有则进入下一步网络请求。
第三步,DNS 查找域名对应的 IP 地址。一般会按照浏览器缓存、操作系统缓存和 hosts 文件、本地 DNS 服务器等顺序查询,直到找到为止。其中如果服务器使用了 CDN,那么 DNS 解析可能会通过 CNAME 返回离用户较近的 CDN 节点 IP 地址,而不是原站地址。
第四步,TCP 三次握手,浏览器与服务器建立连接。如果是 HTTPS 协议的话,还要进行 TLS 加密协议握手。第五步,浏览器构建 HTTP 请求报文并发送给服务器。第六步,服务器处理请求并返回 HTTP 响应,其中包括状态码,比如 404 未找到、301 重定向等等。
第七步,浏览器接收并解析响应,然后渲染页面。第八步,如果这个 TCP 连接不再复用,再通过四次挥手断开连接;如果使用长连接,就不一定会马上断开。
003. HTTP 请求和响应报文
HTTP 请求报文包括请求行、请求头、用来分割头部和数据的空行,还有请求数据。请求行包括方法,比如 GET、POST、HEAD;还有 URI,表示请求的资源;然后是 HTTP 的版本号。
请求头包括的字段比较多,常见的比如 Host,表示请求的服务器域名;Accept 表示客户端能够处理的媒体类型;Accept-Encoding 表示能够解码的数据压缩方式;Cookie 表示客户端存储的用户信息;Content-Length 表示数据的长度;Content-Type 表示媒体类型;还有 Connection,表示连接的状态,比如 keep-alive。
响应报文包括状态行、响应头、用来分割头部和数据的空行,还有响应数据。状态行包括 HTTP 的版本号、状态码和状态信息。状态码从 200 到 300 表示请求成功,300 到 400 表示资源重定向,400 到 500 表示客户端请求有错误,500 到 600 表示服务端有错误。
响应头的字段和请求头有比较大的相似,主要也是 Content-Type 表示媒体类型,Content-Length 表示响应数据长度,Connection 表示连接状态,还有 Content-Encoding 表示数据压缩方式。主要就这些。
004. HTTP 请求方式
HTTP/1.0 定义了三种请求方式,分别是 GET、POST 和 HEAD。GET 主要用来请求指定的资源;POST 用来向指定资源提交数据处理请求,比如注册账户、表单提交等;HEAD 和 GET 类似,但只返回报文头部,不返回具体的报文数据。
然后 HTTP/1.1 以及后续扩展又增加了几种请求方式,比如 PUT 用来创建或者整体更新指定资源,DELETE 用来删除指定资源。另外还有 PATCH,对部分资源进行更新;OPTIONS 用来查询服务器支持的请求方式;CONNECT 主要用于建立 HTTP 代理隧道;TRACE 主要用来测试和诊断,可以回显服务器收到的请求。
005. GET 请求和 POST 请求的区别
从用途上来说,GET 请求因为是幂等的,一般用来向服务器获取数据,因为它正常情况下不会修改服务器的数据。然后 POST 因为通常不是幂等的,所以一般用来向服务器提交数据,常会导致服务器数据被修改。
在传输上,GET 会把参数放在请求行的 URL 后面,POST 通常会把参数放在请求数据中。这就导致 GET 的参数更容易直接显示在 URL 中,比如 user 和 password 都可能出现在地址栏里。但是从网络传输上讲,实际的安全性都要靠 HTTPS 加密来保证,POST 本身并不天然比 GET 安全。
然后数据长度上,GET 常受到浏览器和服务器对 URL 长度的限制,但是 POST 一般可以传输更多数据,实际也可能受服务器配置限制。还有在缓存上,GET 请求一般会被浏览器缓存,但是 POST 基本不会。
006. HTTP 中常见的状态码
状态码的话,从 200 到 300 表示请求成功。常见的是 200,表示请求成功;201 表示请求成功并创建了新的资源;204 表示请求成功,但是没有返回任何内容。
状态码 300 到 400 表示资源重定向,常见的有 301 永久重定向、302 临时重定向。还有 304,表示请求资源没有被修改,所以客户端可以继续使用缓存内容。
状态码 400 到 500 表示客户端请求发生了错误,常见的有 401,表示请求需要身份验证;403 表示请求的资源被禁止访问;404 表示没有找到所请求的资源。
500 到 600 的状态码表示服务器发生了错误。常见的有 500,表示服务器内部错误;502 表示代理服务器接收到的上游服务器响应有问题;504 表示上游服务器响应超时。
007. 强缓存和协商缓存
强缓存和协商缓存是浏览器缓存的两种方式,都可以用来加速页面加载以及降低服务器负担。
强缓存表示客户端在发送 HTTP 请求前,检查上一次服务器返回的响应报文头部。其中的字段分为两种,一种是 Expires,一种是 Cache-Control,它们都可以表示资源的有效时间。如果资源还有效,就直接使用本地缓存内容;如果已经失效,就需要向服务器发送 HTTP 请求。
协商缓存是在强缓存失效的情况下,客户端向服务端发送 HTTP 请求,携带本地资源的标识,与服务端资源进行比对。其中包括两组字段,一组是 ETag 和 If-None-Match,ETag 表示资源的唯一标识;另一组是 Last-Modified 和 If-Modified-Since,表示资源的最后修改时间。
发送之后与服务端资源进行比对,如果资源没有变化,就返回 304,让客户端直接调用缓存;如果不同,就返回新的资源,并附带状态码 200 OK。
008. HTTP/1.0 和 HTTP/1.1 的区别
在连接方式上,HTTP/1.1 默认支持长连接,也就是在建立的一次 TCP 连接中可以发送多次 HTTP 请求和响应;而 HTTP/1.0 默认是短连接,在发送 HTTP 请求时需要重新建立一次 TCP 连接,也就是重新进行三次握手和四次挥手,除非自己显式开启 Connection: keep-alive。
然后 HTTP/1.1 还支持管道化,也就是可以连续发送多个 HTTP 请求,而不需要像原来那样等待第一次请求返回后才能发送后续请求。不过响应还是要按请求顺序返回,所以队头阻塞问题没有被完全解决。
在缓存控制上,HTTP/1.1 新增了 ETag 和 Cache-Control 等字段。相比 HTTP/1.0 主要使用的 Expires 和 Last-Modified,新增的 ETag 可以作为资源的唯一标识,Cache-Control 可以使用相对时间,所以能更加精细、准确地进行缓存控制。
然后在带宽上,HTTP/1.1 新增了 Range 字段,支持断点续传,也可以只请求资源的一部分内容。它还新增了 Host 字段,可以支持同一 IP 托管多个域名。在请求方式上,HTTP/1.1 以及后续扩展还增加了 PUT、DELETE、OPTIONS、TRACE、PATCH 等请求方式。
009. HTTP/2 与 HTTP/1.1 的区别
在数据格式上,HTTP/2 使用的是二进制格式,HTTP/1.1 使用的是文本格式。使用二进制格式可以使数据处理更加高效,也提升了一定的健壮性。
在队头阻塞问题上,HTTP/2 引入了 Stream ID,使它支持多路复用,可以在一个 TCP 连接中乱序、并发地传输多个请求和响应,解决了 HTTP 层面的队头阻塞问题。同时也可以给这些流设置优先级,保证核心资源优先传输。不过它底层还是 TCP,如果 TCP 发生丢包,多个流还是会一起等待重传。
然后 HTTP/2 还新增了 HPACK 算法,用来压缩请求和响应的头部信息,减少头部冗余信息的传输,提高传输效率。还有 HTTP/2 支持服务器主动推送资源,可以让客户端不需要再次发送请求来获取资源,提高页面资源加载的速度。
010. HTTP/3 有了解过吗
HTTP/3 主要使用了基于 UDP 的 QUIC 协议来替代原本的 TCP,使它获得了几种新的特性。
第一是解决原本 TCP 层面的队头阻塞问题。现在如果某个 Stream 发生了丢包,主要只会阻塞当前丢包的 Stream,而不影响其他 Stream。
然后在连接上,它融合了加密和连接握手,使第一次建立连接通常只需要一次往返;后续连接可以利用之前保存的会话信息来实现零往返。这里的加密握手使用的是 TLS 1.3。
然后在连接迁移上,QUIC 使用 Connection ID,支持在不同 IP 之间切换而不中断网络传输。也就是即使从 Wi-Fi 切换到移动网络,也不会像传统 TCP 那样因为 IP 变化直接导致连接中断。
011. HTTPS 和 HTTP 有哪些区别
HTTPS 就是在 HTTP 的基础上新增了 TLS 加密协议,在原本 HTTP 明文传输的基础上加密数据,保证数据传输的安全性。
要实现这个过程,需要在建立底层连接之后增加 TLS 握手,来实现数据的加密传输。然后为了让浏览器验证服务器身份,公开网站一般还需要给服务器申请可信证书。HTTPS 一般使用 443 号端口,而 HTTP 一般使用 80 号端口。
012. HTTPS 工作原理
HTTPS 的核心工作原理就是:在建立连接时采用非对称加密完成身份认证和密钥协商,在建立连接后使用对称加密进行通信。
在连接建立前,浏览器先给服务器发送请求,然后服务器会在返回时把自己的公钥包含在受信任的证书中。浏览器接收并检验证书后,双方协商出一个对称会话密钥。可以简单理解为,非对称加密解决这个密钥如何安全建立的问题;双方都有会话密钥以后,后续通信就使用对称密钥加密数据,再互相传输。
以前的 RSA 握手可以理解成浏览器用服务器公钥加密秘密信息,服务器再用私钥解密;现代 TLS 一般主要通过 ECDHE 等方式协商会话密钥,但整体记忆还是“非对称完成认证和协商,对称负责数据传输”。
013. TCP 和 UDP 的区别
TCP 是面向连接、可靠的数据传输协议,而 UDP 是无连接、不可靠的数据传输协议。
可靠性上,TCP 主要使用序列号、确认应答和重传机制来保证数据传输的顺序以及完整性;而 UDP 没有这些机制,无法保证数据包传输的顺序和完整性。
然后 TCP 还拥有流量控制和拥塞控制,来控制发送方的传输速度,而 UDP 没有。另外 TCP 的报文头部相比 UDP 更长、也更加复杂。总结的话,TCP 相比 UDP 会更加复杂,性能开销也更大。
014. TCP 连接如何确保可靠性
TCP 连接的可靠性主要可以分为三大部分吧。
第一部分就是 TCP 报文格式的设置。最主要的是它的首部有一个序列号字段来保证传输顺序,然后还有一个校验和字段,用来检查传输过程中数据有没有出现差错。如果有差错,就会把报文丢弃,再通过重传补回来。
第二部分就是传输过程中使用了重传机制,主要有三种。第一种是最常见的超时重传,在发送的时候设定一个计时器,如果超时后还没有收到确认,就重传这个包。第二种是快速重传,如果连续收到了多个相同的确认,一般是三个重复 ACK,就快速重传这个包,不需要等待计时器结束。第三种是选择确认 SACK,可以告诉发送方哪些数据块已经收到,这样只需要针对缺失的数据进行重传,而不需要把后面的数据全部重传。
第三部分算是宏观上的流量控制。一个是使用滑动窗口进行发送方的流量控制,另一个是拥塞控制,其实也是对发送方的发送速率进行控制。这两个合在一起,就是从宏观上控制流量,减少丢包,保证整体网络传输的可靠性。主要就是这三部分。
015. 拥塞控制是怎么实现的
拥塞控制可以分为两个阶段吧。第一个阶段就是拥塞发生之前,最初使用慢启动算法,也就是从一个很小的窗口开始发送数据包,在收到 ACK 确认后对窗口进行增长,而且大约是指数级增长,可以尽快测试到当前网络的承载极限。
到了慢启动设定的阈值 ssthresh 之后,转而进入拥塞避免算法,也就是窗口大小的增长变成线性,而不是一开始的指数级增长。
第二个阶段就是发生了拥塞。最初在轻微拥塞的时候使用快速重传机制,也就是收到几个重复 ACK 后快速重传这个包,并且使用快速恢复机制。以经典 Reno 为例,会把慢启动阈值调整到当前拥塞窗口的一半附近,然后继续进入拥塞避免。
如果发生了超时重传,就代表当前网络拥塞比较严重。那么会把慢启动阈值减小,把当前拥塞窗口重置到一个很小的值,整个拥塞控制重新进入慢启动阶段。
016. TCP 流量控制是怎么实现的
TCP 流量控制核心依靠的是滑动窗口机制。也就是说,接收方收到发送方的报文后,会根据自己当前缓冲区的剩余大小,在返回 ACK 时,在其中的窗口字段附上当前可接收的缓冲区大小。
发送方接收到 ACK 报文中的窗口大小后,会动态调整当前的发送窗口,对发送流量进行控制,避免接收方的缓冲区溢出。
如果接收方返回的窗口大小为 0,那么发送方就会停止发送数据包,但是会设置一个持续计时器,周期性发送一个很小的窗口探测包,也就是询问接收方当前缓冲区是否已经有空闲,防止双方一直等待。
017. UDP 怎么实现可靠传输
UDP 因为本身是一个无连接、不可靠的传输协议,要实现可靠性的话,肯定只能在应用层模拟 TCP 的那一套机制。
首先就是设置序列号字段以及确认号字段,保证数据传输顺序并对数据包进行确认。然后需要设置重传机制,一个是超时重传,一个是选择性重传。还需要根据实际场景实现流量控制以及拥塞控制。
实际中的例子就是 HTTP/3 使用的 QUIC。它基于 UDP,在应用层或者说用户态实现可靠传输,同时解决 TCP 队头阻塞问题,并支持 0-RTT、1-RTT、连接迁移等能力。整体上并不是因为 UDP 头部小就一定更快,主要还是 QUIC 对握手和多路复用这些机制进行了重新设计。
018. TCP 三次握手
TCP 三次握手的过程,首先是客户端向服务端发送请求报文,其中将 SYN 同步标志位置为 1,并附带自己的序列号 x。此时客户端进入 SYN_SENT,也就是同步已发送状态。
然后服务端收到请求后,会返回一个确认报文,将 SYN 和 ACK 都置为 1,把确认号设为 x+1,作为对之前序列号 x 的确认,然后附带自己新的序列号 y 返回给客户端。此时服务端的状态变为 SYN_RCVD,就是同步已收到状态。
客户端收到服务端的确认报文后,需要再返回一个 ACK 报文给服务端,把 ACK 置为 1,序列号是 x+1,确认号是 y+1,作为对序列号 y 的确认。服务端收到以后,双方连接正式建立。
需要三次握手的原因是,这三步每一步都是核心过程。第一步表示客户端可以发送;第二步表示服务端可以接收客户端的消息,并且可以发送;第三步表示客户端也可以接收到服务端的消息。这三次握手结合起来,才能确认双方都具备收发信息的能力,同时同步双方的初始序列号。
如果使用更少的握手次数,在消息延迟或者历史连接请求重复到达的情况下可能发生错误;增加更多次数,实际达到的效果与三次握手一样,所以更多握手过程就是冗余的。
019. TCP 四次挥手
四次挥手的过程,首先客户端会向服务端发送一个终止连接请求,将报文中的 FIN 标志位置为 1,附带一个序列号,比如 x。客户端进入 FIN_WAIT_1 状态。
服务端收到连接释放请求后,会返回一个确认报文,将 ACK 标志位置为 1,确认号为 x+1。此时服务端进入 CLOSE_WAIT 状态。客户端收到确认后进入 FIN_WAIT_2 状态。这个连接进入半关闭状态,也就是客户端不会再向服务端发送新的数据,但是服务端可能还有一些数据继续向客户端发送。
等这些数据发送完毕后,服务端才会向客户端发送一个新的 FIN 报文,附带序列号比如 z,然后进入 LAST_ACK 状态。客户端收到后,向服务端发送最终 ACK,确认号为 z+1,然后进入 TIME_WAIT 状态。
服务端收到最后的 ACK 后会进入 CLOSED,彻底关闭连接。客户端一般等待两倍的最大报文生存时间,也就是 2MSL 后,再进入 CLOSED,双方连接正式结束。
相比三次握手,四次挥手需要多一次的原因是:服务端收到客户端的断开请求后,需要先返回 ACK,表示已经确认这个请求;但是它可能还有数据没有传输完,必须等数据发送结束后,再发送 FIN,表示自己也准备关闭。所以这两次报文代表的意义不同,一个是对客户端 FIN 的确认,一个是服务端自己的连接终止请求。
020. HTTP 长连接
HTTP 的 Keep-Alive 和 TCP 层的 Keepalive 是完全不同的。
简单来说,HTTP 的 Keep-Alive 表示长连接机制,也就是在建立一次 TCP 连接后,可以发送多次 HTTP 请求,而不是像短连接那样,每发送一次 HTTP 请求都要重新建立整个 TCP 连接。这样可以减少 TCP 三次握手、四次挥手的时间开销,避免重复建立连接。
而 TCP 层的 Keepalive 是一种保活机制,也就是双方长时间没有传输新数据时,按照一定时间间隔发送 Keepalive 探测包,确认当前连接是否还有效。如果收到了确认报文,当前连接就继续使用;如果多次发送探测包都没有收到返回,就会断开连接,回收网络资源。
021. DNS 查询过程
DNS 查询是一个将域名转换为具体 IP 地址的过程。首先检查本地 DNS 缓存,如果有对应的 IP 地址就直接返回;如果没有,就会向本地 DNS 服务器,也就是递归解析器,发送查询请求。
本地 DNS 服务器如果有缓存就直接返回;如果没有,它会先向根 DNS 服务器查询。根 DNS 服务器返回顶级域名 DNS 服务器的地址,接着解析器向顶级域名 DNS 服务器发送查询请求。顶级域名 DNS 服务器返回具体的权威 DNS 服务器地址,解析器再向权威 DNS 服务器发送查询请求。
权威 DNS 服务器查询该域名对应的 IP 地址并返回给解析器,解析器再将查询结果返回给浏览器,并保留一份作为 DNS 缓存。
022. CDN 是什么
CDN 简单来说是一种分布式网络服务,它把原站服务器的资源内容存储在各地区的代理或者边缘服务器上。这些服务器会缓存原站中的资源内容,主要有三大作用吧。
首先会加速用户对资源的访问。用户发送资源请求时,DNS 和 CDN 调度会返回一个更适合当前用户访问的 CDN 节点 IP 地址,加速资源访问。
第二点,它把资源访问分散到各地区的节点上,减少对原站服务器的直接访问,降低原站负载。
第三点就是分布式的可靠性。即使一个节点出现问题、无法访问,系统仍然可以把用户请求重定向到其他节点上来获取资源。
023. Cookie 和 Session 是什么?有什么区别
Session 和 Cookie 本质上都可以用于标识用户身份和保存用户状态。Session 是存储在服务端的,而 Cookie 是存储在客户端的。
它的过程可以简单来说是这样的:用户登录的时候,服务端首先创建一个 Session,并生成一个 Session ID,然后通过响应把 Session ID 返回给客户端。客户端保存的 Cookie 会在之后每次发送请求时附带这个 Session ID。
服务端收到 Cookie 中的内容后,会根据其中的 Session ID 查找本地对应的 Session,来确认用户身份信息。
二、操作系统
024. 进程和线程的区别
进程是操作系统进行资源分配的基本单位,而线程是程序执行和 CPU 调度的基本单位。一个进程可以包含多个线程,而且必须至少有一个主线程。多线程主要是用来完成一个进程中的多个子任务。
在资源分配上,多个进程间的资源是相互独立的,每个进程都需要分配独立的地址空间和资源,也就是在进程切换时开销会比较大。而多个线程共享同一进程中的大部分资源,只是各自有独立的栈、寄存器等线程上下文,所以线程间切换的开销会比较小。
也因此在安全性上,进程之间是相互隔离的,一个进程崩溃一般不会直接影响其他进程的稳定性;而线程因为共享同一个进程中的地址空间,所以一个线程如果发生严重错误,可能会导致整个程序和其他线程都一起崩溃。
025. 并行和并发有什么区别
并行是指在某一时刻同时运行多个任务,而并发是指在一段时间内交替执行多个任务。从感觉上来说它们像是在同时进行,但并发也可以通过调度实现多个任务的交替运行。
举个例子,比如一个具有 12 个逻辑处理器的 CPU,那么从软件线程的角度看,它在同一时刻能够真正并行执行的线程数量是有限的。而实际上系统中可能有成千上万个线程在竞争这些执行资源,是通过 CPU 调度,极快地切换,来实现在一段时间内这些线程交替运行,让人感觉它们在同时运行。
所以说,并行实际是在微观、物理意义上的同一时刻运行多个任务,而并发是在宏观上多个任务在某一时间段内共同推进,可能是交替运行,也可能在多核 CPU 上同时并行。
026. 解释一下用户态和内核态
用户态和内核态是 CPU 运行的两种权限级别,用来控制程序访问资源的权限。用户态无法直接访问系统硬件,也只能访问自己被允许使用的内存;而内核态可以直接管理硬件资源,并且可以执行特权指令。二者隔离是为了保障系统的安全和稳定性。
具体来说,用户态和内核态的切换场景主要有三点。第一点是系统调用,主要是程序申请内核服务,比如读写文件、网络通信或者申请内存。
第二种是发生异常,比如程序运行出错、非法访问或者缺页,需要内核态介入处理这些问题。
第三种是外设中断,就是外部硬件,比如硬盘、键盘、鼠标、网卡等发送中断信号,使 CPU 切换到内核态处理。比如鼠标的移动,设备会按照一定的回报率向系统报告位置信息,再由内核进行处理。
027. 进程调度算法你了解多少
进程调度算法,首先几个基础的。第一种是先来先服务,但是它会在有长作业进入的情况下导致后面的任务长时间阻塞,使平均等待时间增加。
第二种是短作业优先,但是它会导致长任务的饥饿问题,也就是短任务持续加入时,长任务可能一直得不到处理。
第三种是前两者的结合,叫高响应比优先。它引入响应比这个概念,可以解决长任务的饥饿问题。在等待一定时间后,任务的响应比会提高,让等待很久的任务得到更高优先级。
另外就是实际比较常用的调度算法。第一种是时间片轮转,通过分配时间片的方式,让每个任务轮流运行。但是时间片的大小设置比较关键,设置太大会导致响应变慢,设置太小会导致频繁的上下文切换,系统开销比较大。
第二种是优先级调度,也就是在进程创建或者运行过程中设置优先级,根据优先级高低来决定调度顺序。还可以增加老化机制,使进程长时间得不到处理时逐渐提高优先级。
第三种是多级反馈队列。它通过设置多个优先级不同的队列,并且相应调整时间片长短,让 CPU 优先处理高优先级任务;长作业如果持续占用时间片,优先级会逐渐降低,从而兼顾短任务和长任务。
进程调度算法的评价指标主要有这几个:一是 CPU 利用率,二是单位时间的吞吐量,三是进程的周转时间和等待时间,四是每个进程的首次响应时间。
028. 进程间有哪些通信方式
进程间有几种通信方式。首先最基础的是管道,它分为用于父子等有亲缘关系进程的匿名管道,和支持无亲缘关系进程的命名管道。它们本质上是内核管理的字节流缓冲区,单个管道一般是单向通信。最常见的就是 Linux Shell 命令中的竖杠。
第二种是消息队列,解决了管道传输的数据没有消息边界的问题。它引入了结构化的消息,可以支持按照消息类型进行传输和读取。
第三种是共享内存,可以让多个进程映射到同一块物理内存上。但是它需要引入其他同步方式,比如 P/V 操作、信号量或者互斥量,来控制共享内存的读写,实现同步和互斥。
然后还有信号,信号主要用于系统事件通知。最后一种是套接字 Socket,主要用来实现客户端与服务端之间的网络通信,也可以用于同一台主机上的进程通信。
029. 解释进程同步和互斥,以及如何实现
进程同步和互斥是在并发编程中解决多进程协作和竞争的核心机制。
进程互斥针对的是进程对资源的竞争关系。当多个进程访问同一个临界资源时,要保证同一时刻只能有一个进程进入临界区。
然后进程同步更多指的是进程间的协作关系,也就是为了完成同一个任务,根据条件和顺序实现多个进程的先后执行。
举个最经典的生产者—消费者问题。生产者和消费者对于缓冲区这个任务队列的访问是互斥的;而消费者需要等待生产者产出数据以后才能读取,这一步就是同步关系。
具体实现同步和互斥的方式,第一种最常见的就是信号量,也就是 P/V 操作。实现互斥的时候,信号量一般初始设为 1;表达一个还没有发生的同步条件时,相关信号量一般初始设为 0。
另一种是管程以及条件变量。对管程我不是特别熟悉,简单来说就是把共享数据、操作和条件变量封装在一起,类似封装在一个类中,然后统一管理同步和互斥。
030. 什么是死锁,如何避免死锁
死锁是指多个进程或者线程在并发情况下,因为竞争资源并且互相等待,导致资源无法释放,形成一种永久阻塞的状态。
产生死锁必须同时满足四种条件。第一种是互斥条件,也就是一个资源在同一时刻只能被一个进程占有,其他进程如果请求就必须等待。
第二种是不可剥夺条件,也就是一个进程占有某个资源的时候,不能被其他进程强行剥夺,必须由该进程自己释放资源。
第三种是请求并保持条件,也就是一个进程在持有一个资源的情况下继续请求其他资源,而且请求发生阻塞时,它自己已经占有的资源不会释放。
第四种是循环等待条件,也就是一系列进程之间形成一个循环等待链条,每个进程都在等待下一个进程释放资源。
产生死锁必须同时满足这四种条件,所以要预防死锁,只要破坏其中任意一个条件就可以。
整体处理方式可以分为几个阶段。第一个是在死锁发生前,通过破坏四个必要条件来预防死锁。第二个是在运行过程中使用算法避免死锁,比如在分配资源时进行检测,最经典的就是银行家算法。第三个是死锁发生后进行检测和解除,比如终止某个进程、回滚进程,或者剥夺一部分资源来解除死锁。
031. 介绍一下几种典型的锁
典型的锁,最基础的就是互斥锁,保证多线程情况下同一时间只有一个线程可以获得锁。其他线程如果尝试获取这把锁,就会被阻塞,直到持有锁的线程释放。
第二种就是自旋锁,通过持续检查锁的状态,直到锁被释放以后立即尝试获得。在 C++ 中可以用一个循环不断检查原子变量,标准库里可以使用 atomic_flag 这类原子类型实现。但是自旋时间太长会一直占用 CPU,所以更适合锁持有时间很短的场景。
第三种是读写锁。读锁可以让多个线程同时读取,但是写操作在同一时间只能由一个线程进行。C++ 中可以使用 shared_mutex 和 shared_lock 来实现共享读,再使用独占锁进行写。
然后还有递归锁,通过一个计数器让同一线程可以多次对同一资源加锁,而不会因为再次加锁直接造成死锁。只有相同次数的解锁完成后,锁才真正释放。
还有两种锁的概念,就是悲观锁和乐观锁。悲观锁是在每次获取资源前都直接加锁;乐观锁一般是先尝试修改资源,如果多线程之间发生冲突,就回退或者重新尝试。一般可以用版本号或者原子 CAS 操作来实现乐观控制。
032. 线程同步的方式有哪些
线程同步方式用 C++ 举例的话,第一种就是互斥锁 mutex,最基础的用法还可以配合 lock_guard 和 unique_lock 这种 RAII 封装。
第二种是 condition_variable 条件变量,通过 notify_one、notify_all,让一个线程达到条件之后通知其他线程。最常见的就是生产者—消费者模型。
第三种是原子操作,通过 CPU 提供的原子指令和 atomic 原子变量,在不使用普通锁的情况下保证单次操作的原子性。
第四种是使用 future 和 promise,异步地获取和传递任务结果。第五种是信号量,是 C++20 引入的,简单来说就是使用一个计数器控制可以同时访问共享资源的线程数量。
033. 有哪些页面置换算法
页面置换算法,首先最理想的是最佳页面置换算法 OPT。它根据未来的内存访问情况,选择未来最长时间不会被使用的页面进行置换。这个算法实际无法实现,但是可以作为其他算法的评判标准。
第二种是先进先出 FIFO,也就是最先进入内存的页面最先被置换出去。
第三种 LRU,就是最近最久未使用,根据内存访问历史,把最长时间没有使用的页面进行置换。
第四种 LFU,就是最不经常使用。它使用计数器记录每个页面的访问频率,根据访问频率来淘汰,频率越低的越先被置换。
最后一种是时钟算法。最基础的做法就是给页面设置一个访问位。指针检查到访问位为 1,就把它置为 0,然后继续往后检查;如果检查到已经是 0,就把这个页面置换出去,相当于给页面第二次机会。
034. 熟悉哪些 Linux 命令
Linux 命令的话,首先是文件操作。ls 是展示当前目录内容,cd 是进入目录,mkdir 是创建文件夹,touch 是创建新文件,rm 是删除文件,mv 是移动或者重命名文件,pwd 是显示当前目录。
然后文件编辑是 vim,查看文件有 cat、head、tail、less、more 这些方式。
网络方面有 ping,然后 Linux 下可以用 ip addr 查看网络接口和地址,curl 用来发送请求。其他还有 sudo,用来以管理员等其他用户权限执行命令;包管理的话,Ubuntu、Debian 这类系统可以使用 apt 或者 apt-get。常用的差不多就这些。
066. 讲一讲你理解的虚拟内存
虚拟内存总的来说,就是给每个进程分配一块看起来连续的虚拟地址空间,然后通过操作系统映射到实际的物理地址空间上,并且建立内存和外存之间的联合存储结构。
首先先讲一下,如果不使用虚拟内存,直接分配物理内存会出现什么问题。第一点,进程需要把所需内存直接分配到物理内存上,不仅占用空间比较大,而且释放内存时可能导致碎片比较严重。
还有一点,各个进程直接使用物理地址时,内存隔离和保护会很困难,可能互相访问和修改数据,影响系统稳定性。所以需要引入虚拟内存来解决这些问题。
虚拟内存引入了分页机制,把虚拟内存中的页映射到物理内存中的页框,常见的页大小是 4 KB。这样就实现了内存的离散分配,不需要整块连续物理内存。每个进程也不需要一开始就把全部内容加载到内存,只需要动态加载当前所需的页面,所以提高了加载效率,也让可使用的虚拟地址空间不完全受当前物理内存大小限制。
第二点,通过操作系统的页表把虚拟地址映射到实际物理地址,各个进程不知道其他进程的映射关系,所以实现了内存隔离和安全性。
另外,这种分页机制还可以让公共库只在物理内存中保留一份,再把同一块物理内存映射到多个进程各自的虚拟地址空间中,实现多个进程共享公共库。
因为内存是动态加载的,当访问的页面不在物理内存中时,就会发生缺页异常,把外存中的内容加载到内存。如果内存满了,会通过页面置换算法,把暂时不用的页面换出或者丢弃。
总的来说,虚拟内存既提高了内存的安全性,又提高了内存使用效率,而且让内存管理更加灵活。
三、C++
035. 静态变量和全局变量、局部变量的区别,在内存上是怎么分布的
首先在内存分布上,主要可以分为几大区域:全局区,也就是静态存储区;栈区;堆区;代码区等等。
这三种变量,首先说全局变量。全局变量存储在静态存储区中,它的生命周期贯穿整个程序。普通的全局变量可以在其他 CPP 文件中通过 extern 声明以后共享使用。
然后局部变量一般开辟在栈空间,存储在栈区中。它在局部代码作用域中被创建,也只能在这个区域内使用,在代码块结束后会自动销毁。
静态变量又可以分为全局静态变量和局部静态变量。全局静态变量相比普通全局变量,static 关键字使其他 CPP 文件无法通过 extern 访问,也就是它只能被当前编译单元使用。
然后局部静态变量存储在全局区,也就是静态存储区中。它是在局部代码块中定义的,但是生命周期贯穿整个程序。也就是说这段代码块结束以后,它不会自动销毁,再次调用时它仍然存在,之前保存的值也还在。
036. 指针和引用的区别
首先指针和引用在本质上来说,指针作为一个变量,保存的是另一个对象的内存地址;而引用只是把一个对象作为别名来使用。
它们的具体区别,第一点,指针在定义时可以不初始化为有效地址,而引用必须初始化。第二点,指针可以改变为指向其他对象,而引用一旦绑定以后不可以再绑定别的对象。第三点,指针可以设置为 nullptr,作为空指针使用,而引用正常情况下不能为空。第四点,指针可以多级嵌套使用,而普通引用没有多级引用这种用法。
第五点,指针变量本身需要存储地址,所以占有内存空间;引用在语言逻辑上只是别名,不过编译器底层也可能需要用地址来实现,所以不能绝对地说引用一定不占空间。
037. C++ 内存分区
C++ 的内存分区主要可以分为几个区域吧,分别是栈区、堆区、全局静态存储区、只读常量区和代码区。
首先第一点,栈区主要存储局部变量、函数参数以及函数调用现场这类内容。然后堆区主要是在 malloc 或者 new 的时候分配动态内存,再使用 free 或者 delete 释放。
第三个是全局静态存储区,存放全局变量和静态变量。其中 data 段主要存储已经初始化的数据,BSS 段主要存储未显式初始化或者零初始化的数据。
第四个是只读常量区,通常存储字符串字面量、部分只读常量等。第五个代码区存储的是函数编译后的二进制指令。具体的分段方式会根据编译器和操作系统有所不同。
038. static 关键字和 const 关键字的作用
static 关键字主要用来影响变量的生命周期、作用域或者链接属性,而 const 关键字用来表明不能通过当前接口修改数据。
具体来说,static 关键字用在局部静态变量中,会使这个变量在代码作用域结束后不被销毁,而是保留到下一次函数调用。然后全局静态变量表示这个变量只在当前文件或者编译单元中使用,其他文件无法通过 extern 直接访问。
在类中的话,用 static 修饰的变量或者函数属于这个类,而不属于某个具体对象。所以调用静态成员函数或者访问静态成员变量时,不需要先实例化对象,可以直接通过类来访问。
然后 const 关键字是表明数据不能通过当前对象或者指针被修改。const 变量的值不能直接修改,const 成员函数表示这个成员函数不会修改普通成员变量。
static 和 const 可以同时使用。另外,C++11 以后局部静态变量的初始化是线程安全的,但初始化以后对这个变量进行并发读写,并不会自动线程安全。
039. 常量指针和指针常量之间有什么区别
从写法上来说,常量指针和指针常量主要看 const 关键字是在星号的左边还是右边。
const 如果在星号左边,比如 const int* p,表示的是常量指针。也就是说 const 修饰的是指针实际指向的数据,这个指针可以改变它指向的地址,但是不能通过这个指针修改所指向的数据。
const 如果在星号右边,比如 int* const p,表示的是指针常量。也就是说 const 修饰的是指针本身,这个指针不能再指向新的地址,但是它指向地址中的数据可以修改。
040. 结构体和类之间有什么区别
结构体和类也就是 struct 和 class。在 C 语言中,struct 主要只能包含成员数据,不能像 C++ 类一样直接包含成员函数。而在 C++ 中,这方面基本没有区别,struct 和 class 都可以包含成员变量和成员函数,也都可以继承。
它们的主要区别是,class 的成员默认是 private,而 struct 的成员默认是 public。然后在继承上,class 默认也是 private 继承,而 struct 默认是 public 继承。
从实践来说,struct 一般用来定义一些比较简单、以数据为主的结构,而 class 用来定义一个比较复杂、需要封装的类。然后 class 还可以作为模板参数的关键字,不过一般也可以用 typename。
041. 什么是智能指针,C++ 有哪几种智能指针
智能指针是对普通指针的一个封装,利用 RAII 机制让指针对象的生命周期和内存资源的生命周期进行绑定,使智能指针对象在被销毁时自动按照所有权规则释放内存,省去人为使用 new、delete 等内存操作,减少内存泄漏或者悬空指针这类问题。
具体来说,C++ 有三种常用智能指针。第一种是 unique_ptr,它保证同一时间只有一个 unique_ptr 拥有这个对象,所以这个智能指针无法被复制,但是可以移动。
第二种是 shared_ptr,它可以使多个智能指针在同一时间共同拥有一个对象。它们共同维护一个引用计数,销毁 shared_ptr 时引用计数减一,直到强引用计数为零时,才释放对应对象。
第三种是 weak_ptr,它主要用来解决多个 shared_ptr 循环引用的问题。循环引用会导致强引用计数无法归零,内存无法释放,从而造成内存泄漏。weak_ptr 不增加强引用计数,只用来观察对象是否还存在。
042. 智能指针的实现原理是什么
智能指针的实现原理,总的来说是定义一个模板类,在类的析构函数中写上对应的资源释放操作,使智能指针对象超出作用域以后自动调用析构函数释放内存。然后 unique_ptr 和 shared_ptr 还重载了解引用星号和箭头访问运算符,让它们使用起来和普通指针比较接近。
具体来说,第一个 unique_ptr 显式删除了拷贝构造函数和拷贝赋值运算符,把它们设置为 delete,从而实现对对象的独占;它通过移动构造或者移动赋值来转移所有权。
第二个 shared_ptr 和 weak_ptr 可以一起说。它们会关联一个控制块,控制块中维护强引用计数和弱引用计数等信息。shared_ptr 创建或者复制时,强引用计数会加一;销毁时会减一;强引用计数归零时,才会释放对应对象。
weak_ptr 维护的是弱引用关系,因为它不拥有这个对象,所以不能直接通过解引用或者箭头运算符访问。它主要用来观察 shared_ptr 管理的对象是否还有效,需要访问时可以通过 lock 尝试获取一个 shared_ptr。
043. new 和 malloc 有什么区别
new 和 malloc 的区别,首先 new 是 C++ 中的关键字和运算操作,而 malloc 是 C 语言标准库中的一个函数。
具体实践上来讲,第一点,new 在分配内存以后会调用类的构造函数来初始化对象,并且对应的 delete 会先调用析构函数,再释放内存。而 malloc 只涉及原始内存的分配,不会初始化对象,也不会调用构造函数。
第二点,在返回类型上,new 会返回具体对象类型的指针,而 malloc 返回的是 void 指针,在 C++ 中需要进行显式的强制类型转换。
第三点,在分配内存失败时,普通 new 会抛出异常,而 malloc 只会返回空指针。
第四点,new 分配内存时会根据类型自动计算所需的内存大小,而 malloc 需要显式指定具体的字节数。
第五点,new 底层使用的 operator new 支持重载,而 malloc 不支持这种运算符重载。
044. delete 和 free 有什么区别
与 new 和 malloc 的区别同理。delete 是 C++ 中的关键字,用来对应 new;free 是 C 标准库中的函数,用来对应 malloc。两者不能混用。
具体来讲,delete 会先调用析构函数,再对内存进行释放;而 free 是直接释放原始内存,不涉及任何对象析构操作。
然后在数组处理上,由于 new[] 是对数组进行内存分配,所以释放时也需要调用 delete[];而 malloc 分配的数组还是直接使用 free。
045. 堆区和栈区的区别
首先堆上的内存需要手动使用 new、delete,或者 malloc、free 来进行分配和释放;而栈上的内存主要存储局部变量、函数参数等,由编译器和运行时自动分配和释放,在超出代码块作用域以后自动回收。
第二点,堆的可用空间一般比较大,主要受虚拟地址空间、物理内存和系统限制影响;而每个线程的栈空间比较小,具体大小由操作系统和程序配置决定。
第三点,线程的栈通常是一块连续的虚拟地址空间,而堆上的内存分配比较零散,频繁分配和释放还可能产生内存碎片。
第四点,栈上分配内存的速度很快,而堆上分配内存需要查找和管理合适的内存块,所以相对速度会慢一点。主要就是这几点吧。
046. 什么是内存泄漏,如何检测和防止
内存泄漏指的是分配了一块内存以后,已经不再使用对应资源,却长时间没有释放相应内存,导致内存一直被占用,造成资源浪费。
具体来说,内存泄漏主要是在 new 或者 malloc 分配内存以后,没有及时 delete 或者 free。比如指针丢失,超出作用域以后无法再释放;或者 shared_ptr 发生循环引用,导致引用计数无法归零;还有通过基类指针删除派生类对象时,没有把基类析构函数设为虚析构函数。这些情况本质上都是资源没有被正确释放。
对于内存泄漏的检测,主要可以使用 Valgrind 这种内存检测工具,还有 AddressSanitizer、Visual Studio 的内存诊断工具这一类。
如果要防止内存泄漏,主要是利用 RAII 机制,比如使用智能指针或者标准库容器。自己使用 new 分配内存以后,也要保证及时使用 delete 释放,并且明确谁负责拥有和释放资源。
047. 什么是野指针?如何避免
野指针和悬空指针这两个类型比较类似,本质上都是指针没有指向一个当前合法、有效的对象,访问以后会导致未定义行为。
稍微区分一下,野指针一般是指没有初始化、地址不确定的指针;悬空指针一般是原来指向合法对象,但是对应内存已经被释放,或者局部对象已经超出作用域,指针还保存着原来的地址。
如果要避免这种情况,可以在定义裸指针时初始化,不使用时设为 nullptr;delete 以后不要继续访问旧指针,并且可以把当前这个指针重新设为 nullptr;尽量使用智能指针和 RAII 管理生命周期。
另外,在局部变量中要避免把指向局部对象的指针保存到对象生命周期以外,否则局部对象超出作用域被销毁以后,这个指针就变成悬空指针了。
048. C++ 面向对象三大特性
C++ 面向对象的三大特性分别是封装、继承和多态。
首先第一个封装,指的是将数据和函数组合在一起,通过 private、public、protected 这三个关键字对访问权限进行限制,使得对外只暴露必要接口,而隐藏内部具体的实现细节,提高安全性。
第二个继承,指的是子类继承父类中允许访问的成员,使子类可以复用和扩展父类的功能,而不需要重复编写相同代码,提高代码复用性。
第三个多态,指的是同一个接口但是有不同的实现。它分为静态多态和动态多态。
静态多态是在编译期就确定具体的函数调用。具体来说,可以通过函数重载来实现,也就是同一个函数名拥有不同的参数类型或者参数数量;另外模板泛型编程和运算符重载也属于静态多态。
动态多态是在运行时确定具体调用。父类使用 virtual 关键字定义虚函数,子类用 override 重写父类方法,然后通过基类指针或者引用调用,底层一般使用虚函数表和虚函数指针机制来实现。
049. 简述 C++ 的重载和重写,以及它们的区别和实现方式
前面其实已经说过,重载就是同一个作用域中,多个函数拥有相同的函数名,但是参数列表不同,包括参数类型、参数数量或者顺序不同。它是在编译期决定调用哪个函数,属于静态多态。返回类型可以不同,但是不能只通过返回类型不同来实现重载。
重写必须发生在父类和子类的继承关系中。父类先定义虚函数,子类使用相同的函数签名重新实现它,调用时在运行期根据对象实际类型决定使用哪个版本,所以属于动态多态。
细节上,重载必须在同一作用域内,而重写必须发生在父类和子类上下关系中。重写的返回类型一般相同,也允许符合规则的协变返回类型。
050. C++ 怎么实现多态
除了之前提到的静态多态,还有一点是运算符重载也属于静态多态。然后关于动态多态的底层实现,还需要补充一下虚函数表和虚函数指针。
一般来说,每个拥有虚函数的类都会有一个虚函数表,然后在实例化对象的时候,对象中会保存一个虚函数指针。具体实现逻辑是在构造对象时先调用基类构造函数,这个阶段虚函数指针指向基类的虚函数表;然后再调用子类构造函数,虚函数指针再更新到子类的虚函数表。
虚函数指针会根据具体虚函数在表中的位置,找到需要调用的函数地址。子类会沿用基类虚函数表中的对应位置,如果子类重写了基类方法,就会把虚函数表中对应位置的函数地址替换为子类重写后的函数地址。
这样通过同一个基类指针或者引用调用时,在相同位置找到的就是实际对象类型对应的函数,实现运行时多态。不过虚函数表和虚函数指针是主流编译器的典型实现,C++ 标准本身只规定多态行为。
051. 虚函数和纯虚函数的区别
先讲一下纯虚函数。纯虚函数就是在虚函数声明后面直接写一个 = 0,它就成为纯虚函数。拥有纯虚函数的类会变成抽象类,抽象类不能被实例化。
它定义的纯虚函数主要相当于声明一个接口,继承抽象类的子类如果想被实例化,一般就需要具体实现这个纯虚函数,否则子类也会继续是抽象类。
那么虚函数和纯虚函数的区别就是,普通虚函数可以在基类中提供默认实现,子类可以不重写;纯虚函数主要是要求具体子类提供实现。从类本身来说,只有普通虚函数的类可以实例化,但是包含纯虚函数的抽象类不可以直接实例化。
052. 虚函数是怎么实现的
前面具体说过,虚函数一般是通过虚函数表和虚函数指针来实现的。
每个拥有虚函数的类都会被编译器创建一张虚函数表,用来存储虚函数的地址。对象中会有一个虚函数指针,指向当前实际类型的虚函数表。
通过基类指针或者引用调用虚函数时,会先通过对象中的虚函数指针找到虚函数表,再根据对应位置找到具体的函数地址。如果子类重写了这个函数,表中对应位置保存的就是子类函数地址,所以最后会调用子类实现。
053. 虚函数表是什么
虚函数表就是包含虚函数的类通常都会被编译器创建的一张表,用来存储虚函数的地址。
同一个类的对象一般共享同一张虚函数表,而对象中保存虚函数指针来指向它。子类如果没有重写某个虚函数,就继续使用基类对应位置的函数地址;如果重写了,就把对应位置替换为子类的函数地址。
054. 什么是构造函数和析构函数?构造函数、析构函数可以是虚函数吗
构造函数是在创建对象时自动调用的一个成员函数,它的作用就是初始化对象和它所需要的资源。析构函数是在销毁对象,也就是对象生命周期结束时自动调用的成员函数,主要用来释放对象持有的资源。
如果不去定义这两个函数,编译器会在符合规则的情况下自动生成比较基础的构造函数和析构函数。
构造函数不可以是虚函数。因为构造函数本身就是用来建立一个确定类型的对象,在对象构造完成前还没有最终派生类型的动态分派语义,所以调用哪个构造函数必须提前确定。
析构函数可以是虚函数。一般在有继承和多态删除的情况下,需要把基类析构函数设置为虚函数。这样通过基类指针删除派生类对象时,才能先正确调用子类析构函数,再调用父类析构函数;否则派生类资源可能无法被正确释放。
055. C++ 构造函数有几种,分别什么作用
构造函数的话主要有几种。第一种是默认构造函数,也就是不需要传参数的构造函数。如果没有人为定义其他会影响它生成的构造函数,编译器可以默认生成一个。
然后是带参数的构造函数,主要是在初始化对象时,根据传入参数初始化成员变量。
然后是拷贝构造函数,也就是以同类对象为参数的构造函数,主要用来复制对象。
然后是移动构造函数,使用同类对象的右值引用作为参数,把这个对象的资源移动到新对象中。一般会对临时对象或者即将不再使用的对象使用,节省拷贝构造的性能开销。
还有转换构造函数,主要用来允许其他类型转换为当前类;如果不希望发生隐式转换,可以使用 explicit。最后还有委托构造函数,就是一个构造函数调用同一个类中的另一个构造函数来完成公共初始化,提高代码复用,减少重复编写代码。主要就是这几个吧。
056. STL 容器了解哪些
STL 容器可以分为两大类。第一大类是序列式容器。
首先第一个 vector,是动态数组,可以根据下标进行随机访问,适用于随机访问和尾部插入的情况。第二种 array,是对原生数组的标准化封装,是静态数组,无法像 vector 一样扩容。
第三种 deque,是双端队列,主要支持在两端频繁插入和删除。第四种 list,是双向链表,可以支持在已知位置的情况下频繁插入和删除。第五种 forward_list,是单向链表,比 list 的双向链表少维护一个方向的指针,可以节省一部分内存。
然后还有三个容器适配器。stack 用于后进先出,默认一般基于 deque;queue 用于先进先出,默认也一般基于 deque;priority_queue 是优先队列,通常基于 vector 和堆来实现,可以优先取出优先级最高的元素。
第二大类就是关联式容器,也可以分为两类。第一类是有序关联式容器,通常基于红黑树,分别是 set、multiset、map 和 multimap。set 存储的是键,map 存储的是键值对,multi 代表允许重复的键。因为基于红黑树,它们查找、插入和删除一般都是 O(log n)。
然后是无序关联式容器,分别是 unordered_set、unordered_multiset、unordered_map 和 unordered_multimap,通常基于哈希表。它们平均查找时间复杂度是 O(1),如果哈希冲突很严重,最坏也可能退化到 O(n)。
057. 深拷贝与浅拷贝的区别
从本质上来说的话,浅拷贝的资源是共用的,而深拷贝的资源是相互独立的。
也就是说,在浅拷贝对象的时候,它只能复制这个对象中的成员。比如对象里有一个指针,它只会复制这个指针保存的内存地址,而不会复制指针具体指向的内存空间。
而深拷贝会把这块内存中的数据重新复制一份,给新对象分配独立的堆内存。这样新对象和原本对象的资源就是相互独立的。简单来说,浅拷贝没有创建新的底层资源,深拷贝创建了一份新的资源。
058. vector 和 list 的区别
vector 和 list 的区别,首先 vector 是动态数组,而 list 是双向链表,所以它们在底层原理上就有很大区别。
vector 作为数组,在内存中的元素是连续的,所以可以通过下标随机访问任意数据,访问时间复杂度是 O(1)。而 list 是链表,节点在内存中是离散的,访问里面的数据需要沿着链表查找,时间复杂度是 O(n)。
然后 vector 在中间插入、删除时,需要移动后面的元素,所以复杂度是 O(n);在尾部插入并且不涉及扩容时是 O(1),平均下来是摊销 O(1)。
list 因为基于链表实现,在已经知道节点位置的情况下,插入和删除的时间复杂度是 O(1);但是如果还需要先查找位置,查找本身还是 O(n)。
059. vector 底层原理和扩容过程
vector 的底层原理,它是一个动态数组,所以创建 vector 的时候会在堆内存上申请一块连续的内存空间,这块空间大小就是它的 capacity,然后可以根据下标随机访问 vector 中的元素。
典型实现中,它包含三个指针,分别是首指针,指向内存首部;当前尾指针,指向最后一个元素的下一个位置;还有一个指针指向整块已分配内存的末尾。前两个位置对应 begin 和 end,最后一个位置可以计算 capacity,当前元素数量就是 size。不过具体是不是保存三个指针属于编译器实现细节。
然后扩容过程,实际是在 size 等于 capacity,而且还要继续加入元素的情况下,申请一块更大的内存。新的内存大小一般是当前容量的 1.5 倍或者 2 倍,根据编译器实现有所不同。
然后会使用拷贝构造函数或者移动构造函数,把元素搬到新申请的内存空间中。满足条件时会优先使用移动构造,否则也可能使用拷贝构造。接着销毁原本位置的元素,释放旧内存。
这会使原本的迭代器失效,因为它内部的地址已经更新到新的内存空间。然后 reserve 函数是用来预留 capacity,resize 函数改变的是当前 size,必要时也可能触发扩容。基本上就是这样。
060. push_back() 和 emplace_back() 的区别
push_back 和 emplace_back 的区别,主要体现在传入的是构造参数、对象还没有提前构造完成时。
push_back 一般会先构造一个临时对象,然后使用拷贝构造或者移动构造,把它放入容器对应的内存空间中,再销毁原本创建的临时对象。
而 emplace_back 可以省去这个临时对象以及额外拷贝或者移动的开销,直接在容器尾部对应的内存空间中原地构造对象,所以有一定性能优势。
但是如果传入的是一个已经构造好的对象,它们都还是要进行拷贝或者移动,这个时候基本等价。
另外我遇到过的一个区别是,push_back 后面可以直接使用大括号列表初始化形成一个元素,而 emplace_back 直接传一个大括号时,可能因为模板参数无法推导而编译失败。还有和 explicit 构造函数有关,push_back 如果依赖隐式转换,会受到 explicit 限制;emplace_back 是直接原地构造,可以直接调用 explicit 构造函数。
061. map、deque、list 的实现原理
map 是关联式容器,而且是有序的,并且键是唯一的。它通常基于红黑树这种自平衡二叉搜索树,根据键进行排序,使它在查找、插入和删除上的时间复杂度是 O(log n)。
然后 deque 是双端队列,是一种序列式容器。它一般是由多块连续的小数组,再加上一层索引这些内存块的结构实现的,所以可以在两端高效插入和删除。在两端操作一般是 O(1),在中间插入删除一般是 O(n)。
list 是双向链表,每个节点维护一个向前和一个向后的指针。已知位置时,插入和删除的时间复杂度是 O(1);查找的时间复杂度是 O(n)。它适用于频繁插入删除,而查找比较少的场景。
062. map 和 unordered_map 的区别和实现机制
首先 map 是有序的,而 unordered_map 是无序的。因为 map 通常基于红黑树这种自平衡二叉搜索树,所以它会根据键来进行排序。
unordered_map 的实现原理是基于哈希表,根据哈希函数把键映射到合适的桶,再处理可能发生的哈希冲突。
然后时间复杂度上的区别,map 因为基于红黑树,查找、插入、删除一般是 O(log n);unordered_map 基于哈希表,平均是 O(1),最坏情况下可能因为哈希冲突退化成 O(n)。
063. C++11 新特性有哪些
C++11 的新特性,首先先说关键字。
第一个 auto 关键字,用来自动推导变量类型。第二个 decltype 关键字,用来获取表达式类型。第三个 using 关键字,用来替代 typedef 定义类型的别名。
第四个 nullptr,用来替代原本的 NULL 来避免作为常量0的这种隐式转换风险。第五个 delete 和 default 关键字,可以显式地定义特殊成员函数,使用编译器默认实现或者禁用。第六个 constexpr 关键字用来将表达式提前到编译期计算提高这个运行时的效率。
然后语法第一个是 Lambda 表达式,作为匿名函数,更简单地编写临时函数。智能指针方面引入 memory 库,提供 shared_ptr、unique_ptr 和 weak_ptr,基于 RAII 原则管理内存,避免内存泄漏。
然后是范围 for 循环,用冒号更简便地遍历容器。还提供了强类型枚举 enum class,避免原本枚举的命名冲突和隐式转换。
并发方面提供了 thread 库进行多线程编程,和 atomic 库实现原子操作,提供对无锁编程的支持,还有 mutex、condition_variable、future 等内容。chrono 库用来提供时间相关操作。
然后还引入了右值引用和移动语义来减少拷贝开销。还有列表初始化,也就是大括号,统一初始化写法的同时禁用了一部分窄化转换。
064. 移动语义有什么作用,原理是什么
移动语义的本质作用就是避免不必要的深拷贝,省去拷贝这一部分的开销。
原理上具体来说,就是通过 std::move 把对象转换成可以匹配右值引用的形式,然后调用移动构造函数或者移动赋值运算符,把它所拥有的资源转移到新对象中。
不过 std::move 本身并不会真的移动资源,它只是做类型转换。真正的资源转移还是由移动构造函数或者移动赋值运算符完成。被移动的对象仍然要保持可以析构、可以重新赋值,只是它原来的具体值一般不再确定。
065. 左值引用和右值引用的区别
左值引用使用一个 &,右值引用使用两个 &&。
简单来说,我觉得其实没有那么复杂。本质上讲,左值引用主要就是给一个已经存在的对象取别名,通常绑定的是有明确身份、可以继续访问的对象。
而右值引用一般用于临时对象,或者即将不再使用的对象上。之后常会使用移动赋值运算符或者移动构造函数,把这个对象的资源转移到新的对象中。基本上就这样,我认为主要就是这些。
067. 说一下 Lambda 函数
Lambda 表达式就是一个匿名函数,主要是作为临时函数使用,可以简化函数的用法,省去单独定义普通函数。
它主要包括捕获列表、参数列表和函数体。捕获列表可以按值或者按引用使用外部变量,编译器底层会生成一个闭包对象来保存这些捕获内容。
然后在 C++14 里支持使用 auto 参数来实现泛型 Lambda;C++17 里增强了 constexpr Lambda,在符合条件时可以在编译期计算;C++20 里支持显式模板参数列表。总的来说,它虽然是匿名函数,但是现在也支持泛型编程。
068. C++ 如何实现一个单例模式
C++ 中实现一个单例模式,也就是要确保全局只有唯一实例。
首先第一点,必须把这个类的构造函数设置为 private,防止外部直接创建多个实例。第二点,要暴露一个 public 的 static 接口,让外部可以获取这个实例。第三点,要把这个类的拷贝构造函数和拷贝赋值运算符禁用,设置为 delete,防止通过拷贝方式创建多个实例。
单例模式具体来说还分为懒汉式和饿汉式。饿汉式就是在程序加载阶段直接创建实例,它本身初始化比较直接,但是实例会提前创建。
懒汉式就是把创建实例的操作放到公共获取接口中,只有第一次调用接口时才进行创建,所以是延迟创建的。这样在多线程情况下,就必须保证初始化线程安全。
实现懒汉式线程安全的方法,我了解的有三种吧。第一种是 C++11 以后,直接在公共接口里定义局部静态变量,它的初始化天然是线程安全的,也是最简单的实现。
第二种是双重检查锁,主要通过 mutex 配合 atomic 来实现,但是稍微复杂一点,具体我自己没有使用过,也比较容易写错。
第三种是 once_flag 配合 call_once,保证初始化逻辑只成功执行一次。我一般使用第一种局部静态变量,或者第三种 once_flag 配合 call_once。
069. 什么是菱形继承
菱形继承是指多个派生类继承于同一个基类,然后一个最派生类又同时继承这几个派生类,导致这几个派生类中都各有一份基类的子对象。
不仅造成了数据的冗余,而且使编译器无法确定最派生类应该调用哪一份基类的数据,会产生访问二义性。
为了解决这个问题,需要使用虚继承,靠指针和虚表,使每个派生类对象不再拷贝这个基类的对象而是只保存了指向虚表的指针,通过表中保存的偏移量,在最派生类调用的时候直接去找到虚表中唯一的一份基类对象。
070. C++ 中的多线程同步机制
C++ 中的多线程同步机制,首先最主要的几个。
第一个就是 mutex 互斥量,具体还有读写锁、递归锁,以及 lock_guard、unique_lock 这种 RAII 封装。
第二种就是 condition_variable 条件变量,用来实现多线程之间的通信和同步。
第三种就是 atomic 原子操作和原子变量,这种原子操作是不可分割的,可以用来保护简单的共享状态。
除了这三个主要的,其他还有 future、promise 这种异步结果传递;call_once、once_flag 这种确保初始化只执行一次的操作;还有 C++20 引入的 semaphore 信号量。差不多就是这些吧。
071. 如何在 C++ 中创建和管理线程
就是通过 std::thread,或者 C++20 的 std::jthread 来创建线程对象,把需要在线程中执行的函数和参数传进去。
然后 std::thread 最后通过 join 等待线程结束,或者通过 detach 让线程独立运行。不过 std::thread 对象销毁前,如果还是 joinable 状态,就必须先 join 或者 detach。
std::jthread 在销毁时可以自动请求停止并 join,管理起来会更方便一点。
