线程间的交互和协作从简单到复杂有很多种方式, 下面会从最简单的join开始到使用各种方式和工具来分析. 为了辅助分析线程间的协作, 先撸一下线程的各个状态和状态间轮转的条件.
状态 | 描述 |
---|---|
NEW | 创建对象后start之前的状态 |
RUNNABLE | 调用start或yield之后, 代表可以随时运行 |
BLOCKED | 线程等待monitor enter时(等锁), 阻塞状态 |
WAITING | 等待状态, 和阻塞不一样通常可以被interrupt |
TIMED_WAITING | 同WAITING, 但是TIMED_WAITING有时间限制, 超时后终止TIMED_WAITING进入RUNNABLE |
TERMINATED | 线程运行完毕或被关闭后的状态 |
各个状态之间的流转参考下图所示
join
join可以做到最简单的线程交互, 可以让某个线程阻塞起来进入WAITING
或TIMED_WAITING
状态, 等另外一个线程执行完成或超时后再继续运行. 以下面代码为例, 线程A启动起来sleep两秒钟. 线程B紧随着线程A启动, 并在线程B中join线程A. 查看运行结果. 线程B在join等待时可以被interrupt
.
final String tag = "testJoin";
final Thread thread = new Thread(new Runnable() {
@Override
public void run() {
log(tag, "Thread A: sleep 2s");
Thread.sleep(2000);
log(tag, "Thread A: finished");
}
});
thread.start();
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
log(tag, "Thread B joining Thread A");
thread.join();
log(tag, "Thread B joined Thread A");
log(tag, "Thread B: finished");
}
});
threadB.start();
运行结果:
testJoin, Thread A: sleep 2s
testJoin, Thread B joining Thread A
testJoin, Thread A: finished
testJoin, Thread B joined Thread A
testJoin, Thread B: finished
join也可以设置超时时间, 避免线程B等待时间多长. 根据下面join的源码, 可以看到join是基于wait/notify来实现的. 在线程B中join线程A后, 线程B会持有线程A内部的lock对象锁, 并且lock对象会根据设定的时间wait等待. 如果没有设置join的时间, 那么就不停得循环等待直到线程A运行结束或者被interrupt后才会唤醒线程B并释放锁.
Thread::join()
synchronized(lock) {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
lock.wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
lock.wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
yield
yield是一个静态native方法, 当某个线程调用yield后该线程会放弃CPU时间片, 将线程状态从RUNNING转为RUNNABLE. 通常会将CPU让给另外一个线程去执行. 以下面demo为例, 线程A和B循环打印100个数组, 两个线程每逢打印到10的倍数时就调用yield让出CPU.
final String tag = "testYield";
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0;i < 100; i++) {
log(tag, "Thread A: " + i);
if (i % 10 == 0) {
log(tag, "Thread A: yield");
Thread.yield();
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0;i < 100; i++) {
log(tag, "Thread B: " + i);
if (i % 10 == 0) {
log(tag, "Thread B: yield");
Thread.yield();
}
}
}
}).start();
执行结果太长这里就不贴了, 观察日志可以看到demo是按照我们的期望运行的. 每次某个线程执行yield后就会切换另外一个线程运行.
其实切换另外一个线程运行这种说法是不准确的. 因为yield只是让当前线程放弃CPU时间片, 让出的CPU时间片是需要多个线程去争抢, 这些线程包括了调用yield方法的线程. 也就是说线程A调用yield放弃CPU时间片, 线程A, B, C, D四个线程去抢, 有可能还是线程A抢到了CPU. 那么现象就是线程A虽然调用了yield方法, 但是他还是会继续运行下去. 我的demo可能是因为数据样本太少没有复现这种场景.
CountDownLatch
CountDownLatch是一个计数开关, 在多线程的情况下基于CAS(CAS无锁优化)进行计数, 计数达到设置值后就会放开await的线程. 之后再await就不会让线程进入等待状态, 也就是说CountDownLatch只能计一轮数.
private static CountDownLatch countDownLatch = new CountDownLatch(2);
- 线程A启动, 使用countDownLatch.await让线程A进入等待状态.
- 线程B启动, 每隔一秒countDown一次. 一共countDown两次.
- 线程A在线程B countDown2次后唤醒. 并且第二次await没有让线程A进入等待状态.
final String tag = "testCountDownLatch";
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
log(tag, "Thread A await");
countDownLatch.await();
countDownLatch.await();
log(tag, "Thread A finished");
}
});
log(tag, "Thread A start");
threadA.start();
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
Thread.sleep(1000);
log(tag, "Thread B countdown 1th");
countDownLatch.countDown();
Thread.sleep(1000);
log(tag, "Thread B countdown 2th");
countDownLatch.countDown();
}
});
log(tag, "Thread B start");
threadB.start();
运行结果:
testCountDownLatch, Thread A start
testCountDownLatch, Thread A await
testCountDownLatch, Thread B start
testCountDownLatch, Thread B countdown 1th
testCountDownLatch, Thread B countdown 2th
testCountDownLatch, Thread A finished
CountDownLatch的特点:
- 基于CAS实现.
- 阻塞开关线程, 计数达到后再唤醒开关所在线程.
- 不能重复计数.
- await可以被interrupt.
CyclicBarrier
CyclicBarrier跟CountDownLatch作用都是开关, 但是使用的场景又不一样. CyclicBarrier像是可以多次触发开关的CountDownLatch, 但是CyclicBarrier阻塞的是计数线程, 并不像CountDownLatch阻塞的是开关线程.
我们设计一个例子来演示一下CyclicBarrier的交互. 假设有一个海边小船租赁商店, 要求是必须两个人来才能借走一艘小船. 这时有五个人来到了店里, 我们用CyclicBarrier来模拟下这个过程. 先新建一个CyclicBarrier对象, 设定门槛为2. 并在达到门槛后打印一下租船信息.
final String tag = "testCyclicBarrier";
final CyclicBarrier cyclicBarrier = new CyclicBarrier(2, new Runnable() {
@Override
public void run() {
log(tag, Thread.currentThread().getName() + " rent a boat");
}
});
循环创建并启动五个线程, 每个线程进来都使用cyclicBarrier.await(). 如果凑齐了两个人, 就触发开关, 计数线程继续运行. 没凑齐的话就进入等待状态. CyclicBarrier的await也可以被interrupt.
Runnable runnable = new Runnable() {
@Override
public void run() {
log(tag, Thread.currentThread().getName() + " wait");
cyclicBarrier.await();
log(tag, Thread.currentThread().getName() + " go boating");
}
};
for (int i = 0; i < 5; i++) {
Thread people = new Thread(runnable);
people.setName("People " + i);
people.start();
Thread.sleep(1000);
}
CyclicBarrier的特点
- 基于ReentrantLock和Condition实现.
- 可以多次触发开关, 不同于CountDownLatch.
- 阻塞计数线程, 满足条件后唤醒最后等待的计数线程.
- await可以被interrupt.
wait notify
上面讲join的时候看源码就是根据wait notify实现的, 直接用wait notify会更加灵活. 跟字面意思一致, 这两个方法中wait会让线程进入WAITING或TIMED_WAITING状态, 而notify会通知当前正在waiting的线程退出等待状态, 争抢CPU轮值.
该系列方法是在Object
基类中通过native方法实现, 方法需要在同步块内使用(当前线程需要获得锁对象的监视器), 否则会抛出IllegalMonitorStateException
异常. wait系列方法是可以被interrupt
. 并且线程调用wait系列方法后会释放同步锁, 因为不释放的话其他的线程就无法获得到锁调用notify方法, 这样就死锁了. 调用notify系列方法并不会释放锁.
demo要求如下: 提供两个数组, 数组的长度都一样内容分别是字母从a到i和数字从1到9. 使用两个线程, 一个线程读取字母数组并输出, 另一个线程读取数组数组输出. 要求两个线程交替输出内容, 输出格式如下: 1a2b3c4d5e6f7g8h9i
private static char[] letters = "abcdefghi".toCharArray();
private static char[] letterNum = "123456789".toCharArray();
private static Thread letterThread, numThread;
- 由于要先打印数字, 所以letter线程先启动获取到锁后直接调用锁的wait方法, letter线程进入等待状态并释放锁.
- num线程随后启动, 等letter线程释放锁后num线程获得锁, 打印第一个数字.
- num线程调用notifyAll方法, 通知letter线程可以继续运行了. 但是由于调用notify方法并不会释放锁, 所以letter会阻塞在那里等待获取到锁.
- num线程调用wait方法, 进入等待状态并释放锁.
- letter线程获取到锁, 打印第一个字母后通知num线程退出等待状态.
- letter线程进行第二次循环, 并在此进入等待状态并释放锁.
- 重复循环上面1-6六个步骤, 完成要求输出1a2b3c4d5e6f7g8h9i结果.
private static Object lock = new Object();
final String tag = "testSyncWaitNotify";
letterThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (lock) {
for (char letter : letters) {
lock.wait();
log(tag, String.valueOf(letter));
lock.notifyAll();
}
}
}
});
numThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (lock) {
for (char num : letterNum) {
log(tag, String.valueOf(num));
lock.notifyAll();
lock.wait();
}
}
}
});
letterThread.start();
numThread.start();
由于wait notify需要配合锁才能使用, 在上面的demo中我们是letter线程先启动进入等待状态, num线程紧随启动申请到锁后再通知lock锁释放等待状态. 这样进行线程间轮转是没有问题的. 如果我们调换线程的启动顺序呢?
numThread.start();
letterThread.start();
调换线程的启动顺序, 先启动num线程 -> 获得锁 -> 打印数字1 -> 通知正在wait的线程 -> num线程wait释放锁. letter线程随后启动 -> 阻塞等锁 -> 等num线程运行完第一个循环释放锁后得到同步锁 -> letter线程wait. 由于同步锁的竞争关系, num线程先调用了notify, letter才调用了wait. 所以letter线程会一直wait在那里, 而num线程也在wait, 完蛋两个线程都等待在那里了. 虽然不是死锁, 但是没有外部interrupt或notify这两个线程跟废了没什么区别. 为了避免这种wait notify的时序问题, 最好自己捋好线程轮转逻辑, 或者使用带超时的wait方法.
总结一下wait notify的特点:
- 必须在同步块中使用(monitorenter和monitorexit之间), 否则会抛异常.
- wait可以被interrupt, wait后释放锁.
- wait notify需要注意时序问题.
LockSupport
使用LockSupport的park和unpark方法也可以进行线程间协作, 并且它比wait notify还要更加灵活. park unpark方法和wait notify方法作用上类似, 都是让当前线程进入等待状态. park unpark方法底层是基于Unsafe类的native方法来实现的, 他们不需要再同步块中使用, 并且是指定某个线程unpark唤醒. AQS和下一节要讲的ReentrantLock的Condition也是基于LockSupport实现.
还是以数字字母交替输出为demo, 实现的思路跟wait notify类似.
- letter线程先启动, 进入循环后直接park等待.
- num线程紧随启动, 打印第一个数字后调用unpark(letterThread), 唤醒letter线程.
- num线程park等待.
- letter线程在第2步后同步运行, 打印第一个字母后调用unpark(numThread), 唤醒num线程.
- letter线程进入第二轮循环并park等待.
- 循环上面1-5五个步骤, 完成要求输出1a2b3c4d5e6f7g8h9i结果.
final String tag = "testLockSupport";
letterThread = new Thread(new Runnable() {
@Override
public void run() {
for (char letter : letters) {
// Thread.sleep(2000);
LockSupport.park();
log(tag, String.valueOf(letter));
LockSupport.unpark(numThread);
}
}
});
numThread = new Thread(new Runnable() {
@Override
public void run() {
for (char num : letterNum) {
log(tag, String.valueOf(num));
LockSupport.unpark(letterThread);
LockSupport.park();
}
}
});
letterThread.start();
numThread.start();
上面在讲wait notify的时候如果调用的时序不对, 会有让两个线程都进入等待状态的问题. 那使用LockSupport可以复现吗? 我们调转两个线程的启动顺序试一下. 由于LockSupport不需要使用同步块, 所以我们不仅调换线程的启动顺序, 还放开letter线程中的注释. 确保num线程已经unpark过letter线程后, letter线程再park.
numThread.start();
letterThread.start();
运行后发现虽然打印慢了点, 但是还是能够输出正确的结果. 说明park unpark是没有时序性的, 先unpark线程再park线程也不会让线程进入等待, 有点类似预授权的意思. 简单看下Hotspot源码可以发现park unpark两个状态是根据属性_counter来区分的.
_counter值 | 含义 |
---|---|
0 | park或初始值 |
1 | unpark |
_counter字段默认是0, 如果先调用了unpark将_counter值设置为1. 等待2s后线程park时会先判断_counter是否大于0, 如果大于0说明已经事先设置了unpark, 将_counter置为0并不需要让线程等待. 由于每次park都会将_counter置为0, 所以不管事先unpark了多少次, 连续两次park肯定会让线程进入等待状态.
总结一下LockSupport的特点:
- 不需要在同步块中使用.
- 可以唤醒指定的线程.
- park时被interrupt, 不会抛异常而是直接唤醒. 如果线程在中断状态, 会忽略park.
- park unpark没有时序问题, 可以先unpark再park.
- 连续两次park肯定会让线程等待.
Condition
在多线程使用同一个ReentrantLock的时候, 可以通过Condition来进行不同线程之间的交互. 首先我们先创建一个ReentrantLock对象, 在根据锁对象创建一个Condition. 多线程间可以利用condition的await 和signal方法实现线程的等待和唤醒. 其实Condition的使用条件的效果跟wait notify很类似. 只是这里的Condition是基于AQS和LockSupport实现的.
private static ReentrantLock reentrantLock = new ReentrantLock();
private static Condition condition = reentrantLock.newCondition();
- letter线程启动, 加锁后await进入等待状态并释放锁.
- num线程紧随启动, 加锁打印1.
- num线程调用signal唤醒letter线程, num线程await并释放锁.
- letter线程申请到锁后打印a.
- letter线程通过signal唤醒num线程, 并进入第二次循环await释放锁.
- 循环1-5五个步骤正确打印出 1a2b3c4d5e6f7g8h9i.
final String tag = "testCondition";
letterThread = new Thread(new Runnable() {
@Override
public void run() {
reentrantLock.lock();
for (char letter : letters) {
condition.await();
log(tag, String.valueOf(letter));
condition.signal();
}
reentrantLock.unlock();
}
});
numThread = new Thread(new Runnable() {
@Override
public void run() {
reentrantLock.lock();
for (char num : letterNum) {
log(tag, String.valueOf(num));
condition.signal();
condition.await();
}
reentrantLock.unlock();
}
});
letterThread.start();
numThread.start();
总结一下Condition的特点:
- 必须在同步块中使用(monitorenter和monitorexit之间), 否则会抛异常.
- await可以被interrupt, await后释放锁.
- await signal需要注意时序问题.
Conditions
基于上一节的Condition, ReentrantLock对象可以创建多个Condition, 这样可以以更细的粒度来处理不同类型线程间的交互. 例如创建两个Condition可以很好的适配生产者消费者场景. 这里还是以数字字母交替打印为例.
private static Condition letterCondition = reentrantLock.newCondition();
private static Condition numCondition = reentrantLock.newCondition();
- letter线程启动, 加锁后使用letterCondition.await进入等待状态, 释放锁.
- num线程紧随启动, 申请到锁后打印1.
- num线程使用letterCondition.signal来唤醒letter线程.
- num线程使用numCondition.await进入等待状态, 释放锁.
- letter线程申请到锁后, 打印a.
- letter线程使用numCondition.signal唤醒num线程.
- letter线程进入第二次循环, 再次进入等待状态并释放锁.
- 循环1-7七个步骤, 同样能打印出1a2b3c4d5e6f7g8h9i.
final String tag = "testConditions";
letterThread = new Thread(new Runnable() {
@Override
public void run() {
reentrantLock.lock();
for (char letter : letters) {
letterCondition.await();
log(tag, String.valueOf(letter));
numCondition.signal();
}
reentrantLock.unlock();
}
});
numThread = new Thread(new Runnable() {
@Override
public void run() {
reentrantLock.lock();
for (char num : letterNum) {
log(tag, String.valueOf(num));
letterCondition.signal();
numCondition.await();
}
reentrantLock.unlock();
}
});
letterThread.start();
numThread.start();
SynchronousQueue
基于BlockingQueue实现的SynchronousQueue特性, 也可以做到两个线程数字和字母交替打印. 我们先新建一个SynchronousQueue对象. SynchronousQueue的put和take方法可以阻塞线程: put阻塞线程, 直到有另外的线程take. 同理take阻塞, 直到有线程put. 因为这个特性, SynchronousQueue又被叫做手递手队列.
private static SynchronousQueue synchronousQueue = new SynchronousQueue();
- 启动letter线程, letter线程从synchronousQueue中获取数据. 由于没有线程put, 所以letter线程进入等待状态.
- 启动num线程, 将数字1通过put传递给letter线程, 并唤醒letter线程.
- num线程从synchronousQueue中获取数据, 进入等待状态.
- letter线程将从队列中获取到的数字1打印, 并将字母a放入队列唤醒num线程.
- letter线程进入第二次循环, take等待.
- num线程获取到字母a并打印
- 循环1-7七个步骤. 正确打印出结果. 这个demo中不再是letter线程打印letter, num线程打印数字. 而是反过来打印的.
final String tag = "testSynchronousQueue";
letterThread = new Thread(new Runnable() {
@Override
public void run() {
for (char letter : letters) {
String takeFromNumThread = String.valueOf(synchronousQueue.take());
log(tag, takeFromNumThread);
synchronousQueue.put(letter);
}
}
});
numThread = new Thread(new Runnable() {
@Override
public void run() {
for (char num : letterNum) {
synchronousQueue.put(num);
String takeFromLetterThread = String.valueOf(synchronousQueue.take());
log(tag, takeFromLetterThread);
}
}
});
letterThread.start();
numThread.start();
SynchronousQueue特点:
- 队列0容量, 只能一个线程take, 同时另一个线程put.
- 没有put时, take阻塞线程.
- 没有take时, put阻塞线程.
- put和take可以被interrupt.
转载请注明出处:https://blog.csdn.net/l2show/article/details/104063430
来源:CSDN
作者:Jesse-csdn
链接:https://blog.csdn.net/l2show/article/details/104063430