JVM系列之:你知道Lombok是如何工作的吗

简介: JVM系列之:你知道Lombok是如何工作的吗

1.jpg

本文为《深入学习 JVM 系列》第十五篇文章


在学习本文前,也许你只是用过 Lombok,知道有一些注解可以帮助我们快速开发,但是你是否了解它是怎么工作的,为什么可以产生这样的效果?让我们带着上述问题,开始本文的学习。


在前文讲解《Javac编译器》时学习编译器的执行过程时,有这么一张流程图:


2.jpg


第二步处理过程就是插入式注解处理器的注解处理过程,当时没做过多了解,本文就来好好学习一番。


首先我们来认识一下注解。


注解


注解(Annontation)是 Java 5 引入的,Annontation 像一种修饰符一样,应用于包、类型、构造方法、方法、成员变量、参数及本地变量的声明语句中。注解不会也不能影响代码的实际逻辑,仅仅起到辅助性的作用。包含在 java.lang.annotation 包中。


注解有很多用处,大致有以下几种:


  • 生成文档。这是最常见的,也是 Java 最早提供的注解。常用的有@param @return 等;


  • 跟踪代码依赖性,实现替代配置文件功能。比如Dagger 2 依赖注入,未来 Java 开发,将大量注解配置,具有很大用处;


  • 在编译时进行格式检查。如@Override 放在方法前,如果你这个方法并不是覆盖了超类方法,则编译时就能检查出。


元注解


java.lang.annotation 提供了四种元注解,专门注解其他的注解(在自定义注解的时候,需要使用到元注解):


  • @Documented – 标识注解是否将显示在生成的 JavaDoc 中
  • @Retention – 用来定义注解的生命周期
  • @Target – 注解用于什么地方
  • @Inherited – 表示是否允许子类继承该注解

@Retention则用来限定当前注解生命周期。注解共有三种不同的生命周期:

  • SOURCE 表示注解只出现在源代码中,当 Java 文件编译成 class 文件的时候,注解被遗弃。
  • CLASS 表示只出现在源代码和字节码中,但 JVM 加载 class 文件时候被遗弃,这是默认的生命周期。
  • RUNTIME 表示出现在源代码、字节码和运行过程中, JVM 加载 class 文件之后仍然存在。


@Target 表示该注解用于什么地方。默认值为任何元素,表示该注解用于什么地方。可用的ElementType 参数包括:


  • ElementType.CONSTRUCTOR: 用于描述构造器
  • ElementType.FIELD: 成员变量、对象、属性(包括enum实例)
  • ElementType.LOCAL_VARIABLE: 用于描述局部变量
  • ElementType.METHOD: 用于描述方法
  • ElementType.PACKAGE: 用于描述包
  • ElementType.PARAMETER: 用于描述参数
  • ElementType.TYPE: 用于描述类、接口(包括注解类型) 或enum声明


@Override


比如我们熟知的 @Override 注解,用来声明某个实例方法重写了父类的同名同参数类型的方法。


package java.lang;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
复制代码


这里@Override 便只能出现在源代码中。一旦标注了@Override 的方法所在的源代码被编译为字节码,该注解便会被擦除。这点在查看字节码文件可以发现,可见@Override 仅对 Java 编译器有用。


@Override 为 Java 编译器引入了一条新的编译规则,即如果所标注的方法不是 Java 语言中的重写方法,那么编译器会报错。而当编译完成时,它的使命也就结束了。

现在项目比较流行使用 SpringBoot 框架,比如想记录一下请求日志,都会自己定义注解,利用 AOP 来实现该功能。这种形式的注解仅限于本项目中使用,如果想要复用,只能 copy 代码,那么有没有一种更便捷的方式,让自定义注解在任何项目中都可以直接使用。


插入式注解处理器


在 JDK 6中又提出并通过了JSR-269提案,该提案设计了一组被称为“插入式注解处理器”的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时, 允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round),这也就对应着上图的那个回环过程。


因为开发人员可以干涉编译器的行为,那么就有了极大的发挥空间,目前已有不少功能强大的插入式注解处理器。譬如Java著名的编码效率工具 Lombok,它可以通过注解来实现自动产生 getter/setter 方法、进行空置检查、生成受查异常表、产生 equals()和 hashCode()方法等等,帮助开发人员消除 Java 的冗长代码,


原理


在讲解《Javac编译器》一文中提到,编译过程大致可以分为1个准备过程和3个处理过程,其中准备过程是用来初始化插入式注解处理器,第二步则对应注解处理器的执行阶段,那么想要研究注解处理器背后的原理,最好的方法莫过于通过代码入手。


