3.29 面试复盘

6 阅读33分钟

一、指针和引用的区别

1. 本质区别

指针本质上是一个存放地址的变量,而引用本质上是一个对象的别名


2. 主要区别

(1)初始化要求不同

指针可以定义后暂时不初始化,只不过此时如果它的值是随机的,那么它就是不安全的。
引用则必须在定义时就完成绑定,不能只声明不绑定。


(2)是否可以为空不同

指针可以为空,也就是可以为 nullptr
引用通常要求绑定一个合法对象,不能像指针那样显式表示“空”。


(3)是否可以改变指向/绑定不同

指针后续可以修改它保存的地址,因此可以重新指向别的对象。
而引用一旦在初始化时绑定到某个对象,后续就不能再改绑到别的对象。

引用不能改绑,但可以通过它修改所绑定对象的值。


(4)使用方式不同

指针在使用时通常需要解引用,比如 *p
引用在语法上使用起来更像原对象本身,不需要额外写解引用操作。


(5)多级形式不同

指针可以有多级指针,比如二级指针、三级指针:

int x = 10;
int* p = &x;
int** pp = &p;

而引用通常不讨论这种多级结构,面试里一般会说:
指针支持多级,引用没有常规意义上的多级引用用法。


(6)内存层面的理解不同

从语言语义上看,引用更像别名,不强调它是一个独立对象;
但从底层实现上看,编译器很多时候会把引用实现成类似指针的形式,因此它在实际存储上往往也会占用空间。

引用在语义上是别名,但在底层实现上通常会以类似指针的方式存在。


3. 面试中容易答错的地方

(1)把野指针只理解成“未初始化指针”

这是不完整的。
野指针更准确地说,是指向无效内存的指针,包括:

  • 未初始化的指针
  • 指向已经释放内存的指针
  • 指向已经出作用域对象的指针
  • 越界后的非法指针

所以“未初始化指针”只是野指针的一种情况。


4. 这道题的面试回答模板

如果在面试里让我回答,我会这样说:

指针和引用的核心区别在于,指针本质上是一个保存地址的变量,而引用本质上是对象的别名。
指针可以不初始化,也可以为空;而引用必须在定义时初始化,通常不能为空。
此外,指针后续可以修改指向别的对象,而引用一旦绑定后就不能再改绑。
在使用上,指针需要显式解引用,并且支持多级指针;引用使用起来更像原对象本身。
最后,从语义上看引用更像别名,但在底层实现上很多编译器会把它实现成类似指针的形式。

二、static 的用法

1. 主要用法

(1)修饰全局变量和全局函数

static 修饰全局变量和全局函数时,改变其链接属性,会让它们只在当前源文件内部可见,不能被其他源文件访问。


(2)修饰局部变量

static 修饰局部变量时,不会改变它的作用域,但会让它的生命周期延长到整个程序结束
并且它只会初始化一次


(3)修饰类的成员变量

static 成员变量属于类本身,不属于某一个对象,因此会被这个类的所有对象共享。


(4)修饰类的成员函数

static 成员函数也属于类本身,不依赖具体对象调用。
没有 this 指针,因此不能直接访问非 static 成员。


2. 面试中容易答错的地方

(1)漏掉 static 成员函数没有 this 指针

这是面试中很常见的追问点。
因为没有 this,所以它不能直接访问普通成员变量和普通成员函数。


3. 这道题的面试回答模板

static 在 C++ 中有几种常见用法。
修饰全局变量和全局函数时,它会让它们只在当前源文件内可见。
修饰局部变量时,会让局部变量的生命周期延长到程序结束,并且只初始化一次。
修饰类成员变量和成员函数时,它们就属于类本身,而不属于某个具体对象。
其中 static 成员函数没有 this 指针,所以不能直接访问非 static 成员。


三、const 的用法

1. 主要用法

(1)修饰普通变量

const 修饰变量时,表示这个变量在初始化之后就不能再被修改。


(2)修饰指针

