面试官:既然有了synchronized,为什么还需要Lock?

296 阅读7分钟

本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~

多年以前 JDK 主流版本还是1.5的时候,这个问题还是非常好回答的,因为那时通过 synchronized 关键字加锁是一个重量级操作,可能加锁操作的时间比执行业务代码逻辑的时间还要长。

但到了 JDK 1.6 版本以后,JVM 团队对 synchronized 做了很多优化,包括:锁消除、锁粗化、自适应自旋、轻量级锁、偏向锁等,两者的性能差距已经相差无几了,也不需要手动释放锁,且官方也表示优先使用 synchronized 关键字。

那问题来了,既然在 Java 中已经有了synchronized,那为什么还需要 Lock 呢?或者换句话说,Lock 还存在哪些 synchronized 不具备的特性呢?

image.png

本文我们就从几个方面来讲一讲。

非阻塞获取锁

我们都知道,如果 A 线程试图通过 synchronized 获取锁来执行对应的代码逻辑时,若此时该锁已经被 B 线程获取到,则 A 线程只能进入阻塞状态,等待 B 线程将代码执行完释放锁。

也就是说,如果 B 线程执行十分钟才释放锁,那 A 线程只能在那眼巴巴地被阻塞十分钟,别无选择。

这本来就是不合理的,举个生活中的例子,我去健身房发现靠窗的跑步机被人占了,那我还不能去找个其他闲置的跑步机,只能眼巴巴地等着这个人跑完吗?

如下图所示:

image.png

而这个 synchronized 无解的场景下,Lock 却提供了对应的解决方案,而且一提供就是三种。

(1)boolean tryLock();

这是一种比较潇洒的做法,通过该方法尝试获取锁,返回值 true false代表成功或失败,该方法不会阻塞等待。

代码实例如下:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
 
public class LockExample {
    private final ReentrantLock lock = new ReentrantLock();
 
    public void execute() {
        // 尝试获取锁一次
        if (lock.tryLock()) {
            try {
                // 在此处执行获取锁后的业务代码逻辑
                System.out.println("Lock acquired, work performed here.");
            } catch (Exception e) {
                // 处理异常
                e.printStackTrace();
            } finally {
                // 确保释放锁
                lock.unlock();
            }
        } else {
            // 在此处执行没有获取锁的业务代码逻辑
            System.out.println("Could not acquire lock, work performed here.");
        }
    }
 
    public static void main(String[] args) {
        LockExample example = new LockExample();
        example.execute();
    }
}

(2)boolean tryLock(long time, TimeUnit unit);

这是一种比较睿智的做法,通过该方法在一段时间内尝试获取锁,获取成功返回 true,超时未获取锁则返回 false。

代码实例如下:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
 
public class LockExample {
    private final ReentrantLock lock = new ReentrantLock();
 
    public void execute() {
        // 尝试获取锁,最多等待100毫秒
        if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
            try {
                // 在此处执行获取锁后的业务代码逻辑
                System.out.println("Lock acquired, work performed here.");
            } catch (Exception e) {
                // 处理异常
                e.printStackTrace();
            } finally {
                // 确保释放锁
                lock.unlock();
            }
        } else {
            // 在此处执行没有获取锁的业务代码逻辑
            System.out.println("Could not acquire lock, work performed here.");
        }
    }
 
    public static void main(String[] args) {
        LockExample example = new LockExample();
        example.execute();
    }
}

(3)void lockInterruptibly();

这是一种比较听人劝的做法,当使用该方法获取锁并处于阻塞状态下,是可以响应中断的。

代码实例如下:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;


public class TestLockInterruptibly {

    final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        TestLockInterruptibly testLockInterruptibly = new TestLockInterruptibly();
        Thread lockThread = new Thread(
                () -> testLockInterruptibly.lock()
        );
        Thread interruptiblyThread = new Thread(
                () -> testLockInterruptibly.lockInterruptibly()
        );
        lockThread.start();
        interruptiblyThread.start();

        TimeUnit.SECONDS.sleep(2);
        interruptiblyThread.interrupt();
    }

    public void lockInterruptibly() {
        try {
            TimeUnit.SECONDS.sleep(1);
            lock.lockInterruptibly();
            System.out.println("通过lockInterruptibly方法获取锁");
        } catch (InterruptedException e) {
            System.out.println("捕捉InterruptedException异常");
        }finally {
            lock.unlock();
            System.out.println("l通过lockInterruptibly方法释放锁");
        }
    }

    public void lock() {
        try {
            lock.lock();
            System.out.println("通过lock方法获取锁");
            TimeUnit.SECONDS.sleep(5);
        }catch (InterruptedException e) {
            System.out.println("捕捉InterruptedException异常");
        }finally {
            lock.unlock();
            System.out.println("通过lock方法释放锁");
        }
    }

}

输出日志为:

通过lock方法获取锁
捕捉InterruptedException异常
Exception in thread "Thread-1" java.lang.IllegalMonitorStateException
  at java.base/java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:175)
  at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1059)
  at java.base/java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:494)
  at TestLockInterruptibly.lockInterruptibly(TestLockInterruptibly.java:33)
  at TestLockInterruptibly.lambda$main$1(TestLockInterruptibly.java:16)
  at java.base/java.lang.Thread.run(Thread.java:1570)
通过lock方法释放锁

