Java Virtual Machine : 一

本文深入介绍JVM相关知识。涵盖JVM内存结构,如程序计数器、虚拟机栈等;垃圾回收机制,包括判断对象回收方法、算法及回收器;类加载与字节码技术,涉及字节码指令、编译器处理等内容,还提及各部分内存溢出情况及解决办法。

什么是jvm?

定义:
Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)
好处:
        1.一次编写,到处运行
        2.自动内存管理,垃圾回收功能
        3.数组下标越界检查
        4.多态
jvm、jre、jdk的对比:

内存结构 

1.程序计数器

程序计数器的作用:记住下一条jvm指令的执行地址

特点:

        1.是线程私有的,即只服务于一个线程 

        2.不会存在内存溢出

        3.轻量级,占用的内存非常小,通常只有几个字节

        4.随着线程创建而创建,随着线程销毁而销毁


2.虚拟机栈

2.1定义

        1.每个线程运行时所需要的内存,成为虚拟机栈

        2.每个栈由多个栈帧(Frame)组成,对应每次方法调用时所占用的内存

        3.每个线程只能有一个活动栈帧,对应当前正在执行的那个方法

1. 垃圾回收是否涉及栈内存?
  • 垃圾回收的焦点通常是堆内存,而不是栈内存。垃圾回收并不直接管理栈内存,但栈内存中的引用变量可以影响对象的可达性,从而影响对象是否会被回收。垃圾回收的目标是管理堆内存中的对象,确保不再被引用的对象能够被安全地回收。栈内存的管理通常由Java虚拟机和线程管理机制自动处理。        
2. 栈内存分配越大越好吗?
  • 栈内存的大小应该根据应用程序的需求和运行环境来合理配置。不必盲目地将栈内存分配得非常大,而是根据具体情况进行调整。过小的栈内存可能导致栈溢出错误,而过大的栈内存可能浪费内存资源。
3. 方法内的局部变量是否线程安全?
  • 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

2.2 栈内存溢出

  • 栈帧过多导致栈内存溢出
  • 栈帧过大导致栈内存溢出

2.3 线程运行诊断


3.本地方法栈

 本地方法栈(Native Method Stack)是Java虚拟机(JVM)中的一部分内存区域,用于管理调用本地方法(Native Method)的过程。本地方法栈的大小通常较小,因为本地方法的调用通常不会消耗大量的栈空间。本地方法是指由本地(非Java)语言编写的方法,通常使用JNI(Java Native Interface)来与Java代码进行交互。

当一个线程执行本地方法时,它会将当前线程的执行上下文切换到本地方法栈,然后在本地方法栈上执行本地方法。本地方法通常是用C、C++等本地编程语言编写的,它们无法像Java方法那样直接运行在JVM上,因此需要本地方法栈来管理调用过程。


4.堆

4.1 定义

Heap 堆

  • 通过new关键字,创建对象都会使用堆内存

特点

  • 它是线程共享的,堆中对象都需要考虑线程安全的问题
  • 有垃圾回收机制

4.2 堆内存溢出

  • 对象创建过多:应用程序在短时间内创建了大量的对象,超过了堆内存的容量。
  • 堆内存设置不足
  • 对象保持引用:如果某些对象被持续引用,即使它们不再被应用程序使用,也不会被垃圾回收。这种情况下,堆内存中的对象会逐渐累积,最终导致堆内存溢出。

4.3堆内存诊断

1. jps 工具
  • 查看当前系统中有哪些 java 进程
2. jmap 工具
  • 查看堆内存占用情况 jmap - heap 进程id
3. jconsole 工具
  • 图形界面的,多功能的监测工具,可以连续监测

案例
  • 垃圾回收后,内存占用仍然很高

利用 jvisualvm 工具查找原因


 5.方法区

5.1 定义

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区。方法区类似于传统语言的编译代码的存储区或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的 特殊方法

特点:

  • 在虚拟机启动时创建
  • jvm8之后,方法区的实现发生了变化,被称为“元空间”(Metaspace)。元空间不再是固定大小的,它位于本地内存之中,而不是jvm内存中。它可以根据需要动态扩展,而不会导致OutOfMemoryError。

5.2组成

jvm8之前:

jvm8之后:

 5.3 方法区内存溢出

  • 原因:方法区主要存储常量、对象类型数据。jdk1.6及之前,字符串常量池内存溢出(调用intern())导致方法区内存溢出,抛出OutOfMemoryError;创建过多的对象类型,典型地是在运行期间通过反射不断创建代理对象,导致方法区溢出,抛出OutOfMemoryError。
  • 溢出类型:java.lang.OutOfMemoryError。
  • 解决方法:jdk1.7开始,字符串常量池移到了堆中,不太可能出现内存溢出;jdk1.8开始,方法区也被元空间(Metaspace)取代,转移到了本地内存中,容量只受系统内存大小的限制,也不大可能出现内存溢出了。不过可以通过-XX:MaxMetaspaceSize指定元空间最大值,-XX:MetaspaceSize指定元空间大小初始值。

5.4 运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

5.5 String Table

特性
  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
    • 在Java中,编译器会对字符串字面值进行优化,将其在编译时添加到字符串常量池中。这意味着在运行Java程序之前,字符串常量池已经包含了所有出现在代码中的字符串字面值。
  • 字符串变量拼接的原理是 StringBuilder 1.8
    • 拼接出的对象会存储在 堆Heap 中
  • 字符串常量拼接的原理是编译期优化
  • 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
    • 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
    • 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回(复制的副本与原对象并不同)

常量池、运行时常量池,字符串常量池

  • 常量池(Constant Pool): 常量池是Java类文件(.class文件)中的一部分,用于存储编译时期的常量,包括字面值常量(如整数、浮点数、字符、布尔值)、符号引用(类名、字段名、方法名等)、静态常量等。
  • 运行时常量池(Runtime Constant Pool): 运行时常量池是方法区(在Java 7及之前版本称为永久代,Java 8及更高版本称为元空间)的一部分,用于存储已加载类的常量信息。它包含了常量池中的各种符号引用(如类和方法的全限定名、字段名)以及字符串字面值(String literals)。运行时常量池在类加载后被初始化,可以在运行时动态生成一些常量(如String的intern()方法将字符串添加到运行时常量池中)。
  • 字符串常量池(String Pool): 字符串常量池是运行时常量池的一部分,用于存储字符串字面值(由双引号括起来的字符串)。

位置

在jvm1.6时,StringTable位于永久代中

在jvm1.6之后,StringTable位于 堆Heap 中

测试:在jvm1.6时,往StringTable中不断存入字符串,最后会报OutOfMemoryError: PermGen Space,即永久代内存溢出了。

而在jvm1.8时,同样的程序首先会报GC overhead limit exceeded,它表示在一段时间内,JVM花费了过多的时间来执行垃圾回收操作,但仍然无法回收足够的内存空间,导致应用程序无法正常运行。

