Java 内存管理
理解Java的内存管理,首先需要了解Java虚拟机是如何对内存进行划分的;同时需要从多个方面来考虑内存划分,如运行时的内存划分、GC内存划分等。
1、 运行时内存
当一个.class文件被类加载子系统加载到内存后,内存被划分为堆、方法区、Java虚拟机栈、本地方法栈、PC计数器等五个区域,如下所示
堆、方法区为所有线程共享,而程序计数器、虚拟机栈、本地方法栈为线程独有,下面将单独介绍各个区的作用。
1.1、程序计数器
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码行号指示器,通过改变程序计数器的值来决定执行的下一条字节码指令。
由于Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式来实现的,在任何时刻,一个处理器只会执行一条线程中的字节码指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间的计数器独立存储,互不影响,我们称这类内存为“线程私有”的内存。“线程私有”的内存随线程启动而创建,随线程结束而释放。
1.2、虚拟机栈
虚拟机栈也是线程私有的内存,虚拟机栈描述的是Java方法执行的内存模型:每一个方法执行时都会创建一个栈帧,用于存储布局变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表存储了编译期间可知的基本数据类型和引用类型(不是实例本身,如下图所示)。局部变量表所需的内存空间在编译期完成分配,在方法运行期间是不会改变局部变量表的大小的。
1.3、本地方法栈
本地方法栈与虚拟机栈的作用类似,区别是虚拟机栈为虚拟机执行字节码服务,而本地方法栈则是为虚拟机使用Native方法服务。
1.4、堆
对大对数应用来说,堆是JVM所管理的内存中最大的一块,是所有线程共享的一块内存区域,在JVM启动时创建,与JVM的生命周期一致。堆的唯一目的就是存放对象实例,几乎所有的对象实例和数组都要在堆上分配内存,如下所示:
在Java中,数组也是引用类型,所以不管数组元素为基本类型还是引用类型,数组元素都会在堆上分配内存(而不是所谓的基本数据类型都在栈上分配内存)。若数组元素为引用类型,则数组中保存的是引用,引用指向了实例。
由于堆是JVM最大的内存区域,几乎所有的实例和数组都在堆上分配内存,而且堆为所有线程共享的内存区域,所以Java堆是垃圾收集器管理的主要区域。
1.5、方法区
方法区和Java堆一样,也是所有线程共享的内存区域。我们知道对象和数组在堆上分配内存,那么描述一个类的信息(Class对象)存储在什么地方呢?方法区正是用于存储已被虚拟机加载的类信息,常量,静态变量,以及即时编译器编译后的代码数据等,方法区可以看作是数据段和代码段的组合。
运行时常量池是方法区的一部分,Class文件中除了有类的版本信息、字段、方法、接口等信息外,还有一项就是常量信息,如编译期的字面量和符号引用等,这部分信息将在类加载后存放到方法区的运行时常量池中。
2、 GC内存
由上面的分析可知,计数器、虚拟机栈、本地方法栈随线程启动而创建,随线程结束而销毁,与线程的生命周期一致,所以不用进行垃圾回收;而堆、方法区是所有线程线程共享的大块内存区域,在Java中所有的实例和数组都在堆上分配内存,若不进行垃圾回收,内存将会很快耗尽。所以垃圾回收主要针对堆,而是否对方法区进行垃圾回收不同的虚拟机实现并不相同。
对堆进行垃圾回收,需要解决的第一个问题就是确定如何判断对象已死,也即该对象可以进行内存回收。
2.1、根搜索算法
很多程序设计语言在对内存管理的时候采用的是引用计数法,该方法简单高效,但并不能有效解决循环引用的问题,如对象A引用对象B,同时,对象B也引用对象A,那么对象A和B的引用计数都不为零,最后对象A、B都不能释放。
在Java中判断对象是否已死,采用的是根搜索方法,基本思想是通过一系列的根对象作为起始点,从这些节点开始向下搜索,若根节点与对象之间存在可达路径则对象是存活的;若根节点与对象之间没有可达路径,则对象已死,可以进行内存回收,如下所示:
由于Object5、Object6、Object7没有到根节点的路径,所以这些对象判定为可回收的对象。
在Java中,可以作为GCRoots对象的有:
l 虚拟机栈中引用的对象;
l 方法区中的静态属性引用对象;
l 方法区常量引用对象;
l 本地方法栈中引用的对象;
2.2、垃圾收集算法
在2.1节中,我们已确定一个对象是否可以进行内存回收,那么如何进行内存回收呢?
最基础的收集算法就是“标记-清除算法”,首先标记那些对象可以进行垃圾回收,之后对标记的对象进行内存回收。该算法主要有两个问题:一个是效率问题,标记和清除过程的效率都不高;第二个问题是,标记清除后内存会有大量的碎片,若要再次需要连续大块的内存区域将很难满足。
为了解决效率和内存碎片问题,一种“复制”算法出现了,该方法将内存分为两块相等的内存区域(注意标记清除算法并没有对内存进行分块处理),每次只使用其中的一块,当这一块用完了,就将存活的对象复制到另一个中,然后把之前的一块内存空间全部清理掉。
为了更好的管理内存,JVM将堆内存划分为新生代和老年代。在分配内存时,优先在新生代中分配,而对于一些长期存活且需要大块内存的对象可以考虑优先在老年代中分配内存。
IBM研究表明,新生代中的98%的对象是朝生夕死的,所以没必要严格按照1:1的比例对新生代内存进行划分,而是将新生代划分为一块较大的Eden区和两块较小的Survivor空间,如下所示
在为对象分配内存时,每次使用Eden和一块survivor;当回收时,将Eden和survivor中存活的对象一次性复制到另一块Survivor空间,最后清理到Eden和刚才用过的Survivor空间。
不同的虚拟机默认的Eden和Survivor的空间划分比例不同,以HotSpot虚拟机为例,默认为8:1:1。有些情况下,我们保证不了Eden和其中块Survivor中存活的对象不多于10%,当另一块Survivor空间不够用时,需要向老年代进行分配担保,这些存活的对象直接通过分配担保机制进入老年代。
2.3、对象分配策略
大多数情况下,对象在新生代Eden区中分配,当Eden区中没有足够的空间时,虚拟机将发起一次Minior GC(新生代GC)。由于Java对象大多都有朝生夕死的特性,所以新生代GC对新生代进行垃圾回收,回收非常频繁,一般回收速度也比较快。除了新生代GC,也有老年代GC或称堆GC(Full GC),将对整个堆进行GC,Full GC速度一般比较慢,所以要尽力避免Full GC。
大对象直接进入老年代,大对象对JVM的内存分配来说是个坏消息,经常出现大对象容易导致内存还有不少空间时就触发垃圾收集以获取足够的连续空间。虚拟机提供了参数设置,可以将大对象直接分配在老年代中,避免在Eden去和两个Survivor区发生大量的内存拷贝现象。
长期存活的对象也应该进入老年代,为了记录对象的存活时间长短,JVM给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过一次新生代GC后仍然存活,则对象年龄加1,当年龄达到一定值后,将对象晋升到老年代中。为了更好地适应不同程序的内存状态,可以动态判断对象年龄的大小,不同虚拟机实现并不相同,在此不再详述。
2.4、内存泄露
数组、容器等容易造成内存泄露,如将数组元素删除时,未显示使数组元素赋值为null;容器元素不再使用时,未显示释放等。
JVM自动管理堆内存的分配和回收,那么在Java中存在内存泄露的问题吗?虽然JVM可以自动管理堆内存的分配和回收,在Java还是会存在内存泄露的问题,典型的内存泄露问题有:
l 数组,容器元素不再使用时,未显示释放
当数据元素或集合中元素不再使用时,对于数组元素为引用类型的,需要显示赋值为null,而对于集合元素需要显示执行删除操作;
l 打开本地资源或网络资源,未关闭
打开本地文件或网络资源,本质上是调用OSAPI进行操作的,对于打开的物理资源,垃圾回收机制无能为力,如果不关闭这些资源,会造成内存泄露 ;
l 长周期生命对象持有短周期生命对象的引用
长周期生命对象持有短周期生命对象的引用,当短生命周期的对象不再使用时,也不能释放内存空间,将造成内存泄漏。如一个外部类实例的方法返回一个内部类的引用,即时外部类不再使用,只要返回的内部类引用一直存在,外部类对象将不能被释放,造成内存泄露。
2.5、优化程序
2.5.1、直接量
当使用字符串,还有Byte,Short,Integer等包装类时,尽量使用直接量来创建对象,而不是采用new关键字来创建对象,如
String str="i am a string";
Integer age=8;
在创建字符串时,若使用字面值,则该字符串内容会缓存在字符串常量池中,当再以字面值创建相同的字符串时,直接返回字符串常量池中以存在的对象,如下
String str="i am a string";
Stringtemp="i am a string";
System.out.println(str==temp);
此外,在创建字符z串时,若多个字面值相加,其实编译器会进行优化,将多个字面值拼接为一个字面值,如 String str="i"+" am" +" a"+"string";等价于Stringstr="i am a string"。
而对于new方法来创建的字符串对象不仅字面值会缓存在字符串常量池中,而且字符串实例(其中包含字符数组)也放在堆上,如new String("i am a string");将会将字面值"i am a string"放在字符串池中,同时堆上的字符串实例中还会存放字符数组,字符数组中包含了字符串中的每个字符。
2.5.2、StringBuilder、StringBuffer
对于字符串拼接,应该采用StringBuilder或StringBuffer,避免频繁创建字符串实例。若不存在线程安全问题,采用StringBuilder;而要求线程安全,则应采用StringBuffer。
2.5.3、尽早释放无用对象
2.5.4、尽量少用静态变量
若某一个对象被static修饰,则垃圾收集通常是不会回收这个对象的。
2.5.5、尽量避免循环创建对象
在经常调用的方法内,应避免循环创建大量对象,一般情况下,可以采用容器来缓存对象。