搞懂多线程(四)之《锁》

一、乐观锁和悲观锁

乐观锁:认为自己在使用数据时不会有别的线程修改数据或资源,所以不会添加锁。

  • 在Java中是通过使用无锁编程来实现,只是在更新数据的时候去判断,之前有没有别的线程更新了这个数据。

    • 如果这个数据没有被更新,当前线程将自己修改的数据成功写入

    • 如果这个数据已经被其它线程更新,则根据不同的实现方式执行不同的操作,比如放弃修改、重试抢锁等等

  • 实现方法

    • 可以通过版本号机制Version字段

    • 最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

  • 适合读操作较多场景,有利于提高查询效率

悲观锁:认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。

  • synchronized关键字和Lock的实现类都是悲观锁

  • 一般适用于写操作过程中,较为重

二、synchronized锁

是基于进入退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter指令,在结束位置插入monitor exit指令(具体见后面锁的原理章节)。

当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monitor的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。

1、对象锁和类锁

  • synchronized修饰普通方法(对象锁):

    • 锁的是当前对象(new出来的对象,类的实例化对象),即this,等同于synchronized(this)

    • 只要是new出来的同一个对象,该对象中synchronized修饰的普通方法持有的都是同一把锁

  • synchronized修饰静态方法(类锁):

    • 锁的是当前class对象(类的初始化对象),

    • 所有new出来的对象,都是持有的同一把锁

  • synchronized修饰同步代码块:

    • 锁的是synchronized(Object)括号中的对象,所以使用时应确保括号中对象的唯一性

    • 底层使用monitorentermonitorexit实现

    • 由于synchronized可以自动释放锁,所以一般情况下一个monitorenter对应两个monitorexit,最后一个防止出现异常,锁未释放,如果在同步代码块中手动抛出异常(中间没有条件判断),则只会有一个monitorexit

2、synchronized锁的自动升级

JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。

可通过对象的对象头中的对象标记(MarkWord)的锁标记位来查看锁的类型 JVM之对象内存布局

优缺点对比

适用场景

偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。

轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似),存在兗争时升级为量级锁,轻显级锁采用的是自旋锁,如果同步方法/代码块扒行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。

重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。

三、公平锁于非公平锁

公平锁:是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票先来的人先买后来的人在队尾排着,这是公平的

  • Lock lock = new ReentrantLock(true);//true表示公平锁,先来先得

非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能台申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁)

  • Lock lock = new ReentrantLock(false);//false表示非公平锁,后来的也可能先获得锁

  • Lock lock = new ReentrantLock();//默认非公平锁

  • 为什么默认为非公平锁

    • 恢复挂起的线程到真正锁的获取还是有时微,但是从CPU的角度来看,这个时间差存在的还是很明显的。间差的,从开发人员来看这个时间微乎其片,尽量减少 CPU 空闲状态时间。所以非公平锁能更充分的利用CPU 的时,

    • 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请听以刚释放锁的线程在此求锁获取同步状态,然后释放同步状态,刻再次获取同步状态的概率就变得非常大所以就减少了线程的开销。

四、可重入锁(递归锁)

可重入锁:是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(锁的对象是同一个),不会因为之前已经获取过还没释放而阻塞。

例如:一个方法有三层锁,每一层的锁的管程都是同一个,外部获取锁之后,内部的锁不会因为外层没有释放而阻塞。现实生活中的例子,进去大门锁之后,不用释放大门锁就可以访问卫生间锁,不能说要出去大门,才能获取卫生间的锁。

Java中ReentrantLocksynchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

可重入原理

每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。

当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有, Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。

在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。

当执行monitorexit时, Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。

五、偏向锁(JAVA15之后已废弃)

在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。

那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接会去检查锁的MarkWord里面是不是放的自己的线程ID

  • 如果相等,表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程 ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

  • 如果不等,表示与其他线程发生了竞争,锁己经不是偏向于自己的线程了,这个时候会尝试使用CAS来替换MarkWord里面的线程ID为自己的线程的ID,

    • 如果替换成功,表示之前的线程不存在了,MarkWord里面的线程ID替换为自己的线程ID,锁不会升级,仍然为偏向锁。

    • 如果替换失败,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

