diff --git a/专栏/Java核心技术面试精讲/07int和Integer有什么区别?-极客时间.md b/专栏/Java核心技术面试精讲/07int和Integer有什么区别?-极客时间.md new file mode 100644 index 0000000..452e3ea --- /dev/null +++ b/专栏/Java核心技术面试精讲/07int和Integer有什么区别?-极客时间.md @@ -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 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 对象的大小? + +请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。 + +你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。 + + + + \ No newline at end of file diff --git a/专栏/Java核心技术面试精讲/08对比Vector、ArrayList、LinkedList有何区别?-极客时间.md b/专栏/Java核心技术面试精讲/08对比Vector、ArrayList、LinkedList有何区别?-极客时间.md new file mode 100644 index 0000000..57ddfb6 --- /dev/null +++ b/专栏/Java核心技术面试精讲/08对比Vector、ArrayList、LinkedList有何区别?-极客时间.md @@ -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 List synchronizedList(List 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 list = new ArrayList<>(); +list.add("Hello"); +list.add("World"); + + +而利用新的容器静态工厂方法,一句代码就够了,并且保证了不可变性。 + +List simpleList = List.of("Hello","world"); + + +更进一步,通过各种 of 静态工厂方法创建的实例,还应用了一些我们所谓的最佳实践,比如,它是不可变的,符合我们对线程安全的需求;它因为不需要考虑扩容,所以空间上更加紧凑等。 + +如果我们去看 of 方法的源码,你还会发现一个特别有意思的地方:我们知道 Java 已经支持所谓的可变参数(varargs),但是官方类库还是提供了一系列特定参数长度的方法,看起来似乎非常不优雅,为什么呢?这其实是为了最优的性能,JVM 在处理变长参数的时候会有明显的额外开销,如果你需要实现性能敏感的 API,也可以进行参考。 + +今天我从 Verctor、ArrayList、LinkedList 开始,逐步分析其设计实现区别、适合的应用场景等,并进一步对集合框架进行了简单的归纳,介绍了集合框架从基础算法到 API 设计实现的各种改进,希望能对你的日常开发和 API 设计能够有帮助。 + +一课一练 + +关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,先思考一个应用场景,比如你需要实现一个云计算任务调度系统,希望可以保证 VIP 客户的任务被优先处理,你可以利用哪些数据结构或者标准的集合类型呢?更进一步讲,类似场景大多是基于什么数据结构呢? + +请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。 + +你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。 + + + + \ No newline at end of file diff --git a/专栏/Java核心技术面试精讲/09对比Hashtable、HashMap、TreeMap有什么不同?-极客时间.md b/专栏/Java核心技术面试精讲/09对比Hashtable、HashMap、TreeMap有什么不同?-极客时间.md new file mode 100644 index 0000000..cfd630a --- /dev/null +++ b/专栏/Java核心技术面试精讲/09对比Hashtable、HashMap、TreeMap有什么不同?-极客时间.md @@ -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 accessOrderedMap = new LinkedHashMap(16, 0.75F, true){ + @Override + protected boolean removeEldestEntry(Map.Entry 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 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[] tab; Node 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[] 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[] newTab = (Node[])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[] tab, int hash) { + int n, index; Node 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 的基本结构,希望对你有所帮助。 + +一课一练 + +关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,解决哈希冲突有哪些典型方法呢? + +请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。 + +你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。 + + + + \ No newline at end of file diff --git a/专栏/Java核心技术面试精讲/10如何保证集合是线程安全的ConcurrentHashMap如何实现高效地线程安全?-极客时间.md b/专栏/Java核心技术面试精讲/10如何保证集合是线程安全的ConcurrentHashMap如何实现高效地线程安全?-极客时间.md new file mode 100644 index 0000000..9dd4541 --- /dev/null +++ b/专栏/Java核心技术面试精讲/10如何保证集合是线程安全的ConcurrentHashMap如何实现高效地线程安全?-极客时间.md @@ -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 + implements Map, Serializable { + private final Map 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 s; // manually integrate access methods to reduce overhead + HashEntry[] tab; + int h = hash(key.hashCode()); + //利用位操作替换普通数学运算 + long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; + // 以Segment为单位,进行定位 + // 利用Unsafe直接进行volatile access + if ((s = (Segment)UNSAFE.getObjectVolatile(segments, u)) != null && + (tab = s.table) != null) { + //省略 + } + return null; + } + + +而对于 put 操作,首先是通过二次哈希避免哈希冲突,然后以 Unsafe 调用方式,直接获取相应的 Segment,然后进行线程安全的 put 操作: + + public V put(K key, V value) { + Segment s; + if (value == null) + throw new NullPointerException(); + // 二次哈希,以保证数据的分散性,避免哈希冲突 + int hash = hash(key.hashCode()); + int j = (hash >>> segmentShift) & segmentMask; + if ((s = (Segment)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 node = tryLock() ? null : + scanAndLockForPut(key, hash, value); + V oldValue; + try { + HashEntry[] tab = table; + int index = (tab.length - 1) & hash; + HashEntry first = entryAt(tab, index); + for (HashEntry 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 implements Map.Entry { + final int hash; + final K key; + volatile V val; + volatile Node 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[] tab = table;;) { + Node 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(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[] initTable() { + Node[] 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[] nt = (Node[])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 Node tabAt(Node[] tab, int i) { + return (Node)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 这样的并发容器呢? + +请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。 + +你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。 + +‍ + + + + \ No newline at end of file diff --git a/专栏/Java核心技术面试精讲/11Java提供了哪些IO方式?NIO如何实现多路复用?-极客时间.md b/专栏/Java核心技术面试精讲/11Java提供了哪些IO方式?NIO如何实现多路复用?-极客时间.md new file mode 100644 index 0000000..b590daf --- /dev/null +++ b/专栏/Java核心技术面试精讲/11Java提供了哪些IO方式?NIO如何实现多路复用?-极客时间.md @@ -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 selectedKeys = selector.selectedKeys(); + Iterator 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 多路复用的局限性是什么呢?你遇到过相关的问题吗? + +请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。 + +你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。 + + + + \ No newline at end of file