您好,欢迎来到百家汽车网。
搜索
您的当前位置:首页java虚拟机详解

java虚拟机详解

来源:百家汽车网
本文主要是基于 周志明的《深入理解JAVA虚拟机》一书,感兴趣的朋友可以去看一看。鉴于篇幅关系,本文只涉及JAVA内存管理,垃圾回收。

垃圾回收是一把双刃剑,在C++中,程序员要负责每一个对象的内存回收。Java的垃圾回收器 可以帮我们解决这个问题,但正是由于JAVA程序员把内存控制的权利交给JAVA虚拟机,一旦出现内存泄露和内存溢出方面的问题,如果不了解虚拟机怎样使用内存,排查错误将会异常艰难。 1. JAVA虚拟机对它管理的内存 划分

Java虚拟机在执行JAVA程序的过程中,会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间。有的区域随着虚拟机进程的启动而存在,有的区域是依赖用户线程的启动和结束而建立和销毁。

运行时数据区

图 1 : Java虚拟机运行时数据区 (颜色部分为所有线程共享的数据区)  程序计数器

程序计数器是一块较小的内存区域,它的作用可以看成是当前线程所执行字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。

执行引擎 本地库接口 本地方法库 堆 程序计数器 方法区 虚拟机栈 本地方法栈 由于JAVA虚拟机的多线程是通过线程轮流切换并分配处理器时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的位置,每条线程都需要有一个的程序计数器,各条线程之间的程序计数器互不影响,存储,所以上面图的程序计数器区域 属于线程私有的。

程序计数器(Native方法除外,此时计数器值为空)记录的是正在执行的虚拟机字节码指令的地址。此区域是唯一一个在JAVA虚拟机规范中没有规定任何OutOfMemeoryError的区域。  JAVA虚拟机栈

与程序计数器一样,也是线程私有的。虚拟机栈描述的是JAVA方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧,用于存储 局部变量表,操作数表,动态链接,方法出口等信息。每个方法被调用直至完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。 局部变量表存放了编译期可知的各种基本数据类型

(boolean,byte,char,short,int,float,double),对象引用和return Address类型。其中位的double和long会占用2个局部变量空间,其余的数据类型只占一个。局部表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在JAVA虚拟机中,对这个区域规定了两种异常状况:如果线程所请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分虚拟机都支持),当扩展是无法申请到足够的内存时会抛出OutOfMemoryError异常。  本地方法栈

与虚拟机栈类似,只不过虚拟机栈为虚拟机执行JAVA方法服务,而本地方法栈则是为了虚拟机使用到的Native方法服务。  Java堆

Java堆是JAVA虚拟机所管理的内存中最大的一块,是被所有线程共享的区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象都在这里分配。

因此,Java堆就是垃圾回收器管理的主要区域,也被称为“GC堆”。

目前主流的虚拟机都支持堆扩展,可以通过-Xmx和-Xms来控制。如果在堆中没有内存来完成实例分配,将会抛出OutOfMemoryError异常。  方法区

方法区也是被线程共享的,它用于存储已经被虚拟机加载的类信息,常量,静态常量,即时编译期编译后的代码等数据。

很多人愿意把方法区称为永久代,本质上二者并不等价。仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA Jrockit,IBM J9)来说是不存在永久代的概念的。即使HotSpot虚拟机本身,现在也有放弃永久代并“搬家”到Native Memory 来实现方法区的规划了。

Java虚拟机规范对这个区域的非常宽松,除了和Java堆一样可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的。这个主要是由于方法区存储的内容有关,比如类信息,静态常量一般不需要垃圾回收。 但并非数据进入了方法区就如永久代的名字一样永远存在了,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,只不过回收条件要比JAVA堆中的苛刻的多,效果也难以令人满意。

在方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。

在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)和缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在JAVA堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中提高性能,因为避免了在Java堆和Native堆中来回复制数据。

所以在设计Java堆内存时,如果存在直接内存,要确保二者的和不能超过机器的物理内存。 2. 判定一个对象已经死亡

垃圾回收主要针对JAVA堆和方法区,回收死亡(不可能再被任何途径使用的对象)的对象,先看看怎么判定一个对象已经死亡。  引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器都为0的对象就判定为死亡。