const 修饰指针时要分两种情况:

  • 如果修饰的是指针指向的内容,说明不能通过这个指针修改它指向的对象
  • 如果修饰的是指针本身,说明这个指针不能再改指向

(3)修饰函数参数

const 修饰函数参数时,表示函数内部不能通过这个参数去修改对应的内容。
常见于传引用或传指针的场景,可以避免拷贝,同时保证只读语义。


(4)修饰成员函数

const 写在成员函数后面时,表示这个函数不能修改当前对象的普通成员状态。
本质上是把 this 变成了指向常量对象的指针。


(5)修饰返回值

const 修饰返回值时,如果返回的是普通值,意义通常不大;
如果返回的是指针或引用,则可以限制调用方不能通过返回值修改对象。


2. 面试中容易答错的地方

(1)把 const 成员函数理解成“不能修改参数”

不对。
const 成员函数限制的是当前对象的成员状态,不是函数参数。


(2)把 mutable 和参数联系起来

mutable 作用在成员变量上,表示即使在 const 成员函数中,这个成员变量也仍然可以被修改。


(3)返回值一律说成“带 const 属性”

不够准确。
更重要的是区分:返回普通值意义不大,返回引用/指针时更有意义。


3. 这道题的面试回答模板

const 在 C++ 中有几种常见用法。
修饰普通变量时,表示变量初始化后不能再修改。
修饰指针时,可以限制“指针本身不能改指向”,也可以限制“不能通过指针修改它指向的对象”。
修饰函数参数时,表示参数在函数内部是只读的。
写在成员函数后面时,表示这个成员函数不能修改当前对象的普通成员状态。
如果修饰返回值,那么在返回引用或指针时意义更明显,可以限制调用方修改对象。


四、内联函数的作用

1. 主要作用

(1)作为编译器的内联建议

inline 修饰函数时,表示这是一个可以考虑内联展开的函数
也就是说,编译器在合适的时候,可以把函数体直接展开到调用处,从而减少函数调用的开销。

但这只是建议,并不代表编译器一定会展开。


(2)允许函数定义写在头文件中

inline 更重要的作用是:
它允许函数定义出现在头文件中,被多个源文件包含时,也不会因为重复定义而报错。

也就是说,它帮助函数满足了多翻译单元下的单一定义规则要求。


2. 面试中容易答错的地方

(1)认为写了 inline 就一定会展开

不对。
是否真的展开,最终由编译器根据优化策略决定。


(2)只说“提高效率”,不说头文件定义的意义

这题里更关键的点其实是:
inline 允许函数定义写在头文件中,并在多个源文件中重复出现。


3. 这道题的面试回答模板

inline 有两个常见作用。
第一,它是给编译器的一个内联建议,编译器可以在合适的时候把函数体直接展开到调用处,从而减少函数调用开销,但这并不是强制的。
第二,也是更重要的一点,它允许函数定义写在头文件中,被多个源文件包含时也不会因为重复定义而报错。


五、内存的分配方式有哪几种

1. 常见的分配方式

(1)自动分配

函数中的局部变量通常由系统自动分配和回收,常见情况下存放在栈区
它的特点是申请和释放由系统自动完成。


(2)动态分配

程序员也可以自己申请和释放内存,常见方式有:

  • new / delete
  • malloc / free

这类内存一般分配在堆区,生命周期由程序员控制。


(3)静态存储

全局变量、静态变量通常存放在静态存储区,它们的生命周期一般贯穿整个程序运行过程。


2. C++ 程序的常见内存分布

一般来说,C++ 程序运行时常见的内存区域可以分为:

(1)栈区

主要存放函数的局部变量、函数参数等。
由系统自动分配和回收,速度快,但空间相对有限。


(2)堆区

主要存放动态申请的内存,比如通过 newmalloc 申请的空间。
需要程序员手动管理,否则可能出现内存泄漏。


(3)静态存储区 / 全局区(已初始化和未初始化)

主要存放全局变量、静态变量。
这部分内存在程序开始时分配,程序结束时释放。


(4)常量区

主要存放字符串常量、const 常量等只读数据。
这部分内容通常不允许修改。


