开发过程中不同业务场景使用不同的锁

787 阅读7分钟

前言

Java提供了各式各样的锁,在我们日常开发的过程中,为了保证我们的业务逻辑在多线程并发的场景下的正确性、安全性、唯一性,通常会使用锁机制来对业务进行限制,因为每个锁的特性不同,在不同的业务场景下使用不同的锁,才能发挥出它的作用,从而展现出更高的效率,除了JDK自带的锁,还有外部锁包括redis的分布式锁,针对不同的业务场景,应该如何来使用锁的类型,让我一起来看下。

锁的分类

大致分为乐观锁、悲观锁,只是从从广义的看来,细致的分类的话就有无锁、偏向锁、轻量级锁、重量级锁、公平、非公平性锁、可重入、不可重入锁、独享锁、共享锁,这么多的锁类型,用法也是不一样的。

乐观锁

首先来看乐观锁,乐观锁是一种无锁的状态,实现方式就是我们最常采用的CAS算法,CAS的全称是compare and swap(比较并且交换),主要就是维护了3个变量,内存值、预期值、更新值,通过循环去比较内存值和预期值是否相等,如果相等,就把内存值赋到更新值上去,适应于多读的场景。

存在问题

  1. ABA的问题,一个比较明显的漏洞,存在预期值可能在中途被更改,更改后又更改回来,这个是后CAS操作就会误认为预期值没有被更改,从而进行CAS操作的时候仍然能够操作成功。解决办法,就是在更新变量前加入version版本号概念,每次比较的时候需要去判断版本号是否一致。
  2. 同时只能保证一个共享变量的原子操作,多个共享变量被操作时,CAS就无法保证原子性了,需要通过悲观锁保证原子性。
  3. CAS循环比较,存在CPU的性能问题。

应用业务场景

保证接口幂等性的场景下,保证接口不会重复更新数据,在请求更新接口的时候,通过在数据库增加版本字段,更新的时候需要判断版本号是否一致。

数据库层面 update table set version =version+1 where id =#{id} and version =1 代码层面,可以使用基于CAS实现的原子类。

//乐观锁调用方式
AtomicInteger atomicInteger =new AtomicInteger();
atomicInteger.incrementAndGet(); //自增1

悲观锁

多个线程去尝试获得同步的资源,保证同时只允许一个线程通过锁获取到资源,其余的线程需要等待获取到同步资源的线程执行完成后,释放锁才能获取到同步资源,保证写数据时数据的正确性。

ReentrantLock

ReentrantLock,基于AQS队列的同步锁,之前关于AQS队列的文章AQS为什么使用双向FIFO队列 - 掘金 这里又涉及到一个公平和非公平性锁的概念,让我们看源码进行分析。

非公平锁

A74D842A-9289-409B-A758-6B967F5BE523.png 公平锁

20505E22-3D42-4A47-86A9-F63B9B3C1D9B.png

hasQueuedPredecessors方法主要用来判断线程需不需要排队,ReentrantLock底层是FIFO双向队列,需要判断队列中有没有线程节点在排队,这也是和非公平锁的区别,公平锁需判断是否排队,排队返回ture,没有排队返回false优先执行。

存在的问题

  1. ReentrantLock默认是非公平锁,如果需要使用公平锁需要手动开启,可能是考虑到性能的原因,所以默认是非公平锁。
  2. 需要在try catch后面执行finally,将锁释放掉,否则当前线程一直获取到锁,其他线程一直处于等待状态,无法获取到锁。
  3. ReentrantLock,进行一次枷锁操作,只能进行一次释放,不能多次释放,否则会报错
  4. try里面进行加锁操作,会导致还没加锁成功,就进行finally释放锁,导致try里面其他业务的报错,被加锁报错的异常覆盖掉,无法排查问题。

应用业务场景

加锁排队进行add操作,同时只允许一个线程获取到锁进行业务操作。

 
    /**
     * 可重入锁、公平锁
     */
private Lock  lock  = new ReentrantLock(true);

  /**
     * 判断是否请求过
     */
 public void  add (String key) {

        try {
   
            if (lock.tryLock(1, TimeUnit.SECONDS)) {
                try {    
                  //执行业务逻辑
                } finally {
                    lock.unlock();
                }
            } else {
                log.info("获取锁失败!直接返回!");
                
            }
        } catch (Exception e) {
            log.warn("获取锁异常! key={} errMsg:" + e.getMessage(), key, e);
        } 
        
    }

Sychronized

基于JVM的锁,通过monitor enter 和monitor exit这两个JVM命令完成加锁, 从主内存中获取数据,共享变量需要在操作后从本地内存在刷回主内存。 可以作用于锁对象和class类。

