java并发性能(二)之显式锁

小孩子 我们都是小青蛙 2018-05-29

点击蓝字,关注我们


关注

本人水平有限,各位如果在文章中发现错误可留言,我看到就会回复,如果留言里不好描述,可加我微信 xiaohaizi4919 ,谢谢。


非常重要的几条阅读建议:

1. 最好使用电脑观看。

2. 如果你非要使用手机观看,那请把字体调整到最小,这样观看效果会好一些。

3. 碎片化阅读并不会得到真正的知识提升,要想有提升还得找张书桌认认真真看一会书,或者我们公众号的文章

4. 如果觉得不错,各位帮着转发转发,如果觉得有问题或者写的哪不清晰,务必私聊我~

5. 本篇文章不是java语法的基本教程!在阅读之前,请保证你有面向对象的编程基础,熟悉封装、继承、多态,否则的话,你不适合阅读本篇文章,先学一下基础吧~

6. 本公众号的文章都是需要被系统性学习的,这篇文章是建立在之前的几篇文章之上的,如果你没有读过,务必读后再看本篇,要不然可能会有阅读不畅的体验:


内置锁的缺陷

到目前为止,我们使用的加锁方案只有synchronized一种,它使用一个对象作为一个锁来保护一段代码,这种锁是伴随着java语言的诞生而出现的,所以这种锁也被称为内置锁。虽然内置锁语法简单,语义明确,但是它获取锁的方式及其死板,也就是说如果多个线程竞争一个锁的话,只有一个线程可以获取到锁,只要它不释放锁,其他线程就需要一直等待下去,无法停止等待锁的行为,所以造成死锁的时候系统将无法恢复,只能重启。为了灵活的使用锁来保护代码,后来设计java的大叔们提出了一种显式锁的概念。

ReentrantLock

设计java的大叔们提出了一个Lock接口,在里边规定了有哪些获取锁的方式以及怎么释放锁的方法:

public interface Lock {
   void lock();

   void lockInterruptibly() throws InterruptedException;

   boolean tryLock();

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

   void unlock();

   Condition newCondition();
}

这个接口的各个方法的意思如下:

这个Lock接口的实现类就是所谓的显式锁,意思它的加锁和释放锁的操作都需要显式的调用方法,而不像内置锁那样进入同步代码块就算是加锁,从同步代码块出来就算是释放锁。

我们最常用到的一个Lock接口的实现类就是:ReentrantLock,它的常见用法是这样的:

Lock lock = new ReentrantLock();
lock.lock();    //获取锁
try {
   // ... 具体代码
} finally {
   lock.unlock();  //释放锁
}

也就是先定义锁对象,然后执行获取锁的操作,获取锁之后执行被锁保护的代码,最后再释放锁。lock方法就相当于内置锁的进入同步代码块,unlock方法就相当于内置锁的退出同步代码块。需要特别注意的是,不像使用内置锁保护的同步代码块,只要退出了代码块就意味着释放了锁,显式锁需要我们手动调用 unlock 方法来释放锁,所以为了防止在执行具体代码中抛出异常而无法释放锁,所以把 unlock 方法 放在finally块中执行

如果多个线程同时调用lock方法的时候,只有一个线程可以获得锁,其余线程都会在lock方法上阻塞,直到获取锁线程释放了锁。下边我们来具体看Lock接口中其他的几个加锁方法。

轮询锁

不像lock()方法在获取不到锁的时候会一直阻塞,tryLock()如果获取到了锁会立即返回true,在没有获取到锁的时候会立即返回false,我们可以根据返回结果来判断是否真的获取到了锁。

因为tryLock()方法有这个立即返回的特性,所以在获取锁失败时我们可以尝试重试,至于重试的策略自然是我们自己指定了,我们可以在获取锁失败后立即重试,也可以在获取锁失败后每隔1秒重试一次,也可以随机休息一段时间后再重试,也可以设置重试次数,比如重试100次后便停止获取锁的操作,这样自定义的重试策略就极大的提升了我们编程的灵活性

我们下边来写一段随机休息一段时间后重试的代码:

Lock lock = new ReentrantLock();
Random random = new Random();

while (true) {
   boolean result = lock.tryLock(); //尝试获取锁的操作
   if (result) {
       try {
           // ... 具体代码
       } finally {
           lock.unlock();
       }
   }

   // 获取锁失败后随即休息一段时间后重试
   try {
       Thread.sleep(random.nextInt(1000)); //随机休眠1秒内的时间
   } catch (InterruptedException e) {
       throw new RuntimeException(e);
   }
}

在获取锁失败后随机休眠一段时间再重试的好处是不会因为获取不到锁而一直阻塞,从而避免发生死锁的危险。大家可以找一段我们前边编写过的可能发生死锁的代码,用轮询锁来替代原来的内置锁试试~

可中断锁

我们需要先了解一下java里的线程中断是个神马意思。

话说狗哥和猫爷是两个线程,狗哥运行的的好好的,猫爷忽然想让狗哥去死,可是杀人是犯法的,杀线程也是犯法的,所以猫爷递给了狗哥一把刀子,对狗哥说赶紧自尽吧,狗哥接过刀子可以自己决定是要去死还是坚强的活着。

每个线程都有一个中断状态,就是指当前线程有没有持有刀子,刚开始线程都没有持有刀子,所以中断状态就为false。这个刀子就是所谓的中断信号,一个线程可以给另一个线程发送一个中断信号,也就是把刀子递给另一个线程,接收到这个中断信号的线程的中断状态就被置为true。java语言中的Thread类提供下边的几个方法来获取和修改线程的中断状态

我们先写个例子看看啥叫个线程中断

public class InterrputedDemo {

   public static void main(String[] args) {
       Thread t1 = new Thread(new Runnable() {

           @Override
           public void run() {
               while (true) {
                   if (Thread.currentThread().isInterrupted()) {
                       System.out.println("有别的线程把线程t1的中断状态变为true,退出循环");
                       break;
                   }
               }
           }
       }, "t1");

       t1.start();
       System.out.println("线程t1是否处于中断状态:" + t1.isInterrupted());

       System.out.println("在main线程中给t1线程发送中断信号");
       t1.interrupt();

       System.out.println("现在线程t1是否处于中断状态:" + t1.isInterrupted());

   }
}

也就是说线程t1一直检测着自己的中断状态,如果中断状态变为true,那么就退出循环。另一边,main线程调用了t1.interrupt()方法给t1线程发送了一个中断信号t1线程收到这个信号之后退出了循环,所以执行结果是:

线程t1是否处于中断状态:false
在main线程中给t1线程发送中断信号
现在线程t1是否处于中断状态:true
有别的线程把线程t1的中断状态变为true,退出循环

上边的例子就是所谓的线程中断的大致用法。很显然一个线程给另一个线程发送中断信号只是一厢情愿的事儿,如果接收中断信号的线程对于这个信号置之不理,并不会有任何影响,也就是说如果狗哥不理猫爷扔过来的刀子,那扔个刀子并没有什么卵用。不过如果接收信号的线程正因为调用了某些方法发生阻塞的时候,比如因为调用了Thread类的 joinsleep方法或者Object类的wait方法线程发生了阻塞,这些方法都有一个InterruptedException的异常说明,一个线程在调用这些方法之前或者阻塞过程中都会监测自己的中断状态是否为true,如果为true,立即返回并且抛出一个InterruptedException的异常,而且还会清除该线程的中断状态,也就是把中断状态再次修改为false。可能说的有点绕,写个代码就清楚了:

public class InterrputedDemo {

