我的简历

CS-Notes 面试笔记 (cyc2018.xyz)
小林coding (xiaolincoding.com)
帅地玩编程-校招|面试|学习路线,你都可以在这里找到 (iamshuaidi.com)

大厂15万字C++开发面试手册出炉(C++和计算机基础全有) (qq.com)

编程十万问的个人空间 (bilibili.com)

程序员面试HR问题汇总及答案,好不容易过了一面二面,千万别栽在HR面_哔哩哔哩_bilibili




手撕

  1. 宇量升
查看解析

就是leetcode的动态规划原题,刷的不多,压根就没往动态规划想,下面是现场做的暴力解法

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
#define _CRT_SECURE_NO_WARNINGS
#include<bits/stdc++.h>

using namespace std;

int main() {
if (freopen("./sample1.txt", "r", stdin) == NULL) { printf("样例文件不存在\n"); return 1; }
/*
nums[n]: 最大和 的连续子数组,至少包含一个数 n < 10^5 int -10^4 ~ 10^4

-2 1 -3 4 -1 2 1 -5 4
output: 6 [4 -1 2 1]

3
output: 3 [3]
*/
vector<int> nums;
int n;
while (scanf("%d", &n) != EOF) nums.push_back(n);
// 子数组[i, j]
int max = -0x7fffffff;
struct { // [left, right]
int left;
int right;
}ans;
for (int i = 0; i < nums.size(); i++) {
int tmp = 0; // 至少一个
for (int j = i; j < nums.size(); j++) {
tmp += nums[j];
if (max < tmp) { // 更新最大值
max = tmp;
ans.left = i;
ans.right = j;
}
}
}
printf("最大数和为:%d\n", max);
for (int i = ans.left; i <= ans.right; i++) printf("%d ", nums[i]);
return 0;
}
  1. 招银网络:求子序列
查看题目

一个字符串a被称作另一个字符串b的子序列,说明从序列b通过去除某些元素但不破坏余下元素的相对位置(在前或在后)可得到序列a。

字典序是基于字母顺序排列的单词按字母顺序排列的方法。比如ca是cmbchina的子序列,但是caa不是

假设小招有一个字符串,只包含英文的小写字母,小招想求出给定字符串的子序列,按照字典序最大的是哪一个?

示例 1

输入

1
cmbchina

输出

1
na

示例 2

输入

1
aabcbccacbbcbaaba

输出

1
cccccbba

现场代码

大的退栈,一直退

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
class Solution {
public:
string maxDictionaryorder(string s) {
stack<char> stk;
for (int i = 0; i < s.size(); i++) {
if (stk.empty()) {
stk.push(s[i]);
continue;
}
char top = stk.top();
while (s[i] > top) {
stk.pop();
if (stk.empty()) {
break;
}
top = stk.top();
}
stk.push(s[i]);

}
string ans;
while (!stk.empty()) {
ans.push_back(stk.top());
stk.pop();
}
return reverseStr(ans);
}
private:
string & reverseStr(string &str) {
for (int i = 0; i < str.size() / 2; i++) {
swap(str[i], str[str.size() - i - 1]);
}
return str;
}
};

回来想的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
/*
思路;倒着输出,找一个非递增序
*/
string maxDictionaryorder(string s) {
string ans;
int maxIndex = s.size() - 1; // 当前找到的最大的字母的索引
for (int i = s.size() - 1; i >= 0; i--) {
if (s[i] >= s[maxIndex]) {
maxIndex = i; // 更新
ans.push_back(s[i]);
}
}
reverse(ans.begin(), ans.end()); // 翻转回来
return ans;
}
};
  1. 快手一面:翻转中间部分链表
查看解析

92. 反转链表 II - 力扣(LeetCode)

反转从位置 mn 的链表。请使用一趟扫描完成反转。

说明:
1 ≤ mn ≤ 链表长度。

示例:

1
2
输入: 1->2->3->4->5->NULL, m = 2, n = 4
输出: 1->4->3->2->5->NULL
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
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode *head0 = new ListNode(0, head); // 新建哨兵节点
// 找到left的前一个
ListNode *p = head0;
for (int i = 0; i < left-1; i++) {
p = p->next;
}
// 头插法 [left right]
ListNode *tmpFirst = p->next;
ListNode *tmp = p->next;
for (int i = 0; i < right - left + 1; i++) {
ListNode *tmpNext = tmp->next;
tmp->next = p->next;
p->next = tmp;
tmp = tmpNext;
}
// 剩下的放在第一个后面,如果没有这一步还可以置空
tmpFirst->next = tmp;
return head0->next;
}
};
  1. 快手二面:循环打印链表
查看解析

希望可以顺时针螺旋打印一个矩阵,用 int[][] a 来表达这个矩阵。

eg:

a[0][0] ... a[0][n] ... a[m][n] ... a[m][0] ... a[1][0] ... a[1][n-1] ...

1 2 3

4 5 6

7 8 9

1 2 3 6 9 8 7 4 5

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
#include <iostream>
#include <vector>
using namespace std;


bool isOver(int x, int y, int m, int n) {
if (x < 0 || y < 0 || x >= m || y >= n) return true;
return false;
}

void printArray(int a[][3], int m, int n) {
vector<vector<bool>> marked(m, vector<bool>(n, false));
enum Dir { LEFT, RIGHT, UP, DOWN };
// 从左上角开始,初始向右
Dir curDir = RIGHT;
int x = 0, y = 0; // 位置
for (int i = 0; i < m * n; i++) {
switch (curDir) {
case RIGHT:
// 没越界 且 没走过
if (!isOver(x, y, m, n) && marked[x][y] == false) {
marked[x][y] = true;
printf("%d ", a[x][y]);
y++; // 继续移动
}
// 需要回退,且转向
else {
y--;
curDir = DOWN;
x++;
i--;
}
break;
case DOWN:
// 没越界 且 没走过
if (!isOver(x, y, m, n) && marked[x][y] == false) {
marked[x][y] = true;
printf("%d ", a[x][y]);
x++; // 继续移动
}
// 需要回退,且转向
else {
x--;
curDir = LEFT;
y--;
i--;
}
break;
case LEFT:
// 没越界 且 没走过
if (!isOver(x, y, m, n) && marked[x][y] == false) {
marked[x][y] = true;
printf("%d ", a[x][y]);
y--; // 继续移动
}
// 需要回退,且转向
else {
y++;
curDir = UP;
x--;
i--;
}
break;
case UP:
// 没越界 且 没走过
if (!isOver(x, y, m, n) && marked[x][y] == false) {
marked[x][y] = true;
printf("%d ", a[x][y]);
x--; // 继续移动
}
// 需要回退,且转向
else {
x++;
curDir = RIGHT;
y++;
i--;
}
break;
}
}
}

int main() {
int a[3][3] = {
{1,2,3},
{4,5,6},
{7,8,9}
};
printArray(a, 3, 3);
return 0;
}
  1. shopee,32位加法
查看代码

36进制由0-9,a-z,共36个字符表示。

要求按照加法规则计算出任意两个36进制正整数的和,如1b + 2x = 48 (解释:47+105=152)

要求:不允许使用先将36进制数字整体转为10进制,相加后再转回为36进制的做法

作答时间30min,完成时间25

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
#include<bits/stdc++.h>

using namespace std;

char map_[36] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
'y', 'z'};


int change2num(char c) {
if (c >= '0' && c <= '9') {
return c - '0';
}
else if (c >= 'a' && c <= 'z') {
return c - 'a' + 10;
}
return -1;
}

int main() {
string num1, num2, ans;
cin >> num1 >> num2;
if (num1.size() < num2.size()) { // 保持num1更长
swap(num1, num2);
}

reverse(num1.begin(), num1.end());
reverse(num2.begin(), num2.end());

bool flag = false; // 是否进位
for (int i = 0; i < num2.size(); i++) {
int curSum = change2num(num1[i]) + change2num(num2[i]);
if (flag == true) {
curSum += 1;
}
int left = curSum % 36;
ans.push_back(map_[left]);
// printf("i=%d, curSum=%d,insert=%c\n", i, curSum, map_[left]);
if (curSum >= 36) {
flag = true;
}
else {
flag = false;
}
}

for (int i = num2.size(); i < num1.size(); i++) {
int curSum = change2num(num1[i]);
if (flag == true) {
curSum += 1;
}
ans.push_back(map_[curSum % 36]);
if (curSum >= 36) {
flag = true;
}
else {
flag = false;
}
}

if (flag == true) {
ans.push_back('1');
}

// cout << reverse(ans.begin(), ans.end()) << endl;
for (int i = ans.size() - 1; i >= 0; i--) {
cout << ans[i];
}
// cout << ans << endl;

return 0;
}




C++面经

C++基础知识

  1. C和C++有什么区别?
查看解析
  • C++是面向对象的语言,而C是面向过程的语言;
  • C++引入 new/delete 运算符,取代了C中的 malloc/free 库函数;
  • C++引入引用的概念,而C中没有;
  • C++引入类的概念,而C中没有;
  • C++引入函数重载的特性,而C中没有
  1. a和&a有什么区别?
查看解析

假设数组int a[10]; int (*p)[10] = &a;其中:

  • a是数组名,是数组首元素地址, +1表示地址值加上一个int类型的大小,如果a的值是0x00000001,加1操作后变为0x00000005*(a + 1) = a[1]
  • &a是数组的指针,其类型为int (*)[10](就是前面提到的数组指针),其加1时,系统会认为是数组首地址加上整个数组的偏移(10int型变量),值为数组a尾元素后一个元素的地址。
  • (int *)p ,此时输出 *p时,其值为a[0]的值,因为被转为int *类型,解引用时按照int类型大小来读取。
  1. static关键字有什么作用?
查看解析
  1. 修饰局部变量时,使得该变量在静态存储区分配内存;只能在首次函数调用中进行首次初始化,之后的函数调用不再进行初始化;其生命周期与程序相同,但其作用域为局部作用域,并不能一直被访问;
  2. 修饰全局变量时,使得该变量在静态存储区分配内存;在声明该变量的整个文件中都是可见的,而在文件外是不可见的;
  3. 修饰函数时,在声明该函数的整个文件中都是可见的,而在文件外是不可见的,从而可以在多人协作时避免同名的函数冲突;
  4. 修饰成员变量时,所有的对象都只维持一份拷贝,可以实现不同对象间的数据共享;不需要实例化对象即可访问;不能在类内部初始化,一般在类外部初始化,并且初始化时不加 static
  5. 修饰成员函数时,该函数不接受 this 指针,只能访问类的静态成员;不需要实例化对象即可访问。
  1. #define和const有什么区别?
查看解析
  1. 编译器处理方式不同: #define 宏是在预处理阶段展开,不能对宏定义进行调试,而 const 常量是在编译阶段使用;
  2. 类型和安全检查不同: #define 宏没有类型,不做任何类型检查,仅仅是代码展开,可能产生边际效应等错
    误,而 const 常量有具体类型,在编译阶段会执行类型检查;
  3. 存储方式不同: #define 宏仅仅是代码展开,在多个地方进行字符串替换,不会分配内存,存储于程序的代码段中,而 const 常量会分配内存,但只维持一份拷贝,存储于程序的数据段中。
  4. 定义域不同: #define 宏不受定义域限制,而 const 常量只在定义域内有效。
  1. 对于一个频繁使用的短小函数,应该使用什么来实现?有什么优缺点?
查看解析

应该使用 inline 内联函数,即编译器将 inline 内联函数内的代码替换到函数被调用的地方。

优点:
在内联函数被调用的地方进行代码展开,省去函数调用的时间,从而提高程序运行效率;
相比于宏函数,内联函数在代码展开时,编译器会进行语法安全检查或数据类型转换,使用更加安全;

缺点:
代码膨胀,产生更多的开销;
如果内联函数内代码块的执行时间比调用时间长得多,那么效率的提升并没有那么大;
如果修改内联函数,那么所有调用该函数的代码文件都需要重新编译;
内联声明只是建议,是否内联由编译器决定,所以实际并不可控


GPT:

inline 关键字在 C++ 中用于建议编译器将某个函数的代码在每个调用点直接展开,而不是执行常规的函数调用。这意味着使用 inline 关键字的函数的代码将直接插入到每个调用该函数的位置,从而可能减少函数调用的开销。但是,最终是否内联,以及如何内联,取决于编译器的决策。

使用场景

inline 关键字通常用于小型、频繁调用的函数。因为这些函数的执行时间可能非常短,常规的函数调用开销(保存寄存器、栈操作等)相对较大,内联可以减少这种开销。

如何使用

在函数声明或定义前加上 inline 关键字。例如:

1
2
3
inline int add(int a, int b) {
return a + b;
}

注意事项

  1. 编译器的自由:尽管 inline 提供了一个明确的建议,但编译器并不总是遵循这个建议。编译器会根据自己的优化规则来决定是否内联一个函数。

  2. 代码膨胀:过度使用 inline 可能会导致代码体积的膨胀,因为每个调用点都会插入函数的完整代码。这不仅增加了程序的大小,还可能影响缓存的利用率和程序的执行速度。

  3. 适用于成员函数:在类定义中,成员函数默认是 inline 的,如果它们是在类定义内部直接定义的。

  4. 链接器的角色:对于在程序多个地方使用的 inline 函数,需要在每个使用点提供函数的定义。这是因为 inline 函数的定义需要对链接器可见,以便在每个调用点展开。通常,这意味着将 inline 函数的定义放在头文件中。

  5. 虚函数:虚函数也可以声明为 inline,但如果通过指针或引用调用虚函数,则不会内联,因为具体调用哪个函数版本在编译时是未知的。

总之,inline 关键字是一种优化工具,它可以减少函数调用的开销,但应谨慎使用,避免导致代码膨胀和其他潜在问题。编译器的现代优化技术通常能够自动决定哪些函数应该内联,因此,在很多情况下,显式使用 inline 关键字可能不是必须的。

  1. 什么是智能指针?智能指针有什么作用?分为哪几种?各自有什么样的特点?
查看解析

智能指针是一个RAII类模型,用于动态分配内存,其设计思想是将基本类型指针封装为(模板)类对象指针,并在离开作用域时调用析构函数,使用 delete 删除指针所指向的内存空间。

智能指针的作用是,能够处理内存泄漏问题和空悬指针问题。

分为 auto_ptrunique_ptrshared_ptrweak_ptr四种,各自的特点

  • 对于 auto_ptr ,实现独占式拥有的概念,同一时间只能有一个智能指针可以指向该对象;但 auto_ptr
    在C++11中被摒弃,其主要问题在于:
    • 对象所有权的转移,比如在函数传参过程中,对象所有权不会返还,从而存在潜在的内存崩溃问题;
    • 不能指向数组,也不能作为STL容器的成员。
  • 对于 unique_ptr ,实现独占式拥有的概念,同一时间只能有一个智能指针可以指向该对象,因为无法进行拷贝构造和拷贝赋值,但是可以进行移动构造和移动赋值;
  • 对于 shared_ptr ,实现共享式拥有的概念,即多个智能指针可以指向相同的对象,该对象及相关资源会在其所指对象不再使用之后,自动释放与对象相关的资源;
  • 对于 weak_ptr ,解决 shared_ptr 相互引用时,两个指针的引用计数永远不会下降为0,从而导致死锁问题。而 weak_ptr 是对象的一种弱引用,可以绑定到 shared_ptr ,但不会增加对象的引用计数。
  1. shared_ptr是如何实现的?
查看解析
  1. 构造函数中计数初始化为1;
  2. 拷贝构造函数中计数值加1;
  3. 赋值运算符中,左边的对象引用计数减1,右边的对象引用计数加1;
  4. 析构函数中引用计数减1;
  5. 在赋值运算符和析构函数中,如果减1后为0,则调用 delete 释放对象。
  1. 右值引用有什么作用?
查看解析

左值、右值是什么?

在C++中,左值(Lvalue)和右值(Rvalue)是根据表达式结束后对象的身份是否还可以被识别来区分的。它们主要用来描述对象的存储期限、地址的可获取性以及表达式的赋值特性。

左值(Lvalue)

  • 定义:左值是指表达式结束后仍然存在的对象或函数。换句话说,左值表达式的结果具有持久状态。
  • 特点:左值可以出现在赋值表达式的左侧,表示对象的身份可以被识别,其内容可以被修改(如果没有const限制)。
  • 示例:变量、解引用指针、数组的元素、具名的非临时对象、函数返回引用等。
1
2
int x = 10; // x 是左值
x = 20; // 可以将20赋值给x,因为x是左值

右值(Rvalue)

  • 定义:右值是指表达式结束时就不再存在的临时对象,以及那些不与对象存储位置直接关联的值。
  • 特点:右值通常用于描述临时对象或者那些不需要持久化的值。右值可以出现在赋值表达式的右侧,但不能被赋值。
  • 分类:纯右值(Prvalue,Pure rvalues)和将亡值(Xvalue,eXpiring values)。纯右值是指临时的或未绑定到对象的值,将亡值是指即将被移动的对象。
  • 示例:字面量、表达式计算的结果、函数返回非引用值等。
1
2
int getNumber() { return 5; }
getNumber(); // getNumber() 的返回值是右值

区分左值和右值的重要性

  • 资源管理:通过区分左值和右值,C++引入了移动语义(Move Semantics),允许资源的所有权在不同对象间转移,提高了程序的效率。
  • 函数重载:函数可以根据接收参数是左值还是右值进行重载,从而更有效地处理数据。
  • 完美转发:利用左值和右值的区分,可以通过std::forward在模板中实现参数的完美转发,既保持了参数的原始类型,又维护了其值类别(左值或右值)。

理解左值和右值对于深入学习C++的现代特性(如右值引用、移动语义和完美转发)非常关键,有助于编写出更高效、更优雅的代码。




什么是移动语义,什么是完美转发,什么是右值引用?

移动语义、完美转发和右值引用是C++11中引入的三个重要概念,它们共同作用于提升C++程序的性能和灵活性,特别是在对象管理和函数调用过程中。

移动语义(Move Semantics)

移动语义允许一个资源(如动态内存)从一个对象转移到另一个对象,避免了不必要的复制,从而提高效率。在传统的复制操作中,对象的内容被复制到新对象,然后原对象保持不变。而在移动操作中,资源的所有权从原对象转移给新对象,原对象不再拥有该资源,这通常通过修改原对象的指针或引用来实现,使其指向一个空状态。

  • 关键点:移动语义通过移动构造函数和移动赋值操作符实现,它们接受一个右值引用作为参数。

完美转发(Perfect Forwarding)

完美转发指的是在函数模板中,能够接受任意实参(包括左值和右值),并将其无损地转发给另一个函数,保持原有的值类别(左值或右值)。这允许开发者编写接受任意实参的模板函数,并将这些实参以几乎零开销转发给其他函数。

  • 实现方法:完美转发通常通过std::forward实现,std::forward是一个条件性转换,它仅当其参数为右值时才将参数转换为右值。

右值引用(Rvalue Reference)

右值引用是引用临时对象(即将销毁的对象)的一种方式,通过类型后面加&&来表示。它是实现移动语义和完美转发的基础。

  • 语法Type&& name,其中Type是数据类型。
  • 特点:右值引用允许一个函数知道其参数是否可以被"移动"。在传统C++中,我们只能通过复制对象来传递它们,即使是临时对象也不例外。右值引用允许我们直接传递临时对象,避免复制的成本。

三者之间的关系

  • 右值引用为移动语义和完美转发提供了语法基础。它允许临时对象被标记并用于优化。
  • 移动语义使用右值引用作为参数,允许资源从一个对象"移动"到另一个对象,而不是复制。
  • 完美转发使用std::forward和右值引用来确保函数模板可以接受任何类型的参数(左值或右值)并将其正确转发给其他函数,保持其原始的值类别。

这三个特性共同作用,使得C++程序在处理临时对象、资源管理以及函数调用时更加高效和灵活。




例子

让我们通过具体的例子来解释移动语义、完美转发和右值引用是如何工作的,以及它们如何改善C++程序的性能。

移动语义示例

假设我们有一个简单的String类,用于动态管理字符串数据。在不使用移动语义的情况下,复制一个String对象会涉及到分配新内存和复制数据,这是资源密集型的。引入移动语义后,我们可以避免这种不必要的复制:

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
class String {
public:
char* data;
size_t size;

String(const char* str) { // 构造函数
size = strlen(str);
data = new char[size + 1];
memcpy(data, str, size + 1);
}

~String() { delete[] data; } // 析构函数

// 移动构造函数
String(String&& other) noexcept : data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}

// 移动赋值运算符
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};

在这个例子中,移动构造函数和移动赋值运算符允许一个String对象接管另一个临时对象的资源,而不是复制它们。当我们有一个临时String对象时,这些资源可以被移动而不是被复制。

完美转发示例

完美转发允许我们写一个模板函数,将其参数精确地转发给另一个函数,保持所有的左值、右值属性不变:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void relay(T&& arg) {
// 使用std::forward<T>来保持arg的左值/右值属性
process(std::forward<T>(arg));
}

void process(int& value) {
std::cout << "Lvalue processed: " << value << std::endl;
}

void process(int&& value) {
std::cout << "Rvalue processed: " << value << std::endl;
}

在这个例子中,relay函数使用模板和右值引用参数T&&接收任何类型的参数。然后它使用std::forward来完美转发这个参数给process函数,无论是左值还是右值。

右值引用示例

右值引用的一个直接用例就是上面提到的移动语义示例中的移动构造函数和移动赋值运算符的实现。右值引用允许函数知道它们可以安全地重用输入参数的资源:

1
2
String a("Hello");
String b(std::move(a)); // 使用移动构造函数

在这个例子中,std::move(a)a转换为右值引用,这使得String的移动构造函数可以接管a的内部资源而不是复制它们。a在移动操作之后处于有效但未定义的状态。

这些例子展示了如何使用移动语义、完美转发和右值引用来提高C++程序的效率,尤其是在涉及资源管理和函数参数传递时。

  1. 悬挂指针与野指针有什么区别?
查看解析

悬挂指针:当指针所指向的对象被释放,但是该指针没有任何改变,以至于其仍然指向已经被回收的内存地址,这种情况下该指针被称为悬挂指针;

野指针:未初始化的指针被称为野指针。

  1. 静态链接和动态链接有什么区别
查看解析

静态链接 是在编译链接时直接将需要的执行代码拷贝到调用处;
优点在于程序在发布时不需要依赖库,可以独立执行,缺点在于程序的体积会相对较大,而且如果静态库更新之后,所有可执行文件需要重新链接;

动态链接 是在编译时不直接拷贝执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定代码时,在共享执行内存中寻找已经加载的动态库可执行代码,实现运行时链接;

  • 优点在于多个程序可以共享同一个动态库,节省资源;
  • 缺点在于由于运行时加载,可能影响程序的前期执行性能。
  1. 变量的声明和定义有什么区别
查看解析

变量的定义为变量分配地址和存储空间, 变量的声明不分配地址。一个变量可以在多个地方声明, 但是只在一个
地方定义。加入 extern 修饰的是变量的声明,说明此变量将在文件以外或在文件后面部分定义。

说明:很多时候一个变量,只是声明不分配内存空间,直到具体使用时才初始化,分配内存空间, 如外部变量。

1
2
3
4
5
6
7
8
int main()
{
extern int A;
// 这是个声明而不是定义,声明A是⼀个已经定义了的外部变量
// 注意:声明外部变量时可以把变量类型去掉如: extern A;
dosth(); // 执行函数
}
int A; // 是定义,定义了A为整型的外部变量
  1. 简述#ifdef、 #else、 #endif和#ifndef的作用
查看解析

利用#ifdef、 #endif将某程序功能模块包括进去,以向特定用户提供该功能。在不需要时用户可轻易将其屏蔽。

1
2
3
#ifdef MATH
#include "math.c"
#endif

在子程序前加上标记,以便于追踪和调试。

1
2
3
#ifdef DEBUG
printf ("Indebugging......!");
#endif

应对硬件的限制。由于一些具体应用环境的硬件不一样,限于条件,本地缺乏这种设备,只能绕过硬件,直接写出预期结果。

「注意」:虽然不用条件编译命令而直接用if语句也能达到要求,但那样做目标程序长(因为所有语句都编译),
运行时间长(因为在程序运行时间对if语句进行测试)。而采用条件编译,可以减少被编译的语句,从而减少目标
程序的长度,减少运行时间。

  1. 写出int 、 bool、 float 、指针变量与 “零值”比较的if 语句
查看解析

注意一下float

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// int与零值比较
if ( n == 0 )
if ( n != 0 )

// bool与零值比较
if (flag) // 表示flag为真
if (!flag) // 表示flag为假

