OOP部分面试题的前世今生

发布时间 2023-11-29 10:41:08作者: 木乃伊人

一、从变量声明开始

       在.NET程序中定义一个变量时,会在RAM(随机存取存储器)中为其分配一些内存块。该内存块有3样东西:名称,数据类型、值。

   变量究竟会被分配到那种类型的内存,取决于变量的数据类型。在.NET中有两种可分配的内存:

   为了便于理解,用以下代码来说明:

public void Method1()
{
    int i=4;// 第一行 
    Class1 cls1 = new Class1();//第二行
}
  1. 第一行:改行代码执行后,编译器会在栈上分配一小块内存。栈会跟踪应用程序中是否有运行内存的需要
  2. 第二行:穿件一个对象cls1.被执行后,.NET会在栈中创建一个指针,但实际对象会被储存到堆的内存区域中。堆不会监测运行内存,它只是能够被随时访问到一堆对象而已。不同于栈,堆用于动态内存分配。
  3. 注意:对象的引用指针是分配在栈上的。
  4. 例如:声明语句 Class1 cls1; 其实并没有为Class1的实例分配内存,它只是在栈上为变量cls1创建了一个引用指针(并且将其默认置为null)。只有当其遇到new关键字时,它才会在堆上为对象分配内存。
  5. 当执行完毕方法Method1(),所有在栈上为变量所分配的内存空间都会被清除。
  6. 注意:执行完方法体时,并不会释放堆中的内存块,堆中的内存块是由GC回收清理。

二、栈与堆的区别

       不同点:

                   1、栈由系统自己分配,速度快;存储地址连续且有容量限制,会出现溢出。

                   2、堆按需申请,手动分配,需要用户手动回收,速度比栈慢;平存储地址通常为链式,内存较大不会溢出。

       说明:

                 栈:栈是程序运行时自动拥有的一小块内存,用于局部变量的存放或者函数调用栈的保存。

①在 C 中如果声明一个局部变量(例如 int a),它存放的地方就在栈中,而当这个局部变量离开其作用域之后,所占用的内存则会被自动释放,因此在 C 中局部变量也叫自动变量
②栈的另一个作用则是保存函数调用栈,这时和数据结构的栈就有关系了。
在函数调用过程中,常常会多层甚至递归调用。每一个函数调用都有各自的局部变量值和返回值,每一次函数调用其实是先将当前函数的状态压栈,然后在栈顶开辟新空间用于保存新的函数状态,接下来才是函数执行。当函数执行完毕之后,栈先进后出的特性使得后调用的函数先返回,这样可以保证返回值的有序传递,也保证函数现场可以按顺序恢复。
操作系统的栈在内存中高地址向低地址增长,也即低地址为栈顶,高地址为栈底。这就导致了栈的空间有限制,一旦局部变量申请过多(例如开个超大数组),或者函数调用太深(例如递归太多次),那么就会导致栈溢出(Stack Overflow),操作系统这时候就会直接把你的程序杀掉。

堆:对于面向对象程序来说,new出来的任何对象,无论是对象内部的成员变量,局部变量,类变量,他们指向的对象都存储在堆内存中(但指针本身存在栈中)。
       比如 C 中的 malloc 函数和 C++ 中的 new 操作。在程序结束之前,操作系统不会删除已经申请的内存,而是要靠程序主动提出释放的请求(free、delete),如果使用后忘记释放,就会造成所谓的内存泄漏问题。

三、 值类型和引用类型

         值类型将数据和内存都保存在同一位置,而引用类型则会有一个指向实际内存区域的指针。

        【值类型】:当我们将一个int类型的值赋值到另一个int类型的值时,它实际上是创建了一个完全不同的副本。换句话说,如果你改变了其中某一个的值,另一个不会发生改变。C#的所有值类型均隐式派生自System.ValueType。

                          值类型:结构体(数值类型、bool型、用户定义的struct),enum,可空类型等。

        【引用类型】:当我们创建一个对象并且将此对象赋值给另外一个对象时,他们彼此都指向了内存中同一块区域。因此,当我们将obj赋值给obj1时,他们都指向了堆中的同一块区域。换句话说,如果此时我们改变了其中任何一个,另一个都会受到影响。

                         引用类型:数组,class、interface、delegate,object,string。

