Dunwu Blog

大道至简,知易行难

Spring 集成 Mybatis

Mybatis 官网 是一款持久层框架,它支持定制化 SQL、存储过程以及高级映射。MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。MyBatis 可以使用简单的 XML 或注解来配置和映射原生类型、接口和 Java 的 POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。

快速入门

要使用 MyBatis, 只需将 mybatis-x.x.x.jar 文件置于类路径(classpath)中即可。

如果使用 Maven 来构建项目,则需将下面的依赖代码置于 pom.xml 文件中:

1
2
3
4
5
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>x.x.x</version>
</dependency>

从 XML 中构建 SqlSessionFactory

每个基于 MyBatis 的应用都是以一个 SqlSessionFactory 的实例为核心的。SqlSessionFactory 的实例可以通过 SqlSessionFactoryBuilder 获得。而 SqlSessionFactoryBuilder 则可以从 XML 配置文件或一个预先配置的 Configuration 实例来构建出 SqlSessionFactory 实例。

从 XML 文件中构建 SqlSessionFactory 的实例非常简单,建议使用类路径下的资源文件进行配置。 但也可以使用任意的输入流(InputStream)实例,比如用文件路径字符串或 file:// URL 构造的输入流。MyBatis 包含一个名叫 Resources 的工具类,它包含一些实用方法,使得从类路径或其它位置加载资源文件更加容易。

1
2
3
String resource = "org/mybatis/example/mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

XML 配置文件中包含了对 MyBatis 系统的核心设置,包括获取数据库连接实例的数据源(DataSource)以及决定事务作用域和控制方式的事务管理器(TransactionManager)。后面会再探讨 XML 配置文件的详细内容,这里先给出一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${driver}"/>
<property name="url" value="${url}"/>
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="org/mybatis/example/BlogMapper.xml"/>
</mappers>
</configuration>

当然,还有很多可以在 XML 文件中配置的选项,上面的示例仅罗列了最关键的部分。 注意 XML 头部的声明,它用来验证 XML 文档的正确性。environment 元素体中包含了事务管理和连接池的配置。mappers 元素则包含了一组映射器(mapper),这些映射器的 XML 映射文件包含了 SQL 代码和映射定义信息。

不使用 XML 构建 SqlSessionFactory

如果你更愿意直接从 Java 代码而不是 XML 文件中创建配置,或者想要创建你自己的配置构建器,MyBatis 也提供了完整的配置类,提供了所有与 XML 文件等价的配置项。

1
2
3
4
5
6
DataSource dataSource = BlogDataSourceFactory.getBlogDataSource();
TransactionFactory transactionFactory = new JdbcTransactionFactory();
Environment environment = new Environment("development", transactionFactory, dataSource);
Configuration configuration = new Configuration(environment);
configuration.addMapper(BlogMapper.class);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);

注意该例中,configuration 添加了一个映射器类(mapper class)。映射器类是 Java 类,它们包含 SQL 映射注解从而避免依赖 XML 映射文件。不过,由于 Java 注解的一些限制以及某些 MyBatis 映射的复杂性,要使用大多数高级映射(比如:嵌套联合映射),仍然需要使用 XML 映射文件进行映射。有鉴于此,如果存在一个同名 XML 映射文件,MyBatis 会自动查找并加载它(在这个例子中,基于类路径和 BlogMapper.class 的类名,会加载 BlogMapper.xml)。具体细节稍后讨论。

从 SqlSessionFactory 中获取 SqlSession

既然有了 SqlSessionFactory,顾名思义,我们可以从中获得 SqlSession 的实例。SqlSession 提供了在数据库执行 SQL 命令所需的所有方法。你可以通过 SqlSession 实例来直接执行已映射的 SQL 语句。例如:

1
2
3
try (SqlSession session = sqlSessionFactory.openSession()) {
Blog blog = (Blog) session.selectOne("org.mybatis.example.BlogMapper.selectBlog", 101);
}

诚然,这种方式能够正常工作,对使用旧版本 MyBatis 的用户来说也比较熟悉。但现在有了一种更简洁的方式——使用和指定语句的参数和返回值相匹配的接口(比如 BlogMapper.class),现在你的代码不仅更清晰,更加类型安全,还不用担心可能出错的字符串字面值以及强制类型转换。

例如:

1
2
3
4
try (SqlSession session = sqlSessionFactory.openSession()) {
BlogMapper mapper = session.getMapper(BlogMapper.class);
Blog blog = mapper.selectBlog(101);
}

现在我们来探究一下这段代码究竟做了些什么。

探究已映射的 SQL 语句

现在你可能很想知道 SqlSession 和 Mapper 到底具体执行了些什么操作,但 SQL 语句映射是个相当广泛的话题,可能会占去文档的大部分篇幅。 但为了让你能够了解个大概,这里先给出几个例子。

在上面提到的例子中,一个语句既可以通过 XML 定义,也可以通过注解定义。我们先看看 XML 定义语句的方式,事实上 MyBatis 提供的所有特性都可以利用基于 XML 的映射语言来实现,这使得 MyBatis 在过去的数年间得以流行。如果你用过旧版本的 MyBatis,你应该对这个概念比较熟悉。 但相比于之前的版本,新版本改进了许多 XML 的配置,后面我们会提到这些改进。这里给出一个基于 XML 映射语句的示例,它应该可以满足上个示例中 SqlSession 的调用。

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.mybatis.example.BlogMapper">
<select id="selectBlog" resultType="Blog">
select * from Blog where id = #{id}
</select>
</mapper>

为了这个简单的例子,我们似乎写了不少配置,但其实并不多。在一个 XML 映射文件中,可以定义无数个映射语句,这样一来,XML 头部和文档类型声明部分就显得微不足道了。文档的其它部分很直白,容易理解。 它在命名空间 “org.mybatis.example.BlogMapper” 中定义了一个名为 “selectBlog” 的映射语句,这样你就可以用全限定名 “org.mybatis.example.BlogMapper.selectBlog” 来调用映射语句了,就像上面例子中那样:

1
Blog blog = (Blog) session.selectOne("org.mybatis.example.BlogMapper.selectBlog", 101);

你可能会注意到,这种方式和用全限定名调用 Java 对象的方法类似。这样,该命名就可以直接映射到在命名空间中同名的映射器类,并将已映射的 select 语句匹配到对应名称、参数和返回类型的方法。因此你就可以像上面那样,不费吹灰之力地在对应的映射器接口调用方法,就像下面这样:

1
2
BlogMapper mapper = session.getMapper(BlogMapper.class);
Blog blog = mapper.selectBlog(101);

第二种方法有很多优势,首先它不依赖于字符串字面值,会更安全一点;其次,如果你的 IDE 有代码补全功能,那么代码补全可以帮你快速选择到映射好的 SQL 语句。

提示 对命名空间的一点补充

在之前版本的 MyBatis 中,命名空间(Namespaces)的作用并不大,是可选的。 但现在,随着命名空间越发重要,你必须指定命名空间。

命名空间的作用有两个,一个是利用更长的全限定名来将不同的语句隔离开来,同时也实现了你上面见到的接口绑定。就算你觉得暂时用不到接口绑定,你也应该遵循这里的规定,以防哪天你改变了主意。 长远来看,只要将命名空间置于合适的 Java 包命名空间之中,你的代码会变得更加整洁,也有利于你更方便地使用 MyBatis。

命名解析:为了减少输入量,MyBatis 对所有具有名称的配置元素(包括语句,结果映射,缓存等)使用了如下的命名解析规则。

  • 全限定名(比如 “com.mypackage.MyMapper.selectAllThings)将被直接用于查找及使用。
  • 短名称(比如 “selectAllThings”)如果全局唯一也可以作为一个单独的引用。 如果不唯一,有两个或两个以上的相同名称(比如 “com.foo.selectAllThings” 和 “com.bar.selectAllThings”),那么使用时就会产生“短名称不唯一”的错误,这种情况下就必须使用全限定名。

对于像 BlogMapper 这样的映射器类来说,还有另一种方法来完成语句映射。 它们映射的语句可以不用 XML 来配置,而可以使用 Java 注解来配置。比如,上面的 XML 示例可以被替换成如下的配置:

1
2
3
4
5
package org.mybatis.example;
public interface BlogMapper {
@Select("SELECT * FROM blog WHERE id = #{id}")
Blog selectBlog(int id);
}

使用注解来映射简单语句会使代码显得更加简洁,但对于稍微复杂一点的语句,Java 注解不仅力不从心,还会让本就复杂的 SQL 语句更加混乱不堪。 因此,如果你需要做一些很复杂的操作,最好用 XML 来映射语句。

选择何种方式来配置映射,以及是否应该要统一映射语句定义的形式,完全取决于你和你的团队。 换句话说,永远不要拘泥于一种方式,你可以很轻松地在基于注解和 XML 的语句映射方式间自由移植和切换。

作用域(Scope)和生命周期

理解我们之前讨论过的不同作用域和生命周期类别是至关重要的,因为错误的使用会导致非常严重的并发问题。

提示 对象生命周期和依赖注入框架

依赖注入框架可以创建线程安全的、基于事务的 SqlSession 和映射器,并将它们直接注入到你的 bean 中,因此可以直接忽略它们的生命周期。 如果对如何通过依赖注入框架使用 MyBatis 感兴趣,可以研究一下 MyBatis-Spring 或 MyBatis-Guice 两个子项目。

SqlSessionFactoryBuilder

这个类可以被实例化、使用和丢弃,一旦创建了 SqlSessionFactory,就不再需要它了。 因此 SqlSessionFactoryBuilder 实例的最佳作用域是方法作用域(也就是局部方法变量)。 你可以重用 SqlSessionFactoryBuilder 来创建多个 SqlSessionFactory 实例,但最好还是不要一直保留着它,以保证所有的 XML 解析资源可以被释放给更重要的事情。

SqlSessionFactory

SqlSessionFactory 一旦被创建就应该在应用的运行期间一直存在,没有任何理由丢弃它或重新创建另一个实例。 使用 SqlSessionFactory 的最佳实践是在应用运行期间不要重复创建多次,多次重建 SqlSessionFactory 被视为一种代码“坏习惯”。因此 SqlSessionFactory 的最佳作用域是应用作用域。 有很多方法可以做到,最简单的就是使用单例模式或者静态单例模式。

SqlSession

