程序员:我终于知道Java这些“锁”事了

586 阅读10分钟

作者:千珏

邮箱:wl625363199@gmail.com

前言

每次面试的时候总是有面试官会甩出致命三连 高并发、高可用、高性能

在这里插入图片描述
我们又称其为程序员三高,今天千珏本珏讲的就是三高中的高并发中的“锁”事。

首先我们要知道java中要有哪些锁,下面这张图千珏认为还是能很清楚的说明java锁之间的区别的(图片来自于网络,如果有侵权,请通过邮箱联系我删除)

在这里插入图片描述

下面千珏就带你来一一过下java中的“锁”事

悲观锁和乐观锁

悲观锁的概念:总是假设最坏的情况,每次拿数据都认为别人会修改数据,所以要加锁,别人只能等待,直到我释放锁才能拿到锁;数据库的行锁、表锁、读锁、写锁都是这种方式。java中的synchronized和Lock的实现类也是悲观锁的思想。

乐观锁的概念:总是假设最好的情况,每次拿数据都认为别人不会修改数据,所以不会加锁,但是更新的时候,会判断在此期间有没有人修改过;一般基于版本号机制实现。java中的乐观锁最常见的是CAS算法。

在这里插入图片描述

根据上面的概念我们可以简单得知乐观锁和悲观锁的应用场景

  1. 乐观锁适用于读多写少的情况,因为不加锁直接读可以让系统的性能大幅度的提高 。
  2. 悲观锁适用于写多读少的情况,因为等待到锁被释放后,可以立即获得锁进行操作。

直接说概念有可能会有点懵,我们来看下java中的调用方式

