别后不知君远近。触目凄凉多少闷。渐行渐远渐无书,水阔鱼沉何处问。
夜深风竹敲秋韵。万叶千声皆是恨。故欹单枕梦中寻,梦又不成灯又烬。《玉楼春·别后不知君远近》 欧阳修
最近挺喜欢的一首诗。大学里面学过的《操作系统》和《计算机组成原理》、JVM,在多线程这一点无法形成一个整体,就只是简单停留在会用,大概理解这个阶段,我是很不喜欢这种感觉,于是就打算重写学习一下线程,让自己的知识点成体系。
该何看待线程?
该如何看待线程呢? 我们还是需要先看进程,我们来回顾一下操作系统的历史,在很久之前操作系统只能支持跑一个程序,也就是说你不能在听歌的时候,看文档。那个时候还没有进程这个概念,很快随着科学技术的发展,我们可以在内存中加载更多的程序,这个时候再用程序这个概念去涵盖运行中的程序就有点不合适了,因为有可能存在一个程序跑多份,因此我们需要一个概念来描述运行中的程序,也就是进程。
在没有线程之前,进程是操作系统调度的基本单位,进程是一个具有一定的独立功能的程序在一个数据集合上的一次动态执行过程, 涵盖了程序执行所需要的的资源和执行流程。这么说可能有点抽象,举一个例子,我写了一个求两个数中最大值的程序,接收两个数,然后输出最大值,这个就是执行流程。在没有线程之前,一个进程中只有一个执行流程。
我们这里从两个方面来理解进程:
- 从资源组合的角度: 进程把一组相关的资源组合起来,构成了一个资源平台(环境),包括地址空间(代码段、数据段)、打开的文件等各种资源、
- 从运行的角度: 代码在这个资源平台上的一条执行流程(线程)。
总结一下:
线程是进程内部的一条执行流程(开销比较小),再通俗一点就是干活的最小单元。
线程也不是只是完全是执行流程,也需要一定的消耗,也需要有自己独享的资源,Java平台开启一个线程大致需要消耗1M的内存。除此之外还有操作系统方面的开销,也就是我们说的上下文切换。我们知道现代计算机上没有线程能够独占CPU,线程占用CPU的时间,我们称之为时间片,每个线程所能分到时间片是非常小的,那么随之而来的问题就是线程中的执行流程执行了一半,时间片耗尽了,CPU去执行另一个线程了,那么再轮到这个线程时,我们肯定是不希望再次重头执行的,这个时候系统是保留了线程的执行进度的,我们可以理解为存档,再执行到这个线程的时候,就会读档,这个读档的过程我们一般称之为上下文切换。老是说我大学上操作系统课程的时候,当时这个代码量还比较少,我还觉得这个概念比较难以理解,后来写的代码多了,就突然理解了,所以说程序是理解计算机的桥梁啊。
从零开始学习Java平台的线程
java平台上的代码与线程
Java中任何一段代码总是执行某个线程之中,执行当前代码的线程就被称为当前线程,这和我们上文讨论的是一致的,即线程是进程内部的一条执行流程,干活的最小单元。Thread.currentThread()可以返回当前线程,Java程序员非常熟悉的main方法就是被main线程来执行。
public class ThreadDemo {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
}
}
写到这里可能有同学会问呢,上面你不是说,线程是进程内部的一条执行流程嘛,那这么多线程都属于哪一个进程啊,当你启动main方法的时候事实上是启动了一个虚拟机进程。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
// 让main线程睡10秒,不然main方法执行完毕,JVM进程也结束了
// 这段代码是当前代码的执行线程沉睡10秒,main方法被main线程所执行
// 趁他沉睡,我们用任务管理器去查看后台的进程
TimeUnit.SECONDS.sleep(10);
}
}
有同学可能会问,你不还没启动啊,为什么就会有两个JVM进程了啊,我使用IDEA是一个java语言编写的,他启动当然是一个JVM进程,通过描述我们可以看到IDEA使用的是Open JDK,另一个是被其他服务所使用,像maven。
如果你不信的话,我们上图
我们启动了ThreadDemo之后,就会多出一个java.exe,
如何创建一个线程
Java平台下我们该如何创建一个线程呢,或者说main方法中此时有一个执行单元(就是一个方法)比较耗时,我们希望将这个比较耗时的方法放入一个线程和main线程交替执行。一般来说Java平台下创建线程有两种方式:
- 创建一个类,继承Thread类,重写Thread类的run方法。
- 创建一个类,实现Runnable接口,重写接口中的run方法。
public class ThreadDemo01 extends Thread{
// 我们说的最小执行单元
@Override
public void run() {
System.out.println("继承方式创建的线程");
System.out.println("继承方式创建的线程"+"我是比较耗时的操作..... ");
}
}
public class ThreadDemo02 implements Runnable {
@Override
public void run() {
System.out.println("接口方式创建的线程");
System.out.println("接口方式创建的线程"+"我是比较耗时的操作.....");
}
}
我的确创建了两个线程,此时这两个线程出于新建状态,那我们该如何启动这两个线程呢? 还是通过Thread类,假设是继承方式创建的线程,我们直接在对应的代码,调用start方法即可。如果是接口方式创建的线程,那么就需要将这个执行单元当做参数传递给Thread的类,像下面这样:
public static void main(String[] args) throws Exception {
ThreadDemo01 thread01 = new ThreadDemo01();
thread01.start();
ThreadDemo02 threadDemo02 = new ThreadDemo02();
Thread thread02 = new Thread(threadDemo02);
thread02.start();
}
通过start()方法, 我们启动了这个线程,但是启动就未必代表这个线程可以马上被执行,这取决线程调度器的调度,由操作系统所决定。由此我们引出线程的生命周期状态,事实上我们上文已经暗示过了,执行流程的开始到结束。
线程的状态,都在图里了,一图胜千言:
上面已经出现了一些线程类常用的API,也许你还不知道用处是什么,不用担心,正是下面要介绍的。
Thread类常用的API
演示:
ThreadDemo01 thread01 = new ThreadDemo01();
thread01.start();
// 礼让
Thread.yield();
// 主线程沉睡10秒
TimeUnit.SECONDS.sleep(10);
// 礼让
//这是主线程调用线程thread01的join方法,那thread01运行完毕,主线程的代码才会继续执行。
thread01.join();
线程两种创建方式的区别
从面向对象编程的角度来看: 创建Thread的子类是一种基于继承的技术,以Runnable接口为实例为构造器参数直接通过new创建Thread实例是一种基于组合的技术。我记得在大学的《软件工程导论》课程中好像讲过慎用继承,继承破坏封装来着,所以一般我们推荐用过Runnable方式来创建线程,更为书面化的描述是组合相对于继承来说,其类与类之间的耦合性更低,因此它也更加灵活。一般我们我们认为组合是优先选用的技术,也就是我们常说的面向接口编程。
通俗的讲你用继承的方式创建线程,那这个类已经基本和线程绑定了,不好复用。
用Runnable方式创建线程,从对象共享的角度来说,多个线程就可以同时执行这一个执行单元,而用第二种,假设你想多个线程多做这一件事,那你就得建多个类,这是很直接的好处。
Java平台下形形色色的线程
你可能已经听过一些关于线程的名词了,父线程、子线程、垃圾回收线程等等,这里我们将对这些名词进行统一的解释,以方便后文的讨论,
按照线程是否阻止Java虚拟机正常停止),我们可以将Java中的线程(Daemon Thread)和用户线程(User Thread, 也称为非守护线程)。我们讨论的简单些,JVM只有在其所有的用户线程都运行结束才能正常停止, 即用户线程不执行完,JVM不停止(我们讨论的是比较简单的),JVM的垃圾回收线程就是一个守护线程,我们这样想假设你写了一个简单的算法,没开线程,但是跑完了,垃圾回收线程还在跑,这不是很奇怪吗?
Java 平台中的线程不是孤立的,线程与线程之间重视存在一些联系。假设线程所执行的代码创建了线程B,那么习惯上我们称线程B为线程A的子线程,相应的我们称线程A为线程B的父线程。
线程安全
计算机存储系统
我们对线程的探讨也要落到硬件上,既要理论也要联系实际。
可能一些人的眼里,程序的执行是这样的,程序加载进内存,CPU读取内存的指令执行,这是一个相当粗糙的模型,虽然内存的读写速度已经很快了,可是相对于CPU来说还是不够看,如果CPU直接从内存中加载指令并执行,那么计算机系统的效率相对于现在来说会慢上几个量级,计算机的存储系统采取了更聪明的设计,即在处理器和较大较慢的存储设备(比如内存(有资料称为主存,这两个是同义语))。实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构,如下图所示。
在这个层次结构中,从上至下,设备的访问速度越来越慢,容量越来越大,并且每字节的造价越来越便宜。存储器的层次结构的主要思想就是上一层的存储器作为低一层存储器的高速缓存。
缓存不一致、处理器优化、指令重排简介
一般来说现代CPU都是多核的,你很难找到单核的CPU,我们研究的基本单位也是多核CPU,CPU一直在计算工作,我们上面将线程视作一个执行单元,这个执行单元当被线程器调度器选中的时候,系统会将该执行单元所需的资源从内存逐步加载到CPU的寄存器中,一般来说典型的流程是根据存储结构,CPU会先从寄存器找,找不到会从L1缓存,L1找不到从L2缓存....。
计算结果最终会被写入到L3缓存,再由L3缓存移入内存中。假设是两个线程呢,共享一个变量,这就可能会出现缓存不一致的情况,因为可能第一个线程在完成计算之后,还来得及将计算结果刷新到主存,另一个线程被分配到了另一个核心上执行,读取的还是还未更新的变量,这就是缓存不一致问题。
有同学可能会问单核CPU没有缓存不一致问题,那是不是在单核CPU上,多线程就是没问题的啊? 单核CPU的确不会出现缓存不一致的问题,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但是回想一下我们上面讨论的上下文切换,线程A执行一半后,时间片耗尽,线程内部的寄存器会保留现场,也就是我们上文说的存档,结果还没从缓存中刷新到主存,只是暂存于线程内部。然后B线程开始执行,CPU中依次从缓存中加载共享变量,我们姑且假定线程A的计算结果还没刷新至缓存,然后线程B接着计算,依然会存在问题。
上面提到的在CPU和主存之间增加缓存,在多核多线程的场景下会存在缓存一致性问题。除此之外为了加速程序的执行,一般的高级语言的编译器还会对程序对应的指令进行重排,编译器重排之后,CPU为了加速执行也不一定会按编译器重排后的指令按序执行,也就是乱序执行。乱序执行和乱序执行会牵扯到操作系统和硬件执行的知识,不是本篇讨论的重点,这两个点我们目前只做简单介绍,后面会结合例子或者专门开一篇文章来讲。
缓存不一致、乱序执行、指令重排序各位可能会感到有些陌生,但是如果我说原子性、有序性、可见性可能各位就会相对来说熟悉一点了,我们将上面的缓存不一致、乱序执行、指令重排序抽象出来就是原子性、有序性、可见性。
可能你还是有点懵,不过不用着急,这三个概念我们会结合例子,进行一一介绍。
原子性 与 可见性
下面是一个用两个线程买票的例子:
public class TicketSell implements Runnable {
// 总共一百张票
private int total = 2000;
@Override
public void run() {
while (total > 0) {
System.out.println(Thread.currentThread().getName() + "正在售卖:" + total--);
}
}
}
public static void main(String[] args) {
TicketSell ticketSell = new TicketSell();
Thread a = new Thread(ticketSell, "a");
Thread b = new Thread(ticketSell, "b");
a.start();
b.start();
}
运行结果截图:
先说一下,不同的操作系统调度机制不同,假如你运行和我一样的代码,跑不出和一样的结果,也在情理之中。也许你的是出现a和b都卖了1998这张票,也许是其他的。一般来说我们都会认为这是不正常的,因为我们认为两个线程应该是协作干活,不应该出现两个线程同时卖一张票的结果,为什么我们会有这种想当然呢?我们先不落实的具体的计算机上,我们先将这个卖票放在现实场景来分析,同样做一个比较粗糙的假定,还没有伟大的程序员们为他们做售票系统,是两个卖票员在一个房间里两个售票口,有一张桌子上面放了一堆票,有人来卖票员去桌子上看,还有没有票,有的话,将票递给乘客。
这其中其实就暗含了售票员在取票的时候是不可以被打断的,不存在说拿了一半这种情况,也就是原子性,还有就是售票员A在拿了一张票之后,售票员B再去拿票之后立马能看售票员拿票之后的结果,也就是可见性。我们潜意识里面多线程共享一个变量的时候,拿票操作是原子性的,拿票之后,另一个线程也能马上能够看到上一个线程的操作结果。
上面的运行结果中出现两个线程同时卖出第2000张票的原因就在于,虽然是两个线程共享两千张票,但是拿票过程是可以被打断的,比如a线程刚进来,读取到当前的票数,时间片耗尽了,轮到b线程了,b线程也进来开始读取当前的票数。在比如一个拿票动作,在线程看来分成三步,第一步读取票数(从内存中将变量加载到缓存中)、第二步CPU执行递增操作第三步将执行后的结果刷新到主存中,也是可以打断的。如果是多核CPU,线程a执行完计算,线程b可以读取到该线程的更新结果,那么我们就称这个线程对该共享变量的更新对其他线程可见。
Java语言规范规定,父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。
有序性
相对于原子性、可见性来说,有序性相对来说稍微有点难以理解,因为有序性相对来说更面向机器,贴近硬件,难以被感知。
顺序结构是结构化编程中的一种基本结构,它表示我们希望某个操作必须先于另外一个操作得以执行。另外两个操作即便是可以用任意一种顺序执行,但是反应在代码上两个操作也总是有先后顺序。但是在多核处理器的环境下,这种操作执行顺序可能是没有保障的,编译器可能改变两个操作的先后顺序;处理器可能不是完全按照目标代码所指定的顺序执行指令,另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码指定的顺序不一致。这种现象,我们称之为重排序。
JIT编译器简介
一图胜千言:
我们知道我们的程序最终还是要被CPU执行的,一个java程序首先要变成字节码,然后被在运行的时候由JIT(Just-In-Time)编译器将字节码翻译成本地机器代码,如果某段代码被调用的次数过度,也就是热点代码,JIT编译器就会将该段代码翻译成本地代码并缓存起来,下次运行的时候就无须再翻译。
关于有序性的一个经典例子:
TicketSell ticketSell = new TicketSell();
产生对象通常情况下是三步:
- 由JVM分配TicketSell实例所需要的的内存空间,并获得一个指向该空间的引用。
用伪码来表示就是 objRef = allocate(TicketSell.class);
- 调用TicketSell类的构造函数初始化objRef 引用指向的TicketSell实例。
- 将TicketSell 实例引用objRef 赋值给实例变量ticketSell
JIT编译器(JVM中负责将字节码解释成对应平台字节码的一个组件),并不是每次都是按上述顺序去生成对应的机器码,在产生对象比较频繁的情况下,顺序可能是1,3,2。如果是这种情况调用的因为调用对应对象的方法可能就会出现问题,以为对象还没有完全被初始化。
《Java多线程编程实战指南》的JITReorderingDemo就跑出来了指令重排序,从设计上来看十分的精巧,用到了线程协作的知识,本篇我们不讲线程同步与线程协作,相识篇(我写博客一般相同的主题大多都拆成三篇: 初遇、相识、甚欢)讲,每一篇博客都有对应的主题,讲完线程同步和线程协作,会专门开一篇博客讲JITReorderingDemo的设计思想
下载地址如下:
http://www.broadview.com.cn/b...
事实上不仅能跑出来,我们也可以通过一些工具看JIT编译器生成的机器码来证明,有一款工具叫hsdis,下载地址如下:
https://github.com/liuzhengya...
有兴致的同学可以自己下载下来玩一玩。
我记得我之前在学习单例模式的时候,测试指令重排序,测不出来,原因可能在于并发还是有点小吧。
为了解决这些问题
为了解决线程安全问题,Java在引入线程的同时也引入了线程同步机制:
- 锁(悲观锁、乐观锁、读写锁、显示锁)
- volatile 禁止重排序和保证可见性
为了让线程们之间更好的配合工作,Java也引入一套相关类:
- wait/notify
- 条件变量
- 倒计时协调器(CountDownLatch)
- 栅栏(CyclicBarrier)
- 信号量
- 线程中断机制
总结一下
- 多线程的好处
不少多线程入门可能会告诉你,引入多线程是为了充分利用多核处理器的资源,我认为这是一种错误的说法,因为在没有多线程之间,多进程照样是并发执行的,照样也是充分的利用了多核处理器的资源,我认为更直接的优势是相对于多进程,多线程共享变量更为简单,创建线程相对于进程更节省资源,这才是更直接的原因,我记得大学时期,学习操作系统的时候,也是将线程拎出来讲的。
- 如何看待线程
线程是一个执行单元,负责执行对应的任务,在没有线程之间,进程是最小的执行单元,在引入线程之后,进程的地位发生了变化,进程原来的执行逻辑被移动到了线程身上,在没有线程之前,进程就像只有老板的公司,在有了线程之后,老板就将活转移到了打工人身上。
- Java平台下如何创建线程
最基本的有两种: 继承Thread,重写run方法,在Runable方法里面写你想委托线程做的事情。
实现Runnable接口,重写run方法,然后将Runnable实现类的实例当做参数传递给Thread类的构造函数
参考资料: