first commit
This commit is contained in:
parent
02730bc441
commit
633f45ea20
188
专栏/Java核心技术面试精讲/07int和Integer有什么区别?-极客时间.md
Normal file
188
专栏/Java核心技术面试精讲/07int和Integer有什么区别?-极客时间.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
07 int和Integer有什么区别?-极客时间
|
||||||
|
Java 虽然号称是面向对象的语言,但是原始数据类型仍然是重要的组成元素,所以在面试中,经常考察原始数据类型和包装类等 Java 语言特性。
|
||||||
|
|
||||||
|
今天我要问你的问题是,int 和 Integer 有什么区别?谈谈 Integer 的值缓存范围。
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
int 是我们常说的整形数字,是 Java 的 8 个原始数据类型(Primitive Types,boolean、byte 、short、char、int、float、double、long)之一。Java 语言虽然号称一切都是对象,但原始数据类型是例外。
|
||||||
|
|
||||||
|
Integer 是 int 对应的包装类,它有一个 int 类型的字段存储数据,并且提供了基本操作,比如数学运算、int 和字符串之间转换等。在 Java 5 中,引入了自动装箱和自动拆箱功能(boxing/unboxing),Java 可以根据上下文,自动进行转换,极大地简化了相关编程。
|
||||||
|
|
||||||
|
关于 Integer 的值缓存,这涉及 Java 5 中另一个改进。构建 Integer 对象的传统方式是直接调用构造器,直接 new 一个对象。但是根据实践,我们发现大部分数据操作都是集中在有限的、较小的数值范围,因而,在 Java 5 中新增了静态工厂方法 valueOf,在调用它的时候会利用一个缓存机制,带来了明显的性能改进。按照 Javadoc,这个值默认缓存是 -128 到 127 之间。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
今天这个问题涵盖了 Java 里的两个基础要素:原始数据类型、包装类。谈到这里,就可以非常自然地扩展到自动装箱、自动拆箱机制,进而考察封装类的一些设计和实践。坦白说,理解基本原理和用法已经足够日常工作需求了,但是要落实到具体场景,还是有很多问题需要仔细思考才能确定。
|
||||||
|
|
||||||
|
面试官可以结合其他方面,来考察面试者的掌握程度和思考逻辑,比如:
|
||||||
|
|
||||||
|
|
||||||
|
我在专栏第 1 讲中介绍的 Java 使用的不同阶段:编译阶段、运行时,自动装箱 / 自动拆箱是发生在什么阶段?
|
||||||
|
我在前面提到使用静态工厂方法 valueOf 会使用到缓存机制,那么自动装箱的时候,缓存机制起作用吗?
|
||||||
|
为什么我们需要原始数据类型,Java 的对象似乎也很高效,应用中具体会产生哪些差异?
|
||||||
|
阅读过 Integer 源码吗?分析下类或某些方法的设计要点。
|
||||||
|
|
||||||
|
|
||||||
|
似乎有太多内容可以探讨,我们一起来分析一下。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
|
||||||
|
理解自动装箱、拆箱
|
||||||
|
|
||||||
|
|
||||||
|
自动装箱实际上算是一种语法糖。什么是语法糖?可以简单理解为 Java 平台为我们自动进行了一些转换,保证不同的写法在运行时等价,它们发生在编译阶段,也就是生成的字节码是一致的。
|
||||||
|
|
||||||
|
像前面提到的整数,javac 替我们自动把装箱转换为 Integer.valueOf(),把拆箱替换为 Integer.intValue(),这似乎这也顺道回答了另一个问题,既然调用的是 Integer.valueOf,自然能够得到缓存的好处啊。
|
||||||
|
|
||||||
|
如何程序化的验证上面的结论呢?
|
||||||
|
|
||||||
|
你可以写一段简单的程序包含下面两句代码,然后反编译一下。当然,这是一种从表现倒推的方法,大多数情况下,我们还是直接参考规范文档会更加可靠,毕竟软件承诺的是遵循规范,而不是保持当前行为。
|
||||||
|
|
||||||
|
Integer integer = 1;
|
||||||
|
int unboxing = integer ++;
|
||||||
|
|
||||||
|
|
||||||
|
反编译输出:
|
||||||
|
|
||||||
|
1: invokestatic #2 // Method
|
||||||
|
java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
|
||||||
|
8: invokevirtual #3 // Method
|
||||||
|
java/lang/Integer.intValue:()I
|
||||||
|
|
||||||
|
|
||||||
|
这种缓存机制并不是只有 Integer 才有,同样存在于其他的一些包装类,比如:
|
||||||
|
|
||||||
|
|
||||||
|
Boolean,缓存了 true/false 对应实例,确切说,只会返回两个常量实例 Boolean.TRUE/FALSE。
|
||||||
|
Short,同样是缓存了 -128 到 127 之间的数值。
|
||||||
|
Byte,数值有限,所以全部都被缓存。
|
||||||
|
Character,缓存范围’\u0000’ 到 ‘\u007F’。
|
||||||
|
|
||||||
|
|
||||||
|
自动装箱 / 自动拆箱似乎很酷,在编程实践中,有什么需要注意的吗?
|
||||||
|
|
||||||
|
原则上,建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,创建 10 万个 Java 对象和 10 万个整数的开销可不是一个数量级的,不管是内存使用还是处理速度,光是对象头的空间占用就已经是数量级的差距了。
|
||||||
|
|
||||||
|
我们其实可以把这个观点扩展开,使用原始数据类型、数组甚至本地代码实现等,在性能极度敏感的场景往往具有比较大的优势,用其替换掉包装类、动态数组(如 ArrayList)等可以作为性能优化的备选项。一些追求极致性能的产品或者类库,会极力避免创建过多对象。当然,在大多数产品代码里,并没有必要这么做,还是以开发效率优先。以我们经常会使用到的计数器实现为例,下面是一个常见的线程安全计数器实现。
|
||||||
|
|
||||||
|
class Counter {
|
||||||
|
private final AtomicLong counter = new AtomicLong();
|
||||||
|
public void increase() {
|
||||||
|
counter.incrementAndGet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如果利用原始数据类型,可以将其修改为
|
||||||
|
|
||||||
|
class CompactCounter {
|
||||||
|
private volatile long counter;
|
||||||
|
private static final AtomicLongFieldUpdater<CompactCounter> updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class, "counter");
|
||||||
|
public void increase() {
|
||||||
|
updater.incrementAndGet(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
源码分析
|
||||||
|
|
||||||
|
|
||||||
|
考察是否阅读过、是否理解 JDK 源代码可能是部分面试官的关注点,这并不完全是一种苛刻要求,阅读并实践高质量代码也是程序员成长的必经之路,下面我来分析下 Integer 的源码。
|
||||||
|
|
||||||
|
整体看一下 Integer 的职责,它主要包括各种基础的常量,比如最大值、最小值、位数等;前面提到的各种静态工厂方法 valueOf();获取环境变量数值的方法;各种转换方法,比如转换为不同进制的字符串,如 8 进制,或者反过来的解析方法等。我们进一步来看一些有意思的地方。
|
||||||
|
|
||||||
|
首先,继续深挖缓存,Integer 的缓存范围虽然默认是 -128 到 127,但是在特别的应用场景,比如我们明确知道应用会频繁使用更大的数值,这时候应该怎么办呢?
|
||||||
|
|
||||||
|
缓存上限值实际是可以根据需要调整的,JVM 提供了参数设置:
|
||||||
|
|
||||||
|
-XX:AutoBoxCacheMax=N
|
||||||
|
|
||||||
|
|
||||||
|
这些实现,都体现在java.lang.Integer源码之中,并实现在 IntegerCache 的静态初始化块里。
|
||||||
|
|
||||||
|
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 = VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
|
||||||
|
...
|
||||||
|
// range [-128, 127] must be interned (JLS7 5.1.7)
|
||||||
|
assert IntegerCache.high >= 127;
|
||||||
|
}
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
第二,我们在分析字符串的设计实现时,提到过字符串是不可变的,保证了基本的信息安全和并发编程中的线程安全。如果你去看包装类里存储数值的成员变量“value”,你会发现,不管是 Integer 还 Boolean 等,都被声明为“private final”,所以,它们同样是不可变类型!
|
||||||
|
|
||||||
|
这种设计是可以理解的,或者说是必须的选择。想象一下这个应用场景,比如 Integer 提供了 getInteger() 方法,用于方便地读取系统属性,我们可以用属性来设置服务器某个服务的端口,如果我可以轻易地把获取到的 Integer 对象改变为其他数值,这会带来产品可靠性方面的严重问题。
|
||||||
|
|
||||||
|
第三,Integer 等包装类,定义了类似 SIZE 或者 BYTES 这样的常量,这反映了什么样的设计考虑呢?如果你使用过其他语言,比如 C、C++,类似整数的位数,其实是不确定的,可能在不同的平台,比如 32 位或者 64 位平台,存在非常大的不同。那么,在 32 位 JDK 或者 64 位 JDK 里,数据位数会有不同吗?或者说,这个问题可以扩展为,我使用 32 位 JDK 开发编译的程序,运行在 64 位 JDK 上,需要做什么特别的移植工作吗?
|
||||||
|
|
||||||
|
其实,这种移植对于 Java 来说相对要简单些,因为原始数据类型是不存在差异的,这些明确定义在Java 语言规范里面,不管是 32 位还是 64 位环境,开发者无需担心数据的位数差异。
|
||||||
|
|
||||||
|
对于应用移植,虽然存在一些底层实现的差异,比如 64 位 HotSpot JVM 里的对象要比 32 位 HotSpot JVM 大(具体区别取决于不同 JVM 实现的选择),但是总体来说,并没有行为差异,应用移植还是可以做到宣称的“一次书写,到处执行”,应用开发者更多需要考虑的是容量、能力等方面的差异。
|
||||||
|
|
||||||
|
|
||||||
|
原始类型线程安全
|
||||||
|
|
||||||
|
|
||||||
|
前面提到了线程安全设计,你有没有想过,原始数据类型操作是不是线程安全的呢?
|
||||||
|
|
||||||
|
这里可能存在着不同层面的问题:
|
||||||
|
|
||||||
|
|
||||||
|
原始数据类型的变量,显然要使用并发相关手段,才能保证线程安全,这些我会在专栏后面的并发主题详细介绍。如果有线程安全的计算需要,建议考虑使用类似 AtomicInteger、AtomicLong 这样的线程安全类。
|
||||||
|
特别的是,部分比较宽的数据类型,比如 float、double,甚至不能保证更新操作的原子性,可能出现程序读取到只更新了一半数据位的数值!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Java 原始数据类型和引用类型局限性
|
||||||
|
|
||||||
|
|
||||||
|
前面我谈了非常多的技术细节,最后再从 Java 平台发展的角度来看看,原始数据类型、对象的局限性和演进。
|
||||||
|
|
||||||
|
对于 Java 应用开发者,设计复杂而灵活的类型系统似乎已经习以为常了。但是坦白说,毕竟这种类型系统的设计是源于很多年前的技术决定,现在已经逐渐暴露出了一些副作用,例如:
|
||||||
|
|
||||||
|
|
||||||
|
原始数据类型和 Java 泛型并不能配合使用
|
||||||
|
|
||||||
|
|
||||||
|
这是因为 Java 的泛型某种程度上可以算作伪泛型,它完全是一种编译期的技巧,Java 编译期会自动将类型转换为对应的特定类型,这就决定了使用泛型,必须保证相应类型可以转换为 Object。
|
||||||
|
|
||||||
|
|
||||||
|
无法高效地表达数据,也不便于表达复杂的数据结构,比如 vector 和 tuple
|
||||||
|
|
||||||
|
|
||||||
|
我们知道 Java 的对象都是引用类型,如果是一个原始数据类型数组,它在内存里是一段连续的内存,而对象数组则不然,数据存储的是引用,对象往往是分散地存储在堆的不同位置。这种设计虽然带来了极大灵活性,但是也导致了数据操作的低效,尤其是无法充分利用现代 CPU 缓存机制。
|
||||||
|
|
||||||
|
Java 为对象内建了各种多态、线程安全等方面的支持,但这不是所有场合的需求,尤其是数据处理重要性日益提高,更加高密度的值类型是非常现实的需求。
|
||||||
|
|
||||||
|
针对这些方面的增强,目前正在 OpenJDK 领域紧锣密鼓地进行开发,有兴趣的话你可以关注相关工程:http://openjdk.java.net/projects/valhalla/ 。
|
||||||
|
|
||||||
|
今天,我梳理了原始数据类型及其包装类,从源码级别分析了缓存机制等设计和实现细节,并且针对构建极致性能的场景,分析了一些可以借鉴的实践。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,前面提到了从空间角度,Java 对象要比原始数据类型开销大的多。你知道对象的内存结构是什么样的吗?比如,对象头的结构。如何计算或者获取某个 Java 对象的大小?
|
||||||
|
|
||||||
|
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
142
专栏/Java核心技术面试精讲/08对比Vector、ArrayList、LinkedList有何区别?-极客时间.md
Normal file
142
专栏/Java核心技术面试精讲/08对比Vector、ArrayList、LinkedList有何区别?-极客时间.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
08 对比Vector、ArrayList、LinkedList有何区别?-极客时间
|
||||||
|
我们在日常的工作中,能够高效地管理和操作数据是非常重要的。由于每个编程语言支持的数据结构不尽相同,比如我最早学习的 C 语言,需要自己实现很多基础数据结构,管理和操作会比较麻烦。相比之下,Java 则要方便的多,针对通用场景的需求,Java 提供了强大的集合框架,大大提高了开发者的生产力。
|
||||||
|
|
||||||
|
今天我要问你的是有关集合框架方面的问题,对比 Vector、ArrayList、LinkedList 有何区别?
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
这三者都是实现集合框架中的 List,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因为具体的设计区别,在行为、性能、线程安全等方面,表现又有很大不同。
|
||||||
|
|
||||||
|
Vector 是 Java 早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector 内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
|
||||||
|
|
||||||
|
ArrayList 是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与 Vector 近似,ArrayList 也是可以根据需要调整容量,不过两者的调整逻辑有所区别,Vector 在扩容时会提高 1 倍,而 ArrayList 则是增加 50%。
|
||||||
|
|
||||||
|
LinkedList 顾名思义是 Java 提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
似乎从我接触 Java 开始,这个问题就一直是经典的面试题,前面我的回答覆盖了三者的一些基本的设计和实现。
|
||||||
|
|
||||||
|
一般来说,也可以补充一下不同容器类型适合的场景:
|
||||||
|
|
||||||
|
|
||||||
|
Vector 和 ArrayList 作为动态数组,其内部元素以数组形式顺序存储的,所以非常适合随机访问的场合。除了尾部插入和删除元素,往往性能会相对较差,比如我们在中间位置插入一个元素,需要移动后续所有元素。
|
||||||
|
而 LinkedList 进行节点插入、删除却要高效得多,但是随机访问性能则要比动态数组慢。
|
||||||
|
|
||||||
|
|
||||||
|
所以,在应用开发中,如果事先可以估计到,应用操作是偏向于插入、删除,还是随机访问较多,就可以针对性的进行选择。这也是面试最常见的一个考察角度,给定一个场景,选择适合的数据结构,所以对于这种典型选择一定要掌握清楚。
|
||||||
|
|
||||||
|
考察 Java 集合框架,我觉得有很多方面需要掌握:
|
||||||
|
|
||||||
|
|
||||||
|
Java 集合框架的设计结构,至少要有一个整体印象。
|
||||||
|
Java 提供的主要容器(集合和 Map)类型,了解或掌握对应的数据结构、算法,思考具体技术选择。
|
||||||
|
将问题扩展到性能、并发等领域。
|
||||||
|
集合框架的演进与发展。
|
||||||
|
|
||||||
|
|
||||||
|
作为 Java 专栏,我会在尽量围绕 Java 相关进行扩展,否则光是罗列集合部分涉及的数据结构就要占用很大篇幅。这并不代表那些不重要,数据结构和算法是基本功,往往也是必考的点,有些公司甚至以考察这些方面而非常知名(甚至是“臭名昭著”)。我这里以需要掌握典型排序算法为例,你至少需要熟知:
|
||||||
|
|
||||||
|
|
||||||
|
内部排序,至少掌握基础算法如归并排序、交换排序(冒泡、快排)、选择排序、插入排序等。
|
||||||
|
外部排序,掌握利用内存和外部存储处理超大数据集,至少要理解过程和思路。
|
||||||
|
|
||||||
|
|
||||||
|
考察算法不仅仅是如何简单实现,面试官往往会刨根问底,比如哪些是排序是不稳定的呢(快排、堆排),或者思考稳定意味着什么;对不同数据集,各种排序的最好或最差情况;从某个角度如何进一步优化(比如空间占用,假设业务场景需要最小辅助空间,这个角度堆排序就比归并优异)等,从简单的了解,到进一步的思考,面试官通常还会观察面试者处理问题和沟通时的思路。
|
||||||
|
|
||||||
|
以上只是一个方面的例子,建议学习相关书籍,如《算法导论》《编程珠玑》等,或相关教程。对于特定领域,比如推荐系统,建议咨询领域专家。单纯从面试的角度,很多朋友推荐使用一些算法网站如 LeetCode 等,帮助复习和准备面试,但坦白说我并没有刷过这些算法题,这也是仁者见仁智者见智的事情,招聘时我更倾向于考察面试者自身最擅长的东西,免得招到纯面试高手。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
我们先一起来理解集合框架的整体设计,为了有个直观的印象,我画了一个简要的类图。注意,为了避免混淆,我这里没有把 java.util.concurrent 下面的线程安全容器添加进来;也没有列出 Map 容器,虽然通常概念上我们也会把 Map 作为集合框架的一部分,但是它本身并不是真正的集合(Collection)。
|
||||||
|
|
||||||
|
所以,我今天主要围绕狭义的集合框架,其他都会在专栏后面的内容进行讲解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
我们可以看到 Java 的集合框架,Collection 接口是所有集合的根,然后扩展开提供了三大类集合,分别是:
|
||||||
|
|
||||||
|
|
||||||
|
List,也就是我们前面介绍最多的有序集合,它提供了方便的访问、插入、删除等操作。
|
||||||
|
Set,Set 是不允许重复元素的,这是和 List 最明显的区别,也就是不存在两个对象 equals 返回 true。我们在日常开发中有很多需要保证元素唯一性的场合。
|
||||||
|
Queue/Deque,则是 Java 提供的标准队列结构的实现,除了集合的基本功能,它还支持类似先入先出(FIFO, First-in-First-Out)或者后入先出(LIFO,Last-In-First-Out)等特定行为。这里不包括 BlockingQueue,因为通常是并发编程场合,所以被放置在并发包里。
|
||||||
|
|
||||||
|
|
||||||
|
每种集合的通用逻辑,都被抽象到相应的抽象类之中,比如 AbstractList 就集中了各种 List 操作的通用部分。这些集合不是完全孤立的,比如,LinkedList 本身,既是 List,也是 Deque 哦。
|
||||||
|
|
||||||
|
如果阅读过更多源码,你会发现,其实,TreeSet 代码里实际默认是利用 TreeMap 实现的,Java 类库创建了一个 Dummy 对象“PRESENT”作为 value,然后所有插入的元素其实是以键的形式放入了 TreeMap 里面;同理,HashSet 其实也是以 HashMap 为基础实现的,原来他们只是 Map 类的马甲!
|
||||||
|
|
||||||
|
就像前面提到过的,我们需要对各种具体集合实现,至少了解基本特征和典型使用场景,以 Set 的几个实现为例:
|
||||||
|
|
||||||
|
|
||||||
|
TreeSet 支持自然顺序访问,但是添加、删除、包含等操作要相对低效(log(n) 时间)。
|
||||||
|
HashSet 则是利用哈希算法,理想情况下,如果哈希散列正常,可以提供常数时间的添加、删除、包含等操作,但是它不保证有序。
|
||||||
|
LinkedHashSet,内部构建了一个记录插入顺序的双向链表,因此提供了按照插入顺序遍历的能力,与此同时,也保证了常数时间的添加、删除、包含等操作,这些操作性能略低于 HashSet,因为需要维护链表的开销。
|
||||||
|
在遍历元素时,HashSet 性能受自身容量影响,所以初始化时,除非有必要,不然不要将其背后的 HashMap 容量设置过大。而对于 LinkedHashSet,由于其内部链表提供的方便,遍历性能只和元素多少有关系。
|
||||||
|
|
||||||
|
|
||||||
|
我今天介绍的这些集合类,都不是线程安全的,对于 java.util.concurrent 里面的线程安全容器,我在专栏后面会去介绍。但是,并不代表这些集合完全不能支持并发编程的场景,在 Collections 工具类中,提供了一系列的 synchronized 方法,比如
|
||||||
|
|
||||||
|
static <T> List<T> synchronizedList(List<T> list)
|
||||||
|
|
||||||
|
|
||||||
|
我们完全可以利用类似方法来实现基本的线程安全集合:
|
||||||
|
|
||||||
|
List list = Collections.synchronizedList(new ArrayList());
|
||||||
|
|
||||||
|
|
||||||
|
它的实现,基本就是将每个基本方法,比如 get、set、add 之类,都通过 synchronized 添加基本的同步支持,非常简单粗暴,但也非常实用。注意这些方法创建的线程安全集合,都符合迭代时 fail-fast 行为,当发生意外的并发修改时,尽早抛出 ConcurrentModificationException 异常,以避免不可预计的行为。
|
||||||
|
|
||||||
|
另外一个经常会被考察到的问题,就是理解 Java 提供的默认排序算法,具体是什么排序方式以及设计思路等。
|
||||||
|
|
||||||
|
这个问题本身就是有点陷阱的意味,因为需要区分是 Arrays.sort() 还是 Collections.sort() (底层是调用 Arrays.sort());什么数据类型;多大的数据集(太小的数据集,复杂排序是没必要的,Java 会直接进行二分插入排序)等。
|
||||||
|
|
||||||
|
|
||||||
|
对于原始数据类型,目前使用的是所谓双轴快速排序(Dual-Pivot QuickSort),是一种改进的快速排序算法,早期版本是相对传统的快速排序,你可以阅读源码。
|
||||||
|
而对于对象数据类型,目前则是使用TimSort,思想上也是一种归并和二分插入排序(binarySort)结合的优化排序算法。TimSort 并不是 Java 的独创,简单说它的思路是查找数据集中已经排好序的分区(这里叫 run),然后合并这些分区来达到排序的目的。
|
||||||
|
|
||||||
|
|
||||||
|
另外,Java 8 引入了并行排序算法(直接使用 parallelSort 方法),这是为了充分利用现代多核处理器的计算能力,底层实现基于 fork-join 框架(专栏后面会对 fork-join 进行相对详细的介绍),当处理的数据集比较小的时候,差距不明显,甚至还表现差一点;但是,当数据集增长到数万或百万以上时,提高就非常大了,具体还是取决于处理器和系统环境。
|
||||||
|
|
||||||
|
排序算法仍然在不断改进,最近双轴快速排序实现的作者提交了一个更进一步的改进,历时多年的研究,目前正在审核和验证阶段。根据作者的性能测试对比,相比于基于归并排序的实现,新改进可以提高随机数据排序速度提高 10%~20%,甚至在其他特征的数据集上也有几倍的提高,有兴趣的话你可以参考具体代码和介绍:
|
||||||
|
|
||||||
|
http://mail.openjdk.java.net/pipermail/core-libs-dev/2018-January/051000.html
|
||||||
|
|
||||||
|
在 Java 8 之中,Java 平台支持了 Lambda 和 Stream,相应的 Java 集合框架也进行了大范围的增强,以支持类似为集合创建相应 stream 或者 parallelStream 的方法实现,我们可以非常方便的实现函数式代码。
|
||||||
|
|
||||||
|
阅读 Java 源代码,你会发现,这些 API 的设计和实现比较独特,它们并不是实现在抽象类里面,而是以默认方法的形式实现在 Collection 这样的接口里!这是 Java 8 在语言层面的新特性,允许接口实现默认方法,理论上来说,我们原来实现在类似 Collections 这种工具类中的方法,大多可以转换到相应的接口上。针对这一点,我在面向对象主题,会专门梳理 Java 语言面向对象基本机制的演进。
|
||||||
|
|
||||||
|
在 Java 9 中,Java 标准类库提供了一系列的静态工厂方法,比如,List.of()、Set.of(),大大简化了构建小的容器实例的代码量。根据业界实践经验,我们发现相当一部分集合实例都是容量非常有限的,而且在生命周期中并不会进行修改。但是,在原有的 Java 类库中,我们可能不得不写成:
|
||||||
|
|
||||||
|
ArrayList<String> list = new ArrayList<>();
|
||||||
|
list.add("Hello");
|
||||||
|
list.add("World");
|
||||||
|
|
||||||
|
|
||||||
|
而利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。
|
||||||
|
|
||||||
|
List<String> simpleList = List.of("Hello","world");
|
||||||
|
|
||||||
|
|
||||||
|
更进一步,通过各种 of 静态工厂方法创建的实例,还应用了一些我们所谓的最佳实践,比如,它是不可变的,符合我们对线程安全的需求;它因为不需要考虑扩容,所以空间上更加紧凑等。
|
||||||
|
|
||||||
|
如果我们去看 of 方法的源码,你还会发现一个特别有意思的地方:我们知道 Java 已经支持所谓的可变参数(varargs),但是官方类库还是提供了一系列特定参数长度的方法,看起来似乎非常不优雅,为什么呢?这其实是为了最优的性能,JVM 在处理变长参数的时候会有明显的额外开销,如果你需要实现性能敏感的 API,也可以进行参考。
|
||||||
|
|
||||||
|
今天我从 Verctor、ArrayList、LinkedList 开始,逐步分析其设计实现区别、适合的应用场景等,并进一步对集合框架进行了简单的归纳,介绍了集合框架从基础算法到 API 设计实现的各种改进,希望能对你的日常开发和 API 设计能够有帮助。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,先思考一个应用场景,比如你需要实现一个云计算任务调度系统,希望可以保证 VIP 客户的任务被优先处理,你可以利用哪些数据结构或者标准的集合类型呢?更进一步讲,类似场景大多是基于什么数据结构呢?
|
||||||
|
|
||||||
|
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
315
专栏/Java核心技术面试精讲/09对比Hashtable、HashMap、TreeMap有什么不同?-极客时间.md
Normal file
315
专栏/Java核心技术面试精讲/09对比Hashtable、HashMap、TreeMap有什么不同?-极客时间.md
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
09 对比Hashtable、HashMap、TreeMap有什么不同?-极客时间
|
||||||
|
Map 是广义 Java 集合框架中的另外一部分,HashMap 作为框架中使用频率最高的类型之一,它本身以及相关类型自然也是面试考察的热点。
|
||||||
|
|
||||||
|
今天我要问你的问题是,对比 Hashtable、HashMap、TreeMap 有什么不同?谈谈你对 HashMap 的掌握。
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
Hashtable、HashMap、TreeMap 都是最常见的一些 Map 实现,是以键值对的形式存储和操作数据的容器类型。
|
||||||
|
|
||||||
|
Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
|
||||||
|
|
||||||
|
HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户 ID 和用户信息对应的运行时存储结构。
|
||||||
|
|
||||||
|
TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(log(n))的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
上面的回答,只是对一些基本特征的简单总结,针对 Map 相关可以扩展的问题很多,从各种数据结构、典型应用场景,到程序设计实现的技术考量,尤其是在 Java 8 里,HashMap 本身发生了非常大的变化,这些都是经常考察的方面。
|
||||||
|
|
||||||
|
很多朋友向我反馈,面试官似乎钟爱考察 HashMap 的设计和实现细节,所以今天我会增加相应的源码解读,主要专注于下面几个方面:
|
||||||
|
|
||||||
|
|
||||||
|
理解 Map 相关类似整体结构,尤其是有序数据结构的一些要点。
|
||||||
|
从源码去分析 HashMap 的设计和实现要点,理解容量、负载因子等,为什么需要这些参数,如何影响 Map 的性能,实践中如何取舍等。
|
||||||
|
理解树化改造的相关原理和改进原因。
|
||||||
|
|
||||||
|
|
||||||
|
除了典型的代码分析,还有一些有意思的并发相关问题也经常会被提到,如 HashMap 在并发环境可能出现无限循环占用 CPU、size 不准确等诡异的问题。
|
||||||
|
|
||||||
|
我认为这是一种典型的使用错误,因为 HashMap 明确声明不是线程安全的数据结构,如果忽略这一点,简单用在多线程场景里,难免会出现问题。
|
||||||
|
|
||||||
|
理解导致这种错误的原因,也是深入理解并发程序运行的好办法。对于具体发生了什么,你可以参考这篇很久以前的分析,里面甚至提供了示意图,我就不再重复别人写好的内容了。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
|
||||||
|
Map 整体结构
|
||||||
|
|
||||||
|
|
||||||
|
首先,我们先对 Map 相关类型有个整体了解,Map 虽然通常被包括在 Java 集合框架里,但是其本身并不是狭义上的集合类型(Collection),具体你可以参考下面这个简单类图。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Hashtable 比较特别,作为类似 Vector、Stack 的早期集合相关类型,它是扩展了 Dictionary 类的,类结构上与 HashMap 之类明显不同。
|
||||||
|
|
||||||
|
HashMap 等其他 Map 实现则是都扩展了 AbstractMap,里面包含了通用方法抽象。不同 Map 的用途,从类图结构就能体现出来,设计目的已经体现在不同接口上。
|
||||||
|
|
||||||
|
大部分使用 Map 的场景,通常就是放入、访问或者删除,而对顺序没有特别要求,HashMap 在这种情况下基本是最好的选择。HashMap 的性能表现非常依赖于哈希码的有效性,请务必掌握 hashCode 和 equals 的一些基本约定,比如:
|
||||||
|
|
||||||
|
|
||||||
|
equals 相等,hashCode 一定要相等。
|
||||||
|
重写了 hashCode 也要重写 equals。
|
||||||
|
hashCode 需要保持一致性,状态改变返回的哈希值仍然要一致。
|
||||||
|
equals 的对称、反射、传递等特性。
|
||||||
|
|
||||||
|
|
||||||
|
这方面内容网上有很多资料,我就不在这里详细展开了。
|
||||||
|
|
||||||
|
针对有序 Map 的分析内容比较有限,我再补充一些,虽然 LinkedHashMap 和 TreeMap 都可以保证某种顺序,但二者还是非常不同的。
|
||||||
|
|
||||||
|
|
||||||
|
LinkedHashMap 通常提供的是遍历顺序符合插入顺序,它的实现是通过为条目(键值对)维护一个双向链表。注意,通过特定构造函数,我们可以创建反映访问顺序的实例,所谓的 put、get、compute 等,都算作“访问”。
|
||||||
|
|
||||||
|
|
||||||
|
这种行为适用于一些特定应用场景,例如,我们构建一个空间占用敏感的资源池,希望可以自动将最不常被访问的对象释放掉,这就可以利用 LinkedHashMap 提供的机制来实现,参考下面的示例:
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
public class LinkedHashMapSample {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
LinkedHashMap<String, String> accessOrderedMap = new LinkedHashMap<String, String>(16, 0.75F, true){
|
||||||
|
@Override
|
||||||
|
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) { // 实现自定义删除策略,否则行为就和普遍Map没有区别
|
||||||
|
return size() > 3;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
accessOrderedMap.put("Project1", "Valhalla");
|
||||||
|
accessOrderedMap.put("Project2", "Panama");
|
||||||
|
accessOrderedMap.put("Project3", "Loom");
|
||||||
|
accessOrderedMap.forEach( (k,v) -> {
|
||||||
|
System.out.println(k +":" + v);
|
||||||
|
});
|
||||||
|
// 模拟访问
|
||||||
|
accessOrderedMap.get("Project2");
|
||||||
|
accessOrderedMap.get("Project2");
|
||||||
|
accessOrderedMap.get("Project3");
|
||||||
|
System.out.println("Iterate over should be not affected:");
|
||||||
|
accessOrderedMap.forEach( (k,v) -> {
|
||||||
|
System.out.println(k +":" + v);
|
||||||
|
});
|
||||||
|
// 触发删除
|
||||||
|
accessOrderedMap.put("Project4", "Mission Control");
|
||||||
|
System.out.println("Oldest entry should be removed:");
|
||||||
|
accessOrderedMap.forEach( (k,v) -> {// 遍历顺序不变
|
||||||
|
System.out.println(k +":" + v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
对于 TreeMap,它的整体顺序是由键的顺序关系决定的,通过 Comparator 或 Comparable(自然顺序)来决定。
|
||||||
|
|
||||||
|
|
||||||
|
我在上一讲留给你的思考题提到了,构建一个具有优先级的调度系统的问题,其本质就是个典型的优先队列场景,Java 标准库提供了基于二叉堆实现的 PriorityQueue,它们都是依赖于同一种排序机制,当然也包括 TreeMap 的马甲 TreeSet。
|
||||||
|
|
||||||
|
类似 hashCode 和 equals 的约定,为了避免模棱两可的情况,自然顺序同样需要符合一个约定,就是 compareTo 的返回值需要和 equals 一致,否则就会出现模棱两可情况。
|
||||||
|
|
||||||
|
我们可以分析 TreeMap 的 put 方法实现:
|
||||||
|
|
||||||
|
public V put(K key, V value) {
|
||||||
|
Entry<K,V> t = …
|
||||||
|
cmp = k.compareTo(t.key);
|
||||||
|
if (cmp < 0)
|
||||||
|
t = t.left;
|
||||||
|
else if (cmp > 0)
|
||||||
|
t = t.right;
|
||||||
|
else
|
||||||
|
return t.setValue(value);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
从代码里,你可以看出什么呢? 当我不遵守约定时,两个不符合唯一性(equals)要求的对象被当作是同一个(因为,compareTo 返回 0),这会导致歧义的行为表现。
|
||||||
|
|
||||||
|
|
||||||
|
HashMap 源码分析
|
||||||
|
|
||||||
|
|
||||||
|
前面提到,HashMap 设计与实现是个非常高频的面试题,所以我会在这进行相对详细的源码解读,主要围绕:
|
||||||
|
|
||||||
|
|
||||||
|
HashMap 内部实现基本点分析。
|
||||||
|
容量(capacity)和负载系数(load factor)。
|
||||||
|
树化 。
|
||||||
|
|
||||||
|
|
||||||
|
首先,我们来一起看看 HashMap 内部的结构,它可以看作是数组(Node[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对,则以链表形式存储,你可以参考下面的示意图。这里需要注意的是,如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),图中的链表就会被改造为树形结构。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
从非拷贝构造函数的实现来看,这个表格(数组)似乎并没有在最初就初始化好,仅仅设置了一些初始值而已。
|
||||||
|
|
||||||
|
public HashMap(int initialCapacity, float loadFactor){
|
||||||
|
// ...
|
||||||
|
this.loadFactor = loadFactor;
|
||||||
|
this.threshold = tableSizeFor(initialCapacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以,我们深刻怀疑,HashMap 也许是按照 lazy-load 原则,在首次使用时被初始化(拷贝构造函数除外,我这里仅介绍最通用的场景)。既然如此,我们去看看 put 方法实现,似乎只有一个 putVal 的调用:
|
||||||
|
|
||||||
|
public V put(K key, V value) {
|
||||||
|
return putVal(hash(key), key, value, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
看来主要的秘密似乎藏在 putVal 里面,到底有什么秘密呢?为了节省空间,我这里只截取了 putVal 比较关键的几部分。
|
||||||
|
|
||||||
|
final V putVal(int hash, K key, V value, boolean onlyIfAbent,
|
||||||
|
boolean evit) {
|
||||||
|
Node<K,V>[] tab; Node<K,V> p; int , i;
|
||||||
|
if ((tab = table) == null || (n = tab.length) = 0)
|
||||||
|
n = (tab = resize()).length;
|
||||||
|
if ((p = tab[i = (n - 1) & hash]) == ull)
|
||||||
|
tab[i] = newNode(hash, key, value, nll);
|
||||||
|
else {
|
||||||
|
// ...
|
||||||
|
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first
|
||||||
|
treeifyBin(tab, hash);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
从 putVal 方法最初的几行,我们就可以发现几个有意思的地方:
|
||||||
|
|
||||||
|
|
||||||
|
如果表格是 null,resize 方法会负责初始化它,这从 tab = resize() 可以看出。
|
||||||
|
resize 方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)。
|
||||||
|
在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。
|
||||||
|
|
||||||
|
|
||||||
|
if (++size > threshold)
|
||||||
|
resize();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
具体键值对在哈希表中的位置(数组 index)取决于下面的位运算:
|
||||||
|
|
||||||
|
|
||||||
|
i = (n - 1) & hash
|
||||||
|
|
||||||
|
|
||||||
|
仔细观察哈希值的源头,我们会发现,它并不是 key 本身的 hashCode,而是来自于 HashMap 内部的另外一个 hash 方法。注意,为什么这里需要将高位数据移位到低位进行异或运算呢?这是因为有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。
|
||||||
|
|
||||||
|
static final int hash(Object kye) {
|
||||||
|
int h;
|
||||||
|
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
前面提到的链表结构(这里叫 bin),会在达到一定门限值时,发生树化,我稍后会分析为什么 HashMap 需要对 bin 进行处理。
|
||||||
|
|
||||||
|
|
||||||
|
可以看到,putVal 方法本身逻辑非常集中,从初始化、扩容到树化,全部都和它有关,推荐你阅读源码的时候,可以参考上面的主要逻辑。
|
||||||
|
|
||||||
|
我进一步分析一下身兼多职的 resize 方法,很多朋友都反馈经常被面试官追问它的源码设计。
|
||||||
|
|
||||||
|
final Node<K,V>[] resize() {
|
||||||
|
// ...
|
||||||
|
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&
|
||||||
|
oldCap >= DEFAULT_INITIAL_CAPAITY)
|
||||||
|
newThr = oldThr << 1; // double there
|
||||||
|
// ...
|
||||||
|
else if (oldThr > 0) // initial capacity was placed in threshold
|
||||||
|
newCap = oldThr;
|
||||||
|
else {
|
||||||
|
// zero initial threshold signifies using defaultsfults
|
||||||
|
newCap = DEFAULT_INITIAL_CAPAITY;
|
||||||
|
newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY;
|
||||||
|
}
|
||||||
|
if (newThr ==0) {
|
||||||
|
float ft = (float)newCap * loadFator;
|
||||||
|
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
|
||||||
|
}
|
||||||
|
threshold = neThr;
|
||||||
|
Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];
|
||||||
|
table = n;
|
||||||
|
// 移动到新的数组结构e数组结构
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
依据 resize 源码,不考虑极端情况(容量理论最大极限由 MAXIMUM_CAPACITY 指定,数值为 1<
|
||||||
|
|
||||||
|
|
||||||
|
门限值等于(负载因子)x(容量),如果构建 HashMap 的时候没有指定它们,那么就是依据相应的默认常量值。
|
||||||
|
门限通常是以倍数进行调整 (newThr = oldThr << 1),我前面提到,根据 putVal 中的逻辑,当元素个数超过门限大小时,则调整 Map 大小。
|
||||||
|
扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
容量、负载因子和树化
|
||||||
|
|
||||||
|
|
||||||
|
前面我们快速梳理了一下 HashMap 从创建到放入键值对的相关逻辑,现在思考一下,为什么我们需要在乎容量和负载因子呢?
|
||||||
|
|
||||||
|
这是因为容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。
|
||||||
|
|
||||||
|
既然容量和负载因子这么重要,我们在实践中应该如何选择呢?
|
||||||
|
|
||||||
|
如果能够知道 HashMap 要存取的键值对数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:
|
||||||
|
|
||||||
|
负载因子 * 容量 > 元素数量
|
||||||
|
|
||||||
|
|
||||||
|
所以,预先设置的容量需要满足,大于“预估元素数量 / 负载因子”,同时它是 2 的幂数,结论已经非常清晰了。
|
||||||
|
|
||||||
|
而对于负载因子,我建议:
|
||||||
|
|
||||||
|
|
||||||
|
如果没有特别需求,不要轻易进行更改,因为 JDK 自身的默认负载因子是非常符合通用场景的需求的。
|
||||||
|
如果确实需要调整,建议不要设置超过 0.75 的数值,因为会显著增加冲突,降低 HashMap 的性能。
|
||||||
|
如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。
|
||||||
|
|
||||||
|
|
||||||
|
我们前面提到了树化改造,对应逻辑主要在 putVal 和 treeifyBin 中。
|
||||||
|
|
||||||
|
final void treeifyBin(Node<K,V>[] tab, int hash) {
|
||||||
|
int n, index; Node<K,V> e;
|
||||||
|
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
|
||||||
|
resize();
|
||||||
|
else if ((e = tab[index = (n - 1) & hash]) != null) {
|
||||||
|
//树化改造逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
上面是精简过的 treeifyBin 示意,综合这两个方法,树化改造的逻辑就非常清晰了,可以理解为,当 bin 的数量大于 TREEIFY_THRESHOLD 时:
|
||||||
|
|
||||||
|
|
||||||
|
如果容量小于 MIN_TREEIFY_CAPACITY,只会进行简单的扩容。
|
||||||
|
如果容量大于 MIN_TREEIFY_CAPACITY ,则会进行树化改造。
|
||||||
|
|
||||||
|
|
||||||
|
那么,为什么 HashMap 要树化呢?
|
||||||
|
|
||||||
|
本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能。
|
||||||
|
|
||||||
|
而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。
|
||||||
|
|
||||||
|
今天我从 Map 相关的几种实现对比,对各种 Map 进行了分析,讲解了有序集合类型容易混淆的地方,并从源码级别分析了 HashMap 的基本结构,希望对你有所帮助。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,解决哈希冲突有哪些典型方法呢?
|
||||||
|
|
||||||
|
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,314 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
10 如何保证集合是线程安全的 ConcurrentHashMap如何实现高效地线程安全?-极客时间
|
||||||
|
我在之前两讲介绍了 Java 集合框架的典型容器类,它们绝大部分都不是线程安全的,仅有的线程安全实现,比如 Vector、Stack,在性能方面也远不尽如人意。幸好 Java 语言提供了并发包(java.util.concurrent),为高度并发需求提供了更加全面的工具支持。
|
||||||
|
|
||||||
|
今天我要问你的问题是,如何保证容器是线程安全的?ConcurrentHashMap 如何实现高效地线程安全?
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
Java 提供了不同层面的线程安全支持。在传统集合框架内部,除了 Hashtable 等同步容器,还提供了所谓的同步包装器(Synchronized Wrapper),我们可以调用 Collections 工具类提供的包装方法,来获取一个同步的包装容器(如 Collections.synchronizedMap),但是它们都是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。
|
||||||
|
|
||||||
|
另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了:
|
||||||
|
|
||||||
|
|
||||||
|
各种并发容器,比如 ConcurrentHashMap、CopyOnWriteArrayList。
|
||||||
|
各种线程安全队列(Queue/Deque),如 ArrayBlockingQueue、SynchronousQueue。
|
||||||
|
各种有序容器的线程安全版本等。
|
||||||
|
|
||||||
|
|
||||||
|
具体保证线程安全的方式,包括有从简单的 synchronize 方式,到基于更加精细化的,比如基于分离锁实现的 ConcurrentHashMap 等并发实现等。具体选择要看开发的场景需求,总体来说,并发包内提供的容器通用场景,远优于早期的简单同步实现。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
谈到线程安全和并发,可以说是 Java 面试中必考的考点,我上面给出的回答是一个相对宽泛的总结,而且 ConcurrentHashMap 等并发容器实现也在不断演进,不能一概而论。
|
||||||
|
|
||||||
|
如果要深入思考并回答这个问题及其扩展方面,至少需要:
|
||||||
|
|
||||||
|
|
||||||
|
理解基本的线程安全工具。
|
||||||
|
理解传统集合框架并发编程中 Map 存在的问题,清楚简单同步方式的不足。
|
||||||
|
梳理并发包内,尤其是 ConcurrentHashMap 采取了哪些方法来提高并发表现。
|
||||||
|
最好能够掌握 ConcurrentHashMap 自身的演进,目前的很多分析资料还是基于其早期版本。
|
||||||
|
|
||||||
|
|
||||||
|
今天我主要是延续专栏之前两讲的内容,重点解读经常被同时考察的 HashMap 和 ConcurrentHashMap。今天这一讲并不是对并发方面的全面梳理,毕竟这也不是专栏一讲可以介绍完整的,算是个开胃菜吧,类似 CAS 等更加底层的机制,后面会在 Java 进阶模块中的并发主题有更加系统的介绍。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
|
||||||
|
为什么需要 ConcurrentHashMap?
|
||||||
|
|
||||||
|
|
||||||
|
Hashtable 本身比较低效,因为它的实现基本就是将 put、get、size 等各种方法加上“synchronized”。简单来说,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。
|
||||||
|
|
||||||
|
前面已经提过 HashMap 不是线程安全的,并发情况会导致类似 CPU 占用 100% 等一些问题,那么能不能利用 Collections 提供的同步包装器来解决问题呢?
|
||||||
|
|
||||||
|
看看下面的代码片段,我们发现同步包装器只是利用输入 Map 构造了另一个同步版本,所有操作虽然不再声明成为 synchronized 方法,但是还是利用了“this”作为互斥的 mutex,没有真正意义上的改进!
|
||||||
|
|
||||||
|
private static class SynchronizedMap<K,V>
|
||||||
|
implements Map<K,V>, Serializable {
|
||||||
|
private final Map<K,V> m; // Backing Map
|
||||||
|
final Object mutex; // Object on which to synchronize
|
||||||
|
// …
|
||||||
|
public int size() {
|
||||||
|
synchronized (mutex) {return m.size();}
|
||||||
|
}
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以,Hashtable 或者同步包装版本,都只是适合在非高度并发的场景下。
|
||||||
|
|
||||||
|
|
||||||
|
ConcurrentHashMap 分析
|
||||||
|
|
||||||
|
|
||||||
|
我们再来看看 ConcurrentHashMap 是如何设计实现的,为什么它能大大提高并发效率。
|
||||||
|
|
||||||
|
首先,我这里强调,ConcurrentHashMap 的设计实现其实一直在演化,比如在 Java 8 中就发生了非常大的变化(Java 7 其实也有不少更新),所以,我这里将比较分析结构、实现机制等方面,对比不同版本的主要区别。
|
||||||
|
|
||||||
|
早期 ConcurrentHashMap,其实现是基于:
|
||||||
|
|
||||||
|
|
||||||
|
分离锁,也就是将内部进行分段(Segment),里面则是 HashEntry 的数组,和 HashMap 类似,哈希相同的条目也是以链表形式存放。
|
||||||
|
HashEntry 内部使用 volatile 的 value 字段来保证可见性,也利用了不可变对象的机制以改进利用 Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。
|
||||||
|
|
||||||
|
|
||||||
|
你可以参考下面这个早期 ConcurrentHashMap 内部结构的示意图,其核心是利用分段设计,在进行并发操作的时候,只需要锁定相应段,这样就有效避免了类似 Hashtable 整体同步的问题,大大提高了性能。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
在构造的时候,Segment 的数量由所谓的 concurrencyLevel 决定,默认是 16,也可以在相应构造函数直接指定。注意,Java 需要它是 2 的幂数值,如果输入是类似 15 这种非幂值,会被自动调整到 16 之类 2 的幂数值。
|
||||||
|
|
||||||
|
具体情况,我们一起看看一些 Map 基本操作的源码,这是 JDK 7 比较新的 get 代码。针对具体的优化部分,为方便理解,我直接注释在代码段里,get 操作需要保证的是可见性,所以并没有什么同步逻辑。
|
||||||
|
|
||||||
|
public V get(Object key) {
|
||||||
|
Segment<K,V> s; // manually integrate access methods to reduce overhead
|
||||||
|
HashEntry<K,V>[] tab;
|
||||||
|
int h = hash(key.hashCode());
|
||||||
|
//利用位操作替换普通数学运算
|
||||||
|
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
|
||||||
|
// 以Segment为单位,进行定位
|
||||||
|
// 利用Unsafe直接进行volatile access
|
||||||
|
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
|
||||||
|
(tab = s.table) != null) {
|
||||||
|
//省略
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
而对于 put 操作,首先是通过二次哈希避免哈希冲突,然后以 Unsafe 调用方式,直接获取相应的 Segment,然后进行线程安全的 put 操作:
|
||||||
|
|
||||||
|
public V put(K key, V value) {
|
||||||
|
Segment<K,V> s;
|
||||||
|
if (value == null)
|
||||||
|
throw new NullPointerException();
|
||||||
|
// 二次哈希,以保证数据的分散性,避免哈希冲突
|
||||||
|
int hash = hash(key.hashCode());
|
||||||
|
int j = (hash >>> segmentShift) & segmentMask;
|
||||||
|
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
|
||||||
|
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
|
||||||
|
s = ensureSegment(j);
|
||||||
|
return s.put(key, hash, value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
其核心逻辑实现在下面的内部方法中:
|
||||||
|
|
||||||
|
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
|
||||||
|
// scanAndLockForPut会去查找是否有key相同Node
|
||||||
|
// 无论如何,确保获取锁
|
||||||
|
HashEntry<K,V> node = tryLock() ? null :
|
||||||
|
scanAndLockForPut(key, hash, value);
|
||||||
|
V oldValue;
|
||||||
|
try {
|
||||||
|
HashEntry<K,V>[] tab = table;
|
||||||
|
int index = (tab.length - 1) & hash;
|
||||||
|
HashEntry<K,V> first = entryAt(tab, index);
|
||||||
|
for (HashEntry<K,V> e = first;;) {
|
||||||
|
if (e != null) {
|
||||||
|
K k;
|
||||||
|
// 更新已有value...
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 放置HashEntry到特定位置,如果超过阈值,进行rehash
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
return oldValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
所以,从上面的源码清晰的看出,在进行并发写操作时:
|
||||||
|
|
||||||
|
|
||||||
|
ConcurrentHashMap 会获取再入锁,以保证数据一致性,Segment 本身就是基于 ReentrantLock 的扩展实现,所以,在并发修改期间,相应 Segment 是被锁定的。
|
||||||
|
在最初阶段,进行重复性的扫描,以确定相应 key 值是否已经在数组里面,进而决定是更新还是放置操作,你可以在代码里看到相应的注释。重复扫描、检测冲突是 ConcurrentHashMap 的常见技巧。
|
||||||
|
我在专栏上一讲介绍 HashMap 时,提到了可能发生的扩容问题,在 ConcurrentHashMap 中同样存在。不过有一个明显区别,就是它进行的不是整体的扩容,而是单独对 Segment 进行扩容,细节就不介绍了。
|
||||||
|
|
||||||
|
|
||||||
|
另外一个 Map 的 size 方法同样需要关注,它的实现涉及分离锁的一个副作用。
|
||||||
|
|
||||||
|
试想,如果不进行同步,简单的计算所有 Segment 的总值,可能会因为并发 put,导致结果不准确,但是直接锁定所有 Segment 进行计算,就会变得非常昂贵。其实,分离锁也限制了 Map 的初始化等操作。
|
||||||
|
|
||||||
|
所以,ConcurrentHashMap 的实现是通过重试机制(RETRIES_BEFORE_LOCK,指定重试次数 2),来试图获得可靠值。如果没有监控到发生变化(通过对比 Segment.modCount),就直接返回,否则获取锁进行操作。
|
||||||
|
|
||||||
|
下面我来对比一下,在 Java 8 和之后的版本中,ConcurrentHashMap 发生了哪些变化呢?
|
||||||
|
|
||||||
|
|
||||||
|
总体结构上,它的内部存储变得和我在专栏上一讲介绍的 HashMap 结构非常相似,同样是大的桶(bucket)数组,然后内部也是一个个所谓的链表结构(bin),同步的粒度要更细致一些。
|
||||||
|
其内部仍然有 Segment 定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构上的用处。
|
||||||
|
因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。
|
||||||
|
数据存储利用 volatile 来保证可见性。
|
||||||
|
使用 CAS 等操作,在特定场景进行无锁并发操作。
|
||||||
|
使用 Unsafe、LongAdder 之类底层手段,进行极端情况的优化。
|
||||||
|
|
||||||
|
|
||||||
|
先看看现在的数据存储内部实现,我们可以发现 Key 是 final 的,因为在生命周期中,一个条目的 Key 发生变化是不可能的;与此同时 val,则声明为 volatile,以保证可见性。
|
||||||
|
|
||||||
|
static class Node<K,V> implements Map.Entry<K,V> {
|
||||||
|
final int hash;
|
||||||
|
final K key;
|
||||||
|
volatile V val;
|
||||||
|
volatile Node<K,V> next;
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
我这里就不再介绍 get 方法和构造函数了,相对比较简单,直接看并发的 put 是如何实现的。
|
||||||
|
|
||||||
|
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
|
||||||
|
int hash = spread(key.hashCode());
|
||||||
|
int binCount = 0;
|
||||||
|
for (Node<K,V>[] tab = table;;) {
|
||||||
|
Node<K,V> f; int n, i, fh; K fk; V fv;
|
||||||
|
if (tab == null || (n = tab.length) == 0)
|
||||||
|
tab = initTable();
|
||||||
|
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
|
||||||
|
// 利用CAS去进行无锁线程安全操作,如果bin是空的
|
||||||
|
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if ((fh = f.hash) == MOVED)
|
||||||
|
tab = helpTransfer(tab, f);
|
||||||
|
else if (onlyIfAbsent // 不加锁,进行检查
|
||||||
|
&& fh == hash
|
||||||
|
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
|
||||||
|
&& (fv = f.val) != null)
|
||||||
|
return fv;
|
||||||
|
else {
|
||||||
|
V oldVal = null;
|
||||||
|
synchronized (f) {
|
||||||
|
// 细粒度的同步修改操作...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bin超过阈值,进行树化
|
||||||
|
if (binCount != 0) {
|
||||||
|
if (binCount >= TREEIFY_THRESHOLD)
|
||||||
|
treeifyBin(tab, i);
|
||||||
|
if (oldVal != null)
|
||||||
|
return oldVal;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addCount(1L, binCount);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
初始化操作实现在 initTable 里面,这是一个典型的 CAS 使用场景,利用 volatile 的 sizeCtl 作为互斥手段:如果发现竞争性的初始化,就 spin 在那里,等待条件恢复;否则利用 CAS 设置排他标志。如果成功则进行初始化;否则重试。
|
||||||
|
|
||||||
|
请参考下面代码:
|
||||||
|
|
||||||
|
private final Node<K,V>[] initTable() {
|
||||||
|
Node<K,V>[] tab; int sc;
|
||||||
|
while ((tab = table) == null || tab.length == 0) {
|
||||||
|
// 如果发现冲突,进行spin等待
|
||||||
|
if ((sc = sizeCtl) < 0)
|
||||||
|
Thread.yield();
|
||||||
|
// CAS成功返回true,则进入真正的初始化逻辑
|
||||||
|
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
|
||||||
|
try {
|
||||||
|
if ((tab = table) == null || tab.length == 0) {
|
||||||
|
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
|
||||||
|
table = tab = nt;
|
||||||
|
sc = n - (n >>> 2);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sizeCtl = sc;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
当 bin 为空时,同样是没有必要锁定,也是以 CAS 操作去放置。
|
||||||
|
|
||||||
|
你有没有注意到,在同步逻辑上,它使用的是 synchronized,而不是通常建议的 ReentrantLock 之类,这是为什么呢?现代 JDK 中,synchronized 已经被不断优化,可以不再过分担心性能差异,另外,相比于 ReentrantLock,它可以减少内存消耗,这是个非常大的优势。
|
||||||
|
|
||||||
|
与此同时,更多细节实现通过使用 Unsafe 进行了优化,例如 tabAt 就是直接利用 getObjectAcquire,避免间接调用的开销。
|
||||||
|
|
||||||
|
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
|
||||||
|
return (Node<K,V>)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
再看看,现在是如何实现 size 操作的。阅读代码你会发现,真正的逻辑是在 sumCount 方法中, 那么 sumCount 做了什么呢?
|
||||||
|
|
||||||
|
final long sumCount() {
|
||||||
|
CounterCell[] as = counterCells; CounterCell a;
|
||||||
|
long sum = baseCount;
|
||||||
|
if (as != null) {
|
||||||
|
for (int i = 0; i < as.length; ++i) {
|
||||||
|
if ((a = as[i]) != null)
|
||||||
|
sum += a.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
我们发现,虽然思路仍然和以前类似,都是分而治之的进行计数,然后求和处理,但实现却基于一个奇怪的 CounterCell。 难道它的数值,就更加准确吗?数据一致性是怎么保证的?
|
||||||
|
|
||||||
|
static final class CounterCell {
|
||||||
|
volatile long value;
|
||||||
|
CounterCell(long x) { value = x; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
其实,对于 CounterCell 的操作,是基于 java.util.concurrent.atomic.LongAdder 进行的,是一种 JVM 利用空间换取更高效率的方法,利用了Striped64内部的复杂逻辑。这个东西非常小众,大多数情况下,建议还是使用 AtomicLong,足以满足绝大部分应用的性能需求。
|
||||||
|
|
||||||
|
今天我从线程安全问题开始,概念性的总结了基本容器工具,分析了早期同步容器的问题,进而分析了 Java 7 和 Java 8 中 ConcurrentHashMap 是如何设计实现的,希望 ConcurrentHashMap 的并发技巧对你在日常开发可以有所帮助。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?留一个道思考题给你,在产品代码中,有没有典型的场景需要使用类似 ConcurrentHashMap 这样的并发容器呢?
|
||||||
|
|
||||||
|
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
271
专栏/Java核心技术面试精讲/11Java提供了哪些IO方式?NIO如何实现多路复用?-极客时间.md
Normal file
271
专栏/Java核心技术面试精讲/11Java提供了哪些IO方式?NIO如何实现多路复用?-极客时间.md
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
|
||||||
|
|
||||||
|
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||||
|
|
||||||
|
|
||||||
|
11 Java提供了哪些IO方式? NIO如何实现多路复用?-极客时间
|
||||||
|
IO 一直是软件开发中的核心部分之一,伴随着海量数据增长和分布式系统的发展,IO 扩展能力愈发重要。幸运的是,Java 平台 IO 机制经过不断完善,虽然在某些方面仍有不足,但已经在实践中证明了其构建高扩展性应用的能力。
|
||||||
|
|
||||||
|
今天我要问你的问题是,Java 提供了哪些 IO 方式? NIO 如何实现多路复用?
|
||||||
|
|
||||||
|
典型回答
|
||||||
|
|
||||||
|
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。
|
||||||
|
|
||||||
|
第一,传统的 java.io 包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。
|
||||||
|
|
||||||
|
java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。
|
||||||
|
|
||||||
|
很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
|
||||||
|
|
||||||
|
第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
|
||||||
|
|
||||||
|
第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
|
||||||
|
|
||||||
|
考点分析
|
||||||
|
|
||||||
|
我上面列出的回答是基于一种常见分类方式,即所谓的 BIO、NIO、NIO 2(AIO)。
|
||||||
|
|
||||||
|
在实际面试中,从传统 IO 到 NIO、NIO 2,其中有很多地方可以扩展开来,考察点涉及方方面面,比如:
|
||||||
|
|
||||||
|
|
||||||
|
基础 API 功能与设计, InputStream/OutputStream 和 Reader/Writer 的关系和区别。
|
||||||
|
NIO、NIO 2 的基本组成。
|
||||||
|
给定场景,分别用不同模型实现,分析 BIO、NIO 等模式的设计和实现原理。
|
||||||
|
NIO 提供的高性能数据操作方式是基于什么原理,如何使用?
|
||||||
|
或者,从开发者的角度来看,你觉得 NIO 自身实现存在哪些问题?有什么改进的想法吗?
|
||||||
|
|
||||||
|
|
||||||
|
IO 的内容比较多,专栏一讲很难能够说清楚。IO 不仅仅是多路复用,NIO 2 也不仅仅是异步 IO,尤其是数据操作部分,会在专栏下一讲详细分析。
|
||||||
|
|
||||||
|
知识扩展
|
||||||
|
|
||||||
|
首先,需要澄清一些基本概念:
|
||||||
|
|
||||||
|
|
||||||
|
区分同步或异步(synchronous/asynchronous)。简单来说,同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系。
|
||||||
|
区分阻塞与非阻塞(blocking/non-blocking)。在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如 ServerSocket 新连接建立完毕,或数据读取、写入操作完成;而非阻塞则是不管 IO 操作是否结束,直接返回,相应操作在后台继续处理。
|
||||||
|
|
||||||
|
|
||||||
|
不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。
|
||||||
|
|
||||||
|
对于 java.io,我们都非常熟悉,我这里就从总体上进行一下总结,如果需要学习更加具体的操作,你可以通过教程等途径完成。总体上,我认为你至少需要理解一下内容。
|
||||||
|
|
||||||
|
|
||||||
|
IO 不仅仅是对文件的操作,网络编程中,比如 Socket 通信,都是典型的 IO 操作目标。
|
||||||
|
输入流、输出流(InputStream/OutputStream)是用于读取或写入字节的,例如操作图片文件。
|
||||||
|
而 Reader/Writer 则是用于操作字符,增加了字符编解码等功能,适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节,不管是网络通信还是文件读取,Reader/Writer 相当于构建了应用逻辑和原始数据之间的桥梁。
|
||||||
|
BufferedOutputStream 等带缓冲区的实现,可以避免频繁的磁盘读写,进而提高 IO 处理效率。这种设计利用了缓冲区,将批量数据进行一次操作,但在使用中千万别忘了 flush。
|
||||||
|
参考下面这张类图,很多 IO 工具类都实现了 Closeable 接口,因为需要进行资源的释放。比如,打开 FileInputStream,它就会获取相应的文件描述符(FileDescriptor),需要利用 try-with-resources、 try-finally 等机制保证 FileInputStream 被明确关闭,进而相应文件描述符也会失效,否则将导致资源无法被释放。利用专栏前面的内容提到的 Cleaner 或 finalize 机制作为资源释放的最后把关,也是必要的。
|
||||||
|
|
||||||
|
|
||||||
|
下面是我整理的一个简化版的类图,阐述了日常开发应用较多的类型和结构关系。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Java NIO 概览
|
||||||
|
|
||||||
|
|
||||||
|
首先,熟悉一下 NIO 的主要组成部分:
|
||||||
|
|
||||||
|
|
||||||
|
Buffer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的 Buffer 实现。
|
||||||
|
Channel,类似在 Linux 之类操作系统上看到的文件描述符,是 NIO 中被用来支持批量式 IO 操作的一种抽象。
|
||||||
|
|
||||||
|
|
||||||
|
File 或者 Socket,通常被认为是比较高层次的抽象,而 Channel 则是更加操作系统底层的一种抽象,这也使得 NIO 得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct Memory Access)等。不同层次的抽象是相互关联的,我们可以通过 Socket 获取 Channel,反之亦然。
|
||||||
|
|
||||||
|
|
||||||
|
Selector,是 NIO 实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态,进而实现了单线程对多 Channel 的高效管理。Selector 同样是基于底层操作系统机制,不同模式、不同版本都存在区别,例如,在最新的代码库里,相关实现如下:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Linux 上依赖于epoll,Windows 上 NIO2(AIO)模式则是依赖于iocp。
|
||||||
|
|
||||||
|
|
||||||
|
Charset,提供 Unicode 字符串定义,NIO 也提供了相应的编解码器等,例如,通过下面的方式进行字符串到 ByteBuffer 的转换:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Charset.defaultCharset().encode(“Hello world!”));
|
||||||
|
|
||||||
|
|
||||||
|
NIO 能解决什么问题?
|
||||||
|
|
||||||
|
|
||||||
|
下面我通过一个典型场景,来分析为什么需要 NIO,为什么需要多路复用。设想,我们需要实现一个服务器应用,只简单要求能够同时服务多个客户端请求即可。
|
||||||
|
|
||||||
|
使用 java.io 和 java.net 中的同步、阻塞式 API,可以简单实现。
|
||||||
|
|
||||||
|
public class DemoServer extends Thread {
|
||||||
|
private ServerSocket serverSocket;
|
||||||
|
public int getPort() {
|
||||||
|
return serverSocket.getLocalPort();
|
||||||
|
}
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
serverSocket = new ServerSocket(0);
|
||||||
|
while (true) {
|
||||||
|
Socket socket = serverSocket.accept();
|
||||||
|
RequestHandler requestHandler = new RequestHandler(socket);
|
||||||
|
requestHandler.start();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
if (serverSocket != null) {
|
||||||
|
try {
|
||||||
|
serverSocket.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
DemoServer server = new DemoServer();
|
||||||
|
server.start();
|
||||||
|
try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) {
|
||||||
|
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream()));
|
||||||
|
bufferedReader.lines().forEach(s -> System.out.println(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 简化实现,不做读取,直接发送字符串
|
||||||
|
class RequestHandler extends Thread {
|
||||||
|
private Socket socket;
|
||||||
|
RequestHandler(Socket socket) {
|
||||||
|
this.socket = socket;
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {
|
||||||
|
out.println("Hello world!");
|
||||||
|
out.flush();
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
其实现要点是:
|
||||||
|
|
||||||
|
|
||||||
|
服务器端启动 ServerSocket,端口 0 表示自动绑定一个空闲端口。
|
||||||
|
调用 accept 方法,阻塞等待客户端连接。
|
||||||
|
利用 Socket 模拟了一个简单的客户端,只进行连接、读取、打印。
|
||||||
|
当连接建立后,启动一个单独线程负责回复客户端请求。
|
||||||
|
|
||||||
|
|
||||||
|
这样,一个简单的 Socket 服务器就被实现出来了。
|
||||||
|
|
||||||
|
思考一下,这个解决方案在扩展性方面,可能存在什么潜在问题呢?
|
||||||
|
|
||||||
|
大家知道 Java 语言目前的线程实现是比较重量级的,启动或者销毁一个线程是有明显开销的,每个线程都有单独的线程栈等结构,需要占用非常明显的内存,所以,每一个 Client 启动一个线程似乎都有些浪费。
|
||||||
|
|
||||||
|
那么,稍微修正一下这个问题,我们引入线程池机制来避免浪费。
|
||||||
|
|
||||||
|
serverSocket = new ServerSocket(0);
|
||||||
|
executor = Executors.newFixedThreadPool(8);
|
||||||
|
while (true) {
|
||||||
|
Socket socket = serverSocket.accept();
|
||||||
|
RequestHandler requestHandler = new RequestHandler(socket);
|
||||||
|
executor.execute(requestHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
这样做似乎好了很多,通过一个固定大小的线程池,来负责管理工作线程,避免频繁创建、销毁线程的开销,这是我们构建并发服务的典型方式。这种工作方式,可以参考下图来理解。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
如果连接数并不是非常多,只有最多几百个连接的普通应用,这种模式往往可以工作的很好。但是,如果连接数量急剧上升,这种实现方式就无法很好地工作了,因为线程上下文切换开销会在高并发时变得很明显,这是同步阻塞方式的低扩展性劣势。
|
||||||
|
|
||||||
|
NIO 引入的多路复用机制,提供了另外一种思路,请参考我下面提供的新的版本。
|
||||||
|
|
||||||
|
public class NIOServer extends Thread {
|
||||||
|
public void run() {
|
||||||
|
try (Selector selector = Selector.open();
|
||||||
|
ServerSocketChannel serverSocket = ServerSocketChannel.open();) {// 创建Selector和Channel
|
||||||
|
serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
|
||||||
|
serverSocket.configureBlocking(false);
|
||||||
|
// 注册到Selector,并说明关注点
|
||||||
|
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
|
||||||
|
while (true) {
|
||||||
|
selector.select();// 阻塞等待就绪的Channel,这是关键点之一
|
||||||
|
Set<SelectionKey> selectedKeys = selector.selectedKeys();
|
||||||
|
Iterator<SelectionKey> iter = selectedKeys.iterator();
|
||||||
|
while (iter.hasNext()) {
|
||||||
|
SelectionKey key = iter.next();
|
||||||
|
// 生产系统中一般会额外进行就绪状态检查
|
||||||
|
sayHelloWorld((ServerSocketChannel) key.channel());
|
||||||
|
iter.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void sayHelloWorld(ServerSocketChannel server) throws IOException {
|
||||||
|
try (SocketChannel client = server.accept();) { client.write(Charset.defaultCharset().encode("Hello world!"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 省略了与前面类似的main
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
这个非常精简的样例掀开了 NIO 多路复用的面纱,我们可以分析下主要步骤和元素:
|
||||||
|
|
||||||
|
|
||||||
|
首先,通过 Selector.open() 创建一个 Selector,作为类似调度员的角色。
|
||||||
|
然后,创建一个 ServerSocketChannel,并且向 Selector 注册,通过指定 SelectionKey.OP_ACCEPT,告诉调度员,它关注的是新的连接请求。
|
||||||
|
|
||||||
|
|
||||||
|
注意,为什么我们要明确配置非阻塞模式呢?这是因为阻塞模式下,注册操作是不允许的,会抛出 IllegalBlockingModeException 异常。
|
||||||
|
|
||||||
|
|
||||||
|
Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒。
|
||||||
|
在 sayHelloWorld 方法中,通过 SocketChannel 和 Buffer 进行数据操作,在本例中是发送了一段字符串。
|
||||||
|
|
||||||
|
|
||||||
|
可以看到,在前面两个样例中,IO 都是同步阻塞模式,所以需要多线程以实现多任务处理。而 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。下面这张图对这种实现思路进行了形象地说明。
|
||||||
|
|
||||||
|
在 Java 7 引入的 NIO 2 中,又增添了一种额外的异步 IO 模式,利用事件和回调,处理 Accept、Read 等操作。 AIO 实现看起来是类似这样子:
|
||||||
|
|
||||||
|
AsynchronousServerSocketChannel serverSock = AsynchronousServerSocketChannel.open().bind(sockAddr);
|
||||||
|
serverSock.accept(serverSock, new CompletionHandler<>() { //为异步操作指定CompletionHandler回调函数
|
||||||
|
@Override
|
||||||
|
public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {
|
||||||
|
serverSock.accept(serverSock, this);
|
||||||
|
// 另外一个 write(sock,CompletionHandler{})
|
||||||
|
sayHelloWorld(sockChannel, Charset.defaultCharset().encode
|
||||||
|
("Hello World!"));
|
||||||
|
}
|
||||||
|
// 省略其他路径处理方法...
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
鉴于其编程要素(如 Future、CompletionHandler 等),我们还没有进行准备工作,为避免理解困难,我会在专栏后面相关概念补充后的再进行介绍,尤其是 Reactor、Proactor 模式等方面将在 Netty 主题一起分析,这里我先进行概念性的对比:
|
||||||
|
|
||||||
|
|
||||||
|
基本抽象很相似,AsynchronousServerSocketChannel 对应于上面例子中的 ServerSocketChannel;AsynchronousSocketChannel 则对应 SocketChannel。
|
||||||
|
业务逻辑的关键在于,通过指定 CompletionHandler 回调接口,在 accept/read/write 等关键节点,通过事件机制调用,这是非常不同的一种编程思路。
|
||||||
|
|
||||||
|
|
||||||
|
今天我初步对 Java 提供的 IO 机制进行了介绍,概要地分析了传统同步 IO 和 NIO 的主要组成,并根据典型场景,通过不同的 IO 模式进行了实现与拆解。专栏下一讲,我还将继续分析 Java IO 的主题。
|
||||||
|
|
||||||
|
一课一练
|
||||||
|
|
||||||
|
关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,NIO 多路复用的局限性是什么呢?你遇到过相关的问题吗?
|
||||||
|
|
||||||
|
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。
|
||||||
|
|
||||||
|
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user