当前位置 > it书童 > java > 正文

不得不说的锁事

java it书童 2021-01-09 17:14:41 0赞 0踩 59阅读 0评论

内置锁的能力不足以满足需求

锁是一种工具,用于控制对共享资源的访问

Lock 和 synchronized,是最常见的锁,都可以达到线程安全的目的,但在使用和功能上有较大的不同

Lock 并不是用来替代 synchronized 的,而是当 synchronized 不适用时,才使用 Lock 来提供高级功能

Lock 接口最常见的实现类是 ReentrantLock

通常情况下,Lock 只允许一个线程来访问共享资源,不过有时候,一些特殊的实现也可允许并发访问,如 ReadWriteLock 中的 ReadLock

为什么 synchronized 不够用?

  1. 效率低:锁的释放情况少,试图获得锁时不能设定超时、不能中断一个正在试图获得锁的线程

  2. 不够灵活:加锁和释放的时机单一,每个锁仅有单一的条件(某个对象)

  3. 无法知道是否成功获取到锁

Lock 主要方法

lock

lock() 最普通的获取锁,如果锁已被其他线程获取,则进行等待。

Lock 锁不会像 synchronized 一样在异常时自动释放锁。基于此,必须在 finally 中释放锁,以保证发生异常时锁一定会被释放

package juc.lock.lock;

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

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): 拿不到锁会等待,超时就放弃

package juc.lock.lock;


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();

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

    @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 == 2) {
            for (int i = 0; i < 100; i++) {
                try {
                    if (lock2.tryLock(800, 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();
                }
            }
        }
    }

}

获取锁时被中断

package juc.lock.lock;

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

public class LockInterruptibly implements Runnable {

    private Lock lock = new ReentrantLock();

    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();
        }

        // 两个线程分别中断看下效果
        //thread0.interrupt();
        thread1.interrupt();
    }

    @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) {
            System.out.println(Thread.currentThread().getName() + "获得锁期间被中断了");
        }
    }
}

两个线程分别可能在获得锁或者睡眠期间被中断

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

锁的分类

锁的分类是从不同角度去看的,这些分类并不是互斥的,即多个类型可以并存:有可能一个锁,同时属于两种类型。如:ReentrantLock 既是互斥锁,又是可重入锁

悲观锁

乐观锁(非互斥同步锁)和悲观锁(互斥同步锁)

互斥同步锁的劣势:

  • 阻塞和唤醒带来的性能劣势

  • 可能陷入永久阻塞

  • 优先级反转,优先级低的线程拿到了锁,优先级高的只能等

悲观锁:如果我不锁住这个资源,别人就会来争抢,造成数据结果错误。所以在每次获取并修改数据时,会把数据锁住,让别人无法访问该数据

Java 中悲观锁的实现就是 synchronized 和 Lock 相关类

悲观锁的执行流程:

乐观锁

乐观锁:认为自己在处理操作时不会有其他线程来干扰,所以并不会锁住被操作对象。在更新数据时,去对比修改期间数据有没有被其他人改变过

如果没被改变,说明真的只有自己在操作,就正常去修改数据

如果数据和我一开始拿到的不一样,说明其他人在这段时间内改过数据,我就不能继续刚才的更新数据过程了,会选择高难度、报错、重试等策略

乐观锁的实现一般都是利用 CAS 算法来实现的

乐观锁的典型例子:原子类、并发容器

两者对比

开销对比:

悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响

相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多

使用场景:

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

  • 临界区有 IO 操作

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

  • 临界区竞争非常激烈

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

可重入锁和非可重入锁

package juc.lock.reentrantlock;

import java.util.concurrent.locks.ReentrantLock;

/**
 * 演示多线程预定电影院座位
 */
public class CinemaBookSeat {

    private static final 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(CinemaBookSeat::bookSeat).start();
        new Thread(CinemaBookSeat::bookSeat).start();
        new Thread(CinemaBookSeat::bookSeat).start();
        new Thread(CinemaBookSeat::bookSeat).start();
    }

}

