农场主的黑科技.

并发操作合集-4.Lcok和Condition

字数统计: 3.3k阅读时长: 20 min
2018/11/01 Share

🍤 并发操作合集系列 目录

🍕 并发操作合集系列 源代码

并发API支持多种显式的锁,它们由Lock接口规定,用于代替synchronized的隐式锁。锁对细粒度的控制支持多种方法,因此它们比隐式的监视器具有更大的开销。

Lock接口和synchronized关键字的差别

两者的区别如下:

类别 synchronized Lock
存在层次 Java的关键字,在jvm层面上 是一个类
锁的释放 1、以获取锁的线程执行完同步代码,释放锁
2、线程执行发生异常,jvm会让线程释放锁
在finally中必须释放锁,不然容易造成线程死锁
锁的获取 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 分情况而定,Lock有多个锁获锁的方式,大致就是可以尝试获得锁,线程可以不用一直等待
锁状态 无法判断 可以判断
锁类型 可重入 不可中断 非公平 可重入 可中断 可公平(两者皆可)
性能 少量同步 大量同步

表中提到了所类型,先了解一下锁类型都有哪些:

  • 可重入锁:在执行对象中所有同步方法不用再次获得锁
  • 可中断锁:在等待获取锁过程中可中断
  • 公平锁: 按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利
  • 读写锁:对资源读取和写入的时候拆分为2部分处理,读的时候可以多线程一起读,写的时候必须同步地写

Lock接口定义的方法和使用

  • lock():获取锁,如果锁被暂用则一直等待

  • unlock():释放锁

  • tryLock(): 注意返回类型是boolean,如果获取锁的时候锁被占用就返回false,否则返回true

  • tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,保证等待参数时间

  • lockInterruptibly():锁的获得方式,如果线程在获取锁的阶段进入了等待,那么可以中断此线程,先去做别的事

通过 以上的解释,大致可以解释在上个部分中“锁类型(lockInterruptibly())”,“锁状态(tryLock())”等问题,还有就是前面子所获取的过程我所写的“大致就是可以尝试获得锁,线程可以不会一直等待”用了“可以”的原因。

简单看一下比较常用的lock()和tryLock()方法的使用方式,启动两个线程来调用这些方法:

lock()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    private void testLock(Thread thread) {
lock.lock();
try {
System.out.println("线程名" + thread.getName() + "获得了锁");
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("线程名" + thread.getName() + "释放了锁");
lock.unlock();
}
}
---------------------------
输出
线程名Thread-1获得了锁
线程名Thread-1释放了锁
线程名Thread-0获得了锁
线程名Thread-0释放了锁

tryLock()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 private void testTryLock(Thread thread) {
if (lock.tryLock()) {
try {
System.out.println("线程名" + thread.getName() + "获得了锁");
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("线程名" + thread.getName() + "释放了锁");
lock.unlock();
}
} else {
System.out.println("我是" + Thread.currentThread().getName() + "有人占着锁,我就不要啦");
}
}
---------------------------
输出
线程名Thread-0获得了锁
我是Thread-1有人占着锁,我就不要啦
线程名Thread-0释放了锁

Lock接口的实现

锁的多个实现在标准JDK中提供,它们会在下面的章节中展示

ReentrantLock

ReentrantLock类是互斥锁,与通过synchronized访问的隐式监视器具有相同行为,但是具有扩展功能。就像它的名称一样,这个锁实现了重入特性,就像隐式监视器一样。

使用ReentrantLock写一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class ReentrantLockDemo {
private static ReentrantLock lock = new ReentrantLock();

private static int count = 0;

private static void increment() {
lock.lock();
try {
count++;
System.out.println(Thread.currentThread().getName());
} finally {
lock.unlock();
}
}

public static void main(String[] args) throws InterruptedException {

//每个线程分别执行3次 increment()
Runnable runnable = () -> IntStream.range(0, 3)
// .range(0, 10000) //count = 20000
.forEach(i -> increment());

Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
t2.start();

TimeUnit.SECONDS.sleep(1);
System.out.println(count);
}
}
------------------------------------
输出
Thread-0
Thread-0
Thread-0
Thread-1
Thread-1
Thread-1
6

