方法引用与lambda底层原理Java方法引用、lambda能被序列化么?

news/2025/1/12 21:20:35/文章来源:https://www.cnblogs.com/cuzzz/p/18667342

系列文章目录和关于我

0.引入

最近笔者使用flink实现一些实时数据清洗(从kafka清洗数据写入到clickhouse)的功能,在编写flink作业后进行上传,发现运行的时候抛出:java.io.NotSerializableException,错误消息可能类似于 “org.apache.flink.streaming.api.functions.MapFunction implementation is not serializable”的错误。该错误引起了我的好奇:

  1. flink为什么要把map,filter这些function interface 进行序列化?
  2. 方法引用或者lambda如何进行序列化?

1.什么是flink、flink为什么要把map,filter这些function interface 进行序列化?

Apache Flink 是一个开源的分布式流批一体化处理框架。它能高效地处理无界(例如:前端埋点数据,只要用户在使用那么会源源不断的产生数据)和有界(例如:2024年的所有交易数据)数据流,并且提供了准确的结果,即使在面对乱序或者延迟的数据时也能很好地应对。Flink 在大数据处理领域应用广泛,可用于实时数据分析、事件驱动型应用、数据管道等多种场景。

如下是一个典型数据管道应用

public class SimpleDataPipelineExample {public static void main(String[] args) throws Exception {// 1. 创建执行环境StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();// 2. 定义数据源,这里简单模拟一个包含字符串的集合作为数据源// 可以想象这里是从kafka中读取数据DataStream<String> inputDataStream = env.fromElements("hello", "world", "flink");// 3. 对数据进行转换操作,这里将每个字符串转换为大写形式// 这里要去map(xxx),filter(xxx) 可以序列化DataStream<String> transformedDataStream = inputDataStream.map(String::toUpperCase).filter(s->s.length(s)>0);// 4. 定义输出,// flink中addSink就是定义数据最终存储到何处transformedDataStream.addSink(new org.apache.flink.streaming.api.functions.sink.PrintSinkFunction<>());// 5. 执行任务env.execute("Simple Data Pipeline Example");}
}

可以看到flink中的编程方式有点类似于java8中的stream,但是我们编写stream流代码的时候,并不需要刻意关注流中的function interface对象是否要序列化,那么flink为什么强制要求能序列化呢?

分布式环境下的任务分发与执行需求

  • Flink 是一个分布式处理框架,任务会被分发到集群中的多个节点上执行。当在DataStreamDataSet上应用mapfilter等操作时,这些操作对应的函数(如MapFunctionFilterFunction)定义了具体的数据处理逻辑。
  • 为了能够将这些处理逻辑发送到不同的计算节点,需要对这些函数进行序列化。例如,假设有一个 Flink 集群包含多个节点,在一个节点上定义了一个DataStream并应用了map操作,其map函数是对输入数据进行某种复杂的转换。这个map函数需要被序列化,以便可以传输到其他节点,从而在整个集群中正确地执行数据转换任务。

2.方法引用和lambda如何被序列化

解释完为什么flink要序列化map,filter这些function interface对象,接下来用一个简单例子来分析下方法引用和lambda如何被序列化

public class SimpleTest {public static void main(String[] args) {List<Integer> list = Arrays.asList(1, 2, 3);list.stream().filter(e -> e % 2 == 0)// 这是一个lambda表式.map(String::valueOf)//这是一个方法引用.forEach(System.out::println);}
}

2.1 对象如何被序列化

如下是一个Java对象使用ObjectOutputStream进行序列化,并打印序列化内容的例子

import java.io.*;
import java.util.Arrays;
import java.util.List;public class SimpleTest2 {static class Test implements Serializable {private int a;public int getA() {return a;}public void setA(int a) {this.a = a;}}public static void main(String[] args) throws Exception {Test t = new Test();t.setA(1);ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(10000);ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);objectOutputStream.writeObject(t);objectOutputStream.flush();objectOutputStream.close();System.out.println(byteArrayOutputStream.toString());}
}

可以看到会判断对象是不是实现了Serializable,没有实现会抛出异常

image-20250112205934355

如果实现了那么先写类的描述信息(类名,是否可序列化,字段个数等等)进一步判断是否实现了Externalizable,Externalizable支持我们自定义序列化和反序列化的方法,接着会写每一个字段的值:

image-20250112205948551

可以看到本质上类似于JSON序列化,有自己的对象序列化协议。

2.2 方法引用和lamda如何被序列化,方法引用和lambda是对象么

Java中一切皆对象,虽然方法引用和lambda看似和对象不同(没有被new出来)但是本质上仍然是一个对象。可以通过下面两张方式验证:

  1. idea断点

    可以看到是一个SimpleTest$$Lambda$xx类的实例对象

    image-20250112210123993

  2. 字节码层面

    image-20250112210151770

​ 可以看到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是如何生成对象的

INVOKEDYNAMIC apply()Ljava/util/function/Function; [// handle kind 0x6 : INVOKESTATICjava/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;// arguments:(Ljava/lang/Object;)Ljava/lang/Object;, // handle kind 0x6 : INVOKESTATICjava/lang/String.valueOf(Ljava/lang/Object;)Ljava/lang/String;, (Ljava/lang/Integer;)Ljava/lang/String;]

如上的字节码对应stream中的map执行

image-20250112210230646

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 操作这样的场景中,作为 Streammap 方法的参数,使得流里的元素可以按照这个 Function 接口实现对象所定义的逻辑进行转换。

类似地,对于 filter 操作对应的 Predicate 接口,也是通过同样的机制,只是具体的参数(比如方法签名、指向的实现逻辑对应的方法等)会根据对应的 Lambda 表达式或具体实现方法有所不同,来生成符合 Predicate 接口要求的对象,进而用于流元素的筛选操作。 所以说,INVOKEDYNAMIC 结合 LambdaMetafactory.metafactory 的这套机制就是在字节码层面实现了在运行时动态生成适配函数式接口对象的关键所在。

4.用 LambdaMetafactory.metafactory生成CallSite调用String#valueOf

import java.lang.invoke.CallSite;
import java.lang.invoke.LambdaMetafactory;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.function.Function;public class LambdaMetafactoryCallSiteExample {public static void main(String[] args) throws Throwable {// 1. 获取查找上下文(caller),代表调用者的查找上下文及访问权限MethodHandles.Lookup lookup = MethodHandles.lookup();// 2. 定义invokedName,即要实现的方法名称,这里对应Function接口的apply方法名String invokedName = "apply";// 3. 定义invokedType,CallSite预期的签名,返回类型是要实现的接口(这里是Function接口)// 参数类型(这里无捕获变量,所以为空),返回类型为Function接口类型MethodType invokedType = MethodType.methodType(Function.class);// 4. 定义samMethodType,函数对象要实现的方法的签名和返回类型// 对于Function接口的apply方法,接收Object类型参数,返回Object类型结果MethodType samMethodType = MethodType.methodType(Object.class, Object.class);// 5. 定义implMethod,指向具体实现逻辑的方法句柄,即String类的静态方法valueOfMethodHandle implMethodHandle = lookup.findStatic(String.class, "valueOf", MethodType.methodType(String.class, Object.class));// 6. 定义instantiatedMethodType,调用时动态强制执行的签名和返回类型,这里和samMethodType保持一致MethodType instantiatedMethodType = samMethodType;// 7. 使用LambdaMetafactory.metafactory生成CallSiteCallSite callSite = LambdaMetafactory.metafactory(lookup,invokedName,invokedType,samMethodType,implMethodHandle,instantiatedMethodType);// 8. 获取生成的函数式接口实例(这里是Function接口实例)Function<Object, String> function = (Function<Object, String>) callSite.getTarget().invoke();// 9. 使用生成的函数式接口实例进行操作String result = function.apply(42);System.out.println("Result: " + result);}
}

至此我们明白了Stream.map传入方法引用的时候,其实是使用LambdaMetafactory.metafactory生成callSite然后生成Function,这个Function保存在流的内部,当流开始执行的时候会调用Function对应的方法

5.lambda生成的静态方法在哪里

image-20250112210300441

如上字节码对应filter的执行逻辑

image-20250112210316168

可以看到这里其实是用了INVOKESTATIC来调用SimpleTest.lambda$main$0方法,也就说说filter的执行类似map,也是用LambdaMetafactory.metafactory生成callSite然后生成Function,但是这个Function的执行是使用INVOKESTATIC来执行生成的SimpleTest.lambda$main$0方法。

INVOKESTATIC指令的核心功能就是发起对一个类中静态方法的调用操作。它允许在字节码层面直接指定要调用的类以及对应的静态方法,并且按照方法定义传递相应的参数,执行完该静态方法后,根据方法的返回类型获取返回结果(如果有返回值的话)

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;public class SimpleTest {public static void main(String[] args) {List<Integer> list = Arrays.asList(1, 2, 3);list.stream().filter(e -> e % 2 == 0).map(String::valueOf).forEach(System.out::println);for (Method method : SimpleTest.class.getDeclaredMethods()) {System.out.println(method.getName());}}
}

执行这段程序可以看到输出了

2//流的打印
main//SimpleTest中有main方法
lambda$main$0//还有个叫lambda$main$0的方法

该类的字节码也可以看到存在lambda$main$0(表示是main方法中第一个lambda)

image-20250112210329533

在 Java 中,Lambda 表达式本质上是一种匿名函数的语法糖,编译器会将其转换为一个对应的方法,并在合适的地方生成相应的字节码来调用这个方法。对于像你展示的这种带有一定逻辑判断的 Lambda 表达式(从字节码中可以看出包含了加载参数、方法调用、算术运算以及条件跳转等操作),编译器会按照一定的规则来生成对应的字节码表示的方法,使其能实现 Lambda 表达式所定义的逻辑功能。

具体是如何生成方法对应字节码的,这就是JVM对应功能实现了,笔者还没有进一步查看JVM源码。

5.个人思考

lambda和方法引用是Java8新增的语法糖,针对Java开发者来说提供了函数式编程更加简洁的写法,虽然看起来和原来面向命令编程有很大的区别,但是底层还是Java 方法调用那一套。

新语法糖的引入并没有打破底层原有逻辑,而是通过引入新的INVOKEDYNAMIC和LambdaMetafactory.metafactory 将新语法糖嫁接到原来的方法调用实现上,这也是一种开闭原则的体现,这样实现的好处是:影响面可控,如果开发一个新功能要打破原有架构,原有代码,那么回归覆盖测试的范围将不可控。另外lambda和方法底层的使用对开发者完全透明,对开发者友好。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/868355.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java方法引用、lambda如何序列化方法引用与lambda底层原理

系列文章目录和关于我 0.引入 最近笔者使用flink实现一些实时数据清洗(从kafka清洗数据写入到clickhouse)的功能,在编写flink作业后进行上传,发现运行的时候抛出:java.io.NotSerializableException,错误消息可能类似于 “org.apache.flink.streaming.api.functions.MapFu…

2024年秋学期 分析力学(理论物理基础Ⅰ)笔记

内容说明舍去了哈密顿雅可比方程等内容 删去了振动相关的一些模型,如参数共振等 授课难度疑似过大了一点(毕竟才半个学期),协变相关内容疑似太tm多了 有心力场模块笔记有所省略,部分笔记不排除记录有误的可能性 部分章节间未换页笔记正文拉格朗日力学及其协变形式 哈密顿力…

find_package()使用指南

关于find_package() 在使用cmake引用第三方库(比如OpenCV)时,我们总是使用find_package()这个指令来实现对包的查找(比如find_package(OpenCV))。调用完后就可以使用一些似乎凭空出现的变量如${OpenCV_INCLUDE_DIRS}以及${OpenCV_LIBS},分别指示了OpenCV库的头文件路径以及各个…

音视频:JavaCV 采集桌面画面并显示的方法

需要进行简单的音视频编程,如果不是特别熟悉C/C++,那么JavaCV应该是比较好的选择,下面记录一下使用JavaCV采集桌面数据的方法。 使用FFmpegFrameGrabber采集桌面数据,指定输入源为desktop,format设置为gdigrab。参考雷神的博客:https://blog.csdn.net/leixiaohua1020/art…

《深入理解Mybatis原理》MyBatis事务管理机制

概述 对数据库的事务而言,应该具有以下几点:创建(create)、提交(commit)、回滚(rollback)、关闭(close)。对应地,MyBatis将事务抽象成了Transaction接口:MyBatis的事务管理分为两种形式:使用JDBC的事务管理机制:即利用java.sql.Connection对象完成对事务的提交(…

【Python基础】Python虚拟环境

什么是Python环境 要搞清楚什么是虚拟环境,首先要清楚Python的环境指的是什么。当我们在执行python test.py时,思考如下问题:python哪里来? 这个主要归功于配置的系统环境变量PATH,当我们在命令行中运行程序时,系统会根据PATH配置的路径列表依次查寻是否有可执行文件pyth…

地平线 LiDAR-Camera 融合多任务 BEVFusion 参考算法-V1.0

该示例为参考算法,仅作为在 征程 6 上模型部署的设计参考,非量产算法。1.简介 激光雷达天然地具有深度信息,摄像头可以提供丰富的语义信息,它们是车载视觉感知系统中两个最关键的传感器。但是,如果激光雷达或者摄像头发生故障,则整个感知框架不能做出任何预测,这在根本上…

47. web框架

1. web框架本质 web框架本质上可以看成一个功能强大的socket服务端,用户的浏览器可以看成拥有可视化界面的socket客户端 通过网络请求实现数据交互,可简单的将web框架看做是对前端、数据库的全方位整合2. 手写简易版web框架 服务端import socketserver = socket.socket() ser…

记一次生产事故:一年的数据被删除了

故事开始快要下班了,心情是这样的。 突然电话响了,一看电话号码是项目甲方负责人的号码菊花一紧,难道出什么问题了,硬着头皮接通了电话。@####@,你们程序怎么回事 ,某个业务的数据界面一条都查询不到了。。。。。我们马上排查一下,看看那什么情况(心理活动:尼玛,尼玛…

【Azure App Service】对App Service中CPU指标数据中系统占用部分(System CPU)的解释

问题描述 在使用Azure App Service服务时,对于它的CPU占比,从App Service Plan级别可以查看整个实例(vm)资源的CPU占比,而如果在具体的一个App Service服务中,则只能查看到当前应用所消耗的CPU时间,如果需要计算它的占比:需要使用公式【 CPU Time / (CPU核数 * 60 )】估算…

2024秋季学期 光学期末复习笔记

累了,懒得写文案了参考资料 [1] 赵凯华, 钟锡华. 光学(重排本)[M]. 北京大学出版社, 2017.10. [2] 崔宏滨, 李永平, 康学亮. 光学(第二版)[M]. 科学出版社, 2015.7. [3] 王安廷. 光学课程PPT[Z]. 中国科学技术大学, 2024. 干涉衍射偏振

Python 基础知识 之 选择(分支)结构 + 模式匹配结构(match)

选择结构按照条件选择执行不同的代码段1. 单分支结构if语法结构执行流程:如果表达式的值为True,就执行语句块,如果表达式的值为False,就跳过语句块,继续执行下面的语句 ⭐注意: ⭐⭐⭐表达式后面的冒号; 缩进,python中通过缩进来控制程序逻辑示例;# 1. 判断是否中奖 n…