这种算法实现简单,Java并没有选用它,原因是:它很难解决对象之间相互循环引用的问题。  根搜索算法

通过一系列名为Gc Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到Gc Roots没有任何引用链相连,就证明此对象是不可用的:如下图:object5,object6,object7虽然互相有关联,但它们到Gc Roots是不可达的,所以它们被判定为死亡对象,可以被回收。

Object4 Object2 Object3 Object1 Object6 Object7 Gc Roots Object5 在根搜索算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索算法后发现没有与Gc Roots相连接的引用链,那么将会被第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。在第二次标记时此对象就被判了“死刑”。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被设置在一个名为F-Queue的队列之中,并在稍后有一条虚拟机自动建立的,低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它结束。这样做得原因是,如果一个对象在一个finalize()方法中执行缓慢,或者发生死循环,将很有可能会导致F-Queue中其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

finalize()方法是对象死里逃生的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功解救自己(只要重新与引用链上的任何一个对象建立关联即可,如把自己this赋给某个类变量或对象的成员变量),那么在第二次标记时它将被移出“及时回收”的集合;如果对象这个时候还没有逃脱,那么就真的离死不远了。  引用

判定对象是否存活和“引用”有关,JDK1.2之前,Java对引用的定义只有被引用和没被引用两种状态。很难描述像这样的场景:当内存空间还足够时,则保留在内存中;如果内存在进行垃圾回收之后还是非常紧张,则可以抛弃这些对象。

JDK1.2之后,对引用的概念进行了扩充,强度依次减弱:

强引用(Strong Reference):类似“Object obj = new Object()”这样的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。

软引用(Soft Reference):描述一些还有用,但并非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出之前,将把这些对象列入回收范围并进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

弱引用(Weak Reference):弱引用也是用来描述非必须对象的,但它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

虚引用(Phantom Reference):虚引用也成为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的:就是希望能在这个对象被垃圾回收时收到一个系统通知。 3. 方法区的回收

前面说过垃圾回收主要回收JAVA堆和方法区。很多人认为方法区(HotSpot虚拟机中的永久代,注意:永久代不是老年代,老年代在JAVA堆中)是没有垃圾

收集的,Java虚拟机规范中确实说过可以不收集方法区,而且在方法区中垃圾收集“性价比”比较低:一般堆中,尤其是新生代中,垃圾收集一般可回收70%--95%的空间,而永久代则远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

 回收废弃常量

与回收Java堆中的对象非常相似。假如一个字符串“abc”已经进入了常量池,但当前系统没有任何一个String对象是叫做“abc”的,也没有其他地方引用了这个常量,如果这时候发生内存回收,而且必要的话,这个“abc”常量将会被系统”请”出常量池。常量池中德其他类,接口,方法,字段的符号引用也与此类似。  回收无用的类

判定一个常量是否为“废弃常量”比较简单,但判定一个类是否无用的条件则相当苛刻。需要类同时满足下面三个条件:

一, 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例 二, 加载该类的ClassLoader已经被回收

三, 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方

反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说得仅仅是“可以”,而不是和对象一样,不用了就必然会回收。可以通过HotSpot的-Xnoclassgc进行控制。在大量使用反射,动态代理,CGLib等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。 4. 垃圾回收算法

一般将Java堆分为老年代和年轻代,这样划分是为了根据对象的特点选择不同的垃圾回收器以针对性的回收。比如-Xmx 20M,-Xms 20M, -Xmn12M,JAVA堆总共20M内存,给年轻代分了12M,那么老年代就是8M。由此得出Minor GC 和Major GC/Full GC的概念

新生代GC(Minor GC): 指发生在新生代的垃圾收集动作,因为java对象大多具有朝生夕死的特性,所有Minor GC非常频繁,一般回收速度也比较快。 老年代GC(Major GC或者Full GC): 指发生在老年代的GC,出现了Major GC 经常会伴随至少一次的Minor GC(但非绝对的,在ParallelScavenge收集器的收

集策略里就有直接进行Major GC的策略选择过程)。Major GC 的速度一般会比Minor GC 慢10倍以上。

 标记清除算法(Mark-Sweep)

它是最基础的收集算法,如它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收掉所有被标记过的对象。 后续的算法都是基于标记清除算法的,它有两个缺点:

一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后产生大量不连续的内存碎片,可能会导致当需要分配较大对象时无法找到足够的连续内存而不得不提前出发另一次垃圾回收动作。  复制算法(主要针对年轻代)

为了解决标记-清除算法的效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将存活的对象复制到另一块上去,然后再把已使用过的内存空间一次性清理掉。缺点是:内存缩小为原来的一半,代价很高。

现代商业虚拟机都采用这种算法回收新生代,IBM的专门研究表明,新生代的对象98%都是朝生夕死的,所以不需要按1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden区和其中的一块Survivor。当回收时,将Eden和刚才用过的Survivor中还存活的对象一次性拷贝到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。特殊情况:如果另一块Survivor区域不足以存放上次新生代收集下来的存活对象,那么将通过分配担保机制直接进入老年代。

HotSpot虚拟机默认Eden和Survivor的比例为8:1:1,这样可用年轻代可用内存达到90%。

 标记-整理算法(主要针对老年代)

复制算法在对象存活率高时,效率会变低,不适合老年代使用。而标记-整理算法的“标记”过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。  分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集“(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象的存活周期的不同将内存分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法。而老年代中因为对象存活率高,没有额外的空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来回收。 5. 垃圾回收器

HotSpot JVM1.6 的垃圾收集器

从图中看出新生代包括三种垃圾收集器:Serial,ParNew,Parallel Scavenge,新生代的三种收集器都采用复制算法(复制算法本来就适合新生代的对象回收);老年代也包括三种:CMS,Serial Old, Parallel Old;问号 是g1收集器,目前1.6只是适用版本,1.7可能会有一个成熟的商用版本。  Serial收集器

最基本,历史最悠久的收集器,它是一个单线程的收集器,更重要的是它进行垃圾回收时,必须暂停其他所有的工作线程(SUN将这件事情称之为Stop The World)

似乎Serial收集器老而无用,但实际上到现在为止,它依然是虚拟机运行在client模式下默认的新生代收集器。它的优势是:简单而高效(跟其他收集器单线程比)

 ParNew收集器

它是Serial收集器的多线程版本,除了使用多条收集线程外,其余行为包括算法、STW、对象分配规则、回收策略等都与Serial收集器一摸一样。ParNew收集器是许多运行在server模式下的虚拟机中首选的新生代收集器(一个原因是在除了serial收集器外,目前只有它能与CMS收集器配合使用)。  Parallel Scavenge收集器

Parallel Scavenge的特点是它的关注点与其他收集器不同,CMS等收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。也被称为吞吐量优先收集器。所谓吞吐量就是CPU用于运行用户代码时间与CPU总消耗时间的比值。吞吐量=运行用户代码时间/运行用户代码时间+垃圾收集时间。

高吞吐量和停顿时间短的策略相比,主要强调任务更快完成,适用于后台运算而不需要太多交互的任务;而后者强调用户交互体验。

Parallel Scavenge提供两个参数精确控制吞吐量,-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间和-XX:GCTimeRatio设置吞吐量大小 ,MaxGCPauseMillis允许的值是一个大于零的毫秒数,收集器将尽力保证内存回收话费的时间不超过设定值。GC停顿时间缩小是以牺牲吞吐量和新生代空间来换取的,也就是要使停顿时间更短,需要使新生代的空间减小,这样垃圾回收的频率会增加,吞吐量也降下来了。  Serial Old收集器

是Serial的老年代版本,同样是单线程收集器,使用标记-整理算法。主要是client模式下的虚拟机使用。参考上面图Serial/Serial old.

两大用途:在JDK1.5及之前的版本中与Parallel Scavenge搭配使用;作为CMS收集器的后备预案。在并发收集发生Concurrent Mode Failure时使用。  Parallel Old收集器

是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。在JDK1.6中才开始使用。由于之前的版本中,Parallel Scavenge只有使用Serial Old作为老年代收集器,其“吞吐量优先”的设计思路不能被很好的贯彻,在Parallel Old收集器出现后,“吞吐量优先”的收集器终于有了名副其实的应用组合。  CMS收集器(Concurrent Mark Sweep)

从名字上看它采用“标记-清除”算法。Concurrent Mark Sweep 以获取最短回收停顿时间为目标的收集器.

基于标记-清除算法实现,运行过程分成4个步骤:

a)初始标记(需要stop the world),标记一下GC Roots能直接关联到的对象,速度很快

