JUC(2) 锁
2024-02-13 23:00:32 # Language # Java

1. synchronized

1.1 三种应用方式

  • 一把锁只能同时被一个线程获取, 没有获得锁的线程只能等待;
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized修饰的是static方法的时候, 所有对象公用同一把锁
  • synchronized修饰的方法, 无论方法正常执行完毕还是抛出异常, 都会释放锁

对象锁:包括方法锁(默认锁对象为this,当前实例对象)和同步代码块锁(自己指定锁对象)

  • 方法锁:synchronized修饰普通方法, 锁对象默认为this
  • 同步代码块锁:见下方代码
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
Object block1 = new Object();
Object block2 = new Object();

@Override
public void run() {
// 这个代码块使用的是第一把锁, 当他释放后, 后面的代码块由于使用的是第二把锁, 因此可以马上执行
synchronized (block1) {
System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
}

synchronized (block2) {
System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
}
}

类锁:指synchronize修饰静态方法或指定锁对象为Class对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// synchronized用在静态方法上, 默认的锁就是当前所在的Class类, 所以无论是哪个线程访问它, 需要的锁都只有一把
public static synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

// 同上
public void run() {
// 所有线程需要的锁都是同一把
synchronized(SynchronizedObjectLock.class){
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}

1.2 字节码分析

命令:javap -c *.class 文件反编译

  • -v -verbose, 输出附加信息(行号、本地变量表、反汇编等详细信息)

同步代码块

image.png

如果代码块中手动抛出了一个异常, 则只会有一个 monitorexit

普通同步方法

image.png

调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置, 如果设置了, 执行线程会将先持有 monitor 锁, 然后再执行该方法, 最后在方法完成(无论是否正常结束)时释放monitor

静态同步方法

ACC_STATICACC_SYNCHRONIZED访问标志区分该方法是否是静态同步方法

image.png

1.3 底层原语分析

为什么任何一个对象都可以成为一个锁

  • 在 HotSpot 虚拟机中, monitor 采用 ObjectMonitor 实现

  • ObjectMonitor.java—->ObjectMonitor.cpp—->ObjectMonitor.hpp

  • 每个对象天生都带着一个对象监视器, 每一个被锁住的对象都会和Monitor关联起来

img

总结:指针指向Monitor对象(也称为管程或监视器)的真实地址。每个对象都存在着一个monitor与之关联, 当一个monitor被某个线程持有后, 它便处于锁定状态。在Java虚拟机(HotSpot)中, monitor是由ObjectMonitor实现的, 其主要的数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件, C++实现):

img

image-20240201194318895

1.4 Lock接口

Lock 实现提供比使用 synchronized 方法和语句可以获得的更广泛的锁定操作。它们允许更灵活的结构化, 可能具有完全不同的属性, 并且可以支持多个相关联的对象 Condition 。

当在不同范围内发生锁定和解锁时, 必须注意确保在锁定时执行的所有代码由 try-finally 或 try-catch 保护, 以确保在必要时释放锁定。

Lock 实现提供了使用 synchronized 方法和语句的附加功能, 通过提供非阻塞尝试来获取锁 tryLock(), 尝试获取可被中断的锁 lockInterruptibly(), 以及尝试获取可以超时 tryLock(long, TimeUnit)

1
2
3
4
5
6
7
8
9
10
11
// 创建可重入锁
private final ReentrantLock lock = new ReentrantLock();
try {
//上锁
lock.lock();
//功能操作
...
} finally {
//解锁
lock.unlock();
}

2. synchronized锁升级

根据对象头的 mark word 锁标志位来确定当前属于哪一种锁

img

偏向锁:MarkWord存储的是偏向的线程ID

轻量锁:MarkWord存储的是指向线程栈中Lock Record的指针

重量锁:MarkWord存储的是指向堆中的monitor对象的指针

在Java早期版本中, synchronized属于重量级锁, 效率低下, 因为监视器锁 (monitor)是依赖于底层的操作系统的Mutex Lock(系统互斥)来实现的, 挂起线程和恢复线程都需要转入内核态去完成

Monitor, Java对象, 线程之间是如何关联的

  • 如果一个java对象被某个线程锁住, 则该对象的 Mark Word 字段中 LockWord 指向 Monitor 的起始地址
  • Monitor 的 owner 字段会存放拥有相关联对象锁的线程id

Java 6之后, 为了减少获得锁和释放锁所带来的性能消耗, 引入了轻量级锁和偏向锁

在这里插入图片描述

2.1 无锁

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
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
/*
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
*/

Object o = new Object();
System.out.println(o.hashCode()); // 只有计算了才会存储下来
System.out.println(ClassLayout.parseInstance(o).toPrintable());
/*
2133927002
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000007f31245a01 (hash: 0x7f31245a; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
*/

2.2 偏向锁

偏向锁:单线程竞争, 当线程A第一次竞争到锁时, 通过修改MarkWord中的偏向线程ID、偏向锁位。如果不存在其他线程竞争, 那么持有偏向锁的线程将永远不需要进行同步。

HotSpot的作者经过研究发现, 大多数情况下:在多线程情况下, 锁不仅不存在多线程竞争, 还存在由同一个线程多次获得的情况, 偏向锁就是在这种情况下出现的, 它的出现是为了解决只有一个线程执行同步时提高性能。当一段同步代码一直被同一个线程多次访问, 由于只有一个线程那么该线程在后续访问时便会自动获得锁

只需要在锁第一次被拥有的时候, 记录下偏向线程ID。这样偏向线程就一直持有着锁 (后续这个线程进入和退出这段加了同步锁的代码块时, 不需要再次加锁和释放锁。而是直接会去检有锁的MarkWord里面是不是放的自己的线程ID)

  • 如果相等, 表示偏向锁是偏向于当前线程的, 就不需要再尝试获得锁了, 直到竞争发生才释放锁。以后每次同步, 检查锁的偏向线程ID与当前线程ID是否一致, 如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。
  • 如果不等, 表示发生了竞争, 锁已经不是总是偏向于同一个线程了, 这个时候会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID
    • 竞争成功, 表示之前的线程不存在了, MarkWord里面的线程ID为新线程的ID, 锁不会升级, 仍然为偏向锁
    • 竞争失败, 这时候可能需要升级变为轻量级锁, 才能保证线程间公平竞争锁。

偏向锁只有遇到其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁, 线程是不会主动释放偏向锁的。

启动偏向锁

测试命令与代码

1
2
3
4
5
6
7
java -XX:+PrintFlagsInitial | grep BiasedLock*
# intx BiasedLockingBulkRebiasThreshold = 20 {product}
# intx BiasedLockingBulkRevokeThreshold = 40 {product}
# intx BiasedLockingDecayTime = 25000 {product}
# intx BiasedLockingStartupDelay = 4000 {product} 启动延时
# bool TraceBiasedLocking = false {product}
# bool UseBiasedLocking = true {product} 使用偏向锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(5000); // 不加会默认使用轻量级锁
Object o = new Object();
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
/*
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000025ca1be3005 (biased: 0x0000000097286f8c; epoch: 0; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
*/

启动偏向锁可以通过

  • 添加JVM参数 -XX:BiasedLockingStartupDelay=0
  • TimeUnit.MILLISECONDS.sleep(4500);

撤销偏向锁

当有另外一个线程逐步来竞争锁的时候, 就不能再使用偏向锁了, 要升级为轻量级锁, 偏向锁是等到竞争出现才释放锁的机制

竞争线程尝试CAS更新对象头失败, 会等到全局安全点(此时不会执行任何字节码)撤销偏向锁, 同时检查持有偏向锁的线程是否还在执行:

  • 第一个线程正在执行synchronized方法(处于同步块), 它还没有执行完, 其他线程来抢夺, 该偏向锁会被取消掉并出现锁升级, 此时轻量级锁由原来持有偏向锁的线程持有, 继续执行同步代码块, 而正在竞争的线程会自动进入自旋等待获得该轻量级锁
  • 第一个线程执行完synchronized(退出同步块), 则将对象头设置为无锁状态并撤销偏向锁, 重新偏向

img

Java15以后逐步废弃偏向锁, 需要手动开启(维护成本高)

2.3 轻量级锁

多线程竞争, 但是任意时候最多只有一个线程竞争, 即不存在锁竞争太激烈的情况, 也就没有线程阻塞, 本质是自旋锁CAS

