Java 基础概念 常见面试问题

本文最后更新于 2024年1月4日 下午

Java 基础概念 常见面试问题

基础概念与常识

Java 语言有哪些特点?

  1. 简单易学;
  2. 面向对象(封装,继承,多态);
  3. 平台无关性( Java 虚拟机实现平台无关性);
  4. 支持多线程( C++ 语言没有内置的多线程机制,C++11 开始(2011 年的时候), C++就引入了多线程库,,而 Java 语言却提供了多线程支持);
  5. 可靠性(具备异常处理和自动内存管理机制);
  6. 安全性(Java 语言本身的设计就提供了多重安全防护机制如访问权限修饰符、限制程序直接访问操作系统资源);
  7. 高效性(通过 Just In Time 编译器等技术的优化,Java 语言的运行效率还是非常不错的);
  8. 支持网络编程并且很方便;
  9. 编译与解释并存;

Java SE vs Java EE

  • Java SE(Java Platform,Standard Edition): Java 平台标准版,Java 编程语言的基础,它包含了支持 Java 应用程序开发和运行的核心类库以及虚拟机等核心组件。Java SE 可以用于构建桌面应用程序或简单的服务器应用程序。
  • Java EE(Java Platform, Enterprise Edition ):Java 平台企业版,建立在 Java SE 的基础上,包含了支持企业级应用程序开发和部署的标准和规范(比如 Servlet、JSP、EJB、JDBC、JPA、JTA、JavaMail、JMS)。 Java EE 可以用于构建分布式、可移植、健壮、可伸缩和安全的服务端 Java 应用程序,例如 Web 应用程序。

简单来说,Java SE 是 Java 的基础版本,Java EE 是 Java 的高级版本。Java SE 更适合开发桌面应用程序或简单的服务器应用程序,Java EE 更适合开发复杂的企业级应用程序或 Web 应用程序。

除了 Java SE 和 Java EE,还有一个 Java ME(Java Platform,Micro Edition)。Java ME 是 Java 的微型版本,主要用于开发嵌入式消费电子设备的应用程序,例如手机、PDA、机顶盒、冰箱、空调等。Java ME 无需重点关注,知道有这个东西就好了,现在已经用不上了。

JVM vs JDK vs JRE

JVM

Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。

运行在 Java 虚拟机之上的编程语言

JVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。

除了我们平时最常用的 HotSpot VM 外,还有 J 9 VM、Zing VM、JRockit VM 等 JVM 。维基百科上就有常见 JVM 的对比:Comparison of Java virtual machines ,感兴趣的可以去看看。并且,你可以在 Java SE Specifications 上找到各个版本的 JDK 对应的 JVM 规范。

JDK 和 JRE

JDK(Java Development Kit),它是功能齐全的 Java SDK,是提供给开发者使用的,能够创建和编译 Java 程序。他包含了 JRE,同时还包含了编译 java 源码的编译器 javac 以及一些其他工具比如 javadoc(文档注释工具)、jdb(调试器)、jconsole(基于 JMX 的可视化监控⼯具)、javap(反编译工具)等等。

JRE(Java Runtime Environment) 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,主要包括 Java 虚拟机(JVM)、Java 基础类库(Class Library)。

JRE 是 Java 运行时环境,仅包含 Java 应用程序的运行时环境和必要的类库。而 JDK 则包含了 JRE,同时还包括了 javac、javadoc、jdb、jconsole、javap 等工具,可以用于 Java 应用程序的开发和调试。如果需要进行 Java 编程工作,比如编写和编译 Java 程序、使用 Java API 文档等,就需要安装 JDK。而对于某些需要使用 Java 特性的应用程序,如 JSP 转换为 Java Servlet、使用反射等,也需要 JDK 来编译和运行 Java 代码。因此,即使不打算进行 Java 应用程序的开发工作,也有可能需要安装 JDK。

JDK 包含 JRE

什么是字节码? 采用字节码的好处是什么?

在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C、 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。

Java 程序从源代码到运行的过程如下图所示

Java程序转变为机器代码的过程

Java 程序转变为机器代码的过程

我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的 (也就是所谓的热点代码),所以后面引进了 JIT(Just in Time Compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言

Java程序转变为机器代码的过程

Java 程序转变为机器代码的过程

HotSpot 采用了惰性评估 (Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。

JDK、JRE、JVM、JIT 这四者的关系如下图所示。

JDK、JRE、JVM、JIT 这四者的关系

为什么说 Java 语言“编译与解释并存”?

我们可以将高级编程语言按照程序的执行方式分为两种:

  • 编译型编译型语言 会通过编译器 将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。
  • 解释型解释型语言 会通过解释器 一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。

编译型语言和解释型语言

根据维基百科介绍:

为了改善编译语言的效率而发展出的即时编译 技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成字节码。到执行期时,再将字节码直译,之后执行。JavaLLVM 是这种技术的代表产物。

相关阅读:基本功 | Java 即时编译器原理解析及实践

为什么说 Java 语言“编译与解释并存”?

这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须由 Java 解释器来解释执行。

AOT 有什么优点?为什么不全部使用 AOT 呢?

JDK 9 引入了一种新的编译模式 AOT (Ahead of Time Compilation) 。和 JIT 不同的是,这种编译模式会在程序被执行前就将其编译成机器码,属于静态编译(C、 C++,Rust,Go 等语言就是静态编译)。AOT 避免了 JIT 预热等各方面的开销,可以提高 Java 程序的启动速度,避免预热时间长。并且,AOT 还能减少内存占用和增强 Java 程序的安全性(AOT 编译后的代码不容易被反编译和修改),特别适合云原生场景。

JIT 与 AOT 两者的关键指标对比:

JIT vs AOT

可以看出,AOT 的主要优势在于启动时间、内存占用和打包体积。JIT 的主要优势在于具备更高的极限处理能力,可以降低请求的最大延迟。

既然 AOT 这么多优点,那为什么不全部使用这种编译方式呢?

我们前面也对比过 JIT 与 AOT,两者各有优点,只能说 AOT 更适合当下的云原生场景,对微服务架构的支持也比较友好。除此之外,AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。然而,很多框架和库(如 Spring、CGLIB)都用到了这些特性。如果只使用 AOT 编译,那就没办法使用这些框架和库了,或者说需要针对性地去做适配和优化。举个例子,CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 .class 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。

Oracle JDK vs OpenJDK

  1. 是否开源:OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是基于 OpenJDK 实现的,并不是完全开源的(个人观点:众所周知,JDK 原来是 SUN 公司开发的,后来 SUN 公司又卖给了 Oracle 公司,Oracle 公司以 Oracle 数据库而著名,而 Oracle 数据库又是闭源的,这个时候 Oracle 公司就不想完全开源了,但是原来的 SUN 公司又把 JDK 给开源了,如果这个时候 Oracle 收购回来之后就把他给闭源,必然会引起很多 Java 开发者的不满,导致大家对 Java 失去信心,那 Oracle 公司收购回来不就把 Java 烂在手里了吗!然后,Oracle 公司就想了个骚操作,这样吧,我把一部分核心代码开源出来给你们玩,并且我要和你们自己搞的 JDK 区分下,你们叫 OpenJDK,我叫 Oracle JDK,我发布我的,你们继续玩你们的,要是你们搞出来什么好玩的东西,我后续发布 Oracle JDK 也会拿来用一下,一举两得!)OpenJDK 开源项目:https://github.com/openjdk/jdk
  2. 是否免费:Oracle JDK 会提供免费版本,但一般有时间限制。JDK 17 之后的版本可以免费分发和商用,但是仅有 3 年时间,3 年后无法免费商用。不过,JDK 8 u 221 之前只要不升级可以无限期免费。OpenJDK 是完全免费的。
  3. 功能性:Oracle JDK 在 OpenJDK 的基础上添加了一些特有的功能和工具,比如 Java Flight Recorder(JFR,一种监控工具)、Java Mission Control(JMC,一种监控工具)等工具。不过,在 Java 11 之后,OracleJDK 和 OpenJDK 的功能基本一致,之前 OracleJDK 中的私有组件大多数也已经被捐赠给开源组织。
  4. 稳定性:OpenJDK 不提供 LTS 服务,而 OracleJDK 大概每三年都会推出一个 LTS 版进行长期支持。不过,很多公司都基于 OpenJDK 提供了对应的和 OracleJDK 周期相同的 LTS 版。因此,两者稳定性其实也是差不多的。
  5. 协议:Oracle JDK 使用 BCL/OTN 协议获得许可,而 OpenJDK 根据 GPL v 2 许可获得许可。

既然 Oracle JDK 这么好,那为什么还要有 OpenJDK?

答:

  1. OpenJDK 是开源的,开源意味着你可以对它根据你自己的需要进行修改、优化,比如 Alibaba 基于 OpenJDK 开发了 Dragonwell 8:https://github.com/alibaba/dragonwell8
  2. OpenJDK 是商业免费的(这也是为什么通过 yum 包管理器上默认安装的 JDK 是 OpenJDK 而不是 Oracle JDK)。虽然 Oracle JDK 也是商业免费(比如 JDK 8),但并不是所有版本都是免费的。
  3. OpenJDK 更新频率更快。Oracle JDK 一般是每 6 个月发布一个新版本,而 OpenJDK 一般是每 3 个月发布一个新版本。(现在你知道为啥 Oracle JDK 更稳定了吧,先在 OpenJDK 试试水,把大部分问题都解决掉了才在 Oracle JDK 上发布)

基于以上这些原因,OpenJDK 还是有存在的必要的!

oracle jdk release cadence

Oracle JDK 和 OpenJDK 如何选择?

建议选择 OpenJDK 或者基于 OpenJDK 的发行版,比如 AWS 的 Amazon Corretto,阿里巴巴的 Alibaba Dragonwell。

🌈 拓展一下:

  • BCL 协议(Oracle Binary Code License Agreement):可以使用 JDK(支持商用),但是不能进行修改。
  • OTN 协议(Oracle Technology Network License Agreement):11 及之后新发布的 JDK 用的都是这个协议,可以自己私下用,但是商用需要付费。

Java 和 C++ 的区别?

  • Java 不提供指针来直接访问内存,程序内存更加安全
  • Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
  • Java 有自动内存管理垃圾回收机制 (GC),不需要程序员手动释放无用内存。
  • C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。

基本语法

注释有哪几种形式?

Java 中的注释有三种:

Java 注释类型总结

  1. 单行注释:通常用于解释方法内某单行代码的作用。
  2. 多行注释:通常用于解释一段代码的作用。
  3. 文档注释:通常用于生成 Java 开发文档。

标识符和关键字的区别是什么?

在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了 标识符 。简单来说, 标识符就是一个名字

有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这些特殊的标识符就是 关键字 。简单来说,关键字是被赋予特殊含义的标识符。比如,在我们的日常生活中,如果我们想要开一家店,则要给这个店起一个名字,起的这个“名字”就叫标识符。但是我们店的名字不能叫“警察局”,因为“警察局”这个名字已经被赋予了特殊的含义,而“警察局”就是我们日常生活中的关键字。

Java 语言关键字有哪些?

分类 关键字
访问控制 private protected public
类,方法和变量修饰符 abstract class extends final implements interface native
new static strictfp synchronized transient volatile enum
程序控制 break continue return do while if else
for instanceof switch case default assert
错误处理 try catch throw throws finally
包相关 import package
基本类型 boolean byte char double float int long
short
变量引用 super this void
保留字 goto const

Tips:所有的关键字都是小写的,在 IDE 中会以特殊颜色显示。

default 这个关键字很特殊,既属于程序控制,也属于类,方法和变量修饰符,还属于访问控制。

在程序控制中,当在 switch 中匹配不到任何情况时,可以使用 default 来编写默认匹配的情况。
在类,方法和变量修饰符中,从 JDK 8 开始引入了默认方法,可以使用 default 关键字来定义一个方法的默认实现。
在访问控制中,如果一个方法前没有任何修饰符,则默认会有一个修饰符 default,但是这个修饰符加上了就会报错。

⚠️ 注意:虽然 true, false, 和 null 看起来像关键字但实际上他们是字面值,同时你也不可以作为标识符来使用。

官方文档:https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html

移位运算符

移位运算符是最基本的运算符之一,几乎每种编程语言都包含这一运算符。移位操作中,被操作的数据被视为二进制数,移位就是将其向左或向右移动若干位的运算。

移位运算符在各种框架以及 JDK 自身的源码中使用还是挺广泛的,HashMap(JDK 1.8) 中的 hash 方法的源码就用到了移位运算符:

1
2
3
4
5
6
7
8
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^:按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在 Java 代码里使用 <<>>>>> 转换成的指令码运行起来会更高效些。

掌握最基本的移位运算符知识还是很有必要的,这不光可以帮助我们在代码中使用,还可以帮助我们理解源码中涉及到移位运算符的代码。

Java 中有三种移位运算符:

Java 移位运算符总结

  • << : 左移运算符,向左移若干位,高位丢弃,低位补零。x << 1, 相当于 x 乘以 2 (不溢出的情况下)。
  • >> : 带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0, 负数高位补 1。x >> 1, 相当于 x 除以 2。
  • >>> : 无符号右移,忽略符号位,空位都以 0 补齐。

由于 doublefloat 在二进制中的表现比较特殊,因此不能来进行移位操作。

移位操作符实际上支持的类型只有 intlong,编译器在对 shortbytechar 类型进行移位前,都会将其转换为 int 类型再操作。

如果移位的位数超过数值所占有的位数会怎样?

当 int 类型左移/右移位数大于等于 32 位操作时,会先求余(%)后再进行左移/右移操作。也就是说左移/右移 32 位相当于不进行移位操作(32%32=0),左移/右移 42 位相当于左移/右移 10 位(42%32=10)。当 long 类型进行左移/右移操作时,由于 long 对应的二进制是 64 位,因此求余操作的基数也变成了 64。

也就是说:x<<42 等同于 x<<10x>>42 等同于 x>>10x >>>42 等同于 x >>> 10

左移运算符代码示例

1
2
3
4
5
6
int i = -1;
System.out.println("初始数据:" + i);
System.out.println("初始数据对应的二进制字符串:" + Integer.toBinaryString(i));
i <<= 10;
System.out.println("左移 10 位后的数据 " + i);
System.out.println("左移 10 位后的数据对应的二进制字符 " + Integer.toBinaryString(i));

输出:

1
2
3
4
初始数据:-1
初始数据对应的二进制字符串:11111111111111111111111111111111
左移 10 位后的数据 -1024
左移 10 位后的数据对应的二进制字符 11111111111111111111110000000000

由于左移位数大于等于 32 位操作时,会先求余(%)后再进行左移操作,所以下面的代码左移 42 位相当于左移 10 位(42%32=10),输出结果和前面的代码一样。

1
2
3
4
5
6
int i = -1;
System.out.println("初始数据:" + i);
System.out.println("初始数据对应的二进制字符串:" + Integer.toBinaryString(i));
i <<= 42;
System.out.println("左移 10 位后的数据 " + i);
System.out.println("左移 10 位后的数据对应的二进制字符 " + Integer.toBinaryString(i));

右移运算符使用类似,篇幅问题,这里就不做演示了。

Continue、break 和 return 的区别是什么?

在循环结构中,当循环条件不满足或者循环次数达到要求时,循环会正常结束。但是,有时候可能需要在循环的过程中,当发生了某种条件之后,提前终止循环,这就需要用到下面几个关键词:

  1. continue:指跳出当前的这一次循环,继续下一次循环。
  2. break:指跳出整个循环体,继续执行循环下面的语句。

return 用于跳出所在方法,结束该方法的运行。Return 一般有两种用法:

  1. return;:直接使用 return 结束方法执行,用于没有返回值函数的方法
  2. return value;:return 一个特定值,用于有返回值函数的方法

基本数据类型

Java 中的几种基本数据类型了解么?

Java 中有 8 种基本数据类型,分别为:

  • 6 种数字类型:
    • 4 种整数型:byteshortintlong
    • 2 种浮点型:floatdouble
  • 1 种字符类型:char
  • 1 种布尔型:boolean

这 8 种基本数据类型的默认值以及所占空间的大小如下:

基本类型 位数 字节 默认值 取值范围
byte 8 1 0 -128 ~ 127
short 16 2 0 -32768(-2^15) ~ 32767(2^15 - 1)
int 32 4 0 -2147483648 ~ 2147483647
long 64 8 0 L -9223372036854775808(-2^63) ~ 9223372036854775807(2^63 -1)
char 16 2 ‘u 0000’ 0 ~ 65535(2^16 - 1)
float 32 4 0 f 1.4 E-45 ~ 3.4028235 E 38
double 64 8 0 d 4.9 E-324 ~ 1.7976931348623157 E 308
boolean 1 false true、false

可以看到,像 byteshortintlong 能表示的最大正数都减 1 了。这是为什么呢?这是因为在二进制补码表示法中,最高位是用来表示符号的(0 表示正数,1 表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为 1。如果我们再加 1,就会导致溢出,变成一个负数。

对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。

注意:

  1. Java 里使用 long 类型的数据一定要在数值后面加上 L,否则将作为整型解析。
  2. char a = 'h' char : 单引号,String a = "hello" : 双引号。

这八种基本类型都有对应的包装类分别为:ByteShortIntegerLongFloatDoubleCharacterBoolean

基本类型和包装类型的区别?

基本类型 vs 包装类型

  • 用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
  • 存储方式基本数据类型局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中
  • 占用空间:相比于包装类型(对象类型),基本数据类型占用的空间往往非常小。
  • 默认值:成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null
  • 比较方式:对于基本数据类型来说,\=\= 比较的是值。对于包装数据类型来说,\=\= 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用 equals() 方法。

为什么说是几乎所有对象实例都存在于堆中呢? 这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存

⚠️ 注意:基本数据类型存放在栈中是一个常见的误区! 基本数据类型的成员变量如果没有被 static 修饰的话(不建议这么使用,应该要使用基本数据类型对应的包装类型),就存放在堆中。

包装类型的缓存机制了解么?

Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。

Byte, Short, Integer, Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False

Integer 缓存源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
private static class IntegerCache {
static final int low = -128;
static final int high;
static {
// high value may be configured by property
int h = 127;
}
}

Character 缓存源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Character valueOf(char c) {
if (c <= 127) { // must cache
return CharacterCache.cache[(int)c];
}
return new Character(c);
}

private static class CharacterCache {
private CharacterCache(){}
static final Character cache[] = new Character[127 + 1];
static {
for (int i = 0; i < cache.length; i++)
cache[i] = new Character((char)i);
}

}

Boolean 缓存源码:

1
2
3
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}

如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。

两种浮点数类型的包装类 Float, Double 并没有实现缓存机制。

1
2
3
4
5
6
7
8
9
10
11
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true

Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22);// 输出 false

Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false

下面我们来看一个问题:下面的代码的输出结果是 true 还是 false 呢?

1
2
3
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);

