JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构的计算机,是通过在实际的计算机上仿真模JVM计算机功能来实现的。JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上一次编译,多次运行,具有跨平台性。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法区。
一、JAVA内存结构
1.1 JAVA堆(JAVA)
Java堆是Java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。
Java堆是垃圾收集器管理的主要区域,因此也被称为“GC堆”。从内存回收角度来看Java堆可分为:新生代和老生代。从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。根据Java虚拟机规范的规定,Java堆可以处理物理上不连续的内存空间中。当前主流的虚拟机都是可扩展的(通过-Xms和-Xmx控制)。如果堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 )
默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
1.2 Java虚拟机栈(Java Virtual Machine Stacks)
java虚拟机也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。局部变量表存放了编译期可知的各种基本数据类型(8个基本数据类型)、对象引用(地址指针)、returnAddress类型。局部变量表所需的内存空间在编译期间完成分配。在运行期间不会改变局部变量表的大小。这个区域规定了两种异常状态:如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,在扩展是无法申请到足够的内存,就会抛出OutOfMemoryError异常。
1.3 本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈所发挥作用非常相似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务。本地方法栈也是抛出两个异常。
1.4 方法区(Method Area)
方法区与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它有个别命叫Non-Heap(非堆)。当方法区无法满足内存分配需求时,抛出OutOfMemoryError异常。
方法区也称"永久代",它用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。
在JDK8之前的HotSpot JVM,存放这些”永久的”的区域叫做“永久代(permanent generation)”。永久代是一片连续的堆空间,在JVM启动之前通过在命令行设置参数-XX:MaxPermSize来设定永久代最大可分配的内存空间,默认大小是64M(64位JVM默认是85M)。
随着JDK8的到来,JVM不再有 永久代(PermGen)。但类的元数据信息(metadata)还在,只不过不再是存储在连续的堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory。
方法区或永生代相关设置
- -XX:PermSize=64MB 最小尺寸,初始分配
- -XX:MaxPermSize=256MB 最大允许分配尺寸,按需分配
- XX:+CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled 设置垃圾不回收
- 默认大小
- -server选项下默认MaxPermSize为64m
- -client选项下默认MaxPermSize为32m
1.5 直接内存(Direct Memory)
直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。但这部分区域也呗频繁使用,而且也可能导致OutOfMemoryError异常。在JDK1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
1.6 运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在加载后进入方法区的运行时常量池中存放。
1.7 程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。称之为“线程私有”的内存。程序计数器内存区域是虚拟机中唯一没有规定OutOfMemoryError情况的区域。
1.8 执行引擎
虚拟机核心的组件就是执行引擎,它负责执行虚拟机的字节码,一般户先进行编译成机器码后执行。
1.9 垃圾收集系统
垃圾收集系统是Java的核心,也是不可少的,Java有一套自己进行垃圾清理的机制,开发人员无需手工清理
二、垃圾回收机制算法分析
2.1什么是垃圾回收机制
不定时去堆内存中清理不可达对象。不可达的对象并不会马上就会直接回收,垃圾收集器在一个Java程序中的执行是自动的,不能强制执行,即使程序员能明确地判断出有一块内存已经无用了,是应该回收的,程序员也不能强制垃圾收集器回收该内存块。程序员唯一能做的就是通过调用System.gc()方法来“建议”执行垃圾收集器。但其是否可以执行,什么时候执行却都是不可知的。这也是垃圾收集器的最主要的缺点。当然相对于它给程序员带来的巨大方便性而言,这个缺点是瑕不掩瑜。下面我们看一个案例:
public class Demo001 {
public static void main(String[] args) {
Demo001 demo001 = new Demo001();
demo001 = null;
System.out.println("手动回收垃圾开始...");
System.gc(); // 手动回收垃圾
System.out.println("手动回收垃圾结束...");
}
@Override
protected void finalize() throws Throwable {
// gc回收垃圾之前调用
System.out.println("垃圾回收机制...");
}
}
输出:
手动回收垃圾开始...
手动回收垃圾结束...
垃圾回收机制...
2.1.1 finalize方法作用
Java技术使用finalize()方法在垃圾收集器将对象从内存中清除出去前,做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的。它是在Object类中定义的,因此所有的类都继承了它。子类覆盖finalize()方法以整理系统资源或者执行其他清理工作。finalize()方法是在垃圾收集器删除对象之前对这个对象调用的。
2.2 新生代与老年代
Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
堆的内存模型大致为:
默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小(本人使用的是 JDK1.6,以下涉及的 JVM 默认值均以该版本为准)。
根据垃圾回收机制的不同,Java堆有可能拥有不同的结构,最为常见的就是将整个Java堆分为新生代和老年代。其中新生带存放新生的对象或者年龄不大的对象,老年代则存放老年对象(即经常使用的对象)。新生代分为den区、s0区、s1区,s0和s1也被称为from和to区域,他们是两块大小相等并且可以互相角色的空间。绝大多数情况下,对象首先分配在eden区,在新生代回收后,如果对象还存活,则进入s0或s1区,之后每经过一次新生代回收,如果对象存活则它的年龄就加1,对象达到一定的年龄后,则进入老年代。
2.3 如何判断对象是否存活
2.3.1 引用计数法
引用计数法就是如果一个对象没有被任何引用指向,则可视之为垃圾。这种方法的缺点就是不能检测到环的存在。
首先需要声明,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存。
什么是引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1.任何时刻计数器值为0的对象就是不可能再被使用的。那为什么主流的Java虚拟机里面都没有选用这种算法呢?其中最主要的原因是它很难解决对象之间相互循环引用的问题。
2.3.2 根搜索算法
根搜索算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。
那么问题又来了,如何选取GCRoots对象呢?在Java语言中,可以作为GCRoots的对象包括下面几种:
(1). 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
(2). 方法区中的类静态属性引用的对象。
(3). 方法区中常量引用的对象。
(4). 本地方法栈中JNI(Native方法)引用的对象。
下面给出一个GCRoots的例子,如下图,为GCRoots的引用链。
从上图我们可以看到,obj8、obj9、obj10都没有和GCRoots发生引用链,所以这几个对象是不可达,GC会产生回收效果。如果这个图还不理解,我们可以观察如下图:
从上图我们可以看到,obj3、obj5对象虽然有联系,但是没有方法区的引用、虚拟机栈的引用、本地栈的引用,所以GC会对其进行回收。需要说明的是,即使再可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
1、对象在进行可达性分析后被发现不可达,它将会被第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行finalise()方法,当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过,那么就没必要执行finalize()方法;
2、如果被判定为有必要执行finalize()方法,那么此对象将会放置在一个叫做F-Quenen的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalize线程去触发这个方法。finalize()方法是对象逃脱死亡的最后一次机会,稍后GC将对F-Quenen中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关系即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移出“即将回收”集合;如果对象这时候还么有成功逃脱,那他就会真的被回收了。
用一段代码来看一下一个对象的finalize()被执行,但是它仍然可以存活:
/**
* 此代码演示了两点:
* 1.对象可以被GC时自我拯救
* 2.这种自救的机会只有一次,因为一个对象的finzlize()方法最多只会被系统自动调用一次
*/
public class FinalizeGC {
public static FinalizeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("我还活着哦...");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize方法开始执行...");
FinalizeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Exception {
SAVE_HOOK = new FinalizeGC();
while(true){
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.out.println("第一次未回收对象SAVE_HOOK的值为:"+SAVE_HOOK);
System.gc();
//因为finalize方法优先级很低,所以暂停1s以等待它
Thread.sleep(1000);
System.out.println("第一次已回收对象SAVE_HOOK的值为:"+SAVE_HOOK);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("我已经被回收了...");
}
//下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.out.println("第二次未回收对象SAVE_HOOK的值为:"+SAVE_HOOK);
System.gc();
//因为finalize方法优先级很低,所以暂停1s以等待它
Thread.sleep(1000);
System.out.println("第二次已回收对象SAVE_HOOK的值为:"+SAVE_HOOK);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("我已经被回收了...");
}
}
}
}
输出结果:
第一次未回收对象SAVE_HOOK的值为:null
finalize方法开始执行...
第一次已回收对象SAVE_HOOK的值为:com.loafer.FinalizeGC@15db9742
我还活着哦...
第二次未回收对象SAVE_HOOK的值为:null
第二次已回收对象SAVE_HOOK的值为:null
我已经被回收了...
第一次未回收对象SAVE_HOOK的值为:null
第一次已回收对象SAVE_HOOK的值为:null
我已经被回收了...
第二次未回收对象SAVE_HOOK的值为:null
第二次已回收对象SAVE_HOOK的值为:null
我已经被回收了...
第一次未回收对象SAVE_HOOK的值为:null
第一次已回收对象SAVE_HOOK的值为:null
我已经被回收了...
SAVE_HOOK对象的finalize()方法确实被GC收集器触发了,并且SAVE_HOOK第一次在被收集前成功逃脱了。逃脱的原因的是:任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
2.4 垃圾回收机制策略
2.4.1 标记清除算法
该算法有两个阶段。
1. 标记阶段:找到所有可访问(可达)的对象,做个标记
2. 清除阶段:遍历堆,把未被标记的对象回收(可以根据上面介绍的引用计数法、GCRoots方法进行标记)
应用场景
该算法一般应用于老年代,因为老年代的对象生命周期比较长。
优缺点
优点
- 是可以解决循环引用的问题
- 必要时才回收(内存不足时)
缺点:
- 回收时,应用需要挂起,也就是stop the world。
- 标记和清除的效率不高,尤其是要扫描的对象比较多的时候
- 会造成内存碎片(会导致明明有内存空间,但是由于不连续,申请稍微大一些的对象无法做到)
标记-清除算法的执行过程如下图所示:
2.4.2复制算法
概念
如果jvm使用了coping算法,一开始就会将可用内存分为两块,from域和to域, 每次只是使用from域,to域则空闲着。当from域内存不够了,开始执行GC操作,这个时候,会把from域存活的对象拷贝到to域,然后直接把from域进行内存清理。
应用场景
coping算法一般是使用在新生代中,因为新生代中的对象一般都是朝生夕死的,存活对象的数量并不多,这样使用coping算法进行拷贝时效率比较高。jvm将Heap 内存划分为新生代与老年代,又将新生代划分为Eden(伊甸园) 与2块Survivor Space(幸存者区) ,然后在Eden –>Survivor Space 以及From Survivor Space 与To Survivor Space 之间实行Copying 算法。 不过jvm在应用coping算法时,并不是把内存按照1:1来划分的,这样太浪费内存空间了。一般的jvm都是8:1。也即是说,Eden区:From区:To区域的比例始终有90%的空间是可以用来创建对象的,而剩下的10%用来存放回收后存活的对象。
1、当Eden区满的时候,会触发第一次young gc,把还活着的对象拷贝到Survivor From(S0)区;当Eden区再次触发young gc的时候,会扫描Eden区和From(S0)区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To(S1)区域,并将Eden和From(S0)区域清空。
2、当后续Eden又发生young gc的时候,会对Eden和To(S1)区域进行垃圾回收,存活的对象复制到From(S0)区域,并将Eden和To(S1)区域清空。
3、可见部分对象会在From(S0)和To(S1)区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代
注意: 万一存活对象数量比较多,那么To域的内存可能不够存放,这个时候会借助老年代的空间。
举个案例我们来说明一下,假如我们创建一些对象如:
User user1 = new User();
User user2 = new User();
user1、user2这些刚刚创建的对象会存放在Eden区
如果垃圾回收机制发现user1、user2经常被使用,那么user1、user2会升级到S0区。
如果这个时候又创建了一个user3对象,则user3放在eden区,user1,user2还在S0区;
如果垃圾回收机制发现user3也经常被使用,user3也会被升级到S0区;
如果这时候垃圾回收机制发现user1不使用了,user2,user3还在经常的被使用,这时候user2、user3会直接复制到S1区,S0区整个被清除。
如果这时候我们又创建了一个user4,垃圾回收机制发现user4对象也经常被使用,这时候user4会被放到S1区
为什么user4对象不放到S0区,而是放到S1区呢?我们只要注意如下两点:
1.S1=S0,他们大小相等,主要是为了存放对象
2.S1与S0不管什么时候,只有一个在存放对象,另一个是空的,主要就是为了复制
如果这时候垃圾回收机制发现只有user4,、user2经常被使用,user3已经不用了,那么user4、user2会被复制到S0区,整个S1区会被删除。
如果这时候垃圾回收机制发现user4还在被使用,并在S0和S1区达到了一定的次数(默认15次)以上,而user2不在使用了,这时候user4会升级到老年区,user2被删除
复制算法的执行过程如下图:
优缺点
优点:能够解决碎片化问题,快速,清理干净。
缺点:浪费空间
注意:如果创建的新对象在Eden区,没有使用,会被直接删除,不会进入S0区和S1区。
2.4.3 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是
直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
“标记-整理”算法的执行过程如下图:
2.4.4分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最合适的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-整理”算法进行回收
2.4.5总结
标记—清除算法 | 复制算法 | 标记—压缩算法 | |
速度 | 中等 | 最快 | 最慢 |
空间开销 | 少(堆积碎片) | 通常需要两倍的空间 | 少 |
移动对象 | 否 | 是 | 是 |
由表格可知:
- 标记——清除算法由于速度效率不高且会产生内存碎片,在实际中也很少被垃圾收集器使用。
- 而复制算法由于它的效率较高,在经常发生GC的新生代区应用是个不错的选择,因为在新生代的存活对象一般不多,对于空间的需求不高,而且还可以通过对象提升把对象放入老年代。
- 而标记压缩算法则可以应用在GC不那么频繁的老年代,虽然效率较低,但由于GC的次数没有那么频繁、同时由于可以进行内存碎片的整理,也有利于老年代的大对象的存放。
- 没有绝对优秀的算法,只有最适合的算法,所以具体使用需要根据业务的需求而定。