Java的锁机制(一)

298 阅读20分钟

@[toc]

1. 引言

1.1 目的

本文档旨在详细介绍Java中的锁机制,包括它们的工作原理、分类以及如何在并发编程中有效使用它们。

1.2 背景

在多线程环境中,多个线程可能会同时访问共享数据。若无适当的同步措施,这可能导致数据不一致性和竞态条件。Java提供了多种锁机制来管理这种并发访问,确保线程互斥访问共享资源,并提供可见性和有序性,从而保障多线程间的数据协调和同步。

2. 锁的概念

2.1 定义

在并发编程中,锁是一种同步机制,用于控制对共享资源的访问。当多个线程需要访问同一资源时,如果没有适当的同步措施,可能会导致数据不一致、竞态条件(race condition)或其他问题。锁提供了一种方法来确保在任何时间点,只有一个线程能够执行特定的代码段,从而保护共享资源的完整性和一致性。

2.2 必要性

在没有锁的情况下,多个线程可能会产生不可预测的结果,导致程序行为异常。

2.3 锁的特性

  1. 互斥性(Mutual Exclusion): 锁确保在某一时刻,只有一个线程能够进入临界区(critical section),即访问共享资源的代码段。

  2. 可见性(Visibility): 当一个线程释放锁时,其他线程能够立即看到这个变化,并且能够看到在锁定期间对共享资源所做的更改。

  3. 原子性(Atomicity): 锁的获取和释放操作是不可分割的,要么完全成功,要么完全不发生。

  4. 有序性(Ordering): 锁确保在锁被释放之前,所有依赖于锁的内存写入操作都已完成。

2.4 锁的基本原理

在Java中,锁的基本原理是通过监视器(monitor)实现的,当一个线程希望获取一个对象的锁时,它会试图进入这个对象的监视器,如果该监视器已经被其他线程占用,那么线程就会阻塞,直到锁可用。一旦线程获得了锁,它就可以对共享资源进行访问,并且其他线程将会被阻塞,直到持有锁的线程释放了锁。synchronized关键字和ReentrantLock类都是基于这个原理实现的。在synchronized中,锁是与对象关联的,而在ReentrantLock中,锁是一个独立的对象。两者都提供了同步代码块的能力,以确保同一时间只有一个线程可以访问关键部分代码。

2.4.1 监视器

监视器是Java中用于同步线程访问共享资源的一种机制。以下是监视器实现锁机制的详细流程:

  • 线程请求锁: 当线程需要访问共享资源时,它首先请求锁。
  • 检查锁状态: 监视器检查锁是否已被其他线程持有。
  • 获取锁: 如果锁未被持有,请求线程获得锁并进入临界区。
  • 阻塞等待: 如果锁已被其他线程持有,请求线程将被阻塞,等待锁被释放。
  • 执行同步代码: 获得锁的线程执行同步代码,访问共享资源。
  • 释放锁: 线程完成同步代码执行后,释放锁,允许其他线程请求锁。
  • 唤醒等待线程: 锁被释放时,监视器唤醒一个或多个等待锁的线程。在这里插入图片描述

3. 锁与Java内存模型(JMM)的关系

3.1 概述

Java内存模型定义了Java程序中共享变量的访问规则,确保在并发环境下,对共享变量的访问是线程安全的。在并发环境下,JMM是围绕着原子性、可见性、和有序性这三个概念, 确保数据的可见性、有序性和原子性,以及线程间的协作。

3.1.1 JVM的主要概念

  1. 主内存(Main Memory): 所有线程共享的内存区域,用于存储共享变量。

  2. 工作内存(Working Memory): 每个线程有自己的工作内存,存储主内存中变量的副本。

  3. 读写操作: 线程对共享变量的所有操作都必须在工作内存中进行,然后同步回主内存。

3.2 对锁机制的影响

锁机制必须遵循JMM的规则,以确保操作的原子性、可见性和有序性。

  1. 原子性: 锁机制确保了临界区代码的原子性。当线程进入同步块时,它必须首先获得锁,这个操作是原子的。同样,当线程离开同步块时,它释放锁,这个操作也是原子的。

  2. 可见性: 当一个线程释放了锁,其他线程能够看到这个锁已经被释放,并且能够看到在锁定期间对共享变量所做的更改。这是因为锁的释放会清空工作内存中的变量副本,并从主内存中重新读取最新的值。

  3. 有序性: 在没有锁的情况下,Java编译器和处理器可能会对代码进行重排序以优化性能。但是,当代码在同步块中时,进入同步块之前的代码不能被重排序到同步块之后,反之亦然。这确保了在锁的保护下,代码的执行顺序与编写顺序一致。

  4. 锁的粒度: 锁的粒度影响内存操作的开销。细粒度锁(例如,只锁定单个变量)可以减少锁的竞争,提高并发性能,但可能需要更复杂的逻辑来维护数据一致性。

