Skip to content

字节跳动算法实习

来源:https://www.nowcoder.com/discuss/573856434702581760

1、自我介绍

2、深挖实习经历(聊了20min)

3、介绍第一段实习经历

4、指针和引用的区别?

  1. 定义:

    • 指针:指针是一个包含变量地址的变量。通过指针,可以访问或修改存储在该地址上的值。
    • 引用:引用是一个别名,它为一个已存在的变量提供了另一个名字。引用在创建时必须初始化,并且一旦初始化后,它将一直引用相同的对象。
  2. 语法:

    • 指针:使用 * 符号来声明指针,以及通过 * 来访问指针指向的值。

      C++
      int x = 10;
      int *ptr = &x;  // 指针的声明和初始化int value = *ptr;  // 使用指针访问值
    • 引用: 使用 & 符号来声明引用,没有类似 * 的解引用符号。

      C++
      int x = 10;
      int &ref = x;  // 引用的声明和初始化int value = ref;  // 直接使用引用访问值
  3. 空值(NULL 或 nullptr):

    • 指针:可以是空值(nullptr 或 NULL),表示指针不指向任何有效的地址。
    • 引用:引用必须在创建时初始化,并且不能为 null。
  4. 地址操作:

    • 指针:可以通过指针进行地址的算术操作,比如指针加法和减法。
    • 引用:引用一旦初始化,不能改变引用的目标。
  5. 多级间接引用:

    • 指针:可以通过多级指针实现多级间接引用。
    • 引用:引用本身不支持多级引用。
  6. 数组:

    • 指针:可以通过指针对数组进行遍历和操作。
    • 引用:引用不直接支持数组的遍历,但可以通过指针和引用的结合来实现。
  7. 传递给函数:

    • 指针:通过指针可以实现函数的参数传递和返回。
    • 引用:通过引用也可以实现函数的参数传递和返回,但语法上更简洁。
  8. 使用场景:

    • 指针:通常用于动态内存分配、数组操作、实现数据结构等。
    • 引用:通常用于函数参数传递、返回引用值、以及在某些情况下取代指针使用。

5、什么时候用malloc,什么时候用new?

  1. malloc

    • malloc是C语言中的函数,也可以在C++中使用。它接受一个参数,即要分配的字节数,返回一个指向分配内存的指针。

    • 使用malloc分配的内存需要手动释放,即使用free函数进行释放。

    • malloc分配的内存不会调用构造函数,因此适用于分配简单的内存块,比如原始的内存缓冲区。

例子:

c++
int* p = (int*)malloc(sizeof(int)); // 分配一个整数大小的内存块
free(p); // 释放内存
  1. new

    • new是C++中的关键字,用于在堆上分配内存,并调用对象的构造函数进行初始化。

    • 使用new分配的内存需要使用delete操作符释放,它会调用对象的析构函数。

    • newdelete可以自动管理对象的生命周期,因此更适用于动态对象的创建和销毁。

例子:

c++
int* p = new int(5); // 在堆上分配一个整数,并初始化为5
delete p; // 释放内存

6、介绍智能指针?

  1. std::shared_ptr

    • 原理:std::shared_ptr是基于引用计数的智能指针,用于管理动态分配的对象。它维护一个引用计数,当计数为零时,释放对象的内存。

    • 使用场景:适用于多个智能指针需要共享同一块内存的情况。例如,在多个对象之间共享某个资源或数据。

    • C++
      std::shared_ptr<int> sharedInt = std::make_shared<int>(42);
      std::shared_ptr<int> anotherSharedInt = sharedInt; // 共享同一块内存
  2. std::unique_ptr

    • 原理:std::unique_ptr是独占式智能指针,意味着它独占拥有所管理的对象,当其生命周期结束时,对象会被自动销毁。

    • 使用场景:适用于不需要多个指针共享同一块内存的情况,即单一所有权。通常用于资源管理,例如动态分配的对象或文件句柄。

    • C++
      std::unique_ptr<int> uniqueInt = std::make_unique<int>(42);
      // uniqueInt 的所有权是唯一的
  3. std::weak_ptr

    • 原理:std::weak_ptr是一种弱引用指针,它不增加引用计数。它通常用于协助std::shared_ptr,以避免循环引用问题。

    • 使用场景:适用于协助解决std::shared_ptr的循环引用问题,其中多个shared_ptr互相引用,导致内存泄漏。

    • C++
      std::shared_ptr<int> sharedInt = std::make_shared<int>(42);
      std::weak_ptr<int> weakInt = sharedInt;
  4. std::auto_ptr(已废弃):

    • 原理:std::auto_ptr是C++98标准引入的智能指针,用于独占地管理对象。但由于其存在潜在的问题,已在C++11中被废弃。

    • 使用场景:在C++98标准中,可用于独占性地管理动态分配的对象。不推荐在现代C++中使用。

    • cpp
      std::auto_ptr<int> autoInt(new int(42)); // 已废弃