每个线程都应该有它自己的 SqlSession 实例。SqlSession 的实例不是线程安全的,因此是不能被共享的,所以它的最佳的作用域是请求或方法作用域。 绝对不能将 SqlSession 实例的引用放在一个类的静态域,甚至一个类的实例变量也不行。 也绝不能将 SqlSession 实例的引用放在任何类型的托管作用域中,比如 Servlet 框架中的 HttpSession。 如果你现在正在使用一种 Web 框架,考虑将 SqlSession 放在一个和 HTTP 请求相似的作用域中。 换句话说,每次收到 HTTP 请求,就可以打开一个 SqlSession,返回一个响应后,就关闭它。 这个关闭操作很重要,为了确保每次都能执行关闭操作,你应该把这个关闭操作放到 finally 块中。 下面的示例就是一个确保 SqlSession 关闭的标准模式:

1
2
3
try (SqlSession session = sqlSessionFactory.openSession()) {
// 你的应用逻辑代码
}

在所有代码中都遵循这种使用模式,可以保证所有数据库资源都能被正确地关闭。

映射器实例

映射器是一些绑定映射语句的接口。映射器接口的实例是从 SqlSession 中获得的。虽然从技术层面上来讲,任何映射器实例的最大作用域与请求它们的 SqlSession 相同。但方法作用域才是映射器实例的最合适的作用域。 也就是说,映射器实例应该在调用它们的方法中被获取,使用完毕之后即可丢弃。 映射器实例并不需要被显式地关闭。尽管在整个请求作用域保留映射器实例不会有什么问题,但是你很快会发现,在这个作用域上管理太多像 SqlSession 的资源会让你忙不过来。 因此,最好将映射器放在方法作用域内。就像下面的例子一样:

1
2
3
4
try (SqlSession session = sqlSessionFactory.openSession()) {
BlogMapper mapper = session.getMapper(BlogMapper.class);
// 你的应用逻辑代码
}

Mybatis 扩展工具

Mybatis Plus

MyBatis-Plus(简称 MP)是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

【集成示例】spring-boot-data-mybatis-plus

Mapper

Mapper 是一个 Mybatis CRUD 扩展插件。

Mapper 的基本原理是将实体类映射为数据库中的表和字段信息,因此实体类需要通过注解配置基本的元数据,配置好实体后, 只需要创建一个继承基础接口的 Mapper 接口就可以开始使用了。

【集成示例】spring-boot-data-mybatis-mapper

PageHelper

PageHelper 是一个 Mybatis 通用分页插件。

【集成示例】spring-boot-data-mybatis-mapper

参考资料

Windows 常用技巧总结

软件

扩展阅读:

视频音频

  • Musicbee - 类似 iTunes,但比 iTunes 更好用。
  • ScreenToGif - 它允许你录制屏幕的一部分区域并保存为 gif 或视频。
  • PotPlayer - 多媒体播放器,具有广泛的编解码器集合,它还为用户提供大量配置选项。
  • 射手影音播放器 - 来自射手网,小巧开源,首创自动匹配字幕功能。

压缩

  • 7-Zip - 用于处理压缩包的开源 Windows 实用程序。完美支持 7z,ZIP,GZIP,BZIP2 和 TAR 的全部特性,其他格式也可解压缩。
  • WinRAR - 强大的归档管理器。 它可以备份您的数据并减小电子邮件附件的大小,解压缩 RAR,ZIP 和其他文件。

文件管理

  • Clover - 为资源管理器加上多标签功能。
  • Total Commander - 老牌、功能异常强大的文件管理增强软件。
  • Q-Dir - 轻量级的文件管理器,各种布局视图切换灵活,默认四个小窗口组成一个大窗口,操作快捷。软件虽小,粉丝忠诚。
  • WoX - 新一代文件定位工具,堪称 Windows 上的 Alfred。
  • Everything - 最快的文件/文件夹搜索工具, 通过名称搜索。
  • Listary - 非常优秀的 Windows 文件浏览和搜索增强工具。
  • Beyond Compare - 好用又万能的文件对比工具。
  • CCleaner - 如果你有系统洁癖,那一定要选择一款干净、良心、老牌的清洁软件。
  • chocolatey - 包管理器
  • Ninite - 最简单,最快速的更新或安装软件的方式。
  • Recuva - 来自 piriform 梨子公司产品,免费的数据恢复工具。
  • Launchy:自由的跨平台工具,帮助你忘记开始菜单、桌面图标甚至文件管理器。

开发

  • Fiddler - web 调试代理工具。
  • Postman - 适合 API 开发的完整工具链,最常用的 REST 客户端。
  • SourceTree - 一个免费的 Git & Mercurial 客户端。
  • TortoiseSVN - Subversion(SVN)的图形客户端
  • Wireshark - 一个网络协议分析工具。
  • Switchhosts
  • Cmder - 控制台模拟器包。扩展阅读:Win 下必备神器之 Cmder
  • Babun - 基于 Cygwin,用于替代 Windows shell。

编辑器

  • JetBrain IDE 系列 - 真香!
  • Visual Studio Code - 用于构建和调试现代 Web 和云应用程序。
  • Eclipse - 一款功能强大的 IDE。
  • Visual Studio - 微软官方的 IDE,通过插件可支持大量编程语言。
  • NetBeans IDE - 免费开源的 IDE。
  • Typora - 个人觉得最好用的 Markdown 编辑器。
  • Cmd Markdown - 跨平台优秀 Markdown 编辑器,本文即用其所写。
  • Notepad++ - 一款支持多种编程语言的源码编辑器。
  • Notepad2 - 用于替代默认文本编辑器的轻量快速的编辑器,拥有众多有用的功能。
  • Sublime Text 3 - 高级文本编辑器。
  • Atom - 面向 21 世纪的极客文本编辑器。

文档

  • Microsoft Office - 微软办公软件。
  • WPS Office - 金山免费办公软件。
  • Calibre - 用于电子书管理和转换的强大软件。
  • 福昕阅读器 - 在全球拥有大量用户,最优秀的国产软件之一。Ribbon 界面,支持手写签名、插入印章等。

效率提升

【笔记】

  • XMind - 优秀的思维导图。
  • OneNote - Windows 下综合评价非常高的笔记应用。
  • 印象笔记 - 老牌跨平台笔记工具,国际版 Evernote。一家立志于做百年公司的企业,安全、可靠。
  • 为知笔记 - 越来越好的笔记应用,记录、查阅一切有价值的信息,同样跨平台支持。
  • 有道云笔记 - 网易旗下笔记工具,同样跨主流平台支持,文字、手写、录音、拍照多种记录方式,支持任意附件格式。
  • ShareX - 你要的所有与截图、录屏相关的功能,这里都有了。

【快捷键】

  • AutoHotkey - Windows 平台的终极自动化脚本语言。

技巧:

办公

  • 有道词典 - 最好用的免费全能翻译软件。
  • Outlook - 大名鼎鼎的 Microsoft Office 组件之一,除了电子邮件,还包含了日历、任务管理、联系人、记事本等功能。
  • Gmail - 功能上可以称为业界标杆,用户数量世界第一,或许你真的找不到比它更好的邮件系统。
  • Chrome - 最好的浏览器。
  • Teamviewer - 专业、功能强大的远程控制软件。使用简单,对个人用户免费。

个性化

  • TranslucentTB - 透明化你的 Windows 任务栏。
  • QTTabBar - 通过多标签和额外的文件夹视图扩展资源管理器的功能。
  • Fences - 管理桌面快捷方式。

参考资料

基本操作

软件管理

dmg 格式:双击安装包,然后拖到 applications 文件夹下即可。

浏览器

更改默认搜索引擎

选择“偏好设置–>搜索–>搜索引擎–>Google”。

导入 chrome 浏览器的书签

选择“文件–>导入自–> Google Chrome”,然后选择要导入的项目。

快捷键

Command + R 刷新

上方显示书签栏/收藏栏

选择“显示–> 显示个人收藏栏”。

关闭软件的右上角通知

在 Mac 系统中有对通知的设置,打开系统偏好设置 — 通知 找到 QQ,然后将 QQ 提示样式设置成无即可。

复制文件/文件夹路径

  • OS X 10.11 系统,选中文件夹,“cmd +Option +c” 复制文件夹路径,cmd+v 粘贴。
    之前的系统,利用 Administrator 创建一个到右键菜单,然后到设置里面设置快捷键。具体操作请百度。

打开来自身份不明的开发者的应用程序

在应用程序文件夹,按住 control 键的同时打开应用程序。

复制文件路径

  • 选择文件/文件夹按 Command+C 复制,在终端中 Command+V 粘贴即可。

  • 如果只是想在 Finder 中看到文件的路径, 并方便切换层级, Finder 内置了“显示路径栏”的功能, 并配置了快捷键(Option+Cmd+P). 如下图所示:

20161124-184148.png

参考链接:

隐藏和取消隐藏 Mac App Store 中的已购项目

Mac 同时登陆两个 QQ

在已经打开的 QQ 中,按住“command + N”即可。

系统便好设置

语音播报

打开“系统便好设置–>辅助功能–>语音”,即可设置不同国家的语言。

勾选上图中的红框部分,可以设置全局快捷键。这样的话,在任何一个软件当中,按下“ option+esc”时,就会朗读选中的文本。

调整字体大小

Mac 调整字体大小:“系统偏好设置 -> 显示器 -> 缩放”。如下图:

如何分别设置 Mac 的鼠标和触控板的滚动方向

很多人习惯鼠标使用相反的滚动方向,而触控板类似 iPad 那样的自然滚动,问如何设置,当时我的回答是不知道,因为目前 OS X 的系统设置里,鼠标和触控板的设置是统一
的。今天发现了一个免费的软件 Scroll Reverser,可以实现鼠标和触控板的分别设置。下载地址:https://pilotmoon.com/scrollreverser/
启动后程序显示在顶部菜单栏,设置简单明了,有需要的用户体验一下吧。

Touch Bar 自定义

打开“系统偏好设置-键盘”,下面有个自定义控制条。

色温调节:夜间模式

iOS9.3 的最明显变化,莫过于苹果在发布会上特意提到的 Night Shift 夜间护眼模式。

iCloud 邮箱

如果您用于设置 iCloud 的 Apple ID 不以“@icloud.com”、“@me.com”或“@mac.com”结尾,您必须先设置一个“@icloud.com”电子邮件地址,然后才能使用 iCloud“邮件”。

如果您拥有以“@mac.com”或“@me.com”结尾的电子邮件地址,则您已经拥有了名称相同但以“@icloud.com”结尾的等效地址。如果您使用的电子邮件别名以“@mac.com”或“@me.com”结尾,您也将拥有以“@icloud.com”结尾的等效地址。

操作如下:

  • 在 iOS 设备上,前往“设置”>“iCloud”,开启“邮件”,然后按照屏幕上的说明操作。

  • 在 Mac 上,选取 Apple 菜单 >“系统偏好设置”,点按“iCloud”,再选择“邮件”,然后按照屏幕上的说明操作。