4. 锁的分类

在这里插入图片描述

4.1 按锁性质划分,有乐观锁和悲观锁。

  • 乐观锁:认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果没有被更新过,则将自己的数据写入,否则不写入。Java 中主要是通过 CAS 算法来实现乐观锁的。
  • 悲观锁:总是假设最坏的情况,每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。Java 中 synchronizedReentrantLock 等独占锁就是悲观锁思想的实现。AQS的原理是悲观锁。

4.2 按锁被持有数量划分,有独占锁和共享锁。

  • 独占锁:当前锁只有被一个线程持有。例如:ReentrantLock锁;
  • 共享锁:当前锁可以被多个线程持有。例如:Semaphore等。

4.3 按公平性划分,有公平锁和非公平锁。

  • 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。例如:ReentrantLock公平锁。
  • 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。例如:Synchronized锁、ReentrantLock锁。

4.4 按可重入性划分,有可重入锁和不可重入锁。

  • 可重入锁:允许一个线程多次加锁。例如:Synchronized锁、ReentrantLock锁。
  • 不可重入锁:允许一个线程仅加锁一次。

4.5 按锁范围划分,有单体锁和分布式锁。

  • 单体锁:仅能锁住当前JVM进程中的共享资源,对其他JVM进程中的共享资源不起作用。例如: Synchronized锁和ReentrantLock锁;
  • 分布式锁:借助中间件,对多个JVM进程中的同一共享资源都能锁住。例如:Redis分布式锁。

5. 内置锁(Synchronized)

5.1 使用方法

synchronized关键字是一种用于实现线程同步的机制,它可以确保同一时刻只有一个线程能够执行特定代码段。synchronized可以用在方法和代码块上。

5.1.1 使用synchronized同步方法

当使用synchronized关键字同步一个方法时,整个方法是同步的。这意味着同一时间只有一个线程能够执行该方法。

  • 同步实例方法:同步实例方法锁定的是当前实例对象(this)。
public synchronized void myMethod() {
    // 方法体
}
  • 同步静态方法:同步静态方法锁定的是整个类的Class对象。
public static synchronized void myStaticMethod() {
    // 方法体
}

5.1.2 使用synchronized同步代码块

在某些业务场景下,只需要同步类中的一小部分代码,而不是整个方法。这时可以使用同步代码块。

  • 同步实例方法的代码块:锁定的是指定的实例对象。
public void myMethod() {
    synchronized (this) {
        // 只有持有this锁的线程可以执行这里的代码
    }
}
  • 同步静态方法的代码块:锁定的是指定的Class对象。
public static void myStaticMethod() {
    synchronized (MyClass.class) {
        // 只有持有MyClass.class锁的线程可以执行这里的代码
    }
}
  • 同步任何对象的代码块:选择锁定对象,这中方式提供了更大的灵活性。
public void myMethod() {
    Object lock = new Object();
    synchronized (lock) {
        // 只有持有lock对象的线程可以执行这里的代码
    }
}

5.2 实现原理

Synchronized的底层原理是采用Java对象头来存储锁信息的,并且还支持锁升级。

5.2.1 对象头

Java对象头包含三部分,分别是Mark WordClass Metadata AddressArray length

  • Mark Word 用来存储独享的HashCode及锁信息。 锁信息包括锁的标志和锁的状态。
  • Class Metadata Address用来存储对象类型的指针。
  • Array length则用来存储数组对象的长度。如果对象不是数组类型,则没有Array length信息。

5.2.2 锁升级