(5)代码区

主要存放程序的机器指令,也就是函数编译后的代码。


3. 面试中容易答错的地方

(1)只答 new/deletemalloc/free

这只能算答到了“动态内存申请方式”,不够完整。
更好的回答应该包含:栈区、堆区、静态存储区 等整体视角。


(2)把“内存区域”和“申请方式”混为一谈

要区分:

  • 栈区、堆区、静态存储区、常量区、代码区 是内存分布
  • new/deletemalloc/free 是动态内存管理方式

4. 这道题的面试回答模板

C++ 中常见的内存分配方式主要有自动分配、动态分配和静态存储。
自动分配的典型例子是函数局部变量,通常在栈区,由系统自动管理;动态分配一般通过 new/deletemalloc/free 完成,通常位于堆区;全局变量和 static 变量通常位于静态存储区,生命周期贯穿整个程序运行过程。
如果从程序内存分布来看,还可以进一步分为栈区、堆区、静态存储区、常量区和代码区。


六、什么是野指针

1. 基本概念

野指针指的是指向无效内存的指针
也就是说,这个指针当前并没有安全地指向一个可以正常访问的对象。


2. 常见情况

野指针常见有以下几种情况:

  • 指针没有初始化
  • 指针指向的内存已经被释放
  • 指针指向的对象已经离开作用域
  • 指针发生越界,指向非法地址

3. 面试中容易答错的地方

(1)把野指针只理解成“未初始化指针”

这是不完整的。
未初始化只是野指针的一种来源。


(2)把空指针和野指针混为一谈

nullptr 是空指针,表示明确不指向任何对象;
野指针则是指向了无效地址,风险更大。


4. 这道题的面试回答模板

野指针指的是指向无效内存的指针。
它不只是未初始化指针,还包括指向已释放内存、已出作用域对象或者越界地址的指针。
本质上说,就是这个指针当前没有安全地指向一个有效对象。
另外,空指针和野指针不一样,空指针是明确可判断的,而野指针通常更危险。


七、析构函数声明为虚函数的作用

1. 主要作用

当通过基类指针删除派生类对象时,如果基类析构函数被声明为 virtual,那么会发生动态绑定,保证先调用派生类析构函数,再调用基类析构函数,从而让整个析构链完整执行。

底层实现是编译器插入了基类的构造/虚析构函数,所以能先连续调用


2. 什么叫“基类指针指向派生类对象”

例如:

class Base {
 public:
  virtual ~Base() = default;
};

class Derived : public Base {
};

Base* p = new Derived();

这里的意思是:

  • p 的类型是 Base*
  • p 实际指向的对象是真正的 Derived 对象

也就是说,指针的静态类型是基类,实际对象的动态类型是派生类
这种写法是多态成立的基础。


3. 为什么这时必须要虚析构

如果写:

Base* p = new Derived();
delete p;

那么真正合理的销毁顺序应该是:

  1. 先调用 Derived 的析构函数
  2. 再调用 Base 的析构函数

因为派生类对象中不仅有基类部分,还有派生类自己新增的成员和资源。
如果基类析构函数不是虚函数,就可能只调用基类析构函数,导致派生类那一部分没有被正确销毁。


4. 面试中容易答错的地方

(1)只说“会调用基类析构函数”

不够准确。
重点是:通过基类指针删除派生类对象时,能不能正确调用派生类析构函数。


(2)把“基类指针指向派生类对象”理解成对象类型变成了基类

不对。
对象本身仍然是完整的派生类对象,只是现在通过基类接口去操作它。


5. 这道题的面试回答模板

析构函数声明为虚函数的作用是:当通过基类指针删除派生类对象时,能够发生动态绑定,保证先调用派生类析构函数,再调用基类析构函数,从而让整个析构过程完整执行。
所谓基类指针指向派生类对象,就是指针的静态类型是基类,但实际指向的对象真实类型是派生类。这也是运行时多态成立的基础。
如果基类析构函数不是虚函数,就可能只调用基类析构函数,导致派生类资源释放不完整。


八、怎么检查内存泄漏

