JVM概要

发布时间 2023-10-23 21:00:46作者: Stargazer4u

JVM概要

学前思考:

JVM本质上是什么,是C/C++写的一个程序吗

JVM和操作系统有什么区别和联系

JVM为java程序提供哪些服务

概览

JVM由C/C++等可以和OS交互的语言编写,目的是在java程序运行时将字节码通过解释器或即时编译器转成对应平台的机器码,达到跨平台的作用。由此可以见JVM并不跨平台

JVM能够向OS申请内存内存回收,负责Java线程与操作系统线程之间的映射和管理,负责使用OS底层API进行IO等,总之就是可以作为中间人给上层Java程序提供OS服务

每当启动一个java程序,OS会在创建新的进程并加载一个JVM进去,由JVM类加载子系统加载并由执行引擎解释字节码放到机器上执行。所以JVM本质上是一个运行在OS上的C++程序(或者其他实现语言),他也有属于自己的内存和线程,认识这一点很重要。而Java程序是他的线程之一。(这一点没有验证过,自己推测的,存疑)

// TODO

存疑:Java程序执行过程中会有很多信息,比如方法区,堆,虚拟机栈,本地方法栈(Native)(本地是相对于不同平台来说的,比如不同地方的本地人,不同平台的本地方法)等,也就是所谓的JVM内存模型,但JVM内存模型并非真的是JVM这个程序本身全部的内存,JVM作为一个程序,有自己的栈和堆(也就是本地内存),而他存放Java程序的相关信息的位置是他自己的堆区,在这个区域内,才是所谓的JVM内存模型。由此也可以知道后面提到的JVM栈并不是真的计算机上的栈,而是JVM给Java程序抽象出来的栈。

Java内存模型(JMM)是一种抽象模型,并不真实存在,他的主要目的是描述Java程序中不同线程之间如何共享和操作内存。不在此处记录。

一张经典图,后面主要研究类加载子系统,运行时数据区,和垃圾回收器。

image-20231023101228622

多线程底层实现

当java程序申请创建线程,JVM先做好一些准备,例如给线程分配内存、缓冲区,准备寄存器,完成后,调用OS的接口创建线程,并且将java程序的该线程和OS给JVM分配的这个线程映射起来。

具体的OS内核线程和该线程是1v1还是1vN还是NvM,取决于OS自己如何实现。

JVM自身也有一些后台常驻内存,例如:GC线程,编译器线程,信号分发线程(接收发送到JVM的信号并调用对应的JVM函数)

JVM内存模型

对应运行时数据区。注意直接内存不属于JVM内存模型,但同样属于JVM向OS申请的内存,只不过他不被JVM的GC机制管辖,因此并不算在JVM内存模型中,JVM内存模型主要是针对Java程序建立的。

运行时数据区中,每个线程私有的部分是:PC,虚拟机栈,本地方法栈。

线程共享区域是堆和元空间。

image-20231023103607691

:最上面的图中的方法区是JVM规范中的概念,不同的JVM可以用不同的方式实现,例如HotSpot虚拟机1.8之前使用永久代实现方法区,之后使用元空间来实现方法区。

image-20231023114706560

直接内存

先把图中没画的说了。直接内存常用于NIO。也叫堆外内存

普通IO的劣势:内核先读取数据到系统缓冲区,然后JVM再把他复制到Java堆中,Java程序才能使用。

NIO使用直接内存:JVM申请一块内存,内核把数据读到这个区域,Java程序可以通过JVM访问这块区域,不必再复制。

其实这里有个疑惑:既然内核可以把数据读取到直接内存,为什么不能直接读到Java堆呢

GPT:理论上来讲可以直接复制,但Java堆是交给JVM管理的,直接进行操作可能存在一些不可预测的问题。

直接内存的问题:不受JVM的自动内存管理机制控制,因此需要手动进行显式的分配和释放操作,容易出现内存泄漏等问题(底层Java类的名字起个Unsafe更加强调这一点)。此外,直接内存的分配和回收代价相对较高,涉及系统调用和内存碎片等方面的开销。

PC

程序计数器存储当前线程执行到的字节码行号。如果当前执行的是本地方法,计数器的值为空。计数器大小固定,不存对象数据,所以不会OOM。

虚拟机栈

每个栈帧存放方法的局部变量表,操作数栈,动态链接,方法出口等。