7、智能指针的底层如何实现的?

智能指针是C++中的一种数据类型,它可以在管理动态分配的内存时提供自动化的内存管理功能,减少内存泄漏和悬挂指针(dangling pointer)等问题。智能指针的底层实现通常基于类和模板,主要包括以下几个关键点:

  1. 类模板的设计:智能指针通常是一个类模板(template class),这样可以使其适用于不同类型的指针。类模板中包含了对指针所指向的对象进行管理的相关逻辑。
  2. 构造函数和析构函数:智能指针的构造函数通常会接受一个原始指针作为参数,并在内部动态分配内存以存储该指针所指向的对象。析构函数负责在智能指针对象生命周期结束时释放所管理的内存。
  3. 重载运算符:智能指针通常会重载一些运算符,比如*->运算符,以模拟原始指针的行为。通过重载这些运算符,智能指针可以像原始指针一样使用,同时具有自动释放内存的功能。
  4. 引用计数:智能指针通常会使用引用计数(reference counting)来跟踪指针被引用的次数。引用计数会在每个智能指针对象中维护一个计数器,当有新的智能指针引用同一块内存时,计数器加一;当智能指针对象被销毁时,计数器减一。当计数器为零时,表示该块内存已经没有任何智能指针引用,可以安全释放。
  5. 其他功能:智能指针可能还包含其他功能,如拷贝构造函数和赋值运算符重载,用于管理指针的所有权转移;移动语义,允许高效地转移指针的所有权而不进行额外的内存分配或释放等。

一个简单的智能指针实现可能如下所示(仅作示例,实际实现可能更加复杂):

c++
template <typename T>
class SmartPointer {
public:
    SmartPointer(T* ptr) : ptr_(ptr), ref_count_(new size_t(1)) {}
    ~SmartPointer() {
        if (--(*ref_count_) == 0) {
            delete ptr_;
            delete ref_count_;
        }
    }

    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }

private:
    T* ptr_;
    size_t* ref_count_;
};

一个简单的智能指针模板类,它使用引用计数来管理指针的生命周期。在实际应用中,还需要考虑线程安全性、循环引用的处理等更复杂的情况。

8、如何排查内存泄露问题的?

内存泄漏是指程序在动态分配内存后,没有释放已经不再使用的内存,导致系统的可用内存减少,最终可能导致程序性能下降或崩溃。

常见的排查方法:

  1. 使用内存检测工具:内存检测工具(如Valgrind、Dr.Memory等)可以帮助检测程序中的内存泄漏问题。这些工具可以在程序运行时跟踪内存的分配和释放情况,并给出泄漏的内存地址和相关信息,有助于快速定位问题所在。
  2. 代码审查:通过仔细审查代码,特别是涉及内存分配和释放的部分,可以发现潜在的内存泄漏问题。检查是否在每次动态分配内存后都有对应的释放操作,以及释放操作是否正确。
  3. 使用智能指针:智能指针可以帮助自动管理内存,减少手动释放内存带来的错误。使用智能指针可以降低内存泄漏的风险。
  4. 内存分析工具:一些集成开发环境(IDE)和性能分析工具提供了内存分析功能,可以帮助定位内存泄漏问题。这些工具可以显示程序的内存使用情况,并标识出可能存在泄漏的内存块。
  5. 记录内存分配和释放日志:在程序中添加日志记录,记录每次内存分配和释放的操作,可以帮助跟踪内存的使用情况,发现异常情况。
  6. 使用内存分析库:一些第三方内存分析库(如Boost.Pool等)提供了更加灵活和高级的内存管理功能,可以帮助检测和解决内存泄漏问题。

