并发编程 | 锁 - 并发世界的兜底方案

865 阅读15分钟

引言

在我们的并发编程旅程中,我们必将遇到各种挑战和困难。线程间的同步数据的一致性以及并发中的竞态条件等问题,都是我们必须要面对并解决的。而解决这些问题的关键,往往就是使用锁。锁是我们在并发世界中的守护者,它能帮助我们在并发世界混乱时,找到一丝秩序,保证代码的正确性和一致性。

然而,锁并不是万能的,错误的使用锁可能会引发死锁饥饿等问题。因此,如何正确地使用锁,避免这些并发问题,就显得尤为重要。在这篇博客中,我们将一起探讨锁在并发编程中的角色,学习如何正确地使用锁,以及如何避免常见的并发问题。接下来,让我们共同探讨锁。


入门 | 基本概念

1. 锁的定义

在并发编程中,锁是一种同步机制,用于在多个线程间实现对共享资源的独占访问。当一个线程需要访问一个被其他线程占用的资源时,这个线程就会被阻塞,直到锁被释放。通过这种方式,锁确保了同一时间只有一个线程可以修改共享资源。

2. 锁的作用

锁的主要作用是为了保证数据一致性和防止数据竞争。在没有锁的情况下,如果两个线程同时修改同一份数据,可能会导致数据不一致的情况,这被称为数据竞争。通过使用锁,我们可以保证任何时刻只有一个线程修改数据,从而避免了数据竞争。

3. Java中的内置锁和显式锁

在Java中,我们可以使用synchronized关键字来创建内置锁,也可以使用java.util.concurrent.locks包中的Lock接口和ReentrantLock类来创建显式锁。

内置锁:synchronized

Java语言提供了内置的锁机制,这种锁也被称为监视器锁。我们可以通过synchronized关键字来创建和使用内置锁。synchronized可以修饰方法或者代码块。

显式锁:Lock和ReentrantLock

Java并发库还提供了更强大的锁机制,即显式锁。显式锁是通过代码显式地获取和释放锁。相比于synchronized,显式锁提供了更多的灵活性,比如可以尝试获取锁,如果无法立即获取锁,线程可以决定等待还是放弃。

3. 锁的分类

锁可以根据多个标准进行分类,如下所示:

按所有权分

独占锁 / 排他锁 独占锁是指该锁一次只能被一个线程所持有。在Java中,ReentrantLock和synchronized都是独占锁。它保证了每次只有一个线程执行同步代码,它的优点是避免了并发和线程安全问题,缺点是可能会引起线程阻塞。

共享锁 共享锁是指该锁可被多个线程所持有。对于Java中的ReentrantReadWriteLock,它的读锁是共享锁,写锁是独占锁。读锁的共享锁可以保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

按锁的状态分

可重入锁 可重入锁,也叫做递归锁,指的是一个线程已经拥有某个锁,可以无阻塞的再次请求这个锁。也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可以减少死锁的发生。

非重入锁 非重入锁,指的是锁不可以被一个已经拥有它的线程多次获取。在Java中,synchronized和ReentrantLock都不属于非重入锁。

按照操作分

公平锁 公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。Java中的ReentrantLock可以通过构造函数指定是否为公平锁,默认是非公平锁。

非公平锁 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发的情况下,有可能会造成优先级反转或者饥饿现象。


入门 | 如何使用锁

使用synchronized关键字

synchronized是Java内置的一种原始锁机制。我们可以通过在方法或者代码块上添加synchronized关键字来使用它。

synchronized方法

public class SynchronizedDemo {
    public synchronized void method() {
        // 业务逻辑代码
    }
}

在这个例子中,我们在方法method上添加了synchronized关键字。这意味着当一个线程进入这个方法时,它将会获取到这个对象的锁,其他任何线程都无法进入这个方法,直到这个线程退出这个方法,释放这个对象的锁。

synchronized代码块

public class SynchronizedDemo {
    private Object lock = new Object();
    
    public void method() {
        synchronized (lock) {
            // 业务逻辑代码
        }
    }
}

在这个例子中,我们在代码块上添加了synchronized关键字。这意味着当一个线程进入这个代码块时,它将会获取到lock对象的锁,其他任何线程都无法进入这个代码块,直到这个线程退出这个代码块,释放lock对象的锁。

使用ReentrantLock类

ReentrantLock是java.util.concurrent包提供的一种锁机制。它提供了与synchronized相同的互斥性和内存可见性,但是添加了类似锁投票、定时锁等候和锁中断等更多功能。

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDemo {
    private final ReentrantLock lock = new ReentrantLock();

    public void method() {
        lock.lock();
        try {
            // 业务逻辑代码
        } finally {
            lock.unlock();
        }
    }
}

