C陷阱:数组越界遍历,不报错却出现死循环?从内存解析角度看数组与局部变量之“爱恨纠葛”

简介: 在代码练习中,通常会避免数组越界访问,但如果运行了这样的代码,可能会导致未定义行为,例如死循环。当循环遍历数组时,如果下标超出数组长度,程序可能会持续停留在循环体内。这种情况的发生与数组和局部变量(如循环变量)在内存中的布局有关。在某些编译器和环境下,数组和局部变量可能在栈上相邻存储,数组越界访问可能会修改到循环变量的值,导致循环条件始终满足,从而形成死循环。理解这种情况有助于我们更好地理解和预防这类编程错误。

在平时的代码练习中,数组越界访问当然是会被规避的。然而,如果运行了令数组越界访问的代码,会产生什么后果?如果我们从未了解过,可能会下意识地认为,编译器会报错、阻止程序运行,或直接挂掉程序。


事实上还有一种较为常见的结果:死循环。循环遍历数组时,如果遍历的数组下标超出数组长度,程序无休止地卡在了循环体内。这和栈中数组与循环变量(局部变量)创建的位置紧密相关。


本文从创建数组和局部变量的内存解析角度,对上述情况加以说明。学习C语言的我们也应当对这类情况有所了解并能清晰解释。


本文先用调试和画图的方式解析死循环出现的原因,文末附上了完整的文字解释,可供大家参考。


注: 本文实验采用的编译器为 RedPanda DevC++,在较新版本的编译器(如VS 2022)下测试结果可能会有差异,因为新的编译器会对代码进行优化。以下是本文的主要内容。


我们来看下面这组代码:


#include <stdio.h>
int main()
{
    int i = 0;
    int arr[] = {1,2,3,4,5,6,7,8,9,10};    //数组长度为10
    for(i=0; i<=12; i++)    //循环变量从0到12,出现了越界情况
    {
        arr[i] = 0;    //将数组内的每个元素置0
        printf("hello\n");    //并打印hello
    }
    return 0;
}


上述程序代码中,数组长度为10,然而下标却取到了11、12,数组的下标越界。


当程序运行后,出现了如下情况:



死循环地打印hello


注意:编译器并未对下标越界的情况报错,程序可以正常启动,但在程序中出现了bug。光以肉眼看,我们无法排查出问题。


我们通过调试来进一步观察。



05行的小警告是提示我arr数组set but not used,与该情况无关~

 

一、调试


1. 启动调试,我们在左侧添加一些监视,包括循环变量i与arr[9](最后一个数组元素)到arr[12]之间的值。 此时理论上来说,arr[10]、arr[11]、arr[12]三个数由于不是我们的数组元素,没有被初始化,里面装的是随机值。



2. 单步调试。


在第二次结束循环体后可以看到,i 的值为2,数组的前两个元素arr[0]与arr[1]也如期被我们改成了0.控制台也打印了两个"hello",此时程序的运行一切正常。




3. 经过观察我们发现,当 i 从0到9时(也就是在数组界内时),程序的运行都是正常的:每次循环都有一个数组元素被置0,并且打印一个"hello"。我们让i快速来到9,这时我们观察在这之后程序内各变量值的变化。




4. 我们让 i 来到10,这时有:数组arr[10],要操作arr[10] = 0;注意,这里也并不会有任何报错提示。因为数组名本质上是一个地址,arr[10]即*(arr+10),尽管此时的空间并不是我们数组内的合法空间,但依旧能够访问并更改其值。


如下图,arr[10]也确实被改成了0.


内存中不属于数组元素的值竟然也能被直接更改,可见野指针/数组越界访问确实是存在非常大的风险的。)




这时重点来了!


我们再向下走,当arr[11]也被改成0时,i竟然也被改成了0!!




很难让人不怀疑:有没有可能,arr[11]和i共用的是同一个空间?换句话说,arr[11]就是i?


通过取地址来求证,我们发现,arr[11]与i的地址相同,从而印证了我们上面的猜想:arr[11]实际上就是i



事实上如果一开始就在监视窗口中添加了arr[11],可以很清晰地发现,随着每次循环i的值发生变化,arr[11]的值也一直在变化:



1. i等于0时,arr[11]也是0



2. i等于5时,arr[11]也是5


确实,arr[11]与i是同步变化的,arr[11]就是i,它们共用同一块内存单元,它们实质上代表同一个变量。