// float与零值比较
const float EPSINON = 0.00001; // = 1e-7也行
if ((x >= - EPSINON) && (x <= EPSINON) //其中EPSINON是允许的误差(即精度)。

// 指针变量与零值比较
if (p == NULL)
if (p != NULL)
  1. 结构体可以直接赋值吗
查看解析

声明时可以直接初始化,同一结构体的不同对象之间也可以直接赋值,但是当结构体中含有指针“成员”时一定要小
心。

「注意」 :当有多个指针指向同一段内存时,某个指针释放这段内存可能会导致其他指针的非法操作。因此在释放
前一定要确保其他指针不再使用这段内存空间。

  1. sizeof 和strlen 的区别
查看解析
  • sizeof是一个操作符, strlen是库函数。
  • sizeof的参数可以是数据的类型,也可以是变量,而strlen只能以结尾为‘\0’的字符串作参数。
  • 编译器在编译时就计算出了sizeof的结果,而strlen函数必须在运行时才能计算出来。并且sizeof计算的是数据类型占内存的大小,而strlen计算的是字符串串实际的长度。
  • 数组做sizeof的参数不退化,传递给strlen就退化为指针了
    (说明,sizeof的参数如果退化为指针,那就会计算指针的大小了,所以其参数还是保持原有的类型)
  1. C 语言的关键字 static 和 C++ 的关键字 static 有什么区别
查看解析

在 C 中 static 用来修饰局部静态变量和外部静态变量、函数。

而 C++中除了上述功能外,还用来定义类的成员变量和函数。即静态成员和静态成员函数。

「注意」 :编程时 static 的记忆性,和全局性的特点可以让在不同时期调用的函数进行通信,传递信息,而 C++的
静态成员则可以在多个对象实例间进行通信,传递信息。

  1. volatile有什么作用
查看解析
  • 状态寄存器一类的并行设备硬件寄存器。
  • 一个中断服务子程序会访问到的非自动变量。
  • 多线程间被几个任务共享的变量。

「注意」 :虽然volatile在嵌入式方面应用比较多,但是在PC软件的多线程中, volatile修饰的临界变量也是非常实用的。


GPT4:

要理解这段描述,首先需要搞清楚volatile关键字在C语言(及类似语言)中的作用,以及它为何在嵌入式系统和多线程编程中特别重要。

volatile关键字的基本作用

volatile是一个类型修饰符。它告诉编译器,修饰的变量可能会在程序的控制之外被改变。换句话说,这意味着编译器在优化代码时,不能假设这个变量的值在两次读取之间是不变的,即使在程序中没有明显的代码对它进行修改。因此,每次访问volatile修饰的变量时,都会直接从其内存位置读取,而不是使用可能存储在寄存器中的缓存值。

volatile的应用场景

  1. 状态寄存器一类的并行设备硬件寄存器

    • 这些寄存器的值可能由硬件事件(如接收到数据)而非软件控制的代码改变。因此,使用volatile修饰可以防止编译器优化掉对这些寄存器的读操作(下面有解释),确保软件能够获取到最新的硬件状态。
  2. 一个中断服务子程序会访问到的非自动变量

    • 在中断服务例程(ISR)中,变量的值可能在ISR外被修改,或者在ISR中修改后需在主程序中使用。因此,这些变量应被声明为volatile,以确保正确读取它们的值。
  3. 多线程间被几个任务共享的变量

    • 在多线程应用中,一个线程可能修改另一个线程可以访问的变量。虽然volatile可以确保变量的读/写操作不会被编译器优化掉,但它并不保证操作的原子性。这意味着在多线程环境下,仅仅使用volatile并不能完全解决并发访问的问题,可能还需要使用互斥锁或其他同步机制来保证数据一致性和完整性。

注意事项

虽然volatile在嵌入式开发中经常被用于处理硬件寄存器访问的情况,但它在多线程程序中也是有用的,尤其是当你需要确保变量的更新能够即时反映到其他线程中时。然而,重要的是要理解volatile并不能替代锁或其他同步机制来处理线程之间的竞争条件。volatile确保了变量访问的可见性,但不保证原子性。在多数现代编程语言和环境中,处理多线程共享数据时,更推荐使用专门的并发控制或同步机制,比如锁(mutexes)、信号量(semaphores)或原子操作(atomic operations)。


编译器在将源代码转换成机器代码的过程中会进行优化,以提高程序的运行效率和减少占用的资源。这些优化可能包括重排指令、消除冗余的代码、以及减少不必要的内存访问等。当编译器认为某些变量的值在它们的使用过程中不会改变时,它可能决定不重复从内存中读取这些变量的值,而是使用之前读取的值或者将这些值缓存起来。

对于普通的变量,这种优化通常是安全且有效的,因为如果代码中没有显式地修改这些变量的值,那么它们的值确实是不变的。然而,对于某些特殊的变量,如硬件状态寄存器或由另一个线程控制的变量,这种假设就不成立了。这些变量的值可能会在外部事件的影响下随时改变,而这种改变对于当前执行的代码来说是不可预见的。

例如,考虑一个与硬件设备交互的情况,设备的状态通过读取特定的内存地址(寄存器)来检查。如果设备状态改变(比如说,数据可用于读取),则相应的寄存器值会变化。如果编译器优化掉了对这个寄存器的读操作,认为它之前读取过一次后就不会再改变,那么程序就可能错过设备状态的更新,继而导致逻辑错误或无法从设备读取数据。

因此,当我们使用volatile关键字声明这类特殊变量时,我们告诉编译器,这些变量的值可能会在程序控制之外改变,编译器不应该对这些变量进行优化,如缓存它们的值或者删除看似无用的读操作。这确保了每次访问volatile变量时都会直接从它们所在的内存地址读取,从而获取到最新的值。

  1. 一个参数可以既是const⼜又是volatile吗
查看解析

可以,用constvolatile同时修饰变量,表示这个变量在程序内部是只读的,不能改变的,只在程序外部条件变化下改变,并且编译器不会优化这个变量。每次使用这个变量时,都要小心地去内存读取这个变量的值,而不是去寄存器读取它的备份。

注意:在此一定要注意const的意思, const只是不允许程序中的代码改变某一变量,其在编译期发挥作用,它并没
有实际地禁止某段内存的读写特性。

  1. 全局变量和局部变量有什么区别?操作系统和编译器是怎么知道的?
查看解析

全局变量是整个程序都可访问的量,谁都可以访问,生存期在整个程序从行到结束(在程序结束时所占内存释放);

而局部变量存在于模块(子程序,函数)中,只有所在模块可以访问,其他模块不可直接访问,模块结束(函数调用完毕),局部变量消失,所占据的内存释放。

操作系统和编译器,可能是通过内存分配的位置来知道的,全局变量分配在全局数据段并且在程序开始运行的时候被加载局部变量则分配在堆栈里面。

  1. 简述strcpy、 sprintf 与memcpy 的区别
查看解析
  • 操作对象不同, strcpy 的两个操作对象均为字符串串, sprintf 的操作源对象可以是多种数据类型, 目的操作对象是字符串, memcpy 的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
  • 执行效率不同, memcpy 最⾼高, strcpy 次之, sprintf 的效率最低。
  • 实现功能不同, strcpy 主要实现字符串串变量间的拷贝, sprintf 主要实现其他数据类型格式到字符串的转化,memcpy 主要是内存块间的拷贝。

「注意」 : strcpysprintfmemcpy 都可以实现拷贝的功能,但是针对的对象不同,根据实际需求,来选择合适的函数实现拷贝功能

  1. 请解析 ((void ()( ) )0)( ) 的含义
查看解析

表达式用到了C语言中的类型转换和函数指针的概念,可能看起来有点复杂,但我们可以一步步解析来理解它们的含义。

  1. void (*0)( )

这个表达式试图声明一个函数指针,但它是不正确的。在C语言中,函数指针的声明需要指定函数的返回类型,函数参数的类型(如果有的话),以及一个变量名作为指针的标识。例如,一个正确的函数指针声明可能看起来像这样:

1
void (*funcPtr)();

这声明了funcPtr为一个函数指针,该函数的返回类型为void且不接受任何参数。因此,void (*0)( )是不合法的,因为0不能用作变量名。

  1. (void (*)( ))0

这个表达式是正确的。它将0(通常代表NULL指针)强制类型转换为一个返回值为void、参数为空的函数指针类型。这是一种将整数(在这种情况下是零值)显式转换为指定类型的函数指针的方法。

  1. (void ()( ))0

这个表达式有语法错误。看起来它试图声明一个函数类型,然后将0作为该类型的实例,但这样的语法是不正确的。如果目的是声明一个函数类型,正确的方式可能是使用typedef来定义一个函数指针类型,例如:

1
typedef void (*funcType)();

然而,即使这样,也不能将0直接与函数类型一起使用来创建一个实例。

  1. ((void ()( ))0)( )

由于第3点中的表达式是错误的,这个表达式同样是基于错误的前提。这个表达式的意图看起来是尝试调用一个起始地址为0的函数,但由于它的基础是不正确的语法,这个表达式也是错误的。

如果想要调用一个函数指针,首先需要正确地声明并初始化这个函数指针。例如,假设有一个函数指针指向地址为0的位置,并希望调用它,正确的做法应该类似于:

1
2
void (*funcPtr)() = (void (*)())0; // 将0转换为函数指针类型
funcPtr(); // 调用函数指针

需要注意,尝试执行起始地址为0的函数是危险的,因为这通常代表着空指针,尝试调用这样的函数会导致未定义行为,可能导致程序崩溃。

总之,这些表达式中的一些是错误的或者基于不正确的语法,理解它们需要对C语言中的类型转换和函数指针有深入的了解。正确使用函数指针非常重要,特别是在涉及到底层编程或嵌入式系统编程时。

  1. C语言的指针 和 c++的引用有什么区别?
查看解析
  • 指针有自己的一块空间,而引用只是一个别名;
  • 使用sizeof看一个指针的大小是4,而引用则是被引用对象的大小;
  • 作为参数传递时,指针需要被解引用才可以对象进行操作,而直接对引用的修改都会改变引用所指向的对象;
  • 可以有const指针,但是没有const引用;
  • 指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变;
  • 指针可以有多级指针(**p),而引用止于一级;
  • 指针和引用使用++运算符的意义不一样;
  • 如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄露。

解释:

const引用

在C++中,确实没有所谓的“非const引用”,因为引用一经初始化后就不能改变它所绑定的对象。但是,可以有指向const类型的引用,我们通常称之为const引用。const引用的特点是不能通过它来修改它所引用的对象。例如:

1
const int& ref = someInt;

这里,ref是一个const引用,指向一个int类型的值。通过ref,你不能修改它所引用的int值。

  1. typedef和define有什么区别
查看解析
  • 用法不同: typedef 用来定义一种数据类型的别名,增强程序的可读性。 define 主要用来定义常量,以及书写复杂使用频繁的宏。
  • 执行时间不同: typedef 是编译过程的一部分,有类型检查的功能。 define 是宏定义,是预编译的部分,其发生在编译之前,只是简单的进行字符串的替换,不进行类型的检查。
  • 作用域不同: typedef 有作用域限定。 define 不受作用域约束,只要是在define声明后的引用都是正确的。
  • 对指针的操作不同: typedefdefine 定义的指针时有很大的区别。

「注意」 : typedef 定义是语句,因为句尾要加上分号。而define 不是语句,千万不能在句尾加分号。

  1. 指针常量与常量指针区别
查看解析

指针常量是指定义了一个指针,这个指针的值只能在定义时初始化,其他地方不能改变。常量指针是指定义了一个指针,这个指针指向一个只读的对象,不能通过常量指针来改变这个对象的值。指针常量强调的是指针的不可改变性,而常量指针强调的是指针对其所指对象的不可改变性。

「注意」 :无论是指针常量还是常量指针,其最大的用途就是作为函数的形式参数,保证实参在被调用函数中的不可改变特性。

  1. 简述队列和栈的异同
查看解析

队列和栈都是线性存储结构,但是两者的插入和删除数据的操作不同,队列是“先进先出”,栈是 “后进先出”。

「注意」 :区别栈区和堆区。堆区的存取是“顺序随意”,而栈区是“后进先出”。栈由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。堆一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS 回收。分配方式类似于链表。它与本题中的堆和栈是两回事。堆栈只是一种数据结构,而堆区和栈区是程序的不同内存储区域。

  1. 设置地址为0x67a9 的整型变量的值为0xaa66
查看解析
1
2
3
int *ptr;
ptr = (int *)0x67a9;
*ptr = 0xaa66;

「注意」 :这道题就是强制类型转换的典型例子,无论在什么平台地址长度和整型数据的长度是一样的, 即一个整型数据可以强制转换成地址指针类型,只要有意义即可。


实际上,地址长度(即指针的大小)与整型数据的长度(例如,int的大小)不一定在所有平台上都是一样的。这两者的大小取决于处理器的架构(尤其是其地址总线和数据总线的宽度),以及编译器的实现细节。

地址长度(指针大小)

  • 地址长度或者说指针大小,取决于计算机的架构。在32位系统上,指针通常是32位(4字节)的,这意味着它们可以直接寻址高达2^32字节(即4GB)的内存空间。而在64位系统上,指针通常是64位(8字节)的,可以寻址2^64字节(即16EB,1EB等于1024PB,1PB等于1024TB)的内存。

整型数据长度

  • 整型数据长度,比如int类型的长度,虽然在很多系统中通常是32位(4字节),但这并不是一个固定的标准。在某些系统或编译器中,int可能被定义为16位(尤其是一些旧的或嵌入式系统),而在某些环境下,编译器可能允许int为64位。C++标准只规定了int的最小范围,即至少能表示-32767到32767之间的整数,但具体的大小由编译器决定。

比较

因此,不能一概而论地说地址长度和整型数据的长度在所有平台上都是一样的。例如,在64位系统上,指针的大小通常是64位(8字节),而int的大小可能仍然是32位(4字节),尽管有的系统或编译器可能提供了64位的整型(如long long在很多系统上都是64位的)。这种差异对于编程时理解数据类型的内存布局非常重要。

为了编写可移植的代码,C和C++提供了特定的类型(如size_tptrdiff_t),它们的大小总是足够存储内存中任何对象的大小或任两个指针间的差值。此外,C++11及更高版本引入了<cstdint>头文件,提供了具有明确大小的整型,如int32_tint64_t等,这些类型在需要确保整数大小和行为一致性时非常有用。


所以这句话只是个普遍情况。

  1. 如何避免“野指针”
查看解析
  • 指针变量声明时没有被初始化。解决办法:指针声明时初始化,可以是具体的地址值,也可让它指向NULL。
  • 指针p被free或者delete之后,没有置为NULL。解决办法:指针指向的内存空间被释放后指针应该指向NULL。
  • 指针操作超越了变量的作用范围。解决办法:在变量的作用域结束前释放掉变量的地址空间并且让指针指向NULL。
  1. C语言的结构体和C++的类有什么区别
查看解析
  • C语言的结构体是不能有函数成员的,而C++的类可以有。
  • C语言的结构体中数据成员是没有privatepublicprotected访问限定的。而C++的类的成员有这些访问限定。
  • C语言的结构体是没有继承关系的,而C++的类却有丰富的继承关系。

「注意」 :虽然C的结构体和C++的类有很大的相似度,但是类是实现面向对象的基础。而结构体只可以简单地理解为类的前身。

  1. 句柄和指针的区别和联系是什么
查看解析

句柄和指针其实是两个截然不同的概念。 Windows系统用句柄标记系统资源,隐藏系统的信息。你只要知道有这个东西,然后去调用就行了,它是个32bit的unsigned int。指针则标记某个物理内存地址,两者是不同的概念。

  1. 说一说extern “C”
查看解析

extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言(而不是C++)的方式进行编译。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。

这个功能十分有用处,因为在C++出现以前,很多代码都是C语言写的,而且很底层的库也是C语言写的,为了更好
的支持原来的C代码和已经写好的C语言库,需要在C++中尽可能的支持C,而extern "C"就是其中的一个策略。

  • C++代码调用C语言代码
  • 在C++的头文件中使用
  • 在多个人协同开发时,可能有的人比较擅长C语言,而有的人擅长C++,这样的情况下也会有用到

你的解释非常准确!extern "C"是C++中一个重要的关键字,用于解决C++代码与C代码之间的互操作性问题。下面是对extern "C"使用的进一步解释和一些补充信息:

名字修饰(Name Mangling)

C++支持函数重载,意味着可以有多个同名函数,只要它们的参数列表不同。为了区分这些函数,C++编译器在编译过程中会对函数名进行修改,这一过程通常称为“名字修饰”(Name Mangling)或“名字变形”。修饰后的名字不仅包含原始的函数名,还包括了函数的参数类型信息等。这样,即使函数名相同,修饰后的名字也会不同,从而使得函数重载成为可能。

C语言的编译方式

与C++不同,C语言不支持函数重载,因此C编译器不会对函数名进行修饰。函数名在编译后的代码(比如目标文件或库文件)中保持不变。

extern "C"的作用

当C++代码需要调用由C语言编写的函数时,如果直接调用,由于C++编译器会对函数名进行修饰,而C语言编译的库中的函数名是未经修饰的,因此会导致链接错误,无法找到对应的函数实现。

通过在C++代码中使用extern "C"声明C语言函数或包含C语言头文件,可以告诉C++编译器对这部分代码禁用名字修饰,确保函数名在C++和C编译后的代码中是一致的。这样就可以成功地在C++代码中调用C语言编写的函数。

使用方式

  • 单个函数声明:可以直接在C++代码中使用extern "C"声明单个C语言函数。
  • 头文件包含:当有多个C语言函数需要在C++中使用时,可以将这些函数声明放在一个或多个头文件中,然后在C++代码中通过extern "C"块包含这些头文件。
1
2
3
extern "C" {
#include "c_header.h"
}
  • 条件编译:为了使头文件既能在C环境下使用,也能在C++环境下正确工作,可以在头文件中使用条件编译:
1
2
3
4
5
6
7
8
9
#ifdef __cplusplus
extern "C" {
#endif

// C函数声明

#ifdef __cplusplus
}
#endif

这种方式确保了头文件在C++编译器处理时使用extern "C",而在C编译器处理时则不使用,从而保持了代码的兼容性。

总结

extern "C"是C++中为了实现与C代码互操作而设计的一项功能,它通过禁用名字修饰来确保C++代码能够正确地链接并调用C语言编写的函数。这个机制对于使用现有的C语言库,以及在C和C++混合编程环境中协同工作非常重要。

  1. 对c++中的smart pointer四个智能指针:shared_ptr, unique_ptr, weak_ptr, auto_ptr的理解
查看解析

让我们再简要概括一下每种智能指针的特点和用途,以便更清晰地理解它们之间的差异和适用场景。

1、auto_ptr(已废弃)

  • 特点auto_ptr采用所有权模式,一个auto_ptr对象拥有对其指向的对象的独占所有权。当auto_ptr对象被销毁时,它指向的对象也会被自动删除。
  • 问题auto_ptr存在所有权转移的问题,复制操作会导致所有权从一个auto_ptr转移到另一个,使得原来的auto_ptr变为空,容易引起悬挂指针问题。
  • 替代:C++11中已被unique_ptr替代。

2、unique_ptr

  • 特点:实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。相比auto_ptrunique_ptr更安全,不允许通过复制构造器或赋值操作复制unique_ptr,但可以通过std::move转移所有权。
  • 用途:用于需要确保资源只被一个指针拥有的场景,如避免资源泄露。

3、shared_ptr

  • 特点:实现共享式拥有概念,多个shared_ptr可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。使用引用计数机制来跟踪有多少个shared_ptr共享同一个资源。
  • 用途:用于资源可以被多个指针共享的场景,特别适合用于如树、图这样的数据结构中。

4、weak_ptr

  • 特点:不控制对象生命周期的智能指针,指向shared_ptr管理的对象。设计目的是为了解决shared_ptr相互引用时可能产生的循环引用问题。
  • 用途:与shared_ptr配合使用,用于观察shared_ptr指向的资源,而不影响该资源的生命周期。可以通过lock()方法尝试从weak_ptr获得一个可用的shared_ptr

智能指针的引入大大简化了资源管理,特别是内存管理的复杂性,减少了内存泄露和其他资源泄露的风险。它们通过自动化的资源管理,使得C++资源管理更接近于具有垃圾回收机制的语言,同时保持了C++的性能优势和灵活性。使用智能指针需要了解它们的工作原理和适用场景,以免误用导致资源管理上的问题。

  1. C++的顶层const和底层const ?
查看解析

底层const是代表对象本身是一个常量(不可改变);

顶层const是代表指针的值是一个常量, 而指针的值(即对象的地址)的内容可以改变(指向的不可改变);


在C++中,const关键字用于定义常量,即定义后值不可更改的变量。理解顶层const和底层const的概念对于掌握C++的const语义特别重要,它们描述了const修饰符所作用的位置和含义。

顶层const

顶层const表示指针本身是个常量。这种const的应用与指针所指向的对象无关,仅仅表示指针本身的值(即存储的地址)不可变。换句话说,你不能改变指针存储的地址,但可以修改指针指向的值(除非指向的对象也被声明为const)。

例如:

1
2
3
const int *p = &x; // 底层const,指向的int是一个常量
int *const p = &x; // 顶层const,指针p本身是一个常量
const int *const p = &x; // 既是底层const也是顶层const

在这个例子中,第二行中的const修饰符就是顶层const,表示指针p本身是一个常量。

底层const

底层const表示指针指向的对象是个常量,即通过指针不能改变所指对象的值。这种const的应用与指针指向的内容有关。

例如:

1
const int *p = &x;

在这个例子中,const修饰的是int,表示p指向的int值是不可修改的,这就是底层const

应用场景

  • 顶层const的应用:顶层const常用于定义不可变的变量。对于指针来说,它用于声明指针本身的值不可变。
  • 底层const的应用:底层const更多地用于指针和引用,表明指针指向的数据或引用的数据不可被修改。

理解这两种const的区别有助于更准确地使用C++的const修饰符,尤其是在复杂的指针操作和函数参数传递中。顶层const和底层const还涉及到类型兼容性和const正确性的问题,这在设计接口和实现函数时非常重要。





C++面向对象

  1. 面向对象的三大特征是哪些?各自有什么样的特点?
查看解析

封装:将客观事物封装成抽象的类,而类可以把自己的数据和方法暴露给可信的类或者对象,对不可信的类或对象则进行信息隐藏。

继承:可以使用现有类的所有功能,并且无需重新编写原来的类即可对功能进行拓展;

多态:一个类实例的相同方法在不同情形下有不同的表现形式,使不同内部结构的对象可以共享相同的外部接口。

  1. C++中类成员的访问权限
查看解析

C++通过 publicprotectedprivate 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。

在类的内部(定义类的代码内部),无论成员被声明为 publicprotected 还是 private,都是可以互相访问的,没有访问权限的限制。

在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 privateprotected 属性的成员。

  1. 多态的实现有哪几种?
查看解析

多态分为静态多态和动态多态。其中,静态多态是通过重载和模板技术实现的,在编译期间确定;动态多态是通过虚函数和继承关系实现的,执行动态绑定,在运行期间确定。

  1. 动态多态有什么作用?有哪些必要条件?
查看解析

动态多态的作用:

  • 隐藏实现细节,使代码模块化,提高代码的可复用性;
  • 接口重用,使派生类的功能可以被基类的指针引用所调用,即向后兼容,提高代码的可扩充性和可维护性。

动态多态的必要条件:

  • 需要有继承;
  • 需要有虚函数覆盖;
  • 需要有基类指针引用指向子类对象。
  1. 动态绑定是如何实现的?
查看解析

当编译器发现类中有虚函数时,会创建一张虚函数表,把虚函数的函数入口地址放到虚函数表中,并且在对象中增加一个指针 vptr ,用于指向类的虚函数表。当派生类覆盖基类的虚函数时,会将虚函数表中对应的指针进行替换,从而调用派生类中覆盖后的虚函数,从而实现动态绑定。

  1. 纯虚函数有什么作用?如何实现?
查看解析

定义纯虚函数是为了实现一个接口,起到规范的作用,想要继承这个类就必须覆盖该函数。

实现方式是在虚函数声明的结尾加上= 0即可。

  1. 虚函数表是针对类的还是针对象的?同一个类的两个对象的虚函数表是怎么维护的?
查看解析

虚函数表是针对类的,类的所有对象共享这个类的虚函数表,因为每个对象内部都保存一个指向该类虚函数表的指针 vptr ,每个对象的 vptr 的存放地址都不同,但都指向同一虚函数表。

  1. 为什么基类的构造函数不能定义为虚函数?
查看解析

虚函数的调用依赖于虚函数表,而指向虚函数表的指针 vptr 需要在构造函数中进行初始化,所以无法调用定义为虚函数的构造函数。

  1. 为什么基类的析构函数需要定义为虚函数?
查看解析

为了实现动态绑定,基类指针指向派生类对象,如果析构函数不是虚函数,那么在对象销毁时,就会调用基类的析构函数,只能销毁派生类对象中的部分数据,所以必须将析构函数定义为虚函数,从而在对象销毁时,调用派生类的析构函数,从而销毁派生类对象中的所有数据。

  1. 构造函数和析构函数能抛出异常吗?
查看解析

从语法的角度来说,构造函数可以抛出异常,但从逻辑和风险控制的角度来说,尽量不要抛出异常,否则可能导致内存泄漏。

析构函数不可以抛出异常,如果析构函数抛出异常,则异常点之后的程序,比如释放内存等操作,就不会被执行,从而造成内存泄露的问题;而且当异常发生时, C++通常会调用对象的析构函数来释放资源,如果此时析构函数也抛出异常,即前一个异常未处理又出现了新的异常,从而造成程序崩溃的问题。

  1. 如何让一个类不能实例化?
查看解析

将类定义为抽象类(也就是存在纯虚函数)或者将构造函数声明为 private

  1. 多继承存在什么问题?如何消除多继承中的二义性?
查看解析
  1. 增加程序的复杂度,使得程序的编写和维护比较困难,容易出错;

  2. 在继承时,基类之间或基类与派生类之间发生成员同名时,将出现对成员访问的不确定性,即同名二义性;

    消除同名二义性的方法:

    • 利用作用域运算符 :: ,用于限定派生类使用的是哪个基类的成员;
    • 在派生类中定义同名成员,覆盖基类中的相关成员;
  3. 当派生类从多个基类派生,而这些基类又从同一个基类派生,则在访问此共同基类的成员时,将产生另一种不
    确定性,即路径二义性;

    消除路径二义性的方法:

    • 消除同名二义性的两种方法都可以;
    • 使用虚继承,使得不同路径继承来的同名成员在内存中只有一份拷贝。
  1. 如果类 A 是一个空类,那么 sizeof(A) 的值为多少?为什么?
查看解析

sizeof(A) 的值为1,因为编译器需要区分这个空类的不同实例,分配一个字节,可以使这个空类的不同实例拥有独一无二的地址。

  1. 覆盖和重载之间有什么区别?
查看解析
  • 覆盖是指派生类中重新定义的函数,其函数名、参数列表、返回类型与父类完全相同,只是函数体存在区别; 覆盖只发生在类的成员函数中;
  • 重载是指两个函数具有相同的函数名,不同的参数列表,不关心返回值;当调用函数时,根据传递的参数列表来判断调用哪个函数;重载可以是类的成员函数,也可以是普通函数。

在C++中,子类中的函数如果与父类中的函数名相同,确实会隐藏父类中所有的同名函数,这被称为隐藏(Hiding),而不是覆盖(Overriding)。覆盖是多态性的一种表现,而隐藏则是名字解析的结果。让我们来详细解释一下这两个概念:

覆盖(Overriding)

覆盖发生在派生类中重新定义基类中的虚函数的情况。为了发生覆盖,派生类中的函数必须与基类中的虚函数有相同的签名(函数名、参数列表相同,返回类型相同或协变),并且基类中的函数必须被声明为虚函数(virtual)。覆盖实现了多态性,即允许通过基类指针或引用调用派生类中的函数实现。

隐藏(Hiding)

隐藏则是一个更广泛的概念,发生在派生类中声明了一个与基类中同名的函数时,不论其参数列表是否相同。隐藏不仅仅发生在虚函数上,它适用于所有的类成员函数。当派生类中声明了一个同名函数时,基类中所有同名函数的其他重载版本都会被隐藏,不论其参数如何。

为什么会隐藏同名函数

这个行为的原因是C++的名字解析规则。当你在派生类对象上调用一个函数时,编译器首先查找派生类的成员。如果在派生类中找到了同名的成员函数,编译器就停止搜索,即使参数列表不匹配。基类中的同名函数就被隐藏了,除非显式地使用作用域解析操作符(::)来指定。

如何避免隐藏

如果你希望在派生类中使用与基类同名的函数,同时保留对基类同名函数的访问,可以在派生类中使用using声明来引入基类中的函数:

1
2
3
4
5
6
7
8
9
10
class Base {
public:
void func(int) { /* ... */ }
};

class Derived : public Base {
public:
using Base::func; // 引入Base中所有的func函数
void func(double) { /* ... */ } // 不会隐藏Base中的func(int),因为使用了using声明
};

这样,Derived类中就同时有了func(int)func(double)两个版本的函数,前者来自基类,后者是派生类自己定义的。

总的来说,虽然C++中的名字隐藏可能会造成一些混淆,了解和正确使用这一机制可以帮助你更好地控制类继承和多态行为。

  1. 拷贝构造函数和赋值运算符重载之间有什么区别?
查看解析
  1. 拷贝构造函数用于构造新的对象;

    1
    2
    3
    Student s;
    Student s1 = s; // 隐式调用拷⻉贝构造函数
    Student s2(s); // 显式调用拷⻉贝构造函数
  2. 赋值运算符重载用于将源对象的内容拷贝到目标对象中,而且若源对象中包含未释放的内存需要先将其释放;

    1
    2
    3
    Student s;
    Student s1;
    s1 = s; // 使用赋值运算符
  3. 一般情况下,类中包含指针变量时需要重载拷贝构造函数、赋值运算符和析构函数


解释第二点:

赋值运算符重载确实用于控制一个对象如何将另一个对象的内容拷贝到自身。当源对象包含动态分配的内存或其他需要显式释放的资源时,正确重载赋值运算符就变得尤为重要。这里的关键在于确保目标对象能够安全地释放自己当前持有的资源,并正确地接管或复制源对象的资源。这个过程很像是目标对象的一次“重新生命周期”。

让我们具体分析一下赋值运算符的重载过程中发生的事情:

  1. 释放当前资源:如果目标对象之前已经拥有了资源(比如动态分配的内存),那么在拷贝新内容之前,这些资源需要被正确释放。这一步是为了防止内存泄漏,确保任何旧的资源都被适当地管理。

  2. 拷贝/转移资源:接下来,源对象中的资源需要被拷贝或转移到目标对象。这可能涉及到简单的值拷贝,或者,对于指针等复杂资源,可能需要动态分配新的内存来存储拷贝的数据。特别是,如果资源是通过指针管理的,就需要深拷贝,以确保源对象和目标对象拥有各自独立的资源副本。

  3. 返回引用:通常,重载的赋值运算符会返回目标对象的引用。这允许赋值表达式可以被链式使用。

在某种意义上,这确实类似于目标对象经历了一次“生命周期的正常结束”后又开始了一个新的生命周期,因为它首先释放了自己的资源,然后接管了新的资源。

正确实现赋值运算符重载非常关键,特别是在处理资源管理的类中。错误的实现可能导致资源泄露、双重释放等问题。除此之外,还要考虑自赋值的情况,在赋值操作开始之前检查是否是自赋值是一个好习惯,这可以避免潜在的错误和资源浪费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass {
public:
MyClass& operator=(const MyClass& other) {
if (this != &other) { // 防止自赋值
// 释放当前对象的资源
delete resource;
// 拷贝或重新分配资源
resource = new ResourceType(*other.resource);
}
return *this;
}
private:
ResourceType* resource;
};

这个简单的例子展示了如何安全地重载赋值运算符,包括释放当前资源、避免自赋值问题、以及如何拷贝资源。

  1. 对虚函数和多态的理解
查看解析

多态的实现主要分为静态多态和动态多态,静态多态主要是重载,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定。

举个例子:一个父类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。

虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段(.text)中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率。

  1. 请你来说一下C++中struct和class的区别
查看解析
  • 默认继承权限不同, class继承默认是private继承,而struct默认是public继承
  • class还可用于定义模板参数,像typename,但是关键字struct不能同于定义模板参数

C++保留struct关键字,原因:

  • 保证与C语言的向下兼容性, C++必须提供一个struct
  • C++中的struct定义必须百分百地保证与C语言中的struct的向下兼容性,把C++中的最基本的对象单元规定为class而不是struct,就是为了避免各种兼容性要求的限制
  • struct定义的扩展使C语言的代码能够更容易的被移植到C++中
  1. 说说强制类型转换运算符
查看解析

C++提供了四种强制类型转换运算符,以替代传统的C风格类型转换。这些运算符提供了更明确、更可控的类型转换机制:

  1. static_cast

    • 用途:用于非多态类型的转换。可以用来在不同类型之间进行转换,如整型和浮点型之间,或者将void*转换为具体类型的指针等。
    • 示例
      1
      2
      3
      4
      int i = 10;
      float f = static_cast<float>(i); // 将int转换为float
      void* p = &i;
      int* ip = static_cast<int*>(p); // 将void*转换为int*
  2. dynamic_cast

    • 用途:主要用于处理多态类型,用于类层次结构中向下的类型转换(从基类指针/引用转换为派生类指针/引用),并在运行时检查转换的安全性。如果转换失败(例如,因为指向的对象不是目标类型或该类型的派生类型),指针类型的dynamic_cast会返回nullptr,引用类型的dynamic_cast会抛出std::bad_cast异常。
    • 示例
      1
      2
      3
      4
      5
      class Base { virtual void dummy() {} };
      class Derived: public Base { int a; };
      Base* b = new Derived;
      Derived* d = dynamic_cast<Derived*>(b); // 安全转换基类指针为派生类指针
      if (d) { /* 成功转换 */ }
  3. const_cast

    • 用途:用于修改类型的constvolatile属性。通常用于去除const属性。
    • 注意:使用const_cast去除const属性后修改所指向的数据,如果原对象本身是const,则行为未定义。
    • 示例
      1
      2
      3
      const int ci = 10;
      int* modifiable = const_cast<int*>(&ci);
      // *modifiable = 20; // 危险!如果ci是真的const,这是未定义行为
  4. reinterpret_cast

    • 用途:进行低级别的重新解释转换,几乎没有任何类型检查。可以将任何指针转换成任何其他指针类型,也可以将指针转换成足够大的整型,反之亦然。
    • 注意reinterpret_cast是非常危险的,应该尽可能避免使用,除非你确切知道自己在做什么,并且了解与之相关的风险。
    • 示例
      1
      2
      int* p = new int(10);
      char* ch = reinterpret_cast<char*>(p); // 将int*转换为char*

这些强制类型转换运算符提供了更精细、更安全的转换控制,使代码意图更加明确,同时帮助避免不安全的转换。


为什么不使用C的强制转换?

C的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。

  1. 简述类成员函数的重写、重载和隐藏的区别
查看解析

tips:重写 就是 覆盖

(1)重写和重载主要有以下几点不同。

  • 范围的区别:被重写的和重写的函数在两个类中,而重载和被重载的函数在同一个类中。
  • 参数的区别:被重写函数和重写函数的参数列表一定相同,而被重载函数和重载函数的参数列表一定不同。
  • virtual的区别:重写的基类中被重写的函数必须要有virtual修饰,而重载函数和被重载函数可以被 virtual修饰,也可以没有。

(2)隐藏和重写、重载有以下几点不同。

  • 与重载的范围不同:和重写一样,隐藏函数和被隐藏函数不在同一个类中。
  • 参数的区别:隐藏函数和被隐藏的函数的参数列表可以相同,也可不同,但是函数名肯定要相同。当参数不相同时,无论基类中的参数是否被 virtual 修饰,基类的函数都是被隐藏,而不是被重写。

「注意」 :虽然重载和覆盖都是实现多态的基础,但是两者实现的技术完全不相同,达到的目的也是完全不同的,覆盖是动态绑定的多态,而重载是静态绑定的多态。

  1. RTTI是什么?其原理是什么?
查看解析

RTTI即运行时类型识别,其功能由两个运算符实现:

  • typeid 运算符,用于返回表达式的类型,可以通过基类的指针获取派生类的数据类型;
  • dynamic_cast 运算符,具有类型检查的功能,用于将基类的指针或引用安全地转换成派生类的指针或引用。
  1. C++的空类有哪些成员函数
查看解析
  • 缺省构造函数。
  • 缺省拷贝构造函数。
  • 缺省析构函数。
  • 赋值运算符。
  • 取址运算符。
  • 取址运算符 const 。

「注意」 :有些书上只是简单的介绍了前四个函数。没有提及后面这两个函数。但后面这两个函数也是空类的默认函数。另外需要注意的是,只有当实际使用这些函数的时候,编译器才会去定义它们。


有点争议啊:

  • 取址运算符(Address-of Operator):这通常指的是通过&获取对象的地址。实际上,这并不是类特有的成员函数,而是C++内置的操作。对于类对象,可以直接使用&操作符获取对象的地址,不需要类中定义特殊的成员函数。

  • 取址运算符 const(Address-of Operator for const objects):这是对const对象使用取址运算符的情形,同样,并不需要类中定义特殊的成员函数来支持。

对于第5点和第6点,实际上在标准C++中,并没有特指“取址运算符”作为类的默认成员函数。取址操作是通过C++语言内置的&运算符完成的,适用于所有类型的对象,包括类的实例,而不需要由编译器为类特别生成。这可能是你所提到的书籍与标准C++文档描述不同的地方。

  1. C++中空结构体和空对象的大小
查看解析

1个字节

空对象占用内存空间为:1
C++编译器会给每个空对象也分配一个字节空间,是为了区分空对象占内存的位置
每个空对象也应该有一个独一无二的内存地址

C语言中的空结构体和C++中的空结构体在所占用的内存大小方面有所不同。在C语言中,空结构体的大小未明确规定,不同的编译器可能会有不同的处理方式;而在C++中,空结构体的大小为1字节。这是因为C++标准规定了每个对象必须有一个独一无二的地址,即使是空对象也不例外,因此空结构体至少占用1字节的空间,以确保它可以被唯一地标识。这个规定有助于C++支持面向对象的特性,如多态和继承。

  1. 为什么析构函数一般写成虚函数
查看解析

由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。

  1. 拷贝初始化和直接初始化,初始化和赋值的区别?
查看解析
  • ClassTest ct1("ab"); 这条语句属于直接初始化,它不需要调用复制构造函数,直接调用构造函数ClassTest(constchar *pc);,所以当复制构造函数变为私有时,它还是能直接执行的。
  • ClassTest ct2 = "ab"; 这条语句为复制初始化,它首先调用构造函数 ClassTest(const char* pc) 函数创建一个临时对象,然后调用复制构造函数,把这个临时对象作为参数,构造对象ct2;所以当复制构造函数变为私有时,该语句不能编译通过。
  • ClassTest ct3 = ct1;这条语句为复制初始化,因为 ct1 本来已经存在,所以不需要调用相关的构造函数,而直接调用复制构造函数,把它值复制给对象 ct3;所以当复制构造函数变为私有时,该语句不能编译通过。
  • ClassTest ct4(ct1) ;这条语句为直接初始化,因为 ct1 本来已经存在,直接调用复制构造函数,生成对象 ct3 的副本对象 ct4。所以当复制构造函数变为私有时,该语句不能编译通过。

要点就是拷贝初始化和直接初始化调用的构造函数是不一样的,但是当类进行复制时,类会自动生成一个临时的对象,然后再进行拷贝初始化。

  1. 虚函数和虚函数表
查看解析

虚函数和虚函数表是C++实现多态性的核心机制。让我们深入了解这两个概念。

虚函数(Virtual Function)

虚函数是在基类中使用virtual关键字声明的成员函数,它可以在派生类中被重写(Override)。虚函数允许派生类根据对象的实际类型来调用相应的函数,实现运行时多态。这意味着,如果有一个基类指针或引用指向一个派生类对象,那么通过这个指针或引用调用虚函数时,调用的将是派生类中的版本(如果派生类中有重写的话)。

虚函数表(Virtual Table,简称vtable)

虚函数表是一个实现细节,用于支持运行时的多态性。每个使用虚函数的类都有一个虚函数表。这个表是一个编译器在编译时期生成的静态数组,用于存储指向类的虚函数的指针。每个对象都包含一个指向其类的虚函数表的指针(称为vptr),通过这个指针可以找到对应的虚函数实现。

工作原理

  1. 虚函数表的创建:编译器为每一个包含虚函数的类生成一个虚函数表。这个表包含了指向类中每个虚函数实现的指针。如果派生类重写了基类中的虚函数,则派生类的虚函数表中会存储指向这些新实现的指针。
  2. 对象的vptr:每个对象实例都会包含一个指针(vptr),指向其类的虚函数表。这个指针在对象被创建时自动设置。
  3. 虚函数的调用:当调用一个对象的虚函数时,实际上是通过对象的vptr来访问虚函数表,然后通过虚函数表找到相应函数的地址,最后调用该函数。

示例

考虑以下的类定义和函数调用:

1
2
3
4
5
6
7
8
9
10
11
12
class Base {
public:
virtual void func() { cout << "Base::func() called" << endl; }
};

class Derived : public Base {
public:
void func() override { cout << "Derived::func() called" << endl; }
};

Base* obj = new Derived();
obj->func(); // 输出 "Derived::func() called"

在这个例子中,尽管obj的静态类型是Base*,但由于func是虚函数,并且obj实际指向Derived对象,所以调用的是Derived中的func实现。

优点与局限

  • 优点:虚函数和虚函数表提供了一种强大的机制,允许C++程序在运行时进行函数调用的决策,从而实现多态性和动态绑定。
  • 局限:虚函数的使用增加了一定的运行时开销,因为每次虚函数调用都需要通过虚函数表来解析函数地址。此外,每个对象因为包含vptr而稍微增加了内存占用。

虚函数和虚函数表是C++中实现面向对象编程核心特性之一,理解它们的工作原理对于深入学习C++非常重要。





内存

  1. new / delete 和 malloc / free 之间有什么关系?
查看解析
1
2
int *p = new int[2];
int *q = (int *)malloc(2*sizeof(int));
  • newdelete直接带具体类型的指针, mallocfree返回void类型的指针。
  • new类型是安全的,而malloc不是。例如int *p = new float[2];就会报错;而int p = malloc(2sizeof(int))编译时编译器就无法指出错误来。
  • new一般分为两步:new操作和构造。 new操作对应与malloc,但new操作可以重载,可以自定义内存分配策略,不做内存分配,甚至分配到非内存设备上,而malloc不行。
  • new调用构造函数, malloc不能; delete调用析构函数,而free不能。
  • malloc/ free需要库文件stdlib.h的支持, new/delete则不需要!

「注意」 : deletefree被调用后,内存不会立即回收,指针也不会指向空, deletefree仅是告诉操作系统,这一块内存被释放了,可以用作其他用途。但是由于没有重新对这块内存进行写操作,所以内存中的变量数值并没有发生变化,出现野指针的情况。因此,释放完内存后,应该将该指针指向NULL

  1. delete与delete []有什么区别?
查看解析
  • 对于简单类型来说,使用 new 分配后,不管是数组还是非数组形式,两种方式都可以释放内存:

    1
    2
    3
    4
    5
    6
    7
    8
    int *a = new int(1);
    delete a;
    int *b = new int(2);
    delete [] b;
    int *c = new int[11];
    delete c;
    int *d = new int[12];
    delete [] d;
  • 对于自定义类型来说,就需要对于单个对象使用 delete ,对于对象数组使用 delete [] ,逐个调用数组中对象的析构函数,从而释放所有内存;

  • 如果反过来使用,即对于单个对象使用 delete [] ,对于对象数组使用 delete ,其行为是未定义的;

  • 所以,最恰当的方式就是如果用了 new ,就用 delete ;如果用了 new [] ,就用 delete []

  1. 内存块太小导致 malloc 和 new 返回空指针,该怎么处理?
查看解析
  • 对于 malloc 来说,需要判断其是否返回空指针,如果是则马上用 return 语句终止该函数或者 exit 终止该程序;

  • 对于 new 来说,默认抛出异常,所以可以使用 try...catch... 代码块的方式:

    1
    2
    3
    4
    5
    try {
    int *ptr = new int[10000000];
    } catch(bad_alloc &memExp) {
    cerr << memExp.what() << endl;
    }

    还可以使用 set_new_handler 函数的方式:

    1
    2
    3
    4
    5
    6
    7
    8
    void no_more_memory() {
    cerr << "Unable to satisfy request for memory" << endl;
    abort();
    }
    int main() {
    set_new_handler(no_more_memory);
    int *ptr = new int[10000000];
    }

    在这种方式里,如果 new 不能满足内存分配请求, no_more_memory 会被反复调用,所以 new_handler 函数必须完成以下事情:

    • 让更多内存可被使用:可以在程序一开始执行就分配一大块内存,之后当 new_handler 第一次被调用,就将这些内存释放还给程序使用;
    • 使用另一个 new_handler
    • 卸除 new_handler :返回空指针,这样 new 就会抛出异常;
    • 直接抛出 bad_alloc 异常;
    • 调用 abortexit
  1. 内存泄漏的场景有哪些?如何判断内存泄漏?如何定位内存泄漏?
查看解析

内存泄漏漏的场景:

  • mallocfree 未成对出现; new/new []delete/delete [] 未成对出现;

  • 在堆中创建对象分配内存,但未显式释放内存;比如,通过局部分配的内存,未在调用者函数体内释放:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void fun() {
    char *p = (char *)malloc(30);
    /*
    ……
    */
    }
    int main() {
    fun();
    return 0;
    }
  • 在构造函数中动态分配内存,但未在析构函数中正确释放内存;

  • 未定义拷贝构造函数或未重载赋值运算符,从而造成两次释放相同内存的做法;比如,类中包含指针成员变量,在未定义拷贝构造函数或未重载赋值运算符的情况下,编译器会调用默认的拷贝构造函数或赋值运算符,以逐个成员拷贝的方式来复制指针成员变量,使得两个对象包含指向同一内存空间的指针,那么在释放第一个对象时,析构函数释放该指针指向的内存空间,在释放第二个对象时,析构函数就会释放同一内存空间,这样的行为是错误的;

  • 没有将基类的析构函数定义为虚函数。

判断和定位内存泄漏的方法:在Linux系统下,可以使用valgrindmtrace等内存泄漏检测工具。

  1. 内存的分配方式有几种?
查看解析
  • 在栈上分配:在执行函数时,局部变量的内存都可以在栈上分配,函数结束时会自动释放;栈内存的分配运算内置于处理器的指令集中,效率很高,但分配的内存容量有限;
  • 从堆上分配:由 new 分配 delete 释放的内存块,也称为动态内存分配,程序员自行申请和释放内存,使用灵活;
  • 从自由存储区分配:由 malloc 分配 free 释放的内存块,与堆类似;
  • 从常量存储区分配:特殊的存储区,存放的是常量,不可修改;
  • 从全局/静态存储区分配:编译期间分配内存,整个程序运行期间都存在,如全局变量、静态变量等。
  1. 堆和栈有什么区别?
查看解析

分配和管理方式不同

  • 堆是动态分配的,其空间的分配和释放都由程序员控制;
  • 栈是由编译器自动管理的,其分配方式有两种:静态分配由编译器完成,比如局部变量的分配;动态分配由 alloca() 函数进行分配,但是会由编译器释放;

产生碎片不同

  • 对堆来说,频繁使用 new/delete 或者 malloc/free 会造成内存空间的不连续,产生大量碎片,使程序效率降低;
  • 对栈来说,不存在碎片问题,因为栈具有先进后出的特性;

生长方向不同

  • 堆是向着内存地址增加的方向增长的,从内存的低地址向高地址方向增长;
  • 栈是向着内存地址减小的方向增长的,从内存的高地址向低地址方向增长;

申请大小限制不同

  • 栈顶和栈底是预设好的,大小固定;
  • 堆是不连续的内存区域,其大小可以灵活调整
  1. 静态内存分配和动态内存分配有什么区别?
查看解析
  • 静态内存分配是在编译时期完成的,不占用CPU资源;动态内存分配是在运行时期完成的,分配和释放需要占用CPU资源;
  • 静态内存分配是在栈上分配的;动态内存分配是在堆上分配的;
  • 静态内存分配不需要指针或引用类型的支持;动态内存分配需要;
  • 静态内存分配是按计划分配的,在编译前确定内存块的大小;动态内存分配是按需要分配的;
  • 静态内存分配是把内存的控制权交给了编译器;动态内存分配是把内存的控制权给了程序员;
  • 静态内存分配的运行效率比动态内存分配高,动态内存分配不当可能造成内存泄漏。
  1. 如何构造一个类,使得只能在堆上或只能在栈上分配内存?
查看解析

只能在堆上分配内存:将析构函数声明为 private

只能在栈上生成对象:将 newdelete 重载为 private


你的概念基本正确,但有一些需要微调和补充的地方。下面是如何实现这两种限制的详细说明:

  1. 限制对象只能在堆上创建

要创建一个只能在堆上分配的类对象,可以将析构函数设为private。这样,用户就不能在栈上创建对象,因为栈上的对象在作用域结束时会自动调用析构函数,而私有析构函数在类外部是不可访问的。同时,你需要提供一个公有的静态成员函数来删除对象,因为用户无法直接调用delete

1
2
3
4
5
6
7
8
9
10
11
class HeapOnly {
public:
static HeapOnly* create() {
return new HeapOnly();
}
void destroy() {
delete this;
}
private:
~HeapOnly() {} // 私有析构函数
};

在这个例子中,用户可以通过调用HeapOnly::create()在堆上创建对象,并通过对象的destroy()方法来删除对象。

  1. 限制对象只能在栈上创建

限制对象只能在栈上创建的关键是防止通过new操作符在堆上分配对象。为此,可以将new操作符重载为private。这样,用户就无法在类外使用new来创建对象实例。由于栈上的对象不需要显式地调用delete,因此没有必要将delete操作符设为private

1
2
3
4
5
6
7
8
class StackOnly {
public:
StackOnly() {} // 公有构造函数
void someMethod() {} // 示例成员函数
private:
void* operator new(size_t) = delete; // 禁用new操作
void operator delete(void*) = delete; // 为了完整性,也可以禁用delete操作
};

在这个例子中,用户可以在栈上创建StackOnly类型的对象,但不能在堆上创建。

注意

  • 限制只在堆上创建对象时,需要额外注意析构函数的调用权。因为析构函数是private的,你需要确保有适当的方法来释放对象。
  • 限制只在栈上创建对象可能会阻止某些高级用法,例如通过智能指针管理对象的生命周期。在实际应用中,需要根据具体需求仔细考虑这些限制的利弊。

这两种方法各有其用途,可以根据特定场景选择适合的实现方式。


为什么析构函数私有化就不能在栈区申请

将析构函数设为private限制了在栈上创建对象的能力,原因在于对象的生命周期管理。在C++中,当一个对象在栈上创建时,它的生命周期由程序的作用域决定。一旦对象所在的作用域结束,编译器会自动插入对该对象析构函数的调用,以确保正确地清理资源。

如果析构函数被声明为private,那么在类的外部就无法访问它。这意味着在类外部的代码(包括由编译器自动生成的代码)不能调用这个析构函数。因此,如果试图在栈上创建这种类的对象,编译器将在编译时报错,因为它不能生成在作用域结束时调用私有析构函数的代码。

这是一种设计选择,用于确保对象的生命周期仅能通过类提供的公有接口来管理。在只能在堆上创建对象的设计中,通常会提供一个公有的静态成员函数来创建对象,并提供一个公有的成员函数来销毁对象,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
class HeapOnly {
public:
static HeapOnly* create() { // 允许在堆上创建对象
return new HeapOnly();
}
void destroy() { // 允许通过对象的公有方法销毁对象
delete this;
}

private:
~HeapOnly() {} // 析构函数私有化
// 类的其他成员
};

通过这种方式,可以控制对象的创建和销毁,确保对象只能通过new在堆上创建,并通过特定方法销毁,同时避免了在栈上创建对象的可能性。

总的来说,将析构函数设为private是一种强制对象只能在堆上分配的技术,它利用了C++对于作用域结束时自动调用析构函数的规则,通过限制析构函数的访问来阻止栈上对象的创建。这样做的目的通常是为了更精细地控制对象的生命周期,尤其是在涉及复杂资源管理的情况下。

  1. 浅拷贝与深拷贝
查看解析

如果你没有为你的类显式定义一个拷贝构造函数,C++编译器会为你自动生成一个默认的拷贝构造函数。这个自动生成的拷贝构造函数执行的是浅拷贝(shallow copy),意味着它会逐个复制对象的所有成员变量到新对象中,这个复制过程是按成员的位复制(bitwise copy)进行的。

浅拷贝的含义和影响:

  • 对于非指针类型的成员,浅拷贝简单地复制其值。这对于内置数据类型(如intfloat等)和包含这些类型的复合数据类型通常是足够的。
  • 对于指针类型的成员,浅拷贝只复制指针的值,而不复制指针所指向的数据。因此,原始对象和拷贝对象的指针成员将指向同一个内存地址。这可能会导致问题,如:
    • 当原始对象或拷贝对象被销毁时,它们的析构函数可能尝试释放同一个内存地址,造成双重释放问题(double-free)。
    • 修改一个对象中的动态分配数据也会影响另一个对象,因为它们共享同一个数据。

深拷贝:

为了解决浅拷贝可能导致的问题,特别是在类成员包含指向动态分配内存的指针时,可以显式定义自己的拷贝构造函数来实现深拷贝(deep copy)。深拷贝不仅复制指针的值,还复制指针所指向的数据到新的内存地址,这样原始对象和拷贝对象就有了各自独立的数据副本,互不干扰。

示例:

下面是一个演示默认拷贝构造函数只执行浅拷贝的例子:

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass {
public:
int* data;
MyClass(int value) { data = new int(value); }
~MyClass() { delete data; }
};

int main() {
MyClass obj1(10);
MyClass obj2 = obj1; // 使用默认拷贝构造函数,浅拷贝
// 此时obj1.data和obj2.data指向同一块内存
}

如果MyClass需要深拷贝行为,你应该显式定义一个拷贝构造函数来正确复制data成员指向的内存。

总结:

当类中没有显式定义拷贝构造函数时,C++编译器生成的默认拷贝构造函数执行浅拷贝。这在许多情况下是合理的,但如果类成员包含指针指向动态分配的内存,就需要考虑实现深拷贝以避免潜在的内存管理问题。

  1. 字节对齐的原则是什么?
查看解析
  • 从偏移为0的位置开始存储;
  • 如果没有定义 #pragma pack(n)
    • sizeof 的最终结果必然是结构内部最大成员的整数倍,不够补齐;
    • 结构内部各个成员的首地址必然是自身大小的整数倍;
  • 如果定义了 #pragma pack(n)
    • sizeof 的最终结果必然是 min[n,结构内部最大成员] 的整数倍,不够补齐;
    • 结构内部各个成员的首地址必然是 min[n,自身大小] 的整数倍。




计算机网络

  1. 说一说三次握手
查看解析

当面试官问你为什么需要有三次握手、三次握手的作用、讲讲三次握手的时候,我想很多人会这样回答:

首先很多人会先讲下握手的过程:
1、第一次握手:客户端给服务器发送一个 SYN 报文。
2、第二次握手:服务器收到 SYN 报文之后,会应答一个 SYN+ACK 报文。
3、第三次握手:客户端收到 SYN+ACK 报文之后,会回应一个 ACK 报文。
4、服务器收到 ACK 报文之后,三次握手建立完成。
作用是为了确认双方的接收与发送能力是否正常。


这里我顺便解释一下为啥只有三次握手才能确认双方的接受与发送能力是否正常,而两次却不可以:
第一次握手:客户端发送网络包,服务端收到了。这样服务端就能得出结论:户端的送能力、务端的收能力是正常的。

第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:务端的收、送能力,户端的收、送能力是正常的。不过此时服务器并不能确认客户端的接收能力是否正常。

第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:户端的收、送能力正常,务器自己的送、收能力也正常

因此,需要三次握手才能确认双方的接收与发送能力是否正常。


这样回答其实也是可以的,但我觉得,这个过程的我们应该要描述的更详细一点,因为三次握手的过程中,双方是由很多状态的改变的,而这些状态,也是面试官可能会问的点。所以我觉得在回答三次握手的时候,我们应该要描述的详细一点,而且描述的详细一点意味着可以扯久一点。加分的描述我觉得应该是这样:

刚开始客户端处于 closed 的状态,服务端处于 listen 状态。然后
1、第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号 ISN©。此时客户端处于SYN_SEND 状态。

2、第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且也是指定了自己的初
始化序列号 ISN(s),同时会把客户端的 ISN + 1 作为 ACK 的值,表示自己已经收到了客户端的 SYN,此时服务器处于 SYN_REVD 的状态。

3、第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 ISN + 1 作为 ACK的值,表示已经收到了服务端的 SYN 报文,此时客户端处于 establised 状态。

4、服务器收到 ACK 报文之后,也处于 establised 状态,此时,双方以建立起了链接

img


三次握手的作用
三次握手的作用也是有好多的,多记住几个,保证不亏。例如:
1、确认双方的接受能力、发送能力是否正常。
2、指定自己的初始化序列号,为后面的可靠传送做准备。


1、(ISN)是固定的吗

三次握手的一个重要功能是客户端和服务端交换ISN(Initial Sequence Number), 以便让对方知道接下来接收数据的时候如何按序列号组装数据。

如果ISN是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。


2、什么是半连接队列
服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把
此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列。当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。

这里在补充一点关于SYN-ACK 重传次数的问题:服务器发送完SYN-ACK包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传,如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。注意,每次重传等待的时间不一定相同,一般会是指数增长,例例如间隔时间为 1s, 2s, 4s, 8s,


3、三次握手过程中可以携带数据吗
很多人可能会认为三次握手都不能携带数据,其实第三次握手的时候,是可以携带数据的。也就是说,第一次、第二次握手不可以携带数据,而第三次握手是可以携带数据的。

为什么这样呢?大家可以想一个问题,假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据,因为攻击者根本就不理服务器的接收、发送能力是否正常,然后疯狂着重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。也就是说,第一次握手可以放数据的话,其中一个简单的原因就是会让服务器更加容易受到攻击了。而对于第三次的话,此时客户端已经处于 established 状态,也就是说,对于客户端来说,他已经建立起连接了,
并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据页没啥毛病。


TCP状态

  1. CLOSED
    • 这是初始状态和最终状态,表示没有任何活动连接,也没有挂起的连接请求。
  2. LISTEN
    • 服务器处于此状态,等待来自客户端的连接请求。在此状态下,服务器准备接受传入的 SYN 请求。
  3. SYN_SENT
    • 客户端发送完 SYN 报文后进入此状态,等待服务器确认。这是第一次握手,客户端已经开始尝试建立连接。
  4. SYN_RECEIVED
    • 服务器收到客户端的 SYN 报文,并且发送自己的 SYN+ACK 报文后,进入此状态。这表示服务器已响应客户端的连接请求,等待客户端的最终确认。
  5. ESTABLISHED
    • 连接已成功建立。在此状态下,客户端和服务器已经完成三次握手,并且可以开始数据传输。客户端和服务器都可以处于此状态。
  6. FIN_WAIT_1
    • 当连接处于建立状态并且有一方(通常是客户端)决定关闭连接时,它发送 FIN 包并进入此状态,等待对方的 ACK。
  7. FIN_WAIT_2
    • 当一方接收到对方的 ACK 后,进入此状态,等待对方的 FIN 包。
  8. TIME_WAIT
    • 当一方接收到对方的 FIN 包并发送 ACK 后,它会进入此状态。这个状态通常持续一段时间(2MSL,即两倍的最大段生命周期),以确保对方收到 ACK。
  9. CLOSE_WAIT
    • 当服务器端接收到客户端的 FIN 包时,它进入此状态。服务器需要等待应用程序关闭连接。
  10. LAST_ACK
    • 当应用程序关闭连接并发送 FIN 包时,服务器进入此状态,等待客户端的最后一个 ACK。
  11. CLOSING
    • 在同时关闭的情况下使用,即当两边几乎同时发送 FIN 包时,双方都会等待对方的 ACK。

TCP 报文的控制位

  • 确认位ACK:ACK=1时确认号有效,在连接建立后所有传送的报文段都必须把ACK置为1。
  • 同步位SYN:SYN=1时,表明是一个连接请求/连接接受报文。
  • 终止位FIN:FIN=1时,表明此报文段发送方数据已发完,要求释放连接。
  1. 说一说四次挥手
查看解析

四次挥手也一样,千万不要
对方一个 FIN 报文,我方一个 ACK 报文,再我方一个 FIN 报文,我方一个 ACK 报文。
然后结束,最好是说的详细一点,例如想下面这样:


刚开始双方都处于 establised 状态,假如是客户端先发起关闭请求,则:

  1. 第一次挥手:FIN from Client
    • 客户端发送一个 FIN 报文到服务端,请求关闭连接。这时客户端进入 FIN_WAIT_1 状态。
  2. 第二次挥手:ACK from Server
    • 服务端接收到这个 FIN 报文后,发送一个 ACK 报文作为回应,确认序号为收到的 FIN 序号加一。服务端此时进入 CLOSE_WAIT 状态,客户端收到 ACK 后进入 FIN_WAIT_2 状态。
  3. 第三次挥手:FIN from Server
    • 服务端准备好关闭连接时,会发送一个 FIN 报文到客户端。此时服务端进入 LAST_ACK 状态。
  4. 第四次挥手:ACK from Client
    • 客户端接收到服务端的 FIN 报文后,发送一个 ACK 报文作为回应,确认序号为收到的 FIN 序号加一。客户端此时进入 TIME_WAIT 状态。在这个状态下,客户端会等待足够的时间以确保服务端收到其 ACK 报文。
  5. 关闭连接
    • 服务端在收到客户端的 ACK 报文后,进入 CLOSED 状态,完成连接的关闭。
    • 客户端在 TIME_WAIT 状态经过一定时间(通常是最大报文段生命周期的两倍,即 2MSL)后,也转入 CLOSED 状态。
  • CLOSE_WAIT 是服务端接收到 FIN 后等待关闭本地连接的状态。
  • LAST_ACK 是服务端发送完 FIN 并等待最终 ACK 的状态。
  • TIME_WAIT 是客户端收到服务端的 FIN 并发送 ACK 后,确保服务端接收到 ACK 的状态,防止最后一个 ACK 丢失导致服务端无法正常关闭。
  • 其他状态写在上一个问题中 —— 说一说三次握手

img


这里特别需要注意的就是TIME_WAIT这个状态了,这个是面试的高频考点,就是要理解,为什么客户端发送 ACK
之后不直接关闭,而是要等一阵子才关闭。

这其中的原因就是,要确保服务器是否已经收到了我们的 ACK 报文,
如果没有收到的话,服务器会重新发 FIN 报文给客户端,客户端再次收到 FIN 报文之后,就知道之前的 ACK 报文丢失了,然后再次发送 ACK 报文。
至于 TIME_WAIT 持续的时间至少是一个报文的来回时间。一般会设置一个计时,如果过了这个计时没有再次收到
FIN 报文,则代表对方成功就是 ACK 报文,此时处于 CLOSED 状态

  1. 说一说POST与GET有哪些区别
查看解析

在HTTP协议中,POSTGET是两种最常用的方法(或称为“动作”),用于在客户端和服务器之间发送请求。这两种方法虽然在许多方面相似,但在使用方式和目的上有明显的差异。以下是POSTGET的一些主要区别:

1. 数据提交方式

  • GET:通过URL传递数据。数据附加在URL之后,并以查询字符串的形式出现,如?key1=value1&key2=value2。这意味着GET请求的数据可以在浏览器的URL栏中看到。
  • POST:将数据作为请求的主体发送,不在URL中显示。这使得POST比GET更安全,因为提交的数据不会保存在浏览器历史或服务器日志中。

2. 数据大小限制

  • GET:由于数据在URL中传递,因此受URL长度限制(由浏览器和服务器定义),通常为2048字符。
  • POST:理论上没有大小限制,因此可以传输大量数据。这使得POST适合于文件上传等操作。

3. 安全性

  • GET:由于数据在URL中可见,因此不适合传输敏感数据,如密码或个人信息。
  • POST:数据在请求体内,相对更加隐蔽,适合发送敏感或大量数据。

安全的 HTTP 方法不会改变服务器状态,也就是说它只是可读的。

GET 方法是安全的,而 POST 却不是,因为 POST 的目的是传送实体主体内容,这个内容可能是用户上传的表单数据,上传成功之后,服务器可能把这个数据存储到数据库中,因此状态也就发生了改变。

安全的方法除了 GET 之外还有: HEADOPTIONS
不安全的方法除了 POST 之外还有 PUTDELETE

4. 缓存和历史

  • GET:可以被缓存,可以被保存在浏览器历史中,可以被书签保存。
  • POST:通常不被缓存,不会保存在浏览器历史中,不能被书签。

5. 幂等性

  • GET:是幂等的,意味着多次执行相同的GET请求,资源的状态应该是相同的,不会因请求次数增加而改变。
  • POST:不是幂等的,每次请求都可能改变服务器上的状态,如每次提交都可能在数据库中添加新的记录。

6. 用途

  • GET:主要用于获取/查询资源信息,而不应当引起服务器上资源的状态变化。
  • POST:用于向服务器提交数据,常用于表单提交、文件上传等,其操作可能会在服务器上创建新资源或更新现有资源。

7. 服务器端影响

  • GET:请求应该只用于获取数据,不应改变服务器状态。
  • POST:可以创建或更改服务器的状态,因此对安全性有更高的要求。

总结

选择GET还是POST,主要取决于操作的性质:如果是获取数据,应使用GET;如果是修改服务器上的数据或提交敏感信息,应使用POST。正确的使用GET和POST可以帮助遵循HTTP协议的设计原则,提高应用程序的可维护性和效率。





幂等性是一个在计算中常用的概念,特别是在网络协议和数据库操作中。在HTTP协议中,如果一个方法是幂等的,那么无论这个方法执行多少次,产生的效果和执行一次是相同的,至少在外部可观察的结果上是这样。

HTTP方法的幂等性

在HTTP中,以下方法被定义为幂等的:

  • GET:用于请求资源,不应该产生副作用,即不应该改变资源状态。无论你请求多少次,资源的展示应该是相同的。
  • HEAD:与GET相似,但服务器在响应中只返回头部信息,不返回实体部分。它也不应改变任何状态,因此是幂等的。
  • DELETE:用于删除资源。理论上,第一次调用删除资源后,资源不再存在,后续的DELETE请求将持续报告该资源已被删除(例如,通过404状态码),因此外部状态不改变。
  • PUT:用于上传资源或在指定位置更新资源。多次上传相同的数据到同一位置的效果应该与一次上传相同,因此被视为幂等。

相反,POST方法通常用于创建资源或触发服务器上的操作,它不是幂等的。例如,如果POST请求用于在数据库中创建一个新条目,那么每次执行相同的POST请求都会创建一个新的条目,这会改变服务器的状态。

举例说明

  1. GET 示例

    • 假设有一个URL,http://example.com/info,它返回一个用户信息的JSON对象。不管你访问这个URL多少次,只要信息没有被改变,返回的结果应该总是相同的。
  2. POST 示例

    • 假设有一个论坛,用户可以通过发送POST请求到 http://example.com/new-post 来发布新帖子。这个请求的体可能包含帖子的标题和内容。如果用户连续发送了两次完全相同的POST请求,系统通常会创建两个内容相同的独立帖子。
  3. PUT 示例

    • 假设有一个URL http://example.com/update-profile,用户可以通过PUT请求更新他们的个人信息。如果用户发送了多个包含相同信息(如姓名、地址等)的PUT请求,不论这个操作执行多少次,用户的最终状态都应该是一样的。
  4. DELETE 示例

    • 假设 http://example.com/delete-item/123 是一个用于删除ID为123的项的URL。第一次DELETE请求执行后,项123被删除。后续的DELETE请求可能会返回404错误,表明资源不存在,但不会改变服务器的状态。

通过这些例子,我们可以看到,幂等方法确保了无论操作执行多少次,资源的状态都保持一致,而非幂等方法则可能每次都改变服务器的状态。这种特性在设计API时非常重要,尤其是在分布式系统和可能面临网络问题的环境中,因为它们帮助开发者理解和预测请求可能带来的改变。

  1. 说一说TCP与UDP的区别
查看解析

TCP协议的主要特点
(1) TCP是面向连接的传输层协议;所谓面向连接就是双方传输数据之前,必须先建立一条通道,例如三次握手
就是建立通道的一个过程,而四次挥手则是结束销毁通道的一个其中过程。
(2)每一条TCP连接只能有两个端点(即两个套接字),只能是点对点的;
(3) TCP提供可靠的传输服务。传送的数据无差错、不丢失、不重复、按序到达;
(4) TCP提供全双工通信。允许通信双方的应用进程在任何时候都可以发送数据,因为两端都设有发送缓存和接
受缓存;
(5)面向字节流。虽然应用程序与TCP交互是一次一个大小不等的数据块,但TCP把这些数据看成一连串无结构的字节流,它不保证接收方收到的数据块和发送方发送的数据块具有对应大小关系,例如,发送方应用程序交给发送方的TCP10个数据块,但就受访的TCP可能只用了4个数据块久保收到的字节流交付给上层的应用程序,但字节流完全一样。

TCP的可靠性原理
可靠传输有如下两个特点:
a.传输信道无差错,保证传输数据正确;
b.不管发送方以多快的速度发送数据,接收方总是来得及处理收到的数据;
(1)首先,采用三次握手来建立TCP连接,四次握手来释放TCP连接,从而保证建立的传输信道是可靠的。
(2)其次, TCP采用了连续ARQ协议(回退N, Go-back-N;超时自动重传)来保证数据传输的正确性,使用滑动窗口协议来保证接方能够及时处理所接收到的数据,进行流量控制。
(3)最后, TCP使用慢开始、拥塞避免、快重传和快恢复来进行拥塞控制,避免网络拥塞。


UDP协议特点
(1) UDP是无连接的传输层协议;
(2) UDP使用尽最大努力交付,不保证可靠交付;
(3) UDP是面向报文的,对应用层交下来的报文,不合并,不拆分,保留原报文的边界;
(4) UDP没有拥塞控制,因此即使网络出现拥塞也不会降低发送速率;
(5) UDP支持一对一 一对多 多对多的交互通信;
(6) UDP的首部开销小,只有8字节.


TCP和UDP的区别
(1)TCP是可靠传输,UDP是不可靠传输;
(2)TCP面向连接,UDP无连接;
(3)TCP传输数据有序,UDP不保证数据的有序性;
(4)TCP不保存数据边界,UDP保留数据边界;
(5)TCP传输速度相对UDP较慢;
(6)TCP有流量控制和拥塞控制,UDP没有;
(7)TCP是重量级协议,UDP是轻量级协议;
(8)TCP首部较长20字节,UDP首部较短8字节;

  1. 说一说HTTP1.0, 1.1, 2.0 的区别
查看解析

HTTP(超文本传输协议)是Web的基础,随着时间的推移,该协议经历了几个重要的版本更新,每个版本都旨在改进性能、提高安全性和增加新功能。以下是HTTP 1.0、HTTP 1.1和HTTP 2.0之间的主要区别:

HTTP 1.0

  • 1996年发布
  • 无状态和无连接:每次请求都需要建立一个新的TCP连接,请求结束后连接就关闭。这导致每次HTTP请求都有显著的开销。
  • 简单和有限:HTTP 1.0支持基本的GET、POST和HEAD命令。
  • 无宿主名支持:HTTP 1.0不支持虚拟主机(多个域名在同一IP地址上),因为请求消息中没有宿主名。

HTTP 1.1

  • 1997年发布
  • 持久连接:HTTP 1.1默认使用持久连接(也称为连接复用),允许多个请求和响应在单个连接上顺序完成,减少了建立和关闭连接的开销。
  • 管道化:允许客户端在收到上一个响应之前发送多个请求,以减少网络延迟。
  • 更多的缓存控制机制:引入了更复杂的缓存控制策略,如ETags,以及更多的缓存头字段。
  • 分块传输编码:允许服务器开始传输响应,而不必先发送内容的总长度。
  • 虚拟主机支持:通过请求头中的 Host 字段支持虚拟主机。
  • 错误通知的改进:增加了更多的状态代码,以提供更详细的错误类型信息。

HTTP 2.0

  • 2015年正式标准化
  • 二进制协议:不再是纯文本协议,HTTP 2.0使用二进制格式,使得解析更快、更简洁,网络优化更有效。
  • 多路复用:在单一连接上可以并行交错地发送多个请求和响应,不需要按照顺序一一对应,极大地减少了延迟。
  • 服务器推送:服务器可以对一个客户端请求发送多个响应。服务器可以额外发送资源到客户端,而无需客户端明确地请求,从而提前加载可能需要的资源,提高性能。
  • 头部压缩:HTTP 2.0使用HPACK压缩格式压缩请求和响应的头部,减少了传输的数据量。

总结

每个HTTP版本的推出都旨在解决前一版本的局限性,提高性能并满足新的网络需求。从HTTP 1.0的简单实现到HTTP 1.1的性能改进,再到HTTP 2.0的高效和现代化特性,每个版本都明显提升了Web通信的效率和速度。随着HTTP 3.0(基于QUIC协议)的出现,HTTP协议的发展预计将继续朝着更高效和更可靠的方向进步。

  1. 什么是SQL注入?举个例子?
查看解析

什么是SQL注入?

SQL注入是一种安全漏洞,它允许攻击者通过在应用程序的输入中插入或“注入”恶意的SQL代码,来操纵后台数据库的执行。这通常发生在应用程序没有正确过滤或清理用户输入的情况下,使得攻击者能够执行未经授权的数据库命令,从而访问、修改、删除甚至在数据库中创建数据。

SQL注入可以对系统造成严重的安全威胁,包括数据泄露、数据损坏和完全的系统控制。

示例

假设有一个简单的网站,该网站允许用户通过输入用户名来查询其信息。后端的SQL查询可能类似于:

1
SELECT * FROM users WHERE username = '输入的用户名';

如果应用程序没有适当过滤用户的输入,攻击者可以在输入中注入SQL片段。例如,如果攻击者输入:

1
admin'; DROP TABLE users; --

这将使得最终执行的SQL语句变为:

1
SELECT * FROM users WHERE username = 'admin'; DROP TABLE users; --';

这里发生的事情是:

  1. admin':将查询条件结束于管理员用户(如果存在)。
  2. ;:SQL语句终止符,结束了原始的查询语句。
  3. DROP TABLE users:这是一个新的SQL语句,它会删除整个用户表。
  4. --:这是SQL中的注释标记,将使得剩余的部分被视为注释,从而忽略任何后续的SQL语句部分。

防御措施

防止SQL注入的策略包括:

  • 使用预处理语句(Prepared Statements)和参数化查询:这些技术确保发送到数据库的SQL命令对于预期的结构是静态的,输入值被数据库处理为数据,而不是SQL代码的一部分。
  • 有效的输入验证:确保应用程序验证所有输入数据,严格限制可能被解释为SQL代码的字符。
  • 使用最小权限原则:确保数据库连接使用的账户权限尽可能低,仅足够应用程序进行必要的操作。
  • 实时监控和警报:使用各种安全工具监控异常数据库活动,及时响应可能的安全事件。

通过上述措施,可以显著降低SQL注入攻击的风险,保护应用程序和数据免受攻击者的侵害。

  1. 谈一谈 XSS 攻击,举个例子?
查看解析
  1. 在交互过程中如果数据传送完了,还不想断开连接怎么办,怎么维持?
查看解析

在很多现代网络应用中,尽管数据已经传送完毕,客户端和服务器可能仍需要保持连接以便未来的通信。维持这种“空闲”连接的方法有多种,主要取决于应用的具体需求和使用的协议。


在 HTTP 中响应体的 Connection 字段指定为 keep-alive

作用和优势

  1. 减少延迟:多次HTTP请求可以复用同一个TCP连接,避免了频繁进行TCP三次握手和四次挥手的延迟。
  2. 节省资源:减少了因为频繁建立和关闭连接而消耗的计算和网络资源。
  3. 提高性能:对于多个资源(如页面上的图片、样式表、脚本等)的请求,保持连接可以显著提高页面加载速度。

在HTTP/1.0中,默认情况下,每个HTTP请求/响应对完成后,创建的TCP连接会立即关闭。这种方式称为“非持久连接”。为了在HTTP/1.0中保持连接,客户端需要在请求头中显式指定Connection: keep-alive

相比之下,在HTTP/1.1中,默认的连接模式是持久连接(keep-alive),即除非明确指示关闭(Connection: close),否则连接将保持开放状态以供进一步的请求使用。这意味着在HTTP/1.1中,如果没有指定Connection: close,则不需要显式声明Connection: keep-alive,因为这已是默认行为

  1. GET请求中URL编码的意义

  2. HTTP 哪些常用的状态码及使用场景?

查看解析

状态码分类
1xx:表示目前是协议的中间状态,还需要后续请求
2xx:表示请求成功
3xx:表示重定向状态,需要重新请求
4xx:表示请求报文错误
5xx:服务器端错误

常用状态码
101 切换请求协议,从 HTTP 切换到 WebSocket
200 请求成功,有响应体
301 永久重定向:会缓存
302 临时重定向:不会缓存
304 协商缓存命中
403 服务器禁止访问
404 资源未找到
400 请求错误
500 服务器端错误
503 服务器繁忙

  1. HTTP 如何实现长连接?在什么时候会超时?

  2. HTTP状态码301和302的区别,都有哪些用途?

  3. IP地址有哪些分类?

查看解析
  1. 类A(Class A)
    • 起始八位字节:0xxx xxxx
    • 地址范围:0.0.0.0 至 127.255.255.255
    • 默认子网掩码:255.0.0.0
    • 用途:支持非常大的网络,适合大型组织。第一个八位字节是网络部分,剩下的三个八位字节是主机部分。
  2. 类B(Class B)
    • 起始八位字节:10xx xxxx
    • 地址范围:128.0.0.0 至 191.255.255.255
    • 默认子网掩码:255.255.0.0
    • 用途:适用于中等规模网络。前两个八位字节是网络部分,后两个是主机部分。
  3. 类C(Class C)
    • 起始八位字节:110x xxxx
    • 地址范围:192.0.0.0 至 223.255.255.255
    • 默认子网掩码:255.255.255.0
    • 用途:适合小型网络。前三个八位字节是网络部分,最后一个八位字节是主机部分。
  4. 类D(Class D)
    • 起始八位字节:1110 xxxx
    • 地址范围:224.0.0.0 至 239.255.255.255
    • 用途:用于多播。在多播中,数据被发送到多个目的地。
  5. 类E(Class E)
    • 起始八位字节:1111 xxxx
    • 地址范围:240.0.0.0 至 255.255.255.255
    • 用途:保留作为实验和未来使用,不用于公共网络。
  1. 简单说下每一层对应的网络协议有哪些?
查看解析

1. 物理层

物理层是网络通信的最低层,负责原始比特流的传输,涉及的是硬件设备、电气信号等物理设备标准,而非特定的协议。这一层主要涉及的标准和技术包括:

  • 以太网(Ethernet):包括各种类型的电缆和信号
  • 光纤(Optical Fiber)
  • 串行传输标准,如RS-232
  • 物理接口和介质,如同轴电缆、双绞线、无线传输介质等

2. 数据链路层

数据链路层负责在相邻的网络设备之间建立、维护和解除数据链路。主要的协议包括:

  • 以太网(Ethernet)
  • IEEE 802.11(无线局域网协议,即WiFi)
  • PPP(点对点协议)
  • ARP(地址解析协议),用于将网络层的地址(如IP地址)解析为数据链路层的地址(如MAC地址)
  • 流量控制:停止-等待协议、后退N帧协议(GBN)、选择重传协议(SR)
  • ALOHA 协议
  • CSMACSMA/CDCSMA/CA

3. 网络层

网络层负责在不同网络之间传输数据包,这包括数据包的路由选择和转发。主要的协议包括:

  • IP(互联网协议),包括IPv4和IPv6
  • ICMP(互联网控制消息协议),用于传递控制消息
  • IGMP(互联网组管理协议),用于IP多播组成员管理
  • 路由协议,如RIP, OSPF, BGP等
  • ARP 协议
  • DHCP 协议

4. 传输层

传输层负责提供主机之间的数据传输服务,对接收到的信息进行错误检查和重发控制。主要协议包括:

  • TCP(传输控制协议),提供面向连接的、可靠的数据传输服务
  • UDP(用户数据报协议),提供无连接的、尽最大努力交付的数据传输服务

5. 应用层

应用层负责处理特定的应用程序细节。这一层的协议直接为用户的应用程序(如Web浏览器、电子邮件客户端等)提供服务。主要的协议包括:

  • HTTP(超文本传输协议),用于Web浏览
  • HTTPS(HTTP安全),即安全的HTTP
  • FTP(文件传输协议)
  • SMTP(简单邮件传输协议)
  • DNS(域名系统)
  • Telnet(远程登录协议)
  • SSH(安全外壳协议)
  1. ARP 协议的工作原理
查看解析

ARP(地址解析协议,Address Resolution Protocol)是网络层与数据链路层之间的一个桥梁,用于在本地网络(如以太网)中将网络层的逻辑地址(如IPv4地址)解析为数据链路层的物理地址(如MAC地址)。ARP是局域网中进行主机或路由器间通信所必需的,因为虽然数据包是通过IP地址发送的,但最终是通过物理媒介传输,而物理媒介依赖于MAC地址来确保数据能够到达正确的设备。

ARP的工作原理

  1. ARP请求
    • 当主机需要发送数据包到同一局域网的另一台主机时,它首先检查自己的ARP缓存表,这个表中包含了IP地址到MAC地址的映射。
    • 如果没有找到相应的MAC地址(即表中没有目标IP地址的条目),它将广播一个ARP请求包到局域网上的所有主机。ARP请求包包含发送者的IP和MAC地址,以及接收者的IP地址,但接收者的MAC地址字段被留空。
  2. ARP应答
    • 局域网中的所有设备都会接收到这个ARP请求,但只有IP地址匹配请求中的目标IP地址的设备会回应一个ARP应答。
    • 这个应答包含了目标设备的IP地址和相对应的MAC地址,这个应答是直接发送给发起ARP请求的主机的,不是广播。
  3. 更新ARP缓存
    • 发起ARP请求的主机收到ARP应答后,会更新其ARP缓存表,将新的IP地址与MAC地址映射添加到表中。这样,未来向同一IP地址发送数据时,可以直接从缓存表中查找MAC地址,无需再次发起ARP请求。
    • ARP缓存中的条目通常不会永久存储,它们会在一段时间后过期,以适应网络配置可能的变化。




ARP(地址解析协议)仅用于将本地网络(局域网)中的IP地址解析为相应的MAC地址。当需要与非局域网内的设备通信时,确实需要通过网关(通常是路由器)来进行。以下是这一过程的简化说明,以及如何递归进行路由选择的细节:

步骤简述

  1. 局域网内的ARP查询

    • 当设备需要发送数据到另一个不在同一局域网内的IP地址时,它会首先检查该IP地址是否属于已配置的路由表中的任何子网。
    • 发现目标IP不在任何已知子网后,设备将使用ARP查询本地配置的默认网关的MAC地址。
  2. 向网关发送数据

    • 设备将数据包发送到获取的网关MAC地址。虽然这个数据包的最终目标IP地址不在局域网内,但通过将其首先发送到网关,网关将负责进一步的路由处理。
  3. 网关的路由决策

    • 网关(路由器)接收到数据包后,检查其路由表以确定下一跳的最佳路径。如果网关知道如何到达目标网络,它将根据路由表中的信息转发数据包。
    • 如果网关不知道如何直接到达目标网络,它会将数据包发送到另一个上游路由器,该路由器进一步决定如何向目标进发。
  4. 递归路由选择

    • 此过程可能涉及多个路由器,每个路由器都根据自己的路由表作出路由决策,直至数据包到达目标网络。
    • 在到达目标网络后,最终的目标路由器使用ARP确定目标设备的MAC地址,并将数据包直接发送到目标设备。

关键点

  • ARP的限制:ARP只在本地网络(局域网)中有效,它不能跨越路由器或其他网络设备用于非直接连接的网络。
  • 网关的作用:网关作为局域网与外部网络之间的桥梁,处理出局域网范围的所有IP通信请求。
  • 数据包的封装和转发:尽管数据包在进入路由器时被封装在以太网帧中,但每经过一个路由点,新的数据链路层封装就会根据那个点的物理网络环境被重新生成。

总之,从一个网络到另一个网络的数据传输是一个复杂的过程,涉及多层网络协议和多个网络设备。每个设备和协议层都为确保数据能够有效地从源头到达目的地发挥着关键作用。

  1. TCP 的主要特点是什么?
查看解析
  1. TCP 是面向连接的。(就好像打电话一样,通话前需要先拨号建立连接,通话结束后要挂机释放连接);
  2. 每一条 TCP 连接只能有两个端点,每一条 TCP 连接只能是点对点的(一对一);
  3. TCP 提供可靠交付的服务。通过 TCP 连接传送的数据,无差错、不丢失、不重复、并且按序到达;
  4. TCP 提供全双工通信。 TCP 允许通信双方的应用进程在任何时候都能发送数据。 TCP 连接的两端都设有发送缓存和接收缓存,用来临时存放双方通信的数据;
  5. 面向字节流。TCP 中的“流”(Stream)指的是流入进程或从进程流出的字节序列。 “面向字节流”的含义是:虽然应用程序和 TCP 的交互是一次一个数据块(大小不等),但 TCP 把应用程序交下来的数据仅仅看成是一连串串的无结构的字节流
  1. UDP 的主要特点是什么?
查看解析
  1. UDP 是无连接的;
  2. UDP 使用尽最大努力交付,即不保证可靠交付,因此主机不需要维持复杂的链接状态(这里面有许多参
    数);
  3. UDP 是面向报文的;
  4. UDP 没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如 直播,实时视频会议等);
  5. UDP 支持一对一、一对多、多对一和多对多的交互通信;
  6. UDP 的首部开销小,只有 8 个字节,比 TCP 的 20 个字节的首部要短。
  1. TCP 和 UDP 分别对应的常见应用层协议有哪些?
