2020年1月

一、单链表

链表是一种线性结构,通过前后节点指针连接起每个节点,从结构来看就像是用一个链把所有的节点都串起来了,因此被称为链表。

它和数组最大的不同就是不能随机访问,数组的内存空间是顺序的,通过计算位置偏差就能定位到元素。而链表的节点的地址是不连续的,它无法直接通过地址定位随机访问。但是数组在以下场景就没有链表方便了:

  1. 开辟新空间:数组元素到达上限后,如需添加新元素,需要重新开辟一块更大的空间,然后把当前数组的数据拷贝过去再继续添加元素。
  2. 删除元素:删除数组元素后,需要把被删除元素后面的元素都集体前移。
  3. 移动元素:移动元素和删除类似,移动节点会导致部分节点数据迁移。

但是对于链表而言,都不存在以上问题。它不管是新增、删除甚至移动节点,都能在O(1)的时间完成。因此,在某些情况下,它更适合数组来使用。

二、单链表的操作

2.1 链表结构

链表都是由一个个节点组成的,从面向对象的特性上来说,链表和节点是两个不同的东西。每个节点至少都包含了一个数据字段和一个next节点指针,next指针指向的是下一个节点地址,而链表只需要保存一个首节点指针就行了。通过链表的首节点指针和每个节点的next指针,就可以循环遍历到节点的每一个元素了。

2.2 添加节点操作

添加节点的操作可通过下图演示:

添加节点

可以分为两步来描述:

  1. 设置新节点(B节点)的next指针为下一个节点(C节点)地址。
  2. 把插入节点(A)节点的next指针指向新节点(B节点)。

这样就完成了一个节点的插入。

2.3 删除节点操作

删除节点的操作图示:

删除节点

操作描述:

  1. 找到删除节点的前置节点(A节点)。
  2. 设置前置节点的next指针为删除节点的next指针指向。
  3. 删除节点。

2.4 移动节点操作

移动节点的操作可以分解为删除和添加节点两步:

  1. 把移动节点从链表中删除。
  2. 把这个删除的节点重新插入到链表的指定位置。

三、代码描述(C++)

3.1 节点和链表定义

节点定义:

template<typename T>
class list_node {
public:
    friend class singly_linklist<T>;
    list_node() : next(nullptr) {}
    list_node(const T &data) : next(nullptr), data(data) {}
private:
    T data;
    list_node<T> *next;
};

节点的定义很简单,包含了一个T类型的数据域和next指针,其中比较特殊的是对singly_linklist类的友元定义,singly_linklist是后面要定义的链表类,通过设置友元可以方便链表类直接操作节点中的私有数据域,因为需要在链表中对节点进行新增、删除和移动等操作。

链表的定义如下:

template<typename T>
class singly_linklist {
public:
    singly_linklist() : len(0), head(nullptr), tail(nullptr) {}
private:
    size_t len;
    list_node<T> *head;
    list_node<T> *tail;
};

链表中包含了两个指针headtailhead指针是上面说的链表首元素的地址,而tail指针则是链表尾元素指针,tail指针在单链表中实际上没有很大的用途,但是在后续的双向链表和双向循环链表中都会用到,单链表中可以用作冗余字段使用。

除了首位指针以外,还有一个表示链表长度的字段,这个字段也是作为冗余使用,实际项目中如果需要获取链表长度就可以在0(1)时间返回,避免了便利链表。链表长度的设置方法也很简单:每添加一个节点,长度加一;每删除一个节点,长度减一。

3.2 添加节点

插入链表节点的逻辑很简单,代码在网上一搜也是一大把,但很少有准确并简单的实现方式。

本文中的链表(包括后续的双向链表和循环链表)都参考了linux内核中的实现方式,力求简单、高效并且准确。

链表节点的插入有头插法和尾插法,头插法的意思是把节点插到链表头部,而尾插法的意思是把节点插到链表尾部。两者逻辑不同,但是共同点就是都要插入节点到链表中,只是插入的位置不同。因此对添加节点而言,有必要把插入节点逻辑抽离出来作为私有函数供其他插入函数调用。

定义一个私有的添加节点函数(c语言中通过static关键字完成,c++通过private关键字完成),传入参数包含三个:

  • 待插入的节点new_node,因为是私有函数提供给类中的其他函数使用的,因此外部函数调用前要确保值不为空。
  • 待插入节点的前置节点prev,值可能为空(插入节点到链表首部的时候)。
  • 待插入节点的后置节点next,值可能为空(插入节点到链表尾部的时候)。

添加节点除了设置前后节点指针以外,还有一个难处理的地方就是首尾指针的赋值。

什么时机才应该重新赋值首尾指针???

更新首尾指针的地址的场景:

  1. 空链表插入新节点。
  2. 非空链表插入节点到链表首部或者尾部。
  3. 删除首、尾节点(删除的场景在下面讨论)。

可以整理出来的一个规律是:

  • 如果新插入节点的前置节点是空(此时插入节点到链表头),设置头指针为新插入的节点。
  • 如果新插入节点的后置节点为空(此时插入节点到链表尾),设置尾指针为新插入的节点。

代码如下:

/*
 * 添加节点操作,新添加的节点需确保不是空值
 * @new_node 新节点
 * @prev 前驱节点,节点可能为空(插入到首部的时候)
 * @next 后继节点,节点可能为空(插入到尾部的时候)
 */
template <typename T>
void singly_linklist::add_node(list_node<T> *new_node, list_node<T> *prev, list_node<T> *next) {
    len++;
    new_node->next = next;

    if (prev) { // 如果前置节点不为空,修改前置节点的next指针
        prev->next = new_node;
    } else { // 前置节点为空,说明放在了链表头部,设置头节点的指针
        head = new_node;
    }

    if (next == nullptr) { // 后置节点为空,说明放在链表的尾部,设置尾节点的指针
        tail = new_node;
    }
}

实现了add_node函数后,再实现头插和尾插函数就简单了:

/*
 * 插入元素到末尾
 * @data 待插入数据
 */
template <typename T>
const singly_linklist<T> &singly_linklist::push_back(list_node<T> *node) {
    if (node == nullptr)
        return *this;

    // 添加节点到末尾,前置节点是尾节点,后置节点是空
    add_node(node, tail, nullptr);

    return *this;
}

/*
 * 插入数据到开头
 * @node 待插入的数据节点
 */
template <typename T>
const singly_linklist &singly_linklist::push_front(list_node<T> *node) {
    if (node == nullptr)
        return *this;

    // 添加节点到开头,前置节点是空,后置节点是头节点
    add_node(node, nullptr, head);
  
    return *this;
}

