🌟 Java虚拟机内存模型
Java虚拟机(JVM)是一种能够在不同平台上运行Java程序的虚拟机。JVM内部有一个内存模型,用于管理其内部的内存分配。JVM内存模型可以分为以下五个部分:
🍊 一、方法区
方法区也被称为永久代(Permanent Generation),是Java虚拟机用于存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的区域。它是虚拟机规范中的一部分,属于非堆内存。在JDK1.8之前,方法区是使用永久代实现的,而JDK1.8之后则使用元空间(Metaspace)来代替永久代,将方法区移到了本地内存中。
在Java虚拟机中,有一种名为“即时编译器”的技术,它可以将Java字节码转换为本地机器代码,并将这些代码存储在方法区中。一些框架,如Spring、MyBatis等,需要进行反射等操作,这些操作会用到通过类加载器加载的类信息和一些常量,而这些信息会存储在方法区中。
方法区中存储的数据包括:
- 已加载类的类型信息,包括类的元数据(如类的访问修饰符、父类、实现的接口、字段、方法等)和类的静态变量。
- 常量池(Constant Pool):常量池是类加载后存储在方法区的一段内存空间,用于存储编译器生成的各种字面量和符号引用。
- 即时编译器编译后的字节码:将Java字节码转换为本地机器代码,并将这些代码存储在方法区中。
- 字符串常量池:存储Java字符串常量。
由于方法区存储的是不可变的数据,容易出现内存泄漏的情况。特别是在使用自定义类加载器时,如果频繁进行类的加载和卸载操作,就有可能导致方法区中的数据越来越多,最终导致内存泄漏。
在JDK1.8之前,方法区使用永久代实现,其大小是固定的且无法回收的,因此容易导致过度内存占用。即使在JDK1.8之后,方法区使用元空间代替永久代,但在默认情况下,元空间也是没有大小限制的,因此仍可能导致过度内存占用。
由于方法区存储的数据通常都是不可变的,因此垃圾回收器在进行垃圾回收时需要扫描大量的无用数据,导致GC效率低下。
为了避免方法区带来的问题,可以采取以下优化手段:
- 设置方法区最大值:可以通过JVM启动参数来进行控制方法区的大小,包括最小值、最大值、初始值。当方法区达到最大值时,JVM会发生OOM(Out Of Memory)错误。
- 使用内存泄漏检测工具:在使用自定义类加载器时,可以采用内存泄漏检测工具来检测是否存在内存泄漏问题。
- 对不需要的类进行卸载:在加载某个类时,可以根据需要选择是否卸载该类。当某个类不再被需要时,可以手动将其卸载,从而避免方法区中的数据越来越多,导致过度内存占用和GC效率低下。
- 调整元空间大小:在JDK1.8之后,可以使用元空间代替永久代,将方法区移到了本地内存中。可以通过JVM启动参数来控制元空间的大小,从而避免过度内存占用。
- 使用弱引用:在使用动态语言和代码生成技术时,可以使用弱引用来避免内存泄漏问题。当某个对象不再被使用时,弱引用会自动将其清除。
🍊 二、堆
🎉 堆的基本概念
堆是Java虚拟机运行时数据区之一,用于存储对象实例。堆是在JVM启动时创建,并且在JVM关闭时才会被销毁,堆的大小可以通过-Xmx参数控制。堆的大小不足会导致OutOfMemoryError,而堆的过大会导致GC时间过长,影响程序的实际性能。
🎉 堆的结构
堆由不同的区域构成:新生代、老年代。新生代由Eden区和Survivor0区、Survivor1区组成。
📝 新生代
新生代是堆的一部分,用于存放新创建的对象。新生代中的对象生命周期短暂,一般很快就会被回收掉。新生代分为Eden区和Survivor0区、Survivor1区。其中,Eden区用于存放新创建的对象,Survivor0区和Survivor1区用于存放经过一次Minor GC后仍然存活的对象。一块Eden区和两块Survivor区比例是8:1:1。
在新生代,每个对象都有一个年龄计数器。当对象在Eden区中被创建时,年龄计数器初始化为0,每经过一次Minor GC年龄计数器的值就会加1。当年龄计数器的值达到一定阈值时,对象将会被晋升到老年代中。晋升到老年代中的对象将会在进行Full GC时被回收。
📝 老年代
老年代是堆的一部分,用于存放存活时间较长的对象。老年代中的对象生命周期较长,一般不会被频繁回收。老年代中的对象在进行Full GC时才会被回收。
🎉 堆的分配策略
堆的分配策略包括两种:对象优先分配和空间优先分配。
📝 对象优先分配
对象优先分配是JVM默认的分配策略,它将新创建的对象分配到Eden区中,如果Eden区空间不足,就会触发Minor GC。在Minor GC时,经过垃圾回收后,如果对象还存活,就会被移动到Survivor0区或Survivor1区中,如果Survivor0区或Survivor1区空间不足,就会触发Minor GC。当对象在Survivor0区或Survivor1区中经过一定次数的垃圾回收后仍然存活,就会被晋升到老年代中。
📝 空间优先分配
空间优先分配是指JVM将新创建的对象分配到空间使用率较低的区域中。通常情况下,空间使用率较低的区域是老年代。在空间使用率较低的情况下,空间优先分配策略可以减少垃圾回收的次数,从而提高程序的性能。
🎉 堆的性能调优
堆的性能调优是Java程序优化的重要部分,主要包括以下几个方面:
- 堆的大小调优:堆的大小直接影响程序的性能,需要根据实际情况对堆的大小进行调优。
- 新生代和老年代的分配比例:新生代和老年代的分配比例也影响程序的性能,通常情况下,新生代占总堆大小的1/3到1/4比较合适。
- 垃圾回收算法的选择:垃圾回收算法的选择也影响程序的性能,需要根据实际情况选择合适的垃圾回收算法。
总的来说,堆是Java虚拟机运行时数据区之一,用于存储对象实例。堆的大小和分配策略对程序的性能有着至关重要的影响。因此,需要根据实际情况对堆进行性能调优,以提高程序的效率和性能。
🍊 三、Java虚拟机栈
栈帧是Java虚拟机执行Java程序的基本单元。在Java程序中,每个方法被调用时,都会为该方法创建一个栈帧。栈帧包括了局部变量表、操作数栈、动态链接、方法出口和线程信息等。当方法执行完成时,栈帧会被销毁。Java虚拟机通过栈帧的入栈和出栈来管理Java程序的方法调用过程。在栈帧的执行过程中,Java虚拟机可以实现动态查找和链接,从而实现了Java程序的跨平台执行。
🎉 栈帧的创建过程
当Java程序调用一个方法时,虚拟机会根据方法的描述信息,为该方法创建一个栈帧。栈帧包括了局部变量表、操作数栈、动态链接、方法出口等信息。Java虚拟机栈将该栈帧入栈,使得该栈帧成为当前栈帧。从而,被调用方法开始执行。
🎉 局部变量表
局部变量表是用于存放方法参数和局部变量的。在栈帧被创建的时候,局部变量表就会被分配空间。局部变量表的大小在编译期间就已经确定了,但是其所需的大小在运行时才能确定。因此,在方法运行之前,Java虚拟机需要根据局部变量表的大小来分配栈帧所需的内存空间。局部变量表所需的内存空间取决于方法所需的局部变量的数量。
🎉 操作数栈
操作数栈是一个后进先出的栈,用于存放方法所有的中间结果。它是栈帧的一个重要组成部分,在方法执行过程中,任何操作都必须通过操作数栈来完成。当方法被调用时,操作数栈是空的。在方法执行过程中,操作数栈中的元素会因为方法中的操作而被推入或弹出。当方法执行完成时,操作数栈被清除,而栈帧也随之出栈。
🎉 动态链接
动态链接是在编译期无法确定的方法调用跳转。在Java虚拟机中,每个栈帧都有一个指向它所属的类的指针,称为类指针(Class Pointer)。在Java虚拟机中,方法的调用通常是通过一个符号引用来实现的。符号引用包括了方法的名字、返回值类型和参数列表的描述符。当Java虚拟机遇到一个符号引用时,它会通过该符号引用查找对应的方法。这个查找过程就称为动态链接。这种动态查找和链接的方式,是Java虚拟机实现跨平台的一种技术手段。如果在查找过程中发现方法没有找到,虚拟机会抛出NoSuchMethodError错误。
🎉 方法出口
方法出口是一个指向方法调用者的返回地址的指针。当一个方法被调用时,方法出口会被压入操作数栈中。当方法执行完成时,该方法的返回值会被压入操作数栈中,返回地址也会从栈中弹出到程序计数器(PC)中,使程序继续执行。
🎉 线程信息
Java程序中的线程是轻量级的执行单元。Java虚拟机会为每个线程分配一个Java虚拟机栈,每个栈由多个栈帧组成。每个线程在运行时,都有一个栈帧作为当前栈帧。每个栈帧包括了线程所需的局部变量表、操作数栈、动态链接、方法出口和线程信息等。线程信息包括了线程的ID、线程名、线程状态等信息。
🎉 栈帧的销毁过程
栈帧的销毁是指栈帧从虚拟机中出栈的过程。当方法执行完成时,Java虚拟机会将该方法的栈帧出栈,并将方法的返回值压入方法调用者的操作数栈中。当方法调用者继续执行时,它会弹出被调用方法的返回值。这个过程就完成了栈帧的销毁。
🍊 四、本地方法栈
本地方法栈是Java虚拟机中的一个重要组成部分,是Java程序中调用的本地方法所使用的内存区域,也是线程私有的。在Java虚拟机栈中,栈帧保存的是Java方法的状态,而在本地方法栈中,栈帧保存的是本地方法(Native Method)的状态。本地方法是Java程序中调用本地库(Native Library)的接口,也就是通过JNI(Java Native Interface)调用外部的C/C++等本地代码,在这种情况下,Java虚拟机就需要提供一片内存区域来支持本地方法的执行。
本地方法栈的空间大小也是可以通过JVM启动参数来控制的,参数为-Xss
。默认情况下,64位JVM的本地方法栈大小为1MB,32位JVM的本地方法栈大小为320KB。当本地方法栈空间不足时,会发生StackOverflowError;当本地方法栈空间无法继续扩展时,会发生OutOfMemoryError。
本地方法栈与Java虚拟机栈的区别在于,Java虚拟机栈保存的是Java方法的状态,而本地方法栈保存的是本地方法的状态。另外,Java虚拟机栈是由JVM自动管理的,包括分配和释放;而本地方法栈则是由本地方法本身负责管理的。在调用本地方法之前,JVM需要将本地方法的参数传递给本地方法,参数传递的方式和C语言类似,有寄存器传递和栈传递两种方式。当本地方法执行完毕后,JVM需要将本地方法的返回值传递回Java程序中,返回值传递的方式同样有寄存器传递和栈传递两种方式。
本地方法栈的创建和销毁与方法调用的进入和返回有关,在Java程序调用本地方法时,JVM会检查是否已经加载了本地方法所在的本地库,并确保本地库已经正确地链接到JVM中。然后,JVM会创建一个新的本地方法栈,并将本地方法的参数复制到本地方法栈中,本地方法开始执行。当本地方法执行完毕并返回时,JVM会将返回值复制回到Java程序中,然后销毁本地方法栈,继续执行Java程序中的其他代码。
本地方法栈在Java程序中的使用相对较少,通常是在需要调用本地库的情况下才使用本地方法栈。如果本地方法栈的空间不足,可以通过增加JVM的栈空间来解决。但是,在实际开发中,我们应该尽量避免使用本地方法,因为本地方法容易引起内存泄漏和安全问题,同时本地方法的跨平台性也比较差。
🍊 五、程序计数器
程序计数器是JVM中的一块较小内存区域,主要用于记录当前线程运行的字节码指令地址,也就是下一条要执行的指令在代码中的位置。JVM中所有线程都有一个独立的程序计数器,它是线程私有的,不会被其他线程访问。
程序计数器的作用是在多线程环境下保证线程切换后能恢复到正确的执行位置。当线程被中断或被抢占时,程序计数器记录了断点的位置,下次恢复时就可以从这个位置继续执行。线程执行Java代码时,程序计数器记录的是当前执行的字节码行数。
除了恢复现场,程序计数器还有一个作用就是支持代码的解释执行。字节码解释器按照程序计数器中的地址,从方法字节码中依次获取指令并执行。如果执行的是Java方法,则程序计数器记录的是该线程当前执行的Java方法地址,如果执行的是本地方法,则程序计数器记录的是undefined。
当线程调用了本地方法时,程序计数器保存的是undefined,当返回到Java方法时,程序计数器会恢复到该方法的指令地址。Java虚拟机规范要求程序计数器是线程私有的,每个线程独立维护。这种设计方案在一定程度上简化了线程上下文切换的操作。
程序计数器的大小是固定的,不会发生OOM错误。对于32位JVM来说,程序计数器的最大值是2的32次方,也就是4GB。对于64位JVM来说,程序计数器的最大值是2的64次方,也就是18EB(Exabytes),完全无需考虑OOM的问题。
程序计数器是JVM中非常重要的一个概念,它是实现Java虚拟机线程安全的关键所在。虽然它的作用看起来比较简单,但是却不可少。程序计数器负责记录线程下一条执行的指令,遇到中断或者线程切换时能够恢复到正确的执行位置,从而保证线程的正确性和安全性。