【面试考点】

计算机网络

计算机网络体系结构

TCP/IP体系

由高到低分别是:

  1. 应用层
    各种应用层协议如DNS、HTTP、HTTPS、SMTP协议等;
  2. 传输层
    TCP、UDP协议;
  3. 网际层
  4. 链路层

OSI体系

按照每层序号编号:

  1. 物理层
  2. 数据链路层
  3. 网络层
  4. 传输层
  5. 会话层
    控制应用程序之间的会话能力(会话的建立、管理和终止)
  6. 表示层
    对从应用层获得到的数据进行格式处理、压缩处理和安全处理。
  7. 应用层

五层协议

  1. 物理层
  2. 数据链路层
    将网络层传下来的IP分组封装成帧,并控制这些数据在链路上的传输。
    数据链路层三个基本问题:封装成帧透明传输差错检验
    数据链路层上的交互数据单位叫做”“。
  3. 网络层
    将传输层传下来的数据封装成IP数据报(IP分组),选择合适的路由转发分组
    网络层交互数据的单位叫做“IP分组、IP数据报、包”;
  4. 传输层
    为两个进程间的通信提供通用的的数据传输服务;
    所谓“通用”,是指传输层提供的服务并不是针对某些应用程序,而是多种应用程序可以使用同一个运输层服务;
    传输层具有“复用”和”分用“的功能,复用是指多个进程可以同时使用运输层服务,分用是指运输层将收到的数据分别交付至对应的应用程序中。
    传输层交互数据的单位叫做”报文段“或”用户数据报“。
  5. 应用层
    应用层交互的数据单位叫做”报文
    应用进程间(可以是同一台主机,也可以是不同的主机间)的通信和数据交换规则

TCP与UDP的区别

TCP是面向连接的、基于数据流可靠传输
在数据传输前,需要经过3次握手握手建立连接;在数据传输结束后,需要经过4次挥手断开连接。
在数据传输过程中,需要保证可靠传输流量控制拥塞控制
TCP在传输数据准确、对速度没有硬性要求的场合有很好的表现。
TCP首部说明

UDP是无连接的基于数据报不可靠传输
在数据传输前,不需要建立连接;在数据传输结束后,不需要断开连接。
在数据传输过程中,不需要保证可靠传输、流量控制、拥塞控制,少了很多开销,因此传输速度快。
UDP在对传输速度要求较高的场景中有很好的表现,如视频通话、网络直播等。
UDP首部说明

面向字节流的含义:
虽然应用程序和TCP之间的交互是一次一个数据块,但TCP把应用程序交下来的数据仅仅看成是一连串的无结构的字节流。具体表现是:发送端发送数据的次数和接收端接收数据的次数不一致。
面向数据报的含义:
UDP对应用层传下来的报文,既不分割也不合并,而是保留这些报文的边界。应用层交给UDP多长的数据,UDP就照样发送,即一次发送一个报文。因此,应用程序必须选择合适大小的报文;如果报文太长,UDP添加首部后交给IP层,IP层就要在传送时进行分片,降低了IP层效率;如果报文太短,UDP交给IP层后,IP数据报的首部就相对太长,这也降低了IP层效率。

三次握手、四次挥手

以客户端服务器为例,
第一次握手:客户端向服务器发送SYN同步报文,表示请求建立连接,同时发送序列号,进入SYN_SEND状态;
第二次握手:服务器向客户端发送SYN同步报文和确认,表示同意建立连接,进入SYN-RECVD状态;
第三次握手:客户端收到服务器的同步报文,向服务器发送确认报文,进入ESTABLISHED状态。

第一次挥手:客户端向服务器发送FIN结束报文,表示请求断开连接;
第二次挥手:服务器向客户端发送ACK确认报文,此时还未断开连接,因为服务器还有数据发送给客户端;
第三次挥手:服务器向客户端发送FIN结束报文,表示服务器的数据发送完了,请求断开连接;
第四次挥手:客户端向服务器发送确认报文。

为什么需要三次握手?
三次握手示意图

防止已失效的连接请求报文突然又发送至服务器端,导致占用服务器资源
有这种情况:
客户端第一次发送的连接请求报文,因为网络的原因被滞留了;
于是客户端触发超时重传机制,第二次发送连接请求;
当第二次连接请求正常断开后,第一次的连接请求又到达服务器;
服务器会误认为客户端又重新请求建立连接,于是同一客户端的连接请求;
然而客户端并没有建立连接的意向,服务器只能等待客户端的确认报文,白白占用客户端的资源。

为什么需要四次挥手?
四次挥手示意图
因为TCP连接是全双工的,客户端发送断开请求之后,表示客户端没有数据发送给服务器了;但是服务器可能还有数据发送至客户端。
TIME-WAIT的作用?

  1. 可靠地终止TCP连接;
  2. 防止本次连接中产生的数据误传到下次连接中。

