Java集合类-实用性梳理

242 阅读11分钟

如果说数据结构是以抽象的形式来描述数据存储的组织和方式,那么Java集合类则是实实在在的容器,他能以不同种类的格式来存放业务数据。

在集合类中,泛型是不可或缺的元素。

根据数据存储格式的不同,可以把集合分为两类:

  • 一类是以Collection为基类的线性表类,如数组和ArrayList等;
  • 另一类是以Map为基类的键值对类,如HashMap和Hashtable;

集合是容器,其中不仅可以存储String、int等数据类型,还可以存储自定义的Class类型数据。

除了数组、List、Set对象之外,Java的线性表类对象还有Vector、队列Queue、堆栈Stack,但在项目中用得比较多的还是前三者。

重视集合对象的选择,可以避免隐藏的问题,可以发挥更好的性能。


List类集合

ArrayList和LinkedList的适用场景及性能分析

ArrayList是基于数组实现的。因此随机查询快。增删要移动数组位置。

LinkedList是基于双向链表实现的。因此增、删快。查询需要从头开始找。

ArrayList和LinkedList都是线程不安全的。

在实际项目中,往往没有单纯的添加(或删除)和单纯的查找操作,一般都是两者配合使用。

测试可得:

  • 在尾部添加元素,ArrayList更快。ArrayList基于数组能很快地定位到尾部。
  • 在随机位置添加元素,LinkedList更快。包括查找位置和添加两个动作。
  • 在随机位置查找,ArrayList更快。

对比ArrayList和Vector,分析Vector为何不常用

ArrayList和Vector都是基于数组实现的。

ArrayList:线程不安全。扩容50%。

Vector:线程安全。扩容100%。

在插入等操作时,Vector需要一定开销来维护线程安全,而大多数的程序都在单线程环境下,无须考虑线程安全问题,所以单线程环境下ArrayList性能优于Vector。

从节省内存空间的角度,也建议使用ArrayList。

Set类集合

从存储数据的格式上看,Set也属于线性表,但在其中不能存储重复的元素。而且,一个Set对象里最多只能存储一个null元素。

由于Set有自动去重的特性,在项目中常用他来整理数据。

自定义类Set集合去重判断

HashSet

HashSet 会先去比较对象的hashcode 方法返回值,如果相同,再去比较对象的equals方法。因此一般可以通过重写对象的hashcode 和equals方法来修改对象的判重规则。

在Java中,若a.equals(b)为true,则a和b的HashCode一定相等。

Object类中的equals方法判断的是对象的引用是否相同。Java中的散列表采用hashCode计算在数组中的位置。

TreeSet:

TreeSet 的去重是由所add对象声明的compareTo 决定的,不由对象的equals方法决定。

Set 接口是按照equals操作定义的,但TreeSet实例使用它的 compareTo(或 compare)方法对所有元素进行比较,因此从 set 的观点来看,此方法认为相等的两个元素就是相等的。即使 set 的顺序与 equals 不一致,其行为也是定义良好的;它只是违背了Set接口的常规协定。

在自定义类中,重写equals方法是很好的习惯。

HashSet、LinkedHashSet、TreeSet的特点

HashSet

  • 基于哈希表实现。
  • 线程不安全。
  • 允许存在null元素,但null元素只能有1个。
  • 不能保证插入次序和遍历次序一致。

LinkedHashSet

  • 基于哈希表实现。
  • 采用链表的方式来保证插入次序和遍历次序一致。

TreeSet

  • TreeSet是SortedSet接口的唯一实现类,通过二叉树存储数据的方式来保证存储的元素处于有序状态。
  • 不允许插入null值。
  • 如果TreeSet中存储的不是基本数据类型,而是自定义的class,那么这个类必须实现Comparable接口中的compareTo方法,TreeSet会根据compareTo中的定义来区分大小,最终确定TreeSet中的次序。
  • compareTo,0表示相等,1表示大于,-1表示小于。

Map类集合

项目中对Map类对象常见用法:

  • 可以通过put来存放对象
  • 通过get方法来获取对象
  • 通过containsKey方法来判断是否存在某个对象