一个要注意的地方是两个函数的返回值都是const singly_linklist &,在函数结束后都返回*this,这么做的目的就是为了支持链式表达式。

3.3 删除节点

删除节点和添加节点一样,难处理的地方就是首尾指针的赋值(也就是上面添加节点时说到的需要处理但是还没有处理的的第3点)。

删除节点时处理首尾指针的规律和插入节点一致:

  1. 如果删除节点的前置节点是空(删除的是头节点),设置头节点指针为删除节点的后置节点。
  2. 如果删除节点的后置节点是空(删除的是尾节点),设置尾节点指针为删除节点的前置节点。

代码如下:

/*
 * 删除节点,待删除的节点请确保不是空值
 * @del_node 待删除节点
 * @prev 前驱节点
 * @next 后继节点
 */
template <typename T>
void singly_linklist::remove_node(list_node<T> *del_node, list_node<T> *prev, list_node<T> *next) {
    len--;
    del_node->next = nullptr;

    if (prev) { // 如果存在前置节点,更新前置节点的next指针
        prev->next = next;
    } else { // 不存在前置节点,删除的是头节点,更新头节点的指针
        head = next;
    }

    if (next == nullptr) { // 不存在后置节点,删除的是尾节点,更新尾节点指针
        tail = prev;
    }
}

对比remove_node和add_node的函数实现可以看到,函数开头都没有对新增节点或删除节点做合法校验。

不校验的意义在于两个都是内部函数,提供给内部其他函数使用的,其他函数在外层调用的时候已经判断参数合法性了,函数内已经确保不会为空。

在实现remove_node之后,需要再实现的一个功能是找到前置节点,因为删除节点要先找到前置节点才能调用。查找前置节点函数find_prev的操作为:

/*
 * 查找某个节点的前一个节点
 * @node 当前节点
 * @return 如果存在上一个节点,返回上一个节点的地址,否则返回nullptr
 */
template <typename T>
list_node<T> *singly_linklist::find_prev(list_node<T> *node) {
    list_node<T> *p = head;
    if (node == nullptr)
        return nullptr;
  
    // 遍历链表查找前置节点
    while (p != nullptr) {
        if (p->next == node) {
            return p;
        }
        p = p->next;
    }
    return p;
}

有了find_prev之后,对外的remove函数就可以实现了:

/*
 * 删除节点
 * @node 待删除节点
 */
template <typename T>
const singly_linklist &singly_linklist::remove(list_node<T> *node) {
    list_node<T> *prev;
    if (node == nullptr)
        return;

    // 找到前置节点
    prev = find_prev(node);

    // 删除节点
    remove_node(node, prev, node->next);

    delete node;
}

基于remove函数实现pop_frontpop_bak

// 弹出首元素
template <typename T>
const singly_linklist &singly_linklist::pop_front() {
    remove(head);
}

// 弹出尾元素
template <typename T>
const singly_linklist &singly_linklist::pop_back() {
    remove(tail);
}

四、单元测试

测试头插法:

TEST(singly_linklist, push_front) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    vector<int> v{1, 2, 3};

    list.push_front(3).push_front(2).push_front(1);
    EXPECT_EQ(3, list.get_len());

    p = list.get_head();
    for (i = 0; i < v.size(); i++) {
        EXPECT_EQ(p->get_data(), v[i]);
        p = p->get_next();
    }
    EXPECT_EQ(p, nullptr);
}

测试尾插法:

TEST(singly_linklist, push_back) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    vector<int> v{1, 2, 3};

    list.push_back(1).push_back(2).push_back(3);
    EXPECT_EQ(3, list.get_len());

    p = list.get_head();
    for (i = 0; i < v.size(); i++) {
        EXPECT_EQ(p->get_data(), v[i]);
        p = p->get_next();
    }
    EXPECT_EQ(p, nullptr);
}

测试获取前置节点:

TEST(singly_linklist, get_prev) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    list_node<int> *node1, *node2, *node3;

    // 插入3个节点
    node1 = new list_node<int>(1);
    node2 = new list_node<int>(2);
    node3 = new list_node<int>(3);
    list.push_back(node1).push_back(node2).push_back(node3);
    EXPECT_EQ(3, list.get_len());

    p = list.find_prev(node1);
    EXPECT_EQ(p, nullptr);
    p = list.find_prev(node2);
    EXPECT_EQ(p, node1);
    p = list.find_prev(node3);
    EXPECT_EQ(p, node2);

}

测试删除节点:

TEST(singly_linklist, remove) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    list_node<int> *node1, *node2, *node3;

    node1 = new list_node<int>(1);
    node2 = new list_node<int>(2);
    node3 = new list_node<int>(3);

    list.push_front(node1);
    list.push_back(node2);
    list.push_back(node3);

    // 删除中间节点
    list.remove(node2);
    EXPECT_EQ(node1, list.get_head());
    EXPECT_EQ(node3, list.get_tail());
    EXPECT_EQ(node1->get_next(), node3);

    // 删除头节点
    list.remove(node1);
    EXPECT_EQ(node3, list.get_head());
    EXPECT_EQ(node3, list.get_tail());
    EXPECT_EQ(node3->get_next(), nullptr);

    // 删除尾节点
    list.remove(node3);
    EXPECT_EQ( list.get_head(), nullptr);
    EXPECT_EQ( list.get_tail(), nullptr);
}

测试结果:

一、基本用法

画一个简单的二叉树:

digraph bin_tree {
    1->2;
    1->3;
}

图形:

二、设置形状

上面的1/2/3都是一个node,通常被称为节点,默认情况下节点是圆形的。可以通过shape属性来设置节点形状。

设置形状为长方形:

digraph bin_tree {
    node [shape="rectangle"];
    1->2;
    1->3;
}

设置形状为三角形:

digraph bin_tree {
    node [shape="triangle"];
    1->2;
    1->3;
}

graphviz提供了很多形状可以选择,具体的类型和样式可在Node Shapes找到。

三、设置线条

3.1 设置虚线

设置线条的属性要修改edge属性,线条不只是箭头,每个node的边也被edge属性控制。

例如设置节点的线条为虚线:

digraph bin_tree {
    node [shape="rectangle" style="dashed"];
    1->2;
    1->3;
}

dash-node

设置箭头的线条为虚线:

digraph bin_tree {
    node [shape="rectangle" style="dashed"];
    edge [style="dashed"];
    1->2;
    1->3;
}

3.2 设置箭头形状

当然,箭头的形状也是可以设置的,例如设置成不要箭头:

digraph bin_tree {
    node [shape="circle"];
    edge [arrowhead="none"];
    1->2;
    1->3 ;
}

