Skip to content

JVM

JVM内存结构

a. 程序计数器

程序运行前,JVM会将程序编译后的字节码加载到内存中;程序运行时,字节码解析器会读取内存中的字节码,按照顺序将字节码的指令解析成固定的操作。在这过程中,程序计数器(Program Counter Register)保存当前线程正在执行的字节码地址。

从字节码运行的原理来看,单线程模型下的程序计数器貌似可有可无,字节码解析器会按照顺序将字节码翻译成固定操作,即使遇到分支跳转,也无碍程序正确运行下去。然而,现实中的程序往往是通过多线程协作来完成一个任务的,CPU会为每个线程分配一定的时间片,一个线程在其时间片耗尽之后会挂起,直到它再次获得时间片后才会重新运行。为了确保程序正确运行,线程必须从挂起的地方重新执行。有了程序计数器,就可以保证在涉及线程上下文切换的情景下,程序依然能够正确无误地运行下去。

b. Java 虚拟机栈

JVM会给每个线程都分配一个私有的内存空间,称为Java虚拟机栈。

JVM只会对其执行两种操作:栈帧(Stack Frame)的入栈和出栈。也就是说,Java虚拟机栈是存储栈帧的后进先出队列(LIFO)。

栈帧是用来存储局部数据和部分过程结果的数据结构,主要包含 局部变量表(Local Variable Table)、操作数栈(Operand Stack)、指向当前方法所属类的运行时常量池的引用(Runtime Constant Pool Reference)

c. 本地方法栈

本地方法栈则为native方法服务的

d. 方法区

方法区(Method Area)是线程间共享的区域,在JVM启动时创建,用于存储类的元信息、静态变量、常量、普通方法的字节码等内容。方法区可以被实现成大小固定或可动态扩展和收缩,如果内存空间不满足内存分配要求就会抛出OutOfMemoryError异常。

运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)属于方法区的一部分,class文件被加载到内存后,其中的常量池信息(包括符号引用和编译期可知的字面值常量)就被存储于此。

e. 堆

Java堆是java虚拟机所管理内存中最大的一块内存空间,处于物理上不连续的内存空间,只要逻辑连续即可,主要用于存放各种类的实例对象。该区域被所有线程共享,在虚拟机启动时创建,用来存放对象的实例,几乎所有的对象以及数组都在这里分配内存(栈上分配、标量替换优化技术的例外)。

堆布局

JVM将堆划分成这么多的区域,主要是为了方便垃圾收集器对对象进行管理,现代的垃圾收集器一般都采用了分代收集算法。

对于HotSpot而言,新生代的回收被称为Minor GC,老年代的回收称为Major GC,而同时对新生代、老年代和方法区的回收则称为Full GC。如果出现Full GC的频率过高,就说明目前堆内存已经不太够用了。

新生代(Young/New Generation)
  1. 大多数新建的对象都位于Eden区。
  2. 年轻代通过Minor GC进行回收,垃圾收集器会将Eden区域和S0区域中存活的对象复制到S1区域中,然后将前两个区域清空。当下次回收的时机到来时,则将Eden区域和S1区域中存活的对象复制到S0区域上,然后将前两个区域清空,依次交替进行。
  3. 如果位于新生代中的对象经过几轮(模式是15岁)垃圾回收都存活了下来,JVM就会将这些对象转存到老年代中。通常这是在年轻代有资格提升到年老代前通过设定年龄阈值来完成的。(年龄阈值可以通过参数-XX:MaxTenuringThreshold来设置)
  • 新生代上都是些 “小” 对象,而且存活率低,进而复制成本较低,因此通常采用复制算法进行垃圾回收
  • 一些 “大” 对象创建时,如果新生代没有足够的空闲内存,JVM也会在老年代中为其分配内存。(当对象过大时或特别大时,因为占用幸存者区空间过大或大于幸存者区,而造成反复GC,重复复制大对象增加GC时间问题,JVM在这方面有专门的优化,就是大对象直接进入老年代,使用JVM参数-XX:PretenureSizeThreshold(单位为字节)设置大对象的大小,如果对象超过设置的大小会直接进入老年代,防止大对象进入年轻代造成重复GC)

年轻代回收过程

老年代 ( Old Generation)

什么样的对象可以进入老年代?

  • Minor GC后S区容纳不下的对象
  • 长期存活的对象
  • 大对象。需要连续大量内存空间的Java对象
  • 动态对象进行年龄判定进入老年代

老年代中的对象通常都是些生命周期较长或者占用空间较大的对象,其复制成本较大,因而垃圾收集器通常采用标记-清除算法进行垃圾回收

老年代回收过程

