JVM

JVM架构

Java虚拟机的主要组件,包括类加载器、运行时数据区和执行引擎。

JVM内存管理

JVM内存结构

程序计数器

可以看做是当前线程所执行的字节码的行号指示计数器。

这块区域是JVM唯一没有规定oom的区域

Java虚拟机栈

Java虚拟机栈是线程私有的,它的生命周期与线程相同。每一个Java方法执行的同时都会创建栈帧,用于存储局部变量表、操作数栈、常量池引用等信息。

本地方法栈

本地方法栈为Native方法服务。它不是采用Java实现的,而是用C语言实现的。

Java堆

Java堆的作用就是存放对象实例,几乎所有的对象实例都是在这里分配内存。

Java堆是垃圾收集的主要区域,因此也被叫GC堆。现代的垃圾收集器基本都采用分代收集算法,该算法思想是针对不同的对象采取不同的垃圾回收算法。

虚拟机把Java堆分为以下三块:

  • 新生代(Young Generation)

    • Eden

    • From Survivos

    • To survivor

  • 老年代(Old Generation)

  • 永生代(Permanent Gerneration)

Java的堆不需要连续的内存,并且可以动态扩展内存,扩展失败会抛出oom的异常

方法区

方法区也被称为永生代。方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。

🔔 注意:和 Java 堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

💡 提示:

  • JDK 1.7 之前,HotSpot 虚拟机把它当成永久代来进行垃圾回收。可通过参数 -XX:PermSize-XX:MaxPermSize 设置。

  • JDK 1.8 之后,取消了永久代,用 **metaspace(元数据)**区替代。可通过参数 -XX:MaxMetaspaceSize 设置。

运行时常量区

Runtime Constant Pool 是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等信息,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容会在类加载后放入这个区域。

  • 字面量 - 文本字符串、声明为 final 的常量值等。

  • 符号引用 - 类和接口的完全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。

堆空间溢出

内存泄漏

Memory Leak

分析内存泄漏的方式:

  • FGC,从应用程序启动到采样时发生Full GC的次数

  • FGCT,从应用程序启动到采样时Full GC所用的时间

  • FGC次数越多,FGCT所需时间所需时间越多,越可能发生内存泄漏

内存溢出

如果不是内存泄漏,即内存中的对象都很正常,都是必须的,那就是内存真的不够用,尝试减少程序运行期间的内存消耗

JVM垃圾收集机制

引用计数法

给对象增加一个引用计数器,被引用则增加,不被引用则减1.引用计数为0则回收。

但是出现循环引用时,无法回收

显然Java不适合

可达性分析算法

通过GC Roots作为起始点进行搜索,JVM将能够达到的对象视为存货,不可达对象视为死亡。

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈中引用的对象

  • 本地方法栈中引用的对象(Native 方法)

  • 方法区中,类静态属性引用的对象

  • 方法区中,常量引用的对象

引用类型

强引用

被强引用的对象不会被垃圾收集器回收。

通过new关键词创建的对象

软引用

被软引用的对象,只有内存不够的情况下才会被回收

使用SoftReference类创建的软引用

弱引用

被弱引用(Weak Reference)关联的对象一定会被垃圾收集器回收,也就是说它只能存活到下一次垃圾收集发生之前。

使用 WeakReference 类来实现弱引用。

WeakHashMapEntry 继承自 WeakReference,主要用来实现缓存。

虚引用

又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。

为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

方法区回收

因为方法区主要存放永久代对象,而永久代对象的回收率比年轻代差很多,因此在方法区上进行回收性价比不高。

主要是对常量池的回收和对类的卸载。

类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。

  • 加载该类的 ClassLoader 已经被回收。

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。

垃圾收集算法

标记-清除算法

将需要回收的对象进行标记,然后清理掉被标记的对象。

不足:

  • 标记和清除过程效率都不高;

  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存

标记-整理算法

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销

复制算法

现在的商业虚拟机都采用这种收集算法来回收年轻代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。

分代收集

它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

一般将Java堆分为年轻代和老年代

  • 年轻代使用:复制算法

  • 老年代使用:标记-清理或者标记-整理算法

image-20240229205615736

新生代

新生代是大部分对象创建和销毁的区域,在通常的 Java 应用中,绝大部分对象生命周期都是很短暂的。其内部又分为 Eden 区域,作为对象初始分配的区域;两个 Survivor,有时候也叫 fromto 区域,被用来放置从 Minor GC 中保留下来的对象。

JVM 会随意选取一个 Survivor 区域作为 to,然后会在 GC 过程中进行区域间拷贝,也就是将 Eden 中存活下来的对象和 from 区域的对象,拷贝到这个to区域。这种设计主要是为了防止内存的碎片化,并进一步清理无用对象。

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代

老年代

放置长生命周期的对象,通常都是从 Survivor 区域拷贝过来的对象。当然,也有特殊情况,如果对象较大,JVM 会试图直接分配在 Eden 其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。

垃圾收集器

image-20240229210231226

上图中连线标识垃圾收集器可以配合使用

G1垃圾收集器既可也回收年轻代内存,也可以回收老年代内存。而其他垃圾收集器只能针对特定代的内存进行回收

串行收集器

串行收集器是最基本、发展历史最悠久的收集器

串行收集器采用单线程 stop-the-world 的方式进行收集。当内存不足时,串行 GC 设置停顿标识,待所有线程都进入安全点(Safepoint)时,应用线程暂停,串行 GC 开始工作,采用单线程方式回收空间并整理内存

单线程意味着复杂度低、占用内存更少,垃圾回收效率高;同时也意味着不能有效利用多核优势。事实上,它只适合堆内存不高、单核甚至双核CPU的场合。

Serial收集器

开启选项:-XX:+UseSerialGC

打开此开关后,使用 Serial + Serial Old 收集器组合来进行内存回收。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。

  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用

并行收集器

并行收集器是以关注吞吐量为目标的垃圾收集器

  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用的体验

并行收集器是server模式下的默认收集器

并行收集器与串行收集器工作模式相似,都是 stop-the-world 方式,只是暂停时并行地进行垃圾收集。并行收集器年轻代采用复制算法,老年代采用标记-整理,在回收的同时还会对内存进行压缩。并行收集器适合对吞吐量要求远远高于延迟要求的场景,并且在满足最差延时的情况下,并行收集器将提供最佳的吞吐量。

Parallel Scavenge 收集器

年轻代垃圾收集器

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是:

  • -XX:MaxGCPauseMillis - 控制最大垃圾收集停顿时间,收集器将尽可能保证内存回收时间不超过设定值。

  • -XX:GCTimeRatio - 直接设置吞吐量大小的(值为大于 0 且小于 100 的整数)。

缩短停顿时间是以牺牲吞吐量和年轻代空间来换取的:年轻代空间变小,垃圾回收变得频繁,导致吞吐量下降。

Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记-整理” 算法

ParNew收集器

ParNew收集器是Serial收集器的多线程版本

并发标记清除收集器

开启选项:-XX:+UseConcMarkSweepGC

打开此开关后,使用 CMS + ParNew + Serial Old 收集器组合来进行内存回收。

并发标记清除收集器是以获取最短停顿时间为目标

开启后,年轻代使用 ParNew 收集器;老年代使用 CMS 收集器,如果 CMS 产生的碎片过多,导致无法存放浮动垃圾,JVM 会出现 Concurrent Mode Failure ,此时使用 Serial Old 收集器来替代 CMS 收集器清理碎片。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

