Java并发编程(四):锁

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并发编程实战》一书中给出的一张性能对比图:

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

首先,线程越多意味着竞争也越激烈,假设现在有两个线程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给我们一个大大的死锁相关的提示,如下所示:

点击右边的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问题对转账的影响:

上图中最后余额应该是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并发编程实战》

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容