平时我们用 List 、 Map 用得挺 Happy 的,只知道可以用它来保存数据,最近面试的时候问到这个 List 和 那个 List 的区别是什么呀, Vector 和 ArrayList 的区别是什么呀,我连 Vector 是什么都不知道,我咋知道它俩有什么区别。。。唉,说来说去还是基础的锅,这不,连视频带看书,重新了解了一下 Java 的类集。
1、基本概念
Java 类集就是一组 Java 实现的数据结构,它是一个特别有用的工具类,它就像一个容器,把多个对象(其实是对象的引用)存到这个容器中。
一般来说,如果想要保存多个对象,可以用到对象数组,但是如果使用对象数组的话,长度是固定的,所以一般不用数组。也可以用链表来实现一个动态的对象数组,但是链表的开发很麻烦,链表的操作性能又比较差,而且链表还会使用 Object 类来保存对象,会涉及到向上转型和向下强制转型。于是乎,类集的优势就体现出来了。
Java 设计之初就提供了类似链表的工具类—— Vector ,但是后来发现这个类并不能很好的描述数据结构,所以从 Java 2 开始,提供了类集框架来实现数据结构,并在 Java 5 引入了泛型的概念,于是可以在类集框架中避免向下强制转型。在 Java 8 后还提供了数据流的操作。
2、Collection 接口
Collection 接口是整个类集之中单值保存的最大父接口。单值保存即每一次向集合中保存一个对象,Collection 接口下的常用方法如下:
public boolean add(E e):将指定元素添加到集合中,添加成功则返回 true。
public boolean addAll(Collection<? extends E> c):将集合 c 中的所有元素添加到集合中,添加成功则返回 true。
public void clear():清空集合中的所有元素。
public boolean contains(Object o):判断集合中是否包含指定元素,如果包含则返回 true,否则返回 false。
public boolean containsAll(Collection<?> c):判断集合中是否包含集合 c 中的所有元素,如果包含则返回 true,否则返回 false。
public boolean isEmpty():判断集合是否为空,如果集合长度为 0 则返回true,否则返回 false。
public boolean remove(Object o):从集合中删除指定元素,如果集合中包含多个该元素时,只会删除第一个,删除成功则返回 true。
public boolean removeAll(Collection<?> c):从集合中删除集合 c 中的所有元素,删除成功则返回 true。
public int size():返回集合的长度,即元素个数。
public Object[] toArray():将集合转换成一个 Object 数组,所有的集合元素则变为对应的数组元素。
public <T> T[] toArray(T[] a):将集合转换为指定类型的数组。
public Iterator<E> iterator():返回 Iterator 对象,该对象用于遍历集合里的元素。
注意 contains() 和 remove() 方法是依靠 equal() 方法支持的。
一般来说,要使用接口中的方法,应该创建一个子类然后实现这个接口,但是由于开发的严格性,所以我们现在不会直接使用 Collection 接口,而是使用它的两个子接口 List 和 Set。这两个子接口的区别主要在于 List 允许元素重复而 Set 不允许重复。
2.1 List 接口
一般来说,80% 的情况都会使用 List 子接口,它对 Collection 接口进行了一些功能的扩展,下面是一些常用的扩展方法,主要是利用索引的一些方法:
public void add(int index,E element):添加元素到指定索引处。
public E get(int index):返回指定索引的元素。
public int indexOf(Object o):返回指定元素第一次出现的索引。
public int lastIndexOf(Object o):返回指定元素最后一次出现的索引。
public ListIterator<E> listIterator():返回一个 ListIterator 对象。
public E remove(int index):删除并返回指定索引的元素。
public E set(int index,E element):设置(修改)指定索引的元素。
public List<E> subList(int fromIndex,int toIndex):返回从 fromIndex(包含)到 toIndex(不包含)的元素的子集合。
default void sort(Comparator<? super E> c):使用 Comparator 对集合进行排序。这个方法后面不做记录,以后有时间专门记录下各个排序方式。
List 也是一个接口,所以要想使用这个接口,就必须使用它的子类,它的子类很多,ArrayList 、 LinkedList 、 Stack 、 Vector等等,最为常用的就是 ArrayList。
2.1.1 ArrayList
对于 ArrayList 不需要做过多的介绍,只需要知道 90% 的情况下都使用这个子类即可,下面通过 ArrayList 这个最为典型的子类来介绍下 Collection 接口和 List 接口常用方法的基本用法,然后等四个子类都介绍完了再将四个子类进行一下对比。
public class ListDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
System.out.println(list);
list.add("迈克尔乔丹");
list.add("科比布莱恩特");
list.add("特雷西麦克格雷迪");
list.add("阿伦艾弗森");
System.out.println(list);
System.out.println("list中是否包含'迈克尔乔丹':" + list.contains("迈克尔乔丹"));
System.out.println("list中是否包含'勒布朗詹姆斯':" + list.contains("勒布朗詹姆斯"));
list.remove("迈克尔乔丹");
System.out.println(list);
list.add(1, "凯文杜兰特");
list.add(3, "凯文杜兰特");
System.out.println(list);
System.out.println("凯文杜兰特第一次出现的索引:" + list.indexOf("凯文杜兰特"));
System.out.println("凯文杜兰特最后一次出现的索引:" + list.lastIndexOf("凯文杜兰特"));
list.set(2, "雷阿伦");
System.out.println(list);
System.out.println(list.get(2));
System.out.println("截取第二个元素到第四个元素的子集合:" + list.subList(2, 4));
list.clear();
System.out.println("list是否为空:" + list.isEmpty());
}
}
运行程序,打印结果如下:
注意 List 是成功保存了重复元素的,这和 Set 是有区别的。因为 List 接口是 Collection 接口的子接口,所以我们也可以用 ArrayList 来实例化 Collection 接口,但是就不能用 List 接口中的方法了:
这些方法的基本使用看着都没问题,但是如果我们现在来保存对象的话,会有一个问题产生:
class Person {
private String name;
private int age;
public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "[姓名:" + name + ",年龄:" + age + "]";
}
}
public class ListDemo {
public static void main(String[] args) {
List<Person> list = new ArrayList<Person>();
list.add(new Person("李一", 22));
list.add(new Person("张三", 23));
list.add(new Person("王八", 24));
System.out.println(list);
list.remove(new Person("王八", 24));
System.out.println(list);
}
}
这个程序看起来应该是添加了三个对象又删除了一个,所以最后应该还剩两个对象才对,但是实际运行结果却是:
这说明 list 集合中没有我们要删除的元素,在介绍 Collection 接口的方法的时候有提到 contains() 和 remove() 方法都依靠 equals() 方法,所以我们应该覆写 equals() 方法,如果不覆写的话则默认依靠地址来比较,覆写 equals() 方法和 hashCode() 方法的话 Eclipse 都有快捷键,也不用我们手动去写,覆写后重新运行,发现相同的对象被删除了:
2.1.2 Vector
Vector 是一个古老的类,在 Java 1 的时候就有了,因为那个年代 Java 还没有提供系统的集合框架,所以 Vector 中的方法名都比较长,比如 addElement(E obj),实际上它和
add(E e) 并没有什么区别。为了方便编程而简短了方法名,应运而生了类集框架,所以对集合的操作就有了新的标准,为了保留下 Vector 类,所以 Vector 类多实现了 List 接口,所以 Vector 中会有一些功能重复的方法,比如 addElement(E obj) 和 add(E e),在 Vector 中这两个方法都有。Vector 的使用跟 ArrayList 一样,因为它们依赖的是接口而不是具体类,所以只是具体子类不一样,用法完全一样:
public class ListDemo {
public static void main(String[] args) {
List<Person> list = new Vector<Person>();
list.add(new Person("李一", 22));
list.add(new Person("张三", 23));
list.add(new Person("王八", 24));
System.out.println(list);
}
}
运行结果如下:
2.1.3 Stack
Stack 表示的是栈操作,栈是一种先进后出的数据结构。Stack 是 Vector 的子类,但是它不会使用 Vector 的方法,它的常用方法并不多,事实上 Stack 这种数据结构使用得也并不多,主要也就是有“入栈”和“出栈”操作。
public E push(E item):将一个元素压入栈,这个元素处于栈顶。
public E peek():返回栈顶第一个元素,但并不会让元素出栈。
public E pop():返回栈顶第一个元素,并让这个元素出栈。
public boolean empty():判断该栈是否为空。
public int search(Object o):返回指定元素的索引。
public class ListDemo {
public static void main(String[] args) {
Stack<Person> stack = new Stack<Person>();
stack.push(new Person("李一", 22));
stack.push(new Person("张三", 23));
stack.push(new Person("王八", 24));
System.out.println("栈是否为空:" + stack.empty());
System.out.println(stack.peek());
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
}
}
运行程序,打印结果如下:
可以看到调用 peek() 方法只会返回栈顶元素,并不会将元素出栈,这个栈里面只有三个元素,当我们出栈三次后再调用 pop() 方法就发现程序报错了:java.util.EmptyStackException,说明当前栈已经为空了,不允许再执行出栈操作了。
栈这种结构在 Android 上体现得比较典型,因为 Back 键实际上体现的就是栈操作,每次按 Back 键后显示的总是处于栈顶的 Activity。
2.1.4 LinkedList
LinkedList 不仅实现了 List 接口,还实现了 Deque 接口,Deque 代表一个“双端队列”,可以同时从两端来添加和删除元素,Deque 的方法不过多描述,需要的时候查一下 API 即可,用一个简单例子示范一下 LinkedList 的用法:
public class ListDemo {
public static void main(String[] args) {
LinkedList<Person> list = new LinkedList<Person>();
list.offer(new Person("李一", 22));
list.push(new Person("张三", 23));
list.push(new Person("王八", 24));
list.offer(new Person("赵四", 25));
// 顺序应该是王八,张三,李一,赵四
System.out.println(list);
// 返回第一个元素但不删除该元素
System.out.println(list.peekFirst());
// 返回最后一个元素但不删除该元素
System.out.println(list.peekLast());
// 返回栈顶元素并将该元素出栈
System.out.println(list.pop());
// 返回最后一个元素并删除该元素
System.out.println(list.pollLast());
System.out.println(list);
}
}
运行程序,打印结果如下:
2.1.5 List 接口小结
先上一张表:
ArrayList | Vector | Stack | LinkedList | |
出现时间 | Java 2 | Java 1 | Java 1 | Java 2 |
性能 | 较优 | 同步处理,较差 | 同步处理,较差 | 随机访问元素时较差,新增和删除元素时占优 |
实现方式 | 数组 | 数组 | 数组 | 双向链表 |
容量扩展 | 每次至少扩展至(原长度*3)/2+1 | 每次至少扩展至原长度的2倍 | 同Vector | |
数据安全 | 线程不安全 | 线程安全 | 线程安全 | 线程不安全 |
总的来说,Vector 现在基本不怎么使用了,它的效率相对于 ArrayList 也要稍差一点,LinkedList 相对于 ArrayList 的话, 它除了 List 的功能还有双端队列和栈的功能,一般对性能要求不高的话,只需要知道它们之间这点差别就行了,但是由于它们俩的内部实现方式不同,数组结构在随机访问元素的时候效率较高,而链表结构在插入、删除操作时效率较高。但是总体来说还是 ArrayList 效率较高。在我们的开发中,还是优先考虑 ArrayList,只有涉及到大量插入、删除操作时才考虑 LinkedList。
2.2 Set 接口
Set 接口并没有对 Collection 接口进行功能的扩展,只是简单的继承了 Collection 接口,所以它没有 get() 等 List 接口特有的方法。
2.2.1 HashSet
HashSet 是 Set 接口的常用实现类,HashSet 就是按 Hash 算法来存储集合中的元素的,它的核心价值在于速度,因为它可以直接根据该元素的 hashCode 来计算元素的存储位置,所以它具有比较好的存取和查找性能。HashSet 的存储是无序的,而且不允许有重复元素,这是它和 List 的最大区别。
public class SetDemo {
public static void main(String[] args) {
Set<String> set = new HashSet<String>();
set.add("李一");
set.add("张三");
set.add("王八");
set.add("王八");
set.add("王八");
System.out.println(set);
}
}
运行程序,打印结果如下:
可以看到 set 集合存储元素跟添加先后无关,是无序的,而且并没有保存重复元素。然后再保存对象试试:
创建一个 Person 类:
class Person {
private String name;
private int age;
public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "[姓名:" + name + ",年龄:" + age + "]";
}
}
利用 HashSet 保存对象:
public class SetDemo {
public static void main(String[] args) {
Set<Person> set = new HashSet<Person>();
set.add(new Person("李一", 21));
set.add(new Person("张三", 22));
set.add(new Person("王八", 23));
set.add(new Person("王八", 23));
System.out.println(set);
}
}
运行程序,打印结果如下:
奇怪的事情发生了,不能保存重复元素的 HashSet 集合却保存了两个“重复”的对象。这是因为 HashSet 判断两个元素相等的标准是通过 equals() 和 hashCode() 方法,只有两个方法都比较相等时才会认为是同一个对象。所以我们需要重写这两个方法(必须两个都重写),利用编译器自动生成即可:
class Person {
private String name;
private int age;
public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + age;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Person other = (Person) obj;
if (age != other.age)
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
@Override
public String toString() {
return "[姓名:" + name + ",年龄:" + age + "]";
}
}
再次运行,发现重复元素没有了:
我们不用手动去重写 hashCode() 方法,但也可以了解一下它的基本规则:
1).程序运行过程中,同一个对象多次调用 hashCode() 方法返回的值应该相同。
2).当两个对象通过 equal() 方法比较返回 true 时,这两个对象的 hashCode() 应该返回相同的值。
3).对象中用作 equals() 方法比较标准的实例变量,都应该用于计算 hashCode 值。
2.2.1 TreeSet
HashSet 是无序的,而 TreeSet 是有序的,如何排序的,先不要方,先来看看能不能像使用 HashSet 一样使用 TreeSet,先保存字符串:
public class SetDemo {
public static void main(String[] args) {
Set<String> set = new TreeSet<String>();
set.add("李一");
set.add("张三");
set.add("王八");
set.add("王八");
System.out.println(set);
}
}
打印如下:
这没有问题,一旦保存对象:
public class SetDemo {
public static void main(String[] args) {
Set<Person> set = new TreeSet<Person>();
set.add(new Person("李一", 21));
set.add(new Person("张三", 22));
set.add(new Person("王八", 23));
set.add(new Person("赵四", 23));
System.out.println(set);
}
}
一运行,程序就报错了:
Exception in thread "main" java.lang.ClassCastException: com.qinshou.collectiondemo.Person cannot be cast to java.lang.Comparable
类型转换错误,Person类 不能转为 Comparable 类,这个 Comparable 类是什么,之前说 TreeSet 有序的,这个 Comparable 就是帮助 TreeSet 排序的比较器,String 类是实现了这个接口的,所以我们保存字符串才会没问题,现在我们要利用 TreeSet 来保存自定义的类,那么这个自定义的类也得实现 Comparable 接口并且覆写接口中的方法,先简单比较一下年龄:
@Override
public int compareTo(Person o) {
// TODO Auto-generated method stub
if (this.age > o.age) {
return 1;
} else if (this.age < o.age) {
return -1;
}
return 0;
}
重新运行程序,结果如下:
我们发现确实按照年龄,排序了,但是却少了“赵四”这个对象,为什么?原因是我们只比较了年龄,年龄相等的时候返回 0 ,所以我们知道了,当 compareTo() 方法返回 0 时, TreeSet 则认为是同一个对象,所以不会保存“重复”元素。所以还需要修改 compareTo() 方法:
@Override
public int compareTo(Person o) {
// TODO Auto-generated method stub
if (this.age > o.age) {
return 1;
} else if (this.age < o.age) {
return -1;
}
return this.name.compareTo(o.name);
}
运行程序,结果如下:
由此我们也发现了 TreeSet 的问题,类中的成员变量我们都需要去比较,当类中成员变量很多的时候,我们覆写 compareTo() 方法就特别麻烦,所以我们一般也不会使用 TreeSet。
2.2.3 Set 接口小结
HashSet | TreeSet | |
性能 | 总是优于TreeSet | 总是低于HashSet |
线程安全 | 线程不安全 | 线程不安全 |
Comparable | 不需要实现 | 必须实现该接口,并重写该接口中的方法进行排序 |
相同元素比较 | 利用 hashCode() 和 equals() 方法 | 利用 compareTo() 即可实现重复元素的判断 |
一般来说,我们为了省事都不愿意使用 TreeSet ,因为成员变量过多会导致覆写比较方法的麻烦,只有当我们需要实现特定的排序方式时,才会使用 TreeSet。需要注意的是,
compareTo() 方法判断重复元素并不是真正意义上的重复判断,在非排序的情况下,判断重复元素都是依靠 hashCode() 和 equals() 方法。
Set 接口使用得也比较少,如果真要使用的时候一定要记住一个原则就是:数据不重复。
3、Map 接口
Collection 接口是单值保存,即每次保存的是一个对象,而 Map 接口则是保存一对对象,即 key-value 对。value 可以重复,但是 key 不允许重复,因为每一个 key 只能对应一个 value,是一对一的关系。通过指定 key 可以拿到唯一确定的 value。 Map 接口常用方法如下:
public void clear():删除该 Map 中的所有 key-value 对。
public boolean containsKey(Object key):查询该 Map 中是否包含指定 key ,如果有则返回 ture ,否则返回 false 。
public boolean containsValue(Object value):查询该 Map 中是否包含指定 value (至少一个),如果有则返回 ture ,否则返回 false 。
public Set<Map.Entry<K,V>> entrySet():返回 Map 中包含 key-value 对组成的 Set 集合,每个元素都为 Map.Entry(Map 的内部类)。
public V get(Object key):根据指定 key 返回对应 value,如果该 Map 中不包含指定 key ,则返回 null。
public Set<K> keySet():返回该 Map 中所有 key 组成的 Set 集合。
public put(K key, V value):添加一个 key-value 对,如果该 key-value 对已存在,则覆盖原来的数据。
public V remove(Object key):删除指定的 key 对应的 key-value 对,如果该 key 不存在,则返回 null。
public Collection<V> values():返回该 Map 中所有 value 组成的 Collection。
Map 接口并不像 Collection 接口那样还有更具体的子接口,它的实现类也很多,比较常用的 HashMap 和 Hashtable。
3.1 HashMap
Map 存放数据的最终目的是为了数据的查找,就像字典一样,而 Collection 的目的是为了存放数据和输出数据。
还是利用 HashMap 子类来介绍一下 Map 的基本使用:
public class MapDemo {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<String, Integer>();
map.put("一", 1);
map.put("二", 2);
map.put("三", 3);
System.out.println(map);
System.out.println("是否包含key '一':" + map.containsKey("一"));
System.out.println("是否包含key '四':" + map.containsKey("四"));
System.out.println("是否包含value '1':" + map.containsValue(1));
System.out.println("是否包含value '4':" + map.containsValue(4));
System.out.println(map.get("二"));
for (String string : map.keySet()) {
System.out.println("每一个key:" + string);
}
for (int i : map.values()) {
System.out.println("每一个value:" + i);
}
map.remove("二");
System.out.println(map);
map.clear();
System.out.println(map);
}
}
运行程序,打印结果如下:
通过打印我们可以看出,HashMap 的存放都是无序的,只要带有 hash,都是无序的。
HashMap 的 key 和 value 都是可以为 null 的。
public class MapDemo {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<String, Integer>();
map.put(null, 0);
map.put("空", null);
System.out.println(map);
}
}
运行程序,打印如下:
一般情况下,我们是使用 Integer 或者 String 类型的数据作为 key ,那么如果我们使用自定义类来作为 key 会发生什么情况呢?先创建一个 Person 类:
class Person {
private String name;
private int age;
public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "[姓名:" + name + ",年龄:" + age + "]";
}
}
我们来看看分别使用 String 和 自定义 Person 类作为 key 时的不同情况:
public class MapDemo {
public static void main(String[] args) {
Map<String, Person> map = new HashMap<String, Person>();
map.put("李一", new Person("李一", 21));
System.out.println(map.get("李一"));
Map<Person, String> map2 = new HashMap<Person, String>();
map2.put(new Person("李一", 21), "李一");
System.out.println(map2.get(new Person("李一", 21)));
}
}
运行程序,打印结果如下:
可以看到使用 Person 类作为 key 的时候,取出时为 null 。Map 中有一个方法 keySet() 是获得所有 key 的,返回的是一个 Set 集合,就是说 key 是存放在 Set 集合中的,前面提到 Set 集合判断重复元素依靠的是 hashCode() 和 equals() 方法,所以我们如果要使用自定义类作为 key 的话,必须要覆写这两个方法。一般情况下,首选都是使用 String 类作为 key。
3.2 Hashtable
Hashtable 是一个古老的实现类,从 Java 1 就出现了,所以它跟 Vector 类似,里面也有几个方法名比较长的方法,它的使用跟 HashMap 完全一样,因为之前说过,它们依赖的是接口。但是它和 HashMap 不同的是它不允许 key 或 value 为 null。
public class MapDemo {
public static void main(String[] args) {
Map<String, Integer> map = new Hashtable<String, Integer>();
map.put("一", 1);
map.put("二", 2);
map.put("三", 3);
System.out.println(map);
map.put(null, 0);
map.put("空", null);
}
}
运行程序,打印结果如下:
当保存 null 的时候抛出了 NullPointerException ,这就是 Hashtable 和 HashMap 的不同。
3.3 Map 接口小结
由于 Hashtable 和 Vector 一样都是一个古老的类,所以 HashMap 与 Hashtable 的区别就和 ArrayList 与 Vector 的区别类似。
HashMap | Hashtable | |
出现时间 | Java 2 | Java 1 |
性能 | 异步处理 | 同步处理 |
数据安全 | 线程不安全 | 线程安全 |
设置null | key和value 都允许为null | key和value都不允许为null |
4、总结
Java 的类集框架其实还有很多接口和子类,比如 Deque 接口,EnumSet 类等,但是最常用的基本就是我总结的几个,这几个类集框架基本也就能应对平时的开发了。类集框架中也还有很多东西没细细总结,比如每个集合的遍历方式,一般最经典的方式就是使用 Iterator 来遍历,这也是 Java 内部实现的迭代器模式,还有我以前老用的 foreach 方式,for 循环方式,以及 Enumeration 接口遍历,List 特有的 listIterator 遍历方式,这些方式实现遍历都不难,只是性能会有些许差异。还有一个操作集合的工具类 Collections 可以帮助我们操作集合,但是里面只是些方法,就不过多总结了。
看这一章节的东西花了三天,其实这些东西在平时开发中也经常用到,只是面试时忽然一问到就有点懵逼,基础不能丢,虽然看着枯燥,但终归还是要弄清楚的。