JUC学习之Lock同步锁
一、简介
引出Lock同步锁之前,先了解一下synchronized同步的一些缺陷:
如果一段代码被synchronized锁住,那么当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁。如果某个时刻获得锁的线程发生阻塞现象,那么这把锁会被它一直持有,而其他线程永远无法获取锁,正常来说,不能让其他线程永远在那里等待。
- 使用Lock锁的话,提供了一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断);
- 使用Lock锁的话,通过tryLock()方法可以尝试获取锁,这就知道线程有没有成功获取到锁;
基于上面Lock的两大优势,我们今天总结一下Lock同步锁相关的知识。
Lock是一个接口,源码如下:
public interface Lock {
//获取锁,如果锁已被其他线程获取,则进行等待。
void lock();
//当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态
void lockInterruptibly() throws InterruptedException;
//尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false
boolean tryLock();
//指定时间内尝试获取锁,在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//释放锁
void unlock();
//创建Condition
Condition newCondition();
}
主要的方法有:lock()获取锁、tryLock()尝试获取锁、unlock()手动释放锁,主要使用Lock一定要手动释放锁,一般放在finally块中保证锁得到释放;
二、常用API使用
(1)、如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。
下面是JDK官网推荐的写法:
Lock l = ...;
l.lock();
try {
// access the resource protected by this lock
} finally {
l.unlock();
}
(2)、尝试获取锁tryLock()
Lock lock = ...;
if(lock.tryLock()) {
try{
//处理业务
}catch(Exception ex){
}finally{
//手动释放
lock.unlock();
}
} else {
//未成功获取锁,则处理其他业务逻辑
}
下面我们介绍一下java.util.concurrent.locks包中常用的类和接口。
三、ReentrantLock可重入锁
ReentrantLock是可重入锁,也是经常使用到的同步锁之一。ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。
类图如下:
public class ReentrantLock implements Lock, java.io.Serializable {
//...
}
下面通过一个简单的示例说明ReentrantLock的使用方法:
我们模拟三个窗口卖30张火车票,如果使用synchronized同步的话,相信小伙伴们都会了,这里就不介绍了,这里使用ReentrantLock可重入锁实现。
/**
* 模拟三个售票员卖出30张票
*/
public class T01_SaleTicketDemo01 {
public static void main(String[] args) {
Ticket ticket = new Ticket();
//窗口1
new Thread(ticket::sale, "A").start();
//窗口2
new Thread(ticket::sale, "B").start();
//窗口3
new Thread(ticket::sale, "C").start();
}
}
/**
* 共享资源类
*/
class Ticket {
private int number = 10;
//声明可重入锁
private Lock lock = new ReentrantLock();
public void sale() {
//获取锁
lock.lock();
try {
while (true) {
if (number > 0) {
TimeUnit.MILLISECONDS.sleep(10);
System.out.println(Thread.currentThread().getName() + "卖出了:" + (number--) + ",剩下:" + number);
} else {
break;
}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//必须手动释放锁,且放在finally块中
lock.unlock();
}
}
}
运行结果:
A卖出了:10,剩下:9
A卖出了:9,剩下:8
B卖出了:8,剩下:7
C卖出了:7,剩下:6
A卖出了:6,剩下:5
A卖出了:5,剩下:4
A卖出了:4,剩下:3
A卖出了:3,剩下:2
A卖出了:2,剩下:1
A卖出了:1,剩下:0
可见,三个线程只要有一个线程获取了锁,其他线程就只能等待去操作共享资源。
四、ReadWriteLock读写锁
ReadWriteLock也是一个接口,源码如下:
public interface ReadWriteLock {
/**
* 获取读锁
*/
Lock readLock();
/**
* 获取写锁,属于排他锁
*/
Lock writeLock();
}
上面两个方法一个用来获取读锁,一个用来获取写锁,将读和写的锁分开,正常来说,读操作应该允许多个线程同时读,当一个线程写的时候,其他线程不能写也不能读。这就提高了读的效率。
常见的实现类就是ReentrantReadWriteLock:
可见,ReentrantReadWriteLock里面分了两把锁:readerLock和writerLock。下面通过例子来介绍一下ReentrantReadWriteLock具体用法:
先看下面的代码:
/**
* ReentrantReadWriteLock读写锁
* <p>
* 读操作共享,写操作独占
* 即读读可共享、写读写写要独占
*/
public class T11_ReentrantReadWriteLock {
public static void main(String[] args) {
//操作共享资源
SharedData sharedData = new SharedData();
//五个线程写
for (int i = 1; i <= 5; i++) {
final int num = i;
new Thread(() -> {
sharedData.put(String.valueOf(num), String.valueOf(num));
}, "A" + num).start();
}
//五个线程读
for (int i = 1; i <= 5; i++) {
final int num = i;
new Thread(() -> {
sharedData.get(String.valueOf(num));
}, "A" + num).start();
}
}
}
class SharedData {
private volatile Map<String, Object> map = new HashMap<>();
/**
* 模拟写操作
*/
public void put(String key, Object value) {
System.out.println(Thread.currentThread().getName() + "写入开始....");
try {
TimeUnit.MILLISECONDS.sleep(400);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "写入完成....value = " + String.valueOf(value));
}
/**
* 模拟读操作
*/
public Object get(String key) {
System.out.println(Thread.currentThread().getName() + "读取开始....");
try {
TimeUnit.MILLISECONDS.sleep(400);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object result = map.get(key);
System.out.println(Thread.currentThread().getName() + "读取完成....result = " + result);
return result;
}
}
运行结果:
A1写入开始....
A2写入开始....
A3写入开始....
A4写入开始....
A5写入开始....
A1读取开始....
A2读取开始....
A3读取开始....
A4读取开始....
A5读取开始....
A1读取完成....result = null
A2读取完成....result = null
A3读取完成....result = null
A4读取完成....result = null
A3写入完成....value = 3
A2写入完成....value = 2
A1写入完成....value = 1
A5读取完成....result = 5
A5写入完成....value = 5
A4写入完成....value = 4
从上面的结果可以看出,比如A1开始写入数据,到A1写入成功期间,居然那么多线程对共享资源进行了操作, 这明显有问题。正确结果应该是A1开始写入数据紧接着就是A1写入成功...依次类推。下面我们使用ReentrantReadWriteLock改造一下:
/**
* ReentrantReadWriteLock读写锁
* <p>
* 读操作共享,写操作独占
* 即读读可共享、写读写写要独占
*/
public class T11_ReentrantReadWriteLock {
public static void main(String[] args) {
//操作共享资源
SharedData sharedData = new SharedData();
//五个线程写
for (int i = 1; i <= 5; i++) {
final int num = i;
new Thread(() -> {
sharedData.put(String.valueOf(num), String.valueOf(num));
}, "A" + num).start();
}
//五个线程读
for (int i = 1; i <= 5; i++) {
final int num = i;
new Thread(() -> {
sharedData.get(String.valueOf(num));
}, "A" + num).start();
}
}
}
class SharedData {
private volatile Map<String, Object> map = new HashMap<>();
/**
* 引入读写锁
*/
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
/**
* 模拟写操作
*/
public void put(String key, Object value) {
readWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "写入开始....");
try {
TimeUnit.MILLISECONDS.sleep(400);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "写入完成....value = " + value);
} catch (Exception e) {
e.printStackTrace();
} finally {
readWriteLock.writeLock().unlock();
}
}
/**
* 模拟读操作
*/
public Object get(String key) {
readWriteLock.readLock().lock();
Object result = null;
try {
System.out.println(Thread.currentThread().getName() + "读取开始....");
try {
TimeUnit.MILLISECONDS.sleep(400);
} catch (InterruptedException e) {
e.printStackTrace();
}
result = map.get(key);
System.out.println(Thread.currentThread().getName() + "读取完成....result = " + result);
} catch (Exception e) {
e.printStackTrace();
} finally {
readWriteLock.readLock().unlock();
}
return result;
}
}
运行结果:
A1写入开始....
A1写入完成....value = 1
A2写入开始....
A2写入完成....value = 2
A3写入开始....
A3写入完成....value = 3
A4写入开始....
A4写入完成....value = 4
A5写入开始....
A5写入完成....value = 5
A1读取开始....
A2读取开始....
A3读取开始....
A4读取开始....
A5读取开始....
A1读取完成....result = 1
A3读取完成....result = 3
A2读取完成....result = 2
A5读取完成....result = 5
A4读取完成....result = 4
由此可知,多个线程只能有一个线程进行写操作,其他线程不能加塞,而读操作则允许多个线程同时读,这样就大大提升了读操作的效率。不过要注意的是,如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。
五、总结
这里我们总结一下Lock和synchronized的区别:
- Lock是一个接口,而synchronized是Java中的关键字;
- Lock在代码执行时出现异常时不会自动释放锁,必须手动释放,而synchronized抛出异常时会自动释放锁;
- Lock如果没有及时释放锁,很可能产生死锁现象,而synchronized由于会自动释放锁,不会导致死锁现象发生;
- Lock可以让等待锁的线程响应中断,而synchronized不能响应中断,只能一直等待;
- Lock通过tryLock尝试获取锁可知是否成功获取锁,而synchronized则不行;
- 如果对共享资源竞争不激烈情况下,两者的性能其实差不多,但是如果竞争激烈,此时Lock的性能要远远优于synchronized方式;