Java热更新失败常见原因总结

编程入门 行业动态 更新时间:2024-10-26 05:31:05

Java热更新失败<a href=https://www.elefans.com/category/jswz/34/1770088.html style=常见原因总结"/>

Java热更新失败常见原因总结

目录

  • 前言
  • 基础知识
  • 常见失败原因
    • Stream中新增filter()
    • 增加Lambda表达式
      • 反汇编利器——javap
      • 小试牛刀
      • 一探究竟
    • 外部类使用内部类的private字段或方法
  • 总结

前言

热更新是Java开发者经常需要考虑的一个问题,无论是游戏还是互联网应用,都需要尽量做到运行时代码修复,以避免重启给用户体验带来的负面影响。目前主流的热更新方案是基于Java的Attach和Instrumentation API。热更新时需要满足不改变方法签名或者类的字段。在普通情况下我们比较容易通过diff看出是否有上述改动,但是在一些特殊情况下失败原因却藏得很深。本文就是通过总结这些特殊情况,避免大家踩坑。

基础知识

无论哪种热更新方案都离不开Java本身的底层支持。Java提供了JVMTI(Java Virtual Machine Tool Interface),作为底层工具来支持对Java程序做调试和监控。目前主流的热更新方案就是基于其中的Attach和Instrumentation API。这两个功能在Java 6中引入,Attach提供了从外部连接到JVM并执行代码的功能,而Instrumentaion能够在运行时改变类的运行逻辑。

下面一起看下实现热更新的具体逻辑:

此处主要是让大家对热更新的流程有一个直观的认识,因此对于细节不做过多展开,需要代码实现的读者可自行搜索相关文章。

  1. 从外部连接到Java进程上:调用VirutalMachine.attach(pid)。
  2. 加载代理jar包:调用loadAgent(jar, agentArgs),注意此处必须是jar包形式而不能是class文件。
  3. 调用agentmain方法:代理类中需包含agentmain方法,该方法会作为代理类的入口方法,在连接到对象JVM后立即执行。
  4. 这里有两种实现热更新方法:
    4.1 调用Instrumentation类的redefineClasses()方法:该方法可用于重定义一个类的实现,它是通过从流读取的形式来更新,因此可以将新的class文件加载到字节流,以实现字节码在类加载器中的替换。
    4.2 调用Transformer类的retransformClasses()方法:Transformer也叫拦截器,它可以自定义拦截行为,来实现字节码的增强或替换。触发时机是在类加载时或者调用retransformClasses()主动触发。

通过Transformer类实现热更新的具体流程是:

  1. 实现自己的Transformer类:在transform()方法中自定义处理逻辑,比如ASM会在其中加入字节码增强的逻辑,当然你也可以加入热更新的逻辑,替换原始的字节码。
  2. 调用addTransformer()方法添加拦截器。
  3. 调用retransformClasses()主动触发拦截器。

通过这种方法实现热更新需满足条件限制。如存在以下情况其中之一,则热更新会失败:

  1. 修改了方法签名:包括增减方法,或者是修改方法的参数列表,也就是说修改只能来自于方法内部。
  2. 修改了类中字段:包括增减类中字段,或者修改字段类型。

当热更新失败时,会看到类似如下的异常:

java.lang.UnsupportedOperationException: class redefinition failed: attempted to add a method

常见失败原因

通常我们都能通过diff前后版本判断热更新能否成功,但是在一些特殊情况下失败原因却藏得很深。它们本质上都是违反了上述限制,只不过因为代码嵌套或者编译器黑魔法的关系,使了个障眼法让我们疏忽。借此机会,我们正好也可以学习一些Java编译相关的底层知识。Let’s go!

Stream中新增filter()

在业务逻辑中使用Stream和Lambda表达式可以让代码更加精简并提升可读性。用起来很爽,可要热更新时却经常会失败,这时就傻眼了。一种典型的情况是,在Stream序列中我们需要添加一个filter()。失败的原因是方法的底层实际上是增加了一个匿名内部类。

    @Overridepublic final Stream<P_OUT> filter(Predicate<? super P_OUT> predicate) {Objects.requireNonNull(predicate);return new StatelessOp<P_OUT, P_OUT>(this, StreamShape.REFERENCE,StreamOpFlag.NOT_SIZED) {@OverrideSink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {@Overridepublic void begin(long size) {downstream.begin(-1);}@Overridepublic void accept(P_OUT u) {if (predicate.test(u))downstream.accept(u);}};}};}

从源码中看出,filter()方法底层增加了一个 StatelessOp类型的匿名内部类。这个新的类显示没有办法被动态加载,那调用它的外部类理所当然也会热更新失败了。
事实上,不只filter(),Stream提供的大部分操作方法底层都会涉及匿名内部类的添加,因此想通过热更新给Stream流添加一个新的处理操作是非常不可靠的。不过Stream毕竟只是一个语法糖,我们总可以找到另外的实现途径,用普通遍历和条件判断实现同样的效果。

增加Lambda表达式

当我们满怀希望地试图通过热更新增加一个Lambda表达式时,总会被现实无情地泼一桶冷水。咋一看百思不得其解,搞不清哪里违反了热更新条件。这时候我们就需要祭出一大利器了——javap!

反汇编利器——javap

