JVM
一、Java 运行时数据区域
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- Java 堆
- 方法区
- 运行时常量池
- 直接内存
二、JVM中对象相关
###
三、垃圾收集器与内存分配策略
1、判断对象已死?
- 如何判断对象是否存活?有以下两种算法:
- 引用计数算法
- 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一。当引用失效时,计数器值就减一。任何时刻计数器为零的对象就是不可能再被使用的
- 缺点: 引用计数就很难解决对象之间 相互循环引用的问题
- 可达性分析算法
- 这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
-
可作为GC Roots的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
- 引用计数算法
2、再谈引用
-
Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(SoftReference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱
- 强引用 是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用 是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内 存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
- 弱引用 也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只 能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
- 虚引用 也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
3、对象正真死亡
- 要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记
- 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可
4、回收方法区
- 方法区垃圾收集的“性价比”通常也是比较低的,因为条件比较苛刻
- 方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
- 判断一个常量是否废弃: 已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量
-
判定一个类型是否属于“不再被使用的类”需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。
2、垃圾收集算法
- 标记-清除算法
- 算法分为“标记”和“清除”两个阶段
- 它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
- 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
- 标记-复制算法
- 一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
- 缺陷:这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多
- Java虚拟机大多都优先采用了标记-复制算法去回收新生代
- 标记-整理算法
- “标记-整 理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可 回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内 存,“标记-整理”算法
- 缺点: 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行
3、算法细节实现
算法细节
安全点(Safepoint)和安全区域(Safe Region)在 JVM 中都是为了支持垃圾回收等需要暂停所有 Java 线程的操作而设计的机制,但它们的应用场景和工作方式有所不同。两者之间的关系主要体现在如何确保 JVM 在执行特定操作时能够安全地暂停和恢复线程。
关系概述
共同目标:两者都旨在解决 JVM 在执行某些全局操作(如垃圾回收、堆栈扫描等)时,需要所有 Java 线程暂时停止的问题。通过让线程在适当的时候暂停,JVM 可以保证内存状态的一致性,从而正确执行这些操作。
不同应用场景:
安全点:适用于大多数正常运行的线程,这些线程在执行过程中会定期到达安全点。这使得 JVM 能够相对快速地暂停所有线程,执行必要的操作后再次恢复它们。
安全区域:针对那些可能长时间不活动或无法频繁到达安全点的线程,比如处于休眠状态或等待外部资源的线程。这类线程可以在进入安全区域时通知 JVM,并在离开安全区域前确认是否可以安全离开。
相互补充:
安全点 提供了一个精确且高效的方式来管理大多数活跃线程的行为,但由于某些线程可能会长时间不活动(例如等待 I/O 操作完成),直接依赖安全点可能会导致这些线程错过 GC 的触发时机。
安全区域 则为这些特殊情况提供了解决方案。它允许线程在进入一个不会改变对象引用的状态区间时告知 JVM,这样即使在进行 GC 操作期间,这些线程也可以安全地保持暂停状态,直到 GC 完成后再继续执行。
工作流程对比
安全点的工作流程:
JVM 发出暂停请求。
各个线程尽快运行到最近的安全点并暂停。
JVM 执行所需的操作(如 GC)。
JVM 通知各线程恢复执行。
安全区域的工作流程:
当线程进入安全区域时,它会向 JVM 注册自己。
如果此时 JVM 正在进行 GC,该线程将被标记为等待状态,直到 GC 完成。
一旦 GC 完成,线程可以从安全区域中继续执行。
总结
安全点 和 安全区域 都是为了确保 JVM 能够安全地执行全局操作而设计的机制。
安全点 更适合于活跃线程,提供了一种高效的方法来暂停和恢复线程。
安全区域 则解决了那些可能长时间不活动的线程如何配合 JVM 进行全局操作的问题,使得 JVM 在任何时刻都能安全地执行垃圾回收或其他关键任务。
理解这两者的区别及其互补作用有助于更好地掌握 JVM 如何管理和优化多线程环境下的内存回收和其他系统级操作。
在 JVM 中,记忆集(Remembered Set, RS) 和 卡表(Card Table) 是用于优化垃圾收集器性能的重要数据结构,尤其是在处理跨代引用时。它们的主要目的是减少垃圾收集过程中需要扫描的对象数量,从而提高垃圾收集的效率。下面详细讲解这两个概念及其工作原理。
记忆集(Remembered Set, RS)
定义
记忆集是一种用于记录对象间引用关系的数据结构,主要用于解决跨代引用问题。在分代垃圾收集器中,堆内存被划分为不同的代(如年轻代和老年代)。当进行年轻代垃圾收集时,理论上只需要扫描年轻代中的对象即可,但如果老年代中有对象引用了年轻代中的对象,则这些引用被称为跨代引用。为了准确地进行垃圾回收,必须知道所有这样的跨代引用,而记忆集就是用来记录这种跨代引用的信息。
原理
记忆集的基本思想是为每个区域维护一个额外的数据结构,用于记录该区域内的对象对其他区域对象的引用。这样,在进行局部垃圾收集时,只需要检查相关区域的记忆集,而不是整个堆,大大减少了扫描的工作量。
应用场景:主要应用于分代垃圾收集器中,帮助确定哪些老年代的对象引用了年轻代的对象,以便在进行年轻代垃圾收集时能够正确地标记存活对象。
实现方式:可以有多种形式,如位图、链表等。选择哪种形式取决于具体的需求和性能考虑。
卡表(Card Table)
定义
卡表是一种特殊类型的记忆集,通常用于记录老年代对象对年轻代对象的引用。它通过将老年代划分成一系列固定大小的“卡片”(通常是512字节),并对每张卡片设置一个标志位来实现。如果某张卡片对应的区域内存在对年轻代对象的引用,则该卡片的标志位会被置为脏状态(Dirty),表示需要关注这张卡片上的对象。
原理
卡表的核心是一个字节数组,数组的每个元素对应着老年代的一部分(即一张卡片)。当发生对老年代对象的写操作时(如更新字段值),会触发一个写屏障(Write Barrier),这个写屏障负责检查是否产生了跨代引用,并相应地更新卡表中的标志位。
写屏障:写屏障是一段代码,它会在每次执行对象字段赋值操作时自动插入到程序中。其作用是检测是否有跨代引用产生,并更新卡表的状态。
标记清除:在进行年轻代垃圾收集时,垃圾收集器只需扫描那些标记为脏状态的卡片所代表的老年代区域,查找其中可能存在的对年轻代对象的引用。
工作流程
初始化阶段:创建并初始化卡表,为老年代分配足够的卡片,并将所有卡片标志位置为干净状态。
写操作阶段:每当老年代中的对象发生写操作时,触发写屏障,检查此次写操作是否涉及对年轻代对象的引用。如果有,则将对应的卡片标志位置为脏状态。
垃圾收集阶段:在进行年轻代垃圾收集时,首先根据卡表找到所有标记为脏状态的卡片,然后只对这些卡片对应的内存区域进行扫描,找出其中所有的跨代引用,确保年轻代中所有存活的对象都被正确地标记。
总结
记忆集提供了一种通用的方法来跟踪不同代之间的引用关系,适用于多种垃圾收集策略。
卡表作为记忆集的一种具体实现形式,特别适合于处理老年代对年轻代的引用,通过引入写屏障机制有效地减少了垃圾收集时需要扫描的对象数量,提高了垃圾收集的效率。
理解记忆集与卡表的工作原理有助于更好地掌握 JVM 的垃圾收集机制,特别是如何在多代垃圾收集环境中高效地管理内存。这对于优化应用程序性能、避免长时间的垃圾收集停顿具有重要意义。
并发的可达性分析
三色标记
4、经典垃圾收集器
垃圾收集器
5、内存分配与回收策略
新生对象通常会分配在新生代中,少数情况下,(例如对象大小超过一定阈值)也可能分配在老年代
- 对象优先在Eden分配
- 新生代由Eden和Survicor组成,一般比例是8:1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是空闲的
- 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
- Minor GC会将存活对象复制到空闲的Survivor区。
- 当Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的
- 大对象直接进去老年代
- 大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者 元素数量很庞大的数组
- HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区。之间来回复制,产生大量的内存复制操作。
- 长期存活的对象直接进入老年代
- HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存 活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对 象年龄(Age)计数器,存储在对象头中
- 对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象 年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁, 当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中
- 对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置。
- 动态对象年龄判断
- HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代
- 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄
- 空间分配担保
- 定义:Minor GC时,当Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代
- 空间分配担保过程:
- 在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。
- 如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
- 如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。
- 不满足空间分配担保条件(老年代最大可用连续空间不足或者-XX:HandlePromotionFailure设置不允许冒险)会触发Full GC
四、类文件结构
五、虚拟机类加载机制
- 类的加载时机
-
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历七个阶段。其中验证、准备、解析三个部分统称 为连接(Linking)。
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化 (Initialization)
- 使用(Using)
- 卸载(Unloading)
-
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历七个阶段。其中验证、准备、解析三个部分统称 为连接(Linking)。
-
类加载过程
- 《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”
-
遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
- 使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候
- 调用一个类型的静态方法的时候。
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
- 各个阶段详细介绍
- 加载
- 在这个阶段,JVM需要找到对应的类文件,并将其加载到内存中。具体来说,JVM会执行以下操作:
- 通过类名获取该类的二进制数据:通常是从.class文件中读取,但也可以从其他来源获取,如网络。
- 在内存中创建一个java.lang.Class对象:代表这个类,用于描述类本身的信息
- 链接(Linking),链接阶段又细分为三个子阶段:验证、准备、解析
- 验证(Verification):确保Class文件的字节码正确、符合当前JVM的要求且不会危害JVM的安全性
- 准备(Preparation):为类的静态变量分配内存空间,并设置默认初始值
- 解析(Resolution):将类、接口、字段和方法的符号引用转换为直接引用的过程
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何 形式的字面量,只要使用时能无歧义地定位到目标即可
- 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄
- 初始化(Initialization)这是类加载过程中的最后一步,也是真正开始执行类中定义的Java程序代码的阶段。
- 在这个阶段,JVM会执行类构造器
()方法,这个方法由编译器自动收集类中的所有静态语句块和静态变量的赋值动作组成。 - 这一步骤主要用来为类的静态变量赋予正确的初始值。
- 在这个阶段,JVM会执行类构造器
- 加载
- 类加载器
- 类与类加载器
- 对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间
- 即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
- Java类加载器是负责加载类到JVM中的组件。Java提供了三种默认的类加载器:
- Bootstrap Class Loader:这是最顶层的类加载器,用来加载核心Java库(如rt.jar)中的类。它是由本地代码实现的,不是Java类的一部分。
- Extension Class Loader:负责加载位于扩展目录(通常是jre/lib/ext目录或者由系统属性java.ext.dirs指定的位置)下的JAR包中的类。
- Application Class Loader:也称为系统类加载器,负责加载应用程序的类路径(classpath)上的类
- 自定义类加载器
- 双亲委派模型
- 双亲委派模型是Java类加载机制的核心概念之一,其工作原理如下:
- 当一个类加载器收到加载请求时,它不会立刻尝试自己去加载这个类,而是把这个请求委派给父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的Bootstrap类加载器。
- 如果父类加载器无法定位该类,则子类加载器才会尝试自己去加载。
- 这种机制确保了JVM中同一个类只会被加载一次,并且保证了核心Java API类不会被用户自定义的类覆盖,从而维护了Java程序的安全性和一致性
- 破坏双亲委派模型
- 破坏双亲委派模型的方法通常涉及自定义类加载器,这些加载器可以打破传统的委派顺序:
- 自定义类加载器:通过继承java.lang.ClassLoader并重写loadClass()方法来改变默认的加载逻辑。这样可以直接控制类的加载过程,包括是否首先委托给父加载器。
- 线程上下文类加载器:JDK 1.2之后引入了线程上下文类加载器(Thread Context ClassLoader),允许在运行时动态设置当前线程使用的类加载器。这主要用于解决某些框架内部的类加载问题,比如JNDI、JDBC驱动的加载等。通过这种方式,可以在一定程度上绕过双亲委派模型,让父类加载器能够访问到子类加载器加载的类
- 类与类加载器