多线程内幕

本文是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),作者:海纳

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

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

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

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Java线程模型

    大家新年好。2018年的第一期来得晚了一些。因为年底有很多工作要做,加班多了一些,公众号停更了两周。 今天借着知乎上一个关于线程模型的问题,我正好可以讲一下Ja...

    海纳
  • 用Atomic实现锁

    一直想写ReentrantLock,就得先介绍AbstractQueueSynchronizer,可是我觉得这样写,不过瘾,我把代码贴一遍,懂的人自己就能找到这...

    海纳
  • synchronized关键字的语义

    上一篇文章,我们讲到,如果发生了多个线程共同访问一个全局变量的时候,就会发生各种意料之外的情况。其实现实生活中有很多这样的例子。我举一个例子。 一群人都要过河,...

    海纳
  • Java线程(四):线程中断、线程让步、线程睡眠、线程合并

    最近在Review线程专栏,修改了诸多之前描述不够严谨的地方,凡是带有Review标记的文章都是修改过了。本篇文章是插进来的,因为原来没有写,现在...

    高爽
  • clb连接数不均

    负载均衡(Cloud Load Balancer,CLB)提供安全快捷的流量分发服务,访问流量经由 CLB 可以自动分配到云中的多台云服务器上,扩展系统的服务能...

    王帅-smaitwang
  • jstack分析多线程死锁,来吧老铁们

    死锁:死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。

    公众号 IT老哥
  • Jenkins 凭据使用

    原文链接:https://blog.spiritling.cn/posts/6b626a8a/

    SpiritLing
  • Vue-devtools安装

    最近用vue开发,需要看一下vuex的数据结构,原本也没什么,就安装了一下vue-devtools,这边重温一下安装的过程。

    wade
  • Apache CloudStack 4.5.2 新特性一览

         Apache CloudStack 4.5.2 新特性一览 ? CloudStack 4.5.2 相比前一个版本修复了大约 200 个Bug。   ...

    田春峰-JCJC错别字检测
  • 计算机视觉(CV)任务介绍:分类、检测、分割、超分、关键点识别、图像生成、度量学习

    CV(Computer Vision,计算机视觉)是AI的两大重要应用之一(另一个是NLP,Natural Language Processing),具体有哪些...

    用户7164815

扫码关注云+社区

领取腾讯云代金券