堆为什么这样划分?
  1. 为了使jvm能够更好的管理内存中的对象,包括内存的分配以及回收
  2. 新生代按eden和两个survivor的分法的优点有以下几个好处:
    1. 有效空间增大,eden+1个survivor
    2. 两个Survivor区可解决内存碎片化
    3. 利于对象代的计算。当一个对象在S0/S1中达到设置的XX:MaxTenuringThreshold值后,会将其挪到老年代中,即只需扫描其中一个survivor。如果没有S0/S1,直接分成两个区,就没法计算对象经过了多少次GC还没被释放。

堆(Heap)和JVM栈是程序运行的关键

  • 堆存储的是对象。

  • 栈存储的是基本数据类型和堆中对象的引用(参数传递的值传递和引用传递)

  • 栈是【运行时】的单位(解决程序的运行问题,即程序如何执行,或者说如何处理数据)。

  • 堆是【存储】的单位(解决的是数据存储的问题,即数据怎么放、放在哪儿)

那为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?
  • 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据,分工明确,处理逻辑更为清晰体现了“分而治之”以及“隔离”的思想。
  • 堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。 这样共享的方式有很多收益:提供了一种有效的数据交互方式(如:共享内存);堆中的共享常量和缓存可以被所有栈访问,节省了空间。
  • 栈因为运行时的需要,比如保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。
  • 堆和栈的结合完美体现了面向对象的设计。当我们将对象拆开,你会发现,对象的属性即是数据,存放在堆中;而对象的行为(方法)即是运行逻辑,放在栈中。因此编写对象的时候,其实既编写了数据结构,也编写的处理数据的逻辑。

堆栈相关参数

核心参数
  • -Xms 堆内存初始大小,单位m、g
  • -Xmx 堆内存最大允许大小,一般不要大于物理内存的80%
  • -Xmn 年轻代内存初始大小
  • -Xss 每个线程的堆栈大小,即JVM栈的大小

-Xms 和 -Xmx 一般设置相等

设置参数的技巧
  1. 每次GC 后会调整堆的大小,【为了防止动态调整带来的性能损耗】,一般设置-Xms-Xmx 相等
  2. 推荐使用的是-Xmn参数,原因是这个参数很简洁,相当于一次性设定 NewSizeMaxNewSize ,而且两者相等

JVM对象

创建对象的方式
  1. 使用new关键字。调用无参或有参构造器函数创建
  2. 使用Class的newInstance方法。调用无参或有参构造器函数创建,且需要是public的构造函数
  3. 使用Constructor类的newInstance方法。调用有参和私有private构造器函数创建,实用性更广。可以调用私有的构造器是因为 Java 中的反射机制允许你访问和操作对象的私有成员,包括私有构造器
  4. 使用Clone方法。不调用任何参构造器函数,且对象需要实现Cloneable接口并实现其定义的clone方法,且默认为浅复制
  5. 第三方库Objenesis。利用了asm字节码技术,动态生成Constructor对象
JVM对象分配
在虚拟机层面上创建对象的步骤

对象分配内存的策略
  1. 空闲链表

    空闲列表是一种通用的内存分配策略,适用于堆内存中有不同大小的对象。 在这种策略中,堆内存被划分为多个内存块,每个内存块可以容纳不同大小的对象。 一个空闲列表维护了可用内存块的列表,分配器会在列表中查找合适大小的内存块来分配对象。 分配对象后,相应的内存块将从列表中移除,当对象不再使用时,内存块会返回到空闲列表。 空闲列表允许更灵活地管理内存,但可能需要更多的内存管理开销(因为需要维护一个列表)。

  2. 指针碰撞

    这是一种内存分配策略,通常用于具有固定大小的堆内存区域,例如在使用”标记-清除”或”复制”垃圾回收算法时。 在指针碰撞中,堆内存被看作是一个连续的内存块,分配新对象时,分配器会维护一个指针,指向当前可用内存的位置。 当分配对象时,分配器会将指针向前移动到下一个可用内存块的位置,并返回对象的引用。 这种方法非常高效,但要求堆内存必须是连续的,而且不适用于可变大小的对象

如何判断一个对象是否存活

引用计数 和 可达性分析

引用计数法:引用计数法是一种最简单的垃圾回收算法,其中每个对象都有一个与之关联的引用计数器。每当有一个引用指向对象时,计数器加1,引用失效时减1。当计数器为0时,对象被认为是垃圾。这个方法容易理解,但无法处理循环引用的情况,因此在Java中不常用。

可达性分析算法:可达性分析是Java主要采用的垃圾回收算法。它从一组称为”GC Roots”(通常是栈、静态变量、方法区中的类引用等)出发,通过一系列引用链追踪对象的可达性。如果一个对象无法从GC Roots访问到,它被认为是不可达的,即可被垃圾回收。这个算法能够处理循环引用和复杂的对象引用关系。

