Java并发工具之锁分类

1. Lock简介、地位、作用

  • 锁是一种工具,用于控制对共享资源的访问;
  • Lock和synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同;
  • Lock并不是用来代替synchronized的,而是当使用synchronized不合适或者不满足要求的时候,来提供高级功能的;
  • Lock接口中最常见的实现类是ReentrantLock;
  • 通常情况下,Lock只允许一个线程来访问这个共享资源,不过有的时候,一些特殊的实现也可允许并发访问,比如ReadWriteLock里面的ReadLock;

2. 为什么synchronized不够用?

  • 锁的释放情况少:1、 代码执行完毕;2、发生异常;一旦发生阻塞,其他线程只能干等;
  • 不够灵活:加锁和释放锁的时机单一;
  • 无法知道是否成功获取到了锁;

3. Lock主要方法介绍

  • 在Lock中声明了四个方法来获取锁:

     1. lock():最普通的锁,Lock不会像synchronized一样在异常时自动释放锁,必须自己try/finally;
     2. tryLock():尝试获取锁,如果当前锁没有被其他线程占用,则获取成功;
     3. tryLock(long time, TimeUnit unit):超时就放弃;
     4. lockInterruptibly():相当于tryLock(long time, TimeUnit unit)把超时时间设置为无限,在等待锁的过程中,线程可以被中断;
    
  • 用lock演示死锁:

public class LockDeadLock {

    private static Lock lockA = new ReentrantLock();
    private static Lock lockB = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                lockA.lock();
                try {
                    System.out.println("获取到lockA");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("正在尝试获取lockB...");
                    lockB.lock();
                    try {
                        System.out.println("获取到lockB");
                    } finally {
                        lockB.unlock();
                    }
                } finally {
                    lockA.unlock();
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                lockB.lock();
                try {
                    System.out.println("获取到lockB");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("正在尝试获取lockA...");
                    lockA.lock();
                    try {
                        System.out.println("获取到lockA");
                    } finally {
                        lockB.unlock();
                    }
                } finally {
                    lockB.unlock();
                }
            }
        });
        t1.start();
        t2.start();
    }
}
  • 用tryLock避免死锁
public class TryLockDeadLock {

    private static Lock lockA = new ReentrantLock();
    private static Lock lockB = new ReentrantLock();

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    if (lockA.tryLock(800, TimeUnit.MILLISECONDS)) {
                        try {
                            System.out.println("获取到lockA");
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            System.out.println("正在尝试获取lockB...");
                            try {
                                if (lockB.tryLock(800, TimeUnit.MILLISECONDS)) {
                                    try {
                                        System.out.println("获取到lockB");
                                    } finally {
                                        lockB.unlock();
                                    }
                                } else {
                                    System.out.println("没有获取到lockB");
                                }
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        } finally {
                            lockA.unlock();
                        }

                    } else {
                        System.out.println("没有获取到lockA");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    if (lockB.tryLock(800, TimeUnit.MILLISECONDS)) {
                        try {
                            System.out.println("获取到lockB");
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            System.out.println("正在尝试获取lockA...");
                            try {
                                if (lockA.tryLock(800, TimeUnit.MILLISECONDS)) {
                                    try {
                                        System.out.println("获取到lockA");
                                    } finally {
                                        lockA.unlock();
                                    }
                                } else {
                                    System.out.println("没有获取到lockA");
                                }
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        } finally {
                            lockB.unlock();
                        }
                    } else {
                        System.out.println("没有获取到lockB");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();
        t2.start();
    }
}
获取到lockA
获取到lockB
正在尝试获取lockB...
正在尝试获取lockA...
没有获取到lockB
没有获取到lockA
  • 用lockInterruptibly演示等待锁的过程中响应中断:
public class LockInterruptibly implements Runnable {

    private static Lock lockA = new ReentrantLock();

    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName() + "正在尝试获取lockA...");
            lockA.lockInterruptibly();
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "在休眠中被中断了");
            } finally {
                lockA.unlock();
            }
        } catch (InterruptedException e) {
            System.out.println(Thread.currentThread().getName() + "在尝试获取锁时被中断了");
        } 
    }

    public static void main(String[] args) throws InterruptedException {
        LockInterruptibly lockInterruptibly = new LockInterruptibly();
        Thread t1 = new Thread(lockInterruptibly);
        Thread t2 = new Thread(lockInterruptibly);
        t1.start();
        Thread.sleep(500);
        t2.start();
        Thread.sleep(2000);
        t2.interrupt();
    }
}
Thread-0正在尝试获取lockA...
Thread-1正在尝试获取lockA...
Thread-1在尝试获取锁时被中断了

