数据结构与算法

展开查看

1.哈希冲突

  • 定义:在哈希表中,不同的键经过哈希函数计算后得到相同的哈希值,导致它们应该存储在哈希表的同一个位置上

  • 链地址法(Chaining):使用链表或其他数据结构,在哈希表的每个位置上维护一个链表,将哈希冲突的元素存储在同一个位置的链表中。当需要查找、插入或删除元素时,遍历对应位置的链表即可。这种方法简单且易于实现,但可能会造成额外的存储空间开销和链表的遍历操作。

  • 开放地址法(Open Addressing):当发生哈希冲突时,通过一定的规则在哈希表的其他位置寻找空槽来存储冲突元素。常见的开放地址法有线性探测、二次探测、双重哈希等。这种方法不需要额外的数据结构存储冲突元素,节省了存储空间,但可能导致聚集现象,即连续的冲突元素会聚集在一起,影响性能。

  • 建立更好的哈希函数:改进哈希函数的设计,减少哈希冲突的发生。好的哈希函数能够尽可能地将键均匀地分布在哈希表的各个位置上,减少冲突的概率。通常,好的哈希函数应该具有均匀性、雪崩效应(即输入的微小变化会导致输出的巨大变化)等特性。

  • 调整哈希表的负载因子:负载因子是指哈希表中已存储元素的数量与哈希表大小的比值。当负载因子过高时,哈希冲突的概率会增加。通过动态调整哈希表的大小,可以控制负载因子在一个合理的范围内,减少哈希冲突的发生。

C++

展开查看

1.C++内存分布:

  • 栈(stack):栈是用于存储局部变量和函数调用信息的内存区域。它的分配和释放是由编译器自动完成的,遵循后进先出(LIFO)的原则。每当函数被调用时,函数的局部变量和参数被压入栈中,并在函数返回时被自动弹出。栈的大小通常是有限的,一般在编译时确定,超出栈大小的分配会导致栈溢出。

  • 堆(heap):堆是用于动态分配内存的区域。在堆上分配的内存需要手动进行管理,包括分配和释放。通常使用new操作符来分配内存,使用delete操作符来释放内存。堆上分配的内存在不需要时需要手动释放,否则可能导致内存泄漏。堆的大小一般比栈大,并且在运行时动态增长。

  • 静态存储区(static storage area):静态存储区用于存储全局变量、静态变量和静态常量。它在程序开始执行时被分配,并在程序结束时被释放。静态存储区的大小在编译时确定,存储区域在整个程序的执行期间都存在。

  • 常量区(Constant Storage):常量区用于存储常量数据,如字符串常量。这些数据在程序执行期间是只读的,无法修改。代码区(存储程序的机器指令)

  • 程序代码区(Code Area):程序代码区存储程序的指令代码。这部分内存是只读的,存储着程序的可执行指令。

2.什么是智能指针?为什么在C++中使用智能指针?

智能指针是C++中的一种包装类,用于管理动态分配的对象的生命周期。它们提供了自动化的内存管理,可以避免内存泄漏和悬挂指针的问题。智能指针通过在对象上使用引用计数或其他技术来跟踪对象的引用,并在引用计数为零时自动释放对象的内存。

3.C++中有哪些类型的智能指针?请简要描述它们的区别

C++中有三种常用的智能指针类型:unique_ptr、shared_ptr和weak_ptr。

  • unique_ptr:独占所有权的智能指针,不能共享所有权。它使用独占式所有权语义,确保只有一个指针可以访问所拥有的对象。

  • shared_ptr:允许多个指针共享同一个对象的智能指针。它使用引用计数来跟踪对象的引用,当引用计数为零时自动释放对象。

  • weak_ptr:用于解决shared_ptr可能引起的循环引用问题。weak_ptr可以观测shared_ptr,但不会增加引用计数,也不能直接访问所指向的对象。

3.如何创建和使用智能指针?

可以使用std::make_uniquestd::make_sharedstd::make_weak等函数来创建智能指针。例如,使用std::make_unique创建unique_ptr。

4.智能指针有什么缺点?

答案:尽管智能指针提供了方便和安全的内存管理,但也存在一些缺点。其中一个主要的缺点是智能指针可能引起循环引用的问题,导致内存泄漏。为了避免这种情况,应该使用weak_ptr来打破循环引用。此外,智能指针相对于裸指针有一些额外的开销,包括引用计数的维护等。

