深入JDK
JDK 是 Java 规范的实现,Java 程序在运行期间除了 JVM 的因素外,JDK 也是另外一个重要的影响因素, Java 规范中定义了众多的接口 规范,但实现则取决于各个 Java 规范实现的厂商, 例如 Sun、 IBM、 Bea, 其在实现 Java 规范时采用的方法并不一定相同,在编写 Java程序时,由于Java提供了众多看似功能一样的类,如何选择合适的类来实现需求就成了一个难题了,这也是需要深入理解JDK的原因,以做到根据需求来选择合适的类,而不是想当然的认为调用某个类的某个方法时就会达到预期的效果,否则很有可能因为对 JDK 的实现不了解而导致最终程序运行的效果和预期不一致。
本章基于 JDK 6 Update 12 的代码对 Sun JDK 常用 package 中的常用类进行分析,分析的方法为掌握常用的类中的方法的实现原理, 并评估类中的常用方法在不同场景下的性能表现,在掌握了这些知识点后, 对于合理的根据需求选择类会有很大的帮助。
JDK 分为了几个大的包, 对于构建大型分布式 Java 应用而言, 其中最需要掌握的有集合、并发、 线程、 网络( 包括 BIO 以及 NIO) 以及序列化, 关于线程在第三章中已经做了深入的分析, 而网络包则遵循通信协议和操作系统实现方式而实现, 因此其表现更多的取决于通信
协议和操作系统的实现方式, 这方面知识还请大家自行查看 TCP/IP、 UDP/IP、 Multicast、 Http等协议方面的书籍, 以及操作关系关于 BIO、 NIO 或 AIO 方面的实现的分析, 在通信时的性能表现更多的取决于基于 JDK 的封装方式以及对连接的处理方式( 例如长连、 连接复用等)等, 这方面的知识需要大家自行查看 Mina、 Grizzly 等通信框架的封装, 在本章就不进行分析了 , 因此在本章中将对除线程、 网络之外的包进行分析, 在掌握了这些包的实现方式后,
对于编写高稳定和高性能的程序而言会有很大的帮助。
ArrayList
对应上面需要掌握的几点, 来看看 ArrayList 的实现方式。
ArrayList()此默认构造器通过调用 ArrayList(int)来完成 ArrayList 的创建, 传入的值为 10, ArrayList(int)
方法的实现代码为:
super(); |
调用的为 AbstractList 的默认构造器方法, 该方法为一个空方法, 因此这段代码中最关键的为实例化了一个 Object 的数组, 并将此数组赋给了当前实例的 elementData 属性,此 Object 数组的大小即为传入的initialCapacity 的值, 因此在调用空构造器的情况下会创建一个大小为 10 的 Object 数组, 根据此也可看出, ArrayList 采用的是数组的方式来存放对象。
add(E)
add 方法简单来看就是将数组中某元素的值设为指向对象的地址而已, 但在 add 时有个很明显的问题是: 如果此时数组满了, 该怎么办? 带着这些问题, 来看看 ArrayList 的实现方式。
当调用 ArrayList 的 add 方法时, 首先基于 ArrayList 中已有的元素数量加 1, 产生一个命名为 minCapacity 的变量, 然后比较此值和 Object 数组的大小, 如果此值大于 Object 数组的大小, 那么首先将当前的 Object 数组赋值给一个数组对象, 接着产生一个新的数组大小的
容量值, 此值的计算方法为当前数组大小 *1.5 并加 1, 如得出的新的容量值仍然小于minCapacity , 那 么 就以 minCapacity 作 为新 的 容量 值 , 在得 出 这个 容 量 值后 , 调用Arrays.copyOf 来生成新的数组对象。
Arrays.copyOf 的实现方法简单来说, 首先是创建一个新的数组对象, 该数组对象的类型和之前 ArrayList 中元素的类型是一致的, 在这里 JDK 做了个小优化, 如果==Object 类型, 则直接通过 new Object[newLength]的方式来创建, 如不==Object 类型, 则通过 Array.newInstance调用 native 方法来创建相应类型的数组; 在创建完新的数组对象后, 调用 System.arraycopy通过 native 方法将之前数组中的对象复制到新的数组中。
在确保了有足够的空间放入新的元素后, 并将数组的末尾的地址指向对象的地址, 例如目前数组的大小为 10, 已经有 5 个元素了 , 那么新加入的元素就自动的放在了数组的 6 的位置。
在往 Collection 中增加对象上, ArrayList 还提供了 add(int,E)这样的方法, 允许将元素直接插入到指定的 int 位置上, 这个方法的实现首先是确保需要插入的位置是目前的 Array 数组中存在的, 之后仍然是确保数组的容量是够用的, 在完成了这些动作后, 和 add(E)不同的
地方就出现了 , 它需要将当前的数组对象进行一次复制动作, 即将目前 index 及其后的数据都往后挪动一位, 然后才能将指定的 index 位置的指针指向 E 元素的地址, 可以看到这种方式下需要多付出一次复制数组的代价。
除了 add(int,E)这种方法将对象插入指定的位置外, ArrayList 还提供了 set(int,E)这样的方法来将指定位置上的对象进行替换。
为了方便开发人员的使用, ArrayList 还对外提供了 addAll(Collection<? extends E>)以及addAll(int,Collection<? extends E>)这样的方法, 其实现方式和 add(E)、 add(int,E)基本类似。
remove(E)
remove 对于集合的性能而言也非常的重要, 当执行此方法时, ArrayList 首先判断对象是否为 null, 如为 null, 则遍历数组中已有值的元素, 并比较其是否为 null, 如为 null, 则调用 fastRemove 来删除相应位置的对象。 fastRemove 方法的实现方式为将 index 后的对象往前
复制复制一位, 并将数组中的最后一个元素的值设置为 null, 即释放了对此对象的引用; 如对象非 null, 唯一的不同在于通过 E 的 equals 来比较元素的值是否相同, 如相同则认为是需要删除的对象的位置, 然后同样是调用 fastRemove 来完成对象的删除。
ArrayList 中还另外提供了 remove(int)这样的方法来删除指定位置的对象, remove(int)的实现比 remove(E)多了一个数组范围的检测, 但少了对象位置的查找, 因此性能会高不少。
get(int)
get 传入的为数组的位置, 因此 ArrayList 仅需先做数组范围的检测, 然后即可直接返回数组中位于此位置的对象。
iterator()
iterator 由 ArrayList 的父类 AbstractList 实现, 当每次调用 iterator 方法时, 都会创建一个新的 AbstractList 内部类 Itr 的实例, 当调用此实例的 hasNext 方法时, 比较当前指向的数组的位置是否和数组中已有的元素大小相等, 如相等则返回 false, 否则返回 true;
当调用实例的 next 方法时, 首先比较在创建此 Iterator 时获取的 modCount 与目前的modCount, 如这两个 modCount 不相等, 则说明在获取 next 元素时, 发生了对于集合大小产生影响( 新增、 删除)的动作, 当发生这种情况时, 则抛出ConcurrentModificationException,
如 modCount 相等, 那么则调用 get 方法来获取相应位置的元素, 当 get 获取不到抛出IndexOutOfBoundsException 时, 仍然是首先检测 modCount, 如 modCount 相等, 则抛出NoSuchElementException。
contains(E)
为了判断 E 在 ArrayList 中是否存在, ArrayList 的做法为遍历整个 ArrayList 中已有的元素,
如 E 为 null, 则直接判断已有元素是否为 null, 如为 null, 则返回 true; 如 E 不为 null, 则通过判断 E.equals 和元素是否相等, 如相等则返回 true。
indexOf 和 lastIndexOf 是 ArrayList 中用于获取对象所在位置的方法, 其中 indexOf 为从前往后寻找, 而 lastIndexOf 为从后向前寻找。
以下是根据上面提到的知识点整理的一些 ArrayList 的题目 , 可以借此来巩固下以上的知识点。
考察对于 ArrayList 抛出异常的原因的掌握
请分别编写两段程序,让其在遍历 ArrayList 中元素时抛出IndexOutOfBoundsException以及ConcurrentModificationException。
考察对于 ArrayList 扩充数组大小的方式的掌握
如 ArrayList 的大小设置为 50, 并已放入了 50 个元素, 请问当此时再调用 add(E)之后ArrayList.length 以及.size 的返回值分别是多少?
考察对于 ArrayList 判断是否包含元素的方式的掌握
如下一段程序:
List<String> list = new ArrayList<String>(); |
或者可以演变为对于 Object 是否存在的判断。
根据 ArrayList 实现的描述, 很容易解释为什么 ArrayList 是非线程安全的, 元素可为 null以及元素中可以有重复的集合对象。
在性能方面, 关注的主要是不同的集合对象在相同的场景下的性能对比状况, 场景设计上分为单线程场景和多线程场景。
在单线程下, 按照如下场景进行测试:
测试在不同的集合大小下增加、 查找以及删除元素的性能变化情况;
集合大小分别为 10、 100、 1000、 10000;
在多线程下, 按照如下场景进行测试:
测试在不同的集合大小以及不同的线程数的情况下, 增加、 查找以及删除元素的性能的变化情况;
集合大小分别为 10、 100、 1000、 10000;
线程数分别为 10、 50、 100, 每个线程所做的事即为增加元素、 查找元素和删除元素。
对于本章节中提及的集合对象, 都进行以上场景的性能测试, 每个场景均运行 10 次,取平均值, 以尽量保证测试能公平的体现集合对象的性能状况, 所有集合对象的测试均采用以下代码进行, 由于集合操作的运行速度通常很快, 因此此处采用 nanoTime 来进行统计:
public class CollectionPerformanceTest |
传入参数 1, 启动 CollectionPerformanceTest, 即为测试 ArrayList 在以上测试场景中的性能状况, 测试结果分析后图示如下。
在不同的集合大小下增加、 查找以及删除元素的性能变化情况;
横轴为集合大小, 纵轴为执行 1 次操作所消耗的时间, 单位为纳秒。
从以上图示来看, ArrayList 中的元素数量从 10 增长到 100 时, 性能变化不明显, 但从100 增长到 1000 后, 查找和删除元素的性能就有不小的下降了 , 当元素增长到 10000 个后,查找和删除元素的性能就开始大幅度下降了 , 增加元素的性能则基本没有变化。
在不同的集合大小以及不同的线程数的情况下, 增加、 查找以及删除元素的性能的变化情况;
增加元素的性能变化如下图所示:
由于在测试中采用的是多个线程在 sleep 一个 1000 的随机数后, 共同进行增加、 查找以及删除元素的动作, 因此有可能有些线程在执行增加元素时恰好碰到等到锁的现象, 导致其执行速度慢, 正是这种原因, 所以会出现上面有些时候线程少的增加元素的平均执行时间反而比线程多的长。
查找元素的性能变化如下图所示:
删除元素的性能变化如下图所示:
综合上面的测试结果来看, 在多线程的测试场景下, 基本也遵循单线程的状况, 增加元素的性能随集合增长变化不大, 只有当线程数增长到 100 后才会大幅度下降, 查找元素和删除元素的性能则在集合从 100 增长到 1000 后有较明显的下降, 同时集合数了 1000 后, 基本上会随着线程数越多性能下降的越明显, 但多线程中的任何操作与单线程对比而言性能都有较大幅度的下降。
ConcurrentHashMap
ConcurrentHashMap 是线程安全的 HashMap 的实现, 来具体看看其实现方法。
ConcurrentHashMap()
和 HashMap 一样, 它同样有 initialCapacity 以及 loadFactor 属性, 不过还多了一个concurrencyLevel 属性, 在调用空构造器的情况下, 这个三个属性的值分别设置为了 16、 0.75以及 16。
在设置了以上三个属性值后, 基于以下方式计算 ssize 值:
int sshift = 0; |
当concurrencyLevel 为 16 的情况下, 最终计算出的 ssize 为 32, 并使用此 ssize 作为参数传入 Segment 的 newArray 方法, 创建大小为 32 的 Segment 对象数组, 接着采用如下方
法计算 cap 变量的值:
int c = initialCapacity / ssize; |
根据以上参数值, 计算出的 cap 为 2, 最后为 Segment 对象数组创建 Segment 对象, 传入的参数为 cap 和 loadFactor, Segment 对象继承 ReentrantLock, 在创建 Segment 对象时,其所做的动作为创建一个指定大小为 cap 的 HashEntry 对象数组, 并基于数组的大小以及
loadFactor 计算 threshold 的值: threshold = (int)(newTable.length * loadFactor);。
put(Object key,Object value)
ConcurrentHashMap 并没有在此方法上加上 synchronized, 首先判断 value 是否为 null,如为 null 则抛出 NullPointerException, 如不为 null, 则继续下面的步骤:
和 HashMap 一样, 首先对 key.hashCode 进行 hash 操作, 得到 key 的 hash 值, hash 操作的算法和 HashMap 也不同:
h += (h << 15) ^ 0xffffcd7d; |
根据此 hash 值计算并获取其对应的数组中的 Segment 对象, 方法如下:
return segments[(hash >>> segmentShift) & segmentMask];
在找到了数组中的 Segment 对象后, 接着调用 Segment 对象的 put 方法来完成当前操作。
当调用 Segment 对象的 put 方法时, 首先进行 lock 操作, 接着判断当前存储的对象个数加 1 后是否大于 threshold, 如大于, 则将当前的 HashEntry 对象数组大小扩大两倍, 并将之前存储的对象进行重新 hash, 转移到新的对象数组中, 在确保了数组的大小足够后, 继
续下面的步骤。
接下去的动作则和 HashMap 基本一样, 通过对 hash 值和对象数组大小减 1 的值进行按位与后, 得到当前 key 需要放入数组的位置, 接着寻找对应的位置上的 HashEntry 对象链表是否有 key、 hash 值和当前 key 相同的, 如有, 则覆盖其 value, 如没有, 则创建一个新的
HashEntry 对象, 赋值给对应位置的数组对象, 并构成链表。
完成了上面的步骤后, 释放锁, 整个 put 动作得以完成。
根据以上分析, 可以看出 , ConcurrentHashMap 基于concurrencyLevel 划分出了多个Segment 来对 key-value 进行存储, 从而避免每次 put 操作就得锁住整个数组, 在默认的情况
下, 最佳情况下可以允许 32 个线程并发无阻塞的操作集合对象, 尽可能的减少并发时的阻塞现象。
remove(Object key)
首先对 key.hashCode 进行 hash 操作, 基于得到 hash 的值找到对应的 Segment 对象, 调用其 remove 方法完成当前操作。
Segment 的 remove 方法进行进行加锁操作, 然后对 hash 值和对象数组大小减 1 的值进行按位与操作, 获取数组上对应位置的 HashEntry 对象, 接下去遍历此 HashEntry 对象以及其 next 对象, 找到 hash 和传入的 hash 值相等, 以及 key 和传入的 key equals 的 HashEntry
对象, 如未找到, 则返回 null。
如找到, 则将 HashEntry 链表中位于删除元素之前的所有 HashEntry 重新创建, 位于其后的元素则不用做动作。
最后释放锁, 完成 remove 操作。
get(Object key)
首先对 key.hashCode 进行 hash 操作, 基于得到 hash 的值找到对应的 Segment 对象, 调用其 get 方法完成当前操作。
Segment 的 get 方法首先判断当前 HashEntry 对象数组中的已存储的对象大小是否为 0,如为 0, 则直接返回 null, 如不为 0, 则继续下面的步骤。对 hash 值和对象数组大小减 1 的值进行按位与操作, 获取数组上对应位置的 HashEntry对象, 接下去遍历此 HashEntry 以及其 next 对象, 寻找到 hash 值相等以及 key equals 的HashEntry 对象, 在找到的情况下, 获取其 value, 如 value 不为 null, 则直接返回此 value,
如为 null, 则调用 readValueUnderLock 方法, readValueUnderLock 方法首先进行 lock 操作,然后直接返回 HashEntry 的 value 属性, 最后释放锁。经过以上步骤后, 就完成了 get 操作, 从上面 Segment 的 get 步骤来看, 仅在寻找到的HashEntry 对象的 value 为 null 时, 才进行了锁操作, 其他情况下并没有锁操作, 也就是可以认为 ConcurrentHashMap 在读数据时大部分情况下是没有采用锁的, 那么它是如何保证并发场景下数据的一致性的呢。
对上面的实现步骤进行分析, get 操作首先通过 hash 值和对象数组大小减 1 的值进行按位与操作来获取数组上对应位置的 HashEntry, 在这个步骤中, 可能会因为对象数组大小的改变以及数组上对应位置的 HashEntry 产生不一致性, 来分析下 ConcurrentHashMap 是如何
保证的。
对象数组大小的改变只有在 put 操作时有可能发生, 由于 HashEntry 对象数组对应的变量是 voliate 类型的, 因此可以保证如 HashEntry 对象数组大小发生改变, 读操作时可看到最新的对象数组大小。
在 put 和 remove 操作进行时, 都有可能造成 HashEntry 对象数组上对应位置的 HashEntry发生改变, 如在读操作已获取到 HashEntry 对象后, 有一个 put 或 remove 操作完成, 此时读操作尚未完成, 那么这时确实会造成读的不一致性, 但这种几率相对而言非常的低。
在获取到了 HashEntry 对象后, 怎么能保证获取的 HashEntry 对象以及其 next 属性构成的链表上的对象不会改变呢, 这点 ConcurrentHashMap 采用了一个简单的方式, 即 HashEntry
对象中的 hash、 key 以及 next 属性都是 final 的, 这也就意味着没办法插入一个 HashEntry对象到 HashEntry 基于 next 属性构成的链表中间或末尾中, 这样可以保证当获取到 HashEntry对象后, 其基于 next 属性构建的链表是不会发生变化的。
至于为什么需要判断下获取的 HashEntry 的 value 是否为 null, 原因在于 put 操作创建一个新的 HashEntry 时, 并发读取时有可能此时 value 属性尚未完成设置, 因此将读取到默认值, 不过具体出现这种现象的原因还未知, 据说只有在老版本的 JDK 中才会有, 而其他属性由于是 final 的, 则可以保证是线程安全的, 因此这里做了个保护, 当value 为 null 则通过加锁操作来确保读到的 value 是一致的。
containsKey(Object key)
和 get 操作一样, 没有进行加锁操作, 整个步骤和 get 相同, 只是当寻找到有 key、 hash相等的 HashEntry 时, 则返回 true, 否则返回 false。
keySet().iterator()
这 其 实 也 是 个 类 似 的 读 取 的 过 程 , 只 是 需 要 读 取 所 有 分 段 中 的 数 据 ,ConcurrentHashMap 采用的方法即为遍历每个分段中的 HashEntry 对象数组, 完成集合中所有 对 象 的 读 取 , 这 个 过 程 也 是 不 加 锁 的 , 因 此 遍 历 ConcurrentHashMap 不 会 抛 出
ConcurrentModificationException, 这点和 HashMap 不同。
从上面的分析可以看出, ConcurrentHashMap 默认情况下采用将数据分为 32 个段进行存储, 并且 32 个段分别持有各自的锁, 锁仅用于 put 和 remove 等改变集合对象的操作, 基于 voliate 以及 HashEntry 链表的不变性实现读取的不加锁, 这些方式使得 ConcurrentHashMap
能够保持极好的并发支持, 尤其是对于读远比插入和删除频繁的 Map 而言, 而其采用的这些方法也可谓是对于 Java 内存、 并发机制的深刻掌握的体现, 是一个设计的非常不错的支
持高并发的集合对象, 不过对于 ConcurrentHashMap 这种而言, 由于没有一个全局锁, size这样的方法就比较复杂了 , 在计算 size 时, ConcurrentHashMap 采取的方法为:
在不加锁的情况下遍历所有的段, 读取其 count 以及 modCount, 这两个属性都是 voliate类型的, 并进行统计, 完毕后, 再遍历一次所有的段, 比较 modCount 是否有改变, 如有改变, 则再尝试两次以上动作。如执行了三次上述动作后, 仍然有问题, 则遍历所有段, 分别进行加锁, 然后进行计算,计算完毕后释放所有锁, 从而完成计算动作。
从上可见, 以上的方法使得 size 方法在大部分情况下也可通过不加锁的方式计算出来。
基于 HashMap 的测试方法对 ConcurrentHashMap 进行性能测试, 最终的测试结果如下图所示。
在不同的集合大小下增加、 查找以及删除元素的性能变化情况;
从性能表现来看, 和 HashMap 基本没有差距, 表现出来的状况也基本一致。
在不同的集合大小以及不同的线程数的情况下, 增加、 查找以及删除元素的性能的变化情况;
增加元素的性能变化如下图所示:
查找元素的性能变化如下图所示:
删除元素的性能变化如下图所示:
从以上结果来看, 在多线程的情况下, 以上三种操作的性能较单线程而言仍然是下降了不少,
但对比 HashMap 在多线程下的表现而言, ConcurrentHashMap 在多线程下的性能表现则明
显提升了不少, 这也是 ConcurrentHashMap 采取拆分锁方式带来的好处。