上面的代码中我分让每个线程分别执行3次 increment()。而同一个线程连续执行了三次才换下一个线程,很显然是可重入锁。我们让increment多执行几次,把IntStream.range(0, 3)改为IntStream.range(0, 10000),结果得到20000,也说明不存在竞争。

另外,锁对细粒度的控制支持多种方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ReentrantLock2Demo {
private static ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) throws InterruptedException {


Thread t1 = new Thread(() -> {
lock.lock();
try {
TimeUnit.SECONDS.sleep(1); //保持1秒不放锁
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
});

Thread t2 = new Thread(() -> {
System.out.println("锁是否被占用: " + lock.isLocked());
System.out.println("占用所的是否是我: " + lock.isHeldByCurrentThread());
boolean locked = lock.tryLock();
System.out.println("是否获取锁 : " + locked);
});

t1.start();
t2.start();

}
}
------------------------------------
输出
锁是否被占用: true
占用所的是否是我: false
是否获取锁 : false

ReadWriteLock

ReadWriteLock接口规定了锁的另一种类型,包含用于读写访问的一对锁。读写锁的理念是,只要没有任何线程写入变量,并发读取可变变量通常是安全的。所以读锁可以同时被多个线程持有,只要没有线程持有写锁。这样可以提升性能和吞吐量,因为读取比写入更加频繁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class ReadWriteLockDemo {
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
ReadWriteLock lock = new ReentrantReadWriteLock();

//写的Runnable
Runnable write = () -> {
lock.writeLock().lock();
try {
TimeUnit.SECONDS.sleep(1);
map.put("foo", "bar");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.writeLock().unlock();
}
};

//读的Runnable
Runnable read = () -> {
lock.readLock().lock();
try {
System.out.println(map.get("foo"));
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();
}
};

//开启一个写线程和两个读线程
new Thread(write).start();
new Thread(read).start();
new Thread(read).start();
}
}

当你执行这一代码示例时,你会注意到两个读任务需要等待写任务完成。在释放了写锁之后,两个读任务会同时执行,并同时打印结果。它们不需要相互等待完成,因为读锁可以安全同步获取,只要没有其它线程获取了写锁。

StampedLock

Java 8 自带了一种新的锁,叫做StampedLock,它同样支持读写锁,就像上面的例子那样。与ReadWriteLock不同的是,StampedLock的锁方法会返回表示为long的标记。你可以使用这些标记来释放锁,或者检查锁是否有效。此外,StampedLock支持另一种叫做乐观锁(optimistic locking)的模式。

让我们使用StampedLock代替ReadWriteLock重写上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Runnable write = () -> {
long stamp = lock.writeLock();
try {
TimeUnit.SECONDS.sleep(1);
map.put("foo", "bar");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlockWrite(stamp);
}
};

Runnable read = () -> {
long stamp = lock.readLock();
try {
System.out.println(map.get("foo"));
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlockRead(stamp);
}
};

输出结果和上例相同。通过readLock()writeLock()来获取读锁或写锁会返回一个标记,它可以在稍后用于在finally块中解锁。要记住StampedLock没有实现重入特性。每次调用加锁都会返回一个新的标记,并且在没有可用的锁时阻塞,即使相同线程已经拿锁了。所以你需要额外注意不要出现死锁。

就像前面的ReadWriteLock例子那样,两个读任务都需要等待写锁释放。之后两个读任务同时向控制台打印信息,因为多个读操作不会相互阻塞,只要没有线程拿到写锁。

下面的例子展示了乐观锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class StampedLockDemo2 {
public static void main(String[] args) throws InterruptedException {
StampedLock lock = new StampedLock();

Runnable optimisticRead = () ->{
long stamp = lock.tryOptimisticRead();
try {
System.out.println("乐观锁是否有效 : "+ lock.validate(stamp));
TimeUnit.SECONDS.sleep(1);
System.out.println("乐观锁是否有效 : "+ lock.validate(stamp));
TimeUnit.SECONDS.sleep(2);
System.out.println("乐观锁是否有效 : "+ lock.validate(stamp));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.tryUnlockRead();
}
};

Runnable write = () -> {
long stamp = lock.writeLock();
try {
System.out.println("获取写锁");
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(stamp);
System.out.println("写完了");
}
};

new Thread(optimisticRead).start();
new Thread(write).start();
}
}