PS:创建 iCloud 电子邮件地址后,您无法对其进行更改。

设置 @icloud.com 电子邮件地址后即可用其登录 iCloud。您也可以用创建 iCloud 帐户时所用的 Apple ID 登录。

您可以从以下任意地址发送 iCloud 电子邮件:

您的 iCloud 电子邮件地址(您的帐号名称@icloud.com)

别名

参考链接:

直接注册以@icloud.com 结尾的 Apple ID:

参考链接:

PodCast

PodCast 中文翻译为播客,是一种特殊的音频 or 视频节目。PodCast 这个单词是由 iPod+Broadcast 这两个单词组成的。

PodCast 可以在 iTunes 中收听。

others

词典

系统有一个自带应用“词典”,可以进行单词的查询。

如何解决 MAC 软件(dmg,akp,app)出现程序已损坏的提示

“xxx.app 已损坏,打不开.你应该将它移到废纸篓”,并非你安装的软件已损坏,而是 Mac 系统的安全设置问题,因为这些应用都是破解或者汉化的,那么解决方法就是临时改变 Mac 系统安全设置。

出现这个问题的解决方法:修改系统配置:系统偏好设置… -> 安全性与隐私。修改为任何来源。

如果没有这个选项的话(macOS Sierra 10.12),打开终端,执行:

1
sudo spctl --master-disable

即可。

参考链接:

备注:这个链接里的各种资源都很不错啊。

终端

在 Finder 的当前目录打开终端

在 Finder 打开 terminal 终端这个功能其实是有的,但是系统默认没有打开。我们可以通过如下方法将其打开:

进入系统偏好设置->键盘->快捷键->服务。

在右边新建位于文件夹位置的终端窗口上打勾。

如此设置后,在 Finder 中右击某文件,在出现的菜单中找到服务,然后点击新建位于文件夹位置的终端窗口即可!

Mac 常用快捷键

Finder

快捷键 作用 备注
Shift + Command + G 前往指定路径的文件夹 包括隐藏文件夹
Shift + Command + . 显示隐藏文件、文件夹 再按一次,恢复隐藏
Command + ↑ 返回上一层
Command + ↓ 进入当前文件夹

编辑

删除文字

快捷键 作用 备注
delete 删除光标的前一个字符 相当于 Windows 键盘上的退格键
fn + delete 删除光标的后一个字符
option + delete 删除光标之前的一个单词 英文有效
command + delete 删除光标之前的整行内容 【荐】
command + delete 在 finder 中删掉该文件
shift + command + delete 清空回收站

剪切文件

首先选中文件,按 Command+C 复制文件;然后按“Command + Option + V”剪切文件。

备注:Command+X 只能剪切文字文本,不要混淆了。

Mac 用户必须知道的 15 组快捷键

参考链接:《轻松玩 Mac》第 6 期:Mac 用户必须知道的 15 组快捷键

“space”键:快速预览

选中文件后, 不需要启动任何应用程序,使用“space”空格键可进行快速预览,再次按下“space”空格键取消预览。

可以预览 mp3、视频、pdf 等文件。

我们还可以选中多张图片, 然后按“space”键,就可以同时对比预览多张图片。这一点,很赞。

改名

选中文件/文件夹后,按 enter 键,就可以改名了。

“command + I”键:查看文件属性

  • 选中文件后,按“command + I”键,可以查看文件的各种属性。

  • 选中文件夹后,按“command + I”键,可以查看文件夹的大小。【荐】

切换输入法

“control + space”

打开 spotlight 搜索框

spotlight 是系统自带的软件,搜索功能不是很强大。我们一般都会用第三方的 Alfred 软件。

编辑相关

Cmd+C、Cmd+V、Cmd+X、Cmd+A、Cmd+Z。

翻页和光标

  • “control + ↑”:将光标定位到文章的最开头(翻页到文档的最上方)

  • “control + ↓”:将光标定位到文章的最末尾(翻页到文档的最下方)

  • “control + ←”:将光标定位到当前行的最左侧

  • “control + →”:将光标定位到当前行的最右侧

“command + shift + Y”:将文字快速保存到便笺

选中你想要的内容(例如文字、链接等),然后按下 command + shift + Y”,那么你选中的内容就会快速保存到系统自带的“便笺”软件中。

如果你想临时性的保存一段内容,这个操作很实用。

程序相关

  • “command + Q”:快速退出程序

  • “command + tab”:切换程序

  • “command + H”:隐藏当前应用程序。这是一个有趣的快捷键。

  • “command + ,”:打开当前应用程序的“偏好设置”。

窗口相关

  • “command + N”:新建一个当前应用程序的窗口

  • “command + `”:在当前应用程序的不同窗口之间切换【很实用】

我们知道,“command + tab”是在不同的软件之间切换。但你不知道的是,“command + `”是在同一个软件的不同窗口之间切换。

  • “command + M”:将当前窗口最小化

  • “command + W”:关闭当前窗口

浏览器相关

  • “command + T”:浏览器中,新建一个标签

  • “command + W”:关闭当前标签

  • “command + R”:强制刷新。
  • “command + L”:定位到地址栏。【重要】

截图相关

  • “command + shift + 3”:截全屏(对整个屏幕截图)。

声音相关

选中文字后,按住“ctrl + esc”键,会将文字进行朗读。(我发现,在触控条版的 mac 上,并没有生效)

Dock 栏相关

  • “option + command + D”:隐藏 dock 栏

强制推出

强制退出的快捷键非常重要

  • “option + command + esc”:打开强制退出的窗口

option 相关

强烈推荐

  • “option + command + H”:隐藏除当前应用程序之外的其他应用程序

  • 在文本中,按住“option”键,配合鼠标的选中,可以进行块状文字选取。

  • “option + command + W”:快速关闭当前应用程序的所有窗口。【很实用】

比如说,你一次性打开了很多文件的详情,然后就可以通过此快捷键,将这些窗口一次性关闭。

  • “option + command + I”:查看多个文件的总的属性。
  • 打开 launchpad,按住“option”键,可以快速卸载应用程序。

  • 在 dock 栏,右键点击软件图标,同时按住“option”键,就可以强制退出该软件。【重要】

  • 在 Safari 浏览器中,按住“option + command + Q”退出 Safari。等下次进入 Safari 的时候,上次退出时的网址会自动被打开。【实用】

推荐一个软件:CheatSheet

打开 CheatSheet 后,长按 command 键,会弹出当前应用程序的所有快捷键。我们还可以对这些快捷键进行保存。

📚 学习资源

🚪 传送

| 回首頁 |

Flume

Sqoop 是一个主要在 Hadoop 和关系数据库之间进行批量数据迁移的工具。

Flume 简介

什么是 Flume ?

Flume 是一个分布式海量数据采集、聚合和传输系统。

特点

  • 基于事件的海量数据采集
  • 数据流模型:Source -> Channel -> Sink
  • 事务机制:支持重读重写,保证消息传递的可靠性
  • 内置丰富插件:轻松与各种外部系统集成
  • 高可用:Agent 主备切换
  • Java 实现:开源,优秀的系统设计

应用场景

Flume 原理

Flume 基本概念

  • Event:事件,最小数据传输单元,由 Header 和 Body 组成。
  • Agent:代理,JVM 进程,最小运行单元,由 Source、Channel、Sink 三个基本组件构成,负责将外部数据源产生的数据以 Event 的形式传输到目的地
    • Source:负责对接各种外部数据源,将采集到的数据封装成 Event,然后写入 Channel
    • Channel:Event 暂存容器,负责保存 Source 发送的 Event,直至被 Sink 成功读取
    • Sink:负责从 Channel 读取 Event,然后将其写入外部存储,或传输给下一阶段的 Agent
    • 映射关系:1 个 Source -> 多个 Channel,1 个 Channel -> 多个 Sink,1 个 Sink -> 1 个 Channel

Flume 基本组件

Source 组件

  • 对接各种外部数据源,将采集到的数据封装成 Event,然后写入 Channel
  • 一个 Source 可向多个 Channel 发送 Event
  • Flume 内置类型丰富的 Source,同时用户可自定义 Source

Channel 组件

  • Event 中转暂存区,存储 Source 采集但未被 Sink 读取的 Event
  • 为了平衡 Source 采集、Sink 读取的速度,可视为 Flume 内部的消息队列
  • 线程安全并具有事务性,支持 Source 写失败重写和 Sink 读失败重读

Sink 组件

  • 从 Channel 读取 Event,将其写入外部存储,或传输到下一阶段的 Agent
  • 一个 Sink 只能从一个 Channel 中读取 Event
  • Sink 成功读取 Event 后,向 Channel 提交事务,Event 被删除,否则 Channel 会等待 Sink 重新读取

Flume 数据流

单层架构

优点:架构简单,使用方便,占用资源较少
缺点
如果采集的数据源或 Agent 较多,将 Event 写入到 HDFS 会产生很多小文件
外部存储升级维护或发生故障,需对采集层的所有 Agent 做处理,人力成本较高,系统稳定性较差
系统安全性较差
数据源管理较混乱

Spark

Spark 简介

Spark 概念

  • 大规模分布式通用计算引擎
    • Spark Core:核心计算框架
    • Spark SQL:结构化数据查询
    • Spark Streaming:实时流处理
    • Spark MLib:机器学习
    • Spark GraphX:图计算
  • 具有高吞吐、低延时、通用易扩展、高容错等特点
  • 采用 Scala 语言开发
  • 提供多种运行模式

Spark 特点

  • 计算高效
    • 利用内存计算、Cache 缓存机制,支持迭代计算和数据共享,减少数据读取的 IO 开销
    • 利用 DAG 引擎,减少中间计算结果写入 HDFS 的开销
    • 利用多线程池模型,减少任务启动开销,避免 Shuffle 中不必要的排序和磁盘 IO 操作
  • 通用易用
    • 适用于批处理、流处理、交互式计算、机器学习算法等场景
    • 提供了丰富的开发 API,支持 Scala、Java、Python、R 等
  • 运行模式多样
    • Local 模式
    • Standalone 模式
    • YARN/Mesos 模式
  • 计算高效
    • 利用内存计算、Cache 缓存机制,支持迭代计算和数据共享,减少数据读取的 IO 开销
    • 利用 DAG 引擎,减少中间计算结果写入 HDFS 的开销
    • 利用多线程池模型,减少任务启动开销,避免 Shuffle 中不必要的排序和磁盘 IO 操作
  • 通用易用
    • 适用于批处理、流处理、交互式计算、机器学习等场景
    • 提供了丰富的开发 API,支持 Scala、Java、Python、R 等

Spark 原理

编程模型

