方法引用与 lambda 底层原理 &Java 方法引用、lambda 能被序列化么?
0.引入
最近笔者使用 flink 实现一些实时数据清洗(从 kafka 清洗数据写入到 clickhouse)的功能,在编写 flink 作业后进行上传,发现运行的时候抛出:java.io.NotSerializableException
,错误消息可能类似于 “org.apache.flink.streaming.api.functions.MapFunction implementation is not serializable”的错误。该错误引起了我的好奇:
flink 为什么要把 map,filter 这些 function interface 进行序列化?
方法引用或者 lambda 如何进行序列化?
1.什么是 flink、flink 为什么要把 map,filter 这些 function interface 进行序列化?
Apache Flink 是一个开源的分布式流批一体化处理框架。它能高效地处理无界(例如:前端埋点数据,只要用户在使用那么会源源不断的产生数据)和有界(例如:2024 年的所有交易数据)数据流,并且提供了准确的结果,即使在面对乱序或者延迟的数据时也能很好地应对。Flink 在大数据处理领域应用广泛,可用于实时数据分析、事件驱动型应用、数据管道等多种场景。
如下是一个典型数据管道应用
可以看到 flink 中的编程方式有点类似于 java8 中的 stream,但是我们编写 stream 流代码的时候,并不需要刻意关注流中的 function interface 对象是否要序列化,那么 flink 为什么强制要求能序列化呢?
分布式环境下的任务分发与执行需求
Flink 是一个分布式处理框架,任务会被分发到集群中的多个节点上执行。当在
DataStream
或DataSet
上应用map
、filter
等操作时,这些操作对应的函数(如MapFunction
、FilterFunction
)定义了具体的数据处理逻辑。为了能够将这些处理逻辑发送到不同的计算节点,需要对这些函数进行序列化。例如,假设有一个 Flink 集群包含多个节点,在一个节点上定义了一个
DataStream
并应用了map
操作,其map
函数是对输入数据进行某种复杂的转换。这个map
函数需要被序列化,以便可以传输到其他节点,从而在整个集群中正确地执行数据转换任务。
2.方法引用和 lambda 如何被序列化
解释完为什么 flink 要序列化 map,filter 这些 function interface 对象,接下来用一个简单例子来分析下方法引用和 lambda 如何被序列化
2.1 对象如何被序列化
如下是一个 Java 对象使用 ObjectOutputStream 进行序列化,并打印序列化内容的例子
可以看到会判断对象是不是实现了 Serializable,没有实现会抛出异常
如果实现了那么先写类的描述信息(类名,是否可序列化,字段个数等等)进一步判断是否实现了 Externalizable,Externalizable 支持我们自定义序列化和反序列化的方法,接着会写每一个字段的值:
可以看到本质上类似于 JSON 序列化,有自己的对象序列化协议。
2.2 方法引用和 lamda 如何被序列化,方法引用和 lambda 是对象么
Java 中一切皆对象,虽然方法引用和 lambda 看似和对象不同(没有被 new 出来)但是本质上仍然是一个对象。可以通过下面两张方式验证:
1、idea 断点
可以看到是一个 SimpleTest$Lambda$xx 类的实例对象
2、字节码层面
可以看到 filter 对应的 lamda 最终会调用 SimpleTest.lambda$main$0(Ljava/lang/Integer;)Z,方法引用则有所不同调用并没有生成一个独特的方法?这是为什么呢?
1、Lambda 表达式生成静态方法的原因
在 Java 编译器处理 Lambda 表达式时,对于在
main
方法(或其他非实例方法)内部定义的 Lambda 表达式,它会生成一个静态私有方法来实现 Lambda 表达式的逻辑。这是因为在这个场景下,没有合适的实例来关联这个 Lambda 表达式的逻辑。以filter(e -> e % 2 == 0)
为例,这个 Lambda 表达式的逻辑需要一个独立的方法来承载。生成的方法被命名为
lambda$main$0
,其中main
表示所在的主方法,0
表示这是在main
方法中生成的第一个 Lambda 表达式对应的方法。这种命名方式有助于编译器在内部管理和引用这些自动生成的方法。
2、方法引用与 Lambda 表达式在字节码生成上的区别
对于方法引用(如
String::valueOf
),它不需要像 Lambda 表达式那样生成一个新的静态方法。这是因为方法引用本身就是指向一个已经存在的方法。在字节码生成过程中,字节码指令会直接利用这个已有的方法。以
INVOKEDYNAMIC apply()Ljava/util/function/Function;
部分为例,字节码通过java/lang/String.valueOf(Ljava/lang/Object;)Ljava/lang/String;
直接指向了String
类中已有的valueOf
方法,这个方法会在map
操作的实际执行过程中被调用,用于将流中的元素转换为字符串。它不需要像 Lambda 表达式那样额外生成一个新的方法来承载逻辑,因为方法引用所引用的方法已经有了明确的定义和实现。
至此我们明白了方法引用和 lambda 是如何执行的——Lambda 表达式生成静态方法,方法引用则是调用 INVOKESTATIC 指令调用到对应的方法
那么 lambda 和方法引用对应生成的对象在哪里呢?
3 INVOKEDYNAMIC 是如何生成对象的
如上的字节码对应 stream 中的 map 执行
INVOKEDYNAMIC
指令的核心作用之一就是在运行时动态地生成对象(准确说是生成调用点 CallSite
以及对应的可调用对象等相关机制来实现类似生成对象的效果),用于适配相应的函数式接口,比如这里的 Function
接口。
LambdaMetafactory.metafactory 方法的逻辑
java/lang/invoke/LambdaMetafactory.metafactory
方法在这个过程中起着关键作用,下面来详细解析一下它相关参数对应的逻辑以及整体是如何实现生成符合要求对象的:
1、参数说明:
MethodHandles$Lookup
参数:它提供了一种查找和访问方法的机制,决定了可以访问哪些类以及这些类中的哪些方法等权限相关内容。简单来说,它用于定位后续所涉及方法的 “查找上下文”,确保能够正确找到要使用的方法。String
参数:通常是一个名称,用于标识生成的这个调用点(CallSite
)相关的逻辑等,不过在实际常见使用场景下,它的作用相对不是特别直观地体现给开发者。MethodType
参数(多个):第一个MethodType
描述了所生成的函数式接口实现的方法整体的类型签名,比如对于Function
接口对应的这里就是(Ljava/lang/Object;)Ljava/lang/Object;
,意味着生成的实现Function
接口的对象其apply
方法接收一个Object
类型的对象作为输入,然后返回一个Object
类型的对象作为输出(这是从通用、抽象层面描述的接口方法签名情况)。第二个MethodType
对应着具体实现逻辑的方法(也就是实际指向的那个已有方法或者对应的 Lambda 表达式转化后的方法等)的类型签名,像此处指向java/lang/String.valueOf
方法,其签名是(Ljava/lang/Object;)Ljava/lang/String;
,表明它接收一个Object
类型的输入并返回一个String
类型的输出。第三个MethodType
则再次强调了在具体使用场景下(结合当前流中元素类型等实际情况)的方法签名,比如这里针对map
操作中流里是Integer
类型元素,所以是(Ljava/lang/Integer;)Ljava/lang/String;
,也就是说明这个动态生成的Function
接口实现对象在应用于当前map
操作时,其apply
方法接收Integer
类型的输入并返回String
类型的输出。MethodHandle
参数:它用于指向具体实现逻辑的方法,在这个例子中就是指向java/lang/String.valueOf
这个已有的静态方法,相当于告诉LambdaMetafactory
具体通过调用哪个方法来实现Function
接口的apply
方法所要求的逻辑。
2、整体生成对象的过程:
LambdaMetafactory.metafactory
方法基于这些参数,在运行时会根据函数式接口(这里是Function
接口)的定义以及所指定的具体实现逻辑(通过String.valueOf
方法),动态地构造出一个符合该接口要求的对象(也就是实现了Function
接口,并且其apply
方法在调用时会按照指向的String.valueOf
方法来执行相应逻辑)。这个生成的对象随后就能被用于像map
操作这样的场景中,作为Stream
中map
方法的参数,使得流里的元素可以按照这个Function
接口实现对象所定义的逻辑进行转换。
类似地,对于 filter
操作对应的 Predicate
接口,也是通过同样的机制,只是具体的参数(比如方法签名、指向的实现逻辑对应的方法等)会根据对应的 Lambda 表达式或具体实现方法有所不同,来生成符合 Predicate
接口要求的对象,进而用于流元素的筛选操作。 所以说,INVOKEDYNAMIC
结合 LambdaMetafactory.metafactory
的这套机制就是在字节码层面实现了在运行时动态生成适配函数式接口对象的关键所在。
4.用 LambdaMetafactory.metafactory 生成 CallSite 调用 String#valueOf
至此我们明白了 Stream.map 传入方法引用的时候,其实是使用 LambdaMetafactory.metafactory 生成 callSite 然后生成 Function,这个 Function 保存在流的内部,当流开始执行的时候会调用 Function 对应的方法
5.lambda 生成的静态方法在哪里
如上字节码对应 filter 的执行逻辑
可以看到这里其实是用了 INVOKESTATIC 来调用 SimpleTest.lambda$main$0 方法,也就说说 filter 的执行类似 map,也是用 LambdaMetafactory.metafactory 生成 callSite 然后生成 Function,但是这个 Function 的执行是使用INVOKESTATIC
来执行生成的 SimpleTest.lambda$main$0 方法。
INVOKESTATIC指令的核心功能就是发起对一个类中静态方法的调用操作。它允许在字节码层面直接指定要调用的类以及对应的静态方法,并且按照方法定义传递相应的参数,执行完该静态方法后,根据方法的返回类型获取返回结果(如果有返回值的话)
执行这段程序可以看到输出了
该类的字节码也可以看到存在 lambda$main$0(表示是 main 方法中第一个 lambda)
在 Java 中,Lambda 表达式本质上是一种匿名函数的语法糖,编译器会将其转换为一个对应的方法,并在合适的地方生成相应的字节码来调用这个方法。
具体是如何生成方法对应字节码的,这就是 JVM 对应功能实现了,笔者还没有进一步查看 JVM 源码。
5.个人思考
lambda 和方法引用是 Java8 新增的语法糖,针对 Java 开发者来说提供了函数式编程更加简洁的写法,虽然看起来和原来面向命令编程有很大的区别,但是底层还是 Java 方法调用那一套。
新语法糖的引入并没有打破底层原有逻辑,而是通过引入新的 INVOKEDYNAMIC 和 LambdaMetafactory.metafactory 将新语法糖嫁接到原来的方法调用实现上,这也是一种开闭原则的体现,这样实现的好处是:影响面可控,如果开发一个新功能要打破原有架构,原有代码,那么回归覆盖测试的范围将不可控。另外 lambda 和方法底层的使用对开发者完全透明,对开发者友好。
文章转载自:Cuzzz
评论