不得不说的锁事
内置锁的能力不足以满足需求
锁是一种工具,用于控制对共享资源的访问
Lock 和 synchronized,是最常见的锁,都可以达到线程安全的目的,但在使用和功能上有较大的不同
Lock 并不是用来替代 synchronized 的,而是当 synchronized 不适用时,才使用 Lock 来提供高级功能
Lock 接口最常见的实现类是 ReentrantLock
通常情况下,Lock 只允许一个线程来访问共享资源,不过有时候,一些特殊的实现也可允许并发访问,如 ReadWriteLock 中的 ReadLock
为什么 synchronized 不够用?
-
效率低:锁的释放情况少,试图获得锁时不能设定超时、不能中断一个正在试图获得锁的线程
-
不够灵活:加锁和释放的时机单一,每个锁仅有单一的条件(某个对象)
-
无法知道是否成功获取到锁
Lock 主要方法
lock
lock() 最普通的获取锁,如果锁已被其他线程获取,则进行等待。
Lock 锁不会像 synchronized 一样在异常时自动释放锁。基于此,必须在 finally 中释放锁,以保证发生异常时锁一定会被释放
package juc.lock.lock;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class MustUnlock {
private static Lock lock =new ReentrantLock();
public static void main(String[] args) {
lock.lock();
try {
// 获取本锁保护的资源
System.out.println(Thread.currentThread().getName() + "开始执行任务");
} finally {
lock.unlock();
}
}
}
lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等待
tryLock
tryLock() 用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true, 否则返回 false
相比于 lock(), 功能更强大,我们可根据是否获取锁来决定后续程序的行为
该方法会立即返回,即使在拿不到锁时也不会一直在那等
tryLock(long time, TimeUnit unit): 拿不到锁会等待,超时就放弃
package juc.lock.lock;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 用 tryLock 来避免死锁
*/
public class TryLockDeadLock implements Runnable {
int flag = 1;
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
public static void main(String[] args) {
TryLockDeadLock r1 = new TryLockDeadLock();
TryLockDeadLock r2 = new TryLockDeadLock();
r1.flag = 1;
r2.flag = 2;
new Thread(r1).start();
new Thread(r2).start();
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if (flag == 1) {
try {
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程1获取到了锁1");
Thread.sleep(new Random().nextInt(1000));
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程1获取到了锁2");
System.out.println("线程1成功获取到了两把锁");
break;
} finally {
lock2.unlock();
}
} else {
System.out.println("线程1获取锁2失败,已重试");
}
} finally {
lock1.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程1获取锁1失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (flag == 2) {
for (int i = 0; i < 100; i++) {
try {
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程2获取到了锁2");
Thread.sleep(new Random().nextInt(1000));
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程2获取到了锁1");
System.out.println("线程2成功获取到了两把锁");
break;
} finally {
lock1.unlock();
}
} else {
System.out.println("线程2获取锁1失败,已重试");
}
} finally {
lock2.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程2获取锁2失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
获取锁时被中断
package juc.lock.lock;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockInterruptibly implements Runnable {
private Lock lock = new ReentrantLock();
public static void main(String[] args) {
LockInterruptibly lockInterruptibly = new LockInterruptibly();
Thread thread0 = new Thread(lockInterruptibly);
Thread thread1 = new Thread(lockInterruptibly);
thread0.start();
thread1.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 两个线程分别中断看下效果
//thread0.interrupt();
thread1.interrupt();
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 尝试获取锁");
try {
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + " 获取到了锁");
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "睡眠期间被中断了");
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + " 释放了锁");
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "获得锁期间被中断了");
}
}
}
两个线程分别可能在获得锁或者睡眠期间被中断
lockInterruptibly():相当于 tryLock(long time, TimeUnit unit) 把超时时间设置为无限。在等待锁的过程中,线程可以被中断
锁的分类
锁的分类是从不同角度去看的,这些分类并不是互斥的,即多个类型可以并存:有可能一个锁,同时属于两种类型。如:ReentrantLock 既是互斥锁,又是可重入锁
悲观锁
乐观锁(非互斥同步锁)和悲观锁(互斥同步锁)
互斥同步锁的劣势:
-
阻塞和唤醒带来的性能劣势
-
可能陷入永久阻塞
-
优先级反转,优先级低的线程拿到了锁,优先级高的只能等
悲观锁:如果我不锁住这个资源,别人就会来争抢,造成数据结果错误。所以在每次获取并修改数据时,会把数据锁住,让别人无法访问该数据
Java 中悲观锁的实现就是 synchronized 和 Lock 相关类
悲观锁的执行流程:
乐观锁
乐观锁:认为自己在处理操作时不会有其他线程来干扰,所以并不会锁住被操作对象。在更新数据时,去对比修改期间数据有没有被其他人改变过
如果没被改变,说明真的只有自己在操作,就正常去修改数据
如果数据和我一开始拿到的不一样,说明其他人在这段时间内改过数据,我就不能继续刚才的更新数据过程了,会选择高难度、报错、重试等策略
乐观锁的实现一般都是利用 CAS 算法来实现的
乐观锁的典型例子:原子类、并发容器
两者对比
开销对比:
悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响
相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多
使用场景:
悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况:
-
临界区有 IO 操作
-
临界区代码复杂或者循环量大
-
临界区竞争非常激烈
乐观锁:适合并发写入少,大部分是读取的场景,不加锁能让读取性能大幅提高
可重入锁和非可重入锁
package juc.lock.reentrantlock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 演示多线程预定电影院座位
*/
public class CinemaBookSeat {
private static final ReentrantLock lock = new ReentrantLock();
private static void bookSeat() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始预定座位");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " 完成预定座位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
new Thread(CinemaBookSeat::bookSeat).start();
new Thread(CinemaBookSeat::bookSeat).start();
new Thread(CinemaBookSeat::bookSeat).start();
new Thread(CinemaBookSeat::bookSeat).start();
}
}
什么是可重入: 可以重复获取同一把锁,不需要先释放
好处:
-
避免死锁: 适用于一个线程需要多次获取同一把锁
-
提升封装性: 不用频繁地释放与加锁
package juc.lock.reentrantlock;
import java.util.concurrent.locks.ReentrantLock;
public class GetHoldCount {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
// 多次获取同一把锁
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
// 释放同一把锁
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
}
}
package juc.lock.reentrantlock;
import java.util.concurrent.locks.ReentrantLock;
public class RecursionDemo {
private static ReentrantLock lock = new ReentrantLock();
private static void accessResource() {
lock.lock();
try {
System.out.println("已经对资源进行了处理");
// 递归处理,每次递归都再次加锁
if (lock.getHoldCount() < 5) {
System.out.println(lock.getHoldCount());
accessResource();
System.out.println(lock.getHoldCount());
}
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
accessResource();
}
}
公平锁与非公平锁
公平指的是按照线程请求的顺序来分配锁
非公平指的是不完全按照请求的顺序,在一定情况下,可以插队。并不提倡插队行为,在合适的时机才插队,而不是盲目插队
那么,什么是合适的时机呢?为什么要有非公平锁?
为了提高效率,避免唤醒带来的空档期
线程A释放锁,唤醒线程B,线程B唤醒需要时间开销,在这个空档期可以让正好处于唤醒状态的线程C占用
如果在创建 ReentrantLock 时,参数为 true, 就是公平锁
公平锁演示:
package juc.lock.reentrantlock;
import java.util.Random;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 演示公平和不公平两种情况
*/
public class FairLock {
public static void main(String[] args) throws InterruptedException {
PrintQueue printQueue = new PrintQueue();
int num = 3;
Thread thread[] = new Thread[num];
for (int i = 0; i < num; i++) {
thread[i] = new Thread(new Job(printQueue));
}
for (int i = 0; i < num; i++) {
thread[i].start();
Thread.sleep(100);
}
}
}
class Job implements Runnable {
private PrintQueue printQueue;
public Job(PrintQueue printQueue) {
this.printQueue = printQueue;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 开始打印");
printQueue.printJob(new Object());
System.out.println(Thread.currentThread().getName() + " 开始完毕");
}
}
class PrintQueue {
private Lock queueLock = new ReentrantLock(true);
public void printJob(Object document) {
// 同一文档打印两次
queueLock.lock();
try {
int duration = new Random().nextInt(10) + 1;
System.out.println(Thread.currentThread().getName() + "正在打印,需要 " + duration + "秒");
Thread.sleep(duration * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
queueLock.lock();
try {
int duration = new Random().nextInt(10) + 1;
System.out.println(Thread.currentThread().getName() + "正在打印,需要 " + duration + "秒");
Thread.sleep(duration * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
}
}
一个个有序排队
如果设置为非公平锁,会是怎样的情况?
private Lock queueLock = new ReentrantLock(false);
两者对比:
共享锁和排他锁
排他锁: 又称为独占锁、独享锁
共享锁:又称为读锁,获得共享锁后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,同样也只能查看
典型是读写锁 RenentrantReadWriteLock,有两把锁,读锁是共享锁,写锁是独享锁
读写锁的规则:
-
多个线程只申请读锁,都可以申请到
-
如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,会一直等待释放读锁
-
如果有一个线程已经占用了写锁,其他申请读或写锁的线程,都会一直等待
总结:要么是一个多个线程同时有读锁,要么是一个线程有写锁
package juc.lock.readwrite;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class CinemaReadWrite {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 得到了读锁,正在读取");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + " 释放读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 得到了写锁,正在写入");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + " 释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
// 读锁可以同时持有
new Thread(CinemaReadWrite::read, "Thread1").start();
new Thread(CinemaReadWrite::read, "Thread2").start();
// 写锁只能挨个持有
new Thread(CinemaReadWrite::write, "Thread3").start();
new Thread(CinemaReadWrite::write, "Thread4").start();
}
}
读锁和写锁的交互方式
非公平:假设线程2和线程4正在同时读取,线程3想要写入,拿不到锁,于是进入等待队列,线程5不在队列里,现在过来想要读取。此时有两种策略
策略1:线程5插队
读可以插队,效率高
容易造成饥饿,如果后面一直有读的线程,就一直排在线程3之前,那么线程3一直获取不到写锁
策略2:线程5排队
避免饥饿
策略的选择取决于具体锁的实现,ReentrantReadWriteLock 的实现是选择了策略2,不允许插队
读锁插队策略
公平锁:不允许插队
非公平锁:
-
写锁可以随时插队,写锁的获取并不容易
-
读锁仅在等待队列头结点不是想获取写锁的线程时可以插队
演示读锁插队:
package juc.lock.readwrite;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class NonfairBargeDemo {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
System.out.println(Thread.currentThread().getName() + " 开始尝试获取读锁");
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 得到读锁, 正在读取");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
System.out.println(Thread.currentThread().getName() + " 释放读锁");
readLock.unlock();
}
}
private static void write() {
System.out.println(Thread.currentThread().getName() + " 开始尝试获取写锁");
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 得到写锁, 正在写入");
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
System.out.println(Thread.currentThread().getName() + " 释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
new Thread(NonfairBargeDemo::write, "Thread1").start();
new Thread(NonfairBargeDemo::read, "Thread2").start();
new Thread(NonfairBargeDemo::read, "Thread3").start();
new Thread(NonfairBargeDemo::write, "Thread4").start();
new Thread(NonfairBargeDemo::read, "Thread5").start();
new Thread(new Runnable() {
final int num = 1000;
@Override
public void run() {
Thread[] thread = new Thread[num];
for (int i = 0; i < num; i++) {
thread[i] = new Thread(NonfairBargeDemo::read, "子线程创建的 Thread " + i);
}
for (int i = 0; i < num; i++) {
thread[i].start();
}
}
}).start();
}
}
锁的升降级
支持锁的降级,不支持升级
场景:先写日志,再读日志。如果一直占用写锁,会影响其他线程的读取,降级为读锁,其他线程就可以共享读锁
writeLock.lock();
try {
// 降级为读锁:在不释放写锁的情况下,获取读锁
readLock.lock();
} finally {
writeLock.unlick();
readLock.unlock();
}
自旋锁和阻塞锁
阻塞和唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间
如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失
如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁
而为了让当前线程”稍等一下“,我们需要让当前线程自旋,如果在自旋完成后前面锁定同步资源的线程已经释放锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁
阻塞锁和自旋锁相反,阻塞锁如果遇到没拿到锁的情况,会直接把线程阻塞,直到被唤醒
自旋锁的缺点:
-
如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源
-
在自旋的过程中,一直消耗 cpu,虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的
java 1.5 版本及以上的并发框架 java.util.concurrent 的 atmoic 包下的类基本都是自旋锁的实现
AtomicInteger 的实现:自旋锁的实现原理是 CAS, AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在 while 里死循环,直至修改成功
自定义一个自旋锁
package juc.lock.spinlock;
import java.util.concurrent.atomic.AtomicReference;
public class SpinLock {
private AtomicReference<Thread> sign = new AtomicReference<>();
public static void main(String[] args) {
SpinLock spinLock = new SpinLock();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 开始尝试获取自旋锁");
spinLock.lock();
System.out.println(Thread.currentThread().getName() + " 获取到了自旋锁");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + " 释放了自旋锁");
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
public void lock() {
Thread current = Thread.currentThread();
while (!sign.compareAndSet(null, current)) {
System.out.println(current.getName() + " 自旋获取失败,再次尝试");
}
}
public void unlock() {
Thread current = Thread.currentThread();
sign.compareAndSet(current, null);
}
}
结果:
Thread-0 开始尝试获取自旋锁
Thread-1 开始尝试获取自旋锁
Thread-0 获取到了自旋锁
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
... 一直自旋
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 自旋获取失败,再次尝试
Thread-1 获取到了自旋锁
Thread-0 释放了自旋锁
Thread-1 释放了自旋锁
自旋锁的适用场景:
-
一般用于多核的服务器,在并发度不是特别高的情况下,比阻塞锁的效率高
-
适用于临界区比较短的情况
可中断锁
如果某一线程 A 正在执行锁中的代码,另一线程 B 正在等待获取该锁,由于等待时间过长,线程 B 不想等了,想先处理其他事,我们可以中断它,就是可中断锁
在 java 中,synchronized 是不可中断锁,Lock 是可中断锁
锁优化
JVM 对锁的优化:
自旋锁:自旋一定次数后,如果还不成功,会转为阻塞锁
锁消除:消除没必要的锁
开发时,如何优化锁:
-
缩小同步代码块
-
尽量不要锁住方法
-
减少请求锁的次数
-
锁中尽量不要再包含锁
- 上一篇: ThreadLocal一次解决老大难问题
- 下一篇: atomic包-原子类