轻量级锁是为了在线程近乎交替执行同步块时提高性能, 通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗

当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁

轻量级锁的加锁与释放

加锁

JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间, 官方称为Displaced Mark Word (也即Lock Record, 锁记录)。若一个线程获得锁时发现是轻量级锁, 会把锁的Mark Word复制到自己的Displaced Mark Word里面。

拷贝成功后, 虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针, 并将Lock Record里的owner指针指向对象的Mark Word。

  • 如果成功, 当前线程获得锁
  • 如果失败, 虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧
    • 是就说明当前线程已经拥有了这个对象的锁, 那就可以直接进入同步块继续执行
    • 否则表示Mark Word已经被替换成了其他线程的锁记录, 说明在与其它线程竞争锁, 当前线程就尝试使用自旋来获取锁。

但是当自旋超过一定的次数, 或者一个线程在持有锁, 一个在自旋, 又有第三个来访时, 轻量级锁升级为重量级锁。

加锁CAS说明:当前值是目前程序中Mark Word中的pointer的值, pointer的预期原值是null, 而目的值则是指向自己Lock Record的指针

释放

在释放锁时, 当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争, 那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁, 那么CAS操作会失败, 此时会释放锁并唤醒被阻塞的线程

解锁CAS说明:当前值是目前程序中的Mark Word的值, 预期值是Lock Record存的值, 而目的值是把Mark Word修改为Lock Record的值, 也就是修改为预期值

自旋升级

Java6之前

  • 默认启动, 默认情况下自旋次数超过 10 次 (通过 -XX:PreBlockSpin=10 来修改), 或者自旋线程数超过 CPU 核数的一半, 则升级为重量级锁

Java6之后

  • 使用自适应自旋锁, 自旋的次数不是固定不变的, 而是根据同一个锁上一次自旋的时间拥有锁线程的状态来决定
    • 线程如果自旋成功了, 那下次自旋的最大次数会增加, 因为JVM认为既然上次成功了, 那么这一次也大概率会成功
    • 如果很少会自旋成功, 那么下次会减少自旋的次数甚至不自旋, 避免CPU空转

轻量级锁和偏向锁的区别

  • 争夺轻量锁失败时, 自旋尝试抢占锁
    • 轻量级锁的获取及释放依赖多次CAS原子指令, 而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
  • 轻量级锁每次退出同步块都需要释放锁, 而偏向锁是在竞争发生时才释放锁

2.4 重量级锁

有大量线程参与锁的竞争, 冲突性很高

Java中synchronized的重量级锁, 是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter指令, 在结束位置插入monitor exit指令

当线程执行到monitor enter指今时, 会尝试获取对象所对应的Monitor所有权, 如果获取到了, 即获取到了锁, 会在Monitor的owner中存放当前线程的id, 这样它将处于锁定状态, 除非退出同步块, 否则其他线程无法获取到这个Monitor

2.5 锁升级补充

hashcode

当一个对象已经计算过一致性哈希码后, 它就再也无法进入偏向锁状态了, 直接升级为轻量级锁

当一个对象正处于偏向锁状态, 又收到需要计算一致性哈希码请求时, 它的偏向状态会被立即撤销, 并且锁会膨胀为重量级锁

  • 代表重量级锁的ObjectMonitor类中有字段可以记录非加锁状态(标志位为01)下的MarkWord, 其中可以存储原来的哈希码

在无锁状态下, Mark Word中可以存储对象的identity hash code值。当对象的hashCode()方法第一次被调用时, JVM会生成对应的identity hash code值并将该值存储到Mark Word中。

对于偏向锁, 在线程获取偏向锁时, 会用Thread ID和epoch值覆盖identity hash code所在的位置。如果一个对象的hashCode()方法已经被调用过一次之后, 这个对象不能被设置偏向锁。

升级为轻量级锁时, JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间, 用于存储锁对象的Mark Word拷贝, 该拷贝中可以包含identity hash code, 所以轻量级锁可以和 identity hash code共存, 哈希码和GC年龄自然保存在此, 释放锁后会将这些信息写回到对象头。