TCP如何实现可靠传输?

  1. 确认和重传机制
    确认是指接收方收到发送方发送的数据后,给发送方返回一个确认报文,告诉发送方你发送的数据我已经收到了,可以发接下来的数据了。
    重传是指,当发送方发送完数据之后,迟迟收不到接收方返回的确认报文,那么发送方就会重新发送之前发送的数据。
  2. 数据校验
    TCP的首部有个“检验和”,用于校验TCP报文是否损坏丢失。
  3. 数据的分片和排序
    TCP会按最大传输单元(MTU)合理分片;接收方的接收缓冲区会保留未按需到达的数据,重新排序后交给应用层。
  4. 流量控制
    流量控制是指,接收方告诉发送方发送的速率不要过快,要让接收方来得及接收。接收方有个接收缓冲区,如果发送方发送的数据过快,接收缓冲区会被很快地填满,导致新来的数据丢失,造成网络资源的浪费。流量控制是指点对点通信量的控制,是个端到端的问题。
    方法是:接收方在给发送方的确认报文中,通过设置窗口值动态地改变发送方的发送速率。
  5. 拥塞控制
    拥塞控制是指,防止过多的数据注入到网络中,造成网络中的路由器和链路出现过载的现象。拥塞控制是一个全局性的过程,涉及所有的主机、路由器等。

TCP如何实现流量控制?

流量控制是指,接收方告诉发送方发送的速度不要太快,要让接收方来得及处理
TCP发送方有个发送缓冲区,接收方有个接收缓冲区。如果发送方发送的太快,会导致接收方接收缓冲区很快被填满,出现丢包现象和造成网络资源浪费的现象。
方法是:接收方通过设置确认报文的“窗口”字段值,动态地改变发送方的发送速率。

TCP如何实现拥塞控制?

在这里插入图片描述

拥塞控制是指,防止过多的数据注入到网络中,造成网络中的路由器和链路出现过载的现象。拥塞控制是一个全局性的过程,涉及所有的主机、路由器,以及与降低网络传输性能有关的所有因素。
拥塞控制的算法有4种:慢开始、拥塞避免、快重传和快恢复。
慢开始:
TCP连接刚建立时,并不知道网络当前的负荷情况,不能将大量的数据注入到网络中,这样会造成网络拥塞;正确做法是先设置较小的拥塞窗口值试探,然后以指数的形式增大。
拥塞避免:
指数形式会使拥塞窗口膨胀的很快,因此设定一个慢开始阶段阈值。当拥塞窗口大小超过阈值时,进入拥塞避免阶段。
拥塞避免阶段会降低拥塞窗口的增长速度,让拥塞窗口以线性方式缓慢增长。当网络出现拥塞时,将慢开始阶段拥塞窗口阈值设为拥塞峰值的一半,然后再次执行慢开始算法。
快重传:
有时,个别报文段会在网络中意外的丢失,但实际上网络并没有发生拥塞;发送方迟迟收不到丢失报文的确认,误认为网络发生拥塞,于是发送方错误地启动慢开始算法,因此不必要的降低了传输效率。
快重传算法要求,接收方收到了失序的报文段后,要立即发送对已经收到的报文段的重复确认。
快重传算法是指,当发送方连续收到3个重复的确认报文,就知道网络并没有发生拥塞,只是丢失了个别报文段;因而立即重新发送丢失的报文段。
快重传说明

快恢复:
如果发送方知道了只是丢失个别报文段,没有发生网络拥塞,于是不执行慢启动算法;而是将拥塞窗口值设为拥塞峰值的一半,然后直接执行拥塞避免算法

TCP粘包

产生原因
发送方有个发送缓冲区,接收方有个接收缓冲区,发送方在发送数据时,会将应用层传下来的多个数据包放在一起,这样数据与数据之间的边界就丢失了;接收方在接收数据时不知道哪些数据是属于同一个包,于是不能正确解析发送方发送的数据。
解决方法:

  1. 发送定长数据
    在mobile项目中,上位机通过发送定长的定位结果给PLC,PLC端只需要按照长度解析即可;
    定位传输协议

  2. 使用标准的应用层协议(比如http、https协议)来封装要发送的数据
    发送方按照固定模式封装数据,接收方按照固定模式解析数据;

  3. 在每条数据的尾部添加一个标识作为结束符
    接收方识别到一个标识符,就说明一条数据接收完毕,有点像C语言中字符串的末尾加个’\0’表示标识当前字符串的结尾。
    缺点:效率低,需要一个字节一个字节判断当前字符是不是结束符。

  4. 在发送数据块的前面,添加一个固定大小的数据头,即整个数据包括:数据头+数据块
    数据头存储了当前数据的总长度,接收方先接收数据头,再根据数据头中的长度接收指定大小的字节;
    数据块,要发送的数据本体。

UDP如何实现可靠传输?

UDP传输层无法保证数据的可靠传输,只能通过应用层去实现。
UDP只是尽最大努力完成数据交付,而不关心接收方是否受到数据
提供超时重传和确认机制。
发送方在发送报文中添加首部字段:序号和时间戳。发送报文时,携带发送序号和时间戳。接收方接收数据后,提取时间戳和序号,添加到确认报文首部后返回给发送方。发送方根据时间戳计算RTT,从而计算出合适的RTO(超时重传时间),用于超时重传。

ARP协议

地址解析协议,根据IP地址,获取其对应的MAC地址
主机有一个ARP高速缓存,缓存中存放了一个从IP地址到MAC地址的映射表,这个映射表是动态更新的。
当主机向局域网中的其它主机发送IP分组时,先在ARP高速缓存中查找有无目标主机的IP地址,如果命中,则从ARP高速缓存中取出其对应的MAC地址;
如果没有命中,

  1. 发送主机就会在局域网中广播一个ARP请求,请求内容是发送方的IP地址和MAC地址以及接收方的IP地址;
  2. 局域网中的所有主机都会收到这个ARP请求,只有IP地址和请求中接收方IP地址符合的主机才会响应这个请求;
  3. 接收方会把自己的MAC地址放在请求的响应中,同时在本地缓存发送方的IP地址到MAC地址的映射;
  4. 发送方收到ARP请求后,会将接收方IP地址到MAC地址的映射写入到ARP高速缓存中。

