HashMap
Java7 实现 (数组 + 链表)
从上图可以看出,在 Java7 中 HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。
负载因子
有序数组存储数据,对数据的索引效率都很高,但是插入和删除就会有性能瓶颈。(如ArrayList)
链表存储数据,要依次比较元素来检索出数据,所以索引效率低,但是插入和删除效率高。(如LinkedList),两者取长补短就产生了哈希散列这种存储方式,也就是 HashMap 的存储逻辑。
HashMap 的底层结构是哈希表 ,是以键值对形式存储的。在向 HashMap 存放数据的过程中,首先会通过 hash() 方法计算出哈希值才能知道数据要存储在哈希表中的某个位置。
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
public final int hashCode() {return Objects.hashCode(key) ^ Objects.hashCode(value);}
但是这样存储有问题。
存储数据时先通过哈希算法计算出要存储的位置,但是有可能该位置已经有数据存储了,这时候就会产生哈希冲突的问题。
如果为了避免哈希冲突,而增大数组容量来减少哈希冲突问题,有可能会导致数组空间浪费或者空间过小的问题。而负载因子就是决定 HashMap 的数据密度。
公式:初识化容量(initailCapacity)* 负载因子(loadFactor)= HashMap 的容量。
HashMap 有三个构造函数,可以选用有参构造函数设置初始化容量和负载因子。官方的建议是 initailCapacity 设置成 2 的 n 次幂,laodFactor 根据业务需求,如果迭代性能不是很重要,可以设置大一些。
也可以选用无参构造函数,不进行设置,此时会使用默认值,分别是 16 和 0.75。
/**
* The default initial capacity - MUST be a power of two.
*/static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16/**
* The load factor used when none specified in constructor.
*/static final float DEFAULT_LOAD_FACTOR = 0.75f;
- capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
- loadFactor:负载因子,默认为 0.75。
- threshold:扩容的阈值,等于 capacity * loadFactor
Java8 实现(数组 + 链表 + 红黑树)
从上图可以看出,Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组 + 链表 + 红黑树 组成。
根据 Java7 HashMap 的讲解可以知道,HashMap 在查找数据的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到需要的数据,这样时间复杂度就取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的 元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
树化分析
下面通过 HashMap 的源代码来分析一下:
这里三个值分别代表:树化阈值 = 8 ,反树化阈值 = 6,hash 表中最小数据值 = 64。
由这段代码可以看出,当链表长度大于 TREEIFY_THRESHOLD = 8 时,会进行树化。
我们看树化方法,首先进行的判断是哈希数组的容量是否大于 MIN_TREEIFY_CSPSCITY= 64。如果小于,则进行扩容操作,而不是树化操作。
综上所述,HashMap 进行树化的条件其实有两个:
- 链表长度大于 TREEIFY_THRESHOLD
- 哈希数组的长度大于 MIN_TREEIFY_CAPACITY
HashMap 是 Java 中非常强大的数据结构,使用频率非常高,几乎所有的应用程序都会用到它。但 HashMap 不是线程安全的,不能在多线程环境下使用,该怎么办呢?
Hashtable
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {
public synchronized V put(K key, V value) {}
public synchronized int size() {}
public synchronized V get(Object key) {}
}
Hashtable 里面的方法全部是synchronized,同步力度非常大,但是性能就没法保证了。
吐槽:t是小写。。。
synchronizedMap
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
public int size() {
synchronized (mutex) {return m.size();}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
}
可以看出,SynchronizedMap 确实比 Hashtable 改进了,synchronized 不再放在方法上,而是放在方法内部,作为同步块出现,但仍然是对象级别的同步锁,读和写操作都需要获取锁,本质上,仍然只允许一个线程访问,其他线程被排斥在外。
ConcurrentHashMap
ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表 ” 部分 “ 或 ” 一段 “的意思,所以很多地方都会将其描述为分段锁。注意:很多地方用了“槽” 来代表一个 Segment。
线程安全:因为 Segment 继承 ReentrantLock 加锁。
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
Java 7 实现
ConcurrentHashMap 有 16 个 Segments,所以理论上最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。
这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
Java8 实现
Java8 对 ConcurrentHashMap 进行了比较大的改动,Java8 也引入了红黑树。
Java8 不再采用分段锁的机制了,而是利用 CAS(Compare and Swap,即比较并替换,实现并发算法时常用到的一种技术)和 synchronized 来保证并发,虽然内部仍然定义了 Segment,但仅仅是为了保证序列化时的兼容性。我们根据注释可以看出: