Java容器

97 阅读8分钟

为什么需要集合类

  • 数组的特点是长度确定,只能存储单一数据类型,初始化后长度不可修改,提供的方法有限且效率不高,无法满足诸如无序,不可重复等复杂要求。

单列集合Collection接口

  • List有序可重复集合
    • ArrayList
      • 底层是数组,支持随机访问,查询效率高,插入和删除效率低,不用存储指针故空间花费比LinkedList低。
      • 初始容量为10个元素,扩容时容量乘以1.5倍,将旧数组内容复制到新数组中
    • LinkedList
      • 底层是双向循环链表,不支持随机访问,查询效率低,插入和删除效率高,每个节点需要存储指针,空间花费比ArrayList高。
    • Vector
      • 线程安全,通过加synchronized关键字实现,效率低。
  • Set无序不可重复集合
    • HashSet
      • HashSet底层是HashMap,HashSet将数据作为HashMap的key,而HashMap的value使用一个相同的虚值来保存。因为HashMap的key值本身就不允许重复,HashMap遇到重复的kv值会拿新v覆盖掉旧v,而HashSet直接返回插入失败即可。
    • LinkedHashSet
      • 在Hashset基础上使用双向链表来保存元素添加的前后关系以实现有序
    • TreeSet
      • 可排序集合(注意可排序是元素大小,LinkedHashSet的有序是插入顺序),底层是红黑树
  • Queue队列
    • ArrayDeque
      • 底层可以由数组或链表实现,是一个双端队列
    • PriorityQueue
      • 优先队列,底层由堆实现
    • BlockingQueue
      • 底层可以由数组或链表实现,队列为空时会阻塞线程获取元素,队列满时会阻塞线程插入元素,队列满足获取或插入条件时会通知被阻塞的线程。
  • Collection接口与Collections接口
    • Collection是集合的接口
    • Collections是操作Collection和Map的工具类,Collections提供synchronized系列方法,可以将指定集合包装成线程同步的集合,可以解决线程安全问题。

Map接口

  • HashMap
    • 底层结构
      • jdk7时底层为数组+链表,jdk8时底层为数组+链表+红黑树(主体是数组,用链表解决hash冲突,链表较长时变为红黑树确保查询效率)
    • 扩容机制
      • 数组默认大小为16,加载因子为0.75,当数组中的元素数量超过16 * 0.75=12时扩容为原来2倍。创建一个新容量大小的数组,将旧的值重哈希到新数组中。
      • 链表什么时候转化为红黑树:当某个链表的长度达到8,此时如果数组长度小于64会先扩容,如果已经达到64,则这个链表变为红黑树。
      • 为什么不直接用红黑树:红黑树需要旋转和变色来保持平衡,查询变快了但是插入变慢了,当元素个数小于8时单链表能够保证查询效率。
      • 负载因子为什么是0.75:是一个较平衡的选择,若空间足够想追求时间可以降低负载因子;若空间不足但对时间要求不高可以增加负载因子。
    • 插入流程
      • 先判断数组是否为空,若是则初始化数组。
      • 取出key的hashcode,根据hashcode计算出hash值,hash值对数组长度取模得到存放位置。
      hash值计算方式为hashcode^(hashcode>>>16)。
      hashcode^(hashcode>>>16)表示让hashcode高低十六位都参与运算,从而使得hash分布更加均匀。
      数组长度为2的n次方时,可以使用与运算代替取余运算加快效率,计算方式为hashcode & (len-1)
      
      • 若数组存放位置为空则添加成功。若位置不为空,依次对比此位置上的所有元素的hash值。若发生hash冲突,且key相同(用equals比较),则覆盖对应的value。若发生hash冲突,但key不同,则将节点加入链表或红黑树中
      • 节点插入之前要判断链表和数组的长度,判断是否需要扩容,执行扩容后再插入节点
    • HashMap线程不安全的情况
      • 多线程下扩容死循环:jdk7中使用头插法插入元素,在多线程下可能形成环形链表,造成死循环。故jdk8使用尾插法,保持了元素原本顺序,避免了扩容死循环。
      • 多线程的put可能导致元素丢失:多线程put同一个位置时,前一个key会被后一个key覆盖导致元素丢失。
      • put和get并发时,可能导致get为null:线程1 put时需要扩容,在rehash时线程2 get导致获取到null。
    • 其他
      • jdk8相比于jdk7,底层数组的声明由饿汉式变为懒汉式,底层数组结构由Entry变为Node,数据结构由数组+链表变为数组+链表+红黑树。
      • 一般用String这种不可变类型作为key,因为不可变所以创建时就计算好了hashcode,不用重新计算,且String重写好了hashCode和equals方法。
      • 解决hash冲突的方法:开放寻址法(冲突后按照一定规则向下找第一个空的地址,不能删除节点),双重哈希法,拉链法,建立统一溢出空间。
  • LinkedHashMap
    • 在HashMap基础上加了双向链表结构,可以记录元素的添加顺序
  • TreeMap
    • 在HashMap基础上实现了按照元素大小排序功能,底层为红黑树
  • ConcurrentHashMap
    • 线程安全的HashMap,默认并发度为16,若设置了其他并发度,会使用大于等于该值的最小的2的幂指数作为实际并发度,方便计算。
    • 底层实现
      • jdk7时由Segment数组结构和HashEntry数组结构组成,即每个Segment控制n个HashEntry,Segment继承了ReentrantLock,为其下的HashEntry加锁,可以并发访问不同的Segment。
      • jdk8时底层与HashMap相同(数组+链表+红黑树),抛弃了Segment,采用CAS和synchronized实现更低粒度的锁,锁住的是链表/红黑树的根节点,大大提高了并发度。
    • put方法执行逻辑
      • jdk7时,先计算出hash值,定位到Segment尝试获取锁,获取失败则自旋获取锁,自旋64次后改为阻塞获取锁。获取到锁后与HashMap的put操作相同。
      • jdk8时,先计算出hash值,定位到头结点,若为null,通过cas的方式尝试添加;若头结点正在扩容,则参与一起扩容;否则用synchronized锁住头结点,进行插入。
    • get方法
      • 不需要加锁,因为Node的值和指针用了volatile修饰,在多线程环境下修改值或指针对其他线程可见,这也是它比其他并发集合效率高的原因。注意这与哈希桶用volatile修饰无关(这是为了数组扩容时保证可见性)
    • 不支持value为null
      • 因为多线程环境下无法区分是找到了null值还是没有找到key而返回null(例如使用containsKey时有线程插入了null)
  • 线程安全
    • 只有Hashtable,Vector,Stack,ConcurrentHashMap是线程安全的,但ConcurrentHashMap效率比其他高,因为锁的粒度小,其他都是直接给整个集合加了一把大锁。

其他

  • 比较器
// 内部比较器,用于对象自身的比较逻辑,与对象耦合,一个类只能实现一个Comparable接口
class Person implements Comparable<Person> {
    private String name;
    private int age;
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // 实现compareTo方法
    public int compareTo(Person other) {
        return this.age - other.age; // 按年龄排序
    }
}

public class ComparableExample {
    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Alice", 25));
        people.add(new Person("Bob", 20));
        Collections.sort(people); // 使用内部比较器排序
    }
}

// 外部比较器,独立的比较逻辑,与对象解耦,同一个类可以定义多个Comparator
public class ComparatorExample {
    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Alice", 25));
        people.add(new Person("Bob", 20));

        // 按年龄排序
        Comparator<Person> ageComparator = Comparator.comparingInt(Person::getAge);
        Collections.sort(people, ageComparator);
        System.out.println("Sorted by age: " + people);

        // 按姓名排序
        Comparator<Person> nameComparator = Comparator.comparing(Person::getName);
        Collections.sort(people, nameComparator);
        System.out.println("Sorted by name: " + people);
    }
}

  • 迭代器
    • 用于集合遍历,提供了一种统一的方式来访问集合中的元素,而不需要暴露集合的内部结构。
    public class IteratorExample {
        public static void main(String[] args) {
            List<String> list = new ArrayList<>();
            list.add("Apple");
            list.add("Banana");
    
            // 获取迭代器
            Iterator<String> iterator = list.iterator();
    
            // 遍历集合
            while (iterator.hasNext()) {         // 判断是否有下一个元素
                String fruit = iterator.next();  // 移动指针
                if (fruit.equals("Banana")) {
                    iterator.remove();           // 移除当前元素
                }
            }
        }
    }
    
    • 快速失败:指迭代器遍历集合对象时,对象被修改了,直接抛出异常,如java.util包下的集合类,不能在多线程下并发修改,即强一致性。
    • 安全失败:迭代器遍历时集合对象被修改了也不会被迭代器检测到,因为是先复制了原有集合,再在拷贝上遍历的,如java.util.concurrent包下的容器,在多线程下可以并发修改,即弱一致性。