Java集合详解

371 阅读15分钟

集合

基本数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。

排序

排序有两种方式:自然排序 vs 定制排序

自然排序

  1. 实现Comparable接口

  2. 重写compareTo方法

  3. 按照属性进行排序

  4. 添加元素

定制排序

  1. 创建一个Comparator实现类的对象

  2. 将Comparator对象传入TreeSet的构造器中

  3. 重写compare方法

  4. 按照属性进行排序

  5. 添加元素

Collections工具类

Collections 是一个操作 Set、List 和 Map 等集合的工具类,Collections 中提供了一系列静态的方法对集合元素进行排序、查询和修改等操作,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法

Collection集合与数组间的转换

  • 集合 –> 数组 : toArray()

  • 数组 –> 集合 : Arrays.asList(T...t)

注意

List:使用Collection集合存储数据,要求数据所在的类满足:必须重写equals方法

Set:存储元素所在类的要求:要求必须重写hashCode和equals方法

TreeSet:自然排序的情况下该对象的类必须实现 Comparable 接口,且重写compareTo()方法与equals()方法

排序操作(均为static方法)

  • reverse(List):反转 List 中元素的顺序

  • shuffle(List):对 List 集合元素进行随机排序

  • sort(List):根据元素的自然顺序对指定 List 集合元素按升序排序

  • sort(List,Comparator):根据指定的 Comparator 产生的顺序对 List 集合元素进行排序

  • swap(List,int, int):将指定 list 集合中的 i 处元素和 j 处元素进行交换

查找、替换

  • Object max(Collection):根据元素的自然顺序,返回给定集合中的最大元素

  • Object max(Collection,Comparator):根据 Comparator 指定的顺序,返回给定集合中的最大元素

  • Object min(Collection)

  • Object min(Collection,Comparator)

  • int frequency(Collection,Object):返回指定集合中指定元素的出现次数

  • void copy(List dest,List src):将src中的内容复制到dest中

  • boolean replaceAll(List list,Object oldVal,Object newVal):使用新值替换 List 对象的所有旧值

操作数组的工具类:Arrays

操作集合的工具类:Collections

List集合去重

  1. 循环list中的所有元素然后删除重复(双重for循环)

  2. 通过HashSet踢除重复元素,

    无序

    public static List removeDuplicate(List list) {   
        Set s = new HashSet(list);   
        list.clear();   
        list.addAll(s);   
        return list;   
    }
    

    有序

    public static void removeDuplicateWithOrder(List list) {    
        Set set = new HashSet();    
        List newList = new ArrayList();    
        for (Iterator iter = list.iterator(); iter.hasNext();) {    
            Object element = iter.next();    
            if (set.add(element))    
                newList.add(element);    
        }     
        list.clear();    
        list.addAll(newList);    
        System.out.println( " remove duplicate " + list);    
    }
    
  3. 使用LinkedHashSet 去重并保持顺序

    public static void main(String[] args){ List numbersList = new ArrayList<>(Arrays.asList(1, 1, 2, 3, 3, 3, 4, 5, 6, 6, 6, 7, 8)); System.out.println(numbersList); Set hashSet = new LinkedHashSet<>(numbersList); ArrayList listWithoutDuplicates = new ArrayList<>(hashSet); System.out.println(listWithoutDuplicates); }

  4. 把list里的对象遍历一遍,用list.contains(),如果不存在就放入到另外一个list集合中

    public static List removeDuplicate(List list){
    List listTemp = new ArrayList();
    for(int i=0;i<list.size();i++){
    if(!listTemp.contains(list.get(i))){
    listTemp.add(list.get(i));
    }
    }
    return listTemp;
    }

  5. 用JDK1.8 Stream中对List进行去重:list.stream().distinct();

    List a = new ArrayList<> (); a.add("a"); a.add("b"); a.add("b"); List b = new ArrayList<> (); b.add("a"); b.add("c"); b.add("b"); a.addAll(b); List list=(List) a.stream().distinct().collect(Collectors.toList()); System.out.println(list);

List集合之ArrayList 线程不安全

  1. new ArrayList时 未指定类型:底层是创建了一个默认大小10Object类型的数组HasnMap默认大小为16)

  2. 当大小不够时,进行自动扩充(使用Arrays.copyOf()进行复制搬家),扩充大小为原值的一半,10 -> 15 ->15+15/2(7)=22

例子:

  1. 故障现象

/* 多线程下 报错 ConcurrentModificationException(并发修改异常) */ List list = new ArrayList<>();

   for (int i = 0; i <30 ; i++) {
       new Thread(()->{
           list.add(UUID.randomUUID().toString().substring(0,8));
           System.out.println(list);
       },String.valueOf(i)).start();
   }

   2. 产生原因

并发争抢同一个资源类,且没加锁

   3. 解决方案

/(不推荐)方法一: 使用Vector()/ List list = new Vector<>();

/*(不推荐)方法二: 使用Collentions工具类*/
	List<String> list = Collections.synchronizedList(new ArrayList<>());

/*方法三: 写时复制*/
	List<String> list = new CopyOnWriteArrayList<>();

   4. 优化建议(确保不会犯第二次)

使用方法三:写时复制方法

写时复制 解读:

/*CopyOnWrite容器即写时复制的容器。往一个容器添加元素的时候,不直接往当前容器Object[]添加,而是先将当前容器Object[]进行copy,复制出一个新的容器Object[] newELements,然后向新的容器Object[] newELements里添加元素,添加完元素之后,再将原容器的引用指向新的容器=>setArray(newELements)。这样做的好处是可以对CopyOnWrite容器进行并发的读,
而不需要加锁,因为当前容器不会添加任何元素。所以Copyonwrite容器也是一种读写分离的思想,读和写不同的容器*/
源码:
private transient volatile Object[] array;
/**...array的get/set方法*/
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();					 //获取修改前数组
        int len = elements.length;						//获取修改前数组长度
        Object[] newElements = Arrays.copyOf(elements, len + 1);//对修改前的数组进行复制扩充 扩充一个位置
        newElements[len] = e;							//将 要添加的数据放在该数组的最后
        setArray(newElements);							//修改数组为修改后的数组
        return true;
    } finally {
        lock.unlock();
    }
}

List集合之LinkedList 线程不安全

继承于AbstractSequentialList的双向链表,且头结点中不存放数据,

没有初始化大小,也没有扩容的机制,就是一直在前面或者后面新增就好

Set集合之HashSet 线程不安全

HashSet底层是HashMap, key为我们设置的值,value为一个常量对象

源码:

解决办法

List list = new CopyOnWriteArraySet<>();

/**添加元素时底层使用CopyOnWriteArrayList.addIfAbsent方法*/
源码:
public boolean add(E e) {
    return al.addIfAbsent(e);
}