四、Struct和Class的区别

  1. struct 是值类型,class 是引用类型  ;
  2. struct 不能被继承,class 可以被继承;
  3. struct 默认的访问权限是public,而class 默认的访问权限是private  ;
  4.  struct总是有默认的构造函数,即使是重载默认构造函数仍然会保留。这是因为struct的构造函数是由编译器自动生成的,但是如果重载构造函数,必需对struct中的变量全部初始化。并且struct的用途是那些描述轻量级的对象,例如Line,Point等,并且效率比较高。class在没有重载构造函数时有默认的无参数构造函数,但是一被重载,默认构造函数将被覆盖;
  5.  struct的new和class的new是不同的。struct的new就是执行一下构造函数创建一个新实例再对所有的字段进行Copy。而class则是在堆上分配一块内存然后再执行构造函数,struct的内存并不是在new的时候分配的,而是在定义的时候分配。

      小结:如果类型的职责主要是存储数据,值类型比较合适,否则使用引用类型会导致内存分配花费更多时间,导致更多内存碎片

五、如果不停Update实例化一个类和结构体会发生什么

      类:引用类型,会不断申请堆内存,导致内存碎片不断增多,频繁触发GC,造成掉帧,发热等问题。

     结构体:值类型,程序离开Update方法,栈上变量分配空间都会被清除。

六、如何确定数据分配在栈还是堆上?值类型一定分配在栈上?

  1.  如果声明在函数的局部变量,就分配到栈中;
  2. 如果声明在一个类中,就分配在堆内存中;
  3. 非空引用类型对象所有装箱值类型对象总是分配在堆内存上;

七、装箱与拆箱

       当数据从值类型转换为引用类型的过程被称为“装箱”,而从引用类型转换为值类型的过程则被成为“拆箱”。

八、装箱和拆箱的性能问题

      装箱和拆箱会导致性能下降,应该避免。   

     装箱和拆箱注意点:

  1. 如果结构体实现了某个接口,那么结构体转换为接口就会装箱;
  2.  对值类型实例调用GetType()会发生装箱;
  3. 对结构体调用ToString()、GetHashCode():在Mono中,直接调用不会发生装箱,但是在IL2CPP中却会有装箱。

九、如何估算对象和结构体大小

       结构体在内存中所占大小,其实就是字段所占大小,成员按照定义时的顺序依次存储在连续的内存空间。
      结构体的大小不是所有成员大小简单的相加,需要考虑到系统在存储结构体变量时的地址对齐问题。
      这个点在面试中偶尔会被问到。比如byte是按1字节对齐的,int是按4字节对齐的。

struct S{
    byte b1; //这个结构体大小是1
} 
struct S{
    byte b1; 
    int i1;// 如果在上面基础上加个int字段,那么这个结构体大小就是8,因为int是4字节对齐的
} 
struct S{
    byte b1; 
    int i1;  
    byte b2;// 再加个byte,那么这个结构体大小是12,按元素最大的对齐规则对齐
} 
struct S{
    byte b1; 
    byte b2; // 但是如果调整顺序,这个结构体大小是8,因为按4对齐,前4个自己可以存下2个byte
    int i1; 
} 

所以在实际编码中,我们在定义结构体字段的时候要注意这个内存对齐规则,通过调整字段顺序,优化对象和结构体大小,减少堆内存占用

常用数据类型在32位编译器架构下内存占用对应字节数(可用如sizeof(char),sizeof(char*)等得出)

  • sizeof(byte)1
  • sizeof(short)2
  • sizeof(ushort)2
  • sizeof(int)4
  • sizeof(uint)4
  • sizeof(long)8
  • sizeof(ulong)8
  • sizeof(char)2
  • sizeof(float)4
  • sizeof(double)8
  • sizeof(decimal)16
  • sizeof(bool)1