1. 常见检查方式

(1)代码审查

最基础的做法是检查程序中动态申请的内存,是否都在合适的时机被正确释放。
例如检查 new 是否对应 deletemalloc 是否对应 free


(2)借助工具检测

实际开发中更常用的是借助工具检查内存泄漏,例如:

  • Valgrind
  • AddressSanitizer(ASan)
  • LeakSanitizer(LSan)
  • Visual Studio 的内存检测工具

这些工具可以帮助定位哪些内存在程序结束前没有被释放。


2. 面试中容易答错的地方

(1)把“检查内存泄漏”和“避免内存泄漏”混在一起

“检查”强调的是发现问题,比如工具检测、日志统计、代码审查;
“避免”强调的是设计和编码阶段减少问题,比如 RAII、智能指针等。


(2)只答“看 new 有没有 delete”

这个方向不算错,但太浅。
面试里更好的回答应该补上:实际开发中通常会借助工具定位。


3. 这道题的面试回答模板

检查内存泄漏,一种方式是做代码审查,查看动态申请的内存是否都被正确释放,比如 new/deletemalloc/free 是否成对出现。
但在实际开发中,更常见的是借助工具来检查,比如 ValgrindASanLSan 或 Visual Studio 的内存检测工具。
此外,也可以通过对象计数、日志和内存统计,辅助判断某类资源是否存在只申请不释放的问题。


4. 怎么避免内存泄漏

(1)养成成对释放的习惯

如果使用的是动态内存分配,那么就要保证:

  • new 对应 delete
  • new[] 对应 delete[]
  • malloc 对应 free

申请和释放方式必须匹配。


(2)尽量使用 RAII

RAII 的核心思想是:
把资源的生命周期绑定到对象生命周期上
对象创建时拿到资源,对象析构时自动释放资源,这样可以减少手动释放遗漏的问题。


(3)优先使用智能指针

在 C++ 中,能不用裸指针管理资源就尽量不用。
例如:

  • 独占资源用 unique_ptr
  • 共享资源用 shared_ptr
  • 避免循环引用时配合 weak_ptr

这样可以明显降低忘记释放内存的风险。


(4)减少手动管理动态内存

能用容器就尽量用容器,比如:

  • std::vector
  • std::string
  • std::map

这些标准库类型会自动管理内部资源,比自己手写 new/delete 更安全。


5. 面试中容易答错的地方

(1)只说“记得 delete 就行”

这太浅了。
真正更稳的思路是:尽量少手动管理资源,而不是单纯靠记忆力。


(2)把避免内存泄漏完全等同于智能指针

智能指针很重要,但不是全部。
更底层的核心其实是:

  • RAII
  • 清晰的所有权
  • 少写裸 new/delete

九、什么是内存碎片

1. 基本概念

内存中虽然还剩下一些可用空间,但这些空间是零散、不连续的,导致内存利用率下降,甚至无法满足新的较大内存申请。


2. 常见类型

(1)内部碎片

分配出去的内存块比实际需要的大,多出来的那部分虽然已经分配了,但没有被有效利用。


(2)外部碎片

内存中存在很多零散的小块空闲空间,虽然总量可能够,但因为不连续,仍然无法分配一块更大的连续内存。


3. 为什么会产生内存碎片

内存频繁地申请和释放,而且每次申请的大小又不一样,就容易把原本连续的内存切成很多零散的小块,从而产生碎片。


4. 面试中容易答错的地方

(1)只说“内存没有连续分配”

这样说太模糊。
更核心的是:空闲内存被分散成很多小块,导致利用率下降。


(2)把内存不足和内存碎片混为一谈

内存碎片不一定代表总内存不够,很多时候是“总量够,但不连续,分配不出来”。


(3)不知道内部碎片和外部碎片

这个不是每次都必须答,但至少要有印象。
一般更常讨论的是外部碎片


十、面向对象的三大特征

1. 三大特征

面向对象的三大特征是:

  • 封装
  • 继承
  • 多态

2. 封装

