当前位置:   article > 正文

JVM主要知识点详解

JVM主要知识点详解

目录

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之前,堆内存通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

下图所示的Eden区,两个Survivor区S0和S1都属于新生代,中间一层属于老年代,最下面一层属于永久代(JDK7)

JDK8之后永久代被元空间取代,元空间使用的是本地内存。

 大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。(这里的15岁是指垃圾回收的对象能够存活15次垃圾回收就被晋升到老年代中,前面的1岁同理)。

堆这里最容易出现的就是OutOfMemoryError错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded :当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. 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之前,通过以下两个参数来调节方法区大小。

  1. -XX:PermSize=N //方法区 (永久代) 初始大小
  2. -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK8之后,通过以下两个参数调节 方法区大小。

  1. -XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
  2. -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

 2.5.4 运行时常量池

常量池是用于存放编译器生成的各种字面量符号引用的。

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数,浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法引用。

符号引用:符号引用是一组符号用来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧视地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

常量池表会在类加载后存放到方法区的运行时常量池中。

运行时常量池是方法区的一部分,会收到方法区内存的限制,当常量池无法再申请内存的时候会抛出OutOfMemoryError错误

2.5.5 字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

  1. // 在堆中创建字符串对象”ab“
  2. // 将字符串对象”ab“的引用保存在字符串常量池中
  3. String aa = "ab";
  4. // 直接返回字符串常量池中字符串对象”ab“的引用
  5. String bb = "ab";
  6. System.out.println(aa==bb);// true

 HotSpot 虚拟机中字符串常量池的实现是 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收集器:Serial,ParallelNew

空闲列表:

  • 适用场景:堆内存不规整的情况下。
  • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块使可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录。
  • 适用该分配方式的GC收集器:CMS(Concurrent 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。测试代码如下:

  1. public class GCTest {
  2. public static void main(String[] args) {
  3. byte[] allocation1, allocation2;
  4. allocation1 = new byte[30900*1024];
  5. }
  6. }

通过以下方式运行:

 添加的参数:-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。

动态年龄计算代码如下:

  1. uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
  2. //survivor_capacity是survivor空间的大小
  3. size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
  4. size_t total = 0;
  5. uint age = 1;
  6. while (age < table_size) {
  7. //sizes数组是每个年龄段对象大小
  8. total += sizes[age];
  9. if (total > desired_survivor_size) {
  10. break;
  11. }
  12. age++;
  13. }
  14. uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
  15. ...
  16. }

 主要进行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,就是它很难解决对象之间的循环引用,比如:

所以目前主流的虚拟机中并没有选择这个算法来管理内存

 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objAobjB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

  1. public class ReferenceCountingGc {
  2. Object instance = null;
  3. public static void main(String[] args) {
  4. ReferenceCountingGc objA = new ReferenceCountingGc();
  5. ReferenceCountingGc objB = new ReferenceCountingGc();
  6. objA.instance = objB;
  7. objB.instance = objA;
  8. objA = null;
  9. objB = null;
  10. }
  11. }

可达性分析算法

这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

