深度理解值类型与引用类型
值类型和引用类型的区别
通常只写高级语言的同学,只接触过引用与值类型的概念,只知道传递的参数如果是值类型,那么在调用的方法中去改变参数,其改变并不会作用到原变量。如果调用方法时,传递的参数是一个对象类型,其改变会作用所引用的对象上。
注意:我这里说的是改变会作用到所引用的对象上,而不是变量,这是有原因的,请继续往下看
因为我不想让整篇博客看起来很难理解,我不想牵涉到太多内存区的知识,我这里做统一表示:
- 操作区:这是我假设的,用来做逻辑处理
- 栈区:可通过变量直接操作的内存区(我们直接操作的变量大多时候就是存在栈中)
- 堆区:一块自由的内存区,通常我们使用栈区变量来引用堆区内存块
值类型
我们先来看下值类型,这里以int类型为例:
int i = 521;
那么可以得出i本身的值是521,理解这些并没有什么难度,我们再看一下值类型的变量赋值操作:
int i = 521;
int j = i + 1;
这里赋值包含了以下几步操作(注意:每种编程语言设计都有所不同,但其大致思路都是一样的):
1、先将i变量初始化为521(521为常量值,常量值可以直接赋值给变量):
2、j被初始化,但是初始化的时候用到了i变量,所以我们要先取到i的值才行(注意j在声明时已经表示自己是一个int类型,所以这里直接分配合适的内存大小即可):
3、找到i的值,并进行i + 1的操作:
(这里的操作区是我假设的,因为每种语言的处理机制都不一样,我这里就假设我们的逻辑处理都在操作区,这是不希望你们混肴)
3、将运算的值赋值给j:
引用类型
这里我们假设声明了一个Data的类,并创建一个Data类型的实例,Data类中包含两个int类型的基本类型:
class Data
{
int i;
int j;
}
Data data = new Data();
来看看完成这一操作之后的内存情况(稍后给出分解步骤):
这里给出一个理论:对象类型的变量本身都是固定大小。好吧,这听起来很难理解,我们来仔细分析一下。
我们假设有两个类:
class Data
{
int i;
String str;
}
class ListNode
{
int data;
String str;
ListNode next;
}
Data data = new Data();
ListNode list = new ListNode();
这里data和list的内存占用大小是一样!data与list只是普通的引用变量,变量本身的值只是引用对象的内存地址,对象存储在堆中,也就是说data与list中存储的只是堆内存中的内存地址而已。
也就是说data和list存的都是内存地址而已,而内存地址的长度都是固定长度的,所以我们没有必要去对固定长度的变量使用不同长度的内存(这里不做太深的说明,如果有兴趣可以了解一下汇编语言)。
我相信你现在肯定还是一知半解,别着急往下看。
我们来分解一下步骤,看一看编程语言究竟是怎么处理引用类型的:
这里假设我们使用的是32位的计算机,在32位的计算机中,int类型大小为4个字节。
class Data
{
int i;
int j;
}
以上的类声明在不考虑任何语言优化的情况下(不增加额外内存),该类的内存占用应该是8个字节。
1、声明Data类型变量,(此时没有做任何赋值操作,data应该是NULL值):
Data data;
堆栈情况如下(堆中此时是空的,data变量本身也是空值也就是空引用):
2、给data变量创建对象,这里可以直接算出内存大小为8,这里我分解成两个步骤:
-
在堆区划分一块8字节的内存:
-
将分配的内存块的内存起始地址赋值给变量data,这里假设的地址是0xd27038:
操作完成,变量data与堆中的那块内存就建立了联系,但是注意这里并没有直接关系,只是data存储了那块内存的内存地址,我们通过data变量中存储的内存地址可以寻找到那块真正存储数据的内存。
- 初始化操作:假设我们不显式地对变量做初始化操作,编程语言会根据变量的类型,将变量初始化成为编程语言设定的基本类型默认初始值,比如int类型的默认值就是0:
来看一下我们是如何操作对象中的字段的,这里假设我们要将data所引用的对象的i变量值改为521。
我们依旧来分解步骤来观察这一过程,首先赋值语句如下:
data.i = 521;
1、找到data变量,取出其内存地址:
2、通过内存地址进行寻址操作,找到堆中的内存块:
(如果你学过C语言,你应该了解这一步骤是叫解引用。)
3、修改内存块中i的值(我这里忽略了内存大小计算等操作):
注意,这一步骤是直接修改的堆中的内存。
我们来总结一下知识,他们有其相同点:
- 引用的变量与普通变量没什么区别,知识引用变量中存的是内存地址而已
- 我这里虽然没有说,但是你应该能猜到,引用变量中的内存地址是可以被修改的,这与普通变量也是一样的
有其不同点:
- 通常情况下引用变量在堆中有其隐式引用的一块内存,而普通变量并没有
更改类中的字段属性等,其实就是通过引用变量解析地址->寻址->操作。
值类型与引用类型的传递
我看过很多书籍,大多数的书籍都说明并介绍了值传递与引用传递,却很少解释这两者的本质是什么,导致很多初级开发者对其都是一知半解,我很喜欢深入理解C#这本书中的这段内容(虽然这里是C#,但是依旧适用于Java等语言):
书中强调了"不该使用'对象传递'与'引用传递'这种文字来描述这种传递过程",但是我还是要这么做,因为我不想整个问题太绕,以至于你永远搞不明白整个问题。
注意:无论该种做法叫什么都并不重要,看清本质才能让你恍然大悟(我个人认为看清代码的本质才是编程的升华提升,做到人码合一(滑稽))。
值传递
观察以下代码,请说出afterChange的值是多少:
static void Main(String[] args)
{
int sourceVal = 521;
void ChangeVal(sourceVal);
int afterChange = sourceVal;
}
static void ChangeVal(int source)
{
source = 741;
}
你肯定会毫不犹豫地说出是521,可是这究竟是怎么做到的呢,我们不是将sourceVal与source关联起来了吗,我们分步骤来看一下:
1、先给sourceVal做赋值操作
2、调用ChangeVal方法
3、进入ChangeVal方法中
观察到了吗,此时ChangeVal包含一个局部变量source,该值被做了初始化动作,这等同于:
int sourceVal = 521;
int source = sourceVal;
source = 741;
source与sourceVal之间并没有任何关系,唯一有关系的一步,只是source变量使用sourceVal中存储的值来做了初始化。
有些书上把此步骤叫做值复制,把引用变量的复制操作叫做引用复制,这话是没有任何问题的,只是对程序的初学者太不友好了。
在我看来值传递和引用传递并没有任何区别,不信继续往下看,看完之后你也会这么认为:
引用传递
观察以下代码(重点在于afterChange的值是否被改变):
class Program
{
static void Main(String[] args)
{
Data data = new Data();
data.value = 745;
ChangeObject(data);
int afterChange = data.value;
}
static void ChangeObject(Data sourceData)
{
sourceData.value = 2020;
}
}
//这里为了方便理解,我直接使用了公开字段的方式
class Data{
public int value;
}
这个问题其实本质很简单,就是传递了内存地址而已,WTF?什么玩意儿?内存地址?你在说什么?别着急我们将步骤分解一下:
1、声明并创建Data对象:
2、调用ChangeObject方法,传递Data类型参数:
调用方法时,只是把那个Data类型的引用变量中的内存地址复制给了另一个同类型引用变量,这些操作与以下代码并没有区别:
Data data = new Data();
Data sourceData = data;
更改sourceData为什么就能影响到data引用的对象的值呢?看以下步骤:
3、更改sourceData所引用的内存中的value值(详细操作就是上边我说过的引用类型):
这里一共有几个步骤:
- 取出sourceData中存储的内存地址
- 解析内存地址,找到堆中的那块内存
- 更改内存块中value字段内存块中的值为2020
完成之上步骤,堆栈情况如下:
4、方法执行完毕,方法对应区域被销毁:
5、取出data引用的内存区中对应的value值,并赋值给afterChange变量(详细操作请看上边的引用类型那一段):
步骤:
- 拿到data中存储的内存地址:0xd27038
- 去堆中找到内存起始地址为0xd27038的那块内存,找到value字段对应的内存区域,取出该区域中存储的值(此时值是2020)
- 将值赋值给afterChange变量
结论
我认为看到这些结论你会说出: that's easy. 因为你已经看透了值与引用的本质。
无论是值类型还是对象类型,他们在传递的时候都是把传递的变量中的值复制了一份
,如果是普通变量拿到的就是普通的值,如果是对象类型,拿到的就是变量中存储的地址。
因为传递过来的参数与复制的参数都引用了同一个地址,所以复制的那个参数对对应的内存区域做出更改后,所有引用了该地址的应用变量都会取到更改后的值。
相信你肯定遇到过以下的异常:NullPointerException或者NullReferenceException......,我相信你已经猜到了是什么原因,就是因为那个引用变量中存储的地址是个空值(空值的概念其实并不简单,这很复杂,这里就不提及了)。空引用表示在堆中没有任何一块区域和该引用变量有引用关系,所以当我们去修改其引用的值时,会抛出空指针异常。
即便是我很认真地写这些内容,但仍不能避免博客中可能会出现错误,如果发现该篇博客中有错误,请私信我(๑•̀ㅂ•́)و✧