ICMP协议

网际控制报文协议,ICMP允许主机或路由器报告差错情况,属于网络层协议。
ICMP报文有两种,ICMP差错报告报文ICMP询问报文
ping就是ICMP询问报文的一种应用。
tracert就是ICMP差错报告报文的一种应用。
ICMP报文种类

HTTP

IO多路复用

CS模型

监听多个文件描述符状态的任务委托给操作系统内核(这个过程是阻塞的),一旦操作系统内核检测到有文件描述就绪(可能是读缓冲区就绪,也可能是写缓冲区就绪)程序的阻塞就会被解除,之后基于这些就绪的文件描述符进行通讯。
与基于多进程、多线程的服务器并发相比,IO多路复用的最大优势就是系统开销小

select

//函数原型
int select(int nfds, fd_set* read_fds, fd_set* write_fds, fd_set* exceptfds, struct timeval* timeout);

其中:

  • nfds,表示委托操作系统内核监听的三个文件描述符集合中最大的文件描述符+1,内核要线性遍历这些文件描述符集合,因此需要一个边界结束遍历;
  • read_fds,它是一个传入传出参数,表示委托内核监测哪些文件描述符的读缓冲区;
  • write_fds,它是一个传入传出参数,表示委托内核监视哪些文件描述符的写缓冲区;
  • exceptfds,它是一个传入传出参数,表示委托内核监视哪些文件描述符是否状态异常;
  • timeout,超时时长,select函数是阻塞的,timeout用来强制解除阻塞。

select的优点是跨平台,降低系统并发的开销,提高效率;
缺点:

  1. 需要将带监测的文件描述符集合来回在用户空间和内核空间进行数据拷贝,效率低;
  2. 内核监测文件描述符的方式是线性监测,如果待监测的文件描述符数量非常多,则效率低;
  3. 能够监测的文件描述符最大个数是1024

poll

poll的特点:

  1. 需要将待监测的自定义文件描述符集合来回在用户空间和内核空间进行数据拷贝,效率低;
  2. 内核监测自定义文件描述符的方式也是线性轮询,如果待监测的自定义文件描述符数量非常多,则效率低;
  3. poll能够监测的文件描述符数量没有限制
  4. poll不能跨平台使用,只能在Linux下使用。

epoll




操作系统

死锁

什么是死锁:
各进程互相等待其它进程所拥有的资源,导致各进程都被阻塞的现象。
死锁产生的必要条件:

  1. 互斥条件:只有对必须互斥使用的资源产生争夺才会出现死锁;
  2. 不可剥夺条件:进程所拥有的资源,只有自己主动释放,不能被其它进程剥夺;
  3. 请求和保持条件:进程在拥有一些资源的同时,又需要请求其它资源才能运行,而这些资源又被其它进程所占有;
  4. 循环等待条件:存在一种进程资源的循环等待链,链中每个进程拥有的资源被下一个进程所请求。

预防死锁:

  1. 破坏互斥条件
    把必须互斥使用的资源改成可以共享使用(但并非所有的资源都能共享使用);
  2. 破坏不可剥夺条件
    有两种方式:如果当前进程请求的资源被其它进程占有,当前进程会释放掉所有的资源;
    如果当前进程请求的资源被其他进程占用,请求操作系统协助,剥夺其它进程的资源;
  3. 破坏请求和保持条件
    采用静态分配方法,在进程执行前,把它所需要的进程全部分配给它(效率太低,还会产生饥饿现象);
  4. 破坏循环等待条件
    顺序资源分配法(编程麻烦、效率低、不好)。

避免死锁:

安全序列:进程的一种执行顺序,按照这个顺序,不会发生死锁现象。
银行家算法:在进行资源分配前,先判断此次资源分配是否会导致系统进入不安全状态;如果会导致系统进入不安全状态的话,则暂时不为该进程分配资源。
在这里插入图片描述
死锁的解除:

  1. 资源剥夺法
    挂起(即暂时放到外存上)阻塞的进程,将该进程所占有的资源分配给其它进程。
  2. 撤销进程法
    强制撤销部分or全部死锁进程,并将该进程占有的资源分配给其它进程。方法简单,但代价比较大,会导致已经执行很久的进程功亏一篑。
  3. 进程回退法
    让死锁进程回退到足以避免死锁的地步,即放弃部分资源的占有权。要求该进程记录历史信息。

一个进程可以创建的最大线程数

跟两个因素相关:

1. 系统的位数
32位机下,一个进程的虚拟地址空间为4G,其中用户空间占3G,内核空间占1G。如果创建线程时分配的栈空间为10M,则一个进程最多能创建300个左右的线程。
64位机下,用户空间的虚拟地址128T,理论上一个进程可以创建很多线程,但会手动系统参数的限制。
2. 系统参数的限制
/proc/sys/kernel/threads-max(系统支持的最大线程数);
/proc/sys/kernel/pid_max(系统全局的PID号数值限制,进程和线程都会占用ID,当ID超过pid_max时,创建线程失败);
/proc/sys/vm/max_map_count(一个进程可以拥有的虚拟内存区域限制)。