9、介绍c++的内存分布?

  1. 栈(Stack):栈是一种后进先出(LIFO)的数据结构,用于存储函数的局部变量、函数参数和函数调用的上下文信息。每当函数被调用时,系统都会为其分配一段栈空间,函数结束时这段空间会自动释放。栈的大小在程序运行时是固定的,通常比堆的大小要小得多。
  2. 堆(Heap):堆是用于动态分配内存的区域,其大小可以在运行时动态调整。在堆上分配的内存需要手动释放,否则可能会导致内存泄漏。C++中使用newdelete关键字来进行堆上内存的分配和释放。
  3. 全局/静态存储区(Global/Static Storage Area):全局变量和静态变量存储在这个区域中。全局变量在程序启动时就会被分配内存,直到程序结束才会释放;静态变量在其所属的作用域结束时释放。
  4. 常量存储区(Constant Storage Area):常量数据通常存储在这个区域中,包括字符串常量和全局的const变量。这个区域的数据是只读的,不允许修改。
  5. 代码区(Code Area):代码区存储程序的执行代码,通常是只读的,不允许修改。

10、静态链接和动态链接的区别?

  1. 静态链接:
    • 静态链接是在编译时将程序的所有模块(包括库文件和目标文件)合并成一个单独的可执行文件的过程。
    • 在静态链接时,链接器会将所有被引用的函数和数据的实际地址都确定下来,并把它们复制到最终的可执行文件中。
    • 静态链接生成的可执行文件包含了所有需要的函数和数据,因此它们可以独立运行,不需要依赖外部的库文件。
  2. 动态链接:
    • 动态链接是在程序运行时才进行链接的过程。在编译时,只会生成对外部函数和数据的引用,而不包含实际的函数和数据。
    • 在程序运行时,操作系统的动态链接器会根据需要在内存中加载所需的共享库,并将程序中的外部引用与这些库中的实际函数和数据进行链接。
    • 动态链接生成的可执行文件体积较小,因为它们不包含所有的函数和数据,而是依赖于系统中已安装的共享库。

11、动态链接时如何找到对应的函数的?

在动态链接过程中,当程序需要调用一个外部函数时,系统需要找到该函数的实际地址,以便程序能够正确地执行。这个过程通常包括以下几个步骤:

  1. 程序中的函数调用:

    当程序执行到一个外部函数的调用语句时,比如printf函数,编译器会生成一个对该函数的引用,并将其作为一个符号(Symbol)保存在可执行文件中。

  2. 共享库的加载:

    • 在程序运行时,操作系统的动态链接器会根据可执行文件中的引用信息,查找系统中已经加载的共享库(通常是.so文件或.dll文件)。
    • 如果找到了需要的共享库,动态链接器会将该库加载到内存中,并将其中的函数和数据准备好以供程序使用。
  3. 符号解析:

    • 当共享库被加载到内存中后,动态链接器会开始解析程序中的符号引用,以确定每个符号的实际地址。
    • 这个过程通常涉及到符号表的查找和重定位。符号表中包含了函数和数据的名称以及对应的地址信息,动态链接器会根据符号表中的信息找到每个符号的实际地址。
  4. 函数调用:

    • 一旦所有的符号都被解析并且地址被确定后,程序就可以正常地调用外部函数了。
    • 这时,程序会通过获取函数的实际地址来执行函数调用,并将控制权转移到外部函数中去执行相应的操作。

12、什么时候会由用户态切换为内核态?

在操作系统中,用户态和内核态是指操作系统对不同程序运行的不同管理模式。在用户态(User Mode)中,程序只能访问受限的资源和执行受限的指令,而在内核态(Kernel Mode)中,程序可以访问系统的所有资源和执行所有指令。用户态和内核态之间的切换是由操作系统控制的,通常发生在以下几种情况下:

  1. 系统调用(System Call):当用户程序需要访问系统资源或执行特权指令时,例如打开文件、分配内存等操作,需要通过系统调用进入内核态。在系统调用期间,用户程序会将请求传递给操作系统,并等待操作系统处理完成后返回结果,这时会发生用户态到内核态的切换。
  2. 异常(Exception):当程序执行过程中出现了错误或异常情况,例如除零错误、访问非法内存等,会触发异常处理机制。操作系统会接管处理这些异常,这时会从用户态切换到内核态。
  3. 外部中断(External Interrupt):外部设备(如硬件设备、时钟等)发生了需要处理的事件时,会触发外部中断。处理外部中断通常需要操作系统介入,因此会引起用户态到内核态的切换。
  4. 调度器(Scheduler):操作系统的调度器负责管理系统中运行的所有进程,包括进程的创建、销毁和切换等。当调度器决定要切换当前运行的进程时,会发生用户态到内核态的切换。