什么对象可以被GC Root

  • 虚拟机栈中的引用对象:函数内的局部变量、参数、异常处理器等
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中JNI(Java Native Interface)引用的对象

被判定为不可达对象后,会立即被判定为死亡吗?

不会立即被判定为死亡。而是在“缓刑”阶段。

不可达对象要经过至少2次标记过程,才能宣告死亡:

  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记
  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法
    1. 假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。直接死亡
    2. 如果这个对象被判定为有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。 —— 收集器将对F-Queue中的对象进行第二次标记

    “执行” 是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做是因为,如果某个对象finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,卡死在这里。甚至导致整个内存回收子系统的崩溃。

在标记过程中,如果重新与引用链上的任何一个对象建立关联,即可复活

触发GC的场景

触发FullGC的原因

Full GC(Full Garbage Collection)是指对整个堆内存进行垃圾回收的过程。在进行 Full GC 时,会对年轻代和老年代(以及永久代或元数据区)中的所有对象进行回收。

  • 显式调用system.gc()
  • 老年代空间不足
  • 永久代(方法区)空间不足
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • JVM自身固定评率的Full GC
  • 堆内存中分配的很大的对象以致超过了老年代剩余的空间,此时会触发Full GC

触发Young GC的原因

  • Eden空间不足
  • Full GC

JVM三种类加载器 ⭐️

  1. 启动类加载器(Bootstrap Class Loader):这是JVM的内置类加载器,负责加载Java核心类库,如java.lang包中的类。它通常由JVM的实现提供,不会被Java应用程序直接引用。
  2. 扩展类加载器(Extension Class Loader):扩展类加载器负责加载Java平台的扩展库,位于JRE的lib/ext目录下。开发人员可以通过系统属性来扩展这个类加载器的搜索路径。它是启动类加载器的子类加载器。
  3. 应用程序类加载器(Application Class Loader):也称为系统类加载器,它负责加载应用程序中的类。这是大多数Java应用程序默认使用的类加载器,它会搜索CLASSPATH中指定的路径来加载类。它是扩展类加载器的子类加载器。
  4. 除了这三种主要的类加载器,还可以通过编写自定义的类加载器来实现特定的类加载需求,例如从网络或非标准位置加载类。这些自定义类加载器必须继承自java.lang.ClassLoader类,并实现自己的类加载逻辑。类加载器在Java中起到了关键的作用,它们帮助实现了类的动态加载和隔离,使得Java应用程序更加灵活和安全。

双亲委派机制工作流程

  1. 首先,应用程序类加载器尝试加载类。
  2. 如果应用程序类加载器无法找到类,它会将请求委派给其父类加载器,即扩展类加载器。
  3. 扩展类加载器再次尝试加载类,如果仍然找不到,它会将请求委派给启动类加载器。
  4. 启动类加载器在自己的类路径中查找类,如果找到了,加载成功。
  5. 如果启动类加载器也找不到该类,会抛出ClassNotFoundException。

Java类是如何被加载的

  1. 加载(Loading):类加载的第一步是加载类的字节码文件。这发生在类加载器查找类文件并将其读取到内存中的阶段。这个阶段不会执行类中的静态代码块或初始化变量
  2. 验证(Verification):在这一步,类加载器会验证加载的字节码文件是否符合Java虚拟机规范,以确保它是安全和合法的。(文件格式验证,元数据验证,字节码验证,符号引用验证)
  3. 准备(Preparation):在准备阶段,为类的静态变量分配内存并初始化为默认值(例如,数值类型初始化为0,引用类型初始化为null),不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中
  4. 解析(Resolution):在这一步,符号引用被替换为直接引用。这包括将类、方法和字段的引用解析为实际的内存地址
    • 符号引用:字符串,能根据这个字符串定位到指定的数据,比如java/lang/StringBuilder
    • 直接引用:内存地址
  5. 初始化(Initialization):这是类加载的最后一步,也是最重要的一步。在这个阶段,类的静态初始化代码块(static代码块)会被执行,静态变量会被赋予初始值。初始化只会执行一次,确保在多线程环境下也只有一个线程执行初始化。
总结,上面可以步骤可以分为3大步骤:
  1. 加载: 查找并加载类的二进制数据。
  2. 链接: 将 Java 类的二进制数据合并到 JVM 运行状态之中。
    1. 验证:验证加载的类是否符合 Java 虚拟机规范。
    2. 准备:为类的静态变量分配内存,并设置默认初始值。
    3. 解析:将类中的符号引用转换为直接引用。
  3. 初始化: 执行类的初始化代码,包括静态变量赋值和静态代码块的执行。

双亲委派机制

双亲委派机制作用

