前言

前言

这是 JavaGuide 面试突击版本,只保留最常问的面试题,并对重点内容进行了 ⭐️ 标注。提供亮色和暗色两个主题,需要打印的朋友请选择亮色版本。

时间充裕的朋友,推荐使用 JavaGuide 网站系统学习,内容更全面深入。

JavaGuide

如果你想要付费支持/面试辅导(比如简历优化、一对一提问、高频考点突击资料等)的话,欢迎了解我的知识星球。已经坚持维护六年,内容持续更新,虽白菜价(0.4元/天)但质量很高,主打一个良心!

知识星球

面试突击最新版可在公众号回复「PDF」获取(知识星球会提前同步最新版)。

JavaGuide 公众号

重要说明

本站所有面试题保持年度系统性优化完善,严格同步 Java 技术生态与招聘市场的最新动态,确保内容时效性与前瞻性。

《SpringAI 智能面试平台+RAG 知识库》

这部分内容摘自 JavaGuide 下面几篇文章中的重点:

JavaGuide

Java 基础常见面试题总结(上)(Java 语言的基本概念、语法、数据类型、变量、方法等)

Java 基础常见面试题总结(上)

Java 基础常见面试题总结(中)(面向对象基础、字符串、对象的比较与拷贝等)

Java 基础常见面试题总结(中)

Java 基础常见面试题总结(下)(异常、泛型、反射、SPI、序列化、注解等)

Java 基础常见面试题总结(下)

基础概念与常识

基础概念与常识

⭐️JVM vs JDK vs JRE

⭐️JVM vs JDK vs JRE

JVM

JVM

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

如下图所示,不同编程语言(Java、Groovy、Kotlin、JRuby、Clojure ...)通过各自的编译器编译成 .class 文件,并最终通过 JVM 在不同平台(Windows、Mac、Linux)上运行。

.class

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

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

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

Comparison of Java virtual machines

Java SE Specifications

JDK 和 JRE

JDK 和 JRE

JDK(Java Development Kit)是一个功能齐全的 Java 开发工具包,供开发者使用,用于创建和编译 Java 程序。它包含了 JRE(Java Runtime Environment),以及编译器 javac 和其他工具,如 javadoc(文档生成器)、jdb(调试器)、jconsole(监控工具)、javap(反编译工具)等。

JRE 是运行已编译 Java 程序所需的环境,主要包含以下两个部分:

JVM : 也就是我们上面提到的 Java 虚拟机。

Java 基础类库(Class Library):一组标准的类库,提供常用的功能和 API(如 I/O 操作、网络通信、数据结构等)。

简单来说,JRE 只包含运行 Java 程序所需的环境和类库,而 JDK 不仅包含 JRE,还包括用于开发和调试 Java 程序的工具。

如果需要编写、编译 Java 程序或使用 JDK 附带的开发工具,就需要安装 JDK。某些会在运行时编译 Java 源码的应用程序(如 JSP 转换为 Servlet)也可能需要 JDK。Java 核心反射 API 属于运行时类库,使用反射本身并不要求安装完整 JDK。

下图清晰展示了 JDK、JRE 和 JVM 的关系。

jdk-include-jre

不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ jlink 工具(随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块)。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。

jlink

在 Java 9 新特性概览这篇文章中,我在介绍模块化系统的时候提到:

Java 9 新特性概览

在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具,创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。

在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具,创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。

也就是说,可以用 jlink 根据自己的需求,创建一个更小的 runtime(运行时),而不是不管什么应用,都是同样的 JRE。

定制的、模块化的 Java 运行时映像有助于简化 Java 应用的部署和节省内存并增强安全性和可维护性。这对于满足现代应用程序架构的需求,如虚拟化、容器化、微服务和云原生开发,是非常重要的。

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

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

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

.class

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

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

我们需要格外注意的是 .class->机器码 这一步。以 HotSpot 为例,JVM 加载字节码后可以先解释执行,并识别经常调用的方法和代码块(即热点代码),再由 JIT(Just in Time Compilation) 编译器将热点字节码编译为机器码。在当前 JVM 进程后续执行这些代码时,可以直接使用已编译的机器码。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。不过,JVM 规范并不要求具体实现必须包含解释器或 JIT 编译器。

.class->机器码

🌈 拓展阅读:基本功 | Java 即时编译器原理解析及实践 - 美团技术团队基于静态编译构建微服务应用 - 阿里巴巴中间件

🌈 拓展阅读:

基本功 | Java 即时编译器原理解析及实践 - 美团技术团队

基本功 | Java 即时编译器原理解析及实践 - 美团技术团队

基于静态编译构建微服务应用 - 阿里巴巴中间件

基于静态编译构建微服务应用 - 阿里巴巴中间件

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

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

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

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

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

下面这张图是 JVM 的大致结构模型。

JVM 的大致结构模型

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

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

其实这个问题我们讲字节码的时候已经提到过,因为比较重要,所以我们这里再提一下。

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

编译型:编译型语言 会通过编译器将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。

编译型语言

编译器

解释型:解释型语言会通过解释器一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。

解释型语言

解释器

编译型语言和解释型语言

根据维基百科介绍:

为了改善解释语言的效率而发展出的即时编译技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成字节码。到执行期时,再将字节码直译,之后执行。Java与LLVM是这种技术的代表产物。相关阅读:基本功 | Java 即时编译器原理解析及实践

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

即时编译

字节码

Java

LLVM

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

基本功 | Java 即时编译器原理解析及实践

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

这是因为常见的 Java 实现同时使用编译和解释技术:Java 源码先由编译器生成字节码(.class 文件),字节码可由 JVM 解释执行,也可在运行时由 JIT 编译为机器码。字节码并非必须由解释器执行,具体执行策略由 JVM 实现决定。

.class

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

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