4. 锁的分类

  • 线程要不要锁住(互斥同步)同步资源:
    1. 锁住:悲观锁,不能让其他线程在我操作的时候去操作这个对象
    2. 不锁住:乐观锁
  • 多线程能否共享一把锁:
    1. 共享:共享锁,比如:读锁
    2. 不共享:独占锁,比如:写锁
  • 多线程竞争时,是否排队:
    1. 排队:公平锁
    2. 先尝试插队,插队失败再排队:非公平锁
  • 同一个线程是否可以重复获取同一把锁:
    1. 可以:可重入锁
    2. 不可以:不可重入锁
  • 是否可中断:
    1. 可以:可中断锁
    2. 不可以:不可中断锁
  • 等锁的过程:
    1. 自旋:自旋锁,自旋:不停的尝试,而不是进入阻塞,比如:原子类;
    2. 阻塞:非自旋锁

5. 乐观锁和悲观锁

悲观锁(互斥同步锁):如果不锁住这个资源,别人就来争抢,会造成数据结果错误,为了保证结果的正确性,会在每次修改数据时把数据锁住,让别人无法访问该数据,确保万无一失。Java中悲观锁的实现是synchronized和Lock。

  1. 锁住之后就是独占的,其他线程想获得资源必须等待,有阻塞和唤醒带来的性能劣势;
  2. 可能永久阻塞:如果持有锁的线程永久阻塞了,比如遇到了死锁等问题;
  3. 优先级反转:一旦优先级低的线程不释放,即便优先级高的线程也拿不到锁;

乐观锁(非互斥同步锁):认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作对象,在更新的时候,去对比在我修改期间数据有没有被其他人改变过:如果没被改变过,那就说明真的是只有我自己在操作,拿我就正常去修改数据;如果数据改变了,拿我就不继续更新了,我会选择放弃、报错、重试等策略。乐观锁的实现一般都是利用CAS算法实现的。Java中乐观锁的实现就是原子类、并发容器等。

乐观锁和悲观锁的对比:

  1. 悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸;
  2. 乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多;

各自的使用场景:

  1. 悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,比如:有IO操作、代码复杂、抢占锁竞争激烈;
  2. 乐观锁:适合并发写入少,大部分是读取的场景,不加锁能让读取性能大幅提高;

6. 可重入锁和非可重入锁,以ReentrantLock为例

public class Recursion {

    private ReentrantLock lock = new ReentrantLock();

    public void go() {
        lock.lock();
        try {
            if (lock.getHoldCount() < 5) {
                System.out.println("第" + lock.getHoldCount() + "次获取到锁");
                go();
            }
        } finally {
            System.out.println("已释放锁");
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        new Recursion().go();
    }
}
1次获取到锁
第2次获取到锁
第3次获取到锁
第4次获取到锁
已释放锁
已释放锁
已释放锁
已释放锁
已释放锁

可以看出,ReentrantLock具有可重入的性质,等方法执行完毕统一释放锁。

  • 可重入的好处是:避免死锁,提高封装性;

性质如下:
在这里插入图片描述

7. 公平锁和非公平锁

  • 公平指的是按照线程请求的顺序来分配锁;
  • 非公平是指不完全按照请求的顺序,在一定情况下,可以插队,使得线程总体执行更快,吞吐量更大,但是有可能产生线程饥饿,也就是某些线程在长时间内始终得不到执行;
  • 为什么要有非公平锁:由于唤醒的开销比较大,避免唤醒带来的空档期,提高效率;
  • 公平的情况:ReentrantLock本身是非公平锁,填写参数为true会改为公平锁;
    在这里插入图片描述

8. 共享锁和排它锁(独占锁)

  • 排它锁:比如synchronized
  • 共享锁:又称为读锁,获取共享锁后,只能查看但是无法修改和删除数据,其他线程也可以获取共享锁;
  • Java中的实现是ReentrantReadWriteLock,其中读锁是共享锁,写锁是独占锁
  • 在没有读写锁之前,我们假设只用ReentrantLock,那么虽然保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题。
  • 在读的地方只用读锁,在写的地方只用写锁。
  • 读写锁规则:要么是一个或多个线程同时有读锁,要么就是一个线程有写锁,读和写不同时出现,要么是读锁定,要么是写锁定。
  • ReentrantReadWriteLock的具体用法:
public class ReadWriteLock {

    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 (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("释放了读锁");
            readLock.unlock();
        }
    }
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入...");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("释放了写锁");
            writeLock.unlock();
        }
    }

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

}
Thread-0得到了读锁,正在读取...
Thread-1得到了读锁,正在读取...
释放了读锁
释放了读锁
Thread-2得到了写锁,正在写入...
释放了写锁
Thread-3得到了写锁,正在写入...
释放了写锁

