U3D面试汇总!!!

文章目录

篇章一:C#语言核心

0.C#语言特性

  • ① 微软开发的一种OOP编程语言。专门用于.NET应用而开发。
  • ② 面向对象的三大特征(封装,继承,多态)。除了三大特征以外,C#还添加了事件和委托,增强了编程的灵活性。
  • 简单,安全。C#不再使用指针,而且不允许直接读取内存等不安全操作。提供了相比C/C++,Java更多的数据类型。且还使用命名空间的概念来管理C# 文件。
  • ④ C# 6.0+版本已支持跨平台使用。并且能开发多种类型维度的应用程序。

1.值类型与引用类型

1.1. 介绍:

值类型:int,bool,float,double,char,enum,struct,short,long…

引用类型:class,object,interface,string,array,delegate+

1.2. 区别:

存储方式:值类型—>栈,引用类型—>堆,引用类型会在栈中存储指向堆数据的内存地址。

存储速度:值类型快,引用类型慢。

存储内容:值类型存储实际数据,引用类型存储指向 内存堆中的指针与引用。

释放方式: 值类型可以在栈中自动释放,引用类型需要在堆中GC释放。

继承类:值类型 :System.ValueType : System.Object,引用类型:System.Object。

1.3. 底层

① 引用类型实例化时,先在栈中开辟空间,用于存储堆中对象地址,然后在堆内开辟空间,存储引用对象。

② 值类型直接在栈中开辟空间,值类型的引用地址也在栈空间中。

在参数对象进入方法体时,实则是在栈中开辟了新的临时空间(对应参数的副本),当栈内值类型修改时,由于栈中内存地址不同,无法影响到本体。而引用类型的存储数据是一个堆内的地址,所以对于引用类型的修改是直接修改堆内对象。

④ 值类型变量中引用类型在堆中(struct中的string属性),引用类型中值类型数据也在堆中(类中的int属性)。

⑤ 内存分为一下三个模块:

  • 栈:存储值类型变量,以及对象内存地址
  • 堆:存储引用类型数据
  • 静态存储区:存储静态的变量以及方法。

2.string类型

2.1. 介绍:

string的修改,实则都是new 了一个新的string,在堆内开辟新的空间,此时栈内的副本也会指向新的对象。

string s = "富强民主";//当前堆内存:“富强民主”
s = "文明和谐";//当前堆内存:“富强民主”,“文明和谐”
s = "自由平等";//当前堆内存:“富强民主”,“我们和谐”,“自由平等”

2.2. StringBuilder和StringBuffer:

