双列集合

双列集合的学习

preview

双列集合定义了映射java.util.Map,是以键值对的方式来存储元素的。接口有四种常用的实现类,分别为HashMap、Hashtable、LinkedHashMap和TreeMap

HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但它的遍历顺序是不确定的。HashMap最多允许一条记录的键位null,允许多条记录的值位null。是线程不安全的

Hashtable:与HashMap类似,不同的是它继承自Dictionary类,并且是线程安全的。但其并发性不如ConcurrentHashMap

LinkedHashMap:是HashMap的一个子类,保存了记录的插入顺序,在用迭代器遍历LinkedHashMap时,先得到的记录是先插入的,也可以在构造时带参数,按照访问次序排序

TreeMap:底层采用了红黑树的数据结构,实现了SortedMap接口,能够把它保存的记录根据键排序,默认是按健值的升序排序,也可以指定排序的比较器。存储对象时,key对象必须实现Comparable接口或者在构造TreeMap时传入自定义的Comparator,否则会在允许时抛出java.lang.ClassCastException类型的异常

HashMap的详细解释

数据结构:采用数组+链表的结构,以下是jdk8时的数据结构

preview

**HashMap的默认初始化容量为16,之后每次扩充,容量表为原来的2倍。**如果在创建时指定初始化容量,HashMap则会将其扩充为2的幂次方大小

1、HashMap存储数据的过程(JDK1.8)

​ HashMap通过key的hashcode经过扰动函数处理得到hash值,通过(n-1)&hash判断当前元素存放的位置,如果存在元素,则判断该元素与要存入的元素的hash值以及key是否相同,相同则覆盖,不相同则采用拉链法解决冲突

所谓的扰动函数即求hash值得过程,可以减少碰撞,JDK1.8源码如下:

static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^ :按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

即让key得hashcode值与右移16位的hashCode进行异或运算,求得hash值

JDK1.7求hash的源码如下:

static int hash(int h) {
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

相较于JDK1.8的方法,1.7求hash的方法性能上会稍微差一点点,毕竟扰动了四次

拉链法就是:将链表和数组结合起来,即当遇到hash冲突的时候,就将冲突的值存储到链表中

jdk1.8之前的内部结构-HashMap

JDK1.8之后,有了较大的改变,当链表的长度大于阈值(默认为8)时,将链表转化为红黑树,减少了搜索的时间。

img

2、HashMap的扩容机制

​ 在·HashMap中有个非常重要的字段,即Node[] table哈希桶数组(数组),默认长度为16,其长度必须为2的n次方。采用这种设计,主要是为了在取模和扩容时做优化,减少hash冲突。

取模时优化:在求hash值时,**“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)**并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

扩容时优化: 先讲下HashMap是如何扩容的。当数组容量不够时,进行resize操作重新计算容量。扩容时,使用新的数组代替小容量的数组。并重新计算hash值,具体如下:

void resize(int newCapacity) {   //传入新的容量
      Entry[] oldTable = table;    //引用扩容前的Entry数组
      int oldCapacity = oldTable.length;         
      if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
         threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
          return;
      }
   
      Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
     transfer(newTable);                         //!!将数据转移到新的Entry数组里
     table = newTable;                           //HashMap的table属性引用新的Entry数组
     threshold = (int)(newCapacity * loadFactor);//修改阈值
     }
//将原有的Entry数组元素存储到新的Entry数组里 
void transfer(Entry[] newTable) {
      Entry[] src = table;                   //src引用了旧的Entry数组
      int newCapacity = newTable.length;
      for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
         Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
          if (e != null) {
              src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
              do {
                  Entry<K,V> next = e.next;
                 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                 e.next = newTable[i]; //标记[1]
                 newTable[i] = e;      //将元素放在数组上
                 e = next;             //访问下一个Entry链上的元素
             } while (e != null);
         }
     }
 }

讲完扩容,那么来讲一讲容量采用2次幂上的优化,在进行rehash的时候,如果容量采用的是2次幂长度,那么重新计算的位置要么在元位置,要么在元位置+oldCap;

示例:图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

img

从中可以看出,重新计算的元素位置要么在原来的位置,要么在原来位置+oldCap位置,不需要像JDK1.7那样重新计算元素位置。

img