双亲委派机制(Parent Delegation Model)是Java类加载器的一种工作机制,用于保证类的加载和安全性。它的核心思想是父类加载器委派给子类加载器加载类,确保类的一致性和防止恶意类的加载。

这个机制的好处在于确保类加载的一致性,避免了同一个类被不同的类加载器多次加载,从而防止类的冲突和安全问题。它还有助于隔离不同类加载器加载的类,确保类加载器之间的互相影响最小化。这在Java的类加载和安全性方面发挥了重要作用。

双亲委派机制缺陷? ChatGPT

  • 限制类加载器的灵活性:双亲委派机制对于自定义类加载器的灵活性有一定限制。有些应用场景可能需要自定义类加载器以实现特定的加载需求,但双亲委派机制可能阻碍了这一自由度。
  • 无法实现类加载的隔离:尽管双亲委派机制可以确保类加载器之间的类不会冲突,但在某些情况下,应用程序可能需要加载相同名称的类的多个版本。这是一种类加载隔离的需求,双亲委派机制无法轻松满足。
  • 不适合模块化系统:在模块化系统中,例如Java 9引入的模块系统,双亲委派机制可能显得过于复杂。模块系统本身提供了更精细的类加载和依赖管理机制,与传统的双亲委派机制不太兼容。
  • 破坏了动态类加载的一致性:在某些动态代码生成和加载类的场景中,双亲委派机制可能不太适用。动态生成的类可能不符合双亲委派机制的预期工作流程。
  • 性能开销:双亲委派机制在类加载时需要进行一系列的委派和检查操作,这可能导致一些性能开销。对于某些需要高性能的应用程序,这可能会成为一个问题。

虽然双亲委派机制有上述缺陷,但它在大多数标准Java应用程序中仍然是一种有效的安全和类加载机制。然而,在某些特殊情况下,开发人员可能需要绕过双亲委派机制,使用自定义类加载器来满足特定需求。这需要开发人员谨慎考虑并了解潜在的安全和一致性风险。

如何打破双亲委派模型?

  1. 自定义类加载器,继承ClassLoader类重写loadClass方法;
  2. SPI(Service Provider interface)
    1. 服务提供接口(服务发现机制)
    2. 通过加载ClassPath下META_INF/services,自动加载文件里所定义的类
    3. 通过ServiceLoader.load/Service.providers方法通过反射拿到实现类的实例

SPI应用:

  • JDBC获取数据库驱动连接过程就是应用这一机制,使用SPI server provider模式的JDBC JAXP都是破坏了双亲委托模式的,在核心类库rt.jar的加载过程中需要加载第三方厂商的类,直接指定使用线程上下文类加载器也就是应用程序类加载器来加载这些类
  • apache最早提供的common-logging只有接口.没有实现..发现日志的提供商通过SPI来具体找到日志提供商实现类
  • Tomcat中的web 容器类加载器也是破坏了双亲委托模式的,自定义的WebApplicationClassLoader除了核心类库外,都是优先加载自己路径下的Class;

总结: 在重写loadclass的过程中,只要不遵从JVM的规范就行了,不盲目的优先向Parent 的ClassLoader进行查找就行了

Tomcat是如何打破双亲委派模型?

Tomcat有着特殊性,它需要容纳多个应用,需要做到应用级别的隔离,而且需要减少重复性加载,所以划分为:

终端窗口
/common 容器和应用共享的类信息
/server 容器本身的类信息
/share 应用通用的类信息
/WEB-INF/lib 应用级别的类信息

整体可以分为:BoostrapClassLoader->ExtensionClassLoader->ApplicationClassLoader->CommonClassLoader->CatalinaClassLoader(容器本身的加载器)/ShareClassLoader(共享的)->WebAppClassLoader。 虽然第一眼是满足双亲委派模型的,但是不是的,因为双亲委派模型是要先提交给父类装载,而Tomcat是优先判断是否是自己负责的文件位置,进行加载的

打破双亲委派模型:虽然Tomcat的Web应用程序类加载器遵循一定的委派机制,但它可以被配置为不完全遵循双亲委派模型。通过配置元素的delegate属性,可以控制类加载器是否首先尝试从父类加载器加载类。

JVM运行流程

  1. 程序在执行之前先要把 Java 代码转换成字节码(class 文件),JVM 首先需要把字节码通过一定的方式类加载器(ClassLoader) 把文件加载到内存中运行时数据区(Runtime Data Area)
  2. 字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器,也就是 JVM 的执行引擎(Execution Engine)会将字节码翻译成底层系统指令再交由 CPU 去执行;
  3. 在执行的过程中,也需要调用其他语言的接口,如通过调用本地库接口(Native Interface) 来实现整个程序的运行

网站当前构建日期: 2024.07.18