【重难点】【Java基础 03】重写hashCode() 和equals()、
一、hashCode() 和 equals()
1.对比
我们来看看官方文档对这两个方法的解释
官方文档对这个规范翻译得会有点拗口,我们来简化一下
- hashCode 和 equals 返回值应该是稳定的,不应该具有随机性
- 两个对象用运算符 == ,如果结果为 true,则对这两个对象用 equals() 也应该返回 true
事实上,如果没有重写 equals(),它的源码就只有一句:return (this == obj);其中 obj 是形式参数 - 如果对两个对象用 equals() 返回 true,那么这两个对象的 hashCode 也应该相等
equals() 方法的源码我点进去看的时候发现没有方法体(后来知道 hashCode() 是一个原生函数),我想知道的是 equals() 在重写之前,到底返回的是什么。好在后来找到了:hashCode() 返回什么具体要看 JVM 是怎么处理的,而 JVM 版本不同返回的东西也不同。确实有的 JVM 是直接返回对象的存储地址,但是大多数情况不是这样的,甚至和存储地址毫无关联,比如说 HotSpot 返回的就不是内存地址,OpenJDK 里计算 hashCode() 也根本没用到存储地址
HotSpot 不是什么陌生的版本,我们电脑装的就是 HotSpot,也是目前绝对的主流版本,其他还有 J9 VM 、JRockit 等等
当大家说起“Java性能如何如何”、“Java有多少种GC”、“JVM如何调优”云云,经常默认说的就是特指 HotSpot VM - == 比较的是两个对象的地址
解释一下什么原生函数,一个方法是原生函数,也就是说这个方法的实现不是用 Java 语言实现的,而是使用 C/C++ 实现的,并且被编译成了 DDL(Dynamic Link Library,动态链接库),由 Java 去调用,而 JDK 源码中并不包含。对于不同的平台它们是不同的,Java 在不同的操作系统调用不同的 native 方法实现对操作系统的访问,因为 Java 语言没有指针,所以不能直接访问操作系统底层
这种方法调用的过程:
- 在 Java 中声明 native 方法,然后编译
- 用 javah 产生一个 .h 文件
- 写一个 .cpp 文件实现 native 导出方法,其中需要包含第二步产生的 .h 文件(其中又包含了 JDK 带的 jni.h 文件)
- 将 .cpp 文件编译成动态链接库文件
- 在 Java 中用 System.loadLibrary() 文件加载第四步产生的动态链接库文件,然后这个 native 方法就可被访问了
对于 hashCode() 还有一些比较冷门的知识:
- 默认情况下,对象的 hashCode 方法返回值永远大于等于 0
- 默认情况下,对象的 hashCode 方法返回值不是对象的地址
- hashCode 相同,不一定是同一个对象,必须限定为同一个类
什么情况下需要重写 hashCode() 和 equals() ?
当我们希望两个对象的某些属性值相同就认为他们是相同对象时,而不是严格要求它们完完全全就是同一对象,比如说两个字符串,我们只需要它们的值相同,就可以认为他们相等。因此我们要重写 equals()
为什么 equals() 和 hashCode() 要一起重写?
这是由于 Java API 文档里的规范,虽然不按照规范不会报检查性异常,但是没有特殊情况最好遵守
如何重写才能符合规范?
首先是重写 equals(),需要进行以下三步
- 判断是否等于自身
- 使用 getClass() 判断传入对象是否为同类型的对象
- 比较类中定义的字段,根据自己的需要,判断是否相等,只有全部相等时才能认为两个对象相等
然后是重写 hashCode(),只需要将 equals() 里选择的字段作为参数,传入到 hash() 方法中计算哈希值并返回就好了
实例
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Pet pet = (Pet) o;
return age == pet.age && Objects.equals(name, pet.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
二、代理模式
1.介绍
你可能会对这个设计模式感到陌生,但是你对 Spring 一定不陌生,而 Spring AOP 就是代理模式的一种实现,因此它的重要性不言而喻
简而言之,代理模式是设置一个中间代理来控制访问被代理对象,以达到增强源被代理对象的功能和简化访问方式
代理模式 UML 类图
代理模式分为静态代理和动态代理,其中动态代理又分为 JDK 代理和 CGB 代理
1、静态代理
静态的代理在使用时,需要定义一个接口,然后让被代理对象和代理对象一起实现这个接口
下面举个例子,设计一个 Tank 类作为代理类,再设计两个代理类,以达到可以在不修改被代理类的情况下为其添加日志和计时功能
第一步,定义一个接口
interface Movable{
void move();
}
第二步,定义一个被代理类
class Tank implements Movable{
@Override
public void move(){
System.out.println("Moving......");
}
try{
Thread.sleep(new Random().nextInt(10000));
}catch(InterruptedException e){
e.printStackTrace();
}
}
第三步,定义一个代理类
class TankTimeProxy() implements Movable{
Movable m;
public TankTimeProxy(Movable m){
this.m = m;
}
@Override
public void move(){
long start = System.currentTimeMillis();
super.move();
long end = System.currentTimeMillis();
System.out.println("运行了 " + end - start + " ms");
}
}
第四步,再定义一个代理类
class TankLogProxy{
Movable m;
public TankLogProxy{
this.m = m;
}
@Override
public void move(){
System.out.println("start...");
m.move();
System.out.println("stopped!");
}
第五步,写 main 方法
public static void main(String args[]){
new TankLogProxy(
new TankTimeProxy(
new Tank;
)
).move();
}
对于代理类来讲,它们都实现了 Movable 接口并且聚合了一个 Movable 对象,它们能代理的类型也实现了 Movable 接口,这是它们能够互相嵌套的前提
在 main 方法里,我们先 new 一个被代理类,然后把这个被代理类作为参数 new 一个 代理类,然后再把这个被代理类作为参数 new 另一个代理类,最后再调用 move() 方法,这样就实现了在不修改被代理类的情况下为其添加日志和计时功能
现在来思考一个问题,如果我想让 LogProxy 可以重用,不只是用来代理 Tank,而是让它可以代理其他任何类型
但是静态代理只能代理继承了 Movable 接口的被代理类,那样的话就只能继承 Object 类,但是那样就很不方便
这个问题从本质上来讲,是想实现代理行为和代理对象分离。但是我们之前把代理对象写死了,只能代理实现 Movable 接口的类
现在我们希望能代理各种各样的类,如果仍然使用静态代理的话就很麻烦,因为无从得知即将代理的是什么类,也无从得知类的方法有哪些。之前的 Tank 类,我们知道它实现了 Movalbe 接口,所以我们知道它有一个 move 方法,所以我们能通过重写 move 方法然后嵌套,这样就可以为 move 方法扩展新的功能
现在的问题就是我们不知道被代理类实现了什么接口,万一被代理没有实现接口该怎么办
这个时候我们就需要动态代理
在介绍动态代理之前我们现总结一下静态代理的问题:
- 代码冗余,由于代理类要实现与被代理对象一致的接口,所以会产生过多的代理类
- 不易维护,一旦接口增加方法,被代理类和代理类都要进行修改
2、动态代理
动态代理是什么意思呢?就是说代理类的代码不是我们自己写的,而是在运行时后台动态生成的
动态代理有很多种实现方式,我们先从 JDK 代理入手
我们仍然举 Tank 类的例子
前两不是定义接口和定义 Tank 类,和静态代理一致,这里就不重复写了
第三步,定义一个调用时处理器,里面写需要扩展的功能
class LogHandler implements InvocationHandler{
Tank tank;
public LogHandler(Tank tank){
this.tank = tank;
}
@Override
public Object invoke(Object proxy,Method method,Object[] args) throw Throwable{
System.out.println("Method" + method.getName() + "start...");
Object o = method.invoke(tank,args);
System.out.println("method" + method.getName() + "end!");
return o;
}
}
第四步,写 main 方法
public static void main(String argps[]){
Tank tank = new Tank();
//创建代理实例
//静态代理是明确生成了一个 TankLogProxy 对象
//这里的 newProxyInstance 相当于是直接在运行时生成了一个 TankLogProxy,连代理类都不用我们写,只需要写扩展功能
//newProxyInstance() 有三个参数
//1、ClassLoader loader 你要使用哪一个 ClassLoader,使用把你 new 出来的代理对象加载到内存时得 ClassLoader 即可
//2、Class<?> [] interfaces 你希望代理类实现哪些接口
//3、InvocationHandler h 调用时处理器
Movable m = (Movable)Proxy.mewProxyInstance(Tank.class.getClassLoader(),
new Class[]{Movable.class},
new LogHandler(tank));
m.move();
运行结果:
method move start…
Moving…
method move end!
这里有一个奇怪的现象,我们认真地看一下 main 方法,我们会发现,没有任何语句调用了 InvocationHandler 的 invoke 方法,我们只调用了 move 方法,但是 invoke() 里写的日志功能也被打印出来了
因此我们可以合理地推测,是我们在调用 move() 的过程中,在一个我们看不见的地方隐式地调用了 invoke 方法
我一步一步地解释一下整个过程
在执行 newProxyInstance()方法时,会生成一个 $Proxy() 类的.class 文件,这个类就是根据我们传入的参数按照一定规则生成的代理类
这个代理类继承了一个父类叫 Proxy,这个父类里有一个聚合对象叫 InvocationHandler h,跟我们的第三个参数是同一类型的
这个父类的构造函数是有参构造,传入的参数为 InvovationHandler h,并且令 this.h = h,也就是用这个参数给聚合对象 h 赋值
代理类作为它的子类也有一个有参构造,参数为 InvocationHandler var1,只是它不像它的父类有一个 InvocationHandler h
它的构造函数里只有一句 super(var1),即让 var1 作为参数调用父类构造
这样,父类的聚合对象 h 就被指定为我们传入的第三个参数,也就是我们自己定义的调用时处理器
我们再来仔细分析一下我们调用的 move() 是哪个类的方法?是被代理类的吗?
显然不是,我们创建的 m 对象实际上是 $Proxy0 的实例,因此我们调用的是 $Proxy0 的方法
这样就破案了,原来是 $Proxy0 做的手脚,它在自己的 move 方法里调用了 InvcationHandler 的 invoke 方法
因此,我们的扩展功能得以实现
动态代理利用了JDK API,动态地在内存中构建代理对象,从而实现对目标对象的代理功能。动态代理又被称为JDK代理或接口代理。动态代理除了通过 JDK 反射来实现之外还有其他各种各样的方式
比如 Instrument,它是利用一个钩子函数,在 class 加载到内存之前将其拦截,并且对其进行一些定制,使其具有扩展功能
但是这种方式用的太少了,因为太过复杂,你必须理解 class 文件的二进制码中的每一个 0 和 1 的实际含义
在介绍 cglib 之前,我们总结一下 JDK代理和静态代理的区别:
- 静态代理在编译时就已经实现,编译完成后代理类是一个实际的 class 文件
- 动态代理是在运行时动态生成的,即编译完成后没有实际的 class 文件,而是在运行时动态生成类字节码,并加载到 JVM 中。而且,动态代理类不需要实现接口,但是要求被代理类要实现接口
还有一种常用且简单的方式,cglib(Code Generation Library ),也叫子类代理
cglib 是一个强大的高性能的代码生成包,它可以在运行期扩展Java类与实现Java接口
它广泛的被许多 AOP 的框架使用,例如 Spring AOP 和 dynaop,为他们提供方法的 interception(拦截)
cglib 包的底层是通过使用一个小而快的字节码处理框架 ASM,来转换字节码并生成新的类
不鼓励直接使用 ASM,因为它需要你对 JVM 内部结构包括 class 文件的格式和指令集都很熟悉
第一步,定义一个被代理类,不实现任何接口
class Tank{
public void move(){
System.out.println("Moving......");
try{
Thread.sleep(new Random.nextInt(10000));
}catch(InterruptedException e){
e.printStackTrace();
}
}
}
第二步,定义一个拦截器,扩展功能
class TimeMethodInterceptor implements MethodInterceptor{
@Override
public Object intercept(Object o,Method method,Object[] objects,MethodProxy methodProxy){
System.out.println("before");
Object result = null;
result = methodProxy.invokeSuper(o,objects);
System.out.println("after");
return result;
}
}
第三步,写 main 方法
public static void main(String args[]){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Tank.class); //把 enhancer 的父类设置为 Tank
//设定回调函数,TimeMEthodInterceptor 是一个拦截器,相当于 InvocationHandler
enhancer.setCallback(new TimeMethodInterceptor());
Tank tank = (Tank)enhancer.create(); //生成动态代理
tank.move(); //调用 move(),同时会调用到 intercept() 方法,执行我们的操作
我们来思考一下,使用 cglib 确实简单,但是它是怎么实现的呢
enhancer.setSuperclass(Tank.class);
我们关注一下这条语句,我们可以知道生成的动态代理类是被代理类的一个子类
cglib 代理就是在内存中构建一个子类对象,并对子类进行增强,从而实现对被代理类功能的扩展
具体一点,就是生成了一个被代理类的子类,那么就可以重写被代理类的方法,然后就能实现对被代理类功能的扩展
我们总结一下 cglib 代理和 JDK 代理的区别:
- JDK 代理的类必须实现接口
- cglib 代理的类无需实现接口,但是不能是 final 类,否则无法生成子类
总结
- 静态代理实现简单,只要代理类对被代理类进行包装即可。静态代理在编译时生成 class 字节码文件,可以直接使用,效率高。但是静态代理只能为一个被代理类服务,如果被代理类过多,就会产生很多代理类,从而导致代码冗余。其次不易维护,如果接口改变,被代理类和代理类都需要修改
- JDK 代理,只有被代理类需要实现接口,而代理类只需要实现 InvocationHandler 接口。但是 JDK 代理需要使用反射,比较消耗系统性能。
- cglib 代理无需实现接口,通过生成类字节码实现代理,比反射稍快,不存在性能问题。但是 cglib 需要继承被代理类重写方法,因此被代理类不能被 final 修饰
参考链接
结合以下链接理解,会有更加深入的认知
5分钟理解 hashCode() 和 equals()
Java Object.hashCode()返回的是对象内存地址?
目前主流的 Java 虚拟机有哪些?
Java Object.hashCode()返回的是对象内存地址?
为什么要重写hashCode()方法和equals()方法以及如何进行重写
70分钟入门代理模式
100分钟强化代理模式概念
CGLIB动态代理
Java的三种代理模式
Java三种代理模式:静态代理、动态代理和cglib代理
面试怎么回答代理模式