前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。
文章目录
- 什么是锁
- 隐式锁和显式锁
- 隐式锁
- 显式锁
- 悲观锁和乐观锁
- 悲观锁
- 乐观锁
- 公平锁和非公平锁
- 公平锁
- 非公平锁
- 可重入锁和非可重入锁
- 可重入锁
- 非可重入锁
- 独占锁和共享锁
- 独占锁
- 共享锁
- 偏向锁、轻量级锁和重量级锁
- 偏向锁
- 轻量级锁
- 重量级锁
- 分段锁
- 自旋锁
- 死锁
- 总结
什么是锁
Java中的锁是一种多线程编程中的同步机制,用于控制线程对共享资源的访问,防止并发访问时的数据竞争和死锁问题。通过使用锁机制,可以实现数据的同步访问,确保多个线程安全地访问共享资源,从而提高程序的并发性能。
隐式锁和显式锁
显式锁和隐式锁是根据锁的获取和释放方式来进行区分的。
隐式锁
隐式锁(Implicit Lock,又称为内置锁或自动锁)是通过Java中的synchronized
关键字来实现的,它在代码块或方法上加上synchronized
关键字,从而隐式地获取和释放锁,不需要显式地调用锁的获取和释放方法。
隐式锁的实现主要有两种:
- 互斥锁(Mutex):基于操作系统的互斥量(Mutex)实现,通常由操作系统提供的底层机制来保证同一时刻只有一个线程可以持有锁。
- 内部锁(Intrinsic Lock):也称为监视器锁(Monitor Lock),是Java对象的内置锁,每个Java对象都有一个关联的监视器锁。通过
synchronized
关键字来获取和释放对象的监视器锁。
优点:
- 使用简便:隐式锁通常是由编程语言、运行时库或者虚拟机自动管理的,不需要程序员手动调用锁的获取和释放方法,使用较为简便。
- 自动释放:隐式锁通常会在不需要的时候自动释放,从而减少了由于忘记释放锁而导致的死锁等问题的风险。
缺点:
- 锁定粒度较大:隐式锁通常是在方法或者代码块级别上加锁的,锁定粒度较大,可能会导致性能下降或者并发度降低。
- 功能较少:隐式锁通常提供的功能较为有限,可能不足以满足复杂的并发场景的需求。
显式锁
显式锁(Explicit Lock,又称手动锁)是通过Java中的Lock
接口及其实现类来实现的,它提供了显式地获取锁和释放锁的方法,例如lock()
和unlock()
方法,需要在代码中明确地调用这些方法来获取和释放锁。
常见的显式锁实现包括:
- ReentrantLock:可重入锁,支持公平锁和非公平锁,并提供了丰富的特性如可中断、超时、条件等。
- ReentrantReadWriteLock:可重入读写锁,支持多线程对共享资源的读操作,以及独占写操作。
- StampedLock:支持乐观读、悲观读和写操作,并提供了乐观读的优化。
优点:
-
可以提供更细粒度的控制:显式锁允许程序员手动地控制锁的获取和释放,从而可以在代码中实现更细粒度的锁定粒度,以满足具体的需求。
-
提供更多的功能:显式锁通常提供了更多的功能,例如可重入锁(Reentrant Lock)允许同一线程多次获取同一把锁,以避免死锁;以及一些高级功能如条件变量(Condition)等,可以在复杂的并发场景中使用。
-
可以避免锁的自动释放:显式锁通常不会自动释放,而需要手动调用锁的释放方法,这可以避免锁在不需要的时候自动释放,从而提供更好的控制。
缺点:
-
使用复杂:显式锁需要程序员手动地调用锁的获取和释放方法,需要更多的代码和注意事项,使用较为复杂。
-
容易出错:由于显式锁需要程序员手动地管理锁的获取和释放,容易出现错误,例如忘记释放锁或者死锁等问题。
悲观锁和乐观锁
乐观锁和悲观锁是以对共享资源的访问方式来区分的。
悲观锁
悲观锁在并发环境中认为数据随时会被其他线程修改,因此每次在访问数据时都会加锁,直到操作完成后才释放锁。悲观锁适用于写操作多、竞争激烈的场景,比如多个线程同时对同一数据进行修改或删除操作的情况。悲观锁可以保证数据的一致性,避免脏读、幻读等问题的发生。悲观锁适用于读少写多的场景。
Java中常用的悲观锁是synchronized关键字和ReentrantLock类。
悲观锁存的问题:
- 效率低:悲观锁需要获取锁才能进行操作,当有多个线程需要访问同一份数据时,每个线程都需要先获取锁,然后再进行操作,如果锁竞争激烈,就会导致线程等待锁的释放,浪费了大量的时间。
- 容易引起死锁:悲观锁在获取锁的过程中,如果获取不到就会一直等待,如果不同的线程都在等待对方释放锁,就会导致死锁的情况出现。
- 可能会引起线程阻塞:当某个线程获取到锁时,其他线程需要等待,如果等待的时间过长,就会导致线程阻塞,影响应用的性能。
乐观锁
乐观锁在并发环境中认为数据一般情况下不会被其他线程修改,因此在访问数据时不加锁,而是在更新数据时进行检查。如果检查到数据被其他线程修改,则放弃当前操作,重新尝试更新。乐观锁适用于读操作多、写操作少的场景,比如多个线程同时对同一数据进行读取操作的情况。乐观锁可以减少锁的竞争,提高系统的并发性能。
Java中常用的乐观锁是基于CAS(Compare and Swap,比较和交换)算法实现的。
CAS操作包括三个操作数:内存地址V、旧的预期值A和新的值B。CAS操作首先读取内存地址V中的值,如果该值等于旧的预期值A,那么将内存地址V中的值更新为新的值B;否则,不进行任何操作。在更新过程中,如果有其他线程同时对该共享资源进行了修改,那么CAS操作会失败,此时需要重试更新操作。
乐观锁存在的问题
CAS虽然很⾼效的解决原⼦操作,但是CAS仍然存在三⼤问题:ABA问题,自旋时间过长和只能保证单个变量的原子性。
-
ABA问题:CAS算法在比较和替换时只考虑了值是否相等,而没有考虑到值的版本信息。如果一个值在操作过程中被修改了两次,从原值变成新值再变回原值,此时CAS会认为值没有发生变化,从而出现操作的错误。为了解决ABA问题,可以在共享资源中增加版本号,每次修改操作都将版本号加1,从而保证每次更新操作的唯一性。在更新数据时先读取当前版本号,如果与自己持有的版本号相同,则可以更新数据,否则更新失败。版本号算法可以避免ABA问题,但需要维护版本号,增加了代码复杂度和内存开销。
-
自旋时间过长:由于CAS算法在失败时会一直自旋,等待共享变量可用,如果共享变量一直不可用,就会出现自旋时间过长的问题,浪费CPU资源。
-
只能保证单个变量的原子性:CAS算法只能保证单个变量的原子性,如果需要多个变量的原子操作,就需要使用锁等其他方式进行保护。
公平锁和非公平锁
按照是否按照请求的顺序来分配锁,锁分为公平锁和非公平锁。
公平锁
公平锁(Fair Lock)是指当多个线程竞争锁时,先到先得,后到后等,按照请求的先后顺序来获取锁。这样可以避免某些线程长时间等待锁,从而提高系统的公平性。但是,公平锁也有一些缺点,比如性能开销较大,因为需要维护一个有序的等待队列,并且可能导致更多的上下文切换。
优点:
-
公平性较好,避免了线程饥饿现象,每个线程都有公平的机会获取锁。
-
具有较高的线程公平性,适用于对公平性要求较高的场景。
缺点:
-
公平锁的实现较为复杂,可能会导致性能较低,因为需要频繁地切换线程和维护等待队列。
-
在高并发场景下,可能会导致大量的线程切换和等待,影响性能。
-
可能引起死锁:如果某个线程获取锁失败而进入等待状态,而锁的持有者又在等待该线程的资源,就会出现死锁的情况。
非公平锁
非公平锁(Unfair Lock)是指当多个线程竞争锁时,不一定按照请求的顺序来分配锁,而是由操作系统决定哪个线程先获取锁。这样可以减少一些开销,提高系统的吞吐量。
优点:
- 实现简单:非公平锁的实现较为简单,通常性能较高,因为无需维护等待队列和频繁地切换线程。
- 性能高:由于非公平锁不考虑线程的等待顺序,所以在高并发环境下可以更快地获取锁,从而提高系统的处理能力。
缺点:
- 不公平:非公平锁会导致一些线程长期无法获取到锁,而其他线程会一直占用锁资源,这种情况会导致线程的饥饿现象,不公平性较高。
- 可能导致线程饥饿:如果一些线程一直占用锁资源,而其他线程无法获取锁,则后者可能永远无法执行,从而导致线程饥饿现象。
- 不利于资源调度:非公平锁不考虑线程的等待时间,也就是说,一个刚刚进入等待队列的线程可能会比一个已经等待很久的线程先获得锁,这种情况不利于资源的合理调度,容易导致一些线程长时间处于等待状态。
Java中有多种实现方式可以实现公平锁和非公平锁。其中最常用的是ReentrantLock类,它是一个可重入的互斥锁,可以通过构造函数传入一个boolean类型的值来指定是否为公平锁。例如:
ReentrantLock fairLock = new ReentrantLock(true); //创建一个公平锁
ReentrantLock unfairLock = new ReentrantLock(false); //创建一个非公平锁
除了ReentrantLock之外,还有其他一些类可以实现公平锁和非公平锁,比如synchronized
关键字是非公平锁、 Semaphore(信号量)、ReadWriteLock(读写锁)、StampedLock(乐观锁)等。
下面是一段ReentrantLock公平锁的简单实现:
import java.util.concurrent.locks.ReentrantLock;
public class FairLockExample {
private static final ReentrantLock lock = new ReentrantLock(true); // true 表示使用公平锁
public static void main(String[] args) {
new Thread(() -> {
while (true) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " get the lock.");
} finally {
lock.unlock();
}
}
}, "Thread-A").start();
new Thread(() -> {
while (true) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " get the lock.");
} finally {
lock.unlock();
}
}
}, "Thread-B").start();
}
}
从运行结果可以看到Thread-A和Thread-B是交替运行获取锁的,如果想使用非公平锁只需要new ReentrantLock(false)
,想尝试的可以自己试一下。
选择公平锁还是非公平锁应该根据具体的业务需求和性能要求来进行权衡。如果对公平性要求较高,并且能容忍性能的一定降低,可以选择公平锁;如果对性能要求较高,并且能容忍一定程度的线程不公平现象。
可重入锁和非可重入锁
根据是否支持同一线程对同一锁的重复获取进行分类,分为可重入锁和非可重入锁。
可重入锁
可重入锁(Reentrant Lock)允许同一线程多次获取同一锁,并且不会造成死锁。可重入锁实现了锁的递归性,同一线程可以重复获取锁而不会被阻塞,因为锁会记录持有锁的线程和锁的重入次数。在线程持有锁的情况下,再次请求该锁时,如果锁是可重入的,线程会成功获取锁而不会被阻塞。
常见的可重入锁有:
java.util.concurrent.ReentrantLock
类:这是 Java 并发包中提供的一个可重入锁实现,提供了丰富的功能,如支持公平和非公平锁、可设置超时时间、支持多个条件等。java.util.concurrent.ReentrantReadWriteLock
类:这是 Java 并发包中提供的一个可重入读写锁实现,允许多个线程同时读取共享资源,但在写操作时需要互斥。- Java 的内置锁
synchronized
关键字是可重入的。
下面通过一段代码加深一下对可重入锁的理解:
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private ReentrantLock lock = new ReentrantLock(); // 创建一个可重入锁
public void doSomething() {
lock.lock(); // 加锁
try {
// 这里是需要加锁的代码
System.out.println("线程 " + Thread.currentThread().getName() + " 获取到锁");
doSomethingElse(); // 可以再次调用自己的方法,多次获取锁,不会导致死锁
} finally {
lock.unlock(); // 解锁
System.out.println("线程 " + Thread.currentThread().getName() + " 释放锁");
}
}
public void doSomethingElse() {
lock.lock(); // 再次加锁,同一个线程可以多次获取同一个锁
try {
// 这里是需要加锁的代码
System.out.println("线程 " + Thread.currentThread().getName() + " 获取到锁(再次获取)");
} finally {
lock.unlock(); // 解锁
System.out.println("线程 " + Thread.currentThread().getName() + " 释放锁(再次获取)");
}
}
public static void main(String[] args) {
ReentrantLockExample example = new ReentrantLockExample();
for (int i = 1; i <= 3; i++) {
new Thread(() -> {
example.doSomething();
}, "Thread " + i).start();
}
}
}
结果:
线程 Thread 1 获取到锁
线程 Thread 1 获取到锁(再次获取)
线程 Thread 1 释放锁(再次获取)
线程 Thread 1 释放锁
线程 Thread 2 获取到锁
线程 Thread 2 获取到锁(再次获取)
线程 Thread 2 释放锁(再次获取)
线程 Thread 2 释放锁
线程 Thread 3 获取到锁
线程 Thread 3 获取到锁(再次获取)
线程 Thread 3 释放锁(再次获取)
线程 Thread 3 释放锁
在上面的示例中,我们使用了ReentrantLock来创建一个可重入锁,并通过lock()
方法加锁,通过unlock()
方法解锁。在doSomething()
方法中,我们可以多次获取同一个锁,而不会导致死锁。这就是可重入锁的特性,使得同一个线程在持有锁的情况下可以继续获取锁,从而避免了死锁的可能性。
优点:
- 支持线程的重复获取:同一个线程可以多次获取同一个锁,避免了死锁的可能性。
- 支持公平和非公平锁:可以根据需求选择公平或非公平锁,灵活性高。
- 提供了丰富的功能:如可设置超时时间、支持多个条件等,使得锁的使用更加灵活。
缺点:
- 复杂性高:相比于内置锁 synchronized 关键字,可重入锁的使用复杂度较高,需要手动加锁和解锁,容易出错。
- 性能相对较低:相较于内置锁 synchronized 关键字,可重入锁的性能可能较低,因为它提供了更多的功能和灵活性。
非可重入锁
非可重入锁(Non-reentrant Lock)不允许同一线程多次获取同一锁,否则会造成死锁。非可重入锁实现了简单的互斥,但不支持同一线程对同一锁的重复获取。
缺点:
- 不支持线程的重复获取:同一个线程无法多次获取同一个锁,容易导致死锁。
- 功能较为简单:非可重入锁通常只提供了基本的加锁和解锁功能,缺乏灵活性和丰富的功能。
是的,你没有看错非可重入锁没有优点。
非可重入锁在 Java 中没有现成的实现。非可重入锁通常是通过自定义的锁机制来实现的,但在 Java 标准库中并没有提供非可重入锁的实现。需要注意的是,使用非可重入锁时需要非常小心,因为它可能导致死锁或其他并发问题。
独占锁和共享锁
独占锁和共享锁是根据对锁的获取方式以及锁对资源的访问权限进行分类的。
独占锁
独占锁(Exclusive Lock),也称为排它锁(Exclusive Lock)、互斥锁(Mutex Lock),它在同一时刻只允许一个线程获取锁并且独占资源,其他线程需要等待该锁释放后才能获取锁并访问资源。独占锁是一种排它性的锁,它确保了在同一时刻只有一个线程可以执行临界区内的代码,从而避免了并发访问导致的竞态条件。
独占锁的实现:
- synchronized 关键字:Java 中的 synchronized 关键字可以用来实现独占锁。synchronized 可以修饰方法、代码块或者类,通过获取对象的监视器(Monitor)来实现对资源的独占访问。
- ReentrantLock 类:Java 提供了 ReentrantLock 类,它是一个可重入的独占锁,可以通过调用
lock()
和unlock()
方法来获取和释放锁。
优点:
- 独占锁可以确保在任何时候只有一个线程可以访问被锁定的资源,避免了多个线程之间的竞争和冲突。
- 独占锁通常比较简单,易于实现和使用,适用于对资源进行独占性操作的场景。
缺点:
- 独占锁可能导致性能下降,因为当一个线程获得了独占锁后,其他线程必须等待锁释放才能访问资源,可能导致线程间的竞争和争用。
- 独占锁可能导致死锁,如果一个线程持有了独占锁而没有释放,其他线程无法获取该锁,可能导致死锁现象。
共享锁
共享锁(Shared Lock)也称为读锁(Read Lock),它允许多个线程同时读取共享资源,但在写入共享资源时会阻塞其他线程的读和写操作。简单来说,共享锁允许多个线程同时读取共享资源,但在写入时需要独占资源,防止其他线程同时进行写操作,从而确保数据的一致性和完整性。
举个生活中的例子来解释,假设你和你的朋友在图书馆里读书,你们都可以同时读取同一本书(共享资源),这时候图书馆采用的就是共享锁的机制。但是,当你想要在书中做笔记(写入共享资源)时,你需要独占书本,防止其他人同时进行修改,这时候图书馆就会对你进行阻塞,直到你完成笔记并释放书本的独占锁。
共享锁的实现:
- ReadWriteLock 接口:Java 提供了 ReadWriteLock 接口,它定义了读锁和写锁的两种锁类型,允许多个线程同时获取读锁,但在有写锁时禁止获取读锁,以保护共享资源的一致性。可以通过调用
readLock()
和writeLock()
方法来获取读锁和写锁。 - ReentrantReadWriteLock 类:Java 提供了 ReentrantReadWriteLock 类,它是 ReadWriteLock 接口的实现类,可以通过调用
readLock()
和writeLock()
方法来获取读锁和写锁。
可以发现Java中对共享锁实现是读写锁(ReadWriteLock),读写锁是一种特殊的共享锁,它将对共享数据的访问划分为读访问和写访问两种类型,读访问可以并发执行,写访问需要独占式地进行。读写锁允许多个线程同时读共享数据,而对于写操作,只有一个线程能够进行写操作,写操作执行期间,其它所有的读操作和写操作都被阻塞。
读写锁有两种锁类型:读锁和写锁。当有多个线程要访问共享数据时,如果只有读操作,线程可以同时持有读锁;但如果有写操作,任何线程都不能持有写锁或者读锁,直到写操作完成。
优点:
- 共享锁可以允许多个线程同时读取共享资源,提高了并发性能。
- 共享锁在读多写少的场景中通常比较适用,可以提供更好的性能和并发控制。
缺点:
- 共享锁不能阻止其他线程获取写锁,可能导致写锁饥饿现象,即写锁一直无法获取而导致一直等待。
- 共享锁通常较复杂,需要更复杂的实现和管理,可能增加了编程和维护的复杂性。
以下是一个共享锁的代码示例:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class SharedLockExample {
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private static final String[] data = new String[10];
public static void main(String[] args) throws InterruptedException {
Thread writer1 = new Thread(() -> {
lock.writeLock().lock();
try {
// 写操作,这里简单地用数组来模拟
data[0] = "writer1 data";
Thread.sleep(1000); // 模拟写操作需要一定时间
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.writeLock().unlock();
}
});
Thread writer2 = new Thread(() -> {
lock.writeLock().lock();
try {
// 再次写操作
data[0] = "writer2 data";
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.writeLock().unlock();
}
});
Thread reader1 = new Thread(() -> {
lock.readLock().lock();
try {
// 读操作,这里简单地输出数组的第一个元素
System.out.println("Reader 1 read data: " + data[0]);
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();
}
});
Thread reader2 = new Thread(() -> {
lock.readLock().lock();
try {
// 再次读操作
System.out.println("Reader 2 read data: " + data[0]);
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();
}
});
// 启动写线程
writer1.start();
writer2.start();
// 启动读线程
reader1.start();
reader2.start();
// 等待所有线程结束
writer1.join();
writer2.join();
reader1.join();
reader2.join();
}
}
在这个例子中,我们使用了ReentrantReadWriteLock
来实现共享锁,这个锁可以允许多个线程同时获得读锁,但只允许一个线程获得写锁,以保证数据的一致性。我们启动了两个写线程和两个读线程,从运行结果中可以看到读线程是可以同时进行的,而写线程则是需要等待前一个写线程完成才能进行的。这样,就可以保证在数据读取的同时,也能保证数据的安全性。
独占锁和共享锁通常用于在多线程环境中对共享资源进行不同的操作,独占锁用于保护对资源的独占性操作,而共享锁则允许多个线程同时读取资源而不互斥。这两种锁的选择取决于应用场景和对资源访问的要求。在 Java 中,ReentrantLock 类可以用于实现独占锁和共享锁的功能,具体通过 lock()
方法获取独占锁,通过 readLock()
方法获取共享锁。
偏向锁、轻量级锁和重量级锁
偏向锁、轻量级锁和重量级锁是 Java 中的三种锁状态,它们是根据锁在多线程环境中的竞争状态和性能优化的程度进行分类的。
偏向锁
偏向锁是为了解决无竞争情况下的高性能问题而产生的,适用于只有一个线程访问锁对象的场景。当锁对象第一次被访问时,JVM会将锁对象头部的标记设置为偏向锁。之后该线程再次访问锁对象时,无需再次获取锁,直接进入同步语句块执行。当其他线程试图获取锁时,会检查偏向锁的标记,如果锁被偏向于其他线程,则会撤销偏向锁,升级为轻量级锁。
偏向锁优点:
- 提供了最快的锁获取和释放操作,适用于对象在大多数时间内都只被一个线程访问的场景,减少了多线程竞争的开销。
- 避免了多线程竞争对性能的影响,减少了锁的竞争开销,提高了并发性能。
偏向锁缺点:
- 需要在每个对象头上额外存储偏向锁标记,占用了额外的内存空间。
- 对象可能在某一时刻被多个线程访问,导致偏向锁升级为轻量级锁或重量级锁,增加了额外的锁升级开销。
- 适用场景有限,只适合对象在大多数时间内只被一个线程访问的场景,对于高度并发的场景可能性能不佳。
轻量级锁
轻量级锁是为了解决竞争不激烈的情况下锁开销过大的问题。当第一个线程获取轻量级锁时,JVM会把锁对象头部的信息复制到线程的栈帧中,称为锁记录。之后,其他线程也尝试获取该锁对象时,JVM会先检查锁记录是否被占用,如果没有被占用,则表示该锁对象没有被锁定,将锁对象头部的标记改为偏向锁,并将锁记录中的线程ID设置为当前线程的ID。如果锁记录已经被占用,则将锁升级为重量级锁。
轻量级锁优点:
- 采用自旋的方式,避免了线程的阻塞和唤醒操作,减少了线程上下文切换的开销。
- 在锁竞争较轻的场景中,性能较好,对于多线程竞争不激烈的场景可以提供一定的性能优化。
轻量级锁缺点:
- 自旋的方式会增加CPU的消耗,对于锁竞争较激烈的场景,自旋可能会导致性能下降。
- 轻量级锁需要通过CAS(比较并交换)操作来进行加锁和解锁,CAS操作失败时会升级为重量级锁,增加了额外的锁升级开销。
- 在高度并发的场景中,轻量级锁的性能可能不如其他锁。
重量级锁
重量级锁是多个线程竞争同一个锁对象时采用的策略,JVM会把锁对象头部信息更换为指向一个互斥量(Monitor)的指针,并阻塞等待获取锁的线程,直到锁被释放。由于需要操作系统层面的线程阻塞与唤醒,因此重量级锁的效率相对偏向锁和轻量级锁来说较低。
重量级锁优点:
- 提供了稳定的锁保护,适用于多线程竞争激烈的场景,保证了线程安全性。
- 不会占用额外的对象头空间,对内存占用较小。
重量级锁缺点:
- 需要进行线程的阻塞和唤醒操作,增加了线程上下文切换的开销。
- 锁竞争较激烈时,可能导致大量线程阻塞,从而降低系统的并发性能。
在实现上,偏向锁和轻量级锁都是通过在对象头中记录锁的标记来实现的。重量级锁则是使用了操作系统提供的互斥量机制,在用户态和内核态之间切换,效率较低。
在Java中,偏向锁、轻量级锁和重量级锁之间可以进行转换,转换的条件如下:
-
偏向锁转轻量级锁:
当有一个线程访问对象并获取了偏向锁之后,如果另一个线程尝试访问同一个对象并请求获取锁,偏向锁会先尝试撤销偏向锁,然后将锁状态升级为轻量级锁。这通常发生在多个线程在对同一对象进行并发访问时。 -
轻量级锁转偏向锁:
当持有轻量级锁的线程执行完同步代码块后,会释放锁。如果在释放锁之前,没有其他线程尝试获取同一把锁,那么轻量级锁会恢复为偏向锁状态,将锁标记为偏向锁,并记录获得偏向锁的线程ID,以便后续访问时可以直接偏向给定线程。 -
轻量级锁转重量级锁:
当持有轻量级锁的线程无法成功自旋获取锁时,会将锁升级为重量级锁。自旋是指线程在获取锁时会尝试一段时间的忙等待,避免线程阻塞和唤醒带来的开销。如果自旋超过一定次数或达到某个条件时仍未获取到锁,轻量级锁会升级为重量级锁。 -
重量级锁转轻量级锁:
当持有重量级锁的线程释放锁时,如果没有其他线程在等待获取锁,锁会降级为轻量级锁,从而可以让其他线程尝试通过自旋快速获取锁,减少线程阻塞和唤醒带来的开销。
锁的状态转换在Java虚拟机中是自动进行的,根据线程对锁的访问情况和并发竞争情况自动切换锁的类型,以优化性能和保障线程安全。
分段锁
分段锁是一种锁策略,它将锁分为多个部分,每个部分有独立的锁,用于控制对应部分的并发访问。一般情况下,分段锁被应用于并发读写场景,将数据分为多个段,每个段对应一个锁,多个线程同时对不同的段进行读写操作,以此来提高并发访问性能。
分段锁常用于高并发读写操作,例如:ConcurrentHashMap 的实现。ConcurrentHashMap 采用了分段锁的方式来保证并发的安全性,将整个 ConcurrentHashMap 的数据分成了多个段,每个段使用独立的锁进行控制。这种方式有效地提高了 ConcurrentHashMap 的并发读写能力。
Java中有多个分段锁的实现,其中最常见的包括:
-
ConcurrentHashMap:ConcurrentHashMap是一种高效的线程安全的哈希表,它使用了分段锁来保证线程安全性和并发性。ConcurrentHashMap将整个哈希表分成多个段,每个段都是一个独立的哈希表,拥有自己的锁,多个线程可以同时访问不同的段,从而实现了更好的并发性。
-
ReentrantReadWriteLock:ReentrantReadWriteLock是一种读写锁,它使用了分段锁的思想。ReentrantReadWriteLock内部维护了两个锁,一个读锁和一个写锁,多个线程可以同时获取读锁,但只有一个线程能够获取写锁。读写锁的实现基于分段锁,将整个数据结构分成多个段,每个段都可以被多个线程同时读取,但是只有一个线程可以进行写操作。
-
Striped64:Striped64是Java中一种分段锁的实现,它主要用于并发计数器的实现。Striped64内部维护了一个数组,数组的每个元素都是一个计数器,每个线程访问时都会根据某种算法计算出一个索引,然后加锁对应的计数器进行累加操作。
-
StampedLock:StampedLock是一种基于分段锁的乐观读写锁。StampedLock内部维护了多个段,每个段都对应一个写锁和多个读锁。在读操作时,StampedLock使用了乐观读的方式,允许多个线程同时读取数据,而写操作时则需要获取独占写锁。
下面是一个使用ConcurrentHashMap实现分段锁的示例代码:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SegmentLockMap<K, V> {
private final int segmentsCount;
private final ConcurrentHashMap<K, V>[] segments;
private final Lock[] locks;
public SegmentLockMap(int segmentsCount) {
this.segmentsCount = segmentsCount;
this.segments = new ConcurrentHashMap[segmentsCount];
this.locks = new ReentrantLock[segmentsCount];
for (int i = 0; i < segmentsCount; i++) {
this.segments[i] = new ConcurrentHashMap<>();
this.locks[i] = new ReentrantLock();
}
}
private int getSegmentIndex(K key) {
return Math.abs(key.hashCode() % segmentsCount);
}
public void put(K key, V value) {
int segmentIndex = getSegmentIndex(key);
locks[segmentIndex].lock();
try {
segments[segmentIndex].put(key, value);
} finally {
locks[segmentIndex].unlock();
}
}
public V get(K key) {
int segmentIndex = getSegmentIndex(key);
locks[segmentIndex].lock();
try {
return segments[segmentIndex].get(key);
} finally {
locks[segmentIndex].unlock();
}
}
public void clear() {
for (int i = 0; i < segmentsCount; i++) {
locks[i].lock();
try {
segments[i].clear();
} finally {
locks[i].unlock();
}
}
}
}
这个类将数据分为多个段,每个段有一个独立的锁来保证线程安全。在put和get操作时,先根据key值计算出对应的段的索引,然后获取该段的独立锁,最后对该段的数据进行put或get操作。同时,clear操作会遍历所有的段,并分别获取每个段的独立锁,确保清除操作的线程安全。
分段锁的优点:
-
减小锁的粒度:通过将一个大的锁分解为多个小的锁,可以使并发程度更高,降低锁的粒度,避免出现单点瓶颈,从而提高了系统的并发性能。
-
减少锁冲突:由于每个小锁只锁定一部分数据,因此在多线程并发操作时,不同线程之间很可能不会产生锁冲突,减少了线程的等待时间,提高了系统的并发度。
-
提高系统的可伸缩性:分段锁可以将整个系统分解为多个独立的模块,从而提高了系统的可伸缩性和扩展性,便于系统的水平扩展和集群部署。
分段锁的缺点:
-
增加了锁的管理复杂度:由于需要管理多个锁,因此增加了锁的管理复杂度,需要更多的内存空间来维护锁的信息,同时也需要更复杂的锁协调机制,保证各个锁之间的一致性和可靠性。
-
可能会导致线程饥饿:如果分段不合理或者某些分段访问频率过高,可能会导致某些线程被阻塞,无法获得锁资源,从而导致线程饥饿问题。
-
可能会降低并发度:由于需要管理多个锁,可能会导致锁的竞争变得更加激烈,从而降低系统的并发度。此外,分段锁的实现难度较大,需要合理设计分段策略和锁协调机制,增加了系统的开发和维护成本。
-
内存占用:每个分段都需要使用额外的内存空间来保存锁信息和数据,因此会增加系统的内存占用。
在使用分段锁时,需要根据具体的应用场景,进行合理的锁设计和锁的粒度划分,避免锁的竞争过于激烈或者锁的粒度过大导致的性能问题。同时,在多线程编程中,使用分段锁时需要注意死锁的问题。
自旋锁
自旋锁(Spin Lock)是一种忙等待的锁。因为线程在获取锁时会循环等待,因此它不会主动放弃CPU,而是一直占用CPU资源,直到获取到锁并完成相应的操作后才会释放CPU资源。自旋锁适用于锁的持有时间比较短且竞争不激烈的情况下,可以减少线程上下文切换的开销。
自旋锁通常由一个标志变量组成,线程在获取自旋锁时会循环检查这个标志变量,如果发现被占用,则不断循环等待,直到标志变量变为可用状态才能获取锁。
下面是一个简单的自旋锁代码示例,使用Java中的AtomicBoolean实现:
import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
private AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
boolean acquiredLock = false;
while (!acquiredLock) {
acquiredLock = lock.compareAndSet(false, true);
}
}
public void unlock() {
lock.set(false);
}
}
在上面的代码中,我们使用AtomicBoolean来表示锁状态,通过调用compareAndSet()
方法实现了自旋。在lock()
方法中,如果当前锁已经被占用,那么就会一直自旋,直到获得锁为止;在unlock()
方法中,我们将锁状态设置为false,表示释放锁。
自旋锁的优点是:
-
自旋锁适用于保护非常短小的代码临界区,自旋锁的效率比较高,不需要调度线程和上下文切换的开销,从而提高系统的吞吐量。
-
自旋锁不会引起上下文切换和线程调度,因此对多处理器系统的性能影响较小。
自旋锁的缺点是:
-
自旋锁适用于临界区比较短小的情况,如果临界区比较大,自旋锁将占用较长时间,影响系统的吞吐量。
-
自旋锁采用忙等待的方式,会占用 CPU 时间,如果等待时间过长,会浪费 CPU 资源。
-
自旋锁在单 CPU 系统中,由于自旋锁一直占用 CPU 不释放,因此其他线程无法执行,会出现线程饥饿现象。
死锁
死锁不是一种具体的锁结构,它是多线程并发编程中的一种特定情况,它通常由于资源的竞争和同步不当导致的。
死锁是指多个线程之间发生了互相等待对方持有的锁而无法继续执行的状态。具体而言,当多个线程持有某些锁,并且每个线程都在等待其他线程持有的锁释放,从而形成了一个循环依赖关系,导致线程都无法继续执行下去,称为死锁。
死锁通常在以下情况下可能发生:
-
互斥资源:当多个线程需要互斥地访问某些资源,而这些资源在同一时间只能被一个线程占用时,如果多个线程之间相互等待对方释放资源,就可能发生死锁。
-
竞争资源:当多个线程竞争有限的资源时,而且在资源分配和释放时没有足够的同步和协调,也可能导致死锁。例如,多个线程同时竞争一些全局锁或系统资源时,如果竞争不当,可能会导致死锁。
-
循环等待:当多个线程之间形成了循环等待资源的情况时,也可能导致死锁。例如,线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1,从而形成了循环等待的状态。
-
锁的嵌套:当一个线程在持有一个锁的同时,试图获取另一个锁时,可能会导致死锁。例如,线程 A 持有锁 1,试图获取锁 2,但锁 2 已经被线程 B 持有,同时线程 B 正在等待锁 1,从而导致死锁。
以下是一些常用的方法,可用于避免死锁的产生:
-
避免使用多个锁:如果可能的话,尽量减少对多个锁的使用,从而减少死锁的可能性。可以考虑使用更细粒度的锁,或者使用一种更加高效的并发编程模型,例如使用无锁数据结构或并发容器。
-
统一获取锁的顺序:在多个线程需要获取多个锁的情况下,可以规定一定的获取锁的顺序,从而避免循环等待。例如,如果线程 A 需要获取锁 A 和锁 B,而线程 B 需要获取锁 B 和锁 A,那么可以规定所有线程在获取锁时都按照相同的顺序,例如先获取锁 A,再获取锁 B,从而避免死锁的产生。
-
设置锁的超时时间:在获取锁时可以设置超时时间,如果在超时时间内没有成功获取锁,则放弃锁资源,从而避免长时间的等待导致死锁。
-
避免嵌套锁:尽量避免在一个锁内部获取另一个锁,从而避免锁的嵌套,降低死锁的可能性。
-
及时释放锁:在使用完锁资源后,尽早释放锁,不要持有锁的时间过长,从而减少死锁的风险。
-
使用可重入锁:可重入锁允许同一个线程多次获取同一个锁而不会发生死锁,因此在可能的情况下可以考虑使用可重入锁,避免死锁的发生。
-
良好的设计和资源管理:在编写多线程并发程序时,良好的设计和资源管理也能够帮助避免死锁的产生。例如,合理规划资源的分配和释放,避免资源的长时间占有等。
总之,在编写多线程程序时,需要特别注意锁的使用、资源的管理和同步协调,以避免死锁的发生。
总结
了解Java中锁的分类可以帮助我们在开发中选择合适的锁机制,提高代码效率和并发性能,比如:
- 根据应用场景选择合适的锁类型,例如在读多写少的情况下可以使用读写锁提高并发性能;在需要对共享资源进行加锁保护的情况下,可以使用互斥锁防止多个线程同时访问和修改。
- 根据锁特性选用合适的锁策略,例如在资源竞争非常激烈的情况下,公平锁能够更好地避免线程饥饿的问题;在需要支持可重入性的场景下,可重入锁则能够更便捷地实现该功能。
- 通过了解锁机制的实现原理,可以更好地调优程序,例如通过自旋锁等方式减少线程上下文切换的开销,从而提高程序效率。
总之,了解Java中锁的分类是开发高性能、高可靠性并发程序的基本要求,对于提升程序质量和运行效率具有重要意义。