Thread线程间协作

线程间的交互和协作从简单到复杂有很多种方式, 下面会从最简单的join开始到使用各种方式和工具来分析. 为了辅助分析线程间的协作, 先撸一下线程的各个状态和状态间轮转的条件.

状态描述
NEW创建对象后start之前的状态
RUNNABLE调用start或yield之后, 代表可以随时运行
BLOCKED线程等待monitor enter时(等锁), 阻塞状态
WAITING等待状态, 和阻塞不一样通常可以被interrupt
TIMED_WAITING同WAITING, 但是TIMED_WAITING有时间限制, 超时后终止TIMED_WAITING进入RUNNABLE
TERMINATED线程运行完毕或被关闭后的状态

各个状态之间的流转参考下图所示

在这里插入图片描述

join

join可以做到最简单的线程交互, 可以让某个线程阻塞起来进入WAITINGTIMED_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);
  1. 线程A启动, 使用countDownLatch.await让线程A进入等待状态.
  2. 线程B启动, 每隔一秒countDown一次. 一共countDown两次.
  3. 线程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的特点:

  1. 基于CAS实现.
  2. 阻塞开关线程, 计数达到后再唤醒开关所在线程.
  3. 不能重复计数.
  4. 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的特点

  1. 基于ReentrantLock和Condition实现.
  2. 可以多次触发开关, 不同于CountDownLatch.
  3. 阻塞计数线程, 满足条件后唤醒最后等待的计数线程.
  4. 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;
  1. 由于要先打印数字, 所以letter线程先启动获取到锁后直接调用锁的wait方法, letter线程进入等待状态并释放锁.
  2. num线程随后启动, 等letter线程释放锁后num线程获得锁, 打印第一个数字.
  3. num线程调用notifyAll方法, 通知letter线程可以继续运行了. 但是由于调用notify方法并不会释放锁, 所以letter会阻塞在那里等待获取到锁.
  4. num线程调用wait方法, 进入等待状态并释放锁.
  5. letter线程获取到锁, 打印第一个字母后通知num线程退出等待状态.
  6. letter线程进行第二次循环, 并在此进入等待状态并释放锁.
  7. 重复循环上面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的特点:

  1. 必须在同步块中使用(monitorenter和monitorexit之间), 否则会抛异常.
  2. wait可以被interrupt, wait后释放锁.
  3. wait notify需要注意时序问题.

LockSupport

使用LockSupport的park和unpark方法也可以进行线程间协作, 并且它比wait notify还要更加灵活. park unpark方法和wait notify方法作用上类似, 都是让当前线程进入等待状态. park unpark方法底层是基于Unsafe类的native方法来实现的, 他们不需要再同步块中使用, 并且是指定某个线程unpark唤醒. AQS和下一节要讲的ReentrantLock的Condition也是基于LockSupport实现.

还是以数字字母交替输出为demo, 实现的思路跟wait notify类似.

  1. letter线程先启动, 进入循环后直接park等待.
  2. num线程紧随启动, 打印第一个数字后调用unpark(letterThread), 唤醒letter线程.
  3. num线程park等待.
  4. letter线程在第2步后同步运行, 打印第一个字母后调用unpark(numThread), 唤醒num线程.
  5. letter线程进入第二轮循环并park等待.
  6. 循环上面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值含义
0park或初始值
1unpark

_counter字段默认是0, 如果先调用了unpark将_counter值设置为1. 等待2s后线程park时会先判断_counter是否大于0, 如果大于0说明已经事先设置了unpark, 将_counter置为0并不需要让线程等待. 由于每次park都会将_counter置为0, 所以不管事先unpark了多少次, 连续两次park肯定会让线程进入等待状态.

总结一下LockSupport的特点:

  1. 不需要在同步块中使用.
  2. 可以唤醒指定的线程.
  3. park时被interrupt, 不会抛异常而是直接唤醒. 如果线程在中断状态, 会忽略park.
  4. park unpark没有时序问题, 可以先unpark再park.
  5. 连续两次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();
  1. letter线程启动, 加锁后await进入等待状态并释放锁.
  2. num线程紧随启动, 加锁打印1.
  3. num线程调用signal唤醒letter线程, num线程await并释放锁.
  4. letter线程申请到锁后打印a.
  5. letter线程通过signal唤醒num线程, 并进入第二次循环await释放锁.
  6. 循环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的特点:

  1. 必须在同步块中使用(monitorenter和monitorexit之间), 否则会抛异常.
  2. await可以被interrupt, await后释放锁.
  3. await signal需要注意时序问题.

Conditions

基于上一节的Condition, ReentrantLock对象可以创建多个Condition, 这样可以以更细的粒度来处理不同类型线程间的交互. 例如创建两个Condition可以很好的适配生产者消费者场景. 这里还是以数字字母交替打印为例.

private static Condition letterCondition = reentrantLock.newCondition();
private static Condition numCondition = reentrantLock.newCondition();
  1. letter线程启动, 加锁后使用letterCondition.await进入等待状态, 释放锁.
  2. num线程紧随启动, 申请到锁后打印1.
  3. num线程使用letterCondition.signal来唤醒letter线程.
  4. num线程使用numCondition.await进入等待状态, 释放锁.
  5. letter线程申请到锁后, 打印a.
  6. letter线程使用numCondition.signal唤醒num线程.
  7. letter线程进入第二次循环, 再次进入等待状态并释放锁.
  8. 循环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();
  1. 启动letter线程, letter线程从synchronousQueue中获取数据. 由于没有线程put, 所以letter线程进入等待状态.
  2. 启动num线程, 将数字1通过put传递给letter线程, 并唤醒letter线程.
  3. num线程从synchronousQueue中获取数据, 进入等待状态.
  4. letter线程将从队列中获取到的数字1打印, 并将字母a放入队列唤醒num线程.
  5. letter线程进入第二次循环, take等待.
  6. num线程获取到字母a并打印
  7. 循环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特点:

  1. 队列0容量, 只能一个线程take, 同时另一个线程put.
  2. 没有put时, take阻塞线程.
  3. 没有take时, put阻塞线程.
  4. put和take可以被interrupt.

转载请注明出处:https://blog.csdn.net/l2show/article/details/104063430

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值