智能指针循环引用问题

5.C++多态

  • 什么是多态性(Polymorphism):多态性是面向对象编程中的一个重要特性,它允许使用基类的指针或引用来访问派生类对象,实现代码的灵活性和可扩展性。

  • 多态性的实现方式:两种主要的实现方式:静态多态性(静态绑定)和动态多态性(动态绑定)。静态多态性通过函数重载和模板实现,而动态多态性通过虚函数和继承实现。

  • 虚函数和纯虚函数:虚函数是在基类中声明并使用virtual关键字修饰的函数,它可以在派生类中被重写,实现动态绑定。纯虚函数是在基类中声明并使用virtual关键字修饰的函数,但没有提供具体的实现,需要在派生类中实现。

  • 虚函数表(Virtual Function Table):虚函数表是一个特殊的数据结构,用于实现动态多态性。每个包含虚函数的类都有自己的虚函数表,该表存储了类的虚函数的地址,使得在运行时能够正确调用派生类的虚函数。

  • 虚析构函数:虚析构函数的作用是确保在删除指向派生类对象的基类指针时,能够正确调用派生类的析构函数,防止内存泄漏。

  • 多态性的优点和应用场景:多态性提供了代码的灵活性、可扩展性和可维护性。它使得代码可以更容易地适应变化和扩展,同时也提高了代码的可读性和重用性。

  • 如何阻止派生类重写虚函数:可以在基类中使用final关键字来修饰虚函数,以阻止派生类对该虚函数进行重写。被标记为final的虚函数在派生类中不能被重写。

6.new/delete和malloc/free的区别

  • malloc/free是C/C++的库函数,需要stdlib.h;new/delete是C++的关键字;
  • 都可用于申请动态内存和释放内存,new/delete在对象创建的时候自动执行构造函数,对象消亡前自动执行析构函数,底层实现其实也是malloc/free
  • new无需指定内存块的大小,编译器会根据类型信息自行计算;malloc需要显式地支持所需内存的大小
  • new返回指定类型的指针,无需进行类型转换;malloc默认返回类型为void*,必须强行转换为实际类型的指针
  • new内存分配失败时会抛出bad_alloc异常;malloc失败时返回NULL

7.static的用法

  • static修饰局部变量:使其变为静态存储方式(静态数据区),函数执行完成之后不会被释放,而是继续保存在内存中;

  • static修饰全局变量:使其只在本文件内部有效,其他文件不可链接或引用该变量;

  • static修饰函数:静态函数,即函数只在本文件内部有效,对其他文件不可见;避免同名干扰,同时保护

8.const的用法

const起到强制保护的修饰作用,可以预防意外改动,提高程序的健壮性

  • const修饰常量:定义时就初始化,以后不能更改;

  • const修饰形参:func(const int a); 该形参在函数里不能改变;

  • const修饰类成员函数:const类成员函数不能改变成员变量的数值

const常量和#define的区别

  • const定义的常量有类型名字,存放在内存的静态区域中,在编译时确定其值;
  • define定义的常量是没有类型的一个立即数,编译器会在预处理阶段将程序中所有使用到该常量的地方进行拷贝替换

9.全局变量和静态变量区别

  • 存储方式上并无区别,都是静态存储方式
  • 非静态全局变量作用域为整个源程序;当一个源程序由多个源文件组成时,非静态的全局变量在各个源文件中都是有效的,而静态全局变量则限制了其作用域,只在定义该变量的源文件内有效

Qt

展开查看

网络基础

展开查看

1.IO复用模型

IO复用模型是一种用于实现高效的并发I/O操作的技术,它允许一个线程同时监视多个I/O事件,并在有事件发生时进行处理,避免了传统的阻塞式I/O中的线程阻塞和轮询等待。

