并发容器J.U.C -- AQS同步组件(三)

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS)。AQS使用一个整型的volatile变量(state)来维护同步状态

ReentrantLock

java中两类锁: Synchronized、 J.U.C中提供的锁。
ReentrantLock与Synchronized都是可重入锁,本质上都是lock与unlock的操作。

ReentrantLock 与synchronized 的区别

可重入性:两者的锁都是可重入的,差别不大,有线程进入锁,计数器自增1,等下降为0时才可以释放锁

锁的实现:synchronized是基于JVM实现的(用户很难见到,无法了解其实现),ReentrantLock是JDK实现的。
性能区别:在最初的时候,二者性能差很多,当synchronized引入了偏向锁、轻量级锁(自旋锁)后,二者的性能差别不大,官方推荐synchronized(写法更容易、在优化时其实是借用了ReentrantLock的CAS技术,试图在用户态就把问题解决,避免进入内核态造成线程阻塞)
功能区别:

  1. 便利性:synchronized更便利,它是由编译器保证加锁与释放。ReentrantLock是需要手动释放锁,所以为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
  2. 锁的细粒度和灵活度,ReentrantLock优于synchronized

ReentrantLock 的独有的功能

  1. ReentrantLock 可指定是公平锁和非公平
    (公平锁:先等待的线程先获得锁,默认使用非公平锁)
    synchronized 只能是非公平锁
  2. 提供了一个Condition类,可以分组唤醒需要唤醒的线程。
    synchronized 要么随机唤醒一个线程,要么全部唤醒
  3. 提供能够中断等待锁的线程机制,lock.lockInterruptibly()实现。ReentrantLock
    实现是一种自旋锁,通过循环调用cas自加操作,避免了线程进入内核态发生阻塞
    synchronized 不会忘记释放锁

ReentrantLock 获取锁释放锁

ReentrantLock类图

公平锁vs非公平锁

int c = getState();// 获取锁的开始,首先读volatile变量state
//与nonfairTryAcquire(int acquires)比较,唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()
公平锁vs非公平锁
hasQueuedPredecessors()方法,判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。

   //即加入了同步队列中当前节点是否有前驱节点的判断
   //返回true,则表示有线程比当前线程更早地请求获取锁
   //因此需要等待前驱线程获取并释放锁之后才能继续获取锁。

hasQueuedPredecessors()方法
释放锁 Sync:tryRelease()

protected final boolean tryRelease(int releases) {
    
    
            int c = getState() - releases;//读取state
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
    
    
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);// 释放锁的最后,写volatile变量state
            return free;
        }

公平锁在释放锁的最后写volatile变量state,在获取锁时首先读这个volatile变量。int c = getState();
根据volatile的happens-before规则(一个volatile变量的写操作发生在这个volatile变量随后的读操作之前),释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变的对获取锁的线程可见。

对公平锁和非公平锁的内存语义

对于非公平锁,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁则不同。

公平锁与非公平锁释放时,最后都要写一个volatile变量state
公平锁获取时,首先去读volatile变量
非公平锁获取时,首先用CAS更新volatile变量,该操作同时具有volatile读和volatile写的内存语义。

CAS如何同时具有volatile读和volatile写的内存语义?
编译器不会对volatile读与volatile读后面的任意内存操作重排序;
编译器不会对volatile写与volatile写前面的任意内存操作重排序。
组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。

综上:
公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。
非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

放弃synchronized?

ReentrantLock不仅拥有synchronized的所有功能,而且有一些功能synchronized无法实现的特性。性能方面,ReentrantLock也不比synchronized差,那么我们要不要放弃使用synchronized呢?答案是不要这样做。

