面试冲刺之JVM

本文深入探讨了Java虚拟机(JVM)的运行机制,包括JVM内存区域的划分,如程序计数器、虚拟机栈、本地方法区、堆和方法区。详细阐述了各个区域的功能和特点,如线程私有区域的无溢出特性,堆内存的分代收集以及方法区的永久代与元空间的区别。此外,还介绍了Java的四种引用类型和JVM的类加载机制,包括加载、验证、准备、解析和初始化五个阶段,以及双亲委派模型的工作原理。
摘要由CSDN通过智能技术生成

1. JVM运行机制

JVM是用于运行Java字节码的虚拟机,包括一套字节码指令集、一组程序寄存器、一个虚拟机栈、一个虚拟机堆、一个方法区和一个垃圾回收器。
JVM运行在操作系统之上,不与硬件设备直接交互,每个版本的实现均有不同。

Java程序的具体运行过程

  • Java源文件被编译器编译成字节码文件。
  • JVM将字节码文件编译成相应操作系统的机器码。
  • 机器码调用相应操作系统的本地方法库执行相应的方法。

JVM的运行机制
解释

  • 类加载器子系统用于将编译好的.Class文件加载到JVM中。
  • 运行时数据区用于存储在JVM运行过程中产生的数据,包括程序计数器、方法区、本地方法区、虚拟机栈和虚拟机堆。
  • 执行引擎包括即时编译器和垃圾回收器,即时编译器用于将Java字节码编译成具体的机器码,垃圾回收器用于回收在运行过程中不再使用的对象。
  • 本地接口库用于调用操作系统的本地方法库完成具体的指令操作。

2. JVM的内存区域

JVM的内存区域分为线程私有区域、线程共享区域和直接内存,如图所示。
JVM的内存区域

  • 线程私有区域的生命周期与线程相同,随线程的启动而创建,随线程的结束而销毁。在JVM内部,每个线程都与操作系统的本地线程直接映射,因此线程私有内存区域的存在与否和本地线程的启用和销毁对应。
  • 线程共享区域随虚拟机的启动而创建,随虚拟机的关闭而销毁。
  • 直接内存也叫堆外内存,它并不是JVM运行时数据区的一部分,但在并发编程中被频繁使用。JDK的NIO模块提供的基于Channel与Buffer的I/O操作方式就是基于堆外内存实现的,NIO模块通过调用Native函数库直接在操作系统上分配堆外内存,然后使用DirectByteBuffer对象作为这块内存的引用对内存进行操作,Java进程可以通过堆外内存技术避免在Java堆和Native堆中来回复制数据带来的资源浪费和性能消耗,因此堆外内存在高并发应用场景下被广泛使用(Netty、Flink、HBase、Hadoop都有用到堆外内存)。

2.1 程序计数器:线程私有,无内存溢出问题

  • 程序计数器是一块很小的内存空间,用于存储当前运行的线程所执行的字节码的行号指示器。
  • 每个运行中的线程都有一个独立的程序计数器,在方法正在执行时,该方法的程序计数器记录的是实时虚拟机字节码指令的地址;如果该方法执行的是Native方法,则程序计数器的值为空(Undefined)。
  • 程序计数器属于“线程私有”的内存区域,他是唯一没有内存溢出的区域。

2.2 虚拟机栈:线程私有,描述Java方法的执行过程

虚拟机栈是描述Java方法执行过程的内存模型,它在当前栈帧中存储了局部变量表、操作数栈、动态链接、方法出口等信息。同时,栈帧用来存储部分运行时数据及其数据结构,处理动态链接方法的返回值和异常分派。
虚拟机栈

2.3 本地方法区:线程私有

本地方法区和虚拟机栈的作用类似,区别是虚拟机栈为执行Java方法服务,而本地方法栈为Native方法服务。

2.4 堆:也叫作运行时数据区,线程共享

  • 在JVM运行过程中创建的对象和产生的数据都被存储在堆中,堆是被线程共享的内存区域,也是垃圾收集器进行垃圾回收的最主要的内存区域。
  • 现代JVM采用分代收集算法,从GC的角度还可以将Java堆细分为新生代老年代永久代

2.5 方法区:线程共享

