Java并发中的ReentrantLock,AQS框架和非阻塞同步机制

独享锁(排他锁,互斥锁) VS 共享锁

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

悲观锁与乐观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。

先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改Java中,synchronized关键字和Lock的实现类都是悲观锁

乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

Lock 接口, ReentrantLock 和 synchronized

Lock 接口

synchronized 的一些缺点:

  1. 无法中断,无法定时
  2. 非公平锁

在 Java SE 5 后,Java 并发包 java.util.concurrent 中新增了 Lock 接口及其相关实现类,如:ReentrantLock 来实现锁的功能,它提供了与 synchronized 相似的同步功能,不过在使用时需要显示的获取锁和释放锁,虽然从这个角度来看,使用 Lock 接口更为麻烦,不过我们可以通过 Lock 接口的实现类,实现以下功能:

使用范例:

Lock lock = new ReentrantLock();
lock.lock();
try {
    // 同步代码块
} finally {
    lock.unlock(); // 千万不能忘记在finally块中释放锁
}

轮询与定时锁

轮询锁:

  • 只有在锁没有被其他线程拿到时才获取锁,然后返回 true,否则返回 false,会立即返回,不会阻塞
  • 不是可中断锁
  • 可以避免锁顺序死锁的发生

死锁发生的一个典型示例就是锁顺序死锁,即(假设我们要进行一个转账操作)

 public boolean transferMoney(Account fromAcct, Account toAcct, double money) {
     synchronized (fromAcct) {
         synchronized (toAcct) {
 			// 转账
         }
     }
 }
 // 调用
 final Account A = new Account();
 final Account B = new Account();
 new Thread() {
     public void run() {
         transferMoney(A, B, 100)
     }
 }.start();
 new Thread() {
     public void run() {
         transferMoney(B, A, 100)
     }
 }.start();
 // 两个线程在进行方向相反的转账操作,及容易发生死锁!

我们可以通过 tryLock() 的方式来避免锁顺序死锁

 public boolean transferMoney(Account fromAcct, Account toAcct, double money) {
     long fixedDelay = getFixedDelayComponentNanos(timeout, unit); // 固定时延部分
     long randMod = getRandomDelayModulusNanos(timeout, unit); // 随机时延部分
     long stopTime = System.nanoTime() + unit.toNanos(timeout); // 过期时间
     while (true) {
         if (fromAcct.lock.tryLock()) {
             try {
                 if (toAcct.lock.tryLock()) {
                  // 如果失败了,该线程会放开已经持有的锁,避免了死锁发生
                     try {
                         // 转账
                     } finally {
                         toAcct.lock.unlock();
                     }
                 }
             } finally {
                 fromAcct.lock.unlock();
             }
         }
         if (System.nanoTime() < stopTime) // 检查是否超时
             return false;
         NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod); 
         // 等待一定时长,防止陷入活锁
     }
 }

定时锁:

tryLock(long time, TimeUnit unit) 顾名思义,超时就会失败的一种锁。

中断锁:lock.lockInterruptibly();

  • 能在获得锁的同时保持对中断的响应,即在调用 lockInterruptibly() 获得锁之后,如果线程被 interrupt() 打上了中断标记,会抛中断异常
  • 相当于在同步代码块中加入了取消点
public class InterruptibleLocking {
    private Lock lock = new ReentrantLock();

    public boolean sendOnSharedLine(String message)
            throws InterruptedException {
        lock.lockInterruptibly();
        try {
            return cancellableSendOnSharedLine(message);
        } finally {
            lock.unlock();
        }
    }
    private boolean cancellableSendOnSharedLine(String message) throws InterruptedException {
        /* send something */
        return true;
    }
}

公平性

ReentrantLock提供公平和非公平两种锁,默认是非公平的。公平锁通过构造函数传递true表示。所谓的公平就是指的是:获得锁的顺序与申请锁的顺序一致,不存在插队情况。 ReentrantLock 锁也并不保证绝对意义上的公平性,而只是保证了相对公平。

非公平锁性能更优的原因:

  • 恢复一个被挂起的线程到这个线程真正运行起来之间,存在着巨大时时延
  • 在等待队列中的线程被恢复的超长时延里,如果正好进来了一个线程获取锁,非公平锁会让这个新进来的线程先执行,它很有可以能等待队列中的线程恢复运行前就执行完了,相当于时间不变的情况下,利用等待线程的恢复运行时延,多执行了一个线程
  • 只要当线程运行时间长,或锁的请求频率比较低时,才适合使用公平锁

ReentrantLock 和 synchronized的选择

选择方式:

  • 只有当我们需要如下高级功能时才使用 ReentrantLock,否则优先使用synchronized
    • 可轮询、可定时、可中断的锁
    • 公平锁
    • 非块结构的锁
  • 优先选择 synchronized 的原因:
    • Java 6开始,ReenstrantLock 和内置锁的性能相差不大
    • synchronized 是 JVM 的内置属性,未来更有可能对 synchronized 进行性能优化,如对线程封闭的锁对象的锁消除,增加锁的粒度等
    • ReenstrantLock 危险性更高(如忘记在 finally 块中 lock.unlock() 了,会导致锁永远无法被释放,出现问题,极难 debug)
    • 许多现有程序中已使用了 synchronized,两种方式混合使用比较易错
    • 在线程转储时,内置锁存储的信息要比 ReentrantLock 更加完善。

