代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
举个例子:新娘找来了自己的姨妈来代替自己处理新郎的提问,新娘收到的提问都是经过姨妈处理过滤之后的。姨妈在这里就可以看作是代理你的代理对象,代理的行为(方法)是接收和回复新郎的提问。
https://medium.com/@mithunsasidharan/understanding-the-proxy-design-pattern-5e63fe38052a
代理模式有静态代理和动态代理两种实现方式,我们 先来看一下静态代理模式的实现。
静态代理中,我们对目标对象的每个方法的增强都是手动完成的(后面会具体演示代码),非常不灵活(比如接口一旦新增加方法,目标对象和代理对象都要进行修改)且麻烦(需要对每个目标类都单独写一个代理类)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。
上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
静态代理实现步骤:
- 定义一个接口及其实现类;
- 创建一个代理类同样实现这个接口
- 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。
下面通过代码展示!
1.定义发送短信的接口
2.实现发送短信的接口
3.创建代理类并同样实现发送短信的接口
4.实际使用
运行上述代码之后,控制台打印出:
可以输出结果看出,我们已经增加了 的方法。
相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
说到动态代理,Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。
动态代理在我们日常开发中使用的相对较少,但是在框架中的几乎是必用的一门技术。学会了动态代理之后,对于我们理解和学习各种框架的原理也非常有帮助。
就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态代理等等。
guide-rpc-framework 使用的是 JDK 动态代理,我们先来看看 JDK 动态代理的使用。
另外,虽然 guide-rpc-framework 没有用到 CGLIB 动态代理 ,我们这里还是简单介绍一下其使用以及和JDK 动态代理的对比。
在 Java 动态代理机制中 接口和 类是核心。
类中使用频率最高的方法是: ,这个方法主要用来生成一个代理对象。
这个方法一共有 3 个参数:
- loader :类加载器,用于加载代理对象。
- interfaces : 被代理类实现的一些接口;
- h : 实现了 接口的对象;
要实现动态代理的话,还必须需要实现 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现 接口类的 方法来调用。
方法有下面三个参数:
- proxy :动态生成的代理类
- method : 与代理类对象调用的方法相对应
- args : 当前 method 方法的参数
也就是说:你通过 类的 创建的代理对象在调用方法的时候,实际会调用到实现 接口的类的 方法。 你可以在 方法中自定义处理逻辑,比如在方法执行前后做什么事情。
- 定义一个接口及其实现类;
- 自定义 并重写方法,在 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑;
- 通过 方法创建代理对象;
这样说可能会有点空洞和难以理解,我上个例子,大家感受一下吧!
1.定义发送短信的接口
2.实现发送短信的接口
3.定义一个 JDK 动态代理类
方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 方法,然后 方法代替我们去调用了被代理对象的原生方法。
4.获取代理对象的工厂类
:主要通过方法获取某个类的代理对象
5.实际使用
运行上述代码之后,控制台打印出:
JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。
为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。
CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。
在 CGLIB 动态代理机制中 接口和 类是核心。
你需要自定义 并重写 方法, 用于拦截增强被代理类的方法。
- obj : 被代理的对象(需要增强的对象)
- method : 被拦截的方法(需要增强的方法)
- args : 方法入参
- proxy : 用于调用原始方法
你可以通过 类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 中的 方法。
- 定义一个类;
- 自定义 并重写 方法, 用于拦截增强被代理类的方法,和 JDK 动态代理中的 方法类似;
- 通过 类的 创建代理类;
不同于 JDK 动态代理不需要额外的依赖。CGLIB(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。
1.实现一个使用阿里云发送短信的类
2.自定义 (方法拦截器)
3.获取代理类
4.实际使用
运行上述代码之后,控制台打印出:
- JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
- 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
- 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
- JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
这篇文章中主要介绍了代理模式的两种实现:静态代理以及动态代理。涵盖了静态代理和动态代理实战、静态代理和动态代理的区别、JDK 动态代理和 Cglib 动态代理区别等内容。
文中涉及到的所有源码,你可以在这里找到:https://github.com/Snailclimb/guide-rpc-framework-learning/tree/master/src/main/java/github/javaguide/proxy 。
《阿里巴巴 Java 开发手册》中提到:“为了避免精度丢失,可以使用 来进行浮点数的运算”。
浮点数的运算竟然还会有精度丢失的风险吗?确实会!
示例代码:
为什么浮点数 或 运算的时候会有精度丢失的风险呢?
这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
就比如说十进制下的 0.2 就没办法精确转换成二进制小数:
关于浮点数的更多内容,建议看一下计算机系统基础(四)浮点数这篇文章。
可以实现对浮点数的运算,不会造成精度丢失。
通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 来做的。
《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。
具体原因我们在上面已经详细介绍了,这里就不多提了。
想要解决浮点数运算精度丢失这个问题,可以直接使用 来定义浮点数的值,然后再进行浮点数的运算操作即可。
我们在使用 时,为了防止精度丢失,推荐使用它的构造方法或者 静态方法来创建对象。
《阿里巴巴 Java 开发手册》对这部分内容也有提到,如下图所示。
方法用于将两个 对象相加, 方法用于将两个 对象相减。 方法用于将两个 对象相乘, 方法用于将两个 对象相除。
这里需要注意的是,在我们使用 方法的时候尽量使用 3 个参数版本,并且 不要选择 ,否则很可能会遇到 (无法除尽出现无限循环小数的时候),其中 表示要保留几位小数, 代表保留规则。
保留规则非常多,这里列举几种:
: 返回 -1 表示 小于 ,0 表示 等于 , 1 表示 大于 。
通过 方法设置保留几位小数以及保留规则。保留规则有挺多种,不需要记,IDEA 会提示。
《阿里巴巴 Java 开发手册》中提到:
使用 方法进行等值比较出现问题的代码示例:
这是因为 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 方法比较的时候会忽略精度。
1.0 的 scale 是 1,1 的 scale 是 0,因此 的结果是 false。
方法可以比较两个 的值,如果相等就返回 0,如果第 1 个数比第 2 个数大则返回 1,反之返回-1。
网上有一个使用人数比较多的 工具类,提供了多个静态方法来简化 的操作。
我对其进行了简单改进,分享一下源码:
相关 issue:建议对保留规则设置为 RoundingMode.HALF_EVEN,即四舍六入五成双,#2129 。
浮点数没有办法用二进制精确表示,因此存在精度丢失的风险。
不过,Java 提供了 来操作浮点数。 的实现利用到了 (用来操作大整数), 所不同的是 加入了小数位的概念。
本文整理完善自下面这两篇优秀的文章:
- Java 魔法类:Unsafe 应用解析 - 美团技术团队 -2019
- Java 双刃剑之 Unsafe 类详解 - 码农参上 - 2021
阅读过 JUC 源码的同学,一定会发现很多并发工具类都调用了一个叫做 的类。
那这个类主要是用来干什么的呢?有什么使用场景呢?这篇文章就带你搞清楚!
是位于 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 的使用一定要慎重。
另外, 提供的这些功能的实现需要依赖本地方法(Native Method)。你可以将本地方法看作是 Java 中使用其他编程语言编写的方法。本地方法使用 关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码。
为什么要使用本地方法呢?
- 需要用到 Java 中不具备的依赖于操作系统的特性,Java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用。
- 对于其他语言已经完成的一些现成功能,可以使用 Java 直接调用。
- 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编。
在 JUC 包的很多并发工具类在实现并发机制时,都调用了本地方法,通过它们打破了 Java 运行时的界限,能够接触到操作系统底层的某些功能。对于同一本地方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果。
部分源码如下:
类为一单例实现,提供静态方法 获取 实例。这个看上去貌似可以用来获取 实例。但是,当我们直接调用这个静态方法的时候,会抛出 异常:
为什么 方法无法被直接调用呢?
这是因为在方法中,会对调用者的进行检查,判断当前类是否由加载,如果不是的话那么就会抛出一个异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。
为什么要对 Unsafe 类进行这么谨慎的使用限制呢?
提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。
如若想使用 这个类的话,应该如何获取其实例呢?
这里介绍两个可行的方案。
1、利用反射获得 Unsafe 类中已经实例化完成的单例对象 。
2、从方法的使用限制条件出发,通过 Java 命令行命令把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过方法安全的获取 Unsafe 实例。
概括的来说, 类实现功能可以被分为下面 8 类:
- 内存操作
- 内存屏障
- 对象操作
- 数据操作
- CAS 操作
- 线程调度
- Class 操作
- 系统信息
如果你是一个写过 C 或者 C++ 的程序员,一定对内存操作不会陌生,而在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 中,提供的下列接口可以直接进行内存操作:
使用下面的代码进行测试:
先看结果输出:
分析一下运行结果,首先使用方法申请 4 字节长度的内存空间,调用方法向每个字节写入内容为类型的 1,当使用 Unsafe 调用方法时,因为一个型变量占 4 个字节,会一次性读取 4 个字节,组成一个的值,对应的十进制结果为 。
你可以通过下图理解这个过程:
在代码中调用方法重新分配了一块 8 字节长度的内存空间,通过比较和可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用方法进行了两次内存的拷贝,每次拷贝内存地址开始的 4 个字节,分别拷贝到以和开始的内存空间上:
拷贝完成后,使用方法一次性读取 8 个字节,得到类型的值为 076673。
需要注意,通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在中执行对内存的操作,最终在块中进行内存的释放。
为什么要使用堆外内存?
- 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
- 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。
是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。
下图为 构造函数,创建 的时候,通过 分配内存、 进行内存初始化,而后构建 对象用于跟踪 对象的垃圾回收,以实现当 被垃圾回收时,分配的堆外内存一起被释放。
在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障()就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。
在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能。
中提供了下面三个内存屏障相关方法:
内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。
看到这估计很多小伙伴们会想到关键字了,如果在字段上添加了关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改标志位,注意这里的是没有被修饰的:
在主线程的循环中,加入内存屏障,测试是否能够感知到的修改变化:
运行结果:
而如果删掉上面代码中的方法,那么主线程将无法感知到发生的变化,会一直在中循环。可以用图来表示上面的过程:
了解 Java 内存模型()的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。
在 Java 8 中引入了一种锁的新机制——,它可以看成是读写锁的一个改进版本。 提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。
为了解决这个问题, 的 方法会通过 的 方法加入一个 内存屏障。
例子
输出结果:
对象属性
对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的、方法外,Unsafe 提供了全部 8 种基础数据类型以及的和方法,并且所有的方法都可以越过访问权限,直接修改内存中的数据。阅读 openJDK 源码中的注释发现,基础数据类型和的读写稍有不同,基础数据类型是直接操作的属性值(),而的操作则是基于引用值()。下面是的读写方法:
除了对象属性的普通读写外, 还提供了 volatile 读写和有序写入方法。读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和类型,以类型为例:
相对于普通读写来说,读写具有更高的成本,因为它需要保证可见性和有序性。在执行操作时,会强制从主存中获取属性值,在使用方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。
有序写入的方法有以下三个:
有序写入的成本相对较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:
- :将主内存中的数据拷贝到处理器的缓存中
- :将处理器缓存的数据刷新到主内存中
顺序写入与写入的差别在于,在顺序写时加入的内存屏障类型为类型,而在写入时加入的内存屏障是类型,如下图所示:
在有序写入方法中,使用的是屏障,该屏障确保立刻刷新数据到内存,这一操作先于以及后续的存储指令操作。而在写入中,使用的是屏障,该屏障确保立刻刷新数据到内存,这一操作先于及后续的装载指令,并且,屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。
综上所述,在上面的三类写入方法中,在写入效率方面,按照、、的顺序效率逐渐降低。
对象实例化
使用 的 方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:
分别基于构造函数、反射以及 方法的不同方式创建对象进行比较:
打印结果分别为 1、1、0,说明通过方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将 A 类的构造函数改为类型,将无法通过构造函数和反射创建对象(可以通过构造函数对象 setAccessible 后创建对象),但方法仍然有效。
- 常规对象实例化方式:我们通常所用到的创建对象的方式,从本质上来讲,都是通过 new 机制来实现对象的创建。但是,new 机制有个特点就是当类只提供有参的构造函数且无显式声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。
- 非常规的实例化方式:而 Unsafe 中提供 allocateInstance 方法,仅通过 Class 对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM 安全检查等。它抑制修饰符检测,也就是即使构造器是 private 修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance 在 java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。
与 这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。
这两个与数据操作相关的方法,在 包下的 (可以实现对 数组中每个元素的原子性操作)中有典型的应用,如下图 源码所示,通过 的 、 分别获取数组首元素的偏移地址 及单个元素大小因子 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 方法即通过 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。
这部分主要为 CAS 相关操作的方法。
什么是 CAS? CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题, 提供的 CAS 方法(如 )底层实现即为 CPU 指令 。
在 JUC 包的并发工具类中大量地使用了 CAS 操作,像在前面介绍和的文章中也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在 类中,提供了、、方法来实现的对、、类型的 CAS 操作。以方法为例:
参数中为需要更新的对象,是对象中整形字段的偏移量,如果这个字段的值与相同,则将字段的值设为这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用的例子:
运行代码会依次输出:
在上面的例子中,使用两个线程去修改型属性的值,并且只有在的值等于传入的参数减一时,才会将的值变为,也就是实现对的加一的操作。流程如下所示:
需要注意的是,在调用方法后,会直接返回或的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在类的设计中,也是采用了将的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。
类中提供了、、、、方法进行线程调度。
方法 、 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 方法实现的,调用 方法后,线程将一直阻塞直到超时或者中断等条件出现; 可以终止一个挂起的线程,使其恢复正常。
此外, 源码中相关的三个方法已经被标记为,不建议被使用:
方法用于获得对象锁,用于释放对象锁,如果对一个没有被加锁的对象执行此方法,会抛出异常。方法尝试获取对象锁,如果成功则返回,反之返回。
Java 锁和同步器框架的核心类 (AQS),就是通过调用和实现线程的阻塞和唤醒的,而 的 、 方法实际是调用 的 、 方式实现的。
的方法调用了 的方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用方法唤醒当前线程。下面的例子对 的这两个方法进行测试:
程序输出为:
程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用方法阻塞自己,子线程在睡眠 5 秒后,调用方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:
对的相关操作主要包括类加载和静态变量的操作方法。
静态属性读取相关的方法
创建一个包含静态属性的类,进行测试:
运行结果:
在 的对象操作中,我们学习了通过方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用方法。在上面的代码中,只有在获取对象的过程中依赖到了,而获取静态变量的属性时不再依赖于。
在上面的代码中首先创建一个对象,这是因为如果一个类没有被初始化,那么它的静态属性也不会被初始化,最后获取的字段属性将是。所以在获取静态属性前,需要调用方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:
使用方法允许程序在运行时动态地创建一个类
在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器()和保护域()来源于调用此方法的实例。下面的例子中实现了反编译生成后的 class 文件的功能:
在上面的代码中,首先读取了一个文件并通过文件流将它转化为字节数组,之后使用方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过 JVM 的所有安全检查。
除了方法外,Unsafe 还提供了一个方法:
使用该方法可以用来动态的创建一个匿名类,在表达式中就是使用 ASM 动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在 JDK 15 发布的新特性中,在隐藏类()一条中,指出将在未来的版本中弃用 的方法。
Lambda 表达式实现需要依赖 的 方法定义实现相应的函数式接口的匿名类。
这部分包含两个获取系统相关信息的方法。
这两个方法的应用场景比较少,在类中,在使用计算所需的内存页的数量时,调用了方法获取内存页的大小。另外,在使用方法拷贝内存时,调用了方法,检测 32 位系统的情况。
在本文中,我们首先介绍了 的基本概念、工作原理,并在此基础上,对它的 API 进行了说明与实践。相信大家通过这一过程,能够发现 在某些场景下,确实能够为我们提供编程中的便利。但是回到开头的话题,在使用这些便利时,确实存在着一些安全上的隐患,在我看来,一项技术具有不安全因素并不可怕,可怕的是它在使用过程中被滥用。尽管之前有传言说会在 Java9 中移除 类,不过它还是照样已经存活到了 Java16。按照存在即合理的逻辑,只要使用得当,它还是能给我们带来不少的帮助,因此最后还是建议大家,在使用 的过程中一定要做到使用谨慎使用、避免滥用。
本文来自 Kingshion 投稿。欢迎更多朋友参与到 JavaGuide 的维护工作,这是一件非常有意义的事情。详细信息请看:JavaGuide 贡献指南 。
面向对象设计鼓励模块间基于接口而非具体实现编程,以降低模块间的耦合,遵循依赖倒置原则,并支持开闭原则(对扩展开放,对修改封闭)。然而,直接依赖具体实现会导致在替换实现时需要修改代码,违背了开闭原则。为了解决这个问题,SPI 应运而生,它提供了一种服务发现机制,允许在程序外部动态指定具体实现。这与控制反转(IoC)的思想相似,将组件装配的控制权移交给了程序之外。
SPI 机制也解决了 Java 类加载体系中双亲委派模型带来的限制。双亲委派模型虽然保证了核心库的安全性和一致性,但也限制了核心库或扩展库加载应用程序类路径上的类(通常由第三方实现)。SPI 允许核心或扩展库定义服务接口,第三方开发者提供并部署实现,SPI 服务加载机制则在运行时动态发现并加载这些实现。例如,JDBC 4.0 及之后版本利用 SPI 自动发现和加载数据库驱动,开发者只需将驱动 JAR 包放置在类路径下即可,无需使用显式加载驱动类。
SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
那 SPI 和 API 有啥区别?
说到 SPI 就不得不说一下 API(Application Programming Interface) 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:
一般模块之间都是通过接口进行通讯,因此我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
- 当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API。这种情况下,接口和实现都是放在实现方的包中。调用方通过接口调用实现方的功能,而不需要关心具体的实现细节。
- 当接口存在于调用方这边时,这就是 SPI 。由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。
举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。
SLF4J (Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。
这就是依赖 SPI 机制实现的,那我们接下来就实现一个简易版本的日志框架。
新建一个 Java 项目 目录结构如下:(注意直接新建 Java 项目就好了,不用新建 Maven 项目,Maven 项目会涉及到一些编译配置,如果有私服的话,直接 deploy 会比较方便,但是没有的话,在过程中可能会遇到一些奇怪的问题。)
新建 接口,这个就是 SPI , 服务提供者接口,后面的服务提供者就要针对这个接口进行实现。
接下来就是 类,这个主要是为服务使用者(调用方)提供特定功能的。这个类也是实现 Java SPI 机制的关键所在,如果存在疑惑的话可以先往后面继续看。
新建 类(服务使用者,调用方),启动程序查看结果。
程序结果:
此时我们只是空有接口,并没有为 接口提供任何的实现,所以输出结果中没有按照预期打印相应的结果。
你可以使用命令或者直接使用 IDEA 将整个程序直接打包成 jar 包。
接下来新建一个项目用来实现 接口
新建项目 目录结构如下:
新建 类
将 的 jar 导入项目中。
新建 lib 目录,然后将 jar 包拷贝过来,再添加到项目中。
再点击 OK 。
接下来就可以在项目中导入 jar 包里面的一些类和方法了,就像 JDK 工具类导包一样的。
实现 接口,在 目录下新建 文件夹,然后新建文件 (SPI 的全类名),文件里面的内容是: (Logback 的全类名,即 SPI 的实现类的包名 + 类名)。
这是 JDK SPI 机制 ServiceLoader 约定好的标准。
这里先大概解释一下:Java 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。
所以会提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。
接下来同样将 项目打包成 jar 包,这个 jar 包就是服务提供方的实现。通常我们导入 maven 的 pom 依赖就有点类似这种,只不过我们现在没有将这个 jar 包发布到 maven 公共仓库中,所以在需要使用的地方只能手动的添加到项目中。
为了更直观的展示效果,我这里再新建一个专门用来测试的工程项目:
然后先导入 的接口 jar 包,再导入具体的实现类的 jar 包。
新建 Main 方法测试:
运行结果如下:
说明导入 jar 包中的实现类生效了。
如果我们不导入具体的实现类的 jar 包,那么此时程序运行的结果就会是:
通过使用 SPI 机制,可以看出服务()和 服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改 项目中针对 接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF4J 原理吗?
如果某一天需求变更了,此时需要将日志输出到消息队列,或者做一些别的操作,这个时候完全不需要更改 Logback 的实现,只需要新增一个服务实现(service-provider)可以通过在本项目里面新增实现也可以从外部引入新的服务实现 jar 包。我们可以在服务(LoggerService)中选择一个具体的 服务实现(service-provider) 来完成我们需要的操作。
那么接下来我们具体来说说 Java SPI 工作的重点原理—— ServiceLoader 。
想要使用 Java 的 SPI 机制是需要依赖 来实现的,那么我们接下来看看 具体是怎么做的:
是 JDK 提供的一个工具类, 位于包下。
这是 JDK 官方给的注释:一种加载服务实现的工具。
再往下看,我们发现这个类是一个 类型的,所以是不可被继承修改,同时它实现了 接口。之所以实现了迭代器,是为了方便后续我们能够通过迭代的方式得到对应的服务实现。
可以看到一个熟悉的常量定义:
下面是 方法:可以发现 方法支持两种重载后的入参;
其解决第三方类加载的机制其实就蕴含在 中, 就是线程上下文类加载器(Thread Context ClassLoader)。这是每个线程持有的类加载器,JDK 的设计允许应用程序或容器(如 Web 应用服务器)设置这个类加载器,以便核心类库能够通过它来加载应用程序类。
线程上下文类加载器默认情况下是应用程序类加载器(Application ClassLoader),它负责加载 classpath 上的类。当核心库需要加载应用程序提供的类时,它可以使用线程上下文类加载器来完成。这样,即使是由引导类加载器加载的核心库代码,也能够加载并使用由应用程序类加载器加载的类。
根据代码的调用顺序,在 方法中是通过一个内部类 实现的。先继续往下面看。
实现了 接口的方法后,具有了迭代的能力,在这个 方法被调用时,首先会在 的 缓存中进行查找,如果缓存中没有命中那么则在 中进行查找。
在调用 时,具体实现如下:
可能很多人看这个会觉得有点复杂,没关系,我这边实现了一个简单的 的小模型,流程和原理都是保持一致的,可以先从自己实现一个简易版本的开始学:
我先把代码贴出来:
关键信息基本已经通过代码注释描述出来了,
主要的流程就是:
- 通过 URL 工具类从 jar 包的 目录下面找到对应的文件,
- 读取这个文件的名称找到对应的 spi 接口,
- 通过 流将文件里面的具体实现类的全类名读取出来,
- 根据获取到的全类名,先判断跟 spi 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象,
- 将构造出来的实例对象添加到 的列表中。
其实不难发现,SPI 机制的具体实现本质上还是通过反射完成的。即:我们按照规定将要暴露对外使用的具体实现类在 文件下声明。
另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框的理解。
通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
- 遍历加载所有的实现类,这样效率还是相对较低的;
- 当多个 同时 时,会有并发问题。
作者:Hollis
原文:https://mp.weixin..com/s/o4XdEMq1DL-nBS-f8Za5Aw
语法糖是大厂 Java 面试常问的一个知识点。
本文从 Java 编译原理角度,深入字节码及 class 文件,抽丝剥茧,了解 Java 中的语法糖原理及用法,帮助大家在学会如何使用 Java 语法糖的同时,了解这些语法糖背后的原理。
语法糖(Syntactic Sugar) 也称糖衣语法,是英国计算机学家 Peter.J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。简而言之,语法糖让程序更加简洁,有更高的可读性。
有意思的是,在编程领域,除了语法糖,还有语法盐和语法糖精的说法,篇幅有限这里不做扩展了。
我们所熟知的编程语言中几乎都有语法糖。作者认为,语法糖的多少是评判一个语言够不够牛逼的标准之一。很多人说 Java 是一个“低糖语言”,其实从 Java 7 开始 Java 语言层面上一直在添加各种糖,主要是在“Project Coin”项目下研发。尽管现在 Java 有人还是认为现在的 Java 是低糖,未来还会持续向着“高糖”的方向发展。
前面提到过,语法糖的存在主要是方便开发人员使用。但其实, Java 虚拟机并不支持这些语法糖。这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。
说到编译,大家肯定都知道,Java 语言中,命令可以将后缀名为的源文件编译为后缀名为的可以运行于 Java 虚拟机的字节码。如果你去看的源码,你会发现在中有一个步骤就是调用,这个方法就是负责解语法糖的实现的。
Java 中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱、内部类等。本文主要来分析下这些语法糖背后的原理。一步一步剥去糖衣,看看其本质。
我们这里会用到反编译,你可以通过 Decompilers online 对 Class 文件进行在线反编译。
前面提到过,从 Java 7 开始,Java 语言中的语法糖在逐渐丰富,其中一个比较重要的就是 Java 7 中开始支持。
在开始之前先科普下,Java 中的自身原本就支持基本类型。比如、等。对于类型,直接进行数值的比较。对于类型则是比较其 ascii 码。所以,对于编译器来说,中其实只能使用整型,任何类型的比较都要转换成整型。比如。,(ascii 码是整型)以及。
那么接下来看下对的支持,有以下代码:
反编译后内容如下:
看到这个代码,你知道原来 字符串的 switch 是通过和方法来实现的。 还好方法返回的是,而不是。
仔细看下可以发现,进行的实际是哈希值,然后通过使用方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行 或者使用纯整数常量,但这也不是很差。
我们都知道,很多语言都是支持泛型的,但是很多人不知道的是,不同的编译器对于泛型的处理方式是不同的,通常情况下,一个编译器处理泛型有两种方式:和。C++和 C#是使用的处理机制,而 Java 使用的是的机制。
Code sharing 方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过类型擦除()实现的。
也就是说,对于 Java 虚拟机来说,他根本不认识这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖。
类型擦除的主要过程如下:1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。
以下代码:
解语法糖之后会变成:
以下代码:
类型擦除后会变成:
虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的类对象。比如并不存在或是,而只有。
自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱,反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。原始类型 byte, short, char, int, long, float, double 和 boolean 对应的封装类为 Byte, Short, Character, Integer, Long, Float, Double, Boolean。
先来看个自动装箱的代码:
反编译后代码如下:
再来看个自动拆箱的代码:
反编译后代码如下:
从反编译得到内容可以看出,在装箱的时候自动调用的是的方法。而在拆箱的时候自动调用的是的方法。
所以,装箱过程是通过调用包装器的 valueOf 方法实现的,而拆箱过程是通过调用包装器的 xxxValue 方法实现的。
可变参数()是在 Java 1.5 中引入的一个特性。它允许一个方法把任意数量的值作为参数。
看下以下可变参数代码,其中 方法接收可变参数:
反编译后代码:
从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。(注: 仅在修饰成员变量时有意义,此处 “修饰方法” 是由于在 javassist 中使用相同数值分别表示 以及 ,见 此处。)
Java SE5 提供了一种新的类型-Java 的枚举类型,关键字可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。
要想看源码,首先得有一个类吧,那么枚举类型到底是什么类呢?是吗?答案很明显不是,就和一样,只是一个关键字,他并不是一个类,那么枚举是由什么类维护的呢,我们简单的写一个枚举:
然后我们使用反编译,看看这段代码到底是怎么实现的,反编译后代码内容如下:
通过反编译后代码我们可以看到,,说明,该类是继承了类的,同时关键字告诉我们,这个类也是不能被继承的。
当我们使用来定义一个枚举类型的时候,编译器会自动帮我们创建一个类型的类继承类,所以枚举类型不能被继承。
内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员。
内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,里面定义了一个内部类,一旦编译成功,就会生成两个完全不同的文件了,分别是和。所以内部类的名字完全可以和它的外部类名字相同。
以上代码编译后会生成两个 class 文件:、 。当我们尝试对文件进行反编译的时候,命令行会打印以下内容: 。他会把两个文件全部进行反编译,然后一起生成一个文件。文件内容如下:
—般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。
如在 C 或 CPP 中,可以通过预处理语句来实现条件编译。其实在 Java 中也可实现条件编译。我们先来看一段代码:
反编译后代码如下:
首先,我们发现,在反编译后的代码中没有,这其实就是条件编译。当为 false 的时候,编译器就没有对其内的代码进行编译。
所以,Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在整个 Java 类的结构或者类的属性上进行条件编译,这与 C/C++的条件编译相比,确实更有局限性。在 Java 语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。
在 Java 中,关键字是从 JAVA SE 1.4 引入的,为了避免和老版本的 Java 代码中使用了关键字导致错误,Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关或来开启。
看一段包含断言的代码:
反编译后代码如下:
很明显,反编译之后的代码要比我们自己的代码复杂的多。所以,使用了 assert 这个语法糖我们节省了很多代码。其实断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行。会设置$assertionsDisabled 字段的值。
在 java 7 中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。
比如:
反编译后:
反编译后就是把删除了。也就是说 编译器并不认识在数字字面量中的,需要在编译阶段把他去掉。
增强 for 循环()相信大家都不陌生,日常开发经常会用到的,他会比 for 循环要少写很多代码,那么这个语法糖背后是如何实现的呢?
反编译后代码如下:
代码很简单,for-each 的实现原理其实就是使用了普通的 for 循环和迭代器。
Java 里,对于文件操作 IO 流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过 close 方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。
关闭资源的常用方式就是在块里是释放,即调用方法。比如,我们经常会写这样的代码:
从 Java 7 开始,jdk 提供了一种更好的方式关闭资源,使用语句,改写一下上面的代码,效果如下:
看,这简直是一大福音啊,虽然我之前一般使用去关闭流,并不会使用在中写很多代码的方式,但是这种新的语法糖看上去好像优雅很多呢。看下他的背后:
其实背后的原理也很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。所以,再次印证了,语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。
关于 lambda 表达式,有人可能会有质疑,因为网上有人说他并不是语法糖。其实我想纠正下这个说法。Lambda 表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个 JVM 底层提供的 lambda 相关 api。
先来看一个简单的 lambda 表达式。遍历一个 list:
为啥说他并不是内部类的语法糖呢,前面讲内部类我们说过,内部类在编译之后会有两个 class 文件,但是,包含 lambda 表达式的类编译后只有一个文件。
反编译后代码如下:
可以看到,在方法中,其实是调用了方法,该方法的第四个参数 指定了方法实现。可以看到这里其实是调用了一个方法进行了输出。
再来看一个稍微复杂一点的,先对 List 进行过滤,然后再输出:
反编译后代码如下:
两个 lambda 表达式分别调用了和两个方法。
所以,lambda 表达式的实现其实是依赖了一些底层的 api,在编译阶段,编译器会把 lambda 表达式进行解糖,转换成调用内部 api 的方式。
一、当泛型遇到重载
上面这段代码,有两个重载的函数,因为他们的参数类型不同,一个是另一个是 ,但是,这段代码是编译通不过的。因为我们前面讲过,参数和编译之后都被擦除了,变成了一样的原生类型 List,擦除动作导致这两个方法的特征签名变得一模一样。
二、当泛型遇到 catch
泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型和的
三、当泛型内包含静态变量
以上代码输出结果为:2!
对象相等比较
输出结果:
在 Java 5 中,在 Integer 的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。
适用于整数值区间-128 至 +127。
只适用于自动装箱。使用构造函数创建对象不适用。
会抛出异常。
Iterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变,所以当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出异常。
所以 在工作的时候是不允许被迭代的对象被改变的。但你可以使用 本身的方法来删除对象, 方法会在删除当前迭代对象的同时维护索引的一致性。
前面介绍了 12 种 Java 中常用的语法糖。所谓语法糖就是提供给开发人员便于开发的一种语法而已。但是这种语法只有开发人员认识。要想被执行,需要进行解糖,即转成 JVM 认识的语法。当我们把语法糖解糖之后,你就会发现其实我们日常使用的这些方便的语法,其实都是一些其他更简单的语法构成的。
有了这些语法糖,我们在日常开发的时候可以大大提升效率,但是同时也要避过度使用。使用之前最好了解下原理,避免掉坑。
版权声明:
本文来源网络,所有图片文章版权属于原作者,如有侵权,联系删除。
本文网址:https://www.mushiming.com/mjsbk/2635.html