| I | II | III | IV | V | VI | VII | VIII | IX | X | XI | XII |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 算法 📝 | Java ☕️ | Python 🐍 | 前端 🔗 | 数据库 💾 | 操作系统 💻 | 网络通信 ☁️ | 分布式 📃 | 机器学习 🔍 | 工具 🔨 | Learn 📖 | Talking 💡 |
✨✨✨
和 500+ 技术达人在线交流: 🤟 快来吧,和大家一起技术互动交流
『技术博客』:<a href="https://www.frankfeekr.cn">www.frankfeekr.cn</a> | 『开源贡献』:<a href="notes/开源贡献.md">⊱ 英雄招募令</a> | 『微信订阅号』:全栈开发社区
🔥🔥🔥
欢迎光临 LinTools 开发者的在线导航: https://tools.frankfeekr.cn
如果你有更好的在线工具,请点击留言,持续更新!
前言
在学习技术这条路上并不是一帆风顺,也一直在探索一条适合自己的学习方法。从一开始的技术小白,到现在还比较上道的老鸟,在这个过程中走了太多的弯路,想在这里和大家分享一些我的经历和学习方法。
在编程的世界里,该如何选择自己的技术栈呢。学前端?学 APP 开发?对于 Java、C++、C#、Python、PHP 又如何选择呢?人工智能现如今这么火,是不是机器学习、深度学习更高级一些呢?那么程序员又如何修炼内功呢?
工欲善其事,必先利其器。这里我将推荐开发过程中的提效工具、开发利器、协作工具、文档技术等等。
敏捷软件开发中可能是最富有成效的几种方法学之一
技能图谱
后台开发技能图谱,从程序员的内功修炼到后台语言,分布式系统架构
一、数据结构与算法
排序算法、动态规划、递归、回溯法、贪心算法等
数据处理典型案例,逐渐更新
二、Java
基本概念、面向对象、关键字、基本数据类型与运算、字符串与数组、异常处理、Object 通用方法
数据结构 & 源码分析:ArrayList、Vector、LinkedList、HashMap、ConcurrentHashMap、HashSet、LinkedHashSet and LinkedHashMap
线程状态、线程机制、线程通信、J.U.C 组件、JMM、线程安全、锁优化
磁盘操作、字节操作、字符操作、对象操作、网络操作、NIO
运行时数据区域、垃圾收集、内存分配机制、类加载机制、性能调优监控工具
Java 常见的 10 余种设计模式,全 23 种设计模式逐步更新
包含 Servlet & JSP、Spring、SpringMVC、Mybatis、Hibernate、Structs2 核心思想,如 IOC、AOP 等思想。SSM 更详细请转向:Spring | SpringMVC | MyBatis
三、Python
四、前端
TODO LIST
- HTML5
- CSS3
- CSS 预处理
- sass(scss)
- less
- stylus
- CSS 框架
- BootStarp
- LayUI
- JavaScript
基础语法、进阶、ES6
- JavaScript 框架
- Vue
- React
- Angular
- jQuery
- Node
常用 api、对象池、异常处理、进程通信、高并发
- 静态类型检查
- TypeScript
- Flow
- 构建/打包工具
- webpack
- gulp
- rollup
- 包管理工具
- npm
- yarn
- 服务端渲染
- koa2
- express
- nuxt
- next
五、数据库
存储引擎、事务隔离级别、索引、主从复制
Redis 核心知识
常用 SQL 语句
一个开源的关系数据库,是从伯克利写的 POSTGRES 软件包发展而来的
玩转时序数据库
六、操作系统
进程管理、死锁、内存管理、磁盘设备
基础核心概念、常用命令使用
七、网络通信
传输层、应用层(HTTP)、网络层、网络安全
软件架构风格、格设计原则和约束条件
web前后端漏洞分析与防御,XSS 攻击、CSRF 攻击、DDoS 攻击、SQL 注入
八、分布式
容器化引擎服务
微服务简介、API 网关、服务注册发现、服务通信
分布式协调服务,服务注册发现
深入浅出 Kafka,将用最极简的语言带你走进 Kafka 的消息中间件世界
【说明】分布式专题 笔者也在学习中,这里列举了一些技能列表,笔者将局部更新。敬请期待
TODO LIST
- Kubernetes(k8s)
容器化部署,管理云平台中多个主机上的容器化的应用
- 云计算
SaaS(软件即服务) 、PaaS(平台即服务) 、IaaS(基础架构即服务)
- Zookeeper
分布式协调服务,服务注册发现
- Dubbo、Thrift(RPC 框架)
分布式服务治理
- 分布式事务解决方案
- ActiveMQ、Kafka、RabbitMQ
分布式消息通信
- 熔断,限流,降级机制
- Redis
分布式缓存
- Mycat
数据库路由
- Nginx
反向代理
- Tomcat
Web Server 服务
- DevOps
自动化运维,持续集成、持续交付、持续部署
- 分布式锁
基于 Redis、MySQL、Zookeeper 的分布式锁实现
- FastDFS
轻量级分布式文件管理系统
- Go
并发的、带垃圾回收的、快速编译的语言
九、机器学习
- 经典机器学习算法
K 近邻算法、线性回归、梯度下降法、逻辑回归、支持向量机、决策树、集成学习
十、工具
学习指引,将用最极简的语言带你进入 Git 版本控制的世界
集中式工作流,功能分支工作流, GitFlow 工作流,Forking 工作流,Pull Requests
常见符号含义,速查表
基于 lanproxy 穿透服务,为你定了一键启动的服务端和客户端 Docker 镜像
手把手带你玩转,远程调试与远程热部署
Learn 📖
包含阅读清单,学习课程两部分
Talking :bulb:
本仓库致力于成为一个全栈开发爱好者的学习指南,给初学者一个更明确的学习方向,同时也是对自己技能的强化和巩固。在架构师这条路上,希望和大家一起成长,帮助更多的计算机爱好者能够有一个明确的学习路径。持续不间断的维护本仓库,也欢迎有更多的极客们加入。
都说好记性不如烂笔头,定期的学习和整理必然对学习巩固有所帮助,这里通过索引的方式对全栈开发技术做一个系统分类,方便随时巩固和学习,当然还有面试。在学习这条路上难免会有很多盲点和学不完的知识。有道无术,术尚可求,掌握好思维能力才能应对千变万化的技术。不要把大脑当成硬盘,也不要做高速运转的 CPU,而修行自己的大脑成为一个搜索引擎,学会分析解决问题。
Since 20,May,2018
Reference
个人的能力有限,在编写的过程中引用了诸多优秀的 GitHub 仓库。本项目的启发来自 @CyC2018 的学习笔记,是一个非常优秀的开源项目,在本仓库中部分内容引用文字和图例;引用了 @计算所的小鼠标 中对于 JCF 的源码分析和理解;引用了 阿里面试题总结 中全部的面试题,并对面经进行了整理勘误,并进行了知识拓展和修改;引用了 牛客网 上的面试经验贴。也引用了知乎上的热门回答和优秀博客的回答。在这里特别鸣谢,我将每篇文章中做外链引用说明。
文中我也推荐了学习的书籍和学习课程,都将附着上最高清、最形象的配图进行讲解。在文中的配图都来自自己绘制的、博客、Github、PDF书籍等等,这里没法一一感谢,谢谢你们。
推荐一些优秀的开源项目,供大家参考,reference。
Contributors
Thank you to all the people who already contributed to fullstack-tutorial !
Please make sure to read the Contributing Guide/如何给我的仓库贡献 before making a pull request.
Stargazers over time
License
Copyright (c) 2021-present, Frank Lam
关于作者 :boy:

