对于Java开发者来说,JVM是绕不过去的一道坎,要想对Java这门语言有更深的理解,就必须去理解Java的内存的管理机制,理解Java是如何做好内存管理和垃圾回收的,同时这也是面试的热点之一,本文将分为JVM内存的划分,垃圾收集算法的原理,内存分配与回收策略三个部分进行阐述,构建一个初步的JVM的知识体系结构.
JVM内存的划分的目的是为了更好的分配内存和更好的回收内存;java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据;如下图所示
程序计数器(Program Counter Register)是一块较小的内存空间,可看作是当前线程所执行的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器完成
java虚拟机栈(Java Virtual Machine Stacks) 也是线程私有的,它的生命周期与线程相同.虚拟机栈描述的是java方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表,操作数栈,动态链接,方法出口等信息每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程
虚拟机栈的局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,long,double,float),对象引用(reference类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他于此对象相关的位置),以及returnAddress类型(指向了一条字节码指令的地址);
在这个区域规定了两种异常状况: 如果线程请求的栈的深度大于虚拟机所允许的深度,将抛出StackOverflowError异常; 如果虚拟机动态扩展无法申请到足够的内存,就会抛出OutOfMemeoryError异常.
本地方法栈(Native Method Stack)与虚拟机所发挥的作用非常相似的,虚拟机栈执行Java方法,而本地方法栈是为Native方法服务的,在虚拟机规范中对本地方法栈中使用的语言,使用方式与数据结构并没有强制规定,因此虚拟机可自由实现,甚至有的虚拟机(如Sun HotSpot虚拟机)直接把本地方法栈和虚拟机栈合二为一; 与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutMemoryError异常
对大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建,目的是存放对象实例,几乎所有的对象实例和数组都在堆上分配;Java堆是垃圾收集管理器的主要区域,也称为"GC堆";
从内存回收的角度,Java堆可以细分为: 新生代和老年代;进一步可细分为Eden空间,From Surrivor空间,ToSurvivor空间等;
从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB). 无论如何划分,都与存放的内容无关,无论哪个区域,存储的都仍然是对象实例,划分的目的是为了更好的回收内存或者更快的分配内存
方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据,Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),与Java堆区分开来;
早期的HotSpot虚拟机把GC分代收集扩展至方法区,使用永久代实现方法区,然而在其他虚拟机上(BEA JRockit)不存在永久代的概念,但是这样这样的实现方式更容易遇到内存溢出问题;已经放弃了永久代逐步改为采用Native Memory来实现方法区,现在在发布的JDK1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出了.
方法区和java堆一样不需要连续的内存和可以选择固定大小或者可扩展,还可选择不实现垃圾收集,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载;
运行时常量池是方法区一部分,Class文件中除了有类的版本,.字段,方法,接口等描述信息外,还有常量池信息,存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区运行时常量池中存放
运行时常量池具备动态性,并不要求常量一定只有在编译期才能产生,运行期间可能将新的常量放入池中,如String 类的intern()方法,运行时常量池是方法区的一部分,当常量池无法再申请到内存时就会抛出OutOfMemoryError异常
垃圾收集需要考虑到3个问题:
理解GC的原理有助于排查各种内存溢出,内存泄漏问题;程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已经知道了,因此这几个区域的内存分配和回收都有确定性,因为方法结束或线程结束时,内存自然就跟随者回收了,而Java堆和方法区不一样,一个接口中的多个实现类需要内存不一样,一个方法中的多个分支需要的内存也可能不一样,这部分内存的回收分配都是动态的,垃圾收集器所关注的也是这部分内存;
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的
优点: 实现简单,判断效率高
缺点是很难解决对象之间的相互循环引用的问题,如objA和objB都有字段instance,令objA.instance=objB及objB.instance=objA,除此之外两个对象无任何引用,故主流的Java虚拟机没有选用引用计数法管理内存;
public class ReferenceCountingGc {
public Object instance = null;
private static final int _1MB = 1024*1024;
private byte[] bigSize = new byte[2*_1MB];
public static void testGC() {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
//假设在这行发生GC,objA和objB能否被回收
System.gc();
}
}
通过一系列称为"GC Roots" 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可用
优点: 解决了对象间相互循环引用的问题
要宣告一个对象死亡, 至少要经历两次标记过程: 若对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法.当对象没有覆盖finalize()或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为"没有必要执行".
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的,低优先级的Finalizer线程去执行它,这里执行是指虚拟机会触发这个方法,但是并不会等待它运行结束,防止一个对象在finalize()方法中执行缓慢或者发生了死循环,很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃,finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果此时该对象与引用链上的任何 一个对象建立关联即可拯救自己,如通过this关键字把自己赋值给某个类变量或成员变量,那在第二次标记时它将被移出"即将回收"的集合;如果对象此时还没有逃脱,基本上它就真的被回收了
package com.zach.jvm;
/**
* @Auther: Zach
* @Date: 2019/6/7 15:36
* @Description:
*/
public class FinalizeEscapeGC {
//对象第一次成功拯救自己
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive(){
System.out.println("yes, i am still alive: ");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//finalize方法优先级很低,故暂停0.5秒等待它
Thread.sleep(500);
if(SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
}else {
System.out.println("no,i am dead:");
}
//重复上面的代码,这次自救失败了
SAVE_HOOK = null;
System.gc();
//finalize方法优先级很低,故暂停0.5秒等待它
Thread.sleep(500);
if(SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
}else {
System.out.println("no,i am dead:");
}
}
}
1. 强引用
在程序代码之中普遍存在的,类似"Object obj = new Object()" 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
2. 软引用
软引用是用来描述一些有用但并非必需的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常,在JDK1.2之后,提供了SoftReference类来实现软引用
3. 弱引用
也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,在JDK1.2之后,提供了WeakReference类来实现弱引用
4. 虚引用
最弱的一种引用关系,一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例,为对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知,JDK1.2之后,提供了PhantomReference类来实现虚引用
永久代的垃圾收集主要回收废弃常量和无用的类,回收废弃常量与回收Java堆中的对象非常类似;判定一个常量是否是废弃常量;既没有任何地方引用这个常量这时发生内存回收,常量就会被系统清理出常量池;
判定一个类是否是"无用的类"的需要满足的条件有:
该类所有的实例都已经被回收了,也就是Java堆中存在该类的任何实例
加载该类的ClassLoader已经被回收
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
虚拟机可以对满足上述3个条件的无用类进行回收,但不是和对象一样,不使用了就必然会回收;
1) 标记--清除算法
算法分为标记和清除两个阶段: 首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了;
缺点:
标记和清除的两个过程效率都不高;
标记出清楚之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序在运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
2) 复制算法
其思想是将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶的指针,按顺序分配内存即可,实现简单,运行高效,代价是将内存缩小为原来的一半,有点高;
现在的商用虚拟机都采用这种算法来回收新生代;新生代中对象98%是"朝生夕死",并不需要按照1:1的比列来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Surrivor空间,每次使用Eden和其中一块Survivor,当回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块 Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间,HotSpot虚拟机默认Eden和Survivor的大小比例是8:1.即每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被"浪费",但是无法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保;就是说内存如果另外一块的Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代
3) 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率变低所以根据老年代的特点,提出了标记整理算法,标记的过程与"标记-清除"算法一样,但是后续的步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
对象内存分配,基本上就是在堆上分配(也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下也可能会直接分配在老年代中,分配的规则不是100%固定,受垃圾收集器组合和虚拟机与内存相关的参数设置的影响 .
大多数情况下,对象在新生代Eden区分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC. 虚拟机提供-XX:+PrintGCDetails收集器日志参数,让虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况
大对象是指需要大量连续内存空间的Java对象,如那种很长串字符串和数组,大对象对虚拟机的内存分配来说内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来"安置"它们; 虚拟机提供了-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,目的是避免在Eden区及两个Survivor区之间发生大量的内存复制
虚拟机采用分代收集的思想管理内存,为了做到内存回收时识别哪些对象在新生代,哪些对象在老年代,虚拟机给每个对象定义了一个对象年龄(Age)计数器.如果对象在Eden出生并经过第一次Minor GC 后仍然存活,并且能被Survivor容纳,将被移动到Survivor空间中,并且对象的年龄设为1,当年龄增加到一定的程度(默认是15岁),就将会被晋升到老年代中,对象晋升老年代的年龄阀值,可以通过参数--XX:MaxTenuringThreshold设置
为了适应不同程序内存状况,虚拟机并不是永远要求对象的年龄必须达到了MaxTenuring Threshold才能晋升老年代,如果在Survivor空间中相同年龄所有的对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTrnuringThreshold中要求的年龄
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续内存空间是否大于所有对象总空间,如果是,那么Minor GC 可以确保是安全的,如果不成立,则虚拟机hi查看HandlePromotionFailure设置的值是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续内存空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试一次Minor GC,如果小于,或者HandlePromotionFailure设置不允许冒险,则要改为一次Full GC;
《深入理解Java虚拟机: JVM高级特性与最佳实践》