   public static void main(String[] args) {
       Thread t1 = new Thread(new Runnable() {

           @Override
           public void run() {
               try {
                   Thread.sleep(100000L);  //调用sleep方法阻塞
               } catch (InterruptedException e) {
                   System.out.println("sleep方法抛出异常,现在t1线程的中断状态为:" + Thread.currentThread().isInterrupted());
               }
           }
       }, "t1");

       t1.start();
       System.out.println("线程t1是否处于中断状态:" + t1.isInterrupted());

       System.out.println("在main线程中给t1线程发送中断信号");
       t1.interrupt();

       System.out.println("现在线程t1是否处于中断状态:" + t1.isInterrupted());

   }
}

由于t1线程调用了Thread.sleep方法发生了阻塞,这时候main线程通过调用t1.interrupt()t1线程发送中断信号,把t1线程的中断状态给置为true,所以此时sleep方法会立即返回并且抛出InterruptedException异常,并且把t1线程的中断状态又给置为false,所以最后t1线程的中断状态就是false喽,我们来看一下执行结果:

线程t1是否处于中断状态:false
在main线程中给t1线程发送中断信号
sleep方法抛出异常,现在t1线程的中断状态为:false
现在线程t1是否处于中断状态:false

以上是我们应该了解的java线程中断机制,知道了这个东东,我们再来看如何使用Lock接口的lockInterruptibly方法来定义可中断的锁,再来看看lockInterruptibly的声明:

void lockInterruptibly() throws InterruptedException;

这个方法有一个InterruptedException的异常说明,所以使用这个可中断的锁的时候,需要两个try块,通常使用的方式就是这样的:

Lock lock = new ReentrantLock();
try {   //第1个try块

   lock.lockInterruptibly();   //可中断的锁

   try {   //第2个try块
       // ... 具体代码
   } finally {
       lock.unlock();  //释放锁
   }

} catch (InterruptedException e) {
   // ... 在被中断的时候的处理代码
}

其中,第1个try块是用来捕获lockInterruptibly方法可能抛出的中段异常的,第2个try块是用来释放锁的。

像调用sleepjoinwait方法一样,如果一个线程因为调用lockInterruptibly方法在等待获取锁的过程中发生阻塞,此时另一个线程向该线程发送中断信号的话,则lockInterruptibly方法会立即返回,并且抛出一个InterruptedException异常,并且清除线程的中断状态,我们写段代码看一下:

public static void main(String[] args) throws Exception {
   Lock lock = new ReentrantLock();

   Thread t1 = new Thread(new Runnable() {
       @Override
       public void run() {

           lock.lock();    //先获取锁
           try {
               System.out.println("t1线程获得了锁");
               try {
                   Thread.sleep(5000L);    //获取锁之后就休眠
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           } finally {
               lock.unlock();
           }
       }
   }, "t1");
   t1.start();

   Thread.sleep(1000L);

   Thread t2 = new Thread(new Runnable() {

       @Override
       public void run() {
           try {
               lock.lockInterruptibly();   //可中断的锁
               try {
                   System.out.println("t2线程获得了锁");
               } finally {
                   lock.unlock();
               }
           } catch (InterruptedException e) {
               System.out.println("别的线程发送了中断信号,lockInterruptibly立即返回并且抛出异常,这里是处理异常的代码");
           }
       }
   }, "t2");
   t2.start();

   Thread.sleep(1000L);

   t2.interrupt();
}

我们先让t1线程获取到锁,然后就让它进入休眠状态,然后让t2线程调用lock.lockInterruptibly()方法来获取锁,由于锁已经被t1线程获取了,所以t2线程会一直阻塞,此时在main线程中对正在等待获取锁的t2线程调用interrupt方法,则lockInterruptibly方法会立即返回,并且抛出InterruptedException异常,并且清除t2线程的中断状态,然后进入异常处理的代码。来看一下执行结果:

t1线程获得了锁
别的线程发送了中断信号,lockInterruptibly立即返回并且抛出异常,这里是处理异常的代码

有了可中断的锁,我们就可以随时在别的线程里让另一个线程从等待获取锁的阻塞状态中出来了。

定时锁

我们再来看一下Lock接口中支持定时获取一个锁的操作:

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

也就是说,我们可以在指定时间内获取一个锁,如果在指定时间内获取到了锁,那么该方法就返回true,如果超过了这个指定的时间,那么就返回false。这个定时获取锁的方法也是有一个InterruptedException的异常说明,也需要需要两个try块,通常使用的方式就是这样的:

Lock lock = new ReentrantLock();

try {
   boolean result = lock.tryLock(1000L, TimeUnit.MILLISECONDS);
   if (result) {   //在指定时间内获取锁成功
       try {
           // ... 获取到锁的代码
       } finally {
           lock.unlock();  //释放锁
       }

   } else {   //在指定时间内获取锁失败
       // ... 获取锁失败的代码

   }
} catch (InterruptedException e) {
   // ... 被中断时的异常处理代码
}

lockInterruptibly一样,这个定时获取锁的方法也可能因为别的线程发送中断信号而从阻塞状态中返回并且抛出InterruptedException异常,我们需要做好异常处理工作。

锁的公平性

我们想象一个场景,在一个破烂的卡丁车的赛场,只有一辆卡丁车可用,而且一辆卡丁车只能坐一个人。但是有好多人都想坐着这辆卡丁车跑两圈,所以结果就是很多人去竞争这一辆卡丁车。这辆卡丁车就可以当作锁,每个想坐车的人都可以比做一个线程,在一个人获得了乘坐卡丁车的机会时,其他人都在等待区等待,由于等待区很舒服,所以人们等着等着就给睡着了,每次乘坐卡丁车的人玩完了之后就到等待区叫醒一个人并把车交给他。

如果一个新来的同学发现车已经被别人用了,那他就怪怪的跑到等待区去排队,这个没有问题。但是如果在一个新同学到来的时候,用车的人玩完了,这时候他是应该去等待区把等待的人叫醒还是直接把车交给这个刚来的同学呢?

如果玩完车的这个人去把车交给刚来的这个同学的话,我们就称之为不公平的,因为这个新来的同学插队了嘛!如果把车交给在等待区睡觉的人的话,我们称之为公平的,因为严格按照先来后到来分配车嘛!

把车和人换成我们的锁和线程的话,不论此时有没有线程持有该锁,新来获取这个锁的线程都会被放到等待队列中排队的话,我们把这样的锁称之为公平锁;如果仅仅是在某个线程持有锁的情况下才会进入等待队列排队,没有线程持有锁的情况下新来的线程可能会先于等待队列中的线程获取到锁的话,我们把这种锁称为非公平锁

公平锁不是很好么,大家遵守先来后到的规则不是很正常么,插队多不礼貌呀,为什么还要使用非公平锁呢?

因为礼貌有时会影响到效率呀~ 叫醒等待区的人是需要时间的,可能在叫醒这个人的过程中,新来的同学已经跑了一圈完事儿了。同理,如果一个线程因为获取不到锁而阻塞的话,它可能被操作系统给挂起,也就是从内存中踢出去放到硬盘上,如果要重新恢复这个线程的话需要从硬盘中重新读取进来,这样就造成了性能的损耗,而如果直接把锁分配给新来的线程,在新来的线程执行的过程中再叫醒等待队列的线程,那么可能新来的线程已经执行完它的任务把锁都释放了,正好把锁交给刚醒来的线程,皆大欢喜嘛~

正是因为非公平锁有性能上的优势,所以一般情况下,java的内置锁都是非公平锁,如果我们使用显式锁的话,比如ReentrantLock默认是非公平锁,但是如果我们非要把它定义成公平锁的话,我们可以通过它的构造方法来指定:

public ReentrantLock(boolean fair)

如果fairtrue的话就是公平锁,如果fairfalse的话就是非公平锁

ReentrantLock和synchronized对比

上边说了这么多,ReentrantLock可以用于制作各种牛逼的锁,貌似很刁的样子嘛~那以后是不是都不用synchronized的内置锁,而改用ReentrantLock的显式锁呢?

问出这样的问题答案当然是否喽~ 虽然synchronized很笨,但是大部分程序员还处在对内置锁比较熟悉,对显式锁一脸懵逼的状态的,所以你使用显式锁会造成很大的沟通成本,当然这不是主要原因,主要是内置锁使用起来简单啊,只要把要保护的代码块用synchronized往起一包就完事儿了,也不用担心忘记在finally块中写unlock来释放锁,从而在某天程序发生异常的时候连锁都没释放导致系统宕机,也就是说内置锁对于程序员来说危险性更低。 所以建议是除非你确定使用内置锁不能满足你的需求,才去考虑使用ReentrantLock

读写锁

不管是我们之前用的内置锁,还是刚刚介绍的ReentrantLock,在一个线程持有锁的时候别的线程是不能持有锁的,所以这种锁也叫做互斥锁如果不是为了保护原子性操作的话,锁的作用就是保证对变量的读写具有内存的可见性和抑制重排序。假设用一个互斥锁去保护某个变量的读写操作,那么下边的这四种操作都是不能并发执行的:

  1. 一个线程读的时候另一个线程写,简称读/写操作

  2. 一个线程写的时候另一个线程读,简称写/读操作

  3. 一个线程写的时候另一个线程写,简称写/写操作

  4. 一个线程读的时候另一个线程读,简称读/读操作

前三种操作,也就是读/写写/读写/读我们知道是不能并发操作的,因为可能会读到一个过期的数据或者写入一个过期的数据,但是对于读/读操作来说,多个线程并发的读线程并发的读并不会造成什么不好的后果,所以我们只需要保证一个变量可以被多个读线程同时访问,或者被一个写线程访问,但是两者不能同时访问就好了。所以设计java的大叔们眼睛一亮,发现可以使用读锁来保护对某个共享变量的读操作,使用写锁来保护对某个共享变量的写操作前,这样就可以允许多个线程同时读,但只能一个线程同时写的要求了。所以他们设计了这么一个叫ReadWriteLock的接口:

public interface ReadWriteLock {
   Lock readLock();
   Lock writeLock();
}

这个接口的一个实现类是:ReentrantReadWriteLock,我们可以通过它来获得读锁写锁,其实读锁写锁都是一个Lock对象,我们看下边这个例子:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockDemo {
   private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

   private Lock readLock = readWriteLock.readLock();   //读锁

   private Lock writeLock = readWriteLock.writeLock(); //写锁

   private int i;

   public int getI() {
       readLock.lock();
       try {
           return i;
       } finally {
           readLock.unlock();
       }
   }

   public void setI(int i) {
       writeLock.lock();
       try {
           this.i = i;
       } finally {
           writeLock.unlock();
       }
   }
}

在实际情况中,只有某些变量的读取频率特别高,并且我们实际测试证明了使用读/写锁可以明显提升系统的性能,我们才考虑使用读/写锁来替代互斥锁

题外话

写文章挺累的,有时候你觉得阅读挺流畅的,那其实是背后无数次修改的结果。如果你觉得不错请帮忙转发一下,万分感谢~


    本站仅按申请收录文章,版权归原作者所有
    如若侵权,请联系本站删除
    觉得不错,分享给更多人看到
    我们都是小青蛙 热门文章:

    java并发编程之原子性操作    阅读/点赞 : 0/0

    我们都是小青蛙,呱呱呱呱呱    阅读/点赞 : 0/0

    活跃性(死锁、饥饿、活锁)    阅读/点赞 : 0/0

    InnoDB空间文件布局的基础知识    阅读/点赞 : 0/0

    指令重排序    阅读/点赞 : 0/0

    InnoDB的Buffer Pool简介    阅读/点赞 : 0/0

    一些比较重要的数字电路模块    阅读/点赞 : 0/0

    《UNIX环境高级编程》书籍推荐    阅读/点赞 : 0/0

    InnoDB中的B+树索引结构    阅读/点赞 : 0/0

    InnoDB索引页面的物理结构    阅读/点赞 : 0/0

    我们都是小青蛙 微信二维码

    我们都是小青蛙 微信二维码