Java集合大杂烩,剖析如何选择适合的安全容器

186 阅读7分钟

文章首发于博客:布袋青年,原文链接直达:Java集合知识梳理


Java 中,集合可以笼统的分为两类,一类是数组 (Array) ,另一类则为列表 (LinkList) ,二者各有各有的优缺点。

数组为顺序存储,在随机读取即通过下标方式访问上速度优于列表,但在增删改需要从目标位置移动大量数据,因此效率相对更低。列表底层实现逻辑为链表,在插入删除时只需更新指针指向,显然效果高于数组,但在随机读取时因为链表特性需要从头逐一遍历因此效率低于数组。因此二者并没有孰优孰略,应根据不同的业务场景选择合适的实现方式。

下面依次介绍 Java 中常用的集合对象。

一、枚举数据

1. 介绍

枚举你可以简单的理解为一个常量数组。举个简单例子,你需要定义 4 种颜色,则只需要通过一个枚举即可,然后通过 枚举名.变量名 进行访问,使得代码更具可读性。

public class EnumTest {
    public static void main(String[] args) {
        Color color = Color.Blue;

        // 枚举下标,从 0 开始
        System.out.println(color.ordinal());
        // 两种比较方式等价
        System.out.println(color == Color.Blue);
        System.out.println(color.equals(Color.Blue));
    }
}

enum Color {
    Red, Blue, White, Black
}

2. 应用

在实际工程中在记录状态时通常都是通过数值存储于数据库之间,而在代码中若直接读取数据进行操作虽然可行但可读性相对较低。

在一个复杂的工程中,常会使用到各种状态值,但如果使用简单数值标识可读性又相对较差,针对此类情况通过为不同状态码定义相应枚举元素,即可实现更高的代码可读性。

下面看一个示例,通过使用枚举为不同 HTTP 请求状态码提供更详细的描述。

enum RequestStatus {
    SUCCESS(200, "请求成功。"),
    PARAMS_ERROR(400, "请求参数有误。"),
    NOT_FOUND(404, "请求地址不存在。"),
    NOT_RECOGNIZE(500, "无法识别请求。");

    public final int code;
    private final String describe;

    Weekday(int code, String describe) {
        this.code = code;
        this.describe = describe;
    }

    @Override
    public String toString() {
        return this.describe;
    }
}


public class EnumTest {
    public void demo2() {
        int code = 200;
        switch(code) {
            case RequestStatus.SUCCESS.getCode():
                System.out.println("请求成功。");
                break;
            case RequestStatus.NOT_FOUND.getCode():
                System.out.println("请求地址不存在。");
                break;
            case RequestStatus.NOT_RECOGNIZE.getCode():
                System.out.println("无法识别请求。");
                break;
            default:
                System.out.println("无效状态码。");
                break;
        }
    }
}

二、数组集合

1. Array

数组作为基本的数据结构之一在实际开发中并不陌生,其底层逻辑为一片连续的物理存储空间,在随机访问时拥有不俗的性能。

数组声明共有两种方式:一种在创建时仅声明大小不定义内容,另一种为同时声明大小和内容。

需要注意的是无论哪种方式都必须在创建时定义长度,后续只允许修改数组内容,并不允许修改数组长度,即数组为定长集合且大小在初始化时确定。

/**
 * 仅声明大小
 */
public void demo() {
    String[] strArr = new String[2];
    strArr[0] = "Hello";
    strArr[1] = "World";
    System.out.println(Arrays.toString(strArr));
}

/**
 * 同时定义大小和内容
 */
public void demo1() {
    // 以下两种定义方式等价
    String[] arr1 = {"Hello", "World"};
    String[] arr2 = new String[]{"Hello", "World"};
    System.out.println(Arrays.toString(arr1));
    System.out.println(Arrays.toString(arr2));
}
  • 类型转化
    通过 Arrays.asList() 可以将数组与列表之间进行转化,但转化后对于底层而言仍为数组,无法使用 add() 等系列操作,必须通过流 stream 的方式转化才允许。
    public void arrayToList() {
        String[] str = {"path1", "path2", "path3"};
        List<String> list1 = Arrays.asList(str);
        // 下行语句非法
        // list.add("path4");
        System.out.println(list1);
    
        List<String> list2 = Arrays.stream(str).collect(Collectors.toList());
        list2.add("path4");
        System.out.println(list2);
    }
    

2. Vector