13、为什么文件io操作时要切换到内核态?

在进行文件 I/O(Input/Output,输入/输出)操作时,需要切换到内核态的主要原因是文件 I/O 涉及到操作系统管理的文件系统资源,而这些资源只能在内核态中被访问和操作。

原因:

  1. 权限管理:文件系统通常会对文件进行权限控制,例如确定哪些进程有权读写某个文件。这种权限检查和管理需要在内核态进行,因为只有内核才能访问文件系统的底层数据结构来进行权限检查。
  2. 资源分配:文件 I/O 涉及到磁盘读写等物理设备的操作,这些设备的访问需要在内核态进行。内核负责管理这些设备的访问和调度,确保多个进程之间对设备的共享访问是安全和有效的。
  3. 缓存管理:为了提高文件 I/O 的性能,操作系统通常会对文件进行缓存,将常用的数据存储在内存中。这些缓存的管理需要在内核态进行,包括缓存的读取、更新和失效等操作。
  4. 错误处理:文件 I/O 操作可能会涉及到各种错误情况,例如文件不存在、磁盘空间不足等。这些错误需要在内核态被捕获并进行适当的处理,例如返回错误码给用户态的程序。

14、介绍虚拟内存?

虚拟内存是一种计算机内存管理技术,它允许程序访问一个比实际物理内存更大的地址空间,从而使得每个程序都拥有独立的连续地址空间。虚拟内存的实现基于硬件和操作系统的协同工作,主要包括以下几个方面:

  1. 地址映射(Address Mapping):

    • 虚拟内存将程序使用的地址空间划分为若干个固定大小的页(Page)或者段(Segment),同时将物理内存划分为相同大小的物理页面。
    • 操作系统通过页表(Page Table)或者段表(Segment Table)来记录虚拟地址和物理地址之间的映射关系,以便在程序运行时能够将虚拟地址转换为对应的物理地址。
  2. 内存访问控制:

    虚拟内存可以实现对内存的访问控制,例如读、写、执行权限的控制。通过页表或段表中的权限位,操作系统可以控制程序对内存的访问权限,从而保护系统的安全性。

  3. 页面置换(Page Replacement):

    当物理内存不足时,操作系统需要选择合适的页面将其换出到磁盘上,以便为新的页面腾出空间。这个过程称为页面置换,通常使用一些算法(如LRU、FIFO等)来选择被置换的页面。

  4. 页面错误处理(Page Fault Handling):

    当程序访问的页面不在物理内存中时,会发生页面错误(Page Fault)。此时操作系统需要将对应的页面从磁盘加载到内存中,并更新页表,然后重新执行导致页面错误的指令。

  5. 内存共享和保护:

    虚拟内存可以实现内存共享,多个程序可以共享同一段物理内存,从而节省内存空间。同时,虚拟内存也可以实现内存保护,确保每个程序只能访问自己被分配的内存空间。

虚拟内存的主要优点包括了:

  • 允许程序使用比物理内存更大的地址空间;
  • 简化了内存管理,使得程序的编写和调试更加方便;
  • 实现了内存保护和共享,提高了系统的安全性和可靠性。

15、虚拟内存如何映射到物理内存的?

虚拟内存到物理内存的映射是通过操作系统中的页表(Page Table)实现的。页表是一个数据结构,用于记录虚拟地址和物理地址之间的映射关系。在虚拟内存系统中,内存地址被划分为固定大小的页(Page),而物理内存也被划分为相同大小的物理页框(Page Frame)。

虚拟内存到物理内存的映射过程通常包括以下几个步骤:

  1. 地址转换:

    当程序访问内存时,CPU生成的是虚拟地址。虚拟地址由两部分组成:页号(Page Number)和页内偏移(Page Offset)。页号用于索引页表,而页内偏移用于定位页内的具体位置。

  2. 页表查找:

    操作系统维护着每个进程的页表,页表中的每一项记录了虚拟页号和对应的物理页框号的映射关系。当程序访问内存时,CPU会使用虚拟页号来查找页表,以获取对应的物理页框号。

  3. 物理地址生成:

    通过查找页表,CPU得到了虚拟页号对应的物理页框号。然后将物理页框号和页内偏移组合成物理地址,用于访问物理内存中的数据。

  4. 访问物理内存:

    最后,CPU使用物理地址来访问物理内存,执行读取或写入操作。这时,内存控制器会根据物理地址来选择相应的内存芯片和存储单元,完成对内存的访问。

