笔者有一个不太恰当或者不太准确的理解: 我一直都认为性能好的模型, 一般不容易理解; 而容易理解的模型, 往往性能都不是最好的. 在Java编程的领域里面, 个人觉得, 就Java本身而言, 并发编程是相对较难的一块内容. 因为, 人脑是串行的, 一个串行的大脑, 去理解分析一个并行的模型, 是需要较强的抽象和理解能力的.
从事Java开发多年了, 从最开始的一个CRUD码农逐渐成长为一个更多的会去考虑整个系统的性能的工程师. 最近这两三年在并发编程方面, 也有了较深入的学习研究. 刚好这个周末跟朋友聊到一些关于技术沉淀的事情, 也希望通过写博客的方式, 把自己的理解输出出来, 认识到更多的朋友, 可以一起讨论进步.
这一篇我们就来整体的聊一聊并发编程. 我希望通过这篇博客, 能够解答下面的几个问题
想想一下, 星期一你早上起床, 一堆事情等着你做: 热牛奶, 热面包, 烧水洗漱, 换上干净整洁的衣服等等. 为了在最少的时间内, 完成这些事情. 你会怎么来安排? 我相信几乎所有朋友, 都会选择烧水的同时, 用微波炉热牛奶面包, 在等待水烧好的时间内, 就去换好了衣服. 而不会让这些本来可以同时做的事情, 一件一件的单独处理.
如果你能想通上面的问题, 那么对于为什么我们需要并发, 也就是同时处理可以并行完成的任务, 有了基本的理解. 换句话说, 并发(多线程), 就是让我们在有限的时间内, 更好的去利用充足的资源. 从而使得整个事情效率变得更高的一种手段.
如果线程使用得恰当, 是可以有效的提升复杂程序的效率的, 同时还可以降低我们代码的开发和维护成本. 很多朋友不以为然, 觉得使用并发编程, 反而让原来简单的逻辑, 复杂化了. 还是本篇博客开头的话, 因为我们人脑是串行的, 串行的程序, 是模仿了人类的工作方式, 每次只做一件事情, 做完后再做另一件. 但是当我们逐渐去习惯使用这种并发的思路, 也就是打破了固有的串行流水线的思维后, 你会发现, 并发编程, 真的可以让原来复杂的冗长的逻辑, 变得更清晰, 更高效.
CPU的飞速发展为我们提供了非常丰富的硬件资源, 但是通过提升单个CPU的主频来提升计算机整体性能的方式已经变得比较困难, 所以现在的服务器更多的会采用多CPU的方式来承载大型的项目和更高的负荷. 众所周知, 计算机的基本调度单位是线程, 如果我们的程序始终是一个单线程运行, 那么对于一个双CPU的机器来说, 我们相当于浪费了50%的资源. 所以, 合理设计的多线程程序, 是可以通过提高处理器的资源利用率从而提升整个系统的吞吐率.
举一个大家都能体会的例子, 公司要开发一个新项目. 那么参与的角色通常会有产品经理, 后端开发, 前段开发, 测试和运维. 每一种角色, 就好比我们创建的一个线程. 他们只需要关注自己的任务. 并且大多时间, 他们都是并行在执行. 这样看来, 完全比一个超级全栈, 先自己分析需求, 准备PRD, 画原型图, 然后再开发后端接口, 前端页面, 再自己编写测试用例进行测试, 最后再自己部署上线的情况, 效率更高, 而且每个人的任务更加清晰. 当然, 这需要资源的支持.
试想一下, 如果我们常用的web容器, 比如tomcat, 他是个单线程的程序, 那么当面临那么多客户端请求的时候, 他只能一个一个排队处理, 若是有某个客户端, 需求比较过分, 要查询数据库中整张表上百万条数据, 这让排在后面的请求怎么想? 这就意味着当某个线程被阻塞之后, 所有请求都将停顿. 为了避免这个问题, 单线程的服务器, 不得不采用非阻塞IO(NIO)模型来解决这个问题, 但是NIO程序的复杂性, 远高于阻塞IO. 所以, 如果我们让服务器成为一个多线程的程序, 从而让每个线程使用同步IO来处理客户端请求. 就变得轻松容易得多了.
还是那句话, 性能好的模型, 往往都不太好理解. 对一个不太好理解的模型进行编程. 必定会有很多坑等着我们去踩.
并发编程聊得最多也最危险和麻烦的, 要数多线程的安全性问题. 这个问题是非常复杂的, 在没有进行恰当的同步处理时, 多线程的操作顺序是不可预测的, 将会产生各种奇怪的执行结果.一个老生常谈的案例: 电影院5个窗口共同卖100张电影票.
public class ThreadSafetyDemo {
private static int total = 100;
public static void main(String[] args) {
new Thread(ThreadSafetyDemo::sale).start();
new Thread(ThreadSafetyDemo::sale).start();
new Thread(ThreadSafetyDemo::sale).start();
new Thread(ThreadSafetyDemo::sale).start();
new Thread(ThreadSafetyDemo::sale).start();
}
private static void sale() {
while (total > 0) {
String name = Thread.currentThread().getName();
System.out.println(name + "卖出第["+(total --)+"]张票");
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
卖票的结果不堪设想. 不仅仅不同窗口卖出了同一张票, 甚至还有窗口卖第0张票.
Thread-2卖出第[90]张票
Thread-1卖出第[89]张票
Thread-4卖出第[91]张票
Thread-0卖出第[91]张票
Thread-3卖出第[90]张票
...
Thread-3卖出第[3]张票
Thread-4卖出第[2]张票
Thread-0卖出第[0]张票
Thread-2卖出第[1]张票
至于为什么会有这样的现象, 会在后面关于JMM, Java内存模型这一块进行详细解答. 以及如何来解决多线程下的数据安全问题, 后面章节会有专题进行讲解.
活跃性问题其实包含了3类问题: 死锁, 线程饥饿和活锁. 由于本篇只是对并发编程的一个概述, 所以也就用大白话先描述这三种情况. 让大家有个大致的了解. 先说死锁
经典的"哲学家就餐"问题, 很好的描述了死锁这一概念. 以下摘自百度百科
哲学家就餐问题可以这样表述,假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗意大利面,每两个哲学家之间有一只餐叉。因为用一只餐叉很难吃到意大利面,所以假设哲学家必须用两只餐叉吃东西。他们只能使用自己左右手边的那两只餐叉。哲学家就餐问题有时也用米饭和筷子而不是意大利面和餐叉来描述,因为很明显,吃米饭必须用两根筷子。
如果画图的话, 就是下面这样.
当5个哲学家都拿起自己左手边的筷子, 都在等待右手边的筷子空出来, 且都不放下自己手中的筷子的时候. 这就产生了死锁. 5个人活活饿死.简单来说, 就是我占有着你需要的资源, 在等着你释放我需要的资源. 但是你却又在等着我释放资源, 你才能给出我需要的资源.
还是哲学家就餐的例子. 为了避免大家都被饿死, 所以哲学家们定了这样一条规矩. 如果等待旁边的人放下筷子(右边)的时间超过一分钟, 那就先放下自己手中的筷子(左边), 并且一分钟之后, 再拿起筷子(左边)继续等待.这样看来, 貌似解决了死锁的问题. 但是又会伴随着活锁的出现: 如果五位哲学家在完全相同的时刻进入餐厅,并同时拿起左边的餐叉,那么这些哲学家就会等待一分钟,同时放下手中的餐叉,再等一分钟,又同时拿起这些餐叉。
由于CPU分配资源(时间片)是随机的, 尤其是在我们不恰当的设置了线程优先级的情况下, 一些优先级较低的线程, 始终无法抢占CPU资源而无法继续执行. 这时也就发生了线程的饥饿.
还有一种情况, 就是当某个线程获取到锁, 然后进入了死循环, 从而无法释放锁资源, 其他线程都等着获取这把锁的时候, 也产生了线程饥饿. 这种情况注意要跟死锁进行区分. 死锁是大家都在相互等待. 而饥饿, 则是单方面的等待.
举一个不太准确, 但是便于理解的例子. 当你写代码的时候,突然想去上厕所. 但是你又没有分身之术. 这两件事情, 都得由你亲自完成.正常情况, 你都会选择一次性上完厕所,再回来继续写代码. 而不会为了实现并发的模型, 上一部分厕所, 回来写两行代码, 再去上一部分…这样, 就会有大量时间浪费在工位和厕所来回的路程上.
程序也是如此. 如果我们硬件资源有限, 比如就一个CPU, 但是我们却启动了很多线程, 线程之间的切换, 我们称之为"上下文切换". 关于上下文切换的定义和消耗问题, 网上已经有很多非常专业和严谨的资料.这里依然继续摘自百度百科.
在操作系统中,CPU切换到另一个进程需要保存当前进程的状态并恢复另一个进程的状态:当前运行任务转为就绪(或者挂起、删除)状态,另一个被选定的就绪任务成为当前任务。上下文切换包括保存当前任务的运行环境,恢复将要运行任务的运行环境。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
"Hello World"对程序员来说再熟悉不过了. 我相信绝大部分Java coder写的第一个方法就是main()方法了. 但那个时候, 天真纯洁的我们, 永远不知道, 就是那么简单的一个方法. 一行简单的System.out.println("Hello World!");
跑起来之后, 背后其实发生了很多感人的故事. 那么, 一个main()
方法起来后, 到底有多少线程在运行着, 我们一起来看看.
public class Multithreading {
public static void main(String[] args) {
// get thread manager : mxBean
ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
// No information about the monitor and synchronizer is required
ThreadInfo[] threadInfos = mxBean.dumpAllThreads(false, false);
// Iterate through and output information
for (ThreadInfo info : threadInfos) {
System.out.println("[" + info.getThreadId() + "]--" + info.getThreadName() +
"--[" + info.getThreadState() + "]");
}
}
}
输出的结果如下:
[5]--Monitor Ctrl-Break--[RUNNABLE]
[4]--Signal Dispatcher--[RUNNABLE]
[3]--Finalizer--[WAITING]
[2]--Reference Handler--[WAITING]
[1]--main--[RUNNABLE]
简单描述一下这些线程的作用
java -version
, jstack
等命令后, 通常会希望jvm能给我们反馈信息. 当这些外部命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并且返回处理结果.好了, 第01篇关于并发编程的一些简介, 就写到这里了. 后面如果没有特殊情况, 我会逐渐的把关于并发的点都分享一下. 可能前面会偏理论一些. 也非常欢迎大家一起来讨论. 我讲得不清楚或者不对的地方, 大家多多给予指正.
如果能坚持把并发专题写完, 会考虑继续写一个关于JVM调优的专题, 也把近些年遇到的系统瓶颈和一些JVM的故障分享出来.
最后, 多谢各位看官. 咱们江湖再见!