【昕宝爸爸系列】如何将集合变成线程安全的?

发布时间:2024年01月10日

在这里插入图片描述


?典型解析


1 . 在调用集合前,使用synchronized或者 ReentrantLock 对代码加锁 (读写都要加锁)


public class SynchronizedCollectionExample {

	private List<Integer> list = new ArrayList<>();

	public void add(int value) {
		
		synchronized (SynchronizedCollectionExample.class) {
		
			list.add(value);
		}
	}

	public int get(int index) {
	
		synchronized (SynchronizedCollectionExample.class) {
		
			return list.get(index);
		}
	}
}

2 . 使用 ThreadLocal ,将集合放到线程内访问,但是这样集合中的值就不能被其他线程访问了


public class ThreadlocalCollectionExample {
	
	private ThreadLocal<list<Integer>> threadlocallist = Threadlocal.withInitial(Arraylist::new);

	public void add(int value) {
		
		threadLocalList.get( ) .add(value);
	}

	public int get(int index) {
	
		return threadLocalList.get().get(index);
	}
}

3 . 使用Collections.synchronizedXXX()方法,可以获得一个线程安全的集合


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

public class CollectionssynchronizedExample {

	List<Integer> synchronizedList = Collections.synchronizedList(new ArrayList<Integer>());

	public void add(int value) {
	
		synchronizedList.add(value);
	}	

	public int get(int index) {
		
		return synchronizedList.get(index);
	}
}

4 . 使用不可变集合进行封装,当集合是不可变的时候,自然是线程安全的


import com.google.common.collect.ImmutableList;

public class ImmutableCollectionExample {
	private Immutablelist<Integer> immutablelist = Immutablelist.of(123);



	public int get(int index) {
	
		return immutablelist .get(index);
	}	
}

或者(or)


import java.util.List;

public class ImmutableJava9Example {
	
	private List<Integer> immutableList = List.of(1,23);

	public int get(int index) {
		
		return immutablelist.get(index);
	}
}

🟢拓展知识仓


??Java中都有哪些线程安全的集合?


Java1.5并发包 (java.util.concurrent) 包含线程安全集合类,允许在迭代时修改集合。


1. ConcurrentHashMap

2.ConcurrentLinkedDeque

3. ConcurrentLinkedQueue

4.ConcurrentSkipListMap

5. ConcurrentSkipSet

6.CopyOnWriteArrayList

7.CopyOnWriteArraySet


🟠线程安全集合类的优缺点是什么


线程安全集合类的优缺点如下:

优点

1. 线程安全:线程安全集合类可以在多线程环境中安全地使用,不会出现数据不一致或竞争条件的问题

2. 高性能:线程安全集合类在处理大量数据时通常具有较好的性能,因为它们内部进行了优化以减少同步的开销

3. 可靠性:由于线程安全集合类具有内置的同步机制,因此它们可以避免因并发问题而导致的数据损坏或程序崩溃

缺点

1. 资源竞争:由于线程安全集合类的同步机制,当多个线程同时访问这些集合类时,会导致资源竞争,从而影响程序的性能

2. 死锁风险:线程安全集合类可能会增加死锁的风险,尤其是在复杂的并发环境中。

3. 过度同步:线程安全集合类的同步机制可能会导致过度同步,从而限制了并发性能的进一步提高。

4. 性能调优难度:由于线程安全集合类的内部实现较为复杂,因此对它们的性能调优可能会更加困难。


线程安全集合类在多线程环境中是必不可少的,但使用时需要注意它们的优缺点,并根据实际需求进行选择和调整。


我们来看一个场景的Demo:


import java.util.concurrent.*; 
/**
* @author xinbaobaba
* 涉及多个线程和多个线程安全集合类
* 这个例子使用 java.util.concurrent 包中的 ExecutorService 
* 和 BlockingQueue 来实现一个线程安全的生产者-消费者模型
*/    
public class ProducerConsumerExample {  
    // 定义最大队列大小  
    private static final int MAX_QUEUE_SIZE = 10;  
    // 定义线程安全的阻塞队列  
    private static final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(MAX_QUEUE_SIZE);  
  
    public static void main(String[] args) {  
        // 创建一个固定大小的线程池  
        ExecutorService executorService = Executors.newFixedThreadPool(2);  
        // 创建生产者线程  
        Producer producer = new Producer(queue);  
        // 创建消费者线程  
        Consumer consumer = new Consumer(queue);  
        // 将生产者线程提交到线程池中运行  
        executorService.submit(producer);  
        // 将消费者线程提交到线程池中运行  
        executorService.submit(consumer);  
    }  
  
    // 生产者线程内部类  
    static class Producer implements Runnable {  
        private final BlockingQueue<Integer> queue; // 生产者使用的队列  
  
        public Producer(BlockingQueue<Integer> queue) {  
            this.queue = queue; // 初始化队列  
        }  
  
        @Override  
        public void run() { // 生产者的运行方法  
            try {  
                for (int i = 0; i < 100; i++) { // 循环生产100个元素  
                    queue.put(i); // 将元素放入队列中,如果队列满则阻塞等待空间可用  
                    System.out.println("Produced: " + i); // 打印已生产的元素  
                    Thread.sleep(100); // 生产一个元素后休眠100毫秒,模拟生产时间  
                }  
            } catch (InterruptedException e) { // 如果生产过程中被中断,则捕获异常并打印堆栈信息  
                e.printStackTrace();  
            }  
        }  
    }  
  
    // 消费者线程内部类  
    static class Consumer implements Runnable {  
        private final BlockingQueue<Integer> queue; // 消费者使用的队列  
  
        public Consumer(BlockingQueue<Integer> queue) { // 初始化队列  
            this.queue = queue;  
        }  
  
        @Override  
        public void run() { // 消费者的运行方法  
            try {  
                while (true) { // 无限循环消费元素,直到程序被终止或异常发生  
                    Integer value = queue.take(); // 从队列中取出元素,如果队列空则阻塞等待有元素可用  
                    System.out.println("Consumed: " + value); // 打印已消费的元素  
                }  
            } catch (InterruptedException e) { // 如果消费过程中被中断,则捕获异常并打印堆栈信息  
                e.printStackTrace();  
            }  
        }  
    }  
}

Demo中,创建一个生产者和一个消费者线程。生产者线程将数字0到99放入队列中,而消费者线程从队列中取出数字并打印出来。我们使用 BlockingQueue 来实现线程安全的队列,它提供了 put() take()方法,这些方法在队列满或队列空时会自动阻塞,直到队列不再满或不再空为止。这样就可以保证生产者和消费者线程在访问队列时不会发生并发冲突


🟡如何选择合适的线程安全集合类


选择合适的线程安全集合类需要考虑以下几个方面:

  1. 并发级别:首先需要了解应用程序的并发级别,即同时访问集合的线程数量。高并发级别需要选择性能更好的线程安全集合类,如 ConcurrentHashMap
  2. 读写比例:根据应用程序对读和写的需求,选择适合的线程安全集合类。如果读操作远多于写操作,则可以选择 CopyOnWriteArrayListCopyOnWriteArraySet,它们在读操作时不需要同步,而在写操作时复制底层数组,保证了线程安全。
  3. 锁粒度:不同的线程安全集合类使用的锁粒度不同,这会影响并发性能。例如,Hashtable 使用单个锁保护整个表,而 ConcurrentHashMap 使用多个锁保护不同的段,从而提供更好的并发性能。
  4. 元素访问顺序:根据对元素访问顺序的需求,选择具有合适迭代器的线程安全集合类。例如,VectorHashtable 提供了稳定的迭代器,而 ConcurrentHashMap 不保证迭代顺序。
  5. 内存效率:线程安全集合类的内存效率也是一个重要的考虑因素。例如,HashtableVector 在添加元素时需要扩容,这可能会导致额外的内存开销。
  6. 自定义需求:根据应用程序的特定需求,可以选择具有自定义功能的线程安全集合类。例如,如果需要自定义的锁策略或特殊的数据结构,可以自行实现线程安全的集合类。

选择合适的线程安全集合类需要综合考虑应用程序的并发级别、读写比例、锁粒度、元素访问顺序、内存效率和自定义需求等因素。在选择时,可以参考Java标准库提供的线程安全集合类,并根据实际情况进行选择和调整。


??如何解决线程安全集合类并发冲突问题


