geek的技术日志,记录每一次技术思考的闪光点。

JAVA锁机制-可重入锁,可中断锁,公平锁,读写锁,自旋锁

在并发编程中,经常遇到多个线程访问同一个 共享资源 ,这时候作为开发者必须考虑如何维护数据一致性,在java中synchronized关键字被常用于维护数据一致性。synchronized机制是给共享资源上锁,只有拿到锁的线程才可以访问共享资源,这样就可以强制使得对共享资源的访问都是顺序的,因为对于共享资源属性访问是必要也是必须的,下文会有具体示例演示。
    一.java中的锁
一般在java中所说的锁就是指的内置锁,每个java对象都可以作为一个实现同步的锁,虽然说在java中一切皆对象, 但是锁必须是引用类型的,基本数据类型则不可以 。每一个引用类型的对象都可以隐式的扮演一个用于同步的锁的角色,执行线程进入synchronized块之前会自动获得锁,无论是通过正常语句退出还是执行过程中抛出了异常,线程都会在放弃对synchronized块的控制时自动释放锁。 获得锁的唯一途径就是进入这个内部锁保护的同步块或方法 。
正如引言中所说,对共享资源的访问必须是顺序的,也就是说当多个线程对共享资源访问的时候,只能有一个线程可以获得该共享资源的锁,当线程A尝试获取线程B的锁时,线程A必须等待或者阻塞,直到线程B释放该锁为止,否则线程A将一直等待下去,因此java内置锁也称作互斥锁,也即是说锁实际上是一种互斥机制。
根据使用方式的不同一般我们会将锁分为对象锁和类锁,两个锁是有很大差别的,对象锁是作用在实例方法或者一个对象实例上面的,而类锁是作用在静态方法或者Class对象上面的。一个类可以有多个实例对象,因此一个类的对象锁可能会有多个,但是每个类只有一个Class对象,所以类锁只有一个。 类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定的是实例方法还是静态方法区别的 。
在java中实现锁机制不仅仅限于使用synchronized关键字,还有JDK1.5之后提供的Lock,Lock不在本文讨论范围之内。一个synchronized块包含两个部分:锁对象的引用,以及这个锁保护的代码块。如果作用在实例方法上面,锁就是该方法所在的当前对象,静态synchronized方法会从Class对象上获得锁。

 

锁的相关概念介绍

1.可重入锁

如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。举个简单的例子,当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。

看下面这段代码就明白了:

复制代码
class MyClass {
    public synchronized void method1() {
        method2();
    }
 
    public synchronized void method2() {
 
    }
}
复制代码

上述代码中的两个方法method1和method2都用synchronized修饰了,假如某一时刻,线程A执行到了method1,此时线程A获取了这个对象的锁,而由于method2也是synchronized方法,假如synchronized不具备可重入性,此时线程A需要重新申请锁。但是这就会造成一个问题,因为线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样就会线程A一直等待永远不会获取到的锁。

而由于synchronized和Lock都具备可重入性,所以不会发生上述现象。

2.可中断锁

可中断锁:顾名思义,就是可以相应中断的锁。

在Java中,synchronized就不是可中断锁,而Lock是可中断锁。

如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。

在前面演示lockInterruptibly()的用法时已经体现了Lock的可中断性。

3.公平锁

公平锁即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。

非公平锁即无法保证锁的获取是按照请求锁的顺序进行的。这样就可能导致某个或者一些线程永远获取不到锁。

在Java中,synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。

而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。

看一下这2个类的源代码就清楚了:

在ReentrantLock中定义了2个静态内部类,一个是NotFairSync,一个是FairSync,分别用来实现非公平锁和公平锁。

我们可以在创建ReentrantLock对象时,通过以下方式来设置锁的公平性:

ReentrantLock lock = new ReentrantLock(true);

如果参数为true表示为公平锁,为fasle为非公平锁。默认情况下,如果使用无参构造器,则是非公平锁。

另外在ReentrantLock类中定义了很多方法,比如:

复制代码
isFair()        //判断锁是否是公平锁

isLocked()    //判断锁是否被任何线程获取了

isHeldByCurrentThread()   //判断锁是否被当前线程获取了

hasQueuedThreads()   //判断是否有线程在等待该锁
复制代码

 

在ReentrantReadWriteLock中也有类似的方法,同样也可以设置为公平锁和非公平锁。不过要记住,ReentrantReadWriteLock并未实现Lock接口,它实现的是ReadWriteLock接口。

4.读写锁

读写锁将对一个资源(比如文件)的访问分成了2个锁,一个读锁和一个写锁。

正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。

ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。