进程间通讯方式

进程的空间是独立的,因此不能直接访问其它进程的内存空间,需要通过其它的方式实现进程间的通信。
进程间通讯主要指:数据传输共享数据通知事件进程控制
1. 管道
匿名管道:
是一种半双工的通信方式,数据只能单向流动,而且只能在父子进程间使用。
命名管道:
是一种半双工的通信方式,数据只能单向流动,但允许在没有亲缘关系的进程间使用。

2. 消息队列
消息队列是进程间传递数据块的一种方法,每个数据块都有一个特定的类型,接收方根据类型有选择地接收数据。
消息队列的本质就是存放在操作系统内核中的链表,其中每个节点对应用户定义的数据结构。发送数据相当于往链表中插入节点,接收数据相当于从链表中删除节点。
主要步骤是:
创建消息队列msgget()、将消息添加到消息队列msgsnd()、从消息队列中获取消息msgrcv()、查看/设置/删除消息队列msgctl()。
消息队列在收发数据时,数据一定要按照规定的格式来,如下所示:

struct msgStruct{
	long msgType;	//消息类型
	char msgText[n];	//消息正文,n为字节数,具体多少有程序员控制
	...
}

消息队列示意图
上图中,0记录了消息队列中的第一个消息,1对应一种类型的消息,2对应另一种类型的消息,以此类推…
消息队列的缺点
进程往消息队列中放数据时,会发生用户态拷贝数据到内核态的过程;进程往消息队列中取数据时,会发生内核态拷贝数据到用户态的操作;即都需要操作系统内核的频繁介入,效率较低。

3. 共享内存
共享内存是指多个进程通过访问同一块物理内存,从而达到通讯的效果。
共享内存是最高效的IPC机制,因为共享内存一旦建立起来,所有的访问都是常规的访存,无需借助内核;即数据不会在进程间来回拷贝。
因为存在多个进程对同一块内存空间的访问,所以涉及到进程的同步互斥问题
主要步骤是:
创建/获取共享内存shmget()、关联当前进程和共享内存shmat()、通过指针访问共享内存、分离当前进程和共享内存shmdt()、删除共享内存shmctl()。
注意:在操作共享内存时,会更新结构体变量shmid_ds,其内容如下所示:
shmid_ds
shm_nattch是共享内存的引用计数,只有shm_nattch值为0时,才会真正删除共享内存(和shared_ptr类似)。

  1. 信号
  2. 信号量
  3. socket
    答复

线程间通信


IO多路复用

select

进程调度

进程同步互斥

生产者消费者模型
生产者消费者模型

/*
提取对象:生产者对象、消费者对象、缓冲区;
分析关系:生产者和消费者访问缓冲区是互斥的;
 		   因为缓冲区大小是有限制的,故只有生产者生产了产品,消费者才能拿走产品;
	如果缓冲区为空,消费者进程就会被阻塞,这是同步关系;
		   如果缓冲区满了,生产者进程就会被阻塞;只有消费者进程消费了产品,生产者才能生产产品,这也是同步关系。
*/
//用于互斥访问缓冲区
Semophore mutex = 1;
//用于产品和容量同步
Semophore capacity = n;
Semophore product = 0;
void Producer()
{
	while(1){
		//生产产品
		P(capacity);
		P(mutex);
		//将产品放入缓冲区
		V(mutex);
		V(product);
	}
}

void Consumer()
{
	while(1){
		P(product);
		P(mutex);
		//从缓冲区中取出产品
		V(mutex);
		V(capacity);
		//消费产品
	}
}
//注意:用于访问缓冲区的P操作一定要放在用于同步的P操作之后,否则会造成死锁现象。

读者写者模型
写进程与写进程互斥、写进程和读进程互斥、读进程和读进程不互斥
如果在读、写前后都进行上锁、解锁操作,那么读进程和读进程就是互斥的了。像下面这样:

Semophore mutex = 1;
void Reader()
{
	while(1){
		P(mutex);
		//读数据...
		V(mutex);
	}
}

读者写者问题就是要解决,取消读进程和读进程之间的互斥关系
只让第一个读进程进行上锁操作,最后一个读进程进行解锁操作。想到一个方法,搞个计数器count,用于记录当前是第几个读进程。

int count = 0;
Semophore mutex = 1void Reader()
{
	while(1){
		//先检查当前读进程是否为第一个读进程,如果是,则上锁;否则,不上锁,读与读之间不互斥
		if(count == 0)
			P(mutex);
		count++;
		//读数据...
		count--;
		//检查是否为最后一个读进程,如果是,则解锁
		if(count == 0)
			V(mutex);
	}
}

上面的代码并非完全正确,因为存在多个进程并发访问count变量的问题。
原因:检查count变量和更新count变量不是一气呵成的
这造成的结果是:当count=0,读进程A执行到P(mutex)后,count++前发生进程切换;读进程B检查到count=0,仍然会执行上锁操作,这就造成了读进程B阻塞的现象。
解决办法:所有的读进程互斥地访问count计数器,搞一把锁,锁住count计数器。代码如下:

int count = 0;
Semophore mutex = 1//新增互斥量
Semophore mutex_count = 1;
void Reader()
{
	while(1){
		//先检查当前读进程是否为第一个读进程,如果是,则上锁;否则,不上锁,读与读之间不互斥
		P(mutex_count);
		if(count == 0)
			P(mutex);
		count++;
		V(mutex_count);
		//读数据...
		P(mutex_count);
		count--;
		//检查是否为最后一个读进程,如果是,则解锁
		if(count == 0)
			V(mutex);
		V(mutex_count);
	}
}

上述代码已经做了很大改进,实现了读进程与读进程不互斥的功能,但并不完美。
如果系统中创建很多对该资源的读进程,就会造成写进程饥饿的现象。因为只要有进程在读临界资源,写进程就会被阻塞(等待最后一个读进程释放锁);而新来的读进程却不需要等待直接可以读临界资源,这样写进程等待时间变得更长了。
读写公平
解决办法如下:

int count = 0;
Semophore mutex = 1//新增互斥量
Semophore mutex_count = 1;
Semophore rw = 1;
void Reader()
{
	while(1){
		P(rw);
		
		//先检查当前读进程是否为第一个读进程,如果是,则上锁;否则,不上锁,读与读之间不互斥
		P(mutex_count);
		if(count == 0)
			P(mutex);
		count++;
		V(mutex_count);

		V(rw)
		
		//读数据...
		P(mutex_count);
		count--;
		//检查是否为最后一个读进程,如果是,则解锁
		if(count == 0)
			V(mutex);
		V(mutex_count);
	}
}

void Writer()
{
	while(1){
		P(rw);
		P(mutex);
		//写数据...
		v(mutex);
		V(rw);
	}
}

进程控制块

用来描述和控制进程运行的一种数据结构,是进程实体的一部分,是进程存在的唯一标识。
PCB中包含以下几种信息:
1. 进程标识符
进程标识符用于唯一的标识一个进程,每个进程都被操作系统赋予了一个唯一的数字标识符
2. 处理机状态
处理机的状态信息主要是由处理机各种寄存器中的内容组成。包括:通用寄存器、指令计数器、程序状态字PSW和用户栈指针。当进程被中断时,必须将这些寄存器的值保存到PCB中,以便重新运行时能够恢复到中断前的状态。
3. 进程调度信息
进程调度信息主要包括:进程的状态、进程优先级、阻塞原因等,这些信息都和进程的切换(即调度)相关。
4. 进程控制信息
主要包括程序和数据的地址信息、进程同步和通信机制信息、资源清单(分配到该进程的资源)、链接指针(指向队列中相同状态的下一个PCB的地址)。

PCB的组织方式:
PCB组织方式是指如何组织和管理多个PCB。有两种方式:
1. 链接方式
把处在同一状态的PCB,用链表的形式组织成一个队列,于是形成了:就绪队列、阻塞队列、空白队列等。就绪队列中常按进程优先级的高低排列,优先级较高的PCB放在队列前面。
链接方式

2. 索引方式
索引方式是指,根据PCB的状态,创建几张索引表,如:就绪索引表、阻塞索引表等。表项记录了相应状态的PCB在PCB表中的地址。
索引方式

进程和线程区别

根本区别:
进程是资源分配的基本单位;线程是处理机调度的基本单位。
资源开销:
每个进程都有独立的代码和数据空间,进程之间的切换开销较大;
线程可以看作是轻量级进程,同一进程内的线程共享代码和数据空间,每个线程都有自己独立的栈和程序计数器,线程之间的切换开销较小。
包含关系:
一个进程中可以包含多个线程。
内存分配:
同一进程的线程共享本进程的地址空间和资源;进程之间的地址空间和资源是相互独立的。

线程

什么时候使用多线程?(多线程优点)

  1. 处理耗时操作时使用多线程,提高程序的响应(如项目中UI线程和计算线程)。
  2. 并行处理多任务时(如基于多线程的C/S结构服务器、项目中采集数据计算数据)。
  3. 多CPU系统中,提高CPU的利用率。

线程私有资源

线程的使用方法是:创建线程对象,向线程对象中传递线程函数,因此线程的运行本质是函数的执行。函数运行时的信息保存在栈帧中,这些信息包括:函数的参数局部变量返回值以及函数使用的寄存器信息,因此每个线程都拥有自己独立的栈空间
线程私有的栈空间

线程间的共享资源

进程的虚拟地址空间布局如下图所示:
进程虚拟地址空间

  1. 代码段
    操作系统在创建进程时,会将可执行程序加载到内存;进程所属内存中,存放机器指令的内存便称为代码段。线程之间共享进程的代码段,即程序中的任何一个函数都能当作线程函数去执行。
  2. 数据段
    进程的数据段存放全局变量和静态变量,线程间共享这些变量。
  3. 堆空间
    只要知道堆空间上变量的地址,便可以访问这些变量,线程间共享堆空间。
  4. 栈空间(有限制)
    如果一个线程能够拿到另外一个线程栈帧上的指针,就可以访问另外一个线程上的资源。如在主线程中创建子线程,同时向子线程中传递线程参数。
  5. 文件
    在这里插入图片描述

为什么使用线程?

  1. 线程的创建、切换、销毁等开销小。创建进程时,操作系统会为其创建虚拟地址空间,定义很多数据去维护虚拟地址空间的各个内存区域。
  2. 线程间的通信更方便。对于进程来说,进程用于不同的地址空间,要进行通信的话只能通过特定的进程间通信方式完成。而对于线程来说,同一进程下的各个线程之间共享数据空间,但同时也会引入线程安全的问题。

线程状态

  1. 就绪态。等待被CPU调度。
  2. 运行态。正在被CPU调度。
  3. 阻塞态。不被CPU调度,直到等待的事件发生后转为就绪态。
  4. 终止态。已经执行完毕,等待回收线程资源。

虚拟地址空间

现代的处理器使用的是虚拟地址寻址,CPU需要将虚拟地址转换成物理地址后才可以进行访存;在CPU中负责虚拟地址到物理地址转换的是MMU(内存管理单元)。虚拟指的是每个进程的起始地址都被虚拟化为0。
为什么要使用虚拟地址空间?
如果不使用虚拟地址空间,说明进程是直接访问物理内存的,这就会出现几个问题:
1. 进程地址空间不隔离
由于进程直接访问物理内存,那么在多任务处理系统中,一个进程就可能有意无意地去修改了其它进程的内存数据,导致其它进程运行出现异常。这也不满足进程的独立性特点。
引入虚拟地址空间后,就相当引入了一个中间层,进程首先访问的是虚拟地址,访问虚拟地址时可以确保不会越界;然后操作系统负责完成虚拟地址到物理内存地址的映射,这样进程间就达到了隔离的效果。
地址隔离
2. 内存使用效率低。
如果直接使用物理地址,一个进程就对应连续的内存块;在进程的换入换出操作中,就要将整个进程搬走,导致效率低下。
进程虚拟空间对应的各个分区:
分区
每个进程的虚拟地址空间都被分成两部分:内核区 + 用户区
注意:系统中所有进程虚拟地址空间中的内核区都会映射到同一块物理内存上,因为操作系统内核只有一个。






C++语法

指针和引用的区别

  1. 性质和定义不同。指针是个变量,变量值为内存地址;引用是对象的别名。指针通过<数据类型>*定义,引用通过<数据类型>&定义。
  2. 指针的声明和定义可以分开;在声明引用时一定要初始化。
  3. 指针可以初始化为空;引用不能初始化为空。
  4. 指针初始化后可以改变指向;引用在初始化后不能改变指向。
  5. 指针作为参数传递时,传递的是指针变量的值;引用作为参数传递时,传递的是实参本身,没有发生拷贝。

智能指针

智能指针是一个类,类中包含一个指针指向在堆空间上动态分配的对象。当智能指针对象生命周期结束时,自动释放动态分配对象的空间。

shared_ptr

共享指针,允许多个智能指针指向同一个动态分配对象
每当一个新的智能指针指向该对象时,该对象的引用计数就会+1;每当析构一次该对象时,该对象的引用计数就会-1。当引用计数减为0时,自动释放该动态分配的资源。
shared_ptr的核心就是引用计数:

  • 引用计数不能使用静态变量来实现,因为静态变量属于类资源,引用计数是指该类的对象的引用次数。如果引用计数使用静态变量,则该类的所有对象共享同一份引用计数,当shared_ptr指向不同的对象时,它们却在共享同一份引用计数,这是不对的。如果类只实例化一个对象,此时是可以使用static变量作为引用计数的。
  • 引用计数不能使用shared_ptr的数据成员(data member),使用shared_ptr的数据成员是指将引用计数保存在每一个shared_ptr对象中,结果就是指向堆空间上同一动态分配对象的shared_ptr无法共享同一份引用计数
  • 解决方法:将引用计数保存在动态内存中。当创建一个新的shared_ptr同时分配一个新的引用计数;当拷贝或赋值shared_ptr对象时,复制原shared_ptr指向引用计数的指针。这样,指向同一动态分配对象的所有shared_ptr共享同一份引用计数!

以下为shared_ptr的代码简易实现:

tempplate<typename T>
class SharedPtr
{
public:
	SharedPtr(T* ptr = nullptr) : _ptr(ptr), _pcount(new int(1)){}
	SharedPtr(const SharedPtr& s) : _ptr(s.ptr), _pcount(s._pcount){(*_pcount)++;}
	SharedPtr<T>& operator=(const SharedPtr& s){
		//自我检测,防止出错
		if(this != &s){
			if(--(*(this->_pcount)) == 0){
				delete this->_ptr;
				delete this->_pcount;
			}
			this->_ptr = s._ptr;
			this->_pcount = s._pcount;
			//引用计数+1
			*(this->_pcount)++;
		}
		return *this;
	}
	T& operator*(){
		return *(this->_ptr);
	}
	T* operator->(){
		return this->_ptr;
	}
	//析构函数
	~SharedPtr(){
		//每调用一次析构函数,引用计数就需要减1
		--(*(this->_pcount));
		//当引用计数减为0时,释放堆空间上动态分配的对象和引用计数
		if(*(this->_pcount) == 0 ){
			delete this->_ptr;
			this->_ptr = nullptr;
			delete this->_pcount;
			this->_pcount = nullptr;
		}	
	}
private:
	//指向堆空间上动态分配的对象
	T* _ptr;
	//指向引用计数
	int* _pcount;
}

shared_ptr ptr(new T) 和 make_shared()的区别:
前者是先在堆上开辟一块空间存放T,再由shared_ptr的构造函数在堆上开辟一块空间存放引用计数;这两步是不连续的,容易产生内存碎片;分配两次内存。
后者是一次性为对象和引用计数分配一块连续的内存,只分配一次内存,不容易产生内存碎片。

注意:
-不能使用原始指针初始化多个shared_ptr对象

int* rawPt = new int(5);
shared_ptr<int> shpt(rawPt);
shared_ptr<int> shpt2(rawPt);
/*
	上面的做法是错误的,如果用同一个原始指针初始多个shared_ptr对象,在释放内存的时候,会出现double free现象。
	为什么会出现double free呢?
	看一下shared_ptr的初始化构造函数就知道了
*/
//下面是简易版的构造函数:
SmartPtr(T* ptr = nullptr) : _ptr(ptr), _pcount(new int(1)){}
//观察SmartPtr的构造函数发现,如果传入的是对象的原始指针,会在堆空间上重新开辟一块空间保存引用计数
//shpt, shpt2的问题在于,两个智能指针指向的是堆上的同一个对象,然而没有共享一份引用计数;
//那么在shpt、shpt2超出作用域时,指针所指对象会被释放两次,出现错误!

unique_ptr

unique_ptr是一个独占型的智能指针,同一时刻只能有一个unique_ptr指向堆空间上动态分配的对象。因此,unique_ptr不支持普通的拷贝构造和赋值操作,但可以通过std::move将控制权转移至其它的unique_ptr。

weak_ptr

weak_ptr是一个弱引用,它是为了配合shared_ptr而引入的一种智能指针。它指向了shared_ptr管理的堆上内存,但是只引用不计数

shared_ptr有以下几个缺点:

  1. 循环引用导致内存泄漏;
  2. 使用同一个原始指针初始化多个共享指针时出现double free问题;
  3. 函数不能返回管理了this的共享指针对象

对于循环引用问题:

template<typename T>
class Node
{
public:
	Node(const T& val) : m_val(val), m_pPre(shared_ptr<Node<T>>()), m_pNext(shared_ptr<Node<T>>()){
		cout << "Node constructor..." << endl;
	}
	~Node(){
		cout << "Node destructor..." << endl;
	}
	int m_val;
	shared_ptr<Node<T>> m_pPre;
	shared_ptr<Node<T>> m_pNext;
}

int main()
{
	shared_ptr<Node<int>> shptr1(new Node<int>(1));
	shared_ptr<Node<int>> shptr2(new Node<int>(2));
	cout << "shptr1指向对象的引用计数为:" << shptr1.use_count() << endl;
	cout << "shptr2指向对象的引用计数为:" << shptr2.use_count() << endl;
	//相互引用
	shptr1->m_pNext = shptr2;
	shptr2->m_pPre = shptr1;
	cout << "shptr1指向对象的引用计数为:" << shptr1.use_count() << endl;
	cout << "shptr2指向对象的引用计数为:" << shptr2.use_count() << endl;

	return 0;
}
上面代码的执行结果是:
shptr1指向对象的引用计数为:1
shptr2指向对象的引用计数为:1
shptr1指向对象的引用计数为:2
shptr2指向对象的引用计数为:2
函数退出时,shptr1和shptr2的引用计数变成1,堆空间上的内存没有被释放掉,出现内存泄漏!

解决方法:使用weak_ptr解决循环引用,因为weak_ptr指向堆空间上动态分配的对象时,只会引用不会改变引用计数。如下所示:

template<typename T>
class Node
{
public:
	Node(const T& val) : m_val(val), m_pPre(weak_ptr<Node<T>>()), m_pNext(weak_ptr<Node<T>>()){
		cout << "Node constructor..." << endl;
	}
	~Node(){
		cout << "Node destructor..." << endl;
	}
	int m_val;
	weak_ptr<Node<T>> m_pPre;
	weak_ptr<Node<T>> m_pNext;
}

Lambda表达式

Lambda表达式的优点:

  • 简洁:不需要额外的再写一个函数,避免功能分散,让开发者集中精力在当前的问题上,提高生产率;
  • 声明式的编程风格:就地定义函数,有更好的可读性和可维护性。

语法形式如下:

[ capture ] ( params ) opt -> ret { body; };

[capture] 为捕获列表,可以捕获一定范围内的变量,有以下几种:

  1. [] ,不捕获外部变量;
  2. [=] ,按值捕获外部作用域的所有变量,但默认在Lambda主体中不能修改该变量的副本;
  3. [&] ,按引用捕获外部作用域的所有变量;
  4. [this],捕获当前类中的this指针;
  5. 混合捕获。

右值引用

通过引入右值引用优化性能,即通过移动语义来避免不必要的拷贝问题。要实现移动语义,必须要采取某种方式告诉编译器什么时候需要拷贝对象,什么时候不需要。因此,我们需要定义一个移动构造函数,它使用右值引用作为参数。
实现移动语义的步骤:

  • 右值引用告诉编译器什么时候使用移动语义;
  • 编写移动构造函数,完成动态资源所有权的转移;

移动构造函数不会执行深拷贝,而是将所有权转移至其它对象。

  • 左值:lvalue(locator value),可以取地址的值。
  • 右值:rvalue(read value),不能取地址的值;C++11中将右值分为两类:纯右值和将亡值。
  • 纯右值:主要包括字面值(除字符串字面值)、函数的非引用返回值某些表达式的值(++、–、Lambda表达式、逻辑、比较、算术表达式等)。
    i++属于纯右值,因为创建一个临时副本保存i,然后i自增,最后返回刚才的临时副本;i–相同;
    ++i属于左值,因为i先加1再赋值给i;++i返回的是引用,i++返回的是拷贝的对象。
    此外,对于内置类型,++i的效率和i++差不多;对于自定义数据类型,++i的效率比i++效率高(因为i++多了一个拷贝原对象的过程)。
//++i代码实现,前置返回一个引用,效率较高
int& operator++()
{
	(*this) += 1;
	return (*this);
}

//i++代码实现,后置返回一个拷贝对象,效率较低
int operator++(int)
{
	int temp = *this;
	++(*this);
	return temp;
}
//字符串字面值是左值,可以取地址,这是个例外。因为C++用char类型数组实现了字符串字面值
cout << &("hello world") << endl;
x + y;
//x + y是得到的是一个不具名的临时对象,是一个纯右值
  • 将亡值:xvalue,即将消亡的值,通常是要被移为它用的对象,包括函数的右值引用返回值std::move的返回值右值转换函数的返回值。将亡值可以理解为将当前管理的资源转移至其它变量,可以避免内存空间的分配和释放。

野指针

什么是野指针?
野指针是指指针指向的位置是随机的、不确定的。
野指针产生的原因?

  1. 定义指针时没有初始化
int* ptr;
cout << *ptr << endl;
//以上代码编译时就会出错,引用了未初始化的指针(即野指针)
  1. delete释放指针后没有置空
int* p = new int(11);
delete p;
cout << *p << endl;
//以上代码在运行时报错

释放指针后未置空,却又重新引用

  1. 返回局部变量的指针
int* func()
{
	int a = 11;
	int* p = &a;
	return p;
}
int main()
{
	int* ptr = func();
	cout << *ptr << endl;
	return 0;
}

以上代码虽然不会报错,但是会返回一个随机的、不确定的值。

如何避免野指针?
定义指针时一定要初始化;
指针释放后一定要置空。

C和C++区别

  1. C是面向过程的语言,不支持类和对象;C++是面向对象的语言。
  2. 动态内存管理方面,C通过malloc/free申请、释放动态内存;C++通过new/delete申请、释放动态内存。
  3. C不支持函数重载,因为在编译过程中经过名字修饰后同名函数的符号名字相同,链接时就会出错;C++支持函数重载,因为在编译过程中会对符号名追加修饰。
  4. C++中有引用;C没有。

const使用

  1. const修饰普通类型变量,将其变成常量,不能再次赋值。
  2. const修饰指针,有两种情况,常量指针:指针所指向的值是常量,不能修改;指针常量:指针是常量,不能修改其指向。
  3. const修饰普通函数,包括修饰返回值和参数。
  4. const修饰成员函数,包括修饰函数本身:表示在成员函数内部不能修改对象的成员变量,也不能调用非const成员函数;const修饰引用参数:表示在函数内部不能修改引用参数的值

define和const区别

  1. define在编译的预处理阶段起作用;const在编译、运行阶段都起作用。
  2. const可以使用数据类型,因此可以进行安全检查;define没有数据类型,只进行替换,没有安全检查。

STL迭代器

迭代器的定义:提供一种方法,在不暴露容器内部结构的情况下,遍历容器中的各个元素。
STL的设计思想是将容器和算法分开单独设计,然后用迭代器将其撮合起来。
迭代器的本质:迭代器本质是一种智能指针,对原始的指针进行封装。迭代器的最重要的两个操作:解引用和成员访问操作。
迭代器的分类:输入迭代器、输出迭代器、前向迭代器、双向迭代器和随机访问迭代器。
迭代器的类型推导:STL的设计思想是将算法和容器分开,在算法设计中,可能要用到迭代器所指对象的类型去定义中间变量。因此需要一种方法提取迭代器所指对象的数据类型,STL用的方法是traits。
traits技法
STL专门设计了一个iterator_traits模板类(其实是模板结构体)用来萃取迭代器or指针的数据类型。
对于迭代器,在设计迭代器时声明内嵌数据类型:

template <class T>
struct MyIterator{
	typedef T value_type;	//声明内嵌数据类型
	T* ptr;	//封装原生指针
	MyIterator(T* p) : ptr(p){}
	//解引用
	T& operator*(){
		return *ptr;
	}
	//成员访问
	T* operator->(){
		return ptr;
	}
	//......
}

于是,针对某个算法传入的迭代器,可以通过如下方法提取迭代所指向的数据类型:

template <class T>
struct iterator_traits{
	typedef typename T::value_type  value_type;
}

以上设计有一个问题,如果iterator_traits的模板参数是指针类型,那么iterator_traits无法提取该指针对应的数据类型。解决这种问题的方法是利用模板偏特化(template partial specialization)。
所谓模板偏特化,是指将参数的类型进一步限制。
于是,iterator_traits可以修改成:

//如果模板参数是迭代器,则可以通过迭代器的内嵌类型提取出迭代器的value_type
template <class T>
struct iterator_traits{
	typedef typename T::value_type  value_type;	
	//......
}

//如果iterator_traits的模板参数是指针类型
template <class T>
struct iterator_traits<T*>{
	typedef T value_type;
}

//如果iterator_traits的模板参数是常量指针类型
template <class T>
struct iterator_traits<const T*>{
	typedef T value_type;
}

上面最后两个iterator_traits的定义就是模板偏特化。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值