封装的核心思想是:
把数据和操作数据的方法组织在一起,并对外隐藏内部实现细节,只暴露必要的接口给外部使用。

这样做的好处是:

  • 降低外部对内部实现的依赖
  • 控制对象状态的访问方式
  • 提高代码的可维护性

3. 继承

继承表示类和类之间的一种层次关系。
派生类可以复用基类已经有的成员和接口,同时也可以扩展出自己独有的功能。

继承的主要意义在于:

  • 代码复用
  • 结构抽象
  • 为多态提供基础

4. 多态

多态的核心思想是:
同一个接口,在不同对象上表现出不同的行为。

例如都调用同一个函数接口,但不同对象会执行各自对应的实现。

在 C++ 中,多态通常分为:

  • 静态多态:如函数重载、模板
  • 动态多态:如虚函数

十一、多态的实现有哪些

1. 主要实现方式

(1)静态多态

静态多态是在编译期就确定调用哪个函数实现。
常见方式有:

  • 函数重载
  • 函数模板 / 类模板

(2)动态多态

动态多态是在运行时才确定调用哪个函数实现。
在 C++ 中通常通过继承 + 虚函数来实现。

常见场景是:
用基类指针或基类引用指向派生类对象,再通过虚函数调用不同对象各自的实现。

十二、vector 迭代器为什么会失效

1. 根本原因

vector 的底层是连续内存
而迭代器本质上可以理解为“指向某个元素位置的标识”。
一旦 vector 底层内存发生变化,原来的位置就可能不再有效,迭代器也就失效了。


2. 常见失效场景

(1)扩容

vector 当前容量不够时,会申请一块更大的新内存,然后把原来的元素搬过去。
这时原来那块内存地址就失效了,所以原有迭代器也会失效。


(2)插入和删除

在中间位置插入或删除元素时,后面的元素可能整体移动。
即使没有扩容,原来指向这些元素的迭代器也可能失效。

十三、怎么实现智能指针

1. 基本思路

实现智能指针时,通常会封装一个类,这个类内部不仅保存一个指向实际对象的指针,还会关联一个控制块

其中:

  • 对象指针负责指向真正管理的资源对象
  • 控制块负责维护和这个对象相关的管理信息

2. 控制块里通常有什么

控制块中通常会保存:

  • 引用计数
  • 删除器
  • 有时还会有弱引用计数、分配器等附加信息

如果是共享所有权的智能指针,那么多个智能指针对象可以共同指向同一个控制块。


3. 关键过程

(1)创建时

智能指针绑定一个对象,同时建立或关联对应的控制块。


(2)拷贝时

如果是共享所有权的智能指针,那么拷贝后多个智能指针共同管理同一个对象,此时引用计数加一。


(3)析构时

智能指针析构时,引用计数减一。
当强引用计数减到 0 时,对象会被销毁。


(4)控制块释放

如果还存在弱引用,那么对象虽然已经被销毁,但控制块可能还不能立刻释放。
通常要等弱引用也全部消失后,控制块才会真正释放。


十四、如何保证引用计数正确

1. 核心思路

保证引用计数正确,关键是把资源管理和智能指针对象的生命周期绑定起来,也就是 RAII
这样对象创建、拷贝、赋值、析构时,都可以自动维护引用计数。


2. 关键过程

(1)创建时

当一个智能指针开始管理对象时,控制块中的引用计数被正确初始化。


(2)拷贝时

当智能指针被拷贝后,多个智能指针共同管理同一个对象,因此引用计数需要加一。


(3)赋值时

赋值时既要处理旧资源,也要接管新资源。
也就是说,要先把原来管理对象的计数减一,再把新对象对应的计数加一。


(4)析构时

智能指针析构时,引用计数减一。
当引用计数减到 0 时,说明已经没有共享所有者了,此时才销毁对象。


3. 并发下怎么保证安全

如果智能指针需要在多线程环境下共享使用,那么引用计数的增加和减少必须是线程安全的。
常见做法是使用原子操作来维护计数,而不是单纯依赖普通变量。


十五、如何复制智能指针对象

1. 核心思路

对于共享所有权的智能指针来说,复制智能指针对象时,并不是复制底层对象本身,而是让新的智能指针也指向同一个对象,并共享同一个控制块。

同时,控制块中的强引用计数需要加一。


4. 面试中容易答错的地方

(1)以为复制智能指针就是复制底层对象

不对。
shared_ptr 的复制通常不是深拷贝对象,而是共享同一个对象和控制块。


(2)把拷贝和移动混为一谈

  • 拷贝:共享控制块,计数加一
  • 移动:转移控制权,源对象置空

(3)只说“管理控制块”但不说具体怎么管理

这题至少要明确说出:
拷贝时共享控制块,并增加强引用计数。

十六、数组和链表的区别

1. 存储结构不同

数组在内存中通常是连续存储的。
链表在逻辑上是连续的,但在物理内存上通常不连续,而是通过指针把各个节点连接起来。


2. 访问方式不同

数组支持按照下标进行随机访问,访问效率高。
链表如果想访问某个位置的元素,通常需要从头节点开始依次往后找,不支持高效的随机访问。


3. 插入和删除效率不同

数组在中间位置插入或删除元素时,往往需要移动后面的元素,代价较大。
链表在已知节点位置的情况下,插入和删除通常只需要修改指针连接关系,代价更小。


4. 空间开销不同

数组本身只存放数据,但通常需要提前确定容量,扩容时可能需要重新申请一整块更大的连续内存。
链表每个节点除了数据之外,还需要额外空间存储指针,因此空间开销更大。


十七、字典的实现方式

1. 基本思路

字典通常基于哈希表实现。
它会先对键 key 进行哈希计算,再把结果映射到某个桶位置,从而实现较快的查找、插入和删除。


2. 冲突处理

当不同的键经过哈希计算后落到同一个位置时,就会发生哈希冲突。
常见的处理方式有:

  • 链地址法:同一个桶中挂链表或其他结构
  • 开放地址法:发生冲突后继续寻找其他空位置

3. 为什么字典查找快

因为通过哈希函数,键可以直接映射到某个位置,所以平均情况下查找、插入、删除效率都比较高,通常可以接近 O(1)


十八、开放地址冲突如何解决

1. 基本思路

开放地址法指的是:
当一个键经过哈希计算后,发现目标位置已经被占用了,这时不使用链表,而是在哈希表内部继续寻找其他可用位置来存放这个元素。

也就是说,所有元素都直接存放在哈希表数组中。


2. 常见方式

(1)线性探测

如果当前位置冲突,就按顺序继续往后找:

  • 当前位置不行,就看下一个位置
  • 再不行,就继续往后找

这种方式实现简单,但容易产生聚集现象


(2)二次探测

如果冲突,不是简单地一个一个往后找,而是按照某种二次规律去找新的位置。
这样可以在一定程度上减少线性探测带来的聚集问题。


(3)双重哈希

发生冲突后,使用第二个哈希函数来决定下一次探测的位置。
这种方式通常比前两种更灵活。


3. 开放地址法的特点

开放地址法的特点是:

  • 所有元素都存放在哈希表内部
  • 不需要额外链表空间
  • 对缓存更友好

但它也有一些问题:

  • 容易产生聚集
  • 删除操作更复杂
  • 装载因子过高时性能下降明显

十九、BFS 和 DFS 的区别

1. 搜索顺序不同

(1)DFS

DFS 是深度优先搜索
它的特点是沿着一条路径一直往下走,走到不能再走时再回退。


(2)BFS

BFS 是广度优先搜索
它的特点是从起点开始,一层一层地向外扩展。


2. 使用的数据结构不同

(1)DFS

DFS 通常借助:

  • 递归

来实现。


(2)BFS

BFS 通常借助:

  • 队列

来实现。


3. 适合解决的问题不同

(1)DFS

DFS 更适合:

  • 遍历
  • 回溯
  • 判断路径是否存在
  • 连通性问题

(2)BFS

BFS 更适合:

  • 按层遍历
  • 无权图最短路径
  • 求从起点出发的最少步数问题