操作数栈:JVM是栈式虚拟机,意思是他的运算指令是通过将操作数压栈计算然后出栈的方式来完成的,区别于寄存器式虚拟机。ref

动态链接:区别于C++中的动态链接,Jvm中的动态链接是为了实现多态,具体指:在栈帧中使用一个指向运行时常量池中该栈帧所属方法的引用,该引用指向实际要调用的方法。在执行方法调用时,JVM会通过动态链接将方法引用解析为实际的方法入口地址,然后跳转到该地址执行方法。// TODO 不是很懂

(Native)本地方法栈

GPT:

JVM的本地方法栈(Native Method Stack)主要用于执行Java程序中调用本地方法(Native Method)的过程。本地方法是使用本地语言(如C、C++)编写的方法,通过JNI(Java Native Interface)与Java程序进行交互。

本地方法栈的主要作用包括:

  1. 提供本地方法执行的环境:本地方法栈为本地方法的执行提供了必要的环境,包括方法的参数、局部变量以及方法的返回值等。
  2. 管理本地方法调用:本地方法栈负责管理本地方法的调用过程,包括方法的入栈和出栈操作,以及参数的传递和返回值的处理。
  3. 支持本地方法与Java程序的交互:通过本地方法栈,本地方法可以与Java程序进行数据的交互,包括读取和修改Java对象的状态,调用Java方法等。

Java堆

用于存放对象实例。从GC的角度可以被分为(jdk1.8后)新生代、老生代

元空间

元空间或者说方法区,使用直接内存,因此也被叫做非堆。类似于Cpp中的代码存储区,用于存放类的构造信息,例如构造函数,运行时常量,字段方法等类的元数据。类加载的时候会用到。

GC

内存回收,对于JVM来说,其实只是把之前给Java程序用的内存收回给JVM,而不是操作系统(至少第一步是返还给JVM)。

依然是之前提到的,JVM是一个程序,他把自己的堆内存拿来构建一个JVM内存模型给Java程序使用。

Java堆内存分布

GC的主要区域是Java堆。从GC的角度,他可以分成以下几个部分

查看源图像

新生代:老年代 1 : 2。

新生代中默认:Eden : S1 : S2 = 8:1:1

GC时机和流程

新生代GC

Java创建新对象先判断是否是大对象,是则直接放入老年代,否则放入Eden区。

大对象的阈值使用:XX:PretenureSizeThreshold设置

如果Eden区内存不足,触发MinorGC,对新生代中Eden和SurvivorFrom区域垃圾回收,并将幸存者(Survivor)复制到To区。然后清空另外两个区。

:每次GC幸存者对象的年龄+1,如果到了老年代阈值,则直接复制到老年代。用参数XX:MAxTenuringThreshold设置

如果幸存者是大对象,也复制到老年区。疑惑:按理说创建对象的时候大对象都已经分配到老年去了,为什么新生代还会有大对象?可能因为对象创建之后还能在内部new对象?

如果To区存不下,也放到老年区。

此时To区成了下一次GC的From区,并且Eden和To区(原来的From)为空。即From和To区每次GC都会切换角色。

老年代GC

老年代内存不够用就会触发MajorGC。先扫描并标记存活对象,然后清除未标记对象。容易产生内存碎片。

Ref Minor GC 与 FullGC 的触发时机

注意:FullGC不等同MajorGC,FullGC是对整个Java堆的GC,会触发所有分代的垃圾回收算法,包括清理新生代并进行对象复制,标记并清理老年代中的垃圾对象,还有元空间的相关数据。

GC算法

四种引用类型,如何确定垃圾,如何GC,有哪些GC器

如何确定垃圾

  • 引用计数,引用为0是垃圾。问题:循环引用
  • 用可达性分析解决循环引用。定义GCRoot对象,以这些对象向下搜索,如果对象和任何GCRoot都没有可达路径,说明是垃圾

Java有四种引用,你都知道么

  • 强引用:通常使用的就是。
  • 软引用:SoftReference类实现,内存不足的时候被回收。
  • 弱引用:WeakReference类实现,垃圾回收时一定被回收。
  • 虚引用:PhantomReference类实现,几乎没有直接访问对象的能力。虚引用的主要作用是在对象被垃圾回收时收到系统通知。它通常与引用队列一起使用,用于跟踪对象的回收过程。

