遇到VerifyError束手无策?从虚拟机源码分析原因

原创 吴就业 125 0 2020-04-12

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/dace982427f3418594553bbf4f73fbb7

作者:吴就业
链接:https://wujiuye.com/article/dace982427f3418594553bbf4f73fbb7
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

本篇文章写于2020年04月12日,从公众号|掘金|CSDN手工同步过来(博客搬家),本篇为原创文章。

VerifyError通常是修改字节码引起的类加载阶段的验证错误。类加载过程分三个阶段,分别是加载、链接和初始化,而链接阶段又可细分为验证、准备和解析三个阶段。VerifyError异常发生在链接阶段的验证阶段。在学习使用asm动态生成字节码的过程中,我们或多或少都会遇到这样个错误,那么越到这个问题我们该如何解决呢?本篇文章教大家如何解决这个老大难的问题。对asm改写字节码不了解的读者也可以看一下,了解类的加载过程。

类的验证阶段在hotspot虚拟机中,是在类初始化之前执行的,我们使用ClassLoaderloadClass方法加载类时,如果加载完成后不使用,虚拟机是不会对这个类进行验证和初始化的。触发类初始化的字节码指令有newgetstaticsetstaticinvokestatic这四条指令,分别对应new一个对象、访问该类的某个静态字段,调用该类的某个静态方法。

为验证类的字节码验证是发生在类初始化之前的,我修改了hotspot虚拟机源码,在一些链接、验证相关步骤的方法中加入了日记打印。测试类加载的代码程序如下。

public static void main(String[] args) throws Exception {
        Class<?> clz = LinkAndVerifyTest.class.getClassLoader()
                .loadClass("com.wujiuye.asmbytecode.book.fourth.VerifyTest2");
        System.out.println(clz);
        try {
            Object target = clz.newInstance();
            Method method = clz.getMethod("getId");
            System.out.println("return value:" + method.invoke(target));
        } catch (Exception e) {
            e.printStackTrace();
        }
}

将修改后的hotspot源码重新编译后,我们再使用编译后的java命令来执行测试例子,程序输出的结果如下图所示。

调试hotspot源码控制台输出

从测试结果中可以看出,在ClassLoaderlocaClass方法执行完成后,我们就已经能够获取Class对象,并且打印Class对象的类名,此时虚拟机的方法区中已经存在一个InstanceKlass实例。在通过反射创建对象时,才看到链接方法以及字节码验证方法中打印的日记,说明链接阶段并不是在加载阶段完成后立即执行的。

并且我将测试例子中的实例化并通过反射调用对象的方法这部分去掉后,就不会打印链接与验证字节码的相关日记,说明链接阶段确实是在初始化阶段触发的,在类初始化之前再去链接,包括完成字节码的验证工作。

很多人在遇到VerifyError时,从网上找到的答案都是加-noverify参数,虽然加-noverify参数可以忽略VerifyError异常,让程序正常跑起来,但去掉验证后,程序运行的过程中可能会出现问题。并且-noverify并不是忽略所有的验证错误,有些错误是忽略不了的。本篇将以一个例子教大家如何解决VerifyError

为模拟类加载阶段抛出一个VerifyError,我使用asm编写了一个测试类,在实现这个测试类的实例初始化方法<init>时,我并未生成调用父类的实例初始化方法<init>asm编写测试类的代码如下。

 public static class VerifyTestByteCodeHandler implements ByteCodeHandler {

        private ClassWriter classWriter;

        public VerifyTestByteCodeHandler() {
            this.classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        }

        @Override
        public String getClassName() {
            return "com/wujiuye/asmbytecode/book/fourth/VerifyTestNew";
        }

        private void voidConstructor() {
            // 生成<init>方法
            MethodVisitor methodVisitor = this.classWriter.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
            methodVisitor.visitCode();

            // 调用父类构造器
//            methodVisitor.visitVarInsn(ALOAD, 0);
//            methodVisitor.visitMethodInsn(INVOKESPECIAL, Object.class.getName().replace(".", "/"),
//                    "<init>", "()V", false);

            methodVisitor.visitInsn(RETURN);
            methodVisitor.visitMaxs(1, 1);
            methodVisitor.visitEnd();
        }

        @Override
        public byte[] getByteCode() {
            this.classWriter.visit(Opcodes.V1_8, ACC_PUBLIC, getClassName(), null,
                    Object.class.getName().replace(".", "/"), null);
            voidConstructor();
            this.classWriter.visitEnd();
            return this.classWriter.toByteArray();
        }

    }

来看下asm编写的测试类输出的class文件使用idea反编译后的java代码。

public class VerifyTest2 {
    public VerifyTest2() {
    }
}

从反编译的java代码中,并看不出这个类有什么问题。现在我们编写测试代码,试着使用类加载器加载这个class。测试代码中用到的类加载器是自定义的类加载器。