箭头的形状可以在Arrow Shapes找到,还有很多线条的形状可以设置。

四、设置颜色

设置线条颜色为红色,节点填充色为灰色:

digraph bin_tree {
    node [shape="rectangle" style="dashed,filled" color="gray"];
    edge [style="dashed" color="red"];
    1->2;
    1->3;
}

对node/edge的属性配置是全局生效的,局部生效的方法:

digraph bin_tree {
    node [shape="rectangle" style="dashed,filled" color="gray"];
    edge [style="dashed" color="red"];
    1->2;
    1->3 [color="blue"];
}

设置rgb颜色

edge [style="dashed" color="#ff00ff"];

判断操作系统的方法:

# project variables
MESSAGE(STATUS "platform: ${CMAKE_SYSTEM_NAME}")
if (UNIX AND NOT APPLE)
    MESSAGE(STATUS "unix")
elseif (WIN32)
    MESSAGE(STATUS "windows")
elseif (APPLE)
    MESSAGE(STATUS "mac os")
else ()
    MESSAGE(STATUS "other platform")
endif ()

要注意的问题是APPLE也属于UNIX

一、安装googletest

单测对程序员而言是提升代码质量最重要、最有效的一个措施,对程序员来说,要想写一个好的程序,那么必定少不了好的单元测试。googletest(gtest)是google开发出来的一个开源的、跨平台的测试框架,是C++中最出名的测试框架。

gtest支持linux、windows以及mac系统,安装它依赖下面几项:

  1. gtest源码:gtest属于开源项目,代码仓库https://github.com/google/googletest
  2. cmake:gtest使用cmake构建项目,编译需要cmake环境,cmake下载地址
  3. 编译器:linux环境下可使用g++编译,windows环境下使用vs或者clion等工具编译,mac环境使用xcode或clion等工具编译。

这里的测试环境为mac+clion(付费),clion下载地址,选择clion是因为clion跨平台。

windows环境除了配置clion编译环境以外,其他步骤和mac系统一致。

1.1 环境配置

第一步,先使用git克隆代码到本地,注意最好不要放到中文路径了。

git clone https://github.com/google/googletest.git

第二步,安装cmake,不同系统的的安装方式不一样,windows在上面的页面下载一直下一步安装就行了,其他系统可以直接使用对应平台的包管理工具安装。

# mac
brew install cmake
# centos 
yum install cmake
# ubuntu
apt-get install cmake

第三步,安装clion,linux和mac环境下安装clion和gcc环境就可以使用了,windows配置clion编译环境可参考Window10上CLion极简配置教程

1.2 编译gtest库

配置好环境后,使用clion打开代码目录,然后载入代码目录,选择gtest项目编译生成:

编译成功后生成libgtestd.a文件到cmake编译路径的lib路径下:

生成的libgtestd.a即为gtest的库文件,项目中引用这个库文件就能使用gtest了。

二、使用googletest

2.1 引入库

将libgtestd.a文件拷贝到代码根路径的lib路径下,在CMakeList.txt中加上以下内容:

# 添加上库文件的路径,注意相对路径
link_directories(lib/)
# 添加可执行文件
add_executable(demo demo.cpp)
# 链接gtest库文件
target_link_libraries(demo libgtestd.a)

2.2 引入头文件

拷贝googletest/include目录下的gtest目录到当前目录下,然后在CMakeList.txt中添加上对应的调用:

include_directories(
    include/
)

然后在代码中添加头文件gtest/gtest.h就可以使用了。

2.3 测试

添加代码add.cpp

#include "gtest/gtest.h"

int add(int a, int b) {
    return a + b;
}

TEST(add, zero) {
    EXPECT_EQ(0, add(0, 0));
}

TEST(add, positive_number) {
    EXPECT_EQ(3, add(1, 2));
}

TEST(add, negative_number) {
    EXPECT_EQ(-3, add(-1, -2));
}

int main() {
    ::testing::InitGoogleTest();
    return RUN_ALL_TESTS();
}

执行结果:

 title=

三、gtest的使用教程

参考文档:Googletest Primer,google官方出品。

3.1 基本用法

gtest最基本的用法就是断言,它内部提供了很多种断言方式,例如:

ASSERT_EQ()
ASSERT_TURE()
EXPECT_EQ()
EXPECT_TRUE()
// ...

其中ASSERT_*的断言,在条件不满足后会终止,而EXPECT_*不会终止。

以上面的代码为例,代码编写了一个add函数,返回两个传参的和:

int add(int a, int b) {
    return a + b;
}

然后引入gtest并写了三个测试用例:

#include "gtest/gtest.h"

TEST(add, zero) {
    EXPECT_EQ(0, add(0, 0));
}

TEST(add, positive_number) {
    EXPECT_EQ(3, add(1, 2));
}

TEST(add, negative_number) {
    EXPECT_EQ(-3, add(-1, -2));
}

三个用例分别表示:

  • 测试零值相加
  • 测试正数相加
  • 测试负数相加

主函数中添加启动gtest的入口:

::testing::InitGoogleTest();
RUN_ALL_TESTS();

运行程序,系统就会自动调用三个测试用例的函数来测试,并输出测试报告:

...
[ RUN      ] add.zero
[       OK ] add.zero (0 ms)
[ RUN      ] add.positive_number
[       OK ] add.positive_number (0 ms)
[ RUN      ] add.negative_number
[       OK ] add.negative_number (0 ms)
...

如果中间有断言失败的地方,报告也会表达出来。例如修改上面测试中的负数相加函数来制造错误场景:

TEST(add, negative_number) {
    EXPECT_EQ(-3, add(-1, 2)); // 把-2改成2,制造错误场景。
}

再执行测试,报告中就会把不通过的案例展示出来,并且会定位到对应的行,打印出失败的详细原因:

当案例执行失败后,我们也可以打印出一些我们自己的数据以供调试使用,例如:

TEST(add, negative_number) {
    EXPECT_EQ(-3, add(-1, 2)) << "this is a incorrect test";
}

案例失败后,不仅会打印出失败原因,还会打印出我们自己添加的语句:

3.2 常用断言

基本断言

致命断言非致命断言验证
ASSERT_TRUE(condition);EXPECT_TRUE(condition);条件condition为真
ASSERT_FALSE(condition);EXPECT_FALSE(condition);条件condition为假

二进制比较