/**官方注释:如果不存在追加元素*/
public boolean addIfAbsent(E e) {
    Object[] snapshot = getArray();
    //判断e是否存在在数组中,indexOf>0表示是,返回false,否则调用 addIfAbsent(e, snapshot);
    return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
    addIfAbsent(e, snapshot);	
}
/** 与List添加元素同理,但是在第一步进行判断与之前的数组是否一致,是为了防止这种可能性的出现:在判断完是否存在元素和加锁之间,另一个线程加入了我们要加的元素,不判断的话,那么同一个元素就有可能出现俩次,违背了addIfAbsent的意愿。*/
private boolean addIfAbsent(E e, Object[] snapshot) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] current = getArray();
        int len = current.length;
        if (snapshot != current) {
            // Optimize for lost race to another addXXX operation
            int common = Math.min(snapshot.length, len);
            for (int i = 0; i < common; i++)
                if (current[i] != snapshot[i] && eq(e, current[i]))
                    return false;
            if (indexOf(e, current, common, len) >= 0)
                return false;
        }
        Object[] newElements = Arrays.copyOf(current, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

Map集合 线程不安全

java 1.7 HashMap中hash数组默认大小是16,之后每次扩充为原来的2n。 HashTable中hash数组默认大小是11,增加的方式是 2n+1,TreeMap 的实现就是红黑树数据结构,也就说是一棵自平衡的排序二叉树,

解决办法:

方法一: HashTable 线程安全 效率低 源码: public synchronized V put(K key, V value) { // Make sure the value is not null if (value == null) { throw new NullPointerException(); } 方法二:锁分段技术 ConcurrentHashMap:容器里有多把锁,每一把锁用于锁容器其中一部分数据 Map<String,String> map = new ConcurrentHashMap<>();

锁分段技术ConcurrentHashMap实现原理

JDK1.8的实现

已经抛弃了Segment分段锁机制,利用CAS+Synchronized来保证并发更新的安全。数据结构采用:Node数组+链表+红黑树。

put操作 对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述

  1. 如果没有初始化就先调用initTable()方法来进行初始化过程

  2. 如果没有hash冲突就直接CAS插入

  3. 如果还在进行扩容操作就先进行扩容

  4. 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,

  5. 最后一个如果Hash冲突时会形成Node链表,在链表长度超过8,且Node数组超过64时会将链表结构转换为红黑树的结构,break再一次进入循环

  6. 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

get操作

  1. 计算hash值,定位到该table索引位置,如果是首节点符合就返回

  2. 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回

  3. 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null

size操作

在扩容和addCount()方法就已经有处理了

JDK1.7的实现

在JDK1.7版本中,concurrentHashMap是由Segment数组结构和HashEntry数组结构组成。(Segment是一种可重入锁,继承ReentrantLock,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。)

put操作

put 和get需要两次hash去定位数据的存储位置,先定位Segment位置,再定位HashEntry位置

从上Segment的继承体系可以看出,Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLocktryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒

get操作

ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null

size操作

在计算size的时候,并发的插入数据会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据)解决办法:

  1. (类似CAS)第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的

  2. 第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回

Hash函数:

直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址。 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。 伪随机数法:采用一个伪随机数当作哈希函数。

解决Hash冲突,避免hash碰撞

  • 开放定址法:

    开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

  • 链地址法

    将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。

  • 再哈希法

    当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。

  • 建立公共溢出区

    将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。

HashMapHashTable

HashTable

Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。

Hashtable也是JDK1.0引入的类,是线程安全的,能用于多线程环境中。

Hashtable同样实现了Serializable接口,它支持序列化,实现了Cloneable接口,能被克隆。

HashMapHasptable区别

  1. 继承的父类不同

    HashMap继承自AbstractMap类。但二者都实现了Map接口。 Hashtable继承自Dictionary类,Dictionary类是一个已经被废弃的类(见其源码中的注释)。父类都被废弃,自然而然也没人用它的子类Hashtable了。

  2. HashMap线程不安全,HashTable线程安全

    Hashtable 中的方法大多是Synchronize的,而HashMap中的方法在一般情况下是非Synchronize的。

  3. 包含的contains方法不同

  4. 是否允许null

  5. 计算hash不同

  6. 扩容方式不同

  7. 解决hash冲突不同

HashMap的工作原理是什么

使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。

以下是具体的put过程(JDK1.8版)

1、对Key求Hash值,然后再计算下标

2、如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中)

3、如果碰撞了,以链表的方式链接到后面

4、如果链表长度超过阈值( TREEIFY THRESHOLD==8),就把链表转成红黑树, 红黑树节点元素小于6,就把红黑树转回链表

5、如果节点已经存在就替换旧值

6、如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)

以下是具体get过程(考虑特殊情况如果两个键的hashcode相同,你如何获取值对象?)

当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。

重新调整HashMap大小存在什么问题吗?

多线程的环境下不使用HashMap,1.7头插法并发操作容易造成死锁

HashMap中的节点数超过阈值的时候,就会自动扩容,扩容的时候就会调整HashMap的大小,一旦调整了HashMap的大小就会导致之前的HashMap计算出来的hash表中下标无效,所以所有的节点都需要重新hash运算,结果就是带来时间上的浪费。因此我们要尽量避免HashMap调整大小,所以我们使用HashMap的时候要给HashMap设置一个默认值,这个默认值要大于我们HashMap中存放的节点数。

1.7在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。

拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

说说你对红黑树的见解?

1、每个节点非红即黑

2、根节点总是黑色的

3、如果节点是红色的,则它的子节点必须是黑色的(反之不一定)

4、每个叶子节点都是黑色的空节点(NIL节点)

5、从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

HashMap1.7/1.8变化

底层数据结构的变化:

HashMap 实现方式由数组+单向链表变为数组+单向链表+红黑树

当链表数组大于8时,底层结构由数组+链表转化为数组+红黑树。提高性能

解决冲突的办法的变化:

HashMap和其他基于map的类都是通过链地址法解决冲突,它们使用单向链表来存储相同索引值的元素。在最坏的情况下,这种方式会将HashMap的get方法的性能从O(1)降低到O(n)。为了解决在频繁冲突时Hashmap性能降低的问题,Java 8中使用平衡树来替代链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)提高到O(logn)