什么是可重入: 可以重复获取同一把锁,不需要先释放

好处:

  • 避免死锁: 适用于一个线程需要多次获取同一把锁

  • 提升封装性: 不用频繁地释放与加锁

package juc.lock.reentrantlock;

import java.util.concurrent.locks.ReentrantLock;

public class GetHoldCount {

    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());
    }

}

package juc.lock.reentrantlock;

import java.util.concurrent.locks.ReentrantLock;

public class RecursionDemo {

    private static ReentrantLock lock = new ReentrantLock();

    private static void accessResource() {
        lock.lock();
        try {
            System.out.println("已经对资源进行了处理");
            // 递归处理,每次递归都再次加锁
            if (lock.getHoldCount() < 5) {
                System.out.println(lock.getHoldCount());
                accessResource();
                System.out.println(lock.getHoldCount());
            }
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        accessResource();
    }

}

公平锁与非公平锁

公平指的是按照线程请求的顺序来分配锁

非公平指的是不完全按照请求的顺序,在一定情况下,可以插队。并不提倡插队行为,在合适的时机才插队,而不是盲目插队

那么,什么是合适的时机呢?为什么要有非公平锁?

为了提高效率,避免唤醒带来的空档期

线程A释放锁,唤醒线程B,线程B唤醒需要时间开销,在这个空档期可以让正好处于唤醒状态的线程C占用

如果在创建 ReentrantLock 时,参数为 true, 就是公平锁

公平锁演示:

package juc.lock.reentrantlock;

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) throws InterruptedException {
        PrintQueue printQueue = new PrintQueue();
        int num = 3;
        Thread thread[] = new Thread[num];
        for (int i = 0; i < num; i++) {
            thread[i] = new Thread(new Job(printQueue));
        }

        for (int i = 0; i < num; i++) {
            thread[i].start();
            Thread.sleep(100);
        }
    }

}

class Job implements Runnable {

    private 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);

    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();
        }
    }
}

一个个有序排队

如果设置为非公平锁,会是怎样的情况?

private Lock queueLock = new ReentrantLock(false);

两者对比:

共享锁和排他锁

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

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

典型是读写锁 RenentrantReadWriteLock,有两把锁,读锁是共享锁,写锁是独享锁

读写锁的规则:

  • 多个线程只申请读锁,都可以申请到

  • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,会一直等待释放读锁

  • 如果有一个线程已经占用了写锁,其他申请读或写锁的线程,都会一直等待

总结:要么是一个多个线程同时有读锁,要么是一个线程有写锁

package juc.lock.readwrite;

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 (InterruptedException 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 (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + " 释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        // 读锁可以同时持有
        new Thread(CinemaReadWrite::read, "Thread1").start();
        new Thread(CinemaReadWrite::read, "Thread2").start();
        
        // 写锁只能挨个持有
        new Thread(CinemaReadWrite::write, "Thread3").start();
        new Thread(CinemaReadWrite::write, "Thread4").start();
    }

}

读锁和写锁的交互方式

非公平:假设线程2和线程4正在同时读取,线程3想要写入,拿不到锁,于是进入等待队列,线程5不在队列里,现在过来想要读取。此时有两种策略

策略1:线程5插队

读可以插队,效率高

容易造成饥饿,如果后面一直有读的线程,就一直排在线程3之前,那么线程3一直获取不到写锁

策略2:线程5排队

避免饥饿

策略的选择取决于具体锁的实现,ReentrantReadWriteLock 的实现是选择了策略2,不允许插队

读锁插队策略

公平锁:不允许插队

非公平锁:

  • 写锁可以随时插队,写锁的获取并不容易

  • 读锁仅在等待队列头结点不是想获取写锁的线程时可以插队

演示读锁插队:

package juc.lock.readwrite;

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class NonfairBargeDemo {

    private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

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

    private static void read() {
        System.out.println(Thread.currentThread().getName() + " 开始尝试获取读锁");
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 得到读锁, 正在读取");
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } finally {
            System.out.println(Thread.currentThread().getName() + " 释放读锁");
            readLock.unlock();
        }
    }

    private static void write() {
        System.out.println(Thread.currentThread().getName() + " 开始尝试获取写锁");
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 得到写锁, 正在写入");
            try {
                Thread.sleep(40);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } finally {
            System.out.println(Thread.currentThread().getName() + " 释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(NonfairBargeDemo::write, "Thread1").start();
        new Thread(NonfairBargeDemo::read, "Thread2").start();
        new Thread(NonfairBargeDemo::read, "Thread3").start();
        new Thread(NonfairBargeDemo::write, "Thread4").start();
        new Thread(NonfairBargeDemo::read, "Thread5").start();
        new Thread(new Runnable() {
            final int num = 1000;
            @Override
            public void run() {
                Thread[] thread = new Thread[num];
                for (int i = 0; i < num; i++) {
                    thread[i] = new Thread(NonfairBargeDemo::read, "子线程创建的 Thread " + i);
                }
                for (int i = 0; i < num; i++) {
                    thread[i].start();
                }
            }
        }).start();
    }

}

锁的升降级

支持锁的降级,不支持升级

场景:先写日志,再读日志。如果一直占用写锁,会影响其他线程的读取,降级为读锁,其他线程就可以共享读锁

writeLock.lock();
try {
    // 降级为读锁:在不释放写锁的情况下,获取读锁
    readLock.lock();
} finally {
    writeLock.unlick();
    readLock.unlock();
}

自旋锁和阻塞锁

阻塞和唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间

如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失

如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁

而为了让当前线程”稍等一下“,我们需要让当前线程自旋,如果在自旋完成后前面锁定同步资源的线程已经释放锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁

阻塞锁和自旋锁相反,阻塞锁如果遇到没拿到锁的情况,会直接把线程阻塞,直到被唤醒

自旋锁的缺点:

  • 如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源

  • 在自旋的过程中,一直消耗 cpu,虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的

java 1.5 版本及以上的并发框架 java.util.concurrent 的 atmoic 包下的类基本都是自旋锁的实现

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

自定义一个自旋锁

package juc.lock.spinlock;

import java.util.concurrent.atomic.AtomicReference;

public class SpinLock {

    private AtomicReference<Thread> sign = new AtomicReference<>();

    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();
    }

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

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

}

结果:

Thread-0 开始尝试获取自旋锁
Thread-1 开始尝试获取自旋锁
Thread-0 获取到了自旋锁
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
... 一直自旋
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 获取到了自旋锁
Thread-0 释放了自旋锁
Thread-1 释放了自旋锁

自旋锁的适用场景:

  • 一般用于多核的服务器,在并发度不是特别高的情况下,比阻塞锁的效率高

  • 适用于临界区比较短的情况

可中断锁

如果某一线程 A 正在执行锁中的代码,另一线程 B 正在等待获取该锁,由于等待时间过长,线程 B 不想等了,想先处理其他事,我们可以中断它,就是可中断锁

在 java 中,synchronized 是不可中断锁,Lock 是可中断锁

锁优化

JVM 对锁的优化:

自旋锁:自旋一定次数后,如果还不成功,会转为阻塞锁

锁消除:消除没必要的锁

开发时,如何优化锁:

  • 缩小同步代码块

  • 尽量不要锁住方法

  • 减少请求锁的次数

  • 锁中尽量不要再包含锁

关于我
一个文科出身的程序员,追求做个有趣的人,传播有价值的知识,微信公众号主要分享读书思考心得,不会有代码类文章,非程序员的同学请放心订阅
转载须注明出处:https://www.itshutong.com/articles/1018