JDK 9 曾通过 JEP 295 引入实验性的 AOT(Ahead of Time Compilation) 工具 jaotc,但该工具已在 JDK 17 中移除。因此,JDK 17 及之后的标准 JDK 不再包含这套内置 AOT 编译器;下文讨论的是一般意义上的 AOT,以及 GraalVM Native Image 等独立工具链。和 JIT 不同,AOT 会在程序执行前将代码编译为机器码,能够减少运行时预热开销并改善启动速度,但具体的内存占用、峰值性能和适用场景取决于所使用的 AOT 实现与应用负载。

jaotc

下面的对比以常见的 HotSpot JIT 和 GraalVM Native Image 为例。不同 AOT 工具的实现方式并不完全相同,实际表现还会受到构建参数、PGO(Profile-Guided Optimization,基于性能数据的优化)和应用负载影响。

对比维度JIT(即时编译)AOT(提前编译)编译时机运行时根据代码执行情况编译构建阶段提前编译启动与预热启动后通常需要解释执行和编译热点代码通常启动更快,不需要等待 JIT 预热长期运行性能可以利用运行时采集的信息持续优化热点代码缺少完整的运行时信息,具体表现取决于实现和构建配置运行时内存需要保存编译器、性能数据和生成的机器码Native Image 等实现通常占用更少的运行时内存运行依赖需要 JVM 和相应运行时Native Image 可以生成独立的本地可执行文件动态特性支持运行时加载、反射和字节码生成闭世界分析工具通常需要元数据或构建期处理常见场景长时间运行、重视持续吞吐量的服务CLI、Serverless、弹性扩缩容和冷启动敏感的服务

对比维度JIT(即时编译)AOT(提前编译)

对比维度

JIT(即时编译)

AOT(提前编译)

编译时机运行时根据代码执行情况编译构建阶段提前编译

编译时机

运行时根据代码执行情况编译

构建阶段提前编译

启动与预热启动后通常需要解释执行和编译热点代码通常启动更快,不需要等待 JIT 预热

启动与预热

启动后通常需要解释执行和编译热点代码

通常启动更快,不需要等待 JIT 预热

长期运行性能可以利用运行时采集的信息持续优化热点代码缺少完整的运行时信息,具体表现取决于实现和构建配置

长期运行性能

可以利用运行时采集的信息持续优化热点代码

缺少完整的运行时信息,具体表现取决于实现和构建配置

运行时内存需要保存编译器、性能数据和生成的机器码Native Image 等实现通常占用更少的运行时内存

运行时内存

需要保存编译器、性能数据和生成的机器码

Native Image 等实现通常占用更少的运行时内存

运行依赖需要 JVM 和相应运行时Native Image 可以生成独立的本地可执行文件

运行依赖

需要 JVM 和相应运行时

Native Image 可以生成独立的本地可执行文件

动态特性支持运行时加载、反射和字节码生成闭世界分析工具通常需要元数据或构建期处理

动态特性

支持运行时加载、反射和字节码生成

闭世界分析工具通常需要元数据或构建期处理

常见场景长时间运行、重视持续吞吐量的服务CLI、Serverless、弹性扩缩容和冷启动敏感的服务

常见场景

长时间运行、重视持续吞吐量的服务

CLI、Serverless、弹性扩缩容和冷启动敏感的服务

JIT vs AOT

AOT 的优势主要体现在启动速度和运行时内存占用,比较适合冷启动频繁、实例生命周期较短或者需要快速扩容的应用。JIT 能根据程序运行时收集到的信息优化热点代码,长时间运行的服务通常更容易发挥这方面的优势。二者的吞吐量和延迟表现不能只由编译方式直接下结论,还需要结合具体工具链和实际负载测试。

提到 AOT 就不得不提 GraalVM 了!GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。感兴趣的同学,可以去看看 GraalVM 的官方文档:https://www.graalvm.org/latest/docs/。如果觉得官方文档看着比较难理解的话,也可以找一些文章来看看,比如:

GraalVM

https://www.graalvm.org/latest/docs/

基于静态编译构建微服务应用

基于静态编译构建微服务应用

走向 Native 化:Spring&Dubbo AOT 技术示例与原理讲解

走向 Native 化:Spring&Dubbo AOT 技术示例与原理讲解

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

以 GraalVM Native Image 为例,它在构建本地可执行文件时会进行闭世界分析:构建器从程序入口出发,分析哪些类、方法和字段可能在运行时被访问,只把可达代码和必要的元数据放进最终产物。程序依旧可以接收动态输入和创建对象;构建阶段完全未知的代码则不会自动进入分析结果。

下面这段代码中的类名来自运行时参数,构建器无法仅靠静态调用关系确定要保留哪些类:

String className = args[0];
Class<?> clazz = Class.forName(className);
Object instance = clazz.getDeclaredConstructor().newInstance();
String className = args[0];
Class<?> clazz = Class.forName(className);
Object instance = clazz.getDeclaredConstructor().newInstance();

反射、动态代理和 JNI 在 Native Image 中仍然可以使用。对于静态分析无法推断的动态访问,通常需要可达性元数据,提前声明运行时可能访问的类、方法、字段、代理接口和 JNI 元素。运行时动态加载未知类、生成并加载新字节码的限制会更严格,因为相关代码在构建阶段并不存在。

可达性元数据

Spring 使用 AOT 处理来适配这种执行方式。它会在构建阶段分析应用上下文,生成 Java 源码、代理字节码以及反射、资源和代理所需的 RuntimeHints。CGLIB 通常借助 ASM 在运行时生成代理类;到了 Native Image 场景,这类工作可以提前到构建阶段完成。框架或应用提供相应的构建期适配后,Spring、CGLIB 和 ASM 仍可参与 AOT 应用的构建与运行。具体机制可以参考 Spring AOT 官方文档。

RuntimeHints

Spring AOT 官方文档