二十、进程和线程的区别

1. 基本区别

进程通常是资源分配和管理的基本单位
线程通常是程序执行和调度的基本单位

也就是说:

  • 进程更强调“资源容器”
  • 线程更强调“执行流”

2. 资源和地址空间不同

(1)进程

不同进程之间通常拥有各自独立的地址空间和资源。
一个进程崩溃后,一般不会直接影响其他进程。


(2)线程

同一进程内的多个线程通常共享:

  • 地址空间
  • 全局变量
  • 打开的文件
  • 堆等大部分资源

但每个线程也有自己独立的:

  • 寄存器上下文
  • 程序计数器

3. 创建和切换开销不同

进程因为资源独立更多,所以创建、销毁和切换的开销通常更大。
线程因为共享所属进程的大部分资源,所以创建和切换开销通常更小。


4. 通信方式不同

进程之间由于资源独立,通信通常需要借助 IPC 机制,例如:

  • 管道
  • 消息队列
  • 共享内存
  • socket

线程之间因为共享地址空间,通信通常更直接,但同时也更需要注意同步和互斥问题。


5. Linux 下的补充理解

在 Linux 中,进程和线程在底层实现上关系很近,常常都和同一套任务调度结构有关。
但从使用语义上看,二者最大的区别仍然是:是否共享资源和地址空间。


二十一、项目里如何缓解 GC、降低 GC 频率

1. 核心思路

降低 GC 频率,本质上就是:

  • 减少不必要的对象分配
  • 减少垃圾产生速度
  • 优化 GC 的触发策略
  • 让每次 GC 更有价值

2. 常见做法

(1)减少不必要的对象创建

如果程序中频繁创建临时对象,就会很快增加 GC 压力。
因此要尽量减少没有必要的对象分配。


(2)对象复用

对于高频创建、生命周期又比较短的对象,可以考虑做对象复用或对象池,减少频繁申请和回收带来的压力。


(3)设置合理的 GC 触发阈值

可以根据对象数量、堆内存大小等条件设置 GC 触发阈值,避免 GC 触发得过于频繁。


(4)动态调整阈值

每次 GC 之后,可以根据本次回收释放的对象数量、释放空间大小、剩余存活对象数量等信息,动态调整下一次 GC 的触发阈值。


(5)优化对象生命周期管理

如果对象生命周期混乱、短命对象过多,也容易导致 GC 压力增大。
因此要尽量让对象创建和销毁更合理,减少无意义的垃圾对象。


二十三、项目中印象比较深的问题

1. 问题背景

如果说特别底层、特别复杂的大 bug,我目前还没有遇到特别典型的。
但我印象比较深的一个问题,出现在我做虚拟机项目里的 GC 模块演进过程中。

一开始我的 GC 逻辑只考虑了串行路径,因此当时基于 gtest 写的测试用例,默认都是围绕串行接口构建的。
后来我又想进一步扩展功能,尝试结合线程池实现并发 GC,并新增了对应的并发触发接口和测试。


2. 具体问题

因为中间隔了一段时间,我在后续改代码时,忘记了之前很多测试和逻辑都是建立在原先串行接口之上的。
结果我在推进并发版本时,把原来的串行接口直接删掉了,导致回归测试时,之前那批老用例无法通过。

这个问题的本质并不是 GC 回收逻辑本身错了,而是:
在功能演进过程中破坏了原有接口路径,导致旧测试和新实现不兼容。


3. 定位过程

后来我重新去检查之前的测试代码和接口调用路径,梳理老测试到底依赖的是哪条逻辑链路。
在这个过程中,我也借助 AI 辅助分析,帮助我更快定位到:问题并不是并发实现本身,而是我把原来的串行入口删掉了,导致旧测试失去了原本依赖的调用路径。


4. 解决方式

最后我的处理方式是:

  • 保留原来的串行接口
  • 新增并发接口
  • 用一个 flag 来决定当前走串行路径还是并发路径

这样既保留了旧测试依赖的行为,也支持了新的并发功能,回归测试和新增功能测试都能兼顾。