接下来我们会在 Javac 源码项目中进行调试,还未下载该项目的朋友,可以参考之前的文章。


我们复习一下编译器的核心内容:Javac 编译器入口位于 src/com/sun/tools/javac/Main.java,我们可以看一下它的 main 方法。


public static void main(String[] args) throws Exception {
        System.exit(compile(args));
    }
复制代码


如果深入查看源码,可以发现,先定位到 com.sun.tools.javac.main.Main 类,然后又到 com.sun.tools.javac.main.JavaCompiler 类,那么上述 3个处理过程应该就是在 JavaCompiler 类中实现的,具体指 compile()、compile2()这两个方法,这里直接引用书中的图片。


3.jpg


了解了代码执行流程后,我们把重心放在 initProcessAnnotations 和 processAnnotations 方法上,那么我们就准备引入一个插入式注解处理器,这里引入的是 lombok,研究一下它的注解的具体处理流程。


抽象处理器


因为代码逻辑有些复杂,所以要明确重点,这里我们了解一下注解处理器类的实现,所有的注解处理器类都需要实现接口 Processor。


public interface Processor {
  void init(ProcessingEnvironment processingEnv);
  Set<String> getSupportedAnnotationTypes();
  SourceVersion getSupportedSourceVersion();
  boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv);
  ...
}
复制代码


该接口主要有四个重要方法。其中,


  • init方法用来存放注解处理器的初始化代码。之所以不用构造器,是因为在 Java 编译器中,注解处理器的实例是通过反射 API 生成的。也正是因为使用反射 API,每个注解处理器类都需要定义一个无参数构造器。


  • 通常来说,当编写注解处理器时,我们不声明任何构造器,并依赖于 Java 编译器,为之插入一个无参数构造器。而具体的初始化代码,则放入init方法之中。


  • getSupportedAnnotationTypes 方法将返回注解处理器所支持的注解类型,这些注解类型只需用字符串形式表示即可。


  • getSupportedSourceVersion 方法将返回该处理器所支持的 Java 版本,通常,这个版本需要与你的 Java 编译器版本保持一致;


  • process方法则是最为关键的注解处理方法,编写用于扫描、评估和处理注释以及生成 java 文件的代码。


JDK 提供了一个实现 Processor 接口的抽象类 AbstractProcessor。该抽象类实现了 init、getSupportedAnnotationTypes 和getSupportedSourceVersion 方法。它的子类可以通过@SupportedAnnotationTypes 和@SupportedSourceVersion 注解来声明所支持的注解类型以及 Java 版本。


综上,我们知道了该查看什么信息,首先注解处理器类会继承 AbstractProcessor,除此之外我们要关注 getSupportedAnnotationTypes 和 process 这两个方法。


Lombok注解调试


1、首先在 javac 源码项目中引入 Lombok 依赖,如下:


<dependencies>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
  </dependency>
</dependencies>
复制代码


2、创建测试类,使用注解 @Data 等


package com.sun.tools.javac.test;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {
  private String name;
  private double price;
  private int num;
}
复制代码


3、配置 src/com/sun/tools/javac/Main.java 的 Program arguments 值如下:


/Users/xxx/IdeaProjects/javac-source-code-reading/src/com/sun/tools/javac/test/Product.java
复制代码


4、在 com.sun.tools.javac.main.JavaCompiler 的 compile 方法中增加断点:


4.jpg


接下来还有一些重要的断点,这里就不讲是如何定位的了,以及为什么在那里打断点,只能多加调试。不过大家可以参考一下这篇源码解析文章


还是在 com.sun.tools.javac.main.JavaCompiler 文件

5.jpg

还有 com.sun.tools.javac.processing.JavacProcessingEnvironment 文件,

6.jpg

下述断点处,可以看到 getSupportedAnnotationTypes 方法,大致了解处理哪些注解。

7.jpg


5、调试过程不怎么顺利,没有看到想要的内容,我转头去下载了 lombok 的源码,准备去哪里找一点方向。不过在此之前我们可以修改一下 Program arguments,下面两个指令用来查看注解处理器运作的详细信息。


-XprintRounds -XprintProcessorInfo /Users/xxx/IdeaProjects/javac-source-code-reading/src/com/sun/tools/javac/test/Product.java
复制代码


输出结果为:


循环 1:
  输入文件: {com.sun.tools.javac.test.Product}
  注释: [lombok.Data, lombok.AllArgsConstructor, lombok.NoArgsConstructor]
  最后一个循环: false
处理程序lombok.launch.AnnotationProcessorHider$AnnotationProcessor与[lombok.Data, lombok.AllArgsConstructor, lombok.NoArgsConstructor]匹配并返回true。
循环 2:
  输入文件: {}
  注释: []
  最后一个循环: false