AOT 把一部分运行时工作和信息搬到了构建阶段,同时增加了构建时间、元数据维护和兼容性适配成本。对于依赖运行时动态加载、Java Agent 或大量动态字节码生成的应用,JIT 模式通常更省事;对于冷启动和内存占用敏感的应用,AOT 更有吸引力。

Oracle JDK vs OpenJDK

Oracle JDK vs OpenJDK

可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK。那么 Oracle JDK 和 OpenJDK 之间是否存在重大差异?下面我通过收集到的一些资料,为你解答这个被很多人忽视的问题。

首先,2006 年 SUN 公司将 Java 开源,也就有了 OpenJDK。2009 年 Oracle 收购了 Sun 公司,于是自己在 OpenJDK 的基础上搞了一个 Oracle JDK。Oracle JDK 是不开源的,并且刚开始的几个版本(Java8 ~ Java11)还会相比于 OpenJDK 添加一些特有的功能和工具。

其次,对于 Java 7 而言,OpenJDK 和 Oracle JDK 是十分接近的。 Oracle JDK 是基于 OpenJDK 7 构建的,只添加了一些小功能,由 Oracle 工程师参与维护。

下面这段话摘自 Oracle 官方在 2012 年发表的一个博客:

问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别?答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些闭源的第三方组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。

问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别?

答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些闭源的第三方组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。

最后,简单总结一下 Oracle JDK 和 OpenJDK 的区别:

是否开源: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。

https://github.com/openjdk/jdk

是否免费:Oracle JDK 的许可取决于具体版本和更新版本。Oracle JDK 21 及之后的特定更新在 NFTC 有效期内允许包括商业生产在内的免费使用;例如,Oracle 计划对 JDK 21 更新采用 NFTC 至 2026 年 9 月,对 JDK 25 更新采用 NFTC 至 2028 年 9 月。免费期结束后改变的是后续更新的许可,已经下载的版本仍按下载时的许可证使用。Oracle 的 OpenJDK 构建则使用 GPLv2 + Classpath Exception。

功能性:Oracle JDK 在 OpenJDK 的基础上添加了一些特有的功能和工具,比如 Java Flight Recorder(JFR,一种监控工具)、Java Mission Control(JMC,一种监控工具)等工具。不过,在 Java 11 之后,OracleJDK 和 OpenJDK 的功能基本一致,之前 OracleJDK 中的私有组件大多数也已经被捐赠给开源组织。

长期支持:OpenJDK 项目本身不承诺商业 LTS 服务;Oracle 以及多家 OpenJDK 发行版厂商会为特定版本提供长期支持。Java 8、11、17、21、25 是 Oracle LTS 版本,Oracle 计划未来每两年发布一个 LTS 版本。

协议:Oracle JDK 的许可证随版本和更新版本而异,可能是 NFTC 或 OTN;BCL 仅用于 2019 年 4 月 16 日之前发布的版本。Oracle OpenJDK 使用 GPLv2 + Classpath Exception。

既然 Oracle JDK 这么好,那为什么还要有 OpenJDK?答:OpenJDK 是开源的,开源意味着你可以对它根据你自己的需要进行修改、优化,比如 Alibaba 基于 OpenJDK 开发了 Dragonwell8:https://github.com/alibaba/dragonwell8OpenJDK 项目源码采用 GPLv2 + Classpath Exception,但具体发行版的二进制许可、更新周期和商业支持由发行商决定;Oracle JDK 的使用许可也随版本与更新版本变化,不能笼统概括为“Oracle JDK 免费”或“OpenJDK 一定免费”。OpenJDK 与 Oracle JDK 的特性版本都遵循六个月发布节奏;各发行版的更新与支持周期则可能不同。基于以上这些原因,OpenJDK 还是有存在的必要的!

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

答:

OpenJDK 是开源的,开源意味着你可以对它根据你自己的需要进行修改、优化,比如 Alibaba 基于 OpenJDK 开发了 Dragonwell8:https://github.com/alibaba/dragonwell8

https://github.com/alibaba/dragonwell8

OpenJDK 项目源码采用 GPLv2 + Classpath Exception,但具体发行版的二进制许可、更新周期和商业支持由发行商决定;Oracle JDK 的使用许可也随版本与更新版本变化,不能笼统概括为“Oracle JDK 免费”或“OpenJDK 一定免费”。

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 用的都是这个协议,可以自己私下用,但是商用需要付费。

基本语法

基本语法

⭐️自增自减运算符

⭐️自增自减运算符

在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1。Java 提供了自增运算符 (++) 和自减运算符 (--) 来简化这种操作。

++
--

++ 和 -- 运算符可以放在变量之前,也可以放在变量之后:

++
--

前缀形式(例如 ++a 或 --a):先自增/自减变量的值,然后再使用该变量,例如,b = ++a 先将 a 增加 1,然后把增加后的值赋给 b。

++a
--a
b = ++a
a
b

后缀形式(例如 a++ 或 a--):先使用变量的当前值,然后再自增/自减变量的值。例如,b = a++ 先将 a 的当前值赋给 b,然后再将 a 增加 1。

a++
a--
b = a++
a
b
a

为了方便记忆,可以使用下面的口诀:符号在前就先加/减,符号在后就后加/减。

下面来看一个考察自增自减运算符的高频笔试题:执行下面的代码后,a 、b 、 c 、d和e的值是?

a
b
c
d
e
int a = 9;
int b = a++;
int c = ++a;
int d = c--;
int e = --d;
int a = 9;
int b = a++;
int c = ++a;
int d = c--;
int e = --d;

答案:a = 11 、b = 9 、 c = 10 、 d = 10 、 e = 10。

a = 11
b = 9
c = 10
d = 10
e = 10

⭐️移位运算符

⭐️移位运算符

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

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

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

使用移位运算符的主要原因:

