深度理解值类型与引用类型

值类型和引用类型的区别

通常只写高级语言的同学,只接触过引用与值类型的概念,只知道传递的参数如果是值类型,那么在调用的方法中去改变参数,其改变并不会作用到原变量。如果调用方法时,传递的参数是一个对象类型,其改变会作用所引用的对象上。

注意:我这里说的是改变会作用到所引用的对象上,而不是变量,这是有原因的,请继续往下看

因为我不想让整篇博客看起来很难理解,我不想牵涉到太多内存区的知识,我这里做统一表示:

  • 操作区:这是我假设的,用来做逻辑处理
  • 栈区:可通过变量直接操作的内存区(我们直接操作的变量大多时候就是存在栈中)
  • 堆区:一块自由的内存区,通常我们使用栈区变量来引用堆区内存块

值类型

我们先来看下值类型,这里以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......,我相信你已经猜到了是什么原因,就是因为那个引用变量中存储的地址是个空值(空值的概念其实并不简单,这很复杂,这里就不提及了)。空引用表示在堆中没有任何一块区域和该引用变量有引用关系,所以当我们去修改其引用的值时,会抛出空指针异常。

即便是我很认真地写这些内容,但仍不能避免博客中可能会出现错误,如果发现该篇博客中有错误,请私信我(๑•̀ㅂ•́)و✧

posted @ 2020-12-08 11:46  Erosion2020  阅读(447)  评论(0)    收藏  举报