Java - 锁

Java - 锁

乐观锁

分为三个阶段:数据读取、写入校验、数据写入。

假设数据一般情况下不会造成冲突,只有在数据进行提交更新时,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回错误信息,让用户决定如何去做。fail-fast机制。

悲观锁

正如其名,它指对数据被外界(可能是本机的其他事务,也可能是来自其它服务器的事务处理)的修改持保守态度。在整个数据处理过程中,将数据处于锁定状态。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。如果加锁的时间过长,其他用户长时间无法访问,影响程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受。

分布式锁

分布式集群中,对锁接口QPS性能要求很高,单台服务器满足不了要求,可以考虑将锁服务部署在独立的分布式系统中,比如借助分布式缓存来实现。

可重入锁

可重入锁,也叫做递归锁,是指在同一个线程在调外层方法获取锁的时候,再进入内层方法会自动获取锁。ReentrantLocksynchronized 都是 可重入锁。可重入锁的一个好处是可一定程度避免死锁。

这对于设计复杂的程序或库来说是非常重要的。考虑下面这种情况:

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class SomeClass {

private final Lock lock = new ReentrantLock();

public void methodA() {

lock.lock();
try {

// 一些代码
methodB(); // 在 methodA 中调用 methodB
// 更多代码
} finally {

lock.unlock();
}
}

public void methodB() {

lock.lock();
try {

// 一些代码
} finally {

lock.unlock();
}
}
}

在这个例子中,methodA()methodB() 都使用了同一把锁。如果 ReentrantLock 不是可重入的,那么当线程在 methodA() 中已经获取了锁后,再次尝试在 methodB() 中获取锁时,就会导致死锁,因为锁已经被同一个线程持有,而不是其他线程。

因此,”可重入”锁允许在同一线程中多次获取同一把锁,这样就能避免死锁,并且简化了程序设计,因为无需担心方法之间的调用顺序是否会导致死锁。

尽管在您提出的例子中看起来似乎并没有太大意义,但在实际的程序设计中,”可重入”锁确实是一个非常重要的概念。

自旋锁

自旋锁是一种基于忙等待(busy-waiting)的锁,它在获取锁时会不断地循环尝试获取锁,而不是让线程进入睡眠状态。自旋锁的主要特点是在锁被其他线程占用时,当前线程会不断地尝试获取锁,直到获取到锁为止,而不会释放 CPU 控制权。

自旋锁适用于以下情况:

  1. 锁被持有的时间短:如果锁被持有的时间很短,那么等待锁的线程不需要进入睡眠状态,使用自旋锁可以避免线程切换的开销

  2. 多核处理器:在多核处理器上,一个线程在自旋等待锁的同时,其他线程可以继续执行,因此自旋锁在多核处理器上能够充分利用 CPU 时间,提高并发性能。

  3. 高并发场景:在高并发的情况下,锁的竞争可能会很激烈,自旋锁可以减少线程的阻塞时间,提高系统的响应速度。

然而,自旋锁也有一些缺点:

  1. 等待时间过长可能会浪费 CPU 资源:如果锁的竞争很激烈,导致线程不断自旋等待锁的释放,可能会浪费大量的 CPU 时间。

  2. 不适用于长时间持有锁的情况:如果锁被持有的时间较长,自旋锁会导致其他线程长时间等待,影响系统的响应性能。

因此,在使用自旋锁时需要权衡利弊,根据具体的场景来决定是否使用自旋锁。通常情况下,自旋锁适用于锁被持有时间短、锁的竞争不激烈的情况下,能够有效提高并发性能。

当然可以。以下是一个简单的示例,演示了如何使用自旋锁来保护一个共享资源:

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.concurrent.atomic.AtomicBoolean;

public class SpinLock {

private AtomicBoolean locked = new AtomicBoolean(false);

public void lock() {

// 不断尝试获取锁,直到成功为止
while (!locked.compareAndSet(false, true)) {

// 自旋等待,不做其他事情,持续尝试获取锁
}
}

public void unlock() {

// 释放锁
locked.set(false);
}
}

在这个示例中,SpinLock 类实现了一个自旋锁。AtomicBoolean 类被用作锁状态的标记,初始时为 false 表示未锁定状态。lock() 方法使用了自旋等待的方式来尝试获取锁,它会不断地尝试将 locked 的值从 false 设置为 true,直到成功获取到锁为止。在 unlock() 方法中,锁会被释放,将 locked 的值重新设置为 false

以下是一个使用 SpinLock 的示例:

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class SpinLockExample {

private static int counter = 0;
private static SpinLock spinLock = new SpinLock();

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {

for (int i = 0; i < 1000; i++) {

spinLock.lock();
counter++;
spinLock.unlock();
}
});

Thread t2 = new Thread(() -> {

for (int i = 0; i < 1000; i++) {

spinLock.lock();
counter++;
spinLock.unlock();
}
});

t1.start();
t2.start();
t1.join();
t2.join();

System.out.println("Counter: " + counter);
}
}

在这个示例中,两个线程 t1t2 分别对 counter 执行了 1000 次增加操作,每次增加操作都在获取自旋锁后执行,并在执行完毕后释放自旋锁。最后输出 counter 的值,由于自旋锁的保护,counter 的增加操作是线程安全的。