synchronized的锁升级的过程中,包括无锁状态、偏向锁状态、轻量锁状态、重量锁状态。这些步骤是为了在多线程并发情况下保证数据的安全性和一致性。其中,锁的状态会根据线程竞争的情况逐步升级,以适应多种不同的并发场景。

  • 无锁状态:当没有线程持有锁时,代表任何线程都可以获取到这个锁并持有它。

  • 偏向锁状态:当只有一个线程访问同步块并获取对象的锁时,会将锁的标记记录在线程的栈帧中,并将对象头中的Thread ID设置为当前线程的ID。当这个线程再次请求相同对象的锁时,虚拟机会使用已经记录的锁标记,而不需要再次进入同步块。

  • 轻量级锁状态:当多个线程竞争同一个锁时,偏向锁会升级为轻量级锁。轻量级锁的竞争是基于自旋CAS操作来实现的,如果自旋CAS成功,代表获取锁成功,失败则升级为重量级锁。

  • 重量级锁状态:当自旋CAS操作一直失败时,锁会升级为重量级锁,这时会使用操作系统的互斥量来保证同步。重量级锁状态下,线程会进入阻塞状态,性能相对较低。

简单来说,从偏向锁到轻量级锁再到重量级锁,是一种逐步升级的过程,适应了不同类型的并发竞争情况。

1)当只有一个线程去争抢锁的时候,会先使用偏向锁,就是给一个标识,说明现在这个锁被线程a占有。

2)后来又来了线程b,线程c,说凭什么你占有锁,需要公平的竞争,于是将标识去掉,也就是撤销偏向锁,升级为轻量级锁,三个线程通过CAS自旋进行锁的争抢(其实这个抢锁过程还是偏向于原来的持有偏向锁的线程)。

3)现在线程a占有了锁,线程b,线程c一直在循环尝试获取锁,后来又来了十个线程,一直在自旋,那这样等着也是干耗费CPU资源,所以就将锁升级为重量级锁,向内核申请资源,直接将等待的线程进行阻塞。 在这里插入图片描述

6. 显式锁(ReentrantLock)

6.1 Lock接口

java.util.concurrent.locks.Lock接口是Java并发包中提供的一个用于锁操作的高级接口。这个接口提供了比synchronized关键字更丰富的锁操作,例如尝试非阻塞获取锁、可中断的锁获取、以及尝试超时获取锁等。Lock接口的实现类ReentrantLock是这些高级特性的一个具体实现。

6.1.1 使用方法

  • lock(): 获取锁。如果锁不可用,调用线程将被阻塞,直到锁被另一个线程释放。
  • lockInterruptibly(): 可中断地获取锁。如果当前线程在尝试获取锁的过程中被中断,该方法将抛出InterruptedException。
  • tryLock(): 尝试非阻塞地获取锁。如果锁可用,则获取锁并立即返回true;如果锁不可用,则立即返回false。
  • tryLock(long timeout, TimeUnit unit): 尝试在给定的等待时间内获取锁。如果在指定的时间内锁变为可用,获取锁并返回true;如果超时,返回false。
  • unlock(): 释放锁。
  • newCondition(): 创建与此锁关联的条件变量。条件变量可以用于实现等待/通知模式。

6.2 高级特性

ReentrantLock是Lock接口的一个实现,它提供了与synchronized关键字类似的可重入锁功能。ReentrantLock有几个特点:

  • 可重入:与synchronized一样,ReentrantLock也支持可重入性,即同一个线程可以多次获取同一个锁。
  • 公平性ReentrantLock允许指定锁的公平性。公平锁按照线程请求锁的顺序来获取锁,而非公平锁则允许线程在任何时候尝试获取锁。
  • 条件变量ReentrantLock提供了条件变量的支持,允许更灵活的线程间通信。

6.2.1 ReentratLock的底层原理

Java中的ReentrantLockSemaphoreCountDownLatch等同步器的实现使用了 Abstract Queued Synchronizer(抽象的队列式同步器)作为其底层同步机制。

AQS 使用一个整数(state)来表示同步状态,通过原子操作对这个状态进行更改。

它基于 FIFO(先进先出)队列来管理那些获取锁失败的线程,这些线程将会被添加到队列中,并在适当的时候被唤醒。

6.2.2 ReentrantLock尝试非阻塞获取锁

可以使用tryLock()方法尝试获取锁,如果锁不可用则立即返回false,而不是等待。

Lock lock = new ReentrantLock();
if (lock.tryLock()) {
    try {
        // 执行需要同步的代码
    } finally {
    	// 释放锁
        lock.unlock(); 
    }
} else {
    // 处理无法获取锁的情况
}

6.2.3 可中断的锁获取

可以使用lockInterruptibly()方法,在尝试获取锁的过程中允许响应中断。

