多线程和虚拟机的宏观理解

作者:贺拔达奚
链接:https://www.zhihu.com/question/59725713/answer/168709945
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

###
多线程和虚拟机。实际工作中,大部分程序员可能几乎不用,但这两项技能是你面试所谓高级工程师的敲门砖,也是你在机会到来的时候能否顶上去的弹药库。很多人,把这两部看的太高深,望而却步,我觉得一个重要原因就是大部分博客和书籍写的太差,只讲结果不谈背景。比如,讲到虚拟机,上来就以hotspot为例,内存模型,各种分区、回收算法;讲到多线程,上来就各种synchronized关键字、各种锁、线程池怎么用。新手看到就蒙了。要知道,一切技术的出现都是有背景的。所有技术的出现都是基于计算机原理和体系结构的。为了解决特定问题,人们基于计算机理解的语言才创造了各种解决问题的方法,也就是说这些解决方案不过是践行某种思想的一种体现罢了。

先说虚拟机,我们都知道Java程序运行在虚拟机上,虚拟机又和操作系统打交道,最终通过二进制指令操纵电子电路运行。完成数据的读取,存储,运算和输出。
虚拟机在加载.class文件的时候,会在内存开辟一块区域“方法区”,专门用来存储类的基本信息,同时在“堆”区为这些类生成一个Class对象,作为类的“镜像”或“模具”,为反射提供基础。程序运行过程中,对象不断的生成和死亡,有的朝生暮死(大多数对象都这样,最常见的是方法内部生成的临时对象),有的壮年而亡,有的长命百岁,有的长生不死除非世界毁灭(虚拟机关闭,典型的如servlet)。对象生要吃喝,死了得埋,所以虚拟机就不停的申请内存、回收内存。对象的生成方法很多,new、反射等,对象回收的方法也有很多,这就是GC,标记-清除、复制、标记-整理等等。

垃圾回收,顾名思义,得确定垃圾是什么、在那里、如何回收。对象的生命周期不同,回收的方法不一样。假如让你设计垃圾回收,你该怎么做?大多数人都会想到,后台启动一个线程,隔一段时间(或达到某种状态,去堆用掉了80%),扫描垃圾对象,然后清除,然后继续执行原来的程序(串行收集器)。恭喜你,你也可以设计虚拟机了。但不幸的是,情况往往比你想象的复杂。效率、安全性、对原程序的影响,都是你要考虑的。人们最先发现,对象生命周期不同,用同一种GC方法,实在是效率差,怎么办?就如hotspot的方案,堆区根据对象生命周期不同,分成了Eden、Survivor0、Survivor1和Old区。每个区采用了不同的清理算法。多核的出现,自然人们会想到并行收集器,即多个回收线程一起跑;为了将对原程序影响降到最低(STW),又出现了并发收集器。这些,本质上,就是抽象分层思想的体现。类似于,重构代码中的,抽离属性和抽离方法。这种思想,我认为是计算机最重要的思想。可以讲三天三夜。如分布式服务中,根据业务模型,分拆用户服务、商品服务、订单服务。

到此为止,虚拟机优化就涉及到两大方面,各个区的大小怎么划分最优、垃圾回收算法怎么选择最优。直接点,就是JVM参数调整。但关键在于,给你一个系统(可能是一个陌生的系统,我说的陌生可能就是你开放的系统,只是每个人负责的只是一个模块,对系统整体不熟悉),你怎么样能恰当估算系统业务情况,进而有针对性的收集系统数据,根据场景,确定优化的方向点,然后找到这个点对应的虚拟机参数,调整参数,或者,优化代码。注意,一切优化必须基于业务模型。不同业务系统、甚至同一套系统不同用户基数调整的方向都不一样。平时,我遇到的情况大概分为两种,一种是堆的问题,比如代码问题导致List或map越来越大,或者是string使用不当,造成频繁old gc;某个外部组件调用,生成大量代理类无法销毁。还有一种是线程栈,线程阻塞甚至死锁的问题。多线程使用不当,比不使用还坑爹。

多线程,任何一个程序员都知道,但实际工作中,大部分程序员每天面对的基本是业务问题的CRUD和Bug定位,貌似没有直接接触多线程的机会。

大家知道程序运行的时候,最关键的是内存和cpu,而cpu运算的时候,是要从内存取值,当然很多时候是从缓存取值的,然后放入寄存器,参与运算,得到结果,先放入寄存器,然后放入内存。程序执行的指令也放在寄存器,它记录了当前程序执行的地址。用一句话概括:程序=数据结构+算法。CPU运算需要知道,我要执行什么程序、我的程序数据怎么获取。

大家应该看出问题来了吧?首先,线程执行是语言指令寄存器的,也就是当你切换线程的时候,得从虚拟机的程序计数器(PC)把该线程的执行指令放到指令寄存器,当然线程涉及的其他资源也要切换,比如IO设备。这些都是需要耗费资源的,这就是所谓的线程上下文切换。大学时候,记得很清楚的一句话:线程是CPU执行的最小单位。当时没怎么理解,后来想CPU执行程序,总得知道执行什么吧,那得准备指令寄存器的值,原材料得有吧,就可能涉及文件系统、网络资源吧,运算结果得输出到内存、文件或者网络吧。这些都是资源啊。所以,线程创建是一笔很大的开销。当然,如果你就一个线程,那就无所谓了,反正资源都是我的,想怎么用就怎么用。所以,很多时候,单线程比多线程快。

很多面试宝典,有这么一道题:Java线程的start和run方法有什么区别?通过我上面关于线程执行的分析,应该一目了然。我用一个做饭的例子说明,start需要你买菜、准备锅碗瓢盆油盐酱醋、洗菜切肉,而run则是往锅里放油放菜炒。大家可以看到,Thread源码的start0是个native方法,也就是资源准备是虚拟机帮你做了。你不用管我菜是怎么买的、价钱多少。当然了,如果菜市场很远,一直没买到,或者排队很长,甚至被别人插队,那你这顿饭就一直做不上。这就是所谓的线程阻塞了。如果两个厨师都在做饭,一个拿着酱油想要醋,一个拿着醋想要酱油,互不相让,就出现所谓的死锁。不好意思,扯远了。关于start和run,如果把方法名改为:applyResourceAndPerformAction和doConcreteActions,是不是很容易理解?很多人面试的时候,背一下宝典,原理根本不清楚。你能指望他处理复杂问题?线程必须的资源虚拟机帮你做了,你需要的就是告诉线程你具体做什么,所以实现线程的几种方式就有了,1、继承Thread目的重写run方法;2、实现Runnable接口,实现run方法;3实现Callable接口,回调获取线程结果。1使用了继承,2和3使用了组合,内部持有了你所实现的类,更加灵活。你看,多用组合少用继承的原则就这么体现了。

第二点,上面说到了,一个数值,进入CPU运算,经过了内存、多级缓存、寄存器,也就是说,当多线程运算同一个值的时候,是需要把值从主内存拿到该线程工作内存(寄存器)中的,当一个线程计算完毕(CPU首先把运算结果放到寄存器),还没刷新到主内存的时候,另一个线程从主内存取到的是旧值。JVM运行的每个线程都有自己的线程栈,不同线程运行的时候,都要复制主内存的一份副本到工作内存。怎么保证每个线程拿到的数据是最新的,这就是同步机制。volatile和synchronized,就是为了解决这个问题的。

首先,谁都能想到的最直接的办法就是:共享变量同一时刻只允许有一个线程操作。这样就保证了所有线程要么拿不到值,要么拿到的值是“纯粹”的。于是有了synchronized,用来告诉虚拟机:这个地方是圣地,不允许多个人同时涉足。这里有一把锁,必须拿到锁才能进入,其他人要想进来必须等待。Java中的锁,可以是this对象、方法、类,也可以是声明的某个变量。锁的范围,可以是小块代码段,可以是整个方法区,甚至是所有方法。一定要注意锁和锁的范围,这是两个维度的事情。虚拟机会在锁对象和线程之间建立联系,其他线程跑到锁对象的时候,会看到:哦,其他哥们已经来了,我先等着吧。特别注意,不要以为对象和类的定义一样,不过是属性和方法的集合,类和对象是两回事。类似模具和产品的关系。虚拟机生成一个对象,这个对象有很多额外信息,起码有对象内存地址你是知道的吧?所以,要标识这个对象当前被哪个线程占有,是一件很容易的事情。感兴趣的同学,可以去看看对象在内存中的布局。

我们很快发现,上面的方法有点粗暴,也不够灵活。很多时候,我们不关心共享值在被谁操作,我只关当前这个值“到底”是什么。所以,就有了volatile,大部分博客提到volatile,就一句话:保证可见性,不保证原子性。这什么鬼?实际上,如果一个共享变量声明为volatile,等于告诉虚拟机控制的所有线程:这个变量有点帅,要请他出山必须亲自去他老家——主内存去请,回来的时候也要尽快送回老家。所以,CPU计算的时候要从主内存取值,计算完毕,直接就写入主内存,不会写到高速缓存了。这就是所谓的“可见性”,也就是当前这个值是什么,你是完全知道的。至于不保证原子性,就很明显了,这个值谁都可以取来运算,从计算机角度来讲,跟普通变量的区别就在于:效率差了。因为写入和读取高速缓存,效率远远高于内存。一路题外话,不要以为数据库插入数据就直接到磁盘了,其实写入的也是缓存,由后台线程刷到磁盘的。这样既可以起到缓冲的作用,又可以提高效率。不然你以为怎么能那么快。其实,从底层到高层,从硬件到软件,很多原理都是相通的。

————————————
感谢朋友们的认可和指正。本文是有感而发,因为看过了太多坑人的博客和书籍,感慨自己走过的弯路,不希望其他初学者被网上互相抄袭的博客和东拼西凑的书籍浪费时间,想以一个相对宏观的视野来描述一个概念,力求通俗易懂,所以没有深入太多细节,简化了很多模型,给部分朋友造成了疑惑,说声抱歉。也没有配图,都是抽时间手机码字,打个分割线都费劲,图呢,其实网上都有。

记得我在另外一篇答案中提到,计算机程序(不仅仅各种语言的代码,一切能向计算机发出指令的序列都是程序,当然包括Java虚拟机)的努力方向:最大化利用计算机资源。多线程就是如此,一个CPU密集型的任务在跑,你让IO干等着,这不是浪费吗?所以,这时候你启动一个IO密集型的任务,资源利用率就提升了。当然,这是一种简化模型,实际上一个人任务的不同阶段,需要的计算机资源是不同的,如果你能合理安排多个任务的执行逻辑,资源利用率就会很大提升。

我们学习程序语言,一定不要被束缚到语言细节和规范上去,而要从计算机逻辑执行层面思考问题。因为细节和规范都是人为设定的,是大牛抽象计算机逻辑后的加工品,你囿于此,其实是在理解别人的思想,而不是理解计算机。我们常说的高层依赖于抽象而不依赖于底层,是一样的意思。说了这么多,想表达的就是,对技术问题,要有思考的深度,要寻根溯源,要高屋建瓴。

回到多线程。上面提到synchronized,必须多说几句,这对理解锁的本质至关重要。多线程和锁,首先请大家记住一个场景:多人上厕所。

多线程和锁,一个是线程,一个是对象。一个在私有的线程栈中,一个在共享的堆中。如何标识某个线程持有某个锁对象?如何如何标志某个对象被某个线程锁定?很显然,线程栈中开启一片区域“栈帧”存储对象锁记录,堆中对象有对象头(对象头主要保存了对象的类元数据,以及对象的运行时状态,其中就包括了锁线程和GC分代等信息。)可以标识被哪个线程锁定。实际上,虚拟机就是利用对象头和monitor(后面讲)来实现锁的。

回到多人上厕所,人比做线程,厕所比做共享对象,锁比做对象头,monitor比做钥匙。

synchronized锁的是一个对象,或者是类的某个实例,或者是类本身(即常量池的Class)。 synchronized内部原理是通过对象内部的一个叫做监视器(monitor)来实现的。本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,这就是为什么synchronized效率低的原因。比如Hashtable(再次吐槽小写t,浑身难受)和用Collections.synchronizedMap装饰的HashMap,内部都使用了 synchronized,所以性能差,不是因为“它性能差”,而是因为“它使用的同步方式”性能差,那天人家底层重写了性能高了你怎么办?很多时候,点下鼠标进入源码看几眼就知道的东西,没必要死记硬背。

synchronized这种依赖于操作系统所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。别被这些名词砸晕了,这些锁的名字很有误导性,其实是对获取锁的方式的优化,不是锁。

所谓锁的优化,主要方向是优化获取锁的方式和加锁(释放)的方式。我不想一一解释枯燥名词。还是用上厕所举例。重量级锁可以认为是,你去上厕所,得先去管理处(人或者机器)登记并拿到钥匙上厕所,这个过程可以认为存在一次“用户态”到“内核态”的切换。是非常重量级的。

这里我必须强调一下,你的目标是上厕所,不是加锁,加锁只是为了你更好的上厕所。线程也一样,目的是为了完成某项任务。加锁是不得以为之的。

假如一层楼就你一个人,一个厕所, 你觉得还有必要去登记吗?要什么自行车?直接上啊。这就是无锁状态;如果这层楼还有一个哥们,但他尿泡比较强悍,一天不上厕所。厕所门上有个显示器,能显示上次上厕所的是谁、期间有没有其他人上厕所,那你上的时候,只要看下显示器就知道:没别人上过,还是我,照片都没变,不用刷脸,此厕可直接上。这就是偏向锁,因为“偏向你”;假如这个哥们偶尔也上一次,这次你发现厕所有别人上过,因为显示器上有他照片,那你就得重新刷脸,好吧,那我再刷了上吧,大部分时候,里面都没这哥们,你可顺利上厕所,这叫轻量级锁;如果某天这哥们腹泻(我一同事吃湖南蒸菜有过一次),那你悲剧了,你每次上的时候,不仅显示器不是你,你想刷脸进入,发展里面还有人。没办法,只能去管理处登记等待了,变成了重量级锁。锁升级是不会降级的。这里,重量级锁涉及操作系统的处理,而偏向锁和轻量级锁涉及CAS,硬件可以搞定,效率更高。

上述锁状态转移和加锁(解锁不讲了)是由虚拟机(配合操作系统)完成的,我们不可见,既然是虚拟机控制,当然就有相关参数,如是否启用偏向锁,我忘了参数名字,但我知道肯定有这样的参数。如果面试我的面试官因为我不知道参数名字鄙视我,我能反怼死他。记个别人定的名字很自豪?

上面讲到重量级锁的时候,其实就是锁竞争很激烈的时候。比如早上高峰期,厕所坑位紧缺,排队的人很多,如果你一直等,等待的状态就叫“自旋”,当然你可以自旋十分钟左右后离开(虚拟机自旋也有参数控制),因为你觉得里面的哥们玩手机不知道啥时候结束,你有更重要的事情要干,还不如去外面登记等通知。显然,自旋的前提是你知道上一个哥们不会很久。多次之后,你会摸清这些人上厕所的时间后,你自旋起来就更有针对性了,这叫“适应性自旋”。

还有,锁消除,锁粗化,比如基本没人用的StringBuffer、Vector,你用在某个方法中,其实根本没必要加锁,或者说比如连续的append,没必要每次都加锁,虚拟机就会进行锁消除或者锁粗化处理。

上面讲了这么多,主体是线程和锁对象,核心是获取锁的方式和锁定的方式,还有,不加锁或者“伪加锁”是不是能搞定?再次强调一遍,线程生来是为了完成任务的,不是为了和锁纠缠的。

多线程竞争锁的时候,肯定涉及到线程的排队,新来的线程怎么处理,是去竞争锁还是直接排队?排队中的线程,那些有资格竞争锁?有资格的线程,那个拿到锁(只是拿到锁,还未执行共享区)?不管怎么实现,这些东西是必须要考虑的。你在synchronized没见到,是因为虚拟机帮你处理了,涉及的队列也是虚拟机在维护。重量级锁的时候,又涉及和操作系统信号的交互。当然,要是你不用和操作系统进行如Mutex Lock这样“重量级的”交互也能更好、更快、更好的处理同步,那你就是大牛了。

大牛当然是存在,比如李老头。下面会开始讲更加灵活的、细粒度、可定制的Lock锁。可以认为是把synchronized加锁的过程、锁定的方式等流程中细节拆分出来,用灵巧的实现方式实现线程同步。再后面会讲对象的wait、notify,线程的sleep,主体不一样,思考的角度不一样

posted @ 2018-02-27 15:17  Rainyn  阅读(316)  评论(0编辑  收藏  举报