在Java中,我们会接触到各种各样的锁,包括但不限于CAS锁,synchronized可变换锁,可重入锁,分布式锁等等,由于其功能不同,适应场景各异,所以使用起来就需要根据具体的场景进行甄别,避免因为不合时宜的使用导致线上业务问题。

这里为了方便说明,我们把锁分为两种类型,一种是单机锁,另一种是分布式锁。

先说下单机锁吧。

synchronized锁机制

Synchronized是Java提供的一种内置的同步机制,它通过对“共享资源”加上锁,来实现对资源的互斥访问,防止多个线程同时对共享资源进行修改,从而确保数据的一致性和安全性。 Synchronized的工作原理是基于每个对象都对应一个监视器锁(monitor),当监视器锁被占用,其他线程就必须等待,获得锁的线程退出同步代码块后,或进入等待状态,释放监视器锁,这时等待线程则可以获得锁。

synchronized的用法主要有三种:

1. 同步实例方法:

这种用法会让整个方法变成同步方法,即在同一个时间只能有一个线程执行这个方法。 例如:

 public synchronized void method(){}

2. 同步代码块:

这种用法会对指定的对象加锁,其它线程要访问这段代码,必须先获得指定对象的锁。 例如:

synchronized(object){}

3. 同步静态方法:

这种用法与同步实例方法类似,只是生效范围为整个类的静态方法。 例如:

 public static synchronized void method(){}

这里特别需要注意的地方就是锁该往哪里加的问题,发现很多朋友在写代码的时候经常会弄错,导致的结果可能就是并发情况下发生严重的阻塞甚至会导致影响应用主流程的情况。

如果多个实例竞争公共的资源,则锁应该加在静态类或者方法上。

如果每个实例内部,并发请求争夺实例内部的公共资源,则锁应该加在当前实例上。每个实例内部的并发请求争夺实例外部的公共资源,则锁此时就应该加在静态类或者方法上了。

比如我们经常会使用双检锁去初始化一些对象,假如初始化的对象是当前实例拥有的对象,诸如xxServiceStarter, 则synchronized应该锁在当前实例上。但是如果初始化的对象是一些公共资源,比如redis client等,则synchronized应该锁在静态类上。

synchronized锁根据线程的竞争情况,可以分为偏向锁、轻量级锁、重量级锁。

1)偏向锁:从名字上我们就可以看出这种锁会倾向于第一次获得它的线程,如果在接下来的运行过程中,该锁没有被其他的线程所访问,那么持有偏向锁的线程将永远不需要触发同步。偏向锁适用于只有一个线程访问同步块的情况。

2)轻量级锁:当有多个线程竞争同步锁时,偏向锁就会升级为轻量级锁。轻量级锁依赖于CAS操作 markword 来达到锁的目的。当有线程尝试获取锁时,JVM先通过CAS操作在对象头和栈帧中建立一个锁记录,然后把对象头复制到锁记录里,这样在后面这个线程退出同步块释放锁的时候就可以再通过一个CAS操作把对象头换回来,表示这个锁已经释放了。

3)重量级锁:当线程争用严重且CAS操作无法成功时,锁就会膨胀为重量级锁。在这种情况下,锁的获取和释放都需要通过操作系统的内核来完成,涉及线程上下文切换等操作,所以性能消耗 relatively 大。

偏向锁->轻量级锁的转化过程: 当一个已经获取了偏向锁的同步代码块被另一个线程访问的时候,那么偏向锁就会升级为轻量级锁。升级过程为先撤销偏向锁,然后在自旋中尝试使用CAS获取锁,如果成功,则使用轻量级锁,否则,进入阻塞状态,使用重量级锁。

轻量级锁->重量级锁的转化过程: 当某一个线程试图获取一个已经被另一个线程持有的锁时,它会进行自旋操作尝试获取这个锁。如果自旋次数超过一定的阈值或者一个线程在自旋过程中发现有新的线程试图获取该锁,那么轻量级锁就会膨胀为重量级锁。

