Java并发编程中各种锁的分析。

原文链接:https://www.leahy.club/archives/java%E5%9F%BA%E7%A1%80java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B%E4%B8%AD%E7%9A%84%E5%90%84%E7%A7%8D%E9%94%81

  1. 公平锁 VS 非公平锁

    公平锁:获取不到锁的时候,会自动加入队列,等待线程释放后,队列的第一个线程获取锁。

    非公平锁:获取不到锁的时候,会自动加入队列,等待线程释放锁后所有等待的线程同时去竞争。

    ReentrantLock默认是非公平锁,但是其还有有参的构造器可以将参数设置为true,就是公平锁了。

    public ReentrantLock() {
    	sync = new NonfairSync();
    }
    public ReentrantLock(boolean fair) {
    	sync = fair ? new FairSync() : new NonfairSync();
    }
    

    Synchronized是非公平锁。

  2. 可重入锁 VS 不可重入锁

    可重入锁指的是可重复可递归调用的锁,拿到外层的锁之后,也就拿到了内部的锁。可重入不是可重复利用的意思,而是可以递归进入的意思。

    不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁,同一个线程两次调用lock方法 ,如果不去unlock的话,第二次lock会产生死锁。

    综上,可重入锁并不是可以多次使用的锁,而是可重入可递归!举个例子:

    public class UnReentrant{
     Lock lock = new Lock();
     public void outer(){
         lock.lock();
         inner();
         lock.unlock();
     }
     public void inner(){
         lock.lock();
         //do something
         lock.unlock();
     }
    }
    

    outer中调用了inner,outer先锁住了lock,这样inner就不能再获取lock。其实调用outer的线程已经获取了lock锁,但是不能在inner中重复利用已经获取的锁资源,这种锁即称之为 不可重入 。相对来说,可重入就意味着:线程可以进入任何一个它已经拥有的锁所同步着的代码块。

    不可重入锁的基本原理:

    当isLocked被设置为true后,在线程调用unlock()解锁之前不管线程是否已经获得锁,都只能wait()。
    代码如下:

    public class Lock{  
     private boolean isLocked = false;  
     public synchronized void lock()  
         throws InterruptedException{  
         while(isLocked){  
             wait();  
         }  
         isLocked = true;  
     }  
    
     public synchronized void unlock(){  
         isLocked = false;  
         notify();  
     }  
    }  
    

    可重入锁的基本原理

    修改Lock,加入一个变量lockBy用来保存已经获得锁的线程,这样就能对有锁的线程放行。代码如下:

    public class Lock{
     boolean isLocked = false;
     Thread  lockedBy = null;
     int lockedCount = 0;
     public synchronized void lock() throws InterruptedException{
         Thread callingThread = Thread.currentThread();
         while(isLocked && lockedBy != callingThread){
             wait();
         }
         isLocked = true;
         lockedCount++;
         lockedBy = callingThread;
     }
     public synchronized void unlock(){
         if(Thread.curentThread() == this.lockedBy){
             lockedCount--;
             if(lockedCount == 0){
                 isLocked = false;
                 notify();
             }
         }
     }
    }
    

    具体实现:Synchronized和ReentrantLock都是可重入锁。

    public class Demo{
     public static void main(String[] args) {
         Phone phone = new Phone();
         
         new Thread(() -> {
             phone.sms();
         }, "A").start();
         
         new Thread(() -> {
             phone.sms();
         }, "B").start();
     }
    }
    
    class Phone {
     public synchronized void sms() {
         System.out.println(Thread.currentThread().getName() + "sms");
         call();
     }
     
     public synchronized void call() {
         System.out.println(Thread.currentThread().getName() + "call");
     }
    }
    

    不可重入锁:使用自己写的自旋锁(自旋锁是不可重入锁)来实现;但是也可以将自旋锁改为可重入锁,详见代码。

    UnReentryLock.java

    import java.util.concurrent.atomic.AtomicReference;
    
    public class UnReentryLock {
    
     private AtomicReference<Thread> owner = new AtomicReference<>();
     private int state = 0;
    
    
     public void lock() {
         Thread current = Thread.currentThread();
    
         //自旋锁
         while (!owner.compareAndSet(null, current));
    
     }
    
     public void unlock() {
         Thread current = Thread.currentThread();
         owner.compareAndSet(current, null);
     }
    
     //可重入的lock
     public void relock() {
         Thread current = Thread.currentThread();
         if(current == owner.get()) {
             state++;
             return;
         }
    
         //自旋锁,owner的初始值为null,故先进来的thread获得锁
         while (!owner.compareAndSet(null, current));
    
     }
    
     //可重入的unlock
     public void reunlock() {
         Thread current = Thread.currentThread();
    
         //在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state计数,不用每次去释放锁。
         if (current == owner.get()) {
             if (state != 0)
                 state--;
             else
                 owner.compareAndSet(current, null);
         }
     }
    
    }
    
    

    Test

    public class Test{
     public static void main(String[] args) {
         Phone3 phone = new Phone3();
    
         new Thread(() -> {
             phone.sms();
         }, "A").start();
    
         new Thread(() -> {
             phone.sms();
         }, "B").start();
     }
    }
    
    class Phone {
    
     private UnReentryLock lock = new UnReentryLock();
    
     public void sms() {
    
         lock.lock();
    
         System.out.println(Thread.currentThread().getName() + "sms");
         call();
    
         lock.unlock();
     }
    
     public synchronized void call() {
    
         lock.lock();
    
         System.out.println(Thread.currentThread().getName() + "call");
    
         lock.unlock();
     }
    }
    
    class Phone2 {
    
     public synchronized void sms() {
         System.out.println(Thread.currentThread().getName() + "sms");
         call();
     }
    
     public synchronized void call() {
         System.out.println(Thread.currentThread().getName() + "call");
     }
    }
    
    
    class Phone3 {
    
     private UnReentryLock lock = new UnReentryLock();
    
     public void sms() {
    
         lock.relock();
    
         System.out.println(Thread.currentThread().getName() + "sms");
         call();
    
         lock.reunlock();
     }
    
     public synchronized void call() {
    
         lock.relock();
    
         System.out.println(Thread.currentThread().getName() + "call");
    
         lock.reunlock();
     }
    }
    

    根据结果可知,Phone和Phone3均调用了可重入锁,Phone2调用的是不可重入锁。

  3. 自旋锁 VS 互斥锁

    自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

    自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

    自旋锁存在的问题

    1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
    2. 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

    自旋锁的优点

    1. 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
    2. 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

    自旋锁的其他变种

    TicketLock主要解决的是公平性的问题。

    思路:每当有线程获取锁的时候,就给该线程分配一个递增的id,我们称之为排队号,同时,锁对应一个服务号,每当有线程释放锁,服务号就会递增,此时如果服务号与某个线程排队号一致,那么该线程就获得锁,由于排队号是递增的,所以就保证了最先请求获取锁的线程可以最先获取到锁,就实现了公平性。

    可以想象成银行办理业务排队,排队的每一个顾客都代表一个需要请求锁的线程,而银行服务窗口表示锁,每当有窗口服务完成就把自己的服务号加一,此时在排队的所有顾客中,只有自己的排队号与服务号一致的才可以得到服务。

    public class TicketLock {
        /** * 服务号 */
        private AtomicInteger serviceNum = new AtomicInteger();
        /** * 排队号 */
        private AtomicInteger ticketNum = new AtomicInteger();
        /** * 新增一个ThreadLocal,用于存储每个线程的排队号 */
        private ThreadLocal<Integer> ticketNumHolder = new ThreadLocal<Integer>();
        public void lock() {
            int currentTicketNum = ticketNum.incrementAndGet();
            // 获取锁的时候,将当前线程的排队号保存起来
            ticketNumHolder.set(currentTicketNum);
            while (currentTicketNum != serviceNum.get()) {
                // Do nothing
            }
        }
        public void unlock() {
            // 释放锁,从ThreadLocal中获取当前线程的排队号
            Integer currentTickNum = ticketNumHolder.get();
            serviceNum.compareAndSet(currentTickNum, currentTickNum + 1);
        }
    }
    

    TicketLock存在的问题:

    多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量serviceNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。

    修改自:https://zhuanlan.zhihu.com/p/40729293

  4. 乐观锁 VS 悲观锁

    乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中 java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

    悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中 synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

    使用场景

    从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

    详见:https://mp.weixin.qq.com/s?__biz=Mzg2OTA0Njk0OA==&mid=2247484911&idx=1&sn=1d53616437f50b353e33edad6fda2e4f&source=41#wechat_redirect