可以通过readLock()获取读锁,通过writeLock()获取写锁。

 

5、自旋锁
首先是一种锁,与互斥锁相似,基本作用是用于线程(进程)之间的同步。与普通锁不同的是,一个线程A在获得普通锁后,如果再有线程B试图获取锁,那么这个线程B将会挂起(阻塞);试想下,如果两个线程资源竞争不是特别激烈,而处理器阻塞一个线程引起的线程上下文的切换的代价高于等待资源的代价的时候(锁的已保持者保持锁时间比较短),那么线程B可以不放弃CPU时间片,而是在“原地”忙等,直到锁的持有者释放了该锁,这就是自旋锁的原理,可见自旋锁是一种非阻塞锁。
二、自旋锁可能引起的问题:
1.过多占据CPU时间:如果锁的当前持有者长时间不释放该锁,那么等待者将长时间的占据cpu时间片,导致CPU资源的浪费,因此可以设定一个时间,当锁持有者超过这个时间不释放锁时,等待者会放弃CPU时间片阻塞;
2.死锁问题:试想一下,有一个线程连续两次试图获得自旋锁(比如在递归程序中),第一次这个线程获得了该锁,当第二次试图加锁的时候,检测到锁已被占用(其实是被自己占用),那么这时,线程会一直等待自己释放该锁,而不能继续执行,这样就引起了死锁。因此递归程序使用自旋锁应该遵循以下原则:递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。

JAVA中一种自旋锁的实现:   CAS是Compare And Set的缩写

复制代码
import java.util.concurrent.atomic.AtomicReference;  
class SpinLock {  
        //java中原子(CAS)操作  
    AtomicReference<Thread> owner = new AtomicReference<Thread>();//持有自旋锁的线程对象  
    private int count;  
    public void lock() {  
        Thread cur = Thread.currentThread();  
        //lock函数将owner设置为当前线程,并且预测原来的值为空。unlock函数将owner设置为null,并且预测值为当前线程。当有第二个线程调用lock操作时由于owner值不为空,导致循环    
  
            //一直被执行,直至第一个线程调用unlock函数将owner设置为null,第二个线程才能进入临界区。  
        while (!owner.compareAndSet(null, cur)){  
        }  
    }  
    public void unLock() {  
        Thread cur = Thread.currentThread();  
            owner.compareAndSet(cur, null);  
        }  
    }  
}  
public class Test implements Runnable {  
    static int sum;  
    private SpinLock lock;  
      
    public Test(SpinLock lock) {  
        this.lock = lock;  
    }  
    public static void main(String[] args) throws InterruptedException {  
        SpinLock lock = new SpinLock();  
        for (int i = 0; i < 100; i++) {  
            Test test = new Test(lock);  
            Thread t = new Thread(test);  
            t.start();  
        }  
          
        Thread.currentThread().sleep(1000);  
        System.out.println(sum);  
    }  
      
    @Override  
    public void run() {  
        this.lock.lock();  
        sum++;  
        this.lock.unLock();  
    }  
}
复制代码

 

 
    二.synchronized使用示例
    1.多窗口售票
假设一个火车票售票系统,有若干个窗口同时售票,很显然在这里票是作为多个窗口的共享资源存在的,由于座位号是确定的,因此票上面的号码也是确定的,我们用多个线程来模拟多个窗口同时售票,首先在不使用synchronized关键字的情况下测试一下售票情况。
先将票本身作为一个共享资源放在单独的线程中,这种作为共享资源存在的线程很显然应该是实现Runnable接口,我们将票的总数num作为一个入参传入,每次生成一个票之后将num做减法运算,直至num为0即停止,说明票已经售完了,然后开启多个线程将票资源传入。

复制代码
   public class Ticket implements Runnable{
     private int num;//票数量
     private boolean flag=true;//若为false则售票停止
     public Ticket(int num){
     this.num=num;
     }
     @Override
     public void run() {
     while(flag){
     ticket();
     }
     }
     private void ticket(){
     if(num<=0){
     flag=false;
     return;
     }
     try {
     Thread.sleep(20);//模拟延时操作
     } catch (InterruptedException e) {
     e.printStackTrace();
     }
     //输出当前窗口号以及出票序列号
     System.out.println(Thread.currentThread().getName()+"售出票序列号:"+num--);
     }
    }
    public class MainTest {
     public static void main(String[] args) {
     Ticketticket = new Ticket(5);
     Threadwindow01 = new Thread(ticket, "窗口01");
     Threadwindow02 = new Thread(ticket, "窗口02");
     Threadwindow03 = new Thread(ticket, "窗口03");
     window01.start();
     window02.start();
     window03.start();
     }
    }