以上转换过程并不一定按照偏向锁->轻量级锁->重量级锁的顺序,当启动时没有通过-XX:+UseBiasedLocking参数开启偏向锁的时候,初始级别为轻量级锁。

CAS锁机制

CAS,全称Compare And Swap,即比较并交换。它是一种用于解决并发问题的无锁算法,主要运用在多线程编程中实现无锁(即不使用锁)的数据结构。

CAS锁机制的基本思路是三个步骤,分别是:获取内存值、比较内存值与预期值是否相等、如果相等则设置为新的值。所以,在JAVA中对应的CAS操作通常可以表示为: compareAndSet(expectedValue, newValue) 。如果内存中的值与预期值(expectedValue)相等,则设置为新值(newValue),操作成功返回true;否则操作失败返回false。

例如,原子整型(AtomicInteger)类中的 incrementAndGet 方法就是使用 CAS 机制实现的,原子性地自增并获取:

public final int incrementAndGet() { 
       for (;;) { 
           int current = get(); 
           int next = current + 1;
           if (compareAndSet(current, next)) 
                   return next; 
       } 
}

在这里,首先获取了当前的值,然后+1得到期望的新值,接着使用 CAS 操作尝试更新,如果成功则返回新值,否则重试,直到成功为止。

CAS机制相对于synchronized来说,避免了线程切换和阻塞的额外消耗,因此在并发量比较高的情况下,CAS机制大大提高了性能和效率。但它也有自身的问题,比如ABA问题,循环时间长开销大,只能保证一个共享变量的原子操作等,需要根据具体场景选择合适的并发控制机制。

上面只是CAS的一些原理讲解和简单的用法,比较简单。

在项目中,该怎么用呢?

比如有如下的一个场景,缓存穿透后,需要请求线程回源数据库,反刷数据到缓存。如果我们使用普通的方式回源数据库,则在一瞬间一般会穿透十多个或者几十个请求线程回源数据库,如果数据库撑不住,就直接回源挂了。此时我们其实就可以利用CAS锁,只允许一个线程回源到数据库进行数据反刷,避免回源导致的数据库穿透问题。

在写框架的时候,又该怎么用呢?

在写框架的时候,我们经常会写一些xxxSpringStarter类,这些类实际上就是初始化你的框架实例,实际上在初始化的时候,我们就可以利用CAS类控制框架实例的生成,避免一次性产生多个实例而导致框架出问题,比如:

    /**
    * 初始的cas锁
    */
   private static AtomicBoolean startLocker = new AtomicBoolean();


   /**
    * spring拉起检测
    *
    * @param ev
    */
   @Override
   public void onApplicationEvent(ContextRefreshedEvent ev) {
       if (startLocker.compareAndSet(false, true)) {
           providersManager = new ProvidersLifeCycleManager();
           Map<String, ChaosProviderBeanDetail> stringChaosProviderBeanDetailMap = buildProviderBeanDetails();
           providersManager.start(stringChaosProviderBeanDetailMap);
       }
   }

AQS锁机制

AQS,全称是AbstractQueuedSynchronizer,抽象队列同步器。它是JDK中提供的一个用于构建锁和其他同步组件的框架。

AQS的主要使用方式是继承,它定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如ReentrantLock、Semaphore、CountDownLatch等。

在AQS中,通过内置的FIFO队列来完成获取资源线程的排队工作,每一个申请获取资源的线程都是一个Node节点,所有的Node节点构成一个不定长的链表。同时,AQS使用了一个int类型的成员变量state来表示资源,通过这种方式,AQS既能支持排他性也能支持共享性的获取资源方式。

在进行资源获取时,如果获取失败(获取排他锁状态的值失败或可获取资源数不足),AQS会将当前线程及等待状态等信息包装成一个Node节点,加入到队列中。获取资源成功的线程会负责通知后继节点。

