【Beautiful JUC Part.4】不可不说的“锁”事

【Beautiful JUC Part.4】不可不说的“锁”事

  • Lock接口

  • 锁的分类

  • 乐观锁和悲观锁

  • 可重入锁和非可重入锁,以ReentrantLock为例(重点)

  • 公平锁和非公平锁

  • 共享锁和排它锁:以ReentrantReadWriteLock读写锁为例(重点)

  • 自旋锁和阻塞锁

  • 可中断锁:顾名思义,就是可以响应中断的锁

  • 锁优化

一、Lock接口

​ 锁是一种工具,用于控制对共享资源的访问。Lock和synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,打哪是在使用上和功能上又有较大的不同。Lock并不是用来代替synchronized的,而是当使用synchronized不合适或者不足以满足要求的时候,来提供高级功能的。

​ Lock接口最常见的实现类是ReentrantLock,通常情况下,Lock只允许一个线程来访问这个共享资源。不过有的时候,一些特殊的实现也可以允许并发访问,比如ReadWriteLock里面的ReadLock。

1、为什么synchronized不够用?

效率低:

  • 锁的释放情况少、试图获得锁时不能设定超时、不能中断一个正在视图获得锁的线程。
  • 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的。
  • 无法知道是否成功获取到锁

2、Lock主要方法介绍

①lock()

  • lock()就是最普通的获取锁。如果锁已被其他线程获取,则进行等待。
  • Lock不会像synchronized一样在异常时自动释放锁
  • 因此最佳实践是,在finally中释放锁,以保证发生异常时锁一定被释放。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 描述:Lock不会像synchronized一样,异常的时候自动释放锁,
 * 所以最佳实践是,finally中释放锁,以便保证发生异常的时候锁一定被释放
 */
public class MustUnlock {
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        lock.lock();
        try {
            //获取本锁保护的资源
            System.out.println(Thread.currentThread().getName() + "开始执行任务");
        }finally {
            lock.unlock();
        }
    }
}
  • lock()方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock()就会陷入永久等待。

②tryLock()

tryLock()用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,则返回true,否则返回false,代表获取锁失败。

  • 相比于lock,这样的方法显然功能更强大了,我们可以根据是否能获取到锁来决定后续程序的行为
  • 该方法会立即返回,即便在拿不到锁时不会一直在那等。

tryLock(long time, TimeUnit unit):超时就放弃

避免死锁
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 描述:用tryLock来避免死锁
 */
public class TryLockDeadLock implements Runnable{
    int flag = 1;
    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if (flag == 1) {
                try {
                    //可能会被中断
                    if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
                        try {
                            System.out.println("线程1获取到了锁1");
                            Thread.sleep(new Random().nextInt(1000));

                            if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
                                try {
                                    System.out.println("线程1获取到了锁2");
                                    System.out.println("线程1成功获取到了两把锁");
                                    break;
                                } finally {
                                    lock2.unlock();
                                }
                            }else {
                                System.out.println("线程1获取锁2失败,已重试");
                            }
                        }finally {
                            lock1.unlock();
                            Thread.sleep(new Random().nextInt(1000));
                        }
                    }else {
                        System.out.println("线程1获取锁1失败,已重试");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            if (flag == 0) {
                try {
                    //可能会被中断
                    if (lock2.tryLock(3000, TimeUnit.MILLISECONDS)) {
                        try {
                            System.out.println("线程2获取到了锁2");
                            Thread.sleep(new Random().nextInt(1000));

                            if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
                                try {
                                    System.out.println("线程2获取到了锁1");
                                    System.out.println("线程2成功获取到了两把锁");
                                    break;
                                } finally {
                                    lock1.unlock();
                                }
                            }else {
                                System.out.println("线程2获取锁1失败,已重试");
                            }
                        }finally {
                            lock2.unlock();
                            Thread.sleep(new Random().nextInt(1000));
                        }
                    }else {
                        System.out.println("线程2获取锁2失败,已重试");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        TryLockDeadLock r1 = new TryLockDeadLock();
        TryLockDeadLock r2 = new TryLockDeadLock();
        r1.flag = 1;
        r2.flag = 0;
        new Thread(r1).start();
        new Thread(r2).start();
    }
}

image-20220208125011343

③lockInterruptibly()

相当于tryLock(long time, TimeUnit unit)把超时时间设置为无限。在等待锁的过程中,线程可以被中断。

lock 优先考虑获取锁,待获取锁成功后,才响应中断。
lockInterruptibly 优先考虑响应中断,而不是响应锁的普通获取或重入获取。

ReentrantLock.lockInterruptibly允许在等待时由其它线程调用等待线程的Thread.interrupt方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个InterruptedException。 ReentrantLock.lock方法不允许Thread.interrupt中断,即使检测到Thread.isInterrupted,一样会继续尝试获取锁,失败则继续休眠。只是在最后获取锁成功后再把当前线程置为interrupted状态,然后再中断线程。

线程等锁的时候打断
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockInterruptibly implements Runnable{
    private Lock lock = new ReentrantLock();
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "尝试获取锁");
        try {
            lock.lockInterruptibly();
            try {
                System.out.println(Thread.currentThread().getName() + "获取到了锁");
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "睡眠期间被中断了");
            } finally {
                lock.unlock();
                System.out.println(Thread.currentThread().getName() + "释放了锁");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
            System.out.println(Thread.currentThread().getName() + "获得锁期间被中断了");
        }
    }

