数组与内存分配
- 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:为数组赋值后的存储示意图
此时zhang
和students[0]
指向同一个内存区,而且它们都是引用类型变量,因此通过zhang
和students[0]
来访问Person实例的Field和方法的效果完全一样,不论修改students[0]
所指向的Person
实例的Field
还是修改zhang
变量所指向的Person
实例的Field,所修改的其实是同一个内存区,所以必然互相影响,同理,li和students[1]
也是引用同一个Person对象,也具有相同的效果。