附录:Java的内存分配

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

附录
内存管理
主要内容
✓Java程序的内存分配
✓内存垃圾回收
✓值传递与引用传递
✓字符串池与字符串操作优化
1.Java程序的内存分配
掌握程序在运行时对内存的分配、占用,对合理化程序设计、优化程序结构有很大的好处,了解Java语言对内存的管理,也有利于我们了解Java语言的一些特性与机制。

良好的、健壮的代码不但要能够实现要求的功能,还要求合理利用内存,优化执行效率。

要了解Java程序的内存分配,首先要了解在进行内存分配时,Java程序、Java虚拟机与操作系统之间的关系,下面从程序准备执行开始说明三者之间的关系:
1.有些编程语言编写的程序会直接向操作系统请求内存,而Java语言为保证其平台
无关性,并不允许程序直接向操作系统发出请求,而是在准备执行程序时由Java
虚拟机向操作系统请求一定的内存空间,并分配给所执行的程序,这时所请求的内
存空间大小称之为初始内存空间。

程序执行过程中所需的内存都由Java虚拟机从
这片内存空间中划分。

2.当程序所需内存空间超出初始内存空间时,Java虚拟机会再次向操作系统申请更多
的内存供程序使用。

3.如果Java虚拟机已申请的内存达到了规定的最大内存空间,但程序还需要更多的
内存,这时会出现内存溢出的错误。

下面三张图演示了Java程序、Java虚拟机与操作系统之间的关系:
图1 初始内存分配
图2 初始内存不足时继续分配内存
图3 内存达到上限时通知程序内存溢出
至此可以看出,Java程序所使用的内存是由Java虚拟机进行管理、分配的。

Java虚拟机规定了Java程序的初始内存空间和最大内存空间(两者都可以进行调整),开发者只需要关心Java虚拟机是如何管理内存空间的,而不用关心某一种操作系统是如何管理内存的。

接下来介绍Java虚拟机对内存空间的管理,Java程序中的哪些内容会占用内存空间呢?面向对象编程中接触最多的类的结构、对象中的数据、变量(包括基本类型和引用类型)等都会占用内存空间,如下图所示:
图4 程序对内存的占用
为方便管理,Java虚拟机对应的在内存中划分了三个区域“方法区”、“堆区”和“栈区”,分别保存类结构、对象中的数据和变量(基本型和引用型)。

这三个内存区域都有大小限制,任何一个区域内存溢出都会导致程序出现错误,栈内存溢出会发生StackOverflowException 错误,堆内存溢出会发生OutOfMemoryError错误。

图5 Java虚拟机在内存中划分了三个区域
方法区中的内存分配:方法区默认最大容量为64M,Java虚拟机会将加载的java类存入方法区,保存类的结构(属性与方法),类静态成员等内容。

编写中小型程序时,一般不会造成方法区的内存溢出。

类结构在方法区中的存放形式如下图所示:
图6 Student和Demo类在方法区中的示意图
堆中的内存分配:堆默认最大容量为64M,堆存放对象持有的数据,同时保持对原类的引用。

可以简单的理解为对象属性的值保存在堆中,对象调用的方法保存在方法区。

下面的代码实例化两个Student类的对象:
代码演示:实例化Student对象(Student类定义与图4中相同)
public class Demo {
public static void main(String[] args) {
int i = 10;
Student stu1 = new Student();
= "Tom";
stu1.age = 18;
Student stu2 = new Student();
= "Jerry";
stu2.age = 22;
}
}
图7 对象在堆中分配示意图
栈中的内存分配:栈默认最大容量为1M,在程序运行时,每当遇到方法调用时,Java 虚拟机就会在栈中划分一块内存称为栈帧(Stack frame),栈帧中的内存供局部变量(包括基本类型与引用类型)使用,当方法调用结束后,Java虚拟机会收回此栈帧占用的内存。

需要注意的是,基本类型在栈中存放对应的变量值,而引用类型则在栈中存放对象的引用(即:保存的是堆中对象的地址)。

栈、堆、方法区三者关系如下:
图8 栈、堆、方法区之间的关系
引用类型的比较:我们习惯于使用“==”运算符来判断两个变量是否相等,这对于基本类型的变量来说没有问题,但是对于引用类型的变量,“==”运算符判断的是两个变量的引用地址是否相同,而不是引用的内容。