解决线程安全集合类并发冲突问题可以采用以下几种方法:

  1. 使用锁:在访问线程安全集合类时,可以使用适当的锁机制来避免并发冲突。例如,可以使用 synchronized 关键字或ReentrantLock 来实现同步访问。

  2. 使用并发集合类:Java标准库提供了一些并发集合类,如ConcurrentHashMapCopyOnWriteArrayList等,这些集合类内部已经实现了线程安全,可以避免并发冲突。

  3. 使用乐观锁:乐观锁采用乐观策略,即在读取数据时不加锁,在更新数据时通过版本号或CAS(Compare and Swap)机制来保证数据的一致性。乐观锁可以减少锁的竞争,提高并发性能。

  4. 使用读写锁:读写锁允许多个线程同时读取数据,但在写入数据时需要独占式的访问。Java标准库中的ReadWriteLock接口提供了读写锁机制。

  5. 使用分段锁:分段锁是将数据分成多个段,每个段使用独立的锁来保护。这样可以减少锁的竞争范围,提高并发性能。例如,ConcurrentHashMap就是使用分段锁实现的。

  6. 使用无锁机制:无锁机制是一种基于CAS操作的并发控制方法,它不需要显式的锁机制即可实现线程安全。无锁机制的优点是避免了锁的竞争和死锁问题,但实现起来较为复杂。

解决线程安全集合类并发冲突问题需要根据实际情况选择合适的并发控制方法。在选择时,需要考虑应用程序的并发级别、读写比例、数据一致性需求等因素,并权衡性能、可读性和可维护性等方面


??乐观锁实现方式 (具体步骤)。


乐观锁是一种并发控制策略,它假设多个事务在同一时间对同一数据进行操作时,不会产生冲突,只有在提交更新时才会检查是否有冲突。如果发现冲突,则回滚事务。乐观锁的实现方式主要有两种:数据版本记录机制和基于CAS操作的机制。

数据版本记录机制是乐观锁最常用的实现方式。具体步骤如下:

  1. 将需要锁定的资源以键值对的方式存储在数据库中。
  2. 给资源添加一个版本号字段,用于记录资源的版本信息。
  3. 当需要访问资源时,先读取资源的当前版本号,并将该版本号保存在本地。
  4. 处理完需要访问的数据后,将本地保存的版本号与数据库中的版本号进行比较。如果两个版本号相等,则提交事务;否则,表示资源已经被其他事务修改,需要回滚事务并重新处理。

基于CAS操作的机制是一种无锁机制,它通过比较并交换(Compare-And-Swap, CAS)操作来实现并发控制。具体步骤如下:

  1. 将需要锁定的资源以键值对的方式存储在内存中。
  2. 当需要更新资源时,使用CAS操作来比较并交换资源的新旧值。如果资源没有被其他线程修改过,则CAS操作成功,更新资源并提交事务;否则,表示资源已经被其他线程修改过,需要回滚事务并重新处理。

注意:乐观锁在处理大量并发请求时可能会有较高的回滚率,因此需要根据实际情况选择是否使用乐观锁,或者采用其他并发控制策略。


乐观锁的实现方式有很多种,具体选择哪种方式还需要根据实际业务场景和技术栈来决定。


?乐观锁在数据量大的情况下性能如何


乐观锁在数据量大的情况下,性能表现主要取决于具体实现方式数据访问模式

乐观锁机制不会像悲观锁一样在操作系统中挂起,而是允许失败的线程重试,也允许自动放弃退出操作。因此,乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。


在乐观锁的实现方式中,使用数据版本(Version)记录机制是比较常用的一种。当大量数据需要更新时,每次更新都需要判断版本号是否匹配,这会增加一定的开销。但是,由于乐观锁避免了长事务中的数据库加锁开销,所以在高并发的情况下,乐观锁的性能表现可能优于悲观锁。

对于使用Redis实现乐观锁的情况,如果数据量非常大,频繁地读取和比较版本号可能会对性能产生一定的影响。但是,通过合理的设计和优化,比如使用有序集合(sorted set)来存储版本号,可以有效地提高性能。

乐观锁在数据量大的情况下,性能表现取决于具体实现方式和数据访问模式。通过合理的设计和优化,乐观锁可以提供较好的性能和并发控制能力。


