美文网首页Java 并发编程
Java并发编程(四):锁

Java并发编程(四):锁

作者: yeonon | 来源:发表于2018-12-09 16:51 被阅读0次

    1 概述

    什么是锁?锁其实是一种同步机制,或者说是实现同步的一种手段,其他的同步机制还有信号量、管程等。其功能就是将一部分代码块或者方法(函数)包围起来作为一个“临界区”,线程访问这段临界区时需要先获取锁(可以理解为获取权限),获取成功则可以进入临界区执行内部代码,否则不能进入临界区。那获取锁失败的线程会干嘛呢?这取决于具体实现,有可能是轮询再次尝试获取,也可能是进入阻塞状态,等待唤醒,甚至可能直接放弃。

    锁的分类方式有很多,多种分类方式中有交叉部分,例如悲观锁也可以是公平锁。这里我想表达的是:分类方式其实是指定了某个特定的场景或者说是环境,然后在此基础上对锁进行符合条件的分类。常见的锁类型有如下几种:

    • 公平锁和非公平锁,强调公平性。
    • 乐观锁和悲观锁,强调的是如何看待并发,即是以乐观的态度,认为没有加锁的必要,还是以悲观的态度,认为肯定要加锁。
    • 可重入锁,强调的是其“可重入”特性。
    • 独占锁和共享锁,强调的是持有锁的状态。
    • 轻量级锁和重量级锁,强调的是锁的“量级”,如果对一个方法或者代码块的开销很大,那么该锁就是一个重量级的。

    ..........

    Java中的锁机制主要有两种(Java5之前,只有一种,即内置锁),内置锁和显式锁。本文主要就是围绕这两种机制展开讨论,不会涉及到底层和操作系统相关的知识。

    2 内置锁

    所谓内置锁其实就是synchronized关键字,在之前的文章 Java并发编程(二):线程安全 中,我介绍过synchronized关键字的几种用法,所以这里我就不再介绍其用法了,而是介绍一下其实现原理。

    2.1 synchronized的实现原理

    下面我仍然使用计数的例子来作为演示代码,如下所示:

    public class Main {
    
        private static int count;
    
        public static void main(String[] args) throws InterruptedException {
            ExecutorService service = Executors.newFixedThreadPool(4);
    
            for (int i = 0; i < 5; i++) {
                service.execute(() -> {
                    for (int j = 0; j < 10000; j++) {
                        increment(1);
                    }
                });
            }
            service.shutdown();
            service.awaitTermination(100, TimeUnit.SECONDS);
            System.out.println(count);
        }
    
        public synchronized static void increment(int delta) {
            count += delta;
        }
    }
    

    使用javac编译并且用javap来查看字节码信息:

    > javac Main.java
    > javap -verbose Main.class > Main.txt #重定向到文件中,方便查看
    

    Main.txt里内容很多,常量池、MD5校验码等等,不过这里我们只需要关注increment()方法就行了,其相关的字节码如下所示:

      public static synchronized void increment(int);
        descriptor: (I)V
        flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
        Code:
          stack=2, locals=1, args_size=1
             0: getstatic     #11                 // Field count:I
             3: iload_0
             4: iadd
             5: putstatic     #11                 // Field count:I
             8: return
          LineNumberTable:
            line 31: 0
            line 32: 8
    

    可以看到flags里有一个flag是ACC_SYNCHRONIZED,即同步的意思,因为在演示代码中,synchronized关键是作用在方法上的,JVM运行程序的时候会看到这个ACC_SYNCHRONIZED标记,当执行到该方法时,JVM检查到该方法有ACC_SYNCHRONIZED标记,此时执行该方法的线程就需要先持有一个monitor,然后再执行方法,执行方法完毕之后再释放monitor,该monitor是具有互斥性的,即如果一个线程持有了monitor,他们其他线程就无法获取monitor了。如果在执行方法的过程中发生异常,并且方法内部无法处理异常,那么monitor将在异常抛出时自动释放,保证不会发生monitor无法释放的问题。

    演示代码中的synchronized是作用在方法上的,那么如果synchronized作用在代码块中,会是怎么一个情况呢?现在来修改一下increment()方法,变成这样:

    public static void increment(int delta) {
        synchronized (Main.class) {
            count += delta;
        }
    }
    

    再次编译,并用javap打印出字节码信息。还是一样,我们只关注increment()方法相关的信息,如下所示:

     public static void increment(int);
        descriptor: (I)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=3, args_size=1
             0: ldc           #13                 // class top/yeonon/post4/Main
             2: dup
             3: astore_1
             4: monitorenter
             5: getstatic     #11                 // Field count:I
             8: iload_0
             9: iadd
            10: putstatic     #11                 // Field count:I
            13: aload_1
            14: monitorexit
            15: goto          23
            18: astore_2
            19: aload_1
            20: monitorexit
            21: aload_2
            22: athrow
            23: return
          Exception table:
             from    to  target type
                 5    15    18   any
                18    21    18   any
    ....
    

    发现,和作用在方法上不一样,此时flags集合中没有了ACC_SYNCHRONIZED标记。但指令比之前多了,那多了哪些呢?仔细对比,可以发现,最最主要的是多了monitorenter和monitorexit指令!又遇到monito这个东西了,那这monitorenter和monitorexit是个什么意思呢?

    monitorenter指令指明了同步代码块的起始位置,相应的,monitorexit指令指明了同步代码块的结束位置。这两个指令包裹的区域就是所谓的“临界区”,在示例中的这几条指令其实就对应着count += delta;这行代码,正是源代码中synchronized包裹的代码块。

    在线程执行这段代码块之前,需要先获取和与objectref(即对象引用,synchronized括号里的那个对象的引用)对应的monito,如果该monito的计数器值为0,则获取成功,并且将计数器值+1,如果该线程尝试再次获取monito(注意此时monito值不为0),那么也能获取成功,并且将计数器再+1(此时值为2),这就是可重入。在该线程执行完毕之后会释放monito并且将计数器值设置为0,以便其他线程获取monito。

    如果在代码块中抛出了异常并且没有处理异常的逻辑,那么该异常就会被默认添加的异常处理器捕获异常(该异常处理器捕获的是任意异常),然后跳转到代码块外部去执行异常处理逻辑,从字节码中,我们可以看出,序号20也是一个monitorexit指令,这个monitorexit指令其实是属于异常处理器的处理逻辑,目的是为了保证即使发生了异常,也一定会释放monito,其实如果对字节码的异常表有了解的话,这个过程会理解的比较深刻。

    2.1 synchronized可重入性

    上面提到了synchronized的可重入性,现在来稍微介绍一下什么是可重入,维基百科上对于可重入有如下解释:

    若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

    下面的例子在一个同步方法中调用了另一个同步方法:

    public class ReentrantTest implements Runnable {
    
        public synchronized void get() {
            System.out.println(Thread.currentThread().getName() + ";get");
            set();
        }
    
        public synchronized void set() {
            System.out.println(Thread.currentThread().getName() + ";set");
        }
    
        public void run() {
            get();
        }
    
        public static void main(String[] args) {
            ReentrantTest rt = new ReentrantTest();
            ExecutorService service = Executors.newFixedThreadPool(4);
            for (int i = 0; i < 4; i++) {
                service.execute(() -> {
                    for (int j = 0; j < 1000; j++) {
                        rt.get();
                    }
                });
            }
    
            service.shutdown();
        }
    }
    

    截取了一小段输出:

    pool-1-thread-1;get
    pool-1-thread-1;set
    pool-1-thread-1;get
    pool-1-thread-1;set
    pool-1-thread-1;get
    pool-1-thread-1;set
    

    对于没有可重入性的锁来说,锁被某个线程获取之后,需要等待线程将锁释放之后,该锁才能再次被获取。假设线程A调用了get()方法,获取了锁(现在锁其实就是rt对象锁),如果该锁是不可重入的,那么当调用set()方法时(也是rt对象锁),就会被阻塞,因为该锁已经被获取过了,更严重的是,现在其他线程也没办法获取到rt对象锁,也就是说发生了“死锁”。但如果锁是可重入的,线程A就可以继续获取rt对象锁,成功执行set方法逻辑,最后代码顺利执行完毕,不会发生死锁。

    2.3 synchronized锁优化

    从上面的分析中,可以知道synchronized中的锁对象其实就是实例对象(作用在实例方法上或者代码块中括号里的对象是实例字段)或者类对象(作用在静态方法上或者代码块中括号里的对象是类字段)。无论是那种类型的对象,终究都是对象,每个对象都有一个对象头,在对象头中有一部分用来保存对象运行时数据的信息,叫做"Mark Word"。里面包含了锁相关的标记,下面即将要提到的锁升级就是其实就在对锁标记进行修改。

    锁的状态有四种,无锁状态、偏向锁、轻量级锁、重量级锁,等级自低向高,开销也自低向高,安全性也同样。Java之所以要分出这4种状态,最主要的原因就是为了提高锁性能,即所谓的锁优化。对于不存在锁竞争的场合,无锁状态是没有同步开销的,所以性能最好,对于竞争不激烈的场合,无锁状态显然不合适了,所以不得不将无锁状态“升级”,但不会直接升级成重量级的锁,而是先升级成开销较小偏向锁,如果偏向锁失效(即可能会导致线程安全问题了),就再次“升级”,最终升级到重量级锁,基本上到了重量级锁,无论竞争是否激烈,都不会发生线程安全问题了。

    那锁能否降级呢?说实话,我不确定,有些文章说不能降级,但有的文章说实际上是存在锁降级的,而且其论述让我感觉也符合逻辑。

    上面提到几个锁,下面逐一介绍这几个类型的锁,其中穿插着锁升级的过程。

    2.3.1 偏向锁

    偏向锁是Java6之后新加入的锁类型,具有“偏向(偏心)”的特性。在没有锁竞争的场景下(例如单线程环境),如果一个线程获取到了锁并执行逻辑,那么当该线程再次执行这段逻辑时,就不再需要去获取锁了,即减少了获取锁的次数,因此降低了开销,提高性能。因此,即使我们在单线程环境下执行同步方法或者同步代码块,其实也不会有太大的性能损失。如果此时另外一个线程也要执行这段逻辑(即发生了锁竞争),没有锁竞争的情况就不复存在了,虚拟机识别到这种情况就会进行锁升级,尝试将偏向锁升级到轻量级锁。

    2.3.2 轻量级锁

    轻量级锁是基于这么一个假设提前的:对绝大部分的锁,在整个同步周期内都不存在竞争。即如果一个线程获取了轻量级锁,然后执行逻辑,此时其他线程不会来尝试获取锁。轻量级锁的开销大于偏向锁,但安全性要优于偏向锁,但如果上述的假设其他被打破,那么轻量级锁就会升级到重量级锁。

    2.3.3 重量级锁

    基本上就是我们普遍认知的锁,具有很强的互斥性,当一个线程获取到重量级锁之后,无论竞争是否激烈,都会阻止其他线程获取锁,知道该线程主动放弃锁,或者逻辑执行完毕。

    在这里顺便说一下所谓的“锁消除”机制(这其实不应该属于锁优化的一部分,应该属于JIT即时编译优化的部分,但由于和锁相关,就放在这里说了。)如果虚拟机支持JIT,那么JIT可能会通过分析上下文,得出某段同步代码其实并不需要同步的结论,并采取“锁消除”的行动,即将同步措施去掉,从而提高性能。这个过程也许会有风险,不过现在的JIT很完善,具有“回滚”的能力,所以并不会对系统造成太大的影响,最多是损失一些性能而已。

    3 显式锁

    在Java5之前,锁只有一种synchronized内置锁,Java5中新增了Lock、ReadWriteLock等接口及其实现,使得Java多了一种同步手段,一般把这种同步手段称作“显式锁”。

    为什么叫“显式锁”呢?在之前介绍synchronized内置锁的时候,我们看到,虽然synchronized用起来不需要用户手动的加锁,解锁,但实际上是编译器帮我们对某个区域进行了加锁和解锁,甚至还附带了默认异常处理器来处理异常情况,可以说是非常贴心了。而接下来要介绍的这种锁,需要用户自己手动的加锁、解锁、处理异常情况等,相对synchronized来说,这是一种显式的操作,故称作“显式锁”。

    3.1 Lock和ReentrantLock

    Lock接口位于java.util.concurrent.locks包下,是Doug Lea大佬的作品。该接口总共提供了6个抽象方法,如下所示:

    public interface Lock {
    
        //加锁
        void lock();
    
        //加锁,但是是可中断的
        void lockInterruptibly() throws InterruptedException;
        
        //尝试获取锁,只有在锁是空闲的时候才会获取成功
        boolean tryLock();
        
        //尝试获取锁,只有在给定时间内锁是空闲的情况下,才会获取成功
        boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    
        //解锁
        void unlock();
        
        //获取一个新的Condition,Condition是一个非常方便的用来协调线程的类
        Condition newCondition();
    }
    
    

    ReentrantLock(可重入锁)实现了Lock接口,并提供了与synchronized相同的互斥性和内存可见性。在获取锁和释放锁的时候,也与synchronized有相同的内存语义,而且还和synchronized一样,是可重入的。可见,ReentrantLock和synchronized非常相似,那么为什么要专门搞出这么一套东西呢?因为synchronized的功能有一些局限,例如在线程获取锁的时候,无法被中断,或者无法实现非阻塞的加锁规则等等,相比之下ReentrantLock具有更丰富的功能以及灵活性,但也比synchronized的使用更加麻烦、易错。

    这里我不打算对ReentrantLock的源码做介绍,因为ReentrantLock最最核心的部分涉及到了AQS(AbstractQueuedSynchronizer),而AQS在本文以及之前的文章中没有介绍过(虽然遇到过),所以在后续文章中介绍AQS的时候再把ReentrantLock作为一个例子来讲可能会更好一些。

    3.1.1 lock()

    下面仍然以计数器的例子来作演示:

    public class Main {
        
        private static int count = 0;
    
        private static ReentrantLock lock = new ReentrantLock();
    
        public static void main(String[] args) throws InterruptedException {
            ExecutorService service = Executors.newFixedThreadPool(4);
            for (int i = 0; i < 4; i++) {
                service.execute(() -> {
                    for (int j = 0; j < 25000; j++) {
                        increment(1);
                    }
                });
            }
            service.shutdown();
            service.awaitTermination(1000, TimeUnit.SECONDS);
            System.out.println(count);
        }
    
        public static void increment(int delta) {
            lock.lock();
            try {
                count += delta;
            } finally {
                lock.unlock();
            }
        }
    }
    

    和之前不同是这次不再使用内置锁synchronized,而是使用ReentrantLock,要使用ReentrantLock,首先要做的当然是声明ReentrantLock变量并初始化一个实例对象赋值给该变量。使用显式锁的标准格式是这样的:

    lock.lock();
    try {
        .....
    } finally {
        lock.unlock();
    }
    //如果有catch的话,再加入即可。
    

    之所以要使用finally来释放lock,是为了保证无论被锁住的区域是否发生异常,都会触发解锁操作,防止出现线程获取了锁但永远不会释放锁的情况。这里有一道常见的面试题(顺便说一下,该规则已被加入到阿里巴巴Java开发手册里),为什么加锁操作不在try内部呢?因为如果放入了try块内部,当lock发生异常的时候(注意此时加锁失败,线程没有获取到锁),程序最终还是会走到finally块并执行解锁操作,Lock.unlock()方法的文档中有类似这样的描述:当非锁持有线程调用该方法的时候会抛出unchecked异常,最终可能会导致加锁失败的异常被该异常覆盖,使得程序员无法通过异常堆栈定位问题。

    3.1.2 tryLock()

    下面来看看tryLock()方法的使用,还是刚刚的例子,只修改了increment()方法:

    public static void increment(int delta)  {
        if (lock.tryLock()) {
            try {
                count += delta;
            } finally {
                lock.unlock();
            }
        }
    }
    

    暂时不分析tryLock起到怎么一个作用,先运行一下试试,发现运行多次都很难出现正确的答案(正确答案应该是100000),为什么?

    lock.tryLock()的文档有这么一句话:

    Acquires the lock only if it is free at the time of invocation.

    Acquires the lock if it is available and returns immediately
    with the value {@code true}.
    If the lock is not available then this method will return
    immediately with the value {@code false}.

    即如果调用tryLock()方法成功获取到锁,那么就立即返回true,否则立即返回false,隐含的意思就是放弃本次操作。结合这段描述以及实际情况来看,线程调用这个方法的时候即使获取失败也不会进入阻塞状态,而是直接放弃本次操作!回到上面的例子,假设现在有两个线程同时竞争锁,肯定只会有一个线程获胜,另一个获取失败的线程不会傻傻的等待机会再次获取锁,而是直接放弃,不干了!最终导致“丢失”了很多次+1操作,这就解释了为什么示例输出总是小于等于100000。

    tryLock()方法还有一个有两个参数的重载形式,第一个参数是时间数值,第一个参数是时间单位,该方法的意义是,如果在设置的时间内成功获取锁,那么就返回true,和无参的tryLock()不同,如果获取失败,也不会立即返回false并放弃此次操作,而是在设置的时间内获取失败,才会返回false并放弃本次操作,在这段时间内,可以多次尝试。下面稍微修改一下示例中的increment()的代码:

    //注意在上层调用代码中处理InterruptedException异常
    public static void increment(int delta) throws InterruptedException {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                count += delta;
            } finally {
                lock.unlock();
            }
        }
    }
    

    发现输出的结果出现100000的概率高了很多!但是要注意,并不是这样做了之后就一定能保证每次都一定会输出正确的结果100000,这取决于机器的性能,如果机器非常非常慢,仍然会出现小于100000的情况,至于为什么,建议仔细再看看上面我对tryLock(long,TimeUnit)的解释。

    那该方法用在什么场合呢?显然,我们的计数器示例并不适合使用该方法加锁,更加合适的场景是那种如果超时就放弃的场景,即使放弃本次操作也不会造成太大的影响。例如Web场景,当请求达到服务端,服务端线程在若干秒(配置好的)都无法获取到锁,那么就直接放弃本次操作,并将失败结果返回给前端,就好像服务降级一样(但实际的服务降级并不会那么简单)。

    3.1.3 lockInterruptibly()

    还剩下一个Lock.lockInterruptibly()方法,从名字上大致能猜出该方法的作用是:获取锁的时候是可响应中断的。下面是一个示例:

    public class Main {
    
        private static int count = 0;
    
        private static ReentrantLock lock = new ReentrantLock();
    
        //为了简单,我没按照标准写法写
        public static void main(String[] args) throws InterruptedException {
            //main线程直接获取锁
            lock.lock();
            Runnable runnable = () -> {
                try {
                    increment(1);
                } catch (InterruptedException e) {
                    System.out.println("interrupt!");
                }
            };
            Thread thread1 = new Thread(runnable);
            Thread thread2 = new Thread(runnable);
            thread1.start();
            thread2.start();
            thread1.interrupt();
            Thread.sleep(200);
            thread2.interrupt();
    
            thread1.join();
            thread2.join();
            lock.unlock();
            System.out.println(count);
    
        }
    
        public static void increment(int delta) throws InterruptedException {
            lock.lockInterruptibly();
            try {
                count += delta;
            } finally {
                lock.unlock();
            }
        }
    }
    

    main线程一开始就获取到了锁,显然后面的两个线程都无法成功获取,所以会陷入阻塞状态,等待锁被释放。但由于lockInterruptibly()是可以响应中断的,所以如果尝试给出中断信号,线程会从阻塞状态中醒过来并抛出InterruptedException异常,如果有处理异常的逻辑,那么就可以捕获该异常并做相应的处理。运行程序,会发现有类似活下输出:

    interrupt!
    interrupt!
    0
    

    0是正确的结果,因为确实两个线程都没能成功获取锁,也就没有能执行+1操作。

    lockInterruptibly()的例子可能并不太合适,不过应该足够说明lockInterruptibly()的功能了。(举例子真的挺难的......)

    3.1.4 ReentrantLock公平性

    ReentrantLock还有一个重载的构造函数,该构造函数接受一个布尔类型的参数fair,传入的值是true表示该锁是公平锁,否则就是非公平锁。公平意味着线程获取到锁的顺序与他到达的顺序一致,即不会出现“插队”的现象,而非公平就会出现某些线程得到“特殊对待”,会出现“插队”。

    公平锁和非公平锁除了上述的区别之外,还有一个重要的区别:性能。下面是《Java并发编程实战》一书中给出的一张性能对比图:

    ihEQYt.png

    从图中可以看出,线程的数量越多,公平锁的吞吐率越低。而非公平锁吞吐率虽然也在降低,但是降低的速度没公平锁那么快。为什么呢?

    首先,线程越多意味着竞争也越激烈,假设现在有两个线程A和B竞争同一个锁,A比较快,先获取了锁,B只能阻塞等待。但A释放锁时,B会被唤醒,此时又有一个线程C来请求获取锁,如果该锁是非公平锁,那么C很有会在B被完全唤醒之前获取到锁,然后执行逻辑,释放锁,随后B被完全唤醒,成功获取该锁,如果该锁是公平锁,那么C无法在B之前获取锁,只能等待B被完全唤醒,然后执行逻辑,释放锁。

    3.2 ReadWriteLock

    ReadWriteLock即读写锁,该接口仅有两个抽象方法:

    Lock readLock();
    
    Lock writeLock();
    

    readLock()会返回一个读锁,writeLock()会返回一个写锁。在JDK里有一个类ReentrantReadWriteLock,该类实现了该接口,在其内部还有两个重要的静态内部类WriteLock和ReadLock,这两个类都实现了Lock接口。如下所示:

    public static class WriteLock implements Lock, java.io.Serializable {
        //....
    }
    
    public static class ReadLock implements Lock, java.io.Serializable {
        //...
    }
    

    ReentrantReadWriteLock对ReadWriteLock的两个实现方法也非常简单,就是返回WriteLock和ReadLock的实例,如下所示:

    public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
    public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }
    

    WriteLock和ReadLock的使用方法和ReentrantLock没什么区别,只是语义上有些区别而已。ReadLock即读锁,该锁可以被多个线程共享,WriteLock即写锁,该锁是互斥的,同一时刻只能被一个线程持有。

    这里我对读写锁的介绍非常少,原因是之前已经比较详细的介绍了ReentrantLock,读写锁和ReentrantLock不同的地方只是在于具体的实现,而具体的实现又涉及到AQS,所以就没有介绍太多。

    4 内置锁synchronized和显式锁如何选择

    在Java5和Java6中,显式锁的性能高于synchronized内置锁,而且功能比synchronized丰富,例如支持可中断的等待,可定时的等待以及公平性等,缺点就是较synchronized复杂一些,代码不如synchronized简洁,而且容易出错(例如忘了在finally中释放锁,或者把加锁操作放到try块内部),那究竟如何在两者之间做选择呢?

    我个人倾向于优先选择内置锁synchronized,除非需要使用到一些内置锁无法提供的功能。因为synchronized是JVM支持的一种同步机制,可操作或者说可优化空间很大,JVM完全可以在底层实现中对synchronized做优化,例如上面提到过的锁升级,锁消除等,所以性能上并不会比内置锁差,而且实际上Java也一直在新的版本中对synchronized做优化。而显式锁本质是只是“类库”中的一员,难以获得JVM底层的支持,可优化空间比较小。

    最后在重复一遍:除非需要使用一些内置锁无法提供的高级功能,例如支持可中断的线程等待、公平性等,否则建议优先选择内置锁synchronized。

    5 死锁

    死锁是这么一种情况:假设有两个线程A和B以及两个锁lock1和lock2,线程A持有锁lock2,但是想获取锁lock2,而线程B持有lock2,但想获取lock1,这样导致的结果就是A和B都无法获得他们想获取的锁,从而导致两个线程永远处于阻塞等待的状态。

    死锁有四个必要条件,其中任何一个条件不成立,都不会造成死锁:

    • 占有并等待,线程在持有锁的情况下,仍然想要获取另一个锁。
    • 禁止抢占,不能抢占其他线程持有的锁。
    • 互斥等待,锁同时只能被一个线程持有。
    • 循环等待,一系列线程互相持有其他线程所需要的锁。

    下面是一个死锁的示例:

    public class DeadLockTest {
    
        public static void main(String[] args) throws InterruptedException {
            String lock1 = "lock1";
            String lock2 = "lock2";
            DeadLock deadLock1 = new DeadLock(lock1, lock2);
            DeadLock deadLock2 = new DeadLock(lock2, lock1);
    
            Thread thread1 = new Thread(deadLock1);
            Thread thread2 = new Thread(deadLock2);
    
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
        }
    
    
        private static class DeadLock implements Runnable {
            private final String lockA;
            private final String lockB;
    
            private DeadLock(String lockA, String lockB) {
                this.lockA = lockA;
                this.lockB = lockB;
            }
    
            @Override
            public void run() {
                synchronized (lockA) {
                    System.out.println(Thread.currentThread() + " get " + lockA);
                    try {
                        Thread.sleep(250);
                        synchronized (lockB) {
                            System.out.println(Thread.currentThread() + "get " + lockB);
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
    

    运行一下,输出结果大致如下所示:

    Thread[Thread-1,5,main] get lock2
    Thread[Thread-0,5,main] get lock1
    

    线程1获取了lock2,想要获取lock1,但lock1已经被线程0持有了,同时线程0还想获取lock2(满足上述四个条件中的条件1、4),而且lock1和lock2都是互斥锁(满足条件3),默认情况下,这两个锁都是不可强占的(满足条件2)。至此,上述四个条件都满足了,所以发生死锁也是必然的。

    这个示例很简单,我们可以立即知道死锁在哪里发生,为什么会发生死锁,但如果在大型的程序中发生死锁,往往很难发现,因为死锁不会抛出异常,也就不会有异常堆栈来让我们分析,那么如果在大型程序中发生了死锁,该如何定位问题呢?下面我将介绍两种方法来定位问题,分析问题。

    5.1 使用jstack工具来定位死锁问题

    jstack是JDK自带的堆栈分析工具,关于该工具的使用我已经在之前的文章中有过介绍,在此不再赘述,直接开始动手。

    先使用Jps来获取进程ID:

    > jps
    30512 Jps
    33348 Launcher
    43716 DeadLockTest
    49188
    51500 RemoteMavenServer
    

    得到DeadLockTest的进程ID是43716,然后使用jstack打印其堆栈信息:

    > jstack -l 43716
    #省略了部分内容
    Found one Java-level deadlock:
    =============================
    "Thread-1":
      waiting to lock monitor 0x0000000002d1cc98 (object 0x00000000d5fbd1b8, a java.lang.String),
      which is held by "Thread-0"
    "Thread-0":
      waiting to lock monitor 0x0000000018d1b808 (object 0x00000000d5fbd1f0, a java.lang.String),
      which is held by "Thread-1"
    
    Java stack information for the threads listed above:
    ===================================================
    "Thread-1":
            at top.yeonon.post4.DeadLockTest$DeadLock.run(DeadLockTest.java:42)
            - waiting to lock <0x00000000d5fbd1b8> (a java.lang.String)
            - locked <0x00000000d5fbd1f0> (a java.lang.String)
            at java.lang.Thread.run(Thread.java:748)
    "Thread-0":
            at top.yeonon.post4.DeadLockTest$DeadLock.run(DeadLockTest.java:42)
            - waiting to lock <0x00000000d5fbd1f0> (a java.lang.String)
            - locked <0x00000000d5fbd1b8> (a java.lang.String)
            at java.lang.Thread.run(Thread.java:748)
    
    Found 1 deadlock.
    

    jstack已经帮我们发现了死锁问题,并且还打印了堆栈信息供我们分析。从分析中,可以看出Thread-1等待0x00000000d5fbd1b8锁,并持有0x00000000d5fbd1f0锁,Thread-0等待0x00000000d5fbd1f0锁,并持有0x00000000d5fbd1b8锁,发生问题的地方是DeadLockTest.java的源码第42行,虽然这个行数可能并不准确,但至少可以缩小排查范围,能更快的定位问题,解决问题。

    5.2 使用VistualVM来定位死锁问题

    VistualVM在之前的文章中也有介绍过,在这里也不再多说了,直接使用。打开VistualVM时候,选择DeadLockTest进程,然后选择上边Tab栏的Threads选项卡,点进来就发看到VistualVM给我们一个大大的死锁相关的提示,如下所示:

    ihrDXV.png

    点击右边的Thread Dump,就能查看线程堆栈信息了,其信息和Jstack打印出来的几乎一摸一样,就不多少了。

    现在死锁问题是找到源头了,但如何解决问题呢?死锁既然已经发生了,一般就很难依靠程序自己去解决了,只能依靠人工杀死其中一个线程来解决问题了,关于死锁的预防、避免等,在这里我就不多说了(其实很多手段我自己也没搞太明白,建议各位自己多多查找网上资料学习)。

    6 CAS

    CAS即Compare And Set(比较并设置),包含的意义是:先拿旧值和当前值比较,如果相等即表示值没有发生变化,最后再将新的值赋值给变量。

    那这个机制有什么用呢?如果是单线程的环境下,这样做显得有些多余,但在多线程环境下确实能保证一定的线程安全,而且是无锁的(即没有锁的开销)。假设现在有两个线程A和B对同一个共享变量做修改操作并且程序使用了CAS机制,当A线程对共享变量进行修改的时候,他会先检查一下该共享变量和之前读到的是否一致,如果一致,就将该变量的值更新,否则就放弃更新操作,继续轮询,直到一致为止。那为什么会出现不一致的情况呢?因为也许A线程刚刚读到该变量的值就被CPU换出去了,B线程开始执行,读取该变量值、然后检查该值的实际值和上一步读到的变量一致,这时候没有其他线程干预,会发现是一致的,然后就修改该值,写入内存,然后又切换到A线程,很明显,此时A线程去做CAS的时候,发现两个值不一致,所以就会放弃本次操作,继续轮询。

    说了那么多,可能有些抽象,下面是一个例子,使用Unsafe对象提供了API:

    public void increment(int delta) {
        int oldValue;
        do {
            oldValue = unsafe.getIntVolatile(this, countOffset);
        } while (!unsafe.compareAndSwapInt(this, countOffset, oldValue, oldValue + delta));
    }
    

    仔细看看代码,结合上面我说的那一串逻辑,应该就能理解整个过程了。下面是完整版代码:

    public class CASTest {
    
        private static Unsafe unsafe;
    
        private volatile int count;
    
        public CASTest(int count) {
            this.count = count;
        }
    
        private static long countOffset;
    
        static {
            try {
                Field field = Unsafe.class.getDeclaredField("theUnsafe");
                field.setAccessible(true);
                unsafe = (Unsafe) field.get(null);
                countOffset = unsafe.objectFieldOffset(CASTest.class.getDeclaredField("count"));
            } catch (NoSuchFieldException | IllegalAccessException e) {
                e.printStackTrace();
            }
    
        }
    
        public void increment(int delta) {
            int oldValue;
            do {
                oldValue = unsafe.getIntVolatile(this, countOffset);
            } while (!unsafe.compareAndSwapInt(this, countOffset, oldValue, oldValue + delta));
        }
    
        public int getCount() {
            return count;
        }
        
        public static void main(String[] args) throws InterruptedException {
            CASTest casTest = new CASTest(0);
            ExecutorService service = Executors.newFixedThreadPool(4);
            for (int i = 0; i < 4; i++) {
                service.execute(() -> {
                    for (int j = 0; j < 25000; j++) {
                        casTest.increment(1);
                    }
                });
            }
            service.shutdown();
            service.awaitTermination(1000, TimeUnit.SECONDS);
            System.out.println(casTest.getCount());
        }
    }
    

    6.1 ABA问题

    CAS操作非常容易出现ABA问题。什么是ABA问题呢?假设有两个线程A和B对同一变量做修改操作,初始化时该变量值为0,之后A线程使用CAS操作将其修改为1,但出于某种需求,又把值修改为0,此时B线程进行CAS操作时,检查发现刚刚读取到的值和当前值没有变化,所以检查成功,并将其修改为1,但整个过程中,B完全不知道其实该共享变量已经被修改过了,有时候出现这种情况对整个系统没什么影响,有时候影响又非常严重,尤其是涉及到转账等金融计算问题的时候,下面的这个例子演示了ABA问题对转账的影响:

    ihvyD0.png

    上图中最后余额应该是100(100-50+50),但最终却变成了50。最根本的原因就是因为ABA问题,这里我就不多做解释了,希望读者能理解这么个意思。要解决ABA问题,现在常见的方法有加入版本号,这个版本号会随着操作次数增加而增加,这样线程在比较的时候就可以知道共享变量是否已经被修改过了,也可以换成时间戳,效果差不多。

    6.2 CAS的优缺点

    优点:

    1. CAS是无锁的同步机制,开销比锁要小很多。

    缺点:

    1. CPU开销大,在竞争激烈的场合,空转的时间占比会比较大。
    2. 代码可读性差。
    3. 编程模型也较为复杂。(Java中能使用Unasfe提供的API已经算是大大简化了开发)
    4. 上面说到的ABA问题。

    所以,虽然CAS开起来是一个能提高性能的技术,但问题也很多,选择的时候要非常非常慎重,开发的时候也需要小心翼翼。

    7 小结

    本文介绍了什么是锁,比较详细的介绍了Java内置锁以及显式锁,两种类型的锁都很常用,内存语义也非常相似,最大的区别是显式锁的功能较为丰富,支持可中断的线程等待等功能。内置锁是Java大力发展、支持的对象,性能随着版本迭代也越来越好,建议优先考虑使用内置锁,只有在内置锁无法满足需求的情况下,才选择使用显式锁。之后还简单介绍了死锁的概念以及死锁发生的四个必要条件,并且介绍了两种方法来定位死锁问题所在,但没有涉及到死锁的避免、预防等,这方面的知识,各位可以去看看银行家算法的相关资料。最后讲了一下什么是CAS,Java对CAS的支持以及所谓的“ABA”问题。

    这篇文章我写了好多天,但还是感觉很多东西没有讲到,例如分布式锁,数据库表锁,行锁等。希望读者能继续扩展阅读其他优秀资料,丰富自己的技能树,逐步解开“锁”的神秘面纱!

    8 参考资料

    《Java并发编程实战》

    互联网上相关博客文章(实在太多了,很多博客的地址都记不住了,抱歉)

    相关文章

      网友评论

        本文标题:Java并发编程(四):锁

        本文链接:https://www.haomeiwen.com/subject/hgkkhqtx.html