如下例所示:
代码演示:使用“==”运算符比较引用类型
public class Demo {
public static void main(String[] args) {
Student stu1 = new Student();
= "Tom";
stu1.age = 18;
Student stu2 = new Student();
= "Tom";
stu2.age = 18;
System.out.println(stu1==stu2);
}
}
stu1与stu2引用的对象内容完全一样,但是输出结果却为false,因为stu1与stu2的引用地址不同。

为了对引用类型的内容进行比较,Object类提供了equals方法,每个类可以重写该方法,定义自己的比较规则。

如我们学习过的String类,就重写了equals方法。

如果类没有重写
引用地址,而非对象的内容。

那么我们该如何正确的重写equals方法呢?先来看在Object类中对于equals的方法定义:
public boolean equals(Object obj)
equals方法应该实现的功能是将当前对象(this)与传入对象进行比较,返回比较结果。

返回值为boolean类型很好理解,认为相等返回true,否则返回false。

传入的参数代表比较对象,将其与当前对象(this)的内容进行比较。

一般来说应该按照如下规则实现equals方法:
1.首先判断传入对象是否为null,如果为null返回false。

2.判断传入对象与当前对象是否为同一类型(通过instanceof关键字),如果不是返
回false。

3.判断当前对象与传入对象的内容。

应用上述3个规则为Student类添加equals方法:
代码演示:为Student类添加equals方法
class Student {
public String name;
public int age;
public boolean equals(Object obj) {
if (obj == null) return false;
//判断传入参数是否为Student类的实例
if (!(obj instanceof Student)) return false;
Student stu = (Student) obj;
return this.age == stu.age && .equals();
}
}
public class Demo {
public static void main(String[] args) {
Student stu1 = new Student();
= "Tom";
stu1.age = 18;
Student stu2 = new Student();
= "Tom";
stu2.age = 18;
System.out.println(stu1==stu2);
System.out.println(stu1.equals(stu2));
}
}
运行代码,表达式stu1==stu2依然输出false,但stu1.equals(stu2)输出结果为true。

2.值传递与引用传递
我们已经知道,每当一个方法调用另一个方法时,Java虚拟机会在栈中划分一个栈帧给新的方法,而方法往往是具有参数的,参数的值是如何从一个栈帧传递到另一个栈帧的呢?根据参数变量类型的不同,传递方式分为值传递与引用传递两种。

如果方法参数是基本数据类型,该参数采用值传递,Java虚拟机会拷贝参数的值,放入新的栈帧;如果方法参数是引用数据类型,该参数采用引用传递,Java虚拟机会拷贝引用对象在堆中的地址,放入新的栈帧。

引用传递之所以拷贝的是对象的地址而不是对象的数据,是为了提高运行效率,减少内存的浪费。

两种传参形式的原理不同,特点也不一样,请观察下面的代码:
代码演示:值传递与引用传递的示例
public class Demo {
public static void main(String[] args) {
int i = 10;
Student stu = new Student();
= "Tom";
stu.age = 18;
changeInt(i);
changeStudent(stu);
System.out.println(i);
System.out.println(stu.age);
}
//值传递
public static void changeInt(int i) {
i = 5;
}
//引用传递
public static void changeStudent(Student stu) {
stu.age = 5;
}
}
上面代码中的两个方法changeInt和changeStudent分别使用了值传递和引用传递来传递参数,并且都在方法中对参数的内容进行了修改,但程序最后的输出结果告诉我们,i的值还是10,并没有被修改,而stu.age的值被修改为5,下图展示了产生这种结果的原因:
图9 值传递与引用传递的示意图
请思考下面代码的运行结果,并尝试画出类似于图9的示意图:
代码演示:思考按引用传递
public class Demo {
public static void main(String[] args) {
Student stu = new Student();
= "Tom";
stu.age = 18;
changeStudent(stu);
System.out.println(stu.age);
}
public static void changeStudent(Student stu) {
stu = new Student();
stu.age = 10;
}
}
3.垃圾回收机制
垃圾回收(Garbage Collection)机制简称gc,它极大的简化了程序对内存的管理,是Java语言的重要特征之一。

很多编程语言要求程序员手工申请内存并手工释放内存,但程序员往往会遗忘释放内存这一步骤(造成内存泄漏),或因为程序过于复杂进行错误的释放。

在Java语言中,程序员需要手工申请内存(一般使用new关键字),但不用手工编码释放内存,而是由Java虚拟机自动对不再使用的内存进行回收,这种机制成为垃圾回收机制。

在栈、堆、方法区三个内存分区中,栈会在方法调用时划分栈帧,方法调用结束时回收栈帧,不需要垃圾回收,堆和方法区都会进行垃圾回收。