通过hash算法来了解HashMap的高效性。

理解重写equals和hashCode方法的必要性。

hash值冲突的问题无法彻底避免,但是hash函数设计得合理,能保证同义词链表的长度被控制在一个合理的范围中。

为什么要重写equals和hashCode方法

HashMap存入自定义类时,如果不重写自定义类的equals和hashCode方法,就会调用Object的,导致得到的结果会和预期的不一样。

对于引用对象,“==”比较的是对象的内存地址。

Object.equals:实际上跟“==”等效,比较的是内存地址

public boolean equals(Object obj) {
        return (this == obj);
    }

Object.hashCode:native方法,返回值不仅仅是对象内存地址。

public native int hashCode();

对比HashMap、Hashtable、HashSet

三者都是基于hash表实现的。

在遍历时,都是乱序的。原因是存储元素的位置和元素的值有一定的关联关系,是通过hashCode关联的,而不是顺序插入的。

如果要保证遍历的顺序,可以使用LinkedHashMap或LinkedHashSet。但在实际项目中,更多的是要“查询元素的便捷性”,对于“顺序访问”并没有打大的需求,所以项目中Linked类的对象用得并不多 。

HashMap、Hashtable是键值对的集合,HashSet是线性表的集合。

HashSet是基于HashMap实现的。

为了保证HashSet中自定义对象的唯一性,必须重写自定义类中equals和hashCode方法。

HashMap和Hashtable存放自定义的类,也要重写equals和hashCode方法。

HashMap是线程不安全的,HashTable是线程安全的。所以HashMap是轻量级的,在单线程情况下,性能要由于Hashtable。

Hashtable不允许null值作为键。HashMap允许null值为键,但尽量不要这样使用。

通过迭代器遍历HashMap的方法

有多种方法可用来遍历HashMap等键值对集合,但还是建议使用迭代器遍历。

Map<String,String> map = new HashMap<String,String>();
Iterator<Entry<String,String>> mapIt = map.entrySet().iterator();
while(mapIt.hasNext()) {
	Map.Entry<String,String> entry = (Map.Entry<String,String>)mapIt.next();
    String key = entry.getKey();
    String value = entry.getValue();
}
/*
//也建议这样遍历
for(Entry<String,String> entry:map.entrySet()) {
	String key = entry.getKey();
    String value = entry.getValue();
}
*/

其中Entry用来表示HashMap中的每条键值对信息。

集合中存放的是引用,理解浅复制和深复制

自定义的类是以引用的形式放入集合的,如果使用不当,会引发非常隐蔽的错误。

Car c = new Car(1);
ArrayList<Car> a1 = new ArrayList<Car>();
ArrayList<Car> a2 = new ArrayList<Car>();
a1.add(c);
a2.add(c);

a1.get(0).setI(2);//a2中的值也会改变

由此证明,集合中从存放的是引用,属于浅复制。

程序员把同一份变量放入两个不同的集合对象中,本意是在一个集合中给该变量做个备份,只在另一个集合中修改。要正确的实现“一个集合做备份另一个集合做修改”的效果,就要通过clone方法来实现深复制。

要实现clone,自定义的类必须要实现Cloneable接口。同时重写clone方法,也可以通过super调用父类的clone方法来完成内容的复制。

class Car implements Cloneable {
    private int i;
    public Car(i){
        this.i = i;
    }
    //调用父类的clone完成对象的复制
    public Object clone() throw CloneNotSupportedException {
		return super.clone();
    }
}


Car c1 = new Car(1);
Car c2 = (Car)c1.clone();
ArrayList<Car> a1 = new ArrayList<Car>();
ArrayList<Car> a2 = new ArrayList<Car>();
a1.add(c1);
a2.add(c2);

通过迭代器访问线性表的注意事项

不推荐使用for或foreach来遍历ArrayList等线性表对象。

在实际项目中,更多地使用迭代器Iterator来比遍历。优点是无论待访问的集合类型是什么,也无论待访问的集合存储的是哪种类型的对象,迭代器都可以用一种方式来遍历。 即通过统一的方式来访问集合对象。