读写锁采用的策略:

  • 公平锁:不允许插队;
  • 非公平锁(默认):写锁可以随时插队,因为写锁不容易获取到锁;读锁仅仅在等待队列头结点不是写锁的时候可以插队;

9. 锁的升降级

  • 为什么需要升降级:比如读写过程中,已经没有写操作了,此时不需要写锁,但是线程又不想释放写锁,那么就可以将写锁降级成读锁。
  • 读写锁支持降级不支持升级,因为降级可以提高效率,降级成读锁不会修改数据;
public class UpDownLock {

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

    private static void updateLock() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "获取到读锁");
            System.out.println(Thread.currentThread().getName() + "正在升级成写锁...");
            writeLock.lock();
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放了读锁");
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放了写锁");
        }
    }

    private static void downLock() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "获取到写锁");
            readLock.lock();
            System.out.println(Thread.currentThread().getName() + "降级成读锁成功");
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放了读锁");
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "释放了写锁");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> downLock());
        t1.start();
        Thread.sleep(1000);
        System.out.println("-----------------------------");
        Thread t2 = new Thread(() -> updateLock());
        t2.start();
    }
}
Thread-0获取到写锁
Thread-0降级成读锁成功
Thread-0释放了读锁
Thread-0释放了写锁
-----------------------------
Thread-1获取到读锁
Thread-1正在升级成写锁...

因为读写锁,只能多读或一写,如果其中一个线程想要升级成写锁,那么其他线程必须放弃读锁,如果所有的读线程都想升级成写锁,那么就必须都得相互等待对方释放读锁,而两者都想升级就都不释放读锁,这就陷入了死锁。

总结:

  • 锁申请和释放策略:要么多读,要么一写;
  • 插队策略:写锁可以插队,读锁仅仅在等待队列头结点不是写锁的时候可以插队;
  • 升降级策略:只能降级,提高效率;不能升级,会导致死锁;
  • 使用场景:适合读多写少情况,可以提高并发效率;

10. 可中断锁

  • synchronized不是可中断锁;
  • Lock是可中断锁,因为tryLock(设置超时时间)和lockInterruptibly(等待获取锁期间)都能响应中断;

11. 自旋锁和阻塞锁

  • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间;
  • 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长
  • 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失;
  • 如果物理机有多个处理器,能够让两个以上的线程同时并行执行,我们就可以让后的那个请求锁的线程不放弃CPU的执行时间(不阻塞),看看持有锁的线程是否很快就会释放锁;
  • 而为了让当前线程等待一下,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。
  • 阻塞锁和自旋锁相反,阻塞所如果没有拿到锁,会直接把线程阻塞,直到被唤醒;

自旋锁的缺点:

  • 如果锁被占用时间过长,那么自旋的线程只会白白浪费处理器资源;
  • 在自旋的过程中,一直消耗CPU,所以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的;

原子类是自旋锁实现的,AtomicInteger的实现:自旋锁的实现原理是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直到修改成功

  • 自己实现一个简单的自旋锁:
public class SpinLock implements Runnable {
    private AtomicReference<Thread> sign = new AtomicReference<>();

    @Override
    public void run() {
        lock();
        System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        unlock();

    }

    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);
        System.out.println(current.getName() + "释放了自旋锁");
    }

    public static void main(String[] args) {
        SpinLock spinLock = new SpinLock();
        Thread t1 = new Thread(spinLock);
        Thread t2 = new Thread(spinLock);
        t1.start();
        t2.start();
    }
}
Thread-0获取到了自旋锁
Thread-0释放了自旋锁
Thread-1获取到了自旋锁
Thread-1释放了自旋锁

自旋锁的使用场景:

  • 使用于并发度不高的情况;
  • 适合临界区比较小的情况;

12. 锁优化

  • 自旋锁和自适应:提高效率,尝试自旋的时候,如果尝试不到就转为阻塞锁;
  • 锁消除:有一些场景下不必要加锁,JVM会分析出来直接消除了;
  • 锁粗化:消除了加锁解锁的过程,把前后相邻的synchronized代码块合并一起;

13. 写代码时候如何优化锁和提高并发性能

  1. 缩小同步代码块;
  2. 尽量不要锁住方法;
  3. 减少请求锁的次数;
  4. 锁中尽量不要包含锁,不要嵌套锁;
  5. 选择合适的锁的类型以及合适的工具类;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章