高效:移位运算符直接对应于处理器的移位指令。现代处理器具有专门的硬件指令来执行这些移位操作,这些指令通常在一个时钟周期内完成。相比之下,乘法和除法等算术运算在硬件层面上需要更多的时钟周期来完成。

节省内存:通过移位操作,可以使用一个整数(如 int 或 long)来存储多个布尔值或标志位,从而节省内存。

int
long

移位运算符最常用于快速乘以或除以 2 的幂次方。除此之外,它还在以下方面发挥着重要作用:

位字段管理:例如存储和操作多个布尔值。

哈希算法和加密解密:通过移位和与、或等操作来混淆数据。

数据压缩:例如霍夫曼编码通过移位运算符可以快速处理和操作二进制数据,以生成紧凑的压缩格式。

数据校验:例如 CRC(循环冗余校验)通过移位和多项式除法生成和校验数据完整性。

内存对齐:通过移位操作,可以轻松计算和调整数据的对齐地址。

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

Java 中有三种移位运算符:

<< :左移运算符,向左移若干位,高位丢弃,低位补零。x << n,相当于 x 乘以 2 的 n 次方(不溢出的情况下)。

<<
x << n

> :带符号右移,向右移若干位,高位补符号位,低位丢弃。正数高位补 0,负数高位补 1。x >> n,相当于 x 除以 2 的 n 次方。

>>
x >> n

>> :无符号右移,忽略符号位,空位都以 0 补齐。

>>>

虽然移位运算本质上可以分为左移和右移,但在实际应用中,右移操作需要考虑符号位的处理方式。

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

double
float

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

int
long
short
byte
char
int

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

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

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

x<<42
x<<10
x>>42
x>>10
x >>>42
x >>> 10

左移运算符代码示例:

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));
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
初始数据对应的二进制字符串:11111111111111111111111111111111
左移 10 位后的数据 -1024
左移 10 位后的数据对应的二进制字符 11111111111111111111110000000000
初始数据:-1
初始数据对应的二进制字符串:11111111111111111111111111111111
左移 10 位后的数据 -1024
左移 10 位后的数据对应的二进制字符 11111111111111111111110000000000

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

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

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

⭐️基本数据类型

⭐️基本数据类型

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

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

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

6 种数字类型: 4 种整数型:byte、short、int、long2 种浮点型:float、double

4 种整数型:byte、short、int、long

byte
short
int
long

2 种浮点型:float、double

float
double

1 种字符类型:char

char

1 种布尔型:boolean。

boolean

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

基本类型位数字节默认值取值范围byte810-128 ~ 127short1620-32768(-2^15) ~ 32767(2^15 - 1)int3240-2147483648 ~ 2147483647long6480L-9223372036854775808(-2^63) ~ 9223372036854775807(2^63 -1)char162'\u0000'0 ~ 65535(2^16 - 1)float3240f约 -3.4028235E38 ~ 3.4028235E38,最小正非零值约 1.4E-45,另含 ±0、±∞、NaNdouble6480d约 -1.7976931348623157E308 ~ 1.7976931348623157E308,最小正非零值约 4.9E-324,另含 ±0、±∞、NaNboolean未规定未规定falsetrue、false

基本类型位数字节默认值取值范围

基本类型

位数

字节

默认值

取值范围

byte810-128 ~ 127

byte

byte

8

1

0

-128 ~ 127

short1620-32768(-2^15) ~ 32767(2^15 - 1)

short

short

16

2

0

-32768(-2^15) ~ 32767(2^15 - 1)

int3240-2147483648 ~ 2147483647

int

int

32

4

0

-2147483648 ~ 2147483647

long6480L-9223372036854775808(-2^63) ~ 9223372036854775807(2^63 -1)

long

long

64

8

0L

-9223372036854775808(-2^63) ~ 9223372036854775807(2^63 -1)

char162'\u0000'0 ~ 65535(2^16 - 1)

char

char

16

2

'\u0000'

0 ~ 65535(2^16 - 1)

float3240f约 -3.4028235E38 ~ 3.4028235E38,最小正非零值约 1.4E-45,另含 ±0、±∞、NaN

float

float

32

4

0f

约 -3.4028235E38 ~ 3.4028235E38,最小正非零值约 1.4E-45,另含 ±0、±∞、NaN

double6480d约 -1.7976931348623157E308 ~ 1.7976931348623157E308,最小正非零值约 4.9E-324,另含 ±0、±∞、NaN

double

double

64

8

0d

约 -1.7976931348623157E308 ~ 1.7976931348623157E308,最小正非零值约 4.9E-324,另含 ±0、±∞、NaN

boolean未规定未规定falsetrue、false

boolean

boolean

未规定

未规定

false

true、false

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

byte
short
int
long

Java 语言和 JVM 规范没有规定 boolean 的具体存储大小。它在语义上只有 true 和 false 两个取值,但字段、局部变量和数组元素的实际布局由 JVM 实现决定,不能据此推导为固定占用 1 位或 1 字节。

boolean
true
false

另外,Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一(《Java 编程思想》2.2 节有提到)。

注意:

整数字面量默认按 int 解析;超出 int 范围或需要显式写成 long 字面量时,应在数值后加 L。能通过赋值转换表示的 int 字面量可以直接赋给 long,例如 long n = 1;。

int
int
long
int
long
long n = 1;

带小数点或指数的浮点字面量默认是 double,赋给 float 时通常需要加 f 或 F;整数字面量在可转换时可直接赋给 float,例如 float n = 1;。

double
float
float
float n = 1;

char a = 'h'char :单引号,String a = "hello" :双引号。

char a = 'h'
String a = "hello"

这八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean。

Byte
Short
Integer
Long
Float
Double
Character
Boolean

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

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

用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。

存储方式:基本数据类型的局部变量保存在当前栈帧的局部变量表中,基本数据类型的实例字段属于对象状态。包装类型属于对象类型,其实例通常分配在堆中,但 JIT 可能通过逃逸分析和标量替换消除实际分配。