JDK1.8源码如下:

 1 final Node<K,V>[] resize() {
 2     Node<K,V>[] oldTab = table;
 3     int oldCap = (oldTab == null) ? 0 : oldTab.length;
 4     int oldThr = threshold;
 5     int newCap, newThr = 0;
 6     if (oldCap > 0) {
 7         // 超过最大值就不再扩充了,就只好随你碰撞去吧
 8         if (oldCap >= MAXIMUM_CAPACITY) {
 9             threshold = Integer.MAX_VALUE;
10             return oldTab;
11         }
12         // 没超过最大值,就扩充为原来的2倍
13         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14                  oldCap >= DEFAULT_INITIAL_CAPACITY)
15             newThr = oldThr << 1; // double threshold
16     }
17     else if (oldThr > 0) // initial capacity was placed in threshold
18         newCap = oldThr;
19     else {               // zero initial threshold signifies using defaults
20         newCap = DEFAULT_INITIAL_CAPACITY;
21         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22     }
23     // 计算新的resize上限
24     if (newThr == 0) {
25 
26         float ft = (float)newCap * loadFactor;
27         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28                   (int)ft : Integer.MAX_VALUE);
29     }
30     threshold = newThr;
31     @SuppressWarnings({"rawtypes""unchecked"})
32         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33     table = newTab;
34     if (oldTab != null) {
35         // 把每个bucket都移动到新的buckets中
36         for (int j = 0; j < oldCap; ++j) {
37             Node<K,V> e;
38             if ((e = oldTab[j]) != null) {
39                 oldTab[j] = null;
40                 if (e.next == null)
41                     newTab[e.hash & (newCap - 1)] = e;
42                 else if (e instanceof TreeNode)
43                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44                 else { // 链表优化重hash的代码块
45                     Node<K,V> loHead = null, loTail = null;
46                     Node<K,V> hiHead = null, hiTail = null;
47                     Node<K,V> next;
48                     do {
49                         next = e.next;
50                         // 原索引
51                         if ((e.hash & oldCap) == 0) {
52                             if (loTail == null)
53                                 loHead = e;
54                             else
55                                 loTail.next = e;
56                             loTail = e;
57                         }
58                         // 原索引+oldCap
59                         else {
60                             if (hiTail == null)
61                                 hiHead = e;
62                             else
63                                 hiTail.next = e;
64                             hiTail = e;
65                         }
66                     } while ((e = next) != null);
67                     // 原索引放到bucket里
68                     if (loTail != null) {
69                         loTail.next = null;
70                         newTab[j] = loHead;
71                     }
72                     // 原索引+oldCap放到bucket里
73                     if (hiTail != null) {
74                         hiTail.next = null;
75                         newTab[j + oldCap] = hiHead;
76                     }
77                 }
78             }
79         }
80     }
81     return newTab;
82 }

HashMap的常见问题

1、1.8和1.7的hashMap性能对比

​ 1.8链表采用的是尾插法,1.7采用的头插法。采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

​ 1.8和1.7的计算hash方式不同(详见上面描述),1.8计算hash方式扰动次数少,减少了hash冲突

​ 1.8和1.7在扩容的时候不同,1.8扩容进行元素重新定位时,不像1.7重新进行与运算,而是判断bit为1还是0,从而得到元素新的位置(原位置或者原位置+oldcap)

	1.8和1.7的链表不同,1.8在链表达到某个阈值(默认为8),会将链表转变为红黑树,提高了效率

2、HashMap和Hashtable的不同

​ HashMap是线程不安全的,HashTable是线程安全的。从效率上考虑,建议采用HashTable。从安全上建议采用ConcurrentHashMap

​ HashMap中允许一个键的值为null,而Hashtable中不允许有健值为null的,一有便会抛出异常

​ 初始化不指定容量时,HashTable的默认容量为11,之后的每次扩容,容量变为原来的2n+1.而HashMap的初始化容量为16,之后的每次扩容,将容量变为原来的两倍;如果指定初始化容量,Hashtable会直接使用给定的容量,而HashMap会将容量扩充为2的幂次方。

​ 数据结构上,1.8后,HashMap链表长度达到8阈值时,会将链表转变为红黑树。而HashTable没有这样的机制

ConcurrentHashMap

1、JDK1.7的ConcurrentHashMap底层采用分段数据+链表的形式

JDK1.7的ConcurrentHashMap

由Segment数组结构和HashEntry数组结构组成,Segment实现了ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

static class Segment<K,V> extends ReentrantLock implements Serializable {
}

一个Segment就包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着HashEntry数组里元素,要操作HashEntry数组的数据时,必须首先获得对应的Segment的锁。

**JDK1.8之后取消了Segment的分段锁,采用CAS和synchronized来保证并发安全。**数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N)))

JDK1.8的ConcurrentHashMap

整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;

2、Hashtable和HashMap的区别

底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

实现线程安全的方式(重要):在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

HashTable全表锁

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值