Integer i1=40 这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是缓存中的对象。而 Integer i2 = new Integer(40) 会直接创建新的对象。

因此,答案是 false 。你答对了吗?

记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较

自动装箱与拆箱了解吗?原理是什么?

什么是自动拆装箱?

  • 装箱:将基本类型用它们对应的引用类型包装起来;
  • 拆箱:将包装类型转换为基本数据类型;

从字节码中,我们发现装箱其实就是调用了包装类的 valueOf() 方法,拆箱其实就是调用了 xxxValue() 方法。

因此,

  • Integer i = 10 等价于 Integer i = Integer.valueOf(10)
  • int n = i 等价于 int n = i.intValue();

注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。

1
2
3
4
5
6
7
private static long sum() {
// 应该使用 long 而不是 Long
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}

为什么浮点数运算的时候会有精度丢失的风险?

浮点数运算精度丢失代码演示:

1
2
3
4
5
float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.println(a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false

为什么会出现这个问题呢?

这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示

就比如说十进制下的 0.2 就没办法精确转换成二进制小数:

1
2
3
4
5
6
7
8
// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...

如何解决浮点数运算的精度丢失问题?

BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

1
2
3
4
5
6
7
8
9
10
BigDecimal a = new BigDecimal ("1.0");
BigDecimal b = new BigDecimal ("0.9");
BigDecimal c = new BigDecimal ("0.8");

BigDecimal x = a.subtract (b);
BigDecimal y = b.subtract (c);

System.Out.Println (x); /* 0.1 */
System.Out.Println (y); /* 0.1 */
System.Out.Println (Objects.Equals (x, y)); /* true */

关于 BigDecimal 的详细介绍,可以看看我写的这篇文章:BigDecimal 详解

超过 long 整型的数据应该如何表示?

基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。

在 Java 中,64 位 long 整型是最大的整数类型。

1
2
3
Long l = Long. MAX_VALUE;
System.Out.Println (l + 1); // -9223372036854775808
System.Out.Println (l + 1 == Long. MIN_VALUE); // true

BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。

相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。

变量

成员变量与局部变量的区别?

成员变量 vs 局部变量

  • 语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public, private, static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
  • 存储方式:从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
  • 生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
  • 默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外: 被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。

成员变量与局部变量代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Public class VariableExample {

// 成员变量
Private String name;
Private int age;

// 方法中的局部变量
Public void method () {
Int num 1 = 10; // 栈中分配的局部变量
String str = "Hello, world!"; // 栈中分配的局部变量
System.Out.Println (num 1);
System.Out.Println (str);
}

// 带参数的方法中的局部变量
Public void method 2 (int num 2) {
Int sum = num 2 + 10; // 栈中分配的局部变量
System.Out.Println (sum);
}

// 构造方法中的局部变量
Public VariableExample (String name, int age) {
This. Name = name; // 对成员变量进行赋值
This. Age = age; // 对成员变量进行赋值
Int num 3 = 20; // 栈中分配的局部变量
String str 2 = "Hello, " + this. Name + "!"; // 栈中分配的局部变量
System.Out.Println (num 3);
System.Out.Println (str 2);
}
}

静态变量有什么作用?

静态变量也就是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。

静态变量是通过类名来访问的,例如 StaticVariableExample. StaticVar(如果被 private 关键字修饰就无法这样访问了)。

1
2
3
4
Public class StaticVariableExample {
// 静态变量
Public static int staticVar = 0;
}

通常情况下,静态变量会被 final 关键字修饰成为常量。

1
2
3
4
Public class ConstantVariableExample {
// 常量
Public static final int constantVar = 0;
}

字符型常量和字符串常量的区别?

  • 形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。
  • 含义 : 字符常量相当于一个整型值 ( ASCII 值), 可以参加表达式运算; 字符串常量代表一个地址值 (该字符串在内存中存放位置)。
  • 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节。

⚠️ 注意 char 在 Java 中占两个字节。

方法

什么是方法的返回值? 方法有哪几种类型?

方法的返回值 是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能产生结果)。返回值的作用是接收出结果,使得它可以用于其他的操作!

我们可以按照方法的返回值和参数类型将方法分为下面这几种:

1、无参数无返回值的方法

1
2
3
4
5
6
7
8
9
10
11
Public void f 1 () {
//......
}
// 下面这个方法也没有返回值,虽然用到了 return
Public void f (int a) {
If (...) {
// 表示结束方法的执行, 下方的输出语句不会执行
Return;
}
System.Out.Println (a);
}

2、有参数无返回值的方法

1
2
3
Public void f 2 (Parameter 1, ..., Parameter n) {
//......
}

3、有返回值无参数的方法

1
2
3
4
Public int f 3 () {
//......
Return x;
}

4、有返回值有参数的方法

1
2
3
Public int f 4 (int a, int b) {
Return a * b;
}

静态方法为什么不能调用非静态成员?

这个需要结合 JVM 的相关知识,主要原因如下:

  1. 静态方法是属于类的,在类加载的时候就会分配内存,可以通过类名直接访问。而非静态成员属于实例对象,只有在对象实例化之后才存在,需要通过类的实例对象去访问。
  2. 在类的非静态成员不存在的时候静态方法就已经存在了,此时调用在内存中还不存在的非静态成员,属于非法操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Public class Example {
// 定义一个字符型常量
Public static final char LETTER_A = 'A';

// 定义一个字符串常量
Public static final String GREETING_MESSAGE = "Hello, world!";

Public static void main (String[] args) {
// 输出字符型常量的值
System.Out.Println ("字符型常量的值为:" + LETTER_A);

// 输出字符串常量的值
System.Out.Println ("字符串常量的值为:" + GREETING_MESSAGE);
}
}

静态方法和实例方法有何不同?

1、调用方式

在外部调用静态方法时,可以使用 类名. 方法名 的方式,也可以使用 对象. 方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象

不过,需要注意的是一般不建议使用 对象. 方法名 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。

因此,一般建议使用 类名. 方法名 的方式来调用静态方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Public class Person {
Public void method () {
//......
}

Public static void staicMethod (){
//......
}
Public static void main (String[] args) {
Person person = new Person ();
// 调用实例方法
Person.Method ();
// 调用静态方法
Person.StaicMethod ()
}
}

2、访问类成员是否存在限制

静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法),而实例方法不存在这个限制。

重载和重写有什么区别?

重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理

重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法

重载

发生在同一个类中(或者父类和子类之间),方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。

《Java 核心技术》这本书是这样介绍重载的:

如果多个方法 (比如 StringBuilder 的构造方法)有相同的名字、不同的参数,便产生了重载。

编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。如果编译器找不到匹配的参数,就会产生编译时错误,因为根本不存在匹配,或者没有一个比其他的更好 (这个过程被称为重载解析 (overloading resolution))。

Java 允许重载任何方法,而不只是构造器方法。

综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。

重写

重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。

  1. 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。
  2. 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
  3. 构造方法无法被重写

总结

综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。

区别点 重载方法 重写方法
发生范围 同一个类 子类
参数列表 必须修改 一定不能修改
返回类型 可修改 子类方法返回值类型应比父类方法返回值类型更小或相等
异常 可修改 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
访问修饰符 可修改 一定不能做更严格的限制(可以降低限制)
发生阶段 编译期 运行期

方法的重写要遵循“两同两小一大”(以下内容摘录自《疯狂 Java 讲义》,issue#892 ):

  • “两同”即方法名相同、形参列表相同;
  • “两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
  • “一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。

⭐️ 关于 重写的返回值类型 这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。

什么是可变长参数?

从 Java 5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面的这个 printVariable 方法就可以接受 0 个或者多个参数。

1
2
3
Public static void method 1 (String... Args) {
//......
}

另外,可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。

1
2
3
Public static void method 2 (String arg 1, String... Args) {
//......
}

遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?

答案是会优先匹配固定参数的方法,因为固定参数的方法匹配度更高。

面向对象基础

面向对象和面向过程的区别

两者的主要区别在于解决问题的方式不同:

  • 面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。
  • 面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。

另外,面向对象开发的程序一般更易维护、易复用、易扩展。

创建一个对象用什么运算符? 对象实体与对象引用有何不同?

New 运算符,new 创建对象实例(对象实例在内存中),对象引用指向对象实例(对象引用存放在栈内存中)。

  • 一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);
  • 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。

对象的相等和引用相等的区别

  • 对象的相等一般比较的是内存中存放的内容是否相等。
  • 引用相等一般比较的是他们指向的内存地址是否相等。

如果一个类没有声明构造方法,该程序能正确执行吗?

构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。

如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。

我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。

构造方法有哪些特点?是否可被 override?

构造方法特点如下:

  • 名字与类名相同。
  • 没有返回值,但不能用 void 声明构造函数。
  • 生成类的对象时自动执行,无需调用。

构造方法不能被 override(重写), 但是可以 overload(重载), 所以你可以看到一个类中有多个构造函数的情况。

面向对象三大特征

封装

封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。 但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了(当然现在还有很多其他方法,这里只是为了举例子)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Public class Student {
Private int id;//id 属性私有化
Private String name;//name 属性私有化

//获取 id 的方法
Public int getId () {
Return id;
}

//设置 id 的方法
Public void setId (int id) {
This. Id = id;
}

//获取 name 的方法
Public String getName () {
Return name;
}

//设置 name 的方法
Public void setName (String name) {
This. Name = name;
}
}

继承

关于继承如下 3 点请记住:

  1. 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有
  2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
  3. 子类可以用自己的方式实现父类的方法。(以后介绍)。

多态

多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。

多态的特点:

  • 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
  • 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
  • 多态不能调用“只在子类存在但在父类不存在”的方法;
  • 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。

接口和抽象类有什么共同点和区别?

共同点

  • 都不能被实例化。
  • 都可以包含抽象方法。
  • 都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)。

区别

  • 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
  • 一个类只能继承一个类,但是可以实现多个接口。
  • 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。

深拷贝和浅拷贝区别了解吗?什么是引用拷贝?

关于深拷贝和浅拷贝区别,我这里先给结论:

  • 浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
  • 深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。

那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。

我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝:

浅拷贝、深拷贝、引用拷贝示意图

Object

Object 类的常见方法有哪些?

Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
*/
public final native Class<?> getClass ()
/**
* native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的 HashMap。
*/
Public native int hashCode ()
/**
* 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
*/
Public boolean equals (Object obj)
/**
* native 方法,用于创建并返回当前对象的一份拷贝。
*/
Protected native Object clone () throws CloneNotSupportedException
/**
* 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
*/
Public String toString ()
/**
* native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程 (监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
*/
Public final native void notify ()
/**
* native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
*/
Public final native void notifyAll ()
/**
* native 方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁,timeout 是等待时间。
*/
Public final native void wait (long timeout) throws InterruptedException
/**
* 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。所以超时的时间还需要加上 nanos 纳秒。。
*/
Public final void wait (long timeout, int nanos) throws InterruptedException
/**
* 跟之前的 2 个 wait 方法一样,只不过该方法一直等待,没有超时时间这个概念
*/
Public final void wait () throws InterruptedException
/**
* 实例被垃圾回收器回收的时候触发的操作
*/
Protected void finalize () throws Throwable { }

== 和 equals () 的区别

== 对于基本类型和引用类型的作用效果是不同的:

  • 对于基本数据类型来说,\=\= 比较的是值。
  • 对于引用数据类型来说,\=\= 比较的是对象的内存地址。

因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。

equals () 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals () 方法存在于 Object 类中,而 Object 类是所有类的直接或间接父类,因此所有的类都有 equals () 方法。

Objectequals () 方法:

1
2
3
Public boolean equals (Object obj) {
Return (this == obj);
}

equals () 方法存在两种使用情况:

  • 类没有重写 equals () 方法:通过 equals () 比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Objectequals () 方法。
  • 类重写了 equals () 方法:一般我们都重写 equals () 方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true (即,认为这两个对象相等)。

举个例子(这里只是为了举例。实际上,你按照下面这种写法的话,像 IDEA 这种比较智能的 IDE 都会提示你将 \=\= 换成 equals () ):

1
2
3
4
5
6
7
8
String a = new String ("ab"); // a 为一个引用
String b = new String ("ab"); // b 为另一个引用, 对象的内容一样
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
System.Out.Println (aa == bb);// true
System.Out.Println (a == b);// false
System.Out.Println (a.equals (b));// true
System.Out.Println (42 == 42.0);// true

String 中的 equals 方法是被重写过的,因为 Objectequals 方法是比较的对象的内存地址,而 Stringequals 方法比较的是对象的值。

当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 String 对象。

Stringequals () 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Public boolean equals (Object anObject) {
If (this == anObject) {
Return true;
}
If (anObject instanceof String) {
String anotherString = (String) anObject;
Int n = value. Length;
If (n == anotherString. Value. Length) {
Char v 1[] = value;
Char v 2[] = anotherString. Value;
Int i = 0;
While (n-- != 0) {
If (v 1[i] != v 2[i])
Return false;
I++;
}
Return true;
}
}
Return false;
}

HashCode () 有什么用?

hashCode () 的作用是获取哈希码(int 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。

hashCode() 方法

HashCode () 方法

hashCode () 定义在 JDK 的 Object 类中,这就意味着 Java 中的任何类都包含有 hashCode () 函数。另外需要注意的是:ObjecthashCode () 方法是本地方法,也就是用 C 语言或 C++ 实现的。

⚠️ 注意:该方法在 Oracle OpenJDK 8 中默认是 “使用线程局部状态来实现 Marsaglia’s xor-shift 随机数生成”, 并不是 “地址” 或者 “地址转换而来”, 不同 JDK/VM 可能不同在 Oracle OpenJDK 8 中有六种生成方式 (其中第五种是返回地址), 通过添加 VM 参数: -XX:hashCode=4 启用第五种。参考源码:

1
Public native int hashCode ();

散列表存储的是键值对 (key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)

为什么要有 hashCode?

我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode

下面这段内容摘自我的 Java 启蒙书《Head First Java》:

当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashCode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode 值作比较,如果没有相符的 hashCodeHashSet 会假设对象没有重复出现。但是如果发现有相同 hashCode 值的对象,这时会调用 equals () 方法来检查 hashCode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。

其实, hashCode ()equals () 都是用于比较两个对象是否相等。

那为什么 JDK 还要同时提供这两个方法呢?

这是因为在一些容器(比如 HashMapHashSet)中,有了 hashCode () 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进 HashSet 的过程)!

我们在前面也提到了添加元素进 HashSet 的过程,如果 HashSet 在对比的时候,同样的 hashCode 有多个对象,它会继续使用 equals () 来判断是否真的相同。也就是说 hashCode 帮助我们大大缩小了查找成本。

那为什么不只提供 hashCode () 方法呢?

这是因为两个对象的 hashCode 值相等并不代表两个对象就相等。

那为什么两个对象有相同的 hashCode 值,它们也不一定是相等的?

因为 hashCode () 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode )。

总结下来就是:

  • 如果两个对象的 hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。
  • 如果两个对象的 hashCode 值相等并且 equals () 方法也返回 true,我们才认为这两个对象相等。
  • 如果两个对象的 hashCode 值不相等,我们就可以直接认为这两个对象不相等。

相信大家看了我前面对 hashCode ()equals () 的介绍之后,下面这个问题已经难不倒你们了。

为什么重写 equals () 时必须重写 hashCode () 方法?

因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。

如果重写 equals () 时没有重写 hashCode () 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。

思考:重写 equals () 时没有重写 hashCode () 方法的话,使用 HashMap 可能会出现什么问题。

总结

  • equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
  • 两个对象有相同的 hashCode 值,他们也不一定是相等的(哈希碰撞)。

更多关于 hashCode ()equals () 的内容可以查看:Java hashCode() 和 equals()的若干问题解答

String

String、StringBuffer、StringBuilder 的区别?

可变性

String不可变的(后面会详细分析原因)。

StringBuilderStringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 finalprivate 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
Abstract class AbstractStringBuilder implements Appendable, CharSequence {
Char[] value;
Public AbstractStringBuilder append (String str) {
If (str == null)
Return appendNull ();
Int len = str.Length ();
EnsureCapacityInternal (count + len);
Str.GetChars (0, len, value, count);
Count += len;
Return this;
}
//...
}

线程安全性

String 中的对象是不可变的,也就可以理解为常量,线程安全AbstractStringBuilderStringBuilderStringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacityappendinsertindexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者使用的总结:

  1. 操作少量的数据: 适用 String
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

String 为什么是不可变的?

  1. 保存字符串的数组被 final 修饰且为私有的,并且 String 类没有提供/暴露修改这个字符串的方法。
  2. String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。
1
2
3
4
public final class String implements java. Io. Serializable, Comparable<String>, CharSequence {
Private final char value[];
//...
}

相关阅读:如何理解 String 类型值的不可变? - 知乎提问

字符串拼接用“+” 还是 StringBuilder?

Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。

1
2
3
4
String str 1 = "he";
String str 2 = "llo";
String str 3 = "world";
String str 4 = str 1 + str 2 + str 3;

上面的代码对应的字节码如下:

可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append () 方法实现的,拼接完成之后调用 toString () 得到一个 String 对象。

不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象

1
2
3
4
5
6
String[] arr = {"he", "llo", "world"};
String s = "";
For (int i = 0; i < arr. Length; i++) {
S += arr[i];
}
System. Out. Println (s);

StringBuilder 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。

如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。

1
2
3
4
5
6
String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder ();
For (String value : arr) {
s.append (value);
}
System. Out. Println (s);

如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。

不过,使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK 9 中得到了解决。在 JDK 9 当中,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants () 来实现,而不是大量的 StringBuilder 了。这个改进是 JDK 9 的 JEP 280 提出的,这也意味着 JDK 9 之后,你可以放心使用“+” 进行字符串拼接了。关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用 StringBuilder?来重温一下字符串拼接吧

String equals () 和 Object equals () 有何区别?

String 中的 equals 方法是被重写过的,比较的是 String 字符串的值是否相等。 Objectequals 方法是比较的对象的内存地址。

字符串常量池的作用了解吗?

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

1
2
3
4
5
6
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System. Out. Println (aa==bb);// true

String s 1 = new String (“abc”); 这句话创建了几个字符串对象?

会创建 1 或 2 个字符串对象。

1、如果字符串常量池中不存在字符串对象“abc”的引用,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。

示例代码(JDK 1.8):

1
String s 1 = new String ("abc");

对应的字节码:

ldc 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。

2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。

示例代码(JDK 1.8):

1
2
3
4
// 字符串常量池中已存在字符串对象“abc”的引用
String s 1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s 2 = new String ("abc");

对应的字节码:

这里就不对上面的字节码进行详细注释了,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 ldc 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。

String intern 方法有什么作用?

String. Intern () 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:

  • 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
  • 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。

示例代码(JDK 1.8) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 在堆中创建字符串对象”Java“
// 将字符串对象”Java“的引用保存在字符串常量池中
String s 1 = "Java";
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s 2 = s 1. Intern ();
// 会在堆中在单独创建一个字符串对象
String s 3 = new String ("Java");
// 直接返回字符串常量池中字符串对象”Java“对应的引用
String s 4 = s 3. Intern ();
// s 1 和 s 2 指向的是堆中的同一个对象
System. Out. Println (s 1 == s 2); // true
// s 3 和 s 4 指向的是堆中不同的对象
System. Out. Println (s 3 == s 4); // false
// s 1 和 s 4 指向的是堆中的同一个对象
System. Out. Println (s 1 == s 4); //true

String 类型的变量和常量做“+”运算时发生了什么?

先来看字符串不加 final 关键字拼接的情况(JDK 1.8):

1
2
3
4
5
6
7
8
String str 1 = "str";
String str 2 = "ing";
String str 3 = "str" + "ing";
String str 4 = str 1 + str 2;
String str 5 = "string";
System. Out. Println (str 3 == str 4);//false
System. Out. Println (str 3 == str 5);//true
System. Out. Println (str 4 == str 5);//false

注意:比较 String 字符串的值是否相等,可以使用 equals () 方法。 String 中的 equals 方法是被重写过的。 Objectequals 方法是比较的对象的内存地址,而 Stringequals 方法比较的是字符串的值是否相等。如果你使用 \=\= 比较两个字符串是否相等的话,IDEA 还是提示你使用 equals () 方法替换。

对于编译期可以确定值的字符串,也就是常量字符串,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。

在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠 (Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到:

常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一 (代码优化几乎都在即时编译器中进行)。

对于 String str 3 = "str" + "ing"; 编译器会给你优化成 String str 3 = "string";

并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:

  • 基本数据类型 ( bytebooleanshortcharintfloatlongdouble)以及字符串常量。
  • final 修饰的基本数据类型和字符串变量
  • 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )

引用的值在程序编译期是无法确定的,编译器无法对其进行优化。

对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append () 方法实现的,拼接完成之后调用 toString () 得到一个 String 对象。

1
String str 4 = new StringBuilder (). Append (str 1). Append (str 2). ToString ();

我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder 或者 StringBuffer

不过,字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。

示例代码:

1
2
3
4
5
6
Final String str 1 = "str";
Final String str 2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";// 常量池中的对象
String d = str 1 + str 2; // 常量池中的对象
System. Out. Println (c == d);// true

final 关键字修饰之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。

如果,编译器在运行时才能知道其确切值的话,就无法对其优化。

示例代码(str 2 在运行时才能确定其值):

1
2
3
4
5
6
7
8
Final String str 1 = "str";
Final String str 2 = getStr ();
String c = "str" + "ing";// 常量池中的对象
String d = str 1 + str 2; // 在堆上创建的新的对象
System. Out. Println (c == d);// false
Public static String getStr () {
Return "ing";
}

异常

Java 异常类层次结构图概览

Java 异常类层次结构图

Exception 和 Error 有什么区别?

在 Java 中,所有的异常都有一个共同的祖先 java. Lang 包中的 Throwable 类。Throwable 类有两个重要的子类:

  • Exception : 程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。
  • ErrorError 属于程序无法处理的错误,我们没办法通过 catch 来进行捕获不建议通过 catch 捕获。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误 (OutOfMemoryError)、类定义错误(NoClassDefFoundError)等。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。

Checked Exception 和 Unchecked Exception 有什么区别?

Checked Exception 即受检查异常,Java 代码在编译过程中,如果受检查异常没有被 catch 或者 throws 关键字处理的话,就没办法通过编译。

比如下面这段 IO 操作的代码:

除了 RuntimeException 及其子类以外,其他的 Exception 类及其子类都属于受检查异常。常见的受检查异常有:IO 相关的异常、ClassNotFoundExceptionSQLException…。

Unchecked Exception不受检查异常 ,Java 代码在编译过程中,我们即使不处理不受检查异常也可以正常通过编译。

RuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):

  • NullPointerException (空指针错误)
  • IllegalArgumentException (参数错误比如方法入参类型错误)
  • NumberFormatException(字符串转换为数字格式错误,IllegalArgumentException 的子类)
  • ArrayIndexOutOfBoundsException(数组越界错误)
  • ClassCastException(类型转换错误)
  • ArithmeticException(算术错误)
  • SecurityException (安全错误比如权限不够)
  • UnsupportedOperationException (不支持的操作错误比如重复创建同一用户)
  • ……

Throwable 类常用方法有哪些?

  • String getMessage (): 返回异常发生时的简要描述
  • String toString (): 返回异常发生时的详细信息
  • String getLocalizedMessage (): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage () 返回的结果相同
  • void printStackTrace (): 在控制台上打印 Throwable 对象封装的异常信息

Try-catch-finally 如何使用?

  • try 块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
  • catch 块:用于处理 try 捕获到的异常。
  • finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。

代码示例:

1
2
3
4
5
6
7
8
Try {
System.Out.Println ("Try to do something");
Throw new RuntimeException ("RuntimeException");
} catch (Exception e) {
System.Out.Println ("Catch Exception -> " + e.getMessage ());
} finally {
System.Out.Println ("Finally");
}

输出:

1
2
3
Try to do something
Catch Exception -> RuntimeException
Finally

注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。

Finally 中的代码一定会执行吗?

不一定的!在某些情况下,finally 中的代码不会被执行。

就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。

1
2
3
4
5
6
7
8
9
10
Try {
System.Out.Println ("Try to do something");
Throw new RuntimeException ("RuntimeException");
} catch (Exception e) {
System.Out.Println ("Catch Exception -> " + e.getMessage ());
// 终止当前正在运行的 Java 虚拟机
System.Exit (1);
} finally {
System.Out.Println ("Finally");
}

输出:

1
2
Try to do something
Catch Exception -> RuntimeException

另外,在以下 2 种特殊情况下,finally 块的代码也不会被执行:

  1. 程序所在的线程死亡。
  2. 关闭 CPU。

如何使用 try-with-resources 代替 try-catch-finally

  1. 适用范围(资源的定义): 任何实现 java. Lang. AutoCloseable 或者 java. Io. Closeable 的对象
  2. 关闭资源和 finally 块的执行顺序:try-with-resources 语句中,任何 catch 或 finally 块在声明的资源关闭后运行

《Effective Java》中明确指出:

面对必须要关闭的资源,我们总是应该优先使用 try-with-resources 而不是 try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources 语句让我们更容易编写必须要关闭的资源的代码,若采用 try-finally 则几乎做不到这点。

Java 中类似于 InputStreamOutputStreamScannerPrintWriter 等的资源都需要我们调用 close () 方法来手动关闭,一般情况下我们都是通过 try-catch-finally 语句来实现这个需求,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//读取文本文件的内容
Scanner scanner = null;
Try {
Scanner = new Scanner (new File ("D://read. Txt"));
While (scanner.HasNext ()) {
System.Out.Println (scanner.NextLine ());
}
} catch (FileNotFoundException e) {
e.printStackTrace ();
} finally {
If (scanner != null) {
Scanner.Close ();
}
}

使用 Java 7 之后的 try-with-resources 语句改造上面的代码:

1
2
3
4
5
6
7
Try (Scanner scanner = new Scanner (new File ("test. Txt"))) {
While (scanner.HasNext ()) {
System.Out.Println (scanner.NextLine ());
}
} catch (FileNotFoundException fnfe) {
Fnfe.PrintStackTrace ();
}

当然多个资源需要关闭的时候,使用 try-with-resources 实现起来也非常简单,如果你还是用 try-catch-finally 可能会带来很多问题。

通过使用分号分隔,可以在 try-with-resources 块中声明多个资源。

1
2
3
4
5
6
7
8
9
10
Try (BufferedInputStream bin = new BufferedInputStream (new FileInputStream (new File ("test. Txt")));
BufferedOutputStream bout = new BufferedOutputStream (new FileOutputStream (new File ("out. Txt")))) {
Int b;
While ((b = bin.Read ()) != -1) {
Bout.Write (b);
}
}
Catch (IOException e) {
e.printStackTrace ();
}

异常使用有哪些需要注意的地方?

  • 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
  • 抛出的异常信息一定要有意义。
  • 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出 NumberFormatException 而不是其父类 IllegalArgumentException
  • 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在一段代码逻辑中)。
  • ……

泛型

什么是泛型?有什么作用?

Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。

编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如 ArrayList<Person> persons = new ArrayList<Person>() 这行代码就指明了该 ArrayList 对象只能传入 Person 对象,如果传入其他类型的对象就会报错。

1
ArrayList<E> extends AbstractList<E>

并且,原生 List 返回类型是 Object ,需要手动转换类型才能使用,使用泛型后编译器自动转换。

泛型的使用方式有哪几种?

泛型一般有三种使用方式:泛型类泛型接口泛型方法

1. 泛型类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//此处 T 可以随便写为任意标识,常见的如 T、E、K、V 等形式的参数常用于表示泛型
//在实例化泛型类时,必须指定 T 的具体类型
public class Generic<T>{

Private T key;

Public Generic (T key) {
This. Key = key;
}

Public T getKey (){
Return key;
}
}

如何实例化泛型类:

1
Generic<Integer> genericInteger = new Generic<Integer>(123456);

2. 泛型接口

1
2
3
public interface Generator<T> {
Public T method ();
}

实现泛型接口,不指定类型:

1
2
3
4
5
6
class GeneratorImpl<T> implements Generator<T>{
@Override
Public T method () {
Return null;
}
}

实现泛型接口,指定类型:

1
2
3
4
5
6
class GeneratorImpl<T> implements Generator<String>{
@Override
Public String method () {
Return "hello";
}
}

3. 泛型方法

1
2
3
4
5
6
7
public static < E > void printArray ( E[] inputArray )
{
For ( E element : inputArray ){
System.Out.Printf ( "%s ", element );
}
System.Out.Println ();
}

使用:

1
2
3
4
5
// 创建不同类型数组:Integer, Double 和 Character
Integer[] intArray = { 1, 2, 3 };
String[] stringArray = { "Hello", "World" };
PrintArray ( intArray );
PrintArray ( stringArray );

注意: public static < E > void printArray ( E[] inputArray ) 一般被称为静态泛型方法; 在 java 中泛型只是一个占位符,必须在传递类型后才能使用。类在实例化时才能真正的传递类型参数,由于静态方法的加载先于类的实例化,也就是说类中的泛型还没有传递真正的类型参数,静态的方法的加载就已经完成了,所以静态泛型方法是没有办法使用类上声明的泛型的。只能使用自己声明的 <E>

项目中哪里用到了泛型?

  • 自定义接口通用返回结果 CommonResult<T> 通过参数 T 可根据具体的返回类型动态指定结果的数据类型
  • 定义 Excel 处理类 ExcelUtil<T> 用于动态指定 Excel 导出的数据类型
  • 构建集合工具类(参考 Collections 中的 sort, binarySearch 方法)。

反射

何谓反射?

反射赋予了我们在运行时分析类以及执行类中方法的能力。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。

反射的优缺点?

优点:可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利

缺点:让我们在运行时有了分析操作类的能力,这同样也增加了安全问题。比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。

相关阅读:Java Reflection: Why is it so slow?

反射的应用场景?

像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。但是!这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。

这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。

比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 Method 来调用指定的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Public class DebugInvocationHandler implements InvocationHandler {
/**
* 代理类中的真实对象
*/
Private final Object target;

Public DebugInvocationHandler (Object target) {
This. Target = target;
}

Public Object invoke (Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
System.Out.Println ("before method " + method.GetName ());
Object result = method.Invoke (target, args);
System.Out.Println ("after method " + method.GetName ());
Return result;
}
}

另外,像 Java 中的一大利器 注解 的实现也用到了反射。

为什么你使用 Spring 的时候,一个 @Component 注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value 注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?

这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。

反射实战

获取 Class 对象的四种方式

如果我们动态获取到这些信息,我们需要依靠 Class 对象。Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了四种方式获取 Class 对象:

1. 知道具体类的情况下可以使用:

1
Class alunbarClass = TargetObject. Class;

但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化

2. 通过 Class.ForName () 传入类的全路径获取:

1
Class alunbarClass 1 = Class.ForName ("cn. Javaguide. TargetObject");

3. 通过对象实例 instance.GetClass () 获取:

1
2
TargetObject o = new TargetObject ();
Class alunbarClass 2 = o.getClass ();

4. 通过类加载器 xxxClassLoader.LoadClass () 传入类路径获取:

1
ClassLoader.GetSystemClassLoader (). LoadClass ("cn. Javaguide. TargetObject");

通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行

反射的一些基本操作

  1. 创建一个我们要使用反射操作的类 TargetObject
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Package cn. Javaguide;

Public class TargetObject {
Private String value;

Public TargetObject () {
Value = "JavaGuide";
}

Public void publicMethod (String s) {
System.Out.Println ("I love " + s);
}

Private void privateMethod () {
System.Out.Println ("value is " + value);
}
}
  1. 使用反射操作这个类的方法以及参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Package cn. Javaguide;

Import java. Lang. Reflect. Field;
Import java. Lang. Reflect. InvocationTargetException;
Import java. Lang. Reflect. Method;

Public class Main {
Public static void main (String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchFieldException {
/**
* 获取 TargetObject 类的 Class 对象并且创建 TargetObject 类实例
*/
Class<?> targetClass = Class.ForName ("cn. Javaguide. TargetObject");
TargetObject targetObject = (TargetObject) targetClass.NewInstance ();
/**
* 获取 TargetObject 类中定义的所有方法
*/
Method[] methods = targetClass.GetDeclaredMethods ();
For (Method method : methods) {
System.Out.Println (method.GetName ());
}

/**
* 获取指定方法并调用
*/
Method publicMethod = targetClass.GetDeclaredMethod ("publicMethod",
String. Class);

PublicMethod.Invoke (targetObject, "JavaGuide");

/**
* 获取指定参数并对参数进行修改
*/
Field field = targetClass.GetDeclaredField ("value");
//为了对类中的参数进行修改我们取消安全检查
Field.SetAccessible (true);
Field.Set (targetObject, "JavaGuide");

/**
* 调用 private 方法
*/
Method privateMethod = targetClass.GetDeclaredMethod ("privateMethod");
//为了调用 private 方法我们取消安全检查
PrivateMethod.SetAccessible (true);
PrivateMethod.Invoke (targetObject);
}
}

输出内容:

1
2
3
4
PublicMethod
PrivateMethod
I love JavaGuide
Value is JavaGuide

1. 代理模式

代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象 (real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。

代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。

2. 静态代理

静态代理中,我们对目标对象的每个方法的增强都是手动完成的(_后面会具体演示代码_),非常不灵活(_比如接口一旦新增加方法,目标对象和代理对象都要进行修改_)且麻烦 (_需要对每个目标类都单独写一个代理类_)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。

上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。

静态代理实现步骤:

  1. 定义一个接口及其实现类;
  2. 创建一个代理类同样实现这个接口
  3. 将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。

下面通过代码展示!

1. 定义发送短信的接口

1
2
3
Public interface SmsService {
String send (String message);
}

2. 实现发送短信的接口

1
2
3
4
5
6
Public class SmsServiceImpl implements SmsService {
Public String send (String message) {
System.Out.Println ("send message: " + message);
Return message;
}
}

3. 创建代理类并同样实现发送短信的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Public class SmsProxy implements SmsService {

Private final SmsService smsService;

Public SmsProxy (SmsService smsService) {
This. SmsService = smsService;
}

@Override
Public String send (String message) {
//调用方法之前,我们可以添加自己的操作
System.Out.Println ("before method send ()");
SmsService.Send (message);
//调用方法之后,我们同样可以添加自己的操作
System.Out.Println ("after method send ()");
Return null;
}
}

4. 实际使用

1
2
3
4
5
6
7
Public class Main {
Public static void main (String[] args) {
SmsService smsService = new SmsServiceImpl ();
SmsProxy smsProxy = new SmsProxy (smsService);
SmsProxy.Send ("java");
}
}

运行上述代码之后,控制台打印出:

1
2
3
Before method send ()
Send message:java
After method send ()

可以输出结果看出,我们已经增加了 SmsServiceImplsend () 方法。

3. 动态代理

相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类 (CGLIB 动态代理机制)。

从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。

说到动态代理,Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。

动态代理在我们日常开发中使用的相对较少,但是在框架中的几乎是必用的一门技术。学会了动态代理之后,对于我们理解和学习各种框架的原理也非常有帮助。

就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理CGLIB 动态代理等等。

guide-rpc-framework 使用的是 JDK 动态代理,我们先来看看 JDK 动态代理的使用。

另外,虽然 guide-rpc-framework 没有用到 CGLIB 动态代理 ,我们这里还是简单介绍一下其使用以及和JDK 动态代理的对比。

3.1. JDK 动态代理机制

3.1.1. 介绍

在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心。

Proxy 类中使用频率最高的方法是:newProxyInstance () ,这个方法主要用来生成一个代理对象。

1
2
3
4
5
6
7
Public static Object newProxyInstance (ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
Throws IllegalArgumentException
{
......
}

这个方法一共有 3 个参数:

  1. loader : 类加载器,用于加载代理对象。
  2. interfaces : 被代理类实现的一些接口;
  3. h : 实现了 InvocationHandler 接口的对象;

要实现动态代理的话,还必须需要实现 InvocationHandler 来自定义处理逻辑。当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现 InvocationHandler 接口类的 invoke 方法来调用。

1
2
3
4
5
6
7
8
Public interface InvocationHandler {

/**
* 当你使用代理对象调用方法的时候实际会调用到这个方法
*/
Public Object invoke (Object proxy, Method method, Object[] args)
Throws Throwable;
}

invoke () 方法有下面三个参数:

  1. proxy : 动态生成的代理类
  2. method : 与代理类对象调用的方法相对应
  3. args : 当前 method 方法的参数

也就是说:你通过 Proxy 类的 newProxyInstance () 创建的代理对象在调用方法的时候,实际会调用到实现 InvocationHandler 接口的类的 invoke () 方法。 你可以在 invoke () 方法中自定义处理逻辑,比如在方法执行前后做什么事情。

3.1.2. JDK 动态代理类使用步骤
  1. 定义一个接口及其实现类;
  2. 自定义 InvocationHandler 并重写 invoke 方法,在 invoke 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑;
  3. 通过 Proxy.NewProxyInstance (ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) 方法创建代理对象;
3.1.3. 代码示例

这样说可能会有点空洞和难以理解,我上个例子,大家感受一下吧!

1. 定义发送短信的接口

1
2
3
Public interface SmsService {
String send (String message);
}

2. 实现发送短信的接口

1
2
3
4
5
6
Public class SmsServiceImpl implements SmsService {
Public String send (String message) {
System.Out.Println ("send message: " + message);
Return message;
}
}

3. 定义一个 JDK 动态代理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Import java. Lang. Reflect. InvocationHandler;
Import java. Lang. Reflect. InvocationTargetException;
Import java. Lang. Reflect. Method;

/**
* @author shuang. Kou
* @createTime 2020 年 05 月 11 日 11:23:00
*/
Public class DebugInvocationHandler implements InvocationHandler {
/**
* 代理类中的真实对象
*/
Private final Object target;

Public DebugInvocationHandler (Object target) {
This. Target = target;
}


Public Object invoke (Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
//调用方法之前,我们可以添加自己的操作
System.Out.Println ("before method " + method.GetName ());
Object result = method.Invoke (target, args);
//调用方法之后,我们同样可以添加自己的操作
System.Out.Println ("after method " + method.GetName ());
Return result;
}
}

invoke () 方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 invoke () 方法,然后 invoke () 方法代替我们去调用了被代理对象的原生方法。

4. 获取代理对象的工厂类

1
2
3
4
5
6
7
8
9
Public class JdkProxyFactory {
Public static Object getProxy (Object target) {
Return Proxy.NewProxyInstance (
Target.GetClass (). GetClassLoader (), // 目标类的类加载器
Target.GetClass (). GetInterfaces (), // 代理需要实现的接口,可指定多个
New DebugInvocationHandler (target) // 代理对象对应的自定义 InvocationHandler
);
}
}

getProxy ():主要通过 Proxy. NewProxyInstance() 方法获取某个类的代理对象

5. 实际使用

1
2
SmsService smsService = (SmsService) JdkProxyFactory.GetProxy (new SmsServiceImpl ());
SmsService.Send ("java");

运行上述代码之后,控制台打印出:

1
2
3
Before method send
Send message:java
After method send

3.2. CGLIB 动态代理机制

3.2.1. 介绍

JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。

为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。

CGLIB (Code Generation Library)是一个基于 ASM 的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了 CGLIB,例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。

在 CGLIB 动态代理机制中 MethodInterceptor 接口和 Enhancer 类是核心。

你需要自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法。

1
2
3
4
5
6
Public interface MethodInterceptor
Extends Callback{
// 拦截被代理类中的方法
Public Object intercept (Object obj, java. Lang. Reflect. Method method, Object[] args, MethodProxy proxy) throws Throwable;
}

  1. obj : 被代理的对象(需要增强的对象)
  2. method : 被拦截的方法(需要增强的方法)
  3. args : 方法入参
  4. proxy : 用于调用原始方法

你可以通过 Enhancer 类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor 中的 intercept 方法。

3.2.2. CGLIB 动态代理类使用步骤
  1. 定义一个类;
  2. 自定义 MethodInterceptor 并重写 intercept 方法,intercept 用于拦截增强被代理类的方法,和 JDK 动态代理中的 invoke 方法类似;
  3. 通过 Enhancer 类的 create () 创建代理类;
3.2.3. 代码示例

不同于 JDK 动态代理不需要额外的依赖。CGLIB (Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。

1
2
3
4
5
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>

1. 实现一个使用阿里云发送短信的类

1
2
3
4
5
6
7
8
Package github. Javaguide. DynamicProxy. CglibDynamicProxy;

Public class AliSmsService {
Public String send (String message) {
System.Out.Println ("send message: " + message);
Return message;
}
}

2. 自定义 MethodInterceptor(方法拦截器)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Import net. Sf. Cglib. Proxy. MethodInterceptor;
Import net. Sf. Cglib. Proxy. MethodProxy;

Import java. Lang. Reflect. Method;

/**
* 自定义 MethodInterceptor
*/
Public class DebugMethodInterceptor implements MethodInterceptor {


/**
* @param o 被代理的对象(需要增强的对象)
* @param method 被拦截的方法(需要增强的方法)
* @param args 方法入参
* @param methodProxy 用于调用原始方法
*/
@Override
Public Object intercept (Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
//调用方法之前,我们可以添加自己的操作
System.Out.Println ("before method " + method.GetName ());
Object object = methodProxy.InvokeSuper (o, args);
//调用方法之后,我们同样可以添加自己的操作
System.Out.Println ("after method " + method.GetName ());
Return object;
}

}

3. 获取代理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Import net. Sf. Cglib. Proxy. Enhancer;

Public class CglibProxyFactory {

public static Object getProxy (Class<?> clazz) {
// 创建动态代理增强类
Enhancer enhancer = new Enhancer ();
// 设置类加载器
Enhancer.SetClassLoader (clazz.GetClassLoader ());
// 设置被代理类
Enhancer.SetSuperclass (clazz);
// 设置方法拦截器
Enhancer.SetCallback (new DebugMethodInterceptor ());
// 创建代理类
Return enhancer.Create ();
}
}

4. 实际使用

1
2
AliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.GetProxy (AliSmsService. Class);
AliSmsService.Send ("java");

运行上述代码之后,控制台打印出:

1
2
3
Before method send
Send message:java
After method send

3.3. JDK 动态代理和 CGLIB 动态代理对比

  1. JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
  2. 就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。

4. 静态代理和动态代理的对比

  1. 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
  2. JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。

注解

何谓注解?

Annotation (注解) 是 Java 5 开始引入的新特性,可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。

注解本质是一个继承了 Annotation 的特殊接口:

1
2
3
4
5
6
7
8
9
@Target (ElementType. METHOD)
@Retention (RetentionPolicy. SOURCE)
Public @interface Override {

}

Public interface Override extends Annotation{

}

JDK 提供了很多内置的注解(比如 @Override@Deprecated),同时,我们还可以自定义注解。

注解的解析方法有哪几种?

注解只有被解析之后才会生效,常见的解析方法有两种:

  • 编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用 @Override 注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。
  • 运行期通过反射处理:像框架中自带的注解 (比如 Spring 框架的 @Value@Component)都是通过反射来进行处理的。

SPI

SPI 介绍

何谓 SPI?

SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。

SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。

很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。

SPI 和 API 有什么区别?

那 SPI 和 API 有啥区别?

说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下:

一般模块之间都是通过通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。

当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。

当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。

举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。

SPI 的优缺点?

通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:

  • 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
  • 当多个 ServiceLoader 同时 load 时,会有并发问题。

实战演示

SLF 4 J (Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log 4 j、Log 4 j 2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好了。

这就是依赖 SPI 机制实现的,那我们接下来就实现一个简易版本的日志框架。

Service Provider Interface

新建一个 Java 项目 service-provider-interface 目录结构如下:(注意直接新建 Java 项目就好了,不用新建 Maven 项目,Maven 项目会涉及到一些编译配置,如果有私服的话,直接 deploy 会比较方便,但是没有的话,在过程中可能会遇到一些奇怪的问题。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
│  service-provider-interface. Iml

├─. Idea
│ │ .gitignore
│ │ misc. Xml
│ │ modules. Xml
│ └─ workspace. Xml

└─src
└─edu
└─jiangxuan
└─up
└─spi
Logger. Java
LoggerService. Java
Main. Class

新建 Logger 接口,这个就是 SPI ,服务提供者接口,后面的服务提供者就要针对这个接口进行实现。

1
2
3
4
5
6
Package edu. Jiangxuan. Up. Spi;

Public interface Logger {
Void info (String msg);
Void debug (String msg);
}

接下来就是 LoggerService 类,这个主要是为服务使用者(调用方)提供特定功能的。这个类也是实现 Java SPI 机制的关键所在,如果存在疑惑的话可以先往后面继续看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Package edu. Jiangxuan. Up. Spi;

Import java. Util. ArrayList;
Import java. Util. List;
Import java. Util. ServiceLoader;

Public class LoggerService {
Private static final LoggerService SERVICE = new LoggerService ();

Private final Logger logger;

private final List<Logger> loggerList;

Private LoggerService () {
ServiceLoader<Logger> loader = ServiceLoader.Load (Logger. Class);
List<Logger> list = new ArrayList<>();
For (Logger log : loader) {
List.Add (log);
}
// LoggerList 是所有 ServiceProvider
LoggerList = list;
If (! List.IsEmpty ()) {
// Logger 只取一个
Logger = list.Get (0);
} else {
Logger = null;
}
}

Public static LoggerService getService () {
Return SERVICE;
}

Public void info (String msg) {
If (logger == null) {
System.Out.Println ("info 中没有发现 Logger 服务提供者");
} else {
Logger.Info (msg);
}
}

Public void debug (String msg) {
If (loggerList.IsEmpty ()) {
System.Out.Println ("debug 中没有发现 Logger 服务提供者");
}
LoggerList.ForEach (log -> log.Debug (msg));
}
}

新建 Main 类(服务使用者,调用方),启动程序查看结果。

1
2
3
4
5
6
7
8
9
10
Package org. Spi. Service;

Public class Main {
Public static void main (String[] args) {
LoggerService service = LoggerService.GetService ();

Service.Info ("Hello SPI");
Service.Debug ("Hello SPI");
}
}

程序结果:

Info 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者

此时我们只是空有接口,并没有为 Logger 接口提供任何的实现,所以输出结果中没有按照预期打印相应的结果。

你可以使用命令或者直接使用 IDEA 将整个程序直接打包成 jar 包。

Service Provider

接下来新建一个项目用来实现 Logger 接口

新建项目 service-provider 目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
│  service-provider. Iml

├─. Idea
│ │ .gitignore
│ │ misc. Xml
│ │ modules. Xml
│ └─ workspace. Xml

├─lib
│ service-provider-interface. Jar
|
└─src
├─edu
│ └─jiangxuan
│ └─up
│ └─spi
│ └─service
│ Logback. Java

└─META-INF
└─services
Edu. Jiangxuan. Up. Spi. Logger

新建 Logback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Package edu. Jiangxuan. Up. Spi. Service;

Import edu. Jiangxuan. Up. Spi. Logger;

Public class Logback implements Logger {
@Override
Public void info (String s) {
System.Out.Println ("Logback info 打印日志:" + s);
}

@Override
Public void debug (String s) {
System.Out.Println ("Logback debug 打印日志:" + s);
}
}

service-provider-interface 的 jar 导入项目中。

新建 lib 目录,然后将 jar 包拷贝过来,再添加到项目中。

再点击 OK 。

接下来就可以在项目中导入 jar 包里面的一些类和方法了,就像 JDK 工具类导包一样的。

实现 Logger 接口,在 src 目录下新建 META-INF/services 文件夹,然后新建文件 edu. Jiangxuan. Up. Spi. Logger (SPI 的全类名),文件里面的内容是:edu. Jiangxuan. Up. Spi. Service. Logback (Logback 的全类名,即 SPI 的实现类的包名 + 类名)。

这是 JDK SPI 机制 ServiceLoader 约定好的标准。

这里先大概解释一下:Java 中的 SPI 机制就是在每次类加载的时候会先去找到 class 相对目录下的 META-INF 文件夹下的 services 文件夹下的文件,将这个文件夹下面的所有文件先加载到内存中,然后根据这些文件的文件名和里面的文件内容找到相应接口的具体实现类,找到实现类后就可以通过反射去生成对应的对象,保存在一个 list 列表里面,所以可以通过迭代或者遍历的方式拿到对应的实例对象,生成不同的实现。

所以会提出一些规范要求:文件名一定要是接口的全类名,然后里面的内容一定要是实现类的全类名,实现类可以有多个,直接换行就好了,多个实现类的时候,会一个一个的迭代加载。

接下来同样将 service-provider 项目打包成 jar 包,这个 jar 包就是服务提供方的实现。通常我们导入 maven 的 pom 依赖就有点类似这种,只不过我们现在没有将这个 jar 包发布到 maven 公共仓库中,所以在需要使用的地方只能手动的添加到项目中。

效果展示

为了更直观的展示效果,我这里再新建一个专门用来测试的工程项目:java-spi-test

然后先导入 Logger 的接口 jar 包,再导入具体的实现类的 jar 包。

新建 Main 方法测试:

1
2
3
4
5
6
7
8
9
10
11
Package edu. Jiangxuan. Up. Service;

Import edu. Jiangxuan. Up. Spi. LoggerService;

Public class TestJavaSPI {
Public static void main (String[] args) {
LoggerService loggerService = LoggerService.GetService ();
LoggerService.Info ("你好");
LoggerService.Debug ("测试 Java SPI 机制");
}
}

运行结果如下:

Logback info 打印日志:你好 Logback debug 打印日志:测试 Java SPI 机制

说明导入 jar 包中的实现类生效了。

如果我们不导入具体的实现类的 jar 包,那么此时程序运行的结果就会是:

Info 中没有发现 Logger 服务提供者 debug 中没有发现 Logger 服务提供者

通过使用 SPI 机制,可以看出服务(LoggerService)和服务提供者两者之间的耦合度非常低,如果说我们想要换一种实现,那么其实只需要修改 service-provider 项目中针对 Logger 接口的具体实现就可以了,只需要换一个 jar 包即可,也可以有在一个项目里面有多个实现,这不就是 SLF 4 J 原理吗?

如果某一天需求变更了,此时需要将日志输出到消息队列,或者做一些别的操作,这个时候完全不需要更改 Logback 的实现,只需要新增一个服务实现(service-provider)可以通过在本项目里面新增实现也可以从外部引入新的服务实现 jar 包。我们可以在服务 (LoggerService)中选择一个具体的服务实现 (service-provider) 来完成我们需要的操作。

那么接下来我们具体来说说 Java SPI 工作的重点原理—— ServiceLoader

ServiceLoader

ServiceLoader 具体实现

想要使用 Java 的 SPI 机制是需要依赖 ServiceLoader 来实现的,那么我们接下来看看 ServiceLoader 具体是怎么做的:

ServiceLoader 是 JDK 提供的一个工具类,位于 package java. Util; 包下。

1
A facility to load implementations of a service.

这是 JDK 官方给的注释:一种加载服务实现的工具。

再往下看,我们发现这个类是一个 final 类型的,所以是不可被继承修改,同时它实现了 Iterable 接口。之所以实现了迭代器,是为了方便后续我们能够通过迭代的方式得到对应的服务实现。

1
public final class ServiceLoader<S> implements Iterable<S>{ xxx...}

可以看到一个熟悉的常量定义:

private static final String PREFIX = "META-INF/services/";

下面是 load 方法:可以发现 load 方法支持两种重载后的入参;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static <S> ServiceLoader<S> load (Class<S> service) {
ClassLoader cl = Thread.CurrentThread (). GetContextClassLoader ();
Return ServiceLoader.Load (service, cl);
}

public static <S> ServiceLoader<S> load (Class<S> service,
ClassLoader loader) {
return new ServiceLoader<>(service, loader);
}

private ServiceLoader (Class<S> svc, ClassLoader cl) {
Service = Objects.RequireNonNull (svc, "Service interface cannot be null");
Loader = (cl == null) ? ClassLoader.GetSystemClassLoader () : cl;
Acc = (System.GetSecurityManager () != null) ? AccessController.GetContext () : null;
Reload ();
}

Public void reload () {
Providers.Clear ();
LookupIterator = new LazyIterator (service, loader);
}

根据代码的调用顺序,在 reload () 方法中是通过一个内部类 LazyIterator 实现的。先继续往下面看。

ServiceLoader 实现了 Iterable 接口的方法后,具有了迭代的能力,在这个 iterator 方法被调用时,首先会在 ServiceLoaderProvider 缓存中进行查找,如果缓存中没有命中那么则在 LazyIterator 中进行查找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

public Iterator<S> iterator () {
return new Iterator<S>() {

Iterator<Map.Entry<String, S>> knownProviders
= providers.EntrySet (). Iterator ();

Public boolean hasNext () {
If (knownProviders.HasNext ())
Return true;
Return lookupIterator.HasNext (); // 调用 LazyIterator
}

Public S next () {
If (knownProviders.HasNext ())
Return knownProviders.Next (). GetValue ();
Return lookupIterator.Next (); // 调用 LazyIterator
}

Public void remove () {
Throw new UnsupportedOperationException ();
}

};
}

在调用 LazyIterator 时,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81

Public boolean hasNext () {
If (acc == null) {
Return hasNextService ();
} else {
PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
Public Boolean run () {
Return hasNextService ();
}
};
Return AccessController.DoPrivileged (action, acc);
}
}

Private boolean hasNextService () {
If (nextName != null) {
Return true;
}
If (configs == null) {
Try {
//通过 PREFIX(META-INF/services/)和类名获取对应的配置文件,得到具体的实现类
String fullName = PREFIX + service.GetName ();
If (loader == null)
Configs = ClassLoader.GetSystemResources (fullName);
Else
Configs = loader.GetResources (fullName);
} catch (IOException x) {
Fail (service, "Error locating configuration files", x);
}
}
While ((pending == null) || !Pending.HasNext ()) {
If (! Configs.HasMoreElements ()) {
Return false;
}
Pending = parse (service, configs.NextElement ());
}
NextName = pending.Next ();
Return true;
}


Public S next () {
If (acc == null) {
Return nextService ();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
Public S run () {
Return nextService ();
}
};
Return AccessController.DoPrivileged (action, acc);
}
}

Private S nextService () {
If (! HasNextService ())
Throw new NoSuchElementException ();
String cn = nextName;
NextName = null;
Class<?> c = null;
Try {
C = Class.ForName (cn, false, loader);
} catch (ClassNotFoundException x) {
Fail (service,
"Provider " + cn + " not found");
}
If (! Service.IsAssignableFrom (c)) {
Fail (service,
"Provider " + cn + " not a subtype");
}
Try {
S p = service.Cast (c.newInstance ());
Providers.Put (cn, p);
Return p;
} catch (Throwable x) {
Fail (service,
"Provider " + cn + " could not be instantiated",
X);
}
Throw new Error (); // This cannot happen
}

可能很多人看这个会觉得有点复杂,没关系,我这边实现了一个简单的 ServiceLoader 的小模型,流程和原理都是保持一致的,可以先从自己实现一个简易版本的开始学:

自己实现一个 ServiceLoader

我先把代码贴出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
Package edu. Jiangxuan. Up. Service;

Import java. Io. BufferedReader;
Import java. Io. InputStream;
Import java. Io. InputStreamReader;
Import java. Lang. Reflect. Constructor;
Import java. Net. URL;
Import java. Net. URLConnection;
Import java. Util. ArrayList;
Import java. Util. Enumeration;
Import java. Util. List;

public class MyServiceLoader<S> {

// 对应的接口 Class 模板
private final Class<S> service;

// 对应实现类的可以有多个,用 List 进行封装
private final List<S> providers = new ArrayList<>();

// 类加载器
Private final ClassLoader classLoader;

// 暴露给外部使用的方法,通过调用这个方法可以开始加载自己定制的实现流程。
public static <S> MyServiceLoader<S> load (Class<S> service) {
return new MyServiceLoader<>(service);
}

// 构造方法私有化
private MyServiceLoader (Class<S> service) {
This. Service = service;
This. ClassLoader = Thread.CurrentThread (). GetContextClassLoader ();
DoLoad ();
}

// 关键方法,加载具体实现类的逻辑
Private void doLoad () {
Try {
// 读取所有 jar 包里面 META-INF/services 包下面的文件,这个文件名就是接口名,然后文件里面的内容就是具体的实现类的路径加全类名
Enumeration<URL> urls = classLoader.GetResources ("META-INF/services/" + service.GetName ());
// 挨个遍历取到的文件
While (urls.HasMoreElements ()) {
// 取出当前的文件
URL url = urls.NextElement ();
System.Out.Println ("File = " + url.GetPath ());
// 建立链接
URLConnection urlConnection = url.OpenConnection ();
UrlConnection.SetUseCaches (false);
// 获取文件输入流
InputStream inputStream = urlConnection.GetInputStream ();
// 从文件输入流获取缓存
BufferedReader bufferedReader = new BufferedReader (new InputStreamReader (inputStream));
// 从文件内容里面得到实现类的全类名
String className = bufferedReader.ReadLine ();

While (className != null) {
// 通过反射拿到实现类的实例
Class<?> clazz = Class.ForName (className, false, classLoader);
// 如果声明的接口跟这个具体的实现类是属于同一类型,(可以理解为 Java 的一种多态,接口跟实现类、父类和子类等等这种关系。)则构造实例
If (service.IsAssignableFrom (clazz)) {
Constructor<? extends S> constructor = (Constructor<? extends S>) clazz.GetConstructor ();
S instance = constructor.NewInstance ();
// 把当前构造的实例对象添加到 Provider 的列表里面
Providers.Add (instance);
}
// 继续读取下一行的实现类,可以有多个实现类,只需要换行就可以了。
ClassName = bufferedReader.ReadLine ();
}
}
} catch (Exception e) {
System.Out.Println ("读取文件异常。。。");
}
}

// 返回 spi 接口对应的具体实现类列表
public List<S> getProviders () {
Return providers;
}
}

关键信息基本已经通过代码注释描述出来了,

主要的流程就是:

  1. 通过 URL 工具类从 jar 包的 /META-INF/services 目录下面找到对应的文件,
  2. 读取这个文件的名称找到对应的 spi 接口,
  3. 通过 InputStream 流将文件里面的具体实现类的全类名读取出来,
  4. 根据获取到的全类名,先判断跟 spi 接口是否为同一类型,如果是的,那么就通过反射的机制构造对应的实例对象,
  5. 将构造出来的实例对象添加到 Providers 的列表中。

总结

其实不难发现,SPI 机制的具体实现本质上还是通过反射完成的。即:我们按照规定将要暴露对外使用的具体实现类在 META-INF/services/ 文件下声明。

另外,SPI 机制在很多框架中都有应用:Spring 框架的基本原理也是类似的方式。还有 Dubbo 框架提供同样的 SPI 扩展机制,只不过 Dubbo 和 spring 框架中的 SPI 机制具体实现方式跟咱们今天学得这个有些细微的区别,不过整体的原理都是一致的,相信大家通过对 JDK 中 SPI 机制的学习,能够一通百通,加深对其他高深框的理解。

通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:

  1. 遍历加载所有的实现类,这样效率还是相对较低的;
  2. 当多个 ServiceLoader 同时 load 时,会有并发问题。

序列化和反序列化

什么是序列化? 什么是反序列化?

如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。

简单来说:

  • 序列化:将数据结构或对象转换成二进制字节流的过程
  • 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类 (Class),但是在 C++这种半面向对象的语言中,struct (结构体)定义的是数据结构类型,而 class 对应的是对象类型。

下面是序列化和反序列化常见应用场景:

  • 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
  • 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
  • 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
  • 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。

维基百科是如是介绍序列化的:

序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。

综上:序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。

序列化协议对应于 TCP/IP 4 层模型的哪一层?

序列化协议属于 TCP/IP 协议应用层的一部分。

如果有些字段不想进行序列化怎么办?

对于不想进行序列化的变量,使用 transient 关键字修饰。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。

关于 transient 还有几点注意:

  • transient 只能修饰变量,不能修饰类和方法。
  • transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0
  • static 变量因为不属于任何对象 (Object),所以无论有没有 transient 关键字修饰,均不会被序列化。

为什么不推荐使用 JDK 自带的序列化?

我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:

  • 不支持跨语言调用 : 如果调用的是其他语言开发的服务的时候就不支持了。
  • 性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
  • 存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。相关阅读:应用安全:JAVA 反序列化漏洞之殇

常见序列化协议有哪些?

JDK 自带的序列化方式一般不会用,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。

像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。

JDK 自带的序列化方式

JDK 自带的序列化,只需实现 java. Io. Serializable 接口即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@ToString
Public class RpcRequest implements Serializable {
Private static final long serialVersionUID = 1905122041950251207 L;
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 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。 static 变量是属于类的而不是对象。你反序列之后,static 变量的值就像是默认赋予给了对象一样,看着就像是 static 变量被序列化,实际只是假象罢了。

官方说明如下:

如果想显式指定 serialVersionUID ,则需要在类中使用 staticfinal 关键字来修饰一个 long 类型的变量,变量名字必须为 "serialVersionUID"

也就是说,serialVersionUID 只是用来被 JVM 识别,实际并没有被序列化。

Kryo

Kryo 是一个高性能的序列化/反序列化工具,由于其变长存储特性并使用了字节码生成机制,拥有较高的运行速度和较小的字节码体积。

另外,Kryo 已经是一种非常成熟的序列化实现了,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛的使用。

guide-rpc-framework 就是使用的 kryo 进行序列化

GitHub 地址:https://github.com/EsotericSoftware/kryo

Protobuf

Protobuf 出自于 Google,性能还比较优秀,也支持多种语言,同时还是跨平台的。就是在使用中过于繁琐,因为你需要自己定义 IDL 文件和生成对应的序列化代码。这样虽然不灵活,但是,另一方面导致 protobuf 没有序列化漏洞的风险。

Protobuf 包含序列化格式的定义、各种语言的库以及一个 IDL 编译器。正常情况下你需要定义 proto 文件,然后使用 IDL 编译器编译成你需要的语言

GitHub 地址:https://github.com/protocolbuffers/protobuf

ProtoStuff

由于 Protobuf 的易用性,它的哥哥 Protostuff 诞生了。

Protostuff 基于 Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。

GitHub 地址:https://github.com/protostuff/protostuff

Hessian

Hessian 是一个轻量级的,自定义描述的二进制 RPC 协议。Hessian 是一个比较老的序列化实现了,并且同样也是跨语言的。

Dubbo 2. X 默认启用的序列化方式是 Hessian 2 ,但是,Dubbo 对 Hessian 2 进行了修改,不过大体结构还是差不多。

总结

Kryo 是专门针对 Java 语言序列化方式并且性能非常好,如果你的应用是专门针对 Java 语言的话可以考虑使用,并且 Dubbo 官网的一篇文章中提到说推荐使用 Kryo 作为生产环境的序列化方式。(文章地址:https://cn.dubbo.apache.org/zh-cn/docsv2.7/user/serialization/)。

像 Protobuf、 ProtoStuff、hessian 这类都是跨语言的序列化方式,如果有跨语言需求的话可以考虑使用。

除了我上面介绍到的序列化方式的话,还有像 Thrift,Avro 这些。

I/O

Java IO 流了解吗?

IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。

Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。

  • InputStream / Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
  • OutputStream / Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

I/O 流为什么要分为字节流和字符流呢?

问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?

个人认为主要有两点原因:

  • 字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时;
  • 如果我们不知道编码类型的话,使用字节流的过程中很容易出现乱码问题。

Java IO 中的设计模式有哪些?

BIO、NIO 和 AIO 的区别?

语法糖

什么是语法糖?

语法糖(Syntactic Sugar) 也称糖衣语法,是英国计算机学家 Peter. J. Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。简而言之,语法糖让程序更加简洁,有更高的可读性。

语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。

举个例子,Java 中的 for-each 就是一个常用的语法糖,其原理其实就是基于普通的 for 循环和迭代器。

1
2
3
4
String[] strs = {"JavaGuide", "公众号:JavaGuide", "博客: https://javaguide.cn/" };
For (String s : strs) {
System.Out.Println (s);
}

不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。如果你去看 com. Sun. Tools. Javac. Main. JavaCompiler 的源码,你会发现在 compile () 中有一个步骤就是调用 desugar (),这个方法就是负责解语法糖的实现的。

开始之前,我们先来搞懂下面这两个概念:

  • 形参&实参
  • 值传递&引用传递

形参&实参

方法的定义可能会用到 参数(有参的方法),参数在程序语言中分为:

  • 实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。
  • 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。
1
2
3
4
5
6
7
String hello = "Hello!";
// hello 为实参
SayHello (hello);
// str 为形参
Void sayHello (String str) {
System.Out.Println (str);
}

值传递&引用传递

程序设计语言将实参传递给方法(或函数)的方式分为两种:

  • 值传递:方法接收的是实参值的拷贝,会创建副本。
  • 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。

很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。

为什么 Java 只有值传递?

为什么说 Java 只有值传递呢? 不需要太多废话,我通过 3 个例子来给大家证明。

案例 1:传递基本类型参数

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Public static void main (String[] args) {
Int num 1 = 10;
Int num 2 = 20;
Swap (num 1, num 2);
System.Out.Println ("num 1 = " + num 1);
System.Out.Println ("num 2 = " + num 2);
}

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);
}

输出:

1
2
3
4
A = 20
B = 10
Num 1 = 10
Num 2 = 20

解析:

swap () 方法中,ab 的值进行交换,并不会影响到 num 1num 2。因为,ab 的值,只是从 num 1num 2 的复制过来的。也就是说,a、b 相当于 num 1num 2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。

通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,而对象引用作为参数就不一样,请看案例 2。

案例 2:传递引用类型参数 1

代码:

1
2
3
4
5
6
7
8
9
10
11
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) {
// 将数组的第一个元素变为 0
Array[0] = 0;
}

输出:

解析:

看了这个案例很多人肯定觉得 Java 对引用类型的参数采用的是引用传递。

实际上,并不是的,这里传递的还是值,不过,这个值是实参的地址罢了!

也就是说 change 方法的参数拷贝的是 arr (实参)的地址,因此,它和 arr 指向的是同一个数组对象。这也就说明了为什么方法内部对形参的修改会影响到实参。

为了更强有力地反驳 Java 对引用类型的参数采用的不是引用传递,我们再来看下面这个案例!

案例 3:传递引用类型参数 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 person 1, Person person 2) {
Person temp = person 1;
Person 1 = person 2;
Person 2 = temp;
System.Out.Println ("person 1: " + person 1.GetName ());
System.Out.Println ("person 2: " + person 2.GetName ());
}

输出:

1
2
3
4
Person 1: 小李
Person 2: 小张
XiaoZhang: 小张
XiaoLi: 小李

解析:

怎么回事???两个引用类型的形参互换并没有影响实参啊!

swap 方法的参数 person 1person 2 只是拷贝的实参 xiaoZhangxiaoLi 的地址。因此, person 1person 2 的互换只是拷贝的两个地址的互换罢了,并不会影响到实参 xiaoZhangxiaoLi

引用传递是怎么样的?

看到这里,相信你已经知道了 Java 中只有值传递,是没有引用传递的。但是,引用传递到底长什么样呢?下面以 C++ 的代码为例,让你看一下引用传递的庐山真面目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#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";
}

输出结果:

1
2
3
4
Invoke before: 10
Incr before: 10
Incr after: 11
Invoke after: 11

分析:可以看到,在 incr 函数中对形参的修改,可以影响到实参的值。要注意:这里的 incr 形参的数据类型用的是 int& 才为引用传递,如果是用 int 的话还是值传递哦!

为什么 Java 不引入引用传递呢?

引用传递看似很好,能在方法内就直接把实参的值修改了,但是,为什么 Java 不引入引用传递呢?

注意:以下为个人观点看法,并非来自于 Java 官方:

  1. 出于安全考虑,方法内部对值进行的操作,对于调用者都是未知的(把方法定义为接口,调用方不关心具体实现)。你也想象一下,如果拿着银行卡去取钱,取的是 100,扣的是 200,是不是很可怕。
  2. Java 之父 James Gosling 在设计之初就看到了 C、C++ 的许多弊端,所以才想着去设计一门新的语言 Java。在他设计 Java 的时候就遵循了简单易用的原则,摒弃了许多开发者一不留意就会造成问题的“特性”,语言本身的东西少了,开发者要学习的东西也少了。
总结

Java 中将实参传递给方法(或函数)的方式是 值传递

  • 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
  • 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。

Java 中有哪些常见的语法糖?

Switch 支持 String 与枚举

前面提到过,从 Java 7 开始,Java 语言中的语法糖在逐渐丰富,其中一个比较重要的就是 Java 7 中 switch 开始支持 String

在开始之前先科普下,Java 中的 switch 自身原本就支持基本类型。比如 intchar 等。对于 int 类型,直接进行数值的比较。对于 char 类型则是比较其 ascii 码。所以,对于编译器来说,switch 中其实只能使用整型,任何类型的比较都要转换成整型。比如 byteshortchar (ascii 码是整型)以及 int

那么接下来看下 switchString 的支持,有以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Public class switchDemoString {
Public static void main (String[] args) {
String str = "world";
Switch (str) {
Case "hello":
System.Out.Println ("hello");
Break;
Case "world":
System.Out.Println ("world");
Break;
Default:
Break;
}
}
}

反编译后内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Public class switchDemoString
{
Public switchDemoString ()
{
}
Public static void main (String args[])
{
String str = "world";
String s;
Switch ((s = str). HashCode ())
{
Default:
Break;
Case 99162322:
if (s.equals ("hello"))
System.Out.Println ("hello");
Break;
Case 113318802:
if (s.equals ("world"))
System.Out.Println ("world");
Break;
}
}
}

看到这个代码,你知道原来 字符串的 switch 是通过 equals ()hashCode () 方法来实现的。 还好 hashCode () 方法返回的是 int,而不是 long

仔细看下可以发现,进行 switch 的实际是哈希值,然后通过使用 equals 方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行 switch 或者使用纯整数常量,但这也不是很差。

泛型

我们都知道,很多语言都是支持泛型的,但是很多人不知道的是,不同的编译器对于泛型的处理方式是不同的,通常情况下,一个编译器处理泛型有两种方式:Code specializationCode sharing。C++和 C# 是使用 Code specialization 的处理机制,而 Java 使用的是 Code sharing 的机制。

Code sharing 方式为每个泛型类型创建唯一的字节码表示,并且将该泛型类型的实例都映射到这个唯一的字节码表示上。将多种泛型类形实例映射到唯一的字节码表示是通过类型擦除(type erasue)实现的。

也就是说,对于 Java 虚拟机来说,他根本不认识 Map<String, String> map 这样的语法。需要在编译阶段通过类型擦除的方式进行解语法糖。

类型擦除的主要过程如下:1. 将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2. 移除所有的类型参数。

以下代码:

1
2
3
4
Map<String, String> map = new HashMap<String, String>();
Map.Put ("name", "hollis");
Map.Put ("wechat", "Hollis");
map.Put ("blog", " www.hollischuang.com" );

解语法糖之后会变成:

1
2
3
4
Map map = new HashMap ();
Map.Put ("name", "hollis");
Map.Put ("wechat", "Hollis");
map.Put ("blog", " www.hollischuang.com" );

以下代码:

1
2
3
4
5
6
7
8
9
10
public static <A extends Comparable<A>> A max (Collection<A> xs) {
Iterator<A> xi = xs.Iterator ();
A w = xi.Next ();
While (xi.HasNext ()) {
A x = xi.Next ();
if (w.compareTo (x) < 0)
W = x;
}
Return w;
}

类型擦除后会变成:

1
2
3
4
5
6
7
8
9
10
11
 Public static Comparable max (Collection xs){
Iterator xi = xs.Iterator ();
Comparable w = (Comparable) xi.Next ();
While (xi.HasNext ())
{
Comparable x = (Comparable) xi.Next ();
if (w.compareTo (x) < 0)
W = x;
}
Return w;
}

虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的 Class 类对象。比如并不存在 List<String>. class 或是 List<Integer>. class,而只有 List. Class

自动装箱与拆箱

自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱,反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。原始类型 byte, short, char, int, long, float, double 和 boolean 对应的封装类为 Byte, Short, Character, Integer, Long, Float, Double, Boolean。

先来看个自动装箱的代码:

1
2
3
4
 Public static void main (String[] args) {
Int i = 10;
Integer n = i;
}

反编译后代码如下:

1
2
3
4
5
Public static void main (String args[])
{
Int i = 10;
Integer n = Integer.ValueOf (i);
}

再来看个自动拆箱的代码:

1
2
3
4
5
Public static void main (String[] args) {

Integer i = 10;
Int n = i;
}

反编译后代码如下:

1
2
3
4
5
Public static void main (String args[])
{
Integer i = Integer.ValueOf (10);
int n = i.intValue ();
}

从反编译得到内容可以看出,在装箱的时候自动调用的是 IntegervalueOf (int) 方法。而在拆箱的时候自动调用的是 IntegerintValue 方法。

所以,装箱过程是通过调用包装器的 valueOf 方法实现的,而拆箱过程是通过调用包装器的 xxxValue 方法实现的。

可变长参数

可变参数 (variable arguments)是在 Java 1.5 中引入的一个特性。它允许一个方法把任意数量的值作为参数。

看下以下可变参数代码,其中 print 方法接收可变参数:

1
2
3
4
5
6
7
8
9
10
11
12
Public static void main (String[] args)
{
print ("Holis", "公众号: Hollis", "博客: www.hollischuang.com" , "QQ:907607222");
}

Public static void print (String... Strs)
{
For (int i = 0; i < strs. Length; i++)
{
System.Out.Println (strs[i]);
}
}

反编译后代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
 Public static void main (String args[])
{
Print (new String[] {
"Holis", "\u 516 C\u 4 F 17\u 53 F 7: Hollis", "\u 535 A\u 5 BA 2\uFF 1 A www.hollischuang.com" , "QQ\uFF 1 A 907607222"
});
}

Public static transient void print (String strs[])
{
For (int i = 0; i < strs. Length; i++)
System.Out.Println (strs[i]);

}

从反编译后代码可以看出,可变参数在被使用的时候,他首先会创建一个数组,数组的长度就是调用该方法是传递的实参的个数,然后再把参数值全部放到这个数组当中,然后再把这个数组作为参数传递到被调用的方法中。

枚举

Java SE 5 提供了一种新的类型-Java 的枚举类型,关键字 enum 可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。

要想看源码,首先得有一个类吧,那么枚举类型到底是什么类呢?是 enum 吗?答案很明显不是,enum 就和 class 一样,只是一个关键字,他并不是一个类,那么枚举是由什么类维护的呢,我们简单的写一个枚举:

1
2
3
Public enum t {
SPRING, SUMMER;
}

然后我们使用反编译,看看这段代码到底是怎么实现的,反编译后代码内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Public final class T extends Enum
{
Private T (String s, int i)
{
Super (s, i);
}
Public static T[] values ()
{
T at[];
Int i;
T at 1[];
System.Arraycopy (at = ENUM$VALUES, 0, at 1 = new T[i = at. Length], 0, i);
Return at 1;
}

Public static T valueOf (String s)
{
Return (T) Enum.ValueOf (demo/T, s);
}

Public static final T SPRING;
Public static final T SUMMER;
Private static final T ENUM$VALUES[];
Static
{
SPRING = new T ("SPRING", 0);
SUMMER = new T ("SUMMER", 1);
ENUM$VALUES = (new T[] {
SPRING, SUMMER
});
}
}

通过反编译后代码我们可以看到,public final class T extends Enum,说明,该类是继承了 Enum 类的,同时 final 关键字告诉我们,这个类也是不能被继承的。

当我们使用 enum 来定义一个枚举类型的时候,编译器会自动帮我们创建一个 final 类型的类继承 Enum 类,所以枚举类型不能被继承。

内部类

内部类又称为嵌套类,可以把内部类理解为外部类的一个普通成员。

内部类之所以也是语法糖,是因为它仅仅是一个编译时的概念,outer. Java 里面定义了一个内部类 inner,一旦编译成功,就会生成两个完全不同的 . Class 文件了,分别是 outer. Classouter$inner. Class。所以内部类的名字完全可以和它的外部类名字相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Public class OutterClass {
Private String userName;

Public String getUserName () {
Return userName;
}

Public void setUserName (String userName) {
This. UserName = userName;
}

Public static void main (String[] args) {

}

Class InnerClass{
Private String name;

Public String getName () {
Return name;
}

Public void setName (String name) {
This. Name = name;
}
}
}

以上代码编译后会生成两个 class 文件:OutterClass$InnerClass. ClassOutterClass. Class 。当我们尝试对 OutterClass. Class 文件进行反编译的时候,命令行会打印以下内容:Parsing OutterClass. Class... Parsing inner class OutterClass$InnerClass. Class... Generating OutterClass. Jad 。他会把两个文件全部进行反编译,然后一起生成一个 OutterClass. Jad 文件。文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Public class OutterClass
{
Class InnerClass
{
Public String getName ()
{
Return name;
}
Public void setName (String name)
{
This. Name = name;
}
Private String name;
Final OutterClass this$0;

InnerClass ()
{
This. This$0 = OutterClass. This;
Super ();
}
}

Public OutterClass ()
{
}
Public String getUserName ()
{
Return userName;
}
Public void setUserName (String userName){
This. UserName = userName;
}
Public static void main (String args 1[])
{
}
Private String userName;
}

条件编译

—般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。

如在 C 或 CPP 中,可以通过预处理语句来实现条件编译。其实在 Java 中也可实现条件编译。我们先来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Public class ConditionalCompilation {
Public static void main (String[] args) {
Final boolean DEBUG = true;
If (DEBUG) {
System.Out.Println ("Hello, DEBUG!");
}

Final boolean ONLINE = false;

If (ONLINE){
System.Out.Println ("Hello, ONLINE!");
}
}
}

反编译后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Public class ConditionalCompilation
{

Public ConditionalCompilation ()
{
}

Public static void main (String args[])
{
Boolean DEBUG = true;
System.Out.Println ("Hello, DEBUG!");
Boolean ONLINE = false;
}
}

首先,我们发现,在反编译后的代码中没有 System.Out.Println ("Hello, ONLINE!");,这其实就是条件编译。当 if (ONLINE) 为 false 的时候,编译器就没有对其内的代码进行编译。

所以,Java 语法的条件编译,是通过判断条件为常量的 if 语句实现的。其原理也是 Java 语言的语法糖。根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个 Java 类的结构或者类的属性上进行条件编译,这与 C/C++的条件编译相比,确实更有局限性。在 Java 语言设计之初并没有引入条件编译的功能,虽有局限,但是总比没有更强。

断言

在 Java 中,assert 关键字是从 JAVA SE 1.4 引入的,为了避免和老版本的 Java 代码中使用了 assert 关键字导致错误,Java 在执行的时候默认是不启动断言检查的(这个时候,所有的断言语句都将忽略!),如果要开启断言检查,则需要用开关 -enableassertions-ea 来开启。

看一段包含断言的代码:

1
2
3
4
5
6
7
8
9
10
Public class AssertTest {
Public static void main (String args[]) {
Int a = 1;
Int b = 1;
Assert a == b;
System.Out.Println ("公众号:Hollis");
Assert a != b : "Hollis";
System.Out.Println ("博客: www.hollischuang.com" );
}
}

反编译后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Public class AssertTest {
Public AssertTest ()
{
}
Public static void main (String args[])
{
Int a = 1;
Int b = 1;
If (!$assertionsDisabled && a != b)
Throw new AssertionError ();
System.Out.Println ("\u 516 C\u 4 F 17\u 53 F 7\uFF 1 AHollis");
If (!$assertionsDisabled && a == b)
{
Throw new AssertionError ("Hollis");
} else
{
System.Out.Println ("\u 535 A\u 5 BA 2\uFF 1 A www.hollischuang.com" );
Return;
}
}

Static final boolean $assertionsDisabled = !Com/hollis/suguar/AssertTest.DesiredAssertionStatus ();

}

很明显,反编译之后的代码要比我们自己的代码复杂的多。所以,使用了 assert 这个语法糖我们节省了很多代码。其实断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行。-enableassertions 会设置$assertionsDisabled 字段的值。

数值字面量

在 java 7 中,数值字面量,不管是整数还是浮点数,都允许在数字之间插入任意多个下划线。这些下划线不会对字面量的数值产生影响,目的就是方便阅读。

比如:

1
2
3
4
5
6
Public class Test {
Public static void main (String... Args) {
Int i = 10_000;
System.Out.Println (i);
}
}

反编译后:

1
2
3
4
5
6
7
8
Public class Test
{
Public static void main (String[] args)
{
Int i = 10000;
System.Out.Println (i);
}
}

反编译后就是把 _ 删除了。也就是说 编译器并不认识在数字字面量中的 _,需要在编译阶段把他去掉。

For-each

增强 for 循环(for-each)相信大家都不陌生,日常开发经常会用到的,他会比 for 循环要少写很多代码,那么这个语法糖背后是如何实现的呢?

1
2
3
4
5
6
7
8
9
10
Public static void main (String... Args) {
String[] strs = {"Hollis", "公众号:Hollis", "博客: www.hollischuang.com" };
For (String s : strs) {
System.Out.Println (s);
}
List<String> strList = ImmutableList.Of ("Hollis", "公众号:Hollis", "博客: www.hollischuang.com" );
For (String s : strList) {
System.Out.Println (s);
}
}

反编译后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Public static transient void main (String args[])
{
String strs[] = {
"Hollis", "\u 516 C\u 4 F 17\u 53 F 7\uFF 1 AHollis", "\u 535 A\u 5 BA 2\uFF 1 A www.hollischuang.com"
};
String args 1[] = strs;
Int i = args 1. Length;
For (int j = 0; j < i; j++)
{
String s = args 1[j];
System.Out.Println (s);
}

List strList = ImmutableList.Of ("Hollis", "\u 516 C\u 4 F 17\u 53 F 7\uFF 1 AHollis", "\u 535 A\u 5 BA 2\uFF 1 A www.hollischuang.com" );
String s;
For (Iterator iterator = strList.Iterator (); iterator.HasNext (); System.Out.Println (s))
S = (String) iterator.Next ();

}

代码很简单,for-each 的实现原理其实就是使用了普通的 for 循环和迭代器。

Try-with-resource

Java 里,对于文件操作 IO 流、数据库连接等开销非常昂贵的资源,用完之后必须及时通过 close 方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄露等问题。

关闭资源的常用方式就是在 finally 块里是释放,即调用 close 方法。比如,我们经常会写这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Public static void main (String[] args) {
BufferedReader br = null;
Try {
String line;
Br = new BufferedReader (new FileReader ("d:\\hollischuang. Xml"));
While ((line = br.ReadLine ()) != null) {
System.Out.Println (line);
}
} catch (IOException e) {
// handle exception
} finally {
Try {
If (br != null) {
Br.Close ();
}
} catch (IOException ex) {
// handle exception
}
}
}

从 Java 7 开始,jdk 提供了一种更好的方式关闭资源,使用 try-with-resources 语句,改写一下上面的代码,效果如下:

1
2
3
4
5
6
7
8
9
10
Public static void main (String... Args) {
Try (BufferedReader br = new BufferedReader (new FileReader ("d:\\ hollischuang. Xml"))) {
String line;
While ((line = br.ReadLine ()) != null) {
System.Out.Println (line);
}
} catch (IOException e) {
// handle exception
}
}

看,这简直是一大福音啊,虽然我之前一般使用 IOUtils 去关闭流,并不会使用在 finally 中写很多代码的方式,但是这种新的语法糖看上去好像优雅很多呢。看下他的背后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Public static transient void main (String args[])
{
BufferedReader br;
Throwable throwable;
Br = new BufferedReader (new FileReader ("d:\\ hollischuang. Xml"));
Throwable = null;
String line;
Try
{
While ((line = br.ReadLine ()) != null)
System.Out.Println (line);
}
Catch (Throwable throwable 2)
{
Throwable = throwable 2;
Throw throwable 2;
}
If (br != null)
If (throwable != null)
Try
{
Br.Close ();
}
Catch (Throwable throwable 1)
{
Throwable.AddSuppressed (throwable 1);
}
Else
Br.Close ();
Break MISSING_BLOCK_LABEL_113;
Exception exception;
Exception;
If (br != null)
If (throwable != null)
Try
{
Br.Close ();
}
Catch (Throwable throwable 3)
{
Throwable.AddSuppressed (throwable 3);
}
Else
Br.Close ();
Throw exception;
IOException ioexception;
Ioexception;
}
}