致命断言非致命断言验证
ASSERT_EQ(val1, val2);EXPECT_EQ(val1, val2);val1 == val2
ASSERT_NE(val1, val2);EXPECT_NE(val1, val2);val1 != val2
ASSERT_LT(val1, val2);EXPECT_LT(val1, val2);val1 < val2
ASSERT_LE(val1, val2);EXPECT_LE(val1, val2);val1 <= val2
ASSERT_GT(val1, val2);EXPECT_GT(val1, val2);val1 > val2
ASSERT_GE(val1, val2);EXPECT_GE(val1, val2);val1 >= val2

字符串比较

致命断言非致命断言验证
ASSERT_STREQ(str1,str2);EXPECT_STREQ(str1,str2);两个c字符串内容相同
ASSERT_STRNE(str1,str2);EXPECT_STRNE(str1,str2);两个c字符串内容不同
ASSERT_STRCASEEQ(str1,str2);EXPECT_STRCASEEQ(str1,str2);两个c字符串内容相同(忽略大小写)
ASSERT_STRCASENE(str1,str2);EXPECT_STRCASENE(str1,str2);两个c字符串内容不同(忽略大小写)

四、使用Test Fixtures

Test Fixtures使用场景:测试案例需要初始化数据或者多个测试案例使用相同的测试数据。

例如在对一个的做单元测试时,测试pop功能,按照上面的测试方法,测试案例得这么写:

TEST(stack, pop) {
  my_stack s;
  // 先推入3个元素
  s.push(1); s.push(2); s.push(3);
  // 测试pop
  s.pop();
  EXPECT_EQ(s.size(), 2);
}

测试过程可以描述为:

  1. 创建一个栈对象的实例s。
  2. 推入3个元素,以便后面pop使用。
  3. 开始测试pop。

从直观上来看,所有和pop相关的测试案例都要这么来写,要先推入元素,再弹出。而实际上,步骤1和步骤2是和本轮测试无关,它只起到了初始化数据的作用,它是多余的,但是所有的测试案例又不得不做这一步操作。那么有没有办法解决这个问题呢?有!Test Fixtures的就是解决这种问题的,它可以在测试案例开始前自动生成好需要的数据。

定义了一个简单的的类:

class my_stack {
public:
    void push(int a) {
        s.push(a);
    }
    void pop() {
        s.pop();
    }
    int size() {
        return s.size();
    }
private:
    stack<int> s;
};

再定义一个测试类stack_test

class stack_test : public ::testing::Test {
protected:
    void SetUp() override {
        stack1.push(1);
        stack1.push(2);
    }

    void TearDown() override {
    }

    my_stack stack1;
    my_stack stack2;
};

它要公有继承于::testing::Test,其中的SetUpTearDown函数分别是初始化和清理函数,也就是类生成前和使用后要做的工作。

此时使用TEST_F宏定义来测试:

TEST_F(stack_test, push) {
    EXPECT_EQ(stack1.size(), 2);
    EXPECT_EQ(stack2.size(), 0);
    stack2.push(1);
    EXPECT_EQ(stack2.size(), 1);
}

TEST_F(stack_test, pop) {
    EXPECT_EQ(stack1.size(), 2);
    EXPECT_EQ(stack2.size(), 0);
    stack1.pop();
    EXPECT_EQ(stack1.size(), 1);
}

在执行TEST_F之前,gtest会自动构建一个stack_test的实例,并执行SetUp函数。也就是说,当真正执行到我们的测试代码的时候,就已经存在一个初始化好的测试环境了。这个时候可以直接访问stack_test的内部成员,通过成员变量来做单元测试。

原理来看其实很简单,就是把初始化的过程交给了gtest来完成,它来帮我们实例对象,进行初始化,我们直接用就行。

测试结果:

五、其他

5.1 clion环境跨平台使用gtest

如何在不改变CMakeList.txt的情况下跨平台使用gtest?配置CMakeList,根据不同平台读取不同的库:

# 根据不同平台设置库的目录
if (UNIX AND NOT APPLE) # unix非苹果系统
    set(PROJ_LIBRARY_PATH /home/maqian/code/lib)
    set(GTEST_LIBRARY_NAME libgtestd.a)
elseif (WIN32) # windwos系统
    set(PROJ_LIBRARY_PATH d:/mingw64/lib)
    set(GTEST_LIBRARY_NAME libgtestd.a)
elseif (APPLE) # mac系统
    set(PROJ_LIBRARY_PATH /Users/maqian/code/lib)
    set(GTEST_LIBRARY_NAME libgtestd.a)
else () # 未知系统
    MESSAGE(STATUS "other platform: ${CMAKE_SYSTEM_NAME}")
endif ()
# 引入链接库目录
link_directories(${PROJ_LIBRARY_PATH})
# 链接库到目标
target_link_libraries(xxxx ${GTEST_LIBRARY_NAME})

六、参考

Google Test support

Googletest Primer

一、概述

CMakeLists.txt是cmake编译系统构建器的构建文件,就像是make中的Makefile一样。

Makefile是通过固定的文件格式来构建目标,相对于Makefile而言,CMakeList并没有固定的语法格式,他是通过各种函数和指令来完成构建。

一个基本的CMakeList.txt内容结构为:

cmake_minimum_required(VERSION 3.4.1)

project("link_list")

# 设置变量
set(PROJ_ROOT_DIRECTORY /home/maqian/Desktop/code2019)
set(PROJ_ROOT_LIBRARY ${PROJ_ROOT_DIRECTORY}/lib)

# 添加头文件
include_directories(
        ../../common
)

# 添加编译选项
add_compile_options(-std=c++11)

# 添加宏定义
add_definitions(-D__LINKLIST_TEST__)

# 添加目标
add_executable(doubly_linklist doubly_linklist.cpp)

# 添加静态库
link_directories(${PROJ_ROOT_LIBRARY})
target_link_libraries(doubly_linklist ${PROJ_ROOT_LIBRARY}/libgtest.a)

二、常见用法

2.1 设置变量

设置变量的函数是set,格式为:

set(SOURCE_DIR /usr/src/)

表示设置一个SOURCE_DIR的变量,其值为/usr/src

2.2 添加头文件目录

添加上级目录下的inc目录作为头文件的搜索路径:

include_directories(
    ../inc/
)

2.3 添加编译选项

添加编译选项的函数为add_compile_options,例如指定编译标准为c++11

add_compile_options(-std=c++11)
设置编译标准还可以使用:set(CMAKE_CXX_STANDARD 11)。

2.4 添加宏定义

添加一个__XTEST__的宏定义:

add_definitions(-D__XTEST__)

2.5 添加静态库

添加静态库依赖两个函数link_directoriestarget_link_libraries,用法如下:

set(PROJ_ROOT_LIBRARY /usr/lib/)

link_directories(${PROJ_ROOT_LIBRARY})
target_link_libraries(doubly_linklist ${PROJ_ROOT_LIBRARY}/libgtest.a)