查看解析

1. TCP 对应的应用层协议

  • FTP:定义了文件传输协议,使用 21 端口。常说某计算机开了 FTP 服务便是启动了文件传输服务。下载文件,上传主页,都要用到 FTP 服务。
    FTP使用端口21(命令)和20(数据)

  • Telnet:它是一种用于远程登陆的端口,用户可以自己的身份远程连接到计算机上,通过这种端口可以提供一种基于 DOS 模式下的通信服务。如以前的 BBS 是-纯字符界面的,支持 BBS 的服务器将 23 端口打开,对外提供服务。

  • SMTP:定义了简单邮件传送协议,现在很多邮件服器都用的是这个协议,用于发送邮件。如常⻅见的免费邮件服务中用的就是这个邮件服务端口,所以在电子邮件设置-中常看到有这么 SMTP 端口设置这个栏,服务器开放的是 25 号端口。

  • POP3:它是和 SMTP 对应, POP3 用于接收邮件。通常情况下, POP3 协议所用的是 110 端口。也是说,只要你有相应的使用 POP3 协议的程序(例如 Fo-xmail 或 Outlook),就可以不以 Web 方式登陆进邮箱界面,直接用邮件程序就可以收到邮件(如是163 邮箱就没有必要先进入网易网站,再进入自己的邮箱来收信)。

  • HTTP:从 Web 服务器传输超文本到本地浏览器的传送协议。

    HTTP使用端口80;HTTPS使用端口443


