在码代码的过程中,我们常常需要对大量对象引用管理,为了有效的归类管理,将同类的引用放在一个数据容器中。
(如果对您的学习有所帮助记得点个赞喔)
容器主要由:Collection与Map两种构成。
一.概述
1.Collection
包含三大类,set、list、queue。思维导图如下所示:
1.1 Set
TreeeSet:使用二叉树的原理对新 add()的对象按照指定的顺序排序(升序、降序),每增加一个对象都会进行排序,将对象插入的二叉树指定的位置。
HashSet: HashSet 通过 hashCode 值来确定元素在内存中的位置。一个 hashCode 位置上可以存放多个元素。
LinkedHashSet:是HashSet与LinkedHashMap的结合。底层使用 LinkedHashMap 来保存所有元素,其所有的方法操作上又与 HashSet 相同。
1.2 List
ArrayList:是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。
因此,它适合随机查找和遍历,不适合插入和删除。
Vector:也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问 ArrayList慢。
LinkedList:用链表结构存储数据的,适合动态插入与删除,随机访问、遍历较慢,此外提供了用于操作表头表尾的方法,可当作栈、队列、双向队列使用。
2.Map
相关知识思维导图如下所示:
2.1 HashMap(数组+链表+红黑树)
根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。HashMap线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。
2.2 HashTable
很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,并且是线程安全的,任一时间有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分锁。
2.3TreeMap
TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。
如果使用排序的映射,建议使用 TreeMap。
2.4 LinkedHashMap(记录插入顺序)
LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,在用 Iterator 遍历LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
二.深入理解
下面就几个重要的知识点深入讲解:
1、ArrayList
ArrayList是基于数组实现的,其默认大小为10,但存储的内容超出十个就将考虑扩容。
(1)、扩容
扩容一般发生在添加元素时,有一个ensureCapacityInternal()方法保证容量足够,如果不够,就使用grow()方法进行扩容,扩容后变为原有容量的1.5倍。
public boolean add(E e) { //元素添加方法
ensureCapacityInternal(size + 1); // 保证有足够的空间
elementData[size++] = e; //直接添加
return true;
}
在初次添加元素的时候需要做特别的处理,然后判断是否超过最大容量,判断在现有的size+1的基础上是否需要扩容,然后扩容,拷贝数组添加元素。
private void ensureCapacityInternal(int minCapacity) {
/*如果使用默认的构造参数的话,则选择minCapacity和DEFAULT_CAPACITY中较大的一个来作为最小的初始
容量, 大家可能奇怪,既然判断了初始化这一说,为何还会有传入的minCapacity 和DEFAULT_CAPACITY
比较这一说,是因为ensureCapacityInternal也会被addAll调用
*/
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
//需要扩容的情况
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
grow()代码实现扩容
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);//copy the data to new array
}
(2)元素删除
需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上,该操作的时间复杂度为 O(N),所以这就是为什么说ArrayList 删除元素的代价是非常高的。代码就忽略了,较为简单的向前移位操作。
(3)序列化
所谓的JAVA序列化与反序列化,序列化就是将JAVA 对象以一种的形式保持,比如存放到硬盘,或是用于传输。反序列化是序列化的一个逆过程。规定被序列化的对象必须实现java.io.Serializable这个接口,而我们分析的目标ArrayList同样实现了该接口。
但在实际开发工作中我们并不需要所有元素都需要序列化,比如银行卡、密码等信息,同时数组是由transient修饰,默认不序列化,此时就需要对部分元素进行序列化。ArrayList 实现了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。(此处不展开讲、有兴趣可以自行查询相关知识)
(4).Fail-Fast
modCount 用来记录 ArrayList 结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化。
在进行序列化或者迭代遍历等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出 ConcurrentModificationException。
出现的场景:
在单线程中:遍历的过程中进行修改(使用Iterator遍历)
多线程中:一个线程修改另外一个线程的遍历的Collection
解决的方法:对对象加锁、使用java.util.concurrent包中的类来代替ArrayList和Hashmap来表达。
(5).线程安全问题
ArrayList没有同步机制,所以是线程不安全的,有以下两种方式对其进行操作使其安全。
a.使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList。
List
List
b.可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。
List
注:CopyOnWriteArrayList()
核心思想:读写分离,写操作在复制数组进行、读操作在原数组进行,互相分离,互不干扰。
写操作需要加锁,防止并发写入时导致写入数据丢失。
写操作结束之后需要把原始数组指向新的复制数组。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
final void setArray(Object[] a) {
array = a;
}
优点:大大提高了读操作的性能,因此很适合读多写少的应用场景。
缺点:内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。
结论:CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。
2、Vector
(1)线程安全
实现与ArrayList类似,不过使用Synchronized进行同步。
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
(2)扩容
Vector的构造函数与ArrayList有所不同,Vector 的构造函数可以传入 capacityIncrement 参数,它的作用是在扩容时使容量 capacity 增长 capacityIncrement。如果这个参数的值小于等于0,扩容时每次都令 capacity 为原来的两倍。默认情况下,capacityIncrement的值为0,也就是说默认情况下 Vector 每次扩容时容量都会翻倍。
public Vector(int initialCapacity, int capacityIncrement) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
this.capacityIncrement = capacityIncrement;
}
扩容方法如下
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
(3).与ArrayList对比
1.Vector是同步的,线程安全,ArrayList不安全。
2.同样是由于同步原因,开销就比 ArrayList 要大,访问速度更慢。
3.Vector 每次扩容请求其大小的 2 倍,ArrayList是1.5倍。
3、LinkedList
基于双向链表实现,使用 Node 存储链表节点信息。
private static class Node
E item;
Node
Node
}
transient Node
transient Node
与ArrayList对比,可以归结为数组和链表的区别:
数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
链表不支持随机访问,但插入删除只需要改变指针。
4、HashMap
数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。
数组:数组必须事先定义固定的长度(元素个数),不能适应数据动态地增减的情况。当数据增加时,可能超出原先定义的元素个数;当数据减少时,造成内存浪费。
数组是静态分配内存,并且在内存中连续。
数组利用下标定位,时间复杂度为O(1)
数组插入或删除元素的时间复杂度O(n)
数组的特点是:寻址容易,插入和删除困难
链表:链表存储区间离散,占用内存比较宽松。
链表是动态分配内存,并不连续。
链表定位元素时间复杂度O(n)
链表插入或删除元素的时间复杂度O(1)
链表的特点是:寻址困难,插入和删除容易。
结合二者,哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间。
(1)HashMap的存储结构:
包含了一个 Entry 类型的数组 table。Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。
transient Entry[] table;
static class Entry
final K key;
V value;
Entry
int hash;
Entry(int h, K k, V v, Entry
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
......
}
(2)拉链法解决哈希冲突
HashMap
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
上面代码为新建hashMap并插入过程,如下图所示;插入到同一个桶,使用头插法进行插入。
确定桶下标:
int hash = hash(key);
int i = indexFor(hash, table.length);
计算哈希值
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// 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);
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方(这也就是为什么HashMap扩容每次都是2倍的原因),那么就可以将这个操作转换为位运算。
static int indexFor(int h, int length) {
return h & (length-1);//位运算效果与取模操作一致,但性能更高。
}
/*位运算
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010
*/
//取模: y%x : 00000010
(3)扩容
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。
为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry
if (e != null) {
src[j] = null;
do {
Entry
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上,使用 hash%capacity 来确定桶下标,而每次扩容都是2的n次方,简化了重新计算的。
(4)比较:
A.HashMap和HashTable的区别
1.HashTable的方法是同步的,在方法的前面都有synchronized来同步,HashMap未经同步,所以在多线程场合要手动同步。
2.HashTable不允许null值(key和value都不可以) ,HashMap允许null值(key和value都可以)。
3.HashTable有一个contains(Object value)功能和containsValue(Object value)功能一样。
4.HashTable使用Enumeration进行遍历,HashMap使用Iterator进行遍历。
5.HashTable中hash数组默认大小是11,增加的方式是 old*2+1。HashMap中hash数组的默认大小是16,而且一定是2的指数。
B.HashMap与HashSet的关系:
1、HashSet底层是采用HashMap实现的:
public HashSet() {
map = new HashMap
}
2、调用HashSet的add方法时,实际上是向HashMap中增加了一行(key-value对),该行的key就是向HashSet增加的那个对象,该行的value就是一个Object类型的常量。
C.Hashtable 和 ConcurrentHashMap 的关系
主要区别就是加锁的粒度以及如何加锁,ConcurrentHashMap 的加锁粒度要比HashTable更细一点。将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
5.ConcurrentHashMap
ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。Segment 继承自 ReentrantLock。具体信息可以参考链接[https://zhuanlan.zhihu.com/p/31614308]讲的很生动,当然,有耐心可以去看看JUC源码。
hello.world:JVM基础知识大整合,面试看这一篇就够了!zhuanlan.zhihu.com
如果对您有所帮助就点赞吧!最近找工作,欢迎大家一起交流。欢迎收藏点赞!
(文中部分图片源自书本、某大佬博客,侵删)