Lock lock = new ReentrantLock();
try {
	// 可中断的获取锁
    lock.lockInterruptibly(); 
    // 执行需要同步的代码
} catch (InterruptedException e) {
    // 处理线程中断异常
	// 恢复中断状态
    Thread.currentThread().interrupt(); 
} finally {
    if (lock.isHeldByCurrentThread()) {
    	// 释放锁
        lock.unlock(); 
    }
}

6.2.4 超时获取锁

可以使用tryLock(long timeout, TimeUnit unit)方法尝试在指定的时间内获取锁。

Lock lock = new ReentrantLock();
boolean isLocked = false;
try {
		
    isLocked = lock.tryLock(1000, TimeUnit.MILLISECONDS); 
    if (isLocked) {
        // 执行需要同步的代码
    } else {
        // 处理超时情况,未能获取锁
    }
} catch (InterruptedException e) {
    // 处理线程中断异常
    // 恢复中断状态
    Thread.currentThread().interrupt(); 
} finally {
    if (isLocked) {
    	// 释放锁
        lock.unlock(); 
    }
}

6.2.5 使用公平锁

创建一个公平的ReentrantLock实例,按照请求锁的顺序来授予锁。

// true 表示公平锁
Lock fairLock = new ReentrantLock(true); 
// 获取锁
fairLock.lock(); 
try {
    // 执行需要同步的代码
} finally {
	// 释放锁
    fairLock.unlock(); 
}

6.2.6 使用条件变量

使用newCondition()创建与锁相关联的条件变量,实现等待/通知模式。

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
lock.lock();
try {
    // 等待条件满足
    while (!conditionSatisfied) {
    	// 等待
        condition.await(); 
    }
    // 条件已满足,执行相关操作
} catch (InterruptedException e) {
    // 处理中断异常
} finally {
    lock.unlock();
}

// 其他线程
lock.lock();
try {
    // 改变条件状态
    conditionSatisfied = true;
    // 通知所有等待的线程
    condition.signalAll(); 
} finally {
    lock.unlock();
}

7. 共享锁

7.1 Semaphore(信号量)

Semaphore是一个计数信号量,用来控制同时访问某个特定资源的线程数量。它通过一个整数值来表示可用的许可证数量。

  • 释放许可证(release): 当线程完成对共享资源的访问时,Semaphore的计数器会增加,表示释放了一个许可证。
  • 获取许可证(acquire): 线程在访问资源前需要获取一个许可证,如果计数器大于0,计数器减1并继续执行;如果计数器为0,则等待,直到有许可证被释放。
// 创建一个初始许可证数量为2的Semaphore
Semaphore semaphore = new Semaphore(2); 

try {
	// 获取一个许可证
    semaphore.acquire(); 
    // 访问共享资源
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} finally {
	// 释放许可证
    semaphore.release(); 
}

7.2 CountDownLatch(倒计时门闩)

CountDownLatch是用来协调一个或多个线程在继续执行之前等待一组事件发生的同步辅助工具。它持有一个给定的计数初始值,在CountDownLatch实例被创建时指定。

  • 计数减1(countDown): 每当一个事件完成时,调用countDown()方法,这会将计数减1。
  • 等待(await): 调用await()方法的线程会阻塞,直到CountDownLatch的计数达到0。
CountDownLatch latch = new CountDownLatch(1);

// 启动线程执行任务
new Thread(() -> {
    // 任务完成后计数减1
    latch.countDown();
}).start();

// 等待计数达到0
latch.await();
// 继续执行后续任务

7.3 CyclicBarrier(循环屏障)

CyclicBarrier允许一组线程相互等待,直到所有线程都达到了某个公共屏障点。它与CountDownLatch类似,但CyclicBarrier可以重用,它在释放等待线程后可以被重置。

  • 等待(await): 线程调用await()方法后会阻塞,直到所有线程都到达屏障点。
  • 重置屏障: 一旦所有线程都到达屏障,CyclicBarrier可以被重置,以便再次使用。
CyclicBarrier barrier = new CyclicBarrier(2, () -> {
    // 所有线程到达屏障后会执行的代码
});

// 线程1
barrier.await();

// 线程2
barrier.await();

8. 锁的性能和优化

8.1 锁的开销