Vector 底层封装了数组,在此基础上新增了扩容操作,弥补了普通数据必须在定义时固定长度的缺点,且元素操作封装了 synchronized 因此为线程安全容器,在多线程并发情况下可替代 ArrayList 容器。

通过 new Vector<>(init, increase) 方式创建当数组长度达到 init 大小后,将会根据自动扩容 increase 个空间,若未指定默认的初始容量为 10,增长量为当前容量的一倍。

public void vectorDemo() {
    // 初始化长度 4, 超过则再扩容 5 个单位
    Vector<Integer> vector = new Vector<>(4, 5);
    for (int i = 1; i < 6; i += 2) {
        vector.addElement(new Integer(i));
    }
    System.out.println(vector);
    // 总容量大小
    System.out.println(vector.capacity());

    // 在下标 2 处插入元素
    vector.insertElementAt(new Integer(2), 2);
    // 移除在下标 2 的元素
    vector.removeElementAt(2);
    // 移除值为 4 的元素
    vector.removeElement(new Integer(4));
    System.out.println(vector);
}

三、列表集合

1. ArrayList

ArrayListList 的接口实现类,按照存入的顺序存放元素,是有序集合且允许重复元素。

ArrayList 是非线程安全容器,在多线程并发下将会抛出异常,并发场景下可考虑选用 VectorCopyOnWriteArrayList 容器进行替代。

常见方法接口参考下表内容。

方法 作用
add() 添加单个元素至容器内。
addAll() 向容器内添加集合。
get() 根据下标获取元素,从 0 开始
set(i, v) 将元素 v 插入至下标为 i 位置, i 不能大于集合 size。
remove() 移除容器单个元素。
removeAll() 批量移除容器中的元素。
clear() 清空整个容器内容。
size() 获取当前容器中的元素个数。
isEmpty() 判断当前容器是否为空,返回 boolean 值。
public void listDemo() {
    List<String> list = new ArrayList<>();
    list.add("Jack");
    list.add("Beth");
    list.add("Mark");
    System.out.println(list);
}
  • 子串获取
    通过 subList() 方法可以获取列表的子串。
    public void SubListDemo() {
        List<Integer> list = new ArrayList<>();
        for (int i = 1; i < 9; i++) {
            list.add(i);
        }
        List<Integer> list1 = list.subList(0, 5);
        System.out.println(list1);
    }
    

2. LinkedList

LinkedList 同样是 List 的接口实现类,底层实现为 链表,可以在任意位置进行高效插入删除操作。

public void linkedListDemo() {
    LinkedList<String> list = new LinkedList<>();
    list.add("Jack");
    list.add("Mark");

    // 操作头
    list.addFirst("Great");
    list.getFirst();
    list.removeFirst();

    // 操作尾
    list.addLast("Beth");
    list.getLast();
    list.removeLast();

    System.out.println(list);
}

3. CopyOnWriteArrayList

CopyOnWriteArrayList 进一步扩展了 ArrayList 操作接口,在操作元素时会先复制一份原集合,将待执行操作在复制的容器中执行,最后再将旧容器的引用指向新容器。

通过复制可以解决多线程并发 读取 ,但同时复制会导致内存资源占用,且只能保证数据最终一致性,无法保证实时一致性。

观察其添加方法 add() 可以看出在添加元素时通过 synchronized 实现加锁,保证同一时刻只有单个线程在操作,然后将原容器通过 Arrays.copyOf() 实现复制并将新元素添加至复制的容器中,最后将旧容器的引用指向复制添加后的新容器。

public boolean add(E e) {
    synchronized (lock) {
        Object[] es = getArray();
        int len = es.length;
        es = Arrays.copyOf(es, len + 1);
        es[len] = e;
        setArray(es);
        return true;
    }
}

final void setArray(Object[] a) {
    array = a;
}

CopyOnWriteArrayList 容器的 set()remove() 等常用列表方法采取的实现方式类似,都是通过 synchronized 配合复制操作实现,这里不重复介绍。需要注意其 get() 方法没有实现加锁,因此在增改容器时若执行查询读取的数据仍可能是旧制,因为操作时发生在复制的容器中(未完成前引用指向的仍是旧容器),但其保证了最终的数据一致性。

四、Set集合

1. HashSet

HashSet 是无序集合,根据数据的哈希值进行排序存储,不允许存入重复元素,非线程安全。

