数组与内存分配

  • 2019 年 12 月 26 日
  • 筆記

前言

数组是编程语言中最常见的数据结构,可用于存储多个数据,每个数组元素存放一个数据,通常可通过数组元素的索引来访问数组元素,包括为数组元素赋值和取出数组元素的值。Java数组并不是什么很难的知识,如果单从语法角度来看,数组的用法并不不难,只是对Java数组的内存分配可能不是很明白,《Java雇佣兵之数组与内存分配》系列尝试探讨从基础到内存分配的两大方面全面介绍Java的数组知识。

一、数组入门

1、首先明确一点:数组也是一种类型

Java数组要求所有的数组元素具有相同的数据类型。因此,在一个数组中,数组元素的类型是唯一的,即要求数组里只能存储一种类型的数据,而不能存储多种数据类型的数据。

一旦数组的初始化完成,数组在内存中所占的空间将被固定下来,因此数组的长度将不可改变。即使把某个数组元素的数据清空,但它所占的空间依然被保留下来,依然属于该数组,数组的长度依然不变。Java数组既可以存储基本数据类型数据,也可以存储引用类型数据,只要所有的数组元素具有相同的类型即可。

值得指出的是,数组也是一种数据类型,它本身是一种引用类型。例如int是一个基本类型,但int[]就是一种引用类型了。

2、定义数组

关于定义数组的语法 Java语言支持两种语法格式来定义数组:

type[] arrayName;  type arrayName[];  

对于这种两种语法格式而言,通常推荐使用第一种格式。因为第一种格式不仅具有更好的语意,而且具有更好的可读性。对于type[] arrayName;方式,很容易理解这是定义一个变量,其中变量名是arrayName,而变量类型是type[]type[]是一种新类型,与type完全不同,例如,int类型是几本类型,但int[]是引用类型,因此,这种方式既容易理解也符合定义变量的语法。但这第二种个事故type arrayname[]的可读性就差了,看起来好像定义了一个类型为type的变量,而变量名是arrayName[],这与真实的含义相去甚远。Java的这个设计就很糟糕,语法就像数学上的公理和定义,不要让语法产生歧义。

定义数组时不能指定数组长度?(是的,为什么?) 数组是一种引用类型的变量,因此使用它定义一个变量时,仅仅表示定义了一个引用变量(可以认为是指针),这个引用变量还未指向任何有效的内存,因此定义数组时不能指定数组长度。而且由于定义数组只是定义了一个引用变量,并未指向任何有效的内存空间,所以还没有内存空间来存储数组元素,因此这个数组也不能使用,只有对数组进行初始化后才能使用。说白了,就是还没有内存空间,所以你还什么都干不了。

3、数组的初始化

Java语言中数组必须先初始化,然后才可以使用。所谓初始化,就是为数组的数组元素分配内存空间,并为每个数组元素赋初始值。

能不能只分配内存空间而不赋予初始值呢? 不能。一旦为数组的每个数组元素分配了内存空间,每个内存空间里存储的内容就是该数组元素的值,即使这个内存空间存储的内容是空,这个空也是一个值(null)。不管以哪种方式来初始化数组,只要为数组元素分配了内存空间,数组元素就具有了初始值。初始值的获得有两种方式:一种由系统自动分配,另一种由程序员指定。

数组的初始化的两种方式

  • 静态初始化:初始化时有程序员显示指定每个数组元素的初始值,由系统决定数组的长度。
  • 动态初始化:初始化时程序员只指定数组长度,由系统分配数组元素的初始值。

静态初始化 语法格式如下:

arrayName = new type[]{element1,element2,element3,element4,....}  

在上面的语法格式中,前面的type就是数组元素的数据类型,此处的type必须与定义数组变量时所使用的type相同,也可以是定义数组时所指定的type的子类。下面举个例子:

//定义一个int数组类型的变量,变量名为intArr  int[] intArr;  //使用静态初始化,初始化时只只指定数组元素的初始值,不指定数组长度  intArr = new int[]{1,2,3,4,5}  //定义一个object数组类的变量,变量名为objArr  Object[] objArr;  //使用静态初始化。初始化数组元素的类型是定义数组时数组元素的子类。  objArr = new String[]{"Java","C#"}  Object objArr2;  objArr2 = new Object[]{"Java","C#"}

因为Java语言是面向对象的编程语言,能很好的支持子类和父类的继承关系:子类实例是一种特殊的父类实例。在上面程序中,String类型是Object类型的子类,即字符串是一种特殊的Object实例。

除此之外,静态初始化还有如下简化的语法格式:

arryName = {element1,element2,element3,...}

在这种语法格式中,直接使用花括号来定义一个数组,在实际开发中,可能更习惯使用这种方式来定义和初始化数组,上面的代码也可以如下,等价:

int[] intArr = {1,2,3,4,5}

动态初始化 动态初始化只指定数组的长度,由系统为每个数组元素指定初始值。动态初始化的语法格式如下:

  arryName = new type[length];

在上面语法中,需要指定一个int类型的length参数,这个参数之地呢了数组的长度,也即是可以容纳的数组元素的个数。与静态初始化相似的是,此处的type必须与定义数组时使用的type类型要相同,或者是定义数组时使用的type类型的子类。下面代码演示了动态初始化:

int[] prices = new int[5];  Object[] books = new String[4];

指向初始化时,程序员只需要指定数组的长度,即为每个数组元素指定所需要的内存空间,系统将负责为这些数组元素分配初始值。那么系统时按什么规则来分配初始值的呢?规则如下: a. 数组元素的类型是基本类型中的整数类型时(byte、short、int和long),则数组元素的值是0; b. 数组元素的类型是基本类型中的浮点类型(float、double),则数组元素的值是0.0; c. 数组元素的类型是基本类型中的字符类型(char),则数组元素的值是u0000; d. 数组元素的类型是基本类型中的布尔类型(boolean),则数组元素的值是false; e. 数组元素的类型是引用类型(类、接口和数组),则数组元素的值是null

4、数组一定要初始化吗?

从前面我们已经知道,使用java数组之前必须先初始化数组,也就是位数组元素分配空间,并指定初始值。实际上,如果真正掌握了Java数组在内存中的分配机制,那么完全可以换一个方式来 初始化数组,或者说,数组无须经过初始化。

始终记住:java的数组变量是引用类型的变量,他并不是数组对象本身,只要让数组变量指向有效的数组对象,程序中即可使用该数组变量,示例如下:

public static void main(String[] args) {  	//定义并初始化nums数组  	int[] nums = new int[]{3, 5, 20, 12};  	//定义一个prices数组变量  	int[] prices;  	//让prices数组指向nums所引用的数组  	prices = nums;  	for(int i = 0 ; i < prices.length ; i++ ){  		System.out.println(prices[i]);  	}    	//将prices数组的第三个元素赋值为34  	prices[2] = 34;  	//访问nums数组的第3个元素,将看到输出34  	System.out.println("nums数组的第3元素的值是:" + nums[2]);  }

从上面粗体字可以看出,程序定义了prices数组之后,并对prices数组进行初始化。当执行int[] prices之后,程序在内存中的分配如下图:

当程序执行prices = nums;之后,prices变量将指向nums变量所引用的数组,此时prices变量和nums变量引用同一个数组对象。执行这条语句之后,prices变量已经指向有效的内存及一个长度为4的数组对象,因此程序完全可以正常使用prices变量了。

常常说使用Java数组之前必须先初始化,可是现在prices变量却无须初始化,这不是互相矛盾吗?其实一点都不矛盾。关键是大部分的时候,我们把数组变量和数组对象搞混了,数组变量只是一个引用变量(有点类似于C语言里的指针),通常存放在栈内存中(也可被放入堆内存中);而数组对象就是保存在对内存中的连续内存空间。对数组执行初始化,其实并不是对数组变量执行初始化,而是要对数组对象执行初始化——也就是位该数组对象分配一块内存空间,这块连续的内存空间的长度就是数组长度。虽然上面程序中的prices变量看似没有经过初始化,但执行prices= nums;就会让prices变量直接指向一个已经执行初始化的数组。

二、内存中的数组

数组引用变量只是一个引用,这个引用变可以指向任何有效的内存,只有当该引用指向有效的内存后,才可通过该数组变量来访问数组元素。引用变量时访问真实对象的根本方式。也就是说,如果我们希望在程序中访问数组对象本身,则只能通过这个数组的引用变量来访问。

实际的数组对象被存储在堆(heap)内存中,如果引用该数组对象的数组引用变量是一个局部变量,那么它被存储在栈(stack)内存中。数组在内存中的存储示意图如下1:

图1:数组在内存中的存储示意图

如果需要访问图1所示堆内存中的数组元素,则程序中只能通过p[index]的形式实现。也就是说,数组引用变量时访问堆内存中数组元素的根本方式。

如果对内存中数组不在有任何引用变量指向自己,则这个数组将成为垃圾,该数组所占的内存将会被系统垃圾回收几只回收。因此,为了让垃圾回收机制回收一个数组所占用的内存空间,可以将该数组变量赋值为null,也就是切断了数组引用变量和实际数组之间的引用关系,实际的数组也就成为了垃圾。

只要类型相互兼容,就可以让一个数组变量指向另一个实际的数组,这种操作会让人产生数组的长度可变的错觉。如下面代码:

//定义并初始化一个数组,使用静态初始化  int[] a = [5,7,20];  //定义并初始化一个数组,使用动态初始化  int[] b = new int[4];  //输出b数组的长度  System.out.println("b数组的长度:" + b.length);  //循环输出a数组的元素  for(int i=0; i<a.length; i++){      System.out.println(a[i]);  }  //循环输出b数组的元素  for(int i=0; i<b.length; i++){      System.out.println(b[i]);  }  //因为a是int类型,b也是,所以可以将a的值赋给b。  //也就是让b引用指向a引用指向的数组  b = a;  //在此输出b数组的长度  System.out.println("b数组的长度为:" + b.length);  

输出结果:

看起来似乎数组的长度是可变的,但这只是一个假象。我们必须牢记:定义并初始化一个数组后,在内存中分配了两个空间,一个用于存放数组的引用变量,另一个用于存放数组本身。下面结合图像来说明上面程序都的运行过程。

当程序定义并初始化了两个a、b数组后,系统内存中实际产生了4块内存区域,其中栈内存中有两个引用变量:a和b,堆内存中也有两块内存区,分别用于存储a和b引用所指向的数组本身。此时计算机内存的存储示意图如下图:

图2:定义并初始化a、b两个数组的存储示意图

从图2中可以看出a和b引用个字所引用的数组对象,并可以很清楚的看出a变量所引用的数组长度是3,b变量所引用的数组长度是4。

当执行b=a时,系统将会把a的赋值给b,a和b都是引用类型变量,存储的是地址。因此把a的值赋给b后,就是让b指向a所指向的地址。此时计算机内存的存储示意图如下图:

图3:让b引用指向a引用所指向的数组后的存储示意图

从图3可以看出,当执行了b=a之后,堆内存中的第一个数组具有了两个引用:a变量和b变量都引用了第一个数组。此时第二个数组就失去了引用,就变成了垃圾了,只有等待垃圾回收机制来回收它——但它的长度依然保持不变,直到它彻底消失。

1、基本类型数组的初始化

对于基本数组类型而言,数组元素的值直接存储在对应数组元素中,因此,初始化数组时,先为该数组分配内存空间,然后直接将数组元素的值存入对应数组中。

下面程序定义了一个int[]类型的数组变量,采用动态初始化方式初始化了该数组,并显示的为每个数组元素赋值:

public static void main(String[] args){       int[] iArr;       iArr = new int[5];       for(int i = 0 ; i < iArr.length ; i ++){  	     iArr[i] = i + 10;       }  }

上面代码的执行过程代表了基本类型数组初始化的典型过程。下面将集合示意图详细介绍这段代码的执行过程。 执行第一行代码int[] iArr;时,仅定义一个数组变量,此时内存中的存储示意图如图4:

图4:定义iArr数组变量后的存储示意图

执行了int[] iArr;后,仅在内存中定义了一个空引用(就是iArr数组变量),这个引用濒危指向任何有效的内存,淡然无法指定数组的长度。

当执行iArr = new int[5];动态初始化后,系统将负责为该数组分配内存空间,并分配默认的初始值;所有数组元素都被赋值为0,此时内存中的存储示意图如图5所示:

图5:动态初始化iArr数组后的存储示意图

此时iArr数组的每个元素的值都是0,当循环为该数组的每个元素一次赋值后,此时每个数组元素的值都变成程序显示指定的值。显示指定每个数组元素值后的存储示意图如下:

图6:显示指定每个数组元素值后的存储示意图

从上图中可以看到基本类型数组的存储示意图,每个数组元素的值直接存储在对应的内存中。操作基本类型数组的数组元素是,实际上就是操作基本类型的变量。

2、引用类型数组的初始化

引用类型数组的数组元素是引用,因此情况变得更为复杂。每个数组元素里存储的还是一个引用,它指向另一块内存,这块内存里存储了有效的数据。

为了更好的说明引用类型数组的运行过程,下面先定义一个Person类(所有类都是引用类型):

public class Person{  	//年龄  	public int age;  	//身高  	public double height;  	//定义一个info方法  	public void info(){  		System.out.println("我的年龄是:"+age +",我的身高是:"+height);  	}  }

下面程序定义一个Person[]数组,接着动态初始化这个数组,并为每个数组元素指定值:

public class ArrayTest {  	public static void main(String[] args) {  		//定义一个students数组变量,其类型是Person[]  		Person[] students;  		//执行动态初始化  		students = new Person[2];  		//创建一个Person实例,并将这个Person实例赋给zhang变量  		Person zhang = new Person();  		//为zhang所引用的Person对象的age,height赋值  		zhang.age = 15;  		zhang.height = 158;  		//创建一个Person实例,并将这个Person实例赋给li变量  		Perosn li = new Person();  		//为li所引用的Person对象的age,height赋值  		li.age = 16;  		li.height = 161;  		//将zhang变量的值赋值给第一数组元素  		students[0] = zhang;  		//将li变量的值赋值给第二数组元素  		students[1] = li;  		//下面两行代码的结果完全一样,因为li和students[1]指向的是同一个Person实例  		li.info();  		stsudents[1].info();  	}  }

上面代码的执行过程代表了引用类型数组初始化的典型过程。

执行Person[] students;代码时,这行代码仅仅在栈内存中定义了一个引用变量,也就是一个指针,这个指针还并没有指向任何有效的内存区。此时内存中存储示意图如下:

图7:定义一个students数组变量后的存储示意图

在上图中,栈内存中定义了一个students变量,它仅仅是一个引用,并未指向任何有效的内存,直到执行初始化,本程序对students数组执行动态初始化,动态初始化由系统为数组元素分配默认的初始值:null,即为每个数组元素的值都是null。执行动态初始化后的示意图如下图8:

图8:执行动态初始化后的示意图

从图8中可以看出,students数组的两个数组元素都是引用,而且这个引用并未指向任何有效的内存,因此每个数组元素的值都是null。这意味着依然不能直接使用students数组元素,因为每个数组元素都是null,这相当于定义了两个连续的Person变量,但这个变量还未指向任何有效的内存区,所以这两个连续的Person变量还不能使用。

接着代码定义了zhang和li两个Person实例,定义这两个实例实际上分配了4块内存,在栈内存中储存了zhang和li两个引用变量,还在堆内存中存储了两个Person实例,此时的内存示意图如下图9:

图9:创建2个Person实例后的存储示意图

此时students数组的两个元素依然是null,直到程序依次将zhang赋给students数组的第一个元素,把li赋给students数组的第二个元素,students数组的两个数组元素将会指向有效的内存区。此时的内存存储示意图如图10:

图10:为数组赋值后的存储示意图

此时zhangstudents[0]指向同一个内存区,而且它们都是引用类型变量,因此通过zhangstudents[0]来访问Person实例的Field和方法的效果完全一样,不论修改students[0]所指向的Person实例的Field还是修改zhang变量所指向的Person实例的Field,所修改的其实是同一个内存区,所以必然互相影响,同理,li和students[1]也是引用同一个Person对象,也具有相同的效果。