| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [View Raw Code] [Original HTTPS Page] |
| title | Java基础常见面试题总结(上) | |||||||
|---|---|---|---|---|---|---|---|---|
| category | Java | |||||||
| description | Java基础常见面试题总结:包含Java语言特点、JVM/JDK/JRE区别、字节码详解、基本数据类型、自动装箱拆箱、方法重载与重写等核心知识点,助力Java开发者面试通关。 | |||||||
| tag |
|
|||||||
| head |
|
🐛 修正(参见:issue#544):C++11 开始(2011 年的时候),C++ 就引入了多线程库,在 Windows、Linux、macOS 都可以使用 std::thread 和 std::async 来创建线程。参考链接:http://www.cplusplus.com/reference/thread/thread/?kw=thread
🌈 拓展一下:
“Write Once, Run Anywhere(一次编写,随处运行)”这句宣传口号,真心经典,流传了好多年!以至于,直到今天,依然有很多人觉得跨平台是 Java 语言最大的优势。实际上,跨平台已经不是 Java 最大的卖点了,各种 JDK 新特性也不是。目前市面上虚拟化技术已经非常成熟,比如你通过 Docker 就很容易实现跨平台了。在我看来,Java 强大的生态才是!
简单来说,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 无需重点关注,知道有这个东西就好了,现在已经用不上了。
Java 虚拟机(Java Virtual Machine, JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。
如下图所示,不同编程语言(Java、Groovy、Kotlin、JRuby、Clojure ...)通过各自的编译器编译成 .class 文件,并最终通过 JVM 在不同平台(Windows、Mac、Linux)上运行。
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 规范。
JDK(Java Development Kit)是一个功能齐全的 Java 开发工具包,供开发者使用,用于创建和编译 Java 程序。它包含了 JRE(Java Runtime Environment),以及编译器 javac 和其他工具,如 javadoc(文档生成器)、jdb(调试器)、jconsole(监控工具)、javap(反编译工具)等。
JRE 是运行已编译 Java 程序所需的环境,主要包含以下两个部分:
简单来说,JRE 只包含运行 Java 程序所需的环境和类库,而 JDK 不仅包含 JRE,还包括用于开发和调试 Java 程序的工具。
如果需要编写、编译 Java 程序或使用 JDK 附带的开发工具,就需要安装 JDK。某些会在运行时编译 Java 源码的应用程序(如 JSP 转换为 Servlet)也可能需要 JDK。Java 核心反射 API 属于运行时类库,使用反射本身并不要求安装完整 JDK。
下图清晰展示了 JDK、JRE 和 JVM 的关系。
不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ jlink 工具(随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块)。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。
在 Java 9 新特性概览这篇文章中,我在介绍模块化系统的时候提到:
在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具,创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。
也就是说,可以用 jlink 根据自己的需求,创建一个更小的 runtime(运行时),而不是不管什么应用,都是同样的 JRE。
定制的、模块化的 Java 运行时映像有助于简化 Java 应用的部署和节省内存并增强安全性和可维护性。这对于满足现代应用程序架构的需求,如虚拟化、容器化、微服务和云原生开发,是非常重要的。
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C、 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。
Java 程序从源代码到运行的过程如下图所示:
我们需要格外注意的是 .class->机器码 这一步。以 HotSpot 为例,JVM 加载字节码后可以先解释执行,并识别经常调用的方法和代码块(即热点代码),再由 JIT(Just in Time Compilation) 编译器将热点字节码编译为机器码。在当前 JVM 进程后续执行这些代码时,可以直接使用已编译的机器码。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。不过,JVM 规范并不要求具体实现必须包含解释器或 JIT 编译器。
🌈 拓展阅读:
HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。
JDK、JRE、JVM、JIT 这四者的关系如下图所示。
下面这张图是 JVM 的大致结构模型。
其实这个问题我们讲字节码的时候已经提到过,因为比较重要,所以我们这里再提一下。
我们可以将高级编程语言按照程序的执行方式分为两种:
根据维基百科介绍:
为了改善解释语言的效率而发展出的即时编译技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成字节码。到执行期时,再将字节码直译,之后执行。Java与LLVM是这种技术的代表产物。
为什么说 Java 语言“编译与解释并存”?
这是因为常见的 Java 实现同时使用编译和解释技术:Java 源码先由编译器生成字节码(.class 文件),字节码可由 JVM 解释执行,也可在运行时由 JIT 编译为机器码。字节码并非必须由解释器执行,具体执行策略由 JVM 实现决定。
JDK 9 曾通过 JEP 295 引入实验性的 AOT(Ahead of Time Compilation)工具 jaotc,但该工具已在 JDK 17 中移除。因此,JDK 17 及之后的标准 JDK 不再包含这套内置 AOT 编译器;下文讨论的是一般意义上的 AOT,以及 GraalVM Native Image 等独立工具链(Native Image 是 GraalVM 提供的一项 AOT 技术,后文会进一步介绍 GraalVM)。和 JIT 不同,AOT 会在程序执行前将代码编译为机器码,能够减少运行时预热开销并改善启动速度,但具体的内存占用、峰值性能和适用场景取决于所使用的 AOT 实现与应用负载。
下面的对比以常见的 HotSpot JIT 和 GraalVM Native Image 为例。不同 AOT 工具的实现方式并不完全相同,实际表现还会受到构建参数、应用负载,以及是否使用 PGO(Profile-Guided Optimization,即利用程序实际运行时收集的性能信息辅助优化)等因素影响。
| 对比维度 | JIT(即时编译) | AOT(提前编译) |
|---|---|---|
| 编译时机 | 运行时根据代码执行情况编译 | 构建阶段提前编译 |
| 启动与预热 | 启动后通常需要解释执行和编译热点代码 | 通常启动更快,不需要等待 JIT 预热 |
| 长期运行性能 | 可以利用运行时采集的信息持续优化热点代码 | 缺少完整的运行时信息,具体表现取决于实现和构建配置 |
| 运行时内存 | 需要保存编译器、性能数据和生成的机器码 | Native Image 等实现通常占用更少的运行时内存 |
| 运行依赖 | 需要 JVM 和相应运行时 | Native Image 可以生成独立的本地可执行文件 |
| 动态特性 | 支持运行时加载、反射和字节码生成 | 闭世界分析工具通常需要元数据或构建期处理 |
| 常见场景 | 长时间运行、重视持续吞吐量的服务 | CLI、Serverless、弹性扩缩容和冷启动敏感的服务 |
AOT 的优势主要体现在启动速度和运行时内存占用,比较适合冷启动频繁、实例生命周期较短或者需要快速扩容的应用。JIT 则能根据程序运行时收集到的信息优化热点代码,长时间运行的服务通常更容易发挥这方面的优势。二者的吞吐量和延迟表现不能只由编译方式直接下结论,还需要结合具体工具链和实际负载测试。
提到 AOT 就不得不提 GraalVM 了!GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。感兴趣的同学,可以去看看 GraalVM 的官方文档:https://www.graalvm.org/latest/docs/。如果觉得官方文档看着比较难理解的话,也可以找一些文章来看看,比如:
既然 AOT 这么多优点,那为什么不全部使用这种编译方式呢?
以 GraalVM Native Image 为例,它在构建本地可执行文件时会进行闭世界分析:构建器从程序入口出发,分析哪些类、方法和字段可能在运行时被访问,只把可达代码和必要的元数据放进最终产物。程序依旧可以接收动态输入和创建对象;构建阶段完全未知的代码则不会自动进入分析结果。
下面这段代码中的类名来自运行时参数,构建器无法仅靠静态调用关系确定要保留哪些类:
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 官方文档。
AOT 把一部分运行时工作和信息搬到了构建阶段,同时增加了构建时间、元数据维护和兼容性适配成本。对于依赖运行时动态加载、Java Agent 或大量动态字节码生成的应用,JIT 模式通常更省事;对于冷启动和内存占用敏感的应用,AOT 更有吸引力。
可能在看这个问题之前很多人和我一样并没有接触和使用过 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 的所有部分,除了我们考虑商业功能的部分。
最后,简单总结一下 Oracle JDK 和 OpenJDK 的区别:
既然 Oracle JDK 这么好,那为什么还要有 OpenJDK?
答:
- OpenJDK 是开源的,开源意味着你可以对它根据你自己的需要进行修改、优化,比如 Alibaba 基于 OpenJDK 开发了 Dragonwell8:https://github.com/alibaba/dragonwell8
- OpenJDK 是商业免费的(这也是为什么通过 yum 包管理器上默认安装的 JDK 是 OpenJDK 而不是 Oracle JDK)。虽然 Oracle JDK 也是商业免费(比如 JDK 8),但并不是所有版本都是免费的。
- OpenJDK 与 Oracle JDK 的特性版本都遵循六个月发布节奏;各发行版的更新与支持周期则可能不同。
基于以上这些原因,OpenJDK 还是有存在的必要的!
Oracle JDK 和 OpenJDK 如何选择?
建议选择 OpenJDK 或者基于 OpenJDK 的发行版,比如 AWS 的 Amazon Corretto,阿里巴巴的 Alibaba Dragonwell。
🌈 拓展一下:
我知道很多人没学过 C++,但是面试官就是没事喜欢拿咱们 Java 和 C++ 比呀!没办法!!!就算没学过 C++,也要记下来。
虽然,Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态,但是,它们还是有挺多不相同的地方:
Java 中的注释有三种:
单行注释:通常用于解释方法内某单行代码的作用。
多行注释:通常用于解释一段代码的作用。
文档注释:通常用于生成 Java 开发文档。
用的比较多的还是单行注释和文档注释,多行注释在实际开发中使用的相对较少。
在我们编写代码的时候,如果代码量比较少,我们自己或者团队其他成员还可以很轻易地看懂代码,但是当项目结构一旦复杂起来,我们就需要用到注释了。注释并不会执行(编译器在编译代码之前会把代码中的所有注释抹掉,字节码中不保留注释),是我们程序员写给自己看的,注释是你的代码说明书,能够帮助看代码的人快速地理清代码之间的逻辑关系。因此,在写程序的时候随手加上注释是一个非常好的习惯。
《Clean Code》这本书明确指出:
代码的注释不是越详细越好。实际上好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。
若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。
举个例子:
去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可
// check to see if the employee is eligible for full benefits if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))应替换为
if (employee.isEligibleForFullBenefits())
在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了 标识符。简单来说, 标识符就是一个名字。
有一些标识符,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 来编写默认匹配的情况。
- 在类,方法和变量修饰符中,从 JDK8 开始引入了默认方法,可以使用 default 关键字来定义一个方法的默认实现。
- 在访问控制中,如果一个方法前没有任何修饰符,则默认会有一个修饰符 default,但是这个修饰符加上了就会报错。
⚠️ 注意:虽然 true, false, 和 null 看起来像关键字但实际上他们是字面值,同时你也不可以作为标识符来使用。
官方文档:https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html
在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1。Java 提供了自增运算符 (++) 和自减运算符 (--) 来简化这种操作。
++ 和 -- 运算符可以放在变量之前,也可以放在变量之后:
为了方便记忆,可以使用下面的口诀:符号在前就先加/减,符号在后就后加/减。
flowchart LR
%% 定义全局样式
classDef step fill:#4CA497,color:#fff,rx:10,ry:10
classDef example fill:#E99151,color:#fff,rx:10,ry:10
subgraph Prefix["前缀形式 ++a / --a"]
direction TB
style Prefix fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
P1["第一步:变量自增/自减"]:::step --> P2["第二步:使用新值参与运算"]:::step
P3["示例:b = ++a<br先 a=a+1,再 b=a"]:::example
end
subgraph Suffix["后缀形式 a++ / a--"]
direction TB
style Suffix fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
S1["第一步:使用当前值参与运算"]:::step --> S2["第二步:变量自增/自减"]:::step
S3["示例:b = a++<br先 b=a,再 a=a+1"]:::example
end
linkStyle default stroke-width:1.5px,opacity:0.8
下面来看一个考察自增自减运算符的高频笔试题:执行下面的代码后,a、b、 c、d 和 e 的值是?
int a = 9;
int b = a++;
int c = ++a;
int d = c--;
int e = --d;答案:a = 11、b = 9、 c = 11、 d = 10、 e = 10。
移位运算符是最基本的运算符之一,几乎每种编程语言都包含这一运算符。移位操作中,被操作的数据被视为二进制数,移位就是将其向左或向右移动若干位的运算。
移位运算符在各种框架以及 JDK 自身的源码中使用还是挺广泛的,HashMap(JDK1.8) 中的 hash 方法的源码就用到了移位运算符:
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^:按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}使用移位运算符的主要原因:
移位运算符最常用于快速乘以或除以 2 的幂次方。除此之外,它还在以下方面发挥着重要作用:
掌握最基本的移位运算符知识还是很有必要的,这不光可以帮助我们在代码中使用,还可以帮助我们理解源码中涉及到移位运算符的代码。
flowchart TB
%% 定义全局样式,保持统一风格
classDef left fill:#4CA497,color:#fff,rx:10,ry:10
classDef right fill:#00838F,color:#fff,rx:10,ry:10
classDef uright fill:#E99151,color:#fff,rx:10,ry:10
subgraph ShiftOps["Java 三种移位运算符"]
direction TB
style ShiftOps fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
subgraph Left["左移 <<"]
style Left fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
L1["操作:向左移动 n 位"]:::left
L2["规则:高位丢弃,低位补 0"]:::left
L3["效果:相当于 × 2^n"]:::left
L4["示例:8 << 2 = 32"]:::left
end
subgraph Right["带符号右移 >>"]
style Right fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
R1["操作:向右移动 n 位"]:::right
R2["规则:低位丢弃,高位补符号位"]:::right
R3["效果:相当于 ÷ 2^n"]:::right
R4["示例:-8 >> 2 = -2"]:::right
end
subgraph URight["无符号右移 >>>"]
style URight fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
U1["操作:向右移动 n 位"]:::uright
U2["规则:低位丢弃,高位补 0"]:::uright
U3["效果:逻辑右移"]:::uright
U4["示例:-8 >>> 2 = 1073741822"]:::uright
end
end
linkStyle default stroke-width:1.5px,opacity:0.8
Java 中有三种移位运算符:
虽然移位运算本质上可以分为左移和右移,但在实际应用中,右移操作需要考虑符号位的处理方式。
由于 double,float 在二进制中的表现比较特殊,因此不能来进行移位操作。
移位操作符实际上支持的类型只有 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。
左移运算符代码示例:
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
由于左移位数大于等于 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));右移运算符使用类似,篇幅问题,这里就不做演示了。
在循环结构中,当循环条件不满足或者循环次数达到要求时,循环会正常结束。但是,有时候可能需要在循环的过程中,当发生了某种条件之后,提前终止循环,这就需要用到下面几个关键词:
return 用于跳出所在方法,结束该方法的运行。return 一般有两种用法:
flowchart TB
subgraph Method["方法体"]
direction TB
style Method fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
Start["方法开始"] --> Loop
subgraph Loop["循环体 for/while"]
direction TB
style Loop fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
L1["循环条件判断"] -->|"满足"| L2["执行循环体"]
L2 --> L3{{"遇到关键字?"}}
L3 -->|"continue"| Continue["跳过本次<br/>继续下一次循环"]
L3 -->|"break"| Break["跳出整个循环"]
L3 -->|"无"| L1
Continue --> L1
end
Break --> AfterLoop["循环后的代码"]
L1 -->|"不满足"| AfterLoop
AfterLoop --> L4{{"遇到 return?"}}
L4 -->|"是"| Return["结束整个方法"]
L4 -->|"否"| End["方法正常结束"]
end
classDef start fill:#E99151,color:#fff,rx:10,ry:10
classDef loop fill:#4CA497,color:#fff,rx:10,ry:10
classDef decision fill:#00838F,color:#fff,rx:10,ry:10
classDef alert fill:#C44545,color:#fff,rx:10,ry:10
class Start,End start
class L1,L2,AfterLoop loop
class L3,L4 decision
class Continue,Break,Return alert
linkStyle default stroke-width:1.5px,opacity:0.8
思考一下:下列语句的运行结果是什么?
public static void main(String[] args) {
boolean flag = false;
for (int i = 0; i <= 3; i++) {
if (i == 0) {
System.out.println("0");
} else if (i == 1) {
System.out.println("1");
continue;
} else if (i == 2) {
System.out.println("2");
flag = true;
} else if (i == 3) {
System.out.println("3");
break;
} else if (i == 4) {
System.out.println("4");
}
System.out.println("xixi");
}
if (flag) {
System.out.println("haha");
return;
}
System.out.println("heihei");
}运行结果:
0
xixi
1
2
xixi
3
haha
Java 中有 8 种基本数据类型,分别为:
flowchart TB
Root["Java 8种基本数据类型"] --> Numeric["数字类型(6种)"]
Root --> Char["字符类型"]
Root --> Bool["布尔类型"]
Numeric --> IntType["整数型(4种)"]
Numeric --> FloatType["浮点型(2种)"]
IntType --> byte["byte<br/>8位"]
IntType --> short["short<br/>16位"]
IntType --> int["int<br/>32位"]
IntType --> long["long<br/>64位"]
FloatType --> float["float<br/>32位"]
FloatType --> double["double<br/>64位"]
Char --> char["char<br/>16位"]
Bool --> boolean["boolean<br/>存储大小未规定"]
classDef root fill:#E99151,color:#fff,rx:10,ry:10
classDef category fill:#00838F,color:#fff,rx:10,ry:10
classDef type fill:#4CA497,color:#fff,rx:10,ry:10
class Root root
class Numeric,Char,Bool,IntType,FloatType category
class byte,short,int,long,float,double,char,boolean type
linkStyle default stroke-width:1.5px,opacity:0.8
这 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 | 0L | -9223372036854775808(-2^63) ~ 9223372036854775807(2^63 -1) |
| char | 16 | 2 | '\u0000' | 0 ~ 65535(2^16 - 1) |
| float | 32 | 4 | 0f | 约 -3.4028235E38 ~ 3.4028235E38,最小正非零值约 1.4E-45,另含 ±0、±∞、NaN |
| double | 64 | 8 | 0d | 约 -1.7976931348623157E308 ~ 1.7976931348623157E308,最小正非零值约 4.9E-324,另含 ±0、±∞、NaN |
| boolean | 未规定 | 未规定 | false | true、false |
可以看到,像 byte、short、int、long 能表示的最大正数都减 1 了。这是为什么呢?这是因为在二进制补码表示法中,最高位是用来表示符号的(0 表示正数,1 表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为 1。如果我们再加 1,就会导致溢出,变成一个负数。
对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。
另外,Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一(《Java 编程思想》2.2 节有提到)。
注意:
这八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean。
为什么说对象实例通常存在于堆中呢? 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 修饰局部变量
}
}Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 TRUE or FALSE。
对于 Integer,可以通过 JVM 参数 -XX:AutoBoxCacheMax=<size> 修改缓存上限,但不能修改下限 -128。实际使用时,并不建议设置过大的值,避免浪费内存,甚至是 OOM。
对于 Byte,Short,Long ,Character 没有类似 -XX:AutoBoxCacheMax 参数可以修改,因此缓存范围是固定的,无法通过 JVM 参数调整。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;
}
}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);
}
}Boolean 缓存源码:
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。
两种浮点数类型的包装类 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下面我们来看一个问题:下面的代码的输出结果是 true 还是 false 呢?
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 方法比较。
什么是自动拆装箱?
flowchart LR
subgraph Row["装箱与拆箱对比"]
direction LR
style Row fill:#F0F2F5,stroke:#E0E6ED,stroke-width:1.5px
subgraph Unboxing["拆箱过程"]
direction LR
style Unboxing fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
D["Integer obj"] -->|"自动拆箱"| E["obj.intValue()"]
E --> F["int 基本类型"]
end
subgraph Boxing["装箱过程"]
direction LR
style Boxing fill:#F5F7FA,stroke:#E0E6ED,stroke-width:1.5px
A["int i = 10"] -->|"自动装箱"| B["Integer.valueOf(10)"]
B --> C["Integer 对象"]
end
end
classDef core fill:#4CA497,color:#fff,rx:10,ry:10
classDef highlight fill:#E99151,color:#fff,rx:10,ry:10
class A,D core
class C,F highlight
linkStyle default stroke-width:1.5px,opacity:0.8
举例:
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从字节码中,我们发现装箱其实就是调用了 包装类的 valueOf() 方法,拆箱其实就是调用了 xxxValue() 方法。
因此,
注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。
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 和 double,许多十进制小数转换为二进制后会无限循环,只能舍入为有限位数,因此存在精度损失的风险。不过,像 0.5、0.25 这样能够表示为有限二进制小数的值可以被精确表示。
就比如说十进制下的 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(发生循环)
...关于浮点数的更多内容,建议看一下计算机系统基础(四)浮点数这篇文章。
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 的详细介绍,可以看看我写的这篇文章:BigDecimal 详解。
基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。
在 Java 中,64 位 long 整型是最大的整数类型。
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // trueBigInteger 内部使用 int[] 数组来存储任意大小的整形数据。
相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。
为什么成员变量有默认值?
JLS 规定,类变量、实例变量和数组元素在创建时会被初始化为各自类型的默认值,例如数值类型为 0、boolean 为 false、引用类型为 null。局部变量不进行默认初始化,并受“明确赋值”(definite assignment)规则约束:在读取局部变量前,编译器必须能够确定它已经被赋值。这里是语言规范直接规定的两套初始化规则,并不是因为编译器无法预测成员变量何时赋值。
成员变量与局部变量代码示例:
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);
}
}静态变量也就是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
静态变量是通过类名来访问的,例如 StaticVariableExample.staticVar(如果被 private 关键字修饰就无法这样访问了)。
public class StaticVariableExample {
// 静态变量
public static int staticVar = 0;
}通常情况下,静态变量会被 final 关键字修饰成为常量。
public class ConstantVariableExample {
// 常量
public static final int constantVar = 0;
}⚠️ 注意 char 在 Java 中占两个字节。
字符型常量和字符串常量代码示例:
public class StringExample {
// 字符型常量
public static final char LETTER_A = 'A';
// 字符串常量
public static final String GREETING_MESSAGE = "Hello, world!";
public static void main(String[] args) {
System.out.println("字符型常量占用的字节数为:"+Character.BYTES);
System.out.println("字符串使用 UTF-8 编码后的字节数为:"+GREETING_MESSAGE.getBytes(java.nio.charset.StandardCharsets.UTF_8).length);
}
}输出:
字符型常量占用的字节数为:2
字符串使用 UTF-8 编码后的字节数为:13
方法的返回值 是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能产生结果)。返回值的作用是接收出结果,使得它可以用于其他的操作!
我们可以按照方法的返回值和参数类型将方法分为下面这几种:
1、无参数无返回值的方法
public void f1() {
//......
}
// 下面这个方法也没有返回值,虽然用到了 return
public void f(int a) {
if (...) {
// 表示结束方法的执行,下方的输出语句不会执行
return;
}
System.out.println(a);
}2、有参数无返回值的方法
public void f2(Parameter 1, ..., Parameter n) {
//......
}3、有返回值无参数的方法
public int f3() {
//......
return x;
}4、有返回值有参数的方法
public int f4(int a, int b) {
return a * b;
}静态方法在静态上下文中执行,没有隐式的当前实例 this,因此不能直接访问实例成员。静态方法仍然可以通过一个显式的对象引用访问该对象的实例成员,这与类加载或成员是否已经“分配内存”无关。
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、调用方式
在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象.方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。
不过,需要注意的是一般不建议使用 对象.方法名 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。
因此,一般建议使用 类名.方法名 的方式来调用静态方法。
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 允许重载任何方法, 而不只是构造器方法。
综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
重写是子类实例方法与父类可访问实例方法之间的声明关系,由编译器按规则检查;运行期发生的是对重写方法的动态分派。
综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。
| 区别点 | 重载 (Overloading) | 重写 (Overriding) |
|---|---|---|
| 发生范围 | 同一个类中。 | 父类与子类之间(存在继承关系)。 |
| 方法签名 | 方法名必须相同,但参数列表必须不同(参数的类型、个数或顺序至少有一项不同)。 | 方法名、参数列表必须完全相同。 |
| 返回类型 | 与返回值类型无关,可以任意修改。 | 子类方法的返回类型必须与父类方法的返回类型相同,或者是其子类。 |
| 访问修饰符 | 与访问修饰符无关,可以任意修改。 | 子类方法的访问权限不能低于父类方法的访问权限。(public > protected > default > private) |
| 绑定时期 | 编译时绑定或称静态绑定 | 运行时绑定 (Run-time Binding) 或称动态绑定 |
方法的重写要遵循“两同两小一大”(以下内容摘录自《疯狂 Java 讲义》,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();
}
}从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面这个方法就可以接受 0 个或者多个参数。
public static void method1(String... args) {
//......
}另外,可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。
public static void method2(String arg1, String... args) {
//......
}遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?
答案是会优先匹配固定参数的方法,因为固定参数的方法匹配度更高。
我们通过下面这个例子来证明一下。
/**
* 微信搜 JavaGuide 回复"面试突击"即可免费领取个人原创的 Java 面试手册
*
* @author Guide哥
* @date 2021/12/13 16:52
**/
public class VariableLengthArgument {
public static void printVariable(String... args) {
for (String s : args) {
System.out.println(s);
}
}
public static void printVariable(String arg1, String arg2) {
System.out.println(arg1 + arg2);
}
public static void main(String[] args) {
printVariable("a", "b");
printVariable("a", "b", "c", "d");
}
}输出:
ab
a
b
c
d
另外,Java 的可变参数编译后实际会被转换成一个数组,我们看编译后生成的 class 文件就可以看出来了。
public class VariableLengthArgument {
public static void printVariable(String... args) {
String[] var1 = args;
int var2 = args.length;
for(int var3 = 0; var3 < var2; ++var3) {
String s = var1[var3];
System.out.println(s);
}
}
// ......
}| Back | FazBrowse Home | New Git URL |