如果你查看 HashSet 的源码即可发现其是基于 HashMap 结构进行存储,其中对应的 Key 即为存入的数据,而 value 则填充了一个空对象,而 HashMap 是基于哈希值进行存储的,因此在去重或判断值等操作上 HashSet 往往相较于 ArrayList 拥有更好的性能。

private transient HashMap<E,Object> map;
public HashSet() {
    map = new HashMap<>();
}

private static final Object PRESENT = new Object();
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

// HashMap.put()
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

2. LinkedHashSet

LinkedHashSet 在基于链表扩展实现,因此其为有序集合,按照存入的顺序存放元素,与 HashSet 类似其不允许重复元素,性能方面略低于 HashSet

public void linkedHashSetDemo(){
    Set<String> set = new LinkedHashSet<>();
    set.add("Alex");
    set.add("Bob");
    set.add("Mark");
    // [Alex, Bob, Mark]
    System.out.println(set);
}

3. TreeSet

TreeSetSortedSet 接口的唯一实现类,基于红黑树对存入的元素进行排序存储。

public void treeSetDemo(){
    Set set = new TreeSet();
    set.add("B");
    set.add("A");
    set.add("C");

    // [A, B, C]
    System.out.println(set);
}

4. CopyOnWriteArraySet

CopyOnWriteArraySet 作用效果与 CopyOnWriteArrayList 效果类似,这里不再详细介绍。

public void copyOnWriteArraySetDemo() {
    Set<Integer> set = new CopyOnWriteArraySet<>();
    for (int i = 0; i < 5; i++) {
        set.add(i);
    }
    System.out.println(set);
}

五、Map对象

1. 数据结构

HashMap 底层实现主要分为部分:数组和链表/红黑树。数组是 HashMap 的主体,链表和红黑树是为了解决哈希冲突而存在的辅助结构。

当我们向 HashMapput 键值对时, HashMap 会根据键的哈希值算出其在数组中的位置,然后将键值对存储在数组的对应位置上。如果发生哈希冲突 HashMap 会将发生冲突的键值对以链表的形式存储在对应位置上。但是,如果链表中的元素超过了一定的数量,为了提高查询效率, HashMap 会将链表转换为红黑树。

image.png

其中 key 值不允许重复,对于存入 key 重复的元素进行覆盖,有且仅有一个元素的的 key 值为 null

HashMap 容器的存储效率由 capacityloadFactor 变量控制,前者为容器的大小,默认为 16,后者为容器的扩容策略,取值范围为 0~1 ,默认值为 0.75,当容器的内的元素数量达到 capacity*loadFactor 时将自动扩容为 2*capacity。如默认初始化容器大小为 16,当存入的个数超过 12(16*0.75) 时,容器大小将扩容至 32(16*2)

public void hashMapDemo(){
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "Alex");
    map.put(2, "Bob");
    // 重复 Key 重写值
    map.put(2, "Jack");
    map.put(3, "Mark");

    System.out.println(map);
}

3. LinkedHashMap

LinkedHashMap 相较于 HashMap 容器其基于链表实现,因此其为有序序列,按照元素存入的顺序存放,其它特性同 HashMap

public void linkedHashMapDemo(){
    Map<Integer, String> map = new LinkedHashMap<>();
    map.put(1, "Alex");
    map.put(2, "Bob");
    map.put(3, "Mark");
    System.out.println(map);
}

4. TreeMap

TreeMap 基于红黑树实现,对于存入的元素根据元素的 Key 进行排序,性能低于 HashMap

public void rreeMapDemo(){
    Map<Integer, String> map = new TreeMap<>();
    map.put(2, "Alex");
    map.put(1, "Bob");
    map.put(3, "Mark");

    // 输出 (1, "Bob") (2, "Alex") (3, "Mark")
    System.out.println(map);
}

5. HashTable

HashTableHashMap 类似,但其操作使用 synchronized 关键字保证多线程的数据原子性。

具象的使用示例不再重复介绍,这里分析一下其对应的源码实现。打开 HashTable 的源码,可以看见几乎所有操作方法都被 synchronized 关键字修饰,因此在并发的情景下仅有一个线程能取得锁实现对应操作,从而达到线程安全的效果。

public synchronized V get(Object key) {
    // Omit code

}

public synchronized V put(K key, V value) {
    // Omit code

}

public synchronized V remove(Object key) {
    // Omit code

}

6. ConcurrentHashMap

ConcurrentHashMapHashTable 都是线程安全,但前者针对后者实现进一步的优化,从而实现更高的性能。

