1. 等待一个线程 join
有一天张三与小美在家里吃饭,张三早早的把饭吃完了,对小美说,等你把饭吃完了,我就去洗碗了!
此时张三就要等待小美吃完饭才能去洗碗,就好比线程 A 要等待线程 B 执行完,线程 A 才能接着往后干活!
等待线程,就是控制两个线程的结束顺序,使用 join 方法。
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("小美吃饭中!");
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("小美吃完饭了!");
});
t.start();
System.out.println("张三吃完饭了!");
t.join(); // main 线程等待 t 线程执行完毕
System.out.println("张三去洗碗了!");
这里当 t.start() 之后,t 和 main 线程就开始并发执行了,当 main 线程执行到 t.join() 表示 main 线程得等 t 线程执行完毕后,才能接着往后执行代码!只要 t 线程没有执行完毕,main 线程就会发生阻塞,一直阻塞到 t 线程执行完毕,也就是执行完对应的 run 方法!
像上述代码是可以等到 t 线程执行完毕了,如果 t 线程执行的任务里面出现了死循环怎么办呢?此时 main 线程是不是也得无休止的等待了?确实是这样的,但是 join 给我们提供了一个带参数的方法,等指定的时间,到点就不等了!
比如小美吃饭的速度特别慢,张三说,我就等你 5 秒钟,5 秒之后你还没吃完,我就自己洗碗了去了!
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("小美吃饭中!");
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("小美吃完饭了!");
});
t.start();
System.out.println("张三吃完饭了!");
t.join(5000); // 只等待5秒
System.out.println("张三去洗碗了!");
}
此时就能发现,小美没有吃完饭,但是张三还是去洗碗了,为什么呢?
因为 main 线程只等了 t 线程 5秒,但是 t 线程执行的 run 方法,可不止 5秒,里面还 sleep 了10秒钟,相当于小美还需要 10秒 才能吃完饭呢!
由此可见,这次张三肯定不会等小美吃完饭之后才去洗碗,就如同上面的打印结果一般。
此时还有一种情况,会不会小美比张三先吃完饭呢?
有可能!比如 main 线程执行 t.join() 的时候,但是 t 线程的 run 方法已经执行完了,此时 join 就不会阻塞了,就会立即返回!
同时 join 还提供了两个参数的版本:public void join(long millis, int nanos) 这个可以更高精度的等待,这里就不多说了。
2. 休眠当前线程
这个方法在前面也使用过,这里就来更细入的介绍一下。
让线程休眠,本质上就是让这个线程不参与调度了(不去 CPU 上执行了)。
通过调用 Thread 类中的 sleep 静态方法,传入指定时间,就能令线程休眠了。
注意:哪个线程里调用 Thread.sleep(1000),就让哪个线程休眠 1000 毫秒!
回顾一下上面讲过的知识,如果 main 线程中调用 t.join(),表示 main 线程要等待 t 线程结束,并且 main 线程进入阻塞状态。
而现在所讲的 sleep 方法,本质上是让线程休眠,也是进入了阻塞状态!
如何阻塞?这里需要了解两个玩意,就绪队列,阻塞队列。
当线程 A 调用 sleep() 方法,就会进入一个阻塞队列,当 sleep 结束,就会进入到就绪队列中。
阻塞队列里的线程,也就是暂时不参与 CPU 的调度了,就绪队列中的线程,随时可以被 CPU 调度!
所以一旦线程进入阻塞状态,对应的 PCB 就进入阻塞队列了, 因此暂时不能被 CPU 调度了,那如果线程阻塞结束,对应 PCB 进入到就绪队列,就一定会立即被 CPU 调度到吗?不一定!
这里一定要明确,CPU 是随机调度的!
这里来看一段代码:
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (true) {
System.out.println("hello");
}
});
t.start();
Thread.sleep(5000);
System.out.println("main 继续执行");
}
这里 t 线程死循环打印 "hello",而 main 线程中休眠了 5000ms,那么一定刚好休眠 5000ms 后就打印 "main 线程继续执行" 吗?
不一定!此时虽然是 sleep(5000),但实际上考虑到调度的开销,对应线程是无法在唤醒之后就立即执行的,所以实际上的时间大概率要大于 5000ms。
3. 线程的状态
3.1 认识线程的六种状态
线程的状态是一个枚举类型 Thread.State,一个线程从创建到销毁都有着不同的状态。
这里就看下这个 State 枚举类型里面包含的所有状态:
public static void main(String[] args) throws InterruptedException {
for (Thread.State s : Thread.State.values()) {
System.out.println(s);
}
}
● NEW 状态:创建了 Thread 对象,但是还没有调用 start 方法(内核中还没创建 PCB)
● TERMINATED:表示内核中的 PCB 已经执行完毕了(run执行完了),但是 Thread 对象还在
● RUNNABLE:就绪状态,细分 1) 等待被 CPU 调度,2) 正在 CPU 上执行,但在 Java 中,没有 运行时状态,都是 RUNNABLE(就绪) 状态
● WAITING:调用 wait 或 join 时进入的状态,后面介绍
● TIMED_WAITING:调用 sleep 进入的状态
● BLOCKED:等待锁时产生的状态
最后这三个状态都是阻塞状态,都表示线程的 PCB 在阻塞队列中,只是为啥阻塞的原因不同而已,这些在后续内容中会详细展开介绍。
3.2 线程的状态转换
从这一节开始就提到过,一个线程从创建到销毁都有着不同的状态,下面就用一张简图和多段代码来理解线程工作中不同状态的转换。
下面就来看几段代码,通过代码来观察下线程的状态:
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 10_0000; i++) {
}
});
System.out.println("start 之前: " + t.getState());
t.start();
System.out.println("线程执行中的状态 : " + t.getState());
Thread.sleep(10);
System.out.println("线程结束后的状态 : " + t.getState());
}
当线程进入 TERMINATED 状态后,对应的 PCB 也就销毁了,也就无法再重新启动线程了,否则会抛异常,但是线程对应的对象 t 并没有被释放,我们仍然可以调用这个对象的一些方法属性,但是无法通过多线程来做一些事情了!
通过代码查看线程调用 sleep 进入 TIMED_WAITING 状态:
public static void main(String[] args) {
Thread t = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
for (int i = 0; i < 1000; i++) {
System.out.println("t 的状态: " + t.getState());
}
}
这里发现大量打印 TIMED_WAITING 状态是因为线程执行的 sleep(1) 休眠时间太长了(站在 CPU 的视角),CPU 1 毫秒可以做很多事,对于执行线程中的 for 循环来说,只会转瞬即逝,所以该线程大部分时间都在休眠,真正在 CPU 上执行的时间特别少,所以也就出现 TIMED_WAITING 状态比 RUNNABLE 多特别多!
目前只演示这四个状态,剩下的 WAITING,BLOCKED 等后续学习到了,会进行讲解。
4. 多线程的优势
前面讲了那么多多线程的理论知识,还很少写过代码,那么现在我们就来通过一小段代码来一下多线程的优势在哪。
● 现有两个变量 a,b 用两个循环对这两个变量分别自增 100 亿次:
单线程实现:
public static void main(String[] args) {
long a = 0;
long b = 0;
long begin = System.currentTimeMillis(); //开始时间
for (long i = 0; i < 100_00000000L; i++) {
a++;
}
for (long i = 0; i < 100_00000000L; i++) {
b++;
}
long end = System.currentTimeMillis(); //结束时间
System.out.println(end - begin + " ms");
}
// 打印结果:5097 ms
多线程实现:
public static void main(String[] args) throws InterruptedException {
long begin = System.currentTimeMillis(); //开始时间
Thread t1 = new Thread(() -> {
int a = 0;
for (long i = 0; i < 100_00000000L; i++) {
a++;
}
});
Thread t2 = new Thread(() -> {
int b = 0;
for (long i = 0; i < 100_00000000L; i++) {
b++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
long end = System.currentTimeMillis(); //结束时间
System.out.println(end - begin + " ms");
}
// 打印结果:2817 ms
上述两种实现方式,在不同配置的电脑上,执行的时间都不一样,但多线程肯定是比单线程要快的,但这里有个疑问,为啥多线程消耗的时间不是单线程消耗时间的一半呢?
按道理来说,如果单线程跑两个循环需要 5000ms,那用两个线程一个线程跑一个循环,不应该是 2500ms 吗?
实际上,t1 和 t2 在执行过程中,会经历很多次调度,有时候是并发执行的(两个线程在一个核心上快速切换),有时候是并行执行的(两个线程在不同的核心上执行),至于什么时候是并发,什么时候是并行?这些都是不确定的,取决于系统的配置,也取决于当前程序运行的环境,如果同一时刻你电脑上跑的程序很多,此时并行的概率就更小了。另外线程的调度也是有开销了,所以上述多线程版本执行时间肯定不会比单线程版本缩短 50%。
通过上述案例,不难发现在这种 CPU 密集型的任务中,多线程有着非常大的作用,可以充分利用 CPU 多核资源,从而加快程序的执行效率。
多线程一定能提高效率吗?
其实也不一定,如果电脑配置特别特别低,CPU 只有双核,其实也提高不了多少。
如果当前电脑跑了特别多程序,CPU 核心都满载了,这个时候启动更多的线程也没啥用。
学习到现在,看似多线程很美好,但殊不知有一个很大的危险正在向我们靠近,这也是多线程编程中让程序猿苦恼的事情,同时也是面试官经常问的问题 —— 线程安全!
下期预告:【多线程】线程安全问题