由此可见,与 synchronized 的获取锁不成只能傻傻阻塞等待不同,Lock 则具备了在该场景下的非阻塞方式,其中包括尝试获取一次、尝试一段时间内获取和获取时响应中断。

公平锁

我们都知道 synchronized 是一种非公平锁,而 Lock 则同时支持公平锁和公平锁两种模式。

公平锁需要维护一个等待队列(AQS),且其休眠和唤醒操作需要涉及到操作系统用户态和内核态的切换,因此其吞吐量比非公平锁低很多。

公平锁实现方式,如下图:

image.png

非公平锁实现方式,如下图:

image.png

但公平锁会严格按照请求的顺序来分配锁并执行代码逻辑,这样可以防止某些线程的长时间锁等待,避免了线程饥饿的问题,这点在一些高并发场景非常重要。

代码实例如下:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class FairLockExample {
    private static ReentrantLock fairLock = new ReentrantLock(true);

    public static void main(String[] args) {
        Runnable fairTask = new FairTask();
        Thread thread1 = new Thread(fairTask, "Thread-1");
        Thread thread2 = new Thread(fairTask, "Thread-2");
        Thread thread3 = new Thread(fairTask, "Thread-3");
        thread1.start();
        thread2.start();
        thread3.start();
    }

    static class FairTask implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    fairLock.lock();
                    System.out.println(Thread.currentThread().getName() + " 获得锁");
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    System.out.println(Thread.currentThread().getName() + " 释放锁");
                    fairLock.unlock();
                }
            }
        }
    }
}

输出日志为:

Thread-1 获得锁
Thread-1 释放锁
Thread-2 获得锁
Thread-2 释放锁
Thread-3 获得锁
Thread-3 释放锁
Thread-1 获得锁
Thread-1 释放锁
Thread-2 获得锁
Thread-2 释放锁
Thread-3 获得锁
Thread-3 释放锁

读写锁

读写锁(ReentrantReadWriteLock )也是 Lock 的一种实现方式,其允许多个读线程同时访问,但仅允许一个写线程访问,当写线程访问时会阻塞所有的读写线程,跟MySQL 数据库中的共享锁和独占锁的策略异曲同工。

这种设计方式在读操作远多于写操作的时候,可能获得吞吐量的大幅提升。

代码实例如下:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private Lock readLock = lock.readLock();
    private Lock writeLock = lock.writeLock();


    public void readData() {
        try {
            readLock.lock();
            System.out.println(Thread.currentThread().getName() + " 获得读锁");
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + " 释放读锁");
            readLock.unlock();
        }
    }

    public void writeData() {
        try {
            writeLock.lock();
            System.out.println(Thread.currentThread().getName() + " 获得写锁");
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + " 释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        ReadWriteLockExample example = new ReadWriteLockExample();

        // 多个读操作可以同时进行
        for (int i = 0; i < 10; i++) {
            new Thread(() -> example.readData()).start();
        }

        // 写操作将阻塞所有的读写操作
        new Thread(() -> example.writeData()).start();
    }
}

输出日志为:

Thread-2 获得读锁
Thread-7 获得读锁
Thread-9 获得读锁
Thread-1 获得读锁
Thread-0 获得读锁
Thread-3 获得读锁
Thread-6 获得读锁
Thread-5 获得读锁
Thread-4 获得读锁
Thread-8 获得读锁
Thread-4 释放读锁
Thread-0 释放读锁
Thread-2 释放读锁
Thread-3 释放读锁
Thread-1 释放读锁
Thread-5 释放读锁
Thread-8 释放读锁
Thread-7 释放读锁
Thread-6 释放读锁
Thread-9 释放读锁
Thread-10 获得写锁
Thread-10 释放写锁

Condition

与 Lock 配合使用的 Condition 类,它的 await()和signal()、signalAll() 方法的功能,与 synchronized 中的wait()、notify()、notifyAll() 差不多,但在一个Lock对象里可以创建多个Condition,从而可以有选择的进行线程通知,在线程调度上更加灵活。

代码实例如下:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionExample {

    private ReentrantLock lock = new ReentrantLock();
    public Condition conditionA = lock.newCondition();
    public Condition conditionB = lock.newCondition();

    public void awaitA() {
        try {
            lock.lock();
            System.out.println(Thread.currentThread().getName() + "进入了awaitA方法");
            conditionA.await();
            System.out.println(Thread.currentThread().getName()+"被唤醒");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void awaitB() {
        try {
            lock.lock();
            System.out.println(Thread.currentThread().getName() + "进入了awaitB方法");
            conditionB.await();
            System.out.println(Thread.currentThread().getName()+"被唤醒");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void signallA() {
        try {
            lock.lock();
            System.out.println("执行唤醒程序A的操作");
            conditionA.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public void signallB() {
        try {
            lock.lock();
            System.out.println("执行唤醒程序A的操作");
            conditionB.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        ConditionExample condition = new ConditionExample();

        new Thread(() -> condition.awaitA()).start();
        new Thread(() -> condition.awaitB()).start();

        TimeUnit.SECONDS.sleep(1);

        condition.signallA();
        condition.signallB();
    }
}

输出日志为:

Thread-0进入了awaitA方法
Thread-1进入了awaitB方法
执行唤醒程序A的操作
执行唤醒程序A的操作
Thread-0被唤醒
Thread-1被唤醒

所以,我对“既然有了 synchronized,为什么还需要 Lock”这个问题的回答是五个字 —— 存在即合理。