a. 更快的搜索和插入速度
红黑树是一种自平衡二叉搜索树,因此查找和插入操作的时间复杂度为 O(log n),而链表的时间复杂度为 O(n)。在哈希冲突比较严重的情况下,使用红黑树能够更快地进行搜索和插入操作。
b. 更稳定的性能
红黑树是“近似平衡”的。相比AVL树,在检索的时候效率其实差不多,都是通过平衡来二分查找。
但对于插入删除等操作效率提高很多。红黑树不像AVL树一样追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,AVL树调平衡有时候代价较大,所以效率不如红黑树,在现在很多地方都是底层都是红黑树。
c. 更好的迭代性能
在 Java 8 中,当 HashMap 内部使用红黑树时,元素是按照键的自然顺序排序的。这意味着在迭代 HashMap 的元素时,元素的顺序是可预测的,而不是像使用链表时那样随机的。
加载因子就是表示Hash表中元素的填满程度。
加载因子 = 填入表中的元素个数 / 散列表的长度
加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。
默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。
如果我们把负载因子设置成1,容量使用默认初始值16,那么表示一个HashMap需要在"满了"之后才会进行扩容。那么在HashMap中,最好的情况是这16个元素通过hash算法之后分别落到了16个不同的桶中,否则就必然发生哈希碰撞。而且随着元素越多,哈希碰撞的概率越大,查找速度也会越低。
如果负载因子设置为0.5,那么就会频繁的扩容,浪费空间。
loadFactor=0.75科学依据:
a .根据数学公式推算。负载因子为log(2)的时候,可以既减少哈希冲突,又浪费空间,是时间和空间的权衡。
log(2)大约为0.7。
b.根据HashMap的扩容机制,应该保证capacity的值永远都是2的幂。
为了保证负载因子(loadFactor) * 容量(capacity)的结果是一个整数,这个值是0.75(3/4)比较合理,因为这个数和任何2的幂乘积结果都是整数。
dk1.7的数据丢失、死循环问题在JDK1.8中已经得到了很好的解决,直接在HashMap的resize()中完成了数据迁移。
JDK1.8会出现数据覆盖的情况:
源码在判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完该行判断代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之外,pullVal方法中还有框中代码的有个++size语句,如果还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的size大小为10,当线程A执行到if(++size > threshold)代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。
以 JDK 1.7 为例,当多线程并发扩容时就会出现环形引用的问题,从而导致死循环的出现,一直死循环就会导致 CPU 运行 100%,所以在多线程使用时,我们需要使用 ConcurrentHashMap 来替代 HashMap
HashMap 发生死循环的一个重要原因是 JDK 1.7 时链表的插入是头部倒序插入的,而 JDK 1.8 时已经变成了尾部插入
以 JDK 1.7 为例,假设 HashMap 的默认大小为 2,HashMap 本身中有一个键值 key(5),我们再使用两个线程:t1 添加 key(3),t2 添加 key(7),首先两个线程先把 key(3) 和 key(7) 都添加到 HashMap 中,此时因为 HashMap 的长度不够用了就会进行扩容操作,然后这时线程 t1 在执行到 Entry
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; // 线程一执行此处
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
那么此时线程 t1 中的 e 指向了 key(3),而 next 指向了 key(7) ;之后线程 t2 重新 rehash 之后链表的顺序被反转,链表的位置变成了 key(5) -> key(7) -> key(3),其中 “->” 用来表示下一个元素,当 t1 重新获得执行权之后,先执行 newTalbe[i] = e 把 key(3) 的 next 设置为 key(7),而下次循环时查询到 key(7) 的 e.next 为 key(3),于是就形 成了 key(3) 和 key(7) 的环形引用,就导致了死循环的产生
哈希冲突:当两个不同的数经过哈希函数计算后得到了同一个结果,即他们会被映射到哈希表的同一个位置时,即称为发生了哈希冲突。简单来说就是哈希函数算出来的地址被别的元素占用了。
解决哈希冲突办法
1、开放定址法:我们在遇到哈希冲突时,去寻找一个新的空闲的哈希地址。
如:线性探测法
当我们的所需要存放值的位置被占了,我们就往后面一直加1并对m取模直到存在一个空余的地址供我们存放值,取模是为了保证找到的位置在0~m-1的有效空间之中。
公式:h(x)=(Hash(x)+i)mod (Hashtable.length);(i会逐渐递增加1)
平方探测法(二次探测)
当我们的所需要存放值的位置被占了,会前后寻找而不是单独方向的寻找。
公式:h(x)=(Hash(x) +i)mod (Hashtable.length);(i依次为+(i^2)
和-(i^2))
2、再哈希法:同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是增加了计算时间。
3、链地址法:将所有哈希地址相同的记录都链接在同一链表中。
首先根据 hash 方法获取到 key 的 hash 值
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
然后通过 hash & (length - 1) 的方式获取到 key 所对应的Node数组下标 ( length是数组长度 )
首先判断此结点是否为空,是否就是要找的值,是则返回空,否则进入第二个结点。
接着判断第二个结点是否为空,是则返回空,不是则判断此时数据结构是链表还是红黑树
链表结构进行顺序遍历查找操作,每次用 == 符号 和 equals( ) 方法来判断 key 是否相同,满足条件则直接返回该结点。链表遍历完都没有找到则返回空。
红黑树结构执行相应的 getTreeNode( ) 查找操作。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//Node数组不为空,数组长度大于0,数组对应下标的Node不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
//也是通过 hash & (length - 1) 来替代 hash % length 的
(first = tab[(n - 1) & hash]) != null) {
//先和第一个结点比,hash值相等且key不为空,key的第一个结点的key的对象地址和值均相等
//则返回第一个结点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果key和第一个结点不匹配,则看.next是否为空,不为null则继续,为空则返回null
if ((e = first.next) != null) {
//如果此时是红黑树的结构,则进行处理getTreeNode()方法搜索key
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//是链表结构的话就一个一个遍历,直到找到key对应的结点,
//或者e的下一个结点为null退出循环
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
1.继承的父类不同
HashMap继承自AbstractMap类。但二者都实现了Map接口。
Hashtable继承自Dictionary类,Dictionary类是一个已经被废弃的类。
2.HashMap线程不安全,HashTable线程安全
在hashmap的put方法调用addEntry()方法,假如A线程和B线程同时对同一个数组位置调用addEntry,两个线程会同时得到现在的头结点,然后A写入新的头结点之后,B也写入新的头结点,那B的写入操作就会覆盖A的写入操作造成A的写入操作丢失。
故解决方法就是使用 使用ConcurrentHashMap。
同时HashMap的迭代器(Iterator)是fail-fast迭代器,故当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException异常,而Hashtable的enumerator迭代器不是fail-fast的。但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。
3.包含的contains方法不同
HashMap是没有contains方法的,而包括containsValue和containsKey方法;hashtable则保留了contains方法,效果同containsValue,还包括containsValue和containsKey方法。
4.是否允许null值
Hashmap是允许key和value为null值的,用containsValue和containsKey方法判断是否包含对应键值对;HashTable键值对都不能为空,否则报空指针异常。
5.计算hash值方式不同
HashMap计算hash值:
先调用hashCode方法计算出来一个hash值,再将hash与右移16位后相异或,从而得到新的hash值
Hashtable计算hash值:
通过计算key的hashCode()来得到hash值
6.扩容机制不同:
当已用容量>总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 +1。
明确插入null的区别是:
HashMap可以存放一个键是null,多个值是null 的对象,
而Hashtable则不可以存放键为null,或者是值为null的对象
为什么HashTable不能存null键和null值?
原因:
当value值为null时主动抛出空指针异常
key值会进行哈希计算,如果为null的话,无法调用该方法,还是会抛出空指针异常
public synchronized V put(K key, V value) {
// 确认值不为空
if (value == null) {
throw new NullPointerException(); // 如果值为null,则抛出空指针异常
}
// 确认值之前不存在Hashtable里
Entry<?,?> tab[] = table;
int hash = key.hashCode(); // 如果key如果为null,调用这个方法会抛出空指针异常
int index = (hash & 0x7FFFFFFF) % tab.length;//计算存储位置
//遍历,看是否键或值对是否已经存在,如果已经存在返回旧值
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
为什么HashMap可以存null键和null值?
HashMap在put的时候会调用hash()方法来计算key的hashcode值,
HashMap的hash算法指定了:当key==null时返回的值为0。
因此key为null时,hash算法返回值为0,不会调用key的hashcode方法。
//当key==null时hash的值为0
//一个hashmap对象只会存储一个key为null的键值对,因为它们的hash值都为0
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
JDK1.7 中的 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
...
}
Segment 是 ConcurrentHashMap 的一个内部类,继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。
存放元素的 HashEntry,也是一个静态内部类,其中,用 volatile 修饰了 HashEntry 的数据 value 和 下一个节点 next,保证了多线程环境下数据获取时的可见性
JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加细粒度的锁。
将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。
ConcurrentHashMap在JDK1.8中使用内置锁Synchronized来替换ReentractLock重入锁
锁粒度降低了;官方对synchronized进行了优化和升级,使得synchronized不那么“重”了,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
在大数据量的操作下,对基于API的ReentractLock进行操作会有更大的内存开销,假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。