为了更加全面的理解java虚拟机,更好的对代码快进行理解,也为了更好的在面试中表述想法
java它的优点有(只列出两个):
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁
。
建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。
看作是当前线程所执行的字节码的行号指示器。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置
每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java虚拟机规范中没有规定任何OutOfMemoryError 情况的区域。
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同
。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame )用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中人栈到出栈的过程。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError异常;
如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError异常。
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的
它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务
。
Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块
。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例
,几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(GarbageCollected Heap,幸好国内没翻译成“垃圾堆”)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例
,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中
,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
方法区.(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
。
根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
除了程序计数器外,虚拟机内存的其他几个运行时区域都有可能发生oom异常
Java堆用于存储对象实例,只要不断地创建对象
,并且保证GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
下面的代码展示:
Dump 出当前的内存堆转储快照
以便事后进行分析。要解决这个区域的异常,一般的手段是先通过内存映像分析工具
对 Dump出来的堆转储快照
进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory)
如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots 的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄露对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置。
如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗
先思考如下问题:
目前动态分配与内存回收技术已经很成熟,进入自动化,至于为何还要了解GC和内存分配,这是因为要排查各种内存溢出还是内存泄漏问题
介绍了运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈
这三个区域随着线程的生而生,灭而灭。这几个区域的不需要过多的回收问题。方法结束或者线程结束,内存自然就回收了
。
java堆和方法区不同,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存可能也不一样,这些只有在运行期间才知道创建哪些对象,内存的分配和回收都是动态的。
至于判断是否需要回收,通过如下:
在堆里面存放着Java中几乎所有的对象实例。
垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
判断这种机制可以通过如下两种方法:
给对象中添加一个引用计数器
,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1﹔任何时刻计数器为0的对象就是不可能再被使用的。
但是,它有个缺点,就是很难解决对象之间相互循环引用的问题
举个简单的例子。
testGE()方法:对象obA和 objB都有字段instance赋值令objA.instance = objB及objB.instance = objA,除此之外,这两个对象再无任何何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
gcroot 引用的对象:
对于以前引用的这个定义如下:
在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用
jdk1.2之后,多加了定义:
Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用
4种
在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常
。在JDK 1.2之后,提供了SoftReference类来实现软引用。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
。在 JDK 1.2之后,提供了WeakReference类来实现弱引用。完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
它的主要不足有两个:一个是效率问题
,标记和清除两个过程的效率都不高;另一个是空间问题
,标记清除之后会产生大量不连续的内存碎片
,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
为了解决效率问题,一种称为“复制”(Copying)的收集算法出现
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收
,内存分配时也就不用考虑内存碎片等复杂
情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都f00%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记–清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代
,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法
,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记一整理”算法来进行回收
。
从Serial 收集器到 Parallel收集器,再到Concurrent Mark Sweep (CMS)乃至GC收集器的最前沿成果Garbage First (G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断缩短
,但是仍然没有办法完全消除(这里暂不包括RTSJ中的收集器)。寻找更优秀的垃圾收集器的工作仍在继续!
Serial收集器是最基本、发展历史最悠久的收集器
一个单线程的收集器
,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束
。在用户不可见的情况下把用户正常工作的线程全部停掉
,这对很多应用来说都是难以接受的。读者不妨试想一下,要是你的计算机每运行一个小时就会暂停响应5分钟
单线程高效率,这点停顿对于客户端来说还是可以的,所以客户端中是一个不错的选择
ParNew收集器其实就是Serial 收集器的多线程版本
,除了使用多条线程进行垃圾收集之外,其余行为包括Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio,-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。
ParNew收集器除了多线程收集之外,其他与Serial 收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器
,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
科普:CMS 收集器,真正意义上的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
ParNew收集器在单CPU的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器
。当然,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的
。它默认开启的收集线程数与CPU的数量相同,在CPU非常多(譬如32个,现在CPU 动辄就4核加超线程,服务器超过32个逻辑CPU的情况越来越多了)的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器
……看上去和ParNew都一样,那它有什么特别之处呢?
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同
CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间
,而 Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。
所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
吞吐量和停顿时间是一个正比的效果,吞吐量多,停顿时间就会多。
Serial Old是Serial 收集器的老年代版本
,它同样是一个单线程收集器
,使用“标记一整理”
算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用
。如果在Server模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5以及之前的版本中与ParallelScavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案
,在并发收集发生Concurrent Mode Failure时使用。这两点都将在后面的内容中详细讲解。
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记一整理”算法
。
这个收集器是在JDK 1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old (PS MarkSweep)收集器外别无选择
(还记得上面说过Parallel Scavenge收集器无法与CMS 收集器配合工作吗?)。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew 加 CMS的组合“给力”。
直到Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加 ParallelOld收集器
。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间
为目标的收集器。目前很大一部分的-Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
以上都有四个步骤,分别为初始标记,并发标记,重新标记,并发清除
其中,初始标记、重新标记这两个步骤仍然需要“Stop The World"。
标记一下GC.Roots能直接关联到的对象
,速度很快进行GC Roots Tracing 的过程
变动的那一部分对象的标记记录
,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作
,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
有以下明显几个缺点:
CMS 收集器对CPU资源非常敏感。其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程或者说CPU资源)而导致应用程序变慢,总吞吐量会降低
。CMS 默认启动的回收线程数是(CPU 数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的 CPU资源,并且随着CPU 数量的增加而下降。但是当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。为了应付这种情况,虚拟机提供了一种称为‘增量式并发收集器”(Incremental Concurrent Mark Sweep / i-CMS)的CMS收集器变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记、清理的时候让GC线程、用户线程交替运行,尽量减少GC线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,也就是速度下降没有那么明显
。实践证明,增量时的CMS收集器效果一般,不推荐使用
CMS收集器无法处理浮动垃圾(Floating Garbage)
,可能出现“Concurrent ModeFailure”失败而导致另一次Full GC 的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
在 JDK 1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在JDK 1.6中,CMS收集器的启动阈值已经提升至92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高很容易导致大量“Concurrent Mode Failure"失败,性能反而降低。
会有大量空间碎片产生。
空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。
优点有:
G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU
(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC 动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。基于“复制”算法实现的
,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。还能建立可预测的停顿时间模型
,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java (RTSJ)的垃圾收集器的特征了。