2.socket编程的流程

  1. 创建Socket:使用socket()系统调用创建一个Socket对象。需要指定网络协议、Socket类型和协议族。

  2. 绑定Socket:使用bind()系统调用将Socket绑定到一个特定的IP地址和端口号上。这一步是可选的,如果不绑定,操作系统会自动为Socket分配一个可用的端口。

  3. 监听连接请求(仅适用于服务器):使用listen()系统调用将Socket设置为监听状态,以等待客户端的连接请求。服务器Socket需要指定可以同时处理的最大连接数。

  4. 接受连接(仅适用于服务器):使用accept()系统调用接受客户端的连接请求,返回一个新的Socket对象用于与客户端进行通信。服务器Socket会一直阻塞,直到有客户端连接进来。

  5. 连接服务器(仅适用于客户端):使用connect()系统调用连接到服务器的Socket。需要指定服务器的IP地址和端口号。

  6. 发送和接收数据:使用send()recv()系统调用进行数据的发送和接收。可以根据具体需求进行数据的分割、组装和处理。

  7. 关闭连接:使用close()系统调用关闭Socket连接,释放相关资源。

3.TCP

  • TCP的基本概念:TCP是一种可靠的、面向连接的传输协议,它提供了可靠的数据传输、流量控制、拥塞控制和错误恢复机制等功能。

  • 三次握手:包括客户端向服务器发送SYN(同步)请求,服务器回复SYN-ACK(同步-应答)确认,最后客户端发送ACK(应答)确认。

  • 四次挥手:客户端或服务器发送FIN(结束)请求,对方回复ACK确认,然后再发送FIN请求,对方再次回复ACK确认

4.大小端

  • 大端字节序(Big Endian):在大端字节序中,高位字节存储在低地址,低位字节存储在高地址。小端字节序(Little Endian):在小端字节序中,高位字节存储在高地址,低位字节存储在低地址。网络协议通常使用大端字节序来传输数据。

  • 大小端转换

    • 使用联合体(Union):可以定义一个联合体,其中包含相同的数据,但使用不同的字节序。通过将数据存储在一个字节序中,然后在另一个字节序中读取,可以实现大小端转换。

    • 使用库函数:C++ 标准库或其他第三方库中可能提供了字节序转换的函数。例如,可以使用 <arpa/inet.h> 头文件中的 ntohs()htons() 函数进行网络字节序和主机字节序之间的转换。

5.select

是什么:select 是一种基于IO复用的模型,是Unix系统提供的一种系统调用。它允许进程或线程同时监视多个文件描述符,并在其中的任何一个准备就绪时通知调用者。

使用 select 模型的基本流程如下:

  • 调用 select 函数,传入需要监视的文件描述符集合、超时时间等参数。

  • select 函数开始监视所有文件描述符,直到有一个或多个文件描述符准备就绪。

  • select 函数返回时,可以使用一些辅助函数(如FD_ISSET)来判断哪些文件描述符已经准备就绪。

  • 对于准备就绪的文件描述符,进行相应的读取、写入或其他操作。

  • 重复以上步骤,实现高效的并发IO处理。

使用 select 模型的优点是:

  • 简单易用:相对于其他IO复用模型(如 epoll 和 kqueue),select 的接口更加简单,易于理解和使用。

  • 跨平台性:select 是跨平台的,可以在不同的操作系统上使用。

  • 支持多种类型的文件描述符:可以同时监视多种类型的文件描述符,如标准输入输出、套接字等。

select 也存在一些缺点:

  • 扩展性有限:select 模型在处理大量文件描述符时性能较差,因为每次调用 select 都需要线性扫描整个文件描述符集合。

  • 数量限制:不同操作系统对 select 函数支持的文件描述符数量有限制,通常是1024或更小。

  • 低效的事件通知:当有文件描述符准备就绪时,select 只提供了一个整体的通知,需要使用额外的轮询来确定具体是哪些文件描述符准备就绪

6.poll

poll 模型的基本原理是通过一个结构体数组来表示待监视的文件描述符集合,称为 pollfd 数组。每个 pollfd 结构体包含了一个文件描述符以及监视的事件类型。

使用 poll 模型的基本流程如下:

  • 创建一个 pollfd 数组,用于存储需要监视的文件描述符和事件类型。

  • 使用 poll 函数,将 pollfd 数组传递给它,同时设置超时时间

  • poll 函数开始监视所有文件描述符,直到有一个或多个文件描述符准备就绪或超时。

  • poll 函数返回时,可以通过遍历 pollfd 数组来找到准备就绪的文件描述符。

  • 对于准备就绪的文件描述符,进行相应的读取、写入或其他操作。

  • 重复以上步骤,实现高效的并发IO处理

优点:

  • 没有文件描述符数量限制:poll 模型没有对文件描述符数量的限制,可以处理更多的文件描述符。

  • 提供更直观的事件通知:当有文件描述符准备就绪时,poll 可以提供具体的事件类型,使得处理更加方便和直观。

