0%

new

用 new 申请的内存最少占用 1 个字节——尽管我们申请的可能是 0 个字节。

delete/delete[]

delete 和 delete[] 都会归还空间,但是 delete[] 会询问元素数量,并析构数组中的每个元素,而 delete 只会析构一个元素。

尽管虚析构函数允许我们正常 delete 掉指向子类对象的基类指针,但是在基类指针上使用 delete[] 可能是有错的:

#include <cstdio>

struct A {
    double a;
    virtual ~A() {
        puts("~A()");
        fflush(stdout);
    }
};

struct B : A {
    double b;
    ~B() override {
        puts("~B()");
        fflush(stdout);
    }
};

static_assert(sizeof(A) == 16);
static_assert(sizeof(B) == 24);

int main() {
    A *array_of_A = new B[2];
    delete [] array_of_A; // <-- 大问题!
}

noexcept 有几个用法:

  1. 在编译期返回一个常量布尔值,评估其表达式是否会抛出异常。
  2. 用于标志一个函数是否能抛出异常,需要一个编译期布尔常量作为参数。
  3. 标志函数不会抛出异常。相当于 noexcept(true)
// whether foo is declared noexcept depends on if the expression
// T() will throw any exceptions
template<class T>
void foo() noexcept(noexcept(T())) {}

void bar() noexcept(true) {}
void baz() noexcept { throw 42; } // noexcept is the same as noexcept(true)

int main()
{
    foo<int>(); // noexcept(noexcept(int())) => noexcept(true), so this is fine

    bar(); // fine
    baz(); // compiles, but at runtime this calls std::terminate
}

