DotNet面试题解析01-值类型与引用类型

基本概念

CLR 支持两种类型:引用类型值类型。这是 .NET 语言的基础和关键,他们从类型定义、实例创建、参数传递,到内存分配都有所不同。

typesystem.gif

下图清晰了展示了 .NET 中类型分类,值类型主要是一些简单的、基础的数据类型,引用类型主要用于更丰富的、复杂的、复合的数据类型。

2009020510331710.jpg

使用值类型最佳实践

  • 对象很小,并且打算创建很多这样的对象
  • 需要高密度的内存集合
  • 在值类型中重写 Equals ,重载 Equals ,实现 IEquatable<T> ,以及重载操作符==和!=。
  • 在值类型中重写 GetHashCode
  • 考虑将值类型实现为不可变的。

为什么要为值类型重定义相等性

原因主要有以下几点:

  • 值类型默认无法使用 == 操作符,除非对它进行重写 ( System.ValueType 里面对 object.Equals() 方法的重写)
  • 性能原因,因为值类型默认的相等性比较会使用装箱和反射,所以性能很差
  • 根据业务需求,其实际相等性的意义和默认的比较结果可能会不同,但是这种情况可能不较少

986268-20190413072415659-1667812156.png

值类型和 string 类型除外,因为所有值类型继承于 System.ValueType() ( System.ValueType() 同样继承于Object,但是 System.ValueType() 本身却是引用类型),而 System.ValueType()Equals() 和==操作符进行了重写,是逐字节比较的。而 string 类型是比较特殊的引用类型,所以 string 在很多地方都是特殊处理的。

在值类型使用 Equals() 时,因为 Equals() 使用了反射,在比较时会影响效率。

注意:

如果要把引用类型做为 DictionaryHashTablekey 使用时,必须重写这两个方法。

原因:当我们把引用类型( string 除外)做为 DictionaryHashTable 的key时,有可能永远无法根据 Key 获得 value 的值,或者说两个类型的 HashCode 永远不会相等。

Dictionary 来说,虽然我们存储的时候是键值对,但是 CLR 会先把 key 转成 HashCode 并且验证 Equals 后再做存储,根据 key 取值的时候也是把 key 转换成 HashCode 并且验证 Equals 后再取值。

注意验证时 HashCodeEquals 的关系是并且( && )的关系。也就是说,只要 GetHashCodeEqulas 中有一个方法没有重写,在验证时没有重写的那个方法会调用基类的默认实现,而这两个方法的默认实现都是根据内存地址判断的,也就是说,只重写一个方法的返回值永远会是 false

参考:

聊一聊C#的Equals()和GetHashCode()方法

实现步骤

  • 重写 object.Equals() 方法
  • 实现 IEquatable<T>.Equals() 接口方法
  • 重写 == 和 != 操作符
  • 重写 object.GetHashCode()

参考:

C# 为值类型重定义相等性

如何重写GetHashCode方法

ObjectGetHasCode 的算法保证了同一对象返回同一 HashCode,而不同的对象返回不同的 HashCode,但对于值类型等,视内容相等的对象为相等对象的类型时,默认的 GetHashCode 算法并不正确。重写后的 GetHashCode 必须要保证同一对象无论何时都返回同一 HashCode 值,而相等的对象也必须返回相同的值。并且在此基础上,保证 HashCode 尽量随机地散列分布。

引用类型

标准配置,引用对象的额外空间:对象头字节(包括同步块索引)TypeHandle(或方法表指针,类型对象指针)

方法表指针指向一个 CLR 内部数据结构–方法表( MT )。方法表指向另一个内部数据结构—EEClass (EE代表Execution Engine,执行引擎)。方法表和 EEClass 包含一些信息,可用来分发虚方法和接口方法调用,访问静态变量,确定对象的运行时类型,高效访问基类型方法等。

内存结构

值类型引用类型 最根源的区别就是其内存分配的差异,在这之前首先要理解 CLR 的内存中两个重要的概念:

Stack 栈:线程栈,由操作系统管理,存放值类型、引用类型变量(就是引用对象在托管堆上的地址)。栈是基于线程的,也就是说一个线程会包含一个线程栈,线程栈中的值类型在对象作用域结束后会被清理,效率很高。

GC Heap托管堆:进程初始化后在进程地址空间上划分的内存空间,存储 .NET 运行过程中的对象,所有的引用类型都分配在托管堆上,托管堆上分配的对象是由 GC 来管理和释放的。托管堆是基于进程的,当然托管堆内部还有其他更为复杂的结构。

