HashMap && HashTable && synchronizedMap&& ConcurrentHashMap 面试常问题
开始
Collections.synchronizedMap()
-
HashMap是线程不安全的,那么怎么解决线程不安全呢?
- 使用HashTable 代替
- 使用Collections.synchronizedMap() 代替
- 使用ConcurrentHashMap 代替
但是,最好用ConcurrentHashMap。
-
Collections.synchronizedMap() 怎么解决线程不安全的?
它的源码内部有一个final修饰的Map,而且还有一个独占对象锁。通过构造参数接收传过来的map给到自己的final map。然后给所有公共方法加锁,锁的是代码块。SynchronizedMap是Collections的私有内部静态类。
private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable { private static final long serialVersionUID = 1978198479659022715L; private final Map<K,V> m; // Backing Map final Object mutex; // Object on which to synchronize SynchronizedMap(Map<K,V> m) { this.m = Objects.requireNonNull(m); mutex = this; } SynchronizedMap(Map<K,V> m, Object mutex) { this.m = m; this.mutex = mutex; }
可见一个final 修饰的 Map 和一个final修饰的独占锁mutex
一部分公共方法
public int size() { synchronized (mutex) {return m.size();} } public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} } public boolean containsKey(Object key) { synchronized (mutex) {return m.containsKey(key);} } public boolean containsValue(Object value) { synchronized (mutex) {return m.containsValue(value);} } 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);} } public V remove(Object key) { synchronized (mutex) {return m.remove(key);} } public void putAll(Map<? extends K, ? extends V> map) { synchronized (mutex) {m.putAll(map);} } public void clear() { synchronized (mutex) {m.clear();} }
可见,都是被synchronized修饰的代码块。
HashTable
-
HashTable是什么?
HashTable是可以用与多线程场景下,并发安全的类。和HashMap类似,区别就是HashTable是线程安全的,而HashMap不是。但是HashTable性能差。
-
为什么性能差?
因为HashTable会将所有公共方法都加上锁,对方法加锁。就连get方法都是直接加锁
public synchronized V get(Object key) { Entry<?,?> tab[] = table;
-
HashTable和HashMap还有什么差别?
-
HashTable的key和value不能用null值,而HashMap可以。
为什么?
HashTable对于key为null 直接抛出空指针异常。而HashMap则是进行了处理。看下put函数
HashTable
public synchronized V put(K key, V value) { // Make sure the value is not null if (value == null) { throw new NullPointerException(); } int hash = key.hashCode();//这里没有对key为null处理,如果是key==null,则抛空指针异常
HashMap
//并没有判断value为null的代码。对于key的处理则是如果为null,返回hashcode为0 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这是代码层次的处理,具体为什么是指?
HashTable使用的是安全失败的机制,fail-safe。在这个机制下不能保证每次读取的值都是最新值。如果使用null,就会使其无法判断是不存在还是为null。因为无法调用一次contain(key)来对key是否存在判断,ConcurentHashMap同理。
ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m可能已经不同了,m为定位到的Segment,每次put值都可能会定位到不同的段。
fail-safe 是什么?
为了实现并发修改,这些支持并发的类都使用fail-safe。这些类在遍历的时候并不是访问原有集合,而是重新复制一份当前的数据。在此数据上遍历。这样其它线程修改原有数据就不会影响到遍历了。也有明显的缺点,就是不能遍历时知道遍历过程中原集合新的修改。
-
实现方式不同
Hashtable 继承了Dictionary类,而HashMap则是AbstractMap
-
初始化化容量不同
Hashtable,初始容量 11 而HashMap 16 默认负载因子都是0.75
-
扩容机制不同
HashMap 是 当前容量翻倍,而HashTable是当前容量翻倍+1
-
迭代器不同
HashMap迭代器有fail-fast,而Hashtable无
fail-fast,是java.util包下集合类都有的一个性质。主要用与多线程迭代器遍历下,能够快速失败,并不是在未来某个不确认的时间失败。如果迭代器遍历时,集合元素有更新,即新增、删除、修改。都会使迭代器抛出ConcurrentModificationException
实现:在迭代器遍历时,会将当前modCount记录到expectedmodCount。modCount是一个int形变量transient int modCount;int expectedModCount;
而每当有新增,修改,删除集合元素时都会时modCount改变。此时就和expectedmodCount不相等,即抛异常
-
ConcurrentHashMap
1.7版本
实现就和HashMap1.7差不多。就是加了分段Segment ,然后这个Segment里面有一部分HashEntry(这个可以说是一部分的数组) 也就是说一个段就相当于截取了一部分桶,然后加锁。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
// (fail—fast)
transient int modCount;
// 大小
transient int threshold;
// 负载因子
final float loadFactor;
}
可见Segment继承了ReentrantLock。HashEntry 和HashMap中的差不多,重点是用了volatile修饰,修饰了它的value和下一个节点next;
volatile的特性是啥?
-
保证可见性,对volatile读来说,其它线程对该变量的修改都可见。或者说volatile写,那么此变量的变化将对其它所有线程可见。
-
禁止指令重排序,有序性
-
单个变量原子性。
-
此版本的ConcurrentHashMap怎么实现的并发安全?为什么并发度高?
由上面可知,Segment可以认为是原数组的一个段而且继承了ReentranLock即线程访问此段会加锁。而一个ConcurrentHashMap会有很多个Segment(默认16) 即可以让16个线程同时对这16个段put元素。所有这就是分段锁。每一个段和每一个都互不影响,各自加锁。
public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException();//这就是为啥他不可以put null值的原因 int hash = hash(key); int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject (segments, (j << SSHIFT) + SBASE)) == null) s = ensureSegment(j); return s.put(key, hash, value, false); }
首先定位到Segment,之后调用此Segment的put方法,可以想象成又是一个小的HashMap。
final V put(K key, int hash, V value, boolean onlyIfAbsent) { // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry HashEntry<K,V> node = tryLock() ? null : //尝试获取锁,获取成功就加锁,进入put流程 scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash; HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e != null) { K k; // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。 if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。 if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { //释放锁 unlock(); } return oldValue; }
首先,第一步是通过tryLock获取锁,获取不到就进入scanAndLockForPut方法
- 自旋获取锁
- 自旋次数达到限定值,就阻塞该线程。保证会获取成功
那么get流程呢?
key通过hash定位到Segment上,然后再Hash定位到元素。而且HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。所以,这样是性能高的原因之一,get不加锁。
但是,随之HashMap改到了1.8,ConcurrentHashMap也改到了1.8.伴随改变,也就是链表长度过长会变红黑树,短了就变链表
1.8版本
数据结构
没有再使用Segment锁了,而是采用synchronized和cas+自旋操作。跟HashMap1.8类似,将HashEntry改成了Node。但是还是用volatile修饰value和next指针。保证可见性,也引入了红黑树
-
put/get流程
put流程是在for循环中的,保证新增元素一定成功
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode());//对比HashMap 多了一个(h ^ (h >>> 16)) & HASH_BITS 与运算 int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; //判断初始化 if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //如果计算出的下标位置为null,则cas设置新node,失败就自旋保证成功 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } //判断扩容,需要此线程帮忙迁移元素 else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { //这里就是有冲突了,会加锁写入。也就是这里保证了当前槽点只有一个线程可以访问 V oldVal = null; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; //这就和HashMap流程差不多了 for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && //判断链表中有没有当前key ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent)//通过参数,看覆盖不,默认是覆盖 e.val = value; break;//覆盖完成退出链表遍历 } Node<K,V> pred = e; if ((e = e.next) == null) {//无重复key则添加到链表末尾 pred.next = new Node<K,V>(hash, key, value, null); break; } } } else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } //binCount说明已经添加了 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD)//判断需要转红黑树吗 treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } //继承容器是不是要扩容,如果需要去扩容,调用 transfer 方法去扩容 // 如果已经在扩容中了,check有无完成 addCount(1L, binCount); return null; }
流程:
1. 进入for循环,判断有无初始化,未初始化则初始化之后继续进入for 2. 判断槽点是不是空,空的自旋cas设置node。再判断下是不是需要扩容 3. 有冲突,则加锁,锁槽点。遍历链表看有没有重复key,有则覆盖value。无则加到链表尾部 4. 看要不要转红黑树。 1. 看要不要扩容,并给整个table元素+1
CAS操作是什么?自旋又是什么?
CAS是乐观锁的一种实现,乐观锁就是认为并发问题不会多,只是在更新数据的时候,用cas判断一下。怎么判断呢?就是cas有一个当前值和内存预期值,比较这俩个值,如果没有修改,则说明在读取到这里比较这段时间里并没有其它线程修改。则直接将新的值赋值。自旋呢就是在cas失败的情况下保证可以把值加到集合,就是再来一遍流程
cas不能一定保证数据没有被其它线程修改过?ABA问题
解决可以加上版本号。
为什么jdk8用synchronized?
因为java官方修改了synchronized。在1.8之前都是重量级锁。而现在修改为,一开始是偏向锁,即一旦当前线程持有锁之后,就不会再次去获取锁,而是直接比较锁中记录的线程id。如果有竞争,则锁会升级为cas轻量级锁,失败就会短暂自旋,防止被挂起。最好如果以上都失败就换为重量级锁。
-
get流程
根据计算的hashcode之后计算的下标,直接在定位桶,如果在桶上就直接返回。是红黑树就按照红黑树寻找。是链表就按照链表。
-
addCount(1L, binCount);方法用于整个map统计元素个数。并发场景下,统计元素个数也是个难题。可以加volatile,然后cas+1,但是concurrentHashMap有着更优的计数。实现和LongAdder差不多
首先,是操作一个volatile baseCount 线程cas将值+1,成功返回,失败则将线程分散到不同的CounterCell [i]中,操作计数变量 volatile value +1.
扩容机制
-
addCount()扩容校验
private final void addCount(long x, int check) { CounterCell[] as; long b, s; //首先会判断当前数组as是否为null,为null说明可能竞争不激烈,就直接使用cas使baseCount+1. //如果cas失败,则进入if语句给当前线程分配一个格子 if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { CounterCell a; long v; int m; boolean uncontended = true;//字面意思为无竞争。 //进入if条件执行 fullAddCount(x, uncontended);有三种可能 //1.数组as为null //2.[ThreadLocalRandom.getProbe() & m 表示计算到的一个格子的下标 如果此格子为null 也进入 //3. as数组不空,格子不空,那么就cas操作使value+1,失败进入 if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null || !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) { fullAddCount(x, uncontended);//确保能够加+1 return; } if (check <= 1) return; s = sumCount(); } //检查扩容,put会默认会检查是不是要扩容 if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; //如果s(当前元素个数)>=扩容阈值 && tab!=null 且未达到最大容量 即1<<30 while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { //当前length的一个标记 int rs = resizeStamp(n); //sc小于0代表有线程在扩容 if (sc < 0) { //sc的高16位代表,sc>>>16 然后和rs比较。这俩此时都代表数据校验标识。如果不等于退出循环 //sc == rs + 1 ||sc == rs + MAX_RESIZERS 说的是扩容结束了,好像有bug //(nt = nextTable) == null 说明要扩容的数组还未创建 //transferIndex <= 0 说明不需要其它线程帮忙扩容了 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; //执行到这里还未break,说明当前线程还能帮助扩容,sc+1.代表扩容的线程数加+1 //sc 刚才说了高16位的意思,低16位代表的是正在帮助扩容的线程数 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } //执行到这里,代表sc为扩容阈值,也就是说之前从未有线程执行到这里,也就是说nextTable==null //因为如果有线程执行到这里,会将sc=SIZECTL 置为一个负数。 //扩容时,需要用到这个参数校验是否所有线程都全部帮助扩容完成。 else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) //扩容,第二个参数代表新表,传入null,则说明是第一次初始化新表(nextTable) transfer(tab, null); s = sumCount(); } } }
流程:
-
判断当前数组是不是为null,为null代表竞争还不激烈,可以cas操作baseCount+1
-
不为null,或者cas失败,代表竞争已开始。如果此时数组还为null或者线程进入的格子为null或者cas格子中的value失败。
那么就得靠fullAddCount()死循环解决了,这个方法确保能+1
-
加+1流程结束,可以开始判断扩容了,首先看有没有到达扩容阈值,且有没有高过上限或低过下限
-
可以扩容了,用sc<0来判断当前其它线程是不是正在扩容。正在扩容那么看看要不要本线程帮忙,不要就退出,要就sc+1帮
-
到扩容阈值,且无其它线程正在扩容,那么就自己来。自己来怎么让其它线程知道自己扩容,改变sc为负值,其它线程判断sc时就知道了。
-
-
transfer()扩容
先不看代码,真的长,不好理解,先来点概念。
扩容,只能有一个线程初始化nextTable(扩容后的新表),其它线程都是帮助迁移元素到新的数组的
上图为假设的数组长度为8的数组。所以线程帮助迁移数据的时候都从后往前开始。线程A首先进来迁移桶7和6的数据。而且当前线程迁移会有一个范围,限定能迁移的步长,此时其它线程就不能迁移这部分数据了。且每次固定步长推进,保证不遗漏。线程B进入,发现线程A正在帮忙,就推进2个长度,迁移4,5.为了标识什么时候终止迁移,会维护一个transferIndex,来表示全部线程推移的下标位置。比如说现在线程B到了4,5则transferIndex=4.线程A迁移万6,7则到了2,3同时transferIndex到了2.直到为0表示迁移结束。并且在addCount()那里也会劝退想要参与迁移的线程。
//这个类是一个标识,用来代表当前桶所以元素以迁移完毕 static final class ForwardingNode<K,V> extends Node<K,V> { final Node<K,V>[] nextTable; ForwardingNode(Node<K,V>[] tab) { //把当前桶的头结点的 hash 值设置为 -1,表明已经迁移完成, //这个节点中并不存储有效的数据 super(MOVED, null, null, null); this.nextTable = tab; } private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length, stride; //确定上面描述的步长,通过cpu数确定,最小是16 if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // subdivide range //初始化,addCount()保证只有一个线程能进入此if if (nextTab == null) { // initiating try { @SuppressWarnings("unchecked") //数组长度翻倍 Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; nextTab = nt; } catch (Throwable ex) { // try to cope with OOME sizeCtl = Integer.MAX_VALUE; return; } //新表 nextTable = nextTab; //推进下标值,迁移完毕为0,此时为元素个数 transferIndex = n; } int nextn = nextTab.length;//新数组长度 //创建一个标志类,当每次处理完一个桶,就用来表示当前桶已处理完毕 ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); //是否向前推进的标志 boolean advance = true; //是否所有线程都全部迁移完成的标志 boolean finishing = false; // to ensure sweep before committing nextTab //i 代表当前线程正在迁移的桶的下标,bound代表它本次可以迁移的范围下限 for (int i = 0, bound = 0;;) { Node<K,V> f; int fh; while (advance) {//需要向前推进,transferIndex还没有==0 int nextIndex, nextBound; //--i,如果符合条件说明当前已经不需要推进了,结束循环 if (--i >= bound || finishing) advance = false; // 已经拷贝完成,满足条件说明无需推进了,都有线程处理了,之后判断线程都处理完毕没 else if ((nextIndex = transferIndex) <= 0) { i = -1; advance = false; } //第一个线程会执行这个,cas将nextIndex更新为 transferIndex 的最新值 else if (U.compareAndSwapInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) { bound = nextBound;//初始化步长,就是当前线程可以处理的桶数 i = nextIndex - 1;//当前线程在哪个桶 advance = false;//还未迁移了,此时在初始化限定值,就不能继续推进了 } } if (i < 0 || i >= n || i + n >= nextn) { int sc; //若全部线程都迁移完毕,就是说扩容结束了 if (finishing) { nextTable = null; table = nextTab; //设置table为新数组 sizeCtl = (n << 1) - (n >>> 1); // 阈值为新数组的0.75 return; } //这里,说明此线程已无用,即完成了自己的所有迁移,不管是多少次 //SIZECTL 帮助迁移的线程数-1 if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { //这里校验当前sc是不是和初始化新表的那个线程设置的sc相等,相等代表所有线程都迁移完毕了, //也就是说扩容结束了 if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) return; finishing = advance = true; i = n; // recheck before commit } } //若i的位置元素为空,则说明当前桶的元素已经被迁移完成,就把头结点设置为fwd标志。 else if ((f = tabAt(tab, i)) == null) advance = casTabAt(tab, i, null, fwd); //若当前桶的头结点是 ForwardingNode ,说明迁移完成,则向前推进 else if ((fh = f.hash) == MOVED) advance = true; // already processed else { //处理当前桶的数据迁移。 synchronized (f) { //头结点加锁,只有一个线程可以处理 if (tabAt(tab, i) == f) { Node<K,V> ln, hn; if (fh >= 0) { int runBit = fh & n; Node<K,V> lastRun = f; for (Node<K,V> p = f.next; p != null; p = p.next) { int b = p.hash & n; if (b != runBit) { runBit = b; lastRun = p; } } if (runBit == 0) { ln = lastRun; hn = null; } else { hn = lastRun; ln = null; } for (Node<K,V> p = f; p != lastRun; p = p.next) { int ph = p.hash; K pk = p.key; V pv = p.val; if ((ph & n) == 0) ln = new Node<K,V>(ph, pk, pv, ln); else hn = new Node<K,V>(ph, pk, pv, hn); } setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd); advance = true; } else if (f instanceof TreeBin) { TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> lo = null, loTail = null; TreeNode<K,V> hi = null, hiTail = null; int lc = 0, hc = 0; for (Node<K,V> e = t.first; e != null; e = e.next) { int h = e.hash; TreeNode<K,V> p = new TreeNode<K,V> (h, e.key, e.val, null, null); if ((h & n) == 0) { if ((p.prev = loTail) == null) lo = p; else loTail.next = p; loTail = p; ++lc; } else { if ((p.prev = hiTail) == null) hi = p; else hiTail.next = p; hiTail = p; ++hc; } } ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : (hc != 0) ? new TreeBin<K,V>(lo) : t; hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : (lc != 0) ? new TreeBin<K,V>(hi) : t; setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd); advance = true; } } } } } }
-
协助扩容:helpTransfer(),当线程对当前桶执行put操作时,刚刚好正在扩容。那么插入也执行不了。被置为 forwardingNode。因此线程也不是只等待,而是会帮助扩容
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) { Node<K,V>[] nextTab; int sc; //头结点为 ForwardingNode ,并且新数组已经初始化 if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) { // 根据 length 得到一个标识符号 int rs = resizeStamp(tab.length); while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) { //若校验标识失败,或者已经扩容完成,或推进下标到头,则退出 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || transferIndex <= 0) break; //帮助扩容 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { transfer(tab, nextTab); break; } } return nextTab; } return table; }
参考:
【JUC源码】并发容器:ConcurrentHashMap(三)扩容源码分析
我就知道面试官接下来要问我 ConcurrentHashMap 底层原理了
ConcurrentHashMap & Hashtable