死磕 Java 并发(5):线程详解,Java 开发这么久,这些线程的基础知识你确定都会了?
熟悉Java开发的同学都知道,Java天生支持多线程编程的。这篇文章我们主要来学习下Java线程的基础知识,从线程的启动到不同线程间的通信方式,目的是更系统的掌握Java线程基础。
本文的讲解主要从以下几个点展开:
什么是线程
线程都有哪几种状态
线程的启动和终止
线程间的通信
利用本文讲解的线程知识,实现一个简单的线程池
如果上面列出的这几个点,你都已经熟练的掌握了,那么可能本文就无法给你带来帮助了。选择性的往下看哦。
ok,开始步入今天的正题
什么是线程
下面是针对操作系统中进程和线程的概念:
现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序,操作系统就会创建一个Java进程。现代操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。
那么什么是Java中的线程呢?
Java中的线程
在Java中,“线程”指两件不同的事情:
java.lang.Thread类的一个实例;
线程的执行;
使用java.lang.Thread类或者java.lang.Runnable接口编写代码来定义、实例化和启动新线程。一个Thread类实例只是一个对象,像Java中的任何其他对象一样,具有变量和方法,生死于堆上。
一个Java应用总是从main()方法开始运行,mian()方法运行在一个线程内,它被称为主线程。一旦创建一个新的线程,就产生一个新的调用栈。
需要注意的是Java中有一个特殊的线程,那就是Daemon线程。
Daemon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true) 线程设置为Daemon线程。Daemon属性需要在启动线程之前设置,不能在启动线程之后设置。
线程的状态
Java线程在整个生命周期中一共分为6中状态,在给定的任一时刻,线程只能处于一种状态。
感兴趣的同学,可以动手实践下,利用jstack工具查看运行中的代码的线程信息,这样比只看有用多了,能够更加深入的理解线程的各种状态。
这里提供示例代码如下,同时示例中的代码都已经上传到 github,需要的同学可以从这个地址获取
https://github.com/coderluojust/java-study/
这里写一下使用 jstack 查看的步骤:
运行上面的示例,打开终端或者命令提示符,输入
jps
,找到对应的进程号;
从上一步得到示例对应的进程号是13376,接着输入 jstack 13376
,就可以得到当前程序的线程堆栈,观察状态;
部分输出如下:
线程在自身的生命周期中,并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java线程状态变迁参考下图:
启动和终止线程
在运行一个线程之前,首先要构建一个线程对象。程对象在构造的时候需要提供线程所需要的属性,如线程所属的线程组、线程优先级、是否是Daemon线程等信息。 这部分信息我们可以查看Thread类的init() 方法,下面截取了java.lang.Thread
中初始化方法init的部分内容:
启动线程
线程对象在初始化完成之后,调用start()方法就可以启动这个线程。线程start()方法的含义是:当前线程(即parent线程)同步告知Java虚拟机,只要线程规划器空闲,应立即启动调用start()方法的线程。
注意:启动一个线程前,最好为这个线程设置线程名称,因为这样在使用jstack分析程序或者进行问题排查时,就会给我们提供一些提示,自定义的线程最好能够起个名字。
安全的终止线程
这里为什么说安全的终止线程呢,是因为 最早的 暂定、恢复、停止线程使用的是Thread 的API:suspend()、resume()、stop()。但是这些API是已经过期的,不建议使用。具体的不建议使用原因主要是:
以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下。
上面只说了不建议使用的线程暂停、恢复、停止相关的API,那么如何安全的 终止线程 和进行 等待恢复操作呢?
采用线程中断,也就是调用线程的interrupt()方法,改变中断状态来实现线程间交互,从而停止任务;
利用一个boolean变量来控制是否需要停止任务终止线程;
示例代码如下,同样的已经上传到 github上,需要的小伙去点击下方链接去获取:https://github.com/coderluojust/java-study/
示例中,main线程通过中断操作和 cancel() 方法均可使CountThread 停止,这两种方式能否使线程在终止时有机会去清理资源,而不是武断的将线程停止,因此这种终止线程的做法显得更加优雅和安全。
聪明的你可能会问,线程的终止问题是解决了,那等待和恢复的方法呢 。。。
强大的Java肯定是有解决办法的,具体的线程等待和恢复我们可以采用等待/通知机制。接着往下看吧,在线程间通信机制讲解时会提到这块。
线程间通信
线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码一步一步地执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立地运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,这将会带来巨大的价值。
既然线程间需要相互配合完成工作,那么线程间的通信就是首先要解决的问题,下面我们来学习线程间通信一共有哪些方式?
volatile和synchronized关键字
前面几篇关于 java内存模型的文章,我们深入学习了 volatile和synchronized 关键字,它们是jvm层面提供的用来解决导致并发bug的三个源头(可见性、有序性、原子性)。
我们都知道 Java 支持多个线程同时访问一个对象或者对象的成员变量,所以它是支持线程之间的通信的。但是由于现代多核处理器,为了解决cpu和内存之间的性能差异每个线程都有一份变量的拷贝存储在cpu的高速缓存中,所以程序的执行过程中,一个线程看到的不一定是最新的内容,这就影响了线程间的通信。
关键字 volatile 可以用来修饰字段,就是告知程序对该变量的访问需要从共享内存获取,而对它的改变必须同步刷新回共享内存(实现原理就是通过一个lock前缀指令,相当于一个内存屏障,它的功能是可以将当前处理器缓存行的数据立即写回内存, 写回操作经过总线传播数据,其它处理器通过嗅探在总线上传播的数据,发现对应内存地址的数据变更将各自缓存失效),确保所有线程对变量访问的可见性。
关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。
等待/通知机制
等待/通知机制简单点说,就是生产者、消费者模式。一个线程的修改了一个对象的值,而另一个线程感知到了变化,进而执行相应的操作。那么如何实现这种功能呢?
最简单的方案就是消费者线程写一个死循环不断的检查变量是否符合预期,可以参考如下伪代码:
这种方式虽然实现了需要的功能,但是确存在如下问题:
不够及时。在睡眠时基本不消耗cpu资源,但是睡眠过久,无法及时发现变化;
难以降低开销。如果要保证及时性,就要降低睡眠时间,这样会消耗更多的CPU资源;
上面两个问题,你可以发现是互斥的,难道就没有更合理的方案? 这就需要Java内置的等待通知机制来解决这个矛盾。
等待/通知的相关方法是任意Java对象都具备的,因为这些方法被定义在所有对象的鼻祖 java.lang.Object
中,相关方法具体如下:
等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
这里也有对应的示例代码,由于篇幅原因,就不展示代码了,如果你想动手实践加深理解,一样的,本文涉及的示例都已经上传到github,点击文末 阅读原文 查看,寻找 WaitNotify
这个类。
在使用wait()、notify()、notifyAll()时有几个细节需要掌握:
调用wait()、notify()、notifyAll() 需要先对调用对象加锁;
调用wait()方法后,线程状态有RUNNING 变为 WAITING ,并释放当前对象锁,将当前线程放入到对象的等待队列中;
notify()和notifyAll() 方法调用后,等待线程依旧不会从wait()返回,需要等待调用notify()、notifyAll() 的线程释放锁之后,等待线程才能去竞争锁,获取到锁才能从wait()返回;
notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING变为BLOCKED。
从wait()方法返回的前提是获得了调用对象的锁。
管道输入/输出流
管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。
主要实现有如下四种:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter
,前两种面向字节,而后两种面向字符;
用法如下:
Thread.join()
如果一个线程A调用了线程B的join()方法,那么当前线程A 等待线程B终止之后才会从B.join()返回。线程Thread除了提供join()方法之外还提供了等待超时方法join(long)和join(long,int)。
这里其实也是用到了等待/通知机制,即A线程同步调用B线程的join()方法,进入循环等待,等到B线程结束之后,接收通知从B.join()方法返回,执行后续逻辑;
下面是JDK中Thread.join() 方法的核心源码,和我们上面描述的意思是一样的(部分调整后):
ThreadLocal 的使用
ThreadLocal 即线程变量,是以当前线程为参数先获取当前线程的ThreadLocal.ThreadLocalMap
属性,即 ThreadLocalMap
是绑定在当前线程上的。这个ThreadLocalMap 中又是以 ThreadLocal 对象为key,任意对象为值的map数据结构。也就是说一个线程可以根据一个 ThreadLocal 对象查询到绑定到这个线程上的一个值。
应用实践
俗话说学而不思等于白学,接下来我们就结合今天学习的线程基础基础,包括线程状态以及等待通知等线程基础方法,来实现一个简单的线程池,巩固提高下。
主要功能为预先创建若干数量的线程,并且用户不用直接对线程的创建进行控制,用户只需要将需要执行的任务提交给线程池,线程池重复利用数目固定的线程来完成任务。好处在于减少频繁的创建和销毁线程的开销,避免一个任务一个线程导致系统频繁的进行上下文切换,增加系统的负载。
线程池接口定义如下:
具体实现这里不展示了,感兴趣可以去我的 github 上找对应的实现。
总结
本文主要阐述了Java并发编程的基础知识,从线程是什么开始,讲述了线程的各种状态,以及如何优雅安全的开启和终止。详细学习了线程之间的各种通信方法,以及经典范式 等待/通知 机制,最后通过一个线程池的示例,巩固了Java多线程的这些基础知识,加深理解。
相信如果你能按照文章讲述运行对应的代码示例,一定会对Java多线程的基础知识有一个更深刻的理解和掌握。
2020.04.29
fighting!
笔者水平有限,文章难免会有纰漏,如有错误欢迎扫码交流一起交流探讨,我会第一时间更正的。都看到这里了,码字不易,可爱的你记得 "点赞" 哦,我需要你的正向反馈。
最近面试 字节、BAT,整理一份面试资料《Java 面试 BAT 通关手册》,覆盖了 Java 核心技术、JVM、Java 并发、SSM、微服务、数据库、数据结构等等。获取方式:点赞关注,关注公众号并回复 666 领取,更多内容陆续奉上
版权声明: 本文为 InfoQ 作者【七哥爱编程】的原创文章。
原文链接:【http://xie.infoq.cn/article/1c20718974e939344cd65143b】。文章转载请联系作者。
评论 (1 条评论)