Java多线程第八篇--聊聊Java的锁

485 阅读13分钟

在上一篇中,我们仔细看了Java的线程安全相关的概念,知道了如何才叫线程安全,安全的等级如何划分,以及在平时的编程中,我们可以通过哪些方法可以保证线程安全。

在本篇中,我们将聊聊线程安全的一大利器,。我们将会从概念,比较分析,和部分源码分析来看看在java中锁的各种定义以及实现。

先上图,在Java中我们会有哪些锁可以供选择,按照不同的分类,简单总结如下图: image.png

宏观分类

悲观锁VS乐观锁

  • 悲观锁总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁,当其他线程想要访问数据时,都需要阻塞挂起。
  • 乐观锁总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS操作实现。 按照我的理解,悲观锁就是不管你同步资源目前到底是什么情况,第一步就是尝试加锁,没人使用,则加锁成功,有人使用,加锁失败则等待;加锁成功的在业务处理完成或者处理业务发生异常了,将锁释放,并通知其他人进行尝试加锁。在Java中典型的悲观锁有很多,关键字synchronized,并发包Lock的实现类等。

乐观锁就是在获取数据后,就尝试更新数据,如果同步资源没有被其他线程更新,则更新成功,如果被其他人线程更新,则采取报错或者重试机制。具体策略方案根据具体业务而定。典型的乐观锁,就是之前讲过的CASCompare And Swap,并非是单点登录的那个cas概念哈~),在Java中很多的实现就是利用了CAS的理论算法,比如java.util.concurrent.atomic下很多的实现类等。如下就是实现类AtomicInteger的自增方法的部分源码实现:底层就是比较寄存器中的A和 内存中的值 V,如果相等,就把要写入的新值 B 存入内存中。 image.png 当然,在上面的理解中我们可以看出,CAS可能存在两个问题:

  • 一个就是我们之前说过的ABA问题
  • 还有一个就是性能问题,循环操作是很浪费CPU的资源的(一般CPU 100%,十之八九死循环或者类似死循环。。。当然也有可能一直间断性的进行某些大型的计算,在消耗CPU;或者就是中毒了;或者就是硬件本身出问题了。。。)

自旋锁

其实在大部分的CAS的实现中,基本都是通过自旋来完成的,所谓的自旋锁其实就是,在我们还没达到我们想要的条件时(比如获取同步资源,即锁),为了不进行切换当前的线程,让当前的线程等一下,进行一定的自我循环等待操作,直至达到条件(获取到了同步资源/锁)。 如下所示:都是自旋的一些操作 image.png 当然在其他的锁的一些实现里,有很多自旋锁的体现,比如AQS的一些底层实现,JDK8中并发容器ConcurrentHasMap的底层实现等。

自适应自旋锁

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

公平锁VS非公平锁(是否需要排队)

获取锁的公平与否,说到底就是在当多个线程来尝试(申请)加锁时,是否按照申请的顺序来获取锁资源来定的。

公平锁就是多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的头部head线程才能获得锁。而非公平锁就是线程来尝试获取锁,如果当前正好有人释放锁,他则可以直接插队获取到锁资源无需排队,如果尝试获取不到,则再进行排队等待。从实现的效果来讲,明显可以看到非公平锁减少了线程的唤醒操作,从而整体的吞吐率比较高,但也有相对应的缺点,就是可能在队列里等待获取资源的线程可能会永久等不到资源(饿死),或者很久才会获取到锁。其实从这些优缺点的字面意思也可以看出所谓的公平和非公平到底是什么意思了。

在Java中具体的实现类也有很多,比如我们常用的lock实现类,可重入锁ReentrantLock,在new一个ReentrantLock的时候,构造函数就可以知道了,你将要使用的是公平锁,还是非公平锁,如下源码展示:无参构造函数默认就是非公平锁的实现;有参构造函数,由人可控制到底是公平还是非公平。 image.png 其实在具体的公平还是非公平,我们比较下如下两段尝试获取同步资源的源代码就可以知道了:请看源码里我的注释内容哈~

首先是非公平锁的实现

static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        /**
         * Performs lock.  Try immediate barge, backing up to normal
         * acquire on failure.
         */
        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }
        //非公平锁尝试加锁的方法
        protected final boolean tryAcquire(int acquires) {
            //此处调的方法是Sync的nonfairTryAcquire
            return nonfairTryAcquire(acquires);
        }
    }
    
   // Sync的nonfairTryAcquire的实现如下
   final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
            //此处就是非公平 可以插队获取锁资源的地方了,在当前线程发现无人占用同步资源
            //(c=0,就是同步资源没人占用),他就通过CAS的方法尝试获取锁资源,成功的话,
            //就直接占用锁资源,将当前占用锁资源的线程更新为当前线程,并返回。
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

再来看公平锁的实现