占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。

默认值:成员变量包装类型不赋值就是 null,而基本类型有默认值且不是 null。

null
null

比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较两个引用是否指向同一个对象(或都为 null)。比较包装对象表示的数值通常使用 equals() 或相应的 compare()/compareTo() 方法。

==
==
null
equals()
compare()
compareTo()

为什么说对象实例通常存在于堆中呢? JVM 规范将堆定义为分配类实例和数组的运行时数据区。不过,JIT 可以通过逃逸分析和标量替换消除某些对象的实际分配,这不等同于必须把完整对象分配到栈上。

⚠️ 注意:基本数据类型存放在栈中是一个常见的误区! 基本数据类型的存储位置取决于变量种类:局部变量保存在栈帧的局部变量表中,实例字段属于堆中对象的一部分;静态字段属于类,具体存储方式由 JVM 实现决定,不能笼统地说在方法区或元空间中。

public class Test {
    // 成员变量,存放在堆中
    int a = 10;
    // 静态字段的存储属于 JVM 实现细节;在 JDK 8 及之后的 HotSpot 中位于 Java 堆。
    // 变量属于类,不属于对象。
    static int b = 20;

    public void method() {
        // 局部变量,存放在栈中
        int c = 30;
        static int d = 40; // 编译错误,不能在方法中使用 static 修饰局部变量
    }
}
public class Test {
    // 成员变量,存放在堆中
    int a = 10;
    // 静态字段的存储属于 JVM 实现细节;在 JDK 8 及之后的 HotSpot 中位于 Java 堆。
    // 变量属于类,不属于对象。
    static int b = 20;

    public void method() {
        // 局部变量,存放在栈中
        int c = 30;
        static int d = 40; // 编译错误,不能在方法中使用 static 修饰局部变量
    }
}

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

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

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

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

Byte
Short
Integer
Long
Character
Boolean
TRUE
FALSE

对于 Integer,可以通过 JVM 参数 -XX:AutoBoxCacheMax=<size> 修改缓存上限,但不能修改下限 -128。实际使用时,并不建议设置过大的值,避免浪费内存,甚至是 OOM。

Integer
-XX:AutoBoxCacheMax=<size>

对于Byte,Short,Long ,Character 没有类似 -XX:AutoBoxCacheMax 参数可以修改,因此缓存范围是固定的,无法通过 JVM 参数调整。Boolean 则直接返回预定义的 TRUE 和 FALSE 实例,没有缓存范围的概念。

Byte
Short
Long
Character
-XX:AutoBoxCacheMax
Boolean
TRUE
FALSE

Integer 缓存源码:

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;
    }
}
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 缓存源码:

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

}
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 缓存源码:

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

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

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

Float
Double
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
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 呢?

true
false
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);
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) 会直接创建新的对象。

Integer i1=40
Integer i1=Integer.valueOf(40)
i1
Integer i2 = new Integer(40)

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

false

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

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

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

什么是自动拆装箱?

装箱:将基本类型用它们对应的引用类型包装起来;

拆箱:将包装类型转换为基本数据类型;

举例:

Integer i = 10;  //装箱
int n = i;   //拆箱
Integer i = 10;  //装箱
int n = i;   //拆箱

上面这两行代码对应的字节码为:

L1

    LINENUMBER 8 L1

    ALOAD 0

    BIPUSH 10

    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;

    PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;

   L2

    LINENUMBER 9 L2

    ALOAD 0

    ALOAD 0

    GETFIELD AutoBoxTest.i : Ljava/lang/Integer;

    INVOKEVIRTUAL java/lang/Integer.intValue ()I

    PUTFIELD AutoBoxTest.n : I

    RETURN
L1

    LINENUMBER 8 L1

    ALOAD 0

    BIPUSH 10

    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;

    PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;

   L2

    LINENUMBER 9 L2

    ALOAD 0

    ALOAD 0

    GETFIELD AutoBoxTest.i : Ljava/lang/Integer;

    INVOKEVIRTUAL java/lang/Integer.intValue ()I

    PUTFIELD AutoBoxTest.n : I

    RETURN

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

valueOf()
xxxValue()

因此,

Integer i = 10 等价于 Integer i = Integer.valueOf(10)

Integer i = 10
Integer i = Integer.valueOf(10)

int n = i 等价于 int n = i.intValue();

int n = i
int n = i.intValue()

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

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

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

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

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