在上述 HashTable 的示例中介绍了其通过方法加 synchronized 关键字实现线程安全,但该方法将锁住整个数据结构相对过于笨重,而 ConcurrentHashMap 正是在此基础上实现更细粒度上的操控达到线程安全的同时拥有更好性能。

下面以 ConcurrentHashMapput() 方式为例,通过源码可以看到在添加元素时当容器为空或被添加的元素不存在于容器中(Key 值不重复)时并没有选择加锁,只有当数据出现重复时通过 synchronized 关键字锁住需添加的对象。而 HashTable 锁住的是整个容器结构, ConcurrentHashMap 通过更细粒的操作保证相同的元素在同一刻只有一个线程能获取其锁对象,但是不影响其它元素的操作。

概括而言,ConcurrentHashMap 只对影响一致性的容器元素加锁控制,并且锁的作用对象是单个目标元素而非整个容器结构,因此其在实现线程安全的情况下仍能达到相对较好的性能。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        if (tab == null || (n = tab.length) == 0)
            // 容器不存在则初始化
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 容器为空直接添加(未加锁)
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;                   
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else if (onlyIfAbsent // check first node without acquiring lock
                && fh == hash
                && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                && (fv = f.val) != null)
            // 元素首次添加不加锁
            return fv;
        else {
            V oldVal = null;
            synchronized (f) {
                // 加锁并发控制,略去具体实现
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

7. Properties

Properties 类继承于 Hashtable,因此使用起来二者其实差别并不大,但 Properties 为我们额外封装的一些方法可应用于部分业务场景。

通过 Properties 类的 load() 方法可以便捷的将 .conf.properties 等配置文件以键值对的形式加载,如下示例中则读取 boostrap.conf 文件的内容并赋值 Properties 对象。

public void propertiesDemo() throws IOException {
    String location = "E:\\Workspace\\bootstrap.conf";
    Path path = Paths.get(location);
    try (
            InputStream in = Files.newInputStream(path);
            InputStreamReader reader = new InputStreamReader(in);
    ) {
        Properties props = new Properties();
        props.load(reader);
        System.out.println(props);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

与之相似,除了从配置文件中读取信息, Properties 支持将信息持久化保存至本地空间。

如下示例中将 Properties 对象内容以文件访问保存至 src\\main\\resources 目录下。

public void propertiesDemo() {
    Properties prop = new Properties();
    prop.setProperty("key3", "value3");
    prop.setProperty("key4", "value4");
    prop.setProperty("key5", "value5");
    // 导出为 xml 文件
    prop.storeToXML(new FileOutputStream("src\\main\\resources\\test.xml"),
                "配置文件",
                "GBK");
}

六、栈结构

(Stack) 是一种单向通道 先进后出 的线性结构,先入栈的元素在栈底,结构示意图如下:

image.png

Stack 具体定义方式与常见操作如下:

public void stackDemo() {
    Stack<String> stack = new Stack<>();
    // 访问栈顶元素
    stack.peek();
    // 弹出栈顶元素并返回值
    stack.pop();

    // 添加元素
    stack.push("alex");
    // 返回对应元素的位置
    stack.search("alex");

    boolean isEmpty = stack.empty();
    System.out.println(isEmpty);
}

七、队列集合

1. 单端队列

单端队列即为我们最常见队列结构,与日常生活中的排队是类似,只有一端能进另一端能出,下面示意图即为单端队列。

image.png

2. 双端队列

双端队列和单端队列最根本的区别就是前者在队列的任何一端都可进行插入删除操作,示意图如下:

image.png

因此双端队列的基本操作方法只是增加了头尾的区别声明,示例代码如下:

public void dequeueDemo() {
    Deque<Integer> queue = new LinkedList<>();
    // 插入对头
    queue.offerFirst(1);
    // 插入对尾
    queue.offerLast(2);
}

3. 基本操作

队列基本的入队出队方法与描述信息参考下表。

方法 作用
offer() 添加元素至队列,失败返回 false。
add() 添加元素队列,失败会抛出异常。
peek() 获取队头元素,容量为 0 的时候返回 null。
element() 获取队头元素,容量为 0 的时候会抛出异常。
poll() 删除队头元素并返回值,容量为 0 的时候返回 false。
remove() 删除队头元素并返回值,容量为 0 的时候会抛出异常。

表中涉及方法操作示例代码如下:

public void queueDemo(){
    Queue<Integer> queue = new LinkedList<>();
    // 数据入队
    queue.add(1);
    queue.offer(2);
    // 获取队列元素
    System.out.println(queue.peek());
    System.out.println(queue.element());
    // 数据出队
    System.out.println(queue.poll());
    System.out.println(queue.remove());
    System.out.println(queue);
}

4. 优先队列

所谓优先队列,即赋予队列中元素访问优先级,优先级越高则出队的优先即也越高。

PriorityQueue 内部实现了一套排序机制,默认根据存入元素的 ASCII 值进行存入,ASCII 值更小的元素哪怕后存也会在队头。

public void priorityDemo() {
    Queue<String> queue = new PriorityQueue<>();
    queue.offer("apple");
    queue.offer("pear");
    queue.offer("banana");

    while(queue.size()>0){
        // 输出顺序:apple banana pear
        System.out.println(queue.poll());
    }
}

我们也可以通过 Comparator 自定义元素的优先级,队列中的元素对象需要实现 Comparator 接口并重写 compare() 方法。

其中 compare() 返回值取值范围为:(-1,0,1),依次代表 大于等于小于

public void priorityDemo() {
    PriorityQueue<User> queue1 = new PriorityQueue<>(new MyComparator());
    queue1.offer(new User("Alex", 30));
    queue1.offer(new User("Beth", 25));
    queue1.offer(new User("Jack", 26));
    while (queue1.size() > 0) {
        System.out.println(queue1.poll());
    }
}

class MyComparator implements Comparator<User> {
    @Override
    public int compare(User o1, User o2) {
        int interval = o1.getAge() - o2.getAge();
        return Integer.compare(interval, 0);
    }
}

5. 阻塞队列

Java 中默认提供了两类阻塞队列,其结构为线程安全,扩展了一系列方法实现阻塞读存。

  • ArrayBlockingQueue:有界集合,队列容量在初始化时指定,一旦定义后续无法变更。
  • LinkedBlockingQueue:无界集合,若初始化时指定容量则效果等同于 ArrayBlockingQueue,若不指定容量则为无界集合。

在阻塞队列中除了 offer()、poll() 方法可以指定阻塞时间,同时提供了 put()、take() 方法实现阻塞读存。

方法 作用
offer() 阻塞新增,指定时间未能成功返回 false。
poll() 阻塞获取,指定时间未能成功返回 false。
put() 阻塞新增,若队列已满则会一直处于阻塞待有可用空间时新增。
take() 阻塞查询,若队列为空则会一直处于阻塞状态。
public void blockDemo() throws InterruptedException {
    ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
    for (int i = 0; i < 6; i++) {
        // Add element
        // If capacity is full then wait, if still full return false
        System.out.println(queue.offer(i, 3, TimeUnit.SECONDS));
    }
    while (queue.size() > 0) {
        System.out.println(queue.poll());
    }
    // Get element
    // If capacity is full then wait, if still full return null
    System.out.println(queue.poll(3, TimeUnit.SECONDS));
}

6. 延迟队列

Java 同时提供了一种阻塞的延迟队列 DelayQueue ,其队列元素必须实现 Delayed 接口。

  • Delayed

    新建一个类实现 Delayed 接口并重写 getDelay()compareTo() 接口。

    public class DelayTask implements Delayed {
    
        private String data;
        private long startTime;
    
        public DelayTask(String data, long startTime) {
            this.data = data;
            this.startTime = startTime;
        }
    
        /**
         * Get element will activate getDelay().
         * If "interval" lower the zero then retrieved to get.
         */
        @Override
        public long getDelay(TimeUnit unit) {
            long interval = startTime - System.currentTimeMillis();
            return unit.convert(interval, TimeUnit.MILLISECONDS);
        }
    
        @Override
        public int compareTo(Delayed o) {
            DelayTask task = (DelayTask) o;
            return Ints.saturatedCast(startTime - task.startTime);
        }
    }
    
  • 初始化

    介绍了 Delayed 的基本定义之后下面看一下延迟队列的初始化。

    public void delayDemo() throws InterruptedException {
        BlockingQueue<DelayTask> delayQueue = new DelayQueue<>();
        delayQueue.put(new DelayTask("Alex", 1980836715841L));
        TimeUnit.MILLISECONDS.sleep(200);
        delayQueue.put(new DelayTask("Beth", System.currentTimeMillis()));
    
        DelayTask task = delayQueue.take();
        System.out.println(task);
    }