如何回收垃圾

上面写GC时机流程的时候也基本介绍了。但还是有所区别。

  • 标记清除算法。容易产生内存碎片。
  • 标记整理算法。标记完后,将存活的对象集中到内存一端(集中起来),然后清除。
  • 复制算法。类似上面新生代的MinorGC描述的流程。将内存分成相等大小的两块,GC时候先将存活对象复制到另一块,然后清空这一块内存。适合对象存活时间不长的场景,否则要经常复制大量对象。
  • 上面三种其实并不完全等于之前描述的GC方法,因为上面三种并不是分代的。之前讲的是分代GC算法,针对对象不同大小和存活时间进行GC。
  • 分区回收算法。将整个堆空间分成连续的大小不同的小区域。根据系统可以接收的停顿时间,每次都快速回收托干小区域的内存,最后以累加的方式完成整个内存区域回收。

垃圾回收器

针对新生代和老年代有不同的垃圾回收器,还有用于分区算法的垃圾回收器。

新生代

都采用复制算法

  • Serial:单线程,简单高效,是JVM客户端模式下默认GC器
  • ParNew: 多线程,默认数量和CPU数量相同。可以XX:ParallelThreads调节
  • Parallel Scavenge: 多线程,可以自定义最大垃圾回收停顿时间和吞吐量。

老生代

有标记清除或标记整理算法

  • Serial Old
  • Parallel New : 设计上优先考虑吞吐量,其次停顿时间

以上两种都是标记整理。并且gc的时候都要停止工作线程。

  • CMS (ConcMarkSweep):目的是最短停顿时间。使用标记清除,整个过程中只有少部分时间需要停止工作线程。他的流程如下

    1. 初始标记(Initial Mark):暂停所有工作线程,标记所有从根对象直接可达的对象,这一步很快。
    2. 并发标记(Concurrent Mark):与应用程序线程并发运行,标记从根对象可达的对象以及这些对象引用的其他对象。
    3. 最终标记(Final Remark):暂停所有工作线程,完成最终的标记工作。
    4. 并发清除(Concurrent Sweep):与应用程序线程并发运行,清除被标记为垃圾的对象。

分区GC

标记整理

G1 (Garbage First) 回收器:目标是精确控制GC停顿时间,在有限时间回收最多的垃圾。

JVM参数调整

到这里大概有哪些参数可以调整也知道了。这里不会详细介绍参数,会给出可以调整的方面。

  • Java堆内存大小,和最大堆内存
  • 直接内存大小
  • 堆内存各部分比例和大小
  • 元空间大小
  • OOM时打印日志
  • 各种日志地址、时间戳
  • 并行和并发GC线程数
  • 指定GC器
  • 即时编译线程数
  • 指定类加载路径
  • ..........

类加载

类编译后为class文件,也叫字节码文件,JVM运行时会用类加载器将其加载到方法区中,并在Java堆中创建他的Class对象。

方法区中,使用同一加载器的情况下,每个类只会有一份Class字节流信息
Java堆中,使用同一加载器的情况下,每个类只会有一份 java.lang.Class 类的对象

ref

时机

  1. 类被创建实例
  2. 访问类的静态成员或方法
  3. 使用反射访问类
  4. 初始化子类:当一个子类初始化时,它的父类也会被加载。
  5. 启动执行的主类

流程

整体流程图

image-20231023162609898

注意,这5个阶段,并不是严格意义上的按顺序完成,在类加载的过程中,这些阶段会互相混合,交叉运行,最终完成类的加载和初始化。

例如:在加载阶段,需要使用验证的能力去校验字节码正确性。在解析阶段,也要使用验证的能力去校验符号引用的正确性。或者加载阶段生成Class对象的时候,需要解析阶段符号引用转直接引用的能力等等......

加载

  1. 通过类的全限定名去找到其对应的.class文件
  2. 将这个.class文件内的二进制数据读取出来,转化成方法区的运行时数据结构
  3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

Java虚拟机并没有规定类的字节流必从.class文件中加载,在加载阶段,程序员可以通过自定义类加载器,自行定义读取的地方,例如通过网络、数据库等。

Linking.验证

验证阶段的目的是保证文件内容里的字节流符合Java虚拟机规范,且这些内容信息运行后不会危害虚拟机自身的安全。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

验证的内容包括:文件格式,元数据(查看是否符合Java语法),字节码, 符号引用。详见上面的ref

Linking.准备

这个阶段,类的静态字段信息(即使用 static 修饰过的变量)会得到内存分配,并且设置为初始值

注意:即使代码这样写public static a = 3;在这个阶段还是赋值为0,真正赋值为3是在初始化阶段。

Linking.解析

此阶段把这个class文件中,常量池内的符号引用转换为直接引用。主要解析的是 类或接口、字段、类方法、接口方法、方法类型、方法句柄等符号引用。

初始化

初始化的过程,就是执行类构造器 <clinit>()方法的过程。

<clinit>() 方法的作用是什么?

() 方法的作用,就是给这些之前设置了初始值的static变量赋值。并且执行静态代码块。(if have)

<clinit>() 方法是什么?

方法 和 方法是不同的,它们一个是“类构造器”,一个是实例构造器
子类方法在执行前,Java虚拟机会保证父类的 () 已经执行完毕。而 方法则需要显性的调用父类的构造器。
() 方法由编译器自动生成,但不是必须生成的,只有这个类存在static修饰的变量,或者类中存在静态代码块但时候,才会自动生成()方法。

类加载器

类加载器参与整个类加载过程。JVM提供三种类加载器,用户也可以自定义。

类加载器根据类的全限定名(包名加类名),去他们自己专属的加载路径加载。

启动类加载器(Bootstrap Class Loader):负责加载<JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数制定的路径,例如 jre/lib/rt.jar 里所有的class文件。由C++实现,不是ClassLoader子类

拓展类加载器(Extension Class Loader):负责加载Java平台中扩展功能的一些jar包,包括<JAVA_HOME>\lib\ext 目录中 或 java.ext.dirs 指定目录下的jar包。由Java代码实现。

应用程序类加载器(Application Class Loader):我们自己开发的应用程序,就是由它进行加载的,负责加载ClassPath路径下所有jar包。

双亲委派机制

任何一个类加载器在接到一个类的加载请求时,都会先让其父类进行加载,只有父类无法加载(或者没有父类)的情况下,才尝试自己加载。当然, 可以自定义类加载器,在他的loadClass方法中不交给父类加载,就能破坏这个机制。

ClassLoader源码

为什么需要这个机制

保证类唯一性:Java中,对于任意一个类,都需要由加载它的类加载器和这个类本身来一同确立其在Java虚拟机中的唯一性。如果使用了不同的类加载器(通常是自定义的加载器)加载同一个类,那他们在JVM看来就是不同类型。

保证JDK安全:另外,能保证安全性。例如我们写了一个java.lang.Integer,加载的时候会委托给父类加载,父类会根据名字去他自己的路径下加载,他自己的路径肯定是jdk的类而不是我们自己写的。

除非你自己去他的加载路径把他换掉。那我们自己肯定不会吃饱了撑的去换这个。所以他能保证JDK的核心类不被恶意替换。

为什么要破坏这个机制

要是真就写了两个全限定名一样的类,但是实现不同,例如Tomcat里面不同的web项目。那么加载的时候会出问题。

破坏的时候,还会涉及到一个概念,线程上下文类加载器,它负责在该线程中加载类和资源。

setContextClassLoader(ClassLoader); //设置
Thread.currentThread().getContextClassLoader(); //获取

Tomcat中不同web项目在不同线程,设置不同的加载器即可破坏类加载机制,防止不同项目中类加载冲突。

Tomcat类加载器破坏双亲委派

tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。

为什么要重写ClassLoader

重写findClass:一个主要的原因是我们需要的类可能是从网络的输入流中读取,这就需要做一些加密和解密操作,需要自己实现加载类的逻辑。

重写loadClass:一个原因就是上面的破坏双亲委派。

性能监控和分析工具

  • jps,查看主机所有运行的Java程序信息。可以是远程主机
  • jinfo,查看java进程的某些配置参数,也可以修改某些参数(不停机)
  • jstat,监控JVM内存使用和垃圾回收情况
  • jstack,一般用于查看进程内线程的堆栈信息,根据调用栈信息,定位占用CPU时间最长的代码块
  • jmap,获取Java进程的堆内存使用情况、对象分布、类加载信息,还能生成内存映像文件,然后用内存分析工具读取并可视化分析。
  • GC日志,可以用GCViewer工具可视化分析。