漱石斋笔谈

gaotf 的博客

使用pthread中的条件变量和锁,构建了一个可以模拟死锁的脚本,能实现每个线程按顺序执行,从而形成固定死锁场景,方便测试死锁算法性能,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
#include <stdbool.h>
#include "libpq-fe.h"
#include <unistd.h>

#define RING_TRANS_NUM 5
#define OTHER_TRANS_NUM 10

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t conds[RING_TRANS_NUM + OTHER_TRANS_NUM];
static int turn = 0; // 当前执行的线程编号

typedef struct Tag
{
int row_id;
int thread_id;
}Tag;

enum cn_port {
cn01 = 1,
cn02 = 2,
};

void free_and_end_trans(PGconn *conn, PGresult *res)
{
if(res != NULL)
{
PQclear(res);
}
PQfinish(conn);
pthread_exit(NULL);
}

void *other_thread_function(void *arg)
{
PGconn *conn = NULL;
PGresult *res = NULL;
char conn_info[100];
int cur_port = rand() % 2 + cn01;

snprintf(conn_info, sizeof(conn_info),
"dbname = postgres user = gaotianfu password = 129212351GTFgtf_ host = 127.0.0.1 port = %d", cur_port);

conn = PQconnectdb(conn_info);
if (PQstatus(conn) != CONNECTION_OK) {
free_and_end_trans(conn, res);
}

/* 开启事务 */
res = PQexec(conn, "BEGIN");
if (PQresultStatus(res) != PGRES_COMMAND_OK) {
free_and_end_trans(conn, res);
}
PQclear(res);

Tag *tag = (Tag *)arg;

pthread_mutex_lock(&mutex);
while (turn != tag->thread_id) {
pthread_cond_wait(&conds[tag->thread_id], &mutex);
}

turn = turn == (RING_TRANS_NUM + OTHER_TRANS_NUM - 1) ? (RING_TRANS_NUM - 1) : turn + 1;
pthread_cond_signal(&conds[turn]); // 通知下一个线程
pthread_mutex_unlock(&mutex);

char update_query[100];
snprintf(update_query, sizeof(update_query), "UPDATE a SET str = '11' WHERE id = %d", tag->row_id);
free(tag);
res = PQexec(conn, update_query);
if (PQresultStatus(res) == PGRES_COMMAND_OK)
{
PQclear(res);
}

res = PQexec(conn, "END");
free_and_end_trans(conn, res);
return NULL;
}

void *ring_thread_function(void *arg) {
PGconn *conn = NULL;
PGresult *res = NULL;
char conn_info[100];
int cur_port = rand() % 2 + cn01;

snprintf(conn_info, sizeof(conn_info),
"dbname = xxx user = xxx password = xxx host = xxx port = %d", cur_port);

conn = PQconnectdb(conn_info);
if (PQstatus(conn) != CONNECTION_OK) {
free_and_end_trans(conn, res);
}

/* 开启事务 */
res = PQexec(conn, "BEGIN");
if (PQresultStatus(res) != PGRES_COMMAND_OK) {
free_and_end_trans(conn, res);
}
PQclear(res);

Tag *tag = (Tag *)arg;

for(int i = 0; i < 2; i++)
{
pthread_mutex_lock(&mutex);
while (turn != tag->thread_id) {
pthread_cond_wait(&conds[tag->thread_id], &mutex);
}
// printf("%lu my_thread_id is %d, ture is %d ",pthread_self(), tag->thread_id, turn);

char update_query[100];
snprintf(update_query, sizeof(update_query), "UPDATE a SET str = '11' WHERE id = %d", tag->row_id);

if(i == 1)
{
turn = turn == (RING_TRANS_NUM - 2) ? RING_TRANS_NUM: turn + 1; // 倒数第二个 ring_thread 通知 other_thread
pthread_cond_signal(&conds[turn]);
pthread_mutex_unlock(&mutex);
printf("the %d time, %lu update %d, signal %d\n", i, pthread_self(), tag->row_id, turn);
}
res = PQexec(conn, update_query);
if (PQresultStatus(res) == PGRES_COMMAND_OK)
{
if((tag->row_id == RING_TRANS_NUM + 1) && (i == 1))
{
printf(" make deadlock\n");
}
else
{
printf(" %lu update %d\n", pthread_self(), tag->row_id);
}
PQclear(res);
}
if(i == 1)
{
free(tag);
}
if(i == 0)
{
tag->row_id = (tag->row_id == RING_TRANS_NUM) ? 1 : tag->row_id + 1;
turn = turn == (RING_TRANS_NUM - 1) ? 0 : (turn % RING_TRANS_NUM) + 1;
pthread_cond_signal(&conds[turn]); // 通知下一个线程
pthread_mutex_unlock(&mutex);
printf("the %d time, %lu , signal %d\n", i, pthread_self(), turn);
}
}

res = PQexec(conn, "END");
free_and_end_trans(conn, res);
return NULL;
}


int main(int argc, char *argv[]) {
for (int i = 0; i < RING_TRANS_NUM + OTHER_TRANS_NUM; ++i) {
pthread_cond_init(&conds[i], NULL); // 初始化条件变量
}

pthread_t *ring_thread;
ring_thread = (pthread_t *) malloc(sizeof(pthread_t) * RING_TRANS_NUM);

pthread_t *other_thread;
other_thread = (pthread_t *) malloc(sizeof(pthread_t) * OTHER_TRANS_NUM);

/* 形成依赖 */
for(int i = 0; i < RING_TRANS_NUM; ++i)
{
Tag *cur_tag = (Tag *) malloc(sizeof(Tag));
cur_tag->row_id = i + 1;
cur_tag->thread_id = i;
pthread_create(&ring_thread[i], NULL, ring_thread_function, (void *) cur_tag);
pthread_detach(ring_thread[i]);
}

/* 其他依赖 */
for(int i = 0; i < OTHER_TRANS_NUM; ++i)
{
Tag *cur_tag = (Tag *) malloc(sizeof(Tag));
cur_tag->row_id = 2; /* 只更新第 2 行 */
cur_tag->thread_id = i + RING_TRANS_NUM;
pthread_create(&other_thread[i], NULL, other_thread_function, (void *) cur_tag);
pthread_detach(other_thread[i]);
}

while (1) {
sleep(10);
}

return 0;
}

上面的代码实现的效果为,首先形成一个长依赖路径,路径上的每个事务都更新自己的数据行,例如第一个事务更新第一行数据,第二个事务更新第二行数据,第RING_TRANS_NUM个事务更新第RING_TRANS_NUM行数据,此时通知第一个事务所在的线程去更新第二行数据,第二个事务去更新第三行数据,直到第RING_TRANS_NUM - 1个事务更新第RING_TRANS_NUM行数据。这样就形成了长依赖路径。

然后添加其他依赖,这些依赖依附于上述长依赖路径,本程序中设置其他依赖都更新第二行数据。当这些其他依赖都执行完事务语句时,通知第RING_TRANS_NUM个事务所在的线程去更新第一行数据,最终形成了死锁环。

1. git仓库的结构

首先介绍以下几个术语:

  • remote:表示远程仓库
  • repository:表示本地仓库
  • workspace:表示自己项目的工作区间
  • index:暂存区,接下来可以commit

2. 工作流

推荐使用 github flow 的工作模式,即不在主仓库的分支上开发,而是首先 fork 到自己的 workspace 下。每次开发,首先 checkout 一个分支,开发后 commit,并 push 到自己的仓库内,再向主仓库提 MR

2.1 fork

一般主仓库是组长建立的,保存着所有人开发的代码,为了防止污染导致数据丢失问题,剩下的小组成员需要对主仓库进行 fork,即将其拷贝一份到自己账户下,这样之后的开发工作就可以从自己的仓库开始。

2.2 clone

接下来从自己的仓库进行clone。需要注意的是,一般一个仓库都有多个分支,比如有 master 主分支和 develop 分支,所以 clone 时要找到对应的分支,例如要克隆 develop 分支,使用的命令如下:

git clone -b develop ssh链接

命令中 ssh 链接需要填写自己仓库下的 ssh 链接。

2.3 添加远程仓库

在项目的根目录重新打开 git bash,首先输入 git remote -v 来查看现有的仓库

其中的 origin 表示是自己的远程仓库名称,一个用来 fetch,一个用来 push。接下来需要将组长的主仓库添加进来,使用的命令为:

git remote add upstream 主仓库ssh链接

其中upstream是给主仓库起的名字,这个名字可以是任意的。再次查看当前的远程仓库:

2.4 拉取最新代码

由于主仓库是合并开发,因此其代码进度是最快的,在每次开发前,都需要从主仓库拉取最新的代码,例如要从主仓库拉取 develop 分支的最新代码。这里有两种方式,第一种是使用 git fetch + git merge 的组合方式,好处是可以在 fetch 后查看更新,从而在 merge 的时候更方便的解决潜在的代码冲突;第二种方式是使用 git pull,它会立即拉取远程仓库的更新,并且直接合并到你的本地分支,这种情况下会导致合并后的代码产生大量冲突,解决起来很麻烦。因此推荐使用第一种组合方式。使用的命令为:

git fetch upstream develop

由于我们之前已经在本地拉取了 develop 分支,因此上面的 git fetch 命令是不创建本地新分支的方式,然后使用如下代码查看版本的差异

git log -p develop..upstream/develop

或者

git diff develop

然后使用如下代码进行合并

git merge upstream/develop

需要注意的是,合并过程中可能出现冲突的情况,我们可以对冲突的文件进行编辑。冲突的产生是多个用户同时修改了同一个文件的相同区域的内容。具体会发生冲突的情况和解决方法可以查看git中发生冲突的原因和解决方案

2.5 开发

通过输入 git branch 可以查看当前处于哪个分支;输入 git branch -a 可以查看所有的本地分支和远程分支;以及可以输入 git status 来查看当前分支和分支的状态。

2.6 新建分支

有了最新的代码,就可以开始着手写代码了。但是需要注意,不要直接在本地的develop分支上直接写,而是要新建一个分支,使用如下命令:

git checkout -b check # (分支名称,随意)

这样我们就新建了一个check分支,且直接切换到其上,上面的代码其实做了两件事:

git branch check # 新建 check 分支
git checkout check # 切换到 check 分支上

2.7 贮藏代码

当你想提交代码时,始终要记得,主仓库内的代码可能还是比你的进展要更快,因此你还需要到主仓库中拉取最新的代码,这里需要你先把当前的进度 commit 到本地暂存区,或者使用 git stash,将代码贮藏来保管当前的进度。使用的命令为:

git stash save "save message"

然后切换到 develop 分支上,还是使用 git fetch 和 git merge 去拉取合并最新的代码。贮藏的代码实际上被储存在一个栈上,越晚贮藏的越接近栈顶,可以使用如下代码查看栈中贮藏的信息:

git stash list

可以看到有三个贮藏,使用如下命令可以将栈顶的贮藏重新应用

git stash apply

如果想应用其中一个更早的贮藏,可以通过名字来指定

git stash apply stash@{2}

如上的命令只会尝试应用贮藏的工作,意味着堆栈上还有它,可以使用如下命令将贮藏从堆栈上移除:

git stash drop stash@{2}

或者可以使用如下代码来应用栈顶的贮藏,并且将其从栈顶弹出

git stash pop

最后记得要将 check 分支合并到本地的 develop 分支上,之后的操作都要在 develop 上进行。使用的命令如下:

git checkout develop
git merge check

2.8 提交和推送

接下来要将代码提交到本地仓库,首先查看代码的状态

git diff # 可以查看到这次的改动内容,按 q 退出

然后使用跟踪所有的文件

git add .

随后提交到暂存区

git commit -m "add a new txt file"