5. 这件事带来的认识

这个问题让我印象比较深,主要有两个原因:

(1)功能演进不能轻易破坏旧路径

在做模块扩展时,不能只盯着新功能能不能跑通,还要考虑原有调用方和旧测试是否还能正常工作。


(2)回归测试的价值非常真实

回归测试的意义不只是“多跑一遍”,而是能帮助我们及时发现:
新改动是否破坏了旧功能、旧接口和旧行为。


(3)代码设计要考虑可扩展性和兼容性

如果一开始就能把串行和并发路径做成更明确的可切换设计,后续扩展时就不会那么容易破坏旧逻辑。


6. 面试里这题应该怎么理解

如果面试官问“项目中最难的 bug”,不一定非要讲一个特别夸张的大 bug。
更重要的是,你能不能讲清楚:

  • 问题出现在哪里
  • 本质是什么
  • 你是怎么定位的
  • 你怎么修复
  • 最后学到了什么

这个例子虽然不一定是最“硬核”的并发 bug,但它是一个很完整的功能演进 + 回归测试 + 兼容性设计问题,完全可以讲。


你在面试里可以这样说

你不用背,我帮你压成一个更自然的说法:

如果说特别复杂的大 bug,我目前还没有遇到特别典型的,但有一个我印象很深的问题出现在 GC 模块演进过程中。
一开始我的 GC 只有串行实现,所以当时 gtest 里的很多测试用例都是基于串行接口写的。后来我又想扩展并发 GC,于是加了线程池和并发触发接口,但因为中间隔得比较久,我在改代码时把原来的串行接口删掉了。
结果回归测试时,之前的老用例都过不了了。后来我重新检查旧测试和接口调用路径,也借助 AI 辅助分析,发现问题本质不是 GC 逻辑本身错了,而是我在功能扩展时破坏了旧接口路径。
最后我的做法是把原来的串行接口保留下来,再增加一个 flag 去区分当前走串行还是并发路径。这样既兼容旧测试,也支持新功能。
这件事让我比较深地意识到,做功能演进时一定要考虑兼容性,而且回归测试真的很重要,它能及时帮我们发现新改动有没有破坏旧行为。

二十四、实习经历怎么讲

1. 不要把实习讲成“只是跑流程”

虽然这段实习本身偏测试,不是直接做开发,但它依然有价值。
因为它让我接触到了真实项目中的:

  • 需求理解
  • 功能验证
  • 回归测试
  • 团队协作
  • 工具平台

所以这段经历不应该讲成“只是点点点”,而应该讲成:
让我建立了真实项目流程感和质量意识。


2. 这段实习可以怎么理解

我之前在腾讯光子做的是游戏测试实习,参与过未上线开放世界项目的功能测试,也接触过策划案、需求评审、模块测试和主分支冒烟测试,还参与过项目管理平台的需求梳理。

这段经历虽然不是直接做开发,但让我更直观地理解了:

  • 一个需求是怎么从文档走向落地的
  • 一个功能上线前为什么要经过验证和回归
  • 工具和平台为什么会影响研发效率和协作成本

3. 这段实习最值得讲的收获

(1)建立了项目流程感

让我知道了真实项目里,一个功能不是“写完就结束”,而是还要进入评审、测试、回归和版本协作流程。


(2)建立了质量意识

让我意识到质量不是最后补出来的,而是从需求理解、功能验证到回归测试都要参与进来的。


(3)理解了工具平台的价值

在接触项目管理平台需求梳理之后,我会更直接地感受到:
工具不是边缘工作,它会影响信息同步、问题流转、协作成本和研发效率。


4. 这段实习和开发岗的关系

虽然实习岗位偏测试,但它并不是和开发完全无关。
因为开发写出来的功能,最终一定要进入:

  • 团队协作
  • 测试验证
  • 回归检查
  • 版本迭代

这段实习让我对这些真实环节有了更具体的感受。
所以它对开发岗的帮助,更多体现在:

  • 流程理解
  • 质量意识
  • 协作视角