RDD

  • 弹性分布式数据集(Resilient Distributed Datesets)
    • 分布在集群中的只读对象集合
    • 由多个 Partition 组成
    • 通过转换操作构造
    • 失效后自动重构(弹性)
    • 存储在内存或磁盘中
  • Spark 基于 RDD 进行计算

RDD 操作(Operator)

  • Transformation(转换)
    • 将 Scala 集合或 Hadoop 输入数据构造成一个新 RDD
    • 通过已有的 RDD 产生新 RDD
    • 惰性执行:只记录转换关系,不触发计算
    • 例如:map、filter、flatmap、union、distinct、sortbykey
  • Action(动作)
    • 通过 RDD 计算得到一个值或一组值
    • 真正触发计算
    • 例如:first、count、collect、foreach、saveAsTextFile

RDD 依赖(Dependency)

  • 窄依赖(Narrow Dependency)
    • 父 RDD 中的分区最多只能被一个子 RDD 的一个分区使用
    • 子 RDD 如果有部分分区数据丢失或损坏,只需从对应的父 RDD 重新计算恢复
    • 例如:map、filter、union
  • 宽依赖(Shuffle/Wide Dependency )
    • 子 RDD 分区依赖父 RDD 的所有分区
    • 子 RDD 如果部分或全部分区数据丢失或损坏,必须从所有父 RDD 分区重新计算
    • 相对于窄依赖,宽依赖付出的代价要高很多,尽量避免使用
    • 例如:groupByKey、reduceByKey、sortByKey

YARN

YARN 简介

Apache YARN (Yet Another Resource Negotiator) 是 hadoop 2.0 引入的集群资源管理系统。用户可以将各种服务框架部署在 YARN 上,由 YARN 进行统一地管理和资源分配。

YARN 架构

ResourceManager

ResourceManager 通常在独立的机器上以后台进程的形式运行,它是整个集群资源的主要协调者和管理者。ResourceManager 负责给用户提交的所有应用程序分配资源,它根据应用程序优先级、队列容量、ACLs、数据位置等信息,做出决策,然后以共享的、安全的、多租户的方式制定分配策略,调度集群资源。

NodeManager

NodeManager 是 YARN 集群中的每个具体节点的管理者。主要负责该节点内所有容器的生命周期的管理,监视资源和跟踪节点健康。具体如下:

  • 启动时向 ResourceManager 注册并定时发送心跳消息,等待 ResourceManager 的指令;
  • 维护 Container 的生命周期,监控 Container 的资源使用情况;
  • 管理任务运行时的相关依赖,根据 ApplicationMaster 的需要,在启动 Container 之前将需要的程序及其依赖拷贝到本地。

ApplicationMaster

在用户提交一个应用程序时,YARN 会启动一个轻量级的进程 ApplicationMasterApplicationMaster 负责协调来自 ResourceManager 的资源,并通过 NodeManager 监视容器内资源的使用情况,同时还负责任务的监控与容错。具体如下:

  • 根据应用的运行状态来决定动态计算资源需求;
  • ResourceManager 申请资源,监控申请的资源的使用情况;
  • 跟踪任务状态和进度,报告资源的使用情况和应用的进度信息;
  • 负责任务的容错。

Container

Container 是 YARN 中的资源抽象,它封装了某个节点上的多维度资源,如内存、CPU、磁盘、网络等。当 AM 向 RM 申请资源时,RM 为 AM 返回的资源是用 Container 表示的。YARN 会为每个任务分配一个 Container,该任务只能使用该 Container 中描述的资源。ApplicationMaster 可在 Container 内运行任何类型的任务。例如,MapReduce ApplicationMaster 请求一个容器来启动 map 或 reduce 任务,而 Giraph ApplicationMaster 请求一个容器来运行 Giraph 任务。

YARN 工作原理

  1. Client 提交作业到 YARN 上;

  2. Resource Manager 选择一个 Node Manager,启动一个 Container 并运行 Application Master 实例;

  3. Application Master 根据实际需要向 Resource Manager 请求更多的 Container 资源(如果作业很小,应用管理器会选择在其自己的 JVM 中运行任务);

  4. Application Master 通过获取到的 Container 资源执行分布式计算。

作业提交

client 调用 job.waitForCompletion 方法,向整个集群提交 MapReduce 作业 (第 1 步) 。新的作业 ID(应用 ID) 由资源管理器分配 (第 2 步)。作业的 client 核实作业的输出,计算输入的 split, 将作业的资源 (包括 Jar 包,配置文件,split 信息) 拷贝给 HDFS(第 3 步)。 最后,通过调用资源管理器的 submitApplication() 来提交作业 (第 4 步)。

作业初始化

当资源管理器收到 submitApplciation() 的请求时,就将该请求发给调度器 (scheduler), 调度器分配 container, 然后资源管理器在该 container 内启动应用管理器进程,由节点管理器监控 (第 5 步)。

MapReduce 作业的应用管理器是一个主类为 MRAppMaster 的 Java 应用,其通过创造一些 bookkeeping 对象来监控作业的进度,得到任务的进度和完成报告 (第 6 步)。然后其通过分布式文件系统得到由客户端计算好的输入 split(第 7 步),然后为每个输入 split 创建一个 map 任务,根据 mapreduce.job.reduces 创建 reduce 任务对象。

任务分配

如果作业很小,应用管理器会选择在其自己的 JVM 中运行任务。

如果不是小作业,那么应用管理器向资源管理器请求 container 来运行所有的 map 和 reduce 任务 (第 8 步)。这些请求是通过心跳来传输的,包括每个 map 任务的数据位置,比如存放输入 split 的主机名和机架 (rack),调度器利用这些信息来调度任务,尽量将任务分配给存储数据的节点,或者分配给和存放输入 split 的节点相同机架的节点。

任务运行

