2.2 ASM-类-接口和组件

ASM-类-接口和组件

2.2 接口和组件

2.2.1 介绍

ASM API对编译类进行生成和编辑,都是基于抽象类ClassVisitor实现的(参照表格 2.4)。 该类中的每一个方法都对应class文件中的同名的结构部分(参考表格-2.1:编译后的class结构)。 简单的结构部分可以通过一个方法进行方法,该方法参数描述了该结构部分,返回void。 其他可能是任意长度和复杂性的结构部分,可以通过调用一个初始化方法,返回一个辅助的visitor类。 这便是visitAnnotationvisitField、和visitMethod的调用模式,这几个方法分别返回AnnotationVisitorFieldVisitorMethodVisitor。 同样的原则也适用于递归调用这些辅助类。例如每个方法在抽象类FieldVisitor中都对应了class文件中同名的子结构

 图 2.4 :ClassVisitor类
 
public abstract class ClassVisitor {
    protected final int api;
    protected ClassVisitor cv;
    public ClassVisitor(final int api) {
        //....
    }
    public ClassVisitor(final int api, final ClassVisitor cv) {
        //....
    }
    public void visit(int version, int access, String name, String signature,
            String superName, String[] interfaces) {
        //....
    }
    public void visitSource(String source, String debug) {
        //....
    }
    public void visitOuterClass(String owner, String name, String desc) {
        //....
    }
    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
        //....
    }
    public AnnotationVisitor visitTypeAnnotation(int typeRef,
            TypePath typePath, String desc, boolean visible) {
        //....
    }
    public void visitAttribute(Attribute attr) {
        //....
    }
    public void visitInnerClass(String name, String outerName,
            String innerName, int access) {
        //....
    }
    public FieldVisitor visitField(int access, String name, String desc,
            String signature, Object value) {
        //....
    }
    public MethodVisitor visitMethod(int access, String name, String desc,
            String signature, String[] exceptions) {
        //....
    }
    public void visitEnd() {
        //....
    }
}

ClassVisitor类中的visitAnnotation方法会返回辅助类AnnotationVisitor。 在下一个章节会介绍如何创建和使用这些辅助类:在本章节将介绍一些可以单独使用ClassVisitor解决的简单问题。

图表-2.5.: FieldVisitor类

public abstract class FieldVisitor {
    public FieldVisitor(int api);
    public FieldVisitor(int api, FieldVisitor fv);
    public AnnotationVisitor visitAnnotation(String desc, boolean visible);
    public void visitAttribute(Attribute attr);
    public void visitEnd();
}

ClassVisitor类的方法必须按照以下的顺序被调用执行。

visit visitSource? visitOuterClass? ( visitAnnotation | visitAttribute )\*
( visitInnerClass | visitField | visitMethod )\*
visitEnd

visit方法必须被最先调用,接着再最多调用一次visitSource方法,接着再最多调用一次visitOuterClass方法, 接着再按照任意次序调用任意次的visitAnnotationvisitAttribute方法, 接着再按照任意次序调用任意次的visitInnerClassvisitFieldvisitMethod方法,最后调用一次visitEnd方法。

ASM提供了三个基于ClassVisitor的组件进行生成和转换class:

  1. ClassReader可以从byte数组中解析一个编译后的class,将一个ClassVisitor的实例作为accept的方法参数传递给ClassReader, 然后调用ClassVisitor的响应visitXxx方法。ClassReader可以被作为基于event**模式的生产者。
  2. ClassWriter是抽象类ClassVisitor的子类,它可以直接以二进制的方法构建编译后的class。ClassWriter可以看作是基于event模式的消费者。
  3. ClassVisitor可以完全代理其他ClassVisitor的方法调用。ClassVisitor可被看作是基于event模式的过滤器。

接下来的部分,是一些具体示例,演示使用这些组件如何构建和转换class。

2.2.2 解析Class

解析一个已经存在的class,所需要的唯一组件就是ClassReader类。举个例子介绍一下ClassReader类。 假设我们要打印一个class的内容,类似javap

第一步要写一个ClassVisitor的子类来访问需要打印的信息。下面是一种方式,过于简单的实现:

import static org.objectweb.asm.Opcodes.ASM4;
import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.Attribute;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.FieldVisitor;
import org.objectweb.asm.MethodVisitor;
public class ClassPrinter extends ClassVisitor {
    public ClassPrinter() {
        super(ASM4);
    }
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        System.out.println(name + " extends " + superName + " {");
    }
    public void visitSource(String source, String debug) {
    }
    public void visitOuterClass(String owner, String name, String desc) {
    }
    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
        return null;
    }
    public void visitAttribute(Attribute attr) {
    }
    public void visitInnerClass(String name, String outerName, String innerName, int access) {
    }
    public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {
        System.out.println(" " + desc + " " + name); return null;
    }
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        System.out.println(" " + name + desc); return null;
    }
    public void visitEnd() {
        System.out.println("}");
    }
}

第二步是绑定ClassPrinterClassReader,这样ClassReader产生的event都可以被ClassPrinter消费:

ClassPrinter cp = new ClassPrinter();
ClassReader cr = new ClassReader("java.lang.Runnable");
cr.accept(cp, 0);

第而行语句创建了一个解析java.lang.Runnable接口的ClassReader。最后一行调用accept方法,解析Runnable接口并且调用对象cpClassVisitor的响应方法。 以下是程序输出结果:

java/lang/Runnable extends java/lang/Object {
    run()V
}

ClassReader构造实例的方法有很多种。 像上面通过class全名调用的方式必须确保class文件能被访问到,或者通过一个代表class实体信息的byte数组或者InputSteam构造实例。 读取一个class的输入流,可以通过调用ClassLoadergetResourceAsStream方法:

	
cl.getResourceAsStream(classname.replace(’.’, ’/’) + ".class");

2.2.3 生成Class

生成一个class所需要的唯一组件就是ClassWriter。举个例子说明一下。参考下面接口:

package pkg;
public interface Comparable extends Mesurable {
    int LESS = -1;
    int EQUAL = 0;
    int GREATER = 1;
    int compareTo(Object o);
}

可以通过调用ClassVisitor的6个方法来生成这个接口:

import static org.objectweb.asm.Opcodes.ACC_ABSTRACT;
import static org.objectweb.asm.Opcodes.ACC_FINAL;
import static org.objectweb.asm.Opcodes.ACC_INTERFACE;
import static org.objectweb.asm.Opcodes.ACC_PUBLIC;
import static org.objectweb.asm.Opcodes.ACC_STATIC;
import static org.objectweb.asm.Opcodes.V1_5;
import org.objectweb.asm.ClassWriter;
public class ClassGenerator {
    public static void main(String[] args) {
        ClassWriter cw = new ClassWriter(0);
        cw.visit(V1_5, ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE, "pkg/Comparable", null, "java/lang/Object",
            new String[] { "pkg/Mesurable" });
        cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "LESS", "I", null, new Integer(-1)).visitEnd();
        cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "EQUAL", "I", null, new Integer(0)).visitEnd();
        cw.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "GREATER", "I", null, new Integer(1)).visitEnd();
        cw.visitMethod(ACC_PUBLIC + ACC_ABSTRACT, "compareTo", "(Ljava/lang/Object;)I", null, null).visitEnd();
        cw.visitEnd();
        byte[] b = cw.toByteArray();
    }
}

第一行代码创建了一个ClassWriter实例,该实例将会生成代表class的byte数组。(构造参数会在下一章介绍。) 调用visit方法定义class的头信息。V1_5参数是一个常量,和其他ASM常量一样,在Opcode接口中定义。 代码指定了class的JVM版本为1.5。ACC_XXX常量标志对应了Java修饰符。 这里我们制定了该class是一个接口,有用publicabstract修饰符(因为该类不可以被实例化)。 第三个参数“pkg/Comparable”指定了class的名称,使用内部名格式(参照2.1.2章节)。 回顾一下,编译后的class,不包含package和import部分,所以所有的的class名称都必须是全名。 第四个参数是泛型(参照4.1章)。在这个示例中是null,因为接口没有参数化的类型变量。 第五个参数是父类,使用内部名格式(接口类隐式继承Object类)。 最后一个参数是一个数组表示该类实现的接口,使用内部名格式。