为了解决字符串修改重复创建的问题,当频繁的对一个字符串进行修改时,使用StringBuilder代替string。 StringBuilder非线程安全,性能略好,用于单线程`,StringBuffer线程安全,用于多线程

2.3. StringBuilder一定比string性能好吗?

不一定。极少拼接的情况下 性能:string > StringBuilder,由于初始内存消耗:string < StringBuilder,大量拼接的情况下 性能:string < StringBuilder。

2.4. 字符串池

字符串池 是CLR(公共语言运行库)一种针对反复修改字符串对象的优化措施,能减少一定性能消耗。原理是 内部开辟容器通过 键值对 的形式注册字符串对象,键 存储字符串对象的内容,值 存储字符串在堆上的引用。这样当新建字符串时,回去检查,如果不存在就在这个容器中开辟新的空间存放字符串。(废弃资源的回收利用)

3.GC(垃圾回收)

3.1. 介绍:

3.1.1 Unity内部存在两个内存管理池 ↓

栈内存 主要用来存储较小的和短暂的数据(值类型变量,临时变量…)

堆内存 主要用来存储较大的和存储时间长的数据(引用类型变量,对象实例…)

unity中的变量只会在堆内存或栈内存上进行内存分配。非堆即栈。

3.1.2 变量的激活与否 ↓

当变量处于激活状态时,则其占用内存被标记为使用状态,该部分内存被标记为分配状态。

当变量未激活时,其占用内存不再需要,该部分内存可以被会受到内存池中再次使用。该操作即为GC。栈内存回收极其迅速,堆内存并不是及时回收的,此时其对应内存仍为使用状态,不再使用的内存只会在GC的时候被回收。

3.1.3 GC的定义

GC(垃圾回收)主要指堆上的内存分配与回收,unity会定时对堆内存进行GC操作。

3.2. GC算法:

C#:C#使用分代算法来进行GC操作。有着 内存整理,避免碎片化,压缩等特点。

分为以下三代:

  • 0代:未被标记回收的新分配对象。
  • 1代: 上次垃圾回收中没有被回收的对象。
  • 2代: 一次以上的垃圾回收后依然没有被回收的对象。

简易流程:

  1. GC会检查堆内存上的每个存储变量。
  2. 对每个变量检测其引用是否处于激活状态。
  3. 如果变量引用变为不激活,则会标记为可回收
  4. 被标记的变量会被移除,其占有的内存空间被回收到堆内存上。

详细流程:

  1. 当新建立引用对象时,检查0代存储空间是否充足,如果没有,将0代对象进行遍历检查,是否有 激活状态却没有被调用的对象 标记为 可回收。
  2. 遍历完成后,将符合 “可回收” 的对象进行GC,释放的空间返回给0代存储区,其他对象迁移到1代存储区,此时对象是分散分布的,需要进行 压缩 操作,是1代对象顺序紧密排列。新对象存储于0代存储空间。
  3. 当1代空间溢出时,将1代对象同样按照上述流程 遍历,迁移,压缩 到 2代存储区,同时0代迁移到1代。

3.3. GC存在的问题

  1. 游戏性能:GC是一个及其耗费时间的操作,堆内存上变量较多时会导致遍历检查操作变得十分缓慢,使得游戏卡顿(FPS下降),对玩家体验造成极大的影响。
  2. 游戏内存:(unityGC采用非分代非压缩的标记清除算法)GC会产生“内存碎片化”。(例如:房子很大,但可利用房间很小,无法找到合适的房间)。“内存碎片化” 会导致 GC或者堆内存扩容。二者都会时游戏运行效率降低或者游戏内存增大。

3.4. GC的触发时机

  1. 在堆内存上进行内存分配操作,内存不够时会触发GC。(常用)
  2. GC会以一定的频率自动触发。
  3. GC可以被强制执行。

3.5. 如何避免GC

  1. 减小临时变量的使用,多使用公共对象,多利用缓存机制。(将容器定义到函数外)。

  2. 减少new对象的次数。

  3. string和StringBuilder的转换使用(详情见2)

  4. 定义容器时,尽量将空间大小设置为足够,减少容器的扩容

  5. 帧更新函数进行计时器控制,防止内存爆炸。此外,可以选择在加载进度条中手动GC

  6. 缓存对象池:常用于经常摧毁创建的对象,比如子弹,特效等…。将Destroy换成SetActive(false)。将Instantiate换位SetActive(true)

  7. 减少拆 / 装箱:

    装箱:value —> object 拆箱:object —> value。

    产生GC的原因:装箱时,对于值类型会在堆内存上分配一个Object类型的引用来封装该值类型变量,其对应缓存就会产生垃圾。

    多用泛型!处理多个代码对不同数据类型执行相同指令操作时!

    **在使用协程时,yield return 0 —> yield return null ** 能有效避免装箱。

    项目打包之前,删掉所有的Log。

4. 面向对象三大特征

4.1. 封装

将数据和行为相结合,通过行为约束代码修改数据的程度,增强数据安全性。(属性是C#封装特效的最好实例)。

4.2. 继承

提高代码复用度,增强软件可维护的重要手段,符合开闭原则(见设计模式)。继承就是把子类的共性聚集起来提取出一个父类,C#的继承是单继承,但具有传承性(这一点上与Java一致)。

4.3. 多态

同名的方法在不同环境下,会自适应转换为不同的逻辑表现。(例子:猫在走猫步,鸟儿在飞翔…)

5.访问修饰符

  1. public :对任何类和成员都公开,无限制访问。
  2. private :仅对该类公开。
  3. protected :对该类及其派生类公开访问。
  4. internal:只能在包含该类的程序集中访问该类。
  5. protected internal:具有上述两个子修饰符的特征。

6.密封关键字sealed

① 当该关键字修饰类时,该类无法被继承。

② 当该关键字修饰方法时,该方法无法被重写。常常配合override关键字一起使用。

总结:sealed关键字的存在限制了方法或类的向下拓展。可以理解为绝育。即规定最终版,无法再去重写方法。

public class B : A{
    
    
    protected override void M (){
    
    }
    protected sealed override void N (){
    
    } 
}

public sealed class C : B {
    
    
    protected override void M (){
    
    }
    //无法重写N方法了,因为在父类中已经被密封
}
//无法再继承C类了

7. 结构体和类

7.1. 对比:

数据类型:结构体是值类型,类是引用类型。结构体存在栈中,类存在堆中。

参数传递

结构体参数传递时是值类型的传递,函数中改变参数的值,结构体对象值是不变的。类参数传递时是引用的传递。

C#中结构体定义时,无法给参数赋值。而类可以使用构造函数给成员变量初始化。

构造函数

结构体无法声明无参构造,且有参构造不会顶替掉无参构造。

类可以声明无参构造,但如果写了有参构造,其会顶替调无参构造。

结构体需要在构造函数中初始化所有成员变量,而类随意。

修饰与特性

结构体无法被 static 修饰,而类可以。

结构体无法被继承,而类可以。

结构体中无法声明析构函数,而类可以。

7.2. 使用环境:

结构体

① 结构体是值类型在栈中,存取数据比堆快,容量小。适合去轻量级对象,比如:点,矩形,颜色…

② 如果对象是数据集合,优先考虑结构体,比如集合,坐标。

③ 变量传值时,希望传递对象是拷贝,而不是对象的引用地址,可用结构体。

①类是引用对象,存在堆中,容量大,适合重量级对象。

② 如果对象需要使用继承多态等面向对象特征,使用类,比如玩家 ,怪物…

8.抽象类和接口

8.1. 对比

① 接口不是类,无法被实例化(无构造函数和析构函数),抽象类只能间接实例化(实例化其子类时自动实例化父抽象类)抽象类有构造函数。

② 接口只能做方法声明,抽象类既可以做方法声明也可以做方法实现。

③ 接口只能包含抽象成员,完全抽象。而抽象类可以有实现成员,属于部分抽象。

④ 接口需要被子类实现,而抽象类需要被子类继承。

⑤ 接口中成员只能是public因此可以省略。抽象类随意。

⑥ 接口可以实现多继承,而抽象类只能实现单继承。

⑦ 抽象方法(抽象类和接口都有)需要被实现,故不能是静态也不能是私有。

8.2. 使用环境

抽象类适合做某个领域的固有属性(把对象的共同点抽象提取出来)。而接口是用来定义某个领域的扩展功能。

抽象类:

  1. 当多个类中有重复部分的时候,我们可以根据其共同特征提取出一个基类,希望这个基类不能被实例化,就可以将该基类设计为抽象类。
  2. 当需要为一些类提供实现代码时,可优先考虑抽象类,因为抽象类中的非抽象方法可以被子类继承下来,使代码功能更简单。

接口:

当注重代码的扩展性和可维护性时,优先使用接口

  1. 接口和其实现类可以不存在任何层次关系,接口可以实现毫不相关类的相同行为,比抽象类使用更加方便。
  2. 接口只关心对象之间交互的方法,而不关心对象所对应的具体类。接口是程序间的一个协议,比抽象类的使用更加安全清晰。

9. 静态构造函数

9.1. 样例

//静态构造函数
class Student{
    
    
    public static string type;
    static Student(){
    
    
        //常常用于静态变量的赋值
        type = "学生";
    }
    public void Study(){
    
    }
}

//调用时机
Student.type;
new Student().Study();

9.2. 特点

① 静态构造函数既没有访问修饰符,也没有参数。

② 在创建第一个实例或任何静态成员被引用时,.NET将自动调用静态构造函数来初始化类。

③ 一个类只能有一个静态构造函数并且最多仅运行一次。

④ 静态构造函数无法被继承。且如果类中没有该模块,但又在程序运行生存期内需要初始化静态成员,编译器会自动生成该语句块。

⑤ 如果该模块语句发生一场,运行时不会调用该构造函数,并且在程序运行盛器内,该类型保持未初始化。

10.虚函数实现原理

每个虚函数都会有一个 虚函数表。该虚函数表实质是一个 指针数组。存放每一个对象虚函数入口地址。对于一个派生类来说,他会继承基类的虚函数同时增加自己的虚函数入口地址,如果派生类重写了虚函数,则继承过来的虚函数入口地址将被派生类的重写虚函数入口地址代替。程序会发生动态绑定,将父类指针绑定到实例化的对象上从而实现多态。

11. 指针和引用的区别

为空:引用无法为空,指针可以指向空对象。

初始化相关:引用必须初始化且初始化后无法改变,指定对那个对象的引用。而指针无需且可以改变指向。

访问相关:引用可以直接访问对象,指针只能间接访问对象。

数据大小引用的大小是指所引用对象的大小。而指针大小是本身的大小,通常为4字节

其他特性

  1. 引用没有const,而指针有。
  2. 引用和指针的自增运算意义不同。
  3. 引用无需分配内存空间,指针需要。

12. ref & out

12.1. 作用:

解决值类型和引用类型在函数内部改值或重新声明能够影响外部传入的变量也被修改。

12.2. 区别:

ref:ref传入的变量必须初始化但是在内部可改可不改。

out:out传入的变量可以不用初始化,但在内部必须修改其值。

案例:

RaycastHit hitInfo;
Physics.Raycast(ray,out hitInfo);

13. 委托 & 事件

13.1 委托的介绍:

委托是约束集合中的一个类,相当于一组方法列表的引用,可以便捷地使用委托对方法集合进行操作。委托是对函数指针的封装。

13.2 委托和事件的区别:

关系

事件可以看作是委托的一个变量。事件是基于委托存在的,事件是委托的安全包裹,让委托的使用更具有安全性。

区别

声明 委托可以用 “ = ” 来赋值,事件不可以。

调用范围 委托可以在声明他的类外部调用,而事件只能在其内部调用。

委托是一个类型,而事件只是一个对象

④ **事件的本质就是 一个private 的委托与Add,Remove两个方法。**只能对自己进行 +=-= 操作。

14. C#数据类型汇总

14.1 布尔类型

bool,true / false,占一个字节。(bool -> System.Boolean),

14.2 有符号整数类型

sbyte , 占一个字节。(sbyte -> System.Byte),-128 - 127

short, ,占2个字节。(short -> System.Int16),-32768 - 32767

int,,占4个字节。(int -> System.Int32), -2147483648 - 2147483647

long,,占8个字节。(long -> System.Int64),大约1E+20。

14.3 无符号整数类型

byte , 占1个字节。(byte -> System.Byte),0 - 255

ushort,占2个字节。(ushort -> System.UInt16),0 - 65535

uint,占4个字节。(uint -> System.UInt32),0 - 4294967295

ulong,占8个字节。(ulong -> System.UInt64),大约1E+20。

14.4 浮点型

float,占4个字节。(float -> System.Single)

double,占8个字节。(double -> System.Double)

14.5 字符型

char ,占2个字节。(char -> System.Char)

15.new的底层实现

案例:

Student s = new Student();
  1. 在应用程序堆中创建一个引用类型对象实例,并为其分配内存地址。
  2. 自动传递该实例的引用给构造函数(构造函数中能使用 this.属性 的原因)。
  3. 调用该实例的构造函数。
  4. 返回该实例的引用内存地址,赋值给s变量,该s引用对象保存到数据是指向在堆上创建该类型的实例地址。

16.协变与逆变

协变(out):

和谐,自然的变化。

里氏替换原则中,父类型容器可以装在子类型对象,子类可以转换为父类。感受是和谐的。

逆变(in):

逆常规,不正常的变化。

里氏替换原则中,子类对象不能装载父类对象。所以父类转换为子类,感受是不和谐的。

协变和逆变是用来修饰泛型的。只有泛型接口与委托能用。

案例:

//out修饰的泛型,只能作为返回值
delegate T Test<out T>();
//in修饰的泛型,只能作为参数
delegate T Test<in T>(T t);

17.反射

作用:

在程序加载运行时,动态获取程序集,并且可以获取到程序集的信息反射在运行时动态获取类,对象,方法,对象数组数据的一种重要手段。

优点:允许在运行时发现并使用编译时仍解决不了的类型和成员。

缺点:

1.根据目标类型字符串搜索扫描程序集的元数据时间较长。

2.反射调用方法或者属性比较耗时。

通过反射获取实例:

反射可以直接访问类的构造,直接通过 getConstructor,访问这个构造函数,然后通过不同的参数列表,就可以具体定位到哪一个重载,通过这个方法,去得到类的实例,把对象就拿到了。

18.删除List中元素时需注意什么

在遍历删除List中的元素时,会导致List.Count发生变化,从而使for循环错误,数组下标越界或删除错误值。

解决方案

可以从后往前删除元素。

19. 字典Dictionary的实现原理

定义:一种 键值对 形式存放数据。key的类型无限制。

实现原理核心:Hash算法和 解决Hash冲突的算法。

Hash算法

对实例对象和字符串来说,它们没有直接的数字作为Hash标准,因此它们需要通过内存地址计算一个Hash值,计算这个值的算法就是哈希函数。

除留余数法:取key被某个不大于散列表表长的数p除后余数作为散列地址。

Hash冲突:不同的key进行Hash计算,得到的结果一定是同一Hash地址。常用的解决办法是拉链法(链地址法)。

拉链法:将产生冲突的元素建立一个单链表,并将头指针地址存储至Hash表对应位置,这样定位到Hash位置后可通过遍历单链表来查找元素。

20. 进程,线程,协程全归纳

20.1 并发和并行

并发:在操作系统中,同一时间段,几个程序在同一个CPU上运行,但在任意时间点,只有一个程序在CPU上运行。

并行:当操作系统有多个CPU时,一个处理A线程,一个处理B线程,线程之间互不干扰,可以同时进行。

区别并发在宏观表现为有多个程序同时运行,微观上这些程序是分时交替执行的。

并行在多CPU系统中,将并发执行的程序分配到不同CPU上处理,每个CPU用来处理一个程序,这样的程序可以实现同时执行。

**20.2 **进程

概念:一个进程好比一个程序,它是 操作系统 资源分配的最小单位。同一时间执行的进程数不会超过核心数。但单核CPU也可以运行多进程。只是会极快地在进程间来回切换实现的多进程。进程是CPU资源分配的基本单位。

20.3 线程

概念:线程相比进程即为应用程序的不同任务。线程依赖于进程。它是 程序执行过程中的最小单元,不过线程的堆共享堆不共享栈,这也会导致锁的问题。线程是独立运行和调度的基本单位

20.4 线程与进程的区别

  • 1.进程是CPU分配资源的基本单位,线程是独立运行和调度的基本单位。
  • 2.进程有自己的资源空间,一个进程包含多个线程,线程与CPU资源分配无关,多个线程共享一个进程的资源。
  • 3.线程调度与切换比进程快很多。

20.5 阻塞和非阻塞

阻塞指调用线程或进程被操作系统挂起。

非阻塞指调用线程或进程不会被操作系统挂起。

20.6 同步和异步

同步:阻塞模式。指一个进程在执行某个请求时,如果该请求需要一段时间才能返回信息,那么这个进程将会一直等待下去,直到收到返回信息才继续执行。

异步:非阻塞模式。无需一直等待下去,而是继续执行接下来的操作,不管其他进程的状态。当有消息返回时系统会通知进程进行处理。

20.7 协程

概念:协程即为 一种用户态的轻量级线程。

特点:协程有自己的寄存器上下文和栈。协程可以保留上一次调用时的状态,每次重入时,相当于进入上一次调用的状态,即进入上一次离开时所处的逻辑流位置。

优点:高并发 + 高扩展 + 低成本。

  • 1.无需线程上下文切换的开销。
  • 2.无需原子操作锁定及同步的开销。
  • 3.方便切换控制流,简化编程模型。

缺点

  • 1.无法利用多核资源。
  • 2.进行阻塞时会阻塞整个程序。

篇章二:数据结构基础

1.力扣105 重建二叉树

题目说明:

输入某二叉树的前序遍历和中序遍历的结果,请构建该二叉树并返回其根节点。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。

输入输出样例:

Input: preorder = [3,9,20,15,7],inorder=[9,3,15,20,7]
Output: [3,9,20,null,null,15,7]			

代码:

class Solution {
    
    
    //构建哈希表方便后续快速根据val得到在中序遍历的位置
 	private Map<Integer,Integer> map = new HashMap<>();  
    public TreeNode buildTree(int[] preorder,int[] inorder){
    
    
        //判空
        if (preorder.length == 0) return null;
        int n = preorder.length;
        //初始化哈希表
        for (int i = 0;i < n;++i)
            map.put(inorder[i],i);
        //直接调用核心逻辑
        return CoreBuildTree(preorder,inorder,0,n-1,0,n-1);
    }
    
    //核心算法逻辑
     /*
    * Param1:前序遍历数组
    * Param2:中序遍历数组
    * Param3:前序遍历左边界值
    * Param4:前序遍历右边界值
    * Param5:中序遍历左边界值
    * Param1:中序遍历右边界值
    * Return:返回当前递归的树根节点
    */
    private TreeNode CoreBuildTree(int[] preorder,int[] inorder,int preorder_left,int preorder_right,int inorder_left,int inorder_right){
    
    
        //递归函数优先考虑返回条件
        if (preorder_left > preorder_right) return null;
        //通过前序遍历拿到当前根节点值
        int rootVal = preorder[preorder_left];
        //根据当前根节点值找到其在中序遍历表中的下标
        int rootIndex_inorder = map.get(rootVal);
        //创建出当前根节点
        TreeNode root = new TreeNode(rootVal);
        //通过中序遍历表查出当前根节点有多少左子节点
        int size_left_subTree = rootIndex_inorder - inorder_left;
        
        //递归部分(左右两边)
        root.left = CoreBuildTree(preorder,inorder,preorder_left + 1,preorder_left + size_left_subTree,inorder_left,rootIndex_inorder - 1);
        root.right = CoreBuildTree(preorder,inorder,preorder_left + size_left_subTree+1,preorder_right,rootIndex_inorder + 1,inorder_right);
        return root;
    }
}

2. 力扣79 矩阵中的路径

题目描述:

给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false

案例:

输入:board = 
[["A","B","C","E"],
["S","F","C","S"],
["A","D","E","E"]],
word = "ABCCED"
输出:true

代码:

class Solution {
    
    
    public boolean exist(char[][] board, String word) {
    
    
    	//数组的长度
        int m = board.length,n = board[0].length;
        //存储某位置是否被访问过
        boolean[][] visited = new boolean[m][n];
        //遍历二维数组
        for (int i = 0;i < m;++i){
    
    
            for (int j = 0;j < n;++j){
    
    
                boolean ans = aroundCheck(board,visited,i,j,word,0);
                if (ans) return ans;
            }
        }
        return false;
    }
 	//核心递归算法
    /*
        Param1:二维字符网格
        Param2:标记数组
        Param3,4:当前所在的位置
        Param5:当前比对的字符串从k开始的子串
        Param6:字符串中的第几位
    */
    private boolean aroundCheck(char[][] board,boolean[][] visited,int i,int j,String target,int k){
    
    
        //作为递归函数优先考虑返回条件
        if (board[i][j] != target.charAt(k)) return false;
        if (k == target.length - 1) return true;
        
        //将该位置设为已访问
        visited[i][j] = true;
        //准备向四个方向遍历
        int[][] directions = {
    
    {
    
    0,1},{
    
    0,-1},{
    
    1,0},{
    
    -1,0}};
        boolean res = false;
        
        //分别查找各个方向是否符合条件
        for (int[] dir : directions){
    
    
            int curi = i + dir[0],curj = j + dir[1];
            if (curi >= 0 && curi < board.length && curj >= 0 && curj < board[0].length)
                boolean flag = aroundCheck(board,visited,curi,curj,target,k+1)
                if (flag){
    
    
                    //找完了
                  	res = true;
                    break;
              	}    
        }
        //剪枝回溯
        visited[i][j] = false;
        return res;
    }
}

篇章三:游戏算法基础

1.A*寻路算法 (重点)

1.1. A*寻路的基本原理

① 寻路消耗公式:f = g + h

f:寻路消耗

g:距离起点的距离,详细解释看以下具体寻路步骤

h:距离终点的曼哈顿距离,即当前点到终点的x差值 + y差值。之所以采用 曼哈顿距离,是因为该距离相比直接计算直线距离少了开方步骤,性能更高。

② 开启列表:详情如下。

③ 关闭列表:详情如下。

④ 格子对象的父对象。

1.2. A* 寻路具体步骤

  1. 将 起点数据(格子信息,格子的父对象) 加入 关闭列表
  2. 将当前结点作为 新的起点,依次遍历该格子周围的八个格子,如果遍历到的格子为 不可移动(障碍物)或者已经被加入到开启列表或关闭列表中时,则直接跳过该格子的相关操作。反之,将遍历到的格子信息存入 开启列表中,通过 寻路消耗计算公式 计算其 寻路消耗,找到 寻路消耗 最小的格子并将其加入 关闭列表 中。
  3. 将2找到的格子作为新的起点,重复2的操作。当满足 当前格子 等于 终点位置 时,代表寻路成功。
  4. 寻路成功后,顺着关闭列表中存储的父对象回溯到起点,回溯点位即为寻路后找到的最优路径。如果开启列表 为空时,则寻路失败,遇到死路。

1.3. A*的算法实现

1.3.1 寻路结点类

//格子类型枚举
public enum E_Node_Type
{
    
    
    Walkable,Stoppable
}

//A*寻路结点类
public class AStarNode 
{
    
    
    //当前格子坐标
    public int x;
    public int y;

    //寻路消耗f
    public float f;
    //距离起点的常规距离
    public float g;
    //距离终点的曼哈顿距离
    public float h;

    //父节点
    public AStarNode father;

    //格子类型
    public E_Node_Type type;

    //构造函数
    public AStarNode( int x,int y ,E_Node_Type type)
    {
    
    
        this.x = x;
        this.y = y;
        this.type = type;
    }
}

1.3.2 寻路管理器类(单例)

public class AStarManager
{
    
    
    #region 单例模式
    private static AStarManager instance;
    public static AStarManager Instance
    {
    
    
        get
        {
    
    
            if (instance == null)
            {
    
    
                instance = new AStarManager();
            }
            return instance;
        }
    }

    #endregion

    //存储所有的格子
    private AStarNode[,] nodes;

    //开启列表
    private List<AStarNode> openList;
    //关闭列表
    private List<AStarNode> closeList;

    //地图尺寸
    private int mapW;
    private int mapH;

    //初始化地图格子
    public void InitMap(int mapW, int mapH)
    {
    
    
        //根据传入的地图尺寸,初始化格子列表
        this.mapW = mapW;
        this.mapH = mapH;

        nodes = new AStarNode[mapW, mapH];

        for (int i = 0; i < nodes.Length; i++)
        {
    
    
            for (int j = 0; j < nodes.Length; j++)
            {
    
    
                //TODO:这里随机数设置,方便测试
                nodes[i, j] = new AStarNode(i, j, Random.Range(0, 100) < 20 ? E_Node_Type.Stoppable : E_Node_Type.Walkable);
            }
        }
    }

    //寻路核心算法(传入起点和终点)
    public List<AStarNode> FindPath(Vector2 startPos, Vector2 endPos)
    {
    
    
        //判断传入的起点终点是否合法:①是否超出地图边界,②是否为不可行走
        if (startPos.x < 0 || startPos.x >= mapW ||
            startPos.y < 0 || startPos.y >= mapH ||
            endPos.x < 0 || endPos.x >= mapW ||
            endPos.y < 0 || endPos.y >= mapH)
        {
    
    
            return null;
        }

        //根据坐标先拿到起点终点对应的格子
        AStarNode start = nodes[(int)startPos.x, (int)startPos.y];
        AStarNode end = nodes[(int)endPos.x, (int)endPos.y];
        if (start.type == E_Node_Type.Stoppable || end.type == E_Node_Type.Stoppable)
            return null;

        //先将起点格子加入开启列表中
        start.father = null;
        start.f = 0;
        start.g = 0;
        start.h = 0;
        //清空之前列表信息
        closeList.Clear();
        openList.Clear();

        closeList.Add(start);

        while (true)
        {
    
    
            //分别遍历当前格子周围的点,如果合法并且没有在开启或关闭列表中,将其加入开启列表中
            for (int i = -1; i <= 1; i++)
            {
    
    
                for (int j = -1; j <= 1; j++)
                {
    
    
                    if (i == 0 && j == 0)
                        continue;
                    if (i == 0 || j == 0)
                        FindNearlyNodeToOpenList(start.x + i, start.y + j, 1, start, end);
                    else
                        FindNearlyNodeToOpenList(start.x + i, start.y + j, 1.4f, start, end);
                }
            }

            //死路判断
            if (openList.Count == 0)
                return null;

            //从开启列表中查找 寻路消耗最小的结点,放入关闭列表中。
            openList.Sort(SortOpenList);
            //放入关闭列表,删除开启列表的数据
            closeList.Add(openList[0]);
            start = openList[0];
            openList.RemoveAt(0);

            //如果查找到终点,直接返回
            if (start == end)
            {
    
    
                List<AStarNode> path = new List<AStarNode>();
                path.Add(end);
                while (end.father != null)
                {
    
    
                    path.Add(end.father);
                    end = end.father;
                }
                path.Reverse();
                return path;
            }
        }
    }

    //自定义排序规则
    private int SortOpenList(AStarNode a, AStarNode b)
    {
    
    
        if (a.f > b.f)
            return 1;
        else if (a.f < b.f)
            return -1;
        else
            return 1;
    }

    //将临近点放入开启列表
    private void FindNearlyNodeToOpenList(int x, int y, float g, AStarNode father,AStarNode end)
    {
    
    
        //边界测试
        if (x < 0 || x >= mapW || y < 0 || y >= mapH)
            return;
        AStarNode node = nodes[x, y];
        //是否被阻挡或者已经遍历过
        if (node == null ||
            node.type == E_Node_Type.Stoppable ||
            closeList.Contains(node) ||
            openList.Contains(node))
            return;

        //计算寻路消耗
        node.father = father;
        node.g = father.g + g;
        node.h = Mathf.Abs(end.x - x) + Mathf.Abs(end.y - y);
        node.f = node.g + node.h;

        //存入到开启列表中
        openList.Add(node);
    }
}

1.4. A*优缺点:

  1. 对环境反应迅速,搜索路径方式直接,是一种直接的搜索算法。
  2. 实时性差,每一节点的运算量大,运算时间长,随着节点增多,运算效率降低。

1.5. 优化

  1. 找到更合适的OpenList数据结构。
  2. 在openList列表中用list存储,时间复杂度:O(n)。可以用二叉堆代替,只需要拿出堆顶元素即可,时间复杂度:O(logN)。
  3. 在f相同时,选择h小的结点,减少遍历次数。
  4. 预先分配必要的内存。
  5. 缓存和一个结点相邻的结点,因为A*需要频繁获取某个结点相邻节点的操作。
  6. 根据具体情况可以加强A*寻路的视野,缺点是计算量增大,效率降低,但可能会利用更少的次数寻找到终点。

1.6. 拉绳算法优化

定义:从起点开始,与路径网格的临边点进行相连,然后以此移动左右边界,判断移动后夹角变换,如果夹角变小了,说明距离终点更近了;反之,说明距离更远了,则选择另一个边界进行移动;如果同样变大,则需要进行路径合并,选择当前边界的点作为新的起点,并与旧起点连起来得到一条路径。

2.冒泡排序

public int[] BubbleSort(int[] arr){
    
    
    //如果数组个数在两个以下,无需排序
    if (arr.Length < 2) return arr;
    //当前 n-1 个元素排好序后,最后一个自动有序
    for (int i = 0;i < arr.Length - 1;++i){
    
    
        //单轮次是否存在元素互换
        bool swapped = false;
        for (int j = 1;j < arr.Length - i;++j){
    
    
            //如果左 > 右,交换两元素,并标记布尔变量
            if (arr[j - 1] > arr[j]){
    
    
                int temp = arr[j];
            	arr[j] = arr[j - 1];
            	arr[j - 1] = temp;
            	swapped = true;
            }
        }
        if (!swapped) break;
    }
    return arr;
}

3.二分查找模板

递归模板

明确目的:从给定的一个有序数组nums中找到目标值为target的所在下标。

public int BinarySearch(int[] nums,int l,int r,int target){
    
    
    if (l > r) return -1;
    int mid = (l + r) >> 1;
    if (nums[mid] > target) return BinarySearch(nums,l,mid - 1,target);
    else if (nums[mid] < target) return BinarySearch(nums,mid + 1,r,target);
    else return mid;
}

迭代模板

public int BinarySearch(int[] nums,int target){
    
    
    int l = 0;
    int r = nums.length;
    int mid;
    while (l <= r){
    
    
        mid = (l + r) >> 1;
        if (nums[mid] > target)
            r = mid - 1;
        else if (nums[mid] < target)
            l = mid + 1;
        else 
            return mid;
    }
    return -1;
}

4.快速排序模板

//注意:牢记以下对应关系
// mid + 1时用i-1和i,mid时用j和j+1
public void QuickSort(int[] nums,int l,int r){
    
    
    if (l >= r) return;
    int i = l - 1,j = r + 1,mid = (l + r) >> 1;
    while (i < j){
    
    
        do ++i;while(nums[i] < mid);
        do --j;while(nums[j] > mid);
        if (i < j){
    
    
            int temp = nums[i];
            nums[i] = nums[j];
            nums[j] = temp;
        }
    }
    QuickSort(nums,l,j);
    QuickSort(nums,j+1,r);
}

5.归并排序模板

//主要是分治的思想
int[] nums = new int[N];
int[] tmp = new int[nums.length];

public void MergeSort(int[] nums,int l,int r){
    
    
    if (l >= r) return;
    int mid = (l + r) >> 1;
    
    MergeSort(nums,l,mid);
    MergeSort(nums,mid + 1,r);
    int k = 0,i = l,j = mid + 1;
    while (i <= mid && j <= r){
    
    
        if (nums[i] <= nums[j]) tmp[k++] = nums[i++];
        else tmp[k++] = nums[j++];
    }
    while (i <= mid)  tmp[k++] = nums[i++];
    while (j <= r)  tmp[k++] = nums[j++];
    
    for (i = l,j = 0;i <= r;i++,j++) nums[i] = tmp[j];
}

6.从100w数据中选出前1w大的数字(堆排序)

思路解析

先拿1w和数据建立堆模型(最小堆),然后依次添加剩余元素,如果大于堆顶的元素(当前堆中最小元素),将其替换掉,并使用上浮或者下沉算法使其仍为一个最小堆,这样遍历完成后,堆中的1w个数即为要找的数据。建立堆的时间复杂度:O(mlogm) ,m = 1w,算法的时间复杂度为:O(nmlogm),n = 100。

优化思路:如果对10亿个数据找出1w个最大数,算法该如何优化?

分治。可以把所有的10亿个数据分组存放。比如放在1000个文件中。这样处理就可以在每个文件的10^6个数据中找到最大的10000个数,合并到一起再找出1000 * 10000中的最终结果即可。

7.DFS算法模板

定义:深搜是一种用于遍历或搜索树或图的算法。沿着树的深度遍历到树的节点,尽可能深的搜索树的分支,尽可能深的搜索树的分支。当结点v的所在边都已经被探寻过,搜索将回溯到发现结点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有结点为止。

算法模板:二叉树的中序遍历(递归)(Java版):

public List<Integer> inorderTraversal(TreeNode root) {
    
    
    List<Integer> ans = new LinkedList<Integer>();
    MidTraversal(root,ans);
    return ans;
}

//递归函数
public void MidTraversal(TreeNode root,List<Integer> ans){
    
    
    if (root == null) return ;
    MidTraversal(root.left,ans);
    ans.add(root.val);
    MidTraversal(root.right);
}

8.BFS算法模板

定义:BFS是一种盲目搜索,目的是系统展开并检查图中所有结点,以寻找结果。换句话说,它不考虑结果的可能位置,彻底搜索整张图,直到找到结果为止。

**算法模板:**二叉树的层序遍历(Java版):

public List<List<Integer>> levelOrder(TreeNode root) {
    
    
	List<List<Integer>> ans = new LinkedList<>();
    if (root == null) return ans;
    //队列生成
    Deque<TreeNode> queue = new LinkedList<>();
    queue.offer(root);	//根节点进入队列
    while (!queue.isEmpty()){
    
    
        List<Integer> level = new LinkedList<>(); 
        for (int i = 1;i <= queue.size();i++){
    
    
            TreeNode curr = queue.poll();
            level.add(curr.val);
            if (curr.left != null)
                queue.offer(curr.left);
            if (curr.right != null)
                queue.offer(curr.right);
        }
        ans.add(level);
    }
    return ans;
}

9.力扣240 二维数组查找

题目:

在一个 n * m 的二维数组中,每一行都按照从左到右 非递减 的顺序排序,每一列都按照从上到下 非递减 的顺序排序。请完成一个高效的函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。

案例:

[
[1,   4,  7, 11, 15],
[2,   5,  8, 12, 19],
[3,   6,  9, 16, 22],
[10, 13, 14, 17, 24],
[18, 21, 23, 26, 30]
]

代码样例:(二维数组也可单次遍历出结果!类二分查找)

class Solution {
    
    
    public boolean findNumberIn2DArray(int[][] matrix, int target) {
    
    
        if (matrix.length == 0) return false;
        int m = matrix.length,n = matrix[0].length;
        int i = m-1,j = 0;  //从左下角开始
        while (i >= 0 && j < n){
    
    
            int cur = matrix[i][j];
            if (cur > target) --i;
            else if (cur < target) ++j;
            else return true;
        }
        return false;
    }
}

篇章四:设计模式基础

1.面向对象设计模式:

1.1. 单一职责原则:

一个类最好只用来做一件事,只有一个引起其变化。(比如,人物的操作模块分为 攻击控制,移动控制,交互控制等)

1.2. 开闭原则:

开放扩展,封闭更改。**抽象化设计 **是实现开闭原则的的关键。(接口抽象类,方法重写…)

1.3. 里氏替换原则:

子类能够替换程序中父类对象出现的任何地方,并且保证其原来程序的逻辑行为不变且正确性不改变。在运用时,父类通常设计为抽象类或接口,让子类继承父类并实现在父类中声明的方法。运行时,子类实例替换父类,能有效地拓展程序的功能,从而不修改原有子类代码,增加原有功能可以通过一个新的子类来实现。

1.4. 依赖倒置原则:

抽象不应该依赖于细节,正如要针对接口编程,而不是针对实现编程。

1.5. 接口隔离原则:

使用多个的专门,而不是使用单一的总接口。

1.6. 合成复用原则:

尽量使用对象组合,而不是单一的靠继承达到复用目的。

1.7. 迪米特法则:

一个软件实体应尽可能少的与其他实体发生相互作用。

2.简单工厂模式

概念:属于 创建型模式。也叫 静态工厂模式。主要是一个工厂类通过传入的参数,动态决定创建哪一个产品类(继承同一个父类或接口)的实例。

特点:产品都继承于同一个父类或接口。工厂类中的工厂方法一般都是 静态方法。需要创建什么实例,就传入一个正确的参数即可,就可以获取到所需要的实例。只有一个工厂类创建所有产品。

优点

  • 实现了对象创建和使用的分离
  • 客户端无需直到所创建的具体类类名,只需要直到产品对应参数即可。

缺点

  • 工厂类集中了所有产品的创建逻辑,一旦出问题,整个系统都会受到影响。
  • 增加了系统中类的个数,增加了系统复杂度和理解难度。
  • 系统扩展困难,一旦增添新的产品,需要修改工厂逻辑。违背OOP设计模式开闭原则。

2.工厂模式

2.1 定义

通过工厂父类定义负责创建产品的公共接口,子类复杂生产具体对象。

2.2. 使用场合

角色技能是一系列类。游戏场景转换:角色AI状态管理等。

相比简单工厂模式,工厂方法模式增添了抽象的工厂父类。将不同产品的创建依托给不同的工厂类。

2.3. 应用实例:Java工具库中的Collection和迭代器的关系便是工厂模式的典型案例。

优点

  • 更符合开闭原则,增加一个产品类,只需要实现其具体产品类和具体工厂类即可。
  • 更符合单一职责原则,每个工厂只生产对应的产品。

缺点

  • 每添加一个产品,就要添加其对应的具体工厂类和产品类。使整体类的数量较多。

2.4. 代码案例:

//产品接口
interface Phone {
    
    }

//苹果手机类
class IPhone : Phone{
    
    
    public void print(){
    
    
        Debug.Log("IPhone已生产");
    }
}
//华为手机类
class HuaweiPhone : Phone{
    
    
    public void print(){
    
    
        Debug.Log("Huawei已生产");
    }
}

//工厂接口
interface Factory{
    
    
    //创建手机的函数
    Phone CreatePhone();
}
//苹果工厂
class IPhoneFactory : Factory {
    
    
    public Phone CreatePhone(){
    
    
        return new IPhone();
    }
}
//华为工厂
class HuaweiFactory : Factory {
    
    
    public Phone CreatePhone(){
    
    
        return new HuaweiPhone();
    }
}

public class Main(){
    
    
    Factory factory = new IPhoneFactory();
    Phone phone = factory.CreatePhone();
    phone.print();//IPhone已生产
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vth0qNYD-1675819698781)(D:\简历\工厂模式类图.png)]

2.抽象工厂模式

定义

向客户端提供一个接口,使客户端在不同指定产品的具体情况下,创建多个产品族(具有相同属性的同类型产品)的产品对象。

优点

  • 当一个产品族中的多个对象被涉及到一起工作时,能保证客户端只使用一个产品族里的对象。
  • 减少工厂类和具体产品类的添加。

缺点:产品族扩展十分困难,要增加某一系列的产品,既要在抽象工厂和抽象产品里加代码,又要在具体实现里加代码。

3.观察者模式

定义:对象之间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。

案例:

//借方接口
interface Debit{
    
    
    //借钱方法
    void Borrow(Credit credit);
    //通知借钱者
    void NotifyCredits();
}

//借钱者
class Zhangsan : Debit
{
    
    
·	//借钱人名单
	private List<Credit> credits = new List<Credit>();
	private bool isRich = false;
    public void Borrow(Credit credit){
    
    
    	//借钱逻辑
    	credits.Add(credit);
    }
    public void NotifyCredits(){
    
    
    	//通知借钱者逻辑
    	foreach(Credit c in credits){
    
    
    		c.takeMoney();
    	}
    }
}

//被借钱者
interface Credit{
    
    
	void TakeMoney();
}

class Lisi : Credit{
    
    
	public void TakeMoney(){
    
    
		//要钱逻辑
	}
}

4.发布/订阅者模式

该模式下大多数是异步的,且订阅者在订阅事件时,只关注事件本身,而不关注发布事件的人。发布者在发布的时候,也只会关注事件本身,而不关注谁订阅了该事件。该模式相比 观察者模式 完全隔离了订阅者和发布者的依赖关系。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4hSve9kv-1675819698782)(D:\简历\发布订阅者模式.png)]

5.单例模式

5.1 饿汉模式

public class Singleton {
    
    
    //在类加载的时候实例化,类加载只有一次
    private static Singleton instance = new Singleton();
	public static Singleton Instance
    {
    
    
        get 
        {
    
    
            return instance;
        }
    }
}

缺点:

1.执行慢。2.脚本执行顺序无法保证,调用语句在类加载之后会空指针。

5.2 懒汉模式

public class Singleton{
    
    
    //在调用指定实例方法时再实例化,效率略高于饿汉模式
    private static Singleton instance;
    public sttaic Singleton Instance
    {
    
    
    	get
        {
    
    
            if (instance == null)
                instance = new Singleton();
            return instance;
        }    
    }
}

缺点:

1.多线程,同步调用单例,判断不存在就会同时生产,生成多个实例,但可以用双向加锁解决。

5.3 饱汉模式

//直接new出来,不管你需不需要。

5.4 单例模式的优缺点

特点:

① 私有的构造方法(防止外部初始化)

② 私有的静态的实例化变量

③ 公有的静态的获取类实例对象属性

缺点

①不适用于变化的对象,如果同一类型对象总是在不同用例场景发生变化,就会引起数据错误。

② 单例模式没有抽象层,所以扩展困难,一定程度上违背了 单一原则。

优点

① 某些类创建比较复杂,对一些大型对象,这是一笔很大的系统开销。省去了new,降低系统内存使用频率,减轻GC压力。

使用场景:

声音播放,UI显示,资源加载,计时器等等…

6.代理模式 / 装饰器模式

定义 : 目标对象代理对象 实现同一个接口,通过访问代理对象 来访问真正需要的目标对象。

实例代码:假设一辆汽车具有车载日志功能。我们用代理模式模拟其过程。

//汽车以及车载工具都具有移动功能
interface I_Moveable {
    
    
    void Move();
}

//汽车类
class Car : I_Moveable {
    
    
    public void Move(){
    
    /*汽车行驶逻辑*/ }
}

//日志代理类
class LogProxy : I_Moveable {
    
    
    private I_Moveable car;
    public LogProxy (I_Moveable car){
    
    
        this.car = car; 
    }
    
    public void Move(){
    
    
        Log();
        car.Move();
    }
    
    //记录日志代理方法
    private void Log(){
    
    
        Debug.Log("日志开始记录");
    }
    
    //装饰器模式:在原有功能上进行功能拓展,而不改变原有结构
    public void Speak(){
    
    
        //扩展逻辑
        car.Move();
    }
}

7.命令模式

定义:命令模式(Command Pattern)是一种数据驱动的设计模式,它属于行为型模式。 请求以命令的形式包裹在对象中,并传给调用对象。 调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。

案例说明:使用 远程遥控器 发出 命令命令的执行者 执行对应逻辑,从而实现发出命令的人和命令执行者之间的 解耦合

//命令相关
interface Command{
    
    
    void execute();
}

//远程遥控器
class RemoteControl {
    
    
    public Command onCommands[];
    public Command offCommands[];
    public RemoteControl(){
    
    
        onCommands = new Command[10]();
        offCommands = new Command[10]();
    }
    
   	//设置对应命令
    public void SetCommand(int num,Command on,Command off){
    
    
        onCommands[num] = on;
        offCommands[num] = off;
    }
    
    //打开被操作物
    public void on(int num){
    
    
        onCommands[num].execute();
    }
    //关闭被操作物
    public void off(int num){
    
    
        offCommands[num].execute();
    }
}

//被操作物体
class Light{
    
    
    //开灯逻辑
    public void on(){
    
    }
    //关灯逻辑
    public void off(){
    
    }
}

//开灯的命令
class LightOnCommand : Command{
    
    
    private Light light;
    public LightOnCommand(Light light){
    
    
        this.light = light;
    }
    public void execute(){
    
    
        light.on();
    }
}
//关灯的命令
class LightOffCommand : Command{
    
    
    private Light light;
    public LightOffCommand(Light light){
    
    
        this.light = light;
    }
    public void execute(){
    
    
        light.off();
    }
}

//执行者
class Main{
    
    
    static void Main(string[] args){
    
    
        Light light = new Light();
        RemoteControl control = new RemoteControl();
        Command onLight = new LightOnCommand(light);
        Command offLight = new LightOffCommand(light);
        control.SetCommand(1,onLight,offLight);
        control.on(1);
        control.off(1);
    }
}

8.策略模式

定义

  • 定义了一组算法(业务规则);
  • 封装了每个算法;
  • 这族的算法可互换代替(interchangeable)。

实例:

手机导航算法中,导航会根据用户选择的模式不同而更改不同的算法(骑行,驾车,步行等),对于某些经常变动的算法,我们可以将其抽象为接口,让不同类型的情况继承接口,在子类中书写具体算法。并让具体采用该策略的类去继承该接口,并选择对应需求的子类去调用对应的算法。

代码样例:

//导航策略
interface NavigationStrategy{
    
    
    void FindWay(Point a,Point b);
}

//公路导航
class NavRoad : NavigationStrategy{
    
    
    public void FindWay(Point a,Point b){
    
    
        //公路导航逻辑算法实现
    }
}
//骑行导航
class NavRide : NavigationStrategy{
    
    
    public void FindWay(Point a,Point b){
    
    
        //骑行导航逻辑算法实现
    }
}
//步行导航
class NavWalk : NavigationStrategy{
    
    
    public void FindWay(Point a,Point b){
    
    
        //步行导航逻辑算法实现
    }
}

//手机类(包含导航功能)
class Telephone{
    
    
    public NavigationStrategy nav ;
    public Telephone(NavigationStrategy nav){
    
    
        this.nav = nav;
    }
    //使用手机导航
    public void FindWayWithPhone(Point a,Point b){
    
    
        nav.FindWay(a,b);
    }
}

9.状态模式

定义

允许一个对象在其内部状态改变时改变其行为,对象看起来似乎修改了它的类。,状态模式是一种对象行为型模式。

对游戏开发者来说,轻车熟路了。怪物 有限状态机 使用的逻辑即为状态模式。

有限状态机案例:通过FSM来控制怪物或者NPC的AI行径。

//对一个怪物来说,存在静止,追逐,攻击,死亡等状态,在一定条件下切换为对应的状态并做出反应即为AI的基础功能。
//状态的接口(包含进入时,进行时,结束时)
interface IState{
    
    
    void EnterDo();
    void UpdateDo();
    void ExitDo();
}

//静止状态
class State_Idle : IState{
    
    
    //状态机参数
    public FSM fsm;
    
    public State_Idle(FSM fsm){
    
    
        this.fsm = fsm;
    }
    
    public void EnterDo(){
    
    
        //进入静止状态时执行的逻辑
    }
    public void UpdateDo(){
    
    
        //静止状态下帧更新逻辑
    }
    public void ExitDo(){
    
    
        //退出该状态时执行的逻辑
    }
}

//攻击状态
class State_Attack : IState{
    
    
    public void EnterDo(){
    
    
        //进入攻击状态时执行的逻辑
    }
    public void UpdateDo(){
    
    
        //攻击状态下帧更新逻辑
    }
    public void ExitDo(){
    
    
        //退出该状态时执行的逻辑
    }
}

//怪物状态枚举
enum E_EnemyState{
    
    
    Idle,Chase,Attack,Dead
}

//在怪物的脚本类里面去引用状态机
class FSM : MonoBehaviour{
    
    
    public IState currState;
    //存储状态
    public Dictionary<E_EnemyState,IState> states = new Dictionary<E_EnemyState,IState>();
    void Start(){
    
    
        states.Add(E_EnemyState.Idle,new State_Idle(this));
        states.Add(E_EnemyState.Attack,new State_Attack(this));
    }
    
    //对外提供转换状态的逻辑
    public void TransitionState(E_EnemyState state){
    
    
        if (currState != null)
        	currState.ExitDo();
        currState = states[state];
        currState.EnterDo();
    }
}

10.组合模式

定义:部分整体模式,用于把一组相似对象当作单一的对象。依据树形模式结构来组合对象,用来表示部分以及整体层次。多用于:你想表达对象的部分-整层次结构。你希望用户忽略组合对象与单个对象的不同,用户统一使用组合结构中的所有对象。

例如:不同界面的逻辑和UI通过同一个管理器统一控制。

典例:人口普查,县级汇总上报省级,省级汇总上报国家。从而形成一种树形模式。

11.MVC

**定义:**全名 Model View Controller,一种软件设计规范。

主要是用一种业务逻辑,数据,界面显示分离的方法组织代码,将业务逻辑聚集到一个不见里面,再改进和个性化定制界面及用户交互同时,不需要重新编写业务逻辑。

在同一个模块内:

  • M 仅用于操作数据并发送更新消息。
  • V 仅用于接收消息并控制界面显示跳转。
  • C 用来处理界面与数据的频繁操作。

MVC一般流程:

  • 1.View触发事件。
  • 2.Controller处理业务触发数据更新。
  • 3.更新了Model。
  • 4.Model带着数据回到View。
  • 5.View更新数据。

关于MVC各层之间对应的设计模式:

  • 1.View,单独实现了组合模式。
  • 2.Model与View,实现了观察者模式。
  • 3.View和Controller,实现了策略模式。

优点:

  • 1.耦合性较低,重用性较高,可维护性高,部署快。(前端注重显示,后端注重逻辑)
  • 2.生命周期成本低(MVC使开发和维护用户接口的技术含量降低)
  • 3.各司其职,互不干涉。
  • 4.有利于开发中的分工。
  • 5.有利于组件重用。提高开发效率。

缺点:

  • 1.没有明确定义,不适合中小型规模程序。
  • 2.增加系统结构和实现的复杂性。
  • 3.视图控制器连接过于紧密。
  • 4.增加了程序文件的体量。

12.ECS架构

定义:全称 Entity-Component-System(实体-组件-系统),是基于组合由于继承的一种模式,将不变的模块使用继承方便复用,将多变的部分用组合来方便拓展。

游戏中每个单元都是一个(怪物,相机等)实体,每个实体又是由一个或多个组件构成的。每个组件仅包含该组件需要关注的数据(例如技能组件保存技能伤害,范围等),而系统用来处理这些实体的集合,其只存逻辑,不存状态,类似于一个工具,例如技能系统会根据遍历到的每一个拥有技能组件的实体,根据状态执行技能。

优点:

  • 1.添加一个新的实体只需要明确其上有多少组件,将组件快速整合。
  • 2.容易定义新的实体数据,可以通过一个新的组件来储存。
  • 3.更高的效率,系统只关心对应组件数据。例如:技能系统只关心技能,移动系统只关心位移数据,遍历时,内存的命中率比较高,首先通过创建时申请一个连续的内存空间,同时对于整个对象,其内存跨度会小很多。

缺点:

  • 1.数据共享。比如渲染组件需要物体的位置信息,碰撞组件也要位置信息,就会出现数据共享问题。解决该问题的方法是把位置信息作为组件单独抽取出来,但这个会使效率更低。另一种方法是使用引用,构建对象时分配同一位置对象,然后传递引用给其他组件。
  • 2.遍历次数。游戏中数量巨大且系统数量也增加时,很明显算法复杂度将不低于O(n * m),遍历对象成本巨大。比如碰撞检测系统如果两对象进行逻辑处理,速度损失极其严重。集合处理可以有效解决。

篇章五:Unity引擎核心

1.生命周期函数汇总

1.1. Awake:

概述:Awake用于游戏开始之前初始化变量或者游戏状态,在脚本周期中仅被调用一次(当脚本被禁用时同样会被调用)。

使用说明:Awake会在所有对象被初始化后调用,所以可以安全地与其他对象交互。一般情况下,在Awake中设置脚本间的引用,并用Start函数来传递信息。Awake在Start之前进行,并且无法执行协同程序。程序内的所有Awake函数会以随机顺序被调用。

1.2. OnEnable:

当对象变为可用或激活状态时被调用事件监听。

1.3. Start:

概述:同Awake在生命周期中仅被调用一次。但仅仅在脚本时里启用状态下才会调用。常常与Awake协同实现初始化顺序的控制。

1.4. Update:

帧更新函数,游戏运行每帧被调用一次。

1.5. FixedUpdate:

固定帧更新函数。每隔固定的时间间隔被调用一次,FU是逻辑帧,而Update是渲染帧。如果帧率很低,Update调用次数减少,会导致FU被调用多次。相反的,帧率过高,可能在Update更新频率中不会调用FixedUpdate。另外,U和FU独立计时器。

1.6. LateUpdate:

LU生命周期中晚于Update执行。常常用于摄像机相关的操作。避免出现摄像机先于玩家移动从而出现空帧的情况。

1.7. OnGUI:

1.8. OnDisable:

作用与OnEnable函数作用相反。不过多阐述。

1.9. OnDestroy:

当脚本所挂载对象被销毁时调用。

脚本生命周期执行次序

Awake --> OnEnable --> Start --> FixedUpdate --> Update --> LateUpdate --> OnGUI --> OnDisable --> OnDestroy

2.碰撞相关

必要条件:两个碰撞物体必须存在格子的碰撞器Collider,并且至少一个对象要有Rigidbody刚体组件。

3.协程

3.1. 进程:

保存在硬盘上的程序运行后,会在内存中形成一个独立的内存体,这个内存体有自己的堆,不同进程间可以相互通信,上级单位是操作系统。一个应用程序相当于一个进程,操作系统以进程为单位,分配系统资源(CPU时间片,内存),进程是资源分配的最小单位。

3.2. 线程:

线程属于进程,是程序的实际执行者。**线程是操作系统能够进行运算调度的最小单位。**一个进程可以并发多个线程,每条线程执行不同的任务。线程有自己独立的栈和共享的堆,不共享栈。

3.3. 协程:

3.3.1:概述

协程是伴随主线程一起运行的一段程序。协程之间是并行执行的,协程与主线程也是并行执行,同一时间只能执行一个协程。提起协程,一定要想到线程,因为协程伴随主线程执行。一个协程可以拥有多个协程,协程不受操作系统控制,完全由程序控制。协程与线程一样,共享堆不共享栈。

3.3.2:与线程的对比

相同点:独立栈不独立堆。有自己的局部变量,有自己的指令指针。

不同点:

协程:协程与其他协程程序共享全局变量等信息。同一时间只能执行一段协程,开辟多个协程开销不大,协程常用来 对某任务进行分时处理

线程:同一时间可以执行多个线程,开辟多条线程开销很大。线程适合多任务同时处理

3.3.3 协程的作用:

在Unity中只有主线程才能访问U3D对象,方法,组件。当主线程资源消耗过大时,游戏出现卡顿,帧率下降等。为了解决这个麻烦,可以使用协程来处理资源的加载等繁琐工作。常常用于异步加载资源或场景。

3.3.4. 协程的应用:

//定义
IEnumerator Test(string str){
    
    
    yield return null;
}
//启动协程
StartCoroutine(string methodName);
StartCoroutine(IEnumerator routine);
//停止协程
StopCoroutine(IEnumerator routine);
StopAllCoroutine();

3.3.5 协程的原理

协程的本质是 迭代器

协程的特征:

① 方法的返回值是IEnumerator。②方法中有yield关键字。方法内的内容被 MoveNext()方法分为两部分;yield之前的代码会在第一次MoveNext()时执行,yield之后的代码会在第二次MoveNext()时执行。协程以帧为单位执行,也就是说每一帧都会判断当前帧是否满足协程所定义的条件。

4.物体移动汇总

4.1. Transform相关

① Transform.position:

//最基础的移动方式,当前位置每帧 += 计算好的新位置
public float speed = 3f;
void Update(){
    
    
    transform.position += transform.forward * Time.deltaTime * speed;
}

② Transform.Translate:

//直接调用API,原理与第一种类似
public float speed = 3f;
void Update(){
    
    
    transform.Translate(Vector3.forward * Time.deltaTime * speed);
}

4.2. Vector3相关

移动本质:仍然是改变物体的position。

③ Vector3.Lerp:

//两个向量之间的线性插值,适用于某点移动得到某点,缓动但是非匀速
public Transform target;
public float speed = 3f;
void Update(){
    
    
    transform.position = Vector3.Lerp(transform.position,target.position,Time.deltaTime * speed);
}

④ Vector3.Slerp:

//两个向量之间的弧形插值,缓动,当前位置与目标位置越远,效果越明显,非匀速
public Transform target;
public float speed = 3f;
void Update(){
    
    
    transform.position = Vector3.Slerp(transform.position,target.position,Time.deltaTime * speed);
}

⑤ Vector3.MoveTowards:

//与Lerp基本相同,但此函数多了个速度限制,并且是匀速到达目标,而Lerp和Slerp是到达时会放缓。
public Transform target;
public float speed = 3f;
void Update(){
    
    
    transform.position = Vector3.MoveTowards(transform.position,target.position,Time.deltaTime * speed);
}

//如果实现移动可以采用以下算法
transform.position = Vector3.MoveTowards(transform.position,transform.position + transform.forward * Time.deltaTime * speed,Time.deltaTime * speed);

⑥ Vector3.SmoothDamp:

//平滑阻尼,无比丝滑的从A到B,速度可控,适用于摄像机跟随。
public Transform target;
public Vector3 currVelocity = Vector3.zero; //当前速度
public float smoothTime = 0.3f;//所需时间
void Update(){
    
    
    transform.position = Vector3.SmoothDamp(transform.position,target.position,ref currVelocity,smoothTime);
}

4.3. Rigidbody相关:

移动原理:通过物理模拟来控制物体的位置,使用该组件相关移动时,在FixedUpdate方法中更新数据。

⑦ AddForce:

public Vector3 force;
public Rigidbody rb;
void FixedUpdate(){
    
    
    rb.AddForce(force,ForceMode.Force);
}

⑧ MovePosition:

//移动刚体到一个新位置,会受物理影响
public Vector3 speed ;
public Rigidbody rb;
void FixedUpdate(){
    
    
    rb.MovePosition(transform.position + speed * Time.deltaTime);
}

⑨ Velocity:

//瞬间给一个物体恒定的速度,将该物体提升到这个速度,保持。但相比跳跃功能,使用AddForce效果更好,因为AddForce是恒定高度。

5.获取对象的方式

5.1. 用Find查找游戏对象:

GameObject go = GameObject.Find("对象名")

5.2. 用标签查找:

GameObject go = GameObject.FindGameObjectWithTag("对象Tag")

5.3. 用类型查找:

T t = GameObject.FindObjectOfType<T>();

6.Invoke函数

6.1. Invoke:

Invoke函数是U3D的一种委托机制

使用Invoke需要注意的:

  • 它应该在脚本的生命周期函数中被调用。
  • 不能接受含参的方法。
  • 在Time.ScaleTime = 0的情况下,不会被调用。

6.2. InvokeRepeating:

该方法被激活时设置了,但此时将对象设置为false,还会被执行!

6.3. Invoke和协程的区别:

Invoke:执行并没有被挂起,相当于设置完被调用函数的执行时间后即时向下执行。应用到每隔一段时间执行某个函数很方便。

Coroutine:新开一条执行序列并挂起,等待中断指令结束。开销不大。当需要挂起当前执行时使用,效率比Invoke高。

6.4. 隐藏物体与禁止脚本对于协程和Invoke的影响

① 禁止脚本:

当协程被开启 或 Invoke执行后,关闭脚本开关,此时二者都会正常运行。

② 隐藏物体:

Invoke会正常运行,但Coroutine是伴随物体对象而存在的,协程不会正常运行

7.导航系统NavMeshAgent

7.1. 使用步骤:

  • 烘焙导航网络
  • 需要导航的物体添加NavMeshAgent组件
  • 运行时使用组件的SetDestination函数进行导航

篇章六:计算机网络

1.计算机网络分层汇总

计算机网络体系大致分为以下三种:OSI,TCP/IP,五层模型。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-evm0xsxU-1675819698785)(D:\简历\计算机网络层级.png)]

各层级的描述与解释:

  • 应用层:为应用程序提供交互服务。应用层协议有很多,比如:域名系统DNS,支持万维网应用的HTTP协议,支持电子邮件的SMTP协议。

  • 表示层:主要负责数据格式的转换,如 加密解密,转换翻译,压缩解压等。

  • 会话层:负责在网络中两节点之间建立,维持和中止通信,如服务器验证用户登录。

  • 运输层:向主机进程提供通用的数据传输服务

    – TCP:提供面向连接的,可靠的数据传输服务。

    – UDP:提供无链接的,尽最大努力的数据传输服务,但无法保证传输可靠性。

  • 网络层:选择合适的路由和交换节点,确保数据及时传送,主要包括IP协议。

  • 数据链路层:简称链路层。将网络层传下来的IP数据包组装成帧,并在相邻链路上传送帧。

  • 物理层:实现相邻节点间比特流的透明传输,尽可能屏蔽传输介质和通信手段的差异。

2.谈谈TCP和UDP

UDP TCP
是否连接
是否可靠 是,使用流量控制和拥塞控制
是否有序 无序 有序,有可能乱但会重排
传输速度
连接对象个数 一对一,一对多,多对一,多对多 只能一对一
传输方式 面向报文 面向字节流
首部开销 小,仅8字节 最小20,最大60字节
使用场景 实时应用(IP电话,视频会议) 可靠传输的环境 文件传输)

总结

TCP和UDP各有其好处与缺点,需要按照需求选择使用。

TCP和UDP各自应用场景

TCP

  • FTP文件传输
  • HTTP/HTTPS…

UDP

  • 包总量较小的通信,如DNS,SNMP…
  • 视频,音频等多媒体通信
  • 广播通信

一些常见的应用层协议:

应用层协议 应用 传输类型
SMTP 电子邮件 TCP
TELNET 远程终端接入 TCP
HTTP 万维网 TCP
FTP 文件传输 TCP
DNS 域名转换 UDP
TFTP 文件传输 UDP
SNMP 网络管理 UDP
NFS 远程文件服务器 UDP

3.TCP三次握手

3.1. 概述

Three-way Handshake,是指建立一个TCP连接时,需要客户端和服务器总共发送3个包。三次握手的目的是链接服务器指定端口,建立TCP链接,并同步连接双方的顺序号和确认号并交换TCP信息。

3.2. 图解TCP三次握手

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K03J2WBC-1675819698785)(D:\简历\TCP三握详解.png)]

图片来自:三次握手和四次挥手以及TCP标志位的详细介绍 - 掘金 (juejin.cn)

3.3. 机制详解

  • 第一次:客户端请求建立连接,向服务端发送一个 **同步报文(SYN = 1)**同时选择一个随机数 seq = x 作为 初始序列号,并进入 SYN_SENT状态,等待服务器确认。
  • 第二次:服务端收到连接请求报文后,如果同意建立连接,则向客户端发送 同步确认报文(SYN = 1,ACK = 1),确认号为 ack = x + 1,同时选择一个随机数 seq = y 作为 初始序列号 ,此时服务器进入 SYN_RECV状态
  • 第三次:客户端收到服务端确认后,向服务端发送一个 确认报文(ACK = 1),确认号为 ack = y + 1 ,序列号为 seq = x + 1,客户端和服务器进入 ESTABLISHED状态,从而完成三次握手。

理想状态下,TCP连接一旦建立,通信双方任何一方主动关闭连接之前,TCP连接都将被一直保持。

3.4. 为什么不是两次或者四次握手

三个原因 ↓

  • 1.访止已过期的连接请求报文突然又传送到服务器,因而产生错误和资源浪费
    解释:在双方两次握手即可建立连接的情况下,假设客户端发送A报文段请求建立连接,由于网络原因造成A暂时无法到达服务器,服务器接收不到请求报文段就不会返回确认报文段。
    客户端在长时间得不到应答的情况下重新发送请求报文段B,这次B顺利到达服务器,服务器随即返回确认报文并进入ESTABLISHED状态,客户端在收到确认报文后也进入ESTABLISHED状态,双方建立连接并传输数据,之后正常断开连接。
    这时姗姗来迟的A报文段才到达服务器,服务器随即返回确认报文并进入ESTABLISHED状态,但是已经进入CLOSED状态的客户端无法再接受确认报文段,更无法进入ESTABLISHED状态,这将导致服务器长时间单方面等待,造成资源浪费。

  • 2.三次握手才能让双方均确认自己和对方的发送和接收能力都正常。
    解释:① 客户端只是发送处请求报文段,什么都无法确认,而服务器可以确认自己的接收能力和对方的发送能力正常;
    ② 客户端可以确认自己发送能力和接收能力正常,对方发送能力和接收能力正常;
    ③ 服务器可以确认自己发送能力和接收能力正常,对方发送能力和接收能力正常;
    可见三次握手才能让双方都确认自己和对方的发送和接收能力全部正常,这样就可以愉快地进行通信了。

  • 3.告知对方自己的初始序号值,并确认收到对方的初始序号值。

    解释:TCP实现了可靠的数据传输,原因之一就是TCP报文段中维护了序号字段和确认序号字段,通过这两个字段双方都可以知道在自己发出的数据中,哪些是已经被对方确认接收的。这两个字段的值会在初始序号值得基础递增,如果是两次握手,只有发起方的初始序号可以得到确认,而另一方的初始序号则得不到确认。

3.5 谈谈SYN洪泛攻击

概述

SYN洪泛攻击属于DOS攻击的一种,利用TCP协议缺陷,发送大量的半连接请求,耗费CPU和内存资源。

原理

  • 在三握过程中,服务器发送 [SYN/ACK](第二个包) 包后,收到客户端的 [ACK] 包(第三个包)之前的TCP连接被称为半连接,此时服务器处于 SYN_RECV状态(等待客户端响应)。如果接收到客户端的 [ACK] ,则TCP连接成功,否则,会不断重发请求直到成功。
  • SYN攻击的攻击者在短时间内伪造大量不存在的IP地址,向服务器不断发送 [SYN] 包,服务器回复 [SYN/ACK] 包,等待客户确认,由于源地址不存在,服务器会不断重发直至超时。
  • 这些伪造的 [SYN] 包将长时间占用未连接队列,影响了正常的SYN,导致目标系统运行缓慢,网络堵塞甚至系统瘫痪。

检测方法:当服务器上看到大量半连接状态时,特别是源IP地址随机,则基本可以断定是一次SYN攻击。

防范

  • 防火墙,路由器等过滤网关防护。
  • 通过加固 TCP/IP 协议栈防范,如增加最大半连接数,缩短超时时间等。
  • SYN cookies技术。该技术是对三握的一些修改,专门用来防范SYN泛洪攻击的手段。

3.6 三握阶段若最后一次ACK包丢失,会发生什么?

服务端

  • 服务端该TCP连接状态为 SYN_RECV,并且会根据TCP的超时重传机制,等待 3s,6s,12s后重新发送 SYN+ACK 包,以便客户端重新发送ACK包。
  • 如果重发指定次数后,仍未收到客户端应答,则一段时间后,服务器自动关闭该连接。

客户端

  • 客户端认为连接已经建立,如果此时向服务端发送数据,服务端将以 RST包(Reset,表示复位,用于异常的关闭连接)相应。此时,客户端知道第三次握手失败。

4. TCP四次挥手

4.1 图解

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8r4KJvsu-1675819698785)(D:\求职资料\四次挥手.png)]

4.2 过程详述

  • 第一次挥手:客户端向服务端发送连接释放报文(FIN = 1,ACK = 1),主动关闭连接,同时等待服务端确认。序列号seq = u(客户端上次发送报文最后一个字节序号+1)。确认号ack = k(服务端上次发送报文的最后一个字节的序号+1).
  • 第二次挥手:服务端收到连接释放报文后,立即发出 确认报文(ACK = 1),序列号seq = k,确认号ack = u+1。此时TCP连接处于半关闭状态,客户端到服务端的连接已经释放,但服务端到客户端还未释放。这表示客户端已经没有数据发送了,但服务端还可能要给客户端发送数据。
  • 第三次挥手:服务端向客户端发送释放连接报文(FIN = 1,ACK = 1),主动关闭连接,同时等待客户端确认。序列号seq = w(服务端上次发送的报文的最后一个字节序号+1),确认号ack = u+1,与第二次挥手相同,这段时间客户端没有发送数据。
  • 第四次挥手:客户端收到服务端的连接释放报文后,立即发出确认报文(ACK = 1),序列号seq = u+1,确认号ack = w + 1。此时,客户端就进入了 TIME-WAIT 状态,此时客户端到TCP连接还没有释放,必须经过2*MSL(最长报文段寿命)时间后,才进入CLOSED状态。而服务端只要收到客户端确认,就立即进入CLOSED状态,可以看到,服务端结束TCP的时间比客户端早一点点。

总结:“我先撤了”,“收到”,“我也撤了”,“好嘞”。

8.为什么会出现三握四挥?

服务器收到客户端的FIN报文后,可能还有一些数据要传输,所以不能马上关闭连接,但是会做出响应,返回ACK报文段。接下来会继续发送数据,数据发送完后,服务器向客户端发送FIN报文,表示数据已经发送完成,请求关闭连接,服务端的ACK和FIN一般会分开发送,从而导致多了一次,因此需要四次挥手。

9.TIME-WAIT状态相关

9.1 为什么客户端的TIME-WAIT必须等待?

  • 1.确保ACK报文能够到达服务端,从而使服务器正常关闭连接。

    第四次挥手时,客户端第四次挥手的ACK报文不一定会到达服务端。服务端会超时重传FIN / ACK报文,此时如果客户端已经断开了连接,那么就无法相应服务端的二次请求,这样服务器迟迟收不到FIN/ACK的确认报文,就无法正常断开连接。MSL是报文段在网络上存活最长时间。客户端等待2MSL后,即【客户端ACK报文1MSL超时 + 服务端FIN报文1MSL传输】,就能收到服务器重传的FIN/ACK报文,然后客户端重传一次ACK报文,并重新启动2MSL计时器。如此保证服务器正常关闭。如果服务端重发的FIN没有成功在2MSL时间里传给客户端,服务端则会超时直到断开连接。

  • 2.防止已失效的连接请求报文段出现在之后的连接中

    TCP要求在2MSL内不使用相同的序列号,客户端在发送完最后一个ACK报文端后,再经过时间2MSL,就可以保证本连接的持续时间内产生所有的报文段都从网络中消失。这样就可以使下一个连接中不会出现旧的连接报文段。或者即使收到这些过时的报文,也可以不处理它。

9.2 如果已经建立连接,但客户端出故障了怎么办?

分析题目换个问法,如果三握四挥的包丢失了怎么办?如“客户端重发FIN丢失”的问题。简而言之:通过定时器 + 超时重试机制 ,尝试获取确认,直到最后会自动断开连接。具体来说:TCP设有一个保活计时器,服务器每收到一次客户端的数据,就会重新复位计时器,时间通常设置为两个小时,若两个小时还没收到客户端的任何数据,服务器就开始重试,每隔75分钟发送一个检测报文段,若一连发送10个报文后客户端依旧没有回应,那么服务器就认为连接已经断开。

9.3 TIME-WAIT状态过多会产生什么后果?

  • 服务端

    短时间内关闭大量Client连接,就会造成服务器上出现大量TIME-WAIT连接,严重消耗服务器资源,此时部分客户端就会显示连接不上。

  • 客户端

    导致端口资源被占用,因为端口就65535个,被占满就会导致无法创建新的连接。

  • 解决办法

    • 服务器可以设置SO_REUSEADDR 套接字选项来避免 TIME_WAIT状态,此套接字告诉内核,即使端口正忙,也请继续重用它。
    • 调整系统内核参数,修改/etc/sysctl.conf文件。
    • 强制关闭,发送RST包越过TIME_WAIT状态,直接进入CLOSED状态。

9.4 TIME_WAIT是服务端状态还是客户端?

TIME_WAIT是主动断开连接的一方进入的状态,一般情况下,都是客户端所处的状态,服务器端一般设置不主动关闭连接。

TIME_WAIT需要等待2MSL,大量短连接的情况下,TIME_WAIT会太多,这也会消耗很多系统资源。对服务器来说,再HTTP协议里指定KeepAlive(浏览器会用一个TCP处理多个HTTP请求),由浏览器主动断开连接,可以一定程度减少服务器这个问题。

10.TCP协议如何保证可靠性?

TCP主要提供了检验和序列号/确认应答超时重传滑动窗口拥塞控制流量控制等方法实现了可靠性传输。

  • 1.检验和:接收端可以检测出数据是否由差错和异常,加入由差错就会放弃TCP,重新发送。
  • 2.序列号/确认应答:序列号的作用不仅仅是应答,有了序列号可以使接收到的数据根据序列号排序,并且去掉重复序列号的数据。TCP传输过程中,每次接收方收到数据后,都会对传输方进行确认应答。也就是发送ACK报文,这个ACK报文当中带有对应的确认序列号,告诉发送方,接收到了哪些数据,下一次的数据从哪里发。
  • 3.滑动窗口:提高了报文传输的效率,也避免了发送方发送过多数据导致接收方无法正常处理的异常。
  • 4.超时重传:指发送出去的数据包到接收到确认包之间的时间,如果超过了这个时间会被认为是丢包了,需要重传。最大超时时间是动态计算的。
  • 5.拥塞控制:再数据传输过程中,可能由于网络状态原因,造成网络拥堵,此时进入拥塞控制机制,再保证TCP可靠性的同时,提高性能。
  • 6.流量控制:如果主机A一直向主机B发送数据,不考虑B的接受能力,可能导致主机B的接收缓冲区满了而无法接收数据,从而导致大量的数据丢包,引发重传机制。而在重传的过程中,主机B的接收缓冲区情况仍未好转,则会将大量时间浪费在重传数据上,降低传送数据的效率。所以引入流量控制机制,B通过告诉A自己接收缓冲区的大小,来使主机A控制发送的数据量。流量控制与TCP协议报头中的窗口大小有关。

11.详谈TCP的滑动窗口

定义

进行数据传输时,如果传输的数据比较大,就需要拆分多个数据包进行发送。TCP协议需要对数据进行确认后,才可发送下一个数据包。这样一来,就会再等待确认应答包环节浪费时间。

解决方案

为了避免在该情况,TCP引入了窗口概念。窗口大小指不需要等待确认应答包而可以继续发送数据包的最大值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Srue6Ofe-1675819698786)(D:\求职资料\滑动窗口.png)]

左边是已经发送并且被确认的分组,滑动窗口右边是还没轮到的分组。

随着信息传输的过程,窗口部分不断向右移动,让还没有轮到的分组进入窗口内部。可以看到滑动窗口起到了一个限流的作用,也就是说当前滑动窗口的大小决定了当前TCP发送包的速率,而滑动窗口的大小却决于拥塞控制窗口和流量控制窗口两者间的最小值。

12.详谈TCP的拥塞控制

TCP一共使用了四种算法来实现拥塞控制:

  • 慢开始(slow-start)
  • 拥塞避免(congestion avoidance)
  • 快速重传(fast retransmit)
  • 快速恢复(fast recovery)

发送方维持一个叫做拥塞窗口cwnd(congestion window)的状态变量。当cwndssthresh时,改用拥塞避免算法。

慢开始
不要一开始就发送大量数据,由小到大逐渐增加拥塞窗口的大小。

拥塞避免

拥塞避免算法让拥塞窗口缓慢增长,即经过一个往返时间RTT就把发送方的拥塞窗口cwnd加1而不是加倍。这样拥塞窗口按线性规律缓慢增长。

快重传

剔除一些不必要的拥塞报文,提高网络吞吐量。比如接收方再收到一个失序的报文段后立即出发重复确认,而不要等到自己发送数据时捎带确认。规定:发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段。而不需要继续等待重传计时器时间到期。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IJslSPgd-1675819698786)(D:\求职资料\快重传机制.png)]

快恢复

主要配合快重传。当发送方连续收到三个重复确认时,就执行“乘法减小算法”,把ssthresh门限减半(为了预防网络发生拥塞),但接下来并不执行慢开始算法,如果网络出现拥塞的话就不会收到好几个重复确认,收到三个重复就确认网络状态正常。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kj166Tva-1675819698786)(D:\求职资料\快恢复算法.png)]

13.HTTP常见状态码

  • 200:服务器成功处理请求。
  • 301:请求的网页已永久移动到新位置。服务器返回此响应时,会自动将请求者跳转到新位置。
  • 302:服务器目前从不同位置网页响应请求,但请求者继续用原有位置进行之后的请求。
  • 400:客户端请求有语法错误,不能被服务器所理解。
  • 403:服务器收到请求,但是拒绝提供服务。
  • 404:服务器找不到请求的网页。
  • 500:服务器遇到错误,无法完成请求。

总结:

  • 1xx:接收的请求正在处理。
  • 2xx:请求正常处理完毕。
  • 3xx:需要进行附加操作以完成请求。
  • 4xx:服务器无法处理请求。
  • 5xx:服务器处理请求出错。

301和302的区别

相同点

301和302都表示重定向,浏览器拿到服务器返回的状态码后会自动跳转到一个新的URL地址,这个地址可以从Location首部获取(用户看到自己输入的地址A变到了另一个地址B)。

不同点

301表示旧地址A资源以及被永久移除了,搜索引擎在抓取新的内容同时也将旧的网址A跳转到B。302表示A资源还在(仍旧可以访问),这个重定向临时从A转到了B,搜索引擎会抓取新的内容而保存旧的地址。

发生情况

  • 1.网页调整(改变网页目录结构)
  • 2.网页被移动到另一个新的地址。
  • 3.网页扩展名改变(.php -> Html)

14.HTTP常用的请求方式

  • GET:获取资源
  • POST:传输实体主体
  • PUT:上传文件
  • DELETE:删除文件
  • HEAD:与GET类似,但只返回报文首部,不返回报文实体
  • PATCH:对资源进行部分修改
  • OPTIONS:查询指定的URL支持的方法
  • CONNECT:要求用隧道协议连接代理
  • TRACE:服务器会将通信路径返回给客户端

GET和POST的区别

  • 1.GET使用URL或Cookie传参,而POST将数据放在BODY中,这个是因为HTTP协议用法规定。
  • 2.GET方式提交的数据长度受限,POST的数据可以非常大,这个是因为它们使用的操作系统和浏览器设置不同的区别。
  • 3.POST比GET安全,因为地址栏数据不可见。
  • 4.本质上,最大区别是 GET请求是 幂等性 的,POST不是。

幂等性:一次或多次去请求某一个资源应具有同样的副作用。意味着对统一URL的多个请求应该返回同样的结果。

15.HTTP的长短连接

HTTP/1.0中,默认使用短连接。也就是说,浏览器和服务器每次进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。如果客户端浏览器访问的某个HTML或其他类型的Web页中包含其他Web资源,如JS,图像,CSS等,当浏览器遇到这样一个Web资源,就会建立一个HTTP对话。

HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头中加入 Connection:keep-alive

在使用长连接的情况下,当一个网页打开完成后,客户端与服务端间用于传输HTTP数据的TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,他有一个保持时间。实现长连接要服务端和客户端都支持长连接。

HTTP协议长短连接本质是 TCP协议的长短连接。

16.HTTP请求报文和响应报文格式

请求报文格式

  • 1.请求行(请求方法 + URI协议 + 版本号)
  • 2.请求头
  • 3.空行
  • 4.请求体
GET/samp1e.jspHTTP/1.1 请求行
Accept:image/,gif.image/jpeg, 请求头部
Accept-Language:zh-cn
Connection:Keep-Alive
Host:localhost
User-Agent:Mozila/4.0(compatible;MSIE5.01;window NT5.0)
Accept-Encoding:gzip,deflate

username=shawn&password=6666 请求主体

响应报文格式

  • 1.状态行(版本号 + 状态码 + 原因短句)
  • 2.响应头
  • 3.空行
  • 4.响应主体
HTTP/1.1 200 OK
Server:Apache Tomcat/5.0.12
Date Mon,60ct2003 13:23:42 GMT
Content-Length:112

<htm1>
	<head>
		<tit1e>HTTP响应示例<tit1e>
	</head>
	<body>
		Hel1o HTTP!
	</body>
</htm1>

17.HTTP1.0 & HTTP1.1 & HTTP2.0

HTTP1.0 & HTTP 1.1对比

  • 长连接:HTTP1.1支持长连接(Persistent Connection)和请求的流水线(Pipelining)处理,在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟,在HTTP1.1中默认开启Connection:keep-a1ive,一定程度上弥补了HTTP1.0每次请求都要创建连接的缺点。
  • 缓存处理:在HTTP1.0中主要使用neader.里的If-Modified-Since,Expires来做为缓存判断的标准,TTP1.则引入了更多的缓存控制策略,可供选择的缓存头来控制缓存策略。带宽优化及网络连接的使用:HTTP1.0中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1则在请求头引入了range头域,它允许只请求资源的某个部分,即返回码206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
  • 错误通知的管理:在HTTP1.1中新增了24个错误状态响应码,如409(Conflict)表示请求的资源与资源的当前状态发生冲突;410(Gone)表示服务器上的某个资源被永久性的删除。
  • Host头处理:在HTTP1.O中认为每台服务器都绑定一个唯一的IP地址,因此,请求消息中的URL并没有传递主机名(hostname)。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机(Multi–homed Web Servers),并且它们共享一个IP地址。HTTP1.1的请求消息和响应消息都应支持Host头域,且请求消息中如果没有Host头域会报告一个错误(400 Bad Request)。

HTTP1.1 & HTTP2.0对比

  • 新的二进制格式:HTTP1.1的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮。
  • 多路复用,即连接共享,即每一个request都是用作连接共享机制的。一个request对应一个id,这样一个连接上可以有多个request,每个连接的request可以随机的混杂在一起,接收方可以根据request的id将request.再归属到各自不同的服务端请求里面。
  • 头部压缩,HTTP1.1的头部(header)带有大量信息,而且每次都要重复发送;HTTP2.0使用encoder来减少需要传输的neaderi大小,通讯双方各自cache一份neader fields表,既避免了重复neaderl的传输,又减小了需要传输的大小。
  • 服务端推送:服务器除了对最初请求的响应外,服务器还可以额外的向客户端推送资源,而无需客户端明确的请求。

18.谈谈HTTP与HTTPS

\ HTTP HTTPS
协议 运行在TCP 运行在SSL,SSL又在TCP之上
端口 80 443
安全性 无加密,安全性差 有加密机制,安全性高
资源消耗 较少 更多
是否需要证书 不需要 需要

HTTPS优缺点

优点:

  • 安全性:

    使用HTTPS协议可认证用户和服务器,确保数据发送到正确的客户机和服务器;

    HTTPS协议是由SSL + HTTP协议构建的可进行加密传输,身份认证的网络协议,比HTTP安全,防止数据在传输过程中不被窃取,改变,保证数据的完整性。

    HTTPS是现行框架下最安全的解决方案,虽然不是绝对安全,但大幅增加了中间人攻击的成本。

  • SEO:

    采用HTTPS加密的网站在搜索结果中排名会更高。

缺点:

  • 相同网络环境下,HTTPS比HTTP无论是响应时间还是耗电量都大幅上升。
  • HTTPS的安全是有范围的,在黑客攻击,服务器劫持等情况无法起到作用。
  • 在现有证书的机制下,中间人攻击依然有可能发生。
  • HTTPS需要更多服务器资源,也会导致成本增高。

HTTPS加密原理:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ilbMK6Gy-1675819698786)(D:\Downloads\HTTPS原理.png)]

  • 1.客户端请求HTTPS网址,然后连接到server的443端口。
  • 2.采用该协议的服务器必须要有一套数字CA证书,颁发证书的同时会产生公钥和私钥。私钥由服务器自己保存,不可泄漏。公钥附带在证书的信息中,可以公开。证书本身也附带证书电子签名,这个去前面用来验证证书的完整性和真实性,可以防止证书被修改。
  • 3.服务器响应客户端请求,将证书传递给客户端,证书包含公钥和大量其他信息。
  • 4.客户端解析证书并对其进行验证。如果证书不是可信机构颁布,或者证书中域名与实际不一致,或者已过期,就会向访问者弹出一个警告,由其选择是否要继续通信。如果证书无误,客户端会从服务器中去除服务器的公钥A。然后客户端还会生成一个随机码KEY,并使用公钥A将其加密。
  • 5.客户端把加密的随机码KEY发送到服务器,作为后面对称加密的密钥。
  • 6.服务器在收到KEY之后会使用私钥B将其解密。随后,CS两端建立了安全连接,完美解决了对称加密的密钥泄露问题,接下来就可以安全通信了。

总结:服务器使用密钥(随机码KE)对数据进行对称加密并发送给客户端,客户端使用相同的密钥(随机码KEY)解密数据。

19.访问某网站的全过程

  • 1.域名解析:域名 —> ip地址

    浏览器搜索自己的DNS缓存(一张域名与IP的的对应表);如果没有,则搜索操作系统的DNS缓存;如果没有,搜索操作系统的hosts文件。如果都没呀,则找tcp/ip参数中设置的首选DNS服务器,即本地DNS服务器,如果没有,将本地DNS服务器将IP返回给操作系统,缓存IP。

  • 2.发起TCP的三握,建立连接。浏览器会以一个随机端口向服务器的web程序80端口发起TCP连接。

  • 3.建立TCP连接后发起HTTP请求

  • 4.服务器响应HTTP请求后,客户端得到HTML代码。服务器web应用程序收到HTTP请求后,就开始处理请求,之后返回给浏览器HTML文件。

  • 5.浏览器解析HTML代码,并请求HTML中的资源。

  • 6.浏览器对页面渲染,并呈现给用户。

20.谈谈Cookie和Session

Cookie

HTTP Cookie是服务器发送到用户浏览器并保存在本地的一小块数据,他会在浏览器下次向同一服务器发起请求时携带并发送到服务器上。通常,其用于告知服务端两个请求是否来自同一浏览器,如保持用户的登陆状态。主要用于以下三个方面:

  • 会话状态管理(用户登录状态,购物车,游戏分数等)
  • 个性化设置
  • 浏览器行为跟踪

Session

Session代表当前服务器和客户端一次会话的过程。Session对象存储特定用户会话所需的属性及其配置信息。这样,当用户在应用程序的Web之间跳转时,存储在Session对象中的变量不会丢失,而是整个用户会话中一直存在下去。当客户端关闭会话,或者Session超时失效时会话结束。

Cookie和Session如何配合?

用户的第一次请求服务器时,服务器根据用户提交的相关信息,创建的对应的Session,请求返回时将此Session的唯一标识信息SessionID返回给浏览器,浏览器接收到ID后,会将此信息存入Cookie,同时Cookie记录该Session属于哪个域名。

当用户第二次访问服务器时,请求会自动判断该域名下是否存在Cookie信息,如果存在将Cookie信息也发送给服务端,服务端会从Cookie中获取SessionID,再根据SessionID查找对应的Session信息,如果没有找到,说明用户没有登录或失效,如果找到,则登录成功。

总结:Cookie —存储—> SessionID —查找—> Session

Cookie和Session的区别

  • 作用范围不同。Cookie保存在客户端(浏览器),Session保存在服务端。
  • 存取方式不同。Cookie只能保存ASCII,Session可以存任意数据类型,一般情况下,我们可以再Session中保存一些常用变量,比如USERID。
  • 有效期不同。Cookie可设置长时间保持(默认登录),Session一般时长较短,客户端关闭或超时都会使其失效。
  • 隐私策略不同。Cookie容易遭到不法获取,而Session存在服务端,安全性相比要更高一些。
  • 存储大小不同。单个Cookie保存数据不能超过4K,Session可存储数据高于Cookie。

如何防止分布式Session的问题

在互联网公司为了可以支撑更大的流量,后端往往需要多台服务器共同来支撑前端用户请求,那如果用户在A服务器登录了,第二次请求跑到服务B就会出现登录失效问题。
分布式Session一般会有以下几种解决方案:

  • 客户端存储:直接将信息存储在cookie中,cookie是存储在客户端上的一小段数据,客户端通过http协议和服务器进行cookie交互,通常用来存储一些不敏感信息
  • Nginx ip_hash策略:服务端使用Nginx代理,每个请求按访问IP的hash分配,这样来自同一IP固定访问一个后台服务器,避免了在服务器A创建Session,第二次分发到服务器B的现象。
  • Session复制:任何一个服务器上的Session发生改变(增删改),该节点会把这个Session的所有内容序列化,然后广播给所有其它节点。
  • 共享Session(推荐):服务端无状态话,将用户的Session等信息使用缓存中间件(如Redis)来统一管理,保障分发到每一个服务器的响应结果都一致。建议采用共享Session的方案。

21. 一些常见的网络攻击

DDOS

DDos全称Distributed Denial of Service,分布式拒绝服务攻击。最基本的DOS攻击过程如下:

  • 1.客户端向服务端发送请求链接数据包。
  • 2.服务端向客户端发送确认数据包。
  • 3.客户端不向服务端发送确认数据包,服务器一直等待来自客户端的确认。

DDoS则是采用分布式的方法,通过在网络上占领多台“肉鸡”,用多台计算机发起攻击。
DOS攻击现在基本没啥作用了,因为服务器的性能都很好,而且是多台服务器共同作用,V1的模式黑客无法占上风。对于DDOS攻击,预防方法有:

  • 减少SYN timeout时间。在握手的第三步,服务器会等待30秒-120秒的时间,减少这个等待时间就能释放更多的资源。
  • 限制同时打开的SYN半连接数目。

XXS

XSS也称cross-site scripting,跨站脚本。这种攻击是由于服务器将攻击者存储的数据原原本本地显
示给其他用户所致的。比如一个存在XSS漏洞的论坛,用户发帖时就可以引入带有

SQL注入

SQL注入就是在用户输入的字符串中加入SQL语句,如果在设计不良的程序中忽略了检查,那么这些注入进去的SQL语句就会被数据库服务器误认为是正常的SQL语句而运行,攻击者就可以执行计划外的命令或访问未被授权的数据。

SQL注入的原理主要有以下4点

  • 恶意拼接查询
  • 利用注释执行非法命令
  • 传入非法参数
  • 添加额外条件

避免SQL注入的一些方法

  • 限制数据库权限,给用户提供仅仅能够满足其工作的最低权限。
  • 对进入数据库的特殊字符(八尖括号&*:等)转义处理。
  • 提供参数化查询接口,不要直接使用原生SQL。

22.谈谈负载均衡算法

多台服务器以对称的方式组成一个服务器集合,每台服务器有等价的地位,能互相分担负载。

  • 轮询:按顺序轮流分配到服务器集合,无法发挥出一些高性能服务器的优势。
  • 随机:随机获取。
  • 哈希:通过ip地址哈希化要选择的服务器编号。这样每次用户访问服务器都是同一个,能很好地利用Session和Cookie。
  • 加权轮询:根据服务器性能不同加权分配。

篇章七:操作系统

1. 进程与线程

进程定义:进程好比一个程序,它是操作系统分配资源的最小单位,他的同一时刻执行的进程数不会超过CPU核心数。

线程定义:程序内任务与任务之间的关系,线程依赖于进程,是程序执行过程中的最小单元。独立调度的基本单位。

  • 调度:进程是资源管理的基本单位,线程是程序执行的基本单位。
  • 切换:线程上下文切换比进程上下文切换要快得多。
  • 拥有资源:进程是拥有资源的一个独立单位,线程不拥有资源,但可以访问隶属于进程的资源。
  • 系统开销:创建或撤销进程时,系统都要为之分配或回收系统资源,如内存空间,I/O设备等,OS所付出的开销大于在创建或撤销线程时的开销,进程切换的开销也远大于线程切换的开销。

2.协程与线程

  • 机制:线程和进程都是同步机制,协程是异步机制。
  • 运行方式:线程是抢占式,协程是非抢占式。需要用户释放使用权切换到其他协程,因此同一时间只有一个协程右运行权,相当于单线程的能力。
  • 包含关系:一个线程可以有多个协程,一个进程也可以有多个协程。
  • 操作关系:协程不被OS内核管理,完全由程序控制。线程是被分割的CPU资源,协程是组织好的代码流程。协程不会直接使用线程资源,协程直接利用的是执行器关联任意线程或线程池。
  • 此外,协程可以保留上一次调用的状态。

3.并发和并行

并发:一段时间内,多个任务都会被处理;但在某一时刻,只有一个任务在执行。单核处理器可以做到并发。比如:两个进程A,B,A运行在一个时间片之后,切换到B,B运行一个时间片后又切换到A。切换速度足够快,所以宏观表现为一段时间能同时运行多个程序。

并行:同一时刻,多个任务在执行。需要多核处理器才能完成,在微观上能同时执行多条指令,不同程序被放到不同处理器上运行,这个是物理上多个进程同时进行。

4.进程和线程的切换流程

进程

  • 1.切换页表以使用新的内存地址,一旦切换上下文,处理器中所有已缓存的内存地址一瞬间都作废了。
  • 2.切换内核栈和硬件上下文。

线程

​ 对linux来说,进程和线程最大区别就是 地址空间,对于线程切换,上述第一步不需要做,第二部是进程线程切换都需要做的。

对比:

由于每个进程有自己的虚拟地址空间,线程是共享所在进程的虚拟地址空间,在同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。

为什么虚拟地址空间切换费时

​ 进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个Cache就是TLB(translation Lookaside Buffer,TLB本质上就是一个Cache,是用来加速页表查找的)。
​ 由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后TLB就失效了,Cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致TLB失效,因为线程无需切换地址空间,因此我们通常说线程切换要比较进程切换块,原因就在这里。

5.进程间的通信方式

5.1. 管道

概述:这种方式分为两类。匿名管道和命名管道。匿名管道是单向的,只能在有亲缘关系的进程间通信;命名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。

优缺点:速度慢,容量有限。

5.2. 信号量

概述:一个计数器,可以用来控制多个进程对共享资源的访问。常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

优缺点:不能传递复杂消息,只能用来同步。

5.3 消息队列

概述:消息的链接表,有足够权限的进程可以向队列中添加消息,被赋予读权限的进程可以读队列中的消息,消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。

优缺点:容量收到系统限制,注意第一次读的时候,要考虑上一次有没有读完数据的问题。

5.4 共享内存

概述:映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的IPC方式,他是针对其他进程间通信方式运行效率低专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步与通信。

优缺点:很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通信,不过没这个必要。

5.5. Socket

概述:可用于不同机器间的进程通信。

优缺点:任何进程间都可以通信,但速度慢。

6.进程同步的方式

6.1 临界区:通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。

优点:保证某一时刻只有一个线程能访问数据。

缺点:虽然临界区同步速度快,但却只能用来同步本进程内的线程,不可用来同步多个进程的线程。

6.2 互斥量:为协调共同对一个共享资源的单独访问而设计。互斥量跟临界区相似,比临界区复杂,互斥对象只有一个,只有拥有互斥对象的线程才具有访问资源的权限。

优点:使用互斥不仅仅能在统一应用程序不同线程中实现资源的安全共享,而且可以在不同应用程序的线程之间实现对资源的安全共享。

缺点

  • 互斥量是可以命名的,也就是说它可以跨越进程使用,所以创建互斥量需要的资源更多,所以如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量。
  • 通过互斥量可以指定资源被独占的方式使用,但如果有下面一种情况通过互斥量就无法处理,比如现在一位用户购买了一份三个并发访问许可的数据库系统,可以根据用户购买的访问许可数量来决定有多少个线程/进程能同时进行数据库操作,这时候如果利用互斥量就没有办法完成这个要求,信号量对象可以说是一种资源计数器。

6.3 信号量:为控制一个具有有限数量用户资源而设计。它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。互斥量是信号量的一种特殊情况,当信号量的最大资源数=1就是互斥量了。

优点:适用于Socket(套接字)程序中线程的同步。

缺点

  • 信号量机制必须有公共内存,不能用于分布式操作系统,这是它最大的弱点;
  • 信号量机制功能强大,但使用时对信号量的操作分散,而且难以控制,读写和维护都很困难,加重了程序员的编码负担;
  • 核心操作PV分散在各用户程序的代码中,不易控制和管理,一旦错误,后果严重,且不易发现和纠正。

6.4 事件:用来通知线程有一些事件已发生,从而启动后继任务的开始。

优点:事件对象通过通知操作的方式来保持线程的同步,并且可以实现不同进程中的线程同步操作。

7.线程同步的方式

7.1 临界区:当多个线程访问一个独占性共享资源时,可以使用临界区对象。拥有临界区的线程可以访问被保护起来的资源或代码段,其他线程若想访问,则被挂起,直到拥有临界区的线程放弃临界区为止,以此达到用原子方式操作共享资源的目的。

7.2 事件:事件机制,则允许一个线程在处理完一个任务后,主动唤醒另外一个线程执行任务。

7.3 互斥量:互斥对象和临界区对象非常相似,只是其允许在进程间使用,而临界区只限制与同一进程的各个线程之间使用,但是更节省资源,更有效率。

7.4 信号量:当需要一个计数器来限制可以使用某共享资源的线程数目时,可以使用”信号量"对象。
区别:

  • 互斥量与临界区的作用非常相似,但互斥量是可以命名的,也就是说互斥量可以跨越进程使用,但创建互斥量需要的资源更多,所以如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量。因为互斥量是跨进程的互斥量一旦被创建,就可以通过名字打开它。
  • 互斥量,信号量,事件都可以被跨越进程使用来进行同步数据操作。

8.线程的分类

从线程的运行空间来说,分为用户级线程(user-level thread,ULT)和内核级线程(kernel-level,KLT)

内核级线程:这类线程依赖于内核,又称为内核支持的线程或轻量级进程。无论是在用户程序中的线程还是系统进程中的线程,它们的创建、撤销和切换都由内核实现。比如英特尔5-8250U是4核8线程,这里的线程就是内核级线程
用户级线程:它仅存在于用户级中,这种线程是不依赖于操作系统核心的。应用进程利用线程库来完成其创建和管理,速度比较快,操作系统内核无法感知用户级线程的存在。

9.谈谈临界区,并说说如何解决冲突

临界区:每个进程中访问临界资源的那段程序称为临界区,一次仅允许一个进程使用的资源称为临界资源。

解决冲突的办法:

  • 如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入,如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待;
  • 进入临界区的进程要在有限时间内退出。
  • 如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等"现象。

10.谈谈死锁

概念:在两个或者多个并发进程中,如果每个进程持有某种资源而又等待其它进程释放它或它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组进程产生了死锁。通俗的讲就是两个或多个进程无限期的阻塞、相互等待的一种状态。

产生条件

  • 互斥条件:一个资源一次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得资源保持不妨。
  • 不剥夺条件:进程获得的资源在未使用完全之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相连的环形等待资源关系。

如何处理:常用的处理死锁的方法有:死锁预防、死锁避免、死锁检测、死锁解除、鸵鸟策略

  • 死锁预防:基本思想就是确保死锁发生的四个必要条件中至少有一个不成立。
  • 死锁避免:死锁预防通过约束资源请求,防止4个必要条件中至少一个的发生,可以通过直接或间接预防方法,但是都会导致低效的资源使用和低效的进程执行。而死锁避免则允许前三个必要条件,但是通过动态地检测
    资源分配状态,以确保循环等待条件不成立,从而确保系统处于安全状态。所谓安全状态是指:如果系统能按某个顺序为每个进程分配资源(不超过其最大值),那么系统状态是安全的,换句话说就是,如果存在一个安全序列,那么系统处于安全状态。银行家算法是经典的死锁避免的算法。
  • 死锁检测:死锁预防策略是非常保守的,他们通过限制访问资源和在进程上强加约束来解决死锁的问题。死锁检测则是完全相反,它不限制资源访问或约束进程行为,只要有可能,被请求的资源就被授权给进程。但是操作系统会周期性地执行一个算法检测前面的循环等待的条件。死锁检测算法是通过资源分配图来检测是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有存在环,也就是检测到死锁的发生。
  • 死锁解除:死锁解除的常用方法就是终止进程和资源抢占,回滚。所谓进程终止就是简单地终止一个或多个进程以打破循环等待,包括两种方式:终止所有死锁进程和一次只终止一个进程直到取消死锁循环为止;所谓
    资源抢占就是从一个或者多个死锁进程那里抢占一个或多个资源。
  • 鸵鸟策略:把头埋在沙子里,假装根本没发生问题。因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任何措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以
    采用鸵鸟策略。大多数操作系统,包括Unix,Linux和Windows,处理死锁问题的办法仅仅是忽略它。

11.进程有哪些状态

进程一共有5种状态,分别是创建、就绪、运行(执行)、终止、阻塞

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U80pGpiM-1675819698787)(D:\简历\进程状态图.png)]

  • 运行状态就是进程正在CPU上运行。在单处理机环境下,每一时刻最多只有一个进程处于运行状态。
  • 就绪状态就是说进程已处于准备运行的状态,即进程获得了除CPU之外的一切所需资源,一旦得到CPU即可运行。
  • 阻塞状态就是进程正在等待某一事件而暂停运行,比如等待某资源为可用或等待工/o完成。即使CPU空闲,该进程也不能运行。

运行态→阻塞态:往往是由于等待外设,等待主存等资源分配或等待人工干预而引起的。
阻塞态→就绪态:则是等待的条件已满足,只需分配到处理器后就能运行。
运行态→就绪态:不是由于自身原因,而是由外界原因使运行状态的进程让出处理器,这时候就变成就绪态。例如时间片用完,或有更高优先级的进程来抢占处理器等。
就绪态→运行态:系统按某种策略选中就绪队列中的一个进程占用处理器,此时就变成了运行态。

12.分页和分段

分页

把内存空间划分为大小相等且固定的块,作为主存的基本单位。因为程序数据存储在不同的页面中,而页面又离散的分布在内存中,因此需要一个页表来记录映射关系,以实现从页号到物理块号的映射。
访问分页系统中内存数据需要两次的内存访问(一次是从内存中访问页表,从中找到指定的物理块号,加上页内偏移得到实际物理地址;第二次就是根据第一次得到的物理地址访问内存取出数据)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vlMGIS4o-1675819698787)(D:\求职资料\分页.png)]

分段

分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)。
分段内存管理当中,地址是二维的,一维是段号,二维是段内地址;其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。由于分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MzjMP4n9-1675819698787)(D:\求职资料\分段.png)]

分段和分页的对比

  • 分页对程序员是透明的,但是分段需要程序员显式划分每个段。
  • 分页的地址空间是一维地址空间,分段是二维的。
  • 页的大小不可变,段的大小可以动态改变。
  • 分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。

交换空间

操作系统把物理内存(physical RAM)分成一块一块的小内存,每一块内存被称为页(page)。当内存资源不足时,Linux把某些页的内容转移至硬盘上的一块空间上,以释放内存空间。硬盘上的那块空间叫做交换空间(swap space),而这一过程被称为交换(swapping)。物理内存和交换空间的总容量就是虚拟内存的可用容量。
用途:

  • 物理内存不足时一些不常用的页可以被交换出去,腾给系统。
  • 程序启动时很多内存页被用来初始化,之后便不再需要,可以交换出去。

13.多种地址空间的区分

13.1 物理地址

物理地址就是内存中真正的地址,它就相当于是你家的门牌号,你家就肯定有这个门牌号,具有唯一性。不管哪种地址,最终都会映射为物理地址。

13.2 线性地址 & 虚拟地址

实模式下,段基址+段内偏移经过地址加法器的处理,经过地址总线传输,最终也会转换为 物理地址

保护模式下,段基址+段内偏移被称为 线性地址,不过此时的段基址不能称为真正的地址,而是会被称作为一个选择子的东西,选择子就是个索引,相当于数组的下标,通过这个索引能够在GDT中找到相应的段描述符,段描述符记录了段的起始、段的大小等信息,这样便得到了基地址。如果此时没有开启内存分页功能,那么这个线性地址可以直接当做物理地址来使用,直接访问内存。如果开启了分页功能,那么这个线性地址又多了一个名字,这个名字就是虚拟地址

13.3 有效地址 & 逻辑地址

不论在实模式还是保护模式下,段内偏移地址都叫做有效地址。有效地址也是逻辑地址
线性地址可以看作是虚拟地址,虚拟地址不是真正的物理地址,但是虚拟地址会最终被映射为物理地址。下面是虚拟地址->物理地址的映射。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9RL6qfo2-1675819698787)(D:\Downloads\虚拟地址与物理地址的映射.png)]

14.页面替换算法

在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存己无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。

  • 最优算法 在当前页面中置换最后要访问的页面。不幸的是,没有办法来判定哪个页面是最后一个要访问的,因此实际上该算法不能使用。然而,它可以作为衡量其他算法的标准。
  • NRU算法根据R位和M位的状态将页面分为四类。从编号最小的类别中随机选择一个页面。NRU算法易于实现,但是性能不是很好。存在更好的算法。
  • FIFO会跟踪页面加载进入内存中的顺序,并把页面放入一个链表中。有可能删除存在时间最长但是还在使用的页面,因此这个算法也不是一个很好的选择。
  • 第二次机会算法是对FIFO的一个修改,它会在删除页面之前检查这个页面是否仍在使用。如果页面正在使用,就会进行保留。这个改进大大提高了性能。
  • 时钟算法是第二次机会算法的另外一种实现形式,时钟算法和第二次算法的性能差不多,但是会花费更少的时间来执行算法。
  • LU算法是一个非常优秀的算法,但是没有特殊的硬件(TLB)很难实现。如果没有硬件,就不能使用LRU算法。
  • NFU算法是一种近似于LRU的算法,它的性能不是非常好。
  • 老化算法是一种更接近LRU算法的实现,并且可以更好的实现,因此是一个很好的选择。

最后两种算法都使用了工作集算法。工作集算法提供了合理的性能开销,但是它的实现比较复杂。WSc1ock是另外一种变体,它不仅能够提供良好的性能,而且可以高效地实现。

总结:最好的算法是老化算法和WSClock算法。他们分别是基于LRU和工作集算法。他们都具有良好的性能并且能够被有效的实现。还存在其他一些好的算法,但实际上这两个可能是最重要的。

15.缓冲区溢出

缓冲区溢出是指当计算机向缓冲区填充数据时超出了缓冲区本身的容量,溢出的数据覆盖在合法数据上。
危害有以下两点

  • 程序崩溃,导致拒绝额服务
  • 跳转并且执行一段恶意代码

造成缓冲区流出的主要原因是程序中没有仔细检查用户输入。

16.详谈虚拟内存

虚拟内存是什么

虚拟内存就是说,让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。虚拟内存使用部分加载的技术,让一个进程或者资源的某些页面加载进内存,从而能够加载更多的进程,甚至能加载比内存大的进程,这样看起来好像内存变大了,这部分内存其实包含了磁盘或者硬盘,并且就叫做虚拟内存。

如何实现虚拟内存

虚拟内存中,允许将一个作业分多次调入内存。采用连续分配方式时,会使相当一部分内存空间都处于暂时或永久的空闲状态,造成内存资源的严重浪费,而且也无法从逻辑上扩大内存容量。因此,虚拟内存的实需要建立在离散分配的内存管理方式的基础上。虚拟内存的实现有以下三种方式:

  • 请求分页存储管理。
  • 请求分段存储管理。
  • 请求段页式存储管理。

17.谈谈IO的多路复用

IO多路复用是指内核一旦发现进程指定的一个或者多个O条件准备读取,它就通知该进程。O多路复用适用如下场合:

  • 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用/O复用。
  • 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
  • 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到/O复用。
  • 如果一个服务器即要处理TCP,又要处理UDP,一般要使用/O复用。
  • 如果一个服务器要处理多个服务或多个协议,一般要使用/O复用。
  • 与多进程和多线程技术相比,/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

18.硬链接与软连接

硬链接

在目录下创建一个条目,记录着文件名与Index编号,这个index就是源文件的index,删除任意一个条目,文件依然存在,只要引用数量不为0,但是硬链接有限制,无法跨越文件系统,也不能对目录进行链接。

软链接

符号链接文件保存着源文件所在的绝对路径,在读取时会定位到源文件上,可以理解为windows的快捷方式。当源文件被删除了,链接文件就打不开了。因为记录的是路径,所以可以为目录建立符号链接。

19.中断与轮询

如何处理中断

  • 1.保护现场:将当前执行程序的相关数据保存在寄存器中,然后入栈。
  • 2.开中断:以便执行中断时能响应较高级别的中断请求。
  • 3.中断处理
  • 4.关中断:保证恢复现场时不被新中断打扰
  • 5.恢复现场:从堆栈中按序取出程序数据,恢复中断前的执行状态。

中断和轮询的区别

轮询:CPU对特定设备轮流询问,效率低等待时间长,CPU利用率不高。

中断:通过特定事件提醒CPU。容易遗漏问题,CPU利用率不高。

20. 用户态与内核态

所有的用户进程都是运行在用户态的,但是我们上面也说了,用户程序的访问能力有限,一些比较重要的比如从硬盘读取数据,从键盘获取数据的操作则是内核态才能做的事情,而这些数据却又对用户程序来说非常重要。所以就涉及到两种模式下的转换,即**用户态->内核态->用户态,而唯一能够做这些操作的只有系统调用**,而能够执行系统调用的就只有操作系统
一般用户态->内核态的转换我们都称之为trap进内核,也被称之为陷阱指令(trap instruction)。
他们的工作流程如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2EPZSmaS-1675819698787)(D:\求职资料\用户态与内核态.png)]

  • 首先用户程序会调用g1ibc库,glibc是一个标准库,同时也是一套核心库,库中定义了很多关键API。
  • glibc库知道针对不同体系结构调用系统调用的正确方法,它会根据体系结构应用程序的二进制接口设置用户进程传递的参数,来准备系统调用。
  • 然后,glibc库调用软件中断指令(SWI),这个指令通过更新CPSR寄存器将模式改为超级用户模式,然后跳转到地址0x08处。
  • 到目前为止,整个过程仍处于用户态下,在执行SWI指令后,允许进程执行内核代码,MMU现在允许内核虚拟内存访问。
  • 从地址Ox08开始,进程执行加载并跳转到中断处理程序,这个程序就是ARM中的vector_swi()。
  • 在vector_.swi0处,从SW指令中提取系统调用号SCNO,然后使用SCNO作为系统调用表sys_ca11_tab1e的索引,调转到系统调用函数。
  • 执行系统调用完成后,将还原用户模式寄存器,然后再以用户模式执行。

21.Unix常见的IO模型

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

  • 等待数据准备就绪(Waiting for the data to be ready)
  • 将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:

  • 阻塞式IO模型(blocking 10 model)
  • 非阻塞式IO模型(noblocking 10 model)
  • IO复用式IO模型(IO multiplexing model)
  • 信号驱动式IO模型(signal-driven 10 model)
  • 异步1O式IO模型(asynchronous 1O model)

其中,IO多路复用模型指的是:使用单个进程同时处理多个网络连接IO,他的原理就是select、poll、epoll不断轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。该模型的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

select,poll,epoll的区别

(1) select:时间复杂度O(n)
select仅仅知道有I/O事件发生,但并不知道是哪几个流,所以只能无差别轮询所有流,找出能读出数据或者写入数据的流,并对其进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

(2) poll:时间复杂度O(n)
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,但是它没有最大连接数的限制,原因是它是基于链表来存储的。

(3)epoll:时间复杂度O(1)
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以说epoll实际上是事件驱动(每个事件关联上的。select,pol,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),就通知程序进行相应的读写操作。

但select,poll,epoll本质上都是同步/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步/O则无需自己负责进行读写,异步/O的实现会负责把数据从内核拷贝到用户空间。

篇章八: Lua与热更实现方案

1.Lua中的数据类型汇总

  1. number : 表示所有的数字类型(整形,浮点,大数字…)
  2. nil :空类型,未被赋值的变量默认为nil类型。
  3. boolean :布尔类型,表示真假
  4. string:字符串类型,用单 / 双引号包括的内容。
  5. function:函数类型
  6. table:表类型
  7. userdata
  8. thread:协程

2.Lua嵌套闭包与变长参数

--函数闭包案例
function f1(x)
    --改变了变量的生命周期,x本应被销毁回收
   return function(y)
        return x + y;
   end
end

f1(10)(5) --15

--变长参数列表
function f2( ... )
    args = {
    
     ... }
    for i = 1,#args do
        print(args[i])
    end
end

f2(666,"hhh",false,nil)

总结一句话:闭包原理会延长Lua函数方法变量的生命周期。

3.Lua中ipairs & pairs 的区别

--ipairs:通过索引遍历,,无法找到 <= 0的自定义索引,并且一旦下标断序,后面的内容都无法找到。
a = {
    
    [0] = 0,[-1] = -1,2,3,4}
for i,j ipairs(a) do
    print(i .. j)
end
--输出内容:1_2,2_3,3_4

--Table = 哈希表 + 数组
local t = {
    
    [1]=1,2,[3]=3,4,[5]=5,[6]=6}
--拆分如下:
--哈希表:{[1] = 1,[3] = 3,[5] = 5,[6] = 6}
--数组:{2,4} ,这数组中元素放入哈希表重新匹配得遍历结果为
-- {[1] = 2,[2] = 4,[3] = 3,[5] = 5,[6] = 6}
print('ipairs')
for index, value in ipairs(t) do
    print(value) --2,4,3
end

--pairs:遍历到所有内容,但自定义索引会放在最后
for i,j pairs(a) do
    print(1 .. j)
end
--输出内容:1_2,2_3,3_4,0_0,-1_-1

如果我既需要解决顺序遍历的问题,也需要遇到nil遍历完全,使用什么方法遍历

for index = 1,maxSize do
    if table[index] ~= nil then
        --对应处理--
    end
end

4.Lua中 "."和 ":"的区别与使用

Student = {
    
    
    age = 18,
    sex = true,
    Learn = function(t)
        print("好好学习" .. t.age)
    end
}
Student.name = "shawn"

--调用函数时,使用:相当于默认给形参添加了一个自己类型的值,以下两句代码意义相同。
Student.Learn(Student) -- 好好学习18
Student:Learn() -- 好好学习18

--声明函数时,也可以使用以上规则
Student.Speak = function()
    print("说话1")
end
--self关键字代表使用:时默认传入的第一个参数
function Student:Speak2()
    print("说话2".. self.name)
end

Student.Speak() -- 说话1
Student:Speak2() --说话2shawn

总结:在表内部访问自己的变量时,必须指定是哪个对象的相关属性,否则会找不到,尽管同样在类的内部。**: **在使用时,会把自己当作第一个参数传入函数。

5.Lua实现面向对象

--封装
--一个学生类(表)(三种属性定义方法)
Student = {
    
    id = 1}
Student.name = "Shawn"
Student["sex"] = true

--给学生类添加一个new对象的方法
function Student:new()
    local stu = {
    
    } --本质是定义个空表
    self.__index = self;
    setmetatable(stu,self)--把对象设为类的子表
    return stu
end

--测试模块
local myStu = Student:new()
print(myStu["name"]) --Shawn
--修改对象的属性
myStu.id = 999 --修改子表(对象)的属性
print(myStu["id"]) --999

--继承
--C#继承语法:class 类名 : 父类
--写一个用于继承的函数
function Student:subClass( className )
    _G[className] = {
    
    } --在大G表中设置一个空表(新的子类)
    local stu = _G[className]
    self.__index = self
    setmetatable(stu,self)
end

--测试模块
Student:subClass("pupil") --定义一个小学生子类
print(pupil.name) --Shawn
--修改子类的值,其值便不顺着其元表修改而修改
pupil["name"] = "小学生"
print(pupil.name) --小学生

--多态
--相同行为,多种表现
Student:subClass("Player")
Player.x = 0
Player.y = 0
function Player:Move( value )
    self.x = self.x + value
    self.y = self.y + value
    print(self.x)
    print(self.y)
end

--高级玩家是玩家的子类,其移动方法相比不同玩家强
--方法重写
Player:subClass("SuperPlayer")
function SuperPlayer:Move()
    self.base.Move(self , 2)
end

local sp1 = SuperPlayer:new()
sp1:Move()--2,2
sp1:Move()--4,4
local sp2 = SuperPlayer:new()
sp2:Move()--2,2

6.Lua深拷贝和浅拷贝

6.1 如何实现浅拷贝

  • 1.当拷贝对象是string,number,bool基本类型时,拷贝过程就是复制粘贴。修改新拷贝的对象,不会影响源对象。
  • 2.当拷贝对象是table时,拷贝出来的对象和原先对象是同一个,只是两个名字不同,类似C#引用地址,两者任意改变都会影响双方。

6.2 如何实现深拷贝

核心思想逻辑:使用递归遍历表中所有元素。

  • 如果该类型不是表,就没有遍历的必要,可以作为返回值赋值。
  • 如果该类型是表,就新建一个表存储老表中的内容,遍历原表,将k,v分别赋值。完成赋值后,将老表的元表赋值给新表。
  • 对k,v进行赋值时,同样需要判断数据类型是不是表。
function copy_Table(obj)
    function copy(obj)
        if type(obj) ~= "table" then
            return obj;
        end
        local newTable = {
    
    };
        
        for k,v in pairs(obj) do
            newTable[copy(k)] = copy(v);
        end
        return setmetatable(newTable,getmetatable(obj));
    end
    return copy(obj)
end nm 

3.热更新的原理

2.1. 为什么使用Lua作为热更新语言,不用C#实现?

1.语言性质问题

热更新本身对于资源热更是很容易的,Unity自带的AB包就可以实现,麻烦的是代码热更新。由于C#是编译型语言,Unity在打包后,会将C# 编译成一种中间代码,再由Mono虚拟机编译成汇编代码供给各个平台执行,打包后就变成二进制了,跟着程序同时启动,无法进行更改。

Lua是解释型语言 ,并不需要事先编译成块,而是运行时动态解释执行的,这样Lua就和游戏资源没有区别,因此可以在运行时直接从Web服务器上下载到持久化目录呗其他Lua文件调用。

2.平台限制原因

C#只能在安卓上实现热更,苹果不能。在安卓上可以通过反射机制实现动态代码加载从而实现热更。实现原理是将频繁更改的逻辑部分独立出来做成DLL,在主模块调用这些DLL,只有作为业务模块的DLL需要修改。游戏运行时通过这些反射机制加载就实现了热更。

苹果平台对于反射机制有限制,无法实现这样的热更。之所以限制反射,是因为 反射功能太过强大,会给系统带来很大的安全隐患。

ILRuntime(C#热更):

该项目基于C#平台提供了一个纯以实现快速,方便的IL运行时,使得其在不支持JIT的硬件环境(IOS等)能够实现代码的热更。

编译原理:将代码分为两个dll文件,启动时仅启动一个,另一个dll通过反射启动,在修改后使用第二个dll将第一个dll替换掉,达到一个热更效果。

4.热更新的流程

2.1. 导出热更的流程

① 打包热更资源对应的md5信息。

② 上传热更AB包到热更服务器。

③ 上传版本信息到版本服务器。

2.2. 游戏热更流程

① 启动游戏

② 根据当前版本号,和平台号去版本服务器上检查是否有热更。

③ 从热更服务器上下载MD5文件,比对需要热更的具体文件列表。

④ 从热更服务器上下载需要热更的资源,解压到热更新源目录。

⑤ 游戏运行加载资源,优先到热更目录中加载,再到母包资源加载。

猜你喜欢

转载自blog.csdn.net/qq_55071342/article/details/128929642