缺点:

  • poll 在内部实现上仍然需要遍历整个 pollfd 数组来确定准备就绪的文件描述符,对于大量的文件描述符会有性能问题。

  • 跨平台兼容性:poll 不像 select 那样具有跨平台的特性,因此在不同的操作系统上可能存在差异。

7.epoll

epoll 模型的基本原理是通过使用内核提供的 epoll 系统调用,将需要监视的文件描述符注册到一个事件表(event table)中。当文件描述符准备就绪时,内核会将事件通知给应用程序。

  • 支持较大数量的并发连接:epoll 模型使用基于事件驱动的方式,可以支持非常大的并发连接数,远远超过传统的 select 和 poll 模型的限制。

  • 高效的事件通知机制:epoll 使用了回调机制,只有当事件发生时才进行通知,避免了轮询和线性扫描整个文件描述符集合的性能损耗。

  • 高性能的数据结构:epoll 使用了红黑树(Red-Black Tree)和就绪列表(Ready List)等高效的数据结构,可以快速地查找和管理准备就绪的文件描述符。

  • 较低的内存占用:epoll 模型使用了事件通知机制,只需维护准备就绪的文件描述符,而不需要维护整个文件描述符集合,因此占用的内存较少。

  • 支持多种触发模式:epoll 提供了水平触发(Level-Triggered)和边缘触发(Edge-Triggered)两种模式,可以根据需求选择适合的模式。

使用 epoll 模型的基本流程如下:

  • 创建 epoll 实例(epoll_create 或 epoll_create1 函数)

  • 创建监听套接字,并设置为非阻塞模式。

  • 将监听套接字添加到 epoll 实例的事件表中(epoll_ctl 函数,指定事件类型为 EPOLLIN)。

  • 进入事件循环,调用 epoll_wait 函数等待事件发生。

  • 当 epoll_wait 返回时,遍历就绪的事件列表,处理每个事件对应的文件描述符。

  • 如果是监听套接字准备就绪,表示有新的连接请求,接受连接并将新的套接字添加到 epoll 实例的事件表中。

  • 如果是已连接套接字准备就绪,进行读取或写入操作。

边缘触发模式:

  • 精确的事件通知:边缘触发模式只在文件描述符状态发生变化时才进行通知,而不是像水平触发模式那样只要文件描述符可读或可写就会通知。这意味着应用程序只在真正发生状态变化时才会收到通知,避免了不必要的通知,减少了上下文切换和事件处理的开销。

  • 适应高并发环境:边缘触发模式适用于高并发连接的场景,特别是在大规模并发连接的情况下,由于只在状态变化时进行通知,可以有效降低系统开销。边缘触发模式通常与非阻塞 I/O 配合使用,以更好地利用系统资源和处理并发连接。

水平触发模式:

  • 持续通知:一旦文件描述符可读或可写,内核会持续通知应用程序,直到应用程序处理完相关事件或缓冲区不再可读或可写。这意味着应用程序需要在每次通知后重新处理 I/O 事件,否则可能会造成 CPU 资源的浪费。

  • 适用于阻塞 I/O:水平触发模式通常与阻塞 I/O 结合使用。当文件描述符准备就绪时,应用程序可以直接进行 I/O 操作,如果数据未完全就绪或无法立即发送,则会阻塞在相应的读取或写入操作上,直到数据就绪或可写入

两种模式和I/O的组合:epoll水平/边缘触发模式设置阻塞/非阻塞IO事件触发条件及次数 - Jcpeng_std - 博客园

  • 具体地说,采用边缘触发模式必须使用非阻塞IO,不然读缓冲区数据没有了,就会一直阻塞在recv()函数这里。(内核通知fd需要读取,不过却卡在了读取的路上)

数据库

展开查看

Git

展开查看

操作系统

展开查看

1.死锁的必要条件

  • 互斥条件(Mutual Exclusion)

  • 不可剥夺条件(No Preemption)

  • 请求和保持条件(Hold and Wait)

  • 循环等待条件(Circular Wait)

2.死锁的破坏

  • 预防死锁(Deadlock Prevention):

    • 互斥条件破坏:将某些资源设计为可共享的,多个进程可以同时访问。

    • 请求与保持条件破坏:进程在申请资源时,要求一次性申请所需要的所有资源,而不是逐个申请。

    • 不可剥夺条件破坏:允许操作系统在必要时剥夺进程已经占有的资源。

    • 循环等待条件破坏:对系统中的资源进行全局排序,并规定进程按照序号递增的顺序申请资源。

  • 避免死锁(Deadlock Avoidance):银行家算法(Banker’s Algorithm)和资源分配图(Resource Allocation Graph)。

  • 检测和恢复死锁(Deadlock Detection and Recovery):常见的死锁检测算法有图论算法、资源分配图算法等。恢复死锁可以采取一些策略,如终止某个或多个进程、抢占资源、回滚进程等。

  • 忽略死锁(Deadlock Ignorance):这种方法适用于死锁发生的概率极低,或者死锁发生后对系统造成的影响较小的场景。

3.进程通信方式

  1. 共享内存(Shared Memory):多个进程可以访问和操作同一块共享内存区域,从而实现数据的共享和交换。进程可以直接读写内存,无需进行复制和传输。共享内存需要使用同步机制(如信号量)来避免多个进程同时访问导致的竞态条件。

  2. 管道(Pipe):管道是一种半双工的通信方式,用于具有亲缘关系的进程间通信。一个进程可以将输出写入管道,另一个进程则可以从管道读取输入。管道可以是匿名管道(在进程创建时自动创建)或命名管道(通过文件系统中的路径名创建)。

  3. 命名管道(Named Pipe):命名管道允许不具有亲缘关系的进程进行通信。与匿名管道不同,命名管道在文件系统中有一个关联的路径名,进程可以通过打开该路径名来进行通信。

  4. 消息队列(Message Queue):消息队列是一种可以实现进程间异步通信的方式。进程可以将消息发送到队列中,其他进程则可以从队列中接收这些消息。消息队列允许不同进程之间以先进先出的顺序进行通信。

  5. 信号量(Semaphore):信号量是一种用于进程间同步的机制。它可以用来保护临界区,限制资源的访问以及实现进程间的互斥和同步操作。进程可以通过等待(wait)和释放(signal)信号量来实现对共享资源的访问控制。

  6. 套接字(Socket):套接字是一种用于网络通信的接口。它允许不同主机上的进程进行通信,实现了跨网络的进程间通信。

  7. 文件(File):进程可以通过读写文件来进行通信。一个进程可以将数据写入文件,另一个进程则可以从文件中读取这些数据。

4.进程和线程的区别

  • 定义:进程是程序的执行实例,是资源分配和调度的基本单位。它拥有独立的内存空间,包含程序代码、数据和执行状态。线程是进程内的一个执行单元,是进程的实际执行者。线程共享进程的内存空间和资源,可以看作是进程内的子任务。

  • 资源占用:每个进程都有独立的内存空间和资源,包括文件描述符、环境变量、信号处理器等。不同进程之间的资源是相互隔离的,需要通过进程间通信机制来实现数据交换。而线程共享进程的内存空间和资源,可以直接读取和修改进程的数据。

  • 切换开销:进程之间切换的开销较大,因为需要保存和恢复整个进程的状态信息,包括程序计数器、寄存器状态、内存映射等。线程切换的开销较小,因为线程共享进程的内存空间,只需要保存和恢复线程私有的寄存器状态即可。

  • 通信和同步:进程间通信(IPC)是相对复杂的,需要使用进程间通信机制,如管道、消息队列、共享内存等。线程之间通信和同步较为简单,可以直接读写共享的内存空间,也可以使用线程间同步机制,如互斥锁、条件变量等,来保证数据的一致性和互斥访问。

  • 安全性:由于每个进程有独立的内存空间,一个进程的崩溃不会影响其他进程。而在多线程编程中,一个线程的错误可能导致整个进程崩溃。

5.僵尸进程和孤儿进程

  • 孤儿进程(Orphan Process):当一个进程的父进程在其退出之前先退出或被终止时,该进程会成为孤儿进程。此时,孤儿进程将由 init 进程(进程 ID 为 1)接管,并成为 init 进程的子进程。init 进程负责回收孤儿进程的资源,并保持系统的稳定运行。孤儿进程不会成为僵尸进程,因为它的退出状态会被 init 进程处理。

  • 僵尸进程(Zombie Process):当一个进程终止(退出),但其父进程尚未调用 wait()waitpid() 系统调用来获取子进程的退出状态时,子进程会变成僵尸进程。在这种状态下,僵尸进程的进程控制块(Process Control Block,PCB)仍然存在,但不再执行任何代码。僵尸进程占用一些系统资源,因此父进程应该及时回收子进程,以避免僵尸进程的累积。父进程可以通过调用适当的系统调用来获取子进程的退出状态,并释放僵尸进程的资源。