//悲观锁用synchronized实现
public synchronized  void test(){//执行相应的操作}
//悲观锁用Lock实现
Lock lock = new ReentrantLock();
public void testLock(){   
    lock.lock();
    //TODO 执行相应的操作
    lock.unlock();
}
//乐观锁
AtomicInteger atomicInteger = new AtomicInteger();
public void testCAS(){
  atomicInteger.incrementAndGet();
}

看到以上的调用方式我们可以看出来悲观锁都是直接加锁来保证资源的同步,这时候很多朋友就会问了为什么乐观锁没加锁也能实现资源同步呢,是呀,为什么呢,且看千珏的分析。

在这里插入图片描述

为什么乐观锁没加锁也能实现资源同步呢?

我们开头就说了因为乐观锁最主要的实现方式是CAS算法。

CAS就是Compare and Swap,即比较再交换,jdk5中增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

就是这个CAS可以让我们用无锁的方式实现“锁”,CAS虽然很强,但是也存在着几个问题

  1. ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

    从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

    关于ABA问题参考文档: blog.hesey.net/2011/09/res…

  2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

  3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。

从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

自旋锁和适应性自旋锁

自旋锁:为了避免线程在获取同步资源时,线程的频繁挂起和恢复,可以让原本需要等待的线程一直循环的获得锁,这就是自旋锁。

适应性自旋锁:自适应自旋锁的自适应反映在自旋的时间不在固定了。如果在同一个锁对象上,自旋线程之前刚刚获得过锁,且现在持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能会成功,进而允许该线程等待持续相对更长的时间,比如100个循环。反之,如果某个锁自旋很少获得过成功,那么之后再获取锁的时候将可能省略掉自旋过程,以避免浪费处理器资源。

以上概念来源于网络。

在这里插入图片描述

自旋锁的缺点:自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。JDK6中默认开启自旋锁。

自旋锁实现的原理同样也是CAS, 上面也说了乐观锁的实现原理是CAS可以达到无锁的方式来上锁,自旋锁呢 就是要自旋加个无限循环直到他的值改变成功。

自旋锁中有三种常见的锁形式:TicketLock、CLHlock和MCSlock.(如果有想要了解的朋友留言给我,我单独开篇单章)

无锁和偏向锁和轻量级锁和重量级锁

这四种锁实际上是锁的四种状态,这个时候我相信肯定又要有读者问了锁的状态是什么,锁是存在哪里的呢。

在这里插入图片描述

别急别急,跟着千珏走,offer拿到手抽筋。

锁是存在哪里的呢?

锁存在Java对象头中的Mark Word。Mark Word默认不仅存放锁标志位,还存放对象hashCode等信息。运行时,会根据锁的状态 ,修改Mark Work的存储内容。如果对象是数组类型,则虚拟机用3个字宽存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit.关于对象头等相关知识,可以参考Java虚拟机相关文章。

Mark Word里面有关于锁的内容

存储内容 标志位 状态
对象hashcode、对象分代年龄、是否是偏向锁(0) 01 无锁
指向锁记录的指针 00 轻量级锁
指向重量级锁的指针 10 重量级锁
偏向线程ID、偏向时间戳、对象分代年龄,是否是偏向锁(1) 01 偏向锁

无锁:就是不上锁,不对资源进行锁定,使得所有的线程都能访问资源,但是同时只有一个资源 能修改成功。

偏向锁:线程在大多数情况下并不存在竞争条件,使用同步会消耗性能,而偏向锁是对锁的优化,可以消除同步,提升性能。当一个线程获得锁,会将对象头的锁标志位设为01,进入偏向模式.偏向锁可以在让一个线程一直持有锁,在其他线程需要竞争锁的时候,再释放锁。

轻量级锁:当线程1获得偏向锁后,线程2进入竞争状态,需要获得线程1持有的锁,那么偏向锁就会升级为轻量级锁,其他 线程会通过自旋的形式尝试获取锁。

重量级锁:当自旋超过一定的次数,或者一个线程在持有锁,一个线程在自旋,又有第三个来访时,轻量级锁升级为重量级锁,此时等待锁的线程都会进入阻塞状态。

整体锁状态升级流程为:偏向锁 ----> 轻量级锁 ----> 重量级锁

公平锁和非公平锁

公平锁:就是每个线程都能拿到锁。

非公平锁:不能保证每个线程都能拿到锁。

有语言描述看的话有点懵,举个例子。

公平锁就是你去食堂打饭的时候如果老老实实排队打饭的话就是公平锁。

非公平锁就是你去食堂打饭的时候可以不用排队,前面一个人如果打好饭了,你可以直接打饭,不用管还有多少人没打饭。这样的话就是非公平锁。

在这里插入图片描述

java当中的公平锁,非公平锁实现

//公平锁
ReentrantLock lock = new ReentrantLock(true);
//非公平锁
ReentrantLock lock = new ReentrantLock(false);

具体原理就不探讨了,如果想要知道为什么这样实现的,可以留言给我。

适用场景就是:线程占用时间要长于线程切换时间的还是用公平锁好一些,反之用非公平锁好一些。

可重入锁和非可重入锁

可重入锁就是可重复调用的锁,在外面方法使用锁之后,在里面依然可以使用锁,并且不发生死锁(前提是同一个对象或者class),这样的锁就叫做可重入锁。synchronized和ReentrantLock都是可重入锁。

看着概念你有可能 有点懵,但是看实现就觉得这玩意很简单了。

public class Test implements Runnable{
    public static void main(String []args){
        Test test = new Test();
        for(int i = 0; i < 5; i++){
            new Thread(test).start();
        }
    }
    @Override
    public void run() {
        out();
    }

    public synchronized void out(){
        System.out.println(Thread.currentThread().getName());
        in();
    }

    public synchronized void in(){
        System.out.println(Thread.currentThread().getName());
    }
}

//输出结果如下,可以看出线程是没有阻塞的

Thread-2 Thread-2 Thread-4 Thread-4 Thread-3 Thread-3 Thread-1 Thread-1 Thread-0 Thread-0

不可重入锁就是不可重复调用的锁,在外面方法使用锁之后,在里面就不能使用锁了,这个时候锁会阻塞直到你外面的锁释放后才会获得里面的锁。会产生死锁这种情况 。

不可重入锁实现如下:

public class NoLock {
    private boolean isLocked = false;
    public synchronized  void lock() throws InterruptedException {
        while (isLocked){
            wait();
        }
        isLocked = true;
    }
    public synchronized void unlock(){
        isLocked = false;
        notify();
    }
}

独享锁和共享锁

独享锁:该锁每一次只能被一个线程所持有,synchronized以及ReentrantLock都是独享锁

共享锁:该锁可被多个线程共有。获得共享锁的线程只能读数据,不能修改数据。

ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。读锁是共享锁,写锁是独享锁

总结

java"锁"事到此就结束了,有很多原理方面的东西,千珏没有深入的介绍,一方面由于是本人水平不太够,一方面由于篇幅的问题,如果读者看完对此篇文章有什么疑问的地方都可以留言告诉我。

最后,求求大家看到这篇文章觉得写的还行的,麻烦麻烦你们的小手点个关注吧,点个赞吧,你们的赞和关注是千珏写作的动力。

2019完结,希望2020能更好吧,祝大家元旦快乐。