J.U.C包中的锁定类是用于高级情况和高级用户的工具,除非说你对Lock的高级特性有特别清楚的了解以及有明确的需要,或这有明确的证据表明同步已经成为可伸缩性的瓶颈的时候,否则我们还是继续使用synchronized。相比较这些高级的锁定类,synchronized还是有一些优势的,比如synchronized不可能忘记释放锁。还有当JVM使用synchronized管理锁定请求和释放时,JVM在生成线程转储时能够包括锁定信息,这些信息对调试非常有价值,它们可以标识死锁以及其他异常行为的来源。

ReentrantLock的使用

//创建锁:使用Lock对象声明,使用ReentrantLock接口创建
private final static Lock lock = new ReentrantLock();
//使用锁:在需要被加锁的方法中使用
private static void add() {
    
    
    lock.lock();//获取锁
    try {
    
    
        count++;
    } finally {
    
    
        lock.unlock();//释放锁
    }
}

源码

//初始化方面:
//在new ReentrantLock的时候默认给了一个不公平锁
public ReentrantLock() {
    
    
    sync = new NonfairSync();
}
//加参数来初始化指定使用公平锁还是不公平锁
//fair=true时公平锁
public ReentrantLock(boolean fair) {
    
    
    sync = fair ? new FairSync() : new NonfairSync();
}

ReentrantLock 函数方法

tryLock():仅在调用时锁定未被另一个线程保持的情况下才获取锁定。
tryLock(long timeout, TimeUnit unit):如果锁定在给定的时间内没有被另一个线程保持且当前线程没有被中断,则获取这个锁定。
lockInterruptbily:如果当前线程没有被中断的话,那么就获取锁定。如果中断了就抛出异常。
isLocked:查询此锁定是否由任意线程保持
isHeldByCurrentThread:查询当前线程是否保持锁定状态。
isFair:判断是不是公平锁

Condition相关特性:

hasQueuedThread(Thread):查询指定线程是否在等待获取此锁定
hasQueuedThreads():查询是否有线程在等待获取此锁定
getHoldCount():查询当前线程保持锁定的个数,也就是调用Lock方法的个数

Condition的使用

Condition可以非常灵活的操作线程的唤醒,下面是一个线程等待与唤醒的例子,其中用1234序号标出了日志输出顺序