当一个任务由资源管理器的调度器分配给一个 container 后,应用管理器通过联系节点管理器来启动 container(第 9 步)。任务由一个主类为 YarnChild 的 Java 应用执行, 在运行任务之前首先本地化任务需要的资源,比如作业配置,JAR 文件,以及分布式缓存的所有文件 (第 10 步。 最后,运行 map 或 reduce 任务 (第 11 步)。

YarnChild 运行在一个专用的 JVM 中,但是 YARN 不支持 JVM 重用。

进度和状态更新

YARN 中的任务将其进度和状态 (包括 counter) 返回给应用管理器,客户端每秒 (通 mapreduce.client.progressmonitor.pollinterval 设置) 向应用管理器请求进度更新,展示给用户。

作业完成

除了向应用管理器请求作业进度外,客户端每 5 分钟都会通过调用 waitForCompletion() 来检查作业是否完成,时间间隔可以通过 mapreduce.client.completion.pollinterval 来设置。作业完成之后,应用管理器和 container 会清理工作状态, OutputCommiter 的作业清理方法也会被调用。作业的信息会被作业历史服务器存储以备之后用户核查。

提交作业到 YARN 上运行

这里以提交 Hadoop Examples 中计算 Pi 的 MApReduce 程序为例,相关 Jar 包在 Hadoop 安装目录的 share/hadoop/mapreduce 目录下:

1
2
# 提交格式:hadoop jar jar 包路径 主类名称 主类参数
# hadoop jar hadoop-mapreduce-examples-2.6.0-cdh5.15.2.jar pi 3 3

参考资料

大数据简介

简介

什么是大数据

大数据是指超出传统数据库工具收集、存储、管理和分析能力的数据集。与此同时,及时采集、存储、聚合、管理数据,以及对数据深度分析的新技术和新能力,正在快速增长,就像预测计算芯片增长速度的摩尔定律一样。

  • Volume - 数据规模巨大
  • Velocity - 生成和处理速度极快
  • Variety - 数据规模巨大
  • Value - 生成和处理速度极快

应用场景

基于大数据的数据仓库

基于大数据的实时流处理

Hadoop 编年史

时间 事件
2003.01 Google 发表了 Google File System 论文
2004.01 Google 发表了 MapReduce 论文
2006.02 Apache Hadoop 项目正式启动,并支持 MapReduce 和 HDFS 独立发展
2006.11 Google 发表了 Bigtable 论文
2008.01 Hadoop 成为 Apache 顶级项目
2009.03 Cloudera 推出世界上首个 Hadoop 发行版——CDH,并完全开放源码
2012.03 HDFS NameNode HA 加入 Hadoop 主版本
2014.02 Spark 代替 MapReduce 成为 Hadoop 的缺省计算引擎,并成为 Apache 顶级项目

技术体系

HDFS

概念

  • Hadoop 分布式文件系统(Hadoop Distributed File System)
  • 在开源大数据技术体系中,地位无可替代

特点

  • 高容错:数据多副本,副本丢失后自动恢复
  • 高可用:NameNode HA,安全模式
  • 高扩展:10K 节点规模
  • 简单一致性模型:一次写入多次读取,支持追加,不允许修改
  • 流式数据访问:批量读而非随机读,关注吞吐量而非时间
  • 大规模数据集:典型文件大小 GB~TB 级,百万以上文件数量, PB 以上数据规模
  • 构建成本低且安全可靠:运行在大量的廉价商用机器上,硬件错误是常态,提供容错机制

MapReduce

概念

  • 面向批处理的分布式计算框架
  • 编程模型:将 MapReduce 程序分为 Map、Reduce 两个阶段

核心思想

  • 分而治之,分布式计算
  • 移动计算,而非移动数据

特点

  • 高容错:任务失败,自动调度到其他节点重新执行
  • 高扩展:计算能力随着节点数增加,近似线性递增
  • 适用于海量数据的离线批处理
  • 降低了分布式编程的门槛

Spark

高性能分布式通用计算引擎

  • Spark Core - 基础计算框架(批处理、交互式分析)
  • Spark SQL - SQL 引擎(海量结构化数据的高性能查询)
  • Spark Streaming - 实时流处理(微批)
  • Spark MLlib - 机器学习
  • Spark GraphX - 图计算

采用 Scala 语言开发

特点

  • 计算高效 - 内存计算、Cache 缓存机制、DAG 引擎、多线程池模型
  • 通用易用 - 适用于批处理、交互式计算、流处理、机器学习、图计算等多种场景
  • 运行模式多样 - Local、Standalone、YARN/Mesos

YARN

概念

  • Yet Another Resource Negotiator,另一种资源管理器
  • 为了解决 Hadoop 1.x 中 MapReduce 的先天缺陷
  • 分布式通用资源管理系统
  • 负责集群资源的统一管理
  • 从 Hadoop 2.x 开始,YARN 成为 Hadoop 的核心组件

特点

  • 专注于资源管理和作业调度
  • 通用 - 适用各种计算框架,如 - MapReduce、Spark
  • 高可用 - ResourceManager 高可用、HDFS 高可用
  • 高扩展

Hive

概念

  • Hadoop 数据仓库 - 企业决策支持
  • SQL 引擎 - 对海量结构化数据进行高性能的 SQL 查询
  • 采用 HDFS 或 HBase 为数据存储
  • 采用 MapReduce 或 Spark 为计算框架

特点

  • 提供类 SQL 查询语言
  • 支持命令行或 JDBC/ODBC
  • 提供灵活的扩展性
  • 提供复杂数据类型、扩展函数、脚本等

HBase

概念

  • Hadoop Database
  • Google BigTable 的开源实现
  • 分布式 NoSQL 数据库
  • 列式存储 - 主要用于半结构化、非结构化数据
  • 采用 HDFS 为文件存储系统

特点

  • 高性能 - 支持高并发写入和查询
  • 高可用 - HDFS 高可用、Region 高可用
  • 高扩展 - 数据自动切分和分布,可动态扩容,无需停机
  • 海量存储 - 单表可容纳数十亿行,上百万列

ElasticSearch

  • 开源的分布式全文检索引擎
  • 基于 Lucene 实现全文数据的快速存储、搜索和分析
  • 处理大规模数据 - PB 级以上
  • 具有较强的扩展性,集群规模可达上百台
  • 首选的分布式搜索引擎

术语

数据仓库(Data Warehouse) - 数据仓库,是为企业所有级别的决策制定过程,提供所有类型数据支持的战略集合。它是单个数据存储,出于分析性报告和决策支持目的而创建。 为需要业务智能的企业,提供指导业务流程改进、监视时间、成本、质量以及控制。

资源

HBase 运维

配置文件

  • backup-masters - 默认情况下不存在。列出主服务器应在其上启动备份主进程的主机,每行一个主机。
  • hadoop-metrics2-hbase.properties - 用于连接 HBase Hadoop 的 Metrics2 框架。
  • hbase-env.cmd and hbase-env.sh - 用于 Windows 和 Linux / Unix 环境的脚本,用于设置 HBase 的工作环境,包括 Java,Java 选项和其他环境变量的位置。
  • hbase-policy.xml - RPC 服务器用于对客户端请求进行授权决策的默认策略配置文件。仅在启用 HBase 安全性时使用。
  • hbase-site.xml - 主要的 HBase 配置文件。此文件指定覆盖 HBase 默认配置的配置选项。您可以在 docs / hbase-default.xml 中查看(但不要编辑)默认配置文件。您还可以在 HBase Web UI 的 HBase 配置选项卡中查看群集的整个有效配置(默认值和覆盖)。
  • log4j.properties - log4j 日志配置。
  • regionservers - 包含应在 HBase 集群中运行 RegionServer 的主机列表。默认情况下,此文件包含单个条目 localhost。它应包含主机名或 IP 地址列表,每行一个,并且如果群集中的每个节点将在其 localhost 接口上运行 RegionServer,则应仅包含 localhost。

环境要求

  • Java
    • HBase 2.0+ 要求 JDK8+
    • HBase 1.2+ 要求 JDK7+
  • SSH - 环境要支持 SSH
  • DNS - 环境中要在 hosts 配置本机 hostname 和本机 IP
  • NTP - HBase 集群的时间要同步,可以配置统一的 NTP
  • 平台 - 生产环境不推荐部署在 Windows 系统中
  • Hadoop - 依赖 Hadoop 配套版本
  • Zookeeper - 依赖 Zookeeper 配套版本

运行模式

单点

hbase-site.xml 配置如下:

1
2
3
4
5
6
7
8
9
10
<configuration>
<property>
<name>hbase.rootdir</name>
<value>hdfs://namenode.example.org:8020/hbase</value>
</property>
<property>
<name>hbase.cluster.distributed</name>
<value>false</value>
</property>
</configuration>

分布式

hbase-site.xm 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<configuration>
<property>
<name>hbase.rootdir</name>
<value>hdfs://namenode.example.org:8020/hbase</value>
</property>
<property>
<name>hbase.cluster.distributed</name>
<value>true</value>
</property>
<property>
<name>hbase.zookeeper.quorum</name>
<value>node-a.example.com,node-b.example.com,node-c.example.com</value>
</property>
</configuration>

引用和引申

扩展阅读

Java 并发简介

摘要 - 并发编程并非 Java 语言所独有,而是一种成熟的编程范式,Java 只是用自己的方式实现了并发工作模型。学习 Java 并发编程,应该先熟悉并发的基本概念,然后进一步了解并发的特性以及其特性所面临的问题。掌握了这些,当学习 Java 并发工具时,才会明白它们各自是为了解决什么问题,为什么要这样设计。通过这样由点到面的学习方式,更容易融会贯通,将并发知识形成体系化。

什么是并发

技术在进步,CPU、内存、I/O 设备的性能也在不断提高。但是,始终存在一个核心矛盾:CPU、内存、I/O 设备存在很大的速度差异 - CPU 远快于内存,内存远快于 I/O 设备。木桶短板理论告诉我们:一只木桶能装多少水,取决于最短的那块木板。同理,程序整体性能取决于最慢的操作(即 I/O 操作),所以单方面提高 CPU、内存的性能是无效的。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与 CPU 内存的速度差异;
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

其中,进程、线程使得计算机、程序有了并发处理任务的能力。并发是指具备处理多个任务的能力,但不一定要同时。

并发的优点

并发所带来的好处有:

  • 提升资源利用率
  • 程序响应更快

提升资源利用率

想象一下,一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要 5 秒,处理一个文件需要 2 秒。处理两个文件则需要:

1
2
3
4
5
6
5 秒读取文件 A
2 秒处理文件 A
5 秒读取文件 B
2 秒处理文件 B
---------------------
总共需要 14 秒

从磁盘中读取文件的时候,大部分的 CPU 时间用于等待磁盘去读取数据。在这段时间里,CPU 非常的空闲。它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用 CPU 资源。看下面的顺序:

1
2
3
4
5
5 秒读取文件 A
5 秒读取文件 B + 2 秒处理文件 A
2 秒处理文件 B
---------------------
总共需要 12 秒

CPU 等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU 会去处理第一个文件。记住,在等待磁盘读取文件的时候,CPU 大 部分时间是空闲的。

总的说来,CPU 能够在等待 IO 的时候做一些其他的事情。这个不一定就是磁盘 IO。它也可以是网络的 IO,或者用户输入。通常情况下,网络和磁盘的 IO 比 CPU 和内存的 IO 慢的多。

程序响应更快

将一个单线程应用程序变成多线程应用程序的另一个常见的目的是实现一个响应更快的应用程序。设想一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。

服务器的流程如下所述:

1
2
3
4
while(server is active) {
listen for request
process request
}

如果一个请求需要占用大量的时间来处理,在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候,请求才能被接收。另一种设计是,监听线程把请求传递给工作者线程 (worker thread),然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端。这种设计如下所述:

1
2
3
4
while(server is active) {
listen for request
hand request to worker thread
}

这种方式,服务端线程迅速地返回去监听。因此,更多的客户端能够发送请求给服务端。这个服务也变得响应更快。

桌面应用也是同样如此。如果你点击一个按钮开始运行一个耗时的任务,这个线程既要执行任务又要更新窗口和按钮,那么在任务执行的过程中,这个应用程序看起来好像没有反应一样。相反,任务可以传递给工作者线程(worker thread)。当工作者线程在繁忙地处理任务的时候,窗口线程可以自由地响应其他用户的请求。当工作者线程完成任务的时候,它发送信号给窗口线程。窗口线程便可以更新应用程序窗口,并显示任务的结果。对用户而言,这种具有工作者线程设计的程序显得响应速度更快。

任何事物都有利弊,并发也不例外。我们知道了并发带来的好处:提升资源利用率、程序响应更快,同时也要认识到并发带来的问题,主要有:

  • 安全性问题
  • 活跃性问题
  • 性能问题

下面会一一讲解。

安全性问题

并发最重要的问题是并发安全问题。所谓并发安全,是指保证程序的正确性,使得并发处理结果符合预期。

并发安全需要保证几个基本特性:

  • 可见性 - 是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
  • 原子性 - 简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制(加锁:sychronizedLock)实现。
  • 有序性 - 是保证线程内串行语义,避免指令重排等。

缓存导致的可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,称为 可见性

在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。例如在下面的图中,线程 A 和线程 B 都是操作同一个 CPU 里面的缓存,所以线程 A 更新了变量 V 的值,那么线程 B 之后再访问变量 V,得到的一定是 V 的最新值(线程 A 写过的值)。

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。

::: tabs#计数器示例

@tab 线程不安全的计数器

【示例】线程不安全的计数器示例 ❌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@NotThreadSafe
public class NotThreadSafeCounter {

private static long count = 0;

private void add() {
int cnt = 0;
while (cnt++ < 100000) {
count += 1;
}
}

public static void main(String[] args) throws InterruptedException {
final NotThreadSafeCounter demo = new NotThreadSafeCounter();
// 创建两个线程,执行 add() 操作
Thread t1 = new Thread(() -> {
demo.add();
});
Thread t2 = new Thread(() -> {
demo.add();
});
// 启动两个线程
t1.start();
t2.start();
// 等待两个线程执行结束
t1.join();
t2.join();
System.out.println("count = " + count);
}

}
// 输出:
// count = 156602
// 实际结果总是会小于预期值 200000

这段程序的目的是将 count 变量累加到 100000,两个线程执行,则应该累加到 200000,但实际结果总是会小于预期值 200000。

假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

@tab 线程安全的计数器

【示例】线程安全的计数器示例 ✔

针对上面线程不安全的计数器最简单的改造方法就是在 add() 方法上增加 synchronized 锁,如下所示:

1
2
3
4
5
6
7
8
9
10
@ThreadSafe
public class ThreadSafeCounter {
private synchronized void add() {
int cnt = 0;
while (cnt++ < 100000) {
count += 1;
}
}
// 省略
}

:::

线程切换带来的原子性问题

由于 IO 太慢,早期的操作系统就发明了多进程。CPU 会给各个程序分配一个允许执行时间段,即时间片。从表面上看,各程序是同时运行的;实际上, 如果在时间片结束时进程还在运行,则 CPU 将被剥夺并分配给另一个进程。 如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换(称为“任务切换”)。

Java 的并发也是基于任务切换。Java 中,即使是一条语句,也可能需要执行多条 CPU 指令。一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。违背直觉的是,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行+1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

因此,执行 count += 1 不是原子操作。

编译优化带来的有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a=6; b=7; 编译器优化后可能变成 b=7; a=6;,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

在 Java 领域一个经典的案例就是利用双重检查创建单例对象。

【示例】双重检查创建单例对象

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

保证并发安全的思路

互斥同步(阻塞同步)

互斥同步是最常见的并发正确性保障手段。

同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程访问

互斥是实现同步的一种手段。临界区(Critical Sections)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

最典型的案例是使用 synchronizedLock

互斥同步最主要的问题是线程阻塞和唤醒所带来的性能问题,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

非阻塞同步

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

为什么说乐观锁需要 硬件指令集的发展 才能进行?因为需要操作和冲突检测这两个步骤具备原子性。而这点是由硬件来完成,如果再使用互斥同步来保证就失去意义了。

这类乐观锁指令常见的有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(CAS)
  • 加载链接、条件存储(Load-linked / Store-Conditional)

Java 典型应用场景:J.U.C 包中的原子类(基于 Unsafe 类的 CAS 操作)

无同步

要保证线程安全,不一定非要进行同步。同步只是保证共享数据争用时的正确性,如果一个方法本来就不涉及共享数据,那么自然无须同步。

Java 中的 无同步方案 有:

  • 可重入代码 - 也叫纯代码。如果一个方法,它的 返回结果是可以预测的,即只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性,当然也是线程安全的。
  • 线程本地存储 - 使用 ThreadLocal 为共享变量在每个线程中都创建了一个本地副本,这个副本只能被当前线程访问,其他线程无法访问,那么自然是线程安全的。

活跃性问题

程序运行时,当某个操作无法继续执行下去时,就会产生活跃性问题。

对于串行程序,活跃性问题的常见形式是无意中造成的死循环,使得循环之后的代码无法执行。

对于并发程序,会有一些其他的活跃性问题,常见形式有:

  • 死锁
  • 活锁
  • 饥饿

死锁(Deadlock)

什么是死锁

死锁一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象

死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。

【示例】存在死锁的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) {
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}

