初识多线程
在编程学习中,我们都要接触到多线程,多线程会帮系统性能带来很大提升,但是这种提升是风险与收益并存的。因此,学习并掌握多线程原理至关重要。
单线程与多线程
既然有多线程,那肯定是有单线程的。那单线程是什么呢?单线程就是在特定时间系统只做一件事,这就和人一样只能「一心一意」,不能「三心二意」。
平时开发中跑的 Java 程序大多都是单应用,对于一些没有什么访问量的系统来说,能勉强支撑。反之,访问量较大的话,一个线程难以处理,很影响用户的体验。就像一片麦子一个人割,和10个人一起割时间完全不同的。因此,我们就需要用到多线程来解决性能问题。
创建多线程
在 Java 设计之初,就考虑到了线程,因此「多线程」在 Java 中是提供了语言级别的支持。在 Java 中使用多线程也很方便。创建线程最简单的方法就是使用 new Thread()
,如下代码所示
1 |
|
上面创建两个线程,一个给他 wheatHarvest()
割麦子的任务,另一个给他 hoeing()
锄地的任务。创建了两个线程之后,这两件事是同时执行的,并不会说非要割完麦子,才去锄地。但上面只是创建了线程,分配了要执行的任务,线程并没有执行,要让线程执行必须调用线程的 start()
方法,线程才会执行。如下
1 |
|
start 和 run 的区别
刚开始学习多线程的时候经常将这两个方法搞混,现在重新复习一下。
start 方法就是让线程开始执行,而 run 是要等待线程执行完才往下执行。如下,start 方法可以让割麦子和锄地同时运行
1 |
|
而 run 方法则是,先割麦子,等麦子割完了,然后再去锄地
1 |
|
在这个方法上实现中,run 方法和我们的单线程基本没区别,而且还额外增加了线程的开销。
线程安全问题
既然多线程能给系统性能带来提升,那我们就可以无脑使用吗?答案肯定不是的。既然享受了多线程给我们带来性能上的提升,同时我们也要承担多线程带来的安全问题。
问题来源
多线程问题的来源就是,所有被线程共享的变量。当多个线程去操作一个共享变量的时候,线程安全问题就会出现。我们看如下代码,我们使用多线程操作,把共享变量 i 自增1,把结果打印看看会发生什么
1 |
|
这份代码,肯定会有人认为程序会按顺序输出 1~100,实际上不是如此。你可以把代码复制到自己的环境中运行,得出的结果可能会出乎你的意料,输出的顺序都乱了,并且可能会出现重复输出。为什么会出现这种情况呢?这是因为 i++
操作并非是一个原子操作
那什么是原子操作呢?原子操作就是一个不可分割的操作,之所以称作「原子操作」,我猜想和高中学的化学有关,原子是不可再分割的粒子,因此计算机就引用了这一概念。在多线程中,一个事情在某一时刻只能被一个线程操作,称作原子操作
回到刚才的 i++
操作,表面上我们以为是一步操作,实际上它包含了三个步骤:第一步,取 i 的值。第二步,把 i 的值加 1。第三步,把修改后的值写回 i。
我们使用两个线程来举例,线程1,线程2。线程1先执行,执行到 i ++的第二步操作,先取 i 的值,取到 i 的值为 0,然后把值加1,这个时候值变为 2,但是请注意,这个值并没有写回 i 中。这时,线程2开始执行了,它把 i++ 操作执行完了。由于线程1并没有把值写回 i 中,因此线程2 取到 i 的值依然为 0,把值加 1,写回到 i 中,完成 i++ 操作,并执行输出语句,打印 i 的值为1,到此线程2完成了。此时线程1继续执行刚才未完成的步骤,把值写回到 i 中,刚才线程1计算的值为1,因此把 1 写回到 i 中,完成 i++操作,打印 i 的值仍然为1。经过上面的操作,会发现 i 被重复写入了,因此我们无法保证输出的结果是 1~100。
你可能会有疑问,为什么线程1执行的好好的,线程2突然就插一脚呢?
这是因为,在微观上(cpu 眼中),多线程问题来源就是 cpu 的上下文切换,每个线程都会占用固定的时间周期,超过时间换线程执行。上面的例子就刚好是 cpu 的上下文切换,导致了1 这个值重复写入到 i 中。
我们打开 QQ,打开微信,打开浏览器,我们都认为他们是在同时运行的。但实际上都是 cpu 在进行切换,一会切换到微信,一会切换到浏览器,一会到 QQ,由于这个速度很快,我们就主观的认为他们是在同时运行的。多线程也是如此。
线程不安全的表现(死循环,死锁,哲学家用餐)
著名的 HashMap 的死循环问题可以点击该链接,了解详情。
死锁详解
以下是一个简单的死锁 Demo
1 |
|
以上这段代码运行之后,控制台并没有输出,但是程序还在一直执行。代码最初声明了两把锁 lock1 和 lock2,使用synchronized 关键字,去获取锁。例如 synchronized(lock1)
表示,需要拿到 lock1 才会执行后面的代码块,执行完{}
包裹的代码块,锁才会释放。需要注意的是,一把锁同时只能有一个线程拿到。
因此上面的程序执行流程是,主线程开辟了两个线程,Thread1 和 Thread2,并开始执行。其中 Thread1 需要先获取 lock1,获取之后线程休眠 500ms,然后再去获取 lock2。Thread2 则是先去拿到 lock2,休眠 100ms,然后再去拿 lock1。当 Thread1 去拿 lock2 时,发现 lock2 被拿了,于是 Thread1 等待;Thread2 准备去拿 lock1 的时候发现,lock1 被拿走了,于是 Thread2 等待。Thread1 和 Thread2 都在等待彼此释放自己需要的锁,于是产生了死锁等待。
简单的死锁排查
既然死锁了,我们就要需要先拿到死锁的进程 id
在 Linux 中使用 ps aux | grep java
,列出所有 java 进程的 id。或者使用 java 自带的 jps
命令,列出所有 java 进程。
之后使用 jstack 命令打印进程的栈信息,通过输出的栈信息来排查死锁。
一个经典的多线程问题哲学家用餐
预防死锁产生的原则:所有的线程按照相同的顺序获取资源的锁。上面的例子 Thread1 和 Thread2 获取锁的顺序不不一致,Thread1 先拿 lock1,Thread2 先拿 lock2。假如两个线程都先去拿 lock1 或者 lock2,那就不会产生死锁了。
实现线程安全的基本手段
为了规避和解决线程带来的安全问题,我们可以采取一些措施
使用不可变类
使用 Integer / String 这些不可变类。
使用 synchronized 同步块
方法一:synchronized(一个对象)把这个对象当成锁
1 |
|
还是上面的代码,我们把声明了一个锁,并在 modifySharedVariable
方法体中使用 synchronized
关键字。这样线程每次执行这个方法的时候,都会先去获取 lock1,当代码块中的代码执行后,lock1 被释放,其他的线程才能继续拿 lock1 去执行。
方法二:static synchronized 方法,把 Class 对象当成锁
1 |
|
这次不声明锁,而是直接在 modifySharedVariable
方法上使用 synchronized
关键字。在 static 方法上使用 synchronized,实际上是把这个类的 Class 对象当成锁。因此每次访问这个方法都要去拿到 Class 对象,也保证了 i++ 顺序执行。
方法三:实例的 synchronized 方法把该类的实例当成锁。(调用的对象)
1 |
|
当 synchronized 声明在普通的方法上,实际上是把调用的对象当成锁。上面的代码是通过 Demo 类的一个实例 object 来调用 modifySharedVariable 方法的。因此,object 就被当成锁,也保证了 i++ 顺序执行。上面的代码也可以改成这样
1 |
|
直接用 synchronized (this)
,这与 synchronized 声明在实例方法,功能是等价的。
使用 Collections 工具类
我们可以使用 Java 给我们提供的 Collection 的工具类,把不安全的 Collection 变成安线程全的。像 Collections.synchronizedList(),Collections.synchronizedSet() 等等
例如将普通的 Map 变成线程安全的 Map
1 |
|
使用 JUC(java.util.concurrent) 包
juc 包提供了很多线程安全的类,遇到线程安全问题,我们都可以把不是线程安全的类,换成线程安全的。
原子操作类:AtomicInteger、AtomicBoolean..,之前的我们得 i++ 操作不是原子的,可以使用原子操作类 AtomicInteger,来进行替换。使用 AtomicInteger 的 incrementAndGet
方法,就可以实现原子自增1的操作。
线程安全集合:ConcurrentHashMap,ConcurrentLinkedQueue 等。在任何使用 HashMap 有线程安全问题的地方,都可以无脑使用ConcurrentHashMap 替换
ReentrantLock (可重入锁)
ReentrantLock 所做的事情和 synchronized 几乎一样。
区别在于 ReentrantLock 可以自己定义加锁和解锁时机。使用 synchronized 关键字,执行完代码块中的代码,锁就会释放,但是有的时候我们需要在其他地方释放锁,而不是执行完就释放。因此可以使用 ReentrantLock 加锁,在适当的时机解锁
1 |
|
还是之前的例子,如果你对输出结果并不关心的话,进入到 modifySharedVariable
方法后,先使用 reentrantLock.lock() 获取锁,等 i++ 操作结束之后,使用 reentrantLock.unLock() 释放锁。reentrantLock 获取锁和释放锁的操作时机,都可以根据实际情况自己定义。
Tips:可重入锁相关概念。如下 Demo
1 |
|
a 和 b 方法都使用 synchronized 声明了,主线程调用声明了一个实例调用 a 方法。根据前面学的知识,调用 a 方法需要拿到实例锁,然后执行 a 方法。a 方法中又调用了 b 方法,b 方法也需要拿到实例锁,但是因为 a 已经拿到了实例锁,并且 synchronized 也是可重入锁,所以调用 a 方法中调用 b 方法无需再去获取实例锁,这就是可重入锁的概念。
可以点击查看 StackOverflow 上的大牛对于可重入锁的概念的理解链接
Object 类里的线程方法
说方法之前,了解下 Java 线程中的 6 种状态
- 初始(NEW),创建一个线程对象,但没有调用 start 方法
- 运行(RUNNABLE),开始执行操作(得到 CPU 使用权)
- 阻塞(BLOCKED),线程阻塞与锁
- 等待(WAITING),需要其他线程唤醒,或中断
- 超时等待(TIMED_WAITING),可以指定时间后,自行返回
- 终止(TERMINATED),线程执行完毕
使用代码解释这几个状态
1 |
|
用 new 一个线程的时候就是初始状态,调用了 start 方法,线程进入运行状态。假如第一个线程更快一步拿到 lock,这时其它两个线程就处于阻塞状态,当 lock 调用 wait() 方法,拿到 lock 的线程就进入等待状态,然后释放 lock,当线程方法执行完之后,线程进入终止状态
wait() 方法
让当前线程进入等待状态。调用 wait 方法之前,必须先拿到锁。当调用 wait 方法之后,拿到的锁也就会释放。
notify()
随机唤醒一个处于等待状态的线程。
notifyAll()
唤醒所有处于等待状态的线程。
实现生产者消费者模型 demo,可点击查看。
线程池与 Callable / Future
什么是线程池
在前面提到,我们每次使用线程都要先创建一个线程,然后使用给他分配任务,最后调用他的 start 方法执行这个任务。上面的步骤看起来没多大问题,仔细想想看,要是任务一多,每次分配任务的时候都要创建一个新的线程,这个创建线程的花销在 Java 世界中是很「昂贵的」。
类比到生活中,公司每次新接一个项目都去招一些人,做完项目就炒了,然后下次又来一个新的项目,又要去招人,这对于 HR 来说很麻烦。市面上的策略大多都是,招一群有潜力的人才,然后公司培养,有项目来就参与项目开发,下次再遇到新项目还是用之前招的人,这样就减少了公司频繁找人的开销。
线程池就是预先定义好若干个线程,每次需要线程的时候就去调用,避免了每次创建线程的开销,这与公司找人的策略是一样的。
定义线程池
使用 Executors 类去创建相应的线程池,并且可以配置线程的信息,使用 newFixedThreadPool
方法,创建固定数量的线程池。
1 |
|
Runable 与 Callable
在线程池中,都是使用 submit 方法提交并执行任务。使用 submit 方法的时候可以发现,它接收两个不同类型的参数。一个是与之前多线程相同的参数 Runable,而另一个则是 Callable。通过查看两者源代码可以发现,前者是没有返回值的,而 Callable 则有返回值。
我们可以发现 submit 方法返回了一个 Future 对象,Future 泛型的值与 Callable 里 call 方法返回的值是一样的。
Future
Future 表示异步计算的结果,也可以理解未来返回的结果。例如:我们让一个工人去割麦子,他去执行之后,我们只需要看看仓库是否增加了这么多麦子即可。工人割麦子的时候,我们可以去干其他事情,只需最后看结果就行。这个 Future 就是工人割的麦子总量。
Future 的常用 API
- get() 方法即可拿到返回的数据(拿到麦子)
- get(long timeout, TimeUnit unit) 给定等待时间,拿到结果(在规定时间内,检查割了多少麦子)
- cancel() 方法取消当前线程的任务(不让工人割麦子)
- isCancelled() 方法判断当前线程是否在正常结束之前被取消(不让工人割麦子之后,检查麦子是否割完)
- isDone() 判断当前线程是否执行完成(检查麦子是否割完)
使用多线程实现 Word Count
WordCount 就是给定一段或者多段文本(假设每个单词之间都是用空格分隔),记录每个单词出现的次数。
实现思路:可以定义一个线程池,线程池中线程的数量可以根据参数传递。每个线程的任务就是读取文件的一行,然后统计该行每个单词出现的次数。最后把每个线程执行的结果汇总,这样就完成了。
先把整个思路的代码写好,通过参数 threadNum
定义了线程池中线程的数量。使用 Map<String,Integer> 记录单词出现的次数,Future<Map<String, Integer>> 表示线程返回的结果, List<Future<Map<String, Integer>>> 就表示多个线程返回的集合。
由于有多个文件,因此使用 for 循环对每个文件都要进行统计操作。threadPool.submit(() -> workJob(file))
提交了任务并执行,任务就是 workJob
,即统计一行,单词出现的次数,返回的结果是Future<Map<String,Integer>>
,再用刚才定义好的集合 futures,把所有线程返回的结果收集起来。
futures 收集完成后,开始遍历这个集合,把线程返回的结果进行统计合并,使用 mergeWorkResultIntoFileResult
得到一个最终的结果,然后把最终的结果返回,程序执行结束。
1 |
|
接下来是具体的方法实现,首先是 workJob() 方法。统计一行文本中,单词出现的次数。
先读取一行文本保存到字符串,然后 split() 方法对单词进行分割,得到单词数组 words。把单词数组遍历,map 记录单词出现的次数。其中 result.getOrDefault(word, 0) + 1
方法表示:从 map 中拿到 key 为 word 的值,如果存在这个 key,就对这个 key 的值进行 +1 操作,然后 put 进去。如果不存在这个 key,就使它的值默认为 0,然后再进行 +1 操作,put 进去。
1 |
|
最后的工作 mergeWorkResultIntoFileResult()
,把线程返回的结果,合并统计,得到最终结果。统计的操作和 workJob() 方法类似。把合并的结果返回即可。
1 |
|
至此,一个使用多线程的 wordcount 功能实现。代码可以点击看源代码,后续会在代码中更新其他方法实现 WordCount。
多线程应用场景
不推荐:对于 cpu 密集型应用稍有折扣。cpu 密集型操作会把 cpu 跑满,因此再使用多线程去操作,性能上很那有提升。
推荐:IO 密集型操作(文件 IO,网络 IO),这两个操作相比 cpu 的执行速度慢如蜗牛,因此用多线程来执行,性能上会有很大提升。
多线程性能提升的上限:单核 cpu 100%,如果是多核就是 N*100%。当 cpu 跑满的时候,就很难有「闲工夫」去处理其他请求。