Java 值传递详解
说到参数,我们先来搞懂一下这两个概念
-
形参&实参
-
值传递&引用传递
形参&实参
方法的定义可能会用到 参数(有参的方法),参数在程序语言中分为:
-
实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。
-
形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。
String hello = "Hello!";
// hello 为实参
sayHello(hello);
// str 为形参
void sayHello(String str) {System.out.println(str);
}
值传递&引用传递
程序设计语言将实参传递给方法(或函数)的方式分为两种:
-
值传递:方法接收的是实参值的拷贝,会创建副本。
-
引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。
很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递
为啥Java中只有值传递呀?
我们先来看一个传递基本类型参数的案例:
public static void main(String[] args) {int num1 = 10;int num2 = 20;swap(num1, num2);System.out.println("num1 = " + num1);System.out.println("num2 = " + num2);
}
public static void swap(int a, int b) {int temp = a;a = b;b = temp;System.out.println("a = " + a);System.out.println("b = " + b);
}
输出:
a = 20
b = 10
num1 = 10
num2 = 20
在 swap()
方法中,a
、b
的值进行交换,并不会影响到 num1
、num2
。因为,a
、b
的值,只是从 num1
、num2
的复制过来的。也就是说,a、b 相当于 num1
、num2
的副本,副本的内容无论怎么修改,都不会影响到原件本身
通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,但使用对象引用作为参数就不一样
public static void main(String[] args) {int[] arr = { 1, 2, 3, 4, 5 };System.out.println(arr[0]);change(arr);System.out.println(arr[0]);}
public static void change(int[] array) {// 将数组的第一个元素变为0array[0] = 0;}
输出:
1
0
看了这个案例是不是觉得对于引用类型的参数就是使用引用传递呢?其实并没有,这里传递的是我们实参的地址,方法中的参数是拷贝了实参的地址进行传递,因此使用这个地址是指向同一个数组对象
我们再来看一个案例:public class Person {private String name;// 省略构造函数、Getter&Setter方法
}
public static void main(String[] args) {Person xiaoZhang = new Person("小张");Person xiaoLi = new Person("小李");swap(xiaoZhang, xiaoLi);System.out.println("xiaoZhang:" + xiaoZhang.getName());System.out.println("xiaoLi:" + xiaoLi.getName());
}
public static void swap(Person person1, Person person2) {Person temp = person1;person1 = person2;person2 = temp;System.out.println("person1:" + person1.getName());System.out.println("person2:" + person2.getName());
}
输出:
person1:小李
person2:小张
xiaoZhang:小张
xiaoLi:小李
swap
方法的参数 person1
和 person2
只是拷贝的实参 xiaoZhang
和 xiaoLi
的地址。因此, person1
和 person2
的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhang
和 xiaoLi
。
引用传递是怎么样的?
看到这里,我们已经知道了 Java 中只有值传递,是没有引用传递的。那到底什么是引用传递呢?比如 C++ 提供了真正的引用传递机制,允许你直接传递变量的引用,并且可以在函数或方法中改变这个引用的指向
#include <iostream>
void incr(int& num)
{std::cout << "incr before: " << num << "\n";num++;std::cout << "incr after: " << num << "\n";
}
int main()
{int age = 10;std::cout << "invoke before: " << age << "\n";incr(age);std::cout << "invoke after: " << age << "\n";
}
输出:
invoke before: 10
incr before: 10
incr after: 11
invoke after: 11
可以看到,在 incr
函数中对形参的修改,可以影响到实参的值。
要注意:这里的
incr
形参的数据类型用的是int&
才为引用传递,如果是用int
的话还是值传递哦!
为什么Java不使用引用传递呢?
引用传递看似很好,能在方法内就直接把实参的值修改了,但是,为什么 Java 不引入引用传递呢?
-
复杂性增加:引入引用传递会增加语言的复杂性,需要考虑更多的边界情况和特殊处理。这样会增加学习成本,并且容易出现错误。
-
难以预测:引用传递会使得代码的行为难以预测。当一个方法修改了传递进来的对象时,其他使用该对象的地方也会受到影响,这可能导致程序的行为变得不可控。
-
安全性降低:引用传递可能会导致对象的状态被意外修改,从而引发潜在的错误和安全问题
-
出于安全考虑,方法内部对值进行的操作,对于调用者都是未知的(把方法定义为接口,调用方不关心具体实现)。你也想象一下,如果拿着银行卡去取钱,取的是 100,扣的是 200,直接倒下
Java 中将实参传递给方法(或函数)的方式是 值传递:
-
如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本
-
如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本
序列化
什么是序列化和反序列化?
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
-
序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式
-
反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程
对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型
面是序列化和反序列化常见应用场景:
-
对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
-
将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
-
将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
-
将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化
序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中
序列化协议对应于 TCP/IP 4 层模型的哪一层?
网络通信的双方必须要采用和遵守相同的协议。TCP/IP 四层模型是下面这样的,序列化协议属于哪一层呢?
-
应用层
-
传输层
-
网络层
-
网络接口层
如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这就对应的是序列化和反序列化
OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分
常见序列化协议有哪些?
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议
像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择
JDK 自带的序列化方式
JDK 自带的序列化,只需实现 java.io.Serializable
接口即可
public class RpcRequest implements Serializable {private static final long serialVersionUID = 1905122041950251207L;private String requestId;private String interfaceName;private String methodName;private Object[] parameters;private Class<?>[] paramTypes;private RpcMessageTypeEnum rpcMessageTypeEnum;
}
serialVersionUID 有什么作用?
序列化号 serialVersionUID
属于版本控制的作用。反序列化时,会检查 serialVersionUID
是否和当前类的 serialVersionUID
一致。如果 serialVersionUID
不一致则会抛出 InvalidClassException
异常。强烈推荐每个序列化类都手动指定其 serialVersionUID
,如果不手动指定,那么编译器会动态生成默认的 serialVersionUID
serialVersionUID
不是被 static 变量修饰了吗?为什么还会被“序列化”?
static
修饰的变量是静态变量,属于类而非类的实例,本身是不会被序列化的。然而,serialVersionUID
是一个特例,serialVersionUID
的序列化做了特殊处理。当一个对象被序列化时,serialVersionUID
会被写入到序列化的二进制流中;在反序列化时,也会解析它并做一致性判断,以此来验证序列化对象的版本一致性。如果两者不匹配,反序列化过程将抛出InvalidClassException
,因为这通常意味着序列化的类的定义已经发生了更改,可能不再兼容。
serialVersionUID
只是用来作为被 JVM 识别的一种标识而已,实际上它并没有被序列化
如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,可以使用 transient
关键字修饰
transient
关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient
修饰的变量值不会被持久化和恢复
关于
transient
还有几点注意:
transient
只能修饰变量,不能修饰类和方法
transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化
为什么不推荐使用 JDK 自带的序列化?
-
不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了
-
性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大
-
存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读:应用安全:JAVA 反序列化漏洞之殇 - CryinJava 反序列化安全漏洞怎么回事? - Monica
Kryo
Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积
另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用
kryo
序列化和反序列化相关的代码如下:
/*** Kryo serialization class, Kryo serialization efficiency is very high, but only compatible with Java language** @author shuang.kou* @createTime 2020年05月13日 19:29:00*/
@Slf4j
public class KryoSerializer implements Serializer {
/*** Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects*/private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {Kryo kryo = new Kryo();kryo.register(RpcResponse.class);kryo.register(RpcRequest.class);return kryo;});
@Overridepublic byte[] serialize(Object obj) {try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();Output output = new Output(byteArrayOutputStream)) {Kryo kryo = kryoThreadLocal.get();// Object->byte:将对象序列化为byte数组kryo.writeObject(output, obj);kryoThreadLocal.remove();return output.toBytes();} catch (Exception e) {throw new SerializeException("Serialization failed");}}
@Overridepublic <T> T deserialize(byte[] bytes, Class<T> clazz) {try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);Input input = new Input(byteArrayInputStream)) {Kryo kryo = kryoThreadLocal.get();// byte->Object:从byte数组中反序列化出对象Object o = kryo.readObject(input, clazz);kryoThreadLocal.remove();return clazz.cast(o);} catch (Exception e) {throw new SerializeException("Deserialization failed");}}
}
GitHub 地址:GitHub - EsotericSoftware/kryo: Java binary serialization and cloning: fast, efficient, automatic
Protobuf
Protobuf 出自于 Google,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险
Protobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下你需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言
一个简单的 proto 文件如下:
// protobuf的版本
syntax = "proto3";
// SearchRequest会被编译成不同的编程语言的相应对象,比如Java中的class、Go中的struct
message Person {//string类型字段string name = 1;// int 类型字段int32 age = 2;
}
GitHub 地址:GitHub - protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format
ProtoStuff
由于 Protobuf 的易用性较差,它的哥哥 Protostuff 诞生了
protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差
GitHub 地址:GitHub - protostuff/protostuff: Java serialization library, proto compiler, code generator。
Hessian
Hessian 是一个轻量级的,自定义描述的二进制 RPC 协议。Hessian 是一个比较老的序列化实现了,并且同样也是跨语言的
Dubbo2.x 默认启用的序列化方式是 Hessian2 ,但是,Dubbo 对 Hessian2 进行了修改,不过大体结构还是差不多
Kryo 是专门针对 Java 语言序列化方式并且性能非常好,如果你的应用是专门针对 Java 语言的话可以考虑使用,并且 Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式 (文章地址:https://cn.dubbo.apache.org/zh-cn/docsv2.7/user/serialization/)
像 Protobuf、 ProtoStuff、hessian 这类都是跨语言的序列化方式,如果有跨语言需求的话可以考虑使用