小米C++ 面试题及参考答案上(120道面试题覆盖各种类型八股文)
进程和线程的联系和区别
进程是资源分配的基本单位,它拥有自己独立的地址空间、代码段、数据段和堆栈等。线程是进程中的一个执行单元,是 CPU 调度的基本单位。
联系方面,线程是进程的一部分,一个进程可以包含多个线程。它们都用于实现程序的并发执行,共享进程的代码段、数据段等资源(线程共享进程的大部分资源,但也有自己的私有资源,如栈空间)。进程中的线程可以访问进程的全局变量等资源,并且线程的执行依赖于所属的进程。
区别主要体现在以下几点。首先是资源拥有情况,进程有独立的地址空间,不同进程之间的地址空间是相互隔离的。这意味着一个进程无法直接访问另一个进程的内存空间。而线程共享所属进程的地址空间,多个线程可以访问同一块内存区域,这在一定程度上方便了数据共享,但也可能导致数据同步的问题。其次是调度方面,进程的切换开销较大,因为操作系统需要切换进程的地址空间等大量资源。而线程切换的开销相对较小,因为线程共享进程的大部分资源,只需要切换线程的执行上下文,如程序计数器、寄存器等少量资源。再者,从创建和销毁的角度来看,创建一个进程需要为其分配大量的系统资源,包括内存空间、文件描述符等,销毁进程也需要释放这些资源,所以进程的创建和销毁比较复杂且开销大。而线程的创建和销毁相对简单,开销较小,因为它可以共享进程的资源。最后,在并发性上,多个进程可以并发执行,不同进程之间相对独立,通信相对复杂。而多个线程在同一个进程内并发执行,它们之间的通信可以通过共享内存等方式,相对比较简单,但需要注意数据同步和互斥的问题。
进程和线程的应用场景
进程的应用场景:
- 当需要高度的隔离性和安全性时,例如不同的应用程序之间,如浏览器进程和文字处理进程。因为每个进程有自己独立的地址空间,一个进程的崩溃不会影响到其他进程,这在操作系统中用于保障系统的稳定性。如果一个程序出现严重错误导致崩溃,它所在的进程终止,但不会影响其他正在运行的程序。
- 对于需要资源分配独立性的情况,比如不同的服务程序。以服务器环境为例,一个邮件服务器进程和一个 Web 服务器进程,它们分别管理自己的资源,如网络端口、文件系统访问权限等,互不干扰。
线程的应用场景:
- 当程序需要并发执行多个任务并且这些任务之间需要频繁地共享数据时,使用线程比较合适。例如在一个图形处理软件中,一个线程用于接收用户的操作输入,另一个线程用于实时渲染图形,它们共享内存中的图形数据结构,通过合理的同步机制可以高效地协同工作。
- 对于一些计算密集型的任务,可以通过多线程来充分利用多核 CPU 的性能。比如科学计算中的矩阵乘法,将矩阵划分为多个子块,每个线程负责计算一个子块,这样可以加速计算过程。在这种场景下,线程之间共享计算的数据结构,通过共享内存来传递中间结果,相比进程间通信更加高效。
线程的单例模式如何处理多线程
单例模式是一种设计模式,保证一个类只有一个实例,并提供一个全局访问点。在多线程环境下,需要考虑线程安全问题,以确保在多个线程同时访问单例对象时不会创建多个实例。
一种常见的方法是使用双重检查锁定(Double - Checked Locking)。在这种方法中,首先检查单例对象是否已经被创建,如果没有,则加锁再次检查。这样可以减少锁的使用次数,提高性能。代码示例如下:
class Singleton {
public:static Singleton* getInstance() {if (instance == nullptr) {std::lock_guard<std::mutex> lock(mutex);if (instance == nullptr) {instance = new Singleton;}}return instance;}
private:Singleton() {}static Singleton* instance;static std::mutex mutex;
};
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;
在这个示例中,第一次检查if (instance == nullptr)
是在没有加锁的情况下进行的,这是为了避免每次调用getInstance
函数都加锁导致的性能开销。如果instance
已经被创建,就可以直接返回。如果instance
为空,就通过std::lock_guard
来获取互斥锁mutex
,然后再次检查instance
是否为空。这是因为在第一次检查和加锁之间,可能有其他线程已经创建了单例对象。最后,如果instance
仍然为空,就创建一个新的单例对象。
另一种方法是使用静态局部变量。在 C++11 及以后的标准中,静态局部变量的初始化是线程安全的。例如:
class Singleton {
public:static Singleton* getInstance() {static Singleton instance;return &instance;}
private:Singleton() {}
};
在这个示例中,当第一次调用getInstance
函数时,会创建一个静态局部变量instance
,并且 C++ 标准保证这个初始化过程是线程安全的。后续的调用会直接返回这个已经创建的单例对象的地址。
如何创建进程与线程
创建进程
在 C++ 中,可以使用fork
函数(在 Unix/Linux 系统下)来创建一个新的进程。当fork
函数被调用时,操作系统会创建一个新的进程,这个新进程几乎是父进程的一个副本。新进程会继承父进程的代码段、数据段、堆和栈等资源。代码示例如下:
#include <iostream>
#include <unistd.h>
int main() {pid_t pid = fork();if (pid == -1) {// 创建进程出错std::cerr << "Fork failed" << std::endl;return 1;} else if (pid == 0) {// 子进程执行的代码std::cout << "This is the child process." << std::endl;} else {// 父进程执行的代码std::cout << "This is the parent process. Child PID is " << pid << std::endl;}return 0;
}
在这个示例中,fork
函数返回一个进程 ID(pid_t
类型)。如果返回值是-1
,表示创建进程失败。如果返回值是0
,表示当前是子进程。如果返回值大于0
,表示当前是父进程,返回值就是新创建的子进程的 ID。
在 Windows 系统下,可以使用CreateProcess
函数来创建进程。这个函数比fork
函数更复杂,需要传递更多的参数来指定新进程的属性,如可执行文件的路径、命令行参数、进程的安全属性等。示例代码如下:
#include <iostream>
#include <windows.h>
int main() {STARTUPINFO si;PROCESS_INFORMATION pi;ZeroMemory(&si, sizeof(si));si.cb = sizeof(si);ZeroMemory(&pi, sizeof(pi));// 创建一个新的进程,执行notepad.exeif (!CreateProcess(NULL, "notepad.exe", NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) {std::cerr << "CreateProcess failed" << std::endl;return 1;}// 等待新进程结束WaitForSingleObject(pi.hProcess, INFINITE);// 关闭进程和线程句柄CloseHandle(pi.hProcess);CloseHandle(pi.hThread);return 0;
}
在这个示例中,首先初始化了STARTUPINFO
和PROCESS_INFORMATION
结构体,然后使用CreateProcess
函数创建一个新的进程来执行notepad.exe
。如果创建成功,使用WaitForSingleObject
函数等待新进程结束,最后关闭进程和线程的句柄。
创建线程
在 C++ 11 及以后的标准中,可以使用<thread>
头文件中的std::thread
来创建线程。示例代码如下:
#include <iostream>
#include <thread>
void threadFunction() {std::cout << "This is a thread function." << std::endl;
}
int main() {std::thread t(threadFunction);// 等待线程执行完毕t.join();return 0;
}
在这个示例中,首先定义了一个函数threadFunction
,这个函数将在新创建的线程中执行。然后通过std::thread
类创建一个新的线程t
,并将threadFunction
作为参数传递给它。最后使用t.join()
来等待线程执行完毕。如果不调用join
函数,主线程可能会在子线程执行完毕之前结束,导致程序异常退出。
在旧的 C++ 标准或者一些特定的平台上,也可以使用平台相关的线程库来创建线程。例如在 Unix/Linux 系统下,可以使用pthread
库。示例代码如下:
#include <iostream>
#include <pthread.h>
void* threadFunction(void* arg) {std::cout << "This is a pthread function." << std::endl;return NULL;
}
int main() {pthread_t thread;int result = pthread_create(&thread, NULL, threadFunction, NULL);if (result!= 0) {std::cerr << "pthread_create failed" << std::endl;return 1;}// 等待线程执行完毕pthread_join(thread, NULL);return 0;
}
在这个示例中,使用pthread_create
函数创建一个新的线程,传递的参数包括线程标识符pthread_t
类型的变量、线程属性(这里为NULL
表示使用默认属性)、线程函数和线程函数的参数。最后使用pthread_join
函数等待线程执行完毕。
进程线程同步情况,条件变量和信号量的区别
在多进程和多线程编程中,同步是非常重要的,用于协调不同执行单元对共享资源的访问。
条件变量(Condition Variable)
条件变量主要用于线程之间的同步,它允许一个线程等待某个条件满足后再继续执行。条件变量通常和互斥锁一起使用。一个线程可以通过等待条件变量来进入阻塞状态,直到另一个线程通知它条件已经满足。
例如,一个生产者 - 消费者模型中,消费者线程在缓冲区为空时需要等待,直到生产者线程生产了数据并通知消费者线程。使用条件变量可以很好地实现这种同步。代码示例如下:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mutex;
std::condition_variable condition;
std::queue<int> buffer;
const int bufferSize = 10;
void producer() {for (int i = 0; i < 20; ++i) {std::unique_lock<std::mutex> lock(mutex);while (buffer.size() == bufferSize) {condition.wait(lock);}buffer.push(i);std::cout << "Produced: " << i << std::endl;condition.notify_one();}
}
void consumer() {for (int i = 0; i < 20; ++i) {std::unique_lock<std::mutex> lock(mutex);while (buffer.empty()) {condition.wait(lock);}int data = buffer.front();buffer.pop();std::cout << "Consumed: " << data << std::endl;condition.notify_one();}
}
int main() {std::thread producerThread(producer);std::thread consumerThread(consumer);producerThread.join();consumerThread.join();return 0;
}
在这个示例中,std::condition_variable
类型的condition
和std::mutex
类型的mutex
一起用于控制生产者和消费者对共享缓冲区buffer
的访问。当缓冲区满时,生产者线程等待条件变量condition
,当缓冲区为空时,消费者线程等待条件变量。当生产者生产了一个数据或者消费者消费了一个数据后,会通过notify_one
函数来通知等待在条件变量上的一个线程。
条件变量的特点是它是基于某个条件的等待机制。线程等待的是一个条件表达式为真,而不是一个简单的计数或者资源可用性。
信号量(Semaphore)
信号量是一种更通用的同步机制,可以用于进程间或线程间的同步。信号量维护一个计数器,表示可用资源的数量。线程或进程在访问共享资源之前需要先获取信号量,如果信号量的计数器大于 0,则可以获取并将计数器减 1,表示占用了一个资源。如果计数器为 0,则线程或进程会被阻塞,直到信号量的计数器大于 0。
例如,假设有一个资源池,里面有一定数量的资源可以被多个线程使用。可以使用信号量来控制对这些资源的访问。代码示例如下:
#include <iostream>
#include <thread>
#include <semaphore.h>
const int resourceCount = 5;
sem_t semaphore;
void threadFunction() {sem_wait(&semaphore);std::cout << "Thread got a resource." << std::endl;// 模拟使用资源std::this_thread::sleep_for(std::chrono::seconds(1));std::cout << "Thread released a resource." << std::endl;sem_post(&semaphore);
}
int main() {sem_init(&semaphore, 0, resourceCount);std::thread threads[10];for (int i = 0; i < 10; ++i) {threads[i] = std::thread(threadFunction);}for (int i = 0; i < 10; ++i) {threads[i].join();}sem_destroy(&semaphore);return 0;
}
在这个示例中,sem_t
类型的semaphore
用于控制对资源的访问。sem_init
函数用于初始化信号量,第二个参数0
表示信号量是在同一个进程的线程之间共享,第三个参数resourceCount
表示初始的资源数量。sem_wait
函数用于获取信号量,如果信号量计数器为 0,则线程会被阻塞。sem_post
函数用于释放信号量,将计数器加 1。
信号量和条件变量的区别主要有以下几点。首先,信号量基于资源计数,主要用于控制对有限资源的访问,而条件变量基于条件表达式,用于等待某个条件为真。其次,信号量可以用于进程间和线程间的同步,而条件变量主要用于线程间的同步。另外,信号量的操作相对简单直接,获取和释放信号量就是对计数器的操作,而条件变量需要和互斥锁配合使用,等待条件变量时需要先获取互斥锁,并且在等待过程中会自动释放互斥锁,在条件满足被唤醒后又会重新获取互斥锁,这个过程相对复杂。
两个线程循环调用,具有共享数据,使用哪种同步方式。
当两个线程循环调用且存在共享数据时,可以使用多种同步方式。
一种常用的方式是互斥锁(Mutex)。互斥锁可以保证在同一时刻只有一个线程能够访问共享数据。例如,在 C++ 中可以使用std::mutex
。当一个线程想要访问共享数据时,它首先尝试获取互斥锁。如果锁已经被另一个线程持有,那么这个线程就会被阻塞,直到锁被释放。一旦线程获取到锁,就可以安全地访问共享数据,访问结束后再释放锁。这样就可以防止多个线程同时修改共享数据导致的数据不一致问题。
信号量(Semaphore)也是一种选择。信号量维护一个计数器,用于表示可用资源的数量。对于共享数据的访问,可以将信号量的初始值设为 1,这样就相当于一个互斥锁。当一个线程想要访问共享数据时,它对信号量执行wait
操作(如果计数器大于 0,将计数器减 1 并继续执行,否则阻塞),访问结束后执行signal
操作(将计数器加 1)。
条件变量(Condition Variable)结合互斥锁也很有效。条件变量允许线程等待某个特定条件的成立。例如,当共享数据满足某种条件时,一个线程才会去访问它。线程首先获取互斥锁,然后检查条件是否满足,如果不满足就等待条件变量,同时释放互斥锁。当另一个线程修改共享数据使得条件满足时,它可以通过条件变量通知等待的线程,等待的线程被唤醒后重新获取互斥锁并访问共享数据。
以生产者 - 消费者模型为例,有一个共享的缓冲区。生产者线程往缓冲区中放入数据,消费者线程从缓冲区中取出数据。可以使用互斥锁来保证在同一时刻只有一个线程(生产者或消费者)在访问缓冲区。同时使用条件变量,消费者线程在缓冲区为空时等待,生产者线程在缓冲区满时等待。当生产者生产了一个数据后,通过条件变量通知消费者;当消费者消费了一个数据后,也通过条件变量通知生产者。这样就可以实现两个线程对共享缓冲区的安全、高效的循环访问。
多线程之间如何区分?
多线程之间可以从多个方面进行区分。
从线程的执行函数角度来看,每个线程都有自己的入口函数,这个函数定义了线程要执行的任务。通过不同的入口函数可以区分线程。例如,一个线程的入口函数是用于读取文件内容,另一个线程的入口函数是用于处理数据计算,这样就可以根据入口函数的功能来区分这两个线程。
线程 ID 也是区分线程的重要标识。在操作系统中,每个线程都有一个唯一的标识符。在 C++ 中,不同的线程库提供了获取线程 ID 的方法。比如,在pthread
库中,可以使用pthread_self
函数来获取当前线程的 ID。通过比较线程 ID,可以确定不同的线程。
从线程所处理的数据和资源角度区分。不同的线程可能负责处理不同的数据集合或者资源。例如,在一个网络服务器程序中,一个线程负责处理接收客户端的连接请求,它主要操作网络套接字资源;另一个线程负责处理已经连接的客户端的数据传输,它主要处理数据缓冲区等资源。
线程的优先级也可以用于区分。不同的线程可以有不同的优先级设置。优先级高的线程在 CPU 调度时会更优先被执行。例如,在一个实时系统中,负责处理紧急任务的线程可以设置为高优先级,而负责一些后台数据清理的线程可以设置为低优先级。通过查看线程的优先级设置可以区分线程。
另外,线程的创建时间和生命周期也能帮助区分。了解线程是何时创建的,以及它的生命周期阶段(如正在初始化、运行中、等待资源、即将结束等)可以区分不同的线程。比如,先创建的线程可能负责初始化一些全局数据结构,后创建的线程可能依赖这些数据结构进行后续的处理。
进程之间的通信方式?
进程间通信(IPC)有多种方式。
管道(Pipe)是一种简单的进程间通信方式,它主要用于具有亲缘关系(如父子进程)之间的通信。管道是一种半双工的通信方式,数据只能单向流动。在 Unix/Linux 系统中,可以使用pipe
函数来创建管道。管道有一个读端和一个写端,一个进程可以往管道的写端写入数据,另一个进程可以从管道的读端读取数据。例如,一个父进程创建一个管道,然后通过fork
函数创建一个子进程。父进程关闭管道的读端,通过管道的写端向子进程发送数据;子进程关闭管道的写端,从管道的读端接收数据。
命名管道(Named Pipe)克服了管道只能用于亲缘关系进程的限制。它是一种特殊类型的文件,多个不相关的进程可以通过命名管道进行通信。在 Unix/Linux 系统中,可以使用mkfifo
命令或者相关的系统函数来创建命名管道。进程可以像操作普通文件一样打开命名管道进行读写操作,从而实现进程间的通信。
消息队列(Message Queue)是一种比较高级的进程间通信方式。消息队列是一个消息的链表,它存储了由不同进程发送的消息。一个进程可以往消息队列中发送消息,另一个进程可以从消息队列中接收消息。消息队列具有一定的消息格式,并且可以设置消息的优先级等属性。在 Unix/Linux 系统中,System V
消息队列是一种常用的实现,它提供了一组系统调用用于创建、发送和接收消息队列中的消息。
共享内存(Shared Memory)是一种高效的进程间通信方式。多个进程可以共享同一块物理内存区域,这样进程之间可以直接读写共享内存中的数据,而不需要通过内核进行数据复制。但是,共享内存需要注意数据同步的问题,因为多个进程同时访问共享内存可能会导致数据不一致。在 Unix/Linux 系统中,可以使用shmget
、shmat
等系统调用创建和使用共享内存。
信号(Signal)是一种异步的进程间通信方式。一个进程可以向另一个进程发送信号,用于通知某个事件的发生。信号有多种类型,如SIGINT
(中断信号)、SIGTERM
(终止信号)等。接收信号的进程可以定义信号处理函数来处理信号。当一个进程收到信号时,它会暂停当前的执行流程,转而执行信号处理函数。
套接字(Socket)是一种更为通用的进程间通信方式,它不仅可以用于同一台计算机上的进程通信,还可以用于不同计算机之间的进程通信。套接字基于网络协议(如 TCP/IP),可以实现可靠的双向通信。例如,在网络服务器和客户端程序中,服务器进程和客户端进程通过套接字建立连接,然后进行数据的发送和接收。
说一下进程和线程。
进程是操作系统进行资源分配和保护的基本单位。一个进程拥有自己独立的地址空间,包括代码段、数据段、堆和栈等。这意味着不同进程之间的内存是相互隔离的,一个进程不能直接访问另一个进程的内存空间。进程有自己独立的资源,如打开的文件描述符、系统资源配额等。
从操作系统的调度角度来看,进程是一个独立的执行单位。进程的创建和销毁相对复杂,因为它涉及到大量资源的分配和回收。例如,当创建一个进程时,操作系统需要为其分配内存空间,初始化进程控制块等;当销毁一个进程时,需要释放它所占用的所有资源。
线程是进程内部的一个执行单元,是 CPU 调度的基本单位。线程共享所属进程的地址空间,包括代码段、数据段和堆等。这使得线程之间可以方便地共享数据,但同时也需要注意数据同步的问题,因为多个线程同时访问共享数据可能会导致数据不一致。
线程的创建和销毁相对简单,开销比进程小很多。因为线程不需要像进程那样分配独立的地址空间等大量资源,它只需要在所属进程的地址空间内分配一些用于线程执行的资源,如栈空间等。
在并发执行方面,多个进程可以并发执行,它们之间相对独立,通信相对复杂,需要使用进程间通信的机制。而多个线程在一个进程内部并发执行,它们之间的通信可以通过共享内存等简单方式实现,但由于共享数据可能会引发数据同步和互斥的问题。例如,在一个多线程的服务器程序中,多个线程可以同时处理客户端的请求,它们共享服务器的一些全局数据结构,如配置信息、连接池等。
以一个简单的文本编辑器应用程序为例,当用户打开多个文件时,每个文件可以看作是一个独立的进程,它们有自己独立的内存空间,互不干扰。而在一个文件的编辑过程中,如同时进行拼写检查和格式调整,这两个任务可以看作是两个线程,它们共享文件的内容数据,通过合理的同步机制协同工作。
谈谈进程间通信。
进程间通信(IPC)是指在不同进程之间交换数据和信息的机制。
管道是进程间通信的一种基本方式。它是一种单向的数据通道,通常用于具有亲缘关系的进程之间。管道的优点是简单易用,数据传输相对高效。但是它的缺点也很明显,比如管道是半双工的,数据只能单向流动,并且管道的容量有限,如果写入管道的数据超过了管道的容量,写进程会被阻塞。
命名管道在管道的基础上进行了扩展。它是一个有名字的管道文件,不同的进程可以通过文件名来访问它,因此不局限于亲缘关系的进程。命名管道可以在不同的用户进程之间进行通信,增强了管道的通用性。不过,命名管道的读写操作仍然需要遵循一定的规则,比如在读取一个空的命名管道时,读进程会被阻塞,直到有数据写入。
消息队列提供了一种更灵活的通信方式。消息队列中的消息具有一定的格式和优先级,进程可以按照消息的类型或者优先级来发送和接收消息。这种方式使得进程间的通信更加有序和可控。然而,消息队列的实现相对复杂,需要维护消息的链表结构,并且在消息队列满或者空的时候,发送和接收操作也会受到相应的限制。
共享内存是一种高效的进程间通信方式。它允许多个进程直接访问同一块物理内存区域,这样可以大大减少数据复制的开销。但是,共享内存的使用需要谨慎处理数据同步的问题。因为多个进程可以同时访问共享内存,可能会导致数据不一致。通常需要配合使用互斥锁、信号量等同步机制来保证数据的正确性。
信号是一种异步的通信方式。它主要用于通知进程某个事件的发生。信号的处理相对简单,进程可以定义信号处理函数来响应不同类型的信号。不过,信号携带的信息有限,一般只用于简单的事件通知,如进程终止、中断等情况。
套接字通信是一种非常强大的进程间通信方式。它不仅可以用于同一台计算机上的进程通信,还可以用于不同计算机之间的通信。套接字基于网络协议,能够实现可靠的、双向的数据传输。但是,套接字通信的实现相对复杂,需要处理网络连接、协议栈等多个方面的问题。例如,在开发一个网络应用程序时,服务器进程和客户端进程通过套接字建立连接,然后按照协议进行数据的发送和接收。这种通信方式在分布式系统和网络应用中非常常见。
进程线程同步情况,条件变量和信号量的区别。
在进程和线程同步场景中,主要是为了协调对共享资源的访问,避免数据不一致等问题。
条件变量主要用于线程间的同步。它允许一个线程等待某个条件为真后再继续执行。通常会和互斥锁一起配合使用。一个线程在等待条件变量时会进入阻塞状态,当另一个线程改变共享数据使得条件成立后,通过信号来唤醒等待的线程。比如在生产者 - 消费者模型中,消费者线程等待缓冲区非空这个条件,当生产者往缓冲区放入数据后,就可以唤醒消费者线程。条件变量重点关注的是条件是否满足,而不是资源的数量。
信号量则可以用于进程间或线程间的同步。信号量有一个计数器,用于表示可用资源的数量。当一个进程或线程想要访问共享资源时,需要先获取信号量,若计数器大于 0,则可以访问,同时计数器减 1;若计数器为 0,则会被阻塞,直到计数器大于 0。例如,在一个资源池场景中,有多个线程需要访问有限的资源,就可以通过信号量来控制访问。信号量侧重于对资源数量的控制。
两者的区别很明显。从使用场景看,条件变量更多用于等待某个复杂的条件,信号量用于控制资源的访问数量。从实现机制来讲,条件变量依赖于互斥锁,等待过程涉及释放和重新获取互斥锁;信号量的操作主要是对计数器的增减。从适用范围上,条件变量主要用于线程间,信号量可以用于进程和线程。
两个线程循环调用,具有共享数据,使用哪种同步方式。
当两个线程循环调用且存在共享数据时,有多种同步方式可供选择。
互斥锁是一种简单有效的方式。它可以保证在同一时刻只有一个线程能够访问共享数据。例如,C++ 中的std::mutex
。当一个线程想要访问共享数据时,它会尝试获取互斥锁。如果锁已经被另一个线程持有,这个线程就会被阻塞,直到锁被释放。获取到锁的线程可以安全地访问共享数据,访问结束后再释放锁,这样就能防止数据不一致。
信号量也可以使用。如果把信号量的初始值设为 1,就相当于一个互斥锁。一个线程对信号量执行等待操作,若信号量的值大于 0,则可以访问共享数据,同时信号量的值减 1;访问结束后执行信号操作,信号量的值加 1。信号量在一些复杂场景下更灵活,比如可以设置初始值为大于 1 的值来控制同时访问共享资源的线程数量。
条件变量结合互斥锁也是很好的选择。线程先获取互斥锁,然后检查共享数据是否满足某个条件。如果不满足,就等待条件变量,同时释放互斥锁。当另一个线程修改共享数据使条件满足时,会通过条件变量通知等待的线程,等待的线程被唤醒后重新获取互斥锁并访问共享数据。比如在一个读写场景中,读线程等待数据已更新这个条件,写线程更新数据后通知读线程。
共享内存安全吗,有什么措施保证?
共享内存本身是不安全的。因为多个进程或线程可以同时访问共享内存区域,这很容易导致数据不一致、竞争条件等问题。
为了保证共享内存的安全,可以采用多种措施。首先是使用互斥锁。互斥锁可以保证在同一时刻只有一个进程或线程访问共享内存。例如,在 C++ 中可以使用std::mutex
。当一个进程或线程想要访问共享内存时,先获取互斥锁,如果锁已经被占用,就等待,直到获取到锁,访问完成后再释放锁。
信号量也可以用于共享内存的安全控制。通过信号量的计数器来控制访问共享内存的进程或线程数量。比如将信号量初始值设为 1,就可以实现互斥访问。
还可以使用原子操作。原子操作是不可被中断的操作,在多线程或多进程访问共享内存时,可以保证数据的一致性。例如,C++ 中的std::atomic
类型可以用于对共享数据进行原子操作,像原子的加载和存储操作,避免了在操作过程中被其他进程或线程干扰。
另外,使用读写锁也是一种方式。当多个线程或进程对共享内存进行读操作时,可以同时进行;但当有一个线程或进程进行写操作时,需要独占访问。读写锁可以区分读和写操作,提高共享内存的并发访问效率,同时保证数据的安全性。
Linux 中内存分布有哪些,C++ 呢?
在 Linux 系统中,内存分布主要包括以下几个部分。
首先是程序代码段,这部分存储的是可执行程序的机器指令。它是只读的,在程序运行过程中一般不会被修改。
然后是数据段,它又分为已初始化数据段和未初始化数据段。已初始化数据段包含了程序中已经初始化的全局变量和静态变量的值。未初始化数据段主要存储未初始化的全局变量和静态变量,在程序开始运行时会被初始化为 0。
堆是内存分布中的一个重要区域。程序可以通过动态内存分配函数(如malloc
、calloc
等)在堆上分配内存。堆的大小可以在程序运行过程中动态增长,主要用于存储程序运行过程中动态创建的数据结构,如链表、树等。
栈用于存储函数的调用信息,包括函数的参数、局部变量、返回地址等。栈是从高地址向低地址生长的,函数调用时会在栈上分配空间,函数返回时会释放对应的栈空间。
在 C++ 中,内存分布和 Linux 系统内存分布有一定的关联。C++ 对象的存储位置根据其类型和生命周期而定。全局对象和静态对象存储在数据段。动态分配的对象(通过new
操作符分配)存储在堆中。局部对象(在函数内部定义的对象)存储在栈中。另外,C++ 中的常量数据(如const
修饰的全局常量)存储在只读数据区,类似于 Linux 系统中的程序代码段的属性,不能被修改。
虚拟内存与物理内存如何转换?
虚拟内存和物理内存的转换是操作系统内存管理的一个关键部分。
虚拟内存是一种逻辑上的内存空间,每个进程都有自己独立的虚拟内存空间。它为进程提供了一个统一的、连续的地址空间假象,而不管实际物理内存的情况如何。
操作系统通过页表来实现虚拟内存和物理内存的转换。页表是一种数据结构,它存储了虚拟页和物理页之间的映射关系。当 CPU 访问一个虚拟地址时,首先会将虚拟地址分解为页号和页内偏移量。然后通过查找页表,找到对应的物理页号,再结合页内偏移量就可以得到实际的物理地址。
在现代操作系统中,通常采用多级页表来提高页表的查找效率。例如,在 32 位系统中,可能采用二级页表。第一级页表称为页目录表,它的每个表项指向一个二级页表。二级页表的每个表项才指向真正的物理页。
当发生缺页中断时,也就是 CPU 访问的虚拟页没有对应的物理页时,操作系统会进行页面置换操作。操作系统会选择一个物理页(通常是根据一定的页面置换算法,如 LRU 算法),将其内容保存到磁盘(如果被置换的页已经被修改),然后将需要的虚拟页从磁盘加载到这个物理页中,并更新页表的映射关系。
另外,为了提高内存访问效率,操作系统还会采用一些缓存机制,如 TLB(Translation Lookaside Buffer)。TLB 是一种高速缓存,它存储了最近使用的页表项。当 CPU 访问虚拟地址时,首先会在 TLB 中查找对应的映射关系,如果找到就可以直接得到物理地址,大大提高了访问速度。如果在 TLB 中没有找到,才会去查找页表。
页表和缺页中断
页表是操作系统用于实现虚拟内存到物理内存映射的关键数据结构。每个进程都有自己独立的页表,它记录了虚拟地址空间中的页与物理内存中的页框之间的对应关系。虚拟地址空间被划分为大小相等的页,物理内存也被划分为同样大小的页框。
当 CPU 要访问一个虚拟地址时,会将这个虚拟地址分解为页号和页内偏移量。通过查找页表中对应的页号,就能找到该虚拟页对应的物理页框号,再结合页内偏移量就可以确定实际的物理地址。这种映射方式使得每个进程都可以拥有自己独立的、连续的虚拟地址空间,而不必关心物理内存的实际分配情况。
缺页中断是在内存访问过程中出现的一种情况。当 CPU 访问一个虚拟页时,发现该页没有对应的物理页框(也就是该页没有加载到物理内存中),就会触发缺页中断。此时,操作系统会暂停当前进程的执行,转而去处理缺页中断。操作系统会根据一定的页面置换算法,从物理内存中选择一个页面(如果物理内存已满),将其换出到磁盘(如果该页面被修改过,还需要先保存修改后的内容),然后从磁盘中将需要的页面加载到物理内存中,更新页表,建立新的虚拟 - 物理页映射关系,之后再恢复被中断的进程继续执行。
例如,在一个多任务操作系统中,多个进程的虚拟地址空间总和可能远远大于物理内存。通过页表和缺页中断机制,操作系统可以有效地管理内存,将暂时不需要的页面置换到磁盘,使得系统能够在有限的物理内存下运行更多的进程。同时,这种机制也提高了内存的利用率和系统的灵活性,因为进程不需要一次性将所有的数据和代码都加载到物理内存中,只有在实际需要访问某个页面时才会进行加载。
内存管理 / 内存布局
内存管理是操作系统的一个重要功能,它主要涉及对内存的分配、回收和保护等操作。在不同的操作系统和编程语言环境下,内存管理的方式和策略会有所不同。
从操作系统层面看,内存可以分为多个区域。首先是内核空间,这部分内存是操作系统内核使用的,用于存储内核代码、数据结构以及一些内核模块等。它对普通用户进程是不可访问的(处于保护状态),只有在特定的系统调用或者中断处理时,用户进程才能间接访问内核空间的部分数据。
用户空间是提供给应用程序使用的内存区域。在用户空间中,又可以细分为多个部分。代码段是存储可执行程序的机器指令的区域,这个区域通常是只读的,因为程序的指令在运行过程中一般不会被修改。数据段包含已初始化和未初始化的数据。已初始化数据段存放程序中已经初始化的全局变量和静态变量的值,未初始化数据段用于存储未初始化的全局变量和静态变量,在程序启动时这些变量会被初始化为 0。
堆是一块动态分配内存的区域,应用程序可以通过系统调用(如malloc
、calloc
等函数)在堆上申请内存。堆的大小可以动态增长,它主要用于存储在程序运行过程中动态创建的数据结构,比如链表、树等复杂的数据结构。
栈是用于存储函数调用相关信息的区域。当一个函数被调用时,函数的参数、局部变量、返回地址等信息会被压入栈中。栈是从高地址向低地址生长的,函数返回时,对应的栈空间会被自动释放。
在 C++ 语言中,内存布局也有其特点。C++ 对象的存储位置取决于其类型和生命周期。全局对象和静态对象存储在数据段,动态分配的对象(通过new
操作符分配)存储在堆中,局部对象(在函数内部定义的对象)存储在栈中。另外,C++ 中的常量数据(如const
修饰的全局常量)通常存储在只读数据区,类似于操作系统中的代码段的属性,不能被修改。这种内存布局方式有助于合理利用内存资源,并且保证程序的正确运行,同时也方便编译器进行优化和内存管理。
内存泄漏 / 越界和死机情况,如何检查和解决?
内存泄漏检查和解决
内存泄漏是指程序在动态分配内存后,没有正确地释放这些内存,导致内存占用不断增加。在 C++ 中,常见的原因是忘记释放通过new
操作符分配的内存。
检查内存泄漏的方法有多种。一种是使用工具,如 Valgrind(在 Linux 环境下)。Valgrind 可以在程序运行时监控内存的分配和释放情况,它能够检测出内存泄漏的位置和大小。当运行程序时加上 Valgrind 工具,它会输出详细的报告,包括泄漏的内存块是在哪个函数中分配的等信息。
另一种方法是在代码中添加调试信息。可以自己实现一个简单的内存管理系统,在new
和delete
操作符(或者malloc
和free
函数)的基础上进行包装。例如,维护一个已分配内存块的列表,记录每个内存块的大小、分配位置等信息。在程序结束或者适当的时候检查这个列表,看是否有未释放的内存块。
解决内存泄漏问题主要是确保正确地释放内存。在 C++ 中,对于每一个new
操作,都要有一个对应的delete
操作。如果是通过new[]
分配的数组,要使用delete[]
来释放。同时,要注意在异常处理等复杂情况下,也要保证内存能够正常释放。例如,在一个函数中,如果在分配内存后发生异常,应该在异常处理代码中释放已经分配的内存。
内存越界检查和解决
内存越界是指程序访问了超出其合法范围的内存区域。这可能导致程序崩溃或者产生不可预测的结果。
检查内存越界可以使用编译器提供的一些功能。例如,有些编译器可以开启边界检查选项,当访问数组等数据结构时,如果发生越界,编译器会发出警告或者错误信息。
还可以使用工具,如 AddressSanitizer(在 C++ 中)。它可以在运行时检测内存越界访问,并且能够准确地指出越界访问发生的位置。当检测到内存越界时,它会输出详细的信息,包括访问的内存地址、所在的函数等。
解决内存越界问题主要是要正确地使用数组和指针。在访问数组时,要确保索引在合法范围内。对于指针,要确保它指向的是合法的内存区域,并且在使用指针进行算术运算时,不要超出其指向的内存块的边界。同时,在使用 C 风格的字符串函数(如strcpy
等)时,要确保目标缓冲区有足够的空间,避免缓冲区溢出。
死机情况检查和解决
死机可能是由多种原因引起的,包括内存问题、硬件故障、死循环等。
如果是由于内存问题导致死机,如内存泄漏或者内存越界导致系统资源耗尽,可以按照前面提到的方法检查和解决内存问题。
如果是死循环导致死机,可以通过调试工具来检查。在调试器中,可以查看程序的执行流程,看是否有某个线程或者函数陷入了死循环。例如,在多线程程序中,一个线程的死循环可能会导致整个程序看起来像是死机了。可以在代码中适当添加日志输出,记录程序的执行状态,以便在死机时能够分析最后执行的位置。
对于硬件故障导致的死机,这可能需要检查硬件设备,如内存模块是否损坏、硬盘是否有坏道等。可以通过硬件诊断工具来检查硬件的健康状况。
C++ 内存对齐
C++ 中的内存对齐是一种优化策略,它主要是为了提高内存访问的速度。计算机在访问内存时,通常是以字(word)为单位进行的。如果数据存储的位置不符合内存对齐的要求,那么在访问这些数据时,可能需要进行多次内存访问操作,从而降低了访问效率。
内存对齐的规则是基于数据类型的大小和处理器的字长。例如,在 32 位处理器中,字长是 4 字节。对于基本数据类型,如int
(通常是 4 字节),编译器会尽量将其存储在内存地址是 4 的倍数的位置。这是因为这样可以保证在一次内存访问中就能够读取到完整的数据。
对于结构体(struct
)来说,内存对齐规则会更复杂一些。编译器会根据结构体中成员的类型和顺序来进行内存对齐。首先,结构体的第一个成员的地址通常是按照其类型的对齐要求进行对齐的。然后,后续成员的地址要满足其自身类型的对齐要求以及结构体整体的对齐要求。结构体整体的对齐要求通常是其最大成员类型大小的倍数。
例如,假设有一个结构体如下:
struct MyStruct {char c;int i;
};
在这个结构体中,char
类型通常占用 1 字节,int
类型通常占用 4 字节。由于int
类型的对齐要求,编译器会在char
成员后填充 3 个字节,使得int
成员的地址是 4 的倍数。这样整个结构体的大小可能是 8 字节,而不是简单的 1 + 4 = 5 字节。
内存对齐可以通过编译器的选项来控制。有些编译器可以让用户指定是否开启内存对齐以及对齐的字节数。不过,在大多数情况下,编译器会默认开启内存对齐,因为这有助于提高程序的性能。
另外,在某些特殊情况下,如在进行网络协议数据打包或者内存映射文件等操作时,可能需要精确控制数据的存储位置,这时候可能需要关闭内存对齐或者手动调整数据的存储方式,以满足特定的要求。
虚拟内存到物理内存过程,负责这个流程的物理器件叫什么?
在虚拟内存到物理内存的转换过程中,主要是由内存管理单元(MMU,Memory Management Unit)负责。
MMU 是一种硬件设备,它位于 CPU 和物理内存之间。当 CPU 发出一个虚拟地址访问请求时,MMU 会将这个虚拟地址转换为物理地址。它通过查找页表来实现这种转换。页表是存储在内存中的一种数据结构,它记录了虚拟页和物理页之间的映射关系。
MMU 首先会将虚拟地址分解为页号和页内偏移量。然后,根据页号在页表中查找对应的物理页号。这个查找过程可以是直接查找(在简单的页表结构中),也可能是通过多级页表进行查找(在复杂的系统中,如采用二级或多级页表的情况)。找到物理页号后,再结合页内偏移量,就可以得到最终的物理地址。
除了地址转换功能,MMU 还参与了内存保护机制。它可以根据页表中的权限位来判断 CPU 对某个虚拟地址的访问是否合法。例如,如果一个虚拟页被标记为只读,而 CPU 尝试进行写操作,MMU 会触发一个异常,阻止这种非法访问。
在现代计算机系统中,为了提高地址转换的速度,还会有一个与之相关的高速缓存器件,即 TLB(Translation Lookaside Buffer)。TLB 是一种高速缓存,它存储了最近使用的页表项。当 CPU 访问虚拟地址时,首先会在 TLB 中查找对应的映射关系。如果在 TLB 中找到,就可以直接得到物理地址,大大提高了访问速度。如果在 TLB 中没有找到,才会通过 MMU 去查找页表进行地址转换。
Linux 进程地址空间布局
在 Linux 系统中,进程地址空间主要分为以下几个部分。
首先是只读段,这部分包含了程序的代码以及只读数据。代码部分是程序的机器指令,在进程运行过程中是不会被修改的。只读数据包括像字符串常量等,它们被放在这里是为了防止程序意外修改。
然后是数据段,它包含已经初始化的全局变量和静态变量。这些变量在程序启动时就被赋予了初始值,并且在进程的生命周期内可以被修改。数据段中的数据存储是连续的,编译器会根据变量的定义顺序来安排它们在内存中的位置。
接着是未初始化数据段,也称为 BSS 段(Block Started by Symbol)。这里存储的是未初始化的全局变量和静态变量。当进程启动时,操作系统会将这部分内存初始化为零。这种安排可以节省可执行文件的空间,因为不需要为未初始化的变量在文件中存储初始值。
堆是进程地址空间中用于动态分配内存的区域。程序可以通过系统调用如malloc
、calloc
等来在堆上分配内存。堆的大小可以在进程运行时动态增长,它向上生长(从低地址向高地址)。当进程不再需要使用某块堆内存时,需要通过free
函数来释放,否则会导致内存泄漏。
栈用于存储函数调用相关的信息。包括函数的参数、局部变量、返回地址等。栈是从高地址向低地址方向生长的。每当一个函数被调用时,会在栈上为这个函数分配一个栈帧,栈帧中包含了上述的这些信息。当函数返回时,对应的栈帧就会被销毁,栈空间被释放。
另外,还有内核空间,这部分是操作系统内核所使用的地址空间。进程在正常情况下不能直接访问内核空间,但是可以通过系统调用等方式,由操作系统内核来访问内核空间中的数据,以完成如文件读写、进程管理等功能。这种进程地址空间布局有助于系统的稳定性和安全性,同时也方便了程序的开发和内存管理。
Socket 与其他通信方式有什么不同?
Socket 通信与其他通信方式相比,有许多独特之处。
与管道(Pipe)通信不同,管道主要用于具有亲缘关系(如父子进程)之间的单向通信,是一种半双工的通信方式,数据只能在一个方向流动。而 Socket 通信可以实现全双工通信,数据可以在两个方向同时传输。并且 Socket 通信不局限于亲缘关系的进程,它可以用于不同主机上的进程之间的通信,甚至可以跨越不同的网络。
和消息队列(Message Queue)相比,消息队列是在同一台主机上不同进程之间进行消息传递的方式。消息队列有自己的消息格式和优先级等机制,进程需要按照这些规则来发送和接收消息。Socket 通信则更侧重于网络通信,它基于网络协议(如 TCP/IP),可以直接利用网络基础设施进行通信。Socket 通信在传输数据的格式上更加灵活,没有像消息队列那样固定的消息格式要求。
与共享内存(Shared Memory)相比,共享内存是一种高效的进程间通信方式,它允许多个进程直接访问同一块物理内存区域,通过共享内存来交换数据。但是共享内存主要用于同一台计算机上的进程,并且在使用时需要特别注意数据同步和保护的问题。Socket 通信虽然效率可能没有共享内存高(因为涉及网络传输等),但它能够在不同的计算机之间建立通信连接,并且具有良好的网络适应性和安全性。
总的来说,Socket 通信的最大特点是它的通用性和网络适应性。它可以用于不同主机之间的通信,支持多种网络协议,并且能够灵活地进行数据传输,无论是文本、二进制数据还是其他复杂的数据结构,都可以通过 Socket 进行传输。
Tcp 和 Udp 的区别,如何进行可靠传输的(流量控制,拥塞控制)?
Tcp 和 Udp 的区别
TCP(Transmission Control Protocol)和 UDP(User Datagram Protocol)是两种不同的传输层协议。
TCP 是一种面向连接的协议。在数据传输之前,通信双方需要先建立连接。这个连接是通过三次握手来完成的,确保双方都能够正确地接收和发送数据。TCP 提供可靠的数据传输服务,它通过序列号、确认应答、重传机制等来保证数据的完整性。例如,发送方发送的数据段都带有一个序列号,接收方收到数据后会发送确认应答,告知发送方已经正确接收。如果发送方在一定时间内没有收到确认应答,就会重传该数据段。
UDP 是一种无连接的协议。它不需要在发送数据之前建立连接,数据发送方直接将数据报发送给接收方。UDP 不保证数据的完整性和顺序性,数据报可能会丢失、重复或者乱序到达。但是 UDP 的优点是它的传输效率高,因为它不需要像 TCP 那样进行复杂的连接建立和维护过程,以及数据的确认和重传操作。
在应用场景方面,TCP 适合对数据准确性和完整性要求较高的应用,如文件传输、电子邮件等。UDP 则适用于对实时性要求较高,对数据丢失有一定容忍度的应用,如视频直播、在线游戏等。
TCP 的可靠传输(流量控制,拥塞控制)
流量控制
TCP 使用滑动窗口机制来进行流量控制。发送方和接收方都有一个窗口,这个窗口表示可以发送或接收的数据量。接收方会告诉发送方自己的接收窗口大小,发送方根据这个大小来调整自己的发送速度。例如,如果接收方的接收窗口为 0,表示接收方暂时无法接收新的数据,发送方就会停止发送,直到接收方通知它窗口大小变为非零。这样可以避免接收方因为来不及处理过多的数据而导致数据丢失。
拥塞控制
TCP 的拥塞控制主要是为了避免网络拥塞。它有多种算法,其中一个典型的是慢启动、拥塞避免、快重传和快恢复算法。
在慢启动阶段,TCP 连接刚建立时,发送方会以较小的拥塞窗口(cwnd)开始发送数据,通常初始值为 1 个最大报文段长度(MSS)。每次收到一个确认应答,拥塞窗口就会加倍。这样可以快速探测网络的可用带宽。
当拥塞窗口达到一个阈值(ssthresh)时,就进入拥塞避免阶段。在这个阶段,拥塞窗口不再是加倍增长,而是线性增长,以避免网络拥塞。
快重传是指当接收方收到一个失序的数据段时,会立即发送重复的确认应答。当发送方收到三个重复的确认应答时,就会认为数据段丢失,而不需要等待超时就立即重传该数据段。
快恢复是在快重传之后进行的。当发送方收到三个重复的确认应答后,会将慢启动阈值(ssthresh)设置为当前拥塞窗口的一半,然后直接进入拥塞避免阶段,而不是重新进入慢启动阶段。这是因为发送方认为网络可能没有发生严重的拥塞,只是丢失了个别数据段,所以可以直接以较合理的速度继续发送数据。
为什么快速恢复是直接进入拥塞避免,而不是慢启动开始,有思考过吗?
在 TCP 的拥塞控制机制中,快速恢复直接进入拥塞避免阶段而不是慢启动阶段,是基于对网络状态的合理推测和高效利用网络资源的考虑。
当发送方收到三个重复的确认应答触发快重传后,这意味着网络可能并没有出现严重的拥塞情况。这种重复的确认应答很可能是因为个别数据段丢失导致的,而不是网络带宽被完全占用或者出现了严重的链路故障。如果此时重新进入慢启动阶段,发送方会从一个较小的拥塞窗口开始重新探测网络的可用带宽,这会导致传输效率降低。
直接进入拥塞避免阶段可以让发送方在一个相对合理的窗口大小下继续发送数据。因为在快重传之前,发送方已经通过之前的慢启动和拥塞避免阶段对网络的大致带宽有了一定的了解。将慢启动阈值(ssthresh)设置为当前拥塞窗口的一半后,发送方可以在这个基础上以线性的方式增加拥塞窗口,继续探测网络的剩余可用带宽,同时又不会像慢启动阶段那样过于激进地增加发送速率,从而避免可能出现的网络拥塞。
这种方式可以在保证一定程度的数据传输效率的同时,对网络的轻微拥塞情况(如个别数据段丢失)进行快速恢复,使得 TCP 连接能够更快地回到一个稳定的传输状态,有效地利用网络资源,减少因频繁的慢启动过程而导致的传输延迟。
I/O 多路复用
I/O 多路复用是一种高效的 I/O 处理机制,它可以让一个进程同时监听多个 I/O 事件,提高 I/O 的处理效率。
在传统的 I/O 模型中,如阻塞 I/O 模型,如果一个进程要处理多个 I/O 源(比如多个文件描述符或者多个网络连接),它需要为每个 I/O 源单独创建一个线程或者进程,这样会消耗大量的系统资源,并且在处理多个 I/O 源时效率较低。
I/O 多路复用通过使用一个特殊的系统调用(如select
、poll
或者epoll
等)来实现。这些系统调用可以让进程同时监听多个文件描述符(包括网络套接字等)的 I/O 状态变化。
以select
为例,进程可以将多个文件描述符添加到一个select
监听集合中。select
会阻塞等待,直到这个集合中的某个文件描述符有 I/O 事件发生(如可读、可写或者出现异常等)。当有事件发生时,select
会返回,然后进程可以通过遍历文件描述符集合来确定具体是哪个文件描述符发生了事件,并进行相应的处理。
poll
的工作原理与select
类似,但是在一些细节上有所不同。poll
使用一个结构体数组来表示文件描述符集合,并且在处理大量文件描述符时可能会比select
更高效一些。
epoll
是一种更高级的 I/O 多路复用机制,主要在 Linux 系统中使用。epoll
通过在内核中维护一个事件表来记录文件描述符的 I/O 事件状态。进程可以通过epoll_ctl
函数向这个事件表中添加、修改或者删除文件描述符。当有 I/O 事件发生时,epoll_wait
函数会返回,并且只返回有事件发生的文件描述符,这样就避免了像select
和poll
那样需要遍历整个文件描述符集合的过程,大大提高了 I/O 处理的效率。
I/O 多路复用在网络服务器等需要同时处理大量客户端连接的场景中非常有用。通过使用 I/O 多路复用,服务器可以用一个或少量的进程来高效地处理多个客户端的 I/O 请求,减少了系统资源的消耗,同时提高了系统的并发处理能力。
IO 多路复用的区别
IO 多路复用主要有 select、poll 和 epoll 等机制,它们之间存在一些区别。
select 机制有文件描述符数量限制,一般在不同操作系统下有不同的上限,这是因为它是通过固定长度的数组来存储文件描述符集合。当有大量文件描述符需要监听时,性能会下降,因为每次调用 select 都需要遍历整个文件描述符集合来检查哪些文件描述符就绪。select 的参数类型使用的是 fd_set 结构体,它是一个位掩码,操作起来相对复杂。并且,当 select 返回后,需要再次遍历文件描述符集合来确定具体是哪些文件描述符发生了事件。
poll 机制和 select 类似,不过它没有文件描述符数量的严格限制,它使用的是 pollfd 结构体数组来存储文件描述符相关信息。这使得它在处理大量文件描述符时相对 select 更灵活一些。在每次调用 poll 后,也需要遍历整个数组来确定就绪的文件描述符,这在一定程度上也会影响效率,但是在某些场景下比 select 更具优势。
epoll 是一种更高效的 IO 多路复用机制。它通过在内核中维护一个事件表,使用红黑树来管理文件描述符,这种数据结构使得添加、删除和查找文件描述符的操作更加高效。epoll 有两种工作模式:水平触发(LT)和边缘触发(ET)。水平触发模式下,只要文件描述符满足可读或可写条件,就会一直触发事件通知;边缘触发模式下,只有在文件描述符状态发生变化时才会触发事件通知。当 epoll_wait 返回时,它只返回有事件发生的文件描述符,不需要像 select 和 poll 那样遍历所有文件描述符,因此在高并发场景下效率更高。
IO 多路复用 select 和 poll 的区别
select 和 poll 在功能上有相似之处,但也有一些明显的区别。
从文件描述符数量限制方面看,select 有文件描述符数量限制,这个限制通常由操作系统决定,而且相对较小。例如,在一些旧版本的 Linux 系统中,这个限制可能是 1024。这是因为 select 使用固定大小的位掩码来表示文件描述符集合。而 poll 没有严格的文件描述符数量限制,它是通过一个结构体数组来存储文件描述符信息,数组的大小可以根据实际需要动态分配,所以理论上可以处理更多的文件描述符。
在数据结构和操作方面,select 使用 fd_set 结构体来存储文件描述符集合,这是一个位掩码结构。对文件描述符集合的操作(如添加、删除)相对复杂,需要使用特定的宏来操作。例如,使用 FD_SET 来添加文件描述符到集合中,FD_CLR 来删除文件描述符。poll 使用 pollfd 结构体数组,每个结构体包含文件描述符、事件类型(如可读、可写)和事件发生状态等信息。对 pollfd 数组的操作更直观,直接修改数组元素中的字段即可。
当检查文件描述符状态时,select 和 poll 都需要遍历整个文件描述符集合来确定哪些文件描述符就绪。但是由于 select 的位掩码结构和文件描述符数量限制,在大量文件描述符的情况下,性能下降可能更明显。而 poll 虽然也需要遍历,但由于其数据结构的灵活性,在一定程度上可以更好地处理较多文件描述符的情况。
Tcp 3 次握手和四次挥手,为什么是四次挥手,同步分节里面有什么?
TCP 的三次握手用于建立连接。首先,客户端发送一个带有 SYN(同步序列号)标志的 TCP 报文段,这个报文段中包含了客户端的初始序列号,此时客户端进入 SYN_SENT 状态。服务器收到这个报文段后,会返回一个带有 SYN 和 ACK(确认)标志的报文段,这个报文段中的 SYN 标志表示服务器也发送自己的初始序列号,ACK 标志用于确认收到客户端的序列号,服务器进入 SYN_RCVD 状态。最后,客户端收到服务器的报文段后,发送一个带有 ACK 标志的报文段来确认收到服务器的序列号,此时客户端和服务器都进入 ESTABLISHED 状态,连接建立成功。
TCP 的四次挥手用于断开连接。首先,主动关闭方(假设是客户端)发送一个带有 FIN(结束)标志的报文段,表示自己没有数据要发送了,客户端进入 FIN_WAIT_1 状态。服务器收到这个 FIN 报文段后,会返回一个 ACK 报文段,确认收到客户端的 FIN,此时服务器进入 CLOSE_WAIT 状态,客户端进入 FIN_WAIT_2 状态。接着,服务器如果也没有数据要发送了,会发送一个带有 FIN 标志的报文段,服务器进入 LAST_ACK 状态。最后,客户端收到服务器的 FIN 报文段后,发送一个 ACK 报文段来确认收到,此时客户端进入 TIME_WAIT 状态,服务器进入 CLOSED 状态,经过一段时间后客户端也进入 CLOSED 状态,连接彻底断开。
之所以是四次挥手,是因为 TCP 是全双工通信协议。当一方(如客户端)发送 FIN 表示不再发送数据时,另一方(如服务器)可能还有数据要发送。所以服务器收到 FIN 后先回复 ACK,然后等自己的数据发送完后再发送 FIN。这就需要四个报文段来完成连接的关闭。
在同步分节(带有 SYN 标志的报文段)中,包含了初始序列号。这个序列号对于 TCP 的可靠传输非常重要,它用于对数据进行编号,接收方通过确认应答机制,根据序列号来确认收到的数据是否完整、是否有重复等。同时,在握手过程中,双方交换序列号,使得双方能够对后续传输的数据进行正确的排序和确认。
Time_wait 状态,为什么?什么时候?那个端产生的,可不可以一个 msl?
TIME_WAIT 状态是 TCP 连接终止过程中的一个重要状态。当主动关闭连接的一方(如客户端)发送最后一个 ACK 报文段后,会进入 TIME_WAIT 状态。
之所以要有 TIME_WAIT 状态,主要是为了确保最后一个 ACK 能够被对方收到,防止因为网络延迟等原因导致对方重传 FIN 报文段而自己已经关闭连接无法响应。在这个状态下,主动关闭方会等待一段时间,这个时间通常是 2 倍的 MSL(Maximum Segment Lifetime,报文段最大生存时间)。MSL 是一个 TCP 报文段在网络中的最长存活时间,不同的操作系统可能有不同的定义,一般为 30 秒到 2 分钟左右。
在 TIME_WAIT 状态期间,主动关闭方会占用本地的端口和 IP 地址组合。这是因为如果立即释放这个端口和 IP 地址组合,新的连接可能会收到旧连接残留的报文段,导致数据混乱。
这个状态是由主动关闭连接的一端产生的。例如,在客户端主动发起关闭连接的情况下,客户端会进入 TIME_WAIT 状态。
不能将 TIME_WAIT 状态的等待时间缩短为一个 MSL。因为如果只等待一个 MSL,可能会出现以下情况:对方重传的 FIN 报文段在一个 MSL 时间内到达,而自己已经关闭了连接,无法正确处理这个 FIN 报文段,从而导致对方无法正常关闭连接,出现连接半关闭的情况,影响 TCP 连接的正常关闭流程。
Time_wait 状态情况下产生地址和端口占用,怎么解决(socket 中的 SO_REUSEADDR)?
在 TCP 连接处于 TIME_WAIT 状态时,会出现本地地址和端口被占用的情况,这可能会影响新连接的建立。可以使用socket
中的SO_REUSEADDR
选项来解决这个问题。
SO_REUSEADDR
是一个套接字选项,它允许在一定条件下重用本地地址和端口。当设置了这个选项后,即使本地地址和端口处于 TIME_WAIT 状态,也可以将其绑定到新的套接字上,用于建立新的连接。
在服务器端,特别是对于一些需要频繁重启的服务器程序,这个选项非常有用。例如,在开发一个网络服务器时,每次修改代码后重启服务器,如果没有设置SO_REUSEADDR
,在 TIME_WAIT 状态的端口可能无法被新的服务器进程使用,导致无法启动或者出现绑定错误。
需要注意的是,使用SO_REUSEADDR
也可能会带来一些潜在的问题。例如,在某些情况下可能会收到旧连接的数据,因为新的连接复用了处于 TIME_WAIT 状态的地址和端口。但是在一些特定的应用场景下,如服务器快速重启或者需要在同一端口上同时运行多个服务实例(需要谨慎设计以避免端口冲突),SO_REUSEADDR
的好处可能大于潜在的风险。在具体使用时,需要根据实际应用场景来权衡是否使用这个选项。
介绍一下 TCP/UDP/TLS/SSL
TCP(Transmission Control Protocol)
TCP 是一种面向连接的、可靠的传输层协议。它提供全双工通信,这意味着数据可以在两个方向同时传输。在传输数据之前,通信双方需要通过三次握手建立连接。TCP 通过序列号、确认应答、重传机制等来确保数据的可靠传输。例如,发送方为每个发送的数据段分配一个序列号,接收方收到数据后会发送确认应答,告知发送方已正确接收。如果发送方在一定时间内未收到确认应答,就会重传该数据段。
TCP 的应用场景包括文件传输、电子邮件等对数据准确性和完整性要求很高的服务。它将数据看作是字节流,会对数据进行分段、编号和重组,以适应网络的传输能力和接收方的处理能力。
UDP(User Datagram Protocol)
UDP 是一种无连接的传输层协议。它不需要像 TCP 那样在传输数据前建立连接,数据发送方直接将数据报发送给接收方。UDP 不保证数据的顺序性、完整性和可靠性。数据报在传输过程中可能会丢失、重复或者乱序到达。
不过,UDP 具有传输效率高的优点,因为它省略了复杂的连接建立和维护过程以及数据确认和重传操作。UDP 适用于对实时性要求较高、对数据丢失有一定容忍度的应用,如视频直播、在线游戏等。UDP 将数据封装成一个个独立的数据报进行发送,每个数据报有自己的长度和校验和。
TLS(Transport Layer Security)和 SSL(Secure Sockets Layer)
TLS 和 SSL 主要用于在网络通信中提供安全加密机制。它们在传输层之上,应用层之下工作,对应用层的数据进行加密和认证。
SSL 是早期的安全协议,TLS 是 SSL 的继任者,它们的目的都是为了确保数据在网络传输过程中的保密性、完整性和身份验证。通过使用公钥和私钥加密技术,TLS/SSL 在客户端和服务器之间建立安全通道。例如,在 HTTPS 协议中,TLS/SSL 用于加密 HTTP 请求和响应,防止中间人窃取或篡改数据。在建立安全连接时,客户端和服务器会进行密钥交换和身份验证过程,确定双方的身份合法后,才会开始加密的数据传输。
TCP 的握手挥手过程?(详细)TCP 为什么要连接?TCP 建立连接这里你是怎么理解的?
TCP 握手过程
TCP 的三次握手用于建立连接。首先,客户端发送一个带有 SYN(同步序列号)标志的 TCP 报文段,这个报文段包含客户端的初始序列号(假设为 ISN1),此时客户端进入 SYN - SENT 状态。这一步就像是客户端在向服务器打招呼,说 “我想和你建立连接,这是我的初始序列号”。
然后,服务器收到这个报文段后,会返回一个带有 SYN 和 ACK(确认)标志的报文段。这个报文段中的 SYN 标志表示服务器也发送自己的初始序列号(假设为 ISN2),ACK 标志用于确认收到客户端的序列号,并且确认号为 ISN1 + 1。服务器进入 SYN - RCVD 状态。这相当于服务器回应客户端:“我收到你的请求了,这是我的初始序列号,我也同意和你建立连接”。
最后,客户端收到服务器的报文段后,发送一个带有 ACK 标志的报文段来确认收到服务器的序列号,确认号为 ISN2 + 1,此时客户端和服务器都进入 ESTABLISHED 状态,连接建立成功。这一步是客户端再次确认收到服务器的响应,表明双方都已经准备好进行数据传输。
TCP 挥手过程
TCP 的四次挥手用于断开连接。首先,主动关闭方(假设是客户端)发送一个带有 FIN(结束)标志的报文段,表示自己没有数据要发送了,客户端进入 FIN - WAIT_1 状态。这就好比客户端说 “我这边数据发送完了,我们可以准备断开连接了”。
服务器收到这个 FIN 报文段后,会返回一个 ACK 报文段,确认收到客户端的 FIN,此时服务器进入 CLOSE - WAIT 状态,客户端进入 FIN - WAIT_2 状态。这表示服务器知道客户端想要断开连接,先回应一个确认,并且服务器可能还有数据要发送。
接着,服务器如果也没有数据要发送了,会发送一个带有 FIN 标志的报文段,服务器进入 LAST - ACK 状态。这是服务器在自己的数据发送完后也表示要断开连接。
最后,客户端收到服务器的 FIN 报文段后,发送一个 ACK 报文段来确认收到,此时客户端进入 TIME - WAIT 状态,服务器进入 CLOSED 状态。经过一段时间(通常是 2 倍的 MSL,MSL 是报文段最大生存时间)后,客户端也进入 CLOSED 状态,连接彻底断开。这一步是为了确保最后一个 ACK 能被服务器收到,防止因为网络延迟等原因导致服务器重传 FIN 而客户端已经关闭无法响应。
TCP 为什么要连接?
TCP 建立连接主要是为了提供可靠的通信服务。通过连接的建立,双方可以协商一些通信参数,如初始序列号。这使得发送方和接收方能够对传输的数据进行正确的编号、排序和确认。建立连接还可以让通信双方预留资源,例如缓存空间等,用于存储和处理即将到来的数据。
TCP 建立连接的理解
TCP 建立连接可以看作是双方在通信前的一种约定和准备。就像两个人要进行一场重要的对话,首先需要互相打招呼,确认对方能够听到自己说话(三次握手)。这个过程确定了双方的身份和通信的起点(初始序列号),为后续可靠的数据传输打下基础。通过交换序列号,双方能够跟踪数据的发送和接收情况,确保数据不会丢失、重复或者乱序。而且,建立连接也让网络中的设备(如路由器等)知道这两个端点之间正在进行通信,在一定程度上可以对这个连接的数据进行优化和管理。
半连接队列?全连接队列?
在 TCP 服务器接收客户端连接请求的过程中,涉及半连接队列和全连接队列。
半连接队列也称为 SYN 队列。当服务器收到客户端的第一个带有 SYN 标志的连接请求报文段时,会将这个请求放入半连接队列中。这个队列主要用于存储已经发送了 SYN 请求,但还没有完成三次握手的连接。服务器会为每个在半连接队列中的请求启动一个定时器,等待客户端发送的 ACK 报文段。如果在定时器超时之前没有收到 ACK,服务器会认为这个连接请求是无效的,将其从半连接队列中移除。
全连接队列也称为 ESTABLISHED 队列。当服务器收到客户端的最后一个 ACK 报文段,完成三次握手后,会将这个连接从半连接队列移到全连接队列中。这个队列中的连接是已经成功建立的 TCP 连接,服务器可以从这个队列中取出连接,为其分配资源,如为这个连接分配一个线程或者进程来处理后续的数据传输等。
半连接队列和全连接队列的大小对于服务器的性能和安全性都有重要影响。如果半连接队列过小,在遭受 SYN Flood 攻击(攻击者大量发送虚假的 SYN 请求来占用服务器资源)时,服务器可能会因为无法处理大量的半连接请求而拒绝正常的连接请求。如果全连接队列过小,可能会导致已经建立的连接无法及时得到服务,影响服务器的服务质量。
TCP 流量控制和拥塞控制?
TCP 流量控制
TCP 流量控制是为了防止发送方发送数据的速度过快,导致接收方来不及接收和处理。它主要通过滑动窗口机制来实现。
接收方会在发送给发送方的 TCP 报文段中包含一个窗口大小字段,这个窗口大小表示接收方当前能够接收的数据量。发送方会根据这个窗口大小来调整自己的发送速度。例如,如果接收方的窗口大小为 0,表示接收方暂时无法接收新的数据,发送方就会停止发送,直到接收方通知它窗口大小变为非零。
发送方有一个发送窗口,它的大小由接收方的接收窗口大小和网络拥塞情况等因素决定。发送窗口内的数据可以被发送,发送方会不断地根据接收方的窗口更新信息来调整发送窗口的大小。这样可以确保发送的数据量不会超过接收方的处理能力,避免数据丢失。
TCP 拥塞控制
TCP 拥塞控制是为了避免网络出现拥塞。它的主要目标是在网络出现拥塞时,降低发送方的发送速率,防止网络过载。
TCP 拥塞控制主要包括慢启动、拥塞避免、快重传和快恢复等机制。
在慢启动阶段,TCP 连接刚建立时,发送方会以较小的拥塞窗口(cwnd)开始发送数据,通常初始值为 1 个最大报文段长度(MSS)。每次收到一个确认应答,拥塞窗口就会加倍。这样可以快速探测网络的可用带宽。
当拥塞窗口达到一个阈值(ssthresh)时,就进入拥塞避免阶段。在这个阶段,拥塞窗口不再是加倍增长,而是线性增长,以避免网络拥塞。
快重传是指当接收方收到一个失序的数据段时,会立即发送重复的确认应答。当发送方收到三个重复的确认应答时,就会认为数据段丢失,而不需要等待超时就立即重传该数据段。
快恢复是在快重传之后进行的。当发送方收到三个重复的确认应答后,会将慢启动阈值(ssthresh)设置为当前拥塞窗口的一半,然后直接进入拥塞避免阶段,而不是重新进入慢启动阶段。这是因为发送方认为网络可能没有发生严重的拥塞,只是丢失了个别数据段,所以可以直接以较合理的速度继续发送数据。
UDP 为什么是不可靠的?bind 和 connect 对于 UDP 的作用是什么?
UDP 为什么是不可靠的?
UDP 是不可靠的主要是因为它没有像 TCP 那样的复杂机制来确保数据的完整性、顺序性和准确性。UDP 在发送数据报时,不进行连接建立过程,发送方直接将数据报发送给接收方。
UDP 没有序列号、确认应答和重传机制。这意味着数据报在网络传输过程中可能会丢失,而发送方不会知道数据报是否成功到达接收方。同样,UDP 也不保证数据报的顺序,接收方可能会收到乱序的数据报。此外,UDP 的数据报可能会因为网络中的错误而被篡改,并且没有机制来检测和纠正这种篡改。
不过,UDP 的这种不可靠性在某些应用场景下反而成为优势。例如,在实时性要求很高的应用中,如视频直播和在线游戏,稍微的数据丢失或者乱序对于用户体验的影响相对较小,而 UDP 的高效传输可以保证数据能够及时发送和接收,减少延迟。
bind 和 connect 对于 UDP 的作用是什么?
bind 的作用
在 UDP 中,bind 函数主要用于将 UDP 套接字与一个本地 IP 地址和端口号绑定。这个操作使得 UDP 套接字能够接收发送到这个特定 IP 地址和端口的 UDP 数据报。
当一个 UDP 服务器程序启动时,通常会使用 bind 函数将服务器套接字绑定到一个知名端口(well - known port)上,这样客户端就能够知道向哪个端口发送请求。例如,一个 DNS 服务器会将自己绑定到端口 53,这样其他主机在需要进行域名解析时,就可以向这个端口发送 UDP 数据报。
connect 的作用
在 UDP 中,connect 函数的作用和在 TCP 中有一些不同。在 UDP 中使用 connect 函数主要是为了指定通信的对方端点(IP 地址和端口号)。这使得后续的 UDP 发送和接收操作可以更方便地进行。
当 UDP 套接字使用 connect 函数连接到一个远程端点后,就可以使用 send 和 recv 函数来发送和接收数据报,就好像在和这个远程端点进行一对一的通信。不过,与 TCP 不同的是,UDP 的 connect 操作不会像 TCP 那样进行连接建立过程,它只是在本地记录了通信对方的端点信息,方便数据报的发送和接收。而且,UDP 仍然可以向其他未连接的端点发送数据报,只要知道对方的 IP 地址和端口号。
NAT 是什么?底层实现原理?
NAT(Network Address Translation)即网络地址转换。它主要用于在 IP 网络中,将私有 IP 地址转换为公有 IP 地址,或者进行 IP 地址和端口号的转换,从而使得多个设备可以共享一个或少量的公有 IP 地址访问互联网。
在一个局域网中,内部设备通常使用私有 IP 地址,这些地址在互联网上是不可直接路由的。当内部设备(如家庭网络中的计算机)需要访问互联网时,NAT 设备(如路由器)会将内部设备的私有 IP 地址和端口号转换为一个公有 IP 地址和一个新的端口号。这个转换后的公有 IP 地址和端口号组合用于在互联网上进行通信。
从底层实现原理来看,NAT 设备维护一个转换表。当内部设备发送数据包到互联网时,NAT 设备会检查数据包的源 IP 地址和源端口号,然后从可用的公有 IP 地址池中选择一个公有 IP 地址,并为这个连接分配一个新的端口号。同时,它会在转换表中记录这个映射关系,包括内部设备的私有 IP 地址和端口号与转换后的公有 IP 地址和端口号。
当互联网上的服务器返回数据包时,NAT 设备会根据数据包的目的 IP 地址(即 NAT 设备的公有 IP 地址)和目的端口号,查找转换表,找到对应的内部设备的私有 IP 地址和端口号,然后将数据包转发给内部设备。这样就实现了内部设备通过共享公有 IP 地址来访问互联网的功能。
例如,在一个企业网络中,有许多内部计算机使用私有 IP 地址。当这些计算机访问外部网站时,企业的 NAT 路由器会为每一个连接分配一个唯一的端口号,并将数据包的源 IP 地址转换为路由器的公有 IP 地址。外部网站返回的数据通过路由器时,路由器根据端口号和转换表将数据转发到正确的内部计算机。
C++ 多态实现方式。
C++ 中的多态主要有两种实现方式:编译时多态和运行时多态。
编译时多态是通过函数重载和模板来实现的。函数重载允许在同一个作用域内定义多个同名函数,只要它们的参数列表不同(参数个数、类型或者顺序不同)。当调用这个重载函数时,编译器会根据实参的类型和数量来确定调用哪一个具体的函数。例如,定义了两个名为add
的函数,一个接受两个整数参数,另一个接受两个浮点数参数。当使用整数调用add
函数时,编译器会选择接受整数参数的add
函数进行编译。
模板也是实现编译时多态的重要方式。模板可以创建泛型程序,它允许编写与类型无关的代码。例如,定义一个函数模板或者类模板。函数模板可以根据调用时传入的实际类型生成对应的函数版本。以template <typename T> T add(T a, T b)
为例,当传入整数时,编译器会生成一个处理整数相加的函数版本;当传入浮点数时,会生成处理浮点数相加的函数版本。
运行时多态是通过虚函数来实现的。在基类中定义虚函数,然后在派生类中重写这个虚函数。当通过基类指针或引用调用这个虚函数时,实际调用的是派生类中重写后的函数。例如,有一个基类Shape
,其中有一个虚函数draw
。派生类Circle
和Rectangle
分别重写了draw
函数。当通过Shape*
指针指向Circle
或Rectangle
对象并调用draw
函数时,会根据指针所指向的实际对象类型来调用对应的draw
函数版本。这是因为每个包含虚函数的类对象在内存中都有一个虚函数表(vtable),虚函数表中存储了虚函数的地址。当通过基类指针或引用调用虚函数时,会根据对象的虚函数表来确定实际调用的函数。
C++ 面向对象的优势。
C++ 面向对象编程有许多优势。
首先是代码的封装性。通过将数据和操作数据的函数封装在类中,可以隐藏数据的实现细节。例如,定义一个BankAccount
类,将账户余额等数据成员设置为私有,外部代码无法直接访问这些数据。同时提供公共的成员函数如deposit
(存款)和withdraw
(取款)来操作账户余额。这样可以保证数据的安全性和完整性,防止外部代码随意修改数据,并且在需要修改数据的存储方式或者操作逻辑时,只需要修改类的内部实现,而不会影响到使用这个类的其他代码。
其次是继承性带来的代码复用。继承允许创建一个新类(派生类),它可以继承基类的属性和方法。例如,有一个基类Vehicle
,它有一些通用的属性如速度、颜色等和方法如start
(启动)和stop
(停止)。然后可以创建派生类Car
和Motorcycle
,它们继承了Vehicle
的属性和方法,并且可以添加自己特有的属性和方法,如Car
可以有车门数量这个属性,Motorcycle
可以有挡位数量这个属性。这样就不需要在Car
和Motorcycle
中重复编写Vehicle
已经有的启动和停止等方法,提高了代码的复用率。
面向对象编程还提供了多态性,使得程序更加灵活和易于扩展。以图形绘制系统为例,有一个基类Shape
和多个派生类如Circle
、Rectangle
等。通过多态,可以用一个统一的接口来处理不同形状的绘制。例如,定义一个函数drawShape(Shape* shape)
,它可以接受任何派生自Shape
的对象指针,然后根据对象的实际类型调用相应的绘制方法。这样在添加新的形状类时,只需要让它继承自Shape
并实现自己的绘制方法,就可以很容易地集成到现有的绘制系统中,而不需要修改大量的现有代码。
C++ 面向对象的三种特性?(每条详细说说)
封装性
封装是将数据和操作数据的函数组合在一起,形成一个类,并且可以控制对数据的访问权限。在 C++ 中,可以通过访问控制符(如public
、private
和protected
)来实现封装。
private
访问控制符用于隐藏类的内部数据和函数,只有类的成员函数或者友元函数可以访问。例如,在一个Person
类中,将年龄这个数据成员设置为private
,这样外部代码就不能直接修改年龄。同时提供public
的成员函数如setAge
和getAge
来间接访问和修改年龄。这种方式可以确保数据的完整性和安全性,防止外部代码对数据进行不恰当的操作。
public
访问控制符用于定义类的接口,这些成员函数和数据可以被外部代码访问。例如,Person
类中的getName
函数可以是public
的,这样外部代码可以获取人的名字。
protected
访问控制符主要用于继承场景。它允许派生类访问基类的受保护成员,但是外部类不能访问。这在继承体系中有助于实现代码的复用和扩展。
继承性
继承是一种创建新类(派生类)的方式,派生类可以继承基类的属性和方法。例如,有一个基类Animal
,它有属性如体重、颜色等和方法如eat
(进食)和sleep
(睡觉)。可以创建一个派生类Dog
,Dog
继承了Animal
的体重、颜色等属性和eat
、sleep
等方法,并且还可以添加自己特有的属性如品种和方法如bark
(吠叫)。
继承可以分为单继承(一个派生类继承一个基类)和多继承(一个派生类继承多个基类)。在单继承中,派生类和基类之间形成了一种层次关系,这种关系使得代码更加清晰和易于理解。在多继承中,虽然可以实现更复杂的功能复用,但也可能会带来一些问题,如命名冲突和菱形继承问题。
通过继承,派生类可以在基类的基础上进行扩展,同时继承也体现了一种 “是一种” 的关系。例如,Dog
是一种Animal
,这种关系符合现实世界中的分类逻辑,有助于构建更加合理的软件系统。
多态性
多态是指同一个操作作用于不同的对象,可以有不同的行为。在 C++ 中,多态主要通过虚函数来实现。
例如,有一个基类Shape
,其中定义了一个虚函数draw
。然后有两个派生类Circle
和Rectangle
,它们都重写了draw
函数。当通过基类指针或引用调用draw
函数时,实际调用的是指针或引用所指向对象的draw
函数版本。
多态性使得程序可以更加灵活地处理不同类型的对象。在设计模式中,多态性被广泛应用。例如,在工厂模式中,可以通过一个工厂函数返回不同类型的对象,这些对象都继承自一个基类。外部代码通过基类指针来操作这些对象,根据对象的实际类型来执行不同的操作,而不需要知道具体的对象类型,这样就提高了代码的可扩展性和可维护性。
C++ 继承和组合?你在实际项目中是怎么使用的?什么情况下使用继承?什么情况下使用组合?
在实际项目中,继承和组合都是构建复杂类关系的重要方式。
继承主要用于当两个类之间存在一种 “是一种” 的关系时。例如,在一个图形绘制软件中,有一个基类Shape
,它代表所有的几何形状。然后有派生类Circle
、Rectangle
等。Circle
是一种Shape
,Rectangle
也是一种Shape
。通过继承,派生类可以继承基类的通用属性和方法,如形状的颜色、位置等属性,以及绘制方法的基本框架。在绘制系统中,可以通过一个基类指针数组来存储不同形状的对象,然后遍历数组并调用每个对象的绘制方法,利用多态性实现不同形状的绘制。
组合则用于当两个类之间存在一种 “有一个” 的关系时。例如,在一个汽车管理系统中,有一个Car
类,它可能包含一个Engine
类的对象和几个Wheel
类的对象。Car
有一个Engine
,这种关系通过组合来实现。Engine
类负责处理汽车发动机的相关功能,如启动、停止、调节功率等。Wheel
类负责处理车轮的相关功能,如旋转、刹车等。Car
类通过组合这些类,可以利用它们的功能来实现汽车的整体功能,如行驶、转弯等。
使用继承的情况通常是当需要在现有类的基础上进行扩展,并且新类和现有类具有明显的层次关系,新类可以自然地继承现有类的大部分属性和方法。例如,在一个员工管理系统中,有一个基类Employee
,它有员工的基本信息如姓名、工号等属性和一些通用的方法如计算工资的基本框架。然后可以有派生类Manager
和Engineer
,Manager
继承了Employee
的基本属性和方法,并且可以添加自己特有的属性如管理的部门、管理权限等,Engineer
也可以继承Employee
并添加自己特有的属性如技术专长等。
使用组合的情况是当一个类需要使用其他类的功能来构建自己的功能,并且这些类之间不存在层次上的 “是一种” 关系。例如,在一个游戏开发项目中,有一个Character
类,它可能组合了一个Weapon
类和一个Armor
类。Character
有武器和盔甲,通过组合这些类,Character
可以使用Weapon
类的攻击方法和Armor
类的防御方法来实现战斗功能。
C++ 如何实现多态?虚表指针是什么时候被初始化的?实例化一个对象需要那几个阶段?(三个)
C++ 实现多态的方式
C++ 主要通过虚函数来实现运行时多态。在基类中声明一个虚函数,然后在派生类中重新定义这个函数(重写)。当通过基类指针或引用调用这个虚函数时,会根据指针或引用所指向的实际对象类型来调用对应的函数版本。例如,有基类Shape
,其中有虚函数draw()
,派生类Circle
和Rectangle
分别重写draw()
函数。当使用Shape*
指针指向Circle
或Rectangle
对象并调用draw()
时,就会调用对应的派生类函数。
另外,C++ 还可以通过函数重载实现编译时多态。函数重载是指在同一作用域内,定义多个同名函数,但它们的参数列表(参数个数、类型、顺序)不同。当调用这个函数时,编译器会根据实参的类型等来确定调用哪一个具体的函数。例如,有两个add
函数,一个接受两个整数,另一个接受两个浮点数,编译器会根据传入的参数类型来决定调用哪个add
函数。
虚表指针的初始化
在对象构造时,虚表指针会被初始化。当创建一个包含虚函数的类的对象时,编译器会在对象的内存布局中插入一个虚表指针。这个指针指向该类的虚函数表(vtable)。在对象构造函数的初始化列表和函数体执行之前,虚表指针就会被正确地设置为指向该类对应的虚函数表。如果是派生类对象,虚表指针会指向派生类的虚函数表,该虚函数表可能包含对基类虚函数的重写版本。
对象实例化的三个阶段
- 内存分配阶段:首先要为对象分配内存空间。如果是栈上的对象,编译器会根据对象的大小在栈上预留相应的空间。对于堆上的对象,通过
new
操作符,系统会在堆中找到足够的内存空间来存储对象。这个阶段主要是确定对象存储的物理位置,并且这个位置的大小要足够容纳对象的所有成员。 - 初始化阶段:在内存分配好之后,会对对象的成员进行初始化。对于基本数据类型的成员,会根据其类型的默认初始化规则进行初始化。例如,整数类型可能被初始化为 0。对于类类型的成员,会调用其默认构造函数进行初始化。如果在类的定义中有初始化列表,会按照初始化列表的顺序对成员进行初始化,这是一个很重要的阶段,确保了对象的初始状态是符合预期的。
- 构造函数执行阶段:最后是执行对象的构造函数。构造函数可以包含自定义的代码,用于进一步初始化对象或者执行一些与对象创建相关的操作。例如,在构造函数中可以对一些成员变量进行更复杂的赋值,或者打开文件、建立网络连接等操作,这个阶段完成后,对象就完全实例化好了。
说说 C++ 重载、重写、覆盖?
函数重载(Overload)
函数重载是指在同一作用域内,有多个同名函数,但是它们的参数列表(参数个数、类型、顺序)不同。函数重载主要用于提供一组功能相似,但参数类型或数量不同的函数。例如,有一组print
函数,一个可以打印整数,如void print(int num)
,另一个可以打印字符串,如void print(const char* str)
。当调用print
函数时,编译器会根据传入的实际参数类型来决定调用哪一个print
函数。这是一种编译时多态,编译器在编译阶段就能确定要调用的具体函数版本。
函数重写(Override)
函数重写主要用于实现运行时多态。它发生在派生类和基类之间,当派生类重新定义了基类中的虚函数时,就称为函数重写。例如,基类Animal
中有虚函数sound()
,派生类Dog
重新定义了sound()
函数来发出 “汪汪” 声。重写要求函数的签名(函数名、参数列表、返回类型,对于返回类型有特殊情况,协变返回类型可以不同)必须和基类中的虚函数相同。并且,重写是基于虚函数机制的,只有通过基类指针或引用调用这个虚函数时,才能体现重写的效果,即根据对象的实际类型(是基类对象还是派生类对象)来调用相应的函数版本。
覆盖(Hide)
覆盖是指在派生类中定义了一个与基类同名的函数,但是这个函数不是虚函数。这会导致在派生类的作用域内,基类的同名函数被隐藏。例如,基类Base
有一个非虚函数func()
,派生类Derived
也定义了一个func()
函数。当通过派生类对象调用func()
时,调用的是派生类的func()
函数,而如果要调用基类的func()
函数,需要使用作用域解析运算符::
,如Derived::Base::func()
。这种情况和重写不同,它没有运行时多态的效果,只是简单的名字隐藏。
C 和 C++ 最大的区别。
面向对象编程支持
C 语言是一种面向过程的编程语言,它主要关注函数和数据结构的设计。而 C++ 是一种支持面向对象编程(OOP)的语言。在 C++ 中,可以定义类,将数据和操作数据的函数封装在一起。例如,在 C++ 中可以创建一个Person
类,将人的姓名、年龄等数据成员和获取姓名、设置年龄等成员函数封装在一个类中。通过访问控制符(如public
、private
、protected
)来控制对数据的访问,这种封装特性使得代码的维护和扩展更加容易。C++ 还支持继承、多态等面向对象的特性,这些特性可以帮助构建更加复杂和灵活的软件系统。
函数重载和模板
C++ 支持函数重载,而 C 语言不支持。在 C++ 中,同一作用域内可以有多个同名函数,只要它们的参数列表不同。例如,有两个add
函数,一个用于整数相加,一个用于浮点数相加。编译器会根据传入的参数类型来选择合适的函数进行调用。C++ 还支持模板,模板可以创建泛型程序,允许编写与类型无关的代码。例如,函数模板template <typename T> T add(T a, T b)
可以根据传入的实际类型(如整数或浮点数)生成对应的函数版本,这大大提高了代码的复用性。
异常处理机制
C++ 有一套完整的异常处理机制,通过try - catch
块来捕获和处理异常。例如,在一个函数中可能会出现除数为零的情况,可以将可能出现异常的代码放在try
块中,当出现异常时,通过catch
块来捕获并处理异常。而 C 语言没有像 C++ 这样内置的异常处理机制,在 C 语言中通常需要通过返回值来表示错误状态,或者使用setjmp
和longjmp
来进行非局部跳转来处理异常情况,但这种方式相对比较复杂和容易出错。
内存管理的便利性
在 C++ 中,除了可以像 C 语言一样使用malloc
和free
来管理内存,还提供了new
和delete
操作符。new
操作符不仅会分配内存,还会调用对象的构造函数来初始化对象,delete
操作符会调用对象的析构函数来清理对象占用的资源后再释放内存。这种方式在处理对象的内存管理时更加方便和安全,有助于避免内存泄漏等问题。
如何用 C 语言实现类(函数指针)?
在 C 语言中,虽然没有像 C++ 那样的类的概念,但可以通过结构体和函数指针来模拟类的一些特性。
首先,定义一个结构体来存储数据成员,这些数据成员类似于 C++ 类中的成员变量。例如,要模拟一个简单的 “点” 类,可以定义如下结构体:
typedef struct {int x;int y;
} Point;
然后,为了模拟类的成员函数,可以定义函数指针,并将这些函数指针作为结构体的成员。这些函数指针可以指向实现具体操作的函数。例如,对于 “点” 类,可以定义两个函数来设置点的坐标:
void setPointX(Point* p, int newX) {p->x = newX;
}void setPointY(Point* p, int newY) {p->y = newY;
}
然后修改结构体定义,将函数指针包含进去:
typedef struct {int x;int y;void (*setX)(Point*, int);void (*setY)(Point*, int);
} Point;
在使用时,需要先创建结构体对象,然后初始化函数指针。例如:
int main() {Point p;p.setX = setPointX;p.setY = setPointY;p.setX(&p, 10);p.setY(&p, 20);return 0;
}
这样就通过结构体和函数指针模拟了 C++ 中类的部分功能,包括数据成员和成员函数。不过,这种方式相对比较繁琐,而且没有 C++ 中类的访问控制、继承、多态等特性。
C++ 的缺省函数有哪些?
C++ 中有几个重要的缺省函数。
默认构造函数
如果一个类没有定义任何构造函数,编译器会自动生成一个默认构造函数。这个默认构造函数会对类的成员进行默认初始化。对于基本数据类型的成员,可能会将其初始化为一些默认值(如整数类型初始化为 0)。对于类类型的成员,会调用其默认构造函数进行初始化。不过,如果类中有其他构造函数定义,编译器就不会自动生成这个默认构造函数。例如,对于简单的class MyClass {};
,编译器会生成一个默认构造函数,当创建MyClass
对象时,如MyClass obj;
,这个默认构造函数会被调用。
拷贝构造函数
当使用一个对象来初始化另一个对象时,会调用拷贝构造函数。例如,MyClass obj1; MyClass obj2 = obj1;
,这里就会调用MyClass
的拷贝构造函数。如果没有自己定义拷贝构造函数,编译器会自动生成一个浅拷贝的拷贝构造函数。它会逐个成员地进行拷贝。但在一些情况下,如类中包含指针成员,浅拷贝可能会导致问题,需要自己定义拷贝构造函数来实现深拷贝。例如,一个类中有一个指针成员指向动态分配的内存,浅拷贝只会拷贝指针的值,而不会拷贝指针所指向的内存内容,可能导致两个对象的指针成员指向同一块内存,在析构时会出现问题。
赋值运算符重载
当把一个对象赋值给另一个对象时,会调用赋值运算符重载函数。例如,MyClass obj1, obj2; obj2 = obj1;
,这里就会调用MyClass
的赋值运算符重载函数。和拷贝构造函数类似,如果没有自己定义,编译器会自动生成一个浅赋值的函数。同样,对于包含指针成员的类,可能需要自己定义赋值运算符重载来实现深赋值,以避免指针悬挂等问题。
析构函数
析构函数用于在对象销毁时清理资源。当一个对象的生命周期结束时,析构函数会被调用。例如,对于在堆上动态分配内存的对象,在析构函数中可以释放这些内存。如果没有自己定义析构函数,编译器会自动生成一个析构函数,它会对类的成员调用其各自的析构函数(如果是类类型成员)。例如,一个类中有一个std::vector
成员,当对象销毁时,编译器自动生成的析构函数会调用std::vector
的析构函数来清理资源。
C++ 的运算符重载
运算符重载是 C++ 的一个强大特性,它允许自定义运算符对于自定义类型的操作。这使得代码可以像操作内置类型一样操作自定义类型,增强了代码的可读性和直观性。
运算符重载是通过定义特殊的成员函数或非成员函数来实现的。例如,对于一个自定义的复数类Complex
,可以重载+
运算符来实现复数相加。如果将+
运算符重载为成员函数,其形式大概如下:
class Complex {
public:Complex operator+(const Complex& other) const {return Complex(re + other.re, im + other.im);}//...其他成员
private:double re;double im;
};
这里operator+
函数实现了两个复数相加的功能。当使用Complex c1, c2; Complex c3 = c1 + c2;
这样的表达式时,就会调用这个重载的+
运算符函数。
除了成员函数形式,还可以将运算符重载为非成员函数。例如,对于上述复数类,也可以这样重载+
运算符:
Complex operator+(const Complex& c1, const Complex& c2) {return Complex(c1.re + c2.re, c1.im + c2.im);
}
在进行运算符重载时,有一些规则和限制。首先,并不是所有的运算符都可以重载,像::
、?:
、sizeof
等运算符不能重载。其次,重载后的运算符应该保持其原有的语义。例如,+
运算符通常应该实现加法相关的操作,而不是其他无关的功能。
运算符重载还可以用于实现类型转换。例如,可以通过重载类型转换运算符,将一个自定义类型转换为其他类型。比如,为Complex
类重载double
类型转换运算符,使得复数可以转换为实数(可能是取复数的模等方式)。
C++ 的 static 关键词修饰的全局变量、函数、局部变量的存储空间和作用域。
静态全局变量
存储空间:静态全局变量存储在数据段。在程序的整个生命周期内都存在,它的内存空间在程序开始运行时就被分配,直到程序结束才被释放。
作用域:其作用域被限制在定义它的文件内。这意味着即使在其他文件中使用extern
关键字也无法访问这个静态全局变量。例如,在一个文件file1.cpp
中定义了static int global_var;
,在其他文件中不能直接访问这个变量,这有助于实现文件级别的数据隐藏,避免不同文件之间的命名冲突。
静态函数
存储空间:函数本身不存在像变量一样的存储分配,静态函数在代码段存储,和普通函数类似。
作用域:静态函数的作用域也限制在定义它的文件内。这使得它只能在该文件中被调用,不能被其他文件中的函数调用。例如,在file1.cpp
中有一个静态函数static void static_func();
,在其他文件中不能调用这个函数,这有利于将函数的作用范围局限在一个文件内,方便模块划分和代码维护。
静态局部变量
存储空间:静态局部变量存储在数据段。它的存储空间在程序开始运行时就分配,并且在函数多次调用之间保持不变。
作用域:其作用域仅限于定义它的函数内部。但是,它和普通局部变量不同的是,它在函数第一次调用时初始化,并且在函数调用结束后不会被销毁。例如,在一个函数中定义了static int local_var;
,每次调用这个函数时,local_var
的值都会保留上次调用结束时的值,而不是像普通局部变量一样每次都重新初始化。
局部变量和全局变量能不能重名。
局部变量和全局变量可以重名。
当在一个函数内部定义了一个与全局变量同名的局部变量时,在这个函数内部,局部变量会屏蔽全局变量。这意味着在这个函数内部使用这个变量名时,访问的是局部变量。例如,有一个全局变量int global_var = 10;
,在一个函数void func()
中定义了int global_var = 20;
,在func()
函数内部,当使用global_var
这个名字时,操作的是局部变量global_var
,其值为 20。
如果想要在局部变量屏蔽全局变量的情况下访问全局变量,可以使用作用域解析运算符::
。例如,在上述func()
函数中,使用::global_var
就可以访问到全局变量,其值为 10。
这种特性使得在函数内部可以定义一个和全局变量同名的临时变量来进行局部操作,而不会影响全局变量的值。不过,在编写代码时,为了避免混淆,最好尽量避免局部变量和全局变量重名的情况,除非有特殊的需求。
C++ 析构函数可以是虚函数吗?为什么要将析构函数设置为虚函数?
C++ 析构函数可以是虚函数。
当一个类可能会作为基类被继承,并且通过基类指针或引用删除派生类对象时,析构函数应该设置为虚函数。如果析构函数不是虚函数,当通过基类指针删除派生类对象时,只会调用基类的析构函数,而派生类的析构函数不会被调用,这可能会导致派生类中动态分配的资源没有被正确释放,从而造成内存泄漏等问题。
例如,有一个基类Base
和一个派生类Derived
。如果Base
的析构函数不是虚函数,当使用Base* p = new Derived; delete p;
这样的代码时,只会调用Base
的析构函数,Derived
类中可能有的额外资源(如动态分配的内存、打开的文件等)就不会被正确清理。
当将析构函数设置为虚函数后,通过基类指针或引用删除对象时,会根据对象的实际类型(是基类对象还是派生类对象)调用相应的析构函数。在上述例子中,当Base
的析构函数是虚函数时,delete p;
会先调用Derived
的析构函数来清理派生类特有的资源,然后再调用Base
的析构函数,这样就可以保证整个对象的资源都被正确清理。
new 和 malloc 区别。
内存分配方式
new
是 C++ 中的操作符,它不仅分配内存,还会调用对象的构造函数来初始化对象。例如,当使用new
创建一个class A
的对象时,A* p = new A;
,首先会在堆上分配足够的内存空间来存储A
对象,然后会调用A
的构造函数来初始化这个对象。而malloc
是 C 语言中的函数,它仅仅分配内存,不会进行初始化操作。例如,void* p = malloc(sizeof(A));
只是在堆上分配了和A
对象大小相同的内存空间,但是这片内存中的内容是未定义的。
返回值类型
new
返回的是对象类型的指针。例如,对于A* p = new A;
,返回的是A*
类型的指针,这个指针直接指向新创建的对象。malloc
返回的是void*
类型的指针,需要进行类型转换才能用于具体的对象操作。例如,void* p = malloc(sizeof(A)); A* q = (A*)p;
,需要将malloc
返回的void*
指针转换为A*
类型才能正确地使用这个内存来存储A
对象。
内存分配失败处理
new
在内存分配失败时会抛出bad_alloc
异常。例如,在内存不足的情况下,new
会通过异常机制来通知程序内存分配失败,这样程序可以在try - catch
块中捕获这个异常并进行相应的处理。malloc
在内存分配失败时返回NULL
。例如,void* p = malloc(size);
如果size
太大,内存不足,p
就会为NULL
,程序需要检查p
是否为NULL
来判断内存分配是否成功。
内存释放方式
new
操作符分配的内存需要使用delete
操作符来释放。并且,对于通过new[]
分配的数组,需要使用delete[]
来释放。例如,A* p = new A; delete p;
和A* p = new A[10]; delete[] p;
。malloc
分配的内存需要使用free
函数来释放。例如,void* p = malloc(size); free(p);
。如果使用错误的释放方式,可能会导致程序出现错误,如内存泄漏或者非法内存访问等问题。
static 关键字的作用?(要全面)怎么实现的?
作用
- 修饰全局变量:当
static
修饰全局变量时,这个变量的作用域被限制在定义它的文件内。这有助于实现文件级别的数据隐藏,避免不同文件之间的命名冲突。例如,在一个大型项目的多个源文件中,每个文件可以定义自己的static
全局变量,这些变量不会相互干扰。从存储角度看,static
全局变量存储在数据段,在程序的整个生命周期内都存在,其内存空间在程序开始运行时就被分配,直到程序结束才被释放。 - 修饰函数:
static
修饰函数时,函数的作用域限制在定义它的文件内。这使得函数只能在该文件中被调用,不能被其他文件中的函数调用,有利于将函数的作用范围局限在一个文件内,方便模块划分和代码维护。和普通函数一样,static
函数存储在代码段,它主要是通过编译器在链接阶段限制对函数的访问来实现作用域的限制。 - 修饰局部变量:对于局部变量,
static
改变了它的生命周期。static
局部变量存储在数据段,它的存储空间在程序开始运行时就分配,并且在函数多次调用之间保持不变。其作用域仅限于定义它的函数内部。例如,在一个函数中定义了static int local_var;
,每次调用这个函数时,local_var
的值都会保留上次调用结束时的值,而不是像普通局部变量一样每次都重新初始化。
实现方式
在编译器编译阶段,对于static
全局变量和static
函数,编译器会在符号表中标记它们的属性,使得在链接过程中,其他文件无法访问这些标记为static
的符号。对于static
局部变量,编译器会在数据段为其分配空间,并且在函数的每次调用中,通过特殊的指令来访问和更新这个变量,而不是像普通局部变量一样在栈上分配和释放空间。
inline 和宏定义的区别?inline 是如何实现的?宏定义是如何实现的?
区别
- 语法检查方面:
inline
函数是真正的函数,会进行语法检查,包括参数类型检查、返回值类型检查等。而宏定义只是简单的文本替换,没有语法检查。例如,如果宏定义中的参数运算出现错误,在预处理阶段不会被发现,只有在真正的代码替换后,可能在编译阶段才会出现错误。而inline
函数如果参数类型不匹配等情况,在编译阶段就会报错。 - 作用域规则:
inline
函数遵循普通函数的作用域规则,可以在类内定义,也可以在命名空间等其他作用域内定义。宏定义没有作用域的限制,从定义处开始,在整个预处理后的文件范围内有效,除非被#undef
取消定义。 - 调试支持:
inline
函数可以像普通函数一样进行调试,因为它是真正的函数。而宏定义由于是文本替换,在调试时可能会出现一些复杂的情况,很难像函数一样进行单步调试等操作。 - 参数求值次数:对于宏定义,参数在每次展开时都会重新求值。例如,宏定义
#define SQUARE(x) (x)*(x)
,如果x
是一个带有副作用的表达式,如i++
,在SQUARE(i++)
这样的调用中,i
可能会被多次求值,导致结果不符合预期。而inline
函数参数求值和普通函数一样,只在函数调用时求值一次。
inline 实现方式
inline
函数在编译时,编译器会尝试将函数的代码直接嵌入到调用它的地方。如果inline
函数比较简单,编译器可能会将其完整的代码复制到每个调用点,这样可以减少函数调用的开销。但是,编译器并不一定会完全按照inline
的要求来做,如果函数体过于复杂,编译器可能会忽略inline
关键字,把它当作普通函数来处理,仍然通过函数调用的方式来执行。
宏定义实现方式
宏定义是在预处理阶段由预处理器进行文本替换实现的。预处理器会在程序中找到所有宏定义的标识符,并用对应的文本替换它们。例如,对于宏定义#define MAX(a,b) ((a) > (b)? (a) : (b))
,在程序中出现MAX(x,y)
的地方,预处理器会将其替换为((x) > (y)? (x) : (y))
。这个过程只是简单的文本操作,没有进行任何语法分析和语义理解。
指针和引用的区别?怎么实现的?
区别
- 定义和初始化:指针是一个变量,它存储的是另一个变量的地址。例如,
int* p;
定义了一个指针p
,它可以指向一个int
类型的变量。指针可以在定义后不立即初始化,之后可以通过赋值操作让它指向一个有效的内存地址。引用是一个别名,它必须在定义时初始化,并且之后不能再绑定到其他变量。例如,int a; int& r = a;
定义了一个引用r
,它是变量a
的别名,并且不能再让r
成为其他变量的引用。 - 内存占用:指针本身占用一定的内存空间,其大小取决于系统的寻址位数。例如,在 32 位系统中,指针大小通常是 4 字节,在 64 位系统中,指针大小通常是 8 字节。引用在语法上是变量的别名,它不单独占用内存空间,它和被引用的变量共享同一块内存空间。
- 可操作性:指针可以进行算术运算,如
p++
(如果p
是指向数组元素的指针),可以通过指针访问不同的内存地址。引用只是一个别名,它不能进行像指针那样的算术运算,它总是代表被引用的变量。 - 空值情况:指针可以为
NULL
,表示它不指向任何有效的内存地址。通过检查指针是否为NULL
可以避免一些非法的内存访问。引用必须始终引用一个有效的对象,不存在 “空引用” 的概念。
实现方式
指针在内存中有自己的存储单元,用于存储变量的地址。当对指针进行解引用操作(如*p
)时,编译器会根据指针存储的地址去访问对应的内存单元。引用在编译时,编译器会将引用替换为被引用变量的直接访问。例如,对于int a; int& r = a;
,在代码中使用r
的地方,编译器会直接将其替换为a
,这样就实现了引用作为别名的功能。
malloc 和 mmap 的底层实现?malloc 分配的是什么?
malloc 底层实现
malloc
是一个用于动态内存分配的函数。其底层实现通常是通过维护一个空闲内存块的链表来进行的。
在程序启动时,操作系统会为进程分配一块较大的内存区域作为堆空间。malloc
函数会在这个堆空间中寻找合适大小的空闲内存块。当请求分配内存时,它会遍历空闲内存块链表,找到一个大小足够的内存块。如果找到的内存块比请求的大小稍大,它可能会将这个内存块分割,一部分用于满足当前的内存请求,另一部分放回空闲内存块链表。
如果没有找到合适大小的空闲内存块,malloc
可能会通过系统调用向操作系统请求更多的内存,将新获取的内存加入到空闲内存块链表中,然后再进行分配。当释放内存(使用free
函数)时,malloc
会将释放的内存块重新加入到空闲内存块链表中,以便后续的分配。
malloc
分配的是堆内存中的空间,这些空间可以用于存储各种类型的数据,包括用户自定义的数据结构等。
mmap 底层实现
mmap
(内存映射)的底层实现是通过操作系统的虚拟内存管理机制。mmap
函数会在进程的虚拟地址空间中创建一个映射区域,这个映射区域可以关联到一个文件或者匿名内存区域。
如果是映射到文件,mmap
会将文件的一部分内容映射到进程的虚拟内存空间。操作系统会根据文件的存储位置和进程的虚拟内存映射关系,在需要访问文件内容时,通过页表将虚拟地址转换为实际的物理地址,从而读取或写入文件内容。这个过程涉及到虚拟内存和物理内存之间的页映射以及文件系统的操作。
对于匿名映射(不关联到文件),mmap
会在虚拟内存空间中创建一个匿名的内存区域,这个区域的内容初始是未定义的。操作系统会在需要时为这个区域分配物理内存,通常是通过和内存管理系统(如malloc
类似的空闲内存块管理)协同工作来实现。
Free 怎么知道空间大小?
当使用malloc
分配内存时,malloc
函数会在分配的内存块头部或者其他地方记录一些关于这块内存的信息,其中包括内存块的大小。
这些信息对于用户是不可见的,但free
函数可以访问这些信息。在一些实现中,内存块头部可能包含一个字段来记录内存块的大小,这个大小信息可能还包括一些额外的标记用于内存管理,如这块内存是否是通过malloc
分配的、是否已经被释放等。
当调用free
函数时,它会根据这些内部记录的信息来确定要释放的内存块的大小,从而正确地将内存块放回空闲内存块链表中,以便后续的malloc
分配可以使用这块内存。不同的malloc
和free
实现可能会有不同的方式来记录和获取内存块大小信息,但基本原理都是通过在分配内存时记录相关信息,在释放时利用这些信息来完成正确的操作。
析构函数是什么?
析构函数是类的一个特殊成员函数,它的主要作用是在对象销毁时进行资源清理工作。当一个对象的生命周期结束时,比如在离开对象所在的作用域(对于栈上的对象)或者通过delete
操作符删除堆上的对象时,析构函数就会被自动调用。
析构函数的名字是在类名前加上 “~” 符号,例如对于类MyClass
,析构函数是~MyClass()
。它没有返回值,也不能有参数(除了在一些特殊的继承情况下可以有一个指向异常对象的指针参数,但这种情况比较少见)。
以一个简单的包含动态分配内存的类为例。假设我们有一个String
类,它内部有一个字符指针来存储字符串内容,在构造函数中通过new
操作符动态分配内存来存储字符串。那么析构函数的重要性就体现出来了,它需要在对象销毁时释放这块动态分配的内存。如果没有正确地定义析构函数来释放内存,就会导致内存泄漏。
对于包含其他资源的类,如打开文件、网络连接等,析构函数也可以用于关闭文件、断开网络连接等操作,确保资源的正确回收和系统的稳定。
在继承关系中,析构函数的调用顺序也很重要。当销毁一个派生类对象时,首先会调用派生类的析构函数,然后再调用基类的析构函数,这个顺序和构造函数的顺序刚好相反,这样可以保证先清理派生类特有的资源,再清理基类的资源。
析构函数为什么是虚函数?(不知道)
在很多情况下,析构函数应该是虚函数。这主要是为了在通过基类指针或引用操作派生类对象时,能够正确地调用派生类的析构函数。
假设我们有一个基类Base
和一个派生类Derived
。如果Base
的析构函数不是虚函数,当我们通过基类指针删除派生类对象时,比如Base* ptr = new Derived; delete ptr;
,只会调用基类的析构函数。这是因为编译器在编译时,根据指针的类型(这里是Base*
)来决定调用哪个析构函数,而不是根据指针所指向的实际对象(这里是Derived
对象)。
这样就会导致一个严重的问题,派生类中可能有自己特有的资源需要在析构函数中清理,如动态分配的内存、打开的文件等,由于没有调用派生类的析构函数,这些资源就无法得到正确的清理,从而导致内存泄漏或者其他资源泄漏的问题。
析构函数为什么要用 virtual 修饰?
使用virtual
修饰析构函数可以实现多态的销毁对象机制。当有继承关系时,可能会通过基类指针或引用指向派生类对象。如果析构函数是虚函数,在通过基类指针或引用删除对象时,会根据对象的实际类型来调用相应的析构函数。
例如,有一个基类Shape
,它有派生类Circle
和Rectangle
。Shape
类的析构函数被定义为虚函数。当通过Shape*
指针指向Circle
或Rectangle
对象并使用delete
操作符删除这个指针时,首先会调用派生类(Circle
或Rectangle
)的析构函数来清理派生类特有的资源,比如对于Circle
可能是释放用于存储圆周率精度的额外内存,然后再调用基类Shape
的析构函数。
这种机制保证了在面向对象的继承层次结构中,资源的清理是完整和正确的。如果没有将析构函数定义为虚函数,那么在上述情况中,只会调用基类的析构函数,派生类特有的资源清理步骤就会被跳过,很可能导致内存泄漏等问题。
虚函数的原理、多态的底层实现。
虚函数原理
在 C++ 中,当一个类包含虚函数时,编译器会为这个类创建一个虚函数表(vtable)。虚函数表是一个存储类中虚函数指针的数组。每个包含虚函数的类对象在内存中有一个额外的指针,这个指针指向所属类的虚函数表,这个指针被称为虚表指针(vptr)。
当通过基类指针或引用调用虚函数时,编译器会通过对象的虚表指针找到对应的虚函数表,然后在虚函数表中查找要调用的虚函数的指针,进而调用实际的虚函数。例如,有一个基类Base
,其中有一个虚函数func()
,派生类Derived
重写了func()
。Base
类对象和Derived
类对象在内存中都有虚表指针,Base
类对象的虚表指针指向Base
类的虚函数表,Derived
类对象的虚表指针指向Derived
类的虚函数表。当通过Base*
指针指向Derived
对象并调用func()
时,会根据Derived
对象的虚表指针找到Derived
类的虚函数表,从而调用Derived
类重写后的func()
函数。
多态的底层实现
多态分为编译时多态和运行时多态。编译时多态主要通过函数重载和模板实现,这里主要说运行时多态,它是基于虚函数实现的。
在继承关系中,通过基类指针或引用调用虚函数就实现了运行时多态。由于虚函数表和虚表指针的存在,使得在运行时能够根据对象的实际类型来决定调用哪个函数。例如,有一个图形绘制系统,基类Shape
有虚函数draw()
,派生类Circle
和Rectangle
分别重写了draw()
函数。可以通过Shape*
指针数组来存储不同形状的对象,在遍历数组并调用draw()
函数时,会根据每个指针所指向的实际对象(Circle
或Rectangle
)的虚函数表来调用对应的draw()
函数版本,从而实现不同形状的正确绘制,这体现了运行时多态的灵活性和强大之处。
如果一个类里面只有虚函数的话,大小为多少(4 或 8 个字节)。
如果一个类里面只有虚函数,那么这个类对象的大小通常是一个指针的大小。在 32 位系统中,这个大小一般是 4 字节,在 64 位系统中,这个大小一般是 8 字节。
这是因为当类中有虚函数时,编译器会为这个类的每个对象添加一个虚表指针(vptr),这个虚表指针用于指向该类的虚函数表(vtable)。虚函数表存储了类中虚函数的地址,通过这个虚表指针可以在运行时调用正确的虚函数。所以这个虚表指针占据的空间就是类对象的大小,其大小取决于系统的指针大小。例如,在一个简单的Base
类中只有一个虚函数func()
,那么Base
类对象的大小在 32 位系统下是 4 字节,在 64 位系统下是 8 字节,这个空间就是用于存储虚表指针的。
解释一下 C++ 智能指针。
C++ 智能指针是一种用于管理动态分配内存的工具,它的出现主要是为了解决手动管理内存时容易出现的内存泄漏、悬空指针等问题。
智能指针本质上是一个类模板,它的行为类似于指针,但又具有自动内存管理的功能。它在内部维护了一个指向堆上对象的普通指针,并且通过重载*
(解引用运算符)和->
(成员访问运算符)等运算符来模拟普通指针的操作。
例如,std::shared_ptr
是一种智能指针。当使用std::shared_ptr
来管理一个对象时,多个std::shared_ptr
可以共享对同一个对象的所有权。它通过引用计数的方式来管理对象的生命周期。每当一个新的std::shared_ptr
指向这个对象时,引用计数就会加 1;当一个std::shared_ptr
不再指向这个对象(例如超出作用域或者被重新赋值)时,引用计数就会减 1。当引用计数变为 0 时,说明没有智能指针再指向这个对象了,此时就会自动调用对象的析构函数来释放内存。
另一种智能指针是std::unique_ptr
,它提供了独占式的对象所有权。一个std::unique_ptr
在同一时刻只能有一个拥有者,这意味着它不能被复制,但可以被移动。这种特性使得std::unique_ptr
在管理那些不应该被多个对象共享的资源时非常有用,比如文件句柄、网络连接等。通过这种独占式的管理,可以确保资源的唯一性和正确的释放。
智能指针还可以帮助防止悬空指针的问题。由于智能指针会自动管理对象的生命周期,当对象被释放后,智能指针不会再指向无效的内存区域,从而避免了因访问已释放内存而导致的程序错误。
智能指针用过哪些?
在实际的 C++ 编程中,std::unique_ptr
和std::shared_ptr
是比较常用的智能指针。
std::unique_ptr
用于那些具有独占资源所有权的情况。比如在一个函数中动态分配了一个对象,并且这个对象在函数内部独占使用,函数结束后就不再需要这个对象了。可以使用std::unique_ptr
来管理这个对象。例如,在一个工厂函数中,创建一个对象并返回它的所有权,就可以使用std::unique_ptr
。
std::unique_ptr<MyClass> createObject() {return std::unique_ptr<MyClass>(new MyClass);
}
std::shared_ptr
用于多个对象需要共享一个资源的情况。比如在一个数据缓存系统中,多个数据处理模块可能需要访问同一份缓存数据。可以使用std::shared_ptr
来管理这份缓存数据,使得每个模块都可以访问,并且只有当所有模块都不再需要这份数据时,数据才会被释放。
在一些复杂的对象关系场景中,会结合使用std::shared_ptr
和std::weak_ptr
。例如,在一个图形界面库中,窗口对象和其中的子部件对象可能存在相互引用的情况。使用std::shared_ptr
来管理主要的引用关系,而对于可能导致循环引用的反向引用,可以使用std::weak_ptr
,这样就能确保在窗口关闭等情况下,所有相关的对象都能正确地被销毁。
static 关键字的作用?(要全面)怎么实现的?
作用
- 修饰全局变量:当
static
修饰全局变量时,这个变量的作用域被限制在定义它的文件内。这有助于实现文件级别的数据隐藏,避免不同文件之间的命名冲突。从存储角度看,static
全局变量存储在数据段,在程序的整个生命周期内都存在,其内存空间在程序开始运行时就被分配,直到程序结束才被释放。例如,在一个大型项目的多个源文件中,每个文件可以定义自己的static
全局变量,这些变量不会相互干扰。 - 修饰函数:
static
修饰函数时,函数的作用域限制在定义它的文件内。这使得函数只能在该文件中被调用,不能被其他文件中的函数调用,有利于将函数的作用范围局限在一个文件内,方便模块划分和代码维护。和普通函数一样,static
函数存储在代码段,它主要是通过编译器在链接阶段限制对函数的访问来实现作用域的限制。 - 修饰局部变量:对于局部变量,
static
改变了它的生命周期。static
局部变量存储在数据段,它的存储空间在程序开始运行时就分配,并且在函数多次调用之间保持不变。其作用域仅限于定义它的函数内部。例如,在一个函数中定义了static int local_var;
,每次调用这个函数时,local_var
的值都会保留上次调用结束时的值,而不是像普通局部变量一样每次都重新初始化。
实现方式
在编译器编译阶段,对于static
全局变量和static
函数,编译器会在符号表中标记它们的属性,使得在链接过程中,其他文件无法访问这些标记为static
的符号。对于static
局部变量,编译器会在数据段为其分配空间,并且在函数的每次调用中,通过特殊的指令来访问和更新这个变量,而不是像普通局部变量一样在栈上分配和释放空间。
inline 和宏定义的区别?inline 是如何实现的?宏定义是如何实现的?
区别
- 语法检查方面:
inline
函数是真正的函数,会进行语法检查,包括参数类型检查、返回值类型检查等。而宏定义只是简单的文本替换,没有语法检查。例如,如果宏定义中的参数运算出现错误,在预处理阶段不会被发现,只有在真正的代码替换后,可能在编译阶段才会出现错误。而inline
函数如果参数类型不匹配等情况,在编译阶段就会报错。 - 作用域规则:
inline
函数遵循普通函数的作用域规则,可以在类内定义,也可以在命名空间等其他作用域内定义。宏定义没有作用域的限制,从定义处开始,在整个预处理后的文件范围内有效,除非被#undef
取消定义。 - 调试支持:
inline
函数可以像普通函数一样进行调试,因为它是真正的函数。而宏定义由于是文本替换,在调试时可能会出现一些复杂的情况,很难像函数一样进行单步调试等操作。 - 参数求值次数:对于宏定义,参数在每次展开时都会重新求值。例如,宏定义
#define SQUARE(x) (x)*(x)
,如果x
是一个带有副作用的表达式,如i++
,在SQUARE(i++)
这样的调用中,i
可能会被多次求值,导致结果不符合预期。而inline
函数参数求值和普通函数一样,只在函数调用时求值一次。
inline 实现方式
inline
函数在编译时,编译器会尝试将函数的代码直接嵌入到调用它的地方。如果inline
函数比较简单,编译器可能会将其完整的代码复制到每个调用点,这样可以减少函数调用的开销。但是,编译器并不一定会完全按照inline
的要求来做,如果函数体过于复杂,编译器可能会忽略inline
关键字,把它当作普通函数来处理,仍然通过函数调用的方式来执行。
宏定义实现方式:
宏定义是在预处理阶段由预处理器进行文本替换实现的。预处理器会在程序中找到所有宏定义的标识符,并用对应的文本替换它们。例如,对于宏定义#define MAX(a,b) ((a) > (b)? (a) : (b))
,在程序中出现MAX(x,y)
的地方,预处理器会将其替换为((x) > (y)? (x) : (y))
。这个过程只是简单的文本操作,没有进行任何语法分析和语义理解。
相关文章:
小米C++ 面试题及参考答案上(120道面试题覆盖各种类型八股文)
进程和线程的联系和区别 进程是资源分配的基本单位,它拥有自己独立的地址空间、代码段、数据段和堆栈等。线程是进程中的一个执行单元,是 CPU 调度的基本单位。 联系方面,线程是进程的一部分,一个进程可以包含多个线程。它们都用于…...
SQL SELECT 语句:基础与进阶应用
SQL SELECT 语句:基础与进阶应用 SQL(Structured Query Language)是一种用于管理关系数据库的编程语言。在SQL中,SELECT语句是最常用的命令之一,用于从数据库表中检索数据。本文将详细介绍SELECT语句的基础用法&#…...
微服务即时通讯系统的实现(服务端)----(1)
目录 1. 项目介绍和服务器功能设计2. 基础工具安装3. gflags的安装与使用3.1 gflags的介绍3.2 gflags的安装3.3 gflags的认识3.4 gflags的使用 4. gtest的安装与使用4.1 gtest的介绍4.2 gtest的安装4.3 gtest的使用 5 Spdlog日志组件的安装与使用5.1 Spdlog的介绍5.2 Spdlog的安…...
《Spring 依赖注入方式全解析》
一、Spring 依赖注入概述 Spring 依赖注入(Dependency Injection,DI)是一种重要的设计模式,它在 Spring 框架中扮演着关键角色。依赖注入的核心概念是将对象所需的依赖关系由外部容器(通常是 Spring 容器)进…...
【C++动态规划】1411. 给 N x 3 网格图涂色的方案数|1844
本文涉及知识点 C动态规划 LeetCode1411. 给 N x 3 网格图涂色的方案数 提示 你有一个 n x 3 的网格图 grid ,你需要用 红,黄,绿 三种颜色之一给每一个格子上色,且确保相邻格子颜色不同(也就是有相同水平边或者垂直…...
外包干了3年,技术退步明显...
先说情况,大专毕业,18年通过校招进入湖南某软件公司,干了接近6年的功能测试,今年年初,感觉自己不能够在这样下去了,长时间呆在一个舒适的环境会让一个人堕落! 而我已经在一个企业干了四年的功能…...
SpringBoot 2.x 整合 Redis
整合 1)添加依赖 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 如果没有使用下面给出的工具类,那么就不需要引入 -…...
React的API✅
createContext createContext要和useContext配合使用,可以理解为 “React自带的redux或mobx” ,事实上redux就是用context来实现的。但是一番操作下来我还是感觉,简单的context对视图的更新的细粒度把控比不上mobx,除非配合memo等…...
什么是全渠道客服中心?都包括哪些电商平台?
什么是全渠道客服中心?都包括哪些电商平台? 作者:开源呼叫中心系统 FreeIPCC,Github地址:https://github.com/lihaiya/freeipcc 全渠道客服中心是一种能够同时接入并处理来自多个渠道客户咨询和请求的综合服务平台。以…...
Jtti:如何知晓服务器的压力上限?具体的步骤和方法
了解服务器的压力上限(也称为性能极限或容量)是确保系统在高负载下仍能稳定运行的重要步骤。这通常通过压力测试(也称为负载测试或性能测试)来实现。以下是详细的步骤和方法来确定服务器的压力上限: 1. 定义测试目标和指标 在进行压力测试前,明确测试目标…...
贪心算法(1)
目录 柠檬水找零 题解: 代码: 将数组和减半的最少操作次数(大根堆) 题解: 代码: 最大数(注意 sort 中 cmp 的写法) 题解: 代码: 摆动序列࿰…...
SpringBoot,IOC,DI,分层解耦,统一响应
目录 详细参考day05 web请求 1、BS架构流程 2、RequestParam注解 完成参数名和形参的映射 3、controller接收json对象,使用RequestBody注解 4、PathVariable注解传递路径参数 5、ResponseBody(return 响应数据) RestController源码 6、统一响…...
目标驱动学习python动力
文章目录 迟迟未开始的原因打破思维里的围墙抛砖引玉爬虫 结束词 迟迟未开始的原因 其实我也是很早就知道有python,当时听说这个用于做测试不错,也就一直没有提起兴趣,后来人工智能火了之后,再次接触python,安装好pyth…...
力扣-Hot100-回溯【算法学习day.39】
前言 ###我做这类文档一个重要的目的还是给正在学习的大家提供方向(例如想要掌握基础用法,该刷哪些题?)我的解析也不会做的非常详细,只会提供思路和一些关键点,力扣上的大佬们的题解质量是非常非常高滴&am…...
小熊派Nano接入华为云
一、华为云IoTDA创建产品 创建如下服务,并添加对应的属性和命令。 二、小熊派接入 根据小熊派官方示例代码D6完成了小熊派接入华为云并实现属性上传命令下发。源码:小熊派开源社区/BearPi-HM_Nano 1. MQTT连接代码分析 这部分代码在oc_mqtt.c和oc_mq…...
【linux硬件操作系统】计算机硬件常见硬件故障处理
这里写目录标题 一、故障排错的基本原则二、硬件维护注意事项三、关于最小化和还原出厂配置四、常见故障处理及调试五、硬盘相关故障六、硬盘相关故障:硬盘检测问题七、硬盘相关故障:自检硬盘报错八、硬盘相关故障:硬盘亮红灯九、硬盘相关故障…...
谈学生公寓安全用电系统的涉及方案
学生公寓安全 学生公寓安全用电系统的设计方案主要包括以下几个方面: 电气线路设计: 合理布线:确保所有电气线路按照国家或地区的电气安全标准进行设计,避免线路过载和短路。使用阻燃材料:选用阻燃或低…...
自动语音识别(ASR)与文本转语音(TTS)技术的应用与发展
💝💝💝欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。 推荐:kwan 的首页,持续学…...
Go 语言数组
Go 语言数组 引言 Go 语言是一种静态类型、编译型语言,由 Google 开发,旨在提高多核处理器下的编程效率。数组作为 Go 语言中的一种基本数据结构,提供了存储一系列具有相同类型元素的能力。本文将深入探讨 Go 语言中数组的使用方法、特性以…...
13. 【.NET 8 实战--孢子记账--从单体到微服务】--简易权限--完善TODO标记的代码
这篇文章特别短,短到可以作为一篇文章的一个章节,那让我们开始吧 一、编写代码 我们在代码中标记了大量的TODO标记,并且注明了这里暂时写死,等权限和授权完成后再改为动态获取这句话。那么到目前为止和权限有关的代码已经完成了…...
深入剖析Java内存管理:机制、优化与最佳实践
🚀 作者 :“码上有前” 🚀 文章简介 :Java 🚀 欢迎小伙伴们 点赞👍、收藏⭐、留言💬 深入剖析Java内存管理:机制、优化与最佳实践 一、Java内存模型概述 1. Java内存模型的定义与作…...
【Amazon】亚马逊云科技Amazon DynamoDB 实践Amazon DynamoDB
Amazon DynamoDB 是一种完全托管的 NoSQL 数据库服务,专为高性能和可扩展性设计,特别适合需要快速响应和高吞吐量的应用场景,如移动应用、游戏、物联网和实时分析等。 工作原理 Amazon DynamoDB 在任何规模下响应时间一律达毫秒级ÿ…...
Qt-常用的显示类控件
QLabel QLabel有如下核心属性: 关于文本格式的验证: 其中<b>xxx<b>,就是加粗的意思。 效果: 或者再把它改为markdown形式的: 在markd中,#就是表示一级标题,我们在加上##后&#x…...
LabVIEW内燃机缸压采集与分析
基于LabVIEW开发的内燃机缸压采集与分析系统结合高性能压力传感器和NI数据采集设备,实现了内燃机工作过程中缸压的实时监测与分析,支持性能优化与设计改进。文中详细介绍了系统的开发背景、硬件组成、软件设计及其工作原理,展现了完整的开发流…...
【Linux学习】【Ubuntu入门】1-7 ubuntu下磁盘管理
1.准备一个U盘或者SD卡(插上读卡器),将U盘插入主机电脑,右键点击属性,查看U盘的文件系统确保是FAT32格式 2.右键单击ubuntu右下角图标,将U盘与虚拟机连接 参考链接 3. Ubuntu磁盘文件:/dev/s…...
VScode clangd插件安装
前提 在VScode中写C代码时,总会用到 C/C 这个插件,也就自然而然地使用了这个插件带来的代码跳转和代码提示功能。但是当代码变地很多时,就会变得非常慢。所以经过调查后弃用C/C 插件的这个功能,使用 clangd 这个插件来提示C代码和…...
【机器学习】- L1L2 正则化操作
目录 0.引言1.正则化的基本思想2.L1 正则化3.L2 正则化4.L1 与 L2 正则化的比较5.应用:控制模型复杂度6.超参数 λ \lambda λ 的选择7.总结 0.引言 在机器学习中,正则化是一种通过约束模型参数来控制模型复杂度的技术。它可以有效减少过拟合ÿ…...
Logback实战指南:基础知识、实战应用及最佳实践全攻略
背景 在Java系统实现过程中,我们不可避免地会借助大量开源功能组件。然而,这些组件往往功能丰富且体系庞大,官方文档常常详尽至数百页。而在实际项目中,我们可能仅需使用其中的一小部分功能,这就造成了一个挑战&#…...
基于python的机器学习(三)—— 关联规则与推荐算法
目录 一、关联规则挖掘 1.1 基本概念 1.2 Apriori算法 1.2.1 Apriori算法的原理 1.2.2 Apriori算法的实例 1.2.3 Apriori算法的程序实现(efficient-apriori模块) 1.3 FP-Growth算法 1.3.1 FP-Growth算法的原理 1.3.2 FP-Growth算法的实例 二、…...
【大模型】LLaMA: Open and Efficient Foundation Language Models
链接:https://arxiv.org/pdf/2302.13971 论文:LLaMA: Open and Efficient Foundation Language Models Introduction 规模和效果 7B to 65B,LLaMA-13B 超过 GPT-3 (175B)Motivation 如何最好地缩放特定训练计算预算的数据集和模型大小&…...
政府网站建设背景/培训机构不退费最有效方式
在 Centos 8 和 RHEL 8 系统中,默认未安装 VNC 服务器,它需要手动安装。在本文中,我们将通过简单的分步指南,介绍如何在 Centos 8 / RHEL 8 上安装 VNC 服务器。-- Pradeep Kumar(作者)VNC( 虚拟网络计算(Virtual Network Computi…...
上海公司建立网站/关键词快速排名平台
RecyclerView没有像之前ListView提供divider属性,而是提供了方法 recyclerView.addItemDecoration()其中ItemDecoration需要我们自己去定制重写,一开始可能有人会觉得麻烦不好用,最后你会发现这种可插拔设计不仅好用,而且功能强大…...
郑州做网站推广电话/百度链接收录
求二分图最大完备匹配数和序最大的方案,匈牙利算法解决。 1 /*2 ID:esxgx13 LANG:C4 PROG:hdu37295 */6 #include <cstdio>7 #include <cstring>8 #include <iostream>9 #include <stack> 10 #include <algorithm> 11 using namespac…...
柳州在哪里做网站/上海哪家seo好
春天到来前,5G正在准备搞点大事情。就在前两天,特朗普又专门为5G发推特,强调美国要尽快发展5G,并且强调要靠竞争而不是封杀来发展技术。这里咱们放下特朗普的内心戏暂且不表,至少美国总统频频发声,证明了5G…...
sea wordpress/手机端怎么刷排名
项目背景和意义 目的:本课题主要目标是设计并能够实现一个基于web网页的疫情核酸检查预约系统,整个网站项目使用了B/S架构,基于java的springboot框架下开发;;通过后台设置医院信息、录入医院科室信息、录入医生信息、设…...