几道不太简单的C艹面试题

下面几道问题是我整理的来源于网络的比较坑的问题,看起来题目很简单,但理清整个逻辑是非常不容易的,除非对底层知识有非常深刻的理解。你觉得你掌握了C++的基础么?这几道简单的问题,来试试吧?

1、下面哪些表达式必须加锁
A、a = 4; B、a = b; C、a++; D、a = b + 4;

2、下面代码double计算时间总比float小,请解释原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float fs[] = { 1.1f, 2.2f, 3.3f, 4.4f, 5.5f };
double ds[] = { 1.1, 2.2, 3.3, 4.4, 5.5 };
DWORD tk = ::GetTickCount ();
float f = 0.0f;
for (int i = 0; i < 100000000; ++i) {
    f += fs[i % 5];
}
tk = ::GetTickCount () - tk;
std::cout << "float:" << f << "    " << tk << '\n';
tk = ::GetTickCount ();
double d = 0.0;
for (int i = 0; i < 100000000; ++i) {
    d += ds[i % 5];
}
tk = ::GetTickCount () - tk;
std::cout << "double:" << d << "    " << tk << '\n';

3、下面代码,257的二维数组计算时间总比256的二维数组计算时间短,请解释原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
char a[256][256], b[257][257]; // 全局变量
DWORD tk = ::GetTickCount ();
for (int i = 0; i < 256; ++i)
    for (int j = 0; j < 256; ++j)
        for (int k = 0; k < 10000; ++k)
            a[i][j] += '\x01';
tk = ::GetTickCount () - tk;
std::cout << "256:" << tk << '\n';
tk = ::GetTickCount ();
for (int i = 0; i < 257; ++i)
    for (int j = 0; j < 257; ++j)
        for (int k = 0; k < 10000; ++k)
            b[i][j] += '\x01';
tk = ::GetTickCount () - tk;
std::cout << "257:" << tk << '\n';

4、std::string中的COW优化与SSO优化分别对应怎样的输出?

1
2
3
4
5
6
7
std::string a = "hello";
std::string b = a;
std::cout < < (a.c_str () == b.c_str () ? "true" : "false") << '\n';
char ch = b[0];
std::cout << (a.c_str () == b.c_str () ? "true" : "false") << '\n';
a = "";
std::cout << (a.c_str () == b.c_str () ? "true" : "false") << '\n';


----------------------------------------(华丽的分割线)----------------------------------------

好了,下面公布答案,并给出答案解析。
1、答案是C
这道题有两个考点,网上有很多解析,但几乎都是错的,原因在于没有看清加锁的本质。一条语句,编译为多条汇编指令后就必须加锁了?不见得。加锁的意义在于,只有在不加锁的情况下导致出现不可预料的情况,这时候加锁才是有意义的。否则加锁是无意义的。
如果只学linux,那么四个都不用选,因为c是只在Windows平台出现的情况。具体解析如下:
A、不用加锁的原因是,将一个立即数赋值给内存区域,无论多少个线程来执行,结果都是固定的,所以a不用选
B、我们先假设b的值可能为1-10中的任意一个数字,随机改变,那么预期加锁的a的值也为1-10中的任意一个数字。好,这时候,我们把锁给去掉,并考虑极端情况,也就是一条赋值语句被打断然后执行另一条赋值语句的情况。这句赋值语句的汇编代码为:
  mov eax, dword ptr[b]
  mov dword ptr[a], eax
  我们考虑两种情况:
  (1)、第一条指令被打断的情况:假设第一个线程b原始值为5,第二个下城b原始值为6,那么,首先第一个线程执行完第一条语句,此时eax的值为5,然后cpu切换至另一个现场,赋值完成,此时a的值为6,然后cpu恢复第一个现场,继续执行,将eax中的5赋值给a,此时a的值为5。a的结果依旧是b的结果中的其中一个,所以符合预期。
  (2)、多核系统两条指令并发执行的情况:数据流动过程为,cpu总线到北桥芯片,再到内存。可以理解为,北桥是cpu到内存的桥梁。cpu向内存写数据总是先通过北桥芯片。这儿又会出现两种情况,也就是早期双核和现代多核。早期双核只有一个北桥芯片,这时候访问内存是抢占式,也就是说并发执行内存赋值语句将会造成,一个核心的内存赋值数据在流向内存过程中,另一个核心的内存赋值数据在北桥位置等待,使得两者有一个先后顺序,所以不会导致数据错乱,a依旧为b的两个值之一;现代多核下北桥可以实现并发,数据从cpu总线通过北桥后,进入到一级高速缓存位置,这儿的高速缓存是线程独占的,两个数据流将分别流向两块高速缓冲区域(假如某个核心所需内存数据没有在高速缓冲做映射,那么将会首先执行映射,使得两条数据流非同时执行,所以数据不会错乱,这儿就不考虑了),然后分别将高速缓冲区域标记为已修改。这时候的两块数据都在内存的一级高速缓冲区,然后,在cpu时间片切换换成,恢复场景时,分别两两块一级高速缓冲区的数据复制到二级高速缓冲区,这儿因为是遍历,所以有先后过程。所以,到了这儿,一级高速缓冲区的数据就有一份被覆盖掉了,在接下来的讲数据移动到内存过程中都不会再冲突。所以,B选项是没必要加锁的。