方法区中的垃圾回收主要作用是回收不再被对象引用的类结构所占用的内存,堆中的垃圾回收主要作用是回收不再被引用的对象数据所占用的内存。

接下来主要介绍堆中的垃圾回收机制。

要进行垃圾回收,首先要判断什么是“垃圾”。

在堆中,不被引用的对象被称为垃圾。

如下图所示,当方法show执行完成后,show方法栈帧会被回收,stu对象的引用也就不存在了,此时堆中的stu对象即被标示为“垃圾”。

图10 垃圾回收示意图
所谓不被引用的对象,即不被栈所引用、同时也不被堆中的其他对象所引用的对象。

当堆中的对象被标示为垃圾时,该对象并不会立刻被回收,而是要等到垃圾回收程序执行时才被回收。

根据具体Java 虚拟机的实现不同,垃圾回收程序执行的时机也不相同,可以定时执行,也可以等到内存不足时执行或其他方式。

当对象被垃圾回收程序回收时,会调用对象的finalize 方法通知开发者该对象即将被回收。

但因为垃圾回收程序的执行时机是不可测的,甚至在一些占用内存少的程序中根本不会进行垃圾回收,所以不能依赖此方法实现重要的功能。

代码演示:finalize 方法
class Student { public String name; public int age; public void study () { System.out .println("我在学习");
}
protected void finalize() throws Throwable { System.out .println("我被垃圾回收了!"); }
}
Q 后面的A throws Throwable
垃圾回收时因需要整理堆中的内存,为防止出现错误的操作,一般垃圾回收执行时会暂停主程序的执行。

所以应该避免频繁的进行垃圾回收,以免影响程序的性能。

4.Runtime类的使用
我们可以借助于Java提供的Runtime类提供的方法来加深对垃圾回收过程的了解,下表中列出了Runtime类中的主要方法:
表1 Runtime类提供的常用方法
Runtime
比如连续的调用
下面的程序通过申请和释放长度为50万的数组,演示了垃圾回收的特性:
代码演示:finalize方法
public class Demo {
public static void main(String[] args) {
showMemory("程序初始时");
Student []stus = new Student[500000];
for (int i = 0; i < stus.length; i++) {
stus[i] = new Student();
stus[i].name = "Tom";
stus[i].age = 18;
}
showMemory("创建数组后");
Runtime.getRuntime().gc();
//因为stus数组引用了各个Student对象,所以这时进行垃圾回收并不会回收内存
showMemory("第一次垃圾回收后");
stus = null;
//将stus设置为null后,各个Student对象失去了引用,会被标示为垃圾
Runtime.getRuntime().gc();
showMemory("垃圾回收后");
}
public static void showMemory(String state) {
Runtime rt = Runtime.getRuntime();
System.out.println(state + ":分配内存= " + rt.totalMemory()/1024 + "KB");
System.out.println(state + ":剩余内存= " + rt.freeMemory()/1024 + "KB");
System.out.println(state + ":内存上限= " + rt.maxMemory()/1024 + "KB");
System.out.println("---------------------------------------");
}
}
以上程序运行后会输出类似于下面的结果:
从结果可以观察到,内存上限是不会更改的,而JVM分配给程序的内存和程序可使用的剩余内存会在运行时更改。

初始时JVM给程序分配的内存只有5056K(不到5M),创建数组后,则增加到了14444KB(约14M),第二次垃圾回收后又减少了给程序分配的内存,为13456KB (约13M)。

Q
就是说这时程序只使用了
不是初始时的
A
用过
这部分内存。

的内存占用空间。

5.字符串在内存中的分配
我们经常使用的字符串是String类的对象,它也遵守一般的内存分配规则,但Java为了节省内存,提出了“字符串池”的概念:在内存中划分一片区域称为“字符串池”,将编译时可以确定的字符串常量放入池中,相同内容的字符串引用池中的同一个对象,如下例所示:
代码演示:字符串池
public class Demo {
public static void main(String[] args) {
String a = "Tom";
String b = "Tom";
String c = "Jerry";
String d = "Jerry";
}
}
上面的程序如按照一般规则,需要在堆中创建4个String对象。

有了字符串池,只需要在池中创建2个对象,再另a和b引用值为"Tom"的对象,c和d引用值为"Jerry"的对象即可。

图11 字符串池示意图
之前我们学习过Java语言中运算符“==”比较对象的地址,equals方法比较对象的内容。