需要注意的是,虚拟内存到物理内存的映射是动态的,页表中的映射关系可以随着程序的运行而发生变化。例如,在页面置换过程中,操作系统可能会修改页表中的映射关系,将某个虚拟页号映射到不同的物理页框号,从而实现页面置换。

16、了解过哪些锁?

  1. 互斥锁(Mutex):

    互斥锁是最常见的一种锁,它用于保护共享资源,使得在同一时刻只有一个线程可以访问该资源。当一个线程获得了互斥锁之后,其他线程需要等待这个锁被释放才能访问资源。

  2. 读写锁(Reader-Writer Lock):

    读写锁允许多个线程同时读取共享资源,但是在有线程进行写操作时需要独占整个资源,禁止其他线程进行读或写操作。这种锁适用于读操作频繁、写操作较少的场景,可以提高并发读取的性能。

  3. 自旋锁(Spinlock):

    自旋锁是一种忙等待的锁,它不会使线程进入睡眠状态,而是一直循环检查锁是否可用。在锁被占用时,线程会一直循环等待,直到锁被释放。自旋锁适用于锁被占用的时间非常短的情况。

  4. 信号量(Semaphore):

    信号量是一种更为通用的同步原语,它可以用来控制对多个资源的访问。信号量可以用于解决生产者-消费者问题、控制并发线程数量等场景。

  5. 条件变量(Condition Variable):

    条件变量是一种用于线程间通信的机制,它可以让线程等待某个条件满足后再继续执行。条件变量通常和互斥锁一起使用,用于实现线程的等待和唤醒操作。

  6. 屏障(Barrier):

    屏障用于协调多个线程的执行顺序,它可以让一组线程在某个点上同步,直到所有线程都到达这个点之后才继续执行。

17、互斥锁的用途?

互斥锁(Mutex,全称Mutual Exclusion)是一种用于保护共享资源的锁,它的主要作用是确保在任意时刻只有一个线程可以访问共享资源,从而避免多个线程同时对共享资源进行读写而导致的数据不一致性和竞态条件(Race Condition)问题。

常见的用途:

  1. 多线程并发访问共享资源:

    在多线程程序中,如果有多个线程需要同时访问某个共享资源(比如全局变量、数据结构等),就需要使用互斥锁来保护这个资源,以防止多个线程同时进行读写操作导致数据不一致的问题。

  2. 防止竞态条件:

    竞态条件是指在多线程程序中,由于线程执行顺序的不确定性而导致的结果依赖于线程执行顺序的问题。互斥锁可以用来避免竞态条件,通过串行化对共享资源的访问,确保每次只有一个线程可以修改共享资源,从而避免不确定的结果。

  3. 临界区保护:

    临界区是指一段代码,在这段代码中对共享资源的访问需要进行保护。互斥锁常常被用来保护临界区,以确保在任意时刻只有一个线程可以执行临界区代码,从而避免数据不一致性和竞态条件。

  4. 数据库操作:

    在数据库系统中,多个客户端可能同时访问同一个数据库,为了保证数据库操作的原子性和一致性,数据库系统通常会使用互斥锁来保护数据库的访问,确保同一时间只有一个客户端可以对数据库进行修改操作。

18、实际问题中碰到的死锁现象?

19、python的深拷贝和浅拷贝?

  1. 浅拷贝(Shallow Copy):

    • 浅拷贝创建一个新的对象,但是其中的元素(如果是容器对象)仍然是原始对象中元素的引用。换句话说,浅拷贝只拷贝了容器对象本身,而不拷贝容器中的元素。

    • 在 Python 中,可以使用copy模块中的copy()函数来进行浅拷贝。

    python
    import copy
    
    original_list = [1, 2, [3, 4]]
    shallow_copied_list = copy.copy(original_list)
    
    # 修改原始对象的元素
    original_list[2][0] = 'a'
    
    print(original_list)        # 输出 [1, 2, ['a', 4]]
    print(shallow_copied_list)   # 输出 [1, 2, ['a', 4]]

    例子中,修改原始对象 original_list 中的嵌套列表的元素后,浅拷贝 shallow_copied_list 中的相应元素也发生了变化,这是因为浅拷贝只拷贝了嵌套列表的引用。

  2. 深拷贝(Deep Copy):

    • 深拷贝创建一个新的对象,并且递归地拷贝原始对象中的所有元素,包括嵌套的对象。换句话说,深拷贝会创建原始对象的完整副本。
    • 在 Python 中,可以使用copy模块中的deepcopy()函数来进行深拷贝。
    python
    import copy
    
    original_list = [1, 2, [3, 4]]
    deep_copied_list = copy.deepcopy(original_list)
    
    # 修改原始对象的元素
    original_list[2][0] = 'a'
    
    print(original_list)       # 输出 [1, 2, ['a', 4]]
    print(deep_copied_list)    # 输出 [1, 2, [3, 4]]

    例子中,修改原始对象 original_list 中的嵌套列表的元素后,深拷贝 deep_copied_list 中的相应元素不会发生变化,这是因为深拷贝创建了一个完全独立的副本。