乐观锁的核心思想是:在数据读取时,不对数据进行加锁,但在数据提交更新时,会判断在此期间是否有其他线程对数据进行了修改。下面是一个使用Java实现的乐观锁示例:


import java.util.concurrent.atomic.AtomicInteger;  
  
public class OptimisticLocker {  
    // 定义一个版本号字段  
    private AtomicInteger version = new AtomicInteger(0);  
  
    // 获取当前版本号  
    public int getVersion() {  
        return version.get();  
    }  
  
    // 更新数据,并递增版本号  
    public boolean updateData(int newData, int expectedVersion) {  
        while (true) {  
            int currentVersion = version.get();  
            if (currentVersion != expectedVersion) {  
                // 如果当前版本号与期望的版本号不一致,说明数据在此期间被其他线程修改过  
                return false; // 返回false表示更新失败  
            } else {  
                // 更新数据并递增版本号  
                if (version.compareAndSet(currentVersion, currentVersion + 1)) {  
                    // 数据更新成功,返回true表示更新成功  
                    return true;  
                } else {  
                    // 如果在更新过程中,其他线程也修改了版本号,则重新获取当前版本号并继续尝试更新  
                    continue;  
                }  
            }  
        }  
    }  
}

可以看到:我们使用 AtomicInteger 作为版本号的存储,利用其原子性来保证并发访问时的正确性。updateData方法 接受两个参数:newData表示要更新的新数据,expectedVersion表示期望的版本号。方法中的 while循环 是为了确保数据的一致性。如果在更新过程中发现当前版本号与期望的版本号不一致,则认为数据被其他线程修改过,更新失败;否则,更新数据并递增版本号。


?乐观锁的优缺点


乐观锁的优点主要体现在以下几个方面:

  1. 提高并发性能:乐观锁机制不会对读操作造成阻塞,只有在提交更新时才会进行版本检查,因此可以最大程度地提高并发性能,允许多个用户同时读取同一份数据,不会出现读阻塞的情况。
  1. 减少锁冲突:乐观锁采用版本号机制,可以降低锁冲突的概率,提高并发性能。
  1. 解决数据一致性问题:乐观锁通过版本检查的方式来解决并发修改可能导致的数据一致性问题。在提交更新时,会再次检查版本号是否一致,如果不一致则说明有其他用户修改了数据,避免了数据被覆盖或丢失的情况。
  1. 灵活性:乐观锁相对于悲观锁来说更加灵活,不需要给整个事务加锁,只在提交更新时进行版本检查。这样可以避免长时间的锁等待,提高系统的响应速度。

乐观锁缺点:


  1. 更新失败的概率较高:一旦出现锁冲突,可能会导致更新失败。如果锁的粒度掌握不好,可能会出现大量更新失败的情况,进而影响业务的正常运行。
  1. 适用场景有限:乐观锁适用于读操作频繁的场景,可以提高系统的吞吐量。但在写操作频繁的场景下,悲观锁可能更加适合,因为它能避免写操作的阻塞。
  1. 可能引发业务失败:由于乐观锁在更新时进行版本检查,一旦出现版本不一致的情况,可能会导致业务失败。这需要开发者在编写业务逻辑时充分考虑这种情况,并进行相应的处理。

总结:乐观锁的优点是可以提高并发性能、减少锁冲突、解决数据一致性问题以及提供更高的灵活性;而缺点是更新失败的概率较高、适用场景有限以及可能引发业务失败。在实际应用中,需要根据具体场景和需求来选择使用乐观锁还是悲观锁


## ?如何解决线程安全缓存集合类并发冲突问题

线程安全缓存集合类并发冲突问题可以通过以下几种方式解决:


  1. 使用同步机制:可以使用synchronized关键字或Lock接口来实现同步机制,确保同一时间只有一个线程可以访问集合类,从而避免并发冲突。

  1. 使用并发集合类:Java提供了多种并发集合类,如ConcurrentHashMap、CopyOnWriteArrayList等,这些集合类在多线程环境下表现出较好的性能和线程安全性。

  1. 使用乐观锁:乐观锁通过版本号机制来解决并发修改可能导致的数据一致性问题。在提交更新时,会再次检查版本号是否一致,如果不一致则说明有其他用户修改了数据,避免了数据被覆盖或丢失的情况。

  1. 使用读写锁:读写锁允许多个线程同时读取数据,但在写入数据时需要独占式访问,这样可以减少锁的竞争,提高并发性能。

  1. 使用分布式缓存系统:分布式缓存系统可以将数据分散到多个节点上,每个节点都有自己的锁机制,从而避免了单点故障和并发冲突问题。

