多线程和虚拟机?

时间:2022-12-26 17:20:25
作者:贺拔达奚
链接: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计算的时候要从主内存取值,计算完毕,直接就写入主内存,不会写到高速缓存了。这就是所谓的“可见性”,也就是当前这个值是什么,你是完全知道的。至于不保证原子性,就很明显了,这个值谁都可以取来运算,从计算机角度来讲,跟普通变量的区别就在于:效率差了。因为写入和读取高速缓存,效率远远高于内存。一路题外话,不要以为数据库插入数据就直接到磁盘了,其实写入的也是缓存,由后台线程刷到磁盘的。这样既可以起到缓冲的作用,又可以提高效率。不然你以为怎么能那么快。其实,从底层到高层,从硬件到软件,很多原理都是相通的。