因此,每当 i 到达11时候,arr[11]就会被置为0,而arr[11]就是i ,相当于 i 被置为0.于是, i 在0到11之间反复横跳,永远也无法跳出这个范围。同样,arr[12]永远也无法遍历到,循环无法自行结束,这样一来便出现了死循环的现象。


下面我们画内存解析图,分析底层原理。


二、内存解析图解


首先我们需要知道:


1. 数组随着下标的增长地址是由低到高变化的。

2. i 和 arr 是局部变量,局部变量是放在栈区的。

3. 栈区内存的使用习惯:先使用高地址的空间,再使用低地址的空间。


上述代码中,创建局部变量i和数组的代码顺序是这样的:


int i = 0;    //先创建局部变量 i
int arr[] = {1,2,3,4,5,6,7,8,9,10};    //再创建数组arr


由栈区内存的使用习惯知,变量 i 的地址要高于arr数组。因此,内存中各个变量的创建位置如下图所示:



内存解析图,一个格子代表一个int型


绿色部分为数组元素,连续的10个空间;蓝色部分为arr[11],即 i 。


当遍历数组元素时,自低地址向高地址走,而 i 又恰好“等”在高地址的某处。数组不越界则已,一越界就很有可能碰上在“高”处等候的变量 i 。此时无意间操作了循环变量 i ,就极有可能造成循环失控,而死循环正是循环失控的一种。



数组自低地址向高地址遍历元素,越界后可能碰到高地址的 i


同样,这种循环失控并不是百分百的。就拿本题来说,若把 i 的边界控制成为 i <10,令 i 到不了11,那么程序依旧可以正常运行,不会报错或失控。



那么变量 i 的空间与数组末元素的空间相隔多少个空间呢? 这完全取决于编译器如何实现。经过不完全测验,一些编译器及其空间分配关系如下:


1. VC 6.0                中间没有多余空间。


2. gcc                中间空1个整型。


3. vs2013/2019                中间空2个整型。


4. DevC++                中间空1个整型。


5. vs2022                功能优化,在运行时会直接报错,不会出现如上问题。


*可能有误,具体实现以编译器实际情况为准。



博主本人在vs2022测试时发现的情况


release模式下,代码会自动优化,规避掉可能存在的隐患。因此在release模式下运行,也不会出现循环失控。



release模式下检查i与arr元素的地址高低关系,发现虽然i先声明创建但i的地址却低于arr[0]

这是编译器自动调整的结果


如果调换代码书写的位置,又会如何呢?


int arr[] = {1,2,3,4,5,6,7,8,9,10};
int i = 0;


这时,由于变量创建的顺序发生了变化,而内存的使用习惯仍然是先使用高地址,再使用低地址,此时我们的变量 i 在内存中的地址在数组arr的更低地址处,此时不会发生循环失控。



如图,此时即使循环越界,i 与arr[i]也不会相遇


需要注意的是,虽然这样不会发生循环失控,但仍然可能会有新的问题。毕竟数组发生越界,且强行更改了不属于数组元素空间内的值,编译器可能会弹窗报错(类似上面vs2022的处理情况)。


之所以原来的代码书写位置不会让编译器报错,是因为程序卡在了死循环,没有报错的机会。而当不出现死循环时,编译器就有可能发现异常,弹窗报错。(具体看编译器的处理。)


三、总结:原因解释


面试题



解释


变量i与数组a在栈上开辟空间,而我们知道,栈区的使用习惯是先使用高地址再使用低地址,因此i在高地址的位置,arr数组在低地址的位置。同时,随着数组下标的增长,地址是由低到高变化的。数组适当往后越界,就有可能覆盖到 i ,将循环变量 i 改变,从而导致循环的判断条件恒为真,最终造成程序循环失控。(结合示意图说明更清晰。)






