前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >多线程内幕

多线程内幕

作者头像
海纳
发布2018-03-02 14:43:39
6480
发布2018-03-02 14:43:39
举报
文章被收录于专栏:海纳周报

本文是HinusWeekly第三期的第二篇文章,第三期的主题就是多线程编程。本文试图从单核CPU的角度讨论并发编程的困难。

函数调用的过程,就是不断地创建栈帧,销毁栈帧。实际上,多线程程序的执行只是这个模型的一种推广,也就是每一个线程都拥有自己独立的栈空间。

我们看一下这个程序:

代码语言:javascript
复制
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程序员,我们是不必关心的(我希望读者能够理解这些机制,但是我们的课程内容有限,不可能做到面面俱到,所以我把这些内容都安排到作业里去了,希望读者能认真完成课后习题)。我们只知道,多个线程在并发执行的时候,其运行结果是不确定的,依赖于操作系统的调度。

这个现象就说明了多线程编程为什么这么困难。线程之间,各个指令的执行顺序是不确定的。而写程序,最怕的就是不确定性。我们再看一个例子:

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

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2018-01-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 HinusWeekly 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档