其实背后的原理也很简单,那些我们没有做的关闭资源的操作,编译器都帮我们做了。所以,再次印证了,语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。

Lambda 表达式

关于 lambda 表达式,有人可能会有质疑,因为网上有人说他并不是语法糖。其实我想纠正下这个说法。Lambda 表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个 JVM 底层提供的 lambda 相关 api。

先来看一个简单的 lambda 表达式。遍历一个 list:

1
2
3
4
5
Public static void main (String... Args) {
List<String> strList = ImmutableList.Of ("Hollis", "公众号:Hollis", "博客: www.hollischuang.com" );

StrList.ForEach ( s -> { System.Out.Println (s); } );
}

为啥说他并不是内部类的语法糖呢,前面讲内部类我们说过,内部类在编译之后会有两个 class 文件,但是,包含 lambda 表达式的类编译后只有一个文件。

反编译后代码如下:

1
2
3
4
5
6
7
8
Public static /* varargs */ void main (String ... Args) {
ImmutableList strList = ImmutableList.Of ((Object)"Hollis", (Object)"\u 516 c\u 4 f 17\u 53 f 7\uff 1 aHollis", (Object)"\u 535 a\u 5 ba 2\uff 1 a www.hollischuang.com" );
strList.ForEach ((Consumer<String>) LambdaMetafactory.Metafactory (null, null, null, (Ljava/lang/Object;) V, lambda$main$0 (java. Lang. String ), (Ljava/lang/String;) V)());
}

