深入理解Java虚拟机:JVM内存结构与垃圾回收机制详解
JVM内存区域概览
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途、创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些则是依赖用户线程的启动和结束而建立和销毁。JVM内存区域主要分为线程共享区域和线程私有区域两大类。线程共享区域包括方法区和堆,而线程私有区域则包括虚拟机栈、本地方法栈和程序计数器。理解这些区域是掌握JVM内存管理和垃圾回收的基础。
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,因此程序计数器是线程私有的内存区域。此区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用和returnAddress类型。这个区域可能抛出StackOverflowError和OutOfMemoryError异常。
本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
Java堆
对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。从内存回收的角度来看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”、“老年代”、“Eden空间”、“From Survivor空间”、“To Survivor空间”等名词。Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
方法区
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”,目的是与Java堆区分开来。很多人更愿意把方法区称为“永久代”,本质上两者并不等价,仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区。到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间来代替。如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
垃圾回收的基本原理
垃圾回收主要关注的是Java堆和方法区这两个线程共享的区域。程序计数器、虚拟机栈、本地方法栈这三个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的正是这部分内存。
判断对象存活的算法
垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。判断对象存活的基本算法主要有两种:引用计数算法和可达性分析算法。引用计数算法在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。但是,在主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是这个算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,比如单纯的引用计数就很难解决对象之间相互循环引用的问题。因此,Java虚拟机是通过可达性分析算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能再被使用的。
垃圾收集算法
从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两大类,主流Java虚拟机均采用追踪式垃圾收集。追踪式垃圾收集理论中主要包含三种基本的垃圾收集算法:标记-清除算法、标记-复制算法和标记-整理算法。标记-清除算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。它的主要不足有两个:一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;另一个是内存空间的碎片化问题。标记-复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法的代价是将可用内存缩小为了原来的一半,空间浪费太多。标记-整理算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这会让停顿时间变得更长。
分代收集理论与HotSpot的算法实现
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,它建立在两个分代假说之上:弱分代假说和强分代假说。基于这两个假说,收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而才有了“Minor GC”、“Major GC”、“Full GC”这样的回收类型的划分。以HotSpot虚拟机为例,它不仅要准确快速地完成GC Roots的枚举,还必须高效地实现对象存活判定、在垃圾收集过程中更新引用等操作。HotSpot使用一组称为OopMap的数据结构来达到这个目的,并借助安全点和安全区域来精确地控制垃圾收集的发生时机。这些精心的设计共同保证了现代Java虚拟机高效、低停顿的垃圾收集能力。
经典垃圾收集器
垃圾收集器是垃圾收集算法的具体实现。由于Java虚拟机规范中对垃圾收集器的实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别。经典的HotSpot虚拟机包含的收集器有:Serial收集器、ParNew收集器、Parallel Scavenge收集器、Serial Old收集器、Parallel Old收集器、CMS收集器、Garbage First收集器等。这些收集器各有优缺点,适用于不同的应用场景。例如,Serial收集器是一个单线程工作的收集器,但它的简单而高效对于内存资源受限的环境是很有价值的;而G1收集器则面向服务端应用,旨在尽可能满足停顿时间要求的同时,还具备高吞吐量的性能特征。了解这些收集器的工作原理和适用场景,对于进行JVM调优至关重要。
内存分配与回收策略
Java技术体系的自动内存管理最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存。对象的内存分配,从概念上讲,应该都是在堆上分配。在经典分代的设计下,新生对象通常会分配在新生代的Eden区中,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代,分配规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。大对象直接进入老年代,而长期存活的对象将逐渐从新生代晋升到老年代。动态对象年龄判定和空间分配担保等规则共同决定了对象在内存中的生命周期和位置,这些策略共同保障了内存分配的高效性和垃圾回收的有效性。
JVM内存结构与垃圾回收详解
1123

被折叠的 条评论
为什么被折叠?



