线程之间的通信
在并发编程中,主要考虑两种方式来实现线程之间的通信:
- 共享内存
- 消息传递
共享内存的一致性就需要锁来维护了,而在erlang中采用消息传递的方式,每个线程都有自己的消息队列,存放从别的线程拿到的消息。
锁模式
锁的工作模式有两种:
- 独占模式
- 共享模式
用读写锁来理解这两种模式很方便:读锁占有时,可以在上面继续加读锁。写锁占有时,任何锁都不能往上加(包括写锁)。
synchronized
synchronized是java关键字,被它修饰的代码块同一时间只能有一个线程进入。常见的两种用法如下:
public class Test {
public void func1() {
synchronized (Test.this) {
}
}
public synchronized void func2() {
}
}
在JDK 1.5之前synchronized和concurrent之间的性能差距还是挺大的,但是在JDK 1.6中通过优化,性能已经有比较大的改善。所以有时候还是用JDK自带的东西比较好,他能不断持续优化。被修饰的对象会有四种状态:
- 无锁
- 偏向锁
- 轻量级锁
- 重锁
具体的实现方式是在对象头中保存信息。可以猜想一段时间内,统一资源的线程数不会很多。进一步说,一个线程很有可能连续的访问同一个资源。如果不停地去获取锁,这样效率就会下降很多。偏向锁是乐观锁,如果发现已经持有锁了,那么就省去了一大堆的操作。
一个线程一直持有锁的时间也不会很长,更常见的情况应该是中间会被短暂打断。此时不需要将当前线程放到阻塞队列里面去,只需要用CAS操作在其自旋几次就可以了,这就是轻量级锁的优势。
如果上面的这些情况都没有出现,那显然是一个悲观的情况,变为重锁。
concurrent
大家经常说的并发包就是指java.util.concurrent.*的内容,需要去花时间理解AQS,他对底层的操作(cas、pack、unpack)进行封装,你只需要实现它的几个抽象方法,就能实现不同策略的并发功能(可以把他们看做是关键入口的开关):
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
protected boolean tryReleaseShared(int arg) {
throw new UnsupportedOperationException();
}
下面来详细看AQS到底封装了什么。首先,被阻塞的线程会放到一个队列上等待被唤醒,相关的结构如下:
static final class Node {
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
}
private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;
在并发包中对park、unpark等进行了封装,在LockSupport中实现:
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
unsafe.park(false, 0L);
setBlocker(t, null);
}
public static void unpark(Thread thread) {
if (thread != null)
unsafe.unpark(thread);
}
在park、unpark中需要注意一点,被unpark的线程会在park的地方继续执行,而unpark的线程做完之后就接着干自己的事情。
独占模式加锁
首先来看阻塞模式下加锁的代码:
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
final boolean acquireQueued(final Node node, int arg) {
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor(); // 获取前面的节点
if (p == head && tryAcquire(arg)) { // 在阻塞队列的最里面 || 尝试获取锁成功
setHead(node);
p.next = null;
return interrupted;
}
// 阻塞操作
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} catch (RuntimeException ex) {
cancelAcquire(node);
throw ex;
}
}
在acquire中的tryAcquire可以用来实现不同的加锁策略,这个可以看ReentrantLock中的FairSync和NonfairSync。在acquireQueued中实现了真正的逻辑:
不停地尝试获取资源(tryAcquire),如果成功,那么当前线程运行在独占模式,如果失败,则继续阻塞。
整个运行的过程在看完下面解锁的过程就明白了。
独占模式解锁
在独占模式下,要释放锁的时候,只需要将头结点后面的节点唤醒即可:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
// 去掉之前CANCLE的节点
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 唤醒后继节点中的线程
if (s != null)
LockSupport.unpark(s.thread);
}
独占模式下的解锁时分成两步来进行操作的:
- 释放掉占用状态
- 唤醒后面的线程
释放成功之后,后面的线程就工作在独占模式了,对应到加锁代码中的话,就是从parkAndCheckInterrupt中返回了。用一个简单的图来看独占模式:
共享模式加锁
共享与独占的模式有些不同,如果一个共享锁获取成功,那么后面正在被阻塞等待共享锁的线程也会被唤醒执行,看代码:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) { // 获取成功
setHeadAndPropagate(node, r); // 设置头结点并唤醒后面的节点
p.next = null;
if (interrupted)
selfInterrupt();
return;
}
}
// 获取失败则被阻塞
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} catch (RuntimeException ex) {
cancelAcquire(node);
throw ex;
}
}
主要的不同点在setHeadAndPropagate中,如果节点是共享状态,那么会调用doReleaseShared方法来完成对后面节点唤醒操作。那么被唤醒的节点继续执行,在下一次循环中会继续唤醒后面的节点,所以可以看到这里是一个依次传递的过程。
共享模式解锁
虽然在共享模式下加锁的时候会唤起后面的节点,但是如果后的节点没有继续运行的话(也就是说虽然这个节点没有被继续阻塞,但是它确实没有执行),那么后面的节点就没办法被唤醒了。
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
unparkSuccessor(h);
} else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
if (h == head)
break;
}
}
在共享模式下可能出现下面一种情况:唤醒的线程B比当前线程A执行更快,以至于在A的循环还没执行完head已经变化了:
- 出现这种情况再次进去循环,将head的状态设置为Node.PROPAGATE。
还有一种情况是B执行的没有那么快,此时head没有发生变化:
- 在这种情况下当前线程不会沿着链表继续unpark下去。
下面用一张图来看共享模式下的操作:
下面来看concurrent包中的并发工具的用法。
ReentrantLock
可重入锁的含义就是说,一个拿到锁的线程还能再次拿到锁,它工作在独占模式下,用法如下:
lock.lock();
try {
// ...
} finally {
lock.unlock();
}
Condition
在很多时候线程会等到某个条件成熟之后才能恢复运行,在此之前它甚至没有机会去被unpark,因为在Condition上等待的线程没有在阻塞队列中:
lock.lock();
try {
while (count == capacity) {
notFull.await();
}
count++;
notEmpty.signalAll();
} finally {
lock.unlock();
}
CountDownLatch
一个计数器:
final CountDownLatch latch = new CountDownLatch(2);
new Thread() {
public void run() {
// TODO
latch.countDown();
}
}.start();
latch.await();
CyclicBarrier
等到线程数到齐了就一起启动,可以重复使用:
final CyclicBarrier barrier = new CyclicBarrier(2);
new Thread() {
public void run() { barrier.await();
// TODO
}
}.start();
Semaphore
信号量,用来控制访问资源的线程数目:
final Semaphore semaphore = new Semaphore(2);
new Thread() {
public void run() {
semaphore.acquire(1);
try {
} finally {
semaphore.release(1);
}
}
}.start();
ReentrantReadWriteLock
经典的读写锁实现:
final ReadWriteLock rwl = new ReentrantReadWriteLock();
final Lock readLock = rwl.readLock(); // 读锁
final Lock writeLock = rwl.writeLock(); // 写锁
new Thread() {
public void run() {
readLock.lock();
try {
} finally {
readLock.unlock();
}
}
}.start();
new Thread() {
public void run() {
writeLock.lock();
try {
} finally {
writeLock.unlock();
}
}
}.start();
----------END----------