20、python的可变对象和不可变对象?

  1. 可变对象(Mutable Object):

    • 可变对象是指在创建之后,其内容可以被修改的对象。例如,列表(list)、字典(dict)和集合(set)等都属于可变对象,因为它们的内容可以随时进行增加、删除或修改。

    • 可变对象在被修改后,其在内存中的地址不会发生改变,只是其内部的值发生了变化。

    c++
    my_list = [1, 2, 3]  # 可变对象列表
    my_list.append(4)    # 修改列表,添加元素4
    print(my_list)       # 输出 [1, 2, 3, 4]
  2. 不可变对象(Immutable Object):

    • 不可变对象是指在创建之后,其内容不可被修改的对象。例如,整数(int)、浮点数(float)、字符串(str)和元组(tuple)等都属于不可变对象,因为它们一旦创建后,其值就无法被修改。
    • 不可变对象在被修改后,会创建一个新的对象,并将新的值赋予新的对象,原对象的值不会改变。
    c++
    my_tuple = (1, 2, 3)  # 不可变对象元组
    my_tuple += (4,)      # 修改元组,添加元素4
    print(my_tuple)       # 输出 (1, 2, 3, 4)

    不可变对象的特点使得它们更加安全,因为其值不可被修改,可以避免一些意外的错误。而可变对象的特点则在于其内容可以被修改,具有更大的灵活性。

21、多线程和多进程的区别?

  1. 定义:
    • 多线程是指在同一个进程内,允许多个线程同时执行,共享进程的地址空间和系统资源。
    • 多进程是指在操作系统中同时运行多个独立的进程,每个进程有自己独立的地址空间和系统资源。
  2. 资源占用:
    • 多线程共享同一进程的地址空间和系统资源,因此创建和销毁线程的开销较小,但需要注意线程间的同步和互斥。
    • 多进程每个进程有独立的地址空间和系统资源,因此创建和销毁进程的开销较大,但进程间的数据不共享,因此更容易实现并发访问的安全性。
  3. 数据共享:
    • 多线程共享同一进程的地址空间,因此线程间的数据共享和通信相对容易。
    • 多进程每个进程有独立的地址空间,需要使用进程间通信(IPC,Inter-Process Communication)机制来实现数据共享。
  4. 切换开销:
    • 多线程的切换开销相对较小,因为线程共享同一进程的地址空间,切换时只需切换线程的上下文。
    • 多进程的切换开销相对较大,因为每个进程有独立的地址空间和系统资源,切换时需要切换进程的上下文。
  5. 调试和维护:
    • 多线程的调试和维护相对复杂,因为线程共享同一进程的资源,容易出现线程安全和死锁等问题。
    • 多进程的调试和维护相对简单,因为每个进程有独立的资源,不容易相互影响。

22、进程切换的开销为什么比线程更大,详细一些?

进程切换的开销通常比线程更大,这是因为进程是操作系统中的基本执行单元,而线程是在进程内部的执行单元。

