从本节开始,进入并发编程专题,共计5个小节,分别是:
本节重点:
多线程的发展历史
线程的应用
并发编程的基础
线程安全问题
对于线程这个概念,我会先从操作系统讲起。因为操作系统的发展带来了软件层面的变革。 从多线程的发展来看,可以操作系统的发展分为三个历史阶段:
最早的计算机是为了解决数学算数上复杂性而出现的,类似于现在我们常用的计算器。早期的计算机只能接受一些特定的指令,用户输入一个指令,计算机就会执行相应的操作。而当用户输入完这个数据的时候,计算机会处在非工作状态。
最早的计算机只能解决简单的数学运算问题,比如正弦、余弦等。运行方式:程序员首先把程序写到纸上,然后穿孔成卡票,再把卡片盒带入到专门的输入室。输入室会有专门的操作员将卡片的程序输入到计算机上。计算机运行完当前的任务以后,把计算结果从打印机上进行输出,操作员再把打印出来的结果送入到输出室,程序员就可以从输出室取到结果。然后,操作员再继续从已经送入到输入室的卡片盒中读入另一个任务重复上述的步骤。
操作员在机房里面来回调度资源,造成计算机存在大量的空闲状态 。而当时的计算机是非常昂贵的,人们为了减少这种资源的浪费。就采用了 批处理系统 来解决。
批处理操作系统 的运行方式:在输入室收集全部的作业,然后用一台比较便宜的计算机把它们读取到磁带上。然后把磁带输入到计算机,计算机通过读取磁带的指令来进行运算,最后把结果输出磁带上。批处理操作系统的好处在于,计算机会一直处于运算状态,合理的利用了计算机资源。
批处理操作系统虽然能够解决计算机的空闲问题,但是当某一个作业因为等待磁盘或者其他I/O操作而暂停,那CPU就只能阻塞直到该I/O完成,对于CPU操作密集型的程序,I/O操作相对较少,因此浪费的时间也很少。但是对于I/O操作较多的场景来说,CPU的资源是属于严重浪费的。
多道程序设计的出现解决了这个问题,就是把内存分为几个部分,每一个部分放不同的程序。当一个程序需要等待I/O操作完成时。那么CPU可以切换执行内存中的另外一个程序。如果内存中可以同时存放足够多的程序,那CPU的利用率可以接近100%。 在这个时候,引入了第一个概念 进程, 进程的本质是一个正在执行的程序,程序运行时系统会创建一个进程,并且给每个进程分配独立的内存地址空间保证每个进程地址不会相互干扰。同时,在CPU对进程做时间片的切换时,保证进程切换过程中仍然要从进程切换之前运行的位置出开始执行。所以进程通常还会包括程序计数器、堆栈指针。
现代操作系统的硬件架构:
有了进程以后,可以让操作系统从宏观层面实现多应用并发。而并发的实现是通过CPU时间片不端切换执行的。对于单核CPU来说,在任意一个时刻只会有一个进程在被CPU调度
有了进程以后,为什么还会出现线程呢?
在一个应用进程中,会存在多个同时执行的任务,如果其中一个任务被阻塞,将会引起不依赖该任务的任务也被阻塞。举个具体的例子来说,我们平常用word文档编辑内容的时候,都会有一个自动保存的功能,这个功能的作用是,当计算机出现故障的情况下如果用户未保存文档,则能够恢复到上一次自动保存的点。假设word的自动保存因为磁盘问题导致写入较慢,势必会影响到用户的文档编辑功能,直到磁盘写入完成用户才可编辑,这种体验是很差的。如果我们把一个进程中的多个任务通过线程的方式进行隔离,那么按照前面提到的进程演进的理论来说,在单核心CPU架构中可以通过CPU的时间片切换实现线程的调度充分利用CPU资源以达到最大的性能。
我们用了比较长的篇幅介绍了进程、线程发展的历史。总的来说是人们对于计算机的要求越来越高;对于计算机本身的资源的利用率也在不断提高。
线程的优势
前面分析了线程的发展历史,这里简单总结一下线程有的优势如下
线程可以认为是轻量级的进程,所以线程的创建、销毁要比进程更快
从性能上考虑,如果进程中存在大量的I/O处理,通过多线程能够加快应用程序的执行速度(通过CPU时间片的快速切换)。
由于线程是CPU的最小调度单元,所以在多CPU架构中能够实现真正的并行执行。每一个CPU可以调度一个线程
这里有两个概念很多人没有搞明白,就是并行和并发。
并行:同时执行多个任务,在多核心CPU架构中,一个CPU核心运行一个线程,那么4核心CPU,可以同时执行4个线程
并发:同处理多个任务的能力,通常我们会通过TPS或者QPS来表示某某系统支持的并发数是多少。
总的来说,并行是并发的子集。也就是说我们可以写一个拥有多线程并行的程序,如果在没有多核心CPU来执行这些线程,那就不能以并行的方式来运行程序中的多个线程。所以并发程序可以是并行的,也可以不是。Erlang之父Joe Armstrong通过一张图型的方式来解释并发和并行的区别,图片如下
线程出现的目的是什么?解决进程中多任务的实时性问题?其实简单来说,也就是解决“阻塞”的问题,阻塞的意思就是程序运行到某个函数或过程后等待某些事件发生而暂时停止 CPU 占用的情况,也就是说会使得 CPU 闲置。还有一些场景就是比如对于一个函数中的运算逻辑的性能问题,我们可以通过多线程的技术,使得一个函数中的多个逻辑运算通过多线程技术达到一个并行执行,从而提升性能
所以,多线程最终解决的就是“等待”的问题,所以简单总结的使用场景:
在很多场景中,可能会有一些定时的批量任务,比如定时发送短信、定时生成批量文件。在这些场景中可以通过多线程的来执行
比如在用户注册成功以后给用户发送优惠券或者短信,可以通过异步的方式来执行,一方面提升主程序的执行性能;另一方面可以解耦核心功能,防止非核心功能对核心功能造成影响
分布式处理,比如fork/join,将一个任务拆分成多个子任务分别执行
BIO模型中的线程任务分发,也是一种比较常见的使用场景,一个请求对应一个线程
tomcat7 以前的 io 模型
那么我们可以使用多线程,一部分线程在等待 IO 操作返回其他线程可以继续做其他的事。此时从客户端角度来说,客户端没有闲着。
在Java 中,有多种方式来实现多线程。继承 Thread 类、实现 Runnable 接口、使用 ExecutorService、Callable、Future 实现带返回结果的多线程。
Thread 类本质上是实现了 Runnable 接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过 Thread 类的 start()实例方法。start()方法是一个native 方法,它会启动一个新线程,并执行 run()方法。这种方式实现多线程很简单,通过自己的类直接 extend Thread,并复写 run()方法,就可以启动新线程并执行自己定义的 run()方法。
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread.run()");
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyThread myThread1 = new MyThread();
MyThread myThread2 = new MyThread();
myThread1.start();
myThread2.start();
}
}
如果自己的类已经 extends 另一个类,就无法直接 extends Thread,此时,可以实现一个 Runnable 接口
public class MyThread extends OtherClass implements Runnable {
@Override
public void run() {
System.out.println("MyThread.run()");
}
}
有的时候,我们可能需要让一步执行的线程在执行完成以后,提供一个返回值给到当前的主线程,主线程需要依赖这个值进行后续的逻辑处理,那么这个时候,就需要用到带返回值的线程了。Java 中提供了这样的实现方式 :
public class CallableDemo implements Callable<String> {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(1);
CallableDemo callableDemo = new CallableDemo();
Future<String> future = executorService.submit(callableDemo);
System.out.println(future.get());
executorService.shutdown();
}
@Override
public String call() throws Exception {
int a=1;
int b=2;
System.out.println(a+b);
return "执行结果:"+(a+b);
}
}
合理的利用异步操作,可以大大提升程序的处理性能,下面这个案例,如果看过 zookeeper 源码的同学应该都见过通过阻塞队列以及多线程的方式,实现对请求的异步化处理,提升处理性能:。
创建 Request 类:
public class Request {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "Request{" +
"name='" + name + '\'' +
'}';
}
}
接口封装 RequestProcessor:
public interface RequestProcessor {
void processorRequest(Request request);
}
多线程实现类 PrintProcessor:
public class PrintProcessor extends Thread implements RequestProcessor{
LinkedBlockingQueue<Request> linkedBlockingQueue=new LinkedBlockingQueue();
private final RequestProcessor nextProcessor;
public PrintProcessor(RequestProcessor nextProcessor) {
this.nextProcessor = nextProcessor;
}
@Override
public void run() {
while(true){
try {
Request request=linkedBlockingQueue.take();
System.out.println("print data:"+request);
nextProcessor.processorRequest(request);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Override
public void processorRequest(Request request) {
linkedBlockingQueue.add(request);
}
}
多线程实现类 SaveProcessor:
public class SaveProcessor extends Thread implements RequestProcessor{
LinkedBlockingQueue<Request> linkedBlockingQueue=new LinkedBlockingQueue();
@Override
public void run() {
while(true){
try {
Request request=linkedBlockingQueue.take();
System.out.println("save data:"+request);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Override
public void processorRequest(Request request) {
linkedBlockingQueue.add(request);
}
}
启动测试类 Demo :
public class Demo {
PrintProcessor printProcessor;
public Demo() {
SaveProcessor saveProcessor = new SaveProcessor();
saveProcessor.start();
printProcessor = new PrintProcessor(saveProcessor);
printProcessor.start();
}
public static void main(String[] args) {
Request request = new Request();
request.setName("test");
new Demo().doTest(request);
}
public void doTest(Request request){
printProcessor.processorRequest(request);
}
}
线程作为操作系统调度的最小单元,并且能够让多线程同时执行,极大的提高了程序的性能,在多核环境下的优势更加明显。但是在使用多线程的过程中,如果对它的特性和原理不够理解的话,很容易造成各种问题。
Java 线程既然能够创建,那么也势必会被销毁,所以线程是存在生命周期的,那么我们接下来从线程的生命周期开始去了解线程。
线程一共有 6 种状态(NEW、RUNNABLE、BLOCKED、WAITING、TIME_WAITING、TERMINATED)
NEW:初始状态,线程被构建,但是还没有调用 start 方法
RUNNABLED:运行状态,JAVA 线程把操作系统中的就绪和运行两种状态统一称为“运行中”
BLOCKED:阻塞状态,表示线程进入等待状态,也就是线程因为某种原因放弃了 CPU 使用权,阻塞也分为几种情况
等待阻塞:运行的线程执行 wait 方法,jvm 会把当前线程放入到等待队列
同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被其他线程锁占用了,那么 jvm 会把当前的线程放入到锁池中
其他阻塞:运行的线程执行 Thread.sleep 或者 t.join 方法,或者发出了 I/O
请求时,JVM 会把当前线程设置为阻塞状态,当 sleep 结束、join 线程终止、 io 处理完毕则线程恢复
WAITING:等待状态,设置线程进入等待状态等待其他线程做一些特定的动作进行触发。
TIME_WAITING:超时等待状态,超时以后自动返回
TERMINATED:终止状态,表示当前线程执行完毕
这里有一个问题大家可能搞不明白,BLOCKED和WAITING这两个阻塞有什么区别?
BLOCKED状态是指当前线程在等待一个获取锁的操作时的状态。
WAITING是通过Object.wait或者Thread.join、LockSupport.park等操作实现的
BLOCKED是被动的标记,而WAITING是主动操作
如果说得再深入一点,处于WAITING状态的线程,被唤醒以后,需要进入同步队列去竞争锁操作,而在同步队列中,如果已经有其他线程持有锁,则线程会处于BLOCKED状态。所以可以说BLOCKED状态是处于WAITING状态的线程重新唤醒的必经的状态
接下来,我们写一个demo用以测试线程的状态:
public class ThreadStatusDemo {
public static void main(String[] args) {
new Thread(()->{
while(true){
try {
TimeUnit.SECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"timewaiting").start();
new Thread(()->{
while(true){
synchronized (ThreadStatusDemo.class){
try {
ThreadStatusDemo.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
},"waiting").start();
new Thread(new BlockDemo(),"BlockDemo-0").start();
new Thread(new BlockDemo(),"BlockDemo-1").start();
}
static class BlockDemo extends Thread{
public void run(){
synchronized (BlockDemo.class){
while(true){
try {
TimeUnit.SECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
运行主函数
线程的启动过程大家都非常熟悉,但是如何终止一个线程,我相信绝大部分人在面试的时候被问到这个问题时,也会不知所措,不知道怎么回答。
记住,线程的终止,并不是简单的调用 stop 命令去。虽然 api 仍然可以调用,但是和其他的线程控制方法如 suspend、resume 一样都是过期了的不建议使用,就拿 stop 来说,stop 方法在结束一个线程时并不会保证线程的资源正常
释放,因此会导致程序可能出现一些不确定的状态。
要优雅的去中断一个线程,在线程中提供了一个 interrupt 方法
当其他线程通过调用当前线程的 interrupt 方法,表示向当前线程打个招呼,告诉他可以中断线程的执行了,至于什么时候中断,取决于当前线程自己。线程通过检查资深是否被中断来进行相应,可以通过 isInterrupted()来判断是否被中断。通过下面这个例子,来实现了线程终止的逻辑:
public class InterruptDemo {
private static int i;
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
while(!Thread.currentThread().isInterrupted()){
i++;
}
System.out.println(i);
},"interruptDemo");
thread.start();
TimeUnit.SECONDS.sleep(1);
thread.interrupt();
}
}
这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程停止,因此这种终止线程的做法显得更加安全和优雅。
Thread.interrupted
上面的案例中,通过 interrupt,设置了一个标识告诉线程可以终止了,线程中还提供了静态方法 Thread.interrupted()对设置中断标识的线程复位。比如在上面的案例中,外面的线程调用 thread.interrupt 来设置中断标识,而在线程里面,又通过 Thread.interrupted 把线程的标识又进行了复位:
public class InterruptDemoPro {
public static void main(String[] args) throws InterruptedException{
Thread thread = new Thread(()->{
while(true){
boolean ii=Thread.currentThread().isInterrupted(); if(ii){
System.out.println("before:"+ii);
Thread.interrupted();//对线程进行复位,中断标识为false
System.out.println("after:"+Thread.currentThread().isInterrupted());
}
}
});
thread.start();
TimeUnit.SECONDS.sleep(1);
thread.interrupt();//设置中断标识,中断标识为 true
}
}
运行结果:
其他的线程复位
除了通过 Thread.interrupted 方法对线程中断标识进行复位以外,还有一种被动复位的场景,就是对抛出 InterruptedException 异常的方法,在 InterruptedException 抛出之前,JVM 会先把线程的中断标识位清除,然后才会抛出 InterruptedException,这个时候如果调用 isInterrupted 方法,将会返回 false:
public class ThreadInterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
while(true){
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
TimeUnit.SECONDS.sleep(1);
thread.interrupt();
System.out.println("before:"+thread.isInterrupted());
TimeUnit.SECONDS.sleep(1);
System.out.println("after:"+thread.isInterrupted());
}
}
运行结果:
有同学在问线程为什么要复位?首先我们来看看线程执行 interrupt 以后的源码是做了什么?
thread.cpp
void Thread::interrupt(Thread* thread) {
trace("interrupt", thread);
debug_only(check_for_dangling_thread_pointer(thread);)
os::interrupt(thread);
}
os_linux.cpp
void os::interrupt(Thread* thread) {
assert(Thread::current() == thread ||Threads_lock->owned_by_self(), "possibility of dangling Thread pointer");
OSThread* osthread = thread->osthread();
if (!osthread->interrupted()) {
osthread->set_interrupted(true);
//More than one thread can get here with the same value of osthread,
//resulting in multiple notifications. We do, however, want the store
//to interrupted() to be visible to other threads before we execute unpark().
OrderAccess::fence();
ParkEvent * const slp = thread->_SleepEvent ;
if (slp != NULL) slp->unpark() ;
}
//For JSR166. Unpark even if interrupt status already was set
if (thread->is_Java_thread())
((JavaThread*)thread)->parker()->unpark();
ParkEvent * ev = thread->_ParkEvent ;
if (ev != NULL) ev->unpark() ;
}
其实就是通过 unpark 去唤醒当前线程,并且设置一个标识位为 true。 并没有所谓的中断线程的操作,所以实际上,线程复位可以用来实现多个线程之间的通信。
除了通过 interrupt 标识为去中断线程以外,我们还可以通过下面这种方式,定义一个 volatile 修饰的成员变量,来控制线程的终止。这实际上是应用了 volatile 能够实现多线程之间共享变量的可见性这一特点来实现的。
public class VolatileDemo {
private volatile static boolean stop=false;
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
int i = 0;
while(!stop){
i++;
}
});
thread.start();
System.out.println("begin start thread");
Thread.sleep(1000);
stop=true;
}
}
大家都知道,线程会存在安全性问题,那接下来我们从原理层面去了解线程为什么会存在安全性问题,并且我们应该怎么去解决这类的问题。
其实线程安全问题可以总结为: 可见性、原子性、有序性这几个问题,我们搞懂了这几个问题并且知道怎么解决,那么多线程安全性问题也就不是问题了
线程是 CPU 调度的最小单元,线程涉及的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。
而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
高速缓存从下到上越接近 CPU 速度越快,同时容量也越小。现在大部分的处理器都有二级或者三级缓存,从下到上依次为 L3 cache, L2 cache, L1 cache. 缓存又可以分为指令缓存和数据缓存,指令缓存用来缓存程序的代码,数据缓存用来缓存程序的数据
L1 Cache,一级缓存,本地 core 的缓存,分成 32K 的数据缓存 L1d 和 32k 指令缓存 L1i,访问 L1 需要 3cycles,耗时大约 1ns;
L2 Cache,二级缓存,本地 core 的缓存,被设计为 L1 缓存与共享的 L3 缓存之间的缓冲,大小为 256K,访问 L2 需要 12cycles,耗时大约 3ns;
L3 Cache,三级缓存,在同插槽的所有 core 共享 L3 缓存,分为多个 2M 的段,访问 L3 需要 38cycles,耗时大约 12ns;
CPU-0 读取主存的数据,缓存到 CPU-0 的高速缓存中,CPU-1 也做了同样的事情,而 CPU-1 把 count 的值修改成了 2,并且同步到 CPU-1 的高速缓存,但是这个修改以后的值并没有写入到主存中,CPU-0 访问该字节,由于缓存没有更新,所以仍然是之前的值,就会导致数据不一致的问题。
引发这个问题的原因是因为多核心 CPU 情况下存在指令并行执行,而各个 CPU 核心之间的数据不共享从而导致缓存一致性问题,为了解决这个问题, CPU 生产厂商提供了相应的解决方案。
总线锁
当一个 CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信号。其他处理器的请求将会被阻塞,那么该处理器可以独占共享内存。总线锁相当于把 CPU 和内存之间的通信锁住了,所以这种方式会导致 CPU 的性能下降,所以 P6 系列以后的处理器,出现了另外一种方式,就是缓存锁。
缓存锁
如果缓存在处理器缓存行中的内存区域在 LOCK 操作期间被锁定,当它执行锁操作回写内存时,处理不在总线上声明 LOCK 信号,而是修改内部的缓存地址,然后通过缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据,当其他处理器回写已经被锁定的缓存行的数据时会导致该缓存行无效。
所以如果声明了 CPU 的锁机制,会生成一个 LOCK 指令,会产生两个作用:
缓存一致性协议
处理器上有一套完整的协议,来保证 Cache 的一致性,比较经典的应该就是 MESI 协议了,它的方法是在 CPU 缓存中保存一个标记位,这个标记为有四种状态:
M(Modified) 修改缓存,当前 CPU 缓存已经被修改,表示已经和内存中的数据不一致了
I(Invalid) 失效缓存,说明 CPU 的缓存已经不能使用了
E(Exclusive) 独占缓存,当前 cpu 的缓存和内存中数据保持一直,而且其他处理器没有缓存该数据
S(Shared) 共享缓存,数据和内存中数据一致,并且该数据存在多个 cpu缓存中
每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读写操作,嗅探(snooping)"协议.。
CPU 的读取会遵循几个原则
如果缓存的状态是 I,那么就从内存中读取,否则直接从缓存读取
如果缓存处于 M 或者 E 的 CPU 嗅探到其他 CPU 有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为 S
只有缓存状态是 M 或 E 的时候,CPU 才可以修改缓存中的数据,修改后,缓存状态变为 MC
这里给出一个demo:
public class AtomicDemo {
private static int count=0;
public static void inc(){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
for(int i=0;i<1000;i++){
new Thread(AtomicDemo::inc).start();
}
Thread.sleep(4000);
System.out.println("运行结果:"+count);
}
}
CPU 的优化执行
除了增加高速缓存以为,为了更充分利用处理器内内部的运算单元,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果充足,保证该结果与顺序执行的结果一直,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,这个是处理器的优化执行;还有一个就是编程语言的编译器也会有类似的优化,比如做指令重排来提升性能。
前面说的和硬件有关的概念你可能听得有点蒙,还不知道他到底和软件有啥关系,其实原子性、可见性、有序性问题,是我们抽象出来的概念,他们的核心本质就是刚刚提到的缓存一致性问题、处理器优化问题导致的指令重排序问题。
比如缓存一致性就导致可见性问题、处理器的乱序执行会导致原子性问题、指令重排会导致有序性问题。为了解决这些问题,所以在 JVM 中引入了 JMM 的概念。
本节演示代码地址:
https://github.com/harrypottry/ThreadDemo
更多架构知识,欢迎关注本套系列文章:Java架构师成长之路