    public static void main(String[] args) {
        LockInterruptibly lockInterruptibly = new LockInterruptibly();
        Thread thread0 = new Thread(lockInterruptibly);
        Thread thread1 = new Thread(lockInterruptibly);
        thread0.start();
        thread1.start();

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread1.interrupt();
    }
}

3、可见性保证

Lock的加解锁和synchronized有同样的内存语义,也就是说,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作。(happens-before原则)

二、锁的分类

image-20220208132724661

这些分类并不是互斥的,也就是多个类型可以并存:有可能一个锁,同时属于两种类型

比如ReentrantLock既是互斥锁,又是可重入锁

1、乐观锁和悲观锁

乐观锁——非互斥同步锁

悲观锁——互斥同步锁

思考:为什么会诞生非互斥同步锁?

  • 互斥同步锁的劣势
    • 阻塞和唤醒带来的性能劣势
    • 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲催的线程,将永远也得不到执行。
    • 优先级反转
      • 如果优先级低的线程获取到了锁,即便有优先级高的线程来了,也得等待锁释放。

什么是乐观锁和悲观锁?

  • 悲观锁:

    • 如果我不锁住这个资源,别人就回来争抢,就会造成数据结果错误,所以每次悲观锁为了确保结果的正确性,会在每次获取并修改数据时,把数据锁住,让别人无法访问该数据,这样就可以确保数据内容万无一失。
    • 悲观锁的实现就是synchronized和Lock相关类
  • 乐观锁:

    • 认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作对象。
    • 在更新的时候,去对比在我修改的期间数据有没有被其他人改变过,如果没有被改变过,就说明真的是只有我自己在操作,那我就正常去修改数据。
    • 如果数据和我一开始拿到的不一样了,说明其他人在这段时间内改过数据,那我就不能继续刚才的更新数据过程了,我会选择放弃、报错、重试等策略。
    • 乐观锁的实现一般都是利用CAS算法来实现的
    • 典型例子就是原子类、并发容器等

使用场景

悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况:

  • 临界区有IO操作

  • 临界区代码复杂或者循环量大

  • 临界区竞争非常激烈

乐观锁:适合并发写入少,大部分是读取的场景,不加锁能让读取性能大幅提高

2、可重入锁和非可重入锁

以ReentrantLock为例

使用案例:预定电影院座位

import java.util.concurrent.locks.ReentrantLock;

/**
 * 描述:演示多线程预定电影院座位
 */
public class CinemaBookSeat {
    private static ReentrantLock lock = new ReentrantLock();
    private static void bookSeat() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "开始预定座位");
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "完成预定座位");
        } catch (InterruptedException e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(() -> bookSeat()).start();
        new Thread(() -> bookSeat()).start();
        new Thread(() -> bookSeat()).start();
        new Thread(() -> bookSeat()).start();
    }
}

预定座位我们需要一个结束再一个,要不然座位都重复了。

使用案例:打印完整字符串

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

/**
 * 描述:     演示ReentrantLock的基本用法,演示被打断
 */
public class LockDemo {

    public static void main(String[] args) {
        new LockDemo().init();
    }

