MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java多线程环境下的集合类选择

2023-05-223.8k 阅读

Java 多线程环境下集合类的概述

在 Java 编程中,集合类是常用的数据结构,用于存储和操作一组对象。在单线程环境下,我们可以根据具体需求轻松选择合适的集合类,如 ArrayList 用于有序可重复元素的存储,HashSet 用于无序不可重复元素的存储等。然而,当程序进入多线程环境,情况变得复杂起来。多线程并发访问集合类时,可能会引发数据不一致、线程安全等问题。因此,选择合适的线程安全集合类至关重要。

Java 提供了多种在多线程环境下可用的集合类,大致可分为以下几类:

  1. 同步包装类:通过 Collections.synchronizedXxx 方法将普通集合转换为线程安全的集合。
  2. 并发包下的集合类:位于 java.util.concurrent 包中,专为多线程环境设计,具有更高的并发性能。
  3. 旧版线程安全集合类:如 VectorHashtable,虽然线程安全,但性能相对较低,在新代码中不推荐使用。

同步包装类

原理与使用

Java 的 Collections 类提供了一系列静态方法,如 synchronizedListsynchronizedSetsynchronizedMap,可以将普通的 ListSetMap 转换为线程安全的版本。这些方法返回的是一个同步包装类,它通过在每个方法调用上同步来确保线程安全。

以下是使用 synchronizedList 的示例代码:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class SynchronizedListExample {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        List<String> synchronizedList = Collections.synchronizedList(list);

        Thread thread1 = new Thread(() -> {
            synchronizedList.add("Element 1");
        });

        Thread thread2 = new Thread(() -> {
            synchronizedList.add("Element 2");
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(synchronizedList);
    }
}

在上述代码中,我们首先创建了一个普通的 ArrayList,然后通过 Collections.synchronizedList 方法将其转换为线程安全的 List。在多线程环境下,不同线程对这个同步列表的操作是线程安全的。

优缺点

优点

  1. 简单易用:只需对普通集合进行简单包装,不需要改变太多代码逻辑。
  2. 兼容性好:适用于任何需要线程安全的标准集合类场景。

缺点

  1. 性能问题:由于每个方法都进行同步,在高并发情况下,性能瓶颈明显。所有线程需要竞争同一把锁,这可能导致线程阻塞,降低系统的并发处理能力。
  2. 迭代器问题:同步包装类的迭代器并非线程安全。在迭代过程中,如果其他线程修改了集合,可能会抛出 ConcurrentModificationException。为了避免这个问题,需要在迭代时手动同步整个集合,如下所示:
List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());
synchronized (synchronizedList) {
    for (String element : synchronizedList) {
        System.out.println(element);
    }
}

这种做法不仅增加了代码复杂度,还会影响性能,因为在迭代期间整个集合被锁定,其他线程无法访问。

并发包下的集合类

CopyOnWriteArrayList

原理CopyOnWriteArrayList 是一种读写分离的集合类。当进行写操作(如添加、删除元素)时,它会先复制一份原数组,在新数组上进行操作,操作完成后再将原数组指向新数组。而读操作(如获取元素、迭代)则直接在原数组上进行,不需要加锁。

以下是 CopyOnWriteArrayList 的使用示例:

import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListExample {
    public static void main(String[] args) {
        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();

        Thread thread1 = new Thread(() -> {
            list.add("Element 1");
        });

        Thread thread2 = new Thread(() -> {
            list.add("Element 2");
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

适用场景:适用于读多写少的场景。由于写操作需要复制数组,开销较大,所以在写操作频繁的情况下性能不佳。但读操作性能较高,因为不需要加锁,不会阻塞其他线程。

CopyOnWriteArraySet

原理CopyOnWriteArraySet 内部基于 CopyOnWriteArrayList 实现,它通过 addIfAbsent 方法保证元素的唯一性。当添加元素时,会先检查集合中是否已存在该元素,如果不存在则添加。同样,写操作复制数组,读操作直接在原数组上进行。

以下是 CopyOnWriteArraySet 的使用示例:

import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArraySet;

public class CopyOnWriteArraySetExample {
    public static void main(String[] args) {
        CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>();

        Thread thread1 = new Thread(() -> {
            set.add("Element 1");
        });

        Thread thread2 = new Thread(() -> {
            set.add("Element 2");
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Iterator<String> iterator = set.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

适用场景:与 CopyOnWriteArrayList 类似,适用于读多写少且需要保证元素唯一性的场景。

ConcurrentHashMap

原理ConcurrentHashMap 是线程安全的哈希表,它在 JDK 1.7 及之前采用分段锁机制,将整个哈希表分为多个段(Segment),每个段都有自己的锁。不同线程可以同时访问不同段的数据,从而提高并发性能。在 JDK 1.8 及之后,ConcurrentHashMap 摒弃了分段锁,采用 CAS(Compare and Swap)操作和 synchronized 关键字相结合的方式来保证线程安全。当桶中节点数小于等于 6 时,采用 CAS 操作进行插入;当桶中节点数大于 6 时,使用 synchronized 关键字对桶进行加锁。

以下是 ConcurrentHashMap 的使用示例:

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        Thread thread1 = new Thread(() -> {
            map.put("Key 1", 1);
        });

        Thread thread2 = new Thread(() -> {
            map.put("Key 2", 2);
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(map);
    }
}

适用场景:适用于高并发读写的场景。由于采用了更细粒度的锁机制和 CAS 操作,在多线程环境下具有较高的并发性能,广泛应用于需要高效并发访问的哈希表场景。

ConcurrentSkipListMap

原理ConcurrentSkipListMap 是基于跳表(Skip List)实现的线程安全有序映射。跳表是一种随机化的数据结构,通过在每个节点上增加多个指针,使得在查找、插入和删除操作时可以跳过一些节点,从而提高操作效率。ConcurrentSkipListMap 使用 CAS 操作和锁分段技术来保证线程安全。

以下是 ConcurrentSkipListMap 的使用示例:

import java.util.concurrent.ConcurrentSkipListMap;

public class ConcurrentSkipListMapExample {
    public static void main(String[] args) {
        ConcurrentSkipListMap<String, Integer> map = new ConcurrentSkipListMap<>();

        Thread thread1 = new Thread(() -> {
            map.put("Key 1", 1);
        });

        Thread thread2 = new Thread(() -> {
            map.put("Key 2", 2);
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(map);
    }
}

适用场景:适用于需要在多线程环境下保持有序性且高效并发访问的映射场景。如果需要对键进行排序,并且在多线程环境下有较高的并发读写需求,ConcurrentSkipListMap 是一个不错的选择。

ConcurrentSkipListSet

原理ConcurrentSkipListSet 内部基于 ConcurrentSkipListMap 实现,它通过将元素作为键存储在 ConcurrentSkipListMap 中,利用 ConcurrentSkipListMap 的有序性和线程安全性来保证集合的有序性和线程安全性。

以下是 ConcurrentSkipListSet 的使用示例:

import java.util.Iterator;
import java.util.concurrent.ConcurrentSkipListSet;

public class ConcurrentSkipListSetExample {
    public static void main(String[] args) {
        ConcurrentSkipListSet<String> set = new ConcurrentSkipListSet<>();

        Thread thread1 = new Thread(() -> {
            set.add("Element 1");
        });

        Thread thread2 = new Thread(() -> {
            set.add("Element 2");
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Iterator<String> iterator = set.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

适用场景:适用于需要在多线程环境下保持有序性且高效并发访问的集合场景,尤其适用于对元素有序性要求较高且读写操作较为频繁的情况。

旧版线程安全集合类

Vector

原理Vector 是 Java 早期提供的线程安全的动态数组。它的实现方式是在几乎所有的方法上都加上 synchronized 关键字,从而保证线程安全。

以下是 Vector 的使用示例:

import java.util.Vector;

public class VectorExample {
    public static void main(String[] args) {
        Vector<String> vector = new Vector<>();

        Thread thread1 = new Thread(() -> {
            vector.add("Element 1");
        });

        Thread thread2 = new Thread(() -> {
            vector.add("Element 2");
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(vector);
    }
}

优缺点优点:线程安全,使用简单,在早期 Java 开发中广泛应用。 缺点:性能较低,由于所有方法都同步,在高并发情况下,锁竞争严重,导致性能瓶颈。此外,Vector 的迭代器也不是线程安全的,与同步包装类类似,在迭代时需要手动同步整个集合。

Hashtable

原理Hashtable 是 Java 早期提供的线程安全的哈希表。它通过在每个方法上同步来保证线程安全。

以下是 Hashtable 的使用示例:

import java.util.Hashtable;

public class HashtableExample {
    public static void main(String[] args) {
        Hashtable<String, Integer> hashtable = new Hashtable<>();

        Thread thread1 = new Thread(() -> {
            hashtable.put("Key 1", 1);
        });

        Thread thread2 = new Thread(() -> {
            hashtable.put("Key 2", 2);
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(hashtable);
    }
}

优缺点优点:线程安全,适用于需要线程安全哈希表的早期 Java 程序。 缺点:性能较低,由于方法同步导致锁竞争严重。此外,Hashtable 不允许键或值为 null,这在某些场景下不太灵活。同时,Hashtable 的迭代器也存在与 Vector 类似的线程安全问题。

多线程集合类选择的考量因素

  1. 读写比例:如果读操作远远多于写操作,CopyOnWriteArrayListCopyOnWriteArraySet 等读写分离的集合类可能更合适;如果读写操作较为均衡,ConcurrentHashMap 等细粒度锁控制的集合类可能更具优势。
  2. 有序性需求:如果需要集合保持元素的插入顺序或自然顺序,ConcurrentSkipListMapConcurrentSkipListSet 是不错的选择;如果不需要有序性,ConcurrentHashMap 等无序集合类性能可能更高。
  3. 性能要求:在高并发场景下,同步包装类和旧版线程安全集合类由于锁竞争严重,性能较差。应优先考虑并发包下的集合类,如 ConcurrentHashMapCopyOnWriteArrayList 等,它们通过更细粒度的锁控制或读写分离机制,提供了更好的并发性能。
  4. 代码复杂度:同步包装类只需对普通集合进行简单包装,代码改动较小;而并发包下的集合类可能需要对其原理有更深入的理解,代码实现上可能相对复杂一些。但从长远来看,为了获得更好的性能,适当增加代码复杂度是值得的。
  5. 兼容性:如果项目需要与旧版 Java 代码兼容,可能需要考虑使用旧版线程安全集合类,但应尽量避免在新代码中使用,而是选择性能更好的并发包下的集合类。

总结

在 Java 多线程环境下选择合适的集合类是一项重要任务,它直接影响到程序的性能、正确性和可维护性。同步包装类简单易用,但性能受限;并发包下的集合类通过先进的设计和优化,提供了更高的并发性能;旧版线程安全集合类虽然线程安全,但由于性能问题在新代码中应尽量避免使用。通过综合考虑读写比例、有序性需求、性能要求、代码复杂度和兼容性等因素,我们能够选择出最适合具体应用场景的集合类,从而编写出高效、稳定的多线程程序。在实际开发中,应根据项目的特点和需求,灵活运用这些集合类,以实现最佳的性能和功能。同时,不断学习和了解新的集合类特性和优化方法,也是提升多线程编程能力的关键。

希望通过本文对 Java 多线程环境下集合类的介绍和分析,能帮助读者在实际项目中做出更明智的选择,避免多线程集合类使用不当带来的各种问题,提高程序的质量和效率。在日常开发中,建议多进行性能测试和代码优化,以确保选择的集合类在实际应用场景中发挥出最佳性能。同时,关注 Java 集合框架的发展和更新,及时采用新的更高效的集合类和技术,以提升整个项目的竞争力。