结合下图理解,变量a及其值3都是存储在栈上面。变量b在栈上存储,其值指向字符串“123”的托管堆对象地址(字符串是引用类型,字符串对象是存储在托管堆上面。字符串是一个特殊的引用类型)

151257-20160301092426314-1061559039.png

值类型一直都存储在栈上面吗?所有的引用类型都存储在托管堆上面吗?

  • 1.单独的值类型变量,如局部值类型变量都是存储在栈上面的;
  • 2.当值类型是自定义 class 的一个字段、属性时,它随引用类型存储在托管堆上,此时她是引用类型的一部分;
  • 4.所有的引用类型肯定都是存放在托管堆上的。
  • 5.结构体(值类型)中定义引用类型字段,结构体是存储在栈上,其引用变量字段只存储内存地址,指向堆中的引用实例。

对象的传递

将值类型的变量赋值给另一个变量(或者作为参数传递),会执行一次值复制。
将引用类型的变量赋值给另一个引用类型的变量,它复制的值是引用对象的内存地址,因此赋值后就会多个变量指向同一个引用对象实例。

理解这一点非常重要,下面代码测试验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
int v1 = 0;
int v2 = v1;
v2 = 100;
Console.WriteLine("v1=" + v1); //输出:v1=0
Console.WriteLine("v2=" + v2); //输出:v2=100

User u1=new User();
u1.Age = 0;
User u2 = u1;
u2.Age = 100;
Console.WriteLine("u1.Age=" + u1.Age); //输出:u1.Age=100
Console.WriteLine("u2.Age=" + u2.Age); //输出:u2.Age=100,因为u1/u2指向同一个对象

当把对象作为参数传递的时候,效果同上面一样,他们都称为按值传递,但因为值类型和引用类型的区别,导致其产生的效果也不同。

参数-按值传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void DoTest(int a)
{
a *= 2;
}

private void DoUserTest(User user)
{
user.Age *= 2;
}

[NUnit.Framework.Test]
public void DoParaTest()
{
int a = 10;
DoTest(a);
Console.WriteLine("a=" + a); //输出:a=10
User user = new User();
user.Age = 10;
DoUserTest(user);
Console.WriteLine("user.Age=" + user.Age); //输出:user.Age=20
}

上面的代码示例,两个方法的参数,都是按值传递

  • 对于值类型( int a) :传递的是变量a的值拷贝副本,因此原本的a值并没有改变。
  • 对于引用类型( User user) :传递的是变量 user 的引用地址( User 对象实例的内存地址)拷贝副本,因此他们操作都是同一个 User 对象实例。

参数-按引用传递:

按引用传递的两个主要关键字:out 和 ref不管值类型还是引用类型,按引用传递的效果是一样的,都不传递值副本,而是引用的引用(类似c++的指针的指针)。out 和 ref告诉编译器方法传递额是参数地址,而不是参数本身,理解这一点很重要。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void DoTest(ref int a)
{
a *= 2;
}

private void DoUserTest(ref User user)
{
user.Age *= 2;
}