随后就可以推送到自己的远程仓库

git push origin HEAD:分支名称

这里的 HEAD 指的是当前分支,冒号后面指的是要 push 的分支,如果远程仓库没有,则会自动创建该分支

2.9 提MR

在自己的远程仓库内,可以向主仓库提交分支合并请求,一般是自己的 develop 分支合并到主仓库的 develop 分支,如下时 gitlab 的示例:

接下来可以增加这次合并请求的描述,加 title 和 description等,之后就可以 submit 提交。
分支合并请求通过后,可以选择将本地的 check 分支删除

git branch -d check

2.10 注意点

如果有项目伙伴因需求更改了项目文件名称或是一些文件夹的名称,需要告知所有在工作中的伙伴停下手中的工作,等待文件名称修改后,push到仓库后,其他伙伴再拉新的代码,再进行工作。

关系型数据库和非关系型数据库的区别

1.关系型数据库:指用关系模型来组织数据信息的数据库
关系模型指的是二维表格模型,而一个关系型数据库便是由二维表以及表之间的关系所构成的一个数据集合。
2.关系型数据库的优势:

  • 便于理解:二维表构造非常贴近逻辑;
  • 应用方便:支持通用的SQL(结构化查询语言)语句;
  • 易于维护:全部由表结构组成,文件格式一致;
  • 复杂操作:可以用SQL句子多个表之间做非常繁杂的查询;
  • 事务管理:促使针对安全性性能很高的数据信息浏览规定得到完成。
    3.关系型数据库存在的不足
  • 读写性能差,尤其是海量信息的效率高读写能力;
  • 固定不动的表构造,灵便度稍欠;
  • 高并发读写时,硬盘I/O存在瓶颈;
  • 可扩展性不足,不像web server和app server那样简单的添- 加硬件和服务节点来拓展性能和负荷工作能力。

4.非关系型数据库概念介绍
非关系型数据库:指非关系型的,分布式系统的,且一般不确保遵照ACID标准的数据储存系统。
非关系型数据库算是一种数据结构化储存的集合,可以是文档或键值对等。
5.非关系型数据库的类型:

  • 键值储存数据库
  • 列储存数据库
  • 文档型数据库
  • 图数据库
    6.非关系型数据库的优点
  • 格式灵活:数据存储格式非常多样,应用领域广泛,而关系型数据库则只适用基础的关系模型。
  • 性能优越:NOSQL是根据键值对的,不用历经SQL层的分析,因此 性能非常高。
  • 可扩展性:基于键值对,数据之间耦合度极低,因此容易水平扩展。
  • 低成本:非关系型数据库部署简易,且大部分可以开源使用。
    6.非关系型数据库的不足:
  • 不支持sql,学习和运用成本比较高;
  • 无事务处理机制;
  • 数据结构导致复杂查询不容易实现。

为什么使用索引

  • 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。
  • 可以大大加快数据的检索速度,这也是创建索引的最主要的原因。
  • 帮助服务器避免排序和临时表
  • 将随机IO变为顺序IO。

执行一条SQL查询语句期间发生了什么

1.连接器:建立连接,管理连接、校验用户身份;
2.查询缓存:查询语句如果命中查询缓存则直接返回,否则继续往下执行。MySQL 8.0 已删除该模块;
3.解析 SQL,通过解析器对 SQL 查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段、语句类型;
4.执行 SQL:执行 SQL 共有三个阶段:

  • 预处理阶段:检查表或字段是否存在;将 select * 中的 * 符号扩展为表上的所有列。
  • 优化阶段:基于查询成本的考虑, 选择查询成本最小的执行计划;
  • 执行阶段:根据执行计划执行 SQL 查询语句,从存储引擎读取记录,返回给客户端;

MyISAM和InnoDB实现B树索引方式的区别是什么?

1.MyISAM,B+Tree叶节点的data域存放的是数据记录的地址,在索引检索的时候,首先按照B+Tree搜索算法搜索索引,如果指定的key存在,则取出其data域的值,然后以data域的值为地址读取相应的数据记录,这被称为“非聚簇索引”
2.InnoDB,其数据文件本身就是索引文件,相比MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按B+Tree组织的一个索引结构,树的节点data域保存了完整的数据记录,这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引,这被称为“聚簇索引”或者聚集索引,而其余的索引都作为辅助索引,辅助索引的data域存储相应记录主键的值而不是地址,这也是和MyISAM不同的地方。
在根据主索引搜索时,直接找到key所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。因此,在设计表的时候,不建议使用过长的字段为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。

Innodb为什么要用自增id作为主键?

如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。 如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置, 频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE(optimize table)来重建表并优化填充页面。

你了解MySQL的内部构造吗?一般可以分为哪两个部分?

1.服务层包括连接器、查询缓存、分析器、优化器、执行器等
2.存储引擎层负责数据的存储和提取

说一说Drop、Delete与Truncate的共同点和区别

Drop、Delete、Truncate都表示删除,但是三者有一些差别: Delete用来删除表的全部或者一部分数据行,执行delete之后,用户需要提交(commmit)或者回滚(rollback)来执行删除或者撤销删除,会触发这个表上所有的delete触发器。 Truncate删除表中的所有数据,这个操作不能回滚,也不会触发这个表上的触发器,TRUNCATE比delete更快,占用的空间更小。 Drop命令从数据库中删除表,所有的数据行,索引和权限也会被删除,所有的DML触发器也不会被触发,这个命令也不能回滚。
因此,在不再需要一张表的时候,用Drop;在想删除部分数据行时候,用Delete;在保留表而删除所有数据的时候用Truncate。

文件索引和数据库索引为什么使用B+树?

1.文件与数据库都是需要较大的存储,也就是说,它们都不可能全部存储在内存中,故需要存储到磁盘上。而所谓索引,则为了数据的快速定位与查找,那么索引的结构组织要尽量减少查找过程中磁盘I/O的存取次数,因此B+树相比B树更为合适。数据库系统巧妙利用了局部性原理与磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次I/O就可以完全载入
2.最重要的是,B+树还有一个最大的好处:方便扫库。B树必须用中序遍历的方法按序扫库,而B+树直接从叶子结点挨个扫一遍就完了,B+树支持range-query非常方便,而B树不支持,这是数据库选用B+树的最主要原因。
3.B+tree的磁盘读写代价更低:B+tree的内部结点并没有指向关键字具体信息的指针(红色部分),因此其内部结点相对B树更小。如果把所有同一内部结点的关键字存放在同一块盘中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多,相对来说IO读写次数也就降低了

数据库为什么要进行分库和分表呢?都放在一个库或者一张表中不可以吗

通过分表,可以减少数据库的单表负担,将压力分散到不同的表上,同时因为不同的表上的数据量少了,起到提高查询性能,缩短查询时间的作用

  • 水平分表:取模分表就属于随机分表,而时间维度分表则属于连续分表。 如何设计好垂直拆分,我的建议:将不常用的字段单独拆分到另外一张扩展表. 将大文本的字段单独拆分到另外一张扩展表, 将不经常修改的字段放在同一张表中,将经常改变的字段放在另一张表中。 对于海量用户场景,可以考虑取模分表,数据相对比较均匀,不容易出现热点和并发访问的瓶颈。
  • 库内分表,仅仅是解决了单表数据过大的问题,但并没有把单表的数据分散到不同的物理机上,因此并不能减轻 MySQL 服务器的压力,仍然存在同一个物理机上的资源竞争和瓶颈,包括 CPU、内存、磁盘 IO、网络带宽等。

索引失效的情况

1.对索引使用左或者左右模糊匹配:因为B+树是按照索引值有序排列的,只能根据前缀进行比较
2.对索引使用函数:不过可以针对函数计算后的值建立一个索引,从而使得该索引的值是函数计算后的值,就可以通过扫描索引来查询数据
3.对索引使用表达式计算
4.对索引隐式类型转换
5.联合索引非最左匹配
6.WHEREA子句中的OR

数据库事务是什么,都有哪些操作?

事务即为数据库的操作序列,要么完全执行,要么完全不执行。满足ACID属性。
A:原子性:要么全做,要么全不做
C:一致性:即事务操作前后满足完整性约束,比如A给B转账,完成后,A的少了200元,B的多了200元
I:隔离性:事务可以是并发的,隔离性可以保证多个事务并发执行由于使用相同的数据而互不干扰。每个事务都有自己的数据空间。
D:持久性:事务完成后,对数据的修改是永久的,即使系统故障也不会丢失。
事务的操作:开启事务,回滚,提交。其中回滚在数据没有提交之前可以回滚到之前的版本。

脏读,幻读,不可重复读的概念

1.脏读:一个事务读到了另一个未提交事务修改过的数据(因为此时第二个事务有可能发生回滚操作)
2.不可重复读:在一个事务内多次读取同一个数据,出现了两次读到的数据不一样的情况(比如A在读一个数据时,B修改了这个数据,当A再次读这个数据时,读到的数据和上一次不一样)
3.幻读:在一个事务内多次查询某个符合查询条件的记录数量,前后两次记录数量不一样(比如数据增加或者被删除)

三者的严重程度:脏读 > 不可重复读 > 幻读

数据库隔离级别

1.读未提交:一个事务还没提交时,其所做的变更就可以被其它事务看到(会发生脏读,不可重复读和幻读)
2.读提交:一个事务提交后,它所做的变更才能被其它事务看到(会发生不可重复读和幻读)
3.可重复读:一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据一样(InnoDB默认的隔离级别)(会发生幻读)
4.串行化:会对记录加上读写锁,后访问的事务必须等前面的事务处理完才能继续执行。

MYSQL有哪些锁

1.全局锁:使得整个数据库处于只读状态
2.表级锁:

  • 表锁:分为读锁(共享锁)和写锁(独占锁)
  • 元数据锁(MDL):对数据库表进行操作时,会自动的给这个表加上MDL,在事务提交后释放。
  • 意向锁:

MySQL如何实现事务提交和回滚

1.实现数据恢复:redo log
redo log分为两部分:

  • 内存中的redo log Buffer是日志缓冲区,这部分数据是容易丢失的
  • 磁盘上的redo log file是日志文件,这部分数据已经持久化到磁盘,不容易丢失
    在MySQL中可以自已控制log buffer刷新到log file中的频率,通过innodb_flush_log_at_trx_commit参数可以设置事务提交时log buffer如何保存到log file中,innodb_flush_log_at_trx_commit参数有3个值(0、1、2),表示三种不同的方式
  • 为1表示事务每次提交都会将log buffer写入到os buffer,并调用操作系统的fsync()方法将日志写入log file,这种方式的好处是就算MySQL崩溃也不会丢数据,redo log file保存了所有已提交事务的日志,MySQL重新启动后会通过redo log file进行恢复。但这种方式每次提交事务都会写入磁盘,IO性能较差
  • 为0表示事务提交时不会将log buffer写入到os buffer中,而是每秒写入os buffer然后调用fsync()方法将日志写入log file,这种方式在MySQL系统崩溃时会丢失大约1秒钟的数据
  • 为2表示事务每次提交仅将log buffer写入到os buffer中,然后每秒调用fsync()方法将日志写入log file,这种方式在MySQL崩溃时也会丢失大约1秒钟的数据

2.undo log
和redo log类似,也分为内存缓冲区和磁盘的日志文件,但是undo log记录的是相反的语句,当事务需要回滚的时候,可以从undo log中找到相应的内容进行回滚操作。

3.二者的区别
为了保证数据的持久性,数据库在执行SQL操作数据之前会先记录redo log和undo log
redo log是重做日志,通常是物理日志,记录的是物理数据页的修改,它用来恢复提交后的物理数据页
undo log是回滚日志,用来回滚行记录到某个版本,undo log一般是逻辑日志,根据行的数据变化进行记录
redo/undo log都是写先写到日志缓冲区,再通过缓冲区写到磁盘日志文件中进行持久化保存
undo日志还有一个用途就是用来控制数据的多版本(MVCC)

http请求报文

http请求报文由请求行、请求头部、空行 和 请求包体 4 个部分组成,如下图所示:

  1. 请求行:请求行由方法字段、URL字段和HTTP协议版本3个部分组成,下面主要介绍一下GET和POST方法:
  • GET:当客户端要从服务器中读取某个资源时,使用GET方法。

  • POST:当客户端给服务器提供信息较多时可以使用POST 方法,POST 方法向服务器提交数据,比如完成表单数据的提交,将数据提交给服务器处理。GET 一般用于获取/查询资源信息,POST 会附带用户数据,一般用于更新资源信息。

  • 如果带参数时,在约定中,GET方法的参数应该放在URL中,POST方法参数应该放在请求体中。例如,如果参数是 name=Java, age = 25,那么GET方法的报文是这样的:

    GET /updateInfo?name=JavA&age=25 HTTP/1.1
    Host: localhost

POST方法的报文为:

POST /updateInfo HTTP/1.1
Host: localhost
Content-Type: application/x-www-form-urlencoded

name=Javanx&age=25

如果不带参数,最大的区别就是二者请求行的方法名称不同而已。实际上两种方法都是基于TCP连接的,GET也可以有请求包体,POST也不一定要使用请求包体,只要客户端和服务端约定好规范即可。

  1. 请求头部:请求头部由关键字/值对组成,每行一对,关键字和值用英文冒号“:”分隔。常见的头部信息有:
  • User-Agent:产生请求的浏览器类型;
  • Accept:客户端可识别的响应内容类型列表;星号 “ * ” 用于按范围将类型分组,用 “ / ” 指示可接受全部类型,用“ type/* ”指示可接受 type 类型的所有子类型;
  • Accept-Language:客户端可接受的自然语言
  • Accept-Encoding:客户端可接受的编码压缩格式;
  • Host:请求的主机名,允许多个域名同处一个IP 地址,即虚拟主机;
  • connection:连接方式(close 或 keep-alive);
  • Cookie:存储于客户端扩展字段,向同一域名的服务端发送属于该域的cookie;
  • content-length:表示包体数据长度,用来POST方法区分下一条请求的界限
  1. 空行:最后一个请求头部之后是一个空行,发送回车符和和换行符,通知服务端以下不再有请求头。
  2. 请求包体:请求包体不在GET方法中使用,而是在POST方法中使用。适用于需要客户填写表单的场合。

http响应报文

HTTP响应报文由状态行、响应头部、空行和响应包体4个部分组成,如下图所示:

下面对响应报文格式进行简单的分析:

  • 状态行:由HTTP协议版本,状态码和状态码的描述文本3个部分组成
  • 状态码由三位数字组成, 常用的有以下:
    1xx:表示服务器已接收了客户端请求,客户端可继续发送请求;
    2xx:表示服务器已成功接收到请求并进行处理;
    3xx:表示服务器要求客户端重定向;
    4xx:表示客户端的请求有非法内容;
    5xx:表示服务器未能正常处理客户端的请求而出现意外错误;
  • 响应头部:
  1. Location:Location响应报头域用于重定向接受者到一个新的位置。例如:客户端所请求的页面已不存在原先的位置,为了让客户端重定向到这个页面新的位置,服务器端可以发回Location响应报头后使用重定向语句,让客户端去访问新的域名所对应的服务器上的资源;
  2. Connection:连接方式
    对于请求来说:close(告诉 WEB 服务器或者代理服务器,在完成本次请求的响应后,断开连接,不等待本次连接的后续请求了)。keepalive(告诉WEB服务器或者代理服务器,在完成本次请求的响应后,保持连接,等待本次连接的后续请求);
    对于响应来说:close(连接已经关闭); keepalive(连接保持着,在等待本次连接的后续请求); Keep-Alive:如果浏览器请求保持连接,则该头部表明希望WEB 服务器保持连接多长时间(秒);例如:Keep-Alive:300;
  • 空行:最后一个响应头部之后是一个空行,发送回车符和换行符,通知服务器以下不再有响应头部。
  • 响应包体:服务器返回给客户端的文本信息

一些问题

  1. GET方法的长度限制:首先说明一点,HTTP 协议没有 Body 和 URL 的长度限制,对 URL 限制的大多是浏览器和服务器的原因。原因是指:解析URL的时候要分配内存,即分配一个buffer来保存所有要储存的数据,处理长 URL 要消耗比较多的资源,为了性能和安全(防止恶意构造长 URL 来攻击)考虑,会给 URL 长度加限制。
  2. POST 方法会产生两个 TCP 数据包?
    有些文章中提到,post 会将 header 和 body 分开发送,先发送 header,服务端返回 100 状态码再发送 body。HTTP 协议中没有明确说明 POST 会产生两个 TCP 数据包,而且实际测试(Chrome)发现,header 和 body 不会分开发送。所以,header 和 body 分开发送是部分浏览器或框架的请求方法,不属于 post 必然行为。
  3. HTTP请求和响应的步骤,例如在浏览器地址栏键入URL,按下回车以后会经历以下流程:
  • 浏览器向DNS服务器请求解析该URL中的域名对应的IP地址
  • 解析出IP地址后,根据该IP地址和默认端口80,和服务器建立TCP连接
  • 浏览器发出读取文件(URL中域名后面部分对应的文件)的HTTP请求,该请求报文作为TCP第三次握手的报文数据发送给服务器
  • 服务器对浏览器请求做出响应,并把对应的html文本发送给浏览器
  • 释放TCP连接:若connection 模式为close,则服务器主动关闭TCP 连接,客户端被动关闭连接,释放TCP 连接;若connection 模式为keepalive,则该连接会保持一段时间,在该时间内可以继续接收请求
  • 浏览器将html文本显示内容
  1. 服务器如何解析POST包体数据,即如何辨别包体结束?
    一般有两种方式:第一种方式就是在包头中有个content-Length字段,这个字段的值的大小标识了POST数据的长度,服务器收到一个数据包后,先从包头解析出这个字段的值,再根据这个值去读取相应长度的作为http协议的包体数据。还有一个格式叫做http chunked技术(分块),大致意思是将大包分成小包
  2. 如果某个客户端通过程序模拟了一个连接请求,但是迟迟不发含有\r\n\r\n的数据,这路连接将一直占用,这里我们可以设置一个最大URL长度,如果超过这个长度还不包含\r\n\r\n的话我们认为连接非法,将连接断开。并且如果一个客户端连接后不发送任何数据,这样会浪费大量的连接资源,所以还需要一个定时器去定时检测哪些http连接超过这个最大时间没发送数据,找到后将连接断开。

DNS解析流程

1.首先看浏览器缓存,若没有则看操作系统缓存,如果没有则去看host文件,如果没有则开始进行对服务器的询问
2.首先客户端询问本地DNS服务器,自己要查找的url的IP地址,如果本地服务器能找到缓存则返回,否则本地服务器会去询问根服务器,根服务器返回给它一个顶级域名服务器的地址;本地服务器会再去询问顶级域名服务器,后者会给它一个权威域名服务器地址,本地服务器再去询问,直到查询到自己要找的url的IP地址,并将其返回给客户端,客户端和目标建立连接

路由表转发规则

如果有多个网卡,即有多个源IP地址,我们需要判断使用哪个来发送IP数据报
1.根据目的IP地址,和本地路由表中的多个条目的子网掩码进行与运算,得到的结果和路由表中的目的IP地址进行比对,正确的就是我们要选择的网卡,
2。如果没有匹配的网卡,那么选择默认网关,其目标IP地址和子网掩码都是0.0.0.0
3.后续将数据包发送给路由器,路由表中的Gateway就表示路由器的IP地址,即源IP地址

如何优化HTTP/1.1

  1. 避免发送HTTP请求:缓存技术(强制缓存和协商缓存)
  2. 在需要发送 HTTP 请求时,考虑如何减少请求次数
  • 减少重定向请求次数:如果有代理服务器,那么重定向的过程可以由它来进行,减少HTTP请求次数(301和308表示客户端可以将重定向响应缓存到本地磁盘,之后自动用新的url来替换之前的url来访问服务器)
  • 合并请求(对于多个小的资源的请求,合并成一个总的资源请求)
  • 延迟发送请求:请求网页的时候,没必要把全部资源都获取到,而是只获取当前用户所看到的页面资源,当用户向下滑动页面的时候,再向服务器获取接下来的资源,这样就达到了延迟发送请求的效果。
  1. 减少 HTTP 响应的数据大小
  • 无损压缩:br,gzip算法:将常出现的数据用较短的二进制比特序列表示,将不常出现的数据用较长的二进制比特序列表示
  • 有损压缩:比如对图

TCP为什么是三次握手

1.阻止重复历史连接的初始化(主要原因)
如果是两步,那么服务端接收到之后就会建立连接,并向客户端发送数据,如果当前建立的连接并不是客户端需要的,那么就白白浪费了这次历史连接的建立,增加了开销。
2.同步双方的初始序列号:如果只有两次,那么服务端的序列号无法被确认,四次的话又可以压缩,所以是三次。
3.避免资源浪费:如果初始SYN发生了网络阻塞,客户端进行了超时重传,那么服务端由于不知道自己发送的SYN+ACK是否被接收到,只能再次建立连接。

为什么每次建立 TCP 连接时,初始化的序列号都要求不一样

  • 为了防止历史报文被下一个相同四元组的连接接收(主要方面)
    客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。
  • 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收

初始序列号的产生:M + F
M是一个计时器,每隔4微秒 + 1,F是根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。

既然 IP 层会分片,为什么 TCP 层还需要 MSS

如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率

为什么需要 TIME_WAIT状态

  • 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
    如果某一个网络包被延迟,并且在延迟过程中客户端和服务端连接断开,然后如果TIME_WAIT状态没有或者过短,那么如果客户端和服务端以相同的四元组重新建立连接,那么该被延迟的数据是有可能被窗口接受的,而TIME_WAIT持续2MSL时间,就是为了让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
  • 保证「被动关闭连接」的一方,能被正确的关闭;
  • 如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。

假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSE 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。
服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。

为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。
客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。

为什么需要CLOSE_WAIT状态

因为虽然此时服务端收到了客户端断开连接的请求,此时需要通知进程调用close和shutdown函数来断开连接,而此时可能应用层还有别的任务需要处理,所以需要一个中间状态,然后等调用close或者shutdown函数后,服务端才会变为last_ack状态

重传机制

1.超时重传:数据包丢失和确认应答丢失
其机制是每一次重传时间增加一倍
2.快速重传:数据驱动,三次同样的ACK就会触发,但是并不知道所需要的ACK后面的需不需要重传
3.SACK:选择性确认,三次同样的ACK触发,TCP头部保存已经收到的数据段信息,只选择没有接收到的数据段重新发送。
4.D-SACK:告知发送方有哪些数据被重复接收了(例如ACK丢失和网络延时),会同时包括ACK和SACK两种,其中ACK表示需要的下一个包序列,SACK表示重复接收到的包,不需要再进行发送

滑动窗口

1.概念:无需等待确认应答,而可以继续发送数据的最大值。窗口大小一般由接收方告诉发送方
2.发送窗口中分为两部分,一部分是已经发送但是还没有收到ACK的窗口大小,一部分是还没有发送的可用的发送部分,每当接收到一些ACK之后,窗口就可以向右滑动来增加可用部分
3.接收窗口表示未接收到但可以接收的数据,每次接收到数据后,窗口就可以向右滑动
涉及到的部分是流量控制:发送窗口和接收窗口所存放的数据都是放在系统内存缓冲区的,会在数据传输的过程中不断被系统调整

全连接队列满了之后只能丢弃连接吗

丢弃连接是Linux内核的默认行为,我们可以通过设置参数tcp_abort_on_overflow的值来修改他的行为

  • 值为0,则表示如果队列满了,则丢弃客户端发送的最后一次ack
  • 值为1,则服务端会发送一个RST给客户端,表示废弃掉这个握手过程和这个连接

通常情况下,应该将该值设置为0,来应对突发的流量,因为可能过一段时间后,全连接队列就能空出位置来接收客户端重新发送的ack,二者还能够成功建立连接

为什么需要四次挥手

因为服务端收到客户端的FIN报文时,马上会回复一个ACK报文,但是服务端应用程序可能还有数据要发送,所以并不能马上发送FIN报文,而是将该权限交给应用程序

  • 如果应用程序还要发送数据,那么发送完数据后才调用关闭连接的函数
  • 如果应用程序不再发送数据,那么直接就可以调用关闭连接的函数

三次挥手

如果服务端没有数据要发送而且开启了TCP延迟确认的情况,那么第二次挥手和第三次挥手就会合并成一次。
TCP延迟确认的规则:

  • 如果有响应数据要发送,那么响应数据和ACK会一起发送
  • 如果没有响应数据要发送,那么会等待一段时间看是否有响应数据需要发送
  • 如果在等待期间又接收到了数据,那么会立即发送ACK

没有accept也可以建立连接

accept最终返回是从全连接队列中取出一个元素,在前面的过程中实际上已经构建了连接
全连接队列和半连接队列是在服务点listen阶段内核创建的,其中全连接队列是链表,半连接队列是哈希表
没有listen的情况下也可以建立连接,比如两个客户端产生连接或者客户端和自己建立连接,这是因为内核有一个全局的hash表,可以用来存放客户端的连接信息,当消息发出后,经过回环地址重新回来以后,从hash表中取出连接信息,建立连接。

TCP的异常断开连接情况

1.如果进程崩溃,那么内核会发送FIN,和另一端进行四次挥手断开连接
2.如果客户端宕机且没有重启,不会发生四次挥手:

  • 如果服务端发送数据,那么会超时重传,直到达到最大重传次数断开连接
  • 如果服务端不发送数据,如果服务端开启了keepalive,那么会去探测对方是否还在,如果探测不到,就会断开连接,如果没有开启保活机制,那么会一直保持在连接状态
    3.如果客户端宕机且迅速重启:此时如果服务端向客户端发送数据,都会收到它发送的RST断开连接的报文

拔掉网线的问题

实际上拔掉网线不会影响TCP四元组的信息,其连接状态还是存在
1.如果有数据传输

  • 在数据传输的重传次数到达最大之前插入网线,还是能保持连接
  • 达到最大重传次数前没有插入网线,则断开连接
    2.如果没有数据传输
  • 如果二者都没有keepalive,那么二者都默认是连接状态
  • 如果开启了保活机制,那么在探测期间内插入了网线,还是保持连接,如果探测失败,则断开连接

1.strlen函数

原型:size_t strlen(const char *str),计算字符串的长度

int my_strlen(const char *str){
    int count = 0;
    assert(str != nullptr);
    while(*str != '\0'){
        count++;
        str++;
    }
    return count;
}

2.strcpy函数

原型:char *strcpy(char *dest, const char *src),将src指向的字符串复制到dest中

char *my_strcpy(char *str_des, const char *str_source){
    assert(str_des != nullptr);
    assert(str_source != nullptr);
    char *ret = str_des;
    while((*str_des++ = *str_source++) != '\0'){
        ;
    }
    return ret;
}

3.strcat函数

原型:char *strcat(char *dest, const char *src),将src指向的字符串追加到dest指向的字符串结尾。

char *my_strcat(char *str_des, const char *str_source){
    assert(str_des != nullptr);
    assert(str_source != nullptr);
    char *ret = str_des;
    while(*str_des){
        str_des++;
    }
    while((*str_des++ = *str_source++) != '\0'){
        ;
    } 
    
    return ret;
    
}

4.strcmp函数

原型:int strcmp(const char *str1, const char *str2),把str1指向的字符串和str2指向的字符串进行比较,小于返回一个小于零的值,大于返回一个大于零的值,相等返回0

int my_strcmp(const char *str1, const char *str2){
    while(*str1 && *str2){
        if(*str1 == *str2){
            str1++;
            str2++;
        }
        else if(*str1 > *str2)return -1;
        else return 1;
    }
    if(*str1)return 1;
    if(*str2)return -1;
    return 0;
}

5.strchr函数

原型:char *strchr(const char *str, int c),在参数str所指向的字符串中搜索第一次出现字符c的位置,如果未找到返回NULL

char *my_strchr(const char *str, char c){
    while(*str++ != '\0'){
        if(*str == c)return str;
    }
    return NULL;
}

6.strstr函数

原型:char *strstr(const char *haystack, const char *needle),在字符串haystack中查找第一次出现字符串needle的位置,不包括终止符’\0’

char *my_strstr(char *str1, char *str2){
    const char *index1;
    const char *index2;
    if(!str1 || !str2)return str1;
    
    //遍历str1字符串
    while(*str1){
        index1 = str1;
        index2 = str2;
        do{
            if(!*index2)return str1;
        }while(*index1++ == *index2++);
        str1++;
    }
    return NULL;
    
}

本文结构

  • 基础构造函数
  • 复制语义
  • 移动语义
  • 一些工具函数
  • 析构函数

1.基础构造函数

面试中如果要求实现一个shared_ptr,我们可以从最简单的结构写起:

template<class T>
class my_shared_ptr{
private:
    T* m_ptr = nullptr;
    unsigned int * m_ref_count = nullptr;
    //默认构造函数
public:   
    my_shared_ptr(): m_ptr(nullptr), m_ref_count(nullptr){ }
    my_shared_ptr(T* ptr): m_ptr(ptr), m_ref_count(new unsigned int(1)){ }
};

1.为什么m_ref_count这个引用计数要用指针来表示?
因为shared_ptr会被复制来复制去的,对于同一个被引用的对象,其引用计数需要同时更新和共享,使用指针的话就会比较方便。
2.shared_ptr的引用计数是线程安全的吗?
在其源代码中,是线程安全的,因为最后使用了_Atomic_word类型来计数,表明其是一个原子操作,保证了线程安全。
3.shared_ptr储存的指针是线程安全的吗?
并不是,源代码中指针类型是element_type*,其为用户使用时自定义的指针类型,没有保证原子操作。

2.拷贝构造部分

//拷贝构造函数
my_shared_ptr(const my_shared_ptr & obj){
    m_ptr = obj.m_ptr;
    m_ref_count = obj.m_ref_count;
    if(m_ref_count != nullptr){
        (*m_ref_count)++;
    }
}

//拷贝重载=运算符
my_shared_ptr& operator=(const my_shared_ptr & obj){
    if(obj.m_ptr == m_ptr){
        return *this;
    }
    
    //先处理原有的指针和引用计数
    if(m_ref_count != nullptr){
        (*m_ref_count)--;
        if(*m_ref_count == 0){
            delete m_ptr;
            delete m_ref_count;
        }
    }
    
    m_ptr = obj.m_ptr;
    m_ref_count = obj.m_ref_count;
    
    if(m_ref_count != nullptr){
        (*m_ref_count)++;
    }
    return *this;
}

1.为什么拷贝构造的参数是const my_shared_ptr&,而不是const my_shared_ptr
因为我们写的是拷贝构造函数,如果参数为后者,相当于要调用一次拷贝构造,就会无限递归下去。
2.为什么重载=运算符的返回值是my_shared_ptr&,能不能返回void
理论上是可以的,但是为了支持连等a = b = c,还是要返回my_shared_ptr&

3.移动构造部分

//移动语义
//移动构造
my_shared_ptr(my_shared_ptr && dying_obj): m_ptr(nullptr), m_ref_count(nullptr){
    //初始化后交换指针和引用计数,相当于清除了dying_obj的内容
    dying_obj.swap(*this);
}
//移动赋值
my_shared_ptr & operator=(my_shared_ptr && dying_obj){

    //my_shared_ptr(std::move(dying_obj))用移动构造函数创建出一个新的shared_ptr(此时原shared_ptr的内容被清除了)
    //再和this交换指针和引用计数
    //因为this的内容被交换到了当前的临时创建的my_shared_ptr里,原this指向的引用计数-1

    my_shared_ptr(std::move(dying_obj)).swap(*this);
    return *this;
}

void swap(my_shared_ptr & other){
    std::swap(m_ptr, other.m_ptr);
    std::swap(m_ref_count, other.m_ref_count);
}

4.一些工具函数

指针相关的运算符重载和获取引用计数的函数

//重载指针运算符
T* operator->() const{
    return m_ptr;
}

//重载解引用运算符
T* operator*() const{
    return *m_ptr;
}

T* get() const{
    return m_ptr;
}

unsigned int use_count() const{
    return *m_ref_count;
}

5.析构函数

//析构函数
~my_shared_ptr(){
    if(m_ref_count == nullptr){
        return;
    }
    (*m_ref_count)--;
    if(*m_ref_count > 0){
        return;
    }
    
    if(m_ptr != nullptr){
        delete m_ptr;
    }
    delete m_ref_count;
}

6.make_shared函数

1.为什么会推荐使用std::make_shared,而不是直接构造std::shared_ptr
如果直接构造shared_ptr,那么是先分配一块内存给实例化对象,再分配一块内存给引用计数模块(引用计数,删除器等),但是std::make_shared可以一次性分配一整块内存给引用计数模块和实例化对象,这样有两部分优点:

  • 优点一:异常安全
    在C++17之前,在某种情况下构造一个std::shared_ptr不一定是安全的,看下面的案例:

    void F(const std::shared_ptr &lhs, const std::shared_ptr &rhs) { /* … */ }

    F(std::shared_ptr(new Lhs(“foo”)),
    std::shared_ptr(new Rhs(“bar”)));

一个可能的执行顺序是

1.new Lhs("foo")
2.new Rhs("bar")
3.std::shared_ptr<Lhs>
4.std::shared_ptr<Rhs>

假设在第2步出现了一个异常(比如内存耗尽或者构造函数的异常),那么第一步分配的内存地址就没有保存在任何地方,所以这块内存永远回收不了,导致内存泄露。使用std::make_shared就可以避免这种问题。

  • 优点二:减少开销
    一次性分配一整块内存来使用可以减少碎片化内存,减少使用临时变量,也减少了和内核的交流。

2.有没有什么情况下没法使用std::make_shared

  • 有时候我们把构造函数定义为私有,就可以强制用户使用工厂模式A::Create创建shared_ptr,这样可以避免用户直接创建实例或者使用生指针进而管理不善导致内存泄漏。

      #include <iostream>
      #include <memory>
    
      class A
      {
          public:
          static std::shared_ptr<A> Create(){ return std::shared_ptr<A>(new A(100)); }
    
          int GetId(){ return m_i; }
    
          private:
          int m_i;
          A(int i): m_i(i){std::cout<<"private ctor called"<<std::endl;};
      };
    
      int main()
      {
          std::shared_ptr<A> s_ptr = A::Create();
          std::cout<<s_ptr->GetId()<<std::endl;
    
          //A * p = new A(300);  //make_shared无法访问私有构造函数
          return 0;
      }
    

在这种情况下,std::make_shared进行placement new操作的时候,会直接调用构造函数,但因为我们的构造函数都是私有的,这时候就会报错。而我们在工厂模式里直接new出来的实例是通过成员函数new操作符operator new访问的私有构造函数,所以没有问题。

  • 还有一种情况,make_shared 只分配一次内存, 这看起来很好. 减少了内存分配的开销. 问题来了, weak_ptr 会保持控制块(强引用, 以及弱引用的信息)的生命周期, 而因此连带着保持了对象分配的内存, 只有最后一个 weak_ptr 离开作用域时, 内存才会被释放. 原本强引用减为 0 时就可以释放的内存, 现在变为了强引用, 弱引用都减为 0 时才能释放, 意外的延迟了内存释放的时间. 这对于内存要求高的场景来说, 是一个需要注意的问题。

1.Linux 中一个进程的虚拟内存分布长什么样?内核空间+用户空间(6 种不同的内存段)
代码段:存放程序代码,运行前就已经确定(编译时确定)
常量区:存放const定义的全局变量,define定义的常量和字符串常量等
数据段:存放已经初始化的全局变量
bss段:存放没有初始化的全局变量或默认为0的全局变量,执行期间会将这一段的内容全部设为0
栈:存放函数参数和局部变量,由系统进行申请和释放,空间较小,先进先出
堆:存放动态分配的内存,由用户自己申请和释放(比如malloc和free)

2.为什么要使用虚拟内存
如果没有虚拟地址,cpu访问的都是真实的物理地址,那么会产生:程序直接访问真实内存,没有顺序没有规则,很容易导致错误的产生,并且无法同时运行多个程序。
有了虚拟地址以后:

  • 程序可以用一系列相邻的虚拟地址访问实际物理内存中不相邻的大内存缓冲区
  • 程序可以使用虚拟地址访问大于可用物理地址的内存缓冲区
  • 不同进程之间的虚拟地址分隔开,不同进程不会操作同一个物理地址,防止程序崩溃

中断问题

Linux将中断问题分为上下两部分

  • 上半部分对应硬件,硬中断,用来快速处理中断,把网卡的数据读到内存中,更新一下硬件寄存器的状态
  • 下半部分对应内核,软中断,通常耗时比较长,例如从内存中读取数据,根据协议栈对网络数据进行逐层解析和处理,最终交给应用程序

cpu写入问题

  1. 写直达:同时把数据写入Cache和内存
  • 如果Cache中有该数据,先将数据更新,再写入到内存中
  • 如果Cache中没有该数据,则直接写入到内存中
  1. 写回:当写数据时,新的数据被写入到Cache中,只有当修改过的Cache Block被替换时才写入到内存中
    这种情况下,只有当缓存不命中,且对应的Cache Block是脏的情况下,才会将该脏的数据写入内存中,然后将要写入的数据,先从内存读入到Cache Block中,然后再把当前要写入的数据写入到Cache Block中,并标记为脏的

缓存一致性问题

如果有多个CPU核心操作缓存数据,按照写回原则,则会出错,因为当没有发生缓存不命中且对应数据为脏时,缓存中的数据不会写回到内存中,此时其他核心从内存中读取的数据就是错的。为了解决,需要写传播和事务的串行化

  • 写传播:当某个CPU核心更新了Cache中的数据,需要广播到其他核心中,使用总线嗅探技术
  • 事务的串行化:基于总线嗅探技术实现的MESI方法,解决了上述两个问题

malloc申请内存的方式

1.brk()函数从堆申请内存,具体为将指针从堆的低地址向高地址移动,小于128KB
2.mmap()函数从文件映射区分配内存,大于128KB
malloc分配的是虚拟地址,如果该虚拟地址没有被访问,则不会映射到真实物理地址,如果操作系统访问虚拟地址,通过页表查询发现虚拟地址对应的页没有在物理内存中,就会发生缺页中断,然后才会建立虚拟地址和物理内存之间的映射
不能都使用mmap函数,因为其会发生运行态的切换,而且mmap申请的内存总是会被释放掉,从而每次访问都会缺页中断,导致CPU消耗过大
也不能都使用brk函数,因为其堆指针连续向上增长,会产生内存碎片问题,导致内存泄露

free释放内存的问题

1.如果是malloc使用brk()函数申请的内存,则不会释放掉
2.如果是mmap()函数申请的内存,则会释放掉
其中malloc返回给用户的数据会有一个16字节的信息头,其中保存了该内存块的大小,这样free释放的时候就知道要释放多大的内存块了

内存分配的过程

应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存,CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的缺页中断函数处理。缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。

  • 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
  • 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
  • 如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。

进程调度算法

1.先来先服务
2.最短作业:优先选择运行时间最短的
3.高响应比优先调度算法:等待时间 + 要求服务时间/ 要求服务时间(无法实现)
4.时间片轮转算法:每个进程被分配一个时间片
5.最高优先级调度算法:静态和动态优先级
6.多级反馈队列:多个队列,每个队列优先级从高到低,新来的任务先加入第一级的队尾,按照先来先服务运行,如果没有运行完则加入第二级的队尾,如果来了一个优先级高的,则先去运行优先级高的任务队列,此时将原任务加入当前队列的队尾

进程间通信

1.匿名管道:通过fork父进程的文件描述符来建立连接,先进先出
2.命名管道:在不相关的进程间也能进行通信,其创建了一个类型为管道的设备文件
3.消息队列:在内核中,不适合比较大的文件传输,同时会带来用户态与内核态之间的数据拷贝开销
4.共享内存:一个虚拟内存,映射到同一块真实物理内存
5.信号量:P操作-1,如果小于0,则阻塞 V操作:如果小于等于0则有进程阻塞,唤醒它,如果大于0,则没有进程阻塞

  • 1:互斥信号量
  • 0:同步信号量:需要先执行的只需要V操作,需要后执行的只需要P操作
    6.信号:异常状态下的通信
    7.Socket:跨网络和不同的主机间进程通信

死锁

1.死锁的四个条件

  • 互斥
  • 忙等待
  • 不可剥夺
  • 环路等待

2.如何避免

  • 资源有序分配法来打破环路等待:如果两个进程需要的资源相同,那么就按照同样的顺序去获取资源

锁的类型

1.互斥锁:获取不到资源时,将CPU释放,去执行其他进程
2.自旋锁:获取不到资源时,线程会忙等待,直到拿到锁
3.读写锁:当写锁没有被线程持有,那么多个线程可以持有读锁,如果线程持有了读锁,那么其他线程的读锁和写锁都会被阻塞

  • 读优先锁:写锁被阻塞,且后来的读锁也会先与写锁被执行
  • 写优先锁:读锁会阻塞写锁,但是后面的线程无法再持有读锁,那么写线程就会接着被执行
  • 比较公平的方法:把所有锁加入一个队列,先来先执行
    4.以上都是悲观锁,还有乐观锁:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。

页面置换算法

1.先进先出:选择在内存中保留时间最长的页面
2.最近最久未使用:LRU
3.最不常用:选择访问次数最少的
4.时钟页面:所有页面保存在环形列表中,表针指向最老的页面,如果发生缺页就检查访问位置:如果是0则淘汰,并插入当前页面,如果是1,则置为0,表针向前一个走

如果系统中具有快表后,那么地址的转换过程变成什么样了?

  • CPU给出逻辑地址,由某个硬件算得页号、页内偏移量,将页号与快表中的所有页号进行比较。
  • 如果找到匹配的页号,说明要访问的页表项在快表中有副本,则直接从中取出该页对应的内存块号,再将内存块号与页内偏移量拼接形成物理地址,最后,访问该物理地址对应的内存单元。因此,若快表命中,则访问某个逻辑地址仅需一次访存即可。
  • 如果没有找到匹配的页号,则需要访问内存中的页表,找到对应页表项,得到页面存放的内存块号,再将内存块号与页内偏移量拼接形成物理地址,最后,访问该物理地址对应的内存单元。因此,若快表未命中,则访问某个逻辑地址需要两次访存(注意:在找到页表项后,应同时将其存入快表,以便后面可能的再次访问。但若快表已满,则必须按照-定的算法对旧的页表项进行替换)

动态分区分配算法

1.首次适应算法:空闲分区以地址递增的次序排列,每次都从低地址开始查找空闲分区链或者空闲分区表,找到第一个能满足大小的空闲分区。
2.最佳适应算法:空闲分区按容量递增次序链接,每次分配内存时按顺序查找空闲分区表,找到大小能满足要求的第一个空闲分区。
3.最坏适应算法:空闲分区按容量递减次序链接,每次分配内存时按顺序查找空闲分区表,找到大小能满足要求的第一个空闲分区。
4.邻近适应算法:空闲分区以地址递增的顺序排列(可排成一个循环链表),每次分配内存时从上次查找结束的位置开始查找空闲分区链,找到大小能满足要求的第一个空闲分区。

静态链接和动态链接

1.静态链接:函数和数据被编译进一个二进制文件,链接时,链接器从库中复制这些函数和数据,并把他们和应用程序的其他模块组合起来创建最终的可执行文件。

  • 空间浪费:每个可执行文件中对所有需要的目标文件都要有一个副本
  • 更新困难:每当库函数代码发生变化,就需要重新编译链接形成可执行程序
  • 优点:运行速度快
    2.动态链接:把程序按照模块拆分成各个相对独立的部分,在程序运行时才将他们链接到一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
  • 共享库:即使每个程序都依赖一个库,但是该库不会像静态链接那样在内存中存在多个副本,而是多个程序在执行时共享同一个副本。
  • 更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再链接一遍。
  • 性能损耗:每次程序运行都需要进行链接,性能会有一定损失。

读者-写者问题

问题描述:允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。

一个整型变量 count 记录在对数据进行读操作的进程数量,一个互斥量 count_mutex 用于对 count 加锁,一个互斥量 data_mutex 用于对读写的数据加锁。

typedef int semaphore;
semaphore count_mutex = 1;
semaphore data_mutex = 1;
int count = 0;

void reader() {
    while(TRUE) {
        down(&count_mutex);
        count++;
        if(count == 1) down(&data_mutex); // 第一个读者需要对数据进行加锁,防止写进程访问
        up(&count_mutex);

        read();  

        down(&count_mutex);
        count--;
        if(count == 0) up(&data_mutex);//最后一个读者要对数据进行解锁,防止写进程无法访问
        up(&count_mutex);
    }
}

void writer() {
    while(TRUE) {
        down(&data_mutex);

        write();

        up(&data_mutex);
    }
}

中断和异常的区别

1.中断是硬件产生的,通过中断控制器发给CPU,CPU判断其来自哪个硬件设备,最后发送给内核,由内核进行处理
2.异常由CPU产生,例如缺页异常;当运行除法程序时,当除数为0时,又会产生除0异常等。其会发送给内核,由内核来进行异常处理。

用户态和内核态

1.用户态:用户可以操作和访问的空间,这个空间通常存放我们用户自己的数据等
2.内核态:是系统内核来操作的一块空间,用来存放系统内核的函数,接口等
3.二者之间的最大区别就是特权级不同:用户态拥有最低的特权级,内核态拥有较高的特权级
4.为什么要分为内核态和用户态:为了安全性。在CPU的一些指令中,有些指令如果用错,将导致整个系统崩溃。分开之后,如果用户需要某些操作,内核为其提供了API,可以通过系统调用陷入内核,让内核去执行操作。
5.什么样的功能应该在内核态下实现:

  • CPU的管理和内存管理:更为安全
  • 诊断和测试程序:因为需要访问计算机的所有资源
  • 文件系统:用户数据的管理可以放在用户态下,而对于文件系统本身的管理,需要放在内核态中。
  • IO管理:因为要访问各种设备和底层数据结构,所以也要放在内核态实现。
    6.如何辨别当前处于哪个态:CPU中有一个状态字,标识了当前的状态,用户态为3,内核态为0

用户态和内核态的切换

1.系统调用:其机制核心还是通过操作系统为用户特别开放的一个中断来实现,表现为一个正常的异常(软中断)。

  • 进程调用:exit和fork
  • 文件系统访问:chmod和chown
  • 设备调用:read和write
  • 信息读取:读取设备信息
  • 通信:pipe和mmap
    2.中断:当外围设备完成用户请求的操作之后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条将要执行的指令,转而去执行中断信号的处理程序,如果先执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了有
    用户态到内核态的切换。
    3.异常:当CPU在执行用户态的程序时,发现某些异常情况,就会触发由当前进程切换到异常的内核相关程序中,比如缺页异常。

申请的虚拟内存大小超过了物理内存的大小

1.应用程序使用malloc申请的内存实际上是虚拟内存,此时并不会分配物理内存,只有当应用程序读取了虚拟内存,CPU会去访问这块虚拟内存,发现并没有映射到物理内存上,CPU就会产生缺页中断,用户态切换到内核态,由缺页中断处理函数去处理

  • 如果有空闲物理内存,就会直接分配,并且建立虚拟内存和物理内存的映射
  • 如果没有,内核就会开始内存回收的任务,如果空闲的物理内存大小不够,就会触发OOM
    2.在32位机器上,最大可申请的虚拟内存大小是3G
    3.在64位机器上,最大可申请的虚拟内存大小是128T,即使物理内存只有2G,我们也可以申请4G或者8G的虚拟内存,此时分为两种情况:
  • 没有开启Swap分区:此时由于物理内存不够,最终会触发OOM(内存溢出)
  • 开启了Swap分区,此时进程可以正常运行(Swap机制会将不常用的内存先写到磁盘中,然后释放这些内存,再次访问这些内存时,从磁盘读入即可)

Swap机制触发的条件

1.内存不足:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。
2.内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。

协程是什么

1.用户态的轻量级线程,是线程内部调度的基本单位,只在用户态内进行切换
2.先将寄存器上下文和栈保存,等切换回来的时候再进行恢复
3.同一时间只能执行一个协程,而其他协程处于休眠状态,适合对任务进行分时处理
4.基本没有到内核态切换的开销,可以不加锁的访问全局变量,所以上下文切换的非常快
5.其通信方式是共享队列和消息队列

一般需要几级页表

32位系统下,一个页大小是4KB,虚拟地址4GB,那么可以分为100万个页,一个页表项4B,那么4B * 2 * 2^20 = 4MB,这对应的是一个进程,而多个进程就会有多个页表,导致页表会及其庞大
32位系统两级分页就足够了,64位的系统是4级分页

中断的作用

一方面,有了中断功能,PC系统就可以使CPU和外设同时工作,使系统可以及时地响应外部事件。而且有了中断功能,CPU可允许多个外设同时工作。这样就大大提高了CPU的利用率,也提高了数据输入、输出的速度。 另一方面,有了中断功能,就可以使CPU及时处理各种软硬件故障。计算机在运行过程中,往往会出现事先预料不到的情况或出现一些故障,如电源掉电、存储出错,运算溢出等等。计算机可以利用中断系统自行处理,而不必停机或报告工作人员。

CPU Cache的数据结构和读取过程

1.存储结构:Cache Line组成,包括头标记和数据块,每次载入数据都是按照Cache Line整个读取
2.访问和存储:索引 + 有效位 + 组标记 + 偏移量,访问步骤如下:

  • 根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Cache Line 的地址;
  • 找到对应 CPU Cache Line 后,判断 CPU Cache Line 中的有效位,确认 CPU Cache Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行;
  • 对比内存地址中组标记和 CPU Cache Line 中的组标记,确认 CPU Cache Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行;
  • 根据内存地址中偏移量信息,从 CPU Cache Line 的数据块中,读取对应的字。

关于伪共享的问题

1.如果两个变量属于同一个Cache Line块,而恰好两个CPU核心需要分别修改这两个变量,则会发生Cache Line没有起到缓存的作用,降低了代码执行的速度
2.避免伪共享的方法
将变量设置为内存对齐,用空间换取时间

CPU选择线程的问题

1.三个运行队列:

  • Deadline运行队列
  • 实时任务运行队列
  • CFS运行队列:完全公平调度,优先选择虚拟运行时间少的任务,虚拟运行时间 += 实际运行时间 / 权重

硬中断和软中断

Linux 将中断处理程序分为上半部和下半部:
上半部,对应硬中断,由硬件触发中断,用来快速处理中断;
下半部,对应软中断,由内核触发中断,用来异步处理上半部未完成的工作,Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型

进程的上下文切换

1.切换内容:用户空间的虚拟内存,栈,全局变量等;内核空间的堆栈、寄存器等
2.何时发生切换:CPU时间片用完;内存不足,则进程也会被挂起,当有优先级更高的进程运行,当前进程也会被挂起;发生硬件中断的时候也会发生进程的切换

互斥锁和自旋锁

互斥锁加锁失败会发生线程的上下文切换,需要消耗一定的时间,自旋锁是用户态完成加锁和解锁操作,不会主动产生线程上下文切换,运行时间更快,自旋锁通过 CPU 提供的 CAS 函数实现,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;
    CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

ping命令的工作原理

1.ping发送一个ICMP(Internet Control Messages Protocol),即因特网信报控制协议;接收端回声消息给目的地并报告是否收到所希望的应答。它的原理是:利用网络上机器IP地址的唯一性,给目标IP地址发送一个数据包,通过对方回复的数据包来确定两台网络机器是否连接相通,时延是多少。
2.ping是应用层直接使用网络层ICMP的一个例子,它没有通过运输层的TCP或UDP。所以ICMP属于网络层协议。
3.对于ping命令,我们需要的到达的效果是检查是否联通。那么只需要我们的请求方带上数字标识8(回送请求),如果对方回送的数值是0,那么证明两者是联通的。
4.一个例子:

  • 执行 ping 192.168.0.5:此时ping命令会构建一个固定格式的ICMP请求数据包
  • IP层协议将以地址“192.168.0.5”作为目的地址,本机IP地址作为源地址,加上一些其他的控制信息,构建一个IP数据包发往192.168.0.5。
  • 目的主机相关操作:接收后检查该数据帧,将IP数据包从帧中提取出来,交给本机的IP层协议。IP层检查后,将有用的信息提取后交给ICMP协议。ICMP协议后者处理后,马上构建一个ICMP应答包,发送给主机A。

IPV4每个部分的含义

1.版本:4位,表示是IPV4还是IPV6
2.首部长度:默认20B
3.总长度:首部+数据和的长度,最大65535B,而MTU为1500B
4.标识:用于分片后进行重装
5.标志:MF = 1表示后面还有分片,MF = 0表示是最后一个分片,DF = 0表示允许分片
6.TTL:生存时间,报文经过每个路由器都将减一,当为0时,丢弃该报文
7.协议:6表示TCP,17表示UDP
8.首部校验和:只检验数据报首部
9.源地址和目的地址

epoll如何知道数据已经被完整读取

用epoll + ET模式读取数据,当检查到监听的socket可以读取数据时,使用recv函数读取上面的数据,当该函数返回-1且errno是EAGAIN或EWOULDBLOCK的时候,表示数据已经全部读取完毕。

epoll的ET模式时,如果数据只读了一半,也就是缓冲区的数据只读了一点,然后又来新事件了怎么办?

这里我使用通过epoll_ctl对该文件描述符注册epolloneshot事件,一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件

http断点续传

1.http 1.1中定义了断点续传的相关Range和Content-Range字段。

  • 客户端:Range字段,指定第一个字节的位置和最后一个字节的位置,有多种格式,可以具体的指示需要哪些字节,甚至可以指定多个范围值
  • 服务端:Content-Range字段,返回当前接受的范围和文件总大小,响应完成后,返回的响应头内容也不一样,例如200 OK表示不使用断点续传的方式,206则表示使用断点续传的方式
  • 增强校验:在实际场景中,会出现一种情况,即在终端发起续传请求时,URL 对应的文件内容在服务器端已经发生变化,此时续传的数据肯定是错误的。如何解决这个问题了?显然此时需要有一个标识文件唯一性的方法。在 RFC2616 中也有相应的定义,比如实现Last-Modified 来标识文件的最后修改时间,这样即可判断出续传文件时是否已经发生过改动。同时 FC2616 中还定义有一个 ETag 的头,可以使用 ETag 头来放置文件的唯一标识。
    2.工作原理
  • 第一次请求:客户端发起GET请求一个文件,服务器处理请求,返回文件内容以及相应的 Header,其中包括 Etag(例如:627-4d648041f6b80)(假设服务器支持 Etag 生成并已开启了 Etag)状态码为 200。
  • 第二次请求(断点续传):客户端发起 HTTP GET 请求一个文件,同时发送 If-Range(该头的内容就是第一次请求时服务器返回的 Etag:627-4d648041f6b80)。
    服务器判断接收到的 Etag 和计算出来的 Etag 是否匹配,如果匹配,那么响应的状态码为 206;否则,状态码为 200。

一. C++部分

1.说一说指针和引用的区别

指针内部保存的是指向的对象的地址信息,而引用是变量的别名;
指针可以随时初始化,而引用被定义之后就要初始化,不可以为空
sizeof引用是被引用的对象的大小,而sizeof指针是指针类型的大小
指针既有顶层const(指针本身不能变),也有底层const(指针指向的对象不能变),而引用只有底层const,顶层const是无意义的。
二者的++操作不同,指针++表示指针运算,而引用++表示变量执行++
引用只是C++的语法糖,可以看作是自动取地址解引用的常量指针。在汇编层面是一样的。

2.struct和class的区别

默认的(继承)访问权限,class是private,struct默认是public
class可以实现模板类,而struct不可以
struct是值类型,而class是引用类型

3.C++中多态

为了使代码重用性增加,使得代码可以模块化
静态多态:通过重载函数和泛型编程实现,通过基类指针指向的对象不同而调用不同的函数。是在编译器就完成的,编译器根据实参类型来确定。具有更好的类型安全性,因为编译阶段会对所有的绑定类型进行检查。
动态多态:通过虚函数和继承实现,运行时确定。虚函数表保存在只读数据段,编译器将类对象的前四个字节设置为指向虚函数表的指针。

4.面向对象

面向对象的三大特征:封装、继承、多态。封装:将客观事物进行抽象,将其属性和方法合成为一个类,类封装了成员变量和成员函数,同时又实现对属性和方法的权限控制,降低与外界的耦合度 继承:子类继承父类的各种属性和方法,同时子类还可以在父类的基础上重新定义和扩展父类的属性和方法,使其具有不同的功能,继承提高了代码的复用性及可维护性 多态:同一调用语句在父类和子类间使用时具有不同的表现形式,可以使用同一段代码处理不行类型的对象,提高代码的复用性

5.浅拷贝和深拷贝

浅拷贝 (Shallow Copy) 只复制某个对象的指针, 而不复制对象本身, 新旧对象还是共享同一块内存。在对象析构后,容易产生指针访问一个不存在的对象,从而产生悬空指针。
深拷贝 (Deep Copy) 在拷贝的过程中会另外创造一个一模一样的对象. 新对象跟原对象不共享内存, 修改新对象不会改到原对象.

