深圳的游戏公司后端开发面经
问题如下
网络协议基础
- 知道TCP吗?介绍一下TCP,包括三次握手的原理和意义
- TCP的拥塞控制和流量控制是怎么样的?
- HTTP是一种什么样的协议?
- HTTPS和HTTP区别?
- 7层模型和5层模型
操作系统原理
- 内核态和用户态的区别?为什么要这样区分?
- 作业调度和页面置换的算法有哪些?
- 什么是死锁?解决办法有哪些?
- 协程是怎么样的?
数据库基础
- 数据库事务的隔离级别?
- 数据库乐观锁和悲观锁是怎么样的?
C++ 核心语言特性
- 介绍一下虚函数和内部相关的东西,以及其工作方式?
- 构造函数中可不可以调用虚函数,为什么?
- 什么时候会合成默认版本的拷贝构造函数?
- 介绍下拷贝构造函数的功能?
- 介绍一下左值引用和右值引用的区别?
- 内存泄漏是怎么造成的?如何避免?
数据结构与算法
- 讲一下红黑树的深度遍历?
编程范式与语言特性
- 讲讲面向过程、面向对象的区别?
其他技术问题
- 你对Go语言有什么了解吗?
1.TCP (Transmission Control Protocol) 详解
TCP,即传输控制协议,是互联网协议套件(TCP/IP)中的核心协议之一,位于传输层。它是一种面向连接的、可靠的、基于字节流的传输层协议。
- 面向连接 (Connection-Oriented):在数据传输开始前,通信双方必须先建立一个连接(通过“三次握手”)。数据传输结束后,再断开连接(通过“四次挥手”)。这就像打电话,先拨号建立通话,通话结束后挂断。
- 可靠的 (Reliable):TCP确保数据无差错、不丢失、不重复,并且按序到达。它通过一系列机制来保证可靠性:
- 序列号 (Sequence Numbers):每个发送的字节都被编号。接收方利用序列号对接收到的数据进行排序,并识别重复的数据。
- 确认应答 (Acknowledgements, ACK):接收方收到数据后,会向发送方发送一个确认报文(ACK),告诉发送方“我已经收到了到某个序列号为止的所有数据”。
- 超时重传 (Retransmission):发送方在发送数据后会启动一个计时器。如果在计时器超时前没有收到对应的ACK,它会认为数据包丢失,并重新发送该数据。
- 校验和 (Checksum):TCP报文头包含一个校验和字段,用于检测数据在传输过程中是否出错。如果接收方检测到校验和错误,会丢弃该数据包。
- 基于字节流 (Byte Stream):TCP将应用层交付下来的数据看作一连串无结构的字节流。虽然数据在传输时可能被拆分成多个TCP报文段(Segment),但接收方会将这些报文段按序重组,恢复成原始的字节流交付给应用层。
三次握手 (Three-Way Handshake) 详解
三次握手是TCP建立连接的核心过程,其目的不仅仅是建立连接,更是为了同步通信双方的初始序列号 (Initial Sequence Number, ISN),并验证双方的发送和接收能力。
假设客户端是A,服务器是B。
第一次握手 (A -> B)
- 动作:客户端A发送一个SYN (Synchronize Sequence Numbers) 报文给服务器B。
- 内容:
- SYN标志位:设置为1,表示这是一个同步请求。
- 序列号 (seq):一个随机生成的初始序列号,记为
ISN_A。例如,seq = x。
- 状态:客户端A发送后,进入
SYN_SENT状态。 - 目的:告知服务器B,我(客户端A)想要建立连接,并告知我的初始序列号是
ISN_A。
第二次握手 (B -> A)
- 动作:服务器B收到客户端A的SYN报文后,如果同意建立连接,则发送一个SYN + ACK 报文给客户端A。
- 内容:
- SYN标志位:设置为1,表示同意建立连接。
- ACK标志位:设置为1,表示对客户端A的SYN报文的确认。
- 确认号 (ack):
ack = x + 1。这表示B期望收到的下一个字节的序列号是x + 1,即它已经成功接收了序列号为x的SYN报文。 - 序列号 (seq):服务器B也生成一个随机的初始序列号,记为
ISN_B。例如,seq = y。
- 状态:服务器B发送后,进入
SYN_RCVD状态。 - 目的:告知客户端A,我(服务器B)同意建立连接。同时,确认收到A的SYN报文,并告知A我的初始序列号是
ISN_B。
第三次握手 (A -> B)
- 动作:客户端A收到服务器B的SYN+ACK报文后,发送一个ACK 报文给服务器B。
- 内容:
- ACK标志位:设置为1,表示对服务器B的SYN报文的确认。
- 确认号 (ack):
ack = y + 1。这表示A期望收到的下一个字节的序列号是y + 1,即它已经成功接收了序列号为y的SYN报文。 - 序列号 (seq):
seq = x + 1。因为A已经发送了序列号为x的SYN,并且收到了B的确认,所以接下来发送的数据(如果有的话)的序列号从x + 1开始。
- 状态:客户端A发送后,进入
ESTABLISHED状态。服务器B收到这个ACK报文后,也进入ESTABLISHED状态。 - 目的:确认收到服务器B的SYN报文。至此,双方都确认了对方的初始序列号,并且双方都收到了对方的确认,连接建立成功。
为什么是三次握手,而不是两次或四次?
- 两次握手不行:如果只有两次握手(A发SYN,B回SYN+ACK),B无法确认A是否收到了自己的SYN+ACK。如果A没有收到,A就不会发送后续数据,但B会一直等待A的数据,造成资源浪费。同时,两次握手也无法有效防止旧的重复连接请求在网络中延迟后到达服务器,导致服务器建立错误的连接。
- 三次握手足够:三次握手已经能让双方都确认对方的初始序列号,并验证了双方的发送和接收能力。四次握手是多余的,增加了不必要的网络开销和延迟。
- 核心意义:
- 同步初始序列号 (ISN):确保双方都知道自己和对方的初始序列号,这是后续数据传输、排序和确认的基础。
- 验证通信能力:通过三次交互,验证了:
- A -> B: A能发,B能收 (第一次握手)
- B -> A: B能发,A能收 (第二次握手)
- A -> B: A能收 (第三次握手)
- 防止历史连接的建立:随机生成的初始序列号使得旧的、延迟的连接请求(其序列号很可能已经过时)无法被服务器接受,从而避免了建立错误的连接。
2. TCP的拥塞控制和流量控制是怎么样的?
想象一下你和朋友通过一条公路(网络)互相邮寄包裹(数据)。
流量控制 (Flow Control):
- 类比:你朋友(接收方)告诉你,他家的邮箱(接收缓冲区)一次只能放10个包裹。如果你一次寄了20个,他家就放不下了,多余的包裹会丢失或弄乱。
- 原理:TCP接收方会告诉发送方:“我的缓冲区还能接收多少数据”,这个数字就是接收窗口 (rwnd)。发送方不能发送超过这个窗口大小的数据。就像你根据朋友邮箱容量来决定一次寄多少包裹。
- 目的:防止发送方把接收方“撑爆”,确保接收方能处理得过来。
拥塞控制 (Congestion Control):
- 类比:这条公路(网络)不是你一个人在用。如果所有发包裹的人都一股脑地发,公路就会堵车(网络拥塞),包裹(数据包)会迟到甚至丢掉。
- 原理:TCP发送方需要感知整条“公路”的拥堵情况。它通过丢包(超时或收到重复ACK)来判断网络可能堵了。然后它会减慢发包裹的速度(减小拥塞窗口 cwnd)。
- 主要算法:
- 慢启动:像新手司机,先试探性地慢慢发(
cwnd从很小开始指数增长),看看路好不好走。 - 拥塞避免:路走得差不多了,就稳步前进(
cwnd线性增长),避免突然加速导致拥堵。 - 快重传/快恢复:如果发现有包裹(数据包)丢了(通过连续收到重复的ACK知道),就立刻重发那个包裹,然后稍微降点速,但不像严重堵车(超时)那样降很多。
- 慢启动:像新手司机,先试探性地慢慢发(
- 目的:防止发送方把整个网络“撑爆”,让所有用户都能顺畅地使用网络。
总结(面试回答要点): 流量控制是点对点的,防止接收方处理不过来(靠接收窗口 rwnd 控制)。拥塞控制是全局的,防止网络过载(靠拥塞窗口 cwnd 控制),通过慢启动、拥塞避免、快重传/快恢复等算法动态调整发送速率。
rwnd: Receive Window (接收窗口)
- 用途: 由接收方(例如,客户端或服务器中的接收端)维护。它告诉发送方,“我的接收缓冲区还能接收多少字节的数据”。发送方发送的数据量不能超过这个窗口大小,主要用于流量控制 (Flow Control),防止发送方把接收方的缓冲区“撑爆”。
cwnd: Congestion Window (拥塞窗口)
- 用途: 由发送方(例如,客户端或服务器中的发送端)维护。它代表发送方根据对网络拥塞状况的感知,当前允许发送但未被确认的最大字节数。发送方发送的数据量不能超过这个窗口大小,主要用于拥塞控制 (Congestion Control),防止发送方把整个网络“撑爆”。
总结:
rwnd是接收方告诉发送方“我还能接收多少”,用于流量控制。cwnd是发送方自己决定“我这次能发多少”,用于拥塞控制。- 实际发送窗口的大小是
min(rwnd, cwnd),即同时受接收能力和网络能力的限制。
3. HTTP是一种什么样的协议?
- 类比:HTTP就像你去餐厅点餐的流程。你(浏览器)对服务员(服务器)说“我要一份XX菜”(发送请求),服务员把菜端给你(返回响应)。
- 定义:HTTP是应用层协议,是浏览器和网站服务器之间沟通的“标准语言”。
- 特点:
- 请求-响应:你先发请求,服务器再给你响应。
- 无状态:每次请求都是独立的,服务器不会记住你上一次做了什么。就像服务员不会因为上次你点了什么,就给你打折。
- 基于TCP:它依赖TCP来保证数据能可靠地发送和接收。
- 明文传输:数据是“裸奔”的,别人可以看(窃听)和改(篡改)。
4. HTTPS和HTTP区别?
- 类比:HTTPS就像给HTTP加了一个“信封”和“锁”。你和餐厅之间的点餐信息都写在信封里,用一把只有你知道的钥匙才能打开。
- 核心区别:HTTPS = HTTP + SSL/TLS 加密层。
- 区别:
- 安全性:HTTP明文传输,不安全。HTTPS加密传输,数据安全,别人看不到内容也改不了。
- 端口:HTTP用80端口,HTTPS用443端口。
- 证书:HTTPS需要服务器提供一个由权威机构(CA)颁发的“身份证”(证书),证明它确实是你想访问的那个网站。
- 性能:HTTPS因为加密解密会慢一点点,但优化后差距不大。
总结(面试回答要点): HTTPS通过SSL/TLS协议对HTTP数据进行加密,保证了数据传输的机密性、完整性和身份认证,使用443端口,需要证书。HTTP是明文传输,不安全,使用80端口。
5. 7层模型和5层模型
OSI七层模型 (Open Systems Interconnection Reference Model) 和 TCP/IP五层模型 (TCP/IP Five-Layer Model) 是两种用于描述和理解网络通信过程的分层架构模型。它们将复杂的网络通信任务分解为一系列逻辑上相对独立的层次,每一层只关心特定的功能,并为其上一层提供服务。
OSI七层模型
这是一个理论上的、标准化的参考模型,旨在促进不同厂商网络设备的互操作性。它将网络通信划分为七个逻辑层次:
- 应用层 (Application Layer):为应用程序提供网络服务接口。例如,HTTP、FTP、SMTP、DNS等协议工作在此层。
- 表示层 (Presentation Layer):处理数据的表示方式,如加密/解密、压缩/解压、格式转换(例如将数据转换为网络标准格式)。SSL/TLS协议的部分功能可以认为在此层。
- 会话层 (Session Layer):负责建立、管理和终止会话(应用程序之间的通信对话)。例如,RPC(远程过程调用)协议。
- 传输层 (Transport Layer):提供端到端的可靠或不可靠的数据传输服务。主要协议有TCP(可靠、面向连接)和UDP(不可靠、无连接)。
- 网络层 (Network Layer):负责数据包的路由和转发,实现主机到主机的通信。IP协议是核心。
- 数据链路层 (Data Link Layer):负责节点间(通常是相邻节点)的数据帧传输和错误检测。例如以太网协议。
- 物理层 (Physical Layer):负责在物理介质上传输原始比特流。涉及网线、光纤、无线电波等物理介质和信号。
TCP/IP五层模型
这是实际应用中更常用的模型,是对OSI模型的简化和整合,更贴近TCP/IP协议栈的实际实现:
- 应用层 (Application Layer):整合了OSI模型的应用层、表示层和会话层。为应用程序提供网络服务。协议如HTTP, FTP, SMTP, DNS等。
- 传输层 (Transport Layer):与OSI模型的传输层功能相同。协议如TCP, UDP。
- 网络层 (Network Layer):与OSI模型的网络层功能相同。协议如IP, ICMP。
- 数据链路层 (Data Link Layer):整合了OSI模型的数据链路层和物理层的逻辑部分。协议如以太网、WiFi。
- 物理层 (Physical Layer):与OSI模型的物理层功能相同。涉及硬件和物理介质。
关键区别与联系 (面试要点)
模型性质:
- OSI七层模型:是一个理论框架,旨在提供一个通用的、独立于特定技术的网络通信标准。它在实际部署中并不完全被遵循。
- TCP/IP五层模型:是实际协议栈的体现,反映了Internet(TCP/IP协议族)的实际工作方式。它更加实用。
分层结构:
- OSI模型将功能分得更细(7层),特别是将应用相关的功能分为了应用层、表示层、会话层。
- TCP/IP模型将这些功能合并为一个应用层。同样,它也将OSI的数据链路层和物理层合并为数据链路层(有时也称为网络接口层)。
实际应用:
- 在实际的网络编程和协议分析中,虽然TCP/IP模型更贴近现实,但OSI模型的分层思想仍然非常有用,因为它提供了一个清晰的逻辑框架,有助于理解不同协议之间的关系和功能划分。
- 面试官期望:我们日常讨论、编程和分析网络问题时,通常基于TCP/IP模型,但理解OSI模型有助于构建更全面的网络知识体系。例如,当我们讨论HTTP(应用层)、TCP(传输层)、IP(网络层)、以太网(数据链路层)时,就是在使用TCP/IP模型的视角。
总结 (面试深度回答):
OSI七层模型是一个理论参考模型(应用、表示、会话、传输、网络、数据链路、物理),旨在标准化网络通信。TCP/IP五层模型是实际应用模型(应用、传输、网络、数据链路、物理),它将OSI的上三层(应用、表示、会话)合并为应用层,下两层(数据链路、物理)合并为数据链路层(或网络接口层)。虽然OSI模型是理论框架,但TCP/IP模型是Internet协议栈的实际体现。在实际开发和面试中,我们主要基于TCP/IP模型讨论协议(如HTTP/TCP/IP),但OSI模型的分层思想对于理解网络架构和协议交互至关重要。
6. 内核态和用户态的区别?为什么要这样区分?
定义:
- 用户态 (User Mode):CPU在此模式下执行用户程序(如你的应用程序、游戏等)。在此状态下,程序的权限受到严格限制,不能直接访问硬件资源(如内存地址、I/O端口、中断控制器),也不能执行一些特权指令(如修改页表、设置中断向量、禁用中断等)。
- 内核态 (Kernel Mode):CPU在此模式下执行操作系统内核的代码。在此状态下,程序拥有最高权限,可以执行所有指令,访问所有内存空间和硬件资源。
区别:
- 权限:内核态权限最高,用户态权限最低。
- 可执行指令:用户态只能执行非特权指令,内核态可以执行所有指令。
- 资源访问:用户态不能直接访问硬件,内核态可以。
为什么要区分(面试要点):
- 系统安全 (Security):防止用户程序随意访问硬件或修改关键系统数据结构(如页表、进程控制块),避免恶意程序破坏系统或窃取其他进程的数据。
- 系统稳定 (Stability):防止用户程序的错误(如无限循环、访问非法内存地址)导致整个系统崩溃。所有对硬件和关键资源的访问都必须通过操作系统提供的系统调用 (System Call) 进行,由操作系统内核统一管理和控制,形成一道保护屏障。
- 资源管理 (Resource Management):操作系统需要统一管理CPU、内存、I/O设备等资源,用户态的隔离确保了这种统一管理的可行性。
7. 作业调度和页面置换的算法有哪些?
作业调度 (Job Scheduling):决定哪个作业(或进程)从后备队列调入内存并创建进程,使其获得CPU使用权。主要发生在长程调度阶段。
- 先来先服务 (FCFS - First-Come, First-Served):按作业提交或进入就绪队列的先后顺序进行调度。优点是公平、简单;缺点是平均等待时间长,可能导致“护航效应”(短作业等待长作业)。
- 短作业优先 (SJF - Shortest Job First):优先调度预计运行时间最短的作业。优点是能最小化平均等待时间;缺点是需要预知运行时间,且可能导致长作业“饥饿”(Starvation)。
- 优先级调度 (Priority Scheduling):为每个作业分配一个优先级,优先调度优先级高的作业。优点是灵活,可照顾重要作业;缺点是可能导致低优先级作业“饥饿”。
- 高响应比优先 (HRRN - Highest Response Ratio Next):综合考虑等待时间和预计运行时间,响应比 = (等待时间 + 服务时间) / 服务时间。优先调度响应比最高的作业。优点是兼顾了等待时间和运行时间,减少了长作业的“饥饿”问题。
页面置换 (Page Replacement):在虚拟内存管理中,当物理内存不足,需要将内存中的某个页面换出到外存(通常是交换区或交换文件)时,选择哪个页面换出的策略。这是短程调度的一部分。
- 最佳置换算法 (OPT - Optimal):选择将来最长时间内不再被访问的页面进行置换。理论上能产生最少的缺页中断,是评价其他算法的基准;但无法实现(因为需要预知未来)。
- 先进先出置换算法 (FIFO - First-In, First-Out):选择最早进入内存的页面进行置换。实现简单;缺点是可能会置换掉经常使用的页面(Belady现象)。
- 最近最久未使用置换算法 (LRU - Least Recently Used):选择最近最久未被访问的页面进行置换。性能较好,接近OPT;缺点是实现相对复杂(需要硬件支持或软件近似算法,如计数器、栈)。
- 时钟置换算法 (Clock / Second Chance):是对FIFO的改进。给每个页面关联一个“使用位”(Reference Bit)。选择页面时,像时钟指针一样扫描页面,如果页面的使用位为0,则置换该页面;如果为1,则将其置为0,并给它一次“第二次机会”,继续扫描下一个。实现相对简单,性能接近LRU。
8. 什么是死锁?解决办法有哪些?
定义:死锁是指多个进程(或线程)因竞争资源而造成的一种互相等待的僵局,若无外力作用,这些进程将无法向前推进。
产生死锁的必要条件(面试要点):
- 互斥条件 (Mutual Exclusion):资源不能被多个进程同时共享使用(如打印机、文件等临界资源)。
- 请求和保持条件 (Hold and Wait):进程已获得部分资源,但又申请新的资源,而该资源被其他进程占用,此时该进程阻塞,但对自己已获得的资源保持不放。
- 不剥夺条件 (No Preemption):进程已获得的资源,在未使用完毕前,不能被剥夺,只能由该进程自己释放。
- 循环等待条件 (Circular Wait):存在一个进程等待队列 {P1, P2, ..., Pn},其中P1等待P2占有的资源,P2等待P3占有的资源,...,Pn等待P1占有的资源,形成一个资源等待的循环链。
解决办法:
- 预防死锁 (Deadlock Prevention):破坏产生死锁的四个必要条件之一。
- 破坏请求和保持:要求进程一次性申请所有需要的资源,或者在请求新资源时必须释放已占有的资源。
- 破坏不剥夺:当进程请求资源失败时,可以剥夺它已占有的资源。
- 破坏循环等待:对资源进行编号,要求进程按序申请资源,防止形成循环等待链。
- 避免死锁 (Deadlock Avoidance):允许死锁条件存在,但在资源分配时,计算此次分配是否会导致系统进入不安全状态。如果会导致不安全状态,则不进行分配。代表算法是银行家算法 (Banker's Algorithm)。
- 检测和解除死锁 (Deadlock Detection and Recovery):允许死锁发生,但系统定时运行死锁检测算法(如资源分配图算法、简化算法)来检查是否存在死锁。一旦发现,则采取措施解除死锁(如剥夺资源、撤销进程、进程回滚)。
- 预防死锁 (Deadlock Prevention):破坏产生死锁的四个必要条件之一。
9. 协程是怎么样的?
定义:协程(Coroutine)是一种用户态的轻量级线程,由用户程序自己调度,而不是由操作系统内核调度。它允许函数在执行过程中挂起(Yield),让出CPU控制权,稍后在挂起点恢复执行。
特点:
- 轻量级 (Lightweight):创建和切换的开销远小于线程(通常比线程快几个数量级),因为不需要陷入内核态,上下文切换只在用户态进行,速度快。
- 用户态调度 (User-Space Scheduling):调度逻辑由应用程序控制,不依赖操作系统。协程的切换是协作式的,由程序显式调用挂起函数(如
yield)触发。 - 协作式 (Cooperative):协程的执行是协作式的,一个协程必须主动让出控制权(Yield),其他协程才能获得执行机会。这与线程的抢占式调度不同。
- 共享栈空间 (Shared Stack):一个线程内的多个协程共享该线程的栈空间,但它们的执行是分时的。协程在挂起时会保存自己的栈上下文(寄存器、局部变量等),恢复时再加载回来。
应用场景:
- 高并发 I/O 操作:非常适合用于I/O密集型任务,如网络请求、文件读写等。当一个协程发起I/O操作时(通常是异步非阻塞I/O),它可以挂起,让出CPU给其他协程执行,从而提高整体的并发效率和吞吐量,避免了传统多线程模型中大量线程阻塞导致的线程切换开销。
- 异步编程:简化异步编程模型,使代码看起来像同步代码一样顺序执行,提高代码的可读性和可维护性。
- 游戏开发:在游戏逻辑中用于管理复杂的、分步执行的任务。
与线程的区别(面试要点):
- 调度者:线程由操作系统内核调度;协程由用户程序调度。
- 开销:线程切换开销大(涉及内核态切换);协程切换开销小(用户态切换)。
- 数量:一个进程能创建的线程数量受系统限制;一个进程可以创建大量的协程。
- 并发性:线程可以利用多核CPU实现并行;协程本身是单线程内的并发,在单核CPU上也能高效处理多任务,但在多核环境下通常需要配合多线程使用。
总结(面试回答要点): 协程是用户态轻量级线程,由程序自身调度,通过挂起/恢复实现协作式多任务。其核心优势是切换开销小,非常适合处理高并发I/O密集型场景,能显著提升并发效率。
数据库基础
数据库事务的隔离级别?
事务隔离级别是数据库系统为了解决并发事务操作时可能出现的脏读、不可重复读、幻读等问题而设定的规范。SQL标准定义了四种隔离级别,级别越高,数据一致性越好,但并发性能越低。
读未提交 (Read Uncommitted):
- 定义: 事务A可以读取事务B未提交的变更。
- 问题: 脏读 (Dirty Read)。事务A读取了事务B修改但未提交的数据。如果事务B回滚,事务A读取的就是无效数据。
- 示例:
-- 事务A SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; START TRANSACTION; SELECT balance FROM accounts WHERE id = 1; -- 假设读到 1000 -- 事务B 此时将 id=1 的 balance 改为 500 但未提交 SELECT balance FROM accounts WHERE id = 1; -- 可能读到 500 (脏数据) COMMIT;-- 事务B START TRANSACTION; UPDATE accounts SET balance = 500 WHERE id = 1; -- 假设这里回滚了 ROLLBACK; -- 事务A读取到的 500 就是脏数据 - 性能: 最高,几乎不加锁。
- 一致性: 最低。
读已提交 (Read Committed):
- 定义: 事务A只能读取事务B已提交的变更。
- 解决: 解决了脏读问题。
- 问题: 不可重复读 (Non-repeatable Read)。事务A在同一个事务内,两次读取同一行数据,结果不一致,因为事务B在两次读取之间提交了对该行的修改。
- 示例:
-- 事务A SET TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM accounts WHERE id = 1; -- 读到 1000 -- 事务B 在此期间提交了更新 SELECT balance FROM accounts WHERE id = 1; -- 读到 500 (与第一次读取不同) COMMIT;-- 事务B START TRANSACTION; UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT; - 性能: 较高。
- 一致性: 较低。
- 常见数据库: Oracle、SQL Server 默认级别。
可重复读 (Repeatable Read):
- 定义: 事务A在同一个事务内,多次读取同一行数据,结果始终一致,即使事务B提交了修改。
- 解决: 解决了脏读和不可重复读问题。
- 问题: 幻读 (Phantom Read)。事务A读取一个范围内的数据(如
SELECT * FROM accounts WHERE balance > 100),事务B在该范围内插入或删除了一行并提交,事务A再次查询该范围时,会发现记录数不一致(多了一条或少了一条“幻影”记录)。 - 示例:
-- 事务A SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM accounts WHERE balance > 100; -- 假设返回 5 条记录 -- 事务B 在此期间插入了一条 balance > 100 的记录并提交 SELECT * FROM accounts WHERE balance > 100; -- 仍返回 5 条记录 (MySQL InnoDB 通过多版本并发控制 MVCC 和 Next-Key Locks 避免了幻读) COMMIT;-- 事务B START TRANSACTION; INSERT INTO accounts (id, balance) VALUES (2, 150); COMMIT; - 性能: 中等。
- 一致性: 中等。
- 常见数据库: MySQL InnoDB 存储引擎的默认级别。InnoDB 通过 MVCC (Multi-Version Concurrency Control) 和 Next-Key Locks(记录锁 + 间隙锁)在可重复读级别下也解决了幻读问题。
串行化 (Serializable):
- 定义: 最高隔离级别,强制事务串行执行,完全避免了脏读、不可重复读和幻读。
- 解决: 解决了所有并发问题。
- 问题: 性能最低,因为事务必须排队执行,严重限制了并发性。
- 示例: 事务A执行查询时,可能会锁住整个表或满足条件的范围,直到事务A结束,事务B才能执行相关操作。
- 性能: 最低。
- 一致性: 最高。
数据库乐观锁和悲观锁是怎么样的?
乐观锁和悲观锁是解决并发冲突的两种不同策略,主要体现在对共享资源访问时的处理方式上。
悲观锁 (Pessimistic Locking):
- 核心思想: 假设最坏的情况,认为并发冲突很可能发生,因此在访问数据的整个过程中都持有锁,防止其他事务修改。
- 实现方式:
- 数据库层面: 通常通过
SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE等语句实现。这些语句会获取行级锁或表级锁。 - 应用层面: 使用同步原语(如互斥锁
mutex)。
- 数据库层面: 通常通过
- 优点: 数据一致性高,能有效防止所有类型的并发冲突。
- 缺点: 阻塞其他事务,降低并发性能。加锁和解锁本身也有开销。
- 适用场景: 写操作频繁、冲突概率高的场景。例如,银行转账、库存扣减等。
- 代码示例 (数据库):
-- 事务A (悲观锁) START TRANSACTION; -- 获取排他锁,直到事务结束 SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 其他事务无法同时 SELECT ... FOR UPDATE 或 UPDATE -- 模拟业务处理时间 -- ... 执行业务逻辑 ... UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;-- 事务B (尝试同时执行) START TRANSACTION; -- 这里会被阻塞,直到事务A提交或回滚释放锁 SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 阻塞等待 -- ... 后续操作 ... COMMIT;
乐观锁 (Optimistic Locking):
- 核心思想: 假设最好的情况,认为并发冲突不太可能发生,因此在访问数据时不加锁。只在更新数据时,检查在此期间数据是否被其他事务修改过。
- 实现方式:
- 版本号 (Version Number): 在数据表中增加一个
version字段。读取数据时也读取version。更新时,WHERE条件中包含version = old_version,并递增version。如果UPDATE影响的行数为0,说明数据已被修改,更新失败,需要重试。 - 时间戳 (Timestamp): 类似版本号,使用时间戳字段。
- 版本号 (Version Number): 在数据表中增加一个
- 优点: 不阻塞其他事务,并发性能高。
- 缺点: 如果冲突频繁,需要不断重试,性能反而会下降。实现逻辑相对复杂。
- 适用场景: 读操作多、写操作少、冲突概率低的场景。例如,论坛帖子浏览、配置信息读取等。
- 代码示例 (版本号):
-- 表结构 CREATE TABLE accounts (id INT PRIMARY KEY,balance INT,version INT DEFAULT 1 -- 版本号字段 );-- 事务A (乐观锁) START TRANSACTION; -- 读取数据和版本号 SELECT id, balance, version FROM accounts WHERE id = 1; -- 假设读到 version = 1 -- 模拟业务处理时间 -- ... 执行业务逻辑 ... -- 更新数据,检查版本号是否未变 UPDATE accounts SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 1; -- WHERE 条件检查版本 -- 如果影响行数为0,说明 version 不是1,数据已被其他事务修改,更新失败 COMMIT;-- 事务B (同时执行) START TRANSACTION; SELECT id, balance, version FROM accounts WHERE id = 1; -- 也可能读到 version = 1 (如果事务A还没提交) -- ... 执行业务逻辑 ... UPDATE accounts SET balance = balance - 50, version = version + 1 WHERE id = 1 AND version = 1; -- 也可能成功或失败,取决于执行顺序 COMMIT;
#include <iostream>// --- 基类 ---
class Animal {
public:// 1. 虚函数: 使用 virtual 关键字声明virtual void makeSound() const {std::cout << "Some generic animal sound" << std::endl;}// 2. 虚函数: 另一个虚函数virtual void move() const {std::cout << "Animal moves" << std::endl;}// 3. 虚析构函数: 非常重要,确保派生类对象能被正确销毁virtual ~Animal() {std::cout << "Animal destructor called" << std::endl;}// 注意: 编译器会为 Animal 类生成一个虚函数表 (vtable)// Animal 的 vtable 内容大致为:// [ makeSound 的地址 (Animal::makeSound) ]// [ move 的地址 (Animal::move) ]// [ ~Animal 的地址 (Animal::~Animal) ]
};// --- 派生类 ---
class Dog : public Animal {
public:// 4. 重写 (Override) 虚函数: Dog 类重写了 makeSoundvoid makeSound() const override { // 'override' 关键字是可选的,但推荐使用以确保正确重写std::cout << "Woof!" << std::endl;}// 5. 重写 (Override) 另一个虚函数void move() const override {std::cout << "Dog runs" << std::endl;}// 6. 派生类自己的虚函数 (如果有的话)virtual void fetch() const {std::cout << "Dog fetches the ball" << std::endl;}~Dog() override {std::cout << "Dog destructor called" << std::endl;}// 注意: 编译器会为 Dog 类生成一个虚函数表 (vtable)// Dog 的 vtable 内容大致为:// [ makeSound 的地址 (Dog::makeSound) ] <- 指向 Dog 的重写版本// [ move 的地址 (Dog::move) ] <- 指向 Dog 的重写版本// [ ~Animal 的地址 (Dog::~Dog) ] <- 虚析构函数也被继承和重写// [ fetch 的地址 (Dog::fetch) ] <- Dog 类新增的虚函数
};// --- 另一个派生类 ---
class Cat : public Animal {
public:// 7. 重写 (Override) 虚函数: Cat 类重写了 makeSoundvoid makeSound() const override {std::cout << "Meow!" << std::endl;}// 注意: Cat 没有重写 move,所以它会继承 Animal 的 move 实现~Cat() override {std::cout << "Cat destructor called" << std::endl;}// 注意: 编译器会为 Cat 类生成一个虚函数表 (vtable)// Cat 的 vtable 内容大致为:// [ makeSound 的地址 (Cat::makeSound) ] <- 指向 Cat 的重写版本// [ move 的地址 (Animal::move) ] <- 指向基类 Animal 的版本// [ ~Animal 的地址 (Cat::~Cat) ] <- 虚析构函数也被继承和重写
};int main() {// 8. 创建派生类对象Animal* animal1 = new Dog(); // 基类指针指向 Dog 对象// 此时,animal1 指向的 Dog 对象内部有一个虚函数指针 (vptr)// 在 Dog 构造函数执行完毕后,这个 vptr 被设置为指向 Dog 类的 vtableAnimal* animal2 = new Cat(); // 基类指针指向 Cat 对象// 此时,animal2 指向的 Cat 对象内部有一个虚函数指针 (vptr)// 在 Cat 构造函数执行完毕后,这个 vptr 被设置为指向 Cat 类的 vtable// 9. 通过基类指针调用虚函数 (动态绑定)std::cout << "Calling makeSound on animal1 (Dog): ";animal1->makeSound(); // 输出: Woof!// 执行过程:// 1. 从 animal1 指向的 Dog 对象中读取 vptr// 2. 通过 vptr 找到 Dog 类的 vtable// 3. 在 vtable 中查找 makeSound 的地址 (即 Dog::makeSound)// 4. 调用 Dog::makeSoundstd::cout << "Calling move on animal1 (Dog): ";animal1->move(); // 输出: Dog runs// 执行过程类似,调用 Dog::movestd::cout << "Calling makeSound on animal2 (Cat): ";animal2->makeSound(); // 输出: Meow!// 执行过程:// 1. 从 animal2 指向的 Cat 对象中读取 vptr// 2. 通过 vptr 找到 Cat 类的 vtable// 3. 在 vtable 中查找 makeSound 的地址 (即 Cat::makeSound)// 4. 调用 Cat::makeSoundstd::cout << "Calling move on animal2 (Cat): ";animal2->move(); // 输出: Animal moves// 执行过程类似,但 Cat 没有重写 move,所以调用的是 Animal::move// 10. 释放内存delete animal1; // 会调用 Dog 的析构函数,因为它指向 Dog 对象delete animal2; // 会调用 Cat 的析构函数,因为它指向 Cat 对象return 0;
}关键点总结:
- 虚函数: 用
virtual声明的函数。 - 虚函数表 (vtable): 编译器为每个包含虚函数的类生成的静态表,包含该类所有虚函数的地址。标注在代码注释中。
- 虚函数指针 (vptr): 每个包含虚函数的类的对象内部都有一个指针,指向其实际类型的
vtable。在代码中没有显式定义,但编译器会自动添加。 - 动态绑定: 通过基类指针/引用调用虚函数时,运行时会根据对象的
vptr找到正确的vtable,然后调用vtable中对应的函数。在main函数的调用注释中详细说明了过程。
C++ 核心语言特性
构造函数中可不可以调用虚函数,为什么?
不可以在构造函数中安全地调用虚函数。
原因: 在构造派生类对象时,会先调用基类构造函数,再调用派生类构造函数。在基类构造函数执行时,对象的
vptr还指向基类的vtable,只有当派生类构造函数执行时,vptr才会被更新指向派生类的vtable。因此,在基类构造函数中调用虚函数,会调用基类的版本,而不是派生类重写的版本。代码示例:
#include <iostream>class Base {
public:Base() { // 基类构造函数std::cout << "Base constructor called." << std::endl;std::cout << "About to call virtual func() from Base constructor:" << std::endl;func(); // <--- 在基类构造函数中调用虚函数,这里会调用 Base::func()std::cout << "Back from virtual func() call in Base constructor." << std::endl;}virtual void func() const { // <--- 虚函数std::cout << "Base::func() called." << std::endl; // <--- 这个会被调用}virtual ~Base() = default;
};class Derived : public Base {
public:Derived() { // 派生类构造函数std::cout << "Derived constructor called." << std::endl;}void func() const override { // <--- 重写基类的虚函数std::cout << "Derived::func() called." << std::endl; // <--- 这个不会被调用}
};int main() {std::cout << "Creating Derived object..." << std::endl;Derived d; // 创建派生类对象std::cout << "Back in main, calling func() on object d:" << std::endl;d.func(); // <--- 对象完全构造后,调用虚函数,这里会调用 Derived::func()return 0;
}
// 预期输出:
// Creating Derived object...
// Base constructor called.
// About to call virtual func() from Base constructor:
// Base::func() called. // <--- 注意:这里调用了 Base::func,而不是 Derived::func
// Back from virtual func() call in Base constructor.
// Derived constructor called.
// Back in main, calling func() on object d:
// Derived::func() called. // <--- 对象完全构造后,调用的是 Derived::func什么时候会合成默认版本的拷贝构造函数?
编译器会在以下没有用户自定义拷贝构造函数的条件下,自动合成一个默认的拷贝构造函数:
- 类中没有声明拷贝构造函数。
- 类中没有声明移动构造函数(C++11及以后)。如果声明了移动构造函数,编译器通常会删除(
= delete)默认的拷贝构造函数,除非显式地= default。 - 类的直接基类没有删除拷贝构造函数。
- 所有非静态数据成员的类型都有可用的拷贝构造函数。
#include <iostream>class MyClass { private:int value;// 假设没有用户定义的拷贝构造函数public:MyClass(int v) : value(v) {}// 编译器会在此处合成一个默认的拷贝构造函数,等价于:// MyClass(const MyClass& other) : value(other.value) {}// 它执行逐成员拷贝 (Memberwise Copy) };int main() {MyClass obj1(10); // 创建 obj1MyClass obj2 = obj1; // <--- 调用合成的默认拷贝构造函数std::cout << "obj1.value: " << obj1.getValue() << std::endl; // 假设有一个 getValue() 方法std::cout << "obj2.value: " << obj2.getValue() << std::endl; // 也是 10return 0; }介绍下拷贝构造函数的功能?
拷贝构造函数(Copy Constructor)是一种特殊的构造函数,用于使用一个已存在的同类型对象来初始化一个新对象。
语法:
ClassName(const ClassName& other);功能:
- 初始化新对象: 当以
ClassName obj2 = obj1;或ClassName obj2(obj1);的形式创建对象时,会调用拷贝构造函数。 - 按值传递参数: 当函数参数是按值传递(
void func(ClassName obj))时,实参会通过拷贝构造函数复制给形参。 - 按值返回对象: 当函数按值返回一个对象(
ClassName func() { ... return obj; })时,可能需要调用拷贝构造函数(尽管现代编译器常有优化)。
- 初始化新对象: 当以
浅拷贝 vs 深拷贝:
- 默认拷贝构造函数(合成的)执行的是浅拷贝(Shallow Copy)。对于指针成员,它只是复制指针的值(地址),导致新旧对象的指针指向同一块内存。这在析构时可能导致双重删除(Double Free)错误。
- 如果类管理了动态内存或其他资源(如文件句柄),通常需要自定义拷贝构造函数来执行深拷贝(Deep Copy),即分配新的内存并复制指针指向的数据。
介绍一下左值引用和右值引用的区别?
C++11引入了右值引用(Rvalue Reference),以支持移动语义(Move Semantics)和完美转发(Perfect Forwarding)。
左值 (lvalue): 代表一个有身份(可以取地址)且内容可以被修改(除非是
const)的对象。右值 (rvalue): 代表一个没有身份(不能取地址)且**内容可以被“窃取”**的对象。
左值引用 (Lvalue Reference,
T&): 只能绑定到左值。右值引用 (Rvalue Reference,
T&&): 主要绑定到右值。
int main() {int x = 5; // x 是左值// 1. 左值引用绑定左值 - OKint& ref1 = x; // ref1 绑定到有身份的 xref1 = 10; // 可以通过 ref1 修改 xstd::cout << x << std::endl; // 输出 10// 2. 左值引用绑定右值 - ERROR!// int& ref2 = 10; // Error: 不能将非 const 左值引用绑定到右值 10// 如果这行能编译,ref2 就会引用一个临时的、即将销毁的 10,这是危险的。// 3. const 左值引用绑定右值 - OKconst int& ref3 = 10; // OK: const 左值引用可以绑定到右值std::cout << ref3 << std::endl; // 输出 10,安全地读取// 临时值 10 的生命周期被 ref3 延长,直到 ref3 离开 main 的作用域// 4. const 左值引用绑定左值 - 也 OKconst int& ref4 = x; // OK: const 左值引用也可以绑定到左值// x = 15; // 可以修改 x// std::cout << ref4 << std::endl; // ref4 会反映出 x 的新值 15 (如果 x 被修改)return 0;
}总结: int& ref2 = 10; 是错误的,因为左值引用(非 const)被设计用来修改其引用的对象。如果允许它绑定到临时的右值(如 10),会导致尝试修改一个即将销毁的临时值,这是危险且无意义的。C++ 禁止这种操作来保护程序员避免犯错。const 左值引用是一个例外,因为它只用于读取,不会修改,因此可以安全地绑定到临时值并延长其生命周期。
内存泄漏是怎么造成的?如何避免?
内存泄漏(Memory Leak)是指程序在堆(Heap)上动态分配了内存(例如通过 new 或 malloc),但在使用完毕后没有将其释放回操作系统(例如通过 delete 或 free),导致这部分内存无法被再次利用。
随着时间的推移,如果程序持续泄漏内存,可用的堆内存会越来越少,最终可能导致程序崩溃或系统性能下降。
内存泄漏是怎么造成的?
忘记
delete/delete[]/free:- 原因: 最常见的原因。程序员使用
new或malloc分配内存后,忘记或疏忽调用对应的delete/delete[]/free
- 原因: 最常见的原因。程序员使用
#include <iostream>void memoryLeakExample1() {int* ptr = new int(42); // 分配内存std::cout << *ptr << std::endl; // 使用内存// 忘记 delete ptr; // <--- 错误!没有释放内存
} // 函数结束,ptr 变量本身被销毁,但 ptr 指向的内存 (42) 仍然泄漏int main() {memoryLeakExample1();// main 结束,泄漏的内存仍未被回收return 0;
}异常导致的泄漏:
- 原因: 在
new之后和delete之前,如果发生了异常,delete语句可能永远不会被执行
#include <iostream>void riskyFunction() {int* ptr = new int(100); // 分配内存std::cout << *ptr << std::endl;// ... 执行一些可能抛出异常的代码 ...if (someCondition) { // 假设 someCondition 为 truethrow std::runtime_error("Something went wrong!"); // 抛出异常}delete ptr; // <--- 如果上面抛出异常,这行代码永远不会执行,导致泄漏
} // 函数结束,由于异常,ptr 没有被 delete,内存泄漏int main() {try {riskyFunction();} catch (...) {std::cout << "Exception caught" << std::endl;}// main 结束,泄漏的内存仍未被回收return 0;
}循环引用 (主要在智能指针中):
- 原因: 在使用
std::shared_ptr时,如果两个或多个对象互相持有对方的shared_ptr,它们的引用计数永远不会降为 0,导致对象无法被销毁,内存无法释放。
#include <iostream>
#include <memory>struct Node {std::shared_ptr<Node> next; // <--- shared_ptr 可能导致循环引用std::shared_ptr<Node> prev; // <--- 这里也持有 shared_ptrint data;Node(int val) : data(val) { std::cout << "Node " << val << " created." << std::endl; }~Node() { std::cout << "Node " << data << " destroyed." << std::endl; }
};void circularReferenceExample() {auto node1 = std::make_shared<Node>(1);auto node2 = std::make_shared<Node>(2);node1->next = node2; // node1 持有 node2node2->prev = node1; // node2 持有 node1,形成循环引用// 当 node1 和 node2 离开作用域时// node1 的引用计数为 1 (被 node2->prev 持有)// node2 的引用计数为 1 (被 node1->next 持有)// 它们都无法被销毁,导致内存泄漏!
} // node1 和 node2 的引用计数永远不会降为 0int main() {circularReferenceExample();std::cout << "End of main" << std::endl;// 期望看到 Node 的析构函数被调用,但实际上不会// Node 1 和 Node 2 的内存泄漏了return 0;
}函数返回裸指针:
- 原因: 函数返回
new出来的指针,但调用方忘记delete。
#include <iostream>int* createInt() {return new int(200); // 函数返回一个指向堆上内存的指针
}void caller() {int* ptr = createInt(); // 接收指针std::cout << *ptr << std::endl; // 使用内存// 忘记 delete ptr; // <--- 错误!调用方忘记释放内存
} // 函数结束,ptr 变量销毁,但其指向的内存泄漏int main() {caller();return 0;
}讲一下红黑树的深度遍历?
红黑树(Red-Black Tree)是一种自平衡的二叉搜索树(BST)。深度优先遍历(Depth-First Traversal)是遍历树的一种基本方法,它沿着树的深度尽可能远地搜索树的分支。对于红黑树,深度遍历的方法与普通二叉树相同,因为它本质上首先是一棵二叉树。
有三种主要的深度遍历方式,它们的区别在于访问根节点相对于遍历左右子树的顺序:
前序遍历 (Pre-order Traversal): 根 -> 左 -> 右
- 步骤:
- 访问根节点。
- 递归地对左子树进行前序遍历。
- 递归地对右子树进行前序遍历。
- 用途: 复制树、打印表达式树(前缀表示)。
- 步骤:
中序遍历 (In-order Traversal): 左 -> 根 -> 右
- 步骤:
- 递归地对左子树进行中序遍历。
- 访问根节点。
- 递归地对右子树进行中序遍历。
- 特点: 对于二叉搜索树(包括红黑树),中序遍历的结果是有序的(从小到大)。
- 用途: 获取排序后的元素序列。
- 步骤:
后序遍历 (Post-order Traversal): 左 -> 右 -> 根
- 步骤:
- 递归地对左子树进行后序遍历。
- 递归地对右子树进行后序遍历。
- 访问根节点。
- 用途: 计算目录大小(先计算子目录)、删除树(先删除子节点)。
- 步骤:
#include <iostream>// 简化的红黑树节点结构(仅用于遍历演示,省略颜色等红黑树特有属性)
struct Node {int data;Node* left;Node* right;Node(int val) : data(val), left(nullptr), right(nullptr) {}
};// 前序遍历 (根 -> 左 -> 右)
void preOrder(Node* root) {if (root == nullptr) return;std::cout << root->data << " "; // 1. 访问根preOrder(root->left); // 2. 遍历左子树preOrder(root->right); // 3. 遍历右子树
}// 中序遍历 (左 -> 根 -> 右)
void inOrder(Node* root) {if (root == nullptr) return;inOrder(root->left); // 1. 遍历左子树std::cout << root->data << " "; // 2. 访问根inOrder(root->right); // 3. 遍历右子树
}// 后序遍历 (左 -> 右 -> 根)
void postOrder(Node* root) {if (root == nullptr) return;postOrder(root->left); // 1. 遍历左子树postOrder(root->right); // 2. 遍历右子树std::cout << root->data << " "; // 3. 访问根
}int main() {// 构建一个简单的红黑树 (或普通BST) 示例:// 4// / \// 2 6// / \ / \// 1 3 5 7Node* root = new Node(4);root->left = new Node(2);root->right = new Node(6);root->left->left = new Node(1);root->left->right = new Node(3);root->right->left = new Node(5);root->right->right = new Node(7);std::cout << "Pre-order: ";preOrder(root); // 输出: 4 2 1 3 6 5 7std::cout << std::endl;std::cout << "In-order: ";inOrder(root); // 输出: 1 2 3 4 5 6 7 (有序!)std::cout << std::endl;std::cout << "Post-order: ";postOrder(root); // 输出: 1 3 2 5 7 6 4std::cout << std::endl;// 记得释放内存 (实际项目中建议使用智能指针)// ... (省略释放代码)return 0;
}