public void DoParaTest()
{
int a = 10;
DoTest(ref a);
Console.WriteLine("a=" + a); //输出:a=20 ,a的值改变了
User user = new User();
user.Age = 10;
DoUserTest(ref user);
Console.WriteLine("user.Age=" + user.Age); //输出:user.Age=20
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int m = 0;
Console.WriteLine(RefValue(1, ref m)); //输出 1
Console.WriteLine(m); //输出 222

int n;
Console.WriteLine(OutValue(1, out n)); //输出 223
Console.WriteLine(n); //输出 222

static int RefValue(int i, ref int j)
{
int k = j;
j = 222;
return i + k;
}

static int OutValue(int i, out int j)
{
j = 222;
return i + j;
}

outref的主要异同:

  • outref 都指示编译器传递参数地址,在行为上是相同的;
  • 他们的使用机制稍有不同,ref 必须要求参数在使用之前要显式初始化,out 必须要在方法内部初始化;
  • 使用 ref 和 out 时,在方法的参数和执行方法时,都要加Ref或Out关键字,以满足匹配;
  • out 适合用在需要 retrun 多个返回值的地方,而 ref 则用在需要被调用的方法修改调用者的引用的时候。
  • out 和 ref 不可以重载,就是不能定义 Method(ref int a)Method(out int a) 这样的重载,从编译角度看,二者的实质是相同的,只是使用时有区别;
  • ref 是有进有出,而 out 是只出不进。
注:在C#中,方法的参数传递有四种类型:传值(by value),传址(by reference),输出参数(by output),数组参数(by array)。传值参数无需额外的修饰符,传址参数需要修饰符ref,输出参数需要修饰符out,数组参数需要修饰符params。

常见问题

值类型和引用类型区别.jpg

题目答案解析

1. 值类型和引用类型的区别?

值类型包括简单类型、结构体类型和枚举类型,引用类型包括自定义类、数组、接口、委托等。

  • 1、赋值方式:将一个值类型变量赋给另一个值类型变量时,将复制包含的值。这与引用类型变量的赋值不同,引用类型变量的赋值只复制对象的引用(即内存地址,类似 C++ 中的指针),而不复制对象本身。
  • 2、继承:值类型不可能派生出新的类型,所有的值类型均隐式派生自 System.ValueType。但与引用类型相同的是,结构也可以实现接口。
  • 3、null:与引用类型不同,值类型不可能包含 null 值。然而,可空类型允许将 null 赋给值类型(他其实只是一种语法形式,在 clr 底层做了特殊处理)。
  • 4、每种值类型均有一个隐式的默认构造函数来初始化该类型的默认值,值类型初始会默认为0,引用类型默认为null。
  • 5、值类型存储在栈中,引用类型存储在托管堆中。

2. 结构和类的区别?

结构体是值类型,类是引用类型,主要区别如题1。其他的区别:

  • 结构不支持无惨构造函数,不支持析构函数,并且不能有 protected 修饰;
  • 结构常用于数据存储,类 class 多用于行为;
  • class 需要用 new 关键字实例化对象,struct 可以不适用 new 关键字;
  • class 可以为抽象类,struct 不支持抽象;

3. delegate是引用类型还是值类型?enum、int[]和string呢?

enum 枚举是值类型,其他都是引用类型。

4. 堆和栈的区别?

线程堆栈:简称栈 Stack
托管堆: 简称堆 Heap

  • 值类型大多分配在栈上,引用类型都分配在堆上;
  • 栈由操作系统管理,栈上的变量在其作用域完成后就被释放,效率较高,但空间有限。堆受 CLR 的 GC 控制;
  • 栈是基于线程的,每个线程都有自己的线程栈,初始大小为1M。堆是基于进程的,一个进程分配一个堆,堆的大小由 GC 根据运行情况动态控制;

5.“结构”对象可能分配在堆上吗?什么情况下会发生,有什么需要注意的吗?

结构是值类型,有两种情况会分配在对上面:

  • 结构作为class的一个字段或属性,会随class一起分配在堆上面;
  • 装箱后会在堆中存储,尽量避免值类型的装箱,值类型的拆箱和装箱都有性能损失;

6. 理解参数按值传递?以及按引用传递?

  • 按值传递:对于值类型传递的它的值拷贝副本,而引用类型传递的是引用变量的内存地址,他们还是指向的同一个对象。
  • 按引用传递:通过关键字 out 和 ref 传递参数的内存地址,值类型和引用类型的效果是相同的。

7. out 和 ref的区别与相同点?

  • out 和 ref 都指示编译器传递参数地址,在行为上是相同的;
  • 他们的使用机制稍有不同,ref 要求参数在使用之前要显式初始化,out 要在方法内部初始化;
  • out 和 ref 不可以重载,就是不能定义 Method(ref int a) 和 Method(out int a) 这样的重载,从编译角度看,二者的实质是相同的,只是使用时有区别;

8. C#支持哪几个预定义的值类型?C#支持哪些预定义的引用类型?

值类型:整数、浮点数、字符、bool 和 decimal

引用类型:Object,String

9. 有几种方法可以判定值类型和引用类型?

简单来说,继承自 System.ValueType 的是值类型,反之是引用类型。

10. 说说值类型和引用类型的生命周期?

值类型在作用域结束后释放。

引用类型由 GC 垃圾回收期回收。

11. 如果结构体中定义引用类型,对象在内存中是如何存储的?例如下面结构体中的class类 User对象是存储在栈上,还是堆上?

1
2
3
4
5
public struct MyStruct
{
public int Index;
public User User;
}

MyStruct 存储在栈中,其字段 User 的实例存储在堆中,MyStruct.User 字段存储指向 User 对象的内存地址。

参考:

.NET面试题解析(01)-值类型与引用类型

2md

C#基础:ref和out的区别