一、锁的概念
在 Java 多线程编程中,锁机制是确保线程安全的重要手段,为了解决多线程环境下对共享资源的访问冲突而设计的一系列同步控制机制。Java 提供了多种锁机制,每种锁都有其特定的使用场景和优缺点。
二、锁的作用
- 确保线程安全:锁机制可以防止多个线程同时访问共享资源导致的数据不一致问题。
- 控制对共享资源的访问:锁可以确保在同一时间只有一个线程可以访问共享资源,或者允许多个线程以特定的方式(如读写锁)访问共享资源。
- 实现线程同步:锁机制可以用于实现线程之间的同步,确保线程按照预期的顺序执行。
三、锁的种类
在Java中,锁类型可以根据不同的分类标准进行划分。以下是一些常见的锁类型及其特点:
-
按照锁的级别划分
- 内置锁(synchronized):
- Java语言内置的锁机制,通过关键字synchronized实现。
- 可以修饰方法或代码块,确保同一时间只有一个线程能够执行被锁定的代码。
- 简单易用,但灵活性相对较低。
- 显式锁(Explicit Lock,如ReentrantLock):
- Java并发包java.util.concurrent.locks中提供的锁类,如ReentrantLock。
- 提供了比synchronized更丰富的功能,如手动释放锁、可中断的锁获取、尝试获取锁、超时获取锁以及公平锁与非公平锁的选择等。
- 内置锁(synchronized):
-
按照锁的用途划分
- 读写锁(ReadWriteLock):
- 允许多个线程同时读取共享资源,但在写入时必须独占访问。
- 适用于读多写少的场景,可以提高并发性能。
- Java中的实现类是ReentrantReadWriteLock。
- 条件锁(Condition):
- 与显式锁配合使用,提供了更灵活的线程等待和通知机制。
- 通过Lock对象的newCondition()方法创建。
- 读写锁(ReadWriteLock):
-
按照锁的特性划分
- 公平锁:
- 按照线程请求锁的顺序来获取锁,即先请求的线程先获得锁。
- 避免了线程饥饿问题,但可能会导致性能下降。
- 非公平锁:
- 不保证线程请求锁的顺序,可能会存在“插队”现象。
- 有可能先申请的线程一直获取不到锁,从而可能会造成饥饿现象。
- 通常性能较高,因为减少了线程切换的开销。
- 可重入锁:
- 允许一个线程多次获取同一个锁而不会发生死锁。
- synchronized和ReentrantLock都是可重入锁。
- 乐观锁与悲观锁:
- 乐观锁:假设不会发生并发冲突,在提交数据时检查是否冲突,如果不冲突则提交,否则回滚并重试。Java中的StampedLock提供了乐观读锁。
- 悲观锁:假设一定会发生并发冲突,因此先获取锁再操作共享资源。synchronized和ReentrantLock都是悲观锁。
- 公平锁:
-
其他锁类型
- 自旋锁:
- 线程在等待锁时不是简单地阻塞,而是执行一个忙循环(自旋)等待锁被释放。
- 适用于锁持有时间较短的场景,可以避免线程切换的开销,缺点是循环会消耗CPU。
- 自旋锁:
四、Synchronized与ReentrantLock区别
Synchronized 和 ReentrantLock 是 Java 中两种常用的线程同步机制,它们都可以用于实现线程安全,但在实现方式、功能和性能上有一些区别。以下是它们的详细对比:
4.1 基本概念
- Synchronized
- synchronized 是 Java 的关键字,用于实现线程同步。
- 它可以修饰方法或代码块,确保同一时间只有一个线程可以执行被修饰的代码。
- ReentrantLock
- ReentrantLock 是 java.util.concurrent.locks 包中的一个类,提供了比 synchronized 更灵活的锁机制。
- 它需要显式地加锁和解锁。
4.2 实现方式
- Synchronized
- 基于 JVM 内置的监视器锁(Monitor Lock)实现。
- 使用简单,只需在方法或代码块前加上 synchronized 关键字。
- ReentrantLock
- 基于 ReentrantLock 类实现。
- 需要显式地调用 lock() 和 unlock() 方法。
4.3 功能对比
- 锁的获取方式
- Synchronized:
- 自动获取和释放锁。
- 不支持中断等待锁的线程。
- ReentrantLock:
- 需要显式地获取和释放锁。
- 支持中断等待锁的线程(lockInterruptibly())。
- Synchronized:
- 公平性
- Synchronized:
- 不支持公平锁,锁的获取是非公平的。
- ReentrantLock:
- 支持公平锁和非公平锁(默认是非公平锁)。
- 可以通过构造函数指定公平性:
- Synchronized:
- 条件变量
- Synchronized:
- 只能通过 wait()、notify() 和 notifyAll() 实现条件等待和通知。
- ReentrantLock:
- 支持多个条件变量(Condition),可以实现更复杂的线程协作。
- Synchronized:
- 锁的可重入性
- Synchronized:
- 支持可重入锁,同一个线程可以多次获取同一把锁。
- ReentrantLock:
- 也支持可重入锁,同一个线程可以多次获取同一把锁。
- Synchronized:
- 锁的尝试获取
- Synchronized:
- 不支持尝试获取锁。
- ReentrantLock:
- 支持尝试获取锁(tryLock()),可以设置超时时间。
- Synchronized:
4.4 性能对比
- Synchronized:
- 在 JDK 1.6 之后,synchronized 的性能已经大幅优化,与 ReentrantLock 的性能差距不大。
- 适合简单的同步场景。
- ReentrantLock:
- 在竞争激烈的情况下,ReentrantLock 的性能可能优于 synchronized。
- 适合需要高级功能的场景。
五、Synchronized
Synchronized是JVM层面的锁,是Java关键字,通过monitor对象来完成。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。
5.1 使用方式:
- 使用Synchronized关键字在方法或者代码块中声明。
对象锁:包括方法锁(默认锁对象为this,即当前实例对象)和同步代码块锁(自己指定锁对象)。 public synchronized void method(){} -->方法锁 Synchronized(object){} --> 同步代码块 类锁:指synchronized关键字修饰的静态方法或者指定锁对象为.class对象。 public synchronized static void method(){} --> synchronized 加在静态方法上 Synchronized(*.class){} --> 锁对象为class对象
5.2 对象锁与类锁的区别
- 对象锁
- 定义:
- 对象锁是基于实例对象的锁。每个 Java 对象都有一个内置锁(Monitor),synchronized 可以锁定一个具体的对象实例。
- 对象锁的作用范围是 实例级别,即只对同一个对象的同步代码块或方法有效。
- 特点:
- 锁的范围:只对同一个对象实例的同步代码有效。
- 多个实例互不影响:不同实例的对象锁是独立的,互不干扰。
- 适用场景:需要对实例级别的共享资源进行同步。
- 定义:
- 类锁
- 定义:
- 类锁是基于类的锁。每个类的 Class 对象都有一个内置锁,synchronized 可以锁定一个类的 Class 对象。
- 类锁的作用范围是 类级别,即对所有实例的同步代码块或方法有效。
- 特点:
- 锁的范围:对所有实例的同步代码有效。
- 全局唯一:类锁是全局唯一的,所有实例共享同一把锁。
- 适用场景:需要对类级别的共享资源进行同步。
- 定义:
5.3 synchronized 锁特点:
- 内置锁:synchronized 是 JVM 内置的锁机制,无需显式加锁和释放锁。
- 可重入:同一个线程可以多次获取同一把锁。
- 自动释放锁:当同步代码块执行完毕或发生异常时,锁会自动释放。
- 非公平锁:默认情况下,synchronized 是非公平锁,不保证线程获取锁的顺序。
5.4 synchronized 锁优缺点:
- 优点:
- 使用简单,无需手动管理锁的获取和释放。
- 由 JVM 管理,避免锁泄漏。
- 缺点:
- 功能相对简单,不支持超时、中断等高级特性。
- 性能较低,尤其是在高竞争场景下。
5.5 synchronized 锁实现原理
- JVM 层面的实现:synchronized 通过对象头和 Monitor 实现锁机制。
- 对象头与 Monitor
- 每个 Java 对象在内存中都有一个对象头(Object Header),其中包含与锁相关的信息。
- 对象头中有一个 Mark Word,用于存储对象的哈希码、锁状态、GC 分代年龄等信息。
- 当对象被 synchronized 锁定时,Mark Word 会指向一个 Monitor 对象(也称为管程或监视器锁)。
- 对象头与 Monitor
- 字节码层面的实现:synchronized 代码块通过 monitorenter 和 monitorexit 指令实现,方法通过 ACC_SYNCHRONIZED 标志实现。
- synchronized 修饰代码块
- 在字节码中,synchronized 代码块是通过 monitorenter 和 monitorexit 指令实现的。
- monitorenter:尝试获取对象的 Monitor。
- monitorexit:释放对象的 Monitor。
- synchronized 修饰方法
- 在字节码中,synchronized 修饰的方法会通过 方法访问标志位(ACC_SYNCHRONIZED)来实现同步。
- JVM 会在方法调用时自动获取锁,在方法返回时自动释放锁。
- synchronized 修饰代码块
- 操作系统层面的实现:重量级锁依赖于操作系统的互斥量和条件变量。
- 重量级锁
- 当多个线程竞争锁时,JVM 会将锁升级为 重量级锁。
- 重量级锁依赖于操作系统的 互斥量(Mutex) 和 条件变量(Condition Variable) 来实现线程的阻塞和唤醒。
- 线程在获取锁失败时,会进入阻塞状态,由操作系统管理线程的调度。
- 线程阻塞与唤醒
- 当线程无法获取锁时,JVM 会调用操作系统的 线程挂起(Park) 方法,将线程放入等待队列。
- 当锁被释放时,JVM 会调用操作系统的 线程唤醒(Unpark) 方法,从等待队列中唤醒一个线程。
- 重量级锁
5.6 synchronized 锁的优化
为了减少锁的开销,JVM 通过偏向锁、轻量级锁、自旋锁、锁消除和锁粗化等技术优化 synchronized 的性能。
- 偏向锁(Biased Locking)
- 目标:优化单线程重复获取锁的场景。
- 实现:在对象头中记录偏向的线程 ID,避免重复加锁和解锁的开销。
- 升级:当其他线程尝试获取锁时,偏向锁会升级为轻量级锁。
- 轻量级锁(Lightweight Locking)
- 目标:优化低竞争场景。
- 实现:通过 CAS 操作将对象头的 Mark Word 替换为指向线程栈中锁记录的指针。
- 升级:当 CAS 操作失败时,轻量级锁会升级为重量级锁。
- 自旋锁(Spin Locking)
- 目标:减少线程阻塞和唤醒的开销。
- 实现:线程在获取锁失败时,不会立即阻塞,而是循环尝试获取锁。
- 适用场景:锁竞争时间较短的场景。
- 锁消除(Lock Elimination)
- 目标:消除不必要的锁。
- 实现:JVM 通过逃逸分析(Escape Analysis)判断锁是否可以被消除。
- 锁粗化(Lock Coarsening)
- 目标:减少频繁加锁和解锁的开销。
- 实现:将多个连续的锁操作合并为一个更大的锁操作。
六、ReentrantLock
ReentrantLock 是 java.util.concurrent.locks 包中的一个类,实现了 Lock 接口。它提供了比 synchronized 更灵活的锁机制。
6.1 使用方式:
- 必须显式调用 lock() 和 unlock() 来管理锁。
ReentrantLock lock = new ReentrantLock(); public void method() { lock.lock(); // 加锁 try { // 同步代码 } finally { lock.unlock(); // 释放锁 } }
6.2 ReentrantLock 的核心方法
- lock():如果获取了锁立即返回,如果别的线程持有锁,当前线程则一直处于休眠状态,直到获取锁。
- tryLock():如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false。
- tryLock(long timeout,TimeUnit unit):如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false。
- lockInterruptibly():如果获取了锁立即返回,如果别的线程持有锁,则当前线程会中断等待获取锁的过程。
6.3 ReentrantLock 锁特点:
- 显式锁:需要手动加锁和释放锁。
- 可重入:同一个线程可以多次获取同一把锁。
- 公平锁与非公平锁:ReentrantLock 可以设置为公平锁(先等待的线程先获取锁)或非公平锁(默认)。
- 支持超时:可以尝试获取锁,如果超时则放弃。
- 支持中断:等待锁的线程可以被中断。
- 条件变量:支持多个条件变量(Condition),用于更复杂的线程协作。
6.4 ReentrantLock 锁优缺点:
-
优点:
- 功能强大,支持超时、中断、公平锁等特性。
- 性能较高,尤其是在高竞争场景下。
-
缺点:
- 使用复杂,需要手动管理锁的获取和释放。
- 容易忘记释放锁,导致锁泄漏。
6.5 ReentrantLock 锁实现原理
ReentrantLock 的核心实现原理基于 AQS(AbstractQueuedSynchronizer),这是一个用于构建锁和同步器的框架。下面从 AQS 的核心思想、ReentrantLock 的实现 和 公平锁与非公平锁 三个方面详细介绍 ReentrantLock 的实现原理。
-
AQS(AbstractQueuedSynchronizer)的核心思想
- 状态管理
- AQS 使用一个 volatile 的 int 类型的变量 state 来表示同步状态。
- 对于 ReentrantLock,state 表示锁的持有次数:
- state == 0:锁未被持有。
- state > 0:锁被持有,且 state 表示锁的重入次数。
- 对于 ReentrantLock,state 表示锁的持有次数:
- AQS 使用一个 volatile 的 int 类型的变量 state 来表示同步状态。
- 线程队列
- AQS 维护了一个 FIFO 双向队列(CLH 队列),用于存储等待获取锁的线程。
- 每个节点(Node)代表一个等待线程,节点中保存了线程的引用和状态。
- 获取锁与释放锁
- 获取锁:线程尝试通过 CAS 操作修改 state,如果成功则获取锁;如果失败,则进入队列等待。
- 释放锁:线程释放锁时,修改 state,并唤醒队列中的下一个线程。
- 状态管理
-
ReentrantLock 的实现
- 核心类结构
- ReentrantLock:对外暴露的锁接口。
- Sync:继承 AQS,实现了锁的核心逻辑。
- NonfairSync:非公平锁的实现。
- FairSync:公平锁的实现。
- 获取锁的过程
- 非公平锁(NonfairSync)
- 线程尝试通过 CAS 操作将 state 从 0 修改为 1。
- 如果成功,则获取锁,并将当前线程设置为锁的持有者。
- 如果失败,则调用 AQS 的 acquire() 方法,进入队列等待。
- 公平锁(FairSync)
- 线程首先检查队列中是否有等待的线程。
- 如果没有,则尝试通过 CAS 操作将 state 从 0 修改为 1。
- 如果成功,则获取锁,并将当前线程设置为锁的持有者。
- 如果失败,则调用 AQS 的 acquire() 方法,进入队列等待。
- 释放锁的过程
- 线程将 state 减 1。
- 如果 state == 0,则释放锁,并唤醒队列中的下一个线程。
- 非公平锁(NonfairSync)
- 核心类结构
6.6 公平锁与非公平锁
- 公平锁
- 特点:线程获取锁的顺序严格按照先到先得的原则。
- 优点:避免线程饥饿。
- 缺点:增加线程切换的开销,降低吞吐量。
- 实现代码:
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { 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; }
- 非公平锁
- 特点:线程获取锁的顺序不一定是先到先得,可能存在“插队”现象。
- 优点:减少线程切换的开销,提高吞吐量。
- 缺点:可能导致线程饥饿。
- 实现代码:
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { 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; }
七、如何预防死锁
7.1 死锁产生的四个必要条件
要预防死锁,首先需要理解死锁产生的四个必要条件。只有以下四个条件同时满足时,死锁才会发生:
- 互斥条件:资源一次只能被一个线程占用。
- 占有并等待:线程持有至少一个资源,并等待获取其他被占用的资源。
- 不可抢占:线程已占用的资源不能被其他线程强行抢占,只能由线程自己释放。
- 循环等待:存在一个线程等待的循环链,每个线程都在等待下一个线程占用的资源。
7.2 预防死锁的策略
-
破坏互斥条件
- 方法:将资源设计为可共享的,避免互斥访问。
- 局限性:并非所有资源都可以共享,例如写操作必须互斥。
-
破坏占有并等待条件
- 方法:要求线程一次性申请所有需要的资源,如果无法获取全部资源,则释放已占用的资源并重试。
- 实现:
- 使用 tryLock() 尝试获取锁,如果失败则释放已获取的锁。
- 使用资源分配算法(如银行家算法)确保资源分配的安全性。
- 示例:
ReentrantLock lock1 = new ReentrantLock(); ReentrantLock lock2 = new ReentrantLock(); public void method() { while (true) { if (lock1.tryLock()) { try { if (lock2.tryLock()) { try { // 成功获取两把锁,执行业务逻辑 return; } finally { lock2.unlock(); } } } finally { lock1.unlock(); } } // 未能获取全部锁,稍后重试 Thread.yield(); } }
-
破坏不可抢占条件
- 方法:允许线程抢占其他线程占用的资源。
- 实现:
- 使用 tryLock() 和超时机制,如果获取锁失败则释放已占用的锁。
- 使用 Lock 的 lockInterruptibly() 方法,允许线程在等待锁时被中断。
- 示例:
ReentrantLock lock1 = new ReentrantLock(); ReentrantLock lock2 = new ReentrantLock(); public void method() throws InterruptedException { while (true) { if (lock1.tryLock()) { try { if (lock2.tryLock()) { try { // 成功获取两把锁,执行业务逻辑 return; } finally { lock2.unlock(); } } } finally { lock1.unlock(); } } // 未能获取全部锁,稍后重试 Thread.sleep(100); } }
-
破坏循环等待条件
- 方法:对资源进行排序,要求线程按照固定的顺序申请资源。
- 实现:
- 为所有锁定义一个全局顺序,线程必须按照顺序申请锁。
- 避免线程以不同的顺序申请锁。
- 示例:
ReentrantLock lock1 = new ReentrantLock(); ReentrantLock lock2 = new ReentrantLock(); public void method1() { lock1.lock(); try { lock2.lock(); try { // 执行业务逻辑 } finally { lock2.unlock(); } } finally { lock1.unlock(); } } public void method2() { lock1.lock(); try { lock2.lock(); try { // 执行业务逻辑 } finally { lock2.unlock(); } } finally { lock1.unlock(); } }
-
其他预防死锁的策略
- 使用超时机制
- 方法:在获取锁时设置超时时间,如果超时则放弃锁并重试。
- 实现:使用 tryLock(long timeout, TimeUnit unit) 方法。
- 使用线程池
- 方法:通过线程池限制并发线程的数量,减少资源竞争。
- 实现:使用 ExecutorService 管理线程。
- 避免嵌套锁
- 方法:尽量减少锁的嵌套使用,避免复杂的锁依赖关系。
- 实现:重构代码,减少锁的层次。
- 使用超时机制