锁在多线程编程中是确保数据一致性和线程安全的关键机制,但它们也带来了一定的开销。

  1. 上下文切换(Context Switching): 当一个线程释放锁并唤醒另一个线程时,可能会发生上下文切换,这涉及到保存和加载线程的执行环境,这会消耗CPU周期。

  2. 线程阻塞和唤醒(Thread Blocking and Waking): 锁的获取过程中,如果锁被其他线程持有,当前线程可能会进入阻塞状态,等待锁被释放。线程从阻塞状态到就绪状态的转换需要时间。

  3. 资源等待(Resource Waiting): 线程在等待锁的过程中不能执行,这意味着CPU不能执行其他有用的工作,导致资源的浪费。

  4. 死锁(Deadlock): 不恰当的锁使用可能导致死锁,当多个线程相互等待对方持有的锁时,这些线程将无法继续执行,造成资源的长时间占用。

  5. 活锁(Livelock): 活锁是另一种同步问题,线程不断尝试获取锁,但总是因为其他线程的需要而放弃,导致没有实际进展。

  6. 优先级反转(Priority Inversion): 当一个高优先级的线程等待一个低优先级线程持有的锁时,可能会出现优先级反转,影响系统的响应性和吞吐量。

  7. 锁竞争(Lock Contention): 在高并发场景下,多个线程可能频繁地请求同一个锁,增加了锁的争用,降低了系统的并发性能。

8.2 锁优化技术

锁优化技术是一系列用于提高并发程序性能的方法,特别是在多线程环境中减少锁的开销。

  1. 自旋锁与自适应自旋: 自旋锁是一种让线程在获取锁时执行忙循环(自旋)而不是立即阻塞的技术。自适应自旋根据前一次自旋的成功率和锁的拥有者状态来调整自旋时间。

  2. 轻量级锁: 轻量级锁是JDK 1.6中引入的,旨在减少没有锁竞争时的同步开销。它使用CAS操作尝试获取锁,如果存在竞争,则可能升级为重量级锁。

  3. 偏向锁: 偏向锁是JDK 1.6中引入的,它偏向于第一个获取它的线程。如果没有竞争,持有偏向锁的线程将无需进行同步。

  4. 锁粗化: 锁粗化是将多个细粒度的锁操作合并为一个更大范围的锁,以减少锁请求的次数和锁状态的检查开销。

  5. 锁消除: 锁消除是编译器在运行时识别出不存在共享数据竞争的锁请求,并消除这些锁,从而提高性能。

  6. 锁降级: 锁降级是将一个高级别的锁(如重量级锁)降级为低级别的锁(如轻量级锁或偏向锁),以减少锁的开销。

  7. 细粒度锁: 细粒度锁是指使用多个锁来保护不同的资源,而不是使用单一的锁来保护所有资源,从而提高并发性。

  8. 锁分离: 锁分离是将锁的获取和释放操作分离,以减少锁的持有时间和锁竞争。

  9. 读写锁: 读写锁允许多个读操作同时进行,但写操作是排他的,这可以提高读多写少场景下的并发性能。

  10. 锁的分类和使用: 根据锁的特性(如公平性、可重入性、共享性等)选择适当的锁类型,以及正确地使用锁来避免死锁和饥饿问题。

9. 死锁和避免策略

9.1 死锁的概念

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

9.1.1 死锁产生的必要条件

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

9.2 避免死锁

  1. 锁顺序: 为所有锁分配一个全局顺序,并确保所有线程都按照这个顺序来获取锁。
  2. 锁超时: 为锁的获取设置超时时间。如果超时,则释放所有已持有的锁,并重试。
  3. 避免嵌套锁: 尽量避免嵌套使用锁,因为嵌套锁增加了锁的顺序复杂性,可能导致循环等待。
  4. 使用定时锁: 使用支持定时功能的锁,如tryLock()方法,来避免无限期地等待资源。
  5. 锁粗化: 将多个细粒度的锁合并为一个粗粒度的锁,以减少锁的请求次数和死锁的可能性。
  6. 锁分解: 将一个大的锁分解为多个小的锁,这样线程可以更细粒度地控制资源。
  7. 避免线程持有锁的时间太长: 尽量缩短线程持有锁的时间,例如,只在临界区代码中保持锁。
  8. 使用高级并发工具:使用java.util.concurrent包中的高级并发工具,如ReentrantLockSemaphore等,它们提供了更灵活的锁机制。
  9. 锁的公平性:考虑使用公平锁,以确保线程按照请求锁的顺序获得锁,减少饥饿现象。
  10. 避免死循环:在设计资源分配策略时,避免产生循环依赖,这可以减少死锁的可能性。
  11. 测试和监控:在开发和测试阶段使用工具检测死锁,并在生产环境中监控死锁。