采用标记-清除算法实现。垃圾收集的主要步骤如下:

  • 初始标记

    • 需要stw,从GC roots开始,标记所有能直接关联的对象,速度很快。

  • 并发标记

    • 从GC roots直接关联的对象开始遍历整个对象图的过程,这个过程耗时较久,但是不需要stw,可以和用户线程并发执行。

  • 并发预清理

    • 这个阶段是为了尽量减少重新标记阶段的stw时间所设计的。

    • 此阶段做的事情和重新标记类似,也是对对象进行标记,但这里标记的是 新生代晋升的对象新分配到老年代的对象以及在并发阶段被修改了的对象

    • 由于这里可能会存在老年代引用了新生代的对象,传统做法是需要对老年代和新生代对象都进行一个遍历才可以得到相应的引用,所以这里可能会尽量等待一次新生代的GC,来尽可能的减少我们的扫描新生代所带来的代价

    • 对于老年代来说,CSM会将老年代分块card,一块是512字节,会有一个card table,一个字节数组,每一个值对应着一个card,在上一阶段的并发标记过程中,如果某个对象的引用发生了变化,那么这个块会被标记为dirty card

    • 在本阶段就会重新dirty card块,将对象引用的对象标识为可达。

    • 除上述的作用外,当有老年代引用新生代,对应的card table会被标记为相应的值(card table中是一个byte,有八位,约定好每一位的含义就可区分哪个是引用新生代,哪个是并发标记阶段修改过的).

    • 所以在年轻代的垃圾收集 过程中,通过扫描老年代的card table,就可以很快的识别出老年代引用新生代。

  • 重新标记

    • 为了修正在并发标记期间,因用户继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段需要stw,时间比第一个阶段要稍微久一点,但是远比并发标记时间短。

  • 并发清除

    • 清理删除掉标记为死亡的对象,由于不需要移动存活对象,这个阶段可以和用户线程并发执行。不需要stw

缺点

  • 对处理器资源非常敏感,因为是并发标记,所以这个过程中,会和用户线程争抢cpu

  • CMS收集器无法处理浮动垃圾(Floating Garbage),有可能出现Concurrent Mode Failure失败,这时候会触发使用Serial old收集器来重新对老年代进行垃圾收集。

    在CMS并发标记和并发清理阶段,用户线程还是继续运行的,自然会有新的垃圾出现,但是这些垃圾无法在本次垃圾回收过程中被回收,只能等待下次的垃圾回收。这一部分垃圾被成为浮动垃圾。同时也因为在垃圾收集阶段用户线程还要持续运行,那就还要预留足够的内存空间给用户使用。所以CMS不能够等待老年代几乎被填满的情况下再收集,必须预留内存,供并发收集时程序使用。

  • 标记清除算法本身的缺点,会产生大量的空间碎片。在没有足够大的连续空间分配对象时,会不得不提前触发Full gc,来合并碎片。

G1收集器

年轻代+老年代一起收集

Java类加载

Java类完整的生命周期包括以下几个阶段:

  • 加载

  • 链接

    • 验证

    • 准备

    • 解析

  • 初始化

  • 使用

  • 卸载

加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始。而解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定

类加载过程是指加载、验证、准备、解析和初始化这 5 个阶段

加载

加载啊是类加载的一个阶段,是指查找字节流,并且据此创建类的过程。

加载过程完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流

  • 将这个字节流所代表的静态存储结构转换为方法区的运行时存储结构。

  • 在内存中生成一个代表这个类的Class对象,作为方法区中这个类的各种数据的访问入口。

验证

验证时链接阶段的第一步,验证的目标时确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证大致完成4个阶段的检验动作:

  • 文件格式验证

  • 元数据验证 对字节码的描述信息进行语义分析,以保证其描述的信息符合Java语言的规范

  • 字节码验证。通过数据流和控制流分析,确保程序语义时合法、符合逻辑的。

  • 符号引用验证。发生在虚拟机将符号引用转换为直接引用的时候,对类自身以外(常量池中各种符号引用)的信息进行匹配性校验。

这个阶段很重要,但是并不是必须的阶段。

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值阶段。这里使用的是方法区的内存,即永久代or元数据区

其中实例变量并不会在这个阶段分配内存。它会在对象实例化时,随着对象一起分配在Java堆中。类加载发生在所有实例化操作之前,类加载只发生一次,类实例化可以进行多次。

注意:

  • 这里所设置的初始值通常情况下是数据类型默认的零值,如0,null,false等,而不是在Java代码中被显示赋予的值如下:

这里在准备阶段value被分配的值为0,而非123,但是对于常量

会被赋值为123,而非0

这里可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中。

解析

在class文件被加载到Java虚拟机之前,这个类无法直到其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

**解析阶段的目标是将常量池的符号引用替换为直接引用的过程。**解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

  • 符号引用。符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义地定位到目标即可。

  • 直接引用。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

初始化

在Java代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码中对其赋值。

如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >

初始化阶段才真正开始执行类中的定义的 Java 程序代码。初始化,为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化

类初始化步骤

  1. 如果类还没有被加载和链接,开始加载该类

  2. 如果该类的直接父类还没有被初始化,先初始化其父类

  3. 如果该类有初始化语句,则依次执行这些初始化语句。

类初始化时机

只有主动引用类的时候才会导致类的初始化。有如下几种主动引用情况:

  • 创建类的实例对象,new对象

  • 访问静态变量,(被final修饰、已在编译期把结果放入常量池的静态字段除外)

  • 访问静态方法。(这就是单例模式的静态内部类实现方式的基础)

  • 反射。如Class。forName("com.example.Test")

  • 初始化子类,初始化某个类的子类时,其父类也会被初始化

  • 启动类。Java虚拟机启动时被标明为启动类的类,直接使用Java.exe命令运行某个主类

被动引用并不会触发初始化,下面是几种被动引用的例子

  • 子类引用父类的静态字段

  • 数组定义来引用类

  • 常量在编译阶段会存入到调用类的常量池中,本质并没有直接引用到定义常量的类,因此不会出发定义常量的类的初始化。

类初始化细节

<clinit>方法的细节

  • 此方法由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。值得注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。

  • 虚拟机会保证在子类的此方法运行开始之前,父类的此方法已经运行结束。

  • 此方法并不是必须的,如果一个类中不包括静态语句块,也没有对类变量的赋值操作,编译器可以不为其生成此方法

  • 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作。但是接口与类不同的是,执行子接口的此方法,不需要先执行父接口的此方法,只有当父接口中定义的变量被子类使用时,父接口才会初始化。另外接口的实现类在初始化时也一样不会执行接口的此方法。

  • 如果多个线程同时加载一个类,那么只会有一个线程执行这个类的此方法,其他线程都会被阻塞等待。

ClassLoader

类加载器,负责将类加载到JVM中。在Java虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类

JVM加载class文件到内存有两种方式:

  • 隐式加载,JVM自动加载需要的类到内存中

  • 显示加载,通过使用classloader来加载一个类到内存中。

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,

也即如果两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那么这两个类就必定不相等。

Bootstrap ClassLoader

Bootstrap ClassLoader ,即启动类加载器 ,负责加载 JVM 自身工作所需要的类

Bootstrap ClassLoader 会将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中

Bootstrap ClassLoader 是由 C++ 实现的,它完全由 JVM 自己控制的,启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。

ExtClassLoader

扩展类加载器

ExtClassLoader负责将JAVA_HOME\lib\ext或者被 java.ext.dir系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。

AppClassLoader

AppClassLoader,即应用程序类加载器,由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。

AppClassLoader 负责加载用户类路径(即 classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

自定义类加载器

自定义类加载器可以做到如下几点:

  • 在执行非置信代码之前,自动验证数字签名。

  • 动态地创建符合用户特定需要的定制化构建类。

  • 从特定的场所取得 java class,例如数据库中和网络中。

双亲委派

下图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型。该模型要求除了顶层的Bootstrap ClassLoader外,其余的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般通过组合关系实现,而不是通过继承的关系实现。

image-20240301154702323

工作过程

一个类加载器首先将类加载请求传送到父类加载器,只有当父类加载器无法完成类加载请求时才尝试加载。

好处

使得Java类随着它的类加载器一起具有一种带有优先级的层级关系,从而使得基础类得到统一:

  • 系统类防止内存中出现多芬同样的字节码

  • 保证Java程序安全稳定运行。

实现

逻辑很简单,先检查类是否已经加载过,如果没有则交给父类加载器加载,如果父类加载失败抛出异常,此时尝试自己去加载

如何打破双亲委派机制

类的默认加载方式是双亲委派,如果我们有一个类想要通过自定义的类加载器来加载这个类,而不是通过系统默认的类加载器,其实就是不走双亲委派

最后更新于

这有帮助吗?