1. 引言
多线程在运行的时候互不干扰,但当我们需要多个线程之间协作运行时,就涉及到了线程之间的通信。
2. 同步与锁
什么是同步?
多线程情况下,资源的修改应当立即可见。
举个栗子:
设现有两名学生抄答案补作业,其抄作业的速度不一。突然老师公布答案某部分是错的,需要更正答案,其速度快的那位学生可能都已经抄完了。
这也就类似于多线程情况下的资源修改,其每个线程进度不一。如果任意线程操作了共享资源,那么此操作需要立即可见,即同步。否则会出现资源错乱,出现未知的结果。
什么是锁?
锁又称作对象监视器,它是基于对象的,所以又称作为对象锁。
线程与锁是什么关系?
同一时间一个锁只能被一个线程所占有,类似于一夫一妻制。当其它线程需要得到这个锁时,只能等待拥有锁的线程释放锁。
可以理解为:
- 线程同步是线程之间按照一定的顺序执行。
- 线程同步需要锁来实现
未使用锁栗子:
public class Demo {
static class Task implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.printf("%s -> %d %n", Thread.currentThread().getName(), i);
}
}
}
public static void main(String[] args) {
Thread taskA = new Thread(new Task(), "Task-A");
Thread taskB = new Thread(new Task(), "Task-B");
taskA.start();
taskB.start();
}
}
输出:
...
Task-A -> 25
Task-A -> 26
Task-B -> 0
Task-B -> 1
...
可以看到,每个线程互不干扰。可以多运行几次,且每次运行的结果都不一样。
问题引出:
如何让 taskA 线程执行完再去执行 taskB 呢?这里就涉及到了上文所讲的“锁(对象锁)”。
使用锁的栗子:
public class Demo {
private final static Object LOCK = new Object();
static class Task implements Runnable {
@Override
public void run() {
synchronized (LOCK) {
for (int i = 0; i < 100; i++) {
System.out.printf("%s -> %d %n", Thread.currentThread().getName(), i);
}
}
}
}
public static void main(String[] args) {
Thread taskA = new Thread(new Task(), "Task-A");
Thread taskB = new Thread(new Task(), "Task-B");
taskA.start();
taskB.start();
}
}
输出:
Task-A -> 0
Task-A -> 1
Task-A -> 2
Task-A -> 3
...
Task-B -> 1
Task-B -> 2
Task-B -> 3
...
需要注意的是,这里 taskA 与 taskB 都会去抢占锁,并不是谁先调用 start() 方法就有优先权。两个线程都有可能会抢占到锁先行输出,但谁先抢占到锁这是未知的。
当被锁的语句执行完成时就会释放锁,释放出来的锁又会进入到被线程抢占的过程中。
3. 等待与通知
上文所讲的基于“锁”的方式,有个短缺的地方就是线程需要不断地去抢占锁,如果抢占失败,又会再继续尝试。这种抢占行为可能会消耗服务器资源。
而等待与通知是另一种方式,相对而言就没有了不断抢占锁的过程。
Java多线程的等待于通知机制是基于Object的 wait()
,notify()
,notifyAll()
方法实现的。
wait()
:使线程进入WAITING
状态notify()
:随机唤醒一个处于WAITING
状态的线程notifyAll()
:唤醒所有处于WAITING
状态的线程
等待与通知如何使用:
前文讲到,一个锁同一时间只能被一个线程所拥有。
调用 lock.wait()
方法可以使当前拥有锁的线程进入 WAITING
状态并且释放锁。
处于
WAITING
状态的线程不会参与抢占锁,进入等待锁的队伍。
调用 lock.notify()
方法可以随机唤醒一个等待锁的队伍中的线程,而调用 lock.notifyAll()
可以唤醒全部。
调用
lock.notify()
或lock.notifyAll()
会使处于WAITING
状态的线程转换至RUNNABLE
状态。
处于RUNNABLE
状态的线程会参与抢占锁。
使用等待与通知机制的代码:
public class Demo {
private final static Object LOCK = new Object();
private final static String TASK_A = "Task-A";
private final static String TASK_B = "Task-B";
static class Task implements Runnable {
@Override
public void run() {
synchronized (LOCK) {
// 如果抢占到锁的线程是 taskA,则使它进入 WAITING 状态
if (TASK_A.equals(Thread.currentThread().getName())) {
try {
// 使当前线程进入 WAITING 状态
LOCK.wait();
} catch (InterruptedException ignore) {
}
}
for (int i = 0; i < 100; i++) {
System.out.printf("%s -> %d %n", Thread.currentThread().getName(), i);
}
// 随机唤醒一个处于 WAITING 状态的线程
LOCK.notify();
}
}
}
public static void main(String[] args) {
Thread taskA = new Thread(new Task(), TASK_A);
Thread taskB = new Thread(new Task(), TASK_B);
taskA.start();
taskB.start();
}
}
输出:
Task-B -> 0
Task-B -> 1
Task-B -> 2
...
Task-B -> 99
Task-A -> 0
Task-A -> 1
Task-A -> 2
...
Task-A -> 99
在此栗子中利用了等待与通知机制,使 taskB 总是优先于 taskA 执行。
需要注意的是,等待与通知是基于锁之上的一种机制。
但是多个线程使用一个以上的锁,那么它们之间是无法通信的。
4. 信号量
JDK 提供了一些类似于“信号量”功能的 Semaphore
的类。
但本节并不是介绍这个,而是介绍一种基于 volatile 关键字实现的信号量。
volatile
关键字能够保证内存的可见性,如果一个线程更改了volatile
关键字声明的变量,那么其它线程对此是立即可见的。
举个栗子,现有需求如下:
按线程顺序交叉打印数字,如何来实现呢?
代码:
public class Demo {
private final static Object LOCK = new Object();
private static volatile int signal = 0;
private final static String TASK_A = "Task-A";
private final static String TASK_B = "Task-B";
public static void main(String[] args) {
Thread taskA = new Thread(printByCondition(num -> num % 2 == 0), TASK_A);
Thread taskB = new Thread(printByCondition(num -> num % 2 == 1), TASK_B);
taskA.start();
taskB.start();
}
private static Runnable printByCondition(Predicate<Integer> condition) {
return () -> {
while (signal < 5) {
if (condition.test(signal)) {
System.out.printf("%s -> %d %n", Thread.currentThread().getName(), signal);
// 由于 signal ++ 不是一个原子性操作,所以需要上锁
synchronized (LOCK) {
signal ++;
}
}
}
};
}
}
信号量使用场景:
假如在⼀个停⻋场中,⻋位是我们的公共资源,线程就如同⻋辆,⽽看⻔的管理员就是起的“信号量”的作⽤。
因为在这种场景下,多个线程(超过2个)需要相互合作,我们⽤简单的“锁”和“等待通知机制”就不那么⽅便了。这个时候就可以⽤到信号量。
5. 管道
管道就是基于管道流实现的通信。
JDK提供了:
- PipedWriter
- PipedReader
- PipedOutputStream
- PipedInputStream
其中前两个是基于字符流的,后两个是基于字节流的。
这是一个基于字符流来实现多线程之间的通信栗子:
public class Demo {
private final static String TASK_A = "Task-A";
private final static String TASK_B = "Task-B";
static class Writer implements Runnable {
private final PipedWriter writer;
Writer(PipedWriter writer) {
this.writer = writer;
}
@Override
public void run() {
try {
writer.write("Hello World");
} catch (Exception e) {
e.printStackTrace();
}
}
}
static class Reader implements Runnable {
private final PipedReader reader;
Reader(PipedReader reader) {
this.reader = reader;
}
@Override
public void run() {
char[] buffer = new char[1024];
try {
int length = reader.read(buffer);
String content = new String(buffer, 0, length);
System.out.println(content);
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
try (
PipedWriter writer = new PipedWriter();
PipedReader reader = new PipedReader(writer)
) {
Thread taskA = new Thread(new Writer(writer), TASK_A);
Thread taskB = new Thread(new Reader(reader), TASK_B);
taskA.start();
taskB.start();
// 将主线程阻塞至 taskA 与 taskB 执行完成
// 如果不添加此代码,try 语句会调用 writer,reader 的 close() 方法。
// 会导致 reader 在读取的过程中报 Pipe closed
taskA.join();
taskB.join();
} catch (Exception e) {
e.printStackTrace();
}
}
}
使用场景:当需要⼀个线程给另⼀个线程发送信息(⽐如字符串)或者⽂件等等时,就需要使⽤管道通信了。
5. 其它通信
5.1 join()
join() 方法会将当前线程进入等待状态,直至调用 join() 方法的线程执行完成,再继续执行当前线程。
使用场景:
主线程启动子线程时,子线程需要进行大量的数据运算,那么子线程的执行时间远远多于主线程,主线程任务就会早于子线程结束。
如果主线程想等待⼦线程执⾏完毕后,获得⼦线程中的处理完的某个数据,就要⽤到 join() ⽅法了。
join() 栗子:
public class Demo {
public static void main(String[] args) throws Exception {
Runnable task = () -> {
int count = 0;
while (count++ < 10000) {
System.out.printf("%d%n", count);
}
};
Thread thread = new Thread(task);
thread.start();
thread.join();
System.out.println("加 thread.join() 的话,我将会在 thread 线程执行完后再输出。");
}
}
5.2 sleep()
使当前线程睡眠,它不会释放锁。
与 wait() 的区别:
- wait 可以指定时间,也可以不指定;sleep 必须指定时间。
- wait 释放 CPU 资源同时也释放锁;sleep 释放 CPU 资源,但不释放锁。
- wait 必须在同步块中或同步方法中;sleep 可以在任意位置。
5.3 ThreadLocal
ThreadLocal是一个本地线程副本变量工具类。内部是一个弱引用的 Map 来维护。
为当前线程提供本地变量或本地存储,严格意义上来讲这并不算是多线程间的通信,而是让各个线程拥有自己“独立"的变量,各个线程之间互不影响。
ThreadLocal 会为每个线程都创建一个副本,每个线程可以访问自己内部的副本变量。
一个 ThreadLocal 的栗子:
public class Demo {
private final static ThreadLocal<String> TL = new ThreadLocal<>();
public static void main(String[] args) {
Runnable taskA = () -> {
TL.set("Task-A");
System.out.println(TL.get());
};
Runnable taskB = () -> {
TL.set("Task-B");
System.out.println(TL.get());
};
Thread t1 = new Thread(taskA);
Thread t2 = new Thread(taskB);
t1.start();
t2.start();
}
}
ThreadLoacl 常用于解决数据库连接、Session 管理等。数据库连接和 Session 管理涉及多个复杂对象的初始化和关闭。
如果在每个线程中声明⼀些私有变量来进⾏操作,那这个线程就变得不那么“轻量”了,需要频繁的创建和关闭连接。
至此,文章到此就结束了。
博主水平有限,博文有错误的地方可以私信或评论指出。
Bye.