要注意的是PROJ_ROOT_LIBRARY设置的路径是全路径,并且target_link_libraries要在add_executable的后面声明。

一、HTTP/2概述

HTTP/2是HTTP协议的第二个大版本,相较于HTTP/1而言,HTTP/2的核心观念是“构建一个更快、更简单以及更强大”的web应用。

HTTP/2 will make our applications faster, simpler, and more robust—a rare combination—by allowing us to undo many of the HTTP/1.1 workarounds previously done within our applications and address these concerns within the transport layer itself.

HTTP/2的前身是SPDY协议,这个协议是由google开发出来的一个实验性协议,在2009年中旬发布,主要目的是为了减少网页加载延迟。这个协议的目标是:

  1. 减少50%的页面加载时间。
  2. 无需网站作者修改任何内容。
  3. 最小化部署的复杂性,避免对网络架构的更改。
  4. 与开源社区合作开发这个新协议。
  5. 收集真实的性能数据,验证此协议是否真的有效。

在2012年,SPDY协议得到了chrome、firefox和opera的支持,越来越多的大型网站(如google、twitter和facebook)以及小型网站都在其基础设施内部署SPDY。在被越来越多的行业采用后,SPDY已经具备了称为一个标准的条件。也正因为这样,HTTP-WG在SPDY的基础上制定了官方的HTTP/2协议。在2015年初,IESG批准了这个基于SPDY协议的HTTP/2协议。

其实很多应用目前(2020)就已经使用了SPDY协议,例如我在一个部署了阿里云CDN的网站上就看到了SPDY相关的身影:

虽然是个报错信息,但可以看出目前SPDY(HTTP/2)确实已经被广泛使用。而在实验数据上,通过模拟网络连接下载了25个最流行的网站之后,页面加载速度最高加快了55%。

So far we have only tested SPDY in lab conditions. The initial results are very encouraging: when we download the top 25 websites over simulated home network connections, we see a significant improvement in performance - pages loaded up to 55% faster.

——来源:Chromium Blog

HTTP/2没有改变HTTP的基本语义和操作,各种操作方法(如GET/POST/HEAD等)、请求头和请求体机制都没有改变,不同的只是传输的方式改变了。HTTP/1.x 协议以换行符作为纯文本的分隔符,而 HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式对它们编码。

HTTP/2的主要功能特性:

  1. 将HTTP协议中的纯文本传输修改成了二进制帧传输,分为头部帧和数据帧。
  2. 使用HPCAK对头部数据压缩,添加头部索引减少重复头部数据传输。
  3. 添加数据优先级,针对不同的数据源建立不同的优先级树,确保优先级高的数据不会被阻塞。
  4. 请求响应的复用,同一个数据流中可以同时以不同的顺序发送多个不同的帧,每个数据源只产生一个连接。
  5. 流控制,阻止发送方向接收方发送的大量数据,通过请求窗口限制请求速率。
  6. 服务端推送,打破严格的请求-响应机制,服务端也可以主动推送数据到客户端。

二、数据帧的传递方法

2.1 HTTP/2数据传输的基本元素

新的HTTP/2协议通过二进制帧来传输数据,改变了客户端与服务器之间交换数据的方式,主要涉及了三个概念:

  • 帧:HTTP/2数据传输的基本单位,通过把原始的文本数据处理成二进制帧来发送。
  • 消息:逻辑请求和响应构成的统一整体就是消息。
  • 数据流:建立的TCP连接,承载发送一条和多条消息。

帧和消息

和HTTP/1一样,HTTP/2依旧还是保留了请求头、请求体以及响应体的部分。只不过在传输的时候被处理成了二进制的内容格式,同时把请求帧和响应帧区分开来了,两者可以单独发送。

例如以下是一个GET请求的帧格式(实际传输是二进制的):

因为GET请求不包含请求体,因此只会有一个标头帧发送出去。而服务端响应的帧格式为:

上面的请求帧和响应帧构成的帧集合即为消息。

一个TCP连接上包含的多个消息即为流,它体现形式为:

2.2 每个来源一个连接

在浏览器中,一个页面存在很多请求,虽然HTTP/1.1中由连接保持机制,但是大量的连接还会使得浏览器不得不建立多个连接来连接复用。大部分的浏览器还会限制单个源的连接数量,并且由于HTTP/1.1中的keep-alive是基于pipeline的,所以一旦有些请求阻塞后会导致其他请求也阻塞。

当使用二进制帧传输后,HTTP/2不再依赖创建多个TCP连接进行连接复用。每个数据流都拆分成很多帧,而这些帧可以交错,还可以分别设定优先级。 因此,所有 HTTP/2 连接都是永久的,而且仅需要每个来源一个连接,随之带来诸多性能优势。

大多数 HTTP 传输都是短暂且急促的,而 TCP 则针对长时间的批量数据传输进行了优化。 通过重用相同的连接,HTTP/2 既可以更有效地利用每个 TCP 连接,也可以显著降低整体协议开销。 不仅如此,使用更少的连接还可以减少占用的内存和处理空间,也可以缩短完整连接路径(即,客户端、可信中介和源服务器之间的路径) 这降低了整体运行成本并提高了网络利用率和容量。 因此,迁移到 HTTP/2 不仅可以减少网络延迟,还有助于提高通量和降低运行成本。

连接数量减少对提升 HTTPS 部署的性能来说是一项特别重要的功能:可以减少开销较大的 TLS 连接数、提升会话重用率,以及从整体上减少所需的客户端和服务器资源。

三、标头压缩

每个 HTTP 传输都承载一组标头,这些标头说明了传输的资源及其属性。 在 HTTP/1.x 中,此元数据始终以纯文本形式,通常会给每个传输增加 500–800 字节的开销。如果使用 HTTP Cookie,增加的开销有时会达到上千字节。 (请参阅测量和控制协议开销。) 为了减少此开销和提升性能,HTTP/2 使用 HPACK 压缩格式压缩请求和响应标头元数据,这种格式采用两种简单但是强大的技术:

  1. 这种格式支持通过静态霍夫曼代码对传输的标头字段进行编码,从而减小了各个传输的大小。
  2. 这种格式要求客户端和服务器同时维护和更新一个包含之前见过的标头字段的索引列表(换句话说,它可以建立一个共享的压缩上下文),此列表随后会用作参考,对之前传输的值进行有效编码。

利用霍夫曼编码,可以在传输时对各个值进行压缩,而利用之前传输值的索引列表,我们可以通过传输索引值的方式对重复值进行编码,索引值可用于有效查询和重构完整的标头键值对。