static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        final void lock() {
            acquire(1);
        }

        /**
         * Fair version of tryAcquire.  Don't grant access unless
         * recursive call or no waiters or is first.
         */
         //此处就是公平锁尝试加锁的实现
        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                //我们从这可以看到,在C=0的时候,当前线程需要先调用hasQueuedPredecessors方法,
                //而这个方法是干嘛的呢?这个方法的实现是在同步器AQS
               //(AbstractQueuedSynchronizer这是一个灰常牛逼的类)里的一个方法
               //这个方法简单来讲,就是看当前有没有线程在排队等待获取锁资源
               //(准确的说这个方法是用来判断当前线程需不需要排队)
               //如果判断当前有线程在排队,则直接跳出判断返回false,返回false后,
               //又会进入AQS的另一个方法acquire,从中可以看到,!tryAcquire(arg)则会返回true,
               //就会进入acquireQueued(addWaiter(Node.EXCLUSIVE), arg)这个方法,
               //而这个方法就是把当前线程加入到同步队列进行排队的方法了,
               //这里就不展开了,具体在后面的ReentrantLock篇幅中会说的。
               /**
                public final void acquire(int arg) {
                            if (!tryAcquire(arg) &&
                                acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
                                    selfInterrupt();
                }
               */
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

可重入锁VS不可重入锁(可不可以重入)

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。 举个例子代码:

class Demo{
    public synchronized void A(){
        System.out.printf("A function");
        B();
    }
    
    public synchronized void B(){
        System.out.printf("B function");
    }
}

在上面的例子中,A和B两个内置的方法都是用关键字synchronized修饰的,在A中调用了B方法,由于synchronized是具备可重入性的,所以同一个线程在调用B方法的时候,可以再次直接获取到当前对象的锁,从而进入B方法。

如果synchronized不具备可重入性,那么当前线程在调用B的时候,就会等A释放掉锁,才可以进入,而A中调用了B,所以A就一直不会释放,这样就造成了典型的死锁场景。。。

独占锁VS共享锁

独占锁也叫排他锁,是指同一时间该锁只能被一个线程持有,其他线程这时就不能再获取到锁了;共享锁则是指该锁可以被多个线程所持有。 在Java中,典型的独占锁有ReentrantLock,共享锁有倒计数器CountDownLatch和信号量Semaphore等。

其实举一个比较粗俗的例子就是,独占锁就是一个卫生间只有一个坑,说明这个卫生间同时只能允许一个人在里面方便;共享锁就是这个卫生间里有多个坑,可以容纳多个人一起方便。。。

至于源码的展示本篇就不展示了,在后面介绍AQS的时候都会有的。

synchronized关键字

最后我们来看看,synchronized关键字,先来看看synchronized的基本用法吧

用法

同步普通方法

image.png

这个也是我们用得最多的,只要涉及线程安全,上来就给方法来个同步锁。这种方法使用虽然最简单,但是只能作用在单例上面,如果不是单例,同步方法锁将失效。 此时,同一个实例只有一个线程能获取锁进入这个方法。

同步静态方法

image.png

同步静态方法,不管你有多少个类实例,同时只有一个线程能获取锁进入这个方法。 同步静态方法是类级别的锁,一旦任何一个线程进入这个方法,其他所有线程将无法访问这个类的任何同步类锁的方法。

同步类(修饰代码块)

image.png

锁住效果和同步静态方法一样,都是类级别的锁,同时只有一个线程能访问带有同步类锁的方法 这里的两种用法是同步块的用法,这里表示只有获取到这个类锁才能进入这个代码块。

同步this(修饰代码块)

image.png

这也是同步块的用法,表示锁住整个当前对象实例,只有获取到这个实例的锁才能进入这个方法。 用法和同步普通方法锁一样,都是锁住整个当前实例

同步对象(修饰代码块)

image.png

这也是同步块的用法,和上面的锁住当前实例一样,这里表示锁住整个 LOCK 对象实例,只有获取到这个 LOCK 实例的锁才能进入这个方法类锁与实例锁不相互阻塞,但相同的类锁,相同的当前实例锁,相同的对象锁会相互阻塞。

用法总结

  • 对于同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前对象的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。

synchronized同步锁的原理

JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

如下示例: image.png 用黄色高亮的部分就是需要注意的部分了,这也是添Synchronized关键字之后独有的。执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。通过分析之后可以看出,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。

原文链接:blog.csdn.net/qq_36934826… 每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

ObjectMonitor中有两个队列_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

根据上面两段文字,我画了一个草图,方便大家的理解,不对的话,海涵~ image.png

synchronized内存语义

image.png 线程A会首先先从主内存中读取共享变量a=0的值然后将该变量拷贝到自己的本地内存,进行加一操作后,再将该值刷新到主内存,整个过程即为线程A 加锁-->执行临界区代码-->释放锁相对应的内存语义

线程B获取锁的时候同样会从主内存中共享变量a的值,这个时候就是最新的值1,然后将该值拷贝到线程B的工作内存中去,释放锁的时候同样会重写到主内存中。

从横向来看,这就像线程A通过主内存中的共享变量和线程B进行通信,A 告诉 B 我们俩的共享数据现在为1啦,这种线程间的通信机制正好吻合java的内存模型正好是共享内存的并发模型结构。

synchronized锁优化

现在对Synchronized应该有所了解了,它最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)。这种方式肯定效率低下,每次只能通过一个线程,既然每次只能通过一个,这种形式不能改变的话,那么我们能不能让每次通过的速度变快一点了。在Java1.6之前呢,我们的synchronized的效率的确很低,但在1.6之后,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。其实这里面体现的就是CAS乐观锁的思想。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁的获取:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

image.png 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

synchronized锁优化总结

偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

参考文献 《java并发编程的艺术》