处理程序lombok.launch.AnnotationProcessorHider$AnnotationProcessor与[]匹配并返回false。
循环 3:
  输入文件: {}
  注释: []
  最后一个循环: false
处理程序lombok.launch.AnnotationProcessorHider$AnnotationProcessor与[]匹配并返回false。
循环 4:
  输入文件: {}
  注释: []
  最后一个循环: true
复制代码


那么我们接着去找 lombok.launch.AnnotationProcessorHider$AnnotationProcessor 类吧。


8.jpg


这里我们见到了 init 和 process 方法,针对每个注解,process 逻辑有所不同,至于这背后的知识一时半会也搞不定,再加上我是引入的 lombok 依赖,即时是在 javac 项目中也没法进行调试,最终我放弃这种方法来研究注解处理器了。


我想到可以自定义一个注解处理器,然后来研究学习它,那就来实现 lombok 的 @Getter 注解吧。


手撸@Getter注解并调试


关于 Lombok 注解的实现,网上有相关文章介绍地很详细,这里推荐一篇文章,文中详细介绍了如何手撸一个 @Getter 注解。


虽然我们不需要完全实现文章中的步骤,但是要对各步骤有个印象,接下来我们将用到。


首先明确我们要做什么,我们要在 javac 项目中调用自定义的注解处理器,来生成对应的字节码文件,并且可以调试代码来学习其调用流程。


下述代码都是在 javac 项目中进行。


1、在 com.sun.tools.javac 包下创建 annotation 包,然后创建如下两个文件。


Getter.java


package com.sun.tools.javac.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Getter {
}
复制代码


GetterProcessor.java


package com.sun.tools.javac.annotation;
import com.sun.source.tree.Tree;
import com.sun.tools.javac.api.JavacTrees;
import com.sun.tools.javac.code.Flags;
import com.sun.tools.javac.processing.JavacProcessingEnvironment;
import com.sun.tools.javac.tree.JCTree;
import com.sun.tools.javac.tree.TreeMaker;
import com.sun.tools.javac.tree.TreeTranslator;
import com.sun.tools.javac.util.Context;
import com.sun.tools.javac.util.List;
import com.sun.tools.javac.util.ListBuffer;
import com.sun.tools.javac.util.Name;
import com.sun.tools.javac.util.Names;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
//对Getter感兴趣
@SupportedAnnotationTypes("com.sun.tools.javac.annotation.Getter")
//支持的版本,使用1.8就写这个
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class GetterProcessor extends AbstractProcessor {
  // 编译时期输入日志的
  private Messager messager;
  // 将Element转换为JCTree的工具,提供了待处理的抽象语法树
  private JavacTrees trees;
  // 封装了创建AST节点的一些方法
  private TreeMaker treeMaker;
  // 提供了创建标识符的方法
  private Names names;
  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    this.messager = processingEnv.getMessager();
    this.trees = JavacTrees.instance(processingEnv);
    Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
    this.treeMaker = TreeMaker.instance(context);
    this.names = Names.instance(context);
  }
  @Override
  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    // 获取被@Getter注解标记的所有元素(这个元素可能是类、变量、方法等等)
    Set<? extends Element> set = roundEnv.getElementsAnnotatedWith(Getter.class);
    set.forEach(element -> {
      // 将Element转换为JCTree
      JCTree jcTree = trees.getTree(element);
      jcTree.accept(new TreeTranslator() {
        /***
         * JCTree.Visitor有很多方法,我们可以通过重写对应的方法,(从该方法的形参中)来获取到我们想要的信息:
         * 如: 重写visitClassDef方法, 获取到类的信息;
         *     重写visitMethodDef方法, 获取到方法的信息;
         *     重写visitVarDef方法, 获取到变量的信息;
         * @param jcClassDecl
         */
        @Override
        public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
          //创建一个变量语法树节点的List
          List<JCTree.JCVariableDecl> jcVariableDeclList = List.nil();
          // 遍历defs,即是类定义的详细语句,包括字段、方法的定义等等
          for (JCTree tree : jcClassDecl.defs) {
            if (tree.getKind().equals(Tree.Kind.VARIABLE)) {
              JCTree.JCVariableDecl jcVariableDecl = (JCTree.JCVariableDecl) tree;
              jcVariableDeclList = jcVariableDeclList.append(jcVariableDecl);
            }
          }
          // 对于变量进行生成方法的操作
          jcVariableDeclList.forEach(jcVariableDecl -> {
            messager.printMessage(Diagnostic.Kind.NOTE, "get " + jcVariableDecl.getName() + " has been processed");
            treeMaker.pos = jcVariableDecl.pos;
            //类里的前面追加生成的Getter方法
            jcClassDecl.defs = jcClassDecl.defs.prepend(makeGetterMethodDecl(jcVariableDecl));
          });
          super.visitClassDef(jcClassDecl);
        }
      });
    });
    //我们有修改过AST,所以返回true
    return true;
  }
  private JCTree.JCMethodDecl makeGetterMethodDecl(JCTree.JCVariableDecl jcVariableDecl) {
    /***
     * JCStatement:声明语法树节点,常见的子类如下
     * JCBlock:语句块语法树节点
     * JCReturn:return语句语法树节点
     * JCClassDecl:类定义语法树节点
     * JCVariableDecl:字段/变量定义语法树节点
     * JCMethodDecl:方法定义语法树节点
     * JCModifiers:访问标志语法树节点
     * JCExpression:表达式语法树节点,常见的子类如下
     * JCAssign:赋值语句语法树节点
     * JCIdent:标识符语法树节点,可以是变量,类型,关键字等等
     */
    ListBuffer<JCTree.JCStatement> statements = new ListBuffer<>();
    statements.append(treeMaker.Return(treeMaker.Select(treeMaker.Ident(names.fromString("this")), jcVariableDecl.getName())));
    JCTree.JCBlock body = treeMaker.Block(0, statements.toList());
    return treeMaker.MethodDef(
        treeMaker.Modifiers(Flags.PUBLIC),//mods:访问标志
        getNewMethodName(jcVariableDecl.getName()),//name:方法名
        jcVariableDecl.vartype,//restype:返回类型
        List.nil(),//typarams:泛型参数列表
        List.nil(),//params:参数列表
        List.nil(),//thrown:异常声明列表
        body,//方法体
        null);
  }
  private Name getNewMethodName(Name name) {
    String s = name.toString();
    return names.fromString("get" + s.substring(0, 1).toUpperCase() + s.substring(1, name.length()));
  }
}
复制代码