升级为重量级锁后, Mark Word保存的重量级锁指针, 代表重量级锁的ObjectMonitor类里有字段记录非加锁状态下的Mark Word, 锁释放后也会将信息写回到对象头。

2.6 JIT编译器对锁的优化

JIT:Just In Time Compiler,即时编译器

锁消除

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
36
37
38
39
40
41
/**
* 锁消除
* 从JIT角度看相当于无视他,synchronized(o)不存在了
* 这个锁对象并没有被共用扩散到其他线程使用
* 极端的说就是根本没有加锁对象的底层机器码,消除了锁的使用
*/
public class LockClearUpDemo {
static Object object = new Object();

public void m1() {
// synchronized (object) {
// System.out.println("hello ------ LockClearUpDemo");
// }
//锁消除问题,JIT会无视它,底层并没有加锁
Object o = new Object();
synchronized (o) {
System.out.println("-----------hello LockClearUpDemo" + "\t" + o.hashCode() + "\t" + object.hashCode());
}
}

public static void main(String[] args) {
LockClearUpDemo lockClearUpDemo = new LockClearUpDemo();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
lockClearUpDemo.m1();
}, String.valueOf(i)).start();
}
}
}
/**
* -----------hello LockClearUpDemo 229465744 57319765
* -----------hello LockClearUpDemo 219013680 57319765
* -----------hello LockClearUpDemo 1109337020 57319765
* -----------hello LockClearUpDemo 94808467 57319765
* -----------hello LockClearUpDemo 973369600 57319765
* -----------hello LockClearUpDemo 64667370 57319765
* -----------hello LockClearUpDemo 1201983305 57319765
* -----------hello LockClearUpDemo 573110659 57319765
* -----------hello LockClearUpDemo 1863380256 57319765
* -----------hello LockClearUpDemo 1119787251 57319765
*/

锁粗化

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
/**
* 锁粗化
* 假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器会把这几个synchronized块合并为一个大块
* 加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提高了性能
*/
public class LockBigDemo {
static Object objectLock = new Object();

public static void main(String[] args) {
new Thread(() -> {
synchronized (objectLock) {
System.out.println("111111111111");
}
synchronized (objectLock) {
System.out.println("222222222222");
}
synchronized (objectLock) {
System.out.println("333333333333");
}
synchronized (objectLock) {
System.out.println("444444444444");
}
//底层JIT的锁粗化优化为如下代码
// synchronized (objectLock) {
// System.out.println("111111111111");
// System.out.println("222222222222");
// System.out.println("333333333333");
// System.out.println("444444444444");
// }
}, "t1").start();
}
}

3. 乐观锁|悲观锁|表锁|行锁|读锁|写锁

悲观锁:认为自己在使用数据的时候一定有别的线程来修改数据, 因此在获取数据的时候会先加锁, 确保数据不会被别的线程修改, synchronized 和 Lock 的实现类都是悲观锁

  • 适合写操作多的场景, 先加锁可以保证写操作时数据正确, 显式的锁定之后再操作同步资源

image-20231229131203595

乐观锁:认为自己在使用数据的时候不会有别的线程修改数据, 不会添加锁, Java中使用无锁编程来实现, 只是在更新的时候去判断, 之前有没有别的线程更新了这个数据, 如果这个数据没有被更新, 当前线程将自己修改的数据成功写入, 如果已经被其他线程更新, 则根据不同的实现方式执行不同的操作, 比如:放弃修改、重试抢锁等等。判断规则有:版本号机制Version, 最常采用的是CAS算法, Java原子类中的递增操作就通过CAS自旋实现的。

  • 适合读操作多的场景, 不加锁的特性能够使其读操作的性能大幅提升, 乐观锁直接去操作同步资源, 是一种无锁算法

image-20231229131238997

表锁:整个表操作, 不会发生死锁

行锁:每个表中的单独一行进行加锁, 会发生死锁

读锁:共享锁(可以有多个人读), 会发生死锁

写锁:独占锁(只能有一个人写), 会发生死锁

4. 公平锁和非公平锁

  • 公平锁:是指多个线程按照申请锁的顺序来获取锁, 这里类似于排队买票, 先来的人先买, 后来的人再队尾排着, 这是公平的
    • Lock lock = new ReentrantLock(true)
  • 非公平锁:是指多个线程获取锁的顺序并不是按照申请的顺序, 有可能后申请的线程比先申请的线程优先获取锁, 在高并发环境下, 有可能造成优先级反转或者饥饿的状态(某个线程一直得不到锁)
    • Lock lock = new ReentrantLock(false), 默认为非公平锁。

面试题:

  • 为什么会有公平锁/非公平锁的设计?为什么默认非公平?

    • 恢复挂起的线程到真正锁的获取还是有时间差的, 从开发人员来看这个时间微乎其微, 但是从CPU的角度来看, 这个时间差存在的还是很明显的。所以非公平锁能更充分地利用CPU的时间片, 尽量减少CPU空间状态时间

    • 使用多线程很重要的考量点是线程切换的开销, 当采用非公平锁时, 当一个线程请求锁获取同步状态, 然后释放同步状态, 所以刚释放锁的线程在此刻再次获取同步状态的概率就变得很大, 所以就减少了线程的开销

  • 什么时候用公平?什么时候用非公平?

    • 如果为了更高的吞吐量, 很显然非公平锁是比较合适的, 因为节省了很多线程切换的时间, 吞吐量自然就上去了;否则就用公平锁, 大家公平使用。

5. 可重入锁(递归锁)

  • 隐式锁(即synchronized关键字使用的锁), 默认是可重入锁

    • 在一个synchronized修饰的方法或者代码块的内部调用本类的其他synchronized修饰的方法或者代码块时, 是永远可以得到锁。
  • 显式锁(即Lock)也有 ReentrantLock 这样的可重入锁

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
36
37
38
39
40
41
42
43
44
45
46
/**
* @author Guanghao Wei
* @create 2023-04-10 16:05
*/
public class ReEntryLockDemo {

public static void main(String[] args) {
final Object o = new Object();
new Thread(() -> {
synchronized (o) {
System.out.println("---------------外层调用");
synchronized (o) {
System.out.println("---------------中层调用");
synchronized (o) {
System.out.println("---------------内层调用");
}
}
}
}, "t1").start();

/**
* 注意:加锁几次就需要解锁几次
*/
Lock lock = new ReentrantLock();
new Thread(() -> {
lock.lock();
try {
System.out.println("---------------外层调用");
lock.lock();
try {
System.out.println("---------------中层调用");
lock.lock();
try {
System.out.println("---------------内层调用");
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
}, "t2").start();
}
}

重入的实现机理

  • 每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针
  • 当执行monitorenter时, 如果目标锁对象的计数器为零, 那么说明它没有被其他线程所持有, Java虚拟机会将该锁对象的持有线程设置为当前线程, 并且将其计数器加1
  • 在目标锁对象的计数器不为零的情况下, 如果锁对象的持有线程是当前线程, 那么Java虚拟机可以将其计数器加1, 否则需要等待, 直至持有线程释放该锁
  • 当执行monitorexit时, Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放

6. 死锁及排查

死锁是指两个或两个以上的线程在执行过程中, 因抢夺资源而造成的一种互相等待的现象, 若无外力干涉, 则它们无法再继续推进下去。

产生原因:

  • 系统资源不足
  • 进程运行推进顺序不合适
  • 系统资源分配不当

6.1 排查死锁

纯命令

  • jps -l

  • jstack 进程编号

在这里插入图片描述

image.png

图形化

  • jconsole

image.png

7. 读写锁

7.1 读写锁的使用

读写锁(ReentrantReadWriteLock):一个资源可以被多个读线程访问, 也可以被一个写线程访问, 但不能同时存在读写线程

  • 读读共享, 读写互斥, 写写互斥
  • 支持锁降级, 不支持锁升级

创建读写锁对象 private ReadWriteLock rwLock = new ReentrantReadWriteLock();

写锁(ReentrantReadWriteLock.WriteLock)

  • 加锁: rwLock.writeLock().lock()
  • 解锁: rwLock.writeLock().unlock()

读锁(ReentrantReadWriteLock.ReadLock)

  • 加锁: rwLock.readLock().lock()
  • 解锁: rwLock.readLock().unlock()
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
36
37
38
39
40
41
class MyCache{
// 需要模仿从Map中取对象, 所以先穿件一个map对象
private volatile Map<String, Object> map = new HashMap<>();

// 创建读写锁
private ReadWriteLock rwlock = new ReentrantReadWriteLock();

// 放数据
public void put(String key, Object value) {
// 添加写锁
rwlock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"正在写操作"+key);
TimeUnit.MICROSECONDS.sleep(300);
map.put(key, value);
System.out.println(Thread.currentThread().getName()+"写完了"+key);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放写锁
rwlock.writeLock().unlock();
}
}

// 取数据
public void get(String key) {
// 添加读锁
rwlock.readLock().lock();;
try {
System.out.println(Thread.currentThread().getName()+"正在取操作"+key);
TimeUnit.MICROSECONDS.sleep(300);
map.get(key);
System.out.println(Thread.currentThread().getName()+"取完了"+key);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放读锁
rwlock.readLock().unlock();
}
}
}

7.2 读写锁的演变

无锁无序 -> 加独占锁 -> 读写锁 -> 邮戳锁

读写锁缺点

