diff --git "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257.pdf" "b/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257.pdf" deleted file mode 100644 index 75e5959b1a87c2e6d0141b522d25774952cc9248..0000000000000000000000000000000000000000 Binary files "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257.pdf" and /dev/null differ diff --git "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\344\272\256\347\231\275\347\211\210\357\274\210\344\277\235\347\234\237\357\274\211.pdf" "b/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\344\272\256\347\231\275\347\211\210\357\274\210\344\277\235\347\234\237\357\274\211.pdf" deleted file mode 100644 index 589dcea8b735887f79a386a63cd72b37b215bf20..0000000000000000000000000000000000000000 Binary files "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\344\272\256\347\231\275\347\211\210\357\274\210\344\277\235\347\234\237\357\274\211.pdf" and /dev/null differ diff --git "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\346\232\227\351\273\221\347\211\210\345\270\246\347\233\256\345\275\225.pdf" "b/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\346\232\227\351\273\221\347\211\210\345\270\246\347\233\256\345\275\225.pdf" deleted file mode 100644 index daeac80b44025bf2289ffbc5e4fa1c6f6525c5fc..0000000000000000000000000000000000000000 Binary files "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\346\232\227\351\273\221\347\211\210\345\270\246\347\233\256\345\275\225.pdf" and /dev/null differ diff --git "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\346\232\227\351\273\221\347\211\210\357\274\210\344\277\235\347\234\237\357\274\211.pdf" "b/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\346\232\227\351\273\221\347\211\210\357\274\210\344\277\235\347\234\237\357\274\211.pdf" deleted file mode 100644 index 9228da6b50c5d417e60e5a235b50ef609cbd2c94..0000000000000000000000000000000000000000 Binary files "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\346\232\227\351\273\221\347\211\210\357\274\210\344\277\235\347\234\237\357\274\211.pdf" and /dev/null differ diff --git "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\357\274\210\347\262\276\347\256\200\347\211\210\357\274\211.md" "b/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\357\274\210\347\262\276\347\256\200\347\211\210\357\274\211.md" deleted file mode 100644 index a477bf82e1f481fb49e37dff988d7222569423e2..0000000000000000000000000000000000000000 --- "a/\344\272\214\345\223\245\347\232\204Java\350\277\233\351\230\266\344\271\213\350\267\257 PDF \347\246\273\347\272\277\347\211\210/\344\272\214\345\223\245\347\232\204 Java \350\277\233\351\230\266\344\271\213\350\267\257\357\274\210\347\262\276\347\256\200\347\211\210\357\274\211.md" +++ /dev/null @@ -1,26327 +0,0 @@ -![](https://cdn.tobebetterjavaer.com/stutymore/readme-fengmian.png) - -# 第一章:小册简介 - -以上就是小册的封面了,自我感觉还不错哈,简洁大方,但包含的信息又足够的丰富: - -- 小册名字:二哥的 Java 进阶之路 -- 小册作者:沉默王二 -- 小册品质:能在 GitHub 取得 7600+ star 自认为品质是有目共睹的,尤其是国内还有不少小伙伴在访问 GitHub 的时候很不顺利。 -- 小册风格:通俗易懂、风趣幽默、深度解析,新手可以拿来入门,老手可以拿来进阶,重要的知识,比如说面试高频的内容会从应用到源码挖个底朝天,还会穿插介绍一些计算机底层知识,力求讲个明白) -- 小册简介:这是一份通俗易懂、风趣幽默的Java学习指南,内容涵盖Java基础、Java并发编程、Java虚拟机、Java面试等核心知识点。学Java,就认准二哥的Java进阶之路😄 -- 小册品位:底部用了梵高 1889 年的《星空》(the starry night),绝美的漩涡星空,耀眼的月亮,宁静的村庄,还有一颗燃烧着火焰的巨大柏树,我想小册的艺术品位也是恰到好处的。 -- 小册角色:为了增加小册的趣味性,我特意为此追加了两个虚拟角色,一个二哥,一个三妹,二哥负责教,三妹负责学。这样大家在学习 Java 的时候代入感也会更强烈一些,希望这样的设定能博得大家的欢心。 - -## 小册包含哪些内容? - -三妹出场:“二哥,帮读者朋友们问一下哈,为什么会有《二哥的Java进阶之路》这份小册呢?” - -*二哥巴拉巴拉 ing...* - -小册的内容主要来源于我的开源知识库《[Java程序员进阶之路](https://github.com/itwanger/toBeBetterJavaer)》,目前在 GitHub 上收获 7600+ star,深受读者喜爱。小册之所以叫《二哥的Java进阶之路》,是因为这样更方便小册的读者知道这份小册的作者是谁,IP 感更强烈一些。 - -如果有读者是第一次阅读这份小册,肯定又会问,“二哥是哪个鸟人?” - -噢噢噢噢,正是鄙人了,一个英俊潇洒的男人(见下图),你可以通过我的微信公众号“**沉默王二**”了解更多关于我的信息,总之,就是一个非常喜欢王小波的程序员了,写得一手风趣幽默的技术文章,所以被读者“尊称”为二哥就对了。现实中,三妹也是真实存在的哦。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/readme-ece0be3e-d176-447c-bff9-59d9f02c7a65.jpg) - -《**二哥的 Java 进阶之路**》是我自学 Java 以来所有原创文章和学习资料的大聚合。[在线网站](https://tobebetterjavaer.com/)和 [GitHub 仓库](https://github.com/itwanger/toBeBetterJavaer)里的内容包括 Java 基础、Java 并发编程、Java 虚拟机、Java 企业级开发(包括开发/构建/测试、JavaWeb、SSM、Spring Boot、Linux、Nginx、Docker、k8s、微服务&分布式、消息队列等)、Java 面试等核心内容。这也是小册最终版会覆盖的内容。 - -小册旨在为学习 Java 的小伙伴提供一系列: - - - **优质的原创 Java 教程** - - **全面清晰的 Java 学习路线** - - **免费但靠谱的 Java 学习资料** - - **精选的 Java 岗求职面试指南** - - **Java 企业级开发所需的必备技术** - -接下来,送你 4 个“掏心掏肺”的阅读建议: - -- 如果你是零基础的小白,可以按照小册的顺序一路读下去,小册的内容安排都是经过我精心安排的; -- 否则,请按照目录按需阅读,该跳过的跳过,该放慢节奏的放慢节奏。 -- 小册中会有一个虚拟人物,三妹,当然她的原型也是真实存在的,目的就是通过我们之间的对话,来增强文章的趣味性,以便你能更轻松地获取知识。 -- 最重要的一点,“光看不练假把戏”,请在阅读的过程中把该敲的代码敲了,把该记的笔记记了,语雀、思维导图、GitHub 仓库都可以,养成好的学习习惯。 - -如果你喜欢在线阅读,请戳下面这个网址: - -> [https://tobebetterjavaer.com](https://tobebetterjavaer.com) - -首页见下图,同样简洁、清新、方便沉浸式阅读: - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411102619.png) - -你也可以到技术派的[教程栏(戳这里)](https://paicoding.com/column)里阅读,目前正在连载更新中。 - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230410215012.png) - ->技术派是一个基于 Spring Boot、MyBatis-Plus、MySQL、Redis、ElasticSearch、MongoDB、Docker、RabbitMQ 等技术栈实现的社区系统,采用主流的互联网技术架构、全新的UI设计、支持一键源码部署,拥有完整的文章&教程发布/搜索/评论/统计流程等,[代码完全开源(可戳)](https://github.com/itwanger/paicoding),没有任何二次封装,是一个非常适合二次开发/实战的现代化社区项目👍 。 - -如果你在阅读过程中感觉这份小册写的还不错,甚至有亿点点收获,**请肆无忌惮地把这份小册分享给你的同事、同学、舍友、朋友,让他们也进步亿点点,赠人玫瑰手有余香嘛**。 - -如果这份小册有幸被更多人看得到,我的虚荣心也会得到恰当的满足,嘿嘿😜 - -## 如何获取最新版? - -小册会持续保持**更新**,如果想获得最新版,请在我的微信公众号 **沉默王二** 后台回复 **222** 获取(你懂我的意思吧,我肯定是足够二才有这样的勇气定义这样一个关键字)! - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - -## 面试指南(配套教程) - -《Java 面试指南》是[二哥编程星球的](https://tobebetterjavaer.com/zhishixingqiu/)的一个内部小册,和《Java 进阶之路》内容互补。相比开源的版本来说,《Java 面试指南》添加了下面这些板块和内容: - -- 面试准备篇(20+篇),手把手教你如何准备面试。 -- 职场修炼篇(10+篇),手摸手教你如何在职场中如鱼得水。 -- 技术提升篇(30+篇),手拉手教你如何成为团队不可或缺的技术攻坚小能手。 -- 面经分享篇(20+篇),手牵手教你如何在面试中知彼知己,百战不殆。 -- 场景设计篇(20+篇),手握手教你如何在面试中脱颖而出。 - -### 内容概览 - -#### 面试准备篇 - -所谓临阵磨枪,不快也光。更何况提前做好充足的准备呢?这 20+篇文章会系统地引导你该如何做准备。 - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411111002.png) - -#### 职场修炼篇 - -如何平滑度过试用期?如何平滑度过 35 岁程序员危机?如何在繁重的工作中持续成长?如何做副业?等等,都是大家迫切关心的问题,这 10+篇文章会一一为你揭晓答案。 - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411111200.png) - -#### 技术提升篇 - -编程能力、技术功底,是我们程序员安身立命之本,是我们求职/工作的最核心的武器。 - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411112059.png) - -#### 面经分享篇 - -知彼知己,方能百战不殆,我们必须得站在学长学姐的肩膀上,才能走得更远更快。 - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411112435.png) - -#### 场景设计题篇 - -这里收录的都是精华,让天底下没有难背的八股文;场景设计题篇页都是面试中经常考察的大项,可以让你和面试官对线半小时(😁) - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411112637.png) - -### 星球其他资源 - -除了《Java 面试指南》外,星球还提供了《编程喵实战项目笔记》、《二哥的 LeetCode 刷题笔记》,以及技术派实战项目配套的 120+篇硬核教程。 - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411113022.png) - -这里重点介绍一下技术派吧,这个项目上线后,一直广受好评,读者朋友们的认可度非常高,项目配套的教程也足够的硬核。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/zhishixingqiu/readme-75e5d61a-55e8-46b8-a261-31bd0d86b81a.png) - -这是部分目录(共计 120 篇,大厂篇、基础篇、进阶篇、工程篇,全部落地)。 - -开篇: - -- 技术答疑(⭐️) -- 技术派问题反馈及解决方案(⭐️) -- 踩坑实录之本地缓存Caffeine采坑实录(⭐️) -- 技术派系统架构、功能模块一览(⭐️⭐️⭐️⭐️⭐️) - -大厂篇: - -- 技术派产品调研,让你了解产品诞生背后的故事(⭐️⭐️) -- 技术派产品设计(⭐️) -- 技术派交互视觉设计(⭐️) -- 技术派整体架构方案设计全过程(⭐️⭐️⭐️) -- 技术方案详细设计(⭐️⭐️⭐️⭐️) -- 技术派项目管理流程(⭐️⭐️) -- 技术派项目管理研发阶段(⭐️⭐️⭐️) - -基础篇: - -- 技术派中实体对象 DO、DTO、VO 到底代表了什么(⭐️) -- 通过技术派项目讲解 MVC 分层架构的应用(⭐️⭐️) -- 技术派整合本地缓存之Guava(⭐️⭐️⭐️) -- 技术派整合本地缓存之Caffeine(⭐️⭐️⭐️⭐️) -- 技术派整合 Redis(⭐️) -- 技术派中基于 Redis 的缓存示例(⭐️⭐️⭐️) -- 技术派中基于Cacheable注解实现缓存示例(⭐️⭐️) -- 技术派中的事务使用实例(⭐️⭐️⭐️) -- 事务使用的 7 条注意事项(⭐️⭐️⭐️) -- 技术派中的多配置文件说明(⭐️) -- 技术派整合 Logback/lombok 配置日志输出(⭐️) -- 技术派整合邮件服务实现邮件发送(⭐️) -- Web 三大组件之 Filter 在技术派中的应用(⭐️) -- Web 三大组件之 Servlet 在技术派中的应用(⭐️) -- Web 三大组件之 listenter 在技术派中的应用(⭐️) -- 技术派实时在线人数统计-单机版(⭐️) - -进阶篇: - -- 技术派之扫码登录实现原理(⭐️) -- 技术派身份验证之session与 cookie(⭐️) -- 技术派中基于异常日志的报警通知(⭐️) - -扩展篇: - -- 技术派的数据库表自动初始化实现方案(⭐️⭐️⭐️⭐️⭐️) -- 技术派中基于 filter 实现请求日志记录(⭐️) - -工程篇: - -- 技术派项目工程搭建手册(⭐️⭐️⭐️⭐️) -- 技术派本地多机器部署开发教程(⭐️⭐️) -- 技术派服务器部署指导手册(⭐️⭐️) -- 技术派的 MVC 分层架构(⭐️⭐️) -- 技术派 Docker 本机部署开发手册(⭐️⭐️⭐️) -- 技术派多环境配置管理(⭐️) - -欣赏一下技术派实战项目的首页吧,绝壁清新、高级、上档次! - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/zhishixingqiu/readme-72345f90-68dc-421d-97c5-0ebf411212ae.png) - -### 星球限时优惠 - -一年前,星球的定价是 99 元一年,第一批优惠券的额度是 30 元,等于说 69 元的低价就可以加入,再扣除掉星球手续费,几乎就是纯粹做公益。 - -随着时间的推移,星球积累的干货/资源越来越多,我花在星球上的时间也越来越多,[星球的知识图谱](https://tobebetterjavaer.com/zhishixingqiu/map.html)里沉淀的问题,你可以戳这个[链接](https://tobebetterjavaer.com/zhishixingqiu/map.html)去感受一下。有学习计划啊、有学生党秋招&春招&offer选择&考研&实习&专升本&培训班的问题啊、有工作党方向选择&转行&求职&职业规划的问题啊,还有大大小小的技术细节,我都竭尽全力去帮助球友,并且得到了球友的认可和尊重。 - -目前星球已经 2100+ 人了,所以星球也涨价到了 119 元,后续会讲星球的价格调整为 139 元/年,所以想加入的小伙伴一定要趁早。 - -![](https://cdn.tobebetterjavaer.com/stutymore/readme-20230411113706.png) - -你可以添加我的微信(没有⼿机号再申请微信,故使⽤企业微信。不过,请放⼼,这个号的消息也是 -我本⼈处理,平时最常看这个微信)领取星球专属优惠券(推荐),限时 80/年 加⼊(续费半价)! - - - - -或者你也可以微信扫码或者长按自动识别领取 30 元优惠券,**89/年** 加入! - - - -对了,**加入星球后记得花 10 分钟时间看一下星球的两个置顶贴,你会发现物超所值**! - -成功没有一蹴而就,没有一飞冲天,但只要你能够一步一个脚印,就能取得你心满意足的好结果,请给自己一个机会! - -最后,把二哥的座右铭送给你:**没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟**。 - -共勉 ⛽️。 - -## 如何贡献? - -对了,如果你在阅读的过程中遇到一些错误,欢迎到我的开源仓库提交 issue、PR(审核通过后可成为 Contributor),我会第一时间修正,感谢你为后来者做出的贡献。 - ->- GitHub:[https://github.com/itwanger/toBeBetterJavaer](https://github.com/itwanger/toBeBetterJavaer) ->- 码云:[https://gitee.com/itwanger/toBeBetterJavaer](https://gitee.com/itwanger/toBeBetterJavaer) - -## 更新记录 - -### V1.0-2023年04月11日 - -第一版《二哥的 Java 进阶之路》正式完结发布! - -# 第二章:Java概述及环境配置 - -## 2.1 Java简介 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[2.1 Java简介](https://tobebetterjavaer.com/overview/what-is-java.html) - -## 2.2 安装JDK - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[2.2 安装 JDK](https://tobebetterjavaer.com/overview/jdk-install-config.html) - - - -## 2.3 安装IDEA - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[2.3 安装Intellij IDEA](https://tobebetterjavaer.com/overview/IDEA-install-config.html) - - - -## 2.4 第一个Java程序 - -“三妹,今天,我们来编写第一个 Java 程序,Hello World 期待吗?” - -三妹点了点头,表示认同(😂)。 - -“好的,那我们直接开始。” - -打开 [Intellij IDEA](https://tobebetterjavaer.com/overview/IDEA-install-config.html),新建一个学习 Java 的项目,点击 File → New → Project。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-3810664d-49fb-4bed-ad32-3cb962ab5201.png) - -选择 JDK 版本,比如之前我们[安装的 JDK 8](https://tobebetterjavaer.com/overview/jdk-install-config.html)。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-3773144f-ba5a-4639-8747-70eb815f1ccd.png) - -你也可以选择 JDK 11 或者最新的 JDK 17 或者添加新的 JDK 版本,但(不建议)。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-85e3860b-3207-45c1-85d6-09c7cfd83c77.png) - -然后点击「next」,直到填写项目名字,比如说 tobebetterjavaerdemo。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-aad8ff0b-61e4-4dc1-9f5b-70f64f34a49b.png) - -然后点击 finish,之后就可以看到我们新建的项目界面了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-5ef6024a-86e4-4fb7-860c-526ed867ab4a.png) - -如果你的 Intellij IDEA 主题和二哥不一样,没关系,当然了,如果你也是个有颜值追求的家伙,可以安装 Vuesion Theme 插件,安装方法[戳这里](https://tobebetterjavaer.com/ide/shenji-chajian-10.html)。 - -“OK,到这里,我们已经把学习 Java 的环境准备好了,接下来就可以写第一个 Hello World 程序了。”我自信地对三妹说。 - -一般我们会把源代码放在 src 目录下(source 的前缀,所以学编程,英语中常用的单词必须得会,不会就去学)。 - -右键 src 目录,在菜单中依次选择 New → Java Class。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-3dcc212c-bc06-49b0-989a-d3d129586064.png) - -填写 Class 名,也就是类名(不知道类名是啥,后面会讲),注意大小写敏感,然后按下 enter 键。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-c4e95c3f-1832-4fc5-bfe7-ce5def0129e2.png) - -就会出现这样的代码。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-380bd820-3b92-4195-86cc-1af30836ce38.png) - -注释是二哥配置好的,你如果没配置可能没有,`public class HelloWorld {}` 是 Intellij IDEA 帮我们自动生成的。 - -之后在大括号里面键入 `main` 等 Intellij IDEA 给出提示后键入 enter 键。 - -Intellij IDEA 就会帮我们自动生成 main 方法,也就是这段代码。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-e0acad2b-1735-42d9-b843-5d65d48c0946.png) - -然后在 main 方法中键入 `so` 等出现提示后键入 enter 键。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-9733e9ba-9e38-41d6-8a58-e73062ee9ed2.png) - -Intellij IDEA 就会帮我们自动添加 `System.out.println()`,这是一个向控制台输出的方法(小白先不管它是什么意思,后面会讲)。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-8eb1a438-88d8-4ea2-8861-af96862518fc.png) - -接着在 `println()` 的小括号中键入 `"Hello World"`,注意是英文的双引号,中文的会报错哦,三妹。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-f5aa5fd1-4498-4022-b6e6-cc50f23dacb7.png) - -然后在 HelloWorld.java 的代码编辑器,也就是光标所在的位置右键,选择「Run 'HelloWorld.main()'」。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-98e26e22-4811-4546-a88f-59a67d2d93ca.png) - -等 Intellij IDEA 编译&运行后就可以在控制台看到这样的输出内容。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/overview/hello-world-39d0d413-a2c6-4238-bead-f29183d271c7.png) - -这就表明我们的第一个 Java 代码完成了,恭喜自己一下吧,三妹! - -“二哥,你太棒了,好激动哦!!!!!!!” - -下面,我们来简单解释一下这段代码。 - -第一个 Java 程序非常简单,我们来改一下输出内容,把 Hello World 替换掉: - -```java -public class HelloWorld { - public static void main(String[] args) { - System.out.println("三妹,少看手机少打游戏,好好学,美美哒。"); - } -} -``` - -- class 关键字:用于在 Java 中声明一个[类](https://tobebetterjavaer.com/oo/object-class.html)。 -- public 关键字:一个表示可见性的[访问修饰符](https://tobebetterjavaer.com/oo/access-control.html)。 -- [static 关键字](https://tobebetterjavaer.com/oo/static.html):我们可以用它来声明任何一个方法,被 static 修饰后的方法称之为静态方法。静态方法不需要为其创建对象就能调用。 -- void 关键字:表示该方法不返回任何值。 -- main 关键字:表示该方法为主方法,也就是程序运行的入口。`main()` 方法由 Java 虚拟机执行,配合上 static 关键字后,可以不用创建对象就可以调用,可以节省不少内存空间。 -- `String [] args`:`main()` 方法的参数,类型为 [String](https://tobebetterjavaer.com/string/immutable.html) [数组](https://tobebetterjavaer.com/array/array.html),参数名为 args。 -- `System.out.println()`:一个 Java 语句,一般情况下是将传递的参数打印到控制台。System 是 java.lang 包中的一个 final 类,该类提供的设施包括标准输入,标准输出和错误输出流等等。out 是 System 类的静态成员字段,类型为 [PrintStream](https://tobebetterjavaer.com/io/print.html),它与主机的标准输出控制台进行映射。println 是 PrintStream 类的一个方法,通过调用 print 方法并添加一个换行符实现的。 - -“实在记不住也没关系,我们后面还会讲哦(可以跳转的地方都会展开细讲)。”我的话令三妹感到非常开心。 - -好,接下来再告诉你一点额外的知识点(如果觉得比较难可跳过),三妹。 - -在 Intellij IDEA 的 terminal 面板中,可以看到对应的 java 源代码文件和编译后的 .class 文件。 - -可以在对应的文件上右键选择 open in terminal 打开。 - -![](https://cdn.tobebetterjavaer.com/stutymore/hello-world-20230408184558.png) - -可以通过 pwd 命令查看当前包路径,通过 ls 命令查看包路径下面有哪些文件。 - -![](https://cdn.tobebetterjavaer.com/stutymore/hello-world-20230408184256.png) - -class 文件在 target 目录下,classes 为 src/main 目录下的 class 文件;test-classes 为 src/test 目录下的 class 文件。 - -![](https://cdn.tobebetterjavaer.com/stutymore/hello-world-20230408184835.png) - -“二哥,.class 文件和 .java 源代码,它们之间的关系是什么样的呢?”三妹还是挺喜欢学习的嘛,发现的问题都很关键。 - -“不错不错,都能挖掘到这个点了。” - -.java 是源代码,也就是我们开发人员可以看懂的,可以编写的;.class 是字节码文件,是经过 javac 编译后的文件,是交给 [JVM](https://tobebetterjavaer.com/jvm/what-is-jvm.html) 执行的文件。 - -“三妹,这里再顺带给你讲一下,Java 是编译型语言还是解释型语言。” - -“好啊,我正要问这个‘编译’到底是怎么回事呢?” - -Java 的第一道工序是通过 javac 命令把 Java 源码编译成字节码。 - -比如说我们可以主动执行 `javac Hello.java` 命令将源代码文件编译为 Hello.class 文件(用 Intellij IDEA 的话,并不需要我们主动去编译「javac」,直接运行就可以自动生成 .class 文件)。 - -![](https://cdn.tobebetterjavaer.com/stutymore/hello-world-20230408185202.png) - -之后,我们可以通过 java 命令运行字节码(比如说 `java Hello`),此时就有 2 种处理方式了。 - -- 1、字节码由 JVM 逐条解释执行。 -- 2、部分字节码可能由 [JIT(即时编译,戳链接了解](https://tobebetterjavaer.com/jvm/jit.html))编译为机器指令直接执行。 - -①、逐条解释执行: - -逐条解释执行是 Java 虚拟机的基本执行模式。在这种模式下,Java 虚拟机会逐条读取字节码文件中的指令,并将其解释为对应的底层操作。解释执行的优点是实现简单,启动速度较快,但由于每次执行都需要对字节码进行解释,因此执行效率相对较低。 - -总结一下逐条解释执行的特点: - -- 实现简单 -- 启动速度较快 -- 执行效率较低 - -②、JIT 即时编译: - -为了提高 Java 程序的执行效率,Java 虚拟机引入了即时编译([JIT,Just-In-Time Compilation](https://tobebetterjavaer.com/jvm/jit.html))技术。在 JIT 模式下,Java 虚拟机会在运行时将频繁执行的字节码编译为本地机器码,这样就可以直接在硬件上运行,而不需要再次解释。这样做的结果是显著提高了程序的执行速度。需要注意的是,JIT 编译器并不会编译所有的字节码,而是根据一定的策略,仅编译被频繁调用的代码段(热点代码)。 - -总结一下 JIT 即时编译的特点: - -- 提高执行效率 -- 编译热点代码 -- 动态优化 - -实际上,现代 Java 虚拟机(如 HotSpot)通常会结合这两种执行方式,即解释执行和 JIT 即时编译。在程序运行初期,Java 虚拟机会采用解释执行,以减少启动时间。随着程序的运行,Java 虚拟机会识别出热点代码并使用 JIT 编译器将其编译为本地机器码,从而提高程序的执行效率。这种结合策略称为混合模式。 - - -也就是说,为了跨平台,Java 源代码首先会编译成字节码,字节码不是机器语言,需要 JVM 来解释。 - -![](https://cdn.tobebetterjavaer.com/stutymore/hello-world-20230408190024.png) - -有了 JVM 这个中间层,Java 的运行效率就没有直接把源代码编译为机器码来得效率更高,这个应该能理解吗,多了中间商嘛。所以为了提高效率,JVM 引入了 JIT 编译器,把一些经常执行的字节码直接搞成机器码。 - -所以,Java 是解释和编译并存。但通常来说,我们会说“Java 是编译型语言”,尽管这样并不准确,主要是 JIT 是后面才出现的,“先入为主嘛”。 - -“好的,二哥,我了解了。” - -# 第三章:Java语法基础 - -## 3.1 Java关键字和保留字 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[3.1 Java关键字和保留字](https://tobebetterjavaer.com/basic-extra-meal/48-keywords.html) - - - -## 3.2 Java注释 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[3.2 Java注释](https://tobebetterjavaer.com/basic-grammar/javadoc.html) - - - -## 3.3 Java数据类型 - -“Java 是一种静态类型的编程语言,这意味着所有变量必须在使用之前声明好,也就是必须得先指定变量的类型和名称。”我吸了一口麦香可可奶茶后对三妹说。 - -Java 中的数据类型可分为 2 种: - -1)**基本数据类型**。 - -基本数据类型是 Java 语言操作数据的基础,包括 boolean、char、byte、short、int、long、float 和 double,共 8 种。 - -2)**引用数据类型**。 - -除了基本数据类型以外的类型,都是所谓的引用类型。常见的有[数组](https://tobebetterjavaer.com/array/array.html)(对,没错,数组是引用类型,后面我们会讲)、class(也就是[类](https://tobebetterjavaer.com/oo/object-class.html)),以及[接口](https://tobebetterjavaer.com/oo/interface.html)(指向的是实现接口的类的对象)。 - -来个思维导图,感受下。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/nine-01.png) - -[变量](https://tobebetterjavaer.com/oo/var.html)可以分为局部变量、成员变量、静态变量。 - -当变量是局部变量的时候,必须得先初始化,否则编译器不允许你使用它。拿 int 来举例吧,看下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/nine-02.png) - -当变量是成员变量或者静态变量时,可以不进行初始化,它们会有一个默认值,仍然以 int 为例,来看代码: - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class LocalVar { - private int a; - static int b; - - public static void main(String[] args) { - LocalVar lv = new LocalVar(); - System.out.println(lv.a); - System.out.println(b); - } -} -``` - -来看输出结果: - -``` -0 -0 -``` - -瞧见没,int 作为成员变量时或者静态变量时的默认值是 0。那不同的基本数据类型,是有不同的默认值和大小的,来个表格感受下。 - -| 数据类型 | 默认值 | 大小 | -| -------- | -------- | ------ | -| boolean | false | 1 比特 | -| char | '\u0000' | 2 字节 | -| byte | 0 | 1 字节 | -| short | 0 | 2 字节 | -| int | 0 | 4 字节 | -| long | 0L | 8 字节 | -| float | 0.0f | 4 字节 | -| double | 0.0 | 8 字节 | - -### 01、比特和字节 - -那三妹可能要问,“比特和字节是什么鬼?” - -比特币(Bitcoin)听说过吧?字节跳动(Byte Dance)听说过吧?这些名字当然不是乱起的,确实和比特、字节有关系。 - -#### **1)bit(比特)** - -比特作为信息技术的最基本存储单位,非常小,但大名鼎鼎的比特币就是以此命名的,它的简写为小写字母“b”。 - -大家都知道,计算机是以二进制存储数据的,二进制的一位,就是 1 比特,也就是说,比特要么为 0 要么为 1。 - -#### **2)Byte(字节)** - -通常来说,一个英文字符是一个字节,一个中文字符是两个字节。字节与比特的换算关系是:1 字节 = 8 比特。 - -在往上的单位就是 KB,并不是 1000 字节,因为计算机只认识二进制,因此是 2 的 10 次方,也就是 1024 个字节。 - -(终于知道 1024 和程序员的关系了吧?狗头保命) - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/nine-03.png) - -### 02、基本数据类型 - -接下来,我们再来详细地了解一下 8 种基本数据类型。 - -#### 1)布尔 - -布尔(boolean)仅用于存储两个值:true 和 false,也就是真和假,通常用于条件的判断。代码示例: - -```java -boolean hasMoney = true; -boolean hasGirlFriend = false; -``` - -#### 2)byte - -一个字节可以表示 2^8 = 256 个不同的值。由于 byte 是有符号的,它的值可以是负数或正数,其取值范围是 -128 到 127(包括 -128 和 127)。 - -在网络传输、大文件读写时,为了节省空间,常用字节来作为数据的传输方式。代码示例: - -```java -byte b; // 声明一个 byte 类型变量 -b = 10; // 将值 10 赋给变量 b -byte c = -100; // 声明并初始化一个 byte 类型变量 c,赋值为 -100 -``` - -#### 3)short - -short 的取值范围在 -32,768 和 32,767 之间,包含 32,767。代码示例: - -```java -short s; // 声明一个 short 类型变量 -s = 1000; // 将值 1000 赋给变量 s -short t = -2000; // 声明并初始化一个 short 类型变量 t,赋值为 -2000 -``` - -实际开发中,short 比较少用,整型用 int 就 OK。 - -#### 3)int - -int 的取值范围在 -2,147,483,648(-2 ^ 31)和 2,147,483,647(2 ^ 31 -1)(含)之间。如果没有特殊需求,整型数据就用 int。代码示例: - -```java -int i; // 声明一个 int 类型变量 -i = 1000000; // 将值 1000000 赋给变量 i -int j = -2000000; // 声明并初始化一个 int 类型变量 j,赋值为 -2000000 -``` - -#### 5)long - -long 的取值范围在 -9,223,372,036,854,775,808(-2^63) 和 9,223,372,036,854,775,807(2^63 -1)(含)之间。如果 int 存储不下,就用 long。代码示例: - -```java -long l; // 声明一个 long 类型变量 -l = 100000000000L; // 将值 100000000000L 赋给变量 l(注意要加上 L 后缀) -long m = -20000000000L; // 声明并初始化一个 long 类型变量 m,赋值为 -20000000000L -``` - -为了和 int 作区分,long 型变量在声明的时候,末尾要带上大写的“L”。不用小写的“l”,是因为小写的“l”容易和数字“1”混淆。 - -#### 6)float - -float 是单精度的浮点数(单精度浮点数的有效数字大约为 6 到 7 位),32 位(4 字节),遵循 IEEE 754(二进制浮点数算术标准),取值范围为 1.4E-45 到 3.4E+38。float 不适合用于精确的数值,比如说金额。代码示例: - -```java -float f; // 声明一个 float 类型变量 -f = 3.14159f; // 将值 3.14159f 赋给变量 f(注意要加上 f 后缀) -float g = -2.71828f; // 声明并初始化一个 float 类型变量 g,赋值为 -2.71828f -``` - -为了和 double 作区分,float 型变量在声明的时候,末尾要带上小写的“f”。不需要使用大写的“F”,是因为小写的“f”很容易辨别。 - -#### 7)double - -double 是双精度浮点数(双精度浮点数的有效数字大约为 15 到 17 位),占 64 位(8 字节),也遵循 IEEE 754 标准,取值范围大约 ±4.9E-324 到 ±1.7976931348623157E308。double 同样不适合用于精确的数值,比如说金额。 - -代码示例: - -```java -double myDouble = 3.141592653589793; -``` - -在进行金融计算或需要精确小数计算的场景中,可以使用 [BigDecimal 类](https://tobebetterjavaer.com/basic-grammar/bigdecimal-biginteger.html)来避免浮点数舍入误差。BigDecimal 可以表示一个任意大小且精度完全准确的浮点数。 - -> 在实际开发中,如果不是特别大的金额(精确到 0.01 元,也就是一分钱),一般建议乘以 100 转成整型进行处理。 - -#### 8)char - -char 用于表示 Unicode 字符,占 16 位(2 字节)的存储空间,取值范围为 0 到 65,535。 - -代码示例: - -```java -char letterA = 'A'; // 用英文的单引号包裹住。 -``` - -注意,字符字面量应该用单引号('')包围,而不是双引号(""),因为[双引号表示字符串字面量](https://tobebetterjavaer.com/string/constant-pool.html)。 - -### 03、单精度和双精度 - -单精度(single-precision)和双精度(double-precision)是指两种不同精度的浮点数表示方法。 - -单精度是这样的格式,1 位符号,8 位指数,23 位小数。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/nine-04.png) - -单精度浮点数通常占用 32 位(4 字节)存储空间。数值范围大约是 ±1.4E-45 到 ±3.4028235E38,精度大约为 6 到 9 位有效数字。 - -双精度是这样的格式,1 位符号,11 位指数,52 为小数。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/nine-05.png) - -双精度浮点数通常占用 64 位(8 字节)存储空间,数值范围大约是 ±4.9E-324 到 ±1.7976931348623157E308,精度大约为 15 到 17 位有效数字。 - -计算精度取决于小数位(尾数)。小数位越多,则能表示的数越大,那么计算精度则越高。 - -一个数由若干位数字组成,其中影响测量精度的数字称作有效数字,也称有效数位。有效数字指科学计算中用以表示一个浮点数精度的那些数字。一般地,指一个用小数形式表示的浮点数中,从第一个非零的数字算起的所有数字。如 1.24 和 0.00124 的有效数字都有 3 位。 - -以下是确定有效数字的一些基本规则: - -- 非零数字总是有效的。 -- 位于两个非零数字之间的零是有效的。 -- 对于小数,从左侧开始的第一个非零数字之前的零是无效的。 -- 对于整数,从右侧开始的第一个非零数字之后的零是无效的。 - -下面是一些示例,说明如何确定有效数字: - -- 1234:4 个有效数字(所有数字都是非零数字) -- 1002:4 个有效数字(零位于两个非零数字之间) -- 0.00234:3 个有效数字(从左侧开始的前两个零是无效的) -- 1200:2 个有效数字(从右侧开始的两个零是无效的) - -### 04、int 和 char 类型互转 - -int 和 char 之间比较特殊,可以互转,也会在以后的学习当中经常遇到。 - -1)可以通过[强制类型转换](https://tobebetterjavaer.com/basic-grammar/type-cast.html)将整型 int 转换为字符 char。 - -```java -int value_int = 65; -char value_char = (char) value_int; -System.out.println(value_char); -``` - -输出 `A`(其 [ASCII 值](https://tobebetterjavaer.com/basic-extra-meal/java-unicode.html)可以通过整数 65 来表示)。 - -2)可以使用 `Character.forDigit()` 方法将整型 int 转换为字符 char,参数 radix 为基数,十进制为 10,十六进制为 16。。 - -```java -int radix = 10; -int value_int = 6; -char value_char = Character.forDigit(value_int , radix); -System.out.println(value_char ); -``` - -Character 为 char 的包装器类型。我们随后会讲。 - -3)可以使用 int 的包装器类型 Integer 的 `toString()` 方法+String 的 `charAt()` 方法转成 char。 - -```java -int value_int = 1; -char value_char = Integer.toString(value_int).charAt(0); -System.out.println(value_char ); -``` - -4)char 转 int - -当然了,如果只是简单的 char 转 int,直接赋值就可以了。 - -```java -int a = 'a'; -``` - -因为发生了[自动类型转换](https://tobebetterjavaer.com/basic-grammar/type-cast.html),后面会细讲。 - -### 05、包装器类型 - -包装器类型(Wrapper Types)是 Java 中的一种特殊类型,用于将基本数据类型(如 int、float、char 等)转换为对应的[对象类型](https://tobebetterjavaer.com/oo/object-class.html)。 - -Java 提供了以下包装器类型,与基本数据类型一一对应: - -- Byte(对应 byte) -- Short(对应 short) -- Integer(对应 int) -- Long(对应 long) -- Float(对应 float) -- Double(对应 double) -- Character(对应 char) -- Boolean(对应 boolean) - -包装器类型允许我们使用基本数据类型提供的各种实用方法,并兼容需要对象类型的场景。例如,我们可以使用 Integer 类的 parseInt 方法将字符串转换为整数,或使用 Character 类的 isDigit 方法检查字符是否为数字,还有前面提到的 `Character.forDigit()` 方法。 - -下面是一个简单的示例,演示了如何使用包装器类型: - -```java -// 使用 Integer 包装器类型 -Integer integerValue = new Integer(42); -System.out.println("整数值: " + integerValue); - -// 将字符串转换为整数 -String numberString = "123"; -int parsedNumber = Integer.parseInt(numberString); -System.out.println("整数值: " + parsedNumber); - -// 使用 Character 包装器类型 -Character charValue = new Character('A'); -System.out.println("字符: " + charValue); - -// 检查字符是否为数字 -char testChar = '9'; -if (Character.isDigit(testChar)) { -System.out.println("字符是个数字."); -} -``` - -上面的示例中,我们创建了一个 [Integer 类型](https://tobebetterjavaer.com/basic-extra-meal/int-cache.html)的对象 integerValue 并为其赋值 42。然后,我们将其值打印到控制台。 - -我们有一个包含数字的[字符串](https://tobebetterjavaer.com/string/immutable.html) numberString。我们使用 `Integer.parseInt()` 方法将其转换为整数 parsedNumber。然后,我们将转换后的值打印到控制台。 - -我们有一个字符变量 testChar,并为其赋值字符 '9'。我们使用 `Character.isDigit()` 方法检查 testChar 是否为数字字符。如果是数字字符,我们将输出一条消息到控制台。 - -从 Java 5 开始,[自动装箱(Autoboxing)和自动拆箱(Unboxing)机制](https://tobebetterjavaer.com/basic-extra-meal/box.html)允许我们在基本数据类型和包装器类型之间自动转换,无需显式地调用构造方法或转换方法(链接里会细讲)。 - -```java -Integer integerValue = 42; // 自动装箱,等同于 new Integer(42) -int primitiveValue = integerValue; // 自动拆箱,等同于 integerValue.intValue() -``` - -### 06、引用数据类型 - -基本数据类型在作为成员变量和静态变量的时候有默认值,引用数据类型也有的(学完数组&字符串,以及面向对象编程后会更加清楚,这里先简单过一下)。 - -[String](https://tobebetterjavaer.com/string/immutable.html) 是最典型的引用数据类型,所以我们就拿 String 类举例,看下面这段代码: - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class LocalRef { - private String a; - static String b; - - public static void main(String[] args) { - LocalRef lv = new LocalRef(); - System.out.println(lv.a); - System.out.println(b); - } -} -``` - -输出结果如下所示: - -``` -null -null -``` - -null 在 Java 中是一个很神奇的存在,在你以后的程序生涯中,见它的次数不会少,尤其是伴随着令人烦恼的“[空指针异常](https://tobebetterjavaer.com/exception/npe.html)”,也就是所谓的 `NullPointerException`。 - -也就是说,引用数据类型的默认值为 null,包括数组和接口。 - -那你是不是很好奇,为什么[数组](https://tobebetterjavaer.com/array/array.html)和[接口](https://tobebetterjavaer.com/oo/interface.html)也是引用数据类型啊? - -先来看数组: - -```java -int [] arrays = {1,2,3}; -System.out.println(arrays); -``` - -arrays 是一个 int 类型的数组,对吧?打印结果如下所示: - -``` -[I@2d209079 -``` - -`[I` 表示数组是 int 类型的,@ 后面是十六进制的 hashCode——这样的打印结果太“人性化”了,一般人表示看不懂!为什么会这样显示呢?查看一下 `java.lang.Object` 类的 `toString()` 方法就明白了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/nine-06.png) - -数组虽然没有显式定义成一个类,但它的确是一个对象,继承了祖先类 Object 的所有方法。那为什么数组不单独定义一个类来表示呢?就像字符串 String 类那样呢? - -一个合理的解释是 Java 将其隐藏了。假如真的存在一个 Array.java,我们也可以假想它真实的样子,它必须要定义一个容器来存放数组的元素,就像 String 类那样。 - -```java -public final class String - implements java.io.Serializable, Comparable, CharSequence { - /** The value is used for character storage. */ - private final char value[]; -} -``` - -数组内部定义数组?没必要的! - -再来看接口: - -```java -List list = new ArrayList<>(); -System.out.println(list); -``` - -[List](https://tobebetterjavaer.com/collection/gailan.html) 是一个非常典型的接口: - -```java -public interface List extends Collection {} -``` - -而 [ArrayList](https://tobebetterjavaer.com/collection/arraylist.html) 是 List 接口的一个实现: - -```java -public class ArrayList extends AbstractList - implements List, RandomAccess, Cloneable, java.io.Serializable -{} -``` - -对于接口类型的引用变量来说,你没法直接 new 一个: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/nine-07.png) - -只能 new 一个实现它的类的对象——那自然接口也是引用数据类型了。 - -来看一下基本数据类型和引用数据类型之间最大的差别。 - -基本数据类型: - -- 1、变量名指向具体的数值。 -- 2、基本数据类型存储在栈上。 - -引用数据类型: - -- 1、变量名指向的是存储对象的内存地址,在栈上。 -- 2、内存地址指向的对象存储在堆上。 - -### 07、堆和栈 - -看到这,三妹是不是又要问,“堆是什么,栈又是什么?” - -堆是堆(heap),栈是栈(stack),如果看到“堆栈”的话,请不要怀疑自己,那是翻译的错,堆栈也是栈,反正我很不喜欢“堆栈”这种叫法,容易让新人掉坑里。 - -堆是在程序运行时在内存中申请的空间(可理解为动态的过程);切记,不是在编译时;因此,Java 中的对象就放在这里,这样做的好处就是: - -> 当需要一个对象时,只需要通过 new 关键字写一行代码即可,当执行这行代码时,会自动在内存的“堆”区分配空间——这样就很灵活。 - -栈,能够和处理器(CPU,也就是脑子)直接关联,因此访问速度更快。既然访问速度快,要好好利用啊!Java 就把对象的引用放在栈里。为什么呢?因为引用的使用频率高吗? - -不是的,因为 Java 在编译程序时,必须明确的知道存储在栈里的东西的生命周期,否则就没法释放旧的内存来开辟新的内存空间存放引用——空间就那么大,前浪要把后浪拍死在沙滩上啊。 - -这么说就理解了吧? - -如果还不理解的话,可以看一下这个视频,讲的非常不错:[什么是堆?什么是栈?他们之间有什么区别和联系?](https://www.zhihu.com/question/19729973/answer/2238950166) - -用图来表示一下,左侧是栈,右侧是堆。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-grammar/basic-data-type-dc26645a-3ed8-4ad4-815d-52528ad12d6b.png) - -这里再补充一些额外的知识点,能看懂就继续吸收,看不懂可以先去学下一节,以后再来补,没关系的。学习就是这样,可以跳过,可以温故。 - -举个例子。 - -```java -String a = new String("沉默王二") -``` - -这段代码会先在堆里创建一个 沉默王二的字符串对象,然后再把对象的引用 a 放到栈里面。这里面还会涉及到[字符串常量池](https://tobebetterjavaer.com/string/constant-pool.html),后面会讲。 - -那么对于这样一段代码,有基本数据类型的变量,有引用类型的变量,堆和栈都是如何存储他们的呢? - -```java -public void test() -{ - int i = 4; - int y = 2; - Object o1 = new Object(); -} -``` - -我来画个图表示下。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-grammar/basic-data-type-3d5b3e40-1abb-4624-8282-b83e58388825.png) - -应该一目了然了吧? - -“好了,三妹,关于 Java 中的数据类型就先说这么多吧,你是不是已经清楚了?”转动了一下僵硬的脖子后,我对三妹说。 - -### 08、小结 - -本文详细探讨了 Java 数据类型,包括比特与字节、基本数据类型、单精度与双精度、int 与 char 互转、包装器类型、引用数据类型以及堆与栈的内存模型。通过阅读本文,你将全面了解 Java 数据类型的概念与使用方法,为 Java 编程打下坚实基础。 - - - -## 3.4 Java数据类型转换 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[3.4 Java数据类型转换](https://tobebetterjavaer.com/basic-grammar/type-cast.html) - - - -## 3.5 Java基本数据类型缓存池 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[3.5 Java基本数据类型缓存池](https://tobebetterjavaer.com/basic-extra-meal/int-cache.html) - -## 3.6 Java运算符 - -“二哥,让我盲猜一下哈,运算符是不是指的就是加减乘除啊?”三妹的脸上泛着甜甜的笑容,我想她一定对提出的问题很有自信。 - -“是的,三妹。运算符在 Java 中占据着重要的位置,对程序的执行有着很大的帮助。除了常见的加减乘除,还有许多其他类型的运算符,来看下面这张思维导图。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/eleven-01.png) - - -### 01、算术运算符 - -算术运算符除了最常见的加减乘除,还有一个取余的运算符,用于得到除法运算后的余数,来串代码感受下。 - -```java -int a = 10; -int b = 5; - -System.out.println(a + b);//15 -System.out.println(a - b);//5 -System.out.println(a * b);//50 -System.out.println(a / b);//2 -System.out.println(a % b);//0 - -b = 3; -System.out.println(a + b);//13 -System.out.println(a - b);//7 -System.out.println(a * b);//30 -System.out.println(a / b);//3 -System.out.println(a % b);//1 -``` - -对于初学者来说,加法(+)、减法(-)、乘法(*)很好理解,但除法(/)和取余(%)会有一点点疑惑。在以往的认知里,10/3 是除不尽的,结果应该是 3.333333...,而不应该是 3。相应的,余数也不应该是 1。这是为什么呢? - -因为数字在程序中可以分为两种,一种是整型,一种是浮点型(不清楚的同学可以回头看看[数据类型那篇](https://tobebetterjavaer.com/basic-grammar/basic-data-type.html)),整型和整型的运算结果就是整型,不会出现浮点型。否则,就会出现浮点型。 - -```java -int a = 10; -float c = 3.0f; -double d = 3.0; -System.out.println(a / c); // 3.3333333 -System.out.println(a / d); // 3.3333333333333335 -System.out.println(a % c); // 1.0 -System.out.println(a % d); // 1.0 -``` - -需要注意的是,当浮点数除以 0 的时候,结果为 Infinity 或者 NaN。 - -```java -System.out.println(10.0 / 0.0); // Infinity -System.out.println(0.0 / 0.0); // NaN -``` - -Infinity 的中文意思是无穷大,NaN 的中文意思是这不是一个数字(Not a Number)。 - -当整数除以 0 的时候(`10 / 0`),会抛出[异常](https://tobebetterjavaer.com/exception/gailan.html): - -``` -Exception in thread "main" java.lang.ArithmeticException: / by zero - at com.itwanger.eleven.ArithmeticOperator.main(ArithmeticOperator.java:32) -``` - -所以整数在进行除法运算时,需要先判断除数是否为 0,以免程序抛出异常。 - -算术运算符中还有两种特殊的运算符,自增运算符(++)和自减运算符(--),它们也叫做一元运算符,只有一个操作数。 - -```java -int x = 10; -System.out.println(x++);//10 (11) -System.out.println(++x);//12 -System.out.println(x--);//12 (11) -System.out.println(--x);//10 -``` - -一元运算符可以放在数字的前面或者后面,放在前面叫前自增(前自减),放在后面叫后自增(后自减)。 - -前自增和后自增是有区别的,拿 `int y = ++x` 这个表达式来说(x = 10),它可以拆分为 `x = x+1 = 11; y = x = 11`,所以表达式的结果为 `x = 11, y = 11`。拿 `int y = x++` 这个表达式来说(x = 10),它可以拆分为 `y = x = 10; x = x+1 = 11`,所以表达式的结果为 `x = 11, y = 10`。 - -```java -int x = 10; -int y = ++x; -System.out.println(y + " " + x);// 11 11 - -x = 10; -y = x++; -System.out.println(y + " " + x);// 10 11 -``` - -对于前自减和后自减来说,你可以自己试一把。 - -### 02、关系运算符 - -关系运算符用来比较两个操作数,返回结果为 true 或者 false。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/eleven-02.png) - -来看示例: - -```java -int a = 10, b = 20; -System.out.println(a == b); // false -System.out.println(a != b); // true -System.out.println(a > b); // false -System.out.println(a < b); // true -System.out.println(a >= b); // false -System.out.println(a <= b); // true -``` - -### 03、位运算符 - -在学习位运算符之前,需要先学习一下二进制,因为位运算符操作的不是整型数值(int、long、short、char、byte)本身,而是整型数值对应的二进制。 - -```java -System.out.println(Integer.toBinaryString(60)); // 111100 -System.out.println(Integer.toBinaryString(13)); // 1101 -``` - - 从程序的输出结果可以看得出来,60 的二进制是 0011 1100(用 0 补到 8 位),13 的二进制是 0000 1101。 - -PS:现代的二进制记数系统由戈特弗里德·威廉·莱布尼茨于 1679 年设计。莱布尼茨是德意志哲学家、数学家,历史上少见的通才。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-grammar/eleven-03.png) - -来看示例: - -```java -int a = 60, b = 13; -System.out.println("a 的二进制:" + Integer.toBinaryString(a)); // 111100 -System.out.println("b 的二进制:" + Integer.toBinaryString(b)); // 1101 - -int c = a & b; -System.out.println("a & b:" + c + ",二进制是:" + Integer.toBinaryString(c)); - -c = a | b; -System.out.println("a | b:" + c + ",二进制是:" + Integer.toBinaryString(c)); - -c = a ^ b; -System.out.println("a ^ b:" + c + ",二进制是:" + Integer.toBinaryString(c)); - -c = ~a; -System.out.println("~a:" + c + ",二进制是:" + Integer.toBinaryString(c)); - -c = a << 2; -System.out.println("a << 2:" + c + ",二进制是:" + Integer.toBinaryString(c)); - -c = a >> 2; -System.out.println("a >> 2:" + c + ",二进制是:" + Integer.toBinaryString(c)); - -c = a >>> 2; -System.out.println("a >>> 2:" + c + ",二进制是:" + Integer.toBinaryString(c)); -``` - -对于初学者来说,位运算符无法从直观上去计算出结果,不像加减乘除那样。因为我们日常接触的都是十进制,位运算的时候需要先转成二进制,然后再计算出结果。 - -鉴于此,初学者在写代码的时候其实很少会用到位运算。对于编程高手来说,为了提高程序的性能,会在一些地方使用位运算。比如说,HashMap 在计算哈希值的时候: - -```java -static final int hash(Object key) { - int h; - return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); -} -``` - -如果对位运算一点都不懂的话,遇到这样的源码就很吃力。所以说,虽然位运算用的少,但还是要懂。 - -1)按位左移运算符: - -```java -System.out.println(10<<2);//10*2^2=10*4=40 -System.out.println(10<<3);//10*2^3=10*8=80 -System.out.println(20<<2);//20*2^2=20*4=80 -System.out.println(15<<4);//15*2^4=15*16=240 -``` - -`10<<2` 等于 10 乘以 2 的 2 次方;`10<<3` 等于 10 乘以 2 的 3 次方。 - -2)按位右移运算符: - -```java -System.out.println(10>>2);//10/2^2=10/4=2 -System.out.println(20>>2);//20/2^2=20/4=5 -System.out.println(20>>3);//20/2^3=20/8=2 -``` - -`10>>2` 等于 10 除以 2 的 2 次方;`20>>2` 等于 20 除以 2 的 2 次方。 - -### 04、逻辑运算符 - -逻辑与运算符(&&):多个条件中只要有一个为 false 结果就为 false。 - -逻辑或运算符(||):多个条件只要有一个为 true 结果就为 true。 - -```java -int a=10; -int b=5; -int c=20; -System.out.println(ab||ab|a aList = new ArrayList<>(); -for (int element : anArray) { - aList.add(element); -} -``` - -更优雅的方式是通过 Arrays 类的 `asList()` 方法: - -```java -List aList = Arrays.asList(anArray); -``` - -不过需要注意的是,Arrays.asList 的参数需要是 Integer 数组,而 anArray 目前是 int 类型,我们需要换另外一种方式。 - -```java -List aList = Arrays.stream(anArray).boxed().collect(Collectors.toList()); -``` - -这又涉及到了 Java [流](https://tobebetterjavaer.com/java8/stream.html)的知识,后面会讲到。 - -还有一个需要注意的是,Arrays.asList 方法返回的 ArrayList 并不是 `java.util.ArrayList`,它其实是 Arrays 类的一个内部类: - -```java -private static class ArrayList extends AbstractList - implements RandomAccess, java.io.Serializable{} -``` - -如果需要添加元素或者删除元素的话,需要把它转成 `java.util.ArrayList`。 - -```java -new ArrayList<>(Arrays.asList(anArray)); -``` - -Java 8 新增了 Stream 流的概念,这就意味着我们也可以将数组转成 Stream 进行操作。 - -```java -String[] anArray = new String[] {"沉默王二", "一枚有趣的程序员", "好好珍重他"}; -Stream aStream = Arrays.stream(anArray); -``` - -如果想对数组进行排序的话,可以使用 Arrays 类提供的 `sort()` 方法。 - -- 基本数据类型按照升序排列 -- 实现了 Comparable 接口的对象按照 `compareTo()` 的排序 - -来看第一个例子: - -```java -int[] anArray = new int[] {5, 2, 1, 4, 8}; -Arrays.sort(anArray); -``` - -排序后的结果如下所示: - -```java -[1, 2, 4, 5, 8] -``` - -来看第二个例子: - -```java -String[] yetAnotherArray = new String[] {"A", "E", "Z", "B", "C"}; -Arrays.sort(yetAnotherArray, 1, 3, - Comparator.comparing(String::toString).reversed()); -``` - -只对 1-3 位置上的元素进行反序,所以结果如下所示: - -``` -[A, Z, E, B, C] -``` - -有时候,我们需要从数组中查找某个具体的元素,最直接的方式就是通过遍历的方式: - -```java -int[] anArray = new int[] {5, 2, 1, 4, 8}; -for (int i = 0; i < anArray.length; i++) { - if (anArray[i] == 4) { - System.out.println("找到了 " + i); - break; - } -} -``` - -上例中从数组中查询元素 4,找到后通过 break 关键字退出循环。 - -如果数组提前进行了排序,就可以使用二分查找法,这样效率就会更高一些。`Arrays.binarySearch()` 方法可供我们使用,它需要传递一个数组,和要查找的元素。 - -```java -int[] anArray = new int[] {1, 2, 3, 4, 5}; -int index = Arrays.binarySearch(anArray, 4); -``` - -“除了一维数组,还有二维数组,三妹你可以去研究下,比如说用二维数组打印一下杨辉三角。”说完,我就去阳台上休息了,留三妹在那里学习,不能打扰她。 - - - -## 4.2 掌握Java二维数组 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[4.2 掌握Java二维数组](https://tobebetterjavaer.com/array/double-array.html) - - - -## 4.3 打印Java数组 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[4.3 打印Java数组](https://tobebetterjavaer.com/array/print.html) - -## 4.4 解读String类源码 - -我正坐在沙发上津津有味地读刘欣大佬的《码农翻身》——Java 帝国这一章,门铃响了。起身打开门一看,是三妹,她从学校回来了。 - -“三妹,你回来的真及时,今天我们打算讲 Java 中的字符串呢。”等三妹换鞋的时候我说。 - -“哦,可以呀,哥。听说字符串的细节特别多,什么[字符串常量池](https://tobebetterjavaer.com/string/constant-pool.html)了、字符串不可变性了、[字符串拼接](https://tobebetterjavaer.com/string/join.html)了、字符串长度限制了等等,你最好慢慢讲,否则我可能一时半会消化不了。”三妹的态度显得很诚恳。 - -“嗯,我已经想好了,今天就只带你大概认识一下字符串,主要读一读它的源码,其他的细节咱们后面再慢慢讲,保证你能及时消化。” - -“好,那就开始吧。”三妹已经准备好坐在了电脑桌的边上。 - -我应了一声后走到电脑桌前坐下来,顺手打开 [Intellij IDEA](https://tobebetterjavaer.com/overview/IDEA-install-config.html),并找到了 String 的源码(Java 8)。 - -### String 类的声明 - -```java -public final class String - implements java.io.Serializable, Comparable, CharSequence { -} -``` - -“第一,String 类是 [final](https://tobebetterjavaer.com/oo/final.html) 的,意味着它不能被子类继承。” - -“第二,String 类实现了 [Serializable 接口](https://tobebetterjavaer.com/io/Serializbale.html),意味着它可以[序列化](https://tobebetterjavaer.com/io/serialize.html)。” - -“第三,String 类实现了 [Comparable 接口](https://tobebetterjavaer.com/basic-extra-meal/comparable-omparator.html),意味着最好不要用‘==’来[比较两个字符串是否相等](https://tobebetterjavaer.com/string/equals.html),而应该用 `compareTo()` 方法去比较。” - -因为 == 是用来比较两个对象的地址,这个在讲字符串比较的时候会详细讲。如果只是说比较字符串内容的话,可以使用 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; -} -``` - -“第四,[StringBuffer、StringBuilder 和 String](https://tobebetterjavaer.com/string/builder-buffer.html) 一样,都实现了 CharSequence 接口,所以它们仨属于近亲。由于 String 是不可变的,所以遇到字符串拼接的时候就可以考虑一下 String 的另外两个好兄弟,StringBuffer 和 StringBuilder,它俩是可变的。” - -### String 类的底层实现 - -```java -private final char value[]; -``` - -“第五,Java 9 以前,String 是用 char 型[数组](https://tobebetterjavaer.com/array/array.html)实现的,之后改成了 byte 型数组实现,并增加了 coder 来表示编码。这样做的好处是在 Latin1 字符为主的程序里,可以把 String 占用的内存减少一半。当然,天下没有免费的午餐,这个改进在节省内存的同时引入了编码检测的开销。” - ->Latin1(Latin-1)是一种单字节字符集(即每个字符只使用一个字节的编码方式),也称为ISO-8859-1(国际标准化组织8859-1),它包含了西欧语言中使用的所有字符,包括英语、法语、德语、西班牙语、葡萄牙语、意大利语等等。在Latin1编码中,每个字符使用一个8位(即一个字节)的编码,可以表示256种不同的字符,其中包括ASCII字符集中的所有字符,即0x00到0x7F,以及其他西欧语言中的特殊字符,例如é、ü、ñ等等。由于Latin1只使用一个字节表示一个字符,因此在存储和传输文本时具有较小的存储空间和较快的速度 - -```java -public final class String - implements java.io.Serializable, Comparable, CharSequence { - @Stable - private final byte[] value; - private final byte coder; - private int hash; -} -``` - -接下来,我们来详细地说一下。 - -从 `char[]` 到 `byte[]`,最主要的目的是**节省字符串占用的内存空间**。内存占用减少带来的另外一个好处,就是 GC 次数也会减少。 - -我们使用 `jmap -histo:live pid | head -n 10` 命令就可以查看到堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。 - -以我正在运行着的[编程喵](https://github.com/itwanger/coding-more)项目实例(基于 Java 8)来说,结果是这样的。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-extra-meal/jdk9-char-byte-string-d826ce88-bbbe-47a3-a1a9-4dd86dd3632f.png) - -其中 String 对象有 17638 个,占用了 423312 个字节的内存,排在第三位。 - -由于 Java 8 的 String 内部实现仍然是 `char[]`,所以我们可以看到内存占用排在第 1 位的就是 char 数组。 - -`char[]` 对象有 17673 个,占用了 1621352 个字节的内存,排在第一位。 - -那也就是说优化 String 节省内存空间是非常有必要的,如果是去优化一个使用频率没有 String 这么高的类,就没什么必要,对吧? - -众所周知,char 类型的数据在 JVM 中是占用两个字节的,并且使用的是 UTF-8 [编码](https://tobebetterjavaer.com/basic-extra-meal/java-unicode.html),其值范围在 '\u0000'(0)和 '\uffff'(65,535)(包含)之间。 - -也就是说,使用 `char[]` 来表示 String 就会导致,即使 String 中的字符只用一个字节就能表示,也得占用两个字节。 - ->PS:在计算机中,单字节字符通常指的是一个字节(8位)可以表示的字符,而双字节字符则指需要两个字节(16位)才能表示的字符。单字节字符和双字节字符的定义是相对的,不同的编码方式对应的单字节和双字节字符集也不同。常见的单字节字符集有ASCII(美国信息交换标准代码)、ISO-8859(国际标准化组织标准编号8859)、GBK(汉字内码扩展规范)、GB2312(中国国家标准,现在已经被GBK取代),像拉丁字母、数字、标点符号、控制字符都是单字节字符。双字节字符集包括 Unicode、UTF-8、GB18030(中国国家标准),中文、日文、韩文、拉丁文扩展字符属于双字节字符。 - -当然了,仅仅将 `char[]` 优化为 `byte[]` 是不够的,还要配合 Latin-1 的编码方式,该编码方式是用单个字节来表示字符的,这样就比 UTF-8 编码节省了更多的空间。 - -换句话说,对于: - -```java -String name = "jack"; -``` - -这样的,使用 Latin-1 编码,占用 4 个字节就够了。 - -但对于: - -```java -String name = "小二"; -``` - -这种,木的办法,只能使用 UTF16 来编码。 - -针对 JDK 9 的 String 源码里,为了区别编码方式,追加了一个 coder 字段来区分。 - -```java -/** - * The identifier of the encoding used to encode the bytes in - * {@code value}. The supported values in this implementation are - * - * LATIN1 - * UTF16 - * - * @implNote This field is trusted by the VM, and is a subject to - * constant folding if String instance is constant. Overwriting this - * field after construction will cause problems. - */ -private final byte coder; -``` - -Java 会根据字符串的内容自动设置为相应的编码,要么 Latin-1 要么 UTF16。 - -也就是说,从 `char[]` 到 `byte[]`,**中文是两个字节,纯英文是一个字节,在此之前呢,中文是两个字节,英文也是两个字节**。 - -在 UTF-8 中,0-127 号的字符用 1 个字节来表示,使用和 ASCII 相同的编码。只有 128 号及以上的字符才用 2 个、3 个或者 4 个字节来表示。 - -- 如果只有一个字节,那么最高的比特位为 0; -- 如果有多个字节,那么第一个字节从最高位开始,连续有几个比特位的值为 1,就使用几个字节编码,剩下的字节均以 10 开头。 - -具体的表现形式为: - -- 0xxxxxxx:一个字节; -- 110xxxxx 10xxxxxx:两个字节编码形式(开始两个 1); -- 1110xxxx 10xxxxxx 10xxxxxx:三字节编码形式(开始三个 1); -- 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:四字节编码形式(开始四个 1)。 - -也就是说,UTF-8 是变长的,那对于 String 这种有随机访问方法的类来说,就很不方便。所谓的随机访问,就是charAt、subString这种方法,随便指定一个数字,String要能给出结果。如果字符串中的每个字符占用的内存是不定长的,那么进行随机访问的时候,就需要从头开始数每个字符的长度,才能找到你想要的字符。 - -那你可能会问,UTF-16也是变长的呢?一个字符还可能占用 4 个字节呢? - -的确,UTF-16 使用 2 个或者 4 个字节来存储字符。 - -- 对于 Unicode 编号范围在 0 ~ FFFF 之间的字符,UTF-16 使用两个字节存储。 -- 对于 Unicode 编号范围在 10000 ~ 10FFFF 之间的字符,UTF-16 使用四个字节存储,具体来说就是:将字符编号的所有比特位分成两部分,较高的一些比特位用一个值介于 D800~DBFF 之间的双字节存储,较低的一些比特位(剩下的比特位)用一个值介于 DC00~DFFF 之间的双字节存储。 - -但是在 Java 中,一个字符(char)就是 2 个字节,占 4 个字节的字符,在 Java 里也是用两个 char 来存储的,而String的各种操作,都是以Java的字符(char)为单位的,charAt是取得第几个char,subString取的也是第几个到第几个char组成的子串,甚至length返回的都是char的个数。 - -所以UTF-16在Java的世界里,就可以视为一个定长的编码。 - ->参考链接:[https://www.zhihu.com/question/447224628](https://www.zhihu.com/question/447224628) - -### String 类的 hashCode 方法 - -“第六,每一个字符串都会有一个 hash 值,这个哈希值在很大概率是不会重复的,因此 String 很适合来作为 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html) 的键值。” - -来看 String 类的 hashCode 方法。 - -```java -private int hash; // Cache the hash code for the string - -public int hashCode() { - int h = hash; - if (h == 0 && value.length > 0) { - char val[] = value; - - for (int i = 0; i < value.length; i++) { - h = 31 * h + val[i]; - } - hash = h; - } - return h; -} -``` - -hashCode 方法首先检查是否已经计算过哈希码,如果已经计算过,则直接返回缓存的哈希码。否则,方法将使用一个循环遍历字符串的所有字符,并使用一个乘法和加法的组合计算哈希码。这种计算方法被称为“31 倍哈希法”。计算完成后,将得到的哈希值存储在 hash 成员变量中,以便下次调用 hashCode 方法时直接返回该值,而不需要重新计算。这是一种缓存优化,称为“惰性计算”。 - -31倍哈希法(31-Hash)是一种简单有效的字符串哈希算法,常用于对字符串进行哈希处理。该算法的基本思想是将字符串中的每个字符乘以一个固定的质数31的幂次方,并将它们相加得到哈希值。具体地,假设字符串为s,长度为n,则31倍哈希值计算公式如下: - -``` -H(s) = (s[0] * 31^(n-1)) + (s[1] * 31^(n-2)) + ... + (s[n-1] * 31^0) -``` - -其中,s[i]表示字符串s中第i个字符的ASCII码值,`^`表示幂运算。 - -31倍哈希法的优点在于简单易实现,计算速度快,同时也比较均匀地分布在哈希表中。 - -[hashCode 方法](https://tobebetterjavaer.com/basic-extra-meal/hashcode.html),我们会在另外一个章节里详细讲,戳前面的链接了解。 - -我们可以通过以下方法模拟 String 的 hashCode 方法: - -```java -public class HashCodeExample { - public static void main(String[] args) { - String text = "沉默王二"; - int hashCode = computeHashCode(text); - System.out.println("字符串 \"" + text + "\" 的哈希码是: " + hashCode); - - System.out.println("String 的 hashCode " + text.hashCode()); - } - - public static int computeHashCode(String text) { - int h = 0; - for (int i = 0; i < text.length(); i++) { - h = 31 * h + text.charAt(i); - } - return h; - } -} -``` - -看一下结果: - -``` -字符串 "沉默王二" 的哈希码是: 867758096 -String 的 hashCode 867758096 -``` - -结果是一样的,又学到了吧? - -### String 类的 substring 方法 - -String 类中还有一个方法比较常用 substring,用来截取字符串的,来看源码。 - -```java -public String substring(int beginIndex) { - if (beginIndex < 0) { - throw new StringIndexOutOfBoundsException(beginIndex); - } - int subLen = value.length - beginIndex; - if (subLen < 0) { - throw new StringIndexOutOfBoundsException(subLen); - } - return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); -} -``` - -substring 方法首先检查参数的有效性,如果参数无效,则抛出 StringIndexOutOfBoundsException [异常](https://tobebetterjavaer.com/exception/gailan.html)。接下来,方法根据参数计算子字符串的长度。如果子字符串长度小于零,抛出StringIndexOutOfBoundsException异常。 - -如果 beginIndex 为 0,且 endIndex 等于字符串的长度,说明子串与原字符串相同,因此直接返回原字符串。否则,使用 value 数组(原字符串的字符数组)的一部分创建一个新的 String 对象并返回。 - -下面是几个使用 substring 方法的示例: - -①、提取字符串中的一段子串: - -```java -String str = "Hello, world!"; -String subStr = str.substring(7, 12); // 从第7个字符(包括)提取到第12个字符(不包括) -System.out.println(subStr); // 输出 "world" -``` - -②、提取字符串中的前缀或后缀: - -```java -String str = "Hello, world!"; -String prefix = str.substring(0, 5); // 提取前5个字符,即 "Hello," -String suffix = str.substring(7); // 提取从第7个字符开始的所有字符,即 "world!" -``` - -③、处理字符串中的空格和分隔符: - -```java -String str = " Hello, world! "; -String trimmed = str.trim(); // 去除字符串开头和结尾的空格 -String[] words = trimmed.split("\\s+"); // 将字符串按照空格分隔成单词数组 -String firstWord = words[0].substring(0, 1); // 提取第一个单词的首字母 -System.out.println(firstWord); // 输出 "H" -``` - -④、处理字符串中的数字和符号: - -```java -String str = "1234-5678-9012-3456"; -String[] parts = str.split("-"); // 将字符串按照连字符分隔成四个部分 -String last4Digits = parts[3].substring(1); // 提取最后一个部分的后三位数字 -System.out.println(last4Digits); // 输出 "456" -``` - -总之,substring 方法可以根据需求灵活地提取字符串中的子串,为字符串处理提供了便利。 - -### String 类的 indexOf 方法 - -indexOf 方法用于查找一个子字符串在原字符串中第一次出现的位置,并返回该位置的索引。来看该方法的源码: - -```java -/* - * 查找字符数组 target 在字符数组 source 中第一次出现的位置。 - * sourceOffset 和 sourceCount 参数指定 source 数组中要搜索的范围, - * targetOffset 和 targetCount 参数指定 target 数组中要搜索的范围, - * fromIndex 参数指定开始搜索的位置。 - * 如果找到了 target 数组,则返回它在 source 数组中的位置索引(从0开始), - * 否则返回-1。 - */ -static int indexOf(char[] source, int sourceOffset, int sourceCount, - char[] target, int targetOffset, int targetCount, - int fromIndex) { - // 如果开始搜索的位置已经超出 source 数组的范围,则直接返回-1(如果 target 数组为空,则返回 sourceCount) - if (fromIndex >= sourceCount) { - return (targetCount == 0 ? sourceCount : -1); - } - // 如果开始搜索的位置小于0,则从0开始搜索 - if (fromIndex < 0) { - fromIndex = 0; - } - // 如果 target 数组为空,则直接返回开始搜索的位置 - if (targetCount == 0) { - return fromIndex; - } - - // 查找 target 数组的第一个字符在 source 数组中的位置 - char first = target[targetOffset]; - int max = sourceOffset + (sourceCount - targetCount); - - // 循环查找 target 数组在 source 数组中的位置 - for (int i = sourceOffset + fromIndex; i <= max; i++) { - /* Look for first character. */ - // 如果 source 数组中当前位置的字符不是 target 数组的第一个字符,则在 source 数组中继续查找 target 数组的第一个字符 - if (source[i] != first) { - while (++i <= max && source[i] != first); - } - - /* Found first character, now look at the rest of v2 */ - // 如果在 source 数组中找到了 target 数组的第一个字符,则继续查找 target 数组的剩余部分是否匹配 - if (i <= max) { - int j = i + 1; - int end = j + targetCount - 1; - for (int k = targetOffset + 1; j < end && source[j] - == target[k]; j++, k++); - - // 如果 target 数组全部匹配,则返回在 source 数组中的位置索引 - if (j == end) { - /* Found whole string. */ - return i - sourceOffset; - } - } - } - // 没有找到 target 数组,则返回-1 - return -1; -} -``` - -来看示例。 - -①、示例1:查找子字符串的位置 - -```java -String str = "Hello, world!"; -int index = str.indexOf("world"); // 查找 "world" 子字符串在 str 中第一次出现的位置 -System.out.println(index); // 输出 7 -``` - -②、示例2:查找字符串中某个字符的位置 - -```java -String str = "Hello, world!"; -int index = str.indexOf(","); // 查找逗号在 str 中第一次出现的位置 -System.out.println(index); // 输出 5 -``` - -③、示例3:查找子字符串的位置(从指定位置开始查找) - -```java -String str = "Hello, world!"; -int index = str.indexOf("l", 3); // 从索引为3的位置开始查找 "l" 子字符串在 str 中第一次出现的位置 -System.out.println(index); // 输出 3 -``` - -④、示例4:查找多个子字符串 - -```java -String str = "Hello, world!"; -int index1 = str.indexOf("o"); // 查找 "o" 子字符串在 str 中第一次出现的位置 -int index2 = str.indexOf("o", 5); // 从索引为5的位置开始查找 "o" 子字符串在 str 中第一次出现的位置 -System.out.println(index1); // 输出 4 -System.out.println(index2); // 输出 8 -``` - -### String 类的其他方法 - -比如说 `length()` 用于返回字符串长度。 - -比如说 `isEmpty()` 用于判断字符串是否为空。 - -比如说 `charAt()` 用于返回指定索引处的字符。 - -比如说 `getBytes()` 用于返回字符串的字节数组,可以指定编码方式,比如说: - -```java -String text = "沉默王二"; -System.out.println(Arrays.toString(text.getBytes(StandardCharsets.UTF_8))); -``` - -比如说 `trim()` 用于去除字符串两侧的空白字符,来看源码: - -```java -public String trim() { - int len = value.length; - int st = 0; - char[] val = value; /* avoid getfield opcode */ - - while ((st < len) && (val[st] <= ' ')) { - st++; - } - while ((st < len) && (val[len - 1] <= ' ')) { - len--; - } - return ((st > 0) || (len < value.length)) ? substring(st, len) : this; -} -``` - -举例:`" 沉默王二 ".trim()` 会返回"沉默王二" - -除此之外,还有 [split](https://tobebetterjavaer.com/string/split.html)、[equals](https://tobebetterjavaer.com/string/equals.html)、[join](https://tobebetterjavaer.com/string/join.html) 等这些方法,我们后面会一一来细讲。 - - - -## 4.5 String为什么不可变 - -String 可能是 Java 中使用频率最高的引用类型了,因此 String 类的设计者可以说是用心良苦。 - -比如说 String 的不可变性。 - -- String 类被 [final 关键字](https://tobebetterjavaer.com/oo/final.html)修饰,所以它不会有子类,这就意味着没有子类可以[重写](https://tobebetterjavaer.com/basic-extra-meal/override-overload.html)它的方法,改变它的行为。 -- String 类的数据存储在 `char[]` 数组中,而这个数组也被 final 关键字修饰了,这就表示 String 对象是没法被修改的,只要初始化一次,值就确定了。 - -```java -public final class String - implements java.io.Serializable, Comparable, CharSequence { - /** The value is used for character storage. */ - private final char value[]; -} -``` - -“哥,为什么要这样设计呢?”三妹有些不解。 - -“我先简单来说下,三妹,能懂最好,不能懂后面再细说。” - -第一,可以保证 String 对象的安全性,避免被篡改,毕竟像密码这种隐私信息一般就是用字符串存储的。 - -以下是一个简单的 Java 示例,演示了字符串的不可变性如何有助于保证 String 对象的安全性。在本例中,我们创建了一个简单的 User 类,该类使用 String 类型的字段存储用户名和密码。同时,我们使用一个静态方法 getUserCredentials 从外部获取用户凭据。 - -```java -class User { - private String username; - private String password; - - public User(String username, String password) { - this.username = username; - this.password = password; - } - - public String getUsername() { - return username; - } - - public String getPassword() { - return password; - } -} - -public class StringSecurityExample { - public static void main(String[] args) { - String username = "沉默王二"; - String password = "123456"; - User user = new User(username, password); - - // 获取用户凭据 - String[] credentials = getUserCredentials(user); - - // 尝试修改从 getUserCredentials 返回的用户名和密码字符串 - credentials[0] = "陈清扬"; - credentials[1] = "612311"; - - // 输出原始 User 对象中的用户名和密码 - System.out.println("原始用户名: " + user.getUsername()); // 输出 "JohnDoe" - System.out.println("原始密码: " + user.getPassword()); // 输出 "mySecurePassword" - } - - public static String[] getUserCredentials(User user) { - String[] credentials = new String[2]; - credentials[0] = user.getUsername(); - credentials[1] = user.getPassword(); - return credentials; - } -} -``` - -在这个示例中,尽管我们尝试修改 getUserCredentials 返回的字符串数组(即用户名和密码),但原始 User 对象中的用户名和密码保持不变。这证明了字符串的不可变性有助于保护 String 对象的安全性。 - -第二,保证哈希值不会频繁变更。毕竟要经常作为[哈希表](https://tobebetterjavaer.com/collection/hashmap.html)的键值,经常变更的话,哈希表的性能就会很差劲。 - -在 String 类中,哈希值是在第一次计算时缓存的,后续对该哈希值的请求将直接使用缓存值。这有助于提高哈希表等数据结构的性能。以下是一个简单的示例,演示了字符串的哈希值缓存机制: - -```java -String text1 = "沉默王二"; -String text2 = "沉默王二"; - -// 计算字符串 text1 的哈希值,此时会进行计算并缓存哈希值 -int hashCode1 = text1.hashCode(); -System.out.println("第一次计算 text1 的哈希值: " + hashCode1); - -// 再次计算字符串 text1 的哈希值,此时直接返回缓存的哈希值 -int hashCode1Cached = text1.hashCode(); -System.out.println("第二次计算: " + hashCode1Cached); - -// 计算字符串 text2 的哈希值,由于字符串常量池的存在,实际上 text1 和 text2 指向同一个字符串对象 -// 所以这里直接返回缓存的哈希值 -int hashCode2 = text2.hashCode(); -System.out.println("text2 直接使用缓存: " + hashCode2); -``` - -在这个示例中,我们创建了两个具有相同内容的字符串 text1 和 text2。首次计算 text1 的哈希值时,会进行实际计算并缓存该值。当我们再次计算 text1 的哈希值或计算具有相同内容的 text2 的哈希值时,将直接返回缓存的哈希值,而不进行重新计算。 - -由于 String 对象是不可变的,其哈希值在创建后不会发生变化。这使得 String 类可以缓存哈希值,提高哈希表等数据结构的性能。如果 String 是可变的,那么在每次修改时都需要重新计算哈希值,这会降低性能。 - -第三,可以实现[字符串常量池](https://tobebetterjavaer.com/string/constant-pool.html),Java 会将相同内容的字符串存储在字符串常量池中。这样,具有相同内容的字符串变量可以指向同一个 String 对象,节省内存空间。 - -“由于字符串的不可变性,String 类的一些方法实现最终都返回了新的字符串对象。”等三妹稍微缓了一会后,我继续说到。 - -“就拿 `substring()` 方法来说。” - -```java -public String substring(int beginIndex) { - if (beginIndex < 0) { - throw new StringIndexOutOfBoundsException(beginIndex); - } - int subLen = value.length - beginIndex; - if (subLen < 0) { - throw new StringIndexOutOfBoundsException(subLen); - } - return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); -} -``` - -`substring()` 方法用于截取字符串,最终返回的都是 new 出来的新字符串对象。 - -“还有 `concat()` 方法。” - -```java -public String concat(String str) { - int olen = str.length(); - if (olen == 0) { - return this; - } - if (coder() == str.coder()) { - byte[] val = this.value; - byte[] oval = str.value; - int len = val.length + oval.length; - byte[] buf = Arrays.copyOf(val, len); - System.arraycopy(oval, 0, buf, val.length, oval.length); - return new String(buf, coder); - } - int len = length(); - byte[] buf = StringUTF16.newBytesFor(len + olen); - getBytes(buf, 0, UTF16); - str.getBytes(buf, len, UTF16); - return new String(buf, UTF16); -} -``` - -`concat()` 方法用于拼接字符串,不管编码是否一致,最终也返回的是新的字符串对象。 - -“`replace()` 替换方法其实也一样,三妹,你可以自己一会看一下源码,也是返回新的字符串对象。” - -“这就意味着,不管是截取、拼接,还是替换,都不是在原有的字符串上进行的,而是重新生成了新的字符串对象。也就是说,这些操作执行过后,**原来的字符串对象并没有发生改变**。” - -“三妹,你记住,String 对象一旦被创建后就固定不变了,对 String 对象的任何修改都不会影响到原来的字符串对象,都会生成新的字符串对象。” - -“嗯嗯,记住了,哥。”三妹很乖。 - -“那今天就先讲到这吧,后面我们再对每一个细分领域深入地展开一下。你可以找一些资料先预习下,我出去散会心。。。。。” - - - - -## 4.6 深入理解Java字符串常量池 - -“三妹,今天我们来学习一下字符串常量池,这是字符串中非常关键的一个知识点。”我话音未落,青岛路小学那边传来了嘹亮的歌声就钻进了我的耳朵,“唱 ~ 山 ~ 歌 ~”,我都有点情不自禁地哼唱起来了。 - -三妹赶紧拦住我说,“好了,开始吧,哥。” - -### new String("二哥")创建了几个对象 - -“先从这道面试题开始吧!” - -```java -String s = new String("二哥"); -``` - -“这行代码创建了几个[对象](https://tobebetterjavaer.com/oo/object-class.html)?” - -“不就一个吗?”三妹不假思索地回答。 - -“不,两个!”我直接否定了三妹的答案,“使用 new 关键字创建一个字符串对象时,Java 虚拟机会先在字符串常量池中查找有没有‘二哥’这个字符串对象,如果有,就不会在字符串常量池中创建‘二哥’这个对象了,直接在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的对象地址返回赋值给变量 s。” - -“如果没有,先在字符串常量池中创建一个‘二哥’的字符串对象,然后再在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的字符串对象地址返回赋值给变量 s。” - -我画图表示一下,会更加清楚。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string//constant-pool-6dee151e-3a13-4f85-b870-3c9d1797557a.png) - -在Java中,栈上存储的是基本数据类型的变量和对象的引用,而对象本身则存储在堆上。 - -对于这行代码 `String s = new String("二哥");`,它创建了两个对象:一个是字符串对象 "二哥",它被添加到了字符串常量池中,另一个是通过 new String() 构造函数创建的字符串对象 "二哥",它被分配在堆内存中,同时引用变量 s 存储在栈上,它指向堆内存中的字符串对象 "二哥"。 - -“**为什么要先在字符串常量池中创建对象,然后再在堆上创建呢**?这样不就多此一举了?”三妹敏锐地发现了问题。 - -我回答,“是的。由于字符串的使用频率实在是太高了,所以 Java 虚拟机为了提高性能和减少内存开销,在创建字符串对象的时候进行了一些优化,特意为字符串开辟了一块空间——也就是字符串常量池。” - -### 字符串常量池的作用 - -通常情况下,我们会采用双引号的方式来创建字符串对象,而不是通过 new 关键字的方式,就像下面👇🏻这样,这样就不会多此一举: - -```java -String s = "三妹"; -``` - -当执行 `String s = "三妹"` 时,Java 虚拟机会先在字符串常量池中查找有没有“三妹”这个字符串对象,如果有,则不创建任何对象,直接将字符串常量池中这个“三妹”的对象地址返回,赋给变量 s;如果没有,在字符串常量池中创建“三妹”这个对象,然后将其地址返回,赋给变量 s。 - - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string//constant-pool-80ca8b18-2446-431e-98e3-b194e1c608e3.png) - -Java 虚拟机创建了一个字符串对象 "三妹",它被添加到了字符串常量池中,同时引用变量 s 存储在栈上,它指向字符串常量池中的字符串对象 "三妹"。你看,是不是省了一步,比之前高效了。 - - -“哦,我明白了,哥。”三妹突然插话到,“有了字符串常量池,就可以通过双引号的方式直接创建字符串对象,不用再通过 new 的方式在堆中创建对象了,对吧?” - -“是滴。new 的方式始终会创建一个对象,不管字符串的内容是否已经存在,而双引号的方式会重复利用字符串常量池中已经存在的对象。”我说。 - -来看下面这个例子: - -```java -String s = new String("二哥"); -String s1 = new String("二哥"); -``` - -按照我们之前的分析,这两行代码会创建三个对象,字符串常量池中一个,堆上两个。 - -再来看下面这个例子: - -```java -String s = "三妹"; -String s1 = "三妹"; -``` - -这两行代码只会创建一个对象,就是字符串常量池中的那个。这样的话,性能肯定就提高了! - -### 字符串常量池在内存中的什么位置呢? - -“那哥,字符串常量池在内存中的什么位置呢?”三妹问。 - -我说,“三妹,你这个问题问得好呀!” - -分为三个阶段。 - -#### Java 7 之前 - -在 Java 7 之前,字符串常量池位于永久代(Permanent Generation)的内存区域中,主要用来存储一些字符串常量(静态数据的一种)。永久代是 Java 堆(Java Heap)的一部分,用于存储类信息、方法信息、常量池信息等静态数据。 - -而 Java 堆是 JVM 中存储对象实例和数组的内存区域,也就是说,永久代是 Java 堆的一个子区域。 - -换句话说,永久代中存储的静态数据与堆中存储的对象实例和数组是分开的,它们有不同的生命周期和分配方式。 - -但是,永久代和堆的大小是相互影响的,因为它们都使用了 JVM 堆内存,因此它们的大小都受到 JVM 堆大小的限制。 - -于是,当我们创建一个字符串常量时,它会被储存在永久代的字符串常量池中。如果我们创建一个普通字符串对象,则它将被储存在堆中。如果字符串对象的内容是一个已经存在于字符串常量池中的字符串常量,那么这个对象会指向已经存在的字符串常量,而不是重新创建一个新的字符串对象。 - -画幅图,大概就是这个样子。 - - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string//constant-pool-ed6518ec-1d51-4718-ab8a-e1e2cda774bd.png) - - -#### Java 7 - -需要注意的是,永久代的大小是有限的,并且很难准确地确定一个应用程序需要多少永久代空间。如果我们在应用程序中使用了大量的类、方法、常量等静态数据,就有可能导致永久代空间不足。这种情况下,JVM 就会抛出 OutOfMemoryError 错误。 - -因此,从 Java 7 开始,为了解决永久代空间不足的问题,将字符串常量池从永久代中移动到堆中。这个改变也是为了更好地支持动态语言的运行时特性。 - -再画幅图,大概就是这样子。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string//constant-pool-f5231378-a442-421e-a470-8256da1715e8.png) - -#### Java 8 - -到了 Java 8,永久代(PermGen)被取消,并由元空间(Metaspace)取代。元空间是一块本机内存区域,和 JVM 内存区域是分开的。不过,元空间的作用依然和之前的永久代一样,用于存储类信息、方法信息、常量池信息等静态数据。 - -与永久代不同,元空间具有一些优点,例如: - -- 它不会导致 OutOfMemoryError 错误,因为元空间的大小可以动态调整。 -- 元空间使用本机内存,而不是 JVM 堆内存,这可以避免堆内存的碎片化问题。 -- 元空间中的垃圾收集与堆中的垃圾收集是分离的,这可以避免应用程序在运行过程中因为进行类加载和卸载而频繁地触发 Full GC。 - -再画幅图,对比来看一下,就会一目了然。 - - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string//constant-pool-422e3214-97df-41ec-bcb5-132cfc76b669.png) - -### 永久代、方法区、元空间 - - -“哥,能再简单给我解释一下方法区,永久代和元空间的概念吗?有点模糊。”三妹说。 - -“可以呀。” - -- 方法区是 Java 虚拟机规范中的一个概念,就像是一个[接口](https://tobebetterjavaer.com/oo/interface.html)吧; -- 永久代是 HotSpot 虚拟机中对方法区的一个实现,就像是接口的实现类; -- Java 8 的时候,移除了永久代,取而代之的是元空间,是方法区的另外一种实现,更灵活了。 - -永久代是放在运行时数据区中的,所以它的大小受到 Java 虚拟机本身大小的限制,所以 Java 8 之前,会经常遇到 `java.lang.OutOfMemoryError: PremGen Space` 的异常,PremGen Space 就是方法区的意思;而元空间是直接放在内存中的,所以只受本机可用内存的限制。 - -“明白了吧,三妹?”我问。 - -“嗯嗯。”三妹回答。 - -“那关于字符串常量池,就先说这么多吧,是不是还挺有意思的。”我说。 - -“是的,我现在是彻底搞懂了字符串常量池,哥,你真棒!”三妹说。 - - - -## 4.7 详解 String.intern() 方法 - -“哥,你发给我的那篇文章我看了,结果直接把我给看得不想学 Java 了!”三妹气冲冲地说。 - -“哪一篇啊?”看着三妹面色沉重,我关心地问到。 - -“就是[美团技术团队深入解析 `String.intern()` 那篇](https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html)啊!”三妹回答。 - -“哦,我想起来了,不挺好一篇文章嘛,深入浅出,精品中的精品,看完后你应该对 String 的 intern 方法彻底理解了才对呀。” - -“好是好,但我就是看不懂!”三妹委屈地说,“哥,还是你亲自给我讲讲吧?” - -“好吧,上次学的[字符串常量池](https://tobebetterjavaer.com/string/constant-pool.html)你都搞清楚了吧?” - -“嗯。”三妹微微的点了点头。 - -要理解美团技术团队的这篇文章,你只需要记住这几点内容: - -第一,使用双引号声明的字符串对象会保存在字符串常量池中。 - -第二,使用 new 关键字创建的字符串对象会先从字符串常量池中找,如果没找到就创建一个,然后再在堆中创建字符串对象;如果找到了,就直接在堆中创建字符串对象。 - -第三,针对没有使用双引号声明的字符串对象来说,就像下面代码中的 s1 那样: - -```java -String s1 = new String("二哥") + new String("三妹"); -``` - -如果想把 s1 的内容也放入字符串常量池的话,可以调用 `intern()` 方法来完成。 - -不过,需要注意的是,Java 7 的时候,字符串常量池从永久代中移动到了堆中,虽然此时永久代还没有完全被移除。Java 8 的时候,永久代被彻底移除。 - -这个变化也直接影响了 `String.intern()` 方法在执行时的策略,Java 7 之前,执行 `String.intern()` 方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象; Java 7 之后呢,由于字符串常量池放在了堆中,执行 `String.intern()` 方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。 - -“还没有理解清楚,二哥”,三妹很苦恼。 - -“嗯。。。别怕,三妹,先来猜猜这段代码输出的结果吧。”我说。 - -```java -String s1 = new String("二哥三妹"); -String s2 = s1.intern(); -System.out.println(s1 == s2); -``` - -“哥,这我完全猜不出啊,还是你直接解释吧。”三妹说。 - -“好吧。” - -第一行代码,字符串常量池中会先创建一个“二哥三妹”的对象,然后堆中会再创建一个“二哥三妹”的对象,s1 引用的是堆中的对象。 - -第二行代码,对 s1 执行 `intern()` 方法,该方法会从字符串常量池中查找“二哥三妹”这个字符串是否存在,此时是存在的,所以 s2 引用的是字符串常量池中的对象。 - -也就意味着 s1 和 s2 的引用地址是不同的,一个来自堆,一个来自字符串常量池,所以输出的结果为 false。 - -“来看一下运行结果。”我说。 - -``` -false -``` - -“我来画幅图,帮助你理解下。”看到三妹惊讶的表情,我耐心地说。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string/intern-01.png) - -“这下理解了吧?”我问三妹。 - -“嗯嗯,一下子就豁然开朗了!”三妹说。 - -“好,我们再来看下面这段代码。” - -```java -String s1 = new String("二哥") + new String("三妹"); -String s2 = s1.intern(); -System.out.println(s1 == s2); -``` - -“难道也输出 false ?”三妹有点不确定。 - -“不,这段代码会输出 true。”我否定了三妹的猜测。 - -“为啥呀?”三妹迫切地想要知道答案。 - -第一行代码,会在字符串常量池中创建两个对象,一个是“二哥”,一个是“三妹”,然后在堆中会创建两个匿名对象“二哥”和“三妹”,最后还有一个“二哥三妹”的对象(稍后会解释),s1 引用的是堆中“二哥三妹”这个对象。 - -第二行代码,对 s1 执行 `intern()` 方法,该方法会从字符串常量池中查找“二哥三妹”这个对象是否存在,此时不存在的,但堆中已经存在了,所以字符串常量池中保存的是堆中这个“二哥三妹”对象的引用,也就是说,s2 和 s1 的引用地址是相同的,所以输出的结果为 true。 - -“来看一下运行结果。”我胸有成竹地说。 - -``` -true -``` - -“我再来画幅图,帮助你理解下。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string/intern-02.png) - -“哇,我明白了!”三妹长舒一口气,大有感慨 intern 也没什么难理解的意味,“不过,我有一个疑惑,“二哥三妹”这个对象是什么时候创建的呢?” - -“三妹,不错嘛,能抓住问题的关键。再来解释一下 `String s1 = new String("二哥") + new String("三妹")` 这行代码。”我对三妹的表现非常开心。 - -1. 创建 "二哥" 字符串对象,存储在字符串常量池中。 -2. 创建 "三妹" 字符串对象,存储在字符串常量池中。 -3. 执行 `new String("二哥")`,在堆上创建一个字符串对象,内容为 "二哥"。 -4. 执行 `new String("三妹")`,在堆上创建一个字符串对象,内容为 "三妹"。 -5. 执行 `new String("二哥") + new String("三妹")`,会创建一个 StringBuilder 对象,并将 "二哥" 和 "三妹" 追加到其中,然后调用 StringBuilder 对象的 toString() 方法,将其转换为一个新的字符串对象,内容为 "二哥三妹"。这个新的字符串对象存储在堆上。 - -也就是说,当编译器遇到 `+` 号这个操作符的时候,会将 `new String("二哥") + new String("三妹")` 这行代码编译为以下代码: - -``` -new StringBuilder().append("二哥").append("三妹").toString(); -``` - -实际执行过程如下: - -- 创建一个 StringBuilder 对象。 -- 在 StringBuilder 对象上调用 append("二哥"),将 "二哥" 追加到 StringBuilder 中。 -- 在 StringBuilder 对象上调用 append("三妹"),将 "三妹" 追加到 StringBuilder 中。 -- 在 StringBuilder 对象上调用 toString() 方法,将 StringBuilder 转换为一个新的字符串对象,内容为 "二哥三妹"。 - -关于 [StringBuilder](https://tobebetterjavaer.com/string/builder-buffer.html),我们随后会详细地讲到。今天先了解到这。 - -不过需要注意的是,尽管 intern 可以确保所有具有相同内容的字符串共享相同的内存空间,但也不要烂用 intern,因为任何的缓存池都是有大小限制的,不能无缘无故就占用了相对稀缺的缓存空间,导致其他字符串没有坑位可占。 - -另外,字符串常量池本质上是一个固定大小的 StringTable,如果放进去的字符串过多,就会造成严重的哈希冲突,从而导致链表变长,链表变长也就意味着字符串常量池的性能会大幅下降,因为要一个一个找是需要花费时间的。 - -“好了,三妹,关于 String 的 intern 就讲到这吧,这次理解了吧?”我问。 - -“哥,你真棒!” - -看到三妹一点一滴的进步,我也感到由衷的开心。 - - - -## 4.8 String、StringBuilder、StringBuffer - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[4.8 StringBuilder和StringBuffer](https://tobebetterjavaer.com/string/builder-buffer.html) - -## 4.9 String相等判断 - -“二哥,如何比较两个字符串相等啊?”三妹问。 - -“这个问题看似简单,却在 Stack Overflow 上有超过 370 万+的访问量。”我说,“这个问题也可以引申为 `.equals()` 和 ‘==’ 操作符有什么区别。” - -- “==”操作符用于比较两个对象的地址是否相等。 -- `.equals()` 方法用于比较两个对象的内容是否相等。 - -“不是很理解。”三妹感到很困惑。 - -“我来举个不恰当又很恰当的例子,一看你就明白了,三妹。” - -有一对双胞胎,姐姐叫阿丽塔,妹妹叫洛丽塔。我们普通人可能完全无法分辨谁是姐姐谁是妹妹,可她们的妈妈却可以轻而易举地辨认出。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string/equals-01.png) - - -`.equals()` 就好像我们普通人,看见阿丽塔以为是洛丽塔,看见洛丽塔以为是阿丽塔,看起来一样就觉得她们是同一个人;“==”操作符就好像她们的妈妈,要求更严格,观察更细致,一眼就能分辨出谁是姐姐谁是妹妹。 - -```java -String alita = new String("小萝莉"); -String luolita = new String("小萝莉"); - -System.out.println(alita.equals(luolita)); // true -System.out.println(alita == luolita); // false -``` - -就上面这段代码来说,`.equals()` 输出的结果为 true,而“==”操作符输出的结果为 false——前者要求内容相等就可以,后者要求必须是同一个对象。 - -“三妹,之前已经学过了,Java 的所有类都默认地继承 Object 这个超类,该类有一个名为 `.equals()` 的方法。”一边说,我一边打开了 Object 类的源码。 - -```java -public boolean equals(Object obj) { - return (this == obj); -} -``` - -你看,Object 类的 `.equals()` 方法默认采用的是“==”操作符进行比较。假如子类没有重写该方法的话,那么“==”操作符和 `.equals()` 方法的功效就完全一样——比较两个对象的内存地址是否相等。 - -但实际情况中,有不少类重写了 `.equals()` 方法,因为比较内存地址的要求比较严格,不太符合现实中所有的场景需求。拿 String 类来说,我们在比较字符串的时候,的确只想判断它们俩的内容是相等的就可以了,并不想比较它们俩是不是同一个对象。 - -况且,字符串有[字符串常量池](https://tobebetterjavaer.com/string/constant-pool.html)的概念,本身就推荐使用 `String s = "字符串"` 这种形式来创建字符串对象,而不是通过 new 关键字的方式,因为可以把字符串缓存在字符串常量池中,方便下次使用,不用遇到 new 就在堆上开辟一块新的空间。 - -“哦,我明白了。”三妹说。 - -“那就来看一下 String 类的 `.equals()` 方法的源码吧。”我说。 - -```java -public boolean equals(Object anObject) { - if (this == anObject) { - return true; - } - if (anObject instanceof String) { - String aString = (String)anObject; - if (coder() == aString.coder()) { - return isLatin1() ? StringLatin1.equals(value, aString.value) - : StringUTF16.equals(value, aString.value); - } - } - return false; -} -``` - -首先,如果两个字符串对象的可以“==”,那就直接返回 true 了,因为这种情况下,字符串内容是必然相等的。否则就按照字符编码进行比较,分为 UTF16 和 Latin1,差别不是很大,就拿 Latin1 的来说吧。 - -```java -@HotSpotIntrinsicCandidate -public static boolean equals(byte[] value, byte[] other) { - if (value.length == other.length) { - for (int i = 0; i < value.length; i++) { - if (value[i] != other[i]) { - return false; - } - } - return true; - } - return false; -} -``` - -这个 JDK 版本是 Java 17,也就是最新的 LTS(长期支持)版本。该版本中,String 类使用字节数组实现的,所以比较两个字符串的内容是否相等时,可以先比较字节数组的长度是否相等,不相等就直接返回 false;否则就遍历两个字符串的字节数组,只有有一个字节不相等,就返回 false。 - -这是 Java 8 中的 equals 方法源码: - -```java -public boolean equals(Object anObject) { - // 判断是否为同一对象 - if (this == anObject) { - return true; - } - // 判断对象是否为 String 类型 - 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; -} -``` - -JDK 8 比 JDK 17 更容易懂一些:首先判断两个对象是否为同一个对象,如果是,则返回 true。接着,判断对象是否为 String 类型,如果不是,则返回 false。如果对象为 String 类型,则比较两个字符串的长度是否相等,如果长度不相等,则返回 false。如果长度相等,则逐个比较每个字符是否相等,如果都相等,则返回 true,否则返回 false。 - -“嗯,二哥,这段源码不难理解。”三妹自信地说。 - -“那出几道题考考你吧!”我说。 - -第一题: - -```java -new String("小萝莉").equals("小萝莉") -``` - -“输出什么呢?”我问。 - -“`.equals()` 比较的是两个字符串对象的内容是否相等,所以结果为 true。”三妹不假思索地答到。 - - -第二题: - -```java -new String("小萝莉") == "小萝莉" -``` - -“==操作符左侧的是在堆中创建的对象,右侧是在字符串常量池中的对象,尽管内容相同,但内存地址不同,所以返回 false。”三妹答。 - -第三题: - -```java -new String("小萝莉") == new String("小萝莉") -``` - -“new 出来的对象肯定是完全不同的内存地址,所以返回 false。”三妹答。 - -第四题: - -```java -"小萝莉" == "小萝莉" -``` - -“字符串常量池中只会有一个相同内容的对象,所以返回 true。”三妹答。 - -第五题: - -```java -"小萝莉" == "小" + "萝莉" -``` - -“由于‘小’和‘萝莉’都在字符串常量池,所以编译器在遇到‘+’操作符的时候将其自动优化为“小萝莉”,所以返回 true。” - -PS:至于为什么,查看这篇[String、StringBuilder、StringBuffer](https://tobebetterjavaer.com/string/builder-buffer.html) - -第六题: - -```java -new String("小萝莉").intern() == "小萝莉" -``` - -“`new String("小萝莉")` 在执行的时候,会先在字符串常量池中创建对象,然后再在堆中创建对象;执行 `intern()` 方法的时候发现字符串常量池中已经有了‘小萝莉’这个对象,所以就直接返回字符串常量池中的对象引用了,那再与字符串常量池中的‘小萝莉’比较,当然会返回 true 了。”三妹说。 - -PS:[intern](https://tobebetterjavaer.com/string/intern.html) 方法我们之前已经深究过了。 - -哇,不得不说,三妹前几节的字符串相关内容都完全学会了呀! - -“三妹,哥再给你补充一点。”我说。 - -“如果要进行两个字符串对象的内容比较,除了 `.equals()` 方法,还有其他两个可选的方案。” - -1)`Objects.equals()` - -`Objects.equals()` 这个静态方法的优势在于不需要在调用之前判空。 - -```java -public static boolean equals(Object a, Object b) { - return (a == b) || (a != null && a.equals(b)); -} -``` - -如果直接使用 `a.equals(b)`,则需要在调用之前对 a 进行判空,否则可能会抛出空指针 `java.lang.NullPointerException`。`Objects.equals()` 用起来就完全没有这个担心。 - -```java -Objects.equals("小萝莉", new String("小" + "萝莉")) // --> true -Objects.equals(null, new String("小" + "萝莉")); // --> false -Objects.equals(null, null) // --> true - -String a = null; -a.equals(new String("小" + "萝莉")); // throw exception -``` - -2)String 类的 `.contentEquals()` - -`.contentEquals()` 的优势在于可以将字符串与任何的字符序列(StringBuffer、StringBuilder、String、CharSequence)进行比较。 - -```java -public boolean contentEquals(CharSequence cs) { - // Argument is a StringBuffer, StringBuilder - if (cs instanceof AbstractStringBuilder) { - if (cs instanceof StringBuffer) { - synchronized(cs) { - return nonSyncContentEquals((AbstractStringBuilder)cs); - } - } else { - return nonSyncContentEquals((AbstractStringBuilder)cs); - } - } - // Argument is a String - if (cs instanceof String) { - return equals(cs); - } - // Argument is a generic CharSequence - int n = cs.length(); - if (n != length()) { - return false; - } - byte[] val = this.value; - if (isLatin1()) { - for (int i = 0; i < n; i++) { - if ((val[i] & 0xff) != cs.charAt(i)) { - return false; - } - } - } else { - if (!StringUTF16.contentEquals(val, cs, n)) { - return false; - } - } - return true; -} -``` - -从源码上可以看得出,如果 cs 是 StringBuffer,该方法还会进行同步,非常的智能化;如果是 String 的话,其实调用的还是 `equals()` 方法。当然了,这也就意味着使用该方法进行比较的时候,多出来了很多步骤,性能上有些损失。 - -同样来看一下 JDK 8 的源码: - -```java -public boolean contentEquals(CharSequence cs) { - // argument can be any CharSequence implementation - if (cs.length() != value.length) { - return false; - } - // Argument is a StringBuffer, StringBuilder or String - if (cs instanceof AbstractStringBuilder) { - char v1[] = value; - char v2[] = ((AbstractStringBuilder)cs).getValue(); - int i = 0; - int n = value.length; - while (n-- != 0) { - if (v1[i] != v2[i]) - return false; - i++; - } - return true; - } - // Argument is a String - if (cs.equals(this)) - return true; - // Argument is a non-String, non-AbstractStringBuilder CharSequence - char v1[] = value; - int i = 0; - int n = value.length; - while (n-- != 0) { - if (v1[i] != cs.charAt(i)) - return false; - i++; - } - return true; -} -``` - -同样更容易理解一些:首先判断参数长度是否相等,不相等则返回 false。如果参数是 AbstractStringBuilder 的实例,则取出其 char 数组,遍历比较两个 char 数组的每个元素是否相等。如果参数是 String 的实例,则直接调用 equals 方法比较两个字符串是否相等。如果参数是其他实现了 CharSequence 接口的对象,则遍历比较两个对象的每个字符是否相等。 - -“是的,总体上感觉还是 `Objects.equals()` 比较舒服。”三妹的眼睛是雪亮的,发现了这个方法的优点。 - - - -## 4.10 String拼接 - -“哥,你让我看的《[Java 开发手册](https://tobebetterjavaer.com/pdf/ali-java-shouce.html)》上有这么一段内容:循环体内,拼接字符串最好使用 StringBuilder 的 `append()` 方法,而不是 + 号操作符。这是为什么呀?”三妹疑惑地问。 - -“其实这个问题,我们之前已经[聊过](https://tobebetterjavaer.com/string/builder-buffer.html)。”我慢吞吞地回答道,“不过,三妹,哥今天来给你深入地讲讲。” - -PS:三妹能在学习的过程中不断地发现问题,让我感到非常的开心。其实很多时候,我们不应该只是把知识点记在心里,还应该问一问自己,到底是为什么,只有迈出去这一步,才能真正的成长起来。 - -### javap 探究+号操作符拼接字符串的本质 - -“+ 号操作符其实被 Java 在编译的时候重新解释了,换一种说法就是,+ 号操作符是一种语法糖,让字符串的拼接变得更简便了。”一边给三妹解释,我一边在 Intellij IDEA 中敲出了下面这段代码。 - -```java -class Demo { - public static void main(String[] args) { - String chenmo = "沉默"; - String wanger = "王二"; - System.out.println(chenmo + wanger); - } -} -``` - -在 Java 8 的环境下,使用 `javap -c Demo.class` 反编译字节码后,可以看到以下内容: - -``` -Compiled from "Demo.java" -class Demo { - Demo(); - Code: - 0: aload_0 - 1: invokespecial #1 // Method java/lang/Object."":()V - 4: return - - public static void main(java.lang.String[]); - Code: - 0: ldc #2 // String 沉默 - 2: astore_1 - 3: ldc #3 // String 王二 - 5: astore_2 - 6: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream; - 9: new #5 // class java/lang/StringBuilder - 12: dup - 13: invokespecial #6 // Method java/lang/StringBuilder."":()V - 16: aload_1 - 17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; - 20: aload_2 - 21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; - 24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; - 27: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V - 30: return -} -``` - -“你看,三妹,这里有一个 new 关键字,并且 class 类型为 `java/lang/StringBuilder`。”我指着标号为 9 的那行对三妹说,“这意味着新建了一个 StringBuilder 的对象。” - -“然后看标号为 17 的这行,是一个 invokevirtual 指令,用于调用对象的方法,也就是 StringBuilder 对象的 `append()` 方法。” - -“也就意味着把 chenmo("沉默")这个字符串添加到 StringBuilder 对象中了。” - -“再往下看,标号为 21 的这行,又调用了一次 `append()` 方法,意味着把 wanger("王二")这个字符串添加到 StringBuilder 对象中了。” - -换成 Java 代码来表示的话,大概是这个样子: - -```java -class Demo { - public static void main(String[] args) { - String chenmo = "沉默"; - String wanger = "王二"; - System.out.println((new StringBuilder(chenmo)).append(wanger).toString()); - } -} -``` - -“哦,原来编译的时候把“+”号操作符替换成了 StringBuilder 的 `append()` 方法啊。”三妹恍然大悟。 - -“是的,不过到了 Java 9(不是长期支持版本,所以我会拿 Java 11 来演示),情况发生了一些改变,同样的代码,字节码指令完全不同了。”我说。 - -同样的代码,在 Java 11 的环境下,字节码指令是这样的: - -``` -Compiled from "Demo.java" -public class com.itwanger.thirtyseven.Demo { - public com.itwanger.thirtyseven.Demo(); - Code: - 0: aload_0 - 1: invokespecial #1 // Method java/lang/Object."":()V - 4: return - - public static void main(java.lang.String[]); - Code: - 0: ldc #2 // String - 2: astore_1 - 3: iconst_0 - 4: istore_2 - 5: iload_2 - 6: bipush 10 - 8: if_icmpge 41 - 11: new #3 // class java/lang/String - 14: dup - 15: ldc #4 // String 沉默 - 17: invokespecial #5 // Method java/lang/String."":(Ljava/lang/String;)V - 20: astore_3 - 21: ldc #6 // String 王二 - 23: astore 4 - 25: aload_1 - 26: aload_3 - 27: aload 4 - 29: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; - 34: astore_1 - 35: iinc 2, 1 - 38: goto 5 - 41: return -} -``` - -看标号为 29 的这行,字节码指令为 `invokedynamic`,该指令允许由应用级的代码来决定方法解析,所谓的应用级的代码其实是一个方法——被称为引导方法(Bootstrap Method),简称 BSM,BSM 会返回一个 CallSite(调用点) 对象,这个对象就和 `invokedynamic` 指令链接在一起。以后再执行这条 `invokedynamic` 指令时就不会创建新的 CallSite 对象。CallSite 其实就是一个 MethodHandle(方法句柄)的 holder,指向一个调用点真正执行的方法——此时就是 `StringConcatFactory.makeConcatWithConstants()` 方法。 - -“哥,你别再说了,再说我就听不懂了。”三妹打断了我的话。 - -“好吧,总之就是 Java 9 以后,JDK 用了另外一种方法来动态解释 + 号操作符,具体的实现方式在字节码指令层面已经看不到了,所以我就以 Java 8 来继续讲解吧。” - -### 为什么要编译为 StringBuilder.append - -“再回到《Java 开发手册》上的那段内容:循环体内,拼接字符串最好使用 StringBuilder 的 `append()` 方法,而不是 + 号操作符。原因就在于循环体内如果用 + 号操作符的话,就会产生大量的 StringBuilder 对象,不仅占用了更多的内存空间,还会让 Java 虚拟机不停的进行垃圾回收,从而降低了程序的性能。” - -更好的写法就是在循环的外部新建一个 StringBuilder 对象,然后使用 `append()` 方法将循环体内的字符串添加进来: - -```java -class Demo { - public static void main(String[] args) { - StringBuilder sb = new StringBuilder(); - for (int i = 1; i < 10; i++) { - String chenmo = "沉默"; - String wanger = "王二"; - sb.append(chenmo); - sb.append(wanger); - } - System.out.println(sb); - } -} -``` - -来做个小测试。 - -第一个,for 循环中使用”+”号操作符。 - -```java -String result = ""; -for (int i = 0; i < 100000; i++) { - result += "六六六"; -} -``` - -第二个,for 循环外部新建 StringBuilder,循环体内使用 `append()` 方法。 - -```java -StringBuilder sb = new StringBuilder(); -for (int i = 0; i < 100000; i++) { - sb.append("六六六"); -} -``` - -“这两个小测试分别会耗时多长时间呢?三妹你来运行下。” - -“哇,第一个小测试的执行时间是 6212 毫秒,第二个只用了不到 1 毫秒,差距也太大了吧!”三妹说。 - -“是的,这下明白了原因吧?”我说。 - -“是的,哥,原来如此。” - -### append方法源码解析 - -“好了,三妹,来看一下 StringBuilder 类的 `append()` 方法的源码吧!” - -```java -public StringBuilder append(String str) { - super.append(str); - return this; -} -``` - -这 3 行代码其实没啥看的。我们来看父类 AbstractStringBuilder 的 `append()` 方法: - -```java -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; -} -``` - -1)判断拼接的字符串是不是 null,如果是,当做字符串“null”来处理。`appendNull()` 方法的源码如下: - -```java -private AbstractStringBuilder appendNull() { - int c = count; - ensureCapacityInternal(c + 4); - final char[] value = this.value; - value[c++] = 'n'; - value[c++] = 'u'; - value[c++] = 'l'; - value[c++] = 'l'; - count = c; - return this; -} -``` - -2)获取字符串的长度。 - -3)`ensureCapacityInternal()` 方法的源码如下: - -```java -private void ensureCapacityInternal(int minimumCapacity) { - // overflow-conscious code - if (minimumCapacity - value.length > 0) { - value = Arrays.copyOf(value, - newCapacity(minimumCapacity)); - } -} -``` - -由于字符串内部是用数组实现的,所以需要先判断拼接后的字符数组长度是否超过当前数组的长度,如果超过,先对数组进行扩容,然后把原有的值复制到新的数组中。 - - 4)将拼接的字符串 str 复制到目标数组 value 中。 - -```java -str.getChars(0, len, value, count) -``` - -5)更新数组的长度 count。 - -### String.concat 拼接字符串 - -“除了可以使用 + 号操作符,StringBuilder 的 `append()` 方法,还有其他的字符串拼接方法吗?”三妹问。 - -“有啊,比如说 String 类的 `concat()` 方法,有点像 StringBuilder 类的 `append()` 方法。” - -```java -String chenmo = "沉默"; -String wanger = "王二"; -System.out.println(chenmo.concat(wanger)); -``` - -可以来看一下 `concat()` 方法的源码。 - -```java -public String concat(String str) { - int otherLen = str.length(); - if (otherLen == 0) { - return this; - } - int len = value.length; - char buf[] = Arrays.copyOf(value, len + otherLen); - str.getChars(buf, len); - return new String(buf, true); -} -``` - -1)如果拼接的字符串的长度为 0,那么返回拼接前的字符串。 - -2)将原字符串的字符数组 value 复制到变量 buf 数组中。 - -3)把拼接的字符串 str 复制到字符数组 buf 中,并返回新的字符串对象。 - -我一行一行地给三妹解释着。 - -“和 `+` 号操作符相比,`concat()` 方法在遇到字符串为 null 的时候,会抛出 NullPointerException,而“+”号操作符会把 null 当做是“null”字符串来处理。” - -如果拼接的字符串是一个空字符串(""),那么 concat 的效率要更高一点,毕竟不需要 `new StringBuilder` 对象。 - -如果拼接的字符串非常多,`concat()` 的效率就会下降,因为创建的字符串对象越来越多。 - -“还有吗?”三妹似乎对字符串拼接很感兴趣。 - -“有,当然有。” - -### String.join 拼接字符串 - -String 类有一个静态方法 `join()`,可以这样来使用。 - -```java -String chenmo = "沉默"; -String wanger = "王二"; -String cmower = String.join("", chenmo, wanger); -System.out.println(cmower); -``` - -第一个参数为字符串连接符,比如说: - -```java -String message = String.join("-", "王二", "太特么", "有趣了"); -``` - -输出结果为:`王二-太特么-有趣了`。 - -来看一下 join 方法的源码: - -```java -public static String join(CharSequence delimiter, CharSequence... elements) { - Objects.requireNonNull(delimiter); - Objects.requireNonNull(elements); - // Number of elements not likely worth Arrays.stream overhead. - StringJoiner joiner = new StringJoiner(delimiter); - for (CharSequence cs: elements) { - joiner.add(cs); - } - return joiner.toString(); -} -``` - -里面新建了一个叫 StringJoiner 的对象,然后通过 for-each 循环把可变参数添加了进来,最后调用 `toString()` 方法返回 String。 - -### StringUtils.join 拼接字符串 - -“实际的工作中,`org.apache.commons.lang3.StringUtils` 的 `join()` 方法也经常用来进行字符串拼接。” - -```java -String chenmo = "沉默"; -String wanger = "王二"; -StringUtils.join(chenmo, wanger); -``` - -该方法不用担心 NullPointerException。 - -```java -StringUtils.join(null) = null -StringUtils.join([]) = "" -StringUtils.join([null]) = "" -StringUtils.join(["a", "b", "c"]) = "abc" -StringUtils.join([null, "", "a"]) = "a" -``` - -来看一下源码: - -```java -public static String join(final Object[] array, String separator, final int startIndex, final int endIndex) { - if (array == null) { - return null; - } - if (separator == null) { - separator = EMPTY; - } - - final StringBuilder buf = new StringBuilder(noOfItems * 16); - - for (int i = startIndex; i < endIndex; i++) { - if (i > startIndex) { - buf.append(separator); - } - if (array[i] != null) { - buf.append(array[i]); - } - } - return buf.toString(); -} -``` - -内部使用的仍然是 StringBuilder。 - -“好了,三妹,关于字符串拼接的知识点我们就讲到这吧。注意 Java 9 以后,对 + 号操作符的解释和之前发生了变化,字节码指令已经不同了,等后面你学了[字节码指令](https://tobebetterjavaer.com/jvm/zijiema-zhiling.html)后我们再详细地讲一次。”我说。 - -“嗯,哥,你休息吧,我把这些例子再重新跑一遍。”三妹说。 - - - -## 4.11 String拆分 - -“哥,我感觉字符串拆分没什么可讲的呀,直接上 String 类的 `split()` 方法不就可以了!”三妹毫不客气地说。 - -“假如你真的这么觉得,那可要注意了,事情远没这么简单。”我微笑着说。 - -假如现在有这样一串字符序列“沉默王二,一枚有趣的程序员”,需要按照中文逗号“,”进行拆分,这意味着第一串字符序列为逗号前面的“沉默王二”,第二串字符序列为逗号后面的“一枚有趣的程序员”。 - -“这不等于没说吗?哥!”还没等我说,三妹就打断了我。 - -“别着急嘛,等哥说完。”我依然保持着微笑继续说,“在拆分之前,要先进行检查,判断一下这串字符是否包含逗号,否则应该抛出异常。” - -```java -public class Test { - public static void main(String[] args) { - String cmower = "沉默王二,一枚有趣的程序员"; - if (cmower.contains(",")) { - String [] parts = cmower.split(","); - System.out.println("第一部分:" + parts[0] +" 第二部分:" + parts[1]); - } else { - throw new IllegalArgumentException("当前字符串没有包含逗号"); - } - } -} -``` - -“三妹你看,这段代码挺严谨的吧?”我说,“来看一下程序的输出结果。” - -``` -第一部分:沉默王二 第二部分:一枚有趣的程序员 -``` - -“的确和预期完全一致。”三妹说。 - -“这是建立在字符串是确定的情况下,最重要的是分隔符是确定的。否则,麻烦就来了。”我说,“大约有 12 种英文特殊符号,如果直接拿这些特殊符号替换上面代码中的分隔符(中文逗号),这段程序在运行的时候就会出现以下提到的错误。” - - -- 反斜杠 `\`(ArrayIndexOutOfBoundsException) -- 插入符号 `^`(同上) -- 美元符号 `$`(同上) -- 逗点 `.`(同上) -- 竖线 `|`(正常,没有出错) -- 问号 `?`(PatternSyntaxException) -- 星号 `*`(同上) -- 加号 `+`(同上) -- 左小括号或者右小括号 `()`(同上) -- 左方括号或者右方括号 `[]`(同上) -- 左大括号或者右大括号 `{}`(同上) - -“那遇到这些特殊符号该怎么办呢?”三妹问。 - -“用正则表达式。”我说,“正则表达式是一组由字母和符号组成的特殊文本,它可以用来从文本中找出满足你想要的格式的句子。” - -我在 GitHub 上找打了一个开源的正则表达式学习文档,非常详细。一开始写正则表达式的时候难免会感觉到非常生疏,你可以查看一下这份文档。记不住没关系,遇到就查。 - ->[https://github.com/cdoco/learn-regex-zh](https://github.com/cdoco/learn-regex-zh) - -除了这份文档,还有一份: - ->[https://github.com/cdoco/common-regex](https://github.com/cdoco/common-regex) - -作者收集了一些在平时项目开发中经常用到的正则表达式,可以直接拿来用。 - -“哥,你真周到。”三妹笑着说。 - -“好了,来用英文逗点 `.` 替换一下分隔符。”我说。 - -```java -String cmower = "沉默王二.一枚有趣的程序员"; -if (cmower.contains(".")) { - String [] parts = cmower.split("\\."); - System.out.println("第一部分:" + parts[0] +" 第二部分:" + parts[1]); -} -``` - -由于英文逗点属于特殊符号,所以在使用 `split()` 方法的时候,就需要使用正则表达式 `\\.` 而不能直接使用 `.`。 - -“为什么用两个反斜杠呢?”三妹问。 - -“因为反斜杠本身就是一个特殊字符,需要用反斜杠来转义。”我说。 - -当然了,你也可以使用 `[]` 来包裹住英文逗点“.”,`[]` 也是一个正则表达式,用来匹配方括号中包含的任意字符。 - -```java -cmower.split("[.]"); -``` - -除此之外, 还可以使用 Pattern 类的 `quote()` 方法来包裹英文逗点“.”,该方法会返回一个使用 `\Q\E` 包裹的字符串。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string/split-01.png) - -来看示例: - -```java -String [] parts = cmower.split(Pattern.quote(".")); -``` - -当 `split()` 方法的参数是正则表达式的时候,方法最终会执行下面这行代码: - -```java -return Pattern.compile(regex).split(this, limit); -``` - -也就意味着,拆分字符串有了新的选择,可以不使用 String 类的 `split()` 方法,直接用下面的方式。 - -```java -public class TestPatternSplit { - private static Pattern twopart = Pattern.compile("\\."); - - public static void main(String[] args) { - String [] parts = twopart.split("沉默王二.一枚有趣的程序员"); - System.out.println("第一部分:" + parts[0] +" 第二部分:" + parts[1]); - } -} -``` - -“为什么要把 Pattern 表达式声明称 static 的呢?”三妹问。 - -“由于模式是确定的,通过 static 的预编译功能可以提高程序的效率。”我说,“除此之外,还可以使用 Pattern 配合 Matcher 类进行字符串拆分,这样做的好处是可以对要拆分的字符串进行一些严格的限制,来看这段示例代码。” - -```java -public class TestPatternMatch { - /** - * 使用预编译功能,提高效率 - */ - private static Pattern twopart = Pattern.compile("(.+)\\.(.+)"); - - public static void main(String[] args) { - checkString("沉默王二.一枚有趣的程序员"); - checkString("沉默王二."); - checkString(".一枚有趣的程序员"); - } - - private static void checkString(String str) { - Matcher m = twopart.matcher(str); - if (m.matches()) { - System.out.println("第一部分:" + m.group(1) + " 第二部分:" + m.group(2)); - } else { - System.out.println("不匹配"); - } - } -} -``` - -正则表达式 `(.+)\\.(.+)` 的意思是,不仅要把字符串按照英文标点的方式拆成两部分,并且英文逗点的前后要有内容。 - -来看一下程序的输出结果: - -```java -第一部分:沉默王二 第二部分:一枚有趣的程序员 -不匹配 -不匹配 -``` - -不过,使用 Matcher 来匹配一些简单的字符串时相对比较沉重一些,使用 String 类的 `split()` 仍然是首选,因为该方法还有其他一些牛逼的功能。比如说,如果你想把分隔符包裹在拆分后的字符串的第一部分,可以这样做: - -```java -String cmower = "沉默王二,一枚有趣的程序员"; -if (cmower.contains(",")) { - String [] parts = cmower.split("(?<=,)"); - System.out.println("第一部分:" + parts[0] +" 第二部分:" + parts[1]); -} -``` - -程序输出的结果如下所示: - -``` -第一部分:沉默王二, 第二部分:一枚有趣的程序员 -``` - -可以看到分隔符“,”包裹在了第一部分,如果希望包裹在第二部分,可以这样做: - -```java -String [] parts = cmower.split("(?=,)"); -``` - -“`?<=` 和 `?=` 是什么东东啊?”三妹好奇地问。 - -“它其实是正则表达式中的断言模式。”我说,“你有时间的话,可以看看前面我推荐的两份开源文档。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string/split-02.png) - -“`split()` 方法可以传递 2 个参数,第一个为分隔符,第二个为拆分的字符串个数。”我说。 - -```java -String cmower = "沉默王二,一枚有趣的程序员,宠爱他"; -if (cmower.contains(",")) { - String [] parts = cmower.split(",", 2); - System.out.println("第一部分:" + parts[0] +" 第二部分:" + parts[1]); -} -``` - -进入 debug 模式的话,可以看到以下内容: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/string/split-03.png) - -也就是说,传递 2 个参数的时候,会直接调用 `substring()` 进行截取,第二个分隔符后的就不再拆分了。 - -来看一下程序输出的结果: - -``` -第一部分:沉默王二 第二部分:一枚有趣的程序员,宠爱他 -``` - -“没想到啊,这个字符串拆分还挺讲究的呀!”三妹感慨地说。 - -“是的,其实字符串拆分在实际的工作当中还是挺经常用的。前端经常会按照规则传递一长串字符序列到后端,后端就需要按照规则把字符串拆分再做处理。”我说。 - -“嗯,我把今天的内容温习下,二哥,你休息会。”三妹说。 - ---- - -GitHub 上标星 7600+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 7600+ 的 Java 教程](https://tobebetterjavaer.com/overview/) - -微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - -# 第五章:面向对象编程 - -# 5.1 Java中的类和对象 - -#“二哥,那天我在图书馆复习《Java进阶之路》的时候,刚好碰见一个学长,他问我有没有‘对象’,我说还没有啊。结果你猜他说什么,‘要不要我给你 new 一个啊?’我当时就懵了,new 是啥意思啊,二哥?”三妹满是疑惑的问我。 - -“哈哈,三妹,你学长还挺幽默啊。new 是 Java 中的一个关键字,用来把类变成对象。”我笑着对三妹说,“对象和类是 Java 中最基本的两个概念,可以说撑起了面向对象编程(OOP)的一片天。” - -### 01、面向过程和面向对象 - -三妹是不是要问,什么是 OOP? - -OOP 的英文全称是 Object Oriented Programming,要理解它的话,就要先理解面向对象,要想理解面向对象的话,就要先理解面向过程,因为一开始没有面向对象的编程语言,都是面向过程。 - -举个简单点的例子来区分一下面向过程和面向对象。 - -有一天,你想吃小碗汤了,怎么办呢?有两个选择: - -1)自己买食材,豆腐皮啊、肉啊、蒜苔啊等等,自己动手做。 - -2)到饭店去,只需要对老板喊一声,“来份小碗汤。” - -第一种就是面向过程,第二种就是面向对象。 - -面向过程有什么劣势呢?假如你买了小碗汤的食材,临了又想吃宫保鸡丁了,你是不是还得重新买食材? - -面向对象有什么优势呢?假如你不想吃小碗汤了,你只需要对老板说,“我那个小碗汤如果没做的话,换成宫保鸡丁吧!” - -面向过程是流程化的,一步一步,上一步做完了,再做下一步。 - -面向对象是模块化的,我做我的,你做你的,我需要你做的话,我就告诉你一声。我不需要知道你到底怎么做,只看功劳不看苦劳。 - -不过,如果追到底的话,面向对象的底层其实还是面向过程,只不过把面向过程进行了抽象化,封装成了类,方便我们的调用。 - -### 02、类 - -对象可以是现实中看得见的任何物体,比如说,一只特立独行的猪;也可以是想象中的任何虚拟物体,比如说能七十二变的孙悟空。 - -Java 通过类(class)来定义这些物体,这些物体有什么状态,通过字段来定义,比如说比如说猪的颜色是纯色还是花色;这些物体有什么行为,通过方法来定义,比如说猪会吃,会睡觉。 - -来,定义一个简单的类给你看看。 - -```java -/** - * 微信搜索「沉默王二」,回复 Java - * - * @author 沉默王二 - * @date 2020/11/19 - */ -public class Person { - private String name; - private int age; - private int sex; - - private void eat() { - } - - private void sleep() { - } - - private void dadoudou() { - } -} -``` - -一个类可以包含: - -- 字段(Filed) -- 方法(Method) -- 构造方法(Constructor) - -在 Person 类中,字段有 3 个,分别是 name、age 和 sex,它们也称为成员[变量](https://tobebetterjavaer.com/oo/var.html)——在类内部但在方法外部,方法内部的叫临时变量。 - -成员变量有时候也叫做实例变量,在编译时不占用内存空间,在运行时获取内存,也就是说,只有在对象实例化(`new Person()`)后,字段才会获取到内存,这也正是它被称作“实例”变量的原因。 - -[方法](https://tobebetterjavaer.com/oo/method.html)有 3 个,分别是 `eat()`、`sleep()` 和 `dadoudou()`,表示 Person 这个对象可以做什么,也就是吃饭睡觉打豆豆。 - -那三妹是不是要问,“怎么没有[构造方法](https://tobebetterjavaer.com/oo/construct.html)呢?” - -的确在 Person 类的源码文件(.java)中没看到,但在反编译后的字节码文件(.class)中是可以看得到的。 - -```java -// -// Source code recreated from a .class file by IntelliJ IDEA -// (powered by Fernflower decompiler) -// - -package com.itwanger.twentythree; - -public class Person { - private String name; - private int age; - private int sex; - - public Person() { - } - - private void eat() { - } - - private void sleep() { - } - - private void dadoudou() { - } -} -``` - -`public Person(){}` 就是默认的构造方法,因为是空的构造方法(方法体中没有内容),所以可以缺省。Java 聪明就聪明在这,有些很死板的代码不需要开发人员添加,它会偷偷地做了。 - -### 03、new 一个对象 - -创建 Java 对象时,需要用到 `new` 关键字。 - -```java -Person person = new Person(); -``` - -这行代码就通过 Person 类创建了一个 Person 对象。所有**对象**在创建的时候都会在**堆内存中分配空间**。 - -创建对象的时候,需要一个 `main()` 方法作为入口, `main()` 方法可以在当前类中,也可以在另外一个类中。 - -第一种:`main()` 方法直接放在 Person 类中。 - -```java -public class Person { - private String name; - private int age; - private int sex; - - private void eat() {} - private void sleep() {} - private void dadoudou() {} - - public static void main(String[] args) { - Person person = new Person(); - System.out.println(person.name); - System.out.println(person.age); - System.out.println(person.sex); - } -} -``` - -输出结果如下所示: - -``` -null -0 -0 -``` - -第二种:`main()` 方法不在 Person 类中,而在另外一个类中。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/16-01.png) - -实际开发中,我们通常不在当前类中直接创建对象并使用它,而是放在使用对象的类中,比如说上图中的 PersonTest 类。 - -可以把 PersonTest 类和 Person 类放在两个文件中,也可以放在一个文件(命名为 PersonTest.java)中,就像下面这样。 - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class PersonTest { - public static void main(String[] args) { - Person person = new Person(); - } -} - -class Person { - private String name; - private int age; - private int sex; - - private void eat() {} - private void sleep() {} - private void dadoudou() {} -} -``` - -### 04、初始化对象 - -在之前的例子中,程序输出结果为: - -``` -null -0 -0 -``` - -为什么会有这样的输出结果呢?因为 Person 对象没有初始化,因此输出了 String 的默认值 null,int 的默认值 0。 - -那怎么初始化 Person 对象(对字段赋值)呢? - -#### 第一种:通过对象的引用变量。 - -```java -public class Person { - private String name; - private int age; - private int sex; - - public static void main(String[] args) { - Person person = new Person(); - person.name = "沉默王二"; - person.age = 18; - person.sex = 1; - - System.out.println(person.name); - System.out.println(person.age); - System.out.println(person.sex); - } -} -``` - -person 被称为对象 Person 的引用变量,见下图: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/16-02.png) - -通过对象的引用变量,可以直接对字段进行初始化(`person.name = "沉默王二"`),所以以上代码输出结果如下所示: - -``` -沉默王二 -18 -1 -``` - -#### 第二种:通过方法初始化。 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class Person { - private String name; - private int age; - private int sex; - - public void initialize(String n, int a, int s) { - name = n; - age = a; - sex = s; - } - - public static void main(String[] args) { - Person person = new Person(); - person.initialize("沉默王二",18,1); - - System.out.println(person.name); - System.out.println(person.age); - System.out.println(person.sex); - } -} -``` - -在 Person 类中新增方法 `initialize()`,然后在新建对象后传参进行初始化(`person.initialize("沉默王二", 18, 1)`)。 - -#### 第三种:通过构造方法初始化。 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class Person { - private String name; - private int age; - private int sex; - - public Person(String name, int age, int sex) { - this.name = name; - this.age = age; - this.sex = sex; - } - - public static void main(String[] args) { - Person person = new Person("沉默王二", 18, 1); - - System.out.println(person.name); - System.out.println(person.age); - System.out.println(person.sex); - } -} -``` - -这也是最标准的一种做法,直接在 new 的时候把参数传递过去。 - -补充一点知识,匿名对象。匿名对象意味着没有引用变量,它只能在创建的时候被使用一次。 - -```java -new Person(); -``` - -可以直接通过匿名对象调用方法: - -```java -new Person().initialize("沉默王二", 18, 1); -``` - -### 05、关于对象 - -#### **1)抽象的历程** - -所有编程语言都是一种抽象,甚至可以说,我们能够解决的问题的复杂程度取决于抽象的类型和质量。 - -Smalltalk 是历史上第一门获得成功的面向对象语言,也为 Java 提供了灵感。它有 5 个基本特征: - -- 万物皆对象。 -- 一段程序实际上就是多个对象通过发送消息的方式来告诉彼此该做什么。 -- 通过组合的方式,可以将多个对象封装成其他更为基础的对象。 -- 对象是通过类实例化的。 -- 同一类型的对象可以接收相同的消息。 - -总结一句话就是: - ->状态+行为+标识=对象,每个对象在内存中都会有一个唯一的地址。 - -#### **2)对象具有接口** - -所有的对象,都可以被归为一类,并且同一类对象拥有一些共同的行为和特征。在 Java 中,class 关键字用来定义一个类型。 - -创建抽象数据类型是面向对象编程的一个基本概念。你可以创建某种类型的变量,Java 中称之为对象或者实例,然后你就可以操作这些变量,Java 中称之为发送消息或者发送请求,最后对象决定自己该怎么做。 - -类描述了一系列具有相同特征和行为的对象,从宽泛的概念上来说,类其实就是一种自定义的数据类型。 - -一旦创建了一个类,就可以用它创建任意多个对象。面向对象编程语言遇到的最大一个挑战就是,如何把现实/虚拟的元素抽象为 Java 中的对象。 - -对象能够接收什么样的请求是由它的[接口](https://tobebetterjavaer.com/oo/interface.html)定义的。具体是怎么做到的,就由它的实现方法来实现。 - -#### **3)访问权限修饰符** - -类的创建者有时候也被称为 API 提供者,对应的,类的使用者就被称为 API 调用者。 - -JDK 就给我们提供了 Java 的基础实现,JDK 的作者也就是基础 API 的提供者(Java 多线程部分的作者 Doug Lea 是被 Java 程序员敬佩的一个大佬),我们这些 Java 语言的使用者,说白了就是 JDK 的调用者。 - -![](https://cdn.tobebetterjavaer.com/stutymore/object-class-20230410094307.png) - -当然了,假如我们也提供了新的类给其他调用者,我们也就成为了新的创建者。 - -API 创建者在创建新的类的时候,只暴露必要的接口,而隐藏其他所有不必要的信息,之所以要这么做,是因为如果这些信息对调用者是不可见的,那么创建者就可以随意修改隐藏的信息,而不用担心对调用者的影响。 - -这里就必须要讲到 [Java 的权限修饰符](https://tobebetterjavaer.com/oo/access-control.html)。 - -访问权限修饰符的第一个作用是,防止类的调用者接触到他们不该接触的内部实现;第二个作用是,让类的创建者可以轻松修改内部机制而不用担心影响到调用者的使用。 - -- public -- private -- protected - -还有一种“默认”的权限修饰符,是缺省的,它修饰的类可以访问同一个包下面的其他类。 - -#### **4)组合** - -我们可以把一个创建好的类作为另外一个类的成员变量来使用,利用已有的类组成成一个新的类,被称为“复用”,组合代表的关系是 has-a 的关系。 - -#### **5)继承** - -[继承](https://tobebetterjavaer.com/oo/extends-bigsai.html)是 Java 中非常重要的一个概念,子类继承父类,也就拥有了父类中 protected 和 public 修饰的方法和字段,同时,子类还可以扩展一些自己的方法和字段,也可以重写继承过来方法。 - -常见的例子,就是形状可以有子类圆形、方形、三角形,它们的基础接口是相同的,比如说都有一个 `draw()` 的方法,子类可以继承这个方法实现自己的绘制方法。 - -如果子类只是重写了父类的方法,那么它们之间的关系就是 is-a 的关系,但如果子类增加了新的方法,那么它们之间的关系就变成了 is-like-a 的关系。 - -#### **6)多态** - -比如说有一个父类Shape - -```java -public class Shape { - public void draw() { - System.out.println("形状"); - } -} -``` - -子类Circle - -```java -public class Circle extends Shape{ - @Override - public void draw() { - System.out.println("圆形"); - } -} -``` - -子类Line - -```java -public class Line extends Shape { - @Override - public void draw() { - System.out.println("线"); - } -} -``` - -测试类 - -```java -public class Test { - public static void main(String[] args) { - Shape shape1 = new Line(); - shape1.draw(); - Shape shape2 = new Circle(); - shape2.draw(); - } -} -``` - -运行结果: - -``` -线 -圆形 -``` - -在测试类中,shape1 的类型为 Shape,shape2 的类型也为 Shape,但调用 `draw()` 方法后,却能自动调用子类 Line 和 Circle 的 `draw()` 方法,这是为什么呢? - -其实就是 Java 中的[多态](https://tobebetterjavaer.com/oo/polymorphism.html)。 - -### 06、小结 - -“怎么样,三妹,是不是对 Java 有了更深入更清晰的理解?”终于讲完了,我深呼了一口气,好舒畅啊! - -“是的,哥,感觉 Java 也就那么回事嘛。”哎呀,三妹有点狂了起来,“万物皆对象,除了基本数据类型。” - -“哇,三妹,你可以啊,都会自己梳理总结了。”我倍感欣慰,觉得果然是劳有所获,你讲的认真,听众就能理解和 get,满足了。 - -## 5.2 Java中的包 - -“三妹,这一节,我们简单过一下 Java 中的包,也就是 package,这个一点就透,很好掌握。”我放下手中的雪碧,翻开笔记本,登上 GitHub,点开《二哥的 Java 进阶之路》,找到这篇「Java 中的包」,开始滔滔不绝起来。 - -“二哥,你等一下。”让我打开思维导图做一下笔记📒。 - -### 关于包 - -在前面的代码中,我们把类和接口命名为`Person`、`Student`、`Hello`等简单的名字。 - -在团队开发中,如果小明写了一个`Person`类,小红也写了一个`Person`类,现在,小白既想用小明的`Person`,也想用小红的`Person`,怎么办? - -如果小军写了一个`Arrays`类,恰好 JDK 也自带了一个`Arrays`类,如何解决类名冲突? - -在 Java 中,我们使用`package`来解决名字冲突。 - -Java 定义了一种名字空间,称之为包:`package`。一个类总是属于某个包,类名(比如`Person`)只是一个简写,真正的完整类名是`包名.类名`。 - -例如: - -小明的`Person`类存放在包`ming`下面,因此,完整类名是`ming.Person`; - -小红的`Person`类存放在包`hong`下面,因此,完整类名是`hong.Person`; - -小军的`Arrays`类存放在包`mr.jun`下面,因此,完整类名是`mr.jun.Arrays`; - -JDK 的`Arrays`类存放在包`java.util`下面,因此,完整类名是`java.util.Arrays`。 - -在定义`class`的时候,我们需要在第一行声明这个`class`属于哪个包。 - -小明的`Person.java`文件: - -```java -package ming; // 申明包名ming - -public class Person { -} -``` - -小军的`Arrays.java`文件: - -```java -package mr.jun; // 申明包名mr.jun - -public class Arrays { -} -``` - -在 Java 虚拟机执行的时候,JVM 只看完整类名,因此,只要包名不同,类就不同。 - -包可以是多层结构,用`.`隔开。例如:`java.util`。 - - ->要特别注意:包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。 - - -没有定义包名的`class`,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。 - -我们还需要按照包结构把上面的 Java 文件组织起来。假设以`package_sample`作为根目录,`src`作为源码目录,那么所有文件结构就是: - -```ascii -package_sample -└─ src - ├─ hong - │ └─ Person.java - │ ming - │ └─ Person.java - └─ mr - └─ jun - └─ Arrays.java -``` - -即所有 Java 文件对应的目录层次要和包的层次一致。 - -编译后的`.class`文件也需要按照包结构存放。如果使用 IDE,把编译后的`.class`文件放到`bin`目录下,那么,编译的文件结构就是: - -```ascii -package_sample -└─ bin - ├─ hong - │ └─ Person.class - │ ming - │ └─ Person.class - └─ mr - └─ jun - └─ Arrays.class -``` - -编译的命令相对比较复杂,我们需要在`src`目录下执行`javac`命令: - -``` -javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java -``` - -在 IDE 中,会自动根据包结构编译所有 Java 源码,所以不必担心使用命令行编译的复杂命令。 - -### 包的作用域 - -位于同一个包的类,可以访问包作用域的字段和方法。 - -不用`public`、`protected`、`private`修饰的字段和方法就是包作用域。例如,`Person`类定义在`hello`包下面: - -```java -package hello; - -public class Person { - // 包作用域: - void hello() { - System.out.println("Hello!"); - } -} -``` - -`Main`类也定义在`hello`包下面,就可以直接访问 Person 类: - -```java -package hello; - -public class Main { - public static void main(String[] args) { - Person p = new Person(); - p.hello(); // 可以调用,因为Main和Person在同一个包 - } -} -``` - -### 导入包 - -在一个`class`中,我们总会引用其他的`class`。例如,小明的`ming.Person`类,如果要引用小军的`mr.jun.Arrays`类,他有三种写法: - -第一种,直接写出完整类名,例如: - -```java -// Person.java -package ming; - -public class Person { - public void run() { - mr.jun.Arrays arrays = new mr.jun.Arrays(); - } -} -``` - -很显然,每次都要写完整的类名比较痛苦。 - -因此,第二种写法是用`import`语句,导入小军的`Arrays`,然后写简单类名: - -```java -// Person.java -package ming; - -// 导入完整类名: -import mr.jun.Arrays; - -public class Person { - public void run() { - Arrays arrays = new Arrays(); - } -} -``` - -在写`import`的时候,可以使用`*`,表示把这个包下面的所有`class`都导入进来(但不包括子包的`class`): - -```java -// Person.java -package ming; - -// 导入mr.jun包的所有class: -import mr.jun.*; - -public class Person { - public void run() { - Arrays arrays = new Arrays(); - } -} -``` - -我们一般不推荐这种写法,因为在导入了多个包后,很难看出`Arrays`类属于哪个包。 - -还有一种`import static`的语法,它可以导入一个类的静态字段和静态方法: - -```java -package main; - -// 导入System类的所有静态字段和静态方法: -import static java.lang.System.*; - -public class Main { - public static void main(String[] args) { - // 相当于调用System.out.println(…) - out.println("Hello, world!"); - } -} -``` - -`import static`很少使用。 - -Java 编译器最终编译出的`.class`文件只使用 *完整类名*,因此,在代码中,当编译器遇到一个`class`名称时: - -- 如果是完整类名,就直接根据完整类名查找这个`class`; -- 如果是简单类名,按下面的顺序依次查找: - - 查找当前`package`是否存在这个`class`; - - 查找`import`的包是否包含这个`class`; - - 查找`java.lang`包是否包含这个`class`。 - -如果按照上面的规则还无法确定类名,则编译报错。 - -我们来看一个例子: - -```java -// Main.java -package test; - -import java.text.Format; - -public class Main { - public static void main(String[] args) { - java.util.List list; // ok,使用完整类名 -> java.util.List - Format format = null; // ok,使用import的类 -> java.text.Format - String s = "hi"; // ok,使用java.lang包的String -> java.lang.String - System.out.println(s); // ok,使用java.lang包的System -> java.lang.System - MessageFormat mf = null; // 编译错误:无法找到MessageFormat: MessageFormat cannot be resolved to a type - } -} -``` - -因此,编写 class 的时候,编译器会自动帮我们做两个 import 动作: - -- 默认自动`import`当前`package`的其他`class`; -- 默认自动`import java.lang.*`。 - - ->注意:自动导入的是java.lang包,但类似java.lang.reflect这些包仍需要手动导入。 - - -如果有两个`class`名称相同,例如,`mr.jun.Arrays`和`java.util.Arrays`,那么只能`import`其中一个,另一个必须写完整类名。 - -### 包的最佳实践 - -为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。例如: - -- org.apache -- org.apache.commons.log -- com.tobebetterjavaer.sample - -子包就可以根据功能自行命名。 - -要注意不要和`java.lang`包的类重名,即自己的类不要使用这些名字: - -- String -- System -- Runtime -- ... - -要注意也不要和 JDK 常用类重名: - -- java.util.List -- java.text.Format -- java.math.BigInteger -- ... - - -### 小结 - -Java 内建的`package`机制是为了避免`class`命名冲突; - -JDK 的核心类使用`java.lang`包,编译器会自动导入; - -JDK 的其它常用类定义在`java.util.*`,`java.math.*`,`java.text.*`,……; - -包名推荐使用倒置的域名,例如`org.apache`。 - ---- - -> 参考链接:[https://www.liaoxuefeng.com/wiki/1252599548343744/1260467032946976](https://www.liaoxuefeng.com/wiki/1252599548343744/1260467032946976),整理:沉默王二 - - - - - - - -## 5.3 Java中的变量 - -“二哥,听说 Java 变量在以后的日子里经常用,能不能提前给我透露透露?”三妹咪了一口麦香可可奶茶后对我说。 - -“三妹啊,搬个凳子坐我旁边,听二哥来给你慢慢说啊。” - -Java 变量就好像一个容器,可以保存程序在运行过程中的值,它在声明的时候会定义对应的[数据类型](https://tobebetterjavaer.com/basic-grammar/basic-data-type.html)(Java 分为两种数据类型:基本数据类型和引用数据类型)。变量按照作用域的范围又可分为三种类型:局部变量,成员变量和静态变量。 - -比如说,`int data = 88;`,其中 data 就是一个变量,它的值为 88,类型为整型(int)。 - - -### 01、局部变量 - -在方法体内声明的变量被称为局部变量,该变量只能在该方法内使用,类中的其他方法并不知道该变量。来看下面这个示例: - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class LocalVariable { - public static void main(String[] args) { - int a = 10; - int b = 10; - int c = a + b; - System.out.println(c); - } -} -``` - -其中 a、b、c 就是局部变量,它们只能在当前这个 main 方法中使用。 - -声明局部变量时的注意事项: - -- 局部变量声明在方法、构造方法或者语句块中。 -- 局部变量在方法、构造方法、或者语句块被执行的时候创建,当它们执行完成后,将会被销毁。 -- 访问修饰符不能用于局部变量。 -- 局部变量只在声明它的方法、构造方法或者语句块中可见。 -- 局部变量是在栈上分配的。 -- 局部变量没有默认值,所以局部变量被声明后,必须经过初始化,才可以使用。 - -### 02、成员变量 - -在类内部但在方法体外声明的变量称为成员变量,或者实例变量,或者字段。之所以称为实例变量,是因为该变量只能通过类的实例(对象)来访问。来看下面这个示例: - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class InstanceVariable { - int data = 88; - public static void main(String[] args) { - InstanceVariable iv = new InstanceVariable(); - System.out.println(iv.data); // 88 - } -} -``` - -其中 iv 是一个变量,它是一个引用类型的变量。`new` 关键字可以创建一个类的实例(也称为对象),通过“=”操作符赋值给 iv 这个变量,iv 就成了这个对象的引用,通过 `iv.data` 就可以访问成员变量了。 - -声明成员变量时的注意事项: - -- 成员变量声明在一个类中,但在方法、构造方法和语句块之外。 -- 当一个对象被实例化之后,每个成员变量的值就跟着确定。 -- 成员变量在对象创建的时候创建,在对象被销毁的时候销毁。 -- 成员变量的值应该至少被一个方法、构造方法或者语句块引用,使得外部能够通过这些方式获取实例变量信息。 -- 成员变量可以声明在使用前或者使用后。 -- 访问修饰符可以修饰成员变量。 -- 成员变量对于类中的方法、构造方法或者语句块是可见的。一般情况下应该把成员变量设为私有。通过使用访问修饰符可以使成员变量对子类可见;成员变量具有默认值。数值型变量的默认值是 0,布尔型变量的默认值是 false,引用类型变量的默认值是 null。变量的值可以在声明时指定,也可以在构造方法中指定。 - -### 03、静态变量 - -通过 [static 关键字](https://tobebetterjavaer.com/oo/static.html)声明的变量被称为静态变量(类变量),它可以直接被类访问,来看下面这个示例: - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class StaticVariable { - static int data = 99; - public static void main(String[] args) { - System.out.println(StaticVariable.data); // 99 - } -} -``` - -其中 data 就是静态变量,通过`类名.静态变量`就可以访问了,不需要创建类的实例。 - -声明静态变量时的注意事项: - -- 静态变量在类中以 static 关键字声明,但必须在方法构造方法和语句块之外。 -- 无论一个类创建了多少个对象,类只拥有静态变量的一份拷贝。 -- 静态变量除了被声明为常量外很少使用。 -- 静态变量储存在静态存储区。 -- 静态变量在程序开始时创建,在程序结束时销毁。 -- 与成员变量具有相似的可见性。但为了对类的使用者可见,大多数静态变量声明为 public 类型。 -- 静态变量的默认值和实例变量相似。 -- 静态变量还可以在静态语句块中初始化。 - -### 04、常量 - -在 Java 中,有些数据的值是不会发生改变的,这些数据被叫做常量——使用 [final 关键字](https://tobebetterjavaer.com/oo/final.html)修饰的成员变量。常量的值一旦给定就无法改变! - -常量在程序运行过程中主要有 2 个作用: - -- 代表常数,便于修改(例如:圆周率的值,`final double PI = 3.14`) -- 增强程序的可读性(例如:常量 UP、DOWN 用来代表上和下,`final int UP = 0`) - -Java 要求常量名必须大写。来看下面这个示例: - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class FinalVariable { - final String CHEN = "沉"; - static final String MO = "默"; - public static void main(String[] args) { - FinalVariable fv = new FinalVariable(); - System.out.println(fv.CHEN); - System.out.println(MO); - } -} -``` - -“好了,三妹,关于 Java 变量就先说这么多吧,你是不是已经清楚了?”转动了一下僵硬的脖子后,我对三妹说。 - -“是啊,二哥,我想以后还会再见到它们吧?” - -“那见的次数可就多了,就好像你每天眨眼的次数一样多。” - - -## 5.4 Java中的方法 - -“二哥,这一节我们学什么呢?”三妹满是期待的问我。 - -“这一节我们来了解一下 Java 中的方法——什么是方法?如何声明方法?方法有哪几种?什么是实例方法?什么是静态方法?什么是抽象方法?什么是本地方法?”我笑着对三妹说,“我开始了啊,你要注意力集中啊。” - -### 01、Java中的方法是什么? - -方法用来实现代码的可重用性,我们编写一次方法,并多次使用它。通过增加或者删除方法中的一部分代码,就可以提高整体代码的可读性。 - -只有方法被调用时,它才会执行。Java 中最有名的方法当属 `main()` 方法,这是程序的入口。 - -### 02、如何声明方法? - -方法的声明反映了方法的一些信息,比如说可见性、返回类型、方法名和参数。如下图所示。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/17-01.png) - -**访问权限**:它指定了方法的可见性。Java 提供了四种[访问权限修饰符](https://tobebetterjavaer.com/oo/access-control.html): - -- public:该方法可以被所有类访问。 -- private:该方法只能在定义它的类中访问。 -- protected:该方法可以被同一个包中的类,或者不同包中的子类访问。 -- default:如果一个方法没有使用任何访问权限修饰符,那么它是 package-private 的,意味着该方法只能被同一个包中的类可见。 - -**返回类型**:方法返回的数据类型,可以是基本数据类型、对象和集合,如果不需要返回数据,则使用 void 关键字。 - -**方法名**:方法名最好反应出方法的功能,比如,我们要创建一个将两个数字相减的方法,那么方法名最好是 subtract。 - -方法名最好是一个动词,并且以小写字母开头。如果方法名包含两个以上单词,那么第一个单词最好是动词,然后是形容词或者名词,并且要以驼峰式的命名方式命名。比如: - -- 一个单词的方法名:`sum()` -- 多个单词的方法名:`stringComparision()` - -一个方法可能与同一个类中的另外一个方法同名,这被称为方法重载。 - -**参数**:参数被放在一个圆括号内,如果有多个参数,可以使用逗号隔开。参数包含两个部分,参数类型和参数名。如果方法没有参数,圆括号是空的。 - -**方法签名**:每一个方法都有一个签名,包括方法名和参数。 - -**方法体**:方法体放在一对花括号内,把一些代码放在一起,用来执行特定的任务。 - -### 03、方法有哪几种? - -方法可以分为两种,一种叫标准类库方法,一种叫用户自定义方法。 - -#### **1)预先定义方法** - -Java 提供了大量预先定义好的方法供我们调用,也称为标准类库方法,或者内置方法。比如说 String 类的 `length()`、`equals()`、`compare()` 方法,以及我们在初学 Java 阶段最常用的 `println()` 方法,用来在控制台打印信息。 - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class PredefinedMethodDemo { - public static void main(String[] args) { - System.out.println("沉默王二,一枚有趣的程序员"); - } -} -``` - -在上面的代码中,我们使用了两个预先定义的方法,`main()` 方法是程序运行的入口,`println()` 方法是 `PrintStream` 类的一个方法。这些方法已经提前定义好了,所以我们可以直接使用它们。 - -我们可以通过集成开发工具查看预先定义方法的方法签名,当我们把鼠标停留在 `println()` 方法上面时,就会显示下图中的内容: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/17-02.png) - -`println()` 方法的访问权限修饰符是 public,返回类型为 void,方法名为 println,参数为 `String x`,以及 Javadoc(方法是干嘛的)。 - -预先定义方法让编程变得简单了起来,我们只需要在实现某些功能的时候直接调用这些方法即可,不需要重新编写。 - -Java 的一个非常大的优势,就是,JDK 的设计者(开发者)为我们提供了大量的标准类库方法,这对于初学编程的新手来说极其友好;不仅如此,GitHub/码云上也有大量可以直接拿到生产环境下使用的第三方类库,比如说 hutool 啊、Apache 包啊、一线大厂或者顶级开发大佬贡献的类库,比如说 Druid、Gson 等等。 - -但如果你想从一个初级开发者(俗称调包侠)晋升为一名优秀的 Java 工程师,那就需要深入研究这些源码,并掌握,最好是能自己写出来这些源码,最起码能自定义一些源码,以便为我们所用。 - -#### **2)用户自定义方法** - -当预先定义方法无法满足我们的要求时,就需要自定义一些方法,比如说,我们来定义这样一个方法,用来检查数字是偶数还是奇数。 - -```java -public static void findEvenOdd(int num) { - if (num % 2 == 0) { - System.out.println(num + " 是偶数"); - } else { - System.out.println(num + " 是奇数"); - } -} -``` - -方法名叫做 `findEvenOdd`,访问权限修饰符是 public,并且是静态的(static),返回类型是 void,参数有一个整型(int)的 num。方法体中有一个 if else 语句,如果 num 可以被 2 整除,那么就打印这个数字是偶数,否则就打印这个数字是奇数。 - -方法被定义好后,如何被调用呢? - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class EvenOddDemo { - public static void main(String[] args) { - findEvenOdd(10); - findEvenOdd(11); - } - - public static void findEvenOdd(int num) { - if (num % 2 == 0) { - System.out.println(num + " 是偶数"); - } else { - System.out.println(num + " 是奇数"); - } - } -} -``` - -`main()` 方法是程序的入口,并且是静态的,那么就可以直接调用同样是静态方法的 `findEvenOdd()`。 - -当一个方法被 static 关键字修饰时,它就是一个静态方法。换句话说,静态方法是属于类的,不属于类实例的(不需要通过 new 关键字创建对象来调用,直接通过类名就可以调用)。 - -### 04、什么是实例方法? - -没有使用 [static 关键字](https://tobebetterjavaer.com/oo/static.html)修饰,但在类中声明的方法被称为实例方法,在调用实例方法之前,必须创建类的对象。 - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class InstanceMethodExample { - public static void main(String[] args) { - InstanceMethodExample instanceMethodExample = new InstanceMethodExample(); - System.out.println(instanceMethodExample.add(1, 2)); - } - - public int add(int a, int b) { - return a + b; - } -} -``` - -`add()` 方法是一个实例方法,需要创建 InstanceMethodExample 对象来访问。 - -实例方法有两种特殊类型: - -- getter 方法 -- setter 方法 - -getter 方法用来获取私有变量(private 修饰的字段)的值,setter 方法用来设置私有变量的值。 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class Person { - private String name; - private int age; - private int sex; - - 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; - } - - public int getSex() { - return sex; - } - - public void setSex(int sex) { - this.sex = sex; - } -} -``` - -getter 方法以 get 开头,setter 方法以 set 开头。 - -### 05、什么是静态方法? - -相应的,有 [static 关键字](https://tobebetterjavaer.com/oo/static.html)修饰的方法就叫做静态方法。 - -```java -/** - * 微信搜索「沉默王二」,回复 Java - * - * @author 沉默王二 - * @date 8/9/22 - */ -public class StaticMethodExample { - public static void main(String[] args) { - System.out.println(add(1,2)); - } - - public static int add(int a, int b) { - return a + b; - } -} -``` - -StaticMethodExample 类中,mian 和 add 方法都是静态方法,不同的是,main 方法是程序的入口。当我们调用静态方法的时候,就不需要 new 出来类的对象,就可以直接调用静态方法了,一些工具类的方法都是静态方法,比如说 hutool 工具类库,里面有大量的静态方法可以直接调用。 - -> Hutool 的目标是使用一个工具方法代替一段复杂代码,从而最大限度的避免“复制粘贴”代码的问题,彻底改变我们写代码的方式。 - -以计算 MD5 为例: - -- 👴【以前】打开搜索引擎 -> 搜“Java MD5 加密” -> 打开某篇博客-> 复制粘贴 -> 改改好用 -- 👦【现在】引入 Hutool -> SecureUtil.md5() - -Hutool 的存在就是为了减少代码搜索成本,避免网络上参差不齐的代码出现导致的 bug。 - -### 06、什么是抽象方法? - -没有方法体的方法被称为抽象方法,它总是在[抽象类](https://tobebetterjavaer.com/oo/abstract.html)中声明。这意味着如果类有抽象方法的话,这个类就必须是抽象的。可以使用 atstract 关键字创建抽象方法和抽象类。 - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -abstract class AbstractDemo { - abstract void display(); -} -``` - -当一个类继承了抽象类后,就必须重写抽象方法: - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class MyAbstractDemo extends AbstractDemo { - @Override - void display() { - System.out.println("重写了抽象方法"); - } - - public static void main(String[] args) { - MyAbstractDemo myAbstractDemo = new MyAbstractDemo(); - myAbstractDemo.display(); - } -} -``` - -输出结果如下所示: - -``` -重写了抽象方法 -``` - -“关于方法,我们就讲到这里吧,学会了类/变量/方法,基本上就可以做一个入门级的 Java 程序员了。”我面露微笑,继续对三妹说,“继续加油吧!” - -“好的,谢谢二哥你的细心帮助。” - - -## 5.5 Java可变参数 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[5.5 Java可变参数](https://tobebetterjavaer.com/basic-extra-meal/varables.html) - - - -## 5.6 Java native方法 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[5.6 Java native方法](https://tobebetterjavaer.com/oo/native-method.html) - - -## 5.7 Java构造方法 - -“三妹,[上一节](https://tobebetterjavaer.com/oo/method.html)学了 Java 中的方法,接着学构造方法的话,难度就小很多了。”刚吃完中午饭,虽然有些困意,但趁机学个 10 分钟也是不错的,睡眠会更心满意足一些,于是我面露微笑地对三妹说。 - -“在 Java 中,构造方法是一种特殊的方法,当一个类被实例化的时候,就会调用构造方法。只有在构造方法被调用的时候,对象才会被分配内存空间。每次使用 `new` 关键字创建对象的时候,构造方法至少会被调用一次。” - -“如果你在一个类中没有看见构造方法,并不是因为构造方法不存在,而是被缺省了,编译器会给这个类提供一个默认的构造方法。就是说,Java 有两种类型的构造方法:**无参构造方法和有参构造方法**。” - -“注意,之所以叫它构造方法,是因为对象在创建的时候,需要通过构造方法初始化值——描写对象有哪些初始化状态。” - -“哥,你缓缓,一口气说这么多,也真有你的。”三妹听得聚精会神,但也知道关心她这个既当哥又当老师的二哥了。 - -### 01、创建构造方法的规则 - -构造方法必须符合以下规则: - -- 构造方法的名字必须和类名一样; -- 构造方法没有返回类型,包括 void; -- 构造方法不能是抽象的(abstract)、静态的(static)、最终的(final)、同步的(synchronized)。 - -简单解析一下最后一条规则。 - -- 由于构造方法不能被子类继承,所以用 final 和 abstract 关键字修饰没有意义; -- 构造方法用于初始化一个对象,所以用 static 关键字修饰没有意义; -- 多个线程不会同时创建内存地址相同的同一个对象,所以用 synchronized 关键字修饰没有必要。 - -构造方法的语法格式如下: - -```java -class class_name { - public class_name(){} // 默认无参构造方法 - public ciass_name([paramList]){} // 定义有参数列表的构造方法 - … - // 类主体 -} -``` - -值得注意的是,如果用 void 声明构造方法的话,编译时不会报错,但 Java 会把这个所谓的“构造方法”当成普通方法来处理。 - -```java -/** - * 微信搜索「沉默王二」,回复 Java - * - * @author 沉默王二 - * @date 2020/11/26 - */ -public class Demo { - void Demo(){ } -} -``` - -`void Demo(){}` 看起来很符合构造方法的写法(与类名相同),但其实只是一个不符合规范的普通方法,方法名的首字母使用了大写,方法体为空,它并不是默认的无参构造方法,可以通过反编译后的字节码验证。 - -```java -public class Demo { - public Demo() { - } - - void Demo() { - } -} -``` - -`public Demo() {}` 才是真正的无参构造方法。 - -不过,可以使用[访问权限修饰符](https://tobebetterjavaer.com/oo/access-control.html)(private、protected、public、default)来修饰构造方法,访问权限修饰符决定了构造方法的创建方式。 - - -### 02、默认构造方法 - -如果一个构造方法中没有任何参数,那么它就是一个默认构造方法,也称为无参构造方法。 - -```java -/** - * @author 微信搜「沉默王二」,回复关键字 PDF - */ -public class Bike { - Bike(){ - System.out.println("一辆自行车被创建"); - } - - public static void main(String[] args) { - Bike bike = new Bike(); - } -} -``` - -在上面这个例子中,我们为 Bike 类中创建了一个无参的构造方法,它在我们创建对象的时候被调用。 - -程序输出结果如下所示: - -``` -一辆自行车被创建 -``` - -通常情况下,无参构造方法是可以缺省的,我们开发者并不需要显式的声明无参构造方法,把这项工作交给编译器就可以了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/18-01.png) - -“二哥,默认构造方法的目的是什么?它为什么是一个空的啊?”三妹疑惑地看着我,提出了这个尖锐的问题。 - -“三妹啊,默认构造方法的目的主要是为对象的字段提供默认值,看下面这个例子你就明白了。”我胸有成竹地回答道。 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class Person { - private String name; - private int age; - - public static void main(String[] args) { - Person p = new Person(); - System.out.println("姓名 " + p.name + " 年龄 " + p.age); - } -} -``` - -输出结果如下所示: - -``` -姓名 null 年龄 0 -``` - -在上面的例子中,默认构造方法初始化了 name 和 age 的值,name 是 String 类型,所以默认值为 null,age 是 int 类型,所以默认值为 0。如果没有默认构造方法的话,这项工作就无法完成了。 - - -### 03、有参构造方法 - -有参数的构造方法被称为有参构造方法,参数可以有一个或多个。有参构造方法可以为不同的对象提供不同的值。当然,也可以提供相同的值。 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class ParamConstructorPerson { - private String name; - private int age; - - public ParamConstructorPerson(String name, int age) { - this.name = name; - this.age = age; - } - - public void out() { - System.out.println("姓名 " + name + " 年龄 " + age); - } - - public static void main(String[] args) { - ParamConstructorPerson p1 = new ParamConstructorPerson("沉默王二",18); - p1.out(); - - ParamConstructorPerson p2 = new ParamConstructorPerson("沉默王三",16); - p2.out(); - } -} -``` - -在上面的例子中,构造方法有两个参数(name 和 age),这样的话,我们在创建对象的时候就可以直接为 name 和 age 赋值了。 - -```java -new ParamConstructorPerson("沉默王二",18); -new ParamConstructorPerson("沉默王三",16); -``` - -如果没有有参构造方法的话,就需要通过 setter 方法给字段赋值了。 - - -### 04、重载构造方法 - -在 Java 中,构造方法和方法类似,只不过没有返回类型。它也可以像方法一样被[重载](https://tobebetterjavaer.com/basic-extra-meal/override-overload.html)。构造方法的重载也很简单,只需要提供不同的参数列表即可。编译器会通过参数的数量来决定应该调用哪一个构造方法。 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class OverloadingConstrutorPerson { - private String name; - private int age; - private int sex; - - public OverloadingConstrutorPerson(String name, int age, int sex) { - this.name = name; - this.age = age; - this.sex = sex; - } - - public OverloadingConstrutorPerson(String name, int age) { - this.name = name; - this.age = age; - } - - public void out() { - System.out.println("姓名 " + name + " 年龄 " + age + " 性别 " + sex); - } - - public static void main(String[] args) { - OverloadingConstrutorPerson p1 = new OverloadingConstrutorPerson("沉默王二",18, 1); - p1.out(); - - OverloadingConstrutorPerson p2 = new OverloadingConstrutorPerson("沉默王三",16); - p2.out(); - } -} -``` - -创建对象的时候,如果传递的是三个参数,那么就会调用 `OverloadingConstrutorPerson(String name, int age, int sex)` 这个构造方法;如果传递的是两个参数,那么就会调用 `OverloadingConstrutorPerson(String name, int age)` 这个构造方法。 - - -### 05、构造方法和方法的区别 - -构造方法和方法之间的区别还是蛮多的,比如说下面这些: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/18-02.png) - - - -### 06、复制对象 - -复制一个对象可以通过下面三种方式完成: - -- 通过构造方法 -- 通过对象的值 -- 通过 Object 类的 `clone()` 方法 - -#### 1)通过构造方法 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class CopyConstrutorPerson { - private String name; - private int age; - - public CopyConstrutorPerson(String name, int age) { - this.name = name; - this.age = age; - } - - public CopyConstrutorPerson(CopyConstrutorPerson person) { - this.name = person.name; - this.age = person.age; - } - - public void out() { - System.out.println("姓名 " + name + " 年龄 " + age); - } - - public static void main(String[] args) { - CopyConstrutorPerson p1 = new CopyConstrutorPerson("沉默王二",18); - p1.out(); - - CopyConstrutorPerson p2 = new CopyConstrutorPerson(p1); - p2.out(); - } -} -``` - -在上面的例子中,有一个参数为 CopyConstrutorPerson 的构造方法,可以把该参数的字段直接复制到新的对象中,这样的话,就可以在 new 关键字创建新对象的时候把之前的 p1 对象传递过去。 - -#### 2)通过对象的值 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class CopyValuePerson { - private String name; - private int age; - - public CopyValuePerson(String name, int age) { - this.name = name; - this.age = age; - } - - public CopyValuePerson() { - } - - public void out() { - System.out.println("姓名 " + name + " 年龄 " + age); - } - - public static void main(String[] args) { - CopyValuePerson p1 = new CopyValuePerson("沉默王二",18); - p1.out(); - - CopyValuePerson p2 = new CopyValuePerson(); - p2.name = p1.name; - p2.age = p1.age; - - p2.out(); - } -} -``` - -这种方式比较粗暴,直接拿 p1 的字段值复制给 p2 对象(`p2.name = p1.name`)。 - -#### 3)通过 Object 类的 `clone()` 方法 - -```java -/** - * @author 沉默王二,一枚有趣的程序员 - */ -public class ClonePerson implements Cloneable { - private String name; - private int age; - - public ClonePerson(String name, int age) { - this.name = name; - this.age = age; - } - - @Override - protected Object clone() throws CloneNotSupportedException { - return super.clone(); - } - - public void out() { - System.out.println("姓名 " + name + " 年龄 " + age); - } - - public static void main(String[] args) throws CloneNotSupportedException { - ClonePerson p1 = new ClonePerson("沉默王二",18); - p1.out(); - - ClonePerson p2 = (ClonePerson) p1.clone(); - p2.out(); - } -} -``` - -通过 `clone()` 方法复制对象的时候,ClonePerson 必须先实现 Cloneable 接口的 `clone()` 方法,然后再调用 `clone()` 方法(`ClonePerson p2 = (ClonePerson) p1.clone()`)。 - ->拓展阅读:[浅拷贝与深拷贝](https://tobebetterjavaer.com/basic-extra-meal/deep-copy.html) - -### 07、ending - -“二哥,我能问一些问题吗?”三妹精神焕发,没有丝毫的疲惫。 - -“当然可以啊,你问。”我很欣赏三妹孜孜不倦的态度。 - -“构造方法真的不返回任何值吗?” - -“构造方法虽然没有返回值,但返回的是类的对象。” - -“构造方法只能完成字段初始化的工作吗?” - -“初始化字段只是构造方法的一种工作,它还可以做更多,比如启动线程,调用其他方法等。” - -“好的,二哥,我的问题问完了,今天的学习可以结束了!”三妹一脸得意的样子。 - -“那你记得复习下一节的内容哦。”感受到三妹已经学到了知识,我也很欣慰。 - -## 5.8 Java访问权限修饰符 - -“我们先来讨论一下为什么需要访问权限控制。其实之前我们在讲[类和对象](https://tobebetterjavaer.com/oo/object-class.html)的时候有提到,今天我们来详细地聊一聊,三妹。”我开门见山地说,“三妹,你打开思维导图,记得做笔记哦。” - -“好的。”三妹应声回答。 - -考虑两个场景: - -场景 1:工程师 A 编写了一个类 ClassA,但是工程师 A 并不希望 ClassA 被其他类都访问到,该如何处理呢? - -场景 2:工程师 A 编写了一个类 ClassA,其中有两个方法 fun1、fun2,工程师只想让 fun1 对外可见,也就是说,如果别的工程师来调用 ClassA,只可以调用方法 fun1,该怎么处理呢? - -此时,访问权限控制便可以起到作用了。 - -在 Java 中,提供了四种访问权限控制: - -- 默认访问权限(包访问权限) -- public -- private -- protected - -类只可以用默认访问权限和 public 修饰。比如说: - -```java -public class Wanger{} -``` - -或者 - -```java -class Wanger{} -``` - -但变量和方法则都可以修饰。 - -### 1. 修饰类 - -- 默认访问权限(包访问权限):用来修饰类的话,表示该类只对同一个包中的其他类可见。 -- public:用来修饰类的话,表示该类对其他所有的类都可见。 - - -例 1: - -Main.java: - -```java -package com.tobetterjavaer.test1; - -public class Main { - public static void main(String\[\] args) { - - People people = new People("Tom"); - System.out.println(people.getName()); - } - -} -``` - -People.java - -```java -package com.tobetterjavaer.test1; - -class People {//默认访问权限(包访问权限) - - private String name = null; - - public People(String name) { - this.name = name; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} -``` - -从代码可以看出,修饰 People 类采用的是默认访问权限,而由于 People 类和 Main 类在同一个包中,因此 People 类对于 Main 类是可见的。 - -例子 2: - -People.java - -```java -package com.tobetterjavaer.test2; - -class People {//默认访问权限(包访问权限) - - private String name = null; - - public People(String name) { - this.name = name; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} -``` - -此时 People 类和 Main 类不在同一个包中,会发生什么情况呢? - -下面是 Main 类中的提示的错误: - - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/bokeyuan-jianxijavazhongdifangwenquanxiankongzhi-154ae82f-72a5-45fc-ad3c-e1eb575d8572.png) - - -提示 Peolple 类在 Main 类中不可见。从这里就可以看出,如果用默认访问权限去修饰一个类,该类只对同一个包中的其他类可见,对于不同包中的类是不可见的。 - -正如上图的快速修正提示所示,将 People 类的默认访问权限更改为 public 的话,People 类对于 Main 类便可见了。 - -### 2. 修饰方法和变量 - -- 默认访问权限(包访问权限):如果一个类的方法或变量被包访问权限修饰,也就意味着只能在同一个包中的其他类中显示地调用该类的方法或者变量,在不同包中的类中不能显式地调用该类的方法或变量。 -- private:如果一个类的方法或者变量被 private 修饰,那么这个类的方法或者变量只能在该类本身中被访问,在类外以及其他类中都不能显式的进行访问。 -- protected:如果一个类的方法或者变量被 protected 修饰,对于同一个包的类,这个类的方法或变量是可以被访问的。对于不同包的类,只有继承于该类的类才可以访问到该类的方法或者变量。 -- public:被 public 修饰的方法或者变量,在任何地方都是可见的。 - - -例 3: - -Main.java 没有变化 - -People.java - -```java -package com.tobebetterjavaer.test1; - -public class People { - - private String name = null; - - public People(String name) { - this.name = name; - } - - String getName() { //默认访问权限(包访问权限) - return name; - } - - void setName(String name) { //默认访问权限(包访问权限) - this.name = name; - } -} -``` - -此时在 Main 类是可以显示调用方法 getName 和 setName 的。 - -但是如果 People 类和 Main 类不在同一个包中: - -```java -package com.tobebetterjavaer.test2; //与Main类处于不同包中 - -public class People { - - private String name = null; - - public People(String name) { - this.name = name; - } - - String getName() { //默认访问权限(包访问权限) - return name; - } - - void setName(String name) { //默认访问权限(包访问权限) - this.name = name; - } -} -``` - -此时在 Main 类中会提示错误: - - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/bokeyuan-jianxijavazhongdifangwenquanxiankongzhi-b3e9dc56-53e8-42f1-b8ee-35115edfe7e7.png) - - -由此可以看出,如果用默认访问权限来修饰类的方法或者变量,则只能在同一个包的其他类中进行访问。 - -例 4: - -People.java - -```java -package com.tobebetterjavaer.test1; - -public class People { - - private String name = null; - - public People(String name) { - this.name = name; - } - - protected String getName() { - return name; - } - - protected void setName(String name) { - this.name = name; - } -} -``` - -此时是可以在 Main 中显示调用方法 getName 和 setName 的。 - -如果 People 类和 Main 类处于不同包中: - -```java -package com.tobebetterjavaer.test2; - -public class People { - - private String name = null; - - public People(String name) { - this.name = name; - } - - protected String getName() { - return name; - } - - protected void setName(String name) { - this.name = name; - } -} -``` - -则会在 Main 中报错: - - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/bokeyuan-jianxijavazhongdifangwenquanxiankongzhi-b1d4b7ed-fc87-47d4-bdd9-3f6a8ea96100.png) - - -如果在 com.cxh.test1 中定一个类 Man 继承 People,则可以在类 Man 中显示调用方法 getName 和 setName: - -```java -package com.tobebetterjavaer.test1; - -import com.tobebetterjavaer.test2.People; - -public class Man extends People { - - public Man(String name){ - super(name); - } - - public String toString() { - return getName(); - } -} -``` - -补充一些关于 Java 包和类文件的知识: - -1)Java 中的包主要是为了防止类文件命名冲突以及方便进行代码组织和管理; - -2)对于一个 Java 源代码文件,如果存在 public 类的话,只能有一个 public 类,且此时源代码文件的名称必须和 public 类的名称完全相同。 - -另外,如果还存在其他类,这些类在包外是不可见的。如果源代码文件没有 public 类,则源代码文件的名称可以随意命名。 - -“三妹,理解了吧?”我问三妹。 - -“是的,很简单,换句话说,不想让别人看的就 private,想让人看的就 public,想同一个班级/部门看的就默认,想让下一级看的就 protected,对吧?哥”三妹很自信地回答。 - -“不错不错,总结得有那味了。” - ->原文链接:[https://www.cnblogs.com/dolphin0520/p/3734915.html](https://www.cnblogs.com/dolphin0520/p/3734915.html) 作者: Matrix海子,编辑:沉默王二 - - - -## 5.9 Java代码初始化块 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[5.9 Java代码初始化块](https://tobebetterjavaer.com/oo/code-init.html) - - - -## 5.10 Java抽象类 - -“二哥,你这明显加快了更新的频率呀!”三妹对于我最近的肝劲由衷的佩服了起来。 - -“哈哈,是呀,我要给广大的学弟学妹们一个完整的 Java 学习体系,记住我们的口号,**学 Java 就上二哥的 Java 进阶之路**。”我对未来充满了信心。 - -“那就开始吧。”三妹说。 - -### 01、定义抽象类 - -定义抽象类的时候需要用到关键字 `abstract`,放在 `class` 关键字前,就像下面这样。 - -```java -abstract class AbstractPlayer { -} -``` - -关于抽象类的命名,《[阿里的 Java 开发手册](https://tobebetterjavaer.com/pdf/ali-java-shouce.html)》上有强调,“抽象类命名要使用 Abstract 或 Base 开头”,这条规约还是值得遵守的,真正做到名如其意。 - -### 02、抽象类的特征 - -抽象类是不能实例化的,尝试通过 `new` 关键字实例化的话,编译器会报错,提示“类是抽象的,不能实例化”。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/abstract-01.png) - -虽然抽象类不能实例化,但可以有子类。子类通过 `extends` 关键字来继承抽象类。就像下面这样。 - -```java -public class BasketballPlayer extends AbstractPlayer { -} -``` - -如果一个类定义了一个或多个抽象方法,那么这个类必须是抽象类。 - -当我们尝试在一个普通类中定义抽象方法的时候,编译器会有两处错误提示。第一处在类级别上,提示“这个类必须通过 `abstract` 关键字定义”,见下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/abstract-02.png) - -第二处在尝试定义 abstract 的方法上,提示“抽象方法所在的类不是抽象的”,见下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/abstract-03.png) - -抽象类中既可以定义抽象方法,也可以定义普通方法,就像下面这样: - -```java -public abstract class AbstractPlayer { - abstract void play(); - - public void sleep() { - System.out.println("运动员也要休息而不是挑战极限"); - } -} -``` - -抽象类派生的子类必须实现父类中定义的抽象方法。比如说,抽象类 AbstractPlayer 中定义了 `play()` 方法,子类 BasketballPlayer 中就必须实现。 - -```java -public class BasketballPlayer extends AbstractPlayer { - @Override - void play() { - System.out.println("我是张伯伦,篮球场上得过 100 分"); - } -} -``` - -如果没有实现的话,编译器会提示“子类必须实现抽象方法”,见下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/abstract-04.png) - -### 03、抽象类的应用场景 - -“二哥,抽象方法我明白了,那什么时候使用抽象方法呢?能给我讲讲它的应用场景吗?”三妹及时的插话道。 - -“这问题问的恰到好处呀!”我扶了扶眼镜继续说。 - -#### **01)第一种场景** - -当我们希望一些通用的功能被多个子类复用的时候,就可以使用抽象类。比如说,AbstractPlayer 抽象类中有一个普通的方法 `sleep()`,表明所有运动员都需要休息,那么这个方法就可以被子类复用。 - -```java -abstract class AbstractPlayer { - public void sleep() { - System.out.println("运动员也要休息而不是挑战极限"); - } -} -``` - -子类 BasketballPlayer 继承了 AbstractPlayer 类: - -```java -class BasketballPlayer extends AbstractPlayer { -} -``` - -也就拥有了 `sleep()` 方法。BasketballPlayer 的对象可以直接调用父类的 `sleep()` 方法: - -```java -BasketballPlayer basketballPlayer = new BasketballPlayer(); -basketballPlayer.sleep(); -``` - -子类 FootballPlayer 继承了 AbstractPlayer 类: - -```java -class FootballPlayer extends AbstractPlayer { -} -``` - -也拥有了 `sleep()` 方法,FootballPlayer 的对象也可以直接调用父类的 `sleep()` 方法: - -```java -FootballPlayer footballPlayer = new FootballPlayer(); -footballPlayer.sleep(); -``` - -这样是不是就实现了代码的复用呢? - -#### **02)第二种场景** - -当我们需要在抽象类中定义好 API,然后在子类中扩展实现的时候就可以使用抽象类。比如说,AbstractPlayer 抽象类中定义了一个抽象方法 `play()`,表明所有运动员都可以从事某项运动,但需要对应子类去扩展实现,表明篮球运动员打篮球,足球运动员踢足球。 - -```java -abstract class AbstractPlayer { - abstract void play(); -} -``` - -BasketballPlayer 继承了 AbstractPlayer 类,扩展实现了自己的 `play()` 方法。 - -```java -public class BasketballPlayer extends AbstractPlayer { - @Override - void play() { - System.out.println("我是张伯伦,我篮球场上得过 100 分,"); - } -} -``` - -FootballPlayer 继承了 AbstractPlayer 类,扩展实现了自己的 `play()` 方法。 - -```java -public class FootballPlayer extends AbstractPlayer { - @Override - void play() { - System.out.println("我是C罗,我能接住任意高度的头球"); - } -} -``` - -为了进一步展示抽象类的特性,我们再来看一个具体的示例。 - ->PS:[网站](https://tobebetterjavaer.com/oo/abstract.html)评论区说涉及到了文件的读写以及 Java 8 的新特性,不适合新人,如果觉得自己实在是看不懂,跳过,等学了 IO 流再来看也行。如果说是为了复习 Java 基础知识,就不存在这个问题了。 - -假设现在有一个文件,里面的内容非常简单,只有一个“Hello World”,现在需要有一个读取器将内容从文件中读取出来,最好能按照大写的方式,或者小写的方式来读。 - -这时候,最好定义一个抽象类 BaseFileReader: - -```java -/** - * 抽象类,定义了一个读取文件的基础框架,其中 mapFileLine 是一个抽象方法,具体实现需要由子类来完成 - */ -abstract class BaseFileReader { - protected Path filePath; // 定义一个 protected 的 Path 对象,表示读取的文件路径 - - /** - * 构造方法,传入读取的文件路径 - * @param filePath 读取的文件路径 - */ - protected BaseFileReader(Path filePath) { - this.filePath = filePath; - } - - /** - * 读取文件的方法,返回一个字符串列表 - * @return 字符串列表,表示文件的内容 - * @throws IOException 如果文件读取出错,抛出该异常 - */ - public List readFile() throws IOException { - return Files.lines(filePath) // 使用 Files 类的 lines 方法,读取文件的每一行 - .map(this::mapFileLine) // 对每一行应用 mapFileLine 方法,将其转化为指定的格式 - .collect(Collectors.toList()); // 将处理后的每一行收集到一个字符串列表中,返回 - } - - /** - * 抽象方法,子类需要实现该方法,将文件中的每一行转化为指定的格式 - * @param line 文件中的每一行 - * @return 转化后的字符串 - */ - protected abstract String mapFileLine(String line); -} -``` - -- filePath 为文件路径,使用 protected 修饰,表明该成员变量可以在需要时被子类访问到。 - -- `readFile()` 方法用来读取文件,方法体里面调用了抽象方法 `mapFileLine()`——需要子类来扩展实现大小写的不同读取方式。 - -在我看来,BaseFileReader 类设计的就非常合理,并且易于扩展,子类只需要专注于具体的大小写实现方式就可以了。 - -小写的方式: - -```java -class LowercaseFileReader extends BaseFileReader { - protected LowercaseFileReader(Path filePath) { - super(filePath); - } - - @Override - protected String mapFileLine(String line) { - return line.toLowerCase(); - } -} -``` - -大写的方式: - -```java -class UppercaseFileReader extends BaseFileReader { - protected UppercaseFileReader(Path filePath) { - super(filePath); - } - - @Override - protected String mapFileLine(String line) { - return line.toUpperCase(); - } -} -``` - -从文件里面一行一行读取内容的代码被子类复用了。与此同时,子类只需要专注于自己该做的工作,LowercaseFileReader 以小写的方式读取文件内容,UppercaseFileReader 以大写的方式读取文件内容。 - -来看一下测试类 FileReaderTest: - -```java -public class FileReaderTest { - public static void main(String[] args) throws URISyntaxException, IOException { - URL location = FileReaderTest.class.getClassLoader().getResource("helloworld.txt"); - Path path = Paths.get(location.toURI()); - BaseFileReader lowercaseFileReader = new LowercaseFileReader(path); - BaseFileReader uppercaseFileReader = new UppercaseFileReader(path); - System.out.println(lowercaseFileReader.readFile()); - System.out.println(uppercaseFileReader.readFile()); - } -} -``` - -在项目的 resource 目录下建一个文本文件,名字叫 helloworld.txt,里面的内容就是“Hello World”。文件的具体位置如下图所示,我用的集成开发环境是 Intellij IDEA。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/abstract-05.png) - - -在 resource 目录下的文件可以通过 `ClassLoader.getResource()` 的方式获取到 URI 路径,然后就可以取到文本内容了。 - -输出结果如下所示: - -``` -[hello world] -[HELLO WORLD] -``` - -### 04、抽象类总结 - -好了,对于抽象类我们简单总结一下: - -- 1、抽象类不能被实例化。 -- 2、抽象类应该至少有一个抽象方法,否则它没有任何意义。 -- 3、抽象类中的抽象方法没有方法体。 -- 4、抽象类的子类必须给出父类中的抽象方法的具体实现,除非该子类也是抽象类。 - -“完了吗?二哥”三妹似乎还沉浸在聆听教诲的快乐中。 - -“是滴,这次我们系统化的学习了抽象类,可以说面面俱到了。三妹你可以把代码敲一遍,加强了一些印象,电脑交给你了。”说完,我就跑到阳台去抽烟了。 - -“呼。。。。。”一个大大的眼圈飘散开来,又是愉快的一天~ - - -## 5.11 Java接口 - -“今天开始讲 Java 的接口。”我对三妹说,“对于面向对象编程来说,抽象是一个极具魅力的特征。如果一个程序员的抽象思维很差,那他在编程中就会遇到很多困难,无法把业务变成具体的代码。在 Java 中,可以通过两种形式来达到抽象的目的,一种上一篇的主角——[抽象类](https://tobebetterjavaer.com/oo/abstract.html),另外一种就是今天的主角——[接口](https://tobebetterjavaer.com/oo/interface.html)。” - -“二哥,开讲之前,先恭喜你呀。我看你朋友圈说《[Java进阶之路](https://github.com/itwanger/toBeBetterJavaer)》开源知识库在 GitHub 上收到了第一笔赞赏呀,虽然只有一块钱,但我也替你感到开心。”三妹的脸上洋溢着自信的微笑,仿佛这钱是打给她的一样。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/interface-01.png) - ->PS:2021-04-29到2023-02-11期间,《二哥的 Java 进阶之路》收到了 58 笔赞赏,真的非常感谢大家的认可和支持😍,我会继续肝下去的。 - -“是啊,早上起来的时候看到这条信息,还真的是挺开心的,虽然只有一块钱,但是开源的第一笔,也是我人生当中的第一笔,真的非常感谢这个读者,值得纪念的一天。”我自己也掩饰不住内心的激动。 - -“有了这份鼓励,我相信你更新下去的动力更足了!”三妹今天说的话真的是特别令人喜欢。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/interface-02.png) - -“是呀是呀,让我们开始吧!” - -### 01、定义接口 - -“接口是什么呀?”三妹顺着我的话题及时的插话到。 - -接口通过 interface 关键字来定义,它可以包含一些常量和方法,来看下面这个示例。 - -```java -public interface Electronic { - // 常量 - String LED = "LED"; - - // 抽象方法 - int getElectricityUse(); - - // 静态方法 - static boolean isEnergyEfficient(String electtronicType) { - return electtronicType.equals(LED); - } - - // 默认方法 - default void printDescription() { - System.out.println("电子"); - } -} -``` - -来看一下这段代码反编译后的字节码。 - -```java -public interface Electronic -{ - - public abstract int getElectricityUse(); - - public static boolean isEnergyEfficient(String electtronicType) - { - return electtronicType.equals("LED"); - } - - public void printDescription() - { - System.out.println("\u7535\u5B50"); - } - - public static final String LED = "LED"; -} -``` - -发现没?接口中定义的所有变量或者方法,都会自动添加上 `public` 关键字。 - -接下来,我来一一解释下 Electronic 接口中的核心知识点。 - -**1)接口中定义的变量会在编译的时候自动加上 `public static final` 修饰符**(注意看一下反编译后的字节码),也就是说上例中的 LED 变量其实就是一个常量。 - -Java 官方文档上有这样的声明: - ->Every field declaration in the body of an interface is implicitly public, static, and final. - -换句话说,接口可以用来作为常量类使用,还能省略掉 `public static final`,看似不错的一种选择,对吧? - -不过,这种选择并不可取。因为接口的本意是对方法进行抽象,而常量接口会对子类中的变量造成命名空间上的“污染”。 - -**2)没有使用 `private`、`default` 或者 `static` 关键字修饰的方法是隐式抽象的**,在编译的时候会自动加上 `public abstract` 修饰符。也就是说上例中的 `getElectricityUse()` 其实是一个抽象方法,没有方法体——这是定义接口的本意。 - -**3)从 Java 8 开始,接口中允许有静态方法**,比如说上例中的 `isEnergyEfficient()` 方法。 - -静态方法无法由(实现了该接口的)类的对象调用,它只能通过接口名来调用,比如说 `Electronic.isEnergyEfficient("LED")`。 - -接口中定义静态方法的目的是为了提供一种简单的机制,使我们不必创建对象就能调用方法,从而提高接口的竞争力。 - -**4)接口中允许定义 `default` 方法**也是从 Java 8 开始的,比如说上例中的 `printDescription()` 方法,它始终由一个代码块组成,为实现该接口而不覆盖该方法的类提供默认实现。既然要提供默认实现,就要有方法体,换句话说,默认方法后面不能直接使用“;”号来结束——编译器会报错。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/interface-03.png) - -“为什么要在接口中定义默认方法呢?”三妹好奇地问到。 - -允许在接口中定义默认方法的理由很充分,因为一个接口可能有多个实现类,这些类就必须实现接口中定义的抽象类,否则编译器就会报错。假如我们需要在所有的实现类中追加某个具体的方法,在没有 `default` 方法的帮助下,我们就必须挨个对实现类进行修改。 - -由之前的例子我们就可以得出下面这些结论: - -- 接口中允许定义变量 -- 接口中允许定义抽象方法 -- 接口中允许定义静态方法(Java 8 之后) -- 接口中允许定义默认方法(Java 8 之后) - -除此之外,我们还应该知道: - -**1)接口不允许直接实例化**,否则编译器会报错。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/interface-04.png) - -需要定义一个类去实现接口,见下例。 - -```java -public class Computer implements Electronic { - - public static void main(String[] args) { - new Computer(); - } - - @Override - public int getElectricityUse() { - return 0; - } -} -``` - -然后再实例化。 - -``` -Electronic e = new Computer(); -``` - -**2)接口可以是空的**,既可以不定义变量,也可以不定义方法。最典型的例子就是 Serializable 接口,在 `java.io` 包下。 - -```java -public interface Serializable { -} -``` - -Serializable 接口用来为序列化的具体实现提供一个标记,也就是说,只要某个类实现了 Serializable 接口,那么它就可以用来序列化了。 - -**3)不要在定义接口的时候使用 final 关键字**,否则会报编译错误,因为接口就是为了让子类实现的,而 final 阻止了这种行为。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/interface-05.png) - -**4)接口的抽象方法不能是 private、protected 或者 final**,否则编译器都会报错。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/interface-06.png) - -**5)接口的变量是隐式 `public static final`(常量)**,所以其值无法改变。 - -### 02、接口的作用 - -“接口可以做什么呢?”三妹见缝插针,问的很及时。 - -**第一,使某些实现类具有我们想要的功能**,比如说,实现了 Cloneable 接口的类具有拷贝的功能,实现了 Comparable 或者 Comparator 的类具有比较功能。 - -Cloneable 和 Serializable 一样,都属于标记型接口,它们内部都是空的。实现了 Cloneable 接口的类可以使用 `Object.clone()` 方法,否则会抛出 CloneNotSupportedException。 - -```java -public class CloneableTest implements Cloneable { - @Override - protected Object clone() throws CloneNotSupportedException { - return super.clone(); - } - - public static void main(String[] args) throws CloneNotSupportedException { - CloneableTest c1 = new CloneableTest(); - CloneableTest c2 = (CloneableTest) c1.clone(); - } -} -``` - -运行后没有报错。现在把 `implements Cloneable` 去掉。 - -```java -public class CloneableTest { - @Override - protected Object clone() throws CloneNotSupportedException { - return super.clone(); - } - - public static void main(String[] args) throws CloneNotSupportedException { - CloneableTest c1 = new CloneableTest(); - CloneableTest c2 = (CloneableTest) c1.clone(); - - } -} -``` - -运行后抛出 CloneNotSupportedException: - -``` -Exception in thread "main" java.lang.CloneNotSupportedException: com.cmower.baeldung.interface1.CloneableTest - at java.base/java.lang.Object.clone(Native Method) - at com.cmower.baeldung.interface1.CloneableTest.clone(CloneableTest.java:6) - at com.cmower.baeldung.interface1.CloneableTest.main(CloneableTest.java:11) -``` - - -**第二,Java 原则上只支持单一继承,但通过接口可以实现多重继承的目的**。 - -如果有两个类共同继承(extends)一个父类,那么父类的方法就会被两个子类重写。然后,如果有一个新类同时继承了这两个子类,那么在调用重写方法的时候,编译器就不能识别要调用哪个类的方法了。这也正是著名的菱形问题,见下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/object-class/interface-07.png) - - -简单解释下,ClassC 同时继承了 ClassA 和 ClassB,ClassC 的对象在调用 ClassA 和 ClassB 中重写的方法时,就不知道该调用 ClassA 的方法,还是 ClassB 的方法。 - -接口没有这方面的困扰。来定义两个接口,Fly 接口会飞,Run 接口会跑。 - -```java -public interface Fly { - void fly(); -} -public interface Run { - void run(); -} -``` - -然后让 Pig 类同时实现这两个接口。 - -```java -public class Pig implements Fly,Run{ - @Override - public void fly() { - System.out.println("会飞的猪"); - } - - @Override - public void run() { - System.out.println("会跑的猪"); - } -} -``` - -在某种形式上,接口实现了多重继承的目的:现实世界里,猪的确只会跑,但在雷军的眼里,站在风口的猪就会飞,这就需要赋予这只猪更多的能力,通过抽象类是无法实现的,只能通过接口。 - -**第三,实现多态**。 - -什么是多态呢?通俗的理解,就是同一个事件发生在不同的对象上会产生不同的结果,鼠标左键点击窗口上的 X 号可以关闭窗口,点击超链接却可以打开新的网页。 - -多态可以通过继承(`extends`)的关系实现,也可以通过接口的形式实现。 - -Shape 接口表示一个形状。 - -```java -public interface Shape { - String name(); -} -``` - -Circle 类实现了 Shape 接口,并重写了 `name()` 方法。 - -```java -public class Circle implements Shape { - @Override - public String name() { - return "圆"; - } -} -``` - -Square 类也实现了 Shape 接口,并重写了 `name()` 方法。 - -```java -public class Square implements Shape { - @Override - public String name() { - return "正方形"; - } -} -``` - -然后来看测试类。 - -```java -List shapes = new ArrayList<>(); -Shape circleShape = new Circle(); -Shape squareShape = new Square(); - -shapes.add(circleShape); -shapes.add(squareShape); - -for (Shape shape : shapes) { - System.out.println(shape.name()); -} -``` - -这就实现了多态,变量 circleShape、squareShape 的引用类型都是 Shape,但执行 `shape.name()` 方法的时候,Java 虚拟机知道该去调用 Circle 的 `name()` 方法还是 Square 的 `name()` 方法。 - -说一下多态存在的 3 个前提: - -- 1、要有继承关系,比如说 Circle 和 Square 都实现了 Shape 接口。 -- 2、子类要重写父类的方法,Circle 和 Square 都重写了 `name()` 方法。 -- 3、父类引用指向子类对象,circleShape 和 squareShape 的类型都为 Shape,但前者指向的是 Circle 对象,后者指向的是 Square 对象。 - -然后,我们来看一下测试结果: - -``` -圆 -正方形 -``` - -也就意味着,尽管在 for 循环中,shape 的类型都为 Shape,但在调用 `name()` 方法的时候,它知道 Circle 对象应该调用 Circle 类的 `name()` 方法,Square 对象应该调用 Square 类的 `name()` 方法。 - -### 03、接口的三种模式 - -**在编程领域,好的设计模式能够让我们的代码事半功倍**。在使用接口的时候,经常会用到三种模式,分别是策略模式、适配器模式和工厂模式。 - -#### 1)策略模式 - -策略模式的思想是,针对一组算法,将每一种算法封装到具有共同接口的实现类中,接口的设计者可以在不影响调用者的情况下对算法做出改变。示例如下: - -```java -// 接口:教练 -interface Coach { - // 方法:防守 - void defend(); -} - -// 何塞·穆里尼奥 -class Hesai implements Coach { - - @Override - public void defend() { - System.out.println("防守赢得冠军"); - } -} - -// 德普·瓜迪奥拉 -class Guatu implements Coach { - - @Override - public void defend() { - System.out.println("进攻就是最好的防守"); - } -} - -public class Demo { - // 参数为接口 - public static void defend(Coach coach) { - coach.defend(); - } - - public static void main(String[] args) { - // 为同一个方法传递不同的对象 - defend(new Hesai()); - defend(new Guatu()); - } -} -``` - -`Demo.defend()` 方法可以接受不同风格的 Coach,并根据所传递的参数对象的不同而产生不同的行为,这被称为“策略模式”。 - -#### 2)适配器模式 - -适配器模式的思想是,针对调用者的需求对原有的接口进行转接。生活当中最常见的适配器就是HDMI(英语:`High Definition Multimedia Interface`,中文:高清多媒体接口)线,可以同时发送音频和视频信号。适配器模式的示例如下: - -```java -interface Coach { - void defend(); - void attack(); -} - -// 抽象类实现接口,并置空方法 -abstract class AdapterCoach implements Coach { - public void defend() {}; - public void attack() {}; -} - -// 新类继承适配器 -class Hesai extends AdapterCoach { - public void defend() { - System.out.println("防守赢得冠军"); - } -} - -public class Demo { - public static void main(String[] args) { - Coach coach = new Hesai(); - coach.defend(); - } -} -``` -Coach 接口中定义了两个方法(`defend()` 和 `attack()`),如果类直接实现该接口的话,就需要对两个方法进行实现。 - -如果我们只需要对其中一个方法进行实现的话,就可以使用一个抽象类作为中间件,即适配器(AdapterCoach),用这个抽象类实现接口,并对抽象类中的方法置空(方法体只有一对花括号),这时候,新类就可以绕过接口,继承抽象类,我们就可以只对需要的方法进行覆盖,而不是接口中的所有方法。 - -#### 3)工厂模式 - -所谓的工厂模式理解起来也不难,就是什么工厂生产什么,比如说宝马工厂生产宝马,奔驰工厂生产奔驰,A 级学院毕业 A 级教练,C 级学院毕业 C 级教练。示例如下: - -```java -// 教练 -interface Coach { - void command(); -} - -// 教练学院 -interface CoachFactory { - Coach createCoach(); -} - -// A级教练 -class ACoach implements Coach { - - @Override - public void command() { - System.out.println("我是A级证书教练"); - } - -} - -// A级教练学院 -class ACoachFactory implements CoachFactory { - - @Override - public Coach createCoach() { - return new ACoach(); - } - -} - -// C级教练 -class CCoach implements Coach { - - @Override - public void command() { - System.out.println("我是C级证书教练"); - } - -} - -// C级教练学院 -class CCoachFactory implements CoachFactory { - - @Override - public Coach createCoach() { - return new CCoach(); - } - -} - -public class Demo { - public static void create(CoachFactory factory) { - factory.createCoach().command(); - } - - public static void main(String[] args) { - // 对于一支球队来说,需要什么样的教练就去找什么样的学院 - // 学院会介绍球队对应水平的教练。 - create(new ACoachFactory()); - create(new CCoachFactory()); - } -} -``` - -有两个接口,一个是 Coach(教练),可以 `command()`(指挥球队);另外一个是 CoachFactory(教练学院),能 `createCoach()`(教出一名优秀的教练)。然后 ACoach 类实现 Coach 接口,ACoachFactory 类实现 CoachFactory 接口;CCoach 类实现 Coach 接口,CCoachFactory 类实现 CoachFactory 接口。当需要 A 级教练时,就去找 A 级教练学院;当需要 C 级教练时,就去找 C 级教练学院。 - -依次类推,我们还可以用 BCoach 类实现 Coach 接口,BCoachFactory 类实现 CoachFactory 接口,从而不断地丰富教练的梯队。 - -“怎么样三妹,一下子接收这么多知识点不容易吧?” - -“其实还好啊,二哥你讲的这么细致,我都做好笔记📒了,学习嘛,认真一点,效果就会好很多了。” - -三妹这种积极乐观的态度真的让我感觉到“付出就会有收获”,💪🏻。 - -### 04、抽象类和接口的区别 - -简单总结一下抽象类和接口的区别。 - -在 Java 中,通过关键字 `abstract` 定义的类叫做抽象类。Java 是一门面向对象的语言,因此所有的对象都是通过类来描述的;但反过来,并不是所有的类都是用来描述对象的,抽象类就是其中的一种。 - -以下示例展示了一个简单的抽象类: - -```java -// 个人认为,一名教练必须攻守兼备 -abstract class Coach { - public abstract void defend(); - - public abstract void attack(); -} -``` - -我们知道,有抽象方法的类被称为抽象类,也就意味着抽象类中还能有不是抽象方法的方法。这样的类就不能算作纯粹的接口,尽管它也可以提供接口的功能——只能说抽象类是普通类与接口之间的一种中庸之道。 - -**接口(英文:Interface),在 Java 中是一个抽象类型,是抽象方法的集合**;接口通过关键字 `interface` 来定义。接口与抽象类的不同之处在于: - -- 1、抽象类可以有方法体的方法,但接口没有(Java 8 以前)。 -- 2、接口中的成员变量隐式为 `static final`,但抽象类不是的。 -- 3、一个类可以实现多个接口,但只能继承一个抽象类。 - -以下示例展示了一个简单的接口: - -```java -// 隐式的abstract -interface Coach { - // 隐式的public - void defend(); - void attack(); -} -``` - -- 接口是隐式抽象的,所以声明时没有必要使用 `abstract` 关键字; -- 接口的每个方法都是隐式抽象的,所以同样不需要使用 `abstract` 关键字; -- 接口中的方法都是隐式 `public` 的。 - -“哦,我理解了哥。那我再问一下,抽象类和接口有什么差别呢?” - -“哇,三妹呀,你这个问题恰到好处,问到了点子上。”我不由得为三妹竖起了大拇指。 - -#### 1)语法层面上 - -- 抽象类可以提供成员方法的实现细节,而接口中只能存在 public abstract 方法; -- 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是 public static final 类型的; -- 接口中不能含有静态代码块,而抽象类可以有静态代码块; -- 一个类只能继承一个抽象类,而一个类却可以实现多个接口。 - -#### 2)设计层面上 - -抽象类是对一种事物的抽象,即对类抽象,继承抽象类的子类和抽象类本身是一种 `is-a` 的关系。而接口是对行为的抽象。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口却是对类局部(行为)进行抽象。 - -举个简单的例子,飞机和鸟是不同类的事物,但是它们都有一个共性,就是都会飞。那么在设计的时候,可以将飞机设计为一个类 Airplane,将鸟设计为一个类 Bird,但是不能将 飞行 这个特性也设计为类,因此它只是一个行为特性,并不是对一类事物的抽象描述。 - -此时可以将 飞行 设计为一个接口 Fly,包含方法 fly(),然后 Airplane 和 Bird 分别根据自己的需要实现 Fly 这个接口。然后至于有不同种类的飞机,比如战斗机、民用飞机等直接继承 Airplane 即可,对于鸟也是类似的,不同种类的鸟直接继承 Bird 类即可。从这里可以看出,继承是一个 "是不是"的关系,而 接口 实现则是 "有没有"的关系。如果一个类继承了某个抽象类,则子类必定是抽象类的种类,而接口实现则是有没有、具备不具备的关系,比如鸟是否能飞(或者是否具备飞行这个特点),能飞行则可以实现这个接口,不能飞行就不实现这个接口。 - -接口是对类的某种行为的一种抽象,接口和类之间并没有很强的关联关系,举个例子来说,所有的类都可以实现 [`Serializable` 接口](https://tobebetterjavaer.com/io/Serializbale.html),从而具有序列化的功能,但不能说所有的类和 Serializable 之间是 `is-a` 的关系。 - -抽象类作为很多子类的父类,它是一种模板式设计。而接口是一种行为规范,它是一种辐射式设计。什么是模板式设计?最简单例子,大家都用过 ppt 里面的模板,如果用模板 A 设计了 ppt B 和 ppt C,ppt B 和 ppt C 公共的部分就是模板 A 了,如果它们的公共部分需要改动,则只需要改动模板 A 就可以了,不需要重新对 ppt B 和 ppt C 进行改动。而辐射式设计,比如某个电梯都装了某种报警器,一旦要更新报警器,就必须全部更新。也就是说对于抽象类,如果需要添加新的方法,可以直接在抽象类中添加具体的实现,子类可以不进行变更;而对于接口则不行,如果接口进行了变更,则所有实现这个接口的类都必须进行相应的改动。 - -## 5.12 Java内部类 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[5.12 Java内部类](https://tobebetterjavaer.com/oo/inner-class.html) - - -## 5.13 Java封装继承多态 - -在谈 Java 面向对象的时候,不得不提到面向对象的三大特征:[封装](https://tobebetterjavaer.com/oo/encapsulation.html)、[继承](https://tobebetterjavaer.com/oo/extends-bigsai.html)、[多态](https://tobebetterjavaer.com/oo/polymorphism.html)。三大特征紧密联系而又有区别,合理使用继承能大大减少重复代码,**提高代码复用性。** - -### 1)封装 - -“三妹,准备好了没,我们来讲 Java 封装,算是 Java 的三大特征之一,理清楚了,对以后的编程有较大的帮助。”我对三妹说。 - -“好的,哥,准备好了。”三妹一边听我说,一边迅速地打开了 XMind,看来一边学习一边总结思维导图这个高效的学习方式三妹已经牢记在心了。 - -封装从字面上来理解就是包装的意思,专业点就是信息隐藏,**是指利用抽象将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体**。 - -数据被保护在类的内部,尽可能地隐藏内部的实现细节,只保留一些对外接口使之与外部发生联系。 - -其他对象只能通过已经授权的操作来与这个封装的对象进行交互。也就是说用户是无需知道对象内部的细节(当然也无从知道),但可以通过该对象对外的提供的接口来访问该对象。 - -使用封装有 4 大好处: - -- 1、良好的封装能够减少耦合。 -- 2、类内部的结构可以自由修改。 -- 3、可以对成员进行更精确的控制。 -- 4、隐藏信息,实现细节。 - -首先我们先来看两个类。 - -Husband.java - -```java -public class Husband { - - /* - * 对属性的封装 - * 一个人的姓名、性别、年龄、妻子都是这个人的私有属性 - */ - private String name ; - private String sex ; - private int age ; - private Wife wife; - - /* - * setter()、getter()是该对象对外开发的接口 - */ - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getSex() { - return sex; - } - - public void setSex(String sex) { - this.sex = sex; - } - - public int getAge() { - return age; - } - - public void setAge(int age) { - this.age = age; - } - - public void setWife(Wife wife) { - this.wife = wife; - } -} -``` - -Wife.java - -```java -public class Wife { - private String name; - private int age; - private String sex; - private Husband husband; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getSex() { - return sex; - } - - public void setSex(String sex) { - this.sex = sex; - } - - public void setAge(int age) { - this.age = age; - } - - public void setHusband(Husband husband) { - this.husband = husband; - } - - public Husband getHusband() { - return husband; - } - -} -``` - -可以看得出, Husband 类里面的 wife 属性是没有 `getter()`的,同时 Wife 类的 age 属性也是没有 `getter()`方法的。至于理由我想三妹你是懂的。 - -没有哪个女人愿意别人知道她的年龄。 - -所以封装把一个对象的属性私有化,同时提供一些可以被外界访问的属性的方法,如果不想被外界方法,我们大可不必提供方法给外界访问。 - -但是如果一个类没有提供给外界任何可以访问的方法,那么这个类也没有什么意义了。 - -比如我们将一个房子看做是一个对象,里面有漂亮的装饰,如沙发、电视剧、空调、茶桌等等都是该房子的私有属性,但是如果我们没有那些墙遮挡,是不是别人就会一览无余呢?没有一点儿隐私! - -因为存在那个遮挡的墙,我们既能够有自己的隐私而且我们可以随意的更改里面的摆设而不会影响到外面的人。 - -但是如果没有门窗,一个包裹的严严实实的黑盒子,又有什么存在的意义呢?所以通过门窗别人也能够看到里面的风景。所以说门窗就是房子对象留给外界访问的接口。 - -通过这个我们还不能真正体会封装的好处。现在我们从程序的角度来分析封装带来的好处。如果我们不使用封装,那么该对象就没有 `setter()`和 `getter()`,那么 Husband 类应该这样写: - -```java -public class Husband { - public String name ; - public String sex ; - public int age ; - public Wife wife; -} -``` - -我们应该这样来使用它: - -```java -Husband husband = new Husband(); -husband.age = 30; -husband.name = "张三"; -husband.sex = "男"; //貌似有点儿多余 -``` - -但是哪天如果我们需要修改 Husband,例如将 age 修改为 String 类型的呢?你只有一处使用了这个类还好,如果你有几十个甚至上百个这样地方,你是不是要改到崩溃。如果使用了封装,我们完全可以不需要做任何修改,只需要稍微改变下 Husband 类的 `setAge()`方法即可。 - -```java -public class Husband { - - /* - * 对属性的封装 - * 一个人的姓名、性别、年龄、妻子都是这个人的私有属性 - */ - private String name ; - private String sex ; - private String age ; /* 改成 String类型的*/ - private Wife wife; - - public String getAge() { - return age; - } - - public void setAge(int age) { - //转换即可 - this.age = String.valueOf(age); - } - - /** 省略其他属性的setter、getter **/ - -} -``` - -其他的地方依然这样引用( `husband.setAge(22)` )保持不变。 - -到了这里我们确实可以看出,**封装确实可以使我们更容易地修改类的内部实现,而无需修改使用了该类的代码**。 - -我们再看这个好处:**封装可以对成员变量进行更精确的控制**。 - -还是那个 Husband,一般来说我们在引用这个对象的时候是不容易出错的,但是有时你迷糊了,写成了这样: - -```java -Husband husband = new Husband(); -husband.age = 300; -``` - -也许你是因为粗心写成了这样,你发现了还好,如果没有发现那就麻烦大了,谁见过 300 岁的老妖怪啊! - -但是使用封装我们就可以避免这个问题,我们对 age 的访问入口做一些控制(setter)如: - -```java -public class Husband { - - /* - * 对属性的封装 - * 一个人的姓名、性别、年龄、妻子都是这个人的私有属性 - */ - private String name ; - private String sex ; - private int age ; /* 改成 String类型的*/ - private Wife wife; - - public int getAge() { - return age; - } - - public void setAge(int age) { - if(age > 120){ - System.out.println("ERROR:error age input...."); //提示錯誤信息 - }else{ - this.age = age; - } - - } - - /** 省略其他属性的setter、getter **/ - -} -``` - -上面都是对 setter 方法的控制,其实通过封装我们也能够对对象的出口做出很好的控制。例如性别在数据库中一般都是以 1、0 的方式来存储的,但是在前台我们又不能展示 1、0,这里我们只需要在 `getter()`方法里面做一些转换即可。 - -```java -public String getSexName() { - if("0".equals(sex)){ - sexName = "女"; - } - else if("1".equals(sex)){ - sexName = "男"; - } - return sexName; -} -``` - -在使用的时候我们只需要使用 sexName 即可实现正确的性别显示。同理也可以用于针对不同的状态做出不同的操作。 - -```java -public String getCzHTML(){ - if("1".equals(zt)){ - czHTML = "启用"; - } - else{ - czHTML = "禁用"; - } - return czHTML; -} -``` - -“好了,关于封装我们就暂时就聊这么多吧。”我喝了一口普洱茶后,对三妹说。 - -“好的,哥,我懂了。” - -> 参考链接:[https://www.cnblogs.com/chenssy/p/3351835.html](https://www.cnblogs.com/chenssy/p/3351835.html),整理:沉默王二 - -### 2)继承 - -#### 01、什么是继承 - -**继承**(英语:inheritance)是面向对象软件技术中的一个概念。它使得**复用以前的代码非常容易。** - -Java 语言是非常典型的面向对象的语言,在 Java 语言中**继承就是子类继承父类的属性和方法,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的方法**。 - -我们来举个例子:动物有很多种,是一个比较大的概念。在动物的种类中,我们熟悉的有猫(Cat)、狗(Dog)等动物,它们都有动物的一般特征(比如能够吃东西,能够发出声音),不过又在细节上有区别(不同动物的吃的不同,叫声不一样)。 - -在 Java 语言中实现 Cat 和 Dog 等类的时候,就需要继承 Animal 这个类。继承之后 Cat、Dog 等具体动物类就是子类,Animal 类就是父类。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-bf43b473-4a05-4727-a543-c4edd44e5437.png) - -#### 02、为什么需要继承 - -三妹,你可能会问**为什么需要继承**? - -如果仅仅只有两三个类,每个类的属性和方法很有限的情况下确实没必要实现继承,但事情并非如此,事实上一个系统中往往有很多个类并且有着很多相似之处,比如猫和狗同属动物,或者学生和老师同属人。各个类可能又有很多个相同的属性和方法,这样的话如果每个类都重新写不仅代码显得很乱,代码工作量也很大。 - -这时继承的优势就出来了:可以直接使用父类的属性和方法,自己也可以有自己新的属性和方法满足拓展,父类的方法如果自己有需求更改也可以重写。这样**使用继承不仅大大的减少了代码量,也使得代码结构更加清晰可见**。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-eeee7ea3-30d5-4bb1-9c9d-5e3bf427e805.png) - -所以这样从代码的层面上来看我们设计这个完整的 Animal 类是这样的: - -```java -class Animal -{ - public int id; - public String name; - public int age; - public int weight; - - public Animal(int id, String name, int age, int weight) { - this.id = id; - this.name = name; - this.age = age; - this.weight = weight; - } - //这里省略get set方法 - public void sayHello() - { - System.out.println("hello"); - } - public void eat() - { - System.out.println("I'm eating"); - } - public void sing() - { - System.out.println("sing"); - } -} -``` - -而 Dog,Cat,Chicken 类可以这样设计: - -```java -class Dog extends Animal//继承animal -{ - public Dog(int id, String name, int age, int weight) { - super(id, name, age, weight);//调用父类构造方法 - } -} -class Cat extends Animal{ - - public Cat(int id, String name, int age, int weight) { - super(id, name, age, weight);//调用父类构造方法 - } -} -class Chicken extends Animal{ - - public Chicken(int id, String name, int age, int weight) { - super(id, name, age, weight);//调用父类构造方法 - } - //鸡下蛋 - public void layEggs() - { - System.out.println("我是老母鸡下蛋啦,咯哒咯!咯哒咯!"); - } -} -``` - -各自的类继承 Animal 后可以直接使用 Animal 类的属性和方法而不需要重复编写,各个类如果有自己的方法也可很容易地拓展。 - -#### 03、继承的分类 - -继承分为单继承和多继承,Java 语言只支持类的单继承,但可以通过实现接口的方式达到多继承的目的。**这个我们之前在讲接口的时候就提到过,这里我们再聊一下。** - -继承 | 定义 | 优缺点 | ----| ---- | ------ | -单继承![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-62bbc6a2-4e0e-4150-9f83-fceb65c56667.png)|一个子类只拥有一个父类|优点:在类层次结构上比较清晰
缺点:结构的丰富度有时不能满足使用需求| -多继承(Java 不支持,但可以用其它方式满足多继承使用需求)![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-e2ebc65a-5385-44a0-8ef3-a1b17e0252f1.png)|一个子类拥有多个直接的父类|优点:子类的丰富度很高
缺点:容易造成混乱| - -##### **单继承** - -单继承,一个子类只有一个父类,如我们上面讲过的 Animal 类和它的子类。**单继承在类层次结构上比较清晰,但缺点是结构的丰富度有时不能满足使用需求**。 - -##### **多继承** - -多继承,一个子类有多个直接的父类。这样做的好处是子类拥有所有父类的特征,**子类的丰富度很高,但是缺点就是容易造成混乱**。下图为一个混乱的例子。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-ab4c9fef-63be-4bba-a871-7e5fb9bf711a.png) - -Java 虽然不支持多继承,但是 Java 有三种实现多继承效果的方式,**分别是**内部类、多层继承和实现接口。 - -[内部类](https://tobebetterjavaer.com/oo/inner-class.html)可以继承一个与外部类无关的类,保证了内部类的独立性,正是基于这一点,可以达到多继承的效果。 - -**多层继承:**子类继承父类,父类如果还继承其他的类,那么这就叫**多层继承**。这样子类就会拥有所有被继承类的属性和方法。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-d3789496-09f8-4a62-8424-e5c45e224320.png) - -[实现接口](https://tobebetterjavaer.com/oo/interface.html)无疑是满足多继承使用需求的最好方式,一个类可以实现多个接口满足自己在丰富性和复杂环境的使用需求。 - -类和接口相比,**类就是一个实体,有属性和方法,而接口更倾向于一组方法**。举个例子,就拿斗罗大陆的唐三来看,他存在的继承关系可能是这样的: - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-c06ece50-32e5-4b03-a31b-05ef03592d0c.png) - -#### 04、如何实现继承 - -##### **extends 关键字** - -在 Java 中,类的继承是单一继承,也就是说一个子类只能拥有一个父类,所以**extends**只能继承一个类。其使用语法为: - -```java -class 子类名 extends 父类名{} -``` - -例如 Dog 类继承 Animal 类,它是这样的: - -```java -class Animal{} //定义Animal类 -class Dog extends Animal{} //Dog类继承Animal类 -``` - -子类继承父类后,就拥有父类的非私有的**属性和方法**。如果不明白,请看这个案例,在 IDEA 下创建一个项目,创建一个 test 类做测试,分别创建 Animal 类和 Dog 类,Animal 作为父类写一个 sayHello()方法,Dog 类继承 Animal 类之后就可以调用 sayHello()方法。具体代码为: - -```java -class Animal { - public void sayHello()//父类的方法 - { - System.out.println("hello,everybody"); - } -} -class Dog extends Animal//继承animal -{ } -public class test { - public static void main(String[] args) { - Dog dog=new Dog(); - dog.sayHello(); - } -} -``` - -点击运行的时候 Dog 子类可以直接使用 Animal 父类的方法。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-2ba4864f-af39-4bd7-b59c-db53ec1c38f6.png) - -##### **implements 关键字** - -使用 implements 关键字可以变相使 Java 拥有多继承的特性,使用范围为类实现接口的情况,一个类可以实现多个接口(接口与接口之间用逗号分开)。 - -我们来看一个案例,创建一个 test2 类做测试,分别创建 doA 接口和 doB 接口,doA 接口声明 sayHello()方法,doB 接口声明 eat()方法,创建 Cat2 类实现 doA 和 doB 接口,并且在类中需要重写 sayHello()方法和 eat()方法。具体代码为: - -```java -interface doA{ - void sayHello(); -} -interface doB{ - void eat(); - //以下会报错 接口中的方法不能具体定义只能声明 - //public void eat(){System.out.println("eating");} -} -class Cat2 implements doA,doB{ - @Override//必须重写接口内的方法 - public void sayHello() { - System.out.println("hello!"); - } - @Override - public void eat() { - System.out.println("I'm eating"); - } -} -public class test2 { - public static void main(String[] args) { - Cat2 cat=new Cat2(); - cat.sayHello(); - cat.eat(); - } -} -``` - -Cat 类实现 doA 和 doB 接口的时候,需要实现其声明的方法,点击运行结果如下,这就是一个类实现接口的简单案例: - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-32bdceb5-e838-47cb-ad96-b7453abae6a5.png) - -#### 05、继承的特点 - -继承的主要内容就是子类继承父类,并重写父类的方法。使用子类的属性或方法时候,首先要创建一个对象,而对象通过[构造方法](https://tobebetterjavaer.com/oo/construct.html)去创建,在构造方法中我们可能会调用子父类的一些属性和方法,所以就需要提前掌握 [this 和 super关键字](https://tobebetterjavaer.com/oo/this-super.html)。 - -创建完这个对象之后,再调用**重写**父类后的方法,注意[重写和重载的区别](https://tobebetterjavaer.com/basic-extra-meal/override-overload.html)。 - -##### this 和 super 关键字 - ->[后面](https://tobebetterjavaer.com/oo/this-super.html)会详细讲,这里先来简单了解一下。 - -this 和 super 关键字是继承中**非常重要的知识点**,分别表示当前对象的引用和父类对象的引用,两者有很大相似又有一些区别。 - -**this 表示当前对象,是指向自己的引用。** - -```java -this.属性 // 调用成员变量,要区别成员变量和局部变量 -this.() // 调用本类的某个方法 -this() // 表示调用本类构造方法 -``` - -**super 表示父类对象,是指向父类的引用。** - -```java -super.属性 // 表示父类对象中的成员变量 -super.方法() // 表示父类对象中定义的方法 -super() // 表示调用父类构造方法 -``` - -##### 构造方法 - -[构造方法](https://tobebetterjavaer.com/oo/construct.html)是一种特殊的方法,**它是一个与类同名的方法**。在继承中**构造方法是一种比较特殊的方法**(比如不能继承),所以要了解和学习在继承中构造方法的规则和要求。 - -继承中的构造方法有以下几点需要注意: - -**父类的构造方法不能被继承:** - -因为构造方法语法是**与类同名**,而继承则不更改方法名,如果子类继承父类的构造方法,那明显与构造方法的语法冲突了。比如 Father 类的构造方法名为 Father(),Son 类如果继承 Father 类的构造方法 Father(),那就和构造方法定义:**构造方法与类同名**冲突了,所以在子类中不能继承父类的构造方法,但子类会调用父类的构造方法。 - -**子类的构造过程必须调用其父类的构造方法:** - -Java 虚拟机**构造子类对象前会先构造父类对象,父类对象构造完成之后再来构造子类特有的属性,**这被称为**内存叠加**。而 Java 虚拟机构造父类对象会执行父类的构造方法,所以子类构造方法必须调用 super()即父类的构造方法。就比如一个简单的继承案例应该这么写: - -```java -class A{ - public String name; - public A() {//无参构造 - } - public A (String name){//有参构造 - } -} -class B extends A{ - public B() {//无参构造 - super(); - } - public B(String name) {//有参构造 - //super(); - super(name); - } -} -``` - -**如果子类的构造方法中没有显示地调用父类构造方法,则系统默认调用父类无参数的构造方法。** - -你可能有时候在写继承的时候子类并没有使用 super()调用,程序依然没问题,其实这样是为了节省代码,系统执行时会自动添加父类的无参构造方式,如果不信的话我们对上面的类稍作修改执行: - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-33980b57-857b-4428-8b27-47d6d5060f29.png) - -##### 方法重写(Override) - -[方法重写](https://tobebetterjavaer.com/basic-extra-meal/Overriding.html)也就是子类中出现和父类中一模一样的方法(包括返回值类型,方法名,参数列表),它建立在继承的基础上。你可以理解为方法的**外壳不变,但是核心内容重写**。 - -在这里提供一个简单易懂的方法重写案例: - -```java -class E1{ - public void doA(int a){ - System.out.println("这是父类的方法"); - } -} -class E2 extends E1{ - @Override - public void doA(int a) { - System.out.println("我重写父类方法,这是子类的方法"); - } -} -``` - -其中`@Override` 注解显示声明该方法为注解方法,可以帮你检查重写方法的语法正确性,当然如果不加也是可以的,但建议加上。 - -##### 方法重载(Overload) - -如果有两个方法的**方法名相同**,但参数不一致,那么可以说一个方法是另一个方法的[重载](https://tobebetterjavaer.com/basic-extra-meal/override-overload.html)。 - -重载可以通常理解为完成同一个事情的方法名相同,但是参数列表不同其他条件也可能不同。一个简单的方法重载的例子,类 E3 中的 add()方法就是一个重载方法。 - -```java -class E3{ - public int add(int a,int b){ - return a+b; - } - public double add(double a,double b) { - return a+b; - } - public int add(int a,int b,int c) { - return a+b+c; - } -} -``` - -#### 06、继承与修饰符 - -Java 修饰符的作用就是对类或类成员进行修饰或限制,每个修饰符都有自己的作用,而在继承中可能有些特殊修饰符使得被修饰的属性或方法不能被继承,或者继承需要一些其他的条件。 - -Java 语言提供了很多修饰符,修饰符用来定义类、方法或者变量,通常放在语句的最前端。主要分为以下两类: - -- [访问权限修饰符](https://tobebetterjavaer.com/oo/access-control.html),也就是 public、private、protected 等 -- 非访问修饰符,也就是 static、final、abstract 等 - -##### 访问修饰符 - -Java 子类重写继承的方法时,**不可以降低方法的访问权限**,**子类继承父类的访问修饰符作用域不能比父类小**,也就是更加开放,假如父类是 protected 修饰的,其子类只能是 protected 或者 public,绝对不能是 default(默认的访问范围)或者 private。所以在继承中需要重写的方法不能使用 private 修饰词修饰。 - -如果还是不太清楚可以看几个小案例就很容易搞懂,写一个 A1 类中用四种修饰词实现四个方法,用子类 A2 继承 A1,重写 A1 方法时候你就会发现父类私有方法不能重写,非私有方法重写使用的修饰符作用域不能变小(大于等于)。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-ec684196-f877-46af-9f1e-087a5d313beb.png) - -正确的案例应该为: - -```java -class A1 { - private void doA(){ } - void doB(){}//default - protected void doC(){} - public void doD(){} -} -class A2 extends A1{ - - @Override - public void doB() { }//继承子类重写的方法访问修饰符权限可扩大 - - @Override - protected void doC() { }//继承子类重写的方法访问修饰符权限可和父类一致 - - @Override - public void doD() { }//不可用protected或者default修饰 -} -``` - -还要注意的是,**继承当中子类抛出的异常必须是父类抛出的异常或父类抛出异常的子异常**。下面的一个案例四种方法测试可以发现子类方法的异常不可大于父类对应方法抛出异常的范围。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-6d5118fb-0807-4d78-a767-d6c4282e4b2b.png) - -正确的案例应该为: - -```java -class B1{ - public void doA() throws Exception{} - public void doB() throws Exception{} - public void doC() throws IOException{} - public void doD() throws IOException{} -} -class B2 extends B1{ - //异常范围和父类可以一致 - @Override - public void doA() throws Exception { } - //异常范围可以比父类更小 - @Override - public void doB() throws IOException { } - //异常范围 不可以比父类范围更大 - @Override - public void doC() throws IOException { }//不可抛出Exception等比IOException更大的异常 - @Override - public void doD() throws IOException { } -} -``` - -##### 非访问修饰符 - -访问修饰符用来控制访问权限,而非访问修饰符每个都有各自的作用,下面针对 static、final、abstract 修饰符进行介绍。 - -[static 修饰符](https://tobebetterjavaer.com/oo/static.html) - -static 翻译为“静态的”,能够与变量,方法和类一起使用,**称为静态变量,静态方法(也称为类变量、类方法)**。如果在一个类中使用 static 修饰变量或者方法的话,它们**可以直接通过类访问,不需要创建一个类的对象来访问成员。** - -我们在设计类的时候可能会使用静态方法,有很多工具类比如`Math`,`Arrays`等类里面就写了很多静态方法。 - -可以看以下的案例证明上述规则: - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-6a6ab068-2ef1-401a-ab2f-86a84b29dbbb.png) - -源代码为: - -```java -class C1{ - public int a; - public C1(){} - // public static C1(){}// 构造方法不允许被声明为static - public static void doA() {} - public static void doB() {} -} -class C2 extends C1{ - public static void doC()//静态方法中不存在当前对象,因而不能使用this和super。 - { - //System.out.println(super.a); - } - public static void doA(){}//静态方法能被静态方法重写 - // public void doB(){}//静态方法不能被非静态方法重写 -} -``` - -[final 修饰符](https://tobebetterjavaer.com/oo/final.html) - -final 变量: - -- final 表示"最后的、最终的"含义,**变量一旦赋值后,不能被重新赋值**。被 final 修饰的实例变量必须显式指定初始值(即不能只声明)。final 修饰符通常和 static 修饰符一起使用来创建类常量。 - -final 方法: - -- **父类中的 final 方法可以被子类继承,但是不能被子类重写**。声明 final 方法的主要目的是防止该方法的内容被修改。 - -final 类: - -- **final 类不能被继承**,没有类能够继承 final 类的任何特性。 - -所以无论是变量、方法还是类被 final 修饰之后,都有代表最终、最后的意思。内容无法被修改。 - -[abstract 修饰符](https://tobebetterjavaer.com/oo/abstract.html) - -abstract 英文名为“抽象的”,主要用来修饰类和方法,称为抽象类和抽象方法。 - -**抽象方法**:有很多不同类的方法是相似的,但是具体内容又不太一样,所以我们只能抽取他的声明,没有具体的方法体,即抽象方法可以表达概念但无法具体实现。 - -**抽象类**:**有抽象方法的类必须是抽象类**,抽象类可以表达概念但是无法构造实体的类。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-c9cd573a-39e9-40a0-b6f5-6a0fe925487d.png) - -比如我们可以这样设计一个 People 抽象类以及一个抽象方法,在子类中具体完成: - -```java -abstract class People{ - public abstract void sayHello();//抽象方法 -} -class Chinese extends People{ - @Override - public void sayHello() {//实现抽象方法 - System.out.println("你好"); - } -} -class Japanese extends People{ - @Override - public void sayHello() {//实现抽象方法 - System.out.println("口你七哇"); - } -} -class American extends People{ - @Override - public void sayHello() {//实现抽象方法 - System.out.println("hello"); - } -} -``` - -#### 07、Object 类和转型 - -提到 Java 继承,不得不提及所有类的根类:Object(java.lang.Object)类,如果一个类没有显式声明它的父类(即没有写 extends xx),那么默认这个类的父类就是 Object 类,任何类都可以使用 Object 类的方法,创建的类也可和 Object 进行向上、向下转型,所以 Object 类是掌握和理解继承所必须的知识点。 - -Java 向上和向下转型在 Java 中运用很多,也是建立在继承的基础上,所以 Java 转型也是掌握和理解继承所必须的知识点。 - -##### Object 类概述 - -1. Object 是类层次结构的**根类**,所有的类都隐式的继承自 Object 类。 -2. Java 中,所有的对象都拥有 Object 的默认方法。 -3. Object 类有一个[构造方法](https://tobebetterjavaer.com/oo/construct.html),并且是**无参构造方法**。 - -Object 是 Java 所有类的父类,是整个类继承结构的顶端,也是最抽象的一个类。 - -像 toString()、equals()、hashCode()、wait()、notify()、getClass()等都是 Object 的方法。你以后可能会经常碰到,但其中遇到更多的就是 toString()方法和 equals()方法,我们经常需要重写这两种方法满足我们的使用需求。 - -toString()方法表示返回该对象的字符串,由于各个对象构造不同所以需要重写,如果不重写的话默认返回`类名@hashCode`格式。 - -**如果重写 toString()方法后**直接调用 toString()方法就可以返回我们自定义的该类转成字符串类型的内容输出,而不需要每次都手动的拼凑成字符串内容输出,大大简化输出操作。 - -equals()方法主要比较两个对象是否相等,因为对象的相等不一定非要严格要求两个对象地址上的相同,有时内容上的相同我们就会认为它相等,比如 String 类就重写了euqals()方法,通过[字符串的内容比较是否相等](https://tobebetterjavaer.com/string/equals.html)。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-1caee9d0-ccbc-41cd-82e2-115b86c57a5a.png) - -##### 向上转型 - -**向上转型** : 通过子类对象(小范围)实例化父类对象(大范围),这种属于自动转换。用一张图就能很好地表示向上转型的逻辑: - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-0cd258c9-b897-4be3-bdb2-2ddd9c073609.png) - -父类引用变量指向子类对象后,只能使用父类已声明的方法,但方法如果被重写会执行子类的方法,如果方法未被重写那么将执行父类的方法。 - -##### 向下转型 - -**向下转型** : 通过父类对象(大范围)实例化子类对象(小范围),在书写上父类对象需要加括号`()`强制转换为子类类型。但父类引用变量实际引用必须是子类对象才能成功转型,这里也用一张图就能很好表示向下转型的逻辑: - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-ef0d4716-8b4f-4adf-845e-dd293871b7a7.png) - -子类引用变量指向父类引用变量指向的对象后(一个 Son()对象),就完成向下转型,就可以调用一些子类特有而父类没有的方法 。 - -在这里写一个向上转型和向下转型的案例: - -```java -Object object=new Integer(666);//向上转型 - -Integer i=(Integer)object;//向下转型Object->Integer,object的实质还是指向Integer - -String str=(String)object;//错误的向下转型,虽然编译器不会报错但是运行会报错 -``` - -#### 08、子父类初始化顺序 - -在 Java 继承中,父子类初始化先后顺序为: - -1. 父类中静态成员变量和静态代码块 -2. 子类中静态成员变量和静态代码块 -3. 父类中普通成员变量和代码块,父类的构造方法 -4. 子类中普通成员变量和代码块,子类的构造方法 - -总的来说,就是**静态>非静态,父类>子类,非构造方法>构造方法**。同一类别(例如普通变量和普通代码块)成员变量和代码块执行从前到后,需要注意逻辑。 - -这个也不难理解,静态变量也称类变量,可以看成一个全局变量,静态成员变量和静态代码块在类加载的时候就初始化,而非静态变量和代码块在对象创建的时候初始化。所以静态快于非静态初始化。 - -而在创建子类对象的时候需要先创建父类对象,所以父类优先于子类。 - -而在调用构造方法的时候,是对成员变量进行一些初始化操作,所以普通成员变量和代码块优于构造方法执行。 - -至于更深层次为什么这个顺序,就要更深入了解 JVM 执行流程啦。下面一个测试代码为: - -```java -class Father{ - public Father() { - System.out.println(++b1+"父类构造方法"); - }//父类构造方法 第四 - static int a1=0;//父类static 第一 注意顺序 - static { - System.out.println(++a1+"父类static"); - } - int b1=a1;//父类成员变量和代码块 第三 - { - System.out.println(++b1+"父类代码块"); - } -} -class Son extends Father{ - public Son() { - System.out.println(++b2+"子类构造方法"); - }//子类构造方法 第六 - static {//子类static第二步 - System.out.println(++a1+"子类static"); - } - int b2=b1;//子类成员变量和代码块 第五 - { - System.out.println(++b2 + "子类代码块"); - } -} -public class test9 { - public static void main(String[] args) { - Son son=new Son(); - } -} -``` - -执行结果: - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-d23e0bbf-a4a4-4d20-ad21-f437fcee1c82.png) - - -### 3)多态 - -Java 多态是指在面向对象编程中,同一个类的对象在不同情况下表现出不同的行为和状态。 - -- 子类可以继承父类的属性和方法,子类对象可以直接使用父类中的方法和变量。 -- 子类可以对从父类继承的方法进行重新实现,使得子类对象调用这个方法时表现出不同的行为。 -- 可以将子类对象赋给父类类型的变量,这样就可以通过父类类型的变量调用子类中重写的方法,实现多态。 - -“很枯燥,有没有?再具体的分析一下。” - -#### 01、多态是什么 - -在我刻板的印象里,西游记里的那段孙悟空和二郎神的精彩对战就能很好的解释“多态”这个词:一个孙悟空,能七十二变;一个二郎神,也能七十二变;他们都可以变成不同的形态,但只需要悄悄地喊一声“变”。 - -Java的多态是什么呢?其实就是一种能力——同一个行为具有不同的表现形式;换句话说就是,执行一段代码,Java 在运行时能根据对象的不同产生不同的结果。和孙悟空和二郎神都只需要喊一声“变”,然后就变了,并且每次变得还不一样;一个道理。 - -多态的前提条件有三个: - -* 子类继承父类 -* 子类覆盖父类的方法 -* 父类引用指向子类对象 - -多态的一个简单应用,来看程序清单1-1: - -```java -//子类继承父类 -public class Wangxiaoer extends Wanger { - public void write() { // 子类覆盖父类方法 - System.out.println("记住仇恨,表明我们要奋发图强的心智"); - } - - public static void main(String[] args) { - // 父类引用指向子类对象 - Wanger[] wangers = { new Wanger(), new Wangxiaoer() }; - - for (Wanger wanger : wangers) { - // 对象是王二的时候输出:勿忘国耻 - // 对象是王小二的时候输出:记住仇恨,表明我们要奋发图强的心智 - wanger.write(); - } - } -} - -class Wanger { - public void write() { - System.out.println("勿忘国耻"); - } -} -``` - -#### 02、多态与后期绑定 - -现在,我们来思考一个问题:程序清单1-1在执行 `wanger.write()` 时,由于编译器只有一个 Wanger 引用,它怎么知道究竟该调用父类 Wanger 的 `write()` 方法,还是子类 Wangxiaoer 的 `write()` 方法呢? - -答案是在运行时根据对象的类型进行后期绑定,编译器在编译阶段并不知道对象的类型,但是Java的方法调用机制能找到正确的方法体,然后执行出正确的结果。 - -多态机制提供的一个重要的好处程序具有良好的扩展性。来看程序清单2-1: - -```java -//子类继承父类 -public class Wangxiaoer extends Wanger { - public void write() { // 子类覆盖父类方法 - System.out.println("记住仇恨,表明我们要奋发图强的心智"); - } - - public void eat() { - System.out.println("我不喜欢读书,我就喜欢吃"); - } - - public static void main(String[] args) { - // 父类引用指向子类对象 - Wanger[] wangers = { new Wanger(), new Wangxiaoer() }; - - for (Wanger wanger : wangers) { - // 对象是王二的时候输出:勿忘国耻 - // 对象是王小二的时候输出:记住仇恨,表明我们要奋发图强的心智 - wanger.write(); - } - } -} - -class Wanger { - public void write() { - System.out.println("勿忘国耻"); - } - - public void read() { - System.out.println("每周读一本好书"); - } -} -``` - -在程序清单 2-1 中,我们在 Wanger 类中增加了 read() 方法,在 Wangxiaoer 类中增加了eat()方法,但这丝毫不会影响到 write() 方法的调用。write() 方法忽略了周围代码发生的变化,依然正常运行。这让我想起了金庸《倚天屠龙记》里九阳真经的口诀:“他强由他强,清风拂山岗;他横由他横,明月照大江。” - -多态的这个优秀的特性,让我们在修改代码的时候不必过于紧张,因为多态是一项让程序员“将改变的与未改变的分离开来”的重要特性。 - -#### 03、多态与构造方法 - -在构造方法中调用多态方法,会产生一个奇妙的结果,我们来看程序清单3-1: - -```java -public class Wangxiaosan extends Wangsan { - private int age = 3; - public Wangxiaosan(int age) { - this.age = age; - System.out.println("王小三的年龄:" + this.age); - } - - public void write() { // 子类覆盖父类方法 - System.out.println("我小三上幼儿园的年龄是:" + this.age); - } - - public static void main(String[] args) { - new Wangxiaosan(4); -// 上幼儿园之前 -// 我小三上幼儿园的年龄是:0 -// 上幼儿园之后 -// 王小三的年龄:4 - } -} - -class Wangsan { - Wangsan () { - System.out.println("上幼儿园之前"); - write(); - System.out.println("上幼儿园之后"); - } - public void write() { - System.out.println("老子上幼儿园的年龄是3岁半"); - } -} -``` - -从输出结果上看,是不是有点诧异?明明在创建 Wangxiaosan 对象的时候,年龄传递的是 4,但输出结果既不是“老子上幼儿园的年龄是 3 岁半”,也不是“我小三上幼儿园的年龄是:4”。 - -为什么? - -因为在创建子类对象时,会先去调用父类的构造方法,而父类构造方法中又调用了被子类覆盖的多态方法,由于父类并不清楚子类对象中的属性值是什么,于是把int类型的属性暂时初始化为 0,然后再调用子类的构造方法(子类构造方法知道王小二的年龄是 4)。 - -#### 04、多态与向下转型 - -向下转型是指将父类引用强转为子类类型;这是不安全的,因为有的时候,父类引用指向的是父类对象,向下转型就会抛出 ClassCastException,表示类型转换失败;但如果父类引用指向的是子类对象,那么向下转型就是成功的。 - -来看程序清单4-1: - -```java -public class Wangxiaosi extends Wangsi { - public void write() { - System.out.println("记住仇恨,表明我们要奋发图强的心智"); - } - - public void eat() { - System.out.println("我不喜欢读书,我就喜欢吃"); - } - - public static void main(String[] args) { - Wangsi[] wangsis = { new Wangsi(), new Wangxiaosi() }; - - // wangsis[1]能够向下转型 - ((Wangxiaosi) wangsis[1]).write(); - // wangsis[0]不能向下转型 - ((Wangxiaosi)wangsis[0]).write(); - } -} - -class Wangsi { - public void write() { - System.out.println("勿忘国耻"); - } - - public void read() { - System.out.println("每周读一本好书"); - } -} -``` - -“好了,三妹,到此为止,我们就将 Java 的三大特性,封装继承多态全部讲完了,希望你能重新把他们梳理一下。” - -“好的,二哥,遵命。”三妹顽皮地笑了。 - -### 4)小结 - -好啦,三妹,本次继承就介绍到这里啦,Java 面向对象三大特征之一继承——优秀的你已经掌握。 - -封装:是对类的封装,封装是对类的属性和方法进行封装,只对外暴露方法而不暴露具体使用细节,所以我们一般设计类成员变量时候大多设为私有而通过一些 get、set 方法去读写。 - -继承:子类继承父类,即“子承父业”,子类拥有父类除私有的所有属性和方法,自己还能在此基础上拓展自己新的属性和方法。主要目的是**复用代码**。 - -**多态**:多态是同一个行为具有多个不同表现形式或形态的能力。即一个父类可能有若干子类,各子类实现父类方法有多种多样,调用父类方法时,父类引用变量指向不同子类实例而执行不同方法,这就是所谓父类方法是多态的。 - -最后送你一张图捋一捋其中的关系吧。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/oo/extends-bigsai-2bf1876f-0c1c-4e83-8721-e6f48d6451c0.png) - -“好的,二哥,我来消化一下,今天内容真不少。你先去休息一下。”三妹回应到。 - -> 参考链接:[https://bbs.huaweicloud.com/blogs/271358](https://bbs.huaweicloud.com/blogs/271358),作者:bigsai,整理:沉默王二 - - - - -## 5.14 Java this和super关键字 - -“哥,被喊大舅子的感觉怎么样啊?”三妹不怀好意地对我说,她眼睛里充满着不屑。 - -“说实话,这种感觉还不错。”我有点难为情的回答她,“不过,有一点令我感到些许失落。大家的焦点似乎都是你的颜值,完全忽略了我的盛世美颜啊!” - -“哥,你想啥呢,那是因为你文章写得好,不然谁认识我是谁啊!有你这样的哥哥,我还是挺自豪的。”三妹郑重其事地说,“话说今天咱学啥呢?” - -“三妹啊,你这句话说得我喜欢。今天来学习一下 Java 中的 this 关键字吧。”喝了一口农夫山泉后,我对三妹说。 - -“this 关键字有很多种用法,其中最常用的一个是,它可以作为引用变量,指向当前对象。”我面带着朴实无华的微笑继续说,“除此之外, this 关键字还可以完成以下工作。” - -- 调用当前类的方法; -- `this()` 可以调用当前类的构造方法; -- this 可以作为参数在方法中传递; -- this 可以作为参数在构造方法中传递; -- this 可以作为方法的返回值,返回当前类的对象。 - -### 01、 指向当前对象 - -“三妹,来看下面这段代码。”话音刚落,我就在键盘上噼里啪啦一阵敲。 - -```java -public class WithoutThisStudent { - String name; - int age; - - WithoutThisStudent(String name, int age) { - name = name; - age = age; - } - - void out() { - System.out.println(name+" " + age); - } - - public static void main(String[] args) { - WithoutThisStudent s1 = new WithoutThisStudent("沉默王二", 18); - WithoutThisStudent s2 = new WithoutThisStudent("沉默王三", 16); - - s1.out(); - s2.out(); - } -} -``` - -“在上面的例子中,构造方法的参数名和实例变量名相同,由于没有使用 this 关键字,所以无法为实例变量赋值。”我抬起右手的食指,指着屏幕上的 name 和 age 对着三妹说。 - -“来看一下程序的输出结果。” - -``` -null 0 -null 0 -``` - -“从结果中可以看得出来,尽管创建对象的时候传递了参数,但实例变量并没有赋值。这是因为如果构造方法中没有使用 this 关键字的话,name 和 age 指向的并不是实例变量而是参数本身。”我把脖子扭向右侧,看着三妹说。 - -“那怎么解决这个问题呢?哥。”三妹着急地问。 - -“如果参数名和实例变量名产生了冲突.....”我正准备给出答案,三妹打断了我。 - -“难道用 this 吗?”三妹脱口而出。 - -“哇,越来越棒了呀,你。”我感觉三妹在学习 Java 这条道路上逐渐有了自己主动思考的意愿。 - -“是的,来看加上 this 关键字后的代码。” - -安静的屋子里又响起了一阵噼里啪啦的键盘声。 - -```java -public class WithThisStudent { - String name; - int age; - - WithThisStudent(String name, int age) { - this.name = name; - this.age = age; - } - - void out() { - System.out.println(name+" " + age); - } - - public static void main(String[] args) { - WithThisStudent s1 = new WithThisStudent("沉默王二", 18); - WithThisStudent s2 = new WithThisStudent("沉默王三", 16); - - s1.out(); - s2.out(); - } -} -``` - -“再来看一下程序的输出结果。” - -``` -沉默王二 18 -沉默王三 16 -``` - -“这次,实例变量有值了,在构造方法中,`this.xxx` 指向的就是实例变量,而不再是参数本身了。”我慢吞吞地说着,“当然了,如果参数名和实例变量名不同的话,就不必使用 this 关键字,但我建议使用 this 关键字,这样的代码更有意义。” - -### 02、调用当前类的方法 - -“仔细听,三妹,看我敲键盘的速度是不是够快。” - -```java -public class InvokeCurrentClassMethod { - void method1() {} - void method2() { - method1(); - } - - public static void main(String[] args) { - new InvokeCurrentClassMethod().method1(); - } -} -``` - -“仔细瞧,三妹,上面这段代码中没有见到 this 关键字吧?”我面带着神秘的微笑,准备给三妹变个魔术。 - -“确实没有,哥,我确认过了。” - -“那接下来,神奇的事情就要发生了。”我突然感觉刘谦附身了。 - -我快速的在 classes 目录下找到 InvokeCurrentClassMethod.class 文件,然后双击打开(IDEA 默认会使用 FernFlower 打开字节码文件)。 - -```java -public class InvokeCurrentClassMethod { - public InvokeCurrentClassMethod() { - } - - void method1() { - } - - void method2() { - this.method1(); - } - - public static void main(String[] args) { - (new InvokeCurrentClassMethod()).method1(); - } -} -``` - -“瞪大眼睛仔细瞧,三妹,`this` 关键字是不是出现了?” - -“哇,真的呢,好神奇啊!”三妹为了配合我的演出,也是十二分的卖力。 - -“我们可以在一个类中使用 this 关键字来调用另外一个方法,如果没有使用的话,编译器会自动帮我们加上。”我对自己深厚的编程功底充满自信,“在源代码中,`method2()` 在调用 `method1()` 的时候并没有使用 this 关键字,但通过反编译后的字节码可以看得到。” - -### 03、调用当前类的构造方法 - -“再来看下面这段代码。” - -```java -public class InvokeConstrutor { - InvokeConstrutor() { - System.out.println("hello"); - } - - InvokeConstrutor(int count) { - this(); - System.out.println(count); - } - - public static void main(String[] args) { - InvokeConstrutor invokeConstrutor = new InvokeConstrutor(10); - } -} -``` - -“在有参构造方法 `InvokeConstrutor(int count)` 中,使用了 `this()` 来调用无参构造方法 `InvokeConstrutor()`。”这次,我换成了左手的食指,指着屏幕对三妹说,“`this()` 可用于调用当前类的构造方法——构造方法可以重用了。” - -“来看一下输出结果。” - -``` -hello -10 -``` - -“真的啊,无参构造方法也被调用了,所以程序输出了 hello。”三妹看到输出结果后不假思索地说。 - -“也可以在无参构造方法中使用 `this()` 并传递参数来调用有参构造方法。”话音没落,我就在键盘上敲了起来,“来看下面这段代码。” - -```java -public class InvokeParamConstrutor { - InvokeParamConstrutor() { - this(10); - System.out.println("hello"); - } - - InvokeParamConstrutor(int count) { - System.out.println(count); - } - - public static void main(String[] args) { - InvokeParamConstrutor invokeConstrutor = new InvokeParamConstrutor(); - } -} -``` - -“再来看一下程序的输出结果。” - -``` -10 -hello -``` - -“不过,需要注意的是,`this()` 必须放在构造方法的第一行,否则就报错了。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/20-01.png) - -### 04、作为参数在方法中传递 - -“来看下面这段代码。” - -```java -public class ThisAsParam { - void method1(ThisAsParam p) { - System.out.println(p); - } - - void method2() { - method1(this); - } - - public static void main(String[] args) { - ThisAsParam thisAsParam = new ThisAsParam(); - System.out.println(thisAsParam); - thisAsParam.method2(); - } -} -``` - -“`this` 关键字可以作为参数在方法中传递,此时,它指向的是当前类的对象。”一不小心,半个小时过去了,我感到嗓子冒烟,于是赶紧又喝了一口水,润润嗓子后继续说道。 - -“来看一下输出结果,你就明白了,三妹。” - -``` -com.itwanger.twentyseven.ThisAsParam@77459877 -com.itwanger.twentyseven.ThisAsParam@77459877 -``` - -“`method2()` 调用了 `method1()`,并传递了参数 this,`method1()` 中打印了当前对象的字符串。 `main()` 方法中打印了 thisAsParam 对象的字符串。从输出结果中可以看得出来,两者是同一个对象。” - -### 05、作为参数在构造方法中传递 - -“继续来看代码。” - -```java -public class ThisAsConstrutorParam { - int count = 10; - - ThisAsConstrutorParam() { - Data data = new Data(this); - data.out(); - } - - public static void main(String[] args) { - new ThisAsConstrutorParam(); - } -} - -class Data { - ThisAsConstrutorParam param; - Data(ThisAsConstrutorParam param) { - this.param = param; - } - - void out() { - System.out.println(param.count); - } -} -``` - -“在构造方法 `ThisAsConstrutorParam()` 中,我们使用 this 关键字作为参数传递给了 Data 对象,它其实指向的就是 `new ThisAsConstrutorParam()` 这个对象。” - -“`this` 关键字也可以作为参数在构造方法中传递,它指向的是当前类的对象。当我们需要在多个类中使用一个对象的时候,这非常有用。” - -“来看一下输出结果。” - -``` -10 -``` - -### 06、作为方法的返回值 - -“需要休息会吗?三妹” - -“没事的,哥,我的注意力还是很集中的,你继续讲吧。” - -“好的,那来继续看代码。” - -```java -public class ThisAsMethodResult { - ThisAsMethodResult getThisAsMethodResult() { - return this; - } - - void out() { - System.out.println("hello"); - } - - public static void main(String[] args) { - new ThisAsMethodResult().getThisAsMethodResult().out(); - } -} -``` - -“`getThisAsMethodResult()` 方法返回了 this 关键字,指向的就是 `new ThisAsMethodResult()` 这个对象,所以可以紧接着调用 `out()` 方法——达到了链式调用的目的,这也是 this 关键字非常经典的一种用法。” - -“链式调用的形式在 JavaScript 代码更加常见。”为了向三妹证实这一点,我打开了 jQuery 的源码。 - -“原来这么多链式调用啊!”三妹感叹到。 - -“是的。”我点点头,然后指着 `getThisAsMethodResult()` 方法的返回值对三妹说,“需要注意的是,`this` 关键字作为方法的返回值的时候,方法的返回类型为类的类型。” - -“来看一下输出结果。” - -``` -hello -``` - -“那么,关于 this 关键字的介绍,就到此为止了。”我活动了一下僵硬的脖子后,对三妹说,“如果你学习劲头还可以的话,我们顺带把 super 关键字捎带着过一下,怎么样?” - -“不用了吧,听说 super 关键字更简单,我自己看看就行了,不用你讲了!” - -“不不不,三妹啊,你得假装听一下,不然我怎么向读者们交差。” - -“噢噢噢噢。”三妹意味深长地笑了。 - -### 07、super 关键字 - -“super 关键字的用法主要有三种。” - -- 指向父类对象; -- 调用父类的方法; -- `super()` 可以调用父类的构造方法。 - -“其实和 this 有些相似,只不过用意不大相同。”我端起水瓶,咕咚咕咚又喝了几大口,好渴。“每当创建一个子类对象的时候,也会隐式的创建父类对象,由 super 关键字引用。” - -“如果父类和子类拥有同样名称的字段,super 关键字可以用来访问父类的同名字段。” - -“来看下面这段代码。” - -```java -public class ReferParentField { - public static void main(String[] args) { - new Dog().printColor(); - } -} - -class Animal { - String color = "白色"; -} - -class Dog extends Animal { - String color = "黑色"; - - void printColor() { - System.out.println(color); - System.out.println(super.color); - } -} -``` - -“父类 Animal 中有一个名为 color 的字段,子类 Dog 中也有一个名为 color 的字段,子类的 `printColor()` 方法中,通过 super 关键字可以访问父类的 color。” - -“来看一下输出结果。” - -``` -黑色 -白色 -``` - -“当子类和父类的方法名相同时,可以使用 super 关键字来调用父类的方法。换句话说,super 关键字可以用于方法重写时访问到父类的方法。” - - -```java -public class ReferParentMethod { - public static void main(String[] args) { - new Dog().work(); - } -} - -class Animal { - void eat() { - System.out.println("吃..."); - } -} - -class Dog extends Animal { - @Override - void eat() { - System.out.println("吃..."); - } - - void bark() { - System.out.println("汪汪汪..."); - } - - void work() { - super.eat(); - bark(); - } -} -``` - -“瞧,三妹。父类 Animal 和子类 Dog 中都有一个名为 `eat()` 的方法,通过 `super.eat()` 可以访问到父类的 `eat()` 方法。” - -等三妹在自我消化的时候,我在键盘上又敲完了一串代码。 - -```java -public class ReferParentConstructor { - public static void main(String[] args) { - new Dog(); - } -} - -class Animal { - Animal(){ - System.out.println("动物来了"); - } -} - -class Dog extends Animal { - Dog() { - super(); - System.out.println("狗狗来了"); - } -} -``` - -“子类 Dog 的构造方法中,第一行代码为 `super()`,它就是用来调用父类的构造方法的。” - -“来看一下输出结果。” - -``` -动物来了 -狗狗来了 -``` - -“当然了,在默认情况下,`super()` 是可以省略的,编译器会主动去调用父类的构造方法。也就是说,子类即使不使用 `super()` 主动调用父类的构造方法,父类的构造方法仍然会先执行。” - -```java -public class ReferParentConstructor { - public static void main(String[] args) { - new Dog(); - } -} - -class Animal { - Animal(){ - System.out.println("动物来了"); - } -} - -class Dog extends Animal { - Dog() { - System.out.println("狗狗来了"); - } -} -``` - -“输出结果和之前一样。” - -``` -动物来了 -狗狗来了 -``` - -“`super()` 也可以用来调用父类的有参构造方法,这样可以提高代码的可重用性。” - -```java -class Person { - int id; - String name; - - Person(int id, String name) { - this.id = id; - this.name = name; - } -} - -class Emp extends Person { - float salary; - - Emp(int id, String name, float salary) { - super(id, name); - this.salary = salary; - } - - void display() { - System.out.println(id + " " + name + " " + salary); - } -} - -public class CallParentParamConstrutor { - public static void main(String[] args) { - new Emp(1, "沉默王二", 20000f).display(); - } -} -``` - -“Emp 类继承了 Person 类,也就继承了 id 和 name 字段,当在 Emp 中新增了 salary 字段后,构造方法中就可以使用 `super(id, name)` 来调用父类的有参构造方法。” - -“来看一下输出结果。” - -``` -1 沉默王二 20000.0 -``` - -三妹点了点头,所有所思。 - - -## 5.15 Java static关键字 - -“哥,你牙龈肿痛轻点没?周一的《教妹学 Java》(二哥的Java进阶之路前身)你都没有更新,偷懒了呀!”三妹关心地问我。 - -“今天周四了,吃了三天的药,疼痛已经减轻不少,咱妈还给我打了电话,让我买点牛黄解毒片下下火。”我面带着微笑对三妹说,“学习可不能落下,今天我们来学 Java 中 `static` 关键字吧。” - -“static 是 Java 中比较难以理解的一个关键字,也是各大公司的面试官最喜欢问到的一个知识点之一。”我喝了一口咖啡继续说道。 - -“既然是面试重点,那我可得好好学习下。”三妹连忙说。 - -“static 关键字的作用可以用一句话来描述:‘**方便在没有创建对象的情况下进行调用**,包括变量和方法’。也就是说,只要类被加载了,就可以通过类名进行访问。”我扶了扶沉重眼镜,继续说到,“static 可以用来修饰类的成员变量,以及成员方法。我们一个个来看。” - -### 01、静态变量 - -“如果在声明变量的时候使用了 static 关键字,那么这个变量就被称为静态变量。静态变量只在类加载的时候获取一次内存空间,这使得静态变量很节省内存空间。”家里的暖气有点足,我跑去开了一点窗户后继续说道。 - -“来考虑这样一个 Student 类。”话音刚落,我就在键盘上噼里啪啦一阵敲。 - -```java -public class Student { - String name; - int age; - String school = "郑州大学"; -} -``` - -这段代码敲完后,我对三妹说:“假设郑州大学录取了一万名新生,那么在创建一万个 Student 对象的时候,所有的字段(name、age 和 school)都会获取到一块内存。学生的姓名和年纪不尽相同,但都属于郑州大学,如果每创建一个对象,school 这个字段都要占用一块内存的话,就很浪费,对吧?三妹。” - -“因此,最好将 school 这个字段设置为 static,这样就只会占用一块内存,而不是一万块。” - -安静的房子里又响起了一阵噼里啪啦的键盘声。 - -```java -public class Student { - String name; - int age; - static String school = "郑州大学"; - - public Student(String name, int age) { - this.name = name; - this.age = age; - } - - public static void main(String[] args) { - Student s1 = new Student("沉默王二", 18); - Student s2 = new Student("沉默王三", 16); - } -} -``` - -“瞧,三妹。s1 和 s2 这两个引用变量存放在栈区(stack),沉默王二+18 这个对象和沉默王三+16 这个对象存放在堆区(heap),school 这个静态变量存放在静态区。” - -“等等,哥,栈、堆、静态区?”三妹的脸上塞满了疑惑。 - -“哦哦,别担心,三妹,画幅图你就全明白了。”说完我就打开 draw.io 这个网址,认真地画起了图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-01.png) - -“现在,是不是一下子就明白了?”看着这幅漂亮的手绘图,我心里有点小开心。 - -“哇,哥,惊艳了呀!”三妹也不忘拍马屁,给我了一个大大的赞。 - -“好了,三妹,我们来看下面这串代码。” - -```java -public class Counter { - int count = 0; - - Counter() { - count++; - System.out.println(count); - } - - public static void main(String args[]) { - Counter c1 = new Counter(); - Counter c2 = new Counter(); - Counter c3 = new Counter(); - } -} -``` - -“我们创建一个成员变量 count,并且在构造函数中让它自增。因为成员变量会在创建对象的时候获取内存,因此每一个对象都会有一个 count 的副本, count 的值并不会随着对象的增多而递增。” - -我在侃侃而谈,而三妹似乎有些不太明白。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-02.png) - - -“没关系,三妹,你先盲猜一下,这段代码输出的结果是什么?” - -“按照你的逻辑,应该输出三个 1?是这样吗?”三妹眨眨眼,有点不太自信地回答。 - -“哎呀,不错哟。” - -我在 IDEA 中点了一下运行按钮,程序跑了起来。 - -``` -1 -1 -1 -``` - -“每创建一个 Counter 对象,count 的值就从 0 自增到 1。三妹,想一下,如果 count 是静态的呢?” - -“我不知道啊。” - -“嗯,来看下面这段代码。” - -```java -public class StaticCounter { - static int count = 0; - - StaticCounter() { - count++; - System.out.println(count); - } - - public static void main(String args[]) { - StaticCounter c1 = new StaticCounter(); - StaticCounter c2 = new StaticCounter(); - StaticCounter c3 = new StaticCounter(); - } -} -``` - -“来看一下输出结果。” - -``` -1 -2 -3 -``` - -“简单解释一下哈,由于静态变量只会获取一次内存空间,所以任何对象对它的修改都会得到保留,所以每创建一个对象,count 的值就会加 1,所以最终的结果是 3,明白了吧?三妹。这就是静态变量和成员变量之间的差别。” - -“另外,需要注意的是,由于静态变量属于一个类,所以不要通过对象引用来访问,而应该直接通过类名来访问,否则编译器会发出警告。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-03.png) - - -### 02、静态方法 - -“说完静态变量,我们来说静态方法。”说完,我准备点一支华子来抽,三妹阻止了我,她指一指烟盒上的「吸烟有害身体健康」,我笑了。 - -“好吧。”我只好喝了一口咖啡继续说,“如果方法上加了 static 关键字,那么它就是一个静态方法。” - -“静态方法有以下这些特征。” - -- 静态方法属于这个类而不是这个类的对象; -- 调用静态方法的时候不需要创建这个类的对象; -- 静态方法可以访问静态变量。 - -“来,继续上代码” - -```java -public class StaticMethodStudent { - String name; - int age; - static String school = "郑州大学"; - - public StaticMethodStudent(String name, int age) { - this.name = name; - this.age = age; - } - - static void change() { - school = "河南大学"; - } - - void out() { - System.out.println(name + " " + age + " " + school); - } - - public static void main(String[] args) { - StaticMethodStudent.change(); - - StaticMethodStudent s1 = new StaticMethodStudent("沉默王二", 18); - StaticMethodStudent s2 = new StaticMethodStudent("沉默王三", 16); - - s1.out(); - s2.out(); - } -} -``` - -“仔细听,三妹。`change()` 方法就是一个静态方法,所以它可以直接访问静态变量 school,把它的值更改为河南大学;并且,可以通过类名直接调用 `change()` 方法,就像 ` StaticMethodStudent.change()` 这样。” - -“来看一下程序的输出结果吧。” - -``` -沉默王二 18 河南大学 -沉默王三 16 河南大学 -``` - -“需要注意的是,静态方法不能访问非静态变量和调用非静态方法。你看,三妹,我稍微改动一下代码,编译器就会报错。” - -“先是在静态方法中访问非静态变量,编译器不允许。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-04.png) - -“然后在静态方法中访问非静态方法,编译器同样不允许。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-05.png) - -“关于静态方法的使用,这下清楚了吧,三妹?” - -看着三妹点点头,我欣慰地笑了。 - -“哥,我想到了一个问题,为什么 main 方法是静态的啊?”没想到,三妹串联知识点的功力还是不错的。 - -“如果 main 方法不是静态的,就意味着 Java 虚拟机在执行的时候需要先创建一个对象才能调用 main 方法,而 main 方法作为程序的入口,创建一个额外的对象显得非常多余。”我不假思索的回答令三妹感到非常的钦佩。 - -“java.lang.Math 类的几乎所有方法都是静态的,可以直接通过类名来调用,不需要创建类的对象。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-06.png) - -### 03、静态代码块 - -“三妹,站起来活动一下,我的脖子都有点僵硬了。” - -我们一起走到窗户边,映入眼帘的是从天而降的雪花。三妹和我都高兴坏了,迫不及待地打开窗口,伸出手去触摸雪花的温度,那种稍纵即逝的冰凉,真的舒服极了。 - -“北国风光,千里冰封,万里雪飘。望长城内外,惟余莽莽;大河上下,顿失滔滔。山舞银蛇,原驰蜡象,欲与天公试比高。须晴日,看红装素裹,分外妖娆。。。。。。”三妹竟然情不自禁地朗诵起了《沁园春·雪》。 - -确实令人欣喜,这是 2020 年洛阳的第一场雪,的确令人感到开心。 - -片刻之后。 - -“除了静态变量和静态方法,static 关键字还有一个重要的作用。”我心情愉悦地对三妹说,“用一个 static 关键字,外加一个大括号括起来的代码被称为静态代码块。” - -“就像下面这串代码。” - -```java -public class StaticBlock { - static { - System.out.println("静态代码块"); - } - - public static void main(String[] args) { - System.out.println("main 方法"); - } -} -``` - -“静态代码块通常用来初始化一些静态变量,它会优先于 `main()` 方法执行。” - - -“来看一下程序的输出结果吧。” - -``` -静态代码块 -main 方法 -``` - -“二哥,既然静态代码块先于 `main()` 方法执行,那没有 `main()` 方法的 Java 类能执行成功吗?”三妹的脑回路越来越令我敬佩了。 - -“Java 1.6 是可以的,但 Java 7 开始就无法执行了。”我胸有成竹地回答到。 - -```java -public class StaticBlockNoMain { - static { - System.out.println("静态代码块,没有 main"); - } -} -``` - -“在命令行中执行 `java StaticBlockNoMain` 的时候,会抛出 NoClassDefFoundError 的错误。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-07.png) - -“三妹,来看下面这个例子。” - -```java -public class StaticBlockDemo { - public static List writes = new ArrayList<>(); - - static { - writes.add("沉默王二"); - writes.add("沉默王三"); - writes.add("沉默王四"); - - System.out.println("第一块"); - } - - static { - writes.add("沉默王五"); - writes.add("沉默王六"); - - System.out.println("第二块"); - } -} -``` - -“writes 是一个静态的 ArrayList,所以不太可能在声明的时候完成初始化,因此需要在静态代码块中完成初始化。” - -“静态代码块在初始集合的时候,真的非常有用。在实际的项目开发中,通常使用静态代码块来加载配置文件到内存当中。” - -### 04、静态内部类 - -“三妹啊,除了以上只写,static 还有一个不太常用的功能——静态内部类。” - -“Java 允许我们在一个类中声明一个内部类,它提供了一种令人信服的方式,允许我们只在一个地方使用一些变量,使代码更具有条理性和可读性。” - -“常见的内部类有四种,成员内部类、局部内部类、匿名内部类和静态内部类,限于篇幅原因,前三种不在我们本次的讨论范围之内,以后有机会再细说。” - -“来看下面这个例子。”三妹有点走神,我敲了敲她的脑袋后继续说。 - -```java -public class Singleton { - private Singleton() {} - - private static class SingletonHolder { - public static final Singleton instance = new Singleton(); - } - - public static Singleton getInstance() { - return SingletonHolder.instance; - } -} -``` - -“三妹,打起精神,马上就结束了。” - -“哦哦,这段代码看起来很别致啊,哥。” - -“是的,三妹,这段代码在以后创建单例的时候还会见到。” - -“第一次加载 Singleton 类时并不会初始化 instance,只有第一次调用 `getInstance()` 方法时 Java 虚拟机才开始加载 SingletonHolder 并初始化 instance,这样不仅能确保线程安全,也能保证 Singleton 类的唯一性。不过,创建单例更优雅的一种方式是使用枚举,以后再讲给你听。” - -“需要注意的是。第一,静态内部类不能访问外部类的所有成员变量;第二,静态内部类可以访问外部类的所有静态变量,包括私有静态变量。第三,外部类不能声明为 static。” - -“三妹,你看,在 Singleton 类上加 static 后,编译器就提示错误了。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/19-08.png) - -三妹点了点头,所有所思。 - - - -## 5.16 Java final关键字 - -“哥,今天学什么呢?” - -“今天学一个重要的关键字——final。 ”我面带着朴实无华的微笑回答着她,“对了,三妹,你打算考研吗?” - -“还没想过,我今年才大一呢,到时候再说吧,你决定。” - -“好吧。”我摊摊手,表示很无辜,真的是所有的决定都交给我这个哥哥了,如果决定错了,锅得背上。 - -### 01、final 变量 - -“好了,我们先来看 final 修饰的变量吧!” - -“被 final 修饰的变量无法重新赋值。换句话说,final 变量一旦初始化,就无法更改。” - -“来看这行代码。” - -```java -final int age = 18; -``` - -“当尝试将 age 的值修改为 30 的时候,编译器就生气了。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/23-01.png) - -“再来看这段代码。” - -```java -public class Pig { - private String name; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} -``` - -“这是一个很普通的 Java 类,它有一个字段 name。” - -“然后,我们创建一个测试类,并声明一个 final 修饰的 Pig 对象。” - -```java -final Pig pig = new Pig(); -``` - -“如果尝试将 pig 重新赋值的话,编译器同样会生气。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/23-02.png) - -“但我们仍然可以去修改 pig 对象的 name。” - -```java -final Pig pig = new Pig(); -pig.setName("特立独行"); -System.out.println(pig.getName()); // 特立独行 -``` - -“另外,final 修饰的成员变量必须有一个默认值,否则编译器将会提醒没有初始化。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/23-03.png) - -“final 和 static 一起修饰的成员变量叫做常量,常量名必须全部大写。” - -```java -public class Pig { - private final int age = 1; - public static final double PRICE = 36.5; -} -``` - -“有时候,我们还会用 final 关键字来修饰参数,它意味着参数在方法体内不能被再修改。” - -“来看下面这段代码。” - -```java -public class ArgFinalTest { - public void arg(final int age) { - } - - public void arg1(final String name) { - } -} -``` - -“如果尝试去修改它的话,编译器会提示以下错误。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/23-04.png) - -### 02、final 方法 - -“被 final 修饰的方法不能被重写。如果我们在设计一个类的时候,认为某些方法不应该被重写,就应该把它设计成 final 的。” - -“Thread 类就是一个例子,它本身不是 final 的,这意味着我们可以扩展它,但它的 `isAlive()` 方法是 final 的。” - -```java -public class Thread implements Runnable { - public final native boolean isAlive(); -} -``` -“需要注意的是,该方法是一个本地(native)方法,用于确认线程是否处于活跃状态。而本地方法是由操作系统决定的,因此重写该方法并不容易实现。” - -“来看这段代码。” - -```java -public class Actor { - public final void show() { - - } -} -``` - -“当我们想要重写该方法的话,就会出现编译错误。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/23-05.png) - - -“三妹,来问你一个问题吧。”正想趁三妹回答问题的时候喝口水。 - -“你说吧,哥。” - -“一个类是 final 的,和一个类不是 final,但它所有的方法都是 final 的,考虑一下,它们之间有什么区别?” - -“我能想到的一点,就是前者不能被[继承](https://tobebetterjavaer.com/oo/extends-bigsai.html),也就是说方法无法被重写;后者呢,可以被继承,然后追加一些非 final 的方法。”还没等我把水咽下去,三妹就回答好了,着实惊呆了我。 - -“嗯嗯嗯,没毛病没毛病,进步很大啊!” - -“那必须啊,谁叫我是你妹呢。” - -### 03、final 类 - -“如果一个类使用了 final 关键字修饰,那么它就无法被继承.....” - -“等等,哥,我知道,String 类就是一个 final 类。”还没等我说完,三妹就抢着说到。 - -“说得没毛病。” - -```java -public final class String - implements java.io.Serializable, Comparable, CharSequence, - Constable, ConstantDesc {} -``` - -“那三妹你知道为什么 String 类要设计成 final 吗?” - -“这个还真不知道。”三妹的表情透露出这种无奈。 - -“原因大致有 3 个。” - -- 为了实现字符串常量池 -- 为了线程安全 -- 为了 HashCode 的不可变性 - -“想了解更详细的原因,可以一会看看我之前写的这篇文章。” - -[为什么 Java 字符串是不可变的?](https://tobebetterjavaer.com/string/immutable.html) - -“任何尝试从 final 类继承的行为将会引发编译错误。来看这段代码。” - -```java -public final class Writer { - private String name; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} -``` - -“尝试去继承它,编译器会提示以下错误,Writer 类是 final 的,无法继承。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/keywords/23-06.png) - -“不过,类是 final 的,并不意味着该类的对象是不可变的。” - -“来看这段代码。” - -```java -Writer writer = new Writer(); -writer.setName("沉默王二"); -System.out.println(writer.getName()); // 沉默王二 -``` - -“Writer 的 name 字段的默认值是 null,但可以通过 settter 方法将其更改为沉默王二。也就是说,如果一个类只是 final 的,那么它并不是不可变的全部条件。” - -“关于不可变类,我们留到后面来细讲。” - -[不可变类](https://tobebetterjavaer.com/basic-extra-meal/immutable.html) - -“把一个类设计成 final 的,有其安全方面的考虑,但不应该故意为之,因为把一个类定义成 final 的,意味着它没办法继承,假如这个类的一些方法存在一些问题的话,我们就无法通过重写的方式去修复它。” - -“三妹,final 关键字我们就学到这里吧,你一会再学习一下 Java 字符串为什么是不可变的和不可变类。”我揉一揉犯困的双眼,疲惫地给三妹说,“学完这两个知识点,你会对 final 的认知更清晰一些。” - -“好的,二哥,我这就去学习去。你去休息会。” - -我起身站到阳台上,看着窗外的车水马龙,不一会儿,就发起来呆。 - -“好想去再看一场周杰伦的演唱会,不知道 2021 有没有这个机会。” - -我心里这样想着,天渐渐地暗了下来。 - - - -## 5.17 Java instanceof关键字 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[5.17 Java instanceof关键字](https://tobebetterjavaer.com/basic-extra-meal/instanceof.html) - -## 5.18 Java不可变对象 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[5.18 Java不可变对象](https://tobebetterjavaer.com/basic-extra-meal/immutable.html) - - - -## 5.19 Java方法重写和方法重载 - -入冬的夜,总是来得特别的早。我静静地站在阳台,目光所及之处,不过是若隐若现的钢筋混凝土,还有那毫无情调的灯光。 - -“哥,别站在那发呆了。今天学啥啊,七点半我就要回学校了,留给你的时间不多了,你要抓紧哦。”三妹傲娇的声音一下子把我从游离的状态拉回到了现实。 - -“今天要学习 Java 中的方法重载与方法重写。”我迅速地走到电脑前面,打开一份 Excel 文档,看了一下《教妹学 Java(二哥的 Java 进阶之路前身)》的进度,然后对三妹说。 - -“如果一个类有多个名字相同但参数个数不同的方法,我们通常称这些方法为方法重载。 ”我面带着朴实无华的微笑继续说,“如果方法的功能是一样的,但参数不同,使用相同的名字可以提高程序的可读性。” - -“如果子类具有和父类一样的方法(参数相同、返回类型相同、方法名相同,但方法体可能不同),我们称之为方法重写。 方法重写用于提供父类已经声明的方法的特殊实现,是实现多态的基础条件。” - -“只不过,方法重载与方法重写在名字上很相似,就像是兄弟俩,导致初学者经常把它们俩搞混。” - -“方法重载的英文名叫 Overloading,方法重写的英文名叫 Overriding,因此,不仅中文名很相近,英文名之间也很相近,这就更容易让初学者搞混了。” - -“但两者其实是完全不同的!通过下面这张图,你就能看得一清二楚。” - -话音刚落,我就在 IDEA 中噼里啪啦地敲了起来。两段代码,分别是方法重写和方法重载。然后,把这两段代码截图到 draw.io(一个很漂亮的在线画图网站)上,加了一些文字说明。最后,打开 Photoscape X,把两张图片合并到了一起。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/21-01.png) - -### 01、方法重载 - -“三妹,你仔细听哦。”我缓了一口气后继续说道。 - -“在 Java 中,有两种方式可以达到方法重载的目的。” - -“第一,改变参数的数目。来看下面这段代码。” - -```java -public class OverloadingByParamNum { - public static void main(String[] args) { - System.out.println(Adder.add(10, 19)); - System.out.println(Adder.add(10, 19, 20)); - } -} - -class Adder { - static int add(int a, int b) { - return a + b; - } - - static int add(int a, int b, int c) { - return a + b + c; - } -} -``` - -“Adder 类有两个方法,第一个 `add()` 方法有两个参数,在调用的时候可以传递两个参数;第二个 `add()` 方法有三个参数,在调用的时候可以传递三个参数。” - -“二哥,这样的代码不会显得啰嗦吗?如果有四个参数的时候就再追加一个方法?”三妹突然提了一个很尖锐的问题。 - -“那倒是,这个例子只是为了说明方法重载的一种类型。如果参数类型相同的话,Java 提供了可变参数的方式,就像下面这样。” - -```java -static int add(int ... args) { - int sum = 0; - for ( int a: args) { - sum += a; - } - return sum; -} -``` - -“第二,通过改变参数类型,也可以达到方法重载的目的。来看下面这段代码。” - -```java -public class OverloadingByParamType { - public static void main(String[] args) { - System.out.println(Adder.add(10, 19)); - System.out.println(Adder.add(10.1, 19.2)); - } -} - -class Adder { - static int add(int a, int b) { - return a + b; - } - - static double add(double a, double b) { - return a + b; - } -} -``` - -“Adder 类有两个方法,第一个 `add()` 方法的参数类型为 int,第二个 `add()` 方法的参数类型为 double。” - -“二哥,改变参数的数目和类型都可以实现方法重载,为什么改变方法的返回值类型就不可以呢?”三妹很能抓住问题的重点嘛。 - -“因为仅仅改变返回值类型的话,会把编译器搞懵逼的。”我略带调皮的口吻回答她。 - -“编译时报错优于运行时报错,所以当两个方法的名字相同,参数个数和类型也相同的时候,虽然返回值类型不同,但依然会提示方法已经被定义的错误。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/21-02.png) - -“你想啊,三妹。我们在调用一个方法的时候,可以指定返回值类型,也可以不指定。当不指定的时候,直接指定 `add(1, 2)` 的时候,编译器就不知道该调用返回 int 的 `add()` 方法还是返回 double 的 `add()` 方法,产生了歧义。” - -“方法的返回值只是作为方法运行后的一个状态,它是保持方法的调用者和被调用者进行通信的一个纽带,但并不能作为某个方法的‘标识’。” - -“二哥,我想到了一个点,`main()` 方法可以重载吗?” - -“三妹,这是个好问题啊!答案是肯定的,毕竟 `main()` 方法也是个方法,只不过,Java 虚拟机在运行的时候只会调用带有 String 数组的那个 `main()` 方法。” - -```java -public class OverloadingMain { - public static void main(String[] args) { - System.out.println("String[] args"); - } - - public static void main(String args) { - System.out.println("String args"); - } - - public static void main() { - System.out.println("无参"); - } -} -``` - -“第一个 `main()` 方法的参数形式为 `String[] args`,是最标准的写法;第二个 `main()` 方法的参数形式为 `String args`,少了中括号;第三个 `main()` 方法没有参数。” - -“来看一下程序的输出结果。” - -``` -String[] args -``` - -“从结果中,我们可以看得出,尽管 `main()` 方法可以重载,但程序只认标准写法。” - -“由于可以通过改变参数类型的方式实现方法重载,那么当传递的参数没有找到匹配的方法时,就会发生隐式的类型转换。” - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-extra-meal/override-overload-0d30f41f-1f53-4988-b506-731d79ed16d1.png) - -“如上图所示,byte 可以向上转换为 short、int、long、float 和 double,short 可以向上转换为 int、long、float 和 double,char 可以向上转换为 int、long、float 和 double,依次类推。” - -“三妹,来看下面这个示例。” - -```java -public class OverloadingTypePromotion { - void sum(int a, long b) { - System.out.println(a + b); - } - - void sum(int a, int b, int c) { - System.out.println(a + b + c); - } - - public static void main(String args[]) { - OverloadingTypePromotion obj = new OverloadingTypePromotion(); - obj.sum(20, 20); - obj.sum(20, 20, 20); - } -} -``` - -“执行 `obj.sum(20, 20)` 的时候,发现没有 `sum(int a, int b)` 的方法,所以此时第二个 20 向上转型为 long,所以调用的是 `sum(int a, long b)` 的方法。” - -“再来看一个示例。” - -```java -public class OverloadingTypePromotion1 { - void sum(int a, int b) { - System.out.println("int"); - } - - void sum(long a, long b) { - System.out.println("long"); - } - - public static void main(String args[]) { - OverloadingTypePromotion1 obj = new OverloadingTypePromotion1(); - obj.sum(20, 20); - } -} -``` - -“执行 `obj.sum(20, 20)` 的时候,发现有 `sum(int a, int b)` 的方法,所以就不会向上转型为 long,调用 `sum(long a, long b)`。” - -“来看一下程序的输出结果。” - -``` -int -``` - -“继续来看示例。” - -```java -public class OverloadingTypePromotion2 { - void sum(long a, int b) { - System.out.println("long int"); - } - - void sum(int a, long b) { - System.out.println("int long"); - } - - public static void main(String args[]) { - OverloadingTypePromotion2 obj = new OverloadingTypePromotion2(); - obj.sum(20, 20); - } -} -``` - -“二哥,我又想到一个问题。当有两个方法 `sum(long a, int b)` 和 `sum(int a, long b)`,参数个数相同,参数类型相同,只不过位置不同的时候,会发生什么呢?” - -“当通过 `obj.sum(20, 20)` 来调用 sum 方法的时候,编译器会提示错误。” -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/21-04.png) - -“不明确,编译器会很为难,究竟是把第一个 20 从 int 转成 long 呢,还是把第二个 20 从 int 转成 long,智障了!所以,不能写这样让编译器左右为难的代码。” - -### 02、方法重写 - -“三妹,累吗?我们稍微休息一下吧。”我把眼镜摘下来,放到桌子上,闭上了眼睛,开始胡思乱想起来。 - -2000 年,周杰伦横空出世,让青黄不接的唱片行业为之一振,由此开启了新一代天王争霸的黄金时代。2020 年,杰伦胖了,也贪玩了,一年出一张单曲都变得可遇不可求。 - -20 年前,程序员很稀有;20 年后,程序员内卷了。时间永远不会停下脚步,明年会不会好起来呢? - -“哥,醒醒,你就说休息一会,没说睡着啊。赶紧,我还有半个小时就要走了。” - -我戴上眼镜,对三妹继续说道:“在 Java 中,方法重写需要满足以下三个规则。” - -- 重写的方法必须和父类中的方法有着相同的名字; -- 重写的方法必须和父类中的方法有着相同的参数; -- 必须是 is-a 的关系(继承关系)。 - -“来看下面这段代码。” - -```java -public class Bike extends Vehicle { - public static void main(String[] args) { - Bike bike = new Bike(); - bike.run(); - } -} - -class Vehicle { - void run() { - System.out.println("车辆在跑"); - } -} -``` - -“来看一下程序的输出结果。” - -``` -车辆在跑 -``` - -“Bike is-a Vehicle,自行车是一种车,没错。Vehicle 类有一个 `run()` 的方法,也就是说车辆可以跑,Bike 继承了 Vehicle,也可以跑。但如果 Bike 没有重写 `run()` 方法的话,自行车就只能是‘车辆在跑’,而不是‘自行车在跑’,对吧?” - -“如果有了方法重写,一切就好办了。” - -```java -public class Bike extends Vehicle { - @Override - void run() { - System.out.println("自行车在跑"); - } - - public static void main(String[] args) { - Bike bike = new Bike(); - bike.run(); - } -} - -class Vehicle { - void run() { - System.out.println("车辆在跑"); - } -} -``` - -我把鼠标移动到 Bike 类的 `run()` 方法,对三妹说:“你看,在方法重写的时候,IDEA 会建议使用 `@Override` 注解,显式的表示这是一个重写后的方法,尽管可以缺省。” - -“来看一下程序的输出结果。” - -``` -自行车在跑 -``` - -“Bike 重写了 `run()` 方法,也就意味着,Bike 可以跑出自己的风格。” - -好,接下来说一下重写时应当遵守的 12 条规则,应当谨记哦。 - -#### **规则一:只能重写继承过来的方法**。 - -因为重写是在子类重新实现从父类[继承](https://tobebetterjavaer.com/oo/extends-bigsai.html)过来的方法时发生的,所以只能重写继承过来的方法,这很好理解。这就意味着,只能重写那些被 public、protected 或者 default 修饰的方法,private 修饰的方法无法被重写。 - -Animal 类有 `move()`、`eat()` 和 `sleep()` 三个方法: - -```java -public class Animal { - public void move() { } - - protected void eat() { } - - void sleep(){ } -} -``` - -Dog 类来重写这三个方法: - -```java -public class Dog extends Animal { - public void move() { } - - protected void eat() { } - - void sleep(){ } -} -``` - -OK,完全没有问题。但如果父类中的方法是 private 的,就行不通了。 - -```java -public class Animal { - private void move() { } -} -``` - -此时,Dog 类中的 `move()` 方法就不再是一个重写方法了,因为父类的 `move()` 方法是 private 的,对子类并不可见。 - -```java -public class Dog extends Animal { - public void move() { } -} -``` - -#### **规则二:final、static 的方法不能被重写**。 - -一个方法是 [final](https://tobebetterjavaer.com/oo/final.html) 的就意味着它无法被子类继承到,所以就没办法重写。 - -```java -public class Animal { - final void move() { } -} -``` - -由于父类 Animal 中的 `move()` 是 final 的,所以子类在尝试重写该方法的时候就出现编译错误了! - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-extra-meal/Overriding-2.png) - -同样的,如果一个方法是 [static](https://tobebetterjavaer.com/oo/static.html) 的,也不允许重写,因为静态方法可用于父类以及子类的所有实例。 - -```java -public class Animal { - final void move() { } -} -``` - -重写的目的在于根据对象的类型不同而表现出多态,而静态方法不需要创建对象就可以使用。没有了对象,重写所需要的“对象的类型”也就没有存在的意义了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-extra-meal/Overriding-3.png) - -#### **规则三:重写的方法必须有相同的参数列表**。 - -```java -public class Animal { - void eat(String food) { } -} -``` - -Dog 类中的 `eat()` 方法保持了父类方法 `eat()` 的同一个调调,都有一个参数——String 类型的 food。 - -```java -public class Dog extends Animal { - public void eat(String food) { } -} -``` - -一旦子类没有按照这个规则来,比如说增加了一个参数: - -```java -public class Dog extends Animal { - public void eat(String food, int amount) { } -} -``` - -这就不再是重写的范畴了,当然也不是重载的范畴,因为重载考虑的是同一个类。 - -**规则四:重写的方法必须返回相同的类型**。 - -父类没有返回类型: - -```java -public class Animal { - void eat(String food) { } -} -``` - -子类尝试返回 String: - -```java -public class Dog extends Animal { - public String eat(String food) { - return null; - } -} -``` - -于是就编译出错了(返回类型不兼容)。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-extra-meal/Overriding-4.png) - -#### **规则五:重写的方法不能使用限制等级更严格的权限修饰符**。 - -可以这样来理解: - -- 如果被重写的方法是 default,那么重写的方法可以是 default、protected 或者 public。 -- 如果被重写的方法是 protected,那么重写的方法只能是 protected 或者 public。 -- 如果被重写的方法是 public, 那么重写的方法就只能是 public。 - -举个例子,父类中的方法是 protected: - -```java -public class Animal { - protected void eat() { } -} -``` - -子类中的方法可以是 public: - -```java -public class Dog extends Animal { - public void eat() { } -} -``` - -如果子类中的方法用了更严格的权限修饰符,编译器就报错了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/basic-extra-meal/Overriding-5.png) - -#### **规则六:重写后的方法不能抛出比父类中更高级别的异常**。 - -举例来说,如果父类中的方法抛出的是 IOException,那么子类中重写的方法不能抛出 Exception,可以是 IOException 的子类或者不抛出任何[异常](https://tobebetterjavaer.com/exception/gailan.html)。这条规则只适用于可检查的异常。 - -可检查(checked)异常必须在源代码中显式地进行捕获处理,不检查(unchecked)异常就是所谓的运行时异常,比如说 NullPointerException、ArrayIndexOutOfBoundsException 之类的,不会在编译器强制要求。 - -父类抛出 IOException: - -```java -public class Animal { - protected void eat() throws IOException { } -} -``` - -子类抛出 FileNotFoundException 是可以满足重写的规则的,因为 FileNotFoundException 是 IOException 的子类。 - -```java -public class Dog extends Animal { - public void eat() throws FileNotFoundException { } -} -``` - -如果子类抛出了一个新的异常,并且是一个 checked 异常: - -```java -public class Dog extends Animal { - public void eat() throws FileNotFoundException, InterruptedException { } -} -``` - -那编译器就会提示错误: - -``` -Error:(9, 16) java: com.itwanger.overriding.Dog中的eat()无法覆盖com.itwanger.overriding.Animal中的eat() - 被覆盖的方法未抛出java.lang.InterruptedException -``` - -但如果子类抛出的是一个 unchecked 异常,那就没有冲突: - -```java -public class Dog extends Animal { - public void eat() throws FileNotFoundException, IllegalArgumentException { } -} -``` - -如果子类抛出的是一个更高级别的异常: - -```java -public class Dog extends Animal { - public void eat() throws Exception { } -} -``` - -编译器同样会提示错误,因为 Exception 是 IOException 的父类。 - -``` -Error:(9, 16) java: com.itwanger.overriding.Dog中的eat()无法覆盖com.itwanger.overriding.Animal中的eat() - 被覆盖的方法未抛出java.lang.Exception -``` - -#### **规则七:可以在子类中通过 super 关键字来调用父类中被重写的方法**。 - -子类继承父类的方法而不是重新实现是很常见的一种做法,在这种情况下,可以按照下面的形式调用父类的方法: - -```java -super.overriddenMethodName(); -``` - -来看例子。 - -```java -public class Animal { - protected void eat() { } -} -``` - -子类重写了 `eat()` 方法,然后在子类的 `eat()` 方法中,可以在方法体的第一行通过 `super.eat()` 调用父类的方法,然后再增加属于自己的代码。 - -```java -public class Dog extends Animal { - public void eat() { - super.eat(); - // Dog-eat - } -} -``` - -#### **规则八:构造方法不能被重写**。 - -因为[构造方法](https://tobebetterjavaer.com/oo/construct.html)很特殊,而且子类的构造方法不能和父类的构造方法同名(类名不同),所以构造方法和重写之间没有任何关系。 - -#### **规则九:如果一个类继承了抽象类,抽象类中的抽象方法必须在子类中被重写**。 - -先来看这样一个接口: - -```java -public interface Animal { - void move(); -} -``` - -接口中的方法默认都是抽象方法,通过反编译是可以看得到的: - -```java -public interface Animal -{ - public abstract void move(); -} -``` - -如果一个抽象类实现了 Animal 接口,`move()` 方法不是必须被重写的: - -```java -public abstract class AbstractDog implements Animal { - protected abstract void bark(); -} -``` - -但如果一个类继承了抽象类 AbstractDog,那么 Animal 接口中的 `move()` 方法和抽象类 AbstractDog 中的抽象方法 `bark()` 都必须被重写: - -```java -public class BullDog extends AbstractDog { - - public void move() {} - - protected void bark() {} -} -``` - -#### **规则十:synchronized 关键字对重写规则没有任何影响**。 - -[synchronized 关键字](https://tobebetterjavaer.com/thread/synchronized-1.html)用于在多线程环境中获取和释放监听对象,因此它对重写规则没有任何影响,这就意味着 synchronized 方法可以去重写一个非同步方法。 - -#### **规则十一:strictfp 关键字对重写规则没有任何影响**。 - -如果你想让浮点运算更加精确,而且不会因为硬件平台的不同导致执行的结果不一致的话,可以在方法上添加 [strictfp 关键字,之前讲过](https://tobebetterjavaer.com/basic-extra-meal/48-keywords.html)。因此 strictfp 关键字和重写规则无关。 - -### 03、总结 - -“好了,三妹,我来简单做个总结。”我瞥了一眼电脑右上角的时钟,离三妹离开的时间不到 10 分钟了。 - -“首先来说一下方法重载时的注意事项,‘两同一不同’。” - -“‘两同’:在同一个类,方法名相同。” - -“‘一不同’:参数不同。” - -“再来说一下方法重写时的注意事项,‘两同一小一大’。” - -“‘两同’:方法名相同,参数相同。” - -“‘一小’:子类方法声明的异常类型要比父类小一些或者相等。” - -“‘一大’:子类方法的访问权限应该比父类的更大或者相等。” - -“记住了吧?三妹。带上口罩,拿好手机,咱准备出门吧。”今天限号,没法开车送三妹去学校了。 - - - - -## 5.20 Java注解 - -“二哥,这节讲注解吗?”三妹问。 - -“是的。”我说,“注解是 Java 中非常重要的一部分,但经常被忽视也是真的。之所以这么说是因为我们更倾向成为一名注解的使用者而不是创建者。`@Override` 注解用过吧?[方法重写](https://tobebetterjavaer.com/basic-extra-meal/override-overload.html)的时候用到过。但你知道怎么自定义一个注解吗?” - -三妹毫不犹豫地摇摇头,摆摆手,不好意思地承认自己的确没有自定义过。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/annotation/annotation-01.png) - -“好吧,哥来告诉你吧。” - -注解(Annotation)是在 Java 1.5 时引入的概念,同 class 和 interface 一样,也属于一种类型。注解提供了一系列数据用来装饰程序代码(类、方法、字段等),但是注解并不是所装饰代码的一部分,它对代码的运行效果没有直接影响,由编译器决定该执行哪些操作。 - -来看一段代码。 - -```java -public class AutowiredTest { - @Autowired - private String name; - - public static void main(String[] args) { - System.out.println("沉默王二,一枚有趣的程序员"); - } -} -``` - -注意到 `@Autowired` 这个注解了吧?它本来是为 Spring(后面会讲)容器注入 Bean 的,现在被我无情地扔在了字段 name 的身上,但这段代码所在的项目中并没有启用 Spring,意味着 `@Autowired` 注解此时只是一个摆设。 - -“既然只是个摆设,那你这个地方为什么还要用 `@Autowired` 呢?”三妹好奇地问。 - -“傻呀你,就是给你举个例子,证明:注解对代码的运行效果没有直接影响,明白我的用意了吧?”我毫不客气地说。 - -“哦。”三妹若有所思地说。 - -“认真听哈,接下来给你讲讲注解的生命周期。”我瞅了瞅三妹,看她是否在专注的听,然后继续说,“注解的生命周期有 3 种策略,定义在 RetentionPolicy 枚举中。” - -1)SOURCE:在源文件中有效,被编译器丢弃。 - -2)CLASS:在编译器生成的字节码文件中有效,但在运行时会被处理类文件的 JVM 丢弃。 - -3)RUNTIME:在运行时有效。这也是注解生命周期中最常用的一种策略,它允许程序通过反射的方式访问注解,并根据注解的定义执行相应的代码。 - -“然后我们来讲注解装饰的目标。”我看三妹还在线,就继续说。 - -注解的目标定义了注解将适用于哪一种级别的 Java 代码上,有些注解只适用于方法,有些只适用于成员变量,有些只适用于类,有些则都适用。截止到 Java 9,注解的类型一共有 11 种,定义在 ElementType 枚举中。 - -1)TYPE:用于类、接口、注解、枚举 - -2)FIELD:用于字段(类的成员变量),或者枚举常量 - -3)METHOD:用于方法 - -4)PARAMETER:用于普通方法或者构造方法的参数 - -5)CONSTRUCTOR:用于构造方法 - -6)LOCAL_VARIABLE:用于变量 - -7)ANNOTATION_TYPE:用于注解 - -8)PACKAGE:用于包 - -9)TYPE_PARAMETER:用于泛型参数 - -10)TYPE_USE:用于声明语句、泛型或者强制转换语句中的类型 - -11)MODULE:用于模块 - -“哥,你将这些我都记不住,能不能直接开撸注解呀!!!!!”三妹不耐烦了。 - -“确实哈,说再多,都不如撸个注解来得让人心动。撸个什么样的注解呢?一个字段注解吧,它用来标记对象在序列化成 JSON 的时候要不要包含这个字段。”我笑着对三妹说,“怎么样?” - -“好呀!” - -“来看下面这段代码。” - -```java -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) -public @interface JsonField { - public String value() default ""; -} -``` - -1)JsonField 注解的生命周期是 RUNTIME,也就是运行时有效。 - -2)JsonField 注解装饰的目标是 FIELD,也就是针对字段的。 - -3)创建注解需要用到 `@interface` 关键字。 - -4)JsonField 注解有一个参数,名字为 value,类型为 String,默认值为一个空字符串。 - -“为什么参数名要为 value 呢?有什么特殊的含义吗?”三妹问。 - -“当然是有的,value 允许注解的使用者提供一个无需指定名字的参数。举个例子,我们可以在一个字段上使用 `@JsonField(value = "沉默王二")`,也可以把 `value =` 省略,变成 `@JsonField("沉默王二")`。”我说。 - - - - - -“那 `default ""` 有什么特殊含义吗?”三妹继续问。 - -“当然也是有的,它允许我们在一个字段上直接使用 `@JsonField`,而无需指定参数的名和值。”我回答说。 - - -“明白了,那 `@JsonField` 注解已经撸好了,是不是可以使用它了呀?”三妹激动地说。 - -“嗯,假设有一个 Writer 类,他有 3 个字段,分别是 age、name 和 bookName,后 2 个是必须序列化的字段。就可以这样来用 `@JsonField` 注解。”我说。 - -```java -public class Writer { - private int age; - - @JsonField("writerName") - private String name; - - @JsonField - private String bookName; - - public Writer(int age, String name, String bookName) { - this.age = age; - this.name = name; - this.bookName = bookName; - } - - // getter / setter - - @Override - public String toString() { - return "Writer{" + - "age=" + age + - ", name='" + name + '\'' + - ", bookName='" + bookName + '\'' + - '}'; - } -} -``` - -1)name 上的 `@JsonField` 注解提供了显式的字符串值。 - -2)bookName 上的 `@JsonField` 注解使用了缺省项。 - -接下来,我们来编写序列化类 JsonSerializer,内容如下: - -```java -public class JsonSerializer { - public static String serialize(Object object) throws IllegalAccessException { - Class objectClass = object.getClass(); - Map jsonElements = new HashMap<>(); - for (Field field : objectClass.getDeclaredFields()) { - field.setAccessible(true); - if (field.isAnnotationPresent(JsonField.class)) { - jsonElements.put(getSerializedKey(field), (String) field.get(object)); - } - } - return toJsonString(jsonElements); - } - - private static String getSerializedKey(Field field) { - String annotationValue = field.getAnnotation(JsonField.class).value(); - if (annotationValue.isEmpty()) { - return field.getName(); - } else { - return annotationValue; - } - } - - private static String toJsonString(Map jsonMap) { - String elementsString = jsonMap.entrySet() - .stream() - .map(entry -> "\"" + entry.getKey() + "\":\"" + entry.getValue() + "\"") - .collect(Collectors.joining(",")); - return "{" + elementsString + "}"; - } -} -``` - -“JsonSerializer 类的内容看起来似乎有点多啊,二哥,我有点看不懂。”三妹说。 - -“不要怕,我一点点来解释,直到你搞明白为止。” - -1)`serialize()` 方法是用来序列化对象的,它接收一个 Object 类型的参数。`objectClass.getDeclaredFields()` 通过反射的方式获取对象声明的所有字段,然后进行 for 循环遍历。在 for 循环中,先通过 `field.setAccessible(true)` 将反射对象的可访问性设置为 true,供序列化使用(如果没有这个步骤的话,private 字段是无法获取的,会抛出 IllegalAccessException 异常);再通过 `isAnnotationPresent()` 判断字段是否装饰了 `JsonField` 注解,如果是的话,调用 `getSerializedKey()` 方法,以及获取该对象上由此字段表示的值,并放入 jsonElements 中。 - -2)`getSerializedKey()` 方法用来获取字段上注解的值,如果注解的值是空的,则返回字段名。 - -3)`toJsonString()` 方法借助 Stream 流的方式返回格式化后的 JSON 字符串。Stream 流你还没有接触过,不过没关系,后面我再给你讲。 - -“现在是不是豁然开朗了?”我问三妹,看到三妹点了点头,我继续说,“接下来,我们来写一个测试类 JsonFieldTest。” - -```java -public class JsonFieldTest { - public static void main(String[] args) throws IllegalAccessException { - Writer cmower = new Writer(18,"沉默王二","Web全栈开发进阶之路"); - System.out.println(JsonSerializer.serialize(cmower)); - } -} -``` - -程序输出结果如下: - -``` -{"bookName":"Web全栈开发进阶之路","writerName":"沉默王二"} -``` - -从结果上来看: - -1)Writer 类的 age 字段没有装饰 `@JsonField` 注解,所以没有序列化。 - -2)Writer 类的 name 字段装饰了 `@JsonField` 注解,并且显示指定了字符串“writerName”,所以序列化后变成了 writerName。 - -3)Writer 类的 bookName 字段装饰了 `@JsonField` 注解,但没有显式指定值,所以序列化后仍然是 bookName。 - -“怎么样,三妹,是不是也不是特别难?”我对三妹说。 - -“撸个注解好像真没什么难度,但你接下来的那个 JsonSerializer 我还需要再消化一下。”三妹很认真地说。 - -“嗯,你好好复习下,我看会《编译原理》。”说完我拿起桌子边上的一本书就走了。 - - - - -## 5.21 Java枚举(enum) - -“今天我们来学习枚举吧,三妹!”我说,“同学让你去她家玩了两天,感觉怎么样呀?” - -“心情放松了不少。”三妹说,“可以开始学 Java 了,二哥。” - -“OK。” - -“枚举(enum),是 Java 1.5 时引入的关键字,它表示一种特殊类型的类,继承自 java.lang.Enum。” - -“我们来新建一个枚举 PlayerType。” - -```java -public enum PlayerType { - TENNIS, - FOOTBALL, - BASKETBALL -} -``` - -“二哥,我没看到有继承关系呀!” - -“别着急,看一下反编译后的字节码,你就明白了。” - -```java -public final class PlayerType extends Enum -{ - - public static PlayerType[] values() - { - return (PlayerType[])$VALUES.clone(); - } - - public static PlayerType valueOf(String name) - { - return (PlayerType)Enum.valueOf(com/cmower/baeldung/enum1/PlayerType, name); - } - - private PlayerType(String s, int i) - { - super(s, i); - } - - public static final PlayerType TENNIS; - public static final PlayerType FOOTBALL; - public static final PlayerType BASKETBALL; - private static final PlayerType $VALUES[]; - - static - { - TENNIS = new PlayerType("TENNIS", 0); - FOOTBALL = new PlayerType("FOOTBALL", 1); - BASKETBALL = new PlayerType("BASKETBALL", 2); - $VALUES = (new PlayerType[] { - TENNIS, FOOTBALL, BASKETBALL - }); - } -} -``` - -“看到没?Java 编译器帮我们做了很多隐式的工作,不然手写一个枚举就没那么省心省事了。” - -- 要继承 Enum 类; -- 要写构造方法; -- 要声明静态变量和数组; -- 要用 static 块来初始化静态变量和数组; -- 要提供静态方法,比如说 `values()` 和 `valueOf(String name)`。 - -“确实,作为开发者,我们的代码量减少了,枚举看起来简洁明了。”三妹说。 - -“既然枚举是一种特殊的类,那它其实是可以定义在一个类的内部的,这样它的作用域就可以限定于这个外部类中使用。”我说。 - -```java -public class Player { - private PlayerType type; - public enum PlayerType { - TENNIS, - FOOTBALL, - BASKETBALL - } - - public boolean isBasketballPlayer() { - return getType() == PlayerType.BASKETBALL; - } - - public PlayerType getType() { - return type; - } - - public void setType(PlayerType type) { - this.type = type; - } -} -``` - -PlayerType 就相当于 Player 的内部类。 - -由于枚举是 final 的,所以可以确保在 Java 虚拟机中仅有一个常量对象,基于这个原因,我们可以使用“==”运算符来比较两个枚举是否相等,参照 `isBasketballPlayer()` 方法。 - -“那为什么不使用 `equals()` 方法判断呢?”三妹问。 - -```java -if(player.getType().equals(Player.PlayerType.BASKETBALL)){}; -``` - -“我来给你解释下。” - -“==”运算符比较的时候,如果两个对象都为 null,并不会发生 `NullPointerException`,而 `equals()` 方法则会。 - -另外, “==”运算符会在编译时进行检查,如果两侧的类型不匹配,会提示错误,而 `equals()` 方法则不会。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/enum/enum-01.png) - -“枚举还可用于 switch 语句,和基本数据类型的用法一致。”我说。 - -```java -switch (playerType) { - case TENNIS: - return "网球运动员费德勒"; - case FOOTBALL: - return "足球运动员C罗"; - case BASKETBALL: - return "篮球运动员詹姆斯"; - case UNKNOWN: - throw new IllegalArgumentException("未知"); - default: - throw new IllegalArgumentException( - "运动员类型: " + playerType); - -} -``` - -“如果枚举中需要包含更多信息的话,可以为其添加一些字段,比如下面示例中的 name,此时需要为枚举添加一个带参的构造方法,这样就可以在定义枚举时添加对应的名称了。”我继续说。 - -```java -public enum PlayerType { - TENNIS("网球"), - FOOTBALL("足球"), - BASKETBALL("篮球"); - - private String name; - - PlayerType(String name) { - this.name = name; - } -} -``` - -“get 了吧,三妹?” - -“嗯,比较好理解。” - -“那接下来,我就来说点不一样的。” - -“来吧,我准备好了。” - -“EnumSet 是一个专门针对枚举类型的 [Set 接口](https://tobebetterjavaer.com/collection/gailan.html)(后面会讲)的实现类,它是处理枚举类型数据的一把利器,非常高效。”我说,“从名字上就可以看得出,EnumSet 不仅和 Set 有关系,和枚举也有关系。” - -“因为 EnumSet 是一个抽象类,所以创建 EnumSet 时不能使用 new 关键字。不过,EnumSet 提供了很多有用的静态工厂方法。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/enum/enum-02.png) - -“来看下面这个例子,我们使用 `noneOf()` 静态工厂方法创建了一个空的 PlayerType 类型的 EnumSet;使用 `allOf()` 静态工厂方法创建了一个包含所有 PlayerType 类型的 EnumSet。” - -```java -public class EnumSetTest { - public enum PlayerType { - TENNIS, - FOOTBALL, - BASKETBALL - } - - public static void main(String[] args) { - EnumSet enumSetNone = EnumSet.noneOf(PlayerType.class); - System.out.println(enumSetNone); - - EnumSet enumSetAll = EnumSet.allOf(PlayerType.class); - System.out.println(enumSetAll); - } -} -``` - -“来看一下输出结果。” - -```java -[] -[TENNIS, FOOTBALL, BASKETBALL] -``` - -有了 EnumSet 后,就可以使用 Set 的一些方法了,见下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/enum/enum-03.png) - -“除了 EnumSet,还有 EnumMap,是一个专门针对枚举类型的 Map 接口的实现类,它可以将枚举常量作为键来使用。EnumMap 的效率比 HashMap 还要高,可以直接通过数组下标(枚举的 ordinal 值)访问到元素。” - -“和 EnumSet 不同,EnumMap 不是一个抽象类,所以创建 EnumMap 时可以使用 new 关键字。” - -```java -EnumMap enumMap = new EnumMap<>(PlayerType.class); -``` - -有了 EnumMap 对象后就可以使用 Map 的一些方法了,见下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/enum/enum-04.png) - -和 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html)(后面会讲)的使用方法大致相同,来看下面的例子。 - -```java -EnumMap enumMap = new EnumMap<>(PlayerType.class); -enumMap.put(PlayerType.BASKETBALL,"篮球运动员"); -enumMap.put(PlayerType.FOOTBALL,"足球运动员"); -enumMap.put(PlayerType.TENNIS,"网球运动员"); -System.out.println(enumMap); - -System.out.println(enumMap.get(PlayerType.BASKETBALL)); -System.out.println(enumMap.containsKey(PlayerType.BASKETBALL)); -System.out.println(enumMap.remove(PlayerType.BASKETBALL)); -``` - -“来看一下输出结果。” - -``` -{TENNIS=网球运动员, FOOTBALL=足球运动员, BASKETBALL=篮球运动员} -篮球运动员 -true -篮球运动员 -``` - -“除了以上这些,《Effective Java》这本书里还提到了一点,如果要实现单例的话,最好使用枚举的方式。”我说。 - -“等等二哥,单例是什么?”三妹没等我往下说,就连忙问道。 - -“单例(Singleton)用来保证一个类仅有一个对象,并提供一个访问它的全局访问点,在一个进程中。因为这个类只有一个对象,所以就不能再使用 `new` 关键字来创建新的对象了。” - -“Java 标准库有一些类就是单例,比如说 Runtime 这个类。” - -```java -Runtime runtime = Runtime.getRuntime(); -``` - -“Runtime 类可以用来获取 Java 程序运行时的环境。” - -“关于单例,懂了些吧?”我问三妹。 - -“噢噢噢噢。”三妹点了点头。 - -“通常情况下,实现单例并非易事,来看下面这种写法。” - -```java -public class Singleton { - private volatile static Singleton singleton; - private Singleton (){} - public static Singleton getSingleton() { - if (singleton == null) { - synchronized (Singleton.class) { - if (singleton == null) { - singleton = new Singleton(); - } - } - } - return singleton; - } -} -``` - -“要用到 [volatile](https://tobebetterjavaer.com/thread/volatile.html)、[synchronized](https://tobebetterjavaer.com/thread/synchronized-1.html) 关键字等等,但枚举的出现,让代码量减少到极致。” - -```java -public enum EasySingleton{ - INSTANCE; -} -``` - -“就这?”三妹睁大了眼睛。 - -“对啊,枚举默认实现了 [Serializable 接口](https://tobebetterjavaer.com/io/Serializbale.html),因此 Java 虚拟机可以保证该类为单例,这与传统的实现方式不大相同。传统方式中,我们必须确保单例在反序列化期间不能创建任何新实例。”我说。 - -“好了,关于枚举就讲这么多吧,三妹,你把这些代码都手敲一遍吧!” - -“好勒,这就安排。二哥,你去休息吧。” - -“嗯嗯。”讲了这么多,必须跑去抽烟机那里安排一根华子了。 - ---- - -GitHub 上标星 7600+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 7600+ 的 Java 教程](https://tobebetterjavaer.com/overview/) - -微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - -# 第六章:Java 集合框架 - - - -## 6.1 List、Set、Map、队列,全面解析 - -眼瞅着三妹的王者荣耀杀得正嗨,我趁机喊到:“别打了,三妹,我们来一起学习 Java 的集合框架吧。” - -“才不要呢,等我打完这一局啊。”三妹倔强地说。 - -“好吧。”我只好摊摊手地说,“那我先画张集合框架的结构图等着你。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/gailan-01.png) - - -“完了没?三妹。” - -“完了好一会儿了,二哥,你图画得真慢,让我瞧瞧怎么样?” - -“害,图要画得清晰明了,不容易的。三妹,你瞧,不错吧。” - -“哇,果然很棒,哥,你可真认真!” - -“我来简单介绍一下吧,Java 集合框架可以分为两条大的支线:” - -- Collection,主要由 List、Set、Queue 组成,List 代表有序、可重复的集合,典型代表就是封装了动态数组的 ArrayList 和封装了链表的 LinkedList;Set 代表无序、不可重复的集合,典型代表就是 HashSet 和 TreeSet;Queue 代表队列,典型代表就是双端队列 ArrayDeque,以及优先级队列 PriorityQueue。 -- Map,代表键值对的集合,典型代表就是 HashMap。 - -### 01、List - -List 的特点是存取有序,可以存放重复的元素,可以用下标对元素进行操作。 - -#### **1)ArrayList** - -先来一段 ArrayList 的增删改查,学会用。 - -```java -// 创建一个集合 -ArrayList list = new ArrayList(); -// 添加元素 -list.add("王二"); -list.add("沉默"); -list.add("陈清扬"); - -// 遍历集合 for 循环 -for (int i = 0; i < list.size(); i++) { - String s = list.get(i); - System.out.println(s); -} -// 遍历集合 for each -for (String s : list) { - System.out.println(s); -} - -// 删除元素 -list.remove(1); -// 遍历集合 -for (String s : list) { - System.out.println(s); -} - -// 修改元素 -list.set(1, "王二狗"); -// 遍历集合 -for (String s : list) { - System.out.println(s); -} -``` - -简单介绍一下 ArrayList 的特征,[后面还会详细讲](https://tobebetterjavaer.com/collection/arraylist.html)。 - -- ArrayList 是由数组实现的,支持随机存取,也就是可以通过下标直接存取元素; -- 从尾部插入和删除元素会比较快捷,从中间插入和删除元素会比较低效,因为涉及到数组元素的复制和移动; -- 如果内部数组的容量不足时会自动扩容,因此当元素非常庞大的时候,效率会比较低。 - -#### **2)LinkedList** - -同样先来一段 LinkedList 的增删改查,和 ArrayList 几乎没什么差别。 - -```java -// 创建一个集合 -LinkedList list = new LinkedList(); -// 添加元素 -list.add("王二"); -list.add("沉默"); -list.add("陈清扬"); - -// 遍历集合 for 循环 -for (int i = 0; i < list.size(); i++) { - String s = list.get(i); - System.out.println(s); -} -// 遍历集合 for each -for (String s : list) { - System.out.println(s); -} - -// 删除元素 -list.remove(1); -// 遍历集合 -for (String s : list) { - System.out.println(s); -} - -// 修改元素 -list.set(1, "王二狗"); -// 遍历集合 -for (String s : list) { - System.out.println(s); -} -``` - -不过,LinkedList 和 ArrayList 仍然有较大的不同,[后面也会详细地讲](https://tobebetterjavaer.com/collection/linkedlist.html)。 - -- LinkedList 是由双向链表实现的,不支持随机存取,只能从一端开始遍历,直到找到需要的元素后返回; -- 任意位置插入和删除元素都很方便,因为只需要改变前一个节点和后一个节点的引用即可,不像 ArrayList 那样需要复制和移动数组元素; -- 因为每个元素都存储了前一个和后一个节点的引用,所以相对来说,占用的内存空间会比 ArrayList 多一些。 - -#### **3)Vector 和 Stack** - -List 的实现类还有一个 Vector,是一个元老级的类,比 ArrayList 出现得更早。ArrayList 和 Vector 非常相似,只不过 Vector 是线程安全的,像 get、set、add 这些方法都加了 `synchronized` 关键字,就导致执行执行效率会比较低,所以现在已经很少用了。 - -我就不写太多代码了,只看一下 add 方法的源码就明白了。 - -```java -public synchronized boolean add(E e) { - elementData[elementCount++] = e; - return true; -} -``` - -这种加了同步方法的类,注定会被淘汰掉,就像[StringBuilder取代StringBuffer](https://tobebetterjavaer.com/string/builder-buffer.html)那样。JDK 源码也说了: - ->如果不需要线程安全,建议使用ArrayList代替Vector。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection//gailan-20bfd65a-1f1d-4de7-a3e3-3dda739e6f9d.png) - -Stack 是 Vector 的一个子类,本质上也是由动态数组实现的,只不过还实现了先进后出的功能(在 get、set、add 方法的基础上追加了 pop「返回并移除栈顶的元素」、peek「只返回栈顶元素」等方法),所以叫栈。 - -下面是这两个方法的源码,增删改查我就不写了,和 ArrayList 和 LinkedList 几乎一样。 - -```java -public synchronized E pop() { - E obj; - int len = size(); - - obj = peek(); - removeElementAt(len - 1); - - return obj; -} - -public synchronized E peek() { - int len = size(); - - if (len == 0) - throw new EmptyStackException(); - return elementAt(len - 1); -} -``` - -不过,由于 Stack 执行效率比较低(方法上同样加了 synchronized 关键字),就被双端队列 ArrayDeque 取代了(下面会介绍)。 - -### 02、Set - -Set 的特点是存取无序,不可以存放重复的元素,不可以用下标对元素进行操作,和 List 有很多不同。 - -#### **1)HashSet** - -HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。来简单看一下它的源码。 - -```java -public class HashSet - extends AbstractSet - implements Set, Cloneable, java.io.Serializable -{ - private transient HashMap map; - - // Dummy value to associate with an Object in the backing Map - private static final Object PRESENT = new Object(); - - public HashSet() { - map = new HashMap<>(); - } - - public boolean add(E e) { - return map.put(e, PRESENT)==null; - } - - public boolean remove(Object o) { - return map.remove(o)==PRESENT; - } -} -``` - -实际开发中,HashSet 并不常用,比如,如果我们需要按照顺序存储一组元素,那么ArrayList和LinkedList可能更适合;如果我们需要存储键值对并根据键进行查找,那么HashMap可能更适合。 - -当然,在某些情况下,HashSet仍然是最好的选择。例如,当我们需要快速查找一个元素是否存在于某个集合中,并且我们不需要对元素进行排序时,HashSet可以提供高效的性能。 - -来一段增删改查体验一下: - -```java -// 创建一个新的HashSet -HashSet set = new HashSet<>(); - -// 添加元素 -set.add("沉默"); -set.add("王二"); -set.add("陈清扬"); - -// 输出HashSet的元素个数 -System.out.println("HashSet size: " + set.size()); // output: 3 - -// 判断元素是否存在于HashSet中 -boolean containsWanger = set.contains("王二"); -System.out.println("Does set contain '王二'? " + containsWanger); // output: true - -// 删除元素 -boolean removeWanger = set.remove("王二"); -System.out.println("Removed '王二'? " + removeWanger); // output: true - -// 修改元素,需要先删除后添加 -boolean removeChenmo = set.remove("沉默"); -boolean addBuChenmo = set.add("不沉默"); -System.out.println("Modified set? " + (removeChenmo && addBuChenmo)); // output: true - -// 输出修改后的HashSet -System.out.println("HashSet after modification: " + set); // output: [陈清扬, 不沉默] -``` - - -#### **2)LinkedHashSet** - -LinkedHashSet 虽然继承自 HashSet,其实是由 [LinkedHashMap](https://tobebetterjavaer.com/collection/linkedhashmap.html) 实现的。 - -这是 LinkedHashSet 的无参构造方法: - -```java -public LinkedHashSet() { - super(16, .75f, true); -} -``` - -[super](https://tobebetterjavaer.com/oo/this-super.html) 的意思是它将调用父类的 HashSet 的一个有参构造方法: - -```java -HashSet(int initialCapacity, float loadFactor, boolean dummy) { - map = new LinkedHashMap<>(initialCapacity, loadFactor); -} -``` - -看到 [LinkedHashMap](https://tobebetterjavaer.com/collection/linkedhashmap.html) 了吧,这个我们后面会去讲。 - -好吧,来看一段 LinkedHashSet 的增删改查吧。 - -```java -LinkedHashSet set = new LinkedHashSet<>(); - -// 添加元素 -set.add("沉默"); -set.add("王二"); -set.add("陈清扬"); - -// 删除元素 -set.remove("王二"); - -// 修改元素 -set.remove("沉默"); -set.add("沉默的力量"); - -// 查找元素 -boolean hasChenQingYang = set.contains("陈清扬"); -System.out.println("set包含陈清扬吗?" + hasChenQingYang); -``` - -在以上代码中,我们首先创建了一个LinkedHashSet对象,然后使用add方法依次添加了三个元素:沉默、王二和陈清扬。接着,我们使用remove方法删除了王二这个元素,并使用remove和add方法修改了沉默这个元素。最后,我们使用contains方法查找了陈清扬这个元素是否存在于set中,并打印了结果。 - -LinkedHashSet是一种基于哈希表实现的Set接口,它继承自HashSet,并且使用链表维护了元素的插入顺序。因此,它既具有HashSet的快速查找、插入和删除操作的优点,又可以维护元素的插入顺序。 - -#### **3)TreeSet** - -“二哥,不用你讲了,我能猜到,TreeSet 是由 [TreeMap(后面会讲)](https://tobebetterjavaer.com/collection/treemap.html) 实现的,只不过同样操作的键位,值由一个固定的 Object 对象填充。” - -哇,三妹都学会了推理。 - -是的,与 TreeMap 相似,TreeSet 是一种基于红黑树实现的有序集合,它实现了 SortedSet 接口,可以自动对集合中的元素进行排序。按照键的自然顺序或指定的比较器顺序进行排序。 - -```java -// 创建一个 TreeSet 对象 -TreeSet set = new TreeSet<>(); - -// 添加元素 -set.add("沉默"); -set.add("王二"); -set.add("陈清扬"); -System.out.println(set); // 输出 [沉默, 王二, 陈清扬] - -// 删除元素 -set.remove("王二"); -System.out.println(set); // 输出 [沉默, 陈清扬] - -// 修改元素:TreeSet 中的元素不支持直接修改,需要先删除再添加 -set.remove("陈清扬"); -set.add("陈青阳"); -System.out.println(set); // 输出 [沉默, 陈青阳] - -// 查找元素 -System.out.println(set.contains("沉默")); // 输出 true -System.out.println(set.contains("王二")); // 输出 false -``` - -需要注意的是,TreeSet 不允许插入 null 元素,否则会抛出 NullPointerException 异常。 - -“总体上来说,Set 集合不是关注的重点,因为底层都是由 Map 实现的,为什么要用 Map 实现呢?三妹你能猜到原因吗?” - -“让我想想。” - -“嗯?难道是因为 Map 的键不允许重复、无序吗?” - -老天,竟然被三妹猜到了。 - -“是的,你这水平长进了呀,三妹。” - -### 03、Queue - -Queue,也就是队列,通常遵循先进先出(FIFO)的原则,新元素插入到队列的尾部,访问元素返回队列的头部。 - -#### **1)ArrayDeque** - -从名字上可以看得出,ArrayDeque 是一个基于数组实现的双端队列,为了满足可以同时在数组两端插入或删除元素的需求,数组必须是循环的,也就是说数组的任何一点都可以被看作是起点或者终点。 - -这是一个包含了 4 个元素的双端队列,和一个包含了 5 个元素的双端队列。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/gailan-02.png) - -head 指向队首的第一个有效的元素,tail 指向队尾第一个可以插入元素的空位,因为是循环数组,所以 head 不一定从是从 0 开始,tail 也不一定总是比 head 大。 - -来一段ArrayDeque的增删改查吧。 - -```java -// 创建一个ArrayDeque -ArrayDeque deque = new ArrayDeque<>(); - -// 添加元素 -deque.add("沉默"); -deque.add("王二"); -deque.add("陈清扬"); - -// 删除元素 -deque.remove("王二"); - -// 修改元素 -deque.remove("沉默"); -deque.add("沉默的力量"); - -// 查找元素 -boolean hasChenQingYang = deque.contains("陈清扬"); -System.out.println("deque包含陈清扬吗?" + hasChenQingYang); -``` - -#### **2)LinkedList** - -LinkedList 一般应该归在 List 下,只不过,它也实现了 Deque 接口,可以作为队列来使用。等于说,LinkedList 同时实现了 Stack、Queue、PriorityQueue 的所有功能。 - -```java -public class LinkedList - extends AbstractSequentialList - implements List, Deque, Cloneable, java.io.Serializable -{} -``` - -换句话说,LinkedList 和 ArrayDeque 都是 Java 集合框架中的双向队列(deque),它们都支持在队列的两端进行元素的插入和删除操作。不过,LinkedList 和 ArrayDeque 在实现上有一些不同: - -- 底层实现方式不同:LinkedList 是基于链表实现的,而 ArrayDeque 是基于数组实现的。 -- 随机访问的效率不同:由于底层实现方式的不同,LinkedList 对于随机访问的效率较低,时间复杂度为 O(n),而 ArrayDeque 可以通过下标随机访问元素,时间复杂度为 O(1)。 -- 迭代器的效率不同:LinkedList 对于迭代器的效率比较低,因为需要通过链表进行遍历,时间复杂度为 O(n),而 ArrayDeque 的迭代器效率比较高,因为可以直接访问数组中的元素,时间复杂度为 O(1)。 -- 内存占用不同:由于 LinkedList 是基于链表实现的,它在存储元素时需要额外的空间来存储链表节点,因此内存占用相对较高,而 ArrayDeque 是基于数组实现的,内存占用相对较低。 - -因此,在选择使用 LinkedList 还是 ArrayDeque 时,需要根据具体的业务场景和需求来选择。如果需要在双向队列的两端进行频繁的插入和删除操作,并且需要随机访问元素,可以考虑使用 ArrayDeque;如果需要在队列中间进行频繁的插入和删除操作,可以考虑使用 LinkedList。 - -来一段 LinkedList 作为队列时候的增删改查吧,注意和它作为 List 的时候有很大的不同。 - -```java -// 创建一个 LinkedList 对象 -LinkedList queue = new LinkedList<>(); - -// 添加元素 -queue.offer("沉默"); -queue.offer("王二"); -queue.offer("陈清扬"); -System.out.println(queue); // 输出 [沉默, 王二, 陈清扬] - -// 删除元素 -queue.poll(); -System.out.println(queue); // 输出 [王二, 陈清扬] - -// 修改元素:LinkedList 中的元素不支持直接修改,需要先删除再添加 -String first = queue.poll(); -queue.offer("王大二"); -System.out.println(queue); // 输出 [陈清扬, 王大二] - -// 查找元素:LinkedList 中的元素可以使用 get() 方法进行查找 -System.out.println(queue.get(0)); // 输出 陈清扬 -System.out.println(queue.contains("沉默")); // 输出 false - -// 查找元素:使用迭代器的方式查找陈清扬 -// 使用迭代器依次遍历元素并查找 -Iterator iterator = queue.iterator(); -while (iterator.hasNext()) { - String element = iterator.next(); - if (element.equals("陈清扬")) { - System.out.println("找到了:" + element); - break; - } -} -``` - -在使用 LinkedList 作为队列时,可以使用 offer() 方法将元素添加到队列的末尾,使用 poll() 方法从队列的头部删除元素。另外,由于 LinkedList 是链表结构,不支持随机访问元素,因此不能使用下标访问元素,需要使用迭代器或者 poll() 方法依次遍历元素。 - - -#### **3)PriorityQueue** - -PriorityQueue 是一种优先级队列,它的出队顺序与元素的优先级有关,执行 remove 或者 poll 方法,返回的总是优先级最高的元素。 - -```java -// 创建一个 PriorityQueue 对象 -PriorityQueue queue = new PriorityQueue<>(); - -// 添加元素 -queue.offer("沉默"); -queue.offer("王二"); -queue.offer("陈清扬"); -System.out.println(queue); // 输出 [沉默, 王二, 陈清扬] - -// 删除元素 -queue.poll(); -System.out.println(queue); // 输出 [王二, 陈清扬] - -// 修改元素:PriorityQueue 不支持直接修改元素,需要先删除再添加 -String first = queue.poll(); -queue.offer("张三"); -System.out.println(queue); // 输出 [张三, 陈清扬] - -// 查找元素:PriorityQueue 不支持随机访问元素,只能访问队首元素 -System.out.println(queue.peek()); // 输出 张三 -System.out.println(queue.contains("陈清扬")); // 输出 true - -// 通过 for 循环的方式查找陈清扬 -for (String element : queue) { - if (element.equals("陈清扬")) { - System.out.println("找到了:" + element); - break; - } -} -``` - -要想有优先级,元素就需要实现 [Comparable 接口或者 Comparator 接口](https://tobebetterjavaer.com/basic-extra-meal/comparable-omparator.html)(我们后面会讲)。 - -这里先来一段通过实现 Comparator 接口按照年龄姓名排序的优先级队列吧。 - -```java -import java.util.Comparator; -import java.util.PriorityQueue; - -class Student { - private String name; - private int chineseScore; - private int mathScore; - - public Student(String name, int chineseScore, int mathScore) { - this.name = name; - this.chineseScore = chineseScore; - this.mathScore = mathScore; - } - - public String getName() { - return name; - } - - public int getChineseScore() { - return chineseScore; - } - - public int getMathScore() { - return mathScore; - } - - @Override - public String toString() { - return "Student{" + - "name='" + name + '\'' + - ", 总成绩=" + (chineseScore + mathScore) + - '}'; - } -} - -class StudentComparator implements Comparator { - @Override - public int compare(Student s1, Student s2) { - // 比较总成绩 - return Integer.compare(s2.getChineseScore() + s2.getMathScore(), - s1.getChineseScore() + s1.getMathScore()); - } -} - -public class PriorityQueueComparatorExample { - - public static void main(String[] args) { - // 创建一个按照总成绩排序的优先级队列 - PriorityQueue queue = new PriorityQueue<>(new StudentComparator()); - - // 添加元素 - queue.offer(new Student("王二", 80, 90)); - System.out.println(queue); - queue.offer(new Student("陈清扬", 95, 95)); - System.out.println(queue); - queue.offer(new Student("小驼铃", 90, 95)); - System.out.println(queue); - queue.offer(new Student("沉默", 90, 80)); - while (!queue.isEmpty()) { - System.out.print(queue.poll() + " "); - } - } -} -``` - -Student 是一个学生对象,包含姓名、语文成绩和数学成绩。 - -StudentComparator 实现了 Comparator 接口,对总成绩做了一个排序。 - -PriorityQueue 是一个优先级队列,参数为 StudentComparator,然后我们添加了 4 个学生对象进去。 - -来看一下输出结果: - -``` -[Student{name='王二', 总成绩=170}] -[Student{name='陈清扬', 总成绩=190}, Student{name='王二', 总成绩=170}] -[Student{name='陈清扬', 总成绩=190}, Student{name='王二', 总成绩=170}, Student{name='小驼铃', 总成绩=185}] -Student{name='陈清扬', 总成绩=190} Student{name='小驼铃', 总成绩=185} Student{name='沉默', 总成绩=170} Student{name='王二', 总成绩=170} -``` - -我们使用 offer 方法添加元素,最后用 while 循环遍历元素(通过 poll 方法取出元素),从结果可以看得出,[PriorityQueue](https://tobebetterjavaer.com/collection/PriorityQueue.html)按照学生的总成绩由高到低进行了排序。 - - -### 04、Map - -Map 保存的是键值对,键要求保持唯一性,值可以重复。 - -#### **1)HashMap** - -HashMap 实现了 Map 接口,可以根据键快速地查找对应的值——通过哈希函数将键映射到哈希表中的一个索引位置,从而实现快速访问。[后面会详细聊到](https://tobebetterjavaer.com/collection/hashmap.html)。 - -这里先大致了解一下 HashMap 的特点: - -- HashMap 中的键和值都可以为 null。如果键为 null,则将该键映射到哈希表的第一个位置。 -- 可以使用迭代器或者 forEach 方法遍历 HashMap 中的键值对。 -- HashMap 有一个初始容量和一个负载因子。初始容量是指哈希表的初始大小,负载因子是指哈希表在扩容之前可以存储的键值对数量与哈希表大小的比率。默认的初始容量是 16,负载因子是 0.75。 - -来个简单的增删改查吧。 - -```java -// 创建一个 HashMap 对象 -HashMap hashMap = new HashMap<>(); - -// 添加键值对 -hashMap.put("沉默", "cenzhong"); -hashMap.put("王二", "wanger"); -hashMap.put("陈清扬", "chenqingyang"); - -// 获取指定键的值 -String value1 = hashMap.get("沉默"); -System.out.println("沉默对应的值为:" + value1); - -// 修改键对应的值 -hashMap.put("沉默", "chenmo"); -String value2 = hashMap.get("沉默"); -System.out.println("修改后沉默对应的值为:" + value2); - -// 删除指定键的键值对 -hashMap.remove("王二"); - -// 遍历 HashMap -for (String key : hashMap.keySet()) { - String value = hashMap.get(key); - System.out.println(key + " 对应的值为:" + value); -} -``` - -#### **2)LinkedHashMap** - -HashMap 已经非常强大了,但它是无序的。如果我们需要一个有序的Map,就要用到 [LinkedHashMap](https://tobebetterjavaer.com/collection/linkedhashmap.html)。LinkedHashMap 是 HashMap 的子类,它使用链表来记录插入/访问元素的顺序。 - -LinkedHashMap 可以看作是 HashMap + LinkedList 的合体,它使用了哈希表来存储数据,又用了双向链表来维持顺序。 - -来一个简单的例子。 - -```java -// 创建一个 LinkedHashMap,插入的键值对为 沉默 王二 陈清扬 -LinkedHashMap linkedHashMap = new LinkedHashMap<>(); -linkedHashMap.put("沉默", "cenzhong"); -linkedHashMap.put("王二", "wanger"); -linkedHashMap.put("陈清扬", "chenqingyang"); - -// 遍历 LinkedHashMap -for (String key : linkedHashMap.keySet()) { - String value = linkedHashMap.get(key); - System.out.println(key + " 对应的值为:" + value); -} -``` - -来看输出结果: - -``` -沉默 对应的值为:cenzhong -王二 对应的值为:wanger -陈清扬 对应的值为:chenqingyang -``` - -从结果中可以看得出来,LinkedHashMap 维持了键值对的插入顺序,对吧?为了和 LinkedHashMap 做对比,我们用同样的数据试验一下 HashMap。 - -```java -// 创建一个HashMap,插入的键值对为 沉默 王二 陈清扬 -HashMap hashMap = new HashMap<>(); -hashMap.put("沉默", "cenzhong"); -hashMap.put("王二", "wanger"); -hashMap.put("陈清扬", "chenqingyang"); - -// 遍历 HashMap -for (String key : hashMap.keySet()) { - String value = hashMap.get(key); - System.out.println(key + " 对应的值为:" + value); -} -``` - -来看输出结果: - -``` -沉默 对应的值为:cenzhong -陈清扬 对应的值为:chenqingyang -王二 对应的值为:wanger -``` - -HashMap 没有维持键值对的插入顺序,对吧? - -#### **3)TreeMap** - -[TreeMap](https://tobebetterjavaer.com/collection/treemap.html) 实现了 SortedMap 接口,可以自动将键按照自然顺序或指定的比较器顺序排序,并保证其元素的顺序。内部使用红黑树来实现键的排序和查找。 - -同样来一个增删改查的 demo: - -```java -// 创建一个 TreeMap 对象 -Map treeMap = new TreeMap<>(); - -// 向 TreeMap 中添加键值对 -treeMap.put("沉默", "cenzhong"); -treeMap.put("王二", "wanger"); -treeMap.put("陈清扬", "chenqingyang"); - -// 查找键值对 -String name = "沉默"; -if (treeMap.containsKey(name)) { - System.out.println("找到了 " + name + ": " + treeMap.get(name)); -} else { - System.out.println("没有找到 " + name); -} - -// 修改键值对 -name = "王二"; -if (treeMap.containsKey(name)) { - System.out.println("修改前的 " + name + ": " + treeMap.get(name)); - treeMap.put(name, "newWanger"); - System.out.println("修改后的 " + name + ": " + treeMap.get(name)); -} else { - System.out.println("没有找到 " + name); -} - -// 删除键值对 -name = "陈清扬"; -if (treeMap.containsKey(name)) { - System.out.println("删除前的 " + name + ": " + treeMap.get(name)); - treeMap.remove(name); - System.out.println("删除后的 " + name + ": " + treeMap.get(name)); -} else { - System.out.println("没有找到 " + name); -} - -// 遍历 TreeMap -for (Map.Entry entry : treeMap.entrySet()) { - System.out.println(entry.getKey() + ": " + entry.getValue()); -} -``` - -与 HashMap 不同的是,TreeMap 会按照键的顺序来进行排序。 - -```java -// 创建一个 TreeMap 对象 -Map treeMap = new TreeMap<>(); - -// 向 TreeMap 中添加键值对 -treeMap.put("c", "cat"); -treeMap.put("a", "apple"); -treeMap.put("b", "banana"); - -// 遍历 TreeMap -for (Map.Entry entry : treeMap.entrySet()) { - System.out.println(entry.getKey() + ": " + entry.getValue()); -} -``` - -来看输出结果: - -``` -a: apple -b: banana -c: cat -``` - -默认情况下,已经按照键的自然顺序排过了。 - -“好了,三妹,关于集合框架,我们就先聊到这,随后我们会针对常用的容器进行详细地讲解,比如说 ArrayList、LinkedList、HashMap 等。” - -“哇,二哥,这篇讲的东西可真不少,虽然都是比较基础的,但对于我一个小白来说,还是需要花点时间去消化的。”三妹嘟嘟嘴说到。 - - - -## 6.2 时间复杂度,了解下 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[6.2 时间复杂度,了解下](https://tobebetterjavaer.com/collection/time-complexity.html) - - - -## 6.3 ArrayList详解(附源码) - -“二哥,听说今天我们开讲 ArrayList 了?好期待哦!”三妹明知故问,这个托配合得依然天衣无缝。 - -“是的呀,三妹。”我肯定地点了点头,继续说道,“ArrayList 可以称得上是集合框架方面最常用的类了,可以和 HashMap 一较高下。” - -从名字就可以看得出来,ArrayList 实现了 List 接口,并且是基于数组实现的。 - -数组的大小是固定的,一旦创建的时候指定了大小,就不能再调整了。也就是说,如果数组满了,就不能再添加任何元素了。ArrayList 在数组的基础上实现了自动扩容,并且提供了比数组更丰富的预定义方法(各种增删改查),非常灵活。 - -Java 这门编程语言和别的编程语言,比如说 C语言的不同之处就在这里,如果是 C语言的话,你就必须得动手实现自己的 ArrayList,原生的库函数里面是没有的。 - -### 01、创建 ArrayList - -“二哥,**如何创建一个 ArrayList 啊**?”三妹问。 - -```java -ArrayList alist = new ArrayList(); -``` - -可以通过上面的语句来创建一个字符串类型的 ArrayList(通过尖括号来限定 ArrayList 中元素的类型,如果尝试添加其他类型的元素,将会产生编译错误),更简化的写法如下: - -```java -List alist = new ArrayList<>(); -``` - -由于 ArrayList 实现了 List 接口,所以 alist 变量的类型可以是 List 类型;new 关键字声明后的尖括号中可以不再指定元素的类型,因为编译器可以通过前面尖括号中的类型进行智能推断。 - -此时会调用无参构造方法(见下面的代码)创建一个空的数组,常量DEFAULTCAPACITY_EMPTY_ELEMENTDATA的值为 `{}`。 - -```java -public ArrayList() { - this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; -} -``` - -如果非常确定 ArrayList 中元素的个数,在创建的时候还可以指定初始大小。 - -```java -List alist = new ArrayList<>(20); -``` - -这样做的好处是,可以有效地避免在添加新的元素时进行不必要的扩容。 - -### 02、向 ArrayList 中添加元素 - -“二哥,**那怎么向 ArrayList 中添加一个元素呢**?”三妹继续问。 - -可以通过 `add()` 方法向 ArrayList 中添加一个元素。 - -```java -alist.add("沉默王二"); -``` - -我们来跟一下源码,看看 add 方法到底执行了哪些操作。跟的过程中,我们也可以偷师到 Java 源码的作者(大师级程序员)是如何优雅地写代码的。 - -我先给个结论,全当抛砖引玉。 - -``` -堆栈过程图示: -add(element) -└── if (size == elementData.length) // 判断是否需要扩容 - ├── grow(minCapacity) // 扩容 - │ └── newCapacity = oldCapacity + (oldCapacity >> 1) // 计算新的数组容量 - │ └── Arrays.copyOf(elementData, newCapacity) // 创建新的数组 - ├── elementData[size++] = element; // 添加新元素 - └── return true; // 添加成功 -``` - -来具体看一下,先是 `add()` 方法的源码(已添加好详细地注释) - -```java -/** - * 将指定元素添加到 ArrayList 的末尾 - * @param e 要添加的元素 - * @return 添加成功返回 true - */ -public boolean add(E e) { - ensureCapacityInternal(size + 1); // 确保 ArrayList 能够容纳新的元素 - elementData[size++] = e; // 在 ArrayList 的末尾添加指定元素 - return true; -} -``` - -参数 e 为要添加的元素,此时的值为“沉默王二”,size 为 ArrayList 的长度,此时为 0。 - -继续跟下去,来看看 `ensureCapacityInternal()`方法: - -```java -/** - * 确保 ArrayList 能够容纳指定容量的元素 - * @param minCapacity 指定容量的最小值 - */ -private void ensureCapacityInternal(int minCapacity) { - if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 如果 elementData 还是默认的空数组 - minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); // 使用 DEFAULT_CAPACITY 和指定容量的最小值中的较大值 - } - - ensureExplicitCapacity(minCapacity); // 确保容量能够容纳指定容量的元素 -} -``` - -此时: - -- 参数 minCapacity 为 1(size+1 传过来的) -- elementData 为存放 ArrayList 元素的底层数组,前面声明 ArrayList 的时候讲过了,此时为空 `{}` -- DEFAULTCAPACITY_EMPTY_ELEMENTDATA 前面也讲过了,为 `{}` - -所以,if 条件此时为 true,if 语句`minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity)`要执行。 - -DEFAULT_CAPACITY 为 10(见下面的代码),所以执行完这行代码后,minCapacity 为 10,`Math.max()` 方法的作用是取两个当中最大的那个。 - -```java -private static final int DEFAULT_CAPACITY = 10; -``` - -接下来执行 `ensureExplicitCapacity()` 方法,来看一下源码: - -```java -/** - * 检查并确保集合容量足够,如果需要则增加集合容量。 - * - * @param minCapacity 所需最小容量 - */ -private void ensureExplicitCapacity(int minCapacity) { - // 检查是否超出了数组范围,确保不会溢出 - if (minCapacity - elementData.length > 0) - // 如果需要增加容量,则调用 grow 方法 - grow(minCapacity); -} -``` - -此时: - -- 参数 minCapacity 为 10 -- elementData.length 为 0(数组为空) - -所以 10-0>0,if 条件为 true,进入 if 语句执行 `grow()` 方法,来看源码: - -```java -/** - * 扩容 ArrayList 的方法,确保能够容纳指定容量的元素 - * @param minCapacity 指定容量的最小值 - */ -private void grow(int minCapacity) { - // 检查是否会导致溢出,oldCapacity 为当前数组长度 - int oldCapacity = elementData.length; - int newCapacity = oldCapacity + (oldCapacity >> 1); // 扩容至原来的1.5倍 - if (newCapacity - minCapacity < 0) // 如果还是小于指定容量的最小值 - newCapacity = minCapacity; // 直接扩容至指定容量的最小值 - if (newCapacity - MAX_ARRAY_SIZE > 0) // 如果超出了数组的最大长度 - newCapacity = hugeCapacity(minCapacity); // 扩容至数组的最大长度 - // 将当前数组复制到一个新数组中,长度为 newCapacity - elementData = Arrays.copyOf(elementData, newCapacity); -} -``` - -此时: - -- 参数 minCapacity 为 10 -- 变量 oldCapacity 为 0 - -所以 newCapacity 也为 0,于是 `newCapacity - minCapacity` 等于 -10 小于 0,于是第一个 if 条件为 true,执行第一个 if 语句 `newCapacity = minCapacity`,然后 newCapacity 为 10。 - -紧接着执行 `elementData = Arrays.copyOf(elementData, newCapacity);`,也就是进行数组的第一次扩容,长度为 10。 - -回到 `add()` 方法: - -```java -public boolean add(E e) { - ensureCapacityInternal(size + 1); - elementData[size++] = e; - return true; -} -``` - -执行 `elementData[size++] = e`。 - -此时: - -- size 为 0 -- e 为 “沉默王二” - -所以数组的第一个元素(下标为 0) 被赋值为“沉默王二”,接着返回 true,第一次 add 方法执行完毕。 - -PS:add 过程中会遇到一个令新手感到困惑的右移操作符 `>>`,借这个机会来解释一下。 - -ArrayList 在第一次执行 add 后会扩容为 10,那 ArrayList 第二次扩容发生在什么时候呢? - -答案是添加第 11 个元素时,大家可以尝试分析一下这个过程。 - -### 03、右移操作符 - -“oldCapacity 等于 10,`oldCapacity >> 1` 这个表达式等于多少呢?三妹你知道吗?”我问三妹。 - -“不知道啊,`>>` 是什么意思呢?”三妹很疑惑。 - -“`>>` 是右移运算符,`oldCapacity >> 1` 相当于 oldCapacity 除以 2。”我给三妹解释道,“在计算机内部,都是按照二进制存储的,10 的二进制就是 1010,也就是 `0*2^0 + 1*2^1 + 0*2^2 + 1*2^3`=0+2+0+8=10 。。。。。。” - -还没等我解释完,三妹就打断了我,“二哥,能再详细解释一下到底为什么吗?” - -“当然可以啊。”我拍着胸脯对三妹说。 - -先从位权的含义说起吧。 - -平常我们使用的是十进制数,比如说 39,并不是简单的 3 和 9,3 表示的是 `3*10 = 30`,9 表示的是 `9*1 = 9`,和 3 相乘的 10,和 9 相乘的 1,就是**位权**。位数不同,位权就不同,第 1 位是 10 的 0 次方(也就是 `10^0=1`),第 2 位是 10 的 1 次方(`10^1=10`),第 3 位是 10 的 2 次方(`10^2=100`),最右边的是第一位,依次类推。 - -位权这个概念同样适用于二进制,第 1 位是 2 的 0 次方(也就是 `2^0=1`),第 2 位是 2 的 1 次方(`2^1=2`),第 3 位是 2 的 2 次方(`2^2=4`),第 34 位是 2 的 3 次方(`2^3=8`)。 - -十进制的情况下,10 是基数,二进制的情况下,2 是基数。 - -10 在十进制的表示法是 `0*10^0+1*10^1`=0+10=10。 - -10 的二进制数是 1010,也就是 `0*2^0 + 1*2^1 + 0*2^2 + 1*2^3`=0+2+0+8=10。 - -然后是**移位运算**,移位分为左移和右移,在 Java 中,左移的运算符是 `<<`,右移的运算符 `>>`。 - -拿 `oldCapacity >> 1` 来说吧,`>>` 左边的是被移位的值,此时是 10,也就是二进制 `1010`;`>>` 右边的是要移位的位数,此时是 1。 - -1010 向右移一位就是 101,空出来的最高位此时要补 0,也就是 0101。 - -“那为什么不补 1 呢?”三妹这个问题很尖锐。 - -“因为是算术右移,并且是正数,所以最高位补 0;如果表示的是负数,就需要补 1。”我慢吞吞地回答道,“0101 的十进制就刚好是 `1*2^0 + 0*2^1 + 1*2^2 + 0*2^3`=1+0+4+0=5,如果多移几个数来找规律的话,就会发现,右移 1 位是原来的 1/2,右移 2 位是原来的 1/4,诸如此类。” - -也就是说,ArrayList 的大小会扩容为原来的大小+原来大小/2,也就是 1.5 倍。 - -这下明白了吧? - -你可以通过在 ArrayList 中添加第 11 个元素来 debug 验证一下。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection//arraylist-d01f248c-114f-47e3-af18-7135feac2a5e.png) - -### 04、向 ArrayList 的指定位置添加元素 - -除了 `add(E e)` 方法,还可以通过 `add(int index, E element)` 方法把元素添加到 ArrayList 的指定位置: - -```java -alist.add(0, "沉默王三"); -``` - -`add(int index, E element)` 方法的源码如下: - -```java -/** - * 在指定位置插入一个元素。 - * - * @param index 要插入元素的位置 - * @param element 要插入的元素 - * @throws IndexOutOfBoundsException 如果索引超出范围,则抛出此异常 - */ -public void add(int index, E element) { - rangeCheckForAdd(index); // 检查索引是否越界 - - ensureCapacityInternal(size + 1); // 确保容量足够,如果需要扩容就扩容 - System.arraycopy(elementData, index, elementData, index + 1, - size - index); // 将 index 及其后面的元素向后移动一位 - elementData[index] = element; // 将元素插入到指定位置 - size++; // 元素个数加一 -} -``` - -`add(int index, E element)`方法会调用到一个非常重要的[本地方法](https://tobebetterjavaer.com/oo/native-method.html) `System.arraycopy()`,它会对数组进行复制(要插入位置上的元素往后复制)。 - -来细品一下。 - -这是 arraycopy() 的语法: - -```java -System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length); -``` - -在 `ArrayList.add(int index, E element)` 方法中,具体用法如下: - -```java -System.arraycopy(elementData, index, elementData, index + 1, size - index); -``` - -- elementData:表示要复制的源数组,即 ArrayList 中的元素数组。 -- index:表示源数组中要复制的起始位置,即需要将 index 及其后面的元素向后移动一位。 -- elementData:表示要复制到的目标数组,即 ArrayList 中的元素数组。 -- index + 1:表示目标数组中复制的起始位置,即将 index 及其后面的元素向后移动一位后,应该插入到的位置。 -- size - index:表示要复制的元素个数,即需要将 index 及其后面的元素向后移动一位,需要移动的元素个数为 size - index。 - - -“三妹,注意看,我画幅图来表示下。”我认真地做起了图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/arraylist-01.png) - -### 05、更新 ArrayList 中的元素 - -“二哥,那怎么**更新 ArrayList 中的元素**呢?”三妹继续问。 - -可以使用 `set()` 方法来更改 ArrayList 中的元素,需要提供下标和新元素。 - -```java -alist.set(0, "沉默王四"); -``` - -假设原来 0 位置上的元素为“沉默王三”,现在可以将其更新为“沉默王四”。 - -来看一下 `set()` 方法的源码: - -```java -/** - * 用指定元素替换指定位置的元素。 - * - * @param index 要替换的元素的索引 - * @param element 要存储在指定位置的元素 - * @return 先前在指定位置的元素 - * @throws IndexOutOfBoundsException 如果索引超出范围,则抛出此异常 - */ -public E set(int index, E element) { - rangeCheck(index); // 检查索引是否越界 - - E oldValue = elementData(index); // 获取原来在指定位置上的元素 - elementData[index] = element; // 将新元素替换到指定位置上 - return oldValue; // 返回原来在指定位置上的元素 -} -``` - -该方法会先对指定的下标进行检查,看是否越界,然后替换新值并返回旧值。 - -### 06、删除 ArrayList 中的元素 - -“二哥,那怎么**删除 ArrayList 中的元素**呢?”三妹继续问。 - -`remove(int index)` 方法用于删除指定下标位置上的元素,`remove(Object o)` 方法用于删除指定值的元素。 - -```java -alist.remove(1); -alist.remove("沉默王四"); -``` - -先来看 `remove(int index)` 方法的源码: - -```java -/** - * 删除指定位置的元素。 - * - * @param index 要删除的元素的索引 - * @return 先前在指定位置的元素 - * @throws IndexOutOfBoundsException 如果索引超出范围,则抛出此异常 - */ -public E remove(int index) { - rangeCheck(index); // 检查索引是否越界 - - E oldValue = elementData(index); // 获取要删除的元素 - - int numMoved = size - index - 1; // 计算需要移动的元素个数 - if (numMoved > 0) // 如果需要移动元素,就用 System.arraycopy 方法实现 - System.arraycopy(elementData, index+1, elementData, index, - numMoved); - elementData[--size] = null; // 将数组末尾的元素置为 null,让 GC 回收该元素占用的空间 - - return oldValue; // 返回被删除的元素 -} -``` - -需要注意的是,在 ArrayList 中,删除元素时,需要将删除位置后面的元素向前移动一位,以填补删除位置留下的空缺。如果需要移动元素,则需要使用 System.arraycopy 方法将删除位置后面的元素向前移动一位。最后,将数组末尾的元素置为 null,以便让垃圾回收机制回收该元素占用的空间。 - -再来看 `remove(Object o)` 方法的源码: - -```java -/** - * 删除列表中第一次出现的指定元素(如果存在)。 - * - * @param o 要删除的元素 - * @return 如果列表包含指定元素,则返回 true;否则返回 false - */ -public boolean remove(Object o) { - if (o == null) { // 如果要删除的元素是 null - for (int index = 0; index < size; index++) // 遍历列表 - if (elementData[index] == null) { // 如果找到了 null 元素 - fastRemove(index); // 调用 fastRemove 方法快速删除元素 - return true; // 返回 true,表示成功删除元素 - } - } else { // 如果要删除的元素不是 null - for (int index = 0; index < size; index++) // 遍历列表 - if (o.equals(elementData[index])) { // 如果找到了要删除的元素 - fastRemove(index); // 调用 fastRemove 方法快速删除元素 - return true; // 返回 true,表示成功删除元素 - } - } - return false; // 如果找不到要删除的元素,则返回 false -} -``` - -该方法通过遍历的方式找到要删除的元素,null 的时候使用 == 操作符判断,非 null 的时候使用 `equals()` 方法,然后调用 `fastRemove()` 方法。 - -注意: - -- 有相同元素时,只会删除第一个。 -- 判断两个元素是否相等,可以参考[Java如何判断两个字符串是否相等](https://tobebetterjavaer.com/string/equals.html) - -继续往后面跟,来看一下 `fastRemove()` 方法: - -```java -/** - * 快速删除指定位置的元素。 - * - * @param index 要删除的元素的索引 - */ -private void fastRemove(int index) { - int numMoved = size - index - 1; // 计算需要移动的元素个数 - if (numMoved > 0) // 如果需要移动元素,就用 System.arraycopy 方法实现 - System.arraycopy(elementData, index+1, elementData, index, - numMoved); - elementData[--size] = null; // 将数组末尾的元素置为 null,让 GC 回收该元素占用的空间 -} -``` - -同样是调用 `System.arraycopy()` 方法对数组进行复制和移动。 - -“三妹,注意看,我画幅图来表示下。”我认真地做起了图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/arraylist-02.png) - -### 07、查找 ArrayList 中的元素 - -“二哥,那怎么**查找 ArrayList 中的元素**呢?”三妹继续问。 - -如果要正序查找一个元素,可以使用 `indexOf()` 方法;如果要倒序查找一个元素,可以使用 `lastIndexOf()` 方法。 - -```java -alist.indexOf("沉默王二"); -alist.lastIndexOf("沉默王二"); -``` - -来看一下 `indexOf()` 方法的源码: - -```java -/** - * 返回指定元素在列表中第一次出现的位置。 - * 如果列表不包含该元素,则返回 -1。 - * - * @param o 要查找的元素 - * @return 指定元素在列表中第一次出现的位置;如果列表不包含该元素,则返回 -1 - */ -public int indexOf(Object o) { - if (o == null) { // 如果要查找的元素是 null - for (int i = 0; i < size; i++) // 遍历列表 - if (elementData[i]==null) // 如果找到了 null 元素 - return i; // 返回元素的索引 - } else { // 如果要查找的元素不是 null - for (int i = 0; i < size; i++) // 遍历列表 - if (o.equals(elementData[i])) // 如果找到了要查找的元素 - return i; // 返回元素的索引 - } - return -1; // 如果找不到要查找的元素,则返回 -1 -} -``` - -如果元素为 null 的时候使用“==”操作符,否则使用 `equals()` 方法。 - -`lastIndexOf()` 方法和 `indexOf()` 方法类似,不过遍历的时候从最后开始。 - -```java -/** - * 返回指定元素在列表中最后一次出现的位置。 - * 如果列表不包含该元素,则返回 -1。 - * - * @param o 要查找的元素 - * @return 指定元素在列表中最后一次出现的位置;如果列表不包含该元素,则返回 -1 - */ -public int lastIndexOf(Object o) { - if (o == null) { // 如果要查找的元素是 null - for (int i = size-1; i >= 0; i--) // 从后往前遍历列表 - if (elementData[i]==null) // 如果找到了 null 元素 - return i; // 返回元素的索引 - } else { // 如果要查找的元素不是 null - for (int i = size-1; i >= 0; i--) // 从后往前遍历列表 - if (o.equals(elementData[i])) // 如果找到了要查找的元素 - return i; // 返回元素的索引 - } - return -1; // 如果找不到要查找的元素,则返回 -1 -} -``` - -`contains()` 方法可以判断 ArrayList 中是否包含某个元素,其内部就是通过 `indexOf()` 方法实现的: - -```java -public boolean contains(Object o) { - return indexOf(o) >= 0; -} -``` - -### 08、二分查找法 - -如果 ArrayList 中的元素是经过排序的,就可以使用二分查找法,效率更快。 - -[`Collections`](https://tobebetterjavaer.com/common-tool/collections.html) 类的 `sort()` 方法可以对 ArrayList 进行排序,该方法会按照字母顺序对 String 类型的列表进行排序。如果是自定义类型的列表,还可以指定 Comparator 进行排序。 - -这里先简单地了解一下,后面会详细地讲。 - -```java -List copy = new ArrayList<>(alist); -copy.add("a"); -copy.add("c"); -copy.add("b"); -copy.add("d"); - -Collections.sort(copy); -System.out.println(copy); -``` - -输出结果如下所示: - -``` -[a, b, c, d] -``` - -排序后就可以使用二分查找法了: - -```java -int index = Collections.binarySearch(copy, "b"); -``` - -### 09、ArrayList增删改查时的时间复杂度 - -“最后,三妹,我们来简单总结一下 ArrayList 的时间复杂度吧,方便后面学习 LinkedList 时对比。”我喝了一口水后补充道。 - -#### 1)查询 - -时间复杂度为 O(1),因为 ArrayList 内部使用数组来存储元素,所以可以直接根据索引来访问元素。 - -```java -/** - * 返回列表中指定位置的元素。 - * - * @param index 要返回的元素的索引 - * @return 列表中指定位置的元素 - * @throws IndexOutOfBoundsException 如果索引超出范围(index < 0 || index >= size()) - */ -public E get(int index) { - rangeCheck(index); // 检查索引是否合法 - return elementData(index); // 调用 elementData 方法获取元素 -} - -/** - * 返回列表中指定位置的元素。 - * 此方法不进行边界检查,因此只应由内部方法和迭代器调用。 - * - * @param index 要返回的元素的索引 - * @return 列表中指定位置的元素 - */ -E elementData(int index) { - return (E) elementData[index]; // 返回指定索引位置上的元素 -} -``` - -#### 2)插入 - -添加一个元素(调用 `add()` 方法时)的时间复杂度最好情况为 O(1),最坏情况为 O(n)。 - -- 如果在列表末尾添加元素,时间复杂度为 O(1)。 -- 如果要在列表的中间或开头插入元素,则需要将插入位置之后的元素全部向后移动一位,时间复杂度为 O(n)。 - -#### 3)删除 - -删除一个元素(调用 `remove(Object)` 方法时)的时间复杂度最好情况 O(1),最坏情况 O(n)。 - -- 如果要删除列表末尾的元素,时间复杂度为 O(1)。 -- 如果要删除列表中间或开头的元素,则需要将删除位置之后的元素全部向前移动一位,时间复杂度为 O(n)。 - - -#### 4)修改 - -修改一个元素(调用 `set()`方法时)与查询操作类似,可以直接根据索引来访问元素,时间复杂度为 O(1)。 - -```java -/** - * 用指定元素替换列表中指定位置的元素。 - * - * @param index 要替换元素的索引 - * @param element 要放入列表中的元素 - * @return 原来在指定位置上的元素 - * @throws IndexOutOfBoundsException 如果索引超出范围(index < 0 || index >= size()) - */ -public E set(int index, E element) { - rangeCheck(index); // 检查索引是否合法 - - E oldValue = elementData(index); // 获取原来在指定位置上的元素 - elementData[index] = element; // 将指定位置上的元素替换为新元素 - return oldValue; // 返回原来在指定位置上的元素 -} -``` - -### 10、总结 - -ArrayList,如果有个中文名的话,应该叫动态数组,也就是可增长的数组,可调整大小的数组。动态数组克服了静态数组的限制,静态数组的容量是固定的,只能在首次创建的时候指定。而动态数组会随着元素的增加自动调整大小,更符合实际的开发需求。 - -学习集合框架,ArrayList 是第一课,也是新手进阶的重要一课。要想完全掌握 ArrayList,扩容这个机制是必须得掌握,也是面试中经常考察的一个点。 - -要想掌握扩容机制,就必须得读源码,也就肯定会遇到 `oldCapacity >> 1`,有些初学者会选择跳过,虽然不影响整体上的学习,但也错过了一个精进的机会。 - -计算机内部是如何表示十进制数的,右移时又发生了什么,静下心来去研究一下,你就会发现,哦,原来这么有趣呢? - -“好了,三妹,这一节我们就学到这里,收工!” - - - -## 6.4 LinkedList详解(附源码) - ->这篇换个表达方式,一起来欣赏。 - -大家好,我是 LinkedList,和 ArrayList 是同门师兄弟,但我俩练的内功却完全不同。师兄练的是动态数组,我练的是链表。 - -问大家一个问题,知道我为什么要练链表这门内功吗? - -举个例子来讲吧,假如你们手头要管理一推票据,可能有一张,也可能有一亿张。 - -该怎么办呢? - -申请一个 10G 的大数组等着?那万一票据只有 100 张呢? - -申请一个默认大小的数组,随着数据量的增大扩容?要知道扩容是需要重新复制数组的,很耗时间。 - -关键是,数组还有一个弊端就是,假如现在有 500 万张票据,现在要从中间删除一个票据,就需要把 250 万张票据往前移动一格。 - -遇到这种情况的时候,我师兄几乎情绪崩溃,难受的要命。师父不忍心看到师兄这样痛苦,于是打我进入师门那一天,就强迫我练链表这门内功,一开始我很不理解,害怕师父偏心,不把师门最厉害的内功教我。 - -直到有一天,我亲眼目睹师兄差点因为移动数据而走火入魔,我才明白师父的良苦用心。从此以后,我苦练“链表”这门内功,取得了显著的进步,师父和师兄都夸我有天赋。 - -链表这门内功大致分为三个层次: - -- 第一层叫做“单向链表”,我只有一个后指针,指向下一个数据; -- 第二层叫做“双向链表”,我有两个指针,后指针指向下一个数据,前指针指向上一个数据。 -- 第三层叫做“二叉树”,把后指针去掉,换成左右指针。 - -但我现在的功力还达不到第三层,不过师父说我有这个潜力,练成神功是早晚的事。 - -### 01、LinkedList的内功心法 - -好了,经过我这么样的一个剖白后,大家对我应该已经不陌生了。那么接下来,我给大家展示一下我的内功心法。 - -我的内功心法主要是一个私有的静态内部类,叫 Node,也就是节点。 - -```java -/** - * 链表中的节点类。 - */ -private static class Node { - E item; // 节点中存储的元素 - Node next; // 指向下一个节点的指针 - Node prev; // 指向上一个节点的指针 - - /** - * 构造一个新的节点。 - * - * @param prev 前一个节点 - * @param element 节点中要存储的元素 - * @param next 后一个节点 - */ - Node(Node prev, E element, Node next) { - this.item = element; // 存储元素 - this.next = next; // 设置下一个节点 - this.prev = prev; // 设置上一个节点 - } -} -``` - -它由三部分组成: - -- 节点上的元素 -- 下一个节点 -- 上一个节点 - -我画幅图给你们展示下吧。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/linkedlist-01.png) - -- 对于第一个节点来说,prev 为 null; -- 对于最后一个节点来说,next 为 null; -- 其余的节点呢,prev 指向前一个,next 指向后一个。 - -我的内功心法就这么简单,其实我早已经牢记在心了。但师父叮嘱我,每天早上醒来的时候,每天晚上睡觉的时候,一定要默默地背诵一遍。虽然我有些厌烦,但我对师父的教诲从来都是言听计从。 - -### 02、LinkedList的招式 - -和师兄 ArrayList 一样,我的招式也无外乎“增删改查”这 4 种。在此之前,我们都必须得初始化。 - -```java -LinkedList list = new LinkedList(); -``` - -师兄在初始化的时候可以指定大小,也可以不指定,等到添加第一个元素的时候进行第一次扩容。而我,没有大小,只要内存够大,我就可以无穷大。 - -#### **1)招式一:增** - -可以调用 add 方法添加元素: - -```java -list.add("沉默王二"); -list.add("沉默王三"); -list.add("沉默王四"); -``` - -add 方法内部其实调用的是 linkLast 方法: - -```java -/** - * 将指定的元素添加到列表的尾部。 - * - * @param e 要添加到列表的元素 - * @return 始终为 true(根据 Java 集合框架规范) - */ -public boolean add(E e) { - linkLast(e); // 在列表的尾部添加元素 - return true; // 添加元素成功,返回 true -} -``` - -linkLast,顾名思义,就是在链表的尾部添加元素: - -```java -/** - * 在列表的尾部添加指定的元素。 - * - * @param e 要添加到列表的元素 - */ -void linkLast(E e) { - final Node l = last; // 获取链表的最后一个节点 - final Node newNode = new Node<>(l, e, null); // 创建一个新的节点,并将其设置为链表的最后一个节点 - last = newNode; // 将新的节点设置为链表的最后一个节点 - if (l == null) // 如果链表为空,则将新节点设置为头节点 - first = newNode; - else - l.next = newNode; // 否则将新节点链接到链表的尾部 - size++; // 增加链表的元素个数 -} -``` - -- 添加第一个元素的时候,first 和 last 都为 null。 -- 然后新建一个节点 newNode,它的 prev 和 next 也为 null。 -- 然后把 last 和 first 都赋值为 newNode。 - -此时还不能称之为链表,因为前后节点都是断裂的。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/linkedlist-02.png) - -- 添加第二个元素的时候,first 和 last 都指向的是第一个节点。 -- 然后新建一个节点 newNode,它的 prev 指向的是第一个节点,next 为 null。 -- 然后把第一个节点的 next 赋值为 newNode。 - -此时的链表还不完整。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/linkedlist-03.png) - -- 添加第三个元素的时候,first 指向的是第一个节点,last 指向的是最后一个节点。 -- 然后新建一个节点 newNode,它的 prev 指向的是第二个节点,next 为 null。 -- 然后把第二个节点的 next 赋值为 newNode。 - -此时的链表已经完整了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/linkedlist-04.png) - -我这个增的招式,还可以演化成另外两个版本: - -- `addFirst()` 方法将元素添加到第一位; -- `addLast()` 方法将元素添加到末尾。 - -addFirst 内部其实调用的是 linkFirst: - -```java -/** - * 在列表的开头添加指定的元素。 - * - * @param e 要添加到列表的元素 - */ -public void addFirst(E e) { - linkFirst(e); // 在列表的开头添加元素 -} -``` - -linkFirst 负责把新的节点设为 first,并将新的 first 的 next 更新为之前的 first。 - -```java -/** - * 在列表的开头添加指定的元素。 - * - * @param e 要添加到列表的元素 - */ -private void linkFirst(E e) { - final Node f = first; // 获取链表的第一个节点 - final Node newNode = new Node<>(null, e, f); // 创建一个新的节点,并将其设置为链表的第一个节点 - first = newNode; // 将新的节点设置为链表的第一个节点 - if (f == null) // 如果链表为空,则将新节点设置为尾节点 - last = newNode; - else - f.prev = newNode; // 否则将新节点链接到链表的头部 - size++; // 增加链表的元素个数 -} -``` - -addLast 的内核其实和 addFirst 差不多,内部调用的是 linkLast 方法,前面分析过了。 - -```java -/** - * 在列表的尾部添加指定的元素。 - * - * @param e 要添加到列表的元素 - * @return 始终为 true(根据 Java 集合框架规范) - */ -public boolean addLast(E e) { - linkLast(e); // 在列表的尾部添加元素 - return true; // 添加元素成功,返回 true -} -``` - - -#### **2)招式二:删** - -我这个删的招式还挺多的: - -- `remove()`:删除第一个节点 -- `remove(int)`:删除指定位置的节点 -- `remove(Object)`:删除指定元素的节点 -- `removeFirst()`:删除第一个节点 -- `removeLast()`:删除最后一个节点 - -`remove()` 内部调用的是 `removeFirst()`,所以这两个招式的功效一样。 - -`remove(int)` 内部其实调用的是 unlink 方法。 - -```java -/** - * 删除指定位置上的元素。 - * - * @param index 要删除的元素的索引 - * @return 从列表中删除的元素 - * @throws IndexOutOfBoundsException 如果索引越界(index < 0 || index >= size()) - */ -public E remove(int index) { - checkElementIndex(index); // 检查索引是否越界 - return unlink(node(index)); // 删除指定位置的节点,并返回节点的元素 -} - -``` - -unlink 方法其实很好理解,就是更新当前节点的 next 和 prev,然后把当前节点上的元素设为 null。 - -```java -/** - * 从链表中删除指定节点。 - * - * @param x 要删除的节点 - * @return 从链表中删除的节点的元素 - */ -E unlink(Node x) { - final E element = x.item; // 获取要删除节点的元素 - final Node next = x.next; // 获取要删除节点的下一个节点 - final Node prev = x.prev; // 获取要删除节点的上一个节点 - - if (prev == null) { // 如果要删除节点是第一个节点 - first = next; // 将链表的头节点设置为要删除节点的下一个节点 - } else { - prev.next = next; // 将要删除节点的上一个节点指向要删除节点的下一个节点 - x.prev = null; // 将要删除节点的上一个节点设置为空 - } - - if (next == null) { // 如果要删除节点是最后一个节点 - last = prev; // 将链表的尾节点设置为要删除节点的上一个节点 - } else { - next.prev = prev; // 将要删除节点的下一个节点指向要删除节点的上一个节点 - x.next = null; // 将要删除节点的下一个节点设置为空 - } - - x.item = null; // 将要删除节点的元素设置为空 - size--; // 减少链表的元素个数 - return element; // 返回被删除节点的元素 -} -``` - -remove(Object) 内部也调用了 unlink 方法,只不过在此之前要先找到元素所在的节点: - -```java -/** - * 从链表中删除指定元素。 - * - * @param o 要从链表中删除的元素 - * @return 如果链表包含指定元素,则返回 true;否则返回 false - */ -public boolean remove(Object o) { - if (o == null) { // 如果要删除的元素为 null - for (Node x = first; x != null; x = x.next) { // 遍历链表 - if (x.item == null) { // 如果节点的元素为 null - unlink(x); // 删除节点 - return true; // 返回 true 表示删除成功 - } - } - } else { // 如果要删除的元素不为 null - for (Node x = first; x != null; x = x.next) { // 遍历链表 - if (o.equals(x.item)) { // 如果节点的元素等于要删除的元素 - unlink(x); // 删除节点 - return true; // 返回 true 表示删除成功 - } - } - } - return false; // 如果链表中不包含要删除的元素,则返回 false 表示删除失败 -} -``` - -元素为 null 的时候,必须使用 == 来判断;元素为非 null 的时候,要使用 equals 来判断。 - -removeFirst 内部调用的是 unlinkFirst 方法: - -```java -/** - * 从链表中删除第一个元素并返回它。 - * 如果链表为空,则抛出 NoSuchElementException 异常。 - * - * @return 从链表中删除的第一个元素 - * @throws NoSuchElementException 如果链表为空 - */ -public E removeFirst() { - final Node f = first; // 获取链表的第一个节点 - if (f == null) // 如果链表为空 - throw new NoSuchElementException(); // 抛出 NoSuchElementException 异常 - return unlinkFirst(f); // 调用 unlinkFirst 方法删除第一个节点并返回它的元素 -} -``` - -unlinkFirst 负责的就是把第一个节点毁尸灭迹,并且捎带把后一个节点的 prev 设为 null。 - -```java -/** - * 删除链表中的第一个节点并返回它的元素。 - * - * @param f 要删除的第一个节点 - * @return 被删除节点的元素 - */ -private E unlinkFirst(Node f) { - final E element = f.item; // 获取要删除的节点的元素 - final Node next = f.next; // 获取要删除的节点的下一个节点 - f.item = null; // 将要删除的节点的元素设置为 null - f.next = null; // 将要删除的节点的下一个节点设置为 null - first = next; // 将链表的头节点设置为要删除的节点的下一个节点 - if (next == null) // 如果链表只有一个节点 - last = null; // 将链表的尾节点设置为 null - else - next.prev = null; // 将要删除节点的下一个节点的前驱设置为 null - size--; // 减少链表的大小 - return element; // 返回被删除节点的元素 -} -``` - -#### **3)招式三:改** - -可以调用 `set()` 方法来更新元素: - -```java -list.set(0, "沉默王五"); -``` - -来看一下 `set()` 方法: - -```java -/** - * 将链表中指定位置的元素替换为指定元素,并返回原来的元素。 - * - * @param index 要替换元素的位置(从 0 开始) - * @param element 要插入的元素 - * @return 替换前的元素 - * @throws IndexOutOfBoundsException 如果索引超出范围(index < 0 || index >= size()) - */ -public E set(int index, E element) { - checkElementIndex(index); // 检查索引是否超出范围 - Node x = node(index); // 获取要替换的节点 - E oldVal = x.item; // 获取要替换节点的元素 - x.item = element; // 将要替换的节点的元素设置为指定元素 - return oldVal; // 返回替换前的元素 -} -``` - -来看一下node方法: - -```java -/** - * 获取链表中指定位置的节点。 - * - * @param index 节点的位置(从 0 开始) - * @return 指定位置的节点 - * @throws IndexOutOfBoundsException 如果索引超出范围(index < 0 || index >= size()) - */ -Node node(int index) { - if (index < (size >> 1)) { // 如果索引在链表的前半部分 - Node x = first; - for (int i = 0; i < index; i++) // 从头节点开始向后遍历链表,直到找到指定位置的节点 - x = x.next; - return x; // 返回指定位置的节点 - } else { // 如果索引在链表的后半部分 - Node x = last; - for (int i = size - 1; i > index; i--) // 从尾节点开始向前遍历链表,直到找到指定位置的节点 - x = x.prev; - return x; // 返回指定位置的节点 - } -} -``` - -`size >> 1`:也就是右移一位,相当于除以 2。对于计算机来说,移位比除法运算效率更高,因为数据在计算机内部都是以二进制存储的。 - -换句话说,node 方法会对下标进行一个初步判断,如果靠近前半截,就从下标 0 开始遍历;如果靠近后半截,就从末尾开始遍历,这样可以提高效率,最大能提高一半的效率。 - -找到指定下标的节点就简单了,直接把原有节点的元素替换成新的节点就 OK 了,prev 和 next 都不用改动。 - -#### **4)招式四:查** - -我这个查的招式可以分为两种: - -- indexOf(Object):查找某个元素所在的位置 -- get(int):查找某个位置上的元素 - -来看一下 indexOf 方法的源码。 - -```java -/** - * 返回链表中首次出现指定元素的位置,如果不存在该元素则返回 -1。 - * - * @param o 要查找的元素 - * @return 首次出现指定元素的位置,如果不存在该元素则返回 -1 - */ -public int indexOf(Object o) { - int index = 0; // 初始化索引为 0 - if (o == null) { // 如果要查找的元素为 null - for (Node x = first; x != null; x = x.next) { // 从头节点开始向后遍历链表 - if (x.item == null) // 如果找到了要查找的元素 - return index; // 返回该元素的索引 - index++; // 索引加 1 - } - } else { // 如果要查找的元素不为 null - for (Node x = first; x != null; x = x.next) { // 从头节点开始向后遍历链表 - if (o.equals(x.item)) // 如果找到了要查找的元素 - return index; // 返回该元素的索引 - index++; // 索引加 1 - } - } - return -1; // 如果没有找到要查找的元素,则返回 -1 -} -``` - -get 方法的内核其实还是 node 方法,node 方法之前已经说明过了,这里略过。 - -```java -public E get(int index) { - checkElementIndex(index); - return node(index).item; -} -``` - -其实,查这个招式还可以演化为其他的一些,比如说: - -- `getFirst()` 方法用于获取第一个元素; -- `getLast()` 方法用于获取最后一个元素; -- `poll()` 和 `pollFirst()` 方法用于删除并返回第一个元素(两个方法尽管名字不同,但方法体是完全相同的); -- `pollLast()` 方法用于删除并返回最后一个元素; -- `peekFirst()` 方法用于返回但不删除第一个元素。 - -### 03、LinkedList 的挑战 - -说句实在话,我不是很喜欢和师兄 ArrayList 拿来比较,因为我们各自修炼的内功不同,没有孰高孰低。 - -虽然师兄经常喊我一声师弟,但我们之间其实挺和谐的。但我知道,在外人眼里,同门师兄弟,总要一较高下的。 - -比如说,我们俩在增删改查时候的时间复杂度。 - -也许这就是命运吧,从我进入师门的那天起,这种争论就一直没有停息过。 - -无论外人怎么看待我们,在我眼里,师兄永远都是一哥,我敬重他,他也愿意保护我。 - -[好戏在后头](https://tobebetterjavaer.com/collection/list-war-2.html),等着瞧吧。 - -我这里先简单聊一下,权当抛砖引玉。 - -想象一下,你在玩一款游戏,游戏中有一个道具栏,你需要不断地往里面添加、删除道具。如果你使用的是我的师兄 ArrayList,那么每次添加、删除道具时都需要将后面的道具向后移动或向前移动,这样就会非常耗费时间。但是如果你使用的是我 LinkedList,那么只需要将新道具插入到链表中的指定位置,或者将要删除的道具从链表中删除即可,这样就可以快速地完成道具栏的更新。 - -除了游戏中的道具栏,我 LinkedList 还可以用于实现 LRU(Least Recently Used)缓存淘汰算法。LRU 缓存淘汰算法是一种常用的缓存淘汰策略,它的基本思想是,当缓存空间不够时,优先淘汰最近最少使用的缓存数据。在实现 LRU 缓存淘汰算法时,你可以使用我 LinkedList 来存储缓存数据,每次访问缓存数据时,将该数据从链表中删除并移动到链表的头部,这样链表的尾部就是最近最少使用的缓存数据,当缓存空间不够时,只需要将链表尾部的缓存数据淘汰即可。 - -总之,各有各的好,且行且珍惜。 - - - -## 6.5 ArrayList和LinkedList的区别 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[6.5 ArrayList和LinkedList的区别](https://tobebetterjavaer.com/collection/list-war-2.html) - -## 6.6 Java泛型,深入解析 - -“二哥,为什么要设计泛型啊?”三妹开门见山地问。 - -“三妹啊,听哥慢慢给你讲啊。”我说。 - -Java 在 1.5 时增加了泛型机制,据说专家们为此花费了 5 年左右的时间(听起来是相当不容易)。有了泛型之后,尤其是对集合类的使用,就变得更规范了。 - -看下面这段简单的代码。 - -```java -ArrayList list = new ArrayList(); -list.add("沉默王二"); -String str = list.get(0); -``` - -“三妹,你能想象到在没有泛型之前该怎么办吗?” - -“嗯,想不到,还是二哥你说吧。” - -嗯,我们可以使用 Object 数组来设计 `Arraylist` 类。 - -```java -class Arraylist { - private Object[] objs; - private int i = 0; - public void add(Object obj) { - objs[i++] = obj; - } - - public Object get(int i) { - return objs[i]; - } -} -``` - -然后,我们向 `Arraylist` 中存取数据。 - -```java -Arraylist list = new Arraylist(); -list.add("沉默王二"); -list.add(new Date()); -String str = (String)list.get(0); -``` - -“三妹,你有没有发现这两个问题?” - -- Arraylist 可以存放任何类型的数据(既可以存字符串,也可以混入日期),因为所有类都继承自 Object 类。 -- 从 Arraylist 取出数据的时候需要强制类型转换,因为编译器并不能确定你取的是字符串还是日期。 - -“嗯嗯,是的呢。”三妹说。 - -对比一下,你就能明显地感受到泛型的优秀之处:使用**类型参数**解决了元素的不确定性——参数类型为 String 的集合中是不允许存放其他类型元素的,取出数据的时候也不需要强制类型转换了。 - -### 动手设计一个泛型 - -“二哥,那怎么才能设计一个泛型呢?” - -“三妹啊,你一个小白只要会用泛型就行了,还想设计泛型啊?!不过,既然你想了解,哥义不容辞。” - -首先,我们来按照泛型的标准重新设计一下 `Arraylist` 类。 - -```java -class Arraylist { - private Object[] elementData; - private int size = 0; - - public Arraylist(int initialCapacity) { - this.elementData = new Object[initialCapacity]; - } - - public boolean add(E e) { - elementData[size++] = e; - return true; - } - - E elementData(int index) { - return (E) elementData[index]; - } -} -``` - -一个泛型类就是具有一个或多个类型变量的类。Arraylist 类引入的类型变量为 E(Element,元素的首字母),使用尖括号 `<>` 括起来,放在类名的后面。 - -然后,我们可以用具体的类型(比如字符串)替换类型变量来实例化泛型类。 - -```java -Arraylist list = new Arraylist(); -list.add("沉默王三"); -String str = list.get(0); -``` - -Date 类型也可以的。 - -```java -Arraylist list = new Arraylist(); -list.add(new Date()); -Date date = list.get(0); -``` - -其次,我们还可以在一个非泛型的类(或者泛型类)中定义泛型方法。 - -```java -class Arraylist { - public T[] toArray(T[] a) { - return (T[]) Arrays.copyOf(elementData, size, a.getClass()); - } -} -``` - -不过,说实话,泛型方法的定义看起来略显晦涩。来一副图吧(注意:方法返回类型和方法参数类型至少需要一个)。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/generic/generic-01.png) - -现在,我们来调用一下泛型方法。 - -```java -Arraylist list = new Arraylist<>(4); -list.add("沉"); -list.add("默"); -list.add("王"); -list.add("二"); - -String [] strs = new String [4]; -strs = list.toArray(strs); - -for (String str : strs) { - System.out.println(str); -} -``` - -### 泛型限定符 - -然后,我们再来说说泛型变量的限定符 `extends`。 - -在解释这个限定符之前,我们假设有三个类,它们之间的定义是这样的。 - -```java -class Wanglaoer { - public String toString() { - return "王老二"; - } -} - -class Wanger extends Wanglaoer{ - public String toString() { - return "王二"; - } -} - -class Wangxiaoer extends Wanger{ - public String toString() { - return "王小二"; - } -} -``` - -我们使用限定符 `extends` 来重新设计一下 `Arraylist` 类。 - -```java -class Arraylist { -} -``` - -当我们向 `Arraylist` 中添加 `Wanglaoer` 元素的时候,编译器会提示错误:`Arraylist` 只允许添加 `Wanger` 及其子类 `Wangxiaoer` 对象,不允许添加其父类 `Wanglaoer`。 - -```java -Arraylist list = new Arraylist<>(3); -list.add(new Wanger()); -list.add(new Wanglaoer()); -// The method add(Wanger) in the type Arraylist is not applicable for the arguments -// (Wanglaoer) -list.add(new Wangxiaoer()); -``` - -也就是说,限定符 `extends` 可以缩小泛型的类型范围。 - -### 类型擦除 - -“哦,明白了。”三妹若有所思的点点头,“二哥,听说虚拟机没有泛型?” - -“三妹,你功课做得可以啊。哥可以肯定地回答你,虚拟机是没有泛型的。” - -“怎么确定虚拟机有没有泛型呢?”三妹问。 - -“只要我们把泛型类的字节码进行反编译就看到了!”用反编译工具(我写这篇文章的时候用的是 jad,你也可以用其他的工具)将 class 文件反编译后,我说,“三妹,你看。” - -```java -// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov. -// Jad home page: http://www.kpdus.com/jad.html -// Decompiler options: packimports(3) -// Source File Name: Arraylist.java - -package com.cmower.java_demo.fanxing; - -import java.util.Arrays; - -class Arraylist -{ - - public Arraylist(int initialCapacity) - { - size = 0; - elementData = new Object[initialCapacity]; - } - - public boolean add(Object e) - { - elementData[size++] = e; - return true; - } - - Object elementData(int index) - { - return elementData[index]; - } - - private Object elementData[]; - private int size; -} -``` - -类型变量 `` 消失了,取而代之的是 Object ! - -“既然如此,那如果泛型类使用了限定符 `extends`,结果会怎么样呢?”三妹这个问题问的很巧妙。 - -来看这段代码。 - -```java -class Arraylist2 { - private Object[] elementData; - private int size = 0; - - public Arraylist2(int initialCapacity) { - this.elementData = new Object[initialCapacity]; - } - - public boolean add(E e) { - elementData[size++] = e; - return true; - } - - E elementData(int index) { - return (E) elementData[index]; - } -} -``` - -反编译后的结果如下。 - -```java -// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov. -// Jad home page: http://www.kpdus.com/jad.html -// Decompiler options: packimports(3) -// Source File Name: Arraylist2.java - -package com.cmower.java_demo.fanxing; - - -// Referenced classes of package com.cmower.java_demo.fanxing: -// Wanger - -class Arraylist2 -{ - - public Arraylist2(int initialCapacity) - { - size = 0; - elementData = new Object[initialCapacity]; - } - - public boolean add(Wanger e) - { - elementData[size++] = e; - return true; - } - - Wanger elementData(int index) - { - return (Wanger)elementData[index]; - } - - private Object elementData[]; - private int size; -} -``` - -“你看,类型变量 `` 不见了,E 被替换成了 `Wanger`”,我说,“通过以上两个例子说明,Java 虚拟机会将泛型的类型变量擦除,并替换为限定类型(没有限定的话,就用 `Object`)” - -“二哥,类型擦除会有什么问题吗?”三妹又问了一个很有水平的问题。 - -“三妹啊,你还别说,类型擦除真的会有一些问题。”我说,“来看一下这段代码。” - -```java -public class Cmower { - - public static void method(Arraylist list) { - System.out.println("Arraylist list"); - } - - public static void method(Arraylist list) { - System.out.println("Arraylist list"); - } - -} -``` - -在浅层的意识上,我们会想当然地认为 `Arraylist list` 和 `Arraylist list` 是两种不同的类型,因为 String 和 Date 是不同的类。 - -但由于类型擦除的原因,以上代码是不会通过编译的——编译器会提示一个错误(这正是类型擦除引发的那些“问题”): - -``` ->Erasure of method method(Arraylist) is the same as another method in type - Cmower -> ->Erasure of method method(Arraylist) is the same as another method in type - Cmower -``` - - -大致的意思就是,这两个方法的参数类型在擦除后是相同的。 - -也就是说,`method(Arraylist list)` 和 `method(Arraylist list)` 是同一种参数类型的方法,不能同时存在。类型变量 `String` 和 `Date` 在擦除后会自动消失,method 方法的实际参数是 `Arraylist list`。 - -有句俗话叫做:“百闻不如一见”,但即使见到了也未必为真——泛型的擦除问题就可以很好地佐证这个观点。 - -### 泛型通配符 - -“哦,明白了。二哥,听说泛型还有通配符?” - -“三妹啊,哥突然觉得你很适合作一枚可爱的程序媛啊!你这预习的功课做得可真到家啊,连通配符都知道!” - -通配符使用英文的问号`(?)`来表示。在我们创建一个泛型对象时,可以使用关键字 `extends` 限定子类,也可以使用关键字 `super` 限定父类。 - -我们来看下面这段代码。 - -```java -// 定义一个泛型类 Arraylist,E 表示元素类型 -class Arraylist { - // 私有成员变量,存储元素数组和元素数量 - private Object[] elementData; - private int size = 0; - - // 构造函数,传入初始容量 initialCapacity,创建一个指定容量的 Object 数组 - public Arraylist(int initialCapacity) { - this.elementData = new Object[initialCapacity]; - } - - // 添加元素到数组末尾,返回添加成功与否 - public boolean add(E e) { - elementData[size++] = e; - return true; - } - - // 获取指定下标的元素 - public E get(int index) { - return (E) elementData[index]; - } - - // 查找指定元素第一次出现的下标,如果找不到则返回 -1 - public int indexOf(Object o) { - if (o == null) { - for (int i = 0; i < size; i++) - if (elementData[i]==null) - return i; - } else { - for (int i = 0; i < size; i++) - if (o.equals(elementData[i])) - return i; - } - return -1; - } - - // 判断指定元素是否在数组中出现 - public boolean contains(Object o) { - return indexOf(o) >= 0; - } - - // 将数组中的元素转化成字符串输出 - public String toString() { - StringBuilder sb = new StringBuilder(); - - for (Object o : elementData) { - if (o != null) { - E e = (E)o; - sb.append(e.toString()); - sb.append(',').append(' '); - } - } - return sb.toString(); - } - - // 返回数组中元素的数量 - public int size() { - return size; - } - - // 修改指定下标的元素,返回修改前的元素 - public E set(int index, E element) { - E oldValue = (E) elementData[index]; - elementData[index] = element; - return oldValue; - } -} -``` - -1)新增 `indexOf(Object o)` 方法,判断元素在 `Arraylist` 中的位置。注意参数为 `Object` 而不是泛型 `E`。 - -2)新增 `contains(Object o)` 方法,判断元素是否在 `Arraylist` 中。注意参数为 `Object` 而不是泛型 `E`。 - -3)新增 `toString()` 方法,方便对 `Arraylist` 进行打印。 - -4)新增 `set(int index, E element)` 方法,方便对 `Arraylist` 元素的更改。 - -因为泛型擦除的原因,`Arraylist list = new Arraylist();` 这样的语句是无法通过编译的,尽管 Wangxiaoer 是 Wanger 的子类。但如果我们确实需要这种 “向上转型” 的关系,该怎么办呢?这时候就需要通配符来发挥作用了。 - -利用 `` 形式的通配符,可以实现泛型的向上转型,来看例子。 - -```java -Arraylist list2 = new Arraylist<>(4); -list2.add(null); -// list2.add(new Wanger()); -// list2.add(new Wangxiaoer()); - -Wanger w2 = list2.get(0); -// Wangxiaoer w3 = list2.get(1); -``` - -list2 的类型是 `Arraylist`,翻译一下就是,list2 是一个 `Arraylist`,其类型是 `Wanger` 及其子类。 - -注意,“关键”来了!list2 并不允许通过 `add(E e)` 方法向其添加 `Wanger` 或者 `Wangxiaoer` 的对象,唯一例外的是 `null`。 - -“那就奇了怪了,既然不让存放元素,那要 `Arraylist` 这样的 list2 有什么用呢?”三妹好奇地问。 - -虽然不能通过 `add(E e)` 方法往 list2 中添加元素,但可以给它赋值。 - -```java -Arraylist list = new Arraylist<>(4); - -Wanger wanger = new Wanger(); -list.add(wanger); - -Wangxiaoer wangxiaoer = new Wangxiaoer(); -list.add(wangxiaoer); - -Arraylist list2 = list; - -Wanger w2 = list2.get(1); -System.out.println(w2); - -System.out.println(list2.indexOf(wanger)); -System.out.println(list2.contains(new Wangxiaoer())); -``` - -`Arraylist list2 = list;` 语句把 list 的值赋予了 list2,此时 `list2 == list`。由于 list2 不允许往其添加其他元素,所以此时它是安全的——我们可以从容地对 list2 进行 `get()`、`indexOf()` 和 `contains()`。想一想,如果可以向 list2 添加元素的话,这 3 个方法反而变得不太安全,它们的值可能就会变。 - -利用 `` 形式的通配符,可以向 Arraylist 中存入父类是 `Wanger` 的元素,来看例子。 - -```java -Arraylist list3 = new Arraylist<>(4); -list3.add(new Wanger()); -list3.add(new Wangxiaoer()); - -// Wanger w3 = list3.get(0); -``` - -需要注意的是,无法从 `Arraylist` 这样类型的 list3 中取出数据。 - -### 小结 - -好了,三妹,关于泛型,我们再来做一个简单的总结。 - -在 Java 中,泛型是一种强类型约束机制,可以在编译期间检查类型安全性,并且可以提高代码的复用性和可读性。 - -#### 1)类型参数化 - -泛型的本质是参数化类型,也就是说,在定义类、接口或方法时,可以使用一个或多个类型参数来表示参数化类型。 - -例如这样可以定义一个泛型类。 - -```java -public class Box { - private T value; - - public Box(T value) { - this.value = value; - } - - public T getValue() { - return value; - } - - public void setValue(T value) { - this.value = value; - } -} -``` - -在这个例子中,`` 表示类型参数,可以在类中任何需要使用类型的地方使用 T 代替具体的类型。通过使用泛型,我们可以创建一个可以存储任何类型对象的盒子。 - -```java -Box intBox = new Box<>(123); -Box strBox = new Box<>("Hello, world!"); -``` - -泛型在实际开发中的应用非常广泛,例如集合框架中的 List、Set、Map 等容器类,以及并发框架中的 Future、Callable 等工具类都使用了泛型。 - -#### 2)类型擦除 - -在 Java 的泛型机制中,有两个重要的概念:类型擦除和通配符。 - -泛型在编译时会将泛型类型擦除,将泛型类型替换成 Object 类型。这是为了向后兼容,避免对原有的 Java 代码造成影响。 - -例如,对于下面的代码: - -```java -List intList = new ArrayList<>(); -intList.add(123); -int value = intList.get(0); -``` - -在编译时,Java 编译器会将泛型类型 `List` 替换成 `List`,将 get 方法的返回值类型 Integer 替换成 Object,生成的字节码与下面的代码等价: - -```java -List intList = new ArrayList(); -intList.add(Integer.valueOf(123)); -int value = (Integer) intList.get(0); -``` - -Java 泛型只在编译时起作用,运行时并不会保留泛型类型信息。 - -#### 3)通配符 - -通配符用于表示某种未知的类型,例如 `List` 表示一个可以存储任何类型对象的 List,但是不能对其中的元素进行添加操作。通配符可以用来解决类型不确定的情况,例如在方法参数或返回值中使用。 - -使用通配符可以使方法更加通用,同时保证类型安全。 - -例如,定义一个泛型方法: - -```java -public static void printList(List list) { - for (Object obj : list) { - System.out.print(obj + " "); - } - System.out.println(); -} -``` - -这个方法可以接受任意类型的 List,例如 `List`、`List` 等等。 - -##### 上限通配符 - -泛型还提供了上限通配符 ``,表示通配符只能接受 T 或 T 的子类。使用上限通配符可以提高程序的类型安全性。 - -例如,定义一个方法,只接受 Number 及其子类的 List: - -```java -public static void printNumberList(List list) { - for (Number num : list) { - System.out.print(num + " "); - } - System.out.println(); -} -``` - -这个方法可以接受 `List`、`List` 等等。 - -##### 下限通配符 - -下限通配符(Lower Bounded Wildcards)用 super 关键字来声明,其语法形式为 ``,其中 T 表示类型参数。它表示的是该类型参数必须是某个指定类的超类(包括该类本身)。 - -当我们需要往一个泛型集合中添加元素时,如果使用的是上限通配符,集合中的元素类型可能会被限制,从而无法添加某些类型的元素。但是,如果我们使用下限通配符,可以将指定类型的子类型添加到集合中,保证了元素的完整性。 - -举个例子,假设有一个类 Animal,以及两个子类 Dog 和 Cat。现在我们有一个 `List` 集合,它的类型参数必须是 Dog 或其父类类型。我们可以向该集合中添加 Dog 类型的元素,也可以添加它的子类。但是,不能向其中添加 Cat 类型的元素,因为 Cat 不是 Dog 的子类。 - -下面是一个使用下限通配符的示例: - -```java -List animals = new ArrayList<>(); - -// 可以添加 Dog 类型的元素和其子类型元素 -animals.add(new Dog()); -animals.add(new Bulldog()); - -// 不能添加 Cat 类型的元素 -animals.add(new Cat()); // 编译报错 -``` - -需要注意的是,虽然使用下限通配符可以添加某些子类型元素,但是在读取元素时,我们只能确保其是 Object 类型的,无法确保其是指定类型或其父类型。因此,在读取元素时需要进行类型转换,如下所示: - -```java -List animals = new ArrayList<>(); -animals.add(new Dog()); - -// 读取元素时需要进行类型转换 -Object animal = animals.get(0); -Dog dog = (Dog) animal; -``` - -总的来说,Java 的泛型机制是一种非常强大的类型约束机制,可以在编译时检查类型安全性,并提高代码的复用性和可读性。但是,在使用泛型时也需要注意类型擦除和通配符等问题,以确保代码的正确性。 - - - -## 6.7 Iterator和Iterable的区别 - ->PS: 这篇同样来换一个风格,一起来欣赏。 - -那天,小二去海康威视面试,面试官老王一上来就甩给了他一道面试题:请问 Iterator与Iterable有什么区别? - -小二表示很开心,因为他3 天前刚好在《[二哥的Java进阶之路](https://tobebetterjavaer.com/collection/iterator-iterable.html)》上读过这篇文章,所以回答得胸有成竹。 - -以下↓是小二当时读过的文章内容,他印象深刻。 - ----- - -在 Java 中,我们对 List 进行遍历的时候,主要有这么三种方式。 - -第一种:for 循环。 - -```java -for (int i = 0; i < list.size(); i++) { - System.out.print(list.get(i) + ","); -} -``` - -第二种:迭代器。 - -```java -Iterator it = list.iterator(); -while (it.hasNext()) { - System.out.print(it.next() + ","); -} -``` - -第三种:for-each。 - -```java -for (String str : list) { - System.out.print(str + ","); -} -``` - -第一种我们略过,第二种用的是 Iterator,第三种看起来是 for-each,其实背后也是 Iterator,看一下反编译后的代码(如下所示)就明白了。 - -```java -Iterator var3 = list.iterator(); - -while(var3.hasNext()) { - String str = (String)var3.next(); - System.out.print(str + ","); -} -``` - -for-each 只不过是个语法糖,让我们开发者在遍历 List 的时候可以写更少的代码,更简洁明了。 - -Iterator 是个接口,JDK 1.2 的时候就有了,用来改进 Enumeration 接口: - -- 允许删除元素(增加了 remove 方法) -- 优化了方法名(Enumeration 中是 hasMoreElements 和 nextElement,不简洁) - -来看一下 Iterator 的源码: - -```java -public interface Iterator { - // 判断集合中是否存在下一个对象 - boolean hasNext(); - // 返回集合中的下一个对象,并将访问指针移动一位 - E next(); - // 删除集合中调用next()方法返回的对象 - default void remove() { - throw new UnsupportedOperationException("remove"); - } -} -``` - -JDK 1.8 时,Iterable 接口中新增了 forEach 方法。该方法接受一个 Consumer 对象作为参数,用于对集合中的每个元素执行指定的操作。该方法的实现方式是使用 for-each 循环遍历集合中的元素,对于每个元素,调用 Consumer 对象的 accept 方法执行指定的操作。 - -```java -default void forEach(Consumer action) { - Objects.requireNonNull(action); - for (T t : this) { - action.accept(t); - } -} -``` - -该方法实现时首先会对 action 参数进行非空检查,如果为 null 则抛出 NullPointerException 异常。然后使用 for-each 循环遍历集合中的元素,并对每个元素调用 action.accept(t) 方法执行指定的操作。由于 Iterable 接口是 Java 集合框架中所有集合类型的基本接口,因此该方法可以被所有实现了 Iterable 接口的集合类型使用。 - -它对 Iterable 的每个元素执行给定操作,具体指定的操作需要自己写Consumer接口通过accept方法回调出来。 - -```java -List list = new ArrayList<>(Arrays.asList(1, 2, 3)); -list.forEach(integer -> System.out.println(integer)); -``` - -写得更浅显易懂点,就是: - -```java -List list = new ArrayList<>(Arrays.asList(1, 2, 3)); -list.forEach(new Consumer() { - @Override - public void accept(Integer integer) { - System.out.println(integer); - } -}); -``` - -如果我们仔细观察ArrayList 或者 LinkedList 的“户口本”就会发现,并没有直接找到 Iterator 的影子。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/iterator-iterable-01.png) - -反而找到了 Iterable! - -```java -public interface Iterable { - Iterator iterator(); -} -``` - -也就是说,List 的关系图谱中并没有直接使用 Iterator,而是使用 Iterable 做了过渡。 - -回头再来看一下第二种遍历 List 的方式。 - -```java -Iterator it = list.iterator(); -while (it.hasNext()) { -} -``` - -发现刚好呼应上了。拿 ArrayList 来说吧,它重写了 Iterable 接口的 iterator 方法: - -```java -public Iterator iterator() { - return new Itr(); -} -``` - -返回的对象 Itr 是个内部类,实现了 Iterator 接口,并且按照自己的方式重写了 hasNext、next、remove 等方法。 - -```java -/** - * ArrayList 迭代器的实现,内部类。 - */ -private class Itr implements Iterator { - - /** - * 游标位置,即下一个元素的索引。 - */ - int cursor; - - /** - * 上一个元素的索引。 - */ - int lastRet = -1; - - /** - * 预期的结构性修改次数。 - */ - int expectedModCount = modCount; - - /** - * 判断是否还有下一个元素。 - * - * @return 如果还有下一个元素,则返回 true,否则返回 false。 - */ - public boolean hasNext() { - return cursor != size; - } - - /** - * 获取下一个元素。 - * - * @return 列表中的下一个元素。 - * @throws NoSuchElementException 如果没有下一个元素,则抛出 NoSuchElementException 异常。 - */ - @SuppressWarnings("unchecked") - public E next() { - // 获取 ArrayList 对象的内部数组 - Object[] elementData = ArrayList.this.elementData; - // 记录当前迭代器的位置 - int i = cursor; - if (i >= size) { - throw new NoSuchElementException(); - } - // 将游标位置加 1,为下一次迭代做准备 - cursor = i + 1; - // 记录上一个元素的索引 - return (E) elementData[lastRet = i]; - } - - /** - * 删除最后一个返回的元素。 - * 迭代器只能删除最后一次调用 next 方法返回的元素。 - * - * @throws ConcurrentModificationException 如果在最后一次调用 next 方法之后列表结构被修改,则抛出 ConcurrentModificationException 异常。 - * @throws IllegalStateException 如果在调用 next 方法之前没有调用 remove 方法,或者在同一次迭代中多次调用 remove 方法,则抛出 IllegalStateException 异常。 - */ - public void remove() { - // 检查在最后一次调用 next 方法之后是否进行了结构性修改 - if (expectedModCount != modCount) { - throw new ConcurrentModificationException(); - } - // 如果上一次调用 next 方法之前没有调用 remove 方法,则抛出 IllegalStateException 异常 - if (lastRet < 0) { - throw new IllegalStateException(); - } - try { - // 调用 ArrayList 对象的 remove(int index) 方法删除上一个元素 - ArrayList.this.remove(lastRet); - // 将游标位置设置为上一个元素的位置 - cursor = lastRet; - // 将上一个元素的索引设置为 -1,表示没有上一个元素 - lastRet = -1; - // 更新预期的结构性修改次数 - expectedModCount = modCount; - } catch (IndexOutOfBoundsException ex) { - throw new ConcurrentModificationException(); - } - } -} -``` - -那可能有些小伙伴会问:为什么不直接将 Iterator 中的核心方法 hasNext、next 放到 Iterable 接口中呢?直接像下面这样使用不是更方便? - -```java -Iterable it = list.iterator(); -while (it.hasNext()) { -} -``` - -从英文单词的后缀语法上来看,(Iterable)able 表示这个 List 是支持迭代的,而 (Iterator)tor 表示这个 List 是如何迭代的。 - -支持迭代与具体怎么迭代显然不能混在一起,否则就乱的一笔。还是各司其职的好。 - -想一下,如果把 Iterator 和 Iterable 合并,for-each 这种遍历 List 的方式是不是就不好办了? - -原则上,只要一个 List 实现了 Iterable 接口,那么它就可以使用 for-each 这种方式来遍历,那具体该怎么遍历,还是要看它自己是怎么实现 Iterator 接口的。 - -Map 就没办法直接使用 for-each,因为 Map 没有实现 Iterable 接口,只有通过 `map.entrySet()`、`map.keySet()`、`map.values()` 这种返回一个 Collection 的方式才能 使用 for-each。 - -如果我们仔细研究 LinkedList 的源码就会发现,LinkedList 并没有直接重写 Iterable 接口的 iterator 方法,而是由它的父类 AbstractSequentialList 来完成。 - -```java -public Iterator iterator() { - return listIterator(); -} -``` - -LinkedList 重写了 listIterator 方法: - -```java -public ListIterator listIterator(int index) { - checkPositionIndex(index); - return new ListItr(index); -} -``` - -这里我们发现了一个新的迭代器 ListIterator,它继承了 Iterator 接口,在遍历List 时可以从任意下标开始遍历,而且支持双向遍历。 - -```java -public interface ListIterator extends Iterator { - boolean hasNext(); - E next(); - boolean hasPrevious(); - E previous(); -} -``` - -我们知道,集合(Collection)不仅有 List,还有 Set,那 Iterator 不仅支持 List,还支持 Set,但 ListIterator 就只支持 List。 - -那可能有些小伙伴会问:为什么不直接让 List 实现 Iterator 接口,而是要用内部类来实现呢? - -这是因为有些 List 可能会有多种遍历方式,比如说 LinkedList,除了支持正序的遍历方式,还支持逆序的遍历方式——DescendingIterator: - -```java -/** - * ArrayList 逆向迭代器的实现,内部类。 - */ -private class DescendingIterator implements Iterator { - - /** - * 使用 ListItr 对象进行逆向遍历。 - */ - private final ListItr itr = new ListItr(size()); - - /** - * 判断是否还有下一个元素。 - * - * @return 如果还有下一个元素,则返回 true,否则返回 false。 - */ - public boolean hasNext() { - return itr.hasPrevious(); - } - - /** - * 获取下一个元素。 - * - * @return 列表中的下一个元素。 - * @throws NoSuchElementException 如果没有下一个元素,则抛出 NoSuchElementException 异常。 - */ - public E next() { - return itr.previous(); - } - - /** - * 删除最后一个返回的元素。 - * 迭代器只能删除最后一次调用 next 方法返回的元素。 - * - * @throws UnsupportedOperationException 如果列表不支持删除操作,则抛出 UnsupportedOperationException 异常。 - * @throws IllegalStateException 如果在调用 next 方法之前没有调用 remove 方法,或者在同一次迭代中多次调用 remove 方法,则抛出 IllegalStateException 异常。 - */ - public void remove() { - itr.remove(); - } -} -``` - -可以看得到,DescendingIterator 刚好利用了 ListIterator 向前遍历的方式。可以通过以下的方式来使用: - -```java -Iterator it = list.descendingIterator(); -while (it.hasNext()) { -} -``` - -好了,关于Iterator与Iterable我们就先聊这么多,总结两点: - -- 学会深入思考,一点点抽丝剥茧,多想想为什么这样实现,很多问题没有自己想象中的那么复杂。 -- 遇到疑惑不放弃,这是提升自己最好的机会,遇到某个疑难的点,解决的过程中会挖掘出很多相关的东西。 - - - - -## 6.8 Java foreach 循环陷阱 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[6.8 Java foreach 循环陷阱](https://tobebetterjavaer.com/collection/fail-fast.html) - - -## 6.9 HashMap详解(附源码) - -这篇文章将通过源码的方式,详细透彻地讲清楚 Java 的 HashMap,包括 hash 方法的原理、HashMap 的扩容机制、HashMap的加载因子为什么是 0.75 而不是 0.6、0.8,以及 HashMap 为什么是线程不安全的,基本上 HashMap 的[常见面试题](https://tobebetterjavaer.com/interview/java-hashmap-13.html),都会在这一篇文章里讲明白。 - -HashMap 是 Java 中常用的数据结构之一,用于存储键值对。在 HashMap 中,每个键都映射到一个唯一的值,可以通过键来快速访问对应的值。 - -HashMap 不仅在日常开发中经常用到,在面试中也是重点考察的对象。 - -以下是 HashMap 增删改查的简单例子: - -**1)增加元素**: - -将一个键值对(元素)添加到 HashMap 中,可以使用 put() 方法。例如,将名字和年龄作为键值对添加到 HashMap 中: - -```java -HashMap map = new HashMap<>(); -map.put("沉默", 20); -map.put("王二", 25); -``` - -**2)删除元素**: - -从 HashMap 中删除一个键值对,可以使用 remove() 方法。例如,删除名字为 "沉默" 的键值对: - -```java -map.remove("沉默"); -``` - -**3)修改元素**: - -修改 HashMap 中的一个键值对,可以使用 put() 方法。例如,将名字为 "沉默" 的年龄修改为 30: - -```java -map.put("沉默", 30); -``` - -为什么和添加元素的方法一样呢?这个我们后面会讲,先简单说一下,是因为 HashMap 的键是唯一的,所以再次 put 的时候会覆盖掉之前的键值对。 - -**4)查找元素**: - -从 HashMap 中查找一个键对应的值,可以使用 get() 方法。例如,查找名字为 "沉默" 的年龄: - -```java -int age = map.get("沉默"); -``` - -在实际应用中,HashMap 可以用于缓存、索引等场景。例如,可以将用户 ID 作为键,用户信息作为值,将用户信息缓存到 HashMap 中,以便快速查找。又如,可以将关键字作为键,文档 ID 列表作为值,将文档索引缓存到 HashMap 中,以便快速搜索文档。 - -HashMap 的实现原理是基于哈希表的,它的底层是一个数组,数组的每个位置可能是一个链表或红黑树,也可能只是一个键值对(后面会讲)。当添加一个键值对时,HashMap 会根据键的哈希值计算出该键对应的数组下标(索引),然后将键值对插入到对应的位置。 - -当通过键查找值时,HashMap 也会根据键的哈希值计算出数组下标,并查找对应的值。 - -### 01、hash方法的原理 - -简单了解 HashMap 后,我们来讨论第一个问题:hash 方法的原理,对吃透 HashMap 会大有帮助。 - -来看一下 hash 方法的源码(JDK 8 中的 HashMap): - -```java -static final int hash(Object key) { - int h; - return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); -} -``` - -这段代码究竟是用来干嘛的呢? - -**将 key 的 hashCode 值进行处理,得到最终的哈希值**。 - -怎么理解这句话呢?不要着急。 - -我们来 new 一个 HashMap,并通过 put 方法添加一个元素。 - -```java -HashMap map = new HashMap<>(); -map.put("chenmo", "沉默"); -``` - -来看一下 put 方法的源码。 - -```java -public V put(K key, V value) { - return putVal(hash(key), key, value, false, true); -} -``` - -看到 hash 方法的身影了吧? - -#### hash 方法的作用 - -前面也说了,HashMap 的底层是通过数组的形式实现的,初始大小是 16(这个后面会讲),先记住。 - -也就是说,HashMap 在添加第一个元素的时候,需要通过键的哈希码在大小为 16 的数组中确定一个位置(索引),怎么确定呢? - -为了方便大家直观的感受,我这里画了一副图,16 个方格子(可以把它想象成一个一个桶),每个格子都有一个编号,对应大小为 16 的数组下标(索引)。 - -![](https://files.mdnice.com/user/3903/65e8110b-71b1-4146-b2fa-7fc4ee8530af.png) - -现在,我们要把 key 为 “chenmo”,value 为“沉默”的键值对放到这 16 个格子中的一个。 - -怎么确定位置(索引)呢? - -我先告诉大家结论,通过这个与运算 `(n - 1) & hash`,其中变量 n 为数组的长度,变量 hash 就是通过 `hash()` 方法计算后的结果。 - -那“chenmo”这个 key 计算后的位置(索引)是多少呢? - -答案是 8,也就是说 `map.put("chenmo", "沉默")` 会把key 为 “chenmo”,value 为“沉默”的键值对放到下标为 8 的位置上(也就是索引为 8 的桶上)。 - -![](https://files.mdnice.com/user/3903/e23299c2-a11f-4f0f-a4e5-138fa1fdb331.png) - -这样大家就会对 HashMap 存放键值对(元素)的时候有一个大致的印象。其中的一点是,hash 方法对计算键值对的位置起到了至关重要的作用。 - -回到 hash 方法: - -```java -static final int hash(Object key) { - int h; - return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); -} -``` - -下面是对该方法的一些解释: - -- 参数 key:需要计算哈希码的键值。 -- `key == null ? 0 : (h = key.hashCode()) ^ (h >>> 16)`:这是一个三目运算符,如果键值为null,则哈希码为0(依旧是说如果键为 null,则存放在第一个位置);否则,通过调用`hashCode()`方法获取键的哈希码,并将其与右移16位的哈希码进行异或运算。 -- `^` 运算符:异或运算符是Java中的一种位运算符,它用于将两个数的二进制位进行比较,如果相同则为0,不同则为1。 -- `h >>> 16`:将哈希码向右移动16位,相当于将原来的哈希码分成了两个16位的部分。 -- 最终返回的是经过异或运算后得到的哈希码值。 - -这短短的一行代码,汇聚不少计算机巨佬们的聪明才智。 - -理论上,哈希值(哈希码)是一个 int 类型,范围从-2147483648 到 2147483648。前后加起来大概 40 亿的映射空间,只要哈希值映射得比较均匀松散,一般是不会出现哈希碰撞(哈希冲突会降低 HashMap 的效率)。 - -但问题是一个 40 亿长度的数组,内存是放不下的。HashMap 扩容之前的数组初始大小只有 16,所以这个哈希值是不能直接拿来用的,用之前要和数组的长度做取模运算(前文提到的 `(n - 1) & hash`),用得到的余数来访问数组下标才行。 - -#### 取模运算 - -取模运算((“Modulo Operation”))和取余运算((“Remainder Operation ”))是两种不同的运算方式,它们在计算机中的实现也不同。 - -在数学中,取模运算和取余运算是等价的,都是计算一个数除以另一个数的余数。例如,10 mod 3 和 10 % 3 都等于 1,因为 10 除以 3 的余数是 1。 - -在计算机中,取模运算和取余运算的差别在于,当被除数为负数时,**取模运算的结果符号与被除数相同,取余运算的结果符号与除数相同**。 - -例如,-10 mod 3 的结果是 -1,而 -10 % 3 的结果是 2,因为 -10 除以 3 的余数是 -1,所以 -10 取模 3 的结果应该是 -1;而 -10 对 3 取余的结果是 2,因为:-10 ÷ 3 = -3 余 -1,由于除数为正数 3,余数的符号应与被除数的符号相同,因此余数应为正数 2,而不是 -1。 - -在 Java 中,取模运算使用 % 运算符,取余运算使用 `Math.floorMod()` 方法。例如,计算 -10 mod 3 和 -10 % 3 的结果: - -```java -int a = -10 % 3; // a = -1 -int b = Math.floorMod(-10, 3); // b = 2 -``` - -需要注意的是,在数学中,取模运算和取余运算都有定义域的限制,即除数不能为 0。在计算机中,除数为 0 会抛出异常或返回 NaN(Not a Number)。 - -HashMap 的取模运算有两处。 - -一处是往 HashMap 中 put 的时候(会调用私有的 `putVal` 方法): - -```java -final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { - // 数组 - HashMap.Node[] tab; - // 元素 - HashMap.Node p; - - // n 为数组的长度 i 为下标 - int n, i; - // 数组为空的时候 - if ((tab = table) == null || (n = tab.length) == 0) - // 第一次扩容后的数组长度 - n = (tab = resize()).length; - // 计算节点的插入位置,如果该位置为空,则新建一个节点插入 - if ((p = tab[i = (n - 1) & hash]) == null) - tab[i] = newNode(hash, key, value, null); -} -``` - -其中 `(n - 1) & hash` 为取模运算,为什么没用 `%`,我们随后解释。 - -一处是从 HashMap 中 get 的时候(会调用 `getNode` 方法): - -```java -final Node getNode(int hash, Object key) { - // 获取当前的数组和长度,以及当前节点链表的第一个节点(根据索引直接从数组中找) - Node[] tab; - Node first, e; - int n; - K k; - if ((tab = table) != null && (n = tab.length) > 0 && - (first = tab[(n - 1) & hash]) != null) { - // 如果第一个节点就是要查找的节点,则直接返回 - if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) - return first; - // 如果第一个节点不是要查找的节点,则遍历节点链表查找 - if ((e = first.next) != null) { - do { - if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) - return e; - } while ((e = e.next) != null); - } - } - // 如果节点链表中没有找到对应的节点,则返回 null - return null; -} -``` - -看到没,取模运算 `(n - 1) & hash` 再次出现,说简单点,就是把键的哈希码经过 `hash()` 方法计算后,再和(数组长度-1)做了一个“与”运算。 - -#### 取模运算%和位运算& - -可能大家在疑惑:**取模运算难道不该用 `%` 吗?为什么要用位运算 `&` 呢**? - -这是因为 `&` 运算比 `%` 更加高效,并且当 b 为 2 的 n 次方时,存在下面这样一个公式。 - -> a % b = a & (b-1) - -用 $2^n$ 替换下 b 就是: - ->a % $2^n$ = a & ($2^n$-1) - -我们来验证一下,假如 a = 14,b = 8,也就是 $2^3$,n=3。 - -14%8(余数为 6)。 - -14 的二进制为 1110,8 的二进制 1000,8-1 = 7,7 的二进制为 0111,1110&0111=0110,也就是 0`*`$2^0$+1`*`$2^1$+1`*`$2^2$+0`*`$2^3$=0+2+4+0=6,14%8 刚好也等于 6。 - -害,计算机就是这么讲道理,没办法,😝 - -这也正好解释了为什么 HashMap 的数组长度要取 2 的整次方。 - -为什么会这样巧呢? - -因为(数组长度-1)正好相当于一个“低位掩码”——这个掩码的低位最好全是 1,这样 & 操作才有意义,否则结果就肯定是 0,那么 & 操作就没有意义了。 - -> a&b 操作的结果是:a、b 中对应位同时为 1,则对应结果位为 1,否则为 0 - -2 的整次幂刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,保证了 hash &(length-1) 的最后一位可能为 0,也可能为 1(这取决于 h 的值),即 & 运算后的结果可能为偶数,也可能为奇数,这样便可以保证哈希值的均匀性。 - -& 操作的结果就是将哈希值的高位全部归零,只保留低位值,用来做数组下标访问。 - -假设某哈希值为 `10100101 11000100 00100101`,用它来做取模运算,我们来看一下结果。HashMap 的初始长度为 16(内部是数组),16-1=15,二进制是 `00000000 00000000 00001111`(高位用 0 来补齐): - -``` - 10100101 11000100 00100101 -& 00000000 00000000 00001111 ----------------------------------- - 00000000 00000000 00000101 -``` - -因为 15 的高位全部是 0,所以 & 运算后的高位结果肯定是 0,只剩下 4 个低位 `0101`,也就是十进制的 5,也就是将哈希值为 `10100101 11000100 00100101` 的键放在数组的第 5 位。 - -当然了,如果你是新手,上面这些 01 串没看懂,也没关系,记住取模运算就行了,记不住取模运算也没关系,记住**取模运算是为了计算数组的下标就可以了**。 - -- put 的时候计算下标,把键值对放到对应的桶上。 -- get 的时候通过下标,把键值对从对应的桶上取出来。 - -#### 为什么取模运算之前要调用 hash 方法呢? - -看下面这个图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hash-01.png) - -某哈希值为 `11111111 11111111 11110000 1110 1010`,将它右移 16 位(h >>> 16),刚好是 `00000000 00000000 11111111 11111111`,再进行异或操作(h ^ (h >>> 16)),结果是 `11111111 11111111 00001111 00010101` - -> 异或(`^`)运算是基于二进制的位运算,采用符号 XOR 或者`^`来表示,运算规则是:如果是同值取 0、异值取 1 - -由于混合了原来哈希值的高位和低位,所以低位的随机性加大了(掺杂了部分高位的特征,高位的信息也得到了保留)。 - -结果再与数组长度-1(`00000000 00000000 00000000 00001111`)做取模运算,得到的下标就是 `00000000 00000000 00000000 00000101`,也就是 5。 - -还记得之前我们假设的某哈希值 `10100101 11000100 00100101` 吗?在没有调用 hash 方法之前,与 15 做取模运算后的结果也是 5,我们不妨来看看调用 hash 之后的取模运算结果是多少。 - -某哈希值 `00000000 10100101 11000100 00100101`(补齐 32 位),将它右移 16 位(h >>> 16),刚好是 `00000000 00000000 00000000 10100101`,再进行异或操作(h ^ (h >>> 16)),结果是 `00000000 10100101 00111011 10000000` - -结果再与数组长度-1(`00000000 00000000 00000000 00001111`)做取模运算,得到的下标就是 `00000000 00000000 00000000 00000000`,也就是 0。 - -综上所述,**hash 方法是用来做哈希值优化的**,把哈希值右移 16 位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。 - -说白了,**hash 方法就是为了增加随机性,让数据元素更加均衡的分布,减少碰撞**。 - -我这里写了一段测试代码,假如 HashMap 的容量就是第一次扩容时候的 16,我在里面放了五个键值对,来看一下键的 hash 值(经过 `hash()` 方法计算后的哈希码)和索引(取模运算后) - -```java -HashMap map = new HashMap<>(); -map.put("chenmo", "沉默"); -map.put("wanger", "王二"); -map.put("chenqingyang", "陈清扬"); -map.put("xiaozhuanling", "小转铃"); -map.put("fangxiaowan", "方小婉"); - -// 遍历 HashMap -for (String key : map.keySet()) { - int h, n = 16; - int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); - int i = (n - 1) & hash; - // 打印 key 的 hash 值 和 索引 i - System.out.println(key + "的hash值 : " + hash +" 的索引 : " + i); -} -``` - -输出结果如下所示: - -``` -xiaozhuanling的hash值 : 14597045 的索引 : 5 -fangxiaowan的hash值 : -392727066 的索引 : 6 -chenmo的hash值 : -1361556696 的索引 : 8 -chenqingyang的hash值 : -613818743 的索引 : 9 -wanger的hash值 : -795084437 的索引 : 11 -``` - -也就是说,此时还没有发生哈希冲突,索引值都是比较均匀分布的,5、6、8、9、11,这其中的很大一部分功劳,就来自于 hash 方法。 - -#### 小结 - -hash 方法的主要作用是将 key 的 hashCode 值进行处理,得到最终的哈希值。由于 key 的 hashCode 值是不确定的,可能会出现哈希冲突,因此需要将哈希值通过一定的算法映射到 HashMap 的实际存储位置上。 - -hash 方法的原理是,先获取 key 对象的 hashCode 值,然后将其高位与低位进行异或操作,得到一个新的哈希值。为什么要进行异或操作呢?因为对于 hashCode 的高位和低位,它们的分布是比较均匀的,如果只是简单地将它们加起来或者进行位运算,容易出现哈希冲突,而异或操作可以避免这个问题。 - -然后将新的哈希值取模(mod),得到一个实际的存储位置。这个取模操作的目的是将哈希值映射到桶(Bucket)的索引上,桶是 HashMap 中的一个数组,每个桶中会存储着一个链表(或者红黑树),装载哈希值相同的键值对(没有相同哈希值的话就只存储一个键值对)。 - -总的来说,HashMap 的 hash 方法就是将 key 对象的 hashCode 值进行处理,得到最终的哈希值,并通过一定的算法映射到实际的存储位置上。这个过程决定了 HashMap 内部键值对的查找效率。 - -### 02、HashMap的扩容机制 - -好,理解了 hash 方法后我们来看第二个问题,HashMap 的扩容机制。 - -大家都知道,数组一旦初始化后大小就无法改变了,所以就有了 [ArrayList](https://tobebetterjavaer.com/collection/arraylist.html)这种“动态数组”,可以自动扩容。 - -HashMap 的底层用的也是数组。向 HashMap 里不停地添加元素,当数组无法装载更多元素时,就需要对数组进行扩容,以便装入更多的元素;除此之外,容量的提升也会相应地提高查询效率,因为“桶(坑)”更多了嘛,原来需要通过链表存储的(查询的时候需要遍历),扩容后可能就有自己专属的“坑位”了(直接就能查出来)。 - -来看这个例子,容量我们定位 16: - -```java -HashMap map = new HashMap<>(); -map.put("chenmo", "沉默"); -map.put("wanger", "王二"); -map.put("chenqingyang", "陈清扬"); -map.put("xiaozhuanling", "小转铃"); -map.put("fangxiaowan", "方小婉"); -map.put("yexin", "叶辛"); -map.put("liuting","刘婷"); -map.put("yaoxiaojuan","姚小娟"); - -// 遍历 HashMap -for (String key : map.keySet()) { - int h, n = 16; - int hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); - int i = (n - 1) & hash; - // 打印 key 的 hash 值 和 索引 i - System.out.println(key + "的hash值 : " + hash +" 的索引 : " + i); -} -``` - -来看输出结果: - -``` -liuting的hash值 : 183821170 的索引 : 2 -xiaozhuanling的hash值 : 14597045 的索引 : 5 -fangxiaowan的hash值 : -392727066 的索引 : 6 -yaoxiaojuan的hash值 : 1231568918 的索引 : 6 -chenmo的hash值 : -1361556696 的索引 : 8 -chenqingyang的hash值 : -613818743 的索引 : 9 -yexin的hash值 : 114873289 的索引 : 9 -wanger的hash值 : -795084437 的索引 : 11 -``` - -看到没? - -- fangxiaowan(方小婉)和 yaoxiaojuan(姚小娟)的索引都是 6; -- chenqingyang(陈清扬)和yexin(叶辛)的索引都是 9 - -这就意味着,要采用拉链法(后面会讲)将他们放在同一个索引的链表上。查询的时候,就不能直接通过索引的方式直接拿到([时间复杂度](https://tobebetterjavaer.com/collection/time-complexity.html)为 O(1)),而要通过遍历的方式(时间复杂度为 O(n))。 - -那假如把数组的长度由 16 扩容为 32 呢? - -将之前示例中的 n 由 16 改为 32 即可得到如下的答案: - -```java -liuting的hash值 : 183821170 的索引 : 18 -xiaozhuanling的hash值 : 14597045 的索引 : 21 -fangxiaowan的hash值 : -392727066 的索引 : 6 -yaoxiaojuan的hash值 : 1231568918 的索引 : 22 -chenmo的hash值 : -1361556696 的索引 : 8 -chenqingyang的hash值 : -613818743 的索引 : 9 -yexin的hash值 : 114873289 的索引 : 9 -wanger的hash值 : -795084437 的索引 : 11 -``` - -可以看到: - -- 虽然chenqingyang(陈清扬)和yexin(叶辛)的索引仍然是 9。 -- 但 fangxiaowan(方小婉)的索引为 6,yaoxiaojuan(姚小娟)的索引由 6 变为 22,各自都有坑了。 - -当然了,数组是无法自动扩容的,所以如果要扩容的话,就需要新建一个大的数组,然后把之前小的数组的元素复制过去,并且要重新计算哈希值和重新分配桶(重新散列),这个过程也是挺耗时的。 - -#### resize方法 - -HashMap 的扩容是通过 resize 方法来实现的,JDK 8 中融入了红黑树(链表长度超过 8 的时候,会将链表转化为红黑树来提高查询效率),对于新手来说,可能比较难理解。 - -为了减轻大家的学习压力,就还使用 JDK 7 的源码,搞清楚了 JDK 7 的,再看 JDK 8 的就会轻松很多。 - -来看 Java7 的 resize 方法源码,我加了注释: - -```java -// newCapacity为新的容量 -void resize(int newCapacity) { - // 小数组,临时过度下 - Entry[] oldTable = table; - // 扩容前的容量 - int oldCapacity = oldTable.length; - // MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30 - if (oldCapacity == MAXIMUM_CAPACITY) { - // 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1 - threshold = Integer.MAX_VALUE; - return; - } - - // 初始化一个新的数组(大容量) - Entry[] newTable = new Entry[newCapacity]; - // 把小数组的元素转移到大数组中 - transfer(newTable, initHashSeedAsNeeded(newCapacity)); - // 引用新的大数组 - table = newTable; - // 重新计算阈值 - threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); -} -``` - -该方法接收一个新的容量 newCapacity,然后将 HashMap 的容量扩大到 newCapacity。 - -首先,方法获取当前 HashMap 的旧数组 oldTable 和旧容量 oldCapacity。如果旧容量已经达到 HashMap 支持的最大容量 MAXIMUM_CAPACITY( 2 的 30 次方),就将新的阈值 threshold 调整为 Integer.MAX_VALUE(2 的 31 次方 - 1),这是因为 HashMap 的容量不能超过 MAXIMUM_CAPACITY。 - -因为 2,147,483,647(Integer.MAX_VALUE) - 1,073,741,824(MAXIMUM_CAPACITY) = 1,073,741,823,刚好相差一倍(HashMap 每次扩容都是之前的一倍)。 - -接着,方法创建一个新的数组 newTable,并将旧数组 oldTable 中的元素转移到新数组 newTable 中。转移过程是通过调用 transfer 方法来实现的。该方法遍历旧数组中的每个桶,并将每个桶中的键值对重新计算哈希值后,将其插入到新数组对应的桶中。 - -转移完成后,方法将 HashMap 内部的数组引用 table 指向新数组 newTable,并重新计算阈值 threshold。新的阈值是新容量 newCapacity 乘以负载因子 loadFactor 的结果,但如果计算结果超过了 HashMap 支持的最大容量 MAXIMUM_CAPACITY,则将阈值设置为 MAXIMUM_CAPACITY + 1,这是因为 HashMap 的元素数量不能超过 MAXIMUM_CAPACITY。 - -#### 新容量newCapacity - -那 newCapacity 是如何计算的呢? - -```java -int newCapacity = oldCapacity * 2; -if (newCapacity < 0 || newCapacity >= MAXIMUM_CAPACITY) { - newCapacity = MAXIMUM_CAPACITY; -} else if (newCapacity < DEFAULT_INITIAL_CAPACITY) { - newCapacity = DEFAULT_INITIAL_CAPACITY; -} -``` - -新容量 newCapacity 被初始化为原容量 oldCapacity 的两倍。然后,如果 newCapacity 超过了 HashMap 的容量限制 MAXIMUM_CAPACITY(2^30),就将 newCapacity 设置为 MAXIMUM_CAPACITY。如果 newCapacity 小于默认初始容量 DEFAULT_INITIAL_CAPACITY(16),就将 newCapacity 设置为 DEFAULT_INITIAL_CAPACITY。这样可以避免新容量太小或太大导致哈希冲突过多或者浪费空间。 - -Java 8 的时候,newCapacity 的计算方式发生了一些细微的变化。 - -```java -int newCapacity = oldCapacity << 1; -if (newCapacity >= DEFAULT_INITIAL_CAPACITY && oldCapacity >= DEFAULT_INITIAL_CAPACITY) { - if (newCapacity > MAXIMUM_CAPACITY) - newCapacity = MAXIMUM_CAPACITY; -} else { - if (newCapacity < DEFAULT_INITIAL_CAPACITY) - newCapacity = DEFAULT_INITIAL_CAPACITY; -} -``` - -注意,`oldCapacity * 2` 变成了 `oldCapacity << 1`,出现了左移(`<<`),这里简单介绍一下: - -``` -a=39 -b = a << 2 -``` - -十进制 39 用 8 位的二进制来表示,就是 00100111,左移两位后是 10011100(低位用 0 补上),再转成十进制数就是 156。 - -移位运算通常可以用来代替乘法运算和除法运算。例如,将 0010011(39)左移两位就是 10011100(156),刚好变成了原来的 4 倍。 - -实际上呢,二进制数左移后会变成原来的 2 倍、4 倍、8 倍,记住这个就好。 - -#### transfer方法 - -接下来,来说 transfer 方法,该方法用来转移,将旧的小数组元素拷贝到新的大数组中。 - -```java -void transfer(Entry[] newTable, boolean rehash) { - // 新的容量 - int newCapacity = newTable.length; - // 遍历小数组 - for (Entry e : table) { - while(null != e) { - // 拉链法,相同 key 上的不同值 - Entry next = e.next; - // 是否需要重新计算 hash - if (rehash) { - e.hash = null == e.key ? 0 : hash(e.key); - } - // 根据大数组的容量,和键的 hash 计算元素在数组中的下标 - int i = indexFor(e.hash, newCapacity); - - // 同一位置上的新元素被放在链表的头部 - e.next = newTable[i]; - - // 放在新的数组上 - newTable[i] = e; - - // 链表上的下一个元素 - e = next; - } - } -} -``` - -该方法接受一个新的 Entry 数组 newTable 和一个布尔值 rehash 作为参数,其中 newTable 表示新的哈希表,rehash 表示是否需要重新计算键的哈希值。 - -在方法中,首先获取新哈希表(数组)的长度 newCapacity,然后遍历旧哈希表中的每个 Entry。对于每个 Entry,使用拉链法将相同 key 值的不同 value 值存储在同一个链表中。如果 rehash 为 true,则需要重新计算键的哈希值,并将新的哈希值存储在 Entry 的 hash 属性中。 - -接着,根据新哈希表的长度和键的哈希值,计算 Entry 在新数组中的位置 i,然后将该 Entry 添加到新数组的 i 位置上。由于新元素需要被放在链表的头部,因此将新元素的下一个元素设置为当前数组位置上的元素。 - -最后,遍历完旧哈希表中的所有元素后,转移工作完成,新的哈希表 newTable 已经包含了旧哈希表中的所有元素。 - -#### 拉链法 - -注意,`e.next = newTable[i]`,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素最终会被放到链表的尾部,这就会导致**在旧数组中同一个链表上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上**。 - -就这点上,Java 8 做了很大的优化(下面会讲)。 - -现在假设 hash 算法就是简单的用键的哈希值(一个 int 值)和数组大小取模(也就是 `hashCode % table.length`)。 - -继续假设: - -- 数组 table 的长度为 2 -- 键的哈希值为 3、7、5 - -取模运算后,哈希冲突都到 table[1] 上了,因为余数为 1。那么扩容前的样子如下图所示。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-resize-01.png) - -数组的容量为 2, key 3、7、5 都在 table[1] 的链表上。 - -假设负载因子(后面会细讲) loadFactor 为 1,也就是当元素的个数大于 table 的长度时进行扩容。 - -扩容后的数组容量为 4。 - -- key 3 取模(3%4)后是 3,放在 table[3] 上。 -- key 7 取模(7%4)后是 3,放在 table[3] 上的链表头部。 -- key 5 取模(5%4)后是 1,放在 table[1] 上。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-resize-02.png) - -按照我们的预期,扩容后的 7 仍然应该在 3 这条链表的后面,但实际上呢? 7 跑到 3 这条链表的头部了。 - -针对 JDK 7 中的这个情况,JDK 8 做了哪些优化呢? - -看下面这张图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-resize-03.png) - -n 为 table 的长度,默认值为 16。 - -- n-1 也就是二进制的 0000 1111(1X$2^0$+1X$2^1$+1X$2^2$+1X$2^3$=1+2+4+8=15); -- key1 哈希值的最后 8 位为 0000 0101 -- key2 哈希值的最后 8 位为 0001 0101(和 key1 不同) -- 做与运算后发生了哈希冲突,索引都在(0000 0101)上。 - -扩容后为 32。 - -- n-1 也就是二进制的 0001 1111(1X$2^0$+1X$2^1$+1X$2^2$+1X$2^3$+1X$2^4$=1+2+4+8+16=31),扩容前是 0000 1111。 -- key1 哈希值的低位为 0000 0101 -- key2 哈希值的低位为 0001 0101(和 key1 不同) -- key1 做与运算后,索引为 0000 0101。 -- key2 做与运算后,索引为 0001 0101。 - -新的索引就会发生这样的变化: - -- 原来的索引是 5(*0* 0101) -- 原来的容量是 16 -- 扩容后的容量是 32 -- 扩容后的索引是 21(*1* 0101),也就是 5+16,也就是原来的索引+原来的容量 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-resize-04.png) - - -也就是说,JDK 8 不需要像 JDK 7 那样重新计算 hash,只需要看原来的hash值新增的那个bit是1还是0就好了,是0的话就表示索引没变,是1的话,索引就变成了“原索引+原来的容量”。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-resize-05.png) - -JDK 8 的这个设计非常巧妙,既省去了重新计算hash的时间,同时,由于新增的1 bit是0还是1是随机的,因此扩容的过程,可以均匀地把之前的节点分散到新的位置上。 - -woc,只能说 HashMap 的作者 Doug Lea、Josh Bloch、Arthur van Hoff、Neal Gafter 真的强——的一笔。 - -#### Java 8 扩容 - -JDK 8 的扩容源代码: - -```java -final Node[] resize() { - Node[] oldTab = table; // 获取原来的数组 table - int oldCap = (oldTab == null) ? 0 : oldTab.length; // 获取数组长度 oldCap - int oldThr = threshold; // 获取阈值 oldThr - int newCap, newThr = 0; - if (oldCap > 0) { // 如果原来的数组 table 不为空 - if (oldCap >= MAXIMUM_CAPACITY) { // 超过最大值就不再扩充了,就只好随你碰撞去吧 - threshold = Integer.MAX_VALUE; - return oldTab; - } - else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // 没超过最大值,就扩充为原来的2倍 - oldCap >= DEFAULT_INITIAL_CAPACITY) - newThr = oldThr << 1; // double threshold - } - else if (oldThr > 0) // initial capacity was placed in threshold - newCap = oldThr; - else { // zero initial threshold signifies using defaults - newCap = DEFAULT_INITIAL_CAPACITY; - newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); - } - // 计算新的 resize 上限 - if (newThr == 0) { - float ft = (float)newCap * loadFactor; - newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? - (int)ft : Integer.MAX_VALUE); - } - threshold = newThr; // 将新阈值赋值给成员变量 threshold - @SuppressWarnings({"rawtypes","unchecked"}) - Node[] newTab = (Node[])new Node[newCap]; // 创建新数组 newTab - table = newTab; // 将新数组 newTab 赋值给成员变量 table - if (oldTab != null) { // 如果旧数组 oldTab 不为空 - for (int j = 0; j < oldCap; ++j) { // 遍历旧数组的每个元素 - Node e; - if ((e = oldTab[j]) != null) { // 如果该元素不为空 - oldTab[j] = null; // 将旧数组中该位置的元素置为 null,以便垃圾回收 - if (e.next == null) // 如果该元素没有冲突 - newTab[e.hash & (newCap - 1)] = e; // 直接将该元素放入新数组 - else if (e instanceof TreeNode) // 如果该元素是树节点 - ((TreeNode)e).split(this, newTab, j, oldCap); // 将该树节点分裂成两个链表 - else { // 如果该元素是链表 - Node loHead = null, loTail = null; // 低位链表的头结点和尾结点 - Node hiHead = null, hiTail = null; // 高位链表的头结点和尾结点 - Node next; - do { // 遍历该链表 - next = e.next; - if ((e.hash & oldCap) == 0) { // 如果该元素在低位链表中 - if (loTail == null) // 如果低位链表还没有结点 - loHead = e; // 将该元素作为低位链表的头结点 - else - loTail.next = e; // 如果低位链表已经有结点,将该元素加入低位链表的尾部 - loTail = e; // 更新低位链表的尾结点 - } - else { // 如果该元素在高位链表中 - if (hiTail == null) // 如果高位链表还没有结点 - hiHead = e; // 将该元素作为高位链表的头结点 - else - hiTail.next = e; // 如果高位链表已经有结点,将该元素加入高位链表的尾部 - hiTail = e; // 更新高位链表的尾结点 - } - } while ((e = next) != null); // - if (loTail != null) { // 如果低位链表不为空 - loTail.next = null; // 将低位链表的尾结点指向 null,以便垃圾回收 - newTab[j] = loHead; // 将低位链表作为新数组对应位置的元素 - } - if (hiTail != null) { // 如果高位链表不为空 - hiTail.next = null; // 将高位链表的尾结点指向 null,以便垃圾回收 - newTab[j + oldCap] = hiHead; // 将高位链表作为新数组对应位置的元素 - } - } - } - } - } - return newTab; // 返回新数组 -} -``` - -1、获取原来的数组 table、数组长度 oldCap 和阈值 oldThr。 - -2、如果原来的数组 table 不为空,则根据扩容规则计算新数组长度 newCap 和新阈值 newThr,然后将原数组中的元素复制到新数组中。 - -3、如果原来的数组 table 为空但阈值 oldThr 不为零,则说明是通过带参数构造函数创建的 HashMap,此时将阈值作为新数组长度 newCap。 - -4、如果原来的数组 table 和阈值 oldThr 都为零,则说明是通过无参数构造函数创建的 HashMap,此时将默认初始容量 DEFAULT_INITIAL_CAPACITY(16)和默认负载因子 DEFAULT_LOAD_FACTOR(0.75)计算出新数组长度 newCap 和新阈值 newThr。 - -5、计算新阈值 threshold,并将其赋值给成员变量 threshold。 - -6、创建新数组 newTab,并将其赋值给成员变量 table。 - -7、如果旧数组 oldTab 不为空,则遍历旧数组的每个元素,将其复制到新数组中。 - -8、返回新数组 newTab。 - -#### 小结 - -HashMap 的内部实现是通过一个数组和链表或红黑树的组合来实现的。当我们往 HashMap 中不断添加元素时,HashMap 会自动进行扩容操作(条件是元素数量达到负载因子(load factor)乘以数组长度时),以保证其存储的元素数量不会超出其容量限制。下面是 HashMap 的扩容机制: - -1、在进行扩容操作时,HashMap 会先将数组的长度扩大一倍,然后将原来的元素重新散列(这个词还是挺贴切的)到新的数组中。由于元素的散列位置是通过 key 的 hashcode 和数组长度取模得到的,因此在数组长度扩大后,元素的散列位置也会发生一些改变。 - -2、在重新散列元素时,如果一个元素的散列位置发生了改变,那么它需要被移动到新的位置。如果新的位置上已经有元素了,那么这个元素就会被添加到链表的末尾,如果链表的长度超过了阈值(8个),那么它将会被转换成红黑树。 - -总之,HashMap 的扩容机制是通过增加数组长度和重新散列元素来实现的,它可以保证 HashMap 的存储容量足够大,同时也可以保证 HashMap 的存储效率和检索效率。但是,由于扩容操作需要耗费一定的时间和空间,因此我们需要在使用 HashMap 时,合理地设置初始容量和负载因子,以避免过多的扩容操作。 - -### 03、加载因子为什么是0.75 - -上一个问题提到了加载因子(或者叫负载因子),那么这个问题我们来讨论为什么加载因子是 0.75 而不是 0.6、0.8。 - -我们知道,HashMap 是用数组+链表/红黑树实现的,我们要想往 HashMap 中添加数据(元素/键值对)或者取数据,就需要确定数据在数组中的下标(索引)。 - -先把数据的键进行一次 hash: - -```java -static final int hash(Object key) { - int h; - return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); -} -``` - -再做一次取模运算确定下标: - -```java -i = (n - 1) & hash -``` - -那这样的过程容易产生两个问题: - -- 数组的容量过小,经过哈希计算后的下标,容易出现冲突; -- 数组的容量过大,导致空间利用率不高。 - -加载因子是用来表示 HashMap 中数据的填满程度: - ->加载因子 = 填入哈希表中的数据个数 / 哈希表的长度 - -这就意味着: - -- 加载因子越小,填满的数据就越少,哈希冲突的几率就减少了,但浪费了空间,而且还会提高扩容的触发几率; -- 加载因子越大,填满的数据就越多,空间利用率就高,但哈希冲突的几率就变大了。 - -好难!!!! - -这就必须在“**哈希冲突**”与“**空间利用率**”两者之间有所取舍,尽量保持平衡,谁也不碍着谁。 - -我们知道,HashMap 是通过拉链法来解决哈希冲突的。 - -为了减少哈希冲突发生的概率,当 HashMap 的数组长度达到一个**临界值**的时候,就会触发扩容,扩容后会将之前小数组中的元素转移到大数组中,这是一个相当耗时的操作。 - -这个临界值由什么来确定呢? - ->临界值 = 初始容量 * 加载因子 - -一开始,HashMap 的容量是 16: - -```java -static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 -``` - -加载因子是 0.75: - -```java -static final float DEFAULT_LOAD_FACTOR = 0.75f; -``` - -也就是说,当 16*0.75=12 时,会触发扩容机制。 - -**为什么加载因子会选择 0.75 呢?为什么不是0.8、0.6呢**? - -这跟统计学里的一个很重要的原理——泊松分布有关。 - -是时候上维基百科了: - ->泊松分布,是一种统计与概率学里常见到的离散概率分布,由法国数学家西莫恩·德尼·泊松在1838年时提出。它会对随机事件的发生次数进行建模,适用于涉及计算在给定的时间段、距离、面积等范围内发生随机事件的次数的应用情形。 - -阮一峰老师曾在一篇博文中详细的介绍了泊松分布和指数分布,大家可以去看一下。 - ->链接:[https://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html](https://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html) - -具体是用这么一个公式来表示的。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-01.png) - -等号的左边,P 表示概率,N表示某种函数关系,t 表示时间,n 表示数量。 - -在 HashMap 的 doc 文档里,曾有这么一段描述: - -``` -Because TreeNodes are about twice the size of regular nodes, we -use them only when bins contain enough nodes to warrant use -(see TREEIFY_THRESHOLD). And when they become too small (due to -removal or resizing) they are converted back to plain bins. In -usages with well-distributed user hashCodes, tree bins are -rarely used. Ideally, under random hashCodes, the frequency of -nodes in bins follows a Poisson distribution -(http://en.wikipedia.org/wiki/Poisson_distribution) with a -parameter of about 0.5 on average for the default resizing -threshold of 0.75, although with a large variance because of -resizing granularity. Ignoring variance, the expected -occurrences of list size k are (exp(-0.5) * pow(0.5, k) / -factorial(k)). The first values are: -0: 0.60653066 -1: 0.30326533 -2: 0.07581633 -3: 0.01263606 -4: 0.00157952 -5: 0.00015795 -6: 0.00001316 -7: 0.00000094 -8: 0.00000006 -more: less than 1 in ten million -``` - -为了便于大家的理解,这里来重温一下 HashMap 的拉链法和红黑树结构。 - -Java 8 之前,HashMap 使用链表来解决冲突,即当两个或者多个键映射到同一个桶时,它们被放在同一个桶的链表上。当链表上的节点(Node)过多时,链表会变得很长,查找的效率([LinkedList](https://tobebetterjavaer.com/collection/linkedlist.html) 的查找效率为 O(n))就会受到影响。 - -Java 8 中,当链表的节点数超过一个阈值(8)时,链表将转为红黑树(节点为TreeNode),红黑树(在讲[TreeMap](https://tobebetterjavaer.com/collection/treemap.html)时会细说)是一种高效的平衡树结构,能够在 O(log n) 的时间内完成插入、删除和查找等操作。这种结构在节点数很多时,可以提高 HashMap 的性能和可伸缩性。 - -好,有了这个背景,我们来把上面的 doc 文档翻译为中文: - -``` -因为TreeNode(红黑树的节点)的大小大约是常规节点(链表的节点 Node)的两倍,所以只有当桶内包含足够多的节点时才使用红黑树(参见TREEIFY_THRESHOLD「阈值,值为8」,节点数量较多时,红黑树可以提高查询效率)。 - -由于删除元素或者调整数组大小(扩容)时(再次散列),红黑树可能会被转换为链表(节点数量小于 8 时),节点数量较少时,链表的效率比红黑树更高,因为红黑树需要更多的内存空间来存储节点。 - -在具有良好分布的hashCode使用中,很少使用红黑树。 - -理想情况下,在随机hashCode下,节点在桶中的频率遵循泊松分布(https://zh.wikipedia.org/wiki/卜瓦松分布),平均缩放阈值为0.75,忽略方差,列表大小k的预期出现次数为(exp(-0.5)* pow(0.5,k)/ factorial(k))。 - -前几个值是: -0: 0.60653066 -1: 0.30326533 -2: 0.07581633 -3: 0.01263606 -4: 0.00157952 -5: 0.00015795 -6: 0.00001316 -7: 0.00000094 -8: 0.00000006 - -更多:小于一千万分之一 -``` - -虽然这段话的本意更多的是表示 jdk 8中为什么拉链长度超过8的时候进行了红黑树转换,但提到了 0.75 这个加载因子,但没提到底为什么。 - -为了搞清楚到底为什么,我看到了这篇文章: - ->参考链接:[https://segmentfault.com/a/1190000023308658](https://segmentfault.com/a/1190000023308658) - -里面提到了一个概念:**二项分布(Binomial Distribution)**。 - -在做一件事情的时候,其结果的概率只有2种情况,和抛硬币一样,不是正面就是反面。 - -假如,我们做了 N 次实验,那么在每次试验中只有两种可能的结果,并且每次实验是独立的,不同实验之间互不影响,每次实验成功的概率都是一样的。 - -以此理论为基础:我们往哈希表中扔数据,如果发生哈希冲突就为失败,否则为成功。 - -我们可以设想,实验的hash值是随机的,并且经过hash运算的键都会映射到hash表的地址空间上,那么这个结果也是随机的。所以,每次put的时候就相当于我们在扔一个16面(HashMap 第一次扩容后的数组默认长度为16)的骰子,扔骰子实验那肯定是相互独立的。碰撞发生即扔了n次有出现重复数字。 - -然后,我们的目的是啥呢? - -就是掷了k次骰子,没有一次是相同的概率,需要尽可能的大些,一般意义上我们肯定要大于0.5(这个数是个理想数)。 - -于是,n次事件里面,碰撞为0的概率,由上面公式得: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-02.png) - -这个概率值需要大于0.5,我们认为这样的hashmap可以提供很低的碰撞率。所以: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-03png) - -这时候,我们对于该公式其实最想求的时候长度s的时候,n为多少次就应该进行扩容了?而负载因子则是$n/s$的值。所以推导如下: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-04.png) - -所以可以得到 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-05.png) - -其中 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-06.png) - -这就是一个求 `∞⋅0`函数极限问题,这里我们先令$s = m+1(m \to \infty)$则转化为 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-07.png) - -我们再令 $x = \frac{1}{m} (x \to 0)$ 则有, - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-08.png) - -所以 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-loadfactor-09.png) - - -考虑到 HashMap的容量有一个要求:它必须是2的n 次幂。当加载因子选择了0.75就可以保证它与容量的乘积为整数。 - -``` -16*0.75=12 -32*0.75=24 -``` - -除了 0.75,0.5~1 之间还有 0.625(5/8)、0.875(7/8)可选,从中位数的角度,挑 0.75 比较完美。另外,维基百科上说,拉链法(解决哈希冲突的一种)的加载因子最好限制在 0.7-0.8以下,超过0.8,查表时的CPU缓存不命中(cache missing)会按照指数曲线上升。 - -综上,0.75 是个比较完美的选择。 - -#### 小结 - -HashMap 的加载因子(load factor,直译为加载因子,意译为负载因子)是指哈希表中填充元素的个数与桶的数量的比值,当元素个数达到负载因子与桶的数量的乘积时,就需要进行扩容。这个值一般选择 0.75,是因为这个值可以在时间和空间成本之间做到一个折中,使得哈希表的性能达到较好的表现。 - -如果负载因子过大,填充因子较多,那么哈希表中的元素就会越来越多地聚集在少数的桶中,这就导致了冲突的增加,这些冲突会导致查找、插入和删除操作的效率下降。同时,这也会导致需要更频繁地进行扩容,进一步降低了性能。 - -如果负载因子过小,那么桶的数量会很多,虽然可以减少冲突,但是在空间利用上面也会有浪费,因此选择 0.75 是为了取得一个平衡点,即在时间和空间成本之间取得一个比较好的平衡点。 - -总之,选择 0.75 这个值是为了在时间和空间成本之间达到一个较好的平衡点,既可以保证哈希表的性能表现,又能够充分利用空间。 - -### 04、线程不安全 - -其实这个问题也不用说太多,但考虑到[面试的时候有些面试官会问](https://tobebetterjavaer.com/interview/java-hashmap-13.html),那就简单说一下。 - -三方面原因: - -- 多线程下扩容会死循环 -- 多线程下 put 会导致元素丢失 -- put 和 get 并发时会导致 get 到 null - -#### 1)多线程下扩容会死循环 - -众所周知,HashMap 是通过拉链法来解决哈希冲突的,也就是当哈希冲突时,会将相同哈希值的键值对通过链表的形式存放起来。 - -JDK 7 时,采用的是头部插入的方式来存放链表的,也就是下一个冲突的键值对会放在上一个键值对的前面(讲扩容的时候讲过了)。扩容的时候就有可能导致出现环形链表,造成死循环。 - -resize 方法的源码: - -```java -// newCapacity为新的容量 -void resize(int newCapacity) { - // 小数组,临时过度下 - Entry[] oldTable = table; - // 扩容前的容量 - int oldCapacity = oldTable.length; - // MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30 - if (oldCapacity == MAXIMUM_CAPACITY) { - // 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1 - threshold = Integer.MAX_VALUE; - return; - } - - // 初始化一个新的数组(大容量) - Entry[] newTable = new Entry[newCapacity]; - // 把小数组的元素转移到大数组中 - transfer(newTable, initHashSeedAsNeeded(newCapacity)); - // 引用新的大数组 - table = newTable; - // 重新计算阈值 - threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); -} -``` - -transfer 方法用来转移,将小数组的元素拷贝到新的数组中。 - -```java -void transfer(Entry[] newTable, boolean rehash) { - // 新的容量 - int newCapacity = newTable.length; - // 遍历小数组 - for (Entry e : table) { - while(null != e) { - // 拉链法,相同 key 上的不同值 - Entry next = e.next; - // 是否需要重新计算 hash - if (rehash) { - e.hash = null == e.key ? 0 : hash(e.key); - } - // 根据大数组的容量,和键的 hash 计算元素在数组中的下标 - int i = indexFor(e.hash, newCapacity); - - // 同一位置上的新元素被放在链表的头部 - e.next = newTable[i]; - - // 放在新的数组上 - newTable[i] = e; - - // 链表上的下一个元素 - e = next; - } - } -} -``` - -注意 `e.next = newTable[i]` 和 `newTable[i] = e` 这两行代码,就会将同一位置上的新元素被放在链表的头部。 - -扩容前的样子假如是下面这样子。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-01.png) - -那么正常扩容后就是下面这样子。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-02.png) - -假设现在有两个线程同时进行扩容,线程 A 在执行到 `newTable[i] = e;` 被挂起,此时线程 A 中:e=3、next=7、e.next=null - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-03.png) - - -线程 B 开始执行,并且完成了数据转移。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-04.png) - - -此时,7 的 next 为 3,3 的 next 为 null。 - -随后线程A获得CPU时间片继续执行 `newTable[i] = e`,将3放入新数组对应的位置,执行完此轮循环后线程A的情况如下: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-05.png) - -执行下一轮循环,此时 e=7,原本线程 A 中 7 的 next 为 5,但由于 table 是线程 A 和线程 B 共享的,而线程 B 顺利执行完后,7 的 next 变成了 3,那么此时线程 A 中,7 的 next 也为 3 了。 - -采用头部插入的方式,变成了下面这样子: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-06.png) - -好像也没什么问题,此时 next = 3,e = 3。 - -进行下一轮循环,但此时,由于线程 B 将 3 的 next 变为了 null,所以此轮循环应该是最后一轮了。 - -接下来当执行完 `e.next=newTable[i]` 即 3.next=7 后,3 和 7 之间就相互链接了,执行完 `newTable[i]=e` 后,3 被头插法重新插入到链表中,执行结果如下图所示: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-07.png) - -套娃开始,元素 5 也就成了弃婴,惨~~~ - -不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序(嗯,等于说了半天白说了,哈哈,这个面试题确实是这样,很水,但有些面试官又确实比较装逼)。 - -#### 2)多线程下 put 会导致元素丢失 - -正常情况下,当发生哈希冲突时,HashMap 是这样的: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-08.png) - -但多线程同时执行 put 操作时,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。 - -put 的源码: - -```java -final V putVal(int hash, K key, V value, boolean onlyIfAbsent, - boolean evict) { - Node[] tab; Node p; int n, i; - - // 步骤①:tab为空则创建 - if ((tab = table) == null || (n = tab.length) == 0) - n = (tab = resize()).length; - - // 步骤②:计算index,并对null做处理 - if ((p = tab[i = (n - 1) & hash]) == null) - tab[i] = newNode(hash, key, value, null); - else { - Node e; K k; - - // 步骤③:节点key存在,直接覆盖value - if (p.hash == hash && - ((k = p.key) == key || (key != null && key.equals(k)))) - e = p; - - // 步骤④:判断该链为红黑树 - else if (p instanceof TreeNode) - e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value); - - // 步骤⑤:该链为链表 - else { - for (int binCount = 0; ; ++binCount) { - if ((e = p.next) == null) { - p.next = newNode(hash, key, value, null); - - //链表长度大于8转换为红黑树进行处理 - if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st - treeifyBin(tab, hash); - break; - } - - // key已经存在直接覆盖value - if (e.hash == hash && - ((k = e.key) == key || (key != null && key.equals(k)))) - break; - p = e; - } - } - - // 步骤⑥、直接覆盖 - if (e != null) { // existing mapping for key - V oldValue = e.value; - if (!onlyIfAbsent || oldValue == null) - e.value = value; - afterNodeAccess(e); - return oldValue; - } - } - ++modCount; - - // 步骤⑦:超过最大容量 就扩容 - if (++size > threshold) - resize(); - afterNodeInsertion(evict); - return null; -} -``` - -问题发生在步骤 ② 这里: - -```java -if ((p = tab[i = (n - 1) & hash]) == null) - tab[i] = newNode(hash, key, value, null); -``` - -两个线程都执行了 if 语句,假设线程 A 先执行了 ` tab[i] = newNode(hash, key, value, null)`,那 table 是这样的: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-09.png) - -接着,线程 B 执行了 ` tab[i] = newNode(hash, key, value, null)`,那 table 是这样的: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/hashmap-thread-nosafe-10.png) - -3 被干掉了。 - -#### 3)put 和 get 并发时会导致 get 到 null - -线程 A 执行put时,因为元素个数超出阈值而出现扩容,线程B 此时执行get,有可能导致这个问题。 - -注意来看 resize 源码: - -```java -final Node[] resize() { - Node[] oldTab = table; - int oldCap = (oldTab == null) ? 0 : oldTab.length; - int oldThr = threshold; - int newCap, newThr = 0; - if (oldCap > 0) { - // 超过最大值就不再扩充了,就只好随你碰撞去吧 - if (oldCap >= MAXIMUM_CAPACITY) { - threshold = Integer.MAX_VALUE; - return oldTab; - } - // 没超过最大值,就扩充为原来的2倍 - else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && - oldCap >= DEFAULT_INITIAL_CAPACITY) - newThr = oldThr << 1; // double threshold - } - else if (oldThr > 0) // initial capacity was placed in threshold - newCap = oldThr; - else { // zero initial threshold signifies using defaults - newCap = DEFAULT_INITIAL_CAPACITY; - newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); - } - // 计算新的resize上限 - if (newThr == 0) { - float ft = (float)newCap * loadFactor; - newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? - (int)ft : Integer.MAX_VALUE); - } - threshold = newThr; - @SuppressWarnings({"rawtypes","unchecked"}) - Node[] newTab = (Node[])new Node[newCap]; - table = newTab; -} -``` - -线程 A 执行完 `table = newTab` 之后,线程 B 中的 table 此时也发生了变化,此时去 get 的时候当然会 get 到 null 了,因为元素还没有转移。 - -参考链接: - -> - [https://blog.csdn.net/lonyw/article/details/80519652](https://blog.csdn.net/lonyw/article/details/80519652) -> - [https://zhuanlan.zhihu.com/p/91636401](https://zhuanlan.zhihu.com/p/91636401) -> - [https://www.zhihu.com/question/20733617](https://www.zhihu.com/question/20733617) -> - [https://zhuanlan.zhihu.com/p/21673805](https://zhuanlan.zhihu.com/p/21673805) - -#### 4)小结 - -HashMap 是线程不安全的主要是因为它在进行插入、删除和扩容等操作时可能会导致链表的结构发生变化,从而破坏了 HashMap 的不变性。具体来说,如果在一个线程正在遍历 HashMap 的链表时,另外一个线程对该链表进行了修改(比如添加了一个节点),那么就会导致链表的结构发生变化,从而破坏了当前线程正在进行的遍历操作,可能导致遍历失败或者出现死循环等问题。 - -为了解决这个问题,Java 提供了线程安全的 HashMap 实现类 [ConcurrentHashMap](https://tobebetterjavaer.com/thread/ConcurrentHashMap.html)。ConcurrentHashMap 内部采用了分段锁(Segment),将整个 Map 拆分为多个小的 HashMap,每个小的 HashMap 都有自己的锁,不同的线程可以同时访问不同的小 Map,从而实现了线程安全。在进行插入、删除和扩容等操作时,只需要锁住当前小 Map,不会对整个 Map 进行锁定,提高了并发访问的效率。 - -### 05、总结 - -HashMap是Java中最常用的集合之一,它是一种键值对存储的数据结构,可以根据键来快速访问对应的值。以下是对HashMap的总结: - -- HashMap采用数组+链表/红黑树的存储结构,能够在O(1)的时间复杂度内实现元素的添加、删除、查找等操作。 -- HashMap是线程不安全的,因此在多线程环境下需要使用[ConcurrentHashMap](https://tobebetterjavaer.com/thread/ConcurrentHashMap.html)来保证线程安全。 -- HashMap的扩容机制是通过扩大数组容量和重新计算hash值来实现的,扩容时需要重新计算所有元素的hash值,因此在元素较多时扩容会影响性能。 -- 在Java 8中,HashMap的实现引入了拉链法、树化等机制来优化大量元素存储的情况,进一步提升了性能。 -- HashMap中的key是唯一的,如果要存储重复的key,则后面的值会覆盖前面的值。 -- HashMap的初始容量和加载因子都可以设置,初始容量表示数组的初始大小,加载因子表示数组的填充因子。一般情况下,初始容量为16,加载因子为0.75。 -- HashMap在遍历时是无序的,因此如果需要有序遍历,可以使用[TreeMap](https://tobebetterjavaer.com/collection/treemap.html)。 - -综上所述,HashMap是一种高效的数据结构,具有快速查找和插入元素的能力,但需要注意线程安全和性能问题。 - - - -## 6.10 LinkedHashMap详解(附源码) - ->这篇继续换个文风来写,给大家一点新鲜的空气。 - -俗话说了,“金无足赤人无完人”,HashMap 也不例外,有一种需求它就满足不了,假如我们需要一个按照插入顺序来排列的键值对集合,那 HashMap 就无能为力了。那该怎么办呢?必须得上今天这篇文章的主角:LinkedHashMap。 - -同学们好啊,还记得 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html) 那篇吗?我自己感觉写得非常棒啊,既通俗易懂,又深入源码,真的是分析得透透彻彻、清清楚楚、明明白白的。(一不小心又甩了三个成语,有文化吧?)HashMap 哪哪都好,真的,只要你想用键值对,第一时间就应该想到它。 - -为了提高查找效率,HashMap 在插入的时候对键做了一次哈希算法,这就导致插入的元素是无序的。 - -对这一点还不太明白的同学,可以再回到 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html) 那一篇,看看 hash 方法,再看看我对 `put()` 方法的讲解,就能明白了,我们这里再来回顾一下。 - -```java -final V putVal(int hash, K key, V value, boolean onlyIfAbsent, - -               boolean evict) { -    HashMap.Node[] tab; HashMap.Node p; int n, i; -    // ①、数组 table 为 null 时,调用 resize 方法创建默认大小的数组 -    if ((tab = table) == null || (n = tab.length) == 0) -        n = (tab = resize()).length; -    // ②、计算下标,如果该位置上没有值,则填充 -    if ((p = tab[i = (n - 1) & hash]) == null) -        tab[i] = newNode(hash, key, value, null); -} -``` - -其中这个公式 `i = (n - 1) & hash` 计算后的值就是键位在数组(桶)中的索引(下标/位置),但这它并不是按照 0、1、2、3、4、5 这样有序的下标将键值对插入到数组当中的,而是有一定的随机性。 - -比如说默认大小为 16 的 HashMap,如果 put 了 4 个键值对,可能下标是 0、4、9、11,那这样的话,在遍历 HashMap 的时候,就不一定能按照插入顺序来了。 - -看下面的例子。 - -```java -// 创建 HashMap 对象,键类型为 String,值类型为 String -Map map = new HashMap<>(); - -// 使用 put() 方法向 HashMap 中添加数据 -map.put("chenmo", "沉默"); -map.put("wanger", "王二"); -map.put("chenqingyang", "陈清扬"); - -// 遍历 HashMap,输出所有键值对 -for (Map.Entry entry : map.entrySet()) { - String key = entry.getKey(); - String value = entry.getValue(); - System.out.println("Key: " + key + ", Value: " + value); -} -``` - -来看输出结果 - -``` -Key: chenmo, Value: 沉默 -Key: chenqingyang, Value: 陈清扬 -Key: wanger, Value: 王二 -``` - -对比一下输出结果就可以看得出来,put 的时候是 沉默、王二、陈清扬的顺序,但遍历的时候就没有按照这个顺序来:沉默、陈清扬、王二,因为 HashMap 是无序的。 - -那怎么保证键值对的插入顺序呢? - -LinkedHashMap 就是为这个需求应运而生的。LinkedHashMap 继承了 HashMap,所以 HashMap 有的关于键值对的功能,它也有了。 - -```java -public class LinkedHashMap - -    extends HashMap - -    implements Map{} -``` - -在此基础上,LinkedHashMap 内部追加了双向链表,来维护元素的插入顺序。注意下面代码中的 before 和 after,它俩就是用来维护当前元素的前一个元素和后一个元素的顺序的。 - -```java -static class Entry extends HashMap.Node { - Entry before, after; - Entry(int hash, K key, V value, Node next) { - super(hash, key, value, next); - } -} -``` - -关于双向链表,同学们可以回头看一遍我写的 [LinkedList](https://tobebetterjavaer.com/collection/linkedlist.html) 那篇文章,会对理解本篇的 LinkedHashMap 有很大的帮助。 - -用 LinkedHashMap 替换 HashMap,再来对比一下输出结果。 - -```java -// 创建 LinkedHashMap 对象,键类型为 String,值类型为 String -Map map = new LinkedHashMap<>(); - -// 使用 put() 方法向 LinkedHashMap 中添加数据 -map.put("chenmo", "沉默"); -map.put("wanger", "王二"); -map.put("chenqingyang", "陈清扬"); - -// 遍历 LinkedHashMap,输出所有键值对 -for (Map.Entry entry : map.entrySet()) { - String key = entry.getKey(); - String value = entry.getValue(); - System.out.println("Key: " + key + ", Value: " + value); -} -``` - -来看输出结果: - -``` -Key: chenmo, Value: 沉默 -Key: wanger, Value: 王二 -Key: chenqingyang, Value: 陈清扬 -``` - -看,LinkedHashMap 是不是保持了插入顺序?这就对了。 - -### 01、插入顺序 - -在 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html) 那篇文章里,我有讲解到一点,不知道同学们记不记得,就是 null 会插入到 HashMap 的第一位。 - -```java -Map hashMap = new HashMap<>(); -hashMap.put("沉", "沉默王二"); -hashMap.put("默", "沉默王二"); -hashMap.put("王", "沉默王二"); -hashMap.put("二", "沉默王二"); -hashMap.put(null, null); - -for (String key : hashMap.keySet()) { -    System.out.println(key + " : " + hashMap.get(key)); -} -``` - -输出的结果是: - -``` -null : null -默 : 沉默王二 -沉 : 沉默王二 -王 : 沉默王二 -二 : 沉默王二 -``` - -虽然 null 最后一位 put 进去的,但在遍历输出的时候,跑到了第一位。 - -那再来对比看一下 LinkedHashMap。 - -```java -Map linkedHashMap = new LinkedHashMap<>(); -linkedHashMap.put("沉", "沉默王二"); -linkedHashMap.put("默", "沉默王二"); -linkedHashMap.put("王", "沉默王二"); -linkedHashMap.put("二", "沉默王二"); -linkedHashMap.put(null, null); - -for (String key : linkedHashMap.keySet()) { -    System.out.println(key + " : " + linkedHashMap.get(key)); -} -``` - -输出结果是: - -``` -沉 : 沉默王二 -默 : 沉默王二 -王 : 沉默王二 -二 : 沉默王二 -null : null -``` - -null 在最后一位插入,在最后一位输出。 - -输出结果可以再次证明,**HashMap 是无序的,LinkedHashMap 是可以维持插入顺序的**。 - -那 LinkedHashMap 是如何做到这一点呢?我相信同学们和我一样,非常希望知道原因。 - -要想搞清楚,就需要深入研究一下 LinkedHashMap 的源码。LinkedHashMap 并未重写 HashMap 的 `put()` 方法,而是重写了 `put()` 方法需要调用的内部方法 `newNode()`。 - -这是 HashMap 的。 - -```java -Node newNode(int hash, K key, V value, Node next) { - return new Node<>(hash, key, value, next); -} -``` - -这是 LinkedHashMap 的。 - -```java -HashMap.Node newNode(int hash, K key, V value, HashMap.Node e) { -    LinkedHashMap.Entry p = -            new LinkedHashMap.Entry<>(hash, key, value, e); -    linkNodeLast(p); -    return p; -} -``` - -前面曾提到 LinkedHashMap.Entry 继承了 HashMap.Node,并且追加了两个字段 before 和 after,用来维持键值对的关系。 - -```java -static class Entry extends HashMap.Node { - Entry before, after; - Entry(int hash, K key, V value, Node next) { - super(hash, key, value, next); - } -} -``` - -在 LinkedHashMap 中,链表中的节点顺序是按照插入顺序维护的。当使用 put() 方法向 LinkedHashMap 中添加键值对时,会将新节点插入到链表的尾部,并更新 before 和 after 属性,以保证链表的顺序关系——由 `linkNodeLast()` 方法来完成: - -```java -/** - * 将指定节点插入到链表的尾部 - * - * @param p 要插入的节点 - */ -private void linkNodeLast(LinkedHashMap.Entry p) { - LinkedHashMap.Entry last = tail; // 获取链表的尾节点 - tail = p; // 将 p 设为尾节点 - if (last == null) - head = p; // 如果链表为空,则将 p 设为头节点 - else { - p.before = last; // 将 p 的前驱节点设为链表的尾节点 - last.after = p; // 将链表的尾节点的后继节点设为 p - } -} -``` - -看到了吧,LinkedHashMap 在添加第一个元素的时候,会把 head 赋值为第一个元素,等到第二个元素添加进来的时候,会把第二个元素的 before 赋值为第一个元素,第一个元素的 afer 赋值为第二个元素。 - -这就保证了键值对是按照插入顺序排列的,明白了吧? - -### 02、访问顺序 - -LinkedHashMap 不仅能够维持插入顺序,还能够维持访问顺序。访问包括调用 `get()` 方法、`remove()` 方法和 `put()` 方法。 - -要维护访问顺序,需要我们在声明 LinkedHashMap 的时候指定三个参数。 - -```java -LinkedHashMap map = new LinkedHashMap<>(16, .75f, true); -``` - -第一个参数和第二个参数,看过 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html) 的同学们应该很熟悉了,指的是初始容量和负载因子。 - -第三个参数如果为 true 的话,就表示 LinkedHashMap 要维护访问顺序;否则,维护插入顺序。默认是 false。 - -```java -Map linkedHashMap = new LinkedHashMap<>(16, .75f, true); -linkedHashMap.put("沉", "沉默王二"); -linkedHashMap.put("默", "沉默王二"); -linkedHashMap.put("王", "沉默王二"); -linkedHashMap.put("二", "沉默王二"); - -System.out.println(linkedHashMap); - -linkedHashMap.get("默"); -System.out.println(linkedHashMap); - -linkedHashMap.get("王"); -System.out.println(linkedHashMap); -``` - -输出的结果如下所示: - -``` -{沉=沉默王二, 默=沉默王二, 王=沉默王二, 二=沉默王二} -{沉=沉默王二, 王=沉默王二, 二=沉默王二, 默=沉默王二} -{沉=沉默王二, 二=沉默王二, 默=沉默王二, 王=沉默王二} -``` - -当我们使用 `get()` 方法访问键位“默”的元素后,输出结果中,`默=沉默王二` 在最后;当我们访问键位“王”的元素后,输出结果中,`王=沉默王二` 在最后,`默=沉默王二` 在倒数第二位。 - -也就是说,最不经常访问的放在头部,这就有意思了。有意思在哪呢? - -我们可以使用 LinkedHashMap 来实现 LRU 缓存,LRU 是 Least Recently Used 的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。 - -```java -/** - * 自定义的 MyLinkedHashMap 类,继承了 Java 中内置的 LinkedHashMap 类。 - * 用于实现一个具有固定大小的缓存,当缓存达到最大容量时,会自动移除最早加入的元素,以腾出空间给新的元素。 - * - * @param 键的类型 - * @param 值的类型 - */ -public class MyLinkedHashMap extends LinkedHashMap { - - private static final int MAX_ENTRIES = 5; // 表示 MyLinkedHashMap 中最多存储的键值对数量 - - /** - * 构造方法,使用 super() 调用了父类的构造函数,并传递了三个参数:initialCapacity、loadFactor 和 accessOrder。 - * - * @param initialCapacity 初始容量 - * @param loadFactor 负载因子 - * @param accessOrder 访问顺序 - */ - public MyLinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { - super(initialCapacity, loadFactor, accessOrder); - } - - /** - * 重写父类的 removeEldestEntry() 方法,用于指示是否应该移除最早加入的元素。 - * 如果返回 true,那么将删除最早加入的元素。 - * - * @param eldest 最早加入的元素 - * @return 如果当前 MyLinkedHashMap 中元素的数量大于 MAX_ENTRIES,返回 true,否则返回 false。 - */ - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_ENTRIES; - } - -} -``` - -MyLinkedHashMap 是一个自定义类,它继承了 LinkedHashMap,并且重写了 `removeEldestEntry()` 方法——使 Map 最多可容纳 5 个元素,超出后就淘汰。 - -我们来测试一下。 - -```java -MyLinkedHashMap map = new MyLinkedHashMap<>(16,0.75f,true); -map.put("沉", "沉默王二"); -map.put("默", "沉默王二"); -map.put("王", "沉默王二"); -map.put("二", "沉默王二"); -map.put("一枚有趣的程序员", "一枚有趣的程序员"); - -System.out.println(map); - -map.put("一枚有颜值的程序员", "一枚有颜值的程序员"); -System.out.println(map); - -map.put("一枚有才华的程序员","一枚有才华的程序员"); -System.out.println(map); -``` - -输出结果如下所示: - -``` -{沉=沉默王二, 默=沉默王二, 王=沉默王二, 二=沉默王二, 一枚有趣的程序员=一枚有趣的程序员} -{默=沉默王二, 王=沉默王二, 二=沉默王二, 一枚有趣的程序员=一枚有趣的程序员, 一枚有颜值的程序员=一枚有颜值的程序员} -{王=沉默王二, 二=沉默王二, 一枚有趣的程序员=一枚有趣的程序员, 一枚有颜值的程序员=一枚有颜值的程序员, 一枚有才华的程序员=一枚有才华的程序员} -``` - -`沉=沉默王二` 和 `默=沉默王二` 依次被淘汰出局。 - -假如在 put “一枚有才华的程序员”之前 get 了键位为“默”的元素: - -```java -MyLinkedHashMap map = new MyLinkedHashMap<>(16,0.75f,true); -map.put("沉", "沉默王二"); -map.put("默", "沉默王二"); -map.put("王", "沉默王二"); -map.put("二", "沉默王二"); -map.put("一枚有趣的程序员", "一枚有趣的程序员"); - -System.out.println(map); - -map.put("一枚有颜值的程序员", "一枚有颜值的程序员"); -System.out.println(map); - -map.get("默"); -map.put("一枚有才华的程序员","一枚有才华的程序员"); -System.out.println(map); -``` - -那输出结果就变了,对吧? - -``` -{沉=沉默王二, 默=沉默王二, 王=沉默王二, 二=沉默王二, 一枚有趣的程序员=一枚有趣的程序员} -{默=沉默王二, 王=沉默王二, 二=沉默王二, 一枚有趣的程序员=一枚有趣的程序员, 一枚有颜值的程序员=一枚有颜值的程序员} -{二=沉默王二, 一枚有趣的程序员=一枚有趣的程序员, 一枚有颜值的程序员=一枚有颜值的程序员, 默=沉默王二, 一枚有才华的程序员=一枚有才华的程序员} -``` - -`沉=沉默王二` 和 `王=沉默王二` 被淘汰出局了。 - -那 LinkedHashMap 是如何来维持访问顺序呢?同学们感兴趣的话,可以研究一下下面这三个方法。 - -```java -void afterNodeAccess(Node p) { } -void afterNodeInsertion(boolean evict) { } -void afterNodeRemoval(Node p) { } -``` - -`afterNodeAccess()` 会在调用 `get()` 方法的时候被调用,`afterNodeInsertion()` 会在调用 `put()` 方法的时候被调用,`afterNodeRemoval()` 会在调用 `remove()` 方法的时候被调用。 - -我来以 `afterNodeAccess()` 为例来讲解一下。 - -```java -/** - * 在访问节点后,将节点移动到链表的尾部 - * - * @param e 要移动的节点 - */ -void afterNodeAccess(HashMap.Node e) { // move node to last - LinkedHashMap.Entry last; - if (accessOrder && (last = tail) != e) { // 如果按访问顺序排序,并且访问的节点不是尾节点 - LinkedHashMap.Entry p = (LinkedHashMap.Entry)e, b = p.before, a = p.after; - p.after = null; // 将要移动的节点的后继节点设为 null - if (b == null) - head = a; // 如果要移动的节点没有前驱节点,则将要移动的节点设为头节点 - else - b.after = a; // 将要移动的节点的前驱节点的后继节点设为要移动的节点的后继节点 - if (a != null) - a.before = b; // 如果要移动的节点有后继节点,则将要移动的节点的后继节点的前驱节点设为要移动的节点的前驱节点 - else - last = b; // 如果要移动的节点没有后继节点,则将要移动的节点的前驱节点设为尾节点 - if (last == null) - head = p; // 如果尾节点为空,则将要移动的节点设为头节点 - else { - p.before = last; // 将要移动的节点的前驱节点设为尾节点 - last.after = p; // 将尾节点的后继节点设为要移动的节点 - } - tail = p; // 将要移动的节点设为尾节点 - ++modCount; // 修改计数器 - } -} -``` - -哪个元素被 get 就把哪个元素放在最后。了解了吧? - -那同学们可能还想知道,为什么 LinkedHashMap 能实现 LRU 缓存,把最不经常访问的那个元素淘汰? - -在插入元素的时候,需要调用 `put()` 方法,该方法最后会调用 `afterNodeInsertion()` 方法,这个方法被 LinkedHashMap 重写了。 - -```java -/** - * 在插入节点后,如果需要,可能会删除最早加入的元素 - * - * @param evict 是否需要删除最早加入的元素 - */ -void afterNodeInsertion(boolean evict) { // possibly remove eldest - LinkedHashMap.Entry first; - if (evict && (first = head) != null && removeEldestEntry(first)) { // 如果需要删除最早加入的元素 - K key = first.key; // 获取要删除元素的键 - removeNode(hash(key), key, null, false, true); // 调用 removeNode() 方法删除元素 - } -} -``` - -`removeEldestEntry()` 方法会判断第一个元素是否超出了可容纳的最大范围,如果超出,那就会调用 `removeNode()` 方法对最不经常访问的那个元素进行删除。 - -### 03、小结 - -由于 LinkedHashMap 要维护双向链表,所以 LinkedHashMap 在插入、删除操作的时候,花费的时间要比 HashMap 多一些。 - -这也是没办法的事,对吧,欲戴皇冠必承其重嘛。既然想要维护元素的顺序,总要付出点代价才行。 - -简单总结一下吧。 - -首先,我们知道 HashMap 是一种常用的哈希表数据结构,它可以快速地进行键值对的查找和插入操作。但是,HashMap 本身并不保证键值对的顺序,如果我们需要按照插入顺序或访问顺序来遍历键值对,就需要使用 LinkedHashMap 了。 - -LinkedHashMap 继承自 HashMap,它在 HashMap 的基础上,增加了一个双向链表来维护键值对的顺序。这个链表可以按照插入顺序或访问顺序排序,它的头节点表示最早插入或访问的元素,尾节点表示最晚插入或访问的元素。这个链表的作用就是让 LinkedHashMap 可以保持键值对的顺序,并且可以按照顺序遍历键值对。 - -LinkedHashMap 还提供了两个构造方法来指定排序方式,分别是按照插入顺序排序和按照访问顺序排序。在按照访问顺序排序的情况下,每次访问一个键值对,都会将该键值对移到链表的尾部,以保证最近访问的元素在最后面。如果需要删除最早加入的元素,可以通过重写 removeEldestEntry() 方法来实现。 - -总之,LinkedHashMap 通过维护一个双向链表来保持键值对的顺序,可以按照插入顺序或访问顺序来遍历键值对。如果你需要按照顺序来遍历键值对,那么 LinkedHashMap 就是你的不二选择了! - - - -## 6.11 TreeMap详解(附源码) - ->下面有请王老师上台,来给大家讲一讲 TreeMap,鼓掌了! - -之前 [LinkedHashMap](https://tobebetterjavaer.com/collection/linkedhashmap.html) 那篇文章里提到过了,HashMap 是无序的,所以有了 LinkedHashMap,加上了双向链表后,就可以保持元素的插入顺序和访问顺序,那 TreeMap 呢? - -TreeMap 由红黑树实现,可以保持元素的自然顺序,或者实现了 Comparator 接口的自定义顺序。 - -可能有些同学不了解红黑树,我这里来普及一下: - -> 红黑树(英语:Red–black tree)是一种自平衡的二叉查找树(Binary Search Tree),结构复杂,但却有着良好的性能,完成查找、插入和删除的[时间复杂度](https://tobebetterjavaer.com/collection/time-complexity.html)均为 log(n)。 - -二叉查找树是一种常见的树形结构,它的每个节点都包含一个键值对。每个节点的左子树节点的键值小于该节点的键值,右子树节点的键值大于该节点的键值,这个特性使得二叉查找树非常适合进行数据的查找和排序操作。 - -下面是一个简单的手绘图,展示了一个二叉查找树的结构: - -``` - 8 - / \ - 3 10 - / \ \ - 1 6 14 - / \ / - 4 7 13 -``` - -在上面这个二叉查找树中,根节点是 8,左子树节点包括 3、1、6、4 和 7,右子树节点包括 10、14 和 13。 - -- 3<8<10 -- 1<3<6 -- 4<6<7 -- 10<14 -- 13<14 - -这是一颗典型的二叉查找树: - -- 1)左子树上所有节点的值均小于或等于它的根结点的值。 -- 2)右子树上所有节点的值均大于或等于它的根结点的值。 -- 3)左、右子树也分别为二叉查找树。 - - - - - - -二叉查找树用来查找非常方面,从根节点开始遍历,如果当前节点的键值等于要查找的键值,则查找成功;如果要查找的键值小于当前节点的键值,则继续遍历左子树;如果要查找的键值大于当前节点的键值,则继续遍历右子树。如果遍历到叶子节点仍然没有找到,则查找失败。 - -插入操作也非常简单,从根节点开始遍历,如果要插入的键值小于当前节点的键值,则将其插入到左子树中;如果要插入的键值大于当前节点的键值,则将其插入到右子树中。如果要插入的键值已经存在于树中,则更新该节点的值。 - -删除操作稍微复杂一些,需要考虑多种情况,包括要删除的节点是叶子节点、要删除的节点只有一个子节点、要删除的节点有两个子节点等等。 - -总之,二叉查找树是一种非常常用的数据结构,它可以帮助我们实现数据的查找、排序和删除等操作。 - -理解二叉查找树了吧? - -不过,二叉查找树有一个明显的不足,就是容易变成瘸子,就是一侧多,一侧少,比如说这样: - -``` - 6 - / \ - 4 8 - / / \ - 3 7 9 - / - 1 -``` - -在上面这个不平衡的二叉查找树中,左子树比右子树高。根节点是 6,左子树节点包括 4、3 和 1,右子树节点包括 8、7 和 9。 - -由于左子树比右子树高,这个不平衡的二叉查找树可能会导致查找、插入和删除操作的效率下降。 - -来一个更极端的情况。 - -``` - 1 - \ - 2 - \ - 3 - \ - 4 - \ - 5 - \ - 6 -``` - -在上面这个极度不平衡的二叉查找树中,所有节点都只有一个右子节点,根节点是 1,右子树节点包括 2、3、4、5 和 6。 - -这种极度不平衡的二叉查找树会导致查找、插入和删除操作的效率急剧下降,因为每次操作都只能在右子树中进行,而左子树几乎没有被利用到。 - -查找的效率就要从 log(n) 变成 o(n) 了(戳[这里](https://tobebetterjavaer.com/collection/time-complexity.html)了解时间复杂度),对吧? - -必须要平衡一下,对吧?于是就有了平衡二叉树,左右两个子树的高度差的绝对值不超过 1,就像下图这样: - -``` - 8 - / \ - 4 12 - / \ / \ - 2 6 10 14 - / \ / \ - 5 7 13 15 -``` - -根节点是 8,左子树节点包括 4、2、6、5 和 7,右子树节点包括 12、10、14、13 和 15。左子树和右子树的高度差不超过1,因此它是一个平衡二叉查找树。 - -平衡二叉树就像是一棵树形秤,它的左右两边的重量要尽可能的平衡。当我们往平衡二叉树中插入一个节点时,平衡二叉树会自动调整节点的位置,以保证树的左右两边的高度差不超过1。类似地,当我们删除一个节点时,平衡二叉树也会自动调整节点的位置,以保证树的左右两边的高度差不超过1。 - -常见的平衡二叉树包括AVL树、红黑树等等,它们都是通过旋转操作来调整树的平衡,使得左子树和右子树的高度尽可能接近。 - -AVL树的示意图: - -``` - 8 - / \ - 4 12 - / \ / \ - 2 6 10 14 - / \ - 5 7 -``` - -AVL树是一种高度平衡的二叉查找树,它要求左子树和右子树的高度差不超过1。由于AVL树的平衡度比较高,因此在进行插入和删除操作时需要进行更多的旋转操作来保持平衡,但是在查找操作时效率较高。AVL树适用于读操作比较多的场景。 - -例如,对于一个需要频繁进行查找操作的场景,如字典树、哈希表等数据结构,可以使用AVL树来进行优化。另外,AVL树也适用于需要保证数据有序性的场景,如数据库中的索引。 - -AVL树最初由两位苏联的计算机科学家,Adelson-Velskii和Landis,于1962年提出。因此,AVL树就以他们两人名字的首字母缩写命名了。 - -AVL树的发明对计算机科学的发展有着重要的影响,不仅为后来的平衡二叉树提供了基础,而且为其他领域的数据结构和算法提供了启示。 - -红黑树的示意图(R 即 Red「红」、B 即 Black「黑」): - -``` - 8B - / \ - 4R 12R - / \ / \ - 2B 6B 10B 14B - / \ - 5R 7R -``` - -红黑树,顾名思义,就是节点是红色或者黑色的平衡二叉树,它通过颜色的约束来维持二叉树的平衡,它要求任意一条路径上的黑色节点数目相同,同时还需要满足一些其他特定的条件,如红色节点的父节点必须为黑色节点等。 - -- 1)每个节点都只能是红色或者黑色 -- 2)根节点是黑色 -- 3)每个叶节点(NIL 节点,空节点)是黑色的。 -- 4)如果一个节点是红色的,则它两个子节点都是黑色的。也就是说在一条路径上不能出现相邻的两个红色节点。 -- 5)从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。 - -由于红黑树的平衡度比AVL树稍低,因此在进行插入和删除操作时需要进行的旋转操作较少,但是在查找操作时效率仍然较高。红黑树适用于读写操作比较均衡的场景。 - -那,关于红黑树,同学们就先了解到这,脑子里有个大概的印象,知道 TreeMap 是个什么玩意。 - -### 01、自然顺序 - -默认情况下,TreeMap 是根据 key 的自然顺序排列的。比如说整数,就是升序,1、2、3、4、5。 - -```java -TreeMap mapInt = new TreeMap<>(); -mapInt.put(3, "沉默王二"); -mapInt.put(2, "沉默王二"); -mapInt.put(1, "沉默王二"); -mapInt.put(5, "沉默王二"); -mapInt.put(4, "沉默王二"); - -System.out.println(mapInt); -``` - -输出结果如下所示: - -``` -{1=沉默王二, 2=沉默王二, 3=沉默王二, 4=沉默王二, 5=沉默王二} -``` - -TreeMap 是怎么做到的呢?想一探究竟,就得上源码了,来看 TreeMap 的 `put()` 方法: - -```java -public V put(K key, V value) { - Entry t = root; // 将根节点赋值给变量t - if (t == null) { // 如果根节点为null,说明TreeMap为空 - compare(key, key); // type (and possibly null) check,检查key的类型是否合法 - root = new Entry<>(key, value, null); // 创建一个新节点作为根节点 - size = 1; // size设置为1 - return null; // 返回null,表示插入成功 - } - int cmp; - Entry parent; - // split comparator and comparable paths,根据使用的比较方法进行查找 - Comparator cpr = comparator; // 获取比较器 - if (cpr != null) { // 如果使用了Comparator - do { - parent = t; // 将当前节点赋值给parent - cmp = cpr.compare(key, t.key); // 使用Comparator比较key和t的键的大小 - if (cmp < 0) // 如果key小于t的键 - t = t.left; // 在t的左子树中查找 - else if (cmp > 0) // 如果key大于t的键 - t = t.right; // 在t的右子树中查找 - else // 如果key等于t的键 - return t.setValue(value); // 直接更新t的值 - } while (t != null); - } - else { // 如果没有使用Comparator - if (key == null) // 如果key为null - throw new NullPointerException(); // 抛出NullPointerException异常 - Comparable k = (Comparable) key; // 将key强制转换为Comparable类型 - do { - parent = t; // 将当前节点赋值给parent - cmp = k.compareTo(t.key); // 使用Comparable比较key和t的键的大小 - if (cmp < 0) // 如果key小于t的键 - t = t.left; // 在t的左子树中查找 - else if (cmp > 0) // 如果key大于t的键 - t = t.right; // 在t的右子树中查找 - else // 如果key等于t的键 - return t.setValue(value); // 直接更新t的值 - } while (t != null); - } - // 如果没有找到相同的键,需要创建一个新节点插入到TreeMap中 - Entry e = new Entry<>(key, value, parent); // 创建一个新节点 - if (cmp < 0) // 如果key小于parent的键 - parent.left = e; // 将e作为parent的左子节点 - else - parent.right = e; // 将e作为parent的右子节点 - fixAfterInsertion(e); // 插入节点后需要进行平衡操作 - size++; // size加1 - return null; // 返回null,表示插入成功 -} -``` - -- 首先定义一个Entry类型的变量t,用于表示当前的根节点; -- 如果t为null,说明TreeMap为空,直接创建一个新的节点作为根节点,并将size设置为1; -- 如果t不为null,说明需要在TreeMap中查找键所对应的节点。因为TreeMap中的元素是有序的,所以可以使用二分查找的方式来查找节点; -- 如果TreeMap中使用了Comparator来进行排序,则使用Comparator进行比较,否则使用Comparable进行比较。如果查找到了相同的键,则直接更新键所对应的值; -- 如果没有查找到相同的键,则创建一个新的节点,并将其插入到TreeMap中。然后使用fixAfterInsertion()方法来修正插入节点后的平衡状态; -- 最后将TreeMap的size加1,然后返回null。如果更新了键所对应的值,则返回原先的值。 - -注意 `cmp = k.compareTo(t.key)` 这行代码,就是用来进行 key 比较的,由于此时 key 是 String,所以就会调用 String 类的 `compareTo()` 方法进行比较。 - -```java -public int compareTo(String anotherString) { - // 获取当前字符串和另一个字符串的长度 - int len1 = value.length; - int len2 = anotherString.value.length; - // 取两个字符串长度的较短者作为比较的上限 - int lim = Math.min(len1, len2); - // 获取当前字符串和另一个字符串的字符数组 - char v1[] = value; - char v2[] = anotherString.value; - - int k = 0; - // 对两个字符串的每个字符进行比较 - while (k < lim) { - char c1 = v1[k]; - char c2 = v2[k]; - // 如果两个字符不相等,返回它们的差值 - if (c1 != c2) { - return c1 - c2; - } - k++; - } - // 如果两个字符串前面的字符都相等,返回它们长度的差值 - return len1 - len2; -} -``` - -来看下面的示例。 - -```java -TreeMap mapString = new TreeMap<>(); -mapString.put("c", "沉默王二"); -mapString.put("b", "沉默王二"); -mapString.put("a", "沉默王二"); -mapString.put("e", "沉默王二"); -mapString.put("d", "沉默王二"); - -System.out.println(mapString); -``` - -输出结果如下所示: - -``` -{a=沉默王二, b=沉默王二, c=沉默王二, d=沉默王二, e=沉默王二} -``` - -从结果可以看得出,是按照字母的升序进行排序的。 - -### 02、自定义排序 - -如果自然顺序不满足,那就可以在声明 TreeMap 对象的时候指定排序规则。 - -```java -TreeMap mapIntReverse = new TreeMap<>(Comparator.reverseOrder()); -mapIntReverse.put(3, "沉默王二"); -mapIntReverse.put(2, "沉默王二"); -mapIntReverse.put(1, "沉默王二"); -mapIntReverse.put(5, "沉默王二"); -mapIntReverse.put(4, "沉默王二"); - -System.out.println(mapIntReverse); -``` - -TreeMap 提供了可以指定排序规则的构造方法: - -```java -public TreeMap(Comparator comparator) { -    this.comparator = comparator; -} -``` - -`Comparator.reverseOrder()` 返回的是 Collections.ReverseComparator 对象,就是用来反转顺序的,非常方便。 - -```java -private static class ReverseComparator - implements Comparator>, Serializable { - // 单例模式,用于表示逆序比较器 - static final ReverseComparator REVERSE_ORDER - = new ReverseComparator(); - - // 实现比较方法,对两个实现了Comparable接口的对象进行逆序比较 - public int compare(Comparable c1, Comparable c2) { - return c2.compareTo(c1); // 调用c2的compareTo()方法,以c1为参数,实现逆序比较 - } - - // 反序列化时,返回Collections.reverseOrder(),保证单例模式 - private Object readResolve() { - return Collections.reverseOrder(); - } - - // 返回正序比较器 - @Override - public Comparator> reversed() { - return Comparator.naturalOrder(); - } -} -``` - -所以,输出结果如下所示: - -``` -{5=沉默王二, 4=沉默王二, 3=沉默王二, 2=沉默王二, 1=沉默王二} -``` - -HashMap 是无序的,插入的顺序随着元素的增加会不停地变动。但 TreeMap 能够至始至终按照指定的顺序排列,这对于需要自定义排序的场景,实在是太有用了! - -### 03、排序的好处 - -既然 TreeMap 的元素是经过排序的,那找出最大的那个,最小的那个,或者找出所有大于或者小于某个值的键来说,就方便多了。 - -```java -Integer highestKey = mapInt.lastKey(); -Integer lowestKey = mapInt.firstKey(); -Set keysLessThan3 = mapInt.headMap(3).keySet(); -Set keysGreaterThanEqTo3 = mapInt.tailMap(3).keySet(); - -System.out.println(highestKey); -System.out.println(lowestKey); - -System.out.println(keysLessThan3); -System.out.println(keysGreaterThanEqTo3); -``` - -TreeMap 考虑得很周全,恰好就提供了 `lastKey()`、`firstKey()` 这样获取最后一个 key 和第一个 key 的方法。 - -`headMap()` 获取的是到指定 key 之前的 key;`tailMap()` 获取的是指定 key 之后的 key(包括指定 key)。 - -来看一下输出结果: - -``` -5 -1 -[1, 2] -[3, 4, 5] -``` - -再来看一下例子: - -```java -TreeMap treeMap = new TreeMap<>(); -treeMap.put(1, "value1"); -treeMap.put(2, "value2"); -treeMap.put(3, "value3"); -treeMap.put(4, "value4"); -treeMap.put(5, "value5"); - -// headMap示例,获取小于3的键值对 -Map headMap = treeMap.headMap(3); -System.out.println(headMap); // 输出 {1=value1, 2=value2} - -// tailMap示例,获取大于等于4的键值对 -Map tailMap = treeMap.tailMap(4); -System.out.println(tailMap); // 输出 {4=value4, 5=value5} - -// subMap示例,获取大于等于2且小于4的键值对 -Map subMap = treeMap.subMap(2, 4); -System.out.println(subMap); // 输出 {2=value2, 3=value3} -``` - -headMap、tailMap、subMap方法分别获取了小于3、大于等于4、大于等于2且小于4的键值对。 - - -### 04、如何选择 Map - -在学习 TreeMap 之前,我们已经学习了 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html) 和 [LinkedHashMap](https://tobebetterjavaer.com/collection/linkedhashmap.html) ,那如何从它们三个中间选择呢? - -需要考虑以下因素: - -- 是否需要按照键的自然顺序或者自定义顺序进行排序。如果需要按照键排序,则可以使用 TreeMap;如果不需要排序,则可以使用 HashMap 或 LinkedHashMap。 -- 是否需要保持插入顺序。如果需要保持插入顺序,则可以使用 LinkedHashMap;如果不需要保持插入顺序,则可以使用 TreeMap 或 HashMap。 -- 是否需要高效的查找。如果需要高效的查找,则可以使用 LinkedHashMap 或 HashMap,因为它们的查找操作的时间复杂度为 O(1),而是 TreeMap 是 O(log n)。 - ->LinkedHashMap 内部使用哈希表来存储键值对,并使用一个双向链表来维护插入顺序,但查找操作只需要在哈希表中进行,与链表无关,所以时间复杂度为 O(1) - -来个表格吧,一目了然。 - -特性| TreeMap| HashMap| LinkedHashMap ----|---|---|---| -排序| 支持| 不支持| 不支持 -插入顺序| 不保证| 不保证| 保证 -查找效率| O(log n)| O(1)| O(1) -空间占用| 通常较大| 通常较小| 通常较大 -适用场景| 需要排序的场景| 无需排序的场景| 需要保持插入顺序 - -好了,下课,关于 TreeMap 我们就讲到这里吧,希望同学们都能对 TreeMap 有一个清晰的认识。我们下节课见~ - - - -## 6.12 双端队列ArrayDeque详解 - ->好,我们这节继续有请王老师上台来给大家讲 ArrayDeque,鼓掌欢迎了👏🏻。 - -Java 里有一个叫做*Stack*的类,却没有叫做*Queue*的类(它只是个接口名字,和类还不一样)。 - -```java -public interface Queue extends Collection {} -``` - -当需要使用栈时,Java 已不推荐使用*Stack*,而是推荐使用更高效的*ArrayDeque*(双端队列),原因我们第一次讲[集合框架](https://tobebetterjavaer.com/collection/gailan.html)的时候,其实已经聊过了,Stack 是一个“原始”类,它的核心方法上都加了 `synchronized` 关键字以确保线程安全,当我们不需要线程安全(比如说单线程环境下)性能就会比较差。 - -![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection//arraydeque-51e3552c-af39-4d00-8494-1ff0a4913357.png) - -也就是说,当需要使用栈时候,请首选*ArrayDeque*。 - -```java -// 声明一个双端队列 -ArrayDeque stack = new ArrayDeque<>(); - -// 增加元素 -stack.push("沉默"); -stack.push("王二"); -stack.push("陈清扬"); - -// 获取栈顶元素 -String top = stack.peek(); -System.out.println("栈顶元素为:" + top); // 陈清扬 - -// 弹出栈顶元素 -String pop = stack.pop(); -System.out.println("弹出的元素为:" + pop); // 陈清扬 - -// 修改栈顶元素 -stack.pop(); -stack.push("小明"); -System.out.println("修改后的栈为:" + stack); // [沉默, 小明] - -// 遍历队列查找元素 -Iterator iterator = stack.iterator(); -int index = -1; -String target = "王二"; -while (iterator.hasNext()) { - String element = iterator.next(); - index++; - if (element.equals(target)) { - break; - } -} - -if (index == -1) { - System.out.println("元素 " + target + " 不存在于队列中"); -} else { - System.out.println("元素 " + target + " 在队列中的位置为:" + index); -} -``` - -在上面的示例中,我们先创建了一个 ArrayDeque 对象,然后使用 push 方法向栈中添加了三个元素。接着使用 peek 方法获取栈顶元素,使用 pop 方法弹出栈顶元素,使用 pop 和 push 方法修改栈顶元素,使用迭代器查找元素在栈中的位置。 - -ArrayDeque 又实现了 Deque 接口(Deque 又实现了 Queue 接口): - -```java -public class ArrayDeque extends AbstractCollection - implements Deque, Cloneable, Serializable -{} -``` - -因此,当我们需要使用队列的时候,也可以选择 ArrayDeque。 - -```java -ArrayDeque queue = new ArrayDeque<>(); - -// 增加元素 -queue.offer("沉默"); -queue.offer("王二"); -queue.offer("陈清扬"); - -// 获取队首元素 -String front = queue.peek(); -System.out.println("队首元素为:" + front); // 沉默 - -// 弹出队首元素 -String poll = queue.poll(); -System.out.println("弹出的元素为:" + poll); // 沉默 - -// 修改队列中的元素 -queue.poll(); -queue.offer("小明"); -System.out.println("修改后的队列为:" + queue); // [陈清扬, 小明] - -// 查找元素 -Iterator iterator = queue.iterator(); -int index = 0; -while (iterator.hasNext()) { - String element = iterator.next(); - if (element.equals("王二")) { - System.out.println("元素在队列中的位置为:" + index); // 0 - break; - } - index++; -} -``` - -在上面的示例中,我们先创建了一个 ArrayDeque 对象,然后使用 offer 方法向队列中添加了三个元素。接着使用 peek 方法获取队首元素,使用 poll 方法弹出队首元素,使用 poll 和 offer 方法修改队列中的元素,使用迭代器查找元素在队列中的位置。 - -[我们前面讲了](https://tobebetterjavaer.com/collection/gailan.html),LinkedList不只是个 List,还是一个 Queue,它也实现了 Deque 接口。 - -```java -public class LinkedList - extends AbstractSequentialList - implements List, Deque, Cloneable, java.io.Serializable -{} -``` - -所以,当我们需要使用队列时,还可以选择[LinkedList](https://tobebetterjavaer.com/collection/linkedlist.html)。 - -```java -// 创建一个 LinkedList 对象 -LinkedList queue = new LinkedList<>(); - -// 添加元素 -queue.offer("沉默"); -queue.offer("王二"); -queue.offer("陈清扬"); -System.out.println(queue); // 输出 [沉默, 王二, 陈清扬] - -// 删除元素 -queue.poll(); -System.out.println(queue); // 输出 [王二, 陈清扬] - -// 修改元素:LinkedList 中的元素不支持直接修改,需要先删除再添加 -String first = queue.poll(); -queue.offer("王大二"); -System.out.println(queue); // 输出 [陈清扬, 王大二] - -// 查找元素:LinkedList 中的元素可以使用 get() 方法进行查找 -System.out.println(queue.get(0)); // 输出 陈清扬 -System.out.println(queue.contains("沉默")); // 输出 false - -// 查找元素:使用迭代器的方式查找陈清扬 -// 使用迭代器依次遍历元素并查找 -Iterator iterator = queue.iterator(); -while (iterator.hasNext()) { - String element = iterator.next(); - if (element.equals("陈清扬")) { - System.out.println("找到了:" + element); - break; - } -} -``` - -在使用 LinkedList 作为队列时,可以使用 offer() 方法将元素添加到队列的末尾,使用 poll() 方法从队列的头部删除元素,使用迭代器或者 poll() 方法依次遍历元素。 - -### 栈和队列 - -要讲栈和队列,首先要讲*Deque*接口。*Deque*的含义是“double ended queue”,即双端队列,它既可以当作栈使用,也可以当作队列使用。下表列出了*Deque*与*Queue*相对应的接口: - -| Queue Method | Equivalent Deque Method | 说明 | -| ------------ | ----------------------- | -------------------------------------- | -| add(e) | addLast(e) | 向队尾插入元素,失败则抛出异常 | -| offer(e) | offerLast(e) | 向队尾插入元素,失败则返回`false` | -| remove() | removeFirst() | 获取并删除队首元素,失败则抛出异常 | -| poll() | pollFirst() | 获取并删除队首元素,失败则返回`null` | -| element() | getFirst() | 获取但不删除队首元素,失败则抛出异常 | -| peek() | peekFirst() | 获取但不删除队首元素,失败则返回`null` | - -下表列出了*Deque*与*Stack*对应的接口: - -| Stack Method | Equivalent Deque Method | 说明 | -| ------------ | ----------------------- | -------------------------------------- | -| push(e) | addFirst(e) | 向栈顶插入元素,失败则抛出异常 | -| 无 | offerFirst(e) | 向栈顶插入元素,失败则返回`false` | -| pop() | removeFirst() | 获取并删除栈顶元素,失败则抛出异常 | -| 无 | pollFirst() | 获取并删除栈顶元素,失败则返回`null` | -| peek() | getFirst() | 获取但不删除栈顶元素,失败则抛出异常 | -| 无 | peekFirst() | 获取但不删除栈顶元素,失败则返回`null` | - -上面两个表共定义了*Deque*的 12 个接口。 - -添加,删除,取值都有两套接口,它们功能相同,区别是对失败情况的处理不同。 - -**一套接口遇到失败就会抛出异常,另一套遇到失败会返回特殊值(`false`或`null`)**。除非某种实现对容量有限制,大多数情况下,添加操作是不会失败的。 - -**虽然*Deque*的接口有 12 个之多,但无非就是对容器的两端进行操作,或添加,或删除,或查看**。明白了这一点讲解起来就会非常简单。 - -*ArrayDeque*和*LinkedList*是*Deque*的两个通用实现,由于官方更推荐使用*ArrayDeque*用作栈和队列,加之上一篇已经讲解过[LinkedList](https://tobebetterjavaer.com/collection/linkedlist.html),本文将着重讲解*ArrayDeque*的具体实现。 - -从名字可以看出*ArrayDeque*底层通过数组实现,为了满足可以同时在数组两端插入或删除元素的需求,该数组还必须是循环的,即**循环数组(circular array)**,也就是说数组的任何一点都可能被看作起点或者终点。 - -*ArrayDeque*是非线程安全的(not thread-safe),当多个线程同时使用的时候,需要手动同步;另外,该容器不允许放入`null`元素。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/arraydeque-1e7086a3-3d31-4553-aa16-5eaf2193649e.png) - - -上图中我们看到,**`head`指向首端第一个有效元素,`tail`指向尾端第一个可以插入元素的空位**。因为是循环数组,所以`head`不一定总等于 0,`tail`也不一定总是比`head`大。 - -### 方法剖析 - -#### addFirst() - -`addFirst(E e)`的作用是在*Deque*的首端插入元素,也就是在`head`的前面插入元素,在空间足够且下标没有越界的情况下,只需要将`elements[--head] = e`即可。 - - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/arraydeque-459afbba-2778-4241-97fb-f01a29b79458.png) - -实际需要考虑: - -1. 空间是否够用,以及 -2. 下标是否越界的问题。 - -上图中,如果`head`为`0`之后接着调用`addFirst()`,虽然空余空间还够用,但`head`为`-1`,下标越界了。下列代码很好的解决了这两个问题。 - - -```java -//addFirst(E e) -public void addFirst(E e) { - if (e == null)//不允许放入null - throw new NullPointerException(); - elements[head = (head - 1) & (elements.length - 1)] = e;//2.下标是否越界 - if (head == tail)//1.空间是否够用 - doubleCapacity();//扩容 -} -``` - -上述代码我们看到,**空间问题是在插入之后解决的**,因为`tail`总是指向下一个可插入的空位,也就意味着`elements`数组至少有一个空位,所以插入元素的时候不用考虑空间问题。 - -下标越界的处理解决起来非常简单,`head = (head - 1) & (elements.length - 1)`就可以了,**这段代码相当于取余,同时解决了`head`为负值的情况**。因为`elements.length`必需是`2`的指数倍,`elements - 1`就是二进制低位全`1`,跟`head - 1`相与之后就起到了取模的作用,如果`head - 1`为负数(其实只可能是-1),则相当于对其取相对于`elements.length`的补码。 - -下面再说说扩容函数`doubleCapacity()`,其逻辑是申请一个更大的数组(原数组的两倍),然后将原数组复制过去。过程如下图所示: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/arraydeque-f1386b63-10be-4998-bb6d-bf6560cca7ee.png) - -图中我们看到,复制分两次进行,第一次复制`head`右边的元素,第二次复制`head`左边的元素。 - -```java -//doubleCapacity() -private void doubleCapacity() { - assert head == tail; - int p = head; - int n = elements.length; - int r = n - p; // head右边元素的个数 - int newCapacity = n << 1;//原空间的2倍 - if (newCapacity < 0) - throw new IllegalStateException("Sorry, deque too big"); - Object[] a = new Object[newCapacity]; - System.arraycopy(elements, p, a, 0, r);//复制右半部分,对应上图中绿色部分 - System.arraycopy(elements, 0, a, r, p);//复制左半部分,对应上图中灰色部分 - elements = (E[])a; - head = 0; - tail = n; -} -``` - -该方法的实现中,首先检查 head 和 tail 是否相等,如果不相等则抛出异常。然后计算出 head 右边的元素个数 r,以及新的容量 newCapacity,如果 newCapacity 太大则抛出异常。 - -接下来创建一个新的 Object 数组 a,将原有 ArrayDeque 中 head 右边的元素复制到 a 的前面(即图中绿色部分),将 head 左边的元素复制到 a 的后面(即图中灰色部分)。最后将 elements 数组替换为 a,head 设置为 0,tail 设置为 n(即新容量的长度)。 - -需要注意的是,由于 elements 数组被替换为 a 数组,因此在方法调用结束后,原有的 elements 数组将不再被引用,会被垃圾回收器回收。 - -#### addLast() - -`addLast(E e)`的作用是在*Deque*的尾端插入元素,也就是在`tail`的位置插入元素,由于`tail`总是指向下一个可以插入的空位,因此只需要`elements[tail] = e;`即可。插入完成后再检查空间,如果空间已经用光,则调用`doubleCapacity()`进行扩容。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/arraydeque-832c796a-6c24-4546-9f91-22ed39884363.png) - -```java -public void addLast(E e) { - if (e == null)//不允许放入null - throw new NullPointerException(); - elements[tail] = e;//赋值 - if ( (tail = (tail + 1) & (elements.length - 1)) == head)//下标越界处理 - doubleCapacity();//扩容 -} -``` - -下标越界处理方式`addFirt()`中已经讲过,不再赘述。 - -#### pollFirst() - -`pollFirst()`的作用是删除并返回*Deque*首端元素,也即是`head`位置处的元素。如果容器不空,只需要直接返回`elements[head]`即可,当然还需要处理下标的问题。由于`ArrayDeque`中不允许放入`null`,当`elements[head] == null`时,意味着容器为空。 - -```java -public E pollFirst() { - E result = elements[head]; - if (result == null)//null值意味着deque为空 - return null; - elements[h] = null;//let GC work - head = (head + 1) & (elements.length - 1);//下标越界处理 - return result; -} -``` - -#### pollLast() - -`pollLast()`的作用是删除并返回*Deque*尾端元素,也即是`tail`位置前面的那个元素。 - -```java -public E pollLast() { - int t = (tail - 1) & (elements.length - 1);//tail的上一个位置是最后一个元素 - E result = elements[t]; - if (result == null)//null值意味着deque为空 - return null; - elements[t] = null;//let GC work - tail = t; - return result; -} -``` - -#### peekFirst() - -`peekFirst()`的作用是返回但不删除*Deque*首端元素,也即是`head`位置处的元素,直接返回`elements[head]`即可。 - -```java -public E peekFirst() { - return elements[head]; // elements[head] is null if deque empty -} -``` - -#### peekLast() - -`peekLast()`的作用是返回但不删除*Deque*尾端元素,也即是`tail`位置前面的那个元素。 - -```java -public E peekLast() { - return elements[(tail - 1) & (elements.length - 1)]; -} -``` - -### 小结 - -当需要实现先进先出(FIFO)或者先进后出(LIFO)的数据结构时,可以考虑使用 ArrayDeque。以下是一些使用 ArrayDeque 的场景: - -- 管理任务队列:如果需要实现一个任务队列,可以使用 ArrayDeque 来存储任务元素。在队列头部添加新任务元素,从队列尾部取出任务进行处理,可以保证任务按照先进先出的顺序执行。 -- 实现栈:ArrayDeque 可以作为栈的实现方式,支持 push、pop、peek 等操作,可以用于需要后进先出的场景。 -- 实现缓存:在需要缓存一定数量的数据时,可以使用 ArrayDeque。当缓存的数据量超过容量时,可以从队列头部删除最老的数据,从队列尾部添加新的数据。 -- 实现事件处理器:ArrayDeque 可以作为事件处理器的实现方式,支持从队列头部获取事件进行处理,从队列尾部添加新的事件。 - -简单总结一下吧。 - -ArrayDeque 是 Java 标准库中的一种双端队列实现,底层基于数组实现。与 LinkedList 相比,ArrayDeque 的性能更优,因为它使用连续的内存空间存储元素,可以更好地利用 CPU 缓存,在大多数情况下也更快。 - -为什么这么说呢? - -因为ArrayDeque 的底层实现是数组,而 LinkedList 的底层实现是链表。数组是一段连续的内存空间,而链表是由多个节点组成的,每个节点存储数据和指向下一个节点的指针。因此,在使用 LinkedList 时,需要频繁进行内存分配和释放,而 ArrayDeque 在创建时就一次性分配了连续的内存空间,不需要频繁进行内存分配和释放,这样可以更好地利用 CPU 缓存,提高访问效率。 - -现代计算机CPU对于数据的局部性有很强的依赖,如果需要访问的数据在内存中是连续存储的,那么就可以利用CPU的缓存机制,提高访问效率。而当数据存储在不同的内存块里时,每次访问都需要从内存中读取,效率会受到影响。 - -当然了,使用 ArrayDeque 时,数组复制操作也是需要考虑的性能消耗之一。 - -当 ArrayDeque 的元素数量超过了初始容量时,会触发扩容操作。扩容操作会创建一个新的数组,并将原有元素复制到新数组中。扩容操作的时间复杂度为 O(n)。 - -不过,ArrayDeque 的扩容策略(当 ArrayDeque 中的元素数量达到数组容量时,就需要进行扩容操作,扩容时会将数组容量扩大为原来的两倍)可以在一定程度上减少数组复制的次数和时间消耗,同时保证 ArrayDeque 的性能和空间利用率。 - -ArrayDeque 不仅支持常见的队列操作,如添加元素、删除元素、获取队列头部元素、获取队列尾部元素等。同时,它还支持栈操作,如 push、pop、peek 等。这使得 ArrayDeque 成为一种非常灵活的数据结构,可以用于各种场景的数据存储和处理。 - - ->参考链接:[https://github.com/CarpenterLee/JCFInternals](https://github.com/CarpenterLee/JCFInternals),作者:李豪,整理:沉默王二 - - - - -## 6.13 优先级队列PriorityQueue详解 - ->继续有请王老师,来上台给大家讲讲优先级队列 PriorityQueue。 - -PriorityQueue 是 Java 中的一个基于优先级堆的优先队列实现,它能够在 O(log n) 的时间复杂度内实现元素的插入和删除操作,并且能够自动维护队列中元素的优先级顺序。 - -通俗来说,PriorityQueue 就是一个队列,但是它不是先进先出的,而是按照元素优先级进行排序的。当你往 PriorityQueue 中插入一个元素时,它会自动根据元素的优先级将其插入到合适的位置。当你从 PriorityQueue 中删除一个元素时,它会自动将优先级最高的元素出队。 - -下面👇🏻是一个简单的PriorityQueue示例: - -```java -// 创建 PriorityQueue 对象 -PriorityQueue priorityQueue = new PriorityQueue<>(); - -// 添加元素到 PriorityQueue -priorityQueue.offer("沉默王二"); -priorityQueue.offer("陈清扬"); -priorityQueue.offer("小转铃"); - -// 打印 PriorityQueue 中的元素 -System.out.println("PriorityQueue 中的元素:"); -while (!priorityQueue.isEmpty()) { - System.out.print(priorityQueue.poll() + " "); -} -``` - -在上述代码中,我们首先创建了一个 PriorityQueue 对象,并向其中添加了三个元素。然后,我们使用 while 循环遍历 PriorityQueue 中的元素,并打印出来。来看输出结果: - -``` -PriorityQueue 中的元素: -小转铃 沉默王二 陈清扬 -``` - -再来看一下示例。 - -```java -// 创建 PriorityQueue 对象,并指定优先级顺序 -PriorityQueue priorityQueue = new PriorityQueue<>(Comparator.reverseOrder()); - -// 添加元素到 PriorityQueue -priorityQueue.offer("沉默王二"); -priorityQueue.offer("陈清扬"); -priorityQueue.offer("小转铃"); - -// 打印 PriorityQueue 中的元素 -System.out.println("PriorityQueue 中的元素:"); -while (!priorityQueue.isEmpty()) { - System.out.print(priorityQueue.poll() + " "); -} -``` - -在上述代码中,我们使用了 Comparator.reverseOrder() 方法指定了 PriorityQueue 的优先级顺序为降序。也就是说,PriorityQueue 中的元素会按照从大到小的顺序排序。 - -其他部分的代码与之前的例子相同,我们再来看一下输出结果: - -``` -PriorityQueue 中的元素: -陈清扬 沉默王二 小转铃 -``` - -对比一下两个例子的输出结果,不难发现,顺序正好相反。 - -### PriorityQueue的作用 - -PriorityQueue 的主要作用是维护一组数据的排序,使得取出数据时可以按照一定的优先级顺序进行,当我们调用 poll() 方法时,它会从队列的顶部弹出最高优先级的元素。它在很多场景下都有广泛的应用,例如任务调度、事件处理等场景,以及一些算法中需要对数据进行排序的场景。 - -在实际应用中,PriorityQueue 也经常用于实现 Dijkstra 算法、Prim 算法、Huffman 编码等算法。这里简单说一下这几种算法的作用,理解不了也没关系哈。 - -Dijkstra算法是一种用于计算带权图中的最短路径的算法。该算法采用贪心的策略,在遍历图的过程中,每次选取当前到源点距离最短的一个顶点,并以它为中心进行扩展,更新其他顶点的距离值。经过多次扩展,可以得到源点到其它所有顶点的最短路径。 - -Prim算法是一种用于求解最小生成树的算法,可以在加权连通图中找到一棵生成树,使得这棵生成树的所有边的权值之和最小。该算法从任意一个顶点开始,逐渐扩展生成树的规模,每次选择一个距离已生成树最近的顶点加入到生成树中。 - -Huffman编码是一种基于霍夫曼树的压缩算法,用于将一个字符串转换为二进制编码以进行压缩。该算法的主要思想是通过建立霍夫曼树,将出现频率较高的字符用较短的编码表示,而出现频率较低的字符用较长的编码表示,从而实现对字符串的压缩。在解压缩时,根据编码逐步解析出原字符串。 - -由于 PriorityQueue 的底层是基于堆实现的,因此在数据量比较大时,使用 PriorityQueue 可以获得较好的时间复杂度。 - -这里牵涉到了大小关系,**元素大小的评判可以通过元素本身的自然顺序(_natural ordering_),也可以通过构造时传入的比较器**(_Comparator_,或者元素自身实现 Comparable 接口)来决定。 - -在 PriorityQueue 中,每个元素都有一个优先级,这个优先级决定了元素在队列中的位置。队列内部通过小顶堆(也可以是大顶堆)的方式来维护元素的优先级关系。具体来说,小顶堆是一个完全二叉树,任何一个非叶子节点的权值,都不大于其左右子节点的权值,这样保证了队列的顶部元素(堆顶)一定是优先级最高的元素。 - -完全二叉树(Complete Binary Tree)是一种二叉树,其中除了最后一层,其他层的节点数都是满的,最后一层的节点都靠左对齐。下面是一个完全二叉树的示意图: - -``` - 1 - / \ - 2 3 - / \ / - 4 5 6 -``` - -堆是一种完全二叉树,堆的特点是根节点的值最小(小顶堆)或最大(大顶堆),并且任意非根节点i的值都不大于(或不小于)其父节点的值。 - -这是一颗包含整数 1, 2, 3, 4, 5, 6, 7 的小顶堆: - -``` - 1 - / \ - 2 3 - / \ / \ - 4 5 6 7 -``` - -这是一颗大顶堆。 - -``` - 8 - / \ - 7 5 - / \ / \ - 6 4 2 1 -``` - -因为完全二叉树的结构比较规则,所以可以使用数组来存储堆的元素,而不需要使用指针等额外的空间。 - -在堆中,每个节点的下标和其在数组中的下标是一一对应的,假设节点下标为i,则其父节点下标为i/2,其左子节点下标为2i,其右子节点下标为2i+1。 - -假设有一个数组arr=[10, 20, 15, 30, 40],现在要将其转化为一个小顶堆。 - -首先,我们将数组按照完全二叉树的形式排列,如下图所示: - -``` - 10 - / \ - 20 15 - / \ -30 40 -``` - -从上往下、从左往右依次给每个节点编号,如下所示: - -``` - 1 - / \ - 2 3 - / \ - 4 5 -``` - -接下来,我们按照上述公式,依次确定每个节点在数组中的位置。例如,节点1的父节点下标为1/2=0,左子节点下标为2\*1=2,右子节点下标为2\*1+1=3,因此节点1在数组中的位置为0,节点2在数组中的位置为2,节点3在数组中的位置为3。 - -对应的数组为[10, 20, 15, 30, 40],符合小顶堆的定义,即每个节点的值都小于或等于其子节点的值。 - -好,我们画幅图再来理解一下。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/PriorityQueue-8dca2f55-a7c7-49e1-95a5-df1a34f2aef5.png) - -上图中我们给每个元素按照层序遍历的方式进行了编号,如果你足够细心,会发现父节点和子节点的编号是有联系的,更确切的说父子节点的编号之间有如下关系: - -``` -leftNo = parentNo\*2+1 - -rightNo = parentNo\*2+2 - -parentNo = (nodeNo-1)/2 -``` - -通过上述三个公式,可以轻易计算出某个节点的父节点以及子节点的下标。这也就是为什么可以直接用数组来存储堆的原因。 - -### 方法剖析 - -#### add()和 offer() - -`add(E e)`和`offer(E e)`的语义相同,都是向优先队列中插入元素,只是`Queue`接口规定二者对插入失败时的处理不同,前者在插入失败时抛出异常,后则则会返回`false`。对于*PriorityQueue*这两个方法其实没什么差别。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/PriorityQueue-0fb89aa7-c8fa-4fad-adbb-40c61c3bb0e9.png) - -新加入的元素可能会破坏小顶堆的性质,因此需要进行必要的调整。 - -```Java -//offer(E e) -public boolean offer(E e) { - if (e == null)//不允许放入null元素 - throw new NullPointerException(); - modCount++; - int i = size; - if (i >= queue.length) - grow(i + 1);//自动扩容 - size = i + 1; - if (i == 0)//队列原来为空,这是插入的第一个元素 - queue[0] = e; - else - siftUp(i, e);//调整 - return true; -} -``` - -上述代码中,扩容函数`grow()`类似于`ArrayList`里的`grow()`函数,就是再申请一个更大的数组,并将原数组的元素复制过去,这里不再赘述。需要注意的是`siftUp(int k, E x)`方法,该方法用于插入元素`x`并维持堆的特性。 - -```Java -//siftUp() -private void siftUp(int k, E x) { - while (k > 0) { - int parent = (k - 1) >>> 1;//parentNo = (nodeNo-1)/2 - Object e = queue[parent]; - if (comparator.compare(x, (E) e) >= 0)//调用比较器的比较方法 - break; - queue[k] = e; - k = parent; - } - queue[k] = x; -} -``` - -调整的过程为:**从`k`指定的位置开始,将`x`逐层与当前点的`parent`进行比较并交换,直到满足`x >= queue[parent]`为止**。注意这里的比较可以是元素的自然顺序,也可以是依靠比较器的顺序。 - -#### element()和 peek() - -`element()`和`peek()`的语义完全相同,都是获取但不删除队首元素,也就是队列中权值最小的那个元素,二者唯一的区别是当方法失败时前者抛出异常,后者返回`null`。根据小顶堆的性质,堆顶那个元素就是全局最小的那个;由于堆用数组表示,根据下标关系,`0`下标处的那个元素既是堆顶元素。所以**直接返回数组`0`下标处的那个元素即可**。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/PriorityQueue-5059f157-845e-4d1c-b993-5cfe539d5607.png) - -代码也就非常简洁: - -```Java -//peek() -public E peek() { - if (size == 0) - return null; - return (E) queue[0];//0下标处的那个元素就是最小的那个 -} -``` - -#### remove()和 poll() - -`remove()`和`poll()`方法的语义也完全相同,都是获取并删除队首元素,区别是当方法失败时前者抛出异常,后者返回`null`。由于删除操作会改变队列的结构,为维护小顶堆的性质,需要进行必要的调整。 - -![PriorityQueue_poll.png](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/PriorityQueue-e25ba931-2e6f-4c17-84b8-9b959733d541.png) - -代码如下: - -```Java -public E poll() { - if (size == 0) - return null; - int s = --size; - modCount++; - E result = (E) queue[0];//0下标处的那个元素就是最小的那个 - E x = (E) queue[s]; - queue[s] = null; - if (s != 0) - siftDown(0, x);//调整 - return result; -} -``` - -上述代码首先记录`0`下标处的元素,并用最后一个元素替换`0`下标位置的元素,之后调用`siftDown()`方法对堆进行调整,最后返回原来`0`下标处的那个元素(也就是最小的那个元素)。重点是`siftDown(int k, E x)`方法,该方法的作用是**从`k`指定的位置开始,将`x`逐层向下与当前点的左右孩子中较小的那个交换,直到`x`小于或等于左右孩子中的任何一个为止**。 - -```Java -//siftDown() -private void siftDown(int k, E x) { - int half = size >>> 1; - while (k < half) { - //首先找到左右孩子中较小的那个,记录到c里,并用child记录其下标 - int child = (k << 1) + 1;//leftNo = parentNo*2+1 - Object c = queue[child]; - int right = child + 1; - if (right < size && - comparator.compare((E) c, (E) queue[right]) > 0) - c = queue[child = right]; - if (comparator.compare(x, (E) c) <= 0) - break; - queue[k] = c;//然后用c取代原来的值 - k = child; - } - queue[k] = x; -} -``` - -#### remove(Object o) - -`remove(Object o)`方法用于删除队列中跟`o`相等的某一个元素(如果有多个相等,只删除一个),该方法不是*Queue*接口内的方法,而是*Collection*接口的方法。由于删除操作会改变队列结构,所以要进行调整;又由于删除元素的位置可能是任意的,所以调整过程比其它方法稍加繁琐。 - -具体来说,`remove(Object o)`可以分为 2 种情况: - -1. 删除的是最后一个元素。直接删除即可,不需要调整。 -2. 删除的不是最后一个元素,从删除点开始以最后一个元素为参照调用一次`siftDown()`即可。此处不再赘述。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/collection/PriorityQueue-ed0d08d3-b38e-44a1-a710-ee7a01afda62.png) - -具体代码如下: - -```Java -//remove(Object o) -public boolean remove(Object o) { - //通过遍历数组的方式找到第一个满足o.equals(queue[i])元素的下标 - int i = indexOf(o); - if (i == -1) - return false; - int s = --size; - if (s == i) //情况1 - queue[i] = null; - else { - E moved = (E) queue[s]; - queue[s] = null; - siftDown(i, moved);//情况2 - ...... - } - return true; -} -``` - -### 小结 - -PriorityQueue 是一个非常常用的数据结构,它是一种特殊的堆(Heap)实现,可以用来高效地维护一个有序的集合。 - -- 它的底层实现是一个数组,通过堆的性质来维护元素的顺序。 -- 取出元素时按照优先级顺序(从小到大或者从大到小)进行取出。 -- 如果需要指定排序,元素必须实现 Comparable 接口或者传入一个 Comparator 来进行比较。 - -> 参考链接:[https://github.com/CarpenterLee/JCFInternals](https://github.com/CarpenterLee/JCFInternals),作者:李豪,整理:沉默王二 - - - - - -## 6.14 Comparable和Comparator的区别 - ->在前面学习[优先级队列](https://tobebetterjavaer.com/collection/PriorityQueue.html)的时候,我们曾提到过 Comparable和Comparator,那这篇继续以面试官的角度去切入,一起来看。 - -那天,小二去马蜂窝面试,面试官老王一上来就甩给了他一道面试题:请问Comparable和Comparator有什么区别?小二差点笑出声,因为三年前,也就是 2021 年,他在《[二哥的Java进阶之路](https://tobebetterjavaer.com/basic-extra-meal/comparable-omparator.html)》上看到过这题😆。 - -Comparable 和 Comparator 是 Java 的两个接口,从名字上我们就能够读出来它们俩的相似性:以某种方式来比较两个对象。 - -但它们之间到底有什么区别呢?请随我来,打怪进阶喽! - -### 01、Comparable - -Comparable 接口的定义非常简单,源码如下所示。 - -```java -public interface Comparable { - int compareTo(T t); -} -``` - -如果一个类实现了 Comparable 接口(只需要干一件事,重写 `compareTo()` 方法),就可以按照自己制定的规则将由它创建的对象进行比较。下面给出一个例子。 - -```java -public class Cmower implements Comparable { - private int age; - private String name; - - public Cmower(int age, String name) { - this.age = age; - this.name = name; - } - - @Override - public int compareTo(Cmower o) { - return this.getAge() - o.getAge(); - } - - public static void main(String[] args) { - Cmower wanger = new Cmower(19,"沉默王二"); - Cmower wangsan = new Cmower(16,"沉默王三"); - - if (wanger.compareTo(wangsan) < 0) { - System.out.println(wanger.getName() + "比较年轻有为"); - } else { - System.out.println(wangsan.getName() + "比较年轻有为"); - } - } -} -``` - -在上面的示例中,我创建了一个 Cmower 类,它有两个字段:age 和 name。Cmower 类实现了 Comparable 接口,并重写了 `compareTo()` 方法。 - -程序输出的结果是“沉默王三比较年轻有为”,因为他比沉默王二小三岁。这个结果有什么凭证吗? - -凭证就在于 `compareTo()` 方法,该方法的返回值可能为负数,零或者正数,代表的意思是该对象按照排序的规则小于、等于或者大于要比较的对象。如果指定对象的类型与此对象不能进行比较,则引发 `ClassCastException` 异常(自从有了[泛型](https://tobebetterjavaer.com/basic-extra-meal/generic.html),这种情况就少有发生了)。 - -### 02、Comparator - -Comparator 接口的定义相比较于 Comparable 就复杂的多了,不过,核心的方法只有两个,来看一下源码。 - -```java -public interface Comparator { - int compare(T o1, T o2); - boolean equals(Object obj); -} -``` - -第一个方法 `compare(T o1, T o2)` 的返回值可能为负数,零或者正数,代表的意思是第一个对象小于、等于或者大于第二个对象。 - -第二个方法 `equals(Object obj)` 需要传入一个 Object 作为参数,并判断该 Object 是否和 Comparator 保持一致。 - -有时候,我们想让类保持它的原貌,不想主动实现 Comparable 接口,但我们又需要它们之间进行比较,该怎么办呢? - -Comparator 就派上用场了,来看一下示例。 - -#### 1)原封不动的 Cmower 类。 - -```java -public class Cmower { - private int age; - private String name; - - public Cmower(int age, String name) { - this.age = age; - this.name = name; - } -} -``` - -Cmower 类有两个字段:age 和 name,意味着该类可以按照 age 或者 name 进行排序。 - -#### 2)再来看 Comparator 接口的实现类。 - -```java -public class CmowerComparator implements Comparator { - @Override - public int compare(Cmower o1, Cmower o2) { - return o1.getAge() - o2.getAge(); - } -} -``` - -按照 age 进行比较。当然也可以再实现一个比较器,按照 name 进行自然排序,示例如下。 - -```java -public class CmowerNameComparator implements Comparator { - @Override - public int compare(Cmower o1, Cmower o2) { - if (o1.getName().hashCode() < o2.getName().hashCode()) { - return -1; - } else if (o1.getName().hashCode() == o2.getName().hashCode()) { - return 0; - } - return 1; - } -} -``` - -#### 3)再来看测试类。 - -```java -Cmower wanger = new Cmower(19,"沉默王二"); -Cmower wangsan = new Cmower(16,"沉默王三"); -Cmower wangyi = new Cmower(28,"沉默王一"); - -List list = new ArrayList<>(); -list.add(wanger); -list.add(wangsan); -list.add(wangyi); - -list.sort(new CmowerComparator()); - -for (Cmower c : list) { - System.out.println(c.getName()); -} -``` - -创建了三个对象,age 不同,name 不同,并把它们加入到了 List 当中。然后使用 List 的 `sort()` 方法进行排序,来看一下输出的结果。 - -``` -沉默王三 -沉默王二 -沉默王一 -``` - -这意味着沉默王三的年纪比沉默王二小,排在第一位;沉默王一的年纪比沉默王二大,排在第三位。和我们的预期完全符合。 - -借此机会,再来看一下 sort 方法的源码: - -```java -public void sort(Comparator c) { - // 保存当前队列的 modCount 值,用于检测 sort 操作是否非法 - final int expectedModCount = modCount; - // 调用 Arrays.sort 对 elementData 数组进行排序,使用传入的比较器 c - Arrays.sort((E[]) elementData, 0, size, c); - // 检查操作期间 modCount 是否被修改,如果被修改则抛出并发修改异常 - if (modCount != expectedModCount) { - throw new ConcurrentModificationException(); - } - // 增加 modCount 值,表示队列已经被修改过 - modCount++; -} -``` - -可以看到,参数就是一个 Comparator 接口,并且使用了[泛型](https://tobebetterjavaer.com/basic-extra-meal/generic.html) `Comparator c`。 - -### 03、到底该用哪一个? - -通过上面的两个例子可以比较出 Comparable 和 Comparator 两者之间的区别: - -- 一个类实现了 Comparable 接口,意味着该类的对象可以直接进行比较(排序),但比较(排序)的方式只有一种,很单一。 -- 一个类如果想要保持原样,又需要进行不同方式的比较(排序),就可以定制比较器(实现 Comparator 接口)。 -- Comparable 接口在 `java.lang` 包下,而 `Comparator` 接口在 `java.util` 包下,算不上是亲兄弟,但可以称得上是表(堂)兄弟。 - -举个不恰当的例子。我想从洛阳出发去北京看长城,体验一下好汉的感觉,要么坐飞机,要么坐高铁;但如果是孙悟空的话,翻个筋斗就到了。我和孙悟空之间有什么区别呢? - -孙悟空自己实现了 Comparable 接口(他那年代也没有飞机和高铁,没得选),而我可以借助 Comparator 接口(现代化的交通工具)。 - -好了,关于 Comparable 和 Comparator 我们就先聊这么多。总而言之,如果对象的排序需要基于自然顺序,请选择 `Comparable`,如果需要按照对象的不同属性进行排序,请选择 `Comparator`。 - ---- - -GitHub 上标星 7600+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 7600+ 的 Java 教程](https://tobebetterjavaer.com/overview/) - -微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - -# 第七章:Java IO - - - -## 7.1 IO 分类 - -“老王,Java IO 也太上头了吧?”新兵蛋子小二向头顶很凉快的老王抱怨道,“你瞧,我就按照传输方式对 IO 进行了一个简单的分类,就能搞出来这么多的玩意!” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/io/shangtou-01.png) - -好久没搞过 IO 了,老王看到这幅思维导图也是吃了一惊。想想也是,他当初学习 Java IO 的时候头也大,乌央乌央的一片,全是类,估计是所有 Java 包里面类最多的,一会是 Input 一会是 Output,一会是 Reader 一会是 Writer,真不知道 Java 的设计者是怎么想的。 - -看着肺都快要气炸的小二,老王深深地吸了一口气,耐心地对小二说:“主要是 Java 的设计者考虑得比较多吧,所以 IO 给人一种很乱的感觉,我来给你梳理一下。” - -### 00、初识 Java IO - -IO,即in和out,也就是输入和输出,指应用程序和外部设备之间的数据传递,常见的外部设备包括文件、管道、网络连接。 - -Java 中是通过流处理IO 的,那么什么是流? - -流(Stream),是一个抽象的概念,是指一连串的数据(字符或字节),是以先进先出的方式发送信息的通道。 - -当程序需要读取数据的时候,就会开启一个通向数据源的流,这个数据源可以是文件,内存,或是网络连接。类似的,当程序需要写入数据的时候,就会开启一个通向目的地的流。这时候你就可以想象数据好像在这其中“流”动一样。 - -一般来说关于流的特性有下面几点: - -- 先进先出:最先写入输出流的数据最先被输入流读取到。 -- 顺序存取:可以一个接一个地往流中写入一串字节,读出时也将按写入顺序读取一串字节,不能随机访问中间的数据。(RandomAccessFile除外) -- 只读或只写:每个流只能是输入流或输出流的一种,不能同时具备两个功能,输入流只能进行读操作,对输出流只能进行写操作。在一个数据传输通道中,如果既要写入数据,又要读取数据,则要分别提供两个流。 - -### 01、传输方式划分 - -就按照你的那副思维导图来说吧。 - -传输方式有两种,字节和字符,那首先得搞明白字节和字符有什么区别,对吧? - -字节(byte)是计算机中用来表示存储容量的一个计量单位,通常情况下,一个字节有 8 位(bit)。 - -字符(char)可以是计算机中使用的字母、数字、和符号,比如说 A 1 $ 这些。 - -通常来说,一个字母或者一个字符占用一个字节,一个汉字占用两个字节。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/io/shangtou-02.png) - -具体还要看字符编码,比如说在 UTF-8 编码下,一个英文字母(不分大小写)为一个字节,一个中文汉字为三个字节;在 Unicode 编码中,一个英文字母为一个字节,一个中文汉字为两个字节。 - ->PS:关于字符编码,可以看前面的章节:[锟斤拷](https://tobebetterjavaer.com/basic-extra-meal/java-unicode.html) - -明白了字节与字符的区别,再来看字节流和字符流就会轻松多了。 - -字节流用来处理二进制文件,比如说图片啊、MP3 啊、视频啊。 - -字符流用来处理文本文件,文本文件可以看作是一种特殊的二进制文件,只不过经过了编码,便于人们阅读。 - -换句话说就是,字节流可以处理一切文件,而字符流只能处理文本。 - -虽然 IO 类很多,但核心的就是 4 个抽象类:InputStream、OutputStream、Reader、Writer。 - -(**抽象大法真好**) - -虽然 IO 类的方法也很多,但核心的也就 2 个:read 和 write。 - -**InputStream 类** - -- `int read()`:读取数据 -- `int read(byte b[], int off, int len)`:从第 off 位置开始读,读取 len 长度的字节,然后放入数组 b 中 -- `long skip(long n)`:跳过指定个数的字节 -- `int available()`:返回可读的字节数 -- `void close()`:关闭流,释放资源 - -**OutputStream 类** - -- `void write(int b)`: 写入一个字节,虽然参数是一个 int 类型,但只有低 8 位才会写入,高 24 位会舍弃(这块后面再讲) -- `void write(byte b[], int off, int len)`: 将数组 b 中的从 off 位置开始,长度为 len 的字节写入 -- `void flush()`: 强制刷新,将缓冲区的数据写入 -- `void close()`:关闭流 - -**Reader 类** - -- `int read()`:读取单个字符 -- `int read(char cbuf[], int off, int len)`:从第 off 位置开始读,读取 len 长度的字符,然后放入数组 b 中 -- `long skip(long n)`:跳过指定个数的字符 -- `int ready()`:是否可以读了 -- `void close()`:关闭流,释放资源 - -**Writer 类** - -- `void write(int c)`: 写入一个字符 -- `void write( char cbuf[], int off, int len)`: 将数组 cbuf 中的从 off 位置开始,长度为 len 的字符写入 -- `void flush()`: 强制刷新,将缓冲区的数据写入 -- `void close()`:关闭流 - -理解了上面这些方法,基本上 IO 的灵魂也就全部掌握了。 - -字节流和字符流的区别: - -- 字节流一般用来处理图像、视频、音频、PPT、Word等类型的文件。字符流一般用于处理纯文本类型的文件,如TXT文件等,但不能处理图像视频等非文本文件。用一句话说就是:字节流可以处理一切文件,而字符流只能处理纯文本文件。 -- 字节流本身没有缓冲区,缓冲字节流相对于字节流,效率提升非常高。而字符流本身就带有缓冲区,缓冲字符流相对于字符流效率提升就不是那么大了。 - -以写文件为例,我们查看字符流的源码,发现确实有利用到缓冲区: - -```java -// 声明一个 char 类型的数组,用于写入输出流 -private char[] writeBuffer; - -// 定义 writeBuffer 数组的大小,必须 >= 1 -private static final int WRITE_BUFFER_SIZE = 1024; - -// 写入给定字符串中的一部分到输出流中 -public void write(String str, int off, int len) throws IOException { - // 使用 synchronized 关键字同步代码块,确保线程安全 - synchronized (lock) { - char cbuf[]; - // 如果 len <= WRITE_BUFFER_SIZE,则使用 writeBuffer 数组进行写入 - if (len <= WRITE_BUFFER_SIZE) { - // 如果 writeBuffer 为 null,则创建一个大小为 WRITE_BUFFER_SIZE 的新 char 数组 - if (writeBuffer == null) { - writeBuffer = new char[WRITE_BUFFER_SIZE]; - } - cbuf = writeBuffer; - } else { // 如果 len > WRITE_BUFFER_SIZE,则不永久分配非常大的缓冲区 - // 创建一个大小为 len 的新 char 数组 - cbuf = new char[len]; - } - // 将 str 中的一部分(从 off 开始,长度为 len)拷贝到 cbuf 数组中 - str.getChars(off, (off + len), cbuf, 0); - // 将 cbuf 数组中的数据写入输出流中 - write(cbuf, 0, len); - } -} -``` - -这段代码是 Java IO 类库中的 OutputStreamWriter 类的 write 方法,可以看到缓冲区的大小是 1024 个 char。 - -我们再以文件的字符流和字节流来做一下对比,代码差别很小。 - -```java -// 字节流 -try (FileInputStream fis = new FileInputStream("input.txt"); - FileOutputStream fos = new FileOutputStream("output.txt")) { - byte[] buffer = new byte[1024]; - int len; - while ((len = fis.read(buffer)) != -1) { - fos.write(buffer, 0, len); - } -} catch (IOException e) { - e.printStackTrace(); -} - -// 字符流 -try (FileReader fr = new FileReader("input.txt"); - FileWriter fw = new FileWriter("output.txt")) { - char[] buffer = new char[1024]; - int len; - while ((len = fr.read(buffer)) != -1) { - fw.write(buffer, 0, len); - } -} catch (IOException e) { - e.printStackTrace(); -} -``` - -### 02、操作对象划分 - -小二,你细想一下,IO IO,不就是输入输出(Input/Output)嘛: - -- Input:将外部的数据读入内存,比如说把文件从硬盘读取到内存,从网络读取数据到内存等等 -- Output:将内存中的数据写入到外部,比如说把数据从内存写入到文件,把数据从内存输出到网络等等。 - -所有的程序,在执行的时候,都是在内存上进行的,一旦关机,内存中的数据就没了,那如果想要持久化,就需要把内存中的数据输出到外部,比如说文件。 - -文件操作算是 IO 中最典型的操作了,也是最频繁的操作。那其实你可以换个角度来思考,比如说按照 IO 的操作对象来思考,IO 就可以分类为:文件、数组、管道、基本数据类型、缓冲、打印、对象序列化/反序列化,以及转换等。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/io/shangtou-03.png) - - -#### **1)文件** - -文件流也就是直接操作文件的流,可以细分为字节流(FileInputStream 和 FileOuputStream)和字符流(FileReader 和 FileWriter)。 - -FileInputStream 的例子: - -```java -// 声明一个 int 类型的变量 b,用于存储读取到的字节 -int b; -// 创建一个 FileInputStream 对象,用于读取文件 fis.txt 中的数据 -FileInputStream fis1 = new FileInputStream("fis.txt"); - -// 循环读取文件中的数据 -while ((b = fis1.read()) != -1) { - // 将读取到的字节转换为对应的 ASCII 字符,并输出到控制台 - System.out.println((char)b); -} - -// 关闭 FileInputStream 对象,释放资源 -fis1.close(); -``` - -FileOutputStream 的例子: - -```java -// 创建一个 FileOutputStream 对象,用于写入数据到文件 fos.txt 中 -FileOutputStream fos = new FileOutputStream("fos.txt"); - -// 向文件中写入数据,这里写入的是字符串 "沉默王二" 对应的字节数组 -fos.write("沉默王二".getBytes()); - -// 关闭 FileOutputStream 对象,释放资源 -fos.close(); -``` - -FileReader 的例子: - -```java -// 声明一个 int 类型的变量 b,用于存储读取到的字符 -int b = 0; - -// 创建一个 FileReader 对象,用于读取文件 read.txt 中的数据 -FileReader fileReader = new FileReader("read.txt"); - -// 循环读取文件中的数据 -while ((b = fileReader.read()) != -1) { - // 将读取到的字符强制转换为 char 类型,并输出到控制台 - System.out.println((char)b); -} - -// 关闭 FileReader 对象,释放资源 -fileReader.close(); -``` - -FileWriter 的例子: - -```java -// 创建一个 FileWriter 对象,用于写入数据到文件 fw.txt 中 -FileWriter fileWriter = new FileWriter("fw.txt"); - -// 将字符串 "沉默王二" 转换为字符数组 -char[] chars = "沉默王二".toCharArray(); - -// 向文件中写入数据,这里写入的是 chars 数组中的所有字符 -fileWriter.write(chars, 0, chars.length); - -// 关闭 FileWriter 对象,释放资源 -fileWriter.close(); -``` - -文件流还可以用于创建、删除、重命名文件等操作。FileOutputStream 和 FileWriter 构造函数的第二个参数可以指定是否追加数据到文件末尾。 - -示例代码: - -```java -// 创建文件 -File file = new File("test.txt"); -if (file.createNewFile()) { - System.out.println("文件创建成功"); -} else { - System.out.println("文件已存在"); -} - -// 删除文件 -if (file.delete()) { - System.out.println("文件删除成功"); -} else { - System.out.println("文件删除失败"); -} - -// 重命名文件 -File oldFile = new File("old.txt"); -File newFile = new File("new.txt"); -if (oldFile.renameTo(newFile)) { - System.out.println("文件重命名成功"); -} else { - System.out.println("文件重命名失败"); -} -``` - -当掌握了文件的输入输出,其他的自然也就掌握了,都大差不差。 - -#### **2)数组(内存)** - -通常来说,针对文件的读写操作,使用文件流配合缓冲流就够用了,但为了提升效率,频繁地读写文件并不是太好,那么就出现了数组流,有时候也称为内存流。 - -ByteArrayInputStream 的例子: - -```java -// 创建一个 ByteArrayInputStream 对象,用于从字节数组中读取数据 -InputStream is = new BufferedInputStream( - new ByteArrayInputStream( - "沉默王二".getBytes(StandardCharsets.UTF_8))); - -// 定义一个字节数组用于存储读取到的数据 -byte[] flush = new byte[1024]; - -// 定义一个变量用于存储每次读取到的字节数 -int len = 0; - -// 循环读取字节数组中的数据,并输出到控制台 -while (-1 != (len = is.read(flush))) { - // 将读取到的字节转换为对应的字符串,并输出到控制台 - System.out.println(new String(flush, 0, len)); -} - -// 关闭输入流,释放资源 -is.close(); -``` - -ByteArrayOutputStream 的例子: - -```java -// 创建一个 ByteArrayOutputStream 对象,用于写入数据到内存缓冲区中 -ByteArrayOutputStream bos = new ByteArrayOutputStream(); - -// 定义一个字节数组用于存储要写入内存缓冲区中的数据 -byte[] info = "沉默王二".getBytes(); - -// 向内存缓冲区中写入数据,这里写入的是 info 数组中的所有字节 -bos.write(info, 0, info.length); - -// 将内存缓冲区中的数据转换为字节数组 -byte[] dest = bos.toByteArray(); - -// 关闭 ByteArrayOutputStream 对象,释放资源 -bos.close(); -``` - -数组流可以用于在内存中读写数据,比如将数据存储在字节数组中进行压缩、加密、序列化等操作。它的优点是不需要创建临时文件,可以提高程序的效率。但是,数组流也有缺点,它只能存储有限的数据量,如果存储的数据量过大,会导致内存溢出。 - -#### **3)管道** - -Java 中的管道和 Unix/Linux 中的管道不同,在 Unix/Linux 中,不同的进程之间可以通过管道来通信,但 Java 中,通信的双方必须在同一个进程中,也就是在同一个 JVM 中,管道为线程之间的通信提供了通信能力。 - -一个线程通过 PipedOutputStream 写入的数据可以被另外一个线程通过相关联的 PipedInputStream 读取出来。 - -```java -// 创建一个 PipedOutputStream 对象和一个 PipedInputStream 对象 -final PipedOutputStream pipedOutputStream = new PipedOutputStream(); -final PipedInputStream pipedInputStream = new PipedInputStream(pipedOutputStream); - -// 创建一个线程,向 PipedOutputStream 中写入数据 -Thread thread1 = new Thread(new Runnable() { - @Override - public void run() { - try { - // 将字符串 "沉默王二" 转换为字节数组,并写入到 PipedOutputStream 中 - pipedOutputStream.write("沉默王二".getBytes(StandardCharsets.UTF_8)); - // 关闭 PipedOutputStream,释放资源 - pipedOutputStream.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } -}); - -// 创建一个线程,从 PipedInputStream 中读取数据并输出到控制台 -Thread thread2 = new Thread(new Runnable() { - @Override - public void run() { - try { - // 定义一个字节数组用于存储读取到的数据 - byte[] flush = new byte[1024]; - // 定义一个变量用于存储每次读取到的字节数 - int len = 0; - // 循环读取字节数组中的数据,并输出到控制台 - while (-1 != (len = pipedInputStream.read(flush))) { - // 将读取到的字节转换为对应的字符串,并输出到控制台 - System.out.println(new String(flush, 0, len)); - } - // 关闭 PipedInputStream,释放资源 - pipedInputStream.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } -}); - -// 启动线程1和线程2 -thread1.start(); -thread2.start(); -``` - -使用管道流可以实现不同线程之间的数据传输,可以用于线程间的通信、数据的传递等。但是,管道流也有一些局限性,比如只能在同一个 JVM 中的线程之间使用,不能跨越不同的 JVM 进程。 - -#### **4)基本数据类型** - -基本数据类型输入输出流是一个字节流,该流不仅可以读写字节和字符,还可以读写基本数据类型。 - -DataInputStream 提供了一系列可以读基本数据类型的方法: - -```java -// 创建一个 DataInputStream 对象,用于从文件中读取数据 -DataInputStream dis = new DataInputStream(new FileInputStream("das.txt")); - -// 读取一个字节,将其转换为 byte 类型 -byte b = dis.readByte(); - -// 读取两个字节,将其转换为 short 类型 -short s = dis.readShort(); - -// 读取四个字节,将其转换为 int 类型 -int i = dis.readInt(); - -// 读取八个字节,将其转换为 long 类型 -long l = dis.readLong(); - -// 读取四个字节,将其转换为 float 类型 -float f = dis.readFloat(); - -// 读取八个字节,将其转换为 double 类型 -double d = dis.readDouble(); - -// 读取一个字节,将其转换为 boolean 类型 -boolean bb = dis.readBoolean(); - -// 读取两个字节,将其转换为 char 类型 -char ch = dis.readChar(); - -// 关闭 DataInputStream,释放资源 -dis.close(); -``` - -DataOutputStream 提供了一系列可以写基本数据类型的方法: - -```java -// 创建一个 DataOutputStream 对象,用于将数据写入到文件中 -DataOutputStream das = new DataOutputStream(new FileOutputStream("das.txt")); - -// 将一个 byte 类型的数据写入到文件中 -das.writeByte(10); - -// 将一个 short 类型的数据写入到文件中 -das.writeShort(100); - -// 将一个 int 类型的数据写入到文件中 -das.writeInt(1000); - -// 将一个 long 类型的数据写入到文件中 -das.writeLong(10000L); - -// 将一个 float 类型的数据写入到文件中 -das.writeFloat(12.34F); - -// 将一个 double 类型的数据写入到文件中 -das.writeDouble(12.56); - -// 将一个 boolean 类型的数据写入到文件中 -das.writeBoolean(true); - -// 将一个 char 类型的数据写入到文件中 -das.writeChar('A'); - -// 关闭 DataOutputStream,释放资源 -das.close(); -``` - -除了 DataInputStream 和 DataOutputStream,Java IO 还提供了其他一些读写基本数据类型和字符串的流类,包括 ObjectInputStream 和 ObjectOutputStream(用于读写对象)。 - -示例代码: - -```java -public static void main(String[] args) { - try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("person.dat"))) { - Person p = new Person("张三", 20); - oos.writeObject(p); - } catch (IOException e) { - e.printStackTrace(); - } - - try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("person.dat"))) { - Person p = (Person) ois.readObject(); - System.out.println(p); - } catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); - } -} -``` - -以上代码创建了一个 Person 对象,将其写入文件中,然后从文件中读取该对象,并打印在控制台上。 - -#### **5)缓冲** - -CPU 很快,它比内存快 100 倍,比磁盘快百万倍。那也就意味着,程序和内存交互会很快,和硬盘交互相对就很慢,这样就会导致性能问题。 - -为了减少程序和硬盘的交互,提升程序的效率,就引入了缓冲流,也就是类名前缀带有 Buffer 的那些,比如说 BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/io/shangtou-04.png) - - -缓冲流在内存中设置了一个缓冲区,只有缓冲区存储了足够多的带操作的数据后,才会和内存或者硬盘进行交互。简单来说,就是一次多读/写点,少读/写几次,这样程序的性能就会提高。 - -以下是一个使用 BufferedInputStream 读取文件的示例代码: - -```java -// 创建一个 BufferedInputStream 对象,用于从文件中读取数据 -BufferedInputStream bis = new BufferedInputStream(new FileInputStream("data.txt")); - -// 创建一个字节数组,作为缓存区 -byte[] buffer = new byte[1024]; - -// 读取文件中的数据,并将其存储到缓存区中 -int bytesRead; -while ((bytesRead = bis.read(buffer)) != -1) { - // 对缓存区中的数据进行处理 - // 这里只是简单地将读取到的字节数组转换为字符串并打印出来 - System.out.println(new String(buffer, 0, bytesRead)); -} - -// 关闭 BufferedInputStream,释放资源 -bis.close(); -``` - -上述代码中,首先创建了一个 BufferedInputStream 对象,用于从文件中读取数据。然后创建了一个字节数组作为缓存区,每次读取数据时将数据存储到缓存区中。读取数据的过程是通过 while 循环实现的,每次读取数据后对缓存区中的数据进行处理。最后关闭 BufferedInputStream,释放资源。 - -以下是一个使用 BufferedOutputStream 写入文件的示例代码: - -```java -// 创建一个 BufferedOutputStream 对象,用于将数据写入到文件中 -BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("data.txt")); - -// 创建一个字节数组,作为缓存区 -byte[] buffer = new byte[1024]; - -// 将数据写入到文件中 -String data = "沉默王二是个大傻子!"; -buffer = data.getBytes(); -bos.write(buffer); - -// 刷新缓存区,将缓存区中的数据写入到文件中 -bos.flush(); - -// 关闭 BufferedOutputStream,释放资源 -bos.close(); -``` - -上述代码中,首先创建了一个 BufferedOutputStream 对象,用于将数据写入到文件中。然后创建了一个字节数组作为缓存区,将数据写入到缓存区中。写入数据的过程是通过 write() 方法实现的,将字节数组作为参数传递给 write() 方法即可。 - -最后,通过 flush() 方法将缓存区中的数据写入到文件中。在写入数据时,由于使用了 BufferedOutputStream,数据会先被写入到缓存区中,只有在缓存区被填满或者调用了 flush() 方法时才会将缓存区中的数据写入到文件中。 - -以下是一个使用 BufferedReader 读取文件的示例代码: - -```java -// 创建一个 BufferedReader 对象,用于从文件中读取数据 -BufferedReader br = new BufferedReader(new FileReader("data.txt")); - -// 读取文件中的数据,并将其存储到字符串中 -String line; -while ((line = br.readLine()) != null) { - // 对读取到的数据进行处理 - // 这里只是简单地将读取到的每一行字符串打印出来 - System.out.println(line); -} - -// 关闭 BufferedReader,释放资源 -br.close(); -``` - -上述代码中,首先创建了一个 BufferedReader 对象,用于从文件中读取数据。然后使用 readLine() 方法读取文件中的数据,每次读取一行数据并将其存储到一个字符串中。读取数据的过程是通过 while 循环实现的。 - -以下是一个使用 BufferedWriter 写入文件的示例代码: - -```java -// 创建一个 BufferedWriter 对象,用于将数据写入到文件中 -BufferedWriter bw = new BufferedWriter(new FileWriter("data.txt")); - -// 将数据写入到文件中 -String data = "沉默王二,真帅气"; -bw.write(data); - -// 刷新缓存区,将缓存区中的数据写入到文件中 -bw.flush(); - -// 关闭 BufferedWriter,释放资源 -bw.close(); -``` - -上述代码中,首先创建了一个 BufferedWriter 对象,用于将数据写入到文件中。然后使用 write() 方法将数据写入到缓存区中,写入数据的过程和使用 FileWriter 类似。需要注意的是,使用 BufferedWriter 写入数据时,数据会先被写入到缓存区中,只有在缓存区被填满或者调用了 flush() 方法时才会将缓存区中的数据写入到文件中。 - -最后,通过 flush() 方法将缓存区中的数据写入到文件中,并通过 close() 方法关闭 BufferedWriter,释放资源。 - -使用缓冲流可以提高读写效率,减少了频繁的读写磁盘或网络的次数,从而提高了程序的性能。但是,在使用缓冲流时需要注意缓冲区的大小和清空缓冲区的时机,以避免数据丢失或不完整的问题。 - -#### **6)打印** - -Java 的打印流是一组用于打印输出数据的类,包括 PrintStream 和 PrintWriter 两个类。 - -恐怕 Java 程序员一生当中最常用的就是打印流了:`System.out` 其实返回的就是一个 PrintStream 对象,可以用来打印各式各样的对象。 - -```java -System.out.println("沉默王二是真的二!"); -``` - -PrintStream 最终输出的是字节数据,而 PrintWriter 则是扩展了 Writer 接口,所以它的 `print()/println()` 方法最终输出的是字符数据。使用上几乎和 PrintStream 一模一样。 - -```java -StringWriter buffer = new StringWriter(); -try (PrintWriter pw = new PrintWriter(buffer)) { - pw.println("沉默王二"); -} -System.out.println(buffer.toString()); -``` - -#### **7)对象序列化/反序列化** - -序列化本质上是将一个 Java 对象转成字节数组,然后可以将其保存到文件中,或者通过网络传输到远程。 - -```java -// 创建一个 ByteArrayOutputStream 对象 buffer,用于存储数据 -ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - -// 使用 try-with-resources 语句创建一个 ObjectOutputStream 对象 output,并将其与 buffer 关联 -try (ObjectOutputStream output = new ObjectOutputStream(buffer)) { - - // 使用 writeUTF() 方法将字符串 "沉默王二" 写入到缓冲区中 - output.writeUTF("沉默王二"); -} - -// 使用 toByteArray() 方法将缓冲区中的数据转换成字节数组,并输出到控制台 -System.out.println(Arrays.toString(buffer.toByteArray())); -``` - -与其对应的,有序列化,就有反序列化,也就是再将字节数组转成 Java 对象的过程。 - -```java -try (ObjectInputStream input = new ObjectInputStream(new FileInputStream( - new File("Person.txt")))) { - String s = input.readUTF(); -} -``` - -这段代码主要使用了 Java 的 ByteArrayOutputStream 和 ObjectOutputStream 类,将字符串 "沉默王二" 写入到一个字节数组缓冲区中,并将缓冲区中的数据转换成字节数组输出到控制台。 - -具体的执行过程如下: - -- 创建一个 ByteArrayOutputStream 对象 buffer,用于存储数据。 -- 使用 [try-with-resources](https://tobebetterjavaer.com/exception/try-with-resources.html) 语句创建一个 ObjectOutputStream 对象 output,并将其与 buffer 关联。 -- 使用 writeUTF() 方法将字符串 "沉默王二" 写入到缓冲区中。 -- 当 try-with-resources 语句执行完毕时,会自动调用 output 的 close() 方法关闭输出流,释放资源。 -- 使用 toByteArray() 方法将缓冲区中的数据转换成字节数组。 -- 使用 Arrays.toString() 方法将字节数组转换成字符串,并输出到控制台。 - -#### **8)转换** - -InputStreamReader 是从字节流到字符流的桥连接,它使用指定的字符集读取字节并将它们解码为字符。 - -```java -// 创建一个 InputStreamReader 对象 isr,使用 FileInputStream 对象读取文件 demo.txt 的内容并将其转换为字符流 -InputStreamReader isr = new InputStreamReader(new FileInputStream("demo.txt")); - -// 创建一个字符数组 cha,用于存储读取的字符数据,其中 1024 表示数组的长度 -char[] cha = new char[1024]; - -// 使用 read() 方法读取 isr 中的数据,并将读取的字符数据存储到 cha 数组中,返回值 len 表示读取的字符数 -int len = isr.read(cha); - -// 将 cha 数组中从下标 0 开始、长度为 len 的部分转换成字符串,并输出到控制台 -System.out.println(new String(cha, 0, len)); - -// 关闭 InputStreamReader 对象 isr,释放资源 -isr.close(); -``` - -这段代码主要使用了 Java 的 InputStreamReader 和 FileInputStream 类,从文件 demo.txt 中读取数据并将其转换为字符流,然后将读取的字符数据存储到一个字符数组中,并输出转换成字符串后的结果到控制台。 - -OutputStreamWriter 将一个字符流的输出对象变为字节流的输出对象,是字符流通向字节流的桥梁。 - -```java -// 创建一个 File 对象 f,表示文件 test.txt -File f = new File("test.txt"); - -// 创建一个 OutputStreamWriter 对象 out,使用 FileOutputStream 对象将数据写入到文件 f 中,并将字节流转换成字符流 -Writer out = new OutputStreamWriter(new FileOutputStream(f)); - -// 使用 write() 方法将字符串 "沉默王二!!" 写入到文件 f 中 -out.write("沉默王二!!"); - -// 关闭 Writer 对象 out,释放资源 -out.close(); -``` - -使用转换流可以方便地在字节流和字符流之间进行转换。在进行文本文件读写时,通常使用字符流进行操作,而在进行网络传输或与设备进行通信时,通常使用字节流进行操作。 - -另外,在使用转换流时需要注意字符编码的问题。如果不指定字符编码,则使用默认的字符编码,可能会出现乱码问题。因此,建议在使用转换流时,始终指定正确的字符编码,以避免出现乱码问题。 - -“小二啊,你看,经过我的梳理,是不是感觉 IO 也没多少东西!针对不同的场景、不同的业务,选择对应的 IO 流就可以了,用法上就是读和写。”老王一口气讲完这些,长长的舒了一口气。 - -此时此刻的小二,还沉浸在老王的滔滔不绝中。不仅感觉老王的肺活量是真的大,还感慨老王不愧是工作了十多年的“老油条”,一下子就把自己感觉头大的 IO 给梳理得很清晰了。 - -## 7.2 文件流 - -在 IO 操作中,文件的操作相对来说是比较复杂的,但也是使用频率最高的部分,我们几乎所有的项目中几乎都躺着一个叫做 FileUtil 或者 FileUtils 的工具类。 - -`java.io.File` 类是专门对文件进行操作的类,注意只能对文件本身进行操作,不能对文件内容进行操作,想要操作内容,必须借助输入输出流。 - -`File` 类是文件和目录的抽象表示,主要用于文件和目录的创建、查找和删除等操作。 - -怎么理解上面两句话?其实很简单! - -第一句是说 File 跟流无关,File 类不能对文件进行读和写,也就是输入和输出! - -第二句是说 File 可以表示`D:\\文件目录1`与`D:\\文件目录1\\文件.txt`,前者是文件夹(Directory,或者叫目录)后者是文件(file),File 类就是用来操作它俩的。 - -### File 构造方法 - -在 Java 中,一切皆是对象,File 类也不例外,不论是哪个对象都应该从该对象的构造说起,所以我们来分析分析`File`类的构造方法。 - -比较常用的构造方法有三个: - -1、 `File(String pathname)` :通过给定的**路径**来创建新的 File 实例。 - -2、 `File(String parent, String child)` :从**父路径(字符串)和子路径**创建新的 File 实例。 - -3、 `File(File parent, String child)` :从**父路径(File)和子路径名字符串**创建新的 File 实例。 - -看文字描述不够生动、不够形象、不得劲?没事,通过举例马上就生动形象了,代码如下: - -```java -// 文件路径名 -String path = "/Users/username/123.txt"; -File file1 = new File(path); -// 文件路径名 -String path2 = "/Users/username/1/2.txt"; -File file2 = new File(path2); -------------相当于/Users/username/1/2.txt -// 通过父路径和子路径字符串 -String parent = "/Users/username/aaa"; -String child = "bbb.txt"; -File file3 = new File(parent, child); --------相当于/Users/username/aaa/bbb.txt -// 通过父级File对象和子路径字符串 -File parentDir = new File("/Users/username/aaa"); -String child = "bbb.txt"; -File file4 = new File(parentDir, child); --------相当于/Users/username/aaa/bbb.txt -``` - -注意,macOS 路径使用正斜杠(`/`)作为路径分隔符,而 Windows 路径使用反斜杠(`\`)作为路径分隔符。所以在遇到路径分隔符的时候,不要直接去写`/`或者`\`。 - -Java 中提供了一个跨平台的方法来获取路径分隔符,即使用 `File.separator`,这个属性会根据操作系统自动返回正确的路径分隔符。 - -File 类的注意点: - -1. 一个 File 对象代表硬盘中实际存在的一个文件或者目录。 -2. File 类的构造方法不会检验这个文件或目录是否真实存在,因此无论该路径下是否存在文件或者目录,都不影响 File 对象的创建。 - -### File 常用方法 - -File 的常用方法主要分为获取功能、获取绝对路径和相对路径、判断功能、创建删除功能的方法。 - -#### **1)获取功能的方法** - -1、`getAbsolutePath()` :返回此 File 的绝对路径。 - -2、`getPath()` :结果和 getAbsolutePath 一致。 - -3、`getName()` :返回文件名或目录名。 - -4、`length()` :返回文件长度,以字节为单位。 - -测试代码如下【注意测试以你自己的电脑文件夹为准】: - -```java -File f = new File("/Users/username/aaa/bbb.java"); -System.out.println("文件绝对路径:"+f.getAbsolutePath()); -System.out.println("文件构造路径:"+f.getPath()); -System.out.println("文件名称:"+f.getName()); -System.out.println("文件长度:"+f.length()+"字节"); - -File f2 = new File("/Users/username/aaa"); -System.out.println("目录绝对路径:"+f2.getAbsolutePath()); -System.out.println("目录构造路径:"+f2.getPath()); -System.out.println("目录名称:"+f2.getName()); -System.out.println("目录长度:"+f2.length()); -``` - -注意:`length()` 表示文件的长度,`File` 对象表示目录的时候,返回值并无意义。 - -#### **2)绝对路径和相对路径** - -绝对路径是从文件系统的根目录开始的完整路径,它描述了一个文件或目录在文件系统中的确切位置。在 Windows 系统中,绝对路径通常以盘符(如 C:)开始,例如 "`C:\Program Files\Java\jdk1.8.0_291\bin\java.exe`"。在 macOS 和 Linux 系统中,绝对路径通常以斜杠(`/`)开始,例如 "`/usr/local/bin/python3`"。 - -相对路径是相对于当前工作目录的路径,它描述了一个文件或目录与当前工作目录之间的位置关系。在 Java 中,相对路径通常是相对于当前 Java 程序所在的目录,例如 "`config/config.properties`"。如果当前工作目录是 "`/Users/username/project`",那么相对路径 "`config/config.properties`" 就表示 "`/Users/username/project/config/config.properties`"。 - -注意: - -- 在 Windows 操作系统中,文件系统默认是不区分大小写的,即在文件系统中,文件名和路径的大小写可以混合使用。例如,"`C:\Users\username\Documents\example.txt`" 和 "`C:\Users\Username\Documents\Example.txt`" 表示的是同一个文件。但是,Windows 操作系统提供了一个区分大小写的选项,可以在格式化磁盘时选择启用,这样文件系统就会区分大小写。 -- 在 macOS 和 Linux 等 Unix 系统中,文件系统默认是区分大小写的。例如,在 macOS 系统中,"`/Users/username/Documents/example.txt`" 和 "`/Users/username/Documents/Example.txt`" 表示的是两个不同的文件。 - -```java -// 绝对路径示例 -File absoluteFile = new File("/Users/username/example/test.txt"); -System.out.println("绝对路径:" + absoluteFile.getAbsolutePath()); - -// 相对路径示例 -File relativeFile = new File("example/test.txt"); -System.out.println("相对路径:" + relativeFile.getPath()); -``` - -#### **3)判断功能的方法** - -1、 `exists()` :判断文件或目录是否存在。 - -2、 `isDirectory()` :判断是否为目录。 - -3、`isFile()` :判断是否为文件。 - -方法演示,代码如下: - -```java -File file = new File("/Users/username/example"); - -// 判断文件或目录是否存在 -if (file.exists()) { - System.out.println("文件或目录存在"); -} else { - System.out.println("文件或目录不存在"); -} - -// 判断是否是目录 -if (file.isDirectory()) { - System.out.println("是目录"); -} else { - System.out.println("不是目录"); -} - -// 判断是否是文件 -if (file.isFile()) { - System.out.println("是文件"); -} else { - System.out.println("不是文件"); -} -``` - -#### **4)创建、删除功能的方法** - -- `createNewFile()` :文件不存在,创建一个新的空文件并返回`true`,文件存在,不创建文件并返回`false`。 -- `delete()` :删除文件或目录。如果是目录,只有目录为空才能删除。 -- `mkdir()` :只能创建一级目录,如果父目录不存在,则创建失败。返回 true 表示创建成功,返回 false 表示创建失败。 -- `mkdirs()` :可以创建多级目录,如果父目录不存在,则会一并创建。返回 true 表示创建成功,返回 false 表示创建失败或目录已经存在。 - -**开发中一般用**`mkdirs()`; - -方法测试,代码如下: - -```java -// 创建文件 -File file = new File("/Users/username/example/test.txt"); -if (file.createNewFile()) { - System.out.println("创建文件成功:" + file.getAbsolutePath()); -} else { - System.out.println("创建文件失败:" + file.getAbsolutePath()); -} - -// 删除文件 -if (file.delete()) { - System.out.println("删除文件成功:" + file.getAbsolutePath()); -} else { - System.out.println("删除文件失败:" + file.getAbsolutePath()); -} - -// 创建多级目录 -File directory = new File("/Users/username/example/subdir1/subdir2"); -if (directory.mkdirs()) { - System.out.println("创建目录成功:" + directory.getAbsolutePath()); -} else { - System.out.println("创建目录失败:" + directory.getAbsolutePath()); -} -``` - -#### 5)目录的遍历 - -- `String[] list()` :返回一个 String 数组,表示该 File 目录中的所有子文件或目录。 -- `File[] listFiles()` :返回一个 File 数组,表示该 File 目录中的所有的子文件或目录。 - -```java -File directory = new File("/Users/itwanger/Documents/Github/paicoding"); - -// 列出目录下的文件名 -String[] files = directory.list(); -System.out.println("目录下的文件名:"); -for (String file : files) { - System.out.println(file); -} - -// 列出目录下的文件和子目录 -File[] filesAndDirs = directory.listFiles(); -System.out.println("目录下的文件和子目录:"); -for (File fileOrDir : filesAndDirs) { - if (fileOrDir.isFile()) { - System.out.println("文件:" + fileOrDir.getName()); - } else if (fileOrDir.isDirectory()) { - System.out.println("目录:" + fileOrDir.getName()); - } -} -``` - -**listFiles**在获取指定目录下的文件或者子目录时必须满足下面两个条件: - -- 1. **指定的目录必须存在** -- 2. **指定的必须是目录。否则容易引发 NullPointerException 异常** - -#### 6)递归遍历 - -不说啥了,直接上代码: - -```java -public static void main(String[] args) { - File directory = new File("/Users/itwanger/Documents/Github/paicoding"); - - // 递归遍历目录下的文件和子目录 - traverseDirectory(directory); -} - -public static void traverseDirectory(File directory) { - // 列出目录下的所有文件和子目录 - File[] filesAndDirs = directory.listFiles(); - - // 遍历每个文件和子目录 - for (File fileOrDir : filesAndDirs) { - if (fileOrDir.isFile()) { - // 如果是文件,输出文件名 - System.out.println("文件:" + fileOrDir.getName()); - } else if (fileOrDir.isDirectory()) { - // 如果是目录,递归遍历子目录 - System.out.println("目录:" + fileOrDir.getName()); - traverseDirectory(fileOrDir); - } - } -} -``` - -### RandomAccessFile - -RandomAccessFile 是 Java 中一个非常特殊的类,它既可以用来读取文件,也可以用来写入文件。与其他 IO 类(如 FileInputStream 和 FileOutputStream)不同,RandomAccessFile 允许您跳转到文件的任何位置,从那里开始读取或写入。这使得它特别适用于需要在文件中随机访问数据的场景,如数据库系统。 - -下面是一个使用 RandomAccessFile 的示例,包括写入和读取文件: - -```java -import java.io.IOException; -import java.io.RandomAccessFile; - -public class RandomAccessFileDemo { - - public static void main(String[] args) { - String filePath = "logs/javabetter/itwanger.txt"; - - try { - // 使用 RandomAccessFile 写入文件 - writeToFile(filePath, "Hello, 沉默王二!"); - - // 使用 RandomAccessFile 读取文件 - String content = readFromFile(filePath); - System.out.println("文件内容: " + content); - } catch (IOException e) { - e.printStackTrace(); - } - } - - private static void writeToFile(String filePath, String content) throws IOException { - try (RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "rw")) { - // 将文件指针移动到文件末尾(在此处追加内容) - randomAccessFile.seek(randomAccessFile.length()); - - // 写入内容 - randomAccessFile.writeUTF(content); - } - } - - private static String readFromFile(String filePath) throws IOException { - StringBuilder content = new StringBuilder(); - - try (RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "r")) { - // 将文件指针移动到文件开始处(从头开始读取) - randomAccessFile.seek(0); - - content.append(randomAccessFile.readUTF()); - } - - return content.toString(); - } -} -``` - -为了避免中文乱码问题,我们使用 RandomAccessFile 的 writeUTF 和 readUTF 方法,它们将使用 UTF-8 编码处理字符串。大家可以运行一下这段代码,体验一下。 - -![](https://cdn.tobebetterjavaer.com/stutymore/file-path-20230331193604.png) - -接下来,会详细介绍一下 RandomAccessFile 的构造方法和常用的方法。 - -#### 构造方法 - -RandomAccessFile 主要有两个构造方法: - -- `RandomAccessFile(File file, String mode)`:使用给定的文件对象和访问模式创建一个新的 RandomAccessFile 实例。 -- `RandomAccessFile(String name, String mode)`:使用给定的文件名和访问模式创建一个新的 RandomAccessFile 实例。 - -访问模式 mode 的值可以是: - -- "r":以只读模式打开文件。调用结果对象的任何 write 方法都将导致 IOException。 -- "rw":以读写模式打开文件。如果文件不存在,它将被创建。 -- "rws":以读写模式打开文件,并要求对内容或元数据的每个更新都被立即写入到底层存储设备。这种模式是同步的,可以确保在系统崩溃时不会丢失数据。 -- "rwd":与“rws”类似,以读写模式打开文件,但仅要求对文件内容的更新被立即写入。元数据可能会被延迟写入。 - -#### 主要方法 - -- `long getFilePointer()`:返回文件指针的当前位置。 -- `long length()`:返回此文件的长度。 -- `int read()`:从该文件中读取一个字节数据。 -- `int read(byte[] b)`:从该文件中读取字节数据并将其存储到指定的字节数组中。 -- `int read(byte[] b, int off, int len)`:从该文件中读取字节数据并将其存储到指定的字节数组中,从偏移量 off 开始,最多读取 len 个字节。 -- `String readLine()`:从该文件中读取一行文本。 -- `readUTF()`:从文件读取 UTF-8 编码的字符串。此方法首先读取两个字节的长度信息,然后根据这个长度读取字符串的 UTF-8 字节。最后,这些字节被转换为 Java 字符串。这意味着当你使用 readUTF 方法读取字符串时,需要确保文件中的字符串是使用 writeUTF 方法写入的,这样它们之间的长度信息和编码方式才能保持一致。 -- `void seek(long pos)`:将文件指针设置到文件中的 pos 位置。 -- `void write(byte[] b)`:将指定的字节数组的所有字节写入该文件。 -- `void write(byte[] b, int off, int len)`:将指定字节数组的部分字节写入该文件,从偏移量 off 开始,写入 len 个字节。 -- `void write(int b)`:将指定的字节写入该文件。 -- `writeUTF(String str)`:将一个字符串以 UTF-8 编码写入文件。此方法首先写入两个字节的长度信息,表示字符串的 UTF-8 字节长度,然后写入 UTF-8 字节本身。因此,当你使用 writeUTF 写入字符串时,实际写入的字节数会比字符串的 UTF-8 字节长度多两个字节。这两个字节用于在读取字符串时确定正确的字符串长度。 - -再来看一个示例,结合前面的讲解,就会彻底掌握 RandomAccessFile。 - -```java -File file = new File("logs/javabetter/itwanger.txt"); - -try (RandomAccessFile raf = new RandomAccessFile(file, "rw")) { - // 写入文件 - raf.writeUTF("Hello, 沉默王二!"); - - // 将文件指针移动到文件开头 - raf.seek(0); - - // 读取文件内容 - String content = raf.readUTF(); - System.out.println("内容: " + content); - -} catch (IOException e) { - e.printStackTrace(); -} -``` - -在这个示例中,我们首先创建了一个名为 itwanger.txt 的文件对象。然后我们使用 RandomAccessFile 以读写模式打开这个文件。 - -接下来,我们使用 writeUTF 方法将字符串"Hello, 沉默王二!"写入文件。然后,我们使用 seek 方法将文件指针移动到文件开头,并使用 readUTF 方法读取文件内容。输出应该是"Hello, 沉默王二!"。 - -最后,我们使用[try-with-resources](https://tobebetterjavaer.com/exception/try-with-resources.html)语句确保 RandomAccessFile 在操作完成后被正确关闭。 - -### Apache FileUtils 类 - -FileUtils 类是 Apache Commons IO 库中的一个类,提供了一些更为方便的方法来操作文件或目录。 - -#### **1)复制文件或目录:** - -```java -File srcFile = new File("path/to/src/file"); -File destFile = new File("path/to/dest/file"); -// 复制文件 -FileUtils.copyFile(srcFile, destFile); -// 复制目录 -FileUtils.copyDirectory(srcFile, destFile); -``` - -#### **2)删除文件或目录:** - -```java -File file = new File("path/to/file"); -// 删除文件或目录 -FileUtils.delete(file); -``` - -需要注意的是,如果要删除一个非空目录,需要先删除目录中的所有文件和子目录。 - -#### **3)移动文件或目录:** - -```java -File srcFile = new File("path/to/src/file"); -File destFile = new File("path/to/dest/file"); -// 移动文件或目录 -FileUtils.moveFile(srcFile, destFile); -``` - -#### **4)查询文件或目录的信息:** - -```java -File file = new File("path/to/file"); -// 获取文件或目录的修改时间 -Date modifyTime = FileUtils.lastModified(file); -// 获取文件或目录的大小 -long size = FileUtils.sizeOf(file); -// 获取文件或目录的扩展名 -String extension = FileUtils.getExtension(file.getName()); -``` - -### Hutool FileUtil 类 - -FileUtil 类是 [Hutool](https://hutool.cn) 工具包中的文件操作工具类,提供了一系列简单易用的文件操作方法,可以帮助 Java 开发者快速完成文件相关的操作任务。 - -FileUtil 类包含以下几类操作工具: - -- 文件操作:包括文件目录的新建、删除、复制、移动、改名等 -- 文件判断:判断文件或目录是否非空,是否为目录,是否为文件等等。 -- 绝对路径:针对 ClassPath 中的文件转换为绝对路径文件。 -- 文件名:主文件名,扩展名的获取 -- 读操作:包括 getReader、readXXX 操作 -- 写操作:包括 getWriter、writeXXX 操作 - -下面是 FileUtil 类中一些常用的方法: - -1、copyFile:复制文件。该方法可以将指定的源文件复制到指定的目标文件中。 - -```java -File dest = FileUtil.file("FileUtilDemo2.java"); -``` - -2、move:移动文件或目录。该方法可以将指定的源文件或目录移动到指定的目标文件或目录中。 - -```java -FileUtil.move(file, dest, true); -``` - -3、del:删除文件或目录。该方法可以删除指定的文件或目录,如果指定的文件或目录不存在,则会抛出异常。 - -```java -FileUtil.del(file); -``` - -4、rename:重命名文件或目录。该方法可以将指定的文件或目录重命名为指定的新名称。 - -```java -FileUtil.rename(file, "FileUtilDemo3.java", true); -``` - -5、readLines:从文件中读取每一行数据。 - -```java -FileUtil.readLines(file, "UTF-8").forEach(System.out::println); -``` - -更多方法,可以去看一下 hutool 的源码,里面有非常多实用的方法,多看看,绝对能提升不少编程水平。 - -## 7.3 字节流 - -我们必须得明确一点,一切文件(文本、视频、图片)的数据都是以二进制的形式存储的,传输时也是。所以,字节流可以传输任意类型的文件数据。 - -### 字节输出流(OutputStream) - -`java.io.OutputStream` 是**字节输出流**的**超类**(父类),我们来看一下它定义的一些共性方法: - -1、 `close()` :关闭此输出流并释放与此流相关联的系统资源。 - -2、 `flush()` :刷新此输出流并强制缓冲区的字节被写入到目的地。 - -3、 `write(byte[] b)`:将 b.length 个字节从指定的字节数组写入此输出流。 - -4、 `write(byte[] b, int off, int len)` :从指定的字节数组写入 len 字节到此输出流,从偏移量 off开始。 **也就是说从off个字节数开始一直到len个字节结束** - -### FileOutputStream类 - -`OutputStream` 有很多子类,我们从最简单的一个子类 FileOutputStream 开始。看名字就知道是文件输出流,用于将数据写入到文件。 - -#### **1)FileOutputStrea 的构造方法** - -1、使用文件名创建 FileOutputStream 对象。 - -```java -String fileName = "example.txt"; -FileOutputStream fos = new FileOutputStream(fileName); -``` - -以上代码使用文件名 "example.txt" 创建一个 FileOutputStream 对象,将数据写入到该文件中。**如果文件不存在,则创建一个新文件;如果文件已经存在,则覆盖原有文件**。 - -2、使用文件对象创建 FileOutputStream 对象。 - -```java -File file = new File("example.txt"); -FileOutputStream fos = new FileOutputStream(file); -``` - -FileOutputStream 的使用示例: - -```java -FileOutputStream fos = null; -try { - fos = new FileOutputStream("example.txt"); - fos.write("沉默王二".getBytes()); -} catch (IOException e) { - e.printStackTrace(); -} finally { - if (fos != null) { - try { - fos.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -以上代码创建了一个 FileOutputStream 对象,将字符串 "沉默王二" 写入到 example.txt 文件中,并在最后关闭了输出流。 - - -#### **2)FileOutputStream 写入字节数据** - -使用 FileOutputStream 写入字节数据主要通过 `write` 方法: - -```java -write(int b) -write(byte[] b) -write(byte[] b,int off,int len) //从`off`索引开始,`len`个字节 -``` - - -①、**写入字节**:`write(int b)` 方法,每次可以写入一个字节,代码如下: - -```java -// 使用文件名称创建流对象 -FileOutputStream fos = new FileOutputStream("fos.txt"); -// 写出数据 -fos.write(97); // 第1个字节 -fos.write(98); // 第2个字节 -fos.write(99); // 第3个字节 -// 关闭资源 -fos.close(); -``` - -字符 a 的 [ASCII 值](https://tobebetterjavaer.com/basic-extra-meal/java-unicode.html)为 97,字符 b 的ASCII 值为 98,字符 b 的ASCII 值为 99。也就是说,以上代码可以写成: - -```java -// 使用文件名称创建流对象 -FileOutputStream fos = new FileOutputStream("fos.txt"); -// 写出数据 -fos.write('a'); // 第1个字节 -fos.write('b'); // 第2个字节 -fos.write('c'); // 第3个字节 -// 关闭资源 -fos.close(); -``` - -当使用 `write(int b)` 方法写出一个字节时,参数 b 表示要写出的字节的整数值。由于一个字节只有8位,因此参数 b 的取值范围应该在 0 到 255 之间,超出这个范围的值将会被截断。例如,如果参数 b 的值为 -1,那么它会被截断为 255,如果参数 b 的值为 256,那么它会被截断为 0。 - -在将参数 b 写入输出流中时,write(int b) 方法只会将参数 b 的低8位写入,而忽略高24位。这是因为在 Java 中,整型类型(包括 byte、short、int、long)在内存中以二进制补码形式表示。当将一个整型值传递给 write(int b) 方法时,方法会将该值转换为 byte 类型,只保留二进制补码的低8位,而忽略高24位。 - -例如,如果要写出的整数为 0x12345678,它的二进制补码表示为 0001 0010 0011 0100 0101 0110 0111 1000。当使用 write(int b) 方法写出该整数时,只会将二进制补码的低8位 0111 1000 写出,而忽略高24位 0001 0010 0011 0100 0101 0110。这就是参数 b 的高24位被忽略的原因。 - -0111 1000 是一个8位的二进制数,它对应的十进制数是 120,对应的 ASCII 码字符是小写字母 "x"。在 ASCII 码表中,小写字母 "x" 的十进制 ASCII 码值为 120。因此,如果使用 write(int b) 方法写出一个字节值为 0x78(十进制为 120),那么写出的结果就是小写字母 "x"。 - -我们来验证一下: - -```java -FileOutputStream fos = null; -try { - fos = new FileOutputStream("example.txt"); - - fos.write(120); - fos.write('x'); - fos.write(0x12345678); -} catch (IOException e) { - e.printStackTrace(); -} finally { - if (fos != null) { - try { - fos.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -来看一下结果: - -![](https://cdn.tobebetterjavaer.com/studymore/stream-20230318105229.png) - -果然是 3 个 x。 - -②、**写入字节数组**:`write(byte[] b)`,代码示例: - -```java -// 使用文件名称创建流对象 -FileOutputStream fos = new FileOutputStream("fos.txt"); -// 字符串转换为字节数组 -byte[] b = "沉默王二有点帅".getBytes(); -// 写入字节数组数据 -fos.write(b); -// 关闭资源 -fos.close(); -``` - - -③、**写入指定长度字节数组**:`write(byte[] b, int off, int len)`,代码示例: - -```java -// 使用文件名称创建流对象 -FileOutputStream fos = new FileOutputStream("fos.txt"); -// 字符串转换为字节数组 -byte[] b = "abcde".getBytes(); -// 从索引2开始,2个字节。索引2是c,两个字节,也就是cd。 -fos.write(b,2,2); -// 关闭资源 -fos.close(); -``` - - -#### **3)FileOutputStream实现数据追加、换行** - -在上面的代码示例中,每次运行程序都会创建新的输出流对象,于是文件中的数据也会被清空。如果想保留目标文件中的数据,还能继续**追加新数据**,该怎么办呢?以及如何实现**换行**呢? - -其实很简单。 - -我们来学习`FileOutputStream`的另外两个构造方法,如下: - -1、使用文件名和追加标志创建 FileOutputStream 对象 - -```java -String fileName = "example.txt"; -boolean append = true; -FileOutputStream fos = new FileOutputStream(fileName, append); -``` - -以上代码使用文件名 "example.txt" 和追加标志创建一个 FileOutputStream 对象,将数据追加到该文件的末尾。如果文件不存在,则创建一个新文件;如果文件已经存在,则在文件末尾追加数据。 - -2、使用文件对象和追加标志创建 FileOutputStream 对象 - -```java -File file = new File("example.txt"); -boolean append = true; -FileOutputStream fos = new FileOutputStream(file, append); -``` - -以上代码使用文件对象和追加标志创建一个 FileOutputStream 对象,将数据追加到该文件的末尾。 - -这两个构造方法,第二个参数中都需要传入一个boolean类型的值,`true` 表示追加数据,`false` 表示不追加也就是清空原有数据。 - -实现数据追加代码如下: - -```java -// 使用文件名称创建流对象 -FileOutputStream fos = new FileOutputStream("fos.txt",true); -// 字符串转换为字节数组 -byte[] b = "abcde".getBytes(); -// 写出从索引2开始,2个字节。索引2是c,两个字节,也就是cd。 -fos.write(b); -// 关闭资源 -fos.close(); -``` - -多次运行代码,你会发现数据在不断地追加。 - -在 Windows 系统中,换行符号是`\r\n`,具体代码如下: - -```java -String filename = "example.txt"; -FileOutputStream fos = new FileOutputStream(filename, true); // 追加模式 -String content = "沉默王二\r\n"; // 使用回车符和换行符的组合 -fos.write(content.getBytes()); -fos.close(); -``` - -在 macOS 系统中,换行符是 `\n`,具体代码如下: - -```java -String filename = "example.txt"; -FileOutputStream fos = new FileOutputStream(filename, true); // 追加模式 -String content = "沉默王二\n"; // 只使用换行符 -fos.write(content.getBytes()); -fos.close(); -``` - -这里再唠一唠回车符和换行符。 - -回车符(`\r`)和换行符(`\n`)是计算机中常见的控制字符,用于表示一行的结束或者换行的操作。它们在不同的操作系统和编程语言中的使用方式可能有所不同。 - -在 Windows 系统中,通常使用回车符和换行符的组合(`\r\n`)来表示一行的结束。在文本文件中,每行的末尾都会以一个回车符和一个换行符的组合结束。这是由于早期的打印机和终端设备需要回车符和换行符的组合来完成一行的结束和换行操作。在 Windows 中,文本编辑器和命令行终端等工具都支持使用回车符和换行符的组合来表示一行的结束。 - -而在 macOS 和 Linux 系统中,通常只使用换行符(`\n`)来表示一行的结束。在文本文件中,每行的末尾只有一个换行符。这是由于早期 Unix 系统中的终端设备只需要换行符来完成一行的结束和跨行操作。在 macOS 和 Linux 中,文本编辑器和终端等工具都支持使用换行符来表示一行的结束。 - -在编程语言中,通常也会使用回车符和换行符来进行字符串的操作。例如,在 Java 中,字符串中的回车符可以用 "`\r`" 来表示,换行符可以用 "`\n`" 来表示。在通过输入输出流进行文件读写时,也需要注意回车符和换行符的使用方式和操作系统的差异。 - -### 字节输入流(InputStream) - -`java.io.InputStream` 是**字节输入流**的**超类**(父类),我们来看一下它的一些共性方法: - -1、`close()` :关闭此输入流并释放与此流相关的系统资源。 - -2、`int read()`: 从输入流读取数据的下一个字节。 - -3、`read(byte[] b)`: 该方法返回的 int 值代表的是读取了多少个字节,读到几个返回几个,读取不到返回-1 - -### FileInputStream类 - -InputStream 有很多子类,我们从最简单的一个子类 FileInputStream 开始。看名字就知道是文件输入流,用于将数据从文件中读取数据。 - -#### 1)FileInputStream的构造方法 - -1、`FileInputStream(String name)`:创建一个 FileInputStream 对象,并打开指定名称的文件进行读取。文件名由 name 参数指定。如果文件不存在,将会抛出 FileNotFoundException 异常。 - -2、`FileInputStream(File file)`:创建一个 FileInputStream 对象,并打开指定的 File 对象表示的文件进行读取。 - -代码示例如下: - -```java -// 创建一个 FileInputStream 对象 -FileInputStream fis = new FileInputStream("test.txt"); - -// 读取文件内容 -int data; -while ((data = fis.read()) != -1) { - System.out.print((char) data); -} - -// 关闭输入流 -fis.close(); -``` - -#### 2)FileInputStream读取字节数据 - -①、**读取字节**:`read()`方法会读取一个字节并返回其整数表示。如果已经到达文件的末尾,则返回 -1。如果在读取时发生错误,则会抛出 IOException 异常。 - -代码示例如下: - -```java -// 创建一个 FileInputStream 对象 -FileInputStream fis = new FileInputStream("test.txt"); - -// 读取文件内容 -int data; -while ((data = fis.read()) != -1) { - System.out.print((char) data); -} - -// 关闭输入流 -fis.close(); -``` - - -②、**使用字节数组读取**:`read(byte[] b)` 方法会从输入流中最多读取 b.length 个字节,并将它们存储到缓冲区数组 b 中。 - -代码示例如下: - -```java -// 创建一个 FileInputStream 对象 -FileInputStream fis = new FileInputStream("test.txt"); - -// 读取文件内容到缓冲区 -byte[] buffer = new byte[1024]; -int count; -while ((count = fis.read(buffer)) != -1) { - System.out.println(new String(buffer, 0, count)); -} - -// 关闭输入流 -fis.close(); -``` - -#### 3)字节流FileInputstream复制图片 - -原理很简单,就是把图片信息读入到字节输入流中,再通过字节输出流写入到文件中。 - -代码示例如下所示: - -```java -// 创建一个 FileInputStream 对象以读取原始图片文件 -FileInputStream fis = new FileInputStream("original.jpg"); - -// 创建一个 FileOutputStream 对象以写入复制后的图片文件 -FileOutputStream fos = new FileOutputStream("copy.jpg"); - -// 创建一个缓冲区数组以存储读取的数据 -byte[] buffer = new byte[1024]; -int count; - -// 读取原始图片文件并将数据写入复制后的图片文件 -while ((count = fis.read(buffer)) != -1) { - fos.write(buffer, 0, count); -} - -// 关闭输入流和输出流 -fis.close(); -fos.close(); -``` - -上面的代码创建了一个 FileInputStream 对象以读取原始图片文件,并创建了一个 FileOutputStream 对象以写入复制后的图片文件。然后,使用 while 循环逐个读取原始图片文件中的字节,并将其写入复制后的图片文件中。最后,关闭输入流和输出流释放资源。 - -### 小结 - -InputStream 是字节输入流的抽象类,它定义了读取字节数据的方法,如 `read()`、`read(byte[] b)`、`read(byte[] b, int off, int len)` 等。OutputStream 是字节输出流的抽象类,它定义了写入字节数据的方法,如 `write(int b)`、`write(byte[] b)`、`write(byte[] b, int off, int len)` 等。这两个抽象类是字节流的基础。 - -FileInputStream 是从文件中读取字节数据的流,它继承自 InputStream。FileOutputStream 是将字节数据写入文件的流,它继承自 OutputStream。这两个类是字节流最常用的实现类之一。 - - - -## 7.4 字符流 - -字符流 Reader 和 Writer 的故事要从它们的类关系图开始,来看图。 - -![](https://cdn.tobebetterjavaer.com/stutymore/reader-writer-20230320164938.png) - -字符流是一种用于读取和写入字符数据的输入输出流。与字节流不同,字符流以字符为单位读取和写入数据,而不是以字节为单位。常用来处理文本信息。 - -如果用字节流直接读取中文,可能会遇到乱码问题,见下例: - -```java -//FileInputStream为操作文件的字符输入流 -FileInputStream inputStream = new FileInputStream("a.txt");//内容为“沉默王二是傻 X” - -int len; -while ((len=inputStream.read())!=-1){ - System.out.print((char)len); -} -``` - -来看运行结果: - -``` -运行结果: 沉默王二是傻 X -``` - -看一下截图: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/io/reader-writer-0b68ef81-26d0-4a4e-9c1b-61928ce8646c.png) - - -之所以出现乱码是因为在字节流中,一个字符通常由多个字节组成,而不同的字符编码使用的字节数不同。如果我们使用了错误的字符编码,或者在读取和写入数据时没有正确处理字符编码的转换,就会导致读取出来的中文字符出现乱码。 - -例如,当我们使用默认的字符编码(见上例)读取一个包含中文字符的文本文件时,就会出现乱码。因为默认的字符编码通常是 ASCII 编码,它只能表示英文字符,而不能正确地解析中文字符。 - -那使用字节流该如何正确地读出中文呢?见下例。 - -```java -try (FileInputStream inputStream = new FileInputStream("a.txt")) { - byte[] bytes = new byte[1024]; - int len; - while ((len = inputStream.read(bytes)) != -1) { - System.out.print(new String(bytes, 0, len)); - } -} -``` - -为什么这种方式就可以呢? - -因为我们拿 String 类进行了解码,查看`new String(byte bytes[], int offset, int length)`的源码就可以发现,该构造方法有解码功能: - -```java -public String(byte bytes[], int offset, int length) { - checkBounds(bytes, offset, length); - this.value = StringCoding.decode(bytes, offset, length); -} -``` - -继续追看 `StringCoding.decode()` 方法调用的 `defaultCharset()` 方法,会发现默认编码是`UTF-8`,代码如下 - -```java -public static Charset defaultCharset() { - if (defaultCharset == null) { - synchronized (Charset.class) { - if (cs != null) - defaultCharset = cs; - else - defaultCharset = forName("UTF-8"); - } - } - return defaultCharset; -} -static char[] decode(byte[] ba, int off, int len) { - String csn = Charset.defaultCharset().name(); - try { - // use charset name decode() variant which provides caching. - return decode(csn, ba, off, len); - } catch (UnsupportedEncodingException x) { - warnUnsupportedCharset(csn); - } -} -``` - -在 Java 中,常用的字符编码有 ASCII、ISO-8859-1、UTF-8、UTF-16 等。其中,ASCII 和 ISO-8859-1 只能表示部分字符,而 UTF-8 和 UTF-16 可以表示所有的 Unicode 字符,包括中文字符。 - -当我们使用 `new String(byte bytes[], int offset, int length)` 将字节流转换为字符串时,Java 会根据 UTF-8 的规则将每 3 个字节解码为一个中文字符,从而正确地解码出中文。 - -尽管字节流也有办法解决乱码问题,但不够直接,于是就有了字符流,`专门用于处理文本`文件(音频、图片、视频等为非文本文件)。 - -从另一角度来说:**字符流 = 字节流 + 编码表** - -### 01、字符输入流(Reader) - -`java.io.Reader`是**字符输入流**的**超类**(父类),它定义了字符输入流的一些共性方法: - -- 1、`close()`:关闭此流并释放与此流相关的系统资源。 -- 2、`read()`:从输入流读取一个字符。 -- 3、`read(char[] cbuf)`:从输入流中读取一些字符,并将它们存储到字符数组 `cbuf`中 - -FileReader 是 Reader 的子类,用于从文件中读取字符数据。它的主要特点如下: - -- 可以通过构造方法指定要读取的文件路径。 -- 每次可以读取一个或多个字符。 -- 可以读取 Unicode 字符集中的字符,通过指定字符编码来实现字符集的转换。 - -#### 1)FileReader构造方法 - -- 1、`FileReader(File file)`:创建一个新的 FileReader,参数为**File对象**。 -- 2、`FileReader(String fileName)`:创建一个新的 FileReader,参数为文件名。 - -代码示例如下: - -```java -// 使用File对象创建流对象 -File file = new File("a.txt"); -FileReader fr = new FileReader(file); - -// 使用文件名称创建流对象 -FileReader fr = new FileReader("b.txt"); -``` - -#### 2)FileReader读取字符数据 - -①、**读取字符**:`read`方法,每次可以读取一个字符,返回读取的字符(转为 int 类型),当读取到文件末尾时,返回`-1`。代码示例如下: - -```java -// 使用文件名称创建流对象 -FileReader fr = new FileReader("abc.txt"); -// 定义变量,保存数据 -int b; -// 循环读取 -while ((b = fr.read())!=-1) { - System.out.println((char)b); -} -// 关闭资源 -fr.close(); -``` - -②、**读取指定长度的字符**:`read(char[] cbuf, int off, int len)`,并将其存储到字符数组中。其中,cbuf 表示存储读取结果的字符数组,off 表示存储结果的起始位置,len 表示要读取的字符数。代码示例如下: - -```java -File textFile = new File("docs/约定.md"); -// 给一个 FileReader 的示例 -// try-with-resources FileReader -try(FileReader reader = new FileReader(textFile);) { - // read(char[] cbuf) - char[] buffer = new char[1024]; - int len; - while ((len = reader.read(buffer, 0, buffer.length)) != -1) { - System.out.print(new String(buffer, 0, len)); - } -} -``` - -在这个例子中,使用 FileReader 从文件中读取字符数据,并将其存储到一个大小为 1024 的字符数组中。每次读取 len 个字符,然后使用 String 构造方法将其转换为字符串并输出。 - -FileReader 实现了 AutoCloseable 接口,因此可以使用 [try-with-resources](https://tobebetterjavaer.com/exception/try-with-resources.html) 语句自动关闭资源,避免了手动关闭资源的繁琐操作。 - -### 02、字符输出流(Writer) - -`java.io.Writer` 是**字符输出流**类的**超类**(父类),可以将指定的字符信息写入到目的地,来看它定义的一些共性方法: - -- 1、`write(int c)` 写入单个字符。 -- 2、`write(char[] cbuf)` 写入字符数组。 -- 3、`write(char[] cbuf, int off, int len)` 写入字符数组的一部分,off为开始索引,len为字符个数。 -- 4、`write(String str)` 写入字符串。 -- 5、`write(String str, int off, int len)` 写入字符串的某一部分,off 指定要写入的子串在 str 中的起始位置,len 指定要写入的子串的长度。 -- 6、`flush()` 刷新该流的缓冲。 -- 7、`close()` 关闭此流,但要先刷新它。 - -`java.io.FileWriter` 类是 Writer 的子类,用来将字符写入到文件。 - -#### 1)FileWriter 构造方法 - -- `FileWriter(File file)`: 创建一个新的 FileWriter,参数为要读取的File对象。 -- `FileWriter(String fileName)`: 创建一个新的 FileWriter,参数为要读取的文件的名称。 - -代码示例如下: - -```java -// 第一种:使用File对象创建流对象 -File file = new File("a.txt"); -FileWriter fw = new FileWriter(file); - -// 第二种:使用文件名称创建流对象 -FileWriter fw = new FileWriter("b.txt"); -``` - -#### 2)FileWriter写入数据 - -①、**写入字符**:`write(int b)` 方法,每次可以写出一个字符,代码示例如下: - -```java -FileWriter fw = null; -try { - fw = new FileWriter("output.txt"); - fw.write(72); // 写入字符'H'的ASCII码 - fw.write(101); // 写入字符'e'的ASCII码 - fw.write(108); // 写入字符'l'的ASCII码 - fw.write(108); // 写入字符'l'的ASCII码 - fw.write(111); // 写入字符'o'的ASCII码 -} catch (IOException e) { - e.printStackTrace(); -} finally { - try { - if (fw != null) { - fw.close(); - } - } catch (IOException e) { - e.printStackTrace(); - } -} -``` - -在这个示例代码中,首先创建一个 FileWriter 对象 fw,并指定要写入的文件路径 "output.txt"。然后使用 fw.write() 方法将字节写入文件中,这里分别写入字符'H'、'e'、'l'、'l'、'o'的 ASCII 码。最后在 finally 块中关闭 FileWriter 对象,释放资源。 - -需要注意的是,使用 `write(int b)` 方法写入的是一个字节,而不是一个字符。如果需要写入字符,可以使用 `write(char cbuf[])` 或 `write(String str)` 方法。 - -②、**写入字符数组**:`write(char[] cbuf)` 方法,将指定字符数组写入输出流。代码示例如下: - -```java -FileWriter fw = null; -try { - fw = new FileWriter("output.txt"); - char[] chars = {'H', 'e', 'l', 'l', 'o'}; - fw.write(chars); // 将字符数组写入文件 -} catch (IOException e) { - e.printStackTrace(); -} finally { - try { - if (fw != null) { - fw.close(); - } - } catch (IOException e) { - e.printStackTrace(); - } -} -``` - -③、**写入指定字符数组**:`write(char[] cbuf, int off, int len)` 方法,将指定字符数组的一部分写入输出流。代码示例如下(重复的部分就不写了哈,参照上面的部分): - -```java -fw = new FileWriter("output.txt"); - char[] chars = {'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!'}; -fw.write(chars, 0, 5); // 将字符数组的前 5 个字符写入文件 -``` - -使用 `fw.write()` 方法将字符数组的前 5 个字符写入文件中。 - -④、**写入字符串**:`write(String str)` 方法,将指定字符串写入输出流。代码示例如下: - -```java -fw = new FileWriter("output.txt"); -String str = "沉默王二"; -fw.write(str); // 将字符串写入文件 -``` - -⑤、**写入指定字符串**:`write(String str, int off, int len)` 方法,将指定字符串的一部分写入输出流。代码示例如下(try-with-resources形式): - -```java -String str = "沉默王二真的帅啊!"; -try (FileWriter fw = new FileWriter("output.txt")) { - fw.write(str, 0, 5); // 将字符串的前 5 个字符写入文件 -} catch (IOException e) { - e.printStackTrace(); -} -``` - ->【注意】如果不关闭资源,数据只是保存到缓冲区,并未保存到文件中。 - -#### 3)关闭close和刷新flush - -因为 FileWriter 内置了缓冲区 ByteBuffer,所以如果不关闭输出流,就无法把字符写入到文件中。 - -![](https://cdn.tobebetterjavaer.com/stutymore/reader-writer-20230320183546.png) - -但是关闭了流对象,就无法继续写数据了。如果我们既想写入数据,又想继续使用流,就需要 `flush` 方法了。 - -`flush` :刷新缓冲区,流对象可以继续使用。 - -`close` :先刷新缓冲区,然后通知系统释放资源。流对象不可以再被使用了。 - -flush还是比较有趣的,来段代码体会体会: - -```java -//源 也就是输入流【读取流】 读取a.txt文件 -FileReader fr=new FileReader("abc.txt"); //必须要存在a.txt文件,否则报FileNotFoundException异常 -//目的地 也就是输出流 -FileWriter fw=new FileWriter("b.txt"); //系统会自动创建b.txt,因为它是输出流! -int len; -while((len=fr.read())!=-1){ - fw.write(len); -} -//注意这里是没有使用close关闭流,开发中不能这样做,但是为了更好的体会flush的作用 -``` - -运行效果是怎么样的呢?答案是b.txt文件中依旧是空的,并没有任何东西。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/io/reader-writer-3b4fd024-856f-45ee-8183-1a1ee808e5ce.png) - -原因我们前面已经说过了。**编程就是这样,不去敲,永远学不会**!!!所以一定要去敲,多敲啊!!! - -在以上的代码中再添加下面三句代码,b.txt文件就能复制到源文件的数据了! - -```java -fr.close(); -fw.flush(); -fw.close(); -``` - -`flush()`这个方法是清空缓存的意思,用于清空缓冲区的数据流,进行流的操作时,数据先被读到内存中,然后再把数据写到文件中。 - -你可以使用下面的代码示例再体验一下: - -```java -// 使用文件名称创建流对象 -FileWriter fw = new FileWriter("fw.txt"); -// 写出数据,通过flush -fw.write('刷'); // 写出第1个字符 -fw.flush(); -fw.write('新'); // 继续写出第2个字符,写出成功 -fw.flush(); - -// 写出数据,然后close -fw.write('关'); // 写出第1个字符 -fw.close(); -fw.write('闭'); // 继续写出第2个字符,【报错】java.io.IOException: Stream closed -fw.close(); -``` - -注意,即便是flush方法写出了数据,操作的最后还是要调用close方法,释放系统资源。当然你也可以用 try-with-resources 的方式。 - -#### 4)FileWriter的续写和换行 - -**续写和换行**:操作类似于[FileOutputStream操作](https://tobebetterjavaer.com/io/stream.html),直接上代码: - -```java -// 使用文件名称创建流对象,可以续写数据 -FileWriter fw = new FileWriter("fw.txt",true); -// 写出字符串 -fw.write("沉默王二"); -// 写出换行 -fw.write("\r\n"); -// 写出字符串 -fw.write("是傻 X"); -// 关闭资源 -fw.close(); -``` - -输出结果如下所示: - -``` -输出结果: -沉默王二 -是傻 X -``` - - -#### 5)文本文件复制 - -直接上代码: - -```java -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; - -public class CopyFile { - public static void main(String[] args) throws IOException { - //创建输入流对象 - FileReader fr=new FileReader("aa.txt");//文件不存在会抛出java.io.FileNotFoundException - //创建输出流对象 - FileWriter fw=new FileWriter("copyaa.txt"); - /*创建输出流做的工作: - * 1、调用系统资源创建了一个文件 - * 2、创建输出流对象 - * 3、把输出流对象指向文件 - * */ - //文本文件复制,一次读一个字符 - copyMethod1(fr, fw); - //文本文件复制,一次读一个字符数组 - copyMethod2(fr, fw); - - fr.close(); - fw.close(); - } - - public static void copyMethod1(FileReader fr, FileWriter fw) throws IOException { - int ch; - while((ch=fr.read())!=-1) {//读数据 - fw.write(ch);//写数据 - } - fw.flush(); - } - - public static void copyMethod2(FileReader fr, FileWriter fw) throws IOException { - char chs[]=new char[1024]; - int len=0; - while((len=fr.read(chs))!=-1) {//读数据 - fw.write(chs,0,len);//写数据 - } - fw.flush(); - } -} -``` - -### 03、IO异常的处理 - -我们在学习的过程中可能习惯把异常抛出,而实际开发中建议使用`try...catch...finally` 代码块,处理异常部分,格式代码如下: - -```java -// 声明变量 -FileWriter fw = null; -try { - //创建流对象 - fw = new FileWriter("fw.txt"); - // 写出数据 - fw.write("二哥真的帅"); //哥敢摸si -} catch (IOException e) { - e.printStackTrace(); -} finally { - try { - if (fw != null) { - fw.close(); - } - } catch (IOException e) { - e.printStackTrace(); - } -} -``` - -或者直接使用 try-with-resources 的方式。 - -```java -try (FileWriter fw = new FileWriter("fw.txt")) { - // 写出数据 - fw.write("二哥真的帅"); //哥敢摸si -} catch (IOException e) { - e.printStackTrace(); -} -``` - -在这个代码中,try-with-resources 会在 try 块执行完毕后自动关闭 FileWriter 对象 fw,不需要手动关闭流。如果在 try 块中发生了异常,也会自动关闭流并抛出异常。因此,使用 try-with-resources 可以让代码更加简洁、安全和易读。 - -### 04、小结 - -Writer 和 Reader 是 Java I/O 中用于字符输入输出的抽象类,它们提供了一系列方法用于读取和写入字符数据。它们的区别在于 Writer 用于将字符数据写入到输出流中,而 Reader 用于从输入流中读取字符数据。 - -Writer 和 Reader 的常用子类有 FileWriter、FileReader,可以将字符流写入和读取到文件中。 - -在使用 Writer 和 Reader 进行字符输入输出时,需要注意字符编码的问题。 - - - -## 7.5 缓冲流 - -Java 的缓冲流是对字节流和字符流的一种封装,通过在内存中开辟缓冲区来提高 I/O 操作的效率。Java 通过 BufferedInputStream 和 BufferedOutputStream 来实现字节流的缓冲,通过 BufferedReader 和 BufferedWriter 来实现字符流的缓冲。 - -缓冲流的工作原理是将数据先写入缓冲区中,当缓冲区满时再一次性写入文件或输出流,或者当缓冲区为空时一次性从文件或输入流中读取一定量的数据。这样可以减少系统的 I/O 操作次数,提高系统的 I/O 效率,从而提高程序的运行效率。 - -### 01、字节缓冲流 - -BufferedInputStream 和 BufferedOutputStream 属于字节缓冲流,强化了字节流 InputStream 和 OutputStream,关于字节流,我们前面已经详细地讲过了,可以[戳这个链接](https://tobebetterjavaer.com/io/stream.html)去温习。 - -#### 1)构造方法 - -* `BufferedInputStream(InputStream in)` :创建一个新的缓冲输入流,注意参数类型为**InputStream**。 -* `BufferedOutputStream(OutputStream out)`: 创建一个新的缓冲输出流,注意参数类型为**OutputStream**。 - -代码示例如下: - -```java -// 创建字节缓冲输入流,先声明字节流 -FileInputStream fps = new FileInputStream(b.txt); -BufferedInputStream bis = new BufferedInputStream(fps) - -// 创建字节缓冲输入流(一步到位) -BufferedInputStream bis = new BufferedInputStream(new FileInputStream("b.txt")); - -// 创建字节缓冲输出流(一步到位) -BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("b.txt")); -``` - -#### 2)缓冲流的高效 - -我们通过复制一个 370M+ 的大文件,来测试缓冲流的效率。为了做对比,我们先用基本流来实现一下,代码如下: - -```java -// 记录开始时间 -long start = System.currentTimeMillis(); -// 创建流对象 -try (FileInputStream fis = new FileInputStream("py.mp4");//exe文件够大 - FileOutputStream fos = new FileOutputStream("copyPy.mp4")){ - // 读写数据 - int b; - while ((b = fis.read()) != -1) { - fos.write(b); - } -} -// 记录结束时间 -long end = System.currentTimeMillis(); -System.out.println("普通流复制时间:"+(end - start)+" 毫秒"); -``` - -不好意思,我本机比较菜,10 分钟还在复制中。切换到缓冲流试一下,代码如下: - -```java -// 记录开始时间 -long start = System.currentTimeMillis(); -// 创建流对象 -try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("py.mp4")); - BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("copyPy.mp4"));){ - // 读写数据 - int b; - while ((b = bis.read()) != -1) { - bos.write(b); - } -} -// 记录结束时间 -long end = System.currentTimeMillis(); -System.out.println("缓冲流复制时间:"+(end - start)+" 毫秒"); -``` - -只需要 8016 毫秒,如何更快呢? - -可以换数组的方式来读写,这个我们前面也有讲到,代码如下: - -```java -// 记录开始时间 -long start = System.currentTimeMillis(); -// 创建流对象 -try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("py.mp4")); - BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("copyPy.mp4"));){ - // 读写数据 - int len; - byte[] bytes = new byte[8*1024]; - while ((len = bis.read(bytes)) != -1) { - bos.write(bytes, 0 , len); - } -} -// 记录结束时间 -long end = System.currentTimeMillis(); -System.out.println("缓冲流使用数组复制时间:"+(end - start)+" 毫秒"); -``` - -这下就更快了,只需要 521 毫秒。 - -#### 3)为什么字节缓冲流会这么快? - - -传统的 Java IO 是阻塞模式的,它的工作状态就是“读/写,等待,读/写,等待。。。。。。” - -字节缓冲流解决的就是这个问题:**一次多读点多写点,减少读写的频率,用空间换时间**。 - -- 减少系统调用次数:在使用字节缓冲流时,数据不是立即写入磁盘或输出流,而是先写入缓冲区,当缓冲区满时再一次性写入磁盘或输出流。这样可以减少系统调用的次数,从而提高 I/O 操作的效率。 -- 减少磁盘读写次数:在使用字节缓冲流时,当需要读取数据时,缓冲流会先从缓冲区中读取数据,如果缓冲区中没有足够的数据,则会一次性从磁盘或输入流中读取一定量的数据。同样地,当需要写入数据时,缓冲流会先将数据写入缓冲区,如果缓冲区满了,则会一次性将缓冲区中的数据写入磁盘或输出流。这样可以减少磁盘读写的次数,从而提高 I/O 操作的效率。 -- 提高数据传输效率:在使用字节缓冲流时,由于数据是以块的形式进行传输,因此可以减少数据传输的次数,从而提高数据传输的效率。 - -我们来看 BufferedInputStream 的 read 方法: - -```java -public synchronized int read() throws IOException { - if (pos >= count) { // 如果当前位置已经到达缓冲区末尾 - fill(); // 填充缓冲区 - if (pos >= count) // 如果填充后仍然到达缓冲区末尾,说明已经读取完毕 - return -1; // 返回 -1 表示已经读取完毕 - } - return getBufIfOpen()[pos++] & 0xff; // 返回当前位置的字节,并将位置加 1 -} -``` - -这段代码主要有两部分: - -- `fill()`:该方法会将缓冲 buf 填满。 -- `getBufIfOpen()[pos++] & 0xff`:返回当前读取位置 pos 处的字节(`getBufIfOpen()`返回的是 buffer 数组,是 byte 类型),并将其与 0xff 进行位与运算。这里的目的是将读取到的字节 b 当做无符号的字节处理,因为 Java 的 byte 类型是有符号的,而将 b 与 0xff 进行位与运算,就可以将其转换为无符号的字节,其范围为 0 到 255。 - ->byte & 0xFF 我们一会再细讲。 - -再来看 FileInputStream 的 read 方法: - -![](https://cdn.tobebetterjavaer.com/stutymore/buffer-20230321154534.png) - -在这段代码中,`read0()` 方法是一个[本地方法](https://tobebetterjavaer.com/oo/native-method.html),它的实现是由底层操作系统提供的,并不是 Java 语言实现的。在不同的操作系统上,`read0()` 方法的实现可能会有所不同,但是它们的功能都是相同的,都是用于**读取一个字节**。 - -再来看一下 BufferedOutputStream 的 `write(byte b[], int off, int len)` 方法: - -```java -public synchronized void write(byte b[], int off, int len) throws IOException { - if (len >= buf.length) { // 如果写入的字节数大于等于缓冲区长度 - /* 如果请求的长度超过了输出缓冲区的大小, - 先刷新缓冲区,然后直接将数据写入。 - 这样可以避免缓冲流级联时的问题。*/ - flushBuffer(); // 先刷新缓冲区 - out.write(b, off, len); // 直接将数据写入输出流 - return; - } - if (len > buf.length - count) { // 如果写入的字节数大于空余空间 - flushBuffer(); // 先刷新缓冲区 - } - System.arraycopy(b, off, buf, count, len); // 将数据拷贝到缓冲区中 - count += len; // 更新计数器 -} -``` - -首先,该方法会检查写入的字节数是否大于等于缓冲区长度,如果是,则先将缓冲区中的数据刷新到磁盘中,然后直接将数据写入输出流。这样做是为了避免缓冲流级联时的问题,即缓冲区的大小不足以容纳写入的数据时,可能会引发级联刷新,导致效率降低。 - ->级联问题(Cascade Problem)是指在一组缓冲流(Buffered Stream)中,由于缓冲区的大小不足以容纳要写入的数据,导致数据被分割成多个部分,并分别写入到不同的缓冲区中,最终需要逐个刷新缓冲区,从而导致性能下降的问题。 - -其次,如果写入的字节数小于缓冲区长度,则检查缓冲区中剩余的空间是否足够容纳要写入的字节数,如果不够,则先将缓冲区中的数据刷新到磁盘中。然后,使用 `System.arraycopy()` 方法将要写入的数据拷贝到缓冲区中,并更新计数器 count。 - -最后,如果写入的字节数小于缓冲区长度且缓冲区中还有剩余空间,则直接将要写入的数据拷贝到缓冲区中,并更新计数器 count。 - -也就是说,只有当 buf 写满了,才会 flush,将数据刷到磁盘,默认一次刷 8192 个字节。 - -```java -public BufferedOutputStream(OutputStream out) { - this(out, 8192); -} -``` - -如果 buf 没有写满,会继续写 buf。 - -对比一下 FileOutputStream 的 write 方法,同样是本地方法,一次只能写入一个字节。 - -![](https://cdn.tobebetterjavaer.com/stutymore/buffer-20230321162808.png) - -当把 BufferedOutputStream 和 BufferedInputStream 配合起来使用后,就减少了大量的读写次数,尤其是 `byte[] bytes = new byte[8*1024]`,就相当于缓冲区的空间有 8 个 1024 字节,那读写效率就会大大提高。 - -#### 4)`byte & 0xFF` - -byte 类型通常被用于存储二进制数据,例如读取和写入文件、网络传输等场景。在这些场景下,byte 类型的变量可以用来存储数据流中的每个字节,从而进行读取和写入操作。 - -byte 类型是有符号的,即其取值范围为 -128 到 127。如果我们希望得到的是一个无符号的 byte 值,就需要使用 `byte & 0xFF` 来进行转换。 - -这是因为 0xFF 是一个无符号的整数,它的二进制表示为 11111111。当一个 byte 类型的值与 0xFF 进行位与运算时,会将 byte 类型的值转换为一个无符号的整数,其范围为 0 到 255。 - -0xff 是一个十六进制的数,相当于二进制的 11111111,& 运算符的意思是:如果两个操作数的对应位为 1,则输出 1,否则为 0;由于 0xff 有 8 个 1,单个 byte 转成 int 其实就是将 byte 和 int 类型的 255 进行(&)与运算。 - -例如,如果我们有一个 byte 类型的变量 b,其值为 -1,那么 b & 0xFF 的结果就是 255。这样就可以将一个有符号的 byte 类型的值转换为一个无符号的整数。 - -& 运算是一种二进制数据的计算方式, 两个操作位都为1,结果才为1,否则结果为0. 在上面的 `getBufIfOpen()[pos++] & 0xff` 计算过程中, byte 有 8bit, OXFF 是16进制的255, 表示的是 int 类型, int 有 32bit. - -如果 `getBufIfOpen()[pos++]` 为 -118, 那么其原码表示为 - -``` -00000000 00000000 00000000 10001010 -``` - -反码为 - -``` -11111111 11111111 11111111 11110101 -``` - -补码为 - -``` -11111111 11111111 11111111 11110110 -``` - -0XFF 表示16进制的数据255, 原码, 反码, 补码都是一样的, 其二进制数据为 - -``` -00000000 00000000 00000000 11111111 -``` - -0XFF 和 -118 进行&运算后结果为 - -``` -00000000 00000000 00000000 11110110 -``` - -还原为原码后为 - -``` -00000000 00000000 00000000 10001010 -``` - -其表示的 int 值为 138,可见将 byte 类型的 -118 与 0XFF 进行与运算后值由 -118 变成了 int 类型的 138,其中低8位和byte的-118完全一致。 - -顺带聊一下 原码、反码和补码。 - -①、原码 - -原码就是符号位加上真值的绝对值,即用第一位表示符号,其余位表示值。比如如果是8位二进制: - -``` -[+1]原 = 0000 0001 - -[-1]原 = 1000 0001 -``` - -第一位是符号位。因为第一位是符号位,所以8位二进制数的取值范围就是: - -``` -[1111 1111 , 0111 1111] -``` - -即 - -``` -[-127 , 127] -``` - -②、反码 - -反码的表示方法是: - -- 正数的反码是其本身 -- 负数的反码是在其原码的基础上,符号位不变,其余各个位取反。 - - -例如: - -``` -[+1] = [00000001]原 = [00000001]反 - -[-1] = [10000001]原 = [11111110]反 -``` - -可见如果一个反码表示的是负数,人脑无法直观的看出来它的数值。通常要将其转换成原码再计算。 - -③、补码 - -补码的表示方法是: - -- 正数的补码就是其本身 -- 负数的补码是在其原码的基础上,符号位不变,其余各位取反,最后+1。(即在反码的基础上+1) - -``` -[+1] = [00000001]原 = [00000001]反 = [00000001]补 - -[-1] = [10000001]原 = [11111110]反 = [11111111]补 -``` - -对于负数,补码表示方式也是人脑无法直观看出其数值的。通常也需要转换成原码在计算其数值。 - -从上面可以看到: - -- 对于正数:原码,反码,补码都是一样的 -- 对于负数:原码,反码,补码都是不一样的 - -### 02、字符缓冲流 - -BufferedReader 类继承自 Reader 类,提供了一些便捷的方法,例如 `readLine()` 方法可以一次读取一行数据,而不是一个字符一个字符地读取。 - -BufferedWriter 类继承自 Writer 类,提供了一些便捷的方法,例如 `newLine()` 方法可以写入一个系统特定的行分隔符。 - -#### 1)构造方法 - -* `BufferedReader(Reader in)` :创建一个新的缓冲输入流,注意参数类型为**Reader**。 -* `BufferedWriter(Writer out)`: 创建一个新的缓冲输出流,注意参数类型为**Writer**。 - -代码示例如下: - -```java -// 创建字符缓冲输入流 -BufferedReader br = new BufferedReader(new FileReader("b.txt")); -// 创建字符缓冲输出流 -BufferedWriter bw = new BufferedWriter(new FileWriter("b.txt")); -``` - -#### 2)字符缓冲流特有方法 - -字符缓冲流的基本方法与[普通字符流](https://tobebetterjavaer.com/io/reader-writer.html)调用方式一致,这里不再赘述,我们来看字符缓冲流**特有**的方法。 - -* BufferedReader:`String readLine()`: **读一行数据**,读取到最后返回 null -* BufferedWriter:`newLine()`: **换行**,由系统定义换行符。 - -来看 `readLine()`方法的代码示例: - -```java -// 创建流对象 -BufferedReader br = new BufferedReader(new FileReader("a.txt")); -// 定义字符串,保存读取的一行文字 -String line = null; -// 循环读取,读取到最后返回null -while ((line = br.readLine())!=null) { - System.out.print(line); - System.out.println("--"); -} -// 释放资源 -br.close(); -``` - -再来看 `newLine()` 方法的代码示例: - -```java -// 创建流对象 -BfferedWriter bw = new BufferedWriter(new FileWriter("b.txt")); -// 写出数据 -bw.write("沉"); -// 写出换行 -bw.newLine(); -bw.write("默"); -bw.newLine(); -bw.write("王"); -bw.newLine(); -bw.write("二"); -bw.newLine(); -// 释放资源 -bw.close(); -``` - -### 03、字符缓冲流练习 - -来欣赏一下我写的这篇诗: - -> 6.岑夫子,丹丘生,将进酒,杯莫停。 -> -> 1.君不见黄河之水天上来,奔流到海不复回。 -> -> 8.钟鼓馔玉不足贵,但愿长醉不愿醒。 -> -> 3.人生得意须尽欢,莫使金樽空对月。 -> -> 5.烹羊宰牛且为乐,会须一饮三百杯。 -> -> 2.君不见高堂明镜悲白发,朝如青丝暮成雪。 -> -> 7.与君歌一曲,请君为我倾耳听。 -> -> 4.天生我材必有用,千金散尽还复来。 - -欣赏完了没? - -估计你也看出来了,这是李白写的《将进酒》,不是我王二写的。😝 - -不过,顺序是乱的,还好,我都编了号。那如何才能按照正确的顺序来呢? - -来看代码实现: - -```java -// 创建map集合,保存文本数据,键为序号,值为文字 -HashMap lineMap = new HashMap<>(); - -// 创建流对象 源 -BufferedReader br = new BufferedReader(new FileReader("logs/test.log")); -//目标 -BufferedWriter bw = new BufferedWriter(new FileWriter("logs/test1.txt")); - -// 读取数据 -String line; -while ((line = br.readLine())!=null) { - // 解析文本 - if (line.isEmpty()) { - continue; - } - String[] split = line.split(Pattern.quote(".")); - // 保存到集合 - lineMap.put(split[0], split[1]); -} -// 释放资源 -br.close(); - -// 遍历map集合 -for (int i = 1; i <= lineMap.size(); i++) { - String key = String.valueOf(i); - // 获取map中文本 - String value = lineMap.get(key); - // 写出拼接文本 - bw.write(key+"."+value); - // 写出换行 - bw.newLine(); -} -// 释放资源 -bw.close(); -``` - -这里面用到的知识都是我们前面学过的,比如说 [HashMap](https://tobebetterjavaer.com/collection/hashmap.html),[字符串分割](https://tobebetterjavaer.com/string/split.html),包括刚刚学习的字符缓冲流。 - - -来看输出结果 - -``` -1.君不见黄河之水天上来,奔流到海不复回。 -2.君不见高堂明镜悲白发,朝如青丝暮成雪。 -3.人生得意须尽欢,莫使金樽空对月。 -4.天生我材必有用,千金散尽还复来。 -5.烹羊宰牛且为乐,会须一饮三百杯。 -6.岑夫子,丹丘生,将进酒,杯莫停。 -7.与君歌一曲,请君为我倾耳听。 -8.钟鼓馔玉不足贵,但愿长醉不愿醒。 -``` - - - -## 7.6 转换流 - -转换流可以将一个[字节流](https://tobebetterjavaer.com/io/stream.html)包装成[字符流](https://tobebetterjavaer.com/io/reader-writer.html),或者将一个字符流包装成字节流。这种转换通常用于处理文本数据,如读取文本文件或将数据从网络传输到应用程序。 - -转换流主要有两种类型:InputStreamReader 和 OutputStreamWriter。 - -InputStreamReader 将一个字节输入流转换为一个字符输入流,而 OutputStreamWriter 将一个字节输出流转换为一个字符输出流。它们使用指定的字符集将字节流和字符流之间进行转换。常用的字符集包括 UTF-8、GBK、ISO-8859-1 等。 - -![](https://cdn.tobebetterjavaer.com/studymore/char-byte-20230322165959.png) - -### 01、编码和解码 - -在计算机中,数据通常以二进制形式存储和传输。 - -- 编码就是将原始数据(比如说文本、图像、视频、音频等)转换为二进制形式。 -- 解码就是将二进制数据转换为原始数据,是一个反向的过程。 - -常见的编码和解码方式有很多,举几个例子: - -- ASCII 编码和解码:在计算机中,常常使用 ASCII 码来表示字符,如键盘上的字母、数字和符号等。例如,字母 A 对应的 ASCII 码是 65,字符 + 对应的 ASCII 码是 43。 -- Unicode 编码和解码:Unicode 是一种字符集,支持多种语言和字符集。在计算机中,Unicode 可以使用 UTF-8、UTF-16 等编码方式将字符转换为二进制数据进行存储和传输。 -- Base64 编码和解码:Base64 是一种将二进制数据转换为 ASCII 码的编码方式。它将 3 个字节的二进制数据转换为 4 个 ASCII 字符,以便在网络传输中使用。例如,将字符串 "Hello, world!" 进行 Base64 编码后,得到的结果是 "SGVsbG8sIHdvcmxkIQ=="。 -- 图像编码和解码:在图像处理中,常常使用 JPEG、PNG、GIF 等编码方式将图像转换为二进制数据进行存储和传输。在解码时,可以将二进制数据转换为图像,以便显示或处理。 -- 视频编码和解码:在视频处理中,常常使用 H.264、AVC、MPEG-4 等编码方式将视频转换为二进制数据进行存储和传输。在解码时,可以将二进制数据转换为视频,以便播放或处理。 - -简单一点说就是: - -- 编码:字符(能看懂的)-->字节(看不懂的) -- 解码:字节(看不懂的)-->字符(能看懂的) - -我用代码来表示一下: - -```java -String str = "沉默王二"; -String charsetName = "UTF-8"; - -// 编码 -byte[] bytes = str.getBytes(Charset.forName(charsetName)); -System.out.println("编码: " + bytes); - -// 解码 -String decodedStr = new String(bytes, Charset.forName(charsetName)); -System.out.println("解码: " + decodedStr); -``` - -在这个示例中,首先定义了一个字符串变量 str 和一个字符集名称 charsetName。然后,使用 `Charset.forName()` 方法获取指定字符集的 Charset 对象。接着,使用字符串的 getBytes() 方法将字符串编码为指定字符集的字节数组。最后,使用 `new String()` 方法将字节数组解码为字符串。 - -需要注意的是,在编码和解码过程中,要保证使用相同的字符集,以便正确地转换数据。 - -### 02、字符集 - -Charset:字符集,是一组字符的集合,每个字符都有一个唯一的编码值,也称为码点。 - -常见的字符集包括 ASCII、Unicode 和 GBK,而 Unicode 字符集包含了多种编码方式,比如说 UTF-8、UTF-16。 - -![](https://cdn.tobebetterjavaer.com/studymore/char-byte-20230322174312.png) - -#### **ASCII 字符集** - -ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)字符集是一种最早的字符集,包含 128 个字符,其中包括控制字符、数字、英文字母以及一些标点符号。ASCII 字符集中的每个字符都有一个唯一的 7 位二进制编码(由 0 和 1 组成),可以表示为十进制数或十六进制数。 - -ASCII 编码方式是一种固定长度的编码方式,每个字符都使用 7 位二进制编码来表示。ASCII 编码只能表示英文字母、数字和少量的符号,不能表示其他语言的文字和符号,因此在全球范围内的应用受到了很大的限制。 - -#### Unicode 字符集 - -Unicode 包含了世界上几乎所有的字符,用于表示人类语言、符号和表情等各种信息。Unicode 字符集中的每个字符都有一个唯一的码点(code point),用于表示该字符在字符集中的位置,可以用十六进制数表示。 - -为了在计算机中存储和传输 Unicode 字符集中的字符,需要使用一种编码方式。UTF-8、UTF-16 和 UTF-32 都是 Unicode 字符集的编码方式,用于将 Unicode 字符集中的字符转换成字节序列,以便于存储和传输。它们的差别在于使用的字节长度不同。 - -- UTF-8 是一种可变长度的编码方式,对于 ASCII 字符(码点范围为 `0x00~0x7F`),使用一个字节表示,对于其他 Unicode 字符,使用两个、三个或四个字节表示。UTF-8 编码方式被广泛应用于互联网和计算机领域,因为它可以有效地压缩数据,适用于网络传输和存储。 -- UTF-16 是一种固定长度的编码方式,对于基本多语言平面(Basic Multilingual Plane,Unicode 字符集中的一个码位范围,包含了世界上大部分常用的字符,总共包含了超过 65,000 个码位)中的字符(码点范围为 `0x0000~0xFFFF`),使用两个字节表示,对于其他 Unicode 字符,使用四个字节表示。 -- UTF-32 是一种固定长度的编码方式,对于所有 Unicode 字符,使用四个字节表示。 - -#### GBK 字符集 - -GBK 包含了 GB2312 字符集中的字符,同时还扩展了许多其他汉字字符和符号,共收录了 21,913 个字符。GBK 采用双字节编码方式,每个汉字占用 2 个字节,其中高字节和低字节都使用了 8 位,因此 GBK 编码共有 `2^16=65536` 种可能的编码,其中大部分被用于表示汉字字符。 - -GBK 编码是一种变长的编码方式,对于 ASCII 字符(码位范围为 0x00 到 0x7F),使用一个字节表示,对于其他字符,使用两个字节表示。GBK 编码中的每个字节都可以采用 0x81 到 0xFE 之间的任意一个值,因此可以表示 `2^15=32768` 个字符。为了避免与 ASCII 码冲突,GBK 编码的第一个字节采用了 0x81 到 0xFE 之间除了 0x7F 的所有值,第二个字节采用了 0x40 到 0x7E 和 0x80 到 0xFE 之间的所有值,共 94 个值。 - -GB2312 的全名是《信息交换用汉字编码字符集基本集》,也被称为“国标码”。采用了双字节编码方式,每个汉字占用 2 个字节,其中高字节和低字节都使用了 8 位,因此 GB2312 编码共有 `2^16=65536` 种可能的编码,其中大部分被用于表示汉字字符。GB2312 编码中的每个字节都可以采用 0xA1 到 0xF7 之间的任意一个值,因此可以表示 126 个字符。 - -GB2312 是一个较为简单的字符集,只包含了常用的汉字和符号,因此对于一些较为罕见的汉字和生僻字,GB2312 不能满足需求,现在已经逐渐被 GBK、GB18030 等字符集所取代。 - -GB18030 是最新的中文码表。收录汉字 70244 个,采用多字节编码,每个字可以由 1 个、2 个或 4 个字节组成。支持中国国内少数民族的文字,同时支持繁体汉字以及日韩汉字等。 - -### 03、乱码 - -当使用不同的编码方式读取或者写入文件时,就会出现乱码问题,来看示例。 - -```java -String s = "沉默王二!"; - -try { - // 将字符串按GBK编码方式保存到文件中 - OutputStreamWriter out = new OutputStreamWriter( - new FileOutputStream("logs/test_utf8.txt"), "GBK"); - out.write(s); - out.close(); - - FileReader fileReader = new FileReader("logs/test_utf8.txt"); - int read; - while ((read = fileReader.read()) != -1) { - System.out.print((char)read); - } - fileReader.close(); -} catch (IOException e) { - e.printStackTrace(); -} -``` - -在上面的示例代码中,首先定义了一个包含中文字符的字符串,然后将该字符串按 GBK 编码方式保存到文件中,接着将文件按默认编码方式(UTF-8)读取,并显示内容。此时就会出现乱码问题,显示为“��Ĭ������”。 - -这是因为文件中的 GBK 编码的字符在使用 UTF-8 编码方式解析时无法正确解析,从而导致出现乱码问题。 - -那如何才能解决乱码问题呢? - -这就引出我们今天的主角了——转换流。 - -### 04、InputStreamReader - -`java.io.InputStreamReader` 是 Reader 类的子类。它的作用是将字节流(InputStream)转换为字符流(Reader),同时支持指定的字符集编码方式,从而实现字符流与字节流之间的转换。 - -#### 1)构造方法 - -- `InputStreamReader(InputStream in)`: 创建一个使用默认字符集的字符流。 -- `InputStreamReader(InputStream in, String charsetName)`: 创建一个指定字符集的字符流。 - -代码示例如下: - -```java -InputStreamReader isr = new InputStreamReader(new FileInputStream("in.txt")); -InputStreamReader isr2 = new InputStreamReader(new FileInputStream("in.txt") , "GBK"); -``` - -#### 2)解决编码问题 - -下面是一个使用 InputStreamReader 解决乱码问题的示例代码: - -```java -String s = "沉默王二!"; - -try { - // 将字符串按GBK编码方式保存到文件中 - OutputStreamWriter outUtf8 = new OutputStreamWriter( - new FileOutputStream("logs/test_utf8.txt"), "GBK"); - outUtf8.write(s); - outUtf8.close(); - - // 将字节流转换为字符流,使用GBK编码方式 - InputStreamReader isr = new InputStreamReader(new FileInputStream("logs/test_utf8.txt"), "GBK"); - // 读取字符流 - int c; - while ((c = isr.read()) != -1) { - System.out.print((char) c); - } - isr.close(); -} catch (IOException e) { - e.printStackTrace(); -} -``` - -由于使用了 InputStreamReader 对字节流进行了编码方式的转换,因此在读取字符流时就可以正确地解析出中文字符,避免了乱码问题。 - -### 05、OutputStreamWriter - -`java.io.OutputStreamWriter` 是 Writer 的子类,字面看容易误以为是转为字符流,其实是将字符流转换为字节流,是字符流到字节流的桥梁。 - -- `OutputStreamWriter(OutputStream in)`: 创建一个使用默认字符集的字符流。 -- `OutputStreamWriter(OutputStream in, String charsetName)`:创建一个指定字符集的字符流。 - -代码示例如下: - -```java -OutputStreamWriter isr = new OutputStreamWriter(new FileOutputStream("a.txt")); -OutputStreamWriter isr2 = new OutputStreamWriter(new FileOutputStream("b.txt") , "GBK"); -``` - -通常为了提高读写效率,我们会在转换流上再加一层[缓冲流](https://tobebetterjavaer.com/io/buffer.html),来看代码示例: - -```java -try { - // 从文件读取字节流,使用UTF-8编码方式 - FileInputStream fis = new FileInputStream("test.txt"); - // 将字节流转换为字符流,使用UTF-8编码方式 - InputStreamReader isr = new InputStreamReader(fis, "UTF-8"); - // 使用缓冲流包装字符流,提高读取效率 - BufferedReader br = new BufferedReader(isr); - // 创建输出流,使用UTF-8编码方式 - FileOutputStream fos = new FileOutputStream("output.txt"); - // 将输出流包装为转换流,使用UTF-8编码方式 - OutputStreamWriter osw = new OutputStreamWriter(fos, "UTF-8"); - // 使用缓冲流包装转换流,提高写入效率 - BufferedWriter bw = new BufferedWriter(osw); - - // 读取输入文件的每一行,写入到输出文件中 - String line; - while ((line = br.readLine()) != null) { - bw.write(line); - bw.newLine(); // 每行结束后写入一个换行符 - } - - // 关闭流 - br.close(); - bw.close(); -} catch (IOException e) { - e.printStackTrace(); -} -``` - -在上面的示例代码中,首先使用 FileInputStream 从文件中读取字节流,使用 UTF-8 编码方式进行读取。然后,使用 InputStreamReader 将字节流转换为字符流,使用 UTF-8 编码方式进行转换。接着,使用 BufferedReader 包装字符流,提高读取效率。然后,创建 FileOutputStream 用于输出文件,使用 UTF-8 编码方式进行创建。接着,使用 OutputStreamWriter 将输出流转换为字符流,使用 UTF-8 编码方式进行转换。最后,使用 BufferedWriter 包装转换流,提高写入效率。 - -### 06、小结 - -InputStreamReader 和 OutputStreamWriter 是将字节流转换为字符流或者将字符流转换为字节流。通常用于解决字节流和字符流之间的转换问题,可以将字节流以指定的字符集编码方式转换为字符流,或者将字符流以指定的字符集编码方式转换为字节流。 - -InputStreamReader 类的常用方法包括: - -- `read()`:从输入流中读取一个字符的数据。 -- `read(char[] cbuf, int off, int len)`:从输入流中读取 len 个字符的数据到指定的字符数组 cbuf 中,从 off 位置开始存放。 -- `ready()`:返回此流是否已准备好读取。 -- `close()`:关闭输入流。 - -OutputStreamWriter 类的常用方法包括: - -- `write(int c)`:向输出流中写入一个字符的数据。 -- `write(char[] cbuf, int off, int len)`:向输出流中写入指定字符数组 cbuf 中的 len 个字符,从 off 位置开始。 -- `flush()`:将缓冲区的数据写入输出流中。 -- `close()`:关闭输出流。 - -在使用转换流时,需要指定正确的字符集编码方式,否则可能会导致数据读取或写入出现乱码。 - - -## 7.7 打印流 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[7.7 打印流](https://tobebetterjavaer.com/io/print.html) - - -## 7.8 序列流(序列化和反序列化) - -Java 的序列流(ObjectInputStream 和 ObjectOutputStream)是一种可以将 Java 对象序列化和反序列化的流。 - -序列化是指将一个对象转换为一个字节序列(包含`对象的数据`、`对象的类型`和`对象中存储的属性`等信息),以便在网络上传输或保存到文件中,或者在程序之间传递。在 Java 中,序列化通过实现 java.io.Serializable 接口来实现,只有实现了 [Serializable 接口](https://tobebetterjavaer.com/io/Serializbale.html)的对象才能被序列化。 - -反序列化是指将一个字节序列转换为一个对象,以便在程序中使用。 - -![](https://cdn.tobebetterjavaer.com/stutymore/serialize-20230323105551.png) - -### 01、ObjectOutputStream - -`java.io.ObjectOutputStream` 继承自 OutputStream 类,因此可以将序列化后的字节序列写入到文件、网络等输出流中。 - -来看 ObjectOutputStream 的构造方法: -`ObjectOutputStream(OutputStream out)` - -该构造方法接收一个 OutputStream 对象作为参数,用于将序列化后的字节序列输出到指定的输出流中。例如: - -```java -FileOutputStream fos = new FileOutputStream("file.txt"); -ObjectOutputStream oos = new ObjectOutputStream(fos); -``` - -一个对象要想序列化,必须满足两个条件: - -- 该类必须实现[`java.io.Serializable` 接口](https://tobebetterjavaer.com/io/Serializbale.html),否则会抛出`NotSerializableException` 。 -- 该类的所有字段都必须是可序列化的。如果一个字段不需要序列化,则需要使用[`transient` 关键字](https://tobebetterjavaer.com/io/transient.html)进行修饰。 - -使用示例如下: - -```java -public class Employee implements Serializable { - public String name; - public String address; - public transient int age; // transient瞬态修饰成员,不会被序列化 -} -``` - -接下来,来聊聊 `writeObject (Object obj)` 方法,该方法是 ObjectOutputStream 类中用于将对象序列化成字节序列并输出到输出流中的方法,可以处理对象之间的引用关系、继承关系、静态字段和 transient 字段。 - -```java -public class ObjectOutputStreamDemo { - public static void main(String[] args) { - Person person = new Person("沉默王二", 20); - try { - FileOutputStream fos = new FileOutputStream("logs/person.dat"); - ObjectOutputStream oos = new ObjectOutputStream(fos); - oos.writeObject(person); - oos.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } -} -class Person implements Serializable { - private String name; - private int age; - - public Person(String name, int age) { - this.name = name; - this.age = age; - } - - public String getName() { - return name; - } - - public int getAge() { - return age; - } -} -``` - -上面的代码中,首先创建了一个 Person 对象,然后使用 FileOutputStream 和 ObjectOutputStream 将 Person 对象序列化并输出到 person.dat 文件中。在 Person 类中,实现了 Serializable 接口,表示该类可以进行对象序列化。 - -### 02、ObjectInputStream - -ObjectInputStream 可以读取 ObjectOutputStream 写入的字节流,并将其反序列化为相应的对象(包含`对象的数据`、`对象的类型`和`对象中存储的属性`等信息)。 - -说简单点就是,序列化之前是什么样子,反序列化后就是什么样子。 - -来看一下构造方法:`ObjectInputStream(InputStream in)` : 创建一个指定 InputStream 的 ObjectInputStream。 - -其中,ObjectInputStream 的 readObject 方法用来读取指定文件中的对象,示例如下: - -```java -String filename = "logs/person.dat"; // 待反序列化的文件名 -try (FileInputStream fileIn = new FileInputStream(filename); - ObjectInputStream in = new ObjectInputStream(fileIn)) { - // 从指定的文件输入流中读取对象并反序列化 - Object obj = in.readObject(); - // 将反序列化后的对象强制转换为指定类型 - Person p = (Person) obj; - // 打印反序列化后的对象信息 - System.out.println("Deserialized Object: " + p); -} catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); -} -``` - -我们首先指定了待反序列化的文件名(前面通过 ObjectOutputStream 序列化后的文件),然后创建了一个 FileInputStream 对象和一个 ObjectInputStream 对象。接着我们调用 ObjectInputStream 的 readObject 方法来读取指定文件中的对象,并将其强制转换为 Person 类型。最后我们打印了反序列化后的对象信息。 - -### 03、Kryo - -实际开发中,很少使用 JDK 自带的序列化和反序列化,这是因为: - -- 可移植性差:Java 特有的,无法跨语言进行序列化和反序列化。 -- 性能差:序列化后的字节体积大,增加了传输/保存成本。 -- 安全问题:攻击者可以通过构造恶意数据来实现远程代码执行,从而对系统造成严重的安全威胁。相关阅读:[Java 反序列化漏洞之殇](https://cryin.github.io/blog/secure-development-java-deserialization-vulnerability/) 。 - -Kryo 是一个优秀的 Java 序列化和反序列化库,具有高性能、高效率和易于使用和扩展等特点,有效地解决了 JDK 自带的序列化机制的痛点。 - ->GitHub 地址:[https://github.com/EsotericSoftware/kryo](https://github.com/EsotericSoftware/kryo) - -使用示例: - -第一步,在 pom.xml 中引入依赖。 - -``` - - - com.esotericsoftware - kryo - 5.4.0 - -``` - -第二步,创建一个 Kryo 对象,并使用 `register()` 方法将对象进行注册。然后,使用 `writeObject()` 方法将 Java 对象序列化为二进制流,再使用 `readObject()` 方法将二进制流反序列化为 Java 对象。最后,输出反序列化后的 Java 对象。 - -```java -public class KryoDemo { - public static void main(String[] args) throws FileNotFoundException { - Kryo kryo = new Kryo(); - kryo.register(KryoParam.class); - - KryoParam object = new KryoParam("沉默王二", 123); - - Output output = new Output(new FileOutputStream("logs/kryo.bin")); - kryo.writeObject(output, object); - output.close(); - - Input input = new Input(new FileInputStream("logs/kryo.bin")); - KryoParam object2 = kryo.readObject(input, KryoParam.class); - System.out.println(object2); - input.close(); - } -} - -class KryoParam { - private String name; - private int age; - - public KryoParam() { - } - - public KryoParam(String name, int age) { - 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; - } - - @Override - public String toString() { - return "KryoParam{" + - "name='" + name + '\'' + - ", age=" + age + - '}'; - } -} -``` - -### 04、小结 - -本节我们介绍了 Java 的序列化机制,并推荐了一款高性能的 Java 类库 Kryo 来取代 JDK 自带的序列化机制,已经在 Twitter、Groupon、Yahoo 以及多个著名开源项目(如 Hive、Storm)中广泛使用。 - -以上,希望能帮助到大家。 - - - -## 7.9 序列接口Serializable - -对于 Java 的序列化,我之前一直停留在最浅层次的认知上——把那个要[序列化](https://tobebetterjavaer.com/io/serialize.html)的类实现 `Serializbale` 接口就可以了嘛。 - -我似乎不愿意做更深入的研究,因为会用就行了嘛。 - -但随着时间的推移,见到 `Serializbale` 的次数越来越多,我便对它产生了浓厚的兴趣。是时候花点时间研究研究了。 - -### 01、先来点理论 - -Java 序列化是 JDK 1.1 时引入的一组开创性的特性,用于将 Java 对象转换为字节数组,便于存储或传输。此后,仍然可以将字节数组转换回 Java 对象原有的状态。 - -序列化的思想是“冻结”对象状态,然后写到磁盘或者在网络中传输;[反序列化](https://tobebetterjavaer.com/io/serialize.html)的思想是“解冻”对象状态,重新获得可用的 Java 对象。 - -序列化有一条规则,就是要序列化的对象必须实现 `Serializbale` 接口,否则就会报 NotSerializableException 异常。 - -好,来看看 `Serializbale` 接口的定义吧: - -```java -public interface Serializable { -} -``` - -没别的了! - -明明就一个空的接口嘛,竟然能够保证实现了它的“类对象”被序列化和反序列化? - -### 02、再来点实战 - -在回答上述问题之前,我们先来创建一个类(只有两个字段,和对应的 `getter/setter`),用于序列化和反序列化。 - -```java -class Wanger { - private String name; - private int 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; - } -} -``` - -再来创建一个测试类,通过 `ObjectOutputStream` 将“18 岁的王二”写入到文件当中,实际上就是一种序列化的过程;再通过 `ObjectInputStream` 将“18 岁的王二”从文件中读出来,实际上就是一种反序列化的过程。(前面我们学习[序列流](https://tobebetterjavaer.com/io/serialize.html)的时候也讲过) - -```java -// 初始化 -Wanger wanger = new Wanger(); -wanger.setName("王二"); -wanger.setAge(18); -System.out.println(wanger); - -// 把对象写到文件中 -try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("chenmo"));){ - oos.writeObject(wanger); -} catch (IOException e) { - e.printStackTrace(); -} - -// 从文件中读出对象 -try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("chenmo")));){ - Wanger wanger1 = (Wanger) ois.readObject(); - System.out.println(wanger1); -} catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); -} -``` - -不过,由于 `Wanger` 没有实现 `Serializbale` 接口,所以在运行测试类的时候会抛出异常,堆栈信息如下: - -``` -java.io.NotSerializableException: com.cmower.java_demo.xuliehua.Wanger - at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1184) - at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:348) - at com.cmower.java_demo.xuliehua.Test.main(Test.java:21) -``` - -顺着堆栈信息,我们来看一下 `ObjectOutputStream` 的 `writeObject0()` 方法。其部分源码如下: - -```java -// 判断对象是否为字符串类型,如果是,则调用 writeString 方法进行序列化 -if (obj instanceof String) { - writeString((String) obj, unshared); -} -// 判断对象是否为数组类型,如果是,则调用 writeArray 方法进行序列化 -else if (cl.isArray()) { - writeArray(obj, desc, unshared); -} -// 判断对象是否为枚举类型,如果是,则调用 writeEnum 方法进行序列化 -else if (obj instanceof Enum) { - writeEnum((Enum) obj, desc, unshared); -} -// 判断对象是否为可序列化类型,如果是,则调用 writeOrdinaryObject 方法进行序列化 -else if (obj instanceof Serializable) { - writeOrdinaryObject(obj, desc, unshared); -} -// 如果对象不能被序列化,则抛出 NotSerializableException 异常 -else { -if (extendedDebugInfo) { - throw new NotSerializableException( - cl.getName() + "\n" + debugInfoStack.toString()); -} else { - throw new NotSerializableException(cl.getName()); -} -} -``` - -也就是说,`ObjectOutputStream` 在序列化的时候,会判断被序列化的对象是哪一种类型,字符串?数组?枚举?还是 `Serializable`,如果全都不是的话,抛出 `NotSerializableException`。 - -假如 `Wanger` 实现了 `Serializable` 接口,就可以序列化和反序列化了。 - -```java -class Wanger implements Serializable{ - private static final long serialVersionUID = -2095916884810199532L; - - private String name; - private int age; -} -``` - -具体怎么序列化呢? - -以 `ObjectOutputStream` 为例吧,它在序列化的时候会依次调用 `writeObject()`→`writeObject0()`→`writeOrdinaryObject()`→`writeSerialData()`→`invokeWriteObject()`→`defaultWriteFields()`。 - -```java -private void defaultWriteFields(Object obj, ObjectStreamClass desc) throws IOException { - // 获取对象的类,并检查是否可以进行默认的序列化 - Class cl = desc.forClass(); - desc.checkDefaultSerialize(); - - // 获取对象的基本类型字段的数量,以及这些字段的值 - int primDataSize = desc.getPrimDataSize(); - desc.getPrimFieldValues(obj, primVals); - // 将基本类型字段的值写入输出流 - bout.write(primVals, 0, primDataSize, false); - - // 获取对象的非基本类型字段的值 - ObjectStreamField[] fields = desc.getFields(false); - Object[] objVals = new Object[desc.getNumObjFields()]; - int numPrimFields = fields.length - objVals.length; - desc.getObjFieldValues(obj, objVals); - // 循环写入对象的非基本类型字段的值 - for (int i = 0; i < objVals.length; i++) { - // 调用 writeObject0 方法将对象的非基本类型字段序列化写入输出流 - try { - writeObject0(objVals[i], fields[numPrimFields + i].isUnshared()); - } - // 如果在写入过程中出现异常,则将异常包装成 IOException 抛出 - catch (IOException ex) { - if (abortIOException == null) { - abortIOException = ex; - } - } - } -} -``` - -那怎么反序列化呢? - -以 `ObjectInputStream` 为例,它在反序列化的时候会依次调用 `readObject()`→`readObject0()`→`readOrdinaryObject()`→`readSerialData()`→`defaultReadFields()`。 - -```java -private void defaultReadFields(Object obj, ObjectStreamClass desc) throws IOException { - // 获取对象的类,并检查对象是否属于该类 - Class cl = desc.forClass(); - if (cl != null && obj != null && !cl.isInstance(obj)) { - throw new ClassCastException(); - } - - // 获取对象的基本类型字段的数量和值 - int primDataSize = desc.getPrimDataSize(); - if (primVals == null || primVals.length < primDataSize) { - primVals = new byte[primDataSize]; - } - // 从输入流中读取基本类型字段的值,并存储在 primVals 数组中 - bin.readFully(primVals, 0, primDataSize, false); - if (obj != null) { - // 将 primVals 数组中的基本类型字段的值设置到对象的相应字段中 - desc.setPrimFieldValues(obj, primVals); - } - - // 获取对象的非基本类型字段的数量和值 - int objHandle = passHandle; - ObjectStreamField[] fields = desc.getFields(false); - Object[] objVals = new Object[desc.getNumObjFields()]; - int numPrimFields = fields.length - objVals.length; - // 循环读取对象的非基本类型字段的值 - for (int i = 0; i < objVals.length; i++) { - // 调用 readObject0 方法读取对象的非基本类型字段的值 - ObjectStreamField f = fields[numPrimFields + i]; - objVals[i] = readObject0(Object.class, f.isUnshared()); - // 如果该字段是一个引用字段,则将其标记为依赖该对象 - if (f.getField() != null) { - handles.markDependency(objHandle, passHandle); - } - } - if (obj != null) { - // 将 objVals 数组中的非基本类型字段的值设置到对象的相应字段中 - desc.setObjFieldValues(obj, objVals); - } - passHandle = objHandle; -} -``` - -我想看到这,你应该会恍然大悟的“哦”一声了。`Serializable` 接口之所以定义为空,是因为它只起到了一个标识的作用,告诉程序实现了它的对象是可以被序列化的,但真正序列化和反序列化的操作并不需要它来完成。 - -### 03、再来点注意事项 - -开门见山的说吧,[`static`](https://tobebetterjavaer.com/oo/static.html) 和 [`transient`](https://tobebetterjavaer.com/io/transient.html) 修饰的字段是不会被序列化的。 - -为什么呢?我们先来证明,再来解释原因。 - -首先,在 `Wanger` 类中增加两个字段。 - -```java -class Wanger implements Serializable { - private static final long serialVersionUID = -2095916884810199532L; - - private String name; - private int age; - - public static String pre = "沉默"; - transient String meizi = "王三"; - - @Override - public String toString() { - return "Wanger{" + "name=" + name + ",age=" + age + ",pre=" + pre + ",meizi=" + meizi + "}"; - } -} -``` - -其次,在测试类中打印序列化前和反序列化后的对象,并在序列化后和反序列化前改变 `static` 字段的值。具体代码如下: - -```java -// 初始化 -Wanger wanger = new Wanger(); -wanger.setName("王二"); -wanger.setAge(18); -System.out.println(wanger); - -// 把对象写到文件中 -try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("chenmo"));){ - oos.writeObject(wanger); - } catch (IOException e) { - e.printStackTrace(); - } - - // 改变 static 字段的值 -Wanger.pre ="不沉默"; - -// 从文件中读出对象 -try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("chenmo")));){ - Wanger wanger1 = (Wanger) ois.readObject(); - System.out.println(wanger1); -} catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); -} -``` - -输出结果: - -``` -Wanger{name=王二,age=18,pre=沉默,meizi=王三} -Wanger{name=王二,age=18,pre=不沉默,meizi=null} -``` - -从结果的对比当中,我们可以发现: - -1)序列化前,`pre` 的值为“沉默”,序列化后,`pre` 的值修改为“不沉默”,反序列化后,`pre` 的值为“不沉默”,而不是序列化前的状态“沉默”。 - -为什么呢?因为序列化保存的是对象的状态,而 `static` 修饰的字段属于类的状态,因此可以证明序列化并不保存 `static` 修饰的字段。 - -2)序列化前,`meizi` 的值为“王三”,反序列化后,`meizi` 的值为 `null`,而不是序列化前的状态“王三”。 - -为什么呢?`transient` 的中文字义为“临时的”(论英语的重要性),它可以阻止字段被序列化到文件中,在被反序列化后,`transient` 字段的值被设为初始值,比如 `int` 型的初始值为 0,对象型的初始值为 `null`。 - -如果想要深究源码的话,你可以在 `ObjectStreamClass` 中发现下面这样的代码: - -```java -private static ObjectStreamField[] getDefaultSerialFields(Class cl) { - // 获取该类中声明的所有字段 - Field[] clFields = cl.getDeclaredFields(); - ArrayList list = new ArrayList<>(); - int mask = Modifier.STATIC | Modifier.TRANSIENT; - - // 遍历所有字段,将非 static 和 transient 的字段添加到 list 中 - for (int i = 0; i < clFields.length; i++) { - Field field = clFields[i]; - int mods = field.getModifiers(); - if ((mods & mask) == 0) { - // 根据字段名、字段类型和字段是否可序列化创建一个 ObjectStreamField 对象 - ObjectStreamField osf = new ObjectStreamField(field.getName(), field.getType(), !Serializable.class.isAssignableFrom(cl)); - list.add(osf); - } - } - - int size = list.size(); - // 如果 list 为空,则返回一个空的 ObjectStreamField 数组,否则将 list 转换为 ObjectStreamField 数组并返回 - return (size == 0) ? NO_FIELDS : - list.toArray(new ObjectStreamField[size]); -} -``` - -看到 `Modifier.STATIC | Modifier.TRANSIENT` 了吧,这两个修饰符标记的字段就没有被放入到序列化的字段中,明白了吧? - -### 04、再来点干货 - -除了 `Serializable` 之外,Java 还提供了一个序列化接口 `Externalizable`(念起来有点拗口)。 - -两个接口有什么不一样的吗?试一试就知道了。 - -首先,把 `Wanger` 类实现的接口 `Serializable` 替换为 `Externalizable`。 - -```java -class Wanger implements Externalizable { - private String name; - private int age; - - public Wanger() { - - } - - public String getName() { - return name; - } - - - @Override - public String toString() { - return "Wanger{" + "name=" + name + ",age=" + age + "}"; - } - - @Override - public void writeExternal(ObjectOutput out) throws IOException { - - } - - @Override - public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { - - } - -} -``` - -实现 `Externalizable` 接口的 `Wanger` 类和实现 `Serializable` 接口的 `Wanger` 类有一些不同: - -1)新增了一个无参的构造方法。 - -使用 `Externalizable` 进行反序列化的时候,会调用被序列化类的无参构造方法去创建一个新的对象,然后再将被保存对象的字段值复制过去。否则的话,会抛出以下异常: - -``` -java.io.InvalidClassException: com.cmower.java_demo.xuliehua1.Wanger; no valid constructor - at java.io.ObjectStreamClass$ExceptionInfo.newInvalidClassException(ObjectStreamClass.java:150) - at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:790) - at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1782) - at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1353) - at java.io.ObjectInputStream.readObject(ObjectInputStream.java:373) - at com.cmower.java_demo.xuliehua1.Test.main(Test.java:27) -``` - -2)新增了两个方法 `writeExternal()` 和 `readExternal()`,实现 `Externalizable` 接口所必须的。 - -然后,我们再在测试类中打印序列化前和反序列化后的对象。 - -```java -// 初始化 -Wanger wanger = new Wanger(); -wanger.setName("王二"); -wanger.setAge(18); -System.out.println(wanger); - -// 把对象写到文件中 -try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("chenmo"));) { - oos.writeObject(wanger); -} catch (IOException e) { - e.printStackTrace(); -} - -// 从文件中读出对象 -try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("chenmo")));) { - Wanger wanger1 = (Wanger) ois.readObject(); - System.out.println(wanger1); -} catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); -} -// Wanger{name=王二,age=18} -// Wanger{name=null,age=0} -``` - -从输出的结果看,反序列化后得到的对象字段都变成了默认值,也就是说,序列化之前的对象状态没有被“冻结”下来。 - -为什么呢?因为我们没有为 `Wanger` 类重写具体的 `writeExternal()` 和 `readExternal()` 方法。那该怎么重写呢? - -```java -@Override -public void writeExternal(ObjectOutput out) throws IOException { - out.writeObject(name); - out.writeInt(age); -} - -@Override -public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { - name = (String) in.readObject(); - age = in.readInt(); -} -``` - -1)调用 `ObjectOutput` 的 `writeObject()` 方法将字符串类型的 `name` 写入到输出流中; - -2)调用 `ObjectOutput` 的 `writeInt()` 方法将整型的 `age` 写入到输出流中; - -3)调用 `ObjectInput` 的 `readObject()` 方法将字符串类型的 `name` 读入到输入流中; - -4)调用 `ObjectInput` 的 `readInt()` 方法将字符串类型的 `age` 读入到输入流中; - -再运行一次测试了类,你会发现对象可以正常地序列化和反序列化了。 - ->序列化前:Wanger{name=王二,age=18} -序列化后:Wanger{name=王二,age=18} - -总结一下: - -Externalizable 和 Serializable 都是用于实现 Java 对象的序列化和反序列化的接口,但是它们有以下区别: - -①、Serializable 是 Java 标准库提供的接口,而 Externalizable 是 Serializable 的子接口; - -![](https://cdn.tobebetterjavaer.com/stutymore/Serializbale-20230323161831.png) - - -②、Serializable 接口不需要实现任何方法,只需要将需要序列化的类标记为 Serializable 即可,而 Externalizable 接口需要实现 writeExternal 和 readExternal 两个方法; - -③、Externalizable 接口提供了更高的序列化控制能力,可以在序列化和反序列化过程中对对象进行自定义的处理,如对一些敏感信息进行加密和解密。 - -### 05、再来点甜点 - -让我先问问你吧,你知道 ` private static final long serialVersionUID = -2095916884810199532L;` 这段代码的作用吗? - -嗯...... - -`serialVersionUID` 被称为序列化 ID,它是决定 Java 对象能否反序列化成功的重要因子。在反序列化时,Java 虚拟机会把字节流中的 `serialVersionUID` 与被序列化类中的 `serialVersionUID` 进行比较,如果相同则可以进行反序列化,否则就会抛出序列化版本不一致的异常。 - -当一个类实现了 `Serializable` 接口后,IDE 就会提醒该类最好产生一个序列化 ID,就像下面这样: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/io/Serializbale-7a9a05f6-a65c-46b0-b4d7-8b619297f351.jpg) - -1)添加一个默认版本的序列化 ID: - -```java -private static final long serialVersionUID = 1L。 -``` - -2)添加一个随机生成的不重复的序列化 ID。 - -```java -private static final long serialVersionUID = -2095916884810199532L; -``` - -3)添加 `@SuppressWarnings` 注解。 - -```java -@SuppressWarnings("serial") -``` - -怎么选择呢? - -首先,我们采用第二种办法,在被序列化类中添加一个随机生成的序列化 ID。 - -```java -class Wanger implements Serializable { - private static final long serialVersionUID = -2095916884810199532L; - - private String name; - private int age; - - // 其他代码忽略 -} -``` - -然后,序列化一个 `Wanger` 对象到文件中。 - -```java -// 初始化 -Wanger wanger = new Wanger(); -wanger.setName("王二"); -wanger.setAge(18); -System.out.println(wanger); - -// 把对象写到文件中 -try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("chenmo"));) { - oos.writeObject(wanger); -} catch (IOException e) { - e.printStackTrace(); -} -``` - -这时候,我们悄悄地把 `Wanger` 类的序列化 ID 偷梁换柱一下,嘿嘿。 - -```java -// private static final long serialVersionUID = -2095916884810199532L; -private static final long serialVersionUID = -2095916884810199533L; -``` - -好了,准备反序列化吧。 - -```java -try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("chenmo")));) { - Wanger wanger = (Wanger) ois.readObject(); - System.out.println(wanger); -} catch (IOException | ClassNotFoundException e) { - e.printStackTrace(); -} -``` - -哎呀,出错了。 - -``` -java.io.InvalidClassException: local class incompatible: stream classdesc -serialVersionUID = -2095916884810199532, -local class serialVersionUID = -2095916884810199533 - at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1521) - at com.cmower.java_demo.xuliehua1.Test.main(Test.java:27) -``` - -异常堆栈信息里面告诉我们,从持久化文件里面读取到的序列化 ID 和本地的序列化 ID 不一致,无法反序列化。 - -那假如我们采用第三种方法,为 `Wanger` 类添加个 `@SuppressWarnings("serial")` 注解呢? - -```java -@SuppressWarnings("serial") -class Wanger implements Serializable { -// 省略其他代码 -} -``` - -好了,再来一次反序列化吧。可惜依然报错。 - -``` -java.io.InvalidClassException: local class incompatible: stream classdesc -serialVersionUID = -2095916884810199532, -local class serialVersionUID = -3818877437117647968 - at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1521) - at com.cmower.java_demo.xuliehua1.Test.main(Test.java:27) -``` - -异常堆栈信息里面告诉我们,本地的序列化 ID 为 -3818877437117647968,和持久化文件里面读取到的序列化 ID 仍然不一致,无法反序列化。这说明什么呢?使用 `@SuppressWarnings("serial")` 注解时,该注解会为被序列化类自动生成一个随机的序列化 ID。 - -由此可以证明,**Java 虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,还有一个非常重要的因素就是序列化 ID 是否一致**。 - -也就是说,如果没有特殊需求,采用默认的序列化 ID(1L)就可以,这样可以确保代码一致时反序列化成功。 - -```java -class Wanger implements Serializable { - private static final long serialVersionUID = 1L; -// 省略其他代码 -} -``` - -### 06、再来点总结 - -写这篇文章之前,我真没想到:“空空其身”的`Serializable` 竟然有这么多可以研究的内容! - -写完这篇文章之后,我不由得想起理科状元曹林菁说说过的一句话:“在学习中再小的问题也不放过,每个知识点都要总结”——说得真真真真的对啊! - - - -## 7.10 transient关键字 - -害,小二最熟的是 Java,但很多 Java 基础知识都不知道,比如 transient 关键字以前就没用到过,所以不知道它的作用是什么,今天去招银面试的时候,面试官问到了这个:说说 Java 的 transient 关键字吧,结果小二直接懵逼了。 - -下面是他自己面试凉了以后回去做的总结,分享出来,大家一起涨下姿势~~~好了,废话不多说,下面开始: - -### 01、transient 的作用及使用方法 - -我们知道,一个对象只要实现了 [Serilizable 接口](https://tobebetterjavaer.com/io/Serializbale.html),它就可以被[序列化](https://tobebetterjavaer.com/io/serialize.html)。 - -在实际开发过程中,我们常常会遇到这样的问题,一个类的有些字段需要序列化,有些字段不需要,比如说用户的一些敏感信息(如密码、银行卡号等),为了安全起见,不希望在网络操作中传输或者持久化到磁盘文件中,那这些字段就可以加上 `transient` 关键字。 - -需要注意的是,被 transient 关键字修饰的成员变量在反序列化时会被自动初始化为默认值,例如基本数据类型为 0,引用类型为 null。 - -来看示例: - -```java -public class TransientTest { - public static void main(String[] args) { - - User user = new User(); - user.setUsername("沉默王二"); - user.setPasswd("123456"); - - System.out.println("read before Serializable: "); - System.out.println("username: " + user.getUsername()); - System.err.println("password: " + user.getPasswd()); - - try { - ObjectOutputStream os = new ObjectOutputStream( - new FileOutputStream("user.txt")); - os.writeObject(user); // 将User对象写进文件 - os.flush(); - os.close(); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - try { - ObjectInputStream is = new ObjectInputStream(new FileInputStream( - "user.txt")); - user = (User) is.readObject(); // 从流中读取User的数据 - is.close(); - - System.out.println("\nread after Serializable: "); - System.out.println("username: " + user.getUsername()); - System.err.println("password: " + user.getPasswd()); - - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } catch (ClassNotFoundException e) { - e.printStackTrace(); - } - } -} - -class User implements Serializable { - private static final long serialVersionUID = 8294180014912103005L; - - private String username; - private transient String passwd; - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public String getPasswd() { - return passwd; - } - - public void setPasswd(String passwd) { - this.passwd = passwd; - } - -} -``` - -输出为: - -``` -read before Serializable: -username: 沉默王二 -password: 123456 -read after Serializable: -username: 沉默王二 -password: null -``` - -密码字段为 null,说明反序列化时根本没有从文件中获取到信息。 - -### 02、transient 使用小结 - -1)一旦字段被 transient 修饰,成员变量将不再是对象持久化的一部分,该变量的值在序列化后无法访问。 - -2)transient 关键字只能修饰字段,而不能修饰方法和类。 - -3)被 transient 关键字修饰的字段不能被序列化,一个静态变量([static关键字](https://tobebetterjavaer.com/oo/static.html)修饰)不管是否被 transient 修饰,均不能被序列化,[前面讲到过](https://tobebetterjavaer.com/io/Serializbale.html)。 - -来看示例: - -```java -public class TransientTest { - public static void main(String[] args) { - - User user = new User(); - user.setUsername("沉默王二"); - user.setPasswd("123456"); - - System.out.println("read before Serializable: "); - System.out.println("username: " + user.getUsername()); - System.err.println("password: " + user.getPasswd()); - - try { - ObjectOutputStream os = new ObjectOutputStream( - new FileOutputStream("user.txt")); - os.writeObject(user); // 将User对象写进文件 - os.flush(); - os.close(); - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - try { - // 在反序列化之前改变username的值 - User.username = "沉默王三"; - - ObjectInputStream is = new ObjectInputStream(new FileInputStream( - "user.txt")); - user = (User) is.readObject(); // 从流中读取User的数据 - is.close(); - - System.out.println("\nread after Serializable: "); - System.out.println("username: " + user.getUsername()); - System.err.println("password: " + user.getPasswd()); - - } catch (FileNotFoundException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } catch (ClassNotFoundException e) { - e.printStackTrace(); - } - } -} - -class User implements Serializable { - private static final long serialVersionUID = 8294180014912103005L; - - public static String username; - private transient String passwd; - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public String getPasswd() { - return passwd; - } - - public void setPasswd(String passwd) { - this.passwd = passwd; - } -} -``` - -运行结果为: - -``` -read before Serializable: -username: 沉默王二 -password: 123456 -read after Serializable: -username: 沉默王三 -password: null -``` - -序列化前,static 修饰的 username 为 沉默王二,然后我们在反序列化前将其修改为 沉默王三 了,如果说 static 修饰的字段能保持状态的话,反序列化后应该是 沉默王二,对吧? - -但结果是 沉默王三,这就证明了我们之前的结论:**static 修饰的字段不能被序列化**。 - -### 03、transient 修饰的字段真的不能被序列化? - -思考下面的例子: - -```java -public class ExternalizableTest implements Externalizable { - private transient String content = "是的,我将会被序列化,不管我是否被transient关键字修饰"; - - @Override - public void writeExternal(ObjectOutput out) throws IOException { - out.writeObject(content); - } - - @Override - public void readExternal(ObjectInput in) throws IOException, - ClassNotFoundException { - content = (String) in.readObject(); - } - - public static void main(String[] args) throws Exception { - - ExternalizableTest et = new ExternalizableTest(); - ObjectOutput out = new ObjectOutputStream(new FileOutputStream( - new File("test"))); - out.writeObject(et); - - ObjectInput in = new ObjectInputStream(new FileInputStream(new File( - "test"))); - et = (ExternalizableTest) in.readObject(); - System.out.println(et.content); - - out.close(); - in.close(); - } -} -``` - -来看下输出结果: - -``` -是的,我将会被序列化,不管我是否被transient关键字修饰 -``` - -这是为什么呢?不是说 transient 关键字修饰的字段不能序列化吗? - -我先说结论,这是因为我们使用了 Externalizable 接口而不是 Serializable接口,这个[知识点我们前面其实也讲到过](https://tobebetterjavaer.com/io/Serializbale.html)。 - -在 Java 中,对象的序列化可以通过实现两种接口来实现,如果实现的是 Serializable 接口,则所有的序列化将会自动进行,如果实现的是 Externalizable 接口,则需要在 writeExternal 方法中指定要序列化的字段,与 transient 关键字修饰无关。 - -因此例子输出的是变量 content 的内容,而不是 null。 - -### 04、小结 - -transient 关键字用于修饰类的成员变量,在序列化对象时,被修饰的成员变量不会被序列化和保存到文件中。其作用是告诉 JVM 在序列化对象时不需要将该变量的值持久化,这样可以避免一些安全或者性能问题。但是,transient 修饰的成员变量在反序列化时会被初始化为其默认值(如 int 类型会被初始化为 0,引用类型会被初始化为 null),因此需要在程序中进行适当的处理。 - -transient 关键字和 static 关键字都可以用来修饰类的成员变量。其中,transient 关键字表示该成员变量不参与序列化和反序列化,而 static 关键字表示该成员变量是属于类的,不属于对象的,因此不需要序列化和反序列化。 - -在 Serializable 和 Externalizable 接口中,transient 关键字的表现也不同,在 Serializable 中表示该成员变量不参与序列化和反序列化,在 Externalizable 中不起作用,因为 Externalizable 接口需要实现 readExternal 和 writeExternal 方法,需要手动完成序列化和反序列化的过程。 - ---- - -GitHub 上标星 7600+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 7600+ 的 Java 教程](https://tobebetterjavaer.com/overview/) - -微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - -# 第八章:异常处理 - - -## 8.1 Java异常处理全面解析 - -### 01、什么是异常 - -“二哥,今天就要学习异常了吗?”三妹问。 - -“是的。只有正确地处理好异常,才能保证程序的可靠性,所以异常的学习还是很有必要的。”我说。 - -“那到底什么是异常呢?”三妹问。 - -“异常是指中断程序正常执行的一个不确定的事件。当异常发生时,程序的正常执行流程就会被打断。一般情况下,程序都会有很多条语句,如果没有异常处理机制,前面的语句一旦出现了异常,后面的语句就没办法继续执行了。” - -“有了异常处理机制后,程序在发生异常的时候就不会中断,我们可以对异常进行捕获,然后改变程序执行的流程。” - -“除此之外,异常处理机制可以保证我们向用户提供友好的提示信息,而不是程序原生的异常信息——用户根本理解不了。” - -“不过,站在开发者的角度,我们更希望看到原生的异常信息,因为这有助于我们更快地找到 bug 的根源,反而被过度包装的异常信息会干扰我们的视线。” - -“Java 语言在一开始就提供了相对完善的异常处理机制,这种机制大大降低了编写可靠程序的门槛,这也是 Java 之所以能够流行的原因之一。” - -“那导致程序抛出异常的原因有哪些呢?”三妹问。 - -比如说: - -- 程序在试图打开一个不存在的文件; -- 程序遇到了网络连接问题; -- 用户输入了糟糕的数据; -- 程序在处理算术问题时没有考虑除数为 0 的情况; - -等等等等。 - -挑个最简单的原因来说吧。 - -```java -public class Demo { - public static void main(String[] args) { - System.out.println(10/0); - } -} -``` - -这段代码在运行的时候抛出的异常信息如下所示: - -``` -Exception in thread "main" java.lang.ArithmeticException: / by zero - at com.itwanger.s41.Demo.main(Demo.java:8) -``` - -“你看,三妹,这个原生的异常信息对用户来说,显然是不太容易理解的,但对于我们开发者来说,简直不要太直白了——很容易就能定位到异常发生的根源。” - -### 02、Exception和Error的区别 - -“哦,我知道了。下一个问题,我经常看到一些文章里提到 Exception 和 Error,二哥你能帮我解释一下它们之间的区别吗?”三妹问。 - -“这是一个好问题呀,三妹!” - -从单词的释义上来看,error 为错误,exception 为异常,错误的等级明显比异常要高一些。 - -从程序的角度来看,也的确如此。 - -Error 的出现,意味着程序出现了严重的问题,而这些问题不应该再交给 Java 的异常处理机制来处理,程序应该直接崩溃掉,比如说 OutOfMemoryError,内存溢出了,这就意味着程序在运行时申请的内存大于系统能够提供的内存,导致出现的错误,这种错误的出现,对于程序来说是致命的。 - -Exception 的出现,意味着程序出现了一些在可控范围内的问题,我们应当采取措施进行挽救。 - -比如说之前提到的 ArithmeticException,很明显是因为除数出现了 0 的情况,我们可以选择捕获异常,然后提示用户不应该进行除 0 操作,当然了,更好的做法是直接对除数进行判断,如果是 0 就不进行除法运算,而是告诉用户换一个非 0 的数进行运算。 - -### 03、checked和unchecked异常 - -“三妹,还能想到其他的问题吗?” - -“嗯,不用想,二哥,我已经提前做好预习工作了。”三妹自信地说,“异常又可以分为 checked 和 unchecked,它们之间又有什么区别呢?” - -“哇,三妹,果然又是一个好问题呢。” - -checked 异常(检查型异常)在源代码里必须显式地捕获或者抛出,否则编译器会提示你进行相应的操作;而 unchecked 异常(非检查型异常)就是所谓的运行时异常,通常是可以通过编码进行规避的,并不需要显式地捕获或者抛出。 - -“我先画一幅思维导图给你感受一下。” - -![](https://cdn.tobebetterjavaer.com/studymore/gailan-20230326090207.png) - -首先,Exception 和 Error 都继承了 Throwable 类。换句话说,只有 Throwable 类(或者子类)的对象才能使用 throw 关键字抛出,或者作为 catch 的参数类型。 - -面试中经常问到的一个问题是,NoClassDefFoundError 和 ClassNotFoundException 有什么区别? - -“三妹你知道吗?” - -“不知道,二哥,你解释下呗。” - -它们都是由于系统运行时找不到要加载的类导致的,但是触发的原因不一样。 - -- NoClassDefFoundError:程序在编译时可以找到所依赖的类,但是在运行时找不到指定的类文件,导致抛出该错误;原因可能是 jar 包缺失或者调用了初始化失败的类。 -- ClassNotFoundException:当动态加载 Class 对象的时候找不到对应的类时抛出该异常;原因可能是要加载的类不存在或者类名写错了。 - - -其次,像 IOException、ClassNotFoundException、SQLException 都属于 checked 异常;像 RuntimeException 以及子类 ArithmeticException、ClassCastException、ArrayIndexOutOfBoundsException、NullPointerException,都属于 unchecked 异常。 - -unchecked 异常可以不在程序中显示处理,就像之前提到的 ArithmeticException 就是的;但 checked 异常必须显式处理。 - -比如说下面这行代码: - -```java -Class clz = Class.forName("com.itwanger.s41.Demo1"); -``` - -如果没做处理,比如说在 Intellij IDEA 环境下,就会提示你这行代码可能会抛出 `java.lang.ClassNotFoundException`。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/exception/gailan-02.png) - -建议你要么使用 try-catch 进行捕获: - -```java -try { - Class clz = Class.forName("com.itwanger.s41.Demo1"); -} catch (ClassNotFoundException e) { - e.printStackTrace(); -} -``` - -注意打印异常堆栈信息的 `printStackTrace()` 方法,该方法会将异常的堆栈信息打印到标准的控制台下,如果是测试环境,这样的写法还 OK,如果是生产环境,这样的写法是不可取的,必须使用日志框架把异常的堆栈信息输出到日志系统中,否则可能没办法跟踪。 - -要么在方法签名上使用 throws 关键字抛出: - -```java -public class Demo1 { - public static void main(String[] args) throws ClassNotFoundException { - Class clz = Class.forName("com.itwanger.s41.Demo1"); - } -} -``` - -这样做的好处是不需要对异常进行捕获处理,只需要交给 Java 虚拟机来处理即可;坏处就是没法针对这种情况做相应的处理。 - -“二哥,针对 checked 异常,我在知乎上看到一个帖子,说 Java 中的 checked 很没有必要,这种异常在编译期要么 try-catch,要么 throws,但又不一定会出现异常,你觉得这样的设计有意义吗?”三妹提出了一个很尖锐的问题。 - -“哇,这种问题问的好。”我不由得对三妹心生敬佩。 - -“的确,checked 异常在业界是有争论的,它假设我们捕获了异常,并且针对这种情况作了相应的处理,但有些时候,根本就没法处理。”我说,“就拿上面提到的 ClassNotFoundException 异常来说,我们假设对其进行了 try-catch,可真的出现了 ClassNotFoundException 异常后,我们也没多少的可操作性,再 `Class.forName()` 一次?” - -另外,checked 异常也不兼容函数式编程,后面如果你写 Lambda/Stream 代码的时候,就会体验到这种苦涩。 - -当然了,checked 异常并不是一无是处,尤其是在遇到 IO 或者网络异常的时候,比如说进行 Socket 链接,我大致写了一段: - -```java -public class Demo2 { - private String mHost; - private int mPort; - private Socket mSocket; - private final Object mLock = new Object(); - - public void run() { - } - - private void initSocket() { - while (true) { - try { - Socket socket = new Socket(mHost, mPort); - synchronized (mLock) { - mSocket = socket; - } - break; - } catch (IOException e) { - e.printStackTrace(); - } - } - } -} -``` - -当发生 IOException 的时候,socket 就重新尝试连接,否则就 break 跳出循环。意味着如果 IOException 不是 checked 异常,这种写法就略显突兀,因为 IOException 没办法像 ArithmeticException 那样用一个 if 语句判断除数是否为 0 去规避。 - -或者说,强制性的 checked 异常可以让我们在编程的时候去思考,遇到这种异常的时候该怎么更优雅的去处理。显然,Socket 编程中,肯定是会遇到 IOException 的,假如 IOException 是非检查型异常,就意味着开发者也可以不考虑,直接跳过,交给 Java 虚拟机来处理,但我觉得这样做肯定更不合适。 - -### 04、关于 throw 和 throws - -“二哥,你能告诉我 throw 和 throws 两个关键字的区别吗?”三妹问。 - -“throw 关键字,用于主动地抛出异常;正常情况下,当除数为 0 的时候,程序会主动抛出 ArithmeticException;但如果我们想要除数为 1 的时候也抛出 ArithmeticException,就可以使用 throw 关键字主动地抛出异常。”我说。 - -```java -throw new exception_class("error message"); -``` - -语法也非常简单,throw 关键字后跟上 new 关键字,以及异常的类型还有参数即可。 - -举个例子。 - -```java -public class ThrowDemo { - static void checkEligibilty(int stuage){ - if(stuage<18) { - throw new ArithmeticException("年纪未满 18 岁,禁止观影"); - } else { - System.out.println("请认真观影!!"); - } - } - - public static void main(String args[]){ - checkEligibilty(10); - System.out.println("愉快地周末.."); - } -} -``` - -这段代码在运行的时候就会抛出以下错误: - -``` -Exception in thread "main" java.lang.ArithmeticException: 年纪未满 18 岁,禁止观影 - at com.itwanger.s43.ThrowDemo.checkEligibilty(ThrowDemo.java:9) - at com.itwanger.s43.ThrowDemo.main(ThrowDemo.java:16) -``` - -“throws 关键字的作用就和 throw 完全不同。”我说,“前面的小节里已经讲了 checked exception 和 unchecked exception,也就是检查型异常和非检查型异常;对于检查型异常来说,如果你没有做处理,编译器就会提示你。” - -`Class.forName()` 方法在执行的时候可能会遇到 `java.lang.ClassNotFoundException` 异常,一个检查型异常,如果没有做处理,IDEA 就会提示你,要么在方法签名上声明,要么放在 try-catch 中。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/exception/throw-throws-01.png) - -“那什么情况下使用 throws 而不是 try-catch 呢?”三妹问。 - -“假设现在有这么一个方法 `myMethod()`,可能会出现 ArithmeticException 异常,也可能会出现 NullPointerException。这种情况下,可以使用 try-catch 来处理。”我回答。 - -```java -public void myMethod() { - try { - // 可能抛出异常 - } catch (ArithmeticException e) { - // 算术异常 - } catch (NullPointerException e) { - // 空指针异常 - } -} -``` - -“但假设有好几个类似 `myMethod()` 的方法,如果为每个方法都加上 try-catch,就会显得非常繁琐。代码就会变得又臭又长,可读性就差了。”我继续说。 - -“一个解决办法就是,使用 throws 关键字,在方法签名上声明可能会抛出的异常,然后在调用该方法的地方使用 try-catch 进行处理。” - -```java -public static void main(String args[]){ - try { - myMethod1(); - } catch (ArithmeticException e) { - // 算术异常 - } catch (NullPointerException e) { - // 空指针异常 - } -} -public static void myMethod1() throws ArithmeticException, NullPointerException{ - // 方法签名上声明异常 -} -``` - -“好了,我来总结下 throw 和 throws 的区别,三妹,你记一下。” - - 1)throws 关键字用于声明异常,它的作用和 try-catch 相似;而 throw 关键字用于显式的抛出异常。 - -2)throws 关键字后面跟的是异常的名字;而 throw 关键字后面跟的是异常的对象。 - -示例。 - -```java -throws ArithmeticException; -``` - -```java -throw new ArithmeticException("算术异常"); -``` - -3)throws 关键字出现在方法签名上,而 throw 关键字出现在方法体里。 - -4)throws 关键字在声明异常的时候可以跟多个,用逗号隔开;而 throw 关键字每次只能抛出一个异常。 - -### 05、关于 try-catch-finally - -“二哥,之前你讲了异常处理机制,这一节讲什么呢?”三妹问。 - -“该讲 try-catch-finally 了。”我说,“try 关键字后面会跟一个大括号 `{}`,我们把一些可能发生异常的代码放到大括号里;`try` 块后面一般会跟 `catch` 块,用来处理发生异常的情况;当然了,异常不一定会发生,为了保证发不发生异常都能执行一些代码,就会跟一个 `finally` 块。” - -“具体该怎么用呀,二哥?”三妹问。 - -“别担心,三妹,我一一来说明下。”我说。 - -`try` 块的语法很简单: - -```java -try{ -// 可能发生异常的代码 -} -``` - -“注意啊,三妹,如果一些代码确定不会抛出异常,就尽量不要把它包裹在 `try` 块里,因为加了异常处理的代码执行起来要比没有加的花费更多的时间。” - -`catch` 块的语法也很简单: - -```java -try{ -// 可能发生异常的代码 -}catch (exception(type) e(object)){ -// 异常处理代码 -} -``` - -一个 `try` 块后面可以跟多个 `catch` 块,用来捕获不同类型的异常并做相应的处理,当 try 块中的某一行代码发生异常时,之后的代码就不再执行,而是会跳转到异常对应的 catch 块中执行。 - -如果一个 try 块后面跟了多个与之关联的 catch 块,那么应该把特定的异常放在前面,通用型的异常放在后面,不然编译器会提示错误。举例来说。 - -```java -static void test() { - int num1, num2; - try { - num1 = 0; - num2 = 62 / num1; - System.out.println(num2); - System.out.println("try 块的最后一句"); - } catch (ArithmeticException e) { - // 算术运算发生时跳转到这里 - System.out.println("除数不能为零"); - } catch (Exception e) { - // 通用型的异常意味着可以捕获所有的异常,它应该放在最后面, - System.out.println("异常发生了"); - } - System.out.println("try-catch 之外的代码."); -} -``` - -“为什么 Exception 不能放到 ArithmeticException 前面呢?”三妹问。 - -“因为 ArithmeticException 是 Exception 的子类,它更具体,我们看到就这个异常就知道是发生了算术错误,而 Exception 比较泛,它隐藏了具体的异常信息,我们看到后并不确定到底是发生了哪一种类型的异常,对错误的排查很不利。”我说,“再者,如果把通用型的异常放在前面,就意味着其他的 catch 块永远也不会执行,所以编译器就直接提示错误了。” - -“再给你举个例子,注意看,三妹。” - -```java -static void test1 () { - try{ - int arr[]=new int[7]; - arr[4]=30/0; - System.out.println("try 块的最后"); - } catch(ArithmeticException e){ - System.out.println("除数必须是 0"); - } catch(ArrayIndexOutOfBoundsException e){ - System.out.println("数组越界了"); - } catch(Exception e){ - System.out.println("一些其他的异常"); - } - System.out.println("try-catch 之外"); -} -``` - -这段代码在执行的时候,第一个 catch 块会执行,因为除数为零;我再来稍微改动下代码。 - -```java -static void test1 () { - try{ - int arr[]=new int[7]; - arr[9]=30/1; - System.out.println("try 块的最后"); - } catch(ArithmeticException e){ - System.out.println("除数必须是 0"); - } catch(ArrayIndexOutOfBoundsException e){ - System.out.println("数组越界了"); - } catch(Exception e){ - System.out.println("一些其他的异常"); - } - System.out.println("try-catch 之外"); -} -``` - -“我知道,二哥,第二个 catch 块会执行,因为没有发生算术异常,但数组越界了。”三妹没等我把代码运行起来就说出了答案。 - -“三妹,你说得很对,我再来改一下代码。” - -```java -static void test1 () { - try{ - int arr[]=new int[7]; - arr[9]=30/1; - System.out.println("try 块的最后"); - } catch(ArithmeticException | ArrayIndexOutOfBoundsException e){ - System.out.println("除数必须是 0"); - } - System.out.println("try-catch 之外"); -} -``` - -“当有多个 catch 的时候,也可以放在一起,用竖划线 `|` 隔开,就像上面这样。”我说。 - -“这样不错呀,看起来更简洁了。”三妹说。 - -`finally` 块的语法也不复杂。 - -```java -try { - // 可能发生异常的代码 -}catch { - // 异常处理 -}finally { - // 必须执行的代码 -} -``` - -在没有 [`try-with-resources`](https://tobebetterjavaer.com/exception/try-with-resources.html) 之前,finally 块常用来关闭一些连接资源,比如说 socket、数据库链接、IO 输入输出流等。 - -```java -OutputStream osf = new FileOutputStream( "filename" ); -OutputStream osb = new BufferedOutputStream(opf); -ObjectOutput op = new ObjectOutputStream(osb); -try{ - output.writeObject(writableObject); -} finally{ - op.close(); -} -``` - -“三妹,注意,使用 finally 块的时候需要遵守这些规则。” - -- finally 块前面必须有 try 块,不要把 finally 块单独拉出来使用。编译器也不允许这样做。 -- finally 块不是必选项,有 try 块的时候不一定要有 finally 块。 -- 如果 finally 块中的代码可能会发生异常,也应该使用 try-catch 进行包裹。 -- 即便是 try 块中执行了 return、break、continue 这些跳转语句,finally 块也会被执行。 - -“真的吗,二哥?”三妹对最后一个规则充满了疑惑。 - -“来试一下就知道了。”我说。 - -```java -static int test2 () { - try { - return 112; - } - finally { - System.out.println("即使 try 块有 return,finally 块也会执行"); - } -} -``` - -来看一下输出结果: - -``` -即使 try 块有 return,finally 块也会执行 -``` - -“那,会不会有不执行 finally 的情况呀?”三妹很好奇。 - -“有的。”我斩钉截铁地回答。 - -- 遇到了死循环。 -- 执行了 `System. exit()` 这行代码。 - -`System.exit()` 和 `return` 语句不同,前者是用来退出程序的,后者只是回到了上一级方法调用。 - -“三妹,来看一下源码的文档注释就全明白了!” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/exception/try-catch-finally-01.png) - -至于参数 status 的值也很好理解,如果是异常退出,设置为非 0 即可,通常用 1 来表示;如果是想正常退出程序,用 0 表示即可。 - -### 06、小结 - -Java 的异常处理是一种重要的机制,可以帮助我们处理程序执行期间发生的错误❎或异常。 - -异常分为两类:Checked Exception 和 Unchecked Exception,其中 Checked Exception 需要在代码中显式地处理或声明抛出,而 Unchecked Exception 不需要在代码中显式地处理或声明抛出。异常处理通常使用 try-catch-finally 块来处理,也可以使用 throws 关键字将异常抛出给调用者处理。 - -下面是 Java 异常处理的一些总结: - -- 使用 try-catch 块捕获并处理异常,可以避免程序因异常而崩溃。 -- 可以使用多个 catch 块来捕获不同类型的异常,并进行不同的处理。 -- 可以使用 finally 块来执行一些必要的清理工作,无论是否发生异常都会执行。 -- 可以使用 throw 关键字手动抛出异常,用于在程序中明确指定某些异常情况。 -- 可以使用 throws 关键字将异常抛出给调用者处理,用于在方法签名中声明可能会出现的异常。 -- Checked Exception 通常是由于外部因素导致的问题,需要在代码中显式地处理或声明抛出。 -- Unchecked Exception 通常是由于程序内部逻辑或数据异常导致的,可以不处理或者在需要时进行处理。 -- 在处理异常时,应该根据具体的异常类型进行处理,例如可以尝试重新打开文件、重新建立网络连接等操作。 -- 异常处理应该根据具体的业务需求和设计原则进行,避免过度捕获和处理异常,从而降低程序的性能和可维护性。 - - - -## 8.2 try-with-resources - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[8.2 try-with-resources](https://tobebetterjavaer.com/exception/try-with-resources.html) - -## 8.3 异常处理的20个最佳实践 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[8.3 异常处理的20个最佳实践](https://tobebetterjavaer.com/exception/shijian.html) - -## 8.4 空指针的传说 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[8.4 空指针的传说](https://tobebetterjavaer.com/exception/npe.html) - - -## 8.5 try-catch会影响性能吗? - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[8.5 try-catch会影响性能吗?](https://tobebetterjavaer.com/exception/try-catch-xingneng.html) - -# 第九章:常用工具类 - - -## 9.1 Scanner工具类 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[8.5 try-catch会影响性能吗?](https://tobebetterjavaer.com/common-tool/scanner.html) - - - -## 9.2 Arrays工具类 - -“哥,数组专用工具类是专门用来操作[数组](https://tobebetterjavaer.com/array/array.html)的吗?比如说创建数组、数组排序、数组检索等等。”三妹的提问其实已经把答案说了出来。 - -“是滴,这里说的数组专用工具类指的是 `java.util.Arrays` 类,基本上常见的数组操作,这个类都提供了静态方法可供直接调用。毕竟数组本身想完成这些操作还是挺麻烦的,有了这层封装,就方便多了。”在回答三妹的同时,我打开 Intellij IDEA,找到了 Arrays 类的源码。 - -```java -package java.util; -/** - * @author Josh Bloch - * @author Neal Gafter - * @author John Rose - * @since 1.2 - */ -public class Arrays {} -``` - -“具体来说,数组操作可分为以下 9 种。” - -- 创建数组 -- 比较数组 -- 数组排序 -- 数组检索 -- 数组转流 -- 打印数组 -- 数组转 List -- setAll(没想好中文名) -- parallelPrefix(没想好中文名) - -“我们来一个一个学习。” - -### 01、创建数组 - -使用 Arrays 类创建数组可以通过以下三个方法: - -- copyOf,复制指定的数组,截取或用 null 填充 -- copyOfRange,复制指定范围内的数组到一个新的数组 -- fill,对数组进行填充 - -#### 1)copyOf - -直接来看例子: - -```java -String[] intro = new String[] { "沉", "默", "王", "二" }; -String[] revised = Arrays.copyOf(intro, 3); -String[] expanded = Arrays.copyOf(intro, 5); -System.out.println(Arrays.toString(revised)); -System.out.println(Arrays.toString(expanded)); -``` - -revised 和 expanded 是复制后的新数组,长度分别是 3 和 5,指定的数组长度是 4。来看一下输出结果: - -``` -[沉, 默, 王] -[沉, 默, 王, 二, null] -``` - -看到没?revised 截取了最后一位,因为长度是 3 嘛;expanded 用 null 填充了一位,因为长度是 5。 - -ArrayList(内部的数据结构用的就是数组)源码中的 `grow()` 方法就调用了 `copyOf()` 方法:当 ArrayList 初始大小不满足元素的增长时就会扩容。 - -```java -private Object[] grow(int minCapacity) { - return elementData = Arrays.copyOf(elementData, - newCapacity(minCapacity)); -} -``` - -#### 2)copyOfRange - -直接来看例子: - -```java -String[] intro = new String[] { "沉", "默", "王", "二" }; -String[] abridgement = Arrays.copyOfRange(intro, 0, 3); -System.out.println(Arrays.toString(abridgement)); -``` - -`copyOfRange()` 方法需要三个参数,第一个是指定的数组,第二个是起始位置(包含),第三个是截止位置(不包含)。来看一下输出结果: - -```java -[沉, 默, 王] -``` - -0 的位置是“沉”,3 的位置是“二”,也就是说截取了从 0 位(包含)到 3 位(不包含)的数组元素。那假如说下标超出了数组的长度,会发生什么呢? - -```java -String[] abridgementExpanded = Arrays.copyOfRange(intro, 0, 6); -System.out.println(Arrays.toString(abridgementExpanded)); -``` - -结束位置此时为 6,超出了指定数组的长度 4,来看一下输出结果: - -``` -[沉, 默, 王, 二, null, null] -``` - -仍然使用了 null 进行填充。 - -“为什么要这么做呢?”经过这段时间的学习,三妹的眼光越来越毒辣了,问的问题都恰到好处。 - -“嗯,我想是 Arrays 的设计者考虑到了数组越界的问题,不然每次调用 Arrays 类就要先判断很多次长度,很麻烦。”稍作思考后,我给出了这样一个回答。 - -#### 3)fill - -直接来看例子: - -```java -String[] stutter = new String[4]; -Arrays.fill(stutter, "沉默王二"); -System.out.println(Arrays.toString(stutter)); -``` - -使用 new 关键字创建了一个长度为 4 的数组,然后使用 `fill()` 方法将 4 个位置填充为“沉默王二”,来看一下输出结果: - -``` -[沉默王二, 沉默王二, 沉默王二, 沉默王二] -``` - -如果想要一个元素完全相同的数组时, `fill()` 方法就派上用场了。 - -### 02、比较数组 - -Arrays 类的 `equals()` 方法用来判断两个数组是否相等,来看下面这个例子: - -```java -String[] intro = new String[] { "沉", "默", "王", "二" }; -boolean result = Arrays.equals(new String[] { "沉", "默", "王", "二" }, intro); -System.out.println(result); -boolean result1 = Arrays.equals(new String[] { "沉", "默", "王", "三" }, intro); -System.out.println(result1); -``` - -输出结果如下所示: - -``` -true -false -``` - -指定的数组为沉默王二四个字,比较的数组一个是沉默王二,一个是沉默王三,所以 result 为 true,result1 为 false。 - -简单看一下 `equals()` 方法的源码: - -```java -public static boolean equals(Object[] a, Object[] a2) { - if (a==a2) - return true; - if (a==null || a2==null) - return false; - - int length = a.length; - if (a2.length != length) - return false; - - for (int i=0; i fence(1) - at java.base/java.util.Spliterators.checkFromToBounds(Spliterators.java:387) -``` - -### 06、打印数组 - -因为数组是一个对象,直接 `System.out.println` 的话,结果是这样的: - -``` -[Ljava.lang.String;@3d075dc0 -``` - -[最优雅的打印方式](https://tobebetterjavaer.com/array/print.html),是使用 `Arrays.toString()`,其实前面讲过。来看一下该方法的源码: - -```java -public static String toString(Object[] a) { - if (a == null) - return "null"; - - int iMax = a.length - 1; - if (iMax == -1) - return "[]"; - - StringBuilder b = new StringBuilder(); - b.append('['); - for (int i = 0; ; i++) { - b.append(String.valueOf(a[i])); - if (i == iMax) - return b.append(']').toString(); - b.append(", "); - } -} -``` - -- 先判断 null,是的话,直接返回“null”字符串; -- 获取数组的长度,如果数组的长度为 0( 等价于 length - 1 为 -1),返回中括号“[]”,表示数组为空的; -- 如果数组既不是 null,长度也不为 0,就声明 StringBuilder 对象,然后添加一个数组的开始标记“[”,之后再遍历数组,把每个元素添加进去;其中一个小技巧就是,当遇到末尾元素的时候(i == iMax),不再添加逗号和空格“, ”,而是添加数组的闭合标记“]”。 - -“哥,我能不能问一个问题呀?” - -“你问啊。” - -“就是为什么判断数组长度为 0 的时候判断的是减 1 后比较 -1 呢?为什么不直接比较 0 呢?” - -“呀,你这个问题问的很妙啊!”我想到三妹说一句“respect”,很强!“其实是和遍历数组的时候判断 `i == iMax` 有关了,否则这里就要用 `i == iMax -1` 来判断是否到达数组的最后一个元素了。” - -“哦--”三妹似乎明白了什么。 - -### 07、数组转 List - -尽管数组非常强大,但它自身可以操作的工具方法很少,比如说判断数组中是否包含某个值。如果能转成 List 的话,就简便多了,因为 Java 的[集合框架 List](https://tobebetterjavaer.com/collection/gailan.html) 中封装了很多常用的方法。 - -```java -String[] intro = new String[] { "沉", "默", "王", "二" }; -List rets = Arrays.asList(intro); -System.out.println(rets.contains("二")); -``` - -不过需要注意的是,`Arrays.asList()` 返回的是 `java.util.Arrays.ArrayList`,并不是 [`java.util.ArrayList`](https://tobebetterjavaer.com/collection/arraylist.html),它的长度是固定的,无法进行元素的删除或者添加。 - -```java -rets.add("三"); -rets.remove("二"); -``` - -这个在编码的时候一定要注意,否则在执行这两个方法的时候,会抛出异常: - -``` -Exception in thread "main" java.lang.UnsupportedOperationException - at java.base/java.util.AbstractList.add(AbstractList.java:153) - at java.base/java.util.AbstractList.add(AbstractList.java:111) -``` - -要想操作元素的话,需要多一步转化,转成真正的 `java.util.ArrayList`: - -```java -List rets1 = new ArrayList<>(Arrays.asList(intro)); -rets1.add("三"); -rets1.remove("二"); -``` - -### 08、setAll - -Java 8 新增了 `setAll()` 方法,它提供了一个[函数式编程](https://tobebetterjavaer.com/java8/Lambda.html)的入口,可以对数组的元素进行填充: - -```java -int[] array = new int[10]; -Arrays.setAll(array, i -> i * 10); -System.out.println(Arrays.toString(array)); -``` - -“这段代码什么意思呢?”三妹问。 - -i 就相当于是数组的下标,值从 0 开始,到 9 结束,那么 `i * 10` 就意味着值从 0 * 10 开始,到 9 * 10 结束,来看一下输出结果: - -``` -[0, 10, 20, 30, 40, 50, 60, 70, 80, 90] -``` - -可以用来为新数组填充基于原来数组的新元素。 - -### 09、parallelPrefix - -`parallelPrefix()` 方法和 `setAll()` 方法一样,也是 Java 8 之后提供的,提供了一个函数式编程的入口,通过遍历数组中的元素,将当前下标位置上的元素与它之前下标的元素进行操作,然后将操作后的结果覆盖当前下标位置上的元素。 - -```java -int[] arr = new int[] { 1, 2, 3, 4}; -Arrays.parallelPrefix(arr, (left, right) -> left + right); -System.out.println(Arrays.toString(arr)); -``` - -上面代码中有一个 Lambda 表达式(`(left, right) -> left + right`),是什么意思呢?上面这段代码等同于: - -```java -int[] arr = new int[]{1, 2, 3, 4}; -Arrays.parallelPrefix(arr, (left, right) -> { - System.out.println(left + "," + right); - return left + right; -}); -System.out.println(Arrays.toString(arr)); -``` - -来看一下输出结果就明白了: - -``` -1,2 -3,3 -6,4 -[1, 3, 6, 10] -``` - -也就是说, Lambda 表达式执行了三次: - -- 第一次是 1 和 2 相加,结果是 3,替换下标为 1 的位置 -- 第二次是 3 和 3 相加,结果是 6,也就是第一次的结果和下标为 2 的元素相加的结果 -- 第三次是 6 和 4 相加,结果是 10,也就是第二次的结果和下标为 3 的元素相加的结果 - - -### 10、总结 - -“好了,三妹,就先学到这吧。如果你以后翻 Java 源码的时候,只要是用到数组的,尤其是 ArrayList 类,就可以看到 Arrays 类的很多影子。” - -“嗯嗯,我先复习一下这节的内容。哥,你去休息吧。” - -我来到客厅,坐到沙发上,捧起黄永玉先生的《无愁河上的浪荡汉子·八年卷 1》看了起来,津津有味。。。。。。 - - - -## 9.3 StringUtils工具类 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[9.3 StringUtils工具类](https://tobebetterjavaer.com/common-tool/StringUtils.html) - -## 9.4 Objects工具类 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[9.4 Objects工具类](https://tobebetterjavaer.com/common-tool/Objects.html) - -## 9.5 Collections工具类 - -Collections 是 JDK 提供的一个工具类,位于 java.util 包下,提供了一系列的静态方法,方便我们对集合进行各种骚操作,算是集合框架的一个大管家。 - -还记得我们前面讲过的 [Arrays 工具类](https://tobebetterjavaer.com/common-tool/arrays.html)吗?可以回去温习下。 - -Collections 的用法很简单,在 Intellij IDEA 中敲完 `Collections.` 之后就可以看到它提供的方法了,大致看一下方法名和参数就能知道这个方法是干嘛的。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/collections-01.png) - -为了节省大家的学习时间,我将这些方法做了一些分类,并列举了一些简单的例子。 - -### 01、排序操作 - -- `reverse(List list)`:反转顺序 -- `shuffle(List list)`:洗牌,将顺序打乱 -- `sort(List list)`:自然升序 -- `sort(List list, Comparator c)`:按照自定义的比较器排序 -- `swap(List list, int i, int j)`:将 i 和 j 位置的元素交换位置 - -来看例子: - -```java -List list = new ArrayList<>(); -list.add("沉默王二"); -list.add("沉默王三"); -list.add("沉默王四"); -list.add("沉默王五"); -list.add("沉默王六"); - -System.out.println("原始顺序:" + list); - -// 反转 -Collections.reverse(list); -System.out.println("反转后:" + list); - -// 洗牌 -Collections.shuffle(list); -System.out.println("洗牌后:" + list); - -// 自然升序 -Collections.sort(list); -System.out.println("自然升序后:" + list); - -// 交换 -Collections.swap(list, 2,4); -System.out.println("交换后:" + list); -``` - -输出后: - -``` -原始顺序:[沉默王二, 沉默王三, 沉默王四, 沉默王五, 沉默王六] -反转后:[沉默王六, 沉默王五, 沉默王四, 沉默王三, 沉默王二] -洗牌后:[沉默王五, 沉默王二, 沉默王六, 沉默王三, 沉默王四] -自然升序后:[沉默王三, 沉默王二, 沉默王五, 沉默王六, 沉默王四] -交换后:[沉默王三, 沉默王二, 沉默王四, 沉默王六, 沉默王五] -``` - -### 02、查找操作 - -- `binarySearch(List list, Object key)`:二分查找法,前提是 List 已经排序过了 -- `max(Collection coll)`:返回最大元素 -- `max(Collection coll, Comparator comp)`:根据自定义比较器,返回最大元素 -- `min(Collection coll)`:返回最小元素 -- `min(Collection coll, Comparator comp)`:根据自定义比较器,返回最小元素 -- `fill(List list, Object obj)`:使用指定对象填充 -- `frequency(Collection c, Object o)`:返回指定对象出现的次数 - -来看例子: - -```java -System.out.println("最大元素:" + Collections.max(list)); -System.out.println("最小元素:" + Collections.min(list)); -System.out.println("出现的次数:" + Collections.frequency(list, "沉默王二")); - -// 没有排序直接调用二分查找,结果是不确定的 -System.out.println("排序前的二分查找结果:" + Collections.binarySearch(list, "沉默王二")); -Collections.sort(list); -// 排序后,查找结果和预期一致 -System.out.println("排序后的二分查找结果:" + Collections.binarySearch(list, "沉默王二")); - -Collections.fill(list, "沉默王八"); -System.out.println("填充后的结果:" + list); -``` - -输出后: - -``` -原始顺序:[沉默王二, 沉默王三, 沉默王四, 沉默王五, 沉默王六] -最大元素:沉默王四 -最小元素:沉默王三 -出现的次数:1 -排序前的二分查找结果:0 -排序后的二分查找结果:1 -填充后的结果:[沉默王八, 沉默王八, 沉默王八, 沉默王八, 沉默王八] -``` - -### 03、同步控制 - -[HashMap 是线程不安全](https://tobebetterjavaer.com/collection/hashmap.html#_04%E3%80%81%E7%BA%BF%E7%A8%8B%E4%B8%8D%E5%AE%89%E5%85%A8)的,这个我们前面讲到了。那其实 ArrayList 也是线程不安全的,没法在多线程环境下使用,那 Collections 工具类中提供了多个 synchronizedXxx 方法,这些方法会返回一个同步的对象,从而解决多线程中访问集合时的安全问题。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/collections-02.png) - -使用起来也非常的简单: - -```java -SynchronizedList synchronizedList = Collections.synchronizedList(list); -``` - -看一眼 SynchronizedList 的源码就明白了,不过是在方法里面使用 [synchronized 关键字](https://tobebetterjavaer.com/thread/synchronized-1.html)加了一层锁而已。 - -```java -static class SynchronizedList - extends SynchronizedCollection - implements List { - private static final long serialVersionUID = -7754090372962971524L; - - final List list; - - SynchronizedList(List list) { - super(list); // 调用父类 SynchronizedCollection 的构造方法,传入 list - this.list = list; // 初始化成员变量 list - } - - // 获取指定索引处的元素 - public E get(int index) { - synchronized (mutex) {return list.get(index);} // 加锁,调用 list 的 get 方法获取元素 - } - - // 在指定索引处插入指定元素 - public void add(int index, E element) { - synchronized (mutex) {list.add(index, element);} // 加锁,调用 list 的 add 方法插入元素 - } - - // 移除指定索引处的元素 - public E remove(int index) { - synchronized (mutex) {return list.remove(index);} // 加锁,调用 list 的 remove 方法移除元素 - } -} -``` - -那这样的话,其实效率和那些直接在方法上加 synchronized 关键字的 [Vector、Hashtable](https://tobebetterjavaer.com/collection/gailan.html) 差不多(JDK 1.0 时期就有了),而这些集合类基本上已经废弃了,几乎不怎么用。 - -```java -public class Vector - extends AbstractList - implements List, RandomAccess, Cloneable, java.io.Serializable -{ - - // 获取指定索引处的元素 - public synchronized E get(int index) { - if (index >= elementCount) // 如果索引超出了列表的大小,则抛出数组下标越界异常 - throw new ArrayIndexOutOfBoundsException(index); - - return elementData(index); // 返回指定索引处的元素 - } - - // 移除指定索引处的元素 - public synchronized E remove(int index) { - modCount++; // 修改计数器,标识列表已被修改 - if (index >= elementCount) // 如果索引超出了列表的大小,则抛出数组下标越界异常 - throw new ArrayIndexOutOfBoundsException(index); - E oldValue = elementData(index); // 获取指定索引处的元素 - - int numMoved = elementCount - index - 1; // 计算需要移动的元素个数 - if (numMoved > 0) // 如果需要移动元素 - System.arraycopy(elementData, index+1, elementData, index, - numMoved); // 将数组中的元素向左移动一位 - elementData[--elementCount] = null; // 将最后一个元素设置为 null,等待垃圾回收 - - return oldValue; // 返回被移除的元素 - } -} -``` - -正确的做法是使用并发包下的 [CopyOnWriteArrayList](https://tobebetterjavaer.com/thread/CopyOnWriteArrayList.html)、[ConcurrentHashMap](https://tobebetterjavaer.com/thread/ConcurrentHashMap.html)。这些我们放到并发编程时再讲。 - -### 04、不可变集合 - -- `emptyXxx()`:制造一个空的不可变集合 -- `singletonXxx()`:制造一个只有一个元素的不可变集合 -- `unmodifiableXxx()`:为指定集合制作一个不可变集合 - -举个例子: - -```java -List emptyList = Collections.emptyList(); -emptyList.add("非空"); -System.out.println(emptyList); -``` - -这段代码在执行的时候就抛出错误了。 - -``` -Exception in thread "main" java.lang.UnsupportedOperationException - at java.util.AbstractList.add(AbstractList.java:148) - at java.util.AbstractList.add(AbstractList.java:108) - at com.itwanger.s64.Demo.main(Demo.java:61) -``` - -这是因为 `Collections.emptyList()` 会返回一个 Collections 的内部类 EmptyList,而 EmptyList 并没有重写父类 AbstractList 的 `add(int index, E element)` 方法,所以执行的时候就抛出了不支持该操作的 UnsupportedOperationException 了。 - -这是从分析 add 方法源码得出的原因。除此之外,emptyList 方法是 final 的,返回的 EMPTY_LIST 也是 final 的,种种迹象表明 emptyList 返回的就是不可变对象,没法进行增删改查。 - -```java -public static final List emptyList() { - return (List) EMPTY_LIST; -} - -public static final List EMPTY_LIST = new EmptyList<>(); -``` - -### 05、其他 - -还有两个方法比较常用: - -- `addAll(Collection c, T... elements)`,往集合中添加元素 -- `disjoint(Collection c1, Collection c2)`,判断两个集合是否没有交集 - -举个例子: - -```java -List allList = new ArrayList<>(); -Collections.addAll(allList, "沉默王九","沉默王十","沉默王二"); -System.out.println("addAll 后:" + allList); - -System.out.println("是否没有交集:" + (Collections.disjoint(list, allList) ? "是" : "否")); -``` - -输出后: - -``` -原始顺序:[沉默王二, 沉默王三, 沉默王四, 沉默王五, 沉默王六] -addAll 后:[沉默王九, 沉默王十, 沉默王二] -是否没有交集:否 -``` - -### 06、CollectionUtils:Spring 和 Apache 都有提供的集合工具类 - -对集合操作,除了前面说的 JDK 原生 `Collections` 工具类,`CollectionUtils`工具类也很常用。 - -目前比较主流的是`Spring`的`org.springframework.util`包下的 CollectionUtils 工具类。 - -![](https://cdn.tobebetterjavaer.com/stutymore/utils-20230330101919.png) - -和`Apache`的`org.apache.commons.collections`包下的 CollectionUtils 工具类。 - -![](https://cdn.tobebetterjavaer.com/stutymore/utils-20230330103825.png) - -Maven 坐标如下: - -``` - - org.apache.commons - commons-collections4 - 4.4 - -``` - -Apache 的方法比 Spring 的更多一些,我们就以 Apache 的为例,来介绍一下常用的方法。 - -#### 集合判空 - -通过 CollectionUtils 工具类的`isEmpty`方法可以轻松判断集合是否为空,`isNotEmpty`方法判断集合不为空。 - -```java -List list = new ArrayList<>(); -list.add(2); -list.add(1); -list.add(3); - -if (CollectionUtils.isEmpty(list)) { - System.out.println("集合为空"); -} - -if (CollectionUtils.isNotEmpty(list)) { - System.out.println("集合不为空"); -} -``` - -#### 对两个集合进行操作 - -有时候我们需要对已有的两个集合进行操作,比如取交集或者并集等。 - -```java -List list = new ArrayList<>(); -list.add(2); -list.add(1); -list.add(3); - -List list2 = new ArrayList<>(); -list2.add(2); -list2.add(4); - -//获取并集 -Collection unionList = CollectionUtils.union(list, list2); -System.out.println(unionList); - -//获取交集 -Collection intersectionList = CollectionUtils.intersection(list, list2); -System.out.println(intersectionList); - -//获取交集的补集 -Collection disjunctionList = CollectionUtils.disjunction(list, list2); -System.out.println(disjunctionList); - -//获取差集 -Collection subtractList = CollectionUtils.subtract(list, list2); -System.out.println(subtractList); -``` - -执行结果: - -```java -[1, 2, 3, 4] -[2] -[1, 3, 4] -[1, 3] -``` - -说句实话,对两个集合的操作,在实际工作中用得挺多的,特别是很多批量的场景中。以前我们需要写一堆代码,但没想到有现成的轮子。 - -### 07、小结 - -整体上,Collections 工具类作为集合框架的大管家,提供了一些非常便利的方法供我们调用,也非常容易掌握,没什么难点,看看方法的注释就能大致明白干嘛的。 - -不过,工具就放在那里,用是一回事,为什么要这么用就是另外一回事了。能不能提高自己的编码水平,很大程度上取决于你到底有没有去钻一钻源码,看这些设计 JDK 的大师们是如何写代码的,学会一招半式,在工作当中还是能很快脱颖而出的。 - -恐怕 JDK 的设计者是这个世界上最好的老师了,文档写得不能再详细了,代码写得不能再优雅了,基本上都达到了性能上的极致。 - -可能有人会说,工具类没什么鸟用,不过是调用下方法而已,但这就大错特错了:如果要你来写,你能写出来 Collections 这样一个工具类吗? - -这才是高手要思考的一个问题。 - - - -## 9.6 Hutool工具类库 - -读者群里有个小伙伴感慨说,“Hutool 这款开源类库太厉害了,基本上该有该的工具类,它里面都有。”讲真的,我平常工作中也经常用 Hutool,它确实可以帮助我们简化每一行代码,使 Java 拥有函数式语言般的优雅,让 Java 语言变得“甜甜的”。 - -Hutool 的作者在[官网](https://hutool.cn/)上说,Hutool 是 Hu+tool 的自造词(好像不用说,我们也能猜得到),“Hu”用来致敬他的“前任”公司,“tool”就是工具的意思,谐音就有意思了,“糊涂”,寓意追求“万事都作糊涂观,无所谓失,无所谓得”(一个开源类库,上升到了哲学的高度,作者厉害了)。 - -看了一下开发团队的一个成员介绍,一个 Java 后端工具的作者竟然爱前端、爱数码,爱美女,嗯嗯嗯,确实“难得糊涂”(手动狗头)。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/hutool-01.png) - -废话就说到这,来吧,实操走起! - -### 01、引入 Hutool - -Maven 项目只需要在 pom.xml 文件中添加以下依赖即可。 - -``` - - cn.hutool - hutool-all - 5.4.3 - -``` - -Hutool 的设计思想是尽量减少重复的定义,让项目中的 util 包尽量少。一个好的轮子可以在很大程度上避免“复制粘贴”,从而节省我们开发人员对项目中公用类库和公用工具方法的封装时间。同时呢,成熟的开源库也可以最大限度的避免封装不完善带来的 bug。 - -就像作者在官网上说的那样: - -- 以前,我们打开搜索引擎 -> 搜“Java MD5 加密” -> 打开某篇博客 -> 复制粘贴 -> 改改,变得好用些 -- 有了 Hutool 以后呢,引入 Hutool -> 直接 `SecureUtil.md5()` - -Hutool 对不仅对 JDK 底层的文件、流、加密解密、转码、正则、线程、XML等做了封装,还提供了以下这些组件: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/hutool-02.png) - -非常多,非常全面,鉴于此,我只挑选一些我喜欢的来介绍下(偷偷地告诉你,我就是想偷懒)。 - -### 02、类型转换 - -类型转换在 Java 开发中很常见,尤其是从 HttpRequest 中获取参数的时候,前端传递的是整型,但后端只能先获取到字符串,然后再调用 `parseXXX()` 方法进行转换,还要加上判空,很繁琐。 - -Hutool 的 Convert 类可以简化这个操作,可以将任意可能的类型转换为指定类型,同时第二个参数 defaultValue 可用于在转换失败时返回一个默认值。 - -```java -String param = "10"; -int paramInt = Convert.toInt(param); -int paramIntDefault = Convert.toInt(param, 0); -``` - -把字符串转换成日期: - -```java -String dateStr = "2020年09月29日"; -Date date = Convert.toDate(dateStr); -``` - -把字符串转成 Unicode: - -```java -String unicodeStr = "沉默王二"; -String unicode = Convert.strToUnicode(unicodeStr); -``` - -### 03、日期时间 - -JDK 自带的 Date 和 Calendar 不太好用,Hutool 封装的 DateUtil 用起来就舒服多了! - -获取当前日期: - -```java -Date date = DateUtil.date(); -``` - -`DateUtil.date()` 返回的其实是 DateTime,它继承自 Date 对象,重写了 `toString()` 方法,返回 `yyyy-MM-dd HH:mm:ss` 格式的字符串。 - -有些小伙伴是不是想看看我写这篇文章的时间,输出一下给大家看看: - -``` -System.out.println(date);// 2020-09-29 04:28:02 -``` - -字符串转日期: - -```java -String dateStr = "2020-09-29"; -Date date = DateUtil.parse(dateStr); -``` - -`DateUtil.parse()` 会自动识别一些常用的格式,比如说: - -- yyyy-MM-dd HH:mm:ss -- yyyy-MM-dd -- HH:mm:ss -- yyyy-MM-dd HH:mm -- yyyy-MM-dd HH:mm:ss.SSS - -还可以识别带中文的: - -- 年月日时分秒 - -格式化时间差: - -```java -String dateStr1 = "2020-09-29 22:33:23"; -Date date1 = DateUtil.parse(dateStr1); - -String dateStr2 = "2020-10-01 23:34:27"; -Date date2 = DateUtil.parse(dateStr2); - -long betweenDay = DateUtil.between(date1, date2, DateUnit.MS); - -// 输出:2天1小时1分4秒 -String formatBetween = DateUtil.formatBetween(betweenDay, BetweenFormater.Level.SECOND); -``` - -星座和属相: - -```java -// 射手座 -String zodiac = DateUtil.getZodiac(Month.DECEMBER.getValue(), 10); -// 蛇 -String chineseZodiac = DateUtil.getChineseZodiac(1989); -``` - -### 04、IO 流相关 - -[IO 操作包括读和写](https://tobebetterjavaer.com/io/shangtou.html),应用的场景主要包括网络操作和文件操作,原生的 Java 类库区分[字符流](https://tobebetterjavaer.com/io/reader-writer.html)和[字节流](https://tobebetterjavaer.com/io/stream.html),字节流 InputStream 和 OutputStream 就有很多很多种,使用起来让人头皮发麻。 - -Hutool 封装了流操作工具类 IoUtil、文件读写操作工具类 FileUtil、文件类型判断工具类 FileTypeUtil 等等。 - -```java -BufferedInputStream in = FileUtil.getInputStream("hutool/origin.txt"); -BufferedOutputStream out = FileUtil.getOutputStream("hutool/to.txt"); -long copySize = IoUtil.copy(in, out, IoUtil.DEFAULT_BUFFER_SIZE); -``` - -在 IO 操作中,文件的操作相对来说是比较复杂的,但使用频率也很高,几乎所有的项目中都躺着一个叫 FileUtil 或者 FileUtils 的工具类。Hutool 的 FileUtil 类包含以下几类操作: - -- 文件操作:包括文件目录的新建、删除、复制、移动、改名等 -- 文件判断:判断文件或目录是否非空,是否为目录,是否为文件等等 -- 绝对路径:针对 ClassPath 中的文件转换为绝对路径文件 -- 文件名:主文件名,扩展名的获取 -- 读操作:包括 getReader、readXXX 操作 -- 写操作:包括 getWriter、writeXXX 操作 - -顺带说说 classpath。 - -在实际编码当中,我们通常需要从某些文件里面读取一些数据,比如配置文件、文本文件、图片等等,那这些文件通常放在什么位置呢? - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/hutool-03.png) - -放在项目结构图中的 resources 目录下,当项目编译后,会出现在 classes 目录下。对应磁盘上的目录如下图所示: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/hutool-04.png) - -当我们要读取文件的时候,我是不建议使用绝对路径的,因为操作系统不一样的话,文件的路径标识符也是不一样的。最好使用相对路径。 - -假设在 `src/resources` 下放了一个文件 origin.txt,文件的路径参数如下所示: - -```java -FileUtil.getInputStream("origin.txt") -``` - -假设文件放在 `src/resources/hutool` 目录下,则路径参数改为: - -```java -FileUtil.getInputStream("hutool/origin.txt") -``` - -### 05、字符串工具 - -Hutool 封装的字符串工具类 StrUtil 和 Apache Commons Lang 包中的 StringUtils 类似,作者认为优势在于 Str 比 String 短,尽管我不觉得。不过,我倒是挺喜欢其中的一个方法的: - -```java -String template = "{},一枚沉默但有趣的程序员,喜欢他的文章的话,请微信搜索{}"; -String str = StrUtil.format(template, "沉默王二", "沉默王二"); -// 沉默王二,一枚沉默但有趣的程序员,喜欢他的文章的话,请微信搜索沉默王二 -``` - -### 06、反射工具 - -反射机制可以让 Java 变得更加灵活,因此在某些情况下,反射可以做到事半功倍的效果。Hutool 封装的反射工具 ReflectUtil 包括: - -- 获取构造方法 -- 获取字段 -- 获取字段值 -- 获取方法 -- 执行方法(对象方法和静态方法) - -```java -package com.itwanger.hutool.reflect; - -import cn.hutool.core.util.ReflectUtil; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.Method; - -public class ReflectDemo { - private int id; - - public ReflectDemo() { - System.out.println("构造方法"); - } - - public void print() { - System.out.println("我是沉默王二"); - } - - public static void main(String[] args) throws IllegalAccessException { - // 构建对象 - ReflectDemo reflectDemo = ReflectUtil.newInstance(ReflectDemo.class); - - // 获取构造方法 - Constructor[] constructors = ReflectUtil.getConstructors(ReflectDemo.class); - for (Constructor constructor : constructors) { - System.out.println(constructor.getName()); - } - - // 获取字段 - Field field = ReflectUtil.getField(ReflectDemo.class, "id"); - field.setInt(reflectDemo, 10); - // 获取字段值 - System.out.println(ReflectUtil.getFieldValue(reflectDemo, field)); - - // 获取所有方法 - Method[] methods = ReflectUtil.getMethods(ReflectDemo.class); - for (Method m : methods) { - System.out.println(m.getName()); - } - - // 获取指定方法 - Method method = ReflectUtil.getMethod(ReflectDemo.class, "print"); - System.out.println(method.getName()); - - - // 执行方法 - ReflectUtil.invoke(reflectDemo, "print"); - } -} -``` - -### 07、压缩工具 - -在 Java 中,对文件、文件夹打包压缩是一件很繁琐的事情,Hutool 封装的 ZipUtil 针对 java.util.zip 包做了优化,可以使用一个方法搞定压缩和解压,并且自动处理文件和目录的问题,不再需要用户判断,大大简化的压缩解压的复杂度。 - -```java -ZipUtil.zip("hutool", "hutool.zip"); -File unzip = ZipUtil.unzip("hutool.zip", "hutoolzip"); -``` - -### 08、身份证工具 - -Hutool 封装的 IdcardUtil 可以用来对身份证进行验证,支持大陆 15 位、18 位身份证,港澳台 10 位身份证。 - -```java -String ID_18 = "321083197812162119"; -String ID_15 = "150102880730303"; - -boolean valid = IdcardUtil.isValidCard(ID_18); -boolean valid15 = IdcardUtil.isValidCard(ID_15); -``` - -### 09、扩展 HashMap - -Java 中的 HashMap 是强类型的,而 Hutool 封装的 Dict 对键的类型要求没那么严格。 - -```java -Dict dict = Dict.create() - .set("age", 18) - .set("name", "沉默王二") - .set("birthday", DateTime.now()); - -int age = dict.getInt("age"); -String name = dict.getStr("name"); -``` - -### 10、控制台打印 - -本地编码的过程中,经常需要使用 `System.out` 打印结果,但是往往一些复杂的对象不支持直接打印,比如说数组,需要调用 `Arrays.toString`。Hutool 封装的 Console 类借鉴了 JavaScript 中的 `console.log()`,使得打印变成了一个非常便捷的方式。 - -```java -public class ConsoleDemo { - public static void main(String[] args) { - // 打印字符串 - Console.log("沉默王二,一枚有趣的程序员"); - - // 打印字符串模板 - Console.log("洛阳是{}朝古都",13); - - int [] ints = {1,2,3,4}; - // 打印数组 - Console.log(ints); - } -} -``` - -### 11、字段验证器 - -做 Web 开发的时候,后端通常需要对表单提交过来的数据进行验证。Hutool 封装的 Validator 可以进行很多有效的条件验证: - -- 是不是邮箱 -- 是不是 IP V4、V6 -- 是不是电话号码 -- 等等 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/hutool-05.png) - -```java -Validator.isEmail("沉默王二"); -Validator.isMobile("itwanger.com"); -``` - -### 12、双向查找 Map - -[Guava](https://tobebetterjavaer.com/common-tool/guava.html) 中提供了一种特殊的 Map 结构,叫做 BiMap,实现了一种双向查找的功能,可以根据 key 查找 value,也可以根据 value 查找 key,Hutool 也提供这种 Map 结构。 - -```java -BiMap biMap = new BiMap<>(new HashMap<>()); -biMap.put("wanger", "沉默王二"); -biMap.put("wangsan", "沉默王三"); - -// get value by key -biMap.get("wanger"); -biMap.get("wangsan"); - -// get key by value -biMap.getKey("沉默王二"); -biMap.getKey("沉默王三"); -``` - -在实际的开发工作中,其实我更倾向于使用 Guava 的 BiMap,而不是 Hutool 的。这里提一下,主要是我发现了 Hutool 在线文档上的一处错误,提了个 issue(从中可以看出我一颗一丝不苟的心和一双清澈明亮的大眼睛啊)。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/hutool-06.png) - -### 13、图片工具 - -Hutool 封装的 ImgUtil 可以对图片进行缩放、裁剪、转为黑白、加水印等操作。 - -缩放图片: - -```java -ImgUtil.scale( - FileUtil.file("hutool/wangsan.jpg"), - FileUtil.file("hutool/wangsan_small.jpg"), - 0.5f -); -``` - -裁剪图片: - -```java -ImgUtil.cut( - FileUtil.file("hutool/wangsan.jpg"), - FileUtil.file("hutool/wangsan_cut.jpg"), - new Rectangle(200, 200, 100, 100) -); -``` - -添加水印: - -```java -ImgUtil.pressText(// - FileUtil.file("hutool/wangsan.jpg"), - FileUtil.file("hutool/wangsan_logo.jpg"), - "沉默王二", Color.WHITE, - new Font("黑体", Font.BOLD, 100), - 0, - 0, - 0.8f -); -``` - -趁机让大家欣赏一下二哥帅气的真容。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/hutool-07.png) - -### 14、配置文件 - ->众所周知,Java 中广泛应用的配置文件 Properties 存在一个特别大的诟病:不支持中文。每次使用时,如果想存放中文字符,就必须借助 IDE 相关插件才能转为 Unicode 符号,而这种反人类的符号在命令行下根本没法看。 - -于是,Hutool 的 Setting 运用而生。Setting 除了兼容 Properties 文件格式外,还提供了一些特有功能,这些功能包括: - -- 各种编码方式支持 -- 变量支持 -- 分组支持 - -先整个配置文件 example.setting,内容如下: - -``` -name=沉默王二 -age=18 -``` - -再来读取和更新配置文件: - -```java -public class SettingDemo { - private final static String SETTING = "hutool/example.setting"; - public static void main(String[] args) { - // 初始化 Setting - Setting setting = new Setting(SETTING); - - // 读取 - setting.getStr("name", "沉默王二"); - - // 在配置文件变更时自动加载 - setting.autoLoad(true); - - // 通过代码方式增加键值对 - setting.set("birthday", "2020年09月29日"); - setting.store(SETTING); - } -} -``` - -### 15、日志工厂 - -Hutool 封装的日志工厂 LogFactory 兼容了各大日志框架,使用起来也非常简便。 - -```java -public class LogDemo { - private static final Log log = LogFactory.get(); - - public static void main(String[] args) { - log.debug("难得糊涂"); - } -} -``` - -先通过 `LogFactory.get()` 自动识别引入的日志框架,从而创建对应日志框架的门面 Log 对象,然后调用 `debug()`、`info()` 等方法输出日志。 - -如果不想创建 Log 对象的话,可以使用 StaticLog,顾名思义,一个提供了静态方法的日志类。 - -```java -StaticLog.info("爽啊 {}.", "沉默王二的文章"); -``` - -### 16、缓存工具 - -CacheUtil 是 Hutool 封装的创建缓存的快捷工具类,可以创建不同的缓存对象: - -- FIFOCache:先入先出,元素不停的加入缓存直到缓存满为止,当缓存满时,清理过期缓存对象,清理后依旧满则删除先入的缓存。 - -```java -Cache fifoCache = CacheUtil.newFIFOCache(3); -fifoCache.put("key1", "沉默王一"); -fifoCache.put("key2", "沉默王二"); -fifoCache.put("key3", "沉默王三"); -fifoCache.put("key4", "沉默王四"); - -// 大小为 3,所以 key3 放入后 key1 被清除 -String value1 = fifoCache.get("key1"); -``` - -- LFUCache,最少使用,根据使用次数来判定对象是否被持续缓存,当缓存满时清理过期对象,清理后依旧满的情况下清除最少访问的对象并将其他对象的访问数减去这个最少访问数,以便新对象进入后可以公平计数。 - -```java -Cache lfuCache = CacheUtil.newLFUCache(3); - -lfuCache.put("key1", "沉默王一"); -// 使用次数+1 -lfuCache.get("key1"); -lfuCache.put("key2", "沉默王二"); -lfuCache.put("key3", "沉默王三"); -lfuCache.put("key4", "沉默王四"); - -// 由于缓存容量只有 3,当加入第 4 个元素的时候,最少使用的将被移除(2,3被移除) -String value2 = lfuCache.get("key2"); -String value3 = lfuCache.get("key3"); -``` - -- LRUCache,最近最久未使用,根据使用时间来判定对象是否被持续缓存,当对象被访问时放入缓存,当缓存满了,最久未被使用的对象将被移除。 - -```java -Cache lruCache = CacheUtil.newLRUCache(3); - -lruCache.put("key1", "沉默王一"); -lruCache.put("key2", "沉默王二"); -lruCache.put("key3", "沉默王三"); -// 使用时间近了 -lruCache.get("key1"); -lruCache.put("key4", "沉默王四"); - -// 由于缓存容量只有 3,当加入第 4 个元素的时候,最久使用的将被移除(2) -String value2 = lruCache.get("key2"); -System.out.println(value2); -``` - -### 17、加密解密 - -加密分为三种: - -- 对称加密(symmetric),例如:AES、DES 等 -- 非对称加密(asymmetric),例如:RSA、DSA 等 -- 摘要加密(digest),例如:MD5、SHA-1、SHA-256、HMAC 等 - -Hutool 针对这三种情况都做了封装: - -- 对称加密 SymmetricCrypto -- 非对称加密 AsymmetricCrypto -- 摘要加密 Digester - -快速加密工具类 SecureUtil 有以下这些方法: - -1)对称加密 - -- SecureUtil.aes -- SecureUtil.des - -2)非对称加密 - -- SecureUtil.rsa -- SecureUtil.dsa - -3)摘要加密 - -- SecureUtil.md5 -- SecureUtil.sha1 -- SecureUtil.hmac -- SecureUtil.hmacMd5 -- SecureUtil.hmacSha1 - -只写一个简单的例子作为参考: - -```java -public class SecureUtilDemo { - static AES aes = SecureUtil.aes(); - public static void main(String[] args) { - String encry = aes.encryptHex("沉默王二"); - System.out.println(encry); - String oo = aes.decryptStr(encry); - System.out.println(oo); - } -} -``` - -### 18、其他类库 - -Hutool 中的类库还有很多,尤其是一些对第三方类库的进一步封装,比如邮件工具 MailUtil,二维码工具 QrCodeUtil,Emoji 工具 EmojiUtil,小伙伴们可以参考 Hutool 的官方文档:https://www.hutool.cn/ - -项目源码地址:[https://github.com/looly/hutool](https://github.com/looly/hutool) - - - -## 9.7 Guava工具库 - -### 01、前世今生 - -你好呀,我是 Guava。 - -我由 Google 公司开源,目前在 GitHub 上已经有 39.9k 的铁粉了,由此可以证明我的受欢迎程度。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/guava-01.png) - -我的身体里主要包含有这些常用的模块:集合 [collections] 、缓存 [caching] 、原生类型支持 [primitives support] 、并发库 [concurrency libraries] 、通用注解 [common annotations] 、字符串处理 [string processing] 、I/O 等。新版的 JDK 中已经直接把我引入了,可想而知我有多优秀,忍不住骄傲了。 - -这么说吧,学好如何使用我,能让你在编程中变得更快乐,写出更优雅的代码! - -### 02、引入 Guava - -如果你要在 Maven 项目使用我的话,需要先在 pom.xml 文件中引入我的依赖。 - -``` - - com.google.guava - guava - 30.1-jre - -``` - -一点要求,JDK 版本需要在 8 以上。 - -### 03、基本工具 - -Doug Lea,java.util.concurrent 包的作者,曾说过一句话:“[null 真糟糕](https://tobebetterjavaer.com/exception/npe.html)”。Tony Hoare,图灵奖得主、快速排序算法的作者,当然也是 null 的创建者,也曾说过类似的话:“null 的使用,让我损失了十亿美元。”鉴于此,我用 Optional 来表示可能为 null 的对象。 - -![](https://cdn.tobebetterjavaer.com/stutymore/guava-20230329172935.png) - - -代码示例如下所示。 - -```java -Optional possible = Optional.of(5); -possible.isPresent(); // returns true -possible.get(); // returns 5 -``` - -我大哥 Java 在 JDK 8 中新增了 [Optional 类](https://tobebetterjavaer.com/java8/optional.html),显然是从我这借鉴过去的,不过他的和我的有些不同。 - -- 我的 Optional 是 abstract 的,意味着我可以有子类对象;我大哥的是 final 的,意味着没有子类对象。 -- 我的 Optional 实现了 Serializable 接口,可以序列化;我大哥的没有。 -- 我的一些方法和我大哥的也不尽相同。 - -使用 Optional 除了赋予 null 语义,增加了可读性,最大的优点在于它是一种傻瓜式的防护。Optional 迫使你积极思考引用缺失的情况,因为你必须显式地从 Optional 获取引用。 - -除了 Optional 之外,我还提供了: - -- 参数校验 -- 常见的 Object 方法,比如说 Objects.equals、Objects.hashCode,JDK 7 引入的 Objects 类提供同样的方法,当然也是从我这借鉴的灵感。 -- 更强大的比较器 - -### 04、集合 - -首先我来说一下,为什么需要不可变集合。 - -- 保证线程安全。在并发程序中,使用不可变集合既保证线程的安全性,也大大地增强了并发时的效率(跟并发锁方式相比)。 -- 如果一个对象不需要支持修改操作,不可变的集合将会节省空间和时间的开销。 -- 可以当作一个常量来对待,并且集合中的对象在以后也不会被改变。 - -与 JDK 中提供的不可变集合相比,我提供的 Immutable 才是真正的不可变,我为什么这么说呢?来看下面这个示例。 - -下面的代码利用 JDK 的 [`Collections.unmodifiableList(list)`](https://tobebetterjavaer.com/common-tool/collections.html) 得到一个不可修改的集合 unmodifiableList。 - -```java -List list = new ArrayList(); -list.add("雷军"); -list.add("乔布斯"); - -List unmodifiableList = Collections.unmodifiableList(list); -unmodifiableList.add("马云"); -``` - -运行代码将会出现以下异常: - -``` -Exception in thread "main" java.lang.UnsupportedOperationException - at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1060) - at com.itwanger.guava.NullTest.main(NullTest.java:29) -``` - -很好,执行 `unmodifiableList.add()` 的时候抛出了 UnsupportedOperationException 异常,说明 `Collections.unmodifiableList()` 返回了一个不可变集合。但真的是这样吗? - -你可以把 `unmodifiableList.add()` 换成 `list.add()`。 - -```java -List list = new ArrayList(); -list.add("雷军"); -list.add("乔布斯"); - -List unmodifiableList = Collections.unmodifiableList(list); -list.add("马云"); -``` - -再次执行的话,程序并没有报错,并且你会发现 unmodifiableList 中真的多了一个元素。说明什么呢? - -`Collections.unmodifiableList(…)` 实现的不是真正的不可变集合,当原始集合被修改后,不可变集合里面的元素也是跟着发生变化。 - -我就不会犯这种错,来看下面的代码。 - -```java -List stringArrayList = Lists.newArrayList("雷军","乔布斯"); -ImmutableList immutableList = ImmutableList.copyOf(stringArrayList); -immutableList.add("马云"); -``` - -尝试 `immutableList.add()` 的时候会抛出 `UnsupportedOperationException`。我在源码中已经把 `add()` 方法废弃了。 - -```java -/** - * Guaranteed to throw an exception and leave the collection unmodified. - * - * @throws UnsupportedOperationException always - * @deprecated Unsupported operation. - */ -@CanIgnoreReturnValue -@Deprecated -@Override -public final boolean add(E e) { - throw new UnsupportedOperationException(); -} -``` - -尝试 `stringArrayList.add()` 修改原集合的时候 immutableList 并不会因此而发生改变。 - -除了不可变集合以外,我还提供了新的集合类型,比如说: - -- Multiset,可以多次添加相等的元素。当把 Multiset 看成普通的 Collection 时,它表现得就像无序的 ArrayList;当把 Multiset 看作 `Map` 时,它也提供了符合性能期望的查询操作。 -- Multimap,可以很容易地把一个键映射到多个值。 -- BiMap,一种特殊的 Map,可以用 `inverse()` 反转 - `BiMap` 的键值映射;保证值是唯一的,因此 `values()` 返回 Set 而不是普通的 Collection。 - -### 05、字符串处理 - -字符串表示字符的不可变序列,创建后就不能更改。在我们日常的工作中,字符串的使用非常频繁,熟练的对其操作可以极大的提升我们的工作效率。 - -我提供了连接器——Joiner,可以用分隔符把字符串序列连接起来。下面的代码将会返回“雷军; 乔布斯”,你可以使用 `useForNull(String)` 方法用某个字符串来替换 null,而不像 `skipNulls()` 方法那样直接忽略 null。 - -```java -Joiner joiner = Joiner.on("; ").skipNulls(); -return joiner.join("雷军", null, "乔布斯"); -``` - -我还提供了拆分器—— Splitter,可以按照指定的分隔符把字符串序列进行拆分。 - -```java -Splitter.on(',') - .trimResults() - .omitEmptyStrings() - .split("雷军,乔布斯,, 沉默王二"); -``` - -### 06、缓存 - -缓存在很多场景下都是相当有用的。你应该知道,检索一个值的代价很高,尤其是需要不止一次获取值的时候,就应当考虑使用缓存。 - -我提供的 Cache 和 ConcurrentMap 很相似,但也不完全一样。最基本的区别是 ConcurrentMap 会一直保存所有添加的元素,直到显式地移除。相对地,我提供的 Cache 为了限制内存占用,通常都设定为自动回收元素。 - -如果你愿意消耗一些内存空间来提升速度,你能预料到某些键会被查询一次以上,缓存中存放的数据总量不会超出内存容量,就可以使用 Cache。 - -来个示例你感受下吧。 - -```java -@Test -public void testCache() throws ExecutionException, InterruptedException { - - CacheLoader cacheLoader = new CacheLoader() { - // 如果找不到元素,会调用这里 - @Override - public Animal load(String s) { - return null; - } - }; - LoadingCache loadingCache = CacheBuilder.newBuilder() - .maximumSize(1000) // 容量 - .expireAfterWrite(3, TimeUnit.SECONDS) // 过期时间 - .removalListener(new MyRemovalListener()) // 失效监听器 - .build(cacheLoader); // - loadingCache.put("狗", new Animal("旺财", 1)); - loadingCache.put("猫", new Animal("汤姆", 3)); - loadingCache.put("狼", new Animal("灰太狼", 4)); - - loadingCache.invalidate("猫"); // 手动失效 - - Animal animal = loadingCache.get("狼"); - System.out.println(animal); - Thread.sleep(4 * 1000); - // 狼已经自动过去,获取为 null 值报错 - System.out.println(loadingCache.get("狼")); -} - -/** - * 缓存移除监听器 - */ -class MyRemovalListener implements RemovalListener { - - @Override - public void onRemoval(RemovalNotification notification) { - String reason = String.format("key=%s,value=%s,reason=%s", notification.getKey(), notification.getValue(), notification.getCause()); - System.out.println(reason); - } -} - -class Animal { - private String name; - private Integer age; - - public Animal(String name, Integer age) { - this.name = name; - this.age = age; - } -} -``` - -CacheLoader 中重写了 load 方法,这个方法会在查询缓存没有命中时被调用,我这里直接返回了 null,其实这样会在没有命中时抛出 CacheLoader returned null for key 异常信息。 - -MyRemovalListener 作为缓存元素失效时的监听类,在有元素缓存失效时会自动调用 onRemoval 方法,这里需要注意的是这个方法是同步方法,如果这里耗时较长,会阻塞直到处理完成。 - -LoadingCache 就是缓存的主要操作对象了,常用的就是其中的 put 和 get 方法了。 - -### 07、集合工具 - -`com.google.common.collect`包下的集合工具:`Lists`也非常强大。 - -#### 创建空集合 - -有时候,我们想创建一个空集合。这时可以用Lists的`newArrayList`方法,例如: -```java -List list = Lists.newArrayList(); -``` - -#### 快速初始化集合 - -有时候,我们想给一个集合中初始化一些元素。这时可以用Lists的newArrayList方法,例如: -```java -List list = Lists.newArrayList(1, 2, 3); -``` -执行结果: -```java -[1, 2, 3] -``` - -#### 笛卡尔积 - -如果你想将两个集合做`笛卡尔积`,Lists的`cartesianProduct`方法可以帮你实现: -```java -List list1 = Lists.newArrayList(1, 2, 3); -List list2 = Lists.newArrayList(4,5); -List> productList = Lists.cartesianProduct(list1,list2); -System.out.println(productList); -``` -执行结果: -```java -[[1, 4], [1, 5], [2, 4], [2, 5], [3, 4], [3, 5]] -``` - -#### 分页 - -如果你想将一个`大集合`分成若干个`小集合`,可以使用Lists的`partition`方法: - -```java -List list = Lists.newArrayList(1, 2, 3, 4, 5); -List> partitionList = Lists.partition(list, 2); -System.out.println(partitionList); -``` - -执行结果: - -```java -[[1, 2], [3, 4], [5]] -``` - -这个例子中,list有5条数据,我将list集合按大小为2,分成了3页,即变成3个小集合。 - -这个是我最喜欢的方法之一,经常在项目中使用。 - -比如有个需求:现在有5000个id,需要调用批量用户查询接口,查出用户数据。但如果你直接查5000个用户,单次接口响应时间可能会非常慢。如果改成分页处理,每次只查500个用户,异步调用10次接口,就不会有单次接口响应慢的问题。 - -#### 流处理 - -如果我们想把某个集合转换成另外一个接口,可以使用Lists的 `transform`方法。例如: - -```java -List list = Lists.newArrayList("a","b","c"); -List transformList = Lists.transform(list, x -> x.toUpperCase()); -System.out.println(transformList); -``` - -将小写字母转换成了大写字母。 - -#### 颠倒顺序 - -Lists的有颠倒顺序的方法`reverse`。例如: - -```java -List list = Lists.newArrayList(3, 1, 2); -List reverseList = Lists.reverse(list); -System.out.println(reverseList); -``` - -执行结果: - -```java -[2, 1, 3] -``` - -list的原始顺序是312,使用`reverse`方法颠倒顺序之后,变成了213。 - -Lists还有其他的好用的工具,我在这里只是抛砖引玉,有兴趣的小伙伴,可以仔细研究一下。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/guava-4b962b06-a626-4707-9fe9-f5729536d9c5.jpg) - -### 08、尾声 - -上面介绍了我认为最常用的功能,作为 Google 公司开源的 Java 开发核心库,个人觉得实用性还是很高的(不然呢?嘿嘿嘿)。引入到你的项目后不仅能快速的实现一些开发中常用的功能,而且还可以让代码更加的优雅简洁。 - -我觉得适用于每一个 Java 项目,至于其他的一些功能,比如说散列、事件总线、数学运算、反射,就等待你去发掘了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/common-tool/guava-03.png) - -## 9.8 其他常用工具类 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[9.8 其他常用工具类](https://tobebetterjavaer.com/common-tool/utils.html) - - - - - -# 第十章:Java新特性 - - - - -## 10.1 掌握Stream流 - -两个星期以前,就有读者强烈要求我写一篇 Java Stream 流的文章,我说市面上不是已经有很多了吗,结果你猜他怎么说:“就想看你写的啊!”你看你看,多么苍白的喜欢啊。那就“勉为其难”写一篇吧,嘻嘻。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/java8/stream-1.jpg) - -单从“Stream”这个单词上来看,它似乎和 java.io 包下的 InputStream 和 OutputStream 有些关系。实际上呢,没毛关系。Java 8 新增的 Stream 是为了解放程序员操作集合(Collection)时的生产力,之所以能解放,很大一部分原因可以归功于同时出现的 Lambda 表达式——极大的提高了编程效率和程序可读性。 - -Stream 究竟是什么呢? - ->Stream 就好像一个高级的迭代器,但只能遍历一次,就好像一江春水向东流;在流的过程中,对流中的元素执行一些操作,比如“过滤掉长度大于 10 的字符串”、“获取每个字符串的首字母”等。 - -要想操作流,首先需要有一个数据源,可以是数组或者集合。每次操作都会返回一个新的流对象,方便进行链式操作,但原有的流对象会保持不变。 - -流的操作可以分为两种类型: - -1)中间操作,可以有多个,每次返回一个新的流,可进行链式操作。 - -2)终端操作,只能有一个,每次执行完,这个流也就用光光了,无法执行下一个操作,因此只能放在最后。 - -来举个例子。 - -```java -List list = new ArrayList<>(); -list.add("武汉加油"); -list.add("中国加油"); -list.add("世界加油"); -list.add("世界加油"); - -long count = list.stream().distinct().count(); -System.out.println(count); -``` - -`distinct()` 方法是一个中间操作(去重),它会返回一个新的流(没有共同元素)。 - -```java -Stream distinct(); -``` - -`count()` 方法是一个终端操作,返回流中的元素个数。 - -```java -long count(); -``` - -中间操作不会立即执行,只有等到终端操作的时候,流才开始真正地遍历,用于映射、过滤等。通俗点说,就是一次遍历执行多个操作,性能就大大提高了。 - -理论部分就扯这么多,下面直接进入实战部分。 - -### 01、创建流 - -如果是数组的话,可以使用 `Arrays.stream()` 或者 `Stream.of()` 创建流;如果是集合的话,可以直接使用 `stream()` 方法创建流,因为该方法已经添加到 Collection 接口中。 - -```java -public class CreateStreamDemo { - public static void main(String[] args) { - String[] arr = new String[]{"武汉加油", "中国加油", "世界加油"}; - Stream stream = Arrays.stream(arr); - - stream = Stream.of("武汉加油", "中国加油", "世界加油"); - - List list = new ArrayList<>(); - list.add("武汉加油"); - list.add("中国加油"); - list.add("世界加油"); - stream = list.stream(); - } -} -``` - -查看 Stream 源码的话,你会发现 `of()` 方法内部其实调用了 `Arrays.stream()` 方法。 - -```java -public static Stream of(T... values) { - return Arrays.stream(values); -} -``` - -另外,集合还可以调用 `parallelStream()` 方法创建并发流,默认使用的是 `ForkJoinPool.commonPool()`线程池。 - -```java -List aList = new ArrayList<>(); -Stream parallelStream = aList.parallelStream(); -``` - -### 02、操作流 - -Stream 类提供了很多有用的操作流的方法,我来挑一些常用的给你介绍一下。 - -#### 1)过滤 - -通过 `filter()` 方法可以从流中筛选出我们想要的元素。 - -```java -public class FilterStreamDemo { - public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("周杰伦"); - list.add("王力宏"); - list.add("陶喆"); - list.add("林俊杰"); - Stream stream = list.stream().filter(element -> element.contains("王")); - stream.forEach(System.out::println); - } -} -``` - -`filter()` 方法接收的是一个 Predicate(Java 8 新增的一个函数式接口,接受一个输入参数返回一个布尔值结果)类型的参数,因此,我们可以直接将一个 Lambda 表达式传递给该方法,比如说 `element -> element.contains("王")` 就是筛选出带有“王”的字符串。 - -`forEach()` 方法接收的是一个 Consumer(Java 8 新增的一个函数式接口,接受一个输入参数并且无返回的操作)类型的参数,`类名 :: 方法名`是 Java 8 引入的新语法,`System.out` 返回 PrintStream 类,println 方法你应该知道是打印的。 - -`stream.forEach(System.out::println);` 相当于在 for 循环中打印,类似于下面的代码: - -```java -for (String s : strs) { - System.out.println(s); -} -``` - -很明显,一行代码看起来更简洁一些。来看一下程序的输出结果: - -``` -王力宏 -``` - -#### 2)映射 - -如果想通过某种操作把一个流中的元素转化成新的流中的元素,可以使用 `map()` 方法。 - -```java -public class MapStreamDemo { - public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("周杰伦"); - list.add("王力宏"); - list.add("陶喆"); - list.add("林俊杰"); - Stream stream = list.stream().map(String::length); - stream.forEach(System.out::println); - } -} -``` - -`map()` 方法接收的是一个 Function(Java 8 新增的一个函数式接口,接受一个输入参数 T,返回一个结果 R)类型的参数,此时参数 为 String 类的 length 方法,也就是把 `Stream` 的流转成一个 `Stream` 的流。 - -程序输出的结果如下所示: - -``` -3 -3 -2 -3 -``` - -#### 3)匹配 - -Stream 类提供了三个方法可供进行元素匹配,它们分别是: - -- `anyMatch()`,只要有一个元素匹配传入的条件,就返回 true。 - -- `allMatch()`,只有有一个元素不匹配传入的条件,就返回 false;如果全部匹配,则返回 true。 - -- `noneMatch()`,只要有一个元素匹配传入的条件,就返回 false;如果全部不匹配,则返回 true。 - -```java -public class MatchStreamDemo { - public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("周杰伦"); - list.add("王力宏"); - list.add("陶喆"); - list.add("林俊杰"); - - boolean anyMatchFlag = list.stream().anyMatch(element -> element.contains("王")); - boolean allMatchFlag = list.stream().allMatch(element -> element.length() > 1); - boolean noneMatchFlag = list.stream().noneMatch(element -> element.endsWith("沉")); - System.out.println(anyMatchFlag); - System.out.println(allMatchFlag); - System.out.println(noneMatchFlag); - } -} -``` - -因为“王力宏”以“王”字开头,所以 anyMatchFlag 应该为 true;因为“周杰伦”、“王力宏”、“陶喆”、“林俊杰”的字符串长度都大于 1,所以 allMatchFlag 为 true;因为 4 个字符串结尾都不是“沉”,所以 noneMatchFlag 为 true。 - -程序输出的结果如下所示: - -``` -true -true -true -``` - -#### 4)组合 - -`reduce()` 方法的主要作用是把 Stream 中的元素组合起来,它有两种用法: - -- `Optional reduce(BinaryOperator accumulator)` - -没有起始值,只有一个参数,就是运算规则,此时返回 [Optional](https://mp.weixin.qq.com/s/PqK0KNVHyoEtZDtp5odocA)。 - -- `T reduce(T identity, BinaryOperator accumulator)` - -有起始值,有运算规则,两个参数,此时返回的类型和起始值类型一致。 - -来看下面这个例子。 - -```java -public class ReduceStreamDemo { - public static void main(String[] args) { - Integer[] ints = {0, 1, 2, 3}; - List list = Arrays.asList(ints); - - Optional optional = list.stream().reduce((a, b) -> a + b); - Optional optional1 = list.stream().reduce(Integer::sum); - System.out.println(optional.orElse(0)); - System.out.println(optional1.orElse(0)); - - int reduce = list.stream().reduce(6, (a, b) -> a + b); - System.out.println(reduce); - int reduce1 = list.stream().reduce(6, Integer::sum); - System.out.println(reduce1); - } -} -``` - -运算规则可以是 [Lambda 表达式](https://mp.weixin.qq.com/s/ozr0jYHIc12WSTmmd_vEjw)(比如 `(a, b) -> a + b`),也可以是类名::方法名(比如 `Integer::sum`)。 - -程序运行的结果如下所示: - -```java -6 -6 -12 -12 -``` - -0、1、2、3 在没有起始值相加的时候结果为 6;有起始值 6 的时候结果为 12。 - -### 03、转换流 - -既然可以把集合或者数组转成流,那么也应该有对应的方法,将流转换回去——`collect()` 方法就满足了这种需求。 - -```java -public class CollectStreamDemo { - public static void main(String[] args) { - List list = new ArrayList<>(); - list.add("周杰伦"); - list.add("王力宏"); - list.add("陶喆"); - list.add("林俊杰"); - - String[] strArray = list.stream().toArray(String[]::new); - System.out.println(Arrays.toString(strArray)); - - List list1 = list.stream().map(String::length).collect(Collectors.toList()); - List list2 = list.stream().collect(Collectors.toCollection(ArrayList::new)); - System.out.println(list1); - System.out.println(list2); - - String str = list.stream().collect(Collectors.joining(", ")).toString(); - System.out.println(str); - } -} -``` - -`toArray()` 方法可以将流转换成数组,你可能比较好奇的是 `String[]::new`,它是什么东东呢?来看一下 `toArray()` 方法的源码。 - -```java - A[] toArray(IntFunction generator); -``` - -也就是说 `String[]::new` 是一个 IntFunction,一个可以产生所需的新数组的函数,可以通过反编译字节码看看它到底是什么: - -```java -String[] strArray = (String[])list.stream().toArray((x$0) -> { - return new String[x$0]; -}); -System.out.println(Arrays.toString(strArray)); -``` - -也就是相当于返回了一个指定长度的字符串数组。 - -当我们需要把一个集合按照某种规则转成另外一个集合的时候,就可以配套使用 `map()` 方法和 `collect()` 方法。 - -```java -List list1 = list.stream().map(String::length).collect(Collectors.toList()); -``` - -通过 `stream()` 方法创建集合的流后,再通过 `map(String:length)` 将其映射为字符串长度的一个新流,最后通过 `collect()` 方法将其转换成新的集合。 - -Collectors 是一个收集器的工具类,内置了一系列收集器实现,比如说 `toList()` 方法将元素收集到一个新的 `java.util.List` 中;比如说 `toCollection()` 方法将元素收集到一个新的 ` java.util.ArrayList` 中;比如说 `joining()` 方法将元素收集到一个可以用分隔符指定的字符串中。 - -来看一下程序的输出结果: - -```java -[周杰伦, 王力宏, 陶喆, 林俊杰] -[3, 3, 2, 3] -[周杰伦, 王力宏, 陶喆, 林俊杰] -周杰伦, 王力宏, 陶喆, 林俊杰 -``` - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/java8/stream-2.jpg) - - - -## 10.2 Optional最佳指南 - -想学习,永远都不晚,尤其是针对 Java 8 里面的好东西,Optional 就是其中之一,该类提供了一种用于表示可选值而非空引用的类级别解决方案。作为一名 Java 程序员,我真的是烦透了 [NullPointerException(NPE)](https://tobebetterjavaer.com/exception/npe.html),尽管和它熟得就像一位老朋友,知道它也是迫不得已——程序正在使用一个对象却发现这个对象的值为 null,于是 Java 虚拟机就怒发冲冠地把它抛了出来当做替罪羊。 - -当然了,我们程序员是富有责任心的,不会坐视不管,于是就有了大量的 null 值检查。尽管有时候这种检查完全没有必要,但我们已经习惯了例行公事。终于,Java 8 看不下去了,就引入了 Optional,以便我们编写的代码不再那么刻薄呆板。 - -![](https://cdn.tobebetterjavaer.com/stutymore/guava-20230329172935.png) - - -### 01、没有 Optional 会有什么问题 - -我们来模拟一个实际的应用场景。小王第一天上班,领导老马就给他安排了一个任务,要他从数据库中根据会员 ID 拉取一个会员的姓名,然后将姓名打印到控制台。虽然是新来的,但这个任务难不倒小王,于是他花了 10 分钟写下了这段代码: - -```java -public class WithoutOptionalDemo { - class Member { - private String name; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - } - - public static void main(String[] args) { - Member mem = getMemberByIdFromDB(); - if (mem != null) { - System.out.println(mem.getName()); - } - } - - public static Member getMemberByIdFromDB() { - // 当前 ID 的会员不存在 - return null; - } -} -``` - -由于当前 ID 的会员不存在,所以 `getMemberByIdFromDB()` 方法返回了 null 来作为没有获取到该会员的结果,那就意味着在打印会员姓名的时候要先对 mem 判空,否则就会抛出 NPE 异常,不信?让小王把 `if (mem != null)` 去掉试试,控制台立马打印错误堆栈给你颜色看看。 - -``` -Exception in thread "main" java.lang.NullPointerException - at com.cmower.dzone.optional.WithoutOptionalDemo.main(WithoutOptionalDemo.java:24) -``` - -### 02、Optional 是如何解决这个问题的 - -小王把代码提交后,就兴高采烈地去找老马要新的任务了。本着虚心学习的态度,小王请求老马看一下自己的代码,于是老王就告诉他应该尝试一下 Optional,可以避免没有必要的 null 值检查。现在,让我们来看看小王是如何通过 Optional 来解决上述问题的。 - -```java -public class OptionalDemo { - public static void main(String[] args) { - Optional optional = getMemberByIdFromDB(); - optional.ifPresent(mem -> { - System.out.println("会员姓名是:" + mem.getName()); - }); - } - - public static Optional getMemberByIdFromDB() { - boolean hasName = true; - if (hasName) { - return Optional.of(new Member("沉默王二")); - } - return Optional.empty(); - } -} -class Member { - private String name; - - public String getName() { - return name; - } - - // getter / setter -} -``` - -`getMemberByIdFromDB()` 方法返回了 `Optional` 作为结果,这样就表明 Member 可能存在,也可能不存在,这时候就可以在 Optional 的 `ifPresent()` 方法中使用 Lambda 表达式来直接打印结果。 - -Optional 之所以可以解决 NPE 的问题,是因为它明确的告诉我们,不需要对它进行判空。它就好像十字路口的路标,明确地告诉你该往哪走。 - -### 03、创建 Optional 对象 - -1)可以使用静态方法 `empty()` 创建一个空的 Optional 对象 - -```java -Optional empty = Optional.empty(); -System.out.println(empty); // 输出:Optional.empty -``` - -2)可以使用静态方法 `of()` 创建一个非空的 Optional 对象 - -```java -Optional opt = Optional.of("沉默王二"); -System.out.println(opt); // 输出:Optional[沉默王二] -``` - -当然了,传递给 `of()` 方法的参数必须是非空的,也就是说不能为 null,否则仍然会抛出 NullPointerException。 - -```java -String name = null; -Optional optnull = Optional.of(name); -``` - -3)可以使用静态方法 `ofNullable()` 创建一个即可空又可非空的 Optional 对象 - -```java -String name = null; -Optional optOrNull = Optional.ofNullable(name); -System.out.println(optOrNull); // 输出:Optional.empty -``` - -`ofNullable()` 方法内部有一个三元表达式,如果为参数为 null,则返回私有常量 EMPTY;否则使用 new 关键字创建了一个新的 Optional 对象——不会再抛出 NPE 异常了。 - -### 04、判断值是否存在 - -可以通过方法 `isPresent()` 判断一个 Optional 对象是否存在,如果存在,该方法返回 true,否则返回 false——取代了 `obj != null` 的判断。 - -```java -Optional opt = Optional.of("沉默王二"); -System.out.println(opt.isPresent()); // 输出:true - -Optional optOrNull = Optional.ofNullable(null); -System.out.println(opt.isPresent()); // 输出:false -``` - -Java 11 后还可以通过方法 `isEmpty()` 判断与 `isPresent()` 相反的结果。 - -```java -Optional opt = Optional.of("沉默王二"); -System.out.println(opt.isPresent()); // 输出:false - -Optional optOrNull = Optional.ofNullable(null); -System.out.println(opt.isPresent()); // 输出:true -``` - -### 05、非空表达式 - -Optional 类有一个非常现代化的方法——`ifPresent()`,允许我们使用函数式编程的方式执行一些代码,因此,我把它称为非空表达式。如果没有该方法的话,我们通常需要先通过 `isPresent()` 方法对 Optional 对象进行判空后再执行相应的代码: - -```java -Optional optOrNull = Optional.ofNullable(null); -if (optOrNull.isPresent()) { - System.out.println(optOrNull.get().length()); -} -``` - -有了 `ifPresent()` 之后,情况就完全不同了,可以直接将 Lambda 表达式传递给该方法,代码更加简洁,更加直观。 - -```java -Optional opt = Optional.of("沉默王二"); -opt.ifPresent(str -> System.out.println(str.length())); -``` - -Java 9 后还可以通过方法 `ifPresentOrElse(action, emptyAction)` 执行两种结果,非空时执行 action,空时执行 emptyAction。 - -```java -Optional opt = Optional.of("沉默王二"); -opt.ifPresentOrElse(str -> System.out.println(str.length()), () -> System.out.println("为空")); -``` - -### 06、设置(获取)默认值 - -有时候,我们在创建(获取) Optional 对象的时候,需要一个默认值,`orElse()` 和 `orElseGet()` 方法就派上用场了。 - -`orElse()` 方法用于返回包裹在 Optional 对象中的值,如果该值不为 null,则返回;否则返回默认值。该方法的参数类型和值得类型一致。 - -```java -String nullName = null; -String name = Optional.ofNullable(nullName).orElse("沉默王二"); -System.out.println(name); // 输出:沉默王二 -``` - -`orElseGet()` 方法与 `orElse()` 方法类似,但参数类型不同。如果 Optional 对象中的值为 null,则执行参数中的函数。 - -```java -String nullName = null; -String name = Optional.ofNullable(nullName).orElseGet(()->"沉默王二"); -System.out.println(name); // 输出:沉默王二 -``` - -从输出结果以及代码的形式上来看,这两个方法极其相似,这不免引起我们的怀疑,Java 类库的设计者有必要这样做吗? - -假设现在有这样一个获取默认值的方法,很传统的方式。 - -```java -public static String getDefaultValue() { - System.out.println("getDefaultValue"); - return "沉默王二"; -} -``` - -然后,通过 `orElse()` 方法和 `orElseGet()` 方法分别调用 `getDefaultValue()` 方法返回默认值。 - -```java -public static void main(String[] args) { - String name = null; - System.out.println("orElse"); - String name2 = Optional.ofNullable(name).orElse(getDefaultValue()); - - System.out.println("orElseGet"); - String name3 = Optional.ofNullable(name).orElseGet(OrElseOptionalDemo::getDefaultValue); -} -``` - -注:`类名 :: 方法名`是 Java 8 引入的语法,方法名后面是没有 `()` 的,表明该方法并不一定会被调用。 - -输出结果如下所示: - -```java -orElse -getDefaultValue - -orElseGet -getDefaultValue -``` - -输出结果是相似的,没什么太大的不同,这是在 Optional 对象的值为 null 的情况下。假如 Optional 对象的值不为 null 呢? - -```java -public static void main(String[] args) { - String name = "沉默王三"; - System.out.println("orElse"); - String name2 = Optional.ofNullable(name).orElse(getDefaultValue()); - - System.out.println("orElseGet"); - String name3 = Optional.ofNullable(name).orElseGet(OrElseOptionalDemo::getDefaultValue); -} -``` - -输出结果如下所示: - -```java -orElse -getDefaultValue -orElseGet -``` - -咦,`orElseGet()` 没有去调用 `getDefaultValue()`。哪个方法的性能更佳,你明白了吧? - -### 07、获取值 - -直观从语义上来看,`get()` 方法才是最正宗的获取 Optional 对象值的方法,但很遗憾,该方法是有缺陷的,因为假如 Optional 对象的值为 null,该方法会抛出 NoSuchElementException 异常。这完全与我们使用 Optional 类的初衷相悖。 - -```java -public class GetOptionalDemo { - public static void main(String[] args) { - String name = null; - Optional optOrNull = Optional.ofNullable(name); - System.out.println(optOrNull.get()); - } -} -``` - -这段程序在运行时会抛出异常: - -``` -Exception in thread "main" java.util.NoSuchElementException: No value present - at java.base/java.util.Optional.get(Optional.java:141) - at com.cmower.dzone.optional.GetOptionalDemo.main(GetOptionalDemo.java:9) -``` - -尽管抛出的异常是 NoSuchElementException 而不是 NPE,但在我们看来,显然是在“五十步笑百步”。建议 `orElseGet()` 方法获取 Optional 对象的值。 - -### 08、过滤值 - -小王通过 Optional 类对之前的代码进行了升级,完成后又兴高采烈地跑去找老马要任务了。老马觉得这小伙子不错,头脑灵活,又干活积极,很值得培养,就又交给了小王一个新的任务:用户注册时对密码的长度进行检查。 - -小王拿到任务后,乐开了花,因为他刚要学习 Optional 类的 `filter()` 方法,这就派上了用场。 - -```java -public class FilterOptionalDemo { - public static void main(String[] args) { - String password = "12345"; - Optional opt = Optional.ofNullable(password); - System.out.println(opt.filter(pwd -> pwd.length() > 6).isPresent()); - } -} -``` - -`filter()` 方法的参数类型为 Predicate(Java 8 新增的一个函数式接口),也就是说可以将一个 Lambda 表达式传递给该方法作为条件,如果表达式的结果为 false,则返回一个 EMPTY 的 Optional 对象,否则返回过滤后的 Optional 对象。 - -在上例中,由于 password 的长度为 5 ,所以程序输出的结果为 false。假设密码的长度要求在 6 到 10 位之间,那么还可以再追加一个条件。来看小王增加难度后的代码。 - - -```java -Predicate len6 = pwd -> pwd.length() > 6; -Predicate len10 = pwd -> pwd.length() < 10; - -password = "1234567"; -opt = Optional.ofNullable(password); -boolean result = opt.filter(len6.and(len10)).isPresent(); -System.out.println(result); -``` - -这次程序输出的结果为 true,因为密码变成了 7 位,在 6 到 10 位之间。想象一下,假如小王使用 if-else 来完成这个任务,代码该有多冗长。 - -### 09、转换值 - -小王检查完了密码的长度,仍然觉得不够尽兴,觉得要对密码的强度也进行检查,比如说密码不能是“password”,这样的密码太弱了。于是他又开始研究起了 `map()` 方法,该方法可以按照一定的规则将原有 Optional 对象转换为一个新的 Optional 对象,原有的 Optional 对象不会更改。 - -先来看小王写的一个简单的例子: - -```java -public class OptionalMapDemo { - public static void main(String[] args) { - String name = "沉默王二"; - Optional nameOptional = Optional.of(name); - Optional intOpt = nameOptional - .map(String::length); - - System.out.println( intOpt.orElse(0)); - } -} -``` - -在上面这个例子中,`map()` 方法的参数 `String::length`,意味着要 将原有的字符串类型的 Optional 按照字符串长度重新生成一个新的 Optional 对象,类型为 Integer。 - -搞清楚了 `map()` 方法的基本用法后,小王决定把 `map()` 方法与 `filter()` 方法结合起来用,前者用于将密码转化为小写,后者用于判断长度以及是否是“password”。 - -```java -public class OptionalMapFilterDemo { - public static void main(String[] args) { - String password = "password"; - Optional opt = Optional.ofNullable(password); - - Predicate len6 = pwd -> pwd.length() > 6; - Predicate len10 = pwd -> pwd.length() < 10; - Predicate eq = pwd -> pwd.equals("password"); - - boolean result = opt.map(String::toLowerCase).filter(len6.and(len10 ).and(eq)).isPresent(); - System.out.println(result); - } -} -``` - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/java8/optional-2.jpg) - -好了,我亲爱的读者朋友,以上就是本文的全部内容了——可以说是史上最佳 Optional 指南了,能看到这里的都是最优秀的程序员,二哥必须要伸出大拇指为你点个赞。 - - - -## 10.3 深入浅出Lambda表达式 - -今天分享的主题是《Lambda 表达式入门》,这也是之前一些读者留言强烈要求我写一写的,不好意思,让你们久等了,现在来满足你们,为时不晚吧? - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/java8/Lambda-1.jpg) - -### 01、初识 Lambda - -Lambda 表达式描述了一个代码块(或者叫匿名方法),可以将其作为参数传递给构造方法或者普通方法以便后续执行。考虑下面这段代码: - -```java -() -> System.out.println("沉默王二") -``` - -来从左到右解释一下,`()` 为 Lambda 表达式的参数列表(本例中没有参数),`->` 标识这串代码为 Lambda 表达式(也就是说,看到 `->` 就知道这是 Lambda),`System.out.println("沉默王二")` 为要执行的代码,即将“沉默王二”打印到标准输出流。 - -有点 Java 基础的同学应该不会对 Runnable 接口感到陌生,这是多线程的一个基础接口,它的定义如下: - -```java -@FunctionalInterface -public interface Runnable -{ - public abstract void run(); -} -``` - -Runnable 接口非常简单,仅有一个抽象方法 `run()`;细心的同学会发现一个陌生的注解 `@FunctionalInterface`,这个注解是什么意思呢? - -我看了它的源码,里面有这样一段注释: - ->Note that instances of functional interfaces can be created with lambda expressions, method references, or constructor references. - -大致的意思就是说,通过 `@FunctionalInterface` 标记的接口可以通过 Lambda 表达式创建实例。具体怎么表现呢? - -原来我们创建一个线程并启动它是这样的: - -```java -public class LamadaTest { - public static void main(String[] args) { - new Thread(new Runnable() { - @Override - public void run() { - System.out.println("沉默王二"); - } - }).start(); - } -} -``` - -通过 Lambda 表达式呢?只需要下面这样: - -```java -public class LamadaTest { - public static void main(String[] args) { - new Thread(() -> System.out.println("沉默王二")).start(); - } -} -``` - -是不是很妙!比起匿名内部类,Lambda 表达式不仅易于理解,更大大简化了必须编写的代码数量。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/java8/Lambda-2.jpg) - -### 02、Lambda 语法 - -每个 Lambda 表达式都遵循以下法则: - -``` -( parameter-list ) -> { expression-or-statements } -``` - -`()` 中的 `parameter-list` 是以逗号分隔的参数。你可以指定参数的类型,也可以不指定(编译器会根据上下文进行推断)。Java 11 后,还可以使用 `var` 关键字作为参数类型,有点 JavaScript 的味道。 - -`->` 相当于 Lambda 的标识符,就好像见到圣旨就见到了皇上。 - -`{}` 中的 `expression-or-statements` 为 Lambda 的主体,可以是一行语句,也可以多行。 - -可以通过 Lambda 表达式干很多事情,比如说 - -1)为变量赋值,示例如下: - -```java -Runnable r = () -> { System.out.println("沉默王二"); }; -r.run(); -``` - -2)作为 return 结果,示例如下: - -```java -static FileFilter getFilter(String ext) -{ - return (pathname) -> pathname.toString().endsWith(ext); -} -``` - -3)作为数组元素,示例如下: - -```java -final PathMatcher matchers[] = -{ - (path) -> path.toString().endsWith("txt"), - (path) -> path.toString().endsWith("java") -}; -``` - -4)作为普通方法或者构造方法的参数,示例如下: - -```java -new Thread(() -> System.out.println("沉默王二")).start(); -``` - -需要注意 Lambda 表达式的作用域范围。 - -```java -public static void main(String[] args) { - - int limit = 10; - Runnable r = () -> { - int limit = 5; - for (int i = 0; i < limit; i++) - System.out.println(i); - }; -} -``` - -上面这段代码在编译的时候会提示错误:变量 limit 已经定义过了。 - -和匿名内部类一样,不要在 Lambda 表达式主体内对方法内的局部变量进行修改,否则编译也不会通过:Lambda 表达式中使用的变量必须是 final 的。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/java8/Lambda-3.jpg) - -这个问题发生的原因是因为 Java 规范中是这样规定的: - ->Any local variable, formal parameter, or exception parameter used but not declared in a lambda expression -must either be declared final or be effectively final [(§4.12.4)](http://docs.oracle.com/javase/specs/jls/se8/html/jls-4.html#jls-4.12.4), -or a compile-time error occurs where the use is attempted. - -大致的意思就是说,Lambda 表达式中要用到的,但又未在 Lambda 表达式中声明的变量,必须声明为 final 或者是 effectively final,否则就会出现编译错误。 - -关于 final 和 effectively final 的区别,可能有些小伙伴不太清楚,这里多说两句。 - -```java -final int a; -a = 1; -// a = 2; -// 由于 a 是 final 的,所以不能被重新赋值 - -int b; -b = 1; -// b 此后再未更改 -// b 就是 effectively final - -int c; -c = 1; -// c 先被赋值为 1,随后又被重新赋值为 2 -c = 2; -// c 就不是 effectively final -``` - -明白了 final 和 effectively final 的区别后,我们了解到,如果把 limit 定义为 final,那就无法在 Lambda 表达式中修改变量的值。那有什么好的解决办法呢?既能让编译器不发出警告,又能修改变量的值。 - -思前想后,试来试去,我终于找到了 3 个可行的解决方案: - -1)把 limit 变量声明为 static。 - -2)把 limit 变量声明为 AtomicInteger。 - -3)使用数组。 - -下面我们来详细地一一介绍下。 - -#### 01)把 limit 变量声明为 static - -要想把 limit 变量声明为 static,就必须将 limit 变量放在 `main()` 方法外部,因为 `main()` 方法本身是 static 的。完整的代码示例如下所示。 - -```java -public class ModifyVariable2StaticInsideLambda { - static int limit = 10; - public static void main(String[] args) { - Runnable r = () -> { - limit = 5; - for (int i = 0; i < limit; i++) { - System.out.println(i); - } - }; - new Thread(r).start(); - } -} -``` - -来看一下程序输出的结果: - -``` -0 -1 -2 -3 -4 -``` - -OK,该方案是可行的。 - -#### 02)把 limit 变量声明为 AtomicInteger - -AtomicInteger 可以确保 int 值的修改是原子性的,可以使用 `set()` 方法设置一个新的 int 值,`get()` 方法获取当前的 int 值。 - -```java -public class ModifyVariable2AtomicInsideLambda { - public static void main(String[] args) { - final AtomicInteger limit = new AtomicInteger(10); - Runnable r = () -> { - limit.set(5); - for (int i = 0; i < limit.get(); i++) { - System.out.println(i); - } - }; - new Thread(r).start(); - } -} -``` - -来看一下程序输出的结果: - -``` -0 -1 -2 -3 -4 -``` - -OK,该方案也是可行的。 - -#### 03)使用数组 - -使用数组的方式略带一些欺骗的性质,在声明数组的时候设置为 final,但更改 int 的值时却修改的是数组的一个元素。 - -```java -public class ModifyVariable2ArrayInsideLambda { - public static void main(String[] args) { - final int [] limits = {10}; - Runnable r = () -> { - limits[0] = 5; - for (int i = 0; i < limits[0]; i++) { - System.out.println(i); - } - }; - new Thread(r).start(); - } -} -``` - -来看一下程序输出的结果: - -``` -0 -1 -2 -3 -4 -``` - -OK,该方案也是可行的。 - -### 03、Lambda 和 this 关键字 - -Lambda 表达式并不会引入新的作用域,这一点和匿名内部类是不同的。也就是说,Lambda 表达式主体内使用的 this 关键字和其所在的类实例相同。 - -来看下面这个示例。 - -```java -public class LamadaTest { - public static void main(String[] args) { - new LamadaTest().work(); - } - - public void work() { - System.out.printf("this = %s%n", this); - - Runnable r = new Runnable() - { - @Override - public void run() - { - System.out.printf("this = %s%n", this); - } - }; - new Thread(r).start(); - new Thread(() -> System.out.printf("this = %s%n", this)).start(); - } -} -``` - -Tips:`%s` 代表当前位置输出字符串,`%n` 代表换行符,也可以使用 `\n` 代替,但 `%n` 是跨平台的。 - -`work()` 方法中的代码可以分为 3 个部分: - -1)单独的 this 关键字 - -```java -System.out.printf("this = %s%n", this); -``` - -其中 this 为 `main()` 方法中通过 new 关键字创建的 LamadaTest 对象——`new LamadaTest()`。 - -2)匿名内部类中的 this 关键字 - -```java -Runnable r = new Runnable() -{ - @Override - public void run() - { - System.out.printf("this = %s%n", this); - } -}; -``` - -其中 this 为 `work()` 方法中通过 new 关键字创建的 Runnable 对象——`new Runnable(){...}`。 - -3)Lambda 表达式中的 this 关键字 - -其中 this 关键字和 1)中的相同。 - -我们来看一下程序的输出结果: - -```java -this = com.cmower.java_demo.journal.LamadaTest@3feba861 -this = com.cmower.java_demo.journal.LamadaTest$1@64f033cb -this = com.cmower.java_demo.journal.LamadaTest@3feba861 -``` - -符合我们分析的预期。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/java8/Lambda-4.jpg) - -### 04、最后 - -尽管 Lambda 表达式在简化 Java 编程方面做了很多令人惊讶的努力,但在某些情况下,不当的使用仍然会导致不必要的混乱,大家伙慎用。 - -好了,我亲爱的读者朋友们,以上就是本文的全部内容了。能在疫情期间坚持看技术文,二哥必须要伸出大拇指为你点个赞👍。原创不易,如果觉得有点用的话,请不要吝啬你手中**点赞**的权力——因为这将是我写作的最强动力。 - - - -## 10.4 Java 14 新特性 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[10.4 Java 14 新特性](https://tobebetterjavaer.com/java8/java14.html) - - -# 第十一章:Java 网络编程 - - -## 11.1 了解网络编程的基础知识 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[11.1 了解网络编程的基础知识](https://tobebetterjavaer.com/socket/network-base.html) - - - -## 11.2 网络编程的核心套接字Socket - -在古代,由于通信不便利,一些聪明的人就利用鸽子会飞且飞得比较快、会辨认方向的优点,对其进行了驯化,用来进行消息的传递——也就是所谓的“飞鸽传书”。而在 Java 中,网络套接字(Socket)扮演了同样的角色。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/socket-a1a012de-33e5-424a-a4cf-bd929cd60200.jpg) - -套接字(Socket)是一个抽象层,应用程序可以通过它发送或接收数据;就像操作文件那样可以打开、读写和关闭。套接字允许应用程序将 I/O 应用于网络中,并与其他应用程序进行通信。网络套接字是 IP 地址与端口的组合。 - -### 01、ping 与 telnet - -“老王啊,能不能帮我看一下这个问题呢,明明本地可以进行网络通信,可等我部署到服务器上时就通信不了了,搞了半天也不知道什么原因,我看代码是没有问题的。”小二的语气中充满了沮丧。 - -“ping 过吗?或者 telnet 了吗?”老王头都没回,冷冰冰地扔出去了这句话。 - -“哦,我去试试。”小二心头掠过一丝愧疚。 - -ping 与 telnet 这两个命令,对调试网络程序有着非常大的帮助。 - -ping,一种计算机网络工具,用来测试数据包能否透过 IP 协议到达特定主机。ping 会向目标主机发出一个 ICMP 的请求回显数据包,并等待接收回显响应数据包。 - -例如,我们 ping 一下博客园。截图如下。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/socket-ce54bbbe-3627-4af4-9f9c-4c041a7ef2a7.jpg) - - -telnet,Internet 远程登录服务的标准协议和主要方式,可以让我们坐在家里的计算机面前,登录到另一台远在天涯海角的远程计算机上。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/socket-f94e1fa5-16d0-4414-9a53-a2aa43b2a512.jpg) - -在 Windows 系统中,telnet 一般是默认安装的,但未激活(可以在控制面板中激活它)。 - -例如,我们 telnet 一下火(shui)土(mu)社区。截图如下。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/socket-9d7051d9-e7de-48a8-9b28-b482d84f56c9.jpg) - -使用 telnet 登录远程计算机时,需要远程计算机上运行一个服务,它一直不停地等待那些希望和它进行连接的网络请求;当接收到一个客户端的网络连接时,它便唤醒正在监听网络连接请求的服务器进程,并为两者建立连接。连接会一直保持,直到某一方中止。 - -不过,需要注意的是,telnet 在格外重视安全的现代网络技术中并不受到重用。因为 telnet 是一个明文传输协议,用户的所有内容(包括用户名和密码)都没有经过加密,安全隐患非常大。 - -### 02、Socket 实例 - -不知道你有没有体验一下 telnet 火土社区的那条命令,结果非常有趣。我们也可以通过 Java 的客户端套接字(Socket)实现,代码示例如下。 - -```java -try (Socket socket = new Socket("bbs.newsmth.net", 23);) { - InputStream is = socket.getInputStream(); - Scanner scanner = new Scanner(is, "gbk"); - - while (scanner.hasNextLine()) { - String line = scanner.nextLine(); - System.out.println(line); - } - -} catch (UnknownHostException e) { - e.printStackTrace(); -} catch (IOException e) { - e.printStackTrace(); -} -``` - -1)建立套接字连接非常简单,只需要一行代码: - -```java -Socket socket = new Socket(host, port) -``` - -host 为主机名,port 为端口号(23 为默认的 telnet 端口号)。如果无法确定主机的 IP 地址,则抛出 `UnknownHostException` 异常;如果在创建套接字时发生 IO 错误,则抛出 `IOException` 异常。 - -需要注意的是,套接字在建立的时候,如果远程主机不可访问,这段代码就会阻塞很长时间,直到底层操作系统的限制而抛出异常。所以一般会在套接字建立后设置一个超时时间。 - -```java -Socket socket = new Socket(...); -socket.setSoTimeout(10000); // 单位为毫秒 -``` - -2)套接字连接成功后,可以通过 `java.net.Socket` 类的 `getInputStream()` 方法获取输入流。有了 `InputStream` 对象后,可以借助文本扫描器类(Scanner)将其中的内容打印出来。 - -```java -InputStream is = socket.getInputStream(); -Scanner scanner = new Scanner(is, "gbk"); - -while (scanner.hasNextLine()) { - String line = scanner.nextLine(); - System.out.println(line); -} -``` - -部分结果(完整结果自己亲手实践一下哦)如下图所示: - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/socket-6b60e4b2-14d0-4d95-bb5c-146bc6a06147.jpg) - - -### 03、ServerSocket 实例 - -接下来,我们模拟一个远程服务,通过 `java.net.ServerSocket` 实现。代码示例如下。 - -```java -try (ServerSocket server = new ServerSocket(8888); - Socket socket = server.accept(); - InputStream is = socket.getInputStream(); - OutputStream os = socket.getOutputStream(); - - Scanner scanner = new Scanner(is)) { - PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "gbk"), true); - pw.println("你好啊,欢迎关注「沉默王二」 公众号,回复关键字「2048」 领取程序员进阶必读资料包"); - - boolean done = false; - while (!done && scanner.hasNextLine()) { - String line = scanner.nextLine(); - System.out.println(line); - - if ("2048".equals(line)) { - done = true; - } - } -} catch (UnknownHostException e) { - e.printStackTrace(); -} catch (IOException e) { - e.printStackTrace(); -} -``` - -1)建立服务器端的套接字也比较简单,只需要指定一个能够独占的端口号就可以了(0~1023 这些端口都已经被系统预留了)。 - -```java -ServerSocket server = new ServerSocket(8888); -``` - -2)调用 ServerSocket 对象的 `accept()` 等待客户端套接字的连接请求。一旦监听到客户端的套接字请求,就会返回一个表示连接已建立的 Socket 对象,可以从中获取到输入流和输出流。 - -```java -Socket socket = server.accept(); -InputStream is = socket.getInputStream(); -OutputStream os = socket.getOutputStream(); -``` - -客户端套接字发送的所有信息都会包裹在服务器端套接字的输入流中;而服务器端套接字发送的所有信息都会包裹在客户端套接字的输出流中。 - -3)服务器端可以通过以下代码向客户端发送消息。 - -```java -PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "gbk"), true); -pw.println("你好啊,欢迎关注「沉默王二」 公众号,回复关键字「2048」 领取程序员进阶必读资料包"); -``` - -4)服务器端可以通过以下代码读取客户端发送过来的消息。 - -```java -Scanner scanner = new Scanner(is); -boolean done = false; -while (!done && scanner.hasNextLine()) { - String line = scanner.nextLine(); - System.out.println(line); - - if ("2048".equals(line)) { - done = true; - } -} -``` - - -运行该服务后,可以通过 `telnet localhost 8888` 命令连接该远程服务,不出所料,你将会看到以下信息。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/socket-fac025fa-8cbf-448d-8a6d-cd8993ba87f4.jpg) - -PS:可以在当前命令窗口中输入 2048,服务端收到该消息后会中断该套接字连接(当前窗口会显示“遗失对主机的连接”)。 - -### 04、为多个客户端服务 - -非常遗憾的是,上面的例子中,服务器端只能为一个客户端服务——这不符合服务器端一对多的要求。 - -优化方案也非常简单(你应该也能想得到):服务器端接收到客户端的套接字请求时,可以启动一个线程来处理,而主程序继续等待下一个连接。代码示例如下。 - -```java -try (ServerSocket server = new ServerSocket(8888)) { - - while (true) { - Socket socket = server.accept(); - Thread thread = new Thread(new Runnable() { - - @Override - public void run() { - // 套接字处理程序 - } - }); - thread.start(); - - } -} catch (IOException e) { - e.printStackTrace(); -} -``` - -线程内部(`run(){}` 方法里)用来处理套接字,代码示例如下: - -```java -try { - InputStream is = socket.getInputStream(); - OutputStream os = socket.getOutputStream(); - Scanner scanner = new Scanner(is); - - // 其他代码省略 - // 向客户端发送消息 - // 读取客户端发送过来的消息 -} catch (IOException e) { - e.printStackTrace(); -} finally { - try { - socket.close(); - } catch (IOException e) { - e.printStackTrace(); - } -} -``` - -服务器端代码优化后重新运行,你就可以通过 telnet 命令测试了。打开一个命令行窗口输入 `telnet localhost 8888`,再打开一个新的命令行窗口输入 `telnet localhost 8888`,多个窗口都可以和服务器端进行通信,除非服务器端代码中断运行。 - -### 05、加入多线程 - -[多线程](https://tobebetterjavaer.com/thread/wangzhe-thread.html)我们后面会详细讲,这里就主要是写个例子,好让大家感觉更有趣一些,其实也非常简单。 - -来看服务端: - -```java -import java.io.*; -import java.net.ServerSocket; -import java.net.Socket; - -public class MultiThreadedServer { - public static void main(String[] args) throws IOException { - int port = 12345; - ServerSocket serverSocket = new ServerSocket(port); - System.out.println("Server is listening on port " + port); - - while (true) { - Socket socket = serverSocket.accept(); - System.out.println("Client connected"); - new ClientHandler(socket).start(); - } - } -} -class ClientHandler extends Thread { - private Socket socket; - - public ClientHandler(Socket socket) { - this.socket = socket; - } - - public void run() { - try { - InputStream input = socket.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(input)); - - OutputStream output = socket.getOutputStream(); - PrintWriter writer = new PrintWriter(output, true); - - String line; - while ((line = reader.readLine()) != null) { - System.out.println("Received: " + line); - writer.println("Server: " + line); - } - - socket.close(); - } catch (IOException e) { - System.out.println("Client disconnected"); - } - } -} -``` - -在这个示例中,我们使用了一个 ClientHandler 类,该类继承自 Thread 类。这使得每个客户端连接都可以在单独的线程中处理,从而允许服务器同时处理多个客户端连接。当一个新客户端连接到服务器时,服务器会创建一个新的 ClientHandler 对象,并使用 `start()` 方法启动线程。ClientHandler 类的 `run()` 方法包含处理客户端请求的逻辑。 - -来看客户端代码: - -```java -import java.io.*; -import java.net.Socket; - -public class Client { - public static void main(String[] args) throws IOException { - String hostname = "localhost"; - int port = 12345; - - Socket socket = new Socket(hostname, port); - System.out.println("Connected to the server"); - - InputStream input = socket.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(input)); - - OutputStream output = socket.getOutputStream(); - PrintWriter writer = new PrintWriter(output, true); - - writer.println("Hello, server!"); - String response = reader.readLine(); - System.out.println("Server response: " + response); - - socket.close(); - } -} -``` - -启动服务器端,然后多启动几个客户端,就可以体验到交互的乐趣了。 - -![](https://cdn.tobebetterjavaer.com/stutymore/socket-20230330192826.png) - -### 08、DatagramSocket 实例 - -DatagramSocket 类是 Java 中实现 UDP 协议的核心类。与基于 TCP 的 Socket 和 ServerSocket 类不同,DatagramSocket 类提供了无连接的通信服务,发送和接收数据包。由于无需建立连接,UDP 通常比 TCP 更快,但可能不如 TCP 可靠。 - -以下是一个简单的 DatagramSocket 示例,展示了如何使用 UDP 协议在客户端和服务器之间发送和接收消息。 - -服务器端代码: - -```java -import java.io.IOException; -import java.net.DatagramPacket; -import java.net.DatagramSocket; - -public class UDPServer { - public static void main(String[] args) throws IOException { - int port = 12345; - DatagramSocket serverSocket = new DatagramSocket(port); - System.out.println("Server is listening on port " + port); - - byte[] buffer = new byte[1024]; - DatagramPacket packet = new DatagramPacket(buffer, buffer.length); - - serverSocket.receive(packet); - String message = new String(packet.getData(), 0, packet.getLength()); - System.out.println("Received: " + message); - - serverSocket.close(); - } -} -``` - -客户端代码: - -```java -import java.io.IOException; -import java.net.*; - -public class UDPClient { - public static void main(String[] args) throws IOException { - String hostname = "localhost"; - int port = 12345; - - InetAddress address = InetAddress.getByName(hostname); - DatagramSocket clientSocket = new DatagramSocket(); - - String message = "Hello, server!"; - byte[] buffer = message.getBytes(); - - DatagramPacket packet = new DatagramPacket(buffer, buffer.length, address, port); - clientSocket.send(packet); - System.out.println("Message sent"); - - clientSocket.close(); - } -} -``` - -在这个示例中,服务器端创建一个 DatagramSocket 对象并监听端口 12345。然后,它创建一个 DatagramPacket 对象,用于存储接收到的数据包。`serverSocket.receive(packet)` 方法阻塞,直到收到一个数据包。收到数据包后,服务器从数据包中提取并打印消息。 - -客户端首先解析服务器的 IP 地址,然后创建一个 DatagramSocket 对象。接着,客户端创建一个包含要发送消息的 DatagramPacket 对象,并指定目标地址和端口。最后,客户端通过调用 `clientSocket.send(packet)` 方法发送数据包。 - -运行结果如下所示: - -![](https://cdn.tobebetterjavaer.com/stutymore/socket-20230330193654.png) - -### 07、最后 - -如今大多数基于网络的软件,如浏览器、即时通讯工具甚至是 P2P 下载都是基于 Socket 实现的,所以掌握 Java Socket 编程还是蛮有必要的。Socket 编程也比较有趣,很多初学者都会编写一两个基于“客户端-服务器端”的小程序来提高自己的编程水平,建议你也试一试。 - - - -## 11.3 用Socket实现一个HTTP服务器 - -作为一个 Java 后端,提供 HTTP 服务可以说是基本技能之一了,但是你真的了解 HTTP 协议么?你知道知道如何手撸一个 HTTP 服务器么?Tomcat 的底层是怎么支持 HTTP 服务的呢?大名鼎鼎的 Servlet 又是什么东西呢,该怎么使用呢? - -在初学 Java 时,Socket 编程是逃不掉的一章;虽然在实际业务项目中,使用这个的可能性基本为 0, 但并不意味着不用学。本篇将主要介绍如何使用 Socket 来实现一个简单的 HTTP 服务器,提供常见的 get/post 请求支持,并在此过程中了解下 HTTP 协议。 - -### I. HTTP 服务器从 0 到 1 - -既然我们的目标是借助 Socket 来搭建 HTTP 服务器,那么我们首先需要确认两点,一是如何使用 Socket;另一个则是 HTTP 协议如何解析数据;下面分别进行说明。 - -#### 1\. Socket 编程基础 - -我们这里主要是利用 [ServerSocket](https://tobebetterjavaer.com/socket/socket.html) 来绑定端口,提供 TCP 服务,基本使用姿势也比较简单,一般套路如下 - -- 创建 ServerSocket 对象,绑定监听端口 -- 通过 `accept()` 方法监听客户端请求 -- 连接建立后,通过输入流读取客户端发送的请求信息 -- 通过输出流向客户端发送响应信息 -- 关闭相关资源 - -对应的伪代码如下: - -```java -ServerSocket serverSocket = new ServerSocket(port, ip) -serverSocket.accept(); -// 接收请求数据 -socket.getInputStream(); - -// 返回数据给请求方 -out = socket.getOutputStream() -out.print(xxx) -out.flush();; - -// 关闭连接 -socket.close() -``` - -具体的代码,我们前面的章节详细地讲过了,第一次来的小伙伴可以[戳链接](https://tobebetterjavaer.com/socket/socket.html)去学习一下。 - -#### 2\. HTTP 协议 - -ServerSocket 走的是 [TCP 协议](https://tobebetterjavaer.com/socket/network-base.html),HTTP 协议本身是在 TCP 协议之上的一层。 - -TCP 是一种面向连接的、可靠的、基于字节流的传输层协议。TCP 在两个网络节点之间提供了一条可靠的通信信道,确保数据在传输过程中不会丢失、重复或乱序。TCP 使用握手过程建立连接,通过确认和重传机制确保数据可靠传输,并使用流量控制和拥塞控制算法来优化网络性能。 - -HTTP 是一个用于在 Web 浏览器和 Web 服务器之间传输超文本、图像、视频和其他媒体资源的应用层协议。HTTP 使用请求-响应模型,即客户端(通常是 Web 浏览器)发送请求给服务器,服务器处理请求并返回响应。HTTP 协议定义了一组方法(如 GET、POST、PUT、DELETE 等),用于指定请求的类型和目的。此外,HTTP 协议还定义了一组状态代码(如 200、404、500 等),用于表示响应的结果。 - -HTTP 协议依赖于 TCP 协议来传输数据。当 Web 浏览器向 Web 服务器发送 HTTP 请求时,它首先使用 TCP 协议与服务器建立连接。一旦连接建立,HTTP 请求消息会被封装在 TCP 数据包中,然后通过 TCP 信道发送给服务器。服务器收到 TCP 数据包后,解包提取 HTTP 请求消息,处理请求并生成 HTTP 响应消息。最后,HTTP 响应消息被封装在 TCP 数据包中,并通过相同的 TCP 信道发送回客户端。客户端收到 TCP 数据包后,解包提取 HTTP 响应消息并显示给用户。 - -![](https://cdn.tobebetterjavaer.com/stutymore/http-20230331112928.png) - -这幅图展示了客户端(Web 浏览器)与服务器(Web 服务器)之间的 HTTP 请求和响应,它们通过可靠的、面向连接的 TCP 连接进行数据传输。 - -好,再说回 HTTP 服务器这件事,最需要关注的无非两点: - -- 请求的数据怎么按照 HTTP 协议解析出来 -- 如何按照 HTTP 协议,返回数据 - -所以我们需要知道数据格式的规范。 - -**请求消息** - -HTTP 请求消息由请求行(Request Line)、请求头(Request Headers)、空行(Empty Line)、请求体(Request Body,可选)几个部分组成。 - -①、请求行又包含三个部分,HTTP 方法(例如 GET, POST, PUT, DELETE 等)、请求的目标 URL(通常是相对 URL,但也可以是绝对 URL)、HTTP 版本(例如 HTTP/1.1 或 HTTP/2),这些部分用空格分隔,例如: - -``` -GET /index.html HTTP/1.1 -``` - -②、请求头是一系列以键值对表示的元数据,用于描述请求的附加信息。每个请求头占一行,键和值之间用冒号(:)分隔。请求头包含诸如 Host、User-Agent、Content-Type、Content-Length、Accept 等信息。例如: - -``` -Host: www.tobebetterjavaer.com -User-Agent: Mozilla/5.0 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 -``` - -③、请求头和请求体之间有一个空行,表示请求头的结束。 - -④、对于某些 HTTP 方法(例如 POST、PUT 等),还可以在请求消息中包含请求体。请求体用于传输要发送给服务器的数据。请求体的格式和内容取决于 Content-Type 请求头的值。 - -例如,当提交 HTML 表单时,请求体可能如下所示: - -``` -username=沉默王二&password=123456 -``` - -将这些部分放在一起,就构成了一个完整的 HTTP 请求消息: - -``` -POST /login HTTP/1.1 -Host: Host: www.tobebetterjavaer.com -User-Agent: Mozilla/5.0 -Content-Type: application/x-www-form-urlencoded -Content-Length: 29 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 - -username=沉默王二&password=123456 -``` - -我用一张思维导图来表示下: - -![](https://cdn.tobebetterjavaer.com/stutymore/http-20230331114404.png) - -**响应消息** - -一个典型的 HTTP 响应消息由三部分组成:状态行(Status Line)、响应头(Response Headers)、响应体(Response Body)。 - -![](https://cdn.tobebetterjavaer.com/stutymore/http-20230331120336.png) - -上面两张图,可以让你对 HTTP 请求和响应有个直观映象,接下来开始抓重点。 - -不管是请求消息还是响应消息,都可以划分为三部分,这就为我们后面的处理简化了很多工作。 - -- 第一行:状态行 -- 第二行到第一个空行:header(请求头/相应头) -- 剩下所有:正文 - -#### 3\. HTTP 服务器设计 - -接下来进入正题,基于 Socket 创建一个 HTTP 服务器,使用 Socket 基本没啥太大的问题,我们需要额外关注以下两点: - -- 对请求数据进行解析 -- 封装返回结果 - -##### a. 请求数据解析 - -我们从 Socket 中拿到所有的数据,然后解析为对应的 HTTP 请求,我们先定义个 Request 对象,内部保存一些基本的 HTTP 信息,接下来重点就是将 Socket 中的所有数据都捞出来,封装为 request 对象。 - ->注意📢,这些代码放在 HttpMessageParser 类中,随后会给出完整的代码。 - -```java -@Data -public static class Request { - /** - * 请求方法 GET/POST/PUT/DELETE/OPTION... - */ - private String method; - /** - * 请求的uri - */ - private String uri; - /** - * HTTP版本 - */ - private String version; - - /** - * 请求头 - */ - private Map headers; - - /** - * 请求参数相关 - */ - private String message; -} -``` - -根据前面的 HTTP 协议介绍,解析过程如下,我们先看请求行的解析过程。 - -**请求行**,包含三个基本要素:请求方法 + URI + HTTP 版本,用空格进行分割,所以解析代码如下 - -```java -/** - * 根据标准的HTTP协议,解析请求行 - * - * @param reader - * @param request - */ -private static void decodeRequestLine(BufferedReader reader, Request request) throws IOException { - String[] strs = StringUtils.split(reader.readLine(), " "); - assert strs.length == 3; - request.setMethod(strs[0]); - request.setUri(strs[1]); - request.setVersion(strs[2]); -} -``` - -**请求头的解析**,从第二行,到第一个空白行之间的所有数据,都是请求头;请求头的格式也比较清晰,形如 `key:value`, 具体实现如下: - -```java -/** - * 根据标准 HTTP 协议,解析请求头 - * - * @param reader 读取请求头的 BufferedReader 对象 - * @param request 存储请求信息的 Request 对象 - * @throws IOException 当读取请求头信息时发生 I/O 异常时,将抛出该异常 - */ -private static void decodeRequestHeader(BufferedReader reader, Request request) throws IOException { - // 创建一个 Map 对象,用于存储请求头信息 - Map headers = new HashMap<>(16); - // 读取请求头信息,每行都是一个键值对,以空行结束 - String line = reader.readLine(); - String[] kv; - while (!"".equals(line)) { - // 将每行请求头信息按冒号分隔,分别作为键和值存入 Map 中 - kv = StringUtils.split(line, ":"); - assert kv.length == 2; - headers.put(kv[0].trim(), kv[1].trim()); - line = reader.readLine(); - } - // 将解析出来的请求头信息存入 Request 对象中 - request.setHeaders(headers); -} -``` - -**最后就是正文的解析了**,这一块需要注意一点,正文可能为空,也可能有数据;有数据时,我们要如何把所有的数据都取出来呢? - -先看具体实现如下: - -```java -/** - * 根据标注HTTP协议,解析正文 - * - * @param reader 输入流读取器,用于读取请求中的数据 - * @param request Request 对象,表示 HTTP 请求 - * @throws IOException 当发生 I/O 错误时抛出 - */ -private static void decodeRequestMessage(BufferedReader reader, Request request) throws IOException { - // 从请求头中获取 Content-Length,如果没有,则默认为 0 - int contentLen = Integer.parseInt(request.getHeaders().getOrDefault("Content-Length", "0")); - - // 如果 Content-Length 为 0,表示没有请求正文,直接返回。 - // 例如 GET 和 OPTIONS 请求通常不包含请求正文 - if (contentLen == 0) { - return; - } - - // 根据 Content-Length 创建一个字符数组来存储请求正文 - char[] message = new char[contentLen]; - - // 使用 BufferedReader 读取请求正文 - reader.read(message); - - // 将字符数组转换为字符串,并将其设置为 Request 对象的 message - request.setMessage(new String(message)); -} -``` - -注意上面我的使用姿势,首先是根据请求头中的`Content-Type`的值,来获得正文的数据大小,因此我们获取的方式是创建一个这么大的`char[]` 数组来读取流中所有数据,如果我们的数组比实际的小,则读不完;如果大,则数组中会有一些空的数据; - -**最后将上面的几个解析封装一下**,完成 request 解析: - -```java -/** - * HTTP 请求可以分为三部分: - * 1. 请求行:包括请求方法、URI 和 HTTP 协议版本 - * 2. 请求头:从第二行开始,直到一个空行为止 - * 3. 消息正文:紧跟在空行后的所有内容,长度由请求头中的 Content-Length 决定 - * - * 本方法将 InputStream 中的 HTTP 请求数据解析为一个 Request 对象 - * - * @param reqStream 包含 HTTP 请求数据的输入流 - * @return 一个表示 HTTP 请求的 Request 对象 - * @throws IOException 当发生 I/O 错误时抛出 - */ -public static Request parse2request(InputStream reqStream) throws IOException { - // 使用 BufferedReader 和 InputStreamReader 读取输入流中的数据 - BufferedReader httpReader = new BufferedReader(new InputStreamReader(reqStream, "UTF-8")); - - // 创建一个新的 Request 对象 - Request httpRequest = new Request(); - - // 解析请求行并设置到 Request 对象中 - decodeRequestLine(httpReader, httpRequest); - - // 解析请求头并设置到 Request 对象中 - decodeRequestHeader(httpReader, httpRequest); - - // 解析消息正文并设置到 Request 对象中 - decodeRequestMessage(httpReader, httpRequest); - - // 返回解析后的 Request 对象 - return httpRequest; -} -``` - -接下来,是请求结果的封装,给一个简单的进行演示: - -```java -/** - * Response 类表示一个 HTTP 响应,包括版本、状态码、状态信息、响应头和响应正文。 - */ -@Data -public static class Response { - private String version; - private int code; - private String status; - private Map headers; - private String message; -} - -/** - * 根据给定的 Request 对象和响应字符串构建一个 HTTP 响应。 - * - * @param request 用于构建响应的 Request 对象 - * @param response 响应字符串 - * @return 一个表示 HTTP 响应的字符串 - */ -public static String buildResponse(Request request, String response) { - // 创建一个新的 Response 对象,并设置版本、状态码和状态信息 - Response httpResponse = new Response(); - httpResponse.setCode(200); - httpResponse.setStatus("ok"); - httpResponse.setVersion(request.getVersion()); - - // 设置响应头 - Map headers = new HashMap<>(); - headers.put("Content-Type", "application/json"); - headers.put("Content-Length", String.valueOf(response.getBytes().length)); - httpResponse.setHeaders(headers); - - // 设置响应正文 - httpResponse.setMessage(response); - - // 构建响应字符串 - StringBuilder builder = new StringBuilder(); - buildResponseLine(httpResponse, builder); - buildResponseHeaders(httpResponse, builder); - buildResponseMessage(httpResponse, builder); - return builder.toString(); -} - -/** - * 构建响应行,包括版本、状态码和状态信息。 - * - * @param response 用于构建响应行的 Response 对象 - * @param stringBuilder 用于拼接响应字符串的 StringBuilder 对象 - */ -private static void buildResponseLine(Response response, StringBuilder stringBuilder) { - stringBuilder.append(response.getVersion()).append(" ").append(response.getCode()).append(" ") - .append(response.getStatus()).append("\n"); -} - -/** - * 构建响应头。 - * - * @param response 用于构建响应头的 Response 对象 - * @param stringBuilder 用于拼接响应字符串的 StringBuilder 对象 - */ -private static void buildResponseHeaders(Response response, StringBuilder stringBuilder) { - for (Map.Entry entry : response.getHeaders().entrySet()) { - stringBuilder.append(entry.getKey()).append(":").append(entry.getValue()).append("\n"); - } - stringBuilder.append("\n"); -} - -/** - * 构建响应正文。 - * - * @param response 用于构建响应正文的 Response 对象 - * @param stringBuilder 用于拼接响应字符串的 StringBuilder 对象 - */ -private static void buildResponseMessage(Response response, StringBuilder stringBuilder) { - stringBuilder.append(response.getMessage()); -} -``` - -##### b. 请求任务 HttpTask - -每个请求,单独分配一个任务来干这个事情,就是为了支持并发,对于 ServerSocket 而言,接收到了一个请求,那就创建一个 HttpTask 任务来实现 HTTP 通信。 - -那么这个 httptask 干啥呢? - -- 从请求中捞数据 -- 响应请求 -- 封装结果并返回 - -```java -/** - * HttpTask 类实现了 Runnable 接口,用于处理一个 HTTP 请求。 - * 当在一个线程中执行时,该任务将处理一个 Socket 连接上的 HTTP 请求, - * 并发送响应消息。 - */ -public class HttpTask implements Runnable { - // 用于处理 HTTP 请求的 Socket - private Socket socket; - - /** - * 构造一个新的 HttpTask,用于处理指定的 Socket 连接。 - * - * @param socket 用于处理 HTTP 请求的 Socket - */ - public HttpTask(Socket socket) { - this.socket = socket; - } - - /** - * 实现 Runnable 接口的 run 方法,用于处理 HTTP 请求并发送响应消息。 - */ - @Override - public void run() { - // 检查 socket 是否为 null,如果为 null 则抛出异常 - if (socket == null) { - throw new IllegalArgumentException("socket can't be null."); - } - - try { - // 获取 Socket 的输出流,并创建一个 PrintWriter 对象 - OutputStream outputStream = socket.getOutputStream(); - PrintWriter out = new PrintWriter(outputStream); - - // 从 Socket 的输入流中解析 HTTP 请求 - HttpMessageParser.Request httpRequest = HttpMessageParser.parse2request(socket.getInputStream()); - - try { - // 根据请求结果进行响应,省略返回 - String result = null; - - // 根据请求和结果构建 HTTP 响应 - String httpRes = HttpMessageParser.buildResponse(httpRequest, result); - - // 将 HTTP 响应发送到客户端 - out.print(httpRes); - } catch (Exception e) { - // 如果发生异常,构建一个包含异常信息的 HTTP 响应 - String httpRes = HttpMessageParser.buildResponse(httpRequest, e.toString()); - out.print(httpRes); - } - - // 刷新输出流,确保响应消息被发送 - out.flush(); - } catch (IOException e) { - e.printStackTrace(); - } finally { - // 关闭 Socket 连接 - try { - socket.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - } -} -``` - -##### c. HTTP 服务搭建 - -前面的基本上把该干的事情都干了,剩下的就简单了,创建`ServerSocket`,绑定端口接收请求,我们在线程池中跑这个 HTTP 服务 - -```java -public class BasicHttpServer { - // 创建一个单线程执行器,用于启动 HTTP 服务器 - private static ExecutorService bootstrapExecutor = Executors.newSingleThreadExecutor(); - // 创建一个线程池,用于处理来自客户端的 HTTP 请求 - private static ExecutorService taskExecutor; - // 设置服务器监听的端口号 - private static int PORT = 8999; - - // 启动 HTTP 服务器的方法 - static void startHttpServer() { - // 获取处理器可用核心数,用于设置线程池大小 - int nThreads = Runtime.getRuntime().availableProcessors(); - // 初始化线程池,设置线程池大小,队列大小和丢弃策略 - taskExecutor = - new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100), - new ThreadPoolExecutor.DiscardPolicy()); - - // 循环尝试启动服务器,如果启动失败,则等待10秒后重试 - while (true) { - try { - ServerSocket serverSocket = new ServerSocket(PORT); - bootstrapExecutor.submit(new ServerThread(serverSocket)); - break; - } catch (Exception e) { - try { - // 重试,等待 10 秒 - TimeUnit.SECONDS.sleep(10); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } - } - } - - // 关闭启动执行器 - bootstrapExecutor.shutdown(); - } - - // HTTP 服务器主要任务类 - private static class ServerThread implements Runnable { - // 保存传递给构造函数的 ServerSocket 实例 - private ServerSocket serverSocket; - - // 构造函数 - public ServerThread(ServerSocket s) throws IOException { - this.serverSocket = s; - } - - // 任务主体方法 - @Override - public void run() { - while (true) { - try { - // 等待客户端连接 - Socket socket = this.serverSocket.accept(); - // 创建一个 HttpTask 实例,将 Socket 实例作为参数传递 - HttpTask eventTask = new HttpTask(socket); - // 将 HttpTask 提交给 taskExecutor 执行 - taskExecutor.submit(eventTask); - } catch (Exception e) { - e.printStackTrace(); - try { - // 如果发生异常,等待 1 秒后继续尝试 - TimeUnit.SECONDS.sleep(1); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } - } - } - } - } -} -``` - -这段代码是一个简单的 HTTP 服务器实现。以下是关于这个 HTTP 服务器的主要组件和功能的详细解释: - -1、bootstrapExecutor:一个单线程的 ExecutorService,用于执行 HTTP 服务器的启动任务。 - -2、taskExecutor:一个线程池,用于处理来自客户端的 HTTP 请求。线程池的大小等于处理器可用核心数,队列大小为100,使用 DiscardPolicy 丢弃策略。 - -3、PORT:服务器侦听的端口号,默认为 8999。 - -4、startHttpServer() 方法: - - - a.创建一个线程池 taskExecutor 用于处理 HTTP 请求。 - - b.在一个循环中,尝试创建一个 ServerSocket 实例并绑定到指定端口。如果失败,则等待 10 秒后重试。 - - c.当成功创建 ServerSocket 实例后,将其作为参数提交给 bootstrapExecutor 执行 ServerThread 任务。 - - d.关闭 bootstrapExecutor。 - -5、ServerThread 类实现了 Runnable 接口,它是 HTTP 服务器的主要任务: - - a.serverSocket 成员变量:保存传递给构造函数的 ServerSocket 实例。 - - b.run() 方法: - - 在一个无限循环中,调用 serverSocket.accept() 方法等待客户端的连接。 - - 当接受到一个新的客户端连接时,创建一个 HttpTask 实例,将 Socket 实例作为参数传递。 - - 将 HttpTask 提交给 taskExecutor 执行。 - -这个 HTTP 服务器的主要逻辑是:使用一个线程来监听客户端连接,当有新的客户端连接时,创建一个 HttpTask 来处理客户端的 HTTP 请求,并将这个任务提交给线程池 taskExecutor 执行。这样可以实现多个客户端请求的并发处理。 - -到这里,一个基于 Socket 实现的 HTTP 服务器基本上就搭建完了,接下来就可以进行测试了 - -#### 4\. 测试 - -做这个服务器,主要是基于项目 [quick-fix](https://github.com/liuyueyi/quick-fix) 产生的,这个项目主要是为了解决应用内部服务访问与数据订正,我们在这个项目的基础上进行测试。 - -一个完整的 post 请求如下 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/http-f314ade3-9006-4caa-b905-5726121826c4.gif) - -接下来我们看下打印出返回头的情况 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/socket/http-59db6211-792a-494f-b01a-9d5848eceed1.gif) - -### II. 其他 - -#### 0\. 项目源码 - -- [quick-fix](https://github.com/liuyueyi/quick-fix) -- 相关代码: -- com.git.hui.fix.core.endpoint.BasicHttpServer -- com.git.hui.fix.core.endpoint.HttpMessageParser -- com.git.hui.fix.core.endpoint.HttpTask - ->作者:一灰,整理:沉默王二,团队,技术派 - ---- - -最近整理了一份牛逼的学习资料,包括但不限于 Java 基础部分(JVM、Java 集合框架、多线程),还囊括了 **数据库、计算机网络、算法与数据结构、设计模式、框架类 Spring、Netty、微服务(Dubbo,消息队列) 网关** 等等等等……详情戳:[可以说是 2022 年全网最全的学习和找工作的 PDF 资源了](https://tobebetterjavaer.com/pdf/programmer-111.html) - -微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - - - -# 第十二章:Java NIO - - - - -## 12.1 NIO比IO强在哪? - -我花了几天时间去了解**NIO 的核心知识**,期间看了《Java 编程思想》和《疯狂 Java 讲义》中的 NIO 模块。**但是**,看完之后还是很**迷**,不知道 NIO 是干嘛用的,网上的资料和书上的知识点没有很好地对应上。 - -我这里先给大家展示一副传统 IO 和 NIO 的对比图,感受一下。 - -![](https://cdn.tobebetterjavaer.com/stutymore/nio-better-io-20230406180538.png) - -[传统 IO](https://tobebetterjavaer.com/io/shangtou.html) 基于字节流或字符流(如 FileInputStream、BufferedReader 等)进行文件读写,以及使用 [Socket 和 ServerSocket](https://tobebetterjavaer.com/socket/socket.html) 进行网络传输。 - -NIO 使用[通道(Channel)和缓冲区(Buffer)](https://tobebetterjavaer.com/nio/buffer-channel.html)进行文件操作,以及使用 SocketChannel 和 ServerSocketChannel 进行网络传输。 - -传统 IO 采用阻塞式模型,对于每个连接,都需要创建一个独立的线程来处理读写操作。当一个线程在等待 I/O 操作时,无法执行其他任务。这会导致大量线程的创建和销毁,以及上下文切换,降低了系统性能。 - -NIO 使用非阻塞模型,允许线程在等待 I/O 时执行其他任务。这种模式通过使用选择器(Selector)来监控多个通道(Channel)上的 I/O 事件,实现了更高的性能和可伸缩性。 - -### 01、NIO 和传统 IO 在操作文件时的差异 - -JDK 1.4 中,`java.nio.*包`引入新的 Java I/O 库,其目的是**提高速度**。实际上,“旧”的 I/O 包已经使用 NIO**重新实现过,即使我们不显式的使用 NIO 编程,也能从中受益**。 - -- nio 翻译成 no-blocking io 或者 new io 都无所谓啦,都说得通~ - -在《Java 编程思想》读到“即使我们不显式的使用 NIO 编程,也能从中受益”的时候,我是挺在意的,所以:我们**测试**一下使用 NIO 复制文件和[传统 IO 复制文件](https://tobebetterjavaer.com/io/file-path.html) 的性能: - -```java -public class SimpleFileTransferTest { - - // 使用传统的 I/O 方法传输文件 - private long transferFile(File source, File des) throws IOException { - long startTime = System.currentTimeMillis(); - - if (!des.exists()) - des.createNewFile(); - - // 创建输入输出流 - BufferedInputStream bis = new BufferedInputStream(new FileInputStream(source)); - BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(des)); - - // 使用数组传输数据 - byte[] bytes = new byte[1024 * 1024]; - int len; - while ((len = bis.read(bytes)) != -1) { - bos.write(bytes, 0, len); - } - - long endTime = System.currentTimeMillis(); - return endTime - startTime; - } - - // 使用 NIO 方法传输文件 - private long transferFileWithNIO(File source, File des) throws IOException { - long startTime = System.currentTimeMillis(); - - if (!des.exists()) - des.createNewFile(); - - // 创建随机存取文件对象 - RandomAccessFile read = new RandomAccessFile(source, "rw"); - RandomAccessFile write = new RandomAccessFile(des, "rw"); - - // 获取文件通道 - FileChannel readChannel = read.getChannel(); - FileChannel writeChannel = write.getChannel(); - - // 创建并使用 ByteBuffer 传输数据 - ByteBuffer byteBuffer = ByteBuffer.allocate(1024 * 1024); - while (readChannel.read(byteBuffer) > 0) { - byteBuffer.flip(); - writeChannel.write(byteBuffer); - byteBuffer.clear(); - } - - // 关闭文件通道 - writeChannel.close(); - readChannel.close(); - long endTime = System.currentTimeMillis(); - return endTime - startTime; - } - - public static void main(String[] args) throws IOException { - SimpleFileTransferTest simpleFileTransferTest = new SimpleFileTransferTest(); - File sourse = new File("[电影天堂www.dygod.cn]猜火车-cd1.rmvb"); - File des = new File("io.avi"); - File nio = new File("nio.avi"); - - // 比较传统的 I/O 和 NIO 传输文件的时间 - long time = simpleFileTransferTest.transferFile(sourse, des); - System.out.println(time + ":普通字节流时间"); - - long timeNio = simpleFileTransferTest.transferFileWithNIO(sourse, nio); - System.out.println(timeNio + ":NIO时间"); - } -} -``` - -先解释一下这段代码,里面出现的 [RandomAccessFile](https://tobebetterjavaer.com/io/file-path.html) 我们之前讲过,FileChannel 是 Java NIO(New I/O)库中的一个类,它提供了对文件的高效 I/O 操作,支持随机访问文件,允许在文件的任意位置进行读写操作。 - -与 RandomAccessFile 不同,FileChannel 使用了[缓冲区(ByteBuffer)](https://tobebetterjavaer.com/nio/buffer-channel.html)进行数据传输。 - -好,在我给出实际的结论之前,你是否会有这样的结论: - -- 对于较小的文件,NIO 和普通 IO 之间的性能差异可能不会非常明显,因为文件本身较小,复制过程较快。 -- 对于较大的文件,使用 NIO 的性能可能会明显优于普通 IO。因为 NIO 使用了更高效的缓冲区和通道机制,可以在内存中进行更快的数据传输。 - -然而实际的结果,却会令你大跌眼镜: - -![](https://cdn.tobebetterjavaer.com/stutymore/why-20230331191748.png) - -文件越大的情况下,竟然普通字节流(传统 IO)的速度更快(当然了,个人测试,或许不是很准),那还要 NIO 做什么呢?况且 NIO 的学习成本也比传统 IO 要高一些。 - -那这意味着我们**可以不使用/学习 NIO 了吗**? - -答案是**否定**的,IO 操作往往在**两个场景**下会用到: - -- 文件 IO -- 网络 IO - -而 NIO 的**魅力主要体现在网络中**! - -NIO(New I/O)的设计目标是解决传统 I/O(BIO,Blocking I/O)在处理大量并发连接时的性能瓶颈。传统 I/O 在网络通信中主要使用阻塞式 I/O,为每个连接分配一个线程。当连接数量增加时,系统性能将受到严重影响,线程资源成为关键瓶颈。而 NIO 提供了非阻塞 I/O 和 I/O 多路复用,可以在单个线程中处理多个并发连接,从而在网络传输中显著提高性能。 - -以下是 NIO 在网络传输中优于传统 I/O 的原因: - -①、NIO 支持非阻塞 I/O,这意味着在执行 I/O 操作时,线程不会被阻塞。这使得在网络传输中可以有效地管理大量并发连接(数千甚至数百万)。而在操作文件时,这个优势没有那么明显,因为文件读写通常不涉及大量并发操作。 - -②、NIO 支持 I/O 多路复用,这意味着一个线程可以同时监视多个通道(如套接字),并在 I/O 事件(如可读、可写)准备好时处理它们。这大大提高了网络传输中的性能,因为单个线程可以高效地管理多个并发连接。操作文件时这个优势也无法提现出来。 - -③、NIO 提供了 [ByteBuffer 类](https://tobebetterjavaer.com/nio/buffer-channel.html),可以高效地管理缓冲区。这在网络传输中很重要,因为数据通常是以字节流的形式传输。操作文件的时候,虽然也有缓冲区,但优势仍然不够明显。 - -### 02、NIO 和传统 IO 在网络传输中的差异 - -来看服务器端代码的差别。 - -IO,用的[套接字](https://tobebetterjavaer.com/socket/socket.html),代码比较简单,我就不加注释了,之前学过,应该都能看得懂,用 while 循环监听客户端 Socket: - -```java -public class IOServer { - public static void main(String[] args) { - try { - ServerSocket serverSocket = new ServerSocket(8080); - - while (true) { - Socket client = serverSocket.accept(); - InputStream in = client.getInputStream(); - OutputStream out = client.getOutputStream(); - - byte[] buffer = new byte[1024]; - int bytesRead = in.read(buffer); - out.write(buffer, 0, bytesRead); - - in.close(); - out.close(); - client.close(); - } - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -NIO,这部分我加上注释,主要用到的是 ServerSocketChannel 和 Selector: - -```java -public class NIOServer { - public static void main(String[] args) { - try { - // 创建 ServerSocketChannel - ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); - // 绑定端口 - serverSocketChannel.bind(new InetSocketAddress(8081)); - // 设置为非阻塞模式 - serverSocketChannel.configureBlocking(false); - - // 创建 Selector - Selector selector = Selector.open(); - // 将 ServerSocketChannel 注册到 Selector,关注 OP_ACCEPT 事件 - serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); - - // 无限循环,处理事件 - while (true) { - // 阻塞直到有事件发生 - selector.select(); - // 获取发生事件的 SelectionKey - Iterator iterator = selector.selectedKeys().iterator(); - while (iterator.hasNext()) { - SelectionKey key = iterator.next(); - // 处理完后,从 selectedKeys 集合中移除 - iterator.remove(); - - // 判断事件类型 - if (key.isAcceptable()) { - // 有新的连接请求 - ServerSocketChannel server = (ServerSocketChannel) key.channel(); - // 接受连接 - SocketChannel client = server.accept(); - // 设置为非阻塞模式 - client.configureBlocking(false); - // 将新的 SocketChannel 注册到 Selector,关注 OP_READ 事件 - client.register(selector, SelectionKey.OP_READ); - } else if (key.isReadable()) { - // 有数据可读 - SocketChannel client = (SocketChannel) key.channel(); - // 创建 ByteBuffer 缓冲区 - ByteBuffer buffer = ByteBuffer.allocate(1024); - // 从 SocketChannel 中读取数据并写入 ByteBuffer - client.read(buffer); - // 翻转 ByteBuffer,准备读取 - buffer.flip(); - // 将数据从 ByteBuffer 写回到 SocketChannel - client.write(buffer); - // 关闭连接 - client.close(); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -上面的代码创建了一个基于 Java NIO 的简单 TCP 服务器。它使用 [ServerSocketChannel 和 Selector(后面会讲)](https://tobebetterjavaer.com/nio/buffer-channel.html) 实现了非阻塞 I/O 和 I/O 多路复用。服务器循环监听事件,当有新的连接请求时,接受连接并将新的 SocketChannel 注册到 Selector,关注 OP_READ 事件。当有数据可读时,从 SocketChannel 中读取数据并写入 ByteBuffer,然后将数据从 ByteBuffer 写回到 SocketChannel。 - -这里简单说一下 [Socket 和 ServerSocket](https://tobebetterjavaer.com/socket/socket.html),以及 ServerSocketChannel 和 SocketChannel。 - -Socket 和 ServerSocket 是传统的阻塞式 I/O 编程方式,用于建立和管理 TCP 连接。 - -- Socket:表示客户端套接字,负责与服务器端建立连接并进行数据的读写。 -- ServerSocket:表示服务器端套接字,负责监听客户端连接请求。当有新的连接请求时,ServerSocket 会创建一个新的 Socket 实例,用于与客户端进行通信。 - -在传统阻塞式 I/O 编程中,每个连接都需要一个单独的线程进行处理,这导致了在高并发场景下的性能问题。在接下来的客户端测试用例中会看到。 - -为了解决传统阻塞式 I/O 的性能问题,Java NIO 引入了 [ServerSocketChannel 和 SocketChannel](https://tobebetterjavaer.com/nio/network-connect.html)。它们是非阻塞 I/O,可以在单个线程中处理多个连接。 - -- ServerSocketChannel:类似于 ServerSocket,表示服务器端套接字通道。它负责监听客户端连接请求,并可以设置为非阻塞模式,这意味着在等待客户端连接请求时不会阻塞线程。 -- SocketChannel:类似于 Socket,表示客户端套接字通道。它负责与服务器端建立连接并进行数据的读写。SocketChannel 也可以设置为非阻塞模式,在读写数据时不会阻塞线程。 - -再来简单说一下 [Selector](https://tobebetterjavaer.com/nio/buffer-channel.html),后面会再细讲。 - -Selector 是 Java NIO 中的一个关键组件,用于实现 [I/O 多路复用](https://tobebetterjavaer.com/nio/moxing.html)。它允许在单个线程中同时监控多个 ServerSocketChannel 和 SocketChannel,并通过 SelectionKey 标识关注的事件。当某个事件发生时,Selector 会将对应的 SelectionKey 添加到已选择的键集合中。通过使用 Selector,可以在单个线程中同时处理多个连接,从而有效地提高 I/O 操作的性能,特别是在高并发场景下。 - -客户端测试用例: - -```java -public class TestClient { - public static void main(String[] args) throws InterruptedException { - int clientCount = 10000; - ExecutorService executorServiceIO = Executors.newFixedThreadPool(10); - ExecutorService executorServiceNIO = Executors.newFixedThreadPool(10); - - // 使用传统 IO 的客户端 - Runnable ioClient = () -> { - try { - Socket socket = new Socket("localhost", 8080); - OutputStream out = socket.getOutputStream(); - InputStream in = socket.getInputStream(); - out.write("Hello, 沉默王二 IO!".getBytes()); - byte[] buffer = new byte[1024]; - in.read(buffer); - in.close(); - out.close(); - socket.close(); - } catch (IOException e) { - e.printStackTrace(); - } - }; - - // 使用 NIO 的客户端 - Runnable nioClient = () -> { - try { - SocketChannel socketChannel = SocketChannel.open(); - socketChannel.connect(new InetSocketAddress("localhost", 8081)); - ByteBuffer buffer = ByteBuffer.wrap("Hello, 沉默王二 NIO!".getBytes()); - socketChannel.write(buffer); - buffer.clear(); - socketChannel.read(buffer); - socketChannel.close(); - } catch (IOException e) { - e.printStackTrace(); - } - }; - - // 分别测试 NIO 和传统 IO 的服务器性能 - long startTime, endTime; - - startTime = System.currentTimeMillis(); - for (int i = 0; i < clientCount; i++) { - executorServiceIO.execute(ioClient); - } - executorServiceIO.shutdown(); - executorServiceIO.awaitTermination(1, TimeUnit.MINUTES); - endTime = System.currentTimeMillis(); - System.out.println("传统 IO 服务器处理 " + clientCount + " 个客户端耗时: " + (endTime - startTime) + "ms"); - - startTime = System.currentTimeMillis(); - for (int i = 0; i < clientCount; i++) { - executorServiceNIO.execute(nioClient); - } - executorServiceNIO.shutdown(); - executorServiceNIO.awaitTermination(1, TimeUnit.MINUTES); - endTime = System.currentTimeMillis(); - System.out.println("NIO 服务器处理 " + clientCount + " 个客户端耗时: " + (endTime - startTime) + "ms"); - } -} -``` - -在这个简单的性能测试中,我们使用固定线程池(10个线程)来模拟客户端并发请求。分别测试 NIO 和传统 IO 服务器处理 10000 个客户端请求所需的时间。来看一下结果。 - -![](https://cdn.tobebetterjavaer.com/stutymore/why-20230404141335.png) - -可以看得出,NIO 比 传统 IO 差不多快一倍的时间。当然了,这是放在我本地测试的,如果服务端放在生产环境下,这个结果会有所不同,但我在本地跑了几次,结果差不多是这样的结果。 - -这说明 NIO 在网络传输中的性能确实要优于传统 IO 的。 - -### 03、小结 - -本篇内容主要讲了 NIO(New IO)和传统 IO 之间的差异,包括 IO 模型、操作文件、网络传输等方面。 - -- 传统 I/O 采用阻塞式模型,线程在 I/O 操作期间无法执行其他任务。NIO 使用非阻塞模型,允许线程在等待 I/O 时执行其他任务,通过选择器(Selector)监控多个通道(Channel)上的 I/O 事件,提高性能和可伸缩性。 -- 传统 I/O 使用基于字节流或字符流的类(如 FileInputStream、BufferedReader 等)进行文件读写。NIO 使用通道(Channel)和缓冲区(Buffer)进行文件操作,NIO 在性能上的优势并不大。 -- 传统 I/O 使用 Socket 和 ServerSocket 进行网络传输,存在阻塞问题。NIO 提供了 SocketChannel 和 ServerSocketChannel,支持非阻塞网络传输,提高了并发处理能力。 - - -## 12.2 BIO、NIO 和 AIO 的区别 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[12.2 BIO、NIO 和 AIO 的区别](https://tobebetterjavaer.com/nio/BIONIOAIO.html) - - -## 12.3 Buffer和Channel - -首先我们再来回顾一下 [IO 和 NIO 的区别](https://tobebetterjavaer.com/nio/nio-better-io.html): - -- 可简单认为:**IO 是面向流的处理,NIO 是面向块(缓冲区)的处理** -- 面向流的 I/O 系统**一次一个字节地处理数据**。 -- 一个面向块(缓冲区)的 I/O 系统**以块的形式处理数据**。 - -NIO 主要有**两个核心部分组成**: - -- **Buffer 缓冲区** -- **Channel 通道** - -在 NIO 中,并不是以流的方式来处理数据的,而是以 buffer 缓冲区和 Channel 通道**配合使用**来处理数据的。 - -简单理解一下: - -可以把 Channel 通道比作铁路,buffer 缓冲区比作成火车(运载着货物) - -而我们的 NIO 就是**通过 Channel 通道运输着存储数据的 Buffer 缓冲区的来实现数据的处理**! - -要时刻记住:Channel 不与数据打交道,它只负责运输数据。与数据打交道的是 Buffer 缓冲区 - -- **Channel-->运输** -- **Buffer-->数据** - -相对于传统 IO 而言,**流是单向的**。对于 NIO 而言,有了 Channel 通道这个概念,我们的**读写都是双向**的(铁路上的火车能从广州去北京、自然就能从北京返还到广州)! - -### Buffer 缓冲区 - -我们来看看 Buffer 缓冲区有什么值得我们注意的地方。 - -Buffer 是缓冲区的抽象类: - -![](https://cdn.tobebetterjavaer.com/stutymore/rumen-20230404151539.png) - -其中 ByteBuffer 是**用得最多的实现类**(在通道中读写字节数据)。 - -![](https://cdn.tobebetterjavaer.com/stutymore/rumen-20230404152253.png) - -拿到一个缓冲区我们往往会做什么?很简单,就是**读取缓冲区的数据/写数据到缓冲区中**。所以,缓冲区的核心方法就是 put 和 get: - -![](https://cdn.tobebetterjavaer.com/stutymore/rumen-20230404152445.png) - -Buffer 类维护了 4 个核心变量来提供**关于其所包含的数组信息**。它们是: - -- 容量 Capacity **缓冲区能够容纳的数据元素的最大数量**。容量在缓冲区创建时被设定,并且永远不能被改变。(不能被改变的原因也很简单,底层是数组嘛) -- 上界 Limit **缓冲区里的数据的总数**,代表了当前缓冲区中一共有多少数据。 -- 位置 Position **下一个要被读或写的元素的位置**。Position 会自动由相应的 `get()`和 `put()`函数更新。 -- 标记 Mark 一个备忘位置。**用于记录上一次读写的位置**。 - -首先展示一下**是如何创建缓冲区的,核心变量的值是怎么变化的**。 - -```java -// 创建一个缓冲区 -ByteBuffer byteBuffer = ByteBuffer.allocate(1024); - -// 看一下初始时4个核心变量的值 -System.out.println("初始时-->limit--->"+byteBuffer.limit()); -System.out.println("初始时-->position--->"+byteBuffer.position()); -System.out.println("初始时-->capacity--->"+byteBuffer.capacity()); -System.out.println("初始时-->mark--->" + byteBuffer.mark()); - -System.out.println("--------------------------------------"); - -// 添加一些数据到缓冲区中 -String s = "沉默王二"; -byteBuffer.put(s.getBytes()); - -// 看一下初始时4个核心变量的值 -System.out.println("put完之后-->limit--->"+byteBuffer.limit()); -System.out.println("put完之后-->position--->"+byteBuffer.position()); -System.out.println("put完之后-->capacity--->"+byteBuffer.capacity()); -System.out.println("put完之后-->mark--->" + byteBuffer.mark()); -``` - -运行结果: - -``` -初始时-->limit--->1024 -初始时-->position--->0 -初始时-->capacity--->1024 -初始时-->mark--->java.nio.HeapByteBuffer[pos=0 lim=1024 cap=1024] --------------------------------------- -put完之后-->limit--->1024 -put完之后-->position--->12 -put完之后-->capacity--->1024 -put完之后-->mark--->java.nio.HeapByteBuffer[pos=12 lim=1024 cap=1024] -``` - -现在**我想要从缓存区拿数据**,怎么拿呀??NIO 给了我们一个`flip()`方法。这个方法可以**改动 position 和 limit 的位置**! - -在之前代码的基础上,我们`flip()`一下。 - -```java -// flip()方法 -byteBuffer.flip(); -System.out.println("flip()方法之后-->limit--->"+byteBuffer.limit()); -System.out.println("flip()方法之后-->position--->"+byteBuffer.position()); -System.out.println("flip()方法之后-->capacity--->"+byteBuffer.capacity()); -System.out.println("flip()方法之后-->mark--->" + byteBuffer.mark()); -``` - -再看看 4 个核心属性的值会发生什么变化: - -![](https://cdn.tobebetterjavaer.com/stutymore/rumen-20230404153844.png) - -在调用 `flip()` 之后,limit 变为当前 position 的值(12),position 重置为 0。这意味着你可以从缓冲区的开始位置读取刚刚写入的数据,直到 limit 指定的位置。capacity 保持不变(1024)。 - -假设我们有一个初始容量为 1024 的 ByteBuffer。 - -**初始状态**: - -``` -position = 0 -limit = 1024 -capacity = 1024 -``` - -**添加数据 "沉默王二" 后**: - -由于 "沉默王二" 为 UTF-8 编码,一个汉字占 3 个字节,共有 4 个汉字,所以占用 12 个字节。 - -``` -position = 12 -limit = 1024 -capacity = 1024 -``` - -**调用 `flip()` 方法后**: - -``` -position = 0 -limit = 12 -capacity = 1024 -``` - -用一幅图来表示就是。 - -![](https://cdn.tobebetterjavaer.com/stutymore/rumen-20230404155658.png) - -当切换成读模式之后,我们就可以读取缓冲区的数据了: - -```java -// 创建一个limit()大小的字节数组(因为就只有limit这么多个数据可读) -byte[] bytes = new byte[byteBuffer.limit()]; -// 将读取的数据装进我们的字节数组中 -byteBuffer.get(bytes); -// 输出数据 -System.out.println(new String(bytes, 0, bytes.length)); -``` - -输出后的结果: - -``` -沉默王二 -``` - -随后输出一下核心变量的值看看: - -![](https://cdn.tobebetterjavaer.com/stutymore/rumen-20230404160130.png) - -**读完如何还想写数据到缓冲区**,那就使用`clear()` 方法,这个方法会“清空”缓冲区,数据没有真正被清空,只是被**遗忘**掉了 - -![](https://cdn.tobebetterjavaer.com/stutymore/rumen-20230404160412.png) - -### Channel 通道 - -Channel 通道**只负责传输数据、不直接操作数据**。操作数据都是通过 Buffer 缓冲区来进行操作!通常,通道可以分为两大类:文件通道和套接字通道。 - -FileChannel:用于文件 I/O 的通道,支持文件的读、写和追加操作。FileChannel 允许在文件的任意位置进行数据传输,支持文件锁定以及内存映射文件等高级功能。FileChannel 无法设置为非阻塞模式,因此它只适用于阻塞式文件操作。 - -SocketChannel:用于 TCP 套接字 I/O 的通道。SocketChannel 支持非阻塞模式,可以与 Selector(下文会讲)一起使用,实现高效的网络通信。SocketChannel 允许连接到远程主机,进行数据传输。 - -与之匹配的有ServerSocketChannel:用于监听 TCP 套接字连接的通道。与 SocketChannel 类似,ServerSocketChannel 也支持非阻塞模式,并可以与 Selector 一起使用。ServerSocketChannel 负责监听新的连接请求,接收到连接请求后,可以创建一个新的 SocketChannel 以处理数据传输。 - -DatagramChannel:用于 UDP 套接字 I/O 的通道。DatagramChannel 支持非阻塞模式,可以发送和接收数据报包,适用于无连接的、不可靠的网络通信。 - -这篇我们主要来讲 FileChannel,SocketChannel、ServerSocketChannel 和 DatagramChannel 会放到[后面的章节中](https://tobebetterjavaer.com/nio/network-connect.html)讲解。 - -#### 文件通道 FileChannel - -可以通过下面的方式打开一个通道。 - -```java -FileChannel.open(Paths.get("docs/配套教程.md"), StandardOpenOption.WRITE); -``` - -这里我们用到了 [Paths](https://tobebetterjavaer.com/nio/paths-files.html),这个后面也会讲到。 - -①、使用**FileChannel 配合 ByteBuffer 缓冲区**实现文件复制的功能: - -```java -try (FileChannel sourceChannel = FileChannel.open(Paths.get("logs/javabetter/itwanger.txt"), StandardOpenOption.READ); - FileChannel destinationChannel = FileChannel.open(Paths.get("logs/javabetter/itwanger1.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE)) { - - ByteBuffer buffer = ByteBuffer.allocate(1024); - - while (sourceChannel.read(buffer) != -1) { - buffer.flip(); - destinationChannel.write(buffer); - buffer.clear(); - } -} -``` - -我们创建一个容量为 1024 的 ByteBuffer 作为缓冲区。在循环中,我们从源文件的 FileChannel 读取数据到缓冲区。当 `read()` 方法返回 -1 时,表示已经到达文件末尾。 - -读取数据后,我们调用 `flip()` 方法,以便在缓冲区中准备好要写入的数据。然后,我们将缓冲区的内容写入目标文件的 FileChannel(`write()` 方法)。在写入完成后,我们调用 `clear()` 方法重置缓冲区,以便在下一次迭代中重用它。 - -②、使用**内存映射文件(MappedByteBuffer)**的方式实现**文件复制**的功能(直接操作缓冲区): - -```java -try (FileChannel sourceChannel = FileChannel.open(Paths.get("logs/javabetter/itwanger.txt"), StandardOpenOption.READ); - FileChannel destinationChannel = FileChannel.open(Paths.get("logs/javabetter/itwanger2.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.READ)) { - - long fileSize = sourceChannel.size(); - MappedByteBuffer sourceMappedBuffer = sourceChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize); - MappedByteBuffer destinationMappedBuffer = destinationChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileSize); - - for (int i = 0; i < fileSize; i++) { - byte b = sourceMappedBuffer.get(i); - destinationMappedBuffer.put(i, b); - } -} -``` - -MappedByteBuffer 是 Java NIO 中的一个类,它继承自 `java.nio.ByteBuffer`。MappedByteBuffer 用于表示一个内存映射文件,即将文件的一部分或全部映射到内存中,以便通过直接操作内存来实现对文件的读写。这种方式可以提高文件 I/O 的性能,因为操作系统可以直接在内存和磁盘之间传输数据,无需通过 Java 应用程序进行额外的数据拷贝。 - -通常与 FileChannel 一起使用,可以通过调用 FileChannel 的 `map()` 方法创建 MappedByteBuffer 对象。`map()` 方法接受三个参数:映射模式(FileChannel.MapMode)映射起始位置和映射的长度。 - -映射模式包括只读模式(READ_ONLY)、读写模式(READ_WRITE)和专用模式(PRIVATE)。 - -我们设置源文件的 MappedByteBuffer 为只读模式(READ_ONLY),目标文件的 MappedByteBuffer 为读写模式(READ_WRITE)。 - -在循环中,我们逐字节地从源文件的 MappedByteBuffer 读取数据并将其写入目标文件的 MappedByteBuffer。这样就实现了文件复制功能。利用内存映射文件(MappedByteBuffer)实现的文件复制,可能会比使用 ByteBuffer 的方法更快。 - -需要注意的是,使用 MappedByteBuffer 进行文件操作时,数据的修改可能不会立即写入磁盘。可以通过调用 MappedByteBuffer 的 `force()` 方法将数据立即写回磁盘。 - -③、通道之间通过`transfer()`实现数据的传输(直接操作缓冲区): - -```java -try (FileChannel sourceChannel = FileChannel.open(Paths.get("logs/javabetter/itwanger.txt"), StandardOpenOption.READ); - FileChannel destinationChannel = FileChannel.open(Paths.get("logs/javabetter/itwanger3.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.READ)) { - sourceChannel.transferTo(0, sourceChannel.size(), destinationChannel); -} catch (IOException e) { - throw new RuntimeException(e); -} -``` - -FileChannel 的 `transferTo()` 方法是一个高效的文件传输方法,它允许将文件的一部分或全部内容直接从源文件通道传输到目标通道(通常是另一个文件通道或网络通道)。这种传输方式可以避免将文件数据在用户空间和内核空间之间进行多次拷贝,提高了文件传输的性能。 - -`transferTo()` 方法接受以下三个参数: - -- position:源文件中开始传输的位置。 -- count:要传输的字节数。 -- target:接收数据的目标通道。 - -需要注意的是,`transferTo()` 方法可能无法一次传输所有请求的字节。在实际应用中,你可能需要使用循环来确保所有字节都被传输。 - -```java -public class FileChannelTransferToLoopExampleWithPaths { - public static void main(String[] args) { - Path sourcePath = Paths.get("logs/itwanger/paicoding.txt"); - Path destinationPath = Paths.get("logs/itwanger/paicoding_copy.txt"); - - // 使用 try-with-resources 语句确保通道资源被正确关闭 - try (FileChannel sourceChannel = FileChannel.open(sourcePath, StandardOpenOption.READ); - FileChannel destinationChannel = FileChannel.open(destinationPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { - - long position = 0; - long count = sourceChannel.size(); - - // 循环传输,直到所有字节都被传输 - while (position < count) { - long transferred = sourceChannel.transferTo(position, count - position, destinationChannel); - position += transferred; - } - - } catch (IOException e) { - e.printStackTrace(); - } - } -} -``` - -此外,`transferTo()` 方法在底层使用了操作系统提供的零拷贝功能(如 Linux 的 `sendfile()` 系统调用),可以大幅提高文件传输性能。但是,不同操作系统和 JVM 实现可能会影响零拷贝的可用性和性能,因此实际性能可能因环境而异。 - -零拷贝(Zero-Copy)是一种优化数据传输性能的技术,它最大限度地减少了在数据传输过程中的 CPU 和内存开销。在传统的数据传输过程中,数据通常需要在用户空间和内核空间之间进行多次拷贝,这会导致额外的 CPU 和内存开销。零拷贝技术通过避免这些多余的拷贝操作,实现了更高效的数据传输。 - -在 Java 中,零拷贝技术主要应用于文件和网络 I/O。FileChannel 类的 `transferTo()` 和 `transferFrom()` 方法就利用了零拷贝技术,可以在文件和网络通道之间高效地传输数据。详细参考:[深入剖析Linux IO原理和几种零拷贝机制的实现](https://zhuanlan.zhihu.com/p/83398714) - -#### 直接与非直接缓冲区 - -直接缓冲区和非直接缓冲区的差别主要在于它们在内存中的存储方式。这里给出了直接缓冲区和非直接缓冲区的简要概述和区别: - -非直接缓冲区: - -- 分配在 JVM 堆内存中 -- 受到垃圾回收的管理 -- 在读写操作时,需要将数据从堆内存复制到操作系统的本地内存,再进行 I/O 操作 -- 创建: `ByteBuffer.allocate(int capacity)` - -直接缓冲区: - -- 分配在操作系统的本地内存中 -- 不受垃圾回收的管理 -- 在读写操作时,直接在本地内存中进行,避免了数据复制,提高了性能 -- 创建: `ByteBuffer.allocateDirect(int capacity)` -- 还有前面提到的 `FileChannel.map()` 方法,会返回一个类型为 MappedByteBuffer 的直接缓冲区。 - -ByteBuffer.allocate和ByteBuffer.allocateDirect直接的差异。 - -![](https://cdn.tobebetterjavaer.com/stutymore/buffer-channel-selector-20230406183808.png) - - -直接缓冲区和非直接缓冲区之间的差异。 - -![](https://cdn.tobebetterjavaer.com/stutymore/buffer-channel-selector-20230406182921.png) - -非直接缓冲区存储在JVM内部,数据需要从应用程序(Java)复制到非直接缓冲区,再复制到内核缓冲区,最后发送到设备(磁盘/网络)。而对于直接缓冲区,数据可以直接从应用程序(Java)复制到内核缓冲区,无需经过JVM的非直接缓冲区。 - -#### 异步文件通道 AsynchronousFileChannel - -AsynchronousFileChannel 是 Java 7 引入的一个异步文件通道类,提供了对文件的异步读、写、打开和关闭等操作。 - -可以通过 `AsynchronousFileChannel.open()` 方法打开一个异步文件通道,该方法接受一个 [Path 对象](https://tobebetterjavaer.com/nio/paths-files.html)和一组打开选项(如 StandardOpenOption.READ、StandardOpenOption.WRITE 等)作为参数。 - -```java -Path file = Paths.get("example.txt"); -AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE); -``` - -AsynchronousFileChannel 提供了两种异步操作的方式: - -**①、Future 方式**:使用 [Future](https://tobebetterjavaer.com/thread/callable-future-futuretask.html) 对象来跟踪异步操作的完成情况。当我们调用一个异步操作(如 `read()` 或 `write()`)时,它会立即返回一个 Future 对象。可以使用这个对象来检查操作是否完成,以及获取操作的结果。这种方式适用于不需要在操作完成时立即执行其他操作的场景。 - -举个例子: - -```java -ByteBuffer buffer = ByteBuffer.allocate(1024); -long position = 0; -Future result = fileChannel.read(buffer, position); - -while (!result.isDone()) { - // 执行其他操作 -} - -int bytesRead = result.get(); -System.out.println("Bytes read: " + bytesRead); -``` - -**②、CompletionHandler 方式**,使用一个实现了 CompletionHandler 接口的对象来处理异步操作的完成。我们需要提供一个 CompletionHandler 实现类,重写 `completed()` 和 `failed()` 方法,分别处理操作成功和操作失败的情况。当异步操作完成时,系统会自动调用相应的方法。这种方式适用于需要在操作完成时立即执行其他操作的场景。 - -举个例子: - -```java -ByteBuffer buffer = ByteBuffer.allocate(1024); -long position = 0; - -fileChannel.read(buffer, position, buffer, new CompletionHandler() { - @Override - public void completed(Integer result, ByteBuffer attachment) { - System.out.println("Bytes read: " + result); - } - - @Override - public void failed(Throwable exc, ByteBuffer attachment) { - System.err.println("Read failed"); - exc.printStackTrace(); - } -}); -``` - -来看完整的示例,采用 Future 的形式。 - -```java -Path path = Paths.get("docs/配套教程.md"); - -try (AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) { - ByteBuffer buffer = ByteBuffer.allocate(1024); - long position = 0; - - while (true) { - Future result = fileChannel.read(buffer, position); - - while (!result.isDone()) { - // 在这里可以执行其他任务,例如处理其他 I/O 操作 - } - - int bytesRead = result.get(); - if (bytesRead <= 0) { - break; - } - - position += bytesRead; - buffer.flip(); - - byte[] data = new byte[buffer.limit()]; - buffer.get(data); - System.out.println(new String(data)); - - buffer.clear(); - } -} -``` - -在这个示例中,我们使用 `AsynchronousFileChannel.read()` 方法发起异步读取操作。这个方法会返回一个 `Future` 对象,表示读取操作的结果。我们可以通过调用 `isDone()` 方法来检查异步操作是否完成。完成后,我们可以通过调用 `get()` 方法获取实际读取的字节数。 - -然后我们来看 CompletionHandler 接口的形式: - -```java -public static void readAllBytes(Path path) throws IOException, InterruptedException { - AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(path, StandardOpenOption.READ); - ByteBuffer buffer = ByteBuffer.allocate(1024); - AtomicLong position = new AtomicLong(0); - CountDownLatch latch = new CountDownLatch(1); - - fileChannel.read(buffer, position.get(), null, new CompletionHandler() { - @Override - public void completed(Integer bytesRead, Object attachment) { - if (bytesRead > 0) { - position.addAndGet(bytesRead); - buffer.flip(); - byte[] data = new byte[buffer.limit()]; - buffer.get(data); - System.out.print(new String(data)); - buffer.clear(); - - fileChannel.read(buffer, position.get(), attachment, this); - } else { - latch.countDown(); - try { - fileChannel.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - } - - @Override - public void failed(Throwable exc, Object attachment) { - System.out.println("Error: " + exc.getMessage()); - latch.countDown(); - } - }); - - latch.await(); -} -``` - -1、在 readAllBytes 方法中,我们使用 `AsynchronousFileChannel.open()` 方法以读取模式打开异步文件通道。 - -2、创建一个大小为 1024 的 ByteBuffer 来存储从文件中读取的数据。 - -3、使用 [AtomicLong](https://tobebetterjavaer.com/thread/atomic.html) 类型的 position 变量来记录当前读取的文件位置。初始值为 0。 - -4、创建一个 [CountDownLatch](https://tobebetterjavaer.com/thread/CountDownLatch.html) 对象,用于在异步操作完成时通知主线程。初始值为 1。 - -5、使用 `fileChannel.read()` 方法启动异步读取操作。这个方法的参数包括:用于存储数据的缓冲区、当前读取位置、附加对象(在这个例子中不需要,所以传递 null)以及一个实现了 CompletionHandler 接口的对象,用于在读取操作完成时回调。 - -6、CompletionHandler 接口有两个方法:`completed()` 和 `failed()`。在读取操作成功完成时调用 `completed()` 方法;如果读取操作失败,调用 `failed()` 方法。 - -7、在 `completed()` 方法中,我们首先检查 bytesRead(本次读取的字节数)是否大于 0。如果大于 0,说明还有数据需要读取。 - -- 更新 position 变量,将其增加 bytesRead。 -- 将缓冲区翻转(`flip()`),以便我们可以从中读取数据。 -- 创建一个新的字节数组,其大小等于缓冲区的限制(`limit()`)。 -- 从缓冲区中获取数据并将其存储在新创建的字节数组中。 -- 将字节数组转换为字符串并输出。 -- 清除缓冲区,以便我们可以继续读取更多数据。 -- 再次调用 fileChannel.read() 方法,以继续从文件中读取数据。 - -8、如果 bytesRead 等于或小于 0,说明我们已经读取完文件中的所有数据。此时,我们需要:调用 `latch.countDown()` 方法,以通知主线程异步操作已完成。关闭 fileChannel。 - -9、如果读取操作失败,我们将在 `failed()` 方法中输出错误信息并调用 `latch.countDown()` 方法通知主线程。 - -10、最后,我们调用 `latch.await()` 方法来等待异步操作完成。主线程将在此处阻塞,直到 latch 的计数变为 0。 - -### 小结 - -Java NIO 中的 Buffer 和 Channel 是 NIO 系统的核心组件。Buffer 负责存储数据,提供了对数据的读写操作。它有多种类型,如 ByteBuffer、CharBuffer、IntBuffer 等,以支持不同的数据类型。 - -Channel 代表了与 I/O 设备(如文件或套接字)之间的连接。它提供了从源设备到 Buffer 的数据读取能力和从 Buffer 到目标设备的数据写入能力。Channel 可以是可读、可写或同时可读写的,我们详细介绍了文件通道 FileChannel 和异步文件通道 AsynchronousFileChannel。 - -总之,NIO 使用 Buffer 和 Channel 这两个组件来进行高效的数据传输,以提高 I/O 操作的性能。 - -> 参考链接:[https://www.zhihu.com/question/29005375/answer/667616386](https://www.zhihu.com/question/29005375/answer/667616386),整理:沉默王二 - - -## 12.4 Paths 和 Files - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[12.4 Paths 和 Files](https://tobebetterjavaer.com/nio/paths-files.html) - -## 12.5 NIO 实现简易版聊天室 - -在此之前,我们曾利用 Java 的套接字 Socket 和 ServerSocket 完成[网络编程](https://tobebetterjavaer.com/socket/socket.html),但 Socket 和 ServerSocket 是基于 Java IO 的,在网络编程方面,性能会比较差。[原因我们在之前也讲过](https://tobebetterjavaer.com/nio/nio-better-io.html)。 - -那 Java NIO 的 SocketChannel 和 ServerSocketChannel 性能怎么样呢? - -### SocketChannel 和 ServerSocketChannel - -在学习 NIO 的[第一讲里](https://tobebetterjavaer.com/nio/nio-better-io.html),我们已经介绍过 SocketChannel 和 ServerSocketChannel了,这里再简单补充下。 - -ServerSocketChannel 用于创建服务器端套接字,而 SocketChannel 用于创建客户端套接字。它们都支持阻塞和非阻塞模式,通过设置其 blocking 属性来切换。阻塞模式下,读/写操作会一直阻塞直到完成,而非阻塞模式下,读/写操作会立即返回。 - -阻塞模式: - -- 优点:编程简单,适合低并发场景。 -- 缺点:性能较差,不适合高并发场景。 - -非阻塞模式: - -- 优点:性能更好,适合高并发场景。 -- 缺点:编程相对复杂。 - -我们来看一个简单的示例(阻塞模式下): - -先来看 Server 端的: - -```java -public class BlockingServer { - public static void main(String[] args) throws IOException { - // 创建服务器套接字 - ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); - // 绑定端口 - serverSocketChannel.socket().bind(new InetSocketAddress(8080)); - // 设置为阻塞模式(默认为阻塞模式) - serverSocketChannel.configureBlocking(true); - - while (true) { - // 接收客户端连接 - SocketChannel socketChannel = serverSocketChannel.accept(); - // 分配缓冲区 - ByteBuffer buffer = ByteBuffer.allocate(1024); - - // 读取数据 - int bytesRead = socketChannel.read(buffer); - while (bytesRead != -1) { - buffer.flip(); - System.out.println(StandardCharsets.UTF_8.decode(buffer)); - buffer.clear(); - bytesRead = socketChannel.read(buffer); - } - // 关闭套接字 - socketChannel.close(); - } - } -} -``` - -简单解释一下这段代码,也比较好理解。 - -首先创建服务器端套接字ServerSocketChannel,然后绑定 8080 端口,接着使用 while 循环监听客户端套接字。如果接收到客户端连接 SocketChannel,就从通道里读取数据到缓冲区 ByteBuffer,一直读到通道里没有数据,关闭当前通道。 - -其中 `serverSocketChannel.configureBlocking(true)` 用来设置通道为阻塞模式(可以缺省)。 - -再来看客户端的: - -```java -public class BlockingClient { - public static void main(String[] args) throws IOException { - // 创建客户端套接字 - SocketChannel socketChannel = SocketChannel.open(); - // 连接服务器 - socketChannel.connect(new InetSocketAddress("localhost", 8080)); - // 分配缓冲区 - ByteBuffer buffer = ByteBuffer.allocate(1024); - - // 向服务器发送数据 - buffer.put("沉默王二,这是来自客户端的消息。".getBytes(StandardCharsets.UTF_8)); - buffer.flip(); - socketChannel.write(buffer); - // 清空缓冲区 - buffer.clear(); - - // 关闭套接字 - socketChannel.close(); - } -} -``` - -客户端代码就更简单了,建立通道 SocketChannel,连接服务器,然后在缓冲区里放一段数据,之后写入到通道中,关闭套接字。 - -先运行 BlockingServer,再运行 BlockingClient,可以在 Server 端的控制台收到以下信息。 - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407124624.png) - -好,我们再来看非阻塞模式下的示例。 - -先来看 Server 端: - -```java -public class NonBlockingServer { - public static void main(String[] args) throws IOException { - // 创建服务器套接字 - ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); - // 绑定端口 - serverSocketChannel.socket().bind(new InetSocketAddress(8080)); - // 设置为非阻塞模式 - serverSocketChannel.configureBlocking(false); - - // 创建选择器 - Selector selector = Selector.open(); - // 注册服务器套接字到选择器 - serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); - - while (true) { - selector.select(); - Set selectedKeys = selector.selectedKeys(); - Iterator iterator = selectedKeys.iterator(); - - while (iterator.hasNext()) { - SelectionKey key = iterator.next(); - iterator.remove(); - - if (key.isAcceptable()) { - // 接收客户端连接 - SocketChannel socketChannel = serverSocketChannel.accept(); - socketChannel.configureBlocking(false); - socketChannel.register(selector, SelectionKey.OP_READ); - } - - if (key.isReadable()) { - // 读取数据 - SocketChannel socketChannel = (SocketChannel) key.channel(); - ByteBuffer buffer = ByteBuffer.allocate(1024); - int bytesRead = socketChannel.read(buffer); - - if (bytesRead != -1) { - buffer.flip(); - System.out.print(StandardCharsets.UTF_8.decode(buffer)); - buffer.clear(); - } else { - // 客户端已断开连接,取消选择键并关闭通道 - key.cancel(); - socketChannel.close(); - } - } - } - } - } -} -``` - -与之前阻塞模式相同的,我们就不再赘述了,只说不同的。 - -①、首先,创建一个 ServerSocketChannel,并将其设置为非阻塞模式。 - -```java -ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); -serverSocketChannel.configureBlocking(false); -``` - -②、创建一个 Selector 实例,用于处理多个通道的事件。 - -```java -Selector selector = Selector.open(); -``` - -③、将 ServerSocketChannel 注册到 Selector 上,并设置感兴趣的事件为 OP_ACCEPT。这意味着当有新的客户端连接请求时,Selector 会通知我们。 - -```java -serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); -``` - -看一下 OP_ACCEPT 的注释: - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407130621.png) - - -④、循环处理 Selector 中的事件。首先调用 `selector.select()` 方法来等待感兴趣的事件发生。这个方法会阻塞,直到至少有一个感兴趣的事件发生。 - -```java -while (true) { - int readyChannels = selector.select(); - if (readyChannels == 0) { - continue; - } - // ... -} -``` - -⑤、当 `selector.select()` 返回时,我们可以通过 `selector.selectedKeys()` 获取所有已就绪的事件,并对其进行迭代处理。在处理事件时,根据 SelectionKey 的类型来执行相应的操作。 - -```java -Set selectedKeys = selector.selectedKeys(); -Iterator keyIterator = selectedKeys.iterator(); -while (keyIterator.hasNext()) { - SelectionKey key = keyIterator.next(); - // 处理事件 - // ... - keyIterator.remove(); -} -``` - -⑥、当 SelectionKey 的类型为 OP_ACCEPT 时,说明有新的客户端连接请求。此时,我们需要接受新的连接,并将新创建的 SocketChannel 设置为非阻塞模式。然后,将该 SocketChannel 注册到 Selector 上,并设置感兴趣的事件为 OP_READ。 - -```java -if (key.isAcceptable()) { - ServerSocketChannel server = (ServerSocketChannel) key.channel(); - SocketChannel client = server.accept(); - client.configureBlocking(false); - client.register(selector, SelectionKey.OP_READ); -} -``` - -⑦、当 SelectionKey 的类型为 OP_READ 时,说明有客户端发送了数据。我们需要从 SocketChannel 中读取数据,并进行相应的处理。 - -```java -if (key.isReadable()) { - SocketChannel client = (SocketChannel) key.channel(); - ByteBuffer buffer = ByteBuffer.allocate(1024); - int bytesRead = client.read(buffer); - // 对读取到的数据进行处理 - // ... -} -``` - -⑧、(如果可以的话)当 SelectionKey 的类型为 OP_WRITE 时,说明可以向客户端发送数据。我们可以将要发送的数据写入 SocketChannel。 - -```java -if (key.isWritable()) { - SocketChannel client = (SocketChannel) key.channel(); - ByteBuffer buffer = ByteBuffer.wrap("你好,客户端".getBytes()); - client.write(buffer); -} -``` - -不过,本例中并没有这一步。如果需要的话,可以按照这样的方式向客户端写入数据。 - -⑨、在服务器停止运行时,需要关闭 Selector 和 ServerSocketChannel,释放资源。 - -```java -key.cancel(); -socketChannel.close(); -``` - -好,接下来,我们来看客户端的。 - -```java -public class NonBlockingClient { - public static void main(String[] args) throws IOException { - // 创建客户端套接字 - SocketChannel socketChannel = SocketChannel.open(); - // 设置为非阻塞模式 - socketChannel.configureBlocking(false); - // 连接服务器 - socketChannel.connect(new InetSocketAddress("localhost", 8080)); - - while (!socketChannel.finishConnect()) { - // 等待连接完成 - } - - // 分配缓冲区 - ByteBuffer buffer = ByteBuffer.allocate(1024); - - // 向服务器发送数据 - String message = "你好,沉默王二,这是来自客户端的消息。"; - buffer.put(message.getBytes(StandardCharsets.UTF_8)); - buffer.flip(); - socketChannel.write(buffer); - // 清空缓冲区 - buffer.clear(); - - // 关闭套接字 - socketChannel.close(); - } -} -``` - -客户端代码依然比较简单,我们直接略过,不再解释。然后运行 Server,再运行 Client。可以运行多次,结果如下: - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407131553.png) - -### Scatter 和 Gather - -Scatter 和 Gather 是 Java NIO 中两种高效的 I/O 操作,用于将数据分散到多个缓冲区或从多个缓冲区中收集数据。 - -Scatter(分散):它将从 Channel 读取的数据分散(写入)到多个缓冲区。这种操作可以在读取数据时将其分散到不同的缓冲区,有助于处理结构化数据。例如,我们可以将消息头、消息体和消息尾分别写入不同的缓冲区。 - -Gather(聚集):与 Scatter 相反,它将多个缓冲区中的数据聚集(读取)并写入到一个 Channel。这种操作允许我们在发送数据时从多个缓冲区中聚集数据。例如,我们可以将消息头、消息体和消息尾从不同的缓冲区中聚集到一起并写入到同一个 Channel。 - -来写一个完整的 demo,先看 Server。 - -```java -// 创建一个ServerSocketChannel -ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); -serverSocketChannel.socket().bind(new InetSocketAddress(9000)); - -// 接受连接 -SocketChannel socketChannel = serverSocketChannel.accept(); - -// Scatter:分散读取数据到多个缓冲区 -ByteBuffer headerBuffer = ByteBuffer.allocate(128); -ByteBuffer bodyBuffer = ByteBuffer.allocate(1024); - -ByteBuffer[] buffers = {headerBuffer, bodyBuffer}; - -long bytesRead = socketChannel.read(buffers); - -// 输出缓冲区数据 -headerBuffer.flip(); -while (headerBuffer.hasRemaining()) { - System.out.print((char) headerBuffer.get()); -} - -System.out.println(); - -bodyBuffer.flip(); -while (bodyBuffer.hasRemaining()) { - System.out.print((char) bodyBuffer.get()); -} - -// Gather:聚集数据从多个缓冲区写入到Channel -ByteBuffer headerResponse = ByteBuffer.wrap("Header Response".getBytes()); -ByteBuffer bodyResponse = ByteBuffer.wrap("Body Response".getBytes()); - -ByteBuffer[] responseBuffers = {headerResponse, bodyResponse}; - -long bytesWritten = socketChannel.write(responseBuffers); - -// 关闭连接 -socketChannel.close(); -serverSocketChannel.close(); -``` - -再来看 Client: - -```java -// 创建一个SocketChannel -SocketChannel socketChannel = SocketChannel.open(); -socketChannel.connect(new InetSocketAddress("localhost", 9000)); - -// 发送数据到服务器 -String header = "Header Content"; -String body = "Body Content"; - -ByteBuffer headerBuffer = ByteBuffer.wrap(header.getBytes()); -ByteBuffer bodyBuffer = ByteBuffer.wrap(body.getBytes()); - -ByteBuffer[] buffers = {headerBuffer, bodyBuffer}; -socketChannel.write(buffers); - -// 从服务器接收数据 -ByteBuffer headerResponseBuffer = ByteBuffer.allocate(128); -ByteBuffer bodyResponseBuffer = ByteBuffer.allocate(1024); - -ByteBuffer[] responseBuffers = {headerResponseBuffer, bodyResponseBuffer}; - -long bytesRead = socketChannel.read(responseBuffers); - -// 输出接收到的数据 -headerResponseBuffer.flip(); -while (headerResponseBuffer.hasRemaining()) { - System.out.print((char) headerResponseBuffer.get()); -} - -bodyResponseBuffer.flip(); -while (bodyResponseBuffer.hasRemaining()) { - System.out.print((char) bodyResponseBuffer.get()); -} - -// 关闭连接 -socketChannel.close(); -``` - -在这个示例中,我们使用了 Scattering 从 SocketChannel 分散读取数据到多个缓冲区,并使用 Gathering 将数据从多个缓冲区聚集写入到 SocketChannel。通过这种方式,我们可以方便地处理多个缓冲区中的数据。 - -### 异步套接字通道 AsynchronousSocketChannel 和 AsynchronousServerSocketChannel - -AsynchronousSocketChannel 和 AsynchronousServerSocketChannel 是 Java 7 引入的异步 I/O 类,分别用于处理异步客户端 Socket 和服务器端 ServerSocket。异步 I/O 允许在 I/O 操作进行时执行其他任务,并在操作完成时接收通知,提高了并发处理能力。 - -来看一个简单的示例,先看服务器端。 - -```java -public class AsynchronousServer { - - public static void main(String[] args) throws IOException, InterruptedException { - AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(); - server.bind(new InetSocketAddress("localhost", 5000)); - - System.out.println("服务器端启动"); - - server.accept(null, new CompletionHandler() { - @Override - public void completed(AsynchronousSocketChannel client, Void attachment) { - // 接收下一个连接请求 - server.accept(null, this); - - ByteBuffer buffer = ByteBuffer.allocate(1024); - Future readResult = client.read(buffer); - - try { - readResult.get(); - buffer.flip(); - String message = new String(buffer.array(), 0, buffer.remaining()); - System.out.println("接收到的消息: " + message); - } catch (Exception e) { - e.printStackTrace(); - } - } - - @Override - public void failed(Throwable exc, Void attachment) { - exc.printStackTrace(); - } - }); - - // 为了让服务器继续运行,我们需要阻止 main 线程退出 - Thread.currentThread().join(); - } -} -``` - -代码结构和之前讲到的[异步文件通道 AsynchronousFileChannel](https://tobebetterjavaer.com/nio/buffer-channel.html) 比较相似,异步服务单套接字通道 AsynchronousServerSocketChannel 接收客户端连接,每当收到一个新的连接时,会调用 `completed()` 方法,然后读取客户端发送的数据并将其打印到控制台。 - -来简单分析一下吧。 - -①、创建了一个 AsynchronousServerSocketChannel 实例并将其打开。这个通道将用于监听客户端连接。 - -```java -AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(); -``` - -②、调用 `accept()` 方法来接收客户端连接。这个方法需要一个 CompletionHandler 实例,当客户端连接成功时,`completed()` 方法会被调用。 - -```java -server.accept(null, new CompletionHandler() { ... }); -``` - -③、实现 CompletionHandler,I/O 操作成功时,会调用 `completed()` 方法;当 I/O 操作失败时,会调用 `failed()` 方法。 - -```java -new CompletionHandler() { - @Override - public void completed(AsynchronousSocketChannel client, Void attachment) { ... } - - @Override - public void failed(Throwable exc, Void attachment) { ... } -} -``` - -在 completed 方法中,我们首先调用 `server.accept()` 来接收下一个连接请求。然后,我们创建一个缓冲区 ByteBuffer 并使用 `client.read()` 从客户端读取数据。在这个示例中,我们使用了一个 [Future](https://tobebetterjavaer.com/thread/callable-future-futuretask.html) 对象来等待读取操作完成。当读取完成时,我们将缓冲区的内容打印到控制台。 - -④、为了让服务器继续运行并接收客户端连接,我们需要阻止 main 线程退出。 - -```java -Thread.currentThread().join(); -``` - -再来看客户端的: - -```java -public class AsynchronousClient { - - public static void main(String[] args) { - try { - AsynchronousSocketChannel client = AsynchronousSocketChannel.open(); - Future connectResult = client.connect(new InetSocketAddress("localhost", 5000)); - connectResult.get(); // 等待连接完成 - - String message = "沉默王二,在吗?"; - ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8)); - Future writeResult = client.write(buffer); - writeResult.get(); // 等待发送完成 - - System.out.println("消息发送完毕"); - - client.close(); - } catch (IOException | InterruptedException | ExecutionException e) { - e.printStackTrace(); - } - } -} -``` - -就是简单的连接和写入数据,就不多做解释了。这里先运行一下 Server 端,然后再运行一下客户端,看一下结果。 - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407161351.png) - -### 简单的聊天室 - -我们来通过 SocketChannel 和 ServerSocketChannel 实现一个 0.1 版的聊天室,先说一下需求,比较简单,服务端启动监听客户端请求,当客户端向服务器端发送信息后,服务器端接收到后把客户端消息回显给客户端,比较呆瓜,但可以先来看一下。 - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407164326.png) - -我们来看服务器端代码: - -```java -public class ChatServer { - private Selector selector; - private ServerSocketChannel serverSocketChannel; - private static final int PORT = 8080; - - public ChatServer() { - try { - selector = Selector.open(); - serverSocketChannel = ServerSocketChannel.open(); - serverSocketChannel.socket().bind(new InetSocketAddress(PORT)); - serverSocketChannel.configureBlocking(false); - serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); - System.out.println("聊天室服务端启动了 " + PORT); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public void start() { - try { - while (true) { - if (selector.select() > 0) { - Iterator iterator = selector.selectedKeys().iterator(); - while (iterator.hasNext()) { - SelectionKey key = iterator.next(); - iterator.remove(); - handleKey(key); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - } - - private void handleKey(SelectionKey key) throws IOException { - if (key.isAcceptable()) { - SocketChannel socketChannel = serverSocketChannel.accept(); - socketChannel.configureBlocking(false); - socketChannel.register(selector, SelectionKey.OP_READ); - System.out.println("客户端连接上了: " + socketChannel.getRemoteAddress()); - } else if (key.isReadable()) { - SocketChannel socketChannel = (SocketChannel) key.channel(); - ByteBuffer buffer = ByteBuffer.allocate(1024); - int read = socketChannel.read(buffer); - if (read > 0) { - buffer.flip(); - String msg = new String(buffer.array(), 0, read); - System.out.println("客户端说: " + msg); - socketChannel.write(ByteBuffer.wrap(("服务端回复: " + msg).getBytes())); - } - } - } - - public static void main(String[] args) { - new ChatServer().start(); - } -} -``` - -解释一下代码逻辑: - -1、创建一个 ServerSocketChannel,并将其绑定到指定端口。 - -2、将 ServerSocketChannel 设置为非阻塞模式。 - -3、创建一个 Selector,并将 ServerSocketChannel 注册到它上面,监听 OP_ACCEPT 事件(等待客户端连接)。 - -4、无限循环,等待感兴趣的事件发生。 - -5、使用 `Selector.select()` 方法,等待已注册的通道中有事件发生。 - -6、获取到发生事件的通道的 SelectionKey。 - -7、判断 SelectionKey 的事件类型: - -- a. 如果是 OP_ACCEPT 事件,说明有新的客户端连接进来。接受新的连接,并将新连接的 SocketChannel 注册到 Selector 上,监听 OP_READ 事件。 -- b. 如果是 OP_READ 事件,说明客户端发送了消息。读取客户端发送的消息,并将其返回给客户端。 -处理完毕后,清除已处理的 SelectionKey。 - -再来看一下客户端的代码: - -```java -public class ChatClient { - private Selector selector; - private SocketChannel socketChannel; - private static final String HOST = "localhost"; - private static final int PORT = 8080; - - public ChatClient() { - try { - selector = Selector.open(); - socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT)); - socketChannel.configureBlocking(false); - socketChannel.register(selector, SelectionKey.OP_READ); - System.out.println("连接到聊天室了"); - } catch (IOException e) { - e.printStackTrace(); - } - } - - public void start() { - new Thread(() -> { - try { - while (true) { - if (selector.select() > 0) { - for (SelectionKey key : selector.selectedKeys()) { - selector.selectedKeys().remove(key); - if (key.isReadable()) { - readMessage(); - } - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - }).start(); - - try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in - ))) { - String input; - while ((input = reader.readLine()) != null) { - sendMessage(input); - } - } catch (IOException e) { - e.printStackTrace(); - } - } - private void sendMessage(String message) throws IOException { - if (message != null && !message.trim().isEmpty()) { - ByteBuffer buffer = ByteBuffer.wrap(message.getBytes()); - socketChannel.write(buffer); - } - } - - private void readMessage() throws IOException { - ByteBuffer buffer = ByteBuffer.allocate(1024); - int read = socketChannel.read(buffer); - if (read > 0) { - buffer.flip(); - String msg = new String(buffer.array(), 0, read); - System.out.println(msg); - } - } - - public static void main(String[] args) { - new ChatClient().start(); - } -} -``` - -解释一下代码逻辑: - -1、创建一个 SocketChannel,并连接到指定的服务器地址和端口。 - -2、将 SocketChannel 设置为非阻塞模式。 - -3、创建一个 Selector,并将 SocketChannel 注册到它上面,监听 OP_READ 事件(等待接收服务器的消息)。 - -4、启动一个新线程用于读取用户在控制台输入的消息,并发送给服务器。 - -5、无限循环,等待感兴趣的事件发生。 - -6、使用` Selector.select()` 方法,等待已注册的通道中有事件发生。 - -7、获取到发生事件的通道的 SelectionKey。 - -8、判断 SelectionKey 的事件类型: - -- a. 如果是 OP_READ 事件,说明服务器发送了消息。读取服务器发送的消息,并在控制台显示。 -处理完毕后,清除已处理的 SelectionKey。 - -来看运行后的效果。 - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407164913.png) - -好,接下来,我们来升级一下需求,也就是 0.2 版聊天室,要求服务器端也能从控制台敲入信息主动发送给客户端。 - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407165110.png) - -来看服务器端代码: - -```java -public class Chat2Server { - - public static void main(String[] args) throws IOException { - // 创建一个 ServerSocketChannel - ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); - serverSocketChannel.configureBlocking(false); - serverSocketChannel.bind(new InetSocketAddress(8080)); - - // 创建一个 Selector - Selector selector = Selector.open(); - serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); - System.out.println("聊天室服务端启动了"); - - // 客户端连接 - AtomicReference clientRef = new AtomicReference<>(); - - // 从控制台读取输入并发送给客户端 - Thread sendMessageThread = new Thread(() -> { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { - while (true) { - System.out.println("输入服务器端消息: "); - String message = reader.readLine(); - SocketChannel client = clientRef.get(); - if (client != null && client.isConnected()) { - ByteBuffer buffer = ByteBuffer.wrap((message + "\n").getBytes()); - client.write(buffer); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - }); - sendMessageThread.start(); - - while (true) { - int readyChannels = selector.select(); - - if (readyChannels == 0) { - continue; - } - - Set selectedKeys = selector.selectedKeys(); - Iterator keyIterator = selectedKeys.iterator(); - - while (keyIterator.hasNext()) { - SelectionKey key = keyIterator.next(); - - if (key.isAcceptable()) { - // 接受客户端连接 - SocketChannel client = serverSocketChannel.accept(); - System.out.println("客户端已连接"); - client.configureBlocking(false); - client.register(selector, SelectionKey.OP_READ); - clientRef.set(client); - } else if (key.isReadable()) { - // 读取客户端消息 - SocketChannel channel = (SocketChannel) key.channel(); - ByteBuffer buffer = ByteBuffer.allocate(1024); - int bytesRead = channel.read(buffer); - - if (bytesRead > 0) { - buffer.flip(); - byte[] bytes = new byte[buffer.remaining()]; - buffer.get(bytes); - String message = new String(bytes).trim(); - System.out.println("客户端消息: " + message); - } - } - keyIterator.remove(); - } - } - } -} -``` - -再来看客户端代码: - -```java -public class Chat2Client { - - public static void main(String[] args) throws IOException { - // 创建一个 SocketChannel - SocketChannel socketChannel = SocketChannel.open(); - socketChannel.configureBlocking(false); - socketChannel.connect(new InetSocketAddress("localhost", 8080)); - - // 创建一个 Selector - Selector selector = Selector.open(); - socketChannel.register(selector, SelectionKey.OP_CONNECT); - - // 从控制台读取输入并发送给服务器端 - Thread sendMessageThread = new Thread(() -> { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) { - while (true) { - System.out.println("输入客户端消息: "); - String message = reader.readLine(); - if (socketChannel.isConnected()) { - ByteBuffer buffer = ByteBuffer.wrap((message + "\n").getBytes()); - socketChannel.write(buffer); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - }); - sendMessageThread.start(); - - while (true) { - int readyChannels = selector.select(); - - if (readyChannels == 0) { - continue; - } - - Set selectedKeys = selector.selectedKeys(); - Iterator keyIterator = selectedKeys.iterator(); - - while (keyIterator.hasNext()) { - SelectionKey key = keyIterator.next(); - - if (key.isConnectable()) { - // 连接到服务器 - socketChannel.finishConnect(); - socketChannel.register(selector, SelectionKey.OP_READ); - System.out.println("已连接到服务器"); - } else if (key.isReadable()) { - // 读取服务器端消息 - ByteBuffer buffer = ByteBuffer.allocate(1024); - int bytesRead = socketChannel.read(buffer); - - if (bytesRead > 0) { - buffer.flip(); - byte[] bytes = new byte[buffer.remaining()]; - buffer.get(bytes); - String message = new String(bytes).trim(); - System.out.println("服务器端消息: " + message); - } - } - keyIterator.remove(); - } - } - } -} -``` - -运行 Server,再运行 Client,交互信息如下: - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407180853.png) - -我们使用了 Selector 和非阻塞 I/O,这使得服务器可以同时处理多个连接。所以我们在 Intellij IDEA 中可以再配置一个客户端,见下图(填上这四项内容)。 - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407181717.png) - -然后启动,就可以完成一个 Server 和多个 Client 交互了。 - -![](https://cdn.tobebetterjavaer.com/stutymore/network-connect-20230407181906.png) - -OK,关于聊天室,我们就先讲到这里。 - -### 小结 - -前面我们了解到,Java NIO 在文件 IO 上的性能其实和传统 IO 差不多,甚至在处理大文件的时候还有些甘拜下风,但 NIO 的主要作用体现在网络 IO 上,像 [Netty](https://tobebetterjavaer.com/netty/rumen.html) 框架底层其实就是 NIO,我们来做一下简单的总结吧。 - -SocketChannel(用于 TCP 连接)和 ServerSocketChannel(用于监听和接受新的 TCP 连接)可以用来替代传统的 Socket 和 ServerSocket 类,提供非阻塞模式。 - -NIO 支持阻塞和非阻塞模式。非阻塞模式允许程序在等待 I/O 时执行其他任务,从而提高并发性能。非阻塞模式的实现依赖于 Selector,它可以监控多个通道上的 I/O 事件。 - -NIO 支持将数据分散到多个 Buffer(Scatter)或从多个 Buffer 收集数据(Gather),提供了更高效的数据传输方式。 - -Java NIO.2 引入了 AsynchronousSocketChannel 和 AsynchronousServerSocketChannel,这些类提供了基于回调的异步 I/O 操作。异步套接字通道可以在完成 I/O 操作时自动触发回调函数,从而实现高效的异步处理。 - -最后,我们使用 NIO 实现了简单的聊天室功能。通过 ServerSocketChannel 和 SocketChannel 创建服务端和客户端,实现互相发送和接收消息。在处理多个客户端时,可以使用 Selector 来管理多个客户端连接,提高并发性能。 - -总之,Java NIO 网络编程实践提供了更高效、灵活且可扩展的 I/O 处理方式,对于大型应用程序和高并发场景具有显著优势。 - - -## 12.6 Java IO模型 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[12.6 Java IO模型](https://tobebetterjavaer.com/nio/moxing.html) - -# 第十三章:Java 重要知识点 - - - - -## 13.1 Java命名规范 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[13.1 Java命名规范](https://tobebetterjavaer.com/basic-extra-meal/java-naming.html) - - -## 13.2 中文乱码及字符编码全攻略 - -就在昨天,我在[二哥的编程星球](https://tobebetterjavaer.com/zhishixingqiu/)里看到这样一张截图,有球友反馈说支付宝的理财页面出现了中文乱码,估计不少小伙伴和我一样,都惊呆了😮!阿里这种大厂还能出现这种低级错误? - -![](https://cdn.tobebetterjavaer.com/stutymore/java-unicode-20230408135521.png) - -难道昨天的人力都投入到“通义千问”的突然发布上了? - -那借这个机会,我就来和大家探讨一下,中文乱码是怎么出现的,以及如何快速解决乱码问题。 - -### 什么是中文乱码 - -Q:什么是中文乱码? - -GPT4:中文乱码是指在计算机系统或软件中,由于字符编码不一致或处理不当,导致中文字符无法正常显示,出现乱码现象。这种现象通常表现为中文字符被替换成其他不相关的字符、符号或乱码。 - -比如说,历史上著名的“锟斤拷”同学,就是中文乱码的绝佳代言人。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/ten-06.png) - -### 什么是字符编码 - -Q:那什么是字符编码呢? - -GPT4:字符编码(Character Encoding)是一种规则或约定,它将字符(如字母、数字、标点符号等)映射到计算机中的二进制数据。简单来说,字符编码就是将人类可读的字符转换为计算机可以处理的二进制数据的过程。 - -对于计算机来说,只认 0 和 1,所有的信息最终都是一个二进制数。一个二进制数要么是 0,要么是 1。8 个二进制数放在一起是一个字节,就会组合出 256 种状态,也就是 2 的 8 次方(`2^8`),从 00000000 到 11111111。 - -为了让计算机能够识别和处理各种字符,需要使用字符编码来对字符进行编码和解码。常见的字符编码包括 ASCII、UTF-8、UTF-16、GBK、GB2312 等。 - -![](https://cdn.tobebetterjavaer.com/studymore/char-byte-20230322174312.png) - -#### **1)ASCII** - -ASCII 码由电报码发展而来,第一版标准发布于 1963 年,最后一次更新则是在 1986 年,至今为止共定义了 128 个字符。其中 33 个字符无法显示在一般的设备上,需要用特殊的设备才能显示。 - -ASCII 码的局限在于只能显示 26 个基本拉丁字母、阿拉伯数字和英式标点符号,因此只能用于显示现代美国英语,对于其他一些语言则无能无力,比如在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/ten-01.png) - -PS:拉丁字母(也称为罗马字母)是多数欧洲语言采用的字母系统,是世界上最通行的字母文字系统,是罗马文明的成果之一。 - -虽然名称上叫作拉丁字母,但拉丁文中并没有用 J、U 和 W 这三个字母。 - -在我的印象中,可能说拉丁字母多少有些陌生,说英语字母可能就有直观的印象了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/ten-02.png) - -阿拉伯数字,我们都很熟悉了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/ten-03.png) - -但是,阿拉伯数字并非起源于阿拉伯,而是起源于古印度。学过历史的你应该有一些印象,阿拉伯分布于西亚和北非,以阿拉伯语为主要语言,以伊斯兰教为主要信仰。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/ten-04.png) - -处在这样的地理位置,做起东亚和欧洲的一些生意就很有优势,于是阿拉伯数字就由阿拉伯人传到了欧洲,因此得名。 - -英式标点符号,也叫英文标点符号,和中文标点符号很相近。标点符号是辅助文字记录语言的符号,是书面语的组成部分,用来表示停顿、加强语气等。 - -英文标点符号在 16 世纪时,分为朗诵学派和句法学派,主要由古典时期的希腊文和拉丁文演变而来,在 17 世纪后进入稳定阶段。俄文的标点符号依据希腊文而来,到了 18 世纪后也采用了英文标点符号。 - -在很多人的印象中,古文是没有标点符号的,但管锡华博士研究指出,**中国早在先秦时代就有标点符号了**,后来融合了一些英文标点符号后,逐渐形成了现在的中文标点符号。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/ten-05.png) - - -#### **2)Unicode** - -这个世界上,除了英语,还有法语、葡萄牙语、西班牙语、德语、俄语、阿拉伯语、韩语、日语等等等等。ASCII 码用来表示英语是绰绰有余的,但其他这些语言就没办法了。 - -像我的主人二哥的母语——中文,就博大精深,与其对应的汉字数量很多很多,东汉的《说文解字》收字 9353 个,清朝《康熙字典》收字 47035 个,当代的《汉语大字典》收字 60370 个。1994 年中华书局、中国友谊出版公司出版的《中华字海》收字 85568 个。 - ->常用字大概 2500 个,次常用字 1000 个。 - -一个字节只能表示 256 种符号,所以如果拿 ASCII 码来表示汉字的话,是远远不够用的,那就必须要用更多的字节。简体中文常见的编码方式是 GB2312,使用两个字节表示一个汉字,理论上最多可以表示 256 x 256 = 65536 个符号。 - -要知道,世界上存在着多种编码方式,同一个二进制数字可以被解释成不同的符号。因此,要想打开一个文本文件,就必须知道它的编码方式,否则用错误的编码方式解读,就会出现乱码。 - -- 编码就是将原始数据(比如说文本、图像、视频、音频等)转换为二进制形式。 -- 解码就是将二进制数据转换为原始数据,是一个反向的过程。 - -如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会彻底消失。 - -这个艰巨的任务由谁来完成呢?**Unicode**,中文译作万国码、国际码、统一码、单一码,就像它的名字都表示的,这是一种所有符号的编码。 - -Unicode 至今仍在不断增修,每个新版本都会加入更多新的字符。目前最新的版本为 2020 年 3 月公布的 13.0,收录了 13 万个字符。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/ten-07.png) - -Unicode 是一个很大的集合,现在的规模可以容纳 100 多万个符号。每个符号的编码都不一样,比如,`U+0639`表示阿拉伯字母 `Ain`,`U+0041` 表示英语的大写字母 `A`,`U+4E25` 表示汉字`严`。 - -具体的符号对应表,可以查询: - ->- unicode.org:[http://www.unicode.org](http://www.unicode.org) ->- 汉字对应表:[ttp://www.chi2ko.com/tool/CJK.htm](http://www.chi2ko.com/tool/CJK.htm) - -曾有人这样说: - ->Unicode 支持的字符上限是 65536 个,Unicode 字符必须占两个字节。 - -但这是一种误解,记住,Unicode 只是一个用来映射字符和数字的标准。它对支持字符的数量没有限制,也不要求字符必须占两个、三个或者其它任意数量的字节,所以它可以无穷大。 - -#### 3)UTF-8 - -Unicode 虽然统一了全世界字符的编码,但没有规定如何存储。如果统一规定的话,每个符号就要用 3 个或 4 个字节表示,因为 2 个字节只能表示 65536 个,根本表示不全。 - -那怎么办呢? - -UTF(Unicode Transformation Formats,Unicode 的编码方式)来了!最常见的就是 UTF-8 和 UTF-16。 - -在 UTF-8 中,0-127 号的字符用 1 个字节来表示,使用和 ASCII 相同的编码。只有 128 号及以上的字符才用 2 个、3 个或者 4 个字节来表示。 - -如果只有一个字节,那么最高的比特位为 0;如果有多个字节,那么第一个字节从最高位开始,连续有几个比特位的值为 1,就使用几个字节编码,剩下的字节均以 10 开头。 - -具体的表现形式为: - -- 0xxxxxxx:一个字节; -- 110xxxxx 10xxxxxx:两个字节编码形式(开始两个 1); -- 1110xxxx 10xxxxxx 10xxxxxx:三字节编码形式(开始三个 1); -- 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx:四字节编码形式(开始四个 1)。 - -也就是说,UTF-8 是一种可变长度的编码方式——这是它的优势也是劣势。 - -怎么讲呢?优势就是它包罗万象,劣势就是浪费空间。举例来说吧,UTF-8 采用了 3 个字节(`256*256*256=16777216`)来编码常用的汉字,但常用的汉字没有这么多,这对于计算机来说,就是一种严重的资源浪费。 - -#### 4)GB2312 - -基于这样的考虑,中国国家标准总局于 1980 年发布了 GB2312 编码,即中华人民共和国国家标准简体中文字符集。GB2312 标准共收录 6763 个汉字(2 个字节就够用了),其中一级汉字 3755 个,二级汉字 3008 个;同时收录了包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母在内的 682 个字符。 - -GB2312 的出现,基本满足了汉字的计算机处理需求。对于人名、古汉语等方面出现的罕用字和繁体字,GB2312 不能处理,就有了 GBK(K 为“扩展”的汉语拼音(kuòzhǎn)第一个声母)。 - -### 中文乱码是如何出现的? - -Q:来一串出现中文乱码的 demo,中文为沉默王二 - -GPT4:下面是一个可能导致中文乱码的示例。首先使用 GBK 编码将中文字符串 "沉默王二" 转换为字节数组,然后使用 UTF-8 编码将字节数组转回字符串。这将导致字符串在解码时出现乱码。 - -```java -public class EncodingDemo { - public static void main(String[] args) { - String originalStr = "沉默王二"; - String encodedStr = ""; - - try { - byte[] bytes = originalStr.getBytes("GBK"); - encodedStr = new String(bytes, "UTF-8"); - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - - System.out.println("转码前: " + originalStr); - System.out.println("转码后: " + encodedStr); - } -} -``` - -程序会输出什么呢? - -``` -转码前: 沉默王二 -转码后: ��Ĭ���� -``` - -嘿嘿,乱码来了!在 Unicode 中,� 是一个特殊的符号,它用来表示无法显示,它的十六进制是 `0xEF 0xBF 0xBD`。那么两个 �� 就是 `0xEF 0xBF 0xBD 0xEF 0xBF 0xBD`,如果用 GBK 进行解码的话,就是大名鼎鼎的“**锟斤拷**”。 - -可以通过代码来验证一下: - -```java -// 输出 efbfbdefbfbd -char[] kuijinkao = HexUtil.encodeHex("��", StandardCharsets.UTF_8); -System.out.println(kuijinkao); -// 借助 hutool 转成二进制 -byte[] testBytes = HexUtil.decodeHex(kuijinkao); -// 使用 GBK 解码 -String testResult = new String(testBytes, Charset.forName("GBK")); -// 输出锟斤拷 -System.out.println(testResult); -``` - -来看一下输出结果: - -```java -efbfbdefbfbd -锟斤拷 -``` - -所以,以后再见到**锟斤拷**,第一时间想到 UTF-8 和 GBK 的转换问题准没错。 - -### 如何解决中文乱码呢? - -Q:如何解决中文乱码呢? - -GPT4:解决中文乱码问题需要确保在编码和解码过程中使用相同的字符编码。这是一个修改过的示例,展示了如何避免中文乱码问题: - -```java -String originalStr = "沉默王二"; - -byte[] bytes = originalStr.getBytes(StandardCharsets.UTF_8); -String encodedStr = new String(bytes, StandardCharsets.UTF_8); - -System.out.println("正确转码前: " + originalStr); -System.out.println("正确转码后: " + encodedStr); -``` - -输出结果: - -```java -正确转码前: 沉默王二 -正确转码后: 沉默王二 -``` - -除此之外,以下是一些避免乱码的实践: - -- 使用统一的字符编码,如 UTF-8。它包含了所有 Unicode 字符,并且广泛支持。确保在所有平台和系统上使用相同的编码。 -- 在处理文本文件时,始终明确指定字符编码。例如,在 Java 中使用 InputStreamReader、OutputStreamWriter 时,指定编码参数。 -- 在 HTML、XML 等文档中,指定字符编码。例如,在 HTML 页面中添加 ``。 -- 对于数据库,确保使用正确的字符集。在创建数据库和表时,明确指定字符集,如 utf8mb4。 - -当与第三方服务通信时,确保双方使用相同的字符编码。在处理 API 请求和响应时,检查字符编码是否匹配。 - -Q:针对支付宝这种生产环境事故,如何快速定位并解决问题呢? - -GPT4: - -1、收集信息:了解问题的具体表现,例如哪些页面或功能受到影响,问题的严重程度,以及用户反馈。这将有助于快速定位问题范围。 - -2、检查日志:查看服务器日志和应用程序日志,检查是否存在异常信息或错误。日志信息有助于识别问题发生的时间和具体原因。 - -3、复现问题:在测试环境中尝试复现问题,以便更深入地了解问题细节。如果无法复现问题,可能需要进一步收集生产环境的详细信息。 - -4、定位问题:根据收集到的信息,确定问题可能发生的代码或系统部分。检查相关代码,特别是涉及字符编码、数据传输、数据库操作的部分。 - -5、解决问题:修复问题代码,确保字符编码在整个系统中保持一致。进行单元测试和集成测试,验证问题是否已解决。 - -6、部署修复:将修复后的代码部署到生产环境。为了避免影响正常服务,可以考虑先在一个小规模的生产环境中进行验证,然后逐步扩大部署范围。 - -7、监控:在修复后持续关注系统运行状况,确保问题已得到解决。收集用户反馈,确认用户满意度。 - -8、总结经验:针对此次问题,总结经验教训,分析问题产生的根本原因,优化开发和测试流程,避免类似问题再次发生。 - - - -## 13.3 深入剖析Java中的拆箱和装箱 - -“哥,听说 Java 的每个[基本类型](https://tobebetterjavaer.com/basic-grammar/basic-data-type.html)都对应了一个包装类型,比如说 int 的包装类型为 Integer,double 的包装类型为 Double,是这样吗?”从三妹这句话当中,能听得出来,她已经提前预习这块内容了。 - -“是的,三妹。基本类型和包装类型的区别主要有以下 4 点,我来带你学习一下。”我回答说。我们家的斜对面刚好是一所小学,所以时不时还能听到朗朗的读书声,让人心情非常愉快。 - -“三妹,你准备好了吗?我们开始吧。” - -“第一,**包装类型可以为 null,而基本类型不可以**。别小看这一点区别,它使得包装类型可以应用于 POJO 中,而基本类型则不行。” - -“POJO 是什么呢?”遇到不会的就问,三妹在这一点上还是非常兢兢业业的。 - -“POJO 的英文全称是 Plain Ordinary Java Object,翻译一下就是,简单无规则的 Java 对象,只有字段以及对应的 setter 和 getter 方法。” - -```java -class Writer { - private Integer age; - private String name; - - public Integer getAge() { - return age; - } - - public void setAge(Integer age) { - this.age = age; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} -``` - -和 POJO 类似的,还有数据传输对象 DTO(Data Transfer Object,泛指用于展示层与服务层之间的数据传输对象)、视图对象 VO(View Object,把某个页面的数据封装起来)、持久化对象 PO(Persistant Object,可以看成是与数据库中的表映射的 Java 对象)。 - -“那为什么 POJO 的字段必须要用包装类型呢?”三妹问。 - -“《[阿里巴巴 Java 开发手册](https://tobebetterjavaer.com/pdf/ali-java-shouce.html)》上有详细的说明,你看。”我打开 PDF,并翻到了对应的内容,指着屏幕念道。 - ->数据库的查询结果可能是 null,如果使用基本类型的话,因为要自动拆箱,就会抛出 [NullPointerException 的异常](https://tobebetterjavaer.com/exception/npe.html)。 - -“什么是自动拆箱呢?” - -“自动拆箱指的是,将包装类型转为基本类型,比如说把 Integer 对象转换成 int 值;对应的,把基本类型转为包装类型,则称为自动装箱。” - -“哦。” - -“那接下来,我们来看第二点不同。**包装类型可用于泛型,而基本类型不可以**,否则就会出现编译错误。”一边说着,我一边在 Intellij IDEA 中噼里啪啦地敲了起来。 - -“三妹,你瞧,编译器提示错误了。” - -```java -List list = new ArrayList<>(); // 提示 Syntax error, insert "Dimensions" to complete ReferenceType -List list = new ArrayList<>(); -``` - -“为什么呢?”三妹及时地问道。 - -“因为[泛型](https://tobebetterjavaer.com/basic-extra-meal/generic.html)在编译时会进行类型擦除,最后只保留原始类型,而原始类型只能是 Object 类及其子类——基本类型是个例外。” - -“那,接下来,我们来说第三点,**基本类型比包装类型更高效**。”我喝了一口茶继续说道。 - -“作为局部变量时,基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。”我一边说着,一边打开 [`draw.io`](https://app.diagrams.net/) 画起了图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/box-01.png) - -很显然,相比较于基本类型而言,包装类型需要占用更多的内存空间,不仅要存储对象,还要存储引用。假如没有基本类型的话,对于数值这类经常使用到的数据来说,每次都要通过 new 一个包装类型就显得非常笨重。 - -“三妹,你想知道程序运行时,数据都存储在什么地方吗?” - -“嗯嗯,哥,你说说呗。” - -“通常来说,有 4 个地方可以用来存储数据。” - -1)寄存器。这是最快的存储区,因为它位于 CPU 内部,用来暂时存放参与运算的数据和运算结果。 - -2)栈。位于 RAM(Random Access Memory,也叫主存,与 CPU 直接交换数据的内部存储器)中,速度仅次于寄存器。但是,在分配内存的时候,存放在栈中的数据大小与生存周期必须在编译时是确定的,缺乏灵活性。基本数据类型的值和对象的引用通常存储在这块区域。 - -3)堆。也位于 RAM 区,可以动态分配内存大小,编译器不必知道要从堆里分配多少存储空间,生存周期也不必事先告诉编译器,Java 的垃圾收集器会自动收走不再使用的数据,因此可以得到更大的灵活性。但是,运行时动态分配内存和销毁对象都需要占用时间,所以效率比栈低一些。new 创建的对象都会存储在这块区域。 - -4)磁盘。如果数据完全存储在程序之外,就可以不受程序的限制,在程序没有运行时也可以存在。像文件、数据库,就是通过持久化的方式,让对象存放在磁盘上。当需要的时候,再反序列化成程序可以识别的对象。 - -“能明白吗?三妹?” - -“这节讲完后,我再好好消化一下。” - -“那好,我们来说第四点,**两个包装类型的值可以相同,但却不相等**。” - -```java -Integer chenmo = new Integer(10); -Integer wanger = new Integer(10); - -System.out.println(chenmo == wanger); // false -System.out.println(chenmo.equals(wanger )); // true -``` - -“两个包装类型在使用“==”进行判断的时候,判断的是其指向的地址是否相等,由于是两个对象,所以地址是不同的。” - -“而 `chenmo.equals(wanger)` 的输出结果为 true,是因为 `equals()` 方法内部比较的是两个 int 值是否相等。” - -```java -private final int value; - -public int intValue() { - return value; -} -public boolean equals(Object obj) { - if (obj instanceof Integer) { - return value == ((Integer)obj).intValue(); - } - return false; -} -``` - -虽然 chenmo 和 wanger 的值都是 10,但他们并不相等。换句话说就是:将“==”操作符应用于包装类型比较的时候,其结果很可能会和预期的不符。 - -“三妹,瞧,`((Integer)obj).intValue()` 这段代码就是用来自动拆箱的。下面,我们来详细地说一说自动装箱和自动拆箱。” - -既然有基本类型和包装类型,肯定有些时候要在它们之间进行转换。把基本类型转换成包装类型的过程叫做装箱(boxing)。反之,把包装类型转换成基本类型的过程叫做拆箱(unboxing)。 - -在 Java 1.5 之前,开发人员要手动进行装拆箱,比如说: - -```java -Integer chenmo = new Integer(10); // 手动装箱 -int wanger = chenmo.intValue(); // 手动拆箱 -``` - -Java 1.5 为了减少开发人员的工作,提供了自动装箱与自动拆箱的功能。这下就方便了。 - -```jav -Integer chenmo = 10; // 自动装箱 -int wanger = chenmo; // 自动拆箱 -``` - -来看一下反编译后的代码。 - -```java -Integer chenmo = Integer.valueOf(10); -int wanger = chenmo.intValue(); -``` - -也就是说,自动装箱是通过 `Integer.valueOf()` 完成的;自动拆箱是通过 `Integer.intValue()` 完成的。 - -“嗯,三妹,给你出一道面试题吧。” - -```java -// 1)基本类型和包装类型 -int a = 100; -Integer b = 100; -System.out.println(a == b); - -// 2)两个包装类型 -Integer c = 100; -Integer d = 100; -System.out.println(c == d); - -// 3) -c = 200; -d = 200; -System.out.println(c == d); -``` - -“给你 3 分钟时间,你先思考下,我去抽根华子,等我回来,然后再来分析一下为什么。” - -。。。。。。 - -“嗯,哥,你过来吧,我说一说我的想法。” - -第一段代码,基本类型和包装类型进行 == 比较,这时候 b 会自动拆箱,直接和 a 比较值,所以结果为 true。 - -第二段代码,两个包装类型都被赋值为了 100,这时候会进行自动装箱,按照你之前说的,将“==”操作符应用于包装类型比较的时候,其结果很可能会和预期的不符,我想结果可能为 false。 - -第三段代码,两个包装类型重新被赋值为了 200,这时候仍然会进行自动装箱,我想结果仍然为 false。 - -“嗯嗯,三妹,你分析的很有逻辑,但第二段代码的结果为 true,是不是感到很奇怪?” - -“为什么会这样呀?”三妹急切地问。 - -“你说的没错,自动装箱是通过 `Integer.valueOf()` 完成的,我们来看看这个方法的源码就明白为什么了。” - -```java -public static Integer valueOf(int i) { - if (i >= IntegerCache.low && i <= IntegerCache.high) - return IntegerCache.cache[i + (-IntegerCache.low)]; - return new Integer(i); -} -``` - -是不是看到了一个之前从来没见过的类——[IntegerCache](https://tobebetterjavaer.com/basic-extra-meal/int-cache.html)? - -“难道说是 Integer 的缓存类?”三妹做出了自己的判断。 - -“是的,来看一下 IntegerCache 的源码吧。” - -```java -private static class IntegerCache { - static final int low = -128; - static final int high; - static final Integer cache[]; - - static { - // high value may be configured by property - int h = 127; - int i = parseInt(integerCacheHighPropValue); - i = Math.max(i, 127); - h = Math.min(i, Integer.MAX_VALUE - (-low) -1); - high = h; - - cache = new Integer[(high - low) + 1]; - int j = low; - for(int k = 0; k < cache.length; k++) - cache[k] = new Integer(j++); - - // range [-128, 127] must be interned (JLS7 5.1.7) - assert IntegerCache.high >= 127; - } -} -``` - -大致瞟一下这段代码你就全明白了。-128 到 127 之间的数会从 IntegerCache 中取,然后比较,所以第二段代码(100 在这个范围之内)的结果是 true,而第三段代码(200 不在这个范围之内,所以 new 出来了两个 Integer 对象)的结果是 false。 - -“三妹,看完上面的分析之后,我希望你记住一点:**当需要进行自动装箱时,如果数字在 -128 至 127 之间时,会直接使用缓存中的对象,而不是重新创建一个对象**。” - -“自动装拆箱是一个很好的功能,大大节省了我们开发人员的精力,但也会引发一些麻烦,比如下面这段代码,性能就很差。” - -```java -long t1 = System.currentTimeMillis(); -Long sum = 0L; -for (int i = 0; i < Integer.MAX_VALUE;i++) { - sum += i; -} -long t2 = System.currentTimeMillis(); -System.out.println(t2-t1); -``` - -“知道为什么吗?三妹。” - -“难道是因为 sum 被声明成了包装类型 Long 而不是基本类型 long。”三妹若有所思。 - -“是滴,由于 sum 是个 Long 型,而 i 为 int 类型,`sum += i` 在执行的时候,会先把 i 强转为 long 型,然后再把 sum 拆箱为 long 型进行相加操作,之后再自动装箱为 Long 型赋值给 sum。” - -“三妹,你可以试一下,把 sum 换成 long 型比较一下它们运行的时间。” - -。。。。。。 - -“哇,sum 为 Long 型的时候,足足运行了 5825 毫秒;sum 为 long 型的时候,只需要 679 毫秒。” - -“好了,三妹,今天的主题就先讲到这吧。我再去来根华子。” - - - -## 13.4 深入理解Java浅拷贝与深拷贝 - -“哥,听说浅拷贝和深拷贝是 Java 面试中经常会被问到的一个问题,是这样吗?” - -“还真的是,而且了解浅拷贝和深拷贝的原理,对 [Java 是值传递还是引用传递](https://tobebetterjavaer.com/basic-extra-meal/pass-by-value.html)也会有更深的理解。”我肯定地回答。 - -“不管是浅拷贝还是深拷贝,都可以通过调用 Object 类的 `clone()` 方法来完成。”我一边说,一边打开 Intellij IDEA,并找到了 `clone()` 方法的源码。 - -```java -protected native Object clone() throws CloneNotSupportedException; -``` - -需要注意的是,`clone()` 方法同时是一个本地(`native`)方法,它的具体实现会交给 HotSpot 虚拟机,那就意味着虚拟机在运行该方法的时候,会将其替换为更高效的 C/C++ 代码,进而调用操作系统去完成对象的克隆工作。 - ->Java 9 后,该方法会被标注 `@HotSpotIntrinsicCandidate` 注解,被该注解标注的方法,在 HotSpot 虚拟机中会有一套高效的实现。 - -“哥,那你就先说浅拷贝吧!” - -“好的呀。直接上实战代码。” - -```java -class Writer implements Cloneable{ - private int age; - private String name; - - // getter/setter 和构造方法都已省略 - - @Override - public String toString() { - return super.toString().substring(26) + "{" + - "age=" + age + - ", name='" + name + '\'' + - '}'; - } -} -``` - -Writer 类有两个字段,分别是 int 类型的 age,和 String 类型的 name。然后重写了 `toString()` 方法,方便打印对象的具体信息。 - -“为什么要实现 Cloneable 接口呢?”三妹开启了十万个为什么的模式。 - -Cloneable 接口是一个标记接口,它肚子里面是空的: - -```java -public interface Cloneable { -} -``` - -只是,如果一个类没有实现 Cloneable 接口,即便它重写了 `clone()` 方法,依然是无法调用该方法进行对象克隆的,程序在执行 `clone()` 方法的时候会抛出 CloneNotSupportedException 异常。 - -```java -Exception in thread "main" java.lang.CloneNotSupportedException -``` - -标记接口的作用其实很简单,用来表示某个功能在执行的时候是合法的。 - -“哦,我悟了!”三妹看来是彻底明白了我说的内容。 - -“接着,来测试类。” - -```java -class TestClone { - public static void main(String[] args) throws CloneNotSupportedException { - Writer writer1 = new Writer(18,"二哥"); - Writer writer2 = (Writer) writer1.clone(); - - System.out.println("浅拷贝后:"); - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - - writer2.setName("三妹"); - - System.out.println("调整了 writer2 的 name 后:"); - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - } -} -``` - -- 通过 new 关键字声明了一个 Writer 对象(18 岁的二哥),将其赋值给 writer1。 -- 通过调用 `clone()` 方法进行对象拷贝,并将其赋值给 writer2。 -- 之后打印 writer1 和 writer2。 -- 将 writer2 的 name 字段调整为“三妹”。 -- 再次打印。 - -来看一下输出结果。 - -``` -浅拷贝后: -writer1:Writer@68837a77{age=18, name='二哥'} -writer2:Writer@b97c004{age=18, name='二哥'} -调整了 writer2 的 name 后: -writer1:Writer@68837a77{age=18, name='二哥'} -writer2:Writer@b97c004{age=18, name='三妹'} -``` - -可以看得出,浅拷贝后,writer1 和 writer2 引用了不同的对象,但值是相同的,说明拷贝成功。之后,修改了 writer2 的 name 字段,直接上图就明白了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/deep-copy-01.png) - - 之前的例子中,Writer 类只有两个字段,没有引用类型字段。那么,我们再来看另外一个例子,为 Writer 类增加一个自定义的引用类型字段 Book,先来看 Book 的定义。 - -```java -class Book { - private String bookName; - private int price; - - // getter/setter 和构造方法都已省略 - - @Override - public String toString() { - return super.toString().substring(26) + - " bookName='" + bookName + '\'' + - ", price=" + price + - '}'; - } -} -``` - -有两个字段,分别是 String 类型的 bookName 和 int 类型的 price。 - -然后来看 Writer 类的定义。 - -```java -class Writer implements Cloneable{ - private int age; - private String name; - private Book book; - - // getter/setter 和构造方法都已省略 - - @Override - public String toString() { - return super.toString().substring(26) + - " age=" + age + - ", name='" + name + '\'' + - ", book=" + book + - '}'; - } - - @Override - protected Object clone() throws CloneNotSupportedException { - return super.clone(); - } -} -``` - -比之前的例子多了一个自定义类型的字段 book,`clone()` 方法并没有任何改变。 - -再来看测试类。 - -```java -class TestClone { - public static void main(String[] args) throws CloneNotSupportedException { - Writer writer1 = new Writer(18,"二哥"); - Book book1 = new Book("编译原理",100); - writer1.setBook(book1); - - Writer writer2 = (Writer) writer1.clone(); - System.out.println("浅拷贝后:"); - - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - - Book book2 = writer2.getBook(); - book2.setBookName("永恒的图灵"); - book2.setPrice(70); - System.out.println("writer2.book 变更后:"); - - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - } -} -``` - -- 通过 new 关键字声明了一个 Writer 对象(18 岁的二哥),将其赋值给 writer1。 -- 通过 new 关键字声明了一个 Book 对象(100 块的编译原理),将其赋值给 book1。 -- 将 writer1 的 book 字段设置为 book1。 -- 通过调用 `clone()` 方法进行对象拷贝,并将其赋值给 writer2。 -- 之后打印 writer1 和 writer2。 -- 获取 writer2 的 book 字段,并将其赋值给 book2。 -- 将 book2 的 bookName 字段调整为“永恒的图灵”,price 字段调整为 70。 -- 再次打印。 - -来看一下输出结果。 - -``` -浅拷贝后: -writer1:Writer@68837a77 age=18, name='二哥', book=Book@32e6e9c3 bookName='编译原理', price=100}} -writer2:Writer@6d00a15d age=18, name='二哥', book=Book@32e6e9c3 bookName='编译原理', price=100}} -writer2.book 变更后: -writer1:Writer@68837a77 age=18, name='二哥', book=Book@32e6e9c3 bookName='永恒的图灵', price=70}} -writer2:Writer@36d4b5c age=18, name='二哥', book=Book@32e6e9c3 bookName='永恒的图灵', price=70}} -``` - -与之前例子不同的是,writer2.book 变更后,writer1.book 也发生了改变。这是因为字符串 String 是不可变对象,一个新的值必须在字符串常量池中开辟一段新的内存空间,而自定义对象的内存地址并没有发生改变,只是对应的字段值发生了改变,见下图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/deep-copy-02.png) - -“哇,哥,果真一图胜千言,我明白了。”三妹似乎对我画的图很感兴趣呢,“那你继续说深拷贝吧!” - -“嗯,三妹,你有没有注意到,浅拷贝克隆的对象中,引用类型的字段指向的是同一个,当改变任何一个对象,另外一个对象也会随之改变,除去字符串的特殊性外。” - -“深拷贝和浅拷贝不同的,深拷贝中的引用类型字段也会克隆一份,当改变任何一个对象,另外一个对象不会随之改变。” - -“明白了这一点后,我们再来看例子。” - -```java -class Book implements Cloneable{ - private String bookName; - private int price; - - // getter/setter 和构造方法都已省略 - - @Override - public String toString() { - return super.toString().substring(26) + - " bookName='" + bookName + '\'' + - ", price=" + price + - '}'; - } - - @Override - protected Object clone() throws CloneNotSupportedException { - return super.clone(); - } -} -``` - -注意,此时的 Book 类和浅拷贝时不同,重写了 `clone()` 方法,并实现了 Cloneable 接口。为的就是深拷贝的时候也能够克隆该字段。 - -```java -class Writer implements Cloneable{ - private int age; - private String name; - private Book book; - - // getter/setter 和构造方法都已省略 - - @Override - public String toString() { - return super.toString().substring(26) + - " age=" + age + - ", name='" + name + '\'' + - ", book=" + book + - '}'; - } - - @Override - protected Object clone() throws CloneNotSupportedException { - Writer writer = (Writer) super.clone(); - writer.setBook((Book) writer.getBook().clone()); - return writer; - } -} -``` - -注意,此时 Writer 类也与之前的不同,`clone()` 方法当中,不再只调用 Object 的 `clone()` 方法对 Writer 进行克隆了,还对 Book 也进行了克隆。 - -来看测试类。 - -```java -class TestClone { - public static void main(String[] args) throws CloneNotSupportedException { - Writer writer1 = new Writer(18,"二哥"); - Book book1 = new Book("编译原理",100); - writer1.setBook(book1); - - Writer writer2 = (Writer) writer1.clone(); - System.out.println("深拷贝后:"); - - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - - Book book2 = writer2.getBook(); - book2.setBookName("永恒的图灵"); - book2.setPrice(70); - System.out.println("writer2.book 变更后:"); - - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - } -} -``` - -这个测试类和之前的浅拷贝的测试类就完全一样了,但运行结果是不同的。 - -``` -深拷贝后: -writer1:Writer@6be46e8f age=18, name='二哥', book=Book@5056dfcb bookName='编译原理', price=100}} -writer2:Writer@6d00a15d age=18, name='二哥', book=Book@51efea79 bookName='编译原理', price=100}} -writer2.book 变更后: -writer1:Writer@6be46e8f age=18, name='二哥', book=Book@5056dfcb bookName='编译原理', price=100}} -writer2:Writer@6d00a15d age=18, name='二哥', book=Book@51efea79 bookName='永恒的图灵', price=70}} -``` - -不只是 writer1 和 writer2 是不同的对象,它们中的 book 也是不同的对象。所以,改变了 writer2 中的 book 并不会影响到 writer1。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/deep-copy-03.png) - -不过,通过 `clone()` 方法实现的深拷贝比较笨重,因为要将所有的引用类型都重写 `clone()` 方法,当嵌套的对象比较多的时候,就废了! - -“那有没有好的办法呢?”三妹急切的问。 - -“当然有了,利用[序列化](https://tobebetterjavaer.com/io/serialize.html)。”我胸有成竹的回答,“序列化是将对象写到流中便于传输,而反序列化则是将对象从流中读取出来。” - -“写入流中的对象就是对原始对象的拷贝。需要注意的是,每个要序列化的类都要实现 [Serializable 接口](https://tobebetterjavaer.com/io/Serializbale.html),该接口和 Cloneable 接口类似,都是标记型接口。” - -来看例子。 - -```java -class Book implements Serializable { - private String bookName; - private int price; - - // getter/setter 和构造方法都已省略 - - @Override - public String toString() { - return super.toString().substring(26) + - " bookName='" + bookName + '\'' + - ", price=" + price + - '}'; - } -} -``` - -Book 需要实现 Serializable 接口。 - -```java -class Writer implements Serializable { - private int age; - private String name; - private Book book; - - // getter/setter 和构造方法都已省略 - - @Override - public String toString() { - return super.toString().substring(26) + - " age=" + age + - ", name='" + name + '\'' + - ", book=" + book + - '}'; - } - - //深度拷贝 - public Object deepClone() throws IOException, ClassNotFoundException { - // 序列化 - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(bos); - - oos.writeObject(this); - - // 反序列化 - ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); - ObjectInputStream ois = new ObjectInputStream(bis); - - return ois.readObject(); - } -} -``` - -Writer 类也需要实现 Serializable 接口,并且在该类中,增加了一个 `deepClone()` 的方法,利用 OutputStream 进行序列化,InputStream 进行反序列化,这样就实现了深拷贝。 - -来看示例。 - -```java -class TestClone { - public static void main(String[] args) throws IOException, ClassNotFoundException { - Writer writer1 = new Writer(18,"二哥"); - Book book1 = new Book("编译原理",100); - writer1.setBook(book1); - - Writer writer2 = (Writer) writer1.deepClone(); - System.out.println("深拷贝后:"); - - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - - Book book2 = writer2.getBook(); - book2.setBookName("永恒的图灵"); - book2.setPrice(70); - System.out.println("writer2.book 变更后:"); - - System.out.println("writer1:" + writer1); - System.out.println("writer2:" + writer2); - } -} -``` - -与之前测试类不同的是,调用了 `deepClone()` 方法。 - -``` -深拷贝后: -writer1:Writer@9629756 age=18, name='二哥', book=Book@735b5592 bookName='编译原理', price=100}} -writer2:Writer@544fe44c age=18, name='二哥', book=Book@31610302 bookName='编译原理', price=100}} -writer2.book 变更后: -writer1:Writer@9629756 age=18, name='二哥', book=Book@735b5592 bookName='编译原理', price=100}} -writer2:Writer@544fe44c age=18, name='二哥', book=Book@31610302 bookName='永恒的图灵', price=70}} -``` - -测试结果和之前用 `clone()` 方法实现的深拷贝类似。 - -“不过,三妹,需要注意,由于是序列化涉及到输入流和输出流的读写,在性能上要比 HotSpot 虚拟机实现的 `clone()` 方法差很多。”我语重心长地说。 - -“好的,二哥,你先去休息吧,让我来琢磨一会,总结一下浅拷贝和深拷贝之间的差异。” - -“嗯嗯。” - - - - -## 13.5 Java hashCode方法解析 - -完整版的 PDF 足足 83M,有些软件最大只支持 50M,所以忍痛精简了部分内容。你可以到百度网盘或者阿里云盘上下载完整版 PDF 阅读,也可以通过以下链接访问在线版。 - -[13.5 Java hashCode方法解析](https://tobebetterjavaer.com/basic-extra-meal/hashcode.html) - -## 13.6 Java是值传递还是引用传递? - -“哥,说说 Java 到底是值传递还是引用传递吧?”三妹一脸的困惑,看得出来她被这个问题折磨得不轻。 - -“说实在的,我在一开始学 Java 的时候也被这个问题折磨得够呛,总以为[基本数据类型](https://tobebetterjavaer.com/basic-grammar/basic-data-type.html)在传参的时候是值传递,而引用类型是引用传递。”我对三妹袒露了心声,为的就是让她不再那么焦虑,她哥当年也是这么过来的。 - -[C 语言](https://tobebetterjavaer.com/xuexiluxian/c.html)是很多编程语言的母胎,包括 Java,那么对于 C 语言来说,所有的方法参数都是“通过值”传递的,也就是说,传递给被调用方法的参数值存放在临时变量中,而不是存放在原来的变量中。这就意味着,被调用的方法不能修改调用方法中变量的值,而只能修改其私有变量的临时副本的值。 - -Java 继承了 C 语言这一特性,因此 Java 是按照值来传递的。 - -接下来,我们必须得搞清楚,到底什么是值传递(pass by value),什么是引用传递(pass by reference),否则,讨论 Java 到底是值传递还是引用传递就显得毫无意义。 - -当一个参数按照值的方式在两个方法之间传递时,调用者和被调用者其实是用的两个不同的变量——被调用者中的变量(原始值)是调用者中变量的一份拷贝,对它们当中的任何一个变量修改都不会影响到另外一个变量,据说 Fortran 语言是通过引用传递的。 - -“Fortran 语言?”三妹睁大了双眼,似乎听见了什么新的名词。 - -“是的,Fortran 语言,1957 年由 IBM 公司开发,是世界上第一个被正式采用并流传至今的高级编程语言。” - -当一个参数按照引用传递的方式在两个方法之间传递时,调用者和被调用者其实用的是同一个变量,当该变量被修改时,双方都是可见的。 - -“我们之所以容易搞不清楚 Java 到底是值传递还是引用传递,主要是因为 Java 中的两类数据类型的叫法容易引发误会,比如说 int 是基本类型,说它是值传递的,我们就很容易理解;但对于引用类型,比如说 String,说它也是值传递的时候,我们就容易弄不明白。” - -我们来看看基本数据类型和引用数据类型之间的差别。 - -```java -int age = 18; -String name = "二哥"; -``` - -age 是基本类型,值就保存在变量中,而 name 是引用类型,变量中保存的是对象的地址。一般称这种变量为对象的引用,引用存放在栈中,而对象存放在堆中。 - -这里说的栈和堆,是指内存中的一块区域,和数据结构中的栈和堆不一样。栈是由编译器自动分配释放的,所以适合存放编译期就确定生命周期的数据;而堆中存放的数据,编译器是不需要知道生命周期的,创建后的回收工作由垃圾收集器来完成。 - -“画幅图。” - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/pass-by-value-01.png) - -当用 = 赋值运算符改变 age 和 name 的值时。 - -```java -age = 16; -name = "三妹"; -``` - -对于基本类型 age,赋值运算符会直接改变变量的值,原来的值被覆盖。 - -对于引用类型 name,赋值运算符会改变对象引用中保存的地址,原来的地址被覆盖,但原来的对象不会被覆盖。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/pass-by-value-02.png) - -“三妹,注意听,接下来,我们来说说基本数据类型的参数传递。” - -Java 有 8 种基本数据类型,分别是 int、long、byte、short、float、double 、char 和 boolean,就拿 int 类型来举例吧。 - -```java -class PrimitiveTypeDemo { - public static void main(String[] args) { - int age = 18; - modify(age); - System.out.println(age); - } - - private static void modify(int age1) { - age1 = 30; - } -} -``` - -1)`main()` 方法中的 age 为基本类型,所以它的值 18 直接存储在变量中。 - -2)调用 `modify()` 方法的时候,将会把 age 的值 18 复制给形参 age1。 - -3)`modify()` 方法中,对 age1 做出了修改。 - -4)回到 `main()` 方法中,age 的值仍然为 18,并没有发生改变。 - -如果我们想让 age 的值发生改变,就需要这样做。 - -```java -class PrimitiveTypeDemo1 { - public static void main(String[] args) { - int age = 18; - age = modify(age); - System.out.println(age); - } - - private static int modify(int age1) { - age1 = 30; - return age1; - } -} -``` - -第一,让 `modify()` 方法有返回值; - -第二,使用赋值运算符重新对 age 进行赋值。 - -“好了,再来说说引用类型的参数传递。” - -就以 String 为例吧。 - -```java -class ReferenceTypeDemo { - public static void main(String[] args) { - String name = "二哥"; - modify(name); - System.out.println(name); - } - - private static void modify(String name1) { - name1 = "三妹"; - } -} -``` - -在调用 `modify()` 方法的时候,形参 name1 复制了 name 的地址,指向的是堆中“二哥”的位置。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/pass-by-value-03.png) - -当 `modify()` 方法调用结束后,改变了形参 name1 的地址,但 `main()` 方法中 name 并没有发生改变。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/core-points/pass-by-value-04.png) - -总结: - -- Java 中的参数传递是按值传递的。 -- 如果参数是基本类型,传递的是基本类型的字面量值的拷贝。 -- 如果参数是引用类型,传递的是引用的对象在堆中地址的拷贝。 - -“好了,三妹,今天的学习就到这吧。” - - - -## 12.7 Java为什么无法实现真正的泛型 - -“二哥,为啥 Java 不能实现真正的泛型啊?”三妹开门见山地问。 - -简单来回顾一下[类型擦除](https://tobebetterjavaer.com/basic-extra-meal/generic.html),看下面这段代码。 - -```java -public class Cmower { - public static void method(ArrayList list) { - System.out.println("Arraylist list"); - } - - public static void method(ArrayList list) { - System.out.println("Arraylist list"); - } -} -``` - -“三妹,你是不是认为 `ArrayList list` 和 `ArrayList list` 是两种不同的类型,因为 String 和 Date 是不同的类。”我问。 - -“嗯。”三妹点点头。 - -但由于类型擦除的原因,以上代码是不会编译通过的——编译器会提示一个错误: - -``` ->'method(ArrayList)' clashes with 'method(ArrayList)'; both methods have same erasure -``` - -也就是说,两个 `method()` 方法经过类型擦除后的方法签名是完全相同的,Java 是不允许这样做的。 - -也就是说,按照我们的假设:如果 Java 能够实现真正意义上的泛型,两个 `method()` 方法是可以同时存在的,就好像方法重载一样。 - -```java -public class Cmower { - public static void method(String list) { - } - - public static void method(Date list) { - } -} -``` - -“为什么 Java 不能实现真正意义上的泛型呢?背后的原因是什么?快告诉我呀!”三妹着急了。 - -“保持耐心,好不好?”我安慰道。 - -**第一,兼容性** - -Java 在 2004 年已经积累了较为丰富的生态,如果把现有的类修改为泛型类,需要让所有的用户重新修改源代码并且编译,这就会导致 Java 1.4 之前打下的江山可能会完全覆灭。 - -想象一下,你的代码原来运行的好好的,就因为 JDK 的升级,导致所有的源代码都无法编译通过并且无法运行,是不是会非常痛苦? - -类型擦除就完美实现了兼容性,Java 1.5 之后的类可以使用泛型,而 Java 1.4 之前没有使用泛型的类也可以保留,并且不用做任何修改就能在新版本的 Java 虚拟机上运行。 - -老用户不受影响,新用户可以自由地选择使用泛型,可谓一举两得。 - -**第二,不是“实现不了”**。Pizza,1996 年的实验语言,在 Java 的基础上扩展了泛型。 - ->Pizza 教程地址:[http://pizzacompiler.sourceforge.net/doc/tutorial.html](http://pizzacompiler.sourceforge.net/doc/tutorial.html) - -“1996 年?”三妹表示很吃惊。 - -“嗯,你那会还没出生。”我说,“插一下 Java 的版本历史吧,你好有一个时间线上的观念。” - -- 1995年5月23日,Java语言诞生 -- 1996年1月,JDK1.0 诞生 -- 1997年2月18日,JDK1.1发布 -- 1998年2月,JDK1.1被下载超过2,000,000次 -- 2000年5月8日,JDK1.3发布 -- 2000年5月29日,JDK1.4发布 -- 2004年9月30日18:00 PM,J2SE1.5 发布 - -也就是说,Pizza 在 JDK 1.0 的版本上就实现了“真正意义上的”泛型,我引过来两段例子,你一看就明白了。 - -首先是 StoreSomething,一个泛型类,标识符是大写字母 A 而不是我们熟悉的大写字母 T。 - -```java -class StoreSomething { - A something; - - StoreSomething(A something) { - this.something = something; - } - - void set(A something) { - this.something = something; - } - - A get() { - return something; - } -} -``` - -这个 A 呢,可以是任何合法的 Java 类型: - -```java -StoreSomething a = new StoreSomething("I'm a string!"); -StoreSomething b = new StoreSomething(17+4); - -b.set(9); - -int i = b.get(); -String s = a.get(); -``` - -对吧?这就是我们想要的“真正意义上的泛型”,A 不仅仅可以是引用类型 String,还可以是基本数据类型。要知道,Java 的泛型不允许是基本数据类型,只能是包装器类型。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/generic/true-generic-01.png) - -除此之外,Pizza 的泛型还可以直接使用 `new` 关键字进行声明,并且 Pizza 编译器会从构造方法的参数上推断出具体的对象类型,究竟是 String 还是 int。要知道,Java 的泛型因为类型擦除的原因,程序员是无法知道一个 ArrayList 究竟是 `ArrayList` 还是 `ArrayList` 的。 - -```java -ArrayList ints = new ArrayList(); -ArrayList strs = new ArrayList(); - -System.out.println(ints.getClass()); -System.out.println(strs.getClass()); -``` - -输出结果: - -``` -class java.util.ArrayList -class java.util.ArrayList -``` - -都是 ArrayList 而已。 - -“那 Pizza 这种“真正意义上的泛型”为什么没有被 Java 采纳呢?”三妹很关心这个问题。 - -事实上,Java 的核心开发组对 Pizza 的泛型设计非常感兴趣,并且与 Pizza 的设计者 Martin 和 Phil 取得了联系,新合作了一个项目 Generic Java,争取在 Java 中添加泛型支持,但不引入 Pizza 的其他功能,比如说函数式编程。 - -*这里再补充一点维基百科上的资料,Martin Odersky 是一名德国计算机科学家,他和其他人一起设计了 Scala 编程语言,以及 Generic Java(还有之前的 Pizza),他实现的 Generic Java 编译器成为了 Java 编译器 javac 的基础。* - -站在马后炮的思维来看,Pizza 的泛型设计和函数式编程非常具有历史前瞻性。然而 Java 的核心开发组在当时似乎并不想把函数式编程引入到 Java 中。 - -以至于 Java 在 1.4 之前仍然是不支持泛型的。 - -“为什么 Java 1.5 的时候又突然支持泛型了呢?”三妹问。 - -“当然是到了不支持不行的时候了。”我说。 - -没有泛型之前,我们可以这样写代码: - -```java -ArrayList list = new ArrayList(); -list.add("沉默王二"); -list.add(new Date()); -``` - -不管是 String 类型,还是 Date 类型,都可以一股脑塞进 ArrayList 当中,这看起来似乎很方便,但取的时候就悲剧了。 - -```java -String s = list.get(1); -``` - -这样取行吗?不行。还得加上强制转换。 - -```java -String s = (String) list.get(1); -``` - -但我们知道,这行代码在运行的时候必然会出错: - -``` -Exception in thread "main" java.lang.ClassCastException: java.util.Date cannot be cast to java.lang.String -``` - -这就又回到“兼容性”的问题了。 - -Java 语言和其他编程语言不一样,有着沉重的历史包袱,1.5 之前已经有大量的程序部署在生产环境下了,这时候如果一刀切,原来没有使用泛型的代码直接扼杀了,后果不堪想象。 - -Java 一直以来都强调兼容性,我认为这也是 Java 之所以能被广泛使用的主要原因之一,开发者不必担心 Java 版本升级的问题,一个在 JDK 1.4 上可以跑的代码,放在 JDK 1.5 上仍然可以跑。 - -这里必须得说明一点,J2SE1.5 的发布,是 Java 语言发展史上的重要里程碑,为了表示该版本的重要性,J2SE1.5 也正式更名为 Java SE 5.0,往后去就是 Java SE 6.0,Java SE 7.0。。。。 - -但 Java 并不支持高版本 JDK 编译生成的字节码文件在低版本的 JRE(Java 运行时环境)上跑。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/generic/true-generic-02.png) - - -针对泛型,兼容性具体表现在什么地方呢?来看下面这段代码。 - -```java -ArrayList ints = new ArrayList(); -ArrayList strs = new ArrayList(); -ArrayList list; -list = ints; -list = strs; -``` - -“兼容性表现在上面这段代码必须得能够编译运行。怎么办呢?”我扭头看了一下旁边的三妹,继续说。 - -“只能搞类型擦除了!”我妹肯定地回答。 - -“是滴。” - -编译前进行泛型检测,`ArrayList` 只能放 Integer,`ArrayList` 只能放 String,取的时候就不用担心类型强转出错了。 - -但编译后的字节码文件里,是没有泛型的,放的都是 Object。 - -Java 神奇就神奇在这,表面上万物皆对象,但为了性能上的考量,又存在 int、double 这种原始类型,但原始类型又没办法和 Object 兼容,于是我们就只能写 `ArrayList` 这样很占用内存空间的代码。 - -这恐怕也是 Java 泛型被吐槽的原因之一了。 - -一个好消息是 Valhalla 项目正在努力解决这些因为泛型擦除带来的历史遗留问题。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/generic/true-generic-03.png) - - -Project Valhalla:正在进行当中的 OpenJDK 项目,计划给未来的 Java 添加改进的泛型支持。 - ->源码地址:[http://openjdk.java.net/projects/valhalla/](http://openjdk.java.net/projects/valhalla/) - -让我们拭目以待吧! - -“怎么样?三妹,这下全部都明白了吧!”我问。 - -“嗯嗯。二哥,你讲得可真棒👍”三妹夸奖得我有点小开心,嘿嘿。 - - - -## 12.8 掌握 Java 反射 - -“二哥,什么是反射呀?”三妹开门见山地问。 - -“要想知道什么是反射,就需要先来了解什么是‘正射’。”我笑着对三妹说,“一般情况下,我们在使用某个类之前已经确定它到底是个什么类了,拿到手就直接可以使用 `new` 关键字来调用构造方法进行初始化,之后使用这个类的对象来进行操作。” - -```java -Writer writer = new Writer(); -writer.setName("沉默王二"); -``` - -像上面这个例子,就可以理解为“正射”。而反射就意味着一开始我们不知道要初始化的类到底是什么,也就没法直接使用 `new` 关键字创建对象了。 - -我们只知道这个类的一些基本信息,就好像我们看电影的时候,为了抓住一个犯罪嫌疑人,警察就会问一些目击证人,根据这些证人提供的信息,找专家把犯罪嫌疑人的样貌给画出来——这个过程,就可以称之为**反射**。 - -```java -Class clazz = Class.forName("com.itwanger.s39.Writer"); -Method method = clazz.getMethod("setName", String.class); -Constructor constructor = clazz.getConstructor(); -Object object = constructor.newInstance(); -method.invoke(object,"沉默王二"); -``` - -像上面这个例子,就可以理解为“反射”。 - -“反射的写法比正射复杂得多啊!”三妹感慨地说。 - -“是的,反射的成本是要比正射的高得多。”我说,“反射的缺点主要有两个。” - -- **破坏封装**:由于反射允许访问私有字段和私有方法,所以可能会破坏封装而导致安全问题。 -- **性能开销**:由于反射涉及到动态解析,因此无法执行 Java 虚拟机优化,再加上反射的写法的确要复杂得多,所以性能要比“正射”差很多,在一些性能敏感的程序中应该避免使用反射。 - -“那反射有哪些好处呢?”三妹问。 - -反射的主要应用场景有: - -- **开发通用框架**:像 Spring,为了保持通用性,通过配置文件来加载不同的对象,调用不同的方法。 -- **动态代理**:在面向切面编程中,需要拦截特定的方法,就会选择动态代理的方式,而动态代理的底层技术就是反射。 -- **注解**:注解本身只是起到一个标记符的作用,它需要利用发射机制,根据标记符去执行特定的行为。 - -“好了,来看一下完整的例子吧。”我对三妹说。 - -Writer 类,有两个字段,然后还有对应的 getter/setter。 - -```java -public class Writer { - private int age; - private String name; - - public int getAge() { - return age; - } - - public void setAge(int age) { - this.age = age; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } -} -``` - -测试类: - -```java -public class ReflectionDemo1 { - public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { - Writer writer = new Writer(); - writer.setName("沉默王二"); - System.out.println(writer.getName()); - - Class clazz = Class.forName("com.itwanger.s39.Writer"); - Constructor constructor = clazz.getConstructor(); - Object object = constructor.newInstance(); - - Method setNameMethod = clazz.getMethod("setName", String.class); - setNameMethod.invoke(object, "沉默王二"); - Method getNameMethod = clazz.getMethod("getName"); - System.out.println(getNameMethod.invoke(object)); - } -} -``` - -来看一下输出结果: - -``` -沉默王二 -沉默王二 -``` - -只不过,反射的过程略显曲折了一些。 - -第一步,获取反射类的 Class 对象: - -```java -Class clazz = Class.forName("com.itwanger.s39.Writer"); -``` - -第二步,通过 Class 对象获取构造方法 Constructor 对象: - -```java -Constructor constructor = clazz.getConstructor(); -``` - -第三步,通过 Constructor 对象初始化反射类对象: - -```java -Object object = constructor.newInstance(); -``` - -第四步,获取要调用的方法的 Method 对象: - -```java -Method setNameMethod = clazz.getMethod("setName", String.class); -Method getNameMethod = clazz.getMethod("getName"); -``` - -第五步,通过 `invoke()` 方法执行: - -```java -setNameMethod.invoke(object, "沉默王二"); -getNameMethod.invoke(object) -``` - -“三妹,你看,经过这五个步骤,基本上就掌握了反射的使用方法。”我说。 - -“好像反射也没什么复杂的啊!”三妹说。 - -我先对三妹点点头,然后说:“是的,掌握反射的基本使用方法确实不难,但要理解整个反射机制还是需要花一点时间去了解一下 Java 虚拟机的类加载机制的。” - -要想使用反射,首先需要获得反射类的 Class 对象,每一个类,不管它最终生成了多少个对象,这些对象只会对应一个 Class 对象,这个 Class 对象是由 Java 虚拟机生成的,由它来获悉整个类的结构信息。 - -也就是说,`java.lang.Class` 是所有反射 API 的入口。 - -而方法的反射调用,最终是由 Method 对象的 `invoke()` 方法完成的,来看一下源码(JDK 8 环境下)。 - -```java -public Object invoke(Object obj, Object... args) - throws IllegalAccessException, IllegalArgumentException, - InvocationTargetException { - // 如果方法不允许被覆盖,进行权限检查 - if (!override) { - if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) { - Class caller = Reflection.getCallerClass(); - // 检查调用者是否具有访问权限 - checkAccess(caller, clazz, obj, modifiers); - } - } - // 获取方法访问器(从 volatile 变量中读取) - MethodAccessor ma = methodAccessor; - if (ma == null) { - // 如果访问器为空,尝试获取方法访问器 - ma = acquireMethodAccessor(); - } - // 使用方法访问器调用方法,并返回结果 - return ma.invoke(obj, args); -} -``` - -两个嵌套的 if 语句是用来进行权限检查的。 - -`invoke()` 方法实际上是委派给 MethodAccessor 接口来完成的。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/fanshe/fanshe-01.png) - -MethodAccessor 接口有三个实现类,其中的 MethodAccessorImpl 是一个抽象类,另外两个具体的实现类继承了这个抽象类。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/fanshe/fanshe-02.png) - -- NativeMethodAccessorImpl:通过本地方法来实现反射调用; -- DelegatingMethodAccessorImpl:通过委派模式来实现反射调用; - -通过 debug 的方式进入 `invoke()` 方法后,可以看到第一次反射调用会生成一个委派实现 DelegatingMethodAccessorImpl,它在生成的时候会传递一个本地实现 NativeMethodAccessorImpl。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/fanshe/fanshe-03.png) - -也就是说,`invoke()` 方法在执行的时候,会先调用 DelegatingMethodAccessorImpl,然后调用 NativeMethodAccessorImpl,最后再调用实际的方法。 - -“为什么不直接调用本地实现呢?”三妹问。 - -“之所以采用委派实现,是为了能够在本地实现和动态实现之间切换。动态实现是另外一种反射调用机制,它是通过生成字节码的形式来实现的。如果反射调用的次数比较多,动态实现的效率就会更高,因为本地实现需要经过 Java 到 C/C++ 再到 Java 之间的切换过程,而动态实现不需要;但如果反射调用的次数比较少,反而本地实现更快一些。”我说。 - -“那临界点是多少呢?”三妹问。 - -“默认是 15 次。”我说,“可以通过 `-Dsun.reflect.inflationThreshold` 参数类调整。” - -来看下面这个例子。 - -```java -Method setAgeMethod = clazz.getMethod("setAge", int.class); -for (int i = 0;i < 20; i++) { - setAgeMethod.invoke(object, 18); -} -``` - -在 `invoke()` 方法处加断点进入 debug 模式,当 i = 15 的时候,也就是第 16 次执行的时候,会进入到 if 条件分支中,改变 DelegatingMethodAccessorImpl 的委派模式 delegate 为 `(MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod()`,而之前的委派模式 delegate 为 NativeMethodAccessorImpl。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/fanshe/fanshe-04.png) - -“这下明白了吧?三妹。”我说,“接下来,我们再来熟悉一下反射当中常用的 API。” - -**1)获取反射类的 Class 对象** - -`Class.forName()`,参数为反射类的完全限定名。 - -```java -Class c1 = Class.forName("com.itwanger.s39.ReflectionDemo3"); -System.out.println(c1.getCanonicalName()); - -Class c2 = Class.forName("[D"); -System.out.println(c2.getCanonicalName()); - -Class c3 = Class.forName("[[Ljava.lang.String;"); -System.out.println(c3.getCanonicalName()); -``` - -来看一下输出结果: - -``` -com.itwanger.s39.ReflectionDemo3 -double[] -java.lang.String[][] -``` - -类名 + `.class`,只适合在编译前就知道操作的 Class。。 - -```java -Class c1 = ReflectionDemo3.class; -System.out.println(c1.getCanonicalName()); - -Class c2 = String.class; -System.out.println(c2.getCanonicalName()); - -Class c3 = int[][][].class; -System.out.println(c3.getCanonicalName()); -``` - -来看一下输出结果: - -```java -com.itwanger.s39.ReflectionDemo3 -java.lang.String -int[][][] -``` - -**2)创建反射类的对象** - -通过反射来创建对象的方式有两种: - -- 用 Class 对象的 `newInstance()` 方法。 -- 用 Constructor 对象的 `newInstance()` 方法。 - -```java -Class c1 = Writer.class; -Writer writer = (Writer) c1.newInstance(); - -Class c2 = Class.forName("com.itwanger.s39.Writer"); -Constructor constructor = c2.getConstructor(); -Object object = constructor.newInstance(); -``` - -**3)获取构造方法** - -Class 对象提供了以下方法来获取构造方法 Constructor 对象: - -- `getConstructor()`:返回反射类的特定 public 构造方法,可以传递参数,参数为构造方法参数对应 Class 对象;缺省的时候返回默认构造方法。 -- `getDeclaredConstructor()`:返回反射类的特定构造方法,不限定于 public 的。 -- `getConstructors()`:返回类的所有 public 构造方法。 -- `getDeclaredConstructors()`:返回类的所有构造方法,不限定于 public 的。 - -```java -Class c2 = Class.forName("com.itwanger.s39.Writer"); -Constructor constructor = c2.getConstructor(); - -Constructor[] constructors1 = String.class.getDeclaredConstructors(); -for (Constructor c : constructors1) { - System.out.println(c); -} -``` - -**4)获取字段** - -大体上和获取构造方法类似,把关键字 Constructor 换成 Field 即可。 - -```java -Method setNameMethod = clazz.getMethod("setName", String.class); -Method getNameMethod = clazz.getMethod("getName"); -``` - -**5)获取方法** - -大体上和获取构造方法类似,把关键字 Constructor 换成 Method 即可。 - -```java -Method[] methods1 = System.class.getDeclaredMethods(); -Method[] methods2 = System.class.getMethods(); -``` - -“注意,三妹,如果你想反射访问私有字段和(构造)方法的话,需要使用 `Constructor/Field/Method.setAccessible(true)` 来绕开 Java 语言的访问限制。”我说。 - -“好的,二哥。还有资料可以参考吗?”三妹问。 - -“有的,有两篇文章写得非常不错,你在学习反射的时候可以作为参考。”我说。 - -第一篇:深入理解 Java 反射和动态代理 - ->链接:[https://dunwu.github.io/javacore/basics/java-reflection.html](https://dunwu.github.io/javacore/basics/java-reflection.html) - -第二篇:大白话说Java反射:入门、使用、原理: - ->链接:[https://www.cnblogs.com/chanshuyi/p/head_first_of_reflection.html](https://www.cnblogs.com/chanshuyi/p/head_first_of_reflection.html) - -这里简单总结下。 - -反射是 Java 中的一个强大特性,它允许在运行时检查和操作[类](https://tobebetterjavaer.com/oo/object-class.html)、[接口](https://tobebetterjavaer.com/oo/interface.html)、[字段](https://tobebetterjavaer.com/oo/var.html)和[方法](https://tobebetterjavaer.com/oo/method.html)。反射是 Java 的核心组件,支持各种框架和库的实现,如 Spring、Hibernate 等。使用反射,可以在运行时动态地创建对象、调用方法和访问字段,而无需在编译时了解这些对象的具体实现。 - -反射的主要类位于 `java.lang.reflect` 包中,主要包括以下几个关键类: - -- Class:代表一个类或接口,包含了类的结构信息(如名称、构造函数、方法、字段等)。通过 Class 对象,可以获取类的元数据并操作类的实例。 -- Constructor:代表类的[构造方法](https://tobebetterjavaer.com/oo/construct.html),用于创建类的实例。 -- Method:代表类的方法,可以通过它调用类的实例方法。 -- Field:代表类的字段,可以获取或修改字段的值。 -- Modifier:包含方法、字段和类的[访问修饰符(如 public、private 等)](https://tobebetterjavaer.com/oo/access-control.html)。 - -使用反射时,需要注意以下几点: - -- 性能:反射操作通常比直接操作对象的方法和字段慢,因为涉及到额外的间接调用和动态解析。因此,在关注性能的场景中,慎用反射。 -- 安全性:通过反射,可以访问和操作类的私有字段和方法,这可能导致安全问题。因此,使用反射时要确保代码的安全性。 -- 维护性:反射使代码变得更加复杂,可能导致难以维护。在使用反射时要确保代码的可读性和可维护性。 - -尽管反射存在上述问题,但在某些场景下(如框架开发、动态代理等),它仍然是非常有用的工具。 - -来一个完整的 demo 示例吧。 - -```java -class Person { - private String name; - private int age; - - public Person() { - } - - public Person(String name, int age) { - 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; - } - - private void privateMethod() { - System.out.println("私有方法"); - } -} - -public class ReflectionDemo { - public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, - IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException { - // 获取 Person 类的 Class 对象 - Class personClass = Class.forName("com.github.paicoding.forum.test.javabetter.importance.Person"); - - // 获取并打印类名 - System.out.println("类名: " + personClass.getName()); - - // 获取构造函数 - Constructor constructor = personClass.getConstructor(String.class, int.class); - - // 使用构造函数创建 Person 对象实例 - Object personInstance = constructor.newInstance("沉默王二", 30); - - // 获取并调用 getName 方法 - Method getNameMethod = personClass.getMethod("getName"); - String name = (String) getNameMethod.invoke(personInstance); - System.out.println("名字: " + name); - - // 获取并调用 setAge 方法 - Method setAgeMethod = personClass.getMethod("setAge", int.class); - setAgeMethod.invoke(personInstance, 35); - - // 获取并访问 age 字段 - Field ageField = personClass.getDeclaredField("age"); - ageField.setAccessible(true); - int age = ageField.getInt(personInstance); - System.out.println("年纪: " + age); - - // 获取并调用私有方法 - Method privateMethod = personClass.getDeclaredMethod("privateMethod"); - privateMethod.setAccessible(true); - privateMethod.invoke(personInstance); - } -} -``` - -在这个示例中,我们首先通过 `Class.forName()` 方法获取 Person 类的 Class 对象。接着,我们获取了 Person 类的构造方法、方法和字段,并使用这些反射对象来创建实例、调用方法和访问字段。注意,在访问私有方法和字段时,我们需要调用 `setAccessible(true)` 方法来允许访问。 - -“好了,三妹,关于反射,就先讲到这里吧。” - ---- - -GitHub 上标星 7600+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 7600+ 的 Java 教程](https://tobebetterjavaer.com/overview/) - -微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - - -# 第十四章:Java并发编程 - -限于《二哥的 Java 进阶之路》.pdf 篇幅的原因,目前已经 32 余万字,我 32G 内存的电脑,每次想更新这份小册,都嗡嗡嗡直叫的厉害!所以 Java 并发编程的 PDF 暂时只对《[二哥的编程星球(戳链接有优惠券)](https://tobebetterjavaer.com/zhishixingqiu/)》用户开放,如果你需要这份 PDF 并且想享受更多服务的话,可以扫码加入。 - -![](https://cdn.tobebetterjavaer.com/stutymore/thread-20230410210802.png) - -一句话介绍一下二哥的编程星球,这是一个编程学习指南 + Java项目实战 + LeetCode 刷题的私密圈子,你可以阅读星球专栏、向二哥提问、帮你制定学习计划、和球友一起打卡成长。这是 PDF 的大致内容,想要加入星球的用户,可以提前感受一下。 - -![](https://cdn.tobebetterjavaer.com/stutymore/thread-20230410211343.png) - -如果你不想付费,也没关系,你完全可以到《[二哥的 Java 进阶之路](https://tobebetterjavaer.com/home.html)》网站查看并发编程板块,内容虽然不同,但质量也是完全有保障。 - -众所周知,Java 并发是 Java 程序员必须懂但又很难懂的一块知识点。一般来说,很少有人敢说自己精通 Java 并发的,一是容易被面试官吊打,二是并发编程涉及到操作系统、内存、CPU 等计算机专业比较核心的内容,比较考验一个程序员的内功。 - -这里就顺带给你盘点一下 Java 并发到底该如何从入门到精通,请及时用鸡毛掸子把收藏夹里的灰清理一下。在阅读过程中,如果有所帮助,麻烦默默收藏和主动转发,算是对我码字的这份坚持的亿点点鼓励。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-1.gif) - - -## 一、为什么要学 Java 并发? - -有句话不知道当讲不当讲,先讲了再说,就是“如果你只想 CURD,那么 Java 并发不学也罢!”但其实呢,大家都已经被教育的很有涵养了,工作中拧不拧螺丝不重要,重要的是面试一定要会造火箭,不然面试的机会都很难捞得到。 - -那作为 Java 体系中非常重要的一环,Java 并发自然是必须要掌握的,最起码也得会起个多线程吧?哈哈哈。高级点的,像平常开发中用到的 Tomcat 服务器、消息中间件、RPC 框架等等,它们的底层都涉及到了并发编程。 - -当然了,Java 并发涉及到东西实在是不少,包括操作系统的知识,Java 虚拟机的一些知识,Java 线程模型的知识,多线程相关的关键字,比如说 synchronized、volatile 等,还有锁的知识、JDK 提供的工具类等等,学起来还是非常容易令人头大的。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-2.png) - -因此,我们需要一些高效的学习路线图,以及一些优质的学习资源,从而减少我们学习Java 并发编程所投入的时间和精力。 - -## 二、Java 并发学习路线图 - -这是我最近整理的一张关于 Java 并发编程的思维导图,大的方向可以分为三个部分:线程基础、理论基础、工具类 JUC。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-map.png) - -线程基础部分包括: - -- 线程的创建方式 -- 线程的状态切换 -- 线程的基本操作 -- 线程组和线程优先级 - -理论基础包括: - -- 进程和线程的区别 -- 多线程解决了什么问题,又带来了什么问题? -- 如何解决并发问题?包括 Java 内存模型,以及两个常见的关键字 volatile 和 synchronized - -工具类 JUC 包括: - -- 锁 Lock 系的 AQS、ReentrantLock、ReentrantReadWriteLock、Condition、LockSupport -- 并发容器系的 ConcurrentHashMap、ConcurrentLinkedQueue、CopyOnWriteArrayList、ThreadLocal、BlockingQueue -- 线程池系的 ThreadPoolExecutor、ScheduledThreadPoolExecutor -- 原子系的 AtomicInteger、AtomicIntegerArray、AtomicReference 等等 -- 通信工具系的倒计时器 CountDownLatch、循环栅栏 CyclicBarrier、资源访问控制 Semaphore、数据交换 Exchanger、移相器Phaser -- Fork/Join框架 - -最后再来个经典的生产者消费者模式进行实践,整个 Java 并发体系就学得非常扎实了! - -## 三、硬核 Java 并发学习资料 - -### **1)Java进阶之路** - -学 Java,当然要找二哥的Java进阶之路,网址我贴下面了哈: - ->[https://tobebetterjavaer.com/home.html](https://tobebetterjavaer.com/home.html) - -进去直接找 Java 核心里面的 Java 并发编程就对了。我按照前面的思维导图整理了 29 篇文章,全部都是硬核级别的,跟着学就对了。 - -![](https://cdn.tobebetterjavaer.com/stutymore/thread-20230410204505.png) - -### **2)视频** - -懂的都懂,看视频到 B 站。黑马的《Java并发编程》评价还不错,300 多个小节,我觉得讲的比较好的有三部分:synchronized优化原理、AQS和线程池。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-4.png) - ->视频地址:[https://www.bilibili.com/video/BV16J411h7Rd](https://www.bilibili.com/video/BV16J411h7Rd) - -还有尚硅谷宋红康老师讲的这个视频。 - -![](https://cdn.tobebetterjavaer.com/stutymore/thread-20230406095805.png) - ->视频地址:[https://www.bilibili.com/video/BV1Kw411Z7dF/](https://www.bilibili.com/video/BV1Kw411Z7dF/) - -### **3)书籍** - -纸质书只推荐一本《[Java 并发编程实战](https://book.douban.com/subject/10484692/)》,豆瓣评分 9.0。不过这本书确实有点老了,基本上是按照 Java 6 来讲解的,希望出版社能早点出 2.0 版。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-5.png) - -《Java 并发编程实战》这本书从总体上来看,分两条主线: - -- 介绍 Java 并发包的重要组件和原理 -- 如何利用这些组件来保证线程安全 - -到底该如何获得线程安全呢?背会并理解下面这段话: - ->Writing thread-safe code is, at its core, about managing access to state, and in particular to shared, mutable state. - -如果发现不是很好懂,想从国内作者下手的话,可以尝试一下《[Java并发编程的艺术](https://book.douban.com/subject/26591326/)》和《[图解Java并发编程](https://book.douban.com/subject/35634953/)》这两本书,虽然豆瓣上评分一般,但对于构建 Java 并发的知识体系还是有很大帮助的。 - -之后,再去啃《Java 并发编程实战》就会发现没有以前那么费劲了,这本书之所以被誉为 Java 并发编程的圣经,确实可以看得出作者在并发编程方面有着丰富的经验。 - -### **4)开源电子书** - -推荐 RedSpider社区的[深入浅出 Java 多线程](http://concurrent.redspider.group/RedSpider.html),比Java 并发编程实战更通俗易懂一些,因为里面穿插了很多精美的手绘图。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-6.png) - ->GitHub地址:[https://github.com/RedSpider1/concurrent](https://github.com/RedSpider1/concurrent) - -考虑到有些小伙伴可能需要 [PDF 版本](https://tobebetterjavaer.com/pdf/java-concurrent.html),我花了一周的时间整理了一份,需要的小伙伴请扫描下方的二维码关注作者的原创公众号「**沉默王二**」回复关键字「**并发**」就可以拉取到了。 - -![扫码关注后回复「并发」关键字](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - -再推荐一份 GitHub 上星标 3.6k+ 的 Java 并发知识点总结: - ->[https://github.com/CL0610/Java-concurrency](https://github.com/CL0610/Java-concurrency) - -仓库里有一句话我非常喜欢,也分享给各位小伙伴: - ->努力的意义,就是,在以后的日子里,放眼望去全是自己喜欢的人和事! - - -### **5)付费专栏** - -王宝令老师在极客时间上开了一门《Java 并发编程实战》的付费专栏,质量还是挺高的,喜欢的小伙伴可以戳[链接](http://gk.link/a/11cBH)去购买。 - - - - - - -## 四、优质八股文 - -这里给大家推荐两份 Java 并发编程方面的八股文,一份来自[三分恶滴滴的面渣逆袭](https://tobebetterjavaer.com/sidebar/sanfene/javathread.html),一份来自小牛,先截图给大家看一下 Java 并发方面都有哪些高频的面试题。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-7.png) - -为了方便大家的阅读和背诵,我已经将其整理到了二哥的小破站《Java进阶之路》上,面渣逆袭 Java 并发篇: - ->[https://tobebetterjavaer.com/sidebar/sanfene/javathread.html](https://tobebetterjavaer.com/sidebar/sanfene/javathread.html) - -Java 并发编程八股文(背诵版): - ->[https://tobebetterjavaer.com/baguwen/java-thread.html](https://tobebetterjavaer.com/baguwen/java-thread.html) - -这两份八股文的质量都非常高,来看一下AQS了解多少小节下的内容,图文并茂,非常容易消化和吸收。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-8.png) - -诚实点说,如果能把这两份八股文背会的话,简历上就真的敢写“精通”Java 并发了。 - -## 五、Java 并发学习心得 - -Java 提供的并发组件,大致可以分为两类: - -- 从预防阶段下手,防止错误发生,比如说 synchronized 关键字 -- 一旦发生错误能及时重试,比如说 CAS - -对于线程数量比较多的并发场景,采用预防的措施会比较合理,这样大部分线程就不会因为小概率时间的 CAS 重试浪费掉大量的 CPU 周期;在线程数量小的时候,CAS 的意义就比较大,因为预防措施带来的线程切换要比 CAS 等待的开销更大。 - -想要学好 Java 并发编程,就必须得对下图中提到的基础概念进行充分的理解。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java-thread-9.jpg) - -在我看来,并发编程主要是用来解决这两个痛点的: - -- 多个线程对同一变量造成的不一致问题; -- 为提高性能,计算机的很多执行单元都配备了缓存,那势必会影响并发编程的数据一致性。 - -需要提醒一点的是,多线程并发虽然是用来解决性能问题的,但并不意味着所有情况下都需要开启多线程,有时候反而会适得其反,那如果不是特别要求,尽量不要过早开启多线程。 - -并发编程是 Java 体系当中相对难掌握的一块知识点,比较考验一名程序员的内功,其实并发编程最早的应用领域就是操作系统的实现。 - -如果你已经有一定的编程经验,建议先学一下《计算机组成原理》,对操作系统、内存、CPU 先进行一些大致的了解,然后再来学习 Java 并发编程,可能就会感觉舒服多了! - -结合我多年的工作经验来看,并发编程可以抽象成**三个核心问题:分工、同步和互斥**。 - -1)分工 - -分工指的是如何高效地拆解任务并分配给线程,像并发编程领域的一些设计模式,比如说生产者与消费者就是用来进行分工的。 - -2)同步 - -同步指的是线程之间如何协作,一个线程执行完了一个任务,要通知另外一个线程开工。还拿生产者-消费者模型来说吧,当队列满的时候,生产者线程等待,当队列不满的时候,生产者线程需要被唤醒重新执行;当队列空的时候,消费者线程开始等待,不空的时候,消费者线程被重新唤醒。 - -3)互斥 - -互斥指的是保证同一时刻只有一个线程访问共享资源,是解决线程安全问题的杀手锏。 - -当多个线程同时访问一个共享变量的时候,很容易出现“线程安全”问题,因为结果可能是不确定的——导致出现这个问题的根源就是可见性、有序性和原子性——为了解决它们,Java 引入了内存模型的概念,可以在一定程度上缓解“线程安全”的问题,但要想完全解决“线程安全”问题,还得靠互斥。 - -互斥的核心技术就是锁,比如说 synchronized,还有各种 Lock。 - -锁可以解决线程安全的问题,但同时也就意味着程序的性能要受到影响。 - -因此,Java 提供了针对不同场景下的锁,比如说读写锁 ReadWriteLock,可以解决多线程同时读,但只有一个线程能写的问题;但 ReadWriteLock 也有自己的问题,就是如果有线程正在读,写线程需要等待度线程释放锁后才能获得写锁,也就是读的过程中不允许写,属于一种悲观的读锁。 - -为了进一步提升并发执行的效率,Java 8 引入了一个新的读写锁 StampedLock,与ReadWriteLock 相比,StampedLock的优势在于读的过程中也允许获取写锁后写入,但带来的问题就是可能读的数据不一致,需要一点额外的代码来判断读的过程中是否有写入,本质上是一种乐观的锁。 - -乐观锁的意思就是估计读的过程中大概率不会有写入,而悲观锁则是读的过程中拒绝有写入,两者的区别就在于性能上会有差异,乐观锁需要针对小概率事件进行多一步的检测,但性能也会有所提升;悲观锁更能保证“线程安全性”。 - -听我这么一说,是不是一下子就清晰多了! - -# 第十五章:JVM(Java 虚拟机) - -同样限于《二哥的 Java 进阶之路》.pdf 篇幅的原因,目前已经 32 余万字,我 32G 内存的电脑,每次想更新这份小册,都嗡嗡嗡直叫的厉害!二哥的技术派团队成员楼仔出品的《JVM 手册》,暂时只对《[技术派星球(戳链接有优惠券)](https://paicoding.com/article/detail/169)》用户开放,如果你需要这份 PDF 并且想享受更多服务的话,可以扫码加入。 - -![](https://cdn.tobebetterjavaer.com/stutymore/jvm-20230410212958.png) - -介绍一下技术派星球提供的服务内容,技术派项目学习教程,后续会采用连载的方式,让你从 0 到 1 也能搭建一套自己的网站;技术派项目答疑解惑,让你快速上手该项目,小白也能懂;向楼仔、二哥和大厂嘉宾 1 对 1 交流提问,告别迷茫;个人成长路线、职业规划和建议,帮助你有计划学习;简历指导,让你的简历也能脱颖而出,收获更多面试机会;需求方案、技术架构设计提供参考建议,对标大厂。 - -这是 PDF 的大致内容,想要加入星球的用户,可以提前感受一下。 - -![](https://cdn.tobebetterjavaer.com/stutymore/jvm-20230410213258.png) - -如果你不想付费,也没关系,你完全可以到《[二哥的 Java 进阶之路](https://tobebetterjavaer.com/home.html)》网站查看 JVM 板块的内容,内容虽然不同,但质量也是完全有保障。 - -2020 年的时候,通读了一遍周志明老师的《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》第三版,读完之后受益匪浅,这让我对 Java 虚拟机有了一个更完整的认识。毫无疑问,《深入理解 Java 虚拟机》是 JVM 书籍中最好的一本书了,国产技术书的天花板。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-7036a048-4034-4965-92a6-e35d0211ba71.png) - -如果觉得这本书太厚了,可以阅读我公众号上的这篇文章,对 JVM 会有一个系统的梳理。 - -[JVM 核心知识点总结](https://mp.weixin.qq.com/s/zOdiefZpkpa2dxpcBLlc6A) - -在金三银四/金九银十的跳槽季中,很多小伙伴都会忍不住蠢蠢欲动,其中 JVM 更是面试中不可或缺的一部分,所以我花了几天的时间整理了一条 JVM 的学习路线,希望能帮助到大家。 - -## 一、为什么要学习 JVM? - -曾经我对 JVM 也是感到非常的头痛,完全搞不懂该怎么入门,只是听说《深入理解 Java 虚拟机》这本书很吊,但真读起来,却需要极大的耐心和定力,否则很快就会在读完第一章 JVM 的简史后放弃。 - -那首先,我们就要搞清楚,为什么要学习 JVM ? - -第一,当然是面试要考,这一点毫无疑问,尤其是对于要参加校招的应届生来说,JVM 是绕不过去的,必须得学。 - -第二,如果你想成为公司不可或缺的顶梁柱,那 JVM 你得学,因为一般情况下,遇到的问题基本上 Google 下就能解决了,可一旦遇到 JVM 性能调优,就必须得有能查 OOM 的原因、能看懂字节码的老鸟出马了。 - -应用程序一旦上线,出问题是板上钉钉的事,除了数据库、网络、代码逻辑上的问题,剩下的就有内存溢出啊,频繁 GC 导致的性能瓶颈啊等棘手问题。 - -遇到这种问题,你就必须得能看懂 GC 日志,明白什么是老年代、永久代、元数据区等,这些都是 Java 虚拟机方面的知识。 - -明白了学习 JVM 的重要性,那我们就开搞吧! - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-4cbbdc72-3bdd-4b14-9d8d-ecd7764afb11.png) - - -## 二、JVM 学习路线图 - -这是我最近整理的一张关于 JVM 的思维导图,大的方向可以分为三个部分:字节码与类的加载、内存与垃圾回收、性能监控和调优。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-b4ec034b-9f20-40b9-a2a3-e77afffd2abf.png) - -字节码与类的加载包括: - -- 类的加载机制(类的加载过程、类加载器、双亲委派模型) -- 字节码(类文件结构、字节码指令、字节码执行引擎、实战字节码) - -内存与垃圾回收包括: - -- JVM 内存结构 -- 垃圾回收算法 -- 垃圾回收机制 -- 垃圾收集器 - -性能监控和调优包括: - -- 调优指标 -- 调优对象 -- 调优工具 -- JIT 优化 - -有理论知识,有动手实战,基本上可以把 JVM 这块学的非常扎实了。 - - -## 三、硬核 JVM 学习资料 - -### **1)Java进阶之路** - -学 Java,怎么能少得了《二哥的Java进阶之路》,网址我贴下面了哈: - ->[https://tobebetterjavaer.com/home.html](https://tobebetterjavaer.com/home.html) - -进去直接找「Java 核心」里面的 Java 虚拟机就对了。我按照前面的思维导图整理了 19 篇文章,全部都是硬核级别的,跟着学就对了。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-cc84fead-278b-46aa-a8ab-a3b07acefc12.png) - -### **2)视频** - -懂的都懂,看视频到 B 站。先推荐宋红康老师的 JVM 全套教程,200多万的播放量,还是非常受欢迎的。全套课程分为: - -- 内存与垃圾回收篇 -- 字节码与类的加载篇 -- 性能监控和调优篇 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-59c829ce-7fe3-45a5-b074-35dacb08941e.png) - ->视频地址:[https://www.bilibili.com/video/BV1PJ411n7xZ](https://www.bilibili.com/video/BV1PJ411n7xZ) - -友情提示,对于找工作面试的小伙伴,看 p01-p203、p266-301 就够了! - -还有黑马的这份 JVM 视频教程,1 万+的点赞量,确实很牛逼了。 - -- 1. JVM 内存结构的组成、各部分功能作用,学会利用内存诊断工具排查内存相关问题; -- 2. JVM 的招牌功能-垃圾回收机制是如何工作的,如何进行垃圾回收调优; -- 3. Java 程序从编译为字节码到加载到运行的全流程,各个阶段的优化处理; -- 4. 了解 Java 内存模型相关知识,见识多线程并发读写共享数据时的问题和 Java 的解决方案。 - -![](https://cdn.tobebetterjavaer.com/stutymore/jvm-20230410211836.png) - ->视频地址:[https://www.bilibili.com/video/BV1yE411Z7AP](https://www.bilibili.com/video/BV1yE411Z7AP) - -再推荐一个狂神说的,40多万的播放量,算是一个面向 JVM 面试的突击版。像一些常见的 JVM 面试题,视频里都有讲到,比如说: - -- 什么是 JVM? -- 说一说类加载器? -- 栈和堆的区别是什么? -- JDK 8 和之前发生了什么变化? -- 新生代和老年代是干嘛的? -- From区和 To区什么区别? -- 如何排查 OOM? -- 说说垃圾回收算法 -- 说说 JVM 内存结构 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-be0910d8-7669-46cb-b8b9-2399162e723d.png) - ->视频地址:[https://www.bilibili.com/video/BV1iJ411d7jS](https://www.bilibili.com/video/BV1iJ411d7jS) - -### **3)书籍** - -纸质书只推荐一本周志明老师的神书《[深入理解 Java 虚拟机](https://book.douban.com/subject/34907497/)》,基本上学习 JVM 的小伙伴人手一本。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-3f6065c5-2fbf-4107-804e-393b32a4f9ab.png) - -这是一部从工作原理和工程实践两个维度深入剖析JVM的著作,是计算机领域公认的经典。 - -全书分为五个部分,第一部分为走近 Java,主要是对 JVM 的发展历史进行了介绍,其中第二部分自动内存管理和第三部分虚拟机执行子系统需要重点掌握,这两部分的内容也是最多的。 - -第四部分程序编译与代码优化中需要重点掌握的是 JIT 部分,第五部分高效并发最好和[Java 并发编程](https://tobebetterjavaer.com/xuexiluxian/java/thread.html)(我前面也做了学习路线的总结)这块内容结合起来学习。 - -可以配合 GitHub 上这个开源的阅读笔记学习: - ->[https://github.com/TangBean/understanding-the-jvm](https://github.com/TangBean/understanding-the-jvm) - -### **4)开源电子书** - -推荐 doocs 社区的 [JVM 底层原理最全知识总结](https://doocs.github.io/jvm/),算是《深入理解 Java 虚拟机》这本书的一个精简知识点梳理。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-f2e2e5c4-c193-4af5-b1bc-fea8df2006af.png) - - ->GitHub 地址:[https://github.com/doocs/jvm](https://github.com/doocs/jvm) - -考虑到有些小伙伴可能需要 PDF 版本,我都整理好了,需要的小伙伴请扫描下方的二维码关注作者的原创公众号「**沉默王二**」回复关键字「**java**」就可以拉取到了。 - -![扫码关注后回复「java」关键字](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/gongzhonghao.png) - -再推荐一份 GitHub 上开源的 Java 虚拟机知识点总结: - ->[https://github.com/heibaiying/Full-Stack-Notes/blob/master/notes/Java_虚拟机.md](https://github.com/heibaiying/Full-Stack-Notes/blob/master/notes/Java_虚拟机.md) - -为了方便大家的学习,我也将其整理成了 PDF,内容包含了 Java 内存区域、垃圾收集算法、经典垃圾收集器、虚拟机类加载机制、程序编译和代码优化,手绘图也非常的漂亮。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-2beabbe4-2ddd-4180-8690-1bc3224e6b41.png) - -需要的小伙伴可以微信搜索「**沉默王二**」回复关键字「**java**」就可以拉取到了。 - -### **5)付费专栏** - -郑雨迪老师在极客时间上开了一门《深入拆解Java虚拟机》的付费专栏,质量还是挺高的,喜欢的小伙伴可以戳[链接](http://gk.link/a/11htX)去购买,反正我自己感觉质量还不错。 - - - - - -## 四、JVM 八股文 - -这里给大家推荐两份 Java 虚拟机方面的八股文,一份来自读者[三分恶](https://tobebetterjavaer.com/sidebar/sanfene/jvm.html),一份来自读者小牛,先截图给大家看一下 Java 虚拟机的理解版八股文,图文并茂,非常容易消化和吸收。 - -![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/xuexiluxian/java/jvm-a11893df-e518-4bdc-a166-884b168a8cf0.png) - - -为了方便大家的阅读和背诵,我已经将其整理到了《二哥的Java进阶之路》上,面渣逆袭 Java 虚拟机篇: - ->[https://tobebetterjavaer.com/sidebar/sanfene/jvm.html](https://tobebetterjavaer.com/sidebar/sanfene/jvm.html) - -Java 虚拟机八股文(背诵版): - ->[https://tobebetterjavaer.com/baguwen/jvm.html](https://tobebetterjavaer.com/baguwen/jvm.html) - -诚实点说,如果能把这两份八股文背会/理解的话,简历上就真的敢写“精通”Java 虚拟机了。 - -## 五、学习 JVM 的一点小心得 - -和 C++ 相比,Java 的内存管理机制可以说是一大特色,Java 开发不再需要自己去写代码手动释放内存,甚至你想自己干,JVM 都不给你这个机会,虚拟机完全掌握了 Java 内存的控制权。 - -这看起来挺美好的,但并不意味着Java 开发就可以随意写代码,随意使用内存,从我多年的使用体验来看,内存溢出和内存泄露还是会时不时发生的,尤其是初学阶段,尤其在开启多线程的情况下。 - -一旦出现内存溢出或者内存泄露,排查问题还是挺困难的,所以知道逊尼基到底是怎么管理内存的就变得十分重要了。 - -[JVM 的垃圾回收](https://tobebetterjavaer.com/jvm/gc.html),其实就是收拾那些不再使用的 Java 对象,把他们曾经占用的内存重新释放出来。所以我们要搞清楚: - -- [对象是如何创建的](https://tobebetterjavaer.com/jvm/whereis-the-object.html)?对象是如何被访问的?到底哪些对象是废弃的?于是我们就需要搞清楚对象的生和死。 -- 这些废弃了的对象到底放在哪?于是就需要了解[JVM 的内存结构](https://tobebetterjavaer.com/jvm/neicun-jiegou.html):方法区、堆、程序计数器、虚拟机栈和本地方法栈。 -- 这些废弃了的对象会不会造成内存泄露(OOM,OutOfMemoryError)?于是我们就需要了解每个分区的 OOM。 -- 这些废弃了对象什么时候被回收?于是我们就需要了解垃圾回收算法,比如说清除算法、复制算法、标记整理算法和分代收集算法。 - -知道了一个对象在内存中的生和死,我们还需要知道类是如何在内存中变成对象的?对象的方法是如何执行的? - -于是我们开始学习 Java 虚拟机的执行过程,学习[字节码文件](https://tobebetterjavaer.com/jvm/class-file-jiegou.html)( .class 文件),学习[类的加载机制](https://tobebetterjavaer.com/jvm/class-load.html),学习[虚拟机栈的栈帧结构](https://tobebetterjavaer.com/jvm/how-jvm-run-zijiema-zhiling.html),学习方法的调用过程,学习[字节码指令](https://tobebetterjavaer.com/jvm/zijiema-zhiling.html)等等。 - -为了监控虚拟机和故障排查,我们需要学习[常用的 JDK 命令行工具](https://tobebetterjavaer.com/jvm/problem-tools.html),掌握必要的线上问题排查方法;此外,还需要了解 JIT (Just In Time)并不是简单的将热点代码编译成机器码就收工的,它还会对代码的执行进行优化([方法内联和逃逸分析](https://tobebetterjavaer.com/jvm/jit.html))。 - -JVM 相关的知识已经成为面试必考的科目了,但老实讲,JVM 相关的知识还真的不太好用在项目中,或者说不太好在项目中体现出来。 - -那这里给大家推荐一个实战项目,基于 Spring Boot 的在线 Java IDE,可以远程执行 Java 代码并将程序的运行结果反馈出来。涉及了 Java 类文件的结构、Java 类加载器和 Java 类的热替换等 JVM 相关的技术。 - ->[https://github.com/TangBean/OnlineExecutor](https://github.com/TangBean/OnlineExecutor) - -听我这么一说,是不是一下子就清晰多了! - -**Java 虚拟机虽然难学,但如果你能坚持学下去,内功自然而然就提升了一大截**。 -