java中用到的主要的数据结构有 数组,list,set, map,队列,栈
其实分成两类就是 数组 与 容器
1.首先来说说最原始的 数组
数组与其他容器之间的主要区别在于三方面:效率,类型以及保存基本类型的能力
1.1 在java中有一种说法是 :数组是一种效率最高的存储于随机访问对象引用序列的方式,因为数组就是一个简单的线性表,这使得元素的访问速度非常快,但为这种速度所付出的代价是数组对象的大小要固定,并且在其生命周期中不可变。ArrayList的底层实现其实也是数组,这点在后面讲arraylist的时候再说
1.2 在jdk1.5之前,也就是泛型之前,容器在处理对象是都是将其看作是Object类型,但是数组 却可以持有对象的具体类型类似 Object[].
1.3 在jdk1.5之前,具体来说是泛型与自动装拆箱之前,容器是不能持有基本类型的数据也就是说你写成List<int> 是无法通过编译器编译的(当然现在也不能),但是数组能持有基本类型的引用也就是int [],在自动拆装箱之后,容器能持有基本类型的包装类型(对象)也就是类似于List<Integer>.
以上是数组与容器之间的主要区别,随着自动包装机制的出现,数组仅存的优点就只剩下效率了
在类加载机制的时候提到,数组本是由java虚拟机直接创建。但说到底,数组类其实也是一个类,但是这个类没有放在任何包(java引用包下),没有任何属性与方法,其加载与初始化的过程与其他类型均相同,如果其元素不是基本类型,元素的类加载过程依旧不变。在分析对象内存结构的时候也说到,对象头中类型指针如果当前的对象是数组,那么类型指针中必须还要有一个数组长度的字段,用来确定当前对象所需的内存大小,因为一个对象所需的内存大小在类加载完成之后便可以确定,所以必须要有数组的长度。
数组的具体使用在工具类 Arrays 类中有很好的定义,相应的容器的工具类是 Collections,下面来看看Arrays中关于数组的实用方法:
1.asList 这个方法用于将任意序列或数组转换为List容器(实际上是一个arrayList对象)
2.System.arrayCopy()用于对复制数组,用此方法复制数组比用for循环来复制要快很多,并且对所有类型做了重载,如果当前复制的对象不是原始类型,那么这种复制是一种浅复制称之为浅克隆,只是单纯的复制了当前对象的引用而已,并没有重创建对象。对应到Arrays中是copyOf方法。
3.sort用于对数组进行排序
4.binarySearch用于对已经排序的数组中查找元素(最原始的二分查找),并且针对所有的类型做了重载处理。
5.arrys重写了equals方法,用于对数组进行比较,Arrays.equals(a[],b[]).两个数组相等的前提是数组的长度要相等,并且其包含的元素的equals方法相等(对于基本类型,使用的是包装器类型的equals方法)
6.数组元素的比较,这里使用的是典型的策略模式,通用不变的是排序算法,变化的是各种对象相互比较的方式。java中有两种方式来实现比较功能,第一种是实现Comparable接口,使你的类天生具有比较功能。该接口中只包含有一个compareTo方法,小于返回负值,大于返回正值,等于返回0.第二种方式是创建一个实现Comparator接口的独立的类
2.容器
首先通过一张图来对容器的组成有一个整体的认识:
容器家族的组成结构还是比较复杂的,其中有七大基本接口,分别是 Iterator,List,Collection,Map,Set,Queue,Comparable。其中List,Set接口继承了Collection接口
List系列
1.1 ArrayList
ArrayList 继承了 AbstractList 实现了List 接口,被称之为可扩容的动态数组(resizeable - array)ArrayList 的属性数组 的初始默认大小为 10, arrayList的核心是在扩容,我们来具体分析下扩容的方法:
/**
* Increases the capacity to ensure that it can hold at least the
* number of elements specified by the minimum capacity argument.
*
* @param minCapacity the desired minimum capacity
*/
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);//然后将原数组拷贝到新的数组中
}
add方法(注意区分length 与size length是当前数组的长度,size是当前数组中元素的个数)我们调用ArrayList.size()拿到的是列表中元素的个数
也就是说ArrayList的每次add元素都会先去判断当前的空间是否足够(默认为10),如果空间不够会调用动态扩容,每次扩容会把数组大小扩充为 length + length/2。扩容完成之后,在借用Arrays中的copyOf方法将原数组拷贝到扩容之后的数组中,数组元素的size+1,这里同样是浅克隆,即只克隆当前对象的引用。如果元素加在头部,元素移动的次数是size,如果元素插入在尾部,元素移动的次数为0.
indexOf 方法
该方法用于查到当前对象在列表中位置,其实现的基本原理是一次循环遍历,时间复杂度为n,其最终比较的方法依旧是调用当前对象的 equals 方法去进行比较,如果相等返回当前对象在数组的下标,如果找不到则返回-1。
remove方法
该方法的核心依旧是复制,找到当前要移除的对象,移除当前对象,将当前对象之后的对象全部重新按照下标进行拷贝,也就是说只有移除最后一个位置的对象拷贝的次数为0,移除第一个位置的元素移动的次数为n-1。类似于先进先出的队列的实现方式是不合适用数组的。
clone方法
ArrayList实现了Cloneable接口,所以具备克隆当前列表的能力(浅克隆)
/**
* Returns a shallow copy of this <tt>ArrayList</tt> instance. (The
* elements themselves are not copied.)
*
* @return a clone of this <tt>ArrayList</tt> instance
*/
public Object clone() {
try {
ArrayList<?> v = (ArrayList<?>) super.clone();
v.elementData = Arrays.copyOf(elementData, size);
v.modCount = 0;
return v;
} catch (CloneNotSupportedException e) {
// this shouldn't happen, since we are Cloneable
throw new InternalError(e);
}
}
ArrayList中还有很多经常用到的方法,类似于subList,toArray等等。
ArryList总结
ArrayList的底层实现是 一个可动态扩容的数组(数组默认大小为10),每次扩容的大小为原有数组的一半,基于数组的优劣,ArrayList在读取数据方面相当快的,但 插入 与 删除 数据的时候设计到大量数据的移动与复制,性能有损耗. 在数据的移动与复制时使用的是Arrays.copyOf()方法,该方法性能比较优异的。
1.2LinkedList
首先要了解LinkedList的底层实现是一个双向链表,链表不同于数组,数组的内存分配是连续的,要获取某个位置的对象直接调用数组的位置即可,但是链表不同,链表的内存分配只是在逻辑上是连续的,这就决定了要读取一个对象的速度会比数组要慢许多。链表中要读取一个对象,首先要定位当前对象的位置,需要遍历一次链表,通过对象的equals方法找到当前对象的位置。知道对象的位置之后,链表的内存在物理上是不连续的,不能像数组一样直接通过数组的位置去取当前的对象,而是要通过又一次遍历去找到对应的位置从而返回相应的对象。这其中的两次遍历会有相应的性能损耗,当前在第二次访问的时候只做了查找优化处理,实际上只需要循环 size/2 次。 之所以比数组要慢,是慢在了第二次遍历的时间
在读取对象的速度上,这是数组优于链表的地方
由于是链表,在其上添加或删除一个对象时就变得相当简单了,要在A与B之间添加一个C对象,只需要将A的下一个指向指向C,C的前一个指向指向A,C的下一个指向指向B,B的前一个指向指向C就可以了,删除一个对象原理相同。相对于数组的各种复制与移动,性能上会有很大的提升,这也是链表在添加与删除数据时要优于数组的原因。
在添加与删除对象的性能与速度上,链表要明显优于数组
需要注意的是LinkedList实现了Dqueue接口,使得LinkedList具备了类似于队列与栈的功能,队列功能对应的方法为 poll方法(出队) : 移除第一个元素,头部元素,peek方法只是获取第一个元素,但并不移除改元素。offer方法(入队):将元素添加到链表的末尾。栈功能实现的方法为pop(出栈):移除链表的第一个元素,push(入栈):将元素添加到链表的头部
LinkedList总结
LinkedList底层的实现是个双向链表,其实现了List与Dqueue接口,所以LinkedList兼具有队列与栈的功能,在数据的插入与删除上,其性能要远优于ArrayList,其具体原因是:LinkedList添加与删除元素只需要重置元素之间的关系(即前一个与后一个的关联)的关联打断即可,而arrayList则涉及到一系列的元素移动。但是在数据的查找方面,LinkedList是要比ArrayList性能要差的。其具体原因在于,数组(ArrayList)的内存地址是连续的,知道元素的位置,直接去取位置的元素即可,而LinkedList则要先找到元素的位置,然后通过折半查找从靠近元素一段开始遍历,也就是说最差的情况是LinkedList查找元素要比ArrayList多遍历列表长度的一半。
1.3 Vector
Vector一般没什么可说的,vector是一个线程安全的数组集合,因为vector中的大部分方法都通过synchronized 关键字进行了同步处理。除了线程安全之外,它跟ArrayList没什么区别。考虑到线程同步带来的性能开销,在不考虑同步的情况下一般优先考虑ArrayList。
set 系列 (确定性,互斥性,无序性)
A collection that contains no duplicate elements. set集合中不允许有重复的元素出现,也就是不允许有a.equals(b)的元素存在,当然也只能有一个null。一句话来说就是,在set中不会出现重复的元素
2.1 HashSet
HashSet的底层操作是操作一个HashMap对象,HashSet'能保证数据的唯一性,但不保证数据的有序性。为了保证数据的唯一性,是用对象的hash地址与equals来确定key的唯一性。如果hashcode与equals都保持一致,则视为是同一个对象(验证了重写了equals方法一般会重写hashcode方法的说法),而hashSet是用存储的对象作为key保存的,说到底HashSet的数据是存在hashMap的key中,所以要遍历HashSet中元素的个数只需要遍历HashMap的keySet就可以了。别的就没什么好说的了,具体hashmap是怎么存储的到后面再说。(顺便提一下,到现在为止我都没明白hashSet存在正在的意义是什么,既然HashMap都能搞定为什么还要HashSet这个数据结构?)。
Map 系列
map的核心思想是存储键值对,通过键查找值。与set一样,map里面的key必须保持唯一性,如果同样的key存入map则新的值会覆盖上一次的值。
3.1 HashMap
hashMap的重点是散列算法(hash()方法),散列算法的好坏直接决定hashMap的性能。核心是resize() 与 红黑树,其底层的实现用链地址法 通过 数组与单向链表来存储数据,也就说在底层hashmap是数组与链表的结合体,数组用来存放key通过hash方法生成的值,链表用来存储真正的键值对。
在源码中 hash的算法是这样的,简单而高效:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
我们一起来回一下hashMap存储数据的过程,主要分为以下几个步骤:
1.首先判断用于存放键值对单向链表的数组是否为空,如果为空则先调用resize方法扩容
2.然后通过hash算法获取数组位置并且判断当前位置是否已经存在数据(链表元素),如果没有,创建链表
3.如果当前位置存在链表,则遍历该链表,如果链表中元素的key与当前元素的key相同,则只是覆盖当前key关键字对应的值。
4.如果当前链表中不存在当前的key,则先判断当前链表是否已经转换为红黑树,如果是则直接将元素插入红黑树(红黑树是一颗平衡二叉树,不会有重复的元素,后面会单独详细说)
5.如果当前链表没有转换为红黑树,则遍历当前链表,如果在链表元素大于8之前找到了当前的key则覆盖,如果添加到链表尾部刚好大于8则需要进行红黑树转换的操作
上面就是往HashMap中添加一个元素的全部过程。下面来看看添加的源码:
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)
n = (tab = resize()).length;//如果当前数组为空,创建数组
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);//如果当前数组位置为空,创建一个新节点
else {//如果当前位置存在元素
Node<K,V> e; K k;
if (p.hash == hash &&//如果当前key的对应的对象是同一个对象(hashcode与equals方法都相等),直接覆盖当前key对应的值
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)//如果当前是链表是红黑树,直接插入红黑树
e = ((TreeNode<K,V>)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//如果链表元素大于8,转换为红黑树
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//如果当前的key在链表中存在,直接覆盖key对应的值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//此处是LinkedHashMap中有用到
return oldValue;
}
}
//如果是数组中添加元素,走此处,如果数组空间不够,需要重新扩容
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
下面来看看最核心的resize方法,hashMap的扩容与arrayList的扩容还是有点区别的,arrayList的扩容只是将原数组的数据全数拷贝到新的数组中,而HashMap中的扩容却要复杂的多。我们来看看resize的源码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
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) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容的条件是:当map中包含的Entry的数量大于等于threshold = loadFactor(0.75) * capacity的时候,且新建的Entry刚好落在一个非空的桶上,此刻触发扩容机制,将其容量扩大为2倍。
3.2 LinkedHashMap
LinkedHashMap的底层实现依旧是HashMap只是在hashmap的基础上用一个双向链表将添加到链表中的元素连接起来,形成了一个逻辑上的双向链表,从而使添加进hashmap的元素有了逻辑上的顺序,其余的与hashmap没有区别。LinkedHashMap与hashmap是一个典型的装饰模式的应用。
3.3 ArrayMap(android 中特有,为了节约空间而生)
在以往android开发中,我们常常用key-value存储数据时,随手就会打出HashMap的代码,当数据量较小时,这种方法还不错还可以,当数据量比较多的时候,如果是PC机上,也还阔以。但是如果使用设备是手机等移动设备,这是就要慎重了。手机内存不像PC内存那样,手机内存很宝贵,稍有不慎,可能就会引发OOM问题。那当数据量比较多,又需要在手机端开发,这个时候,我们就可以用ArrayMap替代HashMap。ArrayMap相比传统的HashMap速度要慢,因为查找方法是二分法,并且当你删除或者添加数据时,会对空间重新调整,在使用大量数据时,效率低于50%。可以说ArrayMap是牺牲了时间换区空间。但在写手机app时,适时的使用ArrayMap,会给内存使用带来可观的提升。
上面说了ArrayMap的出现时为了节省内存空间,那么在性能方面是要比hashmap差的,这个也就是实现了传统意义上的时间换空间。
3.4 ConcurrentHashMap
HashMap在多线程环境下是线程不安全的,在resize的再哈希的时候会导致链表死循环,当然在多线程环境下,java官方也提供了类似hashTable与synchronizedMap来保证线程安全性,但其效率非常低下。在JDK1.5 以后,官方提供了一个线程安全的ConcurrentHashMap类。在ConcurrentHashMap中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。特别地,在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设为16),及任意数量线程的读操作。