相关文章
|
4天前
|
机器学习/深度学习 缓存 算法
netty源码解解析(4.0)-25 ByteBuf内存池:PoolArena-PoolChunk
netty源码解解析(4.0)-25 ByteBuf内存池:PoolArena-PoolChunk
|
3天前
|
缓存 Java 编译器
Java内存模型深度解析
【6月更文挑战第22天】在探索Java内存模型的迷宫中,我们不仅需要理解其结构,还要揭开它运作的神秘面纱。本文将深入挖掘Java内存模型的核心概念,从硬件架构出发,到Java内存模型的设计哲学,再到并发编程中的实际应用,我们将一步步解码Java内存模型的奥秘。
|
5天前
|
Java 机器人 数据库连接
Java中的内存泄漏问题解析与应对
Java中的内存泄漏问题解析与应对
|
7天前
|
JavaScript 前端开发 算法
【JavaScript】JavaScript 垃圾回收机制深度解析:内存管理的艺术
JavaScript的内存管理和垃圾回收机制涉及栈内存与堆内存、引用计数与标记-清除算法。栈内存存储基本类型和函数调用时的局部变量,而堆内存用于复杂数据类型,如对象和数组。垃圾回收主要通过标记-清除策略,处理不再被引用的对象。现代引擎如V8使用分代收集和增量标记等优化方法,减少停顿并提升性能。开发者应注意避免内存泄漏,如及时解除引用、管理DOM引用和定时器,使用WeakMap和WeakSet等。理解这些原理和最佳实践对于编写高效代码至关重要。
18 5
|
24天前
|
C++ 存储 Java
C++ 引用和指针:内存地址、创建方法及应用解析
'markdown'C++ 中的引用是现有变量的别名,用 `&` 创建。例如:`string &meal = food;`。指针通过 `&` 获取变量内存地址,用 `*` 创建。指针变量存储地址,如 `string *ptr = &food;`。引用不可为空且不可变,指针可为空且可变,适用于动态内存和复杂数据结构。两者在函数参数传递和效率提升方面各有优势。 ```
|
2天前
|
分布式计算 DataWorks 大数据
MaxCompute操作报错合集之pyodps3的报错信息里,报了程序的解析错误,是什么导致的
MaxCompute是阿里云提供的大规模离线数据处理服务,用于大数据分析、挖掘和报表生成等场景。在使用MaxCompute进行数据处理时,可能会遇到各种操作报错。以下是一些常见的MaxCompute操作报错及其可能的原因与解决措施的合集。
|
8天前
|
JSON 资源调度 Kubernetes
实时计算 Flink版操作报错合集之解析JSON数组时,遇到报错,该怎么解决
在使用实时计算Flink版过程中,可能会遇到各种错误,了解这些错误的原因及解决方法对于高效排错至关重要。针对具体问题,查看Flink的日志是关键,它们通常会提供更详细的错误信息和堆栈跟踪,有助于定位问题。此外,Flink社区文档和官方论坛也是寻求帮助的好去处。以下是一些常见的操作报错及其可能的原因与解决策略。
|
27天前
|
存储 缓存 监控
深度解析操作系统中的核心组件:进程管理与内存优化
【5月更文挑战第29天】 在现代计算技术的心脏,操作系统扮演着至关重要的角色。它不仅管理和控制计算机硬件资源,还为应用程序提供了一个运行环境。本文将深入探讨操作系统中的两个核心组件——进程管理和内存管理,并分析它们对系统性能的影响以及如何通过技术手段实现优化。通过对操作系统内部机制的剖析,我们将揭示这些组件是如何相互作用,以及它们如何共同提升系统的响应速度和稳定性。
|
9天前
|
消息中间件 存储 Kafka
实时计算 Flink版产品使用问题之 从Kafka读取数据,并与两个仅在任务启动时读取一次的维度表进行内连接(inner join)时,如果没有匹配到的数据会被直接丢弃还是会被存储在内存中
实时计算Flink版作为一种强大的流处理和批处理统一的计算框架,广泛应用于各种需要实时数据处理和分析的场景。实时计算Flink版通常结合SQL接口、DataStream API、以及与上下游数据源和存储系统的丰富连接器,提供了一套全面的解决方案,以应对各种实时计算需求。其低延迟、高吞吐、容错性强的特点,使其成为众多企业和组织实时数据处理首选的技术平台。以下是实时计算Flink版的一些典型使用合集。
|
1天前
|
存储 Java C++
Java虚拟机(JVM)管理内存划分为多个区域:程序计数器记录线程执行位置;虚拟机栈存储线程私有数据
Java虚拟机(JVM)管理内存划分为多个区域:程序计数器记录线程执行位置;虚拟机栈存储线程私有数据,如局部变量和操作数;本地方法栈支持native方法;堆存放所有线程的对象实例,由垃圾回收管理;方法区(在Java 8后变为元空间)存储类信息和常量;运行时常量池是方法区一部分,保存符号引用和常量;直接内存非JVM规范定义,手动管理,通过Buffer类使用。Java 8后,永久代被元空间取代,G1成为默认GC。
10 2

推荐镜像

更多