本篇主要从Java代码的编译视角简要去对Lombok、MapStruct的实现原理进行说明,如有谬误,恳请斧正。
可能会涉及到分析的内容:
- 编译原理
- 反射机制
- APT注解处理器
- JSR269
- SPI服务发现机制
一、背景概述
最近,参与组内的MapStruct的替换,主要是用于优化对象拷贝、类转换这两种场景,这件事其实在去年实习时就已经在业务域推行,因此G老师让我来主导这件事。
事实上,实习与转正后不同,由于组织架构调整,我已经不在原来的业务域,或者说比较幸运地得到G老师的赏识,在他被调到另外一个业务域时,主动向上面请求带上我,所以现在来到了一个新的业务域。
而原先的业务域早已完成替换,时隔半年,这次要在另外一个业务域推行,但实习时并没有参与到这件事中,所以这次也算是对我的一次锻炼。
其实早在第一次准备八股时,我就对一件事很好奇,Java代码是如何运行的呢?在C++中我大体知道编译、链接以及静态库和动态库的一些概念,在Linux上也用命令行去操作过makefile文件。
但也许是使用Java的IDE成为常态了,编译后的target目录我也极少翻看,即使我使用txt文件编写过Java代码,也仅仅了解到javac编译器与生成class文件这两个现象而已,因此我在去年五六月份时,做过简单了解,恰巧与这次MapStruct使用的知识相关,所以尝试将两者串联起来,也顺便作为到时候梳理文档的一个逻辑线。
二、编译流程
其实想要分析Lombok与MapStruct便绕不开一个东西,那便是注解处理器APT,这个东西便是在Java代码编译过程中使用的,所以我便干脆把整个编译流程简单梳理一遍。
早在lombok出现时,我想应该就会有人好奇,以下问题:
为什么Lombok可以直接在编译后生成,而不影响性能?
早期lombok为什么有可能会引发各种各样的问题,导致至今都有公司弃之如敝履?
而Java中的注解又怎么会有编译处理与运行处理两大类,编译处理是如何实现的?
javac作为编译器,到底做了什么,在其中扮演着什么样的角色?
究其根本,还是要先回到代码编译的过程中。
Java文件到Class文件中间发生了什么?如果作为一个计算机专业方向的学生,应该都还记得一门课,编译原理。无论是C++、Java,亦或是其他语言,甚至是Mysql和现在大火的LLM,只要与语言相关,从一个语言体系到另一个语言体系都绕不开这件事“编译”,而这个过程,便是构建语法树的过程。
如果还记得编译原理的话,那么应该能回忆起两个阶段,词法分析和语法分析,这大概便是编译这件事的通用流程了,那么在此列出Java编译的四个模块。
- 词法分析器:识别关键词,构造标准token流
- 语法分析器:根据token流构建抽象语法树
- 语义分析器:简化语法树
- 字节码生成器:将语法树翻译成字节码
这四个模块也被我理解为四个步骤,也即识别、构建、简化和翻译,但这不是编译过程中的全部,至少不是Java编译的全部,因此有些人会认为Java编译有七大阶段甚至更多。
但我认为从编译原理的角度出发,其他的内容不过是从一个阶段到另一个阶段之间还隐藏着些许细节,在此不做过多扩展,只挑出与本篇相关的内容来展开。
在Java进行语义分析时,做的是简化操作,主要是对语法糖进行处理,那么抽象语法树怎么会那么大跨度来到可以进行语义分析呢?其实在语法分析到语义分析过程中,javac还进行了两个操作,一是对符号表填充,二是对注解进行处理,在此,我们展开后者进行简单说明。
前文提到过,注解处理分为编译期处理和运行期处理,而运行期处理是通过反射实现的,编译期呢,这就涉及到前文提到的另一个东西——注解处理器(APT)。
注解处理器也许对于很多人来说并不熟悉,但它很好理解,所谓的处理器,其实不过是与替换类似,只不过这个替换的步骤是由代码逻辑实现的,最终的现象就类似于有了这个注解,就会被处理器替换成一段逻辑相同的代码。APT主要进行的操作如下:
- 扫描并解析注解
- 对相应的注解进行相应的逻辑实现
- 生成代码
- 将生成的代码进行编译
在整个编译过程中,需要处理的地方被重新编译后,相当于重新生成的子树一样,直接拼接到原先的抽象语法树的位置即可。
三、实现原理
了解完注解处理器是否联想到了什么?对,如果我定义了一个注解处理器,是对当前这个类识别后,生成属性的get和set方法是不是说明我实现了lombok?如果我在这里实现各种类的转换和拷贝是不是说明我实现了mapstruct?
这就是lombok与mapstruct的原理,就是这么顺其自然,那么如何实现呢?这就不得不引入另外一个东西了JSR269-插入式注解处理器,JSR是Java的一种规范与约定,269代表具体的条数,常见的还有JSR330是有关注解的使用的。
JSR269提出了一种约定,即Java通过约定的方式去识别注解处理器,在编译时对注解进行处理,如果这些处理如果这些处理器在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round
而这种约定,也相当于说明,可以由使用者按照约定去实现注解处理器了,具体的实现方式类似在约定的路径上,继承约定类,则会在编译时执行,具体的实现流程比较繁琐,同时也涉及语法树的解析处理,lombok所需的插件便是类似作用,其中详细知识笔者也是一知半解,便不在此过度展开。那么这个步骤是不是又与某个内容关联起来了?是的,这其中也有赖于Java的SPI服务发现机制,对于约定下新增的具体实现也会被纳入执行范围内。
四、使用优势
回到本次的lombok和mapstruct上,在使用mapstruct后,类转换和拷贝可以说完全在编译期实现,对服务运行不会再造成性能上的影响,组内原先使用的反射的方式被替换后,各项指标立马陡降50%,其中CPU使用率从30%+到15%左右,这还是没有全部替换的情况下。
不过,将事情放到编译期去做,在执行不规范的情况下,很有可能会打乱原有的编译流程,这也是lombok和mapstruct使用时容易出现问题的原因,尤其是同时使用时,如果mapstruct的处理流程在lombok之前,就会导致set与get方法缺失,从而引发一系列问题,或是注解处理器指定后使原先Java自带的注解处理器被覆盖无法运行,如此种种,不一而足,如今也有足够多的博客去列出各种情况的解决方案,笔者便不再赘述。
五、关于反射
其实,MapStruct带来的优势,很大一部分程度是使用反射进行对象拷贝和类转换的劣势。反射这种方式由于使用简便,功能强大,但又存在性能损耗,很容易被滥用,大家过度的关注磁盘IO、网络IO这些瓶颈,却忽视了“小小的损耗”,积沙成塔,这与IPC和RPC分离的历史相似。
RPC作为服务间远程调用的方式,被是作为IPC的一种特例,早先在被设计时,是希望同IPC一般,直接被使用,将具体实现流程隐藏在操作系统层面,而使用者无需关注其中的细节,但很快设计者们就发现由于RPC内部细节被屏蔽后,导致RPC的滥用,使用者错误地认为RPC是毫无时间损耗的。
而反射的性能损耗从何而来?如果有关注反射的人也许会知道,反射是通过Method从Class类向JVM获取内容的,实际上是通过类加载器重新获取了一遍类,在类的元数据中寻找,涉及到更多的指针和内存操作,增加了开销,至于安全性检查、拆装箱等内容,在此也不再过多赘述。
实际上,可以通过关闭安全检查、缓存反射构造器来降低反射的影响,当然具体情况需要具体讨论,笔者也仅仅是在试图了解更多反射相关的知识时接触到这些内容的,并未在具体场景中实践过。
标签:反射,lombok,Java,MapStruct,编译,处理器,注解,Lombok,浅析 From: https://www.cnblogs.com/youngfun/p/18438195