【声明】
欢迎转载,但请保留文章原始出处→_→
文章来源:【http://www.cnblogs.com/smyhvae/p/4748392.html】
文章来源:【http://www.cnblogs.com/smyhvae/p/4810168.html】
【正文】
声明:本文只针对Java虚拟机的内存划分、以及垃圾回收机制的一些总结,对于有可能涉及到Java中的四中引用状态、累的加载机制这里不做探讨。
先把本文的目录画一个思维导图:
一、Java中的内存划分:
Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,就对数据进行了不同空间的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
具体划分为如下5个内存空间:(非常重要)
1、Java虚拟机栈内存(线程私有):
栈内存为虚拟机执行java方法服务:方法被调用时创建栈帧-->局部变量表->局部变量、对象引用,
局部变量表存放了基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(slot),其余数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配。
上方代码中,静态方法有6个形参,实例方法有3个形参。其对应的局部变量表如下:
每个方法被调用的时候都会创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息。局部变量表存放的是:编译期可知的基本数据类型、对象引用类型。
每个方法被调用直到执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
在Java虚拟机规范中,对这个区域规定了两种异常情况:
(1)如果线程请求的栈深度太深,超出了虚拟机所允许的深度,就会出现*Error(比如无限递归。因为每一层栈帧都占用一定空间,而 Xss 规定了栈的最大空间,超出这个值就会报错)
(2)虚拟机栈可以动态扩展,如果扩展到无法申请足够的内存空间,会出现OOM
2、堆内存(线程共享):
存放所有new出来的对象,以及数组
(1)堆是java虚拟机所管理的内存区域中最大的一块,java堆是被所有线程共享的内存区域,在java虚拟机启动时创建,堆内存的唯一目的就是存放对象实例几乎所有的对象实例都在堆内存分配。
(2)堆是GC管理的主要区域,从垃圾回收的角度看,由于现在的垃圾收集器都是采用的分代收集算法,因此java堆还可以初步细分为新生代和老年代。
(3)Java虚拟机规定,堆可以处于物理上不连续的内存空间中,只要逻辑上连续的即可。在实现上既可以是固定的,也可以是可动态扩展的。如果在堆内存没有完成实例分配,并且堆大小也无法扩展,就会抛出OutOfMemoryError异常。
3、方法区(线程共享):
存放被存jvm加载的类的信息,静态变量,常量,静态常量、静态方法、(static或final修饰的变量、方法)
(1)用于存储已被虚拟机加载的类信息、常量、静态变量、静态常量、静态方法、即时编译器编译后的代码等数据。
(2)Sun HotSpot虚拟机把方法区叫做永久代(Permanent Generation),方法区中最终要的部分是运行时常量池。
4、本地方法栈:
为虚拟机执使用到的Native方法服务
(1)本地方法栈与java虚拟机栈作用非常类似,其区别是:java虚拟机栈是为虚拟机执行java方法服务的,而本地方法栈则为虚拟机执使用到的Native方法服务。
(2)Java虚拟机没有对本地方法栈的使用和数据结构做强制规定,Sun HotSpot虚拟机就把java虚拟机栈和本地方法栈合二为一。
(3)本地方法栈也会抛出*Error和OutOfMemoryError。
5、程序计数器(线程私有):
每个线程拥有一个程序计数器,在线程创建时创建,指向下一条指令的地址执行本地方法时,其值为undefined
说的通俗一点,我们知道,Java是支持多线程的,程序先去执行A线程,执行到一半,然后就去执行B线程,然后又跑回来接着执行A线程,那程序是怎么记住A线程已经执行到哪里了呢?这就需要程序计数器了。因此,为了线程切换后能够恢复到正确的执行位置,每条线程都有一个独立的程序计数器,这块儿属于“线程私有”的内存。
6、常量池:
Java中的常量池,实际上分为两种形态:静态常量池和运行时常量池,当常量池无法再申请到内存时就会抛出OutOfMemoryError异常(注:基本数据类型中的flat、doublell类型的包装类,并没有实现常量池)。
静态常量池:
即class文件中的常量池,静态常量池不仅仅包含了字符串,还包含了类、方法的信息、占用class文件绝大部分空间。这种常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References),字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
(1)类和接口的全限定名
(2)字段名称和描述符
(3)方法名称和描述符
运行时常量池:
指jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池
运行时常量池相对于CLass文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。
Java对象被创建后,如果被一个或多个变量引用,那就是可达的。即从根节点可以触及到这个对象。
其实就是从根节点扫描,只要这个对象在引用链中,那就是可触及的。
2、可恢复的:
Java对象不再被任何变量引用就进入了可恢复状态。
在回收该对象之前,该对象的finalize()方法进行资源清理。如果在finalize()方法中重新让变量引用该对象,则该对象再次变为可达状态,否则该对象进入不可达状态
3、不可达的:
Java对象不被任何变量引用,且系统在调用对象的finalize()方法后依然没有使该对象变成可达状态(该对象依然没有被变量引用),那么该对象将变成不可达状态。
当Java对象处于不可达状态时,系统才会真正回收该对象所占有的资源。
三、判断对象死亡的两种常用算法:
在Java中,当对象不被引用的时候,这个对象就是死亡的,等待GC进行回收。
1、引用计数算法:
概念:
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
但是:
主流的java虚拟机并没有选用引用计数算法来管理内存,其中最主要的原因是:它很难解决对象之间相互循环引用的问题。
优点:
算法的实现简单,判定效率也高,大部分情况下是一个不错的算法。很多地方应用到它
缺点:
引用和去引用伴随加法和减法,影响性能,致命的缺陷:对于循环引用的对象无法进行回收
2、根搜索算法:(jvm采用的算法)
概念:
设立若干种根对象,当任何一个根对象(GC Root)到某一个对象均不可达时,则认为这个对象是可以被回收的。
注:
这里提到,设立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是可以被回收的。我们在后面介绍标记-清理算法/标记整理算法时,也会一直强调从根节点开始,对所有可达对象做一次标记,那什么叫做可达呢?
可达性分析:
从根(GC Roots)的对象作为起始点,开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连(用图论的概念来讲,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
如上图所示,ObjectD和ObjectE是互相关联的,但是由于GC roots到这两个对象不可达,所以最终D和E还是会被当做GC的对象,上图若是采用引用计数法,则A-E五个对象都不会被回收。
根(GC Roots):
说到GC roots(GC根),在JAVA语言中,可以当做GC roots的对象有以下几种:
(1)、栈(栈帧中的本地变量表)中引用的对象。
(2)、方法区中的静态成员。
(3)、方法区中的常量引用的对象(全局变量)
(4)、本地方法栈中JNI(一般说的Native方法)引用的对象。
注:第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值。
在根搜索算法的基础上,现代虚拟机的实现当中,垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法。这三种算法都扩充了根搜索算法,不过它们理解起来还是非常好理解的。
四、垃圾回收的算法:
1、标记-清除算法:
概念:
标记阶段:先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;
清除阶段:清除所有未被标记的对象。
缺点:
标记和清除的过程效率不高(标记和清除都需要从头遍历到尾)
标记清除后会产生大量不连续的碎片。
2、复制算法:(新生代的GC)
概念:
将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,然后清除正在使用的内存块中的所有对象。
优点:
这样使得每次都是对整个半区进行回收,内存分配时也就不用考虑内存碎片等情况
只要移动堆顶指针,按顺序分配内存即可,实现简单,运行效率高
缺点:空间的浪费
从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行。
现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。
3、标记-整理算法:(老年代的GC)
复制算法在对象存活率高的时候要进行较多的复制操作,效率将会降低,所以在老年代中一般不能直接选用这种算法。
概念:
标记阶段:先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象
整理阶段:将将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间
优点:
不会产生内存碎片。
缺点:
在标记的基础之上还需要进行对象的移动,成本相对较高,效率也不高。
它们的区别如下:(>表示前者要优于后者,=表示两者效果一样)
(1)效率:复制算法 > 标记/整理算法 > 标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
(2)内存整齐度:复制算法=标记/整理算法>标记/清除算法。
(3)内存利用率:标记/整理算法=标记/清除算法>复制算法。
注1:标记-整理算法不仅可以弥补标记-清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
注2:可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。
注3:时间与空间不可兼得。
4、分代收集算法:
当前商业虚拟机的GC都是采用的“分代收集算法”,这并不是什么新的思想,只是根据对象的存活周期的不同将内存划分为几块儿。一般是把Java堆分为新生代和老年代:短命对象归为新生代,长命对象归为老年代。
存活率低:少量对象存活,适合复制算法:在新生代中,每次GC时都发现有大批对象死去,只有少量存活(新生代中98%的对象都是“朝生夕死”),那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成GC。
存活率高:大量对象存活,适合用标记-清理/标记-整理:在老年代中,因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-清理”/“标记-整理”算法进行GC。
注:老年代的对象中,有一小部分是因为在新生代回收时,老年代做担保,进来的对象;绝大部分对象是因为很多次GC都没有被回收掉而进入老年代。