首页 > 资讯 > 手游资讯

京东第二次面试的时候,面试官让我讲一下Lock同步锁优化的问题。如果我不说这件事可以吗?

时间:2024-12-06来源:网络作者:小白 点击数:

性能方面,当并发量不高、竞争不激烈时,Synchronized同步锁具有层次锁的优点,性能与Lock类似;但在高负载、高并发的情况下,Synchronized同步锁具有层次锁的优点。如果竞争激烈,就会升级为重量级锁,性能不会像Lock锁那么稳定。

我们可以通过一组简单的性能测试来直观地比较两种锁的性能。结果如下所示。

通过上面的数据我们可以发现Lock锁的性能相对来说更加稳定。那么它与上一讲的Synchronized同步锁相比,它的实现原理是怎样的呢?

Lock 锁的实现原理

Lock是用Java实现的锁。 Lock是一个接口类。常用的实现类有ReentrantLock和ReentrantReadWriteLock(RRW),它们都是依赖于AbstractQueuedSynchronizer(AQS)类实现的。

AQS类结构包含一个基于链表的等待队列(CLH队列),用于存储所有被阻塞的线程。 AQS中还有一个状态变量,代表ReentrantLock的锁状态。

这个队列的操作都是通过CAS操作来实现的。我们可以通过一张图来看看整个锁的获取过程。

锁分离优化 Lock 同步锁

虽然Lock的性能稳定,但并不是所有场景都默认使用ReentrantLock独占锁来实现线程同步。

我们知道,读写同一份数据时,如果一个线程在读数据,另一个线程在写数据,那么读到的数据会和最终的数据不一致;如果一个线程在写数据,另一个线程也在写数据,那么读取到的数据会和最终的数据不一致。写入数据时,线程前后看到的数据会不一致。这时,我们可以在读写方法中添加互斥锁,以保证任一时刻只有一个线程可以执行读或写操作。

在大多数业务场景中,读业务操作远大于写业务操作。在多线程编程中,读操作不会修改共享资源的数据。如果多个线程只读取共享资源,这种情况下实际上不需要锁定资源。如果使用互斥锁,会影响业务的并发性能。那么在这种场景下,有没有什么办法可以优化加锁的实现呢?

1. 读写锁 ReentrantReadWriteLock

针对这种多读少写的场景,Java提供了另一个实现Lock接口的读写锁RRW。我们知道ReentrantLock是一种排它锁,只允许一个线程同时访问,而RRW允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。读写锁内部维护两个锁,一个是读操作的ReadLock,一个是写操作的WriteLock。

那么读写锁如何实现锁分离来保证共享资源的原子性呢?

RRW也是基于AQS实现的。它的自定义同步器(继承自AQS)需要在同步状态上维护多个读线程和一个写线程的状态。这个状态的设计成为实现读写锁的关键。 RRW很好地利用了高低位,实现了用整数控制两种状态的功能。读写锁将变量分为两部分。高16位代表读,低16位代表写。

当一个线程尝试获取写锁时,会首先判断同步状态state是否为0,如果state等于0,则表示暂时没有其他线程获取到该锁;如果state不等于0,则说明其他线程已经获取了锁。

此时判断同步状态state的低16位(w)是否为0,如果w为0,则说明其他线程已经获取了读锁,则进入CLH队列阻塞等待;如果w不为0,则说明其他线程已经获取了读锁。线程获取写锁。这时候就需要判断当前线程是否获得了写锁。如果没有,则进入CLH队列阻塞等待;如果是,则判断当前线程获取写锁的次数是否超过最大次数。如果超过则抛出Exception,否则更新同步状态。

当一个线程尝试获取读锁时,它也会首先判断同步状态state是否为0。如果state等于0,则表示暂时没有其他线程获取到该锁。此时判断是否需要阻塞。如果需要阻塞,则进入CLH队列等待阻塞;如果不需要阻塞,CAS将同步状态更新为读状态。

如果state不等于0,则判断同步状态低于16位。如果有写锁,则读锁获取失败,进入CLH阻塞队列。否则,会判断当前线程是否应该被阻塞。如果不应该被阻塞,则会尝试CAS同步状态。同步锁成功更新为读状态。