  • 造成锁饥饿, 一直读, 没有写操作
  • 读的时候不能写, 只有读完才能写, 写的时候可以读

img

7.3 锁降级

  • 将写锁降级为读锁: 遵循获取写锁、获取读锁再释放写锁的次序, 写锁能够降级为读锁
  • 如果一个线程持有了写锁, 在没有释放写锁的情况下, 它还可以继续获得读锁。这就是写锁的降级, 降级成为了读锁。
  • 如果释放了写锁, 那么就完全转换为读锁
  • 如果有线程在读, 那么写线程是无法获取写锁的, 是悲观锁的策略

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) {
//可重入读写锁对象
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();//读锁
ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();//写锁
//锁降级
//1 获取写锁
writeLock.lock();
System.out.println("---write");
//2 获取读锁
readLock.lock();
System.out.println("---read");
//3 释放写锁
writeLock.unlock();
//4 释放读锁
readLock.unlock();
}
// 该顺序可正常执行
// 如果 1 2 互换则无法执行, 因为不支持锁升级, 在读锁后不允许写, 所以会被阻塞

锁降级的必要性

主要是为了保证数据的可见性, 如果当前线程不获取读锁而是直接释放写锁, 假设此刻另一个线程(记作线程T)获取了写锁并修改了数据, 那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁, 即遵循锁降级的步骤, 则线程T将会被阻塞, 直到当前线程使用数据并释放读锁之后, 线程T才能获取写锁进行数据更新。

这时因为可能存在一个事务线程不希望自己的操作被别的线程中断, 而这个事务操作可能分成多部分操作更新不同的数据(或表)甚至非常耗时。如果长时间用写锁独占, 显然对于某些高响应的应用是不允许的, 所以在完成部分写操作后, 退而使用读锁降级, 来允许响应其他进程的读操作。只有当全部事务完成后才真正释放锁。

所以总结下锁降级的意义应该就是:在一边读一边写的情况下提高性能。

img

8. 邮戳锁 StampedLock

StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化

Stamp代表了锁的状态(戳记,long类型)。当Stamp返回零时,表示线程获取锁失败,并且当释放锁或者转换锁的时候,都要传入最初获取的Stamp值。

  • 所有获取锁的方法,都返回一个邮戳,Stamp为零表示失败,其余都表示成功
  • 所有释放锁的方法,都需要一个邮戳,这个Stamp必须是和成功获取锁时得到的Stamp一致

StampedLock是不可重入的(如果一个线程已经持有了写锁,再去获取写锁会造成死锁)

StampedLock有三种访问模式:

  • Reading(读模式悲观):功能和ReentrantReadWriteLock的读锁类似
  • Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
  • Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读时没人修改,假如被修改在实现升级为悲观读模式

8.1 锁饥饿

锁饥饿问题:

  • ReentrantReadWriteLock实现了读写分离,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,因此当前有可能会一直存在读锁,而无法获得写锁。

如何解决锁饥饿问题:

  • 使用”公平”策略可以一定程度上缓解这个问题
    • new ReentrantReadWriteLock(true)
    • 但”公平”策略是以牺牲系统吞吐量为代价的
  • StampedLock 采用乐观读锁方式
    • 采取乐观获取锁,其他线程尝试获取写锁时不会被阻塞在获取乐观读锁后,需要对结果进行校验