图片来源:Introduction to HTTP/2

作为一种进一步优化方式,HPACK 压缩上下文包含一个静态表和一个动态表:静态表在规范中定义,并提供了一个包含所有连接都可能使用的常用 HTTP 标头字段(例如,有效标头名称)的列表;动态表最初为空,将根据在特定连接内交换的值进行更新。 因此,为之前未见过的值采用静态 Huffman 编码,并替换每一侧静态表或动态表中已存在值的索引,可以减小每个请求的大小。

注:在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异:所有标头字段名称均为小写,请求行现在拆分成各个 :method:scheme:authority:path 伪标头字段。

四、服务端推送

HTTP/2 新增的另一个强大的新功能是,服务器可以对一个客户端请求发送多个响应。 换句话说,除了对最初请求的响应外,服务器还可以向客户端推送额外资源,而无需客户端明确地请求。

注:HTTP/2 打破了严格的请求-响应语义,支持一对多和服务器发起的推送工作流,在浏览器内外开启了全新的互动可能性。 这是一项使能功能,对我们思考协议、协议用途和使用方式具有重要的长期影响。

为什么在浏览器中需要一种此类机制呢?一个典型的网络应用包含多种资源,客户端需要检查服务器提供的文档才能逐个找到它们。 那为什么不让服务器提前推送这些资源,从而减少额外的延迟时间呢? 服务器已经知道客户端下一步要请求什么资源,这时候服务器推送即可派上用场。

事实上,如果您在网页中内联过 CSS、JavaScript,或者通过数据 URI 内联过其他资产(请参阅资源内联),那么您就已经亲身体验过服务器推送了。 对于将资源手动内联到文档中的过程,我们实际上是在将资源推送给客户端,而不是等待客户端请求。 使用 HTTP/2,我们不仅可以实现相同结果,还会获得其他性能优势。

五、参考

High Performance Browser Networking

HTTP/2

Introduction to HTTP/2

一、长连接和短连接

长连接和短链接的概念:

  • 短连接:传输完数据后连接立刻关闭。
  • 长连接:传输完数据后不会立刻关闭连接,下次传输数据继续复用这个连接。

很容易看出,长连接和短连接的主要区别就是连接完成后是否会关闭连接,长连接不会在完成后立马关闭。

众所周知,HTTP是基于TCP的,TCP连接的建立和释放需要经过三次握手和四次挥手,这个过程相较于数据传输是极度费时的。而一个web页面往往包含许多个页面请求,因此,使用长连接的优势在于不会频繁创建和释放连接。连接的保持在高并发或者大量请求的情况下能大大减少连接建立的时间,这点对于客户端和服务端来说都能提升性能。

长连接除了可以减少连接建立的时间以外,还有一个重要的用处就是可以检测网络中端到端的故障。因为TCP本身是没有故障检测机制的,默认的keep-alive机制检测时间太长,如果线路的一端异常导致连接没有释放,会导致另一端一直占用连接资源,浪费资源。

二、HTTP的keep-alive

HTTP的长连接保持通过指定Connection头部完成,如果值为keep-alive表明希望建立长连接,而如果值为close则表示短连接。当然,长连接还需要服务端软件也支持,目前广泛使用的apache/nginx等HTTP服务程序都能支持长连接。

在HTTP1.0中,Keep-Alive默认是关闭的,需要手动指定才能开启。而HTTP1.1开始,Keep-Alive默认是开启的。

当开启keep-alive后,同一个TCP连接会发送多个HTTP请求到对方。但是由于HTTP协议的无状态特性,只有一个HTTP连接完成之后才能继续在这个连接上发送下一个HTTP请求,即必须是pipeline模式。

以下是一个keep-alive抓包的示例,在nginx上开启keep-alive,使用浏览器访问web页面,同时使用wireshark抓包。抓到的数据包如下:

以上都是由客户端端口50591发送到服务端80的数据,数据包已经被分为了5段,分别是:

  1. TCP三次握手
  2. HTTP请求+响应
  3. HTTP请求+响应
  4. TCP的keep-alive
  5. TCP四次挥手

其中第二段和第三段都是单独的HTTP请求,说明一个TCP连接中确实可以完成多个HTTP请求。HTTP数据交互过程wireshark已经帮我们解析出来了,很明显就能看出。

同时,通过追踪HTTP流也可以看到HTTP的交互过程:

红色框出来的是第二次HTTP请求,它是在第一个HTTP请求的响应发送完成之后才执行的。

一、概述

事务的出现给并发带来了巨大的便利性,它的ACID特性使得数据在并发时更加可靠。但是对于事务而言,它也会导致出现第一类丢失更新第二类丢失更新脏读不可重复读以及幻读的问题,当然又出现了多种事务隔离级别来避免在产生这几类问题。那么隔离级别是如何实现的呢?

这就是多版本并发控制(MVCC)要做的事情了。《高性能MySQL》中对MVCC的描述为:

  • MySQL的大多数事务性存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制。不仅是MySQL,包括Oracle、PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。
  • 可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。
  • MVCC的实现,是通过保存数据在讴歌时间点的快照来实现的。也就是说,不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始时间的不同,每个事务同一张表、同一时刻看到的数据可能是不一样的。

MVCC的核心功能点是快照,多个事务更新相同数据时,各自都会生成一份对应数据的快照,这个快照被称为一致性读视图(consistent read view)。有了这个视图之后,每个事务都只对自己内部的视图进行更改,这样就不会影响其他事务了,数据并发就不会受到影响。

问题的关键点就在于快照如何创建以及如何把多个事务的更改统一起来。

因为MyISAM不支持事务,因此本篇文章主要讨论的是InnoDB。

二、MVCC基本原理

首先第一个问题:快照是如何创建的?是不是就是给每个事务都拷贝一份数据呢,是不是有100G数据,那每个事务也要拷贝100G数据呢?当然不是。

每个事务都有一个事务ID叫做transaction id,这个id在事务刚启动的时候向InnoDB申请,它不重复并且严格递增。InnoDB隐藏了一个包含最新改动的事务id,每个事务修改后都会把这个字段设置为自己的事务ID。其他事务启动的时候记录下这个最新ID,然后修改的时候比对ID是否有修改。如果没有修改,说明这一行没有改动过,当前事务也能直接修改。如果ID变化了,则就要查找undolog,找到可用的合适的记录。

因此,创建快照就只要记录下这个事务ID就可以了,无需复制所有的数据。