我们通过一个找正方形的例子来体验一下RRW的实现。代码如下:

公共类TestRTTLock { 私有双x, y; private ReentrantReadWriteLock lock=new ReentrantReadWriteLock();//读锁private Lock readLock=lock.readLock();//写锁private Lock writeLock=lock.writeLock(); public double read () {//获取读锁readLock.lock(); try {return Math.sqrt(x * x + y * y);} finally {//释放读锁readLock.unlock();}} public void move( double deltaX, double deltaY) {//获取写锁writeLock.lock();try {x +=deltaX;y +=deltaY;} finally {//释放写锁writeLock.unlock();}} }

2. 读写锁再优化之 StampedLock

RRW很好用在读较大的并发场景比写作。不过,RRW在性能上仍有提升空间。当读多写少时,RRW会导致写线程遇到饥饿问题,这意味着写线程会因为无法竞争锁而处于等待状态。

在JDK1.8中,Java提供了StampedLock类来解决这个问题。 StampedLock并不是基于AQS实现的,但是实现原理和AQS是一样的,都是基于队列和锁状态实现的。与RRW不同的是,StampedLock控制锁有:写、悲观读和乐观读三种模式,并且StampedLock在获取锁时会返回一个票据戳记。释放锁时需要验证获取到的戳记。在乐观读取模式下,读取共享资源后,还会使用stamp作为二次验证。稍后我将解释邮票的工作原理。

我们先通过一个官方的例子来了解一下StampedLock是如何使用的。代码如下:

公共类点{私人双x,y;私有最终StampedLock s1=new StampedLock(); void move(double deltaX, double deltaY) { //获取写锁long stamp=s1.writeLock();尝试{ x +=deltaX ; y +=deltaY; } finally { //释放写锁s1.unlockWrite(stamp); } } double distanceFormOrigin() { //乐观读取操作long stamp=s1.tryOptimisticRead(); //复制变量double currentX=x , currentY=y; //判断读时是否有写操作if (!s1.validate(stamp)) { //升级为悲观读stamp=s1.readLock();尝试{当前X=x;当前Y=y; } 最后{ s1.unlockRead(stamp); return Math.sqrt(currentX * currentX + currentY * currentY);我们可以发现,写线程在获取写锁的过程中,首先通过WriteLock获取了票证。 WriteLock是排他锁。同一时间只有一个线程可以获取锁。当一个线程获取锁时,其他请求线程必须等待。只有当没有线程持有读锁或写锁时才能获取锁。成功请求锁后,将返回一个戳记票变量来表示锁的版本。释放锁时,需要传递unlockWrite,并传递参数stamp。

接下来就是读线程获取锁的过程。首先,线程会通过乐观锁tryOptimisticRead操作获取票据戳记。如果当前没有线程持有写锁,则会返回非0戳版本信息。线程获得标记后,会将共享资源复制到方法堆栈中。在此之前,具体操作都是根据方法栈的复制数据来进行的。

然后该方法需要调用validate 来验证调用tryOptimisticRead 返回的戳记当前是否被其他线程持有。如果是,validate将返回0并升级为悲观锁;否则,可以使用印章版本的锁对。数据被操作。

与RRW相比,StampedLock仅使用AND或运算进行验证,不涉及CAS运算。即使第一次乐观锁获取失败,也会立即升级为悲观锁,这样就可以避免连续CAS操作带来的问题。 CPU使用率是一个性能问题,所以StampedLock效率更高。

总结

无论使用Synchronized同步锁还是Lock同步锁,只要存在锁竞争,就会出现线程阻塞,导致线程之间频繁切换,最终增加性能消耗。因此,如何减少锁竞争就成为优化锁的关键。

在Synchronized同步锁中,我们了解到可以通过减小锁粒度、减少锁占用时间来减少锁竞争。到这里,我们知道,我们可以利用Lock锁的灵活性,通过锁分离来减少锁争用。

用户评论

短发

声明:本文内容仅代表作者个人观点,与本站立场无关。如有内容侵犯您的合法权益,请及时与我们联系,我们将第一时间安排处理。

今日推荐