我是标题
- 1.进程协程线程之间的关系是什么?
- 2.对象池优化
- 3.C#和C++的区别
- 4.C++和Csharp分别是怎么使用堆的?有什么区别?
- 5.你刚刚提到了GC,如何减少GC?
- 6.UDP和TCP的区别
- 7.你提到UDP不可靠,那么要怎么样才能可靠?
- 8.三次握手四次挥手?
- 9.拥塞控制?流量控制?
- 10.UI适配问题
- 11.场景题:现在有一个积分变量,需要经过一些操作,但是有一些地方需要频繁拆装箱,你打算怎么优化?那假设现在积分最多只有1000,你怎么优化?
- 12.cosnt,string,stringbuilder分别存储在哪里?
- 13.堆和栈的区别
- 14.GC的算法
- 15.unity的TileMap
- 16.什么是稳定排序和不稳定排序,讲一下常用的两种排序(merge,quick)
- 17.闭包函数是什么
- 18.Unity渲染管线
- 19.链表和数组的区别
- 20.虚函数以及抽象类接口
- 21.运行时多态和编译时多态
- 22.析构函数什么时候调用
- 23.vector扩容机制?
- 24.Define 和 Const区别
- 25.大根堆(小根堆)怎么形成的
1.进程协程线程之间的关系是什么?
如图所示
- 一个游戏进程包含多个线程,协程辅助线程工作。
unity中存在多进程的情况,但比较复杂。
- 将渲染与游戏逻辑分离到不同进程:在一些对性能要求较高的 Unity 游戏中,尤其是处理大型开放世界场景时,开发者可以将渲染过程与游戏逻辑分离到不同的进程中。这样可以确保渲染不会因为游戏逻辑的复杂计算而受到影响,从而提高帧率和画面流畅度。例如,游戏中的物理计算、AI 逻辑等在一个进程中处理,而渲染相关的任务在另一个进程中进行,两个进程并行工作,提高游戏的整体性能。
- 通过外部进程与 Unity 进行交互:在大型多人在线游戏(MMO)中,常使用这种方式。例如,将 Unity 作为客户端,与一个独立的服务器进程进行通信。客户端进程负责处理游戏的画面显示、用户输入等,而服务器进程则处理游戏的逻辑,如玩家之间的交互、游戏世界状态的更新等。通过这种方式,可以更好地管理游戏中的大量数据和玩家交互,提高游戏的可扩展性和稳定性。
2.对象池优化
如果对象池物体创立之后经过一段时间的高频使用后使用变少,那么本来创建的多个对象就闲置了,有什么办法优化吗?
- 动态回收机制
可以设定一个闲置时间阈值,当对象在对象池中闲置的时间超过这个阈值时,就将其从对象池中移除,释放相关资源。 - 限制对象池大小
在对象池创立之初就应该为对象池设置一个最大容量,当对象数量达到最大容量后,不再创建新的对象。并且在回收对象时,如果池已满,则直接销毁该对象。
3.C#和C++的区别
C#没有指针,只有引用类型与值类型。
引用类型在传递给函数时,传递的是对象的引用而不是对象本身,不过多赘述。
那C#是怎么修改传入的值类型的?
答:C#的关键字Ref和out关键字就是在做这件事。
ref & out
- ref关键字能够把变量按引用传递给函数,这意味着函数内部对参数的修改会反映到原始变量上。运用ref时,调用函数时变量必须先初始化。
- out关键字同样是按引用传递变量,不过它要求在函数内部对参数进行赋值,而且调用函数时变量无需初始化。
4.C++和Csharp分别是怎么使用堆的?有什么区别?
- C++ 中堆的使用
- 在 C++ 里,主要借助new和delete操作符(或者new[]和delete[]用于数组)来在堆上分配和释放内存。
new操作符会在堆上分配指定类型所需的内存空间,并且返回一个指向该内存空间的指针。- 手动管理
- C++ 要求开发者手动管理堆内存,即明确何时分配和释放内存。这就需要开发者有较高的编程技能,以避免内存泄漏(分配了内存却未释放)和悬空指针(指针指向已释放的内存)等问题。
- 性能
- 手动管理内存使得 C++ 在堆内存使用上具有更高的性能,因为开发者可以根据具体需求精确控制内存的分配和释放。
- C# 中堆的使用
- 在 C# 中,对象的内存分配是自动完成的。当使用new关键字创建一个对象时,CLR(公共语言运行时)会在堆上为该对象分配内存。而内存的释放则由垃圾回收器(GC)自动处理。
5.你刚刚提到了GC,如何减少GC?
- 对象池的运用
原理:针对频繁生成与销毁的对象(像子弹、粒子系统、敌人等),借助对象池技术,提前创建一定数量的对象并存储起来。当需要使用时,从对象池中取出;使用完成后,将其放回,而非直接销毁。这样就能避免频繁的内存分配与回收操作。 - 减少临时对象的生成
字符串拼接:在 Unity 里,字符串拼接操作会产生大量临时对象。尽量使用StringBuilder来替代直接的字符串拼接。
例如:String s = new String(“XYZ”); 会有两份字符串。
一个是由于引用类型,创建在堆上。
一个是因为后续可能还会引用到,所以unity自身有一个常量池,池子里会放一份。
using System.Text;
using UnityEngine;
public class StringBuilderExample : MonoBehaviour
{
void Start()
{
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++)
{
sb.Append(i.ToString());
}
string result = sb.ToString();
Debug.Log(result);
}
}
- 资源管理优化
纹理和音频资源:确保纹理和音频资源的大小与格式合理。过大的资源会占用大量内存,并且在加载和卸载时可能触发 GC。使用合适的压缩格式来减小资源的大小。
资源卸载:及时卸载不再使用的资源。使用Resources.UnloadUnusedAssets()方法来卸载未使用的资源,但要注意该方法可能会触发 GC,所以要选择合适的时机调用。 - 代码优化
委托和事件:避免频繁创建委托和事件,因为它们会产生垃圾。可以在初始化时创建委托和事件,然后重复使用。
使用值类型:对于一些小的数据结构,优先使用值类型(如struct)而不是引用类型(如class)。值类型在栈上分配内存,不会触发 GC。 - 监控与分析
使用 Unity Profiler:利用 Unity Profiler 工具来监控 GC 的情况,了解哪些操作导致了大量的内存分配和 GC。通过分析 Profiler 的数据,找出性能瓶颈并进行优化。
6.UDP和TCP的区别
- 连接性
TCP:是面向连接的传输协议。在进行数据传输前,需要通过 “三次握手” 建立连接,传输结束后还要通过 “四次挥手” 断开连接。这确保了数据传输的双方都处于就绪状态,适合对数据准确性要求高的场景,如文件传输、网页浏览等。
UDP:是无连接的传输协议。在发送数据前不需要建立连接,发送端只需将数据报发送到目标地址,接收端在接收到数据报后也不需要反馈。这种方式简单高效,但不保证数据的可靠传输,常用于实时性要求高的场景,如视频会议、在线游戏等。 - 可靠性
TCP:提供可靠的数据传输。它通过序列号、确认应答、重传机制、滑动窗口等技术,确保数据按序、完整、准确地到达接收端。如果数据在传输过程中丢失或损坏,TCP 会自动重传。
UDP:不保证数据的可靠传输。它只是简单地将数据报发送出去,不关心数据是否能到达目的地,也不保证数据的顺序。如果数据在传输过程中丢失或损坏,UDP 不会进行重传。 - 传输效率
TCP:由于需要建立连接、维护连接状态、进行确认应答和重传等操作,会带来一定的开销,因此传输效率相对较低。
UDP:没有这些额外的开销,数据传输速度快,效率高。 - 传输形式
TCP:是面向字节流的传输协议。它将应用层的数据看作是无结构的字节流,在传输过程中会对数据进行分段和重组,接收端需要自己处理数据的边界问题。
UDP:是面向报文的传输协议。它将应用层的数据封装成一个个独立的报文进行传输,每个报文都有自己的边界,接收端可以直接读取完整的报文。 - 拥塞控制
TCP:有拥塞控制机制。当网络出现拥塞时,TCP 会自动调整发送速率,以避免进一步加重网络拥塞。
UDP:没有拥塞控制机制。它不会根据网络状况调整发送速率,可能会在网络拥塞时导致数据包丢失增加。
7.你提到UDP不可靠,那么要怎么样才能可靠?
8.三次握手四次挥手?
9.拥塞控制?流量控制?
10.UI适配问题
- 锚点和轴心共同影响着 UI 元素的布局和变换。锚点决定了元素在父对象中的位置,轴心则决定了元素自身变换的中心。通过合理设置锚点和轴心,可以实现复杂的 UI 布局和动画效果。
- Scale With Screen Size 模式主要不是调节分辨率,而是根据不同的屏幕分辨率对 UI 元素进行缩放,以保证 UI 在不同屏幕上的相对大小和布局一致。
11.场景题:现在有一个积分变量,需要经过一些操作,但是有一些地方需要频繁拆装箱,你打算怎么优化?那假设现在积分最多只有1000,你怎么优化?
问1:使用泛型集合来存储积分变量。
问2:假设1 - 1000的int类型积分要频繁装箱成object使用,那么使用object数组进行1次预装箱(把int存入长度为1000的object数组中),后续全部使用Object数组索引调用。即使再调用1亿次也就只有初始预装箱的1000次操作而已。
12.cosnt,string,stringbuilder分别存储在哪里?
const编译之初就处理好了存在元数据中,不占用堆和栈。
string,stringbuilder引用类型堆。
不同之处:
- string 对象一旦创建,其值就不能被改变。当对 string 进行拼接、替换等操作时,实际上是创建了一个新的 string 对象,对于常量字符串,C# 会使用字符串驻留机制。如果两个常量字符串的值相同,它们会指向堆上的同一个对象,以节省内存。
- StringBuilder 也是引用类型,其对象存储在堆上,引用存储在栈上(如果是局部变量)。与 string 不同,StringBuilder 是可变的,它内部维护了一个字符数组,当进行拼接等操作时,会直接修改这个数组,而不是创建新的对象,因此在需要频繁修改字符串的场景下,使用 StringBuilder 性能更好。
13.堆和栈的区别
- 内存分配方式
栈:内存的分配与释放由系统自动处理。当进入一个函数时,系统会自动为该函数的局部变量在栈上分配内存;函数执行结束后,这些局部变量所占用的内存会被系统自动释放。
堆:内存分配和释放需要程序员手动操作。在 C 语言里,通过malloc、calloc等函数来申请堆内存,使用free函数释放;在 Java、Python 等语言中,虽然有垃圾回收机制来自动处理堆内存的释放,但仍需手动创建对象来申请堆内存。 - 内存空间的连续性
栈:内存空间是连续的,类似于数据结构中的栈,遵循后进先出(LIFO)的原则。新的内存分配总是在栈顶进行,释放也从栈顶开始。
堆:内存空间是不连续的。操作系统会维护一个空闲内存块列表,当程序申请堆内存时,操作系统会从空闲列表中寻找合适大小的内存块分配给程序。 - 数据存储的内容
栈:主要存储函数的局部变量、函数调用的上下文信息(如返回地址、寄存器状态等)。这些数据的生命周期通常与函数的执行周期一致。
堆:用于存储动态分配的数据,如对象、数组等。这些数据的生命周期可以由程序员控制,不依赖于函数的执行周期。 - 访问效率
栈:由于内存空间连续,且操作系统对栈的操作有专门的指令支持,因此栈的访问速度较快。
堆:由于内存空间不连续,在分配和释放时需要进行复杂的内存管理操作,因此堆的访问速度相对较慢。 - 内存大小限制
栈:栈的内存空间通常比较小,其大小由操作系统或编译器预先设定。如果程序在栈上分配的内存超过了这个限制,会导致栈溢出错误。
堆:堆的内存空间相对较大,其大小受限于系统的物理内存和虚拟内存空间。但如果程序频繁地进行堆内存的分配和释放,可能会导致内存碎片问题,影响系统性能。
14.GC的算法
15.unity的TileMap
存储方式 (二维数组(密集)或者字典(稀疏))
16.什么是稳定排序和不稳定排序,讲一下常用的两种排序(merge,quick)
稳定排序指的是在排序操作中,相等元素在排序前后的相对顺序不会发生改变。也就是说,若在待排序序列中有两个元素 a 和 b,它们的值相等,且在排序前 a 在 b 前面,那么排序后 a 仍然在 b 前面。
QuickSort
不断以轴心为依据,不断交换左右两边满足条件的数。
最坏情况递归树变为一条链表。此时空间复杂度退化为O(n),时间复杂度退化到O(n^2)
最好情况 空间O(logn)递归深度, 时间(nlogn)每层遍历 * 层数
#include <iostream>
#include <vector>
#include <algorithm>
#include <bits/stdc++.h>
using namespace std;
// 快速排序函数
void quick_sort(std::vector<int>& q, int l, int r)
{
if(l >= r) return;
int i = l - 1, j = r + 1, x = q[l + r >> 1];
while(i < j)
{
do i ++; while(q[i] < x);
do j --; while(q[j] > x);
if(i < j) swap(q[i], q[j]);
}
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
int main()
{
int n;
cin >> n;
vector<int> q(n);
for (int i = 0; i < n; ++i)
{
cin >> q[i];
}
quick_sort(q, 0, n - 1);
for (int i = 0; i < n; ++i)
{
cout << q[i] << " ";
}
return 0;
}
MergeSort
每次对半,所以递归深度是logn,每次遍历数组n,所以时间复杂度nlogn。
一个辅助数组,空间复杂度n。
没有好坏情况之分,因为都是对半比大小。
#include <iostream>
#include <vector>
using namespace std;
vector<int> a;
void merge(vector<int> &a, int l, int r)
{
if(l >= r) return;
int mid = l + r >> 1;
merge(a, l, mid);
merge(a, mid + 1, r);
int i = l, j = mid + 1;
vector<int> temp;
for(; i <= mid && j <= r;)
{
if(a[i] <= a[j]) temp.push_back(a[i ++]);
else temp.push_back(a[j ++]);
}
while(i <= mid) temp.push_back(a[i ++]);
while(j <= r) temp.push_back(a[j ++]);
for(int k = l; k <= r; k ++)
{
a[k] = temp[k - l];
}
}
int main()
{
int n;
cin >> n;
a.resize(n);
for(int i = 0; i < n; i ++)
{
cin >> a[i];
}
merge(a, 0, n - 1);
for(auto it : a)
{
cout << it << ' ';
}
return 0;
}
17.闭包函数是什么
体现在lambda表达式和匿名函数的使用中。
一个函数内嵌套了第二个函数,内部函数可以不需要参数传入也能获取外部函数已经捕获的变量了。
并且被捕获的变量可以一直存在在内部函数中,可以延长它的生命周期。
把函数当成一个可编辑的变量来使用了。
#include <iostream>
auto createClosure(int x) {
return [x](int y) {
//捕获参数列表[capture list]
//y 传入参数
return x + y;
};
}
int main() {
auto closure = createClosure(10);
std::cout << closure(5) << std::endl;
return 0;
}
using System;
class Program
{
static Func<int, int> CreateClosure(int x)
{
// 这里的 Lambda 表达式类型根据返回类型 Func<int, int> 自动推断
return (y) => x + y;
}
static void Main()
{
var closure = CreateClosure(10);
int result = closure(5);
Console.WriteLine(result);
}
}
18.Unity渲染管线
-
应用阶段
由游戏引擎或应用程序主导,主要负责准备渲染所需的数据,包括场景图的构建、相机设置、光照计算以及材质属性的确定等。例如,在 Unity 中,开发者通过脚本和编辑器设置来定义场景中的物体、光源、相机位置和投影方式等,这些信息都会在应用阶段被整理和传递给下一阶段。 -
几何处理阶段
顶点处理:(MVP)将模型的顶点坐标从模型空间转换到世界空间,再到观察空间和裁剪空间。同时,还会进行顶点光照计算、纹理坐标生成等操作(UV)。例如,在渲染一个角色模型时,会根据角色的位置、朝向以及光源的位置来计算每个顶点的光照效果,确定其最终的颜色和位置。
图元装配:将顶点组装成图元**(链接顶点成为三角形)**,如三角形、线段等。这些图元是构成三维物体的基本单元。
裁剪:将不在视锥体(相机可见范围)内的图元或顶点进行裁剪,剔除不可见的部分,以减少后续的渲染工作量。
归一化 -
光栅化阶段
视口变换:是将裁剪空间中的归一化设备坐标(NDC)转换为屏幕上的实际像素坐标,确定物体在屏幕上的具体显示位置和大小。。
三角形遍历:对于每个屏幕上的像素,判断其是否在三角形等图元内部。如果是,则进行后续的片段处理。 -
片段处理阶段
纹理采样:(MipMap,各向异性过滤(三线性升级版,效果更好,消耗更大),三线性插值(纹理过大), 双线性插值(纹理过小)) 根据顶点的纹理坐标,从纹理图像中采样颜色值。例如,为一个木质桌子模型采样木纹纹理,使桌子看起来更加逼真。
片段着色:(EarlyZ可以提前剔除)根据材质属性、光照模型以及纹理采样结果等,计算每个片段的最终颜色 , ( 为房子的砖指定颜色 )。这一阶段可以通过着色器程序进行高度定制,实现各种复杂的渲染效果,如高光、阴影、反射等。
深度测试与模板测试:深度测试用于确定当前片段是否在其他物体的前面,从而决定是否需要绘制。模板测试则可以根据特定的模板值来控制片段的绘制与否,常用于实现一些特殊效果,如阴影投射、遮罩等。 -
帧缓冲操作阶段
混合:将新生成的片段颜色与帧缓冲区(frame buffer)中已有的颜色进行混合,以实现透明效果或其他合成效果。例如,渲染一个透明的玻璃物体时,需要将玻璃的颜色与背景颜色进行混合。
写入帧缓冲区:将经过处理的片段颜色最终写入帧缓冲区,用于显示在屏幕上。
19.链表和数组的区别
链表在堆上分配。
数组在栈上分配。
数组可以通过下标返回值O(1),因为数组地址连续只需根据公式 address = base_address + i * sizeof(element) 计算出元素的内存地址即可。
增删为O(n),改为O(1)。
链表可以通过指针索引实现O(1)的增删改,而查找是O(n)。
20.虚函数以及抽象类接口
虚函数被继承后不一定要重写。
抽象类中的抽象函数只有签名,在派生类中必须完成对这些抽象函数的实现。抽象类不能实例化。只能继承一个抽象类。
接口中的成员函数必须在子类中全部实现,并且支持多继承接口。
21.运行时多态和编译时多态
运行时多态:运行时多态通过虚方法、抽象方法和接口来实现。在运行阶段,会根据对象的实际类型来确定调用哪个方法版本。(特点运行时才知道)
编译时多态:编译时多态主要借助方法重载和泛型来达成。在编译阶段,编译器会依据方法调用时的参数类型、数量以及顺序等信息,确定要调用的具体方法版本。(特点编译时就确定)
22.析构函数什么时候调用
- 对象生命周期结束时
- 使用 delete 操作符时
- 对象作为容器元素被移除时
- 程序正常结束时对于全局变量
23.vector扩容机制?
- 分配新的内存块:vector 会在堆上分配一块更大的连续内存空间,新内存块的大小通常是原内存块大小的两倍(不同编译器可能会有差异,但一般遵循指数增长的策略)。
- 复制元素:将原内存块中的元素逐个复制到新的内存块中。
- 释放原内存块:释放原内存块所占用的内存空间。
- 更新内部指针:更新 vector 内部的指针,使其指向新的内存块。
24.Define 和 Const区别
Define只是文本替换。
const有严格的类型定义。
25.大根堆(小根堆)怎么形成的
先把下标从一开始的数组看成堆。
2i 是左子节点,2i + 1是右子节点。
然后从后往前遍历跳过叶子节点,从第一个叶子结点的根节点开始判断,如果小于优先与比较大的那个数交换。