多线程内幕
本文是HinusWeekly第三期的第二篇文章,第三期的主题就是多线程编程。本文试图从单核CPU的角度讨论并发编程的困难。
函数调用的过程,就是不断地创建栈帧,销毁栈帧。实际上,多线程程序的执行只是这个模型的一种推广,也就是每一个线程都拥有自己独立的栈空间。
我们看一下这个程序:
public class TestOne {
public static void main(String[] args) throws Exception {
Thread t1 = new Thread() {
public void run() {
int t = add(1, 2);
System.out.println(t);
}
};
Thread t2 = new Thread() {
public void run() {
int t = add(3, 4);
System.out.println(t);
}
};
t1.start();
t2.start();
t1.join();
t2.join();
}
public static int add (int a, int b) {
return a + b;
}
}
多次运行这个程序,每一次得到的结果可能都不一样。有时候可能是"3,7",但有时候又可能是"7,3"。
这段程序的意思就是开启两个线程,一个计算1+2,一个计算3+4。虽然 t1.start 是在 t2.start之前调用的,但这并不意味着t1就一定会在t2之前打印出计算结果。
t1的执行和t2的执行实际上是交替执行的。(如果是在多核机器上,可能是在不同的核上去执行的)我们看下面的这张图:
左边代表 t1 运行时的栈空间,右边代表 t2 运行时的栈空间。在单核的情况下,CPU会在t1上干一会活,然后保存t1的现场,转到t2上再干一会儿,然后保存t2的现场,再转回t1上,把现场恢复了,从刚才停下的地方继续干t1的活儿。而所谓现场,在现阶段,我们就理解为栈空间,大致是不会错的(其实,还有很多东西是保存在control block中的,但我们不去抠那么细节的东西,学习一个新的知识就是这样,先掌握其大概,然后再逐步细化,而不要在一开始就追求面面俱到)。所以你就可以认为CPU在这两个栈空间之间切来切去。
至于干到什么时候停下来,转到隔壁去,以及如何能够保存现场,恢复现场,这些都是CPU和操作系统要关心的,做为Java程序员,我们是不必关心的(我希望读者能够理解这些机制,但是我们的课程内容有限,不可能做到面面俱到,所以我把这些内容都安排到作业里去了,希望读者能认真完成课后习题)。我们只知道,多个线程在并发执行的时候,其运行结果是不确定的,依赖于操作系统的调度。
这个现象就说明了多线程编程为什么这么困难。线程之间,各个指令的执行顺序是不确定的。而写程序,最怕的就是不确定性。我们再看一个例子:
public class TestTwo {
public int total = 0;
public static void main(String[] args) throws Exception{
TestTwo test = new TestTwo();
Thread t1 = new Thread() {
public void run() {
for (int i = 0; i < 5_000; i++) {
test.total += 1;
}
}
};
Thread t2 = new Thread() {
public void run() {
for (int i = 0; i < 5_000; i++) {
test.total += 1;
}
}
};
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(test.total);
}
}
我运行这个例子三次,结果分别是10000,7515,7767。大家可以在不同的机器上多运行几次,你会发现,几乎每一次结果都不相同。
这个例子中,我们开启了两个线程,每个线程都对全局变量 test.total 执行加一的操作,每个线程执行5000次,那么两个线程就执行了一万次。可是为什么每一次的结果都不相同呢?
这是因为,做一次加法,实际上,包含了很多条机器指令。一条高级语言的语句,例如Java,C++等语言,会被翻译成多条机器指令来执行。机器指令是CPU真正看懂的指令。把高级语言翻译成机器语言的工作是由编译器完成的。
在Java中执行一次加一的操作,至少包含了以下几个步骤:
1. 将原来变量的值从内存读入到寄存器中
2. 在寄存器中执行加一操作
3. 把寄存器中的值写回到内存里去
当然,这是化简的情况,真实的情况比我这里写的要复杂得多。我们还是先抛去细节不讨论。这三个步骤就足够说明问题了。
假如,现在变量的值是10,线程1从内存中读到的值就是10,放入到寄存器rax里,这里CPU发生了线程间的切换。那么线程1会把当前的现场保存起来(rax里是10),然后切换到线程2,线程2也去内存中读取 total 的值,当然也是10,放入寄存器rax里,然后执行加一操作,rax里变为11,然后再把11写回到内存里,也就是说total已经变成了11,然后这时候,CPU又切换了线程,回到线程1,马上要做的事情就是恢复现场。刚才切换之前rax里的值是10,恢复完了以后还会是10,然后执行加一操作,变为11,再写回内存。这时就发生错误了。线程2的那次加一操作就被线程一给覆盖掉了。
上面的分析过程也是我们调试多线程编程的一种重要思路,就是随机推演一下CPU在什么时候切换,会带来什么样的问题。因为CPU在任何时候都是有可能切换的,所以有时候测试通过了,也未必意味着你的程序就是正确的,必须经得起这种理论的推敲才行。
后续的文章将会陆续介绍几种控制并发程序的方法。
- #19. 计数(容斥原理)
- 左手用R右手Python系列——多进程/线程数据抓取与网页请求
- #15. 钻石
- P1328 生活大爆炸版石头剪刀布
- ASP.NET MVC 5 Authentication Breakdown
- jquery easyui datagrid mvc server端分页排序筛选的实现
- 左手用R右手Python系列——使用多进程进行任务处理
- 2017.9.17校内noip模拟赛解题报告
- MySQL基础入门——MySQL与R语言、Python交互
- BizTalk Orchestration execute Flat file disassembler ReceivePipeline
- MySQL基础入门系列之——字符与日期数据处理
- P2038 无线网络发射器选址
- ggplot2双坐标轴的解决方案
- Modifying namespace in XML document programmatically
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- three.js 着色器材质之纹理
- three.js 制作属于自己的动态二维码
- three.js 制作机房(上)
- three.js 将图片马赛克化
- three.js 数学方法之Box3
- three.js 数学方法之Plane
- three.js 制作机房(下)
- three.js 着色器材质内置变量
- three.js 数学方法之Vector3
- three.js 数学方法之Matrix3
- three.js 对象绕任意轴旋转--模拟门转动
- three.js UV映射简述
- three.js 数学方法之Matrix4
- three.js 利用uv和ThreeBSP制作一个快递柜
- three.js 欧拉角和四元数