多线程编程的实战技巧
很多同学都会在简历上写自己熟悉并发编程,那么就会衍生一道很经典的题:三个线程 A,B,C,按序来执行 N 次并且打印输出 A B C......;很明显,这就是一个多线程的编程题,挺有意思,借此,我想来写一篇文章来记录一下这个题的写法,更多的在于使用上而不是原理。
前言
在进行多线程编程之前:我们需要有一些多线程编程的共识,对于这类控制多线程执行的题目,我们需要有一个方法论:
- 线程操纵资源类
- 在写业务逻辑的时候遵循这么一个流程:判断 -- 执行 -- 通知
- 避免虚假唤醒,用
while
而不用if
来进行判断。
上面说的三个点是基本的框架逻辑,任何控制多线程执行的题目都是遵循这个框架的,然后,对于多线程编程来说,有哪些工具或者类可以使用呢?最熟悉莫过于 synchronized
以及 lock
;那好,这里又有一个方法论:
synchronized
--wait()
--notify()
lock
--await()
--signal()
一般来说,我更喜欢用 lock
,因为这是一个 API
层面的工具,能提供更多更便利的方法,况且,lock
的那个组合可以实现精准通知!当然你还可以利用其他的工具类来实现,那也是可以的。
至此,控制类多线程编程的基本知识就此结束。
实践
就拿上面的题目来说,我们在思考这些题目如何解答的时候,应该将相关逻辑梳理出来:三个线程执行任务并且要按序执行;
基本逻辑就是如此,所以我们可以开干,将该题目套到上面说的方法论上。
线程操纵资源类:
首先我们需要定义一个资源类来给线程进行操作的呀,且资源类中可以定义对应的方法,让线程直接进行调用就好了(当然,线程自己也是可以在各自的线程里面输出对应的字符)。
class Source {
void printA() {
System.out.println("A");
}
void printB() {
System.out.println("B");
}
void printC() {
System.out.println("C");
}
}
三个线程 A B C:其中线程的逻辑就是进行操纵资源类,所以所需要将 Source
注入进去;然后 B 和 C 亦先如此写着;后面的业务的主要逻辑后面再去实现。
class A implements Runnable {
private Source source;
public A(Source source) {
this.source = source;
}
@Override
public void run() {
}
}
class B implements Runnable {
private Source source;
public A(Source source) {
this.source = source;
}
@Override
public void run() {
}
}
class C implements Runnable {
private Source source;
public A(Source source) {
this.source = source;
}
@Override
public void run() {
}
}
好了,至此整个题目的大致框架都几乎完成,那么我们就需要思考,按序执行和打印输出该如何实现呢?
所以现在的难题变成:如何实现控制线程的执行顺序?
上面我们说过,要使用 lock
那个组合,但是问题又来了:各个线程都是独立的呀,我如何将他们进行联合控制呢?都是独立的那就很不好进行控制了么。所以我们可以看看他们是否有一个共同之处:对了!就是都有一个共同的变量 Source
资源类!这是一个很好的突破口。所以,我们改进我们的资源类 Source
。
class Source {
Lock lock = new ReentrantLock();
// 每一个 condition 都是用来控制自己线程的唤醒和阻塞行为的
Condition conditionA = lock.newCondition();
Condition conditionB = lock.newCondition();
Condition conditionC = lock.newCondition();
// 1代表线程A执行;2代表线程B执行;3代表线程C执行
int state = 1;
void printA() {
System.out.println("A");
}
void printB() {
System.out.println("B");
}
void printC() {
System.out.println("C");
}
}
那好,真正执行控制线程输出以及打印的逻辑来了!!!用线程A 为例子:
class A implements Runnable {
private Source source;
public A(Source source) {
this.source = source;
}
@Override
public void run() {
source.lock.lock();
try {
// 这里就是【判断】逻辑,必须要用 while,避免虚假唤醒
while (source.state != 1) {
source.conditionA.await();
}
// 【执行】
// 当然这里可以不使用资源类的,可以自己在这里输出打印逻辑
source.printA();
// 【通知】
source.state = 2;
source.conditionB.signal();
} catch (Excepition e) {
e.printStackTrace();
} finally {
source.lock.unlock();
}
}
}
线程 B 和 C 也是这样的一个方法,你自己去试试?哈哈。至此上面的业务逻辑已经结束,我们再来一个测试用例验证一下:
class Test {
public static void main(String[] args) throws InterruptedException {
Source source = new Source();
for (int i = 0; i < 100; i++) {
new Thread(new A(source)).start();
new Thread(new B(source)).start();
new Thread(new C(source)).start();
}
Thread.sleep(10000);
System.out.println("执行完毕~");
}
}
对于一些常考的多线程编程的思考
我相信,基于上面的一道题目的讲解,你应该有所了解控制多线程类的题目应该怎么做了吧,意犹未尽?那行吧,你来尝试一下这些题目:
- 三个线程按序打印 ABC
- 两个线程,一个打印奇数,一个打印偶数
- 三个线程 ABC,线程A 输出 “A” 5次,线程B 输出 “B” 10次,线程C 输出 “C” 15次
- 生产者消费者模型
对于生产者消费者模型,我想写写 demo
以及我的思考:
首先,我们要抽象出最简单的模型出来,一个队列 Queue
(当然可以自己去实现,也可以使用 Java
的 API
);然后我们生产者和消费者就是线程来的;当然,这里我觉得不应该像上面一样,将生产者和消费者作为一个对象线程(这词也许不恰当,但就是不想作为一个类)来定义,因为生产者和消费者个数可以很多,但是队列的个数只有一个,且不论是生产者还是消费者,其实核心都是围绕着队列进行操作的,所以我们应该在队列中进行定义生产和消费的逻辑。
public class MyQueue {
private final Queue<Integer> queue = new LinkedBlockingQueue<>(10);
Lock lock = new ReentrantLock();
/**
* 从消费队列的入口和出口进行限制,这思路才是正确的
*/
Condition producer = lock.newCondition();
Condition consumer = lock.newCondition();
void setElement(Integer element) {
lock.lock();
try {
// 阻塞队列的元素个数大于10,那么生产者就不要生产了
while (size > 10) {
producer.await();
}
queue.add(element);
size++;
consumer.signal();
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
lock.unlock();
}
}
Integer getElement() {
lock.lock();
try {
// 队列为空,消费者就别消费了
while (queue.isEmpty()) {
consumer.await();
}
producer.signal();
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
lock.unlock();
}
return queue.poll();
}
}
测试类:
class Test {
public static void main(args[] String) {
MyQueue myQueue = new MyQueue();
Random random = new Random();
// 生产者
new Thread(() -> {
while (true) {
try {
Source source = new Source(random.nextInt(100) + 1);
System.out.println("往队列中添加元素++++++++++" + source);
myQueue.setElement(source.getNumber());
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}).start();
// 消费者
new Thread(() -> {
while (true) {
try {
System.out.println("从队列中获取元素:" + myQueue.getElement());
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}).start();
}
}
}
至此,控制线程类型的题目结束;下面再插播一个多线程编程的案例:死锁
死锁的定义(个人理解):多个线程都持有自己独立且不共享的锁(变量),然后相互依赖对方的锁,并且彼此等待对方释放自己的锁从而循环等待,最终形成死锁。
资源类:
class LockDemo {
private String name;
public LockDemo(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
线程类:
public class ThreadDemo implements Runnable {
/**
* 死锁的构建重大原因:资源独享非共享;多线程抢夺;循环等待;线程A已经拥有了一个线程B所需要的资源,线程B拥有了线程A所需要的资源
*/
private final LockDemo lock1;
private final LockDemo lock2;
public ThreadDemo(LockDemo lock1, LockDemo lock2) {
this.lock1 = lock1;
this.lock2 = lock2;
}
@Override
public void run() {
synchronized (lock1) {
System.out.println(Thread.currentThread().getName() + "获取到" + lock1.getName() + ",正准备获取" + lock2.getName());
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2) {
System.out.println(Thread.currentThread().getName() + "获取到" + lock2.getName());
}
}
}
}
测试类:
public class DeadLockTest {
public static void main(String[] args) {
LockDemo lock1 = new LockDemo("lock1");
LockDemo lock2 = new LockDemo("lock2");
new Thread(new ThreadDemo(lock1, lock2)).start();
new Thread(new ThreadDemo(lock2, lock1)).start();
}
}
一运行,死锁就形成了,那么如何解决?打破其中一个规则都是可以的,譬如:将 ThreadDemo
中的资源 LockDemo
对象用 static
修饰;或者说,你线程 new ThreadDemo()
的时候,参数顺序一致,都是可以的。
该栏目主要是分享一些实用的编码技巧、编程知识以及个人认为有大用的八股知识。