最近面试面了不少了,总是被问到HashMap,有问HashMap和TreeMap区别的,有问HashMap和LinkedList区别的各种,反正就是排列组合。于是就搜集了一下,结合自己的理解,进行一下总结。如有错漏还请在评论区帮忙指出!
看总结直接拉到底。
还未了解的部分:各集合的迭代器。序列化接口部分。
hash值和hashCode:
Java令所有数据类型都继承了一个能够返回32比特整数的hashCode()方法。
每一种数据类型的hashCode方法必须与equals方法一致。
hashCode与equals的关系不再详述了。
String的hashCode():
private final char value[];
/**
* Returns a hash code for this string. The hash code for a
* {@code String} object is computed as
*
* s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
*
* using {@code int} arithmetic, where {@code s[i]} is the
* ith character of the string, {@code n} is the length of
* the string, and {@code ^} indicates exponentiation.
* (The hash value of the empty string is zero.)
*
* @return a hash code value for this object.
*/
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
从注释里可以看到,String的hashCode返回的为:
s[0]*31^(n-1) + s[1]*31^[n-1] + ... + s[n-1] (这里的^是乘方的意思,与后面的按位异或不要搞混)
例子:一个String s = "123";
它的hashCode为: '1' * 31^(3-1) + '2' * 31^(3-2) + '3' * 31^(3-3)
这里char需转为int。可以计算出hashCode为48690。
另外,可以去查看一下源码,会发现Integer的hashCode就是它的值。
为什么这样:
散列表希望hashCode()方法能够将键平均地散布为所有可能的32位整数。
取31。主要是因为31是一个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。
但是在HashMap中并不是直接使用的hashCode()所产生的值来作为索引的。且看下段代码:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这段代码的意思是:(摘自)
hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置.
key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。(h >>> 16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。)
为什么要这样呢?连接里的文章有说,但我没看明白。思想大概就是使碰撞的概率最小。
其他的,比如HashTable里的hash计算方法是不一样的,具体看源码就行了。
HashMap:
从源码中可以看到,HashMap继承了AbstractMap类(一个抽象类),然后AbstractMap又实现了Map接口。
HashMap本身也有hashCode方法,是是现在AbstractMap里的,就是把所有Node的hashCode加起来了。
public int hashCode() {
int h = 0;
Iterator> i = entrySet().iterator();
while (i.hasNext())
h += i.next().hashCode();
return h;
}
而Node的hashCode怎么计算呢?就是把key和value的hashCode按位异或一下。
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
HashMap的MAXIMUM_CAPACITY 为 1<<30,也就是2的30次方。
HashMap的DEFAULT_LOAD_FACTOR为0.75。
HashMap的default initial capacity为16。扩容时候翻倍。
HashMap的构造器有四种,分别是 默认,可以改initialCapacity,可以改loadFactor和initialCapactiy,在构造时直接创建一个Node。
HashMap的get(),containsKey()等,都用到了getNode ()方法。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node[] table;
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
具体为传入key的hash值,以及key本身。然后再getNode方法里找。这里的table暂且按下不表,这里只把table的注释挂上来,应该可以解释一二。
首先判断table是不是空的,也就是判断hashmap是不是空的,如果是空的就返回null好了。
然后这一行
(first = tab[(n - 1) & hash]) != null
说实话没有看明白,猜想应该是按位与找在数组里对应这个hash值的位置是否存有东西。如果这个不为null的话,就代表hash值至少是对上了,通过了数组这一关,可以去链表里找一找了。如果没有的话,就再hash值这一关被卡死了。
然后
if (first instanceof TreeNode)
只有有一行代码判断该节点是否为TreeNode。这是因为在java8里,如果链表的长度超过一定值(记得是8),就会把链表转为一个红黑树来储存。
然后就是put。hashMap的put是通过putVal方法来实现的。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
首先,如果是第一次存入东西,就要开辟空间。
然后,如果新存入的key的hash值没有和原有的产生碰撞,就再table[(n - 1) & hash]位置新放一个Node,并存入新的键值对。
如果hash值碰撞了,就检测key是否已存在,检测结果有:单个键值对Node,链表,树。根据不同的结果做不同的事情。反正就是覆盖原始的键值对。
最后返回一个老的value。
上面看了一下HashMap常用方法的源码。接下来讲一下hashmap的特点。
1.Hash算法就是根据某个算法将一系列目标对象转换成地址,当要获取某个元素的时候,只需要将目标对象做相应的运算获得地址,直接获取。
2.线程不安全。
3.快。因为用了数组和链表,所以插入、删除、定位都很快。
4.每次扩容会重新计算key的hash值,消耗资源
5.如果key是自己的实现的,必须时间hashcode和euqels方法。
6.无序的,key不可重复,可以为null(只一个),vlaue可以重复和为null。.
Hashtable:
最大的不同点:这个Hashtable没有按照驼峰命名法!!
Hashtable继承的是Dictionary类。而Dictionary类里自己都说了这个类已经过时了,让人改用别的类。Hashtable自己也是快死的类了。没事别用它了。
* NOTE: This class is obsolete. New implementations should
* implement the Map interface, rather than extending this class.
initialCapacity 为11。扩容时候*2+1。
loadFactor为0.75。
它的hash计算法与HashMap里的不一样。
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
它的put的思想也是检测是否之前已经与相同的key了,如果有就更新,没有就新加一个。
但是,它的方法都是声明了synchronized的,也就是说,它是线程安全的。
public synchronized V put(K key, V value)
public synchronized V remove(Object key)
public synchronized void putAll(Map extends K, ? extends V> t)
public synchronized void clear()
public synchronized Object clone()
....
一个疑问:
看了hashtable的源码之后,在put以及addEntry的代码里并没有找到它解决碰撞的方法。但看别人的文章说的是它解决碰撞的方法也是拉链法。还请懂的人在评论区告诉我一下。。
hashtable的特点:
1.线程安全的。自然也就慢了。
2.几乎被废弃的(父类都被废弃了)。
3.不允许null为key/value。
4.未遵循驼峰命名法。
5.重新计算hash、自己实现类作为key要重写hashcode equals、无序。这些跟hashmap是一样的。
TreeMap:
继承了AbstractMap抽象类。实现了NavigableMap接口。实现了Cloneable接口。实现了java.io.Serializable接口。
public interface NavigableMap extends SortedMap
这个接口的主要方法有:lowerEntry、floorEntry、ceilingEntry 和 higherEntry 分别返回与小于、小于等于、大于等于、大于给定键的键关联的 Map.Entry 对象,如果不存在这样的键,则返回 null。类似地,方法 lowerKey、floorKey、ceilingKey 和 higherKey 只返回关联的键。所有这些方法是为查找条目而不是遍历条目而设计的。摘自
也就是说,它支持一系列的导航方法,比如返回有序的key集合。
它可以被克隆,支持序列化。
TreeMap跟HashMap不一样,TreeMap用了红黑树来实现了。
TreeMap的基本操作 containsKey、get、put 和 remove 的时间复杂度是 log(n) 。
首先看一下TreeMap的构造函数:
public TreeMap() {
comparator = null;
}
public TreeMap(Comparator super K> comparator) {
this.comparator = comparator;
}
public TreeMap(Map extends K, ? extends V> m) {
comparator = null;
putAll(m);
}
public TreeMap(SortedMap m) {
comparator = m.comparator();
try {
buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
} catch (java.io.IOException cannotHappen) {
} catch (ClassNotFoundException cannotHappen) {
}
}
分别是无比较器,指定比较器,无比较器但是putAll一个map,以及指定比较器和一个SortedMap创建一个有着相同比较器和相同元素的map。
然后TreeMap的containsKey,get等方法都调用了getEntry方法来实现。
/**
* Returns this map's entry for the given key, or {@code null} if the map
* does not contain an entry for the key.
*
* @return this map's entry for the given key, or {@code null} if the map
* does not contain an entry for the key
* @throws ClassCastException if the specified key cannot be compared
* with the keys currently in the map
* @throws NullPointerException if the specified key is null
* and this map uses natural ordering, or its comparator
* does not permit null keys
*/
final Entry getEntry(Object key) {
// Offload comparator-based version for sake of performance
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable super K> k = (Comparable super K>) key;
Entry p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
/**
* Version of getEntry using comparator. Split off from getEntry
* for performance. (This is not worth doing for most methods,
* that are less dependent on comparator performance, but is
* worthwhile here.)
*/
final Entry getEntryUsingComparator(Object key) {
@SuppressWarnings("unchecked")
K k = (K) key;
Comparator super K> cpr = comparator;
if (cpr != null) {
Entry p = root;
while (p != null) {
int cmp = cpr.compare(k, p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
}
return null;
}
首先判断comparator是否为null。差别就在与有没有指定比较器。
如果comparator不为null,调用另一个方法。如果comparator为null,就遍历树,查找有没有相符的key,如果没有相符的key就返回null。有就返回该Entry。
如果comparator不为null。调用getEntryUsingComparator(Object key)方法。就使用comparator的方式来遍历树并查找。
这里涉及到了comparator和comparable的差别。暂且按下不表。
TreeMap的put方法:典型的红黑树插入数据
public V put(K key, V value) {
Entry t = root;
if (t == null) {
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry parent;
// split comparator and comparable paths
Comparator super K> cpr = comparator;
if (cpr != null) {
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable super K> k = (Comparable super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
Entry e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
首先,判断root是否为null,如果root为null,新加入的节点就是root了。并将modCount++(表示树被更改的次数);size++;
如果新插入的节点不是第一个,就要按照规则找地方插入。同样的,分为有没有指定comparator两种。这里用了if-else来区分。
如果找到了原先就在树里的key,就setValue来更新值。并返回。
如果key不在树里(直到找到的节点为null),则新建一个键值对,并将其放在树的新节点上。然后重排红黑树,按下不表,如需要深入了解红黑树且看另一篇文章。
同样的,modCount和size要变化。
TreeMap的特点:
1.红黑树实现。有红黑树的优缺点。
2.红黑树中的key对象必须实现Comparable接口
3.不允许null key,但允许null value。
4.非线程安全。
5.无序(元素与添加顺序不一致),有序(键值大小有序。红黑树本身就是二叉查找树)
6.可以方便地实现排序、比较等方法。
7.TreeMap的增删改查和统计相关的操作的时间复杂度都为 O(logn)。比hashmap慢。
ConcurrentHashMap:
继承了AbstractMap接口,实现了Serializable接口。
这个与HashMap最大的区别就是,线程安全的。但是,怎么实现线程安全的呢?
Hashtable也是线程安全的,但是用的是synchronized,在加锁的时候会把整个都锁住。
但是ConcurrentHashMap只锁住Map的一部分,所以性能更好了。
ConcurrentHashMap由一个个的Segment组成,即为分段锁。ConcurrentHashMap即为一个Segment数组,Segment通过集成ReentrantLock来进行加锁。所以每次需要加锁的操作,锁住的为一个Segment。通过保证每个Segment的线程安全,来保证全局的线程安全。
Segment:
static class Segment extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
看不大懂,
长度为16,不可扩容。
来看看put过程。这里因为我也不大懂,第一次看,就摘抄一下
public V put(K key, V value) {
Segment s;
if (value == null)
throw new NullPointerException();
// 1. 计算 key 的 hash 值
int hash = hash(key);
// 2. 根据 hash 值找到 Segment 数组中的位置 j
// hash 是 32 位,无符号右移 segmentShift(28) 位,剩下低 4 位,
// 然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的最后 4 位,也就是槽的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 刚刚说了,初始化的时候初始化了 segment[0],但是其他位置还是 null,
// ensureSegment(j) 对 segment[j] 进行初始化
if ((s = (Segment)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
// 3. 插入新值到 槽 s 中
return s.put(key, hash, value, false);
}
通过hash来找到Segment。
然后第二层:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 segment 写入前,需要先获取该 segment 的独占锁
// 先看主流程,后面还会具体介绍这部分内容
HashEntry node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
// 这个是 segment 内部的数组
HashEntry[] tab = table;
// 再利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
HashEntry first = entryAt(tab, index);
// 下面这串 for 循环虽然很长,不过也很好理解,想想该位置没有任何元素和已经存在一个链表这两种情况
for (HashEntry e = first;;) {
if (e != null) {
K k;
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 {
// node 到底是不是 null,这个要看获取锁的过程,不过和这里都没有关系。
// 如果不为 null,那就直接将它设置为链表表头;如果是null,初始化并设置为链表表头。
if (node != null)
node.setNext(first);
else
node = new HashEntry(hash, key, value, first);
int c = count + 1;
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 其实就是将新的节点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
// 解锁
unlock();
}
return oldValue;
}
在往segment里写之前,要先获取segment的独占锁。后面的操作和Hashmap的差不多,在修改完后,需要unlock。
初始化Segment:
private Segment ensureSegment(int k) {
final Segment[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment seg;
if ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 这里看到为什么之前要初始化 segment[0] 了,
// 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k]
// 为什么要用“当前”,因为 segment[0] 可能早就扩容过了
Segment proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 初始化 segment[k] 内部的数组
HashEntry[] tab = (HashEntry[])new HashEntry[cap];
if ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u))
== null) { // 再次检查一遍该槽是否被其他线程初始化了。
Segment s = new Segment(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
while ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
获取写入锁: scanAndLockForPut
前面我们看到,在往某个 segment 中 put 的时候,首先会调用 node = tryLock() ? null : scanAndLockForPut(key, hash, value),也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。
下面我们来具体分析这个方法中是怎么控制加锁的。
private HashEntry scanAndLockForPut(K key, int hash, V value) {
HashEntry first = entryForHash(this, hash);
HashEntry e = first;
HashEntry node = null;
int retries = -1; // negative while locating node
// 循环获取锁
while (!tryLock()) {
HashEntry f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
// 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
// 所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。
这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node。
以上大段摘抄了
总结:总结ConcurrentHashMap就是一个分段的hashtable ,根据自定的hashcode算法生成的对象来获取对应hashcode的分段块进行加锁,不用整体加锁,提高了效率。.
总之:
1.HashMap、Hashtable、ConcurrentHashMap用了散列表,而TreeMap用了红黑树。
2.Hashtable已被废弃,用ConcurrentHashMap来代替它。前者是对整个map加锁,而后者是分段锁。所以性能更好。
3.HashMap因为不用考虑加锁,所以性能比线程安全的两个更好(思考:如何使HashMap线程安全?)。
4.因为链表、红黑树的特点。HashMap插入和查询和更改的时间复杂度都是O(1),删除的话应该也是O(1)。TreeMap都是O(logN)。
5.TreeMap里的元素顺序与插入顺序无关,但是很方便就能实现按key排序。
6.各种集合的key和value对null值的容忍也是不同的。
7.以散列表实现的,都需要对象key类有hashCode()和equals()方法。以红黑树实现对象key类的需要实现Comparable接口。