解决线程安全缓存集合类并发冲突问题需要根据具体场景和需求来选择适合的解决方案。


Demo:


import java.util.concurrent.ConcurrentHashMap;  
import java.util.concurrent.atomic.AtomicInteger;  

/**
*  @author xinbaobaba
*  如何使用乐观锁来解决线程安全缓存集合类的并发冲突问题
*/  
public class ThreadSafeCache<K, V> {  
    private final ConcurrentHashMap<K, V> cache;  
    private final AtomicInteger version;  
  
    public ThreadSafeCache() {  
        cache = new ConcurrentHashMap<>();  
        version = new AtomicInteger(0);  
    }  
  
    public V get(K key) {  
        return cache.get(key);  
    }  
  
    public void put(K key, V value) {  
        while (true) {  
            int currentVersion = version.get();  
            if (currentVersion != 0) {  
                // 如果当前版本号不为0,说明有其他线程正在更新数据,需要重新尝试更新  
                continue;  
            } else {  
                // 更新数据并递增版本号  
                if (version.compareAndSet(currentVersion, currentVersion + 1)) {  
                    V oldValue = cache.putIfAbsent(key, value);  
                    if (oldValue == null) {  
                        // 如果缓存中原本没有该键,则更新成功,返回  
                        return;  
                    } else {  
                        // 如果缓存中原本已经有该键,则表示其他线程已经更新了数据,需要重新尝试更新  
                        version.compareAndSet(currentVersion + 1, currentVersion + 2); // 增加版本号以表示冲突  
                        continue; // 重新尝试更新  
                    }  
                } else {  
                    // 如果在更新过程中,其他线程也修改了版本号,则重新获取当前版本号并继续尝试更新  
                    continue;  
                }  
            }  
        }  
    }  
}

示例中,使用ConcurrentHashMap作为缓存的存储,它提供了线程安全的并发访问。我们使用AtomicInteger作为版本号的存储,并利用其原子性来保证并发访问时的正确性。在put方法中,我们使用while循环来不断尝试更新数据,直到成功为止。在每次尝试更新之前,我们首先获取当前版本号,如果版本号不为0,则说明有其他线程正在更新数据,需要重新尝试更新。如果版本号为0,则表示当前没有其他线程正在更新数据,可以进行更新操作。在更新数据时,我们递增版本号,并使用compareAndSet方法来原子性地更新版本号和缓存数据。如果更新成功,则进一步判断缓存中原本是否已经有该键。如果缓存中原本没有该键,则表示更新成功,返回。如果缓存中原本已经有该键,则表示其他线程已经更新了数据,增加版本号以表示冲突,并重新尝试更新。这样就可以利用乐观锁的机制来解决线程安全缓存集合类的并发冲突问题。


? 什么是写时复制?


? 什么是COW,如何保证的线程安全?


Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略


从DK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是 CopyOnWriteArrayListCopyOnWriteArraySetCopyOnWrite容器非常有用,可以在非常多的并发场景中使用到。


CopyOnWriteArrayList 相当于线程安全的ArrayListCopyOnWriteArrayList 使用了一种叫写时复制的方法,当有新元素addCopyOnWriteArrayList时,先从原有的数组中拷贝一份出来,然后在新的数组做写操作,写完之后再将原来的数组引用指向到新数组。


这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。


注意: CopyOnWriteArrayList的整个add操作都是在锁的保护下进行的。也就是说add方法是线程安全的


CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和更新场景。


和ArrayList不同的是,它具有以下特性:


支持高效率并发且是线程安全


因为通常需要复制整个基础数组,所以可变操作 (add()、set() 和 remove() 等等) 的开销很大。

选代器支持hasNext() ,next() 等不可变操作,但不支持可变 remove() 等操作

使用迭代器进行遍历的速度很快,并目不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照

文章来源:https://blog.csdn.net/Java_Yangxiaoyuan/article/details/135514395
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。