From 4e06d124ca11c0de0c868b1a2aabbd17831f7ecc Mon Sep 17 00:00:00 2001 From: guide Date: 2022年8月12日 09:08:03 +0800 Subject: [PATCH] =?UTF-8?q?[docs=20update]java=E9=9B=86=E5=90=88=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=86=85=E5=AE=B9=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _sidebar.md | 4 +- "docs/a-0345円277円205円347円234円213円.md" | 4 +- "docs/b-1Java345円237円272円347円241円200円.md" | 1622 +++++++++++++++++ ...7273円223円-Java345円237円272円347円241円200円.md" | 723 -------- "docs/b-2Java351円233円206円345円220円210円.md" | 622 +++++-- ...va345円244円232円347円272円277円347円250円213円.md" | 996 ---------- docs/b-4jvm.md | 16 + 7 files changed, 2121 insertions(+), 1866 deletions(-) create mode 100755 "docs/b-1Java345円237円272円347円241円200円.md" delete mode 100755 "docs/b-1351円235円242円350円257円225円351円242円230円346円200円273円347円273円223円-Java345円237円272円347円241円200円.md" diff --git a/_sidebar.md b/_sidebar.md index 0f4ea16..1947527 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -8,10 +8,10 @@ - [优质面经汇总(付费)](./docs/a-5优质面经汇总(付费).md) - [项目经验指南](./docs/a-6项目经验指南.md) * Java - * [Java基础](./docs/b-1面试题总结-Java基础.md) + * [Java基础](./docs/b-1Java基础.md) * [Java集合](./docs/b-2Java集合.md) * [Java多线程](./docs/b-3Java多线程.md) - * [JVM](./docs/b-4jvm.md) + * [JVM](./docs/b-4JVM.md) * 计算机基础 * [计算机网络](./docs/c-1计算机网络.md) * [数据结构](./docs/c-2数据结构.md) diff --git "a/docs/a-0345円277円205円347円234円213円.md" "b/docs/a-0345円277円205円347円234円213円.md" index dafe272..fdeb06c 100644 --- "a/docs/a-0345円277円205円347円234円213円.md" +++ "b/docs/a-0345円277円205円347円234円213円.md" @@ -118,7 +118,7 @@ JavaGuide 目前已经 126k+ Star ,目前已经是所有 Java 类别项目中 - [x] 增加了18 道最常见的 Spring Boot 面试题。不过,这部分内容的答案更新在了[知识星球](http://mp.weixin.qq.com/s?__biz=Mzg2OTA0Njk0OA==&mid=100013795&idx=1&sn=aa2db4799c432bb944b6786ae0ec4c56&chksm=4ea1b92879d6303e9077546e2bc42a78f0cd3e18d9adb06e9f15e49e3d8337ec4bd384a25367#rd)。 - [x] 优质面经部分增加了两篇读者面经:双非本科、0实习、0比赛/项目经历。3个月上岸百度、华为|字节|腾讯|京东|网易|滴滴面经分享(6个offer) - +V5.0—2022年8月25日 - +全新版本,内容重构完善! diff --git "a/docs/b-1Java345円237円272円347円241円200円.md" "b/docs/b-1Java345円237円272円347円241円200円.md" new file mode 100755 index 0000000..b0becc5 --- /dev/null +++ "b/docs/b-1Java345円237円272円347円241円200円.md" @@ -0,0 +1,1622 @@ +------ + + + +# 2. Java + +> [JavaGuide](https://javaguide.cn/) :「Java学习+面试指南」一份涵盖大部分 Java 程序员所需要掌握的核心知识。准备 Java 面试,首选 JavaGuide! + +![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7965efc059b841aeb377e3b3e1e1005c~tplv-k3u1fbpfcp-zoom-1.image) + +## 2.1. Java 基础 + +> 这部分内容摘自 [JavaGuide](https://javaguide.cn/) 下面几篇文章: +> +> - [Java基础常见面试题总结(上)](https://javaguide.cn/java/basis/java-basic-questions-01.html) +> - [Java基础常见面试题总结(中)](https://javaguide.cn/java/basis/java-basic-questions-02.html) +> - [Java基础常见面试题总结(下)](https://javaguide.cn/java/basis/java-basic-questions-03.html) + +### JVM vs JDK vs JRE + +#### JVM + +Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言"一次编译,随处可以运行"的关键所在。 + +**JVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。** 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。 + +除了我们平时最常用的 HotSpot VM 外,还有 J9 VM、Zing VM、JRockit VM 等 JVM 。维基百科上就有常见 JVM 的对比:[Comparison of Java virtual machines](https://en.wikipedia.org/wiki/Comparison_of_Java_virtual_machines) ,感兴趣的可以去看看。并且,你可以在 [Java SE Specifications](https://docs.oracle.com/javase/specs/index.html) 上找到各个版本的 JDK 对应的 JVM 规范。 + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/JavaSeSpecifications.jpg) + +#### JDK 和 JRE + +JDK 是 Java Development Kit 缩写,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。 + +JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。 + +如果你只是为了运行一下 Java 程序的话,那么你只需要安装 JRE 就可以了。如果你需要进行一些 Java 编程方面的工作,那么你就需要安装 JDK 了。但是,这不是绝对的。有时,即使您不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,您只是在应用程序服务器中运行 Java 程序。那你为什么需要 JDK 呢?因为应用程序服务器会将 JSP 转换为 Java servlet,并且需要使用 JDK 来编译 servlet。 + +### 什么是字节码?采用字节码的好处是什么? + +在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 `.class` 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。 + +**Java 程序从源代码到运行的过程如下图所示:** + +![Java程序转变为机器代码的过程](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/java-code-to-machine-code.jpg) + +我们需要格外注意的是 `.class->机器码` 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT(just-in-time compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 **Java 是编译与解释共存的语言** 。 + +> HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation),它是直接将字节码编译成机器码,这样就避免了 JIT 预热等各方面的开销。JDK 支持分层编译和 AOT 协作使用。 + +### 为什么不全部使用 AOT 呢? + +AOT 可以提前编译节省启动时间,那为什么不全部使用这种编译方式呢? + +长话短说,这和 Java 语言的动态特性有千丝万缕的联系了。举个例子,CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 `.class` 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。 + +### 为什么说 Java 语言"编译与解释并存"? + +其实这个问题我们讲字节码的时候已经提到过,因为比较重要,所以我们这里再提一下。 + +我们可以将高级编程语言按照程序的执行方式分为两种: + +- **编译型** :[编译型语言](https://zh.wikipedia.org/wiki/%E7%B7%A8%E8%AD%AF%E8%AA%9E%E8%A8%80) 会通过[编译器](https://zh.wikipedia.org/wiki/%E7%B7%A8%E8%AD%AF%E5%99%A8)将源代码一次性翻译成可被该平台执行的机器码。一般情况下,编译语言的执行速度比较快,开发效率比较低。常见的编译性语言有 C、C++、Go、Rust 等等。 +- **解释型** :[解释型语言](https://zh.wikipedia.org/wiki/%E7%9B%B4%E8%AD%AF%E8%AA%9E%E8%A8%80)会通过[解释器](https://zh.wikipedia.org/wiki/直譯器)一句一句的将代码解释(interpret)为机器代码后再执行。解释型语言开发效率比较快,执行速度比较慢。常见的解释性语言有 Python、JavaScript、PHP 等等。 + +![编译型语言和解释型语言](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/compiled-and-interpreted-languages.jpg) + +根据维基百科介绍: + +> 为了改善编译语言的效率而发展出的[即时编译](https://zh.wikipedia.org/wiki/即時編譯)技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成[字节码](https://zh.wikipedia.org/wiki/字节码)。到执行期时,再将字节码直译,之后执行。[Java](https://zh.wikipedia.org/wiki/Java)与[LLVM](https://zh.wikipedia.org/wiki/LLVM)是这种技术的代表产物。 +> +> 相关阅读:[基本功 | Java 即时编译器原理解析及实践](https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html) + +**为什么说 Java 语言"编译与解释并存"?** + +这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(`.class` 文件),这种字节码必须由 Java 解释器来解释执行。 + +### Oracle JDK vs OpenJDK + +可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK 。那么 Oracle JDK 和 OpenJDK 之间是否存在重大差异?下面我通过收集到的一些资料,为你解答这个被很多人忽视的问题。 + +对于 Java 7,没什么关键的地方。OpenJDK 项目主要基于 Sun 捐赠的 HotSpot 源代码。此外,OpenJDK 被选为 Java 7 的参考实现,由 Oracle 工程师维护。关于 JVM,JDK,JRE 和 OpenJDK 之间的区别,Oracle 博客帖子在 2012 年有一个更详细的答案: + +> 问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别? +> +> 答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些闭源的第三方组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。 + +**总结:**(提示:下面括号内的内容是基于原文补充说明的,因为原文太过于晦涩难懂,用人话重新解释了下,如果你看得懂里面的术语,可以忽略括号解释的内容) + +1. Oracle JDK 大概每 6 个月发一次主要版本(从 2014 年 3 月 JDK 8 LTS 发布到 2017 年 9 月 JDK 9 发布经历了长达 3 年多的时间,所以并不总是 6 个月),而 OpenJDK 版本大概每三个月发布一次。但这不是固定的,我觉得了解这个没啥用处。详情参见:[https://blogs.oracle.com/java-platform-group/update-and-faq-on-the-java-se-release-cadence](https://blogs.oracle.com/java-platform-group/update-and-faq-on-the-java-se-release-cadence) 。 + +2. 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) + +3. Oracle JDK 比 OpenJDK 更稳定(肯定啦,Oracle JDK 由 Oracle 内部团队进行单独研发的,而且发布时间比 OpenJDK 更长,质量更有保障)。OpenJDK 和 Oracle JDK 的代码几乎相同(OpenJDK 的代码是从 Oracle JDK 代码派生出来的,可以理解为在 Oracle JDK 分支上拉了一条新的分支叫 OpenJDK,所以大部分代码相同),但 Oracle JDK 有更多的类和一些错误修复。因此,如果您想开发企业/商业软件,我建议您选择 Oracle JDK,因为它经过了彻底的测试和稳定。某些情况下,有些人提到在使用 OpenJDK 可能会遇到了许多应用程序崩溃的问题,但是,只需切换到 Oracle JDK 就可以解决问题; + +4. 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的性能; + +5. Oracle JDK 不会为即将发布的版本提供长期支持(如果是 LTS 长期支持版本的话也会,比如 JDK 8,但并不是每个版本都是 LTS 版本),用户每次都必须通过更新到最新版本获得支持来获取最新版本; + +6. Oracle JDK 使用 BCL/OTN 协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。 + +> 既然 Oracle JDK 这么好,那为什么还要有 OpenJDK? +> +> 答: +> +> 1. OpenJDK 是开源的,开源意味着你可以对它根据你自己的需要进行修改、优化,比如 Alibaba 基于 OpenJDK 开发了 Dragonwell8:[https://github.com/alibaba/dragonwell8](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](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/oracle-jdk-release-cadence.jpg) + +🌈 拓展一下: + +- BCL 协议(Oracle Binary Code License Agreement): 可以使用 JDK(支持商用),但是不能进行修改。 +- OTN 协议(Oracle Technology Network License Agreement): 11 及之后新发布的 JDK 用的都是这个协议,可以自己私下用,但是商用需要付费。 + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/java-guide-blog/20210425151835918.png) + +相关阅读 👍:[《Differences Between Oracle JDK and OpenJDK》](https://www.baeldung.com/oracle-jdk-vs-openjdk) + +### 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](https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html) + +### 自增自减运算符 + +在写代码的过程中,常见的一种情况是需要某个整数类型变量增加 1 或减少 1,Java 提供了一种特殊的运算符,用于这种表达式,叫做自增运算符(++)和自减运算符(--)。 + +++ 和 -- 运算符可以放在变量之前,也可以放在变量之后,当运算符放在变量之前时(前缀),先自增/减,再赋值;当运算符放在变量之后时(后缀),先赋值,再自增/减。例如,当 `b = ++a` 时,先自增(自己增加 1),再赋值(赋值给 b);当 `b = a++` 时,先赋值(赋值给 b),再自增(自己增加 1)。也就是,++a 输出的是 a+1 的值,a++输出的是 a 值。用一句口诀就是:"符号在前就先加/减,符号在后就后加/减"。 + +### 成员变量与局部变量的区别? + +- **语法形式** :从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 `public`,`private`,`static` 等修饰符所修饰,而局部变量不能被访问控制修饰符及 `static` 所修饰;但是,成员变量和局部变量都能被 `final` 所修饰。 +- **存储方式** :从变量在内存中的存储方式来看,如果成员变量是使用 `static` 修饰的,那么这个成员变量是属于类的,如果没有使用 `static` 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。 +- **生存时间** :从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。 +- **默认值** :从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 `final` 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。 + +### 静态变量有什么作用? + +静态变量可以被类的所有实例共享。无论一个类创建了多少个对象,它们都共享同一份静态变量。 + +通常情况下,静态变量会被 `final` 关键字修饰成为常量。 + +### 字符型常量和字符串常量的区别? + +1. **形式** : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。 +2. **含义** : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。 +3. **占内存大小** : 字符常量只占 2 个字节; 字符串常量占若干个字节。 + +(**注意: `char` 在 Java 中占两个字节**) + +### 静态方法和实例方法有何不同? + +**1、调用方式** + +在外部调用静态方法时,可以使用 `类名.方法名` 的方式,也可以使用 `对象.方法名` 的方式,而实例方法只有后面这种方式。也就是说,**调用静态方法可以无需创建对象** 。 + +不过,需要注意的是一般不建议使用 `对象.方法名` 的方式来调用静态方法。这种方式非常容易造成混淆,静态方法不属于类的某个对象而是属于这个类。 + +因此,一般建议使用 `类名.方法名` 的方式来调用静态方法。 + +```java +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` 的构造方法)有相同的名字、不同的参数, 便产生了重载。 +> +> ```java +> StringBuilder sb = new StringBuilder(); +> StringBuilder sb2 = new StringBuilder("HelloWorld"); +> ``` +> +> 编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。 如果编译器找不到匹配的参数, 就会产生编译时错误, 因为根本不存在匹配, 或者没有一个比其他的更好(这个过程被称为重载解析(overloading resolution))。 +> +> Java 允许重载任何方法, 而不只是构造器方法。 + +综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。 + +**重写** + +重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。 + +1. 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。 +2. 如果父类方法访问修饰符为 `private/final/static` 则子类就不能重写该方法,但是被 `static` 修饰的方法能够被再次声明。 +3. 构造方法无法被重写 + +综上:**重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。** + +| 区别点 | 重载方法 | 重写方法 | +| :--------- | :------- | :----------------------------------------------------------- | +| 发生范围 | 同一个类 | 子类 | +| 参数列表 | 必须修改 | 一定不能修改 | +| 返回类型 | 可修改 | 子类方法返回值类型应比父类方法返回值类型更小或相等 | +| 异常 | 可修改 | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; | +| 访问修饰符 | 可修改 | 一定不能做更严格的限制(可以降低限制) | +| 发生阶段 | 编译期 | 运行期 | + +**方法的重写要遵循"两同两小一大"**(以下内容摘录自《疯狂 Java 讲义》,[issue#892](https://github.com/Snailclimb/JavaGuide/issues/892) ): + +- "两同"即方法名相同、形参列表相同; +- "两小"指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; +- "一大"指的是子类方法的访问权限应比父类方法的访问权限更大或相等。 + +⭐️ 关于 **重写的返回值类型** 这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。 + +```java +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 { + public String name() { + return "超级超级英雄"; + } + + @Override + public SuperMan hero() { + return new SuperMan(); + } +} +``` + +### 什么是可变长参数? + +从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面的这个 `printVariable` 方法就可以接受 0 个或者多个参数。 + +```java +public static void method1(String... args) { + //...... +} +``` + +另外,可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。 + +```java +public static void method2(String arg1, String... args) { + //...... +} +``` + +**遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?** + +答案是会优先匹配固定参数的方法,因为固定参数的方法匹配度更高。 + +我们通过下面这个例子来证明一下。 + +```java +/** + * 微信搜 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`文件就可以看出来了。 + +```java +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); + } + + } + // ...... +} +``` + +### Java 中的几种基本数据类型了解么? + +Java 中有 8 种基本数据类型,分别为: + +- 6 种数字类型: + - 4 种整数型:`byte`、`short`、`int`、`long` + - 2 种浮点型:`float`、`double` +- 1 种字符类型:`char` +- 1 种布尔型:`boolean`。 + +这 8 种基本数据类型的默认值以及所占空间的大小如下: + +| 基本类型 | 位数 | 字节 | 默认值 | 取值范围 | +| :-------- | :--- | :--- | :------ | ------------------------------------------ | +| `byte` | 8 | 1 | 0 | -128 ~ 127 | +| `short` | 16 | 2 | 0 | -32768 ~ 32767 | +| `int` | 32 | 4 | 0 | -2147483648 ~ 2147483647 | +| `long` | 64 | 8 | 0L | -9223372036854775808 ~ 9223372036854775807 | +| `char` | 16 | 2 | 'u0000' | 0 ~ 65535 | +| `float` | 32 | 4 | 0f | 1.4E-45 ~ 3.4028235E38 | +| `double` | 64 | 8 | 0d | 4.9E-324 ~ 1.7976931348623157E308 | +| `boolean` | 1 | | false | true、false | + +对于 `boolean`,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。 + +另外,Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一(《Java 编程思想》2.2 节有提到)。 + +**注意:** + +1. Java 里使用 `long` 类型的数据一定要在数值后面加上 **L**,否则将作为整型解析。 +2. `char a = 'h'`char :单引号,`String a = "hello"` :双引号。 + +这八种基本类型都有对应的包装类分别为:`Byte`、`Short`、`Integer`、`Long`、`Float`、`Double`、`Character`、`Boolean` 。 + +### 基本类型和包装类型的区别? + +- 成员变量包装类型不赋值就是 `null` ,而基本类型有默认值且不是 `null`。 +- 包装类型可用于泛型,而基本类型不可以。 +- 基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 `static` 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。 +- 相比于对象类型, 基本数据类型占用的空间非常小。 + +**为什么说是几乎所有对象实例呢?** 这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存 + +⚠️ 注意 : **基本数据类型存放在栈中是一个常见的误区!** 基本数据类型的成员变量如果没有被 `static` 修饰的话(不建议这么使用,应该要使用基本数据类型对应的包装类型),就存放在堆中。 + +```java +class BasicTypeVar{ + private int x; +} +``` + +### 包装类型的缓存机制了解么? + +Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。 + +`Byte`,`Short`,`Integer`,`Long` 这 4 种包装类默认创建了数值 **[-128,127]** 的相应类型的缓存数据,`Character` 创建了数值在 **[0,127]** 范围的缓存数据,`Boolean` 直接返回 `True` or `False`。 + +**Integer 缓存源码:** + +```java +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` 缓存源码:** + +```java +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` 缓存源码:** + +```java +public static Boolean valueOf(boolean b) { + return (b ? TRUE : FALSE); +} +``` + +如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。 + +两种浮点数类型的包装类 `Float`,`Double` 并没有实现缓存机制。 + +```java +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` 呢? + +```java +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 方法比较**。 + +![](https://img-blog.csdnimg.cn/20210422164544846.png) + +### 自动装箱与拆箱了解吗?原理是什么? + +**什么是自动拆装箱?** + +- **装箱**:将基本类型用它们对应的引用类型包装起来; +- **拆箱**:将包装类型转换为基本数据类型; + +举例: + +```java +Integer i = 10; //装箱 +int n = i; //拆箱 +``` + +上面这两行代码对应的字节码为: + +```java + 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()`方法。 + +因此, + +- `Integer i = 10` 等价于 `Integer i = Integer.valueOf(10)` +- `int n = i` 等价于 `int n = i.intValue()`; + +注意:**如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。** + +```java +private static long sum() { + // 应该使用 long 而不是 Long + Long sum = 0L; + for (long i = 0; i <= Integer.MAX_VALUE; i++) + sum += i; + return sum; +} +``` + +### 为什么浮点数运算的时候会有精度丢失的风险? + +浮点数运算精度丢失代码演示: + +```java +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 就没办法精确转换成二进制小数: + +```java +// 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(发生循环) +... +``` + +关于浮点数的更多内容,建议看一下[计算机系统基础(四)浮点数](http://kaito-kidd.com/2018/08/08/computer-system-float-point/)这篇文章。 + +### 如何解决浮点数运算的精度丢失问题? + +`BigDecimal` 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 `BigDecimal` 来做的。 + +```java +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 详解](https://javaguide.cn/java/basis/bigdecimal.html)。 + +### 超过 long 整型的数据应该如何表示? + +基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。 + +在 Java 中,64 位 long 整型是最大的整数类型。 + +```java +long l = Long.MAX_VALUE; +System.out.println(l + 1); // -9223372036854775808 +System.out.println(l + 1 == Long.MIN_VALUE); // true +``` + +`BigInteger` 内部使用 `int[]` 数组来存储任意大小的整形数据。 + +相对于常规整数类型的运算来说,`BigInteger` 运算的效率会相对较低。 + +### 如果一个类没有声明构造方法,该程序能正确执行吗? + +如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会再添加默认的无参数的构造方法了,我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。 + +### 构造方法有哪些特点?是否可被 override? + +构造方法特点如下: + +- 名字与类名相同。 +- 没有返回值,但不能用 void 声明构造函数。 +- 生成类的对象时自动执行,无需调用。 + +构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。 + +### 面向对象三大特征 + +#### 封装 + +封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了(当然现在还有很多其他方法 ,这里只是为了举例子)。 + +```java +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,可在子类中被重新定义,也可被重新赋值。 + +### 深拷贝和浅拷贝区别了解吗?什么是引用拷贝? + +关于深拷贝和浅拷贝区别,我这里先给结论: + +- **浅拷贝**:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。 +- **深拷贝** :深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。 + +上面的结论没有完全理解的话也没关系,我们来看一个具体的案例! + +**浅拷贝** + +浅拷贝的示例代码如下,我们这里实现了 `Cloneable` 接口,并重写了 `clone()` 方法。 + +`clone()` 方法的实现很简单,直接调用的是父类 `Object` 的 `clone()` 方法。 + +```java +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(); + } + } +} +``` + +测试 : + +```java +Person person1 = new Person(new Address("武汉")); +Person person1Copy = person1.clone(); +// true +System.out.println(person1.getAddress() == person1Copy.getAddress()); +``` + +从输出结构就可以看出, `person1` 的克隆对象和 `person1` 使用的仍然是同一个 `Address` 对象。 + +**深拷贝** + +这里我们简单对 `Person` 类的 `clone()` 方法进行修改,连带着要把 `Person` 对象内部的 `Address` 对象一起复制。 + +```java +@Override +public Person clone() { + try { + Person person = (Person) super.clone(); + person.setAddress(person.getAddress().clone()); + return person; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } +} +``` + +测试 : + +```java +Person person1 = new Person(new Address("武汉")); +Person person1Copy = person1.clone(); +// false +System.out.println(person1.getAddress() == person1Copy.getAddress()); +``` + +从输出结构就可以看出,虽然 `person1` 的克隆对象和 `person1` 包含的 `Address` 对象已经是不同的了。 + +**那什么是引用拷贝呢?** 简单来说,引用拷贝就是两个不同的引用指向同一个对象。 + +我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝: + +![浅拷贝、深拷贝、引用拷贝示意图](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/shallow&deep-copy.jpg) + +### Object + +#### Object 类的常见方法有哪些? + +Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法: + +```java +/** + * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。 + */ +public final native Class getClass() +/** + * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。 + */ +public native int hashCode() +/** + * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。 + */ +public boolean equals(Object obj) +/** + * naitive 方法,用于创建并返回当前对象的一份拷贝。 + */ +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()`方法。 + +`Object` 类 `equals()` 方法: + +```java +public boolean equals(Object obj) { + return (this == obj); +} +``` + +`equals()` 方法存在两种使用情况: + +- **类没有重写 `equals()`方法** :通过`equals()`比较该类的两个对象时,等价于通过"=="比较这两个对象,使用的默认是 `Object`类`equals()`方法。 +- **类重写了 `equals()`方法** :一般我们都重写 `equals()`方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。 + +举个例子(这里只是为了举例。实际上,你按照下面这种写法的话,像 IDEA 这种比较智能的 IDE 都会提示你将 `==` 换成 `equals()` ): + +```java +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` 方法是被重写过的,因为 `Object` 的 `equals` 方法是比较的对象的内存地址,而 `String` 的 `equals` 方法比较的是对象的值。 + +当创建 `String` 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 `String` 对象。 + +`String`类`equals()`方法: + +```java +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 v1[] = value; + char v2[] = anotherString.value; + int i = 0; + while (n-- != 0) { + if (v1[i] != v2[i]) + return false; + i++; + } + return true; + } + } + return false; +} +``` + +#### hashCode() 有什么用? + +`hashCode()` 的作用是获取哈希码(`int` 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。 + +`hashCode()`定义在 JDK 的 `Object` 类中,这就意味着 Java 中的任何类都包含有 `hashCode()` 函数。另外需要注意的是: `Object` 的 `hashCode()` 方法是本地方法,也就是用 C 语言或 C++ 实现的,该方法通常用来将对象的内存地址转换为整数之后返回。 + +```java +public native int hashCode(); +``` + +散列表存储的是键值对(key-value),它的特点是:**能根据"键"快速的检索出对应的"值"。这其中就利用到了散列码!(可以快速找到所需要的对象)** + +#### 为什么要有 hashCode? + +我们以"`HashSet` 如何检查重复"为例子来说明为什么要有 `hashCode`? + +下面这段内容摘自我的 Java 启蒙书《Head First Java》: + +> 当你把对象加入 `HashSet` 时,`HashSet` 会先计算对象的 `hashCode` 值来判断对象加入的位置,同时也会与其他已经加入的对象的 `hashCode` 值作比较,如果没有相符的 `hashCode`,`HashSet` 会假设对象没有重复出现。但是如果发现有相同 `hashCode` 值的对象,这时会调用 `equals()` 方法来检查 `hashCode` 相等的对象是否真的相同。如果两者相同,`HashSet` 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 `equals` 的次数,相应就大大提高了执行速度。 + +其实, `hashCode()` 和 `equals()`都是用于比较两个对象是否相等。 + +**那为什么 JDK 还要同时提供这两个方法呢?** + +这是因为在一些容器(比如 `HashMap`、`HashSet`)中,有了 `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()的若干问题解答](https://www.cnblogs.com/skywang12345/p/3324958.html) + +### String + +#### String、StringBuffer、StringBuilder 的区别? + +**可变性** + +`String` 是不可变的(后面会详细分析原因)。 + +`StringBuilder` 与 `StringBuffer` 都继承自 `AbstractStringBuilder` 类,在 `AbstractStringBuilder` 中也是使用字符数组保存字符串,不过没有使用 `final` 和 `private` 关键字修饰,最关键的是这个 `AbstractStringBuilder` 类还提供了很多修改字符串的方法比如 `append` 方法。 + +```java +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` 中的对象是不可变的,也就可以理解为常量,线程安全。`AbstractStringBuilder` 是 `StringBuilder` 与 `StringBuffer` 的公共父类,定义了一些字符串的基本操作,如 `expandCapacity`、`append`、`insert`、`indexOf` 等公共方法。`StringBuffer` 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。`StringBuilder` 并没有对方法进行加同步锁,所以是非线程安全的。 + +**性能** + +每次对 `String` 类型进行改变的时候,都会生成一个新的 `String` 对象,然后将指针指向新的 `String` 对象。`StringBuffer` 每次都会对 `StringBuffer` 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 `StringBuilder` 相比使用 `StringBuffer` 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。 + +**对于三者使用的总结:** + +1. 操作少量的数据: 适用 `String` +2. 单线程操作字符串缓冲区下操作大量数据: 适用 `StringBuilder` +3. 多线程操作字符串缓冲区下操作大量数据: 适用 `StringBuffer` + +#### String 为什么是不可变的? + +`String` 类中使用 `final` 关键字修饰字符数组来保存字符串,~~所以`String` 对象是不可变的。~~ + +```java +public final class String implements java.io.Serializable, Comparable, CharSequence { + private final char value[]; + //... +} +``` + +> 🐛 修正 : 我们知道被 `final` 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,`final` 关键字修饰的数组保存字符串并不是 `String` 不可变的根本原因,因为这个数组保存的字符串是可变的(`final` 修饰引用类型变量的情况)。 +> +> `String` 真正不可变有下面几点原因: +> +> 1. 保存字符串的数组被 `final` 修饰且为私有的,并且`String` 类没有提供/暴露修改这个字符串的方法。 +> 2. `String` 类被 `final` 修饰导致其不能被继承,进而避免了子类破坏 `String` 不可变。 +> +> 相关阅读:[如何理解 String 类型值的不可变? - 知乎提问](https://www.zhihu.com/question/20618891/answer/114125846) +> +> 补充(来自[issue 675](https://github.com/Snailclimb/JavaGuide/issues/675)):在 Java 9 之后,`String` 、`StringBuilder` 与 `StringBuffer` 的实现改用 `byte` 数组存储字符串。 +> +> ```java +> public final class String implements java.io.Serializable,Comparable, CharSequence { +> // @Stable 注解表示变量最多被修改一次,称为"稳定的"。 +> @Stable +> private final byte[] value; +> } +> +> abstract class AbstractStringBuilder implements Appendable, CharSequence { +> byte[] value; +> +> } +> ``` +> +> **Java 9 为何要将 `String` 的底层实现由 `char[]` 改成了 `byte[]` ?** +> +> 新版的 String 其实支持两个编码方案: Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,`byte` 占一个字节(8 位),`char` 占用 2 个字节(16),`byte` 相较 `char` 节省一半的内存空间。 +> +> JDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。 +> +> ![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/jdk9-string-latin1.png) +> +> 如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,`byte` 和 `char` 所占用的空间是一样的。 +> +> 这是官方的介绍:https://openjdk.java.net/jeps/254 。 + +#### 字符串拼接用"+" 还是 StringBuilder? + +Java 语言本身并不支持运算符重载,"+"和"+="是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。 + +```java +String str1 = "he"; +String str2 = "llo"; +String str3 = "world"; +String str4 = str1 + str2 + str3; +``` + +上面的代码对应的字节码如下: + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/image-20220422161637929.png) + +可以看出,字符串对象通过"+"的字符串拼接方式,实际上是通过 `StringBuilder` 调用 `append()` 方法实现的,拼接完成之后调用 `toString()` 得到一个 `String` 对象 。 + +不过,在循环内使用"+"进行字符串的拼接的话,存在比较明显的缺陷:**编译器不会创建单个 `StringBuilder` 以复用,会导致创建过多的 `StringBuilder` 对象**。 + +```java +String[] arr = {"he", "llo", "world"}; +String s = ""; +for (int i = 0; i < arr.length; i++) { + s += arr[i]; +} +System.out.println(s); +``` + +`StringBuilder` 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 `StringBuilder` 对象。 + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/image-20220422161320823.png) + +如果直接使用 `StringBuilder` 对象进行字符串拼接的话,就不会存在这个问题了。 + +```java +String[] arr = {"he", "llo", "world"}; +StringBuilder s = new StringBuilder(); +for (String value : arr) { + s.append(value); +} +System.out.println(s); +``` + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/image-20220422162327415.png) + +如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。 + +#### String#equals() 和 Object#equals() 有何区别? + +`String` 中的 `equals` 方法是被重写过的,比较的是 String 字符串的值是否相等。 `Object` 的 `equals` 方法是比较的对象的内存地址。 + +#### 字符串常量池的作用了解吗? + +**字符串常量池** 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。 + +```java +// 在堆中创建字符串对象"ab" +// 将字符串对象"ab"的引用保存在字符串常量池中 +String aa = "ab"; +// 直接返回字符串常量池中字符串对象"ab"的引用 +String bb = "ab"; +System.out.println(aa==bb);// true +``` + +更多关于字符串常量池的介绍可以看一下 [Java 内存区域详解](https://javaguide.cn/java/jvm/memory-area.html) 这篇文章。 + +#### String s1 = new String("abc");这句话创建了几个字符串对象? + +会创建 1 或 2 个字符串对象。 + +1、如果字符串常量池中不存在字符串对象"abc"的引用,那么会在堆中创建 2 个字符串对象"abc"。 + +示例代码(JDK 1.8): + +```java +String s1 = new String("abc"); +``` + +对应的字节码: + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/open-source-project/image-20220413175809959.png) + +`ldc` 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。 + +2、如果字符串常量池中已存在字符串对象"abc"的引用,则只会在堆中创建 1 个字符串对象"abc"。 + +示例代码(JDK 1.8): + +```java +// 字符串常量池中已存在字符串对象"abc"的引用 +String s1 = "abc"; +// 下面这段代码只会在堆中创建 1 个字符串对象"abc" +String s2 = new String("abc"); +``` + +对应的字节码: + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/open-source-project/image-20220413180021072.png) + +这里就不对上面的字节码进行详细注释了,7 这个位置的 `ldc` 命令不会在堆中创建新的字符串对象"abc",这是因为 0 这个位置已经执行了一次 `ldc` 命令,已经在堆中创建过一次字符串对象"abc"了。7 这个位置执行 `ldc` 命令会直接返回字符串常量池中字符串对象"abc"对应的引用。 + +#### intern 方法有什么作用? + +`String.intern()` 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况: + +- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。 +- 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。 + +示例代码(JDK 1.8) : + +```java +// 在堆中创建字符串对象"Java" +// 将字符串对象"Java"的引用保存在字符串常量池中 +String s1 = "Java"; +// 直接返回字符串常量池中字符串对象"Java"对应的引用 +String s2 = s1.intern(); +// 会在堆中在单独创建一个字符串对象 +String s3 = new String("Java"); +// 直接返回字符串常量池中字符串对象"Java"对应的引用 +String s4 = s3.intern(); +// s1 和 s2 指向的是堆中的同一个对象 +System.out.println(s1 == s2); // true +// s3 和 s4 指向的是堆中不同的对象 +System.out.println(s3 == s4); // false +// s1 和 s4 指向的是堆中的同一个对象 +System.out.println(s1 == s4); //true +``` + +#### String 类型的变量和常量做"+"运算时发生了什么? + +先来看字符串不加 `final` 关键字拼接的情况(JDK1.8): + +```java +String str1 = "str"; +String str2 = "ing"; +String str3 = "str" + "ing"; +String str4 = str1 + str2; +String str5 = "string"; +System.out.println(str3 == str4);//false +System.out.println(str3 == str5);//true +System.out.println(str4 == str5);//false +``` + +> **注意** :比较 String 字符串的值是否相等,可以使用 `equals()` 方法。 `String` 中的 `equals` 方法是被重写过的。 `Object` 的 `equals` 方法是比较的对象的内存地址,而 `String` 的 `equals` 方法比较的是字符串的值是否相等。如果你使用 `==` 比较两个字符串是否相等的话,IDEA 还是提示你使用 `equals()` 方法替换。 + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/java-guide-blog/image-20210817123252441.png) + +**对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。** + +在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 **常量折叠(Constant Folding)** 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到: + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/javaguide/image-20210817142715396.png) + +常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。 + +对于 `String str3 = "str" + "ing";` 编译器会给你优化成 `String str3 = "string";` 。 + +并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以: + +- 基本数据类型( `byte`、`boolean`、`short`、`char`、`int`、`float`、`long`、`double`)以及字符串常量。 +- `final` 修饰的基本数据类型和字符串变量 +- 字符串通过 "+"拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、\>>、\>>> ) + +**引用的值在程序编译期是无法确定的,编译器无法对其进行优化。** + +对象引用和"+"的字符串拼接方式,实际上是通过 `StringBuilder` 调用 `append()` 方法实现的,拼接完成之后调用 `toString()` 得到一个 `String` 对象 。 + +```java +String str4 = new StringBuilder().append(str1).append(str2).toString(); +``` + +我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 `StringBuilder` 或者 `StringBuffer`。 + +不过,字符串使用 `final` 关键字声明之后,可以让编译器当做常量来处理。 + +示例代码: + +```java +final String str1 = "str"; +final String str2 = "ing"; +// 下面两个表达式其实是等价的 +String c = "str" + "ing";// 常量池中的对象 +String d = str1 + str2; // 常量池中的对象 +System.out.println(c == d);// true +``` + +被 `final` 关键字修改之后的 `String` 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。 + +如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。 + +示例代码(`str2` 在运行时才能确定其值): + +```java +final String str1 = "str"; +final String str2 = getStr(); +String c = "str" + "ing";// 常量池中的对象 +String d = str1 + str2; // 在堆上创建的新的对象 +System.out.println(c == d);// false +public static String getStr() { + return "ing"; +} +``` + +### Exception 和 Error 有什么区别? + +在 Java 中,所有的异常都有一个共同的祖先 `java.lang` 包中的 `Throwable` 类。`Throwable` 类有两个重要的子类: + +- **`Exception`** :程序本身可以处理的异常,可以通过 `catch` 来进行捕获。`Exception` 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。 +- **`Error`** :`Error` 属于程序无法处理的错误 ,~~我们没办法通过 `catch` 来进行捕获~~不建议通过`catch`捕获 。例如 Java 虚拟机运行错误(`Virtual MachineError`)、虚拟机内存不够错误(`OutOfMemoryError`)、类定义错误(`NoClassDefFoundError`)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。 + +### Checked Exception 和 Unchecked Exception 有什么区别? + +**Checked Exception** 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 `catch`或者`throws` 关键字处理的话,就没办法通过编译。 + +比如下面这段 IO 操作的代码: + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/checked-exception.png) + +除了`RuntimeException`及其子类以外,其他的`Exception`类及其子类都属于受检查异常 。常见的受检查异常有: IO 相关的异常、`ClassNotFoundException` 、`SQLException`...。 + +**Unchecked Exception** 即 **不受检查异常** ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。 + +`RuntimeException` 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到): + +- `NullPointerException`(空指针错误) +- `IllegalArgumentException`(参数错误比如方法入参类型错误) +- `NumberFormatException`(字符串转换为数字格式错误,`IllegalArgumentException`的子类) +- `ArrayIndexOutOfBoundsException`(数组越界错误) +- `ClassCastException`(类型转换错误) +- `ArithmeticException`(算术错误) +- `SecurityException` (安全错误比如权限不够) +- `UnsupportedOperationException`(不支持的操作错误比如重复创建同一用户) +- ...... + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/unchecked-exception.png) + +### 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` 语句块将在方法返回之前被执行。 + +代码示例: + +```java +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"); +} +``` + +输出: + +``` +Try to do something +Catch Exception -> RuntimeException +Finally +``` + +**注意:不要在 finally 语句块中使用 return!** 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。 + +[jvm 官方文档](https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.10.2.5)中有明确提到: + +> If the `try` clause executes a _return_, the compiled code does the following: +> +> 1. Saves the return value (if any) in a local variable. +> 2. Executes a _jsr_ to the code for the `finally` clause. +> 3. Upon return from the `finally` clause, returns the value saved in the local variable. + +代码示例: + +```java +public static void main(String[] args) { + System.out.println(f(2)); +} + +public static int f(int value) { + try { + return value * value; + } finally { + if (value == 2) { + return 0; + } + } +} +``` + +输出: + +``` +0 +``` + +### finally 中的代码一定会执行吗? + +不一定的!在某些情况下,finally 中的代码不会被执行。 + +就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。 + +```java +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"); +} +``` + +输出: + +``` +Try to do something +Catch Exception -> RuntimeException +``` + +另外,在以下 2 种特殊情况下,`finally` 块的代码也不会被执行: + +1. 程序所在的线程死亡。 +2. 关闭 CPU。 + +相关 issue: 。 + +🧗🏻 进阶一下:从字节码角度分析`try catch finally`这个语法糖背后的实现原理。 + +### 如何使用 `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 中类似于`InputStream`、`OutputStream` 、`Scanner` 、`PrintWriter`等的资源都需要我们调用`close()`方法来手动关闭,一般情况下我们都是通过`try-catch-finally`语句来实现这个需求,如下: + +```java +//读取文本文件的内容 +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` 语句改造上面的代码: + +```java +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`块中声明多个资源。 + +```java +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 Reflection: Why is it so slow?](https://stackoverflow.com/questions/1392351/java-reflection-why-is-it-so-slow) 。 + +### 反射的应用场景? + +像咱们平时大部分时候都是在写业务代码,很少会接触到直接使用反射机制的场景。但是!这并不代表反射没有用。相反,正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。 + +**这些框架中也大量使用了动态代理,而动态代理的实现也依赖反射。** + +比如下面是通过 JDK 实现动态代理的示例代码,其中就使用了反射类 `Method` 来调用指定的方法。 + +```java +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`注解就读取到配置文件中的值呢?究竟是怎么起作用的呢? + +这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。 + +### 何谓 SPI? + +SPI 即 Service Provider Interface ,字面意思就是:"服务提供者的接口",我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。 + +SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。 + +很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。 + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/spi/22e1830e0b0e4115a882751f6c417857tplv-k3u1fbpfcp-zoom-1.jpeg) + +### SPI 和 API 有什么区别? + +**那 SPI 和 API 有啥区别?** + +说到 SPI 就不得不说一下 API 了,从广义上来说它们都属于接口,而且很容易混淆。下面先用一张图说明一下: + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/basis/spi/1ebd1df862c34880bc26b9d494535b3dtplv-k3u1fbpfcp-watermark.png) + +一般模块之间都是通过通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个"接口"。 + +当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。 + +当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根绝这个规则对这个接口进行实现,从而提供服务。 + +举个通俗易懂的例子:公司 H 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 H 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。 + +### SPI 的优缺点? + +通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如: + +- 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。 +- 当多个 `ServiceLoader` 同时 `load` 时,会有并发问题。 + +### 什么是序列化?什么是反序列化? + +如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。 + +简单来说: + +- **序列化**: 将数据结构或对象转换成二进制字节流的过程 +- **反序列化**:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程 + +对于 Java 这种面向对象编程语言来说,我们序列化的都是对象(Object)也就是实例化后的类(Class),但是在 C++这种半面向对象的语言中,struct(结构体)定义的是数据结构类型,而 class 对应的是对象类型。 + +维基百科是如是介绍序列化的: + +> **序列化**(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。 + +综上:**序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。** + +![](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2020-8/a478c74d-2c48-40ae-9374-87aacf05188c.png) + +

https://www.corejavaguru.com/java/serialization/interview-questions-1

+ +### 如果有些字段不想进行序列化怎么办? + +对于不想进行序列化的变量,使用 `transient` 关键字修饰。 + +`transient` 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 `transient` 修饰的变量值不会被持久化和恢复。 + +关于 `transient` 还有几点注意: + +- `transient` 只能修饰变量,不能修饰类和方法。 +- `transient` 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 `int` 类型,那么反序列后结果就是 `0`。 +- `static` 变量因为不属于任何对象(Object),所以无论有没有 `transient` 关键字修饰,均不会被序列化。 + +### Java IO 流了解吗? + +IO 即 `Input/Output`,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。 + +Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。 + +- `InputStream`/`Reader`: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 +- `OutputStream`/`Writer`: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 + +相关阅读:[Java IO 基础知识总结](../io/io-basis.md)。 + +### I/O 流为什么要分为字节流和字符流呢? + +问题本质想问:**不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?** + +个人认为主要有两点原因: + +- 字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时; +- 如果我们不知道编码类型的话,使用字节流的过程中很容易出现乱码问题。 + +### Java IO 中的设计模式有哪些? + +[Java IO 设计模式总结](https://javaguide.cn/java/io/io-design-patterns.html)。 + +### BIO、NIO 和 AIO 的区别? + +[Java IO 模型详解](https://javaguide.cn/java/io/io-model.html)。 + diff --git "a/docs/b-1351円235円242円350円257円225円351円242円230円346円200円273円347円273円223円-Java345円237円272円347円241円200円.md" "b/docs/b-1351円235円242円350円257円225円351円242円230円346円200円273円347円273円223円-Java345円237円272円347円241円200円.md" deleted file mode 100755 index 25e6e20..0000000 --- "a/docs/b-1351円235円242円350円257円225円351円242円230円346円200円273円347円273円223円-Java345円237円272円347円241円200円.md" +++ /dev/null @@ -1,723 +0,0 @@ ------- - - - -# 2. Java - -> 作者:Guide 哥。 -> -> **介绍:** Github 90k Star 项目 **[JavaGuide](https://github.com/Snailclimb/JavaGuide)**(公众号同名) 作者。每周都会在公众号更新一些自己原创干货。公众号后台回复"1"领取 Java 工程师必备学习资料+面试突击 pdf。 - -## 2.1. Java 基础 - -### 2.1.1. 面向对象和面向过程的区别 - -- **面向过程** :**面向过程性能比面向对象高。** 因为类调用时需要实例化,开销比较大,比较消耗资源,所以当性能是最重要的考量因素的时候,比如单片机、嵌入式开发、Linux/Unix 等一般采用面向过程开发。但是,**面向过程没有面向对象易维护、易复用、易扩展。** -- **面向对象** :**面向对象易维护、易复用、易扩展。** 因为面向对象有封装、继承、多态性的特性,所以可以设计出低耦合的系统,使系统更加灵活、更加易于维护。但是,**面向对象性能比面向过程低**。 - -参见 issue : [面向过程 :面向过程性能比面向对象高??](https://github.com/Snailclimb/JavaGuide/issues/431) - -> 这个并不是根本原因,面向过程也需要分配内存,计算内存偏移量,Java 性能差的主要原因并不是因为它是面向对象语言,而是 Java 是半编译语言,最终的执行代码并不是可以直接被 CPU 执行的二进制机械码。 -> -> 而面向过程语言大多都是直接编译成机械码在电脑上执行,并且其它一些面向过程的脚本语言性能也并不一定比 Java 好。 - -### 2.1.2. Java 语言有哪些特点? - -1. 简单易学; -2. 面向对象(封装,继承,多态); -3. 平台无关性( Java 虚拟机实现平台无关性); -4. 可靠性; -5. 安全性; -6. 支持多线程( C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持); -7. 支持网络编程并且很方便( Java 语言诞生本身就是为简化网络编程设计的,因此 Java 语言不仅支持网络编程而且很方便); -8. 编译与解释并存; - -> 修正(参见: [issue#544](https://github.com/Snailclimb/JavaGuide/issues/544)):C++11 开始(2011 年的时候),C++就引入了多线程库,在 windows、linux、macos 都可以使用`std::thread`和`std::async`来创建线程。参考链接:http://www.cplusplus.com/reference/thread/thread/?kw=thread - -### 2.1.3. 关于 JVM JDK 和 JRE 最详细通俗的解答 - -#### 2.1.3.1. JVM - -Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。 - -**什么是字节码?采用字节码的好处是什么?** - -> 在 Java 中,JVM 可以理解的代码就叫做`字节码`(即扩展名为 `.class` 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。 - -**Java 程序从源代码到运行一般有下面 3 步:** - -![Java程序运行过程](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/Java%20%E7%A8%8B%E5%BA%8F%E8%BF%90%E8%A1%8C%E8%BF%87%E7%A8%8B.png) - -我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。 - -> HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快。JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation),它是直接将字节码编译成机器码,这样就避免了 JIT 预热等各方面的开销。JDK 支持分层编译和 AOT 协作使用。但是 ,AOT 编译器的编译质量是肯定比不上 JIT 编译器的。 - -**总结:** - -Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言"一次编译,随处可以运行"的关键所在。 - -#### 2.1.3.2. JDK 和 JRE - -JDK 是 Java Development Kit,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。 - -JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。 - -如果你只是为了运行一下 Java 程序的话,那么你只需要安装 JRE 就可以了。如果你需要进行一些 Java 编程方面的工作,那么你就需要安装 JDK 了。但是,这不是绝对的。有时,即使您不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,您只是在应用程序服务器中运行 Java 程序。那你为什么需要 JDK 呢?因为应用程序服务器会将 JSP 转换为 Java servlet,并且需要使用 JDK 来编译 servlet。 - -### 2.1.4. Oracle JDK 和 OpenJDK 的对比 - -可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK 。那么 Oracle 和 OpenJDK 之间是否存在重大差异?下面我通过收集到的一些资料,为你解答这个被很多人忽视的问题。 - -对于 Java 7,没什么关键的地方。OpenJDK 项目主要基于 Sun 捐赠的 HotSpot 源代码。此外,OpenJDK 被选为 Java 7 的参考实现,由 Oracle 工程师维护。关于 JVM,JDK,JRE 和 OpenJDK 之间的区别,Oracle 博客帖子在 2012 年有一个更详细的答案: - -> 问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别? -> -> 答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些封闭的源代码派对组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。 - -**总结:** - -1. Oracle JDK 大概每 6 个月发一次主要版本,而 OpenJDK 版本大概每三个月发布一次。但这不是固定的,我觉得了解这个没啥用处。详情参见:https://blogs.oracle.com/java-platform-group/update-and-faq-on-the-java-se-release-cadence。 -2. OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是 OpenJDK 的一个实现,并不是完全开源的; -3. Oracle JDK 比 OpenJDK 更稳定。OpenJDK 和 Oracle JDK 的代码几乎相同,但 Oracle JDK 有更多的类和一些错误修复。因此,如果您想开发企业/商业软件,我建议您选择 Oracle JDK,因为它经过了彻底的测试和稳定。某些情况下,有些人提到在使用 OpenJDK 可能会遇到了许多应用程序崩溃的问题,但是,只需切换到 Oracle JDK 就可以解决问题; -4. 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的性能; -5. Oracle JDK 不会为即将发布的版本提供长期支持,用户每次都必须通过更新到最新版本获得支持来获取最新版本; -6. Oracle JDK 根据二进制代码许可协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。 - -### 2.1.5. Java 和 C++的区别? - -我知道很多人没学过 C++,但是面试官就是没事喜欢拿咱们 Java 和 C++ 比呀!没办法!!!就算没学过 C++,也要记下来! - -- 都是面向对象的语言,都支持封装、继承和多态 -- Java 不提供指针来直接访问内存,程序内存更加安全 -- Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。 -- Java 有自动内存管理机制,不需要程序员手动释放无用内存 -- **在 C 语言中,字符串或字符数组最后都会有一个额外的字符‘0円’来表示结束。但是,Java 语言中没有结束符这一概念。** 这是一个值得深度思考的问题,具体原因推荐看这篇文章: [https://blog.csdn.net/sszgg2006/article/details/49148189](https://blog.csdn.net/sszgg2006/article/details/49148189) - -> 作者:Guide 哥。 -> -> **介绍:** Github 90k Star 项目 **[JavaGuide](https://github.com/Snailclimb/JavaGuide)**(公众号同名) 作者。每周都会在公众号更新一些自己原创干货。公众号后台回复"1"领取 Java 工程师必备学习资料+面试突击 pdf。 - -### 2.1.6. 字符型常量和字符串常量的区别? - -1. 形式上: 字符常量是单引号引起的一个字符; 字符串常量是双引号引起的若干个字符 -2. 含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置) -3. 占内存大小 字符常量只占 2 个字节; 字符串常量占若干个字节 (**注意: char 在 Java 中占两个字节**) - -> java 编程思想第四版:2.2.2 节 -> ![](http://my-blog-to-use.oss-cn-beijing.aliyuncs.com/18-9-15/86735519.jpg) - -### 2.1.7. 构造器 Constructor 是否可被 override? - -Constructor 不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。 - -### 2.1.8. 重载和重写的区别 - -> 重载就是同样的一个方法能够根据输入数据的不同,做出不同的处理 -> -> 重写就是当子类继承自父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法 - -**重载:** - -发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。 - -下面是《Java 核心技术》对重载这个概念的介绍: - -![](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/bg/desktopjava核心技术-重载.jpg) - -综上:重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。 - -**重写:** - -重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。 - -1. 返回值类型、方法名、参数列表必须相同,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。 -2. 如果父类方法访问修饰符为 `private/final/static` 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。 -3. 构造方法无法被重写 - -综上:重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变 - -暖心的 Guide 哥最后再来个图表总结一下! - -| 区别点 | 重载方法 | 重写方法 | -| :--------- | :------- | :----------------------------------------------------------- | -| 发生范围 | 同一个类 | 子类 | -| 参数列表 | 必须修改 | 一定不能修改 | -| 返回类型 | 可修改 | 子类方法返回值类型应比父类方法返回值类型更小或相等 | -| 异常 | 可修改 | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; | -| 访问修饰符 | 可修改 | 一定不能做更严格的限制(可以降低限制) | -| 发生阶段 | 编译期 | 运行期 | - -**方法的重写要遵循"两同两小一大"**(以下内容摘录自《疯狂 Java 讲义》,[issue#892](https://github.com/Snailclimb/JavaGuide/issues/892) ): - -- "两同"即方法名相同、形参列表相同; -- "两小"指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; -- "一大"指的是子类方法的访问权限应比父类方法的访问权限更大或相等。 - -⭐️ 关于 **重写的返回值类**型 这里需要额外多说明一下,上面的表述不太清晰准确:如果方法的返回类型是void和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写时是可以返回该引用类型的子类的。 - -```java -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 { - public String name() { - return "超级超级英雄"; - } - - @Override - public SuperMan hero() { - return new SuperMan(); - } -} -``` - -### 2.1.9. Java 面向对象编程三大特性: 封装 继承 多态 - -#### 2.1.9.1. 封装 - -封装把一个对象的属性私有化,同时提供一些可以被外界访问的属性的方法,如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。 - -#### 2.1.9.2. 继承 - -继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承我们能够非常方便地复用以前的代码。 - -**关于继承如下 3 点请记住:** - -1. 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,**只是拥有**。 -2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。 -3. 子类可以用自己的方式实现父类的方法。(以后介绍)。 - -#### 2.1.9.3. 多态 - -所谓多态就是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编程时并不确定,而是在程序运行期间才确定,即一个引用变量到底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在由程序运行期间才能决定。 - -在 Java 中有两种形式可以实现多态:继承(多个子类对同一方法的重写)和接口(实现接口并覆盖接口中同一方法)。 - -### 2.1.10. String StringBuffer 和 StringBuilder 的区别是什么? String 为什么是不可变的? - -**可变性** - -简单的来说:`String` 类中使用 `final` 关键字修饰字符数组来保存字符串,`private final char value[]`,所以 String 对象是不可变的。 - -> 补充(来自[issue 675](https://github.com/Snailclimb/JavaGuide/issues/675)):在 Java 9 之后,String 、`StringBuilder` 与 `StringBuffer` 的实现改用 byte 数组存储字符串 `private final byte[] value` - -而 `StringBuilder` 与 `StringBuffer` 都继承自 `AbstractStringBuilder` 类,在 `AbstractStringBuilder` 中也是使用字符数组保存字符串`char[]value` 但是没有用 `final` 关键字修饰,所以这两种对象都是可变的。 - -`StringBuilder` 与 `StringBuffer` 的构造方法都是调用父类构造方法也就是 `AbstractStringBuilder` 实现的,大家可以自行查阅源码。 - -`AbstractStringBuilder.java` - -```java -abstract class AbstractStringBuilder implements Appendable, CharSequence { - /** - * The value is used for character storage. - */ - char[] value; - - /** - * The count is the number of characters used. - */ - int count; - - AbstractStringBuilder(int capacity) { - value = new char[capacity]; - } -``` - -**线程安全性** - -`String` 中的对象是不可变的,也就可以理解为常量,线程安全。`AbstractStringBuilder` 是 `StringBuilder` 与 `StringBuffer` 的公共父类,定义了一些字符串的基本操作,如 `expandCapacity`、`append`、`insert`、`indexOf` 等公共方法。`StringBuffer` 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。`StringBuilder` 并没有对方法进行加同步锁,所以是非线程安全的。 - -**性能** - -每次对 `String` 类型进行改变的时候,都会生成一个新的 `String` 对象,然后将指针指向新的 `String` 对象。`StringBuffer` 每次都会对 `StringBuffer` 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 `StringBuilder` 相比使用 `StringBuffer` 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。 - -**对于三者使用的总结:** - -1. 操作少量的数据: 适用 `String` -2. 单线程操作字符串缓冲区下操作大量数据: 适用 `StringBuilder` -3. 多线程操作字符串缓冲区下操作大量数据: 适用 `StringBuffer` - -### 2.1.11. 自动装箱与拆箱 - -- **装箱**:将基本类型用它们对应的引用类型包装起来; -- **拆箱**:将包装类型转换为基本数据类型; - -更多内容见:[深入剖析 Java 中的装箱和拆箱](https://www.cnblogs.com/dolphin0520/p/3780005.html) - -### 2.1.12. 在一个静态方法内调用一个非静态成员为什么是非法的? - -由于静态方法可以不通过对象进行调用,因此在静态方法里,不能调用其他非静态变量,也不可以访问非静态变量成员。 - -### 2.1.13. 在 Java 中定义一个不做事且没有参数的构造方法的作用 - -Java 程序在执行子类的构造方法之前,如果没有用 `super()`来调用父类特定的构造方法,则会调用父类中"没有参数的构造方法"。因此,如果父类中只定义了有参数的构造方法,而在子类的构造方法中又没有用 `super()`来调用父类中特定的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到没有参数的构造方法可供执行。解决办法是在父类里加上一个不做事且没有参数的构造方法。 - -### 2.1.14. 接口和抽象类的区别是什么? - -1. 接口的方法默认是 `public`,所有方法在接口中不能有实现(Java 8 开始接口方法可以有默认实现),而抽象类可以有非抽象的方法。 -2. 接口中除了 `static`、`final` 变量,不能有其他变量,而抽象类中则不一定。 -3. 一个类可以实现多个接口,但只能实现一个抽象类。接口自己本身可以通过 `extends` 关键字扩展多个接口。 -4. 接口方法默认修饰符是 `public`,抽象方法可以有 `public`、`protected` 和 `default` 这些修饰符(抽象方法就是为了被重写所以不能使用 `private` 关键字修饰!)。 -5. 从设计层面来说,抽象是对类的抽象,是一种模板设计,而接口是对行为的抽象,是一种行为的规范。 - -> 备注: -> -> 1. 在 JDK8 中,接口也可以定义静态方法,可以直接用接口名调用。实现类和实现是不可以调用的。如果同时实现两个接口,接口中定义了一样的默认方法,则必须重写,不然会报错。(详见 issue:[https://github.com/Snailclimb/JavaGuide/issues/146](https://github.com/Snailclimb/JavaGuide/issues/146)。 -> 2. jdk9 的接口被允许定义私有方法 。 - -总结一下 jdk7~jdk9 Java 中接口概念的变化([相关阅读](https://www.geeksforgeeks.org/private-methods-java-9-interfaces/)): - -1. 在 jdk 7 或更早版本中,接口里面只能有常量变量和抽象方法。这些接口方法必须由选择实现接口的类实现。 -2. jdk 8 的时候接口可以有默认方法和静态方法功能。 -3. Jdk 9 在接口中引入了私有方法和私有静态方法。 - -### 2.1.15. 成员变量与局部变量的区别有哪些? - -1. 从语法形式上看:成员变量是属于类的,而局部变量是在方法中定义的变量或是方法的参数;成员变量可以被 `public`,`private`,`static` 等修饰符所修饰,而局部变量不能被访问控制修饰符及 `static` 所修饰;但是,成员变量和局部变量都能被 `final` 所修饰。 -2. 从变量在内存中的存储方式来看:如果成员变量是使用`static`修饰的,那么这个成员变量是属于类的,如果没有使用`static`修饰,这个成员变量是属于实例的。对象存于堆内存,如果局部变量类型为基本数据类型,那么存储在栈内存,如果为引用数据类型,那存放的是指向堆内存对象的引用或者是指向常量池中的地址。 -3. 从变量在内存中的生存时间上看:成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动消失。 -4. 成员变量如果没有被赋初值:则会自动以类型的默认值而赋值(一种情况例外:被 `final` 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。 - -### 2.1.16. 创建一个对象用什么运算符?对象实体与对象引用有何不同? - -new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。 - -### 2.1.17. 什么是方法的返回值?返回值在类的方法里的作用是什么? - -方法的返回值是指我们获取到的某个方法体中的代码执行后产生的结果!(前提是该方法可能产生结果)。返回值的作用:接收出结果,使得它可以用于其他的操作! - -### 2.1.18. 一个类的构造方法的作用是什么? 若一个类没有声明构造方法,该程序能正确执行吗? 为什么? - -主要作用是完成对类对象的初始化工作。可以执行。因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。 - -### 2.1.19. 构造方法有哪些特性? - -1. 名字与类名相同。 -2. 没有返回值,但不能用 void 声明构造函数。 -3. 生成类的对象时自动执行,无需调用。 - -### 2.1.20. 静态方法和实例方法有何不同 - -1. 在外部调用静态方法时,可以使用"类名.方法名"的方式,也可以使用"对象名.方法名"的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象。 - -2. 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制。 - -### 2.1.21. 对象的相等与指向他们的引用相等,两者有什么不同? - -对象的相等,比的是内存中存放的内容是否相等。而引用相等,比较的是他们指向的内存地址是否相等。 - -### 2.1.22. 在调用子类构造方法之前会先调用父类没有参数的构造方法,其目的是? - -帮助子类做初始化工作。 - -### 2.1.23. == 与 equals(重要) - -**==** : 它的作用是判断两个对象的地址是不是相等。即,判断两个对象是不是同一个对象(基本数据类型==比较的是值,引用数据类型==比较的是内存地址)。 - -**equals()** : 它的作用也是判断两个对象是否相等。但它一般有两种使用情况: - -- 情况 1:类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时,等价于通过"=="比较这两个对象。 -- 情况 2:类覆盖了 equals() 方法。一般,我们都覆盖 equals() 方法来比较两个对象的内容是否相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)。 - -**举个例子:** - -```java -public class test1 { - public static void main(String[] args) { - String a = new String("ab"); // a 为一个引用 - String b = new String("ab"); // b为另一个引用,对象的内容一样 - String aa = "ab"; // 放在常量池中 - String bb = "ab"; // 从常量池中查找 - if (aa == bb) // true - System.out.println("aa==bb"); - if (a == b) // false,非同一对象 - System.out.println("a==b"); - if (a.equals(b)) // true - System.out.println("aEQb"); - if (42 == 42.0) { // true - System.out.println("true"); - } - } -} -``` - -**说明:** - -- String 中的 equals 方法是被重写过的,因为 object 的 equals 方法是比较的对象的内存地址,而 String 的 equals 方法比较的是对象的值。 -- 当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 String 对象。 - -### 2.1.24. hashCode 与 equals (重要) - -面试官可能会问你:"你重写过 `hashcode` 和 `equals`么,为什么重写 `equals` 时必须重写 `hashCode` 方法?" - -**1)hashCode()介绍:** - -`hashCode()` 的作用是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。`hashCode()`定义在 JDK 的 `Object` 类中,这就意味着 Java 中的任何类都包含有 `hashCode()` 函数。另外需要注意的是: `Object` 的 hashcode 方法是本地方法,也就是用 c 语言或 c++ 实现的,该方法通常用来将对象的 内存地址 转换为整数之后返回。 - -```java -public native int hashCode(); -``` - -散列表存储的是键值对(key-value),它的特点是:能根据"键"快速的检索出对应的"值"。这其中就利用到了散列码!(可以快速找到所需要的对象) - -**2)为什么要有 hashCode?** - -我们以"`HashSet` 如何检查重复"为例子来说明为什么要有 hashCode? - -当你把对象加入 `HashSet` 时,`HashSet` 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,`HashSet` 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用 `equals()` 方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,`HashSet` 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。(摘自我的 Java 启蒙书《Head First Java》第二版)。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。 - -**3)为什么重写 `equals` 时必须重写 `hashCode` 方法?** - -如果两个对象相等,则 hashcode 一定也是相同的。两个对象相等,对两个对象分别调用 equals 方法都返回 true。但是,两个对象有相同的 hashcode 值,它们也不一定是相等的 。**因此,equals 方法被覆盖过,则 `hashCode` 方法也必须被覆盖。** - -> `hashCode()`的默认行为是对堆上的对象产生独特值。如果没有重写 `hashCode()`,则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据) - -**4)为什么两个对象有相同的 hashcode 值,它们也不一定是相等的?** - -在这里解释一位小伙伴的问题。以下内容摘自《Head Fisrt Java》。 - -因为 `hashCode()` 所使用的杂凑算法也许刚好会让多个对象传回相同的杂凑值。越糟糕的杂凑算法越容易碰撞,但这也与数据值域分布的特性有关(所谓碰撞也就是指的是不同的对象得到相同的 `hashCode`。 - -我们刚刚也提到了 `HashSet`,如果 `HashSet` 在对比的时候,同样的 hashcode 有多个对象,它会使用 `equals()` 来判断是否真的相同。也就是说 `hashcode` 只是用来缩小查找成本。 - -更多关于 `hashcode()` 和 `equals()` 的内容可以查看:[Java hashCode() 和 equals()的若干问题解答](https://www.cnblogs.com/skywang12345/p/3324958.html) - -### 2.1.25. 为什么 Java 中只有值传递? - -首先回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语。**按值调用(call by value)表示方法接收的是调用者提供的值,而按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。** 它用来描述各种程序设计语言(不只是 Java)中方法参数传递方式。 - -**Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,也就是说,方法不能修改传递给它的任何参数变量的内容。** - -**下面通过 3 个例子来给大家说明** - -> **example 1** - -```java -public static void main(String[] args) { - int num1 = 10; - int num2 = 20; - - swap(num1, num2); - - System.out.println("num1 = " + num1); - System.out.println("num2 = " + num2); -} - -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); -} -``` - -**结果:** - -``` -a = 20 -b = 10 -num1 = 10 -num2 = 20 -``` - -**解析:** - -![example 1 ](http://my-blog-to-use.oss-cn-beijing.aliyuncs.com/18-9-27/22191348.jpg) - -在 swap 方法中,a、b 的值进行交换,并不会影响到 num1、num2。因为,a、b 中的值,只是从 num1、num2 的复制过来的。也就是说,a、b 相当于 num1、num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。 - -**通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,而对象引用作为参数就不一样,请看 example2.** - -> **example 2** - -```java - 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; - } -``` - -**结果:** - -``` -1 -0 -``` - -**解析:** - -![example 2](http://my-blog-to-use.oss-cn-beijing.aliyuncs.com/18-9-27/3825204.jpg) - -array 被初始化 arr 的拷贝也就是一个对象的引用,也就是说 array 和 arr 指向的是同一个数组对象。 因此,外部对引用对象的改变会反映到所对应的对象上。 - -**通过 example2 我们已经看到,实现一个改变对象参数状态的方法并不是一件难事。理由很简单,方法得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。** - -**很多程序设计语言(特别是,C++和 Pascal)提供了两种参数传递的方式:值调用和引用调用。有些程序员(甚至本书的作者)认为 Java 程序设计语言对对象采用的是引用调用,实际上,这种理解是不对的。由于这种误解具有一定的普遍性,所以下面给出一个反例来详细地阐述一下这个问题。** - -> **example 3** - -```java -public class Test { - - public static void main(String[] args) { - // TODO Auto-generated method stub - Student s1 = new Student("小张"); - Student s2 = new Student("小李"); - Test.swap(s1, s2); - System.out.println("s1:" + s1.getName()); - System.out.println("s2:" + s2.getName()); - } - - public static void swap(Student x, Student y) { - Student temp = x; - x = y; - y = temp; - System.out.println("x:" + x.getName()); - System.out.println("y:" + y.getName()); - } -} -``` - -**结果:** - -``` -x:小李 -y:小张 -s1:小张 -s2:小李 -``` - -**解析:** - -交换之前: - -![](http://my-blog-to-use.oss-cn-beijing.aliyuncs.com/18-9-27/88729818.jpg) - -交换之后: - -![](http://my-blog-to-use.oss-cn-beijing.aliyuncs.com/18-9-27/34384414.jpg) - -通过上面两张图可以很清晰的看出: **方法并没有改变存储在变量 s1 和 s2 中的对象引用。swap 方法的参数 x 和 y 被初始化为两个对象引用的拷贝,这个方法交换的是这两个拷贝** - -> **总结** - -Java 程序设计语言对对象采用的不是引用调用,实际上,对象引用是按 -值传递的。 - -下面再总结一下 Java 中方法参数的使用情况: - -- 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。 -- 一个方法可以改变一个对象参数的状态。 -- 一个方法不能让对象参数引用一个新的对象。 - -**参考:** - -《Java 核心技术卷 I》基础知识第十版第四章 4.5 小节 - -### 2.1.26. 简述线程、程序、进程的基本概念。以及他们之间关系是什么? - -**线程**与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。 - -**程序**是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。 - -**进程**是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如 CPU 时间,内存空间,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 -线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。从另一角度来说,进程属于操作系统的范畴,主要是同一段时间内,可以同时执行一个以上的程序,而线程则是在同一程序内几乎同时执行一个以上的程序段。 - -### 2.1.27. 线程有哪些基本状态? - -Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态(图源《Java 并发编程艺术》4.1.4 节)。 - -![Java线程的状态](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/19-1-29/Java%E7%BA%BF%E7%A8%8B%E7%9A%84%E7%8A%B6%E6%80%81.png) - -线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图所示(图源《Java 并发编程艺术》4.1.4 节): - -![Java线程状态变迁](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/19-1-29/Java%20%E7%BA%BF%E7%A8%8B%E7%8A%B6%E6%80%81%E5%8F%98%E8%BF%81.png) - -由上图可以看出: - -线程创建之后它将处于 **NEW(新建)** 状态,调用 `start()` 方法后开始运行,线程这时候处于 **READY(可运行)** 状态。可运行状态的线程获得了 cpu 时间片(timeslice)后就处于 **RUNNING(运行)** 状态。 - -> 操作系统隐藏 Java 虚拟机(JVM)中的 READY 和 RUNNING 状态,它只能看到 RUNNABLE 状态(图源:[HowToDoInJava](https://howtodoinjava.com/):[Java Thread Life Cycle and Thread States](https://howtodoinjava.com/java/multi-threading/java-thread-life-cycle-and-thread-states/)),所以 Java 系统一般将这两个状态统称为 **RUNNABLE(运行中)** 状态 。 - -![RUNNABLE-VS-RUNNING](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-3/RUNNABLE-VS-RUNNING.png) - -当线程执行 `wait()`方法之后,线程进入 **WAITING(等待)**状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 **TIME_WAITING(超时等待)** 状态相当于在等待状态的基础上增加了超时限制,比如通过 `sleep(long millis)`方法或 `wait(long millis)`方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 **BLOCKED(阻塞)** 状态。线程在执行 Runnable 的`run()`方法之后将会进入到 **TERMINATED(终止)** 状态。 - -### 2.1.28. 关于 final 关键字的一些总结 - -final 关键字主要用在三个地方:变量、方法、类。 - -1. 对于一个 final 变量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。 -2. 当用 final 修饰一个类时,表明这个类不能被继承。final 类中的所有成员方法都会被隐式地指定为 final 方法。 -3. 使用 final 方法的原因有两个。第一个原因是把方法锁定,以防任何继承类修改它的含义;第二个原因是效率。在早期的 Java 实现版本中,会将 final 方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升(现在的 Java 版本已经不需要使用 final 方法进行这些优化了)。类中所有的 private 方法都隐式地指定为 final。 - -### 2.1.29. Java 中的异常处理 - -#### 2.1.29.1. Java 异常类层次结构图 - -![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/2020-12/Java%E5%BC%82%E5%B8%B8%E7%B1%BB%E5%B1%82%E6%AC%A1%E7%BB%93%E6%9E%84%E5%9B%BE.png) - -

图片来自:https://simplesnippets.tech/exception-handling-in-java-part-1/

- -![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/2020-12/Java%E5%BC%82%E5%B8%B8%E7%B1%BB%E5%B1%82%E6%AC%A1%E7%BB%93%E6%9E%84%E5%9B%BE2.png) - -

图片来自:https://chercher.tech/java-programming/exceptions-java

- -在 Java 中,所有的异常都有一个共同的祖先 `java.lang` 包中的 `Throwable` 类。`Throwable` 类有两个重要的子类 `Exception`(异常)和 `Error`(错误)。`Exception` 能被程序本身处理(`try-catch`), `Error` 是无法处理的(只能尽量避免)。 - -`Exception` 和 `Error` 二者都是 Java 异常处理的重要子类,各自都包含大量子类。 - -- **`Exception`** :程序本身可以处理的异常,可以通过 `catch` 来进行捕获。`Exception` 又可以分为 受检查异常(必须处理) 和 不受检查异常(可以不处理)。 -- **`Error`** :`Error` 属于程序无法处理的错误 ,我们没办法通过 `catch` 来进行捕获 。例如,Java 虚拟机运行错误(`Virtual MachineError`)、虚拟机内存不够错误(`OutOfMemoryError`)、类定义错误(`NoClassDefFoundError`)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。 - -**受检查异常** - -Java 代码在编译过程中,如果受检查异常没有被 `catch`/`throw` 处理的话,就没办法通过编译 。比如下面这段 IO 操作的代码。 - -![check-exception](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/2020-12/check-exception.png) - -除了`RuntimeException`及其子类以外,其他的`Exception`类及其子类都属于检查异常 。常见的受检查异常有: IO 相关的异常、`ClassNotFoundException` 、`SQLException`...。 - -**不受检查异常** - -Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。 - -`RuntimeException` 及其子类都统称为非受检查异常,例如:`NullPoin​terException`、`NumberFormatException`(字符串转换为数字)、`ArrayIndexOutOfBoundsException`(数组越界)、`ClassCastException`(类型转换错误)、`ArithmeticException`(算术错误)等。 - -#### 2.1.29.2. Throwable 类常用方法 - -- **`public string getMessage()`**:返回异常发生时的简要描述 -- **`public string toString()`**:返回异常发生时的详细信息 -- **`public string getLocalizedMessage()`**:返回异常对象的本地化信息。使用 `Throwable` 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 `getMessage()`返回的结果相同 -- **`public void printStackTrace()`**:在控制台上打印 `Throwable` 对象封装的异常信息 - -#### 2.1.29.3. 异常处理总结 - -- **`try`块:** 用于捕获异常。其后可接零个或多个 `catch` 块,如果没有 `catch` 块,则必须跟一个 `finally` 块。 -- **`catch`块:** 用于处理 try 捕获到的异常。 -- **`finally` 块:** 无论是否捕获或处理异常,`finally` 块里的语句都会被执行。当在 `try` 块或 `catch` 块中遇到 `return` 语句时,`finally` 语句块将在方法返回之前被执行。 - -**在以下 3 种特殊情况下,`finally` 块不会被执行:** - -1. 在 `try` 或 `finally `块中用了 `System.exit(int)`退出程序。但是,如果 `System.exit(int)` 在异常语句之后,`finally` 还是会被执行 -2. 程序所在的线程死亡。 -3. 关闭 CPU。 - -下面这部分内容来自 issue:。 - -**注意:** 当 try 语句和 finally 语句中都有 return 语句时,在方法返回之前,finally 语句的内容将被执行,并且 finally 语句的返回值将会覆盖原始的返回值。如下: - -```java - public static int f(int value) { - try { - return value * value; - } finally { - if (value == 2) { - return 0; - } - } - } -``` - -如果调用 `f(2)`,返回值将是 0,因为 finally 语句的返回值覆盖了 try 语句块的返回值。 - -### 2.1.30. Java 序列化中如果有些字段不想进行序列化,怎么办? - -对于不想进行序列化的变量,使用 transient 关键字修饰。 - -transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方法。 - -### 2.1.31. 获取用键盘输入常用的两种方法 - -方法 1:通过 Scanner - -```java -Scanner input = new Scanner(System.in); -String s = input.nextLine(); -input.close(); -``` - -方法 2:通过 BufferedReader - -```java -BufferedReader input = new BufferedReader(new InputStreamReader(System.in)); -String s = input.readLine(); -``` - -### 2.1.32. Java 中 IO 流 - -#### 2.1.32.1. Java 中 IO 流分为几种? - -- 按照流的流向分,可以分为输入流和输出流; -- 按照操作单元划分,可以划分为字节流和字符流; -- 按照流的角色划分为节点流和处理流。 - -Java Io 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系, Java I0 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。 - -- InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。 -- OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。 - -按操作方式分类结构图: - -![IO-操作方式分类](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/IO-操作方式分类.png) - -按操作对象分类结构图: - -![IO-操作对象分类](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/IO-操作对象分类.png) - -#### 2.1.32.2. 既然有了字节流,为什么还要有字符流? - -问题本质想问:**不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?** - -回答:字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。 - -#### 2.1.32.3. BIO,NIO,AIO 有什么区别? - -- **BIO (Blocking I/O):** 同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机 1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。 -- **NIO (Non-blocking/New I/O):** NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 `Socket` 和 `ServerSocket` 相对应的 `SocketChannel` 和 `ServerSocketChannel` 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发 -- **AIO (Asynchronous I/O):** AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步 IO 的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO 操作本身是同步的。查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。 - -### 2.1.33. 深拷贝 vs 浅拷贝 - -1. **浅拷贝**:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。 -2. **深拷贝**:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。 - -![deep and shallow copy](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-7/java-deep-and-shallow-copy.jpg) - -### 2.1.34. 参考 - -- https://stackoverflow.com/questions/1906445/what-is-the-difference-between-jdk-and-jre -- https://www.educba.com/oracle-vs-openjdk/ -- https://stackoverflow.com/questions/22358071/differences-between-oracle-jdk-and-openjdk?answertab=active#tab-top - -### 2.1.35. 公众号 - -如果大家想要实时关注我更新的文章以及分享的干货的话,可以关注我的公众号。 - -**《JavaGuide 面试突击版》 :** 由本文档衍生的专为面试而生的《JavaGuide 面试突击版》 版本[公众号](#公众号)后台回复 **"Java 面试突击"** 即可免费领取! - -**Java 工程师必备学习资源:** 一些 Java 工程师常用学习资源公众号后台回复关键字 **"1"** 即可免费无套路获取。 - -![我的公众号](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/167598cd2e17b8ec.png) \ No newline at end of file diff --git "a/docs/b-2Java351円233円206円345円220円210円.md" "b/docs/b-2Java351円233円206円345円220円210円.md" index 9664434..9c31e55 100755 --- "a/docs/b-2Java351円233円206円345円220円210円.md" +++ "b/docs/b-2Java351円233円206円345円220円210円.md" @@ -4,29 +4,90 @@ ## 2.2. Java集合 -> 作者:Guide哥。 -> -> **介绍:** Github 70k Star 项目 **[JavaGuide](https://github.com/Snailclimb/JavaGuide)**(公众号同名) 作者。每周都会在公众号更新一些自己原创干货。公众号后台回复"1"领取Java工程师必备学习资料+面试突击pdf。 +Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 `Collection`接口,主要用于存放单一元素;另一个是 `Map` 接口,主要用于存放键值对。对于`Collection` 接口,下面又有三个主要的子接口:`List`、`Set` 和 `Queue`。 -### 2.2.1. 说说List,Set,Map三者的区别? +Java 集合框架如下图所示: -- `List`(对付顺序的好帮手): 存储的元素是有序的、可重复的。 +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/java-collection-hierarchy.png) + + +注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了`AbstractList`, `NavigableSet`等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。 + +### 说说 List, Set, Queue, Map 四者的区别? + +- `List`(对付顺序的好帮手): 存储的元素是有序的、可重复的。 - `Set`(注重独一无二的性质): 存储的元素是无序的、不可重复的。 -- `Map`(用 Key 来搜索的专家): 使用键值对(kye-value)存储,类似于数学上的函数 y=f(x),"x"代表 key,"y"代表 value,Key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。 +- `Queue`(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。 +- `Map`(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),"x" 代表 key,"y" 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。 -### 2.2.2. Arraylist 与 LinkedList 区别? +### 集合框架底层数据结构总结 -1. **是否保证线程安全:** `ArrayList` 和 `LinkedList` 都是不同步的,也就是不保证线程安全; -2. **底层数据结构:** `Arraylist` 底层使用的是 **`Object` 数组**;`LinkedList` 底层使用的是 **双向链表** 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) -3. **插入和删除是否受元素位置的影响:** 1 **`ArrayList` 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。** 比如:执行`add(E e)`方法的时候, `ArrayList` 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(`add(int index, E element)`)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 2 **`LinkedList` 采用链表存储,所以对于`add(E e)`方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置`i`插入和删除元素的话(`(add(int index, E element)`) 时间复杂度近似为`o(n))`因为需要先移动到指定位置再插入。** -4. **是否支持快速随机访问:** `LinkedList` 不支持高效的随机元素访问,而 `ArrayList` 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于`get(int index)`方法)。 -5. **内存空间占用:** ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 +先来看一下 `Collection` 接口下面的集合。 -#### 2.2.2.1. 补充内容:双向链表和双向循环链表 +#### List -**双向链表:** 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。 +- `ArrayList`: `Object[]` 数组 +- `Vector`:`Object[]` 数组 +- `LinkedList`: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环) + +#### Set + +- `HashSet`(无序,唯一): 基于 `HashMap` 实现的,底层采用 `HashMap` 来保存元素 +- `LinkedHashSet`: `LinkedHashSet` 是 `HashSet` 的子类,并且其内部是通过 `LinkedHashMap` 来实现的。有点类似于我们之前说的 `LinkedHashMap` 其内部是基于 `HashMap` 实现一样,不过还是有一点点区别的 +- `TreeSet`(有序,唯一): 红黑树(自平衡的排序二叉树) + +#### Queue + +- `PriorityQueue`: `Object[]` 数组来实现二叉堆 +- `ArrayQueue`: `Object[]` 数组 + 双指针 + +再来看看 `Map` 接口下面的集合。 + +#### Map + +- `HashMap`: JDK1.8 之前 `HashMap` 由数组+链表组成的,数组是 `HashMap` 的主体,链表则是主要为了解决哈希冲突而存在的("拉链法"解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间 +- `LinkedHashMap`: `LinkedHashMap` 继承自 `HashMap`,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,`LinkedHashMap` 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:[《LinkedHashMap 源码详细分析(JDK1.8)》](https://www.imooc.com/article/22931) +- `Hashtable`: 数组+链表组成的,数组是 `Hashtable` 的主体,链表则是主要为了解决哈希冲突而存在的 +- `TreeMap`: 红黑树(自平衡的排序二叉树) + +### 如何选用集合? + +主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用 `Map` 接口下的集合,需要排序时选择 `TreeMap`,不需要排序时就选择 `HashMap`,需要保证线程安全就选用 `ConcurrentHashMap`。 -> 另外推荐一篇把双向链表讲清楚的文章:[https://juejin.im/post/5b5d1a9af265da0f47352f14](https://juejin.im/post/5b5d1a9af265da0f47352f14) +当我们只需要存放元素值时,就选择实现`Collection` 接口的集合,需要保证元素唯一时选择实现 `Set` 接口的集合比如 `TreeSet` 或 `HashSet`,不需要就选择实现 `List` 接口的比如 `ArrayList` 或 `LinkedList`,然后再根据实现这些接口的集合的特点来选用。 + +### 为什么要使用集合? + +当我们需要保存一组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使用数组存储对象具有一定的弊端, +因为我们在实际开发中,存储的数据的类型是多种多样的,于是,就出现了"集合",集合同样也是用来存储多个数据的。 + +数组的缺点是一旦声明之后,长度就不可变了;同时,声明数组时的数据类型也决定了该数组存储的数据的类型;而且,数组存储的数据是有序的、可重复的,特点单一。 +但是集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据。 + +### ArrayList 和 Vector 的区别? + +- `ArrayList` 是 `List` 的主要实现类,底层使用 `Object[ ]`存储,适用于频繁的查找工作,线程不安全 ; +- `Vector` 是 `List` 的古老实现类,底层使用`Object[ ]` 存储,线程安全的。 + +### ArrayList 与 LinkedList 区别? + +- **是否保证线程安全:** `ArrayList` 和 `LinkedList` 都是不同步的,也就是不保证线程安全; +- **底层数据结构:** `ArrayList` 底层使用的是 **`Object` 数组**;`LinkedList` 底层使用的是 **双向链表** 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) +- **插入和删除是否受元素位置的影响:** + - `ArrayList` 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行`add(E e)`方法的时候, `ArrayList` 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(`add(int index, E element)`)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 + - `LinkedList` 采用链表存储,所以,如果是在头尾插入或者删除元素不受元素位置的影响(`add(E e)`、`addFirst(E e)`、`addLast(E e)`、`removeFirst()` 、 `removeLast()`),时间复杂度为 O(1),如果是要在指定位置 `i` 插入和删除元素的话(`add(int index, E element)`,`remove(Object o)`), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。 +- **是否支持快速随机访问:** `LinkedList` 不支持高效的随机元素访问,而 `ArrayList` 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于`get(int index)`方法)。 +- **内存空间占用:** `ArrayList` 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 + +我们在项目中一般是不会使用到 `LinkedList` 的,需要用到 `LinkedList` 的场景几乎都可以使用 `ArrayList` 来代替,并且,性能通常会更好!就连 `LinkedList` 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 `LinkedList` 。 + +![](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/redisimage-20220412110853807.png) + +另外,不要下意识地认为 `LinkedList` 作为链表就最适合元素增删的场景。我在上面也说了,`LinkedList` 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n) 。 + +#### 补充内容:双向链表和双向循环链表 + +**双向链表:** 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。 ![双向链表](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/双向链表.png) @@ -34,7 +95,7 @@ ![双向循环链表](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/双向循环链表.png) -#### 2.2.2.2. 补充内容:RandomAccess 接口 +#### 补充内容:RandomAccess 接口 ```java public interface RandomAccess { @@ -43,7 +104,7 @@ public interface RandomAccess { 查看源码我们发现实际上 `RandomAccess` 接口中什么都没有定义。所以,在我看来 `RandomAccess` 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。 -在 `binarySearch()` 方法中,它要判断传入的 list 是否 `RamdomAccess` 的实例,如果是,调用`indexedBinarySearch()`方法,如果不是,那么调用`iteratorBinarySearch()`方法 +在 `binarySearch()` 方法中,它要判断传入的 list 是否 `RandomAccess` 的实例,如果是,调用`indexedBinarySearch()`方法,如果不是,那么调用`iteratorBinarySearch()`方法 ```java public static @@ -55,26 +116,216 @@ public interface RandomAccess { } ``` -`ArrayList` 实现了 `RandomAccess` 接口, 而 `LinkedList` 没有实现。为什么呢?我觉得还是和底层数据结构有关!`ArrayList` 底层是数组,而 `LinkedList` 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。`ArrayList` 实现了 `RandomAccess` 接口,就表明了他具有快速随机访问功能。 `RandomAccess` 接口只是标识,并不是说 `ArrayList` 实现 `RandomAccess` 接口才具有快速随机访问功能的! +`ArrayList` 实现了 `RandomAccess` 接口, 而 `LinkedList` 没有实现。为什么呢?我觉得还是和底层数据结构有关!`ArrayList` 底层是数组,而 `LinkedList` 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。,`ArrayList` 实现了 `RandomAccess` 接口,就表明了他具有快速随机访问功能。 `RandomAccess` 接口只是标识,并不是说 `ArrayList` 实现 `RandomAccess` 接口才具有快速随机访问功能的! -### 2.2.3. ArrayList 与 Vector 区别呢?为什么要用Arraylist取代Vector呢? +### 说一说 ArrayList 的扩容机制吧 -- `ArrayList` 是 `List` 的主要实现类,底层使用 `Object[ ]`存储,适用于频繁的查找工作,线程不安全 ; -- `Vector` 是 `List` 的古老实现类,底层使用` Object[ ]` 存储,线程安全的。 +详见笔主的这篇文章: [ArrayList 扩容机制分析](https://javaguide.cn/java/collection/arraylist-source-code.html#_3-1-%E5%85%88%E4%BB%8E-arraylist-%E7%9A%84%E6%9E%84%E9%80%A0%E5%87%BD%E6%95%B0%E8%AF%B4%E8%B5%B7) + +### comparable 和 Comparator 的区别 + +- `comparable` 接口实际上是出自`java.lang`包 它有一个 `compareTo(Object obj)`方法用来排序 +- `comparator`接口实际上是出自 java.util 包它有一个`compare(Object obj1, Object obj2)`方法用来排序 + +一般我们需要对一个集合使用自定义排序时,我们就要重写`compareTo()`方法或`compare()`方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写`compareTo()`方法和使用自制的`Comparator`方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 `Collections.sort()`. + +#### Comparator 定制排序 + +```java + ArrayList arrayList = new ArrayList(); + arrayList.add(-1); + arrayList.add(3); + arrayList.add(3); + arrayList.add(-5); + arrayList.add(7); + arrayList.add(4); + arrayList.add(-9); + arrayList.add(-7); + System.out.println("原始数组:"); + System.out.println(arrayList); + // void reverse(List list):反转 + Collections.reverse(arrayList); + System.out.println("Collections.reverse(arrayList):"); + System.out.println(arrayList); + + // void sort(List list),按自然排序的升序排序 + Collections.sort(arrayList); + System.out.println("Collections.sort(arrayList):"); + System.out.println(arrayList); + // 定制排序的用法 + Collections.sort(arrayList, new Comparator() { + + @Override + public int compare(Integer o1, Integer o2) { + return o2.compareTo(o1); + } + }); + System.out.println("定制排序后:"); + System.out.println(arrayList); +``` + +Output: + +``` +原始数组: +[-1, 3, 3, -5, 7, 4, -9, -7] +Collections.reverse(arrayList): +[-7, -9, 4, 7, -5, 3, 3, -1] +Collections.sort(arrayList): +[-9, -7, -5, -1, 3, 3, 4, 7] +定制排序后: +[7, 4, 3, 3, -1, -5, -7, -9] +``` + +#### 重写 compareTo 方法实现按年龄来排序 + +```java +// person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列 +// 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他 +// 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了 +public class Person implements Comparable { + private String name; + private int age; + + public Person(String name, int age) { + super(); + this.name = name; + this.age = age; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + /** + * T重写compareTo方法实现按年龄来排序 + */ + @Override + public int compareTo(Person o) { + if (this.age> o.getAge()) { + return 1; + } + if (this.age < o.getAge()) { + return -1; + } + return 0; + } +} + +``` + +```java + public static void main(String[] args) { + TreeMap pdata = new TreeMap(); + pdata.put(new Person("张三", 30), "zhangsan"); + pdata.put(new Person("李四", 20), "lisi"); + pdata.put(new Person("王五", 10), "wangwu"); + pdata.put(new Person("小红", 5), "xiaohong"); + // 得到key的值的同时得到key所对应的值 + Set keys = pdata.keySet(); + for (Person key : keys) { + System.out.println(key.getAge() + "-" + key.getName()); + + } + } +``` + +Output: + +``` +5-小红 +10-王五 +20-李四 +30-张三 +``` + +### 无序性和不可重复性的含义是什么 + +- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。 +- 不可重复性是指添加的元素按照 `equals()` 判断时 ,返回 false,需要同时重写 `equals()` 方法和 `hashCode()` 方法。 + +### 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同 + +- `HashSet`、`LinkedHashSet` 和 `TreeSet` 都是 `Set` 接口的实现类,都能保证元素唯一,并且都不是线程安全的。 +- `HashSet`、`LinkedHashSet` 和 `TreeSet` 的主要区别在于底层数据结构不同。`HashSet` 的底层数据结构是哈希表(基于 `HashMap` 实现)。`LinkedHashSet` 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。`TreeSet` 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。 +- 底层数据结构不同又导致这三者的应用场景不同。`HashSet` 用于不需要保证元素插入和取出顺序的场景,`LinkedHashSet` 用于保证元素的插入和取出顺序满足 FIFO 的场景,`TreeSet` 用于支持对元素自定义排序规则的场景。 + +### Queue 与 Deque 的区别 + +`Queue` 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 **先进先出(FIFO)** 规则。 -### 2.2.4. 说一说 ArrayList 的扩容机制吧 +`Queue` 扩展了 `Collection` 的接口,根据 **因为容量问题而导致操作失败后处理方式的不同** 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。 -[ArrayList 扩容机制分析](https://javaguide.cn/java/collection/arraylist-source-code/#_3-arraylist-%E6%89%A9%E5%AE%B9%E6%9C%BA%E5%88%B6%E5%88%86%E6%9E%90) +| `Queue` 接口 | 抛出异常 | 返回特殊值 | +| ------------ | --------- | ---------- | +| 插入队尾 | add(E e) | offer(E e) | +| 删除队首 | remove() | poll() | +| 查询队首元素 | element() | peek() | -### 2.2.5. HashMap 和 Hashtable 的区别 +`Deque` 是双端队列,在队列的两端均可以插入或删除元素。 -1. **线程是否安全:** `HashMap` 是非线程安全的,`HashTable` 是线程安全的,因为 `HashTable` 内部的方法基本都经过`synchronized` 修饰。(如果你要保证线程安全的话就使用 `ConcurrentHashMap` 吧!); -2. **效率:** 因为线程安全的问题,`HashMap` 要比 `HashTable` 效率高一点。另外,`HashTable` 基本被淘汰,不要在代码中使用它; -3. **对 Null key 和 Null value 的支持:** `HashMap` 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出 `NullPointerException`。 -4. **初始容量大小和每次扩充容量大小的不同 :** 1 创建时如果不指定容量初始值,`Hashtable` 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。`HashMap` 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。2 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 `HashMap` 会将其扩充为 2 的幂次方大小(`HashMap` 中的`tableSizeFor()`方法保证,下面给出了源代码)。也就是说 `HashMap` 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 -5. **底层数据结构:** JDK1.8 以后的 `HashMap` 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。 +`Deque` 扩展了 `Queue` 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类: -**HashMap 中带有初始容量的构造函数:** +| `Deque` 接口 | 抛出异常 | 返回特殊值 | +| ------------ | ------------- | --------------- | +| 插入队首 | addFirst(E e) | offerFirst(E e) | +| 插入队尾 | addLast(E e) | offerLast(E e) | +| 删除队首 | removeFirst() | pollFirst() | +| 删除队尾 | removeLast() | pollLast() | +| 查询队首元素 | getFirst() | peekFirst() | +| 查询队尾元素 | getLast() | peekLast() | + +事实上,`Deque` 还提供有 `push()` 和 `pop()` 等其他方法,可用于模拟栈。 + + +### ArrayDeque 与 LinkedList 的区别 + +`ArrayDeque` 和 `LinkedList` 都实现了 `Deque` 接口,两者都具有队列的功能,但两者有什么区别呢? + +- `ArrayDeque` 是基于可变长的数组和双指针来实现,而 `LinkedList` 则通过链表来实现。 + +- `ArrayDeque` 不支持存储 `NULL` 数据,但 `LinkedList` 支持。 + +- `ArrayDeque` 是在 JDK1.6 才被引入的,而`LinkedList` 早在 JDK1.2 时就已经存在。 + +- `ArrayDeque` 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 `LinkedList` 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。 + +从性能的角度上,选用 `ArrayDeque` 来实现队列要比 `LinkedList` 更好。此外,`ArrayDeque` 也可以用于实现栈。 + +### 说一说 PriorityQueue + +`PriorityQueue` 是在 JDK1.5 中被引入的, 其与 `Queue` 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。 + +这里列举其相关的一些要点: + +- `PriorityQueue` 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据 +- `PriorityQueue` 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。 +- `PriorityQueue` 是非线程安全的,且不支持存储 `NULL` 和 `non-comparable` 的对象。 +- `PriorityQueue` 默认是小顶堆,但可以接收一个 `Comparator` 作为构造参数,从而来自定义元素优先级的先后。 + +`PriorityQueue` 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第K大的数、带权图的遍历等,所以需要会熟练使用才行。 + +### HashMap 和 Hashtable 的区别 + +- **线程是否安全:** `HashMap` 是非线程安全的,`Hashtable` 是线程安全的,因为 `Hashtable` 内部的方法基本都经过`synchronized` 修饰。(如果你要保证线程安全的话就使用 `ConcurrentHashMap` 吧!); +- **效率:** 因为线程安全的问题,`HashMap` 要比 `Hashtable` 效率高一点。另外,`Hashtable` 基本被淘汰,不要在代码中使用它; +- **对 Null key 和 Null value 的支持:** `HashMap` 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 `NullPointerException`。 +- **初始容量大小和每次扩充容量大小的不同 :** 1 创建时如果不指定容量初始值,`Hashtable` 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。`HashMap` 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。2 创建时如果给定了容量初始值,那么 `Hashtable` 会直接使用你给定的大小,而 `HashMap` 会将其扩充为 2 的幂次方大小(`HashMap` 中的`tableSizeFor()`方法保证,下面给出了源代码)。也就是说 `HashMap` 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 +- **底层数据结构:** JDK1.8 以后的 `HashMap` 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。`Hashtable` 没有这样的机制。 + +**`HashMap` 中带有初始容量的构造函数:** ```java public HashMap(int initialCapacity, float loadFactor) { @@ -94,7 +345,7 @@ public interface RandomAccess { } ``` -下面这个方法保证了 HashMap 总是使用2的幂作为哈希表的大小。 +下面这个方法保证了 `HashMap` 总是使用 2 的幂作为哈希表的大小。 ```java /** @@ -111,7 +362,7 @@ public interface RandomAccess { } ``` -### 2.2.6. HashMap 和 HashSet区别 +### HashMap 和 HashSet 区别 如果你看过 `HashSet` 源码的话就应该知道:`HashSet` 底层就是基于 `HashMap` 实现的。(`HashSet` 的源码非常非常少,因为除了 `clone()`、`writeObject()`、`readObject()`是 `HashSet` 自己不得不实现之外,其他方法都是直接调用 `HashMap` 中的方法。 @@ -120,76 +371,112 @@ public interface RandomAccess { | 实现了 `Map` 接口 | 实现 `Set` 接口 | | 存储键值对 | 仅存储对象 | | 调用 `put()`向 map 中添加元素 | 调用 `add()`方法向 `Set` 中添加元素 | -| `HashMap` 使用键(Key)计算 `hashcode` | `HashSet` 使用成员对象来计算 `hashcode` 值,对于两个对象来说 `hashcode` 可能相同,所以` equals()`方法用来判断对象的相等性 | - -### 2.2.7. HashSet如何检查重复 +| `HashMap` 使用键(Key)计算 `hashcode` | `HashSet` 使用成员对象来计算 `hashcode` 值,对于两个对象来说 `hashcode` 可能相同,所以`equals()`方法用来判断对象的相等性 | -以下内容摘自我的 Java 启蒙书《Head first java》第二版: +### HashMap 和 TreeMap 区别 -当你把对象加入`HashSet`时,`HashSet` 会先计算对象的`hashcode`值来判断对象加入的位置,同时也会与其他加入的对象的 `hashcode` 值作比较,如果没有相符的 `hashcode`,`HashSet` 会假设对象没有重复出现。但是如果发现有相同 `hashcode` 值的对象,这时会调用`equals()`方法来检查 `hashcode` 相等的对象是否真的相同。如果两者相同,`HashSet` 就不会让加入操作成功。 +`TreeMap` 和`HashMap` 都继承自`AbstractMap` ,但是需要注意的是`TreeMap`它还实现了`NavigableMap`接口和`SortedMap` 接口。 -**`hashCode()`与 `equals()` 的相关规定:** +![TreeMap 继承关系图](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/treemap_hierarchy.png) -1. 如果两个对象相等,则 `hashcode` 一定也是相同的 -2. 两个对象相等,对两个 `equals()` 方法返回 true -3. 两个对象有相同的 `hashcode` 值,它们也不一定是相等的 +实现 `NavigableMap` 接口让 `TreeMap` 有了对集合内元素的搜索的能力。 -综上,如果一个类的 `equals()` 方法被覆盖过,则 `hashCode()` 方法也必须被覆盖。 - -` hashCode()` 的默认行为是对堆上的对象产生独特值。如果没有重写 `hashCode() `,即使通过 `equals()` 判断为相同的两个对象,在加入 `HashSet` 时,也不会被 `HashSet` 认为是重复对象。 +实现`SortedMap`接口让 `TreeMap` 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下: ```java -import java.util.HashSet; - -public class People { - String idCard; - - public People(String idCard) { - this.idCard = idCard; +/** + * @author shuang.kou + * @createTime 2020年06月15日 17:02:00 + */ +public class Person { + private Integer age; + + public Person(Integer age) { + this.age = age; } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - People people = (People) o; - return idCard.equals(people.idCard); + public Integer getAge() { + return age; } + public static void main(String[] args) { - People a = new People("a"); - People a1 = new People("a"); - // output: true - System.out.println(a.equals(a1)); - - HashSet set = new HashSet(); - set.add(a); - set.add(a1); - // output: 2 - System.out.println(set.size()); + TreeMap treeMap = new TreeMap(new Comparator() { + @Override + public int compare(Person person1, Person person2) { + int num = person1.getAge() - person2.getAge(); + return Integer.compare(num, 0); + } + }); + treeMap.put(new Person(3), "person1"); + treeMap.put(new Person(18), "person2"); + treeMap.put(new Person(35), "person3"); + treeMap.put(new Person(16), "person4"); + treeMap.entrySet().stream().forEach(personStringEntry -> { + System.out.println(personStringEntry.getValue()); + }); } } ``` -**==与 equals 的区别** +输出: -对于基本类型来说,== 比较的是值是否相等; +``` +person1 +person4 +person2 +person3 +``` -对于引用类型来说,== 比较的是两个引用是否指向同一个对象地址(两者在内存中存放的地址(堆内存地址)是否指向同一个地方); +可以看出,`TreeMap` 中的元素已经是按照 `Person` 的 age 字段的升序来排列了。 -对于引用类型(包括包装类型)来说,equals 如果没有被重写,对比它们的地址是否相等;如果 equals()方法被重写(例如 String),则比较的是地址里的内容。 +上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式: -> 作者:Guide哥。 -> -> **介绍:** Github 90k Star 项目 **[JavaGuide](https://github.com/Snailclimb/JavaGuide)**(公众号同名) 作者。每周都会在公众号更新一些自己原创干货。公众号后台回复"1"领取Java工程师必备学习资料+面试突击pdf。 +```java +TreeMap treeMap = new TreeMap((person1, person2) -> { + int num = person1.getAge() - person2.getAge(); + return Integer.compare(num, 0); +}); +``` + +**综上,相比于`HashMap`来说 `TreeMap` 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。** + +### HashSet 如何检查重复? + +以下内容摘自我的 Java 启蒙书《Head first java》第二版: -### 2.2.8. HashMap的底层实现 +> 当你把对象加入`HashSet`时,`HashSet` 会先计算对象的`hashcode`值来判断对象加入的位置,同时也会与其他加入的对象的 `hashcode` 值作比较,如果没有相符的 `hashcode`,`HashSet` 会假设对象没有重复出现。但是如果发现有相同 `hashcode` 值的对象,这时会调用`equals()`方法来检查 `hashcode` 相等的对象是否真的相同。如果两者相同,`HashSet` 就不会让加入操作成功。 + +在 JDK1.8 中,`HashSet`的`add()`方法只是简单的调用了`HashMap`的`put()`方法,并且判断了一下返回值以确保是否有重复元素。直接看一下`HashSet`中的源码: + +```java +// Returns: true if this set did not already contain the specified element +// 返回值:当 set 中没有包含 add 的元素时返回真 +public boolean add(E e) { + return map.put(e, PRESENT)==null; +} +``` + +而在`HashMap`的`putVal()`方法中也能看到如下说明: + +```java +// Returns : previous value, or null if none +// 返回值:如果插入位置没有元素返回null,否则返回上一个元素 +final V putVal(int hash, K key, V value, boolean onlyIfAbsent, + boolean evict) { +... +} +``` -#### 2.2.8.1. JDK1.8 之前 +也就是说,在 JDK1.8 中,实际上无论`HashSet`中是否已经存在了某元素,`HashSet`都会直接插入,只是会在`add()`方法的返回值处告诉我们插入前是否存在相同元素。 -JDK1.8 之前 `HashMap` 底层是 **数组和链表** 结合在一起使用也就是 **链表散列**。**HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。** +### HashMap 的底层实现 -**所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。** +#### JDK1.8 之前 + +JDK1.8 之前 `HashMap` 底层是 **数组和链表** 结合在一起使用也就是 **链表散列**。HashMap 通过 key 的 `hashcode` 经过扰动函数处理过后得到 hash 值,然后通过 `(n - 1) & hash` 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。 + +所谓扰动函数指的就是 HashMap 的 `hash` 方法。使用 `hash` 方法也就是扰动函数是为了防止一些实现比较差的 `hashCode()` 方法 换句话说使用扰动函数之后可以减少碰撞。 **JDK 1.8 HashMap 的 hash 方法源码:** @@ -222,122 +509,171 @@ static int hash(int h) { 所谓 **"拉链法"** 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。 -![jdk1.8之前的内部结构-HashMap](images/jdk1.8之前的内部结构-HashMap.png) +![jdk1.8 之前的内部结构-HashMap](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/jdk1.7_hashmap.png) -#### 2.2.8.2. JDK1.8 之后 +#### JDK1.8 之后 相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。 -![jdk1.8之后的内部结构-HashMap](images/jdk1.8之后的内部结构-HashMap.png) +![jdk1.8之后的内部结构-HashMap](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/jdk1.8_hashmap.png) > TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。 -### 2.2.9. HashMap 的长度为什么是2的幂次方 +我们来结合源码分析一下 `HashMap` 链表到红黑树的转换。 -为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648到2147483647,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是" `(n - 1) & hash`"。(n代表数组长度)。这也就解释了 HashMap 的长度为什么是2的幂次方。 +**1、 `putVal` 方法中执行链表转红黑树的判断逻辑。** -**这个算法应该如何设计呢?** +链表的长度大于 8 的时候,就执行 `treeifyBin` (转换红黑树)的逻辑。 -我们首先可能会想到采用%取余的操作来实现。但是,重点来了:**"取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。"** 并且 **采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。** +```java +// 遍历链表 +for (int binCount = 0; ; ++binCount) { + // 遍历到链表最后一个节点 + if ((e = p.next) == null) { + p.next = newNode(hash, key, value, null); + // 如果链表元素个数大于等于TREEIFY_THRESHOLD(8) + if (binCount>= TREEIFY_THRESHOLD - 1) // -1 for 1st + // 红黑树转换(并不会直接转换成红黑树) + treeifyBin(tab, hash); + break; + } + if (e.hash == hash && + ((k = e.key) == key || (key != null && key.equals(k)))) + break; + p = e; +} +``` -### 2.2.10. HashMap 多线程操作导致死循环问题 +**2、`treeifyBin` 方法中判断是否真的转换为红黑树。** -主要原因在于 并发下的Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。 +```java +final void treeifyBin(Node[] tab, int hash) { + int n, index; Node e; + // 判断当前数组的长度是否小于 64 + if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) + // 如果当前数组的长度小于 64,那么会选择先进行数组扩容 + resize(); + else if ((e = tab[index = (n - 1) & hash]) != null) { + // 否则才将列表转换为红黑树 + + TreeNode hd = null, tl = null; + do { + TreeNode p = replacementTreeNode(e, null); + if (tl == null) + hd = p; + else { + p.prev = tl; + tl.next = p; + } + tl = p; + } while ((e = e.next) != null); + if ((tab[index] = hd) != null) + hd.treeify(tab); + } +} +``` -详情请查看: +将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。 -### 2.2.11. ConcurrentHashMap 和 Hashtable 的区别 +### HashMap 的长度为什么是 2 的幂次方 -`ConcurrentHashMap` 和 `Hashtable` 的区别主要体现在实现线程安全的方式上不同。 +为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是" `(n - 1) & hash`"。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。 -- **底层数据结构:** JDK1.7 的 `ConcurrentHashMap` 底层采用 **分段的数组+链表** 实现,JDK1.8 采用的数据结构跟 `HashMap1.8` 的结构一样,数组+链表/红黑二叉树。`Hashtable` 和 JDK1.8 之前的 `HashMap` 的底层数据结构类似都是采用 **数组+链表** 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; -- **实现线程安全的方式(重要):** 1 **在 JDK1.7 的时候,`ConcurrentHashMap`(分段锁)** 对整个桶数组进行了分割分段(`Segment`),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 **到了 JDK1.8 的时候已经摒弃了 `Segment` 的概念,而是直接用 `Node` 数组+链表+红黑树的数据结构来实现,并发控制使用 `synchronized` 和 CAS 来操作。(JDK1.6 以后 对 `synchronized` 锁做了很多优化)** 整个看起来就像是优化过且线程安全的 `HashMap`,虽然在 JDK1.8 中还能看到 `Segment` 的数据结构,但是已经简化了属性,只是为了兼容旧版本;2 **`Hashtable`(同一把锁)** :使用 `synchronized` 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。 +**这个算法应该如何设计呢?** -**两者的对比图:** +我们首先可能会想到采用%取余的操作来实现。但是,重点来了:**"取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。"** 并且 **采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。** -**HashTable:** +### HashMap 多线程操作导致死循环问题 -![HashTable全表锁](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/HashTable全表锁.png) +主要原因在于并发下的 Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。 -

http://www.cnblogs.com/chengxiao/p/6842045.html>

+详情请查看: -**JDK1.7 的 ConcurrentHashMap:** +### HashMap 有哪几种常见的遍历方式? -![JDK1.7的ConcurrentHashMap](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/ConcurrentHashMap分段锁.jpg) +[HashMap 的 7 种遍历方式与性能分析!](https://mp.weixin.qq.com/s/zQBN3UvJDhRTKP6SzcZFKw) -

http://www.cnblogs.com/chengxiao/p/6842045.html>

+### ConcurrentHashMap 和 Hashtable 的区别 -**JDK1.8 的 ConcurrentHashMap:** +`ConcurrentHashMap` 和 `Hashtable` 的区别主要体现在实现线程安全的方式上不同。 -![Java8 ConcurrentHashMap 存储结构(图片来自 javadoop)](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/source-code/dubbo/java8_concurrenthashmap.png) +- **底层数据结构:** JDK1.7 的 `ConcurrentHashMap` 底层采用 **分段的数组+链表** 实现,JDK1.8 采用的数据结构跟 `HashMap1.8` 的结构一样,数组+链表/红黑二叉树。`Hashtable` 和 JDK1.8 之前的 `HashMap` 的底层数据结构类似都是采用 **数组+链表** 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; +- **实现线程安全的方式(重要):** + - 在 JDK1.7 的时候,`ConcurrentHashMap` 对整个桶数组进行了分割分段(`Segment`,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 + - 到了 JDK1.8 的时候,`ConcurrentHashMap` 已经摒弃了 `Segment` 的概念,而是直接用 `Node` 数组+链表+红黑树的数据结构来实现,并发控制使用 `synchronized` 和 CAS 来操作。(JDK1.6 以后 `synchronized` 锁做了很多优化) 整个看起来就像是优化过且线程安全的 `HashMap`,虽然在 JDK1.8 中还能看到 `Segment` 的数据结构,但是已经简化了属性,只是为了兼容旧版本; + - **`Hashtable`(同一把锁)** :使用 `synchronized` 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。 -JDK1.8 的 `ConcurrentHashMap` 不在是 **Segment 数组 + HashEntry 数组 + 链表**,而是 **Node 数组 + 链表 / 红黑树**。不过,Node 只能用于链表的情况,红黑树的情况需要使用 **`TreeNode`**。当冲突链表达到一定长度时,链表会转换成红黑树。 +下面,我们再来看看两者底层数据结构的对比图。 -### 2.2.12. ConcurrentHashMap线程安全的具体实现方式/底层具体实现 +**Hashtable** : -#### 2.2.12.1. JDK1.7(上面有示意图) +![Hashtable 的内部结构](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/jdk1.7_hashmap.png) -首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 +

https://www.cnblogs.com/chengxiao/p/6842045.html>

-**`ConcurrentHashMap` 是由 `Segment` 数组结构和 `HashEntry` 数组结构组成**。 +**JDK1.7 的 ConcurrentHashMap** : -Segment 实现了 `ReentrantLock`,所以 `Segment` 是一种可重入锁,扮演锁的角色。`HashEntry` 用于存储键值对数据。 +![Java7 ConcurrentHashMap 存储结构](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/java7_concurrenthashmap.png) -```java -static class Segment extends ReentrantLock implements Serializable { -} -``` - -一个 `ConcurrentHashMap` 里包含一个 `Segment` 数组。`Segment` 的结构和 `HashMap` 类似,是一种数组和链表结构,一个 `Segment` 包含一个 `HashEntry` 数组,每个 `HashEntry` 是一个链表结构的元素,每个 `Segment` 守护着一个 `HashEntry` 数组里的元素,当对 `HashEntry` 数组的数据进行修改时,必须首先获得对应的 `Segment` 的锁。 +`ConcurrentHashMap` 是由 `Segment` 数组结构和 `HashEntry` 数组结构组成。 -#### 2.2.12.2. JDK1.8 (上面有示意图) +`Segment` 数组中的每个元素包含一个 `HashEntry` 数组,每个 `HashEntry` 数组属于链表结构。 -`ConcurrentHashMap` 取消了 `Segment` 分段锁,采用 CAS 和 `synchronized` 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N))) +**JDK1.8 的 ConcurrentHashMap** : -`synchronized` 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。 +![Java8 ConcurrentHashMap 存储结构](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/java8_concurrenthashmap.png) -### 2.2.13. 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同 +JDK1.8 的 `ConcurrentHashMap` 不再是 **Segment 数组 + HashEntry 数组 + 链表**,而是 **Node 数组 + 链表 / 红黑树**。不过,Node 只能用于链表的情况,红黑树的情况需要使用 **`TreeNode`**。当冲突链表达到一定长度时,链表会转换成红黑树。 -`HashSet` 是 `Set` 接口的主要实现类 ,`HashSet` 的底层是 `HashMap`,线程不安全的,可以存储 null 值; +`TreeNode`是存储红黑树节点,被`TreeBin`包装。`TreeBin`通过`root`属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 `ConcurrentHashMap` 中`TreeBin`通过`waiter`属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。 -`LinkedHashSet` 是 `HashSet` 的子类,能够按照添加的顺序遍历; - -`TreeSet` 底层使用红黑树,能够按照添加元素的顺序进行遍历,排序的方式有自然排序和定制排序。 +```java +static final class TreeBin extends Node { + TreeNode root; + volatile TreeNode first; + volatile Thread waiter; + volatile int lockState; + // values for lockState + static final int WRITER = 1; // set while holding write lock + static final int WAITER = 2; // set when waiting for write lock + static final int READER = 4; // increment value for setting read lock +... +} +``` -### 2.2.14. 集合框架底层数据结构总结 +### ConcurrentHashMap 线程安全的具体实现方式/底层具体实现 -先来看一下 `Collection` 接口下面的集合。 +#### JDK1.8 之前 -#### 2.2.14.1. List +![Java7 ConcurrentHashMap 存储结构](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/java7_concurrenthashmap.png) -- `Arraylist`: `Object[]`数组 -- `Vector`:`Object[]`数组 -- `LinkedList`: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环) +首先将数据分为一段一段(这个"段"就是 `Segment`)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 -#### 2.2.14.2. Set +**`ConcurrentHashMap` 是由 `Segment` 数组结构和 `HashEntry` 数组结构组成**。 -- `HashSet`(无序,唯一): 基于 `HashMap` 实现的,底层采用 `HashMap` 来保存元素 -- `LinkedHashSet`:`LinkedHashSet` 是 `HashSet` 的子类,并且其内部是通过 `LinkedHashMap` 来实现的。有点类似于我们之前说的 `LinkedHashMap` 其内部是基于 `HashMap` 实现一样,不过还是有一点点区别的 -- `TreeSet`(有序,唯一): 红黑树(自平衡的排序二叉树) +`Segment` 继承了 `ReentrantLock`,所以 `Segment` 是一种可重入锁,扮演锁的角色。`HashEntry` 用于存储键值对数据。 -再来看看 `Map` 接口下面的集合。 +```java +static class Segment extends ReentrantLock implements Serializable { +} +``` -#### 2.2.14.3. Map +一个 `ConcurrentHashMap` 里包含一个 `Segment` 数组,`Segment` 的个数一旦**初始化就不能改变**。 `Segment` 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。 -- `HashMap`: JDK1.8 之前 `HashMap` 由数组+链表组成的,数组是 `HashMap` 的主体,链表则是主要为了解决哈希冲突而存在的("拉链法"解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间 -- `LinkedHashMap`: `LinkedHashMap` 继承自 `HashMap`,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,`LinkedHashMap` 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:[《LinkedHashMap 源码详细分析(JDK1.8)》](https://www.imooc.com/article/22931) -- `Hashtable`: 数组+链表组成的,数组是 `HashMap` 的主体,链表则是主要为了解决哈希冲突而存在的 -- `TreeMap`: 红黑树(自平衡的排序二叉树) +`Segment` 的结构和 `HashMap` 类似,是一种数组和链表结构,一个 `Segment` 包含一个 `HashEntry` 数组,每个 `HashEntry` 是一个链表结构的元素,每个 `Segment` 守护着一个 `HashEntry` 数组里的元素,当对 `HashEntry` 数组的数据进行修改时,必须首先获得对应的 `Segment` 的锁。也就是说,对同一 `Segment` 的并发写入会被阻塞,不同 `Segment` 的写入是可以并发执行的。 -### 2.2.15. 如何选用集合? +#### JDK1.8 之后 -主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用 `Map` 接口下的集合,需要排序时选择 `TreeMap`,不需要排序时就选择 `HashMap`,需要保证线程安全就选用 `ConcurrentHashMap`。 +![Java8 ConcurrentHashMap 存储结构](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/github/javaguide/java/collection/java8_concurrenthashmap.png) -当我们只需要存放元素值时,就选择实现`Collection` 接口的集合,需要保证元素唯一时选择实现 `Set` 接口的集合比如 `TreeSet` 或 `HashSet`,不需要就选择实现 `List` 接口的比如 `ArrayList` 或 `LinkedList`,然后再根据实现这些接口的集合的特点来选用。 +Java 8 几乎完全重写了 `ConcurrentHashMap`,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。 -如果大家想要实时关注我更新的文章以及分享的干货的话,可以关注我的公众号。 +`ConcurrentHashMap` 取消了 `Segment` 分段锁,采用 `Node + CAS + synchronized` 来保证并发安全。数据结构跟 `HashMap` 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。 -**《JavaGuide 面试突击版》 :** 由本文档衍生的专为面试而生的《JavaGuide 面试突击版》 版本[公众号](#公众号)后台回复 **"Java 面试突击"** 即可免费领取! +Java 8 中,锁粒度更细,`synchronized` 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。 -![我的公众号](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/167598cd2e17b8ec.png) +### JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同? +- **线程安全实现方式** :JDK 1.7 采用 `Segment` 分段锁来保证安全, `Segment` 是继承自 `ReentrantLock`。JDK1.8 放弃了 `Segment` 分段锁的设计,采用 `Node + CAS + synchronized` 保证线程安全,锁粒度更细,`synchronized` 只锁定当前链表或红黑二叉树的首节点。 +- **Hash 碰撞解决方法** : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。 +- **并发度** :JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。 diff --git "a/docs/b-3Java345円244円232円347円272円277円347円250円213円.md" "b/docs/b-3Java345円244円232円347円272円277円347円250円213円.md" index ce3bcb6..f29cf1c 100755 --- "a/docs/b-3Java345円244円232円347円272円277円347円250円213円.md" +++ "b/docs/b-3Java345円244円232円347円272円277円347円250円213円.md" @@ -4,999 +4,3 @@ ## 2.3. 多线程 -> 作者:Guide 哥。 -> -> **介绍:** Github 90k Star 项目 **[JavaGuide](https://github.com/Snailclimb/JavaGuide)**(公众号同名) 作者。每周都会在公众号更新一些自己原创干货。公众号后台回复"1"领取 Java 工程师必备学习资料+面试突击 pdf。 - -### 2.3.1. 什么是线程和进程? - -#### 2.3.1.1. 何为进程? - -进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。 - -在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。 - -如下图所示,在 windows 中通过查看任务管理器的方式,我们就可以清楚看到 window 当前运行的进程(.exe 文件的运行)。 - -![进程示例图片-Windows](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/进程示例图片-Windows.png) - -#### 2.3.1.2. 何为线程? - -线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的**堆**和**方法区**资源,但每个线程有自己的**程序计数器**、**虚拟机栈**和**本地方法栈**,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。 - -Java 程序天生就是多线程程序,我们可以通过 JMX 来看一下一个普通的 Java 程序有哪些线程,代码如下。 - -```java -public class MultiThread { - public static void main(String[] args) { - // 获取 Java 线程管理 MXBean - ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); - // 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息 - ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false); - // 遍历线程信息,仅打印线程 ID 和线程名称信息 - for (ThreadInfo threadInfo : threadInfos) { - System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName()); - } - } -} -``` - -上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可): - -``` -[5] Attach Listener //添加事件 -[4] Signal Dispatcher // 分发处理给 JVM 信号的线程 -[3] Finalizer //调用对象 finalize 方法的线程 -[2] Reference Handler //清除 reference 线程 -[1] main //main 线程,程序入口 -``` - -从上面的输出内容可以看出:**一个 Java 程序的运行是 main 线程和多个其他线程同时运行**。 - -### 2.3.2. 请简要描述线程与进程的关系,区别及优缺点? - -**从 JVM 角度说进程和线程之间的关系** - -#### 2.3.2.1. 图解进程和线程的关系 - -下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。如果你对 Java 内存区域 (运行时数据区) 这部分知识不太了解的话可以阅读一下这篇文章:[《可能是把 Java 内存区域讲的最清楚的一篇文章》](https://github.com/Snailclimb/JavaGuide/blob/3965c02cc0f294b0bd3580df4868d5e396959e2e/Java%E7%9B%B8%E5%85%B3/%E5%8F%AF%E8%83%BD%E6%98%AF%E6%8A%8AJava%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F%E8%AE%B2%E7%9A%84%E6%9C%80%E6%B8%85%E6%A5%9A%E7%9A%84%E4%B8%80%E7%AF%87%E6%96%87%E7%AB%A0.md "《可能是把 Java 内存区域讲的最清楚的一篇文章》") - -
- -
- -从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的**堆**和**方法区 (JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器**、**虚拟机栈** 和 **本地方法栈**。 - -**总结:** 线程 是 进程 划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反 - -下面是该知识点的扩展内容! - -下面来思考这样一个问题:为什么**程序计数器**、**虚拟机栈**和**本地方法栈**是线程私有的呢?为什么堆和方法区是线程共享的呢? - -#### 2.3.2.2. 程序计数器为什么是私有的? - -程序计数器主要有下面两个作用: - -1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。 -2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。 - -需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。 - -所以,程序计数器私有主要是为了**线程切换后能恢复到正确的执行位置**。 - -#### 2.3.2.3. 虚拟机栈和本地方法栈为什么是私有的? - -- **虚拟机栈:** 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。 -- **本地方法栈:** 和虚拟机栈所发挥的作用非常相似,区别是: **虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。** 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。 - -所以,为了**保证线程中的局部变量不被别的线程访问到**,虚拟机栈和本地方法栈是线程私有的。 - -#### 2.3.2.4. 一句话简单了解堆和方法区 - -堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 - -### 2.3.3. 说说并发与并行的区别? - -- **并发:** 同一时间段,多个任务都在执行 (单位时间内不一定同时执行); -- **并行:** 单位时间内,多个任务同时执行。 - -### 2.3.4. 为什么要使用多线程呢? - -先从总体上来说: - -- **从计算机底层来说:** 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。 -- **从当代互联网发展趋势来说:** 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。 - -再深入到计算机底层来探讨: - -- **单核时代:** 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。 -- **多核时代:** 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。 - -### 2.3.5. 使用多线程可能带来什么问题? - -并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:**内存泄漏**、**上下文切换**、**死锁** 。 - -### 2.3.6. 说说线程的生命周期和状态? - -Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态(图源《Java 并发编程艺术》4.1.4 节)。 - -![Java 线程的状态 ](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/19-1-29/Java%E7%BA%BF%E7%A8%8B%E7%9A%84%E7%8A%B6%E6%80%81.png) - -线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图所示(图源《Java 并发编程艺术》4.1.4 节): - -![Java 线程状态变迁 ](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/19-1-29/Java+%E7%BA%BF%E7%A8%8B%E7%8A%B6%E6%80%81%E5%8F%98%E8%BF%81.png) - -由上图可以看出:线程创建之后它将处于 **NEW(新建)** 状态,调用 `start()` 方法后开始运行,线程这时候处于 **READY(可运行)** 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 **RUNNING(运行)** 状态。 - -> 操作系统隐藏 Java 虚拟机(JVM)中的 RUNNABLE 和 RUNNING 状态,它只能看到 RUNNABLE 状态(图源:[HowToDoInJava](https://howtodoinjava.com/ "HowToDoInJava"):[Java Thread Life Cycle and Thread States](https://howtodoinjava.com/java/multi-threading/java-thread-life-cycle-and-thread-states/ "Java Thread Life Cycle and Thread States")),所以 Java 系统一般将这两个状态统称为 **RUNNABLE(运行中)** 状态 。 - -![RUNNABLE-VS-RUNNING](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-3/RUNNABLE-VS-RUNNING.png) - -当线程执行 `wait()`方法之后,线程进入 **WAITING(等待)** 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 **TIME_WAITING(超时等待)** 状态相当于在等待状态的基础上增加了超时限制,比如通过 `sleep(long millis)`方法或 `wait(long millis)`方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 **BLOCKED(阻塞)** 状态。线程在执行 Runnable 的`run()`方法之后将会进入到 **TERMINATED(终止)** 状态。 - -### 2.3.7. 什么是上下文切换? - -多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。 - -概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。**任务从保存到再加载的过程就是一次上下文切换**。 - -上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。 - -Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。 - -### 2.3.8. 什么是线程死锁?如何避免死锁? - -#### 2.3.8.1. 认识线程死锁 - -线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。 - -如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。 - -![线程死锁示意图 ](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-4/2019-4%E6%AD%BB%E9%94%811.png) - -下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》): - -```java -public class DeadLockDemo { - private static Object resource1 = new Object();//资源 1 - private static Object resource2 = new Object();//资源 2 - - public static void main(String[] args) { - new Thread(() -> { - synchronized (resource1) { - System.out.println(Thread.currentThread() + "get resource1"); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - System.out.println(Thread.currentThread() + "waiting get resource2"); - synchronized (resource2) { - System.out.println(Thread.currentThread() + "get resource2"); - } - } - }, "线程 1").start(); - - new Thread(() -> { - synchronized (resource2) { - System.out.println(Thread.currentThread() + "get resource2"); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - System.out.println(Thread.currentThread() + "waiting get resource1"); - synchronized (resource1) { - System.out.println(Thread.currentThread() + "get resource1"); - } - } - }, "线程 2").start(); - } -} -``` - -Output - -``` -Thread[线程 1,5,main]get resource1 -Thread[线程 2,5,main]get resource2 -Thread[线程 1,5,main]waiting get resource2 -Thread[线程 2,5,main]waiting get resource1 -``` - -线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过`Thread.sleep(1000);`让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。 - -学过操作系统的朋友都知道产生死锁必须具备以下四个条件: - -1. 互斥条件:该资源任意一个时刻只由一个线程占用。 -2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。 -3. 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。 -4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。 - -#### 2.3.8.2. 如何避免线程死锁? - -我上面说了产生死锁的四个必要条件,为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下: - -1. **破坏互斥条件** :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。 -2. **破坏请求与保持条件** :一次性申请所有的资源。 -3. **破坏不剥夺条件** :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。 -4. **破坏循环等待条件** :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。 - -我们对线程 2 的代码修改成下面这样就不会产生死锁了。 - -```java - new Thread(() -> { - synchronized (resource1) { - System.out.println(Thread.currentThread() + "get resource1"); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - System.out.println(Thread.currentThread() + "waiting get resource2"); - synchronized (resource2) { - System.out.println(Thread.currentThread() + "get resource2"); - } - } - }, "线程 2").start(); -``` - -Output - -``` -Thread[线程 1,5,main]get resource1 -Thread[线程 1,5,main]waiting get resource2 -Thread[线程 1,5,main]get resource2 -Thread[线程 2,5,main]get resource1 -Thread[线程 2,5,main]waiting get resource2 -Thread[线程 2,5,main]get resource2 - -Process finished with exit code 0 -``` - -**我们分析一下上面的代码为什么避免了死锁的发生?** - -线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。 - -### 2.3.9. 说说 sleep() 方法和 wait() 方法区别和共同点? - -- 两者最主要的区别在于:**`sleep()` 方法没有释放锁,而 `wait()` 方法释放了锁** 。 -- 两者都可以暂停线程的执行。 -- `wait()` 通常被用于线程间交互/通信,`sleep()`通常被用于暂停执行。 -- `wait()` 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 `notify()`或者 `notifyAll()` 方法。`sleep()`方法执行完成后,线程会自动苏醒。或者可以使用 `wait(long timeout)` 超时后线程会自动苏醒。 - -### 2.3.10. 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法? - -这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来! - -new 一个 Thread,线程进入了新建状态。调用 `start()`方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 `start()` 会执行线程的相应准备工作,然后自动执行 `run()` 方法的内容,这是真正的多线程工作。 但是,直接执行 `run()` 方法,会把 `run()` 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。 - -**总结: 调用 `start()` 方法方可启动线程并使线程进入就绪状态,直接执行 `run()` 方法的话不会以多线程的方式执行。** - -![](images/synchronized/synchronized关键字.png) - -### 2.3.11. 说一说自己对于 synchronized 关键字的了解 - -**`synchronized` 关键字解决的是多个线程之间访问资源的同步性,`synchronized`关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。** - -另外,在 Java 早期版本中,`synchronized` 属于 **重量级锁**,效率低下。 - -**为什么呢?** - -因为监视器锁(monitor)是依赖于底层的操作系统的 `Mutex Lock` 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。 - -庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 `synchronized` 较大优化,所以现在的 `synchronized` 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。 - -所以,你会发现目前的话,不论是各种开源框架还是 JDK 源码都大量使用了 `synchronized` 关键字。 - -### 2.3.12. 说说自己是怎么使用 synchronized 关键字 - -**synchronized 关键字最主要的三种使用方式:** - -**1.修饰实例方法:** 作用于当前对象实例加锁,进入同步代码前要获得 **当前对象实例的锁** - -```java -synchronized void method() { - //业务代码 -} -``` - -**2.修饰静态方法:** 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 **当前 class 的锁**。因为静态成员不属于任何一个实例对象,是类成员( _static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份_)。所以,如果一个线程 A 调用一个实例对象的非静态 `synchronized` 方法,而线程 B 需要调用这个实例对象所属类的静态 `synchronized` 方法,是允许的,不会发生互斥现象,**因为访问静态 `synchronized` 方法占用的锁是当前类的锁,而访问非静态 `synchronized` 方法占用的锁是当前实例对象锁**。 - -```java -synchronized staic void method() { - //业务代码 -} -``` - -**3.修饰代码块** :指定加锁对象,对给定对象/类加锁。`synchronized(this|object)` 表示进入同步代码库前要获得**给定对象的锁**。`synchronized(类.class)` 表示进入同步代码前要获得 **当前 class 的锁** - -```java -synchronized(this) { - //业务代码 -} -``` - -**总结:** - -- `synchronized` 关键字加到 `static` 静态方法和 `synchronized(class)` 代码块上都是是给 Class 类上锁。 -- `synchronized` 关键字加到实例方法上是给对象实例上锁。 -- 尽量不要使用 `synchronized(String a)` 因为 JVM 中,字符串常量池具有缓存功能! - -下面我以一个常见的面试题为例讲解一下 `synchronized` 关键字的具体使用。 - -面试中面试官经常会说:"单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!" - -**双重校验锁实现对象单例(线程安全)** - -```java -public class Singleton { - - private volatile static Singleton uniqueInstance; - - private Singleton() { - } - - public static Singleton getUniqueInstance() { - //先判断对象是否已经实例过,没有实例化过才进入加锁代码 - if (uniqueInstance == null) { - //类对象加锁 - synchronized (Singleton.class) { - if (uniqueInstance == null) { - uniqueInstance = new Singleton(); - } - } - } - return uniqueInstance; - } -} -``` - -另外,需要注意 `uniqueInstance` 采用 `volatile` 关键字修饰也是很有必要。 - -`uniqueInstance` 采用 `volatile` 关键字修饰也是很有必要的, `uniqueInstance = new Singleton();` 这段代码其实是分为三步执行: - -1. 为 `uniqueInstance` 分配内存空间 -2. 初始化 `uniqueInstance` -3. 将 `uniqueInstance` 指向分配的内存地址 - -但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 `getUniqueInstance`() 后发现 `uniqueInstance` 不为空,因此返回 `uniqueInstance`,但此时 `uniqueInstance` 还未被初始化。 - -使用 `volatile` 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。 - -### 2.3.13. 构造方法可以使用 synchronized 关键字修饰么? - -先说结论:**构造方法不能使用 synchronized 关键字修饰。** - -构造方法本身就属于线程安全的,不存在同步的构造方法一说。 - -### 2.3.14. 讲一下 synchronized 关键字的底层原理 - -**synchronized 关键字底层原理属于 JVM 层面。** - -#### 2.3.14.1. synchronized 同步语句块的情况 - -```java -public class SynchronizedDemo { - public void method() { - synchronized (this) { - System.out.println("synchronized 代码块"); - } - } -} - -``` - -通过 JDK 自带的 `javap` 命令查看 `SynchronizedDemo` 类的相关字节码信息:首先切换到类的对应目录执行 `javac SynchronizedDemo.java` 命令生成编译后的 .class 文件,然后执行`javap -c -s -v -l SynchronizedDemo.class`。 - -![synchronized关键字原理](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/synchronized关键字原理.png) - -从上面我们可以看出: - -**`synchronized` 同步语句块的实现使用的是 `monitorenter` 和 `monitorexit` 指令,其中 `monitorenter` 指令指向同步代码块的开始位置,`monitorexit` 指令则指明同步代码块的结束位置。** - -当执行 `monitorenter` 指令时,线程试图获取锁也就是获取 **对象监视器 `monitor`** 的持有权。 - -> 在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由[ObjectMonitor](https://github.com/openjdk-mirror/jdk7u-hotspot/blob/50bdefc3afe944ca74c3093e7448d6b889cd20d1/src/share/vm/runtime/objectMonitor.cpp)实现的。每个对象中都内置了一个 `ObjectMonitor`对象。 -> -> 另外,**`wait/notify`等方法也依赖于`monitor`对象,这就是为什么只有在同步的块或者方法中才能调用`wait/notify`等方法,否则会抛出`java.lang.IllegalMonitorStateException`的异常的原因。** - -在执行`monitorenter`时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。 - -在执行 `monitorexit` 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。 - -#### 2.3.14.2. synchronized 修饰方法的的情况 - -```java -public class SynchronizedDemo2 { - public synchronized void method() { - System.out.println("synchronized 方法"); - } -} - -``` - -![synchronized关键字原理](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/synchronized关键字原理2.png) - -`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取得代之的确实是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法。JVM 通过该 `ACC_SYNCHRONIZED` 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。 - -#### 2.3.14.3. 总结 - -`synchronized` 同步语句块的实现使用的是 `monitorenter` 和 `monitorexit` 指令,其中 `monitorenter` 指令指向同步代码块的开始位置,`monitorexit` 指令则指明同步代码块的结束位置。 - -`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取得代之的确实是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法。 - -**不过两者的本质都是对对象监视器 monitor 的获取。** - -### 2.3.15. 为什么要弄一个 CPU 高速缓存呢? - -类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 **CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。** - -我们甚至可以把 **内存可以看作外存的高速缓存**,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。 - -总结:**CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。** - -为了更好地理解,我画了一个简单的 CPU Cache 示意图如下(实际上,现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache): - -![CPU Cache](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/2020-8/303a300f-70dd-4ee1-9974-3f33affc6574.png) - -**CPU Cache 的工作方式:** - -先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 **内存缓存不一致性的问题** !比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。 - -**CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议或者其他手段来解决。** - -### 2.3.16. 讲一下 JMM(Java 内存模型) - -在 JDK1.2 之前,Java 的内存模型实现总是从**主存**(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存**本地内存**(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成**数据的不一致**。 - -![JMM(Java内存模型)](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/2020-8/0ac7e663-7db8-4b95-8d8e-7d2b179f67e8.png) - -要解决这个问题,就需要把变量声明为**`volatile`**,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。 - -所以,**`volatile` 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。** - -![volatile关键字的可见性](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/2020-8/d49c5557-140b-4abf-adad-8aac3c9036cf.png) - -### 2.3.17. 说说 synchronized 关键字和 volatile 关键字的区别 - -`synchronized` 关键字和 `volatile` 关键字是两个互补的存在,而不是对立的存在! - -- **`volatile` 关键字**是线程同步的**轻量级实现**,所以**`volatile`性能肯定比`synchronized`关键字要好**。但是**`volatile` 关键字只能用于变量而 `synchronized` 关键字可以修饰方法以及代码块**。 -- **`volatile` 关键字能保证数据的可见性,但不能保证数据的原子性。`synchronized` 关键字两者都能保证。** -- **`volatile`关键字主要用于解决变量在多个线程之间的可见性,而 `synchronized` 关键字解决的是多个线程之间访问资源的同步性。** - -### 2.3.18. ThreadLocal 了解么? - -通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。**如果想实现每一个线程都有自己的专属本地变量该如何解决呢?** JDK 中提供的`ThreadLocal`类正是为了解决这样的问题。 **`ThreadLocal`类主要解决的就是让每个线程绑定自己的值,可以将`ThreadLocal`类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。** - -**如果你创建了一个`ThreadLocal`变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是`ThreadLocal`变量名的由来。他们可以使用 `get()` 和 `set()` 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。** - -再举个简单的例子:比如有两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。 - -### 2.3.19. ThreadLocal 原理讲一下 - -从 `Thread`类源代码入手。 - -```java -public class Thread implements Runnable { - ...... -//与此线程有关的ThreadLocal值。由ThreadLocal类维护 -ThreadLocal.ThreadLocalMap threadLocals = null; - -//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护 -ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; - ...... -} -``` - -从上面`Thread`类 源代码可以看出`Thread` 类中有一个 `threadLocals` 和 一个 `inheritableThreadLocals` 变量,它们都是 `ThreadLocalMap` 类型的变量,我们可以把 `ThreadLocalMap` 理解为`ThreadLocal` 类实现的定制化的 `HashMap`。默认情况下这两个变量都是 null,只有当前线程调用 `ThreadLocal` 类的 `set`或`get`方法时才创建它们,实际上调用这两个方法的时候,我们调用的是`ThreadLocalMap`类对应的 `get()`、`set()`方法。 - -`ThreadLocal`类的`set()`方法 - -```java - public void set(T value) { - Thread t = Thread.currentThread(); - ThreadLocalMap map = getMap(t); - if (map != null) - map.set(this, value); - else - createMap(t, value); - } - ThreadLocalMap getMap(Thread t) { - return t.threadLocals; - } -``` - -通过上面这些内容,我们足以通过猜测得出结论:**最终的变量是放在了当前线程的 `ThreadLocalMap` 中,并不是存在 `ThreadLocal` 上,`ThreadLocal` 可以理解为只是`ThreadLocalMap`的封装,传递了变量值。** `ThrealLocal` 类中可以通过`Thread.currentThread()`获取到当前线程对象后,直接通过`getMap(Thread t)`可以访问到该线程的`ThreadLocalMap`对象。 - -**每个`Thread`中都具备一个`ThreadLocalMap`,而`ThreadLocalMap`可以存储以`ThreadLocal`为 key ,Object 对象为 value 的键值对。** - -```java -ThreadLocalMap(ThreadLocal firstKey, Object firstValue) { - ...... -} -``` - -比如我们在同一个线程中声明了两个 `ThreadLocal` 对象的话,会使用 `Thread`内部都是使用仅有那个`ThreadLocalMap` 存放数据的,`ThreadLocalMap`的 key 就是 `ThreadLocal`对象,value 就是 `ThreadLocal` 对象调用`set`方法设置的值。 - -![ThreadLocal数据结构](https://guide-blog-images.oss-cn-shenzhen.aliyuncs.com/2020/1/806.jpeg) - -`ThreadLocalMap`是`ThreadLocal`的静态内部类。 - -![ThreadLocal内部类](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/ThreadLocal内部类.png) - -### 2.3.20. ThreadLocal 内存泄露问题了解不? - -`ThreadLocalMap` 中使用的 key 为 `ThreadLocal` 的弱引用,而 value 是强引用。所以,如果 `ThreadLocal` 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,`ThreadLocalMap` 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 `set()`、`get()`、`remove()` 方法的时候,会清理掉 key 为 null 的记录。使用完 `ThreadLocal`方法后 最好手动调用`remove()`方法 - -```java - static class Entry extends WeakReference> { - /** The value associated with this ThreadLocal. */ - Object value; - - Entry(ThreadLocal k, Object v) { - super(k); - value = v; - } - } -``` - -**弱引用介绍:** - -> 如果一个对象只具有弱引用,那就类似于**可有可无的生活用品**。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。 -> -> 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。 - -### 2.3.21. 线程池 - -#### 2.3.21.1. 为什么要用线程池? - -> **池化技术相比大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。** - -**线程池**提供了一种限制和管理资源(包括执行一个任务)。 每个**线程池**还维护一些基本统计信息,例如已完成任务的数量。 - -这里借用《Java 并发编程的艺术》提到的来说一下**使用线程池的好处**: - -- **降低资源消耗**。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 -- **提高响应速度**。当任务到达时,任务可以不需要的等到线程创建就能立即执行。 -- **提高线程的可管理性**。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。 - -#### 2.3.21.2. 实现 Runnable 接口和 Callable 接口的区别 - -`Runnable`自 Java 1.0 以来一直存在,但`Callable`仅在 Java 1.5 中引入,目的就是为了来处理`Runnable`不支持的用例。**`Runnable` 接口**不会返回结果或抛出检查异常,但是**`Callable` 接口**可以。所以,如果任务不需要返回结果或抛出异常推荐使用 **`Runnable` 接口**,这样代码看起来会更加简洁。 - -工具类 `Executors` 可以实现 `Runnable` 对象和 `Callable` 对象之间的相互转换。(`Executors.callable(Runnable task`)或 `Executors.callable(Runnable task,Object resule)`)。 - -`Runnable.java` - -```java -@FunctionalInterface -public interface Runnable { - /** - * 被线程执行,没有返回值也无法抛出异常 - */ - public abstract void run(); -} -``` - -`Callable.java` - -```java -@FunctionalInterface -public interface Callable { - /** - * 计算结果,或在无法这样做时抛出异常。 - * @return 计算得出的结果 - * @throws 如果无法计算结果,则抛出异常 - */ - V call() throws Exception; -} -``` - -#### 2.3.21.3. 执行 execute()方法和 submit()方法的区别是什么呢? - -1. **`execute()`方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;** -2. **`submit()`方法用于提交需要返回值的任务。线程池会返回一个 `Future` 类型的对象,通过这个 `Future` 对象可以判断任务是否执行成功**,并且可以通过 `Future` 的 `get()`方法来获取返回值,`get()`方法会阻塞当前线程直到任务完成,而使用 `get(long timeout,TimeUnit unit)`方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。 - -我们以**`AbstractExecutorService`**接口中的一个 `submit` 方法为例子来看看源代码: - -```java - public Future submit(Runnable task) { - if (task == null) throw new NullPointerException(); - RunnableFuture ftask = newTaskFor(task, null); - execute(ftask); - return ftask; - } -``` - -上面方法调用的 `newTaskFor` 方法返回了一个 `FutureTask` 对象。 - -```java - protected RunnableFuture newTaskFor(Runnable runnable, T value) { - return new FutureTask(runnable, value); - } -``` - -我们再来看看`execute()`方法: - -```java - public void execute(Runnable command) { - ... - } -``` - -#### 2.3.21.4. 如何创建线程池 - -《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险 - -> Executors 返回线程池对象的弊端如下: -> -> - **FixedThreadPool 和 SingleThreadExecutor** : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。 -> - **CachedThreadPool 和 ScheduledThreadPool** : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。 - -**方式一:通过构造方法实现** -![ThreadPoolExecutor构造方法](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/ThreadPoolExecutor构造方法.png) -**方式二:通过 Executor 框架的工具类 Executors 来实现** -我们可以创建三种类型的 ThreadPoolExecutor: - -- **FixedThreadPool** : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 -- **SingleThreadExecutor:** 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 -- **CachedThreadPool:** 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。 - -对应 Executors 工具类中的方法如图所示: -![Executor框架的工具类](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/Executor框架的工具类.png) - -#### 2.3.21.5. ThreadPoolExecutor 类分析 - -`ThreadPoolExecutor` 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么),这里就不贴代码讲了,比较简单。 - -```java - /** - * 用给定的初始参数创建一个新的ThreadPoolExecutor。 - */ - public ThreadPoolExecutor(int corePoolSize, - int maximumPoolSize, - long keepAliveTime, - TimeUnit unit, - BlockingQueue workQueue, - ThreadFactory threadFactory, - RejectedExecutionHandler handler) { - if (corePoolSize < 0 || - maximumPoolSize <= 0 || - maximumPoolSize < corePoolSize || - keepAliveTime < 0) - throw new IllegalArgumentException(); - if (workQueue == null || threadFactory == null || handler == null) - throw new NullPointerException(); - this.corePoolSize = corePoolSize; - this.maximumPoolSize = maximumPoolSize; - this.workQueue = workQueue; - this.keepAliveTime = unit.toNanos(keepAliveTime); - this.threadFactory = threadFactory; - this.handler = handler; - } -``` - -**下面这些对创建 非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。** - -##### 2.3.21.5.1. `ThreadPoolExecutor`构造函数重要参数分析 - -**`ThreadPoolExecutor` 3 个最重要的参数:** - -- **`corePoolSize` :** 核心线程数线程数定义了最小可以同时运行的线程数量。 -- **`maximumPoolSize` :** 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 -- **`workQueue`:** 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。 - -`ThreadPoolExecutor`其他常见参数: - -1. **`keepAliveTime`**:当线程池中的线程数量大于 `corePoolSize` 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 `keepAliveTime`才会被回收销毁; -2. **`unit`** : `keepAliveTime` 参数的时间单位。 -3. **`threadFactory`** :executor 创建新线程的时候会用到。 -4. **`handler`** :饱和策略。关于饱和策略下面单独介绍一下。 - -##### 2.3.21.5.2. `ThreadPoolExecutor` 饱和策略 - -**`ThreadPoolExecutor` 饱和策略定义:** - -如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任时,`ThreadPoolTaskExecutor` 定义一些策略: - -- **`ThreadPoolExecutor.AbortPolicy`**:抛出 `RejectedExecutionException`来拒绝新任务的处理。 -- **`ThreadPoolExecutor.CallerRunsPolicy`**:调用执行自己的线程运行任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。 -- **`ThreadPoolExecutor.DiscardPolicy`:** 不处理新任务,直接丢弃掉。 -- **`ThreadPoolExecutor.DiscardOldestPolicy`:** 此策略将丢弃最早的未处理的任务请求。 - -举个例子: Spring 通过 `ThreadPoolTaskExecutor` 或者我们直接通过 `ThreadPoolExecutor` 的构造函数创建线程池的时候,当我们不指定 `RejectedExecutionHandler` 饱和策略的话来配置线程池的时候默认使用的是 `ThreadPoolExecutor.AbortPolicy`。在默认情况下,`ThreadPoolExecutor` 将抛出 `RejectedExecutionException` 来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。 对于可伸缩的应用程序,建议使用 `ThreadPoolExecutor.CallerRunsPolicy`。当最大池被填满时,此策略为我们提供可伸缩队列。(这个直接查看 `ThreadPoolExecutor` 的构造函数源码就可以看出,比较简单的原因,这里就不贴代码了) - -#### 2.3.21.6. 线程池原理分析 - -承接 4.6 节,我们通过代码输出结果可以看出:**线程池每次会同时执行 5 个任务,这 5 个任务执行完之后,剩余的 5 个任务才会被执行。** 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会) - -现在,我们就分析上面的输出内容来简单分析一下线程池原理。 - -**为了搞懂线程池的原理,我们需要首先分析一下 `execute`方法。**在 4.6 节中的 Demo 中我们使用 `executor.execute(worker)`来提交一个任务到线程池中去,这个方法非常重要,下面我们来看看它的源码: - -```java - // 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) - private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); - - private static int workerCountOf(int c) { - return c & CAPACITY; - } - - private final BlockingQueue workQueue; - - public void execute(Runnable command) { - // 如果任务为null,则抛出异常。 - if (command == null) - throw new NullPointerException(); - // ctl 中保存的线程池当前的一些状态信息 - int c = ctl.get(); - - // 下面会涉及到 3 步 操作 - // 1.首先判断当前线程池中之行的任务数量是否小于 corePoolSize - // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 - if (workerCountOf(c) < corePoolSize) { - if (addWorker(command, true)) - return; - c = ctl.get(); - } - // 2.如果当前之行的任务数量大于等于 corePoolSize 的时候就会走到这里 - // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态才会被并且队列可以加入任务,该任务才会被加入进去 - if (isRunning(c) && workQueue.offer(command)) { - int recheck = ctl.get(); - // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 - if (!isRunning(recheck) && remove(command)) - reject(command); - // 如果当前线程池为空就新创建一个线程并执行。 - else if (workerCountOf(recheck) == 0) - addWorker(null, false); - } - //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 - //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 - else if (!addWorker(command, false)) - reject(command); - } -``` - -通过下图可以更好的对上面这 3 步做一个展示,下图是我为了省事直接从网上找到,原地址不明。 - -![图解线程池实现原理](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-7/图解线程池实现原理.png) - -现在,让我们在回到 4.6 节我们写的 Demo, 现在应该是不是很容易就可以搞懂它的原理了呢? - -没搞懂的话,也没关系,可以看看我的分析: - -> 我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务之行完成后,才会之行剩下的 5 个任务。 - -### 2.3.22. 介绍一下 Atomic 原子类 - -`Atomic` 翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。 - -所以,所谓原子类说简单点就是具有原子/原子操作特征的类。 - -并发包 `java.util.concurrent` 的原子类都存放在`java.util.concurrent.atomic`下,如下图所示。 - -![JUC原子类概览](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/JUC原子类概览.png) - -### 2.3.23. JUC 包中的原子类是哪 4 类? - -**基本类型** - -使用原子的方式更新基本类型 - -- `AtomicInteger`:整形原子类 -- `AtomicLong`:长整型原子类 -- `AtomicBoolean`:布尔型原子类 - -**数组类型** - -使用原子的方式更新数组里的某个元素 - -- `AtomicIntegerArray`:整形数组原子类 -- `AtomicLongArray`:长整形数组原子类 -- `AtomicReferenceArray`:引用类型数组原子类 - -**引用类型** - -- `AtomicReference`:引用类型原子类 -- `AtomicStampedReference`:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 -- `AtomicMarkableReference` :原子更新带有标记位的引用类型 - -**对象的属性修改类型** - -- `AtomicIntegerFieldUpdater`:原子更新整形字段的更新器 -- `AtomicLongFieldUpdater`:原子更新长整形字段的更新器 -- `AtomicReferenceFieldUpdater`:原子更新引用类型字段的更新器 - -### 2.3.24. AQS 了解么? - -AQS 的全称为(`AbstractQueuedSynchronizer`),这个类在`java.util.concurrent.locks`包下面。 - -![AQS类](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/AQS类.png) - -AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 `ReentrantLock`,`Semaphore`,其他的诸如 `ReentrantReadWriteLock`,`SynchronousQueue`,`FutureTask` 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。 - -### 2.3.25. AQS 原理了解么? - -AQS 原理这部分参考了部分博客,在 5.2 节末尾放了链接。 - -> 在面试中被问到并发知识的时候,大多都会被问到"请你说一下自己对于 AQS 原理的理解"。下面给大家一个示例供大家参加,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。 - -下面大部分内容其实在 AQS 类注释上已经给出了,不过是英语看着比较吃力一点,感兴趣的话可以看看源码。 - -#### 2.3.25.1. AQS 原理概览 - -**AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。** - -> CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。 - -看个 AQS(AbstractQueuedSynchronizer)原理图: - -![AQS原理图](https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/AQS原理图.png) - -AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。 - -```java -private volatile int state;//共享变量,使用volatile修饰保证线程可见性 -``` - -状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作 - -```java -//返回同步状态的当前值 -protected final int getState() { - return state; -} - // 设置同步状态的值 -protected final void setState(int newState) { - state = newState; -} -//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) -protected final boolean compareAndSetState(int expect, int update) { - return unsafe.compareAndSwapInt(this, stateOffset, expect, update); -} -``` - -#### 2.3.25.2. AQS 对资源的共享方式 - -**AQS 定义两种资源共享方式** - -- **Exclusive**(独占):只有一个线程能执行,如 `ReentrantLock`。又可分为公平锁和非公平锁: - - 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁 - - 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的 -- **Share**(共享):多个线程可同时执行,如`CountDownLatch`、`Semaphore`、`CountDownLatch`、 `CyclicBarrier`、`ReadWriteLock` 我们都会在后面讲到。 - -`ReentrantReadWriteLock` 可以看成是组合式,因为 `ReentrantReadWriteLock` 也就是读写锁允许多个线程同时对某一资源进行读。 - -不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。 - -#### 2.3.25.3. AQS 底层使用了模板方法模式 - -同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用): - -1. 使用者继承 `AbstractQueuedSynchronizer` 并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放) -2. 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。 - -这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。 - -**AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:** - -```java -isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。 -tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。 -tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。 -tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 -tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。 - -``` - -默认情况下,每个方法都抛出 `UnsupportedOperationException`。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS 类中的其他方法都是 final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。 - -以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。 - -再以 `CountDownLatch` 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后`countDown()` 一次,state 会 CAS(Compare and Swap)减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 `await()` 函数返回,继续后余动作。 - -一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现`tryAcquire-tryRelease`、`tryAcquireShared-tryReleaseShared`中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock`。 - -推荐两篇 AQS 原理和相关源码分析的文章: - -- http://www.cnblogs.com/waterystone/p/4920797.html -- https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html - -### 2.3.26. AQS 组件总结 - -- **`Semaphore`(信号量)-允许多个线程同时访问:** `synchronized` 和 `ReentrantLock` 都是一次只允许一个线程访问某个资源,`Semaphore`(信号量)可以指定多个线程同时访问某个资源。 -- **`CountDownLatch`(倒计时器):** `CountDownLatch` 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。 -- **`CyclicBarrier`(循环栅栏):** `CyclicBarrier` 和 `CountDownLatch` 非常类似,它也可以实现线程间的技术等待,但是它的功能比 `CountDownLatch` 更加复杂和强大。主要应用场景和 `CountDownLatch` 类似。`CyclicBarrier` 的字面意思是可循环使用(`Cyclic`)的屏障(`Barrier`)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。`CyclicBarrier` 默认的构造方法是 `CyclicBarrier(int parties)`,其参数表示屏障拦截的线程数量,每个线程调用 `await()` 方法告诉 `CyclicBarrier` 我已经到达了屏障,然后当前线程被阻塞。 - -### 2.3.27. 用过 CountDownLatch 么?什么场景下用的? - -`CountDownLatch` 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 `CountDownLatch` 。具体场景是下面这样的: - -我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。 - -为此我们定义了一个线程池和 count 为 6 的`CountDownLatch`对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用`CountDownLatch`对象的 `await()`方法,直到所有文件读取完之后,才会接着执行后面的逻辑。 - -伪代码是下面这样的: - -```java -public class CountDownLatchExample1 { - // 处理文件的数量 - private static final int threadCount = 6; - - public static void main(String[] args) throws InterruptedException { - // 创建一个具有固定线程数量的线程池对象(推荐使用构造方法创建) - ExecutorService threadPool = Executors.newFixedThreadPool(10); - final CountDownLatch countDownLatch = new CountDownLatch(threadCount); - for (int i = 0; i < threadCount; i++) { - final int threadnum = i; - threadPool.execute(() -> { - try { - //处理文件的业务操作 - ...... - } catch (InterruptedException e) { - e.printStackTrace(); - } finally { - //表示一个文件已经被完成 - countDownLatch.countDown(); - } - - }); - } - countDownLatch.await(); - threadPool.shutdown(); - System.out.println("finish"); - } - -} -``` - -**有没有可以改进的地方呢?** - -可以使用 `CompletableFuture` 类来改进!Java8 的 `CompletableFuture` 提供了很多对多线程友好的方法,使用它可以很方便地为我们编写多线程程序,什么异步、串行、并行或者等待所有线程执行完任务什么的都非常方便。 - -```java -CompletableFuture task1 = - CompletableFuture.supplyAsync(()->{ - //自定义业务操作 - }); -...... -CompletableFuture task6 = - CompletableFuture.supplyAsync(()->{ - //自定义业务操作 - }); -...... - CompletableFuture headerFuture=CompletableFuture.allOf(task1,.....,task6); - - try { - headerFuture.join(); - } catch (Exception ex) { - ...... - } -System.out.println("all done. "); -``` - -上面的代码还可以接续优化,当任务过多的时候,把每一个 task 都列出来不太现实,可以考虑通过循环来添加任务。 - -```java -//文件夹位置 -List filePaths = Arrays.asList(...) -// 异步处理所有文件 -List> fileFutures = filePaths.stream() - .map(filePath -> doSomeThing(filePath)) - .collect(Collectors.toList()); -// 将他们合并起来 -CompletableFuture allFutures = CompletableFuture.allOf( - fileFutures.toArray(new CompletableFuture[fileFutures.size()]) -); -``` - -### 2.4.1. Reference - -- 《深入理解 Java 虚拟机》 -- 《实战 Java 高并发程序设计》 -- 《Java 并发编程的艺术》 -- http://www.cnblogs.com/waterystone/p/4920797.html -- https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html -- diff --git a/docs/b-4jvm.md b/docs/b-4jvm.md index 604c2a7..0e55483 100755 --- a/docs/b-4jvm.md +++ b/docs/b-4jvm.md @@ -1,4 +1,20 @@ ------ + + ## 2.4. JVM +如果你不准备大厂面试的话,JVM 这部分内容其实可以跳过的。 + +如果你想冲击大厂的话,可以通过我根据《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第三版》总结的下面这几篇文章来准备面试: + +1. [Java 内存区域详解](https://javaguide.cn/java/jvm/memory-area.html) +2. [JVM 垃圾回收详解](https://javaguide.cn/java/jvm/jvm-garbage-collection.html) +3. [类文件结构详解](https://javaguide.cn/java/jvm/class-file-structure.html) +4. [类加载过程详解](https://javaguide.cn/java/jvm/class-loading-process.html) +5. [类加载器详解](https://javaguide.cn/java/jvm/classloader.html) + +由于篇幅问题,这里就不放 JVM 部分的常见面试题了。 + + +

AltStyle によって変換されたページ (->オリジナル) /