在添加运行时参数 -XX:- UseGCOverheadLimit 之后,即停用该操作。这一次的报错位置便成了Java heap space,即堆内存溢出


垃圾回收

StringTable也会参与垃圾回收。当内存不足时,垃圾回收器会清理那些没有引用指向的字符串对象。


调优 

String Table 使用一种特殊的哈希表来存储字符串常量,以提高字符串的重用性和性能。

当我们往String Table中存入字符串时,会涉及到哈希碰撞和哈希桶的概念。在一定范围内,哈希桶的数量越多,存入相同数量的字符串的速度就越快。原因如下:

  1. 减少哈希碰撞: 哈希表的主要目标是将不同的键(字符串)映射到不同的哈希桶中。当哈希桶的数量有限时,不同的字符串可能会映射到相同的桶中,导致哈希碰撞。而增加哈希桶的数量可以减少碰撞的概率,因为有更多的桶可供选择,这有助于均匀地分散字符串,避免大量字符串集中在少数几个桶中。

    1. 选择质数作为哈希桶的数量有助于减少碰撞的概率

  2. 提高查找效率: 哈希表的查找效率与哈希桶的数量有关。如果哈希桶的数量足够多,那么查找特定字符串时,哈希表只需在少数桶中搜索,而不需要遍历整个表。这可以显著提高查找速度,尤其是在大规模的数据集中。

  3. 分散内存分配: 哈希表中的每个哈希桶通常都需要一定的内存来存储数据。如果哈希桶的数量有限,每个桶可能需要存储大量的数据,导致内存分配变得不均匀。增加哈希桶的数量可以更均匀地分散内存分配,有助于减少内存碎片。

另外,合理使用String.intern()方法,将某个被频繁使用的字符串手动加入到String Table 中,以便重用。


6.直接内存

6.1 定义

Direct Memory
  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理

6.2 分配和回收原理

  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
  • ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner clean 方法调用 freeMemory 来释放直接内存

在进行jvm调优时,一般会添加运行时参数:-XX:+DisableExplicitGC,即禁用显性的垃圾回收。也就是说在代码中显性调用的System.gc()都会无效。

这是因为显性调用垃圾回收触发的是FULL GC,FULL GC 会导致整个应用程序的停顿,也就是STW,这个停顿时间可能会相对较长,特别是在处理大量内存时,因为它需要清理整个堆内存,包括新生代和老年代。

  • STW(Stop-The-World)是一种与垃圾回收(GC)相关的概念,它指的是在某些情况下,Java应用程序的所有线程都会被暂停或停止,以便执行垃圾回收或其他一些与Java虚拟机内部操作相关的任务。

那么在禁用显性垃圾回收后,直接内存的回收则会受到一定影响。因为虽然没有引用指向分配的直接内存,但可能因为此时内存空间还较为充裕,不会触发自动垃圾回收,而显性垃圾回收又被禁用,因此这块内存便没有被释放。

解决办法是直接通过Unsafe类的freeMemory()方法释放直接内存。


垃圾回收

1.如何判断对象可以回收

1.1 引用计数法

这个算法跟踪每个对象的引用计数。当引用计数达到零时,对象就被认为是垃圾。引用计数算法容易实现,但它无法处理循环引用的情况,并且会产生额外的开销来维护引用计数。

1.2 可达性分析算法

可达性分析算法(Reachability Analysis)是垃圾回收领域中的一种算法,用于确定在程序执行过程中哪些对象是可访问(或可达)的,以及哪些对象不可访问,从而确定哪些对象应该被回收。

过程:扫描堆中的对象,看是否能够沿着 GC Root对象 为起点的引用链找到该对象,找不到,表示可以回收
优点:
  • 能够处理循环引用(对象之间相互引用)的情况,不会将仍然相互引用的对象误判为垃圾。
  • 能够精确识别可达对象,确保不会回收仍然被引用的对象。
  • 适用于各种内存分配模式和数据结构,包括复杂的数据结构和图形数据。

1.3 四种引用

1. 强引用
  •  只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
