多线程不仅是Java后端开发面试中非常热门的一个问题,也是各种高级工具、框架与分布式的核心基石。但是这个领域相关的知识点涉及到了线程调度、线程同步,甚至在一些关键点上还涉及到了硬件原语、操作系统等更底层的知识。想要背背面试题很容易,但是如果面试官一追问就很容易露馅,更不用说真正想搞明白这个问题并应用在实际的代码实践中了。
不用担心!在接下来的一系列文章中将会由浅入深地贯穿这个问题的方方面面,虽然不如一些面试大全来得直接和速成。但是真正搞明白多线程编程不仅能够一劳永逸地解决面试中的尴尬,而且还能打开通往底层知识的大门,不止是搞明白一个孤立的知识点,更是一个将以前曾经了解过的理论知识融会贯通连点成面的好机会。
虽然阅读本文不需要事先了解并发相关的概念,但是如果已经掌握了一些大概的概念将会大大降低理解的难度。有兴趣的读者可以参考本系列的第一篇文章来了解一下并发相关的基本概念——当我们在说“并发、多线程”,说的是什么?。
这一系列文章将会包含10篇文章,本文是其中的第二篇,相信只要有耐心看完所有内容一定能轻松地玩转多线程编程,不止是游刃有余地通过面试,更是能熟练掌握多线程编程的实践技巧与并发实践这一Java高级工具与框架的共同核心。
前五篇包含以下内容,将会在近期发布:
- 并发基本概念——当我们在说“并发、多线程”,说的是什么?
- 多线程入门——本文
- 线程池剖析
- 线程同步机制解析
- 并发常见问题
为什么要有多线程?
多线程程序和一般的单线程程序相比引入了同步、线程调度、内存可见性等一大堆复杂的问题,大大提高了开发者开发程序的难度,那么为什么现在多线程在各个邻域中还被如此趋之若鹜呢?
一种场景
在我大学的时候宿舍边上有一家盖浇饭,也提供炒菜。老板非常地耿直,非要按点菜的顺序一桌一桌地烧,如果前一桌的菜没上完后一桌一个菜都别想吃到。结果就是每天这家店里都是怨声载道,顾客们常常等了半个小时也等不来一个菜填填肚子。你问我为什么还会有人去吃,受这罪,那肯定是因为好吃啊😂。
不过仔细想想,好像一般的店里好像并没有这种情况,因为大部分饭店都是混合着上的,就算前一桌没上完好歹会给几个菜垫垫肚子。这在程序中也是一样,不同的程序之间可以交替运行,不至于在我们的电脑上打开了开发工具就不能接收微信消息。
这就是多线程的一个应用场景:通过任务的交替执行使一台计算机上可以同时运行多个程序。
另一种场景
还是在小饭馆里,一个服务员在给一桌点完菜之后肯定不会等到这桌菜上完了才去给另外一桌点菜。一般都是点完菜就把订单给了厨房,之后就继续给下一桌点菜了。在这里,我们可以把服务员想象成我们的计算机,把厨房想象成远程的服务器。那么在我们的电脑下载音乐的时候同时继续播放音乐,这就能更高效地利用我们的电脑了。
这种场景可以描述为:在等待网络请求、磁盘I/O等耗时操作完成时,可以用多线程来让CPU继续运转,以达到有效利用CPU资源的目的。
最后一种场景
然后我们来到了厨房,竟然看到了一个大神,能一个人烧2个灶台。如果这个厨师大神是一个多核处理器,那么两个灶台就是两个线程,如果只给一个灶台,那就浪费他的才能了,这绝对是一种损失。
这就是多线程应用的最后一种场景:将计算量比较大的任务拆分到两个CPU上执行可以减少执行完成的时间,而多线程就是拆分和执行任务的载体,没有多线程就没办法把任务放到多个CPU上执行了。
什么是多线程?
多线程就是很多线程的意思,嗯,是不是很简单?
线程是操作系统中的一个执行单元,同样的执行单元还有进程,所有的代码都要在进程/线程中执行。线程是从属于进程的,一个进程可以包含多个线程。进程和线程之间还有一个区别就是,每个进程有自己独立的内存空间,互相直接不能直接访问;但是同一个进程中的多个线程都共享进程的内存空间,所以可以直接访问同一块内存,其中最典型的就是Java中的堆。
初识多线程编程
了解了这么多理论概念,终于到了实际上手写写代码的时候了。
创建线程
Java中的线程使用Thread
类表示,Thread类的构造器可以传入一个实现了Runnable
接口的对象,这个Runnable对象中的void run()
方法就代表了线程中会执行的任务。例如如果要创建一个对整型变量进行自增的Runnable
任务就可以写为:
// 静态变量,用于自增
private static int count = 0;
// 创建Runnable对象(匿名内部类对象)
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1e6; ++i) {
count += 1;
}
}
有了Runnable
对象代表的待执行任务之后,我们就可以创建两个线程来运行它了。
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
但是这时候只是创建了线程对象,实际上线程还没有被执行,想要执行线程还需要调用线程对象的start()
方法。
t1.start();
t2.start();
这时候线程就能开始执行了,完整的代码如下所示:
public class SimpleThread {
private static int count = 0;
public static void main(String[] args) throws Exception {
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1000000; ++i) {
count = count + 1;
}
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
// 等待t1和t2执行完成
// t1.join();
// t2.join();
System.out.println("count = " + count);
}
}
最后输出的结果是8251,你执行的时候应该会与这个值不同,但是一样会远远小于一百万。这好像离我们期望的结果有点远,毕竟每个任务都累加了至少一百万次。
这是因为我们在main方法中创建线程并运行之后并没有等待线程完成,使用t1.join()
可以使当前线程等待t1线程执行完成后再继续执行。让我们去掉两个join方法调用前面的双斜杠试一试效果。
线程同步
在我的电脑上执行的结果是1753490,你执行的结果会有不同,但是同样达不到我们所期望的两百万。具体的原因可以从下面的执行顺序图中找到答案。
t1 | t2 |
---|---|
获取count值为0 | |
获取count值为0 | |
计算0+1的结果为2 | |
将2保存到count | |
计算0+1的结果为2 | |
将2保存到count |
可以看到,t1和t2两个线程之间的并发运行会导致互相自己的结果覆盖,最后的结果就会在一百万与两百万之间,但是离两百万会有比较大的距离。这样的多线程共同读取并修改同一个共享数据的代码区块就被称为临界区,临界区同一时刻只允许一个线程进入,如果同时有多个线程进入就会导致数据竞争问题。如果有读者对这里提到的临界区和数据竞争概念还不清楚的,可以参考本系列的第一篇介绍并发基本概念的文章——当我们在说“并发、多线程”,说的是什么?。
在Java 5之前,我们最常用的线程同步方式就是关键字synchronized
,这个关键字既可以标在方法上,也可以作为独立的块结构使用。方法声明形式的synchronized关键字可以在方法定义时如此使用:public synchronized static void methodName()
。因为我们的累加操作在继承自Runnable接口的run()
方法中,所以没办法改变方法的声明,那么就可以使用如下的块结构形式使用synchronized
关键字:
Runnable task = new Runnable() {
public void run() {
for (int i = 0; i < 1000000; ++i) {
synchronized (SimpleThread.class) {
count += 1;
}
}
}
};
synchronized是一种对象锁,采用的锁和具体的对象有关,如果是同一个对象就是同一个锁;如果是不同的对象则是不同的锁。同一时刻只能有一个线程持有锁,也就意味着其他想要获取同一个锁的线程会被阻塞,直到持有锁的线程释放这个锁为止。这里可以把对象锁对应的对象看做是锁的名称,实现同步的并不是对象本身,而是与对象对应的对象锁。
在块结构的synchronized关键字后的括号中的就是对象锁所对应的对象,在上面的代码中,我们使用了SimpleThread类的类对象对应的锁作为同步工具。而如果synchronized关键字被用在方法声明中,那么如果是实例方法(非static方法)对应的对象就是this指针所指向的对象,如果是static方法,那么对应的对象就是所处类的类对象。
这次我们可以看到输出的结果每次都是稳定的两百万了,我们成功完成了我们的第一个完整的多线程程序🎉🎉🎉
后记
但是一般在实际编写多线程代码时,我们一般不会直接创建Thread对象,而是使用线程池管理任务的执行。相信读者们也在很多地方看见过“线程池”这个词,如果希望了解线程池相关的使用与具体实现,可以关注一下将会在近期发布的下一篇文章。
到目前为止,我们都只是涉及了并发与多线程相关的概念和简单的多线程程序实现。接下来我们就会进入更深入与复杂的多线程实现当中了,包括但不限于volatile关键字、CAS、AQS、内存可见性、常用线程池、阻塞队列、死锁、非死锁并发问题、事件驱动模型等等知识点的应用和串联,最后大家都可以逐步实现在各种工具中常用的一系列并发数据结构与程序,例如AtomicInteger、阻塞队列、事件驱动Web服务器。相信大家通过这一系列多线程编程的冒险历程之后一定可以做到对多线程这个话题举重若轻、有条不紊了。