C++17 已经禁用 throw 的显式异常声明。(被称为 Dynamic exception specification

现在已经没有 Segment 概念,并发系数不生效。能支持高效的并发:

  1. 支持懒初始化,第一次写的时候发生,可能需要自旋但不需要加锁。
  2. 读的时候可能需要自旋,不需要加锁。
  3. 写的时候如果遇到正在扩容,则加入一起扩容;如果写时槽位为空,则只需要原子操作;写时操作非空,且不处在特殊状态,则需要加对象锁。因而读写互不阻塞、不同槽位的写不会阻塞、扩容不会阻塞(因为扩容时其他竞争线程也会被分配任务)、仅有单个槽位的多写需要阻塞。

为了节省空间,在 Node[] 中用 hash 为负的头结点来表示该拉链处于特殊状态:树结点、转移中、预留等。

转移的时候会以槽为单位进行,如果看到槽位正在转移中,则当前线程不会去抢夺工作,除非两个线程刚好看到了同一个槽位需要转移,这时会加对象锁处理。在遍历时会按照 (hash_of_key: ph & capacity_of_old_table: n) 计算出给定位,以此来分组(理想的情况会对半分),然后用头插法生成两条新链表转移到 nextTable。

💡 这当中有个优化过程:计算 lastRun,称链表尾部一连串对应 bit 相同的结点的起始位置为 lastRun,这系列结点将会被转移到 nextTable 的相同位置,因此会直接被转移,而不会对每个结点重新用头插法插入。头插法不仅需要遍历这一系列结点,还要对每个结点申请空间。

写的时候加锁(因为要替换 array 数组的引用,而且复制的过程比较耗时,不宜自旋),读的时候不用加锁。替换的安全性由 Java volatile 保证。

CPU 内存屏障:https://sf-zhou.github.io/programming/memory_barrier.html

volatile 与内存屏障总结: https://zhuanlan.zhihu.com/p/43526907

X86-64 下仅支持一种指令重排:Store-Load ,即读操作可能会重排到写操作前面,同时不同线程的写操作并没有保证全局可见,例子见《Intel® 64 and IA-32 Architectures Software Developer’s Manual》手册 8.6.1、8.2.3.7 节。要注意的是这个问题只能用 mfence 解决,不能靠组合 sfence 和 lfence 解决。(用 sfence+lfence 组合仅可以解决重排问题,但不能解决全局可见性问题,简单理解不如视为 sfencelfence 本身也能乱序重排)

X86-64 一般情况根本不会需要使用 lfencesfence 这两个指令,除非操作 Write-Through 内存或使用 non-temporal 指令(NT 指令,属于 SSE 指令集),比如 movntdq, movnti, maskmovq,这些指令也使用 Write-Through 内存策略,通常使用在图形学或视频处理,Linux 编程里就需要使用 GNC 提供的专门的函数(例子见参考资料 13:Memory part 5: What programmers can do)。

下面是 GNU 中的三种内存屏障定义方法,结合了编译器屏障和三种 CPU 屏障指令:

ReentrantLock 公平锁和非公平锁的实现

如果需要长时间等待,AQS 总是会将等待线程加入到队列尾部,唤醒时总是唤醒队首线程。这样做能够保证已经被睡眠的线程必定按照顺序唤醒。这样做难道不是永远都是公平锁?

不是!NonfairSync 在 tryAcquire() 时不会检查是否为队首。这意味着如果有队列之外的线程尝试获取锁(发生在刚刚调用 ReentrantLock.lock() 的阶段),可能会先于队首线程获得锁。虽然这种情况的发生非常需要巧合,但也是不公平的; FairSync 对这一巧合下的公平性做出了更高的要求。

公平和不公平的主要区别在于等待队列外的线程和等待队列上的线程之间的公平性。

CountDownLatch

只有 countDown() 被调用给定的次数后,await() 才能被唤醒。

互斥:锁

现在对象锁不尊重虚拟线程,但是 JUC 下的锁是尊重虚拟线程的。

共享:ScopedValue

ThreadLocal 和虚拟线程配合的不是很好,尽管功能上 ThreadLocal 是支持虚拟线程的,但是由于虚拟线程数量众多、生命周期短,使用 ThreadLocal 时虽然保证了线程安全,却创建了大量对象——这个问题在平台线程上就不明显。JEP 429 ScopedValues (Java 20) 是为了解决这个问题的。

💡 ThreadLocal 和线程池配合的也不是很好,因此短期任务结束后,将线程放回池中之前要及时清理数据。

ScopedValue 用 where 绑定共享变量,用 call 来启动虚拟线程,这样虚拟线程及子虚拟线程能够在调用环境中通过之前捕获的键(图中为 KEY)来获取值,这里使用方式也类似于之前的 ThreadLocal 查表。于是,一些本可以共享的变量实现了共享。

写在前面

本篇内容参考提案 Simpler implicit move

隐式移动是函数返回值优化的一种,在 C++ 不同版本有不同的规则,这篇文章主要讲隐式移动,不涉及其他返回值优化的内容。

变量表达式(id-expression)指的是仅由变量名组成的表达式,比如 x 或者 (x)。虽然变量本身可能是值类型或者右值引用类型,但是变量表达式是左值。

值类别可见 Value Categories

隐式移动的设计难题

隐式移动使得函数可以在更多的场景用移动构造函数来构造返回对象,从而避免拷贝。但是,隐式移动通常要求编译器在特定的条件下改变返回的表达式的值类别(lvalue / rvalue),这使得一些函数表现出让人意外的语义。

Plain Old Data

https://stackoverflow.com/a/4178176

成为 POD 的条件:

  1. 所有的标准类型都是 POD(尽管不是 aggregate)。
  2. 数组要成为 POD 的条件是每个元素都是 POD。
  3. 一个类要成为 POD,则必须首先是 aggregate,然后:
    1. 没有用户定义的赋值运算符。
    2. 没有析构函数。
    3. 所有成员都是 POD(递归定义)。

数据成员指针存储的是偏移

#include <iostream>
using namespace std;

struct Foo {
    int x;
    int y;
};

int main() {
    Foo x{2};
    int Foo::* px = &Foo::x;
    int Foo::* py = &Foo::y;
    cout << (unsigned long &)px << endl; // 输出0
    cout << (unsigned long &)py << endl; // 输出4
    cout << sizeof(px) << endl;          // gcc输出8
}

数据成员指针的大小和实现相关。

成员函数指针比一般指针占用更多空间

和编译器相关。gcc 里成员函数指针在 64 位机器下是 16 字节。

如果成员函数不是虚函数:函数地址固定;this 指针偏移也可以通过类型在编译时计算出来。先偏移后调用,应该只需要 8 个字节。

如果成员函数是虚函数: