type
status
date
summary
slug
tags
category
password
URL
icon
Java字节码文件
class文件本质上是一个以8位字节为基础的二进制流,各个数据按照指定顺序存放在该文件中。JVM基于特定的规则解析字节码文件,从而得到相关信息。
Class文件结构
常量池
常量池可以理解为Class文件中的资源仓库,主要存放的是两大类常量:字面量和符号引用。字面量类似于Java中的常量,比如字符串常量、final修饰的变量等;符号引用属于编译原理方面的概念,包括以下三种:
- 类和接口的全限定名
- 字段的名称和描述符号
- 方法的名称和描述符
字节码中的变量类型
B基本类型byte
C基本类型char
D基本类型double
F基本类型float
I基本类型int
J基本类型long
S基本类型short
Z基本类型boolean
V特殊类型void
L对象类型,以分号结尾,如Ljava/lang/Object;
Java字节码增强技术
字节码增强技术就是对现有的字节码进行修改或者动态生成全新字节码文件的一个技术。
我们可以使用ASM框架或者Javassist框架来实现字节码的修改。ASM框架是直接操作字节码文件,效率较高,而Javassist是在源代码层次操作字节码,操作更简单直观。
我们这里着重介绍下Javassist技术。在使用Javassist技术操作字节码时,我们主要需要关注ClassPool、CtClass、CtMethod和CtField这几个类就可以。CtClass是一个编译时类信息对象,它可以表示一个class文件;ClassPool是一个保存CtClass映射关系的一个哈希表,我们可以通过全限定名从ClassPool中获取到CtClass;CtMethod对应的类中的方法;CtField对应的是类中的字段。
运行时的类重载
要实现JVM运行时对类进行修改后重新加载,需要借助Instrument这个类库,Instrument是JVM提供的可以修改已加载类的类库。在JDK1.6之前,Instrument只支持在JVM启动加载类时生效。在JDK1.6后,其支持了在JVM运行过程中对类的修改。要使用Instrument提供的类修改功能,需要先实现ClassFileTransformer接口,该接口中有一个transform方法,我们可以使用Javassist在这个方法中修改类的字节码并返回修改后的字节码数组。
实现完ClassFileTransformer接口后,我们还需要借助Instrumentation类,我们可以将我们之前实现的transformer接口注册到这个类中,以及把需要修改的类也注册商。Instrumentation类借助JVM提供的Agent技术,当Agent被注册到JVM时,就会触发agentmain方法的执行然后对类的字节码进行修改。
JVMTI & Agent & Attach API
JVMTI是JVM Tool Interface,即JVM工具接口,其底层实现是Java调试接口。
通过JVMTI,可以实现对JVM的多种操作,它通过接口注册各种事件勾子,在JVM事件触发时,同时触发预定义的勾子,以实现对各个JVM事件的响应,事件包括类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、GC开始和结束、方法调用进入和退出、临界区竞争与等待、VM启动与退出等等。
Agent就是JVMTI的一种实现,启动Agent有两种方式,一种是在JVM启动时通过java -agentlib参数将Agent注册到JVM中,一种是在JVM运行过程中通过attach的方式,将Agent注册到JVM中。
Attach API 的作用是提供JVM进程间通信的能力,比如说我们为了让另外一个JVM进程把线上服务的线程Dump出来,会运行jstack或jmap的进程,并传递pid的参数,告诉它要对哪个进程进行线程Dump,这就是Attach API做的事情。在下面,我们将通过Attach API的loadAgent()方法,将打包好的Agent jar包动态Attach到目标JVM上。
类加载机制
类的生命周期
加载→验证→准备→解析→初始化→使用→卸载。
加载
加载阶段可以细分为三个子阶段:
- 通过一个类的全限定名获取类对应的二进制文件;
- 将这个二进制字节流代表的静态文件转为JVM方法区的运行时数据结构;
- 在Java堆中生成一个代表这个Java类的Class对象,作为堆方法区中这个数据的访问入口。
验证
验证是为了保证加载的二进制字节流的正确性以及安全性。
准备
准备阶段是为加载类中的静态变量分配内存空间,并分配默认值(注意,这些内存空间都是在方法区中分配)。
注意,对基本数据类型的全局变量或者静态变量来说,如果没有显式的赋值,则会在此阶段赋默认值。如果类中的变量同时被static和final修饰,那么在准备阶段,这个值就会被赋值为用户指定的值,而不再是默认值。
解析
解析就是将常量池中的符号引用替换为直接引用的过程。由于之前Class数据并未被加载到内存中,其地址不确定,因此用符号引用来唯一标识,加载到内存后就可以使用指针等方式直接标识了。
初始化
为类的静态变量赋真实值以及执行静态代码块。
类的初始化步骤:
- 如果这个类没有被加载,则先加载并连接该类;
- 如果这个类的父类还没有被初始化,则先初始化其父类;
- 如果类中有初始化语句,则执行执行这语句。
类的初始化时机:第一次使用该类的时候,使用类中的变量、方法、新建一个实例、使用反射加载该类。
使用
在Java程序中使用该类。
卸载
当JVM虚拟机终止(正常终止、异常终止)时会卸载各个类。
JVM内存结构
线程私有:虚拟机栈、本地方法栈、程序计数器
线程共享:方法区、堆。
程序计数器
程序计数器用来存放当前线程要执行的下一条指令,具体而言是下一条要执行的字节码指令地址。
虚拟机栈
虚拟机栈内部存着一个个的栈帧,每个栈帧代表一次方法调用。虚拟机栈主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
虚拟机栈部分可能会出现OutOfMemoryError异常。
本地方法栈
与虚拟机栈类似,虚拟机栈用于存放Java方法的调用,而本地方法栈用于存放本地方法的调用。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
堆内存
堆内存用于存放Java对象实例,该区域是线程共享的。在JDK1.8及以后得JVM版本中,堆内存可以划分为新生代和老年代。
新生代
新生代用于存放新创建的对象实例,其大小要小于老年代。新生代又可进一步划分为一个Eden区和两个Survivor区,默认比例是8:1:1。设计成这个格式是为了在Minor GC的过程中进行垃圾回收。
老年代
老年代主要存放新生代中放不下的大对象实例以及存活较久的对象实例。当老年代内存空间被占满时,会发生FullGC。默认情况下,新生代和老年代的内存分配比例是1:2.
对象的分配过程
- 新创建的对象会被分配在Eden区;
- 当Eden区达到垃圾回收的标准或者新创建的对象在Eden区中无法存放时,会触发新生代的垃圾回收机制;
- 垃圾收集器会先将Eden区存活的对象转移到Survivor区,如果Survivor区存放不下,则直接转移到老年代;
- 垃圾收集器清理Eden区的对象,并将新创建的对象分配到Eden区;
- 在Survivor区多次存活的对象,会被升级到老年代。
- 当老年代中内存不足时,会触发老年代的Major GC,垃圾收集器会标记老年代中的可达对象,并通过标记清理策略将不可达对象回收。
MinorGC、MajorGC和FullGC的区别?
MinorGC是在新生代可用空间不足时发生的垃圾回收,其触发频率高,执行速度较快;
MajorGC是老年代可用空间不足时触发的垃圾回收,其触发频率相对低,但执行时间长,会导致JVM不可用;
FullGC当整个堆内存或者老年代空间不足时,可能触发FullGC,FullGC会对整个堆的内存空间进行垃圾回收,其触发频率最低,执行时间最长,对JVM性能影响最大。
方法区
方法区与堆一样,也是线程共享的一块区域,它并不属于堆。运行时常量池属于方法区的一部分,Class文件中除了有类的版本、接口、方法等描述信息外,还有常量池,用于存放编译期生成的各种字面量和符号引用。
注意,方法区只是JVM规范中定义的一个概念。不同的虚拟机厂商有不同的视线方式。我们常说的永久代(在JDK1.8中被元空间取代)是HotSpot商家提出的一种方法区的实现方式。
可以通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置元空间的大小。
方法区的垃圾回收
方法区的垃圾回收主要是回收常量池中废弃的常量和不再使用的类型。
HotSpot虚拟机堆常量池中常量的回收策略很明确,当常量不再被使用时,即没有任何地方引用,即可被回收。
对于类的回收,需要同时满足三个条件:
- 该类的所有示例都已经被回收,即虚拟机中不包含该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,除非是精心设计的可替换的类加载器,否则该条件很难达到;
- 该类对应的java.lang.Class对象已经被回收,且无法通过任何反射方法访问该Class。
当虚拟机中的类满足以上三个条件时,才允许被回收,但是只是被允许,不一定会被回收。
JVM垃圾回收机制
垃圾回收机制
如何判断对象是否可以被回收:
引用计数算法:每当对象被引用,引用计数器就加1,取消引用后就减1。当对象的引用计数器的值为0时,就表明该对象可以被回收,但是会存在循环引用的问题。
可达性分析算法:在JVM加载的时候,会创建一些普通对象引用正常对象。这些普通对象作为正常对象的起始点,在进行垃圾回收时,会从GC Roots(起始点)开始向下搜索,当一个对象到GC Roots没有任何引用链时,表明该对象可以被回收。
1. 标记-清除算法(Mark and Sweep)
标记-清除算法是最基本的垃圾回收算法之一,它分为两个阶段:
- 标记阶段:从根对象(通常是活动线程的堆栈和静态对象)开始,递归地遍历所有可达对象,并标记为活动对象(即“存活”的对象)。
- 清除阶段:遍历整个堆,清除所有未被标记的对象,即不可达对象。清除后的内存空间可以被重新分配使用。
优点:
- 简单直观,容易实现。
缺点:
- 内存碎片问题:清除后会产生大量的内存碎片,不利于分配大对象。
- 效率问题:标记和清除的过程可能会耗费较长时间,且会引起停顿。
2. 复制算法(Copying)
复制算法是为了解决标记-清除算法中内存碎片问题而提出的一种GC算法。它将堆空间分为两块,每次只使用其中一块,当这一块的空间用尽时,就将存活的对象复制到另一块上,并清除已使用的空间。
- 分区:将堆空间分为两个大小相等的区域,通常称为From区和To区。
- 复制:在垃圾回收时,将活动对象从From区复制到To区,然后清空From区中的所有对象。
- 交换:将From区和To区的角色交换,使To区成为下一个垃圾回收时的From区。
优点:
- 解决了内存碎片问题,分配内存非常快速。
- 适合用于对象存活率较低的场景,如新生代的垃圾回收。
缺点:
- 浪费一半的堆空间,不能用于大对象的存储。
3. 标记-整理算法(Mark and Compact)
标记-整理算法结合了标记-清除和复制算法的优点,主要用于老年代的垃圾回收。
- 标记阶段:与标记-清除算法类似,标记所有活动对象。
- 整理阶段:将所有存活对象向堆的一端移动,然后清理掉边界以外的内存。
优点:
- 解决了标记-清除算法的内存碎片问题,减少了碎片化空间的浪费。
缺点:
- 整理过程中可能会造成较长时间的停顿,影响程序的响应性。
4. 分代算法(Generational)
分代算法是当前主流的垃圾回收算法,它根据对象的存活周期将堆分为不同的代(Generation),通常分为新生代(Young Generation)和老年代(Old Generation)。
- 新生代:大部分对象的存活周期较短,使用复制算法来回收。
- 老年代:存活周期较长的对象,使用标记-清理或标记-整理算法来回收。
优点:
- 针对不同代的特性采用不同的回收算法,提高了垃圾回收的效率和性能。
缺点:
- 需要进行代之间的数据交换,增加了复杂性。
5. 并发标记清除算法(Concurrent Mark-Sweep, CMS)
CMS算法是为了减少垃圾回收时的停顿时间而设计的一种并发垃圾回收算法。
- 标记阶段:并发地标记活动对象,不需要停止用户线程。
- 清除阶段:停止用户线程,清除未标记的对象。
优点:
- 减少了停顿时间,提高了程序的响应性。
缺点:
- 无法处理浮动垃圾(Floating Garbage),可能会产生内存碎片。
6. G1(Garbage-First)
G1是一种面向服务器的垃圾回收器,结合了标记-整理和分代算法的优点,逐步取代了CMS算法。
- 分区:将堆空间分为多个大小相等的区域,每个区域可以是Eden区、Survivor区或Old区。
- 并行:并发地执行垃圾回收,通过优先回收“价值最大”的区域来减少垃圾回收的停顿时间。
优点:
- 高效的垃圾回收,可以在一定程度上控制停顿时间。
缺点:
- 可能出现预期之外的停顿,不适合所有场景。
- 作者:luxinfeng
- 链接:https://www.luxinfeng.top/73c0aac86afb41209651d26dcd5ba706
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。