主要原因:

  1. 上下文切换的开销:
    • 进程切换需要保存和恢复更多的上下文信息。上下文信息包括进程的程序计数器、寄存器状态、内存映射、I/O 状态等,这些信息的保存和恢复需要消耗较多的时间。
    • 线程切换只需要保存和恢复线程的上下文信息,相对于进程来说,线程的上下文信息更少,因此线程切换的开销较小。
  2. 地址空间的切换:
    • 进程有独立的地址空间,进程切换时需要切换地址空间的映射关系,这涉及到页表的切换和 TLB(Translation Lookaside Buffer)的刷新等操作,开销较大。
    • 线程共享同一进程的地址空间,线程切换不涉及地址空间的切换,因此开销较小。
  3. 资源的切换和管理:
    • 进程有独立的资源,包括文件描述符、打开的文件、信号处理器等,进程切换时需要切换和管理这些资源,开销较大。
    • 线程共享同一进程的资源,线程切换不涉及对资源的切换和管理,因此开销较小。
  4. 安全性和隔离性的考虑:
    • 由于进程具有独立的地址空间和资源,进程切换需要考虑安全性和隔离性,例如需要刷新 TLB、清理缓存等,增加了切换的开销。
    • 线程共享同一进程的资源,线程切换不需要考虑安全性和隔离性,因此开销较小。

23、两个线程申请同一把锁时,如何高效的实现两者切换?

当两个线程竞争同一把锁时,操作系统的调度器会决定哪个线程可以获得锁。在实现上,操作系统会根据一定的调度算法(如优先级调度、时间片轮转等)来选择下一个执行的线程。

为了高效地实现两个线程之间的切换,可以考虑以下几点:

  1. 合理设置线程优先级:

    线程的优先级可以影响调度器的决策,高优先级的线程可能会更快地获得锁。但是要注意,过度依赖线程优先级可能导致低优先级线程长时间得不到执行,出现饥饿(Starvation)现象。

  2. 减小临界区的长度:

    临界区是指在执行期间对共享资源进行访问的代码段。减小临界区的长度可以降低线程争夺锁的概率,从而减少线程切换的次数。

  3. 使用自旋锁:

    自旋锁是一种忙等待的锁,它在尝试获得锁时会循环检查锁的状态,而不会让线程进入睡眠状态。在锁被占用的时间很短且竞争不激烈的情况下,自旋锁可以减少线程切换的开销。

  4. 避免锁的过度粒度化:

    如果锁的粒度过小,即每个线程只需要访问少量共享资源时就需要获取锁,可能会导致大量的锁竞争和频繁的线程切换。合理设计锁的粒度可以减少锁的竞争,提高并发性能。

  5. 使用无锁数据结构:

    无锁数据结构是一种不需要使用锁就可以实现并发访问的数据结构,例如无锁队列、无锁栈等。使用无锁数据结构可以避免锁的竞争和线程切换的开销。

24、如何解决python中的内存泄漏?

在 Python 中,内存泄漏通常指的是程序中的某些对象在不再需要时没有被正确地释放,导致内存占用不断增加,最终耗尽系统的内存资源。

解决 Python 中的内存泄漏可以采取以下几个方法:

  1. 使用垃圾回收器(Garbage Collector):

    Python 中的垃圾回收器负责检测并清理不再使用的对象,释放其占用的内存。可以通过 gc 模块手动调用垃圾回收器的 collect() 方法来触发垃圾回收,及时释放无用对象占用的内存。

  2. 避免循环引用:

    循环引用是指两个或多个对象相互引用,导致它们之间形成一个环,垃圾回收器无法正确地识别和释放这些对象。可以通过适当设计数据结构和使用弱引用(Weak Reference)来避免循环引用的产生。

  3. 使用上下文管理器(Context Manager):

    在使用文件、网络连接等资源时,应当使用上下文管理器(with 语句)来确保资源在不再需要时能够正确地关闭和释放。这样可以避免因为忘记关闭资源而导致的内存泄漏。

  4. 定期检查内存使用情况:

    可以使用内置的 tracemalloc 模块来跟踪程序的内存分配情况,并定期检查内存使用情况,发现潜在的内存泄漏问题。

  5. 使用内存分析工具:

    可以使用一些第三方的内存分析工具(如 memory_profilerobjgraph 等)来帮助定位内存泄漏的原因,找出不再使用但未被释放的对象。

  6. 优化算法和数据结构:

    合理设计算法和数据结构可以减少内存的占用,避免不必要的内存分配和释放,从而降低内存泄漏的风险。

25、代码题,leetcode98.验证二叉搜索树?

方法一:递归

思路:

  1. 定义一个递归函数 isValidBST,接收当前节点 root,以及当前节点值的最小允许值 minVal 和最大允许值 maxVal
  2. 如果当前节点为空,则返回 true
  3. 如果当前节点的值不在允许范围内(小于等于 minVal 或大于等于 maxVal),则返回 false
  4. 递归地判断当前节点的左子树和右子树是否也是有效的二叉搜索树,左子树的最大允许值为当前节点值,右子树的最小允许值为当前节点值。
  5. 如果左右子树都是有效的二叉搜索树,则返回 true,否则返回 false

