文章目录
概述
锁的概念
锁,作为一种并发控制机制,被广泛应用于计算机系统中,特别是在多线程或多进程环境下。它的主要功能是协调多个线程或进程对共享资源的访问,以确保数据的一致性和完整性。通过锁机制,我们可以避免多线程同时修改同一数据而导致的冲突或数据不一致问题。
锁的种类
锁的种类丰富多样,根据应用场景和具体需求的不同,可以选择合适的锁类型。以下是一些常见的锁种类:
-
互斥锁(Mutex):
- 也称为二进制锁或排他锁。
- 在任意时刻,只有一个线程可以持有互斥锁。
- 常用于保护临界区,确保同一时间只有一个线程能够执行临界区内的代码。
-
读写锁(Read-Write Lock):
- 允许多个线程同时读取数据,但写入数据时需要独占访问权。
- 分为共享锁(读锁)和排他锁(写锁)。
- 适用于读多写少的场景,可以提高并发性能。
-
乐观锁(Optimistic Locking):
- 假设并发冲突不会发生,只在更新数据时检查版本或时间戳。
- 如果数据在读取后未被其他线程修改,则更新成功;否则,更新失败并重新尝试。
- 适用于冲突较少的场景,避免了锁的开销。
-
悲观锁(Pessimistic Locking):
- 假设并发冲突随时可能发生,因此在读取数据时就加锁。
- 确保了数据的一致性,但可能导致较高的锁开销和等待时间。
- 适用于写多读少或冲突频繁的场景。
-
自旋锁(Spinlock):
- 当线程尝试获取锁失败时,不会立即阻塞,而是采用循环等待的方式不断尝试获取锁。
- 适用于锁持有时间较短的场景,可以避免线程上下文切换的开销。
- 但如果锁持有时间较长,自旋锁会浪费CPU资源。
-
信号量(Semaphore):
- 控制对某一资源的同时访问数量。
- 允许多个线程同时访问资源,但数量有限。
- 常用于限制并发线程的数量。
-
互斥量(Mutex)与条件变量(Condition Variable):
- 互斥量用于保护临界区,而条件变量用于线程间的同步。
- 线程可以在条件变量上等待,直到某个条件满足时被唤醒。
-
递归锁(Recursive Lock):
- 允许同一线程多次获取同一把锁。
- 适用于递归调用需要加锁的场景。
-
公平锁(Fair Lock)与非公平锁(Non-Fair Lock):
- 公平锁按照线程请求的顺序来分配锁,避免了线程饥饿。
- 非公平锁则可能优先分配给当前已经持有CPU的线程,以提高吞吐量。
-
分布式锁:
- 在分布式系统中,用于协调不同节点上的线程或进程对共享资源的访问。
- 常基于数据库、缓存(如Redis)、Zookeeper等实现。
选择合适的锁类型对于确保系统的并发性能和数据一致性至关重要。在实际应用中,需要根据具体的应用场景和需求来选择合适的锁机制。
Java实现
在Java中,锁的实现方式多种多样,每种方式都有其特定的应用场景和优缺点。以下是一些常见的锁实现方式及其详细说明和示例:
一、悲观锁
悲观锁总是假设最坏的情况,即每次读取数据都认为别人会更新,所以每次读取数据的时候都会加锁,这样别人就得阻塞等待它处理完释放锁后才能去读取。
- 实现方式:在Java中,悲观锁通常通过
synchronized
关键字或Lock
接口(如ReentrantLock
)来实现。当使用synchronized
修饰方法或代码块时,它会自动为被修饰的代码块加上锁,确保同一时间只有一个线程能够执行该代码块。而ReentrantLock
则提供了更为灵活的锁控制,如尝试获取锁、超时获取锁等。 - 示例:使用
synchronized
实现悲观锁。
public class Counter {
private int count = 0;
// 使用synchronized修饰的方法实现互斥锁
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
在上面的示例中,increment
方法被synchronized
修饰,确保了在多线程环境下,对count
变量的访问是线程安全的。
二、乐观锁
乐观锁表现出大胆、务实的态度,它认为读取的数据一般不会冲突,不会对其加锁,而是在最后提交数据更新时判断数据是否被更新,如果冲突,则更新不成功。
- 实现方式:在Java中,乐观锁通常通过CAS(Compare-And-Swap)操作来实现。CAS是一种原子操作,它比较内存中的值与某个期望值是否相同,如果相同,则给它赋一个新值。乐观锁在数据库中的实现方式通常是在表中增加一个版本号字段,每次更新数据时,比较当前版本号与期望版本号是否一致,如果一致则更新数据并增加版本号。
- 示例:使用CAS实现乐观锁(以无锁队列为例)。
无锁队列的实现中,通常会用到CAS操作来确保队列的入队和出队操作是线程安全的。这里不直接给出完整的无锁队列实现代码,但可以理解为其核心思想是利用CAS操作来比较并更新队列的头节点或尾节点。
三、读写锁
读写锁是一种特殊的锁,它允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。
- 实现方式:在Java中,读写锁通过
ReentrantReadWriteLock
来实现。ReentrantReadWriteLock
内置了两个锁:一个是读锁,一个是写锁。多个线程可以同时获取读锁,但只有一个线程能够获取写锁。当写锁被持有时,任何线程都不能获取读锁或写锁。 - 示例:使用
ReentrantReadWriteLock
实现读写锁。
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.List;
import java.util.ArrayList;
public class ReadWriteLockLogic {
// 初始化一个ReadWriteLock
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 共享资源
private List<String> shareResources = new ArrayList<>();
// 读操作
public String read() {
lock.readLock().lock();
try {
// 读工作(模拟)
StringBuffer buffer = new StringBuffer();
for (String shareResource : shareResources) {
buffer.append(shareResource).append("\t");
}
return buffer.toString();
} finally {
lock.readLock().unlock();
}
}
// 写操作
public void write(String content) {
lock.writeLock().lock();
try {
// 写工作(模拟)
shareResources.add(content);
} finally {
lock.writeLock().unlock();
}
}
}
在上面的示例中,ReadWriteLockLogic
类中使用ReentrantReadWriteLock
来控制对shareResources
的读写访问。多个线程可以同时调用read
方法进行读操作,但只有一个线程能够调用write
方法进行写操作。
四、自旋锁
自旋锁是一种采用让当前线程不停地在循环体内执行以尝试获取锁的锁机制。
- 实现方式:在Java中,自旋锁通常通过原子变量和循环来实现。当线程尝试获取锁失败时,它会进入一个循环,不断检查锁是否可用。如果锁可用,则获取锁并退出循环;如果锁不可用,则继续循环等待。
- 注意:自旋锁会消耗CPU资源,因此它通常用于短时间的锁等待场景。如果锁等待时间过长,可能会导致CPU资源浪费和性能下降。
五、其他锁机制
除了上述常见的锁机制外,Java还提供了其他一些锁机制来满足不同的并发控制需求。例如:
- 偏向锁:在Java 1.5及以后的版本中,
synchronized
关键字实现了锁升级机制,其中就包括了偏向锁。偏向锁是一种针对单线程访问的优化措施,它假设大多数情况下只有一个线程会访问共享资源。因此,在第一次获取锁时,它会将锁偏向于第一个获取锁的线程,以减少后续获取锁的开销。 - 轻量级锁:当偏向锁被打破(即有其他线程尝试获取锁)时,
synchronized
会升级到轻量级锁。轻量级锁通过CAS操作来尝试获取锁,而不会导致线程阻塞。如果CAS操作成功,则获取锁;如果失败,则进入自旋等待或升级到重量级锁。 - 重量级锁:当轻量级锁无法满足并发控制需求时(如自旋次数过多或存在大量线程竞争锁),
synchronized
会升级到重量级锁。重量级锁是基于操作系统的互斥量(Mutex)来实现的,它会导致线程阻塞和上下文切换,因此开销较大。
综上所述,Java中锁的实现方式多种多样,每种方式都有其特定的应用场景和优缺点。在选择锁机制时,需要根据具体的并发控制需求和性能要求来做出合理的选择。
底层实现原理
synchronized
synchronized
是Java中的一个关键字,用于解决多个线程之间访问资源的同步性问题。其底层实现原理主要基于Java虚拟机(JVM)中的管程(Monitor)对象。以下是对synchronized
底层实现原理的详细解释:
一、synchronized的特性
- 原子性:
synchronized
保证语句块内操作是原子的,即在同一时刻,只有一个线程能够执行被synchronized
修饰的代码块或方法。 - 可见性:
synchronized
保证可见性,即线程在解锁前,必须把共享变量的最新值刷新到主内存中,而线程在加锁前,会清空工作内存中共享变量的值,从而确保线程在使用共享变量时能够从主内存中重新读取最新的值。 - 有序性:
synchronized
保证有序性,即一个变量在同一时刻只允许一条线程对其进行lock操作,从而保证单线程下的运行结果的正确性。 - 可重入性:
synchronized
是可重入锁,即允许一个线程二次请求自己持有对象锁。
二、synchronized的底层实现
1. 同步方法
同步方法并不是由monitorenter
和monitorexit
指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED
标志来隐式实现的。当方法调用时,调用指令会检查方法的ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
2. 同步代码块
同步代码块的底层实现是通过monitorenter
和monitorexit
这两个字节码指令来实现的。它们分别位于同步代码块的开始和结束位置。当JVM执行到monitorenter
指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器加1;当执行monitorexit
指令时,锁计数器减1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
三、monitor监视器锁
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor
实现的。ObjectMonitor
的主要数据结构包括:
_header
:用于存储对象自身运行时数据,如哈希码、GC分代年龄等。_count
:记录进入monitor的线程数。_waiters
:等待锁的线程数。_recursions
:线程的重入次数。_object
:指向当前对象。_owner
:指向持有ObjectMonitor对象的线程。_WaitSet
:处于wait状态的线程集合。_EntryList
:处于等待锁block状态的线程集合。
当多个线程同时访问一段同步代码时,它们会首先进入_EntryList
集合,然后等待获取monitor。当线程获取到对象的monitor后,它会进入_Owner
区域,并把monitor中的owner
变量设置为当前线程,同时monitor中的计数器_count
加1。如果线程调用wait()
方法,它会释放当前持有的monitor,owner
变量恢复为null,_count
自减1,同时该线程进入_WaitSet
集合中等待被唤醒。如果当前线程执行完毕,它也会释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
四、synchronized的优化
在JDK 6及以后的版本中,synchronized
进行了多项优化,以提高其性能。这些优化包括:
- 偏向锁:针对单线程访问的优化措施,它假设大多数情况下只有一个线程会访问共享资源。因此,在第一次获取锁时,它会将锁偏向于第一个获取锁的线程,以减少后续获取锁的开销。
- 轻量级锁:当偏向锁被打破(即有其他线程尝试获取锁)时,
synchronized
会升级到轻量级锁。轻量级锁通过CAS操作来尝试获取锁,而不会导致线程阻塞。如果CAS操作成功,则获取锁;如果失败,则进入自旋等待或升级到重量级锁。 - 自适应自旋锁:自旋锁的一种改进版本。在自适应自旋锁中,线程会根据上一次自旋等待的结果来动态调整自旋的次数,以减少不必要的CPU资源浪费。
- 锁消除:在编译器级别进行的优化。如果编译器可以确定一个锁在后续的代码路径中不会被其他线程访问,那么它就可以将这个锁消除掉,以提高性能。
- 锁粗化:在运行时进行的优化。如果编译器发现一系列的连续加锁、解锁操作,它可能会将这些操作合并成一个较大的锁块,以减少锁的开销。
综上所述,synchronized
的底层实现原理主要基于JVM中的管程(Monitor)对象。通过同步方法和同步代码块两种方式,synchronized
保证了多线程环境下的数据同步和线程安全。同时,随着JDK版本的更新,synchronized
也进行了多项优化以提高其性能。
ReentrantReadWriteLock
ReentrantReadWriteLock(可重入读写锁)是Java并发包java.util.concurrent.locks中的一个重要类,它提供了比synchronized
关键字和ReentrantLock更细粒度的锁控制。ReentrantReadWriteLock的底层实现原理主要基于AbstractQueuedSynchronizer(AQS)框架,并进行了针对读写操作的特殊设计。以下是对其底层实现原理的详细解释:
一、ReentrantReadWriteLock的核心组件
- AQS:ReentrantReadWriteLock内部有一个sync类继承了AQS(抽象队列同步器)。AQS是Java并发包中的核心组件,它定义了一套多线程访问共享资源的同步器框架,用于构建锁或其他同步组件。
- 读锁和写锁:ReentrantReadWriteLock内部维护了一对锁,即读锁(ReadLock)和写锁(WriteLock)。读锁是共享的,允许多个线程同时获取;写锁是独占的,同一时刻只允许一个线程获取。
二、锁状态的管理
- state字段的划分:在AQS中,通常使用一个int类型的state字段来表示锁的状态。但在ReentrantReadWriteLock中,由于需要同时管理读锁和写锁的状态,因此将state字段的高16位用于表示读锁的重入次数(即获取读锁的线程数,采用共享节点),低16位用于表示写锁的重入次数(即获取写锁的线程数,采用独占节点)。
- 状态获取与更新:通过位运算来获取和更新读锁和写锁的状态。例如,使用
state >>> 16
来获取读锁的状态,使用state & 0x0000FFFF
来获取写锁的状态。更新状态时,使用CAS(Compare-And-Swap)操作来保证原子性。
三、锁的获取与释放
-
读锁的获取:
- 当线程尝试获取读锁时,首先会检查写锁是否被持有(即低16位是否大于0)。如果写锁被持有,且持有写锁的线程不是当前线程,则当前线程无法获取读锁,会进入等待状态。
- 如果写锁未被持有或写锁被当前线程持有(写锁可重入),则当前线程会尝试使用CAS操作来增加读锁的重入次数(即高16位自增)。如果CAS操作成功,则当前线程获取读锁成功。
- 如果CAS操作失败(说明有其他线程正在尝试获取读锁或写锁),则当前线程会进入AQS的同步队列中等待,直到其他线程释放锁或CAS操作成功。
-
写锁的获取:
- 当线程尝试获取写锁时,首先会检查写锁和读锁的状态。如果写锁已被其他线程持有,或读锁被其他线程持有(即高16位大于0),则当前线程无法获取写锁,会进入等待状态。
- 如果写锁和读锁都未被持有,则当前线程会尝试使用CAS操作来增加写锁的重入次数(即低16位自增)。如果CAS操作成功,则当前线程获取写锁成功,并成为写锁的独占持有者。
- 如果CAS操作失败(说明有其他线程正在尝试获取写锁或读锁),则当前线程会进入AQS的同步队列中等待,直到其他线程释放锁或CAS操作成功。
-
锁的释放:
- 当线程释放读锁或写锁时,会相应地减少读锁或写锁的重入次数(即高16位或低16位自减)。如果重入次数减为0,则表示锁已被完全释放。
- 释放锁后,如果有其他线程在等待获取该锁(无论是读锁还是写锁),则AQS会唤醒这些等待线程中的一个或多个,以便它们尝试获取锁。
四、锁降级与升级
- 锁降级:写锁可以降级为读锁。这通常发生在写线程完成写操作后,还需要进行读操作的情况。写线程在持有写锁的情况下,可以获取读锁,然后释放写锁,从而实现锁降级。需要注意的是,锁降级操作必须是安全的,即必须保证在降级过程中不会发生其他线程获取写锁的情况。
- 锁升级:读锁不能升级为写锁。如果读线程需要获取写锁,它必须首先释放读锁,然后尝试获取写锁。这可能会导致其他等待获取写锁的线程被唤醒并获取写锁,从而破坏锁降级的语义。
五、公平性与非公平性
ReentrantReadWriteLock支持公平锁和非公平锁两种模式。公平锁按照线程请求锁的顺序来分配锁,即先请求的线程先获得锁。非公平锁则不保证线程请求锁的顺序,可能会导致某些线程长时间等待而无法获得锁。默认情况下,ReentrantReadWriteLock采用非公平锁模式,以提高吞吐量。
综上所述,ReentrantReadWriteLock通过继承AQS并实现读写锁的特殊逻辑来提供细粒度的锁控制。它使用state字段的高16位和低16位分别表示读锁和写锁的状态,通过CAS操作和AQS的同步队列来管理锁的获取与释放过程。同时,它还支持锁降级和公平性与非公平性选择等高级功能。
CAS
Java中的CAS(Compare and Swap)是一种并发控制机制,其底层实现原理主要依赖于硬件的原子操作指令和Java中的Unsafe
类。以下是对Java CAS底层实现原理的详细解释:
一、硬件原子操作指令
CAS的底层实现是通过硬件提供的原子操作指令来实现的。这些指令可以在一个原子操作中比较内存中的值和期望的值,并根据比较结果执行更新操作。例如,在x86架构中,可以使用CMPXCHG指令来实现CAS操作。CMPXCHG指令会先比较目标内存地址中的值和期望值,如果相等,则将其设置为新值,并返回true;如果不相等,则不改变目标内存地址中的值,并返回false。
二、Unsafe类
在Java中,CAS操作是由Unsafe
类提供的一系列原子操作方法来实现的。Unsafe
类是Java中的一个特殊类,它包含了一些直接操作内存和线程的方法,这些方法通常是用于实现底层并发控制机制的。由于Unsafe
类中的方法直接调用操作系统底层资源执行相应任务,因此这些方法通常是不可移植的,并且存在一定的安全风险。因此,Unsafe
类被设计为仅在JDK内部使用,并不对外公开。
Unsafe
类中的CAS操作方法包括compareAndSwapInt
、compareAndSwapObject
等,这些方法分别用于对整型变量和对象引用进行CAS操作。这些方法内部会调用硬件提供的原子操作指令来实现CAS操作,从而保证了CAS操作的原子性和无锁性。
三、CAS操作的具体实现
以compareAndSwapInt
方法为例,其实现过程大致如下:
- 获取内存地址:首先,需要获取要操作变量的内存地址。这通常是通过对象的字段偏移量来实现的。在Java中,可以使用
Unsafe
类提供的objectFieldOffset
方法来获取字段的偏移量。 - 比较和交换:然后,使用硬件提供的原子操作指令(如CMPXCHG)来比较内存地址中的值和期望值。如果相等,则将其设置为新值;如果不相等,则不改变内存地址中的值。
- 返回结果:最后,根据比较结果返回相应的布尔值。如果成功地将内存地址中的值更新为新值,则返回true;否则返回false。
四、CAS的优势和问题
CAS操作具有一些显著的优势,如:
- 无锁性:CAS操作不需要使用传统的锁机制来保护共享变量,从而减少了不必要的线程阻塞和唤醒操作,提高了系统的并发性能。
- 乐观性:CAS操作是一种乐观锁机制,它假设在大多数情况下并发访问不会发生冲突。只有当实际发生冲突时,才会通过自旋或重试等方式来解决。
然而,CAS操作也存在一些问题,如:
- ABA问题:如果一个值从A变为B,然后又变回A,CAS操作无法识别这种情况。这可能会导致一些潜在的并发问题。为了解决这个问题,可以使用带版本号或时间戳的原子引用(如
AtomicStampedReference
)。 - 循环开销:在高并发情况下,CAS操作可能会因为多次重试而导致较大的CPU开销。这可能会影响系统的整体性能。
综上所述,Java中的CAS操作是通过硬件原子操作指令和Unsafe
类来实现的。它具有无锁性和乐观性等优势,但也存在ABA问题和循环开销等问题。因此,在使用CAS操作时,需要权衡其优势和问题,并根据实际情况选择合适的并发控制机制。