深入学习JVM2:自动内存管理机制
本文是对JVM的经典学籍《深入理解Java虚拟机》中知识学习的总结摘抄,原书内容写的很好,所特意从中摘取自己觉得比较重要的点,不求能够全部掌握所有内容,但至少保证能够在整体轮廓上有所斩获。
关于JVM的学习一共包含三篇文章,本文是第二篇:
深入学习JVM1:自动内存管理机制
深入学习JVM2:自动内存管理机制
深入学习JVM3:虚拟机类加载机制
本文的内容是关于JVM的自动内存管理机制,其包含两部分内容:
JVM内存区域
概述
对于Java程序员来说,在虚拟机的自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,而且不容易出现内存泄漏和内存溢出问题。不过也正因为Java程序员把内存控制的权利交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排除错误会成为一项异常简单的工作。
JVM内存数据区域
JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,结构图如下所示:
程序计数器
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常等基础功能都需要依赖这个计器来完成。
JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因为为了线程切换后能恢复到正确的执行位置每条线程都需要一个独立的程序计数器,因此应该为“线程私有”的内存。
Java虚拟机栈
Java栈也称作虚拟机栈(Java Vitual Machine Stack),也就是我们常常所说的栈,跟C语言的数据段中的栈类似。事实上,Java栈是Java方法执行的内存模型。为什么这么说呢?下面就来解释一下其中的原因。
Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。下图表示了一个Java栈的模型:
局部变量表,顾名思义,想必不用解释大家应该明白它的作用了吧。就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用或者是句柄。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。
操作数栈,想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。
指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。
由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。
本地方法栈
与虚拟机栈发挥的作用非常相似,区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
Java堆
Java堆一块被所有线程共享的一块内存区域,在虚拟机启动时创建。在区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被叫做GC堆。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所Java堆中还可以细分为:新生代和老年代;再细致一点,新生代划分为Eden空间、From Survivor空间、To Survivor空间等。无论如何划分,都与存放内容无关,无论哪个区域,都是存放对象实例。划分的目的是为了更好的回收内存或者更快的分配内存。
方法区
方法区也是各个线程共享的内存区域,用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。
运行时常量池
该区域是方法区的一部分。在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。
运行时常量池是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。
对象访问
在Java语言中,对象访问是如何进行的?
对象访问会涉及Java栈、Java堆、方法区这三个最重要内存区域之间的关联关系,如这条代码Object obj = new Objetc()
。
假设这句代码出现在方法体中,那么Object obj
这部分的语义将会反映到Java栈的本地变量表中,作为一个reference类型数据出现。而new Object()
这部分的语义将会反映到Java堆中,形成一块存储了Object类型所有实例数据值的结构化内存。另外,在Java堆中,还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。
由于reference类型在Java虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中对象的具体位置,因此不同虚拟机实现有两种不同的方式,主流的有两种:使用句柄和直接指针。
如果使用句柄访问方式,Java堆中将会划出一块作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息:
如果使用直接指针访问方式,Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。
垃圾收集器与内存分配策略
概述
我们在上文中介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧分配多少内存基本上在类结构确定下来时就已知的,这几个区域的内存分配和回收都具有确定性,这这几个区域不需要过多考虑回收的问题。
但Java堆和方法区就不一样了,这部分内存的分配和回收是动态的,垃圾收集器所关注的是这部分内存。
垃圾回收机制中的算法
引用计数法
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。
- 优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
- 缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.
引用计数算法无法解决循环引用问题,例如:1
2
3
4
5
6
7
8
9
10
11
12public class Main {
public static void main(String[] args) {
MyObject object1 = new MyObject();
MyObject object2 = new MyObject();
object1.object = object2;
object2.object = object1;
object1 = null;
object2 = null;
}
}
最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。
标记-清除算法(Mark-Sweep)
在对这个算法学习之前,首先需要掌握根搜索算法。
根搜索算法
根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。
java中可作为GC Root的对象有:
- 虚拟机栈中引用的对象(本地变量表)
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象(Native对象)
算法分析
如上图。标记-清除算法采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如上图所示。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
该算法是最基础的收集算法,后续的收集算法都是基于这种思想并对其缺点进行改进得到的。它的主要缺点有两个:
- 效率问题,标记和清除过程的效率都不高
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于copying算法的垃圾 收集就从根集中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
现在的商业虚拟机都采用这种收集算法来回收新生代。更加详细的信息将会在后边介绍。
标记-整理算法
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。
分代收集算法
分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。
当前的商业虚拟机的垃圾收集都采用“分代收集”算法,该算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般为新生代、老年代和永久代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都会有大批对象死去,那就选用复制算法。而老年代因为对象存活率高,而没有额外空间对它进行分配担保,必须采用“标记-清除”或者“标记-清理”算法。
新生代
- 所有新生成的对象首先都是放在新生轻代的。新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。
- 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。
- 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。(这里指的是老年代对其进行分配担保)
- 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)
老年代
- 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
- 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
持久代
用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。
垃圾收集器
新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge
老年代收集器使用的收集器:Serial Old、Parallel Old、CMS
- Serial收集器(复制算法):新生代单线程收集器,标记和清理都是单线程,优点是简单高效。
- Serial Old收集器(标记-整理算法):老年代单线程收集器,Serial收集器的老年代版本。
- ParNew收集器(停止-复制算法):新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
- Parallel Scavenge收集器(停止-复制算法):并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。
- Parallel Old收集器(停止-复制算法):Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先
- CMS(Concurrent Mark Sweep)收集器(标记-清理算法):高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择
内存分配策略
Java所提倡的自动内存管理最终可以归结为自动化的解决了两个问题:给对象分配内存以及回收分配给对象的内存。
下面来讨论一下给对象分配内存的策略。
下面是几条最普遍的内存分配规则:
- 对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配的时候,虚拟机将发起一次Minor GC。
- 大对象直接进入老年代:所谓大对象指的是需要大量连续内存空间的Java对象,最典型的的大对象就是那种很长的字符串及数组。
- 长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden每熬过一次Minor GC,年龄增加1,当年龄增加到一定成都,就会晋升到老年代中
Java有了GC同样会出现内存泄露问题
1、静态集合类像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,所有的对象Object也不能被释放,因为他们也将一直被Vector等应用着。1
2
3
4
5
6
7Static Vector v = new Vector();
for (int i = 1; i<100; i++)
{
Object o = new Object();
v.add(o);
o = null;
}
在这个例子中,代码栈中存在Vector 对象的引用 v 和 Object 对象的引用 o 。在 For 循环中,我们不断的生成新的对象,然后将其添加到 Vector 对象中,之后将 o 引用置空。问题是当 o 引用被置空后,如果发生 GC,我们创建的 Object 对象是否能够被 GC 回收呢?答案是否定的。因为, GC 在跟踪代码栈中的引用时,会发现 v 引用,而继续往下跟踪,就会发现 v 引用指向的内存空间中又存在指向 Object 对象的引用。也就是说尽管o 引用已经被置空,但是 Object 对象仍然存在其他的引用,是可以被访问到的,所以 GC 无法将其释放掉。如果在此循环之后, Object 对象对程序已经没有任何作用,那么我们就认为此 Java 程序发生了内存泄漏。
2、各种连接,数据库连接,网络连接,IO连接等没有显示调用close关闭,不被GC回收导致内存泄露。
3、监听器的使用,在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露。