接下来调用的三个visiteField方法是类似的,用于定义接口的三个属性。 第一个参数设置了属性相应的修饰符。这里我们指定了该属性的修饰符为publicfinalstatic。 第二个参数是属性的名称,和源码中是一样的。第三个参数制定了属性的类型,使用类型描述符的方式。 这里指定的属性是int类型的,所以类型描述符是I。 第四个参数是泛型。在这个示例中是null,因为我们没有使用泛型。 最后一个参数是该属性的常量值:这个参数只有在属性是恒定常量的时候才会被使用,即静态final属性。对于其他非静态常量,该参数必须为null。 因为这里没有使用注解,所以我们直接调用visitEnd方法,放回FieldVisitor,即不调用visitAnnotationvisitAttribute方法。

调用visitMethod方法定义compareTo方法。这里第一个参数同样是设置方法的修饰符。 第二个参数指定了方法的名称,和源码中的一样。第三个是该方法的描述符。第四个参数对应方法的泛型。在我们的示例中是null,因为定义的方法没有使用泛型。 最后一个参数指定了可能被该方法抛出的exception数组,使用内部名格式。这里是null,因为该方法不抛出任何异常。 visitMethod方法返回一个MethodVisitor实例(参考图标3.4),可以使用它定义方法的注解、属性和最重要的方法代码。 由于本方法不包含注解,并且该方法是abstract的,我们直接调用visitEnd方法。 最后我们调用cwvisitEnd方法结束该class的声明,并调用toByteArray**方法把该class输出成一个byte数组。

使用生成的类

上面生成的byte数组可以保存到Comparable.class文件中,以便后续使用。 另外,该数组也可以被ClassLoader动态加载。 定义一个ClassLoader的子类,指定它的defineClass方法为public:

class MyClassLoader extends ClassLoader {
  public Class defineClass(String name, byte[] b) {
    return defineClass(name, b, 0, b.length);
  }
}

生成的class可以通过以下方式被直接加载:

	
Class c = myClassLoader.defineClass("pkg.Comparable", b);

另一个方法加载生成的class,需要定义ClassLoader的子类,并重写findClass方法,动态生成需要的class:

class StubClassLoader extends ClassLoader {
    @Override
    protected Class findClass(String name)
        throws ClassNotFoundException {
        if (name.endsWith("_Stub")) {
            ClassWriter cw = new ClassWriter(0);
            //...
            byte[] b = cw.toByteArray();
            return defineClass(name, b, 0, b.length);
        }
        return super.findClass(name);
    }
}

现实情况中,如何使用生成的class取决于程序的上下文,已经超出了ASM API的介绍范围。 如果你写了一个编译器,类生成过程会被抽象语法树驱动来表示编译程序,生成的class会被保存在硬盘上。 如果你编写一个动态代理类生成器或者aspect weaver(切面织入器),需要使用ClassLoader

2.2.4 转换生成的class

至此ClassReader和ClassWriter组件都是被单独使用的。 Event都是由ClassWriter自己产生并且直接消费,或者由ClassReader自己产生并且直接消费,即通过自定义的ClassVisitor实现。 当把这些组件整合起来使用的时候,事情就会变得非常有趣。第一步直接由一个ClassReader生成event传递给一个ClassWriter。 结果就是被ClassReader解析的class,被ClassWriter重建了:

byte[] b1 = ...;
ClassWriter cw = new ClassWriter(0);
ClassReader cr = new ClassReader(b1);
cr.accept(cw, 0);
byte[] b2 = cw.toByteArray(); // b2 represents the same class as b1

这个例子实际上并不有趣(更加简单的实现方法是直接拷贝byte数组!),但请等等。 下一步是介绍在class的读取和写出的过程中使用ClassVisitor组件:

byte[] b1 = ...;
ClassWriter cw = new ClassWriter(0);
// cv forwards all events to cw
ClassVisitor cv = new ClassVisitor(ASM4, cw) { };
ClassReader cr = new ClassReader(b1);
cr.accept(cv, 0);
byte[] b2 = cw.toByteArray(); // b2 represents the same class as b1

上面的代码所对应的结构展示在图表2.6中,组件使用方形表示,event使用箭头表示(垂直方向的时间线作为序列图)。