float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.printf("%.9f",a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false
float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.printf("%.9f",a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false

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

这个和计算机保存浮点数的机制有很大关系。计算机使用有限位宽的二进制格式表示 float 和 double,许多十进制小数转换为二进制后会无限循环,只能舍入为有限位数,因此存在精度损失的风险。不过,像 0.5、0.25 这样能够表示为有限二进制小数的值可以被精确表示。

float
double

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

// 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(发生循环)
...
// 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 可以精确表示十进制数,并提供可显式指定精度和舍入规则的运算。使用有限精度、舍入除法或转换为 float、double 时仍可能发生舍入。通常情况下,大部分需要十进制精确运算结果的业务场景(比如涉及到钱的场景)都会使用 BigDecimal。

BigDecimal
float
double
BigDecimal
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
BigDecimal c = new BigDecimal("0.8");

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

System.out.println(x); /* 0.2 */
System.out.println(y); /* 0.20 */
// 比较内容,不是比较值
System.out.println(Objects.equals(x, y)); /* false */
// 比较值相等用相等compareTo,相等返回0
System.out.println(0 == x.compareTo(y)); /* true */
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
BigDecimal c = new BigDecimal("0.8");

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

System.out.println(x); /* 0.2 */
System.out.println(y); /* 0.20 */
// 比较内容,不是比较值
System.out.println(Objects.equals(x, y)); /* false */
// 比较值相等用相等compareTo,相等返回0
System.out.println(0 == x.compareTo(y)); /* true */

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

BigDecimal

BigDecimal 详解

金额计算应该选择 long 还是 BigDecimal?

金额计算应该选择 long 还是 BigDecimal?

两者都可以,关键是统一金额单位、精度和舍入规则:

long 适合币种和小数位固定、能够统一换算为最小货币单位(如“分”)的场景。它占用空间小、运算快,但必须防范单位混用、乘除法中的中间结果溢出,以及无法自然表达超出最小单位的金额。

long

BigDecimal 适合小数位或计算规则更复杂的场景。应优先用字符串或 BigDecimal.valueOf(long/double) 构造,避免 new BigDecimal(double) 把二进制浮点误差带入金额;除法等可能产生无限小数的运算,要按业务显式指定 scale 和 RoundingMode。

BigDecimal
BigDecimal.valueOf(long/double)
new BigDecimal(double)
scale
RoundingMode

无论选择哪一种,都应在接口、存储和计算链路中统一精度与舍入时机。金额转换为整数最小单位时,建议先移动小数点并用 longValueExact() 做精确转换;直接调用 longValue() 会截断小数,超出范围时还可能丢失高位。

longValueExact()
longValue()

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

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

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

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

long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true

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

BigInteger
int[]

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

BigInteger

面向对象基础

面向对象基础

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

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

语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。

public
private
static
static
final

存储方式:如果成员变量使用 static 修饰,那么它属于类;如果没有使用 static 修饰,那么它属于实例。实例字段是对象状态的一部分,方法参数和局部变量则保存在当前栈帧的局部变量表中。JIT 优化可能消除部分实际存储。

static
static

生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。

默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。

final

为什么成员变量有默认值?

JLS 规定,类变量、实例变量和数组元素在创建时会被初始化为各自类型的默认值,例如数值类型为 0、boolean 为 false、引用类型为 null。局部变量不进行默认初始化,并受“明确赋值”(definite assignment)规则约束:在读取局部变量前,编译器必须能够确定它已经被赋值。这里是语言规范直接规定的两套初始化规则,并不是因为编译器无法预测成员变量何时赋值。

boolean
false
null

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

public class VariableExample {

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

    // 方法中的局部变量
    public void method() {
        int num1 = 10; // 栈中分配的局部变量
        String str = "Hello, world!"; // 栈中分配的局部变量
        System.out.println(num1);
        System.out.println(str);
    }

    // 带参数的方法中的局部变量
    public void method2(int num2) {
        int sum = num2 + 10; // 栈中分配的局部变量
        System.out.println(sum);
    }

    // 构造方法中的局部变量
    public VariableExample(String name, int age) {
        this.name = name; // 对成员变量进行赋值
        this.age = age; // 对成员变量进行赋值
        int num3 = 20; // 栈中分配的局部变量
        String str2 = "Hello, " + this.name + "!"; // 栈中分配的局部变量
        System.out.println(num3);
        System.out.println(str2);
    }
}
public class VariableExample {

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

    // 方法中的局部变量
    public void method() {
        int num1 = 10; // 栈中分配的局部变量
        String str = "Hello, world!"; // 栈中分配的局部变量
        System.out.println(num1);
        System.out.println(str);
    }

    // 带参数的方法中的局部变量
    public void method2(int num2) {
        int sum = num2 + 10; // 栈中分配的局部变量
        System.out.println(sum);
    }

    // 构造方法中的局部变量
    public VariableExample(String name, int age) {
        this.name = name; // 对成员变量进行赋值
        this.age = age; // 对成员变量进行赋值
        int num3 = 20; // 栈中分配的局部变量
        String str2 = "Hello, " + this.name + "!"; // 栈中分配的局部变量
        System.out.println(num3);
        System.out.println(str2);
    }
}

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

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

1、调用方式

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

类名.方法名
对象.方法名

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

对象.方法名

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

类名.方法名
public class Person {
    public void method() {
      //......
    }

    public static void staicMethod(){
      //......
    }
    public static void main(String[] args) {
        Person person = new Person();
        // 调用实例方法
        person.method();
        // 调用静态方法
        Person.staicMethod()
    }
}
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 的构造方法)有相同的名字、不同的参数, 便产生了重载。StringBuilder sb = new StringBuilder();

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

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

StringBuilder
StringBuilder sb = new StringBuilder();
StringBuilder sb2 = new StringBuilder("HelloWorld");
StringBuilder sb = new StringBuilder();
StringBuilder sb2 = new StringBuilder("HelloWorld");

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

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

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

重写

重写

重写是子类实例方法与父类可访问实例方法之间的声明关系,由编译器按规则检查;运行期发生的是对重写方法的动态分派。

方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。

如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。

private/final/static
static

构造方法无法被重写

总结

总结

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

区别点重载 (Overloading)重写 (Overriding)发生范围同一个类中。父类与子类之间(存在继承关系)。方法签名方法名必须相同,但参数列表必须不同(参数的类型、个数或顺序至少有一项不同)。方法名、参数列表必须完全相同。返回类型与返回值类型无关,可以任意修改。子类方法的返回类型必须与父类方法的返回类型相同,或者是其子类。访问修饰符与访问修饰符无关,可以任意修改。子类方法的访问权限不能低于父类方法的访问权限。(public > protected > default > private)绑定时期编译时绑定或称静态绑定运行时绑定 (Run-time Binding) 或称动态绑定

区别点重载 (Overloading)重写 (Overriding)

区别点

重载 (Overloading)

重写 (Overriding)

发生范围同一个类中。父类与子类之间(存在继承关系)。

发生范围

同一个类中。

父类与子类之间(存在继承关系)。

方法签名方法名必须相同,但参数列表必须不同(参数的类型、个数或顺序至少有一项不同)。方法名、参数列表必须完全相同。

方法签名

方法名必须相同,但参数列表必须不同(参数的类型、个数或顺序至少有一项不同)。

方法名、参数列表必须完全相同。

返回类型与返回值类型无关,可以任意修改。子类方法的返回类型必须与父类方法的返回类型相同,或者是其子类。

返回类型

与返回值类型无关,可以任意修改。

子类方法的返回类型必须与父类方法的返回类型相同,或者是其子类。

访问修饰符与访问修饰符无关,可以任意修改。子类方法的访问权限不能低于父类方法的访问权限。(public > protected > default > private)

访问修饰符

与访问修饰符无关,可以任意修改。

子类方法的访问权限不能低于父类方法的访问权限。(public > protected > default > private)

绑定时期编译时绑定或称静态绑定运行时绑定 (Run-time Binding) 或称动态绑定

绑定时期

编译时绑定或称静态绑定

运行时绑定 (Run-time Binding) 或称动态绑定

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

issue#892

“两同”即方法名相同、形参列表相同;

“两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;

“一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。

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

public class Hero {
    public String name() {
        return "超级英雄";
    }
}
public class SuperMan extends Hero{
    @Override
    public String name() {
        return "超人";
    }
    public Hero hero() {
        return new Hero();
    }
}

public class SuperSuperMan extends SuperMan {
    @Override
    public String name() {
        return "超级超级英雄";
    }

    @Override
    public SuperMan hero() {
        return new SuperMan();
    }
}
public class Hero {
    public String name() {
        return "超级英雄";
    }
}
public class SuperMan extends Hero{
    @Override
    public String name() {
        return "超人";
    }
    public Hero hero() {
        return new Hero();
    }
}

public class SuperSuperMan extends SuperMan {
    @Override
    public String name() {
        return "超级超级英雄";
    }

    @Override
    public SuperMan hero() {
        return new SuperMan();
    }
}

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

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

面向过程编程(Procedural-Oriented Programming,POP)和面向对象编程(Object-Oriented Programming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同:

面向过程编程(POP):面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。

面向对象编程(OOP):面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。

相比较于 POP,OOP 开发的程序一般具有下面这些优点:

易维护:由于良好的结构和封装性,OOP 程序通常更容易维护。

易复用:通过继承和多态,OOP 设计使得代码更具复用性,方便扩展功能。

易扩展:模块化设计使得系统扩展变得更加容易和灵活。

POP 的编程方式通常更为简单和直接,适合处理一些较简单的任务。

POP 和 OOP 的性能差异主要取决于它们的运行机制,而不仅仅是编程范式本身。因此,简单地比较两者的性能是一个常见的误区(相关 issue : 面向过程:面向过程性能比面向对象高?? )。

面向过程:面向过程性能比面向对象高??

 POP 和 OOP  性能比较不合适

在选择编程范式时,性能并不是唯一的考虑因素。代码的可维护性、可扩展性和开发效率同样重要。

现代编程语言基本都支持多种编程范式,既可以用来进行面向过程编程,也可以进行面向对象编程。

下面是一个求圆的面积和周长的示例,简单分别展示了面向对象和面向过程两种不同的解决方案。

面向对象:

public class Circle {
    // 定义圆的半径
    private double radius;

    // 构造函数
    public Circle(double radius) {
        this.radius = radius;
    }

    // 计算圆的面积
    public double getArea() {
        return Math.PI * radius * radius;
    }

    // 计算圆的周长
    public double getPerimeter() {
        return 2 * Math.PI * radius;
    }

    public static void main(String[] args) {
        // 创建一个半径为3的圆
        Circle circle = new Circle(3.0);

        // 输出圆的面积和周长
        System.out.println("圆的面积为:" + circle.getArea());
        System.out.println("圆的周长为:" + circle.getPerimeter());
    }
}
public class Circle {
    // 定义圆的半径
    private double radius;

    // 构造函数
    public Circle(double radius) {
        this.radius = radius;
    }

    // 计算圆的面积
    public double getArea() {
        return Math.PI * radius * radius;
    }

    // 计算圆的周长
    public double getPerimeter() {
        return 2 * Math.PI * radius;
    }

    public static void main(String[] args) {
        // 创建一个半径为3的圆
        Circle circle = new Circle(3.0);

        // 输出圆的面积和周长
        System.out.println("圆的面积为:" + circle.getArea());
        System.out.println("圆的周长为:" + circle.getPerimeter());
    }
}

我们定义了一个 Circle 类来表示圆,该类包含了圆的半径属性和计算面积、周长的方法。

Circle

面向过程:

public class Main {
    public static void main(String[] args) {
        // 定义圆的半径
        double radius = 3.0;

        // 计算圆的面积和周长
        double area = Math.PI * radius * radius;
        double perimeter = 2 * Math.PI * radius;

        // 输出圆的面积和周长
        System.out.println("圆的面积为:" + area);
        System.out.println("圆的周长为:" + perimeter);
    }
}
public class Main {
    public static void main(String[] args) {
        // 定义圆的半径
        double radius = 3.0;

        // 计算圆的面积和周长
        double area = Math.PI * radius * radius;
        double perimeter = 2 * Math.PI * radius;

        // 输出圆的面积和周长
        System.out.println("圆的面积为:" + area);
        System.out.println("圆的周长为:" + perimeter);
    }
}

我们直接定义了圆的半径,并使用该半径直接计算出圆的面积和周长。

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

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

对象相等通常由 equals() 定义,用于比较类型约定的逻辑状态或值。

equals()

引用相等由 == 判断,表示两个引用是否指向同一个对象(或都为 null),Java 语言并不暴露或比较物理内存地址。

==
null

这里举一个例子:

String str1 = "hello";
String str2 = new String("hello");
String str3 = "hello";
// 使用 == 比较字符串的引用相等
System.out.println(str1 == str2);
System.out.println(str1 == str3);
// 使用 equals 方法比较字符串的相等
System.out.println(str1.equals(str2));
System.out.println(str1.equals(str3));
String str1 = "hello";
String str2 = new String("hello");
String str3 = "hello";
// 使用 == 比较字符串的引用相等
System.out.println(str1 == str2);
System.out.println(str1 == str3);
// 使用 equals 方法比较字符串的相等
System.out.println(str1.equals(str2));
System.out.println(str1.equals(str3));

输出结果:

false
true
true
true
false
true
true
true

从上面的代码输出结果可以看出:

str1 和 str2 不相等,而 str1 和 str3 相等。这是因为 == 运算符比较的是字符串的引用是否相等。

str1
str2
str1
str3
==

str1、 str2、str3 三者的内容都相等。这是因为 equals 方法比较的是字符串的内容,即使这些字符串的对象引用不同,只要它们的内容相等,就认为它们是相等的。

str1
str2
str3
equals

⭐️面向对象三大特征

⭐️面向对象三大特征

封装

封装

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

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;
    }
}
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 点请记住:

子类对象包含由父类声明的实例状态,但父类的 private 成员不会被子类继承,子类也不能直接访问这些成员。

private

子类可以拥有自己属性和方法,即子类可以对父类进行扩展。

子类可以用自己的方式实现父类的方法。(以后介绍)。

多态

多态

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

多态的特点:

对象类型和引用类型之间具有继承(类)/实现(接口)的关系;

引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;

多态不能调用“只在子类存在但在父类不存在”的方法;

如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。

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

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

接口和抽象类的共同点

接口和抽象类的共同点

实例化:接口和抽象类都不能直接实例化,只能被实现(接口)或继承(抽象类)后才能创建具体的对象。

抽象方法:接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。

接口和抽象类的区别

接口和抽象类的区别

设计目的:接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。

继承和实现:一个类只能继承一个类(包括抽象类),因为 Java 不支持多继承。但一个类可以实现多个接口,一个接口也可以继承多个其他接口。

成员变量:接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符(private, protected, public),可以在子类中被重新定义或赋值。

public static final
private
protected
public

方法: Java 8 之前,接口中的方法默认是 public abstract,也就是只能有方法声明。自 Java 8 起,可以在接口中定义 default(默认) 方法和 static(静态)方法。 自 Java 9 起,接口可以包含 private 方法。抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。

