目录
一、什么是JVM
1.1.JVM的概念及作用
-
JVM全称是Java Virtual Machine,是一种可以执行Java字节码的虚拟计算机。
-
对于它的作用:
-
跨平台兼容性:JVM为Java程序提供了一个与底层硬件和操作系统无关的运行环境。这意味着在任何安装了JVM的设备上,都可以运行相同的Java程序。
-
安全性:JVM提供了一个安全的运行环境,它可以验证字节码以确保它们不会执行任何有害的操作。此外,它还提供了安全管理器来控制对系统资源的访问。
-
自动内存管理:JVM自动管理程序的内存分配和回收,这包括垃圾回收机制,以自动回收不再使用的内存空间,从而减少内存泄漏和提高程序性能。
-
执行Java字节码:JVM加载.class文件中的Java字节码,并解释执行这些字节码。这使得Java程序可以在不同的硬件和操作系统上运行,而不需要针对每个平台重新编译。
-
优化性能:JVM可以对字节码进行即时编译(Just-In-Time,JIT编译),将其转换为特定平台的机器码,从而提高程序的执行效率。
-
多线程支持:JVM支持多线程,允许程序同时执行多个任务,这可以提高程序的响应性和性能。
-
动态链接:JVM可以在运行时动态地链接新的类和方法,这意味着程序可以在运行时加载和使用新的类库。
1.2.JVM、JRE、JDK
知道了JVM的基本概念和作用,我们先来捋清楚三个JAVA中常见的名词,JVM(Java虚拟机)、JRE(Java运行时环境)、JDK(Java开发工具包)三者的区别。
-
JVM(Java虚拟机):
- 作用:JVM是Java程序运行的平台,它是一个抽象的计算机,能够执行存储在内存中的字节码。JVM为Java程序提供了一个与硬件和操作系统无关的运行环境。
- 组成:JVM包括类加载器、字节码解释器、垃圾回收器、内存管理器等组件。
-
JRE(Java运行时环境):
- 作用:JRE是运行Java程序所必需的最小环境,它包含了JVM以及运行Java程序所需的核心类库和基础工具。
- 组成:JRE包括JVM、Java核心类库、Java命令行工具(如java、javac等,但这些工具在JRE中通常不可用,因为它们主要用于开发)。
-
JDK(Java开发工具包):
- 作用:JDK是为Java开发者提供的一套工具集,它包含了编写、编译和运行Java程序所需的所有工具和库。
- 组成:JDK包括JRE(因此也包含了JVM)、编译器(javac)、调试器、打包工具(jar)、Java文档生成器(javadoc)等。
总结来说,JVM是JRE和JDK的组成部分,它是Java程序运行的核心。JRE是运行Java程序的最小环境,而JDK是为开发者提供的完整工具集,包含了JRE和开发Java程序所需的所有工具。同时需要注意的时,高版本比如JDK17已经没有JDK和JRE的区分了,只有JDK,而没有JRE的存在了。
二、JVM内部体系结构
在理解JVM体系结构之前,先对Java代码的执行过程做一个简单的梳理,从Java源代码(.java文件)到主机执行Java程序,这其中的过程可以分为两个阶段,即编译阶段(Compile Time)和运行阶段(Runtime)。
1、编译阶段(Compile Time)
在编译阶段,Java源代码(.java文件)被编译器(javac)转换成字节码文件(.class文件)
2、运行阶段(Runtime)
在运行阶段,这些字节码文件随后由Java虚拟机(JVM)的类加载器加载到JVM的运行时数据区中。字节码是JVM定义的一套指令集规范,它不能直接被底层操作系统执行。为了将字节码转换为机器码,JVM使用其内置的执行引擎。执行引擎可以是解释器,它逐行解释执行字节码;也可以是即时编译器(JIT编译器),它将频繁执行的热点代码编译成特定平台的本地机器码,以提高程序的执行效率。
在这个过程中,如果Java程序需要调用本地库或使用特定于平台的功能,它会通过本地Native接口(如Java Native Interface,JNI)与这些本地库交互。最终,无论是通过解释器解释执行还是通过JIT编译器编译的机器码,都会在操作系统上执行。操作系统的CPU负责执行这些机器码,完成程序的运行。JVM在整个过程中提供了内存管理、安全性检查、多线程支持和垃圾回收等关键服务,确保Java程序能够高效、安全地运行。
2.1.JVM的体系结构
我们可以先了解JVM主要由四大部分组成:ClassLoader(类加载器),Runtime Data Area(运行时数据区,内存分区),Execution Engine(执行引擎),Native Interface(本地库接口),下图可以大致描述 JVM 的结构。
2.2.JVM的架构
下图是JVM的更详细的架构图,即更详细标注JVM各组成部分的流程。比如类加载子系统,这加载、链接、初始化这三步,即是之前讲的类的加载过程(指虚拟机(JVM)将类的.class文件加载到内存中)。
2.3.类加载器
类加载器是Java虚拟机(JVM)的一个核心组件,负责将Java字节码文件(通常是.class
文件)加载到JVM中,使其成为可执行的Java应用程序的一部分。类加载器不仅加载类,还负责链接和初始化这些类。
2.3.1.类加载器的层次结构
Java类加载器体系结构是分层的,主要分为以下几种:
- 启动类加载器(Bootstrap ClassLoader):负责加载Java核心类库,如负责加载 JAVA_HOME\lib 目录中的。
- 扩展类加载器(Extension ClassLoader):负责加载扩展库中的类,如加载 JAVA_HOME\lib\ext 目录中的。
- 系统类加载器(System ClassLoader)/应用类加载器(Application ClassLoader):负责加载应用程序类路径(
classpath
)上的类。 - 用户自定义类加载器:开发者可以通过继承
ClassLoader
类来创建自己的类加载器。
2.3.2双亲委派机制
Java类加载器使用双亲委派模型,这是一种类加载器之间的层次关系和工作流程。当一个类加载器尝试加载一个类时,它会首先委托给其父类加载器去尝试加载这个类,如果父类加载器没有找到这个类,子类加载器才会尝试自己去加载。因为如果一个类被不同的加载器加载,那虚拟机会认定是不同的类,这样Java体系最基础的行为也无从保证,应用程序会变得混乱,所以有了双亲委派模型。
打破双亲委派机制
虽然双亲委派模型提供了类加载的有序性和安全性,但在某些情况下,可能需要打破这种机制。以下是打破双亲委派机制的方法:
- 自定义类加载器:通过继承
ClassLoader
并重写loadClass
方法来创建自定义类加载器。这样,可以不遵循双亲委派模型,实现类的隔离。 - 线程上下文类加载器:利用线程的上下文类加载器来加载类,这在JDBC和JNDI等场景中常见。
- OSGi框架:OSGi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载。
打破双亲委派机制的一个典型例子是Tomcat,它通过自定义类加载器实现了Web应用之间的类隔离。
2.4.沙箱安全机制、Native关键字、PC寄存器
沙箱安全机制:
Java安全模型的核心就是Java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问——CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
Native关键字:
在Java中,native关键字用于声明一个方法是由本地代码(通常是C或C++)实现的。这意味着该方法的具体实现不是用Java编写的,而是由底层的本地代码提供。
那在了解Native之前,我们先了解一下JNI,JNI(Java Native Interface,Java本地接口)是一个编程框架,它允许Java代码与其他语言编写的代码(如C或C++)进行交互。这个框架提供了一种机制,使得Java程序能够调用本地应用程序和库,JNI中的Invocation API可以用来将Java虚拟机(JVM)嵌入到本机应用程序中,从而允许开发人员从本机代码内部调用Java代码。
举一个native关键字的使用与方法实现:
先声明一个native方法(在这个例子中,nativeMethod()是一个native方法,它的具体实现将在本地代码中提供。):
public native void nativeMethod();
为了使用native方法,必须在Java程序中加载本地库,并确保本地库中包含了所需的函数。本地库可以使用Java的JNI来编写,并在程序运行时通过System.loadLibrary()方法加载。下面是一个简单的示例:
public class NativeExample {
static {
System.loadLibrary("nativeLibrary");
}
public native void nativeMethod();
public static void main(String[] args) {
new NativeExample().nativeMethod();
}
}
PC寄存器:
在JVM中,PC(Program Counter)寄存器是一个小的内存空间,用于存储当前线程正在执行的字节码指令的地址。当JVM执行字节码时,PC寄存器会指向当前正在执行的字节码指令。每条字节码指令执行完毕后,PC寄存器会更新为下一条要执行的字节码指令的地址。每个线程都有自己的PC寄存器,这意味着每个线程的执行状态是独立的。PC寄存器是线程私有的,生命周期与线程的生命周期保持一致 。具体想了解PC寄存器可以去学习计算机组成结构,这里只做简单介绍。
2.5.方法区
方法区是被所有线程共享的,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间。
静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中。但是实例变量存在堆内存中,和方法区无关。
垃圾回收(后文会介绍)
- 方法区是垃圾回收的一部分,但回收的频率和复杂度通常低于堆区域。
- 主要回收的是无用的类定义,即没有任何引用指向该类的类定义。
内存管理
- 方法区的大小通常比堆小,但它的大小也会影响JVM的性能。
- 方法区的大小可以通过JVM参数进行配置,如
-XX:PermSize
和-XX:MaxPermSize
(在Java 8之前)。
Java 8的变化
- 在Java 8中,方法区的实现发生了变化。原本位于永久代(PermGen)的方法区被移到了Java堆中,称为元空间(Metaspace)。
- 元空间不再受到JVM内存限制,而是受到本地内存(即操作系统的内存)的限制。
- 这种改变减少了内存溢出(OutOfMemoryError)的风险,并使得JVM的内存管理更加灵活。
2.6.栈
在JVM(Java虚拟机)中,栈(Stack)是一种特殊的内存区域,它与堆(Heap)一起构成了JVM的主要内存结构。栈是线程私有的,每个线程在创建时都会创建自己的栈,这意味着每个线程都有自己的执行栈,线程间的栈不会相互影响。这个栈用于存储方法调用的相关信息,包括局部变量、操作数栈、方法返回地址等。
2.6.1.栈的组成
-
局部变量表(Local Variables):
- 存储方法中的局部变量(如基本数据类型、对象引用等)。
- 每个方法在被调用时都会创建一个局部变量表,用于存储方法参数和局部变量。
-
操作数栈(Operand Stack):
- 用于存储计算过程中的中间结果,如算术运算、方法调用等。
- 操作数栈是后进先出的,即最后压入的元素会最先被弹出。
-
动态链接(Dynamic Linking):
- 存储方法调用的引用,确保方法调用的正确性。
- 动态链接信息用于支持方法的动态分派(Dynamic Dispatch)。
-
方法返回地址(Return Address):
- 存储方法调用后的返回位置,以便方法执行完毕后能够返回到正确的位置继续执行。
2.6.2.栈的工作原理
-
方法调用:
- 当一个方法被调用时,JVM会创建一个新的栈帧(Stack Frame)。
- 栈帧包含了方法的局部变量表、操作数栈、动态链接信息和方法返回地址。
-
方法执行:
- 在方法执行过程中,局部变量会被存储在局部变量表中。
- 计算过程中的中间结果会被存储在操作数栈中。
-
方法返回:
- 当方法执行完毕时,会将结果返回给调用者,并更新调用者的局部变量表和程序计数器。
- 方法的栈帧会从栈中弹出,释放占用的内存空间。
public class StackExample {
public static void main(String[] args) {
int a = 10;
int b = 20;
int sum = add(a, b);
System.out.println("Sum: " + sum);
}
public static int add(int x, int y) {
int result = x + y;
return result;
}
}
在这个例子中,当main方法被调用时,JVM会为main方法创建一个栈帧,包含局部变量a、b和sum。当add方法被调用时,JVM会为add方法创建一个新的栈帧,包含参数x、y和局部变量result。
2.7.堆
JVM堆内存(Heap Memory)是Java虚拟机(JVM)中最大的一块内存区域,它是所有线程共享的。堆内存的主要作用是存储对象实例和数组。一个JVM只有一个堆内存,堆内存的大小是可以调节的。
2.7.1.堆内存的作用
-
存储对象实例:
- 几乎所有的对象实例都在堆内存中分配。当Java程序创建一个新对象时,JVM会在堆内存中为该对象分配空间。
-
垃圾回收的主要区域:
- 堆内存是垃圾回收器(Garbage Collector,GC)管理的主要区域。GC负责回收堆内存中不再使用的对象,以释放内存空间。
-
自动内存管理:
- JVM自动管理堆内存的分配和回收,开发者不需要手动管理对象的内存。
2.7.2.堆内存的组成
-
新生代(Young Generation):
- 新生代是堆内存的一部分,用于存储新创建的对象。新生代分为三个区域:
- Eden区:新创建的对象首先被分配到Eden区。
- Survivor区:在垃圾回收过程中,仍然存活的对象会被移动到Survivor区。Survivor区有两个,通常一个用于存放存活对象,另一个为空,等待下一次垃圾回收。
- Eden区和Survivor区之间的比例:通常Eden区比Survivor区大。
- 新生代是堆内存的一部分,用于存储新创建的对象。新生代分为三个区域:
-
老年代(Old Generation):
- 老年代用于存储在新生代中经过多次垃圾回收仍然存活的对象。老年代的对象通常存活时间较长,因此垃圾回收的频率相对较低。
-
永久代(PermGen)和元空间(Metaspace)
- 在JDK 1.8之前,类的元数据存储在堆的永久代(PermGen)中。在JDK 1.8及以后,永久代被移除,取而代之的是元空间(Metaspace)。元空间位于堆外,用于存储类的元信息,而方法区的静态变量、常量池仍然在堆中。(与文中2.5方法区一一印证)
2.7.3.堆内存分配策略
在Java虚拟机(JVM)中,对象的创建和垃圾回收遵循一个高效的内存管理流程。新对象通常在新生代的Eden区被创建。当Eden区填满时,Minor GC会被触发,清理掉不再使用的对象,而存活的对象则被移动到Survivor区,并随时间推移增加年龄。那些大尺寸或长时间存活的对象将直接进入老年代。JVM通过动态调整对象晋升策略来优化内存使用。当老年代内存不足时,Major GC会被触发,清理整个堆中的无用对象。
这一内存管理策略确保了JVM在对象创建和垃圾回收之间保持平衡,从而优化了内存分配和程序性能。通过调整JVM参数,如堆的大小和比例。
在IDEA里面观察:
2.7.4.堆内存调优
JVM提供了多种参数用于调节堆内存的大小和垃圾回收策略。常见的参数有:
- -Xms:设置JVM堆的初始大小。设置一个合理的初始大小可以避免JVM在启动时不断调整堆大小。
- -Xmx:设置JVM堆的最大大小。这个值应该根据应用程序的内存需求和机器的物理内存来设置。
- -Xmn:直接设置新生代的大小。这可以优化短期对象的分配和回收。
- -XX:NewRatio:设置新生代与老年代的比率。例如,-XX:NewRatio=2意味着新生代占堆内存的1/3,老年代占2/3。
- -XX:SurvivorRatio:设置Eden区和Survivor区的比例。这影响Minor GC的行为和频率。
- -XX:MaxTenuringThreshold:设置对象在晋升到老年代之前在Survivor区需要经过的GC周期数。
2.8.垃圾回收机制(GC)
垃圾回收(Garbage Collection,GC)是编程语言运行时环境自动释放不再使用的内存的机制。在像Java这样的语言中,垃圾回收是一个核心特性。
2.8.1.对象被回收契机
垃圾回收的基本原理是自动检测并回收那些不再被程序引用的对象所占用的内存。这里讲两个引用计数法和可达性分析算法是两种不同的垃圾检测机制。Java虚拟机(JVM)采用的是可达性分析算法
-
引用计数法:
- 它通过跟踪对象被引用的次数来判断对象是否可以被回收。
- 由于循环引用的问题,Java虚拟机(JVM)并没有采用引用计数法。(因为它是给对象中添加一个引用计数器,每被引用一次计数器就+1,反之-1。引用数为0时代表该对象可被回收。如果发生循环引用就会出现内存泄漏的情况)
-
可达性分析算法:
- 它通过从一组根对象开始,递归地追踪所有可达对象,以确定哪些对象是活跃的,哪些是垃圾。称为 “GC Roots”。
- 对于"GC Roots" 是垃圾回收中的一个术语,指的是垃圾回收器在进行可达性分析时,作为起始点的一组对象。这些对象是潜在的引用链的起点。
- 可以作为GC Root的对象:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
2.8.2.垃圾回收算法
以下是实现垃圾回收的具体算法,它们通常基于上述垃圾检测机制。常见的GC算法包括:
- 标记-清除(Mark-Sweep):基于可达性分析,先标记所有可达对象,然后清除未标记的对象。
- 复制(Copying):将内存分为两个区域,每次只使用一个区域。当一个区域满时,将存活的对象复制到另一个区域,然后清除当前区域。
- 标记-整理(Mark-Compact):先标记所有可达对象,然后移动存活的对象,使它们紧凑排列,然后清除边界外的所有对象。
2.8.3.垃圾回收器
Java虚拟机(JVM)提供了多种垃圾回收器(Garbage Collectors),以适应不同的应用场景和性能要求。以下是一些常见的JVM垃圾回收器的介绍:
-
Serial Garbage Collector :
- 这是最简单的GC实现,使用单线程进行垃圾回收。
- 在执行垃圾回收时,会暂停所有应用线程(Stop-The-World事件)。
- 可以通过
-XX:+UseSerialGC
参数启用 。
-
Parallel Garbage Collector :
- 也称为吞吐量收集器(Throughput Collectors),在Java 5至Java 8期间是默认的GC。
- 使用多线程进行垃圾回收,以提高回收效率。
- 同样会在执行垃圾回收时暂停应用线程。
- 可以通过
-XX:+UseParallelGC
参数启用 。
-
CMS (Concurrent Mark-Sweep) Garbage Collector :
- 目标是最小化垃圾回收的停顿时间。
- 使用并发标记-清除算法,以减少应用的停顿时间。
- 适用于响应时间敏感的应用。
- 可以通过
-XX:+UseConcMarkSweepGC
参数启用 。
-
G1 (Garbage-First) Garbage Collector :
- 专为多处理器机器和大内存设计的垃圾回收器。
- 从JDK 7 Update 4开始可用,并在JDK 9中成为默认垃圾回收器。
- 将堆划分为多个区域,并优先回收那些可能含有最多可回收对象的区域。
- 旨在在高吞吐量的同时,尽可能缩短垃圾回收造成的停顿时间。
- 可以通过
-XX:+UseG1GC
参数启用 。
2.8.4.Stop The World现象
Stop-The-World(STW)现象是Java虚拟机(JVM)在执行垃圾回收(GC)过程中的一个特性,它指的是在垃圾回收事件发生时,所有的Java应用线程都会被暂停,只有垃圾收集线程在运行。
为什么需要STW
STW现象的产生主要是因为垃圾回收需要在一个一致性的快照中进行。在进行可达性分析时,JVM需要确保对象的引用关系在分析过程中不会发生变化,否则分析结果的准确性将无法保证。因此,为了安全地进行垃圾回收,所有用户线程必须在某个安全点(SafePoint)停下,等待垃圾回收完成。
STW的影响
- 性能延迟:STW期间,所有的应用线程都会暂停,这意味着在垃圾收集发生时,应用程序将不会处理任何用户线程或执行任何用户代码,这会导致性能延迟。
- 响应时间:对于交互式或者需要快速响应的应用,STW现象会导致用户感受到卡顿,影响用户体验。
- 吞吐量下降:频繁的STW现象会降低应用程序的整体吞吐量,因为CPU时间被垃圾收集线程占用,用户线程的执行时间减少。
- 资源竞争:在多处理器系统中,垃圾收集可能会因为需要与应用程序线程竞争处理器资源而变得更加复杂。
我们GC调优的目标就是尽可能的减少STW的时间和次数。
2.9.JMM
这里简单提一下Java 内存模型(Java Memory Model 简称JMM)是一种抽象的概念,并不真实存在,指一组规则或规范,通过这组规范定义了程序中各个变量的访问方式。不要把JMM与JVM弄混淆。
三、JVM常见问题总结
1、内存溢出(OutOfMemoryError)问题是什么?有什么解决思路?
2、强引用、软引用、弱引用、虚引用分别是什么以及它们的区别?
3、MinorGC、 Mixed GC 、 FullGC的区别是什么?
4、对象回收分代回收的过程?
5、JVM监控和调优?有哪些工具?
PS:文章只做参考学习使用,记录巩固所学知识内容。