Private static /* synthetic */ void lambda$main$0 (String s) {
System.Out.Println (s);
}

可以看到,在 forEach 方法中,其实是调用了 java. Lang. Invoke. LambdaMetafactory #metafactory 方法,该方法的第四个参数 implMethod 指定了方法实现。可以看到这里其实是调用了一个 lambda$main$0 方法进行了输出。

再来看一个稍微复杂一点的,先对 List 进行过滤,然后再输出:

1
2
3
4
5
6
7
Public static void main (String... Args) {
List<String> strList = ImmutableList.Of ("Hollis", "公众号:Hollis", "博客: www.hollischuang.com" );

List HollisList = strList.Stream (). Filter (string -> string.Contains ("Hollis")). Collect (Collectors.ToList ());

HollisList.ForEach ( s -> { System.Out.Println (s); } );
}

反编译后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Public static /* varargs */ void main (String ... Args) {
ImmutableList strList = ImmutableList.Of ((Object)"Hollis", (Object)"\u 516 c\u 4 f 17\u 53 f 7\uff 1 aHollis", (Object)"\u 535 a\u 5 ba 2\uff 1 a www.hollischuang.com" );
List<Object> HollisList = strList.Stream (). Filter ((Predicate<String>) LambdaMetafactory.Metafactory (null, null, null, (Ljava/lang/Object;) Z, lambda$main$0 (java. Lang. String ), (Ljava/lang/String;) Z)()). Collect (Collectors.ToList ());
HollisList.ForEach ((Consumer<Object>) LambdaMetafactory.Metafactory (null, null, null, (Ljava/lang/Object;) V, lambda$main$1 (java. Lang. Object ), (Ljava/lang/Object;) V)());
}