复制代码

程序的输出结果如下:

 

复制代码
    窗口02售出票序列号:5
    窗口03售出票序列号:4
    窗口01售出票序列号:5
    窗口02售出票序列号:3
    窗口01售出票序列号:2
    窗口03售出票序列号:2
    窗口02售出票序列号:1
    窗口03售出票序列号:0
    窗口01售出票序列号:-1
复制代码

从上面程序运行结果可以看出不但票的序号有重号而且出票数量也不对,这种售票系统比12306可要烂多了,人家在繁忙的时候只是刷不到票而已,而这里的售票系统倒好了,出票比预计的多了而且会出现多个人争抢做同一个座位的风险。如果是单个售票窗口是不会出现这种问题,多窗口同时售票就会出现争抢共享资源因此紊乱的现象,解决该现象也很简单,就是在ticket()方法前面加上synchronized关键字或者将ticket()方法的方法体完全用synchronized块包括起来。

复制代码
 //方式一
    private synchronized void ticket(){
     if(num<=0){
     flag=false;
     return;
     }
     try {
     Thread.sleep(20);//模拟延时操作
     } catch (InterruptedException e) {
     e.printStackTrace();
     }
     System.out.println(Thread.currentThread().getName()+"售出票序列号:"+num--);
    }
    //方式二
    private void ticket(){
     synchronized (this) {
     if (num <= 0) {
     flag = false;
     return;
     }
     try {
     Thread.sleep(20);//模拟延时操作
     } catch (InterruptedException e) {
     e.printStackTrace();
     }
     System.out.println(Thread.currentThread().getName() + "售出票序列号:" + num--);
     }
    }
复制代码

再看一下加入synchronized关键字的程序运行结果:

    窗口01售出票序列号:5
    窗口03售出票序列号:4
    窗口03售出票序列号:3
    窗口02售出票序列号:2
    窗口02售出票序列号:1

 

从这里可以看出在实例方法上面加上synchronized关键字的实现效果跟对整个方法体加上synchronized效果是一样的。 另外一点需要注意加锁的时机也非常重要 ,本示例中ticket()方法中有两处操作容易出现紊乱,一个是在if语句模块,一处是在num–,这两处操作本身都不是原子类型的操作,但是在使用运行的时候需要这两处当成一个整体操作,所以synchronized将整个方法体都包裹在了一起。如若不然,假设num当前值是1,但是窗口01执行到了num–,整个操作还没执行完成,只进行了赋值运算还没进行自减运算,但是窗口02已经进入到了if语句模块,此时num还是等于1,等到窗口02执行到了输出语句的时候,窗口01的num–也已经将自减运算执行完成,这时候窗口02就会输出序列号0的票。再者如果将synchronized关键字加在了run方法上面,这时候的操作不会出现紊乱或者错误,但是这种加锁方式无异于单窗口操作,当窗口01拿到锁进入run()方法之后,必须等到flag为false才会将语句执行完成跳出循环,这时候的num就已经为0了,也就是说票已经被售卖完了,这种方式摒弃了多线程操作,违背了最初的设计原则-多窗口售票。
    2.懒汉式单例模式
创建单例模式有很多中实现方式,本文只讨论懒汉式创建。在Android开发过程中单例模式可以说是最常使用的一种设计模式,因为它操作简单还可以有效减少内存溢出。下面是懒汉式创建单例模式一个示例:

(懒汉式与饿汉式的区别:Singleton 单例模式(懒汉方式和饿汉方式)

复制代码
 public class Singleton {
     private static Singletoninstance;
     private Singleton() {
     }
     public static SingletongetInstance() {
     if (instance == null) {
     instance = new Singleton();
     }
     return instance;
     }
    }
复制代码

如果对于多窗口售票逻辑已经完全明白了的话就可以看出这里的实现方式是有问题的,我们可以简单的创建几个线程来获取单例输出对象的hascode值。

    com.sunny.singleton.Singleton@15c330aa
    com.sunny.singleton.Singleton@15c330aa
    com.sunny.singleton.Singleton@41aff40f

在多线程模式下发现会出现不同的对象,这种单例模式很显然不是我们想要的,那么根据上面多窗口售票的逻辑我们在getInstance()方法上面加上一个synchronized关键字,给该方法加上锁,加上锁之后可以避免多线程模式下生成多个不同对象,但是同样会带来一个效率问题,因为不管哪个线性进入getInstance()方法都会先获得锁,然后再次释放锁,这是一个方面,另一个方面就是只有在第一次调用getInstance()方法的时候,也就是在if语句块内才会出现多线程并发问题,而我们却索性将整个方法都上锁了。讨论到这里就引出了另外一个问题,究竟是synchronized方法好还是synchronized代码块好呢? 有一个原则就是锁的范围越小越好 ,加锁的目的就是将锁进去的代码作为原子性操作,因为非原子操作都不是线程安全的,因此synchronized代码块应该是在开发过程中优先考虑使用的加锁方式。

复制代码
  public static SingletongetInstance() {
     if (instance == null) {
     synchronized (Singleton.class) {
     instance = new Singleton();
     }
     }
     return instance;
    }
复制代码

这里也会遇到类似上面的问题,多线程并发下回生成多个实例,如线程A和线程B都进入if语句块,假设线程A先获得锁,线程B则等待,当new一个实例后,线程A释放锁,线程B获得锁后会再次执行new语句,同样不能保证单例要求,那么下面代码再来一个null判断,进行双重检查上锁呢?

复制代码
 public static SingletongetInstance() {
     if (instance == null) {
     synchronized (Singleton.class) {
     if(instance==null){
     instance = new Singleton();
     }
     }
     }
     return instance;
    }
复制代码

该模式就是双重检查上锁实现的单例模式,这里在代码层面我们已经 基本 保证了线程安全了,但是还是有问题的, 双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。双重检查锁定失败的问题并不归咎于 JVM 中的实现bug,而是归咎于java平台内存模型。内存模型允许所谓的“无序写入”,这也是这些习语失败的一个主要原因。 更为详细的介绍可以参考 Java单例模式中双重检查锁的问题 。所以单例模式创建比较建议使用恶汉式创建或者静态内部类方式创建。

    3.synchronized不具有继承性
我们可以通过一个简单的demo验证这个问题,在一个方法中顺序的输出一系列数字,并且输出该数字所在的线程名称,在父类中加上synchronized关键字,子类重写父类方法测试一下加上synchronized关键字和不加关键字的区别即可。

复制代码
 public class Parent {
     public synchronized void test() {
     for (int i = 0; i < 5; i++) {
     System.out.println("Parent " + Thread.currentThread().getName() + ":" + i);
     try {
     Thread.sleep(500);
     } catch (InterruptedException e) {
     e.printStackTrace();
     }
     }
     }
    }
复制代码

子类继承父类Parent,重写test()方法.

复制代码
 public class Child extends Parent {
     @Override
     public void test() {
     for (int i = 0; i < 5; i++) {
     System.out.println("Child " + Thread.currentThread().getName() + ":" + i);
     try {
     Thread.sleep(500);
     } catch (InterruptedException e) {
     e.printStackTrace();
     }
     }
     }
    }
复制代码

测试代码如下:

复制代码
   final Child c = new Child();
    new Thread() {
     public void run() {
     c.test();
     };
    }.start();
    new Thread() {
     public void run() {
     c.test();
     };
    }.start();
复制代码

输出结果如下:

复制代码
    Parent Thread-0:0  Child Thread-0:0
    Parent Thread-0:1  Child Thread-1:0
    Parent Thread-0:2  Child Thread-0:1
    Parent Thread-0:3  Child Thread-1:1
    Parent Thread-0:4  Child Thread-0:2
    Parent Thread-1:0  Child Thread-1:2
    Parent Thread-1:1  Child Thread-0:3
    Parent Thread-1:2  Child Thread-1:3
    Parent Thread-1:3  Child Thread-0:4
    Parent Thread-1:4  Child Thread-1:4
复制代码

通过输出信息可以知道,父类Parent中会将单个线程中序列号输出完成才会执行另一个线程中代码,但是子类Child中确是两个线程交替输出数字,所以synchronized不具有继承性。

    4.死锁示例
死锁是多线程开发中比较常见的一个问题。若有多个线程访问多个资源时,相互之间存在竞争,就容易出现死锁。下面就是一个死锁的示例,当一个线程等待另一个线程持有的锁时,而另一个线程也在等待该线程锁持有的锁,这时候两个线程都会处于阻塞状态,程序便出现死锁。

复制代码
package com.lock;
   class Thread01 extends Thread{
    private Object resource01;
    private Object resource02;
    public Thread01(Object resource01, Object resource02) {
    this.resource01 = resource01;
    this.resource02 = resource02;
    }
    @Override
    public void run() {
    synchronized(resource01){
    System.out.println("Thread01 locked resource01");
    try {
    Thread.sleep(500);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    synchronized (resource02) {
    System.out.println("Thread01 locked resource02");
    }
    }
    }
   }
    class Thread02 extends Thread{
    private Object resource01;
    private Object resource02;
    public Thread02(Object resource01, Object resource02) {
    this.resource01 = resource01;
    this.resource02 = resource02;
    
    }
    @Override
    public void run() {
    synchronized(resource02){
    System.out.println("Thread02 locked resource02");
    try {
    Thread.sleep(500);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    synchronized (resource01) {
    System.out.println("Thread02 locked resource01");
    }
    }
    }
   }
   public class deadlock {
    public static void main(String[] args) {
    final Object resource01="resource01";
    final Object resource02="resource02";
    Thread01 thread01=new Thread01(resource01, resource02);
    Thread02 thread02=new Thread02(resource01, resource02);
    thread01.start();
    thread02.start();
    }
   }
复制代码

结果为:

Thread02 locked resource02
Thread01 locked resource01

 

执行上面的程序就会一直等待下去,出现死锁。当线程Thread01获得resource01的锁后,等待500ms,然后尝试获取resource02的锁,但是此时resouce02锁已经被Thread02持有,同样Thread02也等待了500ms尝试获取resouce01锁,但是该所已经被Thread01持有,这样两个线程都在等待对方所有的资源,造成了死锁。

    三.其它
关键字synchronized具有锁重入功能,当一个线程已经持有一个对象锁后,再次请求该对象锁时是可以得到该对象的锁的,这种方式是必须的,否则在一个synchronized方法内部就没有办法调用该对象的另外一个synchronized方法了。锁重入是通过为每个所关联一个计数器和一个占有它的线程,当计数器为0时,认为锁是未被占有的。线程请求一个未被占有的锁时,JVM会记录锁的占有者,并将计数器设置为1。如果同一个线程再次请求该锁,计数器会递增,每次占有的线程退出同步代码块时计数器会递减,直至减为0时锁才会被释放。
在声明一个对象作为锁的时候要注意字符串类型锁对象,因为字符串有一个常量池,如果不同的线程持有的锁是具有相同字符的字符串锁时,两个锁实际上同一个锁。

 

ReentrantLock特性

轮询锁的和定时锁

可轮询和可定时的锁请求是通过tryLock()方法实现的,和无条件获取锁不一样. ReentrantLock可以有灵活的容错机制.死锁的很多情况是由于顺序锁引起的, 不同线程在试图获得锁的时候阻塞,并且不释放自己已经持有的锁, 最后造成死锁. tryLock()方法在试图获得锁的时候,如果该锁已经被其它线程持有,则按照设置方式立刻返回,而不是一直阻塞等下去,同时在返回后释放自己持有的锁.可以根据返回的结果进行重试或者取消,进而避免死锁的发生.

公平性

ReentrantLock构造函数中提供公平性锁和非公平锁(默认)两种选择。所谓公平锁,线程将按照他们发出请求的顺序来获取锁,不允许插队;但在非公平锁上,则允许插队:当一个线程发生获取锁的请求的时刻,如果这个锁是可用的,那这个线程将跳过所在队列里等待线程并获得锁。我们一般希望所有锁是非公平的。因为当执行加锁操作时,公平性将讲由于线程挂起和恢复线程时开销而极大的降低性能。考虑这么一种情况:A线程持有锁,B线程请求这个锁,因此B线程被挂起;A线程释放这个锁时,B线程将被唤醒,因此再次尝试获取锁;与此同时,C线程也请求获取这个锁,那么C线程很可能在B线程被完全唤醒之前获得、使用以及释放这个锁。这是种双赢的局面,B获取锁的时刻(B被唤醒后才能获取锁)并没有推迟,C更早地获取了锁,并且吞吐量也获得了提高。在大多数情况下,非公平锁的性能要高于公平锁的性能。

可中断获锁获取操作

lockInterruptibly方法能够在获取锁的同时保持对中断的响应,因此无需创建其它类型的不可中断阻塞操作。

读写锁ReadWriteLock

ReentrantLock是一种标准的互斥锁,每次最多只有一个线程能持有锁。读写锁不一样,暴露了两个Lock对象,其中一个用于读操作,而另外一个用于写操作。

复制代码
public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading.
     */
    Lock readLock(); 

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing.
     */
    Lock writeLock();
}
复制代码

可选择实现:

1.释放优先
2.读线程插队
3.重入性
4.降级
5.升级

ReentrantReadWriteLock实现了ReadWriteLock接口,构造器提供了公平锁和非公平锁两种创建方式。读写锁适用于读多写少的情况,可以实现更好的并发性。

未经允许不得转载:极客技术 » JAVA锁机制-可重入锁,可中断锁,公平锁,读写锁,自旋锁

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址