在实现上,InnoDB给每个数据表都添加了隐藏的三列数据DB_TRX_ID/DB_ROLL_PTR/DB_ROW_ID,三者的含义:

  1. DB_TRX_ID: 标记了最新更新这条行记录的transaction id,每处理一个事务,其值自动+1。
  2. DB_ROLL_PTR: 回滚指针,记录了最新一次修改该条记录的undo log,回滚的时候就通过这个指针找到undo log回滚。
  3. DB_ROW_ID: 当数据表没有指定主键时,数据库会自动以这个列来作为主键,生成聚集索引。

每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务ID(即DB_TRX_ID列)。同时,旧的数据版本要保留(通过undo log保留),并且在新的数据版本中,能够有信息可以直接拿到它。也就是说,数据表中的一行数据,其实可能有多个版本。

例如存在以下数据表:

它实际上的表现形式为:

假设此时修改年龄为25,此时数据列和undo log的状态是:

undolog新生成了一个记录,保存了改动之前的数据。新记录中,通过设置DB_ROLL_PRT指向备份的undo log记录,方便回滚。如若再次修改年龄为18,那么两者的状态为:

三、MVCC具体过程

以下通过一个示例来描述MVCC具体的执行过程:

关于start transaction with consistent snapshot:

前面我们说过,行锁的加锁实际并不是事务启动的时候就创建的,而是在修改对应行的时候才创建的。这里的一致性视图默认情况下和视图也是一样,用到的时候才创建,并非事务已启动就创建。

start transaction with consistent snapshot语句的作用就是在启动事务的时候就创建一致性视图。

以上面的学生表为例,同时存在三个事务修改同一行数据中的值,其中事务A和事务B先执行,然后事务C更新数据并提交。此刻事务A和事务B的更新和查询记录的结果会是怎样?

初始时的行数据为:

idnameage
1maqian24

测试

首先启动启动两个终端模拟事务A和事务B,执行start transaction with consistent snapshot

然后开启第三个终端模拟事务C,执行:

mysql> update stu_info set age = age + 1 where name = 'maqian';
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

然后在事务B执行以下操作:先查询age的值,然后也把age加一,然后再查询age的值。

事务B在更新操作执行之前,查询age的值是24,执行更新操作之后,再查询age值是26。

此时事务A查询age的值是24:

因此能得到的结论是:

  1. 事务B在更新数据前查到的age = 24,更新后查到的age = 25。
  2. 事务A查询到的数据是24。

看起来不符合逻辑,为什么会这样呢?

3.1 查询逻辑

我们以trx_id_first/trx_id_last/trx_id_currennt分别表示事务的低水位、高水位和当前事务。

如何理解高低水位:

  • 低水位:已经提交事务的最大值,即启动当前事务时候已经提交了的事务。
  • 高水位:未开始事务的最小值,即当前事务启动时还未启动的事务。
  • 当前事务:高低水位之间的事务,即当前事务启动时候已经存在的未提交事务。

以图形表示为:

图片来源:极客时间

在事务开始的时候,除了生成一致性视图,还要生成一个对应的视图数组,这个数组里面表示的就是所有未提交事务的集合(黄色区域)。查询数据的时候有三种情况:

  1. 数据未提交,数据不可见。
  2. 数据已提交,但是事务ID处于当前事务的高水位段,不可见。
  3. 数据已提交,并且事务ID实在当前事务之前创建,可见。

以上面的测试过程为例,事务A的id等于1,事务B的id等于2,事务C的id等于3。那么事务启动时,事务A的视图数组为[0, 1],事务B的视图数组为[0, 1, 2],事务C的视图数组为[0, 1, 2, 3]

事务C最后启动,因为是自动提交,因此执行完update之后就已经commit了,此时记录的DB_TRX_ID = 3。它处于事务B的高水位区,虽然已经提交但是也不可见,命中第二条规则。因此它要先通过undo log找到一个可见的版本,找到上一个版本trx_id = 0位于它的可见区,然后读取这条记录的age值为24.

而事务B对于事务A而言,也是处于高水位区,并且事务B修改age之后没有提交,所以rtx_id = 2的事务对事务A是不可见的,命中了规则一,要往前找其他版本。先找到上一个版本trx_id = 3后发现还不是可见的,需要继续往前找,找到rtx_id = 0的记录,它对事务A可见,再读取age值为24。

3.2 更新逻辑

上面有一个不合逻辑的点在于事务B,事务B它在更新age的前后分别查询age的值是对不上的:加一之前是24,加一之后是26。这是个什么逻辑呢?

对于更新而言,它有一个很重要的概念是当前读,当前读的意思是:更新的时候,要使用当前版本的记录来读。所谓当前版本指的就是最新更新后的记录,在上面的例子中也就是trx_id = 3的记录。

在事务B修改age的值之前,此时读取age值就像上面所说:先找到trx_id = 3的记录,发现不可见,然后再读取trx_id = 0的记录。

但是事务B在修改age的时候,先读取当前是最新的改动trx_id = 3这条记录,此时age的值为25。然后事务C把age值加一,并设置trx_id = 2。事务C再读取数据,发现最新的改动事务id是2,也就是自己。处于未提交的当前事务区,就能读到age是26了。

四、参考

MySQL-InnoDB-MVCC多版本并发控制

一、行锁和两阶段锁协议

行锁:顾名思义,就是对某一行加锁,修改的时候不会锁住整个表。相对于表锁来说,行锁的开销更大(因为涉及到MVCC等需要保存快照),但是粒度更小,更适合于高并发场景。行锁是每个引擎单独实现的,但是并不是所有的引擎都实现了行锁。例如MyISAM就没有实现行锁,它只支持表锁,而InnoDB支持行锁,因此高并发场景基本都会选择InnoDB作为数据库引擎。

行锁是系统自动加上的,无需手动干预。当多个事务同时更新一行数据时,后来事务的更新操作会被阻塞,直到行锁被释放才能更新。而行锁并不是在事务一启动就加上了,而是在真正需要的时候才加锁,也就是说只有在更新的时候才对行加锁。这样做有两个好处:

  1. 减小锁的粒度和加锁时长,提高并发度。
  2. 事务启动的时候,并没有明确说明需要修改什么行,此时如果如果要锁定行必须锁定整个表才行。

和加锁时机不同,行锁不是在更新完行之后立马就解锁,而是在事务执行完成(执行了commit或者rollback)之后才解锁。这两个加锁的时机被称为两阶段锁协议

例如以下表包含了学生信息:

# 创建表
CREATE TABLE stu_info  (
  `id` int(0) UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(20) NOT NULL DEFAULT '',
  `age` tinyint(0) UNSIGNED NOT NULL DEFAULT 20,
  PRIMARY KEY (`id`)
) ENGINE = InnoDB CHARACTER SET = utf8;
# 插入三行数据
insert into stu_info(name, age) values('maqian', 24), ('zed', 10), ('yasuo', 15);