六、自旋锁(SpinLock)

CAS是实现自旋锁的基础,CAS利用CPU指令保证了操作的原子性,以达到锁的效果

自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU

简单来说,利用一个无限循环,执行一个CAS操作,当操作成功返回true时,循环结束;当返回false时,接着执行循环,继续尝试CAS操作,直到返回true。

七、读写锁

在使用synchronizedLock的情况下,同一时刻一个资源只允许一个线程进行读或写

我们期望,一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程,既读读共存读写/写写互斥

1、ReentrantReadWriteLock

读锁和写锁主要通过内部类ReadLockWriteLock来实现的

ReentrantReadWriteLock.ReadLock

ReentrantReadWriteLock.WriteLock

优缺点

  • 只有在读多写少情景之下,读写锁才具有较高的性能体现。

  • 存在锁的饥饿问题,读线程没有完成时,其他线程的就没办法写

  • 存在锁降级:同一个线程持有了写锁,在没有释放写锁的情况下,它还可以继续获得读锁(可重入锁),那么就会一直持续拥有这把锁,但是会降级为读锁,如果在写入之后释放了写锁,之后再次获取的读锁,可能存在其他线程已经修改了数据,当前的线程是无法感知的,再次读取就会读取到错误数据

  • 遵循获取写锁->再获取读锁->再释放写锁的次序,写锁能够降级成为读锁

  • 当读锁被使用时,如果有线程尝试获取写锁,该写线程会被阻塞。所以,需要释放所有读锁,才可获取写锁。

2、StampedLock

  • writeLock()unlockWrite(long)ReentrantReadWriteLock.ReadLock一致

  • 读: readLock()unlockRead(long)ReentrantReadWriteLock.WriteLock一致

  • 乐观的读写: 通过tryOptimisticRead()返回邮戳,使用 validate​(long stamp)验证邮戳是被修改,如果修改业务自行处理

七、锁消除与锁粗话

1、锁消除

如果每次调用该方法都会重新指定一个锁,也是就是说synchronized(o)的这个o每次都会创建一个新的,从JIT角度看相当于无视它,因为这个锁对象并没有被共用护散到其它线程使用

2、锁粗话

假如方法中首居相接,前后相领的都是同一个锁对象,那么JIT译器就会把这几个synchronized合并成一个大块, 加粗加大范围,一次中请锁使用即可,避免每次申请和释放锁,提升了性能

示例代码

public static void main(String[] args) {

    Object o = new Object();
    //锁粗话之前
    new Thread(() -> {
        synchronized (o) {
            System.out.println("1111");
        }
        synchronized (o) {
            System.out.println("2222");
        }
        synchronized (o) {
            System.out.println("3333");
        }
    }, "AA").start();
    
    //锁粗话之后
    new Thread(() -> {
        synchronized (o) {
            System.out.println("1111");
            System.out.println("2222");
            System.out.println("3333");
    }}, "AA").start();
}
}

八、死锁

死锁:是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象

若无外力干涉那它们都将无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。

死锁示例代码

public static void main(String[] args) {

    Object object1 = new Object();
    Object object2 = new Object();

    new Thread(() -> {
        synchronized (object1) {
            System.out.println(Thread.currentThread().getName() + "\t 持有object1希望获得object2");

            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            synchronized (object2){
                System.out.println(Thread.currentThread().getName() + "\t 获得了object2");
            }

        }
    }, "A").start();


    new Thread(() -> {
        synchronized (object2) {
            System.out.println(Thread.currentThread().getName() + "\t 持有object2希望获得object1");

            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            synchronized (object1){
                System.out.println(Thread.currentThread().getName() + "\t 获得了object1");
            }

        }
    }, "B").start();
}

死锁排查

  • 命令行

    • jps -l :查看当前运行的进程信息; jstack:进程编号

  • 图形化界面

    • jconsole

九、锁的原理

锁的底层是ObjectMonitor.hpp实现的,主要参数有以下

锁的原理说明