8.2 代码示例

传统的读写锁模式

  • 读的时候不能获取写锁
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class StampedLockDemo {
static int number = 37;
static StampedLock stampedLock = new StampedLock();

public void write() {
long stamp = stampedLock.writeLock();
System.out.println(Thread.currentThread().getName() + "\t" + "写线程准备修改");
try {
number = number + 13;
} finally {
stampedLock.unlockWrite(stamp);
}
System.out.println(Thread.currentThread().getName() + "\t" + "写线程结束修改");
}

public void read() {
long stamp = stampedLock.readLock();
System.out.println(Thread.currentThread().getName() + "\t" + " come in readLock codeBlock");
for (int i = 0; i < 4; i++) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t" + " 正在读取中");
}
try {
int result = number;
System.out.println(Thread.currentThread().getName() + "\t" + "获得成员变量值result: " + result);
System.out.println("写线程没有修改成功,读锁时候写锁无法介入,传统的读写互斥");
} finally {
stampedLock.unlockRead(stamp);
}

}

public static void main(String[] args) {
StampedLockDemo resource = new StampedLockDemo();
new Thread(() -> {
resource.read();
}, "readThread").start();

try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}

new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"\t"+" come in");
resource.write();
}, "writeThread").start();
}
}
/**
* readThread come in readLock codeBlock
* readThread 正在读取中
* writeThread come in
* readThread 正在读取中
* readThread 正在读取中
* readThread 正在读取中
* readThread 获得成员变量值result: 37
* 写线程没有修改成功,读锁时候写锁无法介入,传统的读写互斥
* writeThread 写线程准备修改
* writeThread 写线程结束修改
*/

乐观读模式

  • 读的过程中也允许写锁介入
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class StampedLockDemo {
static int number = 37;
static StampedLock stampedLock = new StampedLock();

public void write() {
long stamp = stampedLock.writeLock();
System.out.println(Thread.currentThread().getName() + "\t" + "写线程准备修改");
try {
number = number + 13;
} finally {
stampedLock.unlockWrite(stamp);
}
System.out.println(Thread.currentThread().getName() + "\t" + "写线程结束修改");
}

public void read() {
long stamp = stampedLock.tryOptimisticRead(); // 乐观读
int result = number;

System.out.println("4秒前 stampedLock.validate方法值(true 无修改 false有修改)" + "\t" + stampedLock.validate(stamp));
for (int i = 0; i < 4; i++) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t" + " 正在读取...." + i + "秒后" + "stampedLock.validate方法值(true 无修改 false有修改)" + "\t" + stampedLock.validate(stamp));
}
if (!stampedLock.validate(stamp)) {
System.out.println("有人修改----------有写操作");
stamp = stampedLock.readLock();
try {
System.out.println("从乐观读升级为悲观读");
result = number;
System.out.println("重新悲观读后result:" + result);
} finally {
stampedLock.unlockRead(stamp);
}
}
System.out.println(Thread.currentThread().getName() + "\t" + "finally value: " + result);

}


public static void main(String[] args) {
StampedLockDemo resource = new StampedLockDemo();
new Thread(() -> {
resource.read();
}, "readThread").start();

try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}

new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + " come in");
resource.write();
}, "writeThread").start();
}
}
/**
* 4秒前 stampedLock.validate方法值(true 无修改 false有修改) true
* readThread 正在读取....0秒后stampedLock.validate方法值(true 无修改 false有修改) true
* readThread 正在读取....1秒后stampedLock.validate方法值(true 无修改 false有修改) true
* writeThread come in
* writeThread 写线程准备修改
* writeThread 写线程结束修改
* readThread 正在读取....2秒后stampedLock.validate方法值(true 无修改 false有修改) false
* readThread 正在读取....3秒后stampedLock.validate方法值(true 无修改 false有修改) false
* 有人修改----------有写操作
* 从乐观读升级为悲观读
* 重新悲观读后result:50
* readThread finally value: 50
*/

8.2 优缺点

  • StampedLock不支持重入
  • StampedLock的悲观读锁和写锁都不支持条件变量(Condition)
  • 使用StampedLock一定不要调用中断操作,即不要调用interrupt()方法