上例中变量a和b两个都引用同一个地址,所以使用运算符“==”比较也会返回true。

但是使用new关键字创建的字符串对象和编译时无法确定内容的字符串对象不会放入字符串池中,这时如果使用“==”比较对象会返回false,应使用equals方法进行比较。

代码演示:字符串池
public class Demo {
public static void main(String[] args) {
String a = "Tom";
String b = new String("Tom"); ①
String c = "T" + "o" + "m"; ②
String d = "T";
String e = d + "om"; ③
System.out.println(a==b); //结果为false
System.out.println(a==c); //结果为true
System.out.println(a==d); //结果为false
System.out.println(a==e); //结果为false
}
}
代码解析:
①使用了new关键字,不使用字符串池。

②虽然"T" + "o" + "m"为表达式,但在编译时可以确定表达式的值,编译器会用"Tom"
替换该表达式,可以使用字符串池。

③表达式中存在变量,无法在在编译时确定表达式的值,不使用字符串池(注:不排
除随着编译器的不断优化,对此类简单的表达式也能做到编译时确定值)。

了解了字符串对象在内存中的分配后,我们再来研究字符串进行连接运算(“+”运算)时,对内存的操作,请运行分别下面两个例子:
代码演示:比较例子1
public class Demo {
public static void main(String[] args) {
int c = 0;
for (int i = 0; i < 100000; i++) {
c = c + 1;
}
System.out.println(c);
}
}
代码演示:比较例子2
public class Demo {
public static void main(String[] args) {
String s = "";
for (int i = 0; i < 100000; i++) {
s = s + "1";
}
System.out.println(s);
}
}
运行后会发现,同样是在循环中对变量进行“+”运算,例子1很快可以得出运算结果,例子2却需要很长的时间,这是为什么呢?
原来,每一次对字符串进行“+”运算时,都会将运算结果(一个新的字符串对象)放入堆中,如果进行10万次运算,就会在堆中创建10万个对象,这是非常耗时的操作(见下图示例)。

而例子1是对基本型进行运算,基本型变量保存在栈中,每次运算后只是将新的结果重新写入栈,并不需要分配内存,所以速度很快。

图12 字符串连接操作示例
所以在编码时,应该避免对字符串进行大量的“+”运算,如果一定需要做字符串连接,Java提供了StringBuffer和StringBuilder两个类进行高性能的字符串连接操作。

StringBuffer和StringBuilder的工作原理是预先在堆中开辟一定的内存空间,在进行字
符串连接操作时将字符串内容附件到内存空间中,如果内存空间不够用再申请扩大内存空间,以避免了大量产生新的对象,不断分配内存的情况,下面是代码和示意图:
代码演示:使用StringBuffer优化代码
public class Demo {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 100000; i++) {
sb.append("1");
}
//运算结束后通过toString方法获取StringBuffer中的结果
String result = sb.toString();
System.out.println(result);
}
}
图13 使用StringBuffer进行字符串连接原理示例图
StringBuilder类的用法与StringBuffer类完全一样,但内部实现机制稍有改动,增
加了对多线程同时访问的支持,即实现了所谓的“线程安全”。

关于线程安全的概念在下一
章的课程中也会出现,目前我们开发的代码均为单线程结构,不需要考虑线程安全因素。

多线程开发技术会在第二学期讲到,现在我们只需了解这个术语即可。

6.总结
Java虚拟机的内存管理方式,运行时Java程序、Java虚拟机与操作系统之间如何进行内存分配。

栈、堆、方法区三者的区别于联系。

方法传参时,值传递与引用传递的特点与区别。

垃圾回收的概念,finalize方法。

Runtime类的常用方法。

字符串池的概念,使用StringBuffer和StringBuilder对字符串连接运算进行优化。

7.练习
1.尝试写一段代码让栈内存溢出。

2.尝试写一段代码让堆内存溢出。

3.阅读以下代码,从内存分配角度指出代码中存在的错误,最好画出示意图:
public class Demo {
public static void main(String[] args) {
int[] array = new int[3];
array[0] = 10;
array[1] = 20;
array[2] = 30;
//发现数组长度不足,扩展数组长度
array = new int[4];
array[3] = 40;
//再次发现数组长度不足,扩展数组长度
array = new int[5];
array[4] = 50;
}
}
4.设计方法bigger,实现为在运行时修改数组长度,并保持数组原内容不变,方法定
义如下:
public static void int[] bigger(int[] array, int newLength)
参数array为需要改变长度的数组,参数newLength为新的数组长度。

相关文档
最新文档