方法区也被称为永久代,用于存储常量、静态变量、类信息、即时编译器编译后的机器、运行时常量池等数据,如图所示。
方法区

  • JVM把GC分代收集扩展至方法区,即使用Java堆的永久代来实现方法区,这样JVM的垃圾收集器就可以像管理Java堆一样管理这部分内存。永久代的内存回收主要针对常量池的回收和类的卸载,因此可回收的对象很少。
  • 常量被存储在运行时常量池中,是方法区的一部分。静态变量也属于方法区的一部分。在.Class文件中不但保存了类的版本、字段、方法、接口等描述信息,还保存了常量信息。
  • 在即时编译后,代码的内容将在执行阶段(类加载完成后)被保存在方法区的运行时常量池中。Java虚拟机对.Class文件每一部分的格式都有明确的规定,只有符合JVM规范的.Class文件才能通过虚拟机的检查,然后被装载、执行。

3. JVM的运行时内存

  • JVM的运行时内存也叫作JVM堆
  • 从GC的角度可以将JVM分为新生代、老年代和永久代。
  • 其中,新生代默认占1/3的内存空间,老年代默认占2/3的内存空间。
  • 新生代可以分为Eden区、SurvivorFrom区和SurvivorTo区,Eden区默认占8/10新生代空间,SurvivorFrom区和SurvivorTo区默认分别占1/10新生代空间。
    具体如下图所示。
    JVM的运行时内存(堆)

3.1 新生代

JVM新创建的对象(除了大对象外)会被存放在新生代,默认占1/3的堆内存空间(JVM运行时内存)。由于JVM会频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代分为Eden区、SurvivorTo区和SurvivorFrom区。

1. Eden区
  • Java新创建的对象首先会被存放在Eden区,如果新创建的对象属于大对象,则直接将其分配给老年代。
  • 大对象的定义和具体的JVM版本、堆大小和垃圾回收策略有关,一般为2KB~128KB,可以通过XX:PretenureSizeThreshold设置其大小。
  • 在Eden区内存空间不足时会触发MinorGC,对新生代进行一次垃圾回收。
2. SurvivorTo区

保留上一次MinorGC时的幸存者。

3. SurvivorFrom区

将上一次MinorGC时的幸存者作为这一次MinorGC的被扫描者。

MinorGC
新生代的GC过程叫作MinorGC

3.2 老年代

老年代主要存放长生命周期的对象和大对象。老年代的GC过程叫作MajorGC。在老年代,对象比较稳定,MajorGC不会被频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,在MinorGC过后仍然出现老年代空间不足或无法找到足够大的连续内存空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。
MajorGC采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。
因为要先扫描老年代的所有对象再回收,所以MajorGC的耗时较长。MajorGC的标记清除算法容易产生内存碎片。在老年代没有内存空间可分配时,会抛出OOM异常。

3.3 永久代

永久代是指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在类加载时被放入永久代。
永久代和老年代、新生代不同,GC不会在程序运行期间对永久代的内存进行处理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载的Class文件过多时会抛出OOM异常,比如Tomcat引用Jar文件过多导致JVM内存不足而无法启动。

注意
在Java 8 中永久代已经被元数据区(元空间)取代。元数据区的作用和永久代类似,两者区别在于:
元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。
因此,元空间的大小不受JVM内存的限制,只和操作系统的内存有关。


在Java 8 中,JVM将类的元数据放入本地内存中,将常量池和类的静态变量放入Java堆中,这样一来,JVM能够加载多少元数据信息就不再由JVM的最大可用内存空间决定,而由操作系统的实际可用空间决定。

4. Java中的四种引用类型

在Java中一切皆对象,对象的操作是通过该对象的引用实现的。Java中一共有4种引用类型,如图所示。

Java的四种引用类型
强引用
强引用在Java中最为常见。在把一个对象赋给一个引用变量时,这个引用变量就是一个强引用。
由强引用的对象一定为可达性状态,所以不会被垃圾回收机制回收。
因此,强引用是造成Java内存泄漏的主要原因。

软引用
软引用通过SoftReference类实现。如果一个对象只有一个软引用,则在系统内存空间不足时该对象将被回收。

弱引用
弱引用通过WeakReference类实现。如果一个对象只有弱引用,则在垃圾回收过程中一定会被回收。

虚引用
虚引用通过PhantomReference类实现,虚引用和引用队列联合使用,主要用于跟踪对象的垃圾回收状态。

5. JVM的类加载机制

5.1 JVM的类加载阶段