public static void main(String[] args) throws Exception {
        ByteCodeClassLoader loader = new ByteCodeClassLoader(ClassLoader.getSystemClassLoader());
        String cName = "com/wujiuye/asmbytecode/book/fourth/VerifyTestNew";
        loader.add(cName, new VerifyTestByteCodeHandler());
        Class<?> clz = loader.loadClass(cName);
        System.out.println(clz);
    }

此测试代码是可以正常执行的,如下图。

img

但如果将测试代码改一下,通过反射创建一个对象。修改后的代码如下。

public static void main(String[] args) throws Exception {
        ByteCodeClassLoader loader = new ByteCodeClassLoader(ClassLoader.getSystemClassLoader());
        String cName = "com/wujiuye/asmbytecode/book/fourth/VerifyTestNew";
        loader.add(cName, new VerifyTestByteCodeHandler());
        Class<?> clz = loader.loadClass(cName);
        System.out.println(clz);
        try {
            Object target = clz.newInstance();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

此时就会抛出一个异常,java.lang.VerifyError: Constructor must call super() or this() before return。两次测试结果不一样的原因是,字节码的验证是在类初始化之前才开始的,所以前面的测试代码没有问题,而反射创建对象会触发类的初始化,在类的初始化之前会判断这个类有没有链接,如果未链接则会完成链接。

程序输出的VerifyError是说明该类的实例初始化方法<init>中没有调用父类的实例初始化方法,这个例子很简单。但我们把它当成一个复杂的问题来看待,面对这个异常,我们如何解决。

hotspot源码中找到抛出该异常的位置,字节码验证工作都是在vm/classfile/verifier.cpp这个c++代码文件中完成的。如例子中抛出的异常。

vm/classfile/verifier.cpp

图为hotspot虚拟机ClassVerifier类的verify_class方法部分截图。这与测试例子抛出的异常描述相符,从源码中可以看到抛出异常的原因,在验证方法的最后一条return字节码指令时,如果当前方法名称是<init>,且并未找到调用父类的<init>方法的字节码指令,则抛出异常。

例子比较简单,所以看到这里也就知道怎么解决了,现在我们换一个比较难的例子。

Expecting a stackmap frame at branch target 27

这个例子抛出的java.lang.VerifyError描述信息是Expecting a stackmap frame at branch target 27,从虚拟机中找到的源码如下。

Expecting a stackmap frame

在验证栈映射桢的方法中抛出的,那栈映射桢是什么呢?我们可以从《java虚拟机规范》中有关属性的规定能够找到一个StackMapTable属性,这个属性用在虚拟机的类型检查验证阶段。《java虚拟机规范》中关于StackMapTable属性的描述如图所示。

StackMapTable

因此,我们可以知道,这个异常的原因是由于我们编写的字节码中,需要通过StackMapTable属性使用类型隐式转换。比如,调用一个方法描述符为(Ljava/lang/Long)V的方法,而传递的参数类型却是基本数据类型J(也就是long)。

我们也可以通过使用java代码写一个相同的类,然后使用classpy等字节码查看工具查看编译器生成的class文件的字节码,与通过ASM编写字节码生成的class文件的字节码对比,看两者的差异,从而找到问题的原因。

使用classpy查看类的字节码的StackMapTable

要从入门到进阶java虚拟机字节码,我们需要掌握的知识点不仅仅只是了解字节码指令以及怎么使用asm工具编写字节码,我们更需要对整个class文件结构有着非常熟悉的了解,以及对类加载、验证过程熟悉,而熟悉类加载过程最好的学习方法就是看jvm源码。

通过本篇的学习,遇到VerifyError你还会束手无策吗?

#后端

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

深入理解类加载阶段之准备阶段

准备阶段是为类中定义的静态变量分配内存并设置初始化值的阶段,这里的初始值通常情况下指的是对应类型的零值,比如int类型的零值为0。

访问者模式在ASM框架中的使用

访问者模式的定义是:封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。

为什么要使用Redis的多数据库呢?

`Redis`多数据库是我在`Redis`设计中最糟糕的决定,我希望在某种程度上,我们可以放弃多个数据库的支持,但我认为可能已经太晚了,因为有很多人在工作中使用这个特性。

教你如何将开源项目发布到maven中央仓库

如何将开源项目发布到maven中央仓库,让别人通过依赖使用你的开源项目,想必很多朋友都有过这个想法。

如何优化大表分页查询的Limit性能问题?

如果表的数据量非常大,我们除了优化查询总数的`sql`之外,还是需要优化`limit`的。

Redis实现原子操作的两种方式与商品入库出库解决方案

想要实现针对多个key的复杂原子操作有两种方法。一种是Watch+Multi,即监视器加事务方式,另一种便是通过执行lua脚本实现。