Java 8 之前,接口中的方法默认是 public abstract,也就是只能有方法声明。自 Java 8 起,可以在接口中定义 default(默认) 方法和 static(静态)方法。 自 Java 9 起,接口可以包含 private 方法。

public abstract
default
static
private

抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。

Java 8 为接口引入了 default 方法和 static 方法,Java 9 又允许接口声明 private 方法。这些方法让接口的使用更加灵活。

default
static
private

Java 8 引入的 default 方法用于提供接口方法的默认实现,可以在实现类中被覆盖。这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。

default
public interface MyInterface {
    default void defaultMethod() {
        System.out.println("This is a default method.");
    }
}
public interface MyInterface {
    default void defaultMethod() {
        System.out.println("This is a default method.");
    }
}

Java 8 引入的 static 方法无法在实现类中被覆盖,只能通过接口名直接调用(MyInterface.staticMethod()),类似于类中的静态方法。static 方法通常用于定义一些通用的、与接口相关的工具方法,一般很少用。

static
MyInterface.staticMethod()
static
public interface MyInterface {
    static void staticMethod() {
        System.out.println("This is a static method in the interface.");
    }
}
public interface MyInterface {
    static void staticMethod() {
        System.out.println("This is a static method in the interface.");
    }
}

Java 9 允许在接口中使用 private 方法。private 方法可以用于在接口内部共享代码,不对外暴露。

private
private
public interface MyInterface {
    // default 方法
    default void defaultMethod() {
        commonMethod();
    }

    // static 方法
    static void staticMethod() {
        commonMethod();
    }

    // 私有静态方法,可以被 static 和 default 方法调用
    private static void commonMethod() {
        System.out.println("This is a private method used internally.");
    }

      // 实例私有方法,只能被 default 方法调用。
    private void instanceCommonMethod() {
        System.out.println("This is a private instance method used internally.");
    }
}
public interface MyInterface {
    // default 方法
    default void defaultMethod() {
        commonMethod();
    }

    // static 方法
    static void staticMethod() {
        commonMethod();
    }

    // 私有静态方法,可以被 static 和 default 方法调用
    private static void commonMethod() {
        System.out.println("This is a private method used internally.");
    }

      // 实例私有方法,只能被 default 方法调用。
    private void instanceCommonMethod() {
        System.out.println("This is a private instance method used internally.");
    }
}

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

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

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

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

深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。

上面的结论没有完全理解的话也没关系,我们来看一个具体的案例!

浅拷贝

浅拷贝

浅拷贝的示例代码如下,我们这里实现了 Cloneable 接口,并重写了 clone() 方法。

Cloneable
clone()

clone() 方法的实现很简单,直接调用的是父类 Object 的 clone() 方法。

clone()
Object
clone()
public class Address implements Cloneable{
    private String name;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Address clone() {
        try {
            return (Address) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

public class Person implements Cloneable {
    private Address address;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Person clone() {
        try {
            Person person = (Person) super.clone();
            return person;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}
public class Address implements Cloneable{
    private String name;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Address clone() {
        try {
            return (Address) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

public class Person implements Cloneable {
    private Address address;
    // 省略构造函数、Getter&Setter方法
    @Override
    public Person clone() {
        try {
            Person person = (Person) super.clone();
            return person;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

测试:

Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());

从输出结构就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象。

person1
person1
Address

深拷贝

深拷贝

这里我们简单对 Person 类的 clone() 方法进行修改,连带着要把 Person 对象内部的 Address 对象一起复制。

Person
clone()
Person
Address
@Override
public Person clone() {