并发编程之 Java 三把锁

时间:2022-05-08 21:20:53

并发编程之 Java 三把锁

前言

今天我们继续学习并发。在之前我们学习了 JMM 的知识,知道了在并发编程中,为了保证线程的安全性,需要保证线程的原子性,可见性,有序性。其中,synchronized 高频出现,因为他既保证了原子性,也保证了可见性和有序性。为什么,因为 synchronized 是锁。通过锁,可以让原本并行的任务变成串行。然而如你所见,这也导致了严重的性能受损。因此,不到万不得已,不要使用锁,特别是吞吐量要求特别高的 WEB 服务器。如果锁住,性能将呈几何级下降。

但我们仍然需要锁,在某些操作共享变量的时刻,仍然需要锁来保证数据的准确性。而Java 世界有 3 把锁,今天我们主要说说这 3 把锁的用法。

  1. synchronized 关键字
  2. ReentrantLock 重入锁
  3. ReadWriteLock 读写锁

1. synchronized 关键字

synchronized 可以说是我们学习并发的时候第一个学习的关键字,该关键字粗鲁有效,通常是初级程序员最爱使用的,也因此会经常导致一些性能损失和死锁问题。

下面是 synchronized 的 3 个用法:

  void resource1() {
synchronized ("resource1") {
System.out.println("作用在同步块中");
}
} synchronized void resource3() {
System.out.println("作用在实例方法上");
} static synchronized void resource2() {
System.out.println("作用在静态方法上");
}

整理以下这个关键字的用法:

  1. 指定加锁对象(代码块):对给定对象加锁,进入同步代码前要获得给定对象的锁。
  2. 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
  3. 直接作用于静态方法:相当于对当前类加锁,进入同步代码块前要获得当前类的锁。

synchronized 在发生异常的时候会释放锁,这点需要注意一下。

synchronized 修饰的代码在生产字节码的时候会有 monitorenter 和 monitorexit 指令,而这两个指令在底层调用了虚拟机8大指令中其中两个指令-----lock 和 unlock。

synchronized 虽然万能,但是还是有很多局限性,比如使用它经常会发生死锁,且无法处理,所以 Java 在 1.5版本的时候,加入了另一个锁 Lock 接口。我们看看该接口下的有什么。

2. ReentrantLock 重入锁

JDK 在 1.5 版本新增了java.util.concurrent 包,有并发大师 Doug Lea 编写,其中代码鬼斧神工。值得我们好好学习,包括今天说的 Lock。

Lock 接口


/**
* @since 1.5
* @author Doug Lea
*/
public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition();

void lock(); 获得锁

void lockInterruptibly() ;

boolean tryLock(); 尝试获取锁,如果获取不到,立刻返回false。

boolean tryLock(long time, TimeUnit unit) 在

void unlock(); 在给定的时间里等待锁,超过时间则自动放弃

Condition newCondition(); 获取一个重入锁的好搭档,搭配重入锁使用

上面说了Lock的机构抽象方法,那么 Lock 的实现是什么呢?标准实现了 ReentrantLock, ReadWriteLock。也就是我们今天讲的重入锁和读写锁。我们先讲重入锁。

先来一个简单的例子:

package cn.think.in.java.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; public class LockText implements Runnable { /**
* Re - entrant - Lock
* 重入锁,表示在单个线程内,这个锁可以反复进入,也就是说,一个线程可以连续两次获得同一把锁。
* 如果你不允许重入,将导致死锁。注意,lock 和 unlock 次数一定要相同,如果不同,就会导致死锁和监视器异常。
*
* synchronized 只有2种情况:1继续执行,2保持等待。
*/
static Lock lock = new ReentrantLock();
static int i; public static void main(String[] args) throws InterruptedException {
LockText lockText = new LockText();
Thread t1 = new Thread(lockText);
Thread t2 = new Thread(lockText);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
} @Override
public void run() {
for (int j = 0; j < 1000000; j++) {
lock.lock();
try {
i++;
} finally {
// 因为lock 如果发生了异常,是不会释放锁的,所以必须在 finally 块中释放锁
// synchronized 发生异常会主动释放锁
lock.unlock();
}
}
}
}

在上面的代码中,我们使用了try 块中保护了临界资源 i 的操作。可以看到, 重入锁不管是开启锁还是释放锁都是显示的,其中需要注意的一点是,重入锁运行时如果发生了异常,不会像 synchronized 释放锁,因此需要在 finally 中释放锁。否则将产生死锁。

什么是重入锁?锁就是锁呗,为什么叫重入锁?之所以这么叫,那是因为这种锁是可以反复进入的(一个线程),大家看看下面的代码:

lock.lock();
lock.lock();
tyr{
i++;
} finally{
lock.unlock();
lock.unlock();
}

在这种情况下,一个线程连续两次获得两把锁,这是允许的。如果不允许这么操作,那么同一个线程咋i第二次获得锁是,将会和自己产生死锁。当然,需要注意的是,如果你多次获得了锁,那么也要相同的释放多次,如果释放锁的次数多了,就会得到一个 IllegalMonitorStateException 异常,反之,如果释放锁的次数少了,那么相当于这个线程还没有释放锁,其他线程也就无法进入临界区。

重入锁能够实现 synchronized 的所有功能,而且功能更为强大,我们看看有哪些功能。

中断响应

对于 synchronized 来说,如果一个线程在等待锁,那么结果只有2种,要么他获得这把锁继续运行,要么他就保持等待。没有第三种可能,那如果我有一个需求:需要线程在等待的时候中断线程,synchronizded 是做不到的。而重入锁可以做到,就是 lockInterruptibly 方法,该方法可以获取锁,并且在获取锁的过程种支持线程中断,也就是说,如果调用了线程中断方法,那么就会抛出异常。相对于 lock 方法,是不是更为强大?还是写个例子吧:

package cn.think.in.java.lock;

import java.util.concurrent.locks.ReentrantLock;

/**
* ReentrantLock(重入锁)
*
* Condition(条件)
*
* ReadWriteLock(读写锁)
*/
public class IntLock implements Runnable { /**
* 默认是不公平的锁,设置为 true 为公平锁
*
* 公平:在多个线程的争用下,这些锁倾向于将访问权授予等待时间最长的线程;
* 使用公平锁的程序在许多线程访问时表现为很低的总体吞吐量(即速度很慢,常常极其慢)
* 还要注意的是,未定时的 tryLock 方法并没有使用公平设置
*
* 不公平:此锁将无法保证任何特定访问顺序
*
* 拾遗:1 该类的序列化与内置锁的行为方式相同:一个反序列化的锁处于解除锁定状态,不管它被序列化时的状态是怎样的。
* 2.此锁最多支持同一个线程发起的 2147483648 个递归锁。试图超过此限制会导致由锁方法抛出的 Error。
*/
static ReentrantLock lock1 = new ReentrantLock(true);
static ReentrantLock lock2 = new ReentrantLock();
int lock; /**
* 控制加锁顺序,方便制造死锁
* @param lock
*/
public IntLock(int lock) {
this.lock = lock;
} /**
* lockInterruptibly 方法: 获得锁,但优先响应中断
* tryLock 尝试获得锁,不等待
* tryLock(long time , TimeUnit unit) 尝试获得锁,等待给定的时间
*/
@Override
public void run() {
try {
if (lock == 1) {
// 如果当前线程未被中断,则获取锁。
lock1.lockInterruptibly();// 即在等待锁的过程中,可以响应中断。
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 试图获取 lock 2 的锁
lock2.lockInterruptibly();
} else { lock2.lockInterruptibly();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 该线程在企图获取 lock1 的时候,会死锁,但被调用了 thread.interrupt 方法,导致中断。中断会放弃锁。
lock1.lockInterruptibly();
} } catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock1.isHeldByCurrentThread()) {
lock1.unlock();
} // 查询当前线程是否保持此锁。
if (lock2.isHeldByCurrentThread()) {
lock2.unlock();
} System.out.println(Thread.currentThread().getId() + ": 线程退出");
}
} public static void main(String[] args) throws InterruptedException { /**
* 这部分代码主要是针对 lockInterruptibly 方法,该方法在线程发生死锁的时候可以中断线程。让线程放弃锁。
* 而 synchronized 是没有这个功能的, 他要么获得锁继续执行,要么继续等待锁。
*/ IntLock r1 = new IntLock(1);
IntLock r2 = new IntLock(2);
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r2);
t1.start();
t2.start();
Thread.sleep(1000);
// 中断其中一个线程(只有线程在等待锁的过程中才有效)
// 如果线程已经拿到了锁,中断是不起任何作用的。
// 注意:这点 synchronized 是不能实现此功能的,synchronized 在等待过程中无法中断
t2.interrupt();
// t2 线程中断,抛出异常,并放开锁。没有完成任务
// t1 顺利完成任务。
}
}

在上面的代码种,我们分别启动两个线程,制造了一个死锁,如果是 synchronized 是无法解除这个死锁的,这个时候重入锁的威力就出来了,我们调用线程的 interrupt 方法,中断线程,我们说,这个方法在线程 sleep,join ,wait 的时候,都会导致异常,这里也一羊,由于我们使用的 lock 的 lockInterruptibly 方法,该方法就像我们刚说的那样,在等待锁的时候,如果线程被中断了,就会出现异常,同时调用了 finally 种的 unlock 方法,注意,我们在 finally 中用 isHeldByCurrentThread 判断当前线程是否持有此锁,这是一种预防措施,放置线程没有持有此锁,导致出现 monitorState 异常。

锁申请

除了等待通知之外,避免死锁还有另一种方法,就是超时等待,如果超过这个时间,线程就放弃获取这把锁,这点 ,synchronized 也是不支持的。那么,如何使用呢?

package cn.think.in.java.lock;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock; public class TimeLock implements Runnable { static ReentrantLock lock = new ReentrantLock(false); @Override
public void run() {
try {
// 最多等待5秒,超过5秒返回false,若获得锁,则返回true
if (lock.tryLock(5, TimeUnit.SECONDS)) {
// 锁住 6 秒,让下一个线程无法获取锁
System.out.println("锁住 6 秒,让下一个线程无法获取锁");
Thread.sleep(6000);
} else {
System.out.println("get lock failed");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} public static void main(String[] args) {
TimeLock tl = new TimeLock();
Thread t1 = new Thread(tl);
Thread t2 = new Thread(tl); t1.start();
t2.start(); }
}

上面的代码中,我们设置锁的等待时间是5秒,但是在同步块中,我们设置了6秒暂停,锁外面的线程等待了5面发现还是不能获取锁,就会放弃。走 else 逻辑,结束执行,注意,这里,我们在 finally 块中依然做了判断,如果不做判断,就会出现 IllegalMonitorStateException 异常。

当然了,tryLock 方法也可以不带时间参数,如果获取不到锁,立刻返回false,否则返回 true。该方法也是应对死锁的一个好办法。我们还是写个例子:

package cn.think.in.java.lock;

import java.util.concurrent.locks.ReentrantLock;

public class TryLock implements Runnable {

  static ReentrantLock lock1 = new ReentrantLock();
static ReentrantLock lock2 = new ReentrantLock();
int lock; public TryLock(int lock) {
this.lock = lock;
} @Override
public void run() {
// 线程1
if (lock == 1) {
while (true) {
// 获取1的锁
if (lock1.tryLock()) {
try {
// 尝试获取2的锁
if (lock2.tryLock()) {
try {
System.out.println(Thread.currentThread().getId() + " : My Job done");
return;
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
}
} else {
// 线程2
while (true) {
// 获取2的锁
if (lock2.tryLock()) {
try {
// 尝试获取1的锁
if (lock1.tryLock()) {
try {
System.out.println(Thread.currentThread().getId() + ": My Job done");
return;
} finally {
lock1.unlock();
}
}
} finally {
lock2.unlock();
}
}
}
}
} /**
* 这段代码如果使用 synchronized 肯定会引起死锁,但是由于使用 tryLock,他会不断的尝试, 当第一次失败了,他会放弃,然后执行完毕,并释放外层的锁,这个时候就是
* 另一个线程抢锁的好时机。
* @param args
*/
public static void main(String[] args) {
TryLock r1 = new TryLock(1);
TryLock r2 = new TryLock(2);
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r2);
t1.start();
t2.start();
}
}

这段代码如果使用 synchronized 肯定会引起死锁,但是由于使用 tryLock,他会不断的尝试, 当第一次失败了,他会放弃,然后执行完毕,并释放外层的锁,这个时候就是另一个线程抢锁的好时机。

公平锁和非公平锁

大多数情况下,为了效率,锁都是不公平的。系统在选择锁的时候都是随机的,不会按照某种顺序,比如时间顺序,公平锁的一大特点:他不会产生饥饿现象。只要你排队 ,最终还是可以得到资源的。如果我们使用 synchronized ,得到的锁就是不公平的。因此,这也是重入锁比 synchronized 强大的一个优势。我们同样写个例子:

package cn.think.in.java.lock;

import java.util.concurrent.locks.ReentrantLock;

public class FairLock implements Runnable {

  // 公平锁和非公平锁的结果完全不同
/*
* 10 获得锁
10 获得锁
10 获得锁
10 获得锁
10 获得锁
10 获得锁
10 获得锁
10 获得锁
10 获得锁
10 获得锁
9 获得锁
9 获得锁
9 获得锁
9 获得锁
9 获得锁
9 获得锁
9 获得锁
9 获得锁
9 获得锁
9 获得锁
======================下面是公平锁,上面是非公平锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得锁
9 获得锁
10 获得
*
* */
static ReentrantLock unFairLock = new ReentrantLock(false);
static ReentrantLock fairLock = new ReentrantLock(true); @Override
public void run() {
while (true) {
try {
fairLock.lock();
System.out.println(Thread.currentThread().getId() + " 获得锁");
} finally {
fairLock.unlock();
}
}
} /**
* 默认是不公平的锁,设置为 true 为公平锁
*
* 公平:在多个线程的争用下,这些锁倾向于将访问权授予等待时间最长的线程;
* 使用公平锁的程序在许多线程访问时表现为很低的总体吞吐量(即速度很慢,常常极其慢)
* 还要注意的是,未定时的 tryLock 方法并没有使用公平设置
*
* 不公平:此锁将无法保证任何特定访问顺序,但是效率很高
*
*/
public static void main(String[] args) {
FairLock fairLock = new FairLock();
Thread t1 = new Thread(fairLock, "cxs - t1");
Thread t2 = new Thread(fairLock, "cxs - t2");
t1.start();
t2.start();
}
}

重入锁的构造函数有一个 boolean 参数,ture 表示公平,false 表示不公平,默认是不公平的,公平锁会降低性能。代码中由运行结果,可以看到,公平锁的打印顺序是完全交替运行,而不公平锁的顺序完全是随机的。注意:如果没有特殊需求,请不要使用公平锁,会大大降低吞吐量。

到这里,我们总结一下重入锁相比 synchronized 有哪些优势:

  1. 可以在线程等待锁的时候中断线程,synchronized 是做不到的。
  2. 可以尝试获取锁,如果获取不到就放弃,或者设置一定的时间,这也是 synchroized 做不到的。
  3. 可以设置公平锁,synchronized 默认是非公平锁,无法实现公平锁。

当然,大家会说, synchronized 可以通过 Object 的 wait 方法和 notify 方法实现线程之间的通信,重入锁可以做到吗?楼主告诉大家,当然可以了! JDK 中的阻塞队列就是用重入锁加 他的搭档 condition 实现的。

重入锁的好搭档-----Condition

还记的刚开始说 Lock 接口有一个newCondition 方法吗,该方法就是获取 Condition 的。该 Condition 绑定了该锁。Condition 有哪些方法呢?我们看看:

public interface Condition {

    void await() throws InterruptedException;

    boolean await(long time, TimeUnit unit) throws InterruptedException;

    long awaitNanos(long nanosTimeout) throws InterruptedException;

    boolean await(long time, TimeUnit unit) throws InterruptedException;

    void awaitUninterruptibly();

    boolean awaitUntil(Date deadline) throws InterruptedException;

    void signal();

    void signalAll();
}

看着是不是特别属性,Condition 为了不和 Object 类的 wait 方法冲突,使用 await 方法,而 signal 方法对应的就是 notify 方法。signalAll 方法对应的就是 notifyAll 方法。其中还有一些时间限制的 await 方法,和 Object 的 wait 方法的作用相同。注意,其中有一个 awaitUninterruptibly 方法,该方法从名字可以看出,并不会响应线程的中断,而 Object 的 wait 方法是会响应的。而 awaitUntil 方法就是等待到一个给定的绝对时间。除非调用了 signal 或者中断了。如何使用呢?来一段代码吧:

package cn.think.in.java.lock.condition;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; /**
* 重入锁的好搭档
*
* await 使当前线程等待,同时释放当前锁,当其他线程中使用 signal 或者 signalAll 方法时,线程会重新获得锁并继续执行。
* 或者当线程被中断时,也能跳出等待,这和 Object.wait 方法很相似。
* awaitUninterruptibly() 方法与 await 方法基本相同,但是它并不会在等待过程中响应中断。
* singal() 该方法用于唤醒一个在等待中的线程,相对的 singalAll 方法会唤醒所有在等待的线程,这和 Object.notify 方法很类似。
*/
public class ConditionTest implements Runnable { static Lock lock = new ReentrantLock(); static Condition condition = lock.newCondition(); @Override
public void run() {
try {
lock.lock();
// 该线程会释放 lock 的锁,也就是说,一个线程想调用 condition 的方法,必须先获取 lock 的锁。
// 否则就会像 object 的 wait 方法一样,监视器异常
condition.await();
System.out.println("Thread is going on"); } catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
} public static void main(String[] args) throws InterruptedException {
ConditionTest t = new ConditionTest();
Thread t1 = new Thread(t);
t1.start();
Thread.sleep(1000);
// 通知 t1 继续执行
// main 线程必须获取 lock 的锁,才能调用 condition 的方法。否则就是监视器异常,这点和 object 的 wait 方法是一样的。
lock.lock(); // IllegalMonitorStateException
// 从 condition 的等待队列中,唤醒一个线程。
condition.signal();
lock.unlock();
}
}

可以说,condition 的使用方式和 Object 类的 wait 方法的使用方式很相似,无论在哪一个线程中调用 await 或者 signal 方法,都必须获取对应的锁,否则会出现 IllegalMonitorStateException 异常。

到这里,我们可以说, Condition 的实现比 Object 的 wait 和 notify 还是强一点,其中就包括了等待到指定的绝对时间,并且还有一个不受线程中断影响的 awaitUninterruptibly 方法。因此,我们说,只要允许,请使用重入锁,尽量不要使用无脑的 synchronized 。虽然在 JDK 1.6 后, synchronized 被优化了,但仍然建议使用 重入锁。

3. ReadWriteLock 读写锁

伟大的 Doug Lea 不仅仅创造了 重入锁,还创造了 读写锁。什么是读写锁呢?我们知道,线程不安全的原因来自于多线程对数据的修改,如果你不修改数据,根本不需要锁。我们完全可以将读写分离,提高性能,在读的时候不使用锁,在写的时候才加入锁。这就是 ReadWriteLock 的设计原理。

那么,如何使用呢?

package cn.think.in.java.lock;

import java.util.Random;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock; public class ReadWriteLockDemo { static Lock lock = new ReentrantLock();
static ReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(); static Lock readLock = reentrantReadWriteLock.readLock();
static Lock writeLock = reentrantReadWriteLock.writeLock(); int value; public Object handleRead(Lock lock) throws InterruptedException {
try {
lock.lock();
// 模拟读操作,读操作的耗时越多,读写锁的优势就越明显
Thread.sleep(1000);
return value;
} finally {
lock.unlock();
}
} public void handleWrite(Lock lock, int index) throws InterruptedException {
try {
lock.lock();
Thread.sleep(1000); // 模拟写操作
value = index; } finally {
lock.unlock();
}
} public static void main(String[] args) {
final ReadWriteLockDemo demo = new ReadWriteLockDemo();
Runnable readRunnable = new Runnable() {
@Override
public void run() {
try {
demo.handleRead(readLock);
// demo.handleRead(lock);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}; Runnable writeRunnable = new Runnable() {
@Override
public void run() {
try {
demo.handleWrite(writeLock, new Random().nextInt());
// demo.handleWrite(lock, new Random().nextInt());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}; /**
* 使用读写锁,这段程序只需要2秒左右
* 使用普通的锁,这段程序需要20秒左右。
*/ for (int i = 0; i < 18; i++) {
new Thread(readRunnable).start();
} for (int i = 18; i < 20; i++) {
new Thread(writeRunnable).start();
} } }

使用 ReentrantReadWriteLock 的 readLock()方法可以返回读锁,writeLock 可以返回写锁,我们使用普通的的重入锁和读写锁进行测试,怎么测试呢?

两个循环:一个循环开启18个线程去读数据,一个循环开启两个线程去写。如果使用普通的重入锁,将耗时20秒,因为普通的重入锁在读的时候依然是串行的。而如果使用读写锁,只需要2秒,也就是写的时候是串行的。读的时候是并行的,极大的提高了性能。

注意:只要涉及到写都是串行的。比如读写操作,写写操作,都是串行的,只有读读操作是并行的。

读写锁 ReadWriteLock 接口只有 2个方法:

Lock readLock(); 返回一个读锁

Lock writeLock(); 返回一个写锁

他的标准实现类是 ReentrantReadWriteLock 类,该类和普通重入锁一样,也能实现公平锁,中断响应,锁申请等特性。因为他们返回的读锁或者写锁都实现了 Lock 接口。

总结

到这里,我们已经将 Java 世界的三把锁的使用弄清楚了,从分析的过程中我们知道了,JDK 1.5 的重入锁完全可以代替关键字 synchronized ,能实现很多 synchronized 没有的功能。比如中断响应,锁申请,公平锁等,而重入锁的搭档 Condition 也比 Object 的wait 和notify 强大,比如有设置绝对时间的等待,还有忽略线程中断的 await 方法,这些都是 synchronized 无法实现的。还有优化读性能的 读写锁,在读的时候完全是并行的,在某些场景下,比如读很多,写很少,性能将是几何级别的提升。

所以,以后,能不用 synchronzed 就不要用,用的不好就会导致死锁。

今天的Java 三把锁就介绍到这里。

good luck !!!!