活动地址:CSDN21天学习挑战赛
6. 多线程
6.1 简介
现代计算机绝大部分都有多核处理器,使得程序可以并发的执行多个任务。在Java
中,使用多线程编程,可以使得程序运行速度更快。
6.2 进程与线程
-
Process
:进程就是应用程序的一个实例。进程中包含了程序中所需要的所有数据以及相关其它资源,并且占用一定内存空间。操作系统在同一时间可以运行多个进程。(进程级别的并发) -
Thread
:线程,从技术上来讲,就是由多个指令或操作形成的序列(线程名称的由来),或者说线程就是执行我们所写代码的“主体”。一个进程至少由一个主线程(main thread
)组成,同时,我们还可以创建更多的线程执行其它的任务。(线程级别的并发)
// 获取当前活跃线程数量
System.out.println(Thread.activeCount());
// 获取当前处理器数量
System.out.println(Runtime.getRuntime().availableProcessors());
6.3 线程基本操作
6.3.1 创建与启动线程
public class DownloadFileTask implements Runnable {
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
}
}
public class ThreadDemo {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
for (int i = 0; i < 3; i++) {
Thread t0 = new Thread(new DownloadFileTask());
t0.start();
}
}
}
6.3.2 暂停线程
public class DownloadFileTask implements Runnable {
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Download complete:" + Thread.currentThread().getName());
}
}
-
sleep()
:当前线程进入“睡眠”,它将会挂起(暂停)当前线程,同时使得其它线程有机会使用CPU资源(processor
) -
睡眠时间只是“约等于” 5 秒,具体取决于当前操作系统。
如果创建的线程数量大于CPU的线程数,情况会如何呢?
在我们的操作系统中,有一个专门的线程调度器
,它负责给每个线程分配CPU时间片
,并且在每个线程间进行快速切换,使得每个线程有机会使用processor
来进行运算,这就造就了“并发”的效果。
6.3.3 join
等待加入
public class ThreadDemo {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
Thread t0 = new Thread(new DownloadFileTask());
t0.start();
try {
t0.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("下载完毕,可以开始扫描病毒");
}
}
join()
将会使得当前线程(main thread
)进入等待状态,直到t0
线程执行完毕,才能继续往下执行剩余逻辑,在此期间,main thread
什么都不能做。
6.3.4 interrupt
中断线程
public class DownloadFileTask implements Runnable {
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
for (int i = 0; i < Integer.MAX_VALUE; i++) {
if (Thread.currentThread().isInterrupted()) return;
System.out.println("Downloading - " + i);
}
System.out.println("Download complete:" + Thread.currentThread().getName());
}
}
public class ThreadDemo {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
Thread t0 = new Thread(new DownloadFileTask());
t0.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t0.interrupt();
}
}
-
interrupt()
并不会强制中断线程,它只是发出一个中断请求,由被中断线程来决定是否应该中断。 -
被中断线程需要持续检查是否收到中断请求,编写相应逻辑进行中断。
6.4 并发带来的问题
在此前演示的案例中,每个线程都是单独下载自己的文件,这并不会带来任何负面的影响,但是,如果多个线程共同下载同一个文件,便会引发如下问题:
-
Race Condition
:竞争状况。多个线程比赛和竞争,同时想修改同一份数据。 -
Visibility Problem
:可见性一个线程修改了共享数据,但是其修改对其它线程并不可见,导致多个线程针对同一份数据,看到的数据视图是不一样的。
所以,多个线程访问同一个数据,但其中有一个线程想要修改数据,那么以上问题就会出现;如果所有线程只是读取数据,那么这些问题就不存在。
在多个线程访问同一个数据并且想要进行修改时,为了预防以上问题发生,我们就需要编写线程安全的代码(Thread safe - Code
)来解决并发问题。
在
JDK
中,有些类或方法解决了以上并发带来的问题,我们就称之为线程安全的类
或线程安全的方法
6.5 竞争状况
public class DownloadStatus {
private int totalBytes;
public void incrementTotalBytes(){
totalBytes++;
}
public int getTotalBytes() {
return totalBytes;
}
}
public class DownloadFileTask implements Runnable {
private DownloadStatus status;
public DownloadFileTask(DownloadStatus status) {
this.status = status;
}
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
for (int i = 0; i < 10_000; i++) {
if (Thread.currentThread().isInterrupted()) return;
status.incrementTotalBytes();
}
System.out.println("Download complete:" + Thread.currentThread().getName());
}
}
public class ThreadDemo {
public static void main(String[] args) {
var status = new DownloadStatus();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
var thread = new Thread(new DownloadFileTask(status));
threads.add(thread);
thread.start();
}
for (var thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(status.getTotalBytes());
}
}
运行该程序以后,我们会观察到最终结果并非是 10 个线程下载的总和,主要原因在于:
public void incrementTotalBytes(){
totalBytes++;
}
在这个方法中,修改状态的代码虽然只有一行,但实际上包含了 3 步操作:
-
首先从主存储器(
main memory
)把这个状态的值拷贝到CPU
中 -
CPU
对这个值进行add
操作 -
CPU
将这个更新后的值写回到主存储器(main memory
)
整个方法的逻辑由于包含了 3 步操作,因此我们称之为非原子操作
(non - atomic operation
,不能再进一步细分的操作),假定发生如下场景,那么就会导致对要修改的数据,其中某一个线程的修改操作无效(被另一个线程的修改操作覆盖):
解决方案:
-
将被修改的数据根据访问线程的数量,切割为对应的份数分配给对应的线程,在所有线程操作完毕后再进行合并。
-
待访问的数据使用不可改变(
immutable
)的对象,例如字符串就是不可变对象(当我们把字符串修改为大写时,实际上将会创建一个新的字符串对象) -
将线程同步化(
synchronization
):使用锁(Lock
)防止多个线程在同一时间访问同一个数据我们可以给某一部分代码加上
lock
,让这份代码在同一时间只能被一个线程执行,在执行期间,其它线程只能等待前一个线程执行完毕,才能继续访问这块代码。这最终就形成了所有线程对同一个数据的多个操作只能依次有序进行。(线程同步非常容易导致死锁,dead lock
) -
使用原子对象(
atomic object
):原子对象所有的方法或操作都是不可再细分的,这本身就意味着,不可能有两个以上的线程同时修改一份数据。 -
分区技术:允许多个线程访问同一个数据的不同区域或片段
6.6 切割与合并数据
public class DownloadFileTask implements Runnable {
private DownloadStatus status;
public DownloadFileTask() {
this.status = new DownloadStatus();
}
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
for (int i = 0; i < 10_000; i++) {
if (Thread.currentThread().isInterrupted()) return;
status.incrementTotalBytes();
}
System.out.println("Download complete:" + Thread.currentThread().getName());
}
public DownloadStatus getStatus() {
return status;
}
}
public class ThreadDemo {
public static void main(String[] args) {
var status = new DownloadStatus();
List<Thread> threads = new ArrayList<>();
List<DownloadFileTask> tasks = new ArrayList<>();
for (int i = 0; i < 10; i++) {
var task = new DownloadFileTask();
tasks.add(task);
var thread = new Thread(task);
threads.add(thread);
thread.start();
}
for (var thread : threads)
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
var totalBytes = tasks.stream()
.map(task -> task.getStatus().getTotalBytes())
.reduce(Integer::sum);
System.out.println(totalBytes);
}
}
6.7 Lock
public class DownloadStatus {
private int totalBytes;
private Lock lock = new ReentrantLock();
public void incrementTotalBytes(){
lock.lock();
try {
totalBytes++;
} finally {
lock.unlock();
}
}
public int getTotalBytes() {
return totalBytes;
}
}
我们还可以使用synchronized关键字:
public class DownloadStatus {
private int totalBytes;
public synchronized void incrementTotalBytes() {
totalBytes++;
}
public int getTotalBytes() {
return totalBytes;
}
}
public class DownloadStatus {
private int totalBytes;
private final Object totalBytesLock = new Object();
public void incrementTotalBytes() {
synchronized (totalBytesLock) {
totalBytes++;
}
}
public int getTotalBytes() {
return totalBytes;
}
}
6.8 volatile关键字
public class DownloadStatus {
private int totalBytes;
private boolean isDone;
public synchronized void incrementTotalBytes() {
totalBytes++;
}
public int getTotalBytes() {
return totalBytes;
}
public boolean isDone() {
return isDone;
}
public void setDone(boolean done) {
isDone = done;
}
}
public class DownloadFileTask implements Runnable {
private DownloadStatus status;
public DownloadFileTask(DownloadStatus status) {
this.status = status;
}
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
for (int i = 0; i < 1_000_000; i++) {
if (Thread.currentThread().isInterrupted()) return;
status.incrementTotalBytes();
}
status.setDone(true);
System.out.println("Download complete:" + Thread.currentThread().getName());
}
}
public class ThreadDemo {
public static void main(String[] args) {
var status = new DownloadStatus();
var t1 = new Thread(new DownloadFileTask(status));
t1.start();
var t2 = new Thread(() -> {
while (!status.isDone()) {
}
System.out.println(status.getTotalBytes());
});
t2.start();
}
}
在这个案例中,我们会发现程序一直运行不能停止,且t2
线程无法输出最终结果,主要原因在于:
-
在计算机的
CPU
运行过程中,存在一套缓存机制:为了优化程序执行速度,会首先把主存储器(main memory
)中的数据拷贝到自己的缓存(cache
)中,然后再继续运算。由于直接从自己的cache
中读取数据的速度更快,因此减少了不断传值带来的时间损耗。 -
t1
线程和t2
线程首先都把这个值拷贝到自己的缓存中,然后t1
线程对这个值做出了修改,由于缓存中的数据只有自己可以访问,因此t2
线程并看不到这个修改,甚至当t1
线程将这个修改写回到主存储器,这种修改对于t2
仍然是不可见的。
这种场景,就称为多线程并发的可见性问题:
解决方法:
public class DownloadStatus {
private int totalBytes;
private volatile boolean isDone;
public synchronized void incrementTotalBytes() {
totalBytes++;
}
public int getTotalBytes() {
return totalBytes;
}
public boolean isDone() {
return isDone;
}
public void setDone(boolean done) {
isDone = done;
}
}
当isDone
被volatile
修饰时,意味着告诉JVM
这个变量是不稳定的,不要在计算的时候依赖存储在缓存中的值,而总是从主存储器中读取它,同时,当一个线程修改isDone
时,会立刻更新存储在主存储器中的值。
6.9 线程间通信
上述案例中,t2
线程通过一个while
循环不断检测状态值的变化,这个操作有可能会循环很多次,从而浪费CPU的运算时间,我们可以进一步调整为:
public class ThreadDemo {
public static void main(String[] args) {
var status = new DownloadStatus();
var t1 = new Thread(new DownloadFileTask(status));
t1.start();
var t2 = new Thread(() -> {
while (!status.isDone()) {
synchronized (status) {
try {
status.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
System.out.println(status.getTotalBytes());
});
t2.start();
}
}
public class DownloadFileTask implements Runnable {
private DownloadStatus status;
public DownloadFileTask(DownloadStatus status) {
this.status = status;
}
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
for (int i = 0; i < 10_000; i++) {
if (Thread.currentThread().isInterrupted()) return;
status.incrementTotalBytes();
}
status.setDone(true);
synchronized (status) {
status.notifyAll();
}
System.out.println("Download complete:" + Thread.currentThread().getName());
}
}
-
wait()
:将会导致该线程一直处于等待状态,直到其它线程唤醒它。 -
notifyAll()
:唤醒等待在该对象上的其它线程 -
JVM
要求调用wait()
和notifyAll()
的时候,对代码进行同步化。
6.10 原子对象
为了更有效的进行线程间通信,同时解决多线程并发带来的问题,最有效的方式是使用原子对象:
public class DownloadStatus {
private AtomicInteger totalBytes = new AtomicInteger();
public void incrementTotalBytes() {
totalBytes.getAndIncrement();
}
public int getTotalBytes() {
return totalBytes.get();
}
}
public class DownloadFileTask implements Runnable {
private DownloadStatus status;
public DownloadFileTask(DownloadStatus status) {
this.status = status;
}
@Override
public void run() {
System.out.println("Download:" + Thread.currentThread().getName());
for (int i = 0; i < 10_000; i++) {
if (Thread.currentThread().isInterrupted()) return;
status.incrementTotalBytes();
}
System.out.println("Download complete:" + Thread.currentThread().getName());
}
}
public class ThreadDemo {
public static void main(String[] args) {
var status = new DownloadStatus();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10; i++) {
var thread = new Thread(new DownloadFileTask(status));
threads.add(thread);
thread.start();
}
for (var thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(status.getTotalBytes());
}
}
6.11 同步集合
public class ThreadDemo {
public static void main(String[] args) {
Collection<Integer> collection = Collections.synchronizedList(new ArrayList<>());
var t1 = new Thread(()-> collection.addAll(Arrays.asList(1, 2, 3)));
var t2 = new Thread(()-> collection.addAll(Arrays.asList(4, 5, 6)));
t1.start();
t2.start();
}
}
6.12 并发集合
同步集合,会使用synchronized
机制对代码执行锁,但因此也带来了一些负面影响,即其它线程需要等待前面的线程执行完毕才能继续访问。
而JDK
中,提供了并发集合来提供更高效的访问方式,即允许多个线程采用分区的方式访问同一个数据的不同区域或片段:
Map<String, Integer> map = new ConcurrentHashMap<>();