全栈开发神兵利器
- [一、团队协作](#一团队协作)
- [团队协作](#团队协作)
- [远程](#远程)
- [笔记备忘](#笔记备忘)
- [二、图形与设计](#二图形与设计)
- [思维导图与原型设计](#思维导图与原型设计)
- [绘图工具](#绘图工具)
- [平面与视频设计](#平面与视频设计)
- [三、版本控制](#三版本控制)
- [SVN](#svn)
- [Git](#git)
- [Git 工作流](#git-工作流)
- [Git 托管平台](#git-托管平台)
- [自主搭建代码托管平台](#自主搭建代码托管平台)
- [四、全栈开发](#四全栈开发)
- [数据库管理(以Mysql为例)](#数据库管理以mysql为例)
- [SSH 连接工具](#ssh-连接工具)
- [SHELL](#shell)
- [接口调试工具](#接口调试工具)
- [轻量级开发工具](#轻量级开发工具)
- [容器化技术](#容器化技术)
- [五、文档技术](#五文档技术)
- [文档平台](#文档平台)
- [自动文档生成工具](#自动文档生成工具)
- [开源框架](#开源框架)
全栈开发神兵利器
工欲善其事,必先利其器。这里我将推荐开发过程中的提效工具、开发利器、协作工具、文档工具等等。欢迎在 issues#21 中补充你用到过的神兵利器,我将把留言中的工具更新到本文。
一、团队协作
团队协作
- Teambition:团队协作工具创导者
- 有道云协作:企业知识管理与协作平台
- tower:深受用户喜爱的团队协作工具
远程
- TeamViewer:安全远程访问和支持 TeamViewer 基于最广泛的平台和技术,连接全世界的人、地区和事物。
- 向日葵:简单好用的远程控制软件
- mstsc:运行
win+r,输入mstsc。不要忽略 windows 自带的强大远程桌面连接工具
注意:真的不要再用 QQ 远程了,真的很卡!
笔记备忘
- 印象笔记:工作必备效率应用
- 有道云笔记:网易出品,获得 5000 万用户青睐的笔记软件。提供了 PC 端、移动端、网页端等多端应用,用户可以随时随地对线上资料进行编辑、分享以及协同。
- 日事清:怕工作进度延误 就用日事清
- 滴答清单:一个帮你高效完成任务和规划时间的应用
二、图形与设计
思维导图与原型设计
- XMind:思维导图,框架图等等,非常推荐。收费软件,部分功能可用
- MindManager:让思考、计划和沟通变得更容易
- 百度脑图:在线免费脑图,推荐
- Mockplus:更快、更简单的原型设计
- Axure RP:是一款专业的快速原型设计工具
绘图工具
- Visio:微软绘图工具,以直观的方式工作,轻松绘制图表
- 亿图:国产综合图形图表设计软件。类似 visio 的绘图工具(完美破解版)
- ProcessOn:支持流程图、思维导图、原型图、UML、网络拓扑图、组织结构图等
- draw.io:free online diagram software for making flowcharts, process diagrams, org charts, UML, ER and network diagrams
- StarUML:A sophisticated software modeler for agile and concise modeling(总之 UML 绘图神器)
- logomakr:https://logomakr.com
平面与视频设计
只会写代码,设计都不会?本人从事过多年平面设计和视频相关的工作,这里也给大家推荐一些平时做设计的时的一些软件。
- Adobe Photoshop:图像编辑和合成,这个就不用我介绍了吧
- Adobe Premiere Pro:视频制作和编辑(业余爱好者可使用绘声绘影)
- After Effects:电影视觉效果和动态图形
- After Illustrator:矢量图形和插图
- Corel DRAW:和 AI 齐名的矢量图制作工具
三、版本控制
SVN
- Subversion (SVN) 是一个开源的版本控制系統, 也就是说 Subversion 管理着随时间改变的数据。 这些数据放置在一个中央资料档案库 (repository) 中。 这个档案库很像一个普通的文件服务器, 不过它会记住每一次文件的变动。 这样你就可以把档案恢复到旧的版本, 或是浏览文件的变动历史
- 工具下载:tortoiseSVN
- 学习资源
- 文档:[菜鸟教程 SVN教程](http://www.runoob.com/svn/svn-intro.html)
- 视频:[版本管理工具介绍—SVN篇](https://www.imooc.com/learn/109)
Git
- Git 是一个开源的分布式版本控制系统,用于敏捷高效地处理任何或小或大的项目
- 工具下载:
- [SourceTree](https://www.sourcetreeapp.com/)(推荐★★★)
- [tortoiseGit](https://tortoisegit.org/)
- [GitHub Desktop](https://desktop.github.com/)
- 学习资源
- 文档:[菜鸟教程 Git教程](http://www.runoob.com/git/git-tutorial.html)
- 视频:[版本管理工具介绍—Git篇](https://www.imooc.com/learn/208)
Git 工作流
Git 托管平台
- Github:全球最大的程序员社交网站(同性交友网站)
- 码云:国内比较大的 Git 托管平台。码云专为开发者提供稳定、高效、安全的云端软件开发协作平台。无论是个人、团队、或是企业,都能够用码云实现代码托管、项目管理、协作开发
- CODING:国内 Git 托管平台,Coding,让开发更简单
自主搭建代码托管平台
- GitLab:可以使用 GitLab 官方的服务,也提供了开源社区版供团队搭建使用。(推荐使用 Docker 可实现一键自动化搭建)
- Gogs:一款极易搭建的自助 Git 服务,通过 Go 语言写的,适合在 Linux 服务器上搭建
- VisualSVN:VisualSVN Server allows you to easily install and manage a fully-functional Subversion server on the Windows platform.
- iF.SVNAdmin:The iF.SVNAdmin application is a web based GUI to your Subversion authorization file. It is based on PHP 5.3 and requires a web server (Apache) to be installed. (通过 PHP 在 Linux 上搭建 SVN 平台,并且有 web 管理页面)
四、全栈开发
数据库管理(以Mysql为例)
- Navicat Premium:可以连接所有数据库,配套Navicat也针对不同的数据库有不同的版本,请点击进入官网自行查看,收费软件,需要百度自行破解。
- SQLyog:Administrate MySQL Databases With Ease Using a Graphical Interface,免费
SSH 连接工具
- MobaXterm(超级推荐,太极客了!而且是免费的)
- Xshell 5
- SecureCRT 6.6
- GitKraken
推荐:以上三款工具我都使用过,目前已经弃用 Xshell 和 SecureCRT,推荐使用 MobaXterm
SHELL
接口调试工具
- 抓包工具1 | Fiddler:The free web debugging proxy(很优秀的抓包工具,目前似乎只支持 windows 用户)
- 抓包工具2 | charles:Charles is an HTTP proxy / HTTP monitor / Reverse Proxy that enables a developer to view all of the HTTP and SSL / HTTPS traffic between their machine and the Internet.(MacOS的必备抓包工具)
- 接口调试 | postman:Developers use Postman to build modern software for the API-first world.
轻量级开发工具
- Sublime Text:A sophisticated text editor for code, markup and prose
- VS Code:Free. Open source. Runs everywhere.(非常推荐,后起之秀,有丰富的社区插件,超级推荐使用,推荐安装 One Dark Pro Theme)
- Atom:A hackable text editor for the 21st Century
- brackets:A modern, open source text editor that understands web design.(前端神奇)
- 三者比较请移步知乎:Atom、Sublime Text、VSCode 三者比较,各有哪些优势和弱势?
容器化技术
- Docker:秒级启动虚拟机容器技术。真正一次编写,到处运行。(一定要学!)
五、文档技术
在团队协作中必须会涉及到文档交互部分,这里推荐以下几个文档平台和开源项目
文档平台
- 看云:专注于文档在线创作、协作和托管(极力推荐,每个文档只有50Mb的免费空间,超过需要收费)
自动文档生成工具
- ApiDoc:Inline Documentation for RESTful web APIs,可以通过命令行将代码中的注释生成在线可调试的文档,开发者的福音啊
- Swagger:The Best APIs are Built with Swagger Tools,在 Java web 项目中用的比较多
开源框架
- ShowDoc:一个非常适合IT团队的在线 API 文档、技术文档工具。使用 PHP 开发的文档框架
- MinDoc:MinDoc 是一款针对IT团队开发的简单好用的文档管理系统
- vuepress:vue 官方团队的文档解决方案,适合于静态博客或是文档
- docsify:类似 gitbook 和 vuepress 的文档解决方案
- hexo:markdown 编写,自动生成静态博客
XP极限编程
- [解析极限编程](#解析极限编程)
- [四大价值观](#四大价值观)
- [沟通](#沟通)
- [简单](#简单)
- [反馈](#反馈)
- [勇气](#勇气)
- [四大价值观之外](#四大价值观之外)
- [5 个原则](#5-个原则)
- [快速反馈](#快速反馈)
- [简单性假设](#简单性假设)
- [逐步修改](#逐步修改)
- [提倡更改](#提倡更改)
- [优质工作](#优质工作)
- [13 个最佳实践](#13-个最佳实践)
- [计划游戏](#计划游戏)
- [小型发布](#小型发布)
- [隐喻](#隐喻)
- [简单设计](#简单设计)
- [测试先行/测试驱动开发](#测试先行/测试驱动开发)
- [重构](#重构)
- [结对编程](#结对编程)
- [集体代码所有制](#集体代码所有制)
- [持续集成](#持续集成)
- [每周工作 40 小时/可持续的速度](#每周工作-40-小时/可持续的速度)
- [现场客户](#现场客户)
- [编码标准](#编码标准)
- [配合是关键](#配合是关键)
- [原文链接](#原文链接)
了解 XP 极限编程
敏捷方法论有一个共同的特点,那就是都将矛头指向了“文档”,它们认为传统的软件工程方法文档量太“重”了,称为“重量级”方法,而相应的敏捷方法则是“轻量级”方法。正是因为“轻量级”感觉没有什么力量,不但不能够有效体现灵活性,反而显得是不解决问题的方法论似的。因此,就有了一次划时代的会议,创建了敏捷联盟。
在敏捷方法论领域中,比较知名的、有影响力的,是拥有与 Microsoft 的操作系统相同缩写语——XP的极限编程(eXtreme Programming)。极限编程方法论可以说是敏捷联盟中最鲜艳的一面旗帜,也是被研究、尝试、应用、赞扬、批判最多的一种方法论,也是相对来说最成熟的一种。
这一被誉为“黑客文化”的方法论的雏形最初形成于1996—1999年间,Kent Beck、Ward Cunninggham、Ron Jeffrey 在开发 C3 项目(Chrysler Comprehensive Compensation)的实践中总结出了 XP 的基本元素。在此之后,Kent Beck 和他的一些好朋友们一起在实践中完善提高,终于形成了极限编程方法论。
解析极限编程
那么什么是 XP 呢?XP 是一种轻量(敏捷)、高效、低风险、柔性、可预测、科学而且充满乐趣的软件开发方式。与其他方法论相比,其最大的不同在于:
- 在更短的周期内,更早地提供具体、持续的反馈信息。
- 在迭代的进行计划编制,首先在最开始迅速生成一个总体计划,然后在整个项目开发过程中不断的发展它。
- 依赖于自动测试程序来监控开发进度,并及早地捕获缺陷。
- 依赖于口头交流、测试和源程序进行沟通。
- 倡导持续的演化式设计。
- 依赖于开发团队内部的紧密协作。
- 尽可能达到程序员短期利益和项目长期利益的平衡。
Kent Beck 曾经说过“开车”就是一个 XP 的范例,即使看上去进行得很顺利,也不要把视线从公路上移开,因为路况的变化,将使得你必须随时做出一些这样那样的调整。而在软件项目中,客户就是司机,他们也没有办法确切地知道软件应该做什么,因此程序员就需要向客户提供方向盘,并且告知我们现在的位置。
XP 包括写什么呢?如图,XP 由价值观、原则、实践和行为四个部分组成,它们彼此相互依赖、关联, 并通过行为贯穿于整个生命期。
四大价值观
XP 的核心是其总结的沟通(Communication)、简单(Simplicity)、反馈(Feedback)、勇气(Courage)四大价值观,它们是XP的基础,也是XP的灵魂。
此外还扩展了第五个价值观:谦逊(Modesty)。 XP用“沟通、简单、反馈、勇气和谦逊”来减轻开发压力和包袱;XP 精神可以启发我们如何学习和对待快速变化、多样的开发技术。成功学习 XP 的关键,是用“沟通、简单、反馈、勇气和谦逊”的态度来对待 XP;轻松愉快地来感受 XP 的实践思想;自己认真实践后,通过对真实反馈的分析,来决定 XP 对自己的价值;有勇气接受它,或改进它。
沟通
通常程序员给人留下的印象就是“内向、不善言谈”,然后项目中的许多问题就出在这些缺乏沟通的开发人员身上。经常由于某个程序员做出了一个设计决定,但是却不能及时地通知大家,结果使得大家在协作与配合上出现了很多的麻烦,而在传统的方法论中,并不在意这种口头沟通不畅的问题,而是希望借助于完善的流程和面面俱到的文档、报表、计划来替代,但是这同时又引入了效率不高的新问题。
XP 方法论认为,如果小组成员之间无法做到持续的、无间断的交流,那么协作就无从谈起,从这个角度能够发现,通过文档、报表等人工制品进行交流面临巨大的局限性。因此,XP 组合了诸如对编程这样的最佳实践,鼓励大家进行口头交流,通过交流解决问题,提高效率。
简单
XP 方法论提倡在工作中秉承“够用就好”的思路,也就是尽量地简单化,只要今天够用就行,不考虑明天会发现的新问题。这一点看上去十分容易,但是要真正做到保持简单的工作其实很难的。因为在传统的开发方法中,都要求大家对未来做一些预先规划,以便对今后可能发生的变化预留一些扩展的空间。
正如对传统开发方法的认识一样,许多开发人员也会质疑 XP,保持系统的扩展性很重要,如果都保持简单,那么如何使得系统能够有良好的扩展性呢?其实不然,保持简单的理由有两个:
- 开发小组在开发时所做的规划,并无法保证其符合客户需要的,因此做的大部分工作都将落空,使得开发过程中重复的、没有必要的工作增加,导致整体效率降低。
- 另外,在 XP 中提倡时刻对代码进行重构,一直保持其良好的结构与可扩展性。也就是说,可扩展性和为明天设计并不是同一个概念,XP 是反对为明天考虑而工作,并不是说代码要失去扩展性
而且简单和沟通之间还有一种相对微妙的相互支持关系。当一个团队之间,沟通的越多,那么就越容易明白哪些工作需要做,哪些工作不需要做。另一方面,系统越简单,需要沟通的内容也就越少,沟通也将更加全面。
反馈
是什么原因使得我们的客户、管理层这么不理解开发团队?为什么客户、管理层总是喜欢给我们一个死亡之旅?究其症结,就是开发的过程中缺乏必要的反馈。在许许多多项目中,当开发团队经历过了需求分析阶段之后,在相当长的一段时间内,是没有任何反馈信息的。整个开发过程对于客户和管理层而言就像一个黑盒子,进度完全是不可见的。
而且在项目的过程中,这样的现象不仅出现在开发团队与客户、管理层之间,还包括在开发团队内部。这一切问题都需要我们更加注重反馈。,反馈对于任何软件项目的成功都是至关重要的,而在 XP 方法论中则更进一步,通过持续、明确的反馈来暴露软件状态的问题。具体而言就是:
- 在开发团队内部,通过提前编写单元测试代码,时时反馈代码的问题与进展。
- 在开发过程中,还应该加强集成工作,做到持续集成,使得每一次增量都是一个可执行的工作版本,也就是逐渐是软件长大,整个过程中,应该通过向客户和管理层演示这些可运行的版本,以便及早地反馈,及早地发现问题。
同时,我们也会发现反馈与沟通也有着良好的配合,及时和良好的反馈有助于沟通。而简单的系统更有利于测试和反馈。
勇气
在应用 XP 方法论时,我们每时每刻都在应对变化:由于沟通良好,因此会有更多需求变更的机会;由于时刻保持系统的简单,因此新的变化会带来一些重新开发的需要;由于反馈及时,因此会有更多中间打断你的思路的新需求。
总之这一切,使得你立刻处于变化之中,因此这时就需要你有勇气来面对快速开发,面对可能的重新开发。也许你会觉得,为什么要让我们的开发变得如此零乱,但是其实这些变化若你不让它早暴露,那么它就会迟一些出现,并不会因此消亡,因此,XP 方法论让它们早出现、早解决,是实现“小步快走”开发节奏的好办法。
也就是 XP 方法论要求开发人员穿上强大、自动测试的盔甲,勇往直前,在重构、编码规范的支持下,有目的地快速开发。
勇气可以来源于沟通,因为它使得高风险、高回报的试验成为可能;勇气可以来源于简单,因为面对简单的系统,更容易鼓起勇气;勇气可以来源于反馈,因为你可以及时获得每一步前进的状态(自动测试),会使得你更勇于重构代码。
四大价值观之外
在这四大价值观之下,隐藏着一个更深刻的东西,那就是尊重。因为这一切都建立在团队成员之间的相互关心、相互理解的基础之上。
5 个原则
快速反馈
及时地、快速地获取反馈,并将所学到的知识尽快地投入到系统中去。也就是指开发人员应该通过较短的反馈循环迅速地了解现在的产品是否满足了客户的需求。这也是对反馈这一价值观的进一步补充。
简单性假设
类似地,简单性假设原则是对简单这一价值观的进一步补充。这一原则要求开发人员将每个问题都看得十分容易解决,也就是说只为本次迭代考虑,不去想未来可能需要什么,相信具有将来必要时增加系统复杂性的能力,也就是号召大家出色地完成今天的任务。
逐步修改
就像开车打方向盘一样,不要一次做出很大的改变,那样将会使得可控性变差,更适合的方法是进行微调。而在软件开发中,这样的道理同样适用,任何问题都应该通过一系列能够带来差异的微小改动来解决。
提倡更改
在软件开发过程中,最好的办法是在解决最重要问题时,保留最多选项的那个。也就是说,尽量为下一次修改做好准备。
优质工作
在实践中,经常看到许多开发人员喜欢将一些细小的问题留待后面解决。例如,界面的按钮有一些不平整,由于不影响使用就先不管;某一两个成员函数暂时没用就不先写等。这就是一种工作拖泥带水的现象,这样的坏习惯一旦养成,必然使得代码质量大打折扣。
而在 XP 方法论中,贯彻的是“小步快走”的开发原则,因此工作质量决不可打折扣,通常采用测试先行的编码方式来提供支持。
13 个最佳实践
在 XP 中,集成了 13 个最佳实践,有趣的是,它们没有一个是创新的概念,大多数概念和编程一样老。其主要创新点在于提供一种良好的思路,将这些最佳实践结合在一起,并且确保尽可能彻底地执行它们,使得它们能够在最大程度上相互支持,紧接下来,我们就对每一种最佳实践进行一番了解。
计划游戏
计划游戏的主要思想就是先快速地制定一份概要的计划,然后随着项目细节的不断清晰,再逐步完善这份计划。计划游戏产生的结果是一套用户故事及后续的一两次迭代的概要计划。
“客户负责业务决策,开发团队负责技术决策”是计划游戏获得成功的前提条件。也就是说,系统的范围、下一次迭代的发布时间、用户故事的优先级应该由客户决定;而每个用户故事所需的开发时间、不同技术的成本、如何组建团队、每个用户故事的风险,以及具体的开发顺序应该由开发团队决定。
好了,明白这些就可以进行计划游戏了。首先客户和开发人员坐在同一间屋子里,每个人都准备一支笔、一些用于记录用户故事的纸片,最好再准备一个白板,就可以开始了。
- 客户编写故事:由客户谈论系统应该完成什么功能,然后用通俗的自然语言,使用自己的语汇,将其写在卡片上,这也就是用户故事。
- 开发人员进行估算:首先客户按优先级将用户故事分成必须要有、希望有、如果有更好三类,然后开发人员对每个用户故事进行估算,先从高优先级开始估算。如果在估算的时候,感到有一些故事太大,不容易进行估算,或者是估算的结果超过 2人/周,那么就应该对其进行分解,拆成 2 个或者多个小故事。
- 确定迭代的周期:接下来就是确定本次迭代的时间周期,这可以根据实际的情况进行确定,不过最佳的迭代周期是 2~3 周。有了迭代的时间之后,再结合参与的开发人数,算出可以完成的工作量总数。然后根据估算的结果,与客户协商,挑出时间上够、优先级合适的用户故事组合,形成计划。
小型发布
XP 方法论秉承的是“持续集成,小步快走”的哲学,也就是说每一次发布的版本应该尽可能的小,当然前提条件是每个版本有足够的商业价值,值得发布。
由于小型发布可以使得集成更频繁,客户获得的中间结果也越频繁,反馈也就越频繁,客户就能够实时地了解项目的进展情况,从而提出更多的意见,以便在下一次迭代中计划进去。以实现更高的客户满意度。
隐喻
相对而言,隐喻这一个最佳实践是最令人费解的。什么是隐喻呢?根据词典中的解释是:“一种语言的表达手段,它用来暗示字面意义不相似的事物之间的相似之处”。那么这在软件开发中又有什么用呢?总结而言,常常用于四个方面。
- 寻求共识:也就是鼓励开发人员在寻求问题共识时,可以借用一些沟通双方都比较熟悉的事物来做类比,从而帮助大家更好地理解解决方案的关键结构,也就是更好地理解系统是什么、能做什么。
- 发明共享词汇:通过隐喻,有助于提出一个用来表示对象、对象间的关系通用名称。例如,策略模式(用来表示可以实现多种不同策略的设计模式)、工厂模式(用来表示可以按需“生产”出所需类得设计模式)等。
- 创新的武器:有的时候,可以借助其他东西来找到解决问题的新途径。例如:“我们可以将工作流看做一个生产线”。
- 描述体系结构:体系结构是比较抽象的,引入隐喻能够大大减轻理解的复杂度。例如管道体系结构就是指两个构件之间通过一条传递消息的“管道”进行通信。
当然,如果能够找到合适的隐喻是十分快乐的,但并不是每种情况都可以找到恰当的隐喻,你也没有必要强求
简单设计
强调简单设计的价值观,引出了简单性假设原则,落到实处就是“简单设计”实践。这个实践看上去似乎很容易理解,但却又经常被误解,许多批评者就指责 XP 忽略设计是不正确的。其实,XP 的简单设计实践并不是要忽略设计,而且认为设计不应该在编码之前一次性完成,因为那样只能建立在“情况不会发生变化”或者“我们可以预见所有的变化”之类的谎言的基础上的。
Kent Beck 概念中简单设计是这样的:
- 能够通过所有的测试程序。
- 没有包括任何重复的代码。
- 清楚地表现了程序员赋予的所有意图。
- 包括尽可能少的类和方法
- 他认为要想保持设计简单的系统,需要具备简单思考的能力,拥有理解代码和修改的勇气,以及为了消除代码的“坏味道”而定期重构的习惯。
- 那么如何开始进行简单的设计呢?XP 实践者们也总结也一些具体的、可操作的思考方法。
- 首先写测试代码:具体将在后面详细描述。
- 保持每个类只负责一件事:SRP(单一职责原则)是面向对象设计的基础原则之一。
- 使用 Demeter(迪米特)法则:迪米特法则,也称为 LoD 法则、最少知识原则。也就是指一个对象应当对其他对象尽可能少地了解。用隐喻的方法来解释的话就是“只与你直接的朋友通信”、“不要和陌生人说话”。
- 使用 CRC 卡片进行探索。
测试先行/测试驱动开发
当我第一次看到“测试先行”这个概念的时候,我的第一感觉就是不解,陷入了“程序都还没有写出来,测试什么呀?”的迷思。我开始天马行空地寻求相关的隐喻,终于找到了能够启发我的工匠,首先,我们来看看两个不同的工匠是如何工作的吧。
- 工匠一:先拉上一根水平线,砌每一块砖时,都与这跟水平线进行比较,使得每一块砖都保持水平。
- 工匠二:先将一排砖都砌完,然后再拉上一根水平线,看看哪些砖有问题,对有问题的砖进行适当的调整。
你会选择哪种工作方法呢?你一定会骂工匠二笨吧!这样多浪费时间呀!然而你自己想想,你平时在编写程序的时候又是怎么做的呢?我们就是按工匠二的方法在工作呀!甚至有时候比工匠二还笨,是整面墙都砌完了,直接进行“集成测试”,经常让整面的墙倒塌。看到这里,你还会觉得自己的方法高明吗?这个连工匠都明白的道理,自己却画地为牢呀。
不仅我们没有采用工匠一的工作方法,甚至有的时候程序员会以“开发工作太紧张”为理由,而忽略测试工作。但这样却导致了一个恶性循环,越是没有空编写测试程序,代码的效率与质量越差,花在找 Bug、解决 Bug 的时间也越来越多,实际产能大打降低。由于产能降低了,因此时间更紧张,压力更大。你想想,为什么不拉上一根水平线呢?难道,我们不能够将后面浪费的时间花在单元测试上,使得我们的程序一开始就更健壮,更加易于修改吗?不过,编写测试程序当然要比拉一条水平线难道多,所以我们需要引入“自动化测试工具”,免费的 xUnit 测试框架就是你最佳的选择。
为了鼓励程序员原意甚至喜欢在编写程序之前编写测试代码,XP 方法论还提供了许多有说服力的理由。
- 如果你已经保持了简单的设计,那么编写测试代码根本不难。
- 如果你在结对编程,那么如果你想出一个好的测试代码,那么你的伙伴一定行。
- 当所有的测试都通过的时候,你再也不会担心所写的代码今后会“暗箭伤人”,那种感觉是相当棒的。
- 当你的客户看到所有的测试都通过的时候,会对程序充满前所未有的信心。
- 当你需要进行重构时,测试代码会给你带来更大的勇气,因为你要测试是否重构成功只需要一个按钮。
测试先行是 XP 方法论中一个十分重要的最佳实践,并且其中所蕴含的知识与方法也十分丰富。
重构
重构时一种对代码进行改进而不影响功能实现的技术,XP 需要开发人员在闻到代码的坏味道时,有重构代码的勇气。重构的目的是降低变化引发的风险,使得代码优化更加容易。通常重构发生在两种情况之下。
- 实现某个特性之前:尝试改变现有的代码结构,以使得实现新的特性更加容易。
- 实现某个特性之后:检查刚刚写完的代码后,认真检查一下,看是否能够进行简化。
在《重构》一书中,作者 Martin Fowler 提示我们:在考虑重构时,应该要养成编写并经常运行测试代码的习惯;要先编写代码,再进行重构;把每一次增加功能都当做一次重构的好时机;将每一个纠正错误当做一次重构的重要时机。同时,该书中也列出大量需要重构的情况和重构方法。
最后类似地,给还没有足够勇气进行重构的程序员打几剂强心针:
- XP 提倡集体代码所有制,因此你可以大胆地在任何需要修改的地方做改动。
- 由于在 XP 项目组中有完整的编码标准,因此在重构前无须重新定义格式。
- 在重构中遇到困难,和你结对编程的伙伴能够为你提供有效的帮助。
- 简单的设计,会给重构带来很大的帮助。
- 测试先行让你拥有了一个有效的检验器,随时运行一下就知道你重构的工作是否带来了影响。
- 由于 XP 在持续集成,因此你重构所带来的破坏很快就能够暴露,并且得以解决。
重构技术是对简单性设计的一个良好的补充,也是 XP 中重视“优质工作”的体现,这也是优秀的程序员必备的一项技能。
结对编程
“什么!两个人坐在一起写程序?那岂不是对人力的巨大浪费吗?而且我在工作时可不喜欢有一个人坐在边上当检察官。”是的,正如这里列举出来的问题一样,结对编程技术还是被很多人质疑的。
不过,自从 20 世纪 60 年代,就有类似的实践在进行,长期以来的研究结果却给出了另外一番景象,那就是结对编程的效率反而比单独编程更高。一开始虽然会牺牲一些速度,但慢慢的,开发速度会逐渐加快,究其原因,主要是结对编程大打降低了沟通的成本,提供了工作的质量,具体表现在:
- 所有的设计决策确保不是由一个人做出的。
- 系统的任何一个部分都肯定至少有 2 个人以上熟悉。
- 几乎不可能有 2 个人都忽略的测试项或者其他任务
- 结对组合的动态性,是一个企业知识管理的好途径。
- 代码总是能够保证被评审过。
- 而且 XP 方法论集成的其他最佳实践也能够使得结对编程更加容易进行:
- 编码标准可以消除一些无谓的分歧。
- 隐喻可以帮助结对伙伴更好地沟通。
- 简单设计可以使得结对伙伴更了解他们所从事的工作。
结对编程技术被誉为 XP 保持工作质量、强调人文主义的一个典型的实践,应用得当还能够使得开发团队之前的协作更加流畅、知识交流与共享更加频繁,团队的稳定性也会更加稳固。
集体代码所有制
由于 XP 方法论鼓励团队进行结对编程,而且认为结对编程的组合应该动态地搭配,根据任务的不同、专业技能的不同进行最优组合。由于每个人都肯定会遇到不同的代码,所以代码的所有制就不再适合于私有,因为那样会给修改工作带来巨大的不便。
也就是说,团队中的每个成员都拥有对代码进行改进的权利,每个人都拥有全部代码,也都需要对全部代码负责。同时,XP 强调代码是谁破坏的(也就是修改后发生问题),就应该由谁来修复。
由于在 XP 中,有一些与之匹配的最佳实践,因此你并无须担心采用集体代码所有制会让你的代码变得越来越乱:
- 由于在 XP 项目中,集成工作是一件经常性得工作,因此当有人修改代码而带来了集成的问题,会在很快的时间内被发现。
- 由于每一个类都会有一个测试代码,因此不论谁修改了代码,都需要运行这个测试代码,这样偶然性的破坏发生的概率将很小。
- 由于每一个代码的修改就是通过了结对的两个程序员共同思考,因此通常做出的修改都是对系统有益的。
- 由于大家都坚持了相同的编码标准,因此代码的可读性、可修改性都会比较好,而且还能够避免由于命名法、缩进等小问题引发经常性得代码修改。
集成代码所有制是 XP 与其他敏捷方法的一个较大不同,也是从另一个侧面体现了 XP 中蕴含的很深厚的编码情节。
持续集成
在前面谈到小型发布、重构、结对编程、集体代码所有制等最佳实践的时候,我们多次看到“持续集成”的身影,可以说持续集成是对这些最佳实践的基本支撑条件。
可能大家会对持续集成与小型发布代表的意思混淆不清,其实小型发布是指在开发周期经常发布中间版本,而持续集成的含义则是要求 XP 团队每天尽可能多次地做代码集成,每次都在确保系统运行的单元测试通过之后进行。
这样,就可以及早地暴露、消除由于重构、集体代码所有制所引入的错误,从而减少解决问题的痛苦
要在开发过程中做到持续集成并不容易,首先需要养成这个习惯。而且集成工作往往是十分枯燥、烦琐的,因此适当地引入每日集成工具是十分必要的。XP 建议大家首先使用配置管理服务器将代码管理起来,然后使用 Ant 或 Nant 等 XP 工具,编写集成脚本,调用 xUint 等测试框架,这样就可以实现每当程序员将代码 Check in 到配置服务器上时,Ant 就会自动完成编译和集成,并调用测试代码完成相应的测试工作。
每周工作 40 小时/可持续的速度
这是最让开发人员开心的、管理者反对的一个最佳实践了,加班、再加班早已成为开发人员的家常便饭,也是管理者最常使用的一种策略,而 XP 方法论认为,加班最终会扼杀团队的积极性,最终导致项目失败,这也充分体现了 XP 方法关注人的因素比关注过程的因素更多一些。
Kent Beck 认为开发人员即使能够工作更长的时间,他们也不该这样做,因为这样做会使他们更容易厌倦编程工作,从而产生一些影响他们效能的其他问题。因此,每周工作 40 小时是一种顺势行为,是一种规律。其实对于开发人员和管理者来说,违反这种规律是不值得的。
- 开发人员:如果不懂得休息,那么就无法将自己的节奏调整到最佳状态,那么就会带来很大的负面影响。而且在精神不集中的状态下,开发质量也得不到保证。
- 管理者:也许这可以称得上“第二种人月神话”,那就是你不得不通过延长每天的工作时间来获得更多的人月。这是因为,每个开发人员的工作精力是有限的,不可能无限增长,在精力不足的时候,不仅写出来的代码质量没有保障,而且还可能为项目带来退步的效果。因此采用加班的方式并不是一个理性的方式,是得不偿失的。
不过有一点是需要解释的,“每周工作 40 小时”中的 40 不是一个绝对数,它所代表的意思是团队应该保证按照“正常的时间”进行工作。那么如何做到这一点呢?
- 首先,定义符合你团队情况的“正常工作时间”。
- 其次,逐步将工作时间调整到“正常工作时间”。
- 再次,除非你的时间计划一团糟,否则不应该在时间妥协。
- 最后,鼓起勇气,制定一个合情合理的时间表。
正如米卢说过的“享受足球”一样,同样地,每一个开发人员应该做到“享受编程”,那么“每周工作 40 小时”就是你的起点。
团队只有持久才有获胜的希望。他们以能够长期维持的速度努力工作,他们保存精力,他们把项目看作是马拉松长跑,而不是全速短跑。
现场客户
为了保证开发出来的结果与客户的预想接近,XP 方法论认为最重要的需要将客户请到开发现场。就像计划游戏中提到过的,在 XP 项目中,应该时刻保证客户负责业务决策,开发团队负责技术决策。因此,在项目中有客户在现场明确用户故事,并做出相应的业务决策,对于 XP 项目而言有着十分重要的意义。
也许有人会问,客户提交了用户故事之后不就完成工作了吗?其实很多尝试过用户故事的团队都会发现其太过简单,包含的信息量极少,XP方法论不会不了解,因此,不会把用户故事当做开发人员交付代码的唯一指示。用户故事只是一个起点,后面的细节还需要开发人员与客户之间建立起来的良好沟通来补充。
作为一名有经验的开发人员,绝对不会对现场客户的价值产生任何怀疑,但是都会觉得想要实现现场客户十分困难。要实现这一点,需要对客户进行沟通,让其明白,想对于开发团队,项目成功对于客户而言更为重要。而现场客户则是保障项目成功的一个重要措施,想想在你装修房子的时候,你是不是常常在充当现场客户的角色呢?其实这隐喻就是让客户理解现场客户重要性最好的突破口。
其实现场客户在具体实施时,也不是一定需要客户一直和开发团队在一起,而是在开发团队应该和客户能够随时沟通,可以是面谈,可以是在线聊天,可以是电话,当然面谈是必不可少的。其中的关键是当开发人员需要客户做出业务决策是,需要进一步了解业务细节时能够随时找到相应的客户。
不过,也有一些项目是可以不要现场客户参与的:
- 当开发组织中已经有相关的领域专家时。
- 当做一些探索性工作,而且客户也不知道他想要什么时(例如新产品、新解决方案的研究与开发)。
去尝试吧,现场客户不仅可以争取得到,而且还能使得团队焕然一新,与客户建立起良好的合作与信任。
编码标准
编码标准是一个“雅俗共享”的最佳实践,不管是代表重型方法论的 RUP,PSP,还是代表敏捷方法论的 XP,都认为开发团队应该拥有一个编码标准。XP 方法论认为拥有编码标准可以避免团队在一些与开发进度无关的细节问题上发生争论,而且会给重构、结对编程带来很大麻烦。试想如果有人将你上次写的代码的变量命名法做了修改,下次你需要再改这部分代码时,会是一种什么感觉呢?
不过,XP 方法论的编码标准的目的不是创建一个事无巨细的规则表,而是只要能够提供一个确保代码清晰,便于交流的指导方针。
如果你的团队已经拥有编码标准,就可以直接使用它,并在过程中进行完善。如果还没有,那么大家可以先进行编码,然后在过程中逐步总结出编码规则,边做边形成。当然除了这种文字规范以外,还可以采用一些如自动格式化代码工具之类的方法进行代码规范。,事实上,你只需要很好地贯彻执行其他的实践并且进行沟通,编码标准会很容易地浮现出来。
配合是关键
有句经典名言“1+1 > 2 ”最适合表达 XP 的观点,Kent Beck 认为 XP 方法论的最大价值在于在项目中融会贯通地运用12个最佳实践,而非单独地使用。你当然可以使用其中的一些实践,但这并不意味着你就运用了 XP 方法论。XP 方法论真正能够发挥其效能,就必须完整地运用12个实践。
原文链接
数据结构与算法
前言
本文将系统总结算法面试和经典数据结构相关知识点,在这里分成 【数据结构】 和 【算法】 两部分展开。这里将展示主要的核心知识点,关于代码面试的 Leetcode 习题请转向代码仓库:Interview-code
- 阅读书籍
- 《算法4》
- 《程序员代码面试指南》
- 《剑指Offer》
- 学习课程
- 刘宇波:玩转数据结构,从入门到进阶
- 刘宇波:程序员的内功修炼,学好算法与数据结构
- 刘宇波:玩转算法面试 leetcode题库分门别类详细解析
- 在线OJ
第一部分:数据结构
一、线性表
- 数组
- 链表
二、栈和队列
三、树和二叉树
1. 2-3树
2. 红黑树
红黑树的特性:
(1)每个节点或者是黑色的,或者是红色的
(2)根节点是黑色的
(3)每个叶子节点(NIL,最后的空结点)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
(4)如果一个节点是红色的,那么他的孩子结点都是黑色的
(5)从任意一个节点到叶子节点,经过的黑色节点是一样的。[这里也就可以得到插入的节点必然为红色]
红黑树(一)之 原理和算法详细介绍 - 如果天空不死 - 博客园 http://www.cnblogs.com/skywang12345/p/3245399.html
JCFInternals/5-TreeSet and TreeMap.md at 049c84bb65a3114ba4b8355d83c490fb9b26c6af · CarpenterLee/JCFInternals https://github.com/CarpenterLee/JCFInternals/blob/049c84bb65a3114ba4b8355d83c490fb9b26c6af/markdown/5-TreeSet%20and%20TreeMap.md
3. 二叉树
二分查找法
在有序表中,通过不断的二分判断mid与目标是否一致,并缩小目标所在区间。
代码实现
// 非递归实现
private static int search(int[] data, int l, int r, int target) {
int mid;
while(l < r) {
mid = (l + r) / 2;
if(data[mid] == target) {
return mid;
} else if(data[mid] < target) {
l = mid + 1;
} else {
r = mid;
}
}
return -1;
}
// 递归实现
private static int searchDfs(int[] data, int l, int r, int target) {
if(l >= r) {
return -1;
}
int mid = (l + r) / 2;
if(target == data[mid]) {
return mid;
} else if(target > data[mid]) {
return searchDfs(data, mid + 1, r, target);
} else {
return searchDfs(data, l, mid, target);
}
}
4. 二叉树遍历
深度优先遍历(前序、中序、后序遍历)
/**
* 前序遍历 非递归实现
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> ans;
TreeNode* node = root;
stack<TreeNode*> s;
map<TreeNode*, bool> M;
if(node != NULL) {
s.push(node);
while (!s.empty()) {
node = s.top();
if (!M[node]) {
ans.push_back(node->val);
M[node] = true;
}
if (node->left != NULL) {
s.push(node->left);
node->left = NULL;
} else if (node->right != NULL) {
s.push(node->right);
node->right = NULL;
} else {
s.pop();
}
}
}
return ans;
}
};
/**
* 中序遍历
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> ans;
if(root != NULL) {
traversal(root, ans);
}
return ans;
}
void traversal(TreeNode* node, vector<int> &ans) {
if(node->left != NULL) {
traversal(node->left, ans);
}
ans.push_back(node->val);
if(node->right != NULL) {
traversal(node->right, ans);
}
}
};
/**
* 后序遍历
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> ans;
if(root != NULL) {
traversal(root, ans);
}
return ans;
}
void traversal(TreeNode* node, vector<int> &ans) {
if(node->left != NULL) {
traversal(node->left, ans);
}
if(node->right != NULL) {
traversal(node->right, ans);
}
ans.push_back(node->val);
}
};
广度优先遍历(层序遍历)
5. AVL树
6. B和B+
四、字符串和数组
五、图结构
邻接矩阵
邻接表
第二部分:算法思想
一、排序
1. 选择排序(Selection Sort)
选择出数组中的最小元素,将它与数组的第一个元素交换位置。再从剩下的元素中选择出最小的元素,将它与数组的第二个元素交换位置。不断进行这样的操作,直到将整个数组排序。

代码实现
public static void sort(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 寻找[i, n)区间里的最小值的索引
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if(arr[minIndex] > arr[j]){
minIndex = j;
}
}
swap( arr , i , minIndex);
}
}
private static void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
算法分析
表现最稳定的排序算法之一,因为无论什么数据进去都是O(n2)的时间复杂度,所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。理论上讲,选择排序可能也是平时排序一般人想到的最多的排序方法了吧。
2. 插入排序(Insertion Sort)
插入排序从左到右进行,每次都将当前元素插入到左侧已经排序的数组中,使得插入之后左部数组依然有序。
第 j 元素是通过不断向左比较并交换来实现插入过程:当第 j 元素小于第 j - 1 元素,就将它们的位置交换,然后令 j 指针向左移动一个位置,不断进行以上操作。

代码实现
public static void sort(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 选择 arr[i...n) 中的最小值
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[minIndex] > arr[j]) {
minIndex = j;
}
}
swap(arr, i, minIndex);
}
}
// 改进版插入排序(减少了数组元素的操作次数)
public static void better_sort(int[] arr) {
for (int i = 0; i < arr.length; i++) {
int e = arr[i];
int j = i;
for (; j > 0; j--) {
if (e < arr[j - 1])
arr[j] = arr[j - 1];
else
break;
}
arr[j] = e;
}
}
private static void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
算法分析
插入排序在实现上,通常采用 in-place 排序(即只需用到 O(1) 的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
3. 冒泡排序(Bubble Sort)
通过从左到右不断交换相邻逆序的相邻元素,在一轮的交换之后,可以让未排序的元素上浮到右侧。
在一轮循环中,如果没有发生交换,就说明数组已经是有序的,此时可以直接退出。

代码实现
private static void sort(int[] arr) {
for (int i = arr.length - 1; i > 0; i--) { // 从最后一位开始确定
boolean swapped = false;
for (int j = 0; j < i; j++) {
if(arr[j] > arr[j+1]){
swapped = true;
swap(arr,j,j+1);
}
}
if(!swapped)
return;
}
}
private static void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
4. 希尔排序(Shell Sort)
1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
算法描述
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列个数k,对序列进行k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

代码实现
// 希尔排序
public static void sort(int[] arr) {
int n = arr.length;
for (int h = n / 2; h > 0; h = h / 2) {
// 内部是一个插入排序
for (int i = 0; i < n; i = i + h) {
int e = arr[i];
int j = i;
for (; j > 0; j = j - h) {
if (e < arr[j - h])
arr[j] = arr[j - h];
else
break;
}
arr[j] = e;
}
}
}
// 希尔排序2
public static void sort2(int[] arr) {
int n = arr.length;
// 计算 increment sequence: 1, 4, 13, 40, 121, 364, 1093...
int h = 1;
while (h < n / 3) h = 3 * h + 1;
System.out.println(h);
while (h >= 1) {
// h-sort the array
for (int i = h; i < n; i++) {
// 对 arr[i], arr[i-h], arr[i-2*h], arr[i-3*h]... 使用插入排序
int e = arr[i];
int j = i;
for (; j >= h && e < arr[j - h]; j -= h)
arr[j] = arr[j - h];
arr[j] = e;
}
h /= 3;
}
}
算法分析
对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素,每次只能将逆序数量减少 1。
希尔排序的出现就是为了改进插入排序的这种局限性,它通过交换不相邻的元素,每次可以将逆序数量减少大于 1。
希尔排序使用插入排序对间隔 h 的序列进行排序。通过不断减小 h,最后令 h=1,就可以使得整个数组是有序的。
5. 归并排序(Merge Sort)
归并排序的思想是将数组分成两部分,分别进行排序,然后归并起来。把长度为n的输入序列分成两个长度为n/2的子序列;对这两个子序列分别采用归并排序;将两个排序好的子序列合并成一个最终的排序序列。

代码实现
1.归并方法 归并方法将数组中两个已经排序的部分归并成一个。
private static void sort(int[] arr) {
__MergeSort(arr, 0, arr.length - 1);
}
private static void __MergeSort(int[] arr, int l, int r) {
if (l >= r)
return;
int mid = (l + r) / 2;
__MergeSort(arr, l, mid);
__MergeSort(arr, mid + 1, r);
merge(arr, l, mid, r);
}
// 将arr[l...mid]和arr[mid+1...r]两部分进行归并
private static void merge(int[] arr, int l, int mid, int r) {
int[] aux = Arrays.copyOfRange(arr, l, r + 1);
// 初始化,i指向左半部分的起始索引位置l;j指向右半部分起始索引位置mid+1
int i = l, j = mid + 1;
for (int k = l; k <= r; k++) {
if (i > mid) { // 如果左半部分元素已经全部处理完毕
arr[k] = aux[j - l];
j++;
} else if (j > r) { // 如果右半部分元素已经全部处理完毕
arr[k] = aux[i - l];
i++;
} else if (aux[i - l] < aux[j - l]) { // 左半部分所指元素 < 右半部分所指元素
arr[k] = aux[i - l];
i++;
} else { // 左半部分所指元素 >= 右半部分所指元素
arr[k] = aux[j - l];
j++;
}
}
}
2.自底向上归并排序
private static void sort(int[] arr) {
int N = arr.length;
int[] aux = new int[N];
for (int sz = 1; sz < N; sz += sz)
for (int i = 0; i + sz < N; i += sz + sz)
merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, N - 1));
}
6. 快速排序(Quick Sort)
快速排序可以说是20世纪最伟大的算法之一了。相信都有所耳闻,它的速度也正如它的名字那样,是一个非常快的算法了。当然它也后期经过了不断的改进和优化,才被公认为是一个值得信任的非常优秀的算法。

代码实现
1. 普通快速排序
// 递归使用快速排序,对arr[l...r]的范围进行排序
public static void QuickSort(int[] arr,int l,int r){
if(l>=r)
return;
int p = partition(arr,l,r);
QuickSort(arr,l,p-1);
QuickSort(arr,p+1,r);
}
// 将数组通过p分割成两部分
// 对arr[l...r]部分进行partition操作
// 返回p, 使得arr[l...p-1] < arr[p] ; arr[p+1...r] > arr[p]
public static int partition(int[] arr, int l, int r) {
swap(arr, l, (int) (Math.random() % (r - l + 1)) + l); // 加入这一行变成随机快速排序
int v = arr[l];
int j = l;
for(int i = j +1;i<=r;i++){
if(arr[i] < v){
j++;
swap(arr,i,j);
}
}
swap(arr,l,j);
return j;
}
public static void swap(int[] arr,int i,int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
快速排序是原地排序,不需要辅助数组,但是递归调用需要辅助栈。
快速排序最好的情况下是每次都正好能将数组对半分,这样递归调用次数才是最少的。这种情况下比较次数为 CN=2CN/2+N,复杂度为 O(NlogN)。
最坏的情况下,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般。因此最坏的情况下需要比较 N2/2。为了防止数组最开始就是有序的,在进行快速排序时需要随机打乱数组。
2. 双路快速排序
若果数组中含有大量重复的元素,则partition很可能把数组划分成两个及其不平衡的两部分,时间复杂度退化成O(n²)。这时候应该把小于v和大于v放在数组两端。

// 双路快速排序的partition
// 返回p, 使得arr[l...p-1] < arr[p] ; arr[p+1...r] > arr[p]
private static int partition(int[] arr, int l, int r) {
// 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
// swap(arr, l, (int) (Math.random() % (r - l + 1)) + l);
int v = arr[l];
// arr[l+1...i) <= v; arr(j...r] >= v
int i = l + 1, j = r;
while (true) {
// 注意这里的边界, arr[i].compareTo(v) < 0, 不能是arr[i].compareTo(v) <= 0
// 思考一下为什么?
while (i <= r && arr[i] < v)
i++;
// 注意这里的边界, arr[j].compareTo(v) > 0, 不能是arr[j].compareTo(v) >= 0
// 思考一下为什么?
while (j >= l + 1 && arr[j] > v)
j--;
// 对于上面的两个边界的设定, 有的同学在课程的问答区有很好的回答:)
// 大家可以参考: http://coding.imooc.com/learn/questiondetail/4920.html
if (i > j)
break;
swap(arr, i, j);
i++;
j--;
}
swap(arr, l, j);
return j;
}
// 递归使用快速排序,对arr[l...r]的范围进行排序
private static void QuickSort2Ways(int[] arr, int l, int r) {
// 对于小规模数组, 使用插入排序
if (l >= r) return;
int p = partition(arr, l, r);
QuickSort2Ways(arr, l, p - 1);
QuickSort2Ways(arr, p + 1, r);
}
3. 三路快速排序
数组分成三个部分,大于v 等于v 小于v
在具有大量重复键值对的情况下使用三路快排

// 递归使用快速排序,对arr[l...r]的范围进行排序
private static void QuickSort3Ways(int[] arr, int l, int r){
// 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
swap( arr, l, (int)(Math.random()*(r-l+1)) + l );
int v = arr[l];
int lt = l; // arr[l+1...lt] < v
int gt = r + 1; // arr[gt...r] > v
int i = l+1; // arr[lt+1...i) == v
while( i < gt ){
if( arr[i] < v){
swap( arr, i, lt+1);
i ++;
lt ++;
}
else if( arr[i] > v ){
swap( arr, i, gt-1);
gt --;
}
else{ // arr[i] == v
i ++;
}
}
swap( arr, l, lt );
QuickSort3Ways(arr, l, lt-1);
QuickSort3Ways(arr, gt, r);
}
7. 堆排序(Heap Sort)
1. 堆
堆的某个节点的值总是大于等于子节点的值,并且堆是一颗完全二叉树。
堆可以用数组来表示,因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系。

2. 上浮和下沉
在堆中,当一个节点比父节点大,那么需要交换这个两个节点。交换后还可能比它新的父节点大,因此需要不断地进行比较和交换操作,把这种操作称为上浮(ShiftUp)。
private void shiftUp(int k){
while( k > 1 && data[k/2] < data[k])){
swap(k, k/2);
k /= 2;
}
}
类似地,当一个节点比子节点来得小,也需要不断地向下进行比较和交换操作,把这种操作称为下沉(Shift Down)。一个节点如果有两个子节点,应当与两个子节点中最大那么节点进行交换。
private void shiftDown(int k){
while( 2*k <= count ){ // 当前结点有左孩子
int j = 2*k; // 在此轮循环中,data[k]和data[j]交换位置
if( j+1 <= count && data[j+1] > data[j] )
j ++;
// data[j] 是 data[2*k]和data[2*k+1]中的最大值
if( data[k] >= data[j] )
break;
swap(k, j);
k = j;
}
}
3.插入元素
将新元素放到数组末尾,然后上浮到合适的位置。
// 向最大堆中插入一个新的元素 item
public void insert(Item item){
assert count + 1 <= capacity;
data[count+1] = item;
count ++;
shiftUp(count);
}
4. 删除最大元素
// 从最大堆中取出堆顶元素, 即堆中所存储的最大数据
public Item extractMax(){
assert count > 0;
Item ret = data[1];
swap( 1 , count );
count --;
shiftDown(1);
return ret;
}
5. 堆排序
由于堆可以很容易得到最大的元素并删除它,不断地进行这种操作可以得到一个递减序列。如果把最大元素和当前堆中数组的最后一个元素交换位置,并且不删除它,那么就可以得到一个从尾到头的递减序列,从正向来看就是一个递增序列。因此很容易使用堆来进行排序。并且堆排序是原地排序,不占用额外空间。
// 不使用一个额外的最大堆, 直接在原数组上进行原地的堆排序
public class HeapSort {
// 对整个arr数组使用HeapSort1排序
// HeapSort1, 将所有的元素依次添加到堆中, 在将所有元素从堆中依次取出来, 即完成了排序
// 无论是创建堆的过程, 还是从堆中依次取出元素的过程, 时间复杂度均为O(nlogn)
// 整个堆排序的整体时间复杂度为O(nlogn)
public static void sort1(Comparable[] arr){
int n = arr.length;
MaxHeap<Comparable> maxHeap = new MaxHeap<Comparable>(n);
for( int i = 0 ; i < n ; i ++ )
maxHeap.insert(arr[i]);
for( int i = n-1 ; i >= 0 ; i -- )
arr[i] = maxHeap.extractMax();
}
// 只通过shiftDown操作进行排序
public static void sort2(Comparable[] arr){
int n = arr.length;
// 注意,此时我们的堆是从0开始索引的
// 从(最后一个元素的索引-1)/2开始
// 最后一个元素的索引 = n-1
for( int i = (n-1-1)/2 ; i >= 0 ; i -- )
shiftDown2(arr, n, i);
for( int i = n-1; i > 0 ; i-- ){ // 这个的目的是让序列从小到大排序
swap( arr, 0, i);
shiftDown2(arr, i, 0);
}
}
// 交换堆中索引为i和j的两个元素
private static void swap(Object[] arr, int i, int j){
Object t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
// 原始的shiftDown过程
private static void shiftDown(Comparable[] arr, int n, int k){
while( 2*k+1 < n ){
int j = 2*k+1;
if( j+1 < n && arr[j+1].compareTo(arr[j]) > 0 )
j += 1;
if( arr[k].compareTo(arr[j]) >= 0 )break;
swap( arr, k, j);
k = j;
}
}
// 优化的shiftDown过程, 使用赋值的方式取代不断的swap,
// 该优化思想和我们之前对插入排序进行优化的思路是一致的
private static void shiftDown2(Comparable[] arr, int n, int k){
Comparable e = arr[k];
while( 2*k+1 < n ){
int j = 2*k+1;
if( j+1 < n && arr[j+1].compareTo(arr[j]) > 0 )
j += 1;
if( e.compareTo(arr[j]) >= 0 )
break;
arr[k] = arr[j];
k = j;
}
arr[k] = e;
}
// 测试 HeapSort
public static void main(String[] args) {
Integer[] arr = {10, 91, 8, 7, 6, 5, 4, 3, 2, 1};
HeapSort.sort2(arr);
PrintHelper.printArray(arr);
}
}
6. 堆排序的应用——Top K问题
例如,有1亿个浮点数,如何找出其中最大的10000个?(B326)
8. 计数排序
https://www.cnblogs.com/freedom314/p/5847092.html
9. 排序算法总结
| 平均时间复杂度 | 原地排序 | 额外空间 | 稳定排序 | |
|---|---|---|---|---|
| 插入排序 | O(n^2) | √ | O(1) | √ |
| 归并排序 | O(nlogn) | × | O(n) | √ |
| 快速排序 | O(nlogn) | √ | O(logn) | × |
| 堆排序 | O(nlogn) | √ | O(1) | × |
稳定排序:对于相等的元素,在排序后,原来靠前的元素依然靠前。相等元素的相对位置没有发生变化。
// 可以通过⾃自定义⽐比较函数,让排序算法不不存在稳定性的问题。
bool operator<(const Student& otherStudent){
return score != otherStudent.score ?
score > otherStudent.score :
name < otherStudent.name;
}
二、递归和回溯法
1. 例题
2. 排列问题
3. 组合问题
4. 回溯法的剪枝
5. 二维平面回溯法
6. floodfill算法
三、动态规划
递归和动态规划都是将原问题拆成多个子问题然后求解,他们之间最本质的区别是,动态规划保存了子问题的解,避免重复计算。
动态规划一般可分为4类:
- 线性动规
- 区域动规
- 树形动规
- 背包动规
以198. House Robber为例,动态规划的状态定义和状态转移方程如下:
注意其中对状态的定义:
- 考虑偷取 [x…n-1] 范围⾥里里的房子 (函数的定义)
根据对状态的定义,决定状态的转移:
- f(0) = max{ v(0) + f(2) , v(1) + f(3) , v(2) + f(4) , … ,v(n-3) + f(n-1) , v(n-2),v(n-1) }
(状态转移方程)

1. 斐波那契数列
1.1 递归方式(自顶向下)
public int fib( int n ){
if( n == 0 )
return 0;
if( n == 1 )
return 1;
return fib(n-1) + fib(n-2);
}
//控制台输出
fib(42) = 267914296
time : 1949 ms
run function fib() 866988873 times.
1.2 记忆化搜索(自底向上)
public int fib(int n){
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
return fib(n, memo);
}
private int fib(int n, int[] memo){
if(n == 0)
return 0;
if(n == 1)
return 1;
if(memo[n] == -1)
memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
return memo[n];
}
//控制台输出
fib(1000) = 1556111435
time : 1 ms
run function fib() 1999 times.
1.3 动态规划
public int fib(int n){
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
memo[0] = 0;
memo[1] = 1;
for(int i = 2 ; i <= n ; i ++)
memo[i] = memo[i - 1] + memo[i - 2];
return memo[n];
}
2. 背包问题
先得到该问题的局部解然后扩展到全局问题解。
我们可以假设一个B(k,C) 方法,第k件物品,当前背包所剩下的容量C(初始则C=W)情况下,能够偷的最大价值量。
B( i , c ) = max{ F( i - 1 , C ) , v(i) + F( i - 1, C - w[i] ) };
(1)记忆化搜索
/**
* 记忆化搜索
* 时间复杂度: O(n * C) 其中n为物品个数; C为背包容积
* 空间复杂度: O(n * C)
*/
public class Solution01 {
private static int count = 0;
private static int[][] memo;
public int knapsack(int[] w, int[] v, int C) {
int n = w.length;
memo = new int[n][C + 1];
for(int i = 0;i<n;i++)
Arrays.fill(memo[i],-1);
return bestValue(w, v, n - 1, C);
}
// 用 [0...index]的物品,填充容积为c的背包的最大价值
private int bestValue(int[] w, int[] v, int i, int C) {
count++;
if (i < 0 || C <= 0)
return 0;
if (memo[i][C] != -1) // 记忆化搜索
return memo[i][C];
int res = 0;
res = bestValue(w, v, i - 1, C);
if (C >= w[i])
res = max(res, v[i] + bestValue(w, v, i - 1, C - w[i]));
return memo[i][C] = res;
}
private int max(int a, int b) {
return a > b ? a : b;
}
public static void main(String[] args) {
int[] w = {5,4,6,3};
int[] v = {10,40,30,50};
System.out.println(new Solution01().knapsack(w, v, 10));
System.out.println("count of bestValue() exec:" + count);
PrintHelper.print2DArray(memo);
}
}
(2)动态规划

/**
* 动态规划
* 时间复杂度: O(n * C) 其中n为物品个数; C为背包容积
* 空间复杂度: O(n * C)
*/
public class Solution02 {
public int knapsack(int[] w, int[] v, int C) {
int n = w.length;
int[][] memo = new int[n][C + 1];
if (n == 0 || C == 0)
return 0;
for (int j = 0; j <= C; j++)
memo[0][j] = (j >= w[0] ? v[0] : 0);
for (int i = 1; i < n; i++) {
for (int j = 0; j <= C; j++) {
memo[i][j] = memo[i - 1][j];
if (j >= w[i]) {
memo[i][j] = max(memo[i][j], v[i] + memo[i - 1][j - w[i]]);
}
}
}
return memo[n - 1][C];
}
private int max(int a, int b) {
return a > b ? a : b;
}
public static void main(String[] args) {
int[] w = {1, 2, 3};
int[] v = {6, 10, 12};
int C = 5;
System.out.println(new Solution02().knapsack(w, v, C));
}
}
(3)动态规划优化思路1
优化思路:第i行元素只依赖于第i-1行元素,理论上,只需要保持两行元素即可

/// 动态规划改进: 滚动数组
/// 时间复杂度: O(n * C) 其中n为物品个数; C为背包容积
/// 空间复杂度: O(C), 实际使用了2*C的额外空间
public class Solution1 {
public int knapsack01(int[] w, int[] v, int C){
if(w == null || v == null || w.length != v.length)
throw new IllegalArgumentException("Invalid w or v");
if(C < 0)
throw new IllegalArgumentException("C must be greater or equal to zero.");
int n = w.length;
if(n == 0 || C == 0)
return 0;
int[][] memo = new int[2][C + 1];
for(int j = 0 ; j <= C ; j ++)
memo[0][j] = (j >= w[0] ? v[0] : 0);
for(int i = 1 ; i < n ; i ++)
for(int j = 0 ; j <= C ; j ++){
memo[i % 2][j] = memo[(i-1) % 2][j];
if(j >= w[i])
memo[i % 2][j] = Math.max(memo[i % 2][j], v[i] + memo[(i-1) % 2][j - w[i]]);
}
return memo[(n-1) % 2][C];
}
}
(4)动态规划优化思路2

/// 动态规划改进
/// 时间复杂度: O(n * C) 其中n为物品个数; C为背包容积
/// 空间复杂度: O(C), 只使用了C的额外空间
public class Solution2 {
public int knapsack01(int[] w, int[] v, int C){
if(w == null || v == null || w.length != v.length)
throw new IllegalArgumentException("Invalid w or v");
if(C < 0)
throw new IllegalArgumentException("C must be greater or equal to zero.");
int n = w.length;
if(n == 0 || C == 0)
return 0;
int[] memo = new int[C+1];
for(int j = 0 ; j <= C ; j ++)
memo[j] = (j >= w[0] ? v[0] : 0);
for(int i = 1 ; i < n ; i ++)
for(int j = C ; j >= w[i] ; j --)
memo[j] = Math.max(memo[j], v[i] + memo[j - w[i]]);
return memo[C];
}
}
(5)背包问题更多变种
- 多重背包问题:每个物品不不⽌止1个,有num(i)个
- 完全背包问题:每个物品可以⽆无限使⽤用
- 多维费⽤用背包问题:要考虑物品的体积和重量量两个维度?
- 物品间加⼊入更更多约束:物品间可以互相排斥;也可以互相依赖
3. 最长上升子序列
Longest Increasing Subsequence (LIS)
【Leetcode 300】最长上升子序列
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
- 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
- 你算法的时间复杂度应该为 O(n2) 。
进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?
LIS( i ) 表示以第 i 个数字为结尾的最长上升子序列的长度
LIS( i ) 表示 [0...i] 的范围内,选择数字nums[i]可以获得的最长上升子序列的长度
LIS ( i ) = maxj( 1 + LIS( j ) if nums[i] > nums[j] )
public class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
if (n==0) {
return 0;
}
int res = 1;
int[] memo = new int[n];
Arrays.fill(memo, 1);
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i])
memo[i] = max(memo[i] , memo[j]+1);
}
}
for(int i = 0;i<n;i++){
res = max(memo[i],res);
}
return res;
}
private int max(int a, int b) {
return a > b ? a : b;
}
public static void main(String[] args) {
int[] arr = {10, 9, 2, 5, 3, 7, 101, 18};
System.out.println(new Solution().lengthOfLIS(arr));
}
}
这里思考一个问题:在上面的代码中只求解出了上升子序列的长度,那么如何求出具体的上升子序列呢?
public class Solution2 {
private static List<Integer> LISindex = new ArrayList<>(); // 记录一下有几个上升子序列
public List<List<Integer>> lengthOfLIS(int[] nums) {
List<List<Integer>> resList = new ArrayList<>();
int n = nums.length;
if (n == 0) {
return null;
}
int res = 1;
int[] memo = new int[n];
Arrays.fill(memo, 1);
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i])
memo[i] = max(memo[i], memo[j] + 1);
}
}
for (int i = 0; i < n; i++) {
res = max(memo[i], res);
}
for (int i = 0; i < n; i++) {
if (memo[i] == res)
LISindex.add(i); // 遍历一下最长子序列最后一位是谁,统计一共有多少个子序列
}
for (int lastIndex : LISindex) {
ArrayList<Integer> list = new ArrayList<>();
int nowMemoCount = memo[lastIndex];
for (int i = lastIndex; i >= 0; i--) {
if (nowMemoCount - memo[i] == 1 || nowMemoCount - memo[i] == 0) {
list.add(nums[i]);
nowMemoCount--;
}
}
resList.add(reverseList(list));
}
return resList;
}
private int max(int a, int b) {
return a > b ? a : b;
}
private List<Integer> reverseList(ArrayList<Integer> list) {
List<Integer> newList = new ArrayList<>();
for (int i = list.size() - 1; i >= 0; i--) {
newList.add(list.get(i));
}
return newList;
}
public static void main(String[] args) {
int[] arr = {10, 9, 2, 5, 3, 7, 101, 18};
System.out.println(new Solution2().lengthOfLIS(arr));
}
}
4. 最长公共子序列
Longest Common Sequence (LCS):给出两个字符串S1和S2,求这两个字符串的最长公共子序列的长度
LCS( m , n ) S1[0…m] 和 S2[0…n] 的最长公共子序列的长度
S1[m] == S2[n] :
LCS(m,n) = 1 + LCS(m-1,n-1)
S1[m] != S2[n] :
LCS(m,n) = max( LCS(m-1,n) , LCS(m,n-1) )

/**
* 最长公共子序列
*/
public class Solution3 {
public int LCS(String s1, String s2) {
return bestLength(s1, s2, s1.length() - 1, s2.length() - 1);
}
public int bestLength(String s1, String s2, int m, int n) {
if (m < 0 || n < 0)
return 0;
int lcs = 0;
if (s1.charAt(m) == s2.charAt(n)) {
lcs = 1 + bestLength(s1, s2, m - 1, n - 1);
} else {
lcs = max(bestLength(s1, s2, m - 1, n), bestLength(s1, s2, m, n - 1));
}
return lcs;
}
private int max(int a, int b) {
return a > b ? a : b;
}
public static void main(String[] args) {
System.out.println(new Solution3().LCS("ABCDEE", "ABDCEE"));
}
}
参考资料:
四、贪心算法
1. assign-cookies
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i ,都有一个胃口值 gi ,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j ,都有一个尺寸 sj 。如果 sj >= gi ,我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
注意:
你可以假设胃口值为正。 一个小朋友最多只能拥有一块饼干。
示例 1:
输入: [1,2,3], [1,1]
输出: 1
解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。
示例 2:
输入: [1,2], [1,2,3]
输出: 2
解释:
你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2。
你拥有的饼干数量和尺寸都足以让所有孩子满足。
所以你应该输出2.
public class Solution {
public int findContentChildren(int[] g, int[] s) {
int res = 0;
Arrays.sort(g);
Arrays.sort(s);
for (int i = g.length - 1; i >= 0; i--) {
for (int j = s.length - 1; j >= 0; j--) {
if(s[j] >= g[i]){
res ++;
s[j] = 0;
break;
}
}
}
return res;
}
public static void main(String[] args) {
int res = new Solution().findContentChildren(new int[]{1,2,3}, new int[]{3});
System.out.println(res);
}
}
第三部分:面试指南
1. 判单链表是否对称
网易有道面经
原题:设单链表中存放着 n 个字符,设计算法并判断字符是否中心对称,如 "xyzzyx" 是中心对称的。
思路1:可先将字符串中的全部字符进栈,然后再将栈中的字符逐个与链表中的字符进行比较,如全部相等,则为中心对称。
思路2:将单链表的前一半元素依次入栈,遍历到单链表的后一半元素的第一个元素时,便从栈中弹出一个元素,对它们俩开始比较。
- 若相等,则将链表中的下一个元素与栈中再次弹出的元素进行比较,直至单链表到末尾,而且如果此时栈也为空栈,则可得出此单链表是中心对称的结论;
- 若不相等,则单链表不是中心对称。
2. 合并两个有序数组成一个有序数组
金山云面经
有两种实现思路:
- 定义一个新数组,长度为两个数组长度之和,将两个数组都 copy 到新数组,然后排序。
- 给两个数组分别定义一个下标,最大长度是数组长度减一,按位循环比较两个数组,较小元素的放入新数组,下标加一(注意,较大元素对应的下标不加一),直到某一个下标超过数组长度时退出循环,此时较短数组已经全部放入新数组,较长数组还有部分剩余,最后将剩下的部分元素放入新数组,大功告成。
3. 求二叉树中值为x的结点的层号
金山云面经
思路:利用访问二叉树的路径过程求解(PS:这里不是说遍历过程,是访问过程,无论前中后序遍历,访问过程都是要经过通一结点三次),利用level变量记录访问过程中的层号,递归返回要减一;
#include <stdio.h>
#include <stdlib.h>
typedef char ElementType;
typedef struct TNode *Position;
typedef Position BinTree;
struct TNode{
ElementType Data;
BinTree Left;
BinTree Right;
};
BinTree CreatBinTree(); /* 实现细节忽略 */
void level_in_x(BinTree BT,char x,int level);
int main()
{
int level = 1;
BinTree BT = CreatBinTree();
level_in_x(BT,'H',level);
return 0;
}
//静态建树
BinTree CreatBinTree()
{
BinTree pa = (BinTree)malloc(sizeof(struct TNode));
BinTree pb = (BinTree)malloc(sizeof(struct TNode));
BinTree pc = (BinTree)malloc(sizeof(struct TNode));
BinTree pd = (BinTree)malloc(sizeof(struct TNode));
BinTree pe = (BinTree)malloc(sizeof(struct TNode));
BinTree pf = (BinTree)malloc(sizeof(struct TNode));
BinTree pg = (BinTree)malloc(sizeof(struct TNode));
BinTree ph = (BinTree)malloc(sizeof(struct TNode));
BinTree pi = (BinTree)malloc(sizeof(struct TNode));
pa->Data = 'A';
pb->Data = 'B';
pc->Data = 'C';
pd->Data = 'D';
pe->Data = 'E';
pf->Data = 'F';
pg->Data = 'G';
ph->Data = 'H';
pi->Data = 'I';
pa->Left = pb; pa->Right = pc;
pb->Left = pd; pb->Right = pf;
pc->Left = pg; pc->Right = pi;
pd->Left = NULL; pd->Right = NULL;
pe->Left = NULL; pe->Right = NULL;
pf->Left = pe; pf->Right = NULL;
pg->Left = NULL; pg->Right = ph;
ph->Left = NULL; ph->Right = NULL;
pi->Left = NULL; pi->Right = NULL;
return pa;
}
void level_in_x(BinTree BT,char x,int level)
{
if (BT == NULL){
return ;
}
if(BT->Data == x){
printf("x in %d",level);
}
level++;
level_in_x(BT->Left,x,level);
level_in_x(BT->Right,x,level);
level--;
}
阿里面经OneNote
- 如何判断一个单链表是否有环?
- 快速排序,过程,复杂度?什么情况下适用,什么情况下不适用?
- 什么是二叉平衡树,如何插入节点,删除节点
- 二分搜索的过程
- 归并排序的过程?时间复杂度?空间复杂度?
- 给你一万个数,如何找出里面所有重复的数?用所有你能想到的方法,时间复杂度和空间复杂度分别是多少
- 给你一个数组,如何里面找到和为K的两个数
- 100000个数找出最小或最大的10个?
- 一堆数字里面继续去重,要怎么处理?阅读 RFC2616 文档,即 HTTP/1.1 规范,输入某个网址,利用 Java 的 Socket 发送 HTTP请求,特别要求能够解码 chunked 编码,观察文档中的伪代码实现,自己用Java代码实现,将解析后的整个html文档输出到控制台上,不要求关注太多细节。(就是不允许用httpclient的jar包,自行实现这个jar包类似的功能)
第四部分:参考资料
- 数据结构与算法系列 目录 - 如果天空不死 - 博客园
- 十大经典排序算法
- VisuAlgo - visualising data structures and algorithms through animation
- Data Structure Visualization
- 海量数据处理:十道面试题与十个海量数据处理方法总结 - chenhuan001 - 博客园
海量数据处理
前言
这里主要针对案例进行分析讲解,欢迎大家在 issue 或是直接 contribution 更多相关的海量数据相关难题。这里我将持续性的更新一些面试中常见的海量数据案例。
这里我将会持续更新,时间有限不能全面总结,欢迎大家一起完善。
海量数据问题处理方法
- Hash
- Bit-Map
- 布隆过滤器 (Bloom Filter)
- 堆 (Heap)
- 双层桶划分
- 数据库索引
- 倒排索引 (Inverted Index)
- B+树
- Trie树
- MapReduce
海量数据案例
1. 两个大文件中找出共同记录
题目描述
给定 a、b 两个文件,各存放 50 亿个 url,每个 url 各占 64 字节,内存限制是 4G,让你找出 a、b 文件共同的 url?
解题思路
方案 1
首先我们最常想到的方法是读取文件 a,建立哈希表方便后面查找,然后再读取文件 b,遍历文件 b 中每个 url,对于每个遍历,我们都执行查找 hash 表的操作,若 hash 表中搜索到了,则说明两文件共有,存入一个集合。
可以估计每个文件安的大小为 5G×64 =320G,远远大于内存限制的 4G。所以不可能将其完全加载到内存中处理。
针对上述问题,我们分治算法的思想。
- 遍历文件 a,对每个 url 求取
hash(url)%1000,然后根据所取得的值将 url 分别存储到 1000 个小文件(记为 a0,a1,...,a999 每个小文件约 300M),为什么是 1000?主要根据内存大小和要分治的文件大小来计算,我们就大致可以把 320G 大小分为 1000 份,每份大约 300M(当然,到底能不能分布尽量均匀,得看 hash 函数的设计)
- 遍历文件 b,采取和 a 相同的方式将 url 分别存储到 1000 个小文件(记为 b0,b1,...,b999 )
为什么要这样做?文件 a 的 hash 映射和文件 b 的 hash 映射函数要保持一致,这样的话相同的 url 就会保存在对应的小文件中,比如,如果 a 中有一个 url 记录 data1 被 hash 到了 a99 文件中,那么如果 b 中也有相同 url,则一定被 hash 到了 b99 中。
所以现在问题转换成了:找出 1000 对小文件中每一对相同的 url(不对应的小文件不可能有相同的 url)
- 求每对小文件中相同的 url 时,可以把其中一个小文件的 url 存储到 hash_set 中。然后遍历另一个小文件的每个 url,看其是否在刚才构建的 hash_set 中,如果是,那么就是共同的 url,存到文件里面就可以了。
方案2
如果允许有一定的错误率,可以使用 Bloom filter,4G 内存大概可以表示 340 亿 bit。将其中一个文件中的 url 使用 Bloom filter 映射为这 340 亿 bit,然后挨个读取另外一个文件的 url,检查是否与 Bloom filter,如果是,那么该 ur l应该是共同的 url(注意会有一定的错误率)。
2. 大文件排序和去重
如果有一个500G的超大文件,里面都是数值,如何对这些数值排序? - CSDN博客 https://blog.csdn.net/u011381576/article/details/79385133
大文件的排序和去重 超级简单的实现 - zero_learner - 博客园 https://www.cnblogs.com/yangxudong/p/3848453.html
参考资料
Java基础
前言
本文主要包含 Java 核心基础知识,主要根据以下部分进行节选,选择了个人认为在面试中最为核心的部分。
- 《Java程序员面试笔试宝典》何昊,薛鹏,叶向阳 著
- 《阿里面经OneNote》
主要内容:基本概念、面向对象、关键字、基本数据类型与运算、字符串与数组、异常处理、Object 通用方法
一、基本概念
1. Java程序初始化的顺序是怎么样的
在 Java 语言中,当实例化对象时,对象所在类的所有成员变量首先要进行初始化,只有当所有类成员完成初始化后,才会调用对象所在类的构造函数创建象。
初始化一般遵循3个原则:
- 静态对象(变量)优先于非静态对象(变量)初始化,静态对象(变量)只初始化一次,而非静态对象(变量)可能会初始化多次;
- 父类优先于子类进行初始化;
- 按照成员变量的定义顺序进行初始化。 即使变量定义散布于方法定义之中,它们依然在任何方法(包括构造函数)被调用之前先初始化;
加载顺序
- 父类(静态变量、静态语句块)
- 子类(静态变量、静态语句块)
- 父类(实例变量、普通语句块)
- 父类(构造函数)
- 子类(实例变量、普通语句块)
- 子类(构造函数)
实例
class Base {
// 1.父类静态代码块
static {
System.out.println("Base static block!");
}
// 3.父类非静态代码块
{
System.out.println("Base block");
}
// 4.父类构造器
public Base() {
System.out.println("Base constructor!");
}
}
public class Derived extends Base {
// 2.子类静态代码块
static{
System.out.println("Derived static block!");
}
// 5.子类非静态代码块
{
System.out.println("Derived block!");
}
// 6.子类构造器
public Derived() {
System.out.println("Derived constructor!");
}
public static void main(String[] args) {
new Derived();
}
}
结果是:
Base static block!
Derived static block!
Base block
Base constructor!
Derived block!
Derived constructor!
2. Java和C++的区别
- Java 是纯粹的面向对象语言,所有的对象都继承自 java.lang.Object,C++ 为了兼容 C 即支持面向对象也支持面向过程。
- Java 通过虚拟机从而实现跨平台特性,但是 C++ 依赖于特定的平台。
- Java 没有指针,它的引用可以理解为安全指针,而 C++ 具有和 C 一样的指针。
- Java 支持自动垃圾回收,而 C++ 需要手动回收。(C++11 中引入智能指针,使用引用计数法垃圾回收)
- Java 不支持多重继承,只能通过实现多个接口来达到相同目的,而 C++ 支持多重继承。
- Java 不支持操作符重载,虽然可以对两个 String 对象支持加法运算,但是这是语言内置支持的操作,不属于操作符重载,而 C++ 可以。
- Java 内置了线程的支持,而 C++ 需要依靠第三方库。
- Java 的 goto 是保留字,但是不可用,C++ 可以使用 goto。
- Java 不支持条件编译,C++ 通过 #ifdef #ifndef 等预处理命令从而实现条件编译。
参考资料:
3. 反射
先看一个知乎回答
首先看一个在知乎上的优秀回答吧:
反射是什么呢?当我们的程序在运行时,需要动态的加载一些类这些类可能之前用不到所以不用加载到 JVM,而是在运行时根据需要才加载,这样的好处对于服务器来说不言而喻。
举个例子我们的项目底层有时是用 mysql,有时用 oracle,需要动态地根据实际情况加载驱动类,这个时候反射就有用了,假设 com.java.dbtest.myqlConnection,com.java.dbtest.oracleConnection 这两个类我们要用,这时候我们的程序就写得比较动态化,通过 Class tc = Class.forName("com.java.dbtest.TestConnection"); 通过类的全类名让 JVM 在服务器中找到并加载这个类,而如果是 Oracle 则传入的参数就变成另一个了。这时候就可以看到反射的好处了,这个动态性就体现出 Java 的特性了!
举多个例子,大家如果接触过 spring,会发现当你配置各种各样的 bean 时,是以配置文件的形式配置的,你需要用到哪些 bean 就配哪些,spring 容器就会根据你的需求去动态加载,你的程序就能健壮地运行。
什么是反射
反射 (Reflection) 是 Java 程序开发语言的特征之一,它允许运行中的 Java 程序获取自身的信息,并且可以操作类或对象的内部属性。通过 Class 获取 class 信息称之为反射(Reflection)
简而言之,通过反射,我们可以在运行时获得程序或程序集中每一个类型的成员和成员的信息。
程序中一般的对象的类型都是在编译期就确定下来的,而 Java 反射机制可以动态地创建对象并调用其属性,这样的对象的类型在编译期是未知的。所以我们可以通过反射机制直接创建对象,即使这个对象的类型在编译期是未知的。
反射的核心是 JVM 在运行时才动态加载类或调用方法/访问属性,它不需要事先(写代码的时候或编译期)知道运行对象是谁。
Java 反射框架主要提供以下功能:
1. 在运行时判断任意一个对象所属的类
2. 在运行时构造任意一个类的对象
3. 在运行时判断任意一个类所具有的成员变量和方法(通过反射甚至可以调用 private 方法)
4. 在运行时调用任意一个对象的方法
重点:是运行时而不是编译时
主要用途
很多人都认为反射在实际的 Java 开发应用中并不广泛,其实不然。
当我们在使用 IDE (如Eclipse,IDEA)时,当我们输入一个对象或类并想调用它的属性或方法时,一按点号,编译器就会自动列出它的属性或方法,这里就会用到反射。
反射最重要的用途就是开发各种通用框架
很多框架(比如 Spring )都是配置化的(比如通过 XML 文件配置 JavaBean,Action 之类的),为了保证框架的通用性,它们可能需要根据配置文件加载不同的对象或类,调用不同的方法,这个时候就必须用到反射——运行时动态加载需要加载的对象。
对与框架开发人员来说,反射虽小但作用非常大,它是各种容器实现的核心。而对于一般的开发者来说,不深入框架开发则用反射用的就会少一点,不过了解一下框架的底层机制有助于丰富自己的编程思想,也是很有益的。
获得Class对象
- 调用运行时类本身的
.class属性
Class clazz1 = Person.class;
System.out.println(clazz1.getName());
- 通过运行时类的对象获取
getClass();
Person p = new Person();
Class clazz3 = p.getClass();
System.out.println(clazz3.getName());
- 使用 Class 类的
forName静态方法
public static Class<?> forName(String className)
// 在JDBC开发中常用此方法加载数据库驱动:
Class.forName(driver);
- (了解)通过类的加载器 ClassLoader
ClassLoader classLoader = this.getClass().getClassLoader();
Class clazz5 = classLoader.loadClass(className);
System.out.println(clazz5.getName());
参考资料:
4. 注解
什么是注解
Annontation 是 Java5 开始引入的新特征,中文名称叫注解。它提供了一种安全的类似注释的机制,用来将任何的信息或元数据(metadata)与程序元素(类、方法、成员变量等)进行关联。为程序的元素(类、方法、成员变量)加上更直观更明了的说明,这些说明信息是与程序的业务逻辑无关,并且供指定的工具或框架使用。Annontation 像一种修饰符一样,应用于包、类型、构造方法、方法、成员变量、参数及本地变量的声明语句中。
Java 注解是附加在代码中的一些元信息,用于一些工具在编译、运行时进行解析和使用,起到说明、配置的功能。注解不会也不能影响代码的实际逻辑,仅仅起到辅助性的作用。包含在 java.lang.annotation 包中。
简单来说:注解其实就是代码中的特殊标记,这些标记可以在编译、类加载、运行时被读取,并执行相对应的处理。
为什么要用注解
传统的方式,我们是通过配置文件 .xml 来告诉类是如何运行的。
有了注解技术以后,我们就可以通过注解告诉类如何运行
例如:我们以前编写 Servlet 的时候,需要在 web.xml 文件配置具体的信息。我们使用了注解以后,可以直接在 Servlet 源代码上,增加注解...Servlet 就被配置到 Tomcat 上了。也就是说,注解可以给类、方法上注入信息。
明显地可以看出,这样是非常直观的,并且 Servlet 规范是推崇这种配置方式的。
基本Annotation
在 java.lang 包下存在着5个基本的 Annotation,重点掌握前三个。
- @Override 重写注解
- 如果我们使用IDE重写父类的方法,我们就可以看见它了。
- @Override是告诉编译器要检查该方法是实现父类的,可以帮我们避免一些低级的错误。
- 比如,我们在实现 equals() 方法的时候,把 euqals() 打错了,那么编译器就会发现该方法并不是实现父类的,与注解 @Override 冲突,于是就会给予错误。
- @Deprecated 过时注解
- 该注解也非常常见,Java 在设计的时候,可能觉得某些方法设计得不好,为了兼容以前的程序,是不能直接把它抛弃的,于是就设置它为过时。
- Date对象中的 toLocalString() 就被设置成过时了
- 当我们在程序中调用它的时候,在 IDE 上会出现一条横杠,说明该方法是过时的。
@Deprecated
public String toLocaleString() {
DateFormat formatter = DateFormat.getDateTimeInstance();
return formatter.format(this);
}
- @SuppressWarnings 抑制编译器警告注解
- 该注解在我们写程序的时候并不是很常见,我们可以用它来让编译器不给予我们警告
- 当我们在使用集合的时候,如果没有指定泛型,那么会提示安全检查的警告
- 如果我们在类上添加了@SuppressWarnings这个注解,那么编译器就不会给予我们警告了
- @SafeVarargs Java 7“堆污染”警告
- 什么是堆污染呢??当把一个不是泛型的集合赋值给一个带泛型的集合的时候,这种情况就很容易发生堆污染。
- 这个注解也是用来抑制编译器警告的注解,用的地方并不多。
- @FunctionalInterface 用来指定该接口是函数式接口
- 用该注解显式指定该接口是一个函数式接口。
自定义注解类编写规则
- Annotation 型定义为 @interface, 所有的 Annotation 会自动继承 java.lang.Annotation 这一接口,并且不能再去继承别的类或是接口.
- 参数成员只能用 public 或默认(default)这两个访问权修饰
- 参数成员只能用基本类型 byte,short,char,int,long,float,double,boolean 八种基本数据类型和 String、Enum、Class、annotations 等数据类型,以及这一些类型的数组
- 要获取类方法和字段的注解信息,必须通过 Java 的反射技术来获取 Annotation 对象,因为你除此之外没有别的获取注解对象的方法
- 注解也可以没有定义成员, 不过这样注解就没啥用了
PS:自定义注解需要使用到元注解
自定义注解实例
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 水果名称注解
*/
@Target(FIELD)
@Retention(RUNTIME)
@Documented
public @interface FruitName {
String value() default "";
}
5. 泛型
通俗解释
通俗的讲,泛型就是操作类型的 占位符,即:假设占位符为 T,那么此次声明的数据结构操作的数据类型为T类型。
假定我们有这样一个需求:写一个排序方法,能够对整型数组、字符串数组甚至其他任何类型的数组进行排序,该如何实现?答案是可以使用 Java 泛型。
使用 Java 泛型的概念,我们可以写一个泛型方法来对一个对象数组排序。然后,调用该泛型方法来对整型数组、浮点数数组、字符串数组等进行排序。
泛型方法
你可以写一个泛型方法,该方法在调用时可以接收不同类型的参数。根据传递给泛型方法的参数类型,编译器适当地处理每一个方法调用。
下面是定义泛型方法的规则:
- 所有泛型方法声明都有一个类型参数声明部分(由尖括号分隔),该类型参数声明部分在方法返回类型之前(在下面例子中的 \
)。 - 每一个类型参数声明部分包含一个或多个类型参数,参数间用逗号隔开。一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。
- 类型参数能被用来声明返回值类型,并且能作为泛型方法得到的实际参数类型的占位符。
- 泛型方法体的声明和其他方法一样。注意类型参数 只能代表引用型类型,不能是原始类型 (像 int,double,char 的等)。
public class GenericMethodTest
{
// 泛型方法 printArray
public static < E > void printArray( E[] inputArray )
{
// 输出数组元素
for ( E element : inputArray ){
System.out.printf( "%s ", element );
}
System.out.println();
}
public static void main( String args[] )
{
// 创建不同类型数组: Integer, Double 和 Character
Integer[] intArray = { 1, 2, 3, 4, 5 };
Double[] doubleArray = { 1.1, 2.2, 3.3, 4.4 };
Character[] charArray = { 'H', 'E', 'L', 'L', 'O' };
System.out.println( "整型数组元素为:" );
printArray( intArray ); // 传递一个整型数组
System.out.println( "\n双精度型数组元素为:" );
printArray( doubleArray ); // 传递一个双精度型数组
System.out.println( "\n字符型数组元素为:" );
printArray( charArray ); // 传递一个字符型数组
}
}
泛型类
泛型类的声明和非泛型类的声明类似,除了在类名后面添加了类型参数声明部分。
和泛型方法一样,泛型类的类型参数声明部分也包含一个或多个类型参数,参数间用逗号隔开。一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。因为他们接受一个或多个参数,这些类被称为参数化的类或参数化的类型。
public class Box<T> {
private T t;
public void add(T t) {
this.t = t;
}
public T get() {
return t;
}
public static void main(String[] args) {
Box<Integer> integerBox = new Box<Integer>();
Box<String> stringBox = new Box<String>();
integerBox.add(new Integer(10));
stringBox.add(new String("菜鸟教程"));
System.out.printf("整型值为 :%d\n\n", integerBox.get());
System.out.printf("字符串为 :%s\n", stringBox.get());
}
}
类型通配符
- 类型通配符一般是使用
?代替具体的类型参数。例如List<?>在逻辑上是List<String>,List<Integer>等所有 List<具体类型实参> 的父类。
- 类型通配符上限通过形如 List 来定义,如此定义就是通配符泛型值接受 Number 及其下层子类类型。
- 类型通配符下限通过形如 List super Number> 来定义,表示类型只能接受 Number 及其三层父类类型,如 Objec 类型的实例。
参考资料:
6. 字节与字符的区别
理解编码的关键,是要把字符的概念和字节的概念理解准确。这两个概念容易混淆,我们在此做一下区分:
| 类型 | 概念描述 | 举例 |
|---|---|---|
| 字符 | 人们使用的记号,抽象意义上的一个符号。 | '1', '中', 'a', '$', '¥', …… |
| 字节 | 计算机中存储数据的单元,一个 8 位的二进制数,是一个很具体的存储空间。 | 0x01, 0x45, 0xFA, …… |
| ANSI 字符串 | 在内存中,如果“字符”是以 ANSI 编码形式存在的,一个字符可能使用一个字节或多个字节来表示,那么我们称这种字符串为 ANSI 字符串或者多字节字符串。 | "中文123" (占7字节) |
| UNICODE 字符串 | 在内存中,如果“字符”是以在 UNICODE 中的序号存在的,那么我们称这种字符串为 UNICODE 字符串或者宽字节字符串。 | L"中文123" (占10字节) |
字节与字符区别
它们完全不是一个位面的概念,所以两者之间没有“区别”这个说法。不同编码里,字符和字节的对应关系不同:
| 类型 | 概念描述 |
|---|---|
| ASCII | 一个英文字母(不分大小写)占一个字节的空间,一个中文汉字占两个字节的空间。一个二进制数字序列,在计算机中作为一个数字单元,一般为 8 位二进制数,换算为十进制。最小值 0,最大值 255。 |
| UTF-8 | 一个英文字符等于一个字节,一个中文(含繁体)等于三个字节 |
| Unicode | 一个英文等于两个字节,一个中文(含繁体)等于两个字节。符号:英文标点占一个字节,中文标点占两个字节。举例:英文句号“.”占 1 个字节的大小,中文句号“。”占 2 个字节的大小。 |
| UTF-16 | 一个英文字母字符或一个汉字字符存储都需要 2 个字节(Unicode扩展区的一些汉字存储需要4个字节) |
| UTF-32 | 世界上任何字符的存储都需要 4 个字节 |
参考资料:
7. 有哪些访问修饰符
Java 面向对象的基本思想之一是封装细节并且公开接口。Java 语言采用访问控制修饰符来控制类及类的方法和变量的访问权限,从而向使用者暴露接口,但隐藏实现细节。访问控制分为四种级别:
| 修饰符 | 当前类 | 同 包 | 子 类 | 其他包 |
|---|---|---|---|---|
| public | √ | √ | √ | √ |
| protected | √ | √ | √ | × |
| default | √ | √ | × | × |
| private | √ | × | × | × |
- 类的成员不写访问修饰时默认为 default。默认对于同一个包中的其他类相当于公开(public),对于不是同一个包中的其他类相当于私有(private)。
- 受保护(protected)对子类相当于公开,对不是同一包中的没有父子关系的类相当于私有。
- Java 中,外部类的修饰符只能是 public 或默认,类的成员(包括内部类)的修饰符可以是以上四种。
8. 深拷贝与浅拷贝
- 浅拷贝:被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。换言之,浅拷贝仅仅复制所拷贝的对象,而不复制它所引用的对象。

- 深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。

参考资料:
9. Lamda表达式
Lambda 表达式,也可称为闭包,它是推动 Java 8 发布的最重要新特性。
Lambda 允许把函数作为一个方法的参数(函数作为参数传递进方法中)。
使用 Lambda 表达式可以使代码变的更加简洁紧凑。
语法
lambda 表达式的语法格式如下:
(parameters) -> expression
或
(parameters) -> { statements; }
以下是 lambda 表达式的重要特征:
- 可选类型声明:不需要声明参数类型,编译器可以统一识别参数值。
- 可选的参数圆括号:一个参数无需定义圆括号,但多个参数需要定义圆括号。
- 可选的大括号:如果主体包含了一个语句,就不需要使用大括号。
- 可选的返回关键字:如果主体只有一个表达式返回值则编译器会自动返回值,大括号需要指定明表达式返回了一个数值。
Lambda 表达式实例
Lambda 表达式的简单例子:
// 1. 不需要参数,返回值为 5
() -> 5
// 2. 接收一个参数(数字类型),返回其2倍的值
x -> 2 * x
// 3. 接受2个参数(数字),并返回他们的差值
(x, y) -> x – y
// 4. 接收2个int型整数,返回他们的和
(int x, int y) -> x + y
// 5. 接受一个 string 对象,并在控制台打印,不返回任何值(看起来像是返回void)
(String s) -> System.out.print(s)
在 Java8Tester.java 文件输入以下代码:
public class Java8Tester {
public static void main(String args[]){
Java8Tester tester = new Java8Tester();
// 类型声明
MathOperation addition = (int a, int b) -> a + b;
// 不用类型声明
MathOperation subtraction = (a, b) -> a - b;
// 大括号中的返回语句
MathOperation multiplication = (int a, int b) -> { return a * b; };
// 没有大括号及返回语句
MathOperation division = (int a, int b) -> a / b;
System.out.println("10 + 5 = " + tester.operate(10, 5, addition));
System.out.println("10 - 5 = " + tester.operate(10, 5, subtraction));
System.out.println("10 x 5 = " + tester.operate(10, 5, multiplication));
System.out.println("10 / 5 = " + tester.operate(10, 5, division));
// 不用括号
GreetingService greetService1 = message ->
System.out.println("Hello " + message);
// 用括号
GreetingService greetService2 = (message) ->
System.out.println("Hello " + message);
greetService1.sayMessage("Runoob");
greetService2.sayMessage("Google");
}
interface MathOperation {
int operation(int a, int b);
}
interface GreetingService {
void sayMessage(String message);
}
private int operate(int a, int b, MathOperation mathOperation){
return mathOperation.operation(a, b);
}
}
执行以上脚本,输出结果为:
$ javac Java8Tester.java
$ java Java8Tester
10 + 5 = 15
10 - 5 = 5
10 x 5 = 50
10 / 5 = 2
Hello Runoob
Hello Google
使用 Lambda 表达式需要注意以下两点:
- Lambda 表达式主要用来定义行内执行的方法类型接口,例如,一个简单方法接口。在上面例子中,我们使用各种类型的 Lambda 表达式来定义 MathOperation 接口的方法。然后我们定义了 sayMessage 的执行。
- Lambda 表达式免去了使用匿名方法的麻烦,并且给予 Java 简单但是强大的函数化的编程能力。
变量作用域
lambda 表达式只能引用标记了 final 的外层局部变量,这就是说不能在 lambda 内部修改定义在域外的局部变量,否则会编译错误。
在 Java8Tester.java 文件输入以下代码:
public class Java8Tester {
final static String salutation = "Hello! ";
public static void main(String args[]){
GreetingService greetService1 = message ->
System.out.println(salutation + message);
greetService1.sayMessage("Runoob");
}
interface GreetingService {
void sayMessage(String message);
}
}
执行以上脚本,输出结果为:
$ javac Java8Tester.java
$ java Java8Tester
Hello! Runoob
我们也可以直接在 lambda 表达式中访问外层的局部变量:
public class Java8Tester {
public static void main(String args[]) {
final int num = 1;
Converter<Integer, String> s = (param) -> System.out.println(String.valueOf(param + num));
s.convert(2); // 输出结果为 3
}
public interface Converter<T1, T2> {
void convert(int i);
}
}
lambda 表达式的局部变量可以不用声明为 final,但是必须不可被后面的代码修改(即隐性的具有 final 的语义)
int num = 1;
Converter<Integer, String> s = (param) -> System.out.println(String.valueOf(param + num));
s.convert(2);
num = 5;
//报错信息:Local variable num defined in an enclosing scope must be final or effectively
final
在 Lambda 表达式当中不允许声明一个与局部变量同名的参数或者局部变量。
String first = "";
//编译会出错
Comparator<String> comparator = (first, second) -> Integer.compare(first.length(), second.length());
10. 字符串常量池
Java 中字符串对象创建有两种形式,一种为字面量形式,如 String str = "abc";,另一种就是使用 new 这种标准的构造对象的方法,如 String str = new String("abc");,这两种方式我们在代码编写时都经常使用,尤其是字面量的方式。然而这两种实现其实存在着一些性能和内存占用的差别。这一切都是源于 JVM 为了减少字符串对象的重复创建,其维护了一个特殊的内存,这段内存被成为字符串常量池或者字符串字面量池。
工作原理
当代码中出现字面量形式创建字符串对象时,JVM首先会对这个字面量进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回,否则新的字符串对象被创建,然后将这个引用放入字符串常量池,并返回该引用。
public class Test {
public static void main(String[] args) {
String s1 = "abc";
String s2 = "abc";
// 以上两个局部变量都存在了常量池中
System.out.println(s1 == s2); // true
// new出来的对象不会放到常量池中,内存地址是不同的
String s3 = new String();
String s4 = new String();
/**
* 字符串的比较不可以使用双等号,这样会比较内存地址
* 字符串比较应当用equals,可见String重写了equals
*/
System.out.println(s3 == s4); // false
System.out.println(s3.equals(s4)); // true
}
}
11. 解释型语言与编译型语言的区别
我们使用工具编写的字母加符号的代码,是我们能看懂的高级语言,计算机无法直接理解,计算机需要先对我们编写的代码翻译成计算机语言,才能执行我们编写的程序。
将高级语言翻译成计算机语言有编译,解释两种方式。两种方式只是翻译的时间不同。
1. 编译型语言
编译型语言写得程序在执行之前,需要借助一个程序,将高级语言编写的程序翻译成计算机能懂的机器语言,然后,这个机器语言就能直接执行了,也就是我们常见的(exe文件)。
2. 解释型语言
解释型语言的程序不需要编译,节省了一道工序,不过解释型的语言在运行的时候需要翻译,每个语句都是执行的时候才翻译,对比编译型语言,效率比较低。通俗来讲,就是借助一个程序,且这个程序能试图理解编写的代码,然后按照编写的代码中的要求执行。
3. 脚本语言
脚本语言也是一种解释型语言,又被称为扩建的语言,或者动态语言不需要编译,可以直接使用,由解释器来负责解释。
脚本语言一般都是以文本形式存在,类似于一种命令。
4. 通俗理解编译型语言和解释型语言
同行讨论编译型语言和解释型语言的时候,这么说过,编译型语言相当于做一桌子菜再吃,解释型语言就是吃火锅。解释型的语言执行效率低,类似火锅需要一边煮一边吃。
二、面向对象
1. Java的四个基本特性,对多态的理解,在项目中哪些地方用到多态
- Java的四个基本特性
- 抽象:抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面。抽象只关注对象有哪些属性和行为,并不关注这些行为的细节是什么。
- 封装:通常认为封装是把数据和操作数据的方法绑定起来,对数据的访问只能通过已定义的接口。面向对象的本质就是将现实世界描绘成一系列完全自治、封闭的对象。我们在类中编写的方法就是对实现细节的一种封装;我们编写一个类就是对数据和数据操作的封装。可以说,封装就是隐藏一切可隐藏的东西,只向外界提供最简单的编程接口。
- 继承:继承是从已有类得到继承信息创建新类的过程。提供继承信息的类被称为父类(超类、基类);得到继承信息的类被称为子类(派生类)。继承让变化中的软件系统有了一定的延续性,同时继承也是封装程序中可变因素的重要手段。
- 多态:多态性是指允许不同子类型的对象对同一消息作出不同的响应。
- 多态的理解(多态的实现方式)
- 方法重载(overload):实现的是编译时的多态性(也称为前绑定)。
- 方法重写(override):实现的是运行时的多态性(也称为后绑定)。运行时的多态是面向对象最精髓的东西。
- 要实现多态需要做两件事:
- 1) **方法重写**(子类继承父类并重写父类中已有的或抽象的方法);
- 2) **对象造型**(用父类型引用引用子类型对象,这样同样的引用调用同样的方法就会根据子类对象的不同而表现出不同的行为)。
- 项目中对多态的应用
- 举一个简单的例子,在物流信息管理系统中,有两种用户:订购客户和卖房客户,两个客户都可以登录系统,他们有相同的方法 Login,但登陆之后他们会进入到不同的页面,也就是在登录的时候会有不同的操作,两种客户都继承父类的 Login 方法,但对于不同的对象,拥有不同的操作。
- 面相对象开发方式优点(B65)
- 较高的开发效率:可以把事物进行抽象,映射为开发的对象。
- 保证软件的鲁棒性:高重用性,可以重用已有的而且在相关领域经过长期测试的代码。
- 保证软件的高可维护性:代码的可读性非常好,设计模式也使得代码结构清晰,拓展性好。
2. 什么是重载和重写
- 重载:重载发生在同一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同或者二者都不同)则视为重载。
- 重写:重写发生在子类与父类之间,重写要求子类被重写方法与父类被重写方法有相同的返回类型,比父类被重写方法更好访问,不能比父类被重写方法声明更多的异常(里氏代换原则)。根据不同的子类对象确定调用的那个方法。

3. 面向对象和面向过程的区别?用面向过程可以实现面向对象吗?
- 面向对象和面向过程的区别
- 面向过程就像是一个细心的管家,事无具细的都要考虑到。而面向对象就像是个家用电器,你只需要知道他的功能,不需要知道它的工作原理。
- 面向过程是一种是“事件”为中心的编程思想。就是分析出解决问题所需的步骤,然后用函数把这些步骤实现,并按顺序调用。面向对象是以“对象”为中心的编程思想。
- 简单的举个例子:汽车发动、汽车到站
- 这对于 **面向过程** 来说,是两个事件,汽车启动是一个事件,汽车到站是另一个事件,**面向过程**编程的过程中我们关心的是事件,而不是汽车本身。针对上述两个事件,形成两个函数,之 后依次调用。(事件驱动,动词为主)
- 然而这对于**面向对象**来说,我们关心的是汽车这类对象,两个事件只是这类对象所具有的行为。而且对于这两个行为的顺序没有强制要求。(对象驱动,名词为主,将问题抽象出具体的对象,而这个对象有自己的属性和方法,在解决问题的时候是将不同的对象组合在一起使用)
- 用面向过程可以实现面向对象吗 ?
- 如果是 C 语言来展现出面向对象的思想,C 语言中是不是有个叫结构体的东西,这个里面有自己定义的变量 可以通过函数指针就可以实现对象
4. 面向对象开发的六个基本原则,在项目中用过哪些原则
- 六个基本原则(参考《设计模式之禅》)
- 单一职责(Single Responsibility Principle 简称 SRP):一个类应该仅有一个引起它变化的原因。在面向对象中,如果只让一个类完成它该做的事,而不涉及与它无关的领域就是践行了高内聚的原则,这个类就只有单一职责。
- 里氏替换(Liskov Substitution Principle 简称 LSP):任何时候子类型能够替换掉它们的父类型。子类一定是增加父类的能力而不是减少父类的能力,因为子类比父类的能力更多,把能力多的对象当成能力少的对象来用当然没有任何问题。
- 依赖倒置(Dependence Inversion Principle 简称 DIP):要依赖于抽象,不要依赖于具体类。要做到依赖倒置,应该做到:①高层模块不应该依赖底层模块,二者都应该依赖于抽象;②抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
- 接口隔离(Interface Segregation Principle 简称 ISP):不应该强迫客户依赖于他们不用的方法 。接口要小而专,绝不能大而全。臃肿的接口是对接口的污染,既然接口表示能力,那么一个接口只应该描述一种能力,接口也应该是高度内聚的。
- 最少知识原则(Least Knowledge Principle 简称 LKP):只和你的朋友谈话。迪米特法则又叫最少知识原则,一个对象应当对其他对象有尽可能少的了解。
- 开闭原则(Open Closed Principle 简称 OCP):软件实体应当对扩展开放,对修改关闭。要做到开闭有两个要点:①抽象是关键,一个系统中如果没有抽象类或接口系统就没有扩展点;②封装可变性,将系统中的各种可变因素封装到一个继承结构中,如果多个可变因素混杂在一起,系统将变得复杂而换乱。
- 其他原则
- 合成聚和复用:优先使用聚合或合成关系复用代码
- 面向接口编程
- 优先使用组合,而非继承
- 一个类需要的数据应该隐藏在类的内部
- 类之间应该零耦合,或者只有传导耦合,换句话说,类之间要么没关系,要么只使用另一个类的接口提供的操作
- 在水平方向上尽可能统一地分布系统功能
- 项目中用到的原则
- 单一职责、开放封闭、合成聚合复用(最简单的例子就是String类)、接口隔离
5. 内部类有哪些
可以将一个类的定义放在另一个类的定义内部,这就是内部类。
在 Java 中内部类主要分为成员内部类、局部内部类、匿名内部类、静态内部类
(一)成员内部类
成员内部类也是最普通的内部类,它是外围类的一个成员,所以他是可以无限制的访问外围类的所有成员属性和方法,尽管是private的,但是外围类要访问内部类的成员属性和方法则需要通过内部类实例来访问。
public class OuterClass {
private String str;
public void outerDisplay(){
System.out.println("outerClass...");
}
public class InnerClass{
public void innerDisplay(){
str = "chenssy..."; //使用外围内的属性
System.out.println(str);
outerDisplay(); //使用外围内的方法
}
}
// 推荐使用getxxx()来获取成员内部类,尤其是该内部类的构造函数无参数时
public InnerClass getInnerClass(){
return new InnerClass();
}
public static void main(String[] args) {
OuterClass outer = new OuterClass();
OuterClass.InnerClass inner = outer.getInnerClass();
inner.innerDisplay();
}
}
--------------------
chenssy...
outerClass...
在成员内部类中要注意两点:
- 成员内部类中不能存在
static方法, 但是可以存在static域, 前提是需要使用final关键字进行修饰.
- 成员内部类是依附于外围类的,所以只有先创建了外围类才能够创建内部类。
(二)局部内部类
有这样一种内部类,它是嵌套在方法和作用于内的,对于这个类的使用主要是应用与解决比较复杂的问题,想创建一个类来辅助我们的解决方案,到那时又不希望这个类是公共可用的,所以就产生了局部内部类,局部内部类和成员内部类一样被编译,只是它的作用域发生了改变,它只能在该方法和属性中被使用,出了该方法和属性就会失效。
//定义在方法里:
public class Parcel5 {
public Destionation destionation(String str){
class PDestionation implements Destionation{
private String label;
private PDestionation(String whereTo){
label = whereTo;
}
public String readLabel(){
return label;
}
}
return new PDestionation(str);
}
public static void main(String[] args) {
Parcel5 parcel5 = new Parcel5();
Destionation d = parcel5.destionation("chenssy");
}
}
//定义在作用域内:
public class Parcel6 {
private void internalTracking(boolean b){
if(b){
class TrackingSlip{
private String id;
TrackingSlip(String s) {
id = s;
}
String getSlip(){
return id;
}
}
TrackingSlip ts = new TrackingSlip("chenssy");
String string = ts.getSlip();
}
}
public void track(){
internalTracking(true);
}
public static void main(String[] args) {
Parcel6 parcel6 = new Parcel6();
parcel6.track();
}
}
(三)匿名内部类
匿名内部类也就是没有名字的内部类。正因为没有名字,所以匿名内部类只能使用一次,它通常用来简化代码编写。但使用匿名内部类还有个前提条件:必须继承一个父类或实现一个接口
实例1:不使用匿名内部类来实现抽象方法
abstract class Person {
public abstract void eat();
}
class Child extends Person {
public void eat() {
System.out.println("eat something");
}
}
public class Demo {
public static void main(String[] args) {
Person p = new Child();
p.eat();
}
}
运行结果:eat something
可以看到,我们用 Child 继承了 Person 类,然后实现了 Child 的一个实例,将其向上转型为 Person 类的引用
但是,如果此处的 Child 类只使用一次,那么将其编写为独立的一个类岂不是很麻烦?
这个时候就引入了匿名内部类
实例2:匿名内部类的基本实现
abstract class Person {
public abstract void eat();
}
public class Demo {
public static void main(String[] args) {
Person p = new Person() {
public void eat() {
System.out.println("eat something");
}
};
p.eat();
}
}
运行结果:eat something
可以看到,我们直接将抽象类 Person 中的方法在大括号中实现了,这样便可以省略一个类的书写,并且,匿名内部类还能用于接口上。
实例3:在接口上使用匿名内部类
interface Person {
public void eat();
}
public class Demo {
public static void main(String[] args) {
Person p = new Person() {
public void eat() {
System.out.println("eat something");
}
};
p.eat();
}
}
运行结果:eat something
由上面的例子可以看出,只要一个类是抽象的或是一个接口,那么其子类中的方法都可以使用匿名内部类来实现
最常用的情况就是在多线程的实现上,因为要实现多线程必须继承 Thread 类或是继承 Runnable 接口
实例4:Thread类的匿名内部类实现
public class Demo {
public static void main(String[] args) {
Thread t = new Thread() {
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.print(i + " ");
}
}
};
t.start();
}
}
运行结果:1 2 3 4 5
实例5:Runnable接口的匿名内部类实现
public class Demo {
public static void main(String[] args) {
Runnable r = new Runnable() {
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.print(i + " ");
}
}
};
Thread t = new Thread(r);
t.start();
}
}
运行结果:1 2 3 4 5
(四)静态内部类
关键字 static 中提到 static 可以修饰成员变量、方法、代码块,其他它还可以修饰内部类,使用 static 修饰的内部类我们称之为静态内部类,不过我们更喜欢称之为嵌套内部类。静态内部类与非静态内部类之间存在一个最大的区别,我们知道非静态内部类在编译完成之后会隐含地保存着一个引用,该引用是指向创建它的外围内,但是静态内部类却没有。
- 它的创建是不需要依赖于外围类的。
- 它不能使用任何外围类的非 static 成员变量和方法。
public class OuterClass {
private String sex;
public static String name = "chenssy";
// 静态内部类
static class InnerClass1{
// 在静态内部类中可以存在静态成员
public static String _name1 = "chenssy_static";
public void display(){
// 静态内部类只能访问外围类的静态成员变量和方法
// 不能访问外围类的非静态成员变量和方法
System.out.println("OutClass name :" + name);
}
}
// 非静态内部类
class InnerClass2{
// 非静态内部类中不能存在静态成员
public String _name2 = "chenssy_inner";
// 非静态内部类中可以调用外围类的任何成员,不管是静态的还是非静态的
public void display(){
System.out.println("OuterClass name:" + name);
}
}
// 外围类方法
public void display(){
// 外围类访问静态内部类:内部类
System.out.println(InnerClass1._name1);
// 静态内部类 可以直接创建实例不需要依赖于外围类
new InnerClass1().display();
// 非静态内部的创建需要依赖于外围类
OuterClass.InnerClass2 inner2 = new OuterClass().new InnerClass2();
// 方位非静态内部类的成员需要使用非静态内部类的实例
System.out.println(inner2._name2);
inner2.display();
}
public static void main(String[] args) {
OuterClass outer = new OuterClass();
outer.display();
}
}
----------------
Output:
chenssy_static
OutClass name :chenssy
chenssy_inner
OuterClass name:chenssy
6. 组合、继承和代理的区别
定义
- 组合:在新类中 new 另外一个类的对象,以添加该对象的特性。
- 继承:从基类继承得到子类,获得父类的特性。
- 代理:在代理类中创建某功能的类,调用类的一些方法以获得该类的部分特性。
使用场合
- 组合:各部件之间没什么关系,只需要组合即可。例如组装电脑,需要 new CPU(),new RAM(),new Disk()
public class Computer {
public Computer() {
CPU cpu=new CPU();
RAM ram=new RAM();
Disk disk=new Disk();
}
}
class CPU{ }
class RAM{ }
class Disk{ }
- 继承:子类需要具有父类的功能,各子类之间有所差异。例如 Shape 类作为父类,子类有 Rectangle,CirCle,Triangle……代码不写了,大家都经常用。
- 代理:飞机控制类,我不想暴露太多飞机控制的功能,只需部分前进左右转的控制(而不需要暴露发射导弹功能)。通过在代理类中 new 一个飞机控制对象,然后在方法中添加飞机控制类的各个需要暴露的功能。
public class PlaneDelegation{
private PlaneControl planeControl; //private外部不可访问
// 飞行员权限代理类,普通飞行员不可以开火
PlaneDelegation(){
planeControl = new PlaneControl();
}
public void speed(){
planeControl.speed();
}
public void left(){
planeControl.left();
}
public void right(){
planeControl.right();
}
}
final class PlaneControl {// final表示不可继承,控制器都能继承那还得了
protected void speed() {}
protected void fire() {}
protected void left() {}
protected void right() {}
}
说明:
- 继承:代码复用,引用不灵活;
- 组合:代码复用,
- 接口:引用灵活;
- 推荐组合+接口使用,看 IO 中包装流 FilterInputStream 中的策略模式
7. 什么是构造函数
构造函数是函数的一种特殊形式。特殊在哪里?构造函数中不需要定义返回类型(void 是无需返回值的意思,请注意区分两者),且构造函数的名称与所在的类名完全一致,其余的与函数的特性相同,可以带有参数列表,可以存在函数的重载现象。
一般用来初始化一些成员变量,当要生成一个类的对象(实例)的时候就会调用类的构造函数。如果不显示声明类的构造方法,会自动生成一个默认的不带参数的空的构造函数。
public class Demo{
private int num=0;
//无参构造函数
Demo()
{
System.out.println("constractor_run");
}
//有参构造函数
Demo(int num)
{
System.out.println("constractor_args_run");
}
//普通成员函数
public void demoFunction()
{
System.out.println("function_run");
}
}
在这里要说明一点,如果在类中我们不声明构造函数,JVM 会帮我们默认生成一个空参数的构造函数;如果在类中我们声明了带参数列表的构造函数,JVM 就不会帮我们默认生成一个空参数的构造函数,我们想要使用空参数的构造函数就必须自己去显式的声明一个空参的构造函数。
构造函数的作用
通过开头的介绍,构造函数的轮廓已经渐渐清晰,那么为什么会有构造函数呢?构造函数有什么作用?构造函数是面向对象编程思想所需求的,它的主要作用有以下两个:
- 创建对象。任何一个对象创建时,都需要初始化才能使用,所以任何类想要创建实例对象就必须具有构造函数。
- 对象初始化。构造函数可以对对象进行初始化,并且是给与之格式(参数列表)相符合的对象初始化,是具有一定针对性的初始化函数。
8. 向上造型和向下造型
父类引用能指向子类对象,子类引用不能指向父类对象;
向上造型
父类引用指向子类对象,例如:
Father f1 = new Son();
向下造型
把指向子类对象的父类引用赋给子类引用,需要强制转换,例如:
Father f1 = new Son();
Son s1 = (Son)f1;
但有运行出错的情况:
Father f2 = new Father();
Son s2 = (Son)f2; //编译无错但运行会出现错误
在不确定父类引用是否指向子类对象时,可以用 instanceof 来判断:
if(f3 instanceof Son){
Son s3 = (Son)f3;
}
三、关键字
1. final与static的区别
final
- 1. 数据
- 声明数据为常量,可以是编译时常量,也可以是在运行时被初始化后不能被改变的常量。
- 对于基本类型,final 使数值不变;
- 对于引用类型,final 使引用不变,也就不能引用其它对象,但是被引用的对象本身是可以修改的。
final int x = 1;
// x = 2; // cannot assign value to final variable 'x'
final A y = new A();
y.a = 1;
- 2. 方法
- 声明方法不能被子类覆盖。
- private 方法隐式地被指定为 final,如果在子类中定义的方法和基类中的一个 private 方法签名相同,此时子类的方法不是覆盖基类方法,而是在子类中定义了一个新的方法。
- 3. 类
- 声明类不允许被继承。
static
- 1. 静态变量
静态变量在内存中只存在一份,只在类初始化时赋值一次。
- 静态变量:类所有的实例都共享静态变量,可以直接通过类名来访问它;
- 实例变量:每创建一个实例就会产生一个实例变量,它与该实例同生共死。
public class A {
private int x; // 实例变量
public static int y; // 静态变量
}
注意:不能再成员函数内部定义static变量。
- 2. 静态方法
静态方法在类加载的时候就存在了,它不依赖于任何实例,所以静态方法必须有实现,也就是说它不能是抽象方法(abstract)。
- 3. 静态语句块
静态语句块在类初始化时运行一次。
- 4. 静态内部类
内部类的一种,静态内部类不依赖外部类,且不能访问外部类的非静态的变量和方法。
- 5. 静态导包
import static com.xxx.ClassName.*
在使用静态变量和方法时不用再指明 ClassName,从而简化代码,但可读性大大降低。
- 6. 变量赋值顺序
静态变量的赋值和静态语句块的运行优先于实例变量的赋值和普通语句块的运行,静态变量的赋值和静态语句块的运行哪个先执行取决于它们在代码中的顺序。
public static String staticField = "静态变量";
static {
System.out.println("静态语句块");
}
public String field = "实例变量";
{
System.out.println("普通语句块");
}
最后才运行构造函数
public InitialOrderTest() {
System.out.println("构造函数");
}
存在继承的情况下,初始化顺序为:
- 父类(静态变量、静态语句块)
- 子类(静态变量、静态语句块)
- 父类(实例变量、普通语句块)
- 父类(构造函数)
- 子类(实例变量、普通语句块)
- 子类(构造函数)
2. break、continue、return
break
跳出当前循环;但是如果是嵌套循环,则只能跳出当前的这一层循环,只有逐层 break 才能跳出所有循环。
for (int i = 0; i < 10; i++) {
// 在执行i==6时强制终止循环,i==6不会被执行
if (i == 6)
break;
System.out.println(i);
}
输出结果为0 1 2 3 4 5 ;6以后的都不会输出
continue
终止当前循环,但是不跳出循环(在循环中 continue 后面的语句是不会执行了),继续往下根据循环条件执行循环。
for (int i = 0; i < 10; i++) {
// i==6不会被执行,而是被中断了
if (i == 6)
continue;
System.out.println(i);
}
输出结果为0 1 2 3 4 5 7 8 9; 只有6没有输出
return
- return 从当前的方法中退出,返回到该调用的方法的语句处,继续执行。
- return 返回一个值给调用该方法的语句,返回值的数据类型必须与方法的声明中的返回值的类型一致。
- return 后面也可以不带参数,不带参数就是返回空,其实主要目的就是用于想中断函数执行,返回调用函数处。
特别注意:返回值为 void 的方法,从某个判断中跳出,必须用 return。
3. final、finally和finalize区别
final
final 用于声明属性、方法和类,分别表示属性不可变、方法不可覆盖和类不可被继承。
- final 属性:被final修饰的变量不可变(引用不可变)
- final 方法:不允许任何子类重写这个方法,但子类仍然可以使用这个方法
- final 参数:用来表示这个参数在这个函数内部不允许被修改
- final 类:此类不能被继承,所有方法都不能被重写
finally
在异常处理的时候,提供 finally 块来执行任何的清除操作。如果抛出一个异常,那么相匹配的 catch 字句就会执行,然后控制就会进入 finally 块,前提是有 finally 块。例如:数据库连接关闭操作上
finally 作为异常处理的一部分,它只能用在 try/catch 语句中,并且附带一个语句块,表示这段语句最终一定会被执行(不管有没有抛出异常),经常被用在需要释放资源的情况下。(×)(这句话其实存在一定的问题,还没有深入了解,欢迎大家在 issue 中提出自己的见解)
- 异常情况说明:
- 在执行 try 语句块之前已经返回或抛出异常,所以 try 对应的 finally 语句并没有执行。
- 我们在 try 语句块中执行了 System.exit (0) 语句,终止了 Java 虚拟机的运行。那有人说了,在一般的 Java 应用中基本上是不会调用这个 System.exit(0) 方法的
- 当一个线程在执行 try 语句块或者 catch 语句块时被打断(interrupted)或者被终止(killed),与其相对应的 finally 语句块可能不会执行
- 还有更极端的情况,就是在线程运行 try 语句块或者 catch 语句块时,突然死机或者断电,finally 语句块肯定不会执行了。可能有人认为死机、断电这些理由有些强词夺理,没有关系,我们只是为了说明这个问题。
finalize
finalize() 是 Object 中的方法,当垃圾回收器将要回收对象所占内存之前被调用,即当一个对象被虚拟机宣告死亡时会先调用它 finalize() 方法,让此对象处理它生前的最后事情(这个对象可以趁这个时机挣脱死亡的命运)。要明白这个问题,先看一下虚拟机是如何判断一个对象该死的。
可以覆盖此方法来实现对其他资源的回收,例如关闭文件。
判定死亡
Java 采用可达性分析算法来判定一个对象是否死期已到。Java中以一系列 "GC Roots" 对象作为起点,如果一个对象的引用链可以最终追溯到 "GC Roots" 对象,那就天下太平。
否则如果只是A对象引用B,B对象又引用A,A B引用链均未能达到 "GC Roots" 的话,那它俩将会被虚拟机宣判符合死亡条件,具有被垃圾回收器回收的资格。
最后的救赎
上面提到了判断死亡的依据,但被判断死亡后,还有生还的机会。
如何自我救赎:
- 对象覆写了 finalize() 方法(这样在被判死后才会调用此方法,才有机会做最后的救赎);
- 在 finalize() 方法中重新引用到 "GC Roots" 链上(如把当前对象的引用 this 赋值给某对象的类变量/成员变量,重新建立可达的引用).
需要注意:
finalize() 只会在对象内存回收前被调用一次 (The finalize method is never invoked more than once by a Java virtual machine for any given object. )
finalize() 的调用具有不确定性,只保证方法会调用,但不保证方法里的任务会被执行完(比如一个对象手脚不够利索,磨磨叽叽,还在自救的过程中,被杀死回收了)。
finalize()的作用
虽然以上以对象救赎举例,但 finalize() 的作用往往被认为是用来做最后的资源回收。 基于在自我救赎中的表现来看,此方法有很大的不确定性(不保证方法中的任务执行完)而且运行代价较高。所以用来回收资源也不会有什么好的表现。
综上:finalize() 方法并没有什么鸟用。
至于为什么会存在一个鸡肋的方法:书中说 “它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的一个妥协”。
参考资料:
4. assert有什么作用
断言(assert)作为一种软件调试的方法,提供了一种在代码中进行正确性检查的机制,目前很多开发语言都支持这种机制。
在实现中,assertion 就是在程序中的一条语句,它对一个 boolean 表达式进行检查,一个正确程序必须保证这个 boolean 表达式的值为 true;如果该值为 false,说明程序已经处于不正确的状态下,系统将给出警告并且退出。一般来说,assertion 用于保证程序最基本、关键的正确性。assertion 检查通常在开发和测试时开启。为了提高性能,在软件发布后,assertion 检查通常是关闭的。下面简单介绍一下 Java 中 assertion 的实现。
在语法上,为了支持 assertion,Java 增加了一个关键字 assert。它包括两种表达式,分别如下:
assert
如果
如果为 false,则程序抛出 AssertionError,并终止执行。
assert
如果
如果为 false,则程序抛出 java.lang.AssertionError,并输入<错误信息表达式>。
public static void main(String[] args) {
System.out.println("123");
int a = 0;
int b = 1;
assert a == b; //需显示开启,默认为不开启状态
assert a == b : "执行失败!";
System.out.println("1234");
}
assert 的应用范围很多,主要包括:
- 检查控制流
- 检查输入参数是否有效
- 检查函数结果是否有效
- 检查程序不变
什么是断言
断言是编程术语,表示为一些布尔表达式,程序员相信在程序中的某个特定点该表达式值为真,可以在任何时候启用和禁用断言验证,因此可以在测试时启用断言而在部署时禁用断言。同样,程序投入运行后,最终用户在遇到问题时可以重新启用断言。
使用断言可以创建更稳定、品质更好且 不易于出错的代码。当需要在一个值为 false 时中断当前操作的话,可以使用断言。单元测试必须使用断言(Junit/JunitX)。
常用断言方法
| 断言 | 描述 |
|---|---|
| void assertEquals([String message], expected value, actual value) | 断言两个值相等。值可能是类型有 int, short, long, byte, char or java.lang.Object. 第一个参数是一个可选的字符串消息 |
| void assertTrue([String message], boolean condition) | 断言一个条件为真 |
| void assertFalse([String message],boolean condition) | 断言一个条件为假 |
| void assertNotNull([String message], java.lang.Object object) | 断言一个对象不为空(null) |
| void assertNull([String message], java.lang.Object object) | 断言一个对象为空(null) |
| void assertSame([String message], java.lang.Object expected, java.lang.Object actual) | 断言,两个对象引用相同的对象 |
| void assertNotSame([String message], java.lang.Object unexpected, java.lang.Object actual) | 断言,两个对象不是引用同一个对象 |
| void assertArrayEquals([String message], expectedArray, resultArray) | 断言预期数组和结果数组相等。数组的类型可能是 int, long, short, char, byte or java.lang.Object. |
5. volatile
每次都读错,美式发音:volatile /'vɑlətl/ adj. [化学] 挥发性的;不稳定的;爆炸性的;反复无常的
volatile 是一个类型修饰符(type specifier),它是被设计用来修饰被不同线程访问和修改的变量。在使用 volatile 修饰成员变量后,所有线程在任何时间所看到变量的值都是相同的。此外,使用 volatile 会组织编译器对代码的优化,因此会降低程序的执行效率。所以,除非迫不得已,否则,能不使用 volatile 就尽量不要使用 volatile。
- 每次访问变量时,总是获取主内存的最新值
- 每次修改变量后,立刻写回到主内存中

参考资料:
6. instanceof
instanceof 是 Java 的一个二元操作符,类似于 ==,>,< 等操作符。
instanceof 是 Java 的保留关键字。它的作用是测试它左边的对象是否是它右边的类的实例,返回 boolean 的数据类型。
public class Main {
public static void main(String[] args) {
Object testObject = new ArrayList();
displayObjectClass(testObject);
}
public static void displayObjectClass(Object o) {
if (o instanceof Vector)
System.out.println("对象是 java.util.Vector 类的实例");
else if (o instanceof ArrayList)
System.out.println("对象是 java.util.ArrayList 类的实例");
else
System.out.println("对象是 " + o.getClass() + " 类的实例");
}
}
7. strictfp
strictfp,即 strict float point (精确浮点)。
strictfp 关键字可应用于类、接口或方法。使用 strictfp 关键字声明一个方法时,该方法中所有的 float 和 double 表达式都严格遵守 FP-strict 的限制,符合 IEEE-754 规范。当对一个类或接口使用 strictfp 关键字时,该类中的所有代码,包括嵌套类型中的初始设定值和代码,都将严格地进行计算。严格约束意味着所有表达式的结果都必须是 IEEE 754 算法对操作数预期的结果,以单精度和双精度格式表示。
如果你想让你的浮点运算更加精确,而且不会因为不同的硬件平台所执行的结果不一致的话,可以用关键字strictfp.
8. transient
transient 英 /'trænzɪənt/ adj. 短暂的;路过的 n. 瞬变现象;过往旅客;候鸟
我们都知道一个对象只要实现了 Serilizable 接口,这个对象就可以被序列化,Java 的这种序列化模式为开发者提供了很多便利,我们可以不必关系具体序列化的过程,只要这个类实现了 Serilizable 接口,这个类的所有属性和方法都会自动序列化。
然而在实际开发过程中,我们常常会遇到这样的问题,这个类的有些属性需要序列化,而其他属性不需要被序列化,打个比方,如果一个用户有一些敏感信息(如密码,银行卡号等),为了安全起见,不希望在网络操作(主要涉及到序列化操作,本地序列化缓存也适用)中被传输,这些信息对应的变量就可以加上 transient 关键字。换句话说,这个字段的生命周期仅存于调用者的内存中而不会写到磁盘里持久化。
总之,Java 的 transient 关键字为我们提供了便利,你只需要实现 Serilizable 接口,将不需要序列化的属性前添加关键字transient,序列化对象的时候,这个属性就不会序列化到指定的目的地中。
参考资料:
9. native
native(即 JNI,Java Native Interface),凡是一种语言,都希望是纯。比如解决某一个方案都喜欢就单单这个语言来写即可。Java 平台有个用户和本地 C 代码进行互操作的 API,称为 Java Native Interface (Java本地接口)。

参考资料:
四、基本数据类型与运算
1. Java的基本数据类型和引用类型,自动装箱和拆箱
- 4 类 8 种基本数据类型。4 整数型,2 浮点型,1 布尔型,1 字符型
| 类型 | 存储 | 取值范围 | 默认值 | 包装类 |
|---|---|---|---|---|
| 整数型 | ||||
| byte | 8 | 最大存储数据量是 255,最小 -27,最大 27-1, [-128~127] | (byte) 0 | Byte |
| short | 16 | 最大数据存储量是 65536,[-215,215-1], [-32768,32767],±3万 | (short) 0 | Short |
| int | 32 | 最大数据存储容量是 231-1, [-231,231-1],±21亿,[ -2147483648, 2147483647] | 0 | Integer |
| long | 64 | 最大数据存储容量是 264-1, [-263,263-1], ±922亿亿(±(922+16个零)) | 0L | Long |
| 浮点型 | ||||
| float | 32 | 数据范围在 3.4e-45~1.4e38,直接赋值时必须在数字后加上 f 或 F | 0.0f | Float |
| double | 64 | 数据范围在 4.9e-324~1.8e308,赋值时可以加 d 或 D 也可以不加 | 0.0d | Double |
| 布尔型 | ||||
| boolean | 1 | true / flase | false | Boolean |
| 字符型 | ||||
| char | 16 | 存储 Unicode 码,用单引号赋值 | '\u0000' (null) | Character |
- 引用数据类型
- 类(class)、接口(interface)、数组
- 自动装箱和拆箱
- 基本数据类型和它对应的封装类型之间可以相互转换。自动拆装箱是
jdk5.0提供的新特特性,它可以自动实现类型的转换 - 装箱:从基本数据类型到封装类型叫做装箱
- 拆箱:从封装类型到基本数据类型叫拆箱
- 基本数据类型和它对应的封装类型之间可以相互转换。自动拆装箱是
// jdk 1.5
public class TestDemo {
public static void main(String[] args) {
Integer m =10;
int i = m;
}
}
上面的代码在 jdk1.4 以后的版本都不会报错,它实现了自动拆装箱的功能,如果是 jdk1.4,就得这样写了
// jdk 1.4
public class TestDemo {
public static void main(String[] args) {
Integer b = new Integer(210);
int c = b.intValue();
}
}
2. ValueOf缓存池
new Integer(123) 与 Integer.valueOf(123) 的区别在于,new Integer(123) 每次都会新建一个对象,而 Integer.valueOf(123) 可能会使用缓存对象,因此多次使用 Integer.valueOf(123) 会取得同一个对象的引用。
Integer x = new Integer(123);
Integer y = new Integer(123);
System.out.println(x == y); // false
Integer z = Integer.valueOf(123);
Integer k = Integer.valueOf(123);
System.out.println(z == k); // true
编译器会在自动装箱过程调用 valueOf() 方法,因此多个 Integer 实例使用自动装箱来创建并且值相同,那么就会引用相同的对象。
Integer m = 123;
Integer n = 123;
System.out.println(m == n); // true
valueOf() 方法的实现比较简单,就是先判断值是否在缓存池中,如果在的话就直接使用缓存池的内容。
// valueOf 源码实现
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
在 Java 8 中,Integer 缓存池的大小默认为 -128\~127。
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
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;
}
Java 还将一些其它基本类型的值放在缓冲池中,包含以下这些:
- boolean values true and false
- all byte values
- short values between -128 and 127
- int values between -128 and 127
- char in the range \u0000 to \u007F
因此在使用这些基本类型对应的包装类型时,就可以直接使用缓冲池中的对象。
参考资料:
3. i++和++i有什么区别
i++
i++ 是在程序执行完毕后进行自增,而 ++i 是在程序开始执行前进行自增。
i++ 的操作分三步
- 栈中取出 i
- i 自增 1
- 将 i 存到栈
三个阶段:内存到寄存器,寄存器自增,写回内存(这三个阶段中间都可以被中断分离开)
所以 i++ 不是原子操作,上面的三个步骤中任何一个步骤同时操作,都可能导致 i 的值不正确自增
++i
在多核的机器上,CPU 在读取内存 i 时也会可能发生同时读取到同一值,这就导致两次自增,实际只增加了一次。
i++ 和 ++i 都不是原子操作
原子性:指的是一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程打断。
JMM 三大特性:原子性,可见性,有序性。详情请阅读 Github 仓库:Java 并发编程 一文。
4. 位运算符
Java 定义了位运算符,应用于整数类型 (int),长整型 (long),短整型 (short),字符型 (char),和字节型 (byte)等类型。
下表列出了位运算符的基本运算,假设整数变量A的值为60和变量B的值为13
A(60):0011 1100
B(13):0000 1101
| 操作符 | 名称 | 描述 | 例子 | ||
|---|---|---|---|---|---|
| & | 与 | 如果相对应位都是 1,则结果为 1,否则为 0 | (A&B)得到 12,即 0000 1100 | ||
| \ | 或 | 如果相对应位都是 0,则结果为 0,否则为 1 | (A\ | B)得到 61,即 0011 1101 | |
| ^ | 异或 | 如果相对应位值相同,则结果为 0,否则为 1 | (A^B)得到49,即 0011 0001 | ||
| 〜 | 非 | 按位取反运算符翻转操作数的每一位,即 0 变成 1,1 变成 0 | (〜A)得到-61,即1100 0011 | ||
| << | 左移 | (左移一位乘2)按位左移运算符。左操作数按位左移右操作数指定的位数。左移 n 位表示原来的值乘 2n | A << 2得到240,即 1111 0000 | ||
| >> | (右移一位除2)有符号右移,按位右移运算符。左操作数按位右移右操作数指定的位数 | A >> 2得到15即 1111 | |||
| >>> | 无符号右移 | 无符号右移,按位右移补零操作符。左操作数的值按右操作数指定的位数右移,移动得到的空位以零填充 | A>>>2得到15即0000 1111 |
5. 原码、补码、反码是什么
机器数
一个数在计算机中的二进制表示形式,叫做这个数的机器数。机器数是带符号的,在计算机用一个数的最高位存放符号,正数为 0,负数为 1。
比如,十进制中的数 +3 ,计算机字长为 8 位,转换成二进制就是 00000011。如果是 -3 ,就是 10000011 。那么,这里的 00000011 和 10000011 就是机器数。
真值
因为第一位是符号位,所以机器数的形式值就不等于真正的数值。例如上面的有符号数 10000011,其最高位 1 代表负,其真正数值是 -3 而不是形式值 131(10000011 转换成十进制等于 131)。所以,为区别起见,将带符号位的机器数对应的真正数值称为机器数的真值。
例:0000 0001 的真值 = +000 0001 = +1,1000 0001 的真值 = –000 0001 = –1
原码
原码就是符号位加上真值的绝对值,即用第一位表示符号,其余位表示值。比如如果是 8 位二进制:
[+1]原 = 0000 0001
[-1]原 = 1000 0001
第一位是符号位。因为第一位是符号位,所以 8 位二进制数的取值范围就是:[1111 1111 , 0111 1111],即:[-127 , 127]
原码是人脑最容易理解和计算的表示方式
反码
反码的表示方法是:
- 正数的反码是其本身;
- 负数的反码是在其原码的基础上,符号位不变,其余各个位取反。
[+1] = [00000001]原 = [00000001]反
[-1] = [10000001]原= [11111110]反
可见如果一个反码表示的是负数, 人脑无法直观的看出来它的数值. 通常要将其转换成原码再计算。
补码
补码的表示方法是:
- 正数的补码就是其本身;
- 负数的补码是在其原码的基础上,符号位不变,其余各位取反, 最后+1。(反码的基础上 +1)
[+1] = [0000 0001]原 = [0000 0001]反 = [0000 0001]补
[-1] = [1000 0001]原 = [1111 1110]反 = [1111 1111]补
对于负数,补码表示方式也是人脑无法直观看出其数值的。 通常也需要转换成原码在计算其数值。
参考资料:
6. 不用额外变量交换两个整数的值
如果给定整数 a 和 b,用以下三行代码即可交换 a 和b 的值
a = a ^ b;
b = a ^ b;
a = a ^ b;
- 假设 a 异或 b 的结果记为 c,c 就是 a 整数位信息和 b 整数位信息的所有不同信息。
- 比如:a = 4 = 100,b = 3 = 011,a^b = c = 111
- a 异或 c 的结果就是 b,比如:a = 4 = 100,c = 111,a^c = 011 = 3 = b
- b 异或c 的结果就是 a,比如:b = 3 = 011,c = 111,b^c = 100 = 4 = a
说明:位运算的题目基本上都带有靠经验积累才会做的特征,也就是准备阶段需要做足够多的题,面试时才会有良好的感觉。
7. 不使用运算符进行a+b操作
- a^b; 得到不含进位之和
- (a & b)<<1; 进位
- 只要进位不为零,则迭代;否则返回
#include <stdio.h>
int add(int a, int b)
{
int c = a & b;
int r = a ^ b;
if(c == 0){
return r;
}
else{
return add(r, c << 1);
}
}
int main(int argn, char *argv[])
{
printf("sum = %d\n", add(-10000, 56789));
return 0;
}
8. &和&& 、|和||的区别
(1)&& 和 & 都是表示与,区别是 && 只要第一个条件不满足,后面条件就不再判断。而 & 要对所有的条件都进行判断。
// 例如:
public static void main(String[] args) {
if((23!=23) && (100/0==0)){
System.out.println("运算没有问题。");
}else{
System.out.println("没有报错");
}
}
// 输出的是“没有报错”。而将 && 改为 & 就会如下错误:
// Exception in thread "main" java.lang.ArithmeticException: / by zero
- 原因:
- &&时判断第一个条件为 false,后面的 100/0==0 这个条件就没有进行判断。
- & 时要对所有的条件进行判断,所以会对后面的条件进行判断,所以会报错。
(2)|| 和 | 都是表示 “或”,区别是 || 只要满足第一个条件,后面的条件就不再判断,而 | 要对所有的条件进行判断。 看下面的程序:
public static void main(String[] args) {
if((23==23)||(100/0==0)){
System.out.println("运算没有问题。");
}else{
System.out.println("没有报错");
}
}
// 此时输出“运算没有问题”。若将||改为|则会报错。
- 原因
- || 判断第一个条件为 true,后面的条件就没有进行判断就执行了括号中的代码
- 而 | 要对所有的条件进行判断,所以会报错
五、字符串与数组
1. String,StringBuffer,StringBuilder,以及对String不变性的理解
- String、StringBuffer、StringBuilder
- 都是 final 类,都不允许被继承
- String 长度是不可变的,StringBuffer、StringBuilder 长度是可变的
- StringBuffer 是线程安全的,StringBuilder 不是线程安全的,但它们两个中的所有方法都是相同的,StringBuffer 在 StringBuilder 的方法之上添加了 synchronized 修饰,保证线程安全
- StringBuilder 比 StringBuffer 拥有更好的性能
- 如果一个 String 类型的字符串,在编译时就可以确定是一个字符串常量,则编译完成之后,字符串会自动拼接成一个常量。此时 String 的速度比 StringBuffer 和 StringBuilder 的性能好的多
- String 不变性的理解
- String 类是被 final 进行修饰的,不能被继承
- 在用 + 号链接字符串的时候会创建新的字符串
- String s = new String("Hello world"); 可能创建两个对象也可能创建一个对象。如果静态区中有 “Hello world” 字符串常量对象的话,则仅仅在堆中创建一个对象。如果静态区中没有 “Hello world” 对象,则堆上和静态区中都需要创建对象。
- 在 Java 中, 通过使用 "+" 符号来串联字符串的时候,,实际上底层会转成通过 StringBuilder 实例的 append() 方法来实现。
2. String有重写Object的hashcode和toString吗?如果重写equals不重写hashcode会出现什么问题?
- String 有重写 Object 的 hashcode 和 toString吗?
- String 重写了 Object 类的 hashcode 和 toString 方法。
- 当 equals 方法被重写时,通常有必要重写 hashCode 方法,以维护 hashCode 方法的常规协定,该协定声明相对等的两个对象必须有相同的 hashCode
- object1.equals(object2) 时为 true, object1.hashCode() == object2.hashCode() 为 true
- object1.hashCode() == object2.hashCode() 为 false 时,object1.equals(object2) 必定为 false
- object1.hashCode() == object2.hashCode() 为 true时,但 object1.equals(object2) 不一定定为 true
- 重写 equals 不重写 hashcode 会出现什么问题
- 在存储散列集合时(如 Set 类),如果原对象.equals(新对象),但没有对 hashCode 重写,即两个对象拥有不同的 hashCode,则在集合中将会存储两个值相同的对象,从而导致混淆。因此在重写 equals 方法时,必须重写 hashCode 方法。
3. 如果你定义一个类,包括学号,姓名,分数,如何把这个对象作为key?要重写equals和hashcode吗
- 需要重写 equals 方法和 hashcode,必须保证对象的属性改变时,其 hashcode 不能改变。
4. 字面量
在编程语言中,字面量(literal)指的是在源代码中直接表示的一个固定的值。
八进制是用在整数字面量之前添加 “0” 来表示的。
十六进制用在整数字面量之前添加 “0x” 或者 “0X” 来表示的
Java 7 中新增了二进制:用在整数字面量之前添加 “0b” 或者 “0B” 来表示的。
在数值字面量中使用下划线
在 Java7 中,数值字面量,不管是整数还是浮点数都允许在数字之间插入任意多个下划线。并且不会对数值产生影响,目的是方便阅读,规则只能在数字之间使用。
public class BinaryIntegralLiteral {
public static void main(String[] args) {
System.out.println(0b010101);
System.out.println(0B010101);
System.out.println(0x15A);
System.out.println(0X15A);
System.out.println(077);
System.out.println(5_000);
/**
* 输出结果
* 21
* 21
* 346
* 346
* 63
* 5000
*/
}
}
六、异常处理
1. 常见异常分为那两种(Exception,Error),常见异常的基类以及常见的异常
- Throwable 是 Java 语言中所有错误和异常的超类(万物即可抛)。它有两个子类:Error、Exception。
- 异常种类
- Error:Error 为错误,是程序无法处理的,如 OutOfMemoryError、ThreadDeath 等,出现这种情况你唯一能做的就是听之任之,交由 JVM 来处理,不过 JVM 在大多数情况下会选择终止线程。
- Exception:Exception 是程序可以处理的异常。它又分为两种 CheckedException(受捡异常),一种是 UncheckedException(不受检异常)。
- **受检异常**(CheckException):发生在编译阶段,必须要使用 try…catch(或者throws)否则编译不通过。
- **非受检异常** (UncheckedException):是程序运行时错误,例如除 0 会引发 Arithmetic Exception,此时程序奔溃并且无法恢复。 (发生在运行期,具有不确定性,主要是由于程序的逻辑问题所引起的,难以排查,我们一般都需要纵观全局才能够发现这类的异常错误,所以在程序设计中我们需要认真考虑,好好写代码,尽量处理异常,即使产生了异常,也能尽量保证程序朝着有利方向发展。 )
- 常见异常的基类(Exception)
- IOException
- RuntimeException

七、Object 通用方法
以下为 Object 中的通用方法
public final native Class<?> getClass()
public native int hashCode()
public boolean equals(Object obj)
protected native Object clone() throws CloneNotSupportedException
public String toString()
public final native void notify()
public final native void notifyAll()
public final native void wait(long timeout) throws InterruptedException
public final void wait(long timeout, int nanos) throws InterruptedException
public final void wait() throws InterruptedException
protected void finalize() throws Throwable {} // JVM内存回收之finalize()方法
equals()
1. equals() 与 == 的区别
- 对于基本类型,== 判断两个值是否相等,基本类型没有 equals() 方法。
- 对于引用类型,== 判断两个实例是否引用同一个对象,而 equals() 判断引用的对象是否等价。
Integer x = new Integer(1);
Integer y = new Integer(1);
System.out.println(x.equals(y)); // true
System.out.println(x == y); // false
2. 等价关系
(一)自反性
x.equals(x); // true
(二)对称性
x.equals(y) == y.equals(x); // true
(三)传递性
if (x.equals(y) && y.equals(z))
x.equals(z); // true;
(四)一致性
多次调用 equals() 方法结果不变
x.equals(y) == x.equals(y); // true
(五)与 null 的比较
对任何不是 null 的对象 x 调用 x.equals(null) 结果都为 false
x.euqals(null); // false;
3. 实现
- 检查是否为同一个对象的引用,如果是直接返回 true;
- 检查是否是同一个类型,如果不是,直接返回 false;
- 将 Object 实例进行转型;
- 判断每个关键域是否相等。
public class EqualExample {
private int x;
private int y;
private int z;
public EqualExample(int x, int y, int z) {
this.x = x;
this.y = y;
this.z = z;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EqualExample that = (EqualExample) o;
if (x != that.x) return false;
if (y != that.y) return false;
return z == that.z;
}
}
hashCode()
hasCode() 返回散列值,而 equals() 是用来判断两个实例是否等价。等价的两个实例散列值一定要相同,但是散列值相同的两个实例不一定等价。
在覆盖 equals() 方法时应当总是覆盖 hashCode() 方法,保证等价的两个实例散列值也相等。
下面的代码中,新建了两个等价的实例,并将它们添加到 HashSet 中。我们希望将这两个实例当成一样的,只在集合中添加一个实例,但是因为 EqualExample 没有实现 hasCode() 方法,因此这两个实例的散列值是不同的,最终导致集合添加了两个等价的实例。
EqualExample e1 = new EqualExample(1, 1, 1);
EqualExample e2 = new EqualExample(1, 1, 1);
System.out.println(e1.equals(e2)); // true
HashSet<EqualExample> set = new HashSet<>();
set.add(e1);
set.add(e2);
System.out.println(set.size()); // 2
理想的散列函数应当具有均匀性,即不相等的实例应当均匀分布到所有可能的散列值上。这就要求了散列函数要把所有域的值都考虑进来,可以将每个域都当成 R 进制的某一位,然后组成一个 R 进制的整数。R 一般取 31,因为它是一个奇素数,如果是偶数的话,当出现乘法溢出,信息就会丢失,因为与 2 相乘相当于向左移一位。
一个数与 31 相乘可以转换成移位和减法:31\*x == (x<<5)-x,编译器会自动进行这个优化。
@Override
public int hashCode() {
int result = 17;
result = 31 * result + x;
result = 31 * result + y;
result = 31 * result + z;
return result;
}
toString()
默认返回 ToStringExample@4554617c 这种形式,其中 @ 后面的数值为散列码的无符号十六进制表示。
public class ToStringExample {
private int number;
public ToStringExample(int number) {
this.number = number;
}
}
ToStringExample example = new ToStringExample(123);
System.out.println(example.toString());
ToStringExample@4554617c
clone()
1. cloneable
clone() 是 Object 的 protect 方法,它不是 public,一个类不显式去重写 clone(),其它类就不能直接去调用该类实例的 clone() 方法。
public class CloneExample {
private int a;
private int b;
}
CloneExample e1 = new CloneExample();
// CloneExample e2 = e1.clone(); // 'clone()' has protected access in 'java.lang.Object'
重写 clone() 得到以下实现:
public class CloneExample {
private int a;
private int b;
@Override
protected CloneExample clone() throws CloneNotSupportedException {
return (CloneExample)super.clone();
}
}
CloneExample e1 = new CloneExample();
try {
CloneExample e2 = e1.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
java.lang.CloneNotSupportedException: CloneTest
以上抛出了 CloneNotSupportedException,这是因为 CloneTest 没有实现 Cloneable 接口。
public class CloneExample implements Cloneable {
private int a;
private int b;
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
应该注意的是,clone() 方法并不是 Cloneable 接口的方法,而是 Object 的一个 protected 方法。Cloneable 接口只是规定,如果一个类没有实现 Cloneable 接口又调用了 clone() 方法,就会抛出 CloneNotSupportedException。
参考资料:
更新日志
- 2018/7/11 v1.0 第一版
- 2018/7/31 v2.0 基础版
- 2018/8/30-31 v3.0 初版
Java集合框架
- [集合框架图](#集合框架图)
- [Collection](#Collection)
- [Map](#Map)
- [工具类](#工具类)
- [通用实现](#通用实现)
- [ArrayList](#ArrayList)
- [1. 概览](#1-概览)
- [2. 序列化](#2-序列化)
- [3. 扩容](#3-扩容)
- [4. 删除元素](#4-删除元素)
- [5. Fail-Fast](#5-Fail-Fast)
- [Vector](#Vector)
- [1. 同步](#1-同步)
- [2. ArrayList 与 Vector](#2-ArrayList-与-Vector)
- [3. Vector 替代方案](#3-Vector-替代方案)
- [synchronizedList](#synchronizedList)
- [CopyOnWriteArrayList](#CopyOnWriteArrayList)
- [LinkedList](#LinkedList)
- [1. 概览](#1-概览-1)
- [2. add()](#2-add)
- [3. remove()](#3-remove)
- [4. get()](#4-get)
- [5. 总结](#5-总结)
- [6. ArrayList 与 LinkedList](#6-ArrayList-与-LinkedList)
- [HashMap](#HashMap)
- [1. 存储结构](#1-存储结构)
- [JDK1.7 的存储结构](#jdk17-的存储结构)
- [JDK1.8 的存储结构](#jdk18-的存储结构)
- [2. 重要参数](#2-重要参数)
- [3. 确定哈希桶数组索引位置](#3-确定哈希桶数组索引位置)
- [4. 分析HashMap的put方法](#4-分析HashMap的put方法)
- [5. 扩容机制](#5-扩容机制)
- [6. 线程安全性](#6-线程安全性)
- [7. JDK1.8与JDK1.7的性能对比](#7-jdk18与jdk17的性能对比)
- [8. Hash较均匀的情况](#8-hash较均匀的情况)
- [9. Hash极不均匀的情况](#9-hash极不均匀的情况)
- [10. HashMap与Hashtable](#10-HashMap与Hashtable)
- [11. 小结](#11-小结)
- [ConcurrentHashMap](#ConcurrentHashMap)
- [1. 概述](#1-概述)
- [2. 存储结构](#2-存储结构)
- [2. size 操作](#2-size-操作)
- [3. 同步方式](#3-同步方式)
- [4. JDK 1.8 的改动](#4-jdk-18-的改动)
- [HashSet](#hashset)
- [1. 成员变量](#1-成员变量)
- [2. 构造函数](#2-构造函数)
- [3. add()](#3-add)
- [4. 总结](#4-总结)
- [LinkedHashSet and LinkedHashMap](#LinkedHashSet-and-LinkedHashMap)
- [1. 概览](#1-概览-2)
- [2. get()](#2-get)
- [3. put()](#3-put)
- [4. remove()](#4-remove)
- [5. LinkedHashSet](#5-LinkedHashSet)
- [6. LinkedHashMap经典用法](#6-LinkedHashMap经典用法)
- [迭代器模式](#迭代器模式)
- [适配器模式](#适配器模式)
- [1. ArrayList和LinkedList区别](#1-ArrayList和LinkedList区别)
- [2. HashMap和Hashtable区别,HashMap的key类型](#2-HashMap和Hashtable区别HashMap的key类型)
- [3. HashMap和ConcurrentHashMap](#3-HashMap和ConcurrentHashMap)
- [4. Hashtable的原理](#4-Hashtable的原理)
- [5. Hash冲突的解决办法](#5-hash冲突的解决办法)
- [6. 什么是迭代器](#6-什么是迭代器)
- [7. 构造相同hash的字符串进行攻击,这种情况应该怎么处理?JDK7如何处理](#7-构造相同hash的字符串进行攻击这种情况应该怎么处理jdk7如何处理)
- [8. HashMap为什么大小是2的幂次](#8-HashMap为什么大小是2的幂次)
前言
Java集合框架 (Java Collections Framework, JCF) 也称容器,这里可以类比 C++ 中的 STL,在市面上似乎还没能找到一本详细介绍的书籍。在这里主要对如下部分进行源码分析,及在面试中常见的问题。
例如,在阿里面试常问到的 HashMap 和 ConcurrentHashMap 原理等等。深入源码分析是面试中必备的技能,通过本文的阅读会对集合框架有更深一步的了解。
本文参考:
- CarpenterLee/JCFInternals: 深入理解Java集合框架
- crossoverJie/Java-Interview: 👨🎓 Java related : basic, concurrent, algorithm
- Interview-Notebook/Java 容器.md at master · CyC2018/Interview-Notebook
一、概述
Java集合框架提供了数据持有对象的方式,提供了对数据集合的操作。Java 集合框架位于 java.util 包下,主要有三个大类:Collection(接口)、Map(接口)、集合工具类。
集合框架图

Collection
ArrayList:线程不同步。默认初始容量为 10,当数组大小不足时容量扩大为 1.5 倍。为追求效率,ArrayList 没有实现同步(synchronized),如果需要多个线程并发访问,用户可以手动同步,也可使用 Vector 替代。LinkedList:线程不同步。双向链接实现。LinkedList 同时实现了 List 接口和 Deque 接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(Stack)。这样看来,LinkedList 简直就是个全能冠军。当你需要使用栈或者队列时,可以考虑使用 LinkedList,一方面是因为 Java 官方已经声明不建议使用 Stack 类,更遗憾的是,Java 里根本没有一个叫做 Queue 的类(它是个接口名字)。关于栈或队列,现在的首选是 ArrayDeque,它有着比 LinkedList(当作栈或队列使用时)有着更好的性能。Stack and Queue:Java 里有一个叫做 Stack 的类,却没有叫做 Queue 的类(它是个接口名字)。当需要使用栈时,Java 已不推荐使用 Stack,而是推荐使用更高效的 ArrayDeque;既然 Queue 只是一个接口,当需要使用队列时也就首选 ArrayDeque 了(次选是 LinkedList )。Vector:线程同步。默认初始容量为 10,当数组大小不足时容量扩大为 2 倍。它的同步是通过Iterator方法加synchronized实现的。Stack:线程同步。继承自 Vector,添加了几个方法来完成栈的功能。现在已经不推荐使用 Stack,在栈和队列中有限使用 ArrayDeque,其次是 LinkedList。TreeSet:线程不同步,内部使用NavigableMap操作。默认元素 “自然顺序” 排列,可以通过Comparator改变排序。TreeSet 里面有一个 TreeMap(适配器模式)HashSet:线程不同步,内部使用 HashMap 进行数据存储,提供的方法基本都是调用 HashMap 的方法,所以两者本质是一样的。集合元素可以为 NULL。Set:Set 是一种不包含重复元素的 Collection,Set 最多只有一个 null 元素。Set 集合通常可以通过 Map 集合通过适配器模式得到。PriorityQueue:Java 中 PriorityQueue 实现了 Queue 接口,不允许放入 null 元素;其通过堆实现,具体说是通过完全二叉树(complete binary tree)实现的小顶堆(任意一个非叶子节点的权值,都不大于其左右子节点的权值),也就意味着可以通过数组来作为 PriorityQueue 的底层实现。- 优先队列的作用是能保证每次取出的元素都是队列中权值最小的(Java 的优先队列每次取最小元素,C++ 的优先队列每次取最大元素)。这里牵涉到了大小关系,元素大小的评判可以通过元素本身的自然顺序(natural ordering),也可以通过构造时传入的比较器(Comparator,类似于 C++ 的仿函数)。
NavigableSet:添加了搜索功能,可以对给定元素进行搜索:小于、小于等于、大于、大于等于,放回一个符合条件的最接近给定元素的 key。EnumSet:线程不同步。内部使用 Enum 数组实现,速度比HashSet快。只能存储在构造函数传入的枚举类的枚举值。
注释:更多设计模式,请转向 Java 设计模式
Map
TreeMap:线程不同步,基于 红黑树 (Red-Black tree)的 NavigableMap 实现,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。- TreeMap 底层通过红黑树(Red-Black tree)实现,也就意味着
containsKey(),get(),put(),remove()都有着log(n)的时间复杂度。其具体算法实现参照了《算法导论》。
- TreeMap 底层通过红黑树(Red-Black tree)实现,也就意味着
Hashtable:线程安全,HashMap 的迭代器 \(Iterator\) 是fail-fast迭代器。Hashtable 不能存储 NULL 的 key 和 value。HashMap:线程不同步。根据key的hashcode进行存储,内部使用静态内部类Node的数组进行存储,默认初始大小为 16,每次扩大一倍。当发生 Hash 冲突时,采用拉链法(链表)。JDK 1.8中:当单个桶中元素个数大于等于8时,链表实现改为红黑树实现;当元素个数小于6时,变回链表实现。由此来防止hashCode攻击。- Java HashMap 采用的是冲突链表方式。
- HashMap 是 Hashtable 的轻量级实现,可以接受为 null 的键值 (key\) 和值 \(value\),而 Hashtable 不允许。
LinkedHashMap:保存了记录的插入顺序,在用 Iterator 遍历 LinkedHashMap 时,先得到的记录肯定是先插入的。也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比 HashMap 慢,不过有种情况例外,当 HashMap 容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap 慢,因为 LinkedHashMap 的遍历速度只和实际数据有关,和容量无关,而 HashMap 的遍历速度和他的容量有关。WeakHashMap:从名字可以看出它是某种 Map。它的特殊之处在于 WeakHashMap 里的 entry 可能会被 GC 自动删除,即使程序员没有调用remove()或者clear()方法。 WeakHashMap 的存储结构类似于HashMap- 既然有 WeekHashMap,是否有 WeekHashSet 呢?答案是没有!不过 Java Collections 工具类给出了解决方案,
Collections.newSetFromMap(Map<E,Boolean> map)方法可以将任何 Map包装成一个Set。
- 既然有 WeekHashMap,是否有 WeekHashSet 呢?答案是没有!不过 Java Collections 工具类给出了解决方案,
工具类
Collections、Arrays:集合类的一个工具类帮助类,其中提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。
Comparable、Comparator:一般是用于对象的比较来实现排序,两者略有区别。
- 类设计者没有考虑到比较问题而没有实现 Comparable 接口。这是我们就可以通过使用 Comparator,这种情况下,我们是不需要改变对象的。 - 一个集合中,我们可能需要有多重的排序标准,这时候如果使用 Comparable 就有些捉襟见肘了,可以自己继承 Comparator 提供多种标准的比较器进行排序。
说明:线程不同步的时候可以通过,Collections.synchronizedList() 方法来包装一个线程同步方法
通用实现
| Implementations | ||||||
|---|---|---|---|---|---|---|
| Hash Table | Resizable Array | Balanced Tree | Linked List | Hash Table + Linked List | ||
| Interfaces | Set | HashSet | TreeSet | LinkedHashSet | ||
| List | ArrayList | LinkedList | ||||
| Deque | ArrayDeque | LinkedList | ||||
| Map | HashMap | TreeMap | LinkedHashMap | |||
参考资料:
二、深入源码分析
源码分析基于 JDK 1.8 / JDK 1.7,在 IDEA 中 double shift 调出 Search EveryWhere,查找源码文件,找到之后就可以阅读源码。
ArrayList
1. 概览
实现了 RandomAccess 接口,因此支持随机访问,这是理所当然的,因为 ArrayList 是基于数组实现的。
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
数组的默认大小为 10。
private static final int DEFAULT_CAPACITY = 10;

2. 序列化
基于数组实现,保存元素的数组使用 transient 修饰,该关键字声明数组默认不会被序列化。ArrayList 具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。ArrayList 重写了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。
transient Object[] elementData; // non-private to simplify nested class access
3. 扩容
添加元素时使用 ensureCapacityInternal() 方法来保证容量足够,如果不够时,需要使用 grow() 方法进行扩容,新容量的大小为 oldCapacity + (oldCapacity >> 1),也就是旧容量的 1.5 倍。
扩容操作需要调用 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。
// JDK 1.8
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
// 判断数组是否越界
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
// 扩容
private void grow(int minCapacity) {
// overflow-conscious code
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);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
4. 删除元素
需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上。
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
5. Fail-Fast
开始之前我们想讲讲,什么是 fail-fast 机制?
fail-fast 机制在遍历一个集合时,当集合结构被修改,会抛出 Concurrent Modification Exception。
fail-fast 会在以下两种情况下抛出 Concurrent Modification Exception
(1)单线程环境
- 集合被创建后,在遍历它的过程中修改了结构。
- 注意 remove() 方法会让 expectModcount 和 modcount 相等,所以是不会抛出这个异常。
(2)多线程环境
- 当一个线程在遍历这个集合,而另一个线程对这个集合的结构进行了修改。
modCount 用来记录 ArrayList 结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化。
在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出 Concurrent Modification Exception。
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
Vector
1. 同步
它的实现与 ArrayList 类似,但是使用了 synchronized 进行同步。
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
2. ArrayList 与 Vector
- Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
- Vector 每次扩容请求其大小的 2 倍空间,而 ArrayList 是 1.5 倍。
3. Vector 替代方案
synchronizedList
为了获得线程安全的 ArrayList,可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList。
List<String> list = new ArrayList<>();
List<String> synList = Collections.synchronizedList(list);
CopyOnWriteArrayList

也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。
List<String> list = new CopyOnWriteArrayList<>();
CopyOnWrite 容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。
public boolean add(T e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 复制出新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 把新元素添加到新数组里
newElements[len] = e;
// 把原数组引用指向新数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
final void setArray(Object[] a) {
array = a;
}
读的时候不需要加锁,如果读的时候有多个线程正在向 ArrayList 添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的 ArrayList。
public E get(int index) {
return get(getArray(), index);
}
CopyOnWrite的缺点
- CopyOnWrite 容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题。
- 因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 300M,那么这个时候很有可能造成频繁的 Yong GC 和 Full GC。之前我们系统中使用了一个服务由于每晚使用 CopyOnWrite 机制更新大对象,造成了每晚 15 秒的 Full GC,应用响应时间也随之变长。
- 针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是 10 进制的数字,可以考虑把它压缩成 36 进制或 64 进制。或者不使用 CopyOnWrite 容器,而使用其他的并发容器,如 ConcurrentHashMap 。
数据一致性问题。
- CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。
- 关于 C++ 的 STL 中,曾经也有过 Copy-On-Write 的玩法,参见陈皓的《C++ STL String类中的Copy-On-Write》,后来,因为有很多线程安全上的事,就被去掉了。
参考资料:
LinkedList

1. 概览
LinkedList 底层是基于双向链表实现的,也是实现了 List 接口,所以也拥有 List 的一些特点 (JDK1.7/8 之后取消了循环,修改为双向链表) 。
LinkedList 同时实现了 List 接口和 Deque 接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(Stack)。这样看来, LinkedList 简直就是个全能冠军。当你需要使用栈或者队列时,可以考虑使用 LinkedList ,一方面是因为 Java 官方已经声明不建议使用 Stack 类,更遗憾的是,Java里根本没有一个叫做 Queue 的类(它是个接口名字)。
关于栈或队列,现在的首选是 ArrayDeque,它有着比 LinkedList (当作栈或队列使用时)有着更好的性能。
基于双向链表实现,内部使用 Node 来存储链表节点信息。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
每个链表存储了 Head 和 Tail 指针:
transient Node<E> first;
transient Node<E> last;
LinkedList 的实现方式决定了所有跟下标相关的操作都是线性时间,而在首段或者末尾删除元素只需要常数时间。为追求效率LinkedList没有实现同步(synchronized),如果需要多个线程并发访问,可以先采用 Collections.synchronizedList() 方法对其进行包装。
2. add()

add() 方法有两个版本,一个是 add(E e),该方法在 LinkedList 的末尾插入元素,因为有 last 指向链表末尾,在末尾插入元素的花费是常数时间。只需要简单修改几个相关引用即可;另一个是 add(int index, E element),该方法是在指定下表处插入元素,需要先通过线性查找找到具体位置,然后修改相关引用完成插入操作。
// JDK 1.8
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* Links e as last element.
*/
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
add(int index, E element) 的逻辑稍显复杂,可以分成两部分
- 先根据 index 找到要插入的位置;
- 修改引用,完成插入操作。
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
private void checkPositionIndex(int index) {
if (!isPositionIndex(index))
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
上面代码中的 node(int index) 函数有一点小小的 trick,因为链表双向的,可以从开始往后找,也可以从结尾往前找,具体朝那个方向找取决于条件 index < (size >> 1),也即是 index 是靠近前端还是后端。
3. remove()
remove() 方法也有两个版本,一个是删除跟指定元素相等的第一个元素 remove(Object o),另一个是删除指定下标处的元素 remove(int index)。

两个删除操作都要:
- 先找到要删除元素的引用;
- 修改相关引用,完成删除操作。
在寻找被删元素引用的时候 remove(Object o) 调用的是元素的 equals 方法,而 remove(int index) 使用的是下标计数,两种方式都是线性时间复杂度。在步骤 2 中,两个 revome() 方法都是通过 unlink(Node<E> x) 方法完成的。这里需要考虑删除元素是第一个或者最后一个时的边界情况。
4. get()
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
由此可以看出是使用二分查找来看 index 离 size 中间距离来判断是从头结点正序查还是从尾节点倒序查。
- node() 会以
O(n/2)的性能去获取一个结点- 如果索引值大于链表大小的一半,那么将从尾结点开始遍历
这样的效率是非常低的,特别是当 index 越接近 size 的中间值时。
5. 总结
- LinkedList 插入,删除都是移动指针效率很高。
- 查找需要进行遍历查询,效率较低。
6. ArrayList 与 LinkedList
- ArrayList 基于动态数组实现,LinkedList 基于双向链表实现;
- ArrayList 支持随机访问,LinkedList 不支持;
- LinkedList 在任意位置添加删除元素更快。
HashMap
我们这篇文章就来试着分析下 HashMap 的源码,由于 HashMap 底层涉及到太多方面,一篇文章总是不能面面俱到,所以我们可以带着面试官常问的几个问题去看源码:
- 了解底层如何存储数据的
- HashMap 的几个主要方法
- HashMap 是如何确定元素存储位置的以及如何处理哈希冲突的
- HashMap 扩容机制是怎样的
- JDK 1.8 在扩容和解决哈希冲突上对 HashMap 源码做了哪些改动?有什么好处?
HashMap 的内部功能实现很多,本文主要从根据 key 获取哈希桶数组索引位置、put 方法的详细执行、扩容过程三个具有代表性的点深入展开讲解。
1. 存储结构
JDK1.7 的存储结构
在 1.7 之前 JDK 采用「拉链法」来存储数据,即数组和链表结合的方式:

「拉链法」用专业点的名词来说叫做链地址法。简单来说,就是数组加链表的结合。在每个数组元素上存储的都是一个链表。
我们之前说到不同的 key 可能经过 hash 运算可能会得到相同的地址,但是一个数组单位上只能存放一个元素,采用链地址法以后,如果遇到相同的 hash 值的 key 的时候,我们可以将它放到作为数组元素的链表上。待我们去取元素的时候通过 hash 运算的结果找到这个链表,再在链表中找到与 key 相同的节点,就能找到 key 相应的值了。
JDK1.7 中新添加进来的元素总是放在数组相应的角标位置,而原来处于该角标的位置的节点作为 next 节点放到新节点的后边。稍后通过源码分析我们也能看到这一点。
JDK1.8 的存储结构
对于 JDK1.8 之后的 HashMap 底层在解决哈希冲突的时候,就不单单是使用数组加上单链表的组合了,因为当处理如果 hash 值冲突较多的情况下,链表的长度就会越来越长,此时通过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),因此在 JDK1.8 之后,在链表新增节点导致链表长度超过 TREEIFY_THRESHOLD = 8 的时候,就会在添加元素的同时将原来的单链表转化为红黑树。
对数据结构很在行的读者应该,知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn) 级别,所以利用红黑树的特点就可以更高效的对 HashMap 中的元素进行操作。

从结构实现来讲,HashMap 是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。

这里需要讲明白两个问题:数据底层具体存储的是什么?这样的存储方式有什么优点呢?
(1)从源码可知,HashMap 类中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,明显它是一个 Node 的数组。我们来看 Node( JDK1.8 中) 是何物。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node<K,V> next; //链表的下一个node
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
Node 是 HashMap 的一个内部类,实现了 Map.Entry 接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。
(2)HashMap 就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题, Java 中 HashMap 采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被 Hash 后,得到数组下标,把数据放在对应下标元素的链表上。例如程序执行下面代码:
map.put("美团","小美");
系统将调用 "美团" 这个 key 的 hashCode() 方法得到其 hashCode 值(该方法适用于每个 Java 对象),然后再通过 Hash 算法的后两步运算(高位运算和取模运算,下文有介绍)来定位该键值对的存储位置,有时两个 key 会定位到相同的位置,表示发生了 Hash 碰撞。当然 Hash 算法计算结果越分散均匀,Hash 碰撞的概率就越小,map 的存取效率就会越高。
如果哈希桶数组很大,即使较差的 Hash 算法也会比较分散,如果哈希桶数组数组很小,即使好的 Hash 算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的 hash 算法减少 Hash 碰撞。
那么通过什么方式来控制 map 使得 Hash 碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?
答案就是好的 Hash 算法和扩容机制。
在理解 Hash 和扩容流程之前,我们得先了解下 HashMap 的几个字段。从 HashMap 的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:
int threshold; // 所能容纳的key-value对极限
final float loadFactor; // 负载因子
int modCount;
int size;
首先,Node[] table的初始化长度 length (默认值是16),Load factor 为负载因子(默认值是0.75),threshold 是 HashMap 所能容纳的最大数据量的 Node (键值对)个数。**threshold = length Load factor*。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold 就是在此 Load factor 和 length (数组长度)对应下允许的最大元素数目,超过这个数目就重新 resize(扩容),扩容后的 HashMap 容量是之前容量的两倍。默认的负载因子 0.75 是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子 Load factor 的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子 loadFactor 的值,这个值可以大于1。
size 这个字段其实很好理解,就是 HashMap 中实际存在的键值对数量。注意和 table 的长度 length、容纳最大键值对数量 threshold 的区别。而 modCount 字段主要用来记录 HashMap 内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如 put 新键值对,但是某个 key 对应的 value 值被覆盖不属于结构变化。
在 HashMap 中,哈希桶数组 table 的长度 length 大小必须为 2n(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考 为什么一般hashtable的桶数会取一个素数? ,Hashtable 初始化桶大小为 11,就是桶大小设计为素数的应用(Hashtable 扩容后不能保证还是素数)。HashMap 采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap 定位哈希桶索引位置时,也加入了高位参与运算的过程。
这里存在一个问题,即使负载因子和 Hash 算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。于是,在 JDK1.8 版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高 HashMap 的性能,其中会用到红黑树的插入、删除、查找等算法。本文不再对红黑树展开讨论,想了解更多红黑树数据结构的工作原理可以参考:教你初步了解红黑树。
2. 重要参数
| 参数 | 说明 |
|---|---|
| buckets | 在 HashMap 的注释里使用哈希桶来形象的表示数组中每个地址位置。注意这里并不是数组本身,数组是装哈希桶的,他可以被称为哈希表。 |
| capacity | table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。 |
| size | table 的实际使用量。 |
| threshold | size 的临界值,size 必须小于 threshold,如果大于等于,就必须进行扩容操作。 |
| loadFactor | 装载因子,table 能够使用的比例,threshold = capacity * loadFactor。 |
| TREEIFY_THRESHOLD | 树化阀值,哈希桶中的节点个数大于该值(默认为8)的时候将会被转为红黑树行存储结构。 |
| UNTREEIFY_THRESHOLD | 非树化阀值,小于该值(默认为 6)的时候将再次改为单链表的格式存储 |
3. 确定哈希桶数组索引位置
很多操作都需要先确定一个键值对所在的桶下标。
int hash = hash(key);
int i = indexFor(hash, table.length);
(一)计算 hash 值
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
(二)取模
令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:
x : 00010000
x-1 : 00001111
令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010
这个性质和 y 对 x 取模效果是一样的:
y : 10110010
x : 00010000
y%x : 00000010
我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。
确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。
static int indexFor(int h, int length) {
return h & (length-1);
}
4. 分析HashMap的put方法
HashMap 的 put 方法执行过程可以通过下图来理解,自己有兴趣可以去对比源码更清楚地研究学习。

①.判断键值对数组 table[i] 是否为空或为 null,否则执行 resize() 进行扩容;
②.根据键值 key 计算 hash 值得到插入的数组索引i,如果 table[i]==null,直接新建节点添加,转向 ⑥,如果table[i] 不为空,转向 ③;
③.判断 table[i] 的首个元素是否和 key 一样,如果相同直接覆盖 value,否则转向 ④,这里的相同指的是 hashCode 以及 equals;
④.判断table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向 ⑤;
⑤.遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
⑥.插入成功后,判断实际存在的键值对数量 size 是否超多了最大容量 threshold,如果超过,进行扩容。
JDK1.8 HashMap 的 put 方法源码如下:
public V put(K key, V value) {
// 对key的hashCode()做hash
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> 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<K,V> 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<K,V>)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;
}
5. 扩容机制
扩容 (resize) 就是重新计算容量,向 HashMap 对象里不停的添加元素,而 HashMap 对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然 Java 里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下 resize 的源码,鉴于 JDK1.8 融入了红黑树,较复杂,为了便于理解我们仍然使用 JDK1.7 的代码,好理解一些,本质上区别不大,具体区别后文再说。
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);//修改阈值
}
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer() 方法将原有 Entry 数组的元素拷贝到新的 Entry 数组里。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
newTable[i] 的引用赋给了 e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话),这一点和 Jdk1.8 有区别,下文详解。在旧数组中同一条 Entry 链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
下面举个例子说明下扩容过程。假设了我们的 hash 算法就是简单的用 key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组 table 的 size=2, 所以 key = 3、7、5,put 顺序依次为 5、7、3。在 mod 2 以后都冲突在 table[1] 这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于 table 的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize 成 4,然后所有的 Node 重新 rehash 的过程。

下面我们讲解下 JDK1.8 做了哪些优化。经过观测可以发现,我们使用的是 2 次幂的扩展 (指长度扩为原来 2 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。看下图可以明白这句话的意思,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的示例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。

元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit (红色),因此新的 index 就会发生这样的变化:

因此,我们在扩充 HashMap 的时候,不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap”,可以看看下图为 16 扩充为 32 的 resize 示意图:

这个设计确实非常的巧妙,既省去了重新计算 hash 值的时间,而且同时,由于新增的 1bit 是 0 还是 1 可以认为是随机的,因此 resize 的过程,均匀的把之前的冲突的节点分散到新的 bucket 了。这一块就是 JDK1.8 新增的优化点。有一点注意区别,JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8 不会倒置。有兴趣的同学可以研究下 JDK1.8 的 resize源 码,写的很赞,如下:
final Node<K,V>[] resize() {
Node<K,V>[] 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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表优化重hash的代码块
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
6. 线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的 HashMap,而使用线程安全的 ConcurrentHashMap。那么为什么说 HashMap 是线程不安全的,下面举例子说明在并发的多线程使用场景中使用 HashMap 可能造成死循环。代码例子如下(便于理解,仍然使用 JDK1.7 的环境):
public class HashMapInfiniteLoop {
private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
public static void main(String[] args) {
map.put(5, "C");
new Thread("Thread1") {
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2") {
public void run() {
map.put(3, "A);
System.out.println(map);
};
}.start();
}
}
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法(3.3小节代码块)的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。

注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。


e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

于是,当我们用线程一调用map.get(11)时,悲剧就出现了——Infinite Loop。
7. JDK1.8与JDK1.7的性能对比
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7,下面我们从两个方面用例子证明这一点。
8. Hash较均匀的情况
为了便于测试,我们先写一个类Key,如下:
class Key implements Comparable<Key> {
private final int value;
Key(int value) {
this.value = value;
}
@Override
public int compareTo(Key o) {
return Integer.compare(this.value, o.value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass())
return false;
Key key = (Key) o;
return value == key.value;
}
@Override
public int hashCode() {
return value;
}
}
这个类复写了equals方法,并且提供了相当好的hashCode函数,任何一个值的hashCode都不会相同,因为直接使用value当做hashcode。为了避免频繁的GC,我将不变的Key实例缓存了起来,而不是一遍一遍的创建它们。代码如下:
public class Keys {
public static final int MAX_KEY = 10_000_000;
private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
static {
for (int i = 0; i < MAX_KEY; ++i) {
KEYS_CACHE[i] = new Key(i);
}
}
public static Key of(int value) {
return KEYS_CACHE[value];
}
}
现在开始我们的试验,测试需要做的仅仅是,创建不同size的HashMap(1、10、100、......10000000),屏蔽了扩容的情况,代码如下:
static void test(int mapSize) {
HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
for (int i = 0; i < mapSize; ++i) {
map.put(Keys.of(i), i);
}
long beginTime = System.nanoTime(); //获取纳秒
for (int i = 0; i < mapSize; i++) {
map.get(Keys.of(i));
}
long endTime = System.nanoTime();
System.out.println(endTime - beginTime);
}
public static void main(String[] args) {
for(int i=10;i<= 1000 0000;i*= 10){
test(i);
}
}
在测试中会查找不同的值,然后度量花费的时间,为了计算getKey的平均时间,我们遍历所有的get方法,计算总的时间,除以key的数量,计算一个平均值,主要用来比较,绝对值可能会受很多环境因素的影响。结果如下:

通过观测测试结果可知,JDK1.8的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。由于Hash算法较均匀,JDK1.8引入的红黑树效果不明显,下面我们看看Hash不均匀的的情况。
9. Hash极不均匀的情况
假设我们又一个非常差的Key,它们所有的实例都返回相同的hashCode值。这是使用HashMap最坏的情况。代码修改如下:
class Key implements Comparable<Key> {
//...
@Override
public int hashCode() {
return 1;
}
}
仍然执行main方法,得出的结果如下表所示:

从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树,这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。
测试环境:处理器为2.2 GHz Intel Core i7,内存为16 GB 1600 MHz DDR3,SSD硬盘,使用默认的JVM参数,运行在64位的OS X 10.10.1上。
10. HashMap与Hashtable
- Hashtable 使用 synchronized 来进行同步。
- HashMap 可以插入键为 null 的 Entry。
- HashMap 的迭代器是 fail-fast 迭代器。
- HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
11. 小结
- 扩容是一个特别耗性能的操作,所以当程序员在使用 HashMap 的时候,估算 map 的大小,初始化的时候给一个大致的数值,避免 map 进行频繁的扩容。
- 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
- HashMap 是线程不安全的,不要在并发的环境中同时操作 HashMap,建议使用 ConcurrentHashMap。
- JDK1.8 引入红黑树大程度优化了 HashMap 的性能。
参考资料:
ConcurrentHashMap
1. 概述
众所周知,哈希表是中非常高效,复杂度为 O(1) 的数据结构,在 Java 开发中,我们最常见到最频繁使用的就是 HashMap 和 Hashtable,但是在线程竞争激烈的并发场景中使用都不够合理。
HashMap :先说 HashMap,HashMap 是线程不安全的,在并发环境下,可能会形成环状链表(扩容时可能造成),导致 get 操作时,cpu 空转,所以,在并发环境中使 用HashMap 是非常危险的。
Hashtable : Hashtable 和 HashMap的实现原理几乎一样,差别无非是:(1)Hashtable不允许key和value为null;(2)Hashtable是线程安全的。
但是 Hashtable 线程安全的策略实现代价却太大了,简单粗暴,get/put 所有相关操作都是 synchronized 的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

Hashtable 性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap 所采用的 "分段锁" 思想。

2. 存储结构
ConcurrentHashMap 采用了非常精妙的"分段锁"策略,ConcurrentHashMap 的主干是个 Segment 数组。
final Segment<K,V>[] segments;
Segment 继承了 ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在 ConcurrentHashMap,一个 Segment 就是一个子哈希表,Segment 里维护了一个 HashEntry 数组,并发环境下,对于不同 Segment 的数据进行操作是不用考虑锁竞争的。(就按默认的 ConcurrentLeve 为16来讲,理论上就允许 16 个线程并发执行,有木有很酷)
所以,对于同一个 Segment 的操作才需考虑线程同步,不同的 Segment 则无需考虑。
Segment 类似于 HashMap,一个 Segment 维护着一个 HashEntry 数组
transient volatile HashEntry<K,V>[] table;
HashEntry 是目前我们提到的最小的逻辑处理单元了。一个 ConcurrentHashMap 维护一个 Segment 数组,一个 Segment 维护一个 HashEntry 数组。
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。
Segment 继承自 ReentrantLock。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
}
final Segment<K,V>[] segments;
默认的并发级别为 16,也就是说默认创建 16 个 Segment。
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
2. size 操作
每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。
/**
* The number of elements. Accessed only either within locks
* or among other volatile reads that maintain visibility.
*/
transient int count;
在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。
ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。
尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。
如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。
/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
static final int RETRIES_BEFORE_LOCK = 2;
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 超过尝试次数,则对每个 Segment 加锁
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 连续两次得到的结果一致,则认为这个结果是正确的
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
3. 同步方式
Segment 继承自 ReentrantLock,所以我们可以很方便的对每一个 Segment 上锁。
对于读操作,获取 Key 所在的 Segment 时,需要保证可见性。具体实现上可以使用 volatile 关键字,也可使用锁。但使用锁开销太大,而使用 volatile 时每次写操作都会让所有 CPU 内缓存无效,也有一定开销。ConcurrentHashMap 使用如下方法保证可见性,取得最新的 Segment。
Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)
获取 Segment 中的 HashEntry 时也使用了类似方法
HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE)
对于写操作,并不要求同时获取所有 Segment 的锁,因为那样相当于锁住了整个 Map。它会先获取该 Key-Value 对所在的 Segment 的锁,获取成功后就可以像操作一个普通的 HashMap 一样操作该 Segment,并保证该Segment 的安全性。 同时由于其它 Segment 的锁并未被获取,因此理论上可支持 concurrencyLevel(等于 Segment 的个数)个线程安全的并发读写。
获取锁时,并不直接使用 lock 来获取,因为该方法获取锁失败时会挂起。事实上,它使用了自旋锁,如果 tryLock 获取锁失败,说明锁被其它线程占用,此时通过循环再次以 tryLock 的方式申请锁。如果在循环过程中该 Key 所对应的链表头被修改,则重置 retry 次数。如果 retry 次数超过一定值,则使用 lock 方法申请锁。
这里使用自旋锁是因为自旋锁的效率比较高,但是它消耗 CPU 资源比较多,因此在自旋次数超过阈值时切换为互斥锁。
4. JDK 1.8 的改动
- JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发程度与 Segment 数量相等。
- JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。
- 并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。
参考资料:
HashSet
前面已经说过 HashSet 是对 HashMap 的简单包装,对 HashSet 的函数调用都会转换成合适的 HashMap 方法,因此 HashSet 的实现非常简单,只有不到 300 行代码(适配器模式)。这里不再赘述。
//HashSet是对HashMap的简单包装
public class HashSet<E>
{
......
private transient HashMap<E,Object> map;//HashSet里面有一个HashMap
// 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;
}
......
}
1. 成员变量
首先了解下 HashSet 的成员变量:
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
发现主要就两个变量:
map:用于存放最终数据的。PRESENT:是所有写入 map 的value值。
2. 构造函数
public HashSet() {
map = new HashMap<>();
}
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
构造函数很简单,利用了 HashMap 初始化了 map 。
3. add()
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
比较关键的就是这个 add() 方法。 可以看出它是将存放的对象当做了 HashMap 的健,value 都是相同的 PRESENT 。由于 HashMap 的 key 是不能重复的,所以每当有重复的值写入到 HashSet 时,value 会被覆盖,但 key 不会收到影响,这样就保证了 HashSet 中只能存放不重复的元素。
4. 总结
HashSet 的原理比较简单,几乎全部借助于 HashMap 来实现的。
所以 HashMap 会出现的问题 HashSet 依然不能避免。
LinkedHashSet and LinkedHashMap
1. 概览
如果你已看过前面关于 HashSet 和 HashMap,的讲解,一定能够想到本文将要讲解的 LinkedHashSet 和 LinkedHashMap 其实也是一回事。 LinkedHashSet 和 LinkedHashMap 在 Java 里也有着相同的实现,前者仅仅是对后者做了一层包装,也就是说 LinkedHashSet 里面有一个 LinkedHashMap(适配器模式)。因此本文将重点分析 LinkedHashMap。
LinkedHashMap 实现了 Map 接口,即允许放入 key 为 null 的元素,也允许插入 value 为 null 的元素。从名字上可以看出该容器是 LinkedList 和 HashMap 的混合体,也就是说它同时满足 HashMap 和 LinkedList 的某些特性。可将 LinkedHashMap 看作采用 LinkedList 增强的 HashMap。

事实上 LinkedHashMap 是 HashMap 的直接子类,二者唯一的区别是 LinkedHashMap 在 HashMap 的基础上,采用双向链表(doubly-linked list)的形式将所有 entry 连接起来,这样是为保证元素的迭代顺序跟插入顺序相同。上图给出了 LinkedHashMap 的结构图,主体部分跟 HashMap 完全一样,多了 header 指向双向链表的头部(是一个哑元),该双向链表的迭代顺序就是 entry 的插入顺序。
除了可以保迭代历顺序,这种结构还有一个好处:迭代 LinkedHashMap 时不需要像 HashMap 那样遍历整个table,而只需要直接遍历 header 指向的双向链表即可,也就是说 LinkedHashMap 的迭代时间就只跟entry的个数相关,而跟table的大小无关。
有两个参数可以影响 LinkedHashMap 的性能:初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table的大小,负载系数用来指定自动扩容的临界值。当entry的数量超过capacity*load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。
将对象放入到 LinkedHashMap 或 LinkedHashSet 中时,有两个方法需要特别关心:hashCode() 和 equals()。hashCode() 方法决定了对象会被放到哪个 bucket 里,当多个对象的哈希值冲突时,equals() 方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到 LinkedHashMap 或 LinkedHashSet 中,需要 @OverridehashCode() 和 equals() 方法。
通过如下方式可以得到一个跟源 Map 迭代顺序 一样的 LinkedHashMap:
void foo(Map m) {
Map copy = new LinkedHashMap(m);
...
}
出于性能原因,LinkedHashMap 是非同步的(not synchronized),如果需要在多线程环境使用,需要程序员手动同步;或者通过如下方式将 LinkedHashMap 包装成(wrapped)同步的:
Map m = Collections.synchronizedMap(new LinkedHashMap(...));
2. get()
get(Object key) 方法根据指定的 key 值返回对应的 value。该方法跟HashMap.get()方法的流程几乎完全一样,读者可自行参考前文,这里不再赘述。
3. put()
put(K key, V value) 方法是将指定的 key, value 对添加到 map 里。该方法首先会对 map 做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于get()方法;如果没有找到,则会通过 addEntry(int hash, K key, V value, int bucketIndex) 方法插入新的 entry。
注意,这里的插入有两重含义:
1. 从 table 的角度看,新的 entry 需要插入到对应的 bucket 里,当有哈希冲突时,采用头插法将新的 entry 插入到冲突链表的头部。 2. 从 header 的角度看,新的 entry 需要插入到双向链表的尾部。

addEntry()代码如下:
// LinkedHashMap.addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);// 自动扩容,并重新哈希
hash = (null != key) ? hash(key) : 0;
bucketIndex = hash & (table.length-1);// hash%table.length
}
// 1.在冲突链表头部插入新的entry
HashMap.Entry<K,V> old = table[bucketIndex];
Entry<K,V> e = new Entry<>(hash, key, value, old);
table[bucketIndex] = e;
// 2.在双向链表的尾部插入新的entry
e.addBefore(header);
size++;
}
上述代码中用到了 addBefore()方 法将新 entry e 插入到双向链表头引用 header 的前面,这样 e 就成为双向链表中的最后一个元素。addBefore() 的代码如下:
// LinkedHashMap.Entry.addBefor(),将this插入到existingEntry的前面
private void addBefore(Entry<K,V> existingEntry) {
after = existingEntry;
before = existingEntry.before;
before.after = this;
after.before = this;
}
上述代码只是简单修改相关 entry 的引用而已。
4. remove()
remove(Object key)的作用是删除key值对应的entry,该方法的具体逻辑是在removeEntryForKey(Object key)里实现的。removeEntryForKey()方法会首先找到key值对应的entry,然后删除该entry(修改链表的相应引用)。查找过程跟get()方法类似。
注意,这里的删除也有两重含义:
1. 从table的角度看,需要将该entry从对应的bucket里删除,如果对应的冲突链表不空,需要修改冲突链表的相应引用。 2. 从header的角度来看,需要将该entry从双向链表中删除,同时修改链表中前面以及后面元素的相应引用。

removeEntryForKey() 对应的代码如下:
// LinkedHashMap.removeEntryForKey(),删除key值对应的entry
final Entry<K,V> removeEntryForKey(Object key) {
......
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);// hash&(table.length-1)
Entry<K,V> prev = table[i];// 得到冲突链表
Entry<K,V> e = prev;
while (e != null) {// 遍历冲突链表
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {// 找到要删除的entry
modCount++; size--;
// 1. 将e从对应bucket的冲突链表中删除
if (prev == e) table[i] = next;
else prev.next = next;
// 2. 将e从双向链表中删除
e.before.after = e.after;
e.after.before = e.before;
return e;
}
prev = e; e = next;
}
return e;
}
5. LinkedHashSet
前面已经说过LinkedHashSet是对LinkedHashMap的简单包装,对LinkedHashSet的函数调用都会转换成合适的LinkedHashMap方法,因此LinkedHashSet的实现非常简单,这里不再赘述。
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
......
// LinkedHashSet里面有一个LinkedHashMap
public LinkedHashSet(int initialCapacity, float loadFactor) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
......
public boolean add(E e) {//简单的方法转换
return map.put(e, PRESENT)==null;
}
......
}
6. LinkedHashMap经典用法
LinkedHashMap 除了可以保证迭代顺序外,还有一个非常有用的用法:可以轻松实现一个采用了FIFO替换策略的缓存。具体说来,LinkedHashMap 有一个子类方法 protected boolean removeEldestEntry(Map.Entry<K,V> eldest),该方法的作用是告诉 Map 是否要删除“最老”的 Entry,所谓最老就是当前 Map 中最早插入的 Entry,如果该方法返回 true,最老的那个元素就会被删除。在每次插入新元素的之后 LinkedHashMap 会自动询问 removeEldestEntry() 是否要删除最老的元素。这样只需要在子类中重载该方法,当元素个数超过一定数量时让 removeEldestEntry() 返回 true,就能够实现一个固定大小的 FIFO 策略的缓存。示例代码如下:
/** 一个固定大小的FIFO替换策略的缓存 */
class FIFOCache<K, V> extends LinkedHashMap<K, V>{
private final int cacheSize;
public FIFOCache(int cacheSize){
this.cacheSize = cacheSize;
}
// 当Entry个数超过cacheSize时,删除最老的Entry
@Override
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > cacheSize;
}
}
三、容器中的设计模式
迭代器模式

Collection 实现了 Iterable 接口,其中的 iterator() 方法能够产生一个 Iterator 对象,通过这个对象就可以迭代遍历 Collection 中的元素。
从 JDK 1.5 之后可以使用 foreach 方法来遍历实现了 Iterable 接口的聚合对象。
List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
for (String item : list) {
System.out.println(item);
}
适配器模式
java.util.Arrays.asList() 可以把数组类型转换为 List 类型。
@SafeVarargs
public static <T> List<T> asList(T... a)
如果要将数组类型转换为 List 类型,应该注意的是 asList() 的参数为泛型的变长参数,因此不能使用基本类型数组作为参数,只能使用相应的包装类型数组。
Integer[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
也可以使用以下方式生成 List。
List list = Arrays.asList(1,2,3);
四、面试指南
1. ArrayList和LinkedList区别
- ArrayList 和 LinkedList 可想从名字分析,它们一个是 Array (动态数组) 的数据结构,一个是 Link (链表) 的数据结构,此外,它们两个都是对 List 接口的实现。前者是数组队列,相当于动态数组;后者为双向链表结构,也可当作堆栈、队列、双端队列;
- 当随机访问 List 时(get和set操作),ArrayList 比 LinkedList的效率更高,因为 LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找;
- 当对数据进行增加和删除的操作时(add 和 remove 操作),LinkedList 比 ArrayList 的效率更高,因为 ArrayList 是数组,所以在其中进行增删操作时,会对操作点之后所有数据的下标索引造成影响,需要进行数据的移动;
- 从利用效率来看,ArrayList 自由性较低,因为它需要手动的设置固定大小的容量,但是它的使用比较方便,只需要创建,然后添加数据,通过调用下标进行使用;而 LinkedList 自由性较高,能够动态的随数据量的变化而变化,但是它不便于使用;
- ArrayList 主要空间开销在于需要在 List 列表预留一定空间;而 LinkList 主要控件开销在于需要存储结点信息以及结点指针信息。
- ArrayList、LinkedList 和 Vector如何选择?
- 当对数据的主要操作为索引或只在集合的末端增加、删除元素时,使用 ArrayList 或 Vector 效率比较高;
- 当对数据的操作主要为指定位置的插入或删除操作时,使用 LinkedList 效率比较高;
- 当在多线程中使用容器时(即多个线程会同时访问该容器),选用 Vector 较为安全;
2. HashMap和Hashtable区别,HashMap的key类型
- HashMap和Hashtable的区别
- Hashtable 的方法是同步的,HashMap 非同步,所以在多线程场合要手动同步。
- Hashtable 不允许 null 值 (key 和 value 都不可以),HashMap 允许 null 值( key 和 value 都可以)。
- 两者的遍历方式大同小异,Hashtable 仅仅比 HashMap 多一个 elements 方法。
- Hashtable 和 HashMap 都能通过 values() 方法返回一个 Collection ,然后进行遍历处理。
- 两者也都可以通过 entrySet() 方法返回一个 Set , 然后进行遍历处理。
- Hashtable 使用 Enumeration,HashMap 使用 Iterator。
- 哈希值的使用不同,Hashtable 直接使用对象的 hashCode。而 HashMap 重新计算hash值,而且用于代替求模。
- Hashtable 中 hash 数组默认大小是11,增加的方式是 old*2+1。HashMap 中 hash 数组的默认大小是16,而且一定是 2 的指数。
- Hashtable 基于 Dictionary 类,而 HashMap 基于 AbstractMap 类
- HashMap中的key可以是任何对象或数据类型吗
- 可以为null,但不能是可变对象,如果是可变对象的话,对象中的属性改变,则对象 HashCode 也进行相应的改变,导致下次无法查找到已存在Map中的数据。
- 如果可变对象在 HashMap 中被用作键,那就要小心在改变对象状态的时候,不要改变它的哈希值了。我们只需要保证成员变量的改变能保证该对象的哈希值不变即可。
- Hashtable是线程安全的么
- Hashtable 是线程安全的,其实现是在对应的方法上添加了 synchronized 关键字进行修饰,由于在执行此方法的时候需要获得对象锁,则执行起来比较慢。所以现在如果为了保证线程安全的话,使用 CurrentHashMap。
3. HashMap和ConcurrentHashMap
- HashMap和Concurrent HashMap区别?
- HashMap 是非线程安全的,CurrentHashMap 是线程安全的。
- ConcurrentHashMap 将整个 Hash 桶进行了分段 segment,也就是将这个大的数组分成了几个小的片段segment,而且每个小的片段 segment 上面都有锁存在,那么在插入元素的时候就需要先找到应该插入到哪一个片段 segment,然后再在这个片段上面进行插入,而且这里还需要获取 segment 锁。
- ConcurrentHashMap 让锁的粒度更精细一些,并发性能更好。
- ConcurrentHashMap 线程安全吗, ConcurrentHashMap如何保证 线程安全?
- Hashtable 容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问 Hashtable 的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是 ConcurrentHashMap 所使用的分段锁,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
- get 操作的高效之处在于整个 get 过程不需要加锁,除非读到的值是空的才会加锁重读。get 方法里将要使用的共享变量都定义成 volatile,如用于统计当前 Segement 大小的 count 字段和用于存储值的 HashEntry 的 value。定义成 volatile 的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在 get 操作里只需要读不需要写共享变量 count 和 value,所以可以不用加锁。
- put 方法首先定位到 Segment,然后在 Segment 里进行插入操作。
- 插入操作需要经历两个步骤:(1)判断是否需要对 Segment 里的 HashEntry 数组进行扩容;(2)定位添加元素的位置然后放在HashEntry数组里。
4. Hashtable的原理
Hashtable 使用链地址法进行元素存储,通过一个实际的例子来演示一下插入元素的过程:
假设我们现在 Hashtable 的容量为 5,已经存在了 (8,8),(10,10),(13,13),(16,16),(17,17),(21,21) 这 6 个键值对,目前他们在 Hashtable 中的位置如下:

现在,我们插入一个新的键值对,put(16,22),假设 key=16 的索引为 1.但现在索引 1 的位置有两个 Entry 了,所以程序会对链表进行迭代。迭代的过程中,发现其中有一个 Entry 的 key 和我们要插入的键值对的 key 相同,所以现在会做的工作就是将 newValue=22 替换 oldValue=16,然后返回 oldValue = 16.

然后我们现在再插入一个,put(33,33),key=33 的索引为 3,并且在链表中也不存在 key=33 的 Entry,所以将该节点插入链表的第一个位置。

Hashtable 与 HashMap 的简单比较
- Hashtable 基于 Dictionary 类,而 HashMap 是基于 AbstractMap。Dictionary 是任何可将键映射到相应值的类的抽象父类,而 AbstractMap 是基于 Map 接口的实现,它以最大限度地减少实现此接口所需的工作。
- HashMap 的 key 和 value 都允许为 null,而 Hashtable 的 key 和 value 都不允许为 null。HashMap 遇到 key 为 null 的时候,调用 putForNullKey 方法进行处理,而对 value 没有处理;Hashtable遇到 null,直接返回 NullPointerException。
- Hashtable 方法是同步,而HashMap则不是。我们可以看一下源码,Hashtable 中的几乎所有的 public 的方法都是 synchronized 的,而有些方法也是在内部通过 synchronized 代码块来实现。所以有人一般都建议如果是涉及到多线程同步时采用 Hashtable,没有涉及就采用 HashMap,但是在 Collections 类中存在一个静态方法:synchronizedMap(),该方法创建了一个线程安全的 Map 对象,并把它作为一个封装的对象来返回。
参考资料:
5. Hash冲突的解决办法
- 链地址法
- 开放地址法(向后一位)
- 线性探测
- 平方探测
- 二次哈希
- 再哈希法
6. 什么是迭代器
Java 集合框架的集合类,我们有时候称之为容器。容器的种类有很多种,比如 ArrayList、LinkedList、HashSet...,每种容器都有自己的特点,ArrayList 底层维护的是一个数组;LinkedList 是链表结构的;HashSet 依赖的是哈希表,每种容器都有自己特有的数据结构。
因为容器的内部结构不同,很多时候可能不知道该怎样去遍历一个容器中的元素。所以为了使对容器内元素的操作更为简单,Java 引入了迭代器模式!
把访问逻辑从不同类型的集合类中抽取出来,从而避免向外部暴露集合的内部结构。
迭代器模式:就是提供一种方法对一个容器对象中的各个元素进行访问,而又不暴露该对象容器的内部细节。
public static void main(String[] args) {
// 使用迭代器遍历ArrayList集合
Iterator<String> listIt = list.iterator();
while(listIt.hasNext()){
System.out.println(listIt.hasNext());
}
// 使用迭代器遍历Set集合
Iterator<String> setIt = set.iterator();
while(setIt.hasNext()){
System.out.println(listIt.hasNext());
}
// 使用迭代器遍历LinkedList集合
Iterator<String> linkIt = linkList.iterator();
while(linkIt.hasNext()){
System.out.println(listIt.hasNext());
}
}
参考资料:
7. 构造相同hash的字符串进行攻击,这种情况应该怎么处理?JDK7如何处理
攻击原理:
当客户端发送一个请求到服务器,如果该请求中带有参数,服务器端会将 参数名-参数值 作为 key-value 保存在 HashMap 中。如果有人恶意构造请求,在请求中加入大量相同 hash 值的 String 参数名(key),那么在服务器端用于存储这些 key-value 对的 HashMap 会被强行退化成链表,如图:

如果数据量足够大,那么在查找,插入时会占用大量 CPU,达到拒绝服务攻击的目的。
怎么处理
- 限制 POST 和 GET 请求的参数个数
- 限制 POST 请求的请求体大小
- Web Application FireWall(WAF)
JDK7如何处理
HashMap 会动态的使用一个专门 TreeMap 实现来替换掉它。
8. HashMap为什么大小是2的幂次
首先来看一下 HashMap 的 put 方法的源码
public V put(K key, V value) {
if (key == null)
return putForNullKey(value); //将空key的Entry加入到table[0]中
int hash = hash(key.hashCode()); //计算key.hashcode()的hash值,hash函数由HashMap自己实现
int i = indexFor(hash, table.length); //获取将要存放的数组下标
/*
* for中的代码用于:当hash值相同且key相同的情况下,使用新值覆盖旧值(其实就是修改功能)
*/
//注意:for循环在第一次执行时就会先判断条件
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
//hash值相同且key相同的情况下,使用新值覆盖旧值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
//e.recordAccess(this);
return oldValue;//返回旧值
}
}
modCount++;
addEntry(hash, key, value, i);//增加一个新的Entry到table[i]
return null;//如果没有与传入的key相等的Entry,就返回null
}
/**
* "按位与"来获取数组下标
*/
static int indexFor(int h, int length) {
return h & (length - 1);
}
HashMap 始终将自己的桶保持在2n,这是为什么?indexFor这个方法解释了这个问题
大家都知道计算机里面位运算是基本运算,位运算的效率是远远高于取余 % 运算的
举个例子:2n 转换成二进制就是 1+n 个 0,减 1 之后就是 0+n个1,如16 -> 10000,15 -> 01111
那么根据 & 位运算的规则,都为 1 (真)时,才为 1,那 0≤运算后的结果≤15,假设 h <= 15,那么运算后的结果就是 h 本身,h >15,运算后的结果就是最后四位二进制做 & 运算后的值,最终,就是 % 运算后的余数。
当容量一定是 2n 时,h & (length - 1) == h % length
更新日志
- 2018/8/3 v2.5 基础版
- 2018/9/1 v3.0 初稿版
Java并发编程
- [1. 线程状态转换](#1-线程状态转换)
- [新建(New)](#新建new)
- [可运行(Runnable)](#可运行runnable)
- [阻塞(Blocking)](#阻塞blocking)
- [无限期等待(Waiting)](#无限期等待waiting)
- [限期等待(Timed Waiting)](#限期等待timed-waiting)
- [死亡(Terminated)](#死亡terminated)
- [2. Java实现多线程的方式及三种方式的区别](#2-java实现多线程的方式及三种方式的区别)
- [实现 Runnable 接口](#实现-runnable-接口)
- [实现 Callable 接口](#实现-callable-接口)
- [继承 Thread 类](#继承-thread-类)
- [实现接口 VS 继承 Thread](#实现接口-vs-继承-thread)
- [三种方式的区别](#三种方式的区别)
- [3. 基础线程机制](#3-基础线程机制)
- [Executor](#executor)
- [Daemon(守护线程)](#daemon守护线程)
- [sleep()](#sleep)
- [yield()](#yield)
- [线程阻塞](#线程阻塞)
- [4. 中断](#4-中断)
- [InterruptedException](#interruptedexception)
- [interrupted()](#interrupted)
- [Executor 的中断操作](#executor-的中断操作)
- [5. 互斥同步](#5-互斥同步)
- [synchronized](#synchronized)
- [ReentrantLock](#reentrantlock)
- [synchronized 和 ReentrantLock 比较](#synchronized-和-reentrantlock-比较)
- [synchronized与lock的区别,使用场景。看过synchronized的源码没?](#synchronized与lock的区别使用场景看过synchronized的源码没)
- [什么是CAS](#什么是cas)
- [入门例子](#入门例子)
- [Compare And Swap](#compare-and-swap)
- [什么是乐观锁和悲观锁](#什么是乐观锁和悲观锁)
- [Synchronized(对象锁)和Static Synchronized(类锁)区别](#synchronized对象锁和static-synchronized类锁区别)
- [6. 线程之间的协作](#6-线程之间的协作)
- [join()](#join)
- [wait() notify() notifyAll()](#wait-notify-notifyall)
- [await() signal() signalAll()](#await-signal-signalall)
- [sleep和wait有什么区别](#sleep和wait有什么区别)
- [7. J.U.C - AQS](#7-juc---aqs)
- [CountdownLatch](#countdownlatch)
- [CyclicBarrier](#cyclicbarrier)
- [Semaphore](#semaphore)
- [总结](#总结)
- [8. J.U.C - 其它组件](#8-juc---其它组件)
- [FutureTask](#futuretask)
- [BlockingQueue](#blockingqueue)
- [ForkJoin](#forkjoin)
- [9. 线程不安全示例](#9-线程不安全示例)
- [10. Java 内存模型(JMM)](#10-java-内存模型jmm)
- [主内存与工作内存](#主内存与工作内存)
- [内存间交互操作](#内存间交互操作)
- [内存模型三大特性](#内存模型三大特性)
- [1. 原子性](#1-原子性)
- [2. 可见性](#2-可见性)
- [3. 有序性](#3-有序性)
- [指令重排序](#指令重排序)
- [数据依赖性](#数据依赖性)
- [as-if-serial语义](#as-if-serial语义)
- [程序顺序规则](#程序顺序规则)
- [重排序对多线程的影响](#重排序对多线程的影响)
- [先行发生原则(happens-before)](#先行发生原则happens-before)
- [1. 单一线程原则](#1-单一线程原则)
- [2. 管程锁定规则](#2-管程锁定规则)
- [3. volatile 变量规则](#3-volatile-变量规则)
- [4. 线程启动规则](#4-线程启动规则)
- [5. 线程加入规则](#5-线程加入规则)
- [6. 线程中断规则](#6-线程中断规则)
- [7. 对象终结规则](#7-对象终结规则)
- [8. 传递性](#8-传递性)
- [11. 线程安全](#11-线程安全)
- [线程安全定义](#线程安全定义)
- [线程安全分类](#线程安全分类)
- [1. 不可变](#1-不可变)
- [2. 绝对线程安全](#2-绝对线程安全)
- [3. 相对线程安全](#3-相对线程安全)
- [4. 线程兼容](#4-线程兼容)
- [5. 线程对立](#5-线程对立)
- [线程安全的实现方法](#线程安全的实现方法)
- [1. 阻塞同步(互斥同步)](#1-阻塞同步互斥同步)
- [2. 非阻塞同步](#2-非阻塞同步)
- [3. 无同步方案](#3-无同步方案)
- [(一)可重入代码(Reentrant Code)](#一可重入代码reentrant-code)
- [(二)栈封闭](#二栈封闭)
- [(三)线程本地存储(Thread Local Storage)](#三线程本地存储thread-local-storage)
- [12. 锁优化](#12-锁优化)
- [自旋锁](#自旋锁)
- [锁消除](#锁消除)
- [锁粗化](#锁粗化)
- [轻量级锁](#轻量级锁)
- [偏向锁](#偏向锁)
- [13. 多线程开发良好的实践](#13-多线程开发良好的实践)
- [14. 线程池实现原理](#14-线程池实现原理)
- [并发队列](#并发队列)
- [线程池概念](#线程池概念)
- [Executor类图](#executor类图)
- [线程池工作原理](#线程池工作原理)
- [初始化线程池](#初始化线程池)
- [初始化方法](#初始化方法)
- [常用方法](#常用方法)
- [execute与submit的区别](#execute与submit的区别)
- [shutDown与shutDownNow的区别](#shutdown与shutdownnow的区别)
- [内部实现](#内部实现)
- [线程池的状态](#线程池的状态)
- [线程池其他常用方法](#线程池其他常用方法)
- [如何合理设置线程池的大小](#如何合理设置线程池的大小)
- [1. volatile 与 synchronized 的区别](#1-volatile-与-synchronized-的区别)
- [2. 什么是线程池?如果让你设计一个动态大小的线程池,如何设计,应该有哪些方法?线程池创建的方式?](#2-什么是线程池如果让你设计一个动态大小的线程池如何设计应该有哪些方法线程池创建的方式)
- [3. 什么是并发和并行](#3-什么是并发和并行)
- [并发](#并发)
- [并行](#并行)
- [4. 什么是线程安全](#4-什么是线程安全)
- [非线程安全!=不安全?](#非线程安全不安全)
- [线程安全十万个为什么?](#线程安全十万个为什么)
- [5. volatile 关键字的如何保证内存可见性](#5-volatile-关键字的如何保证内存可见性)
- [5. 什么是线程?线程和进程有什么区别?为什么要使用多线程](#5-什么是线程线程和进程有什么区别为什么要使用多线程)
- [6. 多线程共用一个数据变量需要注意什么?](#6-多线程共用一个数据变量需要注意什么)
- [7. 内存泄漏与内存溢出](#7-内存泄漏与内存溢出)
- [Java内存回收机制](#java内存回收机制)
- [Java内存泄露引起原因](#java内存泄露引起原因)
- [静态集合类](#静态集合类)
- [监听器](#监听器)
- [各种连接](#各种连接)
- [内部类和外部模块等的引用](#内部类和外部模块等的引用)
- [单例模式](#单例模式)
- [8. 如何减少线程上下文切换](#8-如何减少线程上下文切换)
- [9. 线程间通信和进程间通信](#9-线程间通信和进程间通信)
- [线程间通信](#线程间通信)
- [进程间通信](#进程间通信)
- [10. 什么是同步和异步,阻塞和非阻塞?](#10-什么是同步和异步阻塞和非阻塞)
- [同步](#同步)
- [异步](#异步)
- [阻塞](#阻塞)
- [非阻塞](#非阻塞)
- [11. Java中的锁](#11-java中的锁)
- [一个简单的锁](#一个简单的锁)
- [锁的可重入性](#锁的可重入性)
- [锁的公平性](#锁的公平性)
- [在 finally 语句中调用 unlock()](#在-finally-语句中调用-unlock)
- [12. 并发包(J.U.C)下面,都用过什么](#12-并发包juc下面都用过什么)
- [13. 从volatile说到,i++原子操作,线程安全问题](#13-从volatile说到i原子操作线程安全问题)
前言
在本文将总结多线程并发编程中的常见面试题,主要核心线程生命周期、线程通信、并发包部分。主要分成 “并发编程” 和 “面试指南” 两 部分,在面试指南中将讨论并发相关面经。
第一部分:并发编程
1. 线程状态转换

这里笔者也绘制了一张中文版的图,点击查看
新建(New)
创建后尚未启动。
可运行(Runnable)
可能正在运行,也可能正在等待 CPU 时间片。
包含了操作系统线程状态中的 运行(Running ) 和 就绪(Ready)。
阻塞(Blocking)
这个状态下,是在多个线程有同步操作的场景,比如正在等待另一个线程的 synchronized 块的执行释放,或者可重入的 synchronized 块里别人调用 wait() 方法,也就是线程在等待进入临界区。
阻塞可以分为:等待阻塞,同步阻塞,其他阻塞
无限期等待(Waiting)
等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
| 进入方法 | 退出方法 |
|---|---|
| 没有设置 Timeout 参数的 Object.wait() 方法 | Object.notify() / Object.notifyAll() |
| 没有设置 Timeout 参数的 Thread.join() 方法 | 被调用的线程执行完毕 |
| LockSupport.park() 方法 | - |
限期等待(Timed Waiting)
无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用 “使一个线程睡眠” 进行描述。
调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用 “挂起一个线程” 进行描述。
睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。
阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入。
| 进入方法 | 退出方法 |
|---|---|
| Thread.sleep() 方法 | 时间结束 |
| 设置了 Timeout 参数的 Object.wait() 方法 | 时间结束 / Object.notify() / Object.notifyAll() |
| 设置了 Timeout 参数的 Thread.join() 方法 | 时间结束 / 被调用的线程执行完毕 |
| LockSupport.parkNanos() 方法 | - |
| LockSupport.parkUntil() 方法 | - |
死亡(Terminated)
- 线程因为 run 方法正常退出而自然死亡
- 因为一个没有捕获的异常终止了 run 方法而意外死亡
2. Java实现多线程的方式及三种方式的区别
有三种使用线程的方法:
- 实现 Runnable 接口;
- 实现 Callable 接口;
- 继承 Thread 类。
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。
实现 Runnable 接口
需要实现 run() 方法。
通过 Thread 调用 start() 方法来启动线程。
public class MyRunnable implements Runnable {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}
实现 Callable 接口
与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。
public class MyCallable implements Callable<Integer> {
public Integer call() {
return 123;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
继承 Thread 类
同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。
public class MyThread extends Thread {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}
实现接口 VS 继承 Thread
实现接口会更好一些,因为:
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 类可能只要求可执行就行,继承整个 Thread 类开销过大。
三种方式的区别
- 实现 Runnable 接口可以避免 Java 单继承特性而带来的局限;增强程序的健壮性,代码能够被多个线程共享,代码与数据是独立的;适合多个相同程序代码的线程区处理同一资源的情况。
- 继承 Thread 类和实现 Runnable 方法启动线程都是使用 start() 方法,然后 JVM 虚拟机将此线程放到就绪队列中,如果有处理机可用,则执行 run() 方法。
- 实现 Callable 接口要实现 call() 方法,并且线程执行完毕后会有返回值。其他的两种都是重写 run() 方法,没有返回值。
3. 基础线程机制
Executor
Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。
主要有三种 Executor:
- CachedThreadPool:一个任务创建一个线程;
- FixedThreadPool:所有任务只能使用固定大小的线程;
- SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.execute(new MyRunnable());
}
executorService.shutdown();
}
为什么引入Executor线程池框架?
new Thread() 的缺点
- 每次 new Thread() 耗费性能
- 调用 new Thread() 创建的线程缺乏管理,被称为野线程,而且可以无限制创建,之间相互竞争,会导致过多占用系统资源导致系统瘫痪。
- 不利于扩展,比如如定时执行、定期执行、线程中断
采用线程池的优点
- 重用存在的线程,减少对象创建、消亡的开销,性能佳
- 可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞
- 提供定时执行、定期执行、单线程、并发数控制等功能
Daemon(守护线程)
Java 中有两类线程:User Thread (用户线程)、Daemon Thread (守护线程)
用户线程即运行在前台的线程,而守护线程是运行在后台的线程。 守护线程作用是为其他前台线程的运行提供便利服务,而且仅在普通、非守护线程仍然运行时才需要,比如垃圾回收线程就是一个守护线程。当 JVM 检测仅剩一个守护线程,而用户线程都已经退出运行时,JVM 就会退出,因为没有如果没有了被守护这,也就没有继续运行程序的必要了。如果有非守护线程仍然存活,JVM 就不会退出。
守护线程并非只有虚拟机内部提供,用户在编写程序时也可以自己设置守护线程。用户可以用 Thread 的 setDaemon(true) 方法设置当前线程为守护线程。
虽然守护线程可能非常有用,但必须小心确保其他所有非守护线程消亡时,不会由于它的终止而产生任何危害。因为你不可能知道在所有的用户线程退出运行前,守护线程是否已经完成了预期的服务任务。一旦所有的用户线程退出了,虚拟机也就退出运行了。 因此,不要在守护线程中执行业务逻辑操作(比如对数据的读写等)。
另外有几点需要注意:
- setDaemon(true) 必须在调用线程的 start() 方法之前设置,否则会跑出 IllegalThreadStateException 异常。
- 在守护线程中产生的新线程也是守护线程。
- 不要认为所有的应用都可以分配给守护线程来进行服务,比如读写操作或者计算逻辑。
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
main() 属于非守护线程。
使用 setDaemon() 方法将一个线程设置为守护线程。
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}
sleep()
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
yield()
对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。
public void run() {
Thread.yield();
}
线程阻塞
线程可以阻塞于四种状态:
- 当线程执行 Thread.sleep() 时,它一直阻塞到指定的毫秒时间之后,或者阻塞被另一个线程打断;
- 当线程碰到一条 wait() 语句时,它会一直阻塞到接到通知 notify()、被中断或经过了指定毫秒时间为止(若制定了超时值的话)
- 线程阻塞与不同 I/O 的方式有多种。常见的一种方式是 InputStream 的 read() 方法,该方法一直阻塞到从流中读取一个字节的数据为止,它可以无限阻塞,因此不能指定超时时间;
- 线程也可以阻塞等待获取某个对象锁的排他性访问权限(即等待获得 synchronized 语句必须的锁时阻塞)。
注意,并非所有的阻塞状态都是可中断的,以上阻塞状态的前两种可以被中断,后两种不会对中断做出反应
4. 中断
一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。
InterruptedException
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。
public class InterruptExample {
private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
interrupted()
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
public class InterruptExample {
private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
Thread end
Executor 的中断操作
调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。
以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
executorService.shutdownNow();
System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at ExecutorInterruptExample.lambda$main$0(ExecutorInterruptExample.java:9)
at ExecutorInterruptExample$$Lambda$1/1160460865.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)
如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程。
Future<?> future = executorService.submit(() -> {
// ..
});
future.cancel(true);
5. 互斥同步
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。
synchronized
1. 同步一个代码块
public void func() {
synchronized (this) {
// ...
}
}
它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。
对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。
public class SynchronizedExample {
public void func1() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func1());
executorService.execute(() -> e1.func1());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
对于以下代码,两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果可以看出,两个线程交叉执行。
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
SynchronizedExample e2 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func1());
executorService.execute(() -> e2.func1());
}
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9
2. 同步一个方法
public synchronized void func () {
// ...
}
它和同步代码块一样,作用于同一个对象。
3. 同步一个类
public void func() {
synchronized (SynchronizedExample.class) {
// ...
}
}
作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。
public class SynchronizedExample {
public void func2() {
synchronized (SynchronizedExample.class) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
SynchronizedExample e2 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func2());
executorService.execute(() -> e2.func2());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
4. 同步一个静态方法
- 非静态同步函数的锁是:this
- 静态的同步函数的锁是:字节码对象
public synchronized static void fun() {
// ...
}
作用于整个类。
ReentrantLock
重入锁(ReentrantLock)是一种递归无阻塞的同步机制。
public class LockExample {
private Lock lock = new ReentrantLock();
public void func() {
lock.lock();
try {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
} finally {
lock.unlock(); // 确保释放锁,从而避免发生死锁。
}
}
}
public static void main(String[] args) {
LockExample lockExample = new LockExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> lockExample.func());
executorService.execute(() -> lockExample.func());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁,相比于 synchronized,它多了以下高级功能:
1. 等待可中断
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
2. 可实现公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
3. 锁绑定多个条件
一个 ReentrantLock 对象可以同时绑定多个 Condition 对象。
synchronized 和 ReentrantLock 比较
1. 锁的实现
synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
2. 性能
新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等。目前来看它和 ReentrantLock 的性能基本持平了,因此性能因素不再是选择 ReentrantLock 的理由。synchronized 有更大的性能优化空间,应该优先考虑 synchronized。
3. 功能
ReentrantLock 多了一些高级功能。
4. 使用选择
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。
synchronized与lock的区别,使用场景。看过synchronized的源码没?
- (用法)synchronized(隐式锁):在需要同步的对象中加入此控制,synchronized 可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。
- (用法)lock(显示锁):需要显示指定起始位置和终止位置。一般使用 ReentrantLock 类做为锁,多个线程中必须要使用一个 ReentrantLock 类做为对象才能保证锁的生效。且在加锁和解锁处需要通过 lock() 和 unlock() 显示指出。所以一般会在 finally 块中写 unlock() 以防死锁。
- (性能)synchronized 是托管给 JVM 执行的,而 lock 是 Java 写的控制锁的代码。在 Java1.5 中,synchronize 是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。相比之下使用 Java 提供的 Lock 对象,性能更高一些。但是到了 Java1.6 ,发生了变化。synchronize 在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致 在 Java1.6 上 synchronize 的性能并不比 Lock 差。
- (机制)synchronized 原始采用的是 CPU 悲观锁机制,即线程获得的是独占锁。Lock 也属于悲观锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。相对而言乐观锁每次不加锁,而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是 CAS 操作(Compare and Swap)。
什么是CAS
蘑菇街面试,这里简单论述一下
入门例子
在 Java 并发包中有这样一个包,java.util.concurrent.atomic,该包是对 Java 部分数据类型的原子封装,在原有数据类型的基础上,提供了原子性的操作方法,保证了线程安全。下面以 AtomicInteger 为例,来看一下是如何实现的。
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
public final int decrementAndGet() {
for (;;) {
int current = get();
int next = current - 1;
if (compareAndSet(current, next))
return next;
}
}
以这两个方法为例,incrementAndGet 方法相当于原子性的 ++i,decrementAndGet 方法相当于原子性的 --i,这两个方法中都没有使用阻塞式的方式来保证原子性(如 Synchronized ),那它们是如何保证原子性的呢,下面引出 CAS。
Compare And Swap
CAS 指的是现代 CPU 广泛支持的一种对内存中的共享数据进行操作的一种特殊指令。这个指令会对内存中的共享数据做原子的读写操作。
简单介绍一下这个指令的操作过程:
- 首先,CPU 会将内存中将要被更改的数据与期望的值做比较。
- 然后,当这两个值相等时,CPU 才会将内存中的数值替换为新的值。否则便不做操作。
- 最后,CPU 会将旧的数值返回。
这一系列的操作是原子的。它们虽然看似复杂,但却是 Java 5 并发机制优于原有锁机制的根本。简单来说,CAS 的含义是:我认为原有的值应该是什么,如果是,则将原有的值更新为新值,否则不做修改,并告诉我原来的值是多少。 简单的来说,CAS 有 3 个操作数,内存值 V,旧的预期值 A,要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则返回 V。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;而 Synchronized 是一种悲观锁,它认为在它修改之前,一定会有其它线程去修改它,悲观锁效率很低。
什么是乐观锁和悲观锁
- 为什么需要锁(并发控制)
- 在多用户环境中,在同一时间可能会有多个用户更新相同的记录,这会产生冲突。这就是著名的并发性问题。
- 典型的冲突有:
- 丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失。例如:用户 A 把值从 6 改为 2,用户 B 把值从 2 改为 6,则用户 A 丢失了他的更新。
- 脏读:当一个事务读取其它完成一半事务的记录时,就会发生脏读取。例如:用户 A,B 看到的值都是6,用户 B 把值改为 2,用户 A 读到的值仍为 6。
- 为了解决这些并发带来的问题。 我们需要引入并发控制机制。
参考资料:
Synchronized(对象锁)和Static Synchronized(类锁)区别
- 一个是实例锁(锁在某一个实例对象上,如果该类是单例,那么该锁也具有全局锁的概念),一个是全局锁(该锁针对的是类,无论实例多少个对象,那么线程都共享该锁)。
实例锁对应的就是 synchronized关 键字,而类锁(全局锁)对应的就是 static synchronized(或者是锁在该类的 class 或者 classloader 对象上)。
/**
* static synchronized 和synchronized的区别!
* 关键是区别第四种情况!
*/
public class StaticSynchronized {
/**
* synchronized方法
*/
public synchronized void isSynA(){
System.out.println("isSynA");
}
public synchronized void isSynB(){
System.out.println("isSynB");
}
/**
* static synchronized方法
*/
public static synchronized void cSynA(){
System.out.println("cSynA");
}
public static synchronized void cSynB(){
System.out.println("cSynB");
}
public static void main(String[] args) {
StaticSynchronized x = new StaticSynchronized();
StaticSynchronized y = new StaticSynchronized();
/**
* x.isSynA()与x.isSynB(); 不能同时访问(同一个对象访问synchronized方法)
* x.isSynA()与y.isSynB(); 能同时访问(不同对象访问synchronized方法)
* x.cSynA()与y.cSynB(); 不能同时访问(不同对象也不能访问static synchronized方法)
* x.isSynA()与y.cSynA(); 能同时访问(static synchronized方法占用的是类锁,
* 而访问synchronized方法占用的是对象锁,不存在互斥现象)
*/
}
}
6. 线程之间的协作
当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。
join()
在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。
对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。
public class JoinExample {
private class A extends Thread {
@Override
public void run() {
System.out.println("A");
}
}
private class B extends Thread {
private A a;
B(A a) {
this.a = a;
}
@Override
public void run() {
try {
a.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("B");
}
}
public void test() {
A a = new A();
B b = new B(a);
b.start();
a.start();
}
}
public static void main(String[] args) {
JoinExample example = new JoinExample();
example.test();
}
A
B
wait() notify() notifyAll()
调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify()(随机叫醒一个) 或者 notifyAll() (叫醒所有 wait 线程,争夺时间片的线程只有一个)来唤醒挂起的线程。
它们都属于 Object 的一部分,而不属于 Thread。
只能用在同步方法或者同步控制块中使用!否则会在运行时抛出 IllegalMonitorStateExeception。
使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。
public class WaitNotifyExample {
public synchronized void before() {
System.out.println("before");
notifyAll();
}
public synchronized void after() {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("after");
}
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
WaitNotifyExample example = new WaitNotifyExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
before
after
await() signal() signalAll()
java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。
使用 Lock 来获取一个 Condition 对象。
public class AwaitSignalExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
public void before() {
lock.lock();
try {
System.out.println("before");
condition.signalAll();
} finally {
lock.unlock();
}
}
public void after() {
lock.lock();
try {
condition.await();
System.out.println("after");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
AwaitSignalExample example = new AwaitSignalExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
before
after
sleep和wait有什么区别
- sleep 和 wait
- wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
- wait() 会释放锁,sleep() 不会。
- 有什么区别
- sleep() 方法(休眠)是线程类(Thread)的静态方法,调用此方法会让当前线程暂停执行指定的时间,将执行机会(CPU)让给其他线程,但是对象的锁依然保持,因此休眠时间结束后会自动恢复(线程回到就绪状态)。
- wait() 是 Object 类的方法,调用对象的 wait() 方法导致当前线程放弃对象的锁(线程暂停执行),进入对象的等待池(wait pool),只有调用对象的 notify() 方法(或 notifyAll() 方法)时才能唤醒等待池中的线程进入等锁池(lock pool),如果线程重新获得对象的锁就可以进入就绪状态。
7. J.U.C - AQS
AQS 是 AbstractQueuedSynchronizer 的简称,java.util.concurrent(J.U.C)大大提高了并发性能,AQS (AbstractQueuedSynchronizer) 被认为是 J.U.C 的核心。它提供了一个基于 FIFO 队列,这个队列可以用来构建锁或者其他相关的同步装置的基础框架。下图是 AQS 底层的数据结构:

它底层使用的是双向列表,是队列的一种实现 , 因此也可以将它当成一种队列。
- Sync queue 是同步列表,它是双向列表 , 包括 head,tail 节点。其中 head 节点主要用来后续的调度 ;
- Condition queue 是单向链表 , 不是必须的 , 只有当程序中需要 Condition 的时候,才会存在这个单向链表 , 并且可能会有多个 Condition queue。
简单的来说:
- AQS其实就是一个可以给我们实现锁的框架
- 内部实现的关键是:先进先出的队列、state 状态
- 定义了内部类 ConditionObject
- 拥有两种线程模式
- - 独占模式
- 共享模式
- 在 LOCK 包中的相关锁(常用的有 ReentrantLock、 ReadWriteLock )都是基于 AQS 来构建
- 一般我们叫 AQS 为同步器。
CountdownLatch
CountDownLatch 类位于 java.util.concurrent 包下,利用它可以实现类似计数器的功能。比如有一个任务 A,它要等待其他 4 个任务执行完毕之后才能执行,此时就可以利用 CountDownLatch 来实现这种功能了。
维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。

CountDownLatch 类只提供了一个构造器:
public CountDownLatch(int count) { }; // 参数count为计数值
然后下面这 3 个方法是 CountDownLatch 类中最重要的方法:
//调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行
public void await() throws InterruptedException { };
//和await()类似,只不过等待一定的时间后count值还没变为0的话就会继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };
//将count值减1
public void countDown() { };
下面看一个例子大家就清楚 CountDownLatch 的用法了:
public class Test {
public static void main(String[] args) {
final CountDownLatch latch = new CountDownLatch(2);
new Thread() {
public void run() {
try {
System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
Thread.sleep(3000);
System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
;
}.start();
new Thread() {
public void run() {
try {
System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
Thread.sleep(3000);
System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
;
}.start();
try {
System.out.println("等待2个子线程执行完毕...");
latch.await();
System.out.println("2个子线程已经执行完毕");
System.out.println("继续执行主线程");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
执行结果:
线程Thread-0正在执行
线程Thread-1正在执行
等待2个子线程执行完毕...
线程Thread-0执行完毕
线程Thread-1执行完毕
2个子线程已经执行完毕
继续执行主线程
CyclicBarrier
用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。
和 CountdownLatch 相似,都是通过维护计数器来实现的。但是它的计数器是递增的,每次执行 await() 方法之后,计数器会加 1,直到计数器的值和设置的值相等,等待的所有线程才会继续执行。和 CountdownLatch 的另一个区别是,CyclicBarrier 的计数器可以循环使用,所以它才叫做循环屏障。
下图应该从下往上看才正确。

public class CyclicBarrierExample {
public static void main(String[] args) throws InterruptedException {
final int totalThread = 10;
CyclicBarrier cyclicBarrier = new CyclicBarrier(totalThread);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < totalThread; i++) {
executorService.execute(() -> {
System.out.print("before..");
try {
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.print("after..");
});
}
executorService.shutdown();
}
}
before..before..before..before..before..before..before..before..before..before..after..after..after..after..after..after..after..after..after..after..
Semaphore
Semaphore 就是操作系统中的信号量,可以控制对互斥资源的访问线程数。Semaphore 可以控同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。

Semaphore 类位于 java.util.concurrent 包下,它提供了2个构造器:
public Semaphore(int permits) {
//参数permits表示许可数目,即同时可以允许多少线程进行访问
sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
//这个多了一个参数fair表示是否是公平的,即等待时间越久的越先获取许可
sync = (fair) ? new FairSync(permits) : new NonfairSync(permits);
}
下面说一下 Semaphore 类中比较重要的几个方法,首先是 acquire()、release() 方法:
//获取一个许可
public void acquire() throws InterruptedException { }
//获取permits个许可
public void acquire(int permits) throws InterruptedException { }
//释放一个许可
public void release() { }
//释放permits个许可
public void release(int permits) { }
acquire() 用来获取一个许可,若无许可能够获得,则会一直等待,直到获得许可。
release() 用来释放许可。注意,在释放许可之前,必须先获获得许可。
这 4 个方法都会被阻塞,如果想立即得到执行结果,可以使用下面几个方法:
//尝试获取一个许可,若获取成功,则立即返回true,若获取失败,则立即返回false
public boolean tryAcquire() { };
//尝试获取一个许可,若在指定的时间内获取成功,则立即返回true,否则则立即返回false
public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException { };
//尝试获取permits个许可,若获取成功,则立即返回true,若获取失败,则立即返回false
public boolean tryAcquire(int permits) { };
//尝试获取permits个许可,若在指定的时间内获取成功,则立即返回true,否则则立即返回false
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) throws InterruptedException { };
另外还可以通过 availablePermits() 方法得到可用的许可数目。
下面通过一个例子来看一下 Semaphore 的具体使用:
假若一个工厂有 5 台机器,但是有 8 个工人,一台机器同时只能被一个工人使用,只有使用完了,其他工人才能继续使用。那么我们就可以通过 Semaphore 来实现:
public class Test {
public static void main(String[] args) {
int N = 8; //工人数
Semaphore semaphore = new Semaphore(5); //机器数目
for (int i = 0; i < N; i++) new Worker(i, semaphore).start();
}
static class Worker extends Thread {
private int num;
private Semaphore semaphore;
public Worker(int num, Semaphore semaphore) {
this.num = num;
this.semaphore = semaphore;
}
@Override
public void run() {
try {
semaphore.acquire();
System.out.println("工人" + this.num + "占用一个机器在生产...");
Thread.sleep(2000);
System.out.println("工人" + this.num + "释放出机器");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
执行结果:
工人0占用一个机器在生产...
工人1占用一个机器在生产...
工人2占用一个机器在生产...
工人4占用一个机器在生产...
工人5占用一个机器在生产...
工人0释放出机器
工人2释放出机器
工人3占用一个机器在生产...
工人7占用一个机器在生产...
工人4释放出机器
工人5释放出机器
工人1释放出机器
工人6占用一个机器在生产...
工人3释放出机器
工人7释放出机器
工人6释放出机器
总结
下面对上面说的三个辅助类进行一个总结:
- CountDownLatch 和 CyclicBarrier 都能够实现线程之间的等待,只不过它们侧重点不同:
- CountDownLatch 一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;
- CyclicBarrier 一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;
- 另外,CountDownLatch 是不能够重用的,而 CyclicBarrier 是可以重用的。
- Semaphore 其实和锁有点类似,它一般用于控制对某组资源的访问权限。
8. J.U.C - 其它组件
FutureTask
在介绍 Callable 时我们知道它可以有返回值,返回值通过 Future 进行封装。FutureTask 实现了 RunnableFuture 接口,该接口继承自 Runnable 和 Future 接口,这使得 FutureTask 既可以当做一个任务执行,也可以有返回值。
public class FutureTask<V> implements RunnableFuture<V>
public interface RunnableFuture<V> extends Runnable, Future<V>
FutureTask 可用于异步获取执行结果或取消执行任务的场景。当一个计算任务需要执行很长时间,那么就可以用 FutureTask 来封装这个任务,主线程在完成自己的任务之后再去获取结果。
public class FutureTaskExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> futureTask = new FutureTask<Integer>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 0; i < 100; i++) {
Thread.sleep(10);
result += i;
}
return result;
}
});
Thread computeThread = new Thread(futureTask);
computeThread.start();
Thread otherThread = new Thread(() -> {
System.out.println("other task is running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
otherThread.start();
System.out.println(futureTask.get());
}
}
other task is running...
4950
BlockingQueue
java.util.concurrent.BlockingQueue 接口有以下阻塞队列的实现:
- FIFO 队列 :LinkedBlockingQueue、ArrayBlockingQueue(固定长度)
- 优先级队列 :PriorityBlockingQueue
提供了阻塞的 take() 和 put() 方法:如果队列为空 take() 将阻塞,直到队列中有内容;如果队列为满 put() 将阻塞,直到队列有空闲位置。
使用 BlockingQueue 实现生产者消费者问题
public class ProducerConsumer {
private static BlockingQueue<String> queue = new ArrayBlockingQueue<>(5);
private static class Producer extends Thread {
@Override
public void run() {
try {
queue.put("product");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print("produce..");
}
}
private static class Consumer extends Thread {
@Override
public void run() {
try {
String product = queue.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print("consume..");
}
}
}
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
Producer producer = new Producer();
producer.start();
}
for (int i = 0; i < 5; i++) {
Consumer consumer = new Consumer();
consumer.start();
}
for (int i = 0; i < 3; i++) {
Producer producer = new Producer();
producer.start();
}
}
produce..produce..consume..consume..produce..consume..produce..consume..produce..consume..
ForkJoin
主要用于并行计算中,和 MapReduce 原理类似,都是把大的计算任务拆分成多个小任务并行计算。
public class ForkJoinExample extends RecursiveTask<Integer> {
private final int threshold = 5;
private int first;
private int last;
public ForkJoinExample(int first, int last) {
this.first = first;
this.last = last;
}
@Override
protected Integer compute() {
int result = 0;
if (last - first <= threshold) {
// 任务足够小则直接计算
for (int i = first; i <= last; i++) {
result += i;
}
} else {
// 拆分成小任务
int middle = first + (last - first) / 2;
ForkJoinExample leftTask = new ForkJoinExample(first, middle);
ForkJoinExample rightTask = new ForkJoinExample(middle + 1, last);
leftTask.fork();
rightTask.fork();
result = leftTask.join() + rightTask.join();
}
return result;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
ForkJoinExample example = new ForkJoinExample(1, 10000);
ForkJoinPool forkJoinPool = new ForkJoinPool();
Future result = forkJoinPool.submit(example);
System.out.println(result.get());
}
ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数。
public class ForkJoinPool extends AbstractExecutorService
ForkJoinPool 实现了工作窃取算法来提高 CPU 的利用率。每个线程都维护了一个双端队列,用来存储需要执行的任务。工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行。窃取的任务必须是最晚的任务,避免和队列所属线程发生竞争。例如下图中,Thread2 从 Thread1 的队列中拿出最晚的 Task1 任务,Thread1 会拿出 Task2 来执行,这样就避免发生竞争。但是如果队列中只有一个任务时还是会发生竞争。

9. 线程不安全示例
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。
以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值为 997 而不是 1000。
public class ThreadUnsafeExample {
private int cnt = 0;
public void add() {
cnt++;
}
public int get() {
return cnt;
}
}
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
ThreadUnsafeExample example = new ThreadUnsafeExample();
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(example.get());
}
997
10. Java 内存模型(JMM)
Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
主内存与工作内存
处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。
加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。

所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。
Java内存模型和硬件关系图

Java内存模型抽象结构图

内存间交互操作
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

- read:把一个变量的值从主内存传输到工作内存中
- load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
- use:把工作内存中一个变量的值传递给执行引擎
- assign:把一个从执行引擎接收到的值赋给工作内存的变量
- store:把工作内存的一个变量的值传送到主内存中
- write:在 store 之后执行,把 store 得到的值放入主内存的变量中
- lock:作用于主内存的变量,把一个变量标识为一条线程独占状态
- unlock:作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。
Java内存模型还规定了在执行上述8种基本操作时,必须满足如下规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现
- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,lock 和 unlock必须成对出现
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
参考资料:
volatile关键字与Java内存模型(JMM) - yzwall - 博客园
内存模型三大特性
1. 原子性
- 概念
- 事物有原子性,这个概念大概都清楚,即一个操作或多个操作要么执行的过程中不被任何因素打断,要么不执行。
- 如何实现原子性?
- 通过同步代码块 synchronized 或者 local 锁来确保原子性
Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
有一个错误认识就是,int 等原子性的变量在多线程环境中不会出现线程安全问题。前面的线程不安全示例代码中,cnt 变量属于 int 类型变量,1000 个线程对它进行自增操作之后,得到的值为 997 而不是 1000。
为了方便讨论,将内存间的交互操作简化为 3 个:load、assign、store。
下图演示了两个线程同时对 cnt 变量进行操作,load、assign、store 这一系列操作整体上看不具备原子性,那么在 T1 修改 cnt 并且还没有将修改后的值写入主内存,T2 依然可以读入该变量的值。可以看出,这两个线程虽然执行了两次自增运算,但是主内存中 cnt 的值最后为 1 而不是 2。因此对 int 类型读写操作满足原子性只是说明 load、assign、store 这些单个操作具备原子性。

AtomicInteger 能保证多个线程修改的原子性。

使用 AtomicInteger 重写之前线程不安全的代码之后得到以下线程安全实现:
public class AtomicExample {
private AtomicInteger cnt = new AtomicInteger();
public void add() {
cnt.incrementAndGet();
}
public int get() {
return cnt.get();
}
}
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
AtomicExample example = new AtomicExample(); // 只修改这条语句
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(example.get());
}
1000
除了使用原子类之外,也可以使用 synchronized 互斥锁来保证操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit。
public class AtomicSynchronizedExample {
private int cnt = 0;
public synchronized void add() {
cnt++;
}
public synchronized int get() {
return cnt;
}
}
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
AtomicSynchronizedExample example = new AtomicSynchronizedExample();
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(example.get());
}
1000
2. 可见性
可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
主要有有三种实现可见性的方式:
- volatile
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
对前面的线程不安全示例中的 cnt 变量使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。
3. 有序性
有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
指令重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。
指令重排序包括:编译器重排序和处理器重排序
重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
| 名称 | 代码示例 | 说明 |
|---|---|---|
| 写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
| 写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
| 读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial语义
as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和 处理器 都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三个操作的数据依赖关系如下图所示:

如上图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序:

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
程序顺序规则
根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。
这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下, JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。
重排序对多线程的影响
现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
……
}
}
}
flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?
答案是:不一定能看到。
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果?请看下面的程序执行时序图:

如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!
※注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。
下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量 i 中。
从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
参考资料:
先行发生原则(happens-before)
Happens-before 是用来指定两个操作之间的执行顺序。提供跨线程的内存可见性。
在 Java 内存模型中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必然存在 happens-before 关系。
上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。
主要有以下这些原则:
1. 单一线程原则
Single Thread rule
在一个线程内,在程序前面的操作先行发生于后面的操作。

2. 管程锁定规则
Monitor Lock Rule
对一个锁的解锁(unlock ),总是 happens-before 于随后对这个锁的加锁(lock)

3. volatile 变量规则
Volatile Variable Rule
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

4. 线程启动规则
Thread Start Rule
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

5. 线程加入规则
Thread Join Rule
Thread 对象的结束先行发生于 join() 方法返回。

6. 线程中断规则
Thread Interruption Rule
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
7. 对象终结规则
Finalizer Rule
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
8. 传递性
Transitivity
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
11. 线程安全
线程安全定义
一个类在可以被多个线程安全调用时就是线程安全的。
线程安全分类
线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
1. 不可变
不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施,只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态。
不可变的类型:
- final 关键字修饰的基本数据类型;
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的子类型的原子类 AtomicInteger 和 AtomicLong 则并非不可变的。
对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
public class ImmutableExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
unmodifiableMap.put("a", 1);
}
}
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
at ImmutableExample.main(ImmutableExample.java:9)
Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
2. 绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
3. 相对线程安全
相对的线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
对于下面的代码,如果删除元素的线程删除了一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。
public class VectorUnsafeExample {
private static Vector<Integer> vector = new Vector<>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 100; i++) {
vector.add(i);
}
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
});
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
});
executorService.shutdown();
}
}
}
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
at java.util.Vector.remove(Vector.java:831)
at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
}
});
4. 线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。
5. 线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
线程安全的实现方法
1. 阻塞同步(互斥同步)
synchronized 和 ReentrantLock。
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
2. 非阻塞同步
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。
硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
以下代码使用了 AtomicInteger 执行了自增的操作。
private AtomicInteger cnt = new AtomicInteger();
public void add() {
cnt.incrementAndGet();
}
以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt() 。
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值 ==var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
ABA :如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
3. 无同步方案
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
(一)可重入代码(Reentrant Code)
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
(二)栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在栈中,属于线程私有的。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class StackClosedExample {
public void add100() {
int cnt = 0;
for (int i = 0; i < 100; i++) {
cnt++;
}
System.out.println(cnt);
}
}
public static void main(String[] args) {
StackClosedExample example = new StackClosedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> example.add100());
executorService.execute(() -> example.add100());
executorService.shutdown();
}
100
100
(三)线程本地存储(Thread Local Storage)
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完,其中最重要的一个应用实例就是经典 Web 交互模型中的 “一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。
这是一个非常好的例题,请参考整理:
关于ThreadLocal类以下说法正确的是?_迅雷笔试题_牛客网
示例用法
先通过下面这个实例来理解 ThreadLocal 的用法。先声明一个 ThreadLocal 对象,存储布尔类型的数值。然后分别在main线程、Thread1、Thread2中为 ThreadLocal 对象设置不同的数值:
public class ThreadLocalDemo {
public static void main(String[] args) {
// 声明 ThreadLocal对象
ThreadLocal<Boolean> mThreadLocal = new ThreadLocal<Boolean>();
// 在主线程、子线程1、子线程2中去设置访问它的值
mThreadLocal.set(true);
System.out.println("Main " + mThreadLocal.get());
new Thread("Thread#1"){
@Override
public void run() {
mThreadLocal.set(false);
System.out.println("Thread#1 " + mThreadLocal.get());
}
}.start();
new Thread("Thread#2"){
@Override
public void run() {
System.out.println("Thread#2 " + mThreadLocal.get());
}
}.start();
}
}
打印的结果输出如下所示:
MainThread true
Thread#1 false
Thread#2 null
可以看见,在不同线程对同一个 ThreadLocal对象设置数值,在不同的线程中取出来的值不一样。接下来就分析一下源码,看看其内部结构。
结构概览

清晰的看到一个线程 Thread 中存在一个 ThreadLocalMap,ThreadLocalMap 中的 key 对应 ThreadLocal,在此处可见 Map 可以存储多个 key 即 (ThreadLocal)。另外 Value 就对应着在 ThreadLocal 中存储的 Value。
因此总结出:每个 Thread 中都具备一个 ThreadLocalMap,而 ThreadLocalMap 可以存储以 ThreadLocal 为key的键值对。这里解释了为什么每个线程访问同一个 ThreadLocal,得到的确是不同的数值。如果此处你觉得有点突兀,接下来看源码分析!
源码分析
1. ThreadLocal#set
public void set(T value) {
// 获取当前线程对象
Thread t = Thread.currentThread();
// 根据当前线程的对象获取其内部Map
ThreadLocalMap map = getMap(t);
// 注释1
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
如上所示,大部分解释已经在代码中做出,注意注释1处,得到 map 对象之后,用的 this 作为 key,this 在这里代表的是当前线程的 ThreadLocal 对象。 另外就是第二句根据 getMap 获取一个 ThreadLocalMap,其中getMap 中传入了参数 t (当前线程对象),这样就能够获取每个线程的 ThreadLocal 了。
继续跟进到 ThreadLocalMap 中查看 set 方法:
2. ThreadLocalMap
ThreadLocalMap 是 ThreadLocal 的一个内部类,在分析其 set 方法之前,查看一下其类结构和成员变量。
static class ThreadLocalMap {
// Entry类继承了WeakReference<ThreadLocal<?>>
// 即每个Entry对象都有一个ThreadLocal的弱引用(作为key),这是为了防止内存泄露。
// 一旦线程结束,key变为一个不可达的对象,这个Entry就可以被GC了。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// ThreadLocalMap 的初始容量,必须为2的倍数
private static final int INITIAL_CAPACITY = 16;
// resized时候需要的table
private Entry[] table;
// table中的entry个数
private int size = 0;
// 扩容数值
private int threshold; // Default to 0
}
一起看一下其常用的构造函数:
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
构造函数的第一个参数就是本 ThreadLocal 实例 (this),第二个参数就是要保存的线程本地变量。构造函数首先创建一个长度为16的 Entry 数组,然后计算出 firstKey 对应的哈希值,然后存储到 table 中,并设置 size 和 threshold。
注意一个细节,计算 hash 的时候里面采用了 hashCode & (size - 1) 的算法,这相当于取模运算 hashCode % size 的一个更高效的实现(和HashMap中的思路相同)。正是因为这种算法,我们要求 size必须是 2 的指数,因为这可以使得 hash 发生冲突的次数减小。
3. ThreadLocalMap#set
ThreadLocal 中 put 函数最终调用了 ThreadLocalMap 中的 set 函数,跟进去看一看:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
// 冲突了
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
在上述代码中如果 Entry 在存放过程中冲突了,调用 nextIndex 来处理,如下所示。是否还记得 hashmap 中对待冲突的处理?这里好像是另一种套路:只要 i 的数值小于 len,就加1取值,官方术语称为:线性探测法。
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
以上步骤ok了之后,再次关注一下源码中的 cleanSomeSlots,该函数主要的作用就是清理无用的 entry,避免出现内存泄露:
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
4. ThreadLocal#get
看完了 set 函数,肯定是要关注 get 的,源码如下所示:
public T get() {
// 获取Thread对象t
Thread t = Thread.currentThread();
// 获取t中的map
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 如果t中的map为空
return setInitialValue();
}
如果 map 为 null,就返回 setInitialValue() 这个方法,跟进这个方法看一下:
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
最后返回的是 value,而 value 来自 initialValue() ,进入这个源码中查看:
protected T initialValue() {
return null;
}
原来如此,如果不设置 ThreadLocal 的数值,默认就是 null,来自于此。
ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。
参考资料:
12. 锁优化
这里的锁优化主要是指虚拟机对 synchronized 的优化。
自旋锁
互斥同步的进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会“逃逸”到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
轻量级锁
JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。
以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 mark word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出,应该注意的是 state 表格不是存储在对象头中的。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。

下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。
偏向锁
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。

13. 多线程开发良好的实践
- 给线程起个有意义的名字,这样可以方便找 Bug。
- 缩小同步范围,例如对于 synchronized,应该尽量使用同步块而不是同步方法。
- 多用同步类少用 wait() 和 notify()。首先,CountDownLatch, CyclicBarrier, Semaphore 和 Exchanger 这些同步类简化了编码操作,而用 wait() 和 notify() 很难实现对复杂的控制流;其次,这些同步类是由最好的企业编写和维护,在后续的 JDK 中还会不断优化和完善,使用这些更高等级的同步工具你的程序可以不费吹灰之力获得优化。
- 多用并发集合少用同步集合,例如应该使用 ConcurrentHashMap 而不是 Hashtable。
- 使用本地变量和不可变类来保证线程安全。
- 使用线程池而不是直接创建 Thread 对象,这是因为创建线程代价很高,线程池可以有效地利用有限的线程来启动任务。
- 使用 BlockingQueue 实现生产者消费者问题。
14. 线程池实现原理
蘑菇街面试,设计一个线程池

并发队列
入队
非阻塞队列:当队列中满了时候,放入数据,数据丢失
阻塞队列:当队列满了的时候,进行等待,什么时候队列中有出队的数据,那么第11个再放进去
出队
非阻塞队列:如果现在队列中没有元素,取元素,得到的是null
阻塞队列:等待,什么时候放进去,再取出来
线程池使用的是阻塞队列
线程池概念
线程是稀缺资源,如果被无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,合理的使用线程池对线程进行统一分配、调优和监控,有以下好处:
- 降低资源消耗;
- 提高响应速度;
- 提高线程的可管理性。
Java1.5 中引入的 Executor 框架把任务的提交和执行进行解耦,只需要定义好任务,然后提交给线程池,而不用关心该任务是如何执行、被哪个线程执行,以及什么时候执行。
Executor类图

线程池工作原理
线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;如果当前线程数为 corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果阻塞队列满了,那就创建新的线程执行当前任务;直到线程池中的线程数达到 maxPoolSize,这时再有任务来,只能执行 reject() 处理该任务。
初始化线程池
- newFixedThreadPool() 说明:初始化一个指定线程数的线程池,其中 corePoolSize == maxiPoolSize,使用 LinkedBlockingQuene 作为阻塞队列 特点:即使当线程池没有可执行任务时,也不会释放线程。
- newCachedThreadPool() 说明:初始化一个可以缓存线程的线程池,默认缓存60s,线程池的线程数可达到 Integer.MAX_VALUE,即 2147483647,内部使用 SynchronousQueue 作为阻塞队列; 特点:在没有任务执行时,当线程的空闲时间超过 keepAliveTime,会自动释放线程资源;当提交新任务时,如果没有空闲线程,则创建新线程执行任务,会导致一定的系统开销; 因此,使用时要注意控制并发的任务数,防止因创建大量的线程导致而降低性能。
- newSingleThreadExecutor() 说明:初始化只有一个线程的线程池,内部使用 LinkedBlockingQueue 作为阻塞队列。 特点:如果该线程异常结束,会重新创建一个新的线程继续执行任务,唯一的线程可以保证所提交任务的顺序执行
- newScheduledThreadPool() 特点:初始化的线程池可以在指定的时间内周期性的执行所提交的任务,在实际的业务场景中可以使用该线程池定期的同步数据。
初始化方法
// 使用Executors静态方法进行初始化
ExecutorService service = Executors.newSingleThreadExecutor();
// 常用方法
service.execute(new Thread());
service.submit(new Thread());
service.shutDown();
service.shutDownNow();
常用方法
execute与submit的区别
- 接收的参数不一样
- submit有返回值,而execute没有
用到返回值的例子,比如说我有很多个做 validation 的 task,我希望所有的 task 执行完,然后每个 task 告诉我它的执行结果,是成功还是失败,如果是失败,原因是什么。然后我就可以把所有失败的原因综合起来发给调用者。
- submit方便Exception处理
如果你在你的 task 里会抛出 checked 或者 unchecked exception,而你又希望外面的调用者能够感知这些 exception 并做出及时的处理,那么就需要用到 submit,通过捕获 Future.get 抛出的异常。
shutDown与shutDownNow的区别
当线程池调用该方法时,线程池的状态则立刻变成 SHUTDOWN 状态。此时,则不能再往线程池中添加任何任务,否则将会抛出 RejectedExecutionException 异常。但是,此时线程池不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。
内部实现
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 线程存活时间(在 corePore<*<maxPoolSize 情况下有用)
TimeUnit unit, // 存活时间的时间单位
BlockingQueue<Runnable> workQueue // 阻塞队列(用来保存等待被执行的任务)
ThreadFactory threadFactory, // 线程工厂,主要用来创建线程;
RejectedExecutionHandler handler // 当拒绝处理任务时的策略
){
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
关于 workQueue 参数,有四种队列可供选择:
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,按 FIFO 排序任务;
- LinkedBlockingQuene:基于链表结构的阻塞队列,按 FIFO 排序任务;
- SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 ArrayBlockingQuene;
- PriorityBlockingQuene:具有优先级的无界阻塞队列;
关于 handler 参数,线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了 4 种策略:
- ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
- ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
- ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
- ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务
当然也可以根据应用场景实现 RejectedExecutionHandler 接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。
线程池的状态
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
其中 AtomicInteger 变量 ctl 的功能非常强大:利用低 29 位表示线程池中线程数,通过高 3 位表示线程池的运行状态:
- RUNNING:-1 << COUNT_BITS,即高 3 位为 111,该状态的线程池会接收新任务,并处理阻塞队列中的任务;
- SHUTDOWN: 0 << COUNT_BITS,即高 3 位为 000,该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;
- STOP : 1 << COUNT_BITS,即高 3 位为 001,该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;
- TIDYING : 2 << COUNT_BITS,即高 3 位为 010,该状态表示线程池对线程进行整理优化;
- TERMINATED: 3 << COUNT_BITS,即高 3 位为 011,该状态表示线程池停止工作;
线程池其他常用方法
如果执行了线程池的 prestartAllCoreThreads() 方法,线程池会提前创建并启动所有核心线程。 ThreadPoolExecutor 提供了动态调整线程池容量大小的方法:setCorePoolSize() 和 setMaximumPoolSize()。
如何合理设置线程池的大小
一般需要根据任务的类型来配置线程池大小: 如果是 CPU 密集型任务,就需要尽量压榨 CPU,参考值可以设为 NCPU+1 如果是 IO 密集型任务,参考值可以设置为 2*NCPU
- 参考资料
第二部分:面试指南
在这里将总结面试中和并发编程相关的常见知识点,如在第一部分中出现的这里将不进行详细阐述。面试指南中,我将用最简洁的语言描述,更多是以一种大纲的形式列出问答点,根据自己掌握的情况回答。
参考资料:
1. volatile 与 synchronized 的区别
(1)仅靠volatile不能保证线程的安全性。(原子性)
- ① volatile 轻量级,只能修饰变量。synchronized重量级,还可修饰方法
- ② volatile 只能保证数据的可见性,不能用来同步,因为多个线程并发访问 volatile 修饰的变量不会阻塞。
synchronized 不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢 synchronized 锁对象时,会出现阻塞。
(2)线程安全性
线程安全性包括两个方面,①可见性。②原子性。
从上面自增的例子中可以看出:仅仅使用 volatile 并不能保证线程安全性。而 synchronized 则可实现线程的安全性。
2. 什么是线程池?如果让你设计一个动态大小的线程池,如何设计,应该有哪些方法?线程池创建的方式?
- 什么是线程池
- 线程池顾名思义就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销。
- 设计一个动态大小的线程池,如何设计,应该有哪些方法
- 一个线程池包括以下四个基本组成部分:
- 线程管理器 (ThreadPool):用于创建并管理线程池,包括创建线程,销毁线程池,添加新任务;
- 工作线程 (PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
- 任务接口 (Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等;
- 任务队列 (TaskQueue):用于存放没有处理的任务。提供一种缓冲机制;
- 所包含的方法
- private ThreadPool() 创建线程池
- public static ThreadPool getThreadPool() 获得一个默认线程个数的线程池
- public void execute(Runnable task) 执行任务,其实只是把任务加入任务队列,什么时候执行有线程池管理器决定
- public void execute(Runnable[] task) 批量执行任务,其实只是把任务加入任务队列,什么时候执行有线程池管理器决定
- public void destroy() 销毁线程池,该方法保证在所有任务都完成的情况下才销毁所有线程,否则等待任务完成才销毁
- public int getWorkThreadNumber() 返回工作线程的个数
- public int getFinishedTasknumber() 返回已完成任务的个数,这里的已完成是只出了任务队列的任务个数,可能该任务并没有实际执行完成
- public void addThread() 在保证线程池中所有线程正在执行,并且要执行线程的个数大于某一值时。增加线程池中线程的个数
- public void reduceThread() 在保证线程池中有很大一部分线程处于空闲状态,并且空闲状态的线程在小于某一值时,减少线程池中线程的个数
- 线程池四种创建方式
Java 通过 Executors 提供四种线程池,分别为:
- new CachedThreadPool 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- new FixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- new ScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
- new SingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
3. 什么是并发和并行

并发
- 并发是指两个任务都请求运行,而处理器只能按受一个任务,就把这两个任务安排轮流进行,由于时间间隔较短,使人感觉两个任务都在运行。
- 如果用一台电脑我先给甲发个消息,然后立刻再给乙发消息,然后再跟甲聊,再跟乙聊。这就叫并发。
- 多个线程操作相同的资源,保证线程安全,合理使用资源
并行
- 并行就是两个任务同时运行,就是甲任务进行的同时,乙任务也在进行。(需要多核CPU)
- 比如我跟两个网友聊天,左手操作一个电脑跟甲聊,同时右手用另一台电脑跟乙聊天,这就叫并行。
- 服务能同时处理很多请求,提高程序性能
参考资料:
4. 什么是线程安全
当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。——来自《深入理解Java虚拟机》
- 定义
- 某个类的行为与其规范一致。
- 不管多个线程是怎样的执行顺序和优先级,或是 wait , sleep , join 等控制方式,如果一个类在多线程访问下运转一切正常,并且访问类不需要进行额外的同步处理或者协调,那么我们就认为它是线程安全的。
- 如何保证线程安全?(更加详细的请转向第一部分
11. 线程安全)
- 对变量使用 volitate
- 对程序段进行加锁 (synchronized , lock)
- 注意
- 非线程安全的集合在多线程环境下可以使用,但并不能作为多个线程共享的属性,可以作为某个线程独享的属性。
- 例如 Vector 是线程安全的,ArrayList 不是线程安全的。如果每一个线程中 new 一个 ArrayList,而这个ArrayList 只是在这一个线程中使用,肯定没问题。
非线程安全!=不安全?
有人在使用过程中有一个不正确的观点:我的程序是多线程的,不能使用 ArrayList 要使用 Vector,这样才安全。
非线程安全并不是多线程环境下就不能使用。注意我上面有说到:多线程操作同一个对象。注意是同一个对象。比如最上面那个模拟,就是在主线程中 new 的一个 ArrayList 然后多个线程操作同一个 ArrayList 对象。
如果是每个线程中 new 一个 ArrayList,而这个 ArrayList 只在这一个线程中使用,那么肯定是没问题的。
线程安全十万个为什么?
问:平时项目中使用锁和 synchronized 比较多,而很少使用 volatile,难道就没有保证可见性? 答:锁和 synchronized 即可以保证原子性,也可以保证可见性。都是通过保证同一时间只有一个线程执行目标代码段来实现的。
问:锁和 synchronized 为何能保证可见性?
答:根据 JDK 7的Java doc 中对 concurrent 包的说明,一个线程的写结果保证对另外线程的读操作可见,只要该写操作可以由 happen-before 原则推断出在读操作之前发生。
The results of a write by one thread are guaranteed to be visible to a read by another thread only if the write operation happens-before the read operation. The synchronized and volatile constructs, as well as the Thread.start() and Thread.join() methods, can form happens-before relationships.
问:既然锁和 synchronized 即可保证原子性也可保证可见性,为何还需要 volatile? 答:synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而 volatile 开销小很多。因此在只需要保证可见性的条件下,使用 volatile 的性能要比使用锁和 synchronized 高得多。
问:既然锁和 synchronized 可以保证原子性,为什么还需要 AtomicInteger 这种的类来保证原子操作? 答:锁和 synchronized 需要通过操作系统来仲裁谁获得锁,开销比较高,而 AtomicInteger 是通过CPU级的CAS操作来保证原子性,开销比较小。所以使用 AtomicInteger 的目的还是为了提高性能。
问:还有没有别的办法保证线程安全 答:有。尽可能避免引起非线程安全的条件——共享变量。如果能从设计上避免共享变量的使用,即可避免非线程安全的发生,也就无须通过锁或者 synchronized 以及 volatile 解决原子性、可见性和顺序性的问题。
问:synchronized 即可修饰非静态方式,也可修饰静态方法,还可修饰代码块,有何区别 答:synchronized 修饰非静态同步方法时,锁住的是当前实例;synchronized 修饰静态同步方法时,锁住的是该类的 Class 对象;synchronized 修饰静态代码块时,锁住的是 synchronized 关键字后面括号内的对象。
参考资料:
5. volatile 关键字的如何保证内存可见性
- volatile 关键字的作用
- 保证内存的可见性
- 防止指令重排
- 注意:volatile 并不保证原子性
- 内存可见性
- volatile 保证可见性的原理是在每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。所以 volatile 关键字的作用之一就是保证变量修改的实时可见性。
- 当且仅当满足以下所有条件时,才应该使用 volatile 变量
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量没有包含在具有其他变量的不变式中。
- volatile 使用建议
- 在两个或者更多的线程需要访问的成员变量上使用 volatile。当要访问的变量已在 synchronized 代码块中,或者为常量时,没必要使用volatile。
- 由于使用 volatile 屏蔽掉了 JVM 中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。
- volatile 和 synchronized区别
- volatile 不会进行加锁操作:
volatile 变量是一种稍弱的同步机制在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 synchronized 关键字更轻量级的同步机制。
- volatile 变量作用类似于同步变量读写操作:
从内存可见性的角度看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量相当于进入同步代码块。
- volatile 不如 synchronized安全:
在代码中如果过度依赖 volatile 变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当 volatile 变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
- volatile 无法同时保证内存可见性和原子性:
加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而 volatile 变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么 volatile 关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。
5. 什么是线程?线程和进程有什么区别?为什么要使用多线程
(1)线程和进程
- 进程是操作系统分配资源的最小单位
- 线程是CPU调度的最小单位
(2)使用线程的原因
- 使用多线程可以减少程序的响应时间;
- 与进程相比,线程的创建和切换开销更小;
- 多核电脑上,可以同时执行多个线程,提高资源利用率;
- 简化程序的结构,使程序便于理解和维护;
6. 多线程共用一个数据变量需要注意什么?
- 当我们在线程对象(Runnable)中定义了全局变量,run方法会修改该变量时,如果有多个线程同时使用该线程对象,那么就会造成全局变量的值被同时修改,造成错误.
- ThreadLocal 是JDK引入的一种机制,它用于解决线程间共享变量,使用 ThreadLocal 声明的变量,即使在线程中属于全局变量,针对每个线程来讲,这个变量也是独立的。
- volatile 变量每次被线程访问时,都强迫线程从主内存中重读该变量的最新值,而当该变量发生修改变化时,也会强迫线程将最新的值刷新回主内存中。这样一来,不同的线程都能及时的看到该变量的最新值。
7. 内存泄漏与内存溢出
Java内存回收机制
不论哪种语言的内存分配方式,都需要返回所分配内存的真实地址,也就是返回一个指针到内存块的首地址。Java中对象是采用 new、反射、clone、反序列化等方法创建的, 这些对象的创建都是在堆(Heap)中分配的,所有对象的回收都是由Java虚拟机通过垃圾回收机制完成的。GC 为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控,Java 会使用有向图的方法进行管理内存,实时监控对象是否可以达到,如果不可到达,则就将其回收,这样也可以消除引用循环的问题。
在 Java 语言中,判断一个内存空间是否符合垃圾收集标准有两个:一个是给对象赋予了空值 null,以下再没有调用过,另一个是给对象赋予了新值,这样重新分配了内存空间。
Java内存泄露引起原因
首先,什么是内存泄露?经常听人谈起内存泄露,但要问什么是内存泄露,没几个说得清楚。
内存泄露:是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成的内存空间的浪费称为内存泄露。内存泄露有时不严重且不易察觉,这样开发者就不知道存在内存泄露,但有时也会很严重,会提示 Out of memory。
内存溢出:指程序运行过程中无法申请到足够的内存而导致的一种错误。内存泄露是内存溢出的一种诱因,不是唯一因素 那么,Java 内存泄露根本原因是什么呢?长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是 Java 中内存泄露的发生场景。具体主要有如下几大类
静态集合类
静态集合类,使用Set、Vector、HashMap等集合类的时候需要特别注意。当这些类被定义成静态的时候,由于他们的生命周期跟应用程序一样长,这时候就有可能发生内存泄漏。
// 例子
class StaticTest
{
private static Vector v = new Vector(10);
public void init()
{
for (int i = 1; i < 100; i++)
{
Object object = new Object();
v.add(object);
object = null;
}
}
}
在上面的代码中,循环申请object对象,并添加到Vector中,然后设置object=null(就是清除栈中引用变量object),但是这些对象被vector引用着,必然不能被GC回收,造成内存泄露。因此要释放这些对象,还需要将它们从vector中删除,最简单的方法就是将vector=null,清空集合类中的引用。
监听器
在 Java 编程中,我们都需要和监听器打交道,通常一个应用中会用到很多监听器,我们会调用一个控件,诸如 addXXXListener() 等方法来增加监听器,但往往在释放的时候却没有去删除这些监听器,从而增加了内存泄漏的机会。
各种连接
比如数据库连接(dataSourse.getConnection()),网络连接 (socket) 和 IO 连接,除非其显式的调用了其close() 方 法将其连接关闭,否则是不会自动被 GC 回收的。对于 Resultset 和 Statement 对象可以不进行显式回收,但 Connection 一定要显式回收,因为 Connection 在任何时候都无法自动回收,而 Connection一旦回收,Resultset 和 Statement 对象就会立即为 NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭 Resultset Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的 Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在 try 里面去的连接,在 finally 里面释放连接。
内部类和外部模块等的引用
内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。在调用外部模块的时候,也应该注意防止内存泄漏,如果模块A调用了外部模块B的一个方法,如: public void register(Object o) 这个方法有可能就使得A模块持有传入对象的引用,这时候需要查看B模块是否提供了出去引用的方法,这种情况容易忽略,而且发生内存泄漏的话,还比较难察觉。
单例模式
因为单利对象初始化后将在 JVM 的整个生命周期内存在,如果它持有一个外部对象的(生命周期比较短)引用,那么这个外部对象就不能被回收,从而导致内存泄漏。如果这个外部对象还持有其他对象的引用,那么内存泄漏更严重。
8. 如何减少线程上下文切换
使用多线程时,不是多线程能提升程序的执行速度,使用多线程是为了更好地利用 CPU 资源!
程序在执行时,多线程是 CPU 通过给每个线程分配 CPU 时间片来实现的,时间片是CPU分配给每个线程执行的时间,因时间片非常短,所以CPU 通过不停地切换线程执行。
线程不是越多就越好的,因为线程上下文切换是有性能损耗的,在使用多线程的同时需要考虑如何减少上下文切换
一般来说有以下几条经验
- 无锁并发编程。多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照Hash取模分段,不同的线程处理不同段的数据
- CAS算法。Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
- 控制线程数量。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态
- 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
- 协程可以看成是用户态自管理的“线程”。不会参与CPU时间调度,没有均衡分配到时间。非抢占式的
还可以考虑我们的应用是IO密集型的还是CPU密集型的。
- 如果是IO密集型的话,线程可以多一些。
- 如果是CPU密集型的话,线程不宜太多。
9. 线程间通信和进程间通信
线程间通信
- synchronized 同步
- 这种方式,本质上就是 “共享内存” 式的通信。多个线程需要访问同一个共享变量,谁拿到了锁(获得了访问权限),谁就可以执行。
- while 轮询的方式
- 在这种方式下,线程A不断地改变条件,线程 ThreadB 不停地通过 while 语句检测这个条件
(list.size()==5)是否成立 ,从而实现了线程间的通信。但是这种方式会浪费 CPU 资源。之所以说它浪费资源,是因为 JVM 调度器将 CPU 交给线程B执行时,它没做啥“有用”的工作,只是在不断地测试某个条件是否成立。就类似于现实生活中,某个人一直看着手机屏幕是否有电话来了,而不是: 在干别的事情,当有电话来时,响铃通知TA电话来了。
- wait/notify 机制
- 当条件未满足时,线程A调用 wait() 放弃CPU,并进入阻塞状态。(不像 while 轮询那样占用 CPU)
当条件满足时,线程B调用 notify() 通知线程A,所谓通知线程A,就是唤醒线程A,并让它进入可运行状态。
- 管道通信
- java.io.PipedInputStream 和 java.io.PipedOutputStream 进行通信
进程间通信
- 管道(Pipe) :管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。
- 命名管道(named pipe) :命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关 系 进程间的通信。命名管道在文件系统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。
- 信号(Signal) :信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送 信号给进程本身;Linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。
- 消息(Message)队列 :消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺
- 共享内存 :使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
- 内存映射(mapped memory) :内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的进程地址空间来实现它。
- 信号量(semaphore) :主要作为进程间以及同一进程不同线程之间的同步手段。
- 套接口(Socket) :更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:linux和System V的变种都支持套接字。
参考资料:
10. 什么是同步和异步,阻塞和非阻塞?
同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)
同步
- 在发出一个同步调用时,在没有得到结果之前,该调用就不返回。
- 例如:按下电饭锅的煮饭按钮,然后等待饭煮好,把饭盛出来,然后再去炒菜。
异步
- 在发出一个异步调用后,调用者不会立刻得到结果,该调用就返回了。
- 例如:按下电钮锅的煮饭按钮,直接去炒菜或者做别的事情,当电饭锅“滴滴滴”响的时候,再回去把饭盛出来。显然,异步式编程要比同步式编程高效得多。
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.
阻塞
- 调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 例子:你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果
非阻塞
- 在不能立刻得到结果之前,该调用不会阻塞当前线程。
- 例子:你打电话问书店老板有没有《分布式系统》这本书,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果。
参考资料:
11. Java中的锁
本小结参考:Java 中的锁 - Java 并发性和多线程 - 极客学院Wiki
锁像 synchronized 同步块一样,是一种线程同步机制,但比 Java 中的 synchronized 同步块更复杂。因为锁(以及其它更高级的线程同步机制)是由 synchronized 同步块的方式实现的,所以我们还不能完全摆脱 synchronized 关键字(译者注:这说的是 Java 5 之前的情况)。
自 Java 5 开始,java.util.concurrent.locks 包中包含了一些锁的实现,因此你不用去实现自己的锁了。但是你仍然需要去了解怎样使用这些锁,且了解这些实现背后的理论也是很有用处的。可以参考我对 java.util.concurrent.locks.Lock 的介绍,以了解更多关于锁的信息。
一个简单的锁
让我们从 java 中的一个同步块开始:
public class Counter{
private int count = 0;
public int inc(){
synchronized(this){
return ++count;
}
}
}
可以看到在 inc()方法中有一个 synchronized(this)代码块。该代码块可以保证在同一时间只有一个线程可以执行 return ++count。虽然在 synchronized 的同步块中的代码可以更加复杂,但是++count 这种简单的操作已经足以表达出线程同步的意思。
以下的 Counter 类用 Lock 代替 synchronized 达到了同样的目的:
public class Counter{
private Lock lock = new Lock();
private int count = 0;
public int inc(){
lock.lock();
int newCount = ++count;
lock.unlock();
return newCount;
}
}
lock()方法会对 Lock 实例对象进行加锁,因此所有对该对象调用 lock()方法的线程都会被阻塞,直到该 Lock 对象的 unlock()方法被调用。
这里有一个 Lock 类的简单实现:
public class Counter{
public class Lock{
private boolean isLocked = false;
public synchronized void lock()
throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
}
注意其中的 while(isLocked) 循环,它又被叫做 “自旋锁”。自旋锁以及 wait() 和 notify() 方法在线程通信这篇文章中有更加详细的介绍。当 isLocked 为 true 时,调用 lock() 的线程在 wait() 调用上阻塞等待。为防止该线程没有收到 notify() 调用也从 wait() 中返回(也称作虚假唤醒),这个线程会重新去检查 isLocked 条件以决定当前是否可以安全地继续执行还是需要重新保持等待,而不是认为线程被唤醒了就可以安全地继续执行了。如果 isLocked 为 false,当前线程会退出 while(isLocked) 循环,并将 isLocked 设回 true,让其它正在调用 lock() 方法的线程能够在 Lock 实例上加锁。
当线程完成了临界区(位于 lock()和 unlock()之间)中的代码,就会调用 unlock()。执行 unlock()会重新将 isLocked 设置为 false,并且通知(唤醒)其中一个(若有的话)在 lock()方法中调用了 wait()函数而处于等待状态的线程。
锁的可重入性
Java 中的 synchronized 同步块是可重入的。这意味着如果一个 Java 线程进入了代码中的 synchronized 同步块,并因此获得了该同步块使用的同步对象对应的管程上的锁,那么这个线程可以进入由同一个管程对象所同步的另一个 java 代码块。下面是一个例子:
public class Reentrant{
public synchronized outer(){
inner();
}
public synchronized inner(){
//do something
}
}
注意 outer()和 inner()都被声明为 synchronized,这在 Java 中和 synchronized(this) 块等效。如果一个线程调用了 outer(),在 outer()里调用 inner()就没有什么问题,因为这两个方法(代码块)都由同一个管程对象(”this”) 所同步。如果一个线程已经拥有了一个管程对象上的锁,那么它就有权访问被这个管程对象同步的所有代码块。这就是可重入。线程可以进入任何一个它已经拥有的锁所同步着的代码块。
前面给出的锁实现不是可重入的。如果我们像下面这样重写 Reentrant 类,当线程调用 outer() 时,会在 inner()方法的 lock.lock() 处阻塞住。
public class Reentrant2{
Lock lock = new Lock();
public outer(){
lock.lock();
inner();
lock.unlock();
}
public synchronized inner(){
lock.lock();
//do something
lock.unlock();
}
}
调用 outer() 的线程首先会锁住 Lock 实例,然后继续调用 inner()。inner()方法中该线程将再一次尝试锁住 Lock 实例,结果该动作会失败(也就是说该线程会被阻塞),因为这个 Lock 实例已经在 outer()方法中被锁住了。
两次 lock()之间没有调用 unlock(),第二次调用 lock 就会阻塞,看过 lock() 实现后,会发现原因很明显:
public class Lock{
boolean isLocked = false;
public synchronized void lock()
throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
...
}
一个线程是否被允许退出 lock()方法是由 while 循环(自旋锁)中的条件决定的。当前的判断条件是只有当 isLocked 为 false 时 lock 操作才被允许,而没有考虑是哪个线程锁住了它。
为了让这个 Lock 类具有可重入性,我们需要对它做一点小的改动:
public class Lock{
boolean isLocked = false;
Thread lockedBy = null;
int lockedCount = 0;
public synchronized void lock()
throws InterruptedException{
Thread callingThread =
Thread.currentThread();
while(isLocked && lockedBy != callingThread){
wait();
}
isLocked = true;
lockedCount++;
lockedBy = callingThread;
}
public synchronized void unlock(){
if(Thread.curentThread() ==
this.lockedBy){
lockedCount--;
if(lockedCount == 0){
isLocked = false;
notify();
}
}
}
...
}
注意到现在的 while 循环(自旋锁)也考虑到了已锁住该 Lock 实例的线程。如果当前的锁对象没有被加锁(isLocked = false),或者当前调用线程已经对该 Lock 实例加了锁,那么 while 循环就不会被执行,调用 lock()的线程就可以退出该方法(译者注:“被允许退出该方法”在当前语义下就是指不会调用 wait()而导致阻塞)。
除此之外,我们需要记录同一个线程重复对一个锁对象加锁的次数。否则,一次 unblock()调用就会解除整个锁,即使当前锁已经被加锁过多次。在 unlock()调用没有达到对应 lock()调用的次数之前,我们不希望锁被解除。
现在这个 Lock 类就是可重入的了。
锁的公平性
Java 的 synchronized 块并不保证尝试进入它们的线程的顺序。因此,如果多个线程不断竞争访问相同的 synchronized 同步块,就存在一种风险,其中一个或多个线程永远也得不到访问权 —— 也就是说访问权总是分配给了其它线程。这种情况被称作线程饥饿。为了避免这种问题,锁需要实现公平性。本文所展现的锁在内部是用 synchronized 同步块实现的,因此它们也不保证公平性。饥饿和公平中有更多关于该内容的讨论。
在 finally 语句中调用 unlock()
如果用 Lock 来保护临界区,并且临界区有可能会抛出异常,那么在 finally 语句中调用 unlock()就显得非常重要了。这样可以保证这个锁对象可以被解锁以便其它线程能继续对其加锁。以下是一个示例:
lock.lock();
try{
//do critical section code,
//which may throw exception
} finally {
lock.unlock();
}
这个简单的结构可以保证当临界区抛出异常时 Lock 对象可以被解锁。如果不是在 finally 语句中调用的 unlock(),当临界区抛出异常时,Lock 对象将永远停留在被锁住的状态,这会导致其它所有在该 Lock 对象上调用 lock()的线程一直阻塞。
12. 并发包(J.U.C)下面,都用过什么
- concurrent下面的包
- Executor 用来创建线程池,在实现Callable接口时,添加线程。
- FeatureTask 此 FutureTask 的 get 方法所返回的结果类型。
- TimeUnit
- Semaphore
- LinkedBlockingQueue
- 所用过的类
- Executor
13. 从volatile说到,i++原子操作,线程安全问题
从 volatile 说到,i++原子操作,线程安全问题 - CSDN博客 https://blog.csdn.net/zbw18297786698/article/details/53420780
参考资料
更新日志
- 2018/9/2 v3.0
Java IO
- [1、磁盘操作(File)](#1磁盘操作file)
- [2、字节操作(*Stream)](#2字节操作stream)
- [3、字符操作(*Reader | *Writer)](#3字符操作reader--writer)
- [4、Java序列化,如何实现序列化和反序列化,常见的序列化协议有哪些?](#4java序列化如何实现序列化和反序列化常见的序列化协议有哪些)
- [Java序列化定义](#java序列化定义)
- [如何实现序列化和反序列化,底层怎么实现](#如何实现序列化和反序列化底层怎么实现)
- [相关注意事项](#相关注意事项)
- [常见的序列化协议有哪些](#常见的序列化协议有哪些)
- [5、同步和异步](#5同步和异步)
- [6、Java中的NIO,BIO,AIO分别是什么](#6java中的niobioaio分别是什么)
- [BIO](#bio)
- [NIO](#nio)
- [AIO (NIO.2)](#aio-nio2)
- [总结](#总结)
- [7、BIO,NIO,AIO区别](#7bionioaio区别)
- [8、Stock通信的伪代码实现流程](#8stock通信的伪代码实现流程)
- [9、网络操作](#9网络操作)
- [InetAddress](#inetaddress)
- [URL](#url)
- [Sockets](#sockets)
- [Datagram](#datagram)
- [什么是Socket?](#什么是socket)
Java IO
Java 的 I/O 大概可以分成以下几类:
- 磁盘操作:File
- 字节操作:InputStream 和 OutputStream
- 字符操作:Reader 和 Writer
- 对象操作:Serializable
- 网络操作:Socket
- 新的输入/输出:NIO
1、磁盘操作(File)
File 类可以用于表示文件和目录的信息,但是它不表示文件的内容。
递归地输出一个目录下所有文件:
public static void listAllFiles(File dir)
{
if (dir == null || !dir.exists()) {
return;
}
if (dir.isFile()) {
System.out.println(dir.getName());
return;
}
for (File file : dir.listFiles()) {
listAllFiles(file);
}
}
2、字节操作(*Stream)
使用字节流操作进行文件复制:
public static void copyFile(String src, String dist) throws IOException
{
FileInputStream in = new FileInputStream(src);
FileOutputStream out = new FileOutputStream(dist);
byte[] buffer = new byte[20 * 1024];
// read() 最多读取 buffer.length 个字节
// 返回的是实际读取的个数
// 返回 -1 的时候表示读到 eof,即文件尾
while (in.read(buffer, 0, buffer.length) != -1) {
out.write(buffer);
}
in.close();
out.close();
}
Java I/O 使用了装饰者模式来实现。以 InputStream 为例,InputStream 是抽象组件,FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作。FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能,例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。
实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可。
FileInputStream fileInputStream = new FileInputStream(filePath);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
DataInputStream 装饰者提供了对更多数据类型进行输入的操作,比如 int、double 等基本类型。
3、字符操作(Reader | Writer)
不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符。但是在程序中操作的通常是字符形式的数据,因此需要提供对字符进行操作的方法。
- InputStreamReader 实现从字节流解码成字符流;
- OutputStreamWriter 实现字符流编码成为字节流。
逐行输出文本文件的内容:
public static void readFileContent(String filePath) throws IOException
{
FileReader fileReader = new FileReader(filePath);
BufferedReader bufferedReader = new BufferedReader(fileReader);
String line;
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
// 装饰者模式使得 BufferedReader 组合了一个 Reader 对象
// 在调用 BufferedReader 的 close() 方法时会去调用 fileReader 的 close() 方法
// 因此只要一个 close() 调用即可
bufferedReader.close();
}
编码就是把字符转换为字节,而解码是把字节重新组合成字符。
如果编码和解码过程使用不同的编码方式那么就出现了乱码。
- GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节;
- UTF-8 编码中,中文字符占 3 个字节,英文字符占 1 个字节;
- UTF-16be 编码中,中文字符和英文字符都占 2 个字节。
UTF-16be 中的 be 指的是 Big Endian,也就是大端。相应地也有 UTF-16le,le 指的是 Little Endian,也就是小端。
Java 使用双字节编码 UTF-16be,这不是指 Java 只支持这一种编码方式,而是说 char 这种类型使用 UTF-16be 进行编码。char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储。
String 可以看成一个字符序列,可以指定一个编码方式将它转换为字节序列,也可以指定一个编码方式将一个字节序列转换为 String。
String str1 = "中文";
byte[] bytes = str1.getBytes("UTF-8");
String str2 = new String(bytes, "UTF-8");
System.out.println(str2);
在调用无参数 getBytes() 方法时,默认的编码方式不是 UTF-16be。双字节编码的好处是可以使用一个 char 存储中文和英文,而将 String 转为 bytes[] 字节数组就不再需要这个好处,因此也就不再需要双字节编码。getBytes() 的默认编码方式与平台有关,一般为 UTF-8。
byte[] bytes = str1.getBytes();
4、Java序列化,如何实现序列化和反序列化,常见的序列化协议有哪些?
Java序列化定义
(1)Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程;
(2)序列化:对象序列化的最主要的用处就是在传递和保存对象的时候,保证对象的完整性和可传递性。序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中。序列化后的字节流保存了Java对象的状态以及相关的描述信息。序列化机制的核心作用就是对象状态的保存与重建。
(3)反序列化:客户端从文件中或网络上获得序列化后的对象字节流后,根据字节流中所保存的对象状态及描述信息,通过反序列化重建对象。
(4)本质上讲,序列化就是把实体对象状态按照一定的格式写入到有序字节流,反序列化就是从有序字节流重建对象,恢复对象状态。
如何实现序列化和反序列化,底层怎么实现
1、JDK类库中序列化和反序列化API
(1)java.io.ObjectOutputStream:表示对象输出流;
它的writeObject(Object obj)方法可以对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中;
(2)java.io.ObjectInputStream:表示对象输入流;
它的readObject()方法源输入流中读取字节序列,再把它们反序列化成为一个对象,并将其返回;
2、实现序列化的要求
只有实现了 Serializable 或 Externalizable 接口的类的对象才能被序列化,否则抛出异常!
3、实现Java对象序列化与反序列化的方法
假定一个User类,它的对象需要序列化,可以有如下三种方法:
- 若 User 类仅仅实现了 Serializable 接口,则可以按照以下方式进行序列化和反序列化
- ObjectOutputStream 采用默认的序列化方式,对 User 对象的非 transient 的实例变量进行序列化。
- ObjcetInputStream 采用默认的反序列化方式,对对 User 对象的非 transient 的实例变量进行反序列化。
- 若User类仅仅实现了Serializable接口,并且还定义了
readObject(ObjectInputStream in)和writeObject(ObjectOutputSteam out),则采用以下方式进行序列化与反序列化。- ObjectOutputStream 调用 User 对象的 writeObject(ObjectOutputStream out) 的方法进行序列化。
- ObjectInputStream 会调用 User 对象的 readObject(ObjectInputStream in) 的方法进行反序列化。
- 若User类实现了 Externalnalizable 接口,且 User 类必须实现
readExternal(ObjectInput in)和writeExternal(ObjectOutput out)方法,则按照以下方式进行序列化与反序列化。- ObjectOutputStream 调用 User 对象的 writeExternal(ObjectOutput out)) 的方法进行序列化。
- ObjectInputStream 会调用User对象的 readExternal(ObjectInput in) 的方法进行反序列化。
4、JDK类库中序列化的步骤
步骤一:创建一个对象输出流,它可以包装一个其它类型的目标输出流,如文件输出流:
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\\object.out"));
步骤二:通过对象输出流的writeObject()方法写对象:
oos.writeObject(new User("xuliugen", "123456", "male"));
5、JDK类库中反序列化的步骤
步骤一:创建一个对象输入流,它可以包装一个其它类型输入流,如文件输入流:
ObjectInputStream ois= new ObjectInputStream(new FileInputStream("object.out"));
步骤二:通过对象输出流的readObject()方法读取对象:
User user = (User) ois.readObject();
说明:为了正确读取数据,完成反序列化,必须保证向对象输出流写对象的顺序与从对象输入流中读对象的顺序一致。
6、序列化和反序列化的示例
为了更好地理解Java序列化与反序列化,举一个简单的示例如下:
public class SerialDemo {
public static void main(String[] args) throws IOException, ClassNotFoundException {
//序列化
FileOutputStream fos = new FileOutputStream("object.out");
ObjectOutputStream oos = new ObjectOutputStream(fos);
User user1 = new User("xuliugen", "123456", "male");
oos.writeObject(user1);
oos.flush();
oos.close();
//反序列化
FileInputStream fis = new FileInputStream("object.out");
ObjectInputStream ois = new ObjectInputStream(fis);
User user2 = (User) ois.readObject();
System.out.println(user2.getUserName()+ " " +
user2.getPassword() + " " + user2.getSex());
//反序列化的输出结果为:xuliugen 123456 male
}
}
public class User implements Serializable {
private String userName;
private String password;
private String sex;
//全参构造方法、get和set方法省略
}
相关注意事项
1、序列化时,只对对象的状态进行保存,而不管对象的方法;
2、当一个父类实现序列化,子类自动实现序列化,不需要显式实现 Serializable 接口;
3、当一个对象的实例变量引用其他对象,序列化该对象时也把引用对象进行序列化;
4、并非所有的对象都可以序列化,至于为什么不可以,有很多原因了,比如:
- 安全方面的原因,比如一个对象拥有private,public等field,对于一个要传输的对象,比如写到文件,或者进行RMI传输等等,在序列化进行传输的过程中,这个对象的private等域是不受保护的;
- 资源分配方面的原因,比如socket,thread类,如果可以序列化,进行传输或者保存,也无法对他们进行重新的资源分配,而且,也是没有必要这样实现;
5、声明为static和transient类型的成员数据不能被序列化。因为static代表类的状态,transient代表对象的临时数据。
6、序列化运行时使用一个称为 serialVersionUID 的版本号与每个可序列化类相关联,该序列号在反序列化过程中用于验证序列化对象的发送者和接收者是否为该对象加载了与序列化兼容的类。为它赋予明确的值。显式地定义serialVersionUID有两种用途:
- 在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的serialVersionUID;
- 在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。
7、Java有很多基础类已经实现了serializable接口,比如String , Vector等。但是也有一些没有实现serializable接口的;
8、如果一个对象的成员变量是一个对象,那么这个对象的数据成员也会被保存!这是能用序列化解决深拷贝的重要原因;
ArrayList 序列化和反序列化的实现 :ArrayList 中存储数据的数组是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。
private transient Object[] elementData;
参考资料:
常见的序列化协议有哪些
- COM主要用于Windows平台,并没有真正实现跨平台,另外COM的序列化的原理利用了编译器中虚表,使得其学习成本巨大。
- CORBA是早期比较好的实现了跨平台,跨语言的序列化协议。COBRA的主要问题是参与方过多带来的版本过多,版本之间兼容性较差,以及使用复杂晦涩。
- XML & SOAP
- XML是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点。
- SOAP(Simple Object Access protocol) 是一种被广泛应用的,基于XML为序列化和反序列化协议的结构化消息传递协议。SOAP具有安全、可扩展、跨语言、跨平台并支持多种传输层协议。
- JSON(JavaScript Object Notation)
- 这种Associative array格式非常符合工程师对对象的理解。
- 它保持了XML的人眼可读(Human-readable)的优点。
- 相对于XML而言,序列化后的数据更加简洁。
- 它具备javascript的先天性支持,所以被广泛应用于Web browser的应用常景中,是Ajax的事实标准协议。
- 与XML相比,其协议比较简单,解析速度比较快。
- 松散的Associative array使得其具有良好的可扩展性和兼容性。
- Thrift是Facebook开源提供的一个高性能,轻量级RPC服务框架,其产生正是为了满足当前大数据量、分布式、跨语言、跨平台数据通讯的需求。Thrift在空间开销和解析性能上有了比较大的提升,对于对性能要求比较高的分布式系统,它是一个优秀的RPC解决方案;但是由于Thrift的序列化被嵌入到Thrift框架里面,Thrift框架本身并没有透出序列化和反序列化接口,这导致其很难和其他传输层协议共同使用
- Protobuf具备了优秀的序列化协议的所需的众多典型特征
- 标准的IDL和IDL编译器,这使得其对工程师非常友好。
- 序列化数据非常简洁,紧凑,与XML相比,其序列化之后的数据量约为1/3到1/10。
- 解析速度非常快,比对应的XML快约20-100倍。
- 提供了非常友好的动态库,使用非常简介,反序列化只需要一行代码。由于其解析性能高,序列化后数据量相对少,非常适合应用层对象的持久化场景
- Avro的产生解决了JSON的冗长和没有IDL的问题,Avro属于Apache Hadoop的一个子项目。 Avro提供两种序列化格式:JSON格式或者Binary格式。Binary格式在空间开销和解析性能方面可以和Protobuf媲美,JSON格式方便测试阶段的调试。适合于高性能的序列化服务。
- 几种协议的对比
- XML序列化(Xstream)无论在性能和简洁性上比较差;
- Thrift与Protobuf相比在时空开销方面都有一定的劣势;
- Protobuf和Avro在两方面表现都非常优越。
5、同步和异步
同步IO:
- 读写IO时代码等数据返回后才继续执行后续代码
- 代码编写简单,CPU执行效率低
- JDK提供的java.io是同步IO
异步IO:
- 读写IO时仅发出请求,然后立即执行后续代码
- 代码编写复杂,CPU执行效率高
- JDK提供的java.nio是异步IO
6、Java中的NIO,BIO,AIO分别是什么
- 同步阻塞IO(BIO):用户进程发起一个IO操作以后,必须等待IO操作的真正完成后,才能继续运行;
- 同步非阻塞IO(NIO):用户进程发起一个IO操作以后,可做其它事情,但用户进程需要经常询问IO操作是否完成,这样造成不必要的CPU资源浪费;
- 异步非阻塞IO(AIO):用户进程发起一个IO操作然后,立即返回,等IO操作真正的完成以后,应用程序会得到IO操作完成的通知。类比Future模式。
- 先来个例子理解一下概念,以银行取款为例:
- 同步 : 自己亲自出马持银行卡到银行取钱(使用同步IO时,Java自己处理IO读写)。
- 异步 : 委托一小弟拿银行卡到银行取钱,然后给你(使用异步IO时,Java将IO读写委托给OS处理,需要将数据缓冲区地址和大小传给OS(银行卡和密码),OS需要支持异步IO操作API)。
- 阻塞 : ATM排队取款,你只能等待(使用阻塞IO时,Java调用会一直阻塞到读写完成才返回)。
- 非阻塞 : 柜台取款,取个号,然后坐在椅子上做其它事,等号广播会通知你办理,没到号你就不能去,你可以不断问大堂经理排到了没有,大堂经理如果说还没到你就不能去(使用非阻塞IO时,如果不能读写Java调用会马上返回,当IO事件分发器会通知可读写时再继续进行读写,不断循环直到读写完成)。

BIO
定义:BIO 全称Block-IO 是一种阻塞同步的通信模式。我们常说的Stock IO 一般指的是BIO。是一个比较传统的通信方式,模式简单,使用方便。但并发处理能力低,通信耗时,依赖网速。
BIO 设计原理:
服务器通过一个 Acceptor 线程负责监听客户端请求和为每个客户端创建一个新的线程进行链路处理。典型的一请求一应答模式。若客户端数量增多,频繁地创建和销毁线程会给服务器打开很大的压力。后改良为用线程池的方式代替新增线程,被称为伪异步IO。
服务器提供IP地址和监听的端口,客户端通过TCP的三次握手与服务器连接,连接成功后,双放才能通过套接字(Stock)通信。
小结:
BIO模型中通过 Socket 和 ServerSocket 完成套接字通道的实现。阻塞,同步,建立连接耗时。

为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程(需要了解更多请参考前面提供的文章),实现1个或多个线程处理N个客户端的模型(但是底层还是使用的同步阻塞I/O),通常被称为“伪异步I/O模型“。

实现很简单,我们只需要将新建线程的地方,交给线程池管理即可。
我们知道,如果使用 CachedThreadPool 线程池(不限制线程数量,如果不清楚请参考文首提供的文章),其实除了能自动帮我们管理线程(复用),看起来也就像是1:1的客户端:线程数模型,而使用 FixedThreadPool 我们就有效的控制了线程的最大数量,保证了系统有限的资源的控制,实现了N:M的伪异步 I/O 模型。
但是,正因为限制了线程数量,如果发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中的有空闲的线程可以被复用。而对 Socket 的输入流就行读取时,会一直阻塞,直到发生:
- 有数据可读
- 可用数据以及读取完毕
- 发生空指针或 I/O 异常
所以在读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。
而后面即将介绍的NIO,就能解决这个难题。
NIO
NIO(官方:New IO),也叫Non-Block IO 是一种同步非阻塞的通信模式。
NIO 设计原理:
NIO相对于BIO来说一大进步。客户端和服务器之间通过Channel通信。NIO可以在Channel进行读写操作。这些Channel都会被注册在Selector多路复用器上。Selector通过一个线程不停的轮询这些Channel。找出已经准备就绪的Channel执行IO操作。 NIO 通过一个线程轮询,实现千万个客户端的请求,这就是非阻塞NIO的特点。
1)缓冲区Buffer:它是NIO与BIO的一个重要区别。BIO是将数据直接写入或读取到Stream对象中。而NIO的数据操作都是在缓冲区中进行的。缓冲区实际上是一个数组。Buffer最常见的类型是ByteBuffer,另外还有CharBuffer,ShortBuffer,IntBuffer,LongBuffer,FloatBuffer,DoubleBuffer。
2)通道Channel:和流不同,通道是双向的。NIO可以通过Channel进行数据的读,写和同时读写操作。通道分为两大类:一类是网络读写(SelectableChannel),一类是用于文件操作(FileChannel),我们使用的SocketChannel和ServerSocketChannel都是SelectableChannel的子类。
3)多路复用器Selector:NIO编程的基础。多路复用器提供选择已经就绪的任务的能力。就是Selector会不断地轮询注册在其上的通道(Channel),如果某个通道处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以取得就绪的Channel集合,从而进行后续的IO操作。服务器端只要提供一个线程负责Selector的轮询,就可以接入成千上万个客户端,这就是JDK NIO库的巨大进步。
小结:NIO模型中通过SocketChannel和ServerSocketChannel完成套接字通道的实现。非阻塞/阻塞,同步,避免TCP建立连接使用三次握手带来的开销。

AIO (NIO.2)
- 异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理.
- AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。
AIO 并没有采用NIO的多路复用器,而是使用异步通道的概念。其read,write方法的返回类型都是Future对象。而Future模型是异步的,其核心思想是:去主函数等待时间。
小结:AIO模型中通过AsynchronousSocketChannel和AsynchronousServerSocketChannel完成套接字通道的实现。非阻塞,异步。
总结
- BIO模型中通过Socket和ServerSocket完成套接字通道实现。阻塞,同步,连接耗时。
- NIO模型中通过SocketChannel和ServerSocketChannel完成套接字通道实现。非阻塞/阻塞,同步,避免TCP建立连接使用三次握手带来的开销。
- AIO模型中通过AsynchronousSocketChannel和AsynchronousServerSocketChannel完成套接字通道实现。非阻塞,异步。

另外,I/O属于底层操作,需要操作系统支持,并发也需要操作系统的支持,所以性能方面不同操作系统差异会比较明显。
参考:
- Java BIO、NIO、AIO 学习-力量来源于赤诚的爱!-51CTO博客
- Netty序章之BIO NIO AIO演变 - JavaEE教程 - SegmentFault 思否
- Java 网络IO编程总结(BIO、NIO、AIO均含完整实例代码) - CSDN博客
- Java IO Tutorial
7、BIO,NIO,AIO区别
- BIO(同步阻塞):客户端和服务器连接需要三次握手,使用简单,但吞吐量小
- NIO(同步非阻塞):客户端与服务器通过Channel连接,采用多路复用器轮询注册的Channel。提高吞吐量和可靠性。
- AIO(异步非阻塞):NIO的升级版,采用异步通道实现异步通信,其read和write方法均是异步方法。
8、Stock通信的伪代码实现流程
- 服务器绑定端口:server = new ServerSocket(PORT)
- 服务器阻塞监听:socket = server.accept()
- 服务器开启线程:new Thread(Handle handle)
- 服务器读写数据:BufferedReader PrintWriter
- 客户端绑定IP和PORT:new Socket(IP_ADDRESS, PORT)
- 客户端传输接收数据:BufferedReader PrintWriter
9、网络操作
Java 中的网络支持:
- InetAddress:用于表示网络上的硬件资源,即 IP 地址;
- URL:统一资源定位符;
- Sockets:使用 TCP 协议实现网络通信;
- Datagram:使用 UDP 协议实现网络通信。
InetAddress
没有公有构造函数,只能通过静态方法来创建实例。
InetAddress.getByName(String host);
InetAddress.getByAddress(byte[] address);
URL
可以直接从 URL 中读取字节流数据。
public static void main(String[] args) throws IOException
{
URL url = new URL("http://www.baidu.com");
// 字节流
InputStream is = url.openStream();
// 字符流
InputStreamReader isr = new InputStreamReader(is, "utf-8");
BufferedReader br = new BufferedReader(isr);
String line = br.readLine();
while (line != null) {
System.out.println(line);
line = br.readLine();
}
br.close();
}
Sockets
- ServerSocket:服务器端类
- Socket:客户端类
- 服务器和客户端通过 InputStream 和 OutputStream 进行输入输出。

参考资料:
Datagram
- DatagramPacket:数据包类
- DatagramSocket:通信类
什么是Socket?
TCP用主机的IP地址加上主机上的端口号作为TCP连接的端点,这种端点就叫做套接字(socket)或插口。 套接字用(IP地址:端口号)表示。
Socket是进程通讯的一种方式,即调用这个网络库的一些API函数实现分布在不同主机的相关进程之间的数据交换。
socket是网络编程的基础,本文用打电话来类比socket通信中建立TCP连接的过程。
socket函数:表示你买了或者借了一部手机。 bind函数:告诉别人你的手机号码,让他们给你打电话。 listen函数:打开手机的铃声,而不是静音,这样有电话时可以立马反应。listen函数的第二个参数,最大连接数,表示最多有几个人可以同时拨打你的号码。不过我们的手机,最多只能有一个人打进来,要不然就提示占线。 connect函数:你的朋友知道了你的号码,通过这个号码来联系你。在他等待你回应的时候,不能做其他事情,所以connect函数是阻塞的。 accept函数:你听到了电话铃声,接电话,accept it!然后“喂”一声,你的朋友听到你的回应,知道电话已经打进去了。至此,一个TCP连接建立了。 read/write函数:连接建立后,TCP的两端可以互相收发消息,这时候的连接是全双工的。对应打电话中的电话煲。 close函数:通话完毕,一方说“我挂了”,另一方回应"你挂吧",然后将连接终止。实际的close(sockfd)有些不同,它不止是终止连接,还把手机也归还,不在占有这部手机,就当是公用电话吧。
注意到,上述连接是阻塞的,你一次只能响应一个用户的连接请求,但在实际网络编程中,一个服务器服务于多个客户,上述方案也就行不通了,怎么办?想一想10086,移动的声讯服务台,也是只有一个号码,它怎么能同时服务那么多人呢?可以这样理解,在你打电话到10086时,总服务台会让一个接线员来为你服务,而它自己却继续监听有没有新的电话接入。在网络编程中,这个过程类似于fork一个子进程,建立实际的通信连接,而主进程继续监听。10086的接线员是有限的,所以当连接的人数达到上线时,它会放首歌给你听,忙等待,直到有新的空闲接线员为止。 实际网络编程中,处理并发的方式还有select/poll/epoll等。
下面是一个实际的socket通信过程:

Socket的特点
- Socket基于TCP链接,数据传输有保障
- Socket适用于建立长时间链接
- Socket编程通常应用于即时通讯
资料引用
- 部分参考自:CyC2018/CS-Notes