【JVM】——结构组成和垃圾回收
深入浅出Java虚拟机(JVM):从编译到回收的完整之旅
Java语言能够实现“一次编写,到处运行”的魔力,其核心基石便是Java虚拟机(JVM)。它屏蔽了底层操作系统和硬件的差异,为Java字节码提供了一个统一的运行环境。本文将系统地、详细地介绍JVM的核心组成部分和工作原理。
一、 JVM介绍与运行流程
1. JVM是什么? Java虚拟机(JVM)是一个虚构出来的计算机,它通过在实际的计算机上仿真模拟各种计算机功能来实现。它拥有自己完善的硬件架构,如处理器、堆栈、寄存器等,以及相应的指令系统。
2. 运行流程 一个Java程序的运行,始于编译,终于执行,其核心流程如下:
- 编译:开发者编写的
.java
源文件,通过javac
编译器编译成.class
字节码文件。字节码是一种介于源代码和机器码之间的中间代码,它与特定的硬件和操作系统无关。 - 加载(Loading):JVM的类加载器(ClassLoader) 读取
.class
文件,并根据其二进制数据在方法区创建对应的Class
对象。这个过程我们后续会详细说明。 - 解释/编译(Interpreting/Compiling):JVM的执行引擎负责解释或编译这些字节码。
- 解释器:逐条读取字节码,逐条解释执行。优点是启动快,但执行速度相对较慢。
- 即时编译器(JIT Compiler):HotSpot VM的亮点。它会将高频热点代码(如循环、频繁调用的方法)直接编译成本地机器码(Native Code),并缓存起来。下次再执行这段代码时,直接运行机器码,极大提升了执行效率。这也是Java程序“越跑越快”的原因。
- 执行:执行引擎操作运行时数据区(Runtime Data Areas),与操作系统和硬件交互,完成程序的逻辑。
- 垃圾回收(GC):在程序运行过程中,垃圾回收器(Garbage Collector) 会不间断地监控和清理堆内存中不再使用的对象,释放内存空间,防止内存泄漏。
二、 JVM组成(运行时数据区)
JVM在执行Java程序时会把它所管理的内存划分为若干个不同的数据区域。
1. 程序计数器(Program Counter Register)
- 定义:一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- 代码演示:
- 1、先编译
2、在target目录查看运行的方法
3、输入javap命令
4、查看运行信息
程序计数器示例说明:假如说线程1先执行到了第10行,时间片被线程2拿走了,会记录当前的行号,等拿回时间片时又会继续执行到第20行,依次类推。
- 特点:
- 线程私有:每个线程都有自己独立的程序计数器,互不干扰。
- 无垃圾回收:此区域是唯一一个在《Java虚拟机规范》中没有规定任何
OutOfMemoryError
情况的区域。 - 执行Native方法时:当线程执行的是
native
方法(本地方法,如C++代码)时,程序计数器的值为空(undefined
)。
- 作用:字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖它来完成。
2. 堆(Heap)
- 定义:堆是JVM管理的最大的一块内存区域,是被所有线程共享的一块内存区域,在虚拟机启动时创建。
- 核心作用:此内存区域的唯一目的就是存放对象实例和数组。几乎所有的对象实例都会在这里分配内存。
- 关键特性:
- 线程共享:所有线程都访问同一个堆,因此存放对象数据时需要考虑线程安全问题。
- GC主要区域:堆是垃圾收集器管理的内存区域,因此也被称为“GC堆”。
- 内存划分(分代思想):现代垃圾收集器基本都采用分代收集算法,所以Java堆可以细分为:
- 新生代(Young Generation):新创建的对象首先在这里分配。新生代又分为:
- Eden区:对象诞生的地方。
- Survivor区(S0和S1):用于存放经过Minor GC后仍然存活的对象。两个Survivor区也叫做“from区”和“to区”,它们角色会互换。
- 老年代(Old/Tenured Generation):在新生代中经历了多次GC后仍然存活的对象(默认15次),会被晋升到老年代。一些大对象(如长数组)也可能直接进入老年代。
- 新生代(Young Generation):新创建的对象首先在这里分配。新生代又分为:
- 异常:如果堆中没有足够的内存完成实例分配,并且堆也无法再扩展时,JVM会抛出
OutOfMemoryError
错误。
拓展:
jdk1.7的堆内存和jdk1.8的堆内存的区别:
永久代会随着动态加载的类不断增大,如果空间给小了会内存溢出,给大了又占用堆内存。所以就放到了本地内存的元空间。
最终都是为了防止内存溢出
内存溢出(OOM) 是程序申请内存时系统无法满足的直接结果,而其深层原因往往是内存泄漏(该回收的没回收)或业务需求超出资源配置。通过JVM提供的错误信息和强大的调试工具(如
jmap
和MAT),开发者可以有效地定位问题根源,并通过修改代码或调整配置来解决它。
小结:
3. 虚拟机栈(Java Virtual Machine Stack)
512m(512兆)
- 定义:与程序计数器一样,也是线程私有的,它的生命周期与线程相同。
- 核心作用:描述的是Java方法执行的内存模型。每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 栈帧组成:
- 局部变量表:存放了编译期可知的各种基本数据类型(
int
,boolean
等)、对象引用(reference
类型,它不等同于对象本身,可能是一个指向对象的指针地址)和returnAddress
类型(指向了一条字节码指令的地址)。 - 操作数栈:用于方法执行过程中的计算。
- 局部变量表:存放了编译期可知的各种基本数据类型(
- 异常:
- StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的深度。
- OutOfMemoryError:如果虚拟机栈可以动态扩展,但在扩展时无法申请到足够的内存。
拓展:
1、局部变量是否线程安全:
2、栈内存溢出情况
小结:
4. 方法区(Method Area)
测试:创建10000个类看看会不会报异常
结果:没有异常,因为元空间默认大小是没有上限的
测试2:设置元空间大小查看结果
结果:报错,提示元空间太小了
- 定义:与堆一样,是各个线程共享的内存区域。
- 核心作用:用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 演进:在JDK 8之前,方法区的实现被称为永久代(PermGen)。从JDK 8开始,永久代被彻底移除,取而代之的是一块叫做元空间(Metaspace) 的区域。
- 永久代 vs 元空间:最大的区别在于,永久代使用的是JVM的堆内存,而元空间使用的是本地内存(Native Memory)。这解决了永久代大小固定易溢出的问题,元空间的大小理论上只受本地内存限制。
- 运行时常量池:方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
小结:
5. 直接内存(Direct Memory)
代码演示:
nio的方法
常规io的方法
结果:
- 定义:并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但它被频繁使用,并且可能导致
OutOfMemoryError
。 - 来源:在JDK 1.4中引入了NIO(New Input/Output)类,引入了一种基于通道(Channel) 与缓冲区(Buffer) 的I/O方式,它可以使用
Native
函数库直接分配堆外内存,然后通过一个存储在Java堆里的DirectByteBuffer
对象作为这块内存的引用进行操作。 - 常规IO:
- NIO使用直接内存
- 优点与风险:
- 优点:避免了在Java堆和Native堆中来回复制数据,能显著提高性能。
- 风险:直接内存的分配不受Java堆大小的限制,但既然也是内存,就会受到本机总内存大小的限制。如果各个内存区域的总和大于物理内存限制,动态扩展时就会出现
OutOfMemoryError
。
小结:
三、 类加载器(ClassLoader)
小结:
1. 什么是类加载器与双亲委派模型
- 类加载器:负责将Class文件加载到内存中,并为它生成一个
java.lang.Class
对象。 - 双亲委派模型(Parents Delegation Model):JVM中的类加载器在协同工作时,遵循的一种模型。
- 工作过程:当一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此。因此所有的加载请求最终都应该传送到最顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
- 优势:
- 避免类的重复加载:确保一个类在全局唯一性,保证核心API的稳定。比如
java.lang.Object
类,无论哪个加载器要加载它,最终都委派给启动类加载器,从而保证了系统中只有一个Object
类。 - 安全:防止核心API被篡改。用户自定义的
java.lang.Object
类不会被加载,因为会被委派给启动类加载器,而启动类加载器发现核心库中已有Object
类,就不会加载用户自定义的。
- 避免类的重复加载:确保一个类在全局唯一性,保证核心API的稳定。比如
小结:
2. 类装载的执行过程
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括7个阶段。其中加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的。而“类装载”通常指的是前三个阶段:
- 加载(Loading):
- 通过类的全限定名获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
- 链接(Linking):
- 验证(Verification):确保Class文件的字节流中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全(如文件格式、元数据、字节码、符号引用验证)。
- 准备(Preparation):为类变量(
static
变量)分配内存并设置初始值(零值),如static int a = 100;
在准备阶段后a
的值为0
,而非100
。final static
修饰的常量在此阶段会直接赋值为设定的值。 - 解析(Resolution):将常量池内的符号引用替换为直接引用的过程。
- 初始化(Initialization):执行类构造器
<clinit>()
方法的过程。<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。这时才会真正执行static int a = 100;
的赋值操作。
四、 垃圾回收(Garbage Collection, GC)
1. 对象什么时候可以被垃圾器回收?
垃圾回收器回收的是已经“死亡”的对象。判断对象是否“死亡”有两种经典算法:
- 引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效时,计数器就减1。任何时刻计数器为0的对象就是不可能再被使用的。主流的Java虚拟机没有选用引用计数法,因为它很难解决对象之间循环引用的问题。
- 可达性分析算法:当前主流商用语言(Java, C#)都是通过它来判定对象是否存活的。
- 基本思路:通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链。如果某个对象到GC Roots间没有任何引用链相连(即从GC Roots到这个对象不可达),则证明此对象是不可能再被使用的。
- 哪些对象可以作为GC Roots?:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即
native
方法)引用的对象。 - 虚拟机内部的引用(如基本数据类型对应的Class对象,常驻的异常对象等)。
小结:
2. JVM垃圾回收算法有哪些?
- 标记-清除算法(Mark-Sweep):
- 过程:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。
- 缺点:效率不高;会产生大量不连续的内存碎片。
- 复制算法(Copying):
- 过程:将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 优点:实现简单,运行高效,没有内存碎片。
- 缺点:内存利用率太低,只有50%。
- 应用:商业虚拟机都采用这种算法来回收新生代。但并不是按1:1划分,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间(默认8:1:1)。
- 标记-整理算法(Mark-Compact):
- 过程:标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 优点:没有内存碎片,内存利用率高。
- 缺点:移动存活对象并更新引用地址是一个负重操作,需要暂停用户程序(Stop The World)。
- 应用:主要用于老年代的垃圾回收。
小结:
3. JVM的分代回收
基于一个分代假说:绝大多数对象都是朝生夕死的;熬过越多次垃圾收集过程的对象就越难以消亡。 基于此,JVM将堆内存分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
- 新生代(Minor GC):区域小,对象存活率低。采用复制算法,回收频繁但速度快。
- 老年代(Major/Full GC):区域大,对象存活率高。采用标记-清除或标记-整理算法,回收次数少但速度较慢,且通常伴随一次“Stop The World”。
小结:
4. JVM有哪些垃圾回收器?
垃圾回收器是垃圾回收算法的具体实现。
- Serial收集器:单线程,新生代,复制算法。简单高效,适用于Client模式。
- Serial Old收集器:Serial的老年代版本,单线程,标记-整理算法。
- Parallel Scavenge收集器:新生代,多线程,复制算法。目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。
- Parallel Old收集器:Parallel Scavenge的老年代版本,多线程,标记-整理算法。
- CMS收集器:以获取最短回收停顿时间为目标的收集器。基于“标记-清除”算法,过程复杂,包括:初始标记、并发标记、重新标记、并发清除。会产生内存碎片。
其他还有:
- ParNew收集器:Serial的多线程版本,新生代,复制算法。是许多Server模式下的首选新生代收集器,因为它能与CMS配合工作。
- G1收集器:面向服务端应用的垃圾回收器,是当今最前沿的成果之一。下面详细介绍。
5. 详细聊一下G1垃圾回收器
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,在JDK 9中成为默认的垃圾回收器。
就像不再区分新老年代
1、Young Collectiom
2、Young Collection + Concurrent Mark
3、Mixed Collextion
幸存者区(S)一部分到了阈值的对象也复制到老年代(O),进行回收。
另外,如果一个对象太大了,一块区域装不下,就会分配连续的区域进行存储(humongous):
- 设计目标:在延迟可控的情况下获得尽可能高的吞吐量。
- 核心思想:将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但它们不再是物理隔离的了,而都是一部分Region(不需要连续)的集合。
- 运作过程:
- 初始标记:标记一下GC Roots能直接关联到的对象,需要停顿线程(STW),但耗时很短。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,找出存活对象,耗时较长,但可与用户程序并发执行。
- 最终标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,需要停顿线程(STW)。
- 筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。然后选择多个Region构成回收集,将决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。需要停顿线程(STW)。
- 优势:
- 并行与并发:充分利用多核优势。
- 分代收集:依然采用分代思想。
- 空间整合:从整体看是基于“标记-整理”算法,从局部(两个Region之间)看是基于“复制”算法,都不会产生内存碎片。
- 可预测的停顿:能建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
小结:
6. 强引用、软引用、弱引用、虚引用的区别
Java中除了强引用,还提供了3种特殊的引用类型,它们的存在是为了协调对象生命周期与垃圾回收之间的关系。
引用类型 | 被垃圾回收时间 | 用途 | 生存时间 |
---|---|---|---|
强引用 (Strong Reference) | 从来不会 | 对象的一般状态 | JVM停止运行时终止 |
软引用 (Soft Reference) | 在内存不足时 | 实现内存敏感的高速缓存 | 内存不足时终止 |
弱引用 (Weak Reference) | 在垃圾回收时 | 实现规范映射(如WeakHashMap ) | GC运行后终止 |
虚引用 (Phantom Reference) | 任何时候 | 跟踪对象被垃圾回收的活动 | 任何时候都可能被回收 |
- 强引用:类似
Object obj = new Object()
,只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用:用来描述一些还有用但非必需的对象。系统在发生内存溢出异常之前,会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。常用于缓存。
- 弱引用:强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。必须与
ReferenceQueue
联合使用。
小结:
希望这篇详尽的文章能帮助你系统地理解和掌握JVM的核心知识体系。