目录
10.HashMap 和 ConcurrentHashMap 的区别🔒
11.ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?🔒
12.针对ConcurrentHashMap锁机制具体分析(JDK1.7VSJDK1.8)?🔒
13. ConcurrentHashMap在JDK1.8中,为什么要使用内置锁synchronized来代替重入锁ReentrantLock?🔒
ArrayList和LinkedList都是List接口的实现类~~
🔑🔑🔑它们有以下几个方面不同:
(1)它们的底层实现不同:ArrayList 是基于动态数组的数据结构,而 LinkedList 是基于链表的数据结构。
(2)随机访问性能不同:ArrayList 优于 LinkedList,因为 ArrayList 可以根据下标以 O(1) 时间复杂度对元素进行随机访问。而 LinkedList 的访问时间复杂度为 O(n),因为它需要遍历整个链表才能找到指定的元素。
🔨ArrayList的内部实现是基于基础的对象数组的,因此,它使用get方法访问列表中的任意一个元素时,它的速度要比LinkedList快。
🔨不同的是LinkedList中的get方法是按照顺序从列表的一端开始检查,直到另外一端。
(3)插入和删除性能不同:LinkedList 优于 ArrayList,因为 LinkedList 的插入和删除操作时间复杂度为 O(1),而 ArrayList 的时间复杂度为 O(n)。
🔨当一个元素被加到ArrayList的最开端时,所有已经存在的元素都会后移,这就意味着数据移动和复制上的开销。
🔨相反的,将一个元素加到LinkedList的最开端只是简单的为这个元素分配一个记录,然后调整两个连接。
ArrayList和Vector都是List接口的实现类,它们都是动态数组的实现,具有相同的方法(增删改查)
🔑🔑🔑它们有以下几个方面不同:
(1)线程安全性:Vector是线程安全的,而ArrayList不是。所以在多线程的环境下,应该选择Vector。
🔨在多线程环境下,多个线程同时对 ArrayList 进行添加元素的操作,可能会导致数据错乱、数组越界等问题。
🔨Vector大部分方法都使用了synchronized关键字来修饰,这使得在访问Vector对象时同一时间只能有一个线程执行操作,其他线程必须等待。这样可以避免多线程同时修改Vector对象而引发的数据竞争和不一致性问题。
(2)性能:由于Vector是线程安全的,所以它的性能比ArrayList差,在单线程的情况下,ArrayList的速度比Vector快。
(3)初始容量增长方式:当容量不足时,ArrayList会增加50%的容量,而Vector会将容量翻倍,在添加大量元素时,ArrayList需要频繁的扩容操作,所以Vector更适合存储大量数据。
在Java中,抽象类和普通类是两种不同的类类型,
🔑🔑🔑以下是普通类和抽象类的一些区别:
(1)实例化:普通类可以直接实例化,而抽象类不能直接实例化。
🔨抽象类通常用于定义一些基本的行为和属性,而具体的实现则由其子类来完成。
(2)方法:抽象类中既包含抽象方法又可以包含具体的方法,而普通类只能包含普通方法。
(3)实现:普通类实现接口需要重写接口中的方法,而抽象类可以实现接口方法也可以不实现。
在Java中,抽象类和接口是两种不同的类类型,它们都不能直接实例化,它们通常用来定义一些基本的属性和方法~~
🔑🔑🔑它们有以下几个方面不同:
(1)定义:定义的关键字不同,抽象类是 abstract,而接口是 interface。
(2)方法:抽象类可以包含抽象方法和具体方法,而接口只能包含方法声明(抽象方法)。
(3)方法访问控制符:抽象类无限制,只是抽象类中的抽象方法不能被 static、final、private 修饰;而接口有限制,接口默认的是 public 控制符。
🔨抽象方法不能被private修饰是因为:private修饰的方法或变量只能在本类中访问、调用,而抽象方法是必然要被子类覆盖重写的。
🔨抽象方法不能被static修饰是因为:static修饰的方法是属于类的,首先要满足类的调用,如果通过类无法调用,那么这个静态方法就肯定不正确了,为了满足这个要求就必须有方法体,有方法体就不能是抽象方法。且static修饰的方法不能被重写,也就是说这个抽象类的子类不能重写这个方法与抽象方法的本质相驳。
🔨抽象方法不能被final修饰是因为:被final修饰的方法无法被子类重写,而抽象方法被非抽象类继承就必须要重写所有抽象方法,所以抽象方法不能被final修饰。
🔨接口中每一个方法也是隐式抽象的,接口中的方法会被隐式的指定为 public abstract(只能是 public abstract,其他修饰符都会报错)。
(4)实现:一个类只能继承一个抽象类,但可以实现多个接口。
(5)变量:抽象类可以包含实例变量和静态变量,而接口只能包含常量。
接口中可以含有变量,但是接口中的变量会被隐式的指定为 public static final 变量,即常量(并且只能是 public,用 private 修饰会报编译错误)。
(6)构造函数:抽象类可以有构造函数,而接口不能有构造函数。
🔨抽象类是一个类,别的类是用关键字 extends 来继承下来,并扩展的,有非常强的is-a的关系,
🔨接口只是定义功能和行为规范,如果一个类实现了一个接口,那么这个类必须遵守这个接口的方法约定,但没有is-a的关系。把墙壁上的“小学生行为规范”想象成一个接口,那么是小学生必须遵守这个约定,但小学生不是“行为规范”。接口是一种规范,被调用时,主要关注的是里边的方法,而方法是不需要初始化的,类可以实现多个接口
HashMap和Hashtable都实现了Map接口,都是Java中用于存储键值对的数据结构,它们的底层数据结构都是数组加链表的形式(默认情况下),
🔑🔑🔑但它们存在以下几点不同:
(1)线程安全:Hashtable 是线程安全的,而 HashMap 是非线程安全的。
1.在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
2.在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。
(2)性能:因为 Hashtable 使用了 synchronized 给整个方法添加了锁,所以相比于 HashMap 来说,它的性能不如 HashMap。
(3)存储:HashMap 允许 key 和 value 为 null,而 Hashtable 不允许存储 null 键和 null 值。
参照Hashtable源码:
🔨Hashtable 不能存储 null 键和 null 值是因为,它的 key 值要进行哈希计算,如果为 null 的话,无法调用该方法,还是会抛出空指针异常。而 value 值为 null 的话,Hashtable 源码中会主动抛出空指针异常。
参照HashMap源码:
🔨HashMap 允许 key 和 value 为 null 的原因是因为在 HashMap 中对 null 值进行了特殊处理,如果为 null 时会把它赋值为 0,
HashMap 在不同的 JDK 版本下的实现是不同的,在 JDK 1.7 时,HashMap 底层是通过数组 + 链表实现的;而在 JDK 1.8 时,HashMap 底层是通过数组 + 链表或红黑树实现的。
(1)数组存储桶:HashMap内部维护一个数组,这个数组的每个元素称为桶(bucket),每个桶可以存储一个链表或红黑树。这个数组的大小是可以调整的,它的初始大小是16,但可以根据需要自动扩展。
(2)哈希函数:当你将一个键值对放入HashMap时,HashMap会使用键的哈希码(通过调用键的hashCode()
方法获得)来确定这个键应该存储在数组的哪个桶中。哈希函数的目标是将键均匀地分布在不同的桶中,以便高效地检索数据。
(3)碰撞处理:由于哈希函数的限制,不同的键可能会映射到相同的桶中,这就产生了碰撞(collision)。为了解决碰撞问题,每个桶都可以存储一个链表或红黑树。如果多个键哈希到同一个桶,它们将被存储在同一个链表或红黑树中。在Java 8及之后的版本中,当链表中的元素数量超过一定阈值时,链表将被转换为红黑树,以提高检索效率。
🔨链表来存储相同桶内的键值对的示意图:
HashMap: 0 -> [] 1 -> [] 2 -> [ (Key1, Value1), (Key2, Value2) ] 3 -> [] 4 -> [] 5 -> [] 6 -> [] 7 -> []
HashMap: 0 -> [] 1 -> [] 2 -> [ (Key1, Value1) -> (Key2, Value2) ] 3 -> [] 4 -> [] 5 -> [] 6 -> [] 7 -> []🔨红黑树来存储相同桶内的键值对的示意图:
HashMap: 0 -> [] 1 -> [] 2 -> [ (Key1, Value1), (Key2, Value2), (Key3, Value3), ... ] 3 -> [] 4 -> [] 5 -> [] 6 -> [] 7 -> []
HashMap: 0 -> [] 1 -> [] 2 -> [ (Key1, Value1) / \ (Key2, Value2) (Key3, Value3) \ (Key4, Value4) ] 3 -> [] 4 -> [] 5 -> [] 6 -> [] 7 -> []
(4)查找和插入:当你想要查找HashMap中的值时,首先计算键的哈希码,然后根据哈希码找到对应的桶,接着在桶中查找键对应的值。插入操作也类似,只是要在桶中插入键值对。
(5)自动扩容:当HashMap中的元素数量超过了数组容量的一定比例(负载因子,默认为0.75),HashMap会自动扩容,即创建一个更大的数组,并将所有的键值对重新分配到新数组中,以保持哈希表的效率。
(6)删除:在Java的HashMap实现中,当红黑树的节点数量减少到小于等于6时,会将红黑树重新转换为链表结构。这是为了避免过于复杂的数据结构,因为红黑树相对于链表来说,维护和查找节点的开销更大。这种转换是为了维护HashMap的性能和内存使用效率。
HashMap: 0 -> [] 1 -> [] 2 -> [ (Key1, Value1) / \ (Key2, Value2) (Key3, Value3) ] 3 -> [] 4 -> [] 5 -> [] 6 -> [] 7 -> []
HashMap: 0 -> [] 1 -> [] 2 -> [ (Key1, Value1) -> (Key2, Value2) -> (Key3, Value3) ] 3 -> [] 4 -> [] 5 -> [] 6 -> [] 7 -> []
HashMap 和 HashSet 都是 Java 中的集合类,
🔑🔑🔑但它们存在以下几点不同:
数据结构:
用途:
操作:
性能:
🔑🔑🔑
自平衡性:红黑树是一种自平衡树,它通过在每个节点上引入红色或黑色标记,并通过一组规则来确保树的平衡。这些规则确保了树的高度保持在对数级别,从而保证了基本操作(如查找、插入和删除)的时间复杂度为O(log n),其中n是树中节点的数量。
节点颜色:每个节点都被标记为红色或黑色。这些颜色规则的主要目的是确保以下性质:
示意图:
- 7 (黑)
- / \
- 3 (红) 18 (黑)
- / \ / \
- 1(黑) 5(黑) 23(红)
- / / \
- 15(黑) 28(黑)
插入操作:在插入新节点时,需要遵循一组规则来保持树的平衡。通常,插入操作会导致颜色变化和树的旋转操作,以确保树的红黑树性质不被破坏。
现在,我们要插入一个新的节点,例如插入值为10的节点:
插入10: 7 (黑) / \ 3 (红) 18 (黑) / \ / \ 1(黑) 5(黑) 23(红) \ 10 (红)在插入节点10后,由于节点3和节点10都是红色,违反了红黑树的性质。为了修复这个问题,我们需要进行颜色变化和旋转操作:
颜色变化: 7 (红) / \ 3 (黑) 18 (黑) / \ / \ 1(黑) 5(黑) 23(红) \ 10 (黑)将节点3和节点10的颜色由红色变为黑色,同时将节点7的颜色由黑色变为红色。这确保了从根节点到叶子节点的每个路径上的黑高度保持不变。
左旋转: 7 (红) / \ 5 (黑) 18 (黑) / \ / \ 3(黑) 10(黑) 23(红) \ 1 (黑)由于插入节点10后,节点3和节点10形成了连续的红色节点,违反了红黑树的性质。为了修复这个问题,我们执行左旋转操作
完成这些操作后,红黑树再次满足所有红黑树性质,插入操作成功完成。
删除操作:删除节点时也需要进行颜色变化和旋转操作来维护红黑树的性质。删除操作可能比插入操作更复杂,因为需要处理不同情况下的平衡问题。
性能:红黑树的平衡性保证了查找、插入和删除操作的平均时间复杂度为O(log n)。这使得红黑树非常适合需要高效的搜索和更新操作的情况,例如,在各种编程语言和库中广泛用于实现字典、集合和映射数据结构。
应用:红黑树在计算机科学中有广泛的应用,包括在操作系统中管理文件系统的数据结构、在数据库中维护索引、在编程语言的集合库中实现集合和映射等。
哈希冲突是指两个或多个不同的输入值(通常是键)经过哈希函数处理后得到相同的哈希值。哈希函数的目标是将不同的输入均匀分布到哈希表的不同位置,但由于输入值的多样性远远超过了哈希表的大小,所以在实际使用中,哈希冲突是不可避免的。
🔑🔑🔑
哈希冲突可能会导致以下问题:
数据丢失:当不同的键映射到相同的哈希桶(数组位置)时,只能存储一个键值对,因此可能会丢失某些数据。
性能下降:哈希冲突会导致多个键值对存储在同一个哈希桶中,这会增加在该桶中查找特定键值对的时间复杂度,因为需要在链表或其他数据结构中进行线性搜索。
哈希表: 0: [1, 6] 1: [2] 2: [3] 3: [4] 4: [5]在这个示意图中,我们插入了一组数字键,每个键都经过哈希函数后映射到相应的桶中。然而,由于哈希函数的限制以及桶的数量有限,冲突发生了。例如,键1和键6都被映射到桶0中,键2、3、4和5都被映射到不同的桶中。
为了解决哈希冲突,通常有以下几种常见的方法:
开放寻址法(Open Addressing):在发生冲突时,该方法会尝试找到哈希表中的下一个可用位置,并将键值对存储在那里,直到找到一个空位置或达到一定的尝试次数。
链地址法(Chaining):在这种方法中,每个哈希桶存储一个链表或其他数据结构,用于存储相同哈希值的多个键值对。当冲突发生时,新的键值对被添加到链表的末尾。
再哈希(Rehashing):当哈希表的负载因子(已存储键值对的数量与哈希桶的数量的比率)达到一定阈值时,可以进行再哈希操作,即创建一个更大的哈希表,并将所有键值对重新散列到新的表中,以减少冲突的发生。
使用更好的哈希函数:选择一个更好的哈希函数可以减少哈希冲突的发生。好的哈希函数能够均匀地将输入映射到不同的哈希值,从而减少冲突的可能性。
下面演示一下链地址法:
桶 = 哈希值 % 表的长度 插入键值对 (3, "A"): 哈希表: 0: [] 1: [] 2: [] 3: ["A"] 4: [] 插入键值对 (7, "B"): 哈希表: 0: [] 1: [] 2: [] 3: ["A"] 4: ["B"] 插入键值对 (12, "C"): 哈希表: 0: [] 1: [] 2: [] 3: ["A"] 4: ["B", "C"] 插入键值对 (17, "D"): 哈希表: 0: [] 1: [] 2: ["D"] 3: ["A"] 4: ["B", "C"] 插入键值对 (22, "E"): 哈希表: 0: [] 1: [] 2: ["D"] 3: ["A"] 4: ["B", "C", "E"]在这个示意图中,我们可以看到哈希冲突的情况发生在键12和键22,它们都经过哈希函数后映射到了桶2中。为解决冲突,我们使用了链地址法,将具有相同哈希值的键值对存储在同一个桶中的链表中。
HashMap
和 ConcurrentHashMap
都是 Java 中用于存储键值对的数据结构,但它们之间有一些重要的区别,特别是在多线程环境下的行为。
🔑🔑🔑 以下是它们之间的主要区别:
线程安全性:
HashMap
:HashMap
不是线程安全的,这意味着如果多个线程同时访问一个 HashMap
实例并且至少有一个线程在进行写操作(插入、删除等),则需要外部同步来确保线程安全。ConcurrentHashMap
:ConcurrentHashMap
是线程安全的。它使用了一种分段锁的机制,将数据分成多个段(segments),每个段拥有自己的锁。这使得多个线程可以并发地访问不同的段,从而提高了并发性能。性能:
ConcurrentHashMap
的性能通常比HashMap
好,因为多个线程可以并行地读取和写入不同的段,而不会彼此阻塞。HashMap
在单线程环境中的性能可能会稍微好一些,因为不需要额外的同步开销。Null 键和值:
HashMap
允许使用null
作为键和值。ConcurrentHashMap
不允许使用null
作为键或值。如果试图插入null
键或值,将会抛出NullPointerException
。迭代器:
HashMap
的迭代器不是线程安全的。如果在迭代期间修改 HashMap
,可能会导致 ConcurrentModificationException
异常。ConcurrentHashMap
的迭代器是弱一致性的,允许在迭代期间进行修改,但不保证迭代器会反映出所有修改。
ConcurrentHashMap
不允许使用null
作为键或值的设计是为了确保在并发环境下的一致性和可靠性。允许null
作为键或值可能导致以下问题:
空指针异常(NullPointerException):如果允许
null
作为键或值,那么在获取或操作映射中的数据时,就会产生潜在的NullPointerException
风险。这会使代码更难以编写和维护,因为您需要不断地检查键和值是否为null
,以避免异常。一致性问题:在多线程环境下,如果一个线程在映射中插入了
null
键或值,而另一个线程在尝试读取或修改这些null
数据,可能会导致不一致的行为或结果。这会破坏了映射的一致性和可预测性。散列冲突问题:
ConcurrentHashMap
使用散列函数将键映射到桶(buckets)上,如果允许null
键,那么计算null
键的散列值可能会引发异常或者导致不正确的数据分布,因为null
没有散列值。这可能会导致映射无法正常工作。
总的来说,如果需要在多线程环境中安全地操作一个映射(键值对集合),则应该使用 ConcurrentHashMap
。如果只在单线程中使用或者可以手动同步的情况下,可以考虑使用 HashMap
。需要注意的是,在 Java 8 之后,ConcurrentHashMap
进行了一些改进,性能更好,因此在大多数情况下,它是更好的选择。
🔑🔑🔑 JDK 1.8不同于 JDK 1.7 版本的 Segment 数组 + HashEntry 链表,JDK 1.8 版本中的 ConcurrentHashMap 直接抛弃了 Segment 锁,一个 ConcurrentHashMap 包含一个 Node 数组(和 HashEntry 实现差不多),每个 Node 是一个链表结构,并且在链表长度大于一定值时会转换为红黑树结构(TreeBin)。
既然没有使用分段锁,如何保证并发安全性的呢?
synchronized + CAS!
简单来说,Node 数组其实就是一个哈希桶数组,每个 Node 头节点及其所有的 next 节点组成的链表就是一个桶,只要锁住这个桶的头结点,就不会影响其他哈希桶数组元素的读写。桶级别的粒度显然比 1.7 版本的 Segment 段要细。
🔨
ConcurrentHashMap
使用了synchronized
来确保多个线程可以安全地访问集合。这意味着对ConcurrentHashMap
的操作在方法级别或块级别上是同步的,以防止多个线程同时修改集合,从而确保线程安全。🔨
CAS
策略来实现并发性,允许多个线程同时读取和修改不同部分的哈希表,而不会阻塞整个表。这种方式提高了并发性能,因为只有在发生冲突时才需要同步。CAS
在这里用于更新桶(buckets)内的数据,从而实现更细粒度的并发。
🔑🔑🔑
JDK 1.7 中的 ConcurrentHashMap:
Segment-Based设计:
ConcurrentHashMap
使用了分段锁的设计。它将整个数据结构分为多个段(Segment),每个段维护自己的哈希表。ReentrantLock
),因此不同的段之间的操作可以并行执行,从而提高了并发性能。锁粒度:
并发度:
ConcurrentHashMap
可以支持多个读操作并发执行,但写操作需要获取段级别的锁,这可能限制了写操作的并发度。JDK 1.8 中的 ConcurrentHashMap:
CAS-Based设计:
ConcurrentHashMap
引入了基于 CAS
(Compare-And-Swap)的设计,摒弃了分段锁。锁粒度:
ConcurrentHashMap
不再使用段级别的锁。读操作和写操作都不需要全局锁或段级别的锁。CAS
操作和其他无锁算法来实现线程安全,降低了锁的竞争。并发度:
ConcurrentHashMap
在设计上提高了并发度,因为读操作和写操作不再需要锁定整个数据结构或段,而是使用了无锁技术。红黑树优化:
ConcurrentHashMap
会将链表转化为红黑树,以提高查找性能。这个优化有助于防止在极端情况下链表过长而导致的性能下降。总结:
ConcurrentHashMap
使用了分段锁来实现并发性,适用于一定程度的并发,但在高度并发写入场景下可能有性能限制。ConcurrentHashMap
引入了更先进的无锁技术,消除了分段锁,提高了并发性能。它还引入了树结构来优化链表,提高了查找性能。其在设计上更适合高度并发的情况,而且不再受限于分段锁的开销和复杂性。🔑🔑🔑
性能改进: synchronized
在 JDK 1.8 中得到了很大的性能改进。JVM 在某些情况下可以自动地优化 synchronized
块,使其比之前更加高效。这种性能改进降低了使用 synchronized
的代价,并且在许多情况下,性能已经接近于使用 ReentrantLock
,甚至更好。
代码简化: 使用 synchronized
可以简化代码,因为不再需要显式地创建和管理 ReentrantLock
对象。这减少了代码的复杂性,使得代码更容易理解和维护。
可读性: 使用 synchronized
可以使代码更具可读性,因为它是一种更常见的同步机制。这使得其他开发人员更容易理解代码,降低了代码的维护成本。
减少竞争: JDK 1.8 的 ConcurrentHashMap
内部结构经过了优化,减少了不必要的锁竞争,使得 synchronized
更具吸引力。此外,ConcurrentHashMap
在 JDK 1.8 中引入了一些新的技术,如 CAS 操作,以降低锁竞争。
总之,虽然 JDK 1.8 中的 ConcurrentHashMap
不再使用 ReentrantLock
,而是使用内置锁 synchronized
,但这个改变是经过深思熟虑的,主要出于性能改进、代码简化、可读性和竞争减少等因素的考虑。这使得 ConcurrentHashMap
在 JDK 1.8 中更具吸引力,能够更好地处理高度并发的情况。
🔑🔑🔑
负载因子(Load Factor)是与哈希表(Hash Table)相关的一个概念,它用来衡量哈希表的空间利用率。负载因子是一个小数,通常表示为 λ
(lambda),HashMap 扩容的计算公式是:
initialCapacity * loadFactor = HashMap 扩容,
其中,initialCapacity 是初始容量,默认值为 16(懒加载机制,只有当第一次 put 的时候才创建),loadFactor 是负载因子,默认值为 0.75。也就是说当 16 * 0.75 = 12 时,HashMap 就会开始扩容。
负载因子的值在 0 到 1 之间,它反映了哈希表的填充程度。较小的负载因子表示哈希表较空,而较大的负载因子表示哈希表较满。通常,负载因子的选择会影响哈希表的性能和空间占用。
为什么负载因子通常选择为 0.75? 选择负载因子为 0.75 是一个经验性的决策,它在很多情况下被认为是一个良好的折衷值,具有以下优点:
空间效率与性能的平衡: 较低的负载因子会导致哈希表的空间占用更小,但可能需要更频繁的扩容操作,这可能会增加性能开销。较高的负载因子会提高空间利用率,减少扩容操作的频率,从而提高性能。
避免哈希冲突的频繁发生: 较高的负载因子可以减少哈希冲突的频率。如果负载因子太低,哈希表容量过大,元素分布稀疏,容易导致不必要的内存浪费和哈希冲突的不可预测性。
均衡的性能: 0.75 的负载因子通常在保持空间效率的同时,提供了良好的性能。这个值在实际应用中已被证明是一个合理的选择。