Oracle官网上对javap的介绍是JDK自带的反汇编工具,实际上它也有反编译的功能。
反汇编、反编译是容易搞混的两个概念,为此我制作了下面的图以便详细说明:

  1. 反编译:是相对于编译的反操作,是将字节码(class文件)重新转成源代码(Java代码)。
  2. 反汇编:是指把机器码或字节码转换成人类可读的形式。机器码和字节码有个共同点,就是都不是人类可读的,而反汇编可以将其转换成基础的操作指令序列。不同于在具体平台执行的机器码,Java的字节码可被视作一种虚拟的中间状态的机器码,JVM通过提供一个公共的字节码指令集合,屏蔽了不同平台的差异性。javap提供的反汇编功能,就是指把不可读的字节码转换成可读的字节码指令序列。我们可以借此一窥Java在编译过程中的奥秘。

小试牛刀

我们通过一个例子来展示javap的用法。
先编写如下的一个类:

public class LambdaTest {private void func() {}public static void main(String args[]) {new LambdaTest().func();}}

javap 默认的是只展示非private的属性和方法,因此为了显示private方法,我们要加上选项 -p:

// javap -p LambdaTest.class 
public class leetcode.LambdaTest {public leetcode.LambdaTest();private void func();public static void main(java.lang.String[]);
}

以上是反编译的内容,如果需要展示反汇编的内容,我们需要加上-c,这样就可以看到每个方法内部具体调用的指令集:

// javap -c -p LambdaTest.class
public class leetcode.LambdaTest {public leetcode.LambdaTest();Code:0: aload_01: invokespecial #8                  // Method java/lang/Object."<init>":()V4: returnprivate void func();Code:0: returnpublic static void main(java.lang.String[]);Code:0: new           #1                  // class leetcode/LambdaTest3: dup4: invokespecial #17                 // Method "<init>":()V7: invokespecial #18                 // Method func:()V10: return
}

一探究竟

我们在LambdaTest类中加入一条Lambda语句:

import java.util.ArrayList;
import java.util.List;public class LambdaTest {private void func() {List<Integer> list = new ArrayList<>();list.stream().filter(i -> i > 0);}public static void main(String args[]) {new LambdaTest().func();}}

然后用javap看看发生了什么:

// javap -p LambdaTest.class
public class leetcode.LambdaTest {public leetcode.LambdaTest();private void func();public static void main(java.lang.String[]);private static boolean lambda$0(java.lang.Integer);
}

原来Java在编译过程中,会把Lambda表达式转换成一个static方法 :lambda$0()。这个方法的参数和返回值正好与Lambda表达式所代表的函数完美匹配。由于存在新增方法,所以热更新怪不得会失败了。

外部类使用内部类的private字段或方法

这也是一种容易被忽略,但是会造成热更新失败的情况。其实反过来,内部类使用外部类的private字段或方法,也会导致热更失败。原理类似,所以我们只挑前一种情况加以分析说明。

我们在LambdaTest中加入一个内部类:

public class LambdaTest {private void func() {}public static void main(String args[]) {new LambdaTest().func();}class InnerClass{private void innerFunc() {}}}

可以用javap看到内部类中包含对外部类的引用this 0 (注意在 0(注意在 0(注意在InnerClass前要加\,否则会解析失败):

// javap -p LambdaTest\$InnerClass.class
class leetcode.LambdaTest$InnerClass {final leetcode.LambdaTest this$0;leetcode.LambdaTest$InnerClass(leetcode.LambdaTest);private void innerFunc();
}

然后我们在外部类的方法func()中调用内部类的方法innerFunc():

public class LambdaTest {private void func() {InnerClass inner = new InnerClass();inner.innerFunc();}public static void main(String args[]) {new LambdaTest().func();}class InnerClass{private void innerFunc() {}}}

再用javap看看发生了什么:

// javap -p LambdaTest\$InnerClass.class
class leetcode.LambdaTest$InnerClass {final leetcode.LambdaTest this$0;leetcode.LambdaTest$InnerClass(leetcode.LambdaTest);private void innerFunc();static void access$0(leetcode.LambdaTest$InnerClass);
}

神奇的事情出现了!反编译的结果是多了个access$0()方法。原来为了不违反private的私有性,Java编译器在处理外部类引用内部类的private字段或方法时,会为内部类自动添加一个access这样的方法,再通过该方法间接调用private字段或方法。这样既没有破坏private私有性原则,也能方便地让内外部类实现private字段方法的互相调用。

因此,如果一个内部类未来有可能做热更新,那么需要在编写代码时就尽量注意,避免出现内外部类相互调用private字段或方法的情况。还有一种做法是干脆弃用内部类,因为内部类必定有与之等效的外部类的写法。不过内部类的好处是能带来更好的可读性和封装,这就需要编写者做个权衡了。

总结

本文介绍了几种常见的Java热更新失败情况,对这些情况的理解和掌握有助于读者避免踩坑。本文还介绍了相应的基础知识:包括Java热更新的原理以及反汇编利器javap。熟练掌握javap的用法,不仅有助于判断热更新能否成功,还能让我们对Java编译过程更加了解,方便排查一些底层的问题和做代码优化。

更多推荐

Java热更新失败常见原因总结

本文发布于:2024-02-13 13:39:27,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1758736.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:常见   原因   Java

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!