6.虚拟内存

是什么:虚拟内存的主要目的是提供了一种抽象层,使得每个进程认为它拥有连续的、私有的地址空间,而不需要实际的物理内存支持。它通过使用页面(Page)或页面框(Page Frame)作为最小的内存单位,将进程的地址空间划分为固定大小的页面,并将这些页面映射到物理内存或磁盘上。

工作原理:

  • 地址空间划分:操作系统将进程的地址空间划分为多个页面(通常是固定大小的4KB或2MB),并将其映射到虚拟地址空间。

  • 虚拟地址转换:当进程访问虚拟地址时,CPU会将虚拟地址转换为物理地址。这个转换过程是通过页表(Page Table)实现的,页表存储了虚拟页面和物理页面之间的映射关系。

  • 页面置换:如果所需的页面不在物理内存中,操作系统会将页面从磁盘加载到内存中,并更新页表中的映射关系。如果内存不足,操作系统会根据页面置换算法将某些页面置换到磁盘上,以便为新页面腾出空间。

虚拟内存的优势包括:

  • 扩展了物理内存:进程可以访问比物理内存更大的地址空间,允许运行更大的程序和处理更大的数据。

  • 提供了地址空间隔离:每个进程拥有独立的地址空间,保护了进程之间的数据不受干扰,提高了系统的稳定性和安全性。

  • 简化了内存管理:操作系统负责管理虚拟内存的分配和回收,减少了程序员对内存管理的复杂性。

  • 支持页面置换和页面共享:通过页面置换算法,操作系统可以有效地管理内存资源。同时,多个进程可以共享同一物理页面,提高了内存利用率。

设计模式

展开查看

1.单例模式

涉及到类多个对象的操作函数:

  • 构造函数

  • 拷贝构造函数

  • 拷贝赋值操作符函数()

为了把一个类可以实例化多个对象的路堵死:

  • 构造函数私有化

  • 拷贝构造函数私有化或者禁用

  • 拷贝赋值操作符函数私有化或者禁用

要点:静态成员的初始化要在类外

为了得到单例对象,需要定义一个静态的私有的类实例和静态的公共的访问函数。

#include<bits/stdc++.h>
using namespace std;
class TaskQueue
{
public:
TaskQueue(const TaskQueue &t)=delete;
TaskQueue& operator=(const TaskQueue& t)=delete;
static TaskQueue* getInstance()
{
return m_taskQ;
}
void print()
{
cout<<"单例对象的一个成员函数"<<endl;
}
private:
TaskQueue()=default;
static TaskQueue* m_taskQ;
};
TaskQueue* TaskQueue::m_taskQ = new TaskQueue;
int main()
{
TaskQueue* taskQ = TaskQueue::getInstance();
taskQ->print();
return 0;
}

分类:

  • 饿汉模式->定义类的时候已经创建单例对象(以上示例就是懒汉模式)

  • 懒汉模式->使用的时候才创建单例对象(示例如下)

#include<bits/stdc++.h>
using namespace std;
class TaskQueue
{
public:
TaskQueue(const TaskQueue &t)=delete;
TaskQueue& operator=(const TaskQueue& t)=delete;
static TaskQueue* getInstance()
{
if(m_taskQ==nullptr){
m_taskQ=new TaskQueue;
}
return m_taskQ=new TaskQueue;
}
void print()
{
cout<<"单例对象的一个成员函数"<<endl;
}
private:
TaskQueue()=default;
static TaskQueue* m_taskQ;
};
TaskQueue* TaskQueue::m_taskQ = nullptr;
int main()
{
TaskQueue* taskQ = TaskQueue::getInstance();
taskQ->print();
return 0;
}

多线程下:

  • 饿汉模式在多线程下没有线程安全问题,多线程可以同时访问

  • 懒汉模式在多线程下有线程安全问题,可能创建多个类的实例

解决懒汉模式在多线程下的线程安全问题

方案一:加互斥锁,不过多线程下是顺序执行的