启动两个事务,同时修改id为2的学生的年龄,其中事务一先修改但不提交,事务二也修改同一行内容,事务二就会被阻塞。具体的执行流程为:

首先,第一个事务执行修改:

事务1

然后,第二个事务也执行修改:

image.png

此时事务二被阻塞,因此这一条记录已经被事务一锁定了,要等待事务一释放锁后才能修改。

一直到一段时间过去后事务二会弹出报错,意思时等待锁超时了:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

此时事务二虽然不能修改id为2的记录,但是可以修改其他id的记录:

这说明,两个事务都只是对各自修改的记录行加锁了,并没有对整个表加锁。两个事务执行完成后的表:

+----+--------+-----+
| id | name   | age |
+----+--------+-----+
|  1 | maqian |  24 |
|  2 | zed    |  16 |
|  3 | yasuo  |  18 |
+----+--------+-----+

行锁变表锁

当行锁涉及的索引失效时,会导致行锁变成表锁。例如上面的示例中修改条件都是id = 2,因为id是主键,默认会生成索引。因此两个事务更新记录时使用的是行锁,事务一锁住id = 2的行的时候,事务二还是能更新id = 3的行。

但是一旦把条件改成其他列,即where后面的条件改成其他(如where name 'zed')时,行锁会升级成表锁。即使事务一只修改name = 'zed'的列,事务二也无法修改name = 'yasuo'这一列。

二、死锁

在事务中,多个事务间同时对不同的行加锁,可能会导致死锁。例如以下场景:

事务一先修改id = 3的用户的年龄并锁住该行,然后事务二修改id = 2的年龄也锁住这行。接着事务一修改id = 2的用户年龄,因为这一行已经被事务二锁住了,所以这条语句会被阻塞,要等到事务二释放锁才能继续。但是此时事务二想修改id = 3的用户的年龄,刚好这行又被事务一给锁住。两个事务互相都要等待对方先释放锁,产生了死锁。

这个场景比较类似《unix环境高级编程》中对死锁产生原因的描述:当多个线程以不当的顺序同时对多个锁加锁就会导致死锁。

死锁产生后,MySQL有两种方法来解决死锁:

  1. 等待锁超时。如上面测试的一样,当更新语句等待锁一段时间后会超时退出,不会无休止等待下去。但是这个超时时间默认是50S,太长了,在高并发系统中是无法接受的。
  2. 设置死锁检测。通过设置innodb_deadlock_detect = on,开启MySQL的死锁检测,系统会自动检测死锁的事务并回滚改动。

大部分时候使用的都是方式二,主动检测死锁来释放锁,因为方式一超时时间太长了。但是方式二也有缺点是检测时间是O($n^2$)。例如,有100个事务在执行的时候,每个事务执行的时候都要和另外99个线程检测是否存在死锁。此时需要执行10000次死锁检测,当事务的数量再上升的时候,死锁的检测又会上升一个量级。

如何解决这个问题呢?一般有以下几种办法:

  1. 合理规划数据表的执行顺序,尽量避免多个事务以不同顺序更新同一个表。如果都是相同的顺序访问,是不会产生死锁的。这种情况下,可以关闭死锁检测。
  2. 控制并发量,在代码中限制同时执行事务的数量,控制在10以内,超过的排队执行。这样就减少了死锁检测的次数,虽然有排队的事务,但是排队的时间远远小于多个事务之间的死锁检测时间。

往vector中添加元素时,如果空间不够将会导致扩容。vector有两个属性:size和capacity。size表示已经使用的数据容量,capacity表示数组的实际容量,包含已使用的和未使用的。

vector扩容规则:

  1. 当数组大小不够容纳新增元素时,开辟更大的内存空间,把旧空间上的数据复制过来,然后在新空间中继续增加。
  2. 新的更大的内存空间,一般是当前空间的1.5倍或者2倍,这个1.5或者2被称为扩容因子,不同系统实现扩容因子也不同。
注意,扩容会导致迭代器失效。

在VS2017中,vector的扩容因子是1.5。可以追踪push_back的实现:

decltype(auto) emplace_back(_Valty&&... _Val)
{    // insert by perfectly forwarding into element at end, provide strong guarantee
    if (_Has_unused_capacity())
    {
        return (_Emplace_back_with_unused_capacity(_STD forward<_Valty>(_Val)...));
    }

    _Ty& _Result = *_Emplace_reallocate(this->_Mylast(), _STD forward<_Valty>(_Val)...);
    // ...
}

函数中先通过_Has_unused_capacity函数判断是否有还有未使用的空间,如果有未使用的空间,直接在未使用的空间上新增。如果没有未使用的空间了,就需要执行_Emplace_reallocate重新扩容:

pointer _Emplace_reallocate(const pointer _Whereptr, _Valty&&... _Val)
{    
    // ..
    const size_type _Newsize = _Oldsize + 1;
    const size_type _Newcapacity = _Calculate_growth(_Newsize);
    // ..
}

函数中先判断新长度,然后继续调用_Calculate_growth扩容,这个函数才是真正用来扩容的函数。

忽略过其他判断逻辑,_Calculate_growth的实现为:

size_type _Calculate_growth(const size_type _Newsize) const
{    
    // ...
    const size_type _Geometric = _Oldcapacity + _Oldcapacity / 2;
    // ...
}

新的扩容大小等于当前容量再加上当前容量的一半,即按照1.5倍扩容。

在GCC的实现中,vector扩容是2倍扩容的。

1.5倍扩容和2倍扩容的区别

  1. 扩容因子越大,需要分配的新内存空间越多,越耗时。空闲空间较多,内存利用率低。
  2. 扩容因子越小,需要再分配的可能性就更高,多次扩容耗时。空闲空间较少,内存利用率高。

因此,小规模数组,添加元素不频繁的,建议使用扩容因子更小的。当数据规模越大,插入更频繁,大扩容因子更适合。

示例代码

#include <iostream>
#include <vector>

using namespace std;

int main() {
    int i;
    vector<int> v;

    cout << v.capacity() << " ";
    for (i = 0; i < 10; i++) {
        v.push_back(1);
        cout << v.capacity() << " ";
    }

    return 0;
}

以上代码通过循环插入了十个元素,并打印出每次插入后vector的容量:

0 1 2 3 4 6 6 9 9 9 13

能看出的增长规律为:0 -> 1 -> 2 -> 3 -> 4 -> 6 -> 9 -> 13,而同样的代码通过G++编译后的输出结果为:

0 1 2 4 4 8 8 8 8 16 16