C、这个需要加锁的原因是,它使用MSVC编译,再未优化的情况下,将会编译为3条汇编指令:
  mov eax, dword ptr[a]
  add eax, 1
  mov dword ptr[a], eax
  原因是因为,某款老cpu不支持inc指令,而C艹为了兼容更多的cpu所以。。。然后,这几条指令的期望的a的值为这几条汇编指令的执行次数。这几条指令在被打断时的情况:首先假设a的值为0,赋值给eax后,被打断,此时eax为0,a为0,这是另一个加1操作开始执行,a的值由0变为1,然后恢复前面的线程,eax=0,+1,赋值给a,此时a的值依旧为1,与期望值不符,所以这条语句需要加锁。
D、这条不需加锁的原因是,a的期望值始终为b的值+4,假设b的值为1-10,那么a的值范围始终为5-14,具体详情可以参考B选项,所以无需加锁。

2、这道题的答案用一句话概括:浮点数通过CPU的浮点计算指令进行计算,编译器只考虑double类型计算,也就是float的计算都会先被强转为double,然后再计算,相对于double来说,多了很多强转的时间,所以float更慢。

3、这道题也涉及内存高速缓冲区。首先,CPU读一块内存区域时,首先会从物理内存拷贝至三级缓存,然后从三级缓存拷贝至二级缓存,然后从二级缓存拷贝至一级缓存,然后再从一级缓存中读;写的步骤是先写入一级缓存,然后一级缓存拷贝至二级缓存,然后二级缓存拷贝至三级缓存,然后三级缓存拷贝至物理内存。在拷贝过程中,块的大小为1k,也就是256的整数倍。这儿的+=是一个读写过程,所以从物理内存复制到一级缓存,和从一级缓存复制到物理内存的步骤都会执行。我们先计算一下大小,第一个二维数组一共占256k,第二个二维数组一共占大约258k,大小几乎一样,所以从总的读写时间上几乎没有区别,只是前者稍快;然后,内存块数据从物理内存复制到高速缓存中是逐级递减的,原因是物理内存最大,以我现在的电脑为例,内存总大小为16G,三级缓存有6M,二级缓存有1M,一级缓存只有128K了,我的内存还算大的,更小的一级缓存甚至只有64k、32k、16k。内存在逐级拷贝数据过程中,拷贝的大小始终是依次递减,如果发现之前拷贝的不够用了,就会产生中断,触发缺页异常,然后向低一级的高速缓存找是否有这个页面,如果没有那么再依次递归寻找内存,直到内存加载完成后再依次执行。第一个大循环,每次循环的数据刚好在一块高速缓存上,结果是很清晰的,但第二个大循环,因为一次循环始终会超出一块内存页的数据,每次都会迫使高速缓存读取更多的内存页,使得后面的循环指令能得以更顺畅的执行。这个过程主要在三级高速缓存到一级高速缓存过程中体现。所以,257的循环时间总是更短。

4、C艹标准库的字符串类,在不考虑std::string_view的情况下,有两种优化方案,分别分为两派。
第一派是传统gcc,使用COW优化方式。这种优化方式下,字符串复制步骤不会讲原始数据拷贝,而是两个类指向同一个字符串存放区域。所以COW下,第一个输出为true。然后,字符串类的operator[]操作,实际上返回的是字符串某区域的字符的引用,可以读,也可以写。从编译器角度来说,调用operator[]真的是很难区别到底是读还是写,所以干脆统一,只要有operator[]操作,统一触发COW,如果内存区域为两个字符串的拷贝,那么统一复制一份,将调用operator[]操作的对象的字符串给独立出去,所以这时候引用的地址将会改变,第二个输出为false;第三个,两者指向了完全不同的地址,所以依旧是false。
第二派是MSVC,使用SSO优化方式。这种优化方式下,会将短的字符串复制到栈中,所以不同变量的内存地址始终不同,三者均为false。但SSO优化有一个特性,那就是栈的内存长度很难改变,假如执行+=操作,很容易就会重新分配新的堆内存。
这是std::string的两种优化实现方式,各有优缺点,没有哪一种更好。要我说的话,随着编译器的进化,两者都会优化的越来越好。COW优化方式在未来编译器可以识别代码是否通过operstor[]写内存,如果没有写,那么不触发COW;SSO优化方式在未来识别对象后面的字符串的使用,如果没有做其他操作则直接SSO优化,如果做小的+=那么在栈中多预留出小块内存;如果做大的+=那么直接放堆中。

发布者

fawdlstty

又一只萌萌哒程序猿~~

发表评论

电子邮件地址不会被公开。 必填项已用*标注