2. UDP 对应的应用层协议

  • DNS:用于域名解析服务,将域名地址转换为 IP 地址。 DNS 用的是 53 号端口。
  • SNMP:简单网络管理协议,使用 161 号端口,是用来管理网络设备的。由于网络设备很多,无连接的服务就体现出其优势。
  • TFTP(Trival File Transfer Protocal):简单文件传输协议,该协议在熟知端口 69 上使用 UDP 服务。
  1. 为什么 TIME-WAIT 状态必须等待 2MSL 的时间呢?
查看解析

img

1、为了保证 A 发送的最后一个 ACK 报文段能够到达 B。这个 ACK 报文段有可能丢失,因而使处在 LAST-ACK 状态的 B 收不到对已发送的 FIN + ACK 报文段的确认。 B 会超时重传这个 FIN+ACK 报文段,而 A 就能在 2MSL 时间内(超时 + 1MSL 传输)收到这个重传的 FIN+ACK 报文段。接着 A 重传一次确认,重新启动 2MSL 计时器。最后, A 和 B 都正常进入到 CLOSED 状态。如果 A 在 TIME-WAIT 状态不等待一段时间,而是在发送完 ACK 报文段后立即释放连接,那么就无法收到 B 重传的 FIN + ACK 报文段,因而也不会再发送一次确认报文段,这样, B 就无法按照正常步骤进入 CLOSED 状态。

