JVM之自动内存管理机制

注:以下内容皆基于JDK1.7

一. java内存区域

运行时数据区域
  java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干不同的数据区域. 这些区域都各有用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁. 主要包括以下几个区域:

  • 程序计数器: 它可以看做是当前线程所执行的字节码的行号指示器. 在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都要依赖这个计数器来完成. 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,独立存储. 如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码执行令的地址;如果正在执行的是Native方法,这个计数器值则为空. 此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域.
  • java虚拟机栈: 它也是线程私有的. 虚拟机栈描述的是java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息. 局部变量表存放了编译器可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型)和returnAddress类型(指向了一条字节码指令的地址). 局部变量表所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存就会抛出OutOfMemoryError异常.
  • 本地方法栈: 与虚拟机栈作用相似,同样也是线程隔离的. 区别是本地方法栈是为虚拟机使用到的Native方法服务,在Sun HotSpot虚拟机就直接把本地方法栈和虚拟机栈合二为一. 此区域也会抛出StackOverflowError和OutOfMemoryError异常.
  • java堆: 它适合管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建. 唯一目的是存放对象实例,几乎所有的对象实例都在这里分配内存(也有可能栈上分配). java堆是垃圾收集器管理的主要区域,由于现在收集器基本都采用分代收集算法,所以堆还可以细分为:新生代和老年代,在细点有:Eden空间,From Survivor空间,To Survivor空间. 堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可. 如果堆中没有内存可以完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常.
  • 方法区: 和堆一样是各个线程共享的内存区域. 用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据. 此区域内存回收目标主要是针对常量池的回收和对类型的卸载. 运行时常量池是方法区的一部分,Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放,这个具备动态性,运行期间也可以将新的常量放入池中. 当无法申请到内存时也会抛出OutOfMemoryError异常.
  • 另外,还有一个直接内存,直接内存并不属于虚拟机运行时数据区的一部分,但是这一部分内存也频繁的使用,也会出现OutOfMemoryError异常. 在JDK1.4中新加入了NIO类,引入一种基于通道channel与缓冲区buffer的I/O方法,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,提高了性能,因为避免了在java堆和Native堆中来回复制数据.

  最后提一嘴内存溢出异常(OOM),对于java堆溢出,要判断是内存泄漏还是内存溢出; 对于虚拟机栈和本地方法栈,在单个线程下,无论是由于栈帧太小还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常,每个线程分配到的栈容量越大,可以建立的线程数就越少,如果是建立过多线程导致内存溢出,在不能减少线程数或者更换64位虚拟机情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程; 对于方法区,在对类进行增强时,都会使用到CGLib这类字节码增强技术,增强的类越多就需要越大的方法区来保证动态生成的class可以加载到内存,此外还有在大量JSP文件的应用(JSP第一次运行时需要编译为java类)或者基于OSGi的应用下(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等需要注意.

二. 垃圾收集器与内存分配策略

  上个部分介绍了java内存运行时区域,其中程序计数器,虚拟机栈,本地方法栈3个区域随线程而生灭,因此这几个区域的内存分配和回收都是具备确定性的. 而java堆和方法区不一样,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不同,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存分配和回收都是动态的,垃圾收集器所关注的就是这部分内存. 本文后面中的"内存"分配和回收也仅指这一部分内存.
如何判断对象死活?

  • 引用计数算法: 给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加一,当引用失效时,计数器值减一,任何时刻计数器为0的对象就是不可能再被使用的. 优点是实现简单,判定效率也高. 但是主流虚拟机却没有选用这个方法,主要因为它很难解决对象之间互相循环引用的问题.
  • 可达性分析算法: 通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的. java中可作为GC Roots的对象包括:虚拟机栈栈帧中的本地变量表中所引用的对象; 本地方法栈中Native方法引用的对象; 方法区中常量引用的对象; 方法区中类静态属性引用的对象.

  上面两种方式判定对象是否存活都与"引用"有关. 在JDK1.2之后,java对引用概念进行了扩充,4中引用强度高到低依次是: 强引用,类似于new对象引用,只要引用还在,垃圾收集器就永远不会回收掉被引用对象; 软引用,描述一些有用但非必需的对象,空间足够就不回收,对象就可以被使用; 弱引用,描述非必需对象,无论空间是否足够都会回收掉,弱引用可和一个引用队列联合使用,若其所引用的对象被回收,这个若引用就会被加入与之关联的引用引用队列中; 虚引用,此引用不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知,必须和引用队列关联使用,为了记录.
  其实在可达性分析算法中不可达的对象也并非是死亡的对象,要真正宣告一个对象死亡,要至少经历两次标记过程.即1.可达性分析 2.是否有必要执行finalize()方法,标记一 3.是否在方法中拯救了自己,标记二. 说明:如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那么它就会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法或者改方法已经被虚拟机调用过,则虚拟机都会视为"没有必要执行". 如果有必要执行,那么GC将对进行第二次标记,如果对象在该方法中拯救了自己(重新与引用链上的任何一个对象建立关联即可),那在第二次标记时它将被移除出"即将回收"的集合,如果对象这个时候还没有逃脱,那基本上就真的被回收了.
回收方法区
  主要回收两部分:废弃常量和无用的类. 回收常量和回收堆中的对象类似,以常量池中字面量为例,就是没有任何String对象引用常量池中的"abc"常量,也没有其他地方引用这个字面量,这时如果发生内存回收,而且必要的话,这个"abc"常量就会被系统清理出常量池. 如果要判断一个类是否是"无用的类",需要同时满足以下3个条件: 1.该类的所有实例都已被回收 2.加载该类的ClassLoader已经被回收 3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法. 满足以上条件只是可以进行回收,而不是必然会回收. 在大量使用反射,动态代理,CGLib等框架,动态生成JSP等这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证方法区不会溢出.
垃圾收集算法

  • 标记-清除算法: 是最基础的收集算法,有两个缺点,一个是效率问题,另一个是空间问题,标记清除后会产生大量不连续的内存碎片,导致需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集.
  • 复制算法: 它将可用内存按容量划分成大小相等的两块,每次只使用一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后把使用过的内存空间一次清理掉. 这样只要移动堆顶指针,按顺序分配内存即可. 实现简单,运行高效,只是代价是将内存缩小了一半. 该算法主要来回收新生代,因为绝大多数对象是"朝生夕死"的,所以并不需要按照1:1来分配内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一个Survivor. 当回收时,将Eden和Survivor中还存活的对象一次性地复制到另一块Survivor空间上. HotSpot虚拟机默认Eden和Survivor的大小比例是8:1. 当Survivor空间不够时,需要依赖老年代进行分配担保,这些对象将直接进入老年代.
  • 标记-整理算法: 根据老年代特点,对象存活率较高,如果还用复制算法,效率就较低. 此算法和标记-清除算法很像,不一样的是让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存.

注:根据分代收集算法,根据对象存活周期不同,一般将java堆分为新生代和老年代. 新生代用复制算法. 老年代用标记-清除或标记-整理算法.
垃圾收集器
  如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现. 先看下图有个总体概念再来分别详细介绍.
HotSpot虚拟机的垃圾收集器

  • serial收集器: 它是最早最基本的收集器. 这个收集器是一个单线程收集器,是串行的. 它的"单线程"不仅意味着他只会使用一个CPU或一条收集线程去完成收集工作,更重要的是在它进行垃圾回收时,必须暂停其他所有的工作线程,直到它收集结束. 对于限定单个CPU的环境来说,serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率,所有对于运行在client模式下的虚拟机来说是一个很好的选择.
  • serial old收集器: 它是serial的老年代版本,同样是一个单线程收集器,使用标记-整理算法.这个收集器的主要意义也是给client模式下虚拟机使用.
    以上两个收集器运行示意图
  • parnew收集器: 它其实就是serial收集器的多线程版本,在实现上两者共用了很多代码.它是运行在server模式下的虚拟机中首选的新生代收集器,一个很重要原因是除了serial外只有它可以与CMS收集器配合工作. 它默认开启的收集线程数与CPU数量相同,也可以使用-XX:ParallelGCThreads参数来限制线程数.
    parnew运行示意图
  • parallel scavenge收集器: 和parnew一样是多线程并行的收集器. 它的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程停顿时间,而它的目标是达到一个可控制的吞吐量. 所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值. 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务. 牺牲吞吐量和新生代空间可以缩短GC停顿时间. 另外自适应调节策略也是parallel scavenge收集器与parnew收集器的一个重要区别,所谓GC自适应的调节策略就是虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或最大的吞吐量.
  • parallel old收集器: 它是上一个收集器的老年代版本,使用标记-整理算法. 就是为了和parallel scavenge收集器配合使用. 使用在注重吞吐量和CPU资源敏感的场合.
    并行回收器的新老代收集器运行示意图
  • CMS收集器: 它是一种以获取最短回收停顿时间为目标的收集器. CMS(concurrent mark sweep)收集器基于标记-清除算法实现,整个过程分为4步:初始标记,并发标记,重新标记,并发清除. 初始标记和重新标记仍然要"stop the world". 初始标记仅仅只是标记GC Roots能直接关联到的对象,速度很快,并发标记就是进行GC Roots Tracing的过程,时间很长,重新标记是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录. 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以和用户线程一起工作,所以总体上说,CMS收集器的内存回收过程是与用户线程一起并发执行的. 优点:并发收集,低停顿. 缺点:对CPU资源非常敏感,在并发阶段会占用一部分线程而导致应用程序变慢;无法处理浮动垃圾,由于并发清除阶段用户线程还在运行,伴随着就会有新的垃圾产生,CMS只能等到下次才能清理掉;还有一个就是它基于标记-清除,自然就会有内存碎片,虽然提供了参数解决方案,但是在内存整理过程中是无法并发的,导致停顿时间变长.
    CMS收集器运行示意图
  • G1收集器: 它是一款面向服务端应用的垃圾收集器. 与其他收集器相比,它具有以下特点:并行与并发;分代收集,G1不需要其他收集器就能独立管理整个GC堆;空间整合,G1从整体上看基于标记-整理算法,从局部上(两个region之间)看是基于复制算法实现;可预测的停顿,可以控制停顿时间. G1在堆的内存布局上与其他收集器不同,它将整个堆划分为多个大小相等的独立区域(region),新生代与老年代不再是物理隔离的了,他们都是一部分region(不需要连续)的集合. G1之所以可以建立可预测的停顿时间模型,因为它可以有计划的避免在整个堆中进行全区域的垃圾收集,G1跟踪各个region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的region(这就是G1名字的由来),使用这种方式保证了G1收集器在有限的时间内可以获取尽可能高的收集效率. G1收集器运作大致可划分为以下步骤:初始标记,并发标记,最终标记,筛选回收. 步骤和CMS类似,前三步基本上一样的效果,最后在筛选回收阶段首先对各个region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到和用户程序一起并发执行,但是因为只回收一部分region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率.
    G1收集器运行示意图
    内存分配与回收策略
  • 对象优先在Eden分配: 大多数情况下,对象在新生代Eden区分配,当Eden中没有足够空间时,虚拟机将发起一次Minor GC.
  • 大对象直接进入老年代: 大对象(需要大量连续内存空间的java对象)大于虚拟机设置的参数值时直接在老年代分配,这样做目的是避免在Eden区及两个Survivor区之间发生大量的内存复制. 而这个参数只对serial和parnew有效,parallel scavenge不认识这个参数.
  • 长期存活的对象将进入老年代: 虚拟机给每个对象定义了一个对象年龄计数器. 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移到Survivor空间中,并且对象年龄设为1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当他的年龄增加到一定程度(默认15岁),就将会被晋升到老年代中.
  • 动态对象年龄判定: 虚拟机并不是永远地要求对象的年龄必须达到了某值才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代中.
  • 空间分配担保: 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果成立,那么Minor GC可以确保是安全的,如果不成立,虚拟机会查看设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的,如果小于,或者设置值不允许冒险,那这时就要改为进行一次Full GC.
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值