下面的Object6~10将会被回收。

 哪些对象可以作为GC Roots呢?

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 本地方法栈(Native方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象
  • JNI(Java 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 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK20: G1

Serial收集器(Serial+Serial Old)

Serial作为新生代,采用复制算法

Serial Old作用于老年代,采用标记-整理算法。

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器。它是一个单线程的收集器,它的“单线程”的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在垃圾收集工作进行的时候必须赞同其他的所有工作线程(Stop The World),直到它收集结束。

新生代采用复制法,老年代采用标记整理法。

 因为Stop The World为用户带来了不良的体验,所以在后续的垃圾收集器设计中停顿时间在不短缩短(仍然还有停顿)。

但是Serial的优点是简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial收集器对于运行在Client模式下的虚拟机来说是个不错的选择。

Parallel收集器(Parallel New+Parallel Old)

并行和并发概念补充:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。

ParallelNew和ParallelOld是一个并行的垃圾回收器,JDK8默认使用此垃圾回收器

Parallel New作用于新生代,采用复制算法

Parallel Old作用于老年代,采用标记-整理法

垃圾回收时,多个线程在⼯作,并且java应⽤中的所有线程都要暂停(STW),等待垃圾回收 的完成。

Parallel Scavenge收集器 

 Parallel Scavenge收集器也是使用标记-复制算法的多线程收集,它几乎和Parallel New都一样,但是它的特别之处在于:

  1. -XX:+UseParallelGC
  2. 使用 Parallel 收集器+ 老年代串行
  3. -XX:+UseParallelOldGC
  4. 使用 Parallel 收集器+ 老年代并行

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难时,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理优化交给虚拟机完成也是不错的选择。

新生代采用标记-复制算法,老年代采用标记-整理算法。

CMS收集器

CMS(Concurrent 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 className

3. 字节码与类的加载

3.1 JVM如何运行Java代码

3.1.1 编译期

例如HelloWorld这行代码如何打印出来

  1. public class HelloWorld {
  2. public static void main(String[] args) {
  3. System.out.println("Hello World!");
  4. }
  5. }

 点击 IDEA 工具栏中的锤子按钮(Build Project,编译整个项目,通常情况下,并不需要主动编译,IDEA 会自动帮我们编译),如下图所示。

 这时候,就可以在 src 的同级目录 target 下找到一个名为 HelloWorld.class 的文件。

 可以双击打开它,看到如下所示的内容。

  1. //
  2. // Source code recreated from a .class file by IntelliJ IDEA
  3. // (powered by Fernflower decompiler)
  4. //
  5. package doufen.work;
  6. public class HelloWorld {
  7. public HelloWorld() {
  8. }
  9. public static void main(String[] args) {
  10. System.out.println("Hello World");
  11. }
  12. }

 IDEA 默认会用 Fernflower 这个反编译工具将字节码文件(后缀为 .class 的文件,也就是 Java 源代码编译后的文件)反编译为我们可以看得懂的 Java 源代码。

但是这并不是字节码文件。字节码文件包括JVM执行的指令,还有类的元数据信息,如类名,方法和属性等。如果用“show bytecode”打开字节码文件的话是这样子的:

  1. // class version 58.0 (58)
  2. // access flags 0x21
  3. public class doufen/work/HelloWorld {
  4. // compiled from: HelloWorld.java
  5. // access flags 0x1
  6. public <init>()V
  7. L0
  8. LINENUMBER 6 L0
  9. ALOAD 0
  10. INVOKESPECIAL java/lang/Object.<init> ()V
  11. RETURN
  12. L1
  13. LOCALVARIABLE this Ldoufen/work/HelloWorld; L0 L1 0
  14. MAXSTACK = 1
  15. MAXLOCALS = 1
  16. // access flags 0x9
  17. public static main([Ljava/lang/String;)V
  18. L0
  19. LINENUMBER 8 L0
  20. GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
  21. LDC "\u4e09\u59b9\uff0c\u5c11\u770b\u624b\u673a\u5c11\u6253\u6e38\u620f\uff0c\u597d\u597d\u5b66\uff0c\u7f8e\u7f8e\u54d2\u3002"
  22. INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
  23. L1
  24. LINENUMBER 9 L1
  25. RETURN
  26. L2
  27. LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
  28. MAXSTACK = 2
  29. MAXLOCALS = 1
  30. }

 字节码并不是机器码,操作系统无法直接识别,需要在操作系统上安装不同版本的JVM来识别。

通常情况下,我们只需要安装不同版本的 JDK(Java Development Kit,Java 开发工具包)就行了,它里面包含了 JRE(Java Runtime Environment,Java 运行时环境),而 JRE 又包含了 JVM。

说到这里,来讲一下JDK、JRE、JVM的区别(面试题)

JVM:Java 虚拟机,Java 程序运⾏在 Java 虚拟机上。针对不同系统的实现不同的 JVM,因此 Java 语⾔可以实现跨平台。

JRE: Java 运⾏时环境。它是运⾏已编译 Java 程序所需的所有内容的集合,包括Java 虚拟 机(JVM),Java 类库,Java 命令和其他的⼀些基础构件。但是,它不能⽤于创建新程序。

JDK: Java Development Kit,它是功能⻬全的 Java SDK。它拥有 JRE 所拥有的⼀切,还有编译器(javac)和⼯具(如 javadoc 和 jdb)。它能够创建和编译程序。

简单来说,JDK 包含 JRE,JRE 包含 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 Compiler,JIT)可以将频繁执⾏的字节码动态地编译为本地机器代码,从⽽提⾼ 执⾏速度。JIT编译器会根据程序的运⾏情况优化字节码的执⾏,使得Java程序在运⾏时 具有接近本地代码的性能。

3. 灵活性:由于Java程序在字节码级别上运⾏,可以实现⼀些⾼级特性,如动态加载和动态链接。这些特性使得Java可以⽀持动态扩展和插件式开发,使得开发⼈员能够在运⾏时动态地加载和卸载代码。

 

我们使用javap来看一下HelloWorld的字节码指令序列。

  1. 0 getstatic #2 <java/lang/System.out>
  2. 3 ldc #3 <Hello World>
  3. 5 invokevirtual #4 <java/io/PrintStream.println>
  4. 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程序的运行机制。

我们来分析一下下面的类的字节码文件:

  1. public class Main {
  2. private int age = 18;
  3. public int getAge() {
  4. return age;
  5. }
  6. }

3.2.2 字节码的基本信息 

使用 javap -v -p Main.class(-p显示所有类和成员,包括私有)

  1. Classfile /ProgremFiles/ideaProjecct/target/classes/doufen/work/jvm/Main.class
  2. Last modified 202446日; size 385 bytes
  3. SHA-256 checksum 6688843e4f70ae8d83040dc7c8e2dd3694bf10ba7c518a6ea9b88b318a8967c6
  4. Compiled from "Main.java"
  5. public class doufen.work.jvm.Main
  6. minor version: 0
  7. major version: 55
  8. flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  9. this_class: #3 // doufen/work/jvm/Main
  10. super_class: #4 // java/lang/Object
  11. interfaces: 0, fields: 1, methods: 2, attributes: 1
  12. Constant pool:
  13. #1 = Methodref #4.#18 // java/lang/Object."<init>":()V
  14. #2 = Fieldref #3.#19 // doufen/work/jvm/Main.age:I
  15. #3 = Class #20 // doufen/work/jvm/Main
  16. #4 = Class #21 // java/lang/Object
  17. #5 = Utf8 age
  18. #6 = Utf8 I
  19. #7 = Utf8 <init>
  20. #8 = Utf8 ()V
  21. #9 = Utf8 Code
  22. #10 = Utf8 LineNumberTable
  23. #11 = Utf8 LocalVariableTable
  24. #12 = Utf8 this
  25. #13 = Utf8 Lcom/itwanger/jvm/Main;
  26. #14 = Utf8 getAge
  27. #15 = Utf8 ()I
  28. #16 = Utf8 SourceFile
  29. #17 = Utf8 Main.java
  30. #18 = NameAndType #7:#8 // "<init>":()V
  31. #19 = NameAndType #5:#6 // age:I
  32. #20 = Utf8 com/itwanger/jvm/Main
  33. #21 = Utf8 java/lang/Object
  34. {
  35. private int age;
  36. descriptor: I
  37. flags: (0x0002) ACC_PRIVATE
  38. public doufen.work.jvm.Main();
  39. descriptor: ()V
  40. flags: (0x0001) ACC_PUBLIC
  41. Code:
  42. stack=2, locals=1, args_size=1
  43. 0: aload_0
  44. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  45. 4: aload_0
  46. 5: bipush 18
  47. 7: putfield #2 // Field age:I
  48. 10: return
  49. LineNumberTable:
  50. line 6: 0
  51. line 7: 4
  52. LocalVariableTable:
  53. Start Length Slot Name Signature
  54. 0 11 0 this Main;
  55. public int getAge();
  56. descriptor: ()I
  57. flags: (0x0001) ACC_PUBLIC
  58. Code:
  59. stack=1, locals=1, args_size=1
  60. 0: aload_0
  61. 1: getfield #2 // Field age:I
  62. 4: ireturn
  63. LineNumberTable:
  64. line 9: 0
  65. LocalVariableTable:
  66. Start Length Slot Name Signature
  67. 0 5 0 this Main;
  68. }
  69. SourceFile: "Main.java"

3.2.3 常量池

Constant pool,是字节码文件最重要的常量池部分。可以把常量池理解为字节码文件中的资源仓库,主要存放两大类信息。

  1. 字面量(Literal),有点类似Java中的常量概念,比如文本字符串,final常量等。
  2. 符号引用(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

 类型为 Utf8,UTF-8 编码的字符串,值为 java/lang/Object

第 18 个常量:

#18 = NameAndType #7:#8 // "<init>":()V

 类型为 NameAndType,表明是字段或者方法的部分符号引用,指向常量池中下标为 7 和 8 的常量。

第 7 个常量:

#7 = Utf8 <init>

 类型为 Utf8,UTF-8 编码的字符串,值为 <init>,表明为构造方法。

第 8 个常量:

#8 = Utf8 ()V

 类型为 Utf8,UTF-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

 类型为 Utf8,UTF-8 编码的字符串,值为 age,表明字段名为 age。

第 6 个常量:

#6 = Utf8               I

 类型为 Utf8,UTF-8 编码的字符串,值为 I,表明字段的类型为 int。

标识字符含义
B基本数据类型byte
C

基本数据类型char

D基本数据类型double
F基本数据类型float
I基本数据类型int
J基本数据类型long
S基本数据类型short
Z基本数据类型boolean
V特殊类型void
L引用数据类型,以分号“;”结尾
[一维数组

到此为止,第 2 个常量算是摸完了。组合起来的意思就是,声明了一个类型为 int 的字段 age。#3 指向 Class #20(即 com/itwanger/jvm/Main),#19 指向 NameAndType #5:#6(即 age:I)。

3.2.4 字段表集合

字段表用来描述接口或者类中声明的变量,包括类变量和成员变量,但不包含声明在方法中局部变量。

字段的修饰符一般有:

  • 访问权限修饰符,比如 public private protected
  • 静态变量修饰符,比如 static
  • final修饰符
  • 并发可见性修饰符,比如 volatile
  • 序列化修饰符,比如 transient

然后是字段的类型(基本数据类型、数组和对象)和名称。

在Main.class字节码文件中,字段表的信息如下所示。

  1. private int age;
  2. descriptor: I
  3. flags: (0x0002) ACC_PRIVATE

 表明字段的访问权限修饰符为 private,类型为 int,名称为 age。字段的访问标志和类的访问标志非常类似。

 3.2.5 方法表集合

方发表用来描述接口或者类中声明的方法,包括类方法和成员方法,以及构造方法。方法的修饰符和字段略有不同,比如说volatile和transient不能用来修饰方法,再比如说方法的修饰符多了synchronized、native、strictfp和abstract。

 构造方法

下面这部分为构造方法,返回类型为void,访问标志为public

  1. public doufen.work.jvm.Main();
  2. descriptor: ()V
  3. flags: (0x0001) ACC_PUBLIC
  • 声明:public doufen.work.jvm.Main(); 这是 Main 类的构造方法,用于创建 Main 类的实例。它是公开的(public)。
  • 描述符:descriptor: ()V
    这表示构造方法没有参数 (()) 并且没有返回值 (V,代表 void)。
  • 访问标志:flags: (0x0001) ACC_PUBLIC,表示这个构造方法是公开的,可以从其他类中访问。

  1. Code:
  2. stack=2, locals=1, args_size=1
  3. 0: aload_0
  4. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  5. 4: aload_0
  6. 5: bipush 18
  7. 7: putfield #2 // Field age:I
  8. 10: return
  9. LineNumberTable:
  10. line 6: 0
  11. line 7: 4
  12. LocalVariableTable:
  13. Start Length Slot Name Signature
  14. 0 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 类型。

局部变量表所需的容量大小是在编译期间完成计算的,大小由编译器决定,因此不同的编译器编译出来的字节码可能会不一样。

locals=1,这表示局部变量表中有 1 个变量的空间。对于实例方法(如构造方法),局部变量表的第一个位置(索引 0)总是用于存储 this 引用。

③、args_size 为方法的参数个数。

为什么 stack 的值为 2,locals 的值为 1,args_size 的值为 1 呢?默认的构造方法不是没有参数和局部变量吗

这是因为有一个隐藏的 this 变量,只要不是静态方法,都会有一个当前类的对象 this 悄悄的存在着。

这就解释了为什么 locals 和 args_size 的值为 1 的问题。

那为什么 stack 的值为 2 呢?因为字节码指令 invokespecial(调用父类的构造方法进行初始化)会消耗掉一个当前类的引用,所以 aload_0 执行了 2 次,也就意味着操作数栈的大小为 2。

④、LineNumberTable,该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。这对于调试非常重要,因为它允许调试器将正在执行的字节码指令精确地关联到源代码的特定行。

 成员方法

下面这部分为成员方法 getAge(),返回类型为 int,访问标志为 public。

  1. public int getAge();
  2. descriptor: ()I
  3. flags: (0x0001) ACC_PUBLIC

 理解了构造方法的 Code 属性后,再看 getAge() 方法的 Code 属性时,就很容易理解了。

  1. Code:
  2. stack=1, locals=1, args_size=1
  3. 0: aload_0
  4. 1: getfield #2 // Field age:I
  5. 4: ireturn
  6. LineNumberTable:
  7. line 9: 0
  8. LocalVariableTable:
  9. Start Length Slot Name Signature
  10. 0 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)

用于将数据从栈帧的局部变量表和操作数栈之间来回传递。

  1. public void add(){
  2. int a=1;
  3. int b=1;
  4. }

 字节码指令的执行过程如图所示:

 然后我们来分析load和store指令的具体含义:

(1)将局部变量表中的变量压入操作数栈中:

  • xload_<n>(x为i、l、f、d、a、n默认为0到3)表示将第n个局部变量压入操作数栈中。
  • xload(x为i、l、f、d、a)通过指定参数的形式,将局部变量压入操作数栈中,当使用这个指令时,表示局部变量的数量可能超过4个

x 为操作码助记符,表明是哪一种数据类型。见下表所示。

再来看一个例子:

  1. private void load(int age, String name, long birthday, boolean sex) {
  2. System.out.println(age + name + birthday + sex);
  3. }

 通过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 指令。

举例来说:

  1. public void pushConstLdc() {
  2. // 范围 [-1,5]
  3. int iconst = -1;
  4. // 范围 [-128,127]
  5. int bipush = 127;
  6. // 范围 [-32768,32767]
  7. int sipush= 32767;
  8. // 其他 int
  9. int ldc = 32768;
  10. String aconst = null;
  11. String IdcString = "hello";
  12. }

对应的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_<n>(x 为 i、l、f、d、a,n 默认为 0 到 3)
  • xstore(x 为 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
  1. public void calculate(int age) {
  2. int add = age + 1;
  3. int sub = age - 1;
  4. int mul = age * 2;
  5. int div = age / 3;
  6. int rem = age % 4;
  7. age++;
  8. age--;
  9. }

 

  • iadd,加法
  • isub,减法
  • imul,乘法
  • idiv,除法
  • irem,取余
  • iinc,自增的时候 +1,自减的时候 -1

需要注意的是,数据运算可能会导致溢出,比如两个很大的正整数相加,很可能会得到一个负数。但Java虚拟机规范中并没有对这种情况给出具体结果,因此程序是不会显示报错的。所以,在开发的过程中如果涉及到较大的数据进行加法,乘法运算的时候,一定要小心。

当发生溢出时,将会使用有符号的无穷大Infinity来表示;如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。而且所有使用NaN作为操作数的算术操作,结果都会返回NaN。

  1. public void infinityNaN() {
  2. int i = 10;
  3. double j = i / 0.0;
  4. System.out.println(j); // Infinity
  5. double d1 = 0.0;
  6. double d2 = d1 / 0.0;
  7. System.out.println(d2); // NaN
  8. }
  • 任何一个非零的数除以浮点数 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 虚拟机并不会因此抛出运行时异常。

举例来说:

  1. public void updown() {
  2. int i = 10;
  3. double d = i;
  4. float f = 10f;
  5. long ong = (long)f;
  6. }

 解析后之后:

  • i2d,int 宽化为 double
  • f2l, float 窄化为 long

 3.3.4 对象的创建和访问指令

(1)创建指令

数组是一种特殊的对象,它创建的字节码指令和普通对象的创建指令不同。创建数组的指令有三种:

  • newarray:创建基本数据类型的数组
  • anewarray:创建引用类型的数组
  • multianewarray:创建多维数组

而对象的创建指令只有一个,就是new,它会接收一个操作数,指向常量池中的一个索引,表示要创建的类型。

举例来说:

  1. public void newObject() {
  2. String name = new String("hello");
  3. File file = new File("智者不入爱河.book");
  4. int [] ages = {};
  5. }

 字节码指令如下:

  • new #33 <java/lang/String>,创建一个 String 对象。
  • new #35 <java/io/File>,创建一个 File 对象。
  • newarray 10 (int),创建一个 int 类型的数组。

(2)字段访问指令

字段可以分为两类,一类是成员变量,一类是静态变量(也就是类变量),所以字段访问指令可以分为两类:

  • 访问静态变量:getstatic、putstatic。
  • 访问成员变量:getfield、putfield,需要创建对象后才能访问。

成员变量和静态变量的区别?(面试题)

  • 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配、
  • 我们知道类变量表有两次初始化的机会,第一次是在类加载过程中的“prepare阶段”,执行系统初始化,对类变量设置为零值(默认0),另一次则是在“initial阶段”,赋予显式初始值(程序员在代码中定义的初始值)。
  • 和类变量(静态变量不同的是),局部变量表不存在系统初始化的过程,这意味这成员变量必须进行显式赋值(人为手动初始化),否则无法使用。

 举例来说:

  1. package StackTest;
  2. /**
  3. *
  4. * @author doufen
  5. * @date 2024/4/12
  6. */
  7. public class Writer {
  8. private String name;
  9. static String mark = "作者:doufen";
  10. public static void main(String[] args) {
  11. print(mark);
  12. Writer w = new Writer();
  13. print(w.name);
  14. }
  15. public static void print(String arg) {
  16. System.out.println(arg);
  17. }
  18. }

 看字节码指令:

  • getstatic #2 <StackTest/Writer.mark>,访问静态变量 mark
  •  getfield #6 <StackTest/Writer.name>,访问成员变量 name

3.3.5 方法调用和返回指令

方法调用指令有 5 个,分别用于不同的场景:

  • invokevirtual:用于调用对象的成员方法,根据对象的实际类型进行分派,支持多态。
  • invokeinterface:用于调用接口方法,会在运行时搜索由特定对象实现的接口方法进行调用。
  • invokespecial:用于调用一些需要特殊处理的方法,包括构造方法、私有方法和父类方法。
  • invokestatic:用于调用静态方法。
  • invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行。

举例说明:

  1. public class InvokeExamples {
  2. private void run() {
  3. List ls = new ArrayList();
  4. ls.add("智者不入爱河");
  5. ArrayList als = new ArrayList();
  6. als.add("豆粉一路硕博");
  7. }
  8. public static void print() {
  9. System.out.println("invokestatic");
  10. }
  11. public static void main(String[] args) {
  12. print();
  13. InvokeExamples invoke = new InvokeExamples();
  14. invoke.run();
  15. }
  16. }

字节码指令如下: 

 

 InvokeExamples 类有 4 个方法,包括缺省的构造方法在内。

 (1)invokespecial

缺省的构造方法内部会调用超类 Object 的初始化构造方法:

`invokespecial #1 // Method java/lang/Object."<init>":()V`

 (2)invokeinterface和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 指令。

(3)invokestatic

invokestatic  #11 // Method print:()V

 print() 方法是静态的,所以调用的是 invokestatic 指令。

3.3.6 invokedynamic

invokedynamic是在JDK7引入的,主要是为了支持动态语言,如:Groovy、Scala、JRuby等。这些语言都是在运行时动态解析出调用点限定符所引用的方法,并执行。

Lambda 表达式的实现就依赖于 invokedynamic 指令:

  1. import java.util.function.Function;
  2. public class LambdaExample {
  3. public static void main(String[] args) {
  4. // 使用 Lambda 表达式定义一个函数
  5. Function<Integer, Integer> square = x -> x * x;
  6. // 调用这个函数
  7. int result = square.apply(5);
  8. System.out.println(result); // 输出 25
  9. }
  10. }

 字节码指令:

在这个例子中,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。

  • 将一个或两个元素从栈顶弹出,并且直接废弃,比如 pop,pop2;
  • 复制栈顶的一个或两个数值并将其重新压入栈顶,比如 dup,dup2,dup*×1dup2*×1dup*×2dup2*×2
  • 将栈最顶端的两个槽中的数值交换位置,比如 swap。

这些指令不需要指明数据类型,因为是按照位置压入和弹出的。

举例:

  1. public class Dup {
  2. int age;
  3. public int incAndGet() {
  4. return ++age;
  5. }
  6. }

  • 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)比较指令

比较指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp,指令的第一个字母代表的含义分别是 double、float、long。注意,没有 int 类型。

对于 double 和 float 来说,由于 NaN 的存在,有两个版本的比较指令。拿 float 来说,有 fcmpg 和 fcmpl,区别在于,如果遇到 NaN,fcmpg 会将 1 压入栈,fcmpl 会将 -1 压入栈。

举例:

  1. public void lcmp(long a, long b) {
  2. if(a > b){}
  3. }

 

lcmp 用于两个 long 型的数据进行比较。 

(2)条件跳转指令

这些指令都会接收两个字节的操作数,它们的统一含义是,弹出栈顶元素,测试它是否满足某一条件,满足的话,跳转到对应位置。

对于 long、float 和 double 类型的条件分支比较,会先执行比较指令返回一个整型值到操作数栈中后再执行 int 类型的条件跳转指令。

对于 boolean、byte、char、short,以及 int,则直接使用条件跳转指令来完成。

(3)比较条件转指令

3.3.10 异常处理时的字节码指令

  1. public class ExceptionExample {
  2. public void testException() {
  3. try {
  4. int a = 1 / 0; // 这将导致除以零的异常
  5. } catch (ArithmeticException e) {
  6. System.out.println("发生算术异常");
  7. }
  8. }
  9. }
  1. public void testException();
  2. Code:
  3. 0: iconst_1
  4. 1: iconst_0
  5. 2: idiv
  6. 3: istore_1
  7. 4: goto 12
  8. 7: astore_1
  9. 8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
  10. 11: ldc #3 // String 发生算术异常
  11. 13: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  12. 16: return
  13. Exception table:
  14. from to target type
  15. 0 4 7 Class java/lang/ArithmeticException

3.3.11 synchronized的字节码指令

  1. public class SynchronizedExample {
  2. public void syncBlockMethod() {
  3. synchronized(this) {
  4. // 同步块体
  5. }
  6. }
  7. }

 字节码如下:

  1. public void syncBlockMethod();
  2. Code:
  3. 0: aload_0
  4. 1: dup
  5. 2: astore_1
  6. 3: monitorenter
  7. 4: aload_1
  8. 5: monitorexit
  9. 6: goto 14
  10. 9: astore 2
  11. 11: aload_1
  12. 12: monitorexit
  13. 13: aload 2
  14. 15: athrow
  15. 16: return
  16. Exception table:
  17. from to target type
  18. 4 6 9 any
  19. 9 13 9 any

monitorenter / 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:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些内存都将在方法区中分配。对于该阶段有一下需要注意:

  1. 这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 从概念讲,类变量所使用的内存都应当在方法区中进行分配。不过一点需要注意的是:JDK7之前的HotSpot使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。而在JDK7之后,HotSpot已经把原本放在永久代的字符串常量池、静态变量等一定到堆中,这个时候类变量则会随着Class对象一起存放在Java堆中。
  3. 这里所设置的初始值“通常情况”下是数据类型默认的零值,比如我们定义的public static int value=111,那么value变量在准备阶段的初始化就是0而不是111(初始化阶段才会赋值)。特殊情况:比如给value变量加上了final关键字public final static int value=111,那么准备阶段value的值就会被赋值为111.

解析

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

举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化

初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

 对于<clinit>()方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为<clinit>()方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难发现。

对于初始化阶段,虚拟机严格规范了有且只有6中情况下,必须对类进行初始化(只有主动去使用类才会初始化类):

  • 当遇到 newgetstaticputstaticinvokestatic 这 4 条字节码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
  • 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forName("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
  • 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  • 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  • MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,
    就必须先使用 findStaticVarHandle 来初始化要调用的类。
  •  当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类卸载

卸载类即该类的 Class 对象被 GC。

卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被 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

  1. BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。
  2. ExtensionClassLoader(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
  3. AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

 除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class 文件)进行加密,加载时再利用自定义的类加载器对其解密。

每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoadernull的话,那么间接的说明该类是通过 BootstrapClassLoader 加载的。

  1. public abstract class ClassLoader {
  2. ...
  3. // 父加载器
  4. private final ClassLoader parent;
  5. @CallerSensitive
  6. public final ClassLoader getParent() {
  7. //...
  8. }
  9. ...
  10. }

 为什么 获取到 ClassLoadernull就间接证明是 BootstrapClassLoader 加载的呢? 这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。

下面我们来举个例子,用于获取ClassLoader的上下级关系:

  1. package ClassLoaderTest;
  2. public class PrintClassLoaderTree {
  3. public static void main(String[] args) {
  4. ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader();
  5. StringBuilder split = new StringBuilder("|--");
  6. boolean needContinue = true;
  7. while (needContinue){
  8. System.out.println(split.toString() + classLoader);
  9. if(classLoader == null){
  10. needContinue = false;
  11. }else{
  12. classLoader = classLoader.getParent();
  13. split.insert(0, "\t");
  14. }
  15. }
  16. }
  17. }

打印结果是如图:

从输出结果可以看出:

  • 我们编写的 Java 类 PrintClassLoaderTreeClassLoaderAppClassLoader
  • AppClassLoader的父类 ClassLoaderExtClassLoader
  • ExtClassLoader的父类ClassLoaderBootstrap 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 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的。

 类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。

  1. public abstract class ClassLoader {
  2. ...
  3. // 组合
  4. private final ClassLoader parent;
  5. protected ClassLoader(ClassLoader parent) {
  6. this(checkCreateClassLoader(), parent);
  7. }
  8. ...
  9. }

在面向对象编程中,有一条经典的设计原则:组合优于继承,多用组合少用继承。

3.6.1 双亲委派模型的执行流程

 双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoader 的 loadClass() 中,相关代码如下所示。

  1. protected Class<?> loadClass(String name, boolean resolve)
  2. throws ClassNotFoundException
  3. {
  4. synchronized (getClassLoadingLock(name)) {
  5. //首先,检查该类是否已经加载过
  6. Class c = findLoadedClass(name);
  7. if (c == null) {
  8. //如果 c 为 null,则说明该类没有被加载过
  9. long t0 = System.nanoTime();
  10. try {
  11. if (parent != null) {
  12. //当父类的加载器不为空,则通过父类的loadClass来加载该类
  13. c = parent.loadClass(name, false);
  14. } else {
  15. //当父类的加载器为空,则调用启动类加载器来加载该类
  16. c = findBootstrapClassOrNull(name);
  17. }
  18. } catch (ClassNotFoundException e) {
  19. //非空父类的类加载器无法找到相应的类,则抛出异常
  20. }
  21. if (c == null) {
  22. //当父类加载器无法加载时,则调用findClass方法来加载该类
  23. //用户可通过覆写该方法,来自定义类加载器
  24. long t1 = System.nanoTime();
  25. c = findClass(name);
  26. //用于统计类加载器相关的信息
  27. sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
  28. sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
  29. sun.misc.PerfCounter.getFindClasses().increment();
  30. }
  31. }
  32. if (resolve) {
  33. //对类进行link操作
  34. resolveClass(c);
  35. }
  36. return c;
  37. }
  38. }

对应的流程图如下所示:

 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();

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小丑西瓜9/article/detail/477538
推荐阅读
相关标签
  

闽ICP备14008679号