在这个例子中,我们创建了一个ReentrantLock对象lock。当一个线程进入method方法时,它将会调用lock.lock()获取锁,其他任何线程都无法通过lock.lock(),直到这个线程调用lock.unlock()释放锁。


进阶 | 锁的工作原理

对于Java并发锁的工作原理,我们将以synchronizedReentrantLock为例进行说明。

synchronized的工作原理

synchronized是依赖于JVM底层来实现的,其主要的执行原理可以分为三部分:

  1. 获取锁: 当一个线程请求获取synchronized锁时,JVM首先检查这个锁的状态,即是否被其他线程持有。如果当前没有其他线程持有这个锁,那么请求的线程就会成功获取到这个锁。如果锁已经被其他线程持有,那么请求的线程就会进入阻塞状态,直到锁被释放。

  2. 锁定: 当一个线程获取到synchronized锁后,它将进入到锁定状态。在锁定状态下,这个线程可以自由地访问同步代码区域,其他任何线程都无法访问。

  3. 释放锁: 当一个线程完成同步代码区域的执行后,它将释放持有的synchronized锁。此时,如果有其他线程正在等待这个锁,JVM会选择其中一个线程,将锁分配给它,使其从阻塞状态变为运行状态。

ReentrantLock的工作原理

ReentrantLock的工作原理在很大程度上与synchronized相似,但是它更加灵活,提供了更多的功能。

  1. 获取锁: 当一个线程调用lock()方法请求获取锁时,ReentrantLock会首先检查锁的状态。如果锁当前未被其他线程持有,那么请求的线程将成功获取到锁。如果锁已经被其他线程持有,那么请求的线程将会被阻塞,直到锁被释放。

  2. 锁定: 当一个线程获取到锁后,它将进入到锁定状态。在锁定状态下,这个线程可以自由地访问被锁保护的代码,其他任何线程都无法访问。

  3. 释放锁: 当一个线程完成被锁保护的代码执行后,它需要手动调用unlock()方法来释放锁。此时,如果有其他线程正在等待这个锁,ReentrantLock会选择其中一个线程,将锁分配给它,使其从阻塞状态变为运行状态。

重入:无论是synchronized还是ReentrantLock,它们都支持重入,即在持有锁的线程内,可以多次无阻塞地获取同一把锁。


入门 | 锁如何解决原子性问题

在我们讨论了并发编程的基本概念和锁的基本工作原理后,让我们来深入探讨一下,锁是如何解决并发编程中的关键问题之一 - 原子性问题的。

在并发编程中,原子性是指一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。如果一个操作符合这个定义,我们就可以说它是原子操作。但是在实际编程中,很多操作并不能满足原子性,例如count++就不是一个原子操作,因为它实际上包含了三个步骤:读取count的值,对count加一,把新的值写回count

为了解决这个问题,Java提供了锁机制,包括synchronized关键字和Lock接口。锁的基本工作原理是,当一个线程要执行一个锁住的代码块时,它必须先获得锁,如果锁已经被其他线程持有,那么它就会进入等待状态,直到其他线程释放锁。这就保证了在同一时刻,只有一个线程能够执行锁住的代码块,也就实现了原子性。

那么,让我们来看一个使用锁实现原子性的例子:

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

在这个例子中,我们使用synchronized关键字来锁住increment方法。这样,就能保证在同一时刻,只有一个线程可以执行increment方法,实现了count++的原子性。

对于更复杂的场景,我们也可以使用Lock接口。例如,ReentrantLock就是一个支持重入的锁,它可以在同一个线程内多次获取,这对于复杂的并发操作非常有用。

通过以上的解释,我们可以看出锁是如何通过保证同一时间只有一个线程访问特定代码块来解决原子性问题的。但请记住,虽然锁可以解决原子性问题,但并不能保证线程安全,因为它并不能解决可见性和有序性这两个问题。

在介绍了锁的基本概念和如何解决原子性问题之后,我们可以开始介绍更高级的并发控制机制,比如管程。以下是可能的内容。


入门 | 管程和管程模型

我们刚刚讨论了如何使用锁解决原子性问题,现在我们来探讨一种更高级的并发控制机制 - 管程。

管程(Monitor)是一种同步机制,比锁提供了更高级的抽象。管程包含了一组预定义的程序和数据结构(比如共享变量和锁)的集合,这些程序只能被一个线程一次执行。这种一次性的特性,就保证了在一个时刻只有一个线程可以访问管程的资源,从而避免了并发冲突。

管程模型有两个关键部分,一个是互斥性(Mutual exclusion),这意味着任意时刻只允许一个线程执行管程中的一段代码。另一个是条件同步(Conditional synchronization),这意味着允许一个线程等待某个条件,直到这个条件满足时,线程才被唤醒继续执行。