public static void main(String[] args) {
    
    
    ReentrantLock reentrantLock = new ReentrantLock();
    Condition condition = reentrantLock.newCondition();//创建condition
    //线程1
    new Thread(() -> {
    
    
        try {
    
    
            reentrantLock.lock();
            log.info("wait signal"); // 1
            condition.await();
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        log.info("get signal"); // 4
        reentrantLock.unlock();
    }).start();
    //线程2
    new Thread(() -> {
    
    
        reentrantLock.lock();
        log.info("get lock"); // 2
        try {
    
    
            Thread.sleep(3000);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        condition.signalAll();//发送信号
        log.info("send signal"); // 3
        reentrantLock.unlock();
    }).start();
}

输出结果:
1–wait signal
2–get lock
3–send signal
4–get signal

输出过程讲解:
1、线程1调用了reentrantLock.lock(),线程进入AQS等待队列,输出1号log
2、接着调用了awiat方法,线程从AQS队列中移除,锁释放,直接加入condition的等待队列中
3、线程2因为线程1释放了锁,拿到了锁,输出2号log
4、线程2执行condition.signalAll()发送信号,输出3号log
5、condition队列中线程1的节点接收到信号,从condition队列中拿出来放入到了AQS的等待队列,这时线程1并没有被唤醒。
6、线程2调用unlock释放锁,因为AQS队列中只有线程1,因此AQS释放锁按照从头到尾的顺序,唤醒线程1
7、线程1继续执行,输出4号log,并进行unlock操作。

读写锁:ReentrantReadWriteLock

排他锁在同一时刻只允许一个线程进行访问(ReentrantLock属于排他锁)。
读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。

除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行。

使用场景

public class LockExample3 {
    
    
    private final Map<String, Data> map = new TreeMap<>();
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();//读锁
    private final Lock writeLock = lock.writeLock();//写锁
    //加读锁
    public Data get(String key) {
    
    
        readLock.lock();
        try {
    
    
            return map.get(key);
        } finally {
    
    
            readLock.unlock();
        }
    }
    //加写锁  设置key对应的value,并返回旧的value
    public Data put(String key, Data value) {
    
    
        writeLock.lock();
        try {
    
    
            return map.put(key, value);
        } finally {
    
    
            writeLock.unlock();
        }
    }

    class Data {
    
    }
}

票据锁:StempedLock

写、读、乐观读
一个StempedLock的状态是由版本模式两个部分组成。锁获取方法返回一个数字作为票据(stamp),他用相应的锁状态表示并控制相关的访问。数字0表示没有写锁被授权访问,在读锁上分为【悲观读、乐观读】。

乐观读: 读多写少,乐观的认为读、写操作同时发生几率很小,因此不悲观的使用完全的读取锁定。程序可以查看读取之后是否遭到写入的变更,再采取相应措施。

使用

//定义
private final static StampedLock lock = new StampedLock();
//需要上锁的方法
private static void add() {
    
    
    long stamp = lock.writeLock();
    try {
    
    
        count++;
    } finally {
    
    
        lock.unlock(stamp);
    }
}

源码

class Point {
    
    
        private double x, y;
        private final StampedLock sl = new StampedLock();
        void move(double deltaX, double deltaY) {
    
    
            long stamp = sl.writeLock();
            try {
    
    
                x += deltaX;
                y += deltaY;
            } finally {
    
    
                sl.unlockWrite(stamp);
            }
        }

        //下面看看乐观读锁案例
        double distanceFromOrigin() {
    
     // A read-only method
            long stamp = sl.tryOptimisticRead(); //获得一个乐观读锁
            double currentX = x, currentY = y;  //将两个字段读入本地局部变量
            if (!sl.validate(stamp)) {
    
     //检查发出乐观读锁后同时是否有其他写锁发生?
                stamp = sl.readLock();  //如果没有,我们再次获得一个读悲观锁
                try {
    
    
                    currentX = x; // 将两个字段读入本地局部变量
                    currentY = y; // 将两个字段读入本地局部变量
                } finally {
    
    
                    sl.unlockRead(stamp);
                }
            }
            return Math.sqrt(currentX * currentX + currentY * currentY);
        }

        //下面是悲观读锁案例
        void moveIfAtOrigin(double newX, double newY) {
    
     // upgrade
            // Could instead start with optimistic, not read mode
            long stamp = sl.readLock();
            try {
    
    
                while (x == 0.0 && y == 0.0) {
    
     //循环,检查当前状态是否符合
                    long ws = sl.tryConvertToWriteLock(stamp); //将读锁转为写锁
                    if (ws != 0L) {
    
     //这是确认转为写锁是否成功
                        stamp = ws; //如果成功 替换票据
                        x = newX; //进行状态改变
                        y = newY;  //进行状态改变
                        break;
                    } else {
    
     //如果不能成功转换为写锁
                        sl.unlockRead(stamp);  //我们显式释放读锁
                        stamp = sl.writeLock();  //显式直接进行写锁 然后再通过循环再试
                    }
                }
            } finally {
    
    
                sl.unlock(stamp); //释放读锁或写锁
            }
        }
    }

总结

synchronized :JVM实现,可通过一些监控工具监控,出现未知异常时,JVM会自动帮助释放锁。
ReetrantLock 、ReetrantReadWriteLock 、StempedLock都是对象层面的锁定,为保证锁一定释放,要放到finally里才会更安全,StempedLock对性能有很大改进,特别是读线程越来越多情况。

如何选择锁

1、当只有少量竞争者,使用synchronized
2、竞争者不少但是线程增长的趋势是能预估的,使用ReetrantLock

猜你喜欢

转载自blog.csdn.net/eluanshi12/article/details/85262018