网络营销的策略有哪些,福州seo优化,在家做的网站编辑,谷歌网站地图生成目录
1. 性能监控和调优
1.1 调优相关参数
1.2 内存泄漏排查
1.3 cpu飙⾼
2. 内存与垃圾回收
2.1JVM的组成#xff08;面试题#xff09;
2.2 Java虚拟机栈的组成
2.3 本地方法栈
2.4 堆
2.5 方法区#xff08;抽象概念#xff09; 2.5.1 方法区和永久代以及元空…目录
1. 性能监控和调优
1.1 调优相关参数
1.2 内存泄漏排查
1.3 cpu飙⾼
2. 内存与垃圾回收
2.1JVM的组成面试题
2.2 Java虚拟机栈的组成
2.3 本地方法栈
2.4 堆
2.5 方法区抽象概念 2.5.1 方法区和永久代以及元空间是什么关系
2.5.2 为什么将永久代替换为元空间
2.5.3 方法区常用参数有哪些 2.5.4 运行时常量池
2.5.5 字符串常量池
2.5.6 直接内存
2.6 对象的创建
2.7 对象的访问定位
2.8 垃圾回收
2.8.1 内存分配和回收原则
2.8.2 死亡对象的判断方法
2.8.3 垃圾收集算法
2.8.4 垃圾收集器
3. 字节码与类的加载
3.1 JVM如何运行Java代码
3.1.1 编译期
3.1.2 运行时
3.2 javap与字节码
3.2.1 javap
3.2.2 字节码的基本信息
3.2.3 常量池
3.2.4 字段表集合 3.2.5 方法表集合
3.3 字节码指令详解
3.3.1 加载load与存储指令store
3.3.2算术指令
3.3.3 类型转换指令 3.3.4 对象的创建和访问指令
3.3.5 方法调用和返回指令
3.3.6 invokedynamic
3.3.7 方法返回指令
3.3.8 操作数栈管理指令
3.3.9 控制转移指令
3.3.10 异常处理时的字节码指令
3.3.11 synchronized的字节码指令
3.4 类加载过程详解
3.4.1 类的生命周期 3.4.2 类加载过程
3.5 类加载器详解面试
3.5.1 类加载器介绍
3.5.2 类加载器加载规则 3.5.3 类加载器总结
3.5.4 自定义类加载器
3.6 双亲委派模型机制面试
3.6.1 双亲委派模型的执行流程 3.6.2 双亲委派模型的好处
3.6.3 打破双亲委派模型的方法 1. 性能监控和调优
1.1 调优相关参数
-Xms设置堆的初始化⼤⼩
-Xmx设置堆的最⼤⼤⼩
-Xss对每个线程stack⼤⼩的调整-Xss128k
jconsole⽤于对jvm的内存线程类的监控。
VisualVM 能够监控线程内存情况。
1.2 内存泄漏排查
1、通过jmap或设置jvm参数获取堆内存快照dump
2、通过⼯具 VisualVM去分析dump⽂件VisualVM可以加载离线的dump⽂件
3、通过查看堆信息的情况可以⼤概定位内存溢出是哪⾏代码出了问题
4、找到对应的代码通过阅读上下⽂的情况进⾏修复即可。
1.3 cpu飙⾼
1.使⽤top命令查看占⽤cpu的情况
2.通过top命令查看后可以查看是哪⼀个进程占⽤cpu较⾼
3.使⽤ps命令查看进程中的线程信息
4.使⽤jstack命令查看进程中哪些线程出现了问题最终定位问题。
我们线上采⽤了设计⽐较优秀的 G1 垃圾收集器因为它不仅满⾜我们低停顿的要求⽽且解决了 CMS 的浮动垃圾问题、内存碎⽚问题。
案例频繁出现FullGC内存的占⽤升⾼最终出现OOM 这种情况我觉得是出现了内存泄漏最终导致OOM
通过开启了-XX:HeapDumpOnOutOfMemoryError 参数 获得堆内存的 dump ⽂件。然后可以如 JProfiler 也是个图形化⼯具对dump⽂件进⾏分析在 dump ⽂析结果中查找存在⼤量的对象再查对其的引⽤。
2. 内存与垃圾回收
2.1JVM的组成面试题 堆 线程共享的区域主要是用来保存对象实例数组等当堆中没有内存空间可分配给实例也无法再扩展时则抛出OutOfMemoryError异常。 方法区 常量池是*.class文件中的当该类被加载它的常量池信息就会放入运行时常量池并把里面的符号地址变成真实地址。
Java虚拟机栈
每个线程运行时所需要的内存称为虚拟机栈先进后出。
每个栈由多个栈帧frame组成对应着每次方法调用时所占用的内存。
每个线程只能有一个活动栈帧对应着当前正在执行的那个方法。
2.2 Java虚拟机栈的组成 局部变量表 主要存放了编译期可知的各种数据类型boolean、byte、char、short、int、float、long、double、对象引用reference 类型它不同于对象本身可能是一个指向对象起始地址的引用指针也可能是指向一个代表对象的句柄或其他与此对象相关的位置。 操作数栈 主要作为方法调用的中转站使用用于存放方法执行过程中产生的中间计算结果。另外计算过程中产生的临时变量也会放在操作数栈中。
动态链接主要服务一个方法需要调用其他方法的场景。Class文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法需要将常量池中指向方法的符号引用转换为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换成调用方法的直接引用这个过程也被称为动态连接。 栈空间一般正常调用的情况下是不会出现问题的。但是如果函数调用陷入了无限循环的话就会导致栈中被压入太多栈帧而占用太多空间导致栈空间过深。当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候就会抛出StackOverFlowError错误。
Java方法有两种返回方式一种是return语句正常返回一种是抛出异常。不管哪种返回方式都会导致栈帧被弹出。也就是说栈帧随着方法调用而创建随着方法结束而销毁。无论方法正常完成还是异常完成都算方法结束。
除了StackOverFlowError错误之外栈还可能会出现OutOfMemoryError错误这是因为如果栈的内存大小可以动态扩展如果虚拟机在动态扩展栈是无法申请到足够的内存空间则会抛出该错误。
2.3 本地方法栈
和虚拟机栈的区别是虚拟机栈是为虚拟机执行Java方法也就是字节码服务的而本地方法栈则为虚拟机使用到的Native本地方法服务。在HotSpot虚拟机中奖Java虚拟机栈二合一。
本地方法被执行的时候在本地方法栈也会创建一个栈帧用于存放本地方法的局部变量表操作数栈动态链接出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间也会抛出StackOverFlowError和OutOfMemoryError两种错误。
2.4 堆
堆是Java虚拟机所管理的内存中最大的一块Java堆是所有线程共享的一块内存区域在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例几乎所以的对象实例以及数组都在这里分配内存。
但是随着JIT编译器的发展与逃逸分析技术的成熟从JDK1.7开始已经默认开启逃逸分析如果某些方法中的对象引用没有被返回或者未被外面使用未逃逸那么对象可以直接在栈上分配内存。
Java堆是垃圾收集器管理的主要区域因此也被称作GC堆Garbage Collected Heap。从垃圾回收的角度由于现在收集器基本上都采用分代垃圾收集算法所以Java堆还可以细分为新生代老年代再细致一点油Eden伊甸区、Survivor幸存者区、Old老年代区等空间。
在JDK7以及7之前堆内存通常分为下面三部分
新生代内存(Young Generation)老生代(Old Generation)永久代(Permanent Generation)
下图所示的Eden区两个Survivor区S0和S1都属于新生代中间一层属于老年代最下面一层属于永久代JDK7 JDK8之后永久代被元空间取代元空间使用的是本地内存。 大部分情况对象都会首先在 Eden 区域分配在一次新生代垃圾回收后如果对象还存活则会进入 S0 或者 S1并且对象的年龄还会加 1(Eden 区-Survivor 区后对象的初始年龄变为 1)当它的年龄增加到一定程度默认为 15 岁就会被晋升到老年代中。对象晋升到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold 来设置。这里的15岁是指垃圾回收的对象能够存活15次垃圾回收就被晋升到老年代中前面的1岁同理。
堆这里最容易出现的就是OutOfMemoryError错误并且出现这种错误之后的表现形式还会有几种比如
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时就会发生此错误。java.lang.OutOfMemoryError: Java heap space:假如在创建新的对象时堆内存中的空间不足以存放新创建的对象就会引发此错误。和配置的最大堆内存有关且受制于物理内存大小。最大堆内存可通过-Xmx参数配置若没有特别配置将会使用默认值。
2.5 方法区抽象概念
方法区是JVM运行时数据区域的一块逻辑区域是各个线程共享的内存区域。
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说在不同的虚拟机实现上方法区的实现是不同的。 2.5.1 方法区和永久代以及元空间是什么关系
方法区和永久代以及元空间的关系相当于Java中接口和类的关系类实现了接口这里的类就可以看做是永久代和元空间接口可以看做是方法区也就是说永久代以及元空间是HotSpot中方法区的两种实现方法。并且永久代是JDK8之前的方法区实现JDK8及以后方法区的实现变成了元空间。
2.5.2 为什么将永久代替换为元空间
在JDK7之前整个永久代的内存大小是固定的无法进行调整受JVM内存的限制而元空间使用的是本地内存受本机可用内存的限制虽然元空间仍然可能会溢出但是相比原来的固定内存大小的永久代这个溢出的概率要小很多。
可以使用 --XX: MaxMetaspaceSize标志设置最大元空间大小默认值为unlimited这意味着它只受系统的内存限制。
--XX:MetaspaceSize 调整标志定义元空间的初始大小。如果未指定此标志则Metaspace将根据运行时的应用程序需求动态地重新调整大小。
2.5.3 方法区常用参数有哪些
JDK7及7之前通过以下两个参数来调节方法区大小。
-XX:PermSizeN //方法区 (永久代) 初始大小
-XX:MaxPermSizeN //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGenJDK8之后通过以下两个参数调节 方法区大小。
-XX:MetaspaceSizeN //设置 Metaspace 的初始和最小大小
-XX:MaxMetaspaceSizeN //设置 Metaspace 的最大大小2.5.4 运行时常量池
常量池是用于存放编译器生成的各种字面量和符号引用的。
字面量是源代码中的固定值的表示法即通过字面我们就能知道其值的含义。字面量包括整数浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法引用。
符号引用符号引用是一组符号用来描述所引用的目标符号可以使任何形式的字面量只要使用时能无歧视地定位到目标即可。符号引用与虚拟机实现的内存布局无关引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同但是它们能接受的符号引用必须都是一致的因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
直接引用直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用那引用的目标必定已经在虚拟机的内存中存在。
常量池表会在类加载后存放到方法区的运行时常量池中。
运行时常量池是方法区的一部分会收到方法区内存的限制当常量池无法再申请内存的时候会抛出OutOfMemoryError错误
2.5.5 字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串String 类专门开辟的一块区域主要目的是为了避免字符串的重复创建。
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa ab;
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb ab;
System.out.println(aabb);// trueHotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable 容量为 StringTableSize可以通过 -XX:StringTableSize 参数来设置保存的是字符串key和 字符串对象的引用value的映射关系字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
因为永久代的GC回收效率太低只有在Full GC的时候才会被执行GC 。Java程序中通常会有大量的被创建的字符串等待回收将字符串常量池放到堆中能够更高效及时地回收字符串内存。
2.5.6 直接内存
直接内存是一种特殊的内存缓冲区并不在Java堆或方法区中分配的而是通过JNI的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分也不是虚拟机规范中定义的内存区域但是这部分内存也是被频繁地使用。而且也可能导致OutOfMemoryError错误。
直接内存的分配不会收到Java堆的限制但是既然是内存就会收到本机总内存大小以及处理器寻址空间的限制。
2.6 对象的创建 1. 类加载检查
虚拟机遇到一条new指令时首先将去检查这个指令的参数是否能够在常量池中定位到这个类的符号引用并且检查这个符号引用代表的类是否已经被加载过解析和初始化过。如果没有那必须先执行相应的类加载过程。 2.分配内存
在类加载检查通过后接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成之后便可以确定为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有“指针碰撞”和“空闲列表”两种选择哪种分配方式由Java堆是否规整决定而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。 指针碰撞 适用场景堆内存规整即没有内存碎片的情况下。原理用过的内存全部整合到一边没有用过的内存放在另一边吗中间有一个分界指针只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。使用该分配方式的GC收集器SerialParallelNew 空闲列表 适用场景堆内存不规整的情况下。原理虚拟机会维护一个列表该列表中会记录哪些内存块使可用的在分配的时候找一块足够大的内存块来划分给对象实例最后更新列表记录。适用该分配方式的GC收集器CMSConcurrent Mark Sweep并发标记清除 选择以上两种方式中的哪一种取决于Java堆内存是否规整。而Java堆内存是否规整取决于GC收集器的算法是标记-清除法还是标记-整理法。值得一提复制算法内存也是规整的。 内存分配并发问题 在创建对象的时候往往会很频繁的去创建很多对象这就涉及了线程安全问题。虚拟机通过下面两种方式来处理内存分配并发问题 CAS失败重试CAS是乐观锁的一种实现方式。所谓乐观锁就是每次不加锁而是假设没有冲突而去完成某项操作如果因为冲突失败就重试直到成功为止。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。TLAB为每个线程预先在Eden区分配一块内存区域JVM在给线程中的对象分配内存的时候首先在TLAB分配当对象大于TLAB中的剩余内存或TLAB的内存已用尽时再采用上述的CAS进行内存分配。 3.初始化零值
内存分配完成后虚拟机需要将分配到的内存空间都初始化为零值不包括对象头这一步操作保证了对象的实力字段在Java代码中可以不赋初始值就直接使用程序能访问到这些字段的数据类型所对应的零值。 4.设置对象头
初识化零值完成后虚拟机要对对象进行必要的设置例如这个对象是哪个类的实例如何才能找到类的元数据信息对象的哈希码对象的GC分代年龄等信息。这些信息存放在对象头中。另外根据虚拟机当前运行状态的不同如是否启用偏向锁等对象头会有不同的设置方法。 5.执行init方法
在上面工作都完成之后从虚拟机的视角来看一个新的对象已经产生了但从Java程序的视角来看对象创建才刚开始init方法还没执行所有的字段都还是0,。所以执行new指令之后会接着执行init方法把对象按程序的意愿来进行初始化这样一个真正可用的对象才算完全产生出来。
2.7 对象的访问定位
建立对象就是为了使用对象我们的Java程序通过栈上的reference数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定目前主流的访问方式有使用句柄直接指针。
句柄
如果使用句柄的话那么Java堆中将会划分出一块内存来作为句柄池reference中存储的就是对象的句柄地址而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。 直接指针
如果使用直接指针访问reference中存储的直接就是对象的地址 这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址在对象被移动时只会改变句柄中的实例数据指针而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快它节省了一次指针定位的时间开销。
2.8 垃圾回收
2.8.1 内存分配和回收原则
对象优先在Eden区分配
大多数情况下对象在新生代中Eden区分配当Eden区没有足够空间进行分配是虚拟机将发起一次MinorGC。测试代码如下
public class GCTest {public static void main(String[] args) {byte[] allocation1, allocation2;allocation1 new byte[30900*1024];}
}通过以下方式运行 添加的参数-XX:PrintGCDetails 运行结果 (红色字体描述有误应该是对应于 JDK1.7 的永久代) 从上图我们可以看出Eden区内存几乎已经被分配完全即使程序什么也不做新生代也会使用2000多k内存。
我们再为allocation2分配内存会出现什么情况
allocation2 new byte[900*1024];给 allocation2 分配内存的时候 Eden 区内存几乎已经被分配完了
当 Eden 区没有足够空间进行分配时虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去老年代上的空间足够存放 allocation1所以不会出现 Full GC。执行 Minor GC 后后面分配的对象如果能够存在 Eden 区的话还是会在 Eden 区分配内存。
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象字符串、数组等。
大对象直接进入老年代的行为是由虚拟机动态决定的它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略为了避免将大对象放入新生代从而减小新生代的垃圾回收频率和成本。
G1垃圾回收器会根据-XX:G1HeapRegionSize参数设置的堆区域大小和-XX:G1MixedGCLiveThresholdPercent参数设置的阈值来决定哪些对象会直接进入老年代。Parallel Scavenge垃圾回收器中默认情况下并没有一个固定的阈值XX:ThresholdTolerance是动态调整的来决定什么时候直接在老年代分配大对象。
长期存货的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存那么内存回收时就必须能识别哪些对象应放在新生代哪些对象应放在老年代中。为了做到这一点虚拟机给每个对象一个对象年龄计数器。
新对象对象首先会在Eden区分配如果对象在Eden区并经过第一次Minor GC后仍然存货并且能够被Survivor区容纳的话将会被一如Survivor的from区或者to区中并将对象的年龄设置为1。
对象在Survivor中每经过一次MinorGC年龄都会1当它们年龄增加到默认15岁的时候就会被晋升到老年代。对象晋升到老年代的年龄阈值可以通过-XX:MaxTenuringThreshold来设置。 更严谨的说默认晋升年龄并不都是 15这个是要区分垃圾收集器的CMS 就是 6。 动态年龄计算代码如下
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
size_t total 0;
uint age 1;
while (age table_size) {
//sizes数组是每个年龄段对象大小
total sizes[age];
if (total desired_survivor_size) {
break;
}
age;
}
uint result age MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
} 主要进行GC的区域
针对 HotSpot VM 的实现它里面的 GC 其实准确分类只有两大种 部分收集Partial GC
新生代收集MinorGC/YoungGC只对新生代进行垃圾收集老年代收集Major GC/Old GC只对老年代进行垃圾收集。需要注意MajorGC在有点语境中也用于指整堆收集Full GC混合收集Mixed GC:对整个新生代和部分老年代进行垃圾收集。 整堆收集Full GC收集整个Java堆和方法区。
空间分配担保
空间分配担保是为了确保在Minor GC之前老年代本身还有容纳新生代所有对象的剩余空间。
2.8.2 死亡对象的判断方法
引用计数法
给对象中添加一个引用计数器
每当有一个地方引用它计数器就加1当引用失效计数器就减1任何时候计数器为0的对象就是不可能再被使用的。
这个方法实现简单效率高但是有一个BUG就是它很难解决对象之间的循环引用比如 所以目前主流的虚拟机中并没有选择这个算法来管理内存 所谓对象之间的相互引用问题如下面代码所示除了对象 objA 和 objB 相互引用着对方之外这两个对象之间再无任何引用。但是他们因为互相引用对方导致它们的引用计数器都不为 0于是引用计数算法无法通知 GC 回收器回收他们。
public class ReferenceCountingGc {Object instance null;public static void main(String[] args) {ReferenceCountingGc objA new ReferenceCountingGc();ReferenceCountingGc objB new ReferenceCountingGc();objA.instance objB;objB.instance objA;objA null;objB null;}
}可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点从这些节点开始向下搜索节点所走过的路径称为引用链当一个对象到 GC Roots 没有任何引用链相连的话则证明此对象是不可用的需要被回收。
下面的Object6~10将会被回收。 哪些对象可以作为GC Roots呢
虚拟机栈栈帧中的局部变量表中引用的对象本地方法栈Native方法中引用的对象方法区中类静态属性引用的对象方法区中常量引用的对象所有被同步锁持有的对象JNIJava Native Interface引用的对象
对象可以被回收就代表一定被回收吗
即使在可达性算法中不可达的对象也并非是“非死不可”的这时候它们暂时处于“缓刑阶段”要真正宣告一个对象死亡至少要经历两次标记过程
可达性分析法中不可达的对象被第一次标记并且进行一次筛选筛选的条件是次对象是否必要执行finalize方法。
当对象没有覆盖finalize方法或finalize方法已经被虚拟机调用过时虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记除非这个对象与引用链上的任何一个对象建立关联否则就会被真的回收。
强引用、软引用、弱引用、虚引用引用强度逐渐减弱
1.强引用StrongReference
以前我们使用的大部分引用都是强引用这是最普遍的引用。如果一个对象具有强引用那么垃圾回收器绝对不会回收它。当内存不足时Java虚拟机宁愿抛出OutOfMemoryError也不会随意回收强引用的对象来清理内存空间。
2.软引用SoftReference
如果一个对象只具有软引用那它就是可有可无的。如果内存充足时垃圾回收器不会回收它如果内存不足时就会回收这些对象的内存。只要垃圾回收器没有回收它该对象就可以被程序使用。软引用可以用来实现内存敏感的高速缓存。
软引用可以和一个引用队列ReferenceQueue联合使用如果软引用所引用的对象被垃圾回收Java虚拟机就会把这个软引用加到与之关联的引用队列里。
3.弱引用WeakReference
如果一个对象只具有弱引用那它也是可有可无的。弱引用与软引用的区别只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中一旦发现了只具有弱引用的对象不管当前内存空间足够与否都会回收它的内存。不过由于垃圾回收器是一个优先级很低的线程因此不一定很快就会发现只具有弱引用的对象。
弱引用可以和一个引用队列ReferenceQueue联合使用如果弱引用所引用的对象被垃圾回收Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4.虚引用PhantomReference
虚引用不会决定对象的生命周期。如果一个对象只持有虚引用那么它就和没有任何引用一样在任何时候都有可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的区别是虚引用必须和引用堆里ReferenceQueue联合使用。当垃圾回收器准备回收一个对象时如果发现它还有虚引用就会在回收对象的内存之前把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意在程序设计中一般很少使用弱引用与虚引用使用软引用的情况较多这是因为软引用可以加速 JVM 对垃圾内存的回收速度可以维护系统的运行安全防止内存溢出OutOfMemory等问题的产生。
如何判断一个常量是废弃常量
运行时常量池主要回收废弃常量
假如在字符串常量池中存在字符串 abc如果当前没有任何 String 对象引用该字符串常量的话就说明常量 abc 就是废弃常量如果这时发生内存回收的话而且有必要的话abc 就会被系统清理出常量池了。
如何判断一个类是无用的类
同时满足下面 3 个条件才能算是 “无用的类”
该类所有的实例都已经被回收也就是 Java 堆中不存在该类的任何实例。加载该类的 ClassLoader 已经被回收。该类对应的 java.lang.Class 对象没有在任何地方被引用无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,但并不一定必须回收。 2.8.3 垃圾收集算法
标记-清除算法
标记-清除Mark-and-Sweep算法分为“标记Mark”和“清除Sweep”阶段首先根据可达性分析算法标记出所有需要回收的对象在标记完成后统一回收掉所有被标记的对象。
它是最基础的收集算法后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题
1. 效率问题 标记和清除两个过程效率都不高
2. 空间问题 标记和清除后会产生大量的内存碎片。
标记-整理算法
标记-整理算法Mark-and-Compact是根据老年代的特点提出的标记算法标记过程中仍然与“标记-清除法”一样但后序会将存活的对象进行整理使得按顺序排布内存顺序分配然后直接清理掉其余的内存。
由于多了整理这一步因此效率也不高适合老年代这种垃圾回收频率不是很高的场景。
复制算法
为了解决标记-清除算法的效率和内存碎片化问题复制Copying收集算法应运而生。它可以将内存分为大小相同的两块每次使用其中一块。当这一块的内存使用完后就将还存活的对象复制到另一块去然后再把使用的空间一次清理掉。这样就使每次内存回收都是对内存区间的一半进行回收。
分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法根据对象存活周期的不同将内存分为几块。一般将Java堆分为新生代老年代这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代每次都有大量对象死去存活的对象相对不多所以可以用复制算法只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的而且没有额外的空间堆它进行分配担保所以我们必须选择“标记-清除法”“标记-整理法”进行垃圾收集。
2.8.4 垃圾收集器
JDK 默认垃圾收集器使用 java -XX:PrintCommandLineFlags -version 命令查看
JDK 8Parallel Scavenge新生代 Parallel Old老年代JDK 9 ~ JDK20: G1
Serial收集器SerialSerial Old
Serial作为新生代采用复制算法
Serial Old作用于老年代采用标记-整理算法。
Serial串行收集器是最基本、历史最悠久的垃圾收集器。它是一个单线程的收集器它的“单线程”的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作更重要的是它在垃圾收集工作进行的时候必须赞同其他的所有工作线程Stop The World直到它收集结束。
新生代采用复制法老年代采用标记整理法。 因为Stop The World为用户带来了不良的体验所以在后续的垃圾收集器设计中停顿时间在不短缩短仍然还有停顿。
但是Serial的优点是简单而高效与其他收集器的单线程相比。Serial收集器由于没有线程交互的开销自然可以获得很高的单线程收集效率。Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择。
Parallel收集器Parallel NewParallel Old 并行和并发概念补充 并行Parallel指多条垃圾收集线程并行工作但此时用户线程仍然处于等待状态。 并发Concurrent指用户线程与垃圾收集线程同时执行但不一定是并行可能会交替执行用户程序在继续运行而垃圾收集器运行在另一个 CPU 上。 ParallelNew和ParallelOld是一个并行的垃圾回收器JDK8默认使用此垃圾回收器
Parallel New作用于新生代采用复制算法
Parallel Old作用于老年代采用标记-整理法
垃圾回收时多个线程在⼯作并且java应⽤中的所有线程都要暂停STW等待垃圾回收 的完成。 Parallel Scavenge收集器 Parallel Scavenge收集器也是使用标记-复制算法的多线程收集它几乎和Parallel New都一样但是它的特别之处在于
-XX:UseParallelGC使用 Parallel 收集器 老年代串行-XX:UseParallelOldGC使用 Parallel 收集器 老年代并行Parallel Scavenge 收集器关注点是吞吐量高效率的利用CPU。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间提高用户体验。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量如果对于收集器运作不太了解手工优化存在困难时使用Parallel Scavenge收集器配合自适应调节策略把内存管理优化交给虚拟机完成也是不错的选择。
新生代采用标记-复制算法老年代采用标记-整理算法。
CMS收集器
CMSConcurrent Mark Sweep收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS收集器是HotSpot虚拟机第一款真正意义上的并发收集器注意不是并行它第一次实现了让垃圾收集线程与用户线程基本同时工作。
CMS 收集器是一种 “标记-清除”算法实现的它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤
初始标记暂停所有的其他线程并记录下直接与root相连的对象速度很快并发标记同时开启GC和用户线程用一个闭包结构去记录可达对象。但在这个阶段结束这个闭包结构并不能保证当前所有的可达对象。因为用户线程可能会不断的更新引用域所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。重新标记重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录这个阶段的停顿时间一般会比初始标记阶段的时间稍长远远比并发标记阶段时间短。并发清除开启用户线程同时GC线程开始对未标记的区域做清扫。 CMS收集器主要优点并发收集、低停顿。但是它有下面三个明显的缺点
对 CPU 资源敏感无法处理浮动垃圾它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
从 JDK9 开始CMS 收集器已被弃用。
G1收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
并行与并发G1能充分利用CPU、多核环境下的硬件优势使用多个CPU来缩短STW停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作G1收集器仍然可以通过并发的方式让Java程序继续执行。分代收集虽然G1可以不需要其他收集器配合就能独立管理整个GC堆但是还是保留了分代的概念。空间整合与CMS的“标记-清除”算法不同G1从整体来看是基于“标记-整理”法实现的从局部上看是基于“标记-复制”法实现的。可预测的停顿这是G1想对于CMS的另一大优势降低停顿时间是G1和CMS共同的关注点但是G1除了追求低停顿外还能建立可预测的停顿时间模型能让使用者明确指定在一个长度为M毫秒的时间片段内消耗在垃圾收集上的时间不得超过N毫秒。
G1收集器的运作大致分为以下几个步骤
初始标记并发标记最终标记筛选回收 G1收集器在后台维护了一个优先列表每次根据允许的收集时间。优先选择回收价值最大的区域。 这种使用区域划分内存空间以及有优先级的区域回收方式保证了G1收集器在有限时间内可以尽可能高的收集效率把内存化整为零。
从 JDK9 开始G1 垃圾收集器成为了默认的垃圾收集器。
ZGC收集器
与CMS中的ParNew和G1类似ZGC也采用标记-复制算法不过ZGC对该算法做了重大改进。
ZGC可以将暂停时间控制在几毫秒以内且赞同时间不受堆内存大小的影响出现STW的情况会更少但代价是牺牲了一些吞吐量。ZGC最大支持16TB的堆内存。
ZGC在Java11中引出在Java15正常使用。
不过默认的垃圾回收器依然是G1可以通过下面的参数启用 ZGC
java -XX:UseZGC className在 Java21 中引入了分代 ZGC暂停时间可以缩短到 1 毫秒以内。
可以通过下面的参数启用分代 ZGC
java -XX:UseZGC -XX:ZGenerational className3. 字节码与类的加载
3.1 JVM如何运行Java代码
3.1.1 编译期
例如HelloWorld这行代码如何打印出来
public class HelloWorld {public static void main(String[] args) {System.out.println(Hello World);}
} 点击 IDEA 工具栏中的锤子按钮Build Project编译整个项目通常情况下并不需要主动编译IDEA 会自动帮我们编译如下图所示。 这时候就可以在 src 的同级目录 target 下找到一个名为 HelloWorld.class 的文件。 可以双击打开它看到如下所示的内容。
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//package doufen.work;public class HelloWorld {public HelloWorld() {}public static void main(String[] args) {System.out.println(Hello World);}
}IDEA 默认会用 Fernflower 这个反编译工具将字节码文件后缀为 .class 的文件也就是 Java 源代码编译后的文件反编译为我们可以看得懂的 Java 源代码。
但是这并不是字节码文件。字节码文件包括JVM执行的指令还有类的元数据信息如类名方法和属性等。如果用“show bytecode”打开字节码文件的话是这样子的
// class version 58.0 (58)
// access flags 0x21
public class doufen/work/HelloWorld {// compiled from: HelloWorld.java// access flags 0x1public init()VL0LINENUMBER 6 L0ALOAD 0INVOKESPECIAL java/lang/Object.init ()VRETURNL1LOCALVARIABLE this Ldoufen/work/HelloWorld; L0 L1 0MAXSTACK 1MAXLOCALS 1// access flags 0x9public static main([Ljava/lang/String;)VL0LINENUMBER 8 L0GETSTATIC java/lang/System.out : Ljava/io/PrintStream;LDC \u4e09\u59b9\uff0c\u5c11\u770b\u624b\u673a\u5c11\u6253\u6e38\u620f\uff0c\u597d\u597d\u5b66\uff0c\u7f8e\u7f8e\u54d2\u3002INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)VL1LINENUMBER 9 L1RETURNL2LOCALVARIABLE args [Ljava/lang/String; L0 L2 0MAXSTACK 2MAXLOCALS 1
}字节码并不是机器码操作系统无法直接识别需要在操作系统上安装不同版本的JVM来识别。
通常情况下我们只需要安装不同版本的 JDKJava Development KitJava 开发工具包就行了它里面包含了 JREJava Runtime EnvironmentJava 运行时环境而 JRE 又包含了 JVM。 说到这里来讲一下JDK、JRE、JVM的区别面试题 JVMJava 虚拟机Java 程序运⾏在 Java 虚拟机上。针对不同系统的实现不同的 JVM因此 Java 语⾔可以实现跨平台。 JRE Java 运⾏时环境。它是运⾏已编译 Java 程序所需的所有内容的集合包括Java 虚拟 机JVMJava 类库Java 命令和其他的⼀些基础构件。但是它不能⽤于创建新程序。 JDK: Java Development Kit它是功能⻬全的 Java SDK。它拥有 JRE 所拥有的⼀切还有编译器javac和⼯具如 javadoc 和 jdb。它能够创建和编译程序。 简单来说JDK 包含 JREJRE 包含 JVM。 然后回到上文Windows、Linux、MacOS 等操作系统都有相应的 JDK只要安装好了 JDK 就有了 Java 的运行时环境就可以把 Java 源代码编译为字节码然后字节码又可以在不同的操作系统上运行了。
3.1.2 运行时
Java 程序从源代码到运⾏主要有三步 编译将我们的代码.java编译成虚拟机可以识别理解的字节码(.class) 解释虚拟机执⾏ Java 字节码将字节码翻译成机器能识别的机器码 执⾏对应的机器执⾏⼆进制机器码 .class-机器码这一步。在这一步JVM类加载器首先加载字节码文件然后通过解释器逐行解释执行这种方法的执行速度会相对比较慢。而且有些方法和代码块是经常需要被调用的也就是所谓的热点代码
所以后面引进了JIT(just-in-time compilation编译器而JIT属于运行时编译。当JIT编译器完成第一次编译后其会将字节码对应的机器码保存下来下次可以直接使用。机器码的运行效率是高于Java解释器的。
这也解释了为什么经常会说Java是编译与解释共存的语言。
这种编译解释相结合的方式带来了几个重要的好处 1. 平台⽆关性由于Java源代码⾸先被编译为中间字节码然后在不同的平台上由JVM解 释执⾏因此Java程序可以在任何⽀持Java虚拟机的计算机上运⾏⽽不需要重新编写 或修改源代码。 2. ⾼性能虽然解释执⾏相对于编译执⾏来说速度较慢但JVM中的即时编译器Just-In Time CompilerJIT可以将频繁执⾏的字节码动态地编译为本地机器代码从⽽提⾼ 执⾏速度。JIT编译器会根据程序的运⾏情况优化字节码的执⾏使得Java程序在运⾏时 具有接近本地代码的性能。 3. 灵活性由于Java程序在字节码级别上运⾏可以实现⼀些⾼级特性如动态加载和动态链接。这些特性使得Java可以⽀持动态扩展和插件式开发使得开发⼈员能够在运⾏时动态地加载和卸载代码。 我们使用javap来看一下HelloWorld的字节码指令序列。
0 getstatic #2 java/lang/System.out
3 ldc #3 Hello World
5 invokevirtual #4 java/io/PrintStream.println
8 return字节码指令序列通常由多条指令组成每条指令由一个操作码和若干个操作数构成。 操作码一个字节大小的指令用于表示具体的操作。操作数跟随操作码用于提供额外信息。
这段字节码序列的意思是调用System.out.println方法打印“Hello World”字符串。下面是详细解释
1、0: getstatic #2 java/lang/System.out
操作码getstatic操作数#2描述这条指令的作用是获取静态字段这里获取的是java.lang.System类的out静态字段它是一个PrintStream类型的输出流。#2是一个指向常量池的索引。
2、3: ldc #3 Hello World
操作码ldc操作数 #3描述这条指令的作用是从常量池中加载一个常量值字符串“Hello World”到操作数栈顶。#3是一个指向常量池的索引常量池里存储了字符串“Hello World”的引用。
3、5: invokevirtual #4 java/io/PrintStream.println
操作码invokevirtual操作数#4描述这条指令的作用是调用方法。这里调用的是PrintStream类的println方法用来打印字符串。#4 是一个指向常量池的索引常量池里存储了java/io/PrintStream.println方法的引用信息。
4、8: return
操作码return描述这条指令的作用是从当前方法返回。
上面的 getstatic、ldc、invokevirtual、return 等就是字节码指令的操作码。
JVM 就是靠解析这些字节码指令来完成程序执行的。常见的执行方式有两种
一种是解释执行对字节码逐条解释执行
一种是JIT也就是即时编译它会在运行时将热点代码优化并缓存起来下次再执行的时候直接使用缓存起来的机器码而不需要再次解释执行。 注意当类加载器完成字节码数据加载任务后JVM划分了专门的内存区域来装载这些字节码数据已经运行时中间数据。 3.2 javap与字节码
如今的 Java 虚拟机非常强大不仅支持 Java 语言还支持很多其他的编程语言比如说 Groovy、Scala、Koltin 等等。 3.2.1 javap
Java 内置了一个反编译命令 javap可以通过 javap -help 了解 javap 的基本用法。 javap 是 JDK 自带的一个命令行工具主要用于反编译类文件.class 文件。
即将编译后的 .class 文件转换回更易于理解的形式。虽然它不会生成原始的 Java 源代码但它可以显示类的结构包括构造方法、方法、字段等帮助我们更好地理解Java字节码以及Java程序的运行机制。
我们来分析一下下面的类的字节码文件
public class Main {private int age 18;public int getAge() {return age;}
}3.2.2 字节码的基本信息
使用 javap -v -p Main.class-p显示所有类和成员包括私有
Classfile /ProgremFiles/ideaProjecct/target/classes/doufen/work/jvm/Main.classLast modified 2024年4月6日; size 385 bytesSHA-256 checksum 6688843e4f70ae8d83040dc7c8e2dd3694bf10ba7c518a6ea9b88b318a8967c6Compiled from Main.java
public class doufen.work.jvm.Mainminor version: 0major version: 55flags: (0x0021) ACC_PUBLIC, ACC_SUPERthis_class: #3 // doufen/work/jvm/Mainsuper_class: #4 // java/lang/Objectinterfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:#1 Methodref #4.#18 // java/lang/Object.init:()V#2 Fieldref #3.#19 // doufen/work/jvm/Main.age:I#3 Class #20 // doufen/work/jvm/Main#4 Class #21 // java/lang/Object#5 Utf8 age#6 Utf8 I#7 Utf8 init#8 Utf8 ()V#9 Utf8 Code#10 Utf8 LineNumberTable#11 Utf8 LocalVariableTable#12 Utf8 this#13 Utf8 Lcom/itwanger/jvm/Main;#14 Utf8 getAge#15 Utf8 ()I#16 Utf8 SourceFile#17 Utf8 Main.java#18 NameAndType #7:#8 // init:()V#19 NameAndType #5:#6 // age:I#20 Utf8 com/itwanger/jvm/Main#21 Utf8 java/lang/Object
{private int age;descriptor: Iflags: (0x0002) ACC_PRIVATEpublic doufen.work.jvm.Main();descriptor: ()Vflags: (0x0001) ACC_PUBLICCode:stack2, locals1, args_size10: aload_01: invokespecial #1 // Method java/lang/Object.init:()V4: aload_05: bipush 187: putfield #2 // Field age:I10: returnLineNumberTable:line 6: 0line 7: 4LocalVariableTable:Start Length Slot Name Signature0 11 0 this Main;public int getAge();descriptor: ()Iflags: (0x0001) ACC_PUBLICCode:stack1, locals1, args_size10: aload_01: getfield #2 // Field age:I4: ireturnLineNumberTable:line 9: 0LocalVariableTable:Start Length Slot Name Signature0 5 0 this Main;
}
SourceFile: Main.java3.2.3 常量池
Constant pool是字节码文件最重要的常量池部分。可以把常量池理解为字节码文件中的资源仓库主要存放两大类信息。
字面量Literal有点类似Java中的常量概念比如文本字符串final常量等。符号引用Symbolic References属于编译原理方面的概念包括3种
类和接口的全限定名Fully Qualified Name字段的名称和描述符Descriptor方法的名称和描述符
Java虚拟机是在加载字节码文件的时候才进行的动态链接也就是说字段和方法的符号引用只有经过运行期转换后才能获得真正的内存地址。
当Java虚拟机运行时需要从常量池获取对应的符号引用然后在类创建或者运行时解析并翻译到具体的内存地址上。
当前字节码文件中一共有21个常量它们之间是有链接的。 # 号后面跟的是索引索引没有从 0 开始而是从 1 开始是因为设计者考虑到“如果要表达不引用任何一个常量的含义时可以将索引值设为 0 来表示” 号后面跟的是常量的类型没有包含前缀 CONSTANT_ 和后缀 _info。全文中提到的索引等同于下标为了灵活描述没有做统一。 第 1 个常量
#1 Methodref #4.#18 // java/lang/Object.init:()V类型为 Methodref表明是用来定义方法的指向常量池中下标为 4 和 18 的常量。 第 4 个常量
#4 Class #21 // java/lang/Object类型为 Class表明是用来定义类或者接口的指向常量池中下标为 21 的常量。
第 21 个常量
#21 Utf8 java/lang/Object类型为 Utf8UTF-8 编码的字符串值为 java/lang/Object。
第 18 个常量
#18 NameAndType #7:#8 // init:()V类型为 NameAndType表明是字段或者方法的部分符号引用指向常量池中下标为 7 和 8 的常量。
第 7 个常量
#7 Utf8 init类型为 Utf8UTF-8 编码的字符串值为 init表明为构造方法。
第 8 个常量
#8 Utf8 ()V类型为 Utf8UTF-8 编码的字符串值为 ()V表明方法的返回值为 void。
到此为止第 1 个常量算是摸完了。组合起来的意思就是Main 类使用的是默认的构造方法来源于 Object 类。#4 指向 Class #21即 java/lang/Object#18 指向 NameAndType #7:#8即 init:()V。
第 2 个常量
#2 Fieldref #3.#19 // doufen/work/jvm/Main.age:I类型为 Fieldref表明是用来定义字段的指向常量池中下标为 3 和 19 的常量。
第 3 个常量
#3 Class #20 // doufen/work/jvm/Main类型为 Class表明是用来定义类或者接口的指向常量池中下标为 20 的常量。
第 19 个常量
#19 NameAndType #5:#6 // age:I类型为 NameAndType表明是字段或者方法的部分符号引用指向常量池中下标为 5 和 6 的常量。
第 5 个常量
#5 Utf8 age类型为 Utf8UTF-8 编码的字符串值为 age表明字段名为 age。
第 6 个常量
#6 Utf8 I类型为 Utf8UTF-8 编码的字符串值为 I表明字段的类型为 int。
标识字符含义B基本数据类型byteC 基本数据类型char D基本数据类型doubleF基本数据类型floatI基本数据类型intJ基本数据类型longS基本数据类型shortZ基本数据类型booleanV特殊类型voidL引用数据类型以分号“;”结尾[一维数组
到此为止第 2 个常量算是摸完了。组合起来的意思就是声明了一个类型为 int 的字段 age。#3 指向 Class #20即 com/itwanger/jvm/Main#19 指向 NameAndType #5:#6即 age:I。
3.2.4 字段表集合
字段表用来描述接口或者类中声明的变量包括类变量和成员变量但不包含声明在方法中局部变量。
字段的修饰符一般有
访问权限修饰符比如 public private protected静态变量修饰符比如 staticfinal修饰符并发可见性修饰符比如 volatile序列化修饰符比如 transient
然后是字段的类型基本数据类型、数组和对象和名称。
在Main.class字节码文件中字段表的信息如下所示。
private int age;descriptor: Iflags: (0x0002) ACC_PRIVATE表明字段的访问权限修饰符为 private类型为 int名称为 age。字段的访问标志和类的访问标志非常类似。 3.2.5 方法表集合
方发表用来描述接口或者类中声明的方法包括类方法和成员方法以及构造方法。方法的修饰符和字段略有不同比如说volatile和transient不能用来修饰方法再比如说方法的修饰符多了synchronized、native、strictfp和abstract。 构造方法
下面这部分为构造方法返回类型为void访问标志为public
public doufen.work.jvm.Main();descriptor: ()Vflags: (0x0001) ACC_PUBLIC声明public doufen.work.jvm.Main(); 这是 Main 类的构造方法用于创建 Main 类的实例。它是公开的public。描述符descriptor: ()V 这表示构造方法没有参数 (()) 并且没有返回值 V代表 void。访问标志flags: (0x0001) ACC_PUBLIC表示这个构造方法是公开的可以从其他类中访问。 Code:stack2, locals1, args_size10: aload_01: invokespecial #1 // Method java/lang/Object.init:()V4: aload_05: bipush 187: putfield #2 // Field age:I10: returnLineNumberTable:line 6: 0line 7: 4LocalVariableTable:Start Length Slot Name Signature0 11 0 this Ldoufen/work/jvm/Main;①、stack 为最大操作数栈Java 虚拟机在运行的时候会根据这个值来分配栈帧的操作数栈深度这里的值为 2意味着操作数栈的深度为 2。
操作栈是一个 LIFO后进先出栈用于存放临时变量和中间结果。在构造方法中bipush 和 aload_0 指令可能会同时需要栈空间所以需要 2 个操作数栈深度。
②、locals 为局部变量所需要的存储空间单位为槽slot方法的参数变量和方法内的局部变量都会存储在局部变量表中。
局部变量表的容量以变量槽为最小单位一个变量槽可以存放一个 32 位以内的数据类型比如 boolean、byte、char、short、int、float、reference 和 returnAddress 类型。
局部变量表所需的容量大小是在编译期间完成计算的大小由编译器决定因此不同的编译器编译出来的字节码可能会不一样。
locals1这表示局部变量表中有 1 个变量的空间。对于实例方法如构造方法局部变量表的第一个位置索引 0总是用于存储 this 引用。
③、args_size 为方法的参数个数。
为什么 stack 的值为 2locals 的值为 1args_size 的值为 1 呢默认的构造方法不是没有参数和局部变量吗
这是因为有一个隐藏的 this 变量只要不是静态方法都会有一个当前类的对象 this 悄悄的存在着。
这就解释了为什么 locals 和 args_size 的值为 1 的问题。
那为什么 stack 的值为 2 呢因为字节码指令 invokespecial调用父类的构造方法进行初始化会消耗掉一个当前类的引用所以 aload_0 执行了 2 次也就意味着操作数栈的大小为 2。
④、LineNumberTable该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。这对于调试非常重要因为它允许调试器将正在执行的字节码指令精确地关联到源代码的特定行。 成员方法
下面这部分为成员方法 getAge()返回类型为 int访问标志为 public。
public int getAge();descriptor: ()Iflags: (0x0001) ACC_PUBLIC理解了构造方法的 Code 属性后再看 getAge() 方法的 Code 属性时就很容易理解了。
Code:stack1, locals1, args_size10: aload_01: getfield #2 // Field age:I4: ireturnLineNumberTable:line 9: 0LocalVariableTable:Start Length Slot Name Signature0 5 0 this Ldoufen/work/jvm/Main;最大操作数栈为 1局部变量所需要的存储空间为 1方法的参数个数为 1是因为局部变量只有一个隐藏的 this并且字节码指令中只执行了一次 aload_0。
①、字节码指令
aload_0: 加载 this 引用到栈顶以便接下来访问实例字段 age。getfield #2: 获取字段值。这条指令读取 this 对象的 age 字段的值并将其推送到栈顶。#2 是对常量池中的字段引用。ireturn: 返回栈顶整型值。这里返回的是 age 字段的值。
②、附加信息
LineNumberTable 和 LocalVariableTable 同样提供了源代码的行号对应和局部变量信息有助于调试和理解代码的执行流程。
3.3 字节码指令详解
Java 的字节码指令由操作码和操作数组成
操作码Opcode一个字节长度0-255意味着指令集的操作码总数不可能超过 256 条代表着某种特定的操作含义。操作数Operands零个或者多个紧跟在操作码之后代表此操作需要的参数。
由于 Java 虚拟机是基于栈而不是寄存器的结构所以大多数字节码指令都只有一个操作码。比如 aload_0 就只有操作码没有操作数而 invokespecial #1 则由操作码和操作数组成。
aload_0将局部变量表中下标为 0 的数据压入操作数栈中invokespecial #1调用成员方法或者构造方法并传递常量池中下标为 1 的常量
字节码指令主要有以下几种分别是
加载与存储指令算术指令类型转换指令对象的创建与访问指令方法调用和返回指令操作数栈管理指令控制转移指令
3.3.1 加载load与存储指令store
用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
public void add(){int a1;int b1;
} 字节码指令的执行过程如图所示 然后我们来分析load和store指令的具体含义
1将局部变量表中的变量压入操作数栈中
xload_nx为i、l、f、d、a、n默认为0到3表示将第n个局部变量压入操作数栈中。xloadx为i、l、f、d、a通过指定参数的形式将局部变量压入操作数栈中当使用这个指令时表示局部变量的数量可能超过4个
x 为操作码助记符表明是哪一种数据类型。见下表所示。 再来看一个例子
private void load(int age, String name, long birthday, boolean sex) {System.out.println(age name birthday sex);
}通过jclasslib看一下load()方法4个参数的字节码指令。 iload_1将局部变量表中下标为 1 的 int 变量压入操作数栈中。aload_2将局部变量表中下标为 2 的引用数据类型变量此时为 String压入操作数栈中。lload_3将局部变量表中下标为 3 的 long 型变量压入操作数栈中。iload 5将局部变量表中下标为 5 的 int 变量实际为 boolean压入操作数栈中。
通过查看局部变量表就能关联上了。 2将常量池中的常量压入操作数栈中
根据数据类型和入栈内容的不同又可以细分为 const 系列、push 系列和 Idc 指令。
const 系列用于特殊的常量入栈要入栈的常量隐含在指令本身。 push 系列主要包括 bipush 和 sipush前者接收 8 位整数作为参数后者接收 16 位整数。
Idc 指令当 const 和 push 不能满足的时候万能的 Idc 指令就上场了它接收一个 8 位的参数指向常量池中的索引。
Idc_w接收两个 8 位数索引范围更大。如果参数是 long 或者 double使用 Idc2_w 指令。
举例来说
public void pushConstLdc() {// 范围 [-1,5]int iconst -1;// 范围 [-128,127]int bipush 127;// 范围 [-32768,32767]int sipush 32767;// 其他 intint ldc 32768;String aconst null;String IdcString hello;
}对应的jclasslib字节码指令 iconst_m1将 -1 入栈。范围 [-1,5]。bipush 127将 127 入栈。范围 [-128,127]。sipush 32767将 32767 入栈。范围 [-32768,32767]。ldc #6 32768将常量池中下标为 6 的常量 32768 入栈。aconst_null将 null 入栈。ldc #7 hello将常量池中下标为 7 的常量“hello”入栈。
3将栈顶的数据出栈并装入局部变量表中
主要是用来给局部变量赋值这类指令主要以 store 的形式存在。
xstore_nx 为 i、l、f、d、an 默认为 0 到 3xstorex 为 i、l、f、d、a
xstore_n 和 xstore n 的区别在于前者相当于只有操作码占用 1 个字节后者相当于由操作码和操作数组成操作码占 1 个字节操作数占 2 个字节一共占 3 个字节。
3.3.2算术指令
算术指令用于对两个操作数栈上的值进行某种特定运算并把结果重新压入操作数栈。可以分为两类整型数据的运算指令和浮点数据的运算指令。
我把所有的算术指令列出来
加法指令iadd、ladd、fadd、dadd减法指令isub、lsub、fsub、dsub乘法指令imul、lmul、fmul、dmul除法指令idiv、ldiv、fdiv、ddiv求余指令irem、lrem、frem、drem自增指令iinc public void calculate(int age) {int add age 1;int sub age - 1;int mul age * 2;int div age / 3;int rem age % 4;age;age--;} iadd加法isub减法imul乘法idiv除法irem取余iinc自增的时候 1自减的时候 -1
需要注意的是数据运算可能会导致溢出比如两个很大的正整数相加很可能会得到一个负数。但Java虚拟机规范中并没有对这种情况给出具体结果因此程序是不会显示报错的。所以在开发的过程中如果涉及到较大的数据进行加法乘法运算的时候一定要小心。
当发生溢出时将会使用有符号的无穷大Infinity来表示如果某个操作结果没有明确的数学定义的话将会使用NaN值来表示。而且所有使用NaN作为操作数的算术操作结果都会返回NaN。
public void infinityNaN() {int i 10;double j i / 0.0;System.out.println(j); // Infinitydouble d1 0.0;double d2 d1 / 0.0;System.out.println(d2); // NaN
}任何一个非零的数除以浮点数 0注意不是 int 类型可以想象结果是无穷大 Infinity 的。把这个非零的数换成 0 的时候结果又不太好定义就用 NaN 值来表示。
3.3.3 类型转换指令
类型转换指令可以分为两种
1宽化小类型向大类型转换比如int-long-float-double对应的指令有i2l、i2f、i2d、l2f、l2d、f2d。
从 int 到 long或者从 int 到 double是不会有精度丢失的从 int、long 到 float或者 long 到 double 时可能会发生精度丢失从 byte、char 和 short 到 int 的宽化类型转换实际上是隐式发生的这样可以减少字节码指令毕竟字节码指令只有 256 个占一个字节。
2窄化大类型向小类型转换比如从 int 类型到 byte、short 或者 char对应的指令有i2b、i2s、i2c从 long 到 int对应的指令有l2i从 float 到 int 或者 long对应的指令有f2i、f2l从 double 到 int、long 或者 float对应的指令有d2i、d2l、d2f。
窄化很可能会发生精度丢失毕竟是不同的数量级但 Java 虚拟机并不会因此抛出运行时异常。
举例来说
public void updown() {int i 10;double d i;float f 10f;long ong (long)f;
}解析后之后
i2dint 宽化为 doublef2l float 窄化为 long 3.3.4 对象的创建和访问指令
1创建指令
数组是一种特殊的对象它创建的字节码指令和普通对象的创建指令不同。创建数组的指令有三种
newarray创建基本数据类型的数组anewarray创建引用类型的数组multianewarray创建多维数组
而对象的创建指令只有一个就是new它会接收一个操作数指向常量池中的一个索引表示要创建的类型。
举例来说 public void newObject() {String name new String(hello);File file new File(智者不入爱河.book);int [] ages {};} 字节码指令如下 new #33 java/lang/String创建一个 String 对象。new #35 java/io/File创建一个 File 对象。newarray 10 (int)创建一个 int 类型的数组。
2字段访问指令
字段可以分为两类一类是成员变量一类是静态变量也就是类变量所以字段访问指令可以分为两类
访问静态变量getstatic、putstatic。访问成员变量getfield、putfield需要创建对象后才能访问。 成员变量和静态变量的区别面试题 参数表分配完毕之后再根据方法体内定义的变量的顺序和作用域分配、我们知道类变量表有两次初始化的机会第一次是在类加载过程中的“prepare阶段”执行系统初始化对类变量设置为零值默认0另一次则是在“initial阶段”赋予显式初始值程序员在代码中定义的初始值。和类变量静态变量不同的是局部变量表不存在系统初始化的过程这意味这成员变量必须进行显式赋值人为手动初始化否则无法使用。 举例来说
package StackTest;
/**** author doufen* date 2024/4/12*/
public class Writer {private String name;static String mark 作者:doufen;public static void main(String[] args) {print(mark);Writer w new Writer();print(w.name);}public static void print(String arg) {System.out.println(arg);}
}看字节码指令 getstatic #2 StackTest/Writer.mark访问静态变量 mark getfield #6 StackTest/Writer.name访问成员变量 name
3.3.5 方法调用和返回指令
方法调用指令有 5 个分别用于不同的场景
invokevirtual用于调用对象的成员方法根据对象的实际类型进行分派支持多态。invokeinterface用于调用接口方法会在运行时搜索由特定对象实现的接口方法进行调用。invokespecial用于调用一些需要特殊处理的方法包括构造方法、私有方法和父类方法。invokestatic用于调用静态方法。invokedynamic用于在运行时动态解析出调用点限定符所引用的方法并执行。
举例说明 public class InvokeExamples {private void run() {List ls new ArrayList();ls.add(智者不入爱河);ArrayList als new ArrayList();als.add(豆粉一路硕博);}public static void print() {System.out.println(invokestatic);}public static void main(String[] args) {print();InvokeExamples invoke new InvokeExamples();invoke.run();}
}
字节码指令如下 InvokeExamples 类有 4 个方法包括缺省的构造方法在内。 1invokespecial
缺省的构造方法内部会调用超类 Object 的初始化构造方法 invokespecial #1 // Method java/lang/Object.init:()V 2invokeinterface和invokevirtual invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z 由于ls变量的引用类型为接口List所以ls.add()调用的是invokeinterface指令等运行时再确定是不是接口List的实现对象ArrayList的add()方法。 invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z 由于 als 变量的引用类型已经确定为 ArrayList所以 als.add() 方法调用的是 invokevirtual 指令。
3invokestatic invokestatic #11 // Method print:()V print() 方法是静态的所以调用的是 invokestatic 指令。
3.3.6 invokedynamic
invokedynamic是在JDK7引入的主要是为了支持动态语言如Groovy、Scala、JRuby等。这些语言都是在运行时动态解析出调用点限定符所引用的方法并执行。
Lambda 表达式的实现就依赖于 invokedynamic 指令
import java.util.function.Function;public class LambdaExample {public static void main(String[] args) {// 使用 Lambda 表达式定义一个函数FunctionInteger, Integer square x - x * x;// 调用这个函数int result square.apply(5);System.out.println(result); // 输出 25}
}字节码指令
在这个例子中Lambda 表达式 x - x * x 定义了一个接受一个整数并返回其平方的函数。在编译这段代码时编译器会使用 invokedynamic 指令来动态地绑定这个 Lambda 表达式。
①、invokedynamic #2, 0使用 invokedynamic 调用一个引导方法Bootstrap Method这个方法负责实现并返回一个 Function 接口的实例。这里的 Lambda 表达式 x - x * x 被转换成了一个 Function 对象。引导方法在首次执行时会被调用它负责生成一个 CallSite该 CallSite 包含了指向具体实现 Lambda 表达式的方法句柄Method Handle。在这个例子中这个方法句柄指向了 lambda$main$0 方法。
②、astore_1将 invokedynamic 指令的结果Lambda 表达式的 Function 对象存储到局部变量表的位置 1。
③、Lambda 表达式的实现是lambda$main$0这是 Lambda 表达式 x - x * x 的实际实现。它接收一个 Integer 对象作为参数计算其平方然后返回结果。
3.3.7 方法返回指令 3.3.8 操作数栈管理指令
常见的操作数栈管理指令有 pop、dup 和 swap。
将一个或两个元素从栈顶弹出并且直接废弃比如 poppop2复制栈顶的一个或两个数值并将其重新压入栈顶比如 dupdup2dup*×1dup2*×1dup*×2dup2*×2将栈最顶端的两个槽中的数值交换位置比如 swap。
这些指令不需要指明数据类型因为是按照位置压入和弹出的。
举例
public class Dup {int age;public int incAndGet() {return age;}
}aload_0将 this 入栈。dup复制栈顶的 this。getfield #2将常量池中下标为 2 的常量加载到栈上同时将一个 this 出栈。iconst_1将常量 1 入栈。iadd将栈顶的两个值相加后出栈并将结果放回栈上。dup_x1复制栈顶的元素并将其插入 this 下面。putfield #2 将栈顶的两个元素出栈并将其赋值给字段 age。ireturn将栈顶的元素出栈返回。
3.3.9 控制转移指令
控制转移指令包括
比较指令比较栈顶的两个元素的大小并将比较结果入栈。条件跳转指令通常和比较指令一块使用在条件跳转指令执行前一般先用比较指令进行栈顶元素的比较然后进行条件跳转。比较条件转指令类似于比较指令和条件跳转指令的结合体它将比较和跳转两个步骤合二为一。多条件分支跳转指令专为 switch-case 语句设计的。无条件跳转指令目前主要是 goto 指令。
1比较指令
比较指令有dcmpgdcmpl、fcmpg、fcmpl、lcmp指令的第一个字母代表的含义分别是 double、float、long。注意没有 int 类型。
对于 double 和 float 来说由于 NaN 的存在有两个版本的比较指令。拿 float 来说有 fcmpg 和 fcmpl区别在于如果遇到 NaNfcmpg 会将 1 压入栈fcmpl 会将 -1 压入栈。
举例
public void lcmp(long a, long b) {if(a b){}
}lcmp 用于两个 long 型的数据进行比较。
2条件跳转指令 这些指令都会接收两个字节的操作数它们的统一含义是弹出栈顶元素测试它是否满足某一条件满足的话跳转到对应位置。
对于 long、float 和 double 类型的条件分支比较会先执行比较指令返回一个整型值到操作数栈中后再执行 int 类型的条件跳转指令。
对于 boolean、byte、char、short以及 int则直接使用条件跳转指令来完成。
3比较条件转指令 3.3.10 异常处理时的字节码指令
public class ExceptionExample {public void testException() {try {int a 1 / 0; // 这将导致除以零的异常} catch (ArithmeticException e) {System.out.println(发生算术异常);}}
}
public void testException();Code:0: iconst_11: iconst_02: idiv3: istore_14: goto 127: astore_18: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;11: ldc #3 // String 发生算术异常13: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V16: returnException table:from to target type0 4 7 Class java/lang/ArithmeticException3.3.11 synchronized的字节码指令
public class SynchronizedExample {public void syncBlockMethod() {synchronized(this) {// 同步块体}}
} 字节码如下
public void syncBlockMethod();Code:0: aload_01: dup2: astore_13: monitorenter4: aload_15: monitorexit6: goto 149: astore 211: aload_112: monitorexit13: aload 215: athrow16: returnException table:from to target type4 6 9 any9 13 9 anymonitorenter / monitorexit 这两个指令用于同步块的开始和结束。monitorenter 指令用于获取对象的监视器锁monitorexit 指令用于释放锁。
3.4 类加载过程详解
3.4.1 类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止它的整个生命周期可以简单概括为 7 个阶段加载Loading、验证Verification、准备Preparation、解析Resolution、初始化Initialization、使用Using和卸载Unloading。其中验证、准备和解析这三个阶段可以统称为连接Linking。 3.4.2 类加载过程
1加载
类加载过程的第一步主要完成下面 3 件事情
通过全类名获取定义此类的二进制字节流。将字节流所代表的静态存储结构转换为方法区的运行时数据结构。在内存中生成一个代表该类的Class对象作为方法区这些数据的访问入口。
加载这一步需要用类加载器实现类加载器有很多种Bootstrap类加载器、Extension类加载器、Application类加载器、User类加载器当我们想要加载一个类的时候具体使用哪个类加载器是有双亲委派模型机制决定的当然也可以打破双亲委派模型机制。
每个 Java 类都有一个引用指向加载它的 ClassLoader。不过数组类不是通过 ClassLoader 创建的而是 JVM 在需要的时候自动创建的数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。
一个非数组类的加载阶段加载阶段获取类的二进制字节流的动作是可控性最强的阶段这一步我们可以去完成还可以自定义类加载器UserClassLoader去控制字节流的获取方式重写一个类加载器的 loadClass() 方法。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的加载阶段尚未结束链接阶段可能就已经开始了。
2验证
验证是链接阶段的第一步这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求保证这些信息被当做代码运行后不回危害虚拟机本身。
主要包括四种验证文件格式验证Class文件格式、元数据验证字节码语义、字节码验证程序语义、符号引用验证类的正确性。
验证阶段这一步是在整个类加载过程中耗费的资源还是相对较多的但很有必要可以有效的防止恶意代码的执行。
不过验证阶段也不是必须执行的如果程序运行中的全部代码程序员编写的、第三方Jar包的、从外部加载的、动态生成的代码等所有代码都已经经过了反复使用和验证在生产环境就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施以缩短虚拟机类加载的时间。 文件格式验证这一阶段是基于该类的二进制字节流进行的主要目的是保证输入的字节流能正确地解析并存储于方法区之内格式上符合描述一个Java类型信息的要求。其余的三个阶段验证都是基于方法区的存储结构上进行的不会再直接读取、操作字节流了。
符号引用验证发生在类加载过程中的解析阶段具体点说是JVM将符号引用转化为直接引用的过程。
符号引用验证的主要目的是确保解析阶段能正常执行如果无法通过符号引用验证JVM 会抛出异常比如
java.lang.IllegalAccessError当类试图访问或修改它没有权限访问的字段或调用它没有权限访问的方法时抛出该异常。java.lang.NoSuchFieldError当类试图访问或修改一个指定的对象字段而该对象不再包含该字段时抛出该异常。java.lang.NoSuchMethodError当类试图访问一个指定的方法而该方法不存在时抛出该异常。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些内存都将在方法区中分配。对于该阶段有一下需要注意
这时候进行内存分配的仅包括类变量 Class Variables 即静态变量被 static 关键字修饰的变量只与类相关因此被称为类变量而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。从概念讲类变量所使用的内存都应当在方法区中进行分配。不过一点需要注意的是JDK7之前的HotSpot使用永久代来实现方法区的时候实现是完全符合这种逻辑概念的。而在JDK7之后HotSpot已经把原本放在永久代的字符串常量池、静态变量等一定到堆中这个时候类变量则会随着Class对象一起存放在Java堆中。这里所设置的初始值“通常情况”下是数据类型默认的零值比如我们定义的public static int value111那么value变量在准备阶段的初始化就是0而不是111初始化阶段才会赋值。特殊情况比如给value变量加上了final关键字public final static int value111那么准备阶段value的值就会被赋值为111.
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
举个例子在程序执行方法时系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置从而使得方法可以被调用。
综上解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
初始化阶段是执行初始化方法 clinit ()方法的过程是类加载的最后一步这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。 对于clinit()方法的调用虚拟机会自己确保其在多线程环境中的安全性。因为clinit()方法是带锁线程安全所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞并且这种阻塞很难发现。
对于初始化阶段虚拟机严格规范了有且只有6中情况下必须对类进行初始化只有主动去使用类才会初始化类
当遇到 new、 getstatic、putstatic 或 invokestatic 这 4 条字节码指令时比如 new 一个类读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量常量会被加载到运行时常量池)。当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName(...), newInstance() 等等。如果类没初始化需要触发其初始化。初始化一个类如果其父类还未初始化则先触发该父类的初始化。当虚拟机启动时用户需要定义一个要执行的主类 (包含 main 方法的那个类)虚拟机会先初始化这个类。MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制而要想使用这 2 个调用 就必须先使用 findStaticVarHandle 来初始化要调用的类。 当一个接口中定义了 JDK8 新加入的默认方法被 default 关键字修饰的接口方法时如果有这个接口的实现类发生了初始化那该接口要在其之前被初始化。
类卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
该类的所有的实例对象都已被 GC也就是说堆不存在该类的实例对象。该类没有在其他任何地方被引用该类的类加载器的实例已被 GC
所以在 JVM 生命周期内由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
只要想通一点就好了JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的所以使用我们自定义加载器加载的类是可以被卸载掉的。
3.5 类加载器详解面试
3.5.1 类加载器介绍
类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名然后从文件系统中读取该名称的“类文件”。
每个 Java 类都有一个引用指向加载它的 ClassLoader。不过数组类不是通过 ClassLoader 创建的而是 JVM 在需要的时候自动创建的数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。
类加载器的主要作用就是加载 Java 类的字节码 .class 文件到 JVM 中在内存中生成一个代表该类的 Class 对象。 字节码可以是 Java 源程序.java文件经过 javac 编译得来也可以是通过工具动态生成或者通过网络下载得来。
3.5.2 类加载器加载规则
JVM启动的时候并不会一次加载全部的类而是根据需要去动态加载。
对于已经加载的类会被放在 ClassLoader 中。
在类加载的时候系统会首先判断当前类是否被加载过。已经被加载的类会直接返回否则才会尝试加载。也就是说对于一个类加载器来说相同二进制名称的类只会被加载一次。 3.5.3 类加载器总结
JVM 中内置了三个重要的 ClassLoader
BootstrapClassLoader(启动类加载器)最顶层的加载类由 C实现通常表示为 null并且没有父级主要用来加载 JDK 内部的核心类库 %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类以及被 -Xbootclasspath参数指定的路径下的所有类。ExtensionClassLoader(扩展类加载器)主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。AppClassLoader(应用程序类加载器)面向我们用户的加载器负责加载当前应用 classpath 下的所有 jar 包和类。 除了这三种类加载器之外用户还可以加入自定义的类加载器来进行拓展以满足自己的特殊需求。就比如说我们可以对 Java 类的字节码 .class 文件进行加密加载时再利用自定义的类加载器对其解密。
每个 ClassLoader 可以通过getParent()获取其父 ClassLoader如果获取到 ClassLoader 为null的话那么间接的说明该类是通过 BootstrapClassLoader 加载的。
public abstract class ClassLoader {...// 父加载器private final ClassLoader parent;CallerSensitivepublic final ClassLoader getParent() {//...}...
}为什么 获取到 ClassLoader 为null就间接证明是 BootstrapClassLoader 加载的呢 这是因为BootstrapClassLoader 由 C 实现由于这个 C 实现的类加载器在 Java 中是没有与之对应的类的所以拿到的结果是 null。
下面我们来举个例子用于获取ClassLoader的上下级关系
package ClassLoaderTest;public class PrintClassLoaderTree {public static void main(String[] args) {ClassLoader classLoader PrintClassLoaderTree.class.getClassLoader();StringBuilder split new StringBuilder(|--);boolean needContinue true;while (needContinue){System.out.println(split.toString() classLoader);if(classLoader null){needContinue false;}else{classLoader classLoader.getParent();split.insert(0, \t);}}}}
打印结果是如图 从输出结果可以看出
我们编写的 Java 类 PrintClassLoaderTree 的 ClassLoader 是AppClassLoaderAppClassLoader的父类 ClassLoader 是ExtClassLoaderExtClassLoader的父类ClassLoader是Bootstrap ClassLoader因此输出结果为 null。
3.5.4 自定义类加载器
除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果我们要自定义自己的类加载器需要继承 ClassLoader抽象类。
ClassLoader 类有两个关键的方法
protected Class loadClass(String name, boolean resolve)加载指定二进制名称的类实现了双亲委派机制 。name 为类的二进制名称resolve 如果为 true在加载时调用 resolveClass(Class? c) 方法解析该类。protected Class findClass(String name)根据类的二进制名称来查找类默认实现是空方法。
如果我们不想打破双亲委派模型就重写 ClassLoader 类中的 findClass() 方法即可无法被父类加载器加载的类最终会通过这个方法被加载。但是如果想打破双亲委派模型则需要重写 loadClass() 方法。 3.6 双亲委派模型机制面试
Java虚拟机对class文件采用的是按需加载的方式也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。
而且加载某个类的class文件时Java虚拟机采用的是双亲委派模式 。
即把请求交给父类处理它是一种任务委派模式。
工作原理如图所示 1如果一个类加载器收到了类加载器请求它并不会自己先去加载而是把这个请求委托给父类的加载器去执行
2 如果父类加载器还存在其父类加载器则进一步向上委托依次递归请求最终将到达顶层的启动类加载器
3如果父类加载器可以完成类加载任务就成功返回倘若父类加载器无法完成此加载任务子加载器才会尝试自己去加载这就是双亲委派模式。 注意 双亲委派模型并不是一种强制性的约束只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型也是可以的。 类加载器之间的父子关系一般不是以继承的关系来实现的而是通常使用组合关系来复用父加载器的代码。
public abstract class ClassLoader {...// 组合private final ClassLoader parent;protected ClassLoader(ClassLoader parent) {this(checkCreateClassLoader(), parent);}...
}在面向对象编程中有一条经典的设计原则组合优于继承多用组合少用继承。
3.6.1 双亲委派模型的执行流程 双亲委派模型的实现代码非常简单逻辑非常清晰都集中在 java.lang.ClassLoader 的 loadClass() 中相关代码如下所示。
protected Class? loadClass(String name, boolean resolve)throws ClassNotFoundException
{synchronized (getClassLoadingLock(name)) {//首先检查该类是否已经加载过Class c findLoadedClass(name);if (c null) {//如果 c 为 null则说明该类没有被加载过long t0 System.nanoTime();try {if (parent ! null) {//当父类的加载器不为空则通过父类的loadClass来加载该类c parent.loadClass(name, false);} else {//当父类的加载器为空则调用启动类加载器来加载该类c findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {//非空父类的类加载器无法找到相应的类则抛出异常}if (c null) {//当父类加载器无法加载时则调用findClass方法来加载该类//用户可通过覆写该方法来自定义类加载器long t1 System.nanoTime();c findClass(name);//用于统计类加载器相关的信息sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {//对类进行link操作resolveClass(c);}return c;}
}对应的流程图如下所示 JVM 判定两个 Java 类是否相同的具体规则
JVM 不仅要看类的全名是否相同还要看加载此类的类加载器是否一样。
只有两者都相同的情况才认为两个类是相同的。 3.6.2 双亲委派模型的好处
避免类的重复加载保护程序安全防止核心API被随意篡改
3.6.3 打破双亲委派模型的方法
想打破双亲委派模型则需要重写 loadClass() 方法。
这是因为类加载器在进行类加载的时候它首先不会自己去尝试加载这个类而是把这个请求委派给父类加载器去完成调用父加载器 loadClass()方法来加载类。 重写 loadClass()方法之后我们就可以改变传统双亲委派模型的执行流程。例如子类加载器可以在委派给父类加载器之前先自己尝试加载这个类或者在父类加载器返回之后再尝试从其他地方加载这个类。具体的规则由我们自己实现根据项目需求定制化。
我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类然后再加载其他目录下的类就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。
单纯依靠自定义类加载器没办法满足某些场景的要求例如有些情况下高层的类加载器需要加载低层的加载器才能加载的类。
比如SPI 中SPI 的接口如 java.sql.Driver是由 Java 核心库提供的由BootstrapClassLoader 加载。而 SPI 的实现如com.mysql.cj.jdbc.Driver是由第三方供应商提供的它们是由应用程序类加载器或者自定义类加载器来加载的。默认情况下一个类及其依赖类由同一个类加载器加载。所以加载 SPI 的接口的类加载器BootstrapClassLoader也会用来加载 SPI 的实现。按照双亲委派模型BootstrapClassLoader 是无法找到 SPI 的实现类的因为它无法委托给子类加载器去尝试加载。
再比如假设我们的项目中有 Spring 的 jar 包由于其是 Web 应用之间共享的因此会由 SharedClassLoader 加载Web 服务器是 Tomcat。我们项目中有一些用到了 Spring 的业务类比如实现了 Spring 提供的接口、用到了 Spring 提供的注解。所以加载 Spring 的类加载器也就是 SharedClassLoader也会用来加载这些业务类。但是业务类在 Web 应用目录下不在 SharedClassLoader 的加载路径下所以 SharedClassLoader 无法找到业务类也就无法加载它们。
如何解决这个问题呢
这个时候就需要用到 线程上下文类加载器ThreadContextClassLoader 了。
拿 Spring 这个例子来说当 Spring 需要加载业务类的时候它不是用自己的类加载器而是用当前线程的上下文类加载器。
每个 Web 应用都会创建一个单独的 WebAppClassLoader并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader。
这样就可以让高层的类加载器SharedClassLoader借助子类加载器 WebAppClassLoader来加载业务类破坏了 Java 的类加载委托机制让应用逆向使用类加载器。
线程线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里跟线程绑定然后在需要的时候取出来使用。这个类加载器通常是由应用程序或者容器如 Tomcat设置的。
Java.lang.Thread 中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)分别用来获取和设置线程的上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话线程将继承其父线程的上下文类加载器。
Spring 获取线程线程上下文类加载器的代码如下
cl Thread.currentThread().getContextClassLoader();