    private void init() {
        final Outputer outputer = new Outputer();
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        Thread.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    outputer.output("悟空");
                }

            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        Thread.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    outputer.output("大师兄");
                }

            }
        }).start();
    }

    static class Outputer {

        Lock lock = new ReentrantLock();

        //字符串打印方法,一个个字符的打印
        public void output(String name) {

            int len = name.length();
            lock.lock();
            try {
                for (int i = 0; i < len; i++) {
                    System.out.print(name.charAt(i));
                }
                System.out.println("");
            } finally {
                lock.unlock();
            }
        }
    }
}

打印完整的字符串,需要每个线程加锁,然后释放锁,确保整个字符串都被打印出来。

可重入性质

​ 也叫作递归锁,假设线程再次去申请这个锁的时候,无需提前释放掉这把锁,而是可以直接继续使用手里的这把锁,这就是可重入的意思。比如说汽车摇号,许多人排队摇号,如果轮到一个人拿ipad摇号,摇完号这个人还想给自己其他的车也摇号,这是不行的,必须要重新的排队。这就是不可重入的意思。

好处:避免死锁,提升代码封装性

获取锁次数演示
import java.util.concurrent.locks.ReentrantLock;

public class GetHoldlCount {
    private static ReentrantLock lock = new ReentrantLock();
    public static void main(String[] args) {
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.lock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
        lock.unlock();
        System.out.println(lock.getHoldCount());
    }
}
可重入锁源码分析

image-20220208161425062

image-20220208161522937

非可重入所源码分析

image-20220208161554927

可重入所方法介绍

image-20220208161642518

3、公平锁和非公平锁

什么是公平和非公平

公平指的是按照线程请求的顺序,来分配锁;非公平指的是,不完全按照请求的顺序,在一定情况下,可以插队。

注意:非公平也同样不提倡“插队”行为,这里的非公平,指的是“在合适的时机”插队,而不是盲目插队。

那么,什么是合适的时机呢?

比如,线程A持有锁,线程B等待,线程C在运行准备获取锁。当线程A释放锁的时候,线程B的唤醒需要时间,而线程C可以直接运行,这样效率就变高了,直接让C去获取锁,插个队。(唤醒带来的空档期还是比较大的)

ReentrantLock举例

ReentrantLock默认是非公平锁,但是在创建对象时候,参数填写为true,那么这就是个公平锁。

公平锁1

公平锁2

公平锁就是按照先来后到的顺序分配锁。

非公平锁

非公平锁就是插队

代码演示

场景:打印文件,一个线程打印两份文件,采用公平和非公平的方式

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

/**
 * 描述:演示公平和不公平两种情况
 */
public class FairLock {

    public static void main(String[] args) {
        PrintQueue printQueue = new PrintQueue();
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(new Job(printQueue));
        }
        for (int i = 0; i < 10; i++) {
            threads[i].start();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
class  Job implements Runnable{
    PrintQueue printQueue;

    public Job(PrintQueue printQueue) {
        this.printQueue = printQueue;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "开始打印");
        printQueue.printJob(new Object());
        System.out.println(Thread.currentThread().getName() + "打印完毕");
    }
}
class PrintQueue {
    //private Lock queueLock = new ReentrantLock(true);
    private Lock queueLock = new ReentrantLock(false);

    public void printJob(Object document) {
        queueLock.lock();
        try {
            int duration = new Random().nextInt(10) + 1;
            System.out.println(Thread.currentThread().getName() + "正在打印文件一,需要" + duration );
            Thread.sleep(duration * 1000);
        } catch (InterruptedException e){
            e.printStackTrace();
        }finally {
            queueLock.unlock();
        }

        queueLock.lock();
        try {
            int duration = new Random().nextInt(10) + 1;
            System.out.println(Thread.currentThread().getName() + "正在打印文件二,需要" + duration );
            Thread.sleep(duration * 1000);
        } catch (InterruptedException e){
            e.printStackTrace();
        }finally {
            queueLock.unlock();
        }
    }
}

如上所示,非公平的情况是,线程1打印完以后,再次打印,因为不需要唤醒,而公平的情况是,线程0打印完一次,然后线程1打印,然后线程2打印。

特例

image-20220208164840605

tryLock()会插队

公平和非公平的优缺点

image-20220208164935057

源码分析

image-20220208165019566

4、共享锁和排它锁

什么是共享锁和排它锁

排他锁,又称为独占锁、独享锁

共享锁,又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据

共享锁和排它锁的典型是读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁。

读写锁的作用

​ 在没有读写锁之前,我们假设使用ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题。

​ 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率。

规则:

  • 多个线程只申请读锁,都可以申请到
  • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁
  • 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁
  • 总结:要么是一个或者多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现(要么多读,要么一写)

读写锁具体用法

电影院买票的案例

image-20220208183543803

案例升级,升级为读写锁

image-20220208183604399

线程1和2都可以读取,到了写入的时候

image-20220208183636063

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class CinemaReadWrite {
    private static ReentrantReadWriteLock  reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放读锁");
            readLock.unlock();
        }
    }

    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(() -> read(), "Thread1").start();
        new Thread(() -> read(), "Thread2").start();
        new Thread(() -> write(), "Thread3").start();
        new Thread(() -> write(), "Thread4").start();
    }
}

读锁插队策略

公平锁:不允许插队

非公平锁:写锁可以随时插队,读锁仅在等待队列头结点不是想获取写锁的线程的时候可以插队

image-20220208185017188

策略一:

image-20220208185131162

策略二:

image-20220208185205526

image-20220208185344832

避免饥饿

锁的升降级

写锁的级别高,读锁的级别低

支持锁的降级,不支持锁的升级,如果升级会造成阻塞

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * 描述:     演示ReentrantReadWriteLock可以降级,不能升级
 */
public class Upgrading {

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

    private static void readUpgrading() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
            Thread.sleep(1000);
            System.out.println("升级会带来阻塞");
            writeLock.lock();
            System.out.println(Thread.currentThread().getName() + "获取到了写锁,升级成功");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放读锁");
            readLock.unlock();
        }
    }

    private static void writeDowngrading() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
            Thread.sleep(1000);
            readLock.lock();
            System.out.println("在不释放写锁的情况下,直接获取读锁,成功降级");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
//        System.out.println("先演示降级是可以的");
//        Thread thread1 = new Thread(() -> writeDowngrading(), "Thread1");
//        thread1.start();
//        thread1.join();
//        System.out.println("------------------");
//        System.out.println("演示升级是不行的");
        Thread thread2 = new Thread(() -> readUpgrading(), "Thread2");
        thread2.start();
    }
}

为什么不只是锁的升级?

如果两个读锁线程想要升级为写锁,就必须要求其他读锁线程已经释放,那么两个读锁线程互相等待对方释放,就会陷入死锁。

5、自旋锁和阻塞锁

什么是自旋锁和阻塞锁

image-20220208195949379

image-20220208200025522

原理和源码分析

image-20220208200221865

image-20220208200337765

自己实现一个简单的自旋锁

import java.util.concurrent.atomic.AtomicReference;

public class SpinLock {
    private AtomicReference<Thread> sign = new AtomicReference<>();

    public void lock() {
        Thread current = Thread.currentThread();
        while (!sign.compareAndSet(null, current)) {
            System.out.println(Thread.currentThread().getName() + "自旋获取失败,再次尝试");
        }
    }

    public void unlock() {
        Thread current = Thread.currentThread();
        sign.compareAndSet(current, null);
    }

    public static void main(String[] args) {
        SpinLock spinLock = new SpinLock();
        Runnable runnable = new Runnable(){
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
                spinLock.lock();
                System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    spinLock.unlock();
                    System.out.println(Thread.currentThread().getName() + "释放了锁");
                }
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}

image-20220208201227991

6、可中断锁

image-20220208201342752

三、锁优化

1、自旋锁和自适应锁

JVM不会让傻子自旋锁一直自旋下去,如果时间过长,就转为阻塞锁。

2、锁消除

虚拟机会分辨代码是否是同步的或者私有的,从而消除多余的锁

3、锁粗化

把相邻的可以合并的synchronized代码块合并为一个,避免频繁的加锁释放锁

4、日常使用技巧

  • 缩小同步代码块
  • 尽量不要锁住方法
  • 减少请求锁的次数
  • 避免人为制造热点
    • 比如hashmap,每次调用size()去获得容量,都会遍历,我们可以人为的设置一个数量count,每次增加或者删除元素,就在count上操作,直接获取就行。
  • 锁中尽量不要再包含锁
  • 选择合适的锁类型或者合适的工具类
posted @ 2022-02-08 20:20  DarkerG  阅读(53)  评论(0编辑  收藏  举报