2、创建测试类 Goods


package com.sun.tools.javac.test;
import com.sun.tools.javac.annotation.Getter;
@Getter
public class Goods {
  private String name;
  private Double price;
  public static void main(String[] args) {
    Goods goods = new Goods();
  }
}
复制代码


3、首先编译 Getter 和 GetterProcessor 文件,配置 src/com/sun/tools/javac/Main.java 的 Program arguments 值如下:


// 多个文件需要编译,则用空格隔开
/Users/xxx/IdeaProjects/javac-source-code-reading/src/com/sun/tools/javac/annotation/Getter.java /Users/xxx/IdeaProjects/javac-source-code-reading/src/com/sun/tools/javac/annotation/GetterProcessor.java
复制代码


结果如下图所示:


10.jpg


4、然后来编译我们的测试类 Goods,配置 src/com/sun/tools/javac/Main.java 的 Program arguments 值如下:


-processor com.sun.tools.javac.annotation.GetterProcessor /Users/xxx/IdeaProjects/javac-source-code-reading/src/com/sun/tools/javac/test/Goods.java
复制代码


查看生产的 Goods.class 文件


//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.sun.tools.javac.test;
public class Goods {
  private String name;
  private Double price;
  public Double getPrice() {
    return this.price;
  }
  public String getName() {
    return this.name;
  }
  public Goods() {
  }
  public static void main(String[] var0) {
    new Goods();
  }
}
复制代码


可以看到我们自定义的@Getter 注解处理器被编译器成功执行了,最后得到了想要的结果。那么接下来我们就可以调试上述执行过程,仍然基于最初的断点。


调试过程


下面只列举几个重要的执行节点,主要集中在 com.sun.tools.javac.processing.JavacProcessingEnvironment 文件和自建的 GetterProcessor 文件。


1、首先在 ProcessorState 的构造方法中会调用注解处理器的 init 方法


11.jpg


2、初始化 processor 后,通过 processor.getSupportedAnnotationTypes() 方法可以获取要处理的注解类型。


12.jpg


3、知道注解类型后,接下来就准备调用 GetterProcessor 文件中的 process 方法,执行具体的处理逻辑。


13.jpg


4、执行注解处理器核心逻辑


14.jpg


process 方法涉及各种不同类型的 Element,分别指代 Java 程序中的各个结构。如 TypeElement 指代类或者接口,VariableElement 指代字段、局部变量、enum 常量等,ExecutableElement 指代方法或者构造器。