即通过hashNext方法来判断是否有下一个元素;如果有,通过next方法来获取该元素。

List<Integer> list = new ArrayList<Integer>();//换成Set也是这样遍历
for(int i=0 ;i<5;i++) {
    list.add(Integer.valueOf(i));
}
Iterator<Integer> listIt = list.iterator();
while(listIt.hasNext()) {
    System.out.println(listIt.next());
}

迭代器遍历时,不能同时修改待遍历的集合对象。否则会抛出异常。

如果要避免这种异常,方法如下 :

  • 可以用CopyOnWriteArrayList来代替ArrayList。
  • 不使用迭代器,用其他方式遍历(for循环)。

在实际项目中,如果边遍历边修改,会增加出错的风险。这种错误未必明显,以至于在发现和纠错时会付出较大的代价。所以建议使用迭代器遍历,因为它的异常机制是一种保护措施。通过查看异常,程序员可以清楚地知道问题出在哪里,比到处加断点来查问题要好得多。

Collections类和Collction接口

java.util.Collections是集合的一个类,包含了一些与集合操作相关的静态多态方法。

Collection接口是线性表类集合的父接口。

通过sort方法对集合进行排序

大多数集合不支持自动排序(TreeXXX支持)。对于那些不支持的,可以通过Collections.sort实现对其元素的排序。

  • Collections.sort(List);对于自定义类集合,需实现Comparable接口,并在compareTo方法中定义排序规则。
  • Collections.sort(List,Comparator);//直接在Comparator定义排序规则。

把线程不安全变成线程安全的方法

使用Collections中的synchronizeXXX方法。

泛型

不指定泛型,集合对象实际上存储着Object类型的数据。容易在不经意间把不同类型的数据放入同一个集合中,从而导致后续的异常,解决这类问题的成本就提高了。所以应该从源头上预防,在定义集合时,尽可能地应用泛型指定可以接受的类型。

泛型可作用在类和接口上。

把泛型作用到类上,就可以用比较灵活的方式来定义类中的数据类型,从而这个类也有比较高的通用性。作用到接口上与作用到类上相似。

泛型约束可用T或E等任何字符,但要在类或接口上定义先好,并且上下文统一。

泛型的继承和通配符

在定义泛型时,可以通过extends来限定泛型类型的上限,也可以通过super来限定其下限,这两个限定一般会与?等关键字搭配使用。

List<? extends XXX> list	//存放XXX的子类
List<? super XXX> list		//存放XXX的父类

一般从extends的集合中读取元素,向super的集合中写元素。

在定义方法的参数时,可以用带extends和super的泛型来确保输入参数类型的准确性。除此之外,没有其他合适的用途。

一些错误用法:

//用带问号的类型实例化集合对象
List<?> list = new ArrayList<String>();//正确
List<?> list = new ArrayList<?>();//错误。编译器不知道list集合该采用哪种泛型了新型。
//向包含<? extends XXX>泛型的集合中写元素
List<? extends XXX> list = new ArrayList<XXX>();
list.add(f);//错误。编译器不知道基于XXX的子类型究竟是什么,为了保证类型安全,所以不允许在里面加数据。
//从包含<? super XXX>泛型的集合中读
List<? super XXX> list = new ArrayList<XXX>();
list.add(f);//正确
list.get(0);//错误。编译器不知道该用哪种XXX的父类来接收get的返回值,为了保证类型安全,不所以不允许读取。

ConcurrentHashMap底层实现

整个ConcurrentHashMap是一个Segment数组,Segment通过继承ReentrantLock来进行加锁,所以每次需要加锁的操作就是锁住一个Segment,这样只要保证每个Segment是线程安全的,也就实现了全局的线程安全。

get操作是没有加锁的。

put操作的线程安全性。添加节点到链表的操作时插入到表头的,所以,如果这个时候get操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是get操作在put之后,需要保证刚插入表头的节点被读取,这个依赖于setEntryAt方法中使用的UNSAFE.putOrderedObject.