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

前言

热更新是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()。失败的原因是方法的底层实际上是增加了一个匿名内部类。

    @Override
    public 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) {
            @Override
            Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
                return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {
                    @Override
                    public void begin(long size) {
                        downstream.begin(-1);
                    }

                    @Override
                    public 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_0
       1: invokespecial #8                  // Method java/lang/Object."<init>":()V
       4: return

  private void func();
    Code:
       0: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #1                  // class leetcode/LambdaTest
       3: dup
       4: invokespecial #17                 // Method "<init>":()V
       7: invokespecial #18                 // Method func:()V
      10: 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编译过程更加了解,方便排查一些底层的问题和做代码优化。

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值