参考代码:

c++
#include <iostream>
#include <climits>
using namespace std;

// 定义二叉树节点结构体
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};

class Solution {
public:
    // 递归函数,判断以root为根节点的树是否是有效的BST
    bool isValidBST(TreeNode* root) {
        return isValidBST(root, LONG_MIN, LONG_MAX);
    }

    // 递归辅助函数,传入根节点、当前节点值的最小允许值和最大允许值
    bool isValidBST(TreeNode* root, long minVal, long maxVal) {
        // 如果当前节点为空,则是有效的BST
        if (root == nullptr) return true;
        // 如果当前节点的值不在允许范围内,则不是有效的BST
        if (root->val <= minVal || root->val >= maxVal) return false;
        // 递归判断左子树和右子树是否是有效的BST
        return isValidBST(root->left, minVal, root->val) && isValidBST(root->right, root->val, maxVal);
    }
};

int main() {
    // 创建示例二叉树
    TreeNode* root1 = new TreeNode(2);
    root1->left = new TreeNode(1);
    root1->right = new TreeNode(3);
    TreeNode* root2 = new TreeNode(5);
    root2->left = new TreeNode(1);
    root2->right = new TreeNode(4);
    root2->right->left = new TreeNode(3);
    root2->right->right = new TreeNode(6);

    // 测试示例1
    Solution s;
    cout << "Example 1: " << (s.isValidBST(root1) ? "true" : "false") << endl;

    // 测试示例2
    cout << "Example 2: " << (s.isValidBST(root2) ? "true" : "false") << endl;

    return 0;
}

方法二:迭代

思路:使用中序遍历

  1. 定义一个栈 stk 和指针 cur,初始时 cur 指向根节点。
  2. 使用循环遍历二叉树的所有节点,直到当前节点为空且栈为空。
  3. 在循环中,首先将当前节点的所有左子节点入栈,直到没有左子节点为止。
  4. 弹出栈顶节点,判断其值是否大于前一个节点的值(prev),如果不是则返回 false
  5. 将当前节点设为前一个节点,继续遍历当前节点的右子节点。
  6. 如果遍历完所有节点都满足条件,则返回 true,否则返回 false

参考代码:

c++
#include <iostream>
#include <stack>
using namespace std;

// 定义二叉树节点结构体
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};

class Solution {
public:
    // 判断二叉树是否是有效的BST
    bool isValidBST(TreeNode* root) {
        stack<TreeNode*> stk; // 定义一个栈,用于辅助中序遍历
        TreeNode* cur = root;  // 当前节点指针,初始指向根节点
        TreeNode* prev = nullptr; // 用于保存中序遍历的前一个节点,初始为空

        // 循环遍历二叉树的所有节点
        while (cur != nullptr || !stk.empty()) {
            // 将当前节点的所有左子节点入栈
            while (cur != nullptr) {
                stk.push(cur);
                cur = cur->left;
            }
            // 弹出栈顶节点
            cur = stk.top();
            stk.pop();
            // 判断当前节点的值是否大于前一个节点的值
            if (prev != nullptr && cur->val <= prev->val) return false;
            // 将当前节点设为前一个节点
            prev = cur;
            // 继续遍历当前节点的右子节点
            cur = cur->right;
        }
        // 遍历完成后返回true,表示二叉树是有效的BST
        return true;
    }
};

int main() {
    // 创建示例二叉树
    TreeNode* root1 = new TreeNode(2);
    root1->left = new TreeNode(1);
    root1->right = new TreeNode(3);
    TreeNode* root2 = new TreeNode(5);
    root2->left = new TreeNode(1);
    root2->right = new TreeNode(4);
    root2->right->left = new TreeNode(3);
    root2->right->right = new TreeNode(6);

    // 测试示例1
    Solution s;
    cout << "Example 1: " << (s.isValidBST(root1) ? "true" : "false") << endl;

    // 测试示例2
    cout << "Example 2: " << (s.isValidBST(root2) ? "true" : "false") << endl;

    return 0;
}

26、反问,组里主要做AI应用的,涉及到大模型的应用,js、java、go语言都有用到。