存在问题

  1. 锁的的颗粒度较大
  2. 没有公平锁,所有的线程竞争锁,先到先得,一些需要排队处理的场景无法进行实现。
  3. 锁不能进行手动释放,需要的等待当前线程执行完业务后,自己释放锁,其他线程才能获取锁。
  4. 不能设置超时时间,意味当前获取锁在进行io操作的时时候,必须执行完才能释放锁,否则将一直占用锁资源。
  5. 无法中断正在获取锁的线程,不够灵活。

应用业务场景

最常见的就是在单例模式中加入sychronized锁,防止对象重复创建,包括在双亲委派机制里面ClassLoader类的loadClass方法里面,通过对需要进行加载的类的对象进行上锁,业务代码中一般不会去用到sychronized锁,主要取决于它的灵活性实在太低。

Double check 加锁

public class Singleton{
		private static volatile Singleton singleton;
		private Singleton(){}
		public static Singleton newInstance(){
		//double check 校验对象实例是否已经存在
			   if(singleton==null){
			   //对对象加锁,当前只允许一个线程对象实例进行加载
			 		synchronized(Singleton.class){
			 			  		if(singleton==null){
			 							singleton=new Singleton();
			 				   		}
			 			}
			     }
			     return singleton;
		}
}

ClassLoader顶级类加载机制加锁

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);

        //.....
   }
}

读写锁

ReentrantReadWriteLock

顾名思义读锁和写锁结合,ReentrantReadWriteLock源码ReadLock 和WriteLock作为内部类,也是通过Sync类和AQS队列实现。 但是读锁和写锁的加锁方式有些不同,读锁是共享锁,因为涉及到读读不互斥,写锁作为独占锁,需要在触发写的逻辑的时候,做到写写互斥。

那么读写会是怎样的呢,答案是互斥的,读锁获取锁,写锁也要获取锁,因为写锁是独占你的,所以这样就会造成死锁。

public class ReadWriteLockTest {

    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

    private static void methodReadLock() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + ":读锁获取成功");
            Thread.sleep(1000);
            //尝试获取写锁
            System.out.println(Thread.currentThread().getName() + ":写锁尝试获取");
            writeLock.lock();
            System.out.println(Thread.currentThread().getName() + ":写锁获取成功");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
            System.out.println("读锁释放");
        }
    }

    private static void methodWriteLock() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + ":写锁获取成功");

            System.out.println(Thread.currentThread().getName() + ":读锁尝试获取");
            readLock.lock();
            System.out.println(Thread.currentThread().getName() + ":读锁获取成功");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
            System.out.println("写锁释放");
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> methodReadLock(), "线程一");
        Thread thread2 = new Thread(() -> methodWriteLock(), "线程二");
        thread1.start();
        thread2.start();

    }
}

C1A40FDF-9182-4BE8-8F62-447C40F82373.png [image:A8B0FDD4-656B-4298-A29C-027C3DBA827F-42190-00003BFD17BFAD1A/6C98BB3D-C19C-4960-8053-050C10FD16EC.png]

6C98BB3D-C19C-4960-8053-050C10FD16EC.png 改下代码,线程一和线程二都获取读锁,最后都获取成功,做到了读读不互斥。

public class ReadWriteLockTest {

    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

    private static void methodReadLock() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + ":读锁获取成功");
            Thread.sleep(1000);
            //尝试获取写锁
//            System.out.println(Thread.currentThread().getName() + ":写锁尝试获取");
//            writeLock.lock();
//            System.out.println(Thread.currentThread().getName() + ":写锁获取成功");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName()+"读锁释放");
        }
    }

    private static void methodWriteLock() {
//        writeLock.lock();
        try {
//            System.out.println(Thread.currentThread().getName() + ":写锁获取成功");

            System.out.println(Thread.currentThread().getName() + ":读锁尝试获取");
            readLock.tryLock();
            System.out.println(Thread.currentThread().getName() + ":读锁获取成功");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
//            writeLock.unlock();
//            System.out.println("写锁释放");
            readLock.unlock();
            System.out.println(Thread.currentThread().getName()+"读锁释放");
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> methodReadLock(), "线程一");
        Thread thread2 = new Thread(() -> methodWriteLock(), "线程二");
        thread1.start();
        thread2.start();

    }
}


41E3C2A9-3D5F-454F-9886-ED9D5BB05C3F.png

存在问题

  1. 读写交替的场景需要注意,除读读不互斥,读写、写读、写写都是互斥的。
  2. 一些场景容易造成死锁。
  3. 获取读锁的速度会比写锁高,在多写的业务场景下不适合。

总结

通过对我们日常开发中使用的一些锁的总结,让我们很清楚的认识到,锁的优缺点,在开发过程中的某些业务场景,就可以避开这些出现的问题,减少我们出现bug的几率,当然在大部分微服务业务的开发场景下,因为是跨服务的,还是使用分布式锁比较多,比如redis、redission、ZooKeeper的分布式锁,这些锁都会在后面专门结合公司的分布式锁的使用场景,整理出来分享出来,如有什么地方说的有误,欢迎大家在评论区留言。