hashMap&hashtable&ConcurrentMap区别

hashMap&hashtable&ConcurrentMap的介绍

HashTable
继承于Dictionary,实现了Map,Cloneable,Java.io.Serializable接口

  • 底层数组+链表实现,无论key还是value都不能为null,同步线程安全,实现线程安全的方式是锁住整个hashtable,效率低,concurrentMap做了相关优化。
  • 初始容量为11 扩容:newsize=oldsize*2+1
  • 两个参数影响性能:初始容量,加载因子(默认0.75)
  • 计算index方法:index=(hash&0x7FFFFFFF)%tab.length

HashMap

  • 底层数组+链表实现,可以存在null键和null值,线程不安全
  • 初始size为16 扩容:newsize=oldsize*2,size一定为2的n次幂
  • 扩容针对整个map,每次和扩容时,原数组的元素重新计算存放位置,并重新插入。
  • 插入元素后才判断是否需要扩容,若再无插入,无效扩容
  • 加载因子:默认0.75
  • 计算index方法:index=hash&(tab.length-1)
  • 空间换时间:如果希望加快Key查找的时间,还可以进一步降低加载因子,加大初始大小,以降低哈希冲突的概率。

ConcurrentMap

  • 底层采用分段的数组+链表实现,线程安全。
  • 通过把整个map分为N个Segment,可以提供相同的线程安全效率提升N倍,默认16倍。
  • 读操作不加锁,修改操作加分段锁,允许多个修改操作并行发生。
  • 扩容:段内扩容(段内元素超过该段对应的Entry数组的0.75,触发扩容,而不是整段扩容),插入前检测是否需要扩容,避免无效扩容。
    (有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁)。

存储结构中ConcurrentHashMap比HashMap多出了一个类Segment,而Segment是一个可重入锁。
ConcurrentHashMap是使用了锁分段技术来保证线程安全的。
锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。
ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。

ConcurrentMap JDK1.7与 JDK1.8的区别


面试必问:

1、HashMap的工作原理:

HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。

2、当两个对象的hashcode相同会发生什么?

    	String str1 = new String("xx");
    		String str2 = new String("xx");
    		System.out.println(str1 == str2);  ----false
    		
    		Map<String ,String> map = new IdentityHashMap<String ,String>();
    		map.put(str1, "hello");
    		map.put(str2, "world");
    --------------------- 
    for(Entry<String,String> entry : map.entrySet())
    		{
    			System.out.println(entry.getKey()+"   " + entry.getValue());
    		}
    		System.out.println("     containsKey---> " + map.containsKey("xx"));----false
    		System.out.println("str1 containsKey---> " + map.containsKey(str1));----true
    		System.out.println("str2 containsKey---> " + map.containsKey(str2));----true
    		System.out.println("  	  value----> " + map.get("xx")); ---null;
    		System.out.println("str1  value----> " + map.get(str1));----hello 
    		System.out.println("str2  value----> " + map.get(str2));----world

IdentityHashMap是靠对象来判断key是否相等的,如果我们一个key需要保存多个value的时候就需要使用到这个IdentityHashMap类,
hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。

3、如果两个键的hashcode相同,你如何获取值对象?

HashMap在链表中存储的是键值对,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。__----许多情况下,面试者会在这个环节中出错,因为他们混淆了hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作 final 的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

3.1、为什么String, Interger这样的wrapper类适合作为键?

String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。
因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

4、如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置

5、你了解重新调整HashMap大小存在什么问题吗?

当多线程的情况下,当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。

	public class HashMapInfiniteLoop {    
       
        private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);    
        public static void main(String[] args) {    
            map.put(5, "C");    
       
            new Thread("Thread1") {    
                public void run() {    
                    map.put(7, "B");    
                    System.out.println(map);    
                };    
            }.start();    
            new Thread("Thread2") {    
                public void run() {    
                    map.put(3, "A);    
                    System.out.println(map);    
                };    
            }.start();          
        }    
    } 

map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行扩容。

do {  
            Entry<K,V> next = e.next; //假设线程一执行到这里就被调度挂起了  
            int i = indexFor(e.hash, newCapacity);  
            e.next = newTable[i];  
            newTable[i] = e;  
            e = next;  
        } while (e != null); 
  

线程1、线程2都添加了数据之后,线程1执行到transfer()方法的第一行就被调度挂起了,这时线程2被调度来执行扩容操作。线程2的扩容操作结束之后,线程1被调度回来继续执行,此时由于线程2的执行,e已经指向了线程2修改之后的反转链表,但是线程1并不知道线程2已经在它之前做过这些操作了,于是它继续往下走,此时next=key(7),然后计算索引。索引计算完之后执行e.next=newTable[i],此时e.next=key(7)。继续往下走,newTable[i]=e,此时newTable[i]=key(3),再往下,e=next,此时e指向了key(7),本次循环结束。

一切看起来都还没有什么问题。然后新一轮循环开始

这一轮循环我们不需要走完,就能发现问题。

第一句,执行后为:next=null;

第二句,计算索引,还是i

第三句,在这里就出问题了,这句话执行的是e.next=newTable[i],newTable[i]指向的是key(3),因此出现链表末尾的元素的next指针指向了链表头,循环链表就出现了。(按道理,HashMap是不存在循环链表的。)

第四句话,将链表头的元素换成key(7),而循环链表依然存在。

第五句,e=null,执行到这循环结束,因为e=null了。

整个过程并不会发生明显的异常。看起来一切安好。顺利的完成了rehash,但是悲剧在后面:当我们调用get()这个链表中不存在的元素的时候,就会出现死循环。go die

并发环境下的rehash过程可能会带来循环链表,导致死循环致使线程挂掉。因此并发环境下,建议使用Java.util.concurrent包中的ConcurrentHashMap以保证线程安全。至于HashTable,它并未使用分段锁,而是锁住整个数组,高并发环境下效率非常的低,会导致大量线程等待。
同样的,Synchronized关键字、Lock性能都不如分段锁实现的ConcurrentHashMap。

猜你喜欢

转载自blog.csdn.net/qq_38973672/article/details/87296480