多线程内幕

本文是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在任何时候都是有可能切换的,所以有时候测试通过了,也未必意味着你的程序就是正确的,必须经得起这种理论的推敲才行。

后续的文章将会陆续介绍几种控制并发程序的方法。

原文发布于微信公众号 - HinusWeekly(gh_4b8b4eda4e40)

原文发表时间:2018-01-16

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏逻辑熊猫带你玩Python

Python | “一个简单的清单软件easybill”

已上传到Github : https://github.com/lizechen2018/easybill

3811
来自专栏xingoo, 一个梦想做发明家的程序员

Oozie分布式工作流——流控制

最近又开始捅咕上oozie了,所以回头还是翻译一下oozie的文档。文档里面最重要就属这一章了——工作流定义。 一提到工作流,首先想到的应该是工作流都支持...

21910
来自专栏玄魂工作室

Hacker基础之工具篇 APT2

所有模块结果都存储在本地主机上,并且是APT2知识库(Knowledge Base)的一部分

1213
来自专栏deed博客

day01笔记

1685
来自专栏精讲JAVA

怎样编写高质量的Java代码

代码质量概述 怎样辨别一个项目代码写得好还是坏?优秀的代码和腐化的代码区别在哪里?怎么让自己写的代码既漂亮又有生命力?接下来将对代码质量的问题进行一些粗略的介绍...

44210
来自专栏红色石头的机器学习之路

Jupyter notebook入门教程(下)

Jupyter notebook的入门教程第二部分的英文原文出处: Getting started with the Jupyter notebook (p...

2880
来自专栏互联网杂技

前端面试题整理

交互设计前端开发 前言: 现在前端面试主要考察以下几个方面: 初级的:html、css、js,jquery,开发工具git的使用,对其他框架稍微了解; 中级的:...

4769
来自专栏Python小屋

Python实验项目1例:使用进程池统计指定范围内素数的个数

(1)了解使用Python标准库multiprocessing编写多进程程序的方法。

743
来自专栏平凡文摘

怎样编写高质量的Java代码

1583
来自专栏服务端技术杂谈

如何在2016年成为一个更好的Node.js开发者

本文主要讨论一些进行Node.js开发的最佳实践和建议,这些建议不仅仅适合开发者,还适合那些管理与维护Node.js基础架构的工作人员。遵循本文提供的这些建议,...

3087

扫码关注云+社区

领取腾讯云代金券