Private static /* synthetic */ void lambda$main$1 (Object s) {
System.Out.Println (s);
}

Private static /* synthetic */ boolean lambda$main$0 (String string) {
Return string.Contains ("Hollis");
}

两个 lambda 表达式分别调用了 lambda$main$1lambda$main$0 两个方法。

所以,lambda 表达式的实现其实是依赖了一些底层的 api,在编译阶段,编译器会把 lambda 表达式进行解糖,转换成调用内部 api 的方式。

可能遇到的坑

泛型

一、当泛型遇到重载

1
2
3
4
5
6
7
8
9
10
Public class GenericTypes {

public static void method (List<String> list) {
System.Out.Println ("invoke method (List<String> list)");
}

public static void method (List<Integer> list) {
System.Out.Println ("invoke method (List<Integer> list)");
}
}

上面这段代码,有两个重载的函数,因为他们的参数类型不同,一个是 List<String> 另一个是 List<Integer> ,但是,这段代码是编译通不过的。因为我们前面讲过,参数 List<Integer>List<String> 编译之后都被擦除了,变成了一样的原生类型 List,擦除动作导致这两个方法的特征签名变得一模一样。

二、当泛型遇到 catch

泛型的类型参数不能用在 Java 异常处理的 catch 语句中。因为异常处理是由 JVM 在运行时刻来进行的。由于类型信息被擦除,JVM 是无法区分两个异常类型 MyException<String>MyException<Integer>

三、当泛型内包含静态变量

1
2
3
4
5
6
7
8
9
10
11
12
13
Public class StaticTest{
Public static void main (String[] args){
GT<Integer> gti = new GT<Integer>();
Gti. Var=1;
GT<String> gts = new GT<String>();
Gts. Var=2;
System.Out.Println (gti. Var);
}
}
class GT<T>{
Public static int var=0;
Public void nothing (T x){}
}

以上代码输出结果为:2!

由于经过类型擦除,所有的泛型类实例都关联到同一份字节码上,泛型类的所有静态变量是共享的。

自动装箱与拆箱

对象相等比较

1
2
3
4
5
6
7
8
Public static void main (String[] args) {
Integer a = 1000;
Integer b = 1000;
Integer c = 100;
Integer d = 100;
System.Out.Println ("a == b is " + (a == b));
System.Out.Println (("c == d is " + (c == d)));
}

输出结果:

1
2
A == b is false
C == d is true

在 Java 5 中,在 Integer 的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。

适用于整数值区间-128 至 +127。

只适用于自动装箱。使用构造函数创建对象不适用。

增强 for 循环

1
2
3
4
For (Student stu : students) {
If (stu.GetId () == 2)
Students.Remove (stu);
}

会抛出 ConcurrentModificationException 异常。

Iterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变,所以当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出 java. Util. ConcurrentModificationException 异常。

所以 Iterator 在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator 本身的方法 remove () 来删除对象,Iterator.Remove () 方法会在删除当前迭代对象的同时维护索引的一致性。

BigDecimal 类

BigDecimal 介绍

BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。

通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。

具体原因我们在上面已经详细介绍了,这里就不多提了。

想要解决浮点数运算精度丢失这个问题,可以直接使用 BigDecimal 来定义浮点数的值,然后再进行浮点数的运算操作即可。

1
2
3
4
5
6
7
8
BigDecimal a = new BigDecimal ("1.0");
BigDecimal b = new BigDecimal ("0.9");
BigDecimal c = new BigDecimal ("0.8");

BigDecimal x = a.subtract (b);
BigDecimal y = b.subtract (c);

System.Out.Println (x.compareTo (y));// 0

BigDecimal 常见方法

创建

我们在使用 BigDecimal 时,为了防止精度丢失,推荐使用它的 BigDecimal (String val) 构造方法或者 BigDecimal.ValueOf (double val) 静态方法来创建对象。

《阿里巴巴 Java 开发手册》对这部分内容也有提到,如下图所示。

加减乘除

add 方法用于将两个 BigDecimal 对象相加,subtract 方法用于将两个 BigDecimal 对象相减。multiply 方法用于将两个 BigDecimal 对象相乘,divide 方法用于将两个 BigDecimal 对象相除。

1
2
3
4
5
6
7
BigDecimal a = new BigDecimal ("1.0");
BigDecimal b = new BigDecimal ("0.9");
System.Out.Println (a.add (b));// 1.9
System.Out.Println (a.subtract (b));// 0.1
System.Out.Println (a.multiply (b));// 0.90
System.Out.Println (a.divide (b));// 无法除尽,抛出 ArithmeticException 异常
System.Out.Println (a.divide (b, 2, RoundingMode. HALF_UP));// 1.11

这里需要注意的是,在我们使用 divide 方法的时候尽量使用 3 个参数版本,并且 RoundingMode 不要选择 UNNECESSARY,否则很可能会遇到 ArithmeticException(无法除尽出现无限循环小数的时候),其中 scale 表示要保留几位小数,roundingMode 代表保留规则。

1
2
3
Public BigDecimal divide (BigDecimal divisor, int scale, RoundingMode roundingMode) {
Return divide (divisor, scale, roundingMode. OldMode);
}

保留规则非常多,这里列举几种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Public enum RoundingMode {
// 2.5 -> 3 , 1.6 -> 2
// -1.6 -> -2 , -2.5 -> -3
UP (BigDecimal. ROUND_UP),
// 2.5 -> 2 , 1.6 -> 1
// -1.6 -> -1 , -2.5 -> -2
DOWN (BigDecimal. ROUND_DOWN),
// 2.5 -> 3 , 1.6 -> 2
// -1.6 -> -1 , -2.5 -> -2
CEILING (BigDecimal. ROUND_CEILING),
// 2.5 -> 2 , 1.6 -> 1
// -1.6 -> -2 , -2.5 -> -3
FLOOR (BigDecimal. ROUND_FLOOR),
// 2.5 -> 3 , 1.6 -> 2
// -1.6 -> -2 , -2.5 -> -3
HALF_UP (BigDecimal. ROUND_HALF_UP),
//......
}

大小比较

a.compareTo (b) : 返回 -1 表示 a 小于 b,0 表示 a 等于 b , 1 表示 a 大于 b

1
2
3
BigDecimal a = new BigDecimal ("1.0");
BigDecimal b = new BigDecimal ("0.9");
System.Out.Println (a.compareTo (b));// 1

保留几位小数

通过 setScale 方法设置保留几位小数以及保留规则。保留规则有挺多种,不需要记,IDEA 会提示。

1
2
3
BigDecimal m = new BigDecimal ("1.255433");
BigDecimal n = m.setScale (3, RoundingMode. HALF_DOWN);
System.Out.Println (n);// 1.255

BigDecimal 等值比较问题

《阿里巴巴 Java 开发手册》中提到:

BigDecimal 使用 equals () 方法进行等值比较出现问题的代码示例:

1
2
3
BigDecimal a = new BigDecimal ("1");
BigDecimal b = new BigDecimal ("1.0");
System.Out.Println (a.equals (b));//false

这是因为 equals () 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 compareTo () 方法比较的时候会忽略精度。

1.0 的 scale 是 1,1 的 scale 是 0,因此 a.equals (b) 的结果是 false。

compareTo () 方法可以比较两个 BigDecimal 的值,如果相等就返回 0,如果第 1 个数比第 2 个数大则返回 1,反之返回-1。

1
2
3
BigDecimal a = new BigDecimal ("1");
BigDecimal b = new BigDecimal ("1.0");
System.Out.Println (a.compareTo (b));//0

Unsafe 类

Unsafe 介绍

Unsafe 是位于 sun. Misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。但由于 Unsafe 类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe 类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe 的使用一定要慎重。

另外,Unsafe 提供的这些功能的实现需要依赖本地方法(Native Method)。你可以将本地方法看作是 Java 中使用其他编程语言编写的方法。本地方法使用 native 关键字修饰,Java 代码中只是声明方法头,具体的实现则交给 本地代码

为什么要使用本地方法呢?

  1. 需要用到 Java 中不具备的依赖于操作系统的特性,Java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用。
  2. 对于其他语言已经完成的一些现成功能,可以使用 Java 直接调用。
  3. 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编。

在 JUC 包的很多并发工具类在实现并发机制时,都调用了本地方法,通过它们打破了 Java 运行时的界限,能够接触到操作系统底层的某些功能。对于同一本地方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果。

Unsafe 创建

sun. Misc. Unsafe 部分源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Public final class Unsafe {
// 单例对象
Private static final Unsafe theUnsafe;
......
Private Unsafe () {
}
@CallerSensitive
Public static Unsafe getUnsafe () {
Class var 0 = Reflection.GetCallerClass ();
// 仅在引导类加载器`BootstrapClassLoader`加载时才合法
If (! VM.IsSystemDomainLoader (var 0.GetClassLoader ())) {
Throw new SecurityException ("Unsafe");
} else {
Return theUnsafe;
}
}
}

Unsafe 类为一单例实现,提供静态方法 getUnsafe 获取 Unsafe 实例。这个看上去貌似可以用来获取 Unsafe 实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException 异常:

1
2
3
Exception in thread "main" java. Lang. SecurityException: Unsafe
At sun.Misc.Unsafe.GetUnsafe (Unsafe. Java:90)
At com.Cn.Test.GetUnsafeTest.Main (GetUnsafeTest. Java:12)

为什么 public static 方法无法被直接调用呢?

这是因为在 getUnsafe 方法中,会对调用者的 classLoader 进行检查,判断当前类是否由 Bootstrap classLoader 加载,如果不是的话那么就会抛出一个 SecurityException 异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。

为什么要对 Unsafe 类进行这么谨慎的使用限制呢?

Unsafe 提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。

如若想使用 Unsafe 这个类的话,应该如何获取其实例呢?

这里介绍两个可行的方案。

1、利用反射获得 Unsafe 类中已经实例化完成的单例对象 theUnsafe

1
2
3
4
5
6
7
8
9
10
Private static Unsafe reflectGetUnsafe () {
Try {
Field field = Unsafe.Class.GetDeclaredField ("theUnsafe");
Field.SetAccessible (true);
Return (Unsafe) field.Get (null);
} catch (Exception e) {
log.Error (e.getMessage (), e);
Return null;
}
}

2、从 getUnsafe 方法的使用限制条件出发,通过 Java 命令行命令 -Xbootclasspath/a 把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中,使得 A 被引导类加载器加载,从而通过 Unsafe. GetUnsafe 方法安全的获取 Unsafe 实例。

1
Java -Xbootclasspath/a: ${path}   // 其中 path 为调用 Unsafe 相关方法的类所在 jar 包路径

Unsafe 功能

概括的来说,Unsafe 类实现功能可以被分为下面 8 类:

  1. 内存操作
  2. 内存屏障
  3. 对象操作
  4. 数据操作
  5. CAS 操作
  6. 线程调度
  7. Class 操作
  8. 系统信息

内存操作

介绍

如果你是一个写过 C 或者 C++ 的程序员,一定对内存操作不会陌生,而在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 Unsafe 中,提供的下列接口可以直接进行内存操作:

1
2
3
4
5
6
7
8
9
10
//分配新的本地空间
Public native long allocateMemory (long bytes);
//重新调整内存空间的大小
Public native long reallocateMemory (long address, long bytes);
//将内存设置为指定值
Public native void setMemory (Object o, long offset, long bytes, byte value);
//内存拷贝
Public native void copyMemory (Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes);
//清除内存
Public native void freeMemory (long address);

使用下面的代码进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Private void memoryTest () {
Int size = 4;
Long addr = unsafe.AllocateMemory (size);
Long addr 3 = unsafe.ReallocateMemory (addr, size * 2);
System.Out.Println ("addr: "+addr);
System.Out.Println ("addr 3: "+addr 3);
Try {
Unsafe.SetMemory (null, addr ,size, (byte) 1);
For (int i = 0; i < 2; i++) {
Unsafe.CopyMemory (null, addr, null, addr 3+size*i, 4);
}
System.Out.Println (unsafe.GetInt (addr));
System.Out.Println (unsafe.GetLong (addr 3));
}finally {
Unsafe.FreeMemory (addr);
Unsafe.FreeMemory (addr 3);
}
}

先看结果输出:

1
2
3
4
Addr: 2433733895744
Addr 3: 2433733894944
16843009
72340172838076673

分析一下运行结果,首先使用 allocateMemory 方法申请 4 字节长度的内存空间,调用 setMemory 方法向每个字节写入内容为 byte 类型的 1,当使用 Unsafe 调用 getInt 方法时,因为一个 int 型变量占 4 个字节,会一次性读取 4 个字节,组成一个 int 的值,对应的十进制结果为 16843009。

你可以通过下图理解这个过程:

在代码中调用 reallocateMemory 方法重新分配了一块 8 字节长度的内存空间,通过比较 addraddr 3 可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用 copyMemory 方法进行了两次内存的拷贝,每次拷贝内存地址 addr 开始的 4 个字节,分别拷贝到以 addr 3addr 3+4 开始的内存空间上:

拷贝完成后,使用 getLong 方法一次性读取 8 个字节,得到 long 类型的值为 72340172838076673。

需要注意,通过这种方式分配的内存属于堆外内存,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用 freeMemory 方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在 try 中执行对内存的操作,最终在 finally 块中进行内存的释放。

为什么要使用堆外内存?

  • 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
  • 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。
典型应用

DirectByteBuffer 是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。

下图为 DirectByteBuffer 构造函数,创建 DirectByteBuffer 的时候,通过 Unsafe. AllocateMemory 分配内存、Unsafe. SetMemory 进行内存初始化,而后构建 Cleaner 对象用于跟踪 DirectByteBuffer 对象的垃圾回收,以实现当 DirectByteBuffer 被垃圾回收时,分配的堆外内存一起被释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
DirectByteBuffer (int cap) {                   // package-private

Super (-1, 0, cap, cap);
Boolean pa = VM.IsDirectMemoryPageAligned ();
Int ps = Bits.PageSize ();
Long size = Math.Max (1 L, (long) cap + (pa ? Ps : 0));
Bits.ReserveMemory (size, cap);

Long base = 0;
Try {
// 分配内存并返回基地址
Base = unsafe.AllocateMemory (size);
} catch (OutOfMemoryError x) {
Bits.UnreserveMemory (size, cap);
Throw x;
}
// 内存初始化
Unsafe.SetMemory (base, size, (byte) 0);
If (pa && (base % ps != 0)) {
// Round up to page boundary
Address = base + ps - (base & (ps - 1));
} else {
Address = base;
}
// 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放
Cleaner = Cleaner.Create (this, new Deallocator (base, size, cap));
Att = null;
}