以下是一个简单的管程的Java实现:

public class MonitorExample {
    private int a = 0;
    private boolean condition = false;

    public synchronized void method1() {
        while (!condition) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        // 管程中的操作
        a++;
    }

    public synchronized void method2() {
        // 改变条件并唤醒等待的线程
        condition = true;
        notifyAll();
    }
}

在上述代码中,MonitorExample类实现了一个管程。这个管程包含一个共享变量a和一个条件conditionmethod1方法是一个同步方法,它等待conditiontrue。当conditiontrue时,它会增加a的值。method2方法也是一个同步方法,它改变condition的值并唤醒所有等待的线程。

我们可以看到,管程提供了一种有效的方式来处理并发程序中的复杂问题,使得编程变得更简单。然而,管程并不是银弹,我们仍然需要注意其他并发问题,例如死锁。在接下来的部分,我们将介绍死锁及其解决方案。


入门 | 了解死锁

死锁是指两个或者多个线程在执行过程中,由于竞争资源而造成的一种相互等待的现象,如果没有外力干涉那它们都将无法推进下去。 在Java中,死锁经常出现在多线程中,特别是在多个synchronized块中。一个典型的死锁例子如下:

public class DeadlockDemo {
    private static Object lock1 = new Object();
    private static Object lock2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lock1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1 over");
                }
            }
        }).start();

        new Thread(() -> {
            synchronized (lock2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("Thread 2 over");
                }
            }
        }).start();
    }
}

在这个例子中,两个线程分别持有lock1和lock2,然后各自尝试获取对方持有的锁,由于双方都不放弃自己持有的锁,所以形成了死锁。


入门 | 了解锁优化

锁优化是指通过一些手段,尽可能地减少锁的使用,提高并发性能。以下是Java中常见的锁优化技术:

  1. 锁消除: 编译器在运行时,对于那些不可能存在共享数据竞争的锁请求进行消除。如StringBuffer的append操作,虽然是synchronized方法,但在某些情况下,JVM会判断出无需加锁。
  2. 锁粗化: 编译器在运行时,将多个连续的锁合并为一个更大范围的锁,减少锁请求的次数,如在一个循环内对同一对象加锁。
  3. 轻量级锁: 在没有竞争的前提下,消耗更少的系统资源。当一个线程尝试获取一个已经被另一个线程获取的轻量级锁时,会进行锁升级,升级为重量级锁。
  4. 偏向锁: 偏向于第一个获取锁的线程,如果在接下来的运行过程中,该锁没有其他线程竞争,那么持有偏向锁的线程在接下来的同步块中,无需再进行同步。

入门 | 乐观锁和悲观锁

乐观锁和悲观锁不是具体的锁,而是指并发控制的两种策略。 乐观锁认为自己在使用数据时不会有其他线程修改数据,所以不会添加锁,只在更新数据时进行检查。如果发现数据已经被修改,那么操作会重新进行,直到成功为止。 悲观锁则相反,认为自己在使用数据时总会有其他线程来修改数据,因此在每次读写数据时都会加锁,这样可以确保数据的安全,但是付出的代价是并发性能。

实例分析

假设我们有一个银行账户类,它有一个余额字段balance,我们需要在多线程环境下保护这个字段的安全性。于是我们使用了synchronized关键字:

public class Account {
    private double balance;

    public synchronized void deposit(double money) {
        double newBalance = balance + money;
        try {
            Thread.sleep(10);   // 模拟此业务需要一段处理时间
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        balance = newBalance;
    }

    public double getBalance() {
        return balance;
    }
}

在这个例子中,我们在deposit方法上添加了synchronized关键字,保证了balance字段在多线程环境下的安全性。


常见面试题

  1. 描述一下你理解的synchronized关键字。
  2. 你有使用过ReentrantLock吗?它和synchronized有什么区别?
  3. 你了解什么是死锁吗?如何避免?
  4. 你了解锁的优化吗?如偏向锁、轻量级锁等。
  5. 你能解释一下什么是乐观锁和悲观锁吗?

参考文献

  1. Java 并发编程实战

总结

锁是并发编程中的一个重要概念,了解和掌握锁的使用和原理,能够帮助我们写出高效并且安全的并发代码。在编程时,要尽可能地减少锁的使用,避免死锁,选择适当的并发控制策略,这样才能保证程序的并发性能。 在掌握了基本的锁知识后,我们还需要了解一些锁的优化技术,如锁消除、锁粗化、轻量级锁和偏向锁等。这些优化技术能够在不降低程序安全性的前提下,提高程序的并发性能。 最后,希望这篇文章能够帮助你对并发编程中的锁有更深入的理解和应用。在日常的编程和面试中,锁都是一个重要的话题,希望你能够通过学习,熟练掌握并使用它。