2、 防止已失效的连接请求报文段出现在本连接中。 A 在发送完最后一个 ACK 报文段后,再经过时间 2MSL(因为TCP报文段的生存时间被假设为最多MSL),就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样就可以使下一个连接中不会出现这种旧的连接请求报文段。



MSL(Maximum Segment Lifetime)是TCP协议中定义的一个重要概念,它代表任何给定的TCP报文段在网络系统中被允许存在的最大时间。这个时间长度是用来确保TCP报文段在网络中不会无限期地存在,从而可能引起数据混乱或资源浪费。

  1. 报文段过期机制
    • MSL是一种安全和正确性机制,用于限定数据包在网络中存在的时长。超过这段时间后,报文段应被认为是过时的,并应被丢弃,不再传输或处理。这样可以避免长时间滞留在网络设备中的“幽灵”数据包对新的或现有连接造成干扰。
  2. 防止数据混淆
    • 在某些网络条件下,如网络拥堵或路由器错误配置,报文段可能会异常延迟到达目的地。MSL确保这些延迟的报文段在一定时间后失效,减少了它们错误解释为其他连接的有效数据的机会。
  3. 连接的可靠关闭
    • 在TCP连接的终止阶段,保持一个2MSL的等待时间(即TIME-WAIT状态持续时间)确保了连接双方都可以接收到对方的最终ACK确认,同时确保所有在途中的报文段都会在新的连接建立之前从网络中消失。
  1. 保活计时器的作用?
查看解析

除时间等待计时器外, TCP 还有一个保活计时器(keepalive timer)。设想这样的场景:客户已主动与服务器建立了 TCP 连接。但后来客户端的主机突然发生故障。显然,服务器以后就不能再收到客户端发来的数据。因此,应当有措施使服务器不要再白白等待下去。这就需要使用保活计时器了。

服务器每收到一次客户的数据,就重新设置保活计时器,时间的设置通常是两个小时。若两个小时都没有收到客户端的数据,服务端就发送一个探测报文段,以后则每隔 75 秒钟发送一次。若连续发送 10个 探测报文段后仍然无客户端的响应,服务端就认为客户端出了故障,接着就关闭这个连接。

  1. TCP 协议是如何保证可靠传输的?
查看解析
  1. 数据包校验:目的是检测数据在传输过程中的任何变化,若校验出包有错,则丢弃报文段并且不给出响应,这时 TCP 发送数据端超时后会重发数据;
  2. 对失序数据包重排序:既然 TCP 报文段作为 IP 数据报来传输,而 IP 数据报的到达可能会失序,因此 TCP 报文段的到达也可能会失序。 TCP 将对失序数据进行重新排序,然后才交给应用层;
  3. 丢弃重复数据:对于重复数据,能够丢弃重复数据;
  4. 应答机制:当 TCP 收到发自 TCP 连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒;
  5. 超时重发:当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段;
  6. 流量控制: TCP 连接的每一方都有固定大小的缓冲空间。 TCP 的接收端只允许一端发送接收端缓冲区所能接纳的数据,这可以防止较快主机致使较慢主机的缓冲区溢出,这就是流量控制。 TCP 使用的流量控制协议是可变大小的滑动窗口协议。
  1. 谈谈你对停止等待协议的理解?
查看解析

停止等待协议是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组;在停止等待协议中,若接收方收到重复分组,就丢弃该分组,但同时还要发送确认。主要包括以下几种情况:无差错情况、出现差错情况(超时重传)、确认丢失和确认迟到、确认丢失和确认迟到。

  1. 谈谈你对 ARQ 协议的理解?

  2. 谈谈你对滑动窗口的了解?

查看解析

TCP 利用滑动窗口实现流量控制的机制。滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题。

TCP 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0 时,发送方一般不能再发送数据报,但有两种情况除外,一种情况是可以发送紧急数据,例如,允许用户终止在远端机上的运行进程。另一种情况是发送方可以发送一个 1 字节的数据报来通知接收方重新声明它希望接收的下一字节及发送方的滑动窗口大小。

  1. 谈下你对流量控制的理解?
查看解析

TCP 利用滑动窗口实现流量控制。流量控制是为了控制发送方发送速率,保证接收方来得及接收。接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。

  1. 谈下你对 TCP 拥塞控制的理解?使用了哪些算法?
查看解析

拥塞控制和流量控制不同,前者是一个全局性的过程,而后者指点对点通信量的控制。在某段时间,若对网络中某一资源的需求超过了该资源所能提供的可用部分,网络的性能就要变坏。这种情况就叫拥塞

拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致于过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机,所有的路由器,以及与降低网络传输性能有关的所有因素。相反,流量控制往往是点对点通信量的控制,是个端到端的问题。流量控制所要做到的就是抑制发送端发送数据的速率,以便使接收端来得及接收。

为了进行拥塞控制, TCP 发送方要维持一个拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个

TCP 的拥塞控制采用了四种算法,即:慢开始拥塞避免快重传快恢复。在网络层也可以使路由器采用适当的分组丢弃策略略(如:主动队列管理 AQM),以减少网络拥塞的发生。

img

img

  • 慢开始
    慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。 cwnd 初始值为 1,每经过一个传播轮次, cwnd 加倍。

  • 拥塞避免
    拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,即每经过一个往返时间 RTT 就把发送方的 cwnd 加 1。

  • 快重传与快恢复
    在 TCP/IP 中,快速重传和快恢复(Fast Retransmit and Recovery, FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。没有 FRR,如果数据包丢失了, TCP 将会使用定时器来要求传输暂停。在暂停的这段时间内,没有新的或复制的数据包被发送。有了 FRR,如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。

    有了 FRR,就不会因为重传时要求的暂停被耽误。当有单独的数据包丢失时,快速重传和快恢复(FRR)能最有效地工作。当有多个数据信息包在某一段很短的时间内丢失时,它则不能很有效地工作。

  1. 什么是粘包?

  2. TCP 黏包是怎么产生的?

  3. 怎么解决拆包和粘包?

  4. forward 和 redirect 的区别?

查看解析
1

  1. HTTP 方法有哪些?
查看解析

客户端发送的 请求报便 第一行为请求行,包含了方法字段。

  1. GET:获取资源,当前网络中绝大部分使用的都是 GET;
  2. HEAD:获取报文首部,和 GET 方法类似,但是不返回报文实体主体部分;
  3. POST:传输实体主体
  4. PUT:上传文件,由于自身不带验证机制,任何人都可以上传文件,因此存在安全性问题,一般不使用该方法。
  5. PATCH:对资源进行部分修改。 PUT 也可以用于修改资源,但是只能完全替代原始资源, PATCH 允许部分修改。
  6. OPTIONS:查询指定的 URL 支持的方法;
  7. CONNECT:要求在与代理服务器通信时建立隧道。使用 SSL(Secure Sockets Layer,安全套接层)和TLS(Transport Layer Security,传输层安全)协议把通信内容加密后经网络隧道传输。
  8. TRACE:追踪路路径。服务器会将通信路径返回给客户端。发送请求时,在 Max-Forwards 首部字段中填入数值,每经过一个服务器就会减 1,当数值为 0 时就停止传输。通常不会使用 TRACE,并且它容易受到 XST 攻击(Cross-Site Tracing,跨站追踪)。
  1. 在浏览器中输入 URL 地址到显示主页的过程?
查看解析

在浏览器中输入URL地址到显示主页的过程涉及多个步骤,包括网络、应用层协议、服务器处理和浏览器渲染等多个复杂环节。这里详细介绍这一过程:

1. URL解析

  • 当你在浏览器地址栏输入URL并按下回车,浏览器首先解析URL,确定它指向的是哪个服务器以及需要请求的资源。URL通常包括协议(如HTTP或HTTPS)、服务器地址(域名)和资源路径。

2. 域名解析(DNS查找)

  • 如果URL包含的是域名(如 www.example.com),浏览器需要将该域名转换为服务器的IP地址。这一步通过查询域名系统(DNS)完成。浏览器首先检查本地缓存是否有这个域名的记录,如果没有,它会向配置的DNS服务器发送请求,解析出对应的IP地址。

3. 建立连接

  • 获取到IP地址后,浏览器会发起到服务器的网络连接。对于HTTP协议,这意味着建立一个TCP连接;对于HTTPS,则还需要进行TLS握手过程,以确保连接的安全。

4. 发送HTTP请求

  • 一旦TCP连接建立(和TLS握手完成,如果是HTTPS),浏览器就会通过这个连接发送一个HTTP请求。这个请求包括请求行(如 GET /index.html HTTP/1.1),请求头部和可能的请求体。

5. 服务器处理请求

  • 服务器接收到HTTP请求后,会根据请求的资源路径处理请求。这可能包括在服务器上查找请求的文件,执行服务器端脚本,或者查询数据库等操作。

6. 发送HTTP响应

  • 服务器处理完请求后,会向浏览器回送一个HTTP响应。响应通常包括一个状态码(如200表示成功),响应头部和响应体(即实际的资源内容,如HTML文档)。

7. 浏览器处理响应

  • 浏览器接收到HTTP响应后,会开始解析响应体中的HTML文档。它会解析HTML结构,加载文档中引用的CSS样式文件、图像和JavaScript文件。

8. 页面渲染

  • HTML和CSS被解析后,浏览器会构建DOM树和CSSOM树,并将它们合并成渲染树。然后浏览器会进行布局(layout)过程,计算每个节点的准确位置和大小。最后,浏览器会绘制(paint)页面,将内容呈现在屏幕上。

9. 用户交互

  • 一旦页面渲染完成,用户便可以与页面进行交互,如点击链接、填写表单等。JavaScript可以用来处理用户事件,提供动态的交互功能。

这个过程涵盖了从用户输入URL到页面展现的全过程,涉及网络通信、服务器处理和浏览器渲染等多个层面,每一步都至关重要,共同确保了Web浏览的顺利进行。

  1. DNS 的解析过程?
查看解析

DNS(域名系统)解析是互联网上将域名转换为对应IP地址的过程。这个过程涉及多个服务器和几个步骤,确保当你输入一个如 www.example.com 的网址时,你的计算机能够找到托管该网站的服务器的IP地址。下面详细介绍DNS解析的步骤:

1. 浏览器缓存

  • 首先检查:当用户在浏览器中输入一个URL并开始加载网页时,浏览器首先检查自己的缓存中是否有这个域名的DNS记录,并检查这条记录是否仍然有效(未过期)。

2. 操作系统缓存

  • 系统级检查:如果浏览器缓存中没有找到DNS记录,接下来会检查操作系统的DNS缓存。操作系统也可能缓存了之前查询过的DNS解析结果。

3. 路由器缓存

  • 网络设备缓存:如果前两级缓存都没有找到,查询请求会被发送到本地网络中的路由器,很多路由器也会缓存DNS查询结果。

4. ISP DNS服务器

  • ISP查询:如果以上都未能解析,则查询会发送到ISP(互联网服务提供商)的DNS服务器。ISP的DNS服务器通常会有较广泛的域名解析记录。

5. 根域名服务器

  • 根服务器查询:如果ISP的DNS服务器也无法解析该域名,它会查询根域名服务器。互联网有13组根域名服务器,它们是DNS层级的最顶端。根服务器不直接解析域名,而是指向负责该顶级域(如.com、.net)的顶级域名(TLD)服务器。

6. 顶级域名服务器

  • TLD服务器查询:TLD服务器管理在其域内的域名信息,并可以提供存储域名记录的权威DNS服务器的地址。例如,对于www.example.com,TLD服务器是.com域的DNS服务器。

7. 权威DNS服务器

  • 权威查询:最后,查询会发送到权威DNS服务器,这些服务器存储了特定域名的具体DNS记录(如A记录,即IP地址)。如果权威DNS服务器有缓存的记录,它将返回域名对应的IP地址。

8. 缓存结果

  • 记录缓存:得到IP地址后,此地址会被沿途的每个DNS服务器缓存(包括ISP的服务器、路由器以及本地系统),以便快速响应未来对同一域名的查询请求。每条DNS记录都有一个TTL(生存时间),决定了该记录在缓存中的存活时间。

9. 完成解析

  • 使用IP地址:一旦IP地址被确定并返回给原始请求者(如你的浏览器),浏览器就会使用这个IP地址来建立连接并获取网站内容。

DNS解析过程是一个涉及多级网络结构和服务器的分布式查询过程,设计目的是为了确保域名能够被有效、可靠地解析成IP地址,从而使互联网上的资源可以被访问。



img

  1. 主机向本地域名服务器的查询一般都是采用递归查询。所谓递归查询就是:如果主机所询问的本地域名服务器不知道被查询的域名的 IP 地址,那么本地域名服务器就以 DNS 客户的身份,向根域名服务器继续发出查询请求报文(即替主机继续查询),而不是让主机自己进行下一步查询。因此,递归查询返回的查询结果或者是所要查询的 IP 地址,或者是报错,表示无法查询到所需的 IP 地址。

  2. 本地域名服务器向根域名服务器的查询的迭代查询。迭代查询的特点:当根域名服务器收到本地域名服务器发出的迭代查询请求报文时,要么给出所要查询的 IP 地址,要么告诉本地服务器: “你下一步应当向哪一个域名服务器进行查询”。然后让本地服务器进行后续的查询。根域名服务器通常是把自己知道的顶级域名服务器的 IP 地址告诉本地域名服务器,让本地域名服务器再向顶级域名服务器查询。顶级域名服务器在收到本地域名服务器的查询请求后,要么给出所要查询的 IP 地址,要么告诉本地服务器下一步应当向哪一个权限域名服务器进行查询。最后,本地域名服务器得到了所要解析的 IP 地址或报错,然后把这个结果返回给发起查询的主机。

  1. 谈谈你对域名缓存的了解?
查看解析

域名缓存是一个关键的机制,它在多个层面上帮助优化和加速DNS(域名系统)查询过程。这种缓存策略不仅减少了对根服务器和权威DNS服务器的负担,也显著提升了用户访问网络资源的速度。下面我会详细解释域名缓存的作用、存储位置以及它如何工作。

作用与优势

  1. 减少DNS查询时间

    • 通过存储先前查询的结果,DNS解析过程可以迅速完成,避免了多次通过网络进行远程查询的延迟。
  2. 减轻DNS服务器负载

    • 缓存减少了对远端DNS服务器的请求次数,有助于分散网络流量,减轻中心DNS服务器的负担。
  3. 增强网络鲁棒性

    • 在域名解析服务不可用或网络连接受限时,缓存可以使得本地系统仍然能够解析域名。

缓存位置

  1. 浏览器缓存

    • 现代浏览器会在本地缓存DNS记录一段时间。这意味着如果你访问过某个网站,再次访问时,浏览器可能无需进行DNS查询就能解析其域名。
  2. 操作系统缓存

    • 大部分操作系统(如Windows, macOS, Linux)也会在系统级别缓存DNS信息。这对所有使用该操作系统的应用程序都是透明的。
  3. 路由器缓存

    • 许多家庭和企业路由器也会进行DNS缓存,作为网络中所有设备的共享资源。
  4. ISP DNS缓存

    • 你的互联网服务提供商(ISP)也可能在其DNS服务器上缓存DNS查询结果,从而为同一地区内的用户提供快速的DNS解析服务。
  5. 内容分发网络(CDN)DNS缓存

    • 专业的CDN服务,如Cloudflare或Akamai,也会在全球多个位置缓存DNS查询结果,以提高其客户网站的访问速度。

缓存管理

每条DNS记录在缓存中存储的时间由其TTL(Time To Live)值决定。TTL是一个时间长度,指示该记录可以保持有效的最长时间。当缓存中的记录过期后,会在下一次DNS查询时重新从权威服务器获取新的记录。

缓存相关的问题

  1. 缓存过期

    • 如果域名记录发生变更(例如IP地址改变),缓存中的旧记录在TTL过期前仍被使用,可能导致访问错误或延迟。
  2. 缓存污染

    • 如果攻击者成功将错误的DNS信息注入到缓存中,可能导致用户被重定向到恶意站点。这种类型的攻击称为DNS缓存投毒。

总结

域名缓存是DNS架构中非常重要的一部分,有效地加速了域名解析过程,改善了用户体验,同时减轻了网络和服务器的负担。然而,管理缓存以保证数据的及时性和准确性也是网络管理员和系统架构师的重要任务。正确的缓存策略和安全措施对维持网络的健康和安全至关重要。

  1. 谈下你对 HTTP 长连接和短连接的理解?分别应用于哪些场景?

  2. HTTPS 的工作过程

查看解析

HTTPS(超文本传输协议安全)是一种保护在互联网上传输的数据安全的协议,它在HTTP的基础上通过SSL/TLS协议提供数据加密、数据完整性验证和身份验证的功能。HTTPS的工作过程涉及以下几个关键步骤:

1. 客户端发起HTTPS请求

  • 当你在浏览器中输入一个以https://开始的URL时,浏览器解析这个URL,提取服务器的主机名,并在默认的HTTPS端口443上向服务器发起连接。

2. TLS握手 (先进行TCP三次握手,后TLS握手)

  • 客户端Hello:浏览器发送一个"ClientHello"消息到服务器。这个消息包括浏览器支持的TLS版本,支持的加密算法(密码套件),以及一个客户端生成的随机数。

  • 服务器Hello:服务器回应一个"ServerHello"消息,包含服务器选择使用的TLS版本,选择的加密方法和一个服务器生成的随机数。

  • 服务器证书:服务器发送其数字证书给客户端。此证书包含了服务器的公钥,证书颁发机构(CA)的签名等信息。

  • 密钥交换:根据协商的加密方法,客户端和服务器协商生成一个"会话密钥"。客户端可能会发送一个"PreMaster Secret"的加密版本,这个只有持有私钥的服务器才能解密。

  • 客户端和服务器完成:双方发送各自的"Finished"消息,这个消息是对之前消息的加密验证,用来确认握手过程没有被篡改。

3. 加密的数据传输

  • 完成握手后,客户端和服务器开始通过对称加密算法使用会话密钥来加密在通信过程中传输的数据。这保证了传输的数据的机密性和完整性。

4. 数据传输结束

  • 通信结束后,客户端和服务器会发送消息通知对方关闭连接,然后进行连接的安全关闭。

5. 连接状态

  • 在连接关闭之后,客户端和服务器可以选择重新使用相同的会话密钥再次快速建立连接,或者丢弃会话密钥并生成新的密钥来重新开始一个新的TLS握手过程。

安全特性

  • 加密:确保数据在传输过程中不被监听。
  • 身份验证:通过证书和CA保证服务器(以及可选的客户端)的身份,防止身份伪装。
  • 数据完整性:保证数据在传输过程中不被篡改。

HTTPS增加了数据传输的安全性,是现代互联网中保护隐私和安全的关键技术。对于所有传输敏感数据的网站,例如电子商务、在线银行或任何需要登录的服务,HTTPS都是必须的。

  1. HTTP 和 HTTPS 的区别?
查看解析
  1. 费用: HTTPS 协议需要到 CA 申请证书,一般免费证书很少,需要交费;
  2. 资源消耗: HTTP 是超文本传输协议,信息是明文传输, HTTPS 则是具有安全性的 ssl 加密传输协议,需要消耗更多的 CPU 和内存资源;
  3. 端口不同: HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是 80,后者是 443;
  4. 安全性: HTTP 的连接很简单,是无状态的; HTTPS 协议是由 TSL+HTTP 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 协议安全



SSL(Secure Sockets Layer)是一种安全协议,设计用于为互联网通信提供安全性和数据完整性。SSL协议能够保证网络传输中的数据在两个系统(通常是服务器和客户端,如网页浏览器和服务器之间)之间的传输过程中加密,并且安全地认证通信双方的身份。

  1. HTTPS 的优缺点?
查看解析

优点

  1. 使用 HTTPS 协议可认证用户和服务器,确保数据发送到正确的客户机和服务器;
  2. HTTPS 协议是由 SSL + HTTP 协议构建的可进行加密传输、身份认证的网络协议,要比 HTTP 协议安全,可防止数据在传输过程中不被窃取、改变,确保数据的完整性;
  3. HTTPS 是现行架构下最安全的解决方案,虽然不是绝对安全,但它大幅增加了中间人攻击的成本。

缺点

  1. HTTPS 协议握手阶段比较费时,会使页面的加载时间延长近 50%,增加 10% 到 20% 的耗电;
  2. HTTPS 连接缓存不如 HTTP 高效,会增加数据开销和功耗,甚至已有的安全措施也会因此而受到影响;
  3. SSL 证书需要钱,功能越强大的证书费用越高,个人网站、小网站没有必要一般不会用;
  4. SSL 证书通常需要绑定 IP,不能在同一 IP 上绑定多个域名, IPv4 资源不可能支撑这个消耗;
  5. HTTPS 协议的加密范围也比较有限,在黑客攻击、拒绝服务攻击、服务器劫持等方面几乎起不到什么作用。最关键的, SSL 证书的信用链体系并不安全,特别是在某些国家可以控制 CA 根证书的情况下,中间⼈人攻击一样可行
  1. 什么是数字签名?

  2. 什么是数字证书?

查看解析
1

  1. Cookie 和 Session 有什么区别?
查看解析
1

  1. 字节:网络四层协议是什么
查看内容

TCP/IP

它只有四层,相当于五层协议中数据链路层和物理层合并为网络接口层。

TCP/IP 体系结构不严格遵循 OSI 分层概念,应用层可能会直接使用 IP 层或者网络接口层。

img

GPT4

网络的四层协议,通常指的是TCP/IP模型,它将计算机网络体系结构分为四层,分别是应用层、传输层、互联网层和网络接口层。下面我会详细介绍每一层的功能和一些常见的协议:

  1. 应用层:这是用户和网络之间的接口层,提供网络服务给最终用户的应用程序。应用层直接为用户的应用程序(如电子邮件、文件传输和终端仿真)提供服务。常见的应用层协议包括:

    • HTTP(超文本传输协议):用于万维网上的信息传输。
    • FTP(文件传输协议):用于在网络上进行文件传输。
    • SMTP(简单邮件传输协议):用于发送邮件。
    • DNS(域名系统):用于将域名转换为IP地址。
  2. 传输层:负责在网络中的两个端点(主机间或网络中的两个应用程序间)进行可靠的数据传输。传输层协议包括:

    • TCP(传输控制协议):提供一种可靠的、面向连接的通信服务。它保证数据包按序到达,未收到的包会重新发送。
    • UDP(用户数据报协议):提供一种无连接的通信服务。相对于TCP,UDP简单、高效,但不保证数据包的顺序或可靠性。
  3. 互联网层:也称为网络层,负责在多个网络之间传输数据包。这一层封装数据,并在发送端选择路径以将数据传输到接收端。常见的互联网层协议包括:

    • IP(互联网协议):负责将数据包从源主机发送到目标主机。IP协议定义了数据包的结构和网络寻址方式。
    • ICMP(互联网控制消息协议):用于发送错误消息和网络通信的控制消息。
  4. 网络接口层:也被称为链路层或数据链路层,它是网络架构中最低的一层,负责在相邻的网络节点(如两台计算机)间传输数据帧。这一层涵盖了物理和数据链路两个层次,负责物理地址寻址、数据的封装成帧、流量控制和错误检测。常用的技术和协议包括:

    • 以太网(Ethernet):最常见的局域网技术。
    • ARP(地址解析协议):用于将网络层的IP地址解析为链路层的地址。

TCP/IP模型是互联网的基础,其设计简洁高效,支持多种网络架构,使得不同类型的网络可以互联互通。

  1. 快手一面:http协议的工作方式
  2. 快手二面:https的工作方式


一些名词

  • ISP:Internet Service Provider的缩写,即因特网服务提供商




操作系统

  1. 简单说下你对并发和并行的理解?
查看输出
  1. 并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔发生;
  2. 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件;
  1. 同步、异步、阻塞、非阻塞的概念
查看输出

同步 vs 异步

这两个概念主要涉及调用操作的时间处理方式:

  • 同步:在同步操作中,调用者发起一个操作请求并且必须等待直到操作完成才能继续执行。这种方式简单但可能导致效率问题,因为调用者在等待期间不能执行其他任务。
  • 异步:在异步操作中,调用者发起请求后可以立即继续执行其他任务。异步操作通常涉及回调、事件、或者是使用Future/Promise等机制,这些机制允许在操作完成时通知调用者,或者允许调用者在稍后某个时刻获取结果。

阻塞 vs 非阻塞

这两个概念与程序在等待操作完成时的行为有关:

  • 阻塞:在阻塞模式下,调用者请求操作(如I/O操作)时,如果响应不立即可用,当前线程将挂起,直到有结果可用为止。这种方式在编程模型上简单直接,但缺点是当前线程的计算资源在等待期间不能被其他任务利用。
  • 非阻塞:非阻塞操作允许调用者请求操作而不会挂起当前线程;如果结果不立即可用,调用会立即返回一个状态,表示操作尚未完成。非阻塞调用通常需要轮询、事件订阅或回调来处理结果。



1. 同步阻塞

这是最传统也是最直观的方式。在这种模式下,一个操作发起后,调用者必须等待操作完成才能继续执行后续代码。这种方式的编程模型简单,但可能导致应用性能问题,因为在等待操作完成期间,执行线程被挂起,无法执行其他任务。

  • 用途:适用于客户端应用或者后台服务中对性能要求不高的场景,例如简单的脚本或命令行工具。

2. 同步非阻塞

同步非阻塞操作较少见,因为它通常需要轮询或检查操作状态以确定何时可以继续执行,这可能会导致CPU资源的浪费。

  • 用途:某些特定的系统级编程可能会用到,其中调用者主动检查操作的完成状态而不是被动等待。

3. 异步阻塞

这种组合实际上是不常见的,因为异步设计的核心目的是避免阻塞。然而,在某些特殊情况下,如使用特定的同步机制(例如等待某个事件或锁)来完成异步操作,可能会出现阻塞。

  • 用途:特定的多线程设计模式中,如使用条件变量或事件等待机制。

4. 异步非阻塞

这是现代应用中特别是在高性能服务器和实时系统中最理想的模式。在这种模式下,操作被发起后,调用者无需等待操作完成,可以立即继续执行其他任务。操作完成后,通过回调、事件、Promises 或 Futures等机制得到通知。

  • 用途:适用于需要高并发处理的服务器应用程序,如Web服务器、数据库系统、网络服务等。这种模式可以极大地提高应用的吞吐量和响应能力。
  1. 进程和线程的基本概念
查看输出

进程:进程是系统进行资源分配和调度的一个独立单位,是系统中的并发执行的单位。

线程:线程是进程的一个实体,也是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位,有
时又被称为轻权进程或轻量级进程。

  1. 进程与线程的区别
查看输出
  1. 进程是资源分配的最小单位,而线程是 CPU 调度的最小单位;

  2. 创建进程或撤销进程,系统都要为之分配或回收资源,操作系统开销远大于创建或撤销线程时的开销;

  3. 不同进程地址空间相互独立,同一进程内的线程共享同一地址空间。一个进程的线程在另一个进程内是不可见的;

  4. 进程间不会相互影响,而一个线程挂掉将可能导致整个进程挂掉;

  1. 为什么有了进程,还要有线程呢?
查看输出

进程可以使多个程序并发执行,以提高资源的利用率和系统的吞吐量,但是其带来了一些缺点:

  1. 进程在同一时间只能干一件事情;
  2. 进程在执行的过程中如果阻塞,整个进程就会被挂起,即使进程中有些工作不依赖与等待的资源,仍然不会执
    行。

基于以上的缺点,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时,所付出的时间和空间开销,提高并发性能

  1. 进程的状态转换
查看输出

img

  1. 进程间的通信方式有哪些?
查看输出

进程间通信(IPC, InterProcess Communication)是指在不同进程之间传播或交换信息。 IPC 的方式通常有管道
(包括无名管道和命名管道)、消息队列、信号量、共享存储、 Socket、 Streams 等。其中 Socket 和 Streams 支持不同主机上的两个进程 IPC。

管道

  1. 它是半双工(全双工直接用两个管道来实现)的,具有固定的读端和写端;
  2. 它只能用于父子进程或者兄弟进程之间的进程的通信;
  3. 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的 read、 write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

命名管道

  1. FIFO 可以在无关的进程之间交换数据,与无名管道不同;
  2. FIFO 有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。

消息队列

  1. 消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符 ID 来标识;
  2. 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级;
  3. 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除;
  4. 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

信号量

  1. 信号量(semaphore)是一个计数器。用于实现进程间的互斥与同步,而不是用于存储进程间通信数据;
  2. 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存;
  3. 信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作;
  4. 每次对信号量的 PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数;
  5. 支持信号量组。

共享内存

  1. 共享内存(Shared Memory),指两个或多个进程共享一个给定的存储区;
  2. 共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。
  1. 进程的调度算法有哪些?
查看输出

调度算法是指:根据系统的资源分配策略所规定的资源分配算法。常用的调度算法有:先来先服务调度算法、时间片轮转调度法、短作业优先调度算法、最短剩余时间优先、高响应比优先调度算法、优先级调度算法等。

  • 先来先服务调度算法

    先来先服务调度算法是一种最简单的调度算法,也称为先进先出或严格排队方案。当每个进程就绪后,它加入就绪队列。当前正运行的进程停止执行,选择在就绪队列中存在时间最长的进程运行。该算法既可以用于作业调度,也可以用于进程调度。先来先去服务比较适合于常作业(进程),而不利于段作业(进程)。

  • 时间片轮转调度算法

    时间片轮转调度算法主要适用于分时系统。在这种算法中,系统将所有就绪进程按到达时间的先后次序排成一个队列,进程调度程序总是选择就绪队列中第一个进程执行,即先来先服务的原则,但仅能运行一个时间片。

  • 短作业优先调度算法

    短作业优先调度算法是指对短作业优先调度的算法,从后备队列中选择一个或若干个估计运行时间最短的作业,将它们调入内存运行。 短作业优先调度算法一个非抢占策略略,他的原则是下一次选择预计处理时间最短的进程,因此短进程将会越过长作业,跳至队列头。

  • 最短剩余时间优先调度算法

    最短剩余时间是针对最短进程优先增加了抢占机制的版本。在这种情况下,进程调度总是选择预期剩余时间最短的进程。当一个进程加入到就绪队列时,他可能比当前运行的进程具有更短的剩余时间,因此只要新进程就绪,调度程序就能可能抢占当前正在运行的进程。像最短进程优先一样,调度程序正在执行选择函数是必须有关于处理时间的估计,并且存在长进程饥饿的危险。

  • 高响应比优先调度算法

    高响应比优先调度算法主要用于作业调度,该算法是对先来先服务调度算法和短作业优先调度算法的一种综合平衡,同时考虑每个作业的等待时间和估计的运行时间。在每次进行作业调度时,先计算后备作业队列中每个作业的响应比,从中选出响应比最高的作业投入运行。

  • 优先级调度算法
    优先级调度算法每次从后备作业队列中选择优先级最髙的一个或几个作业,将它们调入内存,分配必要的资源,创建进程并放入就绪队列。在进程调度中,优先级调度算法每次从就绪队列中选择优先级最高的进程,将处理机分配给它,使之投入运行。

  • 多级反馈队列调度算法

    • 设置多级就绪队列,各级队列优先级从高到低,时间片从小到大
    • 新进程到达时先进入第1级队列,按FCFS原则排队等待被分配时间片,若用完时间片进程还未结束,则进程进入下一级队列队尾。 如果此时已经是在最下级的队列,则重新放回该队列队尾
    • 只有第 k 级队列为空时,才会为 k+1 级队头的进程分配时间片
  • 多级队列调度算法快手问过

    系统中按进程类型设置多个队列,进程创建成功后插入某个队列

    img

    队列之间可采取固定优先级,或时间片划分
    固定优先级: 高优先级空时低优先级进程才能被调度
    时间片划分: 如三个队列分配时间50%、40%、10%

  1. 什么是死锁?
查看输出

死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。 如下图所示:

img

每位哲学家都在等待自己右边的人放下筷 子,这些哲学家进程都因等待筷子资源而被阻塞。即发生“死锁”

  1. 产生死锁的原因?
查看输出

由于系统中存在一些不可剥夺资源,而当两个或两个以上进程占有自身资源,并请求对方资源时,会导致每个进程都无法向前推进,这就是死锁。

  • 竞争资源
    例如:系统中只有一台打印机,可供进程 A 使用,假定 A 已占用了打印机,若 B 继续要求打印机打印将被阻塞。系统中的资源可以分为两类:
    1. 可剥夺资源:是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺, CPU 和主存均属于可剥夺性资源;
    2. 不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
  • 进程推进顺序不当
    例如:进程 A 和 进程 B 互相等待对方的数据。
  1. 死锁产生的必要条件?
查看输出

产生死锁必须同时满足一下四个条件,只要其中任一条件不成立,死锁就不会发生。

  1. 互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁(如哲学家的筷子、打印机设备)。 像内存、扬声器这样可以同时让多个进程使用的资源是不会导致死锁的(因为进程不用阻塞等待 这种资源)。
  2. 不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。
  3. 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放。
  4. 循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。

注意!发生死锁时一定有循环等待,但是发生循环等待时未必死锁(循环等待是死锁的必要不充分条件)

  1. 解决死锁的基本方法?
查看输出
  • 预防死锁。破坏死锁产生的四个必要条件中的一个或几个。
  • 避免死锁。用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)
  • 死锁的检测和解除。允许死锁的发生,不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁。
  1. 怎么预防死锁?
查看输出
  1. 破坏互斥条件

如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态。比如: SPOOLing技术。

  1. 破坏不剥夺条件

不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。

方案一:当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。

方案二:当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)

  1. 破坏请求和保持条件

可以采用静态分配方法,即进程在运行前一次申请完它所需要的全部资源,在它的资源未满足前, 不让它投入运行。一旦投入运行后,这些资源就一直归它所有,该进程就不会再请求别的任何资源 了。

  1. 破坏循环等待条件

可采用顺序资源分配法。首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源, 同类资源(即编号相同的资源)一次申请完。

  1. 怎么避免死锁?
查看输出

银行家算法步骤:
① 检查此次申请是否超过了之前声明的最大需求数
② 检查此时系统剩余的可用资源是否还能满足这次请求
③ 试探着分配,更改各数据结构
④ 用安全性算法检查此次分配是否会导致系统进入不安全状态

安全性算法步骤:
检查当前的剩余可用资源是否能满足某个进程的最大需求,如果可以,就把该进程加入安全序列,
并把该进程持有的资源全部回收。
不断重复上述过程,看最终是否能让所有进程都加入安全序列。

  1. 怎么解除死锁?
查看输出

检测死锁:资源分配图,如果如果某时刻系统的资源分配图是不可完全简化的,那么此时系统死锁

解除死锁:

  1. 资源剥夺法。挂起(暂时放到外存上)某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿。
  2. 撤销进程法(或称终止进程法)。强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。这种方式的优点是实现简单,但所付出的代价可能会很大。因为有些进程可能已经运行 了很长时间,已经接近结束了,一旦被终止可谓功亏一篑,以后还得从头再来。
  3. 进程回退法。让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点。
  1. 什么是缓冲区溢出?有什么危害?
查看输出

缓冲区溢出(Buffer Overflow)是一种常见的安全漏洞,主要出现在使用低级编程语言如C或C++时。在这些语言中,程序员需要手动管理内存,包括定义存储数据的缓冲区大小。如果程序在处理输入时没有恰当地检查这些输入的长度,就可能发生缓冲区溢出。以下是对缓冲区溢出和它导致的危害的详细解释:

缓冲区的作用

缓冲区在计算机程序中通常用于临时存储数据,比如从一个应用程序传输到另一个应用程序的数据。例如,网络数据在被处理之前可能首先被存放在缓冲区中。

缓冲区溢出的原理

  1. 定义缓冲区:程序定义一个固定大小的缓冲区来存储例如用户输入的数据。
  2. 超出容量:如果输入的数据超过了缓冲区预设的大小,超出的部分将会继续写入相邻的内存地址。
  3. 覆盖内存:这种超出部分的数据可以覆盖缓冲区附近的其他重要的内存数据,包括程序的控制信息。

导致的危害

  1. 程序崩溃和拒绝服务:如果被覆盖的数据包括程序执行的关键控制数据(例如返回地址),这可能导致程序行为异常、崩溃或停止响应,从而导致服务不可用或拒绝服务攻击(DoS)。
  2. 恶意代码执行
    • 控制流篡改:攻击者可以故意使输入数据的溢出部分包含特定的值,用来覆盖程序内存中的执行流程控制数据(如返回地址),使程序跳转到攻击者控制的代码段。
    • 恶意代码载入:进一步地,攻击者可能将恶意代码嵌入输入数据中,通过控制程序执行流使程序执行这些恶意代码。

防御措施

  1. 输入验证:确保所有输入都经过验证,不超过预定的缓冲区大小。
  2. 边界检查:程序中对所有缓冲区操作添加边界检查逻辑,防止数据写入超过缓冲区的部分。
  3. 使用安全函数:在可能的情况下,使用如strncpy()代替strcpy()等安全函数,这些函数可以限制写入缓冲区的数据量。
  4. 编译器保护:利用现代编译器提供的安全增强功能,如堆栈保护、地址空间布局随机化(ASLR)和非执行页(NX),来增加攻击的难度。
  5. 代码审计和工具:对源代码进行安全审计,并使用静态和动态分析工具检测潜在的缓冲区溢出问题。

理解缓冲区溢出的工作原理和它的危害,对于开发安全的应用程序是至关重要的。这不仅涉及编写安全的代码,还涉及设计整个系统的安全架构。

  1. 分页与分段的区别?
查看输出
  1. 段是信息的逻辑单位,它是根据用户的需要划分的,因此段对用户是可见的 ;页是信息的物理单位,是为了管理主存的方便而划分的,对用户是透明的;
  2. 段的大小不固定,有它所完成的功能决定;页大大小固定,由系统决定;
  3. 段向用户提供二维地址空间;页向用户提供的是一维地址空间;
  4. 段是信息的逻辑单位,便于存储保护和信息的共享,页的保护和共享受到限制。
  1. 物理地址、逻辑地址、虚拟内存的概念
查看输出
  1. 物理地址:它是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址从主存中存
    取,是内存单元真正的地址。
  2. 逻辑地址:是指计算机用户看到的地址。例如:当创建一个长度为 100 的整型数组时,操作系统返回一个逻
    辑上的连续空间:指针指向数组第一个元素的内存地址。由于整型元素的大小为 4 个字节,故第二个元素的
    地址时起始地址加 4,以此类推。事实上,逻辑地址并不一定是元素存储的真实地址,即数组元素的物理地址
    (在内存条中所处的位置),并非是连续的,只是操作系统通过地址映射,将逻辑地址映射成连续的,这样更符合人们的直观思维。
  3. 虚拟内存:是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整
    的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在
    需要时进行数据交换。
  1. 页面置换算法有哪些?
查看输出

请求调页,也称按需调页,即对不在内存中的“页”,当进程执行时要用时才调入,否则有可能到程序结束时也不会
调入。而内存中给页面留的位置是有限的,在内存中以帧为单位放置页面。为了防止请求调页的过程出现过多的内存页面错误(即需要的页面当前不在内存中,需要从硬盘中读数据,也即需要做页面的替换)而使得程序执行效率下降,我们需要设计一些页面置换算法,页面按照这些算法进行相互替换时,可以尽量达到较低的错误率。

常用的页面置换算法如下:

  • 先进先出置换算法(FIFO)
    先进先出,即淘汰最早调入的页面。
  • 最佳置换算法(OPT)
    选未来最远将使用的页淘汰,是一种最优的方案,可以证明缺页数最小。
  • 最近最久未使用(LRU)算法
    即选择最近最久未使用的⻚页面予以淘汰
  • 时钟(Clock)置换算法(Clock)
    时钟置换算法也叫最近未用算法 NRU(Not RecentlyUsed)。该算法为每个页面设置一位访问位,将内存中的所有页面都通过链接指针链成一个循环队列。
  1. 谈谈你对动态链接库和静态链接库的理解?
查看输出

在软件开发中,动态链接库(Dynamic Link Libraries, DLLs)和静态链接库(Static Libraries)是两种常用的代码库实现方式,它们分别代表不同的编译和链接策略。了解它们的区别对于优化应用程序的性能、内存使用和分发策略至关重要。

静态链接库(Static Libraries)

静态链接库通常是以.lib(在Windows中)或.a(在Unix-like系统中)文件的形式存在。它们包含了可以直接集成到应用程序中的预编译代码。

  • 链接方式:在编译阶段,静态库中的代码被完整地复制到最终的可执行文件中。
  • 优点
    • 简单性:因为所有需要的代码都包含在单个可执行文件中,所以部署和分发应用程序相对简单。
    • 独立性:应用程序不依赖于外部库文件,运行时不需要额外的库。
  • 缺点
    • 文件大小:静态链接可以增加最终可执行文件的大小,因为每个程序都包含了它所需的整个库的副本。
    • 更新问题:如果库更新,整个应用程序需要重新编译和发布。

动态链接库(Dynamic Link Libraries)

动态链接库在Windows系统中通常以.dll文件形式存在,在Unix-like系统中则为.so(共享对象)文件。它们在运行时被加载到内存中,多个程序可以共享同一份物理内存中的库代码。

  • 链接方式:动态库在程序运行时被加载,程序只包含需要链接和使用这些库的引用。
  • 优点
    • 内存效率:多个运行中的应用程序可以共享同一份动态库的单一物理副本,减少了内存占用。
    • 易于更新:更新库文件后,不需要重新编译使用该库的应用程序,可以简化维护和更新过程。
  • 缺点
    • 依赖性:程序依赖于外部的.dll或.so文件,缺失或兼容性问题可能导致程序无法运行。
    • 启动时间:加载动态库可能会稍微延长程序的启动时间。
    • 版本冲突:可能会遇到“地狱依赖”问题,即不同的应用程序需要不同版本的同一动态库。

总结

选择静态链接库还是动态链接库,取决于具体的应用需求和分发策略。静态库适合那些需要简单部署、独立运行的小型应用程序。动态库则适合那些内存使用效率至关重要,或者需要频繁更新库文件而不重新部署整个应用程序的情况。在设计大型应用或系统时,合理地选择链接类型对于性能优化和资源管理非常关键。

  1. 外中断和异常有什么区别?
查看输出

外中断是指由 CPU 执行指令以外的事件引起,如 I/O 完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。

异常时由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。

  1. 一个程序从开始运行到结束的完整过程,你能说出来多少?
查看输出

四个过程:

(1)预编译

要处理源代码文件中的以“#”开头的预编译指令。处理规则见下
1、删除所有的#define,展开所有的宏定义。

2、处理所有的条件预编译指令,如“#if”、 “#endif”、 “#ifdef”、 “#elif”和“#else”。

3、处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他“#include”。

4、删除所有的注释, “//”和“/**/”。

5、保留所有的#pragma编译器指令,编译器需要用到他们,如: #pragma once 是为了防止有硬件被重复引用。

6、添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是能够显示行号。

(2)编译

把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。

1、词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。

2、语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一
种以表达式为节点的树。

3、语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。

4、优化:源代码级别的一个优化过程。

5、目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。

6、目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。

(3)汇编

将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Linux下)、 xxx.obj(Windows下)。

(4)链接

将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接:

1、静态链接: 函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器
从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本; 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。

运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。

2、动态链接: 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一
起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。

共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本;

更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。

性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。

  1. 介绍一下几种典型的锁?
查看输出

在操作系统中,锁是一种同步机制,用于控制多个进程或线程对共享资源(如数据结构、文件、设备等)的访问。锁的基本目的是确保在任何时刻,只有一个线程或进程可以访问临界区(即需要互斥访问的代码段)。这有助于防止数据竞争和保证数据一致性。

锁的基本类型和作用:

  1. 互斥锁(Mutexes)

    • 互斥锁保证同一时间内只有一个线程可以访问某个特定的资源或代码块。它是最基本的锁类型,用于实现线程或进程的互斥(mutual exclusion)。
    • 当一个线程获得互斥锁并进入临界区时,其他试图进入该临界区的线程将被阻塞,直到锁被释放。
  2. 读写锁(Read-Write Locks)

    • 读写锁允许多个读操作同时进行,但在写操作执行时,它会阻止任何其他的读或写操作。这种锁适用于读多写少的情况,可以提高并发性。
    • 读写锁可以提升性能,因为它允许多个线程同时读取数据,只在写入数据时才需要排他访问。
  3. 自旋锁(Spinlocks)

    • 自旋锁是一种忙等锁,当线程尝试获得锁而锁已被占用时,线程会在一个循环中不断检查锁的状态。这种锁不会使线程进入睡眠状态,因此在等待锁的时间非常短的情况下是有效的。
    • 自旋锁适用于锁持有时间极短的情况,因为它避免了线程从运行态到等待态的转换开销。
  4. 信号量(Semaphores)

    • 信号量是一种更灵活的同步工具,可以看作是可用资源的计数器。信号量通常用于控制对一组相同资源的访问。
    • 信号量主要用于实现资源的计数限制,它允许多个线程或进程根据可用资源数量并发访问资源。

锁的应用

锁在操作系统的许多领域都有应用,包括:

  • 文件系统:防止多个进程同时写入同一文件。
  • 数据库管理:保证事务的原子性和一致性。
  • 网络通信:确保消息的有序处理。
  • 内存管理:保护对共享内存的访问。

锁的挑战

尽管锁是并发控制的基本工具,但它们也引入了一些挑战,例如:

  • 死锁:多个进程或线程互相等待对方持有的锁,导致所有进程都无法向前执行。
  • 活锁:进程或线程不断改变状态,尝试获取锁,但无一成功,导致系统性能下降。
  • 饥饿:一个或多个线程由于竞争条件长时间无法获得所需的锁。

有效的锁策略需要仔细设计,以优化性能并避免死锁、活锁和饥饿等问题。选择正确的锁类型和策略对于建立高效、稳定和可靠的系统至关重要。

  1. 什么是用户态和内核态
查看输出

用户态和内核态是操作系统的两种运行状态。

  • 内核态 :处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之
    为内核态。
  • 用户态 :处于用户态的 CPU 只能受限的访问内存,并且不允许访问外围设备,用户态下的 CPU 不允许独
    占,也就是说 CPU 能够被其他程序获取。

那么为什么要有用户态和内核态呢?
这个主要是访问能力的限制的考量,计算机中有一些比较危险的操作,比如设置时钟、内存清理,这些都需要在内核态下完成,如果随意进行这些操作,那你的系统得崩溃多少次。

  1. 用户态和内核态是如何切换的?
查看输出

所有的用户进程都是运行在用户态的,但是我们上面也说了,用户程序的访问能力有限,一些比较重要的比如从硬盘读取数据,从键盘获取数据的操作则是内核态才能做的事情,而这些数据却又对用户程序来说非常重要。所以就涉及到两种模式下的转换,即用户态 -> 内核态 -> 用户态,而唯一能够做这些操作的只有系统调用 ,而能够执行系统调用的就只有操作系统 。

