亲宝软件园·资讯

展开

Java Lock接口 Java Lock接口实现原理及实例解析

jrliu 人气:0

1、概述

JUC中locks包下常用的类与接口图如下:

图中,Lock和ReadWriteLock是顶层锁的接口,Lock代表实现类是ReentrantLock(可重入锁),ReadWriteLock(读写锁)的代表实现类是ReentrantReadWriteLock。

ReadWriteLock 接口以类似方式定义了读锁而写锁。此包只提供了一个实现,即 ReentrantReadWriteLock。

Condition 接口描述了可能会与锁有关联的条件变量。这些变量在用法上与使用 Object.wait 访问的隐式监视器类似,但提供了更强大的功能。需要特别指出的是,单个 Lock 可能与多个 Condition 对象关联。

2、lock与synchronized比较

synchronized是java中的一个关键字,也就是说是Java语言内置的特性。那么为什么会出现Lock呢?

1、Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;

2、Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

synchronized 的局限性与Lock的优点 

如果一个代码块被synchronized关键字修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待直至占有锁的线程释放锁。事实上,占有锁的线程释放锁一般会是以下三种情况之一:

1:占有锁的线程执行完了该代码块,然后释放对锁的占有;

2:占有锁线程执行发生异常,此时JVM会让线程自动释放锁;

3:占有锁线程进入WAITING状态从而释放锁,例如在该线程中调用wait()方法等。

下列三种情况: 

1 、在使用synchronized关键字的情形下,假如占有锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。因此,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间 (解决方案:tryLock(long time, TimeUnit unit))或者能够响应中断(解决方案:lockInterruptibly())),这种情况可以通过 Lock 解决。

2、当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用synchronized关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程在可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行读操作时,线程之间不会发生冲突。同样地,Lock也可以解决这种情况 (解决方案:ReentrantReadWriteLock) 。

3、通过Lock得知线程有没有成功获取到锁 (解决方案:ReentrantLock) ,但这个是synchronized无法办到的。

上面提到的三种情形,我们都可以通过Lock来解决,但 synchronized 关键字却无能为力。事实上,Lock 是 java.util.concurrent.locks包 下的接口,Lock 实现提供了比 synchronized 关键字更广泛的锁操作,它能以更优雅的方式处理线程同步问题。也就是说,Lock提供了比synchronized更多的功能。

3、Lock接口实现类的使用

// 获取锁
void lock()
// 如果当前线程未被中断,则获取锁,可以响应中断
void lockInterruptibly()
// 返回绑定到此 Lock 实例的新 Condition 实例
Condition newCondition()
// 仅在调用时锁为空闲状态才获取该锁,可以响应中断
boolean tryLock()
// 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁
boolean tryLock(long time, TimeUnit unit)
// 释放锁
void unlock()

3.1、在Lock中声明了四个方法来获取锁,那么这四个方法有何区别呢?首先,lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。在前面已经讲到,如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此,一般来说,使用Lock必须在try…catch…块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用Lock来进行同步的话,是以下面这种形式去使用的:

Lock lock = ...;
lock.lock();
try{
  //处理任务
}catch(Exception ex){
}finally{
  lock.unlock();  //释放锁
}

3.2、tryLock() & tryLock(long time, TimeUnit unit)

  tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true;如果获取失败(即锁已被其他线程获取),则返回false,也就是说,这个方法无论如何都会立即返回(在拿不到锁时不会一直在那等待)。

  tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false,同时可以响应中断。如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。

一般情况下,通过tryLock来获取锁时是这样使用的:

Lock lock = ...;
if(lock.tryLock()) {
   try{
     //处理任务
   }catch(Exception ex){
   }finally{
     lock.unlock();  //释放锁
   }
}else {
  //如果不能获取锁,则直接做其他事情
}

3.3、lockInterruptibly() 
  lockInterruptibly()方法比较特殊,当通过这个方法去获取锁时,如果线程 正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。例如,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。

  由于lockInterruptibly()的声明中抛出了异常,所以lock.lockInterruptibly()必须放在try块中或者在调用lockInterruptibly()的方法外声明抛出 InterruptedException,但推荐使用后者,原因稍后阐述。因此,lockInterruptibly()一般的使用形式如下:

public void method() throws InterruptedException {
  lock.lockInterruptibly();
  try { 
   //.....
  }
  finally {
    lock.unlock();
  } 
}

注意,当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为interrupt()方法只能中断阻塞过程中的线程而不能中断正在运行过程中的线程。因此,当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,那么只有进行等待的情况下,才可以响应中断的。与 synchronized 相比,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。
范例,运行起来后,Thread2能够被正确中断。

public class Test {
  private Lock lock = new ReentrantLock(); 
  public static void main(String[] args) {
    Test test = new Test();
    MyThread thread1 = new MyThread(test);
    MyThread thread2 = new MyThread(test);
    thread1.start();
    thread2.start();
     
    try {
      Thread.sleep(2000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    thread2.interrupt();
  } 
   
  public void insert(Thread thread) throws InterruptedException{
    lock.lockInterruptibly();  //注意,如果需要正确中断等待锁的线程,必须将获取锁放在外面,然后将InterruptedException抛出
    try { 
      System.out.println(thread.getName()+"得到了锁");
      long startTime = System.currentTimeMillis();
      for(  ;   ;) {
        if(System.currentTimeMillis() - startTime >= Integer.MAX_VALUE)
          break;
        //插入数据
      }
    }
    finally {
      System.out.println(Thread.currentThread().getName()+"执行finally");
      lock.unlock();
      System.out.println(thread.getName()+"释放了锁");
    } 
  }
}
class MyThread extends Thread {
  private Test test = null;
  public MyThread(Test test) {
    this.test = test;
  }
  @Override
  public void run() {
     
    try {
      test.insert(Thread.currentThread());
    } catch (InterruptedException e) {
      System.out.println(Thread.currentThread().getName()+"被中断");
    }
  }
}

3.4 具体的锁实现

Lock的实现类

ReentrantLock :即 可重入锁。ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。

ReadWriteLock锁:接口只有两个方法:

//返回用于读取操作的锁
Lock readLock()
//返回用于写入操作的锁
Lock writeLock()

ReadWriteLock维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。范例

class ReadWriteLockQueue {
  //共享数据,只能有一个线程 写数据,但可以多个线程读数据
  private Object data = null;
  private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  //读数据
  public void get() {
    try {
      rwl.readLock().lock();//上读锁,其他线程只能读。
      System.out.print(Thread.currentThread().getName() + "读取 data!");
      Thread.sleep((long) (Math.random() * 1000));
        System.out.println(Thread.currentThread().getName() + "读取到的数据:"+ data);
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      rwl.readLock().unlock();//释放读锁
    }
  }
  //写数据
  public void put(Object data) {
    try {
      rwl.writeLock().lock();//加上写锁,不允许其他线程 读写
      System.out.print(Thread.currentThread().getName() + "写入数据,");
      Thread.sleep((long) (Math.random() * 1000));
      this.data = data;
      System.out.println(Thread.currentThread().getName() + "已经写好数据" + data);
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      rwl.writeLock().unlock();//释放锁
    }
  }
}
public class TestReentrantReadWriteLock {
  public static void main(String[] args) {
    final ReadWriteLockQueue readWriteLockQueue = new ReadWriteLockQueue();
    for (int i = 0; i < 2 ; i++) {
      new Thread(new Runnable() {
        @Override
        public void run() {
          while (true) {
            readWriteLockQueue.put(new Random().nextInt(10000));
          }
        }
      },"写线程").start();
      new Thread(new Runnable() {
        @Override
        public void run() {
          while(true) {
            readWriteLockQueue.get();
          }
        }
      },"读线程").start();
    }
  }
}

4、锁的相关概念

锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

4.1、偏向锁

引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS (Compare and Swap)操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。

偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步!关于偏向锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。

但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

4.2、 轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了CAS操作。 关于轻量级锁的加锁和解锁的原理可以查看

《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。
轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!

4.3、自旋锁和自适应自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。

一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。
何谓自旋锁?它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

JDK1.6及1.6之后,自旋锁就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,但是用户可以修改。
在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。

4.4、锁消除

锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。

4.5、锁粗化

原则上在编写代码的时候,总是推荐将同步快的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。

加载全部内容

相关教程
猜你喜欢
用户评论