具体来说,AQS提供模板方法如tryAcquire/canBeCancelled/tryRelease等,具体的同步组件根据自己的同步机制去实现这些模板方法。

例如,ReentrantLock就是通过实现AQS的模板方法完成对于具体Lock的实现:当一个线程调用ReentrantLock的lock方法获取锁时,如果此锁未被其他线程占用(state=0),那么当前线程就可以获取到这把锁,并将state设置为1;如果此锁已被占用,那么当前线程就会被封装成Node节点,加入到AQS的等待队列中等待获取锁。这就是ReentrantLock基于AQS框架的实现方式。

总结起来,AQS通过内置的FIFO队列和资源获取失败的线程加入队列等待的机制,为许多同步类提供了一个关于如何进行资源管理和线程排队等待的有效的解决方案,大大简化了同步类的实现过程。

这里来比较一下AQS锁机制和synchronized锁机制:

AQS的优点:

1. AQS提供了一个基于FIFO队列,可以实现公平锁和非公平锁的框架。

2. AQS支持共享锁和排他锁,也可以支持多个条件变量。

3. AQS有着良好的扩展性,通过覆写AQS的方法,可以实现各种包含独占锁和共享锁在内的同步结构,比如ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等都是基于AQS实现的。

4. 使用AQS可以完全不必关心同步状态的更新和同步队列的维护,只需要去实现资源的获取和释放方法即可。

synchronized的优点:

1. Java语言内置,代码简单明了。写法更简洁。

2. 无需处理可能产生的异常。

3. 自动释放锁,无须手动操作。

AQS的缺点:

1. 使用起来复杂,需要定义更多的方法和处理更多的异常。

2. 对于简单的锁操作,AQS的实现可能会相对重量级。

synchronized的缺点:

1. 不公平,不保证等待的线程会获取到锁。当竞争激烈时可能导致线程饥饿。

2. 不支持获取锁时的中断操作,也就是说调用synchronized申请锁的线程,在等待过程中无法被中断。

3. 只支持非公平的排他锁,不能根据需要选择公平锁和非公平锁,也不支持共享锁。

4. 不支持超时获取锁的操作,即线程在指定的时间内没有获取到锁就返回等待或者做其他操作。

所以,对于简单的并发操作,synchronized的使用会更加方便;但对于复材的并发操作,比如需要公平性、可中断性、多条件和共享锁等更灵活的场合,AQS的使用会更胜一筹。

基于AQS锁框架写一个自定义锁

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

// 静态内部下面是一个基于AQS框架实现的简化版本的独占锁,MyLock。我们主要用到AQS的模板方法tryAcquire和tryRelease。
public class MyLock {

    private Sync sync;

    public MyLock() {
        sync = new Sync();
    }

    // 加锁操作
    public void lock() {
        sync.acquire(1);
    }

    // 释放锁操作
    public void unlock() {
        sync.release(1);
    }

    // 自定义同步器
    private class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int arg) {
            if (compareAndSetState(0, 1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        @Override
        protected boolean tryRelease(int arg) {
            if (getState() == 0) throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
    }
}

这个MyLock类表示了一个独占锁,当调用lock()方法时,如果state=0,表示锁未被占用,则将状态设置为1,并设置占有锁的线程为当前线程;如果state=1,表示锁已被占用,则当前线程进入AQS队列等待。当调用unlock()方法时,如果当前线程是锁的持有者,则释放锁,并且唤醒等待队列的头部线程。

分布式锁机制

一般我们使用redis分布式锁,其是基于setnx,setex等原子命令实现,这里就不赘述了,只有一个请求可以获得锁,然后执行逻辑。抢不到锁的非重要请求,可以放弃掉,由用户自行重试即可; 而抢不到锁重要的请求,则需要应用进行队列重试处理。具体实现方式根据业务逻辑来进行处置即可。

posted on 2023-12-27 15:38  程序诗人  阅读(13)  评论(0编辑  收藏  举报