Lock与Synchronized
在前面章节已经介绍了Synchronized锁与并发编程中的关键概念 。JAVA多线程同步的实现还可以通过Lock来实现,Lock与Synchronized的区别有:
- Synchronized是JAVA关键字,底层是靠JVM调用对象的monitorenter与monitorexit指令实现的。而Lock不是关键字,是JDK1.5后新增的类,通过这个类实现多线程的对共享区域的同步访问。
- Synchronized可以自动释放锁,就算出现了异常JVM也能确保锁能够被释放。Lock则必须手动释放锁。
- Synchronized是不可中断锁。而Lock是可中断锁。可中断锁的意思是一线程如果尝试获取锁失败,可以自主选择不再等待(中断自己)或设置一个最大等待时间,或被别的线程中断。
- 当多线程读写文件时,读操作和写操作会发生冲突,写操作和写操作也会发生冲突,但是读操作和读操作不会发生冲突现象。但是如果采用synchronized关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程在可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行并行读操作时,线程之间不会发生互斥。Lock可以通过设定读锁还是死锁来解决这种情况,提升多线程访问共享区域的性能。
- 可以通过Lock得知线程有没有成功获取到锁 ,但Synchronized无法办到。
Lock接口的方法
Lock接口定义了使用锁的基本方法:
// 获得锁,锁被占用则等待 void lock() // 尝试获取锁,若获取失败则阻塞,但是可以响应(别的线程的)中断 void lockInterruptibly() // 返回绑定到此Lock实例的新Condition 实例 Condition newCondition() // 调用时尝试获取锁,锁为空闲状态则获取成功,否则中断 boolean tryLock() // 在给定的等待时间尝试获取锁空闲,若超时仍未成功获取则中断 boolean tryLock(long time, TimeUnit unit) // 释放锁 void unlock()
上述方法中,lock()、tryLock()、tryLock(long time, TimeUnit unit) 和 lockInterruptibly()都是用来获取锁的。unLock()方法是用来释放锁的。newCondition()方法主要用于进程间通信,此处暂跳过。下面介绍几种获得锁方式的不同,通过下面的案例能够了解Synchronized与Lock、中断锁/不可中断锁等的区别。
- Lock()
如果锁已被其他线程获取,则进行等待。在前面已经讲到,如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此,一般来说,使用Lock必须在try…catch…块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用Lock来进行同步的话,是以下面这种形式去使用的:
Lock lock = ...; lock.lock(); try{ //处理任务 }catch(Exception ex){ }finally{ lock.unlock(); //释放锁 }
- 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 { //如果不能获取锁,则直接做其他事情 }
- lockInterruptibly()
调用lockInterruptibly()的线程A会尝试获取锁,如果获取失败则进入等待。和lock()不同的是,(别的线程)可以对等待阶段的线程调用进程A的A.interrupt()方法中断线程A的等待过程,被中断(或者说唤醒)的线程A会抛出InterruptedException异常,因此使用lockInterruptibly()的时候要注意处理中断异常。需要注意的是,如果成功获取了锁的线程,是不会被调用它的interrupt()方法中断的。总结:lockInterruptibly()是一个可以响应中断的获取锁的方法。如果获取了锁则不会响应中断,而在等待(阻塞)期间的线程可以响应中断。使用模板如下:
public void method() throws InterruptedException { lock.lockInterruptibly(); try { //..... } finally { lock.unlock(); } }
Lock的实现类ReentrantLock
ReentrantLock实现了Lock接口,能使用上文所描述的功能,并有更多的功能。
ReentrantLock构造方法
可以通过ReentrantLock构造方法构造出公平锁和非公平锁。
- 公平锁:尽量以请求锁的顺序来获取锁。比如,同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程会获得锁,这种就是公平锁。
- 非公平锁:不保证锁的获取是按照请求锁的顺序进行的,这样做可以获得更大的吞吐量,因为从获取锁的阶段来分析,当某一线程要获取锁时,非公平锁可以直接尝试获取锁,而不是判断当前队列中是否有线程在等待。一定情况下可以避免线程频繁的上下文切换,这样的结果是活跃的线程最可能获得锁,而在队列中的锁还要进行唤醒才能继续尝试获取锁。但缺点是可能导致某个或者一些线程永远获取不到锁(饿死)。
下面是ReentrantLock的构造方法,无参构造默认使用非公平锁,有参构通过传入boolean值指定是否为公平锁。
/** * Creates an instance of {@code ReentrantLock}. * This is equivalent to using {@code ReentrantLock(false)}. */ public ReentrantLock() { sync = new NonfairSync(); } /** * Creates an instance of {@code ReentrantLock} with the * given fairness policy. * * @param fair {@code true} if this lock should use a fair ordering policy */ public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
使用ReentrantLock案例如下所示:
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class LockThread { Lock lock = new ReentrantLock(); public void lock(String name) { // 获取锁 lock.lock(); try { System.out.println(name + " get the lock"); // 访问此锁保护的资源 } finally { // 释放锁 lock.unlock(); System.out.println(name + " release the lock"); } } public static void main(String[] args) { LockThread lt = new LockThread(); new Thread(() -> lt.lock("A")).start(); new Thread(() -> lt.lock("B")).start(); } } // 结果 A get the lock A release the lock B get the lock B release the lock
在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态。
ReadWriteLock锁
ReadWriteLock 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持,而写入锁是独占的。ReadWriteLock接口获得读锁和写锁的相关方法:
//返回用于读取操作的锁 Lock readLock() //返回用于写入操作的锁 Lock writeLock()
ReentrantReadWriteLock是ReadWriteLock接口的实现类。下面展示如何通过ReentrantReadWriteLock来为共享区域加读锁与写锁:
import java.util.Random; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; class Queue { //共享数据,只能有一个线程能写该数据,但可以有多个线程同时读该数据。 private Object data = null; ReadWriteLock lock = new ReentrantReadWriteLock(); // 读数据 public void get() { // 加读锁 lock.readLock().lock(); try { System.out.println(Thread.currentThread().getName() + " be ready to read data!"); Thread.sleep((long) (Math.random() * 1000)); System.out.println(Thread.currentThread().getName() + " have read data :" + data); } catch (InterruptedException e) { e.printStackTrace(); } finally { // 释放读锁 lock.readLock().unlock(); } } // 写数据 public void put(Object data) { // 加写锁 lock.writeLock().lock(); try { System.out.println(Thread.currentThread().getName() + " be ready to write data!"); Thread.sleep((long) (Math.random() * 1000)); this.data = data; System.out.println(Thread.currentThread().getName() + " have write data: " + data); } catch (InterruptedException e) { e.printStackTrace(); } finally { // 释放写锁 lock.writeLock().unlock(); } } } public class ReadWriteLockDemo { public static void main(String[] args) { final Queue queue = new Queue(); //一共启动6个线程,3个读线程,3个写线程 for (int i = 0; i < 3; i++) { //启动1个读线程 new Thread() { public void run() { while (true) { queue.get(); } } }.start(); //启动1个写线程 new Thread() { public void run() { while (true) { queue.put(new Random().nextInt(10000)); } } }.start(); } } }
总结:调用ReentrantReadWriteLock对象.readLock()/.writeLock()方法可以获得读/写锁,然后调用读/写锁.lock()/.unlock()方法进行加锁与释放锁。
ReentrantLock/ReentrantReadWriteLock底层实现
在对Sychronized的认识中我们知道Sychronized的底层实现是依赖于JVM对对象monitorenter与monitorexit指令的调用实现的。而Lock作为Java的一个同步类,ReentrantLock与ReentrantReadWriteLock两者底层都是依赖AQS实现的。
AbstractQueuedSynchronizer(AQS),抽象队列同步器,定义了一套多线程访问共享资源的同步器框架。简单来说就是定义了实现同步的基本数据结构。
可以从图上看到,定义了两个核心。一个volatile int state的变量,作为共享资源,或者说锁被占有的标识。(关于volatile的详情可见上篇文章 )还有一个FIFO线程等待队列(多线程争用资源被阻塞时进入此队列)。
AQS定义使用共享资源的方式:Exclusive(独占,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。不同的自定义同步器争用共享资源的方式也不同,如ReentrantLock和read锁就不一样。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在底层实现好了。自定义同步器使用AQS实现同步时主要实现以下几种方法:
- isHeldExclusively():根据用户定义的方式决定该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int):独占方式。尝试获取共享资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放共享资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取共享资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放共享资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
ReentrantLock使用AQS实现同步方式如下:state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
同时我们也可以根据AQS提供的接口实现ReentrantReadWriteLock。读锁和写锁对共享资源的使用,正是对应AQS中的共享方式或独占方式使用state变量。
总结基于AQS实现的同步:自定义同步器要么是独占方法,要么是共享方式,他们也一般只需实现tryAcquire+tryRelease、tryAcquireShared+tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
参考
https://www.cnblogs.com/myseries/p/10784076.html
https://blog.csdn.net/u013851082/article/details/70140223
https://baijiahao.baidu.com/s?id=1664090632564134140&wfr=spider&for=pc
https://www.cnblogs.com/waterystone/p/4920797.html
http://www.cnblogs.com/waterystone/p/4920797.html