类集实际上就是动态容器,说到容器,数组就是我接触到的第一个容器了,但是数组有一个很大的弊端,数组的长度固定,因此引出了类集的概念。
在java.util包里面有两个集合类核心接口:Collection和Map。
1.Collection
其中List,Set为常用的集合类。
1.1.List
其中List与Conllection最大的不同就是List接口中多了一个get(),用来根据索引取得内容。可以由上图可以看出,AbstractList实现了List接口,而之后由子类ArrayList,Vetor,LinkedList 。
1.1.1ArrayList
ArrayList是一个泛型容器,新建ArrayList需要实例化泛型参数,比如:
ArrayList<Integer> inList= new ArrayList <Integer>();
ArrayList中主要方法有:
public boolean add(E e):添加元素到末尾
public void add(int index,E element):在指定位置插入元素
public boolean isEmpty():判断是否为空
public int size():获取长度
public E get(int index):获取指定位置的元素
public int indexOf(Object o):查找元素,找到返回索引位置,没找到返回-1
public boolean contains(Object o):是否包含指定元素
public int lastIndexOf(Object o):从后往前查找
public E remove(int index):删除指定位置元素,返回值为被删对象
public boolean remove(Object o):删除指定对象,只删除第一个相同的对象,返回值表示是否删除了该对象,如果值为null,则删除值为null的元素
public void clear():删除所有元素
public E set(int index,E element):修改指定位置的元素内容为element。
ArrayList的基本原理:
在ArrayList内部有一个数组 Object[] elementData,有一个记录实际的元素个数。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
在添加元素的时候会首先检查容量是不是够用
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
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);
}
上面就是确保容量,以及增加容量的一套机制;
先是计算容器的容量,当第一次向容器中添加元素的时候,会向其分配DEFAULT_CAPACITY(大小为10),接下来确定容器的确定大小(调用ensureExplicitCapacity())其中modCount++表示内部修改的次数。如果当前数组的长度小于需要的容量大小,就扩容(调用grow()),扩容的时候,先将容量扩大之原数组大小的1.5倍,如果还是不能够满足需求,就直接将数组容量扩大至需求的容量。
ArrayList是基于数组的容器,查找元素就只需要通过索引来查找,但是删除某一个元素的时候就得移动元素。
1.1.2 Vector
Vector同样也是一个泛型容器,在使用时同样需要确定泛型类型。
Vector中的用法几乎与ArrayList一样,扩容方法也几乎与ArrayList一样,但是Vector中的很多方法都加入了synchronize同步语句来保证线程安全。但是就是因为保证了线程安全,导致Vector的效率同ArrayList相比要慢上很多。
1.1.3 LinkedList
由上图可以看出LinkedList不仅实现了List接口还实现了Deque接口,所以LinkedList底层与ArrayList不同之处就是它使用的是一个双向链表。
所谓双向链表,就是一个节点中包含两个链接,一个指向前驱节点,一个指向后续节点。
这就是双向循环链表的一个节点,一个指向前驱节点(prev),一个指向后继节点(next),一个是保存的实际的元素(item)。在LinkedList里面还有三个实例变量:
transient int size = 0;
transient Node<E> first;
transient Node<E> last;
其中size表示链表长度,first指向头节点,last指向尾节点,初始值为null。
首先还是来看添加方法:
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
分析上面的代码得知,add()方法主要就是调用了linkLast()方法:
1.l和last节点都指向原链表的尾节点;
2.创建一个新节点newNode;
3.使last指向所创造的尾节点;
4.如果l指向为null(未插入newNode之前是一个空链表),那么头节点first也指向新创建的节点,否则让原链表的尾节点的后续节点指向newNode。
5.增加链表大小,增加修改次数。
List<String>list = new LinkedList<String>();
list.add("a");
list.add("b");
可以看出,LinkedList查找元素必须从头开始遍历,但是删除元素不需要移动节点;相对于ArrayList来说,查找元素显得复杂一些,但是删除元素就简单一些。
1.2 Set
Set接口和List接口中最大的不同就是,Set接口中不可以添加重复的元素,而在Set接口下最常用的就是HashSet与TreeSet两个容器类。
1.2.1 HashSet
HashSet内部是用HashMap实现的(HashMap往下翻)
private transient HashMap<E,Object> map;
HashSet的构造方法:
public HashSet() {
map = new HashMap<>();
}
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
可以看出就是调用HashMap的构造方法。
再来看看add方法:
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
add方法就是调用put方法,向put中添加键值对(e,PRESENT)
解读就是,向HashSet中添加值,就是向HashMap添加键,值就为固定的PRESENT
与HashMap类似,HashSet要求重写hashCode和equals方法,因此HashSet中的元素没有顺序。
HashSet可以方便高效地实现去重,集合运算等功能。
1.2.2 TreeSet
上面的HashSet存储时就是元素之间没有特定的顺序,因此有了TreeSet。
同样TreeSet就是由TreeMap实现的。
private transient NavigableMap<E,Object> m;
private static final Object PRESENT = new Object();
public TreeSet() {
this(new TreeMap<E,Object>());
}
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
那么同样add方法就是调用TreeMap的put方法
TreeMap中没有重复的元素,添加、删除、判断元素是否存在,效率都比较高,为O(log2(N)),N为元素个数。
2.Map
Map接口中有键值对的概念,一个键映射一个值,Map中的键不能重复,即一个键只能存储一份,Map接口以及其实现类如下图所示。
2.1 HashMap
HashMap实现了Map集合,也是通过键值对来保存数据的。在创建HashMap的时候就需要先确定泛型类型;
例如:Map<Integer,Integer> map = new HashMap<Integer,Integer>();
HashMap中的一些主要方法:
V put(K key,V value)//保存键值对,如果原来的Key有值,覆盖,再返回原来的值
V get(Object key)//根据键获取值,没找到,返回null;
V remove(Object key)://根据键删除键值对,返回key原来的值,如果不存在,返回null
int size();//查看Map中的键值对的个数
boolean isEmpty();//是否为空
boolean containsKey(Object key);//查看是否包含某个键
boolean containsValue(Object value);//查看是否包含某个值
HashMap的基本实现原理:
在HashMap中有以下成员变量:
transient Node<K,V>[] table;
transient int size;
int threshold;
final float loadFactor;
table:是一个Node类型的数组,称为哈希表或哈希桶。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
可以由上面的代码可以看出,table李存放的是一个一个单向链表的头节点,链表中的每个节点都是一个键值对。
table初始状态是一个空表,在插入第一个元素的时候就进行默认分配大小为16.table在增长的时候是按2的幂次方增长。
size: 表示实际键值对个数。
threshold:表示阈值当键值对个数size大于等于阈值的时候就考虑进行扩展。阈值=table.size*loadFactor。
loadFactor:负载因子,默认的负载因子为0.75.
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // DEFAULT_LOAD_FACTOR默认值为0.75
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)//MAXIMUM_CAPACITY默认值为2^30表示最大容量.
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
当你自己定义table的初始容量,以及负载因子的时候,会条用tableSizeFor()来保证容量大小为2的幂次方。
下面来一起看看HashMap是如何保存键值对的:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)//如果table为空,也就是第一次调用put。
n = (tab = resize()).length;//那么就对分配空间大小,并计算所分配的大小
if ((p = tab[i = (n - 1) & hash]) == null)//如果计算得到的应该插入元素的位置上没有Node
tab[i] = newNode(hash, key, value, null);//就创造一个Node,将Node放入tab中
else {//如果该位置有Node了
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))//如果该位置上的hash码与待插元素的hash码相同,两者键相同,两者的值相同
e = p;//那么就将该位置的元素赋值给e
else if (p instanceof TreeNode)//如果该位置上的元素已经树化
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);//那就插入一个TreeNode节点将该树化节点赋值给e
else {//如果待插元素既不与定位到的元素相同,定位到的元素也没有进行树化(那就是一个链表)
for (int binCount = 0; ; ++binCount) {//通过循环遍历到链表的末尾,取得链表的长度,
if ((e = p.next) == null) {//如果p节点的下一个节点是null
p.next = newNode(hash, key, value, null);//那么就将待插元素插到定位到的元素的后面
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st(如果链表长度大于等于8)
treeifyBin(tab, hash);//就将其树化
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))//如果带插入元素的hash码,键,值与链表中的元素相同
break;//就跳出循环
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;//记录e的value
if (!onlyIfAbsent || oldValue == null)//如果onlyIfabsent为false,或者旧值为空
e.value = value;//就用新值覆盖
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//增加修改次数
if (++size > threshold)//如果当前桶的长度大于阈值
resize();//扩容或树化
afterNodeInsertion(evict);
return null;
}
可以通过上述代码清楚的看出整个put的流程:
1.计算键的哈希值。
2.根据取得的哈希值在table中寻找应该插入的位置。
2.1如果得到的位置上没有元素,那么就将带插入元素直接插入到该位置上。
2.2如果该位置上有元素
2.2.1如果该位置上的元素的hash码,key,value与待插元素相同,那么就直接覆盖。
2.2.2如果该位置上的元素已经树化,那么就直接将带插入元素插到该树化节点的后面。
2.2.3如果该位置没有树化,且该位置节点与带插入元素不同:
2.2.3.1直接将待插元素插入链表最后面,再判断当前链表的长度是否大于8,如果大于8就将其树化。
2.2.3.2判断链表中是否有与带插入元素相同的元素,如果有就直接跳出循环。
3.增加修改次数,判断当前桶的长度是否大于阈值,如果大于阈值就扩容,或将其树化。
根据上面的代码解释,以及流程梳理,我们发现resize()方法就是HashMap扩容的根本:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//将table的赋给oldTab
int oldCap = (oldTab == null) ? 0 : oldTab.length;//如果oldTab为空,oldCap就为0,否则oldCap为oldTab的长度
int oldThr = threshold;//oldThr为table的阈值
int newCap, newThr = 0;
if (oldCap > 0) {//如果oldTab的长度大于0
if (oldCap >= MAXIMUM_CAPACITY) {//如果oldTab的长度大于2^30
threshold = Integer.MAX_VALUE;//就将阈值设为最大值(意味着table将没法扩容)
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//当oldCap大于16并且oldCap按照2的幂次方进行扩容之后小于2^30(oldCap扩容之后将值赋给newCap)
newThr = oldThr << 1; // oldThr也按2的幂次方进行扩容并将值赋给newThr
}
else if (oldThr > 0) // 如果阈值大于0(table原本不为空)
newCap = oldThr;//新容量就为旧阈值
else { // 如果oldCap,oldThr为0的时候
newCap = DEFAULT_INITIAL_CAPACITY;//新容量采用默认值16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//新阈值才用默认值12
}
if (newThr == 0) {//如果新的阈值为0
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);//当newCap和ft都满足规定值的时候计算得到新的阈值
}
threshold = newThr;//将新阈值赋给threshold
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//创建一个大小为新容量的桶
table = newTab;//将新桶赋给table
if (oldTab != null) {//如果旧的hash桶不为空
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {//将旧桶中的元素赋值给e,当e不为空的时候
oldTab[j] = null;//将旧桶中的元素清空
if (e.next == null)//如果e的下一个元素为空
newTab[e.hash & (newCap - 1)] = e;//通过hash值找到确定的位置将e放入新桶
else if (e instanceof TreeNode)//如果e为一棵树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//将红黑树进行拆分
else { // e为普通链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {//如果e的hash值与旧数组按位与为0
if (loTail == null)//如果loTail为空;
loHead = e;//那么将e的值赋给loHead
else//如果loTail不为空
loTail.next = e;//将e的值赋给loTail的下一个元素
loTail = e;//将e赋值给loTail
}
else {//如果e的hash值与旧数组按位与不为0
if (hiTail == null)//hiTail为空
hiHead = e;//将e的值赋给hiTail
else//hiTail不为空
hiTail.next = e;//将e的值赋给hiTail的下一个元素
hiTail = e;//将e的值赋给hitail
}
} while ((e = next) != null);//遍历整个链表
if (loTail != null) {//如果loTail不为空
loTail.next = null;//将loTail的下一个元素置空
newTab[j] = loHead;//新桶中的该位置插入loHead
}
if (hiTail != null) {//hiTail不为空
hiTail.next = null;//将hiTail下一个元素置空
newTab[j + oldCap] = hiHead;//新桶中的该位置再加上原数组的长度的位置插入hiHead
}
}
}
}
}
return newTab;
}
HashMap 中的元素是没有顺序的,因为Hash值是随机的。
HashMap不是线程安全的,因此Java中还有一个HashTable,里面用了Synchronize关键字,因此安全性能会比HashMap高一些,但是相对于效率HashMap就会更强一些。
HashMap可以允许Key和Value为null,但是,HashTable不允许Key和Value为null。
HashMap每次扩容后的容量要求是2的幂次方,但是HashTable没有要求。
HashMap在当链表长度大于等于8的时候会将其树化,但是HashTable没有这样的机制。
但是基本上现在不用HashTable了,因为还有一个ConcurrentHashMap,它也是线程安全的,但是,它的效率并不低,因为它采用的是分段锁,而HashTable采用的是锁住了整个Map。
2.2TreeMap
TreeMap与HashMap最大的不同就是,TreeMap的存储有序,但是HashMap的存储是无序的。这是因为TreeMap中有一个比较器。
TreeMap底层是用红黑树实现的,下面是TreeMap的内部组成:
private final Comparator<? super K> comparator;
private transient Entry<K,V> root;
comparator就是一个比较器,在构造方法中传递,如果没传,就为null。root就是指向树的根节点,从根节点可以访问到每个节点,节点类型为Entry。
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
每个节点除了Key,Value之外,还有三个引用,left(左孩子),right(右孩子),parent(父节点),对于根节点,父节点为null,对于叶子结点,每一个子孩子都为null。
下面看看TreeMap是如何存储元素的
public V put(K key, V value) {
Entry<K,V> t = root;
if (t == null) {//当添加第一个节点时(root为null)
compare(key, key); // 这里其实是为了检查Key的类型和null
root = new Entry<>(key, value, null);//新增一个节点,让root指向它
size = 1;
modCount++;
return null;
}
int cmp;//保存比较结果
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
if (cpr != null) {//当设置了comparator时
do {
parent = t;//先从根节点开始寻找
cmp = cpr.compare(key, t.key);
if (cmp < 0)//比较的结果小于0
t = t.left;//就去左孩子找
else if (cmp > 0)//如果大于0
t = t.right;//就去右孩子找
else
return t.setValue(value);//如果为0,表示已经存在这个值,就设置值,再返回
} while (t != null);//一直到t为空时,此时parent就指向带插入节点的父节点
}
else {//如果未设置比较器
if (key == null)//如果key为空的时候
throw new NullPointerException();//抛出异常
Comparable<? super K> k = (Comparable<? super K>) key;//假设key实现了Comparable接口
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<K,V> e = new Entry<>(key, value, parent);//新创建一个节点
if (cmp < 0)//比较结果小于0
parent.left = e;//那么带插入节点就是parent的左节点
else
parent.right = e;//否则就是parent的右节点
fixAfterInsertion(e);//调整树的大致平衡
size++;
modCount++;
return null;
}
稍微总结一下,基本思路就是:循环找到父节点,并插入作为其左孩子或者右孩子,然后调整保持树的大致平衡。
接下来看看根据键删除键值对的代码:
public V remove(Object key) {
Entry<K,V> p = getEntry(key);//根据key找到节点
if (p == null)//该节点为空
return null;
V oldValue = p.value;//记录即将被删的节点的值
deleteEntry(p);//调用该方法删除节点
return oldValue;//返回被删除的节点的值
}
final Entry<K,V> getEntry(Object key) {
// Offload comparator-based version for sake of performance
if (comparator != null)//如果comparator不为空
return getEntryUsingComparator(key);//调用此方法
if (key == null)//如果key为空
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;//假设key实现了Comparable接口
Entry<K,V> 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;
}
可以看出remove()主要调用的就是deleteEntry():
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
if (p.left != null && p.right != null) {//如果被删节点的左右孩子都不为空
Entry<K,V> s = successor(p);//找到后继节点,并用后继节点覆盖待删除节点
p.key = s.key;
p.value = s.value;
p = s;
}
/*下面为待删节点只有一个子孩子的情况*/
Entry<K,V> replacement = (p.left != null ? p.left : p.right);//记录待删节点的某一个子孩子
if (replacement != null) {//如果replacement不为空
replacement.parent = p.parent;//那么replacement的父节点就为待删节点的
if (p.parent == null)//如果待删节点的父节点为空(待删节点为根节点)
root = replacement;//那么replacement为根节点
else if (p == p.parent.left)//如果待删节点是左孩子
p.parent.left = replacement;//那么待删节点的父节点的左孩子为replacement
else
p.parent.right = replacement;//否则待删节点的父节点的右孩子为replacement
// Null out links so they are OK to use by fixAfterDeletion.
p.left = p.right = p.parent = null;//然后将待删节点的孩子节点,父节点置空
// Fix replacement
if (p.color == BLACK)
fixAfterDeletion(replacement);
} else if (p.parent == null) { // return if we are the only node.
root = null;
} else { // No children. Use self as phantom replacement and unlink.
if (p.color == BLACK)
fixAfterDeletion(p);
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
if (t == null)//如果该节点为空
return null;
else if (t.right != null) {//如果该节点的右孩子为空,找出右节点中的最小的节点
Entry<K,V> p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {//否则从当前节点的父节点开始往上找,如果它是父节点的右孩子,则继续找父节点的右孩子,如果不是或者父节点为空,第一个非右孩子节点的父节点就被返回
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
TreeMap是按键排序,为了按键有序,TreeMap要求键实现Comparable接口或通过构造方法提供一个Comparator对象。根据键查找,保存,删除的效率比较高,为O(h),h为树的高度,h为log2(N),N为节点数。
好了,不多说了,敲代码了(如果有Bug请留言告知,感谢)