6.STL容器的实现和查找时间复杂度

  1. vector 采用一维数组实现,元素在内存连续存放,不同操作的时间复杂度为: 插入: O(N) 查看: O(1) 删除: O(N) 2. deque 采用双向队列实现,元素在内存连续存放,不同操作的时间复杂度为: 插入: O(N) 查看: O(1) 删除: O(N) 3. list 采用双向链表实现,元素存放在堆中,不同操作的时间复杂度为: 插入: O(1) 查看: O(N) 删除: O(1) 4. map、set、multimap、multiset 上述四种容器采用红黑树实现,红黑树是平衡二叉树的一种。不同操作的时间复杂度近似为: 插入: O(logN) 查看: O(logN) 删除: O(logN) 5. unordered_map、unordered_set、unordered_multimap、 unordered_multiset 上述四种容器采用哈希表实现,不同操作的时间复杂度为: 插入: O(1),最坏情况O(N) 查看: O(1),最坏情况O(N) 删除: O(1),最坏情况O(N) 注意:容器的时间复杂度取决于其底层实现方式。

7.STL 中容器的类型,每种分别有哪些容器

  1. 序列式容器 array、vector、deque、list、forward_list 2. 关联式容器 map、multimap、set、multiset 3. 无序关联式容器 unordered_map、unordered_multimap、unordered_set、unordered_multiset 4. 容器适配器 stack、queue、priority_queue

8.简述一下 C++ 的重载和重写

  1. 重载
    a. 重载是指不同的函数使用相同的函数名,但是函数的参数个数或类型不同(参数列表不同)。调用的时候根据函数的参数来区别不同的函数,函数重载跟返回值无关。
    b. 重载的规则 - 函数名相同 - 必须具有不同的参数列表 - 可以有不同的访问修饰符
    c. 重载用来实现静态多态(函数名相同,功能不一样)。
    d. 重载是多个函数或者同一个类中方法之间的关系,是平行关系。
  2. 重写
    a. 重写(也叫覆盖)是指在派生类中重新对基类中的虚函数重新实现。即函数名和参数都一样,只是函数的实现体不一样。
    b. 重写的规则: - 方法声明必须完全与父类中被重写的方法相同 - 访问修饰符的权限要大于或者等于父类中被重写的方法的访问修饰符 - 子类重写的方法可以加virtual,也可以不加
    c. 重写用来实现动态多态(根据调用方法的对象的类型来执行不同的函数)。
    d. 重写是父类和子类之间的关系,是垂直关系。

隐藏的实质是:在函数查找时,名字查找先于类型检查。如果派生类中成员和基类中的成员同名,就隐藏掉。编译器首先在相应作用域中查找函数,如果找到名字一样的则停止查找。

9.简述一下虚函数的实现原理

https://zhuanlan.zhihu.com/p/75172640

  1. C++ 中的虚函数的作用主要是实现了动态多态的机制。 2. 虚函数实现原理 编译器处理虚函数时,给每个对象添加一个隐藏的成员。隐藏的成员是一个指针类型的数据,指向的是函数地址数组,这个数组被称为虚函数表(virtual function table,vtbl)。虚函数表中存储的是类中的虚函数的地址。如果派生类重写了基类中的虚函数,则派生类对象的虚函数表中保存的是派生类的虚函数地址,如果派生类没有重写基类中的虚函数,则派生类对象的虚函数表中保存的是父类的虚函数地址。使用虚函数时,对于内存和执行速度方面会有一定的成本: 1. 每个对象都会变大,变大的量为存储虚函数表指针; 2. 对于每个类,编译器都会创建一个虚函数表; 3. 对于每次调用虚函数,都需要额外执行一个操作,就是到表中查找虚函数地址。

10.为什么将析构函数设置成虚函数

虚析构函数的主要作用是为了防止遗漏资源的释放,防止内存泄露。如果基类中的析构函数没有声明为虚函数,基类指针指向派生类对象时,则当基类指针释放时不会调用派生类对象的析构函数,而是调用基类的析构函数,如果派生类析构函数中做了某些释放资源的操作,则这时就会造成内存泄露。

11.哈希冲突的原因和影响因素,哈希冲突的解决方法

  1. 哈希冲突产生的原因 哈希是通过对数据进行再压缩,提高效率的一种解决方法。但由于通过哈希函数产生的哈希值是有限的,而数据可能比较多,导致经过哈希函数处理后仍然有不同的数据对应相同的值,这时候就产生了哈希冲突。
  2. 产生哈希冲突的影响因素 装填因子(装填因子=数据总数 / 哈希表长)、哈希函数、处理冲突的方法
  3. 哈希冲突的解决方法
    a.开放地址方法:如果冲突,则根据冲突的值再建立一个值,如果还冲突就重复操作直到不再冲突
    b.链式地址法:将所有冲突的都放入一个链表中
    c.建立公共溢出区:基本表和溢出表
    d.再哈希法:同时构建多个哈希函数,一个发生冲突就使用另一个

12.map,unordered_map 的区别

map:内部实现了一个红黑树,该结构具有自动排序的功能,因此 map 内部的所有元素都是有序的,红黑树的每一个节点都代表着 map 的一个元素,因此,对于 map 进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行这样的操作,故红黑树的效率决定了 map 的效率。
unordered_map:内部实现了一个哈希表,因此其元素的排列顺序是杂乱的,无序的。

13.红黑树的特性,为什么要有红黑树

在那种插入、删除很频繁的场景中,平衡树需要频繁着进行调整,这会使平衡树的性能大打折扣,为了解决这个问题,于是有了红黑树,红黑树具有如下特点:
1、具有二叉查找树的特点;
2、根节点是黑色的;
3、每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存数据;
4、任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
5、每个节点,从该节点到达其可达的叶子节点是所有路径,都包含相同数目的黑色节点。

14.vector 的扩容机制,扩容以后,它的内存地址会变化吗?

当 vector 的大小和容量相等(size==capacity)也就是满载时,如果再向其添加元素,那么 vector 就需要扩容。vector 容器扩容的过程需要经历以下 3 步: 1. 完全弃用现有的内存空间,重新申请更大的内存空间; 2. 将旧内存空间中的数据,按原有顺序移动到新的内存空间中; 3. 最后将旧的内存空间释放。 因为 vector 扩容需要申请新的空间,所以扩容以后它的内存地址会发生改变。vector 扩容是非常耗时的,为了降低再次分配内存空间时的成本,每次扩容时 vector 都会申请比用户需求量更多的内存空间(这也就是 vector 容量的由来,即 capacity>=size),以便后期使用。
vector底层使用三个指针来实现,分别是:顺序表头,顺序表的有效长度位置,顺序表末尾。
https://blog.csdn.net/m0_51723227/article/details/121374795#:~:text=%E5%9C%A8%E6%A0%87%E5%87%86C%2B%2B%E4%B8%AD%2C,%2C%E8%BE%BE%E5%88%B0%E5%AD%98%E5%82%A8%E6%95%88%E6%9E%9C.

15.请你说说 map 实现原理,各操作的时间复杂度是多少

  1. map 实现原理 map 内部实现了一个红黑树(红黑树是非严格平衡的二叉搜索树,而 AVL是严格平衡二叉搜索树),红黑树有自动排序的功能,因此 map 内部所有元素都是有序的,红黑树的每一个节点都代表着 map 的一个元素。因此,对于 map 进行的查找、删除、添加等一系列的操作都相当于是对红黑树进行的操作。map 中的元素是按照二叉树(又名二叉查找树、二叉排序树)存储的,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值,使用中序遍历可将键值按照从小到大遍历出来。 2. 各操作的时间复杂度 插入: O(logN) 查看: O(logN) 删除: O(logN)

16.shared_ptr 怎么知道跟它共享对象的指针释放了

shared_ptr基于”引用计数”模型实现,多个shared_ptr可指向同一个动态对象,并维护一个共享的引用计数器,记录了引用同一对象的shared_ptr实例的数量。当最后一个指向动态对象的shared_ptr销毁时,会自动销毁其所指对象(通过delete操作符)。

17.请你说说左值、右值、左值引用、右值引用、右值引用的使用场景

  1. 左值 在 C++ 中可以取地址的、有名字的就是左值 int a = 10; // 其中 a 就是左值
  2. 右值 不能取地址的、没有名字的就是右值 int a = 10; // 其中 10 就是右值右值
  3. 左值引用 左值引用就是对一个左值进行引用。传统的 C++ 引用(现在称为左值引用)使得标识符关联到左值。左值是一个表示数据的表达式(如变量名或解除引用的指针),程序可获取其地址。最初,左值可出现在赋值语句的左边,但修饰符 const 的出现使得可以声明这样的标识符,即不能给它赋值,但可获取其地址: int n; int * pt = new int; const int b = 101; int & rn = n; int & rt = *pt; const int & rb = b; const int & rb = 10;
  4. 右值引用 右值引用就是对一个右值进行引用。C++ 11 新增了右值引用(rvalue reference),这种引用可指向右值(即可出现在赋值表达式右边的值),但不能对其应用地址运算符。右值包括字面常量(C-风格字符串除外,它表示地址)、诸如 x + y 等表达式以及返回值的函数(条件是该函数返回的不是引用),右值引用使用 && 声明: int x = 10; int y = 23; int && r1 = 13; int && r2 = x + y; double && r3 = std::sqrt(2.0);
  5. 右值引用的使用场景 右值引用可以实现移动语义、完美转发。

17.weak_ptr 如何解决 shared_ptr 的循环引用问题?

weak_ptr 是为了配合 shared_ptr 而引入的一种智能指针,它指向一个由 shared_ptr 管理的对象而不影响所指对象的生命周期,也就是将一个 weak_ptr 绑定到一个 shared_ptr 不会改变 shared_ptr 的引用计数,依此特性可以解决 shared_ptr 的循环引用问题。 weak_ptr 没有解引用 * 和获取指针 -> 运算符,它只能通过 lock 成员函数去获取对应的 shared_ptr 智能指针对象,从而获取对应的地址和内容。 不论是否有 weak_ptr 指向,一旦最后一个指向对象的 shared_ptr 被销毁,对象就会被释放。

18.请你说说虚函数可以是内联函数吗

虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时不可以内联。virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类,这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。

19.请你说说迭代器失效原因,有哪些情况

STL 中某些容器调用了某些成员方法后会导致迭代器失效。例如 vector 容器,如果调用 reserve() 来增加容器容量,之前创建好的任何迭代器(例如开始迭代器和结束迭代器)都可能会失效,这是因为,为了增加容器的容量,vector 容器的元素可能已经被复制或移到了新的内存地址。

  1. 序列式容器迭代器失效 对于序列式容器,例如 vector、deque,由于序列式容器是组合式容器,当当前元素的迭代器被删除后,其后的所有元素的迭代器都会失效,这是因为 vector、deque都是连续存储的一段空间,所以当对其进行 erase 操作时,其后的每一个元素都会向前移一个位置。解决:erase 返回下一个有效的迭代器。
  2. 关联式容器迭代器失效 对于关联容器,例如如 map、 set,删除当前的迭代器,仅仅会使当前的迭代器失效,只要在 erase 时,递增当前迭代器即可。这是因为 map 之类的容器,使用了红黑树来实现,插入、删除一个节点不会对其他点造成影响。erase 迭代器只是被删元素的迭代器失效,但是返回值为 void,所以要采用 erase(iter++) 自增方式删除迭代器。

20.请你说说 auto 和 decltype 如何使用

auto 实现自动类型推断,要求进行显示初始化,让编译器能够将变量的类型设置为初始值的类型
decltype 将变量的类型声明为表达式指定的类型。decltype的类型推导并不是像auto一样是从变量声明的初始化表达式获得变量的类型,而是总是以一个普通表达式作为参数,返回该表达式的类型,而且decltype并不会对表达式进行求值。

21.说说 C++ 中智能指针和指针的区别是什么?

智能指针和普通指针的区别在于智能指针实际上是对普通指针加了一层封装机制,区别是它负责自动释放所指的对象,这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期。指针是一种数据类型,用于保存内存地址,而智能指针是类模板。