b)并发标记,进行GC Roots Tracing的过程。

c)重新标记(需要stop the world),为了修正并发标记时用户继续运行而产生的标记变化,停顿时间比初始标记长,远比并发标记短。 d)并发清除 缺点:

CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是因为占用了一部分CPU资源而导致应用程序变慢,总吞吐量就会降低。CMS默认启动的回收线程数为(CPU数量+3)/4。为了解决这一情况,有一个变种i-CMS,但目前并不推荐使用。

CMS收集器无法处理浮动垃圾(floating garbage)。同样由于CMS GC阶段用户线程还需要运行,即还需要预留足够的内存空间供用户线程使用,因此CMS收集器需要预留一部分空间提供并发收集时的程序运作使用。默认设置下,CMS收集器在老年代使用了68%的空间后就会被激活。这个值可以用-XX:CMSInitiatingOccupancyFraction来设置。要是CMS运行期间预留的内存无法

满足程序需要,就会出现concurrent mode failure,这时候就会启用Serial Old收集器作为备用进行老年代的垃圾收集。

空间碎片过多(标记-清除算法的弊端),提供-XX:+UseCMSCompactAtFullCollection参数,应用于在FULL GC后再进行一个碎片整理过程。-XX:CMSFullGCsBeforeCompaction,多少次不压缩的full gc后来一次带压缩的。  g1收集器

G1收集器与前面的CMS收集器相比有两个显著的改进:一是G1收集器是基于“标记-整理”算法实现的收集器,也就是说它不会产生空间碎片,这对于长时间运行的应用系统来说非常重要。二是它可以非常精确地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

G1收集器可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收,这是由于它能够极力地避免全区域的垃圾收集,之前的收集器进行收集的范围都是整个新生代或老年代,而G1将整个Java堆(包括新生代、老年代)划分为多个大小固定的区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的来由)。区域划分及有优先级的区域回收,保证了G1收集器在有限的时间内可以获得最高的收集效率。 6. 内存分配

JAVA技术体系所提倡的自动内存管理最终可以归结于自动化的解决了两个问题:给对象分配内存以及回收分配给对象的内存。回收内存即上面讲到的垃圾回收,那又是怎么分配的呢?

对象的内存分配,往大的方向讲,就是分配在堆上(但也有可能经过JIT编译后被拆散为标量类型并间接地在栈上分配),对象主要分配在新生代的Eden区,少数情况下也可能会直接分配在老年代中(比如大对象),分配的规则并不是百分百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的配置。  对象优先在Eden区分配

大多数情况下,对象在新生代的Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC.

虚拟机提供了 –XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前内存各区域分配情况。  大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的JAVA对象,最典型的大对象就是那种很长的字符串及数组。大对象对虚拟机的内存分配来说就是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配。以避免在Eden区和Survivor区之间发生大量的内存拷贝。此参数只在Serial和ParNew两款收集器有效,Parallel Scavenge不支持这个参数。  长期存活的对象将进入老年代

虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对象应该放在新生代,哪些对象应该放在老年代。为了做到这点,虚拟机给每个对象定义了一个对象(Age)年龄计数器。

如果对象在Eden出生并经过第一次Minor GC仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1.对象在Survivor区中每熬过一次Minor GC,对象的年龄就增加一岁,当它的年龄增加到一定程度(默认为15)时,就会被晋升到老年代中(可见对象生存很不容易,老的快)。对象晋升老年代的阀值,可以通过参数:-XX:MaxTenuringThreshold来设置。

为了能更好的适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。  空间分配担保

在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC.如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC,如果不允许,则也要改为进行一次Full GC.

前面提到的,新生代使用复制收集算法,但为了内存利用率,只使用其中的一个Survivor区作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时(最极端的情况是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。

与生活中的贷款类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来,在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotion Failure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

因篇幅问题不能全部显示,请点此查看更多更全内容

Copyright © 2019- baijiahaobaidu.com 版权所有 湘ICP备2023023988号-9

违法及侵权请联系:TEL:199 18 7713 E-MAIL:2724546146@qq.com

本站由北京市万商天勤律师事务所王兴未律师提供法律服务