深入理解多线程之一文读懂锁

1,241 阅读15分钟

什么是锁

锁的概念源于生活,每家每户都有一把锁,只有持有钥匙才能打开锁进入房间。对于程序来说,就是防止其他线程进入,先到的线程进入房间锁上门,后到的线程看到上锁了,就在门口排队等待。

为什么需要锁

多线程并发的场景下,防止多个线程同时读写某一块内存区域,造成数据的不一致性。

java中的锁

首先说明:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。

java中的锁分为内置锁(synchronized)和显式锁(ReentrantLock),其中内置锁是JVM提供的最便捷的线程同步工具,在代码块或方法声明上添加synchronized关键字即可使用内置锁。使用内置锁能够简化并发模型;随着JVM的升级,几乎不需要修改代码,就可以直接享受JVM在内置锁上的优化成果。 既然内置锁已经这么好了,那为什么还需要显式锁呢?

  1. synchronized没有等待超时时间,获取不到锁会无限等待;
  2. 无法中断,如果我们想外部线程发送中断信号的方式停止当前线程获取锁,不得行;
  3. 我们想为锁维持多个等待队列,比如一个生产者队列,一个消费者队列,一边提高锁的效率。
  4. synchronized只能是非公平锁,而ReentrantLock可以指定公平还是非公平

接下来分别介绍两种锁。

显式锁

使用方式很简单,下面是一个ReentrantLock的示例:

public static class InstanceCallable implements Callable<Integer> {
        private static int sharedNum = 0;
        private Lock lock = new ReentrantLock();
        private void increase(){
            lock.lock();
            try {
                for (int i = 0; i < 100; i++) {
                    sharedNum++;
                }
            }catch (Exception e){
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }
        @Override
        public Integer call() throws Exception {
            increase();
            return sharedNum;
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        InstanceCallable instanceCallable = new InstanceCallable();
        List<Future<Integer>> futures = new ArrayList<>();
        for (int i = 0; i < 2; i++) {
            futures.add(executorService.submit(instanceCallable));
        }
        try {
            for (Future<Integer> future : futures) {
                Integer result = future.get();
                System.out.println("结果"+result);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

打印结果:

结果100
结果200

这里使用了Future 来获取线程执行的结果, 要注意的地方是需要吧unlock()方法放到finally里面,这样无论你的代码是否发生异常,锁都会释放掉,不然可能造成锁永远都得不到释放。

内置锁

synchronize内置锁主要有以下三种使用方式:

  1. 修饰实例方法,获取的是当前实例的锁
  2. 修饰静态方法,获取的是当前class类的锁
  3. 修饰代码块,获取的锁对象是synchronized(obj)中的这个obj
1. 修饰实例方法
    public static class InstanceCallable implements Callable<Integer> {
        private static int sharedNum = 0;
        private synchronized void increase(){
            for (int i = 0; i < 100; i++) {
                sharedNum++;
            }
        }
        @Override
        public Integer call() throws Exception {
            increase();
            return sharedNum;
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        InstanceCallable instanceCallable = new InstanceCallable();
        List<Future<Integer>> futures = new ArrayList<>();
        for (int i = 0; i < 2; i++) {
            futures.add(executorService.submit(instanceCallable));
        }
        try {
            for (Future<Integer> future : futures) {
                Integer result = future.get();
                System.out.println("结果"+result);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

打印结果:

结果100
结果200
2. 修饰静态方法

将上面的代码稍微改一下:

public static class InstanceCallable implements Callable<Integer> {
        private static int sharedNum = 0;
        private synchronized void increase(){
            for (int i = 0; i < 100; i++) {
                sharedNum++;
            }
        }
        @Override
        public Integer call() throws Exception {
            increase();
            return sharedNum;
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        //InstanceCallable instanceCallable = new InstanceCallable();
        List<Future<Integer>> futures = new ArrayList<>();
        for (int i = 0; i < 2; i++) {
            futures.add(executorService.submit(new InstanceCallable()));
        }
        try {
            for (Future<Integer> future : futures) {
                Integer result = future.get();
                System.out.println("结果"+result);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
    

这个时候你会发现synchronized修饰实例方法不管用了,因为我是在for循环里new的callable对象,这个时候获取的锁是当前对象的锁,两个不同的线程获取的锁是不同的,所以修改共享变量的时候相当于没有加锁,会存在安全问题。 打印结果:

结果103
结果181

这个时候只需要给increase方法加上static修饰符就行了:

private static synchronized void increase(){
            for (int i = 0; i < 100; i++) {
                sharedNum++;
            }
}

synchronized作用于静态方法时,这个锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态成员的并发操作。

3. 修饰代码块
public static class InstanceCallable implements Callable<Integer> {
        private static int sharedNum = 0;
        private void increase(){
            synchronized (this){
                for (int i = 0; i < 100; i++) {
                    sharedNum++;
                }
            }
        }
        @Override
        public Integer call() throws Exception {
            increase();
            return sharedNum;
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        //InstanceCallable instanceCallable = new InstanceCallable();
        List<Future<Integer>> futures = new ArrayList<>();
        for (int i = 0; i < 2; i++) {
            futures.add(executorService.submit(new InstanceCallable()));
        }
        try {
            for (Future<Integer> future : futures) {
                Integer result = future.get();
                System.out.println("结果"+result);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行代码块。

锁的优化

JDK1.5中,synchronized的性能非常低效。因为这是一个重量级操作,它对性能最大的影响是阻塞等待唤醒机制,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。

到了jdk1.6的时代,对synchronized做了很多的优化,比如自旋锁、自适应自旋锁、偏向锁、轻量级锁等等,这些优化使得synchronized的性能和lock持平甚至超越,未来版本也会随着jdk版本的升级对它进行优化,所以如果在使用synchronized能实现需求的情况下,优先考虑使用它。

当然,性能已经不是选择标准

自旋锁

大多数时候,锁的锁定状态持续时间是比较短的,而挂起线程和恢复线程的代价又太高了。自旋锁访问加锁资源时,会一直循环的查看是否释放锁。但是会占用CPU。所以自旋锁适用于多核的CPU。但是还有一个问题是当自旋锁递归调用的时候会造成死锁现象。所以慎重使用自旋锁。

用代码的方式表达,大概是下面这样的:

public static class InstanceRunnable implements Runnable {
        private static int sharedNum = 0;
        private volatile boolean lock = false;
        private void increase(){
            for (int i = 0; i < 10000; i++) {
                sharedNum++;
            }
        }

        @Override
        public void run() {
            while (lock){
            }
            lock = true;
            increase();
            System.out.println("结果:"+sharedNum);
            lock = false;
        }
    }

    public static void main(String[] args) {
        InstanceRunnable runnable = new InstanceRunnable();
        new Thread(runnable).start();
        new Thread(runnable).start();
    }

自适应自旋锁

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

轻量级锁

如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。对于绝大多数锁,在整个同步周期内都是不存在竞争的。轻量级锁使用CAS操作来获取锁和释放锁,轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

偏向锁

在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。如果有其他线程竞争,它会膨胀为轻量级锁。

重量级锁

内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。

锁消除

锁消除是一种更为彻底的优化,在 JIT 编译时,对运行上下文进行扫描,去除不可能存在共享资源竞争的锁。消除的判断依据来源于逃逸分析的数据支持,也就是说,堆上所有的数据都不会逃逸出去从而被其他线程访问到,这些数据都是在栈上分配内存的,所以是线程私有的,对于这种代码,编译器会自动优化消除锁。

public String connectString(String a,String b,String c){
        StringBuffer buffer = new StringBuffer();
        buffer.append(a);
        buffer.append(b);
        buffer.append(c);
        return buffer.toString();
}

上面的代码用到了StringBuffer,但是实际上并不存在锁的竞争,所以可以被安全的消除掉。

锁粗化

如果存在一连串的操作都对同一个对象进行反复的加锁和解锁,甚至加锁的操作出现在循环体中,那么即使没有线程竞争共享资源,频繁的进行加锁操作也会导致性能损耗。锁粗化的思想就是扩大加锁范围,避免反复的加锁和解锁。比如上面的append操作都是对同一个对象加锁,会把加锁同步的范围扩展到整个操作序列的外部。

锁相关的一些概念

可重入锁

可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。 例如:

// 可重入降低了编程复杂性
public class WhatReentrant {
	public static void main(String[] args) {
		new Thread(new Runnable() {
			@Override
			public void run() {
				synchronized (this) {
					System.out.println("第1次获取锁,这个锁是:" + this);
					int index = 1;
					while (true) {
						synchronized (this) {
							System.out.println("第" + (++index) + "次获取锁,这个锁是:" + this);
						}
						if (index == 10) {
							break;
						}
					}
				}
			}
		}).start();
	}
}

java中,synchronized和Reentrantlock都是可重入锁。

公平锁

加锁前先查看是否有排队等待的线程,有的话优先处理排在前面的线程,先来先得。可以使用ReentrantLock(true)实现公平锁。

非公平锁

线程加锁时直接尝试获取锁,获取不到就自动到队尾等待。只要线程进入了等待队列,队列里面依然是FIFO的原则,这个时候非公平锁跟公平锁是一样的。

非公平锁减少了线程挂起的几率(线程切换的开销),后来的线程有一定几率逃离被挂起的开销(当前线程刚好释放锁的同时,后来的线程刚好获取锁)

synchronized和ReentrantLock默认都是非公平锁

乐观锁

这其实是一种思想,当线程去拿数据的时候,认为别的线程不会修改数据,就不上锁,但是在更新数据的时候会去判断以下其他线程是否修改了数据。通过版本来判断,如果数据被修改了就拒绝更新,之所以叫乐观锁是因为并没有加锁。 cas(compareAndSwap)就是实现的这种思想。

这种锁一般用于数据库,当一个数据库的读操作远远大于写的操作次数时,使用乐观锁会加大数据库的吞吐量。一般我们会给数据库表增加一个version字段实现乐观锁,更新的时候判断当前version是否被其他线程修改了,比如下面的sql:

update goods
set name = xxx,state = xxx,version = version + 1
where id = xxx and version = xxx

悲观锁

当线程去哪数据的时候,总以为别的线程会去修改数据,所以它每次拿数据的时候都会上锁,别的线程去拿数据的时候就会阻塞。synchronized和ReentrantLock都是悲观锁。

死锁

指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。

产生的必要条件:必须同时满足以下四个条件,只要有一条不满足,死锁就不会发生。

  1. 互斥条件:在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  2. 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
  3. 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  4. 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。

避免死锁:只需要避免上述4个条件的任意一条即可。

  1. 加锁顺序,线程按照一定的顺序加锁。当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生,如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生
  2. 加锁时限,线程在尝试获取锁的时候加上一定的时间限制,超过时间则放弃对该锁的请求,并释放自己占有的锁。
  3. 死锁检测,它是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁。那么当检测出死锁时,这些线程该做些什么呢?一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。

模拟代码写出一个死锁:

public class DeadLock {
    public static class ResourceA implements Runnable{
        @Override
        public void run() {
            synchronized (ResourceA.class){
                System.out.println("线程: "+ Thread.currentThread().getName() + " 获取资源A");
                try {
                    //为了让第二个线程先获取B的锁,这里休眠一下
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (ResourceB.class){
                    System.out.println("线程: "+ Thread.currentThread().getName() + " 获取资源B");
                }
            }
        }
    }

    public static class ResourceB implements Runnable{
        @Override
        public void run() {
            synchronized (ResourceB.class){
                System.out.println("线程: "+ Thread.currentThread().getName() + " 获取资源B");
                synchronized (ResourceA.class){
                    System.out.println("线程: "+ Thread.currentThread().getName() + " 获取资源A");
                }
            }
        }
    }

    public static void main(String[] args) {
        new Thread(new ResourceA()).start();
        new Thread(new ResourceB()).start();
    }
}

这里线程1获取资源A的锁,对A锁保持不放的同时获取资源B的锁,线程2获取到资源B的锁,对B锁保持不放的同时获取资源A的锁,两边一直僵持下去。

其他锁

数据库锁

数据库锁简单来说,就是数据库为了保证数据的唯一性,而使各种共享资源在被并发访问变得有序所设计的一种规则。MySql各存储引擎使用了三种类型的锁定机制。

  1. 表级锁定

    表级锁定是MySql中最大粒度的锁定机制,该锁定机制最大的特点是实现逻辑非常简单,带来的系统负面影响最小。所以获取锁和释放锁的速度很快。由于表级锁一次会将整个表锁定,所以可以很好的避免困扰我们的死锁问题。 当然,锁定颗粒度大所带来最大的负面影响就是出现锁定资源争用的概率也会最高,致使并大度大打折扣。

  2. 行级锁定

    行级锁定最大的特点就是锁定对象的颗粒度很小,也是目前各大数据库管理软件所实现的锁定颗粒度最小的。由于锁定颗粒度很小,所以发生锁定资源争用的概率也最小,能够给予应用程序尽可能大的并发处理能力而提高一些需要高并发应用系统的整体性能。 虽然能够在并发处理能力上面有较大的优势,但是行级锁定也因此带来了不少弊端。由于锁定资源的颗粒度很小,所以每次获取锁和释放锁需要做的事情也更多,带来的消耗自然也就更大了。此外,行级锁定也最容易发生死锁。

  3. 页级锁定

    页级锁定是MySQL中比较独特的一种锁定级别,在其他数据库管理软件中也并不是太常见。页级锁定的特点是锁定颗粒度介于行级锁定与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力也同样是介于上面二者之间。另外,页级锁定和行级锁定一样,会发生死锁

分布式锁

有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。

针对分布式锁的实现,目前比较常用的有以下几种方案:

  1. 基于数据库实现分布式锁
  2. 基于缓存实现分布式锁
  3. 基于Zookeeper实现分布式锁

实现细节请参考这篇文章:分布式锁的几种实现方式

参考书籍:

深入理解java虚拟机 jvm高级特效与最佳实践 第二版

参考文章:

浅谈偏向锁、轻量级锁、重量级锁

Java锁---偏向锁、轻量级锁、自旋锁、重量级锁

一张图读懂非公平锁与公平锁

mysql数据库的锁有多少种,怎么编写加锁的sql语句

深入理解Java并发之synchronized实现原理