乐观的读锁通过调用tryOptimisticRead()获取,它总是返回一个标记而不阻塞当前线程,无论锁是否真正可用。如果已经有写锁被拿到,返回的标记等于0。你需要总是通过lock.validate(stamp)检查标记是否有效。

执行上面的代码会产生以下输出:

1
2
3
4
5
乐观锁是否有效  : true
获取写锁
乐观锁是否有效 : false
写完了
乐观锁是否有效 : false

乐观锁在刚刚拿到锁之后是有效的。和普通的读锁不同的是,乐观锁不阻止其他线程同时获取写锁。在第一个线程暂停一秒之后,第二个线程拿到写锁而无需等待乐观的读锁被释放。此时,乐观的读锁就不再有效了。甚至当写锁释放时,乐观的读锁还处于无效状态。

所以在使用乐观锁时,你需要每次在访问任何共享可变变量之后都要检查锁,来确保读锁仍然有效。

有时,将读锁转换为写锁而不用再次解锁和加锁十分实用。StampedLock为这种目的提供了tryConvertToWriteLock()方法,就像下面那样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class StampedLockDemo3 {

private static int count = 0;

public static void main(String[] args) {
StampedLock lock = new StampedLock();

Runnable runnable = () -> {
long stamp = lock.readLock(); //先设为读锁
try {
if (count == 0) {
stamp = lock.tryConvertToWriteLock(stamp); //试图转为写锁
if (stamp == 0L) {
System.out.println("转换写锁失败");
stamp = lock.writeLock(); //阻塞当前线程,直到有可用的写锁
}
count = 23;
}
System.out.println(count);

} finally {
lock.unlock(stamp);
}
};

new Thread(runnable).start();
}
}
//输出 23

第一个任务获取读锁,并向控制台打印count字段的当前值。但是如果当前值是零,我们希望将其赋值为23。我们首先需要将读锁转换为写锁,来避免打破其它线程潜在的并发访问。tryConvertToWriteLock()的调用不会阻塞,但是可能会返回为零的标记,表示当前没有可用的写锁。这种情况下,我们调用writeLock()来阻塞当前线程,直到有可用的写锁。

Condition

任何一个java对象都天然继承于Object类,在线程间实现通信的往往会应用到Object的几个方法,比如wait()与notify(),notifyAll()几个方法实现等待/通知机制,同样的, 在java Lock体系下依然会有同样的方法实现等待/通知机制。从整体上来看Object的wait和notify/notify是与对象监视器配合完成线程间的等待/通知机制,而Condition与Lock配合完成等待通知机制,前者是java底层级别的,后者是语言级别的,具有更高的可控制性和扩展性

参照Object的wait和notify/notifyAll方法,Condition也提供了同样的方法:

  • 与Object.wait方法对应的Condition.await方法
  • 与Object.notify/notifyAll方法对应的Condition.signal/signalAll方法

与Lock一起使用

通过一个的示例,来理解如何和配合Lock使用Condition。

我在 2.线程状态转换方法 中写过通过wait/notify实现的发送者 - 接收者同步问题示例,如果你没看过找一下我之前的文章。我把上次的wait/notify改成Condition的方法。

修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class Data {
private String packet;
private final Lock lock = new ReentrantLock();
private boolean transfer = true;

//发送者和接受者各需要一个Condition
private final Condition receiveCondition = lock.newCondition();
private final Condition senderCondition = lock.newCondition();

public String receive() {
lock.lock();
try {
while (transfer) {
receiveCondition.await();
}
transfer = true;
senderCondition.signalAll();

} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
return packet;
}
}

public void send(String packet) {
lock.lock();
try {
while (!transfer) {
senderCondition.await();
}
transfer = false;
this.packet = packet;
receiveCondition.signalAll();

} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

Reference

详解synchronized与Lock的区别与使用

Java 8 并发教程:同步和锁

线程高级篇-Lock锁和Condition条件

CATALOG
  1. 1. Lock接口和synchronized关键字的差别
  2. 2. Lock接口定义的方法和使用
  3. 3. Lock接口的实现
    1. 3.1. ReentrantLock
    2. 3.2. ReadWriteLock
  4. 4. StampedLock
  5. 5. Condition
    1. 5.1. 与Lock一起使用
  6. 6. Reference