如何定位死锁

定位死锁最常见的方式就是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以在图形界面进行有限的死锁检测。

如果我们是开发自己的管理工具,需要用更加程序化的方式扫描服务进程、定位死锁,可以考虑使用 Java 提供的标准管理 API,ThreadMXBean,其直接就提供了 findDeadlockedThreads() 方法用于定位。

如何避免死锁

只有以下这四个条件都发生时才会出现死锁:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

也就是说只要破坏任意一个,就可以避免死锁的发生

其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

  1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。超时释放锁
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

活锁(Livelock)

什么是活锁

活锁是一个递归的情况,两个或更多的线程会不断重复一个特定的代码逻辑。预期的逻辑通常为其他线程提供机会继续支持’this’线程。

想象这样一个例子:两个人在狭窄的走廊里相遇,二者都很礼貌,试图移到旁边让对方先通过。但是他们最终在没有取得任何进展的情况下左右摇摆,因为他们都在同一时间向相同的方向移动。

如图所示:两个线程想要通过一个 Worker 对象访问共享公共资源的情况,但是当他们看到另一个 Worker(在另一个线程上调用)也是“活动的”时,它们会尝试将该资源交给其他工作者并等待为它完成。如果最初我们让两名工作人员都活跃起来,他们将会面临活锁问题。

避免活锁

解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。由于等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它。

饥饿(Starvation)

什么是饥饿

  • 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
  • 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
  • 线程在等待一个本身(在其上调用 wait()) 也处于永久等待完成的对象,因为其他线程总是被持续地获得唤醒。

饥饿问题最经典的例子就是哲学家问题。如图所示:有五个哲学家用餐,每个人要获得两支筷子才可以就餐。当 2、4 就餐时,1、3、5 永远无法就餐,只能看着盘中的美食饥饿的等待着。

解决饥饿

Java 不可能实现 100% 的公平性,我们依然可以通过同步结构在线程间实现公平性的提高。

有三种方案:

  • 保证资源充足
  • 公平地分配资源
  • 避免持有锁的线程长时间执行

这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。

那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。

性能问题

并发执行一定比串行执行快吗?线程越多执行越快吗?

答案是:并发不一定比串行快。因为并发过程中,有创建线程和线程上下文切换的开销。

上下文切换

当 CPU 从执行一个线程切换到执行另一个线程时,CPU 需要保存当前线程的本地数据,程序指针等状态,并加载下一个要执行的线程的本地数据,程序指针等。这个开关被称为上下文切换(Context Switch)

如果频繁地出现上下文切换,将带来极大的开销:恢复执行上下文,丢失局部性,并且 CPU 时间将更多地花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。所有这些因素都会产生额外的性能开销。

减少上下文切换的方法:

  • 无锁并发编程 - 多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
  • CAS 算法 - Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
  • 使用最少线程 - 避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  • 使用协程 - 在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

资源限制

程序的执行速度受限于计算机硬件资源或软件资源。在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行。但是,如果将某段串行的代码并发执行,因为受限于资源仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。

如何解决资源限制的问题呢?在资源受限的情况下,可以根据不同的资源限制调整程序的并发度:

  • 对于硬件资源限制,可以考虑使用集群并行执行程序。
  • 对于软件资源限制,可以考虑使用资源池将资源复用。

并发编程

并发编程可以抽象成三个核心问题:分工、同步、互斥。

  • 分工 - 是指如何高效地拆解任务并分配给线程。
  • 同步 - 是指线程之间如何协作。
  • 互斥 - 是指保证同一时刻只允许一个线程访问共享资源。

J.U.C 简介

Java 的 java.util.concurrent 包(简称 J.U.C)中提供了大量并发工具类,是 Java 并发能力的主要体现(注意,不是全部,有部分并发能力的支持在其他包中)。从功能上,大致可以分为:

  • 原子类 - 如:AtomicIntegerAtomicIntegerArrayAtomicReferenceAtomicStampedReference 等。
  • - 如:ReentrantLockReentrantReadWriteLock 等。
  • 并发容器 - 如:ConcurrentHashMapCopyOnWriteArrayListCopyOnWriteArraySet 等。
  • 阻塞队列 - 如:ArrayBlockingQueueLinkedBlockingQueue 等。
  • 非阻塞队列 - 如: ConcurrentLinkedQueueLinkedTransferQueue 等。
  • 线程池 - 如:ThreadPoolExecutorExecutors 等。

J.U.C 包中的工具类是基于 synchronizedvolatileCASThreadLocal 这样的并发核心机制打造的。所以,要想深入理解 J.U.C 工具类的特性、为什么具有这样那样的特性,就必须先理解这些核心机制。

并发术语

并发编程中有很多术语概念相近,容易让人混淆。本节内容通过对比分析,力求让读者清晰理解其概念以及差异。

串行、并行、并发

并发和并行是最容易让新手费解的概念,那么如何理解二者呢?其最关键的差异在于:是否是同时发生:

  • 串行 - 是指任务按照顺序依次执行,每个任务在前一个任务完成后才能开始执行。
  • 并行 - 是指具备同时处理多个任务的能力
  • 并发 - 是指具备处理多个任务的能力,但不一定要同时

下面是我见过最生动的说明,摘自 并发与并行的区别是什么?——知乎的高票答案

  • 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
  • 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
  • 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

同步和异步

  • 同步 - 是指在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。
  • 异步 - 则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

举例来说明:

  • 同步就像是打电话:不挂电话,通话不会结束。
  • 异步就像是发短信:发完短信后,就可以做其他事;当收到回复短信时,手机会通过铃声或振动来提醒。

阻塞和非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态:

  • 阻塞 - 是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞 - 是指在不能立刻得到结果之前,该调用不会阻塞当前线程

举例来说明:

  • 阻塞调用就像是打电话,通话不结束,不能放下。
  • 非阻塞调用就像是发短信,发完短信后,就可以做其他事,短信来了,手机会提醒。

进程、线程、管程、协程

  • 进程(Process) - 进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。进程是操作系统进行资源分配的基本单位。进程可视为一个正在运行的程序
  • 线程(Thread) - 线程是操作系统进行调度的基本单位
  • 管程(Monitor) - 管程是指管理共享变量以及对共享变量的操作过程,让他们支持并发
    • Java 通过 synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法来实现管程技术。
    • 管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程
  • 协程(Coroutine) - 协程可以理解为一种轻量级的线程
    • 从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。
    • 协程虽然也有自己的栈,但是相比线程栈要小得多,典型的线程栈大小差不多有 1M,而协程栈的大小往往只有几 K 或者几十 K。所以,无论是从时间维度还是空间维度来看,协程都比线程轻量得多。
    • Go、Python、Lua、Kotlin 等语言都支持协程;Java OpenSDK 中的 Loom 项目目标就是支持协程。

进程和线程的差异:

  • 一个程序至少有一个进程,一个进程至少有一个线程。
  • 线程比进程划分更细,所以执行开销更小,并发性更高
  • 进程是一个实体,拥有独立的资源;而同一个进程中的多个线程共享进程的资源。

img

JVM 在单个进程中运行,JVM 中的线程共享属于该进程的堆。这就是为什么几个线程可以访问同一个对象。线程共享堆并拥有自己的堆栈空间。这是一个线程如何调用一个方法以及它的局部变量是如何保持线程安全的。但是堆不是线程安全的并且为了线程安全必须进行同步。

竞态条件和临界区

  • 竞态条件(Race Condition) - 程序的执行结果依赖多线程执行的顺序。通俗的说,即多个线程竞争访问同一个资源
  • 临界区(Critical Sections) - 指的是访问共享资源的程序片段

参考资料

深入理解 Java 基本数据类型

img

数据类型分类

Java 中的数据类型有两类:

  • 值类型(又叫内置数据类型,基本数据类型)
  • 引用类型(除值类型以外,都是引用类型,包括 String、数组等)

值类型

Java 语言提供了 8 种基本类型,大致分为 4 类:布尔型、字符型、整数型、浮点型。

基本数据类型 分类 大小 默认值 取值范围 包装类 说明
boolean 布尔型 - false false、true Boolean boolean 的大小,是由具体的JVM实现来决定的
char 字符型 16 bit 'u0000' 0 ~ 65535($2^{16} - 1$) Character 存储 Unicode 码,用单引号赋值
byte 整数型 8 bit 0 -128(-$2^7$) ~ 127($2^7 - 1$) Byte
short 整数型 16 bit 0 -32768(-$2^{15}$) ~ 32767($2^{15} - 1$) Short
int 整数型 32 bit 0 -$2^{31}$ ~ $2^{31} - 1$ Integer
long 整数型 64 bit 0L -$2^{63}$ ~ $2^{63} - 1$ Long 赋值时一般在数字后加上 lL
float 浮点型 32 bit 0.0f 1.4e-45f ~ 3.4028235e+38f Float 赋值时必须在数字后加上 fF
double 浮点型 64 bit 0.0d 4.9e-324 ~ 1.7976931348623157e+308 Double 赋值时一般在数字后加 dD

