摘要:本文主要介绍了几种集合类型以及有关的一些知识点。
集合类图
类图
类图说明
所有集合类都位于java.util包下。Java的集合类主要由两个接口派生而出:Collection和Map,Collection和Map是Java集合框架的根接口,这两个接口又包含了一些子接口或实现类。
接口用短虚线表示,表示不同集合类型,是集合框架的基础。例如Collection,Map,List,Set,Iterator等。
抽象类用长虚线表示,对接口的部分实现。例如AbstractMap,AbstractCollection,AbstractList,AbstractSet等。
实现类用实线表示,对接口的具体实现。例如ArrayList,LinkedList,HashSet,HashMap等。
集合概述
Collection接口是一组允许重复的对象,属于单列集合。
List接口继承自Collection接口,允许插入重复的数据,是有序的集合,可以通过索引访问元素。
Set接口继承自Collection接口,不允许插入重复的数据,是无序的集合。
Map接口是一组保存了key-value键值对的对象,属于双列集合,只能根据每个键值对的key访问value。
Collection接口
Collection是一个接口,是高度抽象出来的集合,它包含了集合的基本操作和属性。Collection包含了List和Set两大分支。
常用方法
添加单个元素:boolean add(Object object);
添加一个集合里的所有元素:boolean addAll(Collection<? extends E> collection);
删除单个元素:boolean remove(Object object);
删除指定集合里有的元素:boolean removeAll(Collection collection);
删除两个集合都有的元素:boolean retainAll(Collection collection);
判断是否包含某个元素:boolean contains(Object object);
判断是否包含指定集合的所有元素:boolean containsAll(Collection<?> collection);
判断集合是否为空:boolean isEmpty();
清除集合里的元素:void clear();
获取集合元素个数:int size();
将集合转换为数组:Object[] toArray();
将集合转换为指定类型的数组:<T> T[] toArray(T[] array);
获取集合迭代器:Iterator iterator();
集合同数组的比较
数组长度一旦固定,不能再改变,集合的长度是可以改变的。
数组只能保存相同类型的数据,集合可以保存指定类型或其子类型的数据。
数组在使用的时候相对比较麻烦,集合可以利用多种方法,还有工具类。
List接口
List接口继承自Collection接口,允许定义一个重复的有序集合,集合中的每个元素都有对应的一个索引,可以通过索引访问List中的元素。
实现List接口的实现类主要有:ArrayList、LinkedList、Vector、Stack。
特点
允许重复。
有序,取出的顺序和插入的顺序一致。
为每一个元素提供一个索引值,默认从0开始。
常用方法
在指定索引位置添加单个元素:void add(int index, Object object);
在指定索引位置添加一个集合:boolean addAll(int index, Collection<? extends E> collection);
删除指定位置的单个元素:Object remove(int index);
获取指定位置的单个元素:Object get(int index);
替换指定位置的单个元素:Object set(int index, Object object);
获取指定元素的出现的第一个索引:int indexOf(Object object);
获取指定元素的出现的最后一个索引:int lastIndexOf(Object object);
获取指定位置的集合,包含起始位置,不包含结束位置:List<E> subList(int fromIndex, int toIndex);
获取集合迭代器:ListIterator<E> listIterator();
ArrayList类
特点
ArrayList是动态数组结构,也是我们最常用的集合,允许任何符合规则的元素插入,包括null。
ArrayList提供了索引机制,可以通过索引迅速查找元素,查找效率高。但是每次增加或删除元素时,身后的元素都要移动,所以增删效率低。
ArrayList的操作是非同步的,是线程不安全的。
扩容机制
数组结构都会有容量的概念,ArrayList的初始容量为10,加载因子是1,当快插入元素后长度超出原有长度时会进行扩增,扩容增量是0.5,扩增后容量为1.5倍,可使用方法手动扩容和缩减。
如果一开始就明确所插入元素的多少,最好指定一个初始容量值,避免过多的进行扩容操作而浪费时间和效率。
Vector类
特点
与ArrayList相似,它的操作与ArrayList几乎一样。
Vector是同步的,是线程安全的动态数组,但是效率低。
扩容机制
初识容量为10,加载因子为1,扩容增量是1,扩增后容量为原来长度的2倍,适用于数据量大的环境。
LinkedList类
特点
LinkedList是双向链表结构,额外提供了操作列表首尾元素的方法,因为不是数组结构,所以不存在扩容机制。
LinkedList使用了链表结构,通过修改前后两个元素的链接指向实现增加和删除操作,增删效率高,但是查找操作必须从开头或者结尾便利整个列表,所以查找效率低。
LinkedList的操作是非同步的,是线程不安全的。
特殊方法
在开头位置插入元素:void addFirst(Object object);
在结尾位置插入元素:void addLast(Object object);
删除开头位置的元素并返回:Object removeFirst();
删除结尾位置的元素并返回:Object removeLast();
获取开头位置的元素:Object getFirst();
获取结尾位置的元素:Object getLast();
Set接口
Set接口继承自Collection接口,允许定义一个不重复的无序集合,集合中只允许存在一个null值。
实现Set接口的实现类主要有:HashSet、LinkedHashSet、TreeSet。
特点
不可以重复,只能插入一个空值。
无序,不能保证插入的顺序和输出的顺序一致。
没有索引。
HashSet类
特点
HashSet的底层是HashMap。
HashSet使用了一个散列集存储数据,通过元素的Hash值进行排序,不能保证插入和输出的顺序一致。
HashSet不能插入重复的元素,只能存在一个null值。
HashSet内部通过哈希表进行排序,具有很好的查找和存取功能。
HashSet是非同步的,线程不安全。
扩容机制
和HashMap相同。
LinkedHashSet类
特点
LinkedHashSet继承自HashSet,其底层是基于LinkedHashMap来实现的。
LinkedHashSet使用链表维护元素的次序,同时根据元素的hash值来决定元素的存储位置,遍历集合时候,会以元素的添加顺序访问集合的元素。
LinkedHashSet不能插入重复的元素,只能存在一个null值。
LinkedHashSet插入性能略低于HashSet,但在迭代访问Set里的全部元素时有很好的性能。
LinkedHashSet是非同步的,线程不安全。
扩容机制
和HashMap相同。
TreeSet类
特点
TreeSet的底层是TreeMap。
TreeSet基于二叉树结构,可以实现自然排序。
TreeSet通过比较方法的返回值来判断元素是否相等,因此不能添加null的数据,不能添加重复元素,只能插入同一类型的数据。
TreeSet支持两种排序方式,自然排序和定制排序。
自动排序:添加自定义对象的时候,必须要实现Comparable接口,并要覆盖compareTo方法来自定义比较规则。
定制排序:创建TreeSet对象时,传入Comparator接口的实现类。要求Comparator接口的compare方法的返回值和两个元素的equals方法具有一致的返回值。
Map接口
Map与List、Set接口不同,它是由一系列键值对组成的集合,提供了key到value的映射.
同时它也没有继承Collection。
特点
一个key对应一个value,不能存在相同的key值,value值可以相同。
key和value之间存在单向一对一关系,即通过指定的key总能找到唯一确定的value。
实现Map接口的实现类主要有:HashMap、LinkedHashMap、TreeMap。
常用方法
插入一个键值对并返回value的值:V put(K key, V value);
插入一个键值对集合:void putAll(Map<? extends K,? extends V> m);
根据key值移除对应的元素:V remove(Object key);
根据key值获取对应的元素:V get(Object key);
获取Entry的Set集合:Set<Map.Entry<K,V>> entrySet();
获取key的Set集合:Set<K> keySet();
获取value的Collection集合:Collection<V> values();
遍历Map
通过Key遍历
1 Set set = map.keySet(); 2 for(Object obj : set) { 3 System.out.println(obj + " -> " + map.get(obj)); 4 }
通过Entry遍历
1 Set set = map.entrySet(); 2 for(Object obj : set) { 3 Map.Entry entry = (Map.Entry)obj; 4 System.out.println(entry); 5 System.out.println(entry.getKey() + " -> " + entry.getValue()); 6 }
HashMap类
特点
HashMap底层使用数组结构和链表结构。
HashMap根据键的Hash值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。
因为键对象不可以重复,所以HashMap最多只允许一条记录的键为null,允许多条记录的值为null。
HashMap是非同步的,线程不安全。
扩容机制
JDK1.7及以前,默认初始容量16,加载因子是0.75,扩容增量是1。
当同时满足两个条件时才会扩容:当前数据存储的数量大小必须大于等于阈值。当前加入的数据发生了hash冲突。
这就有可能导致存储超多值得时候仍没有扩容:一开始存储的11个值全部hash碰撞,导致存入了同一个链表,后面存入的15个值全部没有hash碰撞,这时存入的个数为26个,但是并不会扩容。
JDK1.8,默认初始容量16,加载因子是0.75,扩容增量是1。
当存入个数超过8时,会将链表转换为红黑树。
当存入个数超过容量的0.75倍时,就会进行扩容,并且扩容增量也变成了一倍。
底层实现原理
HashMap的底层是Entry类型的,名字叫table的数组。
Entry数组中的一个元素保存了一组Key-Value映射。
存储结构中使用了数组结构和链表结构。
添加元素时,根据key所在类的hashCode()得到key的hashCode值,并通过hash算法得到在底层数组中的存放位置,如果hashCode相同,那么位置也是相同的。
如果此位置上没有其他元素,则添加成功。如果此位置上已经有元素存在,则调用key所在类的equals()方法比较key是否相同。
如果返回true,使用添加的entry的value替换原有位置entry的value,返回原值。
如果返回false,表示发生了hash冲突,新的entry仍能添加成功,与旧的entry之间使用链表存储,新添加的在首部。
Hashtable类
特点
Hashtable是Map的古老实现类,使用哈希表算法。
不能存储null的键和值。
线程安全,但是效率低。
扩容机制
默认初始容量为11。
LinkedHashMap
特点
LinkedHashMap继承自HashMap,使用哈希表和链表实现。
LinkedHashMap使用链表维护元素的次序,保留了元素的插入顺序,可以按照顺序遍历。
LinkedHashMap允许使用null值和null键。
LinkedHashMap需要维护元素的插入顺序,因此性能略低于HashMap的性能,但在迭代访问Map里的全部元素时将有很好的性能,因为它以链表来维护内部顺序。
LinkedHashMap是非同步的,线程不安全。
扩容机制
和HashMap相同。
TreeMap
特点
TreeMap是基于红黑树实现的,TreeMap存储时会进行排序,按照添加进Map中的元素的Key的指定属性进行排序。
TreeMap的排序方式有两种,自然排序和定制排序。
自然排序:TreeMap中所有的key必须实现Comparable接口,并且所有的key都应该是同一个类的对象,否则会报ClassCastException异常。
定制排序:定义TreeMap时,创建一个comparator对象,该对象对所有的treeMap中所有的key值进行排序,采用定制排序的时候不需要TreeMap中所有的key必须实现Comparable接口。
TreeMap判断两个Key相等的标准是通过compareTo()方法或者compare()方法。
TreeMap是非同步的,线程不安全。
HashMap的扩容机制以及默认大小为何是2次幂
hash方法
在Object类中有一个hashCode()方法,用来获取对象的hashCode值,它被native修饰,意味着这个方法和平台有关,对于有些JVM,hashCode()返回的就是对象的地址,但大多数情况下是根据一定的规则将与对象相关的信息(比如对象的存储地址,对象的字段等)映射成一个数值,这个数值称作为散列值。
对于包含容器类型的程序设计语言来说,基本上都会涉及到hashCode。在Java中也一样,hashCode的主要作用是为了配合基于散列的集合一起正常运行,这样的散列集合包括HashSet、HashMap以及HashTable。
当向集合中插入对象时,如果调用equals()逐个进行比较,虽然可行但是这样做的效率很低。因此,先调用hashCode()进行判断,如果相同再调用equals()判断,会快很多。
因为在计算元素在HashMap中的下标时,是通过 hash & (length-1) 计算得到的,通过和长度减1进行与运算只会用到低位,所以在使用hashCode之前需要再次进行处理生成新的hash,保证对象的hashCode的32位值只要有一位发生改变,整个hash就会改变,高位的变化也会影响低位,这时再使用低位计算下标就能使元素的分布更加合理。
◆ 在JDK1.7及以前的hash()是进行一系列的移位和按位或运算
1 final int hash(Object k) { 2 int h = hashSeed; 3 if (0 != h && k instanceof String) { 4 return sun.misc.Hashing.stringHash32((String) k); 5 } 6 h ^= k.hashCode(); 7 h ^= (h >>> 20) ^ (h >>> 12); 8 return h ^ (h >>> 7) ^ (h >>> 4); 9 }
◆ 在JDK1.8以后的hash()只需要进行一次异或运算
1 static final int hash(Object key) { 2 int h; 3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 4 }
为什么默认长度为2的次幂
长度为2的次幂,是因为要通过hash计算一个合适的下标,使用的方法是 hash & (length-1) 将hash和长度减1进行按位与运算,如果长度为2的次幂,那么长度减1得到的二进制表示的每个位上的数字都是1,任何数字同1进行与运算得到的结果都是它本身。
如果长度改为其他不为2的次幂的数字,长度减1得到的二进制表示的某个位上会是0,0同任何数字相与都是0。这个位上为1的位置永远都不会被放入元素,而且hash在这个位置上不管为1还是0,得到的位置都是一样的,造成了额外的碰撞。
比如,长度为15时,15减1得到的二进制表示为1110,那么1001,0101,0011这种末位为1的位置上将永远都不会有元素,造成位置浪费,而且hash为1101和hash为1100得到的位置都是1100,产生了碰撞,还需要进一步判断。
只有当所有位置都是1,也就是长度为2的次幂时,才会让所有位置都有可能被用到,并且每个二进制末4位不同的数字都能有唯一的位置,减少了碰撞的产生。
如果一开始设置的长度不是2的次幂
如果手动设置了长度,那么HashMap会对传入的长度进行处理,通过调用tableSizeFor方法,将长度转为二进制位都为1的并且大于传入长度的一个数字,然后加1返回就得到了一个2次幂的数字。
何时会进行扩容
◆ 在JDK1.7及以前,判断是否要扩容的条件是: (size >= threshold) && (null != table[bucketIndex]) 。
第一个条件是长度的阈值,阈值用threshold表示,一般是长度和加载因子的乘积,加载因子默认是0.75。
第二个条件是当前位置上不能为空,也就是说发生了hash碰撞。
只有同时满足了这两个条件,才会进行扩容,这就有可能导致在当前长度超出阈值的情况下仍不会进行扩容操作。
◆ 在JDK1.8以后,判断条件是: ++size > threshold 。
只要在插入之后的长度超过了当前的阈值,就会进行扩容操作。
扩容机制resize方法
在JDK1.7及以前,HashMap使用的是数组加链表的方式存储的。在进行扩容后,原来的元素都要重新计算hash,通过重新计算索引位置后,如果在新表的索引位置相同,则链表元素会倒置 。
1 while(null != e) { 2 Entry<K,V> next = e.next; 3 if (rehash) { 4 e.hash = null == e.key ? 0 : hash(e.key); 5 } 6 int i = indexFor(e.hash, newCapacity); 7 e.next = newTable[i]; 8 newTable[i] = e; 9 e = next; 10 }
在JDK1.8以后,使用的是数组加红黑树的存储方式。在进行扩容后,不再进行重新计算hash,而是通过将原hash同原长度进行按位与运算,判断hash的高位是否为0,如果为0则放回原位置,如果不为0则放在高位。
1 Node<K,V> e; 2 if ((e = oldTab[j]) != null) { 3 oldTab[j] = null; 4 if (e.next == null) 5 newTab[e.hash & (newCap - 1)] = e; 6 else if (e instanceof TreeNode) 7 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 8 else { // preserve order 9 Node<K,V> loHead = null, loTail = null; 10 Node<K,V> hiHead = null, hiTail = null; 11 Node<K,V> next; 12 do { 13 next = e.next; 14 if ((e.hash & oldCap) == 0) { 15 if (loTail == null) 16 loHead = e; 17 else 18 loTail.next = e; 19 loTail = e; 20 } 21 else { 22 if (hiTail == null) 23 hiHead = e; 24 else 25 hiTail.next = e; 26 hiTail = e; 27 } 28 } while ((e = next) != null); 29 if (loTail != null) { 30 loTail.next = null; 31 newTab[j] = loHead; 32 } 33 if (hiTail != null) { 34 hiTail.next = null; 35 newTab[j + oldCap] = hiHead; 36 } 37 } 38 }
重写equals方法和hashCode方法
在发生hash碰撞的时候,一个桶里的两个元素key值不相等,但是他们的hashCode是相等的,如果两个key值也相等,则说明两个key相等。也就是说:
◆ 如果两个对象equals相等,那么这两个对象的HashCode一定也相同。
◆ 如果两个对象的HashCode相同,不代表两个对象就相同,只能说明这两个对象在散列存储结构中,存放于同一个位置。
一般在重写equals方法的时候,也会尽量重写hashCode方法,就是为了在equals方法判断相等的时候也保证让hashCode方法也判断相等。