3.1 ArrayList
3.1.1 ArrayList是如何实现自动扩容的
ArrayList 是一个数组结构的存储容器,默认情况下,数组的长度是 10. 当然我们也可以在构建 ArrayList 对象的时候自己指定初始长度。
随着在程序里面不断地往 ArrayList 中添加数据,当添加的数据达到 10 个的时候,ArrayList 就没有多余容量可以存储后续的数据。这个时候 ArrayList 会自动触发扩容。
扩容的具体流程很简单,
- 首先,创建一个新的数组,这个新数组的长度是原来数组长度的 1.5 倍。
- 然后使用 Arrays.copyOf 方法把老数组里面的数据拷贝到新的数组里面。
扩容完成后再把当前要添加的元素加入新的数组里面,从而完成动态扩容的过程。
3.1.2 ArrayList ,Vector 和LinkedList的存储性能及特性
ArrayList 和 Vector 都是使用数组方式存储数据 , 此数组元素数大于实际存储的数据以 便增加和
插入元素, 它们都允许直接按序号索引元素, 但是插入元素要涉及数组元素移动等内存操作, 所 以索引数据快而插入数据慢 ,
Vector 中 的 方 法由于添加了synchronized 修饰 , 因此 Vector 是线程安全的容器, 但性能上较 ArrayList 差 , 因此已经是Java 中的遗留容器 。
LinkedList 使用双向链表实现存储( 将内存中零散的内存单元通过附加的引用关联起来, 形成
一个可以按序号索引的线性结构, 这种链式存储方式与数组的连续存储方式相比,内存的利用率更高),按序号索引数据需要进行前向或后向遍历, 但是插入数据时只需要记录本项的前后项即可,所以插入速度较快 。
Vector 属于遗留容器已经不推荐使用 ,但是由于 ArrayList 和 LinkedListed 都是非线 程安全的 , 如果遇到多个线程操作同一个容器的场景 , 则可以通过工具类Collections 中的 synchronized List 方法将其转换成线程安全的容器后再使用( 这 是对装潢模式的应用 , 将已有对象传入另一个类的构造器中创建新的对象来增强实现)。
3.2 HashMap
HashMap主要是用于存储键值对。它是基于哈希表实现的,提供了快速的插入、删除和查找操作。从安全角度,HashMap不是线程安全的。如果多个线程同时访问一个HashMap并且至少有一个线程修改了它,则必须手动同步。
HashMap允许一个 null 键和多个 null 值。
HashMap不保证映射的顺序,特别是它不保证顺序会随着时间的推移保持不变。
HashMap提供了 O(1) 时间复杂度的基本操作(如 get 和 put),前提是哈希函数的分布良好且冲突较少。
3.2.1单线程下的HashMap工作原理
HashMap使用哈希表来存储数据。哈希表是基于数组和链表的组合结构。
- 哈希函数:HashMap使用键的hashCode()方法来计算哈希值,然后将哈希值映射到数组的索引位置。
2**. 数组和链表**:HashMap使用一个数组来存储链表或树结构(Java 8 及以后)。每个数组位置被称为一个“桶”,每个桶存储链表或树。 - 冲突处理:当两个键的哈希值相同时,它们会被存储在同一个桶中,形成一个链表(或树)。这种情况称为哈希冲突。
- 再哈希:当HashMap中的元素数量超过容量的负载因子(默认 0.75)时,HashMap会进行再哈希,将所有元素重新分配到一个更大的数组中。
性能注意事项
● 初始容量和负载因子:可以通过构造函数设置HashMap的初始容量和负载因子,以优化性能。初始容量越大,减少再哈希的次数;负载因子越小,减少冲突的概率,但会增加空间开销。
● 哈希函数的质量:哈希函数的质量直接影响HashMap的性能。理想的哈希函数应尽可能均匀地分布键。
3.2.2 HashMap是如何解决Hash冲突的
这个问题我从三个方面来回答。
- 要了解 Hash 冲突,那首先我们要先了解 Hash 算法和 Hash 表。(如图)
a. Hash 算法,就是把任意长度的输入,通过散列算法,变成固定长度的输出,这个输出结果是
散列值。
b. Hash 表又叫做“散列表”,它是通过 key 直接访问在内存存储位置的数据结构,在具体实现
上,我们通过 hash 函数把 key 映射到表中的某个位置,来获取这个位置的数据,从而加快查
找速度。
c. 所谓 hash 冲突,是由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,所以总
会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。
d. 通常解决 hash 冲突的方法有 4 种。
i. 开放定址法,也称为线性探测法,就是从发生冲突的那个位置开始,按照一定的次序从hash 表中找到一个空闲的位置,然后把发生冲突的元素存入到这个空闲位置中。
ThreadLocal 就用到了线性探测法来解决 hash 冲突的。
像这样一种情况(如图),在 hash 表索引 1 的位置存了一个 key=name,当再次添加 key=hobby
时,hash 计算得到的索引也是 1,这个就是 hash 冲突。而开放定址法,就是按顺序向前找到一个空闲的位置来存储冲突的 key。
ii. 链式寻址法,这是一种非常常见的方法,简单理解就是把存在 hash 冲突的 key,以单向链表的方式来存储,比如 HashMap 就是采用链式寻址法来实现的。
像这样一种情况(如图),存在冲突的 key 直接以单向链表的方式进行存储。
iii. 再 hash 法,就是当通过某个 hash 函数计算的 key 存在冲突时,再用另外一个 hash 函
数对这个 key 做 hash,一直运算直到不再产生冲突。这种方式会增加计算时间,性能影
响较大。
iv. 建立公共溢出区, 就是把 hash 表分为基本表和溢出表两个部分,凡是存在冲突的元素,
一律放入溢出表中。
e. HashMap 在 JDK1.8 版本中,通过链式寻址法+红黑树的方式来解决 hash 冲突问题,其中红
黑树是为了优化 Hash 表链表过长导致时间复杂度增加的问题。当链表长度大于 8 并且 hash
表的容量大于 64 的时候,再向链表中添加元素就会触发转化。
3.2.3 HashMap 什么时候扩容,如何自动扩容
在任何语言中,我们希望在内存中临时存放一些数据,可以用一些官方封装好的集合(如图),
比如 List、HashMap、Set 等等。作为数据存储的容器。
当我们创建一个集合对象的时候,实际上就是在内存中一次性申请一块内存空间。
而这个内存空间大小是在创建集合对象的时候指定的。
比如 List 的默认大小是 10、HashMap 的默认大小是 16。
长度不够怎么办
在实际开发中,我们需要存储的数据量往往大于存储容器的大小。
针对这种情况,通常的做法就是扩容。
当集合的存储容量达到某个阈值的时候,集合就会进行动态扩容,从而更好地满足更多数据的存储。
(如图)List 和 HashMap,本质上都是一个数组结构,所以基本上只需要新建一个更长的数组
然后把原来数组中的数据拷贝到新数组就行了。
HashMap 是如何扩容的?
当 HashMap 中元素个数超过临界值时会自动触发扩容,这个临界值有一个计算公式。
threashold=loadFactor*capacity 。
loadFactor 的默认值是 0.75,capacity 的默认值是 16,也就是元素个数达到 12 的时候触发扩容。
扩容后的大小是原来的 2 倍。
由于动态扩容机制的存在,所以我们在实际应用中,需要注意在集合初始化的时候明确指定集合的大小。
避免频繁扩容带来性能上的影响。
假设我们要向 HashMap 中存储 1024 个元素,如果按照默认值 16,随着元素的不断增加,会造成 7次扩容。
而这 7 次扩容需要重新创建 Hash 表,并且进行数据迁移,对性能影响非常大。
我们知道,HashMap 里面采用链式寻址法来解决 hash 冲突问题,为了避免链表过长带来时 间复杂度的增加
所以链表长度大于等于 7 的时候,就会转化为红黑树,提升检索效率。
当扩容因子在 0.75 的时候,链表长度达到 8 的可能性几乎为 0,也就是比较好地达到了空间成本和时 间成本的平衡。
面试题的标准回答
当 HashMap 元素个数达到扩容阈值,默认是 12 的时候,会触发扩容。
默认扩容的大小是原来数组长度的 2 倍,HashMap 的最大容量是 Integer.MAX_VALUE,也就是 2 的31 次方-1。 讲下线程池的线程回收
3.2.4 为什么HashMap会产生死循环
HashMap的死循环问题只在 JDK1.7 版本中会出现,主要是 HashMap 自身的工作机制,再加上并发操作,从而导致出现死循环。JDK1.8 以后,官方彻底解决了这个问题。
HashMap 正常情况下的扩容就是这样一个过程。我们来看,旧 HashMap 的节点会依次转移到
新的 HashMap 中,旧 HashMap 转移链表元素的顺序是 A、B、C,而新 HashMap 使用的是头插法 插入,所以,扩容完成后最终在新 HashMap 中链表元素的顺序是 C、B、A。
2、导致死循环的原因
接下来,我通过动画演示的方式,带大家彻底理解造成 HashMap 死循环的原因。我们按以下三个步 骤来还原并发场景下 HashMap 扩容导致的死循环问题:
第一步:线程启动,有线程 T1 和线程 T2 都准备对 HashMap 进行扩容操作, 此时 T1 和 T2 指向的都是链表的头节点 A,而 T1 和 T2 的下一个节点分别是 T1.next 和 T2.next,它们都指向 B 节点。 第二步:开始扩容,这时候,假设线程 T2 的时间片用完,进入了休眠状态,而线程 T1 开始执行扩容操作,一直到线程 T1 扩容完成后,线程 T2 才被唤醒。
T1 完成扩容之后的场景就变成动画所示的这样。
因为 HashMap 扩容采用的是头插法,线程 T1 执行之后,链表中的节点顺序发生了改变。但线程 T2对于发生的一切还是不可知的,所以它指向的节点引用依然没变。如图所示,T2 指向的是 A 节点,T2.next 指向的是 B 节点。
当线程 T1 执行完成之后,线程 T2 恢复执行时,死循环就发生了。
因为 T1 执行完扩容之后,B 节点的下一个节点是 A,而 T2 线程指向的首节点是 A,第二个节点是 B,这个顺序刚好和 T1 扩容之前的节点顺序是相反的。T1 执行完之后的顺序是 B 到 A,而 T2 的顺序是 A到 B,这样 A 节点和 B 节点就形成了死循环。
3、解决方案
避免 HashMap 发生死循环的常用解决方案有三个:
1)、使用线程安全的 ConcurrentHashMap 替代 HashMap,个人推荐使用此方案。
2)使用线程安全的容器 Hashtable 替代,但它性能较低,不建议使用。
3)使用 synchronized 或 Lock 加锁之后,再进行操作,相当于多线程排队执行,也会影响性能,不建议使用。
3.2.5 HashMap和TreeMap的区别是什么
(1) HashMap:它根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。
HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。如果需要满足线程安全, 可以用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用ConcurrentHashMap。
(2) Hashtable: Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,
并且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为ConcurrentHashMap 引入了分段锁。 Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。
(3) LinkedHashMap:LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,在用 Iterator遍历 LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
(4) TreeMap:TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,
也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用 TreeMap时, key必须实现 Comparable接口或者在构造 TreeMap传入自定义的 Comparator,否则会在运行时抛出 java.lang.ClassCastException 类型的异常。
对于上述四种 Map 类型的类,要求映射中的 key 是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化, Map 对象很可能就定位不到映射的位置了。
3.2.6 为什么ConcurrentHashMap的key不应许为空
打开 ConcurrentHashMap 的源码
在 put 方法里面,可以看到这样一段代码(如图)
如果 key 或者 value 为空,则抛出空指针异常。
但是为什么 ConcurrentHashMap 不允许 key 或者 value 为空呢?
简单来说,就是为了避免在多线程环境下出现歧义问题。
所谓歧义问题,就是如果 key 或者 value 为 null,当我们通过 get(key)获取对应的 value 的时候,如果返回的结果是 null
我们没办法判断,它是 put(k,v)的时候,value 本身为 null 值,还是这个 key 本身就不存在。
比如在这样一种情况下(如图),线程 t1 调用 containsKey 方法判断 key 是否存在,假设当前这个 key 不存在,本来应该返回 false。
但是在 T1 线程返回之前,正好有一个 T2 线程插入了这个 key,但是 value 为 null。
这就导致原本 T1 线程返回的结果有可能是 true,有可能是 false,取决于 T1 和 T2 线程的执行顺序。
这种现象我们可以认为是线程安全性问题,而 ConcurrentHashMap 又是一个线程安全的集合,所以自然就不允许 key 或者 value 为 null。
而 HashMap 中是允许存 null 的,因为它不需要考虑到线程安全性问题。
所以这个问题的核心本质还是 ConcurrentHashMap 这个并发安全性集合的特性。
回答
ConcurrentHashMap 这么设计的原因是为了避免在多线程并发场景下的歧义问题。
也就是说,当一个线程从 ConcurrentHashMap 获取某个 key,如果返回的结果是 null 的时候。这个线程无法确认,这个 null 表示的是确实不存在这个 key,还是说存在 key,但是 value 为空。这种不确定性会造成线程安全性问题,而 ConcurrentHashMap 本身又是一个线程安全的集合。所以才这么设计!
3.2.7 谈谈你对 ConcurrentHashMap底层实现原理的理解
(如图所示),这个是 ConcurrentHashMap 在 JDK1.8 中的存储结构,它是由数组、单向链表、红黑树组成。
当我们初始化一个 ConcurrentHashMap 实例时,默认会初始化一个长度为 16 的数组。由于
ConcurrentHashMap 它的核心仍然是 hash 表,所以必然会存在 hash 冲突问题。
ConcurrentHashMap 采用链式寻址法来解决 hash 冲突。
当 hash 冲突比较多的时候,会造成链表长度较长,这种情况会使得 ConcurrentHashMap 中数据元素的查询复杂度变成 O(n)。因此在 JDK1.8 中,引入了红黑树的机制。
当数组长度大于 64 并且链表长度大于等于 8 的时候,单向链表就会转换为红黑树。
另外,随着 ConcurrentHashMap 的动态扩容,一旦链表长度小于 8,红黑树会退化成单向链表。
ConcurrentHashMap 的基本功能
ConcurrentHashMap 本质上是一个 HashMap,因此功能和 HashMap 一样,但是ConcurrentHashMap 在 HashMap 的基础上,提供了并发安全的实现。
并发安全的主要实现是通过对指定的 Node 节点加锁,来保证数据更新的安全性(如图所示)。
ConcurrentHashMap 在性能方面做的优化
如果在并发性能和数据安全性之间做好平衡,在很多地方都有类似的设计,比如 cpu 的三级缓存、mysql的 buffer_pool、Synchronized 的锁升级等等。
ConcurrentHashMap 也做了类似的优化,主要体现在以下几个方面:
在 JDK1.8 中,ConcurrentHashMap 锁的粒度是数组中的某一个节点,而在 JDK1.7,锁定的是 Segment,锁的范围要更大,因此性能上会更低。
引入红黑树,降低了数据查询的时间复杂度,红黑树的时间复杂度是 O(logn)。
(如图所示),当数组长度不够时,ConcurrentHashMap 需要对数组进行扩容,在扩容的实
现上,ConcurrentHashMap 引入了多线程并发扩容的机制,简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据迁移,从而提升了扩容过程中数据迁移的效率。
ConcurrentHashMap 中有一个 size()方法来获取总的元素个数,而在多线程并发场景中,
在保证原子性的前提下来实现元素个数的累加,性能是非常低的。ConcurrentHashMap 在这
个方面的优化主要体现在两个点:
当线程竞争不激烈时,直接采用 CAS 来实现元素个数的原子递增。
如果线程竞争激烈,使用一个数组来维护元素个数,如果要增加总的元素个数,则直接从
数组中随机选择一个,再通过 CAS 实现原子递增。它的核心思想是引入了数组来实现对并发更新的负载。
3.2.8 ConcurrentHashMap如何保证线程安全
ConcurrentHashMap 相当于是 HashMap 的多线程版本,它的功能本质上和 HashMap 没什么区别。
因为 HashMap 在并发操作的时候会出现各种问题,比如死循环问题、数据覆盖等问题。而这些问题,只要使用 ConcurrentHashMap 就可以完美地解决。那问题来到了,ConcurrentHashMap 它是如何保证线程安全的呢?
1、JDK1.7 实现原理
首先,我们来看 JDK 1.7 中 ConcurrentHashMap 的底层结构,它基本延续了 HashMap 的设计,采用的是数组 加 链表的形式。和 HashMap 不同的是,ConcurrentHashMap 中的数组设计 分为大
数组 Segment 和小数组 HashEntry,来着这张图
大数组 Segment 可以理解为一个数据库,而每个数据库(Segment)中又有很多张(HashEntry),
每个 HashEntry 中又有很多条数据,这些数据是用链表连接的。了解了 ConcurrentHashMap 的基本结构设计,我们再来看它的线程安全实现,就比较简单了。
接下来我们来对照 JDK1.7 中 ConcurrentHashMap 的 put()方法源码实现。
因为 Segment 本身是基于 ReentrantLock 重入锁实现的加锁和释放锁的操作,这样就能保证多个线程同时访问 ConcurrentHashMap 时,同一时间只能有一个线程能够操作相应的节点,这样就保证了ConcurrentHashMap 的线程安全。
也就是说 ConcurrentHashMap 的线程安全是建立在 Segment 加锁的基础上的,所以,我们称它为
分段锁或者片段锁,如图中所示。
2、JDK1.8 优化内容
在 JDK1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组加链表的形式,所以在数据比较多情况下,因为要遍历整个链表,会降低访问性能。所以,JDK1.8 以后采用了数组加链表加红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现如图所示。
当链表长度大于 8,并且数组长度大于 64 时,链表就会升级为红黑树的结构。JDK 1.8 中的ConcurrentHashMap 虽然保留了 Segment 的定义,但这,仅仅是为了保证序列化时的兼容性,不再有任何结构上的用处。
那在 JDK 1.8 中 ConcurrentHashMap 的源码是如何实现的呢?它主要是使用了 CAS 加 volatile 或
者 synchronized 的方式来保证线程安全。
我们可以从源码片段中看到,添加元素时首先会判断容器是否为空,
如果为空则使用 volatile 加 CAS 来初始化,
如果容器不为空,则根据存储的元素计算该位置是否为空。
如果根据存储的元素计算结果为空则利用 CAS 设置该节点;
如果根据存储的元素计算为空不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,
最后再判断是否需要转为红黑树。这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是 ConcurrentHashMap 通过对头结点加锁来保证线程安全的。
这样设计的好处是,使得锁的粒度相比 Segment 来说更小了,发生 hash 冲突 和 加锁的频率也降低了,在并发场景下的操作性能也提高了。而且,当数据量比较大的时候,查询性能也得到了很大地提升。
2、总结
最后,我们来总结一下:
1、ConcurrentHashMap 在 JDK 1.7 中使用的数组 加 链表的结构,其中数组分为两类,大数组Segment 和 小数组 HashEntry,而加锁是通过给 Segment 添加 ReentrantLock 重入锁来保证线程
安全的。
2、ConcurrentHashMap 在 JDK1.8 中使用的是数组 加 链表 加 红黑树的方式实现,它是通过 CAS
或者 synchronized 来保证线程安全的,并且缩小了锁的粒度,查询性能也更高。
ConcurrentHashMap 中有很多设计思想是值得我们去学习和借鉴的,比如说锁的粒度控制、分段锁的设计等等,都可以应用在实际的业务开发场景中。我们通过学习这些底层原理从中获取很多的设计思路,帮助我们更高效地去解决实际问题。
3.2.9 ConcurrentHashMap 的 size()方法是线程安全的吗?
ConcurrentHashMap 的 size()方法是非线程安全的。
当有线程调用 put 方法在添加元素的时候,其他线程在调用 size()方法获取的元素个数和实际存储元素个数是不一致的。
原因是 size()方法是一个非同步方法,put()方法和 size()方法并没有实现同步锁。
put()方法的实现逻辑是:在 hash 表上添加或者修改某个元素,然后再对总的元素个数进行加。
其中,线程的安全性仅仅局限在 hash 表数组粒度的锁同步,避免同一个节点出现数据竞争带来线程安全问题。
(如图)数组元素个数的累加方式用到了两个方案:
当线程竞争不激烈的时候,直接用 cas 的方式对一个 long 类型的变量做原子递增。
当线程竞争比较激烈的时候,使用一个 CounterCell 数组,用分而治之的思想减少多线程竞争,从而实现元素个数的原子累加。
size()方法的逻辑就是遍历 CounterCell 数组中的每个 value 值进行累加,再加上 baseCount,汇总得到一个结果。
所以很明显,size()方法得到的数据和真实数据必然是不一致的。
因此从 size()方法本身来看,它的整个计算过程是线程安全的,因为这里用到了 CAS 的方式解决了并发更新问题。
但是站在 ConcurrentHashMap 全局角度来看,put()方法和 size()方法之间的数据是不一致的,因 此也就不是线程安全的。
之所以不像 HashTable 那样,直接在方法级别加同步锁。在我看来有两个考虑点。
- 直接在 size()方法加锁,就会造成数据写入的并发冲突,对性能造成影响,
当然有些朋友会说可以加读写锁,但是同样会造成 put 方法锁的范围扩大,性能影响极大! - ConcurrentHashMap 并发集合中,对于 size()数量的一致性需求并不大,并发集合更多的是去保证数据存储的安全性。