22.简述一下 C++ 中的四种类型转换

1.static_cast 静态转换 用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换 - 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的 - 进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的 用于基本数据类型之间的转换,如把 int 转换成 char,把 char 转换成 int。这种转换的安全性也要开发人员来保证
2.dynamic_cast 动态转换 dynamic_cast 主要用于类层次间的上行转换和下行转换 在类层次间进行上行转换时,dynamic_cast 和 static_cast 的效果是一样的 在进行下行转换时,dynamic_cast 具有类型检查的功能,比 static_cast 更安全
3. const_cast 常量转换 该运算符用来修改类型的const属性 常量指针被转化成非常量指针,并且仍然指向原来的对象 常量引用被转换成非常量引用,并且仍然指向原来的对象 注意:不能直接对非指针和非引用的变量使用 const_cast 操作符
4. reinterpret_cast 重新解释转换 这是最不安全的一种转换机制,最有可能出问题 主要用于将一种数据类型从一种类型转换为另一种类型,它可以将一个指针转换成一个整数,也可以将一个整数转换成一个指针

new 和 delete 是如何实现的

1.new的实现过程是:首先调用名为operator new 的标准库函数,分配足够大的内存来保存指定类型的一个对象,接下来运行该类型的构造函数,用于初始化该对象,最后返回该对象的指针。
2.delete的实现过程:对指针指向的对象运行适当的析构函数,然后调用operator delete的标准库函数释放该对象的内存

指针占多少字节

一个指针占内存的大小跟编译环境有关,而与机器的位数无关。32位编译环境下为4字节,64位编译环境下为8字节

顶层const和底层const

1.顶层const可以表示任意的对象是常量,这一点对任何数据类型都使用,底层const则与指针和引用的复合类型有关
2.当执行对象的拷贝操作时,顶层const不受什么影响,但是底层const的限制不能被忽略。
3.对于重载,顶层const不构成重载,底层const构成重载

数据库三大范式

1.列不可再分
2.属性完全依赖于主键(一张表中包含了多种不同的属性,那么必须要分成多张表)
3.属性不依赖于其他非主属性(要求已经分好了多张表的话,一张表中只能有另一张表的ID,而不能有其他任何信息)

类成员继承权限问题

1.若继承方式是public,基类成员在派生类中的访问权限保持不变,也就是说,基类中的成员访问权限,在派生类中仍然保持原来的访问权限;
2.若继承方式是private,基类所有成员在派生类中的访问权限都会变为私有(private)权限;
3.若继承方式是protected,基类的共有成员和保护成员在派生类中的访问权限都会变为保护(protected)权限,私有成员在派生类中的访问权限仍然是私有(private)权限。

禁止隐式转换

C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制。可以通过将构造函数声明为explicit加以制止隐式类型转换,关键字explicit只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为explicit。

数组和指针的区别

1.数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:sizeof(数组名);数组大小:sizeof(数组名)/sizeof(数组元素数据类型);
2.用运算符sizeof 可以计算出数组的容量(字节数)。sizeof(p),p 为指针得到的是一个指针变量的字节数,而不是p 所指的内存容量。
3.在向函数传递参数的时候,如果实参是一个数组,那用于接受的形参为对应的指针。也就是传递过去是数组的首地址而不是整个数组,能够提高效率;

写一个比较大小的模板

template<typename type1, typename type2>

type1 max(type1 a, type2 b){
    return a > b ? a : b;
}

void main(){
    cout << max(5.5, 'a') << endl;
}

其实该模板有个比较隐晦的bug,那就是a、b只有在能进行转型的时候才能进行比较,否则 a > b 这一步是会报错的。这个时候往往需要对于 > 号进行重载,这代码量瞬间上来了。

回调函数

回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数,比如epoll中可以通过回调函数找到对应的红黑树节点。

delete和delete[]的区别

delete只会调用一次析构函数。
delete[]会调用数组中每个元素的析构函数

类的对象存储空间

1.非静态成员的数据类型大小之和。
2.编译器加入的额外成员变量(如指向虚函数表的指针)。
3.为了边缘对齐优化加入的padding。
4.空类(无非静态数据成员)的对象的size为1,这样可以保证每个实例均有独占的内存地址,当作为基类时, size为0,如果带有虚函数,则大小比1大,因为还需要包含一个虚函数表指针
5.成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。

在成员函数中调用delete this会出现什么问题?对象还可以使用吗?

在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题

如果在类的析构函数中调用delete this,会发生什么?

会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

auto和decltype的区别

lambda函数

1.利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;
2.每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,其实一个右值。所以,我们上面的lambda表达式的结果就是一个闭包。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块。
3.lambda语法如下:

[capture] (parameters) mutable ->return-type {statement};

4.lambda必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列表和函数体;

STL中hashtable的实现

1.STL中的hashtable使用的是开链法解决hash冲突问题
2.hashtable中的bucket所维护的list既不是list也不是slist,而是其自己定义的由hashtable_node数据结构组成的linked-list,而bucket聚合体本身使用vector进行存储。hashtable的迭代器只提供前进操作,不提供后退操作

vector如何释放空间

1.由于vector的内存占用空间只增不减,比如你首先分配了10,000个字节,然后erase掉后面9,999个,留下一个有效元素,但是内存占用仍为10,000个。所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素。但是即使clear(),vector所占用的内存空间依然如故,无法保证内存的回收。
2.如果使用vector,可以用swap()来帮助你释放多余内存或者清空全部内存。

容器内删除一个元素

1.顺序容器(序列式容器,比如vector、deque)
erase迭代器不仅使所指向被删除的迭代器失效,而且使被删元素之后的所有迭代器失效(list除外),所以不能使用erase(it++)的方式,但是erase的返回值是下一个有效迭代器;

it = c.erase(it);

2.关联容器(关联式容器,比如map、set、multimap、multiset等)
erase迭代器只是被删除元素的迭代器失效,但是返回值是void,所以要采用erase(it++)的方式删除迭代器;

c.erase(it++)

STL每种容器对应的迭代器

1.vector和deque:随机访问迭代器
2.stack、queue、priority_queue:无
3.list、(multi)set/map:双向迭代器
unordered_(multi)set/map、forward_list:前向迭代器

sizeof和strlen的区别

1、sizeof会将空字符\0计算在内,而strlen不会将空字符\0计算在内;
2、sizeof会计算到字符串最后一个空字符\0并结束,而strlen如果遇到第一个空字符\0的话就会停止并计算遇到的第一个空字符\0前面的长度。

int main(void)
{
    char str[100] = "abcde";
    printf("sizeof(str) = %lu\n", sizeof(str));     //字节大小为100

    char str1[] = "abcde";
    printf("sizeof(str1) = %lu\n", sizeof(str1));   //字节大小为6

    char str2[] = "\0abcde";
    printf("sizeof(str2) = %lu\n", sizeof(str2));   //字节大小为7

    char str3[] = "\0ab\0c de";
    printf("sizeof(str3) = %lu\n", sizeof(str3));   //字节大小为9

    char str4[] = "abcde";
    printf("strlen(str4) = %lu\n", strlen(str4));   //字符串长度为5

    char str5[100] = "abcde";
    printf("strlen(str5) = %lu\n", strlen(str5));   //字符串长度为5

    char str6[] = "\0abcde";
    printf("strlen(str6) = %lu\n", strlen(str6));   //字符串长度为0

    char str7[] = "ab cde";
    printf("strlen(str7) = %lu\n", strlen(str7));   //字符串长度为6

    return 0;
}

哪些函数不能是虚函数?把你知道的都说一说

1.构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造
2.内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
3.静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
4.友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
5.普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。

什么时候必须要自定义析构函数

如果本类中一个成员变量是别的对象的指针,而且这个指针不是传进来的地址而是这个指针指向的对象,是在本类中(如果是栈里的定位分配,也不用考虑内存)在堆中开辟的空间创建的。并且该指针没有进行过delete操作,那么久需要在析构方法中进行delete操作,此时我们就必须自己写析构函数。

析构函数的析构过程

析构函数首先执行函数体,然后销毁成员,成员按照初始化顺序的逆序销毁。注意析构函数本身不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的,销毁类类型成员执行它自己的析构函数,销毁内置类型不需要做什么。

何时会调用析构函数

  • 变量在离开其作用域时
  • 当一个对象被销毁时,其成员被销毁
  • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁
  • 对于动态分配的对象,当指向它的指针应用delete运算符时被销毁
  • 对于临时对象,当创建它的完整表达式结束时被销毁

什么时候需要自己重写拷贝构造函数

根据三/五法则,如果需要定义一个非空的析构函数,那么通常情况下也需要自定义一个拷贝构造函数。即包含动态分配成员或者包含指针成员的类都应该提供拷贝构造函数,并且考虑重载赋值运算符。

1.多态,运行时多态(虚函数表和继承),静态多态(重载和模板编程)
2.如何实现对象只能分配在栈上:c++中,类的对象建立分为两种,

  • 静态建立:由编译器在栈上为对象开辟空间,直接调用类的构造函数。
    A a;
  • 动态建立:由new关键字将对象建立在堆空间上,首先执行 operator new()函数,在堆上搜索合适的内存并分配,第二步是调用构造函数构造对象。即间接的调用类的构造函数。
    A a = new A;
    (1)只有使用new关键字才能在堆上建立对象,那么我们就可以将operator new()函数自行声明为私有函数即可,或者设置为delete。
    1
    2
    3
    4
    5
    6
    7
    8
    class A{
    private;
    void* operator new(size_t t){} //设置为私有
    void operator delete(void *ptr){}
    public:
    A(){}
    ~A(){}
    }

(2)只在堆上分配新对象
即不能直接调用类的构造函数。首先要知道,当对象建立在栈上面时, 是由编译器分配内存空间的,当对象使用完以后,编译器会调用析构函数来释放对象所占的空间。实际上,编译器在为类对象分配栈空间时, 会检查类的析构函数的访问性(其他非静态函数也会检查),如果类的析构函数是私有的,则编程器不会在栈空间上为类对象分配内存。因此,我们只需要将析构函数设为私有,类对象就无法建立在栈上了。

1
2
3
4
5
6
7
class A{
public:
A(){}
void destroy(delete this;)
private:
~A(){}
}

注意,由于new表达式会在分配内存以后调用构造函数,因此构造函数必须是公有的,同时由于delete此时无法访问私有的析构函数,因此必须提供一个destroy函数,来进行内存空间的释放。
3.c++11新特性
auto,智能指针,移动语义(右值引用),完美转发
4.内联函数和宏的区别

  • 内联函数是函数,有参数类型检查,更为安全
  • 内联函数由编译器进行处理,而宏定义由预处理器进行处理
  • 内联函数处理时被插入到对应代码区域,而宏定义只是简单的文本替换

5.多线程
半同步半反应线程池
主线程充当异步线程,负责监听所有socket上的事件,若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件,并将数据封装成请求对象插入到请求队列中;所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权

6.Linux进程调度和内存管理
7.设计模式
工厂模式和单例模式,适配器模式和策略模式
8.lambda表达式
9.手撕strcpy(不用任何库函数)
10.快排的 算法原理+手撕代码。
11.读代码说输出结果:一个简单的子类继承父类的代码
子类析构时,首先执行子类析构函数体的代码,然后执行子类成员对象所在类的析构函数,最后按照子类继承各个父类的次序,倒序各个父类的析构函数
https://zhuanlan.zhihu.com/p/371322392

12.栈溢出的原因
内存中栈一般存放,函数地址、函数参数、局部变量等信息存储于栈内存;
1>函数调用层次过深,每调用一次,函数的参数、局部变量等信息就压一次栈。
2>局部变量体积太大

0%