byteshortintlong 的最高比特位都用于表示正负(0 为正,-1 为负)。

值类型和引用类型的区别

值类型 引用类型
用途 一般用于常量和局部变量;不可用于泛型 可用于泛型
存储方式 值类型的局部变量存放在 JVM 中的局部变量表中;值类型的成员变量(未被 static 修饰 )存放在 JVM 中堆中 几乎所有引用类型的对象实例都存在于堆中
默认值 有默认值且不为 null 默认值是 null
比较方式 == 比较的是值 == 比较的是对象的内存地址;使用 equals() 才是比较值

为什么说几乎所有引用类型的对象实例都存在于堆中?

因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析:如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存。

👉 扩展阅读:Java 基本数据类型和引用类型

装箱和拆箱

包装类、装箱、拆箱

Java 中为每一种基本数据类型提供了相应的包装类,如下:

1
2
3
4
5
6
7
8
Byte <-> byte
Short <-> short
Integer <-> int
Long <-> long
Float <-> float
Double <-> double
Character <-> char
Boolean <-> boolean

引入包装类的目的就是:提供一种机制,使得基本数据类型可以与引用类型互相转换

基本数据类型与包装类的转换被称为装箱和拆箱。

  • 装箱(boxing)是将值类型转换为引用类型。例如:intInteger
    • 装箱过程是通过调用包装类的 valueOf 方法实现的。
  • 拆箱(unboxing)是将引用类型转换为值类型。例如:Integerint
    • 拆箱过程是通过调用包装类的 xxxValue 方法实现的。(xxx 代表对应的基本数据类型)。

【示例】装箱、拆箱示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Integer i1 = 10; // 自动装箱
Integer i2 = new Integer(10); // 非自动装箱
Integer i3 = Integer.valueOf(10); // 非自动装箱
int i4 = new Integer(10); // 自动拆箱
int i5 = i2.intValue(); // 非自动拆箱
System.out.println("i1 = [" + i1 + "]");
System.out.println("i2 = [" + i2 + "]");
System.out.println("i3 = [" + i3 + "]");
System.out.println("i4 = [" + i4 + "]");
System.out.println("i5 = [" + i5 + "]");
System.out.println("i1 == i2 is [" + (i1 == i2) + "]");
System.out.println("i1 == i4 is [" + (i1 == i4) + "]"); // 自动拆箱
// Output:
// i1 = [10]
// i2 = [10]
// i3 = [10]
// i4 = [10]
// i5 = [10]
// i1 == i2 is [false]
// i1 == i4 is [true]

【说明】

上面的例子,虽然简单,但却隐藏了自动装箱、拆箱和非自动装箱、拆箱的应用。从例子中可以看到,明明所有变量都初始化为数值 10 了,但为何会出现 i1 == i2 is [false]i1 == i4 is [true]

原因在于:

  • i1、i2 都是包装类,使用 == 时,Java 将它们当做两个对象,而非两个 int 值来比较,所以两个对象自然是不相等的。正确的比较操作应该使用 equals 方法。
  • i1 是包装类,i4 是基础数据类型,使用 == 时,Java 会将两个 i1 这个包装类对象自动拆箱为一个 int 值,再代入到 == 运算表达式中计算;最终,相当于两个 int 进行比较,由于值相同,所以结果相等。

包装类的缓存机制

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

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

Long 缓存源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}

private static class LongCache {
private LongCache(){}

static final Long cache[] = new Long[-(-128) + 127 + 1];

static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}

从以上代码可知:装箱时,若数值不在包装类缓存范围内,就会创建一个新的包装类实例。由此,我们不难进一步得出以下结论:

  1. 装箱操作可能会创建新对象,频繁的装箱操作会造成不必要的内存消耗,影响性能
  2. 基础数据类型的比较操作使用 ==,包装类的比较操作使用 equals 方法

自动装箱/拆箱

JDK5 开始,支持自动装箱/拆箱功能机制。

自动装箱/拆箱是一种简化程序代码的语法糖,使得值类型和包装类之间的转换更加直接。

JDK 5 之前的形式:

1
Integer i1 = new Integer(10); // 非自动装箱

JDK 5 之后:

1
2
Integer i = 10;  // 自动装箱
int n = i; // 自动拆箱

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
L1

LINENUMBER 8 L1

ALOAD 0

BIPUSH 10

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

PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;

L2

LINENUMBER 9 L2

ALOAD 0

ALOAD 0

GETFIELD AutoBoxTest.i : Ljava/lang/Integer;

INVOKEVIRTUAL java/lang/Integer.intValue ()I

PUTFIELD AutoBoxTest.n : I

RETURN

从字节码示例,可以发现:

  • 自动装箱过程是通过调用包装类的 valueOf 方法实现的。
  • 自动拆箱过程是通过调用包装类的 xxxValue 方法实现的。

因此,

  • Integer i = 10 等价于 Integer i = Integer.valueOf(10)
  • int n = i 等价于 int n = i.intValue();

Java 对于自动装箱和拆箱的设计,依赖于一种叫做享元模式的设计模式(有兴趣的朋友可以去了解一下源码,这里不对设计模式展开详述)。

👉 扩展阅读:深入剖析 Java 中的装箱和拆箱

结合示例,一步步阐述装箱和拆箱原理。

判等问题

Java 中,通常使用 equals== 进行判等操作。equals 是方法而 == 是操作符。此外,二者使用也是有区别的:

  • 值类型,比如 intlong,进行判等,只能使用 ==,比较的是字面值。因为基本类型的值就是其数值。
  • 引用类型,比如 IntegerLongString,进行判等,需要使用 equals 进行内容判等。因为引用类型的直接值是指针,使用 == 的话,比较的是指针,也就是两个对象在内存中的地址,即比较它们是不是同一个对象,而不是比较对象的内容。

包装类的判等

我们通过一个示例来深入研究一下判等问题。

【示例】包装类的判等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Integer a = 127; //Integer.valueOf(127)
Integer b = 127; //Integer.valueOf(127)
log.info("\nInteger a = 127;\nInteger b = 127;\na == b ? {}", a == b); // true

Integer c = 128; //Integer.valueOf(128)
Integer d = 128; //Integer.valueOf(128)
log.info("\nInteger c = 128;\nInteger d = 128;\nc == d ? {}", c == d); //false
//设置-XX:AutoBoxCacheMax=1000再试试

Integer e = 127; //Integer.valueOf(127)
Integer f = new Integer(127); //new instance
log.info("\nInteger e = 127;\nInteger f = new Integer(127);\ne == f ? {}", e == f); //false

Integer g = new Integer(127); //new instance
Integer h = new Integer(127); //new instance
log.info("\nInteger g = new Integer(127);\nInteger h = new Integer(127);\ng == h ? {}", g == h); //false

Integer i = 128; //unbox
int j = 128;
log.info("\nInteger i = 128;\nint j = 128;\ni == j ? {}", i == j); //true

第一个案例中,编译器会把 Integer a = 127 转换为 Integer.valueOf(127)。查看源码可以发现,这个转换在内部其实做了缓存,使得两个 Integer 指向同一个对象,所以 == 返回 true。

1
2
3
4
5
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

第二个案例中,之所以同样的代码 128 就返回 false 的原因是,默认情况下会缓存[-128,127]的数值,而 128 处于这个区间之外。设置 JVM 参数加上 -XX:AutoBoxCacheMax=1000 再试试,是不是就返回 true 了呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];

static {
// high value may be configured by property
int h = 127;
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;
}

private IntegerCache() {}
}

第三和第四个案例中,New 出来的 Integer 始终是不走缓存的新对象。比较两个新对象,或者比较一个新对象和一个来自缓存的对象,结果肯定不是相同的对象,因此返回 false。

第五个案例中,我们把装箱的 Integer 和基本类型 int 比较,前者会先拆箱再比较,比较的肯定是数值而不是引用,因此返回 true。

【总结】综上,我们可以得出结论:**包装类需要使用 equals 进行内容判等,而不能使用 ==**。

String 的判等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String a = "1";
String b = "1";
log.info("\nString a = \"1\";\nString b = \"1\";\na == b ? {}", a == b); //true

String c = new String("2");
String d = new String("2");
log.info("\nString c = new String(\"2\");\nString d = new String(\"2\");\nc == d ? {}", c == d); //false

String e = new String("3").intern();
String f = new String("3").intern();
log.info("\nString e = new String(\"3\").intern();\nString f = new String(\"3\").intern();\ne == f ? {}", e == f); //true

String g = new String("4");
String h = new String("4");
log.info("\nString g = new String(\"4\");\nString h = new String(\"4\");\ng == h ? {}", g.equals(h)); //true

在 JVM 中,当代码中出现双引号形式创建字符串对象时,JVM 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化。

第一个案例返回 true,因为 Java 的字符串驻留机制,直接使用双引号声明出来的两个 String 对象指向常量池中的相同字符串。

第二个案例,new 出来的两个 String 是不同对象,引用当然不同,所以得到 false 的结果。

第三个案例,使用 String 提供的 intern 方法也会走常量池机制,所以同样能得到 true。

第四个案例,通过 equals 对值内容判等,是正确的处理方式,当然会得到 true。

虽然使用 new 声明的字符串调用 intern 方法,也可以让字符串进行驻留,但在业务代码中滥用 intern,可能会产生性能问题。

【示例】String#intern 性能测试

1
2
3
4
5
6
7
8
9
//-XX:+PrintStringTableStatistics
//-XX:StringTableSize=10000000
List<String> list = new ArrayList<>();
long begin = System.currentTimeMillis();
list = IntStream.rangeClosed(1, 10000000)
.mapToObj(i -> String.valueOf(i).intern())
.collect(Collectors.toList());
System.out.println("size:" + list.size());
System.out.println("time:" + (System.currentTimeMillis() - begin));

上面的示例执行时间会比较长。原因在于:字符串常量池是一个固定容量的 Map。如果容量太小(Number of
buckets=60013)、字符串太多(1000 万个字符串),那么每一个桶中的字符串数量会非常多,所以搜索起来就很慢。输出结果中的 Average bucket size=167,代表了 Map 中桶的平均长度是 167。

解决方法是:设置 JVM 参数 -XX:StringTableSize=10000000,指定更多的桶。

为了方便观察,可以在启动程序时设置 JVM 参数 -XX:+PrintStringTableStatistic,程序退出时可以打印出字符串常量表的统计信息。

执行结果比不设置 -XX:StringTableSize 要快很多。

【总结】没事别轻易用 intern,如果要用一定要注意控制驻留的字符串的数量,并留意常量表的各项指标

实现 equals