package foo;     // PackageElement
class Foo {      // TypeElement
  int a;           // VariableElement
  static int b;    // VariableElement
  Foo () {}        // ExecutableElement
  void setA (      // ExecutableElement
    int newA         // VariableElement
  ) {}
}
复制代码


使用注解处理器


上文我是在 javac 项目中使用注解处理器,可是实际应用中并不会接触到 javac 项目,所以想要使用自定义的注解处理器,则需要另外的操作。


注册的方法主要有两种。第一种是直接使用 javac 命令的-processor 参数,如下所示:

比如《自定义Lombok详解》一文中的 compile.sh


#!/usr/bin bash
if [ -d classes ]; then
    rm -rf classes;
fi
#创建输出目录
mkdir classes
#先编译注解处理器相关的类,并且-d指定输出到classes文件夹
javac -encoding UTF-8 -cp $JAVA_HOME/lib/tools.jar com/msdn/jsr/Getter* -d classes/
#-cp 指定class路径(运行时用全限定类名) -processor 即指定用上面编译出来的注解类去编译测试类,并且class文件也指定到classes文件夹
javac -cp classes -processor com.msdn.jsr.GetterProcessor com/msdn/jsr/Product.java -d classes/
#-cp 指定class路径,然后运行
java -cp classes com.msdn.jsr.Product
复制代码


这种通过命令行方式来插入自定义注解处理器,在一个项目中还可以,而且操作起来比较繁琐,如果跨项目使用就更不方便了。


第二种则是将注解处理器打成一个 jar 包中,并在 jar 包的配置文件中记录该注解处理器的包名及类名,这样在使用时不需要添加额外参数。


详细步骤还是参考 《自定义Lombok详解》,这里看一下个人的测试项目结构。


1.jpg


如果想在另一个项目中使用,可以这样配置,首先看一下整体结构:


15.jpg


然后看一个测试类 Goods,程序可以顺利执行成功。


package com.msdn.java;
import com.msdn.jsr.Getter;
@Getter
public class Goods {
  private String name;
  private Double price;
  public void setName(String name) {
    this.name = name;
  }
  public static void main(String[] args) {
    Goods goods = new Goods();
    goods.setName("hresh");
    System.out.println(goods.getName());
  }
}
复制代码


总结


本文主要讲述了插入式注解处理器,首先学习了注解的相关内容,然后深入 Javac 项目来研究注解处理器是如何执行的,最后我们通过一个简单的 @Getter 注解来演示 Javac 编译器是如何处理该注解的,基于此点让我们对 Lombok 的工作有了一个大概的认知。


目录
相关文章
|
2月前
|
缓存 easyexcel Java
Java EasyExcel 导出报内存溢出如何解决
大家好,我是V哥。使用EasyExcel进行大数据量导出时容易导致内存溢出,特别是在导出百万级别的数据时。以下是V哥整理的解决该问题的一些常见方法,包括分批写入、设置合适的JVM内存、减少数据对象的复杂性、关闭自动列宽设置、使用Stream导出以及选择合适的数据导出工具。此外,还介绍了使用Apache POI的SXSSFWorkbook实现百万级别数据量的导出案例,帮助大家更好地应对大数据导出的挑战。欢迎一起讨论!
197 1
|
7月前
|
监控 Java 测试技术
滚雪球学Java(45):探秘Java Runtime类:深入了解JVM运行时环境
【5月更文挑战第20天】🏆本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
55 1
滚雪球学Java(45):探秘Java Runtime类:深入了解JVM运行时环境
|
7月前
|
Java
[JVM] Java类的加载过程
[JVM] Java类的加载过程
[JVM] Java类的加载过程
|
7月前
|
存储 前端开发 安全
java 类加载执行的过程
java 类加载执行的过程
71 0
|
JavaScript 前端开发 Java
Java 开发中到底该不该用 Lombok?
Java 开发中到底该不该用 Lombok?
|
存储 Java Linux
Java类加载过程、为什么会出现JVM?
也就是说Java程序可以在windows操作系统上运行,不做任何修改,同样的java程序可以在Linux操作系统上运行,跨平台。
|
存储 缓存 安全
一文带你了解Java Class的生命周期(类加载过程)
一文带你了解Java Class的生命周期(类加载过程)
150 0
一文带你了解Java Class的生命周期(类加载过程)
|
安全 Oracle 前端开发
【JAVA】聊聊类加载过程
Java 通过引入字节码和 JVM 机制,提供了强大的跨平台能力,理解 Java 的类加载机制是深入 Java 开发的必要条件。
105 0
|
存储 Java 测试技术
【JAVA】-观JVM运行时几种常见内存溢出
观JVM运行时几种常见内存溢出
【JAVA】-观JVM运行时几种常见内存溢出