结果没有任何改变,因为ClassVisitor的event过滤器并没有做任何过滤操作。

但是现在通过重写一些方法,已经足够过滤一些event了,从而改变一个class。 例如,参考下面的ClassVisitor子类:

public class ChangeVersionAdapter extends ClassVisitor {
    public ChangeVersionAdapter(ClassVisitor cv) {
        super(ASM4, cv);
    }
    @Override
    public void visit(int version, int access, String name,
        String signature, String superName, String[] interfaces) {
        cv.visit(V1_5, access, name, signature, superName, interfaces);
    }
}

上面的class仅仅重写了ClassVisitor的一个方法。 其产生的结果是,所有通过调用构造函数传递进来的ClassVisitor对象cv,在调用了visit方法后,都会被修改class的版本号,然后才传递下去。 相应的序列图见图表2.7:

可以通过修改visit方法的其他参数来更改class,不仅仅改变class的版本号。 例如,可以添加一个接口到该类的接口列表。 也可以更改class的名字,但要实现修改class的名字,除了修改visit方法的参数,还要做很多其他的调整。 事实上,class的名字可能出现在编译类的很多不同地方,所有出现的地方都要修改成class的真实名字。

优化

上一部分的修改,仅仅改变了原class的4个字节。 然而,在上述代码中,b1数组被全部解析并产生了相应的event,用于从头构造b2数组,这样效率是非常低的。 高效的做法是直接将b1数组中不需要作改变的部分直接拷贝到b2数组中,对这部分不进行解些和生成event。

ASM会自动为方法执行这些优化:

  • ClassReader检测到ClassVisitor中返回的MethodVisitor作为参数传递给一个ClassWriter对象,这意味着这个方法没有被修改,并且实际上不应该被应用所见。
  • 这种情况下,ClassReader组件不解些方法的内容,也不生成相应的event,仅仅把表示该方法的byte数组拷贝到ClassWriter中。

当ClassReader和ClassWriter相互引用对方的时候,该优化会被执行,可以这样设置:

byte[] b1 = ...
ClassReader cr = new ClassReader(b1);
ClassWriter cw = new ClassWriter(cr, 0);
ChangeVersionAdapter ca = new ChangeVersionAdapter(cw);
cr.accept(ca, 0);
byte[] b2 = cw.toByteArray();

优化过的代码比原本的代码要快2倍,因为ChangeVersionAdapter不修改任意方法。 对于一边的class转换,修改一些或者所有方法,性能提升比较小,但是仍旧很明显:实际在10%到20%。 不幸的是,需要拷贝原class中定义的所有常量到转换的class中。(TODO FIXME 整个一段需要优化范围。) 对于添加属性、方法和指令的转换是没有问题的,但是这将产生更大的class文件,相比较未优化的情况,或对于删除或者重命名class中的元素。 因此,建议这种优化手段仅在“添加剂”转化中使用。

使用转换后的class

在上一个部分,我们介绍了转换后的class b2 可以被存储在硬盘上,或者被一个ClassLoader类加载。 但是在一个ClassLoader中转换后的class,既能够被本ClassLoader加载。 如果你想转换所有ClassLoader中的class,就需要在转换放在java.lang.instrument包中定义的ClassFileTransformer类中(了解更多信息请阅读该包的文档信息):

public static void premain(String agentArgs, Instrumentation inst) {
    inst.addTransformer(new ClassFileTransformer() {
        public byte[] transform(ClassLoader l, String name, Class c, ProtectionDomain d, byte[] b)
            throws IllegalClassFormatException {
            ClassReader cr = new ClassReader(b);
            ClassWriter cw = new ClassWriter(cr, 0);
            ClassVisitor cv = new ChangeVersionAdapter(cw);
            cr.accept(cv, 0);
            return cw.toByteArray();
        }
    });
}

2.2.5 删除class的成员

上一部分改变类版本的方法,可以用在ClassVisitor的其他方法上。 例如修改方法中代表修饰符或者名称的参数,就可以修改相应方法或者属性的修饰符或者名称。 此外,除了转发修改后方法参数,也可选择不转发该调用。 产生的效果是,相应那个的class成员会被删除。 例如,下面的class适配器,删除了该类的内部类和外部类信息,以及编译该类的源文件名称信息 (生成的class仍然保留了完整的功能,因为删除的元素仅用于调试)。 这是通过不转发相应的visit方法实现的:

public class RemoveDebugAdapter extends ClassVisitor {
    public RemoveDebugAdapter(ClassVisitor cv) {
        super(ASM4, cv);
    }
    @Override
    public void visitSource(String source, String debug) {
    }
    @Override
    public void visitOuterClass(String owner, String name, String desc) {
    }
    @Override
    public void visitInnerClass(String name, String outerName,
            String innerName, int access) {
    }
}

这种策略并不适用于属性和方法,因为visitFieldvisitMethod方法必须返回一个结果。 为了删除属性或者方法,就不能转发方法调用,直接返回null就可以了。 例如,下面的class适配器,通过指定方法的名称和描述符(名称不足以标识唯一一个方法,因为一个类可以包含很多名称相同,但参数类型不同的方法)删除了一个方法:

public class RemoveMethodAdapter extends ClassVisitor {
    private String mName;
    private String mDesc;
    public RemoveMethodAdapter(
            ClassVisitor cv, String mName, String mDesc) {
        super(ASM4, cv);
        this.mName = mName;
        this.mDesc = mDesc;
    }
    @Override
    public MethodVisitor visitMethod(int access, String name,
            String desc, String signature, String[] exceptions) {
        if (name.equals(mName) && desc.equals(mDesc)) {
            // do not delegate to next visitor -> this removes the method
            return null;
        }
        return cv.visitMethod(access, name, desc, signature, exceptions);
    }
}

2.2.6 添加class成员

相比于减少转发你收到的调用,你可以多几次转发,来增加class的成员。 新的调用可以在原方法调用中的任何地方调用,需要确保不同visitXxx方法按照顺序被调用(参照章节2.2.1): 例如,如果你想在class中新增一个属性,你需要在原本的方法调用中插入一个visitField方法调用,并且在class适配器中的访问方法中插入该调用。 不可以在visit方法中插入visitField方法,这样将会导致visitField方法在visitSourcevisitOuterClassvisitAnnotationvisitAttribute这些方法之前被调用,这是不合法的。 同样,也不能在visitSourcevisitOuterClassvisitAnnotationvisitAttribute这些方法中调用visitField方法。 只可以在visitInnerClassvisitFieldvisitMethodvisitEnd方法中调用。

如果将新的调用方法添加在visitEnd方法中,这个属性肯定会被添加(除非你设置了其他条件来执行该调用),因为visitEnd方法总会被调用。 如果将调用放在visitFieldvisitMethod方法中,会添加几个属性:原class中每一个属性和方法都会调用该方法并增加一个属性。 这两种解决方案都是有意义的;使用那种取决于场景需求。例如你可以增加一个单一的计数器属性来统计对象的调用次数,或者每个方法的计数器来单独统计每个方法的调用次数。

备注 现实中真正正确的解决方案是在visitEnd方法中调用增加新成员的方法。 一个类不能包含重复的成员,确保新增成员唯一性,需要和所有的已有成员做比较,这只能在所有的已有成员都被访问过后才可以做比较,即在visitEnd方法中比较。 这是一个强约束。使用生成的名称可能和程序员使用的命名方式不同,例如在实践中使用’_counter$‘后者’_4B7F‘这种命名可以防止类成员重复,这样就不必在visitEnd方法中调用了。 需要注意的是,正如第一章介绍的,Tree API的调用是没有限制的:在类转换的过程中可以使用API添加类成员。

为了说明上面的讨论,下面有一个class适配器,会在class中添加一个属性,出发该属性已经在该class中:

public class AddFieldAdapter extends ClassVisitor {
    private int fAcc;
    private String fName;
    private String fDesc;
    private boolean isFieldPresent;
    public AddFieldAdapter(ClassVisitor cv, int fAcc, String fName,
            String fDesc) {
        super(ASM4, cv);
        this.fAcc = fAcc;
        this.fName = fName;
        this.fDesc = fDesc;
    }
    @Override
    public FieldVisitor visitField(int access, String name, String desc,
            String signature, Object value) {
        if (name.equals(fName)) {
            isFieldPresent = true;
        }
        return cv.visitField(access, name, desc, signature, value);
    }
    @Override
    public void visitEnd() {
        if (!isFieldPresent) {
            FieldVisitor fv = cv.visitField(fAcc, fName, fDesc, null, null);
            if (fv != null) {
                fv.visitEnd();
            }
        }
        cv.visitEnd();
    }
}

属性在visitEnd方法中添加。重写了visitField方法,并不是为了修改或删除原有的属性,而是检查需要添加的属性是否已经存在。 注意一下,visitEnd方法中,在调用fv.visitEnd()方法前,调用的fv != null 判断语句:原因之前章节有介绍,一个class的visitor调用visitField方法,可能返回null

2.2.7 转换链路

至此为止,我们已经了解了由ClassReader、class适配器和ClassWriter组成的转换链。 当然也可以由多个class适配器组成更加复杂的转换链,。 链路中的几个适配器可以撰写几个不同的类转换器,来实现复杂的转换工作。 需要知道的是,转换链不必是线性的。 可以实现一个ClassVisitor,同时转发它所接受的所有方法调用到不同的ClassVisitor

public class MultiClassAdapter extends ClassVisitor {
    protected ClassVisitor[] cvs;
    public MultiClassAdapter(ClassVisitor[] cvs) {
        super(ASM4);
        this.cvs = cvs;
    }
    @Override public void visit(int version, int access, String name,
            String signature, String superName, String[] interfaces) {
        for (ClassVisitor cv : cvs) {
            cv.visit(version, access, name, signature, superName, interfaces);
        }
    }
    ...
}

对等的多个class适配器可以委托给同一个ClassVisitor(这需要一些预防措施,比如ClassVisitorvisit方法和visitEnd方法只能被调用一次)。 因此一个像图标2.8展示的转换链才是完全有可能的。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏aCloudDeveloper

大神洗礼第四讲——函数相关及编程技巧

Author:bakari       Date:2012.11.2 1、参数传递问题: < 1 >、堆栈传参 < 2 >、寄存器传参(利用通用寄存器进行函数参...

214100
来自专栏java工会

JAVA 同步实现原理

Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized的作用主要有三个:

7700
来自专栏逆向技术

框架原理第二讲,RTTI,运行时类型识别.(以MFC框架讲解)

           框架原理第二讲,RTTI,运行时类型识别.(以MFC框架讲解) 一丶什么是RTTI,以及RTTI怎么设计 通过第一讲,我们知道了怎么样升成...

220100
来自专栏大闲人柴毛毛

深入Java虚拟机——JVM内存详解

在C++中,程序员拥有每一个对象的所有权,但与此同时还肩负着释放对象内存空间的责任;而Java由于有了虚拟机的帮助,程序员拥有对象的所有权的同时不再需要释放对象...

370130
来自专栏微信公众号:Java团长

Java堆和栈的区别

在函数中定义的一些基本类型的变量和对象的引用变量都是在函数的栈内存中分配。当在一段代码块中定义一个变量时,Java就在栈中为这个变量分配内存空间,当超过变量的作...

29530
来自专栏刘君君

JVM Specification notes 1 -Jvm Structure

摘要: Jvm Structure 正文: Java 虚拟机结构 Class文件格式 数据类型 原始类型(基本类型) 数值类型{整数[byte8 short1...

38570
来自专栏数据结构与算法

Tarjan中栈的分析与SLT栈的实现

首先看一下手写的栈: 1 do{ 2 printf("%d ",stack[index]); 3 visit[stack[index]]=0; ...

33760
来自专栏积累沉淀

JSON

JSON的全称是”JavaScript Object Notation”,意思是JavaScript对象表示法,它是一种基于文本,独立于语言的轻量级数据交换格式...

38180
来自专栏达摩兵的技术空间

js中的作用域

相信自从es6出来之后,你一定多少知道或者已经在项目中实践了部分的块级作用域,在函数或者类的内部命名变量已经在使用let了,但是你知道它真正的作用是什么吗?又是...

16620
来自专栏Jackson0714

C#多线程之旅(4)——APM初探

420130

扫码关注云+社区

领取腾讯云代金券