2. 软引用(SoftReference
  • 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象
  • 可以配合引用队列来释放软引用自身
3. 弱引用(WeakReference
  • 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
  • 可以配合引用队列来释放弱引用自身
4. 虚引用(PhantomReference
  • 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
5. 终结器引用(FinalReference
  • 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize方法,第二次 GC 时才能回收被引用对象

2.垃圾回收算法

2.1 标记清除算法

标记-清除算法(Mark and Sweep):

  • 定义:这是最基本的垃圾回收算法之一。它分为两个阶段:标记阶段和清除阶段。在标记阶段,垃圾回收器标记所有仍然存活的对象。在清除阶段,垃圾回收器删除未标记的对象。
  • 优点:速度快
  • 缺点:标记-清除算法有一个缺点,即会产生内存碎片。

2.2 标记整理算法

标记-压缩算法(Mark and Compact):

  • 定义:这个算法结合了标记-清除和复制算法的思想。首先,标记阶段标记所有存活的对象。然后,所有存活的对象会被移动到一起,以便在堆内存中形成连续的块,然后清除未被移动的对象。这减少了内存碎片,但需要额外的复制操作。
  • 优点:不会产生内存碎片
  • 缺点:速度较慢

 2.3 复制算法

复制算法(Copying): 

  • 定义:这个算法将堆内存分为两个区域,通常称为"From"和"To"。对象首先分配在From区域,当From区域满时,垃圾回收器会将仍然存活的对象复制到To区域,并清除From区域。
  • 不会产生内存碎片
  • 需要占用双倍内存空间

3.分代垃圾回收

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1并且交换 from to
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
    • 因为在垃圾回收过程中,可能会涉及对象地址的变化,例如从伊甸园到 TO区,从新生代到老年代等,因此要暂停其他线程
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是154bit,最大即 1111B
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gcSTW的时间更长

from与to:

如下图所示,通常情况下,from 的结尾应该是 to 的开头;图中的情况却正好相反,说明已经进行过一次GC,from 和 to 的位置发生了对调。

大对象直接晋升老年代:

当一个新创建的对象过大时,新生代即使GC依旧无法存放,而老年代可以存放时,则可以直接将该对象晋升至老年代。

3.1 相关VM参数

在此处添加VM参数


4.垃圾回收器 

  1. 串行​​​​​​
    1. 单线程
    2. 堆内存较小,适合个人电脑
  2. 吞吐量优先
    1. 多线程
    2. 堆内存较大,多核CPU
    3. 让单位时间内,STW 的时间最短 ,垃圾回收时间占比最低,这样就称吞吐量高
      1. 例如一小时内发生两次垃圾回收,0.2 0.2 = 0.4,让垃圾回收时间占总时间最少
  3. 响应时间优先
    1. 多线程
    2. 堆内存较大,多核CPU
    3. 尽可能让单次STW的时间最短
      1. 例如一小时内发生5次垃圾回收 0.1 0.1 0.1 0.1 0.1 = 0.5,让单次垃圾回收时间最短

4.1 串行垃圾回收器

VM参数:-XX:+UseSerialGC = Serial + SerialOld

工作流程如上:串行是一种单线程的垃圾回收器。当某个线程需要进行垃圾回收时,所有线程都会在一个安全点内暂停,待垃圾回收结束之后再继续运行

安全点是指,当线程运行到这类位置时,堆对象状态是确定一致的,JVM可以安全地进行操作,如GC,偏向锁解除等。


4.2 吞吐量优先 

VM参数:-XX:+UseParallelGC ~ -XX:+UseParallelOldGC

                -XX:+UseAdaptiveSizePolicy
                -XX:GCTimeRatio=ratio
                -XX:MaxGCPauseMillis=ms
                -XX:ParallelGCThreads=n

吞吐量优先垃圾回收器是并行工作机制,即会有多个垃圾回收线程同时运行,期间其他线程暂停。


4.3 响应时间优先

-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark

响应时间优先是并发机制,即垃圾回收线程运行时,其他工作线程也能正常运行

初始标记阶段:这个阶段会标记那些仍然存活的对象。标记所有 根对象。这个阶段会造成STW(也有文章说不会造成STW)

并发标记阶段:用户线程恢复运行,与此同时,我们的垃圾回收线程它还可以继续并发标记,把剩余的那些垃圾对象(因为上次标记只标记了根对象)给它找出来,这里跟用户线程是并发执行的,在这个过程中,它不用 STW,所以它的响应时间是很短的

重新标记阶段:这个阶段会再次触发STW,这是因为在并发标记的同时, 用户线程也在工作,它工作的时候就有可能产生一些新的对象,改变一些对象的引用,就可能对垃圾回收做了一些干扰,因此会再次标记垃圾对象

再次清理阶段:这个阶段会清理并发标记阶段产生的新的垃圾对象,因为是并发运行垃圾回收线程,所以不会造成STW。不过因为这个阶段用户线程也在同时运行,所以有可能产生新的垃圾,称为“浮动垃圾”


4.4 G1垃圾回收器

G1(Garbage-First)垃圾回收器是Java虚拟机(JVM)中的一种垃圾回收器,它于Java 7版本引入,并在Java 9之后成为了默认的垃圾回收器。G1垃圾回收器的设计目标是提供高吞吐量、低停顿时间的垃圾回收性能,尤其适用于大内存、多核处理器的应用场景。

4.4.1 垃圾回收阶段

这里将G1的垃圾回收分为了三个阶段,分别是:

Young Collection:对新生代的垃圾回收

Young Collection + Concurrent Mark:新生代垃圾回收与并发标记,在Young GC时会对 GC Root 进行初始标记

Mixed Collection:混合回收,对新生代、幸存区和老年代进行全面垃圾回收


4.4.2 Young Collection

G1将整个堆划分为多个大小相等或近似相等的区域,称为"分区",每个区域都可以独立作为伊甸园、幸存区和老年代。

如下图所示,当创建了新对象时,会被分配到伊甸园,即图中绿色方块。因为G1将整个内存都划分为了多个大小相等的区域,所以可以有多个伊甸园或幸存区等。

当创建的对象逐渐增多,伊甸园内存渐渐紧张时,会触发一次Young Collection的新生代垃圾回收,将幸存的对象复制放入幸存区中(如下图)。新生代的垃圾回收造成的STW时间较短。

一段时间过后,当幸存区也渐渐内存不足时,又会触发Young Collection,将幸存区中再次幸存的对象拷贝放入其他幸存区,而部分存活时间较长的则会晋升至老年代


4.4.3 Young Collection + CM
  • 在 Young GC 时会进行 GC Root 的初始标记

  • 在老年代占用堆内存达到阈值时,进行并发标记(不会 STW),由下面的VM参数决定阈值

    -XX:InitiatingHeapOccupancyPercent=percent(默认值为45%)

在进行Young GC的STW期间就会对 根对象 进行标记。然后在老年代占用堆内存达到阈值时,就会进行并发标记。并发标记不会造成STW,而是与用户线程并发执行。并发标记会再从 根对象 出发,顺着它的引用链标记其他对象。


4.4.4 Mixed Collection
会对 ESO 进行全面垃圾回收
  • 最终标记(Remark)会 STW
  • 拷贝存活(Evacuation)会 STW
-XX:MaxGCPauseMillis=ms
混合收集阶段,它会对 伊甸园、幸存区、老年代这三个区域进行一个全面的垃圾回收。
伊甸园中幸存的对象被拷贝至幸存区;
幸存区中的对象可能会被拷贝至其他幸存区,而部分符合晋升条件的对象则会晋升至老年代;
而进行老年代的垃圾回收时,为了使STW的时间小于MaxGCPauseMills这个目标,G1会选取回收价值最高的老年代,即垃圾最多的老年代进行回收,而不会一次性回收所有老年代。


4.4.5 Full GC
  • SerialGC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • ParallelGC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • CMS
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足
      • 与G1类似,并发标记失败后触发Full GC
  • G1
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足
      • 当参数InitiatingHeapOccupancyPercent(老年代内存占堆内存)达到阈值时(默认为45%),会触发并发标记,并进行Mixed Collection。在这两个阶段中,如果垃圾回收的速度大于新垃圾产生的速度,则不会触发Full GC。
      • 如果新垃圾产生速度大于垃圾回收速度,则并发收集失败,触发担保机制,执行一次STW式的、单线程的Full GC

4.4.6 Young Collection跨代引用

在新生代的垃圾回收过程中,需要通过根对象进行可达性分析,以判断新生代中的对象是否存活。但是,部分根对象来自于老年代,而老年代通常存活对象很多。如果对整个老年代进行扫描寻找根对象,显然效率很低。

因此,老年代采用了一种名为 卡片(Card) 的技术,将整个老年代划分为若干个大小为512Byte的Card,如果有老年代的对象引用了新生代对象,则将这个Card标记为 脏卡 

这样的话好处就是在进行GC Root遍历时,不用再扫描整个老年代了,只需要关注 脏卡 即可,提高了扫描根对象的效率。

  • 卡表与 Remembered Set
  • 在引用变更时通过 post-write barrier + dirty card queue
  • concurrent refinement threads 更新 Remembered Set

而在新生代中,有一个Remembered Set,这个集合会记录外部哪些Card有指向新生代的引用,然后通过Remembered Set将这个 Card 标记为 脏卡。

将来对新生代进行垃圾回收时,就可以通过Remembered Set找到对应的脏Card,再根据脏Card去遍历 GC Root,这样可以减少 GC Root 遍历的时间。

那么如何标记 脏Card 呢?G1采用的是post-write barrier,即写后栅栏。在每次对象引用发生变更之后,post-write barrier会记录这次变更,并将脏Card的更新指令存放在 dirty card queue 中,之后由 concurrent refinement threads 对 Rset 进行更新维护。


4.4.7 Remark
  • pre-write barrier + satb_mark_queue

如上图所示,在并发标记阶段,对象会被依次处理。所有对象都处理完毕后,仍被标记为白色的对象被认定为垃圾对象,会被回收。

但是,因为这个阶段是并发进行的,那么在这个过程中,用户线程有可能会更改部分对象的引用,因此在并发标记阶段之后还有一个 Remark 重新标记阶段。在这个阶段,G1会标记那些在并发标记阶段标记之后变得可达的对象。

示例:

1. 处理B对象,因为有强引用,因此标记为黑色。

2.处理完B对象之后,因为这个阶段是并发的,此时用户线程让B不再引用C,所以C对象被标记为白色,即垃圾对象。

但在C对象处理完之后,用户线程又让A对象引用了C,但C已经被标记为白色了,之后会被回收掉。因此需要在Remark阶段进行进一步检查。

解决办法是,当对象引用发生改变时,JVM就会给它加入一个写栅栏post-write barrier。当对象引用发生改变之后,写栅栏就会执行。写栅栏会将C放入一个 队列satb_mark_queue 中,并把C标记为灰色。

Remark 阶段会触发STW,让其他用户线程都暂停,这时候Remark的线程就会对队列中的对象进行依次检查,检查到有强引用在引用C,因此将C标记为黑色。

这样便解决了并发标记阶段对象引用改变的问题。


4.4.8 字符串去重

在 JDK 8u20版本实现了字符串去重功能

  • 优点:节省大量内存
  • 缺点:略微多占用了 cpu 时间,新生代回收时间略微增加

-XX:+UseStringDeduplication

String s1 = new String("hello"); //char[]{'h', 'e', 'l', 'l', 'o'}
String s2 = new String("hello"); //char[]{'h', 'e', 'l', 'l', 'o'}
  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时,G1 并发检查是否有字符串重复
  • 如果它们值一样,让它们引用同一个 char[]
  • 注意,与String.intern() 不一样
    • String.intern() 关注的是字符串对象,让字符串对象本身不重复,使用String Table来去重
    • 而字符串去重关注的是 char[]
    • 在 JVM内部,(String.intern()和G1的去重)使用了不同的字符串表

4.4.9 并发标记类卸载
在JDK 8u40版本实现了类卸载功能增强
所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类
-XX:+ClassUnloadingWithConcurrentMark 默认启用

4.4.10 回收巨型对象
  • 一个对象大于Region的一半时,称之为巨型对象
  • G1 不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉

当一个巨型对象没有被任何引用指向时,会被立刻认定为垃圾对象,并在下一次新生代垃圾回收中进行回收。


4.4.11 并发标记起始时间调整
  • 并发标记必须在堆内存占满前完成,否则退化为 Full GC
  • JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent
  • JDK 9 可以动态调整
    • -XX:InitiatingHeapOccupancyPercent 用来设置初始值
    • 进行数据采样并动态调整
    • 总会添加一个安全的空挡空间
    • 让这个堆的空闲空间总够的大,容纳那些浮动的垃圾,这样就可以尽可能的避免并发垃圾回收退化成 Full GC 的垃圾回收了。

5.垃圾回收调优


类加载与字节码技术

字节码指令

原始Java代码

public class Clazz {
    public static void main(String[] args) {
        int a = 10;
        int b = Short.MAX_VALUE + 1;
        int c = a + b;
        System.out.println(c);
    }
}

1.Javap工具

Oracle 提供了 javap 工具来反编译 class 文件
分为两步,先使用命令:
javac Clazz.java

将Java文件编译成class文件,然后使用命令:

javap -v Clazz.class

即可得到编译后的字节码文件

Classfile /E:/java/projectall/jvm/GC/src/cn/itcast/Clazz.class
  Last modified 2023-9-26; size 438 bytes
  MD5 checksum b9592a68c0d2391edfbcea0c838b7a2f
  Compiled from "Clazz.java"
public class cn.itcast.Clazz
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#16         // java/lang/Object."<init>":()V
   #2 = Class              #17            // java/lang/Short
   #3 = Integer            32768
   #4 = Fieldref           #18.#19        // java/lang/System.out:Ljava/io/PrintStream;
   #5 = Methodref          #20.#21        // java/io/PrintStream.println:(I)V
   #6 = Class              #22            // cn/itcast/Clazz
   #7 = Class              #23            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               SourceFile
  #15 = Utf8               Clazz.java
  #16 = NameAndType        #8:#9          // "<init>":()V
  #17 = Utf8               java/lang/Short
  #18 = Class              #24            // java/lang/System
  #19 = NameAndType        #25:#26        // out:Ljava/io/PrintStream;
  #20 = Class              #27            // java/io/PrintStream
  #21 = NameAndType        #28:#29        // println:(I)V
  #22 = Utf8               cn/itcast/Clazz
  #23 = Utf8               java/lang/Object
  #24 = Utf8               java/lang/System
  #25 = Utf8               out
  #26 = Utf8               Ljava/io/PrintStream;
  #27 = Utf8               java/io/PrintStream
  #28 = Utf8               println
  #29 = Utf8               (I)V
{
  public cn.itcast.Clazz();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        10
         2: istore_1
         3: ldc           #3                  // int 32768
         5: istore_2
         6: iload_1
         7: iload_2
         8: iadd
         9: istore_3
        10: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: iload_3
        14: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
        17: return
      LineNumberTable:
        line 5: 0
        line 6: 3
        line 7: 6
        line 8: 10
        line 9: 17
}
SourceFile: "Clazz.java"

2.图解方法执行流程

2.1 常量池载入运行时常量池

当一段Java代码开始执行时,JVM中的类加载器会将编译后的字节码文件加载到内存中,其中常量池中的数据会放入运行时常量池。

一般如果一个数据的大小没有超过Short的最大值,那么它并不会存入常量池,而是跟着方法的字节码指令存在一起。而如果超过了Short型的最大值大小,那么便会存入常量池中。


 2.2 方法字节码载入方法区


2.3 main线程开始运行,分配栈帧内存 

(stack=2, locals=4)

栈帧中左边为 局部变量表 ,右边为 操作数栈 。在main的字节码中,stack 表示操作数栈的深度, locals 表示局部变量表的长度,main方法一个栈,print方法一个栈。


2.4 执行引擎开始执行字节码

bipush 10

  • 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
  • sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
  • ldc 将一个 int 压入操作数栈
  • ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 8 个字节)
  • 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池

istore 1

  • 将操作数栈顶数据弹出,存入局部变量表的 slot 1

执行完这两条指令之后,a便被赋值为10

ldc #3
  • 从常量池加载 #3 数据到操作数栈
  • 注意 Short.MAX_VALUE 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算 好的

istore_2

  • 将栈顶元素弹出,放入局部变量表中的 slot 2

iload_1

  • 读取局部变量表中的slot 1,放入操作数栈中

iload_2

iadd

  • 执行加法运算,弹出两个变量,并把结果存入 操作数栈 中

istore_3

getstatic #4

  • 从常量池中读取 #4 数据到操作数栈

iload_3

invokevirtual #5

  • 找到常量池 #5
  • 定位到方法区 java/io/PrintStream.println:(I)V 方法
  • 生成新的栈帧(分配 localsstack等)
  • 传递参数,执行新栈帧中的字节码

  • 执行完毕,弹出栈帧
  • 清除 main 操作数栈内容

return

  • 完成 main 方法调用,弹出 main 栈帧
  • 程序结束

2.5 练习-分析a++

源码:

public class demo1 {
    public static void main(String[] args) {
        int a = 10;
        int b = a++ + ++a + a--;
        System.out.println(a);
        System.out.println(b);
    }
}

字节码:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: bipush        10
         2: istore_1
         3: iload_1
         4: iinc          1, 1
         7: iinc          1, 1
        10: iload_1
        11: iadd
        28: iload_2
        29: invokevirtual #3        // Method java/io/PrintStream.println:(I)V
        32: return
      LineNumberTable:
        line 5: 0
        line 6: 3
        line 7: 18
        line 8: 25
        line 9: 32

分析:

  • 注意 iinc 指令是直接在局部变量 slot 上进行运算
  • a++ ++a 的区别是先执行 iload 还是 先执行 iinc

 指令iinc有两个参数,第一个参数表示槽位(slot)1,第二个参数表示加1

至此,a++已执行完毕 


开始执行 ++a

  • 可以看出,a++是先iload,在iinc
  • 而++a是先iinc,再iload

 


3. 字节码指令:条件与循环

3.1 条件判断指令

  • byteshortchar 都会按 int 比较,因为操作数栈都是 4 字节
  • goto 用来进行跳转到指定行号的字节码

源码:

public class Demo3_3 {
    public static void main(String[] args) {
        int a = 0;
        if(a == 0) {
            a = 10;
        } else {
            a = 20;
        }
    }
}

字节码:

0: iconst_0
1: istore_1
2: iload_1
3: ifne 12
6: bipush 10
8: istore_1
9: goto 15
12: bipush 20
14: istore_1
15: return

3.2 循环控制指令

while:

public class Demo3_4 {
    public static void main(String[] args) {
        int a = 0;
        while (a < 10) {
            a++;
        }
    }
}

字节码:

0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

do while:

public class Demo3_5 {
    public static void main(String[] args) {
        int a = 0;
        do {
            a++;
        } while (a < 10);
    }
}

字节码:

 0: iconst_0
 1: istore_1
 2: iinc 1, 1
 5: iload_1
 6: bipush 10
 8: if_icmplt 2
11: return

 for:

public class Demo3_6 {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
        }
    }
}

字节码:

0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

此处通过比较发现,while与for的字节码是一样的。


4. 构造方法

4.1 <cinit>()V

源码:

public class Demo3_8_1 {
    static int i = 10;
    static {
        i = 20;
    }
    static {
        i = 30;
    }
}
编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法 <cinit>()V
0: bipush 10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return
<cinit>()V 方法会在类加载的初始化阶段被调用

4.2 <init>v

源码:

public class Demo3_8_2 {
    private String a = "s1";
    {
        b = 20;
    }
        private int b = 10;
    {
        a = "s2";
    }
    
    public Demo3_8_2(String a, int b) {
        this.a = a;
        this.b = b;
    }
    public static void main(String[] args) {
        Demo3_8_2 d = new Demo3_8_2("s3", 30);
        System.out.println(d.a);
        System.out.println(d.b);
    }
}

 编译器会按从上至下的顺序,收集所有 {} 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后

public cn.itcast.jvm.t3.bytecode.Demo3_8_2(java.lang.String, int);
descriptor: (Ljava/lang/String;I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: aload_0
1: invokespecial #1 // super.<init>()V
4: aload_0            
5: ldc #2           // <- "s1"
7: putfield #3      // -> this.a
10: aload_0
11: bipush 20       // <- 20
13: putfield #4     // -> this.b
16: aload_0
17: bipush 10       // <- 10
19: putfield #4     // -> this.b
22: aload_0
23: ldc #5          // <- "s2"
25: putfield #3     // -> this.a
28: aload_0         // ------------------------------
29: aload_1         // <- slot 1(a) "s3" |
30: putfield #3     // -> this.a |
33: aload_0 |
34: iload_2         // <- slot 2(b) 30 |
35: putfield #4     // -> this.b --------------------
38: return
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
0 39 0 this Lcn/itcast/jvm/t3/bytecode/Demo3_8_2;
0 39 1 a Ljava/lang/String;
0 39 2 b I
MethodParameters: ...

5. 方法调用

被不同关键字修饰的方法,如private,public,final,static等,字节码指令有何区别?

public class Demo3_9 {
    public Demo3_9() { }

    private void test1() { }

    private final void test2() { }
    
    public void test3() { }

    public static void test4() { }

    public static void main(String[] args) {
        Demo3_9 d = new Demo3_9();
            d.test1();
            d.test2();
            d.test3();
            d.test4();
            Demo3_9.test4();
        }
}

字节码:

0: new #2           // class cn/itcast/jvm/t3/bytecode/Demo3_9
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return
  • new 是创建【对象】,给对象分配堆内存,执行成功会将【对象引用】压入操作数栈
  • dup 是赋值操作数栈栈顶的内容,本例即为【对象引用】,为什么需要两份引用呢,一个是要配 invokespecial 调用该对象的构造方法 "<init>":()V (会消耗掉栈顶一个引用),另一个要配合 astore_1 赋值给局部变量
  • 最终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静态绑定
  • 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态
  • 成员方法与静态方法调用的另一个区别是,执行方法前是否需要【对象引用】
  • 比较有意思的是 d.test4(); 是通过【对象引用】调用一个静态方法,可以看到在调用invokestatic 之前执行了 pop 指令,把【对象引用】从操作数栈弹掉了
  • 还有一个执行 invokespecial 的情况是通过 super 调用父类方法

6. 异常处理

6.1 try-catch

源码:

public class Demo3_11_1 {
    public static void main(String[] args) {
        int i = 0;
        try {
            i = 10;
        } catch (Exception e) {
            i = 20;
        }
    }
}

字节码(省略了部分内容):

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0 //-1 到 5 范围内的数可使用iconst指令
1: istore_1
2: bipush 10
4: istore_1
5: goto 12
8: astore_2
9: bipush 20
11: istore_1
12: return
Exception table:
from to target type
2 5 8 Class java/lang/Exception
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/Exception;
0 13 0 args [Ljava/lang/String;
2 11 1 i I
StackMapTable: ...
MethodParameters: ...
}
  • 可以看到多出来一个 Exception table 的结构,[from, to) 是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
  • 8 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 slot 2 位置
  • -1 到 5 范围内的数可使用iconst指令

6.2 多single-catch
public class Demo3_11_2 {
    public static void main(String[] args) {
        int i = 0;
        try {
            i = 10;
        } catch (ArithmeticException e) {
            i = 30;
        } catch (NullPointerException e) {
            i = 40;
        } catch (Exception e) {
            i = 50;
        }
    }
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 26

8: astore_2   //catch1
9: bipush 30
11: istore_1
12: goto 26

15: astore_2  //catch2
16: bipush 40
18: istore_1
19: goto 26

22: astore_2  //catch3
23: bipush 50
25: istore_1

26: return
Exception table:
from to target type
2 5 8 Class java/lang/ArithmeticException
2 5 15 Class java/lang/NullPointerException
2 5 22 Class java/lang/Exception
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/ArithmeticException;
16 3 2 e Ljava/lang/NullPointerException;
23 3 2 e Ljava/lang/Exception;
0 27 0 args [Ljava/lang/String;
2 25 1 i I
StackMapTable: ...
MethodParameters: ...
  • 因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用

6.3 multi-catch
public class Demo3_11_3 {
    public static void main(String[] args) {
        try {
            Method test = Demo3_11_3.class.getMethod("test");
            test.invoke(null);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
        }
    }
    public static void test() {
        System.out.println("ok");
    }
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: ldc #2
2: ldc #3
4: iconst_0
5: anewarray #4
8: invokevirtual #5
11: astore_1
12: aload_1
13: aconst_null
14: iconst_0
15: anewarray #6
18: invokevirtual #7
21: pop
22: goto 30
25: astore_1
26: aload_1
27: invokevirtual #11 // e.printStackTrace:()V
30: return
Exception table:
from to target type
0 22 25 Class java/lang/NoSuchMethodException
0 22 25 Class java/lang/IllegalAccessException
0 22 25 Class java/lang/reflect/InvocationTargetException
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
12 10 1 test Ljava/lang/reflect/Method;
26 4 1 e Ljava/lang/ReflectiveOperationException;
0 31 0 args [Ljava/lang/String;
StackMapTable: ...
MethodParameters: ...


6.4 finally
public class Demo3_11_4 {
    public static void main(String[] args) {
        int i = 0;
        try {
            i = 10;
        } catch (Exception e) {
            i = 20;
        } finally {
            i = 30;
        }
    }
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: iconst_0
1: istore_1      // 0 -> i
2: bipush 10     // try --------------------------------------
4: istore_1      // 10 -> i |
5: bipush 30     // finally |
7: istore_1      // 30 -> i |
8: goto 27       // return -----------------------------------
11: astore_2     // catch Exceptin -> e ----------------------
12: bipush 20    // |
14: istore_1     // 20 -> i |
15: bipush 30    // finally |
17: istore_1     // 30 -> i |
18: goto 27      // return -----------------------------------
21: astore_3     // catch any -> slot 3 ----------------------
22: bipush 30    // finally |
24: istore_1     // 30 -> i |
25: aload_3      // <- slot 3 |
26: athrow       // throw ------------------------------------
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any   // 剩余的异常类型,比如 Error
11 15 21 any // 剩余的异常类型,比如 Error
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
12 3 2 e Ljava/lang/Exception;
0 28 0 args [Ljava/lang/String;
2 26 1 i I
StackMapTable: ...
MethodParameters: ...

实际上,finally中的代码被复制了好几份,分别放入try流程和catch流程以及catch剩余的异常类型处理流程,以确保finally中的代码一定被执行。


6.5 面试题 

下面这段代码的输出结果是什么?

public class Demo3_12_2 {
    public static void main(String[] args) {
        int result = test();
        System.out.println(result);
    }
    public static int test() {
        try {
            return 10;
        } finally {
            return 20;
        }
    }
}

根据字节码来分析:

public static int test();
descriptor: ()I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=0
0: bipush 10     // <- 10 放入栈顶
2: istore_0      // 10 -> slot 0 (从栈顶移除了)
3: bipush 20     // <- 20 放入栈顶
5: ireturn       // 返回栈顶 int(20)
6: astore_1      // catch any -> slot 1
7: bipush 20     // <- 20 放入栈顶
9: ireturn       // 返回栈顶 int(20)
Exception table:
from to target type
0 3 6 any
LineNumberTable: ...
StackMapTable: ...
  • 3,5行和7,9行都是finally中的内容。finally中的内容被插入了所有流程以确保一定能被执行。在这段代码中,最终返回的都是栈顶的20,而不是存入slot 0中的10。
  • 第2行的目的是
  • 这段字节码中没有 athrow 语句,说明finally中的return语句会造成异常抛出丢失。

 面试题2:

public class Demo3_12_2 {
    public static void main(String[] args) {
        int result = test();
        System.out.println(result);
    }
    public static int test() {
        int i = 10;
        try {
            return i;
        } finally {
            i = 20;
        }
    }
}
public static int test();
descriptor: ()I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=0
0: bipush 10  // <- 10 放入栈顶
2: istore_0   // 10 -> i
3: iload_0    // <- i(10)
4: istore_1   // 10 -> slot 1,暂存至 slot 1,目的是为了固定返回值
5: bipush 20  // <- 20 放入栈顶
7: istore_0   // 20 -> i
8: iload_1    // <- slot 1(10) 载入 slot 1 暂存的值
9: ireturn    // 返回栈顶的 int(10)
10: astore_2
11: bipush 20
13: istore_0
14: aload_2
15: athrow
Exception table:
from to target type
3 5 10 any
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I

从字节码中可以看出,3,4行将 10 存入了slot_1,目的是为了巩固返回值。虽然之后finally中的语句将 i 修改为了20,但是8,9行又将之前存入slot_1的值覆盖了20,即最终返回的还是10.


6.6 synchronized  

synchronized 是Java中的关键字,用于实现多线程同步,确保多个线程之间按照一定的顺序访问共享资源,以避免竞态条件和数据不一致性问题。synchronized 实现了互斥锁(Mutex),确保同一时间只有一个线程能够进入被同步的代码块或方法,其他线程需要等待直到锁被释放。这种机制有效地避免了多线程竞争条件下的数据访问问题。

但是,如果在 synchronized 加完锁后,程序出现了异常,那么这个被加锁的对象还会被解锁吗?由谁解锁?

public class Demo3_13 {
    public static void main(String[] args) {
        Object lock = new Object();
        synchronized (lock) {
            System.out.println("ok");
        }
    }
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #2            // new Object
3: dup
4: invokespecial #1  // invokespecial <init>:()V
7: astore_1          // lock引用 -> lock
8: aload_1           // <- lock (synchronized开始)
9: dup
10: astore_2         // lock引用 -> slot 2
11: monitorenter     // monitorenter(lock引用)
12: getstatic #3     // <- System.out
15: ldc #4           // <- "ok"
17: invokevirtual #5 // invokevirtual println:
(Ljava/lang/String;)V
20: aload_2          // <- slot 2(lock引用)
21: monitorexit      // monitorexit(lock引用)
22: goto 30
25: astore_3         // any -> slot 3
26: aload_2          // <- slot 2(lock引用)
27: monitorexit      // monitorexit(lock引用)
28: aload_3
29: athrow
30: return
Exception table:
from to target type
12 22 25 any
25 28 25 any
LineNumberTable: ...
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;
StackMapTable: ...
MethodParameters: ...
  • 从字节码中可以看出,虽然没有使用try-catch语句,但还是出现了Exception-table.而从25到27行的代码便会在程序发生异常之后自动解锁。
  • 注意:方法级别的 synchronized 不会在字节码指令中有所体现

编译器处理

"语法糖"(Syntactic Sugar)是编程语言中一种用于增强代码可读性和编写便捷性的语法特性。这些特性不会引入新的功能,而只是使代码更加清晰易懂,更符合人类的思维方式。语法糖的存在让程序员能够更方便地编写代码,但其实现仍依赖于底层的语言机制。

1.默认构造器

public class Candy1 {
}

编译成字节码后: 

public class Candy1 {
    // 这个无参构造是编译器帮助我们加上的
    public Candy1() {
        super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
    }
}

2.自动拆装箱 

在jdk5以前,Java是不支持自动拆装箱的,以下几行代码必须改写第二种形式才能运行。

public class Candy2 {
    public static void main(String[] args) {
        Integer x = 1;
        int y = x;
    }
}

对于包装类型,必须手动拆箱和装箱。

public class Candy2 {
    public static void main(String[] args) {
        Integer x = Integer.valueOf(1);
        int y = x.intValue();
    }
}

好在JDK5以后,自动拆装箱都由编译器在编译阶段完成。


3.泛型集合取值

泛型也是在 JDK 5 开始加入的特性,但 java 在编译泛型代码后会执行 泛型擦除 的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 Object 类型来处理:
public class Candy3 {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        list.add(10); // 实际调用的是 List.add(Object e)
        Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
    }
}

所以在编译器生成的字节码中,还需要做一个额外的类型转换的操作。

// 需要将 Object 转为 Integer
Integer x = (Integer)list.get(0);

如果 x 变量不是Integer类型而是int类型,那么最终生成的字节码是:

// 需要将 Object 转为 Integer, 并执行拆箱操作
int x = ((Integer)list.get(0)).intValue();

虽然字节码上的泛型信息被擦除了,但是 LocalVariableTypeTable 中仍然保留了方法参数泛型的信息。

LocalVariableTable:
Start Length Slot Name Signature
    0     32    0 args [Ljava/lang/String;
    8     24    1 list Ljava/util/List;
LocalVariableTypeTable:
Start Length Slot Name Signature
    8     24    1 list Ljava/util/List<Ljava/lang/Integer;>;

因此,仍然可以通过反射来获取这些信息:

Method test = Candy3.class.getMethod("test", List.class, Map.class);
Type[] types = test.getGenericParameterTypes();
for (Type type : types) {
    if (type instanceof ParameterizedType) {
        ParameterizedType parameterizedType = (ParameterizedType) type;
        System.out.println("原始类型 - " + parameterizedType.getRawType());
        Type[] arguments = parameterizedType.getActualTypeArguments();
        for (int i = 0; i < arguments.length; i++) {
            System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
        }
    }
}

输出:

原始类型 - interface java.util.List
泛型参数[0] - class java.lang.String
原始类型 - interface java.util.Map
泛型参数[0] - class java.lang.Integer
泛型参数[1] - class java.lang.Object

4.可变参数

可变参数是 JDK5 加入的新特性

public class Candy4 {
    public static void foo(String... args) {
        String[] array = args; // 直接赋值
        System.out.println(array);
    }
    public static void main(String[] args) {
        foo("hello", "world");
    }
}

其中的可变参数 String... args 实际上就是一个String[] args, 从代码中下一行的赋值语句就可以看出来。Java编译器会在编译期间将上述代码转换为:

public class Candy4 {
    public static void foo(String[] args) {
        String[] array = args; // 直接赋值
        System.out.println(array);
    }
    public static void main(String[] args) {
        foo(new String[]{"hello", "world"});
    }
}
注意:如果调用了 无参构造foo() 则等价代码为 foo(new String[]{}) ,创建了一个空的数组,而不会传递null 进去

5. foreach循环

JDK5 开始引入的语法糖:

public class Candy5_1 {
    public static void main(String[] args) {
        int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖
        for (int e : array) {
            System.out.println(e);
        }
    }
}

会被编译器转换为:

public class Candy5_1 {
    public Candy5_1() {
    }
    public static void main(String[] args) {
        int[] array = new int[]{1, 2, 3, 4, 5};
        for(int i = 0; i < array.length; ++i) {
            int e = array[i];
            System.out.println(e);
        }
    }
}

而集合的循环:

public class Candy5_2 {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1,2,3,4,5);
        for (Integer i : list) {
            System.out.println(i);
        }
    }
}

实际上会被编译器转换为对迭代器的调用:

public class Candy5_2 {
    public Candy5_2() {
    }
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
        Iterator iter = list.iterator();
        while(iter.hasNext()) {
            Integer e = (Integer)iter.next();
            System.out.println(e);
        }
    }
}

 注意:foreach 循环写法,能够配合数组,以及所有实现了 Iterable 接口的集合类一起使用,其中Iterable 用来获取集合的迭代器( Iterator


6. switch 字符串

从 JDK7 开始,switch可以作用于字符串和枚举类,这个功能也是语法糖。

public class Candy6_1 {
    public static void choose(String str) {
        switch (str) {
            case "hello": {
                System.out.println("h");
                break;
            }
            case "world": {
                System.out.println("w");
                break;
            }
        }
    }
}

会被编译器转换为:

public class Candy6_1 {
    public Candy6_1() {
    }
    public static void choose(String str) {
        byte x = -1;
        switch(str.hashCode()) {
            case 99162322: // hello 的 hashCode
                if (str.equals("hello")) {
                    x = 0;
                }
                break;
            case 113318802: // world 的 hashCode
                if (str.equals("world")) {
                    x = 1;
                }
            }
        switch(x) {
            case 0:
                System.out.println("h");
                break;
            case 1:
                System.out.println("w");
        }
    }
}
  • 字符串的hashcode在编译阶段就会被算出,因此先使用hashcode进行比较效率会高一些。
  • 而为什么还要使用效率较低的equals方法再比较一遍,则是为了防止hashcode冲突

如,"BM" 与 "C." 的hashcode都是 2123 

public class Candy6_2 {
    public static void choose(String str) {
        switch (str) {
            case "BM": {
                System.out.println("h");
                break;
            }
            case "C.": {
                System.out.println("w");
                break;
            }
        }
    }
}

则会被编译器转换为:

public class Candy6_2 {
    public Candy6_2() {
    }

    public static void choose(String str) {
        byte x = -1;
        switch(str.hashCode()) {
            case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
                if (str.equals("C.")) {
                    x = 1;
                } else if (str.equals("BM")) {
                    x = 0;
                }
            default:
                switch(x) {
                    case 0:
                        System.out.println("h");
                        break;
                    case 1:
                        System.out.println("w");
                }
        }
    }
}

没有break语句,执行完 case:2123 后,会继续执行 default中 的内容


7. switch 枚举

原始代码:

enum Sex {
    MALE, FEMALE
}

public class Candy7 {
    public static void foo(Sex sex) {
        switch (sex) {
            case MALE:
                System.out.println("男"); break;
            case FEMALE:
                System.out.println("女"); break;
        }
    }
}

转换后代码:

public class Candy7 {
    /**
    * 定义一个合成类(仅 jvm 使用,对我们不可见)
    * 用来映射枚举的 ordinal 与数组元素的关系
    * 枚举的 ordinal 表示枚举对象的序号,从 0 开始
    * 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
    */
    static class $MAP {
    // 数组大小即为枚举元素个数,里面存储case用来对比的数字
        static int[] map = new int[2];
        static {
            map[Sex.MALE.ordinal()] = 1;
            map[Sex.FEMALE.ordinal()] = 2;
        }
    }

    public static void foo(Sex sex) {
        int x = $MAP.map[sex.ordinal()];
        switch (x) {
            case 1:
                System.out.println("男");
                break;
            case 2:
                System.out.println("女");
                break;
        }
    }
}

底层实现其实就是将switch要比较的枚举常量转换成了与之对应的整数值。


 8. 枚举类

JDK7 新增了枚举类

enum Sex {
    MALE, FEMALE
}

转化后代码:

public final class Sex extends Enum<Sex> {
    public static final Sex MALE;
    public static final Sex FEMALE;
    private static final Sex[] $VALUES;

    static {
        MALE = new Sex("MALE", 0);
        FEMALE = new Sex("FEMALE", 1);
        $VALUES = new Sex[]{MALE, FEMALE};
    }

    /**
    * Sole constructor. Programmers cannot invoke this constructor.
    * It is for use by code emitted by the compiler in response to
    * enum type declarations.
    *
    * @param name - The name of this enum constant, which is the identifier
    * used to declare it.
    * @param ordinal - The ordinal of this enumeration constant (its position
    * in the enum declaration, where the initial constant is
    assigned
    */

    private Sex(String name, int ordinal) {
        super(name, ordinal);
    }

    public static Sex[] values() {
        return $VALUES.clone();
    }

    public static Sex valueOf(String name) {
        return Enum.valueOf(Sex.class, name);
    }
}

枚举类不像其他类一样可以拥有无数个实例对象,它的实例对象是有限个的。


9. try-with-resources

JDK7 新增了对需要关闭的资源处理的特殊语法 try-with-resources:

try(资源变量 = 创建资源对象){

} catch( ) {

}

这样就无需再手动去释放资源。

其中资源对象需要实现 AutoCloseable 接口,例如 InputStream OutputStream
Connection Statement ResultSet 等接口都实现了 AutoCloseable ,使用 try-with-
resources 可以不用写 finally 语句块,编译器会帮助生成关闭资源代码
public class Candy9 {
    public static void main(String[] args) {
        try(InputStream is = new FileInputStream("d:\\1.txt")) {
            System.out.println(is);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

转换后代码:

public class Candy9 {
    public Candy9() {
    }

    public static void main(String[] args) {
        try {
            InputStream is = new FileInputStream("d:\\1.txt");
            Throwable t = null;
            try {
                System.out.println(is);
            } catch (Throwable e1) {
                // t 是我们代码出现的异常
                t = e1;
                throw e1;
            } finally {
                // 判断了资源不为空
                if (is != null) {
                    // 如果我们代码有异常
                    if (t != null) {
                        try {
                            is.close();
                        } catch (Throwable e2) {
                            // 如果 close 出现异常,作为被压制异常添加
                            t.addSuppressed(e2);
                        }
                    } else {
                        // 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e
                        is.close();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这里使用addSuppressed(Throwable e) (添加被压制异常)的目的是为了防止异常信息的丢失。

public class Test6 {
    public static void main(String[] args) {
        try (MyResource resource = new MyResource()) {
            int i = 1/0;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
class MyResource implements AutoCloseable {
    public void close() throws Exception {
        throw new Exception("close 异常");
    }
}

输出:

java.lang.ArithmeticException: / by zero
at test.Test6.main(Test6.java:7)
Suppressed: java.lang.Exception: close 异常
at test.MyResource.close(Test6.java:18)
at test.Test6.main(Test6.java:6)

可见两个异常都被抛出了。


10. 方法重写时的桥接方法

方法重写时对返回值分两种情况:

  • 父子类的返回值完全一致
  • 子类返回值可以是父类返回值的子类
class A {
    public Number m() {
    return 1;
    }
}

class B extends A {
    @Override
    // 子类 m 方法的返回值是 Integer 是父类 m 方法返回值 Number 的子类
    public Integer m() {
        return 2;
    }
}

对此,Java编译器会做如下处理:

class B extends A {
    public Integer m() {
        return 2;
    }

    // 此方法才是真正重写了父类 public Number m() 方法
    public synthetic bridge Number m() {
        // 调用 public Integer m()
        return m();
    }
}
  • 实际上就是在原来的方法外面再套了一层,然后在这个方法内部调用原来的方法。
  • 这个桥接方法比较特殊,仅对 java 虚拟机可见,并且与原来的 public Integer m() 没有命名冲突,可以用下面的反射代码来验证
for (Method m : B.class.getDeclaredMethods()) {
    System.out.println(m);
}

输出:

public java.lang.Integer test.candy.B.m()
public java.lang.Number test.candy.B.m()

11. 匿名内部类

源代码:

public class Candy11 {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("ok");
            }
        };
    }
}

转换后代码:

// 额外生成的类
final class Candy11$1 implements Runnable {
    Candy11$1() {
    }

    public void run() {
        System.out.println("ok");
    }
}
public class Candy11 {
    public static void main(String[] args) {
        Runnable runnable = new Candy11$1();
    }
}

编译器会自动帮我们生成一个类,然后实际上new的是这个类的实例对象

引用局部变量的匿名内部类:

public class Candy11 {
    public static void test(final int x) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("ok:" + x);
            }
        };
    }
}

转换后代码:

// 额外生成的类
final class Candy11$1 implements Runnable {
    int val$x;
    Candy11$1(int x) {
        this.val$x = x;
    }
    public void run() {
        System.out.println("ok:" + this.val$x);
    }
}
public class Candy11 {
    public static void test(final int x) {
        Runnable runnable = new Candy11$1(x);
    }
}
这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:因为在创建
Candy11$1 对象时,将 x 的值赋值给了 Candy11$1 对象的 val$x 属性,所以 x 不应该再发生变化了,如果变化,那么 val$x 属性没有机会再跟着一起变化。

评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值