一般用户态 -> 内核态的转换我们都称之为 trap 进内核,也被称之为 陷阱指令(trap instruction) 。

他们的工作流程如下:

  1. 首先用户程序会调用 glibc 库, glibc 是一个标准库,同时也是一套核心库,库中定义了很多关键 API。

    glibc 库知道针对不同体系结构调用 系统调用 的正确方法,它会根据体系结构应用程序的二进制接口设置用户
    进程传递的参数,来准备系统调用。

  2. 然后, glibc 库调用 软件中断指令(SWI) ,这个指令通过更新 CPSR 寄存器将模式改为超级用户模式,然后跳
    转到地址 0x08 处。

  3. 到目前为止,整个过程仍处于用户态下,在执行 SWI 指令后,允许进程执行内核代码, MMU 现在允许内核
    虚拟内存访问

  4. 从地址 0x08 开始,进程执行加载并跳转到中断处理程序,这个程序就是 ARM 中的 vector_swi() 。

  5. 在 vector_swi() 处,从 SWI 指令中提取系统调用号 SCNO,然后使用 SCNO 作为系统调用表 sys_call_table 的索引,调转到系统调用函数。

  6. 执行系统调用完成后,将还原用户模式寄存器,然后再以用户模式执行。

  1. 进程终止的方式
查看输出

在操作系统中,进程终止是正常运行周期的一部分,或是由于异常情况而导致的结束。进程可以通过多种方式终止,这些方式可以归纳为以下几类:

1. 正常退出

  • 自主终止:进程执行完毕程序中的所有指令后,通过执行退出系统调用(如C语言中的exit()函数)正常终止。在这种情况下,进程通常会返回一个状态码给其父进程,指示程序的结束状态。

2. 错误退出

  • 运行时错误:如果进程在执行过程中遇到无法恢复的错误(例如除零错误、访问违规等),它可能会导致进程异常终止。这种终止通常由操作系统强制执行,进程也可能返回一个错误码。

3. 异常终止

  • 信号/中断:进程可以由于接收到一个信号(如UNIX/Linux系统中的SIGKILL, SIGTERM等)而终止。信号可以由其他进程发出,用于指示一个进程应该终止。例如,操作系统命令行中的kill命令常用于发送终止信号给进程。

4. 被其他进程终止

  • 父进程请求:在某些系统中,一个父进程可以请求操作系统终止其一个或多个子进程。这可能是因为父进程正在关闭,且不希望其子进程继续运行。

5. 资源耗尽

  • 资源限制:操作系统可能因为系统资源耗尽(如内存不足)而终止一些进程。这是系统尝试释放资源,防止更多的系统级问题发生的一种方式。

6. 关机

  • 系统关闭:当操作系统关闭或重启时,所有进程都将被终止。操作系统通常会向运行中的进程发送终止信号,允许它们有序地关闭。

7. 死锁恢复

  • 死锁终止:在检测到死锁的情况下,操作系统可能会选择终止一个或多个进程来解锁死锁状态。这通常是最后的手段,当其他死锁恢复策略(如资源抢占)无效时采用。

8. 监控或安全

  • 安全策略执行:安全监控软件或操作系统可能因为检测到进程的异常行为(如疑似病毒活动或违反安全策略的行为)而终止进程。

每种终止方式都有其特定的用途和背景,了解它们对于系统管理员、操作系统开发者以及应用程序开发者都非常重要,可以帮助他们设计更健壮、安全且可靠的软件系统。

  1. 守护进程、僵尸进程和孤儿进程
查看输出

在操作系统中,特别是在类 Unix 系统中,守护进程、僵尸进程和孤儿进程是三种特殊类型的进程,它们具有特定的行为和特征。这些进程的管理对于系统的健康运行至关重要。下面是对这些进程类型的详细解释:

1. 守护进程(Daemon Process)

守护进程是在后台运行的进程,它们不与任何终端设备关联,主要用于处理系统级任务,如日志记录、系统监控、调度等。守护进程通常在系统启动时启动,并持续运行直到系统关闭。

  • 特征:守护进程没有控制终端,它们的父进程通常是 init 进程(进程号为1的进程),这使它们在用户退出系统时继续运行。
  • 目的:提供或支持一些后台服务,通常不直接与用户交互。

2. 僵尸进程(Zombie Process)

僵尸进程是已经完成执行并退出的进程,但其在操作系统进程表中的一个记录仍然存在。这种情况发生在子进程已经结束,但其父进程尚未通过调用 wait() 系统调用来读取子进程的退出状态。

  • 特征:僵尸进程不占用除进程表外的任何资源,它们不执行任何代码,但在进程表中占有一个位置,直到被父进程清理。
  • 问题:如果父进程永远不调用 wait(),僵尸进程将一直存在,可能导致进程表资源耗尽。

如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。

设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进
程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵⼫尸状态)。


3. 孤儿进程(Orphan Process)

孤儿进程是指父进程结束或异常退出,而其子进程仍在运行的那些进程。这些子进程的父进程不再存在,因此它们被 init 进程(或其他特殊进程,如systemd)收养。

  • 特征:孤儿进程由 init 进程接管,init 进程将定期调用 wait() 来清理任何已结束的孤儿子进程,确保不会留下僵尸进程。
  • 目的:避免在父进程终止后留下未管理的运行进程,保证系统的稳定和清洁。

系统的影响和管理

  • 守护进程是有益的,通常是系统设计的一部分,需要适当监控和维护。
  • 僵尸进程孤儿进程可能是资源管理不当的标志。系统管理员需要确保这些进程被适当处理,避免资源浪费和潜在的系统不稳定。

了解这些进程类型及其对系统的影响可以帮助开发者和系统管理员优化程序行为,预防潜在的问题,并维护操作系统的性能和稳定性。

  1. 如何避免僵尸进程?
查看输出

1. 父进程调用 wait()waitpid()

父进程可以通过调用 wait()waitpid() 函数来阻塞自身,直到一个或多个子进程结束。这样父进程可以获取子进程的终止状态,同时操作系统会清理掉子进程,防止它成为僵尸进程。

2. 使用信号处理 SIGCHLD

当子进程结束时,父进程会收到 SIGCHLD 信号。父进程可以设定一个信号处理器来自动处理这个信号,通常处理器会调用 wait() 来清理终止的子进程,从而防止产生僵尸进程。

3. 忽略 SIGCHLD 信号

简单地忽略 SIGCHLD 信号也是一个选项。在某些系统中,如果 SIGCHLD 信号被设置为忽略,子进程结束时将不会变成僵尸进程;操作系统会自动回收它们。但这种方式可能不适合需要从子进程获取退出状态的应用。

4. 双重 fork() 方法

这种方法涉及父进程首先 fork() 一个子进程,该子进程随即 fork() 一个孙进程然后立即退出。这样一来,孙进程成为了孤儿进程,通常会被系统的 init 进程收养,init 进程将负责清理孙进程,防止其成为僵尸进程。这种方法尤其适用于守护进程的创建。

5. 定期清理

在一些长时间运行的父进程中,可以定期执行 wait() 调用(不必阻塞等待特定子进程终止),以清理所有已终止的子进程,这种方式有助于管理和回收任何潜在的僵尸进程。

这些策略每种都有其适用场景,选择哪一种取决于具体的应用需求和系统环境。通过有效管理子进程的生命周期,可以确保系统的健康运行和资源的高效使用。

  1. 常见的内存分配内存错误
查看输出

(1)内存分配未成功,却使用了它

编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc或new来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。

(2)内存分配虽然成功,但是尚未初始化就引用它

犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

(3)内存分配成功并且已经初始化,但操作越过了内存的边界。

例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

(4)忘记了释放内存,造成内存泄露。

含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然挂掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。

(5)释放了内存却继续使用它。常见于以下有三种情况

程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。

  1. 内存交换中,被换出的进程保存在哪里?
查看输出

保存在磁盘中,也就是外存中。具有对换功能的操作系统中,通常把磁盘空间分为文件区和对换区两部分。文件区主要用于存放文件,主要追求存储空间的利用率,因此对文件区空间的管理采用离散分配方式; 对换区空间只占磁盘空间的小部分,被换出的进程数据就存放在对换区。由于对换的速度直接影响到系统的整体速度,因此对换区空间的管理主要追求换入换出速度,因此通常对换区采用连续分配方式(学过文件管理章节后即可理解)。总之,对换区的I/O速度比文件区的更快。

  1. 原子操作的是如何实现的
查看输出

在操作系统中,原子操作是那些在执行完毕前不能被中断的操作。这种操作是系统编程中的一个基本概念,特别是在涉及并发编程时。原子操作的执行必须是连续的,无法在中途被其他任务或事件打断。这对于保证数据的一致性和防止竞争条件至关重要。操作系统通常通过以下几种方式实现原子操作:

1. 硬件支持

许多现代CPU提供了特殊的硬件指令,用来保证操作的原子性。这些指令可以直接在硬件级别上锁定总线或内存地址,执行所需的操作,然后释放锁定,整个过程对其他线程或进程不可见。

  • Test-and-Set: 一种常用的硬件指令,用于实现互斥锁。它检查一个值,改变它,并返回原始值,整个过程原子地执行。
  • Compare-and-Swap (CAS): 这种指令检查一个位置的值,如果符合预期,则更新为新的值,这三个步骤作为一个原子操作执行。

2. 中断禁用

在单核处理器系统中,操作系统可以通过禁用中断来创建原子操作环境。当中断被禁用时,当前代码段无法被中断,因此可以安全地执行需要原子性的操作。

  • 这种方法在多核处理器系统中效果不佳,因为其他核心上的进程可以并发运行。

3. 互斥锁和自旋锁

互斥锁(如Mutex)和自旋锁是操作系统层面提供的同步机制,它们在用户空间和内核空间中广泛使用,以保护临界区和共享资源。

  • 互斥锁:提供了一种方法,让只有一个线程可以进入关键的代码段。
  • 自旋锁:与互斥锁类似,但是在等待锁的释放时,它会在那里忙等待而不是睡眠。

4. 事务内存

事务内存是一种同步多线程程序的方法,允许代码片段以原子方式执行,这类似于数据库事务。它是通过一系列的内存操作,如果没有冲突,这些操作就会被提交,如果有冲突,操作会被回滚并重试。

  • 事务内存可以是硬件实现(如Intel的TSX指令集)或软件实现。

5. 序列点

操作系统的调度器可以在特定的序列点(如系统调用、进程上下文切换等)确保操作的原子性。这是通过在这些点确保不会发生并发的方式来管理。

这些原子操作的实现方法各有优劣,并根据应用的特定需求和环境进行选择。在设计并发系统时,合理地选择和使用这些原子操作机制是确保数据一致性、系统稳定性和性能的关键。

  1. 抖动你知道是什么吗?它也叫颠簸现象
查看输出

刚刚换出的页面马上又要换入内存,刚刚换入的页面马上又要换出外存,这种频繁的页面调度行为称为抖动,或颠簸。产生抖动的主要原因是进程频繁访问的页面数目高于可用的物理块数(分配给进程的物理块不够)

为进程分配的物理块太少,会使进程发生抖动现象。为进程分配的物理块太多,又会降低系统整体的并发度,降低某些资源的利用率。为了研究为应该为每个进程分配多少个物理块, Denning 提出了进程“工作集” 的概念。

工作集:指在某段时间间隔里,进程实际访问页面的集合。





数据库

  1. 请说下你对 MySQL 架构的了解?
查看解析

1). 连接层
最上层是一些客户端和链接服务,包含本地sock 通信和大多数基于客户端/服务端工具实现的类似于TCP/IP的通信。主要完成一些类似于连接处理、授权认证、及相关的安全方案。在该层上引入了线程池的概念,为通过认证安全接入的客户端提供线程。同样在该层上可以实现基于SSL的安全链接。服务器也会为安全接入的每个客户端验证它所具有的操作权限。

2). 服务层
第二层架构主要完成大多数的核心服务功能,如SQL接口,并完成缓存的查询,SQL的分析和优化,部分内置函数的执行。所有跨存储引擎的功能也在这一层实现,如 过程、函数等。在该层,服务器会解析查询并创建相应的内部解析树,并对其完成相应的优化如确定表的查询的顺序,是否利用索引等,最后生成相应的执行操作。如果是select语句,服务器还会查询内部的缓存,如果缓存空间足够大,这样在解决大量读操作的环境中能够很好的提升系统的性能。

3). 引擎层
存储引擎层, 存储引擎真正的负责了MySQL中数据的存储和提取,服务器通过API和存储引擎进行通信。不同的存储引擎具有不同的功能,这样我们可以根据自己的需要,来选取合适的存储引擎。数据库中的索引是在存储引擎层实现的。

4). 存储层
数据存储层, 主要是将数据(如: redolog、undolog、数据、索引、二进制日志、错误日志、查询日志、慢查询日志等)存储在文件系统之上,并完成与存储引擎的交互。和其他数据库相比,MySQL有点与众不同,它的架构可以在多种不同场景中应用并发挥良好作用。主要体现在存储引擎上,插件式的存储引擎架构,将查询处理和其他的系统任务以及数据的存储提取分离。这种架构可以根据业务的需求和实际需要选择合适的存储引擎。

  1. 一条 SQL 语句在数据库框架中的执行流程?
查看解析
  1. 应用程序把查询 SQL 语句发送给服务器端执行;
  2. 查询缓存,如果查询缓存是打开的,服务器在接收到查询请求后,并不会直接去数据库查询,而是在数据库的查询缓存中找是否有相对应的查询数据,如果存在,则直接返回给客户端。只有缓存不存在时,才会进行下面的操作;
  3. 查询优化处理,生成执行计划。这个阶段主要包括解析 SQL、预处理、优化 SQL 执行计划;
  4. MySQL 根据相应的执行计划完成整个查询;
  5. 将查询结果返回给客户端。

详细过程可以看这篇博客https://blog.csdn.net/pcwl1206/article/details/86137408

  1. 数据库的三范式是什么?
查看解析
  1. 第一范式(1NF):属性不可分割,即每个属性都是不可分割的原子项。(实体的属性即表中的列)

  2. 第二范式(2NF):满足第一范式;且不存在部分依赖,即非主属性必须完全依赖于主属性。(主属性即主键;完全依赖是针对于联合主键的情况,非主键列不能只依赖于主键的一部分)

  3. 第三范式(3NF):满足第二范式;且不存在传递依赖,即非主属性不能与非主属性之间有依赖关系,非主属性必须直接依赖于主属性,不能间接依赖主属性。(A -> B, B ->C, A -> C)

原文链接:https://blog.csdn.net/ddzzz_/article/details/119277573

  1. char 和 varchar 的区别?
查看解析
  • char(n) : 固定长度类型,比如:订阅 char(10),当你输入"abc"三个字符的时候,它们占的空间还是 10 个字节,其他 7 个是空字节。
    优点:效率高;
    缺点:占用空间;适用场景:存储密码的 md5 值,固定长度的,使用 char 非常合适。
  • varchar(n) : 可变长度,存储的值是每个值占用的字节再加上一个用来记录其长度的字节的长度。

所以,从空间上考虑 varcahr 比较合适;从效率上考虑 char 比较合适,二者使用需要权衡。

  1. varchar(10) 和 varchar(20) 的区别?
查看解析

在 MySQL 中,VARCHAR 是一个可变长度的字符串数据类型,用于存储字符串。VARCHAR 数据类型后的数字表示该字段可以容纳的字符的最大长度。例如,VARCHAR(10) 表示字段可以存储最多 10 个字符的字符串,而 VARCHAR(20) 表示字段可以存储最多 20 个字符的字符串。

以下是 VARCHAR(10)VARCHAR(20) 在几个方面的区别:

  1. 存储容量

    • VARCHAR(10) 最多可以存储 10 个字符。
    • VARCHAR(20) 最多可以存储 20 个字符。
    • 如果存储的字符数量超过字段指定的长度,超出的部分会被截断。
  2. 存储空间使用

    • VARCHAR 类型的实际存储需求取决于存储的具体内容。除了字符数据外,还需要额外的空间来存储字符串的长度信息。
    • 对于 VARCHAR(255) 或更小的字段,每个字段的记录前使用 1 字节来存储长度信息。
    • 如果超过 255 个字符,则使用 2 字节来存储长度信息。
    • 因此,VARCHAR(10)VARCHAR(20) 都会在每条记录前使用 1 字节来存储长度信息。
  3. 性能考虑

    • 在大多数情况下,VARCHAR(10)VARCHAR(20) 在性能上的差异非常小。但是,合理地选择 VARCHAR 的长度可以帮助减少数据存储的空间,尤其是在包含大量这种字段的大型表中。
    • 在某些情况下,例如大量的排序操作和索引,更长的字段可能会略微降低性能,因为处理更多的数据需要更多的内存和可能的磁盘 I/O。【这是因为VARCHAR(20) 实际存放的也更长】
  4. 应用场景

    • 选择 VARCHAR(10)VARCHAR(20) 应基于实际需求。例如,如果你知道数据最长不会超过 10 个字符,那么使用 VARCHAR(10) 就足够了。如果可能会存储更长的数据,应选择 VARCHAR(20) 或更长。

综上,主要的区别在于能够存储的最大字符长度和相应的存储需求。在设计数据库时,合理估计字段长度可以提高存储效率和性能。

  1. 谈谈你对索引的理解?
查看解析

索引的出现是为了提高数据的查询效率,就像书的目录一样。一本500页的书,如果你想快速找到其中的某一个知
识点,在不借助目录的情况下,那我估计你可得找一会儿。同样,对于数据库的表而言,索引其实就是它的“目
录”。

同样索引也会带来很多负面影响:创建索引和维护索引需要耗费时间,这个时间随着数据量的增加而增加;索引需要占用物理空间,不光是表需要占用数据空间,每个索引也需要占用物理空间;当对表进行增、删、改、的时候索引也要动态维护,这样就降低了数据的维护速度。

建立索引的原则:

  1. 在最频繁使用的、用以缩小查询范围的字段上建立索引;
  2. 在频繁使用的、需要排序的字段上建立索引。

不适合建立索引的情况:

  1. 对于查询中很少涉及的列或者重复值比较多的列,不宜建立索引;
  2. 对于一些特殊的数据类型,不宜建立索引,比如:文本字段(text)等。
  1. 索引的底层使用的是什么数据结构?
查看解析

索引的数据结构和具体存储引擎的实现有关,,在MySQL中使用较多的索引有 Hash 索引、 B+树索引等。而我们经常使用的 InnoDB 存储引擎的默认索引实现为 B+ 树索引。

  1. 谈谈你对B+树的理解
查看解析

B+ 树是一种广泛使用的索引和数据结构,尤其在数据库和文件系统中非常常见。B+ 树是 B 树的一种变体,优化了对磁盘存储的访问,通过保持树的平衡性来提供高效的查询和维护性能。下面是 B+ 树的一些关键特征和工作原理:

结构特征

  1. 多路搜索树

    • B+ 树是一种多路平衡搜索树,每个节点可以有多个子节点,通常远多于二叉树的两个。
  2. 节点组成

    • 内部节点:只存储键(keys)和指向子节点的指针。内部节点不存储实际数据或记录,这与 B 树有所不同。
    • 叶子节点:包含所有数据记录的实际值及其键值。在 B+ 树中,所有的叶子节点通过指针相连,形成一个有序链表。
  3. 高度平衡

    • 所有的叶子节点都在同一层上,确保了从根节点到任一叶子节点的路径长度相同,这使得所有搜索操作的时间复杂度均匀。

操作

  1. 搜索

    • 搜索操作从根节点开始,通过比较节点内的键与目标键,选择适当的指针进入子节点,这一过程持续到达叶子节点。由于每个叶子节点都在同一层级,搜索效率很高。
  2. 插入

    • 插入新的键值对时,首先搜索到适当的叶子节点,然后在该叶子节点中插入新的数据。如果叶子节点满了,就会分裂成两个节点,并将分裂产生的新键向上推到父节点中。这可能导致上层节点也分裂,甚至达到根节点。
  3. 删除

    • 删除操作首先找到相应的键所在的叶子节点并移除。如果这导致节点下溢(即节点中的键值对数低于最小限度),则可能需要节点合并或重新分配。这种调整可能向上传播,直到满足 B+ 树的平衡要求。

优势

  1. 效率高

    • B+ 树的搜索、插入和删除操作都可以在对数时间内完成,这使其非常适合处理大量数据。
  2. 磁盘读写优化

    • 由于数据只存在于叶子节点,并且叶子节点是连续的,B+ 树特别适合于磁盘存储,可以减少磁盘读写次数,并提高顺序访问的效率。
  3. 范围查询优化

    • 由于叶子节点是通过指针连成链表的,B+ 树对执行范围查询(如查找某范围内的所有记录)非常有效。

总结来说,B+ 树通过其结构的优化为数据库和文件系统等应用提供了高效的索引机制,特别是在处理大规模数据集时,能够提供高效的查找、插入和删除操作。这种结构支持了高效的磁盘存储及访问,而且改进了大量数据的处理性能。

  1. 为什么 InnoDB 存储引擎选用 B+ 树而不是 B 树呢?
查看解析

用 B+ 树不用 B 树考虑的是 IO 对性能的影响, B 树的每个节点都存储数据,而 B+ 树只有叶子节点才存储数据,所以查找相同数据量的情况下, B 树的高度更高, IO 更频繁。数据库索引是存储在磁盘上的,当数据量大时,就不能把整个索引全部加载到内存了,只能逐一加载每一个磁盘页(对应索引树的节点)。

  1. 谈谈你对 聚簇索引(聚集索引)的理解?
查看解析

聚簇索引是对磁盘上实际数据重新组织以按指定的一个或多个列的值排序的算法。特点是存储数据的顺序和索引顺序一致。一般情况下主键会默认创建聚簇索引,且一张表只允许存在一个聚簇索引

聚簇索引和非聚簇索引的区别:
聚簇索引的叶子节点就是数据节点,而非聚簇索引的叶子节点仍然是索引节点,只不过有指向对应数据块的指针。


非聚集索引的特性

非聚集索引的叶子节点并不直接存储数据记录本身。相反,它们存储索引的键值和一个指向数据行的指针。这个指针的性质取决于表是否有聚集索引:

  1. 在有聚集索引的表中:非聚集索引的叶子节点通常存储指向聚集索引键的指针。由于聚集索引键直接与数据行的物理存储相关联,这意味着通过非聚集索引查找时需要进行“索引跳转”——首先在非聚集索引中查找,然后使用非聚集索引中的指针找到聚集索引键,最后通过聚集索引键定位到实际的数据行。
  2. 在没有聚集索引的表中:表被称为堆表,非聚集索引的叶子节点会存储一个指向数据行的物理位置的指针,因为没有聚集索引来定义数据的物理顺序。
  1. 谈谈你对哈希索引的理解?
查看解析

哈希索引能以 O(1) 时间进行查找,但是失去了有序性。无法用于排序与分组、只支持精确查找,无法用于部分查
找和范围查找。InnoDB 存储引擎有一个特殊的功能叫“自适应哈希索引”,当某个索引值被使用的非常频繁时,会在 B+ 树索引之上再创建一个哈希索引,这样就让 B+Tree 索引具有哈希索引的一些优点,比如:快速的哈希查找。

  1. 谈谈你对覆盖索引的认识?
查看解析

如果一个索引包含了满足查询语句中字段与条件的数据就叫做覆盖索引。具有以下优点:

  1. 索引通常远小于数据行的大小,只读取索引能大大减少数据访问量。
  2. 一些存储引擎(例如: MyISAM)在内存中只缓存索引,而数据依赖于操作系统来缓存。因此,只访问索引可以不使用系统调用(通常比较费时)。
  3. 对于 InnoDB 引擎,若辅助索引能够覆盖查询,则无需访问主索引(聚集索引)。
  1. 索引的分类?
查看解析

从数据结构角度

  1. 树索引 (O(log(n)))
  2. Hash 索引

从物理存储角度

  1. 聚集索引(clustered index)
  2. 非聚集索引(non-clustered index)

从逻辑角度

  1. 普通索引
  2. 唯一索引
  3. 主键索引
  4. 联合索引
  5. 全文索引
  1. 谈谈你对最左前缀原则的理解?
查看解析

MySQL 使用联合索引时,需要满足最左前缀原则。下面举例对其进行说明:

  1. 一个 2 列的索引 (name, age),对 (name)、 (name, age) 上建立了索引;
  2. 一个 3 列的索引 (name, age, sex),对 (name)、 (name, age)、 (name, age, sex) 上建了索引。

1、B+ 树的数据项是复合的数据结构,比如: (name, age, sex) 的时候, B+ 树是按照从左到右的顺序来建立搜索
树的,比如:当(小明, 22, 男)这样的数据来检索的时候, B+ 树会优先比较 name 来确定下一步的所搜方向,如果
name 相同再依次比较 age 和 sex,最后得到检索的数据。但当 (22, 男) 这样没有 name 的数据来的时候, B+ 树
就不知道第一步该查哪个节点,因为建立搜索树的时候 name 就是第一个比较因子,必须要先根据 name 来搜索
才能知道下一步去哪里查询。

2、 当 (小明, 男) 这样的数据来检索时, B+ 树可以用 name 来指定搜索方向,但下一个字段 age 的缺失,所以只能把名字等于小明的数据都找到,然后再匹配性别是男的数据了, 这个是非常重要的性质,即索引的最左匹配特性。


关于最左前缀的补充:

  1. 最左前缀匹配原则会一直向右匹配直到遇到范围查询(>、 <、 between、 like)就停止匹配,比如: a = 1
    and b = 2 and c > 3 and d = 4 如果建立 (a, b, c, d) 顺序的索引, d 是用不到索引的。如果建立 (a, b, d, c) 的
    索引则都可以用到, a、 b、 d 的顺序可以任意调整。

  2. = 和 in 可以乱序,比如: a = 1 and b = 2 and c = 3 建立 (a, b ,c) 索引可以任意顺序, MySQL 的优化器会优化


假设现在有联合索引(idx_a, idx_b, idx_c),查询条件是idx_a > 0 and idx_b = 0 and idx_c = 0时只有idx_a生效,而查询条件是idx_a >= 0 and idx_b = 0 and idx_c = 0,联合索引全部生效?

在解释这个问题时,需要理解联合索引工作机制以及SQL查询优化器如何使用这种索引。对于具有联合索引 (idx_a, idx_b, idx_c) 的表,索引项首先按 idx_a 排序,然后是 idx_b,最后是 idx_c。考虑到这一点,我们来分析以下两种情况:

情况 1: 查询条件是 idx_a > 0 AND idx_b = 0 AND idx_c = 0

在这种情况下,索引在寻找满足 idx_a > 0 的记录时能够高效工作,因为 idx_a 是索引的第一个字段,数据库可以快速跳过所有 idx_a <= 0 的记录。然而,一旦 idx_a > 0 的条件被满足,接下来的条件 idx_b = 0idx_c = 0 不能保证紧接在每个符合 idx_a > 0 条件的记录之后。这是因为每个 idx_a 值可能与多个 idx_bidx_c 的组合相关联。因此,尽管索引还是可以帮助快速定位到 idx_a > 0 的第一个记录,但对于 idx_bidx_c 的过滤则可能需要对每一个符合 idx_a > 0 的记录进行检查。