#include<bits/stdc++.h>
#include<mutex>
using namespace std;
class TaskQueue
{
public:
TaskQueue(const TaskQueue &t)=delete;
TaskQueue& operator=(const TaskQueue& t)=delete;
static TaskQueue* getInstance()
{
m_mutex.lock();
if(m_taskQ==nullptr){
m_taskQ=new TaskQueue;
}
m_mutex.unlock();
return m_taskQ;
}
void print()
{
cout<<"单例对象的一个成员函数"<<endl;
}
private:
TaskQueue()=default;
static TaskQueue* m_taskQ;
static mutex m_mutex;

};
TaskQueue* TaskQueue::m_taskQ=nullptr;
mutex TaskQueue::m_mutex;
int main()
{
TaskQueue* taskQ = TaskQueue::getInstance();
taskQ->print();
return 0;
}

方案二:双重检查锁定,一开始还未创建实例时是顺序执行的,创建后可以并行执行

#include<bits/stdc++.h>
#include<mutex>
using namespace std;
class TaskQueue
{
public:
TaskQueue(const TaskQueue &t)=delete;
TaskQueue& operator=(const TaskQueue& t)=delete;
static TaskQueue* getInstance()
{
if(m_taskQ==nullptr){
m_mutex.lock();
if(m_taskQ==nullptr){
m_taskQ=new TaskQueue;
}
m_mutex.unlock();
}
return m_taskQ;
}
void print()
{
cout<<"单例对象的一个成员函数"<<endl;
}
private:
TaskQueue()=default;
static TaskQueue* m_taskQ;
static mutex m_mutex;

};
TaskQueue* TaskQueue::m_taskQ=nullptr;
mutex TaskQueue::m_mutex;
int main()
{
TaskQueue* taskQ = TaskQueue::getInstance();
taskQ->print();
return 0;
}

方案二中m_taskQ = new TaskQueue执行过程中,正常过程如下:

  • 第一步:分配内存用于保存 TaskQueue 对象。

  • 第二步:在分配的内存中构造一个 TaskQueue 对象(初始化内存)。

  • 第三步:使用 m_taskQ 指针指向分配的内存。

但是被重新排序以后执行顺序可能会变成这样:

  • 第一步:分配内存用于保存 TaskQueue 对象。

  • 第二步:使用 m_taskQ 指针指向分配的内存。

  • 第三步:在分配的内存中构造一个 TaskQueue 对象(初始化内存)。

此时可能会出现这种情况,一个线程执行的顺序如上,执行到第二步时,m_taskQ不等于nullptr了,此时其他线程遇到第一个检查时就可以直接通过并返回指针,造成异常的访问情况

方案三:双重锁定+使用C++11的atomic规定指令执行顺序,使用原子操作可以使得机器指令按顺序执行

#include<bits/stdc++.h>
#include<mutex>
#include<atomic>
using namespace std;
class TaskQueue
{
public:
TaskQueue(const TaskQueue &t)=delete;
TaskQueue& operator=(const TaskQueue& t)=delete;
static TaskQueue* getInstance()
{
TaskQueue* task = m_taskQ.load();
if(task==nullptr){
m_mutex.lock();
task = m_taskQ.load();
if(task==nullptr){
task=new TaskQueue;
m_taskQ.store(task);
}
m_mutex.unlock();
}
return task;
}
void print()
{
cout<<"单例对象的一个成员函数"<<endl;
}
private:
TaskQueue()=default;
//static TaskQueue* m_taskQ;
static mutex m_mutex;
static atomic<TaskQueue*> m_taskQ;

};
//TaskQueue* TaskQueue::m_taskQ=nullptr;
atomic<TaskQueue*> TaskQueue::m_taskQ;
mutex TaskQueue::m_mutex;
int main()
{
TaskQueue* taskQ = TaskQueue::getInstance();
taskQ->print();
return 0;
}

方案四:静态局部对象(懒汉模式多线程下最简单的方案)

原理:如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。

class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue& obj) = delete;
TaskQueue& operator=(const TaskQueue& obj) = delete;
static TaskQueue* getInstance()
{
static TaskQueue taskQ;
return &taskQ;
}
void print()
{
cout << "hello, world!!!" << endl;
}

private:
TaskQueue() = default;
};

int main()
{
TaskQueue* queue = TaskQueue::getInstance();
queue->print();
return 0;
}