结合“锁”性能学习并发集合

本文探讨了如何通过减少锁粒度、读写分离、减少锁持有时间和锁分离来优化锁的性能,并以ConcurrentHashMap、CopyOnWriteArrayList和LinkedBlockingQueue为例,展示了并发集合在Java中的应用。同时,文章还提到了锁粗化这一优化策略,提醒读者在实际场景中灵活权衡。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

“锁”的竞争必然会导致程序性能急剧下降,常见的提高“锁”性能有以下一些建议。我们根据“锁”的优化来顺便学习一下并发集合(java.util.concurrent包下的部分集合类)


1.减少锁粒度

所谓减少锁粒度,就是指缩小锁定对象的范围,从而减少锁冲突的可能性,进而提高系统的并发能力。

我们来了解一下ConcurrentHashMap 类怎么实现减小锁粒度的:

    通过分析Hashtable就知道,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,ConcurrentHashMap 使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,一个ConcurrentHashMap被进一步细分为16个小段,每个段其实就是一个小的hash table,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。

但是减少锁粒度会带来新的问题,那就是,当系统获取全局信息时,其消耗会比较多,当我们试图获取ConcurrentHashMap全局信息时,就需要获取所有的锁资源才能顺利实现。也就是说当我们需要获取当前ConcurrentHashMap.size()时,我们需要获取16个锁然后才能去执行size长度的计算。

2.读写分离代替独占锁:

适用于读多写少的场合,使用读写锁可以有效提升系统的并发能力

使用读写锁ReadWriteLock可以提高系统的性能。使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。如果说上节中提到的减少锁粒度是通过分割数据结构实现的,那么,读写锁则是对系统功能点的分割。在读多写少的场合,读写锁对系统性能是很有好处的。因为如果系统在读写数据时均只使用独占锁,那么读操作和写操作间、读操作和读操作间、写操作和写操作间均不能做到真正的并发,并且需要相互等待。而读操作本身不会影响数据的完整性和一致性。因此,理论上讲,在大部分情况下,应该可以允许多线程同时读,读写锁正是实现了这种功能。

3.减少锁持有时间:

当多个线程去等待同一个“锁”的权限时,我们必然想前面的线程可以更快的释放“锁”资源。非必要时刻及时释放所自愿。以便他用。我们可以去做的是,只有在必要的时候进行同步,在无意义的操作时不进行“锁”占有,这样能够明显减少线程持有锁的时间,提高吞吐量。

      我个人认为CopyOnWriteArrayList 不仅仅是读写分离的演变,更加是将锁持有时间发挥极致的集合类。为什么会这么认为呢?下面我们来了解下CopyOnWriteArrayList 这个List集合是怎么来做到减少锁持有时间的。

适用于读多写少的场合等特性由于读操作根本不会修改原有的数据,因此对于每次读取都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问List的内部数据,毕竟读取操作是安全的。根据读写锁的思想,读锁和读锁之间确实也不冲突。但是,读操作会受到写操作的阻碍,当写发生时,读就必须等待,否则可能读到不一致的数据。同理,如果读操作正在进行,程序也不能进行写入。因此该类有读写分离的相关特性(适用于读多写少的场景)。

为了将读取的性能发挥到极致,JDK中提供了CopyOnWriteArrayList类。对它来说,读取是完全不用加锁的,并且更好的消息是:写入也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升。从这个类的名字我们可以看到,所谓CopyOnWrite就是在写入操作时,进行一次自我复制。换句话说,当这个List需要修改时,我并不修改原有的内容(这对于保证当前在读线程的数据一致性非常重要),而是对原有的数据进行一次复制,将修改的内容写入副本中。写完之后,再将修改完的副本替换原来的数据。这样就可以保证写操作不会影响读了。

我们看一下其主要的读取接口如下,会发现读取时并没有同步控制和锁操作。因为内部ArrayList 不会被修改,取而代之的是另一个类的代替,因此可以保证数据的安全。因此这个类的核心代码的数据的修改上。

    final Object[] getArray() {
        return array;
    }
    private E get(Object[] a, int index) {
        return (E) a[index];
    }
    public E get(int index) {
        return get(getArray(), index);
    }

下面我们看一下 CopyOnWriteArrayList 的写入操作

    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();
        }
    }

首先,写入操作使用锁,当然这个锁仅限于控制写-写的情况。其重点在于第7行代码,进行了内部元素的完整复制。因此,会生成一个新的数组newElements。然后,将新的元素加入newElements。接着,在第9行,使用新的数组替换老的数组,修改就完成了。整个过程不会影响读取,并且修改完后,读取线程可以立即“察觉”到这个修改(因为array变量是volatile类型)。

4.锁分离:

锁分离是读写分离思想的进一步延伸,读写锁是对读写操作进行有效的锁分离,不同场景不同应用可以对锁进行不一样的锁分离。在java.util.concurrent包下有一个LinkedBlockingQueue类,是一个很不错的锁分离样例。那么下面我们就介绍一下这个类是怎么进行锁分离的,从而可以高效的实现线程安全的队列。

LinkedBlockingQueue实现中,take与put函数分别实现从队列中去出数据与插入数据的功能,虽然两个操作都对队列进行了修改,但是一个在队尾操作,一个在对头进行操作。从理论上他们并不冲突,因此LinkedBlockingQueue根据这点进行了锁的分离实现,JDK对take()与put()由两个不同的锁来控制他们,使插入与插入。获取与获取同步,但是插入与获取并不冲突,当队列中没有数据时,take方法会进行等待,当put插入数据后会通知take方法有数据了,你可以来获取,同理,当队列慢了的时候,put会进行等待,take取走数据后会通知put可以插入,因此来实现这个锁分离的队列。

5锁粗化:

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。为此,虚拟机在遇到一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作叫做锁的粗化。

性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同,所以大家需要根据实际情况,进行权衡。

该文章只讲了部分java.util.concurrent包下的结合类,他们都是锁优化的运用的表现,下面我可能会更详细的介绍一些java.util.concurrent包下的集合类,去学习一些运用CAS等的集合类。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值