情况 2: 查询条件是 idx_a >= 0 AND idx_b = 0 AND idx_c = 0

这种情况下,查询可以更高效地使用联合索引。当查询条件从 idx_a > 0 改变为 idx_a >= 0 时,可能看起来区别不大,但实际上这允许了从 idx_a 的最小值开始扫描索引。因为所有三个字段的具体值都被指定了(idx_a >= 0, idx_b = 0, idx_c = 0),查询优化器可以更精确地使用索引来定位到第一个完全匹配所有三个条件的记录。从这一点开始,由于索引的顺序特性,数据库可以连续地访问所有匹配的记录,直到 idx_a 的值不满足条件为止。因此,在这种情况下,联合索引的所有部分都能被有效利用。

为什么第二种情况索引效率更高?

关键在于查询条件提供的信息量和范围限制。>= 对应于一个从某一明确点开始到尽可能远的范围,而条件 idx_b = 0 AND idx_c = 0 则精确匹配索引的下一个和后一个层级,这允许数据库系统精确地利用索引路径来过滤数据。相反,当 idx_a > 0 时,尽管可以确定起始点,但由于 idx_a 的值可能有多个有效载荷,idx_bidx_c 的过滤需要在更多的 idx_a 值中进行,这降低了索引的过滤效率。

因此,联合索引的使用效率高低很大程度上取决于查询条件与索引顺序的匹配程度,以及这些条件如何限制数据扫描的范围。

  1. 怎么知道创建的索引有没有被使用到?或者说怎么才可以知道这条语句运行很慢的原因?
查看解析

使用 Explain 命令来查看语句的执行计划, MySQL 在执行某个语句之前,会将该语句过一遍查询优化器,之后会
拿到对语句的分析,也就是执行计划,其中包含了许多信息。可以通过其中和索引有关的信息来分析是否命中了索引,例如: possilbe_key、 key、 key_len 等字段,分别说明了此语句可能会使用的索引、实际使用的索引以及使用的索引长度。

  1. 什么情况下索引会失效?即查询不走索引?
查看解析

下面列举几种不走索引的 SQL 语句:
1、索引列参与表达式计算:

1
SELECT 'sname' FROM 'stu' WHERE 'age' + 10 = 30;

2、 函数运算:

1
SELECT 'sname' FROM 'stu' WHERE LEFT('date',4) < 1990;

3、 %词语%–模糊查询:

1
2
3
SELECT * FROM 'manong' WHERE `uname` LIKE '码农%' -- 走索引
SELECT * FROM 'manong' WHERE `uname` LIKE "%码农%" -- 不走索引
-- 不是因为单引号和双引号,而是因为%出现在了前面

4、 字符串与数字比较不走索引:

1
2
3
4
CREATE TABLE 'a' ('a' char(10));

EXPLAIN SELECT * FROM 'a' WHERE 'a'= "1" -- 走索引
EXPLAIN SELECT * FROM 'a'WHERE 'a'= 1 -- 不走索引,同样也是使⽤了函数运算

5、 查询条件中有 or ,即使其中有条件带索引也不会使用。换言之,就是要求使用的所有字段,都必须建立索
引:

1
select * from dept where dname='xxx' or loc='xx' or deptno = 45;

6、正则表达式不使用索引。

7、 MySQL 内部优化器会对 SQL 语句进行优化,如果优化器估计使用全表扫描要比使用索引快,则不使用索引。

  1. 查询性能的优化方法?
查看解析

减少请求的数据量

  1. 只返回必要的列:最好不要使用 SELECT * 语句。
  2. 只返回必要的行:使用 LIMIT 语句来限制返回的数据。
  3. 缓存重复查询的数据:使用缓存可以避免在数据库中进行查询,特别在要查询的数据经常被重复查询时,缓存
    带来的查询性能提升将会是非常明显的。

减少服务器端扫描的行数

  1. 最有效的方式是使用索引来覆盖查询
  1. InnoDB 和 MyISAM 的比较?
查看解析
  1. 事务: MyISAM不支持事务, InnoDB支持事务;
  2. 全文索引: MyISAM 支持全文索引, InnoDB 5.6 之前不支持全文索引;
  3. 关于 count(): MyISAM会直接存储总行数, InnoDB 则不会,需要按行扫描。意思就是对于 select count()
    from table; 如果数据量大, MyISAM 会瞬间返回,而 InnoDB 则会一行扫描;
  4. 外键: MyISAM 不支持外键, InnoDB 支持外键;
  5. 锁: MyISAM 只支持表锁, InnoDB 可以支持行锁。
  1. 谈谈你对水平切分和垂直切分的理解?
查看解析
  • 水平切分
    水平切分是将同一个表中的记录拆分到多个结构相同的表中。当一个表的数据不断增多时,水平切分是必然的选择,它可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。
  • 垂直切分
    垂直切分是将一张表按列切分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直切分将经常被使用的列和不经常被使用的列切分到不同的表中。例如:将原来的电商数据库垂直切分成商品数据库、用户数据库

垂直切分(Vertical Partitioning)

垂直切分是将一个数据库表按照列进行分割,将不同的列存储在不同的表或数据库中。这种方式通常用于减少单个表的复杂性,提高查询性能,并优化数据的存储。

例子:

假设有一个名为 Users 的表,包含如下列:UserID, Username, Password, Email, LastLogin, ProfileDescription

在垂直切分的情况下,可以将 Users 表切分为两个表:

  • UserAuth 表,包含用户认证相关的信息:UserID, Username, Password, Email
  • UserProfile 表,包含用户的个人资料信息:UserID, LastLogin, ProfileDescription

这样,当只需要验证用户登录时,只需查询 UserAuth 表,而不需要加载用户的个人资料信息,这可以提高认证过程的效率。同样,当需要查看或更新用户的个人资料时,只需处理 UserProfile 表。


水平切分(Horizontal Partitioning)

水平切分是按行将数据分布到多个表或数据库中。每个分片包含表的全部列,但只包含部分行。这种方式用于处理大量数据,使得数据管理更加分散和易于扩展。

例子:

假设有一个名为 Orders 的表,存储电子商务网站的订单数据,表中有数百万条记录。可以根据订单的地理位置、时间或其他属性进行水平切分。

例如,根据地理位置,可以将 Orders 表分为三个分片:

  • Orders_NorthAmerica
  • Orders_Europe
  • Orders_Asia

每个表存储相应地区的订单数据。这样,查询特定地区的订单时,查询将被限制在较小的数据集上,从而提高查询效率和响应速度。

  1. 主从复制中涉及到哪三个线程?

  2. 主从同步的延迟原因及解决办法?

  3. 谈谈你对数据库读写分离的理解?

查看解析

读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。

读写分离能提高性能的原因在于

  1. 主从服务器负责各自的读和写,极大程度缓解了锁的争用;
  2. 从服务器可以使用 MyISAM,提升查询性能以及节约系统开销;
  3. 增加冗余,提高可用性。
  1. 请你描述下事务的特性?
查看解析
  • 原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
  • 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
  • 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
  • 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。

上述就是事务的四大特性,简称ACID。

  1. 谈谈你对事务隔离级别的理解?
查看解析

为了处理这些问题,SQL标准定义了四个隔离级别,每个级别都以牺牲一定的并发性能为代价,提供了不同程度的隔离保护:

  1. 读未提交 (Read Uncommitted):最低的隔离级别,允许读取未提交的数据变更,可能会导致脏读、不可重复读和幻读。
  2. 读已提交 (Read Committed):保证读取的数据是已经被提交的。这可以避免脏读,但不可重复读和幻读仍然可能发生。
  3. 可重复读 (Repeatable Read):保证在同一个事务内的查询可以多次执行而返回相同的结果,即在事务开始后无法看到其他事务对数据所做的修改。这可以防止脏读和不可重复读,但幻读可能发生。
  4. 串行化 (Serializable):最高的隔离级别,通过强制事务串行执行,防止脏读、不可重复读和幻读。
隔离级别 脏读 不可重复读 幻读
Read uncommitted
Read committed ×
Repeatable Read(默认) × ×
Serializable × × ×
  1. 解释下什么叫脏读、不可重复读和幻读?
查看解析

1). 赃读:一个事务读到另外一个事务还没有提交的数据

比如B读取到了A未提交的数据。

脏读发生在一个事务读取了另一个事务未提交的数据。如果那个事务回滚,读取的数据将是无效的。


2). 不可重复读:一个事务先后读取同一条记录,但两次读取的数据不同,称之为不可重复读。

事务A两次读取同一条记录,但是读取到的数据却是不一样的。

不可重复读发生在一个事务读取了某些数据,然后另一个事务修改或删除了这些数据。当第一个事务再次读取相同的数据时,得到的结果不同。


3). 幻读:一个事务按照条件查询数据时,没有对应的数据行,但是在插入数据时,又发现这行数据
已经存在,好像出现了 “幻影”。

幻读类似于不可重复读,但它是指当一个事务重新执行一个查询时,返回一组符合查询条件的行,但这组行中包含了因为其他事务插入的新行。

  1. MySQL 默认的隔离级别是什么?
查看解析

MySQL 默认采用的 REPEATABLE_READ 隔离级别。
Oracle 默认采用的 READ_COMMITTED 隔离级别。

  1. 谈谈你对MVCC 的了解?
查看解析

数据库并发场景:

  1. 读-读:不存在任何问题,也不需要并发控制;

  2. 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读;

  3. 写-写:有线程安全问题,可能会存在更新丢失问题。

多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照


MVCC 可以为数据库解决以下问题:

  1. 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能;
  2. 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题



多版本并发控制(MVCC,Multi-Version Concurrency Control)是一种广泛使用的数据库管理技术,特别是在关系数据库系统中,如 PostgreSQL 和 MySQL 的 InnoDB 存储引擎。MVCC 允许在不锁定数据库资源的情况下实现事务数据的一致性和隔离性,从而提高并发性能。这种技术的核心在于为每个正在数据库上执行的事务维护数据的不同版本。

MVCC的工作原理

  1. 数据版本

    • 每当数据被修改时,MVCC 不会直接覆盖原有数据,而是创建一个新的数据版本。这样,不同的事务可以看到同一数据的不同版本,具体取决于事务的开始时间。
  2. 事务时间戳

    • 每个事务都会获得一个唯一的时间戳。根据这个时间戳,系统可以确定一个事务应该看到哪个版本的数据。通常,一个事务只能看到在它开始之前已经提交的更改。
  3. 可见性规则

    • 数据版本的可见性由事务的时间戳决定。如果一个数据版本是由时间戳较小(即较早)的事务创建的,那么所有时间戳较大(即稍后启动)的事务都可以看到这个版本,除非有更新的版本可用。
  4. 垃圾回收

    • 由于数据的多个版本共存,旧版本的数据需要在不再被任何事务引用时清理,这个过程通常称为垃圾回收或清理。这有助于释放空间并维护性能。

MVCC的优点

  1. 高并发性

    • MVCC 允许多个读取事务与写入事务并行执行而无需相互阻塞,从而大大提高了并发性能。
  2. 读写分离

    • 读事务不会阻塞写事务,反之亦然。这意味着读取操作可以在数据被其他事务更新的同时安全地执行,而不会遇到锁等待。
  3. 一致性视图

    • 每个事务都有一个一致的数据视图,即使其他事务正在并发修改数据。这保证了数据的一致性和隔离性。

MVCC的挑战

  1. 性能开销

    • 维护数据的多个版本需要额外的存储空间和管理开销。随着版本的增加,垃圾回收的复杂性和成本也会增加。
  2. 版本管理

    • 管理大量的数据版本可能导致性能下降,尤其是在高更新频率的环境中。
  3. 事务冲突

    • 尽管MVCC减少了锁的需要,但在高冲突环境中,如频繁更新同一记录,仍可能导致性能问题。

MVCC 是数据库管理中一种强大的技术,尤其适合需要高并发读取和写入操作的应用。通过优化其版本管理和垃圾回收策略,可以在保证事务隔离和一致性的同时,提高数据库的整体性能和效率。

  1. 说一下 MySQL 的行锁和表锁?
查看解析

MyISAM 只支持表锁, InnoDB 支持表锁和行锁,默认为行锁。

表级锁:开销小,加锁快,不会出现死锁。锁定粒度大,发生锁冲突的概率最高,并发量最低。

行级锁:开销大,加锁慢,会出现死锁。锁力度小,发生锁冲突的概率小,并发度最高。

  1. InnoDB 存储引擎的锁的算法有哪些?
查看解析
  1. Record lock:单个行记录上的锁;
  2. Gap lock:间隙锁,锁定一个范围,不包括记录本身;
  3. Next-key lock: record+gap 锁定一个范围,包含记录本身。
  1. MySQL 问题排查都有哪些手段?
查看解析
  1. 使用 show processlist 命令查看当前所有连接信息;
  2. 使用 Explain 命令查询 SQL 语句执行计划;
  3. 开启慢查询日志,查看慢查询的 SQL。



SHOW PROCESSLIST 是 MySQL 和类似的 SQL 数据库系统中的一条 SQL 命令,用于显示关于当前连接到数据库服务器的所有进程(或线程)的信息。这个命令对数据库管理员非常有用,尤其是在诊断系统性能问题、监控查询或管理数据库连接时。

作用和信息详解

当你执行 SHOW PROCESSLIST 命令时,它会返回一个列表,其中包含当前活跃的每个进程的以下信息:

  1. Id:每个进程的唯一标识符。
  2. User:启动进程的用户。
  3. Host:用户连接到服务器的主机名和端口号。
  4. db:当前进程正在使用的数据库。
  5. Command:该进程正在执行的命令类型,如 QuerySleepConnectKill 等。
  6. Time:当前命令已执行的秒数。
  7. State:显示进程的当前状态,这可能是 Sending dataSorting resultLocked 等,说明了进程执行的具体阶段或等待的原因。
  8. Info:正在执行的 SQL 语句或进程的最近语句。长查询可能会被截断。

使用场景

  • 性能监控:管理员可以使用 SHOW PROCESSLIST 来查看哪些查询正在执行以及它们的执行时间,这有助于识别性能瓶颈。
  • 问题诊断:如果应用程序响应缓慢或数据库访问延迟,此命令可以帮助确定是否有长时间运行的查询或锁争用问题。
  • 连接管理:查看谁连接到了数据库,他们正在做什么,这对于管理数据库连接和安全非常重要。
  • 强制终止查询:通过得到需要被终止的查询的 Id,可以使用 KILL [process id] 命令来终止不需要的或长时间运行的查询。

使用示例

简单执行 SHOW PROCESSLIST; 可以获得所有活跃进程的列表。如果你具有管理员权限,这将包括所有用户的进程;如果没有,则只显示你自己的进程。

对于更高级的用例,你可能希望将输出限制为那些可能影响系统性能的特定进程,比如运行时间超过一定阈值的进程。这可以通过一些 SQL 脚本来实现,结合使用 SHOW PROCESSLIST 和其他条件查询。

权限

  • 在 MySQL 中,执行 SHOW PROCESSLIST 不需要任何特殊权限就可以查看自己的进程信息;但查看其他用户的进程信息则需要 PROCESS 权限。

总的来说,SHOW PROCESSLIST 是数据库管理和性能调优中的一个非常有用的工具,它提供了即时的系统活动视图,帮助管理员管理和优化数据库操作。

  1. MySQL 数据库 CPU 飙升到 500% 的话他怎么处理?
查看解析
  1. 列出所有进程 show processlist,观察所有进程,多少没有状态变化的(干掉);
  2. 查看超时日志或者错误日志 (一般会是查询以及大批量的插入会导致 CPU与 I/O 上涨,当然不排除网络状态突
    然断了,导致一个请求服务器只接受到一半。



当 MySQL 数据库的 CPU 使用率飙升到 500% 或其他极高的水平时,这通常表明数据库正在处理大量的请求或执行一些资源密集型的操作。处理这种情况需要系统地分析问题原因并采取相应措施来优化性能和减轻负载。以下是一些基本步骤和策略,可以帮助你解决 MySQL 数据库的高 CPU 使用问题:

  1. 确认问题

首先确认高 CPU 使用率是由 MySQL 数据库引起的。可以使用如 tophtop 或系统特有的监控工具查看 CPU 使用详情。

  1. 查看当前运行的查询

使用 SHOW PROCESSLIST 命令来查看当前所有运行的查询。这可以帮助你识别是否有长时间运行或锁表的查询。你也可以使用 EXPLAIN 命令对这些查询进行分析,了解它们的执行计划及是否有效利用索引。

  1. 分析慢查询日志

检查 MySQL 的慢查询日志,找出执行时间长的查询。MySQL 可以配置来记录执行时间超过特定秒数的查询。这些信息对于诊断性能问题至关重要。

1
2
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
  1. 优化查询和索引

基于你从慢查询日志和 EXPLAIN 分析中获得的信息,优化查询语句和数据表的索引。确保所有的查询都高效地使用索引,尤其是涉及大量数据的查询。

  1. 调整配置设置

查看并调整 MySQL 的配置设置,如 innodb_buffer_pool_size(InnoDB 缓冲池大小),确保它根据服务器的内存大小进行了优化。其他参数如 query_cache_size, thread_cache_size, table_open_cache 也可以根据具体情况进行调整。

  1. 服务器硬件和资源

检查服务器的硬件资源,确保数据库服务器有足够的 RAM 和高效的 CPU。如果资源使用接近或超过限制,可能需要增加更多的资源或迁移到更强大的硬件。

  1. 分析和监控

持续监控数据库的性能,使用诸如 Prometheus、Grafana 或其他专业的数据库监控工具来获得实时的性能分析数据。

  1. 考虑使用读写分离或负载均衡

如果数据库访问量特别大,考虑实施读写分离,设置复制(replication)和读取副本(read replicas)来分散读取请求的压力。使用负载均衡可以平均分配请求到多个服务器。

  1. 寻求专业帮助

如果问题复杂难以解决,可能需要寻求来自具有专业数据库管理经验的 IT 专家的帮助。

通过这些步骤,你可以诊断并缓解 MySQL 数据库的高 CPU 使用问题,提高数据库的性能和稳定性。





数据结构与算法

  1. 字节:给一百亿个整数,就是很大数量级个整数,怎么找到其中位数
查看内容

处理这么大量级的数据,特别是要找到一百亿个整数中的中位数,不能直接使用常规的排序方法,因为这会需要非常大的内存和处理时间。以下是一些高效处理大规模数据以找到中位数的方法:

  1. 使用外部排序

如果数据太大,无法一次性装入内存,可以使用外部排序算法。外部排序是一种设计用来处理超过内存限制的大量数据的排序算法。这个过程大致如下:

  • 分割:将原始数据集分割成多个小块,每块小到足以装入内存。
  • 排序和存储:分别对每块数据进行排序,然后将排序后的数据块存储到硬盘上。
  • 合并:最后,使用多路归并排序算法合并这些排序后的数据块,直到合并成一个完整的排序后的数据集。

这种方法的一个变种是只部分排序数据,以便更快地找到中位数。

  1. 使用快速选择算法(QuickSelect)

快速选择算法是一种类似于快速排序的选择算法,用于在未排序的列表中找到第k小(或第k大)的元素。如果数据可以分布式存储,可以并行运行快速选择算法,以在合理的时间内找到中位数。

  1. 使用堆(Heaps)

如果数据流是动态的或者可以分批处理,可以使用两个堆(一个最大堆和一个最小堆)来动态地维护中位数。最大堆存储当前较小的一半数,最小堆存储当前较大的一半数。这样,中位数可以容易地从两个堆的顶部计算得到。

  1. BFPRT算法(中位数的中位数算法)

BFPRT算法,也称为中位数的中位数算法,是快速选择算法的一个改进版,可以保证更好的最坏情况时间复杂度。这种算法适用于当数据量非常大时,可以提供比普通快速选择更稳定的性能。

  1. 数据流算法

对于动态变化的数据流,可以使用特定的数据流算法来估算中位数,例如使用T-Digest或Q-Digest这类数据结构,虽然这不会给出精确结果,但可以在有限的内存中提供一个很好的近似。

总的来说,选择哪种方法取决于具体的应用场景,包括数据的大小、是否可以将数据载入内存、数据是否静态、以及对精度的需求等。对于一百亿这样的大数据量,通常需要采用一种或多种分布式处理和/或近似方法来有效地找到中位数。

  1. 4399:有9999瓶水,1瓶毒药,老鼠可以随便喝,喝到毒药10分钟后毒发,要在10分钟后找到这瓶毒药,最少要用多少只老鼠?
查看解释

为了解决这个问题,可以使用二进制表示法来最小化所需的老鼠数量。每瓶水都可以用二进制表示,从00 0000 0000 0000(0)到10 0111 0000 1111(9999)。这里的关键是每位二进制数代表一只老鼠是否应该喝那一批标记了1的水瓶。如果一瓶水的二进制表示在某一位上是1,那么对应的老鼠就需要喝这瓶水。如果在10分钟后,某只老鼠死了,就意味着它喝的水瓶中包含了毒药,那么所有这些老鼠的生死情况共同决定了哪一瓶是毒药。

进行实验

  • 每一只老鼠对应二进制编码中的一个位置。例如,第一只老鼠对应最低位,第二只对应第二位,依此类推。
  • 如果某瓶水的编码在某位是1,则让对应这位的老鼠喝这瓶水。例如,如果某瓶水的编码是1001,那么第一和第四只老鼠需要喝这瓶水。
  • 等待10分钟后,根据哪些老鼠死亡,记录下来。每个老鼠的生死状态对应一个二进制位,将这些二进制位组合起来就能确定哪瓶是毒药。

记录结果

  • 每只老鼠对应一个特定的二进制位位置,从最低位(第一只老鼠)到最高位(第十四只老鼠)。
  • 记录哪些老鼠在实验后死亡。死亡的老鼠对应的位置上标记为 1,未死的标记为 0

最后组合二进制数字,再转十进制就知道是哪一瓶了。

因此,要在10分钟内找出哪瓶水有毒,最少需要14只老鼠。





职场

  1. 韶音科技:产品经理是什么
查看解析

作为计算机专业的本科生,当被问到产品经理是什么时,你可以从以下几个角度来回答:

  1. 定义与角色定位
    产品经理(Product Manager)是负责指导和管理一个产品从构想、开发到市场推广全过程的专业人士。他们充当团队与客户之间的桥梁,确保产品能够满足市场需求和用户期望。产品经理需要具备深入理解市场动态、用户需求、产品设计和技术开发的能力。

  2. 核心职责

    • 市场研究:进行市场调研,了解行业趋势和竞争对手,定义目标用户群体。
    • 产品策略制定:基于市场研究,制定产品的愿景、战略和路线图。
    • 需求管理:收集和分析用户需求,定义产品功能和优先级。
    • 项目管理:与设计师、开发人员协作,确保产品按时发布。
    • 数据分析:通过数据分析来优化产品性能和用户体验。
  3. 技能要求

    • 交际能力:需要与团队成员、用户和其他利益相关者有效沟通。
    • 分析能力:能够处理和解读市场数据,做出基于数据的决策。
    • 项目管理:组织和协调资源,确保项目按计划进行。
    • 技术知识:虽然不一定需要深入的编程知识,但需要理解技术问题和限制,以便与开发团队有效沟通。
  4. 与计算机专业的关系
    作为计算机专业的学生,你具备了理解复杂技术问题的能力,这是成为一名优秀产品经理的重要基础。你可以强调你的技术背景如何帮助你更好地与开发团队合作,以及如何利用这一优势来设计和推动实现高质量的产品解决方案。

回答这个问题时,可以结合自己的经验和兴趣,说明为什么你对产品管理感兴趣,以及你如何利用自己的计算机科学知识和技能来成为一名成功的产品经理。





公司

C++方向

1. 宇量升

一面

直接手撕一道动态规划





2. 招银网络

一面

C++重写和重载

数据库索引

手写一道sql

java中间件

手撕一道算法题:子序列题


二面

简历有什么问什么

问转java能不能接受





3. 快手

一面

问计算机网络、操作系统哪个比较熟悉,挑一个讲,自己讲一下操作系统或者提问知识点

我选了讲解,内容太多,讲完进程被喊停了

手撕一道算法题:翻转链表中间部分

问了http协议


二面

问了一下项目,让自己挑一个讲讲,过程中提问

手撕一道算法题:螺旋打印矩阵

问了一下https

问了一下智能指针





4. 4399

一面

说很适合他们的岗位,给我讲解了一下游戏前端和互联网前端、客户端前端的区别

了解了一下我做的项目,个人情况

问了git的常用命令

问了一道智力题:
有9999瓶水,1瓶毒药,老鼠可以随便喝,喝到毒药10分钟后毒发,要在10分钟后找到这瓶毒药,最少要用多少只老鼠?
没答上来,但是我说到了剪枝,让我说一下剪枝也行。





5. 巴伦如梭

一面:

纯搞笑公司,一直问你项目,希望你的项目技术程度非常高,总之他要听到一个想要的答案,我想问问他知道这是校招吗

拿了10天前做的笔试题来问我代码有一个问题知不知道,代码也不给看;太久远了根本记不清,而且做笔试的时候无法编译无法调试,根本印象不深,后面给了一些提示,也算是回答上来了

问了一下锁,我说不知道,但是我知道事务,说了一下

最后问有没有想要提问的,我回答没有,
然后就反问我对他们公司了解多少,
我说有所欠缺,
然后嘲讽我说,不知道还不问,
我的内心(有病吧,大家都赶时间,我了解nm呢,你招我进去再说不行吗,问情商这么低的问题)
我回答,这些太基础了,可以自己了解,不爱问这种问题

后面听说总共也就只有一面





6. 星环科技

24届春招-分布式存储开发工程师
笔试题

第一题

一个job有很多task,task是最小分配单元;
需要模拟一大批job到来,处理机的处理;
输出job的完成时间;

输入

第一行输入n, m;n表示job数量,m表示处理机数目
接下来n行,每行输入三个数a(job到达时间), b(job的task数量), c(完成task所需要的时间)

规定第i行的到达时间一定小于第i-1行

输出

输出n行,每行表示对应job的完成时间

样例

输入

1
2
3
2 3
0 2 5
2 1 4

输出

1
2
5
6

第二题

翻译百灵鸟的语言

.表示“叽”,_表示“啾”

输入

第一行输入n;
接下来n行输入百灵鸟的单词;
最后一行输入需要翻译的句子

样例

输入

1
2
3
4
5
6
4
.
._
__.
__..
__..._

输出

1
2

解释

1
2
__. / . / ._
__.. / ._




7. TCL

笔试纯纯c++知识点,复习过就是送分





8. 蚂蚁集团

笔试:选择比较难,编程全是送分

……面太多了,后面懒得写了,三十场……