目录
- 1 线程介绍
- 2 创建并启动一个线程
- 尝试一下
- 使用线程
- 使用Jconsole监测
- 3 线程生命周期
- 4 深入了解系统源码
- start源码及注意事项
- 模板设计模式
- 5 Runable
- 策略模式
- 例子
本文章所用demo地址:https://download.csdn.net/download/u013513053/11566762
线程的意思就是同时运行。
我记得有个思维练习题是这样说的:
烧水沏茶时,洗水壶要用 1 分钟,烧开水要用 10 分钟,洗茶壶 要用 2 分钟,洗茶杯2分钟,拿茶叶需要1分钟,如何安排能够尽快的喝到茶?
现在这道题对于大多数人来说非常简单,那就是先洗水壶,烧开水,烧水的同时洗茶壶,洗茶杯,拿茶叶。这样安排效率高,因为这里没有等待水烧开才去做下一步,在等待水烧开的时候把其他的准备工作也做好了。
计算机也可以这样提高效率,这就是多线程
1 线程介绍
现在99.99%的操作系统都支持多任务。对计算机来讲,每个 任务就是一个进程(Process),每个进程内至少有一个线程(Thread)。
其实线程很简单。
自打学习程序以来,就有什么顺序、条件、循环。但他们其实是一种,那就是顺着逻辑一条路走。不管怎么嵌套怎么复杂,依然还是一条线缠来缠去。一团毛线球也只是一根毛线在那缠。那一根毛线也是线程,就是说的主线程,就是上面说的至少有一个的线程。
多线程那就是好几根毛线在那缠,最显著的就是效率的提高。以前上学的时候老师罚抄,有聪明者会一手拿多根笔写,写一遍抵两三遍。
线程是程序执行的一个路径,在每个线程内,都有当前程序执行所需的局部变量表、程序计数器(记录程序运行到哪了)、生命周期等内容
2 创建并启动一个线程
2.1 尝试一下
import java.util.concurrent.TimeUnit;
public class Demo1 {
public static void main(String[] args) {
playMusic();
readArticle();
}
/**
* 听音乐,模拟多个任务
*/
private static void playMusic(){
while (true){
System.out.println("听音乐");
sleep(1);//为了让程序不那么快的运行,睡眠一会
}
}
/**
* 读文章,模拟多个任务
*/
private static void readArticle(){
while (true){
System.out.println("读文章");
sleep(1);
}
}
private static void sleep(int seconds){
try {
TimeUnit.SECONDS.sleep(seconds);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
我们使用这段代码,希望可以同时听音乐和读文章。但是从结果看来,只执行了听音乐,读文章不会运行。
2.2 使用线程并发运行交替输出
改造一下代码,使用多线程。java中的多线程使用的是Thread 类,以后会再次详细说明。
import java.util.concurrent.TimeUnit;
public class Demo2 {
public static void main(String[] args) {
new Thread(Demo2::playMusic).start();//java8 lambda语法,比较简洁
/** 之前的写法
new Thread(){
@Override
public void run() {
playMusic();
}
}.start();
*/
readArticle();
}
/**
* 听音乐,模拟多个任务
*/
private static void playMusic(){
while (true){
System.out.println("听音乐");
sleep(1);//为了让程序不那么快的运行,睡眠一会
}
}
/**
* 读文章,模拟多个任务
*/
private static void readArticle(){
while (true){
System.out.println("读文章");
sleep(1);
}
}
private static void sleep(int seconds){
try {
TimeUnit.SECONDS.sleep(seconds);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
输出:
可以看到听音乐和读文章都有执行,交替输出。
2.3 使用Jconsole
JVM为我们提供了很好的工具帮助我们查看线程的状况,我们可以使用Jconsole或者Jstack来进行查看。
如果配置好了环境变量,可以运行java命令,则可以在cmd中输入jconsole 命令启动。
或者找到jdk安装路径下面 jconsole.exe点击运行
然后点击选择本地进程,这里会自动自动搜索本机运行的所有虚拟机进程,选择自己的程序点击链接
如果提示安全链接失败,没有关系,使用不安全的连接继续
我们可以看到顶部切换选项卡,点击切换到线程
这里我们可以看到有很多线程,但实际上我们开启的线程只有main和Thread-0。main是有jvm启动时创建的,Thread-0 是上面显示创建的。然后还有一些其他的守护线程,比如垃圾回收线程等。建议在创建线程的时候给线程起一个名字,这样找到线程会比较容易。
3 线程生命周期
线程的生命周期主要是五个阶段
3.1 new 创建
当我们new一个Thread的时候,此时并不处于运行状态。只有调用了start方法,才会进入线程运行。单纯new的跟普通的对象没什么两样
3.2 runnable 等待运行/可运行
new出来的Thread对象调用start方法进入runnable 状态,等待运行。为什么不是调用start就运行呢?因为计算机的cpu是总管,有很多事情需要运算处理,新的 任务过来先排号,等待cpu的资源。这个状态是说明当前任务已经具备执行的资格,就差让cpu调度运行了。这就好比去一家饭店,饭店里面本身就有吃饭的,你后来的就得排队等待前面的人腾地。
当获得CPU资源后(scheduler调度),进入running运行中状态
3.3 running 运行中
cpu通过调度选中了可执行队列中的线程,那么此时才会真正执行当前线程的逻辑代码。一个running状态的线程也是runnable状态,runnable准确的说是可以运行,正在运行的状态也属于可运行状态。但是反过来就不成立。
在当前状态下,变换的状态比较多
- 直接进入terminated 状态:当线程执行完毕或者调用了jdk已经不推荐的stop,进入停止
- 进入blocked状态:当调用了sleep或者调用了wait方法,或者进行到阻塞的IO操作,或者需要获取的资源正在被其他使用者加了锁使用,需要等待资源释放。
- 进入runnable状态:CPU调度当前线程失去cpu资源,调用了yield方法放弃了CPU的执行权
3.4 blocked 阻塞
阻塞状态处于一个等待非CPU资源的其他资源的状态。如果说runnable状态已经具备执行的资格,那么blocked状态是执行资格还不具备。再说饭馆的例子,排队排着发现自己钱不够吃饭的,没办法,只能退出来去取到钱后再回来重新排队。
进入阻塞状态上面已经介绍了,再说一下阻塞状态切换至的几种状态
- 进入terminated 状态:调用了jdk不推荐使用的stop或者意外死亡
- 进入runnable状态:
1、线程阻塞状态结束,比如读取到了想要的数据
2、线程完成了指定的休眠
3、被其他线程用notify/notifyall唤醒
4、线程获取到某个锁资源
5、线程在阻塞过程被打断,如其他线程调用了interpret
3.5 terminated 结束
此状态是线程的最终状态,这个状态不会切换至其他任何状态。除了上面提到的进入terminated状态外,还有一些情况也会进入。如
- 线程正常运行结束
- 线程运行出错意外结束
- jvm crash 所有线程都结束
4 深入了解
当我们调用start方法,启动了一个线程,但最终执行的是run方法。底层是如何运行的?
4.1 Thread start方法源码及注意事项
看一下start 源码
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
start 的源码比较简单,最核心的部分就是 start0 这个方法,也就是JNI方法
private native void start0();
也就是说,start方法会调用 native 修饰的start0这个方法。native修饰表示是底层方法,不是由java实现。那么run方法何时调用的?从start方法的注释上我们可以看到这段描述。意思就是,当线程开始执行时,JVM将会执行线程的run方法。
通过start方法的源码我们可以看到
- Thread被构造后,threadStatus这个内部属性为 0
- 不能两次启动Thread,否则会出现 IllegalThreadStateException 异常
- 线程启动后会被加入ThreadGroup中,之后会详细说明ThreadGroup
- 一个线程生命周期结束,再次调用start方法也是不允许的
public static void main(String[] args) {
Thread thread = new Thread(){
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
thread.start();
thread.start();
}
执行这段代码将会抛出IllegalThreadStateException 异常
然后我们再看一下线程生命周期结束之后再次调用
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(){
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
thread.start();
TimeUnit.SECONDS.sleep(3);
thread.start();
}
这里执行也会报IllegalThreadStateException 异常
虽然抛出去的异常是一样的,但它们是有本质区别。一个是重复启动不被允许,第二个是线程已经结束,没有线程可以使用
我们调用start方法,最终执行了run方法。我们把run方法称为线程的执行单元。我们可以看到Thread中run方法。
@Override
public void run() {
if (target != null) {
target.run();
}
}
如果没有使用到Runnable接口,则Thread的run方法就是一个空的实现。所以直接new一个Thread并调用start也是没有问题,只是没有逻辑处理,没有意义。
new Thread().start();
模板设计模式
其实Thread的run和start方法就是一个比较典型的模板设计模式,父类编写算法结构代码 ,子类实现逻辑细节。
下面用一个例子来实现模板设计模式
public abstract class Demo5 {
public final void print(String msg){
System.out.println("调用了print方法");
customPrint(msg);
System.out.println("==================");
}
public abstract void customPrint(String msg);
public static void main(String[] args) throws InterruptedException {
Demo5 t1 = new Demo5() {
@Override
public void customPrint(String msg) {
System.out.println("hello "+msg);
}
};
t1.print("张三");
}
}
输出
这里的print 方法就相当于Thread 的start方法,customPrint 类似于 run方法。这样做的好处是程序由父类控制,并且是final修饰,不允许被重写子类只需要去实现想要的逻辑任务就可以了。
5 Runable
Thread类本身是实现的Runnable接口,我们也可以从外部传入一个Runnable的实现。在一些文章中,经常会说创建线程的两种方式 ,第一种Thread,第二种Runnable。这种说法是不严谨的。在JDK中线程只有Thread这个类。上面也可以看到Thread中run方法的源码
public
class Thread implements Runnable {
//省略其他代码···
@Override
public void run() {
if (target != null) {
target.run();
}
}
//省略其他代码···
}
这里的target就是传入的Runnable对象,在这里有个判断,如果传入的Runnable对象不为空,则执行传入的Runnable对象的run方法。如果为空,就执行自身的run方法。如果是继承了Thread类并重写了run方法,就要注意这一点。否则没有这个条件判断,使用Runnable是不成功的。
5.1 Runnable 策略模式
Thread的run或者使用Runnable都讲线程的控制和本身的业务分离开来,做到了职责分明,功能单一的原则。这种设计模式跟策略模式 很相似。我们来看一下策略模式的例子
public class Demo6 {
public interface Operation {
int doOperation(int num1, int num2);
}
public static class Calculator {
private Operation strategy;
public Calculator(Operation strategy) {
this.strategy = strategy;
}
public int executeOperation(int num1, int num2) {
return strategy.doOperation(num1, num2);
}
}
public static void main(String[] args) {
Calculator calculator = new Calculator(new Operation() {
@Override
public int doOperation(int num1, int num2) {
return num1 + num2;
}
});
int result = calculator.executeOperation(1, 3);
System.out.println(result);
}
}
这里实现一个计算的例子。首先有一个接口Operation ,这里规定了返回类型,输入的内容。Operation 接口类似于Runnable接口。然后有一个Calculator 计算器类。Calculator 计算器类相当于Thread类,用来处理计算器的方法,最终调用doOperation方法。doOperation方法相当于Runnable中的run方法。
当我们创建一个Calculator 类时,需要传入一个Operation 对象,实现doOperation方法。在doOperation方法中我们可以实现自己的逻辑,让两个数字相加、相减、相乘、相除、比较大小,求最大值、求最小值等等都可以,这就是自己的逻辑。
好处就是1、算法可以自由切换。 2、避免使用多重条件判断。 3、扩展性良好。职责分明,每个类功能单一。
通过这个例子或许能清楚一些Thread与Runnable 之间的关系。
5.2 使用Runnable模拟叫号
我们使用Runnable的一个好处就是,可以共享变量。如果使用Thread的run方法,则不能实现共享的变量。我们可以实现一个Runnable的类,然后这个类交由多个Thread去使用,这样多个线程是运行的同一个对象的run方法,所使用的属性也是同一个Runnable对象的属性
public class TicketRunnable implements Runnable {
private int index = 1;
private static final int MAX_NUMBER = 100;
@Override
public void run() {
while (index<MAX_NUMBER){
System.out.println(Thread.currentThread()+ " 当前的号码是:"+index);
index++;
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Test {
public static void main(String[] args) {
//模拟叫号
final TicketRunnable ticket = new TicketRunnable();
new Thread(ticket,"一号窗口").start();
new Thread(ticket,"二号窗口").start();
new Thread(ticket,"三号窗口").start();
new Thread(ticket,"四号窗口").start();
new Thread(ticket,"五号窗口").start();
new Thread(ticket,"六号窗口").start();
}
}
输出
当然,这样做也并不是完美的,多运行几次会发现,可能会出现有的号码被跳过,有的号码重复,最后超过最大值或者不到最大值。这是因为线程访问同一个资源,存在线程安全问题。当多个线程同时访问一个资源时,很容易就导致了状态不同。这个地方就需要考虑数据同步问题。我们可以给这个数据加上一把锁,使用synchronized关键字就可以。对于synchronized关键字之后再做介绍