内存屏障

介绍

在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。

在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java 8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能。

Unsafe 中提供了下面三个内存屏障相关方法:

1
2
3
4
5
6
//内存屏障,禁止 load 操作重排序。屏障前的 load 操作不能被重排序到屏障后,屏障后的 load 操作不能被重排序到屏障前
Public native void loadFence ();
//内存屏障,禁止 store 操作重排序。屏障前的 store 操作不能被重排序到屏障后,屏障后的 store 操作不能被重排序到屏障前
Public native void storeFence ();
//内存屏障,禁止 load、store 操作重排序
Public native void fullFence ();

内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以 loadFence 方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。

看到这估计很多小伙伴们会想到 volatile 关键字了,如果在字段上添加了 volatile 关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改 flag 标志位,注意这里的 flag 是没有被 volatile 修饰的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Getter
Class ChangeThread implements Runnable{
/**volatile**/ boolean flag=false;
@Override
Public void run () {
Try {
Thread.Sleep (3000);
} catch (InterruptedException e) {
e.printStackTrace ();
}
System.Out.Println ("subThread change flag to: " + flag);
Flag = true;
}
}

在主线程的 while 循环中,加入内存屏障,测试是否能够感知到 flag 的修改变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
Public static void main (String[] args){
ChangeThread changeThread = new ChangeThread ();
New Thread (changeThread). Start ();
While (true) {
Boolean flag = changeThread.IsFlag ();
Unsafe.LoadFence (); //加入读内存屏障
If (flag){
System.Out.Println ("detected flag changed");
Break;
}
}
System.Out.Println ("main thread end");
}

运行结果:

1
2
3
SubThread change flag to:false
Detected flag changed
Main thread end

而如果删掉上面代码中的 loadFence 方法,那么主线程将无法感知到 flag 发生的变化,会一直在 while 中循环。可以用图来表示上面的过程:

了解 Java 内存模型(JMM)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。

典型应用

在 Java 8 中引入了一种锁的新机制—— StampedLock,它可以看成是读写锁的一个改进版本。StampedLock 提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock 提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。

为了解决这个问题,StampedLockvalidate 方法会通过 UnsafeloadFence 方法加入一个 load 内存屏障。

1
2
3
4
Public boolean validate (long stamp) {
U.loadFence ();
Return (stamp & SBITS) == (state & SBITS);
}

对象操作

介绍

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Import sun. Misc. Unsafe;
Import java. Lang. Reflect. Field;

Public class Main {

Private int value;

Public static void main (String[] args) throws Exception{
Unsafe unsafe = reflectGetUnsafe ();
Assert unsafe != null;
Long offset = unsafe.ObjectFieldOffset (Main.Class.GetDeclaredField ("value"));
Main main = new Main ();
System.Out.Println ("value before putInt: " + main. Value);
Unsafe.PutInt (main, offset, 42);
System.Out.Println ("value after putInt: " + main. Value);
System.Out.Println ("value after putInt: " + unsafe.GetInt (main, offset));
}

Private static Unsafe reflectGetUnsafe () {
Try {
Field field = Unsafe.Class.GetDeclaredField ("theUnsafe");
Field.SetAccessible (true);
Return (Unsafe) field.Get (null);
} catch (Exception e) {
e.printStackTrace ();
Return null;
}
}

}

输出结果:

1
2
3
Value before putInt: 0
Value after putInt: 42
Value after putInt: 42

对象属性

对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的 putIntgetInt 方法外,Unsafe 提供了全部 8 种基础数据类型以及 Objectputget 方法,并且所有的 put 方法都可以越过访问权限,直接修改内存中的数据。阅读 openJDK 源码中的注释发现,基础数据类型和 Object 的读写稍有不同,基础数据类型是直接操作的属性值(value),而 Object 的操作则是基于引用值(reference value)。下面是 Object 的读写方法:

1
2
3
4
//在对象的指定偏移地址获取一个对象引用
Public native Object getObject (Object o, long offset);
//在对象指定偏移地址写入一个对象引用
Public native void putObject (Object o, long offset, Object x);

除了对象属性的普通读写外,Unsafe 还提供了 volatile 读写有序写入方法。volatile 读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和 Object 类型,以 int 类型为例:

1
2
3
4
//在对象的指定偏移地址处读取一个 int 值,支持 volatile load 语义
Public native int getIntVolatile (Object o, long offset);
//在对象指定偏移地址处写入一个 int,支持 volatile store 语义
Public native void putIntVolatile (Object o, long offset, int x);

相对于普通读写来说,volatile 读写具有更高的成本,因为它需要保证可见性和有序性。在执行 get 操作时,会强制从主存中获取属性值,在使用 put 方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。

有序写入的方法有以下三个:

1
2
3
Public native void putOrderedObject (Object o, long offset, Object x);
Public native void putOrderedInt (Object o, long offset, int x);
Public native void putOrderedLong (Object o, long offset, long x);

有序写入的成本相对 volatile 较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:

  • Load:将主内存中的数据拷贝到处理器的缓存中
  • Store:将处理器缓存的数据刷新到主内存中

顺序写入与 volatile 写入的差别在于,在顺序写时加入的内存屏障类型为 StoreStore 类型,而在 volatile 写入时加入的内存屏障是 StoreLoad 类型,如下图所示:

在有序写入方法中,使用的是 StoreStore 屏障,该屏障确保 Store 1 立刻刷新数据到内存,这一操作先于 Store 2 以及后续的存储指令操作。而在 volatile 写入中,使用的是 StoreLoad 屏障,该屏障确保 Store 1 立刻刷新数据到内存,这一操作先于 Load 2 及后续的装载指令,并且,StoreLoad 屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。

综上所述,在上面的三类写入方法中,在写入效率方面,按照 putputOrderputVolatile 的顺序效率逐渐降低。

对象实例化

使用 UnsafeallocateInstance 方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:

1
2
3
4
5
6
7
@Data
Public class A {
Private int b;
Public A (){
This. B =1;
}
}

分别基于构造函数、反射以及 Unsafe 方法的不同方式创建对象进行比较:

1
2
3
4
5
6
7
8
Public void objTest () throws Exception{
A a 1=new A ();
System.Out.Println (a 1.GetB ());
A a 2 = A.class.NewInstance ();
System.Out.Println (a 2.GetB ());
A a 3= (A) unsafe.AllocateInstance (A.class);
System.Out.Println (a 3.GetB ());
}

打印结果分别为 1、1、0,说明通过 allocateInstance 方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了 Class 对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将 A 类的构造函数改为 private 类型,将无法通过构造函数和反射创建对象(可以通过构造函数对象 setAccessible 后创建对象),但 allocateInstance 方法仍然有效。

典型应用
  • 常规对象实例化方式:我们通常所用到的创建对象的方式,从本质上来讲,都是通过 new 机制来实现对象的创建。但是,new 机制有个特点就是当类只提供有参的构造函数且无显示声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。
  • 非常规的实例化方式:而 Unsafe 中提供 allocateInstance 方法,仅通过 Class 对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM 安全检查等。它抑制修饰符检测,也就是即使构造器是 private 修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance 在 java. Lang. Invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。

数组操作

介绍

arrayBaseOffsetarrayIndexScale 这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。

1
2
3
4
//返回数组中第一个元素的偏移地址
public native int arrayBaseOffset (Class<?> arrayClass);
//返回数组中一个元素占用的大小
public native int arrayIndexScale (Class<?> arrayClass);

典型应用

这两个与数据操作相关的方法,在 java. Util. Concurrent. Atomic 包下的 AtomicIntegerArray(可以实现对 Integer 数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray 源码所示,通过 UnsafearrayBaseOffsetarrayIndexScale 分别获取数组首元素的偏移地址 base 及单个元素大小因子 scale 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd 方法即通过 checkedByteOffset 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。

CAS 操作

介绍

这部分主要为 CAS 相关操作的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* CAS
* @param o 包含要修改 field 的对象
* @param offset 对象中某 field 的偏移量
* @param expected 期望值
* @param update 更新值
* @return true | false
*/
Public final native boolean compareAndSwapObject (Object o, long offset, Object expected, Object update);

Public final native boolean compareAndSwapInt (Object o, long offset, int expected, int update);

Public final native boolean compareAndSwapLong (Object o, long offset, long expected, long update);

什么是 CAS? CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg

典型应用

在 JUC 包的并发工具类中大量地使用了 CAS 操作,像在前面介绍 synchronizedAQS 的文章中也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在 Unsafe 类中,提供了 compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong 方法来实现的对 Objectintlong 类型的 CAS 操作。以 compareAndSwapInt 方法为例:

1
Public final native boolean compareAndSwapInt (Object o, long offset, int expected, int x);

参数中 o 为需要更新的对象,offset 是对象 o 中整形字段的偏移量,如果这个字段的值与 expected 相同,则将字段的值设为 x 这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用 compareAndSwapInt 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Private volatile int a;
Public static void main (String[] args){
CasTest casTest=new CasTest ();
New Thread (()->{
For (int i = 1; i < 5; i++) {
CasTest.Increment (i);
System.Out.Print (casTest. A+" ");
}
}). Start ();
New Thread (()->{
For (int i = 5 ; i <10 ; i++) {
CasTest.Increment (i);
System.Out.Print (casTest. A+" ");
}
}). Start ();
}

Private void increment (int x){
While (true){
Try {
Long fieldOffset = unsafe.ObjectFieldOffset (CasTest.Class.GetDeclaredField ("a"));
If (unsafe.CompareAndSwapInt (this, fieldOffset, x-1, x))
Break;
} catch (NoSuchFieldException e) {
e.printStackTrace ();
}
}
}

运行代码会依次输出:

在上面的例子中,使用两个线程去修改 int 型属性 a 的值,并且只有在 a 的值等于传入的参数 x 减一时,才会将 a 的值变为 x,也就是实现对 a 的加一的操作。流程如下所示:

需要注意的是,在调用 compareAndSwapInt 方法后,会直接返回 truefalse 的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在 AtomicInteger 类的设计中,也是采用了将 compareAndSwapInt 的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。

线程调度

介绍

Unsafe 类中提供了 parkunparkmonitorEntermonitorExittryMonitorEnter 方法进行线程调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
//取消阻塞线程
Public native void unpark (Object thread);
//阻塞线程
Public native void park (boolean isAbsolute, long time);
//获得对象锁(可重入锁)
@Deprecated
Public native void monitorEnter (Object o);
//释放对象锁
@Deprecated
Public native void monitorExit (Object o);
//尝试获取对象锁
@Deprecated
Public native boolean tryMonitorEnter (Object o);

方法 parkunpark 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park 方法实现的,调用 park 方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark 可以终止一个挂起的线程,使其恢复正常。

此外,Unsafe 源码中 monitor 相关的三个方法已经被标记为 deprecated,不建议被使用:

1
2
3
4
5
6
7
8
9
//获得对象锁
@Deprecated
Public native void monitorEnter (Object var 1);
//释放对象锁
@Deprecated
Public native void monitorExit (Object var 1);
//尝试获得对象锁
@Deprecated
Public native boolean tryMonitorEnter (Object var 1);

monitorEnter 方法用于获得对象锁,monitorExit 用于释放对象锁,如果对一个没有被 monitorEnter 加锁的对象执行此方法,会抛出 IllegalMonitorStateException 异常。tryMonitorEnter 方法尝试获取对象锁,如果成功则返回 true,反之返回 false

典型应用

Java 锁和同步器框架的核心类 AbstractQueuedSynchronizer (AQS),就是通过调用 LockSupport.Park ()LockSupport.Unpark () 实现线程的阻塞和唤醒的,而 LockSupportparkunpark 方法实际是调用 Unsafeparkunpark 方式实现的。

1
2
3
4
5
6
7
8
9
10
Public static void park (Object blocker) {
Thread t = Thread.CurrentThread ();
SetBlocker (t, blocker);
UNSAFE.Park (false, 0 L);
SetBlocker (t, null);
}
Public static void unpark (Thread thread) {
If (thread != null)
UNSAFE.Unpark (thread);
}

LockSupportpark 方法调用了 Unsafepark 方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用 unpark 方法唤醒当前线程。下面的例子对 Unsafe 的这两个方法进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Public static void main (String[] args) {
Thread mainThread = Thread.CurrentThread ();
New Thread (()->{
Try {
TimeUnit.SECONDS.Sleep (5);
System.Out.Println ("subThread try to unpark mainThread");
Unsafe.Unpark (mainThread);
} catch (InterruptedException e) {
e.printStackTrace ();
}
}). Start ();

System.Out.Println ("park main mainThread");
Unsafe.Park (false, 0 L);
System.Out.Println ("unpark mainThread success");
}

程序输出为:

1
2
3
Park main mainThread
SubThread try to unpark mainThread
Unpark mainThread success

程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用 park 方法阻塞自己,子线程在睡眠 5 秒后,调用 unpark 方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:

Class 操作

介绍

UnsafeClass 的相关操作主要包括类加载和静态变量的操作方法。

静态属性读取相关的方法

1
2
3
4
5
6
//获取静态属性的偏移量
Public native long staticFieldOffset (Field f);
//获取静态属性的对象指针
Public native Object staticFieldBase (Field f);
//判断类是否需要初始化(用于获取类的静态属性前进行检测)
public native boolean shouldBeInitialized (Class<?> c);

创建一个包含静态属性的类,进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
Public class User {
Public static String name="Hydra";
Int age;
}
Private void staticTest () throws Exception {
User user=new User ();
// 也可以用下面的语句触发类初始化
// 1.
// unsafe.EnsureClassInitialized (User. Class);
// 2.
// System.Out.Println (User. Name);
System.Out.Println (unsafe.ShouldBeInitialized (User. Class));
Field sexField = User.Class.GetDeclaredField ("name");
Long fieldOffset = unsafe.StaticFieldOffset (sexField);
Object fieldBase = unsafe.StaticFieldBase (sexField);
Object object = unsafe.GetObject (fieldBase, fieldOffset);
System.Out.Println (object);
}

运行结果:

Unsafe 的对象操作中,我们学习了通过 objectFieldOffset 方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用 staticFieldOffset 方法。在上面的代码中,只有在获取 Field 对象的过程中依赖到了 Class,而获取静态变量的属性时不再依赖于 Class

在上面的代码中首先创建一个 User 对象,这是因为如果一个类没有被初始化,那么它的静态属性也不会被初始化,最后获取的字段属性将是 null。所以在获取静态属性前,需要调用 shouldBeInitialized 方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:

使用 defineClass 方法允许程序在运行时动态地创建一个类

1
public native Class<?> defineClass (String name, byte[] b, int off, int len, ClassLoader loader, ProtectionDomain protectionDomain);

在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader)和保护域(ProtectionDomain)来源于调用此方法的实例。下面的例子中实现了反编译生成后的 class 文件的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Private static void defineTest () {
String fileName="F:\\workspace\\unsafe-test\\target\\classes\\com\\cn\\model\\User. Class";
File file = new File (fileName);
Try (FileInputStream fis = new FileInputStream (file)) {
Byte[] content=new byte[(int) file.Length ()];
Fis.Read (content);
Class clazz = unsafe.DefineClass (null, content, 0, content. Length, null, null);
Object o = clazz.NewInstance ();
Object age = clazz.GetMethod ("getAge"). Invoke (o, null);
System.Out.Println (age);
} catch (Exception e) {
e.printStackTrace ();
}
}

在上面的代码中,首先读取了一个 class 文件并通过文件流将它转化为字节数组,之后使用 defineClass 方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过 JVM 的所有安全检查。

除了 defineClass 方法外,Unsafe 还提供了一个 defineAnonymousClass 方法:

1
public native Class<?> defineAnonymousClass (Class<?> hostClass, byte[] data, Object[] cpPatches);

使用该方法可以用来动态的创建一个匿名类,在 Lambda 表达式中就是使用 ASM 动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在 JDK 15 发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用 UnsafedefineAnonymousClass 方法。

典型应用

Lambda 表达式实现需要依赖 UnsafedefineAnonymousClass 方法定义实现相应的函数式接口的匿名类。

系统信息

介绍

这部分包含两个获取系统相关信息的方法。

1
2
3
4
//返回系统指针的大小。返回值为 4(32 位系统)或 8(64 位系统)。
Public native int addressSize ();
//内存页的大小,此值为 2 的幂次方。
Public native int pageSize ();

典型应用

这两个方法的应用场景比较少,在 java. Nio. Bits 类中,在使用 pageCount 计算所需的内存页的数量时,调用了 pageSize 方法获取内存页的大小。另外,在使用 copySwapMemory 方法拷贝内存时,调用了 addressSize 方法,检测 32 位系统的情况。


Java 基础概念 常见面试问题
https://hexo.leelurker.com/posts/7600
作者
LeeLurker
发布于
2024年1月3日
许可协议