JVM的类加载分为5个阶段:加载、验证、准备、解析、初始化。在类初始化完成后就可以使用该类的信息,在一个类不再被需要时可以从JVM中卸载,如图所示。
JVM的类加载阶段
1. 加载
加载指的是读取.Class文件,并根据.Class文件描述创建java.lang.Class对象的过程。
类加载过程主要包含将.Class文件读取到运行时区域的方法区内,在堆中创建java.lang.Class对象,并封装类在方法区的数据结构的过程,在读取.Class文件时既可以通过文件的形式读取,也可以通过jar包、war包读取,还可以通过代理自动生成.Class或其他方式读取。

2. 验证
主要用于确保.Class文件符合当前虚拟机的要求,保障虚拟机自身的安全,只有通过验证的.Class文件才能被JVM加载。

3. 准备
主要工作是在方法区中为类变量分配内存空间并设置类中变量的初始值。初始值指不同数据类型的默认值,这里要注意final类型的变量和非final类型的变量在准备阶段的数据初始化过程不同。具体如下。

public static long value = 1000;
/*
	静态变量value在准备阶段的初始值是0,将value设置为1000的动作是在对象初始化时完成的,
	因为JVM在编译阶段会将静态变量的初始化操作定义在构造器中。
*/
public static final int value = 1000;
/*
	value声明为final类型,在JVM编译阶段后会为其生成对应的ConstantValue属性,虚拟机在准备阶段会根据ConstantValue属性将value赋值为1000.
*/

4. 解析
JVM会将常量池中的符号引用替换为直接引用。

5. 初始化
主要通过执行类构造器的<clinit>方法为类初始化。
<clinit>方法是在编译阶段由编译器自动收集类中静态语句块和变量的复制操作组成的。
JVM规定,只有在父类的<clinit>方法都执行成功后,子类中的<clinit>方法才可以被执行。在一个类中既没有静态变量赋值操作也没有静态语句块时,编译器不会为该类生成<clinit>方法。

5.2 类加载器

JVM提供了3种类加载器,分别是启动类加载器、扩展类加载器和应用程序类加载器。如图所示。
类加载器
1. 启动类加载器
负责加载JAVA_HOME/lib目录中的类库,或通过-Xbootclasspath参数指定路径中被虚拟机认可的类库。

2. 扩展类加载器
负责加载JAVA_HOME/lib/ext目录中的类库,或通过java.ext.dirs系统变量加载指定路径中的类库。

3. 应用程序类加载器
负责加载用户路径(classpath)上的类库。

除了以上3中类加载器,我们可以通过继承java.lang.ClassLoader实现自定义的类加载器。

5.3 双亲委派机制

JVM通过双亲委派机制对类进行加载。双亲委派机制指一个类在收到类加载请求后不会尝试自己加载这个类,而是把该类加载请求向上委派给其父类去完成,其父类在接收到该类加载请求后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中。
若父类加载器在接收到类加载请求后发现自己也无法加载该类(通常是因为该类的.Class文件在父类的类加载路径中不存在),则父类会将该信息反馈给子类并向下委派子类加载器加载该类,直到该类被成功加载,如果找不到该类,则JVM会抛出CNF异常。
双亲委派类加载机制的类加载流程如图所示。
类加载流程

说明

  1. 将自定义加载器挂载到应用程序类加载器。
  2. 应用程序类加载器将类加载请求委托给扩展类加载器。
  3. 扩展类加载器将类加载请求委托给启动类加载器。
  4. 启动类加载器在加载路径下查找并加载.Class文件,如果未找到目标.Class文件,则交由扩展类加载器加载。
  5. 扩展类加载器在加载路径下查找并加载.Class文件,如果未找到目标.Class文件,则交由应用程序类加载器加载。
  6. 应用程序类加载器在加载路径下查找并加载.Class文件,如果未找到目标.Class文件,则交由自定义类加载器加载。
  7. 在自定义加载器下查找并加载用户指定目录下的.Class文件,如果在自定义加载路径下未找到目标.Class文件,则抛出CNF异常。

双亲委派机制的核心是保障类的唯一性和安全性。例如,在加载rt.jar包中的java.lang.Object类时,无论是哪个类加载器加载这个类,最终都将类加载请求委托给启动类加载器加载,这样就保证了类加载的唯一性。如果在JVM中存在包名和类名相同的两个类,则该类将无法被加载,JVM也无法完成类加载流程。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值