如果看过 Object 类源码,你可能就知道,equals 的实现其实是比较对象引用

1
2
3
public boolean equals(Object obj) {
return (this == obj);
}

之所以 Integer 或 String 能通过 equals 实现内容判等,是因为它们都覆写了这个方法。

对于自定义类型,如果不覆写 equals 的话,默认就是使用 Object 基类的按引用的比较方式。

实现一个更好的 equals 应该注意的点:

  • 考虑到性能,可以先进行指针判等,如果对象是同一个那么直接返回 true;
  • 需要对另一方进行判空,空对象和自身进行比较,结果一定是 fasle;
  • 需要判断两个对象的类型,如果类型都不同,那么直接返回 false;
  • 确保类型相同的情况下再进行类型强制转换,然后逐一判断所有字段。

【示例】自定义 equals 示例

自定义类:

1
2
3
4
5
class Point {
private final int x;
private final int y;
private final String desc;
}

自定义 equals:

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point that = (Point) o;
return x == that.x && y == that.y;
}

hashCode 和 equals 要配对实现

1
2
3
4
5
6
Point p1 = new Point(1, 2, "a");
Point p2 = new Point(1, 2, "b");

HashSet<PointWrong> points = new HashSet<>();
points.add(p1);
log.info("points.contains(p2) ? {}", points.contains(p2));

按照改进后的 equals 方法,这 2 个对象可以认为是同一个,Set 中已经存在了 p1 就应该包含 p2,但结果却是 false。

出现这个 Bug 的原因是,散列表需要使用 hashCode 来定位元素放到哪个桶。如果自定义对象没有实现自定义的 hashCode 方法,就会使用 Object 超类的默认实现,得到的两个 hashCode 是不同的,导致无法满足需求。

要自定义 hashCode,我们可以直接使用 Objects.hash 方法来实现。

1
2
3
4
@Override
public int hashCode() {
return Objects.hash(x, y);
}

compareTo 和 equals 的逻辑一致性

【示例】自定义 compareTo 出错示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@AllArgsConstructor
static class Student implements Comparable<Student> {

private int id;
private String name;

@Override
public int compareTo(Student other) {
int result = Integer.compare(other.id, id);
if (result == 0) { log.info("this {} == other {}", this, other); }
return result;
}

}

调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
List<Student> list = new ArrayList<>();
list.add(new Student(1, "zhang"));
list.add(new Student(2, "wang"));
Student student = new Student(2, "li");

log.info("ArrayList.indexOf");
int index1 = list.indexOf(student);
Collections.sort(list);
log.info("Collections.binarySearch");
int index2 = Collections.binarySearch(list, student);

log.info("index1 = " + index1);
log.info("index2 = " + index2);

binarySearch 方法内部调用了元素的 compareTo 方法进行比较;

  • indexOf 的结果没问题,列表中搜索不到 id 为 2、name 是 li 的学生;
  • binarySearch 返回了索引 1,代表搜索到的结果是 id 为 2,name 是 wang 的学生。

修复方式很简单,确保 compareTo 的比较逻辑和 equals 的实现一致即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@AllArgsConstructor
static class StudentRight implements Comparable<StudentRight> {

private int id;
private String name;

@Override
public int compareTo(StudentRight other) {
return Comparator.comparing(StudentRight::getName)
.thenComparingInt(StudentRight::getId)
.compare(this, other);
}

}

小心 Lombok 生成代码的“坑”

Lombok 的 @Data 注解会帮我们实现 equals 和 hashcode 方法,但是有继承关系时,
Lombok 自动生成的方法可能就不是我们期望的了。

@EqualsAndHashCode 默认实现没有使用父类属性。为解决这个问题,我们可以手动设置 callSuper 开关为 true,来覆盖这种默认行为。

数据转换

Java 中,数据类型转换有两种方式:

  • 自动转换
  • 强制转换

自动转换

一般情况下,定义了某数据类型的变量,就不能再随意转换。但是 JAVA 允许用户对基本类型做有限度的类型转换。

如果符合以下条件,则 JAVA 将会自动做类型转换:

  • 由小数据转换为大数据

    显而易见的是,“小”数据类型的数值表示范围小于“大”数据类型的数值表示范围,即精度小于“大”数据类型。

    所以,如果“大”数据向“小”数据转换,会丢失数据精度。比如:long 转为 int,则超出 int 表示范围的数据将会丢失,导致结果的不确定性。

    反之,“小”数据向“大”数据转换,则不会存在数据丢失情况。由于这个原因,这种类型转换也称为扩大转换

    这些类型由“小”到“大”分别为:(byte,short,char) < int < long < float < double。

    这里我们所说的“大”与“小”,并不是指占用字节的多少,而是指表示值的范围的大小。

  • 转换前后的数据类型要兼容

    由于 boolean 类型只能存放 true 或 false,这与整数或字符是不兼容的,因此不可以做类型转换。

  • 整型类型和浮点型进行计算后,结果会转为浮点类型

示例:

1
2
3
long x = 30;
float y = 14.3f;
System.out.println("x/y = " + x/y);

输出:

1
x/y = 1.9607843

可见 long 虽然精度大于 float 类型,但是结果为浮点数类型。

强制转换

在不符合自动转换条件时或者根据用户的需要,可以对数据类型做强制的转换。

强制转换使用括号 ()

引用类型也可以使用强制转换。

示例:

1
2
3
float f = 25.5f;
int x = (int)f;
System.out.println("x = " + x);

丢失精度和数据溢出

为什么浮点数计算存在丢失精度的风险

计算机是把数值保存在了变量中,不同类型的数值变量能保存的数值范围不同,当数值超过类型能表达的数值上限则会发生溢出问题。

1
2
3
4
5
6
7
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.0 - 0.8); // 0.19999999999999996
System.out.println(4.015 * 100); // 401.49999999999994
System.out.println(123.3 / 100); // 1.2329999999999999
double amount1 = 2.15;
double amount2 = 1.10;
System.out.println(amount1 - amount2); // 1.0499999999999998

上面的几个示例,输出结果和我们预期的很不一样。为什么会是这样呢?

出现这种问题的主要原因是,计算机是以二进制存储数值的,浮点数也不例外。Java 采用了 IEEE 754 标准实现浮点数的表达和运算,你可以通过这里查看数值转化为二进制的结果。

比如,0.1 的二进制表示为 0.0 0011 0011 0011… (0011 无限循环),再转换为十进制就是 0.1000000000000000055511151231257827021181583404541015625。对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。

如何解决浮点数丢失精度的问题

浮点数无法精确表达和运算的场景,一定要使用 BigDecimal 类型

使用 BigDecimal 时,有个细节要格外注意。让我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
// Output: 0.3000000000000000166533453693773481063544750213623046875

System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
// Output: 0.1999999999999999555910790149937383830547332763671875

System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
// Output: 401.49999999999996802557689079549163579940795898437500

System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));
// Output: 1.232999999999999971578290569595992565155029296875

为什么输出结果仍然不符合预期呢?

使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化 BigDecimal

【示例】浮点数的字符串格式化也要通过 BigDecimal 进行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static void wrong1() {
double num1 = 3.35;
float num2 = 3.35f;
System.out.println(String.format("%.1f", num1)); // 3.4
System.out.println(String.format("%.1f", num2)); // 3.3
}

private static void wrong2() {
double num1 = 3.35;
float num2 = 3.35f;
DecimalFormat format = new DecimalFormat("#.##");
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num1)); // 3.35
format.setRoundingMode(RoundingMode.DOWN);
System.out.println(format.format(num2)); // 3.34
}

private static void right() {
BigDecimal num1 = new BigDecimal("3.35");
BigDecimal num2 = num1.setScale(1, BigDecimal.ROUND_DOWN);
System.out.println(num2); // 3.3
BigDecimal num3 = num1.setScale(1, BigDecimal.ROUND_HALF_UP);
System.out.println(num3); // 3.4
}

BigDecimal 判等问题

1
2
3
4
5
6
7
private static void wrong() {
System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")));
}

private static void right() {
System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1")) == 0);
}

BigDecimal 的 equals 方法的注释中说明了原因,equals 比较的是 BigDecimal 的 value 和 scale,1.0 的 scale 是 1,1 的 scale 是 0,所以结果一定是 false。

如果我们希望只比较 BigDecimal 的 value,可以使用 compareTo 方法

BigDecimal 的 equals 和 hashCode 方法会同时考虑 value 和 scale,如果结合 HashSet 或 HashMap 使用的话就可能会出现麻烦。比如,我们把值为 1.0 的 BigDecimal 加入 HashSet,然后判断其是否存在值为 1 的 BigDecimal,得到的结果是 false。

1
2
3
Set<BigDecimal> hashSet1 = new HashSet<>();
hashSet1.add(new BigDecimal("1.0"));
System.out.println(hashSet1.contains(new BigDecimal("1")));//返回false

解决办法有两个:

第一个方法是,使用 TreeSet 替换 HashSet。TreeSet 不使用 hashCode 方法,也不使用 equals 比较元素,而是使用 compareTo 方法,所以不会有问题。

第二个方法是,把 BigDecimal 存入 HashSet 或 HashMap 前,先使用 stripTrailingZeros 方法去掉尾部的零,比较的时候也去掉尾部的 0,确保 value 相同的 BigDecimal,scale 也是一致的。

1
2
3
4
5
6
7
Set<BigDecimal> hashSet2 = new HashSet<>();
hashSet2.add(new BigDecimal("1.0").stripTrailingZeros());
System.out.println(hashSet2.contains(new BigDecimal("1.000").stripTrailingZeros()));//返回true

Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(new BigDecimal("1.0"));
System.out.println(treeSet.contains(new BigDecimal("1")));//返回true

数值溢出

数值计算还有一个要小心的点是溢出,不管是 int 还是 long,所有的值类型都有超出表达范围的可能性。

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

显然这是发生了溢出,而且是默默的溢出,并没有任何异常。这类问题非常容易被忽略,改进方式有下面 2 种。

方法一是,考虑使用 Math 类的 addExact、subtractExact 等 xxExact 方法进行数值运算,这些方法可以在数值溢出时主动抛出异常。

1
2
3
4
5
6
try {
long l = Long.MAX_VALUE;
System.out.println(Math.addExact(l, 1));
} catch (Exception ex) {
ex.printStackTrace();
}

方法二是,使用 BigInteger类。

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

BigDecimal 是处理浮点数的专家;而 BigInteger 则是对大数进行科学计算的专家。

1
2
3
4
5
6
7
8
BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE));
System.out.println(i.add(BigInteger.ONE).toString());

try {
long l = i.add(BigInteger.ONE).longValueExact();
} catch (Exception ex) {
ex.printStackTrace();
}

参考资料