独享锁

独享锁是指该锁一次只能被一个线程所持有。

共享锁

共享锁是指该锁可被多个线程所持有。ReentrantReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写、写读、写写的过程是互斥的。独享锁与共享锁也是通过AQSAbstractQueuedSynchronizer)来实现的,通过实现不同的方法,来实现独享或者共享。

互斥锁

独享锁/共享锁就是一种广义的说法,互斥锁/读写锁指具体的实现。

读写锁

读写锁在Java中的具体实现就是ReentrantReadWriteLock

阻塞锁

阻塞锁,可以说是让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争进入运行状态。

JAVA中,能够进入\退出、阻塞状态或包含阻塞锁的方法有 ,synchronized 关键字(其中的重量锁),ReentrantLock,Object.wait()/notify(),LockSupport.park()/unpark()

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。

可能造成优先级反转或者饥饿现象。对于Java ReentrantLock而言,通过构造函数 ReentrantLock(boolean fair) 指定该锁是否是公平锁,默认是非公平锁。

非公平锁的优点在于吞吐量比公平锁大。对于Synchronized而言,也是一种非公平锁。

分段锁

分段锁其实是一种锁的设计,目的是细化锁的粒度,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMapJDK7HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLockSegment继承了ReentrantLock)

当需要put元素的时候,并不是对整个HashMap加锁,而是先通过hashcode知道要放在哪一个分段中,然后对这个分段加锁,所以当多线程put时,只要不是放在同一个分段中,可支持并行插入。

对象锁

一个线程可以多次对同一个对象上锁。对于每一个对象,java虚拟机维护一个加锁计数器,线程每获得一次该对象,计数器就加1,每释放一次,计数器就减 1,当计数器值为0时,锁就被完全释放了。

synchronized修饰非静态方法、同步代码块的synchronized (this)synchronized (非this对象),锁的是对象,线程想要执行对应同步代码,需要获得对象锁。

使用 synchronized 加锁 this 时,只有同一个对象会使用同一把锁,不同对象之间的锁是不同的。 ​

当需要同步访问对象的实例方法或实例变量时,应该使用 this 作为 synchronized 的参数。
例如,在一个多线程环境中,多个线程需要同时访问对象的实例方法或实例变量时,可以使用 synchronized(this) 来确保线程安全。

language-java
1
2
3
4
5
6
7
public void synchronizedBlock() {

synchronized (this) {

// 同步代码块
}
}

类锁

synchronized修饰静态方法或者同步代码块的synchronized (类.class),线程想要执行对应同步代码,需要获得类锁。
使用 synchronized 加锁 class 时,无论共享一个对象还是创建多个对象,它们用的都是同一把锁
当需要同步访问类的静态方法或静态变量时,应该使用 MyClass.class 作为 synchronized 的参数。
例如,在一个多线程环境中,多个线程需要同时访问类的静态方法或静态变量时,可以使用 synchronized(MyClass.class) 来确保线程安全。

language-java
1
2
3
4
5
6
7
public static void synchronizedStaticBlock() {

synchronized (MyClass.class) {

// 同步代码块
}
}

锁升级

锁升级是Java虚拟机中的一种优化策略,它是针对 synchronized 关键字进行的优化,并不是像 ReentrantLock 这样的锁类库,可以在代码逻辑中直接使用的锁。

(无锁、偏向锁、轻量级锁、重量级锁)是指在Java虚拟机中对锁的状态进行优化和调整的过程。这些状态反映了对象的锁定状态以及锁的竞争情况。这种锁升级的目的是为了在不同情况下提供不同的锁定机制,以减少锁的竞争、提高性能。

下面是对这几种锁状态的简要介绍:

  1. 无锁状态

    • 当线程尝试获取锁时,对象的锁状态为无锁状态,表示该对象没有被任何线程锁定。
    • 在无锁状态下,线程会通过CAS(Compare and Swap)等原子操作尝试直接修改对象的指针或标记位,来尝试获取锁。
  2. 偏向锁

    • 当只有一个线程访问同步块时,对象的锁状态会升级为偏向锁状态。
    • 偏向锁会将线程的ID记录在对象头中,表示该线程拥有对象的偏向锁。当其他线程尝试获取锁时,会检查偏向锁的线程ID是否与当前线程ID相同,如果相同则表示获取成功。
  3. 轻量级锁

    • 当有多个线程竞争同步块时,对象的锁状态会升级为轻量级锁状态。
    • 轻量级锁使用CAS操作来避免传统的互斥量操作,尝试在用户态下通过自旋来获取锁。如果自旋获取锁失败,则升级为重量级锁。
  4. 重量级锁

    • 当轻量级锁竞争失败时,对象的锁状态会升级为重量级锁状态。
    • 重量级锁会使得竞争失败的线程进入阻塞状态,从而让出CPU资源,减少竞争。

锁升级是Java虚拟机对锁状态的动态调整过程,旨在根据实际的锁竞争情况和线程行为来选择最适合的锁策略,以提高程序的并发性能。

作者

Xiamu

发布于

2024-02-23

更新于

2024-08-11

许可协议

评论