文章目录
本系列文章:
多线程(一)多线程基础
多线程(二)Java内存模型、同步关键字
多线程(三)线程池
多线程(四)显式锁、队列同步器
多线程(五)可重入锁、读写锁
多线程(六)线程间通信机制
多线程(七)原子操作、阻塞队列
多线程(八)并发容器
多线程(九)并发工具
多线程(十)多线程编程总结
一、多线程设计模式
1.1 单线程执行/Single Threaded Execution
常见的就是synchronized、Lock的使用,某段代码或某个方法同一时间只能由一个线程执行。
同步方法示例:
//实例同步方法,锁住的是类的实例对象
public synchronized void method(){
//...
}
//静态同步方法,锁住的是类对象。
public static synchronized void method(){
//...
}
同步代码块示例:
//同步代码块,锁住的是该类的实例对象
synchronized(this){
//...
}
//同步代码块,锁住的是该类的类对象
synchronized(CurrentClass.class){
//...
}
//同步代码块,锁住的是任意对象
final Object lock = new Object();
synchronized(lock){
//...
}
显式锁使用示例:
private Lock lock = new ReentrantLock();
public void test(){
lock.lock();
try{
doSomeThing();
}catch (Exception e){
}finally {
lock.unlock();
}
}
1.2 不可变/Immutable
比如String及8个常见的基本数据类型的包装类(Boolean、Byte、Character、Double、Float、Integer、Long、Short)就是不可变类。
Immutable模式:当一个类的实例创建完成后,其状态就完全不会发生变化。这时,该类的方法就算被多个线程同时执行也没关系,所以这些方法也就无需声明为synchronized。这样一来就可在安全性和生存性都不丧失的同时提高性能。
在Immutable模式中,保护类的并不是synchronized,而是immutability(不可变性)。
Immutable模式的使用场景:
- 1、实例创建后,状态不再发生变化。实例的状态是由字段的值决定的,所以“将字段声明为final字段,且不存在setter方法”是重点所在。
- 2、实例是共享的,且被频繁访问时。Immutable模式的优点是“不需要使用synchronized进行保护”。不需要使用synchronized进行保护就意味着能够在不失去安全性和生存性的前提下提高性能。当实例被多个线程共享,且有可能被频繁访问时,Immutable模式的优点就会凸显出来。
1.3 Guarded Suspension/线程间通信
通过让线程等待来保证实例的安全性,可以简单理解为线程间的等待通知。比如用两个线程,一个输出数字,一个输出字母,最后的结果是交替输出1A2B3C4D5E…示例:
public class ThreadTest {
private static volatile boolean t2Started = false;
final Object lock = new Object();
char[] arrI = "1234567".toCharArray();
char[] arrC = "ABCDEFG".toCharArray();
public static void main(String[] args) {
new ThreadTest().testSyncWaitNofiy();
}
public void testSyncWaitNofiy() {
new Thread(() -> {
synchronized (lock) {
while (!t2Started) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (char c : arrC) {
System.out.print(c);
try {
lock.notify();
lock.wait();//让出锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify();//必须,否则无法终止程序
}
}, "t1").start();
new Thread(() -> {
synchronized (lock) {
for (char c : arrI) {
System.out.print(c);
t2Started = true;
try {
lock.notify();
lock.wait();//让出锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify();//必须,否则无法终止程序
}
}, "t2").start();
}
}
1.4 生产者-消费者/Producer-Consumer
生产者消费者模式,最常见的就是使用wait-notify来实现。在生产者消费者模式中,一般都需要一个容器来存储数据,常见的是用BlockingQueue来存储。示例:
public class ProducerConsumerExample {
public static void main(String[] args) {
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
Runnable producer = () -> {
try {
while (true) {
String item = "" + (int)(Math.random() * 100);
queue.put(item);
System.out.println("生产了:" + item + ",此时队列中的元素:" + queue.toString());
Thread.sleep(1000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
Runnable consumer = () -> {
try {
while (true) {
String taken = queue.take();
System.out.println("消费了: " + taken+ ",此时队列中的元素:" + queue.toString());
Thread.sleep(2000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
Thread producerThread = new Thread(producer);
Thread consumerThread = new Thread(consumer);
producerThread.start();
consumerThread.start();
}
}
1.5 读写锁/Read-Write Lock
在Read-Write Lock模式中,读取操作和写入操作是分开考虑的。在执行读取操作之前,线程必须获取用于读取的锁。而在执行写入操作之前,线程必须获取用于写入的锁。
由于当线程执行读取操作时,实例的状态不会发生变化,所以多个线程可以同时读取。但在读取时,不可以写入。
当线程执行写入操作时,实例的状态就会发生变化。因此,当有一个线程正在写入时,其他线程不可以读取或写入。
该模式的结构:
前置处理(获取锁)
try{
实际的操作
}finally{
后置处理(释放锁)
}
读写锁使用伪代码:
private final ReadwriteLock lock = new ReadWriteLock();
//读锁的使用
lock.readLock();
try{
//读操作
}finally{
lock.readunlock();
}
//写锁的使用
lock.writeLock();
try{
//写操作
}finally{
lock.writeUnlock();
}
读写锁的适用场景:
- 读操作频繁;
- 读操作频率比写操作频率高。
1.6 Worker Thread/线程池
Worker的意思是工作的人、劳动者。在Worker Thread模式中,工人线程(worker thread)会逐个取回工作并进行处理。当所有工作全部完成后,工人线程会等待新的工作到来。
Worker Thread模式也被称为Background Thread(背景线程)模式。另外,如果从“保存多个工人线程的场所”这一点来看,也可以称这种模式为Thread Pool(线程池)模式。
1.7 Thread-Specific Storage
Specific是“特定的”的意思,Storage是储存柜、存储装置的意思。因此,所谓Thread-Specific Storage就是“每个线程特有的存储柜”“为每个线程准备的存储空间”的意思。
ThreadLocal类实现了该模式。
二、多线程编程示例
2.1 交替输出1A2B3C4D5E…
用两个线程,一个输出数字,一个输出字母,最后的结果是交替输出1A2B3C4D5E…
- 实现方式1:synchronized/wait/notify
说到线程的运用,最先想到的可能就是“synchronized/wait/notify”的组合:
- 用synchronized锁住一个对象,达到同一时间只能有一个线程输出的目的;
- 用wait和notify进行线程间的通信,达到交替输出的目的。
上面的思路总体上来说没有问题,不过两个线程同时启动时,获得CPU时间片的次序是不固定的,因此需要用一个变量来控制先输出数字。示例:
public class ThreadTest {
private static volatile boolean t2Started = false;
final Object lock = new Object();
char[] arrI = "1234567".toCharArray();
char[] arrC = "ABCDEFG".toCharArray();
public static void main(String[] args) {
new ThreadTest().testSyncWaitNofiy();
}
public void testSyncWaitNofiy() {
new Thread(() -> {
synchronized (lock) {
while (!t2Started) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (char c : arrC) {
System.out.print(c);
try {
lock.notify();
lock.wait();//让出锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify();//必须,否则无法终止程序
}
}, "t1").start();
new Thread(() -> {
synchronized (lock) {
for (char c : arrI) {
System.out.print(c);
t2Started = true;
try {
lock.notify();
lock.wait();//让出锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify();//必须,否则无法终止程序
}
}, "t2").start();
}
}
以上是经典的交替输出写法,以上代码有两种执行顺序:
1、t1先执行
假如t1先获得CPU时间片,会走入以下逻辑:
接着t1线程被暂停,t2线程获得锁,将t2Started变量置为true,然后打印数字,唤醒t1,t2线程自身被暂停。
这时t1线程获得锁,开始打印字母,然后唤醒t2,自己暂停 ------> t2线程获得锁,开始打印数组,然后唤醒t1,自己暂停…
最后,t2输出数字"7",然后唤醒t1,自己暂停。t1输出字母"G",然后唤醒t2,自己暂停。这时t1线程中第二个notify的作用就体现出来了。假如t1中没有第二个notify,在t1获得锁后,什么也不做,然后释放锁。但是由于没有唤醒其他被暂停的线程,t2将会一直被暂停!
所以即便t1线程的数字数组arrI已经不打印任何字母了,还是需要将暂停的t2线程唤醒,达到两个线程都停止的目的。2、t2先执行
如果t2线程先获得时间片,相当于直接进入打印数字的逻辑,t1中while循环相关的代码不会被执行。- 实现方式2:Condition/await/signal
用Lock和synchronized的逻辑,总体上是一样的,不过是将隐式锁换成了显式锁而已。示例:
public class ThreadTest {
private static volatile boolean t2Started = false;
char[] arrI = "1234567".toCharArray();
char[] arrC = "ABCDEFG".toCharArray();
public static void main(String[] args) {
new ThreadTest().testLockCondition();
}
public void testLockCondition() {
Lock lock = new ReentrantLock();
Condition conditionT1 = lock.newCondition();
Condition conditionT2 = lock.newCondition();
new Thread(() -> {
try {
lock.lock();
while (!t2Started) {
try {
conditionT1.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (char c : arrI) {
System.out.print(c);
conditionT2.signal();
conditionT1.await();
}
conditionT2.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1").start();
new Thread(() -> {
try {
lock.lock();
for (char c : arrC) {
System.out.print(c);
t2Started = true;
conditionT1.signal();
conditionT2.await();
}
conditionT1.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t2").start();
}
}
2.2 生产者–消费者问题
生产者–消费者问题在之前的文章章已经介绍过,这里再写这个例子,是为了可以将这个例子和交替输出字符串的例子放在一起对比看:交替输出字符串的问题要达到交替输出(两个线程按顺序获取锁)的目的,所以在各自的线程中在暂停自身前先唤醒对方;而生产者消费者问题不需要两个线程交替执行,所以没有在暂停自身前唤醒对方的操作
。
在生产者—消费者问题中,生产者的主要职责是生产产品,产品既可以是数据,也可以是任务。消费者的主要职责是消费生产者生产的产品,这里的消费包括对产品所代表的数据进行加工处理或执行产品所代表的任务。
生产者–消费者问题中,实际上主要是包含了两类线程,一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据。
为了解耦生产者和消费者的关系,通常会采用共享数据区的方式,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为;消费者只需要从共享数据区中去获取数据,不再需要关心生产者的行为。这个共享数据区域中应该具备线程间并发协作的功能:
- 如果共享数据区已满的话,阻塞生产者继续放入数据;
- 如果共享数据区为空的话,阻塞消费者继续消费数据。
在实现生产者消费者问题时,可以采用三种方式:
- 使用Object的wait/notify的消息通知机制;
- 使用Condition的await/signal的消息通知机制;
- 使用BlockingQueue实现(BlockingQueue在后续文章详细介绍)。
通常,生产者和消费者的处理能力是不同的,即生产者生产产品的速率和消费者消费产品的速率是不同的,较为常见的情形是生产者的处理能力比消费者的处理能力大。这种情况下,传输通道所起的作用不仅仅作为生产者和消费者之间传递数据的中介,它在一定程度上还起到一个平衡生产者和消费者处理能力的作用。
按照生产者数量和消费者数量的组合来划分,可以分为以下几种:
类别 | 生产者线程数量 | 消费者线程数量 |
---|---|---|
单生产者-单消费者 | 1 | 1 |
单生产者-多消费者 | 1 | N(N>=2) |
多生产者-多消费者 | N(N>=2) | N(N>=2) |
多生产者-单消费者 | N(N>=2) | 1 |
- 实现方式1:synchronized/wait/notify
此种方式指的是:通过配合调用Object对象的wait方法和notify方法或notifyAll方法来实现线程间的通信,简单来说可以分为两个步骤:
- 在线程中调用wait方法,将阻塞当前线程;
- 其他线程调用了调用notify方法或notifyAll方法进行通知之后,当前线程才能从wait方法返回,继续执行下面的操作。
假设有一个箱子,最多只能装10个苹果,箱子里没苹果时不能消费(简单理解为从箱子里往外取苹果),箱子里苹果的数量为10时不能生产(简单理解为向箱子里放苹果)。要实现这一效果,至少有三个点需要关注:
- 要达到
阻塞生产者和消费者
两种效果,需要用不同的条件判断+wait实现
;- 要达到
通知生产者和消费者正常运行下去
的效果,需要用notify/notifyAll实现
(具体用哪种通知方法看需求,notify是随机通知一个,notifyAll是通知所有);- 要
存放共享数据
,需要选择合适的集合类
(该例子比较简单,并没有用集合类,而是用了一个变量来实现)。
示例:
//箱子,存量苹果的容器
public class Box {
//箱子中目前苹果的数量
int num;
public synchronized void put() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//10是箱子中能存放苹果的最大数量
while (num == 10) {
try {
System.out.println("箱子满了,生产者暂停");
//等待消费者消费一个才能继续生产,所以要让出锁
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.println("箱子未装满,开始生产,生产后箱子中的苹果数量:"+num);
//唤醒可能因为没苹果而等待的消费者
this.notify();
}
public synchronized void take() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
while (num == 0) {
try {
System.out.println("箱子空了,消费者暂停");
//等待生产者生产一个才能继续消费,所以要让出锁
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num--;
System.out.println("箱子中有了苹果,开始消费,消费后箱子中的苹果数量:"+num);
//唤醒可能因为苹果满了而等待的生产者
this.notify();
}
}
//消费者
public class Consumer implements Runnable {
private Box box;
public Consumer(Box box) {
this.box= box;
}
@Override
public void run() {
while (true){
box.take();
}
}
}
//生产者
public class Producer implements Runnable {
private Box box;
public Producer(Box box) {
this.box= box;
}
@Override
public void run() {
while (true){
box.put();
}
}
}
public class ConsumerAndProducer {
public static void main(String[] args) {
Box box = new Box();
//生产者线程
Producer p1 = new Producer(box);
//消费者线程
Consumer c1 = new Consumer(box);
new Thread(p1).start();
new Thread(c1).start();
}
}
结果示例:
- 实现方式2:Condition/await/signal
该种方式实现生产者消费者,和之前一种方式原理是一样的,不过是将隐式锁换成了显式锁而已。以上个小节的功能为例,修改Box.java代码即可,示例:
//箱子,存量苹果的容器
public class Box {
//箱子中目前苹果的数量
int num;
Lock lock = new ReentrantLock();
Condition full = lock.newCondition();
Condition empty = lock.newCondition();
public void put() {
lock.lock();
try {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//10是箱子中能存放苹果的最大数量
while (num == 10) {
try {
System.out.println("箱子满了,生产者暂停");
//等待消费者消费一个才能继续生产,所以要让出锁
full.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.println("箱子未装满,开始生产,生产后箱子中的苹果数量:"+num);
//唤醒可能因为没苹果而等待的消费者
empty.signal();
}finally {
lock.unlock();
}
}
public synchronized void take() {
lock.lock();
try {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
while (num == 0) {
try {
System.out.println("箱子空了,消费者暂停");
//等待生产者生产一个才能继续消费,所以要让出锁
empty.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num--;
System.out.println("箱子中有了苹果,开始消费,消费后箱子中的苹果数量:"+num);
//唤醒可能因为苹果满了而等待的生产者
full.signal();
}finally {
lock.unlock();
}
}
}
结果示例:
2.3 手写一个简单的阻塞队列
阻塞队列是一种线程安全的队列,当队列为空时,消费者线程将被阻塞直到队列中有元素可供消费;当队列已满时,生产者线程将被阻塞直到队列有空闲位置可供插入元素。
可以使用 Java 提供的 Lock 和 Condition 接口来实现阻塞队列。示例:
import java.util.LinkedList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BlockingQueue<T> {
private LinkedList<T> queue;
private int capacity;
private Lock lock;
private Condition notFull;
private Condition notEmpty;
public BlockingQueue(int capacity) {
this.capacity = capacity;
queue = new LinkedList<>();
lock = new ReentrantLock();
notFull = lock.newCondition();
notEmpty = lock.newCondition();
}
public void put(T element) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.add(element);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
T element = queue.remove();
notFull.signal();
return element;
} finally {
lock.unlock();
}
}
}
在上述代码中,我们使用了 LinkedList 作为队列的底层数据结构,使用 Lock 和 Condition 接口来实现阻塞队列,其中:
lock:用于同步队列的插入和删除操作;
notFull:用于在队列已满时阻塞生产者线程;
notEmpty:用于在队列为空时阻塞消费者线程。
- 阻塞队列的使用
使用阻塞队列时,我们需要先创建一个阻塞队列对象,指定队列的容量,然后在生产者线程中调用 put 方法向队列中插入元素,在消费者线程中调用 take 方法从队列中获取元素。示例:
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Integer> queue = new BlockingQueue<>(10);
new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
queue.put(i);
System.out.println("Producer put: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
Integer element = queue.take();
System.out.println("Consumer take: " + element);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
在上述代码中,我们创建了一个容量为 10 的阻塞队列,启动了一个生产者线程和一个消费者线程,生产者线程向队列中插入了 20 个元素,消费者线程从队列中取出了 20 个元素。