读写锁:ReentrantReadWriteLock(读多写少场景)

参考:Java-Concurrency-in-Practice/Ch3-Java并发高级主题/00-Java中的锁.md

AQS 框架

AQS 框架是用来构建锁或其他同步组件的基础框架,其核心思想为: 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,AQS 通过 CLH 队列实现了这种机制。 其实现原理为: 使用了一个 int 成员变量表示同步状态,然后通过内置的 FIFO 队列来完场资源获取线程的排队工作 。:

CLH (Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)实现锁分配的,并且这个队列遵循 FIFO 原则。

在这里插入图片描述

  • 当获得锁的线程需要等待某个条件时,会进入 condition 的等待队列,等待队列可以有多个。
  • 当 condition 条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。

AQS 使用方法

在要构建的同步类中加一个私有静态内部类:private class Sync extends AbstractQueuedSynchronizer

在子类中覆盖 AQS 的 try 前缀等方法,这样 Sync 将在执行获取和释放方法时,调用这些被子类覆盖了的 try 方法来判断某个操作是否能执行(模板方法模式,就是基于继承该类,然后根据需要重写模板方法):
主要实现的方法有:
在这里插入图片描述
一个 AQS 实现简单闭锁(类似于所有线程起先一起等待,之后同时执行)的示例:

@ThreadSafe
public class OneShotLatch {
    private final Sync sync = new Sync();

    public void signal() {
        // 共享式
        sync.releaseShared(0);
    }
	// 所有线程调用 await() 进行等待闭锁打开
    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(0);
    }

    //继承并实现 AbstractQueuedSynchronizer 的一些接口
    private class Sync extends AbstractQueuedSynchronizer {
        protected int tryAcquireShared(int ignored) {
            // Succeed if latch is open (state == 1), else fail
            return (super.getState() == 1) ? 1 : -1;
        }

        protected boolean tryReleaseShared(int ignored) {
            super.setState(1); // Latch is now open
            return true; // Other threads may now be able to acquire
        }
    }
}

concurrent 包同步器类中的 AQS

原子变量与非阻塞同步机制

Java的线程

锁的优劣势

  • 优势:
    • 线程之间存在竞争时,锁能自动处理竞争问题,即让一个线程拿到锁执行,然后阻塞其他线程,即独占。
    • 保证获得锁的线程对变量的修改对随后获得这个锁的线程是可见的。
  • 劣势:
    • 线程被阻塞到恢复执行的过程中存在很大的性能开销。

硬件对并发的支持

CAS 比较并交换

//示意代码
@ThreadSafe
public class SimulatedCAS {
    @GuardedBy("this")
    private int value;

    public synchronized int get() {
        return value;
    }
    public synchronized int compareAndSwap(int expectedValue,
                                           int newValue) {
        int oldValue = value;
        if (oldValue == expectedValue)
            value = newValue;
        return oldValue;
    }
    //如果有线程冲突,那么CAS就会返回失败,操作线程可以决定如何再次操作
    public synchronized boolean compareAndSet(int expectedValue,
                                              int newValue) {
        return (expectedValue
                == compareAndSwap(expectedValue, newValue));
    }
}

CAS的典型使用模式是:首先从V中读取值A,并根据A计算新值B,然后再通过CAS
以原子方式将V中的值由A变成B (只要在这期间没有任何线程将V的值修改为其他值)。由于CAS能检测到来自其他线程的干扰,因此即使不使用锁也能够实现原子的读一改一写操作序列。

CAS 与独占锁的比较

CAS

  • 优势
    • 借助冲突检查机制判断在更新状态的过程中有没有其他线程修改状态,如果有,更新操作失败,可以选择重试
    • 适合读多写少,资源竞争少的情况 (资源竞争少时,使用 synchronized 同步锁会进行线程的阻塞和唤醒,而 CAS 不需要切换线程,并且自旋概率较低)
  • 劣势
    • 需要自己控制流程

独占锁:适合写多读少,锁竞争严重的情况 (当资源竞争严重时,CAS 大概率会自旋,会浪费 CPU 资源)

CAS 示例—实现非阻塞的计数器
@ThreadSafe
public class CasCounter {
    private SimulatedCAS value;

    public int getValue() {
        return value.get();
    }

    public int increment() {
        int v;
        do {
            v = value.get();
        } while (v != value.compareAndSwap(v, v + 1));
        return v + 1;
    }
}

原子变量

与 Volatile 的区别(TODO@ME)

性能比较

  • 竞争高时,选择使用独占锁
  • 竞争低时,选择使用原子变量和CAS操作。

ABA 问题

问题描述: V 处的值经历了 A -> B -> A 的变化后,也认为是发生了变化的,而传统的 CAS 是无法发现这种变化的。

解决方法:

  • 使用 AtomicStampedReference 的 int stamp 版本号判断数据是否被修改过
  • 使用 AtomicMarkableReference 的 boolean marked 判断数据是否被修改过

在这里插入图片描述

小结
非阻塞算法通过底层的并发原语(例如比较并交换而不是锁)来维持线程的安全性。这些底层的原语通过原子变量类向外公开,这些类也用做一种“更好的volatile 变量”,从而为整数和对象引用提供原子的更新操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值