游戏开发中内存的概念
我们编写代码时所用到的数据是存放在哪里的?为什么每次重新开始游戏时各个变量都会恢复到初始值而不会被保存?
其实我们定义的所有变量,不论是一个数字还是一个复杂的类,它们都是被存储在电脑里称作内存(memory)的这个区域的!
内存其实不过是一块很大的,由 0 和 1 组成的数字序列。其中的数据并不是永久的。
当我们定义一个变量时,该变量的值会被编码为一串二进制数字并存入内存的某个位置里。这个位置称为变量的地址(address)。
比如 int a = 47,那么在内存里看起来可能是这样的:00000000000000000000000000101111 "这个序列在每台机器中会有所不同
解释:
以
int a = 47为例:C# 里int是4 字节(32 位)的类型,所以变量a会占用4 个连续的内存单元。你看到的
00000000000000000000000000101111,其实是这 4 个内存单元里存储的二进制内容举个具体例子:
假设这 4 个连续内存单元的地址是
0x100、0x101、0x102、0x103(十六进制地址),那么:
- 变量
a的地址就是起始单元的地址0x100;- 而
0x100这个单元里存的是内容的开头 "0",所以说 "第一个 0 所在的位置(即0x100这个内存单元)就是 a 的地址"。- 这个这 4 部分二进制必须组合起来 ,才能解析出 "47" 这个值
- 变量
a的地址 :是这 4 个连续单元的 "起始位置"(比如0x100),用来定位 "a的存储区在哪里";- 变量
a的本身 :是这 4 个连续单元组成的 "存储区域",以及里面存储的 32 位二进制数据 ------ 整个区域合起来,才是完整的变量a。
有趣的是,对于内存来说,并非所有的物体都是平等的。我们希望把 "较小的物体" 放在一个访问较快,但空间较小的内存区域里,而较大的物体放在一个较慢,但空间很大的区域里。
堆与栈
在游戏开发中,堆与栈的使用环境是由它们的特性(大小、速度、管理方式)决定的,结合游戏的性能需求、数据生命周期,两者的使用场景区分很明确
栈的使用环境
栈的核心特性是小而快、自动分配 / 释放、生命周期短 ,因此适合游戏中高频触发、临时存在、数据量小的场景:
-
函数 / 方法的局部变量(值类型为主)
- 游戏逻辑中函数内的临时变量:比如
Update里的循环计数器(int i = 0)、临时计算的Vector3(值类型结构体)、临时布尔值(bool isHit = false)。 - 例子:在角色移动逻辑中,计算临时的目标位置
Vector3 targetPos = transform.position + dir,这个targetPos作为局部值类型变量,会存在栈上,函数执行完自动释放。
- 游戏逻辑中函数内的临时变量:比如
-
函数的参数传递(值类型)
- 函数调用时的参数(值类型):比如
CalculateDamage(int attack, int defense)中的attack和defense,作为值类型参数会被拷贝到栈上,函数执行完释放。
- 函数调用时的参数(值类型):比如
-
高频临时计算
- 游戏帧循环内的短期计算:比如物理碰撞检测中的临时射线检测结果(
RaycastHit结构体)、粒子系统的单帧临时速度向量,这类数据仅在当前帧 / 当前函数内有效,用栈能避免 GC 开销。
- 游戏帧循环内的短期计算:比如物理碰撞检测中的临时射线检测结果(
堆的使用环境
堆的核心特性是大而慢、动态分配、GC 管理 ,适合游戏中需要长期存在、数据量大、跨函数 / 跨帧共享的场景:
-
堆的核心特性是大而慢、动态分配、GC 管理 ,适合游戏中需要长期存在、数据量大、跨函数 / 跨帧共享的场景:
-
类的实例对象(游戏核心逻辑实体)
- 游戏中需要持久化或跨逻辑共享的对象:比如角色的
PlayerData类实例(存储血量、等级)、敌人的EnemyAI组件实例、关卡的LevelManager单例对象(类是引用类型,实例存在堆上)。 - 例子:通过
new PlayerData()创建的角色数据,会存在堆上,直到没有引用被 GC 回收(或游戏结束)。
- 游戏中需要持久化或跨逻辑共享的对象:比如角色的
-
动态资源与大型数据结构
- 游戏资源相关的实例:动态加载的
GameObject预制体实例(比如 instantiate 生成的敌人对象)、存储关卡配置的List<LevelConfig>数组(数组是引用类型,存在堆上)。 - 例子:存储 100 个道具信息的
Item[] items数组,因容量较大且需要跨场景访问,会分配在堆上。
- 游戏资源相关的实例:动态加载的
-
字符串与复杂容器
- 游戏中的文本数据:比如角色对话的字符串(
string是引用类型)、存储玩家背包物品的Dictionary<int, Item>字典(容器类是引用类型,存在堆上)。
- 游戏中的文本数据:比如角色对话的字符串(
-
引用类型与值类型
在 C# 中,数据类型分为两大类:值类型(value type)和引用类型(reference type)。它们的区别主要在数据的存储方式与变量之间的数据传递方式
值类型
值类型直接存储数据,并且变量之间是通过拷贝的方式传递的。
cs
值类型直接存储数据,并且变量之间是通过拷贝的方式传递的。比如 int 是值类型,那么考虑以下的代码:
int a = 7;
int b = a;
b = -1;
很明显,通过修改 b 的值是不能影响到 a 的值的。
这是因为 a 这个变量中直接存储了 "7" 这个数据,而 b = a 实际上是把 a 中的值拷贝了一份再复制给 b 的。所以 b 和 a 没有直接的联系。
值类型的数据通常是存储在栈(Stack)上的------值类型的存储位置由其 "宿主的存储位置" 决定,当宿主在堆上时,值类型会内联到堆内存中:
cs
public class Player {
public int Level; // 值类型字段,随Player实例存储在堆上
}
Player p = new Player(); // p的实例在堆上,Level也在堆上
常见的值类型有:所有的基本数据类型 (int, float, double, bool, long, char, etc.)、枚举类型(enum)、结构体(struct)
引用类型
引用类型与值类型很不同,它并非直接存储数据本身,而是存储了指向该数据的地址。
引用类型的变量之间传递数据时只是拷贝了其地址,并没有拷贝数据本身。所以自始至终只会有一份数据。
cs
Transform 是引用类型,考虑以下代码:
Transform tf1 = object1.transform;
Transform tf2 = tf1;
tf2.position = new Vector3 (1,0,0);
我们通过修改 tf2 的 position 会影响到 object1 的位置吗?会!
这是因为 tf1 和 tf2 中只存储了指向 object1.transform 的地址。
tf2=tf1 这行代码仅拷贝了地址,并没有拷贝 object1.transform 本身,所以 tf2 和 tf1 指向的是同一个物体。
所有的引用类型都继承自 System.Object 类。
引用变量的存储位置由其声明的宿主位置决定,当宿主在堆上时,引用变量会随宿主内联到堆内存中;++而此时案例中++ 的 "引用变量"(如Transform tf1)是方法栈帧的一部分,存储在栈上;而它指向的引用类型实例(如object1.transform对应的对象数据)则存储在堆上。
常见的引用类型有:类(class)、字符串(string)、数组(与数组的元素类型无关)、委托(delegate)
引用类型外部传参

-
Copy方法内的赋值操作:- 方法内执行
go1 = go2,其实是把方法内的形参go1(局部引用) 改成指向形参go2对应的实例(即 "Object 2")。但注意:这个操作只修改了方法内的局部引用 ,对 "外部的go1引用" 没有任何影响。
- 方法内执行
-
不用Copy函数而直接go1=go2
- 同一作用域,修改的是 原引用变量
go1本身
- 同一作用域,修改的是 原引用变量
内存类型
| 内存类型 | 归属(代码 / 引擎相关) | 核心控制者 | 典型例子 |
|---|---|---|---|
| 托管内存(托管堆 / 脚本栈) | 代码相关 | 开发者代码 + GC | C# 类实例、数组、局部变量 |
| C# 非托管内存(NativeArray) | 代码相关 | 开发者代码 | Job 系统的计算数据、高频缓存 |
| 原生内存(引擎 C++ 核心) | 引擎相关 | Unity 引擎 | 图形 API 缓存、物理引擎底层数据 |
| 程序代码内存(库 / 字节码) | 两者共有 | 引擎 + 编译配置 | 引擎库、开发者代码编译后的可执行文件 |
| 本机堆资源内存 | 两者交叉(代码触发) | 代码触发 + 引擎管理 | 贴图、模型等资源的内存 |